@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
@@ -5,6 +5,11 @@ import * as path from "node:path";
5
5
  import * as native from "@gajae-code/natives";
6
6
 
7
7
  import { isEnoent, pathIsWithin, peekFile, toError } from "@gajae-code/utils";
8
+ import {
9
+ assertManagedDirectoryRoot,
10
+ type ManagedDirectoryRoot,
11
+ validateNativeSecurityResult,
12
+ } from "./internal/managed-session-storage";
8
13
 
9
14
  const utf8Decoder = new TextDecoder("utf-8");
10
15
  function canonicalPathSync(value: string): string {
@@ -89,8 +94,41 @@ export interface SessionStorageWriterOpenOptions {
89
94
  onError?: (err: Error) => void;
90
95
  /** Injectable OS-close dispatcher; defaults to `fs.closeSync`. */
91
96
  closeAdapter?: SessionStorageWriterCloseAdapter;
97
+ /** Opaque authority for default-computed managed destinations only. */
98
+ securityContext?: SessionStorageSecurityContext;
92
99
  }
93
100
 
101
+ /**
102
+ * Immutable authority attached only to a computed managed session destination.
103
+ * A caller-supplied pathname never receives this capability, even when it
104
+ * happens to equal the current default session directory.
105
+ */
106
+ export interface ManagedSessionSecurityContext {
107
+ readonly kind: "managed";
108
+ readonly agentDir: string;
109
+ readonly sessionsRoot: string;
110
+ readonly sessionDir: string;
111
+ readonly rootAuthority: ManagedDirectoryRoot;
112
+ readonly retainedAuthority?: native.RecoveryFsRoot;
113
+ }
114
+
115
+ const managedSecurityContexts = new WeakSet<ManagedSessionSecurityContext>();
116
+
117
+ /** @internal Create the only accepted managed writer authority object. */
118
+ export function createManagedSessionSecurityContext(input: {
119
+ agentDir: string;
120
+ sessionsRoot: string;
121
+ sessionDir: string;
122
+ rootAuthority: ManagedDirectoryRoot;
123
+ retainedAuthority?: native.RecoveryFsRoot;
124
+ }): ManagedSessionSecurityContext {
125
+ const context = Object.freeze({ kind: "managed" as const, ...input });
126
+ managedSecurityContexts.add(context);
127
+ return context;
128
+ }
129
+
130
+ export type SessionStorageSecurityContext = ManagedSessionSecurityContext | undefined;
131
+
94
132
  export interface SessionStorageWriter {
95
133
  writeLine(line: string): Promise<void>;
96
134
  /**
@@ -268,8 +306,6 @@ const defaultCloseAdapter: SessionStorageWriterCloseAdapter = {
268
306
  },
269
307
  };
270
308
 
271
- type NativeSecurity = { ok: true } | { ok: false; code: string };
272
-
273
309
  type NativeExactUnlinkResult = { ok: true; detachedPath?: string } | { ok: false; code: string; detachedPath?: string };
274
310
  type NativeExactUnlink = (
275
311
  path: string,
@@ -309,6 +345,7 @@ type NativeDirectoryTreeEntry = {
309
345
  ino: string;
310
346
  size: string;
311
347
  mtimeNs: string;
348
+ ctimeNs: string;
312
349
  sha256?: string;
313
350
  };
314
351
  export type NativeDirectoryTreeSnapshot = {
@@ -349,11 +386,57 @@ function exactUnlinkFailure(result: NativeExactUnlinkResult): SessionDeleteVerif
349
386
  : "stat";
350
387
  return new SessionDeleteVerificationError(kind, `Exact transcript deletion rejected: ${result.code}`);
351
388
  }
352
- function secureOwnerOnlyFile(pathname: string): void {
353
- const applied = native.applyOwnerOnlyPathSecurity(pathname, "file") as NativeSecurity;
354
- if (!applied.ok) throw new Error(`Owner-only security rejected ${pathname}: ${applied.code}`);
355
- const verified = native.verifyOwnerOnlyPathSecurity(pathname, "file") as NativeSecurity;
356
- if (!verified.ok) throw new Error(`Owner-only security rejected ${pathname}: ${verified.code}`);
389
+
390
+ function isValidManagedSecurityContext(value: SessionStorageSecurityContext): value is ManagedSessionSecurityContext {
391
+ if (
392
+ value?.kind !== "managed" ||
393
+ !Object.isFrozen(value) ||
394
+ !managedSecurityContexts.has(value) ||
395
+ !pathIsWithin(value.agentDir, value.sessionsRoot) ||
396
+ !pathIsWithin(value.sessionsRoot, value.sessionDir)
397
+ ) {
398
+ return false;
399
+ }
400
+ assertManagedDirectoryRoot(value.rootAuthority);
401
+ if (!pathIsWithin(value.rootAuthority.canonicalPath, value.agentDir)) return false;
402
+ return true;
403
+ }
404
+
405
+ function secureOwnerOnlyFileDescriptor(
406
+ pathname: string,
407
+ fd: number,
408
+ operation: "apply" | "verify",
409
+ securityContext: SessionStorageSecurityContext,
410
+ ): void {
411
+ if (securityContext && !isValidManagedSecurityContext(securityContext))
412
+ throw new Error("Invalid managed session security context");
413
+ if (process.platform !== "linux" || !securityContext) {
414
+ if (operation === "apply") {
415
+ const applied = validateNativeSecurityResult(
416
+ native.applyOwnerOnlyPathSecurity(pathname, "file"),
417
+ "apply",
418
+ "file",
419
+ );
420
+ if (!applied.ok) throw new Error(`Owner-only security rejected ${pathname}: ${applied.code}`);
421
+ }
422
+ const verified = validateNativeSecurityResult(
423
+ native.verifyOwnerOnlyPathSecurity(pathname, "file"),
424
+ "verify",
425
+ "file",
426
+ );
427
+ if (!verified.ok) throw new Error(`Owner-only security rejected ${pathname}: ${verified.code}`);
428
+ return;
429
+ }
430
+ if (!pathIsWithin(securityContext.sessionDir, pathname))
431
+ throw new Error(`Managed writer escaped its session directory: ${pathname}`);
432
+ const result = validateNativeSecurityResult(
433
+ operation === "apply"
434
+ ? native.applyOwnerOnlyFdSecurity(pathname, "file", fd)
435
+ : native.verifyOwnerOnlyFdSecurity(pathname, "file", fd),
436
+ operation,
437
+ "file",
438
+ );
439
+ if (!result.ok) throw new Error(`Owner-only security rejected ${pathname}: ${result.code}`);
357
440
  }
358
441
 
359
442
  /** Reject a symlink/junction/reparse component before a storage path is created or opened. */
@@ -383,35 +466,41 @@ const writerRegistry = new FinalizationRegistry<number>(fd => {
383
466
 
384
467
  class FileSessionStorageWriter implements SessionStorageWriter {
385
468
  #fd: number;
469
+ #path: string;
470
+
386
471
  #closeState: SessionStorageWriterCloseState = "open";
387
472
  #closeError: Error | undefined;
388
473
  #error: Error | undefined;
389
474
  #onError: ((err: Error) => void) | undefined;
390
475
  #closeAdapter: SessionStorageWriterCloseAdapter;
476
+ #securityContext: SessionStorageSecurityContext;
391
477
 
392
478
  constructor(fpath: string, options?: SessionStorageWriterOpenOptions) {
393
479
  this.#onError = options?.onError;
394
480
  this.#closeAdapter = options?.closeAdapter ?? defaultCloseAdapter;
481
+ this.#securityContext = options?.securityContext;
395
482
  const flags = options?.flags ?? "a";
396
483
  const dir = path.dirname(fpath);
397
484
  assertNoReparsePath(dir);
398
485
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
399
486
  assertNoReparsePath(dir);
400
487
  assertNoReparsePath(fpath);
401
- // The creation mode prevents a POSIX exposure before the native verifier runs.
488
+ // Never truncate before the descriptor and its terminal pathname have passed native security.
402
489
  const openFlags =
403
490
  (flags === "w"
404
- ? fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC
491
+ ? fs.constants.O_WRONLY | fs.constants.O_CREAT
405
492
  : fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND) | (fs.constants.O_NOFOLLOW ?? 0);
406
493
  const fd = fs.openSync(fpath, openFlags, 0o600);
407
494
  try {
408
- // Do not rely on directory inheritance: secure and verify every file independently.
409
- secureOwnerOnlyFile(fpath);
495
+ secureOwnerOnlyFileDescriptor(fpath, fd, "apply", this.#securityContext);
496
+ if (flags === "w") fs.ftruncateSync(fd, 0);
410
497
  } catch (error) {
411
498
  fs.closeSync(fd);
412
499
  throw error;
413
500
  }
414
501
  this.#fd = fd;
502
+ this.#path = fpath;
503
+
415
504
  writerRegistry.register(this, this.#fd, this);
416
505
  }
417
506
 
@@ -469,6 +558,7 @@ class FileSessionStorageWriter implements SessionStorageWriter {
469
558
  if (this.#error) throw this.#error;
470
559
  try {
471
560
  fs.fsyncSync(this.#fd);
561
+ secureOwnerOnlyFileDescriptor(this.#path, this.#fd, "verify", this.#securityContext);
472
562
  } catch (err) {
473
563
  throw this.#recordError(err);
474
564
  }
@@ -481,6 +571,14 @@ class FileSessionStorageWriter implements SessionStorageWriter {
481
571
  // for this numeric fd again; surface the stored non-quiescent error.
482
572
  if (this.#closeState === "close_unknown") throw this.#closeError!;
483
573
  // State is "open" or "close_failed_retryable": a close may be dispatched.
574
+ try {
575
+ secureOwnerOnlyFileDescriptor(this.#path, this.#fd, "verify", this.#securityContext);
576
+ } catch (err) {
577
+ // Verification happens before the OS-close dispatch, so descriptor ownership remains proven.
578
+ this.#closeState = "close_failed_retryable";
579
+ this.#closeError = toError(err);
580
+ throw this.#closeError;
581
+ }
484
582
  try {
485
583
  this.#closeAdapter.close(this.#fd);
486
584
  } catch (err) {
@@ -54,6 +54,10 @@ export interface OutputSummary {
54
54
  }
55
55
 
56
56
  export interface OutputSinkOptions {
57
+ /**
58
+ * Deprecated managed artifact pathname. Bare paths are deliberately ignored:
59
+ * streaming output must be terminally published through ArtifactManager.
60
+ */
57
61
  artifactPath?: string;
58
62
  artifactId?: string;
59
63
  /** Tail buffer budget (bytes). Default DEFAULT_MAX_BYTES. */
@@ -736,6 +740,8 @@ export class OutputSink {
736
740
  artifactMaxBytes = DEFAULT_ARTIFACT_MAX_BYTES,
737
741
  coalesceSanitize = process.env.PI_OUTPUT_SANITIZE_COALESCE === "1",
738
742
  } = options ?? {};
743
+ // Managed callers omit artifactPath at the allocation boundary; explicit callers
744
+ // retain the existing pathname-backed streaming contract.
739
745
  this.#artifactPath = artifactPath;
740
746
  this.#artifactId = artifactId;
741
747
  this.#spillThreshold = spillThreshold;
@@ -107,6 +107,26 @@ export class YieldQueue {
107
107
  this.#idleFlushPending = false;
108
108
  }
109
109
 
110
+ /** Drop only the queued entries of a single kind, leaving other kinds intact. */
111
+ clearKind(kind: string): void {
112
+ this.#entries.delete(kind);
113
+ }
114
+
115
+ /**
116
+ * Re-schedule an idle flush if work remains and the session is idle. Used after
117
+ * a transition (e.g. handoff) releases a delivery fence so entries queued while
118
+ * fenced are not stranded until an unrelated enqueue or agent yield.
119
+ */
120
+ rearmIdle(): void {
121
+ if (this.#options.isStreaming()) return;
122
+ for (const entries of this.#entries.values()) {
123
+ if (entries.length > 0) {
124
+ this.#scheduleIdleFlush();
125
+ return;
126
+ }
127
+ }
128
+ }
129
+
110
130
  #scheduleIdleFlush(): void {
111
131
  if (this.#idleFlushPending) return;
112
132
  this.#idleFlushPending = true;
@@ -1435,6 +1435,42 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1435
1435
  await runtime.ctx.handleCompactCommand(customInstructions);
1436
1436
  },
1437
1437
  },
1438
+ {
1439
+ name: "handoff",
1440
+ priority: 71,
1441
+ description: "Generate a handoff and continue in a new session",
1442
+ acpDescription: "Generate a handoff document and start a new session",
1443
+ inlineHint: "[focus instructions]",
1444
+ acpInputHint: "[focus instructions]",
1445
+ allowArgs: true,
1446
+ handle: async (command, runtime) => {
1447
+ let result: Awaited<ReturnType<typeof runtime.session.handoff>>;
1448
+ try {
1449
+ result = await runtime.session.handoff(command.args || undefined);
1450
+ } catch (err) {
1451
+ // Handoff precondition failures (nothing to hand off, streaming),
1452
+ // cancellation, and provider errors propagate as plain Errors; the
1453
+ // switch is non-destructive so the current session is unchanged.
1454
+ return usage(`Handoff failed: ${errorMessage(err)}; current session is unchanged.`, runtime);
1455
+ }
1456
+ if (!result) {
1457
+ return usage(
1458
+ "Handoff not created (cancelled or nothing to hand off); current session is unchanged.",
1459
+ runtime,
1460
+ );
1461
+ }
1462
+ await runtime.output(
1463
+ result.savedPath
1464
+ ? `Handoff created; new session started. Handoff document saved to: ${result.savedPath}`
1465
+ : "Handoff created; new session started with handoff context.",
1466
+ );
1467
+ return commandConsumed();
1468
+ },
1469
+ handleTui: async (command, runtime) => {
1470
+ runtime.ctx.editor.setText("");
1471
+ await runtime.ctx.handleHandoffCommand(command.args || undefined);
1472
+ },
1473
+ },
1438
1474
  {
1439
1475
  name: "contribute-pr",
1440
1476
  aliases: ["contribution-prep"],
@@ -5,7 +5,6 @@
5
5
  */
6
6
 
7
7
  import { createHash } from "node:crypto";
8
- import * as fs from "node:fs/promises";
9
8
  import path from "node:path";
10
9
  import type {
11
10
  AgentEvent,
@@ -50,6 +49,7 @@ import { ToolAbortError } from "../tools/tool-errors";
50
49
  import type { EventBus } from "../utils/event-bus";
51
50
  import { buildNamedToolChoiceResult } from "../utils/tool-choice";
52
51
  import type { WorkspaceTree } from "../workspace-tree";
52
+ import { validateAllocatedTaskId } from "./id";
53
53
  import { subprocessToolRegistry } from "./subprocess-tool-registry";
54
54
  import { persistTaskTokenLog, taskTokenLogFromUsage } from "./token-log";
55
55
  import {
@@ -227,6 +227,7 @@ export interface ExecutorOptions {
227
227
  * artifacts directory (no per-subagent subdir).
228
228
  */
229
229
  parentArtifactManager?: ArtifactManager;
230
+ managedPersistence?: ManagedTaskPersistence;
230
231
  parentHindsightSessionState?: HindsightSessionState;
231
232
  /**
232
233
  * Parent agent's OpenTelemetry configuration. When defined, the subagent's
@@ -242,6 +243,45 @@ export interface ExecutorOptions {
242
243
  forkContextSeed?: ForkContextSeed;
243
244
  }
244
245
 
246
+ export class ManagedTaskPersistence {
247
+ readonly #artifacts: ArtifactManager;
248
+ readonly #taskId: string;
249
+
250
+ constructor(artifacts: ArtifactManager, taskId: string) {
251
+ this.#artifacts = artifacts;
252
+ this.#taskId = validateAllocatedTaskId(taskId);
253
+ if (!artifacts.getManagedSubtreeRootAuthority())
254
+ throw new Error("Managed task persistence authority is unavailable");
255
+ }
256
+
257
+ async openSession(): Promise<SessionManager> {
258
+ const store = this.#artifacts.getManagedStore();
259
+ if (!store) throw new Error("Managed task persistence authority is unavailable");
260
+ this.#artifacts.assertManagedBinding();
261
+ const sessionFile = path.join(this.#artifacts.dir, `${this.#taskId}.jsonl`);
262
+ const session = await SessionManager.openNestedManaged(
263
+ sessionFile,
264
+ SessionManager.nestedManagedDestination(store, this.#artifacts.dir),
265
+ store,
266
+ );
267
+ this.#artifacts.assertManagedBinding();
268
+ return session;
269
+ }
270
+
271
+ async publishOutput(rawOutput: string, metadata: Uint8Array): Promise<void> {
272
+ await this.#artifacts.publishManagedOutputGeneration(
273
+ `${this.#taskId}.md.selector.json`,
274
+ `${this.#taskId}.md`,
275
+ Buffer.from(rawOutput, "utf8"),
276
+ metadata,
277
+ );
278
+ }
279
+ }
280
+
281
+ export function createManagedTaskPersistence(artifacts: ArtifactManager, taskId: string): ManagedTaskPersistence {
282
+ return new ManagedTaskPersistence(artifacts, taskId);
283
+ }
284
+
245
285
  export function renderSubagentUserPrompt(assignment: string, independentMode: boolean): string {
246
286
  return prompt.render(subagentUserPromptTemplate, { assignment: assignment.trim(), independentMode });
247
287
  }
@@ -888,7 +928,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
888
928
  task,
889
929
  assignment,
890
930
  progress: { ...progress },
891
- sessionFile: subtaskSessionFile,
931
+ sessionFile: null,
892
932
  });
893
933
  }
894
934
  lastProgressEmitMs = Date.now();
@@ -1354,9 +1394,13 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1354
1394
  : (thinkingLevel ?? resolvedThinkingLevel);
1355
1395
  effectiveThinkingLevelForWarning = effectiveThinkingLevel;
1356
1396
 
1357
- const sessionManager = sessionFile
1358
- ? await awaitAbortable(SessionManager.open(sessionFile))
1359
- : SessionManager.inMemory(worktree ?? cwd);
1397
+ const sessionManager = options.managedPersistence
1398
+ ? await awaitAbortable(options.managedPersistence.openSession())
1399
+ : sessionFile
1400
+ ? await awaitAbortable(
1401
+ SessionManager.open(sessionFile, SessionManager.explicitDestination(path.dirname(sessionFile))),
1402
+ )
1403
+ : SessionManager.inMemory(worktree ?? cwd);
1360
1404
  if (options.parentArtifactManager) {
1361
1405
  sessionManager.adoptArtifactManager(options.parentArtifactManager);
1362
1406
  }
@@ -1556,7 +1600,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1556
1600
  agentSource: agent.source,
1557
1601
  description: options.description,
1558
1602
  status: "started",
1559
- sessionFile: subtaskSessionFile,
1603
+ sessionFile: null,
1560
1604
  index,
1561
1605
  });
1562
1606
  }
@@ -1915,37 +1959,38 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1915
1959
  // Write output artifact (input and jsonl already written in real-time)
1916
1960
  // Compute output metadata for agent:// URL integration
1917
1961
  let outputMeta: { lineCount: number; charCount: number; byteSize?: number; sha256?: string } | undefined;
1918
- let outputPath: string | undefined;
1919
- if (options.artifactsDir) {
1920
- const candidateOutputPath = path.join(options.artifactsDir, `${id}.md`);
1962
+ // Never overwrite the artifact with empty output: a failed/no-op resume leg
1963
+ // carries no new information and must preserve the previous retained output.
1964
+ if (options.artifactsDir && rawOutput.length > 0) {
1965
+ const managedPersistence = options.managedPersistence;
1921
1966
  try {
1922
- await Bun.write(candidateOutputPath, rawOutput);
1923
1967
  const byteSize = Buffer.byteLength(rawOutput, "utf8");
1924
1968
  const lineCount = rawOutput.split("\n").length;
1925
1969
  const sha256 = createHash("sha256").update(rawOutput).digest("hex");
1926
1970
  const createdAt = new Date().toISOString();
1927
- await Bun.write(
1928
- `${candidateOutputPath}.meta.json`,
1971
+ if (!managedPersistence) await Bun.write(path.join(options.artifactsDir, `${id}.md`), rawOutput);
1972
+ const metadataBytes = Buffer.from(
1929
1973
  JSON.stringify({ id, kind: "agent-output", sizeBytes: byteSize, lineCount, sha256, createdAt }, null, 2),
1974
+ "utf8",
1930
1975
  );
1931
- outputPath = candidateOutputPath;
1976
+ if (managedPersistence) await managedPersistence.publishOutput(rawOutput, metadataBytes);
1977
+ else await Bun.write(path.join(options.artifactsDir, `${id}.md.meta.json`), metadataBytes);
1932
1978
  outputMeta = {
1933
1979
  lineCount,
1934
1980
  charCount: rawOutput.length,
1935
1981
  byteSize,
1936
1982
  sha256,
1937
1983
  };
1938
- } catch {
1939
- // Output or metadata write failed: never advertise an unverifiable
1940
- // artifact. Best-effort remove any orphaned `.md`/sidecar so a later
1941
- // agent:// read cannot serve unverified content. Non-fatal.
1942
- outputPath = undefined;
1943
- outputMeta = undefined;
1944
- try {
1945
- await fs.rm(candidateOutputPath, { force: true });
1946
- await fs.rm(`${candidateOutputPath}.meta.json`, { force: true });
1947
- } catch {
1948
- // best-effort cleanup; ignore
1984
+ } catch (error) {
1985
+ if (!managedPersistence) {
1986
+ // A missing sidecar keeps a partial immutable output invisible to agent://.
1987
+ outputMeta = undefined;
1988
+ } else {
1989
+ const message = error instanceof Error ? error.message : String(error);
1990
+ stderr = `${stderr}${stderr ? "\n" : ""}Managed output publication failed: ${message}`;
1991
+ exitCode = 1;
1992
+ paused = false;
1993
+ progress.retryFailure = { attempt: 0, errorMessage: `Managed output publication failed: ${message}` };
1949
1994
  }
1950
1995
  }
1951
1996
  }
@@ -1978,7 +2023,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1978
2023
  agentSource: agent.source,
1979
2024
  description: options.description,
1980
2025
  status: progress.status as "completed" | "failed" | "aborted" | "paused",
1981
- sessionFile: subtaskSessionFile,
2026
+ sessionFile: null,
1982
2027
  index,
1983
2028
  });
1984
2029
  }
@@ -2008,7 +2053,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2008
2053
  paused,
2009
2054
  usage: hasUsage ? accumulatedUsage : undefined,
2010
2055
  usageCostBreakdownComplete: hasUsage && usageCostBreakdownComplete ? true : undefined,
2011
- outputPath,
2056
+ outputPath: undefined,
2012
2057
  extractedToolData: progress.extractedToolData,
2013
2058
  retryFailure: progress.retryFailure,
2014
2059
  outputMeta,
package/src/task/index.ts CHANGED
@@ -43,11 +43,11 @@ import {
43
43
 
44
44
  // Import review tools for side effects (registers subagent tool handlers)
45
45
  import "../tools/review";
46
- import type { LocalProtocolOptions } from "../internal-urls";
46
+ import { initializeLocalRoot, type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
47
47
  import { generateCommitMessage } from "../utils/commit-message-generator";
48
48
  import * as git from "../utils/git";
49
49
  import { discoverAgents, filterVisibleAgents, getAgent } from "./discovery";
50
- import { renderSubagentUserPrompt, runSubprocess } from "./executor";
50
+ import { createManagedTaskPersistence, renderSubagentUserPrompt, runSubprocess } from "./executor";
51
51
  import { adviseForkContextMode } from "./fork-context-advisory";
52
52
  import { FORK_CONTEXT_TOKEN_BUDGET_BY_MODE } from "./fork-context-budget";
53
53
  import { getTaskIdValidationError, validateAllocatedTaskId } from "./id";
@@ -581,7 +581,15 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
581
581
  );
582
582
  const finalText = result.content.find(part => part.type === "text")?.text ?? "(no output)";
583
583
  const singleResult = result.details?.results[0];
584
- return singleResult?.paused ? { kind: "paused" } : finalText;
584
+ if (singleResult?.paused) return { kind: "paused" };
585
+ // A resumed subprocess that aborted or exited non-zero is a failed
586
+ // resume, not a completed one. Throw the rendered failure summary
587
+ // (finalText) so the leg is reported failed instead of being
588
+ // returned as successful continuation.
589
+ if (singleResult && ((singleResult.aborted ?? false) || singleResult.exitCode !== 0)) {
590
+ throw new Error(finalText);
591
+ }
592
+ return finalText;
585
593
  },
586
594
  {
587
595
  id: `${descriptor.task.id}-resume-${Snowflake.next()}`,
@@ -623,8 +631,20 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
623
631
  });
624
632
  };
625
633
  const frozenForkSeeds = new Map<string, ForkContextSeed>();
634
+ const asyncParentArtifactManager = this.session.getArtifactManager?.() ?? undefined;
626
635
  const parentSessionFileForBatch = this.session.getSessionFile();
627
636
  const batchArtifactsDir = parentSessionFileForBatch ? parentSessionFileForBatch.slice(0, -6) : null;
637
+ let externalTaskSessionsDir: string | undefined;
638
+ if (!batchArtifactsDir) {
639
+ const asyncLocalOptions: LocalProtocolOptions = {
640
+ getArtifactsDir: this.session.getArtifactsDir ?? (() => null),
641
+ isManagedDestination: this.session.isManagedSessionDestination,
642
+ getSessionId: this.session.getSessionId ?? (() => null),
643
+ };
644
+ await initializeLocalRoot(asyncLocalOptions);
645
+ externalTaskSessionsDir = resolveLocalUrlToPath("local://subagents/sessions", asyncLocalOptions);
646
+ await fs.mkdir(externalTaskSessionsDir, { recursive: true, mode: 0o700 });
647
+ }
628
648
 
629
649
  for (let i = 0; i < taskItems.length; i++) {
630
650
  const taskItem = taskItems[i];
@@ -652,7 +672,12 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
652
672
  const singleParams: TaskParams = { ...params, tasks: [taskItem] };
653
673
  const label = uniqueId;
654
674
  try {
655
- const subtaskSessionFile = batchArtifactsDir ? path.join(batchArtifactsDir, `${uniqueId}.jsonl`) : null;
675
+ const managedPersistence = asyncParentArtifactManager?.getManagedStore()
676
+ ? createManagedTaskPersistence(asyncParentArtifactManager, uniqueId)
677
+ : undefined;
678
+ const subtaskSessionFile = managedPersistence
679
+ ? null
680
+ : path.join(batchArtifactsDir ?? externalTaskSessionsDir!, `${uniqueId}.jsonl`);
656
681
  const jobId = manager.register(
657
682
  "task",
658
683
  label,
@@ -810,8 +835,8 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
810
835
  currentJobId: jobId,
811
836
  historicalJobIds: [],
812
837
  status: manager.getJob(jobId)?.status ?? "running",
813
- sessionFile: subtaskSessionFile,
814
- resumable: !!batchArtifactsDir,
838
+ sessionFile: null,
839
+ resumable: true,
815
840
  });
816
841
  }
817
842
  } catch (error) {
@@ -1132,6 +1157,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1132
1157
  // Share the parent session's local:// root with subagents so they read/write the same scratch space
1133
1158
  const localProtocolOptions: LocalProtocolOptions = {
1134
1159
  getArtifactsDir: this.session.getArtifactsDir ?? (() => null),
1160
+ isManagedDestination: this.session.isManagedSessionDestination,
1135
1161
  getSessionId: this.session.getSessionId ?? (() => null),
1136
1162
  };
1137
1163
 
@@ -1141,6 +1167,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1141
1167
 
1142
1168
  // Initialize progress tracking
1143
1169
  const progressMap = new Map<number, AgentProgress>();
1170
+ const isolatedPatchBytes = new Map<string, Buffer>();
1144
1171
 
1145
1172
  // Update callback
1146
1173
  const emitProgress = () => {
@@ -1201,14 +1228,16 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1201
1228
  };
1202
1229
  }
1203
1230
 
1204
- // Write parent conversation context for subagents. When IRC is available,
1205
- // subagents should ask live peers instead of reading a stale markdown dump.
1206
- await fs.mkdir(effectiveArtifactsDir, { recursive: true });
1231
+ // Place fork-context handoff material in the session-scoped external local
1232
+ // root. Subagent prompts may expose this path to subprocesses, so it must
1233
+ // never carry managed transcript/artifact authority.
1207
1234
  const shouldWriteConversationContext = !hasAvailableIrcTool(this.session);
1208
1235
  const compactContext = shouldWriteConversationContext ? this.session.getCompactContext?.() : undefined;
1209
1236
  let contextFilePath: string | undefined;
1210
1237
  if (compactContext) {
1211
- contextFilePath = path.join(effectiveArtifactsDir, "context.md");
1238
+ await initializeLocalRoot(localProtocolOptions);
1239
+ contextFilePath = resolveLocalUrlToPath("local://subagents/context.md", localProtocolOptions);
1240
+ await fs.mkdir(path.dirname(contextFilePath), { recursive: true, mode: 0o700 });
1212
1241
  await Bun.write(contextFilePath, compactContext);
1213
1242
  }
1214
1243
 
@@ -1307,7 +1336,12 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1307
1336
  recommendedMode: advisory.recommendedMode,
1308
1337
  reasons: advisory.reasons,
1309
1338
  };
1310
- const taskSessionFile = overrides?.sessionFile ?? executionOverrides?.sessionFiles?.get(task.id) ?? null;
1339
+ const managedPersistence = parentArtifactManager?.getManagedStore()
1340
+ ? createManagedTaskPersistence(parentArtifactManager, task.id)
1341
+ : undefined;
1342
+ const taskSessionFile = managedPersistence
1343
+ ? null
1344
+ : (overrides?.sessionFile ?? executionOverrides?.sessionFiles?.get(task.id) ?? null);
1311
1345
  if (!isIsolated) {
1312
1346
  const result = await runSubprocess({
1313
1347
  cwd: this.session.cwd,
@@ -1330,6 +1364,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1330
1364
  sessionFile: taskSessionFile,
1331
1365
  persistArtifacts: !!artifactsDir,
1332
1366
  artifactsDir: effectiveArtifactsDir,
1367
+ managedPersistence,
1333
1368
  contextFile: contextFilePath,
1334
1369
  ircAvailable: hasAvailableIrcTool(this.session),
1335
1370
  enableLsp: subagentLspEnabled,
@@ -1394,6 +1429,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1394
1429
  sessionFile: taskSessionFile,
1395
1430
  persistArtifacts: !!artifactsDir,
1396
1431
  artifactsDir: effectiveArtifactsDir,
1432
+ managedPersistence,
1397
1433
  contextFile: contextFilePath,
1398
1434
  ircAvailable: hasAvailableIrcTool(this.session),
1399
1435
  enableLsp: subagentLspEnabled,
@@ -1465,9 +1501,15 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1465
1501
  if (resultWithForkContext.exitCode === 0) {
1466
1502
  try {
1467
1503
  const delta = await captureDeltaPatch(isolationDir, taskBaseline);
1504
+ await initializeLocalRoot(localProtocolOptions);
1468
1505
  const artifactId = validateAllocatedTaskId(task.id);
1469
- const patchPath = path.join(effectiveArtifactsDir, `${artifactId}.patch`);
1506
+ const patchPath = resolveLocalUrlToPath(
1507
+ `local://subagents/${artifactId}.patch`,
1508
+ localProtocolOptions,
1509
+ );
1510
+ await fs.mkdir(path.dirname(patchPath), { recursive: true, mode: 0o700 });
1470
1511
  await Bun.write(patchPath, delta.rootPatch);
1512
+ isolatedPatchBytes.set(patchPath, Buffer.from(delta.rootPatch, "utf8"));
1471
1513
  const producedChanges = Boolean(delta.rootPatch.trim() || delta.nestedPatches.length);
1472
1514
  return {
1473
1515
  ...resultWithForkContext,
@@ -1622,19 +1664,20 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1622
1664
  changesApplied = false;
1623
1665
  hadAnyChanges = false;
1624
1666
  } else {
1625
- const patchStats = await Promise.all(
1626
- patchesInOrder.map(async patchPath => ({
1627
- patchPath,
1628
- size: (await fs.stat(patchPath)).size,
1629
- })),
1630
- );
1667
+ const patchStats = patchesInOrder.map(patchPath => ({
1668
+ patchPath,
1669
+ size: isolatedPatchBytes.get(patchPath)?.byteLength ?? -1,
1670
+ }));
1671
+ if (patchStats.some(patch => patch.size < 0)) {
1672
+ throw new Error("Captured isolated patch bytes are unavailable");
1673
+ }
1631
1674
  const nonEmptyPatches = patchStats.filter(patch => patch.size > 0).map(patch => patch.patchPath);
1632
1675
  if (nonEmptyPatches.length === 0) {
1633
1676
  changesApplied = true;
1634
1677
  hadAnyChanges = false;
1635
1678
  } else {
1636
- const patchTexts = await Promise.all(
1637
- nonEmptyPatches.map(async patchPath => Bun.file(patchPath).text()),
1679
+ const patchTexts = nonEmptyPatches.map(patchPath =>
1680
+ isolatedPatchBytes.get(patchPath)!.toString("utf8"),
1638
1681
  );
1639
1682
  const combinedPatch = patchTexts
1640
1683
  .map(text => (text.endsWith("\n") ? text : `${text}\n`))