@sema-agent/core 7.31.0 → 7.32.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.
Files changed (99) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/dist/agents/inherited-tool-pool.d.ts +28 -0
  3. package/dist/agents/inherited-tool-pool.js +15 -0
  4. package/dist/agents/list-agents-tool.d.ts +7 -0
  5. package/dist/agents/list-agents-tool.js +9 -2
  6. package/dist/agents/peer-session-drain.d.ts +2 -0
  7. package/dist/agents/peer-session-drain.js +1 -0
  8. package/dist/agents/send-message-tool.d.ts +17 -0
  9. package/dist/agents/send-message-tool.js +11 -4
  10. package/dist/agents/subagent.d.ts +10 -1
  11. package/dist/agents/subagent.js +24 -8
  12. package/dist/core/ask-origin.d.ts +15 -6
  13. package/dist/core/ask-origin.js +15 -9
  14. package/dist/core/background-shell.d.ts +45 -0
  15. package/dist/core/background-shell.js +13 -0
  16. package/dist/core/checkpoint-store.d.ts +5 -0
  17. package/dist/core/checkpoint-store.js +3 -2
  18. package/dist/core/engine-notice.d.ts +9 -0
  19. package/dist/core/gate-lanes.js +1 -1
  20. package/dist/core/governance-codes.d.ts +1 -1
  21. package/dist/core/governance-codes.js +5 -0
  22. package/dist/core/hooks.d.ts +2 -0
  23. package/dist/core/memory-engine/engine.d.ts +10 -1
  24. package/dist/core/memory-engine/engine.js +30 -6
  25. package/dist/core/memory-engine/file-backend.d.ts +15 -0
  26. package/dist/core/memory-engine/file-backend.js +53 -2
  27. package/dist/core/memory-engine/layout.d.ts +29 -4
  28. package/dist/core/memory-engine/layout.js +24 -4
  29. package/dist/core/read-root-grant-screen.d.ts +23 -0
  30. package/dist/core/read-root-grant-screen.js +17 -0
  31. package/dist/core/runner/contracts.d.ts +1 -1
  32. package/dist/core/runner/prepare-artifact.d.ts +12 -7
  33. package/dist/core/runner/prepare-artifact.js +10 -8
  34. package/dist/core/runner/prepare-ask-lane.d.ts +3 -0
  35. package/dist/core/runner/prepare-ask-lane.js +5 -1
  36. package/dist/core/runner/prepare-boundary-parks.js +5 -5
  37. package/dist/core/runner/prepare-delegation-surface.d.ts +18 -0
  38. package/dist/core/runner/prepare-delegation-surface.js +6 -25
  39. package/dist/core/runner/prepare-hands-readface.d.ts +12 -89
  40. package/dist/core/runner/prepare-hands-readface.js +14 -48
  41. package/dist/core/runner/prepare-inherited-gate.d.ts +3 -1
  42. package/dist/core/runner/prepare-inherited-gate.js +2 -1
  43. package/dist/core/runner/prepare-park-ask.js +6 -4
  44. package/dist/core/runner/prepare-policy-chain.js +4 -0
  45. package/dist/core/runner/prepare-task.js +5 -3
  46. package/dist/core/runner/read-root-grant.d.ts +66 -0
  47. package/dist/core/runner/read-root-grant.js +77 -0
  48. package/dist/core/runner/run-notification-lane.d.ts +1 -1
  49. package/dist/core/runner/run-settle-and-teardown.js +3 -9
  50. package/dist/core/runner/runtask.js +1 -1
  51. package/dist/core/runner/send-message-continuation.d.ts +45 -0
  52. package/dist/core/runner/send-message-continuation.js +27 -0
  53. package/dist/core/runner/session-read-file-states.d.ts +91 -0
  54. package/dist/core/runner/session-read-file-states.js +43 -0
  55. package/dist/core/secret-env.d.ts +1 -1
  56. package/dist/core/secret-env.js +7 -2
  57. package/dist/core/store-contracts/checkpoint-store-contract.d.ts +2 -0
  58. package/dist/core/store-contracts/checkpoint-store-contract.js +19 -0
  59. package/dist/core/task-registry-monitor.js +3 -0
  60. package/dist/core/task-registry-shared.d.ts +16 -3
  61. package/dist/core/task-registry.d.ts +65 -29
  62. package/dist/core/task-registry.js +78 -28
  63. package/dist/core/task-spec.d.ts +5 -3
  64. package/dist/core/tool-policy.d.ts +23 -0
  65. package/dist/core/tool-policy.js +15 -4
  66. package/dist/core/tool-spec.d.ts +2 -0
  67. package/dist/engine/execution-env/node-execution-env.d.ts +13 -3
  68. package/dist/engine/execution-env/node-execution-env.js +26 -0
  69. package/dist/index.d.ts +4 -3
  70. package/dist/index.js +2 -1
  71. package/dist/scenarios/full-body.d.ts +5 -2
  72. package/dist/scenarios/full-body.js +14 -7
  73. package/dist/tools/fs/fs-bash-background-text.d.ts +16 -0
  74. package/dist/tools/fs/fs-bash-background-text.js +22 -0
  75. package/dist/tools/fs/fs-bash.d.ts +6 -0
  76. package/dist/tools/fs/fs-bash.js +9 -27
  77. package/dist/tools/fs/fs-read.d.ts +2 -2
  78. package/dist/tools/fs/fs-search-tools.d.ts +3 -2
  79. package/dist/tools/fs/index.d.ts +8 -1
  80. package/dist/tools/fs/index.js +2 -3
  81. package/dist/tools/fs/repo-map.d.ts +2 -1
  82. package/dist/tools/fs/safety.d.ts +3 -1
  83. package/dist/tools/fs/safety.js +2 -1
  84. package/dist/tools/fs/shell-read-check.d.ts +3 -0
  85. package/dist/tools/fs/shell-read-check.js +1 -1
  86. package/dist/tools/fs/shell-read-establish.d.ts +3 -1
  87. package/dist/tools/fs/shell-read-establish.js +2 -2
  88. package/dist/tools/fs/shell-read-plan.d.ts +2 -2
  89. package/dist/tools/fs/shell-read-plan.js +15 -10
  90. package/dist/tools/fs/shell-read-word.d.ts +5 -4
  91. package/dist/tools/fs/shell-read-word.js +3 -2
  92. package/dist/tools/monitor.d.ts +5 -4
  93. package/dist/tools/monitor.js +5 -4
  94. package/dist/tools/todo.d.ts +3 -3
  95. package/dist/tools/todo.js +5 -4
  96. package/package.json +1 -1
  97. package/test/export-surface.snapshot.json +13 -1
  98. package/wire-changes/core@7.31.1.json +189 -0
  99. package/wire-changes/core@7.32.0.json +850 -0
@@ -363,6 +363,23 @@ export async function checkpointStoreContract(make, runAssertion) {
363
363
  assert.equal("mandated" in byId.get("md-false"), false, "a written false is out-of-contract and must read as absent, never project");
364
364
  assert.equal("mandated" in byId.get("md-string"), false, "a non-boolean spelling must read as absent, never project verbatim");
365
365
  });
366
+ run("mandate word: a row's closed-set word survives + projects; absent stays absent; a word outside the set reads as absent", async () => {
367
+ const store = make();
368
+ const worded = createCheckpointFixture({ token: mintCheckpointToken(), sessionId: "mw-word" });
369
+ Object.assign(worded.pendingAction, { mandated: true, mandate: "probe_mandate" });
370
+ const bare = createCheckpointFixture({ token: mintCheckpointToken(), sessionId: "mw-absent" });
371
+ Object.assign(bare.pendingAction, { mandated: true });
372
+ const bogus = createCheckpointFixture({ token: mintCheckpointToken(), sessionId: "mw-bogus" });
373
+ Object.assign(bogus.pendingAction, { mandated: true, mandate: "bypass_ok" });
374
+ for (const cp of [worded, bare, bogus])
375
+ await store.put(cp.token, cp);
376
+ assert.equal((await store.get(worded.token)).pendingAction.mandate, "probe_mandate", "the row's mandate word must survive the round-trip verbatim");
377
+ assert.equal("mandate" in (await store.get(bare.token)).pendingAction, false, "an absent word must stay OMITTED on the row");
378
+ const byId = new Map((await store.listByScope("tenant-a")).map((s) => [s.sessionId, s]));
379
+ assert.equal(byId.get("mw-word")?.mandate, "probe_mandate");
380
+ assert.equal("mandate" in byId.get("mw-absent"), false, "absent must project the key OMITTED");
381
+ assert.equal("mandate" in byId.get("mw-bogus"), false, "a word outside the closed set must read as absent, never project verbatim");
382
+ });
366
383
  await settle();
367
384
  }
368
385
  const sortByToken = (a, b) => a.token.localeCompare(b.token);
@@ -388,6 +405,7 @@ export async function checkpointListByScopeSummaries(store) {
388
405
  kind: "tool_approval",
389
406
  origin: "policy",
390
407
  mandated: true,
408
+ mandate: "tool_marks",
391
409
  toolCallId: "call-pr",
392
410
  toolName: "open_pr",
393
411
  args: { repo: "x" },
@@ -428,6 +446,7 @@ export const EXPECTED_LISTBYSCOPE_SUMMARIES = [
428
446
  toolName: "open_pr",
429
447
  toolInput: '{"repo":"x"}',
430
448
  mandated: true,
449
+ mandate: "tool_marks",
431
450
  origin: "policy",
432
451
  },
433
452
  {
@@ -1,4 +1,5 @@
1
1
  import { delimitUntrusted } from "./untrusted-text.js";
2
+ import { envDeclaresRetain, retainsBackgroundProcesses } from "./background-shell.js";
2
3
  import { assertOwnership, defaultMonitorTimers, MONITOR_MAX_TIMEOUT_MS, MONITOR_DEFAULT_TIMEOUT_MS, MONITOR_BATCH_WINDOW_MS, MONITOR_MAX_BATCHES_PER_MINUTE, MONITOR_STORM_BURST, MONITOR_STORM_KILL_AFTER_MS, MONITOR_LINE_BUF_CAP, MONITOR_SPILL_CAP_CHARS, TASK_OUTPUT_MAX_CHARS, mintCompletionId, clipMonitorEvent, clipMonitorLine, terminalTaskSummary, accountDroppedBytes, renderSpoolBody, SPOOL_REPLAY, rollSpoolText, statusFromBackground, droppedGapNote, firstDropNote, alreadyTerminalStopNote, clipTaskOutput, sleepPollStep, } from "./task-registry-shared.js";
3
4
  import { buildToolResultRef, OFFLOAD_TOOL_NAME, toolResultProvenanceOf } from "./tool-result-store.js";
4
5
  import { thrownText } from "./thrown-value.js";
@@ -41,6 +42,8 @@ export function registerMonitorLane(core, input) {
41
42
  ...(input.onEvent !== undefined ? { onEvent: input.onEvent } : {}),
42
43
  ...(input.toolResultStore !== undefined ? { toolResultStore: input.toolResultStore } : {}),
43
44
  ...(input.sessionId !== undefined ? { spillSessionId: input.sessionId } : {}),
45
+ ...(retainsBackgroundProcesses({ env: envDeclaresRetain(input.env), task: input.retainBackgroundProcesses === true }) ? { retained: true } : {}),
46
+ ...(input.retainBackgroundProcesses !== undefined ? { retainStatedAtLaunch: true } : {}),
44
47
  };
45
48
  core.handles.set(id, handle);
46
49
  try {
@@ -133,9 +133,11 @@ export interface BackgroundBashTaskHandle extends SemaTaskHandle {
133
133
  * run-teardown settle/evict, kept alive through the dispose except-list, reaped by
134
134
  * {@link TaskRegistry.reapSessionBackground}. Explicit flag only — never inferred from owner shape. */
135
135
  sessionScoped?: true;
136
- /** Deliver-a-living-service ledger mark: the run-teardown settle sweep skipped this row under the
137
- * retain gate (spec-level `TaskSpec.retainBackgroundProcesses` OR the env-declared capability). The
138
- * row is the delivered daemon's ONLY management surface — while the process runs it survives the
136
+ /** Deliver-a-living-service ledger mark — the row's lane of THE retain predicate
137
+ * (`retainsBackgroundProcesses`). Set at REGISTRATION when the launching task
138
+ * (`TaskSpec.retainBackgroundProcesses`) or the env capability declares retain, and by a run teardown
139
+ * whose own spec declares it. In-memory only (shell-lane rows are never persisted); a row without the
140
+ * field reads as not marked. The row is the delivered daemon's ONLY management surface — while the process runs it survives the
139
141
  * per-run evict (TaskOutput/TaskStop resolve through this map; the watcher must stay live to mint
140
142
  * the natural-exit receipt) and rides every later run's dispose except-list on the same env
141
143
  * ({@link TaskRegistry.retainedShellIds}). A terminal row evicts normally. Terminals: explicit
@@ -144,6 +146,11 @@ export interface BackgroundBashTaskHandle extends SemaTaskHandle {
144
146
  * row is likewise exempt from the session-release reap ({@link TaskRegistry.reapSessionBackground}):
145
147
  * the delivered daemon outlives its session, with the same three terminals. */
146
148
  retained?: true;
149
+ /** The launcher STATED the task lane at registration (`retainBackgroundProcesses` was passed, true or
150
+ * false): the mark above already carries it, so a run teardown's own spec lane does not re-judge this row
151
+ * (a later run's declaration must not re-label a process its launch receipt described). Absent ⇒ the
152
+ * launcher did not know the lane (a host tool registering directly): the owning run's teardown supplies it. */
153
+ retainStatedAtLaunch?: true;
147
154
  /** design/116 §7 G2b: when a watcher is attached, the registry accumulates the FULL output here (rolling
148
155
  * bound) — TaskOutput then serves a RE-READABLE snapshot (CC semantics) instead of a consume-once cursor.
149
156
  * `timedOut`/`timeoutSec` record a SELF-INFLICTED background-timeout kill (the env's per-shell budget
@@ -449,6 +456,8 @@ export interface MonitorTaskHandle extends SemaTaskHandle {
449
456
  sessionScoped?: true;
450
457
  /** Deliver-a-living-service ledger mark — same semantics as the bash handle's `retained` (see there). */
451
458
  retained?: true;
459
+ /** Same as the bash handle's `retainStatedAtLaunch` (see there). */
460
+ retainStatedAtLaunch?: true;
452
461
  /** Non-persistent only: watcher-enforced hard deadline (epoch ms on the injected clock).
453
462
  * (CC parity): this deadline — not the parent run — is the watch's lifetime anchor.
454
463
  * A monitor's life = min(timeout, process exit, TaskStop); a persistent one is SESSION-anchored.
@@ -523,6 +532,10 @@ export interface RegisterMonitorInput extends TaskAccess {
523
532
  timers?: MonitorTimers;
524
533
  /** Batch window (ms, default 200): lines arriving within one tick coalesce into one notification. */
525
534
  batchWindowMs?: number;
535
+ /** The LAUNCHING task's retain declaration (`TaskSpec.retainBackgroundProcesses`) — the task lane of
536
+ * THE retain predicate; stamped onto the row's `retained` mark at registration, and passing it states the
537
+ * lane, same as the bash lane (see `RegisterBackgroundBashInput.retainBackgroundProcesses`). */
538
+ retainBackgroundProcesses?: boolean;
526
539
  /** The SUSTAINED batch rate the storm bucket refills at (default 50/min). Exceeding it is
527
540
  * suppression, not death — see `stormBurst` and MONITOR_STORM_KILL_AFTER_MS. */
528
541
  maxBatchesPerMinute?: number;
@@ -1,6 +1,6 @@
1
1
  import type { AgentTool, ExecutionEnv } from "../internal/harness.js";
2
2
  import type { ToolEffect } from "./types.js";
3
- import { type BackgroundShellCapability, type BackgroundShellId } from "./background-shell.js";
3
+ import { type BackgroundHardKillOutcome, type BackgroundShellCapability, type BackgroundShellId } from "./background-shell.js";
4
4
  import type { WorkflowRunStore } from "./workflow-run-store.js";
5
5
  import { type BackgroundAgentStore } from "./background-agent-store.js";
6
6
  import type { TaskNotificationPayload } from "./task-notification.js";
@@ -38,6 +38,22 @@ export interface RegisterBackgroundBashInput extends TaskAccess {
38
38
  * append at TaskStop/run-teardown (the tail drain), so the file is written even without a watcher —
39
39
  * there is just no live mirror. Callers create the file (empty) before registering. */
40
40
  outputFile?: string;
41
+ /** The LAUNCHING task's retain declaration (`TaskSpec.retainBackgroundProcesses`) — the task lane of
42
+ * {@link retainsBackgroundProcesses}. Carried onto the row as its `retained` mark at registration
43
+ * (together with the env lane), so faces that never see the spec — the session reap, the exit-boundary
44
+ * hard face — answer the same retain question the launch receipt answered. Passing it (true or false)
45
+ * STATES the lane: a later run teardown does not re-judge the row. Absent = the launcher does not know the
46
+ * lane; the owning run's teardown supplies it. */
47
+ retainBackgroundProcesses?: boolean;
48
+ }
49
+ /** One row's receipt from {@link TaskRegistry.killAllBackgroundShellsNow} (closed `outcome` set; `retained`
50
+ * present when the row was retained, whatever the outcome). */
51
+ export interface BackgroundShellHardKillReceipt {
52
+ taskId: string;
53
+ type: "background_bash" | "monitor";
54
+ owner?: string;
55
+ retained?: true;
56
+ outcome: BackgroundHardKillOutcome | "retained_skipped";
41
57
  }
42
58
  export interface TaskPollOptions {
43
59
  workflowStore?: WorkflowRunStore;
@@ -371,7 +387,7 @@ export declare class TaskRegistry {
371
387
  * file of a background bash row that (a) the caller can access (same owner/scope/session visibility as
372
388
  * a TaskOutput poll — {@link canAccess}) and (b) was registered on the caller's OWN execution env —
373
389
  * `env` identity is part of the match, same discipline as {@link retainedShellIds} /
374
- * {@link sessionResidentShellIds}. A canonical path only names a file within ONE env's filesystem
390
+ * {@link runningShellIds}. A canonical path only names a file within ONE env's filesystem
375
391
  * namespace: on a shared registry, a row minted on env A must never exempt a same-named outside-root
376
392
  * file in env B's namespace (same session or not), so instance equality — never a string proxy — is
377
393
  * required. The output file is minted by the ENGINE itself (createTempFile
@@ -501,10 +517,10 @@ export declare class TaskRegistry {
501
517
  * Aborts every running session-scoped background child registered under `sessionId` and settles it
502
518
  * `killed` (the child's own catch/then would also classify the abort as killed; this makes the registry
503
519
  * state immediate rather than waiting on the child's promise). Idempotent.
504
- * RETAINED shell-lane rows are EXEMPT: under the deliver-a-living-service contract a retained daemon
505
- * outlives the session release — its only terminals are explicit TaskStop, its own process exit (the
506
- * watcher mints that receipt), and env destruction (the multi-tenant leak backstop). The engine-exit
507
- * reap ({@link reapAllSessionBackground}) delegates here, so it inherits the same exemption.
520
+ * RETAINED shell-lane rows (THE retain predicate over the row's mark and the env's declaration) are left
521
+ * whole — row, watcher and process (the deliver-a-living-service contract; their terminals: TaskStop, own
522
+ * exit, env destruction, the hard face under `includeRetained: true`). Every other running shell-lane row
523
+ * this session owns is killed and settled. {@link reapAllSessionBackground} delegates here.
508
524
  * RB-375 A3 — RETURN-VALUE semantics: the count covers DIRECT hits of this sweep only (rows whose
509
525
  * own arm above matched). Children the reaped agents themselves spawned (`sessionScoped:false`
510
526
  * grandchildren) die COLLATERALLY, via each reaped agent's own teardown
@@ -522,11 +538,37 @@ export declare class TaskRegistry {
522
538
  * pair — `owner` = the sessionId by design/129 discipline for background_bash/monitor/background_agent
523
539
  * (RB-215 note: NOT for workflow, whose `owner` is the launching LEG's taskId; its session anchor is
524
540
  * `originatingSessionId` — see {@link reapSessionBackground}'s own workflow arm for why) — and runs
525
- * the per-session reap on each pair; retain-declared envs keep their processes (rows settle only),
526
- * exactly like the per-session anchor. Timing discipline: call at the hardShutdown TERMINUS, never at
527
- * drain start — an in-flight turn may still be TaskOutput-following a bg process. Returns total rows
528
- * reaped. */
541
+ * the per-session reap on each pair; retained rows are left alone (row and process), exactly like the
542
+ * per-session anchor. Timing discipline: call at the hardShutdown TERMINUS, never at drain start — an
543
+ * in-flight turn may still be TaskOutput-following a bg process. Returns total rows reaped.
544
+ *
545
+ * Its kills go through each env's `killBackground` (possibly a TERM-then-timer ladder), and it reaches
546
+ * session-resident rows only: every exit terminus calls {@link killAllBackgroundShellsNow} after it. */
529
547
  reapAllSessionBackground(): number;
548
+ /** The EXIT-BOUNDARY hard face over every shell-lane row (`background_bash` + `monitor`), session-resident or
549
+ * not (docs/background-process-lifetime.md is the contract). SYNCHRONOUS (no await, no timer) and
550
+ * IDEMPOTENT. Visits every row except `completed` (a `killed` row may sit inside a softer stop's grace
551
+ * window; a `failed` one may have lost its watcher, not its process). A retained row (THE retain predicate)
552
+ * is left alone under `includeRetained: false` — no default — unless an explicit stop already settled it
553
+ * `killed`. Every other visited row goes through the seat {@link BackgroundShellCapability.killBackgroundNow}.
554
+ * One receipt per visited row: `killed` (the seat delivered its kill now) / `already_gone` / `retained_skipped`
555
+ * / `no_pgid` (no synchronous kill landed — no seat, the seat said so or threw; the env's ordinary
556
+ * `killBackground` is then dispatched, nothing claimed). Only a running row the seat killed is settled
557
+ * `killed` (stop source "system", no notification). `reason` is forensics only.
558
+ * `sessionId` + `scope` narrow the visit to the rows {@link reapSessionBackground} (sessionId, scope) keys on
559
+ * — the same access (`canAccess` with owner = session = sessionId, in that scope): every session-resident
560
+ * row of the session, plus its run rows where the run's task id is the session id (the default); a run
561
+ * named with its own task id is not reached. Absent ⇒ the whole registry. Same receipts either way. */
562
+ killAllBackgroundShellsNow(opts: {
563
+ includeRetained: boolean;
564
+ reason?: string;
565
+ } & ({
566
+ sessionId?: undefined;
567
+ scope?: undefined;
568
+ } | {
569
+ sessionId: string;
570
+ scope: string;
571
+ })): BackgroundShellHardKillReceipt[];
530
572
  /** subscribe to `reapSessionBackground` (the design/129 session terminal anchor).
531
573
  * Returns an unsubscribe. Hooks are best-effort observers; a throwing hook is swallowed. */
532
574
  onSessionReap(hook: (sessionId: string, scope?: string) => void): () => void;
@@ -568,29 +610,19 @@ export declare class TaskRegistry {
568
610
  * completion notification at the terminal state. `unref()`d — a watcher never keeps the process alive.
569
611
  * Stops itself on terminal, on a vanished shell (env disposed), or when the handle is evicted. */
570
612
  private startBashWatcher;
613
+ /** RETAINED shells (THE retain predicate) still running on THIS env, any owner — the park-only sweep's
614
+ * spare list. Env identity is part of the match (a shared registry never exempts another env's ids). */
615
+ retainedShellIds(env: unknown): BackgroundShellId[];
616
+ /** Every shell-lane row still RUNNING on this env, any owner — the run teardown's dispose except-list.
617
+ * A run's teardown disposes only shells it can attribute to itself: its settle sweep has already killed
618
+ * and settled the rows of this run that end with it, so whatever registered row is still running here is
619
+ * either something that settle kept (session-resident, an in-window Monitor, retained) or belongs to
620
+ * another run or session sharing the env. Unregistered env shells are still disposed. */
621
+ runningShellIds(env: unknown): BackgroundShellId[];
571
622
  /** design/135 G2: register a spawned background process as a MONITOR — the watcher polls at the batch
572
623
  * window (200ms), turns each completed stdout line into an event, batches lines within one tick into
573
624
  * ONE notification, enforces the non-persistent timeout, auto-stops an event storm, and fires exactly
574
625
  * one terminal notification (exit code / timeout / auto-stop / env-side kill). */
575
- /** The shells a run teardown must LEAVE ALIVE: running SESSION-scoped monitors of this
576
- * session on THIS env (their whole point is outliving the turn; the env-wide dispose would otherwise
577
- * orphan the watch — handle alive, process dead, watcher reporting a bogus env-death terminal). Env
578
- * identity is part of the match so a shared registry never exempts another env's ids. */
579
- sessionResidentShellIds(sessionId: string, env: unknown): BackgroundShellId[];
580
- /** The OTHER shells a run teardown must leave alive: NON-persistent monitors still inside
581
- * their timeout window, on THIS env. CC semantics: Monitor(persistent:false) lives to timeout_ms — the
582
- * parent run ending is not an exit (timeout / process exit / TaskStop are). Deliberately NO owner
583
- * filter: on a shared env an earlier run's (or another session's) in-window watch must survive this
584
- * run's env-wide dispose too. Env identity stays part of the match, same as
585
- * {@link sessionResidentShellIds} (a shared registry never exempts another env's ids). */
586
- timeoutResidentShellIds(env: unknown): BackgroundShellId[];
587
- /** The THIRD dispose except-list lane: RETAINED shells (the settle sweep's deliver-a-living-service
588
- * mark) still running on THIS env. Deliberately NO owner filter, same as
589
- * {@link timeoutResidentShellIds}: on a shared static env a LATER task's env-wide teardown dispose
590
- * must not reap an EARLIER task's delivered daemon (that task's own teardown skipped the dispose;
591
- * the next run's sweep was the remaining kill path). Env identity stays part of the match
592
- * (a shared registry never exempts another env's ids). */
593
- retainedShellIds(env: unknown): BackgroundShellId[];
594
626
  registerMonitor(input: RegisterMonitorInput): string;
595
627
  registerWorkflow(input: RegisterWorkflowInput): string;
596
628
  pollTask(id: string, access: TaskAccess, opts?: TaskPollOptions): Promise<UnifiedTaskResult>;
@@ -616,6 +648,10 @@ export declare class TaskRegistry {
616
648
  * explicit flag tells them apart. Bulk attribution should use {@link markStopSourceForOwner}, not a
617
649
  * list()-driven loop (the 500-row cap). */
618
650
  sessionScoped: boolean;
651
+ /** Present on a shell-lane row (`background_bash` / `monitor`) that THE retain predicate keeps alive
652
+ * past the automatic reaps — the "keeps running" half of an exit listing: the controlled-exit reap and
653
+ * the hard face under `includeRetained: false` leave it running; every other running shell row stops. */
654
+ retained?: true;
619
655
  description?: string;
620
656
  toolUseId?: string;
621
657
  createdAt: number;
@@ -1,6 +1,6 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { defineTool, errorResult } from "./tools.js";
3
- import { hasBackgroundShell } from "./background-shell.js";
3
+ import { envDeclaresRetain, hasBackgroundShell, retainsBackgroundProcesses } from "./background-shell.js";
4
4
  import { shutdownDebug } from "./shutdown-debug.js";
5
5
  import { canAccessAgentRecord, } from "./background-agent-store.js";
6
6
  import { delimitUntrusted } from "./untrusted-text.js";
@@ -15,6 +15,9 @@ import { durableAgentArmedLane, durableAgentRowProbeLane, beginDurableClaimLane,
15
15
  import { toolFace } from "./tool-catalog-entries.js";
16
16
  import { thrownText } from "./thrown-value.js";
17
17
  export { canAccessWorkflowRun, clipTaskOutput, MONITOR_BATCH_WINDOW_MS, MONITOR_DEFAULT_TIMEOUT_MS, MONITOR_MAX_TIMEOUT_MS, MONITOR_MAX_BATCHES_PER_MINUTE };
18
+ function shellRowRetained(h) {
19
+ return retainsBackgroundProcesses({ marked: h.retained === true, env: envDeclaresRetain(h.env) });
20
+ }
18
21
  const BLOCK_DEFAULT_TIMEOUT_MS = 30_000;
19
22
  const BLOCK_MAX_TIMEOUT_MS = 600_000;
20
23
  function blockDeadline(timeoutMs, now) {
@@ -276,6 +279,8 @@ export class TaskRegistry {
276
279
  shellId: input.shellId,
277
280
  ...(input.onTerminal !== undefined ? { onTerminal: input.onTerminal } : {}),
278
281
  ...(input.outputFile !== undefined ? { outputFile: input.outputFile } : {}),
282
+ ...(retainsBackgroundProcesses({ env: envDeclaresRetain(input.env), task: input.retainBackgroundProcesses === true }) ? { retained: true } : {}),
283
+ ...(input.retainBackgroundProcesses !== undefined ? { retainStatedAtLaunch: true } : {}),
279
284
  });
280
285
  if (input.onTerminal)
281
286
  this.startBashWatcher(id, input.onTerminal);
@@ -396,7 +401,7 @@ export class TaskRegistry {
396
401
  continue;
397
402
  if (handle.type === "monitor" && monitorInTimeoutWindow(handle))
398
403
  continue;
399
- if ((handle.type === "background_bash" || handle.type === "monitor") && handle.retained === true)
404
+ if ((handle.type === "background_bash" || handle.type === "monitor") && shellRowRetained(handle))
400
405
  continue;
401
406
  const before = handle.stopSource;
402
407
  this.markStopSource(handle.id, source);
@@ -425,7 +430,7 @@ export class TaskRegistry {
425
430
  continue;
426
431
  if (handle.status !== "running" || !canAccess(handle, access))
427
432
  continue;
428
- if ((handle.retained === true || handle.env.backgroundCapabilities.retainBackgroundProcesses === true || opts?.retainProcesses === true) && !opts?.envDying) {
433
+ if (retainsBackgroundProcesses({ marked: handle.retained === true, env: envDeclaresRetain(handle.env), task: opts?.retainProcesses === true && handle.retainStatedAtLaunch !== true, envDying: opts?.envDying === true })) {
429
434
  handle.retained = true;
430
435
  continue;
431
436
  }
@@ -506,18 +511,16 @@ export class TaskRegistry {
506
511
  const access = { owner: sessionId, sessionId, ...(scope !== undefined ? { scope } : {}) };
507
512
  let reaped = 0;
508
513
  for (const handle of this.handles.values()) {
509
- if ((handle.type === "background_bash" || handle.type === "monitor") && handle.retained === true && handle.status === "running")
514
+ if ((handle.type === "background_bash" || handle.type === "monitor") && handle.status === "running" && shellRowRetained(handle))
510
515
  continue;
511
516
  if (handle.type === "monitor" && handle.status === "running" && handle.sessionScoped && canAccess(handle, access)) {
512
- shutdownDebug("session reap: monitor row", { id: handle.id, shellId: handle.shellId, retainEnv: handle.env.backgroundCapabilities.retainBackgroundProcesses === true });
517
+ shutdownDebug("session reap: monitor row", { id: handle.id, shellId: handle.shellId });
513
518
  this.markStopSource(handle.id, "system");
514
519
  if (handle.watcher !== undefined) {
515
520
  handle.timers.clearInterval(handle.watcher);
516
521
  handle.watcher = undefined;
517
522
  }
518
- if (handle.env.backgroundCapabilities.retainBackgroundProcesses !== true) {
519
- void this.reportSessionReapKillFailure("monitor", handle);
520
- }
523
+ void this.reportSessionReapKillFailure("monitor", handle);
521
524
  handle.status = "killed";
522
525
  handle.stoppedBy = handle.stopSource ?? "system";
523
526
  handle.updatedAt = Date.now();
@@ -526,15 +529,13 @@ export class TaskRegistry {
526
529
  continue;
527
530
  }
528
531
  if (handle.type === "background_bash" && handle.status === "running" && handle.sessionScoped && canAccess(handle, access)) {
529
- shutdownDebug("session reap: background_bash row", { id: handle.id, shellId: handle.shellId, retainEnv: handle.env.backgroundCapabilities.retainBackgroundProcesses === true });
532
+ shutdownDebug("session reap: background_bash row", { id: handle.id, shellId: handle.shellId });
530
533
  this.markStopSource(handle.id, "system");
531
534
  if (handle.watcher !== undefined) {
532
535
  clearInterval(handle.watcher);
533
536
  handle.watcher = undefined;
534
537
  }
535
- if (handle.env.backgroundCapabilities.retainBackgroundProcesses !== true) {
536
- void this.reportSessionReapKillFailure("background_bash", handle);
537
- }
538
+ void this.reportSessionReapKillFailure("background_bash", handle);
538
539
  handle.status = "killed";
539
540
  handle.stoppedBy = handle.stopSource ?? "system";
540
541
  handle.updatedAt = Date.now();
@@ -586,6 +587,63 @@ export class TaskRegistry {
586
587
  reaped += this.reapSessionBackground(p.owner, p.scope);
587
588
  return reaped;
588
589
  }
590
+ killAllBackgroundShellsNow(opts) {
591
+ const sessionAccess = opts.sessionId !== undefined ? { owner: opts.sessionId, sessionId: opts.sessionId, scope: opts.scope } : undefined;
592
+ const receipts = [];
593
+ const settledOwners = new Set();
594
+ for (const handle of this.handles.values()) {
595
+ if (handle.type !== "background_bash" && handle.type !== "monitor")
596
+ continue;
597
+ if (sessionAccess !== undefined && !canAccess(handle, sessionAccess))
598
+ continue;
599
+ if (handle.status === "completed")
600
+ continue;
601
+ const retained = shellRowRetained(handle);
602
+ const base = { taskId: handle.id, type: handle.type, ...(handle.owner !== undefined ? { owner: handle.owner } : {}), ...(retained ? { retained: true } : {}) };
603
+ if (retained && !opts.includeRetained && handle.status !== "killed") {
604
+ receipts.push({ ...base, outcome: "retained_skipped" });
605
+ continue;
606
+ }
607
+ let outcome;
608
+ try {
609
+ outcome = handle.env.killBackgroundNow !== undefined ? handle.env.killBackgroundNow(handle.shellId) : "no_pgid";
610
+ }
611
+ catch (err) {
612
+ shutdownDebug("hard kill: seat threw", { id: handle.id, shellId: handle.shellId, error: thrownText(err) });
613
+ outcome = "no_pgid";
614
+ }
615
+ if (outcome !== "killed" && outcome !== "already_gone" && outcome !== "no_pgid")
616
+ outcome = "no_pgid";
617
+ if (outcome === "no_pgid") {
618
+ try {
619
+ void handle.env.killBackground(handle.shellId).catch(() => undefined);
620
+ }
621
+ catch {
622
+ }
623
+ }
624
+ shutdownDebug("hard kill: row", { id: handle.id, shellId: handle.shellId, outcome, reason: opts.reason ?? "" });
625
+ if (handle.status === "running" && outcome === "killed") {
626
+ this.markStopSource(handle.id, "system");
627
+ if (handle.watcher !== undefined) {
628
+ if (handle.type === "monitor")
629
+ handle.timers.clearInterval(handle.watcher);
630
+ else
631
+ clearInterval(handle.watcher);
632
+ handle.watcher = undefined;
633
+ }
634
+ handle.status = "killed";
635
+ handle.stoppedBy = handle.stopSource ?? "system";
636
+ handle.updatedAt = Date.now();
637
+ mintCompletionId(handle);
638
+ if (handle.owner !== undefined)
639
+ settledOwners.add(handle.owner);
640
+ }
641
+ receipts.push({ ...base, outcome });
642
+ }
643
+ for (const owner of settledOwners)
644
+ this.pokeBgQuiescence(owner);
645
+ return receipts;
646
+ }
589
647
  onSessionReap(hook) {
590
648
  this.sessionReapHooks.add(hook);
591
649
  return () => void this.sessionReapHooks.delete(hook);
@@ -595,7 +653,7 @@ export class TaskRegistry {
595
653
  for (const handle of this.handles.values()) {
596
654
  if (handle.type !== "background_bash" && handle.type !== "monitor" && handle.type !== "background_agent")
597
655
  continue;
598
- if (handle.type !== "background_agent" && handle.retained === true)
656
+ if (handle.type !== "background_agent" && shellRowRetained(handle))
599
657
  continue;
600
658
  if (handle.status === "running" && handle.owner === owner)
601
659
  n++;
@@ -777,28 +835,19 @@ export class TaskRegistry {
777
835
  handle.watcher = setInterval(() => void tick(), 1_000);
778
836
  handle.watcher.unref?.();
779
837
  }
780
- sessionResidentShellIds(sessionId, env) {
781
- const out = [];
782
- for (const handle of this.handles.values()) {
783
- if ((handle.type === "monitor" || handle.type === "background_bash") && handle.status === "running" && handle.sessionScoped === true && handle.owner === sessionId && handle.env === env) {
784
- out.push(handle.shellId);
785
- }
786
- }
787
- return out;
788
- }
789
- timeoutResidentShellIds(env) {
838
+ retainedShellIds(env) {
790
839
  const out = [];
791
840
  for (const handle of this.handles.values()) {
792
- if (handle.type === "monitor" && handle.env === env && monitorInTimeoutWindow(handle)) {
841
+ if ((handle.type === "background_bash" || handle.type === "monitor") && handle.status === "running" && shellRowRetained(handle) && handle.env === env) {
793
842
  out.push(handle.shellId);
794
843
  }
795
844
  }
796
845
  return out;
797
846
  }
798
- retainedShellIds(env) {
847
+ runningShellIds(env) {
799
848
  const out = [];
800
849
  for (const handle of this.handles.values()) {
801
- if ((handle.type === "background_bash" || handle.type === "monitor") && handle.status === "running" && handle.retained === true && handle.env === env) {
850
+ if ((handle.type === "background_bash" || handle.type === "monitor") && handle.status === "running" && handle.env === env) {
802
851
  out.push(handle.shellId);
803
852
  }
804
853
  }
@@ -961,6 +1010,7 @@ The durable record is still marked running (last update ${new Date(row.updatedAt
961
1010
  type: h.type,
962
1011
  status: h.status,
963
1012
  sessionScoped: h.sessionScoped === true,
1013
+ ...((h.type === "background_bash" || h.type === "monitor") && shellRowRetained(h) ? { retained: true } : {}),
964
1014
  ...(h.description !== undefined ? { description: h.description } : {}),
965
1015
  ...(h.toolUseId !== undefined ? { toolUseId: h.toolUseId } : {}),
966
1016
  createdAt: h.createdAt,
@@ -1003,7 +1053,7 @@ The durable record is still marked running (last update ${new Date(row.updatedAt
1003
1053
  if (handle.type === "monitor" && !handle.sessionScoped && canAccess(handle, access)) {
1004
1054
  if (monitorInTimeoutWindow(handle))
1005
1055
  continue;
1006
- if (handle.retained === true && handle.status === "running")
1056
+ if (shellRowRetained(handle) && handle.status === "running")
1007
1057
  continue;
1008
1058
  this.handles.delete(id);
1009
1059
  if (handle.status === "running" && handle.owner !== undefined)
@@ -1019,7 +1069,7 @@ The durable record is still marked running (last update ${new Date(row.updatedAt
1019
1069
  continue;
1020
1070
  if (handle.sessionScoped)
1021
1071
  continue;
1022
- if (handle.retained === true && handle.status === "running")
1072
+ if (shellRowRetained(handle) && handle.status === "running")
1023
1073
  continue;
1024
1074
  this.handles.delete(id);
1025
1075
  if (handle.status === "running" && handle.owner !== undefined)
@@ -1138,9 +1138,11 @@ export interface TaskSpec {
1138
1138
  * retention there would be illusory, so the teardown keeps its honest killed receipts instead.
1139
1139
  * - win32: best-effort "no active kill" only; without detached process groups, process-tree survival
1140
1140
  * past the host is a platform boundary (some children may still exit with it).
1141
- * - Explicit stops are untouched: the TaskStop tool, per-shell background timeouts, and the
1142
- * deployment's session-release reap still kill — this flag only exempts the automatic run-teardown
1143
- * sweep (settle + dispose).
1141
+ * - Automatic reaps leave a retained process alone: the run-teardown sweep (settle + dispose), the
1142
+ * session-release reap, and the exit-boundary hard face under `includeRetained: false`. What still ends
1143
+ * it: the TaskStop tool, its per-shell background timeout (while the host runs), env destruction, and the
1144
+ * hard face under `includeRetained: true`. The row carries the lane it was launched under (a later run's
1145
+ * declaration does not re-label a session-resident row) — see docs/background-process-lifetime.md.
1144
1146
  * - NOT inherited by delegated child tasks: each child's own spec decides for the child's own
1145
1147
  * background processes.
1146
1148
  */
@@ -1,4 +1,5 @@
1
1
  import type { AskOrigin } from "./ask-origin.js";
2
+ import { type ReadRootGrantScreen } from "./read-root-grant-screen.js";
2
3
  import { type Settlement } from "./gate-outcome.js";
3
4
  /** A tool call presented to a policy before it executes. */
4
5
  /** design/388 B6 — the per-call face (see {@link ToolCallRequest.face}). */
@@ -332,6 +333,9 @@ export type PermissionResult = {
332
333
  * set, and a policy self-declaring it only opts its own ask OUT of automatic answering. Carried
333
334
  * onto the approval request as {@link AskRequest.mandated}. */
334
335
  mandated?: true;
336
+ /** The TABLE WORD the {@link mandated} bit stands on — see {@link AskRequest.mandate}. ENGINE-STAMPED at
337
+ * the same origin stamp as the bit; a policy's self-declared value is dropped there. */
338
+ mandate?: import("./ask-origin.js").PersistedRuleMandate;
335
339
  /** The probe named a directory that would clear this ask
336
340
  * ({@link import("./types.js").ReversibilityVerdict.readRootCandidate}). Display metadata like the two
337
341
  * `probe*` above; ENGINE-STAMPED and VALIDATED at the tighten, and deliberately NOT merged onto an ask
@@ -790,6 +794,7 @@ export type ApprovalSeatOutcome = boolean | (Extract<AskOutcome, {
790
794
  allow: boolean;
791
795
  }> & {
792
796
  updatedInput?: never;
797
+ readRootGrant?: never;
793
798
  });
794
799
  /**
795
800
  * Human-in-the-loop approval for selected tools. Tools in `requireApproval` call `approve(req)` and
@@ -1294,6 +1299,12 @@ export interface AskRequest {
1294
1299
  * gate, never caller/worker-settable; tightening-only.
1295
1300
  */
1296
1301
  readonly mandated?: true;
1302
+ /** WHICH word of the closed {@link import("./ask-origin.js").PERSISTED_RULE_MANDATES} set {@link mandated} stands
1303
+ * on — present ⇔ that table word is the bit's WHOLE reason (`probe_mandate` = a read outside the session's
1304
+ * directories). ABSENT beside `mandated: true` = another door holds too (governance, a hook, an ancestor's
1305
+ * approver, a policy's own claim) — absence never names a word. Stamped by the gate beside
1306
+ * the bit, copied by the four mint stations; the park row carries the same word. Never adjudication input. */
1307
+ readonly mandate?: import("./ask-origin.js").PersistedRuleMandate;
1297
1308
  /** Present ⇔ this ask is the classifier DENIAL-LIMIT fallback (see the
1298
1309
  * {@link PermissionResult} ask-arm member of the same name): the counts that tripped the bound and
1299
1310
  * this ask's own auto-deny window. Two readers: a card renders it as a countdown; {@link resolveAsk}
@@ -1488,6 +1499,13 @@ export type OnAsk = "deny" | "allow" | ((req: AskRequest, signal?: AbortSignal)
1488
1499
  * that were quietly dropped would leave a settlement looking unattributed rather than misreported. It is
1489
1500
  * carried on the arms where the CALLER settled something (its allow, its human/timeout deny) and never
1490
1501
  * on an end the engine produced (an abort, a throw, an unclonable edit): nobody approved those.
1502
+ *
1503
+ * `readRootGrant` — "yes, and add this directory": the card's {@link AskRequest.readRootCandidate} `dir`, sent back
1504
+ * byte for byte (`shell.read_boundary.grant_candidate_is_the_grant`). Beside `allow: true` on an ask carrying that
1505
+ * candidate, THIS run's read directories include it from then on (read faces only; `covers: "exact"` = that one
1506
+ * path) — announced `approval.read_root_granted`. Anything else changes nothing and is announced
1507
+ * `approval.read_root_grant_rejected`; the verdict it rode stands. A seat with no card (a policy's `approve`)
1508
+ * refuses it. Keeping the directory beyond this run is the deployment's (`TaskSpec.additionalReadDirectories`).
1491
1509
  */
1492
1510
  export type AskOutcome = boolean | "unavailable" | {
1493
1511
  allow: boolean;
@@ -1495,6 +1513,7 @@ export type AskOutcome = boolean | "unavailable" | {
1495
1513
  settledBy?: "human" | "timeout" | "policy";
1496
1514
  reason?: string;
1497
1515
  approver?: string;
1516
+ readRootGrant?: string;
1498
1517
  };
1499
1518
  /**
1500
1519
  * ruled 2026-08-04 — forward an approver into a delegated child, stamping every ask it raises with the
@@ -1649,6 +1668,8 @@ export type ResolvedAsk = (Extract<PermissionResult, {
1649
1668
  * (string modes present nothing). */
1650
1669
  presentedInput?: unknown;
1651
1670
  approverUnavailable?: never;
1671
+ /** The answer's `readRootGrant` ({@link AskOutcome}), screened against the request's candidate. */
1672
+ readRootGrant?: ReadRootGrantScreen;
1652
1673
  }) | (Extract<PermissionResult, {
1653
1674
  action: "deny";
1654
1675
  }> & {
@@ -1657,6 +1678,8 @@ export type ResolvedAsk = (Extract<PermissionResult, {
1657
1678
  settlement: Settlement;
1658
1679
  presentedInput?: unknown;
1659
1680
  approverUnavailable?: true;
1681
+ /** A grant riding a refusal: always `rejected` (`on_refusal`). */
1682
+ readRootGrant?: ReadRootGrantScreen;
1660
1683
  });
1661
1684
  /**
1662
1685
  * Does any string reachable in `value` carry a {@link BIDI_CONTROL_RE} member? Bounded, cycle-safe,