@gajae-code/coding-agent 0.18.8 → 0.18.10

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 +100 -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
@@ -77,6 +77,26 @@ interface KernelStartOptions extends KernelLifecycleOptions {
77
77
  env?: Record<string, string | undefined>;
78
78
  runtimeOptions?: PythonRuntimeOptions;
79
79
  settings?: Settings;
80
+ /** @internal Synchronously registers the actual spawned kernel before startup callbacks run. */
81
+ onKernelCreated?: (kernel: PythonKernel) => void;
82
+ /** @internal Routes partial-start cleanup through the executor's retained ownership. */
83
+ onStartupFailure?: (kernel: PythonKernel) => Promise<void>;
84
+ }
85
+
86
+ export type PythonKernelStartupCleanupFailure =
87
+ | { kind: "unconfirmed"; result: KernelShutdownResult }
88
+ | { kind: "rejected"; error: unknown };
89
+
90
+ /** Startup failed after a real runner was created and physical cleanup failed. */
91
+ export class PythonKernelStartError extends Error {
92
+ constructor(
93
+ readonly kernel: PythonKernel,
94
+ readonly startupError: unknown,
95
+ readonly cleanupFailure: PythonKernelStartupCleanupFailure,
96
+ ) {
97
+ super("Python kernel startup failed and runner cleanup was not confirmed", { cause: startupError });
98
+ this.name = "PythonKernelStartError";
99
+ }
80
100
  }
81
101
 
82
102
  interface KernelShutdownOptions {
@@ -108,6 +128,13 @@ function throwIfAborted(signal: AbortSignal | undefined, fallbackReason: string)
108
128
  throw createAbortError("AbortError", typeof reason === "string" ? reason : fallbackReason);
109
129
  }
110
130
 
131
+ function throwIfLifecycleEnded(lifecycle: KernelLifecycleOptions, message: string): void {
132
+ throwIfAborted(lifecycle.signal, message);
133
+ if (lifecycle.deadlineMs !== undefined && lifecycle.deadlineMs <= Date.now()) {
134
+ throw createAbortError("TimeoutError", `${message} timed out`);
135
+ }
136
+ }
137
+
111
138
  async function waitForLifecycle<T>(
112
139
  value: Promise<T>,
113
140
  lifecycle: PythonRuntimeLifecycleOptions | undefined,
@@ -244,6 +271,7 @@ export class PythonKernel {
244
271
  #alive = true;
245
272
  #disposed = false;
246
273
  #shutdownConfirmed = false;
274
+ #shutdownPromise: Promise<KernelShutdownResult> | null = null;
247
275
  #exitedPromise: Promise<number> | null = null;
248
276
  #exitCode: number | null = null;
249
277
  #stderrTail = "";
@@ -255,6 +283,7 @@ export class PythonKernel {
255
283
  }
256
284
 
257
285
  static async start(options: KernelStartOptions): Promise<PythonKernel> {
286
+ throwIfLifecycleEnded(options, "Python kernel startup");
258
287
  const availability = await logger.time(
259
288
  "PythonKernel.start:availabilityCheck",
260
289
  checkPythonKernelAvailability,
@@ -263,17 +292,20 @@ export class PythonKernel {
263
292
  { signal: options.signal, deadlineMs: options.deadlineMs },
264
293
  options.settings,
265
294
  );
295
+ throwIfLifecycleEnded(options, "Python kernel startup");
266
296
  if (!availability.ok) {
267
297
  throw new Error(availability.reason ?? "Python kernel unavailable");
268
298
  }
269
299
 
270
300
  const settings = options.settings ?? (await Settings.init());
301
+ throwIfLifecycleEnded(options, "Python kernel startup");
271
302
  const { env: shellEnv } = settings.getShellConfig();
272
303
  const baseEnv = filterEnv(shellEnv);
273
304
  const runtime = await ensurePythonRuntime(options.cwd, baseEnv, options.runtimeOptions, {
274
305
  signal: options.signal,
275
306
  deadlineMs: options.deadlineMs,
276
307
  });
308
+ throwIfLifecycleEnded(options, "Python kernel startup");
277
309
  const spawnEnv: Record<string, string> = {};
278
310
  for (const [key, value] of Object.entries(runtime.env)) {
279
311
  if (typeof value === "string") spawnEnv[key] = value;
@@ -286,6 +318,7 @@ export class PythonKernel {
286
318
  spawnEnv.PYTHONIOENCODING = "utf-8";
287
319
 
288
320
  const scriptPath = await ensureRunnerScript();
321
+ throwIfLifecycleEnded(options, "Python kernel startup");
289
322
  const kernel = new PythonKernel(Snowflake.next());
290
323
 
291
324
  const proc = Bun.spawn([runtime.pythonPath, "-u", scriptPath], {
@@ -302,25 +335,40 @@ export class PythonKernel {
302
335
  kernel.#proc = proc;
303
336
  kernel.#stdin = proc.stdin;
304
337
  kernel.#exitedPromise = proc.exited;
305
- void kernel.#exitedPromise.then(code => {
306
- kernel.#exitCode = code;
307
- kernel.#alive = false;
308
- kernel.#abortPendingExecutions(`Python kernel exited with code ${code}`, { kernelKilled: true });
309
- });
310
338
 
311
- kernel.#startReader(proc.stdout as ReadableStream<Uint8Array>);
312
- kernel.#startStderrDrain(proc.stderr as ReadableStream<Uint8Array>);
339
+ try {
340
+ options.onKernelCreated?.(kernel);
341
+ void kernel.#exitedPromise.then(code => {
342
+ kernel.#exitCode = code;
343
+ kernel.#alive = false;
344
+ kernel.#abortPendingExecutions(`Python kernel exited with code ${code}`, { kernelKilled: true });
345
+ });
313
346
 
314
- const startup = { signal: options.signal, deadlineMs: options.deadlineMs };
315
- const startupBudget = Math.min(getRemainingTimeMs(startup.deadlineMs) ?? STARTUP_TIMEOUT_MS, STARTUP_TIMEOUT_MS);
347
+ kernel.#startReader(proc.stdout as ReadableStream<Uint8Array>);
348
+ kernel.#startStderrDrain(proc.stderr as ReadableStream<Uint8Array>);
316
349
 
317
- try {
350
+ const startup = { signal: options.signal, deadlineMs: options.deadlineMs };
351
+ const startupBudget = Math.min(
352
+ getRemainingTimeMs(startup.deadlineMs) ?? STARTUP_TIMEOUT_MS,
353
+ STARTUP_TIMEOUT_MS,
354
+ );
318
355
  const initScript = buildInitScript(options.cwd, options.env);
319
356
  await kernel.#executeWithBudget(initScript, startup.signal, startupBudget, "Python kernel init");
320
357
  await kernel.#executeWithBudget(PYTHON_PRELUDE, startup.signal, startupBudget, "Python kernel prelude");
321
358
  return kernel;
322
359
  } catch (err) {
323
- await kernel.shutdown({ timeoutMs: SHUTDOWN_GRACE_MS }).catch(() => {});
360
+ let cleanupFailure: PythonKernelStartupCleanupFailure | undefined;
361
+ try {
362
+ if (options.onStartupFailure) {
363
+ await options.onStartupFailure(kernel);
364
+ } else {
365
+ const result = await kernel.shutdown({ timeoutMs: SHUTDOWN_GRACE_MS });
366
+ if (!result.confirmed) cleanupFailure = { kind: "unconfirmed", result };
367
+ }
368
+ } catch (cleanupError) {
369
+ cleanupFailure = { kind: "rejected", error: cleanupError };
370
+ }
371
+ if (cleanupFailure) throw new PythonKernelStartError(kernel, err, cleanupFailure);
324
372
  throw err;
325
373
  }
326
374
  }
@@ -475,9 +523,27 @@ export class PythonKernel {
475
523
  }
476
524
  }
477
525
 
478
- async shutdown(options?: KernelShutdownOptions): Promise<KernelShutdownResult> {
479
- if (this.#shutdownConfirmed) return { confirmed: true };
526
+ shutdown(options?: KernelShutdownOptions): Promise<KernelShutdownResult> {
527
+ if (this.#shutdownConfirmed) return Promise.resolve({ confirmed: true });
528
+ if (this.#shutdownPromise) return this.#shutdownPromise;
529
+
530
+ const { promise, resolve, reject } = Promise.withResolvers<KernelShutdownResult>();
531
+ this.#shutdownPromise = promise;
532
+ void promise.then(
533
+ () => {
534
+ if (this.#shutdownPromise === promise) this.#shutdownPromise = null;
535
+ },
536
+ () => {
537
+ if (this.#shutdownPromise === promise) this.#shutdownPromise = null;
538
+ },
539
+ );
540
+ void Promise.resolve()
541
+ .then(() => this.#shutdown(options))
542
+ .then(resolve, reject);
543
+ return promise;
544
+ }
480
545
 
546
+ async #shutdown(options?: KernelShutdownOptions): Promise<KernelShutdownResult> {
481
547
  this.#alive = false;
482
548
  this.#abortPendingExecutions("Python kernel shutdown", { kernelKilled: true });
483
549
 
@@ -72,6 +72,10 @@ async function startServer(): Promise<BridgeServer> {
72
72
  if (!sessionId || !name) {
73
73
  return Response.json({ ok: false, error: "Missing session/name" }, { status: 400 });
74
74
  }
75
+ const live = registrations.get(capability);
76
+ if (live !== registration || registration.signal?.aborted) {
77
+ return new Response("Forbidden", { status: 403 });
78
+ }
75
79
  if (sessionId !== registration.sessionId) {
76
80
  return new Response("Forbidden", { status: 403 });
77
81
  }
@@ -0,0 +1,16 @@
1
+ import * as fs from "node:fs";
2
+
3
+ const HTML_EXPORT_DESTINATION_OPEN_FLAGS =
4
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | (process.platform === "win32" ? 0 : (fs.constants.O_NOFOLLOW ?? 0));
5
+
6
+ /** Owner-only HTML export. A replaced file is locked down on the opened descriptor. */
7
+ export function openHtmlExportDestination(outputPath: string): number {
8
+ const fd = fs.openSync(outputPath, HTML_EXPORT_DESTINATION_OPEN_FLAGS, 0o600);
9
+ try {
10
+ fs.fchmodSync(fd, 0o600);
11
+ return fd;
12
+ } catch (error) {
13
+ fs.closeSync(fd);
14
+ throw error;
15
+ }
16
+ }
@@ -7,6 +7,7 @@ import { getResolvedThemeColors, getThemeExportColors } from "../../modes/theme/
7
7
  import { SessionManager } from "../../session/session-manager";
8
8
  // Pre-generated template (created by scripts/generate-template.ts at publish time)
9
9
  import { cssColorOrFallback } from "./css-color";
10
+ import { openHtmlExportDestination } from "./html-export-destination";
10
11
  import { TEMPLATE } from "./template.generated";
11
12
 
12
13
  export interface ExportOptions {
@@ -169,11 +170,6 @@ const HTML_EXPORT_SOURCE_OPEN_FLAGS =
169
170
  syncFs.constants.O_NONBLOCK |
170
171
  (process.platform === "win32" ? 0 : (syncFs.constants.O_NOFOLLOW ?? 0));
171
172
 
172
- const HTML_EXPORT_DESTINATION_OPEN_FLAGS =
173
- syncFs.constants.O_WRONLY |
174
- syncFs.constants.O_CREAT |
175
- (process.platform === "win32" ? 0 : (syncFs.constants.O_NOFOLLOW ?? 0));
176
-
177
173
  function sameFileIdentity(left: syncFs.BigIntStats, right: syncFs.BigIntStats): boolean {
178
174
  return left.dev === right.dev && left.ino === right.ino;
179
175
  }
@@ -193,7 +189,7 @@ async function openHtmlExportDescriptors(
193
189
  }
194
190
  await assertExportDoesNotAliasSource(sourcePath, outputPath);
195
191
  }
196
- destination = syncFs.openSync(outputPath, HTML_EXPORT_DESTINATION_OPEN_FLAGS, 0o666);
192
+ destination = openHtmlExportDestination(outputPath);
197
193
  if (source !== undefined) {
198
194
  const sourceIdentity = syncFs.fstatSync(source, { bigint: true });
199
195
  const destinationIdentity = syncFs.fstatSync(destination, { bigint: true });
@@ -4,6 +4,30 @@ import { type FunctionHookRegistration, validateFunctionHookTarget } from "./fun
4
4
 
5
5
  export type TaggedFunctionHookHandler = (...args: unknown[]) => Promise<unknown>;
6
6
 
7
+ // Host SDK lifecycle observers publish the accepted run's bookkeeping. They do
8
+ // not execute user work and must not consume provider-attempt replay authority.
9
+ const sdkLifecycleObservers = new WeakSet<(...args: never[]) => unknown>();
10
+ const providerRequestHandlers = new WeakMap<(...args: never[]) => unknown, string>();
11
+
12
+ /** Host-owned provider adapters must not consume replay authority for other providers. */
13
+ export function tagProviderRequestHandler<T extends (...args: never[]) => unknown>(provider: string, handler: T): T {
14
+ providerRequestHandlers.set(handler, provider);
15
+ return handler;
16
+ }
17
+
18
+ export function getProviderRequestHandlerProvider(handler: (...args: never[]) => unknown): string | undefined {
19
+ return providerRequestHandlers.get(handler);
20
+ }
21
+
22
+ export function tagSdkLifecycleObserver<T extends (...args: never[]) => unknown>(handler: T): T {
23
+ sdkLifecycleObservers.add(handler);
24
+ return handler;
25
+ }
26
+
27
+ export function isSdkLifecycleObserver(handler: (...args: never[]) => unknown): boolean {
28
+ return sdkLifecycleObservers.has(handler);
29
+ }
30
+
7
31
  const registrations = new WeakMap<TaggedFunctionHookHandler, FunctionHookRegistration>();
8
32
  const registrationOrders = new WeakMap<(...args: never[]) => unknown, number>();
9
33
 
@@ -21,6 +45,9 @@ export function wrapExtensionHandlerRegistration<T extends (...args: never[]) =>
21
45
  ): T {
22
46
  const registered = ((...args: never[]) => handler(...args)) as T;
23
47
  registrationOrders.set(registered, registrationOrder);
48
+ if (sdkLifecycleObservers.has(handler)) sdkLifecycleObservers.add(registered);
49
+ const provider = providerRequestHandlers.get(handler);
50
+ if (provider !== undefined) providerRequestHandlers.set(registered, provider);
24
51
  return registered;
25
52
  }
26
53
 
@@ -51,6 +51,8 @@ import {
51
51
  import {
52
52
  getExtensionHandlerRegistrationOrder,
53
53
  getFunctionHookRegistration,
54
+ getProviderRequestHandlerProvider,
55
+ isSdkLifecycleObserver,
54
56
  readConstrainedFunctionHookFile,
55
57
  } from "./function-hooks-internal";
56
58
  import type {
@@ -918,6 +920,13 @@ export class ExtensionRunner {
918
920
  const wildcard = this.#handlersByEvent.get("*") ?? [];
919
921
  const matches: IndexedHandler[] = [];
920
922
  for (const indexed of [...exact, ...wildcard]) {
923
+ const provider = getProviderRequestHandlerProvider(indexed.handler);
924
+ if (
925
+ event.type === "before_provider_request" &&
926
+ provider !== undefined &&
927
+ this.createContext().model?.provider !== provider
928
+ )
929
+ continue;
921
930
  const registration = getFunctionHookRegistration(indexed.handler);
922
931
  if (!registration) {
923
932
  if (includeLegacyToolHandlers && indexed.registrationOrder >= 0 && exact.includes(indexed))
@@ -1076,7 +1085,8 @@ export class ExtensionRunner {
1076
1085
  if (handlers.length === 0) return { action: "continue", event };
1077
1086
  const includesLegacy = handlers.some(indexed => getFunctionHookRegistration(indexed.handler) === undefined);
1078
1087
  if (includesLegacy) this.#requireScopeOrFailClosed(options.scope, event.type);
1079
- this.#markAttemptExecuted(options.scope);
1088
+ if (handlers.some(indexed => event.type !== "agent_start" || !isSdkLifecycleObserver(indexed.handler)))
1089
+ this.#markAttemptExecuted(options.scope);
1080
1090
  const functionHookDepth = this.#functionHookDepth.getStore() ?? 0;
1081
1091
  if (functionHookDepth >= 16) {
1082
1092
  const reason = "Function hook re-entry depth exceeded";
@@ -6,7 +6,7 @@ import { GjcPluginLoadError, type GjcPluginMcpManifestEntry } from "./types";
6
6
  /**
7
7
  * Shared MCP security policy applied at BOTH install validation and runtime
8
8
  * connect for third-party plugin-bundle MCP servers. Defaults are deny-first:
9
- * HTTPS only, no private/loopback/link-local/metadata endpoints, stdio confined
9
+ * HTTPS only, no private/loopback/link-local/CGNAT/metadata endpoints, stdio confined
10
10
  * to the plugin root.
11
11
  */
12
12
 
@@ -34,6 +34,7 @@ export function isDeniedIpv4(host: string): boolean {
34
34
  if (a === 172 && b >= 16 && b <= 31) return true; // private 172.16.0.0/12
35
35
  if (a === 192 && b === 168) return true; // private 192.168.0.0/16
36
36
  if (a === 169 && b === 254) return true; // link-local 169.254.0.0/16 (incl 169.254.169.254 metadata)
37
+ if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT 100.64.0.0/10
37
38
  if (a === 0) return true; // 0.0.0.0/8 unspecified/this-network
38
39
  if (a >= 224) return true; // multicast/reserved 224.0.0.0/4 and 240.0.0.0/4
39
40
  return false;
@@ -172,6 +172,8 @@ const initialNodeIdentities = initialProcessEnvironment.then(async environment =
172
172
  const lexical = path.join(pathEntry, process.platform === "win32" ? "node.exe" : "node");
173
173
  try {
174
174
  const real = await fs.realpath(lexical);
175
+ // A temporary root is writable by other local users and by checkouts the user
176
+ // never vetted, so a `node` resolved there never becomes launch authority.
175
177
  if (temporaryRoots.some(root => isWithin(root, real))) continue;
176
178
  if (identities.has(real)) continue;
177
179
  const stat = await fs.stat(real);
@@ -189,7 +191,11 @@ const initialNodeIdentities = initialProcessEnvironment.then(async environment =
189
191
  return identities;
190
192
  });
191
193
 
192
- /** First-use digests of startup Node candidates, keyed by realpath; failures are cached as `undefined`. */
194
+ /**
195
+ * First-use digests of startup Node candidates, keyed by realpath. Successful and
196
+ * in-flight digests stay memoized; a failed attempt is evicted so a transient error
197
+ * (e.g. EMFILE) can be retried against the same startup identity.
198
+ */
193
199
  const initialNodeHashes = new Map<string, Promise<string | undefined>>();
194
200
 
195
201
  /**
@@ -197,20 +203,26 @@ const initialNodeHashes = new Map<string, Promise<string | undefined>>();
197
203
  * was not a startup candidate or no longer is the startup file object.
198
204
  */
199
205
  export function getInitialNodeHash(realPath: string): Promise<string | undefined> {
200
- let digest = initialNodeHashes.get(realPath);
201
- if (!digest) {
202
- digest = initialNodeIdentities.then(async identities => {
203
- const identity = identities.get(realPath);
204
- if (!identity) return undefined;
205
- try {
206
- return await hashStableFile(realPath, "Initial Node executable", MCP_LAUNCHER_MAX_BYTES, identity);
207
- } catch {
208
- return undefined;
209
- }
210
- });
211
- initialNodeHashes.set(realPath, digest);
212
- }
213
- return digest;
206
+ const cached = initialNodeHashes.get(realPath);
207
+ if (cached) return cached;
208
+
209
+ const attempt = initialNodeIdentities.then(async identities => {
210
+ const identity = identities.get(realPath);
211
+ if (!identity) return undefined;
212
+ try {
213
+ return await hashStableFile(realPath, "Initial Node executable", MCP_LAUNCHER_MAX_BYTES, identity);
214
+ } catch {
215
+ // Do not memoize failed attempts to allow retries on transient errors
216
+ // (e.g., EMFILE on file open/read). Remove the pending entry so the next
217
+ // call can retry the operation with the same startup identity.
218
+ initialNodeHashes.delete(realPath);
219
+ return undefined;
220
+ }
221
+ });
222
+ // Memoize the in-flight attempt so only one concurrent operation runs, but
223
+ // the entry may be deleted if the operation fails (to allow retry).
224
+ initialNodeHashes.set(realPath, attempt);
225
+ return attempt;
214
226
  }
215
227
 
216
228
  async function snapshotExistingFile(filePath: string): Promise<FileSnapshot | null> {
@@ -56,6 +56,7 @@ export type SkillDisabledReason = "protected" | "scope-trust" | "ignored" | "inc
56
56
  export interface ManagedSkillRecord {
57
57
  name: string;
58
58
  description: string;
59
+ importedFrom?: string;
59
60
  path: string;
60
61
  scope: SkillScope;
61
62
  /** Canonical source label, e.g. "project .gjc/skills" or "user ~/.gjc/agent/skills". */
@@ -392,9 +393,11 @@ export async function listNativeSkillsForManagement(options: {
392
393
 
393
394
  if (!seenNames.has(skill.name)) {
394
395
  seenNames.add(skill.name);
396
+ const importedFrom = skill.frontmatter?.xGjcImportedFrom ?? skill.frontmatter?.["x-gjc-imported-from"];
395
397
  records.push({
396
398
  name: skill.name,
397
399
  description: typeof skill.frontmatter?.description === "string" ? skill.frontmatter.description : "",
400
+ ...(typeof importedFrom === "string" ? { importedFrom } : {}),
398
401
  path: skill.discoveryPath ?? skill.path,
399
402
  scope,
400
403
  source,
@@ -2,6 +2,7 @@ import * as crypto from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
+ import { canonicalEnvKey, projectEnvSnapshot } from "@gajae-code/utils/env-file";
5
6
  import { shortenPath } from "../tools/render-utils";
6
7
 
7
8
  export type GjcLaunchWorktreeMode =
@@ -175,6 +176,8 @@ function sanitizePathToken(value: string): string {
175
176
  * beside the first.
176
177
  */
177
178
  const WORKTREE_BUCKET_ENV = "GJC_WORKTREE_DIR";
179
+ /** Captured when this module loads, before a later `chdir` or dotenv delete. */
180
+ const startupWorktreeBucketSnapshot = projectEnvSnapshot();
178
181
  /**
179
182
  * Expands to the repository directory name.
180
183
  *
@@ -210,15 +213,35 @@ export function resolveWorktreeBucketForPath(
210
213
  );
211
214
  }
212
215
 
216
+ /**
217
+ * Operator override only. Bun has already copied the project dotenv into the
218
+ * environment, so a value that matches `projectEnvSnapshot` — a static hit or
219
+ * a dynamic declaration — must not select the bucket. An operator value the
220
+ * project does not declare is kept.
221
+ */
222
+ function trustedWorktreeBucketEnv(env: NodeJS.ProcessEnv): string | undefined {
223
+ const raw = env[WORKTREE_BUCKET_ENV];
224
+ if (!raw) return undefined;
225
+ const snapshot = startupWorktreeBucketSnapshot;
226
+ const key = canonicalEnvKey(WORKTREE_BUCKET_ENV);
227
+ const declared = snapshot.values[key];
228
+ const trimmed = raw.trim();
229
+ if (declared !== undefined && (snapshot.dynamic.has(key) || declared === raw || declared === trimmed)) {
230
+ return undefined;
231
+ }
232
+ return trimmed.length > 0 ? trimmed : undefined;
233
+ }
234
+
213
235
  /**
214
236
  * Directory that holds this repository's launch worktrees.
215
237
  *
216
238
  * A relative override resolves against the repository's parent directory. The default
217
239
  * `{repo}/.worktrees` template places managed worktrees inside the repository, while
218
240
  * `{repo}.worktrees` adopts an existing sibling bucket. An absolute override is used verbatim.
241
+ * A project dotenv value is ignored; an operator value the project does not declare is kept.
219
242
  */
220
243
  function resolveWorktreeBucket(repoRoot: string): string {
221
- return resolveWorktreeBucketForPath(repoRoot, process.env[WORKTREE_BUCKET_ENV], os.homedir(), path);
244
+ return resolveWorktreeBucketForPath(repoRoot, trustedWorktreeBucketEnv(process.env), os.homedir(), path);
222
245
  }
223
246
 
224
247
  function ensureRepositoryBucketIgnored(repoRoot: string, bucketPath: string): void {
@@ -454,6 +477,36 @@ function isWorktreeDirty(worktreePath: string): boolean {
454
477
  return runGit(worktreePath, ["status", "--porcelain"]).length > 0;
455
478
  }
456
479
 
480
+ /** Porcelain status is clean when the only unique state is commits on this detached HEAD. */
481
+ function isCommitAncestor(cwd: string, ancestor: string, descendant: string): boolean {
482
+ const result = Bun.spawnSync(["git", "merge-base", "--is-ancestor", ancestor, descendant], {
483
+ cwd,
484
+ stdout: "ignore",
485
+ stderr: "pipe",
486
+ });
487
+ if (result.exitCode === 0) return true;
488
+ if (result.exitCode === 1) return false;
489
+ const stderr = sanitizeWorktreeDiagnostic(result.stderr.toString().trim());
490
+ throw new Error(stderr || "git merge-base --is-ancestor failed");
491
+ }
492
+
493
+ function formatWorktreeDiverged(worktreePath: string, existingHead: string, baseRef: string): string {
494
+ return [
495
+ `worktree_diverged:${worktreePath}`,
496
+ `GJC refused to move detached launch worktree ${existingHead} onto ${baseRef} because that commit is not an ancestor of the source HEAD.`,
497
+ `Path: ${worktreePath}`,
498
+ "Checking out the source HEAD would drop that commit from this worktree. Safe remediation: create a branch at HEAD so the commit stays reachable (git branch <name>), then retarget or remove the worktree yourself if a later launch should reuse this path. GJC did not change HEAD.",
499
+ ].join("\n");
500
+ }
501
+
502
+ function reuseDetachedLaunchHead(plan: GjcLaunchWorktreePlan, existingHead: string, dirty: boolean): void {
503
+ if (dirty) throw new Error(`worktree_dirty:${plan.worktreePath}`);
504
+ if (!isCommitAncestor(plan.worktreePath, existingHead, plan.baseRef)) {
505
+ throw new Error(formatWorktreeDiverged(plan.worktreePath, existingHead, plan.baseRef));
506
+ }
507
+ runGit(plan.worktreePath, ["checkout", "--detach", plan.baseRef]);
508
+ }
509
+
457
510
  function resolveOptionalWorktreeName(args: string[], index: number): { name: string | null; nextIndex: number } {
458
511
  const next = args[index + 1];
459
512
  if (!next) return { name: null, nextIndex: index };
@@ -781,8 +834,7 @@ function ensureLaunchWorktreeSync(
781
834
  throw new Error(formatWorktreeTargetMismatch(plan, existingAtPath));
782
835
  }
783
836
  if (existingAtPath.head !== plan.baseRef) {
784
- if (dirty) throw new Error(`worktree_dirty:${plan.worktreePath}`);
785
- runGit(plan.worktreePath, ["checkout", "--detach", plan.baseRef]);
837
+ reuseDetachedLaunchHead(plan, existingAtPath.head, dirty);
786
838
  dirty = false;
787
839
  }
788
840
  } else if (existingAtPath.branchRef !== expectedBranchRef) {
@@ -877,8 +929,7 @@ export async function ensureLaunchWorktreeCancellable(
877
929
  throw new Error(formatWorktreeTargetMismatch(plan, existingAtPath));
878
930
  }
879
931
  if (existingAtPath.head !== plan.baseRef) {
880
- if (dirty) throw new Error(`worktree_dirty:${plan.worktreePath}`);
881
- runGit(plan.worktreePath, ["checkout", "--detach", plan.baseRef]);
932
+ reuseDetachedLaunchHead(plan, existingAtPath.head, dirty);
882
933
  dirty = false;
883
934
  }
884
935
  } else if (existingAtPath.branchRef !== expectedBranchRef) {
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
+ import { canonicalEnvKey, projectEnvSnapshot } from "@gajae-code/utils/env-file";
3
4
 
4
5
  /**
5
6
  * Windows psmux detection and tmux-binary resolution.
@@ -305,17 +306,40 @@ export interface ResolvedTmuxBinary {
305
306
  viaExplicitOverride: boolean;
306
307
  }
307
308
 
309
+ const GJC_TMUX_COMMAND_ENV = "GJC_TMUX_COMMAND";
310
+ /** Captured when this module loads, before a later `chdir` or dotenv delete. */
311
+ const startupTmuxCommandSnapshot = projectEnvSnapshot();
312
+
313
+ /**
314
+ * Operator override only. Bun has already copied the project dotenv into the
315
+ * environment, so a value that matches `projectEnvSnapshot` — a static hit or
316
+ * a dynamic declaration — must not select the binary. An operator value the
317
+ * project does not declare is kept.
318
+ */
319
+ function trustedTmuxCommand(env: NodeJS.ProcessEnv): string | undefined {
320
+ const raw = env[GJC_TMUX_COMMAND_ENV];
321
+ if (!raw) return undefined;
322
+ const snapshot = startupTmuxCommandSnapshot;
323
+ const key = canonicalEnvKey(GJC_TMUX_COMMAND_ENV);
324
+ const declared = snapshot.values[key];
325
+ const trimmed = raw.trim();
326
+ if (declared !== undefined && (snapshot.dynamic.has(key) || declared === raw || declared === trimmed)) {
327
+ return undefined;
328
+ }
329
+ return trimmed.length > 0 ? trimmed : undefined;
330
+ }
331
+
308
332
  /**
309
- * Resolve the tmux command GJC should invoke. Honors the existing
310
- * GJC_TMUX_COMMAND override; on Windows when no
311
- * override is set, psmux (installed as psmux, pmux, or tmux) is picked
333
+ * Resolve the tmux command GJC should invoke. Honors an operator
334
+ * GJC_TMUX_COMMAND override; a project dotenv value is ignored. On Windows
335
+ * when no override is set, psmux (installed as psmux, pmux, or tmux) is picked
312
336
  * automatically so the default gjc --tmux flow lands on a real multiplexer.
313
337
  */
314
338
  export function resolveGjcTmuxBinary(options: ResolveGjcTmuxBinaryOptions = {}): ResolvedTmuxBinary {
315
339
  const env = options.env ?? process.env;
316
340
  const platform = options.platform ?? process.platform;
317
341
  const runner = options.runner ?? readSpawnRunner();
318
- const explicit = env.GJC_TMUX_COMMAND?.trim();
342
+ const explicit = trustedTmuxCommand(env);
319
343
  if (explicit) {
320
344
  const isPsmux =
321
345
  platform === "win32"
@@ -22,6 +22,7 @@ import { reduceTerminalReceiptState } from "../sdk/receipt-state";
22
22
  import { TOOL_CATALOG } from "../tools/tool-catalog.generated";
23
23
  import { sessionRoot, sessionRuntimeDir, sessionRuntimeStatePath } from "./session-layout";
24
24
  import { SessionStateLockUnavailableError, withSessionStateFileLock } from "./session-state-lock";
25
+ import { trustedCoordinatorEnv } from "./sidecar-bootstrap-env";
25
26
  import {
26
27
  isValidOwnerIntent,
27
28
  lifecyclePaths,
@@ -52,9 +53,9 @@ export const GJC_COORDINATOR_SIDECAR_SIGNATURE_REQUIRED_ENV = "GJC_COORDINATOR_S
52
53
  * children. The process environment is deliberately scrubbed synchronously even when the
53
54
  * key is malformed, so a rejected launch cannot leak it to descendants.
54
55
  */
55
- const coordinatorSidecarSigningBootstrapUrl = process.env[GJC_COORDINATOR_SIDECAR_BOOTSTRAP_URL_ENV]?.trim();
56
+ const coordinatorSidecarSigningBootstrapUrl = trustedCoordinatorEnv(GJC_COORDINATOR_SIDECAR_BOOTSTRAP_URL_ENV);
56
57
  const coordinatorSidecarSigningKeyBootstrap = await (async (): Promise<string | undefined> => {
57
- if (!coordinatorSidecarSigningBootstrapUrl) return process.env[GJC_COORDINATOR_SIDECAR_SIGNING_KEY_ENV];
58
+ if (!coordinatorSidecarSigningBootstrapUrl) return trustedCoordinatorEnv(GJC_COORDINATOR_SIDECAR_SIGNING_KEY_ENV);
58
59
  try {
59
60
  const response = await fetch(coordinatorSidecarSigningBootstrapUrl);
60
61
  if (!response.ok) return undefined;
@@ -0,0 +1,19 @@
1
+ import { canonicalEnvKey, type ProjectEnvSnapshot, projectEnvSnapshot } from "@gajae-code/utils/env-file";
2
+
3
+ /**
4
+ * Operator environment only. A value that matches the project dotenv snapshot
5
+ * was loaded by Bun from the repository and must not become signing material.
6
+ */
7
+ export function trustedCoordinatorEnv(
8
+ name: string,
9
+ env: NodeJS.ProcessEnv = process.env,
10
+ snapshot: ProjectEnvSnapshot = projectEnvSnapshot(),
11
+ ): string | undefined {
12
+ const raw = env[name];
13
+ if (!raw) return undefined;
14
+ const key = canonicalEnvKey(name);
15
+ const declared = snapshot.values[key];
16
+ if (declared !== undefined && (snapshot.dynamic.has(key) || declared === raw)) return undefined;
17
+ const trimmed = raw.trim();
18
+ return trimmed.length > 0 ? trimmed : undefined;
19
+ }
@@ -310,19 +310,33 @@ export function stampWorkflowEnvelopeChecksum<T>(value: T, filePath: string, com
310
310
  return envelope as T;
311
311
  }
312
312
 
313
- export async function detectWorkflowEnvelopeIntegrityMismatch(
314
- filePath: string,
315
- ): Promise<WorkflowEnvelopeIntegrityMismatch | undefined> {
316
- const current = await readJsonIfPresent(filePath);
317
- if (!current || typeof current !== "object" || Array.isArray(current)) return undefined;
318
- const receipt = (current as Record<string, unknown>).receipt;
313
+ export type WorkflowEnvelopeChecksumStatus = "absent" | "match" | "mismatch";
314
+
315
+ /** Compare a parsed envelope to its writer-stamped `content_sha256`, when one is present. */
316
+ export function workflowEnvelopeChecksumStatus(value: unknown): WorkflowEnvelopeChecksumStatus {
317
+ const checksum = workflowEnvelopeChecksumPair(value);
318
+ if (!checksum) return "absent";
319
+ return checksum.actual === checksum.expected ? "match" : "mismatch";
320
+ }
321
+
322
+ function workflowEnvelopeChecksumPair(value: unknown): { expected: string; actual: string } | undefined {
323
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
324
+ const receipt = (value as Record<string, unknown>).receipt;
319
325
  if (!receipt || typeof receipt !== "object" || Array.isArray(receipt)) return undefined;
320
326
  const checksum = (receipt as Record<string, unknown>).content_sha256;
321
327
  if (!checksum || typeof checksum !== "object" || Array.isArray(checksum)) return undefined;
322
328
  const expected = (checksum as Record<string, unknown>).value;
323
329
  if (typeof expected !== "string" || !expected) return undefined;
324
- const actual = workflowEnvelopeContentSha256(current);
325
- return actual === expected ? undefined : { path: filePath, expected, actual };
330
+ return { expected, actual: workflowEnvelopeContentSha256(value) };
331
+ }
332
+
333
+ export async function detectWorkflowEnvelopeIntegrityMismatch(
334
+ filePath: string,
335
+ ): Promise<WorkflowEnvelopeIntegrityMismatch | undefined> {
336
+ const current = await readJsonIfPresent(filePath);
337
+ const checksum = workflowEnvelopeChecksumPair(current);
338
+ if (!checksum || checksum.actual === checksum.expected) return undefined;
339
+ return { path: filePath, expected: checksum.expected, actual: checksum.actual };
326
340
  }
327
341
 
328
342
  function safeString(value: unknown): string {