@gajae-code/coding-agent 0.11.4 → 0.11.6

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 (154) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/types/cli/notify-cli.d.ts +5 -1
  3. package/dist/types/config/settings-schema.d.ts +31 -1
  4. package/dist/types/export/html/template.generated.d.ts +1 -1
  5. package/dist/types/gjc-runtime/deep-interview-state.d.ts +28 -0
  6. package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -0
  7. package/dist/types/gjc-runtime/managed-owner-admission.d.ts +42 -0
  8. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +41 -0
  9. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +9 -0
  10. package/dist/types/gjc-runtime/ultragoal-owner-loss-recovery.d.ts +94 -0
  11. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  12. package/dist/types/harness-control-plane/owner.d.ts +3 -1
  13. package/dist/types/internal-urls/local-protocol.d.ts +18 -0
  14. package/dist/types/main.d.ts +2 -2
  15. package/dist/types/modes/components/ansi-display-validator.d.ts +2 -0
  16. package/dist/types/modes/components/notifications-settings-editor.d.ts +4 -0
  17. package/dist/types/modes/components/session-observer-overlay.d.ts +3 -1
  18. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  19. package/dist/types/modes/components/tool-transcript-format.d.ts +58 -0
  20. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +3 -0
  21. package/dist/types/modes/controllers/goal-mode-controller.d.ts +8 -1
  22. package/dist/types/modes/controllers/selector-controller.d.ts +15 -0
  23. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +3 -0
  24. package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +16 -0
  25. package/dist/types/plan-mode/approved-plan.d.ts +1 -0
  26. package/dist/types/runtime-mcp/manager.d.ts +2 -0
  27. package/dist/types/runtime-mcp/oauth-discovery.d.ts +12 -1
  28. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -1
  29. package/dist/types/sdk/bus/chat-daemon-control.d.ts +42 -4
  30. package/dist/types/sdk/bus/config-commands.d.ts +8 -0
  31. package/dist/types/sdk/bus/config.d.ts +22 -0
  32. package/dist/types/sdk/bus/index.d.ts +162 -2
  33. package/dist/types/sdk/bus/notification-service.d.ts +71 -8
  34. package/dist/types/sdk/bus/session-control.d.ts +11 -8
  35. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +5 -0
  36. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +11 -3
  37. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +23 -4
  38. package/dist/types/sdk/bus/telegram-daemon.d.ts +214 -20
  39. package/dist/types/sdk/session.d.ts +16 -1
  40. package/dist/types/secrets/index.d.ts +4 -2
  41. package/dist/types/secrets/obfuscator.d.ts +1 -1
  42. package/dist/types/session/agent-session.d.ts +31 -2
  43. package/dist/types/session/artifacts.d.ts +30 -8
  44. package/dist/types/session/internal/managed-session-scope.d.ts +7 -0
  45. package/dist/types/session/internal/managed-session-storage.d.ts +63 -6
  46. package/dist/types/session/session-manager.d.ts +98 -18
  47. package/dist/types/session/session-storage.d.ts +27 -0
  48. package/dist/types/session/streaming-output.d.ts +4 -0
  49. package/dist/types/session/yield-queue.d.ts +8 -0
  50. package/dist/types/task/executor.d.ts +9 -0
  51. package/dist/types/tool-discovery/tool-index.d.ts +2 -0
  52. package/dist/types/tools/ask.d.ts +48 -0
  53. package/dist/types/tools/bash-skill-urls.d.ts +2 -2
  54. package/dist/types/tools/index.d.ts +4 -0
  55. package/package.json +9 -9
  56. package/scripts/generate-sdk-operation-inventory.ts +3 -0
  57. package/src/cli/notify-cli.ts +40 -18
  58. package/src/commands/sdk.ts +7 -2
  59. package/src/config/settings-schema.ts +31 -1
  60. package/src/daemon/runtime.ts +2 -2
  61. package/src/defaults/gjc/skills/deep-interview/SKILL.md +70 -49
  62. package/src/defaults/gjc/skills/ralplan/SKILL.md +0 -6
  63. package/src/defaults/gjc/skills/team/SKILL.md +7 -9
  64. package/src/defaults/gjc/skills/ultragoal/SKILL.md +0 -2
  65. package/src/eval/py/index.ts +2 -2
  66. package/src/export/html/template.generated.ts +1 -1
  67. package/src/extensibility/plugins/marketplace/manager.ts +94 -10
  68. package/src/gjc-runtime/deep-interview-recorder.ts +7 -0
  69. package/src/gjc-runtime/deep-interview-runtime.ts +10 -0
  70. package/src/gjc-runtime/deep-interview-state.ts +127 -3
  71. package/src/gjc-runtime/launch-tmux.ts +61 -6
  72. package/src/gjc-runtime/managed-owner-admission.ts +352 -0
  73. package/src/gjc-runtime/managed-owner-supervisor.ts +162 -0
  74. package/src/gjc-runtime/state-runtime.ts +38 -12
  75. package/src/gjc-runtime/team-runtime.ts +1 -1
  76. package/src/gjc-runtime/tmux-owner-isolation.ts +117 -0
  77. package/src/gjc-runtime/ultragoal-owner-loss-recovery.ts +328 -0
  78. package/src/gjc-runtime/ultragoal-runtime.ts +14 -0
  79. package/src/harness-control-plane/owner.ts +59 -3
  80. package/src/hindsight/client.ts +65 -2
  81. package/src/internal-urls/agent-protocol.ts +76 -5
  82. package/src/internal-urls/docs-index.generated.ts +8 -7
  83. package/src/internal-urls/local-protocol.ts +416 -18
  84. package/src/lsp/index.ts +4 -88
  85. package/src/main.ts +36 -12
  86. package/src/modes/acp/acp-agent.ts +8 -0
  87. package/src/modes/components/ansi-display-validator.ts +54 -0
  88. package/src/modes/components/notifications-settings-editor.ts +26 -2
  89. package/src/modes/components/session-observer-overlay.ts +40 -24
  90. package/src/modes/components/settings-selector.ts +3 -1
  91. package/src/modes/components/tool-transcript-format.ts +321 -0
  92. package/src/modes/components/transcript-viewer-overlay.ts +39 -17
  93. package/src/modes/controllers/command-controller.ts +15 -0
  94. package/src/modes/controllers/extension-ui-controller.ts +14 -2
  95. package/src/modes/controllers/goal-mode-controller.ts +20 -1
  96. package/src/modes/controllers/plan-mode-controller.ts +2 -0
  97. package/src/modes/controllers/runtime-mcp-command-controller.ts +3 -0
  98. package/src/modes/controllers/selector-controller.ts +70 -8
  99. package/src/modes/interactive-mode.ts +1 -0
  100. package/src/modes/runtime-init.ts +16 -2
  101. package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -1
  102. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +17 -1
  103. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +4 -2
  104. package/src/modes/shared/agent-wire/workflow-gate-types.ts +120 -3
  105. package/src/plan-mode/approved-plan.ts +3 -1
  106. package/src/prompts/agents/architect.md +1 -0
  107. package/src/prompts/agents/critic.md +1 -0
  108. package/src/prompts/system/custom-system-prompt.md +1 -1
  109. package/src/prompts/system/system-prompt.md +2 -1
  110. package/src/prompts/tools/lsp.md +3 -3
  111. package/src/runtime-mcp/manager.ts +16 -1
  112. package/src/runtime-mcp/oauth-discovery.ts +116 -26
  113. package/src/sdk/broker/session-index.ts +122 -64
  114. package/src/sdk/bus/chat-daemon-cli.ts +72 -10
  115. package/src/sdk/bus/chat-daemon-control.ts +538 -130
  116. package/src/sdk/bus/chat-daemon-runtime.ts +1 -0
  117. package/src/sdk/bus/config-commands.ts +22 -0
  118. package/src/sdk/bus/config.ts +37 -0
  119. package/src/sdk/bus/index.ts +388 -94
  120. package/src/sdk/bus/lifecycle-control-runtime.ts +53 -3
  121. package/src/sdk/bus/notification-service.ts +284 -62
  122. package/src/sdk/bus/recent-activity.ts +14 -5
  123. package/src/sdk/bus/session-control.ts +150 -44
  124. package/src/sdk/bus/telegram-daemon-cli.ts +20 -14
  125. package/src/sdk/bus/telegram-daemon-contract.ts +11 -3
  126. package/src/sdk/bus/telegram-daemon-control.ts +312 -76
  127. package/src/sdk/bus/telegram-daemon.ts +1916 -285
  128. package/src/sdk/host/control/dispatch.ts +14 -5
  129. package/src/sdk/protocol/operation-inventory.generated.json +29 -0
  130. package/src/sdk/session.ts +155 -27
  131. package/src/secrets/index.ts +53 -10
  132. package/src/secrets/obfuscator.ts +21 -18
  133. package/src/secrets/regex.ts +2 -0
  134. package/src/session/agent-session.ts +1447 -852
  135. package/src/session/artifacts.ts +188 -23
  136. package/src/session/context-estimation.ts +7 -2
  137. package/src/session/internal/managed-session-scope.ts +200 -38
  138. package/src/session/internal/managed-session-storage.ts +1152 -46
  139. package/src/session/session-manager.ts +1121 -148
  140. package/src/session/session-storage.ts +109 -11
  141. package/src/session/streaming-output.ts +6 -0
  142. package/src/session/yield-queue.ts +20 -0
  143. package/src/slash-commands/builtin-registry.ts +36 -0
  144. package/src/task/executor.ts +71 -26
  145. package/src/task/index.ts +63 -20
  146. package/src/tool-discovery/tool-index.ts +18 -0
  147. package/src/tools/ask-answer-registry.ts +15 -1
  148. package/src/tools/ask.ts +129 -32
  149. package/src/tools/bash-skill-urls.ts +14 -3
  150. package/src/tools/bash.ts +2 -2
  151. package/src/tools/index.ts +4 -0
  152. package/src/tools/plan-mode-guard.ts +1 -0
  153. package/src/tools/sqlite-reader.ts +3 -2
  154. package/src/tools/subagent.ts +2 -0
@@ -4,6 +4,8 @@
4
4
  * Automatically detects OAuth requirements from MCP server responses
5
5
  * and extracts authentication endpoints.
6
6
  */
7
+ import type { AddressResolver, PublicUrlAccepted } from "../web/insane/url-guard";
8
+ import { validatePublicHttpUrl } from "../web/insane/url-guard";
7
9
 
8
10
  export interface OAuthEndpoints {
9
11
  authorizationUrl: string;
@@ -20,6 +22,16 @@ export interface AuthDetectionResult {
20
22
  message?: string;
21
23
  }
22
24
 
25
+ interface OAuthDiscoveryOptions {
26
+ fetch?: (input: string | URL | Request, init?: BunFetchRequestInit) => Promise<Response>;
27
+ resolver?: AddressResolver;
28
+ maxRequests?: number;
29
+ maxNodes?: number;
30
+ maxRedirects?: number;
31
+ timeoutMs?: number;
32
+ signal?: AbortSignal;
33
+ }
34
+
23
35
  function parseMcpAuthServerUrl(errorMessage: string): string | undefined {
24
36
  const match = errorMessage.match(/Mcp-Auth-Server:\s*([^;\]\s]+)/i);
25
37
  if (!match?.[1]) return undefined;
@@ -194,17 +206,11 @@ export function analyzeAuthError(error: Error): AuthDetectionResult {
194
206
  return { requiresAuth: false };
195
207
  }
196
208
 
197
- const authServerUrl = extractMcpAuthServerUrl(error);
198
-
199
- // Try to extract OAuth endpoints
200
- const oauth = extractOAuthEndpoints(error);
201
-
202
- if (oauth) {
209
+ // Error text is useful for classification, but is not trusted discovery authority.
210
+ if (extractOAuthEndpoints(error) || extractMcpAuthServerUrl(error)) {
203
211
  return {
204
212
  requiresAuth: true,
205
213
  authType: "oauth",
206
- oauth,
207
- authServerUrl,
208
214
  message: "Server requires OAuth authentication. Launching authorization flow...",
209
215
  };
210
216
  }
@@ -220,7 +226,6 @@ export function analyzeAuthError(error: Error): AuthDetectionResult {
220
226
  return {
221
227
  requiresAuth: true,
222
228
  authType: "apikey",
223
- authServerUrl,
224
229
  message: "Server requires API key authentication.",
225
230
  };
226
231
  }
@@ -229,7 +234,6 @@ export function analyzeAuthError(error: Error): AuthDetectionResult {
229
234
  return {
230
235
  requiresAuth: true,
231
236
  authType: "unknown",
232
- authServerUrl,
233
237
  message: "Server requires authentication but type could not be determined.",
234
238
  };
235
239
  }
@@ -241,6 +245,7 @@ export function analyzeAuthError(error: Error): AuthDetectionResult {
241
245
  export async function discoverOAuthEndpoints(
242
246
  serverUrl: string,
243
247
  authServerUrl?: string,
248
+ options: OAuthDiscoveryOptions = {},
244
249
  ): Promise<OAuthEndpoints | null> {
245
250
  const wellKnownPaths = [
246
251
  "/.well-known/oauth-authorization-server",
@@ -250,8 +255,82 @@ export async function discoverOAuthEndpoints(
250
255
  "/.mcp/auth",
251
256
  "/authorize", // Some MCP servers expose OAuth config here
252
257
  ];
253
- const urlsToQuery = [authServerUrl, serverUrl].filter((value): value is string => Boolean(value));
254
- const visitedAuthServers = new Set<string>();
258
+ const fetcher = options.fetch ?? globalThis.fetch;
259
+ const maxRequests = options.maxRequests ?? 32;
260
+ const maxNodes = options.maxNodes ?? 8;
261
+ const maxRedirects = options.maxRedirects ?? 3;
262
+ const timeoutSignal = AbortSignal.timeout(options.timeoutMs ?? 10_000);
263
+ const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
264
+ const { promise: aborted, resolve: resolveAbort } = Promise.withResolvers<null>();
265
+ if (signal.aborted) resolveAbort(null);
266
+ else signal.addEventListener("abort", () => resolveAbort(null), { once: true });
267
+ const queue: string[] = [];
268
+ const queued = new Set<string>();
269
+ const visited = new Set<string>();
270
+ let requests = 0;
271
+
272
+ const canonicalUrl = (raw: string): string | null => {
273
+ try {
274
+ const url = new URL(raw);
275
+ url.hash = "";
276
+ return url.toString();
277
+ } catch {
278
+ return null;
279
+ }
280
+ };
281
+
282
+ const validateUrl = async (raw: string): Promise<PublicUrlAccepted | null> => {
283
+ const canonical = canonicalUrl(raw);
284
+ if (!canonical || signal.aborted) return null;
285
+ const result = await Promise.race([validatePublicHttpUrl(canonical, { resolver: options.resolver }), aborted]);
286
+ if (!result) return null;
287
+ return result.ok && !signal.aborted ? result : null;
288
+ };
289
+
290
+ const enqueue = async (raw: string): Promise<void> => {
291
+ const canonical = canonicalUrl(raw);
292
+ if (!canonical) return;
293
+ const authority = new URL(canonical).origin;
294
+ if (queued.has(authority) || visited.has(authority) || queued.size + visited.size >= maxNodes) return;
295
+ const accepted = await validateUrl(canonical);
296
+ if (!accepted) return;
297
+ queued.add(accepted.url.origin);
298
+ queue.push(accepted.url.origin);
299
+ };
300
+
301
+ const fetchMetadata = async (raw: string): Promise<Record<string, unknown> | null> => {
302
+ let current = raw;
303
+ for (let redirects = 0; redirects <= maxRedirects; redirects++) {
304
+ if (signal.aborted || requests >= maxRequests) return null;
305
+ const accepted = await validateUrl(current);
306
+ if (!accepted) return null;
307
+ const target = new URL(accepted.url);
308
+ const address = accepted.addresses[0];
309
+ target.hostname = address.includes(":") ? `[${address}]` : address;
310
+ requests++;
311
+ const response = await fetcher(target, {
312
+ method: "GET",
313
+ headers: { Accept: "application/json", Host: accepted.url.host },
314
+ redirect: "manual",
315
+ signal,
316
+ tls: accepted.url.protocol === "https:" ? { serverName: accepted.url.hostname } : undefined,
317
+ });
318
+ if ([301, 302, 303, 307, 308].includes(response.status)) {
319
+ const location = response.headers.get("Location");
320
+ if (!location || redirects === maxRedirects) return null;
321
+ current = new URL(location, accepted.url).toString();
322
+ continue;
323
+ }
324
+ if (!response.ok) return null;
325
+ return (await Promise.race([response.json(), aborted])) as Record<string, unknown> | null;
326
+ }
327
+ return null;
328
+ };
329
+
330
+ const server = await validateUrl(serverUrl);
331
+ if (!server) return null;
332
+ await enqueue(server.url.toString());
333
+ if (authServerUrl) await enqueue(authServerUrl);
255
334
 
256
335
  const findEndpoints = (metadata: Record<string, unknown>): OAuthEndpoints | null => {
257
336
  if (metadata.authorization_endpoint && metadata.token_endpoint) {
@@ -310,32 +389,43 @@ export async function discoverOAuthEndpoints(
310
389
  return null;
311
390
  };
312
391
 
313
- for (const baseUrl of urlsToQuery) {
314
- visitedAuthServers.add(baseUrl);
392
+ while (queue.length > 0 && !signal.aborted && requests < maxRequests) {
393
+ const baseUrl = queue.shift()!;
394
+ queued.delete(baseUrl);
395
+ if (visited.has(baseUrl)) continue;
396
+ visited.add(baseUrl);
315
397
  for (const path of wellKnownPaths) {
316
398
  try {
317
399
  const url = new URL(path, baseUrl);
318
- const response = await fetch(url.toString(), {
319
- method: "GET",
320
- headers: { Accept: "application/json" },
321
- });
400
+ const metadata = await fetchMetadata(url.toString());
322
401
 
323
- if (response.ok) {
324
- const metadata = (await response.json()) as Record<string, unknown>;
402
+ if (metadata) {
325
403
  const endpoints = findEndpoints(metadata);
326
- if (endpoints) return endpoints;
404
+ if (endpoints) {
405
+ const issuer = typeof metadata.issuer === "string" ? canonicalUrl(metadata.issuer) : null;
406
+ const issuerBound = issuer === canonicalUrl(baseUrl);
407
+ const authorization = await validateUrl(endpoints.authorizationUrl);
408
+ const token = await validateUrl(endpoints.tokenUrl);
409
+ const legacySameOrigin =
410
+ !issuer && authorization?.url.origin === baseUrl && token?.url.origin === baseUrl;
411
+ if ((issuerBound || legacySameOrigin) && authorization && token) {
412
+ return {
413
+ ...endpoints,
414
+ authorizationUrl: authorization.url.toString(),
415
+ tokenUrl: token.url.toString(),
416
+ };
417
+ }
418
+ }
327
419
 
328
420
  if (path === "/.well-known/oauth-protected-resource") {
421
+ const resource = typeof metadata.resource === "string" ? canonicalUrl(metadata.resource) : null;
422
+ if (resource !== canonicalUrl(server.url.toString())) continue;
329
423
  const authServers = Array.isArray(metadata.authorization_servers)
330
424
  ? metadata.authorization_servers.filter((entry): entry is string => typeof entry === "string")
331
425
  : [];
332
426
 
333
427
  for (const discoveredAuthServer of authServers) {
334
- if (visitedAuthServers.has(discoveredAuthServer)) {
335
- continue;
336
- }
337
- const discovered = await discoverOAuthEndpoints(serverUrl, discoveredAuthServer);
338
- if (discovered) return discovered;
428
+ await enqueue(discoveredAuthServer);
339
429
  }
340
430
  }
341
431
  }
@@ -192,7 +192,14 @@ function alive(pid: number): boolean {
192
192
  return (e as NodeJS.ErrnoException).code === "EPERM";
193
193
  }
194
194
  }
195
+
196
+ interface SessionIndexOpenGroup {
197
+ promise: Promise<void>;
198
+ closed: boolean;
199
+ }
195
200
  export class SessionIndex {
201
+ static #operations = new Map<string, Promise<void>>();
202
+ static #openGroups = new Map<string, SessionIndexOpenGroup>();
196
203
  #agentDir: string;
197
204
  #events: SessionIndexEvent[] = [];
198
205
  #warnings: string[] = [];
@@ -201,13 +208,45 @@ export class SessionIndex {
201
208
  constructor(agentDir: string) {
202
209
  this.#agentDir = agentDir;
203
210
  }
211
+ static #enqueue<T>(indexPath: string, operation: () => Promise<T>): Promise<T> {
212
+ const previous = SessionIndex.#operations.get(indexPath) ?? Promise.resolve();
213
+ const promise = previous.catch(() => {}).then(operation);
214
+ const completion = promise.then(
215
+ () => {},
216
+ () => {},
217
+ );
218
+ SessionIndex.#operations.set(indexPath, completion);
219
+ void completion.then(() => {
220
+ if (SessionIndex.#operations.get(indexPath) === completion) SessionIndex.#operations.delete(indexPath);
221
+ });
222
+ return promise;
223
+ }
204
224
  async open(): Promise<this> {
205
- await fs.mkdir(dirFor(this.#agentDir), { recursive: true, mode: 0o700 });
206
- await fs.chmod(dirFor(this.#agentDir), 0o700);
207
- await withFileLock(logFor(this.#agentDir), () => this.replay());
225
+ const indexPath = path.resolve(logFor(this.#agentDir));
226
+ let group = SessionIndex.#openGroups.get(indexPath);
227
+ if (!group || group.closed) {
228
+ group = { promise: Promise.resolve(), closed: false };
229
+ SessionIndex.#openGroups.set(indexPath, group);
230
+ group.promise = SessionIndex.#enqueue(indexPath, () => this.#prepareOpenGroup(indexPath, group!));
231
+ }
232
+ await group.promise;
233
+ await SessionIndex.#enqueue(indexPath, () => withFileLock(logFor(this.#agentDir), () => this.#replayUnderLock()));
208
234
  return this;
209
235
  }
236
+ async #prepareOpenGroup(indexPath: string, group: SessionIndexOpenGroup): Promise<void> {
237
+ try {
238
+ await fs.mkdir(dirFor(this.#agentDir), { recursive: true, mode: 0o700 });
239
+ await fs.chmod(dirFor(this.#agentDir), 0o700);
240
+ } finally {
241
+ group.closed = true;
242
+ if (SessionIndex.#openGroups.get(indexPath) === group) SessionIndex.#openGroups.delete(indexPath);
243
+ }
244
+ }
210
245
  async replay(): Promise<void> {
246
+ const indexPath = path.resolve(logFor(this.#agentDir));
247
+ await SessionIndex.#enqueue(indexPath, () => withFileLock(logFor(this.#agentDir), () => this.#replayUnderLock()));
248
+ }
249
+ async #replayUnderLock(): Promise<void> {
211
250
  const scan = await this.#scan();
212
251
  if (scan.diagnosis.status === "unsupported") throw scan.unsupportedError!;
213
252
  this.#events = [...scan.snapshotEvents, ...scan.validLogEvents];
@@ -355,56 +394,63 @@ export class SessionIndex {
355
394
  };
356
395
  }
357
396
  async diagnose(): Promise<SessionIndexDiagnosis> {
358
- const exists = await Promise.all(
359
- [snapshotFor(this.#agentDir), logFor(this.#agentDir)].map(async file => {
360
- try {
361
- await fs.stat(file);
362
- return true;
363
- } catch (error) {
364
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
365
- throw error;
366
- }
367
- }),
368
- );
369
- if (!exists.some(Boolean)) return { status: "healthy", validPrefixSeq: 0, snapshotSeq: 0 };
370
- return await withFileLock(logFor(this.#agentDir), async () => (await this.#scan()).diagnosis);
397
+ const indexPath = path.resolve(logFor(this.#agentDir));
398
+ return await SessionIndex.#enqueue(indexPath, async () => {
399
+ const exists = await Promise.all(
400
+ [snapshotFor(this.#agentDir), logFor(this.#agentDir)].map(async file => {
401
+ try {
402
+ await fs.stat(file);
403
+ return true;
404
+ } catch (error) {
405
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
406
+ throw error;
407
+ }
408
+ }),
409
+ );
410
+ if (!exists.some(Boolean)) return { status: "healthy", validPrefixSeq: 0, snapshotSeq: 0 };
411
+ return await withFileLock(logFor(this.#agentDir), async () => (await this.#scan()).diagnosis);
412
+ });
371
413
  }
372
414
  async repair(): Promise<SessionIndexRepairResult> {
373
- await fs.mkdir(dirFor(this.#agentDir), { recursive: true, mode: 0o700 });
374
- return await withFileLock(logFor(this.#agentDir), async () => {
375
- const scan = await this.#scan();
376
- if (scan.diagnosis.status === "unsupported") return { ...scan.diagnosis, repaired: false };
377
- if (scan.diagnosis.status === "healthy") return { ...scan.diagnosis, repaired: false };
378
- const quarantineBase = path.join(dirFor(this.#agentDir), "quarantine");
379
- await fs.mkdir(quarantineBase, { recursive: true, mode: 0o700 });
380
- await syncDirectory(quarantineBase);
381
- const quarantinePath = path.join(quarantineBase, `repair-${Date.now()}-${process.pid}-${randomUUID()}`);
382
- await fs.mkdir(quarantinePath, { mode: 0o700 });
383
- await syncDirectory(quarantinePath);
384
- if (scan.snapshotContents)
385
- await writeAndSync(path.join(quarantinePath, "index.snapshot.json"), scan.snapshotContents);
386
- if (scan.logContents) await writeAndSync(path.join(quarantinePath, "index.jsonl"), scan.logContents);
387
- await syncDirectory(path.join(quarantinePath, "index.jsonl"));
388
- const snapshot = JSON.stringify({
389
- version: SESSION_INDEX_SNAPSHOT_VERSION,
390
- indexSeq: scan.snapshotEvents.at(-1)?.indexSeq ?? 0,
391
- events: scan.snapshotEvents,
415
+ const indexPath = path.resolve(logFor(this.#agentDir));
416
+ return await SessionIndex.#enqueue(indexPath, async () => {
417
+ await fs.mkdir(dirFor(this.#agentDir), { recursive: true, mode: 0o700 });
418
+ return await withFileLock(logFor(this.#agentDir), async () => {
419
+ const scan = await this.#scan();
420
+ if (scan.diagnosis.status === "unsupported") return { ...scan.diagnosis, repaired: false };
421
+ if (scan.diagnosis.status === "healthy") return { ...scan.diagnosis, repaired: false };
422
+ const quarantineBase = path.join(dirFor(this.#agentDir), "quarantine");
423
+ await fs.mkdir(quarantineBase, { recursive: true, mode: 0o700 });
424
+ await syncDirectory(quarantineBase);
425
+ const quarantinePath = path.join(quarantineBase, `repair-${Date.now()}-${process.pid}-${randomUUID()}`);
426
+ await fs.mkdir(quarantinePath, { mode: 0o700 });
427
+ await syncDirectory(quarantinePath);
428
+ if (scan.snapshotContents)
429
+ await writeAndSync(path.join(quarantinePath, "index.snapshot.json"), scan.snapshotContents);
430
+ if (scan.logContents) await writeAndSync(path.join(quarantinePath, "index.jsonl"), scan.logContents);
431
+ await syncDirectory(path.join(quarantinePath, "index.jsonl"));
432
+ const events = [...scan.snapshotEvents, ...scan.validLogEvents];
433
+ const snapshot = JSON.stringify({
434
+ version: SESSION_INDEX_SNAPSHOT_VERSION,
435
+ indexSeq: scan.diagnosis.validPrefixSeq,
436
+ events,
437
+ });
438
+ const log = scan.validLogEvents.map(event => JSON.stringify(event)).join("\n");
439
+ await replaceAtomically(snapshotFor(this.#agentDir), snapshot);
440
+ await replaceAtomically(logFor(this.#agentDir), log ? `${log}\n` : "");
441
+ await this.#replayUnderLock();
442
+ return { ...scan.diagnosis, repaired: true, quarantinePath };
392
443
  });
393
- const log = scan.validLogEvents.map(event => JSON.stringify(event)).join("\n");
394
- await replaceAtomically(snapshotFor(this.#agentDir), snapshot);
395
- await replaceAtomically(logFor(this.#agentDir), log ? `${log}\n` : "");
396
- await this.replay();
397
- return { ...scan.diagnosis, repaired: true, quarantinePath };
398
444
  });
399
445
  }
400
- async #tail(snapshotSeq = this.indexSeq, allowResync = true): Promise<void> {
446
+ async #tailUnderLock(snapshotSeq = this.indexSeq, allowResync = true): Promise<void> {
401
447
  let data: Buffer;
402
448
  try {
403
449
  const handle = await fs.open(logFor(this.#agentDir), "r");
404
450
  try {
405
451
  const stat = await handle.stat();
406
452
  if (stat.size < this.#logOffset) {
407
- if (allowResync) await this.replay();
453
+ if (allowResync) await this.#replayUnderLock();
408
454
  else this.#warn("Session index log was truncated");
409
455
  return;
410
456
  }
@@ -442,7 +488,7 @@ export class SessionIndex {
442
488
  if (corrupt) {
443
489
  this.#corruptSuffix = true;
444
490
  this.#warn("Corrupt session index entry; replay truncated");
445
- if (allowResync) await this.replay();
491
+ if (allowResync) await this.#replayUnderLock();
446
492
  }
447
493
  }
448
494
  #warn(message: string): void {
@@ -450,7 +496,13 @@ export class SessionIndex {
450
496
  }
451
497
 
452
498
  async refresh(): Promise<void> {
453
- await this.#tail();
499
+ const indexPath = path.resolve(logFor(this.#agentDir));
500
+ await SessionIndex.#enqueue(indexPath, () =>
501
+ withFileLock(logFor(this.#agentDir), () => this.#refreshUnderLock()),
502
+ );
503
+ }
504
+ async #refreshUnderLock(): Promise<void> {
505
+ await this.#tailUnderLock();
454
506
  }
455
507
  get indexSeq(): number {
456
508
  return this.#events.at(-1)?.indexSeq ?? 0;
@@ -459,31 +511,37 @@ export class SessionIndex {
459
511
  input: Omit<SessionIndexEvent, "version" | "indexSeq" | "checksum" | "ts"> &
460
512
  Partial<Pick<SessionIndexEvent, "ts">>,
461
513
  ): Promise<SessionIndexEvent> {
462
- await fs.mkdir(dirFor(this.#agentDir), { recursive: true, mode: 0o700 });
463
- return withFileLock(logFor(this.#agentDir), async () => {
464
- await this.replay();
465
- if (this.#corruptSuffix)
466
- throw new Error(
467
- "Cannot append to corrupt session index log; run `gjc gc --repair-session-index` to quarantine evidence and retain the valid prefix",
468
- );
469
- const unsigned: Omit<SessionIndexEvent, "checksum"> = {
470
- ...input,
471
- version: SDK_STATE_VERSION,
472
- indexSeq: this.indexSeq + 1,
473
- ts: input.ts ?? Date.now(),
474
- };
475
- const event: SessionIndexEvent = { ...unsigned, checksum: sessionIndexChecksum(unsigned) };
476
- await appendSync(logFor(this.#agentDir), JSON.stringify(event));
477
- await this.refresh();
478
- if ((await fs.stat(logFor(this.#agentDir))).size >= ROTATE_BYTES) await this.#rotate();
479
- return event;
514
+ const indexPath = path.resolve(logFor(this.#agentDir));
515
+ return await SessionIndex.#enqueue(indexPath, async () => {
516
+ await fs.mkdir(dirFor(this.#agentDir), { recursive: true, mode: 0o700 });
517
+ return await withFileLock(logFor(this.#agentDir), async () => {
518
+ await this.#replayUnderLock();
519
+ if (this.#corruptSuffix)
520
+ throw new Error(
521
+ "Cannot append to corrupt session index log; run `gjc gc --repair-session-index` to quarantine evidence and retain the valid prefix",
522
+ );
523
+ const unsigned: Omit<SessionIndexEvent, "checksum"> = {
524
+ ...input,
525
+ version: SDK_STATE_VERSION,
526
+ indexSeq: this.indexSeq + 1,
527
+ ts: input.ts ?? Date.now(),
528
+ };
529
+ const event: SessionIndexEvent = { ...unsigned, checksum: sessionIndexChecksum(unsigned) };
530
+ await appendSync(logFor(this.#agentDir), JSON.stringify(event));
531
+ await this.#refreshUnderLock();
532
+ if ((await fs.stat(logFor(this.#agentDir))).size >= ROTATE_BYTES) await this.#rotate();
533
+ return event;
534
+ });
480
535
  });
481
536
  }
482
537
  async snapshot(): Promise<void> {
483
- await withFileLock(logFor(this.#agentDir), () => this.#snapshotUnderLock());
538
+ const indexPath = path.resolve(logFor(this.#agentDir));
539
+ await SessionIndex.#enqueue(indexPath, () =>
540
+ withFileLock(logFor(this.#agentDir), () => this.#snapshotUnderLock()),
541
+ );
484
542
  }
485
543
  async #snapshotUnderLock(): Promise<void> {
486
- await this.replay();
544
+ await this.#replayUnderLock();
487
545
  const file = snapshotFor(this.#agentDir);
488
546
  let current: unknown;
489
547
  try {
@@ -26,11 +26,13 @@ export interface ChatDaemonRuntimeHandle {
26
26
  export interface RunChatDaemonInternalDeps {
27
27
  processPid?: number;
28
28
  pidAlive?: (pid: number) => boolean;
29
+ pidIncarnation?: (pid: number) => string | undefined;
29
30
  createRuntime?: (input: {
30
31
  kind: ChatDaemonKind;
31
32
  agentDir: string;
32
33
  config: ChatDaemonConfig;
33
34
  }) => Promise<ChatDaemonRuntimeHandle> | ChatDaemonRuntimeHandle;
35
+ renewHeartbeat?: (input: Parameters<typeof renewChatDaemonHeartbeat>[0]) => Promise<boolean>;
34
36
  setInterval?: typeof setInterval;
35
37
  clearInterval?: typeof clearInterval;
36
38
  }
@@ -65,8 +67,10 @@ async function loadConfig(agentDir: string, kind: ChatDaemonKind): Promise<ChatD
65
67
  idleTimeoutMs: 60_000,
66
68
  rich: { enabled: true },
67
69
  richDraft: { enabled: false },
70
+ toolActivity: { enabled: true },
68
71
  topics: { nameTemplate: undefined },
69
72
  btw: { enabled: true },
73
+ streaming: { enabled: true },
70
74
  })
71
75
  ) {
72
76
  throw new Error("Discord notifications are enabled but configuration is incomplete");
@@ -98,8 +102,10 @@ async function loadConfig(agentDir: string, kind: ChatDaemonKind): Promise<ChatD
98
102
  idleTimeoutMs: 60_000,
99
103
  rich: { enabled: true },
100
104
  richDraft: { enabled: false },
105
+ toolActivity: { enabled: true },
101
106
  topics: { nameTemplate: undefined },
102
107
  btw: { enabled: true },
108
+ streaming: { enabled: true },
103
109
  })
104
110
  ) {
105
111
  throw new Error("Slack notifications are enabled but configuration is incomplete");
@@ -160,10 +166,19 @@ export async function runChatDaemonInternal(
160
166
  if (!ownerId) throw new Error("missing --owner-id");
161
167
  const pid = ownerPid(ownerId);
162
168
  if (pid !== undefined && !(deps.pidAlive ?? defaultPidAlive)(pid)) return;
169
+ const daemonPid = deps.processPid ?? process.pid;
163
170
  const config = await loadConfig(agentDir, kind);
164
171
  if (!config) return;
165
172
  if (
166
- !(await acquireChatDaemonOwnership({ agentDir, kind, ownerId, pid: deps.processPid, identity: config.identity }))
173
+ !(await acquireChatDaemonOwnership({
174
+ agentDir,
175
+ kind,
176
+ ownerId,
177
+ pid: daemonPid,
178
+ identity: config.identity,
179
+ pidAlive: deps.pidAlive,
180
+ pidIncarnation: deps.pidIncarnation,
181
+ }))
167
182
  )
168
183
  return;
169
184
 
@@ -171,30 +186,60 @@ export async function runChatDaemonInternal(
171
186
  let runtime: ChatDaemonRuntimeHandle | undefined;
172
187
  let interval: ReturnType<typeof setInterval> | undefined;
173
188
  let stopping = false;
189
+ let terminalError: unknown;
190
+ let runtimeStop: Promise<void> | undefined;
191
+ const stopRuntime = (): Promise<void> => {
192
+ runtimeStop ??= runtime?.stop() ?? Promise.resolve();
193
+ return runtimeStop;
194
+ };
174
195
  const stop = (): void => {
175
196
  stopping = true;
197
+ void stopRuntime().catch(error => {
198
+ terminalError ??= error;
199
+ });
176
200
  };
177
201
  try {
178
202
  incarnation = (await readChatDaemonState(agentDir, kind))?.incarnation;
179
203
  if (!incarnation) throw new Error("chat daemon ownership state is missing an incarnation");
180
204
  runtime = await (deps.createRuntime?.({ kind, agentDir, config }) ?? defaultRuntime({ kind, agentDir, config }));
181
- const renewHeartbeat = async (): Promise<void> => {
182
- await renewChatDaemonHeartbeat({
205
+ const activeRuntime = runtime;
206
+ const renewHeartbeat = async (): Promise<boolean> =>
207
+ await (deps.renewHeartbeat ?? renewChatDaemonHeartbeat)({
183
208
  agentDir,
184
209
  kind,
185
210
  ownerId,
186
- pid: deps.processPid,
211
+ pid: daemonPid,
187
212
  incarnation,
188
- transportHealthy: runtime?.transportHealthy?.() ?? true,
213
+ transportHealthy: activeRuntime.transportHealthy?.() ?? true,
214
+ pidAlive: deps.pidAlive,
215
+ pidIncarnation: deps.pidIncarnation,
189
216
  });
217
+ const terminateForLostOwnership = async (): Promise<void> => {
218
+ stopping = true;
219
+ await stopRuntime();
190
220
  };
191
221
  process.once("SIGTERM", stop);
192
222
  process.once("SIGINT", stop);
223
+ if (!(await renewHeartbeat())) {
224
+ await terminateForLostOwnership();
225
+ return;
226
+ }
227
+ await runtime.start();
193
228
  interval = (deps.setInterval ?? setInterval)(() => {
194
- void renewHeartbeat();
229
+ void (async () => {
230
+ try {
231
+ if (!(await renewHeartbeat())) await terminateForLostOwnership();
232
+ } catch (error) {
233
+ terminalError ??= error;
234
+ stopping = true;
235
+ try {
236
+ await stopRuntime();
237
+ } catch (stopError) {
238
+ terminalError ??= stopError;
239
+ }
240
+ }
241
+ })();
195
242
  }, 5_000);
196
- await runtime.start();
197
- await renewHeartbeat();
198
243
  while (!stopping) {
199
244
  const request = await readChatDaemonControlRequest(agentDir, kind);
200
245
  if (request?.ownerId === ownerId && request.incarnation === incarnation) {
@@ -208,9 +253,26 @@ export async function runChatDaemonInternal(
208
253
  process.off("SIGTERM", stop);
209
254
  process.off("SIGINT", stop);
210
255
  try {
211
- await runtime?.stop();
256
+ await stopRuntime();
257
+ } catch (error) {
258
+ terminalError ??= error;
212
259
  } finally {
213
- await releaseChatDaemonOwnership({ agentDir, kind, ownerId, pid: deps.processPid, incarnation });
260
+ if (incarnation !== undefined) {
261
+ try {
262
+ await releaseChatDaemonOwnership({
263
+ agentDir,
264
+ kind,
265
+ ownerId,
266
+ pid: daemonPid,
267
+ incarnation,
268
+ pidAlive: deps.pidAlive,
269
+ pidIncarnation: deps.pidIncarnation,
270
+ });
271
+ } catch (error) {
272
+ terminalError ??= error;
273
+ }
274
+ }
214
275
  }
215
276
  }
277
+ if (terminalError !== undefined) throw terminalError;
216
278
  }