@gajae-code/coding-agent 0.11.7 → 0.11.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 (180) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/dist/types/cli/args.d.ts +0 -2
  3. package/dist/types/config/keybindings.d.ts +33 -3
  4. package/dist/types/config/model-profile-activation.d.ts +4 -3
  5. package/dist/types/config/model-profile-contract.d.ts +38 -0
  6. package/dist/types/config/settings-schema.d.ts +15 -2
  7. package/dist/types/edit/modes/patch.d.ts +13 -0
  8. package/dist/types/edit/path-mutation-lock.d.ts +15 -0
  9. package/dist/types/eval/py/executor.d.ts +1 -1
  10. package/dist/types/eval/py/tool-bridge.d.ts +1 -2
  11. package/dist/types/gjc-runtime/repository-binding.d.ts +63 -0
  12. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +1 -0
  13. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +24 -0
  14. package/dist/types/main.d.ts +8 -0
  15. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +2 -1
  16. package/dist/types/modes/components/tool-status-header.d.ts +3 -2
  17. package/dist/types/modes/components/welcome.d.ts +2 -0
  18. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  19. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  20. package/dist/types/modes/interactive-mode.d.ts +5 -2
  21. package/dist/types/modes/prompt-action-autocomplete.d.ts +6 -1
  22. package/dist/types/modes/prompt-suggestion-controller.d.ts +35 -0
  23. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -2
  24. package/dist/types/modes/types.d.ts +3 -0
  25. package/dist/types/modes/utils/hotkeys-markdown.d.ts +2 -1
  26. package/dist/types/runtime-mcp/manager.d.ts +1 -1
  27. package/dist/types/runtime-mcp/oauth-flow.d.ts +0 -5
  28. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +14 -0
  29. package/dist/types/sdk/broker/broker.d.ts +2 -0
  30. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  31. package/dist/types/sdk/bus/chat-daemon-control.d.ts +5 -1
  32. package/dist/types/sdk/bus/config-commands.d.ts +2 -2
  33. package/dist/types/sdk/bus/config.d.ts +2 -0
  34. package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
  35. package/dist/types/sdk/bus/index.d.ts +25 -0
  36. package/dist/types/sdk/bus/notification-orchestration.d.ts +1 -0
  37. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +105 -0
  38. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +13 -3
  39. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -1
  40. package/dist/types/sdk/bus/telegram-daemon.d.ts +128 -1
  41. package/dist/types/sdk/bus/telegram-reference.d.ts +2 -0
  42. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  43. package/dist/types/sdk/host/host.d.ts +1 -1
  44. package/dist/types/sdk/host/query/handlers.d.ts +9 -0
  45. package/dist/types/sdk/index.d.ts +2 -0
  46. package/dist/types/sdk/prompt-status.d.ts +88 -0
  47. package/dist/types/sdk/session-directory.d.ts +1 -1
  48. package/dist/types/sdk/startup-capability.d.ts +3 -0
  49. package/dist/types/session/agent-session.d.ts +2 -0
  50. package/dist/types/session/internal/managed-session-scope.d.ts +11 -2
  51. package/dist/types/session/internal/managed-session-storage.d.ts +17 -2
  52. package/dist/types/session/internal/native-publish-outcome.d.ts +48 -0
  53. package/dist/types/session/session-storage.d.ts +18 -0
  54. package/dist/types/task/index.d.ts +3 -1
  55. package/dist/types/task/receipt.d.ts +2 -0
  56. package/dist/types/task/types.d.ts +94 -0
  57. package/dist/types/tools/ask.d.ts +454 -174
  58. package/dist/types/tools/image-gen.d.ts +47 -1
  59. package/dist/types/tools/index.d.ts +4 -0
  60. package/dist/types/utils/prompt-suggestion.d.ts +33 -0
  61. package/package.json +8 -8
  62. package/scripts/capture-platform-shortcut-labels-showcase.ts +342 -0
  63. package/scripts/dogfood-repository-binding.ts +209 -0
  64. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  65. package/scripts/run-test-manifest.ts +4 -4
  66. package/src/cli/args.ts +2 -92
  67. package/src/cli/notify-cli.ts +5 -38
  68. package/src/cli.ts +18 -0
  69. package/src/commands/sdk.ts +2 -2
  70. package/src/config/keybindings.ts +157 -24
  71. package/src/config/model-profile-activation.ts +7 -18
  72. package/src/config/model-profile-contract.ts +161 -0
  73. package/src/config/model-registry.ts +11 -1
  74. package/src/config/settings-schema.ts +17 -2
  75. package/src/coordinator-mcp/model-preset.ts +21 -74
  76. package/src/defaults/gjc/skills/deep-interview/SKILL.md +2 -2
  77. package/src/defaults/gjc/skills/ralplan/SKILL.md +3 -1
  78. package/src/edit/index.ts +3 -6
  79. package/src/edit/modes/patch.ts +38 -1
  80. package/src/edit/modes/replace.ts +42 -0
  81. package/src/edit/path-mutation-lock.ts +112 -0
  82. package/src/eval/py/executor.ts +30 -11
  83. package/src/eval/py/prelude.py +2 -2
  84. package/src/eval/py/tool-bridge.ts +34 -18
  85. package/src/extensibility/gjc-plugins/runtime-adapters.ts +2 -1
  86. package/src/gjc-runtime/launch-tmux.ts +1 -1
  87. package/src/gjc-runtime/managed-owner-supervisor.ts +83 -4
  88. package/src/gjc-runtime/ralplan-runtime.ts +75 -3
  89. package/src/gjc-runtime/repository-binding.ts +267 -0
  90. package/src/gjc-runtime/session-state-sidecar.ts +25 -9
  91. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -1
  92. package/src/gjc-runtime/tmux-sessions.ts +45 -6
  93. package/src/gjc-runtime/ultragoal-guard.ts +5 -5
  94. package/src/gjc-runtime/ultragoal-runtime.ts +181 -19
  95. package/src/internal-urls/docs-index.generated.ts +6 -5
  96. package/src/internal-urls/local-protocol.ts +33 -16
  97. package/src/internal-urls/mcp-protocol.ts +73 -11
  98. package/src/main.ts +25 -10
  99. package/src/modes/DESIGN.md +5 -0
  100. package/src/modes/acp/acp-agent.ts +50 -16
  101. package/src/modes/components/model-selector.ts +42 -8
  102. package/src/modes/components/plan-preview-overlay.ts +1 -1
  103. package/src/modes/components/runtime-mcp-add-wizard.ts +5 -0
  104. package/src/modes/components/tool-status-header.ts +20 -4
  105. package/src/modes/components/welcome.ts +22 -18
  106. package/src/modes/controllers/command-controller.ts +97 -54
  107. package/src/modes/controllers/event-controller.ts +10 -0
  108. package/src/modes/controllers/input-controller.ts +7 -2
  109. package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -6
  110. package/src/modes/controllers/selector-controller.ts +46 -54
  111. package/src/modes/interactive-mode.ts +41 -27
  112. package/src/modes/prompt-action-autocomplete.ts +32 -13
  113. package/src/modes/prompt-suggestion-controller.ts +96 -0
  114. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -2
  115. package/src/modes/types.ts +3 -0
  116. package/src/modes/utils/hotkeys-markdown.ts +64 -36
  117. package/src/modes/utils/ui-helpers.ts +5 -3
  118. package/src/prompts/agents/architect.md +0 -1
  119. package/src/prompts/agents/critic.md +0 -1
  120. package/src/prompts/system/prompt-suggestion-system.md +32 -0
  121. package/src/prompts/system/system-prompt.md +6 -4
  122. package/src/prompts/tools/cron.md +3 -1
  123. package/src/runtime-mcp/manager.ts +51 -53
  124. package/src/runtime-mcp/oauth-flow.ts +0 -72
  125. package/src/runtime-mcp/plugin-network-boundary.ts +101 -0
  126. package/src/runtime-mcp/transports/http.ts +13 -6
  127. package/src/sdk/broker/broker.ts +34 -20
  128. package/src/sdk/broker/lifecycle.ts +185 -36
  129. package/src/sdk/bus/chat-daemon-control.ts +18 -5
  130. package/src/sdk/bus/config-commands.ts +2 -2
  131. package/src/sdk/bus/config.ts +9 -1
  132. package/src/sdk/bus/conversation-store.ts +40 -6
  133. package/src/sdk/bus/index.ts +240 -65
  134. package/src/sdk/bus/lifecycle-commands.ts +47 -3
  135. package/src/sdk/bus/lifecycle-control-runtime.ts +11 -2
  136. package/src/sdk/bus/lifecycle-orchestrator.ts +32 -1
  137. package/src/sdk/bus/notification-orchestration.ts +12 -0
  138. package/src/sdk/bus/notification-service.ts +27 -2
  139. package/src/sdk/bus/prompt-reconciliation.ts +239 -0
  140. package/src/sdk/bus/recent-activity.ts +90 -26
  141. package/src/sdk/bus/telegram-daemon-contract.ts +13 -3
  142. package/src/sdk/bus/telegram-daemon-control.ts +145 -33
  143. package/src/sdk/bus/telegram-daemon.ts +835 -110
  144. package/src/sdk/bus/telegram-reference.ts +20 -2
  145. package/src/sdk/cli/session-cli.ts +15 -2
  146. package/src/sdk/host/control/dispatch.ts +1 -1
  147. package/src/sdk/host/control/operations.ts +1 -1
  148. package/src/sdk/host/host.ts +2 -6
  149. package/src/sdk/host/query/handlers.ts +70 -5
  150. package/src/sdk/index.ts +12 -0
  151. package/src/sdk/prompt-status.ts +97 -0
  152. package/src/sdk/protocol/operation-inventory.generated.json +47 -0
  153. package/src/sdk/protocol/operation-registry.ts +18 -4
  154. package/src/sdk/session-directory.ts +5 -1
  155. package/src/sdk/session.ts +2 -0
  156. package/src/sdk/startup-capability.ts +9 -1
  157. package/src/session/agent-session.ts +52 -5
  158. package/src/session/blob-store.ts +7 -2
  159. package/src/session/internal/managed-session-scope.ts +854 -84
  160. package/src/session/internal/managed-session-storage.ts +251 -199
  161. package/src/session/internal/native-publish-outcome.ts +266 -0
  162. package/src/session/session-manager.ts +310 -327
  163. package/src/session/session-storage.ts +128 -11
  164. package/src/task/gjc-command.ts +2 -2
  165. package/src/task/index.ts +121 -15
  166. package/src/task/receipt.ts +3 -0
  167. package/src/task/types.ts +34 -3
  168. package/src/tools/ask.ts +198 -88
  169. package/src/tools/ast-edit.ts +2 -2
  170. package/src/tools/bash-pty-selection.ts +2 -2
  171. package/src/tools/browser/screenshot-format.ts +6 -1
  172. package/src/tools/cron.ts +1 -1
  173. package/src/tools/image-gen.ts +178 -6
  174. package/src/tools/index.ts +4 -0
  175. package/src/tools/render-utils.ts +7 -1
  176. package/src/utils/edit-mode.ts +2 -2
  177. package/src/utils/prompt-suggestion.ts +265 -0
  178. package/src/utils/sixel.ts +3 -3
  179. package/src/web/insane/url-guard.ts +3 -1
  180. package/src/web/scrapers/docs-rs.ts +30 -5
@@ -145,6 +145,8 @@ async function assertDirectoryNotSymlink(directoryPath: string): Promise<void> {
145
145
  const LEGACY_MIGRATION_MARKER = ".gjc-local-legacy-migrated-v1";
146
146
  const MAX_LEGACY_LOCAL_BYTES = 64 * 1024 * 1024;
147
147
 
148
+ type LegacyMigrationState = "complete" | "cleanup_pending";
149
+
148
150
  interface LegacyEntrySnapshot {
149
151
  readonly relativePath: string;
150
152
  readonly dev: bigint;
@@ -334,7 +336,7 @@ async function migrateManagedLegacyLocal(
334
336
  }
335
337
  const staging = path.join(scratchParent, `.gjc-local-migration-${randomUUID()}`);
336
338
  const installed: Array<{ readonly path: string; readonly dev: bigint; readonly ino: bigint }> = [];
337
- let installedMarker: { readonly dev: bigint; readonly ino: bigint } | null = null;
339
+ let sourceRetired = false;
338
340
  try {
339
341
  await fs.mkdir(staging, { mode: 0o700 });
340
342
  for (const entry of captured.entries) {
@@ -360,18 +362,22 @@ async function migrateManagedLegacyLocal(
360
362
  const identity = await fs.lstat(destination, { bigint: true });
361
363
  installed.push({ path: destination, dev: identity.dev, ino: identity.ino });
362
364
  }
365
+ try {
366
+ source.retire(captured.snapshot);
367
+ sourceRetired = true;
368
+ } catch (error) {
369
+ if (!(error instanceof Error) || error.message !== "cleanup_pending") throw error;
370
+ await fs.writeFile(marker, "cleanup_pending\n", { mode: 0o600, flag: "wx" });
371
+ return;
372
+ }
363
373
  await fs.writeFile(marker, "verified\n", { mode: 0o600, flag: "wx" });
364
- const markerIdentity = await fs.lstat(marker, { bigint: true });
365
- installedMarker = { dev: markerIdentity.dev, ino: markerIdentity.ino };
366
- source.retire(captured.snapshot);
367
374
  } catch (error) {
368
- const currentMarker = await fs.lstat(marker, { bigint: true }).catch(() => null);
369
- if (installedMarker && currentMarker?.dev === installedMarker.dev && currentMarker.ino === installedMarker.ino)
370
- await fs.rm(marker, { force: true });
371
- for (const destination of installed.reverse()) {
372
- const current = await fs.lstat(destination.path, { bigint: true }).catch(() => null);
373
- if (current?.dev === destination.dev && current.ino === destination.ino)
374
- await fs.rm(destination.path, { recursive: true, force: true });
375
+ if (!sourceRetired) {
376
+ for (const destination of installed.reverse()) {
377
+ const current = await fs.lstat(destination.path, { bigint: true }).catch(() => null);
378
+ if (current?.dev === destination.dev && current.ino === destination.ino)
379
+ await fs.rm(destination.path, { recursive: true, force: true });
380
+ }
375
381
  }
376
382
  throw error;
377
383
  } finally {
@@ -379,15 +385,16 @@ async function migrateManagedLegacyLocal(
379
385
  }
380
386
  }
381
387
 
382
- async function readMigrationMarker(marker: string): Promise<boolean> {
388
+ async function readMigrationMarker(marker: string): Promise<LegacyMigrationState | null> {
383
389
  try {
384
390
  const snapshot = await snapshotRegularFile(marker, LEGACY_MIGRATION_MARKER);
385
391
  if (snapshot.size > 32n || snapshot.bytes === undefined) throw new Error("Unsafe local:// migration marker");
386
392
  const value = snapshot.bytes.toString("utf8");
387
- if (value !== "verified\n" && value !== "absent\n") throw new Error("Unsafe local:// migration marker");
388
- return true;
393
+ if (value === "cleanup_pending\n") return "cleanup_pending";
394
+ if (value === "verified\n" || value === "absent\n") return "complete";
395
+ throw new Error("Unsafe local:// migration marker");
389
396
  } catch (error) {
390
- if (isEnoent(error)) return false;
397
+ if (isEnoent(error)) return null;
391
398
  throw error;
392
399
  }
393
400
  }
@@ -436,7 +443,17 @@ async function migrateLegacyLocal(
436
443
  }
437
444
  await fs.rename(path.join(staging, entry), path.join(localRoot, entry));
438
445
  }
439
- await retireLegacyTree(legacySource.root, manifest);
446
+ try {
447
+ await retireLegacyTree(legacySource.root, manifest);
448
+ } catch (error) {
449
+ if (
450
+ !(error instanceof Error) ||
451
+ error.message !== "Legacy local:// migration retirement failed: cleanup_pending"
452
+ )
453
+ throw error;
454
+ await fs.writeFile(marker, "cleanup_pending\n", { mode: 0o600, flag: "wx" });
455
+ return;
456
+ }
440
457
  await fs.writeFile(marker, "verified\n", { mode: 0o600, flag: "wx" });
441
458
  } finally {
442
459
  await fs.rm(staging, { recursive: true, force: true });
@@ -2,29 +2,91 @@ import { MCPManager } from "../runtime-mcp/manager";
2
2
  import type { MCPResourceReadResult } from "../runtime-mcp/types";
3
3
  import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
4
4
 
5
- function escapeRegex(text: string): string {
6
- return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5
+ const MAX_MCP_RESOURCE_URI_CHARS = 16_384;
6
+ const MAX_MCP_URI_TEMPLATE_CHARS = 8_192;
7
+ const MAX_MCP_URI_TEMPLATE_EXPRESSIONS = 32;
8
+
9
+ interface CompiledUriTemplate {
10
+ literalSegments: string[];
11
+ literalChars: number;
12
+ expressionCount: number;
13
+ }
14
+
15
+ function containsExpressionLineTerminator(text: string, start: number, end: number): boolean {
16
+ for (let index = start; index < end; index++) {
17
+ const code = text.charCodeAt(index);
18
+ if (code === 0x0a || code === 0x0d || code === 0x2028 || code === 0x2029) return true;
19
+ }
20
+ return false;
21
+ }
22
+
23
+ function compileUriTemplate(uriTemplate: string): CompiledUriTemplate | undefined {
24
+ if (uriTemplate.length > MAX_MCP_URI_TEMPLATE_CHARS) return undefined;
25
+
26
+ const literalSegments: string[] = [];
27
+ let expressionCount = 0;
28
+ let literalChars = 0;
29
+ let segmentStart = 0;
30
+ let scanPosition = 0;
31
+ while (scanPosition < uriTemplate.length) {
32
+ const expressionStart = uriTemplate.indexOf("{", scanPosition);
33
+ if (expressionStart < 0) break;
34
+ const expressionEnd = uriTemplate.indexOf("}", expressionStart + 1);
35
+ if (expressionEnd < 0) break;
36
+ if (expressionEnd === expressionStart + 1) {
37
+ scanPosition = expressionStart + 1;
38
+ continue;
39
+ }
40
+ expressionCount++;
41
+ if (expressionCount > MAX_MCP_URI_TEMPLATE_EXPRESSIONS) return undefined;
42
+ const segment = uriTemplate.slice(segmentStart, expressionStart);
43
+ literalSegments.push(segment);
44
+ literalChars += segment.length;
45
+ segmentStart = expressionEnd + 1;
46
+ scanPosition = segmentStart;
47
+ }
48
+ const finalSegment = uriTemplate.slice(segmentStart);
49
+ literalSegments.push(finalSegment);
50
+ literalChars += finalSegment.length;
51
+ return { literalSegments, literalChars, expressionCount };
52
+ }
53
+
54
+ function matchesUriTemplate(uri: string, template: CompiledUriTemplate): boolean {
55
+ const { literalSegments, expressionCount } = template;
56
+ if (expressionCount === 0) return uri === literalSegments[0];
57
+ if (!uri.startsWith(literalSegments[0])) return false;
58
+
59
+ let cursor = literalSegments[0].length;
60
+ for (let index = 1; index < literalSegments.length - 1; index++) {
61
+ const segment = literalSegments[index];
62
+ const matchIndex = uri.indexOf(segment, cursor);
63
+ if (matchIndex < 0 || containsExpressionLineTerminator(uri, cursor, matchIndex)) return false;
64
+ cursor = matchIndex + segment.length;
65
+ }
66
+
67
+ const suffix = literalSegments.at(-1) ?? "";
68
+ const suffixStart = uri.length - suffix.length;
69
+ return suffixStart >= cursor && uri.endsWith(suffix) && !containsExpressionLineTerminator(uri, cursor, suffixStart);
7
70
  }
8
71
 
9
72
  function getUriTemplateMatchScore(
10
73
  uri: string,
11
74
  uriTemplate: string,
12
75
  ): { literalChars: number; expressionCount: number } | undefined {
13
- const expressionPattern = /\{[^}]+\}/g;
14
- const literalSegments = uriTemplate.split(expressionPattern);
15
- const expressionCount = (uriTemplate.match(expressionPattern) ?? []).length;
16
- const pattern = literalSegments.map(escapeRegex).join("(.*?)");
17
- const regex = new RegExp(`^${pattern}$`);
18
- if (!regex.test(uri)) return undefined;
19
- const literalChars = literalSegments.reduce((total, segment) => total + segment.length, 0);
20
- return { literalChars, expressionCount };
76
+ const template = compileUriTemplate(uriTemplate);
77
+ if (!template || !matchesUriTemplate(uri, template)) return undefined;
78
+ return { literalChars: template.literalChars, expressionCount: template.expressionCount };
21
79
  }
22
80
 
23
81
  function extractResourceUri(url: InternalUrl): string {
24
82
  const host = url.rawHost || url.hostname;
25
83
  const rawPathname = url.rawPathname ?? url.pathname;
26
84
  const hasPath = rawPathname && rawPathname !== "/";
27
- const uri = `${host}${hasPath ? rawPathname : ""}${url.search}${url.hash}`.trim();
85
+ const rawUri = `${host}${hasPath ? rawPathname : ""}${url.search}${url.hash}`;
86
+ if (rawUri.length > MAX_MCP_RESOURCE_URI_CHARS) {
87
+ throw new Error("MCP resource URI exceeds the 16384-character limit.");
88
+ }
89
+ const uri = rawUri.trim();
28
90
  if (!uri) {
29
91
  throw new Error("mcp:// URL requires a resource URI: mcp://<resource-uri>");
30
92
  }
package/src/main.ts CHANGED
@@ -11,7 +11,6 @@ import * as path from "node:path";
11
11
  import { createInterface } from "node:readline/promises";
12
12
  import type { ImageContent } from "@gajae-code/ai";
13
13
  import {
14
- $env,
15
14
  $pickenv,
16
15
  getAgentDir,
17
16
  getProjectDir,
@@ -1119,6 +1118,27 @@ export function resolveModelRoleOverrides(parsed: Pick<Args, "smol" | "slow" | "
1119
1118
  if (plan) overrides.plan = plan;
1120
1119
  return overrides;
1121
1120
  }
1121
+
1122
+ /**
1123
+ * Apply the `--no-pty` / `--no-title` terminal-control flags to the environment.
1124
+ *
1125
+ * Sets the canonical `GJC_*` name (so an explicit flag wins over a user-set
1126
+ * `GJC_*` value under the GJC-first resolver — CLI authority) and the legacy
1127
+ * `PI_*` name for backward compatibility. `--acp` mode implies `--no-title`.
1128
+ */
1129
+ export function applyTerminalControlFlagsToEnv(
1130
+ parsed: Pick<Args, "noPty" | "noTitle" | "mode">,
1131
+ env: NodeJS.ProcessEnv = Bun.env,
1132
+ ): void {
1133
+ if (parsed.noPty) {
1134
+ env.GJC_NO_PTY = "1";
1135
+ env.PI_NO_PTY = "1";
1136
+ }
1137
+ if (parsed.noTitle || parsed.mode === "acp") {
1138
+ env.GJC_NO_TITLE = "1";
1139
+ env.PI_NO_TITLE = "1";
1140
+ }
1141
+ }
1122
1142
  export async function runRootCommand(
1123
1143
  parsed: Args,
1124
1144
  rawArgs: string[],
@@ -1258,12 +1278,7 @@ export async function runRootCommand(
1258
1278
  applyAcpDefaultSettingOverrides(settingsInstance);
1259
1279
  }
1260
1280
  modelRegistry.applyConfiguredModelBindings(settingsInstance);
1261
- if (parsedArgs.noPty) {
1262
- Bun.env.PI_NO_PTY = "1";
1263
- }
1264
- if (parsedArgs.noTitle || parsedArgs.mode === "acp") {
1265
- Bun.env.PI_NO_TITLE = "1";
1266
- }
1281
+ applyTerminalControlFlagsToEnv(parsedArgs);
1267
1282
  const hasPreparedInput = parsedArgs.messages.length > 0 || parsedArgs.fileArgs.length > 0;
1268
1283
  const { pipedInput, fileText, fileImages } = await logger.time("prepareInitialMessage", async () => {
1269
1284
  const pipedInput =
@@ -1613,9 +1628,9 @@ export async function runRootCommand(
1613
1628
  process.stdout.write(`${chalk.dim(`Model scope: ${modelList} ${chalk.gray("(Alt+N to cycle)")}`)}\n`);
1614
1629
  }
1615
1630
 
1616
- if ($env.PI_TIMING) {
1631
+ if ($pickenv("GJC_TIMING", "PI_TIMING")) {
1617
1632
  logger.printTimings();
1618
- exitForTiming = $env.PI_TIMING === "x";
1633
+ exitForTiming = $pickenv("GJC_TIMING", "PI_TIMING") === "x";
1619
1634
  }
1620
1635
 
1621
1636
  if (!exitForTiming) {
@@ -1660,7 +1675,7 @@ export async function runRootCommand(
1660
1675
  initialImages,
1661
1676
  suppressProcessExit: deps.suppressProcessExit,
1662
1677
  });
1663
- if ($env.PI_TIMING) {
1678
+ if ($pickenv("GJC_TIMING", "PI_TIMING")) {
1664
1679
  logger.printTimings();
1665
1680
  }
1666
1681
  } finally {
@@ -94,6 +94,11 @@ empty space and list density for an operational setup flow.
94
94
  removing line breaks. Notification secrets will use the dedicated masked
95
95
  input from Work item 6; they must never appear in list values, descriptions,
96
96
  previews, artifacts, or logs.
97
+ ### Shortcut labels and binding authority
98
+
99
+ Keybinding configuration is a portable canonical grammar: textual key IDs use `ctrl`, `alt`, `shift`, and `super` plus a key name (for example, `ctrl+p` or `alt+enter`). Do not serialize or require display-only labels. Runtime UI renders those IDs through the shared formatter for its explicit platform context; macOS uses MacBook-style glyphs (`⌃`, `⌥`, `⇧`, `⌘`, `↩`, `⎋`, `⇥`, `⌫`, `⌦`, and arrow glyphs) while other platforms use textual labels. A glyph is never configuration syntax.
100
+
101
+ Static onboarding and generated documentation have authority only over shipped defaults. Keep generated tables host-independent by showing canonical textual IDs, not the capture host's labels. The runtime `KeybindingsManager` owns the effective binding set after user remaps and extensions load; `/hotkeys` and runtime hints must render that effective set with the platform context injected by their host. Do not let a static onboarding hint imply that it reflects remaps.
97
102
 
98
103
  ### Status, errors, confirmation, and disabled work
99
104
 
@@ -42,7 +42,7 @@ import {
42
42
  AcpSdkAdapterError,
43
43
  } from "../../sdk/acp";
44
44
  import { ensureBroker } from "../../sdk/broker/ensure";
45
- import { readSdkBrokerDiscovery, SdkClient } from "../../sdk/client";
45
+ import { readSdkBrokerDiscovery, SdkClient, SdkClientError } from "../../sdk/client";
46
46
  import { mapAgentWireEventPayloadToAcpSessionUpdates } from "./acp-event-mapper";
47
47
  import { resolveAcpPermissionMode } from "./permission-mode";
48
48
  import type { AcpStartupOptions } from "./startup-options";
@@ -88,6 +88,7 @@ type PendingAttachment = { epoch: number; task: Promise<void> };
88
88
  type SessionRecord = {
89
89
  cwd: string;
90
90
  adapter: AcpSdkAdapter;
91
+ closeIdempotencyKey: string;
91
92
  unsubscribe: () => void;
92
93
  reconnectUnsubscribe: () => void;
93
94
  /** Per-session frame work queue; callbacks never race prompt ownership. */
@@ -571,8 +572,10 @@ export class AcpAgent implements Agent {
571
572
  readonly #attaching = new Map<string, PendingAttachment>();
572
573
  readonly #resolvingExisting = new Map<string, PendingAttachment>();
573
574
  readonly #knownSessionCwds = new Map<string, string>();
575
+ readonly #pendingCloseIdempotencyKeys = new Map<string, string>();
574
576
  readonly #sessionEpochs = new Map<string, number>();
575
577
  readonly #tearingDown = new Map<string, number>();
578
+ readonly #closing = new Map<string, Promise<CloseSessionResponse>>();
576
579
  #clientCapabilities: ClientCapabilities | undefined;
577
580
  #broker: Promise<BrokerConnection> | undefined;
578
581
  readonly #startupOptions: AcpStartupOptions | undefined;
@@ -730,19 +733,21 @@ export class AcpAgent implements Agent {
730
733
  return paginateAcpSessions(listed, params.cwd ?? undefined, this.#cursor(params.cursor));
731
734
  }
732
735
 
733
- async closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
736
+ closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
734
737
  const record = this.#sessions.get(params.sessionId);
735
738
  const cwd = record?.cwd ?? this.#knownSessionCwds.get(params.sessionId);
736
739
  // ACP close has no cwd. Only connection-owned sessions may reach broker lifecycle control.
737
- if (!cwd) return {};
738
- this.#beginTeardown(params.sessionId);
739
- try {
740
- await this.#teardownSession(params.sessionId, "closed", true);
741
- this.#knownSessionCwds.delete(params.sessionId);
742
- return {};
743
- } finally {
744
- this.#finishTeardown(params.sessionId);
745
- }
740
+ if (!cwd) return Promise.resolve({});
741
+ const existing = this.#closing.get(params.sessionId);
742
+ if (existing) return existing;
743
+ const deferred = Promise.withResolvers<CloseSessionResponse>();
744
+ this.#closing.set(params.sessionId, deferred.promise);
745
+ void this.#closeOwnedSession(params.sessionId).then(deferred.resolve, deferred.reject);
746
+ const cleanup = deferred.promise.finally(() => {
747
+ if (this.#closing.get(params.sessionId) === deferred.promise) this.#closing.delete(params.sessionId);
748
+ });
749
+ void cleanup.catch(() => undefined);
750
+ return deferred.promise;
746
751
  }
747
752
 
748
753
  async deleteSession(params: DeleteSessionRequest): Promise<DeleteSessionResponse> {
@@ -932,6 +937,15 @@ export class AcpAgent implements Agent {
932
937
  );
933
938
  }
934
939
 
940
+ #isDefinitiveBrokerResponse(error: unknown): boolean {
941
+ // Response-derived client errors retain the broker error as details. Responses
942
+ // that represent ongoing or ambiguous lifecycle work must keep their key.
943
+ if (!(error instanceof SdkClientError)) return false;
944
+ const details = object(error.details);
945
+ if (details?.code !== error.code || details.message !== error.message) return false;
946
+ return !["terminal_uncertain", "cleanup_pending", "broker_restarting", "unavailable"].includes(error.code);
947
+ }
948
+
935
949
  async #attachExisting(id: string, cwd: string): Promise<void> {
936
950
  const epoch = this.#sessionEpoch(id);
937
951
  const attached = this.#sessions.get(id);
@@ -1045,6 +1059,7 @@ export class AcpAgent implements Agent {
1045
1059
  const record: SessionRecord = {
1046
1060
  cwd,
1047
1061
  adapter,
1062
+ closeIdempotencyKey: randomUUID(),
1048
1063
  unsubscribe: () => {},
1049
1064
  reconnectUnsubscribe: () => {},
1050
1065
  frameTail: Promise.resolve(),
@@ -1059,6 +1074,7 @@ export class AcpAgent implements Agent {
1059
1074
  this.#knownSessionCwds.set(id, cwd);
1060
1075
  await applyAcpPermissionMode(adapter, this.#clientCapabilities);
1061
1076
  this.#assertSessionEpoch(id, epoch);
1077
+ this.#pendingCloseIdempotencyKeys.delete(id);
1062
1078
  } catch (error) {
1063
1079
  if (adapter && this.#sessions.get(id)?.adapter === adapter) {
1064
1080
  try {
@@ -1103,6 +1119,21 @@ export class AcpAgent implements Agent {
1103
1119
  this.#knownSessionCwds.delete(id);
1104
1120
  }
1105
1121
 
1122
+ async #closeOwnedSession(id: string): Promise<CloseSessionResponse> {
1123
+ this.#beginTeardown(id);
1124
+ try {
1125
+ const attaching = this.#attaching.get(id);
1126
+ // The record is published before permission initialization. Let a canceled
1127
+ // provisional attachment retire it before selecting the generation key.
1128
+ if (attaching) await Promise.allSettled([attaching.task]);
1129
+ await this.#teardownSession(id, "closed", true);
1130
+ this.#knownSessionCwds.delete(id);
1131
+ return {};
1132
+ } finally {
1133
+ this.#finishTeardown(id);
1134
+ }
1135
+ }
1136
+
1106
1137
  /**
1107
1138
  * All local session disposal follows one path: remove ownership and reject a
1108
1139
  * waiting prompt before any awaited socket or broker work. A failed close is
@@ -1130,13 +1161,13 @@ export class AcpAgent implements Agent {
1130
1161
  failures.push(error);
1131
1162
  }
1132
1163
  if (closeRemote) {
1164
+ const closeIdempotencyKey =
1165
+ record?.closeIdempotencyKey ?? this.#pendingCloseIdempotencyKeys.get(id) ?? randomUUID();
1166
+ this.#pendingCloseIdempotencyKeys.set(id, closeIdempotencyKey);
1133
1167
  try {
1134
- await (await this.#brokerAdapter()).global(
1135
- "session.close",
1136
- { sessionId: id },
1137
- this.#lifecycleIdempotencyKey(id, "session.close"),
1138
- );
1168
+ await (await this.#brokerAdapter()).global("session.close", { sessionId: id }, closeIdempotencyKey);
1139
1169
  } catch (error) {
1170
+ if (this.#isDefinitiveBrokerResponse(error)) this.#pendingCloseIdempotencyKeys.delete(id);
1140
1171
  if (!(ownershipBound && this.#isAlreadyGone(error))) failures.push(error);
1141
1172
  }
1142
1173
  }
@@ -1146,6 +1177,7 @@ export class AcpAgent implements Agent {
1146
1177
  .join("; ");
1147
1178
  throw aggregateAcpFailure("terminal_uncertain", `ACP session cleanup is uncertain: ${detail}`, failures);
1148
1179
  }
1180
+ if (closeRemote) this.#pendingCloseIdempotencyKeys.delete(id);
1149
1181
  } finally {
1150
1182
  this.#finishTeardown(id);
1151
1183
  }
@@ -1494,6 +1526,8 @@ export class AcpAgent implements Agent {
1494
1526
  this.#attaching.clear();
1495
1527
  this.#resolvingExisting.clear();
1496
1528
  this.#knownSessionCwds.clear();
1529
+ this.#pendingCloseIdempotencyKeys.clear();
1530
+ if (this.#closing.size === 0) this.#closing.clear();
1497
1531
  this.#tearingDown.clear();
1498
1532
  if (this.#broker) {
1499
1533
  const broker = this.#broker;
@@ -345,6 +345,8 @@ export class ModelSelectorComponent extends Container {
345
345
  #selectedActionIndex: number = 0;
346
346
  #pendingThinkingChoice?: PendingThinkingChoice;
347
347
  #selectedThinkingIndex: number = 0;
348
+ #assignmentState: "idle" | "assigning" = "idle";
349
+ #closeAfterAssignment = false;
348
350
 
349
351
  // Preset landing state
350
352
  #viewMode: ModelSelectorViewMode = "presets";
@@ -1493,6 +1495,12 @@ export class ModelSelectorComponent extends Container {
1493
1495
  }
1494
1496
 
1495
1497
  handleInput(keyData: string): void {
1498
+ if (this.#assignmentState === "assigning") {
1499
+ if (getKeybindings().matches(keyData, "tui.select.cancel")) {
1500
+ this.#closeAfterAssignment = true;
1501
+ }
1502
+ return;
1503
+ }
1496
1504
  if (this.#pendingThinkingChoice) {
1497
1505
  this.#handleThinkingMenuInput(keyData);
1498
1506
  return;
@@ -1861,24 +1869,50 @@ export class ModelSelectorComponent extends Container {
1861
1869
  ? item.selector
1862
1870
  : formatModelSelectorValue(item.selector, selectedThinkingLevel);
1863
1871
 
1864
- // Update local state for UI
1865
- for (const targetRole of roles ?? [role]) {
1866
- this.#roles[targetRole] = { model: item.model, thinkingLevel: selectedThinkingLevel };
1867
- }
1868
-
1869
- // Notify caller (for updating agent state if needed)
1870
- this.#onSelectCallback({
1872
+ const selection: Extract<ModelSelectorSelection, { kind: "assignment" }> = {
1871
1873
  kind: "assignment",
1872
1874
  model: item.model,
1873
1875
  role,
1874
1876
  roles,
1875
1877
  thinkingLevel: selectedThinkingLevel,
1876
1878
  selector: selectorValue,
1877
- });
1879
+ };
1880
+ if (this.#isTrackedSingleAssignment(selection)) {
1881
+ void this.#handleTrackedAssignment(selection);
1882
+ return;
1883
+ }
1884
+
1885
+ // Update local state for UI
1886
+ for (const targetRole of roles ?? [role]) {
1887
+ this.#roles[targetRole] = { model: item.model, thinkingLevel: selectedThinkingLevel };
1888
+ }
1889
+
1890
+ // Notify caller (for updating agent state if needed)
1891
+ this.#onSelectCallback(selection);
1878
1892
 
1879
1893
  // Update list to show new badges
1880
1894
  this.#updateList();
1881
1895
  }
1896
+ #isTrackedSingleAssignment(selection: Extract<ModelSelectorSelection, { kind: "assignment" }>): boolean {
1897
+ return selection.role !== null && selection.roles === undefined;
1898
+ }
1899
+
1900
+ async #handleTrackedAssignment(selection: Extract<ModelSelectorSelection, { kind: "assignment" }>): Promise<void> {
1901
+ if (this.#assignmentState !== "idle") return;
1902
+ this.#assignmentState = "assigning";
1903
+ this.#tui.requestRender();
1904
+ try {
1905
+ await Promise.resolve(this.#onSelectCallback(selection));
1906
+ } catch {
1907
+ // The controller reports tracked assignment failures before rethrowing.
1908
+ } finally {
1909
+ this.#assignmentState = "idle";
1910
+ const shouldClose = this.#closeAfterAssignment;
1911
+ this.#closeAfterAssignment = false;
1912
+ if (shouldClose) this.#onCancelCallback();
1913
+ else this.#tui.requestRender();
1914
+ }
1915
+ }
1882
1916
 
1883
1917
  getSearchInput(): Input {
1884
1918
  return this.#searchInput;
@@ -122,7 +122,7 @@ export class PlanPreviewOverlay extends Container {
122
122
  ? `${this.#inputKind === "notes" ? "Notes" : `Comment for source line ${this.#sourceLine}`} (Enter save, Esc cancel): ${this.#input.getValue()}`
123
123
  : this.#focus === "sourceSelect"
124
124
  ? `Source line ${this.#sourceLine}/${this.lines.length || 1} j/k:select c:comment Esc:done`
125
- : `${this.#scroll + 1}-${Math.min(this.#scroll + height, body.length)}/${body.length || 1} s:source line n:notes ${this.options.externalEditorKey ? `${this.options.externalEditorKey.toLowerCase()}:edit ` : ""}Tab:actions PgUp/PgDn:page`;
125
+ : `${this.#scroll + 1}-${Math.min(this.#scroll + height, body.length)}/${body.length || 1} s:source line n:notes ${this.options.externalEditorKey ? `${this.options.externalEditorKey}:edit ` : ""}Tab:actions PgUp/PgDn:page`;
126
126
 
127
127
  const rendered = [
128
128
  new DynamicBorder().render(width)[0] ?? "",
@@ -60,6 +60,7 @@ type WizardStep =
60
60
  */
61
61
  export interface MCPAddWizardOAuthResult {
62
62
  credentialId: string;
63
+ tokenEndpoint: string;
63
64
  clientId?: string;
64
65
  clientSecret?: string;
65
66
  }
@@ -122,6 +123,7 @@ export class MCPAddWizard extends Container {
122
123
  #onCancelCallback: () => void;
123
124
  #onOAuthCallback:
124
125
  | ((
126
+ endpointUrl: string,
125
127
  authUrl: string,
126
128
  tokenUrl: string,
127
129
  clientId: string,
@@ -141,6 +143,7 @@ export class MCPAddWizard extends Container {
141
143
  onComplete: (name: string, config: MCPServerConfig, scope: Scope) => void,
142
144
  onCancel: () => void,
143
145
  onOAuth?: (
146
+ endpointUrl: string,
144
147
  authUrl: string,
145
148
  tokenUrl: string,
146
149
  clientId: string,
@@ -1189,6 +1192,7 @@ export class MCPAddWizard extends Container {
1189
1192
  try {
1190
1193
  // Call OAuth handler
1191
1194
  const oauthResult = await this.#onOAuthCallback(
1195
+ this.#state.url,
1192
1196
  this.#state.oauthAuthUrl,
1193
1197
  this.#state.oauthTokenUrl,
1194
1198
  this.#state.oauthClientId,
@@ -1200,6 +1204,7 @@ export class MCPAddWizard extends Container {
1200
1204
  // Store credential ID + any dynamically-registered client credentials,
1201
1205
  // so the final mcp.json entry persists everything needed for refresh.
1202
1206
  this.#state.oauthCredentialId = oauthResult.credentialId;
1207
+ this.#state.oauthTokenUrl = oauthResult.tokenEndpoint;
1203
1208
  if (oauthResult.clientId) this.#state.oauthClientId = oauthResult.clientId;
1204
1209
  if (oauthResult.clientSecret) this.#state.oauthClientSecret = oauthResult.clientSecret;
1205
1210
 
@@ -3,8 +3,12 @@ import * as fs from "node:fs";
3
3
  import { type Component, truncateToWidth, visibleWidth } from "@gajae-code/tui";
4
4
  import { formatCount, getProjectDir } from "@gajae-code/utils";
5
5
  import { $ } from "bun";
6
- import type { AppKeybinding, KeybindingsManager } from "../../config/keybindings";
7
- import { KEYBINDINGS } from "../../config/keybindings";
6
+ import {
7
+ type AppKeybinding,
8
+ KEYBINDINGS,
9
+ type KeybindingsManager,
10
+ type KeyDisplayContext,
11
+ } from "../../config/keybindings";
8
12
  import { settings } from "../../config/settings";
9
13
  import type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle } from "../../config/settings-schema";
10
14
  import { theme } from "../../modes/theme/theme";
@@ -56,6 +60,7 @@ export interface StatusLineComponentOptions {
56
60
  actionRegistry?: ActionRegistry<void>;
57
61
  getKeybindings?: () => KeybindingsManager;
58
62
  focusDomain?: FocusDomain;
63
+ keyDisplayContext?: KeyDisplayContext;
59
64
  }
60
65
 
61
66
  export interface StatusLineActionHint {
@@ -93,6 +98,7 @@ export function getAvailableActionHints(
93
98
  getKeybindings: (() => KeybindingsManager) | undefined,
94
99
  width: number,
95
100
  domain: FocusDomain = "composer",
101
+ keyDisplayContext?: KeyDisplayContext,
96
102
  ): StatusLineActionHint[] {
97
103
  if (!actionRegistry || !getKeybindings || width <= 0) return [];
98
104
  const keybindings = getKeybindings();
@@ -109,7 +115,9 @@ export function getAvailableActionHints(
109
115
  if (!(bindingId in KEYBINDINGS)) continue;
110
116
  const keys = keybindings.getKeys(bindingId);
111
117
  if (keys.length === 0) continue;
112
- const content = theme.fg("dim", keybindings.getDisplayString(bindingId)) + theme.fg("muted", ` ${action.title}`);
118
+ const content =
119
+ theme.fg("dim", keybindings.getDisplayString(bindingId, keyDisplayContext)) +
120
+ theme.fg("muted", ` ${action.title}`);
113
121
  const nextWidth = visibleWidth(content) + (selected.length === 0 ? 0 : 3);
114
122
  if (used + nextWidth > width) break;
115
123
  selected.push({ id: action.id, content });
@@ -161,6 +169,7 @@ export class StatusLineComponent implements Component {
161
169
  #actionRegistry: ActionRegistry<void> | undefined;
162
170
  #getKeybindings: (() => KeybindingsManager) | undefined;
163
171
  #focusDomain: FocusDomain;
172
+ #keyDisplayContext: KeyDisplayContext | undefined;
164
173
 
165
174
  #resolvedSettingsCache:
166
175
  | (Required<Pick<StatusLineSettings, "leftSegments" | "rightSegments" | "separator" | "segmentOptions">> &
@@ -209,6 +218,7 @@ export class StatusLineComponent implements Component {
209
218
  this.#actionRegistry = options.actionRegistry;
210
219
  this.#getKeybindings = options.getKeybindings;
211
220
  this.#focusDomain = options.focusDomain ?? "composer";
221
+ this.#keyDisplayContext = options.keyDisplayContext;
212
222
  }
213
223
 
214
224
  updateSettings(settings: StatusLineSettings): void {
@@ -796,7 +806,13 @@ export class StatusLineComponent implements Component {
796
806
  const actionHints =
797
807
  effectiveSettings.showActionHints === false
798
808
  ? []
799
- : getAvailableActionHints(this.#actionRegistry, this.#getKeybindings, width, this.#focusDomain);
809
+ : getAvailableActionHints(
810
+ this.#actionRegistry,
811
+ this.#getKeybindings,
812
+ width,
813
+ this.#focusDomain,
814
+ this.#keyDisplayContext,
815
+ );
800
816
  for (const segId of effectiveSettings.rightSegments) {
801
817
  const rendered = renderSegment(segId, ctx);
802
818
  if (rendered.visible && rendered.content) {