@gajae-code/coding-agent 0.11.2 → 0.11.4

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 (101) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/types/cli/launch-disposition.d.ts +23 -0
  3. package/dist/types/cli/worktree-cli.d.ts +8 -25
  4. package/dist/types/commands/gc.d.ts +4 -0
  5. package/dist/types/commands/worktree.d.ts +7 -32
  6. package/dist/types/config/settings-schema.d.ts +85 -10
  7. package/dist/types/gjc-runtime/gc-runtime.d.ts +9 -0
  8. package/dist/types/gjc-runtime/state-argv.d.ts +38 -0
  9. package/dist/types/main.d.ts +1 -0
  10. package/dist/types/modes/components/custom-editor.d.ts +8 -3
  11. package/dist/types/modes/components/tool-transcript-format.d.ts +19 -0
  12. package/dist/types/modes/controllers/input-controller.d.ts +8 -2
  13. package/dist/types/modes/prompt-action-autocomplete.d.ts +4 -0
  14. package/dist/types/sdk/broker/broker.d.ts +1 -0
  15. package/dist/types/sdk/broker/discovery.d.ts +10 -0
  16. package/dist/types/sdk/broker/ensure.d.ts +24 -1
  17. package/dist/types/sdk/broker/internal.d.ts +3 -0
  18. package/dist/types/sdk/broker/session-index.d.ts +12 -0
  19. package/dist/types/sdk/broker/state-version.d.ts +2 -1
  20. package/dist/types/sdk/bus/index.d.ts +2 -0
  21. package/dist/types/sdk/session.d.ts +2 -0
  22. package/dist/types/tools/ask.d.ts +39 -12
  23. package/dist/types/tools/browser/screenshot-format.d.ts +8 -0
  24. package/dist/types/tools/browser/tab-protocol.d.ts +11 -0
  25. package/dist/types/tools/browser/tab-supervisor.d.ts +1 -1
  26. package/dist/types/tools/browser/tab-worker-path-resolver.d.ts +2 -0
  27. package/dist/types/tools/browser/tab-worker-smoke.d.ts +16 -0
  28. package/dist/types/tools/path-utils.d.ts +9 -14
  29. package/dist/types/tools/read.d.ts +2 -0
  30. package/dist/types/tools/render-utils.d.ts +1 -8
  31. package/dist/types/tools/resource-gc.d.ts +14 -0
  32. package/dist/types/tools/subagent.d.ts +1 -0
  33. package/dist/types/utils/file-mentions.d.ts +3 -3
  34. package/dist/types/utils/image-loading.d.ts +23 -0
  35. package/dist/types/utils/image-resize.d.ts +2 -0
  36. package/dist/types/utils/pasted-image-loading.d.ts +39 -0
  37. package/dist/types/utils/pasted-image-path.d.ts +21 -19
  38. package/dist/types/web/scrapers/html-to-markdown.d.ts +5 -0
  39. package/dist/types/web/scrapers/types.d.ts +1 -5
  40. package/package.json +24 -8
  41. package/scripts/run-threat-model-check.ts +60 -0
  42. package/src/cli/launch-disposition.ts +42 -0
  43. package/src/cli/worktree-cli.ts +9 -287
  44. package/src/cli.ts +10 -9
  45. package/src/commands/gc.ts +11 -1
  46. package/src/commands/sdk.ts +3 -2
  47. package/src/commands/worktree.ts +10 -53
  48. package/src/config/model-profiles.ts +15 -15
  49. package/src/config/settings-schema.ts +55 -10
  50. package/src/eval/js/shared/runtime.ts +1 -1
  51. package/src/gjc-runtime/gc-render.ts +19 -1
  52. package/src/gjc-runtime/gc-runtime.ts +68 -3
  53. package/src/gjc-runtime/state-argv.ts +292 -0
  54. package/src/gjc-runtime/state-runtime.ts +51 -212
  55. package/src/main.ts +99 -22
  56. package/src/modes/components/custom-editor.ts +103 -32
  57. package/src/modes/components/custom-provider-wizard.ts +26 -1
  58. package/src/modes/components/session-observer-overlay.ts +10 -23
  59. package/src/modes/components/tool-transcript-format.ts +72 -0
  60. package/src/modes/components/transcript-viewer-overlay.ts +42 -2
  61. package/src/modes/controllers/input-controller.ts +98 -37
  62. package/src/modes/controllers/selector-controller.ts +1 -1
  63. package/src/modes/interactive-mode.ts +13 -2
  64. package/src/modes/prompt-action-autocomplete.ts +7 -0
  65. package/src/sdk/broker/broker.ts +139 -32
  66. package/src/sdk/broker/discovery.ts +125 -0
  67. package/src/sdk/broker/ensure.ts +103 -19
  68. package/src/sdk/broker/internal.ts +10 -0
  69. package/src/sdk/broker/session-index.ts +228 -15
  70. package/src/sdk/broker/state-version.ts +5 -2
  71. package/src/sdk/bus/index.ts +4 -1
  72. package/src/sdk/bus/telegram-daemon.ts +50 -10
  73. package/src/sdk/host/host.ts +13 -1
  74. package/src/sdk/session.ts +4 -1
  75. package/src/session/internal/managed-session-scope.ts +15 -1
  76. package/src/tools/ask.ts +216 -41
  77. package/src/tools/bash-allowed-prefixes.ts +79 -29
  78. package/src/tools/browser/launch.ts +3 -1
  79. package/src/tools/browser/readable.ts +1 -1
  80. package/src/tools/browser/screenshot-format.ts +34 -0
  81. package/src/tools/browser/tab-protocol.ts +3 -0
  82. package/src/tools/browser/tab-supervisor.ts +125 -122
  83. package/src/tools/browser/tab-worker-entry.ts +33 -14
  84. package/src/tools/browser/tab-worker-path-resolver.ts +67 -0
  85. package/src/tools/browser/tab-worker-smoke.ts +87 -0
  86. package/src/tools/browser/tab-worker.ts +7 -5
  87. package/src/tools/output-meta.ts +6 -2
  88. package/src/tools/path-utils.ts +42 -35
  89. package/src/tools/read.ts +189 -64
  90. package/src/tools/render-utils.ts +1 -27
  91. package/src/tools/resource-gc.ts +123 -34
  92. package/src/tools/subagent.ts +43 -2
  93. package/src/utils/file-mentions.ts +4 -4
  94. package/src/utils/image-loading.ts +85 -34
  95. package/src/utils/image-resize.ts +16 -9
  96. package/src/utils/pasted-image-loading.ts +514 -0
  97. package/src/utils/pasted-image-path.ts +223 -98
  98. package/src/web/scrapers/html-to-markdown.ts +68 -0
  99. package/src/web/scrapers/readthedocs.ts +1 -1
  100. package/src/web/scrapers/types.ts +1 -68
  101. package/src/web/search/providers/openai-compatible.ts +76 -23
@@ -1,4 +1,4 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { withFileLock } from "../../config/file-lock";
@@ -48,6 +48,27 @@ export interface SessionList {
48
48
  sessions: IndexedSession[];
49
49
  warnings: string[];
50
50
  }
51
+
52
+ export interface SessionIndexDiagnosis {
53
+ status: "healthy" | "corrupt" | "unsupported";
54
+ validPrefixSeq: number;
55
+ snapshotSeq: number;
56
+ reason?: string;
57
+ }
58
+
59
+ export interface SessionIndexRepairResult extends SessionIndexDiagnosis {
60
+ repaired: boolean;
61
+ quarantinePath?: string;
62
+ }
63
+
64
+ interface SessionIndexScan {
65
+ diagnosis: SessionIndexDiagnosis;
66
+ snapshotEvents: SessionIndexEvent[];
67
+ validLogEvents: SessionIndexEvent[];
68
+ snapshotContents: Buffer | undefined;
69
+ logContents: Buffer | undefined;
70
+ unsupportedError?: UnsupportedStateVersionError;
71
+ }
51
72
  const canonical = (event: Omit<SessionIndexEvent, "checksum">) => JSON.stringify(event);
52
73
  export const sessionIndexChecksum = (event: Omit<SessionIndexEvent, "checksum">) =>
53
74
  createHash("sha256").update(canonical(event)).digest("hex");
@@ -142,6 +163,27 @@ async function syncDirectory(file: string): Promise<void> {
142
163
  }
143
164
  }
144
165
 
166
+ async function writeAndSync(file: string, contents: Buffer | string): Promise<void> {
167
+ const handle = await fs.open(file, "w", 0o600);
168
+ try {
169
+ await handle.writeFile(contents);
170
+ await handle.sync();
171
+ } finally {
172
+ await handle.close();
173
+ }
174
+ }
175
+
176
+ async function replaceAtomically(file: string, contents: Buffer | string): Promise<void> {
177
+ const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
178
+ try {
179
+ await writeAndSync(temporary, contents);
180
+ await fs.rename(temporary, file);
181
+ await syncDirectory(file);
182
+ } finally {
183
+ await fs.rm(temporary, { force: true });
184
+ }
185
+ }
186
+
145
187
  function alive(pid: number): boolean {
146
188
  try {
147
189
  process.kill(pid, 0);
@@ -166,26 +208,194 @@ export class SessionIndex {
166
208
  return this;
167
209
  }
168
210
  async replay(): Promise<void> {
169
- this.#events = [];
211
+ const scan = await this.#scan();
212
+ if (scan.diagnosis.status === "unsupported") throw scan.unsupportedError!;
213
+ this.#events = [...scan.snapshotEvents, ...scan.validLogEvents];
170
214
  this.#warnings = [];
171
- this.#logOffset = 0;
172
- this.#corruptSuffix = false;
215
+ this.#logOffset = scan.logContents?.length ?? 0;
216
+ this.#corruptSuffix = scan.diagnosis.status === "corrupt";
217
+ if (scan.diagnosis.reason === "invalid snapshot") this.#warnings.push("Invalid session index snapshot");
218
+ if (this.#corruptSuffix) this.#warnings.push("Corrupt session index entry; replay truncated");
219
+ }
220
+ async #scan(): Promise<SessionIndexScan> {
221
+ let snapshotContents: Buffer | undefined;
222
+ let logContents: Buffer | undefined;
223
+ let snapshotEvents: SessionIndexEvent[] = [];
173
224
  let snapshotSeq = 0;
225
+ let trustedSnapshotSeq = 0;
226
+ let invalidSnapshot = false;
227
+ let unsupportedError: UnsupportedStateVersionError | undefined;
174
228
  try {
175
- const snapshot = JSON.parse(await fs.readFile(snapshotFor(this.#agentDir), "utf8")) as {
229
+ snapshotContents = await fs.readFile(snapshotFor(this.#agentDir));
230
+ const snapshot = JSON.parse(snapshotContents.toString("utf8")) as {
176
231
  version?: number;
177
- events?: SessionIndexEvent[];
178
- indexSeq?: number;
232
+ indexSeq?: unknown;
233
+ events?: unknown;
179
234
  };
235
+ if (typeof snapshot.indexSeq === "number" && Number.isSafeInteger(snapshot.indexSeq) && snapshot.indexSeq >= 0)
236
+ snapshotSeq = snapshot.indexSeq;
180
237
  assertSupportedSnapshotVersion(snapshotFor(this.#agentDir), snapshot);
181
- if (!isValidSnapshot(snapshot)) throw new Error("invalid snapshot");
182
- this.#events = snapshot.events;
183
- snapshotSeq = snapshot.indexSeq;
184
- } catch (e) {
185
- if (e instanceof UnsupportedStateVersionError) throw e;
186
- if ((e as NodeJS.ErrnoException).code !== "ENOENT") this.#warnings.push("Invalid session index snapshot");
238
+ const supportedEvents: SessionIndexEvent[] = [];
239
+ if (Array.isArray(snapshot.events)) {
240
+ try {
241
+ for (const event of snapshot.events) {
242
+ assertSupportedStateVersion(snapshotFor(this.#agentDir), event);
243
+ supportedEvents.push(event as SessionIndexEvent);
244
+ }
245
+ } catch (error) {
246
+ if (!(error instanceof UnsupportedStateVersionError)) throw error;
247
+ unsupportedError = error;
248
+ snapshotEvents = supportedEvents;
249
+ }
250
+ }
251
+ if (!unsupportedError) {
252
+ if (!isValidSnapshot(snapshot)) invalidSnapshot = true;
253
+ else {
254
+ snapshotEvents = snapshot.events;
255
+ trustedSnapshotSeq = snapshot.indexSeq;
256
+ }
257
+ }
258
+ } catch (error) {
259
+ if (error instanceof UnsupportedStateVersionError) unsupportedError = error;
260
+ else if ((error as NodeJS.ErrnoException).code !== "ENOENT") invalidSnapshot = true;
261
+ }
262
+ try {
263
+ logContents = await fs.readFile(logFor(this.#agentDir));
264
+ } catch (error) {
265
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
266
+ }
267
+ const validLogEvents: SessionIndexEvent[] = [];
268
+ let corrupt = invalidSnapshot;
269
+ let logCorrupt = false;
270
+ let tailStarted = false;
271
+ let historicalLast: number | undefined;
272
+ let expected = trustedSnapshotSeq + 1;
273
+ if (logContents) {
274
+ const text = logContents.toString("utf8");
275
+ const lines = text.split("\n");
276
+ const terminal = lines.pop();
277
+ for (const line of lines) {
278
+ if (!line) continue;
279
+ try {
280
+ const event = JSON.parse(line) as SessionIndexEvent;
281
+ assertSupportedStateVersion(logFor(this.#agentDir), event);
282
+ const { checksum, ...unsigned } = event;
283
+ if (checksum !== sessionIndexChecksum(unsigned)) {
284
+ corrupt = true;
285
+ logCorrupt = true;
286
+ continue;
287
+ }
288
+ if (!tailStarted && !invalidSnapshot && event.indexSeq <= trustedSnapshotSeq) {
289
+ if (
290
+ !Number.isSafeInteger(event.indexSeq) ||
291
+ event.indexSeq <= 0 ||
292
+ (historicalLast !== undefined && event.indexSeq !== historicalLast + 1)
293
+ ) {
294
+ corrupt = true;
295
+ logCorrupt = true;
296
+ } else {
297
+ historicalLast = event.indexSeq;
298
+ }
299
+ continue;
300
+ }
301
+ tailStarted = true;
302
+ if (historicalLast !== undefined && historicalLast !== trustedSnapshotSeq) {
303
+ corrupt = true;
304
+ logCorrupt = true;
305
+ }
306
+ if (event.indexSeq !== expected) {
307
+ corrupt = true;
308
+ logCorrupt = true;
309
+ } else if (!logCorrupt) {
310
+ validLogEvents.push(event);
311
+ expected++;
312
+ }
313
+ } catch (error) {
314
+ if (error instanceof UnsupportedStateVersionError) {
315
+ const verifiedSnapshotPrefix = snapshotEvents.at(-1)?.indexSeq ?? trustedSnapshotSeq;
316
+ const validPrefixSeq = validLogEvents.at(-1)?.indexSeq ?? verifiedSnapshotPrefix;
317
+ return {
318
+ diagnosis: { status: "unsupported", validPrefixSeq, snapshotSeq, reason: error.message },
319
+ snapshotEvents,
320
+ validLogEvents,
321
+ snapshotContents,
322
+ logContents,
323
+ unsupportedError: error,
324
+ };
325
+ }
326
+ corrupt = true;
327
+ logCorrupt = true;
328
+ }
329
+ }
330
+ if (historicalLast !== undefined && !tailStarted && historicalLast !== trustedSnapshotSeq) {
331
+ corrupt = true;
332
+ logCorrupt = true;
333
+ }
334
+ if (terminal !== "") {
335
+ corrupt = true;
336
+ logCorrupt = true;
337
+ }
187
338
  }
188
- await this.#tail(snapshotSeq, false);
339
+ const verifiedSnapshotPrefix = snapshotEvents.at(-1)?.indexSeq ?? trustedSnapshotSeq;
340
+ const validPrefixSeq = validLogEvents.at(-1)?.indexSeq ?? verifiedSnapshotPrefix;
341
+ return {
342
+ diagnosis: {
343
+ status: unsupportedError ? "unsupported" : corrupt ? "corrupt" : "healthy",
344
+ validPrefixSeq,
345
+ snapshotSeq,
346
+ reason:
347
+ unsupportedError?.message ??
348
+ (invalidSnapshot ? "invalid snapshot" : corrupt ? "invalid log sequence" : undefined),
349
+ },
350
+ snapshotEvents,
351
+ validLogEvents,
352
+ snapshotContents,
353
+ logContents,
354
+ unsupportedError,
355
+ };
356
+ }
357
+ 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);
371
+ }
372
+ 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,
392
+ });
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
+ });
189
399
  }
190
400
  async #tail(snapshotSeq = this.indexSeq, allowResync = true): Promise<void> {
191
401
  let data: Buffer;
@@ -252,7 +462,10 @@ export class SessionIndex {
252
462
  await fs.mkdir(dirFor(this.#agentDir), { recursive: true, mode: 0o700 });
253
463
  return withFileLock(logFor(this.#agentDir), async () => {
254
464
  await this.replay();
255
- if (this.#corruptSuffix) throw new Error("Cannot append to corrupt session index log");
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
+ );
256
469
  const unsigned: Omit<SessionIndexEvent, "checksum"> = {
257
470
  ...input,
258
471
  version: SDK_STATE_VERSION,
@@ -13,8 +13,11 @@ export class UnsupportedStateVersionError extends Error {
13
13
  constructor(
14
14
  readonly file: string,
15
15
  readonly version: number,
16
+ readonly maximumSupportedVersion = SDK_STATE_VERSION,
16
17
  ) {
17
- super(`Unsupported SDK state version ${version} in ${file}; maximum supported version is ${SDK_STATE_VERSION}.`);
18
+ super(
19
+ `Unsupported SDK state version ${version} in ${file}; maximum supported version is ${maximumSupportedVersion}.`,
20
+ );
18
21
  this.name = "UnsupportedStateVersionError";
19
22
  }
20
23
  }
@@ -38,7 +41,7 @@ export function assertSupportedSnapshotVersion(file: string, value: unknown): vo
38
41
  const record = value as { version?: unknown; stateVersion?: unknown };
39
42
  for (const version of [record.version, record.stateVersion]) {
40
43
  if (typeof version === "number" && Number.isFinite(version) && version > SESSION_INDEX_SNAPSHOT_VERSION) {
41
- throw new UnsupportedStateVersionError(file, version);
44
+ throw new UnsupportedStateVersionError(file, version, SESSION_INDEX_SNAPSHOT_VERSION);
42
45
  }
43
46
  }
44
47
  }
@@ -2741,6 +2741,8 @@ export function createNotificationsExtension(
2741
2741
  /** Suppress auto-delivery for a GJC-spawned child under `sessionScope=primary`. */
2742
2742
  spawnedByGjc?: boolean;
2743
2743
  controller?: NotificationSessionController;
2744
+ /** Whether this host mode can own the root SDK endpoint. Default: true. */
2745
+ sdkHostModeSupported?: boolean;
2744
2746
 
2745
2747
  onSdkRequest?: (kind: "control" | "query", connectionId: string, frame: Record<string, unknown>) => void;
2746
2748
  runEphemeralTurn?: (promptText: string, signal: AbortSignal) => Promise<{ replyText: string }>;
@@ -2956,7 +2958,8 @@ export function createNotificationsExtension(
2956
2958
  const lifecycleRequestId = safeLifecycleRequestId(process.env.GJC_LIFECYCLE_REQUEST_ID);
2957
2959
  const { settings, cfg, settingsAvailable } = resolveSettings(options.settings);
2958
2960
  const notificationsEnabledForSession = controller.query(ctx).effectiveEnabled;
2959
- const sdkEnabledForSession = shouldHostSdk(settings, isNotificationEligibleContext(ctx));
2961
+ const sdkEnabledForSession =
2962
+ (options.sdkHostModeSupported ?? true) && shouldHostSdk(settings, isNotificationEligibleContext(ctx));
2960
2963
  const lifecycleRequired = lifecycleStartupCapability !== undefined;
2961
2964
  const failLifecycleStartup = (
2962
2965
  reason: "disabled" | "ineligible" | "failed",
@@ -1293,6 +1293,20 @@ export interface DaemonControlHooks {
1293
1293
  clear?(ownerId: string): Promise<void>;
1294
1294
  }
1295
1295
 
1296
+ type BtwTerminalDeliveryOutcome = "accepted" | "not_delivered" | "uncertain" | "partial_accepted" | "stale";
1297
+
1298
+ interface BtwTerminalDeliveryReceipt {
1299
+ requestId: string;
1300
+ logicalSessionId: string;
1301
+ transportSessionId: string;
1302
+ threadId: string;
1303
+ updateId: number;
1304
+ messageId: number;
1305
+ outcome: BtwTerminalDeliveryOutcome;
1306
+ }
1307
+
1308
+ const BTW_TERMINAL_DELIVERY_TEST_OBSERVER = Symbol.for("gjc.test.btw-terminal-delivery-observer");
1309
+
1296
1310
  export interface TelegramDaemonOptions {
1297
1311
  settings: Settings;
1298
1312
  ownerId: string;
@@ -3958,6 +3972,7 @@ export class TelegramNotificationDaemon {
3958
3972
  };
3959
3973
  this.#btwTerminalDeliveries.set(requestId, terminalDelivery);
3960
3974
  let deliveryOutcome: "accepted" | "not_delivered" | "uncertain" | "partial_accepted" = "not_delivered";
3975
+ let observerOutcome: BtwTerminalDeliveryOutcome = deliveryOutcome;
3961
3976
  try {
3962
3977
  if (msg.status !== "ok") {
3963
3978
  const text =
@@ -4023,7 +4038,7 @@ export class TelegramNotificationDaemon {
4023
4038
  }
4024
4039
  };
4025
4040
  const html = markdownToTelegramHtml(markdown);
4026
- const fallback = async (): Promise<typeof deliveryOutcome> => {
4041
+ const fallback = async (): Promise<BtwTerminalDeliveryOutcome> => {
4027
4042
  let acceptedChunks = 0;
4028
4043
  for (const [index, text] of splitTelegramHtml(html).entries()) {
4029
4044
  const outcome = await this.#queueBtwFallbackChunk({
@@ -4045,6 +4060,7 @@ export class TelegramNotificationDaemon {
4045
4060
  continue;
4046
4061
  }
4047
4062
  if (outcome === "partial_accepted" || acceptedChunks > 0) return "partial_accepted";
4063
+ if (outcome === "stale") return "stale";
4048
4064
  return outcome === "uncertain" ? "uncertain" : "not_delivered";
4049
4065
  }
4050
4066
  return "accepted";
@@ -4056,16 +4072,21 @@ export class TelegramNotificationDaemon {
4056
4072
  reply_parameters: { message_id: pending.messageId },
4057
4073
  rich_message: { markdown, skip_entity_detection: true },
4058
4074
  });
4059
- deliveryOutcome =
4060
- outcome === "accepted"
4061
- ? "accepted"
4062
- : outcome === "rejected"
4063
- ? await fallback()
4064
- : outcome === "uncertain"
4065
- ? "uncertain"
4066
- : "not_delivered";
4075
+ if (outcome === "accepted") {
4076
+ deliveryOutcome = "accepted";
4077
+ observerOutcome = "accepted";
4078
+ } else if (outcome === "rejected") {
4079
+ const fallbackOutcome = await fallback();
4080
+ observerOutcome = fallbackOutcome;
4081
+ deliveryOutcome = fallbackOutcome === "stale" ? "not_delivered" : fallbackOutcome;
4082
+ } else {
4083
+ deliveryOutcome = outcome === "uncertain" ? "uncertain" : "not_delivered";
4084
+ observerOutcome = outcome === "stale" ? "stale" : deliveryOutcome;
4085
+ }
4067
4086
  } else {
4068
- deliveryOutcome = await fallback();
4087
+ const fallbackOutcome = await fallback();
4088
+ observerOutcome = fallbackOutcome;
4089
+ deliveryOutcome = fallbackOutcome === "stale" ? "not_delivered" : fallbackOutcome;
4069
4090
  }
4070
4091
  } finally {
4071
4092
  if (this.#btwTerminalDeliveries.get(requestId) === terminalDelivery) {
@@ -4084,6 +4105,25 @@ export class TelegramNotificationDaemon {
4084
4105
  }
4085
4106
  } finally {
4086
4107
  terminalDelivery.finish();
4108
+ observerOutcome = observerOutcome === "stale" ? "stale" : deliveryOutcome;
4109
+ if (this.#pendingBtwTurns.get(requestId) !== pending) {
4110
+ try {
4111
+ const observer = (
4112
+ this as unknown as Record<symbol, ((receipt: BtwTerminalDeliveryReceipt) => void) | undefined>
4113
+ )[BTW_TERMINAL_DELIVERY_TEST_OBSERVER];
4114
+ observer?.({
4115
+ requestId,
4116
+ logicalSessionId: pending.logicalSessionId,
4117
+ transportSessionId: pending.transportSessionId,
4118
+ threadId: pending.threadId,
4119
+ updateId: pending.updateId,
4120
+ messageId: pending.messageId,
4121
+ outcome: observerOutcome,
4122
+ });
4123
+ } catch {
4124
+ logger.warn("notifications: /btw terminal delivery observer failed");
4125
+ }
4126
+ }
4087
4127
  }
4088
4128
  }
4089
4129
  return;
@@ -102,6 +102,7 @@ export class SessionSdkHost {
102
102
  readonly reverse: ReverseLeaseRuntime;
103
103
  readonly #options: SessionSdkHostOptions;
104
104
  #started = false;
105
+ #stopPromise?: Promise<"stopped">;
105
106
  #unsubscribe?: () => void;
106
107
  #registration?: { writer: BrokerIndexWriter; generation: number };
107
108
 
@@ -154,16 +155,27 @@ export class SessionSdkHost {
154
155
  }
155
156
 
156
157
  async stop(): Promise<"stopped" | "already"> {
158
+ if (this.#stopPromise) return this.#stopPromise;
157
159
  if (!this.#started) return "already";
160
+ const stopPromise = this.#stopStartedHost();
161
+ this.#stopPromise = stopPromise;
162
+ try {
163
+ return await stopPromise;
164
+ } finally {
165
+ if (this.#stopPromise === stopPromise) this.#stopPromise = undefined;
166
+ }
167
+ }
168
+
169
+ async #stopStartedHost(): Promise<"stopped"> {
158
170
  this.#unsubscribe?.();
159
171
  this.#unsubscribe = undefined;
160
- this.#started = false;
161
172
  if (this.#registration?.writer.unregister)
162
173
  await this.#registration.writer.unregister({
163
174
  sessionId: this.#options.sessionId,
164
175
  stateRoot: this.#options.stateRoot,
165
176
  endpointGeneration: this.events.generation,
166
177
  });
178
+ this.#started = false;
167
179
  return "stopped";
168
180
  }
169
181
 
@@ -390,6 +390,8 @@ export interface CreateAgentSessionOptions {
390
390
  hasUI?: boolean;
391
391
  /** Whether this host mode can own a notification session endpoint. Default: true. */
392
392
  notificationHostModeSupported?: boolean;
393
+ /** Whether this host mode can own the root SDK endpoint. Default: true. */
394
+ sdkHostModeSupported?: boolean;
393
395
 
394
396
  /**
395
397
  * Opt-in OpenTelemetry instrumentation forwarded to the underlying Agent.
@@ -1763,7 +1765,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1763
1765
  currentAgentType: options.currentAgentType,
1764
1766
  spawnedByGjc,
1765
1767
  }) ||
1766
- shouldHostSdk(notificationCfg, isTopLevelSdkSession)
1768
+ (shouldHostSdk(notificationCfg, isTopLevelSdkSession) && (options.sdkHostModeSupported ?? true))
1767
1769
  ) {
1768
1770
  inlineExtensions.push(async api => {
1769
1771
  try {
@@ -1774,6 +1776,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1774
1776
  settings,
1775
1777
  controller: notificationSessionController,
1776
1778
  spawnedByGjc,
1779
+ sdkHostModeSupported: options.sdkHostModeSupported,
1777
1780
  runEphemeralTurn: async (promptText, signal) => {
1778
1781
  if (!session) throw new Error("Ephemeral turns are unavailable.");
1779
1782
  const { replyText } = await session.runEphemeralTurn({ promptText, signal });
@@ -99,6 +99,7 @@ export type ManagedDeleteCandidateResult =
99
99
  type NativeIdentity =
100
100
  | { ok: true; platform: "posix" | "win32"; canonicalPath: string }
101
101
  | { ok: false; code: NativeIdentityFailureCode };
102
+ type CanonicalNativeIdentity = Extract<NativeIdentity, { ok: true }>;
102
103
 
103
104
  type NativeIdentityFailureCode =
104
105
  | "not_found"
@@ -149,6 +150,18 @@ function identityFor(cwd: string): NativeIdentity {
149
150
  return canonicalExistingDirectoryIdentity(cwd) as NativeIdentity;
150
151
  }
151
152
 
153
+ function canonicalExistingPathForIo(base: string, identity: CanonicalNativeIdentity): string {
154
+ if (identity.platform !== "win32") return identity.canonicalPath;
155
+ try {
156
+ // Native identity uses a stable Volume GUID path on Windows. Bun 1.3.14
157
+ // cannot reliably create/read files through that path, so retain the
158
+ // symlink-resolved DOS path for JavaScript filesystem I/O.
159
+ return fs.realpathSync.native(base);
160
+ } catch {
161
+ return path.resolve(base);
162
+ }
163
+ }
164
+
152
165
  /**
153
166
  * Resolve benign symlinks in the deepest existing ancestor of a trusted storage
154
167
  * root (e.g. macOS `/var -> /private/var`, or a symlinked `$HOME`) while keeping
@@ -164,7 +177,8 @@ export function canonicalizeTrustedPath(target: string): string {
164
177
  for (;;) {
165
178
  const identity = canonicalExistingDirectoryIdentity(base) as NativeIdentity;
166
179
  if (identity.ok) {
167
- return suffix.length === 0 ? identity.canonicalPath : path.join(identity.canonicalPath, ...suffix);
180
+ const canonicalBase = canonicalExistingPathForIo(base, identity);
181
+ return suffix.length === 0 ? canonicalBase : path.join(canonicalBase, ...suffix);
168
182
  }
169
183
  if (identity.code !== "not_found" && identity.code !== "not_directory") return path.resolve(target);
170
184
  const parent = path.dirname(base);