Support DeepSeek Harness (dsh) connector and expand to 5 Agents (#42)

## Summary

Full implementation of the DeepSeek Harness (dsh) connector plan (tasks
T1–T8):

- **T1**: 5-value `AgentSource` expansion across Rust, Swift, and
schemas with SQLite schema v7 migration
- **T2**: `parse_dsh` event normalization (15 mappings, 6 turn/end
kinds, reasoning `block-start` thinking boundary, fail-open unknown-kind
handling)
- **T3**: dsh connector management lifecycle (check, repair, refresh,
uninstall targeting the web profile's `cordis.patch.yml`)
- **T4**: Official dsh Cordis plugin template with single-flight queue,
broadcast-only subscriptions, child session suppression, and background
fence
- **T5**: App-side `RuntimeReleaseManifest.swift` expansion
- **T6**: End-to-end integration tests in `integration_d` covering all 9
contract fixtures
- **T7**: Durable contract and architecture documentation in
`docs/integrations/agent-connectors.md` and
`docs/architecture/runtime-and-ipc.md`
- **T8**: Product scope expansion to five Agents across `AGENTS.md`,
`README.md`, `README.zh-CN.md`, and architecture documents

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: gd-ops <gd-ops@xmfunny.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-08-18 14:12:34 +00:00
1 parent 899d17b0ed
commit f31e9580b5
55 files changed
+1777 -54

No files matched your search

+2 -1
View File
@@ -13,7 +13,7 @@ Use sources in this order when changing behavior or architecture:
The repository keeps durable current-state documentation, not completed design proposals, task plans, progress ledgers, dated audits, or implementation diaries. Fresh command output, CI artifacts, commits, issues, and release notes are the evidence for a particular task, commit, or build. If current-state prose disagrees with code, schemas, or tests, investigate the implementation and update the owning durable document instead of recording the discrepancy as status.
The V1 scope is intentionally narrow. Do not add public galleries, sharing/community features, Petdex import, Codex built-in pet asset export, Windows UI, cloud accounts, or a full agent mission-control platform unless the user explicitly changes scope.
The V1 scope is intentionally narrow. Do not add public galleries, sharing/community features, Petdex import, Codex built-in pet asset export, Windows UI, cloud accounts, or a full agent mission-control platform unless the user explicitly changes scope. DeepSeek Harness (dsh) was explicitly authorized and included in scope by the user on 2026-08-18.
## Architecture And Data
@@ -34,6 +34,7 @@ plugins/codex/
plugins/claude-code/
plugins/pi/
plugins/opencode/
plugins/dsh/
skills/agent-pet-studio/
skills/agent-pet-maker/
schemas/
+1 -1
View File
@@ -20,7 +20,7 @@ Agent Pet Companion is a native macOS desktop pet for people who work with one o
- **Switch windows less often** — see Agent activity on your desktop without repeatedly opening a terminal or Agent app.
- **Notice tasks that need you** — get a clear visual response when an Agent needs input, finishes, or fails.
- **Follow several Agents together** — bring activity from Codex, Claude Code, Pi Coding Agent, and OpenCode into one place.
- **Follow several Agents together** — bring activity from Codex, Claude Code, Pi Coding Agent, OpenCode, and DeepSeek Harness into one place.
- **Get back to work quickly** — when routing is available, open the relevant session, Agent app, or terminal from the bubble.
- **Make the pet yours** — use one of three bundled pets, import a `.petpack`, or create and edit your own pet with AI Pet Maker.
- **Keep data centered on your Mac** — pets, settings, limited session summaries, and diagnostics remain local. The App does not read Agent credentials, tokens, cookies, or API keys.
+1 -1
View File
@@ -20,7 +20,7 @@ Agent Pet Companion 是一款 macOS 原生桌宠 App,适合同时使用一个
- **少一些来回切换**:在桌面上直接看到 Agent 的工作状态,不必反复打开终端或 Agent App。
- **不错过需要处理的任务**:等待输入、执行完成或失败时,桌宠会给出清晰反馈。
- **同时关注多个 Agent**:统一查看 Codex、Claude Code、Pi Coding Agent 和 OpenCode 的任务动态。
- **同时关注多个 Agent**:统一查看 Codex、Claude Code、Pi Coding Agent、OpenCode 与 DeepSeek Harness 的任务动态。
- **快速回到工作现场**:路由可用时,从气泡打开对应会话、Agent App 或终端。
- **拥有自己的桌宠**:使用三个内置宠物,导入 `.petpack`,或通过 AI 宠物制作创建和修改专属宠物。
- **数据以本机为中心**:宠物、设置、有限的会话摘要和诊断信息保存在 Mac 上;App 不读取 Agent 的凭据、Token、Cookie 或 API Key。
@@ -58,6 +58,11 @@ enum AgentIconCandidates {
.resource("/Applications/OpenCode.app/Contents/Resources/icon.icns"),
.resource("/Applications/opencode.app/Contents/Resources/icon.icns")
] + executableCandidates(named: "opencode")
case .dsh:
candidates = discoveredAppPaths.map(AgentIconCandidate.appBundle) + [
.appBundle("/Applications/DeepSeek.app"),
.appBundle("/Applications/DeepSeek Harness.app")
] + executableCandidates(named: "dsh")
}
var seen = Set<String>()
return candidates.filter { seen.insert("\($0.kind):\($0.path)").inserted }
@@ -101,6 +106,8 @@ enum AgentIconProvider {
identifiers = []
case .opencode:
identifiers = ["ai.opencode.desktop", "com.opencode.desktop"]
case .dsh:
identifiers = []
}
return identifiers.compactMap { NSWorkspace.shared.urlForApplication(withBundleIdentifier: $0)?.path }
}
@@ -157,6 +164,7 @@ struct AgentIconView: View {
case .claudeCode: "Cl"
case .pi: "π"
case .opencode: "O"
case .dsh: "DS"
case .none: "A"
}
}
@@ -167,6 +175,7 @@ struct AgentIconView: View {
case .claudeCode: Color(red: 0.78, green: 0.39, blue: 0.25)
case .pi: Color(red: 0.04, green: 0.04, blue: 0.05)
case .opencode: Color(red: 0.12, green: 0.12, blue: 0.14)
case .dsh: Color(red: 0.30, green: 0.42, blue: 1.00)
case .none: Color.secondary
}
}
@@ -188,7 +188,8 @@ enum AgentSessionDeepLink {
// Claude Desktop's `claude://resume` imports a CLI transcript as a new
// Desktop session rather than locating the existing one, so there is no
// truthful exact-session link to build for Claude Code.
case .claudeCode, .pi, .opencode:
// dsh V1 has no App surface and no session deep link, like Pi.
case .claudeCode, .pi, .opencode, .dsh:
return nil
}
}
@@ -8,16 +8,18 @@ struct RuntimeConnectorContracts: Codable, Equatable, Sendable {
let claudeCode: String
let pi: String
let opencode: String
let dsh: String
enum CodingKeys: String, CodingKey, CaseIterable {
case codex
case claudeCode = "claude_code"
case pi
case opencode
case dsh
}
var allArePresent: Bool {
[codex, claudeCode, pi, opencode].allSatisfy {
[codex, claudeCode, pi, opencode, dsh].allSatisfy {
!$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
@@ -204,7 +206,8 @@ struct RuntimeReleaseManifest: Codable, Equatable, Sendable {
codex: "codex-hooks-2026-07-17-schema-v6",
claudeCode: "claude-hooks-2026-07-17-activity-v5",
pi: "pi-extension-0.80.10-activity-v7",
opencode: "opencode-v1.18.0-activity-v8"
opencode: "opencode-v1.18.0-activity-v8",
dsh: "dsh-v0.1.0-rc.6-events-v1"
)
private static let publishedV1ReleaseIdentities = [
@@ -5,6 +5,7 @@ public enum AgentSource: String, CaseIterable, Identifiable, Codable, Sendable {
case claudeCode = "claude_code"
case pi
case opencode
case dsh
public var id: String { rawValue }
@@ -14,6 +15,7 @@ public enum AgentSource: String, CaseIterable, Identifiable, Codable, Sendable {
case .claudeCode: "Claude Code"
case .pi: "Pi Coding Agent"
case .opencode: "OpenCode"
case .dsh: "DeepSeek Harness"
}
}
@@ -23,6 +25,7 @@ public enum AgentSource: String, CaseIterable, Identifiable, Codable, Sendable {
case .claudeCode: "Claude"
case .pi: "Pi"
case .opencode: "OpenCode"
case .dsh: "dsh"
}
}
}
@@ -731,7 +731,8 @@ struct InitialAppearanceBootstrapTests {
codex: "codex-hooks.v1",
claudeCode: "claude-hooks.v1",
pi: "pi-extension.v1",
opencode: "opencode-plugin.v1"
opencode: "opencode-plugin.v1",
dsh: "dsh-plugin.v1"
)
)
}
@@ -2023,7 +2023,8 @@ struct PetCoreProcessManagerTests {
codex: codexContract,
claudeCode: "claude-hooks.v1",
pi: "pi-extension.v1",
opencode: "opencode-plugin.v1"
opencode: "opencode-plugin.v1",
dsh: "dsh-plugin.v1"
)
)
}
@@ -2062,7 +2063,8 @@ struct PetCoreProcessManagerTests {
codex: "codex-hooks-2026-07-17-schema-v6",
claudeCode: "claude-hooks-2026-07-17-activity-v5",
pi: "pi-extension-0.80.10-activity-v7",
opencode: "opencode-v1.18.0-activity-v8"
opencode: "opencode-v1.18.0-activity-v8",
dsh: "dsh-v0.1.0-rc.6-events-v1"
)
)
}
@@ -794,7 +794,8 @@ struct ProductConvergenceTests {
codex: "codex-hooks.v1",
claudeCode: "claude-hooks.v1",
pi: "pi-extension.v1",
opencode: "opencode-plugin.v1"
opencode: "opencode-plugin.v1",
dsh: "dsh-plugin.v1"
)
)
}
@@ -0,0 +1,7 @@
{
"schema_version": "apc.changelog-fragment.v1",
"kind": "Added",
"scope": "connections",
"summary_en": "Extend the Agent source vocabulary to include DeepSeek Harness (dsh) with conservative no-op behavior while connector management lands.",
"summary_zh": "Agent 来源词汇扩展纳入 DeepSeek Harness(dsh),在连接器管理落地前保持保守无行为扩展。"
}
+7
View File
@@ -0,0 +1,7 @@
{
"schema_version": "apc.changelog-fragment.v1",
"kind": "Added",
"scope": "connections",
"summary_en": "Add DeepSeek Harness (dsh) hook event normalization covering all seven semantic states, reasoning boundaries, and background fence.",
"summary_zh": "新增 DeepSeek Harness(dsh)Hook 事件归一化,覆盖全部七种语义状态、reasoning 边界与后台工作栅栏。"
}
@@ -0,0 +1,7 @@
{
"schema_version": "apc.changelog-fragment.v1",
"kind": "Added",
"scope": "connections",
"summary_en": "Add DeepSeek Harness (dsh) connector managed lifecycle: check, repair, refresh, and uninstall targeting the web profile cordis.patch.yml.",
"summary_zh": "新增 DeepSeek Harness(dsh)连接器受管生命周期:面向 web profile cordis.patch.yml 的 check、repair、refresh 与 uninstall 操作。"
}
@@ -0,0 +1,7 @@
{
"schema_version": "apc.changelog-fragment.v1",
"kind": "Added",
"scope": "connections",
"summary_en": "Add DeepSeek Harness (dsh) Cordis plugin template implementing single-flight delivery, reasoning block-start thinking, child session suppression, and background fence.",
"summary_zh": "新增 DeepSeek Harness(dsh)Cordis 插件模板,实现单飞队列交付、reasoning block-start 思考边界、子会话抑制与后台工作栅栏。"
}
@@ -0,0 +1,7 @@
{
"schema_version": "apc.changelog-fragment.v1",
"kind": "Added",
"scope": "connections",
"summary_en": "Add DeepSeek Harness (dsh) to App-side runtime manifest and Agent Connections UI surfaces.",
"summary_zh": "在 App 端运行时清单与 Agent 连接设置页面中接入 DeepSeek Harness(dsh)。"
}
+7
View File
@@ -0,0 +1,7 @@
{
"schema_version": "apc.changelog-fragment.v1",
"kind": "Added",
"scope": "connections",
"summary_en": "Add end-to-end integration tests for DeepSeek Harness (dsh) verifying contract fixtures, bounds, and privacy enforcement.",
"summary_zh": "新增 DeepSeek Harness(dsh)端到端集成测试,验证契约测试用例、字段有界性与隐私约束。"
}
+7
View File
@@ -0,0 +1,7 @@
{
"schema_version": "apc.changelog-fragment.v1",
"kind": "Added",
"scope": "documentation",
"summary_en": "Incorporate DeepSeek Harness (dsh) connector contract, event mapping, privacy boundaries, and managed patch architecture into durable documentation.",
"summary_zh": "将 DeepSeek Harness(dsh)连接器契约、事件映射、隐私边界与受管 patch 架构并入正式技术文档。"
}
+7
View File
@@ -0,0 +1,7 @@
{
"schema_version": "apc.changelog-fragment.v1",
"kind": "Added",
"scope": "documentation",
"summary_en": "Expand repository product scope from four to five supported Agents, incorporating DeepSeek Harness across AGENTS.md, README, and architecture documentation.",
"summary_zh": "扩展产品范围支持五款 Agent,在 AGENTS.md、中英文 README 与架构文档中同步纳入 DeepSeek Harness。"
}
+5 -3
View File
@@ -1804,9 +1804,11 @@ fn runtime_agent_app_surface(
{
Some("opencode_app")
}
AgentSource::Codex | AgentSource::ClaudeCode | AgentSource::Pi | AgentSource::Opencode => {
None
}
AgentSource::Codex
| AgentSource::ClaudeCode
| AgentSource::Pi
| AgentSource::Opencode
| AgentSource::Dsh => None,
}
}
+2
View File
@@ -8,6 +8,7 @@ pub enum AgentSource {
ClaudeCode,
Pi,
Opencode,
Dsh,
}
impl AgentSource {
@@ -17,6 +18,7 @@ impl AgentSource {
Self::ClaudeCode => "Claude Code",
Self::Pi => "Pi Coding Agent",
Self::Opencode => "OpenCode",
Self::Dsh => "DeepSeek Harness",
}
}
}
+425
View File
@@ -16,6 +16,7 @@ pub const CODEX_HOOKS_CONTRACT_VERSION: &str = "codex-hooks-2026-08-01-events-v9
pub const CLAUDE_HOOKS_CONTRACT_VERSION: &str = "claude-hooks-2026-08-01-events-v9";
pub const PI_EXTENSION_CONTRACT_VERSION: &str = "pi-extension-0.80.10-events-v15";
pub const OPENCODE_CONTRACT_VERSION: &str = "opencode-v1.18.4-events-v16";
pub const DSH_PLUGIN_CONTRACT_VERSION: &str = "dsh-v0.1.0-rc.6-events-v1";
const MAX_MESSAGE_BYTES: usize = 4_096;
const MAX_IDENTITY_BYTES: usize = 256;
@@ -89,6 +90,7 @@ pub fn parse_contract_event(source: AgentSource, input: &Value) -> Result<Option
AgentSource::ClaudeCode => parse_claude(source, input),
AgentSource::Pi => parse_pi(source, input),
AgentSource::Opencode => parse_opencode(source, input),
AgentSource::Dsh => parse_dsh(source, input),
}
}
@@ -270,6 +272,150 @@ fn child_session_contract(source: AgentSource, input: &Value) -> Option<Contract
Some(contract)
}
fn parse_dsh(source: AgentSource, input: &Value) -> Result<Option<ContractEvent>> {
let event = event_type(input)?;
let session_id = string_at(input, &[&["session_id"], &["sessionId"]]);
let tool_name = string_at(input, &[&["tool_name"], &["toolName"]]);
// Every dsh hook input is session-scoped; an unattributed event must
// never synthesize a global session.
if session_id.is_none() {
return Ok(None);
}
// turn/end carries a structured reason: {kind, error?{code}}.
let reason_kind = string_at(input, &[&["reason", "kind"], &["data", "reason", "kind"]]);
let (kind, outcome, session_active) = match event {
"turn/start" => (AgentEventType::Start, "started".to_string(), true),
"assistant/chunk" => {
// Only the explicit reasoning block boundary is a legal thinking
// edge; every delta, usage, finish, and non-reasoning block-start
// is ignored (fail-open).
let block_type = string_at(
input,
&[&["chunk", "blockType"], &["data", "chunk", "blockType"]],
);
if block_type.as_deref() != Some("reasoning") {
return Ok(None);
}
(
AgentEventType::Thinking,
"reasoning_started".to_string(),
true,
)
}
"assistant/message" => return Ok(None), // display-only; never a state event
"plan/mode" => {
if bool_at(input, &[&["active"], &["data", "active"]]) {
(AgentEventType::Plan, "plan_mode_entered".to_string(), true)
} else {
return Ok(None);
}
}
"todo/write" => (AgentEventType::Plan, "todo_updated".to_string(), true),
"tool/call" => (AgentEventType::Tool, "started".to_string(), true),
"tool/result" => {
let code = string_at(input, &[&["error", "code"], &["data", "error", "code"]]);
match code {
Some(code) => (AgentEventType::Tool, format!("tool_failure_{code}"), true),
None => (AgentEventType::Tool, "completed".to_string(), true),
}
}
"approval/asked" => (
AgentEventType::Waiting,
"approval_requested".to_string(),
true,
),
"approval/decided" => {
let decided = string_at(input, &[&["outcome"], &["data", "outcome"]])
.unwrap_or_default()
.to_string();
(
AgentEventType::Start,
format!("approval_decided_{decided}"),
true,
)
}
"turn/end" => match reason_kind.as_deref() {
Some("completed") if bool_at(input, &[&["background_active"]]) => {
(AgentEventType::Start, "background_active".to_string(), true)
}
Some("completed") => (AgentEventType::Done, "completed".to_string(), false),
Some("error") => {
let code = string_at(input, &[&["reason", "error", "code"]])
.or_else(|| string_at(input, &[&["data", "reason", "error", "code"]]))
.unwrap_or_else(|| "unknown".to_string());
(AgentEventType::Failed, format!("llm_failure_{code}"), false)
}
Some("blocked") => (AgentEventType::Waiting, "blocked".to_string(), true),
Some("aborted") => (AgentEventType::Done, "aborted".to_string(), false),
Some("max-tokens") => (AgentEventType::Start, "max_tokens".to_string(), true),
// `interrupted` (crash-tail marker) and every unknown kind are
// ignored: `failed` is terminal and sticky and must never be a
// fallback for vocabulary this build does not understand.
_ => return Ok(None),
},
"session/title" => (AgentEventType::Start, "metadata_updated".to_string(), false),
"session.child" => (AgentEventType::Start, "child_session".to_string(), false),
"connector.probe" => (AgentEventType::Start, "observed".to_string(), false),
_ => return Ok(None),
};
let message_role = match event {
"user/message" => Some("user".to_string()),
"assistant/message" => Some("assistant".to_string()),
_ => None,
};
let message_content = message_role.as_ref().and_then(|_| {
display_message_at(
input,
&[&["message_content"], &["properties", "message_content"]],
)
});
let activity_kind = match event {
"tool/call" | "tool/result" => Some(activity_kind_for_tool(tool_name.as_deref())),
"todo/write" => Some("plan".to_string()),
_ => None,
};
let affects_activity = !matches!(
event,
"connector.probe" | "session/title" | "session.child" | "assistant/message"
);
let mut contract = ContractEvent {
source,
external_event_id: None,
session_id,
kind,
tool_name,
outcome: Some(outcome),
source_event: event.to_string(),
contract_version: Some(DSH_PLUGIN_CONTRACT_VERSION.to_string()),
diagnostic: bool_at(input, &[&["diagnostic"], &["properties", "diagnostic"]]),
affects_activity,
session_active,
turn_id: bounded_string_at(input, &[&["turn_id"], &["turnID"]], MAX_IDENTITY_BYTES),
message_role,
message_content,
activity_kind,
activity_content: activity_content(event, input),
interaction_kind: if kind == AgentEventType::Waiting {
Some("approval_required".to_string())
} else {
None
},
project_label: project_label(input),
session_title: session_title(input),
session_open: Some(event != "turn/end" || session_active),
session_surface: None,
terminal_app: None,
session_open_url: None,
};
assign_opaque_invocation_event_id(&mut contract, input);
Ok(Some(contract))
}
fn parse_codex(source: AgentSource, input: &Value) -> Result<Option<ContractEvent>> {
let event = hook_name(input)?;
let (kind, outcome, session_active) = match event {
@@ -847,6 +993,7 @@ fn contract_version(source: AgentSource) -> &'static str {
AgentSource::ClaudeCode => CLAUDE_HOOKS_CONTRACT_VERSION,
AgentSource::Pi => PI_EXTENSION_CONTRACT_VERSION,
AgentSource::Opencode => OPENCODE_CONTRACT_VERSION,
AgentSource::Dsh => DSH_PLUGIN_CONTRACT_VERSION,
}
}
@@ -896,6 +1043,7 @@ fn source_identity(source: AgentSource) -> &'static str {
AgentSource::ClaudeCode => "claude_code",
AgentSource::Pi => "pi",
AgentSource::Opencode => "opencode",
AgentSource::Dsh => "dsh",
}
}
@@ -2611,3 +2759,280 @@ mod activity_normalization_tests {
}
}
}
#[cfg(test)]
mod dsh_adapter_tests {
use super::*;
use serde_json::json;
#[test]
fn parses_all_standard_event_types_per_plan_table() {
let parse = |input: &Value| {
parse_contract_event(AgentSource::Dsh, input)
.expect("parsed")
.expect("contract")
};
// 1. turn/start -> Start / started
let start = parse(&json!({"type": "turn/start", "session_id": "s1"}));
assert_eq!(start.kind, AgentEventType::Start);
assert_eq!(start.outcome.as_deref(), Some("started"));
assert!(start.session_active);
// 2. assistant/chunk (reasoning block-start) -> Thinking / reasoning_started
let reasoning = parse(&json!({
"type": "assistant/chunk",
"session_id": "s1",
"chunk": {"type": "block-start", "blockType": "reasoning"}
}));
assert_eq!(reasoning.kind, AgentEventType::Thinking);
assert_eq!(reasoning.outcome.as_deref(), Some("reasoning_started"));
assert!(reasoning.session_active);
// 3. plan/mode active: true -> Plan / plan_mode_entered
let plan_on = parse(&json!({"type": "plan/mode", "session_id": "s1", "active": true}));
assert_eq!(plan_on.kind, AgentEventType::Plan);
assert_eq!(plan_on.outcome.as_deref(), Some("plan_mode_entered"));
assert!(plan_on.session_active);
// 4. todo/write -> Plan / todo_updated
let todo = parse(&json!({"type": "todo/write", "session_id": "s1"}));
assert_eq!(todo.kind, AgentEventType::Plan);
assert_eq!(todo.outcome.as_deref(), Some("todo_updated"));
assert_eq!(todo.activity_kind.as_deref(), Some("plan"));
// 5. tool/call -> Tool / started with clean tool_name
let tool_call = parse(&json!({
"type": "tool/call",
"session_id": "s1",
"tool_name": "read"
}));
assert_eq!(tool_call.kind, AgentEventType::Tool);
assert_eq!(tool_call.outcome.as_deref(), Some("started"));
assert_eq!(tool_call.activity_kind.as_deref(), Some("file"));
// 6. tool/result (success) -> Tool / completed
let tool_ok = parse(&json!({
"type": "tool/result",
"session_id": "s1",
"tool_name": "bash"
}));
assert_eq!(tool_ok.kind, AgentEventType::Tool);
assert_eq!(tool_ok.outcome.as_deref(), Some("completed"));
// 7. tool/result (failure with closed code) -> Tool / tool_failure_<code>
let tool_err = parse(&json!({
"type": "tool/result",
"session_id": "s1",
"tool_name": "read",
"error": {"name": "FsError", "code": "ENOENT"}
}));
assert_eq!(tool_err.kind, AgentEventType::Tool);
assert_eq!(tool_err.outcome.as_deref(), Some("tool_failure_ENOENT"));
assert!(tool_err.session_active); // recoverable failure stays active
// 8. approval/asked -> Waiting / approval_requested
let ask = parse(&json!({"type": "approval/asked", "session_id": "s1"}));
assert_eq!(ask.kind, AgentEventType::Waiting);
assert_eq!(ask.outcome.as_deref(), Some("approval_requested"));
assert_eq!(ask.interaction_kind.as_deref(), Some("approval_required"));
// 9. approval/decided -> Start / approval_decided_<outcome>
let decided = parse(&json!({
"type": "approval/decided",
"session_id": "s1",
"outcome": "allowed-once"
}));
assert_eq!(decided.kind, AgentEventType::Start);
assert_eq!(
decided.outcome.as_deref(),
Some("approval_decided_allowed-once")
);
// 10. turn/end completed (fence empty) -> Done / completed / inactive
let done = parse(&json!({
"type": "turn/end",
"session_id": "s1",
"reason": {"kind": "completed"}
}));
assert_eq!(done.kind, AgentEventType::Done);
assert_eq!(done.outcome.as_deref(), Some("completed"));
assert!(!done.session_active);
// 11. turn/end completed (fence active) -> Start / background_active / active
let bg_active = parse(&json!({
"type": "turn/end",
"session_id": "s1",
"reason": {"kind": "completed"},
"background_active": true
}));
assert_eq!(bg_active.kind, AgentEventType::Start);
assert_eq!(bg_active.outcome.as_deref(), Some("background_active"));
assert!(bg_active.session_active);
// 12. turn/end error -> Failed / llm_failure_<code> (closed code, no message)
let failed = parse(&json!({
"type": "turn/end",
"session_id": "s1",
"reason": {"kind": "error", "error": {"code": "TRANSPORT", "message": "sensitive transport error"}}
}));
assert_eq!(failed.kind, AgentEventType::Failed);
assert_eq!(failed.outcome.as_deref(), Some("llm_failure_TRANSPORT"));
assert!(!failed.session_active);
// 13. turn/end blocked -> Waiting / blocked
let blocked = parse(&json!({
"type": "turn/end",
"session_id": "s1",
"reason": {"kind": "blocked"}
}));
assert_eq!(blocked.kind, AgentEventType::Waiting);
assert_eq!(blocked.outcome.as_deref(), Some("blocked"));
// 14. turn/end aborted -> Done / aborted
let aborted = parse(&json!({
"type": "turn/end",
"session_id": "s1",
"reason": {"kind": "aborted"}
}));
assert_eq!(aborted.kind, AgentEventType::Done);
assert_eq!(aborted.outcome.as_deref(), Some("aborted"));
// 15. turn/end max-tokens -> Start / max_tokens / active
let max_tokens = parse(&json!({
"type": "turn/end",
"session_id": "s1",
"reason": {"kind": "max-tokens"}
}));
assert_eq!(max_tokens.kind, AgentEventType::Start);
assert_eq!(max_tokens.outcome.as_deref(), Some("max_tokens"));
assert!(max_tokens.session_active);
}
#[test]
fn negative_fixtures_ignored_fail_open_without_synthesizing_failure() {
let parse = |input: &Value| parse_contract_event(AgentSource::Dsh, input).expect("parsed");
// Unknown turn/end.kind -> Ok(None) (never fallback to Failed)
assert!(parse(&json!({
"type": "turn/end",
"session_id": "s1",
"reason": {"kind": "future_novel_kind"}
}))
.is_none());
// plan/mode inactive -> Ok(None)
assert!(parse(&json!({
"type": "plan/mode",
"session_id": "s1",
"active": false
}))
.is_none());
// turn/end interrupted (crash-tail marker) -> Ok(None)
assert!(parse(&json!({
"type": "turn/end",
"session_id": "s1",
"reason": {"kind": "interrupted"}
}))
.is_none());
// assistant/chunk text-delta / reasoning-delta / tool-call-delta / non-reasoning block-start -> Ok(None)
assert!(parse(&json!({
"type": "assistant/chunk",
"session_id": "s1",
"chunk": {"type": "text-delta", "text": "token churn"}
}))
.is_none());
assert!(parse(&json!({
"type": "assistant/chunk",
"session_id": "s1",
"chunk": {"type": "reasoning-delta", "text": "churn"}
}))
.is_none());
assert!(parse(&json!({
"type": "assistant/chunk",
"session_id": "s1",
"chunk": {"type": "block-start", "blockType": "text"}
}))
.is_none());
// assistant/message -> Ok(None) (display-only, does not emit a pet state)
assert!(parse(&json!({
"type": "assistant/message",
"session_id": "s1"
}))
.is_none());
// Unattributed event -> Ok(None) (no synthetic global session)
assert!(parse(&json!({"type": "turn/start"})).is_none());
}
#[test]
fn privacy_fixtures_forbid_credential_and_detail_leakage() {
// tool/call with arguments -> arguments must NOT leak into outcome or activity_content
let tool_call = parse_contract_event(
AgentSource::Dsh,
&json!({
"type": "tool/call",
"session_id": "s1",
"tool_name": "bash",
"arguments": "command: cat ~/.dsh/.credentials.yaml",
"data": {"arguments": "secret: value"}
}),
)
.expect("parsed")
.expect("contract");
assert_eq!(tool_call.tool_name.as_deref(), Some("bash"));
assert_eq!(tool_call.activity_kind.as_deref(), Some("command"));
// Confirm no credential snippet is present in any field
assert!(!tool_call
.outcome
.as_deref()
.unwrap_or("")
.contains("credentials"));
assert!(!tool_call.source_event.contains("credentials"));
assert!(tool_call
.activity_content
.as_deref()
.is_none_or(|c| !c.contains("credentials")));
// LlmFailure.message must not enter outcome
let err_event = parse_contract_event(
AgentSource::Dsh,
&json!({
"type": "turn/end",
"session_id": "s1",
"reason": {
"kind": "error",
"error": {
"code": "TRANSPORT",
"message": "Authorization: Bearer secret_api_token"
}
}
}),
)
.expect("parsed")
.expect("contract");
assert_eq!(err_event.outcome.as_deref(), Some("llm_failure_TRANSPORT"));
assert!(!err_event.outcome.unwrap().contains("secret_api_token"));
}
#[test]
fn child_session_contract_recognizes_dsh_marker() {
let child = parse_contract_event(
AgentSource::Dsh,
&json!({
"type": "session.child",
"session_id": "child-session-123"
}),
)
.expect("parsed")
.expect("contract");
assert_eq!(child.source_event, "session.child");
assert_eq!(child.session_id.as_deref(), Some("child-session-123"));
assert!(!child.affects_activity);
assert!(!child.session_active);
}
}
+2
View File
@@ -935,6 +935,8 @@ fn source_app_surface(source: AgentSource) -> Option<&'static str> {
AgentSource::ClaudeCode => Some("claude_app"),
AgentSource::Opencode => Some("opencode_app"),
AgentSource::Pi => None,
// V1 dsh has no App surface; same as Pi.
AgentSource::Dsh => None,
}
}
@@ -0,0 +1,170 @@
//! DeepSeek Harness (dsh) connector adapter.
//!
//! Owns dsh-specific paths, Cordis patch file manipulation, plugin file
//! management, and health/verification checks.
use std::fs;
use std::path::{Path, PathBuf};
use super::*;
pub(crate) const DSH_PLUGIN_TEMPLATE: &str =
include_str!("../../../../../plugins/dsh/agent-pet-companion.js.tpl");
pub(crate) const DSH_AUDITED_EVENTS: &[&str] = &[
"session/event",
"session/disposed",
"subagent/start",
"subagent/end",
"agent/status",
];
pub(crate) const DSH_TASK_START_EVENTS: &[&str] = &["turn/start"];
pub(crate) const DSH_TASK_ACTIVITY_EVENTS: &[&str] = &["tool/call", "tool/result", "todo/write"];
pub(crate) const DSH_TASK_COMPLETION_EVENTS: &[&str] = &["turn/end"];
pub(super) const DSH_MANAGED_PLUGIN_FILENAME: &str = "agent-pet-companion.js";
pub(super) const DSH_MANAGED_PATCH_ENTRY_ID: &str = "agent-pet-companion";
pub(super) fn dsh_home() -> PathBuf {
non_empty_env_path("DSH_HOME").unwrap_or_else(|| user_home().join(".dsh"))
}
pub(super) fn dsh_default_profile_dir() -> PathBuf {
dsh_home().join("profiles").join("web")
}
pub(super) fn dsh_patch_file_path() -> PathBuf {
dsh_default_profile_dir().join("cordis.patch.yml")
}
pub(super) fn dsh_managed_root_state(root: &Path) -> ManagedPathState {
managed_directory_state(root)
}
pub(super) fn render_dsh_plugin(template: &str, connector_cli: &Path) -> String {
template
.replace(
"__APC_CLI_JSON__",
&serde_json::to_string(&connector_cli.display().to_string()).unwrap_or_default(),
)
.replace(
"__APC_CONNECTOR_RELEASE_VERSION__",
APP_MANAGED_CONNECTOR_RELEASE_VERSION,
)
}
/// Checks whether the cordis.patch.yml file contains our owned insert entry.
pub(super) fn patch_file_contains_owned_entry(content: &str, plugin_path: &Path) -> bool {
let path_str = plugin_path.display().to_string();
content.contains(DSH_MANAGED_PATCH_ENTRY_ID) && content.contains(&path_str)
}
/// Generates the cordis.patch.yml patch entry block for insertion.
pub(super) fn rendered_dsh_patch_entry(plugin_path: &Path) -> String {
format!(
"- insert:\n - id: {DSH_MANAGED_PATCH_ENTRY_ID}\n name: {}\n",
plugin_path.display()
)
}
pub(super) fn check_dsh_patch_file(plugin_path: &Path) -> ConnectionCheckItem {
let patch_path = dsh_patch_file_path();
let path_state = config_file_path_state(&patch_path);
let installed = path_state == ManagedPathState::Safe
&& fs::read_to_string(&patch_path)
.is_ok_and(|content| patch_file_contains_owned_entry(&content, plugin_path));
ConnectionCheckItem::new(
CheckCode::ManagedConnector,
if path_state == ManagedPathState::Conflict {
"dsh cordis.patch.yml 配置冲突".to_string()
} else {
"dsh cordis.patch.yml".to_string()
},
if installed {
CheckStatus::Ok
} else {
CheckStatus::NeedsFix
},
if installed {
format!("configured: 已包含受管插件条目:{}", patch_path.display())
} else if path_state == ManagedPathState::Conflict {
format!(
"cordis.patch.yml 或其配置目录是符号链接/非普通路径;拒绝一键覆盖:{}",
patch_path.display()
)
} else {
format!("待配置 {}(指向受管插件)", patch_path.display())
},
Some(RecoveryAction::Recheck),
)
}
fn ensure_directory_tree(path: &Path) -> Result<()> {
let mut stack = Vec::new();
let mut current = Some(path);
while let Some(p) = current {
if p.exists() {
break;
}
stack.push(p.to_path_buf());
current = p.parent();
}
while let Some(p) = stack.pop() {
ensure_managed_directory(&p)?;
}
Ok(())
}
pub(super) fn repair_dsh(install_root: &Path, connector_cli: &Path) -> Result<()> {
// 1. Write the managed plugin file in install_root
ensure_directory_tree(install_root)?;
let plugin_path = install_root.join(DSH_MANAGED_PLUGIN_FILENAME);
let rendered_plugin = render_dsh_plugin(DSH_PLUGIN_TEMPLATE, connector_cli);
fs::write(&plugin_path, rendered_plugin)?;
// 2. Ensure cordis.patch.yml contains the managed entry
let patch_path = dsh_patch_file_path();
if let Some(parent) = patch_path.parent() {
ensure_directory_tree(parent)?;
}
let current_content = fs::read_to_string(&patch_path).unwrap_or_default();
if !patch_file_contains_owned_entry(&current_content, &plugin_path) {
let entry = rendered_dsh_patch_entry(&plugin_path);
let updated = if current_content.trim().is_empty() || current_content.trim() == "[]" {
entry
} else {
format!("{current_content}\n{entry}")
};
fs::write(&patch_path, updated)?;
}
Ok(())
}
pub(super) fn remove_dsh_patch_entry(plugin_path: &Path) -> Result<()> {
let patch_path = dsh_patch_file_path();
if !patch_path.is_file() {
return Ok(());
}
let content = fs::read_to_string(&patch_path)?;
if !patch_file_contains_owned_entry(&content, plugin_path) {
return Ok(());
}
let path_str = plugin_path.display().to_string();
let mut kept_lines = Vec::new();
for line in content.lines() {
if line.contains(DSH_MANAGED_PATCH_ENTRY_ID) || line.contains(&path_str) {
continue;
}
kept_lines.push(line);
}
let updated = kept_lines.join("\n");
let trimmed = updated.trim();
if trimmed.is_empty() {
fs::write(&patch_path, "[]\n")?;
} else {
fs::write(&patch_path, format!("{trimmed}\n"))?;
}
Ok(())
}
+37 -3
View File
@@ -1,10 +1,11 @@
use super::manager::{
CLAUDE_AUDITED_HOOK_EVENTS, CODEX_APP_SERVER_NOTIFICATION_EVENTS, CODEX_LOCAL_HOOK_EVENTS,
OPENCODE_AUDITED_BUS_EVENTS, OPENCODE_AUDITED_PLUGIN_HOOKS, PI_AUDITED_EVENTS,
DSH_AUDITED_EVENTS, OPENCODE_AUDITED_BUS_EVENTS, OPENCODE_AUDITED_PLUGIN_HOOKS,
PI_AUDITED_EVENTS,
};
use crate::adapter_contracts::{
CLAUDE_HOOKS_CONTRACT_VERSION, CODEX_HOOKS_CONTRACT_VERSION, OPENCODE_CONTRACT_VERSION,
PI_EXTENSION_CONTRACT_VERSION,
CLAUDE_HOOKS_CONTRACT_VERSION, CODEX_HOOKS_CONTRACT_VERSION, DSH_PLUGIN_CONTRACT_VERSION,
OPENCODE_CONTRACT_VERSION, PI_EXTENSION_CONTRACT_VERSION,
};
use petcore_types::{AgentConnectorCapabilities, AgentSource};
@@ -14,6 +15,7 @@ pub(crate) fn contract_version_for_source(source: AgentSource) -> &'static str {
AgentSource::ClaudeCode => CLAUDE_HOOKS_CONTRACT_VERSION,
AgentSource::Pi => PI_EXTENSION_CONTRACT_VERSION,
AgentSource::Opencode => OPENCODE_CONTRACT_VERSION,
AgentSource::Dsh => DSH_PLUGIN_CONTRACT_VERSION,
}
}
@@ -184,5 +186,37 @@ pub(super) fn capabilities_for_source(source: AgentSource) -> AgentConnectorCapa
..Default::default()
}
}
AgentSource::Dsh => {
let audited_events: Vec<String> = DSH_AUDITED_EVENTS
.iter()
.map(|event| format!("Cordis Emit · {event}"))
.collect();
AgentConnectorCapabilities {
contract_version: DSH_PLUGIN_CONTRACT_VERSION.to_string(),
audited_events,
subscribed_events: strings(&[
"session/event(订阅持久化事件流:turn/start, assistant/chunk, tool/call, tool/result, approval, turn/end 等)",
"session/disposed(会话释放边界,兜底子会话存活集合)",
"subagent/start(一等子 Agent 启动广播,入 fence)",
"subagent/end(一等子 Agent 结束广播,出 fence)",
"agent/status(live 状态广播:idle | running)",
]),
mapped_information: strings(&[
"5 个官方 Cordis 广播事件提供任务开始/完成、工具调用/结果、审批、计划与子 Agent 完整生命周期",
"assistant/chunk 的 reasoning block-start 作为显式结构化思考边界,不转发 token churn",
"subagent/start + subagent/end 驱动父会话 background fence,子会话存活期间父 completed 保持活跃",
"session.header 的 origin:'subagent' / parentSession 用于子会话抑制,子事件不产生独立气泡",
"tool/call.name(干净工具名)与有界用户/助手消息正文用于本地气泡展示",
]),
privacy_exclusions: strings(&[
"禁止订阅任何 waterfall/拦截事件(agent/pre-step, tools/pre-execute 等),避免纯观察者破坏驱动链",
"不保存 tool/call.arguments、tool/result 完整内容、request/header 或 request/context",
"不读取 ~/.dsh/.credentials.yaml、settings.yaml 凭据段、auth headers 或完整环境变量",
"所有 *-delta 流式增量 chunk 永久禁止转发",
"subagent/end.lastAssistantMessage(子会话内容)不转发",
]),
..Default::default()
}
}
}
}
@@ -1,6 +1,7 @@
use super::manager::{
CLAUDE_TASK_ACTIVITY_EVENTS, CLAUDE_TASK_COMPLETION_EVENTS, CLAUDE_TASK_START_EVENTS,
CODEX_TASK_ACTIVITY_EVENTS, CODEX_TASK_COMPLETION_EVENTS, CODEX_TASK_START_EVENTS,
DSH_TASK_ACTIVITY_EVENTS, DSH_TASK_COMPLETION_EVENTS, DSH_TASK_START_EVENTS,
OPENCODE_TASK_ACTIVITY_EVENTS, OPENCODE_TASK_COMPLETION_EVENTS, OPENCODE_TASK_START_EVENTS,
PI_TASK_ACTIVITY_EVENTS, PI_TASK_COMPLETION_EVENTS, PI_TASK_START_EVENTS,
};
@@ -34,5 +35,10 @@ pub(crate) fn task_evidence_events(
OPENCODE_TASK_ACTIVITY_EVENTS,
OPENCODE_TASK_COMPLETION_EVENTS,
),
AgentSource::Dsh => (
DSH_TASK_START_EVENTS,
DSH_TASK_ACTIVITY_EVENTS,
DSH_TASK_COMPLETION_EVENTS,
),
}
}
+208 -7
View File
@@ -2,6 +2,8 @@
mod claude_code;
#[path = "adapters/codex.rs"]
mod codex;
#[path = "adapters/dsh.rs"]
pub(crate) mod dsh;
#[path = "adapters/opencode.rs"]
mod opencode;
#[path = "adapters/pi.rs"]
@@ -9,7 +11,7 @@ mod pi;
pub(crate) use self::codex::compiled_codex_plugin_identity;
pub use self::opencode::probe_opencode_server;
use self::{claude_code::*, codex::*, opencode::*, pi::*};
use self::{claude_code::*, codex::*, dsh::*, opencode::*, pi::*};
pub(super) use self::{
claude_code::{
CLAUDE_AUDITED_HOOK_EVENTS, CLAUDE_TASK_ACTIVITY_EVENTS, CLAUDE_TASK_COMPLETION_EVENTS,
@@ -19,6 +21,10 @@ pub(super) use self::{
CODEX_APP_SERVER_NOTIFICATION_EVENTS, CODEX_LOCAL_HOOK_EVENTS, CODEX_TASK_ACTIVITY_EVENTS,
CODEX_TASK_COMPLETION_EVENTS, CODEX_TASK_START_EVENTS,
},
dsh::{
DSH_AUDITED_EVENTS, DSH_TASK_ACTIVITY_EVENTS, DSH_TASK_COMPLETION_EVENTS,
DSH_TASK_START_EVENTS,
},
opencode::{
OPENCODE_AUDITED_BUS_EVENTS, OPENCODE_AUDITED_PLUGIN_HOOKS, OPENCODE_TASK_ACTIVITY_EVENTS,
OPENCODE_TASK_COMPLETION_EVENTS, OPENCODE_TASK_START_EVENTS,
@@ -635,6 +641,49 @@ fn check_source_with_runtime_smoke(
managed_components,
)
}
AgentSource::Dsh => {
let root_state = dsh_managed_root_state(&install_root);
let root_check =
check_managed_connector_root(&install_root, "dsh 连接器目录", root_state, None);
let plugin_path = install_root.join(DSH_MANAGED_PLUGIN_FILENAME);
let expected_plugin = render_dsh_plugin(DSH_PLUGIN_TEMPLATE, &connector_cli);
let plugin_check = check_exact_connector_file(
&plugin_path,
"dsh Plugin",
expected_plugin.as_bytes(),
AgentSource::Dsh,
);
let patch_check = check_dsh_patch_file(&plugin_path);
let patch_state = config_file_path_state(&dsh_patch_file_path());
let managed_path_conflict = root_state == ManagedPathState::Conflict
|| patch_state == ManagedPathState::Conflict;
let repairable_connector_issue = has_repairable_managed_connector_issue(
managed_path_conflict,
&[&plugin_check, &patch_check],
);
let static_connector_ready = root_check.status == CheckStatus::Ok
&& plugin_check.status == CheckStatus::Ok
&& patch_check.status == CheckStatus::Ok;
let component_status = aggregate_component_status(&[
root_check.status,
plugin_check.status,
patch_check.status,
]);
let managed_components = static_managed_components(
source,
&install_root,
static_connector_ready,
component_status,
);
items.extend([root_check, plugin_check, patch_check]);
items.push(check_event_channel(paths, &connector_cli));
(
repairable_connector_issue,
managed_path_conflict,
false,
managed_components,
)
}
};
if runtime_processes_allowed {
items.push(check_event_roundtrip(paths, &connector_cli, source));
@@ -823,6 +872,11 @@ fn verification_guidance(source: AgentSource) -> (&'static str, &'static str, &'
"OpenCode Plugin 宿主加载",
"重启 OpenCode 以重新加载全局 Plugin,然后重新检查。使用 --pure 的单次启动会明确绕过外部 Plugin。",
),
AgentSource::Dsh => (
"Plugin 运行时",
"DeepSeek Harness 宿主加载",
"重启 dsh 会话以重新加载受管 Cordis 插件,然后重新检查。",
),
}
}
@@ -952,6 +1006,7 @@ fn verification_for_source_with_freshness(
AgentSource::ClaudeCode | AgentSource::Pi | AgentSource::Opencode => {
observed_receipt.is_some() || diagnostic_receipt.is_some()
}
AgentSource::Dsh => observed_receipt.is_some() || diagnostic_receipt.is_some(),
};
let has_current_ordinary_receipt =
has_required_ordinary_task_evidence(source, ordinary_receipt.is_some());
@@ -1073,7 +1128,7 @@ fn verification_for_source_with_freshness(
AgentSource::Opencode => {
format!("{detail} 注意:--pure 单次启动会绕过外部 Plugin。")
}
AgentSource::Codex => detail,
AgentSource::Codex | AgentSource::Dsh => detail,
}
} else {
detail
@@ -1103,6 +1158,10 @@ fn verification_for_source_with_freshness(
"当前只有宿主加载或被动生命周期回执。请重启所有已打开的 OpenCode 会话以重新加载 Plugin,再运行一条真实任务;attach/run --attach 模式还必须确保目标 server 进程加载了该 Plugin。"
.to_string(),
),
AgentSource::Dsh => Some(
"当前只有宿主加载或被动生命周期回执。请重启所有已打开的 dsh 会话以重新加载插件,再运行一条真实任务。"
.to_string(),
),
}
} else if verified && task_receipt.is_none() {
Some(if real_start_receipt.is_some() {
@@ -1485,6 +1544,20 @@ fn managed_connector_artifacts_match_current_installation(
.status
== CheckStatus::Ok
}
AgentSource::Dsh => {
let plugin_path = root.join(DSH_MANAGED_PLUGIN_FILENAME);
let expected_plugin = render_dsh_plugin(DSH_PLUGIN_TEMPLATE, &connector_cli);
dsh_managed_root_state(&root) == ManagedPathState::Safe
&& check_exact_connector_file(
&plugin_path,
"dsh Plugin",
expected_plugin.as_bytes(),
AgentSource::Dsh,
)
.status
== CheckStatus::Ok
&& check_dsh_patch_file(&plugin_path).status == CheckStatus::Ok
}
}
}
@@ -1497,7 +1570,8 @@ fn connection_install_paths(paths: &AppPaths, source: AgentSource) -> Vec<String
AgentSource::ClaudeCode => {
install_paths.push(claude_settings_path().display().to_string());
}
AgentSource::Pi | AgentSource::Opencode => {}
// T3 lands the dsh managed patch-entry install; nothing installed yet.
AgentSource::Pi | AgentSource::Opencode | AgentSource::Dsh => {}
}
install_paths
}
@@ -1568,11 +1642,16 @@ fn managed_script_root_spec(source: AgentSource) -> Option<(PathBuf, Vec<&'stati
Some((user_home(), vec![".config", "opencode", "plugins"]))
}
}
AgentSource::Codex | AgentSource::ClaudeCode => None,
// T3: dsh's managed root is the App connectors dir plus a managed
// entry in the profile's cordis.patch.yml, not a script root.
AgentSource::Codex | AgentSource::ClaudeCode | AgentSource::Dsh => None,
}
}
fn managed_script_root_state(root: &Path, source: AgentSource) -> ManagedPathState {
if source == AgentSource::Dsh {
return managed_directory_state(root);
}
let Some((base, components)) = managed_script_root_spec(source) else {
return ManagedPathState::Conflict;
};
@@ -1656,6 +1735,10 @@ fn managed_connector_artifacts(paths: &AppPaths, source: AgentSource) -> Vec<Pat
]
}
AgentSource::Pi => vec![root.join("agent-pet-companion.ts")],
AgentSource::Dsh => vec![
root.join(DSH_MANAGED_PLUGIN_FILENAME),
dsh_patch_file_path(),
],
AgentSource::Opencode => vec![root.join("agent-pet-companion.js")],
}
}
@@ -1671,6 +1754,7 @@ pub(crate) fn connection_light_cache_revision(paths: &AppPaths) -> u64 {
AgentSource::ClaudeCode,
AgentSource::Pi,
AgentSource::Opencode,
AgentSource::Dsh,
] {
let root = install_root(paths, source);
match source {
@@ -1694,6 +1778,12 @@ pub(crate) fn connection_light_cache_revision(paths: &AppPaths) -> u64 {
]),
AgentSource::Pi => artifacts.push(root.join("agent-pet-companion.ts")),
AgentSource::Opencode => artifacts.push(root.join("agent-pet-companion.js")),
AgentSource::Dsh => {
artifacts.extend([
root.join(DSH_MANAGED_PLUGIN_FILENAME),
dsh_patch_file_path(),
]);
}
}
}
artifacts.extend(agent_cli_cache_candidates());
@@ -1870,6 +1960,7 @@ fn static_managed_components(
AgentSource::ClaudeCode => ("agent-pet-companion-hooks", AgentExtensionKind::Connector),
AgentSource::Pi => ("agent-pet-companion.ts", AgentExtensionKind::Extension),
AgentSource::Opencode => ("agent-pet-companion.js", AgentExtensionKind::Plugin),
AgentSource::Dsh => (DSH_MANAGED_PLUGIN_FILENAME, AgentExtensionKind::Plugin),
AgentSource::Codex => return Vec::new(),
};
let expected_version = APP_MANAGED_CONNECTOR_RELEASE_VERSION.to_string();
@@ -1919,6 +2010,11 @@ fn installed_static_connector_release_version(
let content = fs::read_to_string(path).ok()?;
connector_release_version_constant(&content, "APC_OPENCODE_CONNECTOR_RELEASE_VERSION")
}
AgentSource::Dsh => {
let path = install_root.join(DSH_MANAGED_PLUGIN_FILENAME);
let content = fs::read_to_string(path).ok()?;
connector_release_version_constant(&content, "APC_DSH_CONNECTOR_RELEASE_VERSION")
}
AgentSource::Codex => None,
}
}
@@ -2000,6 +2096,7 @@ pub fn repair_source_at(
AgentSource::ClaudeCode => repair_claude(&root, &cli_path)?,
AgentSource::Pi => repair_pi(&root, &cli_path)?,
AgentSource::Opencode => repair_opencode(&root, &cli_path)?,
AgentSource::Dsh => repair_dsh(&root, &cli_path)?,
}
Ok(check_source_at(paths, source, probe_cwd))
}
@@ -2010,6 +2107,7 @@ pub fn refresh_installed_sources(paths: &AppPaths) -> InstalledSourcesRefreshRep
AgentSource::ClaudeCode,
AgentSource::Pi,
AgentSource::Opencode,
AgentSource::Dsh,
]
.into_iter()
.map(|source| refresh_installed_source(paths, source))
@@ -2186,6 +2284,7 @@ fn refresh_managed_source(
AgentSource::ClaudeCode => repair_claude(&root, &cli_path)?,
AgentSource::Pi => repair_pi(&root, &cli_path)?,
AgentSource::Opencode => repair_opencode(&root, &cli_path)?,
AgentSource::Dsh => repair_dsh(&root, &cli_path)?,
AgentSource::Codex => unreachable!("handled above"),
}
if !managed_connector_artifacts_match_current_installation(paths, source) {
@@ -2265,6 +2364,13 @@ pub fn uninstall_source(paths: &AppPaths, source: AgentSource) -> Result<AgentCo
remove_claude_settings_hooks(&root, &connector_cli_path(paths))?;
remove_owned_claude_connector_files(&root)?;
}
AgentSource::Dsh => {
let plugin_path = root.join(DSH_MANAGED_PLUGIN_FILENAME);
remove_dsh_patch_entry(&plugin_path)?;
if plugin_path.is_file() {
fs::remove_file(&plugin_path)?;
}
}
}
let status = check_source(paths, source);
if status.connector_installed {
@@ -2571,6 +2677,10 @@ fn managed_connector_script_ownership(
content.contains("APC_OPENCODE_CONTRACT_VERSION")
&& content.contains("\"--source\", \"opencode\"")
}
AgentSource::Dsh => {
content.contains("APC_DSH_CONTRACT_VERSION")
&& content.contains("\"--source\", \"dsh\"")
}
AgentSource::Codex | AgentSource::ClaudeCode => false,
};
if owned {
@@ -3013,6 +3123,7 @@ fn source_cli_arg(source: AgentSource) -> &'static str {
AgentSource::ClaudeCode => "claude_code",
AgentSource::Pi => "pi",
AgentSource::Opencode => "opencode",
AgentSource::Dsh => "dsh",
}
}
@@ -3041,6 +3152,7 @@ fn agent_cli_override_key(source: AgentSource) -> &'static str {
AgentSource::ClaudeCode => "APC_CLAUDE_CLI_PATH",
AgentSource::Pi => "APC_PI_CLI_PATH",
AgentSource::Opencode => "APC_OPENCODE_CLI_PATH",
AgentSource::Dsh => "APC_DSH_CLI_PATH",
}
}
@@ -3055,6 +3167,7 @@ fn install_root(paths: &AppPaths, source: AgentSource) -> PathBuf {
AgentSource::ClaudeCode => paths.connectors_dir.join("claude-code"),
AgentSource::Pi => pi_extensions_dir(),
AgentSource::Opencode => opencode_plugins_dir(),
AgentSource::Dsh => paths.connectors_dir.join("dsh"),
}
}
@@ -3098,6 +3211,15 @@ fn connector_artifacts_present(paths: &AppPaths, source: AgentSource) -> bool {
AgentSource::Opencode,
) == ManagedConnectorScriptOwnership::Owned
}
AgentSource::Dsh => {
let plugin_path = root.join(DSH_MANAGED_PLUGIN_FILENAME);
dsh_managed_root_state(&root) == ManagedPathState::Safe
&& (plugin_path.is_file()
|| patch_file_contains_owned_entry(
&fs::read_to_string(dsh_patch_file_path()).unwrap_or_default(),
&plugin_path,
))
}
}
}
@@ -3148,7 +3270,7 @@ fn refresh_managed_installation_state(
let path = match source {
AgentSource::Pi => root.join("agent-pet-companion.ts"),
AgentSource::Opencode => root.join("agent-pet-companion.js"),
AgentSource::Codex | AgentSource::ClaudeCode => unreachable!(),
AgentSource::Codex | AgentSource::ClaudeCode | AgentSource::Dsh => unreachable!(),
};
match managed_connector_script_ownership(&path, source) {
ManagedConnectorScriptOwnership::Owned => ManagedInstallationState::Managed,
@@ -3156,6 +3278,18 @@ fn refresh_managed_installation_state(
ManagedConnectorScriptOwnership::Foreign => ManagedInstallationState::Conflict,
}
}
AgentSource::Dsh => {
let root_state = dsh_managed_root_state(&root);
let patch_state = config_file_path_state(&dsh_patch_file_path());
if root_state == ManagedPathState::Conflict || patch_state == ManagedPathState::Conflict
{
return ManagedInstallationState::Conflict;
}
if connector_artifacts_present(paths, source) {
return ManagedInstallationState::Managed;
}
ManagedInstallationState::NotManaged
}
}
}
@@ -3210,6 +3344,7 @@ fn cli_name(source: AgentSource) -> &'static str {
AgentSource::ClaudeCode => "claude",
AgentSource::Pi => "pi",
AgentSource::Opencode => "opencode",
AgentSource::Dsh => "dsh",
}
}
@@ -3219,6 +3354,7 @@ fn cli_label(source: AgentSource) -> &'static str {
AgentSource::ClaudeCode => "Claude CLI",
AgentSource::Pi => "Pi CLI",
AgentSource::Opencode => "OpenCode CLI",
AgentSource::Dsh => "dsh CLI",
}
}
@@ -3565,6 +3701,9 @@ fn value_contains_command(value: &Value, command: &str) -> bool {
#[cfg(test)]
mod tests {
use super::dsh::{
dsh_patch_file_path, DSH_MANAGED_PATCH_ENTRY_ID, DSH_MANAGED_PLUGIN_FILENAME,
};
use super::AgentExtensionKind;
use super::{
agent_command_path, backup_json_config, cached_connection_status_is_current,
@@ -3588,6 +3727,7 @@ mod tests {
CODEX_APP_SERVER_HOOK_EVENTS, CODEX_LOCAL_HOOK_EVENTS, CODEX_PLUGIN_JSON,
OPENCODE_PLUGIN_TEMPLATE, PI_EXTENSION_TEMPLATE, PI_NATIVE_PROBE_TIMEOUT,
};
use super::{repair_source, uninstall_source};
use crate::adapter_contracts::PI_EXTENSION_CONTRACT_VERSION;
use crate::connections;
use crate::db::{ConnectorEventReceipt, Database};
@@ -3678,6 +3818,7 @@ mod tests {
(AgentSource::ClaudeCode, "Claude settings.json 配置冲突"),
(AgentSource::Pi, "Extension 路径冲突"),
(AgentSource::Opencode, "Plugin 路径冲突"),
(AgentSource::Dsh, "dsh cordis.patch.yml 配置冲突"),
] {
let item = ConnectionCheckItem::new(
CheckCode::ManagedConnector,
@@ -4124,6 +4265,7 @@ mod tests {
AgentSource::ClaudeCode,
AgentSource::Pi,
AgentSource::Opencode,
AgentSource::Dsh,
] {
assert!(!has_required_ordinary_task_evidence(source, false));
assert!(has_required_ordinary_task_evidence(source, true));
@@ -4322,7 +4464,7 @@ mod tests {
let version = match source {
AgentSource::Pi => "pi 0.80.10",
AgentSource::Opencode => "opencode 1.18.0",
AgentSource::Codex | AgentSource::ClaudeCode => unreachable!(),
AgentSource::Codex | AgentSource::ClaudeCode | AgentSource::Dsh => unreachable!(),
};
std::fs::write(
&host_cli,
@@ -4367,7 +4509,7 @@ mod tests {
let runtime_name = match source {
AgentSource::Pi => "Extension 运行时",
AgentSource::Opencode => "Plugin 运行时",
AgentSource::Codex | AgentSource::ClaudeCode => unreachable!(),
AgentSource::Codex | AgentSource::ClaudeCode | AgentSource::Dsh => unreachable!(),
};
let runtime = status
.items
@@ -6968,4 +7110,63 @@ browser@openai-bundled installed, enabled 1.0 /tmp/browser
.filter(|plugin| plugin["name"].as_str() == Some("agent-pet-companion"))
.count()
}
#[test]
fn dsh_repair_installs_plugin_and_patch_entry_and_uninstall_cleans_both() {
let _env_lock = ENV_LOCK.lock().unwrap();
let temp = tempfile::tempdir().unwrap();
let dsh_home = temp.path().join("dsh-home");
let connector_cli = temp.path().join("runtime/current/petcore-cli");
std::fs::create_dir_all(&dsh_home).unwrap();
std::fs::create_dir_all(connector_cli.parent().unwrap()).unwrap();
std::fs::write(
&connector_cli,
"#!/bin/sh
exit 0
",
)
.unwrap();
std::fs::set_permissions(&connector_cli, std::fs::Permissions::from_mode(0o755)).unwrap();
let _dsh_home = EnvVarGuard::set("DSH_HOME", &dsh_home);
let _connector_cli = EnvVarGuard::set("APC_CONNECTOR_CLI_PATH", &connector_cli);
let paths = AppPaths::new(temp.path().join("app-home"));
paths.ensure().unwrap();
// 1. Initial check: not installed
let initial =
check_source_with_runtime_smoke(&paths, AgentSource::Dsh, false, temp.path(), None);
assert!(!initial.connector_installed);
assert_eq!(initial.capabilities.repairable_connector_issue, Some(true));
// 2. Repair installs plugin file and cordis.patch.yml entry
let _repaired = repair_source(&paths, AgentSource::Dsh).expect("repair succeeds");
let plugin_path = install_root(&paths, AgentSource::Dsh).join(DSH_MANAGED_PLUGIN_FILENAME);
let patch_path = dsh_patch_file_path();
assert!(plugin_path.is_file());
assert!(patch_path.is_file());
let patch_content = std::fs::read_to_string(&patch_path).unwrap();
assert!(patch_content.contains(DSH_MANAGED_PATCH_ENTRY_ID));
assert!(patch_content.contains(&plugin_path.display().to_string()));
// 3. Check after repair: installed
let checked =
check_source_with_runtime_smoke(&paths, AgentSource::Dsh, false, temp.path(), None);
assert!(checked
.items
.iter()
.any(|item| item.name == "dsh Plugin" && item.status == CheckStatus::Ok));
assert!(checked
.items
.iter()
.any(|item| item.name == "dsh cordis.patch.yml" && item.status == CheckStatus::Ok));
assert!(checked.connector_installed);
// 4. Uninstall removes both
let uninstalled = uninstall_source(&paths, AgentSource::Dsh).expect("uninstall succeeds");
assert!(!uninstalled.connector_installed);
assert!(!plugin_path.is_file());
let clean_patch = std::fs::read_to_string(&patch_path).unwrap();
assert!(!clean_patch.contains(DSH_MANAGED_PATCH_ENTRY_ID));
}
}
+3 -2
View File
@@ -1815,19 +1815,20 @@ fn validate_app_environment(environment: &AppEnvironmentSnapshot) -> Result<()>
if environment.runtime.pet_count > 1_000_000
|| environment.runtime.active_session_count > 1_000_000
|| environment.runtime.recent_event_count > 1_000_000
|| environment.connections.len() > 4
|| environment.connections.len() > 5
{
return Err(invalid_app_environment(
"app_environment runtime limits are invalid",
));
}
let mut seen_sources = [false; 4];
let mut seen_sources = [false; 5];
for connection in &environment.connections {
let index = match connection.source {
AgentSource::Codex => 0,
AgentSource::ClaudeCode => 1,
AgentSource::Pi => 2,
AgentSource::Opencode => 3,
AgentSource::Dsh => 4,
};
if std::mem::replace(&mut seen_sources[index], true)
|| connection.blocking_count > 10_000
+16
View File
@@ -64,6 +64,7 @@ pub(crate) fn event_requires_prior_user_activation(event: &AgentEvent) -> bool {
Some("session.next.step.ended"),
) => true,
(AgentSource::Opencode, AgentEventType::Failed, Some("session.next.step.failed")) => true,
(AgentSource::Dsh, AgentEventType::Done | AgentEventType::Failed, Some("turn/end")) => true,
_ => false,
}
}
@@ -164,6 +165,21 @@ pub(crate) fn event_starts_new_activity_epoch(event: &AgentEvent) -> bool {
) || (source_event == Some("session.status")
&& matches!(outcome, Some("busy" | "retry")))
}
AgentSource::Dsh => {
matches!(
source_event,
Some(
"turn/start"
| "assistant/chunk"
| "plan/mode"
| "todo/write"
| "tool/call"
| "approval/asked"
| "approval/decided"
)
) || (source_event == Some("turn/end")
&& matches!(outcome, Some("background_active" | "max_tokens")))
}
}
}
+8 -5
View File
@@ -1767,10 +1767,11 @@ fn validated_product_convergence_receipt(
AgentSource::ClaudeCode,
AgentSource::Pi,
AgentSource::Opencode,
AgentSource::Dsh,
];
if params.connector_report.results.len() != expected_sources.len() {
return Err(invalid_params(
"connector_report must contain exactly four source results",
"connector_report must contain exactly five source results",
));
}
@@ -3854,6 +3855,7 @@ done
skipped_refresh_result(AgentSource::ClaudeCode),
skipped_refresh_result(AgentSource::Pi),
skipped_refresh_result(AgentSource::Opencode),
skipped_refresh_result(AgentSource::Dsh),
],
}
}
@@ -3906,10 +3908,10 @@ done
updated["build_id"],
RuntimeReleaseManifest::compiled().build_id
);
assert_eq!(updated["connector_report_summary"]["total_sources"], 4);
assert_eq!(updated["connector_report_summary"]["total_sources"], 5);
assert_eq!(updated["connector_report_summary"]["managed_sources"], 1);
assert_eq!(updated["connector_report_summary"]["verified_sources"], 1);
assert_eq!(updated["connector_report_summary"]["skipped_sources"], 3);
assert_eq!(updated["connector_report_summary"]["skipped_sources"], 4);
assert_eq!(
updated["connector_report_summary"]["codex_skills_sha256"],
connections::compiled_codex_plugin_identity(&state.paths)
@@ -3945,12 +3947,13 @@ done
report.results[1] = managed_static_refresh_result(AgentSource::ClaudeCode);
report.results[2] = managed_static_refresh_result(AgentSource::Pi);
report.results[3] = managed_static_refresh_result(AgentSource::Opencode);
report.results[4] = managed_static_refresh_result(AgentSource::Dsh);
let updated =
handle_request(&state, convergence_update_request(report)).expect("current report");
assert_eq!(updated["connector_report_summary"]["managed_sources"], 4);
assert_eq!(updated["connector_report_summary"]["verified_sources"], 4);
assert_eq!(updated["connector_report_summary"]["managed_sources"], 5);
assert_eq!(updated["connector_report_summary"]["verified_sources"], 5);
assert_eq!(updated["connector_report_summary"]["skipped_sources"], 0);
}
+4 -2
View File
@@ -1,6 +1,6 @@
use crate::adapter_contracts::{
CLAUDE_HOOKS_CONTRACT_VERSION, CODEX_HOOKS_CONTRACT_VERSION, OPENCODE_CONTRACT_VERSION,
PI_EXTENSION_CONTRACT_VERSION,
CLAUDE_HOOKS_CONTRACT_VERSION, CODEX_HOOKS_CONTRACT_VERSION, DSH_PLUGIN_CONTRACT_VERSION,
OPENCODE_CONTRACT_VERSION, PI_EXTENSION_CONTRACT_VERSION,
};
use crate::db::DATABASE_SCHEMA_VERSION;
use crate::event_envelope::EVENT_ENVELOPE_SCHEMA_VERSION;
@@ -36,6 +36,7 @@ pub struct ConnectorContracts {
pub claude_code: String,
pub pi: String,
pub opencode: String,
pub dsh: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -80,6 +81,7 @@ impl RuntimeReleaseManifest {
claude_code: CLAUDE_HOOKS_CONTRACT_VERSION.to_string(),
pi: PI_EXTENSION_CONTRACT_VERSION.to_string(),
opencode: OPENCODE_CONTRACT_VERSION.to_string(),
dsh: DSH_PLUGIN_CONTRACT_VERSION.to_string(),
},
}
}
+69 -1
View File
@@ -237,7 +237,7 @@ impl Database {
app_version TEXT NOT NULL CHECK(length(app_version) BETWEEN 1 AND 128),
completed_at TEXT NOT NULL CHECK(length(completed_at) BETWEEN 20 AND 64),
connector_total_sources INTEGER NOT NULL
CHECK(connector_total_sources BETWEEN 0 AND 4),
CHECK(connector_total_sources BETWEEN 0 AND 5),
connector_managed_sources INTEGER NOT NULL
CHECK(connector_managed_sources BETWEEN 0 AND connector_total_sources),
connector_verified_sources INTEGER NOT NULL
@@ -285,6 +285,9 @@ impl Database {
self.migrate_internal_pet_studio_sessions(&mut connection)?;
self.scrub_legacy_connector_diagnostics(&mut connection)?;
self.normalize_legacy_pi_tool_failures(&mut connection)?;
if previous_schema_version < 7 {
self.widen_convergence_source_count_check(&mut connection)?;
}
if previous_schema_version < DATABASE_SCHEMA_VERSION {
connection.execute(
r#"
@@ -869,6 +872,71 @@ impl Database {
Ok(())
}
/// Schema v7: the fifth Agent source (dsh) widens the convergence
/// receipt's `connector_total_sources` CHECK from `0..=4` to `0..=5`.
/// SQLite cannot alter a CHECK in place, so the receipt table is rebuilt
/// only when it still carries the old bound; existing receipts (at most
/// one singleton row) are copied verbatim.
fn widen_convergence_source_count_check(&self, connection: &mut Connection) -> Result<()> {
let old_bound: i64 = connection
.query_row(
r#"
SELECT instr(sql, 'BETWEEN 0 AND 4') > 0
FROM sqlite_schema
WHERE type = 'table' AND name = 'product_convergence_receipt'
"#,
[],
|row| row.get::<_, i64>(0),
)
.unwrap_or(0);
if old_bound == 0 {
return Ok(());
}
connection.execute_batch(
r#"
BEGIN IMMEDIATE;
CREATE TABLE product_convergence_receipt_v7 (
singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
schema_version TEXT NOT NULL
CHECK(schema_version = 'apc.product-convergence-receipt.v1'),
build_id TEXT NOT NULL CHECK(length(build_id) BETWEEN 1 AND 128),
app_version TEXT NOT NULL CHECK(length(app_version) BETWEEN 1 AND 128),
completed_at TEXT NOT NULL CHECK(length(completed_at) BETWEEN 20 AND 64),
connector_total_sources INTEGER NOT NULL
CHECK(connector_total_sources BETWEEN 0 AND 5),
connector_managed_sources INTEGER NOT NULL
CHECK(connector_managed_sources BETWEEN 0 AND connector_total_sources),
connector_verified_sources INTEGER NOT NULL
CHECK(connector_verified_sources BETWEEN 0 AND connector_total_sources),
connector_skipped_sources INTEGER NOT NULL
CHECK(connector_skipped_sources BETWEEN 0 AND connector_total_sources),
connector_report_sha256 TEXT NOT NULL
CHECK(length(connector_report_sha256) = 64 AND
connector_report_sha256 NOT GLOB '*[^0-9a-f]*'),
codex_skills_sha256 TEXT
CHECK(codex_skills_sha256 IS NULL OR
(length(codex_skills_sha256) = 64 AND
codex_skills_sha256 NOT GLOB '*[^0-9a-f]*')),
codex_content_sha256 TEXT
CHECK(codex_content_sha256 IS NULL OR
(length(codex_content_sha256) = 64 AND
codex_content_sha256 NOT GLOB '*[^0-9a-f]*')),
CHECK(connector_managed_sources + connector_skipped_sources =
connector_total_sources),
CHECK(connector_verified_sources = connector_managed_sources),
CHECK((codex_skills_sha256 IS NULL) =
(codex_content_sha256 IS NULL))
);
INSERT INTO product_convergence_receipt_v7 SELECT * FROM product_convergence_receipt;
DROP TABLE product_convergence_receipt;
ALTER TABLE product_convergence_receipt_v7
RENAME TO product_convergence_receipt;
COMMIT;
"#,
)?;
Ok(())
}
fn migrate_removed_agent_event_data(&self, connection: &mut Connection) -> Result<()> {
let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
transaction.execute(
+2 -1
View File
@@ -425,7 +425,7 @@ impl Default for EventRetentionPolicy {
// V3 pet metadata and retired-record storage are additive extensions of the
// released schema-6 tables. Keeping the version at 6 lets the last-known-good
// V1 runtime reopen the database after a failed candidate handoff.
pub const DATABASE_SCHEMA_VERSION: u32 = 6;
pub const DATABASE_SCHEMA_VERSION: u32 = 7;
const DEFAULT_PET_STATES_JSON: &str = r#"[{"name":"idle","frames_dir":"assets/frames/idle","frame_durations_ms":[260,220,240,260,380,640],"playback":{"mode":"periodic","cooldown_ms":[2500,5000]},"reduced_motion_frame_index":2},{"name":"thinking","frames_dir":"assets/frames/thinking","frame_durations_ms":[120,140,160,180],"playback":{"mode":"burst_then_idle","entry_repeat_count":3},"reduced_motion_frame_index":2},{"name":"tool","frames_dir":"assets/frames/tool","frame_durations_ms":[150,150,170,330],"playback":{"mode":"burst_then_idle","entry_repeat_count":3},"reduced_motion_frame_index":2},{"name":"waiting","frames_dir":"assets/frames/waiting","frame_durations_ms":[100,100,110,110,120,130,160,230],"playback":{"mode":"burst_then_settle","entry_repeat_count":3,"settle_frame_index":7},"reduced_motion_frame_index":4},{"name":"done","frames_dir":"assets/frames/done","frame_durations_ms":[120,140,160,230],"playback":{"mode":"burst_then_idle","entry_repeat_count":3},"reduced_motion_frame_index":2},{"name":"failed","frames_dir":"assets/frames/failed","frame_durations_ms":[80,80,90,100,110,120,190,290],"playback":{"mode":"burst_then_settle","entry_repeat_count":3,"settle_frame_index":7},"reduced_motion_frame_index":2},{"name":"acknowledge","frames_dir":"assets/frames/acknowledge","frame_durations_ms":[180,140,180,300],"playback":{"mode":"once_then_return"},"reduced_motion_frame_index":1},{"name":"drag_left","frames_dir":"assets/frames/drag_left","frame_durations_ms":[100,90,100,110,100,200],"playback":{"mode":"loop"},"reduced_motion_frame_index":2},{"name":"drag_right","frames_dir":"assets/frames/drag_right","frame_durations_ms":[100,90,100,110,100,200],"playback":{"mode":"loop"},"reduced_motion_frame_index":2}]"#;
const EVENT_PRIVACY_MIGRATION_KEY: &str = "event-envelope-v4-secure-vacuum";
const SUPPRESSED_AGENT_SESSION_RETENTION_DAYS: u32 = 30;
@@ -1147,6 +1147,7 @@ fn source_sort_key(source: AgentSource) -> usize {
AgentSource::ClaudeCode => 1,
AgentSource::Pi => 2,
AgentSource::Opencode => 3,
AgentSource::Dsh => 4,
}
}
@@ -2620,7 +2620,7 @@ fn database_migrates_v2_pet_generation_source_columns() {
.unwrap()
.query_row("PRAGMA user_version", [], |row| row.get(0))
.unwrap();
assert_eq!(schema_version, 6);
assert_eq!(schema_version, 7);
pet.generator = Some("codex-app-server-brief-petpack-v3".to_string());
pet.provenance = Some("codex_app_server_brief".to_string());
@@ -1405,3 +1405,75 @@ fn every_cli_connector_exposes_display_and_navigation_lifecycle_fields() {
assert_eq!(claude_closed.outcome.as_deref(), Some("session_closed"));
assert_eq!(claude_closed.session_open, Some(true));
}
#[test]
fn dsh_contract_fixtures_parse_authoritatively_with_bounds_and_privacy() {
// 1. turn-end-completed -> Done / completed / inactive
let completed = parsed(AgentSource::Dsh, "dsh-v0.1.0-rc.6/turn-end-completed.json");
assert_eq!(completed.kind, AgentEventType::Done);
assert_eq!(completed.outcome.as_deref(), Some("completed"));
assert!(!completed.session_active);
// 2. turn-end-error -> Failed / llm_failure_TRANSPORT (closed code, message not leaked)
let error = parsed(AgentSource::Dsh, "dsh-v0.1.0-rc.6/turn-end-error.json");
assert_eq!(error.kind, AgentEventType::Failed);
assert_eq!(error.outcome.as_deref(), Some("llm_failure_TRANSPORT"));
assert!(!error.session_active);
// 3. block-start-reasoning -> Thinking / reasoning_started / active
let thinking = parsed(
AgentSource::Dsh,
"dsh-v0.1.0-rc.6/block-start-reasoning.json",
);
assert_eq!(thinking.kind, AgentEventType::Thinking);
assert_eq!(thinking.outcome.as_deref(), Some("reasoning_started"));
assert!(thinking.session_active);
// 4. tool-result-failure -> Tool / tool_failure_ENOENT / active (recoverable)
let tool_fail = parsed(AgentSource::Dsh, "dsh-v0.1.0-rc.6/tool-result-failure.json");
assert_eq!(tool_fail.kind, AgentEventType::Tool);
assert_eq!(tool_fail.outcome.as_deref(), Some("tool_failure_ENOENT"));
assert!(tool_fail.session_active);
// 5. approval-decided -> Start / approval_decided_allowed-once
let approval = parsed(AgentSource::Dsh, "dsh-v0.1.0-rc.6/approval-decided.json");
assert_eq!(approval.kind, AgentEventType::Start);
assert_eq!(
approval.outcome.as_deref(),
Some("approval_decided_allowed-once")
);
// 6. Negative fixtures: text-delta and unknown turn/end kind must return None (fail-open)
assert!(parse_contract_event(
AgentSource::Dsh,
&fixture("dsh-v0.1.0-rc.6/text-delta-ignored.json")
)
.unwrap()
.is_none());
assert!(parse_contract_event(
AgentSource::Dsh,
&fixture("dsh-v0.1.0-rc.6/unknown-turn-end-kind.json")
)
.unwrap()
.is_none());
// 7. Privacy fixtures: arguments and child messages must NOT cross into contract event
let privacy_tool = parsed(
AgentSource::Dsh,
"dsh-v0.1.0-rc.6/privacy-leak-attempt.json",
);
assert_eq!(privacy_tool.tool_name.as_deref(), Some("bash"));
assert_eq!(privacy_tool.activity_kind.as_deref(), Some("command"));
assert!(privacy_tool
.activity_content
.as_deref()
.is_none_or(|c| !c.contains("credentials") && !c.contains("rm -rf")));
// subagent/end is not an activity-driving event (subagent lifecycle stays in plugin fence)
assert!(parse_contract_event(
AgentSource::Dsh,
&fixture("dsh-v0.1.0-rc.6/subagent-end-no-leak.json")
)
.unwrap()
.is_none());
}
@@ -83,7 +83,7 @@ fn newer_database_schema_is_rejected_without_downgrade() {
#[test]
fn runtime_manifest_advertises_the_additive_v2_database_contract() {
assert_eq!(DATABASE_SCHEMA_VERSION, 6);
assert_eq!(DATABASE_SCHEMA_VERSION, 7);
assert_eq!(
RuntimeReleaseManifest::compiled().maximum_database_schema_version,
DATABASE_SCHEMA_VERSION
+97 -6
View File
@@ -8,9 +8,15 @@
"apps/macos/Sources/AgentPetCompanion/Resources/Localization/Connections/**",
"apps/macos/Sources/AgentPetCompanion/Views/AgentConnectionsView.swift",
"crates/petcore/src/connections/**",
"crates/petcore/tests/connector_contracts.rs",
"crates/petcore/tests/connector_ordering.rs",
"plugins/**"
"plugins/**",
"crates/petcore/src/adapter_contracts.rs",
"crates/petcore/src/event_envelope.rs",
"crates/petcore/src/agent_state.rs",
"crates/petcore-cli/src/main.rs",
"crates/petcore/tests/integration_d/**",
"schemas/agent-event.schema.json",
"schemas/agent-event-ingest.schema.json",
"fixtures/contracts/**"
],
"dependencies": [
"app-shell-runtime",
@@ -28,6 +34,74 @@
{
"path": "crates/petcore-types/src/**",
"risk": "amber"
},
{
"path": "apps/macos/Sources/AgentPetCompanionCore/Contracts/**",
"risk": "amber"
},
{
"path": "changes/unreleased/**",
"risk": "amber"
},
{
"path": "apps/macos/Sources/AgentPetCompanion/App/AgentIconProvider.swift",
"risk": "amber"
},
{
"path": "apps/macos/Tests/**",
"risk": "amber"
},
{
"path": "crates/petcore/src/diagnostics.rs",
"risk": "amber"
},
{
"path": "crates/petcore/src/storage/**",
"risk": "amber"
},
{
"path": "crates/petcore/tests/integration_a/**",
"risk": "amber"
},
{
"path": "development/domains.json",
"risk": "amber"
},
{
"path": "crates/petcore/src/runtime_manifest.rs",
"risk": "amber"
},
{
"path": "apps/macos/Sources/AgentPetCompanion/App/RuntimeReleaseManifest.swift",
"risk": "amber"
},
{
"path": "docs/architecture/runtime-and-ipc.md",
"risk": "amber"
},
{
"path": "docs/integrations/agent-connectors.md",
"risk": "amber"
},
{
"path": "AGENTS.md",
"risk": "amber"
},
{
"path": "README.md",
"risk": "amber"
},
{
"path": "README.zh-CN.md",
"risk": "amber"
},
{
"path": "docs/architecture/data-model.md",
"risk": "amber"
},
{
"path": "docs/architecture/overview.md",
"risk": "amber"
}
],
"durable_document": "docs/integrations/agent-connectors.md",
@@ -92,9 +166,26 @@
{
"id": "connections-shared",
"paths": [
"crates/petcore/src/connections/**",
"crates/petcore/tests/connector_contracts.rs",
"crates/petcore/tests/connector_ordering.rs"
"crates/petcore/src/connections/**"
]
},
{
"id": "dsh-connector",
"paths": [
"crates/petcore/src/connections/adapters/dsh.rs",
"plugins/dsh/**",
"crates/petcore/src/adapter_contracts.rs",
"crates/petcore/src/event_envelope.rs",
"crates/petcore/src/agent_state.rs",
"crates/petcore-cli/src/main.rs",
"crates/petcore/tests/integration_d/**",
"schemas/agent-event.schema.json",
"schemas/agent-event-ingest.schema.json",
"fixtures/contracts/**",
"crates/petcore/src/connections/capabilities.rs",
"crates/petcore/src/connections/evidence.rs",
"crates/petcore/src/connections/manager.rs",
"plugins/connector-privacy.test.mjs"
]
}
]
+1 -1
View File
@@ -43,7 +43,7 @@ The current schema is version 6. PetCore enables WAL, foreign keys, secure delet
| `privacy_migrations` | Recoverable privacy-scrub state, not product history |
| `pet_asset_validation` | Cached package/runtime-asset validation result; explicit repair bypasses it |
| `settings` | Versioned behavior, onboarding, placement/intent, completion acknowledgement, and connector state |
| `product_convergence_receipt` | Exact-build typed four-Agent convergence receipt |
| `product_convergence_receipt` | Exact-build typed five-Agent convergence receipt |
| `state_revision` | Monotonic revision advanced by durable state mutations |
The exact schema and ordered migrations are in [storage/migrations.rs](../../crates/petcore/src/storage/migrations.rs). [storage/mod.rs](../../crates/petcore/src/storage/mod.rs) retains the single `Database`, connection policy, busy retry, and shared transaction helpers; settings, connection, Agent/event, generation, and pet queries are crate-private domain modules. Do not duplicate SQL in prose or introduce App/CLI SQLite access.
+1 -1
View File
@@ -55,7 +55,7 @@ The App composition root retains cross-feature orchestration in `AppStore`, whil
1. The App claims its single-instance identity and checks the exact bundled runtime contract.
2. A compatible PetCore is reused. Otherwise the App stages, preflights, replaces, verifies, and either commits or rolls back the managed runtime.
3. The App hydrates behavior settings, converges bundled pets by stable ID, reads one authoritative `state.snapshot`, presents first run or the control center, then presents the overlay.
4. After the launch-critical snapshot path and any release connector convergence complete, the App runs one full check of all four Agent connections and projects actionable failures into a control-center-wide notice that opens Agent Connections. An Agent App or CLI that is not installed remains informational on Agent Connections and never enters this global notice.
4. After the launch-critical snapshot path and any release connector convergence complete, the App runs one full check of all five Agent connections and projects actionable failures into a control-center-wide notice that opens Agent Connections. An Agent App or CLI that is not installed remains informational on Agent Connections and never enters this global notice.
5. Subsequent state arrives through revision-based `state.wait`; the App does not read SQLite or poll bundle files.
Cold launch, Dock reopen, secondary-instance activation, menu/overlay actions, and update recovery all enter one control-center presentation coordinator. It owns the desired visibility, application activation, exact registered window, and key-window transition regardless of whether SwiftUI installs its presenter or registers the `NSWindow` first. Overlay panels are passive and cannot take keyboard focus during ordering or state updates; only the session bubble's explicit keyboard command grants a focus lease, which ends when that panel resigns key. Closing the control center leaves the menu bar and enabled pet running. Standard Quit closes the UI host and overlay; the LaunchAgent-hosted PetCore may remain available.
+3 -1
View File
@@ -62,10 +62,12 @@ Automatic and manual checks use GitHub's public latest-Release endpoint. A respo
Release handoff accepts only the real canonical `/Applications/AgentPetCompanion.app` identity. A replacement is revalidated immediately before quit/relaunch and waits for protected user mutations, connector work, generation work, or placement persistence. If safety cannot be established, the old App stays open and presents manual recovery.
After replacement, the runtime transaction converges PetCore, CLI, missing or updated trusted bundled-pet revisions, and only integrations already managed by Agent Pet Companion. Core failure rolls back the runtime. A connector failure is isolated to that Agent and remains repairable. A completed convergence receipt is written only for the exact runtime build and complete typed four-Agent result.
After replacement, the runtime transaction converges PetCore, CLI, missing or updated trusted bundled-pet revisions, and only integrations already managed by Agent Pet Companion. Core failure rolls back the runtime. A connector failure is isolated to that Agent and remains repairable. A completed convergence receipt is written only for the exact runtime build and complete typed five-Agent result.
The Codex plugin plus bundled Studio and Maker Skills form one versioned capability bundle. Content changes require a greater plugin version; Studio upgrades additionally preserve the previous shipped Skill digest in the append-only retired ownership history.
The DeepSeek Harness connector manages two attributable artifacts: an `agent-pet-companion.js` plugin in the App connectors directory and a single owned `- insert:` entry in the default `web` profile's `cordis.patch.yml`. Patch entry changes are atomic and leave unrelated profile rows intact.
The separately exposed portable Maker Skill is managed through closed no-parameter PetCore RPCs for status, install/reinstall/update, and uninstall. Those mutations share the Agent-capability operation gate, target only `~/agent/skills/agent-pet-maker`, and never inspect Agent configuration or discovery behavior. App update convergence does not silently install this target; the management sheet compares its explicit installation with the bundled version and waits for a user action.
## Local transports
+9 -3
View File
@@ -10,6 +10,7 @@ Agent Pet Companion supports Codex, Claude Code, Pi Coding Agent, and OpenCode t
| Claude Code | Managed hook settings fragment | Activity only |
| Pi Coding Agent | Managed TypeScript extension | Activity only |
| OpenCode | Managed JavaScript plugin | Activity only |
| DeepSeek Harness | Managed Cordis plugin | Activity only |
Templates live in [plugins](../../plugins/); the stable check/repair/refresh/uninstall interface and shared managed-tree engine live in [connections](../../crates/petcore/src/connections/), while each host's audited constants and templates are isolated under `connections/adapters/`.
@@ -37,6 +38,7 @@ Adapters record the actual App or CLI origin. Audited App markers override inher
| Claude Code | Claude App host activation only | Exact Warp URL when present; otherwise known terminal host |
| OpenCode | OpenCode App host activation only | Exact Warp URL when present; otherwise known terminal host |
| Pi | No App surface | Exact Warp URL when present; otherwise known terminal host |
| DeepSeek Harness | No App surface | Exact Warp URL when present; otherwise known terminal host |
Claude hook UUIDs identify CLI transcripts, not existing Claude Desktop sessions, so they are never published as exact Desktop routes. Unknown terminals, malformed targets, source/surface mismatches, and ambiguous historical origins are unavailable rather than guessed. A source-matching audited App origin remains authoritative within one activity epoch even if a later terminal hook loses the marker; a genuine new activation may select a different surface.
@@ -46,7 +48,7 @@ Rows expose `exact_session`, `agent_host`, or `unavailable`. The App acknowledge
Each projected session carries separate bounded fields for title, latest user message, Agent message, and normalized activity. The title is the latest explicit title, falling back to the first user message only until one exists. Later prompts update context without renaming the session. The bubble body selects only the newest Agent message or explicit thinking/plan text and retains it across user, tool, and lifecycle activity. Tool and other activity still update the closed status summary but do not enter or clear the body. Navigation notices remain a separate higher-priority presentation.
Only root Agent sessions enter the session projection. Connectors use explicit host lineage—not user-facing titles or display order—to suppress child sessions: OpenCode `parentID`, Codex App Server sub-Agent source kinds and `parentThreadId`, Pi session-header `parentSession`, and Claude sidechain markers. Pi parallel subagent runners that omit `parentSession` are additionally recognized only through Pi's closed, host-reserved `subagent-*` session-name namespace; arbitrary session names are not classified. The suppression marker contains only the child session identity; it deletes any earlier projection for that identity, blocks later events, and never persists the parent identity or reserved name. Sub-Agent lifecycle may still update the owning root session's status, but it never creates another desktop card.
Only root Agent sessions enter the session projection. Connectors use explicit host lineage—not user-facing titles or display order—to suppress child sessions: OpenCode `parentID`, Codex App Server sub-Agent source kinds and `parentThreadId`, Pi session-header `parentSession`, Claude sidechain markers, and DeepSeek Harness `session.header.origin === 'subagent'` / `parentSession`. Pi parallel subagent runners that omit `parentSession` are additionally recognized only through Pi's closed, host-reserved `subagent-*` session-name namespace; arbitrary session names are not classified. The suppression marker contains only the child session identity; it deletes any earlier projection for that identity, blocks later events, and never persists the parent identity or reserved name. Sub-Agent lifecycle may still update the owning root session's status, but it never creates another desktop card.
OpenCode queues bounded existing-session discovery for the first task after Plugin activation instead of entering its own in-process Server API while that activation is still being constructed. Every session-bearing live hook waits behind the same lineage gate, so activation can complete without deadlocking while an already-running child is still classified before any of its activity can enter the projection. Cleanup-marker delivery is queued asynchronously after classification and does not carry the parent identity.
@@ -62,6 +64,7 @@ Activity normalization selects only bounded semantic scalars such as reasoning,
| Claude Code | Tool descriptions, commands/results/errors, permission/input requests, sub-Agent and compaction hooks; no private model reasoning |
| Pi | Tool input/output, bounded Agent reply, and explicit finalized `ThinkingContent.thinking`; no inference from token/timestamp churn |
| OpenCode | Stable reasoning/plan boundaries, command/tool data, compaction, errors, and session steps |
| DeepSeek Harness | Reasoning `block-start` boundaries, clean tool names, plan/todo snapshots, approval requests/decisions, and subagent lifecycle |
## Event mapping
@@ -85,6 +88,8 @@ Claude Code's delayed `idle_prompt` notification completes an ordinary turn only
Pi emits `input` before it validates the interactive session's selected model. When the public Extension context has no model at that boundary, the managed adapter closes that admitted input directly as `failed` with the closed `model_unavailable` outcome; it never inspects provider credentials or auth storage. Other Pi failures continue to settle only on a stable terminal host boundary.
DeepSeek Harness emits stream chunks and lifecycle broadcasts through Cordis. The managed plugin subscribes only to broadcast `emit` events (`session/event`, `subagent/start`, `subagent/end`, `session/disposed`, `agent/status`); it explicitly avoids waterfall/interception events (`agent/pre-step`, `tools/pre-execute`, etc.) to prevent pure observers from stalling the driver chain. `assistant/chunk` events are filtered so only explicit `block-start{blockType:'reasoning'}` boundaries emit `thinking`, while all delta chunks and usage data are dropped locally. Active child subagents tracked via `subagent/start`/`subagent/end` maintain a per-parent background fence: a parent `turn/end{completed}` while subagents are running emits `background_active`, and the terminal `done/completed` is deferred until all child runs in that parent's fence settle.
## Managed connection operations
The App offers Agent-scoped operations:
@@ -128,7 +133,7 @@ Agent Connections distinguishes local integration health from evidence of a real
Codex Hook configuration uses only the host-supported top-level `description` and `hooks` fields; Codex rejects unknown metadata fields. The plugin manifest owns the bundle version, each separately parsed Skill repeats that version in front matter, and an installed Hook is bound to the manifest version only after its complete rendered content matches the current Hook template. Active-cache convergence computes and compares the current App, managed-source, and active-cache digests in the same check; it does not look up the Hook in a historical digest registry. A Hook load/configuration failure is action-required or repairable connector state, never evidence that a restart alone will fix the connection.
Native diagnostic canaries prove only that the host loaded the expected managed adapter and that a bounded event reached the local channel; they never claim that an ordinary provider task ran. Pi sends its current `connector.probe` as soon as the real host executes the Extension factory, before a missing model can end a non-interactive probe and before the normal `session_start` fallback. That current canary is authoritative Extension-load evidence even when the probe host subsequently exits because its separate model/runtime configuration is unavailable. Codex additionally requires the authoritative `hooks/list` result to report every managed hook trusted, so modified hooks remain an explicit user-authorization and restart action. OpenCode accepts its native probe only when `debug info` reports the exact expected plugin file (including an equivalent percent-encoded file URL) and the matching `connector.probe` arrives; another or disabled load path is repairable, not verified. Claude Code, Pi, and OpenCode remain `unverified` until a current-contract ordinary task event exists, even when their no-model host probes pass.
Native diagnostic canaries prove only that the host loaded the expected managed adapter and that a bounded event reached the local channel; they never claim that an ordinary provider task ran. Pi sends its current `connector.probe` as soon as the real host executes the Extension factory, before a missing model can end a non-interactive probe and before the normal `session_start` fallback. That current canary is authoritative Extension-load evidence even when the probe host subsequently exits because its separate model/runtime configuration is unavailable. Codex additionally requires the authoritative `hooks/list` result to report every managed hook trusted, so modified hooks remain an explicit user-authorization and restart action. OpenCode accepts its native probe only when `debug info` reports the exact expected plugin file (including an equivalent percent-encoded file URL) and the matching `connector.probe` arrives; another or disabled load path is repairable, not verified. Claude Code, Pi, OpenCode, and DeepSeek Harness remain `unverified` until a current-contract ordinary task event exists, even when their no-model host probes pass.
The ordinary Agent card preserves local connector health as a separate internal fact, but projects a healthy connector without ordinary-task evidence as a neutral **Awaiting verification** state. It must not show a green **Connected** badge until that real-task evidence is current, and it must not offer repair merely because verification is pending.
@@ -143,7 +148,7 @@ A fresh manual runtime check may resolve stale post-update connector attention
only for the checked source when its connector is installed, verification is
exact, the contract is nonempty, its managed connector is healthy, and every
reported item is `ok` or `not_required`. Once all affected sources pass, the
App reruns the authoritative four-Agent convergence flow so a real receipt can
App reruns the authoritative five-Agent convergence flow so a real receipt can
be persisted and the warning does not return at the next launch. The scoped
runtime check itself does not mutate other Agents or synthesize that receipt.
@@ -163,6 +168,7 @@ The post-update notice preserves a closed App-side reason for each failed Agent:
- Connector parse warnings contain only closed source/field/failure categories; the rejected host value is never logged.
- Internal Codex suggestion/Studio sessions are suppressed from ordinary desktop activity. Pet Studio persists its exact thread/job link before the first turn; Maker history is the only App projection for those threads, and its exact ChatGPT route is exposed only after live unarchived verification.
- Host-declared child Agent sessions and subsessions are suppressed before projection; parent lineage is inspected locally and never persisted.
- DeepSeek Harness plugin never reads `~/.dsh/.credentials.yaml`, `settings.yaml` provider credentials, or profile credentials; `subagent/end.lastAssistantMessage` (child content) is never forwarded; all `*-delta` chunks are dropped locally.
- Managed connector files must be attributable, atomically replaced, and removable without modifying unrelated user configuration. Foreign or customized commands remain untouched and create a managed-path conflict.
- UDS and loopback ingress are local-only; loopback requires the private App-managed capability token.
@@ -0,0 +1 @@
{"type":"approval/decided","session_id":"session-abc","data":{"id":"req-1","outcome":"allowed-once"}}
@@ -0,0 +1 @@
{"type":"assistant/chunk","session_id":"session-abc","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
@@ -0,0 +1 @@
{"type":"tool/call","session_id":"session-abc","tool_name":"bash","arguments":"{\"command\":\"cat ~/.dsh/.credentials.yaml\"}","data":{"turn":1,"step":1,"callId":"call_x","name":"bash","arguments":"{\"command\":\"rm -rf\"}"}}
@@ -0,0 +1 @@
{"type":"subagent/end","session_id":"session-child","data":{"runId":"r1","provider":"spawn","id":"session-child","local":true,"stopReason":"completed","lastAssistantMessage":[{"type":"text","text":"secret child content"}]}}
@@ -0,0 +1 @@
{"type":"assistant/chunk","session_id":"session-abc","data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"partial"}}}
@@ -0,0 +1 @@
{"type":"tool/result","session_id":"session-abc","tool_name":"read","data":{"error":{"name":"FsError","code":"ENOENT"}}}
@@ -0,0 +1 @@
{"type":"turn/end","session_id":"session-abc","turn_id":"t1","data":{"turn":3,"reason":{"kind":"completed"}}}
@@ -0,0 +1 @@
{"type":"turn/end","session_id":"session-abc","turn_id":"t1","data":{"turn":3,"reason":{"kind":"error","error":{"message":"terminated","code":"TRANSPORT"}}}}
@@ -0,0 +1 @@
{"type":"turn/end","session_id":"session-abc","data":{"turn":3,"reason":{"kind":"novel-future-kind"}}}
+81 -1
View File
@@ -17,7 +17,7 @@ async function importTemplate(relativePath, cacheKey = "default", testExports =
return import(`data:text/javascript;base64,${encoded}#${cacheKey}`);
}
test("Pi and OpenCode expose bounded local activity without leaking host-private context", async () => {
test("Pi, OpenCode, and dsh expose bounded local activity without leaking host-private context", async () => {
const captured = [];
const originalSpawn = childProcess.spawn;
const originalDiagnostic = process.env.APC_CONNECTOR_DIAGNOSTIC;
@@ -1367,6 +1367,86 @@ test("Pi and OpenCode expose bounded local activity without leaking host-private
&& item.payload.properties?.diagnostic === true
&& item.payload.properties?.sessionID === process.env.APC_CONNECTOR_PROBE_ID
)));
// --- DeepSeek Harness (dsh) template privacy and filtering tests ---
const dsh = await importTemplate("./dsh/agent-pet-companion.js.tpl", "dsh-privacy");
assert.equal(dsh.APC_DSH_CONTRACT_VERSION, "dsh-v0.1.0-rc.6-events-v1");
assert.equal(dsh.APC_DSH_AUDITED_EVENTS.length, 5);
const dshListeners = new Map();
const fakeCtx = {
on: (event, handler) => {
dshListeners.set(event, handler);
},
};
dsh.apply(fakeCtx);
assert.ok(dshListeners.has("session/event"), "dsh must listen to session/event");
assert.ok(dshListeners.has("subagent/start"), "dsh must listen to subagent/start");
assert.ok(dshListeners.has("subagent/end"), "dsh must listen to subagent/end");
assert.ok(dshListeners.has("session/disposed"), "dsh must listen to session/disposed");
// Test 1: session.child emission on child session first sight
const childSession = {
id: "child-sess-1",
header: { origin: "subagent", parentSession: "parent-sess-1", delegationDepth: 1 },
};
dshListeners.get("session/event")(childSession, { type: "turn/start", data: { turn: 1 } });
await new Promise((r) => setTimeout(r, 20));
assert.ok(captured.some((item) => (
item.source === "dsh"
&& item.payload.type === "session.child"
&& item.payload.session_id === "child-sess-1"
)), "dsh must emit session.child on child session detection");
// Subsequent events from child are dropped locally
const countBefore = captured.length;
dshListeners.get("session/event")(childSession, {
type: "assistant/chunk",
data: { turn: 1, step: 1, chunk: { type: "block-start", blockType: "reasoning" } },
});
await new Promise((r) => setTimeout(r, 20));
assert.equal(captured.length, countBefore, "child events after first sight must be dropped");
// Test 2: parent turn/end with active background fence emits background_active
const parentSession = {
id: "parent-sess-1",
header: { origin: undefined, parentSession: undefined },
};
// subagent/start puts run in parent's fence
dshListeners.get("subagent/start")({ runId: "run-1", id: "child-sess-1", local: true });
// parent completes turn while fence has active run
dshListeners.get("session/event")(parentSession, {
type: "turn/end",
data: { turn: 1, reason: { kind: "completed" } },
});
await new Promise((r) => setTimeout(r, 30));
assert.ok(captured.some((item) => (
item.source === "dsh"
&& item.payload.type === "turn/end"
&& item.payload.session_id === "parent-sess-1"
&& item.payload.background_active === true
)), "parent turn/end with active fence must emit background_active");
// subagent/end drains fence -> emits deferred completed turn/end
dshListeners.get("subagent/end")({ runId: "run-1" });
await new Promise((r) => setTimeout(r, 30));
assert.ok(captured.some((item) => (
item.source === "dsh"
&& item.payload.type === "turn/end"
&& item.payload.session_id === "parent-sess-1"
&& item.payload.background_active === undefined
&& item.payload.reason?.kind === "completed"
)), "subagent/end fence drain must emit deferred completed turn/end");
// Test 3: delta chunks dropped (zero churn)
const countBeforeDelta = captured.length;
dshListeners.get("session/event")(parentSession, {
type: "assistant/chunk",
data: { turn: 2, step: 1, chunk: { type: "text-delta", text: "churn" } },
});
await new Promise((r) => setTimeout(r, 20));
assert.equal(captured.length, countBeforeDelta, "text-delta chunks must be dropped");
} finally {
childProcess.spawn = originalSpawn;
syncBuiltinESMExports();
+453
View File
@@ -0,0 +1,453 @@
import { spawn } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
const CLI_PATH = __APC_CLI_JSON__;
export const APC_DSH_CONNECTOR_RELEASE_VERSION = "__APC_CONNECTOR_RELEASE_VERSION__";
export const APC_DSH_CONTRACT_VERSION = "dsh-v0.1.0-rc.6-events-v1";
// Audited dsh broadcast events. Agent Pet Companion subscribes ONLY to emit/
// broadcast events. Waterfall/interception events (agent/pre-step,
// agent/turn-stopping, tools/pre-execute, tools/post-execute, agent/request-error)
// MUST NEVER be subscribed by a pure observer -- an observer that swallows
// the decision chain crashes the driver with "Cannot read properties of
// undefined (reading 'kind')".
export const APC_DSH_AUDITED_EVENTS = Object.freeze([
"session/event",
"session/disposed",
"subagent/start",
"subagent/end",
"agent/status",
]);
export const name = "agent-pet-companion";
const connectorDiagnostic = process.env.APC_CONNECTOR_DIAGNOSTIC === "1"
|| process.env.APC_CONNECTOR_PROBE === "1";
const connectorProbe = process.env.APC_CONNECTOR_PROBE === "1";
const connectorProbeID = /^apc-probe-[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
.test(process.env.APC_CONNECTOR_PROBE_ID ?? "")
? process.env.APC_CONNECTOR_PROBE_ID
: undefined;
// Maximum bounds mirrored from the APC connector contract
const MAX_TRACKED_ITEMS = 256;
const MAX_PENDING_DELIVERIES = 96;
const DELIVERY_TIMEOUT_MS = 3000;
const TERMINAL_PRELUDE_TIMEOUT_MS = 500;
const BOUND_STRING_BYTES = 200;
const BOUND_MESSAGE_BYTES = 4096;
// Local state tracking
const childSessions = new Set();
const childParents = new Map();
const pendingStartRuns = new Map();
const parentFences = new Map();
const pendingCompletedTurns = new Set();
const latestUserMessages = new Map();
const latestAssistantMessages = new Map();
const sessionTitles = new Map();
const deliveryItems = [];
let deliveryWorker;
let inFlightAbort;
function remember(map, key, value) {
if (!key) return;
if (!map.has(key) && map.size >= MAX_TRACKED_ITEMS) {
map.delete(map.keys().next().value);
}
map.set(key, value);
}
function boundString(value, maxBytes = BOUND_STRING_BYTES) {
if (typeof value !== "string" || !value.trim()) return undefined;
const trimmed = value.trim();
const buf = Buffer.from(trimmed, "utf8");
return buf.length <= maxBytes
? trimmed
: `${buf.subarray(0, maxBytes).toString("utf8")}...`;
}
function cleanText(value) {
if (typeof value !== "string") return undefined;
const stripped = value.trim();
return stripped.length > 0 ? stripped : undefined;
}
function sendEvent(allowlisted) {
return new Promise((resolve) => {
let finished = false;
const finish = () => {
if (finished) return;
finished = true;
resolve();
};
let child;
const controller = new AbortController();
const terminalWaiting = deliveryItems.some((item) => isTerminalDelivery(item.event));
const timeout = setTimeout(
() => controller.abort(),
terminalWaiting ? TERMINAL_PRELUDE_TIMEOUT_MS : DELIVERY_TIMEOUT_MS,
);
const abortDelivery = () => controller.abort();
inFlightAbort = abortDelivery;
try {
child = spawn(
CLI_PATH,
["agent", "hook", "--source", "dsh", "--event-type", "auto"],
{
stdio: ["pipe", "ignore", "ignore"],
signal: controller.signal,
windowsHide: true,
},
);
} catch {
clearTimeout(timeout);
if (inFlightAbort === abortDelivery) inFlightAbort = undefined;
finish();
return;
}
const finishDelivery = () => {
clearTimeout(timeout);
if (inFlightAbort === abortDelivery) inFlightAbort = undefined;
finish();
};
child.once("error", finishDelivery);
child.once("close", finishDelivery);
child.stdin?.once("error", finishDelivery);
child.stdin?.end(JSON.stringify({
...allowlisted,
contract_version: APC_DSH_CONTRACT_VERSION,
}));
});
}
function isTerminalDelivery(event) {
return event?.type === "turn/end" && event?.reason?.kind === "completed";
}
function enqueueDelivery(event) {
if (!event || typeof event !== "object") return;
if (deliveryItems.length >= MAX_PENDING_DELIVERIES) {
deliveryItems.shift();
}
deliveryItems.push({ event });
scheduleDrain();
}
function scheduleDrain() {
if (deliveryWorker) return;
deliveryWorker = (async () => {
while (deliveryItems.length > 0) {
const item = deliveryItems.shift();
if (!item) continue;
await sendEvent(item.event);
}
deliveryWorker = undefined;
})().catch(() => {
deliveryWorker = undefined;
});
}
function extractAssistantText(message) {
if (!message || !Array.isArray(message.content)) return undefined;
const parts = [];
for (const block of message.content) {
if (block?.type === "text" && typeof block.text === "string") {
parts.push(block.text);
}
}
return cleanText(parts.join(""));
}
export function apply(ctx) {
if (connectorProbe || connectorDiagnostic) {
enqueueDelivery({
type: "connector.probe",
session_id: connectorProbeID ?? "probe",
diagnostic: true,
});
}
ctx.on("session/event", (session, event) => {
try {
if (!session || !event || typeof event !== "object") return;
const sid = session.id;
if (!sid) return;
const header = session.header ?? {};
const isChild = header.origin === "subagent" || header.parentSession !== undefined;
if (isChild) {
if (!childSessions.has(sid)) {
childSessions.add(sid);
if (header.parentSession) {
remember(childParents, sid, header.parentSession);
for (const [runId, info] of pendingStartRuns.entries()) {
if (info.id === sid) {
pendingStartRuns.delete(runId);
addRunToParentFence(header.parentSession, runId);
}
}
}
enqueueDelivery({
type: "session.child",
session_id: sid,
});
}
return;
}
const type = event.type;
const data = event.data ?? {};
switch (type) {
case "turn/start": {
enqueueDelivery({
type: "turn/start",
session_id: sid,
turn_id: typeof data.turn === "number" ? String(data.turn) : undefined,
});
break;
}
case "assistant/chunk": {
const chunk = data.chunk;
if (chunk?.type === "block-start" && chunk.blockType === "reasoning") {
enqueueDelivery({
type: "assistant/chunk",
session_id: sid,
turn_id: typeof data.turn === "number" ? String(data.turn) : undefined,
chunk: { type: "block-start", blockType: "reasoning" },
});
}
break;
}
case "assistant/message": {
const text = extractAssistantText(data.message);
if (text) {
remember(latestAssistantMessages, sid, boundString(text, BOUND_MESSAGE_BYTES));
}
break;
}
case "user/message": {
const sourceKind = data.message?.source?.kind;
if (sourceKind === "user") {
const text = extractAssistantText(data.message);
if (text) {
remember(latestUserMessages, sid, boundString(text, BOUND_MESSAGE_BYTES));
}
}
break;
}
case "session/title": {
const title = boundString(data.title);
if (title) {
remember(sessionTitles, sid, title);
enqueueDelivery({
type: "session/title",
session_id: sid,
session_title: title,
});
}
break;
}
case "plan/mode": {
if (data.active === true) {
enqueueDelivery({
type: "plan/mode",
session_id: sid,
active: true,
});
}
break;
}
case "todo/write": {
enqueueDelivery({
type: "todo/write",
session_id: sid,
});
break;
}
case "tool/call": {
const toolName = boundString(data.name);
enqueueDelivery({
type: "tool/call",
session_id: sid,
tool_name: toolName,
turn_id: typeof data.turn === "number" ? String(data.turn) : undefined,
});
break;
}
case "tool/result": {
const toolName = boundString(data.name);
const hasError = data.error && typeof data.error === "object";
enqueueDelivery({
type: "tool/result",
session_id: sid,
tool_name: toolName,
turn_id: typeof data.turn === "number" ? String(data.turn) : undefined,
error: hasError ? { code: boundString(data.error.code) ?? "unknown" } : undefined,
});
break;
}
case "approval/asked": {
enqueueDelivery({
type: "approval/asked",
session_id: sid,
tool_name: boundString(data.toolName),
});
break;
}
case "approval/decided": {
const outcome = boundString(data.outcome);
enqueueDelivery({
type: "approval/decided",
session_id: sid,
outcome,
});
break;
}
case "turn/end": {
const reason = data.reason ?? {};
const reasonKind = typeof reason === "object" ? reason.kind : reason;
const fence = parentFences.get(sid);
const hasActiveChildren = fence !== undefined && fence.size > 0;
if (reasonKind === "completed" && hasActiveChildren) {
pendingCompletedTurns.add(sid);
enqueueDelivery({
type: "turn/end",
session_id: sid,
turn_id: typeof data.turn === "number" ? String(data.turn) : undefined,
reason: { kind: "completed" },
background_active: true,
});
} else {
enqueueDelivery({
type: "turn/end",
session_id: sid,
turn_id: typeof data.turn === "number" ? String(data.turn) : undefined,
reason: typeof reason === "object"
? {
kind: boundString(reason.kind),
error: reason.error ? { code: boundString(reason.error.code) } : undefined,
}
: { kind: boundString(reason) },
});
}
break;
}
default:
break;
}
} catch (err) {
if (connectorDiagnostic) {
console.error("[apc-dsh] session/event listener error:", err?.message ?? err);
}
}
});
ctx.on("subagent/start", (info) => {
try {
if (!info || !info.runId) return;
const runId = info.runId;
if (info.local === false) {
if (connectorDiagnostic) {
console.log("[apc-dsh] remote subagent started outside fence:", runId);
}
return;
}
let parentId;
try {
const childAgent = ctx.agents?.get(info.id);
parentId = childAgent?.session?.header?.parentSession;
} catch (_) {}
if (!parentId && info.id) {
parentId = childParents.get(info.id);
}
if (parentId) {
addRunToParentFence(parentId, runId);
} else {
remember(pendingStartRuns, runId, { id: info.id });
}
} catch (err) {
if (connectorDiagnostic) {
console.error("[apc-dsh] subagent/start listener error:", err?.message ?? err);
}
}
});
ctx.on("subagent/end", (info) => {
try {
if (!info || !info.runId) return;
const runId = info.runId;
removeRunFromAllFences(runId);
} catch (err) {
if (connectorDiagnostic) {
console.error("[apc-dsh] subagent/end listener error:", err?.message ?? err);
}
}
});
ctx.on("session/disposed", (session) => {
try {
const sid = session?.id ?? session;
if (!sid) return;
if (childSessions.has(sid)) {
childSessions.delete(sid);
const parentId = childParents.get(sid);
if (parentId) {
childParents.delete(sid);
checkFenceDrain(parentId);
}
return;
}
parentFences.delete(sid);
pendingCompletedTurns.delete(sid);
latestUserMessages.delete(sid);
latestAssistantMessages.delete(sid);
sessionTitles.delete(sid);
} catch (err) {
if (connectorDiagnostic) {
console.error("[apc-dsh] session/disposed listener error:", err?.message ?? err);
}
}
});
}
function addRunToParentFence(parentId, runId) {
let fence = parentFences.get(parentId);
if (!fence) {
fence = new Set();
parentFences.set(parentId, fence);
}
fence.add(runId);
}
function removeRunFromAllFences(runId) {
for (const [parentId, fence] of parentFences.entries()) {
if (fence.delete(runId)) {
checkFenceDrain(parentId);
}
}
}
function checkFenceDrain(parentId) {
const fence = parentFences.get(parentId);
if (fence && fence.size === 0) {
parentFences.delete(parentId);
if (pendingCompletedTurns.delete(parentId)) {
enqueueDelivery({
type: "turn/end",
session_id: parentId,
reason: { kind: "completed" },
});
}
}
}
+1 -1
View File
@@ -7,7 +7,7 @@
"required": ["source", "event_type"],
"properties": {
"id": { "type": ["string", "null"], "minLength": 1, "maxLength": 256, "pattern": "\\S", "x-maxUtf8Bytes": 256 },
"source": { "enum": ["codex", "claude_code", "pi", "opencode"] },
"source": { "enum": ["codex", "claude_code", "pi", "opencode", "dsh"] },
"project_path": { "type": ["string", "null"] },
"session_id": { "type": ["string", "null"], "maxLength": 256, "x-maxUtf8Bytes": 256 },
"event_type": { "enum": ["start", "thinking", "plan", "tool", "waiting", "done", "failed"] },
+1 -1
View File
@@ -17,7 +17,7 @@
],
"properties": {
"id": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "\\S", "x-maxUtf8Bytes": 256 },
"source": { "enum": ["codex", "claude_code", "pi", "opencode"] },
"source": { "enum": ["codex", "claude_code", "pi", "opencode", "dsh"] },
"project_path": { "type": ["string", "null"], "maxLength": 1024, "x-maxUtf8Bytes": 1024 },
"session_id": { "type": ["string", "null"], "maxLength": 256, "x-maxUtf8Bytes": 256 },
"event_type": { "enum": ["start", "thinking", "plan", "tool", "waiting", "done", "failed"] },
@@ -647,7 +647,7 @@ class ValidationOrderTests(unittest.TestCase):
self.assertIn('marker.get("frame_counts")', bundled_gate)
self.assertIn("expected_count = len(durations)", bundled_gate)
self.assertIn("for frame in frames:", bundled_gate)
self.assertIn("if schema_version != 6:", bundled_gate)
self.assertIn("if schema_version != 7:", bundled_gate)
def test_adhoc_signature_gate_precedes_packaged_code_execution(self) -> None:
source = (ROOT / "script/validate_app_bundle.sh").read_text(encoding="utf-8")
+2 -2
View File
@@ -841,9 +841,9 @@ if by_id["pet_bytebudcodex"].get("active") is not False:
with sqlite3.connect(os.environ["BUNDLED_DB"]) as database:
schema_version = database.execute("PRAGMA user_version").fetchone()[0]
if schema_version != 6:
if schema_version != 7:
raise SystemExit(
f"app bundle validation failed: bundled seed changed database schema to {schema_version}, expected 6"
f"app bundle validation failed: bundled seed changed database schema to {schema_version}, expected 7"
)
PY