@gajae-code/coding-agent 0.18.8 → 0.18.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 (113) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/dist/types/async/endpoint-id.d.ts +16 -0
  3. package/dist/types/async/support.d.ts +0 -14
  4. package/dist/types/commands/mcp-serve.d.ts +4 -0
  5. package/dist/types/discovery/mcp-json.d.ts +1 -0
  6. package/dist/types/eval/py/kernel.d.ts +18 -0
  7. package/dist/types/export/html/html-export-destination.d.ts +2 -0
  8. package/dist/types/extensibility/extensions/function-hooks-internal.d.ts +5 -0
  9. package/dist/types/extensibility/skill-management.d.ts +1 -0
  10. package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -3
  11. package/dist/types/gjc-runtime/sidecar-bootstrap-env.d.ts +6 -0
  12. package/dist/types/gjc-runtime/state-writer.d.ts +3 -0
  13. package/dist/types/gjc-runtime/tmux-common.d.ts +8 -5
  14. package/dist/types/lsp/config.d.ts +8 -5
  15. package/dist/types/runtime-mcp/manager.d.ts +2 -1
  16. package/dist/types/runtime-mcp/mcp-redirect.d.ts +9 -0
  17. package/dist/types/runtime-mcp/oauth-public-url.d.ts +3 -1
  18. package/dist/types/runtime-mcp/project-host-values.d.ts +27 -0
  19. package/dist/types/sdk/broker/broker.d.ts +6 -1
  20. package/dist/types/sdk/broker/doctor-restart.d.ts +5 -0
  21. package/dist/types/sdk/broker/ensure.d.ts +14 -1
  22. package/dist/types/session/session-storage.d.ts +7 -0
  23. package/dist/types/skill-state/workflow-mutation-guard.d.ts +3 -0
  24. package/dist/types/task/isolation-dir.d.ts +26 -0
  25. package/dist/types/tools/ast-edit-write-path.d.ts +13 -0
  26. package/dist/types/tools/browser/cdp-url.d.ts +2 -0
  27. package/dist/types/tools/github-side-effect.d.ts +22 -0
  28. package/dist/types/tools/monitor-mutation.d.ts +9 -0
  29. package/package.json +7 -7
  30. package/src/async/endpoint-id.ts +26 -0
  31. package/src/async/job-manager.ts +19 -10
  32. package/src/async/support.ts +0 -25
  33. package/src/capability/index.ts +14 -16
  34. package/src/cli/doctor/repairs.ts +4 -1
  35. package/src/cli/doctor-supervisor.ts +3 -0
  36. package/src/cli/public-command-registry.ts +28 -6
  37. package/src/commands/mcp-serve.ts +7 -0
  38. package/src/commands/sdk.ts +453 -415
  39. package/src/config/file-lock.ts +9 -11
  40. package/src/config/model-profile-activation.ts +5 -5
  41. package/src/coordinator-mcp/server.ts +2 -0
  42. package/src/customization/inventory.ts +1 -13
  43. package/src/dap/config.ts +2 -2
  44. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +10 -6
  45. package/src/discovery/builtin.ts +3 -1
  46. package/src/discovery/helpers.ts +12 -9
  47. package/src/discovery/mcp-json.ts +20 -12
  48. package/src/eval/py/executor.ts +459 -77
  49. package/src/eval/py/kernel.ts +79 -13
  50. package/src/eval/py/tool-bridge.ts +4 -0
  51. package/src/export/html/html-export-destination.ts +16 -0
  52. package/src/export/html/index.ts +2 -6
  53. package/src/extensibility/extensions/function-hooks-internal.ts +27 -0
  54. package/src/extensibility/extensions/runner.ts +11 -1
  55. package/src/extensibility/gjc-plugins/mcp-policy.ts +2 -1
  56. package/src/extensibility/gjc-plugins/runtime-adapters.ts +27 -15
  57. package/src/extensibility/skill-management.ts +3 -0
  58. package/src/gjc-runtime/launch-worktree.ts +56 -5
  59. package/src/gjc-runtime/psmux-detect.ts +28 -4
  60. package/src/gjc-runtime/session-state-sidecar.ts +3 -2
  61. package/src/gjc-runtime/sidecar-bootstrap-env.ts +19 -0
  62. package/src/gjc-runtime/state-writer.ts +22 -8
  63. package/src/gjc-runtime/tmux-common.ts +8 -5
  64. package/src/internal-urls/docs-index.generated.ts +7 -7
  65. package/src/lsp/config.ts +12 -63
  66. package/src/memories/index.ts +88 -7
  67. package/src/modes/components/hook-editor.ts +1 -1
  68. package/src/modes/components/hook-selector.ts +1 -1
  69. package/src/modes/controllers/input-controller.ts +2 -2
  70. package/src/modes/controllers/runtime-mcp-command-controller.ts +21 -13
  71. package/src/modes/controllers/selector-controller.ts +3 -7
  72. package/src/modes/controllers/todo-command-controller.ts +1 -1
  73. package/src/modes/interactive-mode.ts +1 -1
  74. package/src/runtime-mcp/client.ts +4 -2
  75. package/src/runtime-mcp/json-rpc.ts +2 -1
  76. package/src/runtime-mcp/manager.ts +29 -11
  77. package/src/runtime-mcp/mcp-redirect.ts +73 -0
  78. package/src/runtime-mcp/oauth-flow.ts +11 -4
  79. package/src/runtime-mcp/oauth-public-url.ts +8 -2
  80. package/src/runtime-mcp/pool.ts +6 -0
  81. package/src/runtime-mcp/project-host-values.ts +46 -0
  82. package/src/runtime-mcp/transports/http.ts +2 -1
  83. package/src/sdk/acp/adapter.ts +2 -0
  84. package/src/sdk/broker/broker.ts +6 -0
  85. package/src/sdk/broker/doctor-restart.ts +4 -0
  86. package/src/sdk/broker/ensure.ts +29 -2
  87. package/src/sdk/host/session-runtime.test.ts +58 -9
  88. package/src/sdk/host/session-runtime.ts +65 -35
  89. package/src/sdk/router/session-router.ts +7 -6
  90. package/src/sdk/session.ts +2 -7
  91. package/src/session/agent-session.ts +59 -17
  92. package/src/session/artifacts.ts +3 -2
  93. package/src/session/internal/managed-session-scope.ts +403 -81
  94. package/src/session/internal/managed-session-storage.ts +125 -17
  95. package/src/session/session-manager.ts +12 -0
  96. package/src/session/session-storage.ts +30 -1
  97. package/src/skill-state/active-state.ts +13 -0
  98. package/src/skill-state/workflow-mutation-guard.ts +102 -10
  99. package/src/slash-commands/builtin-registry.ts +1 -1
  100. package/src/slash-commands/helpers/mcp.ts +8 -4
  101. package/src/task/isolation-dir.ts +143 -0
  102. package/src/task/worktree.ts +16 -7
  103. package/src/tools/ast-edit-write-path.ts +68 -0
  104. package/src/tools/ast-edit.ts +7 -2
  105. package/src/tools/browser/cdp-url.ts +18 -0
  106. package/src/tools/browser.ts +8 -1
  107. package/src/tools/gh.ts +10 -0
  108. package/src/tools/github-side-effect.ts +112 -0
  109. package/src/tools/monitor-mutation.ts +27 -0
  110. package/src/tools/monitor.ts +7 -0
  111. package/src/tools/python.ts +7 -6
  112. package/src/utils/external-editor.ts +3 -2
  113. package/src/web/insane/url-guard.ts +2 -1
@@ -0,0 +1,73 @@
1
+ import { cancelMCPStream } from "./content-limits";
2
+
3
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
4
+ const MAX_REDIRECTS = 20;
5
+ const REQUEST_BODY_HEADERS = [
6
+ "content-encoding",
7
+ "content-language",
8
+ "content-length",
9
+ "content-location",
10
+ "content-type",
11
+ ] as const;
12
+
13
+ function rewritesToGet(status: number, method: string): boolean {
14
+ return (
15
+ ((status === 301 || status === 302) && method === "POST") ||
16
+ (status === 303 && method !== "GET" && method !== "HEAD")
17
+ );
18
+ }
19
+
20
+ /**
21
+ * Follow same-origin MCP redirects only.
22
+ *
23
+ * A cross-origin 307/308 would carry custom credential headers, `Mcp-Session-Id`,
24
+ * and the JSON-RPC body. The first request uses `rawUrl` unchanged so a caller
25
+ * that cannot be parsed (diagnostic redaction) still reaches fetch, but with
26
+ * `redirect: "manual"` so that hop is not followed either.
27
+ */
28
+ export async function fetchMcpRespectingOrigin(rawUrl: string, init: BunFetchRequestInit): Promise<Response> {
29
+ let currentUrl: URL;
30
+ try {
31
+ currentUrl = new URL(rawUrl);
32
+ } catch {
33
+ return fetch(rawUrl, { ...init, redirect: "manual" });
34
+ }
35
+
36
+ let requestTarget = rawUrl;
37
+ let currentInit: BunFetchRequestInit = { ...init, redirect: "manual" };
38
+ for (let redirectCount = 0; ; redirectCount++) {
39
+ const response = await fetch(requestTarget, currentInit);
40
+ if (!REDIRECT_STATUSES.has(response.status)) return response;
41
+
42
+ const location = response.headers.get("location");
43
+ if (!location) return response;
44
+ if (redirectCount >= MAX_REDIRECTS) {
45
+ cancelMCPStream(response.body);
46
+ throw new Error("MCP redirect limit exceeded");
47
+ }
48
+
49
+ let nextUrl: URL;
50
+ try {
51
+ nextUrl = new URL(location, currentUrl);
52
+ } catch (error) {
53
+ cancelMCPStream(response.body);
54
+ throw error;
55
+ }
56
+ if (nextUrl.origin !== currentUrl.origin) {
57
+ cancelMCPStream(response.body);
58
+ throw new Error("cross-origin redirects are not allowed");
59
+ }
60
+
61
+ const headers = new Headers(currentInit.headers);
62
+ const method = (currentInit.method ?? "GET").toUpperCase();
63
+ if (rewritesToGet(response.status, method)) {
64
+ for (const name of REQUEST_BODY_HEADERS) headers.delete(name);
65
+ currentInit = { ...currentInit, method: "GET", headers, body: undefined, redirect: "manual" };
66
+ } else {
67
+ currentInit = { ...currentInit, headers, redirect: "manual" };
68
+ }
69
+ currentUrl = nextUrl;
70
+ requestTarget = nextUrl.toString();
71
+ cancelMCPStream(response.body);
72
+ }
73
+ }
@@ -8,6 +8,7 @@
8
8
  import type { OAuthCallbackFlowOptions } from "@gajae-code/ai/utils/oauth/callback-server";
9
9
  import { OAuthCallbackFlow } from "@gajae-code/ai/utils/oauth/callback-server";
10
10
  import type { OAuthController, OAuthCredentials } from "@gajae-code/ai/utils/oauth/types";
11
+ import { guardedPublicFetch } from "../web/insane/url-guard";
11
12
  import { assertPublicOAuthUrl } from "./oauth-public-url";
12
13
 
13
14
  const DEFAULT_PORT = 3000;
@@ -262,7 +263,7 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
262
263
  params.set("client_secret", clientSecret);
263
264
  }
264
265
 
265
- await assertPublicOAuthUrl(this.config.tokenUrl);
266
+ await assertPublicOAuthUrl(this.config.tokenUrl, { signal: this.ctrl.signal });
266
267
  const response = await fetch(this.config.tokenUrl, {
267
268
  method: "POST",
268
269
  redirect: "error",
@@ -343,7 +344,7 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
343
344
  if (!registrationEndpoint) return;
344
345
 
345
346
  try {
346
- await assertPublicOAuthUrl(registrationEndpoint);
347
+ await assertPublicOAuthUrl(registrationEndpoint, { signal });
347
348
  const response = await fetch(registrationEndpoint, {
348
349
  method: "POST",
349
350
  redirect: "error",
@@ -385,8 +386,10 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
385
386
  try {
386
387
  const authorizationEndpoint = new URL(this.config.authorizationUrl);
387
388
  const metadataUrl = new URL("/.well-known/oauth-authorization-server", authorizationEndpoint.origin);
389
+ await assertPublicOAuthUrl(metadataUrl.toString(), { signal });
388
390
  const response = await fetch(metadataUrl.toString(), {
389
391
  method: "GET",
392
+ redirect: "error",
390
393
  headers: { Accept: "application/json" },
391
394
  signal,
392
395
  });
@@ -406,12 +409,16 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
406
409
 
407
410
  async #assertClientIdNotRequired(authorizationUrl: string, signal?: AbortSignal): Promise<void> {
408
411
  try {
409
- const response = await fetch(authorizationUrl, {
412
+ // Pin the GET to the addresses that passed validation. A plain fetch would
413
+ // resolve the hostname again. Skip the probe when that route cannot be
414
+ // established; the flow signal cancels the DNS lookup.
415
+ const probed = await guardedPublicFetch(authorizationUrl, {
410
416
  method: "GET",
411
- redirect: "manual",
412
417
  headers: { Accept: "text/plain,text/html,application/json" },
413
418
  signal,
414
419
  });
420
+ if (!probed.ok) return;
421
+ const response = probed.response;
415
422
  if (response.status < 400) return;
416
423
  const body = await response.text();
417
424
  if (/client[_-]?id/i.test(body) && /(required|missing|invalid)/i.test(body)) {
@@ -1,8 +1,14 @@
1
1
  import { validatePublicHttpUrl } from "../web/insane/url-guard";
2
2
 
3
- export async function assertPublicOAuthUrl(url: string): Promise<void> {
4
- const checked = await validatePublicHttpUrl(url);
3
+ export async function assertPublicOAuthUrl(url: string, options: { signal?: AbortSignal } = {}): Promise<void> {
4
+ if (options.signal?.aborted) throw abortReason(options.signal);
5
+ const checked = await validatePublicHttpUrl(url, { signal: options.signal });
6
+ if (options.signal?.aborted) throw abortReason(options.signal);
5
7
  if (!checked.ok) {
6
8
  throw new Error(`Refusing non-public OAuth endpoint: ${checked.reason ?? url}`);
7
9
  }
8
10
  }
11
+
12
+ function abortReason(signal: AbortSignal): Error {
13
+ return signal.reason instanceof Error ? signal.reason : new Error("OAuth URL validation aborted");
14
+ }
@@ -307,6 +307,12 @@ class MCPPoolLeaseImpl implements MCPPoolLease {
307
307
  notify: (method, params) => lease.notify(method, params),
308
308
  close: () => lease.release(),
309
309
  closeBeforeReconnect: physical.transport.closeBeforeReconnect,
310
+ get onRequest() {
311
+ return physical.transport.onRequest;
312
+ },
313
+ set onRequest(handler) {
314
+ physical.transport.onRequest = handler;
315
+ },
310
316
  };
311
317
  const facade = { ...physical, transport };
312
318
  for (const property of ["tools", "resources", "resourceTemplates", "prompts"] as const) {
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Host-value policy for MCP config that comes from an untrusted project file.
3
+ *
4
+ * User mcp.json and an explicit `--mcp-config` file may still substitute
5
+ * secret-named environment variables and run `!` shell commands. A project
6
+ * file (native `.gjc/mcp.json` or a repo-root `mcp.json`) uses the same
7
+ * sensitive-name rule as project `ssh.json`: do not copy those host values
8
+ * into server env or headers, and do not execute `!`.
9
+ */
10
+
11
+ import type { SourceMeta } from "../capability/types";
12
+ import { isSensitiveEnvName } from "../discovery/helpers";
13
+
14
+ const UNTRUSTED_PROJECT_MCP_PROVIDERS = new Set(["native", "mcp-json"]);
15
+
16
+ export function nativeMcpSource(level: "user" | "project", filePath: string): SourceMeta {
17
+ return {
18
+ provider: "native",
19
+ providerName: "GJC",
20
+ path: filePath,
21
+ level,
22
+ };
23
+ }
24
+
25
+ /** True when this server was loaded from untrusted project MCP config. */
26
+ export function isUntrustedProjectMcpSource(source: SourceMeta | undefined, toolsOnly: boolean): boolean {
27
+ if (toolsOnly || !source || source.level !== "project") return false;
28
+ return UNTRUSTED_PROJECT_MCP_PROVIDERS.has(source.provider);
29
+ }
30
+
31
+ export type ProjectHostValueDecision =
32
+ | { action: "resolve" }
33
+ | { action: "drop" }
34
+ | { action: "literal"; value: string };
35
+
36
+ /**
37
+ * Decide how to interpret one env or header value.
38
+ * `drop` means "do not run the shell, and omit the value".
39
+ * `literal` means "do not read process.env".
40
+ */
41
+ export function decideUntrustedProjectConfigValue(value: string, untrusted: boolean): ProjectHostValueDecision {
42
+ if (!untrusted) return { action: "resolve" };
43
+ if (value.startsWith("!")) return { action: "drop" };
44
+ if (isSensitiveEnvName(value)) return { action: "literal", value };
45
+ return { action: "resolve" };
46
+ }
@@ -28,6 +28,7 @@ import {
28
28
  MCP_MAX_SSE_REQUEST_MESSAGES,
29
29
  readMCPResponseText,
30
30
  } from "../content-limits";
31
+ import { fetchMcpRespectingOrigin } from "../mcp-redirect";
31
32
  import { fetchPluginMcpRequest, usesPublicNetworkMcpFetch } from "../plugin-network-boundary";
32
33
  import { buildModernMcpHeaders, type MCPModernClientContext, type MCPProtocolEra, withModernMeta } from "../protocol";
33
34
 
@@ -157,7 +158,7 @@ export class HttpTransport implements MCPTransport {
157
158
  #fetch(init: BunFetchRequestInit): Promise<Response> {
158
159
  return usesPublicNetworkMcpFetch(this.config)
159
160
  ? fetchPluginMcpRequest(this.config.url, init)
160
- : fetch(this.config.url, init);
161
+ : fetchMcpRespectingOrigin(this.config.url, init);
161
162
  }
162
163
 
163
164
  #trackReader(promise: Promise<void>, controller?: AbortController): void {
@@ -150,6 +150,8 @@ const ACP_MCP_PRESERVED_LAUNCH_CODES = new Set([
150
150
  "spawn_failed",
151
151
  "worktree_in_use",
152
152
  "uncertain_after_send",
153
+ // Attach-only refusal: raised by ensureBroker before any lifecycle request or MCP server.
154
+ "broker_unavailable",
153
155
  ]);
154
156
 
155
157
  /**
@@ -1581,6 +1581,7 @@ export class Broker {
1581
1581
  #startupAfterDiscoveryWriteTestHook: (() => Promise<void>) | undefined;
1582
1582
  #startupPostPublicationDelayMs: number;
1583
1583
  #completionTask: Promise<void> | null = null;
1584
+ #terminalExit: { mode: BrokerExitMode; reason: BrokerExitReason } | null = null;
1584
1585
  #completion!: Promise<void>;
1585
1586
  #resolveCompletion!: () => void;
1586
1587
  #rejectCompletion!: (error: unknown) => void;
@@ -4019,6 +4020,10 @@ export class Broker {
4019
4020
  get completion(): Promise<void> {
4020
4021
  return this.#completion;
4021
4022
  }
4023
+ /** Why this broker completed, or null while it is still running. */
4024
+ get terminalExit(): { readonly mode: BrokerExitMode; readonly reason: BrokerExitReason } | null {
4025
+ return this.#terminalExit;
4026
+ }
4022
4027
  #restartIdentity(): BrokerRestartOwnerIdentity | undefined {
4023
4028
  const d = this.discovery;
4024
4029
  if (!d?.incarnation) return undefined;
@@ -4312,6 +4317,7 @@ export class Broker {
4312
4317
  blockingLockPath?: string,
4313
4318
  ): Promise<void> {
4314
4319
  if (this.#completionTask) return this.#completionTask;
4320
+ this.#terminalExit = { mode, reason };
4315
4321
  const now = process.hrtime.bigint();
4316
4322
  const fenceStartedAt = this.#lossAt ?? this.#ambiguousAt;
4317
4323
  const exitRecord: BrokerExitRecord & Record<string, unknown> = {
@@ -9,6 +9,7 @@ import type {
9
9
  } from "./broker";
10
10
  import { launchAuthorizedBrokerSuccessor, oldOwnerConfirmedExited } from "./daemon-entry";
11
11
  import { type BrokerDiscovery, readBrokerDiscovery } from "./discovery";
12
+ import { isBrokerAttachOnly } from "./ensure";
12
13
 
13
14
  export interface DoctorBrokerRestartOptions {
14
15
  agentDir: string;
@@ -33,6 +34,8 @@ export type DoctorBrokerRestartOutcome =
33
34
  adopted: boolean;
34
35
  }
35
36
  | { kind: "owner_unavailable"; reason: "no_discovery" | "owner_not_confirmed_live" }
37
+ /** Attach-only mode: the broker belongs to an external supervisor, so restart through it. */
38
+ | { kind: "attach_only_refused"; reason: "supervisor_owned" }
36
39
  | { kind: "prepare_refused"; code: string; message: string }
37
40
  | { kind: "commit_refused"; code: string; message: string }
38
41
  | { kind: "old_owner_exit_timeout"; requestId: string; oldOwner: BrokerRestartOwnerIdentity }
@@ -163,6 +166,7 @@ const OLD_OWNER_EXIT_POLL_MS = 50;
163
166
  * proven it owns the root -- never here, and never before that proof exists.
164
167
  */
165
168
  export async function restartBrokerForDoctor(options: DoctorBrokerRestartOptions): Promise<DoctorBrokerRestartOutcome> {
169
+ if (isBrokerAttachOnly()) return { kind: "attach_only_refused", reason: "supervisor_owned" };
166
170
  const old = await readBrokerDiscovery(options.agentDir);
167
171
  if (!old) return { kind: "owner_unavailable", reason: "no_discovery" };
168
172
  const oldOwner = identity(old);
@@ -6,7 +6,7 @@ import path from "node:path";
6
6
  import packageJson from "../../../package.json" with { type: "json" };
7
7
  import { acquireFileLock, type FileLockOptions, withFileLock } from "../../config/file-lock";
8
8
  import { loadInstallationHostId, loadLegacyInstallationHostId } from "../../config/machine-identity";
9
- import { SdkClient } from "../client/client";
9
+ import { SdkClient, SdkClientError } from "../client/client";
10
10
  import { type BrokerStartupExitRecord, clearBrokerStartupExitRecord, readBrokerStartupExitRecord } from "./broker-exit";
11
11
  import {
12
12
  type BrokerDiscovery,
@@ -1003,9 +1003,36 @@ function startEnsure(settings: EnsureBrokerSettings, initiator: EnsureInitiator)
1003
1003
  return entry;
1004
1004
  }
1005
1005
 
1006
- /** Starts the detached broker entrypoint when discovery has no live owner. */
1006
+ /**
1007
+ * Attach-only client mode. When `GJC_SDK_BROKER_AUTOSTART=0`, `ensureBroker`
1008
+ * never spawns, retires or restarts a broker: it returns a live, reusable
1009
+ * discovery or fails with `broker_unavailable`. Hosts that supervise their own
1010
+ * broker (for example a system service) set it for every client they run. The
1011
+ * broker entrypoints never call `ensureBroker`, so they ignore the variable and
1012
+ * their descendants may inherit it.
1013
+ */
1014
+ export const SDK_BROKER_AUTOSTART_ENV = "GJC_SDK_BROKER_AUTOSTART";
1015
+
1016
+ export function isBrokerAttachOnly(env: NodeJS.ProcessEnv = process.env): boolean {
1017
+ return env[SDK_BROKER_AUTOSTART_ENV] === "0";
1018
+ }
1019
+
1020
+ async function attachExistingBroker(settings: EnsureBrokerSettings): Promise<BrokerDiscovery> {
1021
+ const discovery = await readBrokerDiscovery(settings.agentDir, settings.heartbeatTtlMs);
1022
+ if (discovery && (await isBrokerReusable(discovery))) return discovery;
1023
+ throw new SdkClientError(
1024
+ "broker_unavailable",
1025
+ `SDK broker is not running and ${SDK_BROKER_AUTOSTART_ENV}=0 forbids starting one.`,
1026
+ );
1027
+ }
1028
+
1029
+ /**
1030
+ * Starts the detached broker entrypoint when discovery has no live owner. In
1031
+ * attach-only mode (`SDK_BROKER_AUTOSTART_ENV`) it only attaches.
1032
+ */
1007
1033
  export function ensureBroker(settings: EnsureBrokerSettings): Promise<BrokerDiscovery> {
1008
1034
  const resolvedSettings = { ...settings, agentDir: path.resolve(settings.agentDir) };
1035
+ if (isBrokerAttachOnly()) return attachExistingBroker(resolvedSettings);
1009
1036
  const inFlight = ensureInFlight.get(resolvedSettings.agentDir) ?? startEnsure(resolvedSettings, "discovery");
1010
1037
  return inFlight.discovery;
1011
1038
  }
@@ -5378,19 +5378,18 @@ async function settledStatus(
5378
5378
  harness: InvocationHarness,
5379
5379
  name: string,
5380
5380
  input: Record<string, unknown>,
5381
+ timeoutMs = 10_000,
5381
5382
  ): Promise<NonNullable<ResponseFrame["result"]>> {
5382
5383
  // Wall-clock budget instead of a fixed poll count: CI runners can starve the
5383
- // event loop long enough to exhaust 200 ~1ms polls before a 25ms deadline
5384
- // timer is dispatched, failing a correct implementation on timing alone.
5385
- // The contract is unchanged — the status must still reach a terminal
5386
- // reconciliation state with the asserted shape within a bounded horizon.
5387
- const budgetEndsAt = Date.now() + 10_000;
5384
+ // event loop and delay deadline timers. Polling every 10ms avoids flooding
5385
+ // reconciliation queries while preserving the asserted terminal contract.
5386
+ const budgetEndsAt = Date.now() + timeoutMs;
5388
5387
  for (;;) {
5389
5388
  const frame = await harness.query(name, input);
5390
5389
  const result = frame.result;
5391
5390
  if (result && (result.status === "failed" || result.status === "terminal_ok")) return result;
5392
5391
  if (Date.now() > budgetEndsAt) throw new Error(`${name} never reported a terminal reconciliation status`);
5393
- await Bun.sleep(1);
5392
+ await Bun.sleep(10);
5394
5393
  }
5395
5394
  }
5396
5395
 
@@ -5842,6 +5841,48 @@ test("SDK turn.steer preserves its expected run token and propagates a stale-run
5842
5841
  });
5843
5842
 
5844
5843
  describe("post-acceptance invocation terminalization", () => {
5844
+ test("completion fallback and a held lifecycle end publish one terminal boundary", async () => {
5845
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-completion-lifecycle-race-"));
5846
+ const completion = Promise.withResolvers<string>();
5847
+ const terminalEntered = Promise.withResolvers<void>();
5848
+ const releaseTerminal = Promise.withResolvers<void>();
5849
+ const completionReconciled = Promise.withResolvers<void>();
5850
+ let harness: InvocationHarness | undefined;
5851
+ try {
5852
+ harness = await invocationHarness("completion-lifecycle-race", cwd, {
5853
+ sendUserMessage: async (_content, options) => {
5854
+ await options?.onPreflightAcceptCommit?.();
5855
+ return completion.promise;
5856
+ },
5857
+ persistInterceptor: () => {},
5858
+ agentFailedWriteFailures: 0,
5859
+ persistHold: { type: "agent_end", onEntered: terminalEntered.resolve, release: releaseTerminal.promise },
5860
+ onInvocationCompletionReconciled: () => completionReconciled.resolve(),
5861
+ });
5862
+ const accepted = await harness.control("turn.prompt", { text: "complete once" });
5863
+ expect(accepted.ok).toBe(true);
5864
+ const correlation = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
5865
+ await harness.emit("agent_start");
5866
+ completion.resolve("completed");
5867
+ await terminalEntered.promise;
5868
+ const end = harness.emit("agent_end", {
5869
+ messages: [{ role: "assistant", stopReason: "stop", content: "completed" }],
5870
+ });
5871
+ await Bun.sleep(0);
5872
+ releaseTerminal.resolve();
5873
+ await Promise.all([end, completionReconciled.promise]);
5874
+ expect(await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation })).toMatchObject({
5875
+ status: "terminal_ok",
5876
+ content: { text: "completed" },
5877
+ });
5878
+ expect(harness.broadcasts.filter(frame => frame.kind === "agent_end")).toHaveLength(1);
5879
+ } finally {
5880
+ completion.resolve("completed");
5881
+ releaseTerminal.resolve();
5882
+ await harness?.stop();
5883
+ await rm(cwd, { recursive: true, force: true });
5884
+ }
5885
+ });
5845
5886
  test.each([
5846
5887
  false,
5847
5888
  true,
@@ -8488,8 +8529,15 @@ describe("accepted-control zero-execution bound (#4668)", () => {
8488
8529
  try {
8489
8530
  harness = await invocationHarness(sessionId, cwd, {
8490
8531
  settings: {
8532
+ // This test covers deferred terminal recovery, not deadline-triggered worktree persistence.
8491
8533
  get: (key: string) =>
8492
- key === "sdk.promptDeadlineMs" ? 150 : key === "sdk.promptMaxRuntimeMs" ? 60_000 : undefined,
8534
+ key === "sdk.promptDeadlineMs"
8535
+ ? 150
8536
+ : key === "sdk.promptMaxRuntimeMs"
8537
+ ? 60_000
8538
+ : key === "sdk.flushWorktreeOnDeadline"
8539
+ ? false
8540
+ : undefined,
8493
8541
  } as unknown as Settings,
8494
8542
  sendUserMessage: async (_content, options) => {
8495
8543
  await options?.onPreflightAcceptCommit?.();
@@ -8549,7 +8597,8 @@ describe("accepted-control zero-execution bound (#4668)", () => {
8549
8597
  });
8550
8598
 
8551
8599
  activeTools.clear();
8552
- expect(await settledStatus(harness, "turn.prompt_status", correlation)).toMatchObject({
8600
+ // Keep this inside the 60s hard-runtime budget while tolerating a slow CI retry tick.
8601
+ expect(await settledStatus(harness, "turn.prompt_status", correlation, 30_000)).toMatchObject({
8553
8602
  status: "terminal_ok",
8554
8603
  outcome: { kind: "stopped", reason: "cancelled" },
8555
8604
  });
@@ -8559,7 +8608,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
8559
8608
  await Bun.sleep(10);
8560
8609
  await rm(cwd, { recursive: true, force: true });
8561
8610
  }
8562
- });
8611
+ }, 60_000);
8563
8612
 
8564
8613
  test("an unproven real end stays private until exact settlement evidence exists", async () => {
8565
8614
  // A matching lifecycle end still cannot settle a recovered deadline record
@@ -35,6 +35,7 @@ import {
35
35
  validateSettingPatch,
36
36
  } from "../../config/settings";
37
37
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "../../extensibility/extensions";
38
+ import { tagSdkLifecycleObserver } from "../../extensibility/extensions/function-hooks-internal";
38
39
  import type { AgentEndEvent } from "../../extensibility/shared-events";
39
40
  import { normalizeGoal } from "../../goals/state";
40
41
  import { toAgentWireEventPayload } from "../../modes/shared/agent-wire/event-envelope";
@@ -6046,40 +6047,43 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
6046
6047
  resolveTerminalPublicationWaiters(observed, terminalPublicationByCorrelation);
6047
6048
  }
6048
6049
  };
6049
- api.on("agent_start", (event, ctx) => {
6050
- const owner = lifecycleStateForEvent(ctx, "agent_start", event.sdkRunToken);
6051
- // The activity checkpoint is already fire-and-forget and does not depend on
6052
- // the handler awaiting trackLifecycle, so it composes with #5683 unchanged.
6053
- void (owner?.runtime ?? lifecycleStateForContext(ctx, "agent_start")?.runtime)
6054
- ?.reportActivity("active")
6055
- .catch(error => logger.warn(`sdk: active activity checkpoint failed: ${String(error)}`));
6056
- // Interactive/skill turns must not wait on durable start persist. Keep
6057
- // trackLifecycle so drain, persist-then-publish, content-hold release, and
6058
- // shutdown still run; do not return that promise to the extension runner
6059
- // (EXTENSION_HANDLER_TIMEOUT_MS would otherwise stall the prompt).
6060
- void trackLifecycle(
6061
- async () =>
6062
- emitLifecycle(
6063
- "agent_start",
6064
- ctx,
6065
- undefined,
6066
- undefined,
6067
- owner ? { state: owner, sessionId: owner.sessionId } : undefined,
6068
- undefined,
6069
- false,
6070
- undefined,
6071
- undefined,
6072
- typeof event.sdkRunToken === "string" ? event.sdkRunToken : undefined,
6073
- event.sdkRunTokens,
6074
- event.lifecycleScope,
6075
- ),
6076
- owner,
6077
- ).catch(error => {
6078
- logger.error("SDK agent_start lifecycle task failed", {
6079
- error: sanitizePromptFailure(error),
6050
+ api.on(
6051
+ "agent_start",
6052
+ tagSdkLifecycleObserver((event, ctx) => {
6053
+ const owner = lifecycleStateForEvent(ctx, "agent_start", event.sdkRunToken);
6054
+ // The activity checkpoint is already fire-and-forget and does not depend on
6055
+ // the handler awaiting trackLifecycle, so it composes with #5683 unchanged.
6056
+ void (owner?.runtime ?? lifecycleStateForContext(ctx, "agent_start")?.runtime)
6057
+ ?.reportActivity("active")
6058
+ .catch(error => logger.warn(`sdk: active activity checkpoint failed: ${String(error)}`));
6059
+ // Interactive/skill turns must not wait on durable start persist. Keep
6060
+ // trackLifecycle so drain, persist-then-publish, content-hold release, and
6061
+ // shutdown still run; do not return that promise to the extension runner
6062
+ // (EXTENSION_HANDLER_TIMEOUT_MS would otherwise stall the prompt).
6063
+ void trackLifecycle(
6064
+ async () =>
6065
+ emitLifecycle(
6066
+ "agent_start",
6067
+ ctx,
6068
+ undefined,
6069
+ undefined,
6070
+ owner ? { state: owner, sessionId: owner.sessionId } : undefined,
6071
+ undefined,
6072
+ false,
6073
+ undefined,
6074
+ undefined,
6075
+ typeof event.sdkRunToken === "string" ? event.sdkRunToken : undefined,
6076
+ event.sdkRunTokens,
6077
+ event.lifecycleScope,
6078
+ ),
6079
+ owner,
6080
+ ).catch(error => {
6081
+ logger.error("SDK agent_start lifecycle task failed", {
6082
+ error: sanitizePromptFailure(error),
6083
+ });
6080
6084
  });
6081
- });
6082
- });
6085
+ }),
6086
+ );
6083
6087
  api.on("agent_end", (event, ctx) => {
6084
6088
  const tokenBinding =
6085
6089
  typeof event.sdkRunToken === "string" ? lifecycleRunOwners.get(event.sdkRunToken) : undefined;
@@ -6444,7 +6448,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
6444
6448
  const seams = options.terminalAbortSeams;
6445
6449
  const handle = seams?.getActivePromptHandle();
6446
6450
  const epoch = seams?.getTerminalTurnEpoch();
6447
- if (!seams || !handle || epoch === undefined || !seams.pendingToolExecutions) return;
6451
+ if (!seams || !handle || epoch === undefined) return;
6448
6452
  const existing = deadlineTerminalizationObservations.get(key);
6449
6453
  if (existing) return () => cleanupDeadlineTerminalizationObservation(key, existing);
6450
6454
 
@@ -7075,7 +7079,33 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
7075
7079
  () => acceptingGateResolutions,
7076
7080
  trackGateResolution,
7077
7081
  options.onInvocationCompletionReconciledForTests,
7078
- frame => runtime.emitEvent(frame),
7082
+ frame => {
7083
+ // Completion can win the durable transaction while a lifecycle end is
7084
+ // still waiting on it. Both publishers must share the same claim.
7085
+ if (typeof frame.commandId !== "string" || typeof frame.turnId !== "string") return;
7086
+ const correlation = { commandId: frame.commandId, turnId: frame.turnId };
7087
+ if (frame.type !== "agent_end") {
7088
+ if (!hasClaimedTerminalBoundary(correlation)) runtime.emitEvent(frame);
7089
+ return;
7090
+ }
7091
+ const key = lifecycleCorrelationKey(correlation);
7092
+ if (!claimTerminalBoundary(correlation)) {
7093
+ const previouslyPublished = terminalBoundaryClaims.publicationResult(key) === true;
7094
+ if (previouslyPublished) return;
7095
+ terminalBoundaryClaims.releaseClaim(key);
7096
+ if (!claimTerminalBoundary(correlation)) return;
7097
+ }
7098
+ const sequenceBefore = runtime.host.events.sequence;
7099
+ try {
7100
+ runtime.emitEvent(frame);
7101
+ terminalBoundaryClaims.setPublicationResult(key, true);
7102
+ } catch (error) {
7103
+ if (runtime.host.events.sequence > sequenceBefore)
7104
+ terminalBoundaryClaims.setPublicationResult(key, true);
7105
+ else terminalBoundaryClaims.releaseClaim(key);
7106
+ throw error;
7107
+ }
7108
+ },
7079
7109
  acceptedQueueCancellations,
7080
7110
  );
7081
7111
  const installProviderDefinitions = (capability: string, definitions: unknown): void => {
@@ -1,7 +1,7 @@
1
1
  import * as crypto from "node:crypto";
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
- import { logger, resolveEquivalentPath } from "@gajae-code/utils";
4
+ import { logger, pathIdentityKey } from "@gajae-code/utils";
5
5
  import { endpointIncarnation, matchesIndexedEndpointFile } from "../broker/endpoint-authority";
6
6
  import {
7
7
  canonicalSessionCwd,
@@ -1532,13 +1532,14 @@ export class SessionRouter {
1532
1532
  const cwd = indexed.locator.cwd;
1533
1533
  const defaultStateRoot = path.join(cwd, ".gjc", "state");
1534
1534
  // Locator cwd is already canonical. `stateRoot` remains the host-provided
1535
- // authority path, so this comparison resolves equivalent paths only for the
1536
- // state-root identity boundary; cwd never retains lexical symlink spellings.
1537
- const indexedStateRoot = resolveEquivalentPath(indexed.locator.stateRoot);
1535
+ // authority path, so compare filesystem identity here: case-insensitive
1536
+ // Windows aliases match without rewriting I/O paths, while case-sensitive
1537
+ // directories remain distinct.
1538
+ const indexedStateRoot = pathIdentityKey(indexed.locator.stateRoot);
1538
1539
  const scope =
1539
- indexedStateRoot === resolveEquivalentPath(defaultStateRoot)
1540
+ indexedStateRoot === pathIdentityKey(defaultStateRoot)
1540
1541
  ? "default"
1541
- : indexedStateRoot === resolveEquivalentPath(path.join(defaultStateRoot, "chat"))
1542
+ : indexedStateRoot === pathIdentityKey(path.join(defaultStateRoot, "chat"))
1542
1543
  ? "chat"
1543
1544
  : undefined;
1544
1545
  if (!scope || indexed.endpointMtimeMs === undefined || !Number.isFinite(indexed.endpointMtimeMs)) return null;
@@ -47,13 +47,8 @@ import {
47
47
  providerSupportsAppendOnlyAuto,
48
48
  resolveAppendOnlyMode,
49
49
  } from "../append-only-mode";
50
- import {
51
- type AsyncJob,
52
- AsyncJobManager,
53
- asyncJobEndpointId as deriveAsyncJobEndpointId,
54
- isBackgroundJobSupportEnabled,
55
- jobElapsedMs,
56
- } from "../async";
50
+ import { type AsyncJob, AsyncJobManager, isBackgroundJobSupportEnabled, jobElapsedMs } from "../async";
51
+ import { asyncJobEndpointId as deriveAsyncJobEndpointId } from "../async/endpoint-id";
57
52
  import { resolveBrowserBackend } from "../browser-backend";
58
53
  import { loadCapability, reset as resetCapabilities } from "../capability";
59
54
  import { type Rule, ruleCapability, setActiveRules } from "../capability/rule";