@gajae-code/coding-agent 0.18.7 → 0.18.8

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 (279) hide show
  1. package/CHANGELOG.md +279 -1
  2. package/dist/types/async/job-manager.d.ts +1 -1
  3. package/dist/types/capability/mcp.d.ts +2 -0
  4. package/dist/types/capability/skill.d.ts +2 -0
  5. package/dist/types/capability/types.d.ts +2 -0
  6. package/dist/types/cli/public-command-errors.d.ts +3 -1
  7. package/dist/types/cli/setup-cli.d.ts +1 -1
  8. package/dist/types/commands/deep-interview.d.ts +3 -0
  9. package/dist/types/config/autorouting-tier-map.d.ts +348 -9
  10. package/dist/types/config/file-lock.d.ts +1 -0
  11. package/dist/types/config/model-bindings-applier.d.ts +2 -0
  12. package/dist/types/config/model-profile-activation.d.ts +11 -3
  13. package/dist/types/config/model-registry.d.ts +4 -9
  14. package/dist/types/config/skill-settings-defaults.d.ts +2 -0
  15. package/dist/types/cursor-delete-path.d.ts +1 -0
  16. package/dist/types/cursor-mcp-name.d.ts +8 -0
  17. package/dist/types/customization/inventory.d.ts +2 -0
  18. package/dist/types/debug/report-bundle-parent.d.ts +2 -0
  19. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +1 -0
  20. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/version-manager.d.ts +31 -0
  21. package/dist/types/defaults/gjc-skills.generated.d.ts +5 -6
  22. package/dist/types/discovery/helpers.d.ts +6 -1
  23. package/dist/types/export/html/css-color.d.ts +2 -0
  24. package/dist/types/export/html/template.generated.d.ts +1 -1
  25. package/dist/types/extensibility/extensions/types.d.ts +7 -0
  26. package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +14 -1
  27. package/dist/types/extensibility/plugins/marketplace/cache.d.ts +2 -0
  28. package/dist/types/extensibility/plugins/plugin-link-path.d.ts +2 -0
  29. package/dist/types/extensibility/runtime-skill-discovery.d.ts +3 -1
  30. package/dist/types/extensibility/skill-management.d.ts +3 -1
  31. package/dist/types/extensibility/skills.d.ts +2 -0
  32. package/dist/types/gjc-runtime/deep-interview-crystallize.d.ts +74 -0
  33. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +11 -0
  34. package/dist/types/gjc-runtime/gc-runtime.d.ts +5 -0
  35. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +0 -5
  36. package/dist/types/gjc-runtime/state-argv.d.ts +1 -1
  37. package/dist/types/gjc-runtime/state-migrations.d.ts +8 -0
  38. package/dist/types/gjc-runtime/state-runtime.d.ts +95 -0
  39. package/dist/types/gjc-runtime/state-writer.d.ts +11 -1
  40. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +8 -0
  41. package/dist/types/internal-urls/skill-path.d.ts +1 -0
  42. package/dist/types/lsp/workspace-path.d.ts +1 -0
  43. package/dist/types/modes/acp/acp-agent.d.ts +7 -0
  44. package/dist/types/modes/components/custom-editor.d.ts +6 -0
  45. package/dist/types/modes/components/customization/customization-dashboard.d.ts +1 -0
  46. package/dist/types/modes/components/extensions/state-manager.d.ts +4 -3
  47. package/dist/types/modes/controllers/input-controller.d.ts +9 -0
  48. package/dist/types/modes/interactive-mode.d.ts +1 -0
  49. package/dist/types/modes/utils/interrupt-ownership.d.ts +3 -0
  50. package/dist/types/modes/utils/keybinding-matchers.d.ts +1 -0
  51. package/dist/types/modes/utils/macos-option-forwarding.d.ts +17 -0
  52. package/dist/types/progress/collect-project-progress.d.ts +38 -0
  53. package/dist/types/progress/progress-contract.d.ts +154 -0
  54. package/dist/types/progress/progress-snapshot.d.ts +11 -0
  55. package/dist/types/progress/project-progress.d.ts +141 -0
  56. package/dist/types/progress/render-progress.d.ts +28 -0
  57. package/dist/types/runtime-mcp/oauth-public-url.d.ts +1 -0
  58. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +1 -0
  59. package/dist/types/runtime-mcp/smithery-registry.d.ts +24 -0
  60. package/dist/types/runtime-mcp/types.d.ts +6 -0
  61. package/dist/types/sdk/acp/adapter.d.ts +1 -1
  62. package/dist/types/sdk/broker/broker-exit.d.ts +6 -2
  63. package/dist/types/sdk/broker/broker.d.ts +18 -1
  64. package/dist/types/sdk/broker/session-index.d.ts +12 -6
  65. package/dist/types/sdk/bus/index.d.ts +12 -1
  66. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -1
  67. package/dist/types/sdk/client/client.d.ts +2 -0
  68. package/dist/types/sdk/host/query/handlers.d.ts +3 -0
  69. package/dist/types/sdk/index.d.ts +1 -0
  70. package/dist/types/sdk/router/session-router.d.ts +2 -0
  71. package/dist/types/sdk/session-list.d.ts +1 -1
  72. package/dist/types/sdk/session.d.ts +8 -0
  73. package/dist/types/session/agent-session.d.ts +38 -9
  74. package/dist/types/session/artifacts.d.ts +2 -2
  75. package/dist/types/session/internal/managed-gc-retirement-codec.d.ts +32 -0
  76. package/dist/types/session/internal/managed-session-scope.d.ts +79 -2
  77. package/dist/types/session/internal/managed-session-storage.d.ts +26 -1
  78. package/dist/types/session/internal/managed-task-owner-cleanup.d.ts +62 -0
  79. package/dist/types/session/internal/task-artifact-owner-access.d.ts +2 -0
  80. package/dist/types/session/internal/task-artifact-owner-transcript.d.ts +9 -1
  81. package/dist/types/session/session-manager.d.ts +31 -5
  82. package/dist/types/session/session-retirement.d.ts +31 -7
  83. package/dist/types/session/session-storage.d.ts +62 -2
  84. package/dist/types/session/task-artifact-owner-codec.d.ts +2 -0
  85. package/dist/types/setup/credential-auto-import.d.ts +1 -1
  86. package/dist/types/setup/credential-import.d.ts +6 -4
  87. package/dist/types/skill-state/active-state.d.ts +38 -0
  88. package/dist/types/slash-commands/helpers/progress-report.d.ts +8 -0
  89. package/dist/types/task/executor.d.ts +3 -0
  90. package/dist/types/tools/bash-skill-urls.d.ts +1 -1
  91. package/dist/types/tools/index.d.ts +3 -0
  92. package/dist/types/tools/python.d.ts +9 -1
  93. package/dist/types/tools/recipe/recipe-guard.d.ts +7 -0
  94. package/dist/types/tools/restricted-cwd.d.ts +1 -0
  95. package/dist/types/tools/todo-write.d.ts +6 -0
  96. package/dist/types/utils/tools-checksum.d.ts +7 -0
  97. package/package.json +7 -7
  98. package/scripts/generate-sdk-operation-inventory.ts +21 -0
  99. package/scripts/verify-managed-empty-stop.ts +28 -0
  100. package/src/async/job-manager.ts +228 -122
  101. package/src/capability/index.ts +17 -0
  102. package/src/capability/mcp.ts +2 -0
  103. package/src/capability/skill.ts +2 -0
  104. package/src/capability/types.ts +2 -0
  105. package/src/cli/auth-broker-cli.ts +1 -1
  106. package/src/cli/customize-doctor.ts +15 -2
  107. package/src/cli/public-command-errors.ts +21 -1
  108. package/src/cli/setup-cli.ts +4 -4
  109. package/src/cli/skills-cli.ts +5 -0
  110. package/src/commands/deep-interview.ts +47 -8
  111. package/src/commands/sdk.ts +28 -8
  112. package/src/config/autorouting-tier-map.ts +117 -7
  113. package/src/config/file-lock.ts +307 -68
  114. package/src/config/mcp-schema.json +4 -0
  115. package/src/config/model-bindings-applier.ts +20 -0
  116. package/src/config/model-profile-activation.ts +240 -46
  117. package/src/config/model-registry.ts +47 -153
  118. package/src/config/skill-settings-defaults.ts +7 -0
  119. package/src/coordinator-mcp/policy.ts +3 -2
  120. package/src/cursor-delete-path.ts +10 -0
  121. package/src/cursor-mcp-name.ts +15 -0
  122. package/src/cursor.ts +13 -2
  123. package/src/customization/inventory.ts +3 -0
  124. package/src/debug/report-bundle-parent.ts +22 -0
  125. package/src/debug/report-bundle.ts +3 -0
  126. package/src/debug/system-info.ts +13 -1
  127. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +67 -4
  128. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/version-manager.ts +80 -0
  129. package/src/defaults/gjc/skills/deep-interview/SKILL.md +105 -10
  130. package/src/defaults/gjc/skills/ralplan/SKILL.md +6 -4
  131. package/src/defaults/gjc-defaults.test.ts +18 -1
  132. package/src/defaults/gjc-defaults.ts +3 -0
  133. package/src/defaults/gjc-skills.generated.ts +58 -36
  134. package/src/discovery/agents.ts +6 -1
  135. package/src/discovery/builtin.ts +10 -0
  136. package/src/discovery/helpers.ts +159 -20
  137. package/src/discovery/mcp-json.ts +6 -0
  138. package/src/discovery/ssh.ts +1 -1
  139. package/src/discovery/substitute-plugin-root.ts +3 -1
  140. package/src/edit/file-read-cache.ts +1 -1
  141. package/src/eval/py/executor.ts +380 -120
  142. package/src/export/html/css-color.ts +9 -0
  143. package/src/export/html/index.ts +5 -4
  144. package/src/export/html/template.generated.ts +1 -1
  145. package/src/extensibility/extensions/runner.ts +4 -0
  146. package/src/extensibility/extensions/types.ts +7 -0
  147. package/src/extensibility/gjc-plugins/runtime-adapters.ts +73 -8
  148. package/src/extensibility/plugins/manager.ts +3 -2
  149. package/src/extensibility/plugins/marketplace/cache.ts +15 -0
  150. package/src/extensibility/plugins/marketplace/manager.ts +11 -3
  151. package/src/extensibility/plugins/plugin-link-path.ts +12 -0
  152. package/src/extensibility/runtime-skill-discovery.ts +20 -2
  153. package/src/extensibility/skill-management.ts +6 -2
  154. package/src/extensibility/skills.ts +6 -1
  155. package/src/gjc-runtime/deep-interview-crystallize.ts +1814 -0
  156. package/src/gjc-runtime/deep-interview-recorder.ts +20 -2
  157. package/src/gjc-runtime/deep-interview-runtime.ts +1415 -13
  158. package/src/gjc-runtime/deep-interview-stage.ts +98 -21
  159. package/src/gjc-runtime/gc-runtime.ts +206 -9
  160. package/src/gjc-runtime/launch-worktree.ts +14 -4
  161. package/src/gjc-runtime/ralplan-runtime.ts +447 -52
  162. package/src/gjc-runtime/state-argv.ts +2 -0
  163. package/src/gjc-runtime/state-migrations.ts +91 -29
  164. package/src/gjc-runtime/state-runtime.ts +3716 -135
  165. package/src/gjc-runtime/state-writer.ts +83 -8
  166. package/src/gjc-runtime/ultragoal-runtime.ts +16 -4
  167. package/src/gjc-runtime/workflow-manifest.generated.json +70 -42
  168. package/src/gjc-runtime/workflow-manifest.ts +54 -21
  169. package/src/harness-control-plane/operate.ts +12 -4
  170. package/src/harness-control-plane/owner.ts +5 -1
  171. package/src/hashline/parser.ts +48 -9
  172. package/src/hashline/recovery.ts +13 -4
  173. package/src/internal-urls/docs-index.generated.ts +18 -17
  174. package/src/internal-urls/skill-path.ts +19 -0
  175. package/src/internal-urls/skill-protocol.ts +2 -0
  176. package/src/lsp/edits.ts +6 -0
  177. package/src/lsp/workspace-path.ts +51 -0
  178. package/src/main.ts +195 -31
  179. package/src/modes/acp/acp-agent.ts +88 -4
  180. package/src/modes/components/custom-editor.ts +14 -0
  181. package/src/modes/components/customization/customization-dashboard.ts +12 -0
  182. package/src/modes/components/extensions/extension-dashboard.ts +2 -2
  183. package/src/modes/components/extensions/state-manager.ts +15 -5
  184. package/src/modes/components/model-selector.ts +1 -4
  185. package/src/modes/components/provider-onboarding-selector.ts +1 -1
  186. package/src/modes/components/status-line/segments.ts +4 -0
  187. package/src/modes/controllers/event-controller.ts +11 -4
  188. package/src/modes/controllers/input-controller.ts +74 -29
  189. package/src/modes/controllers/runtime-mcp-command-controller.ts +8 -3
  190. package/src/modes/controllers/selector-controller.ts +28 -15
  191. package/src/modes/interactive-mode.ts +4 -0
  192. package/src/modes/runtime-init.ts +4 -0
  193. package/src/modes/utils/abort-message.ts +3 -1
  194. package/src/modes/utils/interrupt-ownership.ts +12 -0
  195. package/src/modes/utils/keybinding-matchers.ts +4 -0
  196. package/src/modes/utils/macos-option-forwarding.ts +80 -0
  197. package/src/progress/collect-project-progress.ts +153 -0
  198. package/src/progress/progress-contract.ts +172 -0
  199. package/src/progress/progress-snapshot.ts +140 -0
  200. package/src/progress/project-progress.ts +446 -0
  201. package/src/progress/render-progress.ts +209 -0
  202. package/src/prompts/system/master-mode.md +1 -0
  203. package/src/prompts/tools/skill-discovery.md +1 -1
  204. package/src/runtime-mcp/config-writer.ts +10 -6
  205. package/src/runtime-mcp/config.ts +1 -0
  206. package/src/runtime-mcp/oauth-flow.ts +5 -0
  207. package/src/runtime-mcp/oauth-public-url.ts +8 -0
  208. package/src/runtime-mcp/plugin-network-boundary.ts +4 -0
  209. package/src/runtime-mcp/smithery-registry.ts +22 -3
  210. package/src/runtime-mcp/transports/http.ts +2 -2
  211. package/src/runtime-mcp/types.ts +6 -0
  212. package/src/sdk/acp/adapter.ts +93 -8
  213. package/src/sdk/broker/broker-exit.ts +52 -23
  214. package/src/sdk/broker/broker.ts +75 -6
  215. package/src/sdk/broker/ensure.ts +2 -0
  216. package/src/sdk/broker/lifecycle.ts +711 -66
  217. package/src/sdk/broker/session-index.ts +191 -81
  218. package/src/sdk/bus/chat-daemon-cli.ts +38 -1
  219. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  220. package/src/sdk/bus/index.ts +238 -43
  221. package/src/sdk/bus/telegram-daemon-contract.ts +6 -1
  222. package/src/sdk/cli/session-cli.ts +17 -3
  223. package/src/sdk/client/client.ts +28 -8
  224. package/src/sdk/host/query/handlers.ts +9 -1
  225. package/src/sdk/host/session-runtime.test.ts +2647 -294
  226. package/src/sdk/host/session-runtime.ts +426 -109
  227. package/src/sdk/host/surface-policy.ts +1 -0
  228. package/src/sdk/index.ts +8 -0
  229. package/src/sdk/lifecycle-session.ts +3 -1
  230. package/src/sdk/prompt-deadline-manager.ts +15 -0
  231. package/src/sdk/protocol/operation-inventory.generated.json +146 -0
  232. package/src/sdk/protocol/operation-registry.ts +9 -2
  233. package/src/sdk/router/session-router.ts +75 -12
  234. package/src/sdk/session-list.ts +1 -0
  235. package/src/sdk/session.ts +67 -38
  236. package/src/session/agent-session.ts +623 -224
  237. package/src/session/artifacts.ts +174 -70
  238. package/src/session/internal/managed-gc-retirement-codec.ts +319 -0
  239. package/src/session/internal/managed-session-scope.ts +3457 -462
  240. package/src/session/internal/managed-session-storage.ts +376 -61
  241. package/src/session/internal/managed-task-owner-cleanup.ts +473 -0
  242. package/src/session/internal/task-artifact-owner-access.ts +19 -3
  243. package/src/session/internal/task-artifact-owner-transcript.ts +547 -1
  244. package/src/session/session-manager.ts +939 -133
  245. package/src/session/session-retirement.ts +573 -14
  246. package/src/session/session-storage.ts +417 -14
  247. package/src/session/task-artifact-owner-codec.ts +71 -8
  248. package/src/session/task-artifact-owner-retirement.ts +21 -19
  249. package/src/setup/credential-auto-import.ts +12 -3
  250. package/src/setup/credential-import.ts +176 -5
  251. package/src/setup/paseo/announce.ts +6 -1
  252. package/src/skill-state/active-state.ts +132 -2
  253. package/src/slash-commands/builtin-registry.ts +39 -9
  254. package/src/slash-commands/helpers/progress-report.ts +23 -0
  255. package/src/stt/setup.ts +3 -5
  256. package/src/task/commands.ts +3 -1
  257. package/src/task/executor.ts +4 -0
  258. package/src/task/index.ts +6 -0
  259. package/src/tools/ask.ts +205 -6
  260. package/src/tools/ast-edit.ts +4 -0
  261. package/src/tools/bash-allowed-prefixes.ts +15 -0
  262. package/src/tools/bash-skill-urls.ts +56 -3
  263. package/src/tools/bash.ts +48 -79
  264. package/src/tools/descriptors.ts +5 -1
  265. package/src/tools/eval.ts +56 -36
  266. package/src/tools/image-gen.ts +1 -1
  267. package/src/tools/index.ts +3 -0
  268. package/src/tools/python.ts +187 -66
  269. package/src/tools/recipe/index.ts +9 -0
  270. package/src/tools/recipe/recipe-guard.ts +19 -0
  271. package/src/tools/restricted-cwd.ts +10 -0
  272. package/src/tools/skill-discovery.ts +5 -0
  273. package/src/tools/skill.ts +28 -1
  274. package/src/tools/todo-write.ts +10 -1
  275. package/src/tools/tool-catalog.generated.ts +1 -1
  276. package/src/tools/write.ts +1 -0
  277. package/src/utils/tools-checksum.ts +34 -0
  278. package/src/utils/tools-manager.ts +24 -6
  279. package/src/web/search/providers/codex.ts +3 -2
@@ -0,0 +1,19 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+
4
+ export async function realSkillPath(baseDir: string, targetPath: string): Promise<string> {
5
+ const realBase = await fs.realpath(baseDir);
6
+ let realTarget: string;
7
+ try {
8
+ realTarget = await fs.realpath(targetPath);
9
+ } catch (error) {
10
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
11
+ throw new Error(`File not found: ${targetPath}`);
12
+ }
13
+ throw error;
14
+ }
15
+ if (realTarget !== realBase && !realTarget.startsWith(realBase + path.sep)) {
16
+ throw new Error("Path traversal is not allowed");
17
+ }
18
+ return realTarget;
19
+ }
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import * as path from "node:path";
11
11
  import { getActiveSkills } from "../extensibility/skills";
12
+ import { realSkillPath } from "./skill-path";
12
13
  import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
13
14
 
14
15
  function getContentType(filePath: string): InternalResource["contentType"] {
@@ -70,6 +71,7 @@ export class SkillProtocolHandler implements ProtocolHandler {
70
71
  if (!resolvedPath.startsWith(resolvedBaseDir + path.sep) && resolvedPath !== resolvedBaseDir) {
71
72
  throw new Error("Path traversal is not allowed");
72
73
  }
74
+ targetPath = await realSkillPath(skill.baseDir, targetPath);
73
75
  } else {
74
76
  targetPath = skill.filePath;
75
77
  }
package/src/lsp/edits.ts CHANGED
@@ -13,6 +13,7 @@ import type {
13
13
  WorkspaceEdit,
14
14
  } from "./types";
15
15
  import { uriToFile } from "./utils";
16
+ import { assertInsideWorkspace } from "./workspace-path";
16
17
 
17
18
  // =============================================================================
18
19
  // Text Edit Application
@@ -133,6 +134,7 @@ export async function applyWorkspaceEdit(edit: WorkspaceEdit, cwd: string): Prom
133
134
  const textEditsByUri = flattenWorkspaceTextEdits(edit);
134
135
  for (const [uri, textEdits] of textEditsByUri) {
135
136
  const filePath = uriToFile(uri);
137
+ await assertInsideWorkspace(cwd, filePath);
136
138
  await applyTextEdits(filePath, textEdits);
137
139
  applied.push(`Applied ${textEdits.length} edit(s) to ${formatPathRelativeToCwd(filePath, cwd)}`);
138
140
  }
@@ -144,18 +146,22 @@ export async function applyWorkspaceEdit(edit: WorkspaceEdit, cwd: string): Prom
144
146
  if (change.kind === "create") {
145
147
  const createOp = change as CreateFile;
146
148
  const filePath = uriToFile(createOp.uri);
149
+ await assertInsideWorkspace(cwd, filePath);
147
150
  await Bun.write(filePath, "");
148
151
  applied.push(`Created ${formatPathRelativeToCwd(filePath, cwd)}`);
149
152
  } else if (change.kind === "rename") {
150
153
  const renameOp = change as RenameFile;
151
154
  const oldPath = uriToFile(renameOp.oldUri);
152
155
  const newPath = uriToFile(renameOp.newUri);
156
+ await assertInsideWorkspace(cwd, oldPath);
157
+ await assertInsideWorkspace(cwd, newPath);
153
158
  await fs.mkdir(path.dirname(newPath), { recursive: true });
154
159
  await fs.rename(oldPath, newPath);
155
160
  applied.push(`Renamed ${formatPathRelativeToCwd(oldPath, cwd)} → ${formatPathRelativeToCwd(newPath, cwd)}`);
156
161
  } else if (change.kind === "delete") {
157
162
  const deleteOp = change as DeleteFile;
158
163
  const filePath = uriToFile(deleteOp.uri);
164
+ await assertInsideWorkspace(cwd, filePath);
159
165
  await fs.rm(filePath, { recursive: true });
160
166
  applied.push(`Deleted ${formatPathRelativeToCwd(filePath, cwd)}`);
161
167
  }
@@ -0,0 +1,51 @@
1
+ import * as fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { ToolError } from "../tools/tool-errors";
4
+
5
+ function isEnoent(error: unknown): boolean {
6
+ return (error as NodeJS.ErrnoException).code === "ENOENT";
7
+ }
8
+
9
+ function escapes(root: string, candidate: string): boolean {
10
+ return candidate !== root && !candidate.startsWith(`${root}${path.sep}`);
11
+ }
12
+
13
+ /** Resolve a missing path through the nearest existing ancestor. */
14
+ async function canonicalize(filePath: string): Promise<string> {
15
+ try {
16
+ return await fs.realpath(filePath);
17
+ } catch (error) {
18
+ if (!isEnoent(error)) throw error;
19
+ }
20
+
21
+ const missing: string[] = [];
22
+ let cursor = filePath;
23
+ for (;;) {
24
+ let stat: Awaited<ReturnType<typeof fs.lstat>> | undefined;
25
+ try {
26
+ stat = await fs.lstat(cursor);
27
+ } catch (error) {
28
+ if (!isEnoent(error)) throw error;
29
+ }
30
+ if (stat) {
31
+ try {
32
+ return path.join(await fs.realpath(cursor), ...missing);
33
+ } catch (error) {
34
+ if (isEnoent(error)) throw new ToolError(`LSP edit escapes the workspace: ${filePath}`);
35
+ throw error;
36
+ }
37
+ }
38
+ const parent = path.dirname(cursor);
39
+ if (parent === cursor) throw new ToolError(`LSP edit escapes the workspace: ${filePath}`);
40
+ missing.unshift(path.basename(cursor));
41
+ cursor = parent;
42
+ }
43
+ }
44
+
45
+ export async function assertInsideWorkspace(cwd: string, filePath: string): Promise<void> {
46
+ const root = await fs.realpath(cwd);
47
+ const candidate = await canonicalize(filePath);
48
+ if (escapes(root, candidate)) {
49
+ throw new ToolError(`LSP edit escapes the workspace: ${filePath}`);
50
+ }
51
+ }
package/src/main.ts CHANGED
@@ -475,6 +475,11 @@ type StartupModelProfileArgs = {
475
475
  preferCachedDefaultProfile?: boolean;
476
476
  };
477
477
 
478
+ type StartupModelProfileFailure = {
479
+ profileName: string;
480
+ error: ModelProfileCredentialError | UnknownModelProfileError;
481
+ };
482
+
478
483
  function staleDefaultProfileMessage(error: UnknownModelProfileError, profileCatalogRefreshUnavailable = false): string {
479
484
  const refreshNotice = profileCatalogRefreshUnavailable
480
485
  ? " The online profile catalog could not be refreshed; continuing with the last accepted catalog."
@@ -488,6 +493,8 @@ async function applyStartupModelProfilesWithPolicy(
488
493
  onUnknownDefault?: (error: UnknownModelProfileError, profileCatalogRefreshUnavailable: boolean) => void,
489
494
  ): Promise<void> {
490
495
  let profileCatalogRefreshUnavailable = false;
496
+ let profilePassFailures: StartupModelProfileFailure[] = [];
497
+ let unhandledProfileName: string | undefined;
491
498
  const applyProfile = async (
492
499
  profileName: string,
493
500
  persistDefault: boolean,
@@ -496,37 +503,66 @@ async function applyStartupModelProfilesWithPolicy(
496
503
  tolerateCredentialError?: boolean;
497
504
  tolerateUnknownDefault?: boolean;
498
505
  runtimeBindingsOnly?: boolean;
506
+ userSelectionRevision?: number;
499
507
  } = {},
500
508
  ): Promise<boolean> => {
501
- try {
509
+ const isCurrent =
510
+ options.userSelectionRevision === undefined
511
+ ? undefined
512
+ : () => args.session.getUserModelSelectionRevision() === options.userSelectionRevision;
513
+ if (isCurrent && !isCurrent()) return false;
514
+ const apply = async (): Promise<boolean> => {
515
+ if (isCurrent && !isCurrent()) return false;
502
516
  if (options.runtimeBindingsOnly) {
503
- await applyModelProfileRuntimeBindings({
504
- session: args.session,
505
- modelRegistry: args.modelRegistry,
506
- settings: args.settings,
507
- profileName,
508
- });
517
+ await applyModelProfileRuntimeBindings(
518
+ {
519
+ session: args.session,
520
+ modelRegistry: args.modelRegistry,
521
+ settings: args.settings,
522
+ profileName,
523
+ },
524
+ isCurrent,
525
+ );
509
526
  } else {
510
527
  await activateModelProfile(
511
528
  { session: args.session, modelRegistry: args.modelRegistry, settings: args.settings, profileName },
512
- { persistDefault, thinkingLevelOverride: options.thinkingLevelOverride },
529
+ {
530
+ persistDefault,
531
+ thinkingLevelOverride: options.thinkingLevelOverride,
532
+ isCurrent,
533
+ },
513
534
  );
514
535
  }
515
536
  return true;
537
+ };
538
+ try {
539
+ return isCurrent ? await args.session.withSdkControlMutation(apply) : await apply();
516
540
  } catch (error) {
517
541
  if (error instanceof ModelProfileCredentialError && (onCredentialError || options.tolerateCredentialError)) {
518
- if (onCredentialError) onCredentialError(error);
519
- else process.stderr.write(`${chalk.yellow(`Warning: ${error.message}`)}\n`);
542
+ profilePassFailures.push({ profileName, error });
543
+ logger.warn("Startup model profile not applied: missing provider credentials", {
544
+ profile: profileName,
545
+ errorClass: error.name,
546
+ providers: error.providers,
547
+ role: error.role,
548
+ });
549
+ if (!onCredentialError) process.stderr.write(`${chalk.yellow(`Warning: ${error.message}`)}\n`);
520
550
  return false;
521
551
  }
522
552
  if (error instanceof UnknownModelProfileError && options.tolerateUnknownDefault) {
523
- if (onUnknownDefault) onUnknownDefault(error, profileCatalogRefreshUnavailable);
524
- else
553
+ profilePassFailures.push({ profileName, error });
554
+ logger.warn("Startup model profile not applied: unknown profile", {
555
+ profile: profileName,
556
+ errorClass: error.name,
557
+ catalogRefreshUnavailable: profileCatalogRefreshUnavailable,
558
+ });
559
+ if (!onUnknownDefault)
525
560
  process.stderr.write(
526
561
  `${chalk.yellow(`Warning: ${staleDefaultProfileMessage(error, profileCatalogRefreshUnavailable)}`)}\n`,
527
562
  );
528
563
  return false;
529
564
  }
565
+ unhandledProfileName = profileName;
530
566
  throw error;
531
567
  }
532
568
  };
@@ -546,9 +582,16 @@ async function applyStartupModelProfilesWithPolicy(
546
582
  const preferCachedProfiles =
547
583
  (args.preferCachedModels === true && args.parsedArgs.mpreset !== undefined) ||
548
584
  (args.preferCachedDefaultProfile === true && defaultProfile !== undefined);
549
- const applyConfiguredProfiles = async (allowMissingDefault: boolean): Promise<boolean> => {
585
+ let modelSelectionChangedDuringRecovery = false;
586
+ const applyConfiguredProfiles = async (
587
+ allowMissingDefault: boolean,
588
+ profileNames?: ReadonlySet<string>,
589
+ userSelectionRevision?: number,
590
+ ): Promise<boolean> => {
591
+ profilePassFailures = [];
592
+ unhandledProfileName = undefined;
550
593
  let applied = true;
551
- if (defaultProfile) {
594
+ if (defaultProfile && (!profileNames || profileNames.has(defaultProfile))) {
552
595
  applied =
553
596
  (await applyProfile(defaultProfile, false, {
554
597
  thinkingLevelOverride: args.settings.has("defaultThinkingLevel")
@@ -556,37 +599,141 @@ async function applyStartupModelProfilesWithPolicy(
556
599
  : undefined,
557
600
  tolerateCredentialError: tolerateDefaultProfileFailure,
558
601
  tolerateUnknownDefault:
559
- allowMissingDefault && (onUnknownDefault !== undefined || tolerateDefaultProfileFailure),
602
+ onUnknownDefault !== undefined || (allowMissingDefault && tolerateDefaultProfileFailure),
560
603
  runtimeBindingsOnly: args.session.hasRecoveredDefaultFallbackChain(),
604
+ userSelectionRevision,
561
605
  })) && applied;
562
606
  }
563
- if (args.parsedArgs.mpreset) {
564
- applied = (await applyProfile(args.parsedArgs.mpreset, args.parsedArgs.default === true)) && applied;
607
+ if (args.parsedArgs.mpreset && (!profileNames || profileNames.has(args.parsedArgs.mpreset))) {
608
+ applied =
609
+ (await applyProfile(args.parsedArgs.mpreset, args.parsedArgs.default === true, {
610
+ userSelectionRevision,
611
+ })) && applied;
565
612
  }
566
613
  return applied;
567
614
  };
615
+ const profilesFromFirstFailure = (failedProfiles: ReadonlySet<string>): ReadonlySet<string> => {
616
+ const orderedProfiles = [defaultProfile, args.parsedArgs.mpreset].filter(
617
+ (name): name is string => name !== undefined,
618
+ );
619
+ const firstFailureIndex = orderedProfiles.findIndex(name => failedProfiles.has(name));
620
+ return firstFailureIndex === -1 ? failedProfiles : new Set(orderedProfiles.slice(firstFailureIndex));
621
+ };
622
+ const applyFailedProfiles = async (
623
+ failedProfiles: ReadonlySet<string>,
624
+ userSelectionRevision: number,
625
+ ): Promise<boolean> => {
626
+ const applied = await applyConfiguredProfiles(true, failedProfiles, userSelectionRevision);
627
+ if (
628
+ applied &&
629
+ defaultProfile !== undefined &&
630
+ failedProfiles.has(defaultProfile) &&
631
+ args.parsedArgs.mpreset !== undefined &&
632
+ !failedProfiles.has(args.parsedArgs.mpreset)
633
+ ) {
634
+ return applyProfile(args.parsedArgs.mpreset, args.parsedArgs.default === true, { userSelectionRevision });
635
+ }
636
+ return applied;
637
+ };
638
+ const refreshAuthAndCatalog = async (continueOnRefreshError: boolean): Promise<void> => {
639
+ try {
640
+ await args.modelRegistry.authStorage?.reload();
641
+ } catch (error) {
642
+ logger.warn("Failed to reload auth before startup model profile recovery", {
643
+ error: error instanceof Error ? error.message : String(error),
644
+ });
645
+ }
646
+ try {
647
+ await args.modelRegistry.refreshModelPresetProfilesFromRegistry();
648
+ } catch (error) {
649
+ if (!continueOnRefreshError && args.modelRegistry.getError()) throw error;
650
+ profileCatalogRefreshUnavailable = true;
651
+ logger.warn("Failed to refresh model profile catalog before startup recovery", {
652
+ error: error instanceof Error ? error.message : String(error),
653
+ });
654
+ }
655
+ try {
656
+ await args.modelRegistry.refresh("online-if-uncached", args.session.credentialSessionId);
657
+ } catch (error) {
658
+ if (!continueOnRefreshError) throw error;
659
+ logger.warn("Failed to refresh model catalog before startup profile recovery", {
660
+ error: error instanceof Error ? error.message : String(error),
661
+ });
662
+ }
663
+ };
664
+ const reportFinalProfileFailures = (markDefaultUnavailable = true): void => {
665
+ for (const failure of profilePassFailures) {
666
+ if (failure.error instanceof ModelProfileCredentialError) {
667
+ onCredentialError?.(failure.error);
668
+ } else {
669
+ onUnknownDefault?.(failure.error, profileCatalogRefreshUnavailable);
670
+ }
671
+ }
672
+ if (
673
+ markDefaultUnavailable &&
674
+ (onCredentialError || onUnknownDefault) &&
675
+ args.parsedArgs.mpreset === undefined &&
676
+ args.parsedArgs.model === undefined
677
+ ) {
678
+ const defaultFailure = profilePassFailures.find(failure => failure.profileName === defaultProfile);
679
+ if (defaultFailure) args.session.setUnavailableModelProfile(defaultFailure.profileName);
680
+ }
681
+ };
568
682
 
683
+ // Deferred --mpreset startup can overlap selector input; never reapply its
684
+ // captured profile after the user has selected a newer model.
569
685
  if (preferCachedProfiles) {
570
686
  let applied: boolean;
571
687
  let refreshedOnline = false;
688
+ const selectionRevision = args.session.getUserModelSelectionRevision();
572
689
  try {
573
- applied = await applyConfiguredProfiles(false);
690
+ applied = await applyConfiguredProfiles(false, undefined, selectionRevision);
574
691
  } catch (error) {
575
692
  if (error instanceof ModelProfileCredentialError) throw error;
576
693
  if (error instanceof UnknownModelProfileError) {
577
- try {
578
- await args.modelRegistry.refreshModelPresetProfilesFromRegistry();
579
- } catch (refreshError) {
580
- if (args.modelRegistry.getError()) throw refreshError;
581
- profileCatalogRefreshUnavailable = true;
582
- logger.warn("Failed to refresh model profile catalog before startup recovery", {
583
- error: refreshError instanceof Error ? refreshError.message : String(refreshError),
584
- });
585
- }
694
+ await refreshAuthAndCatalog(onCredentialError !== undefined || onUnknownDefault !== undefined);
695
+ } else {
696
+ await args.modelRegistry.refresh("online-if-uncached", args.session.credentialSessionId);
586
697
  }
587
- await args.modelRegistry.refresh("online-if-uncached", args.session.credentialSessionId);
698
+ if (args.session.getUserModelSelectionRevision() !== selectionRevision) return;
588
699
  refreshedOnline = true;
589
- applied = await applyConfiguredProfiles(true);
700
+ const failedProfiles = new Set(profilePassFailures.map(failure => failure.profileName));
701
+ if (unhandledProfileName) failedProfiles.add(unhandledProfileName);
702
+ const profilesToRetry = profilesFromFirstFailure(failedProfiles);
703
+ applied = await applyConfiguredProfiles(
704
+ true,
705
+ profilesToRetry.size > 0 ? profilesToRetry : undefined,
706
+ selectionRevision,
707
+ );
708
+ if (args.session.getUserModelSelectionRevision() !== selectionRevision) return;
709
+ }
710
+ if (args.session.getUserModelSelectionRevision() !== selectionRevision) {
711
+ modelSelectionChangedDuringRecovery = true;
712
+ applied = false;
713
+ }
714
+ if (
715
+ !modelSelectionChangedDuringRecovery &&
716
+ !applied &&
717
+ profilePassFailures.length > 0 &&
718
+ (onCredentialError !== undefined || onUnknownDefault !== undefined)
719
+ ) {
720
+ if (!refreshedOnline) {
721
+ const failedProfiles = new Set(profilePassFailures.map(failure => failure.profileName));
722
+ await refreshAuthAndCatalog(true);
723
+ refreshedOnline = true;
724
+ if (args.session.getUserModelSelectionRevision() !== selectionRevision) {
725
+ modelSelectionChangedDuringRecovery = true;
726
+ applied = false;
727
+ } else {
728
+ applied = await applyFailedProfiles(failedProfiles, selectionRevision);
729
+ if (args.session.getUserModelSelectionRevision() !== selectionRevision) {
730
+ modelSelectionChangedDuringRecovery = true;
731
+ applied = false;
732
+ }
733
+ }
734
+ }
735
+ if (applied) args.session.setUnavailableModelProfile(undefined);
736
+ else reportFinalProfileFailures(!modelSelectionChangedDuringRecovery);
590
737
  }
591
738
  if (applied && !refreshedOnline)
592
739
  args.modelRegistry.refreshInBackground("online-if-uncached", args.session.credentialSessionId);
@@ -598,7 +745,7 @@ async function applyStartupModelProfilesWithPolicy(
598
745
  }
599
746
 
600
747
  // Explicit CLI --model/--thinking must win over any activated or skipped profile.
601
- if (explicitModel) {
748
+ if (explicitModel && !modelSelectionChangedDuringRecovery) {
602
749
  await args.session.setModelTemporary(explicitModel, args.startupThinkingLevel ?? args.parsedArgs.thinking, {
603
750
  persistAsSessionDefault: true,
604
751
  cause: "startup-override",
@@ -606,7 +753,7 @@ async function applyStartupModelProfilesWithPolicy(
606
753
  const selector = `${explicitModel.provider}/${explicitModel.id}`;
607
754
  args.session.setConfiguredModelChain("default", [selector], "startup-override", undefined, true);
608
755
  args.session.seedDefaultFallbackResolution(0, []);
609
- } else if (args.parsedArgs.thinking && args.session.model) {
756
+ } else if (!modelSelectionChangedDuringRecovery && args.parsedArgs.thinking && args.session.model) {
610
757
  await args.session.setModelTemporary(args.session.model, args.parsedArgs.thinking, { cause: "startup-override" });
611
758
  }
612
759
  }
@@ -975,6 +1122,21 @@ export async function runInteractiveMode(
975
1122
  );
976
1123
  }
977
1124
 
1125
+ // Startup input bypasses the editor submit path, so seed the automatic title
1126
+ // here. Only text that `prompt` actually dispatches to a loaded skill is
1127
+ // skipped; unknown `/skill:` text is submitted as an ordinary prompt and is
1128
+ // titled like one. It must run before the prompt (the title gate skips
1129
+ // sessions that already hold a user message), and a title failure must never
1130
+ // drop the prompt.
1131
+ const maybeGenerateStartupTitle = (text: string, images?: readonly unknown[]): void => {
1132
+ try {
1133
+ if (session.resolvePromptSkillInvocation(text, images) || session.isLocallyHandledSlashCommand(text)) return;
1134
+ mode.maybeGenerateSessionTitle(text);
1135
+ } catch (error: unknown) {
1136
+ logger.warn("Startup session title generation failed", { error: String(error) });
1137
+ }
1138
+ };
1139
+
978
1140
  const runStartupInputAndPromptLoop = async (): Promise<never> => {
979
1141
  const hasStartupInput = initialMessage !== undefined || initialMessages.length > 0;
980
1142
  if (!hasStartupInput && resumeAction === "continue-tail") {
@@ -988,6 +1150,7 @@ export async function runInteractiveMode(
988
1150
 
989
1151
  if (initialMessage !== undefined) {
990
1152
  try {
1153
+ maybeGenerateStartupTitle(initialMessage, initialImages);
991
1154
  await session.prompt(initialMessage, { images: initialImages });
992
1155
  } catch (error: unknown) {
993
1156
  const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
@@ -1004,6 +1167,7 @@ export async function runInteractiveMode(
1004
1167
  });
1005
1168
  if (slashResult === true) continue;
1006
1169
  if (typeof slashResult === "string") text = slashResult;
1170
+ maybeGenerateStartupTitle(text);
1007
1171
  await session.prompt(text);
1008
1172
  } catch (error: unknown) {
1009
1173
  const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
@@ -38,6 +38,8 @@ import {
38
38
  } from "@agentclientprotocol/sdk";
39
39
  import { getAgentDir, logger, resolveEquivalentPath } from "@gajae-code/utils";
40
40
  import packageJson from "../../../package.json" with { type: "json" };
41
+ import { PROJECT_PROGRESS_SNAPSHOT_SCHEMA, type ProjectProgressSnapshot } from "../../progress/progress-contract";
42
+ import { PROGRESS_COMMAND_ACP_DESCRIPTION, renderProgressSnapshot } from "../../progress/render-progress";
41
43
  import {
42
44
  ACP_SESSION_RECONNECT,
43
45
  type AcpProviderRegistration,
@@ -258,6 +260,8 @@ type PendingAttachment = { epoch: number; task: Promise<void> };
258
260
 
259
261
  type PromptAdmissionSettlement = { kind: "cancelled" } | { kind: "rejected"; error: AcpSdkAdapterError };
260
262
  type PromptAdmissionReservation = {
263
+ /** Held by a model-free builtin (`/progress`) answered locally; no host turn exists to abort. */
264
+ localCommand?: true;
261
265
  cancelled?: boolean;
262
266
  settlement?: PromptAdmissionSettlement;
263
267
  wake: () => void;
@@ -526,9 +530,12 @@ function pageItems(value: unknown): unknown[] {
526
530
  return Array.isArray(page?.items) ? page.items : [];
527
531
  }
528
532
 
533
+ /** Builtin `/progress`: answered locally from the read-only `session.progress` SDK query, never a model turn. */
534
+ const ACP_PROGRESS_COMMAND: AvailableCommand = { name: "progress", description: PROGRESS_COMMAND_ACP_DESCRIPTION };
535
+
529
536
  /** Build the ACP command palette from the shared builtins and live SDK skill state. */
530
537
  export function acpAvailableCommandsFromSkills(query: unknown): AvailableCommand[] {
531
- const commands = new Map<string, AvailableCommand>();
538
+ const commands = new Map<string, AvailableCommand>([[ACP_PROGRESS_COMMAND.name, ACP_PROGRESS_COMMAND]]);
532
539
  for (const item of pageItems(query)) {
533
540
  const skill = object(item);
534
541
  if (typeof skill?.name !== "string" || !skill.name) continue;
@@ -1508,6 +1515,22 @@ export function acpSkillInvocation(blocks: PromptRequest["prompt"]): { name: str
1508
1515
  return { name: match[1], args: match[2]?.trim() ?? "" };
1509
1516
  }
1510
1517
 
1518
+ /**
1519
+ * Recognize the builtin `/progress` command only when it is the complete, single text
1520
+ * prompt. Like the TUI (`allowArgs: false`), `/progress <args>` is not the command.
1521
+ */
1522
+ export function isAcpProgressCommand(blocks: PromptRequest["prompt"]): boolean {
1523
+ return blocks.length === 1 && blocks[0]?.type === "text" && blocks[0].text.trim() === "/progress";
1524
+ }
1525
+
1526
+ /** Render the `session.progress` query result as the plain-text `/progress` view. */
1527
+ export function acpProgressReportText(query: unknown): string {
1528
+ const snapshot = object(pageItems(query)[0]);
1529
+ if (snapshot?.schema !== PROJECT_PROGRESS_SNAPSHOT_SCHEMA)
1530
+ throw new AcpSdkAdapterError("unavailable", "SDK session.progress response omitted the progress snapshot.");
1531
+ return renderProgressSnapshot(snapshot as unknown as ProjectProgressSnapshot).join("\n");
1532
+ }
1533
+
1511
1534
  /** Convert every ACP prompt block the agent advertises without silently discarding context. */
1512
1535
  export function acpPromptPayload(blocks: PromptRequest["prompt"]): {
1513
1536
  text: string;
@@ -2398,6 +2421,38 @@ export class AcpAgent implements Agent {
2398
2421
  }
2399
2422
  }
2400
2423
 
2424
+ /**
2425
+ * Read-only builtin `/progress`: answered from the session.progress query instead of a
2426
+ * model turn. It owns prompt admission (`pendingPromptAdmission`) from the query until its
2427
+ * response is published, so a concurrent prompt is refused as `conflict` exactly as it is
2428
+ * while a model turn is active. `cancel` and session teardown settle that reservation
2429
+ * through the existing admission paths; this command observes it and stops publishing.
2430
+ */
2431
+ async #runProgressCommand(sessionId: string, record: SessionRecord): Promise<PromptResponse> {
2432
+ const { promise: woken, resolve: wake } = Promise.withResolvers<void>();
2433
+ const reservation: PromptAdmissionReservation = { localCommand: true, wake, woken };
2434
+ // Replaces a follow-up reservation this prompt already consumed; `#submitPrompt` only
2435
+ // clears the reservation it installed, so ownership stays with this command.
2436
+ record.pendingPromptAdmission = reservation;
2437
+ try {
2438
+ const query = record.adapter.query("session.progress");
2439
+ void query.catch(() => undefined);
2440
+ const result = await Promise.race([query.then(value => ({ value })), woken.then(() => undefined)]);
2441
+ const settlement = reservation.settlement;
2442
+ if (settlement?.kind === "rejected") throw settlement.error;
2443
+ if (settlement?.kind === "cancelled" || reservation.cancelled || !result) return { stopReason: "cancelled" };
2444
+ const text = acpProgressReportText(result.value);
2445
+ await this.#publishSessionUpdate(
2446
+ sessionId,
2447
+ { sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } } },
2448
+ record.adapter,
2449
+ );
2450
+ return { stopReason: "end_turn" };
2451
+ } finally {
2452
+ if (record.pendingPromptAdmission === reservation) record.pendingPromptAdmission = undefined;
2453
+ }
2454
+ }
2455
+
2401
2456
  async #waitForPromptSettlement(
2402
2457
  waiter: PromptWaiter,
2403
2458
  admissionReservation: PromptAdmissionReservation,
@@ -2482,6 +2537,7 @@ export class AcpAgent implements Agent {
2482
2537
  );
2483
2538
  return { stopReason: "end_turn" };
2484
2539
  }
2540
+ if (isAcpProgressCommand(params.prompt)) return await this.#runProgressCommand(params.sessionId, record);
2485
2541
  // The SDK transport hard-caps a single request frame at 256 KiB and answers an
2486
2542
  // oversize frame by closing the socket (CloseCode::Size, crates/gjc-sdk/src/server.rs),
2487
2543
  // which surfaces to the client as an opaque `connection_closed` mid-turn. Reject
@@ -2969,6 +3025,7 @@ export class AcpAgent implements Agent {
2969
3025
  // Record the client's intent before awaiting the SDK so a prompt that rejects
2970
3026
  // mid-cancel (e.g. preflight `busy`) can still settle as `cancelled`.
2971
3027
  record.cancelRequested = true;
3028
+ const localCommand = !record.activePrompt && record.pendingPromptAdmission?.localCommand === true;
2972
3029
  this.#settlePendingPromptAdmission(record, { kind: "cancelled" });
2973
3030
  // C04 terminal abort: an external client cancel stops the current turn
2974
3031
  // (`scope:"turn"`, the default, matching the SDK `turn.abort` default and
@@ -2976,6 +3033,12 @@ export class AcpAgent implements Agent {
2976
3033
  // subagents and background tasks stopped opts in with
2977
3034
  // `_meta.gjc.abortScope: "owned"` (or `GJC_ACP_ABORT_SCOPE=owned`).
2978
3035
  const scope = resolveAcpAbortScope(params._meta, process.env);
3036
+ // The current turn is a model-free builtin answered locally: settling its reservation
3037
+ // above cancels it, and the host has no turn to abort.
3038
+ if (localCommand && scope === "turn") {
3039
+ record.cancelRequested = false;
3040
+ return;
3041
+ }
2979
3042
  const waiter = record.activePrompt;
2980
3043
  const waiterWasUnacknowledged = waiter !== undefined && !waiter.acknowledged;
2981
3044
  const waiterWasBeforeActivity = waiter !== undefined && !waiter.observedTurnActivity;
@@ -3051,9 +3114,11 @@ export class AcpAgent implements Agent {
3051
3114
  // publication is advisory and must never gate the settlement above.
3052
3115
  void this.#publishPromptPhaseIdle(params.sessionId, record.adapter);
3053
3116
  } else {
3117
+ if (waiter && record.activePrompt === waiter && waiter.terminal && !waiter.settled)
3118
+ this.#settlePrompt(params.sessionId, record, waiter);
3054
3119
  // The acknowledgement proves the run was aborted, not that its terminal was
3055
3120
  // published. Arm the bounded settlement so the turn cannot outlive the cancel.
3056
- this.#scheduleCancelSettlement(params.sessionId, record);
3121
+ else this.#scheduleCancelSettlement(params.sessionId, record);
3057
3122
  }
3058
3123
  waiter?.cancelAttemptResolve?.(true);
3059
3124
  } catch (error) {
@@ -3069,6 +3134,9 @@ export class AcpAgent implements Agent {
3069
3134
  !waiter.settled
3070
3135
  ) {
3071
3136
  if (waiter.terminalReserved) {
3137
+ await record.frameTail;
3138
+ if (record.activePrompt === waiter && waiter.terminal && !waiter.settled)
3139
+ this.#settlePrompt(params.sessionId, record, waiter);
3072
3140
  waiter.cancelAttemptResolve?.(true);
3073
3141
  return;
3074
3142
  }
@@ -3124,6 +3192,9 @@ export class AcpAgent implements Agent {
3124
3192
  waiter.cancelAttempt = undefined;
3125
3193
  waiter.cancelAttemptResolve = undefined;
3126
3194
  }
3195
+ const cancelWaveSettled = !waiter || waiter.cancelAcknowledged || (waiter.pendingCancelAttempts ?? 0) <= 1;
3196
+ if (cancelWaveSettled && waiter && record.activePrompt === waiter && waiter.terminal && !waiter.settled)
3197
+ this.#settlePrompt(params.sessionId, record, waiter);
3127
3198
  throw cancellationError;
3128
3199
  } finally {
3129
3200
  if (waiter) {
@@ -4885,7 +4956,18 @@ export class AcpAgent implements Agent {
4885
4956
  // Failure diagnostics are useful but advisory. Settle before any mapped
4886
4957
  // session update can await a backpressured client transport; otherwise an
4887
4958
  // already-decided failure can still lose to the inactivity watchdog.
4888
- this.#settlePrompt(id, record, activePrompt);
4959
+ // A terminal that races an in-flight cancel must not settle the public prompt before
4960
+ // the cancel request proves its outcome. Otherwise a follow-up prompt can be admitted
4961
+ // between the stale terminal settlement and the abort acknowledgement, leaving the
4962
+ // cancelled background request reported as `end_turn`.
4963
+ const cancelSettlementPending =
4964
+ activePrompt.cancelAttempt !== undefined &&
4965
+ !activePrompt.cancelAcknowledged &&
4966
+ record.statusRecovery !== activePrompt &&
4967
+ !this.#ownsUncertainAbort(record, activePrompt);
4968
+ if (!cancelSettlementPending) {
4969
+ this.#settlePrompt(id, record, activePrompt);
4970
+ }
4889
4971
  }
4890
4972
  if (!isTerminal && derivedCorrelation === undefined) return;
4891
4973
  if (!isTerminal && hasCorrelation(correlation)) {
@@ -5274,7 +5356,9 @@ export class AcpAgent implements Agent {
5274
5356
  const { outcome } = waiter.terminal;
5275
5357
  this.#rememberSettledPromptCorrelation(id, record, waiter.correlation);
5276
5358
  if (outcome.kind === "stopped") {
5277
- waiter.resolve({ stopReason: outcome.reason });
5359
+ // Once the SDK has acknowledged a client cancel, the cancellation owns the
5360
+ // turn even if a stale terminal frame races in with another stopped reason.
5361
+ waiter.resolve({ stopReason: waiter.cancelAcknowledged ? "cancelled" : outcome.reason });
5278
5362
  return;
5279
5363
  }
5280
5364
  // `phase` is the host's own claim, and a host that omits it leaves `terminalOutcome`
@@ -1,6 +1,7 @@
1
1
  import { Editor, type KeyId, matchesKey, parseKittySequence } from "@gajae-code/tui";
2
2
  import { BracketedPasteHandler } from "@gajae-code/tui/bracketed-paste";
3
3
  import { type AppKeybinding, KEYBINDINGS } from "../../config/keybindings";
4
+ import { macosOptionChordForComposedText } from "../utils/macos-option-forwarding";
4
5
 
5
6
  type ConfigurableEditorAction = Extract<
6
7
  AppKeybinding,
@@ -116,6 +117,12 @@ export class CustomEditor extends Editor {
116
117
  onQueue?: () => void;
117
118
  /** Called when Caps Lock is pressed. */
118
119
  onCapsLock?: () => void;
120
+ /**
121
+ * Called when typed text is what macOS composes for a bound Option chord
122
+ * (for example `œ` for a bound `alt+q`), meaning the terminal sent Option as
123
+ * text. The text is still inserted; the callback only explains the miss.
124
+ */
125
+ onUnforwardedOptionChord?: (key: KeyId, text: string) => void;
119
126
  /** Called when PageUp/PageDown should scroll the transcript viewport instead of prompt history. */
120
127
  onViewportPageScroll?: (direction: -1 | 1) => void;
121
128
  /** Called before regular composer input should return the transcript viewport to the live bottom. */
@@ -473,6 +480,13 @@ export class CustomEditor extends Editor {
473
480
  }
474
481
  }
475
482
 
483
+ if (this.onUnforwardedOptionChord) {
484
+ const optionKey = macosOptionChordForComposedText(data);
485
+ if (optionKey && (this.hasActionKey(optionKey) || this.#customKeyHandlers.has(optionKey))) {
486
+ this.onUnforwardedOptionChord(optionKey, data);
487
+ }
488
+ }
489
+
476
490
  // Pass to parent for normal handling.
477
491
  super.handleInput(data);
478
492
  }