@gajae-code/coding-agent 0.11.7 → 0.11.9

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 (213) hide show
  1. package/CHANGELOG.md +73 -1
  2. package/dist/types/cli/args.d.ts +0 -2
  3. package/dist/types/cli.d.ts +1 -0
  4. package/dist/types/config/keybindings.d.ts +33 -3
  5. package/dist/types/config/model-profile-activation.d.ts +4 -3
  6. package/dist/types/config/model-profile-contract.d.ts +38 -0
  7. package/dist/types/config/model-profiles.d.ts +2 -3
  8. package/dist/types/config/settings-schema.d.ts +15 -2
  9. package/dist/types/edit/modes/patch.d.ts +13 -0
  10. package/dist/types/edit/path-mutation-lock.d.ts +15 -0
  11. package/dist/types/eval/py/executor.d.ts +1 -1
  12. package/dist/types/eval/py/tool-bridge.d.ts +1 -2
  13. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +12 -5
  14. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +8 -0
  15. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +41 -0
  16. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +32 -17
  17. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +17 -0
  18. package/dist/types/gjc-runtime/deep-interview-state.d.ts +135 -4
  19. package/dist/types/gjc-runtime/repository-binding.d.ts +63 -0
  20. package/dist/types/gjc-runtime/state-writer.d.ts +44 -0
  21. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +1 -0
  22. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +24 -0
  23. package/dist/types/gjc-runtime/workflow-manifest.d.ts +5 -3
  24. package/dist/types/main.d.ts +8 -0
  25. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +2 -1
  26. package/dist/types/modes/components/tool-status-header.d.ts +3 -2
  27. package/dist/types/modes/components/welcome.d.ts +4 -0
  28. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  29. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  30. package/dist/types/modes/interactive-mode.d.ts +5 -2
  31. package/dist/types/modes/prompt-action-autocomplete.d.ts +6 -1
  32. package/dist/types/modes/prompt-suggestion-controller.d.ts +35 -0
  33. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -2
  34. package/dist/types/modes/types.d.ts +3 -0
  35. package/dist/types/modes/utils/hotkeys-markdown.d.ts +2 -1
  36. package/dist/types/runtime-mcp/manager.d.ts +1 -1
  37. package/dist/types/runtime-mcp/oauth-flow.d.ts +0 -5
  38. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +14 -0
  39. package/dist/types/runtime-mcp/redaction.d.ts +2 -0
  40. package/dist/types/sdk/broker/broker.d.ts +2 -0
  41. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  42. package/dist/types/sdk/bus/chat-daemon-control.d.ts +7 -1
  43. package/dist/types/sdk/bus/config-commands.d.ts +2 -2
  44. package/dist/types/sdk/bus/config.d.ts +2 -0
  45. package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
  46. package/dist/types/sdk/bus/index.d.ts +25 -0
  47. package/dist/types/sdk/bus/notification-orchestration.d.ts +1 -0
  48. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +105 -0
  49. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +15 -3
  50. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -1
  51. package/dist/types/sdk/bus/telegram-daemon.d.ts +128 -1
  52. package/dist/types/sdk/bus/telegram-reference.d.ts +2 -0
  53. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  54. package/dist/types/sdk/host/host.d.ts +1 -1
  55. package/dist/types/sdk/host/query/handlers.d.ts +9 -0
  56. package/dist/types/sdk/index.d.ts +2 -0
  57. package/dist/types/sdk/prompt-status.d.ts +88 -0
  58. package/dist/types/sdk/session-directory.d.ts +1 -1
  59. package/dist/types/sdk/startup-capability.d.ts +3 -0
  60. package/dist/types/session/agent-session.d.ts +2 -0
  61. package/dist/types/session/internal/managed-session-scope.d.ts +22 -3
  62. package/dist/types/session/internal/managed-session-storage.d.ts +24 -4
  63. package/dist/types/session/internal/native-publish-outcome.d.ts +48 -0
  64. package/dist/types/session/session-manager.d.ts +5 -1
  65. package/dist/types/session/session-storage.d.ts +31 -0
  66. package/dist/types/skill-state/active-state.d.ts +4 -0
  67. package/dist/types/task/index.d.ts +3 -1
  68. package/dist/types/task/receipt.d.ts +2 -0
  69. package/dist/types/task/types.d.ts +94 -0
  70. package/dist/types/tools/ask.d.ts +454 -174
  71. package/dist/types/tools/image-gen.d.ts +47 -1
  72. package/dist/types/tools/index.d.ts +4 -0
  73. package/dist/types/utils/prompt-suggestion.d.ts +33 -0
  74. package/package.json +8 -8
  75. package/scripts/capture-platform-shortcut-labels-showcase.ts +342 -0
  76. package/scripts/dogfood-repository-binding.ts +209 -0
  77. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  78. package/scripts/run-test-manifest.ts +4 -4
  79. package/src/cli/args.ts +2 -92
  80. package/src/cli/mcp-cli.ts +4 -21
  81. package/src/cli/notify-cli.ts +5 -38
  82. package/src/cli.ts +50 -0
  83. package/src/commands/deep-interview.ts +9 -4
  84. package/src/commands/sdk.ts +2 -2
  85. package/src/commit/agentic/index.ts +3 -3
  86. package/src/commit/map-reduce/index.ts +2 -2
  87. package/src/config/keybindings.ts +157 -24
  88. package/src/config/model-profile-activation.ts +7 -18
  89. package/src/config/model-profile-contract.ts +161 -0
  90. package/src/config/model-profiles.ts +2 -3
  91. package/src/config/model-registry.ts +11 -1
  92. package/src/config/settings-schema.ts +17 -2
  93. package/src/coordinator-mcp/model-preset.ts +21 -74
  94. package/src/defaults/gjc/skills/deep-interview/SKILL.md +80 -165
  95. package/src/defaults/gjc/skills/ralplan/SKILL.md +3 -1
  96. package/src/edit/index.ts +3 -6
  97. package/src/edit/modes/patch.ts +38 -1
  98. package/src/edit/modes/replace.ts +42 -0
  99. package/src/edit/path-mutation-lock.ts +112 -0
  100. package/src/eval/py/executor.ts +30 -11
  101. package/src/eval/py/prelude.py +2 -2
  102. package/src/eval/py/tool-bridge.ts +34 -18
  103. package/src/extensibility/gjc-plugins/runtime-adapters.ts +2 -1
  104. package/src/gjc-runtime/deep-interview-ambiguity.ts +68 -47
  105. package/src/gjc-runtime/deep-interview-draft.ts +842 -0
  106. package/src/gjc-runtime/deep-interview-payload.ts +262 -0
  107. package/src/gjc-runtime/deep-interview-recorder.ts +294 -226
  108. package/src/gjc-runtime/deep-interview-repair.ts +1674 -0
  109. package/src/gjc-runtime/deep-interview-runtime.ts +264 -103
  110. package/src/gjc-runtime/deep-interview-state.ts +916 -4
  111. package/src/gjc-runtime/launch-tmux.ts +1 -1
  112. package/src/gjc-runtime/managed-owner-supervisor.ts +83 -4
  113. package/src/gjc-runtime/ralplan-runtime.ts +78 -4
  114. package/src/gjc-runtime/repository-binding.ts +267 -0
  115. package/src/gjc-runtime/session-state-sidecar.ts +25 -9
  116. package/src/gjc-runtime/state-writer.ts +166 -2
  117. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -1
  118. package/src/gjc-runtime/tmux-sessions.ts +45 -6
  119. package/src/gjc-runtime/ultragoal-guard.ts +5 -5
  120. package/src/gjc-runtime/ultragoal-runtime.ts +181 -19
  121. package/src/gjc-runtime/workflow-manifest.generated.json +386 -1
  122. package/src/gjc-runtime/workflow-manifest.ts +191 -5
  123. package/src/internal-urls/docs-index.generated.ts +8 -6
  124. package/src/internal-urls/local-protocol.ts +33 -16
  125. package/src/internal-urls/mcp-protocol.ts +73 -11
  126. package/src/main.ts +25 -10
  127. package/src/modes/DESIGN.md +5 -0
  128. package/src/modes/acp/acp-agent.ts +50 -16
  129. package/src/modes/components/model-selector.ts +42 -8
  130. package/src/modes/components/plan-preview-overlay.ts +1 -1
  131. package/src/modes/components/runtime-mcp-add-wizard.ts +5 -0
  132. package/src/modes/components/tool-status-header.ts +20 -4
  133. package/src/modes/components/welcome.ts +32 -20
  134. package/src/modes/controllers/command-controller.ts +97 -54
  135. package/src/modes/controllers/event-controller.ts +10 -0
  136. package/src/modes/controllers/input-controller.ts +7 -2
  137. package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -6
  138. package/src/modes/controllers/selector-controller.ts +46 -54
  139. package/src/modes/interactive-mode.ts +70 -36
  140. package/src/modes/prompt-action-autocomplete.ts +32 -13
  141. package/src/modes/prompt-suggestion-controller.ts +96 -0
  142. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -2
  143. package/src/modes/types.ts +3 -0
  144. package/src/modes/utils/hotkeys-markdown.ts +64 -36
  145. package/src/modes/utils/ui-helpers.ts +5 -3
  146. package/src/prompts/agents/architect.md +0 -1
  147. package/src/prompts/agents/critic.md +0 -1
  148. package/src/prompts/system/prompt-suggestion-system.md +32 -0
  149. package/src/prompts/system/system-prompt.md +7 -4
  150. package/src/prompts/tools/cron.md +3 -1
  151. package/src/runtime-mcp/json-rpc.ts +7 -4
  152. package/src/runtime-mcp/manager.ts +51 -53
  153. package/src/runtime-mcp/oauth-flow.ts +0 -72
  154. package/src/runtime-mcp/plugin-network-boundary.ts +101 -0
  155. package/src/runtime-mcp/redaction.ts +30 -0
  156. package/src/runtime-mcp/transports/http.ts +13 -6
  157. package/src/sdk/broker/broker.ts +34 -20
  158. package/src/sdk/broker/lifecycle.ts +185 -36
  159. package/src/sdk/broker/session-index.ts +11 -1
  160. package/src/sdk/bus/chat-daemon-control.ts +20 -5
  161. package/src/sdk/bus/config-commands.ts +2 -2
  162. package/src/sdk/bus/config.ts +9 -1
  163. package/src/sdk/bus/conversation-store.ts +40 -6
  164. package/src/sdk/bus/index.ts +240 -65
  165. package/src/sdk/bus/lifecycle-commands.ts +47 -3
  166. package/src/sdk/bus/lifecycle-control-runtime.ts +11 -2
  167. package/src/sdk/bus/lifecycle-orchestrator.ts +32 -1
  168. package/src/sdk/bus/notification-orchestration.ts +12 -0
  169. package/src/sdk/bus/notification-service.ts +27 -2
  170. package/src/sdk/bus/prompt-reconciliation.ts +239 -0
  171. package/src/sdk/bus/recent-activity.ts +90 -26
  172. package/src/sdk/bus/telegram-daemon-contract.ts +15 -3
  173. package/src/sdk/bus/telegram-daemon-control.ts +145 -33
  174. package/src/sdk/bus/telegram-daemon.ts +853 -112
  175. package/src/sdk/bus/telegram-reference.ts +20 -2
  176. package/src/sdk/cli/session-cli.ts +15 -2
  177. package/src/sdk/host/control/dispatch.ts +1 -1
  178. package/src/sdk/host/control/operations.ts +1 -1
  179. package/src/sdk/host/host.ts +2 -6
  180. package/src/sdk/host/query/handlers.ts +70 -5
  181. package/src/sdk/index.ts +12 -0
  182. package/src/sdk/prompt-status.ts +97 -0
  183. package/src/sdk/protocol/operation-inventory.generated.json +47 -0
  184. package/src/sdk/protocol/operation-registry.ts +18 -4
  185. package/src/sdk/session-directory.ts +5 -1
  186. package/src/sdk/session.ts +2 -0
  187. package/src/sdk/startup-capability.ts +9 -1
  188. package/src/session/agent-session.ts +138 -5
  189. package/src/session/blob-store.ts +7 -2
  190. package/src/session/internal/managed-session-scope.ts +998 -133
  191. package/src/session/internal/managed-session-storage.ts +268 -203
  192. package/src/session/internal/native-publish-outcome.ts +266 -0
  193. package/src/session/session-manager.ts +406 -360
  194. package/src/session/session-storage.ts +151 -11
  195. package/src/skill-state/active-state.ts +10 -0
  196. package/src/task/executor.ts +13 -5
  197. package/src/task/gjc-command.ts +2 -2
  198. package/src/task/index.ts +121 -15
  199. package/src/task/receipt.ts +3 -0
  200. package/src/task/types.ts +34 -3
  201. package/src/tools/ask.ts +199 -88
  202. package/src/tools/ast-edit.ts +2 -2
  203. package/src/tools/bash-pty-selection.ts +2 -2
  204. package/src/tools/browser/screenshot-format.ts +6 -1
  205. package/src/tools/cron.ts +1 -1
  206. package/src/tools/image-gen.ts +178 -6
  207. package/src/tools/index.ts +4 -0
  208. package/src/tools/render-utils.ts +7 -1
  209. package/src/utils/edit-mode.ts +2 -2
  210. package/src/utils/prompt-suggestion.ts +265 -0
  211. package/src/utils/sixel.ts +3 -3
  212. package/src/web/insane/url-guard.ts +3 -1
  213. package/src/web/scrapers/docs-rs.ts +30 -5
@@ -0,0 +1,30 @@
1
+ const REDACTED = "<redacted>";
2
+ const SENSITIVE_KEY_PATTERN = /(?:token|secret|key|credential|password|authorization|auth|bearer|cookie|session)/i;
3
+
4
+ export function redactMCPEndpoint(value: string | undefined): string | undefined {
5
+ if (!value) return value;
6
+ try {
7
+ const url = new URL(value);
8
+ if (url.username) url.username = REDACTED;
9
+ if (url.password) url.password = REDACTED;
10
+ if (url.pathname !== "/") url.pathname = `/${REDACTED}`;
11
+ for (const key of Array.from(url.searchParams.keys())) {
12
+ url.searchParams.set(key, REDACTED);
13
+ }
14
+ url.hash = "";
15
+ return url.toString();
16
+ } catch {
17
+ return REDACTED;
18
+ }
19
+ }
20
+
21
+ export function redactMCPDiagnosticValue(value: unknown): unknown {
22
+ if (Array.isArray(value)) return value.map(redactMCPDiagnosticValue);
23
+ if (!value || typeof value !== "object") return value;
24
+ return Object.fromEntries(
25
+ Object.entries(value).map(([key, entry]) => [
26
+ key,
27
+ SENSITIVE_KEY_PATTERN.test(key) ? REDACTED : redactMCPDiagnosticValue(entry),
28
+ ]),
29
+ );
30
+ }
@@ -24,6 +24,7 @@ import {
24
24
  MCP_MAX_SSE_REQUEST_MESSAGES,
25
25
  readMCPResponseText,
26
26
  } from "../content-limits";
27
+ import { fetchPluginMcpRequest, isPluginMcpPublicNetworkBound } from "../plugin-network-boundary";
27
28
 
28
29
  /**
29
30
  * HTTP transport for MCP servers.
@@ -62,6 +63,12 @@ export class HttpTransport implements MCPTransport {
62
63
  this.#connected = true;
63
64
  }
64
65
 
66
+ #fetch(init: BunFetchRequestInit): Promise<Response> {
67
+ return isPluginMcpPublicNetworkBound(this.config)
68
+ ? fetchPluginMcpRequest(this.config.url, init)
69
+ : fetch(this.config.url, init);
70
+ }
71
+
65
72
  #trackReader(promise: Promise<void>, controller?: AbortController): void {
66
73
  if (controller) this.#streamControllers.add(controller);
67
74
  this.#streamReaders.add(promise);
@@ -96,7 +103,7 @@ export class HttpTransport implements MCPTransport {
96
103
 
97
104
  let response: Response;
98
105
  try {
99
- response = await fetch(this.config.url, {
106
+ response = await this.#fetch({
100
107
  method: "GET",
101
108
  headers,
102
109
  signal: AbortSignal.any([sseConnection.signal, headerController.signal]),
@@ -227,7 +234,7 @@ export class HttpTransport implements MCPTransport {
227
234
  : abortController.signal;
228
235
 
229
236
  try {
230
- const response = await fetch(this.config.url, {
237
+ const response = await this.#fetch({
231
238
  method: "POST",
232
239
  headers,
233
240
  body: JSON.stringify(body),
@@ -423,7 +430,7 @@ export class HttpTransport implements MCPTransport {
423
430
  headers["Mcp-Session-Id"] = this.#sessionId;
424
431
  }
425
432
  try {
426
- const resp = await fetch(this.config.url, {
433
+ const resp = await this.#fetch({
427
434
  method: "POST",
428
435
  headers,
429
436
  body: JSON.stringify(body),
@@ -437,7 +444,7 @@ export class HttpTransport implements MCPTransport {
437
444
  this.config.headers ??= {};
438
445
  Object.assign(this.config.headers, newHeaders);
439
446
  Object.assign(headers, newHeaders);
440
- const retry = await fetch(this.config.url, {
447
+ const retry = await this.#fetch({
441
448
  method: "POST",
442
449
  headers,
443
450
  body: JSON.stringify(body),
@@ -480,7 +487,7 @@ export class HttpTransport implements MCPTransport {
480
487
  const timeoutId = setTimeout(() => abortController.abort(), timeout);
481
488
 
482
489
  try {
483
- const response = await fetch(this.config.url, {
490
+ const response = await this.#fetch({
484
491
  method: "POST",
485
492
  headers,
486
493
  body: JSON.stringify(body),
@@ -542,7 +549,7 @@ export class HttpTransport implements MCPTransport {
542
549
  "Mcp-Session-Id": this.#sessionId,
543
550
  };
544
551
 
545
- await fetch(this.config.url, {
552
+ await this.#fetch({
546
553
  method: "DELETE",
547
554
  headers,
548
555
  signal: AbortSignal.timeout(timeout),
@@ -1,7 +1,9 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
+ import type { BigIntStats } from "node:fs";
2
3
  import * as fs from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  import type { NativeDirectoryTreeSnapshot } from "@gajae-code/natives";
6
+ import type { ModelProfileErrorDetails } from "../../config/model-profile-contract";
5
7
  import {
6
8
  type DirectoryMigrationPolicy,
7
9
  listManagedSessionCandidates,
@@ -141,6 +143,7 @@ export type BrokerResponse =
141
143
  error: {
142
144
  code: BrokerErrorCode;
143
145
  message: string;
146
+ details?: ModelProfileErrorDetails;
144
147
  endpoint?: "unavailable";
145
148
  cleanup?: BrokerCleanupEvidence;
146
149
  };
@@ -362,6 +365,7 @@ type BrokerLockSnapshot = {
362
365
  ownerId?: string;
363
366
  pid: number;
364
367
  identity: string;
368
+ lockIdentity: string;
365
369
  };
366
370
 
367
371
  const BROKER_PUBLICATION_CADENCE_MS = 5_000;
@@ -415,7 +419,7 @@ export class Broker {
415
419
  #lockRecordPath(): string {
416
420
  return path.join(this.#lock, BROKER_LOCK_RECORD);
417
421
  }
418
- #lockSnapshot(raw: string): BrokerLockSnapshot {
422
+ async #lockSnapshot(raw: string, lockIdentity: string): Promise<BrokerLockSnapshot> {
419
423
  try {
420
424
  const lock = JSON.parse(raw) as { ownerId?: unknown; pid?: unknown };
421
425
  if (
@@ -425,32 +429,37 @@ export class Broker {
425
429
  Number.isInteger(lock.pid) &&
426
430
  lock.pid > 0
427
431
  )
428
- return { ownerId: lock.ownerId, pid: lock.pid, identity: `owner:${lock.ownerId}` };
432
+ return { ownerId: lock.ownerId, pid: lock.pid, identity: `owner:${lock.ownerId}`, lockIdentity };
429
433
  } catch {}
430
- return { pid: 0, identity: `contents:${createHash("sha256").update(raw).digest("hex")}` };
434
+ return { pid: 0, identity: `contents:${createHash("sha256").update(raw).digest("hex")}`, lockIdentity };
431
435
  }
432
436
  async #readLock(): Promise<BrokerLockSnapshot | null> {
437
+ let lock: BigIntStats;
433
438
  try {
434
- return this.#lockSnapshot(await fs.readFile(this.#lockRecordPath(), "utf8"));
439
+ lock = await fs.lstat(this.#lock, { bigint: true });
435
440
  } catch (e) {
436
- const code = (e as NodeJS.ErrnoException).code;
437
- if (code === "ENOTDIR") {
438
- try {
439
- return this.#lockSnapshot(await fs.readFile(this.#lock, "utf8"));
440
- } catch (legacyError) {
441
- if ((legacyError as NodeJS.ErrnoException).code === "ENOENT") return null;
442
- throw legacyError;
443
- }
444
- }
445
- if (code !== "ENOENT") throw e;
441
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") return null;
442
+ throw e;
446
443
  }
444
+ const lockIdentity = `${lock.dev}:${lock.ino}`;
445
+ let raw: string;
447
446
  try {
448
- const lock = await fs.stat(this.#lock);
449
- return lock.isDirectory() ? { pid: 0, identity: `directory:${lock.dev}:${lock.ino}` } : null;
447
+ raw = lock.isDirectory()
448
+ ? await fs.readFile(path.join(this.#lock, BROKER_LOCK_RECORD), "utf8")
449
+ : await fs.readFile(this.#lock, "utf8");
450
+ } catch (e) {
451
+ if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
452
+ raw = "";
453
+ }
454
+ try {
455
+ const current = await fs.lstat(this.#lock, { bigint: true });
456
+ if (`${current.dev}:${current.ino}` !== lockIdentity || current.isDirectory() !== lock.isDirectory())
457
+ return null;
450
458
  } catch (e) {
451
459
  if ((e as NodeJS.ErrnoException).code === "ENOENT") return null;
452
460
  throw e;
453
461
  }
462
+ return this.#lockSnapshot(raw, lockIdentity);
454
463
  }
455
464
  async #createLock(): Promise<void> {
456
465
  await fs.mkdir(this.#lock, { mode: 0o700 });
@@ -478,13 +487,18 @@ export class Broker {
478
487
  }
479
488
  async #reclaimStaleLock(snapshot: BrokerLockSnapshot): Promise<void> {
480
489
  const current = await this.#readLock();
481
- if (!current || current.identity !== snapshot.identity || (current.pid > 0 && isPidAlive(current.pid))) return;
490
+ if (
491
+ !current ||
492
+ current.identity !== snapshot.identity ||
493
+ current.lockIdentity !== snapshot.lockIdentity ||
494
+ (current.pid > 0 && isPidAlive(current.pid))
495
+ )
496
+ return;
482
497
 
483
- // Keep the tombstone: its immutable-owner-derived pathname prevents a contender
484
- // holding an old snapshot from renaming a newly-created directory lock.
498
+ // Snapshot validation and the deterministic instance suffix protect successor locks.
485
499
  const tombstone = path.join(
486
500
  path.dirname(this.#lock),
487
- `.broker.lock.stale-${createHash("sha256").update(snapshot.identity).digest("hex")}`,
501
+ `.broker.lock.stale-${createHash("sha256").update(snapshot.lockIdentity).digest("hex")}`,
488
502
  );
489
503
  try {
490
504
  await fs.rename(this.#lock, tombstone);
@@ -7,6 +7,13 @@ import type { NativeExactUnlinkResult } from "@gajae-code/natives";
7
7
  import * as native from "@gajae-code/natives";
8
8
  import { resolveEquivalentPath } from "@gajae-code/utils";
9
9
 
10
+ import {
11
+ isModelProfileError,
12
+ type ModelProfileErrorDetails,
13
+ validateModelProfileName,
14
+ } from "../../config/model-profile-contract";
15
+ import { mergeModelProfiles } from "../../config/model-profiles";
16
+ import { ModelsConfigFile } from "../../config/model-registry";
10
17
  import {
11
18
  ensureLaunchWorktree,
12
19
  ensureReusableNodeModules,
@@ -326,14 +333,36 @@ function serializeCleanupIdentity(identity: CleanupIdentity): BrokerCleanupIdent
326
333
  };
327
334
  }
328
335
 
329
- const fail = (code: string, message: string, cleanup?: CleanupEvidence): BrokerResponse => ({
336
+ const fail = (
337
+ code: string,
338
+ message: string,
339
+ cleanup?: CleanupEvidence,
340
+ details?: ModelProfileErrorDetails,
341
+ ): BrokerResponse => ({
330
342
  ok: false,
331
- error: { code: code as never, message, ...(cleanup ? { cleanup } : {}) },
343
+ error: { code: code as never, message, ...(details ? { details } : {}), ...(cleanup ? { cleanup } : {}) },
332
344
  });
333
345
  function text(value: unknown): string | undefined {
334
346
  return typeof value === "string" && value ? value : undefined;
335
347
  }
336
348
 
349
+ function validateBrokerModelPreset(agentDir: string, requestedProfile: string): string | BrokerResponse {
350
+ const modelsConfigFile = ModelsConfigFile.relocate(path.join(agentDir, "models.yml"));
351
+ modelsConfigFile.invalidate();
352
+ const loaded = modelsConfigFile.tryLoad();
353
+ const profiles = mergeModelProfiles(loaded.status === "ok" ? loaded.value.profiles : undefined);
354
+ try {
355
+ return validateModelProfileName(requestedProfile, profiles, loaded.status === "error" ? loaded.error : undefined);
356
+ } catch (error) {
357
+ if (isModelProfileError(error)) return fail(error.code, error.message, undefined, error.details);
358
+ throw error;
359
+ }
360
+ }
361
+
362
+ export function validateBrokerModelPresetForTest(agentDir: string, requestedProfile: string): string | BrokerResponse {
363
+ return validateBrokerModelPreset(agentDir, requestedProfile);
364
+ }
365
+
337
366
  function readinessTimeout(input: Input): number | BrokerResponse {
338
367
  const value = input.readinessTimeoutMs;
339
368
  if (value === undefined) return READY_TIMEOUT_MS;
@@ -624,7 +653,7 @@ type ReadyAuthority = {
624
653
  };
625
654
  type ReadinessResult =
626
655
  | { kind: "ready"; authority: ReadyAuthority }
627
- | { kind: "startup_failed"; message: string }
656
+ | { kind: "startup_failed"; failure: SdkStartupFailure }
628
657
  | { kind: "child_exited" }
629
658
  | { kind: "timeout" };
630
659
  const processIncarnationReadersForTest = new WeakMap<Broker, (pid: number) => string | undefined>();
@@ -797,19 +826,25 @@ function isLifecycleTranscriptEvidence(value: unknown): value is LifecycleTransc
797
826
  function isSdkStartupFailure(value: unknown): value is SdkStartupFailure {
798
827
  if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
799
828
  const failure = value as Record<string, unknown>;
800
- return (
801
- Object.keys(failure).length === 3 &&
802
- (failure.phase === "registration" || failure.phase === "startup") &&
803
- (failure.reason === "disabled" ||
804
- failure.reason === "ineligible" ||
805
- failure.reason === "factory_absent" ||
806
- failure.reason === "runner_absent" ||
807
- failure.reason === "pending" ||
808
- failure.reason === "failed") &&
809
- typeof failure.message === "string" &&
810
- Buffer.byteLength(failure.message) > 0 &&
811
- Buffer.byteLength(failure.message) <= 512
812
- );
829
+ const keys = Object.keys(failure);
830
+ if (
831
+ !keys.every(
832
+ key => key === "phase" || key === "reason" || key === "message" || key === "code" || key === "details",
833
+ ) ||
834
+ (failure.phase !== "registration" && failure.phase !== "startup") ||
835
+ (failure.reason !== "disabled" &&
836
+ failure.reason !== "ineligible" &&
837
+ failure.reason !== "factory_absent" &&
838
+ failure.reason !== "runner_absent" &&
839
+ failure.reason !== "pending" &&
840
+ failure.reason !== "failed") ||
841
+ typeof failure.message !== "string" ||
842
+ Buffer.byteLength(failure.message) === 0 ||
843
+ Buffer.byteLength(failure.message) > 512
844
+ )
845
+ return false;
846
+ if (failure.code === undefined && failure.details === undefined) return keys.length === 3;
847
+ return keys.length === 5 && isModelProfileError(failure);
813
848
  }
814
849
 
815
850
  function isLifecycleFailureArtifact(value: unknown): value is LifecycleFailureArtifact {
@@ -818,8 +853,14 @@ function isLifecycleFailureArtifact(value: unknown): value is LifecycleFailureAr
818
853
  if (!isEffectMarker(record)) return false;
819
854
  const artifact = value as LifecycleFailureArtifact;
820
855
  return (
821
- Object.keys(record).length === (artifact.transcript === undefined ? 7 : 8) &&
822
- isSdkStartupFailure({ phase: artifact.phase, reason: artifact.reason, message: artifact.message }) &&
856
+ Object.keys(record).length ===
857
+ 7 + (artifact.code === undefined ? 0 : 2) + (artifact.transcript === undefined ? 0 : 1) &&
858
+ isSdkStartupFailure({
859
+ phase: artifact.phase,
860
+ reason: artifact.reason,
861
+ message: artifact.message,
862
+ ...(artifact.code === undefined ? {} : { code: artifact.code, details: artifact.details }),
863
+ }) &&
823
864
  isRollbackResult(artifact.rollback) &&
824
865
  (artifact.transcript === undefined || isLifecycleTranscriptEvidence(artifact.transcript))
825
866
  );
@@ -865,6 +906,8 @@ export async function writeSessionLifecycleFailure(
865
906
  ...(transcript ? { transcript } : {}),
866
907
  };
867
908
  const bytes = Buffer.from(canonicalJson(artifact), "utf8");
909
+ if (bytes.length > MAX_LIFECYCLE_METADATA_BYTES)
910
+ throw new Error("Lifecycle startup failure exceeds the metadata size ceiling.");
868
911
  const target = lifecycleFailurePath(root, id, effectMarker);
869
912
  const temporary = path.join(directory, `.${id}.lifecycle.failure.${effectMarker}.${randomUUID()}.tmp`);
870
913
  let published = false;
@@ -1241,6 +1284,10 @@ function validateLifecycleMetadataReplay(cleanup: CleanupEvidence): BrokerRespon
1241
1284
  if (!current) continue;
1242
1285
  if (!sameLifecycleCleanupIdentity(current.identity, file.identity))
1243
1286
  return fail("terminal_uncertain", "Lifecycle metadata candidate lacks exact replay authority.");
1287
+ // A completed file's recorded retained quarantine is receipt-bound durable
1288
+ // evidence; anything else that remains is an active survivor.
1289
+ if (file.completed && file.detachedPath && path.resolve(candidate) === path.resolve(file.detachedPath))
1290
+ continue;
1244
1291
  activeCandidates++;
1245
1292
  }
1246
1293
  if (file.completed && activeCandidates > 0)
@@ -1289,7 +1336,21 @@ function lifecycleMetadataReplayAbsent(cleanup: CleanupEvidence): boolean {
1289
1336
  const id = cleanup.sessionId!;
1290
1337
  const required = new Set<string>([lifecycleMarkerPath(root, id), lifecycleReadyPath(root, id)]);
1291
1338
  for (const file of files) for (const candidate of lifecycleCleanupCandidates(file)) required.add(candidate);
1292
- return [...required].every(absentLifecyclePath);
1339
+ for (const candidate of required) {
1340
+ if (absentLifecyclePath(candidate)) continue;
1341
+ // A completed file's recorded retained quarantine is durable evidence, not a
1342
+ // survivor — accept it only at its receipt-bound path and identity.
1343
+ const owner = files.find(
1344
+ file =>
1345
+ file.completed === true &&
1346
+ file.detachedPath !== undefined &&
1347
+ path.resolve(file.detachedPath) === path.resolve(candidate),
1348
+ );
1349
+ if (!owner) return false;
1350
+ const current = captureLifecycleFile(candidate, true, true);
1351
+ if (!current || !sameLifecycleCleanupIdentity(current.identity, owner.identity)) return false;
1352
+ }
1353
+ return true;
1293
1354
  }
1294
1355
 
1295
1356
  /**
@@ -1552,16 +1613,28 @@ async function reconcileLifecycleCleanup(
1552
1613
  const candidates = lifecycleCleanupCandidates(file);
1553
1614
  if (file.completed) {
1554
1615
  for (const candidate of candidates) {
1616
+ let stat: ReturnType<typeof fsSync.lstatSync>;
1555
1617
  try {
1556
- fsSync.lstatSync(candidate);
1557
- return fail(
1558
- "terminal_uncertain",
1559
- "Lifecycle cleanup receipt marks a target complete while an authorized candidate remains.",
1560
- );
1618
+ stat = fsSync.lstatSync(candidate);
1561
1619
  } catch (error) {
1562
- if ((error as NodeJS.ErrnoException).code !== "ENOENT")
1563
- return fail("terminal_uncertain", "Lifecycle cleanup completion could not be safely inspected.");
1620
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
1621
+ return fail("terminal_uncertain", "Lifecycle cleanup completion could not be safely inspected.");
1622
+ }
1623
+ // A completed file's recorded retained quarantine is durable evidence,
1624
+ // not a survivor — accept it only at its receipt-bound path and identity.
1625
+ if (
1626
+ file.detachedPath &&
1627
+ path.resolve(candidate) === path.resolve(file.detachedPath) &&
1628
+ stat.isFile() &&
1629
+ !stat.isSymbolicLink()
1630
+ ) {
1631
+ const current = captureLifecycleFile(candidate, true, true);
1632
+ if (current && sameLifecycleCleanupIdentity(current.identity, file.identity)) continue;
1564
1633
  }
1634
+ return fail(
1635
+ "terminal_uncertain",
1636
+ "Lifecycle cleanup receipt marks a target complete while an authorized candidate remains.",
1637
+ );
1565
1638
  }
1566
1639
  continue;
1567
1640
  }
@@ -1631,6 +1704,22 @@ async function reconcileLifecycleCleanup(
1631
1704
  quarantineName: path.basename(currentFile.plannedPath),
1632
1705
  });
1633
1706
  if (!result.ok) {
1707
+ if (result.code === "cleanup_pending" && result.detachedPath === currentFile.plannedPath) {
1708
+ // Typed retained authority: the native verified the exact identity-bound
1709
+ // detach and retained the quarantine as durable evidence. Record the
1710
+ // evidence and advance — never claim a terminal byte deletion.
1711
+ const lifecycleFiles = activeCleanup.lifecycleFiles!.map((candidate, candidateIndex) =>
1712
+ candidateIndex === index
1713
+ ? { ...candidate, detachedPath: result.detachedPath, completed: true as const }
1714
+ : candidate,
1715
+ );
1716
+ activeCleanup = { ...activeCleanup, lifecycleFiles };
1717
+ await broker.ledger.transition(identity, "effect_started", {
1718
+ response: fail("cleanup_pending", "Lifecycle cleanup completion was durably reconciled.", activeCleanup),
1719
+ });
1720
+ lifecycleCleanupHooksForTest.get(broker)?.();
1721
+ continue;
1722
+ }
1634
1723
  const lifecycleFiles = activeCleanup.lifecycleFiles!.map((candidate, candidateIndex) =>
1635
1724
  candidateIndex === index && result.detachedPath
1636
1725
  ? { ...candidate, detachedPath: result.detachedPath }
@@ -1665,6 +1754,22 @@ async function readSessionLifecycleFailure(
1665
1754
  ?.artifact;
1666
1755
  }
1667
1756
 
1757
+ export async function readSessionLifecycleFailureForTest(
1758
+ root: string,
1759
+ id: string,
1760
+ expected: { pid: number; effectMarker: string; incarnation: string },
1761
+ ): Promise<SdkStartupFailure | undefined> {
1762
+ const artifact = await readSessionLifecycleFailure(root, id, expected);
1763
+ return artifact
1764
+ ? {
1765
+ phase: artifact.phase,
1766
+ reason: artifact.reason,
1767
+ message: artifact.message,
1768
+ ...(artifact.code === undefined ? {} : { code: artifact.code, details: artifact.details }),
1769
+ }
1770
+ : undefined;
1771
+ }
1772
+
1668
1773
  async function hasDurableProcessIdentity(
1669
1774
  root: string,
1670
1775
  id: string,
@@ -2056,14 +2161,14 @@ async function waitForReady(
2056
2161
  ): Promise<ReadinessResult> {
2057
2162
  while (timing.now() < deadline) {
2058
2163
  const startupFailure = await readSessionLifecycleFailure(root, id, expected);
2059
- if (startupFailure) return { kind: "startup_failed", message: startupFailure.message };
2164
+ if (startupFailure) return { kind: "startup_failed", failure: startupFailure };
2060
2165
  if (
2061
2166
  observeProcess(expected.pid, expected.incarnation, value => processIncarnationForBroker(broker, value)) ===
2062
2167
  "exited"
2063
2168
  ) {
2064
2169
  const finalStartupFailure = await readSessionLifecycleFailure(root, id, expected);
2065
2170
  return finalStartupFailure
2066
- ? { kind: "startup_failed", message: finalStartupFailure.message }
2171
+ ? { kind: "startup_failed", failure: finalStartupFailure }
2067
2172
  : { kind: "child_exited" };
2068
2173
  }
2069
2174
  try {
@@ -2191,6 +2296,8 @@ async function launchInput(
2191
2296
  const requested = sessionId(input);
2192
2297
  if (requested !== undefined && !isCanonicalSessionId(requested))
2193
2298
  return fail("invalid_input", "sessionId must be a canonical safe identifier.");
2299
+ if (input.modelPreset !== undefined && (typeof input.modelPreset !== "string" || input.modelPreset.length === 0))
2300
+ return fail("invalid_input", "modelPreset must be a non-empty exact profile ID.");
2194
2301
  const modelPreset = text(input.modelPreset);
2195
2302
 
2196
2303
  if (operation === "session.create")
@@ -2615,6 +2722,11 @@ async function executeLifecycleResponse(
2615
2722
 
2616
2723
  const launch = await launchInput(broker, operation, input);
2617
2724
  if ("ok" in launch) return launch;
2725
+ if (launch.modelPreset) {
2726
+ const validatedModelPreset = validateBrokerModelPreset(broker.settings.agentDir, launch.modelPreset);
2727
+ if (typeof validatedModelPreset !== "string") return validatedModelPreset;
2728
+ launch.modelPreset = validatedModelPreset;
2729
+ }
2618
2730
  if (!hasProcessIncarnationAuthority())
2619
2731
  return fail(
2620
2732
  "incarnation_unavailable",
@@ -2768,7 +2880,12 @@ async function executeLifecycleResponse(
2768
2880
  `Session ${launch.id} did not become ready and its spawned process could not be verified dead.`,
2769
2881
  );
2770
2882
  return readiness.kind === "startup_failed"
2771
- ? fail("spawn_failed", readiness.message)
2883
+ ? fail(
2884
+ readiness.failure.code ?? "spawn_failed",
2885
+ readiness.failure.message,
2886
+ undefined,
2887
+ readiness.failure.details,
2888
+ )
2772
2889
  : readiness.kind === "child_exited"
2773
2890
  ? fail("spawn_failed", `Session ${launch.id} exited before registering readiness.`)
2774
2891
  : fail(
@@ -3043,11 +3160,26 @@ async function executeLifecycleResponse(
3043
3160
  `Unable to delete saved session artifacts: ${error instanceof Error ? error.message : String(error)}`,
3044
3161
  );
3045
3162
  }
3046
- if (deleted.kind === "artifacts_removed") {
3163
+ const retainedArtifactsCleanup =
3164
+ deleted.kind === "cleanup_pending" &&
3165
+ deleted.phase === "artifacts" &&
3166
+ deleted.detachedArtifactsPath !== undefined &&
3167
+ cleanupTarget.plannedArtifactsPath !== undefined &&
3168
+ (deleted.detachedArtifactsPath === cleanupTarget.plannedArtifactsPath ||
3169
+ deleted.detachedArtifactsPath === `${cleanupTarget.plannedArtifactsPath}.removing`);
3170
+ if (deleted.kind === "artifacts_removed" || retainedArtifactsCleanup) {
3047
3171
  const transcriptPhaseCleanup = {
3048
3172
  ...preauthorizedCleanup,
3049
3173
  phase: "transcript" as const,
3050
3174
  artifactsRemoved: true,
3175
+ ...(deleted.kind === "cleanup_pending" && deleted.phase === "artifacts"
3176
+ ? {
3177
+ detachedArtifactsPath: deleted.detachedArtifactsPath,
3178
+ ...(deleted.artifactsIdentity
3179
+ ? { artifactsIdentity: serializeCleanupIdentity(deleted.artifactsIdentity) }
3180
+ : {}),
3181
+ }
3182
+ : {}),
3051
3183
  ...(preauthorizedCleanup.artifactTree
3052
3184
  ? { artifactTree: { ...preauthorizedCleanup.artifactTree, completed: true as const } }
3053
3185
  : {}),
@@ -3068,7 +3200,14 @@ async function executeLifecycleResponse(
3068
3200
  artifactsRemoved: true,
3069
3201
  });
3070
3202
  }
3071
- if (deleted.kind === "cleanup_pending")
3203
+ if (
3204
+ deleted.kind === "cleanup_pending" &&
3205
+ !(
3206
+ deleted.phase === "transcript" &&
3207
+ deleted.detachedTranscriptPath !== undefined &&
3208
+ deleted.detachedTranscriptPath === cleanupTarget.plannedTranscriptPath
3209
+ )
3210
+ )
3072
3211
  return fail(
3073
3212
  "cleanup_pending",
3074
3213
  `Saved session cleanup is pending in ${deleted.phase}: ${deleted.error.message}`,
@@ -3323,6 +3462,9 @@ export async function executeLifecycle(
3323
3462
  phase: evidence.artifact.phase,
3324
3463
  reason: evidence.artifact.reason,
3325
3464
  message: evidence.artifact.message,
3465
+ ...(evidence.artifact.code === undefined
3466
+ ? {}
3467
+ : { code: evidence.artifact.code, details: evidence.artifact.details }),
3326
3468
  rollback: {
3327
3469
  endpointGeneration: evidence.artifact.rollback.endpointGeneration,
3328
3470
  fenced: evidence.artifact.rollback.fenced,
@@ -3387,11 +3529,18 @@ export async function executeLifecycle(
3387
3529
  : startupFailure && cleanupProof
3388
3530
  ? {
3389
3531
  ok: false,
3390
- error: {
3391
- code: "spawn_failed",
3392
- message: "No ready SDK endpoint remains available.",
3393
- endpoint: "unavailable",
3394
- },
3532
+ error: startupFailure.code
3533
+ ? {
3534
+ code: startupFailure.code,
3535
+ message: startupFailure.message,
3536
+ details: startupFailure.details!,
3537
+ endpoint: "unavailable" as const,
3538
+ }
3539
+ : {
3540
+ code: "spawn_failed",
3541
+ message: "No ready SDK endpoint remains available.",
3542
+ endpoint: "unavailable" as const,
3543
+ },
3395
3544
  ...(durableEffects ? { durableEffects } : {}),
3396
3545
  startupFailure,
3397
3546
  }
@@ -131,6 +131,7 @@ function compactEvents(events: SessionIndexEvent[]): SessionIndexEvent[] {
131
131
 
132
132
  async function appendSync(file: string, value: string): Promise<void> {
133
133
  const h = await fs.open(file, "a", 0o600);
134
+ let failure: { error: unknown } | undefined;
134
135
  try {
135
136
  const data = Buffer.from(`${value}\n`);
136
137
  for (let offset = 0; offset < data.length; ) {
@@ -139,9 +140,18 @@ async function appendSync(file: string, value: string): Promise<void> {
139
140
  offset += bytesWritten;
140
141
  }
141
142
  await h.sync();
142
- } finally {
143
+ } catch (error) {
144
+ failure = { error };
145
+ }
146
+ try {
143
147
  await h.close();
148
+ } catch (error) {
149
+ // Bun may report EBADF when concurrent child-pipe teardown has already
150
+ // released a fully written and fsynced append handle. The descriptor is
151
+ // closed in that case; every other close failure remains fatal.
152
+ if ((error as NodeJS.ErrnoException).code !== "EBADF" && failure === undefined) failure = { error };
144
153
  }
154
+ if (failure !== undefined) throw failure.error;
145
155
  }
146
156
 
147
157
  async function syncDirectory(file: string): Promise<void> {
@@ -28,11 +28,17 @@ export type ChatDaemonAction = "stop" | "reload";
28
28
  * Generation 7 restores macOS daemon signaling (kill(2) with a start-time
29
29
  * incarnation recheck) so a live/hung owner can be replaced without an external
30
30
  * `kill -9`. Generation 8 adopts Windows expected-identity ACL verification and
31
- * repair for shared native authority.
31
+ * repair for shared native authority. Generation 9 refreshes the shared native
32
+ * authority declaration contract with bounded frame-delivery acknowledgement.
33
+ * Generation 11 accepts typed retained exact-unlink cleanup authority — a
34
+ * concrete detached quarantine plus a proven-absent canonical lock pathname —
35
+ * when deleting an observed owner-lock lease. Generation 12 refreshes retained
36
+ * native path and process authority semantics. Generation 13 rejects special
37
+ * files before retained native authority opens.
32
38
  */
33
39
  export const CHAT_DAEMON_GENERATIONS: Readonly<Record<ChatDaemonKind, number>> = {
34
- discord: 8,
35
- slack: 8,
40
+ discord: 13,
41
+ slack: 13,
36
42
  };
37
43
 
38
44
  export function chatDaemonGeneration(kind: ChatDaemonKind): number {
@@ -938,10 +944,19 @@ async function ownsChatDaemonOwnerLock(lock: string, lease: ChatDaemonOwnerLockL
938
944
  /** Deletes only the exact lease observed by this contender; a successor is retained. */
939
945
  function unlinkExactChatDaemonOwnerLock(lock: string, lease: ChatDaemonOwnerLockLease): boolean {
940
946
  try {
941
- return native.exactUnlink(lock, {
947
+ const removed = native.exactUnlink(lock, {
942
948
  ...lease,
943
949
  quarantineName: `.gjc-delete-chat-daemon-lock-${crypto.randomUUID()}`,
944
- }).ok;
950
+ });
951
+ if (removed.ok) return true;
952
+ // Accept only typed retained authority: a concrete detached quarantine plus
953
+ // a proven-absent canonical lock pathname. Anything else stays fail-closed.
954
+ return (
955
+ removed.code === "cleanup_pending" &&
956
+ typeof removed.detachedPath === "string" &&
957
+ removed.detachedPath.length > 0 &&
958
+ !fs.existsSync(lock)
959
+ );
945
960
  } catch {
946
961
  return false;
947
962
  }
@@ -5,8 +5,8 @@
5
5
  * removed), but the user can still adjust per-surface behaviour from inside a
6
6
  * session thread with small slash commands:
7
7
  *
8
- * - `/verbose` switch the mirror to bounded tool-owned summaries + provider-displayable reasoning summaries
9
- * - `/lean` switch back to lean (assistant text + tool names)
8
+ * - `/verbose` switch the mirror to per-turn assistant text + tool summaries + reasoning summaries
9
+ * - `/lean` settled assistant answer at idle + immediate ask lead-ins (no intermediate tool-turn flood)
10
10
  * - `/verbosity lean|verbose`
11
11
  * - `/redact on|off` toggle redaction of streamed content
12
12
  *