@gajae-code/coding-agent 0.12.1 → 0.12.2

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 (63) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/types/config/settings-schema.d.ts +2 -2
  3. package/dist/types/gjc-runtime/ralplan-review-conflicts.d.ts +114 -0
  4. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +5 -3
  5. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +6 -0
  6. package/dist/types/modes/components/irc-sidebar.d.ts +16 -0
  7. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  8. package/dist/types/modes/controllers/event-controller.d.ts +1 -1
  9. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -1
  10. package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
  11. package/dist/types/modes/interactive-mode.d.ts +9 -0
  12. package/dist/types/modes/theme/theme.d.ts +3 -1
  13. package/dist/types/modes/types.d.ts +32 -0
  14. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
  15. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +3 -2
  16. package/dist/types/session/agent-session.d.ts +2 -0
  17. package/dist/types/session/internal/managed-session-storage.d.ts +22 -3
  18. package/dist/types/task/index.d.ts +4 -0
  19. package/dist/types/task/output-manager.d.ts +1 -0
  20. package/dist/types/task/receipt.d.ts +2 -0
  21. package/dist/types/tools/index.d.ts +2 -0
  22. package/package.json +8 -8
  23. package/scripts/dogfood-ralplan-review-conflicts.ts +272 -0
  24. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  25. package/scripts/run-acp-conformance.ts +5 -0
  26. package/src/async/job-manager.ts +22 -20
  27. package/src/config/settings-schema.ts +2 -2
  28. package/src/debug/index.ts +42 -17
  29. package/src/defaults/gjc/skills/ralplan/SKILL.md +9 -7
  30. package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -2
  31. package/src/gjc-runtime/launch-worktree.ts +103 -1
  32. package/src/gjc-runtime/ledger-event-renderer.ts +1 -0
  33. package/src/gjc-runtime/ralplan-review-conflicts.ts +472 -0
  34. package/src/gjc-runtime/ralplan-runtime.ts +80 -10
  35. package/src/gjc-runtime/ultragoal-evidence.ts +6 -74
  36. package/src/gjc-runtime/ultragoal-runtime.ts +236 -3
  37. package/src/gjc-runtime/workflow-manifest.generated.json +29 -0
  38. package/src/gjc-runtime/workflow-manifest.ts +17 -2
  39. package/src/internal-urls/docs-index.generated.ts +1 -1
  40. package/src/modes/DESIGN.md +53 -0
  41. package/src/modes/components/irc-sidebar.ts +30 -1
  42. package/src/modes/components/tool-status-header.ts +0 -1
  43. package/src/modes/controllers/command-controller.ts +38 -34
  44. package/src/modes/controllers/event-controller.ts +27 -16
  45. package/src/modes/controllers/extension-ui-controller.ts +186 -34
  46. package/src/modes/controllers/selector-controller.ts +115 -22
  47. package/src/modes/interactive-mode.ts +189 -62
  48. package/src/modes/jobs-observer.ts +1 -1
  49. package/src/modes/theme/theme.ts +11 -9
  50. package/src/modes/types.ts +88 -0
  51. package/src/sdk/broker/lifecycle.ts +18 -13
  52. package/src/sdk/bus/chat-daemon-control.ts +4 -3
  53. package/src/sdk/bus/telegram-daemon-contract.ts +3 -2
  54. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  55. package/src/sdk/session.ts +1 -0
  56. package/src/session/agent-session.ts +22 -1
  57. package/src/session/internal/managed-session-scope.ts +158 -107
  58. package/src/session/internal/managed-session-storage.ts +445 -57
  59. package/src/session/session-storage.ts +29 -44
  60. package/src/task/index.ts +257 -31
  61. package/src/task/output-manager.ts +24 -14
  62. package/src/task/receipt.ts +11 -1
  63. package/src/tools/index.ts +2 -0
@@ -454,7 +454,7 @@ function secureOwnerOnlyFileDescriptor(
454
454
  ): void {
455
455
  if (securityContext && !isValidManagedSecurityContext(securityContext))
456
456
  throw new Error("Invalid managed session security context");
457
- if (process.platform !== "linux" || !securityContext) {
457
+ if (process.platform !== "linux" && process.platform !== "win32") {
458
458
  if (operation === "apply") {
459
459
  const applied = validateNativeSecurityResult(
460
460
  native.applyOwnerOnlyPathSecurity(pathname, "file"),
@@ -471,7 +471,7 @@ function secureOwnerOnlyFileDescriptor(
471
471
  if (!verified.ok) throw new Error(`Owner-only security rejected ${pathname}: ${verified.code}`);
472
472
  return;
473
473
  }
474
- if (!pathIsWithin(securityContext.sessionDir, pathname))
474
+ if (securityContext && !pathIsWithin(securityContext.sessionDir, pathname))
475
475
  throw new Error(`Managed writer escaped its session directory: ${pathname}`);
476
476
  const result = validateNativeSecurityResult(
477
477
  operation === "apply"
@@ -908,6 +908,28 @@ export class FileSessionStorage implements SessionStorage {
908
908
  }
909
909
 
910
910
  const parentIdentity = this.#directoryIdentity(path.dirname(transcriptPath));
911
+ const durablyRecordArtifactPhase = (): void => {
912
+ if (process.platform === "win32") return;
913
+ let descriptor: number | undefined;
914
+ try {
915
+ descriptor = fs.openSync(
916
+ path.dirname(transcriptPath),
917
+ fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW,
918
+ );
919
+ const durableParent = fs.fstatSync(descriptor, { bigint: true });
920
+ if (
921
+ !durableParent.isDirectory() ||
922
+ durableParent.dev !== parentIdentity.dev ||
923
+ durableParent.ino !== parentIdentity.ino
924
+ )
925
+ throw new Error("parent_changed");
926
+ fs.fsyncSync(descriptor);
927
+ } catch (error) {
928
+ throw new SessionDeleteVerificationError("artifacts", "durability_failed", { cause: toError(error) });
929
+ } finally {
930
+ if (descriptor !== undefined) fs.closeSync(descriptor);
931
+ }
932
+ };
911
933
  if (detachedArtifactsPath) {
912
934
  if (
913
935
  !expectedArtifactsIdentity ||
@@ -938,6 +960,7 @@ export class FileSessionStorage implements SessionStorage {
938
960
  "artifacts",
939
961
  "Native artifact removal returned an unauthorized root",
940
962
  );
963
+ durablyRecordArtifactPhase();
941
964
  return {
942
965
  kind: "cleanup_pending",
943
966
  phase: "artifacts",
@@ -978,6 +1001,7 @@ export class FileSessionStorage implements SessionStorage {
978
1001
  "artifacts",
979
1002
  "Authorized artifact removal root remains after restart",
980
1003
  );
1004
+ durablyRecordArtifactPhase();
981
1005
  return { kind: "artifacts_removed", phase: "artifacts", transcriptIdentity };
982
1006
  }
983
1007
 
@@ -1022,27 +1046,7 @@ export class FileSessionStorage implements SessionStorage {
1022
1046
  `Exact artifact detach rejected: ${detach.ok ? "missing_path" : detach.code}`,
1023
1047
  );
1024
1048
  }
1025
- if (!detach.ok && process.platform !== "win32") {
1026
- let descriptor: number | undefined;
1027
- try {
1028
- descriptor = fs.openSync(
1029
- path.dirname(transcriptPath),
1030
- fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW,
1031
- );
1032
- const durableParent = fs.fstatSync(descriptor, { bigint: true });
1033
- if (
1034
- !durableParent.isDirectory() ||
1035
- durableParent.dev !== parentIdentity.dev ||
1036
- durableParent.ino !== parentIdentity.ino
1037
- )
1038
- throw new Error("parent_changed");
1039
- fs.fsyncSync(descriptor);
1040
- } catch (error) {
1041
- throw new SessionDeleteVerificationError("artifacts", "durability_failed", { cause: toError(error) });
1042
- } finally {
1043
- if (descriptor !== undefined) fs.closeSync(descriptor);
1044
- }
1045
- }
1049
+ if (!detach.ok) durablyRecordArtifactPhase();
1046
1050
  if (!detach.ok) {
1047
1051
  return {
1048
1052
  kind: "cleanup_pending",
@@ -1066,6 +1070,7 @@ export class FileSessionStorage implements SessionStorage {
1066
1070
  "artifacts",
1067
1071
  "Native artifact removal returned an unauthorized root",
1068
1072
  );
1073
+ durablyRecordArtifactPhase();
1069
1074
  return {
1070
1075
  kind: "cleanup_pending",
1071
1076
  phase: "artifacts",
@@ -1090,27 +1095,7 @@ export class FileSessionStorage implements SessionStorage {
1090
1095
  }
1091
1096
  }
1092
1097
  if (!artifactsRemoved) {
1093
- if (process.platform !== "win32") {
1094
- let descriptor: number | undefined;
1095
- try {
1096
- descriptor = fs.openSync(
1097
- path.dirname(transcriptPath),
1098
- fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW,
1099
- );
1100
- const durableParent = fs.fstatSync(descriptor, { bigint: true });
1101
- if (
1102
- !durableParent.isDirectory() ||
1103
- durableParent.dev !== parentIdentity.dev ||
1104
- durableParent.ino !== parentIdentity.ino
1105
- )
1106
- throw new Error("parent_changed");
1107
- fs.fsyncSync(descriptor);
1108
- } catch (error) {
1109
- throw new SessionDeleteVerificationError("artifacts", "durability_failed", { cause: toError(error) });
1110
- } finally {
1111
- if (descriptor !== undefined) fs.closeSync(descriptor);
1112
- }
1113
- }
1098
+ durablyRecordArtifactPhase();
1114
1099
  return { kind: "artifacts_removed", phase: "artifacts", transcriptIdentity };
1115
1100
  }
1116
1101
  if (hasDetachedTranscript) {
package/src/task/index.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  * - Progress tracking via JSON events
13
13
  * - Session artifacts for debugging
14
14
  */
15
+ import { randomUUID } from "node:crypto";
15
16
  import * as fs from "node:fs/promises";
16
17
  import * as os from "node:os";
17
18
  import path from "node:path";
@@ -53,6 +54,7 @@ import {
53
54
  resolveTaskRepositoryBinding,
54
55
  } from "../gjc-runtime/repository-binding";
55
56
  import { initializeLocalRoot, type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
57
+ import { ArtifactManager } from "../session/artifacts";
56
58
  import { generateCommitMessage } from "../utils/commit-message-generator";
57
59
  import * as git from "../utils/git";
58
60
  import { discoverAgents, filterVisibleAgents, getAgent } from "./discovery";
@@ -88,6 +90,7 @@ interface TaskResumeDescriptor {
88
90
  params: TaskParams;
89
91
  task: TaskItem & { id: string };
90
92
  sessionFile: string | null;
93
+ durableOutputAllowed?: boolean;
91
94
  forkContextSeed?: ForkContextSeed;
92
95
  agentSource: AgentDefinition["source"];
93
96
  }
@@ -420,6 +423,37 @@ export function resolveForkContextMaxTokens(configured: number, model: Model | u
420
423
  // ═══════════════════════════════════════════════════════════════════════════
421
424
  // Tool Class
422
425
  // ═══════════════════════════════════════════════════════════════════════════
426
+ interface SessionLifetimeArtifactsState {
427
+ dir?: string;
428
+ manager?: ArtifactManager;
429
+ ensurePromise?: Promise<string | null>;
430
+ outputPrefix: string;
431
+ authorized: boolean;
432
+ cleanupRegistered: boolean;
433
+ originalGetArtifactsDir?: ToolSession["getArtifactsDir"];
434
+ originalGetAuthorizedArtifactsDirs?: ToolSession["getAuthorizedArtifactsDirs"];
435
+ originalGetArtifactManager?: ToolSession["getArtifactManager"];
436
+ originalAgentOutputManager?: AgentOutputManager;
437
+ installedGetArtifactsDir?: ToolSession["getArtifactsDir"];
438
+ installedGetAuthorizedArtifactsDirs?: ToolSession["getAuthorizedArtifactsDirs"];
439
+ installedGetArtifactManager?: ToolSession["getArtifactManager"];
440
+ installedAgentOutputManager?: AgentOutputManager;
441
+ }
442
+
443
+ const sessionLifetimeArtifacts = new WeakMap<ToolSession, SessionLifetimeArtifactsState>();
444
+
445
+ function sessionLifetimeArtifactsState(session: ToolSession): SessionLifetimeArtifactsState {
446
+ let state = sessionLifetimeArtifacts.get(session);
447
+ if (!state) {
448
+ state = {
449
+ authorized: false,
450
+ cleanupRegistered: false,
451
+ outputPrefix: `0-Session${randomUUID().replaceAll("-", "")}`,
452
+ };
453
+ sessionLifetimeArtifacts.set(session, state);
454
+ }
455
+ return state;
456
+ }
423
457
 
424
458
  /**
425
459
  * Task tool - Delegate tasks to specialized agents.
@@ -436,6 +470,10 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
436
470
  readonly renderResult = renderResult;
437
471
  readonly #discoveredAgents: AgentDefinition[];
438
472
  readonly #blockedAgent: string | undefined;
473
+ /**
474
+ * Session-lifetime durable artifact state is shared atomically by every
475
+ * TaskTool created for the same parent ToolSession.
476
+ */
439
477
 
440
478
  get parameters(): TaskToolSchemaInstance {
441
479
  const isolationEnabled = this.session.settings.get("task.isolation.mode") !== "none";
@@ -478,6 +516,172 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
478
516
  return this.session.settings.get("task.simple");
479
517
  }
480
518
 
519
+ /**
520
+ * Ensure a session-lifetime artifact directory when the parent has no session file.
521
+ * Returns null only when durable allocation fails (mkdir error) — callers must not
522
+ * advertise agent:// URIs without a successful allocation.
523
+ */
524
+ async #ensureSessionLifetimeArtifacts(): Promise<{ dir: string; manager: ArtifactManager } | null> {
525
+ const state = sessionLifetimeArtifactsState(this.session);
526
+ if (state.authorized && state.dir && state.manager) return { dir: state.dir, manager: state.manager };
527
+
528
+ const sessionArtifactsDir = this.session.getArtifactsDir?.() ?? null;
529
+ if (sessionArtifactsDir) {
530
+ const existingManager = this.session.getArtifactManager?.() ?? null;
531
+ if (existingManager && path.resolve(existingManager.dir) !== path.resolve(sessionArtifactsDir)) return null;
532
+ const manager = existingManager ?? new ArtifactManager(sessionArtifactsDir);
533
+ state.dir = sessionArtifactsDir;
534
+ state.manager = manager;
535
+ if (!(await this.#authorizeSessionLifetimeArtifacts(state, sessionArtifactsDir, manager, false))) return null;
536
+ return { dir: sessionArtifactsDir, manager };
537
+ }
538
+ if (!this.session.registerSessionCleanup) return null;
539
+
540
+ state.ensurePromise ??= this.#allocateSessionLifetimeArtifacts(state);
541
+ const dir = await state.ensurePromise;
542
+ if (!dir || !state.manager) {
543
+ state.ensurePromise = undefined;
544
+ return null;
545
+ }
546
+ return { dir, manager: state.manager };
547
+ }
548
+
549
+ async #allocateSessionLifetimeArtifacts(state: SessionLifetimeArtifactsState): Promise<string | null> {
550
+ let dir: string;
551
+ try {
552
+ dir = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-task-session-"));
553
+ } catch {
554
+ state.dir = undefined;
555
+ state.manager = undefined;
556
+ return null;
557
+ }
558
+ state.dir = dir;
559
+ state.manager = new ArtifactManager(dir);
560
+ if (!(await this.#authorizeSessionLifetimeArtifacts(state, dir, state.manager, true))) return null;
561
+ return dir;
562
+ }
563
+
564
+ /**
565
+ * Expose the durable root on the parent ToolSession so subsequent parent-side
566
+ * tools and same-session descendants can resolve agent:// without registry-wide
567
+ * enumeration (#3302 scoped authorization).
568
+ */
569
+ async #authorizeSessionLifetimeArtifacts(
570
+ state: SessionLifetimeArtifactsState,
571
+ dir: string,
572
+ manager: ArtifactManager,
573
+ owned: boolean,
574
+ ): Promise<boolean> {
575
+ if (state.authorized) return true;
576
+ state.originalGetArtifactsDir = this.session.getArtifactsDir;
577
+ state.originalGetAuthorizedArtifactsDirs = this.session.getAuthorizedArtifactsDirs;
578
+ state.originalGetArtifactManager = this.session.getArtifactManager;
579
+ state.originalAgentOutputManager = this.session.agentOutputManager;
580
+
581
+ state.installedGetArtifactsDir = () => state.originalGetArtifactsDir?.() ?? dir;
582
+ state.installedGetAuthorizedArtifactsDirs = () => {
583
+ const dirs: string[] = [];
584
+ const add = (candidate: string | null | undefined) => {
585
+ if (!candidate) return;
586
+ const normalized = path.resolve(candidate);
587
+ if (!dirs.includes(normalized)) dirs.push(normalized);
588
+ };
589
+ for (const authorized of state.originalGetAuthorizedArtifactsDirs?.() ?? []) add(authorized);
590
+ add(dir);
591
+ return dirs;
592
+ };
593
+ state.installedGetArtifactManager = () => manager;
594
+ if (owned || !this.session.agentOutputManager) {
595
+ state.installedAgentOutputManager = new AgentOutputManager(() => this.session.getArtifactsDir?.() ?? null, {
596
+ parentPrefix: owned ? state.outputPrefix : undefined,
597
+ getAuthorizedArtifactsDirs: () => this.session.getAuthorizedArtifactsDirs?.() ?? [],
598
+ });
599
+ }
600
+
601
+ let cleanupRan = false;
602
+ const cleanup = async () => {
603
+ cleanupRan = true;
604
+ try {
605
+ if (owned) await fs.rm(dir, { recursive: true, force: true });
606
+ } finally {
607
+ if (this.session.getArtifactsDir === state.installedGetArtifactsDir)
608
+ this.session.getArtifactsDir = state.originalGetArtifactsDir;
609
+ if (this.session.getAuthorizedArtifactsDirs === state.installedGetAuthorizedArtifactsDirs)
610
+ this.session.getAuthorizedArtifactsDirs = state.originalGetAuthorizedArtifactsDirs;
611
+ if (this.session.getArtifactManager === state.installedGetArtifactManager)
612
+ this.session.getArtifactManager = state.originalGetArtifactManager;
613
+ if (this.session.agentOutputManager === state.installedAgentOutputManager)
614
+ this.session.agentOutputManager = state.originalAgentOutputManager;
615
+ sessionLifetimeArtifacts.delete(this.session);
616
+ }
617
+ };
618
+ if (owned) {
619
+ const registerCleanup = this.session.registerSessionCleanup;
620
+ if (!registerCleanup) {
621
+ state.dir = undefined;
622
+ state.manager = undefined;
623
+ state.ensurePromise = undefined;
624
+ sessionLifetimeArtifacts.delete(this.session);
625
+ await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
626
+ return false;
627
+ }
628
+ try {
629
+ registerCleanup(cleanup);
630
+ } catch {
631
+ state.dir = undefined;
632
+ state.manager = undefined;
633
+ state.ensurePromise = undefined;
634
+ sessionLifetimeArtifacts.delete(this.session);
635
+ await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
636
+ return false;
637
+ }
638
+ if (cleanupRan) return false;
639
+ state.cleanupRegistered = true;
640
+ }
641
+
642
+ state.authorized = true;
643
+ this.session.getArtifactsDir = state.installedGetArtifactsDir;
644
+ this.session.getAuthorizedArtifactsDirs = state.installedGetAuthorizedArtifactsDirs;
645
+ this.session.getArtifactManager = state.installedGetArtifactManager;
646
+ if (state.installedAgentOutputManager) this.session.agentOutputManager = state.installedAgentOutputManager;
647
+ return true;
648
+ }
649
+
650
+ /**
651
+ * Resolve the effective artifacts directory for this task batch.
652
+ * File-backed sessions use the session artifacts path; in-memory parents use
653
+ * the session-lifetime durable temp root when allocation succeeds.
654
+ */
655
+ async #resolveEffectiveArtifactsDir(): Promise<{
656
+ sessionArtifactsDir: string | null;
657
+ durableArtifactsDir: string | null;
658
+ effectiveArtifactsDir: string | undefined;
659
+ parentArtifactManager: ArtifactManager | undefined;
660
+ }> {
661
+ const sessionFile = this.session.getSessionFile();
662
+ const sessionArtifactsDir = sessionFile ? sessionFile.slice(0, -6) : null;
663
+ if (sessionArtifactsDir) {
664
+ const candidateManager = this.session.getArtifactManager?.() ?? undefined;
665
+ const parentArtifactManager =
666
+ candidateManager && path.resolve(candidateManager.dir) === path.resolve(sessionArtifactsDir)
667
+ ? candidateManager
668
+ : undefined;
669
+ return {
670
+ sessionArtifactsDir,
671
+ durableArtifactsDir: null,
672
+ effectiveArtifactsDir: sessionArtifactsDir,
673
+ parentArtifactManager,
674
+ };
675
+ }
676
+ const durable = await this.#ensureSessionLifetimeArtifacts();
677
+ return {
678
+ sessionArtifactsDir: null,
679
+ durableArtifactsDir: durable?.dir ?? null,
680
+ effectiveArtifactsDir: durable?.dir,
681
+ parentArtifactManager: durable?.manager,
682
+ };
683
+ }
684
+
481
685
  /**
482
686
  * Create a TaskTool instance.
483
687
  * Repository authority is captured from session cwd *before* agent discovery so
@@ -589,6 +793,25 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
589
793
  };
590
794
  }
591
795
 
796
+ // Prefer file-backed session artifacts; otherwise allocate a session-lifetime
797
+ // durable root so detached outputs remain readable for the parent session.
798
+ // Resolve before ID allocation so agentOutputManager can scan the durable root.
799
+ const { effectiveArtifactsDir: batchArtifactsDir, parentArtifactManager: asyncParentArtifactManager } =
800
+ await this.#resolveEffectiveArtifactsDir();
801
+ let externalTaskSessionsDir: string | undefined;
802
+ if (!batchArtifactsDir) {
803
+ // Durable allocation failed: keep child session jsonl under local:// only.
804
+ // Do not advertise agent:// URIs without durable output backing.
805
+ const asyncLocalOptions: LocalProtocolOptions = {
806
+ getArtifactsDir: this.session.getArtifactsDir ?? (() => null),
807
+ isManagedDestination: this.session.isManagedSessionDestination,
808
+ getSessionId: this.session.getSessionId ?? (() => null),
809
+ };
810
+ await initializeLocalRoot(asyncLocalOptions);
811
+ externalTaskSessionsDir = resolveLocalUrlToPath("local://subagents/sessions", asyncLocalOptions);
812
+ await fs.mkdir(externalTaskSessionsDir, { recursive: true, mode: 0o700 });
813
+ }
814
+
592
815
  const outputManager =
593
816
  this.session.agentOutputManager ?? new AgentOutputManager(this.session.getArtifactsDir ?? (() => null));
594
817
  const uniqueIds = await outputManager.allocateBatch(taskItems.map(t => t.id));
@@ -668,6 +891,14 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
668
891
  resumeMessage: message,
669
892
  sessionFiles: new Map([[descriptor.task.id, descriptor.sessionFile]]),
670
893
  suppressRoiReconciliation: true,
894
+ ...(descriptor.durableOutputAllowed === true
895
+ ? {}
896
+ : {
897
+ persistence: {
898
+ effectiveArtifactsDir: undefined,
899
+ parentArtifactManager: undefined,
900
+ },
901
+ }),
671
902
  },
672
903
  );
673
904
  const finalText = result.content.find(part => part.type === "text")?.text ?? "(no output)";
@@ -714,20 +945,6 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
714
945
  });
715
946
  };
716
947
  const frozenForkSeeds = new Map<string, ForkContextSeed>();
717
- const asyncParentArtifactManager = this.session.getArtifactManager?.() ?? undefined;
718
- const parentSessionFileForBatch = this.session.getSessionFile();
719
- const batchArtifactsDir = parentSessionFileForBatch ? parentSessionFileForBatch.slice(0, -6) : null;
720
- let externalTaskSessionsDir: string | undefined;
721
- if (!batchArtifactsDir) {
722
- const asyncLocalOptions: LocalProtocolOptions = {
723
- getArtifactsDir: this.session.getArtifactsDir ?? (() => null),
724
- isManagedDestination: this.session.isManagedSessionDestination,
725
- getSessionId: this.session.getSessionId ?? (() => null),
726
- };
727
- await initializeLocalRoot(asyncLocalOptions);
728
- externalTaskSessionsDir = resolveLocalUrlToPath("local://subagents/sessions", asyncLocalOptions);
729
- await fs.mkdir(externalTaskSessionsDir, { recursive: true, mode: 0o700 });
730
- }
731
948
 
732
949
  for (let i = 0; i < taskItems.length; i++) {
733
950
  const taskItem = taskItems[i];
@@ -793,6 +1010,10 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
793
1010
  {
794
1011
  sessionFiles: new Map([[uniqueId, subtaskSessionFile]]),
795
1012
  suppressRoiReconciliation: true,
1013
+ persistence: {
1014
+ effectiveArtifactsDir: batchArtifactsDir,
1015
+ parentArtifactManager: asyncParentArtifactManager,
1016
+ },
796
1017
  },
797
1018
  );
798
1019
  const finalText = result.content.find(part => part.type === "text")?.text ?? "(no output)";
@@ -902,6 +1123,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
902
1123
  params,
903
1124
  task: { ...taskItem, id: uniqueId },
904
1125
  sessionFile: subtaskSessionFile,
1126
+ durableOutputAllowed: Boolean(batchArtifactsDir),
905
1127
  forkContextSeed: frozenForkSeed,
906
1128
  agentSource: fallbackAgentSource,
907
1129
  } satisfies TaskResumeDescriptor,
@@ -998,6 +1220,10 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
998
1220
  runMode?: "initial" | "resume" | "message";
999
1221
  resumeMessage?: string;
1000
1222
  sessionFiles?: ReadonlyMap<string, string | null>;
1223
+ persistence?: {
1224
+ effectiveArtifactsDir: string | undefined;
1225
+ parentArtifactManager: ArtifactManager | undefined;
1226
+ };
1001
1227
  /**
1002
1228
  * Set for per-child async runs: the spawnPlan is carried for gate
1003
1229
  * consistency, but batch-level ROI reconciliation must not be computed
@@ -1247,11 +1473,12 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1247
1473
 
1248
1474
  const preferredIsolationBackend = parseIsolationMode(isolationMode);
1249
1475
 
1250
- // Derive artifacts directory
1251
- const sessionFile = this.session.getSessionFile();
1252
- const artifactsDir = sessionFile ? sessionFile.slice(0, -6) : null;
1253
- const tempArtifactsDir = artifactsDir ? null : path.join(os.tmpdir(), `gjc-task-${Snowflake.next()}`);
1254
- const effectiveArtifactsDir = artifactsDir || tempArtifactsDir!;
1476
+ // File-backed session artifacts, or a session-lifetime durable temp root for
1477
+ // in-memory parents. Never use a per-task temp dir that is deleted on return —
1478
+ // advertised agent:// URIs must remain readable for the parent session lifetime.
1479
+ const { effectiveArtifactsDir, parentArtifactManager } =
1480
+ executionOverrides?.persistence ?? (await this.#resolveEffectiveArtifactsDir());
1481
+ const hasDurableOutputBacking = Boolean(effectiveArtifactsDir);
1255
1482
 
1256
1483
  // Share the parent session's local:// root with subagents so they read/write the same scratch space
1257
1484
  const localProtocolOptions: LocalProtocolOptions = {
@@ -1260,9 +1487,9 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1260
1487
  getSessionId: this.session.getSessionId ?? (() => null),
1261
1488
  };
1262
1489
 
1263
- // Subagents adopt the parent's ArtifactManager so artifact IDs are unique
1264
- // across the whole tree and outputs land flat in the parent's dir.
1265
- const parentArtifactManager = this.session.getArtifactManager?.() ?? undefined;
1490
+ // Subagents adopt the parent's ArtifactManager (including session-lifetime
1491
+ // durable managers) so artifact IDs are unique and agent:// resolves under
1492
+ // scoped authorization for the whole tree.
1266
1493
 
1267
1494
  // Initialize progress tracking
1268
1495
  const progressMap = new Map<number, AgentProgress>();
@@ -1468,7 +1695,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1468
1695
  thinkingLevel: thinkingLevelOverride,
1469
1696
  outputSchema: effectiveOutputSchema,
1470
1697
  sessionFile: taskSessionFile,
1471
- persistArtifacts: !!artifactsDir,
1698
+ persistArtifacts: hasDurableOutputBacking,
1472
1699
  artifactsDir: effectiveArtifactsDir,
1473
1700
  managedPersistence,
1474
1701
  contextFile: contextFilePath,
@@ -1542,7 +1769,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1542
1769
  thinkingLevel: thinkingLevelOverride,
1543
1770
  outputSchema: effectiveOutputSchema,
1544
1771
  sessionFile: taskSessionFile,
1545
- persistArtifacts: !!artifactsDir,
1772
+ persistArtifacts: hasDurableOutputBacking,
1546
1773
  artifactsDir: effectiveArtifactsDir,
1547
1774
  managedPersistence,
1548
1775
  contextFile: contextFilePath,
@@ -1703,7 +1930,10 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1703
1930
  abortReason: "Cancelled before start",
1704
1931
  };
1705
1932
  });
1706
- if (!artifactsDir) {
1933
+ // Only advertise agent:// when outputs land on durable backing (file-backed
1934
+ // session artifacts or the session-lifetime durable temp root). Ephemeral
1935
+ // or failed allocation must not emit dead URIs (#3471 / #295).
1936
+ if (!hasDurableOutputBacking) {
1707
1937
  for (const result of results) {
1708
1938
  delete result.outputMeta;
1709
1939
  delete result.outputPath;
@@ -1913,12 +2143,8 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1913
2143
  mergeSummary,
1914
2144
  });
1915
2145
 
1916
- // Cleanup temp directory if used
1917
- const shouldCleanupTempArtifacts =
1918
- tempArtifactsDir && (!isIsolated || changesApplied === true || changesApplied === null);
1919
- if (shouldCleanupTempArtifacts) {
1920
- await fs.rm(tempArtifactsDir, { recursive: true, force: true });
1921
- }
2146
+ // Session-lifetime durable dirs must outlive this return so parent +
2147
+ // same-session descendants can resolve agent://. Do not rm them here.
1922
2148
 
1923
2149
  const details: TaskToolDetails = {
1924
2150
  projectAgentsDir,
@@ -29,10 +29,15 @@ export class AgentOutputManager {
29
29
  #initPromise: Promise<void> | undefined;
30
30
  readonly #getArtifactsDir: () => string | null;
31
31
  readonly #parentPrefix: string | undefined;
32
+ readonly #getAuthorizedArtifactsDirs: (() => readonly string[]) | undefined;
32
33
 
33
- constructor(getArtifactsDir: () => string | null, options?: { parentPrefix?: string }) {
34
+ constructor(
35
+ getArtifactsDir: () => string | null,
36
+ options?: { parentPrefix?: string; getAuthorizedArtifactsDirs?: () => readonly string[] },
37
+ ) {
34
38
  this.#getArtifactsDir = getArtifactsDir;
35
39
  this.#parentPrefix = options?.parentPrefix;
40
+ this.#getAuthorizedArtifactsDirs = options?.getAuthorizedArtifactsDirs;
36
41
  }
37
42
 
38
43
  /**
@@ -53,24 +58,29 @@ export class AgentOutputManager {
53
58
  * This ensures we don't overwrite outputs when resuming a session.
54
59
  */
55
60
  async #scanExistingOutputs(): Promise<void> {
56
- const dir = this.#getArtifactsDir();
57
- if (!dir) return;
58
-
59
- let files: string[];
60
- try {
61
- files = await fs.readdir(dir);
62
- } catch {
63
- return; // Directory doesn't exist yet
64
- }
61
+ const dirs: string[] = [];
62
+ const add = (dir: string | null | undefined) => {
63
+ if (!dir || dirs.includes(dir)) return;
64
+ dirs.push(dir);
65
+ };
66
+ add(this.#getArtifactsDir());
67
+ for (const dir of this.#getAuthorizedArtifactsDirs?.() ?? []) add(dir);
68
+ if (dirs.length === 0) return;
65
69
 
66
70
  const pattern = this.#parentPrefix
67
71
  ? new RegExp(`^${escapeRegExp(this.#parentPrefix)}\\.(\\d+)-.*\\.${RESERVED_OUTPUT_EXTENSIONS_PATTERN}$`)
68
72
  : new RegExp(`^(\\d+)-.*\\.${RESERVED_OUTPUT_EXTENSIONS_PATTERN}$`);
69
-
70
73
  let maxId = -1;
71
- for (const file of files) {
72
- const match = file.match(pattern);
73
- if (match) {
74
+ for (const dir of dirs) {
75
+ let files: string[];
76
+ try {
77
+ files = await fs.readdir(dir);
78
+ } catch {
79
+ continue;
80
+ }
81
+ for (const file of files) {
82
+ const match = file.match(pattern);
83
+ if (!match) continue;
74
84
  const id = Number.parseInt(match[1], 10);
75
85
  if (id > maxId) maxId = id;
76
86
  }
@@ -45,7 +45,14 @@ export interface TaskResultReceipt {
45
45
  abortSummary?: string;
46
46
  preview: string;
47
47
  previewTruncated: boolean;
48
- outputRef?: { uri: string; sizeBytes: number; lineCount: number; sha256?: string };
48
+ outputRef?: {
49
+ uri: string;
50
+ sizeBytes: number;
51
+ lineCount: number;
52
+ sha256?: string;
53
+ /** Output remains readable for the parent session lifetime (and same-session descendants). */
54
+ durability?: "session";
55
+ };
49
56
  outputUnavailable?: boolean;
50
57
  review?: {
51
58
  overallCorrectness?: string;
@@ -224,12 +231,15 @@ export function buildTaskRoiSummary(receipts: readonly TaskResultReceipt[]): Tas
224
231
  }
225
232
 
226
233
  export function buildTaskReceipt(raw: SingleResult): TaskResultReceipt {
234
+ // Receipts only include outputRef when production code kept outputMeta after a
235
+ // durable write (file-backed session dir or session-lifetime durable root).
227
236
  const outputRef = raw.outputMeta
228
237
  ? {
229
238
  uri: `agent://${raw.id}`,
230
239
  sizeBytes: raw.outputMeta.byteSize ?? Buffer.byteLength(raw.output, "utf8"),
231
240
  lineCount: raw.outputMeta.lineCount,
232
241
  sha256: raw.outputMeta.sha256,
242
+ durability: "session" as const,
233
243
  }
234
244
  : undefined;
235
245
  const preview = buildSafeSynopsis(raw, outputRef);
@@ -296,6 +296,8 @@ export interface ToolSession {
296
296
  getAuthorizedArtifactsDirs?: () => readonly string[];
297
297
  /** Get the ArtifactManager backing this session (shared across parent + subagents). */
298
298
  getArtifactManager?: () => ArtifactManager | null;
299
+ /** Register teardown work owned by the current logical session. */
300
+ registerSessionCleanup?: (cleanup: () => Promise<void> | void) => () => void;
299
301
  /** Allocate a new artifact path and ID for session-scoped truncated output. */
300
302
  allocateOutputArtifact?: (toolType: string) => Promise<{ id?: string; path?: string }>;
301
303
  /** Get session spawns */