pi-better-harness 0.37.1 → 0.38.0

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.
@@ -12,7 +12,7 @@ Use `pi-better-subagents` when you want Pi to launch independent agent work with
12
12
 
13
13
  ## Core Features
14
14
  - Non-blocking subagent launches, with an optional role and named-agent catalog (`docs/agent-catalog.md`, `docs/agent-catalog-lifecycle.md`).
15
- - Completed-subagent continuation: `subagent_resume` continues a saved single or batch conversation under a new attempt id. Retention is optional cleanup protection: `retain_session` pins artifacts, and `subagent_release` removes that protection without invalidating history that still exists. See [continuing a completed subagent](docs/usage.md#continuing-a-completed-subagent).
15
+ - Continue a saved conversation with `subagent_spawn({ session_id, prompt })`, or use `session_name` for a unique name in the current folder. Sessions persist by default. Lists show session IDs and search locally; `all:true` searches globally. Batch jobs use the same selectors. See [continuing a saved conversation](docs/usage.md#continuing-a-saved-conversation).
16
16
  - Typed foreground `subagent_spawn_batch` launch receipts for native Pi codemode (Pi 0.99.1+), preserving direct tool calls, per-run callbacks, and batch capacity/catalog guarantees. See [codemode batch launches](docs/usage.md#codemode-batch-launches).
17
17
  - Opt-in OS task sandboxing on macOS and Linux.
18
18
  - Explicit tool allowlists for child sessions.
@@ -25,7 +25,7 @@ export const batchLaunchOutputSchema = {
25
25
  type: "array",
26
26
  items: {
27
27
  type: "object",
28
- properties: { job: { type: "integer", minimum: 1 }, name: { type: "string" }, id: { type: "string" }, modelNote: { type: "string" } },
28
+ properties: { job: { type: "integer", minimum: 1 }, name: { type: "string" }, id: { type: "string" }, sessionId: { type: "string" }, continuedFrom: { type: "string" }, modelNote: { type: "string" } },
29
29
  required: ["job", "name", "id"],
30
30
  additionalProperties: false,
31
31
  },
@@ -94,6 +94,10 @@ export function mergeJobOptions(shared, job) {
94
94
  git_clone_workspace: job.git_clone_workspace ?? shared?.git_clone_workspace,
95
95
  approve: job.approve ?? shared?.approve,
96
96
  allow_nested: job.allow_nested ?? shared?.allow_nested,
97
+ // Native session id selected for this JOB only. A shared value would make
98
+ // every job contend for the same conversation writer, so it is not inherited.
99
+ session_id: typeof job.session_id === "string" ? job.session_id : undefined,
100
+ session_name: typeof job.session_name === "string" ? job.session_name : undefined,
97
101
  // Harness timing; an explicit null means "not set", so it falls through to shared.
98
102
  deadline_minutes: job.deadline_minutes ?? shared?.deadline_minutes,
99
103
  grace_minutes: job.grace_minutes ?? shared?.grace_minutes,
@@ -149,6 +153,11 @@ export function validateBatchPlan({ shared, jobs, onCapacity, config }) {
149
153
  if (typeof job.prompt !== "string" || job.prompt.trim() === "") {
150
154
  throw new Error(`job ${i + 1} is missing a non-empty prompt.`);
151
155
  }
156
+ if (job.session_id !== undefined && job.session_name !== undefined) throw new Error(`job ${i + 1}: pass either session_id or session_name, not both.`);
157
+ if (job.session_name !== undefined && (typeof job.session_name !== "string" || job.session_name.trim() === "")) throw new Error(`job ${i + 1} session_name must be a non-empty name.`);
158
+ if (job.session_id !== undefined && (typeof job.session_id !== "string" || job.session_id.trim() === "")) {
159
+ throw new Error(`job ${i + 1} session_id must be a non-empty native session id.`);
160
+ }
152
161
  if (seenPrompts.has(job.prompt)) {
153
162
  // Duplicate prompts are allowed, but a batch with every prompt
154
163
  // identical is a common mistake; warn via the error path only when
@@ -273,8 +282,8 @@ export function formatBatchLaunchResponse({ batchId, batchName, launched, skippe
273
282
  lines.push(
274
283
  `Batch ${label} launched ${launched.length} subagent(s):`,
275
284
  );
276
- for (const { name, id, modelNote } of launched) {
277
- lines.push(`• ${name} → ${id}${modelNote ? ` · ${modelNote}` : ""}`);
285
+ for (const { name, id, sessionId, modelNote } of launched) {
286
+ lines.push(`• ${name} → ${id}${sessionId && sessionId !== id ? ` (session ${sessionId})` : ""}${modelNote ? ` · ${modelNote}` : ""}`);
278
287
  }
279
288
  }
280
289
 
@@ -222,12 +222,16 @@ export async function clarifyCatalogRequest(
222
222
  const normalized = jobs.map((job, index) => ({
223
223
  job: { ...job, catalogJobIndex: index },
224
224
  index,
225
- agent: agentIdOf(job),
226
- roles: roleIdsOf(job),
225
+ continuation: job.session_id !== undefined || job.session_name !== undefined,
226
+ agent: job.session_id !== undefined || job.session_name !== undefined ? undefined : agentIdOf(job),
227
+ roles: job.session_id !== undefined || job.session_name !== undefined ? [] : roleIdsOf(job),
227
228
  }));
228
229
  const clean: CatalogJobFields[] = [];
229
230
  const pending: { job: CatalogJobFields; index: number; agent?: string; roles: string[] }[] = [];
230
231
  for (const item of normalized) {
232
+ // Saved conversations keep their launch recipe. Repeated selectors are
233
+ // checked against that recipe at admission, never clarified or expanded.
234
+ if (item.continuation) { clean.push(item.job); continue; }
231
235
  if (item.roles.length > 1 || (item.agent && item.roles.length > 0)) pending.push(item);
232
236
  else clean.push(withSelector(item.job, { agent: item.agent, role: item.roles[0] }));
233
237
  }
@@ -11,6 +11,7 @@ import { join, relative, sep } from "node:path";
11
11
  import {
12
12
  baseDir,
13
13
  listMetas,
14
+ observeArchivePublication,
14
15
  ownedByThisParent,
15
16
  orphanedTaskRuntimeProvenance,
16
17
  processStartToken,
@@ -35,6 +36,7 @@ import {
35
36
  withConversationMutation,
36
37
  } from "./continuation.ts";
37
38
  import { processExists } from "./spawn.ts";
39
+ import { archiveCompletedAttempt, archivePublicationPending, listArchivedAttempts } from "./session-store.ts";
38
40
 
39
41
  export const DEFAULT_CLEANUP_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
40
42
 
@@ -346,6 +348,70 @@ function planCleanupOccupancy(
346
348
  interface CleanupScope {
347
349
  occupancy: CleanupOccupancy;
348
350
  runIdsBySession: Map<string, string[]>;
351
+ /**
352
+ * Conversations whose saved session and launch recipe live in durable
353
+ * storage. Their history and owned clone workspace never age out; only the
354
+ * volatile log, metadata, and task scratch are reclaimed.
355
+ */
356
+ persistent: ReadonlySet<string>;
357
+ /**
358
+ * Completed attempts whose compact durable record is still unpublished. The
359
+ * volatile run directory holds the only copy of the recipe, so both bounds
360
+ * retain the whole run until a sweep can publish it.
361
+ */
362
+ archivePending: ReadonlySet<string>;
363
+ }
364
+
365
+ /**
366
+ * Conversations with a durable continuation record: a completed attempt saved
367
+ * its compact metadata and frozen recipe under the Pi agent directory. Their
368
+ * saved history and owned clone workspace are kept indefinitely; only the
369
+ * volatile log, metadata, and task scratch are reclaimed.
370
+ *
371
+ * A completed durable-layout attempt whose compact record could not be published
372
+ * belongs here too: it is not resumable yet, but its recipe, saved history, and
373
+ * clone are the only copy of that conversation.
374
+ *
375
+ * Legacy attempts (recipes pointing at the old temporary session directory, or
376
+ * fixtures without a recipe) never publish that record and keep the existing
377
+ * reclaimable behavior. A conversation that only ever failed is not resumable,
378
+ * so its artifacts stay ordinary aged history and do not pin a clone forever.
379
+ */
380
+ function persistentSessionIds(
381
+ metas: readonly RunMeta[],
382
+ archivePending: ReadonlySet<string>,
383
+ ): Set<string> {
384
+ const sessions = new Set(listArchivedAttempts().map((meta) => meta.sessionId));
385
+ for (const meta of metas) {
386
+ if (archivePending.has(meta.id)) sessions.add(meta.sessionId);
387
+ }
388
+ return sessions;
389
+ }
390
+
391
+ /**
392
+ * Retry publication of every completed attempt whose compact durable record is
393
+ * missing, BEFORE either bound decides what to delete.
394
+ *
395
+ * The volatile run directory is the only place a completed attempt's recipe
396
+ * lives until that record is published, so this pass is what makes the age and
397
+ * size bounds safe: while it keeps failing, the runs it names are retained (see
398
+ * `CleanupScope.archivePending`), and once it succeeds they are ordinary
399
+ * persistent conversations whose raw logs may be reclaimed.
400
+ *
401
+ * Returns the run ids still unpublished.
402
+ */
403
+ function retryPendingArchives(metas: readonly RunMeta[]): Set<string> {
404
+ const pending = new Set<string>();
405
+ for (const meta of metas) {
406
+ if (!archivePublicationPending(meta)) continue;
407
+ try {
408
+ if (archiveCompletedAttempt(meta)) observeArchivePublication(meta.id);
409
+ } catch (error) {
410
+ observeArchivePublication(meta.id, error);
411
+ }
412
+ if (archivePublicationPending(meta)) pending.add(meta.id);
413
+ }
414
+ return pending;
349
415
  }
350
416
 
351
417
  function runIdsBySession(metas: readonly RunMeta[]): Map<string, string[]> {
@@ -457,7 +523,7 @@ function cleanRunDirs(cutoff: number, scope: CleanupScope, errors: string[]): nu
457
523
  const dir = runDir(id);
458
524
  const meta = readMeta(id);
459
525
  if (meta) {
460
- if (!ageSweepEligible(meta, cutoff, retained, scope.occupancy)) continue;
526
+ if (!ageSweepEligible(meta, cutoff, retained, scope)) continue;
461
527
  if (removeAgedRunArtifacts(meta, cutoff, retained, scope, errors)) removed += 1;
462
528
  continue;
463
529
  }
@@ -480,13 +546,15 @@ function ageSweepEligible(
480
546
  meta: RunMeta,
481
547
  cutoff: number,
482
548
  retained: ReadonlySet<string>,
483
- occupancy: CleanupOccupancy,
549
+ scope: CleanupScope,
484
550
  ): boolean {
485
551
  if (!TERMINAL_CLEANUP_STATUSES.has(meta.status)) return false;
486
552
  if (retained.has(meta.sessionId)) return false;
553
+ // The run directory still holds the only copy of this conversation's recipe.
554
+ if (scope.archivePending.has(meta.id)) return false;
487
555
  // A released conversation can still be in use: a later attempt may run
488
556
  // inside this root's clone, or a stop/kill may not have landed.
489
- if (occupancy.runIds.has(meta.id)) return false;
557
+ if (scope.occupancy.runIds.has(meta.id)) return false;
490
558
  return terminalTimestamp(meta) < cutoff;
491
559
  }
492
560
 
@@ -515,8 +583,8 @@ function removeAgedRunArtifacts(
515
583
  return withConversationSerialization(meta.sessionId, () => {
516
584
  if (!conversationQuiescentUnderLock(meta.sessionId, runIds)) return false;
517
585
  const fresh = readMeta(meta.id) ?? meta;
518
- if (!ageSweepEligible(fresh, cutoff, retained, scope.occupancy)) return false;
519
- if (removeMetaArtifacts(fresh)) return true;
586
+ if (!ageSweepEligible(fresh, cutoff, retained, scope)) return false;
587
+ if (removeMetaArtifacts(fresh, { keepWorkspace: scope.persistent.has(fresh.sessionId) })) return true;
520
588
  errors.push(`${runDir(meta.id)}: could not remove run artifacts`);
521
589
  return false;
522
590
  }, () => false);
@@ -528,8 +596,8 @@ function removeAgedRunArtifacts(
528
596
  * `<timestamp>_<sessionId>.jsonl` name, or the bare conversation name. History
529
597
  * that resolves to nothing this sweep knows stays on the ordinary path.
530
598
  */
531
- function sessionIdForPath(path: string, serialized: ReadonlySet<string>): string | undefined {
532
- const rel = relative(sessionsDir(), path);
599
+ function sessionIdForPath(root: string, path: string, serialized: ReadonlySet<string>): string | undefined {
600
+ const rel = relative(root, path);
533
601
  if (!rel || rel.startsWith("..")) return undefined;
534
602
  const known = [...new Set(pathNameCandidates(rel.split(sep)))].filter((candidate) => serialized.has(candidate));
535
603
  // Two different tracked conversations in one path is not a layout this
@@ -553,12 +621,13 @@ function* pathNameCandidates(parts: readonly string[]): Generator<string> {
553
621
  * when this sweep tracks that conversation.
554
622
  */
555
623
  function removeSessionPath(
624
+ root: string,
556
625
  path: string,
557
626
  serialized: ReadonlySet<string>,
558
627
  scope: CleanupScope,
559
628
  errors: string[],
560
629
  ): boolean {
561
- const sessionId = sessionIdForPath(path, serialized);
630
+ const sessionId = sessionIdForPath(root, path, serialized);
562
631
  if (!sessionId) return removePath(path, errors);
563
632
  const runIds = scope.runIdsBySession.get(sessionId) ?? [];
564
633
  return withConversationSerialization(sessionId, () => {
@@ -567,7 +636,7 @@ function removeSessionPath(
567
636
  }, () => false);
568
637
  }
569
638
 
570
- function activeSessionIds(metas: RunMeta[], occupancy: CleanupOccupancy): Set<string> {
639
+ function activeSessionIds(metas: RunMeta[], occupancy: CleanupOccupancy, persistent: ReadonlySet<string>): Set<string> {
571
640
  const active = new Set<string>(occupancy.sessions);
572
641
  for (const meta of metas) {
573
642
  if (meta.status === "running" || meta.status === "orphaned") active.add(meta.sessionId);
@@ -575,12 +644,14 @@ function activeSessionIds(metas: RunMeta[], occupancy: CleanupOccupancy): Set<st
575
644
  // A retained completed conversation is not active work, but its saved
576
645
  // session file is the history a continuation reopens.
577
646
  for (const sessionId of retainedSessionIds()) active.add(sessionId);
647
+ // Durable conversations never expire with the volatile sweeps either.
648
+ for (const sessionId of persistent) active.add(sessionId);
578
649
  return active;
579
650
  }
580
651
 
581
- function pathMentionsActiveSession(path: string, active: Set<string>): boolean {
652
+ function pathMentionsActiveSession(root: string, path: string, active: Set<string>): boolean {
582
653
  if (active.size === 0) return false;
583
- const rel = relative(sessionsDir(), path);
654
+ const rel = relative(root, path);
584
655
  for (const sessionId of active) {
585
656
  if (sessionId && rel.includes(sessionId)) return true;
586
657
  }
@@ -588,6 +659,7 @@ function pathMentionsActiveSession(path: string, active: Set<string>): boolean {
588
659
  }
589
660
 
590
661
  function cleanSessionTree(
662
+ root: string,
591
663
  cutoff: number,
592
664
  active: Set<string>,
593
665
  protectedPaths: readonly string[],
@@ -595,7 +667,6 @@ function cleanSessionTree(
595
667
  serialized: ReadonlySet<string>,
596
668
  errors: string[],
597
669
  ): number {
598
- const root = sessionsDir();
599
670
  if (!existsSync(root)) return 0;
600
671
  let removed = 0;
601
672
 
@@ -614,10 +685,10 @@ function cleanSessionTree(
614
685
  if (entry.isDirectory()) {
615
686
  const childEmpty = visit(path);
616
687
  if (childEmpty
617
- && !pathMentionsActiveSession(path, active)
688
+ && !pathMentionsActiveSession(root, path, active)
618
689
  && !pathOverlapsProtected(path, protectedPaths)) {
619
690
  const mtimeMs = safeStatMtime(path);
620
- if (mtimeMs !== undefined && mtimeMs < cutoff && removeSessionPath(path, serialized, scope, errors)) {
691
+ if (mtimeMs !== undefined && mtimeMs < cutoff && removeSessionPath(root, path, serialized, scope, errors)) {
621
692
  removed += 1;
622
693
  continue;
623
694
  }
@@ -626,12 +697,12 @@ function cleanSessionTree(
626
697
  continue;
627
698
  }
628
699
 
629
- if (pathMentionsActiveSession(path, active) || pathOverlapsProtected(path, protectedPaths)) {
700
+ if (pathMentionsActiveSession(root, path, active) || pathOverlapsProtected(path, protectedPaths)) {
630
701
  empty = false;
631
702
  continue;
632
703
  }
633
704
  const mtimeMs = safeStatMtime(path);
634
- if (mtimeMs !== undefined && mtimeMs < cutoff && removeSessionPath(path, serialized, scope, errors)) {
705
+ if (mtimeMs !== undefined && mtimeMs < cutoff && removeSessionPath(root, path, serialized, scope, errors)) {
635
706
  removed += 1;
636
707
  continue;
637
708
  }
@@ -656,19 +727,33 @@ export function runDailyCleanupOnce(options: DailyCleanupOptions = {}): DailyCle
656
727
  const terminalRetention = retentionMs(options.terminalRunRetentionMs, options.config?.cleanupTerminalRunRetentionMs);
657
728
  const sessionRetention = retentionMs(options.sessionRetentionMs, options.config?.cleanupSessionRetentionMs);
658
729
  const metas = listMetas();
730
+ // Publish any outstanding compact record before this sweep reclaims the only
731
+ // copy of a completed attempt's recipe.
732
+ const archivePending = retryPendingArchives(metas);
733
+ const persistent = persistentSessionIds(metas, archivePending);
659
734
  const scope: CleanupScope = {
660
735
  occupancy: planCleanupOccupancy(metas, options.protect),
661
736
  runIdsBySession: runIdsBySession(metas),
737
+ persistent,
738
+ archivePending,
662
739
  };
663
740
  const removedRunDirs = cleanRunDirs(now - terminalRetention, scope, errors);
664
- const removedSessionPaths = cleanSessionTree(
665
- now - sessionRetention,
666
- activeSessionIds(metas, scope.occupancy),
667
- scope.occupancy.paths,
668
- scope,
669
- serializedConversations(metas),
670
- errors,
671
- );
741
+ // Native saved history is durable regardless of the latest attempt status.
742
+ // Sweep only legacy temporary sessions; eligibility to resume must never
743
+ // determine whether a durable native conversation is kept.
744
+ const sessionRoots = [join(baseDir(), "sessions")];
745
+ let removedSessionPaths = 0;
746
+ for (const root of sessionRoots) {
747
+ removedSessionPaths += cleanSessionTree(
748
+ root,
749
+ now - sessionRetention,
750
+ activeSessionIds(metas, scope.occupancy, persistent),
751
+ scope.occupancy.paths,
752
+ scope,
753
+ serializedConversations(metas),
754
+ errors,
755
+ );
756
+ }
672
757
 
673
758
  try {
674
759
  writeCleanupState({ lastLocalDate: dateKey, lastRunAt: now });
@@ -767,6 +852,10 @@ export function enforceRegistrySizeCapOnce(options: SizeCapOptions = {}): SizeCa
767
852
  ?? (Number.isFinite(configured) && configured! > 0 ? configured! : DEFAULT_MAX_REGISTRY_BYTES);
768
853
 
769
854
  const errors: string[] = [];
855
+ // Same ordering as the age sweep: retry archival first, so a run whose
856
+ // recipe only exists in its volatile directory is never a removal candidate.
857
+ const metas = listMetas();
858
+ const archivePending = retryPendingArchives(metas);
770
859
  const entries = collectSizeCapEntries();
771
860
  // Retained conversations and their workspaces are exempt from the byte
772
861
  // bound until the owner releases them; a size sweep must not delete the
@@ -776,17 +865,20 @@ export function enforceRegistrySizeCapOnce(options: SizeCapOptions = {}): SizeCa
776
865
  // Occupancy costs a metadata scan and lease reads, so it is only computed
777
866
  // when this sweep would actually remove something.
778
867
  const totalBytes = entries.reduce((sum, entry) => sum + entry.bytes, 0);
779
- const metas = listMetas();
780
868
  const occupancy = totalBytes > maxBytes ? planCleanupOccupancy(metas, options.protect) : undefined;
781
869
  const protectedIds = new Set([
782
870
  ...(options.protectedIds ?? ownRunIds(metas)),
783
871
  ...retainedRunIds(metas),
784
872
  ...(occupancy?.runIds ?? []),
873
+ // An unpublished compact record is the recipe's only copy.
874
+ ...archivePending,
785
875
  ]);
786
876
  const plan = planRegistrySizeCap(entries, { maxBytes, protectedIds });
787
877
  const scope: CleanupScope = {
788
878
  occupancy: occupancy ?? { sessions: new Set(), runIds: new Set(), paths: [] },
789
879
  runIdsBySession: runIdsBySession(metas),
880
+ persistent: persistentSessionIds(metas, archivePending),
881
+ archivePending,
790
882
  };
791
883
  const bytesById = new Map(entries.map((entry) => [entry.id, entry.bytes]));
792
884
  const removed: string[] = [];
@@ -830,7 +922,7 @@ function removeSizeCappedRun(
830
922
  const fresh = readMeta(id) ?? meta;
831
923
  if (!TERMINAL_CLEANUP_STATUSES.has(fresh.status)) return "retained";
832
924
  if (scope.occupancy.runIds.has(fresh.id)) return "retained";
833
- if (removeMetaArtifacts(fresh)) return "removed";
925
+ if (removeMetaArtifacts(fresh, { keepWorkspace: scope.persistent.has(fresh.sessionId) })) return "removed";
834
926
  errors.push(`${runDir(id)}: could not remove run artifacts`);
835
927
  return "failed";
836
928
  }, () => "retained");
@@ -18,9 +18,19 @@
18
18
  * clean/nesting/approval intent, confinement, model, effort, role identity,
19
19
  * workspace intent) on run metadata.
20
20
  * 3. **Explicit retention.** `retain_session` records a bounded promise that a
21
- * completed conversation and its workspace stay out of cleanup until the
22
- * owner releases it. Retention lives in the existing runtime storage, so
23
- * system-temp reaping can still remove it; a missing conversation refuses.
21
+ * completed conversation and its workspace stay out of cleanup until a
22
+ * foreground session releases it. Retention lives in the existing runtime
23
+ * storage, so system-temp reaping can still remove it; a missing
24
+ * conversation refuses.
25
+ *
26
+ * Foreground-session ownership is provenance, not an authorization boundary:
27
+ * Pi sessions are ordinary files on disk, so any foreground session that can
28
+ * name a completed attempt with a verifiable saved conversation, workspace, and
29
+ * launch recipe may continue it (or release its optional retention). The new
30
+ * attempt records the INVOKING foreground as its callback origin and its
31
+ * permission admission is re-resolved from the current profile, so continuing
32
+ * transfers the run to the caller instead of borrowing the original owner's
33
+ * authority.
24
34
  * 4. **Conversation writer lease.** A cross-process token lease held from
25
35
  * admission through child lifetime and finalization. Duplicate continuations
26
36
  * of one conversation admit exactly one writer. Lease-path and retention
@@ -66,6 +76,7 @@ import {
66
76
  type RunStatus,
67
77
  } from "./registry.ts";
68
78
  import { processExists } from "./spawn.ts";
79
+ import { durableSubagentRoot } from "./session-store.ts";
69
80
 
70
81
  const nodeRequire = createRequire(import.meta.url);
71
82
 
@@ -231,7 +242,11 @@ export interface RetentionRecord {
231
242
  ownerRunId: string;
232
243
  /** Canonical workspace kept available for continuation. */
233
244
  workspace: string;
234
- /** Foreground session that may continue or release this conversation. */
245
+ /**
246
+ * Foreground session that first retained this conversation. Provenance for
247
+ * diagnostics only: retention is optional convenience, not a tenancy
248
+ * boundary, so any foreground session may release it.
249
+ */
235
250
  ownerOrigin: RunCallbackOrigin;
236
251
  retainedAt: number;
237
252
  lastAttemptId: string;
@@ -398,7 +413,14 @@ export function markRetentionReleased(
398
413
 
399
414
  // ---- ownership and continuation plan ------------------------------------
400
415
 
401
- /** True when `origin` is the foreground session recorded on the run. */
416
+ /**
417
+ * True when `origin` is the foreground session recorded on the run.
418
+ *
419
+ * This is a provenance/labelling helper, deliberately NOT an admission gate: a
420
+ * different foreground session may continue a completed attempt (see the
421
+ * module header). Callers that need to route a notification use it to decide
422
+ * which foreground a run belongs to, not to deny an operation.
423
+ */
402
424
  export function ownedByOrigin(meta: RunMeta, origin: RunCallbackOrigin | undefined): boolean {
403
425
  const recorded = meta.callbackOrigin;
404
426
  if (!origin?.sessionId || typeof origin.cwd !== "string") return false;
@@ -441,8 +463,12 @@ export function planContinuation(options: {
441
463
  }): ContinuationPlan {
442
464
  const { id, prior, origin, others } = options;
443
465
  if (!prior) throw refusal(id, "no such run id.");
466
+ // The invoking foreground is still required, because the new attempt's
467
+ // completion callback and navigator entry are routed to it. It is not
468
+ // required to be the attempt's original owner: the saved session, workspace,
469
+ // and recipe are the thing being continued, and Pi sessions are shared
470
+ // on-disk state rather than a per-foreground boundary.
444
471
  if (!origin?.sessionId) throw refusal(id, "the active foreground session identity is unavailable.");
445
- if (!ownedByOrigin(prior, origin)) throw refusal(id, "only its owning foreground session may continue it.");
446
472
  if (prior.status !== "completed") {
447
473
  throw refusal(id, `its status is ${prior.status}; only a completed run can be continued. Stop or launch a new run instead.`);
448
474
  }
@@ -490,13 +516,13 @@ export function planRetentionRelease(options: {
490
516
  }): RetentionRecord {
491
517
  const { id, prior, origin, others } = options;
492
518
  if (!prior) throw new ContinuationError(`Cannot release ${id}: no such run id.`);
519
+ // Release is the same on-disk convenience promise as continuation: any
520
+ // foreground that can name the attempt may drop optional retention. The
521
+ // invoking identity is still required, because the record keeps `releasedBy`
522
+ // and the decision must be attributable to a real foreground session.
493
523
  if (!origin?.sessionId) throw new ContinuationError(`Cannot release ${id}: the active foreground session identity is unavailable.`);
494
- if (!ownedByOrigin(prior, origin)) throw new ContinuationError(`Cannot release ${id}: only its owning foreground session may change retention.`);
495
524
  const record = readRetention(prior.sessionId);
496
525
  if (!record) throw new ContinuationError(`Cannot release ${id}: it was not launched with retain_session, so it has no retained conversation.`);
497
- if (!record.ownerOrigin.sessionId || record.ownerOrigin.sessionId !== origin.sessionId || record.ownerOrigin.cwd !== origin.cwd) {
498
- throw new ContinuationError(`Cannot release ${id}: only the foreground session that retained this conversation may release it.`);
499
- }
500
526
  if (record.releasedAt !== undefined) {
501
527
  throw new ContinuationError(`Cannot release ${id}: its retention was already released at ${new Date(record.releasedAt).toISOString()}.`);
502
528
  }
@@ -541,8 +567,14 @@ const ADMISSION_LOCK_WAIT_MS = 0;
541
567
  const RELEASE_LOCK_WAIT_MS = 1_000;
542
568
  const RETENTION_LOCK_WAIT_MS = 1_000;
543
569
 
570
+ /**
571
+ * Stable writer-lease root, independent of TMPDIR: a conversation is ordinary
572
+ * on-disk Pi state, so a foreground that restarts under a different temp root
573
+ * must still see the one writer slot. Retention records stay in the volatile
574
+ * registry as before.
575
+ */
544
576
  export function conversationLeaseDir(): string {
545
- return join(baseDir(), "conversation-leases");
577
+ return join(durableSubagentRoot(), "conversation-leases");
546
578
  }
547
579
 
548
580
  function leasePathFor(sessionId: string): string {