@platless/axom-cli 0.1.0 → 0.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +1 -1
  2. package/dist/authScopes.js +19 -0
  3. package/dist/axom-patch/cli/README.md +110 -0
  4. package/dist/axom-patch/cli/pi-steering.json +37 -0
  5. package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +34 -0
  6. package/dist/axom-patch/cli/probes/pi-steering.mjs +80 -0
  7. package/dist/axom-patch/cli/probes/qoder-steering.mjs +89 -0
  8. package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +49 -0
  9. package/dist/axom-patch/cli/qoder-background-terminal.md +81 -0
  10. package/dist/axom-patch/cli/qoder-foreground-output.md +41 -0
  11. package/dist/axom-patch/cli/qoder-steering.json +52 -0
  12. package/dist/axom-patch/cli/qoder-steering.md +30 -0
  13. package/dist/axom-patch/cli/qoder-subagent-origin.json +73 -0
  14. package/dist/axom-patch/cli/qoder-subagent-subwork.md +73 -0
  15. package/dist/axom-patch/cli/runtime.json +37 -0
  16. package/dist/axom-patch/cli/steering/claude-steering-js.patch +89 -0
  17. package/dist/axom-patch/cli/steering/claude-steering-types.patch +28 -0
  18. package/dist/axom-patch/cli/steering/codex-steering.patch +57 -0
  19. package/dist/axom-patch/cli/steering/pi-steering.js +79 -0
  20. package/dist/cli.js +9 -1
  21. package/dist/commands/auth.js +4 -13
  22. package/dist/commands/execute.js +4 -4
  23. package/dist/commands/record.js +6 -6
  24. package/dist/commands/struct.js +4 -4
  25. package/dist/commands/workstation.js +2 -2
  26. package/dist/credentials.js +2 -2
  27. package/dist/install/claude/commands.js +13 -13
  28. package/dist/mcp.js +32 -14
  29. package/dist/workstation/acp/acpAgents.js +447 -166
  30. package/dist/workstation/acp/acpClient.js +232 -18
  31. package/dist/workstation/acp/acpSession.js +502 -74
  32. package/dist/workstation/acp/activityBusy.js +43 -0
  33. package/dist/workstation/acp/activityReporting.js +54 -0
  34. package/dist/workstation/acp/adoption.js +107 -49
  35. package/dist/workstation/acp/authUrlSniffer.js +36 -0
  36. package/dist/workstation/acp/contract.js +11 -6
  37. package/dist/workstation/acp/delegationMcp.js +24 -0
  38. package/dist/workstation/acp/draftInterceptor.js +9 -0
  39. package/dist/workstation/acp/managedPatches.js +228 -0
  40. package/dist/workstation/acp/patchAssets.js +63 -0
  41. package/dist/workstation/acp/permission.js +300 -34
  42. package/dist/workstation/acp/qoderStartup.js +45 -0
  43. package/dist/workstation/acp/registry/catalog.js +263 -4
  44. package/dist/workstation/acp/registry/detect.js +55 -0
  45. package/dist/workstation/acp/sessionScope.js +19 -0
  46. package/dist/workstation/acp/sessionUpdateMapper.js +39 -21
  47. package/dist/workstation/acp/terminalLogin.js +197 -0
  48. package/dist/workstation/acp/test/fakeAgent.js +185 -9
  49. package/dist/workstation/acp/validate.js +33 -5
  50. package/dist/workstation/agentSessions.js +532 -44
  51. package/dist/workstation/channels/agent.js +7 -1
  52. package/dist/workstation/channels/computer.js +152 -4
  53. package/dist/workstation/channels/management.js +3 -3
  54. package/dist/workstation/config.js +3 -1
  55. package/dist/workstation/connection/encryptedChannel.js +88 -21
  56. package/dist/workstation/daemon.js +25 -3
  57. package/dist/workstation/desktop/binding.js +36 -0
  58. package/dist/workstation/desktop/bridge.js +212 -0
  59. package/dist/workstation/desktop/flow-control.js +52 -0
  60. package/dist/workstation/desktop/focused-text.js +130 -0
  61. package/dist/workstation/desktop/protocol.js +35 -0
  62. package/dist/workstation/fileOps.js +277 -0
  63. package/dist/workstation/filePaths.js +174 -0
  64. package/dist/workstation/fileWorker.js +81 -0
  65. package/dist/workstation/fileWorkerClient.js +121 -0
  66. package/dist/workstation/oauth/deviceAuthorization.js +21 -12
  67. package/dist/workstation/protocol/agent.js +154 -5
  68. package/dist/workstation/protocol/common.js +6 -0
  69. package/dist/workstation/protocol/computer.js +92 -3
  70. package/dist/workstation/qoderAdapter/backgroundProcess.js +106 -0
  71. package/dist/workstation/qoderAdapter/backgroundTerminal.js +262 -0
  72. package/dist/workstation/qoderAdapter/completedContinuation.js +139 -0
  73. package/dist/workstation/qoderAdapter/contract.js +78 -0
  74. package/dist/workstation/qoderAdapter/foregroundOutput.js +30 -0
  75. package/dist/workstation/qoderAdapter/history.js +87 -0
  76. package/dist/workstation/qoderAdapter/identityIndex.js +191 -0
  77. package/dist/workstation/qoderAdapter/inputQueue.js +42 -0
  78. package/dist/workstation/qoderAdapter/main.js +93 -0
  79. package/dist/workstation/qoderAdapter/mapper.js +117 -0
  80. package/dist/workstation/qoderAdapter/observerPlugin.js +245 -0
  81. package/dist/workstation/qoderAdapter/observerReceiver.js +187 -0
  82. package/dist/workstation/qoderAdapter/permissions.js +61 -0
  83. package/dist/workstation/qoderAdapter/profile.js +93 -0
  84. package/dist/workstation/qoderAdapter/sdkDriver.js +49 -0
  85. package/dist/workstation/qoderAdapter/session.js +319 -0
  86. package/dist/workstation/qoderAdapter/subagentMeta.js +28 -0
  87. package/dist/workstation/qoderAdapter/subwork.js +77 -0
  88. package/dist/workstation/qoderAdapter/taskControl.js +38 -0
  89. package/dist/workstation/qoderAdapter/taskEvent.js +26 -0
  90. package/dist/workstation/qoderAdapter/taskLedger.js +163 -0
  91. package/dist/workstation/qoderAdapter/types.js +1 -0
  92. package/dist/workstation/qoderAdapter/usage.js +37 -0
  93. package/dist/workstation/register.js +4 -0
  94. package/dist/workstation/tools/browser.js +394 -0
  95. package/dist/workstation/tools/client.js +269 -0
  96. package/dist/workstation/tools/core.js +50 -0
  97. package/dist/workstation/tools/files.js +525 -0
  98. package/dist/workstation/tools/keeperProcess.js +114 -0
  99. package/dist/workstation/tools/terminal.js +214 -0
  100. package/dist/workstation/tools/validation.js +50 -0
  101. package/dist/workstation/update/coordinator.js +29 -0
  102. package/dist/workstation/update/npmInstaller.js +2 -0
  103. package/dist/workstation/update/ptyPreflight.js +24 -0
  104. package/dist/workstation/update/taskRegistry.js +44 -20
  105. package/dist/workstation/update/watchdog.js +66 -0
  106. package/package.json +14 -4
  107. package/skills/axom/SKILL.md +55 -17
  108. package/skills/axom-definition/SKILL.md +32 -32
  109. package/skills/axom-platform/SKILL.md +39 -39
package/README.md CHANGED
@@ -61,4 +61,4 @@ Non-secret CLI auth state is stored in `~/.axom/config.json` with mode `0600`. T
61
61
 
62
62
  ## Learn more
63
63
 
64
- This README is a front page, not the manual. For the full command reference (arguments, exit codes, examples) and architecture notes, see `docs/modules/cli.md` and `guide/en/agent-integration/axom-cli/` in the Axomwork monorepo this package is built from.
64
+ This README is a front page, not the manual. For the full command reference (arguments, exit codes, examples) and architecture notes, see `docs/guide/modules/cli.md` and `guide/en/agent-integration/axom-cli/` in the Axomwork monorepo this package is built from.
@@ -0,0 +1,19 @@
1
+ export const DEFAULT_CLI_SCOPES = ['record_query', 'record_write', 'execute', 'task', 'workstation'];
2
+ /**
3
+ * Requested by `axom auth` but left unchecked on the consent page (the server preselects only
4
+ * the client's defaults). app_studio and setting_platform are shown locked for non-admins.
5
+ */
6
+ export const OPTIONAL_CLI_SCOPES = ['app_studio', 'setting_user', 'setting_platform'];
7
+ export function cliRequestedScopes(cowork = false) {
8
+ return [...DEFAULT_CLI_SCOPES, ...OPTIONAL_CLI_SCOPES, ...(cowork ? ['cowork'] : [])];
9
+ }
10
+ export function grantedCliScopes(scope, requested) {
11
+ if (typeof scope !== 'string' || !scope.trim()) {
12
+ throw new Error('Server does not report granted scopes; upgrade the server before authorizing this CLI.');
13
+ }
14
+ const granted = [...new Set(scope.trim().split(/ +/))];
15
+ if (granted.some((value) => !requested.includes(value))) {
16
+ throw new Error('Server returned unsupported or unrequested scopes; upgrade the server and authorize again.');
17
+ }
18
+ return granted;
19
+ }
@@ -0,0 +1,110 @@
1
+ # CLI / adapter 补丁说明
2
+
3
+ ## 自动应用与交付
4
+
5
+ `runtime.json` 是随 CLI 发布的可信补丁策略,不从 agent 响应或网络下载补丁代码。
6
+ `npm run build` 编译 CLI 后自动复制本目录到 `dist/axom-patch/cli`;打包前检查内容一致。
7
+ Gradle 将带补丁的 npm 包、size/SHA-512 与自动分发标记封装为 Server 的 Client release。
8
+ Workstation Docker 同样携带这份策略;普通 CLI 安装/Server 下发更新不依赖 npm postinstall。
9
+
10
+ 覆盖与发布包补丁都用同一套版本条件选择生效范围,二选一,加载时校验:
11
+
12
+ | | 覆盖(读 registry 版本) | 发布包补丁(读被改的包版本) |
13
+ |---|---|---|
14
+ | when:只在列出的版本 | `when_registry_version: ["0.86.0"]` | `versions: ["0.84.0"]`;原文件哈希不符即报错 |
15
+ | until:低于某版本 | `until_registry_version: "0.86.1"`,registry 读不懂时照样生效 | `until_version: "0.86.1"`;原文件哈希不符的版本按上游启动并告警 |
16
+ | 都不写 | 一直生效,直到删除 | 不允许 |
17
+
18
+ when 用于「这个版本有问题、已有修复版」,registry 一换版本就不再生效;until 用于「等上游发某个版本」,
19
+ 钉住的版本必须排在界限之前。两种都不需要为让位再发 CLI。
20
+
21
+ 用户按正常界面安装/更新适配器即可:补充条目(Kimi Code、Command Code、ZCode)、分发源覆盖和启动环境变量通过 catalog 应用,
22
+ roster、Install、Update、启动统一读取同一来源。
23
+ 发布包代码补丁由 `managedPatches.ts` 在 Install/Update 和首次真实启动时应用。
24
+ 历史安装也会补齐,配置显式 command 的用户自有启动程序按其配置执行。
25
+
26
+ 发布包补丁步骤:
27
+
28
+ 1. 按包名与精确版本选择已验证补丁;不支持的版本按上游启动,并在维护日志提示复核。
29
+ 2. 缓存缺包时走现有 npm 安装机制;原文件身份与 SHA-256 不符合时安装/启动失败,不能声称补丁已生效。
30
+ 3. 复制完整 adapter 及其依赖到 Axom 管理的临时目录,检查修改前/后哈希。
31
+ 4. 写 `axom-patches.json` 收据,原子发布副本,从包声明的入口启动。
32
+ 5. 缓存身份为「包名、版本、补丁内容哈希」;并发复用并保留各次启动参数/环境。
33
+
34
+ 副本位于 Workstation config dir 的 `acp-adapters/patched-npm/`。
35
+ npm 原缓存保持原样;已有旧副本保留给仍运行的会话。后续 CLI 移除配方后,新启动自动改走原发布包。
36
+ 用户无需运行 patch、修改 npm 缓存或单独配置补丁目录。
37
+
38
+ ## Agent catalog(`registry/catalog.ts`)
39
+
40
+ catalog 是 Axom 对官方 ACP registry 的本地扩展层,随 CLI 发布:条目在 catalog 里 = 真机验证过
41
+ (roster 的 `curated`),设置页展开区只放 catalog 条目,其余官方条目折叠在 "Unverified Agents"。
42
+ 它能做的事:静态推荐(`recommended`:claude、codex、pi)、补充/取代官方条目(`supplement` /
43
+ `supersedes`)、钉版本与启动环境(`distribution` / `launchEnv`,临时的放 `runtime.json`)、
44
+ 前置依赖(`prerequisite`)、本地检测(`detect`)。
45
+
46
+ **托管与检测分工**:Axom 启动的一律是自己托管、钉版本的那份(npx 缓存、`uv tool dir`、
47
+ `acp-adapters/` 下的二进制),roster 的 `adapterInstalled` 只说这一份。`detect` 只看用户自己的安装
48
+ (PATH 命令或固定文件,不起进程):检测到就报 `detected`,设置页因"In use on this machine"把它排进推荐位,
49
+ 但不算 Axom 已安装、也不拿来启动。前置依赖同样靠 `detect` 判断;缺了且没有 `install`
50
+ (ZCode)时 roster 报 `missingPrerequisite`,设置页不给 Install、只显示 catalog 的提示。
51
+
52
+ ## 当前补丁
53
+
54
+ | 台账 ID | 状态/应用入口 | AI 核验与下线重点 |
55
+ |---|---|---|
56
+ | kimi-code-distribution | active;catalog.ts 补充条目 `kimi-code`(Kimi Code 2.1.1 官方二进制,六平台 tar.gz + sha256),隐藏已归档的 `kimi` | registry#458 合入后补充条目自动让位、版本由 registry 跟进;验证 Install/Update、复用登录后删除 supplement |
57
+ | command-code-distribution | active;catalog.ts 补充条目 `command-code`(npx `command-code@1.74.3 acp`) | registry 收录后自动让位;确认 registry 版本带 `acp` 子命令后删除 supplement |
58
+ | zcode-acp-distribution | active;catalog.ts 补充条目 `zcode-acp`(npx `zcode-acp-server@0.63.0`),前置依赖 ZCode 本体 | registry#566/#650 合入后自动让位;删除 supplement 时保留 prerequisite |
59
+ | claude-plan-tools | active;启动任务清单工具变量 | 无变量真实 ACP 会话能发布 plan 后下线 |
60
+ | codex-plan-tools | active;CODEX_CONFIG 的 update_plan 开关 | 无覆盖真实 ACP 会话能发布 plan;保留显式用户配置优先级 |
61
+ | claude-async-task-preview | active;registry 为 0.86.0 时钉 0.86.1-preview.2(`when_registry_version`) | 上游 #1249:不再从前台 Bash 文本猜后台任务、已结束 child 的 async 终态照常送达。registry 换成其他版本即不生效,确认正式版含修复后删除本条 |
62
+ | claude-late-async-terminal | retired;0.84.0/0.85.0 配方已删除 | 修复由上游 #1249 带入预览版并被采用;探针在无补丁预览版 6/6 通过 |
63
+ | acp-sdk-draft-variants | active;draftInterceptor / draftSubagents | 发布 SDK 接收 subagent/async 变体后,验证真实通知流,再替换对应边界 |
64
+ | acp-sdk-foreign-session | active;draftInterceptor | SDK 能路由不是当前客户端主动打开的子会话;仅支持 draft 类型不足以下线 |
65
+ | acp-sdk-capability-fields | active;acpClient / draftSubagents | 无兼容桥 initialize 能保留 subagents,且 AIR vendor 能力协商仍有效 |
66
+ | qoder-subagent-origin / qoder-subagent-subwork | active;原生 Qoder 1.1.61 包补丁保留 `_meta.qoder`,适配器归一到 subwork | 无补丁版本的并发过程、终态、load/resume/重连全部通过后,删除包配方与边界;core/App 不加 Qoder 分支 |
67
+ | acp-registry-etarget | active;版本不存在时单次回退 | 上游版本可安装时自然不触发;删除整个策略要检查 registry 契约 |
68
+ | claude-steering | draft;steering/ 的两份旧版 diff | 0.76/0.77 草案未经完整运行时验证,不自动应用;重新测 E1–E3 |
69
+ | codex-steering | draft;steering/ 的旧版 diff | 1.10/1.11 草案不适用于当前新版,不自动应用;重新测 E1–E3 |
70
+
71
+ 每条完整路径、版本哈希、上游链接、删除条件与验证项见 [台账](../manifest.json)。
72
+ steering diff 原样来自历史 notes;维护本目录文件及台账哈希,历史归档保留实验上下文。
73
+
74
+ ## 验证
75
+
76
+ ```bash
77
+ cd axom-cli
78
+ npm run build
79
+ npx vitest run src/workstation/acp/patchAssets.test.ts src/workstation/acp/managedPatches.test.ts src/workstation/acp/registry/catalog.test.ts src/workstation/acp/acpAgents.test.ts
80
+ ```
81
+
82
+ Claude 的真实 runtime 回归(不启动 Claude 模型、不读取凭据):
83
+
84
+ ```bash
85
+ node axom-patch/cli/probes/claude-late-async-terminal.mjs /path/to/pristine/claude-agent-acp --expect-bug
86
+ node axom-patch/cli/probes/claude-late-async-terminal.mjs /path/to/fixed/claude-agent-acp # 例如原版 0.86.1-preview.2
87
+ ```
88
+
89
+ 覆盖 completed/failed/stopped × terminalState/terminalPromise 六种晚到终态,同时要求普通 late transcript 仍被过滤。
90
+ 下线时必须在**无补丁的新发布包**上跑第二条命令,不要用当前带补丁副本证明上游修复。
91
+
92
+ Pi 的发布包运行时探针(使用真实 adapter 类,不启动模型、不读取凭据):
93
+
94
+ ```bash
95
+ node axom-patch/cli/probes/pi-steering.mjs /path/to/pristine/pi-acp --expect-unsupported
96
+ node axom-patch/cli/probes/pi-steering.mjs /path/to/patched/pi-acp
97
+ ```
98
+
99
+ 仅已核验的原生 Pi 0.87.1 声明 `supported: true, timings: ["next"]`。其他原生版本不声明能力。
100
+ 运行中普通消息因此走既有 NEXT 自动投递;已排队消息仍可手动插话。当前工具先完成,再处理插话。
101
+ 空闲、取消中、压缩中返回 `promptRequired`,不提交原生消息。传输异常继续使用现有平台处置。
102
+ 队列插话、取消、出队共享独立互斥锁;成功回执后的历史物化和队列删除不可被请求取消打断。
103
+
104
+ ## AI 新增/下线操作
105
+
106
+ - 新增发布包补丁:提交本目录的 JSON 配方(包名、精确版本、每个文件的原/产物哈希与唯一替换段),
107
+ 加入 runtime.package_patches 和台账,跑原包/补丁对照、安装启动测试与 Client release 打包校验。
108
+ - 新增来源/环境覆盖:修改 runtime.overrides、台账和 catalog 回归,保留 registry/用户显式 env 优先级。
109
+ - 下线:先按统一证据门槛核验,从 runtime 移除策略或替换 SDK 边界,保留回归;台账标 retired 并记录证据。
110
+ - CLI 补丁变化必须 bump CLI npm version。相同版本不会触发 Server 自动升级,不能只改 Server 镜像 tag。
@@ -0,0 +1,37 @@
1
+ {
2
+ "id": "pi-steering",
3
+ "package": "pi-acp",
4
+ "versions": [
5
+ "0.0.34"
6
+ ],
7
+ "edits": [
8
+ {
9
+ "file": "dist/index.js",
10
+ "before_sha256": "324aeb8bba1228937e16b1326fb3e014e2a625b2dea549dbcd49b006cd5df6a2",
11
+ "after_sha256": "a4e110853ca8ebbd2ab02cb8d6851df42b606b3b1a68832b8e61a5f6d5611574",
12
+ "before": "var PiAcpSession = class {",
13
+ "after": "// Injected into pi-acp 0.0.34. Uses its existing RPC process and ACP prompt ownership.\n// Pi 0.87.1 accepts prompt(streamingBehavior=steer) before streaming events finish.\n// Holding the ACP prompt open also tracks a native continuation admitted at the idle boundary.\nclass AxomPiSteering {\n constructor(session) {\n this.session = session;\n this.tail = Promise.resolve();\n this.pending = 0;\n this.requested = false;\n this.finishing = false;\n this.settling = null;\n this.revision = 0;\n }\n steer(message, images) {\n const session = this.session;\n if (!session.pendingTurn || session.cancelRequested || this.finishing) {\n return Promise.resolve({ outcome: \"promptRequired\" });\n }\n const turn = session.pendingTurn;\n this.pending++;\n const operation = this.tail.then(async () => {\n if (session.pendingTurn !== turn || session.cancelRequested) return { outcome: \"promptRequired\" };\n const state = await session.proc.getState();\n if (state?.isStreaming !== true || state?.isCompacting === true) return { outcome: \"promptRequired\" };\n // No await between checking ACP ownership and starting the native request.\n if (session.pendingTurn !== turn || session.cancelRequested) return { outcome: \"promptRequired\" };\n const result = await session.proc.request({ type: \"prompt\", message, images, streamingBehavior: \"steer\" });\n if (!result.success) return { outcome: \"promptRequired\" };\n return { outcome: \"injected\" };\n });\n this.tail = operation.catch(() => {});\n return operation.finally(() => {\n this.pending--;\n if (!this.pending && this.requested) this.settle();\n });\n }\n settle() {\n this.requested = true;\n this.revision++;\n if (this.pending || this.settling || this.finishing) return this.settling;\n const session = this.session;\n const turn = session.pendingTurn;\n if (!turn) return;\n const revision = this.revision;\n this.settling = (async () => {\n const state = await session.proc.getState();\n // An old agent_settled notification may arrive during admission of a new native loop.\n if (this.pending || session.pendingTurn !== turn || state?.isStreaming === true || state?.isCompacting === true) return;\n this.finishing = true;\n this.requested = false;\n try { await session.axomFinishTurn(); }\n finally { this.finishing = false; }\n })().catch(error => {\n if (session.pendingTurn === turn) {\n session.pendingTurn = null;\n turn.reject(error);\n }\n }).finally(() => {\n this.settling = null;\n if (!this.pending && this.requested && this.revision !== revision) this.settle();\n });\n return this.settling;\n }\n}\n\nasync function axomPiSupportsSteering() {\n const command = getPiCommand(process.env.PI_ACP_PI_COMMAND);\n const launch = shouldUseShellForPiCommand(command) ? crossSpawn : spawn;\n return new Promise(resolve => {\n let output = \"\";\n let child;\n try { child = launch(command, [\"--version\"], { stdio: [\"ignore\", \"pipe\", \"ignore\"], env: process.env }); }\n catch { resolve(false); return; }\n const timeout = setTimeout(() => { child.kill(); resolve(false); }, 5000);\n child.stdout.on(\"data\", data => { output += data; });\n child.on(\"error\", () => { clearTimeout(timeout); resolve(false); });\n child.on(\"close\", code => { clearTimeout(timeout); resolve(code === 0 && output.trim() === \"0.87.1\"); });\n });\n}\n\nvar PiAcpSession = class {\n axomSteering = new AxomPiSteering(this);"
14
+ },
15
+ {
16
+ "file": "dist/index.js",
17
+ "before_sha256": "a4e110853ca8ebbd2ab02cb8d6851df42b606b3b1a68832b8e61a5f6d5611574",
18
+ "after_sha256": "4cc9687bd28fda78978b91976d7e9ce968d30e7385be17780d40787be684861e",
19
+ "before": " async settleTurn() {\n await this.publishContextUsage();",
20
+ "after": " async settleTurn() {\n return this.axomSteering.settle();\n }\n async axomFinishTurn() {\n await this.publishContextUsage();"
21
+ },
22
+ {
23
+ "file": "dist/index.js",
24
+ "before_sha256": "4cc9687bd28fda78978b91976d7e9ce968d30e7385be17780d40787be684861e",
25
+ "after_sha256": "9257e9ffd319f7605fbac8dab11ba8e83bb0975345f0eb62cec6cf236a58daa0",
26
+ "before": " async initialize(params) {\n const supportedVersion = 1;",
27
+ "after": " axomSteeringSupported = false;\n async extMethod(method, params) {\n if (method !== \"_session/steering\" || !this.axomSteeringSupported) throw RequestError3.methodNotFound(method);\n if (!params || typeof params.sessionId !== \"string\" || !Array.isArray(params.prompt)) {\n throw RequestError3.invalidParams(undefined, \"Invalid steering request\");\n }\n const steering = params._meta?.steering;\n if (steering?.idleBehavior !== undefined && steering.idleBehavior !== \"promptRequired\") {\n throw RequestError3.invalidParams(undefined, \"Unsupported steering idleBehavior\");\n }\n if (steering?.priority !== undefined && steering.priority !== \"next\") {\n throw RequestError3.invalidParams(undefined, \"Pi steering accepts only next\");\n }\n if (params.prompt.some(block => !block || ![\"text\", \"image\", \"resource\", \"resource_link\"].includes(block.type))) {\n throw RequestError3.invalidParams(undefined, \"Unsupported steering content\");\n }\n const session = this.sessions.maybeGet(params.sessionId);\n if (!session) return { outcome: \"promptRequired\" };\n const { message, images } = promptToPiMessage(params.prompt);\n return session.axomSteering.steer(message, images);\n }\n async initialize(params) {\n this.axomSteeringSupported = await axomPiSupportsSteering();\n const supportedVersion = 1;"
28
+ },
29
+ {
30
+ "file": "dist/index.js",
31
+ "before_sha256": "9257e9ffd319f7605fbac8dab11ba8e83bb0975345f0eb62cec6cf236a58daa0",
32
+ "after_sha256": "1ee621825b7e02d74312336fc0f820046bd41fe26455143f6f6e5078a727fa79",
33
+ "before": " agentInfo: {\n name: pkg.name ?? \"pi-acp\",",
34
+ "after": " ...(this.axomSteeringSupported ? { _meta: { steering: { supported: true, timings: [\"next\"] } } } : {}),\n agentInfo: {\n name: pkg.name ?? \"pi-acp\","
35
+ }
36
+ ]
37
+ }
@@ -0,0 +1,34 @@
1
+ // Runs the actual installed adapter runtime classes, without launching Claude or accessing auth.
2
+ // node <probe> <package-directory> [--expect-bug]
3
+ import assert from 'node:assert/strict';
4
+ import path from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+
7
+ const directory = process.argv[2];
8
+ if (!directory) throw new Error('Supply the adapter package directory');
9
+ const { NativeSubagentRuntime } = await import(pathToFileURL(path.resolve(directory, 'dist/native-subagents.js')));
10
+ const { AsyncTaskRuntime } = await import(pathToFileURL(path.resolve(directory, 'dist/async-tasks.js')));
11
+ const expectBug = process.argv.includes('--expect-bug');
12
+ for (const state of ['completed', 'failed', 'stopped']) {
13
+ for (const closing of ['terminalState', 'terminalPromise']) {
14
+ const events = [];
15
+ const publish = async (notification) => { events.push(notification); };
16
+ const native = new NativeSubagentRuntime(true, 'root', {}, publish, { log() {} });
17
+ const child = { sessionId: 'child', parentSessionId: 'root' };
18
+ native.childByToolCall.set('call', child);
19
+ const tasks = new AsyncTaskRuntime(true, 'root', publish, { routeOf: (call) => native.routeOfToolCall(call) });
20
+ await tasks.taskBackgrounded({ task_id: 'task', tool_use_id: 'call', task_type: 'shell', description: 'background shell' });
21
+ assert.ok(events.some((event) => event.update.sessionUpdate === 'async_task_spawned'));
22
+ child[closing] = closing === 'terminalState' ? 'completed' : Promise.resolve();
23
+ await tasks.taskNotification('task', state, 'done');
24
+ const terminal = events.filter((event) => event.update.sessionUpdate === 'async_task_state_update');
25
+ assert.equal(terminal.length, expectBug ? 0 : 1, `${closing}/${state}`);
26
+ if (!expectBug) {
27
+ assert.equal(terminal[0].sessionId, 'child');
28
+ assert.equal(terminal[0].update.state, state);
29
+ }
30
+ const lateText = { sessionId: 'root', update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'late' } } };
31
+ assert.equal(native.routeOfToolCall('call')(lateText), null, 'ordinary late transcript must remain filtered');
32
+ }
33
+ }
34
+ console.log(expectBug ? 'Reproduced all 6 upstream late-terminal losses' : 'Passed all 6 late-terminal cases; ordinary late transcript stays filtered');
@@ -0,0 +1,80 @@
1
+ // Execute the real published adapter classes, without models or credentials.
2
+ // node <probe> <pi-acp-package-directory> [--expect-unsupported]
3
+ import assert from 'node:assert/strict';
4
+ import { readFile, writeFile, mkdtemp, rm, chmod } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import path from 'node:path';
7
+ import { pathToFileURL } from 'node:url';
8
+ import { createRequire } from 'node:module';
9
+ const directory = path.resolve(process.argv[2]);
10
+ const require = createRequire(path.join(directory, 'package.json'));
11
+ const scratch = await mkdtemp(path.join(tmpdir(), 'axom-pi-runtime-'));
12
+ const previousCommand = process.env.PI_ACP_PI_COMMAND;
13
+ try {
14
+ const original = await readFile(path.join(directory, 'dist/index.js'), 'utf8');
15
+ let source = original.slice(0, original.lastIndexOf('// src/index.ts')).replace(/^#![^\n]*\n/, '');
16
+ source = source.replace(/from "([^"\n]+)"/g, (whole, specifier) => specifier.startsWith('@') || specifier === 'cross-spawn'
17
+ ? `from ${JSON.stringify(pathToFileURL(require.resolve(specifier)).href)}` : whole);
18
+ source += '\nexport { PiAcpAgent, PiAcpSession };\n';
19
+ await writeFile(path.join(scratch, 'package.json'), await readFile(path.join(directory, 'package.json')));
20
+ await writeFile(path.join(scratch, 'runtime.mjs'), source);
21
+ const { PiAcpAgent, PiAcpSession } = await import(pathToFileURL(path.join(scratch, 'runtime.mjs')));
22
+ const command = path.join(scratch, 'native-version');
23
+ await writeFile(command, '#!/bin/sh\nprintf "0.87.1\\n"\n');
24
+ await chmod(command, 0o700);
25
+ process.env.PI_ACP_PI_COMMAND = command;
26
+ const agent = Object.create(PiAcpAgent.prototype);
27
+ const initialized = await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
28
+ if (process.argv.includes('--expect-unsupported')) {
29
+ assert.equal(initialized._meta?.steering?.supported, undefined);
30
+ console.log('PASS pristine pi-acp has no steering extension');
31
+ } else {
32
+ assert.deepEqual(initialized._meta.steering, { supported: true, timings: ['next'] });
33
+ await writeFile(command, '#!/bin/sh\nprintf "0.70.6\\n"\n');
34
+ assert.equal((await agent.initialize({ protocolVersion: 1, clientCapabilities: {} }))._meta?.steering, undefined);
35
+ await writeFile(command, '#!/bin/sh\nprintf "0.87.1\\n"\n');
36
+ await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
37
+ console.log('PASS native version gating and next capability');
38
+ let streaming = false, nativeEvent, releaseReceipt;
39
+ const requests = [], updates = [];
40
+ const proc = {
41
+ onEvent(handler) { nativeEvent = handler; },
42
+ async getState() { return { isStreaming: streaming }; },
43
+ async prompt(message, images) { requests.push({ type: 'original', message, images }); streaming = true; },
44
+ async request(cmd) { requests.push(cmd); return new Promise(resolve => { releaseReceipt = resolve; }); },
45
+ };
46
+ const session = new PiAcpSession({ sessionId: 'fixture', cwd: scratch, mcpServers: [], proc,
47
+ conn: { async sessionUpdate(event) { updates.push(event); } } });
48
+ agent.sessions = { maybeGet(id) { return id === 'fixture' ? session : undefined; } };
49
+ const params = { sessionId: 'fixture', prompt: [{ type: 'text', text: 'inspect this' },
50
+ { type: 'image', mimeType: 'image/png', data: 'fixture-image' }], _meta: { steering: { idleBehavior: 'promptRequired', priority: 'next' } } };
51
+ assert.deepEqual(await agent.extMethod('_session/steering', params), { outcome: 'promptRequired' });
52
+ for (const priority of ['now', 'later']) {
53
+ await assert.rejects(agent.extMethod('_session/steering', { ...params, _meta: { steering: { priority } } }));
54
+ }
55
+ assert.equal(requests.length, 0);
56
+ let finished = false;
57
+ const originalTurn = session.prompt('original').then(reason => { finished = true; return reason; });
58
+ const injected = agent.extMethod('_session/steering', params);
59
+ while (!releaseReceipt) await new Promise(resolve => setTimeout(resolve, 1));
60
+ assert.deepEqual(requests[1], { type: 'prompt', message: 'inspect this',
61
+ images: [{ type: 'image', mimeType: 'image/png', data: 'fixture-image' }], streamingBehavior: 'steer' });
62
+ nativeEvent({ type: 'agent_settled' });
63
+ await new Promise(resolve => setTimeout(resolve, 5));
64
+ assert.equal(finished, false);
65
+ releaseReceipt({ success: true });
66
+ assert.deepEqual(await injected, { outcome: 'injected' });
67
+ await new Promise(resolve => setTimeout(resolve, 5));
68
+ assert.equal(finished, false, 'native continuation remains owned by original ACP prompt');
69
+ streaming = false;
70
+ nativeEvent({ type: 'agent_settled' });
71
+ assert.equal(await originalTurn, 'end_turn');
72
+ assert.equal(requests.length, 2, 'exactly one native submission for the inserted message');
73
+ assert.equal(updates.filter(event => event.update?._meta?.piAcp?.running === false).length, 1);
74
+ console.log('PASS real adapter content conversion, idle refusal, priority validation and tracked continuation');
75
+ }
76
+ } finally {
77
+ if (previousCommand === undefined) delete process.env.PI_ACP_PI_COMMAND;
78
+ else process.env.PI_ACP_PI_COMMAND = previousCommand;
79
+ await rm(scratch, { recursive: true, force: true });
80
+ }
@@ -0,0 +1,89 @@
1
+ // Exercise the real native ACP methods, without model calls or credentials.
2
+ import assert from 'node:assert/strict';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+
7
+ export async function verifyQoderSteering(bundle) {
8
+ const slice = (start, end) => {
9
+ const from = bundle.indexOf(start), to = bundle.indexOf(end, from);
10
+ assert(from >= 0 && to > from, `Native Qoder lacks ${start}`);
11
+ return bundle.slice(from, to);
12
+ };
13
+ class RequestError extends Error {
14
+ static invalidParams(_data, message) { return new this(message); }
15
+ }
16
+ const methods = slice('axomQoderSteer(A){', 'async axomQoderPromptOnce(A,e,t,i,n){');
17
+ const Session = new Function('as', 'mte', `return class {
18
+ axomQoderSteeringMessages=[];active=true;disposed=false;promptRunning=true;
19
+ pendingPrompt=new AbortController;queryEngine={activeAbortController:{},abort:()=>{this.aborts++}};aborts=0;
20
+ ${methods}
21
+ }`)(RequestError, () => 'generated-id');
22
+ const request = text => ({ prompt: [{ type: 'text', text }],
23
+ _meta: { steering: { idleBehavior: 'promptRequired', priority: 'now' } } });
24
+ const session = new Session();
25
+ let finishFirst, finishSecond;
26
+ const inputs = [];
27
+ session.axomQoderPromptOnce = async (prompt, signal) => {
28
+ inputs.push(prompt);
29
+ assert.equal(signal, session.pendingPrompt.signal);
30
+ return new Promise(resolve => {
31
+ if (inputs.length === 1) finishFirst = resolve; else finishSecond = resolve;
32
+ });
33
+ };
34
+ let completed = false;
35
+ const pending = session.promptViaQueryEngine([{ type: 'text', text: 'original' }],
36
+ session.pendingPrompt.signal, 'prompt-id', 'user-id').then(value => { completed = true; return value; });
37
+ assert.deepEqual(session.axomQoderSteer(request('changed')), { outcome: 'injected' });
38
+ assert.equal(session.aborts, 1);
39
+ assert.equal(session.pendingPrompt.signal.aborted, false, 'steering must not cancel the host prompt');
40
+ finishFirst({ stopReason: 'cancelled' });
41
+ await new Promise(resolve => setImmediate(resolve));
42
+ assert.equal(completed, false, 'the interrupted native result must not finish the host turn');
43
+ assert.deepEqual(inputs[1], [{ type: 'text', text: 'changed' }]);
44
+ finishSecond({ stopReason: 'end_turn' });
45
+ assert.equal((await pending).stopReason, 'end_turn');
46
+ assert.equal(session.axomQoderSteeringMessages.length, 0);
47
+
48
+ // Native promptRunning remains true for a microtask after its query iterator finishes.
49
+ // Accepting a message there would report delivery after the wrapper already stopped draining.
50
+ const finished = new Session(); finished.queryEngine.activeAbortController = undefined;
51
+ assert.deepEqual(finished.axomQoderSteer(request('arrived at completion')),
52
+ { outcome: 'promptRequired', reason: 'noRunningTurn' });
53
+ assert.equal(finished.axomQoderSteeringMessages.length, 0);
54
+
55
+ for (const key of ['active', 'promptRunning']) {
56
+ const idle = new Session(); idle[key] = false;
57
+ assert.deepEqual(idle.axomQoderSteer(request('idle')), { outcome: 'promptRequired', reason: 'noRunningTurn' });
58
+ assert.equal(idle.aborts, 0);
59
+ }
60
+ const invalid = new Session();
61
+ for (const value of [request(' '), request('x'.repeat(131073)),
62
+ { ...request('x'), prompt: [{ type: 'image', data: 'x', mimeType: 'image/png' }] },
63
+ { ...request('x'), _meta: { steering: { priority: 'next' } } }]) {
64
+ assert.throws(() => invalid.axomQoderSteer(value));
65
+ }
66
+ assert.equal(invalid.aborts, 0);
67
+ assert.equal(invalid.axomQoderSteeringMessages.length, 0);
68
+
69
+ const stopped = new Session();
70
+ stopped.axomQoderPromptOnce = async () => { stopped.pendingPrompt.abort(); return { stopReason: 'cancelled' }; };
71
+ stopped.axomQoderSteer(request('must not run after Stop'));
72
+ assert.equal((await stopped.promptViaQueryEngine([], stopped.pendingPrompt.signal, 'p', 'u')).stopReason, 'cancelled');
73
+ assert.equal(stopped.axomQoderSteeringMessages.length, 0);
74
+
75
+ const broken = new Session();
76
+ broken.axomQoderPromptOnce = async () => { throw new Error('real failure'); };
77
+ broken.axomQoderSteer(request('cannot hide a failure'));
78
+ await assert.rejects(broken.promptViaQueryEngine([], broken.pendingPrompt.signal, 'p', 'u'), /real failure/);
79
+ assert.equal(broken.axomQoderSteeringMessages.length, 0);
80
+ }
81
+
82
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
83
+ const directory = process.argv[2];
84
+ if (!directory) throw new Error('Usage: node qoder-steering.mjs <patched-qoder-package>');
85
+ const metadata = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8'));
86
+ assert.equal(metadata.name, '@qoder-ai/qodercli'); assert.equal(metadata.version, '1.1.61');
87
+ await verifyQoderSteering(await readFile(join(directory, 'bundle/qodercli.js'), 'utf8'));
88
+ console.log('Native Qoder steering: host-turn lifetime, interruption, idle, validation, Stop and failure checks passed');
89
+ }
@@ -0,0 +1,49 @@
1
+ // Run against the hash-verified patched official bundle. Exercises its actual ACP dispatch methods
2
+ // without starting a model or reading credentials. Real model acceptance is separate.
3
+ import { readFile } from 'node:fs/promises';
4
+ import assert from 'node:assert/strict';
5
+ import { join } from 'node:path';
6
+ const directory = process.argv[2];
7
+ if (!directory) throw new Error('Usage: node qoder-subagent-origin.mjs <patched-package-directory>');
8
+ const metadata = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8'));
9
+ assert.equal(metadata.name, '@qoder-ai/qodercli'); assert.equal(metadata.version, '1.1.61');
10
+ const bundle = await readFile(join(directory, 'bundle/qodercli.js'), 'utf8');
11
+ const method = (start, end) => {
12
+ const from = bundle.indexOf(start), to = bundle.indexOf(end, from);
13
+ assert(from >= 0 && to > from, `Missing real ACP method ${start}`);
14
+ return bundle.slice(from, to);
15
+ };
16
+ const helper = method('axomQoderId(value)', 'supportsTerminalOutput(){');
17
+ const dispatch = method('async dispatchSDKMessage(A)', 'createAcpSlashCommandHandler(){');
18
+ const Session = new Function('brr', 'kRs', 'PRs', 'xc', 'Qze', `return class {
19
+ axomQoderToolOrigins=new Map;toolCallNames=new Map;permissionToolCallIds=new Set;
20
+ constructor(connection){this.connection=connection;this.id='root';this.context={toolRegistry:{getTool:()=>({kind:'read'})},config:{isTasksEnabled:()=>false}}}
21
+ supportsTerminalOutput(){return false} async sendPlanUpdate(){}
22
+ ${helper} ${dispatch}
23
+ }`)(v => ({ toolCallId: v.toolCallId, kind: 'read', rawInput: v.input, status: v.status,
24
+ _meta: { qoder: { toolName: v.name } } }), () => null,
25
+ v => ({ toolCallId: v.toolCallId, status: v.isError ? 'failed' : 'completed', rawOutput: v.content }), 'Plan', new Set());
26
+ const events = [];
27
+ const session = new Session({ sessionUpdate: async value => events.push(value) });
28
+ const tool = (id, parent) => ({ type: 'assistant', parent_tool_use_id: parent,
29
+ message: { content: [{ type: 'tool_use', id, name: 'Read', input: { path: '/probe' } }] } });
30
+ await session.dispatchSDKMessage(tool('read-a', 'agent-a'));
31
+ await session.dispatchSDKMessage(tool('read-b', 'agent-b'));
32
+ await session.dispatchSDKMessage({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'read-b', content: 'B' }] } });
33
+ await session.dispatchSDKMessage({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'read-a', content: 'A' }] } });
34
+ for (const [id, parent] of [['read-a', 'agent-a'], ['read-b', 'agent-b']]) {
35
+ const owned = events.filter(v => v.update.toolCallId === id);
36
+ assert.equal(owned.length, 2);
37
+ for (const event of owned) assert.equal(event.update._meta.qoder.parentToolUseId, parent);
38
+ }
39
+ await session.dispatchSDKMessage({ type: 'stream_event', parent_tool_use_id: 'agent-b',
40
+ event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'child B' } } });
41
+ assert.equal(events.at(-1).update._meta.qoder.parentToolUseId, 'agent-b');
42
+ await session.dispatchSDKMessage({ type: 'stream_event', parent_tool_use_id: null,
43
+ event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'root' } } });
44
+ assert.equal(events.at(-1).update._meta, undefined);
45
+ await session.dispatchSDKMessage(tool('root-read', null));
46
+ assert.equal(events.at(-1).update._meta.qoder.parentToolUseId, undefined);
47
+ await session.dispatchSDKMessage(tool('read-a', 'wrong-agent'));
48
+ assert.equal(events.at(-1).update._meta.qoder.parentToolUseId, undefined);
49
+ console.log('Official Qoder 1.1.61 ACP dispatch: parallel tool/result, child text, root and conflict checks passed');
@@ -0,0 +1,81 @@
1
+ # qoder-background-terminal
2
+
3
+ ## 为什么需要
4
+
5
+ 测试环境 Qoder CLI 1.1.61 的后台 Agent 调用在 ACP 中只返回 `async_launched`。
6
+ 原生根会话 JSONL 随后收到 `origin.kind=task-notification` 的系统完成通知,但 live ACP
7
+ 和 `session/load` 均未提供可供平台确认任务终态的更新。因此任务实际完成,Activity 仍显示
8
+ Waiting / Task result unconfirmed。不是根据主 Agent 的自然语言回答判断完成。
9
+
10
+ ## 补丁边界
11
+
12
+ 实现位于 Workstation 的 ACP 会话边界;不修改 Qoder 二进制,不引入 SDK、数据库表或平台委派。
13
+ 仅 initialize 身份 `qoder-cli@1.1.61` 的真实托管会话启用,配置探测不启用。
14
+ 按进程有效 HOME、cwd、native session ID 定位 `~/.qoder/projects/<encoded-cwd>/<session>.jsonl`。
15
+ 只读当前根会话历史,逐行校验精确 session ID / cwd / 非 sidechain;不打开 `tasks/*.output`
16
+ 或其他子任务完整日志。路径中的叶子文件不跟随 symlink。
17
+
18
+ 同时满足以下三项,才沿原有 WireEvent / journal / seq / ack 发出同一 Agent 工具的
19
+ `rawOutput.kind=agent-result` 更新:
20
+
21
+ 1. ACP 已观测到该根会话的后台 Agent 调用,且工具调用返回;cached resume 不重放
22
+ opening 时,从精确匹配的根 JSONL Agent tool_use 与结构化启动回执恢复相同关系。
23
+ 2. 原生结构化启动回执的 tool-use-id 对应该调用,并提供 agentId。
24
+ 3. 原生系统 task-notification 的 tool-use-id 对应该调用,含明确终态及结果。
25
+
26
+ 原生 ACP 已提供结构化终态时优先使用它。普通用户文字、其他会话、未知版本/格式不能触发补回。
27
+ 不根据标题、消息顺序、主 Agent 回答或超时推测完成。成功、失败、取消按明确通知状态处理;
28
+ 失败/取消格式当前由确定性用例验证,真机覆盖以验收报告为准。
29
+
30
+ 每次最多增量读取 4 MiB,读取块 64 KiB,单行上限 2 MiB;最多保存 512 个关联,单个结果
31
+ 128 KiB,全部结果 4 MiB,超过限制维持原始状态。当前没有待确认调用时不读文件。
32
+ 关闭、连接 abort、读取循环终止即停止定时器。没有文件、格式未知或读取失败不阻塞 ACP。
33
+ 重复轮询和同一文件重读不重复发布;重建运行时可重新确认同一工具,但不会创建第二个任务。
34
+ 历史补回走正常会话恢复路径,不直接修改数据库。
35
+
36
+ 2026-10-03 真机验收补充:默认 Agent 的输入可不带 `subagent_type`。它只是可选的类型
37
+ 文案,不能作为识别任务或跟踪完成通知的必填条件。识别仍要求 Qoder 的工具元数据、
38
+ `kind=think`、prompt 和明确后台标记;非字符串类型仍拒绝。服务端的 Qoder 专用摘要
39
+ 投影同步允许该字段缺失,不推测子会话身份。升级验收必须覆盖默认 Agent(省略类型)
40
+ 和显式类型两种调用,避免测试只覆盖手工指定 `subagent_type` 的请求。
41
+
42
+ ## 上游升级后如何判断是否保留
43
+
44
+ 版本增加不会自动证明修复。新版本默认不启用此补丁,先使用无补丁 CLI 抓取原始 ACP:
45
+
46
+ - 后台成功、失败、取消均有可对应 toolCallId 的明确终态及结果;启动回执不是任务终态。
47
+ - 两个同时运行的后台任务乱序完成,状态和结果不会串任务。
48
+ - 主轮次结束后迟到的后台完成仍会发送。
49
+ - 重开旧会话的 session/load 能恢复相同终态,断线恢复不创建重复任务。
50
+
51
+ 全部通过后登记修复版本、原始抓包、无补丁回归和删除提交,再删除模块、调用接线及测试。
52
+ 只解决 live 而不解决 load 时不能整体退役。需要对新版本继续兼容时,先重新核验目录编码、
53
+ JSONL 字段及通知格式,再显式扩充版本 gate;不对未知版本自动尝试。
54
+
55
+ ## 本地回归
56
+
57
+ `npx vitest run src/workstation/qoderAdapter/backgroundTerminal.test.ts src/workstation/acp/acpSession.test.ts`
58
+ 以及 CLI 全量测试、typecheck/build/补丁台账校验。此次随 CLI 0.3.2(整合主干 0.3.1)交付,无 npm 发布操作。
59
+
60
+ ## 旧会话详情配套投影
61
+
62
+ 真实 session/load 把同一个 Qoder opening 再次导入为无 turnId 的记录,原调用有唯一 turnId。
63
+ 详情原先要求恰好一个 opening,所以即使终态补回也会报 identity ambiguous。
64
+ 配套 `qoder-native-replay-detail` 只在 UI 折叠一个有 turn 的原调用及其更晚、无 turn、
65
+ 同 owner/call/input/title/locations 和完整 Qoder metadata 的精确副本。数据原样保留,
66
+ 多个真实 opening、跨 turn 的重复创建、任何输入或 vendor identity 冲突、其他 provider 的 opening 副本继续拒绝。
67
+ 待平台历史导入不再制造重复 opening 且无例外回归通过,再删除这个单独的投影兼容分支。
68
+ 此配套修改需要构建并部署 Web / Server,与仅 CLI 的终态修复分开核验。
69
+
70
+ Cached resume 的补充规则与限制见 [qoder-foreground-output.md](qoder-foreground-output.md)。
71
+
72
+
73
+ ## 后台结果的到达轮次
74
+
75
+ 2026-10-04 真机复验:session/resume 后按旧 toolCallId 补回两个已完成后台结果,
76
+ 不产生重复 opening,但详情控制器曾把更新的到达轮次也当作另一份调用身份,报 ambiguous。
77
+ 配套通用详情校验仅允许完整历史中唯一、明确 run_in_background=true 的原调用接收更晚更新;
78
+ 更新须同 owner/call/name,kind 不冲突、输入缺省或与原输入完全相等。
79
+ 真实重复 opening、前台跨轮复用、修改输入、未知/截断身份与更新早于创建仍拒绝。
80
+ 不新增厂商分支,不改任何 provider 的路由或执行控制。新增两个红绿功能测试并真机检查详情。
81
+ 这是后台异步结果的正常生命周期校验,独立于 Qoder 补丁,不应随上游补丁退役一并删除。
@@ -0,0 +1,41 @@
1
+ # qoder-foreground-output / qoder-cached-resume
2
+
3
+ ## 为什么需要
4
+
5
+ 2026-10-03 原生 Qoder CLI 1.1.61 抓包证实:两个前台 Agent 同时运行时,子任务
6
+ 文字/思考全部以根 sessionId 发送,没有 messageId 或归属 metadata。字符串在 ACP
7
+ 上线已交错,不能按标题、完成顺序或时间可靠拆回。每个 Agent 的工具最终结果却完整。
8
+ 另一个独立问题是 session/load 可能迟到重放历史,平台把它写到新轮次,制造重复 opening。
9
+ 本补丁不恢复暂存表,也不把未知来源的文字猜成子会话。
10
+
11
+ ## 行为与限制
12
+
13
+ 只启用 initialize 明确报告 qoder-cli@1.1.61 的进程;Claude、Codex、未知版本原样通过。
14
+ 实现位于 qoderAdapter/foregroundOutput.ts,ACP 层只接线。
15
+
16
+ - 根会话明确发出 Agent 前台调用(background=false 或省略)至所有前台调用终止期间,
17
+ 不投递没有 messageId/metadata 的根文字和思考。工具调用、命令、完整工具结果、权限、
18
+ 配置和有身份的子会话事件原样保留。任务完成后的主回答正常展示。
19
+ - 这是结果优先的降级:无法辨别的实时文字(包括此时可能出现的主 Agent 插话)不会展示;
20
+ **不宣称恢复完整子任务过程,也不宣称能独立操作这些摘要任务的 Stop/Resume**。
21
+ 出错、取消、轮次结束会解除过滤;不会因 unfinished Agent 永久屏蔽下一轮。
22
+ - 已有平台历史的普通 reconnect 使用官方 session/resume,保留同一个 native sessionId,
23
+ 模型/权限配置来自恢复响应。拒绝恢复时直接报错,不偷偷新建另一段会话。
24
+ - 真正历史接管/backfill、显式 settings load、无 resume 能力继续旧路径。本补丁不以模糊
25
+ 文本去重掩盖这些路径的迟到历史;没有证据声称所有 session/load 都已彻底解决。
26
+ - resume 不回放 opening,故 backgroundTerminalRepair 可从只读根 JSONL 恢复精确后台
27
+ Agent 输入与启动关系,继续按 tool-use-id 补回明确通知。只发送同一个工具的终态更新,
28
+ 不发送历史 opening、不生成子会话。既有通知可能重新确认相同任务;不是重复执行任务。
29
+ 初次增量扫描完成后,没有待确认任务即停止文件轮询读操作。原有读限制/身份校验仍适用。
30
+
31
+ ## 回归与退役
32
+
33
+ qoderForegroundBaseline.test.ts 使用真实 ACP transport、原始交错抓包和三前台任务边界;
34
+ 覆盖省略类型、失败/取消后续轮、完整结果、主回答、已标识文字、background 不过滤。
35
+ acpAgents.test.ts 覆盖 cached resume / 真正 backfill / settings load 与其他 provider 边界;
36
+ backgroundTerminal.test.ts 覆盖无 opening 回放的后台精确结果恢复。
37
+
38
+ 上游升级后默认不启用。先裸 ACP 测并行前台长文字:必须提供稳定的来源/session/message
39
+ 标识,且终态与实际结果正确;再测 reconnect 不产生旧 opening,backfill 仍能取全历史,
40
+ 迟到后台通知仍能补全。通过原始抓包、无补丁回归及平台真机后记录版本再删除补丁。
41
+ 仅增加 CLI 版本号或最终任务成功不能证明可以退役。
@@ -0,0 +1,52 @@
1
+ {
2
+ "id": "qoder-steering",
3
+ "package": "@qoder-ai/qodercli",
4
+ "versions": [
5
+ "1.1.61"
6
+ ],
7
+ "bin": "qodercli",
8
+ "edits": [
9
+ {
10
+ "file": "bundle/qodercli.js",
11
+ "before_sha256": "88829cdc00f1d435d6b9fb4cd8088d678731a99e8c414cb02159a770ddedc32d",
12
+ "after_sha256": "b2401c8cae2370a486eae2d14f0d8a39826887886f558982b73ba2c839a9d9a3",
13
+ "before": "protocolVersion:xbs,authMethods:e,agentInfo:{name:HQ,title:Hn,version:t},agentCapabilities:",
14
+ "after": "protocolVersion:xbs,authMethods:e,agentInfo:{name:HQ,title:Hn,version:t},_meta:{steering:{supported:true,timings:[\"now\"]}},agentCapabilities:"
15
+ },
16
+ {
17
+ "file": "bundle/qodercli.js",
18
+ "before_sha256": "b2401c8cae2370a486eae2d14f0d8a39826887886f558982b73ba2c839a9d9a3",
19
+ "after_sha256": "d2725132d0ff3bb9a234f7d3a782e463bc6c2637a9e995569c5702ce50130dd4",
20
+ "before": "async prompt(A){await this.ensureAuthenticated();let e=this.sessions.get(A.sessionId);",
21
+ "after": "async extMethod(method,params){\n if(method !== \"_session/steering\") throw as.methodNotFound(method);\n await this.ensureAuthenticated();\n if(!params || typeof params.sessionId !== \"string\" || !params.sessionId) throw as.invalidParams(undefined,\"Missing sessionId\");\n const session=this.sessions.get(params.sessionId);\n if(!session) throw as.invalidParams(undefined,\"Unknown sessionId\");\n return session.axomQoderSteer(params);\n}\nasync prompt(A){await this.ensureAuthenticated();let e=this.sessions.get(A.sessionId);"
22
+ },
23
+ {
24
+ "file": "bundle/qodercli.js",
25
+ "before_sha256": "d2725132d0ff3bb9a234f7d3a782e463bc6c2637a9e995569c5702ce50130dd4",
26
+ "after_sha256": "c3fc2dae248e7e9105417fabe289b52609daea7512ca770e4dc9cbd3d2cfdb61",
27
+ "before": "promptRunning=!1;pendingMessages=[];",
28
+ "after": "promptRunning=!1;pendingMessages=[];axomQoderSteeringMessages=[];"
29
+ },
30
+ {
31
+ "file": "bundle/qodercli.js",
32
+ "before_sha256": "c3fc2dae248e7e9105417fabe289b52609daea7512ca770e4dc9cbd3d2cfdb61",
33
+ "after_sha256": "88dd0b3900897e3d9737ac63e5261711aa7aa277e4ca4d38ee59bdb987584eb5",
34
+ "before": "async promptViaQueryEngine(A,e,t,i,n){",
35
+ "after": "axomQoderSteer(A){\n const steering=A._meta?.steering;\n if(steering?.priority !== undefined && steering.priority !== \"now\") throw as.invalidParams(undefined,\"Qoder supports only now steering\");\n if(steering?.idleBehavior !== undefined && steering.idleBehavior !== \"promptRequired\") throw as.invalidParams(undefined,\"Unsupported steering idleBehavior\");\n if(!Array.isArray(A.prompt) || !A.prompt.length || A.prompt.some(block=>!block || block.type !== \"text\" || typeof block.text !== \"string\")) throw as.invalidParams(undefined,\"Qoder steering requires text blocks\");\n const text=A.prompt.map(block=>block.text).join(\"\\n\");\n if(!text.trim() || text.length > 131072) throw as.invalidParams(undefined,\"Invalid steering text length\");\n if(this.disposed || !this.active || !this.promptRunning || !this.queryEngine?.activeAbortController || !this.pendingPrompt || this.pendingPrompt.signal.aborted) return {outcome:\"promptRequired\",reason:\"noRunningTurn\"};\n if(this.axomQoderSteeringMessages.length >= 32) throw as.invalidParams(undefined,\"Too many pending steering messages\");\n const message={prompt:A.prompt.map(block=>({type:\"text\",text:block.text})),id:mte()};\n this.axomQoderSteeringMessages.push(message);\n try{this.queryEngine.abort({source:\"sdk_interrupt\",origin:\"user\"});}\n catch(error){this.axomQoderSteeringMessages.pop();throw error;}\n return {outcome:\"injected\"};\n}\nasync promptViaQueryEngine(A,e,t,i,n){\n let result;\n try{\n result=await this.axomQoderPromptOnce(A,e,t,i,n);\n const originalId=result.userMessageId;\n while(!e.aborted && !this.disposed && this.active && this.axomQoderSteeringMessages.length){\n const message=this.axomQoderSteeringMessages.shift();\n const next=await this.axomQoderPromptOnce(message.prompt,e,mte(),message.id);\n if(result.usage && next.usage){\n const inputTokens=result.usage.inputTokens+next.usage.inputTokens;\n const outputTokens=result.usage.outputTokens+next.usage.outputTokens;\n next.usage={inputTokens,outputTokens,totalTokens:inputTokens+outputTokens};\n const models=new Map;\n for(const item of [...result._meta?.quota?.model_usage??[],...next._meta?.quota?.model_usage??[]]){\n const prior=models.get(item.model)??{input_tokens:0,output_tokens:0};\n models.set(item.model,{input_tokens:prior.input_tokens+item.token_count.input_tokens,output_tokens:prior.output_tokens+item.token_count.output_tokens});\n }\n next._meta={...next._meta,quota:{token_count:{input_tokens:inputTokens,output_tokens:outputTokens},model_usage:Array.from(models,([model,token_count])=>({model,token_count}))}};\n }\n result=next;\n }\n return {...result,...(originalId?{userMessageId:originalId}:{})};\n }finally{this.axomQoderSteeringMessages.length=0;}\n}\nasync axomQoderPromptOnce(A,e,t,i,n){"
36
+ },
37
+ {
38
+ "file": "bundle/qodercli.js",
39
+ "before_sha256": "88dd0b3900897e3d9737ac63e5261711aa7aa277e4ca4d38ee59bdb987584eb5",
40
+ "after_sha256": "e45ea5a46d85285a41739fe391fe83dae491e1de3ba1882452b34e5b1453fab4",
41
+ "before": "if(!A.includes(\"Operation aborted\"))throw new as(500,A);",
42
+ "after": "if(!A.includes(\"Operation aborted\")&&!(this.axomQoderSteeringMessages.length&&[\"aborted_tools\",\"aborted_streaming\"].includes(c.terminal_reason)))throw new as(500,A);"
43
+ },
44
+ {
45
+ "file": "bundle/qodercli.js",
46
+ "before_sha256": "e45ea5a46d85285a41739fe391fe83dae491e1de3ba1882452b34e5b1453fab4",
47
+ "after_sha256": "a776c9f78e10cb1231229efebf3364ba0259f40b43bb09de77e2a0fb890f7199",
48
+ "before": "else if(\"error_during_execution\"===A.subtype&&\"refusal\"!==A.stop_reason){let e=A.errors??[];",
49
+ "after": "else if(\"error_during_execution\"===A.subtype&&\"refusal\"!==A.stop_reason&&!(this.axomQoderSteeringMessages.length&&[\"aborted_tools\",\"aborted_streaming\"].includes(A.terminal_reason))){let e=A.errors??[];"
50
+ }
51
+ ]
52
+ }