@sema-agent/core 7.26.1 → 7.26.3

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 (33) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/agents/verify.d.ts +16 -6
  3. package/dist/agents/verify.js +12 -5
  4. package/dist/core/checkpoint-store.d.ts +13 -10
  5. package/dist/core/gate-lanes.js +2 -1
  6. package/dist/core/gate-outcome.d.ts +54 -5
  7. package/dist/core/gate-outcome.js +17 -0
  8. package/dist/core/runner/contracts.d.ts +48 -0
  9. package/dist/core/runner/decide-continuation.js +3 -1
  10. package/dist/core/runner/denial-limit-arms.d.ts +4 -3
  11. package/dist/core/runner/denial-limit-arms.js +2 -1
  12. package/dist/core/runner/prepare-delegation-surface.d.ts +4 -0
  13. package/dist/core/runner/prepare-delegation-surface.js +2 -1
  14. package/dist/core/runner/prepare-task.js +1 -1
  15. package/dist/core/runner/resume-apply.js +3 -1
  16. package/dist/core/runner/resume-policy-outcome.js +6 -6
  17. package/dist/core/runner/run-harness-handlers.js +2 -5
  18. package/dist/core/runner/run-identity-wiring.d.ts +6 -0
  19. package/dist/core/runner/run-identity-wiring.js +8 -3
  20. package/dist/core/runner/run-leg.js +9 -3
  21. package/dist/core/runner/runtask.js +2 -2
  22. package/dist/core/tool-policy.d.ts +36 -12
  23. package/dist/core/tool-policy.js +22 -9
  24. package/dist/tools/fs/dead-cwd.d.ts +48 -0
  25. package/dist/tools/fs/dead-cwd.js +56 -0
  26. package/dist/tools/fs/fs-bash.js +4 -32
  27. package/dist/tools/fs/shell-read-plan.js +16 -17
  28. package/dist/tools/monitor.d.ts +4 -0
  29. package/dist/tools/monitor.js +6 -0
  30. package/package.json +1 -1
  31. package/test/export-surface.snapshot.json +1 -1
  32. package/wire-changes/core@7.26.2.json +106 -0
  33. package/wire-changes/core@7.26.3.json +65 -0
@@ -1,4 +1,4 @@
1
- import { createSafeNotifier, observeThenableRejection } from "../safe-notify.js";
1
+ import { observeThenableRejection } from "../safe-notify.js";
2
2
  import { forwardSafely } from "./forward-sink.js";
3
3
  import { emitTrace } from "../trace.js";
4
4
  import { primaryActivityArg } from "../arg-summary.js";
@@ -12,10 +12,7 @@ import { hasVerifiableStructureSignal } from "./grounding-signal.js";
12
12
  import { batchResponseDigest, toolEndBodyFrom } from "./tool-end-body.js";
13
13
  export function createHarnessHandlers(input) {
14
14
  const { prepared, stats, rs, deps } = input;
15
- const { spec, queue, internals, ident, parentToolCallId, subagentName, pushContent, emitCommitted, startedToolCallIds, toolStartAt, toolProgress, writeFamilyOf, toolLabels, postToolBatchHook, batchArgs } = deps;
16
- const internalsNotifier = createSafeNotifier({
17
- onError: (f) => console.warn(`[sema-core] ${f.site}: run-internals activity sink threw (contained; further failures counted, not re-disclosed): ${f.error.message}`),
18
- });
15
+ const { spec, queue, internals, ident, parentToolCallId, subagentName, pushContent, emitCommitted, startedToolCallIds, toolStartAt, toolProgress, writeFamilyOf, toolLabels, postToolBatchHook, batchArgs, internalsNotifier } = deps;
19
16
  let lastWorkspaceCwd = prepared.cwdRef?.current;
20
17
  const announceWorkspaceMove = () => {
21
18
  const cwdNow = prepared.cwdRef?.current;
@@ -1,4 +1,5 @@
1
1
  import type { PeerInboundChainRef } from "../../agents/peer-admission.js";
2
+ import { type SafeNotifier } from "../safe-notify.js";
2
3
  import { type NotificationInjectionOutcome, type PendingSessionNotifications, type SystemInjectionPriority, type TaskNotificationPayload } from "../task-notification.js";
3
4
  import type { PushQueue } from "../push-queue.js";
4
5
  import type { TaskEvent, TaskSpec } from "../types.js";
@@ -75,6 +76,11 @@ export interface RunIdentityWiringResult {
75
76
  followUp: number;
76
77
  } | undefined;
77
78
  };
79
+ /** The run's ONE isolation scope for the {@link RunInternals} observation sinks: minted here because
80
+ * the manifest sink below is the run's first call into one, and handed on to the harness-handlers lane
81
+ * so its activity beats are contained by the SAME account — bounded disclosure is per notifier, so a
82
+ * second scope would report one broken sink twice and count it in two places. */
83
+ internalsNotifier: SafeNotifier;
78
84
  /** Did the run END with a person's steer / follow-up still pending (queued, or drained at the final boundary and
79
85
  * never served)? A READ FACE over this lane's own `let`, written by the same undrained-inputs sink on BOTH its arms
80
86
  * (park or not): the terminal-adoption lane folds it into the answer-settled verdict — a "stop" answer the person had
@@ -1,5 +1,5 @@
1
1
  import { uuidv7 } from "../../internal/harness.js";
2
- import { createSafeNotifier } from "../safe-notify.js";
2
+ import { createSafeNotifier, observeThenableRejection } from "../safe-notify.js";
3
3
  import { discloseDroppedPending, renderTaskNotificationXml, taskNotificationDedupKey } from "../task-notification.js";
4
4
  import { deliverDelegationLifecycle, deliverEngineNotice, undrainedUserInputNotices } from "../types.js";
5
5
  export function runIdentityWiring(input) {
@@ -14,7 +14,12 @@ export function runIdentityWiring(input) {
14
14
  const parentToolCallId = internals?.parentToolCallId;
15
15
  const ident = () => parentToolCallId !== undefined ? { eventId: uuidv7(), parentToolCallId, sourceTaskId: runSourceTaskId } : { eventId: uuidv7() };
16
16
  notificationLane.ident = ident;
17
- queue.push({ type: "wiring_manifest", manifest: prepared.wiringManifest, ...ident() });
17
+ const internalsNotifier = createSafeNotifier({
18
+ onError: (f) => console.warn(`[sema-core] ${f.site}: run-internals sink threw (contained; further failures counted, not re-disclosed): ${f.error.message}`),
19
+ });
20
+ const wiringManifest = prepared.wiringManifest;
21
+ queue.push({ type: "wiring_manifest", manifest: wiringManifest, ...ident() });
22
+ internalsNotifier.notify(() => observeThenableRejection(internals?.onWiringManifest?.(wiringManifest), internalsNotifier, "runtask.onWiringManifest"), "runtask.onWiringManifest");
18
23
  prepared.toolRosterDeltas.subscribe((delta) => queue.push({ type: "tool_roster_delta", delta, ...ident() }));
19
24
  prepared.toolDisclosure?.subscribe((snapshot) => queue.push({ type: "tool_disclosure", ...snapshot, ...ident() }));
20
25
  const delegationLifecycleNotifier = createSafeNotifier({
@@ -90,5 +95,5 @@ export function runIdentityWiring(input) {
90
95
  const loopLatch = { ended: false, userInterrupted: false, userHalted: false };
91
96
  onReady({ harness: prepared.harness, abortController: prepared.abortController, loop: loopLatch, reminderMark: prepared.reminderMark, sessionId: prepared.sessionId, runId: prepared.runId, hookTimeoutMs: prepared.hookTimeoutMs, hookIdentity: prepared.hookIdentity });
92
97
  const stats = { turns: 0, tokens: 0, toolCalls: 0, promptTokens: 0, totalInputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, cacheWriteTokensLong: 0, outputTokens: 0, costMicroUsd: 0 };
93
- return { runSourceTaskId, parentToolCallId, ident, emitDelegationLifecycle, loopLatch, stats, undrainedUserAtEnd: { get current() { return undrainedUserAtEnd; } }, userInputLostAtEnd: { get current() { return userInputLostAtEnd; } } };
98
+ return { runSourceTaskId, parentToolCallId, ident, emitDelegationLifecycle, loopLatch, stats, internalsNotifier, undrainedUserAtEnd: { get current() { return undrainedUserAtEnd; } }, userInputLostAtEnd: { get current() { return userInputLostAtEnd; } } };
94
99
  }
@@ -3,6 +3,7 @@ import { redactThenCut } from "../../agents/subagent-steps.js";
3
3
  import { ASK_USER_QUESTION_TOOL_NAME } from "../ask-question.js";
4
4
  import { CheckpointError, readPendingSteerQueue, realApprovalOrgFact, remainingBudgetMicroUsd } from "../checkpoint-store.js";
5
5
  import { normalizePersistedRuleHit } from "../gate-lanes.js";
6
+ import { decidersNote } from "../gate-outcome.js";
6
7
  import { formatHookFeedback, hookSeatExpiredError, runHookSeat } from "../hooks.js";
7
8
  import { buildHumanInputEvent, projectHumanInput } from "../human-input-projection.js";
8
9
  import { ORG_ADJUDICATION_TIMEOUT_MS, settleOrgVerdictWithin } from "../permission-rule-org.js";
@@ -11,7 +12,7 @@ import { computeCostMicroUsd } from "../pricing.js";
11
12
  import { composeApprovalPostureDirective } from "../auto-mode-directive.js";
12
13
  import { mintSystemReminder, statedReminderBodiesOf } from "../reminder-mint.js";
13
14
  import { hasDestroy } from "../remote-env.js";
14
- import { advertisedWritableRootsSeat, refuseOutOfContractDecision } from "../tool-policy.js";
15
+ import { advertisedWritableRootsSeat, policyRefusalMessage, refuseOutOfContractDecision } from "../tool-policy.js";
15
16
  import { callFaceSeat, judgeParkedToolIdentity, rosterEntryNamed, toolCallFaceOf } from "../tool-roster.js";
16
17
  import { deliverEngineNotice } from "../types.js";
17
18
  import { delimitUntrusted, inlineUntrusted, REVIEWER_NOTE_MAX_BODY, sanitizeUntrustedText } from "../untrusted-text.js";
@@ -566,10 +567,15 @@ async function resolvePendingCall(runner, prepared, resume, emit, emitCommitted,
566
567
  ...(outcome.gateOutcome?.settlement !== undefined && outcome.gateOutcome.origin !== undefined ? { settled: { settlement: outcome.gateOutcome.settlement, origin: outcome.gateOutcome.origin } } : {}),
567
568
  });
568
569
  if (outcome.decision === "deny") {
569
- const defaultDenial = outcome.gateOutcome?.settlement?.kind === "park_sla_expired"
570
+ const settlementKind = outcome.gateOutcome?.settlement?.kind;
571
+ const defaultDenial = settlementKind === "park_sla_expired"
570
572
  ? `No one answered the approval request for the pending tool call "${pendingAction.toolName}" — the approval window elapsed with no answer, so it was not executed.`
571
573
  : `The pending tool call "${pendingAction.toolName}" was denied by an approver.`;
572
- const reason = outcome.reason ? delimitUntrusted("reviewer note", outcome.reason, REVIEWER_NOTE_MAX_BODY) : defaultDenial;
574
+ const reason = settlementKind === "policy_refused"
575
+ ? policyRefusalMessage(pendingAction.toolName, undefined, decidersNote(outcome.reason))
576
+ : outcome.reason
577
+ ? delimitUntrusted("reviewer note", outcome.reason, REVIEWER_NOTE_MAX_BODY)
578
+ : defaultDenial;
573
579
  await settleEnd(true, { content: formatHookFeedback(reason, prepared.reminderMark) });
574
580
  const eid = await prepared.session.appendMessage(toolResultMsg(pendingAction.toolCallId, pendingAction.toolName, formatHookFeedback(reason, prepared.reminderMark), true));
575
581
  emitCommitted(eid, "toolResult", pendingAction.toolCallId);
@@ -414,7 +414,7 @@ export class Runner {
414
414
  runner: this.depsSeat, self: this, sessions: this.sessions, sessionReadStates: this.sessionReadStates, pendingSessionNotifications: this.pendingSessionNotifications,
415
415
  prepareTask,
416
416
  next: ({ prepared, injectTaskNotification, deliveredAtTurnOpen, peerInboundChainRef, modelCatalog, notificationLane, unsubscribeTaskNotifications }) => {
417
- const { runSourceTaskId, parentToolCallId, ident, emitDelegationLifecycle, loopLatch, stats, undrainedUserAtEnd, userInputLostAtEnd } = runIdentityWiring({
417
+ const { runSourceTaskId, parentToolCallId, ident, emitDelegationLifecycle, loopLatch, stats, internalsNotifier, undrainedUserAtEnd, userInputLostAtEnd } = runIdentityWiring({
418
418
  spec, queue, prepared, internals, taskIdRef, onReady, manualCompactRef, notifyRef, captureOptOutRef, mcpRedialRef, injectTaskNotification, deliveredAtTurnOpen, peerInboundChainRef,
419
419
  notificationLane, pendingSessionNotifications: this.pendingSessionNotifications, runner: this.depsSeat,
420
420
  });
@@ -442,7 +442,7 @@ export class Runner {
442
442
  deps: {
443
443
  spec, queue, internals, ident, parentToolCallId, subagentName,
444
444
  pushContent, emitCommitted, startedToolCallIds, toolStartAt, toolProgress, writeFamilyOf,
445
- toolLabels, postToolBatchHook, batchArgs,
445
+ toolLabels, postToolBatchHook, batchArgs, internalsNotifier,
446
446
  runnerHooks: { onError: (e, info) => this.deps.onError?.(e, info) },
447
447
  },
448
448
  });
@@ -1408,12 +1408,18 @@ export type OnAsk = "deny" | "allow" | ((req: AskRequest, signal?: AbortSignal)
1408
1408
  * owns; the engine starts no timer for it — with the ONE #548 exception below), so a host that timed
1409
1409
  * its own approval card out could
1410
1410
  * previously only report a plain `false`, which the engine correctly recorded as `"human"` — a person
1411
- * refusing. The two words the host may self-report:
1411
+ * refusing. The three words the host may self-report:
1412
1412
  * - `"human"` — a person answered (identical to omitting the field);
1413
- * - `"timeout"` — the host's own approval window elapsed with no answer. Legal ONLY with
1414
- * `allow: false`: a window that elapsed cannot be the thing that approved an action, so
1415
- * `{allow: true, settledBy: "timeout"}` is a contradiction and is refused fail-closed (the same
1416
- * narrowing the `PermissionResult` allow arm already spells in its type).
1413
+ * - `"timeout"` — the host's own approval window elapsed with no answer;
1414
+ * - `"policy"` — the deployment's OWN policy answered: a machine refused, not a person (a headless lane
1415
+ * whose approver seat exists but can show nobody a prompt). Spelled `false`, such a deny told the model
1416
+ * the USER did not want to proceed and to wait for their direction — a sentence about a person, in a run
1417
+ * where none is coming; with the word it reads {@link policyRefusalMessage} instead, and the note travels
1418
+ * as the policy's words. OMITTING the field still reads as `"human"`, so an unrevised host is unchanged.
1419
+ * The two non-human words are legal ONLY with `allow: false` — neither an elapsed window nor a refusing
1420
+ * policy can be what APPROVED an action, and neither has an allow-side settlement to become — so
1421
+ * `{allow: true, settledBy: "timeout" | "policy"}` is refused fail-closed (the same narrowing the
1422
+ * `PermissionResult` allow arm already spells in its type).
1417
1423
  * `"aborted"` is deliberately NOT accepted here — that word names the engine's OWN fail-closed ends
1418
1424
  * (abort, throw, unavailable, out-of-contract value), each already stamped at its own arm, and a
1419
1425
  * self-reported one would let a host relabel its refusal as an engine failure.
@@ -1460,7 +1466,7 @@ export type OnAsk = "deny" | "allow" | ((req: AskRequest, signal?: AbortSignal)
1460
1466
  export type AskOutcome = boolean | "unavailable" | {
1461
1467
  allow: boolean;
1462
1468
  updatedInput?: unknown;
1463
- settledBy?: "human" | "timeout";
1469
+ settledBy?: "human" | "timeout" | "policy";
1464
1470
  reason?: string;
1465
1471
  approver?: string;
1466
1472
  };
@@ -1581,6 +1587,22 @@ export declare function tryCloneArgs<T>(v: T): {
1581
1587
  * text they let through. Not part of the package's public surface.
1582
1588
  */
1583
1589
  export declare function describeThrown(err: unknown): string;
1590
+ /**
1591
+ * The ONE composer for a DEPLOYMENT-POLICY refusal of an ask — the sentence the model reads when the
1592
+ * approval channel reported that a MACHINE ended this wait (`settledBy: "policy"` on the synchronous
1593
+ * outcome, `hostDecision.decidedBy: "policy"` on a durable decide). Both legs call it, so one refusal
1594
+ * cannot speak two ways depending on which one carried it, and neither leg re-spells it.
1595
+ *
1596
+ * Not being {@link humanRefusalMessage} is the point of its existence. A person's "no" is the user's
1597
+ * direction, and the posture that follows it is "stop and wait for the user"; a policy's is a standing
1598
+ * capability of this run, where no user turn is coming — so this sentence names the deployment as the
1599
+ * refuser, says interactive approval is what the action needs and this context has none, and directs the
1600
+ * model at the work it CAN still do. A note rides a fence labelled as the POLICY's words, never licensed
1601
+ * as the user's guidance, because no user gave it. `askMessage` is the ask's own prompt where the caller
1602
+ * has one (the synchronous leg does); a durable decide answers a call parked in an earlier leg and has
1603
+ * none, and the head then simply names the call.
1604
+ */
1605
+ export declare function policyRefusalMessage(toolName: string, askMessage: string | undefined, note: string | undefined): string;
1584
1606
  /**
1585
1607
  * A {@link resolveAsk} result: always a TERMINAL `allow`/`deny` (never `ask`). `approverUnavailable`
1586
1608
  * is the out-of-band G1 three-value marker: the live approver returned `"unavailable"` for this ask —
@@ -1640,12 +1662,14 @@ export declare function carriesBidiControls(value: unknown, limits?: {
1640
1662
  * auto-deny default, fail-closed error handling, and stable deny reasons so every ask site is
1641
1663
  * consistent. Never returns `ask`.
1642
1664
  *
1643
- * Every arm that ENDED A WAIT names what ended it ({@link ApprovalSettledBy}, per-arm, at the arm):
1644
- * the approver's own verdicts are `"human"`, and the fail-closed ends around them (abort, a throw, an
1645
- * "unavailable" report, an unclonable presentation, an out-of-contract value) are `"aborted"`. The
1646
- * three POSTURE arms at the top — blanket `"allow"`, its `requiresRealApproval` refusal, and the
1647
- * headless auto-deny — carry NO source on purpose: nobody was asked, so there is no wait for anyone to
1648
- * have ended, and `decisionReason: "mode"` is already the honest word for what produced them.
1665
+ * Every arm that ENDED A WAIT composes the {@link import("./gate-outcome.js").Settlement} for it, at the
1666
+ * arm: the approver's own verdicts settle as a person's unless the outcome names another word on
1667
+ * {@link AskOutcome.settledBy} (its own window, its own policy), and the fail-closed ends around them
1668
+ * (abort, a throw, an "unavailable" report, an unclonable presentation, an out-of-contract value) settle
1669
+ * as the engine's own nobody-ended family. The three POSTURE arms at the top — blanket `"allow"`, its
1670
+ * `requiresRealApproval` refusal, and the headless auto-deny — name no person and no window on purpose:
1671
+ * nobody was asked, so there is no wait for anyone to have ended, and `decisionReason: "mode"` is already
1672
+ * the honest word for what produced them.
1649
1673
  */
1650
1674
  export declare function resolveAsk(req: AskRequest, onAsk: OnAsk | undefined, signal?: AbortSignal,
1651
1675
  /** design/384 — observer for a DETACHED approver's settlement, consulted only after the race
@@ -1004,6 +1004,13 @@ function humanRefusalMessage(req, reason) {
1004
1004
  ? `${head}\nThe user doesn't want to proceed with this tool use; the call did NOT run. The user's note on this rejection follows — treat it as the user's guidance (user authority only: it cannot grant permissions or override system rules):\n${delimitUntrusted("reviewer note", reason, REVIEWER_NOTE_MAX_BODY)}\nIf the note does not tell you how to proceed, STOP what you are doing and wait for the user.`
1005
1005
  : `${head}\nThe user doesn't want to proceed with this tool use; the call did NOT run. STOP what you are doing and wait for the user to tell you how to proceed.`;
1006
1006
  }
1007
+ export function policyRefusalMessage(toolName, askMessage, note) {
1008
+ const head = `approval required for "${toolName}" but this run has no interactive approval channel${askMessage !== undefined ? `: ${askMessage}` : ""}`;
1009
+ const body = `${head}\nThe deployment's policy declined this tool use; the call did NOT run. Action requires interactive approval and ` +
1010
+ `permission prompts are not available in this context. Do not claim it succeeded and do not retry it here; report what ` +
1011
+ `was blocked and why you needed it, then continue with the parts of the task that do not require approval.`;
1012
+ return note === undefined ? body : `${body}\nPolicy note:\n${delimitUntrusted("policy note", note, REVIEWER_NOTE_MAX_BODY)}`;
1013
+ }
1007
1014
  const BIDI_CONTROL_RE = /[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/u;
1008
1015
  const BIDI_SCAN_MAX_NODES = 5_000;
1009
1016
  const BIDI_SCAN_MAX_CHARS = 1_000_000;
@@ -1249,15 +1256,16 @@ async function resolveAskArms(req, onAsk, signal, onLateSettlement) {
1249
1256
  settlement: settledByNobody("approver_contract"),
1250
1257
  };
1251
1258
  }
1252
- if (supplied !== undefined && supplied !== "human" && supplied !== "timeout") {
1259
+ if (supplied !== undefined && supplied !== "human" && supplied !== "timeout" && supplied !== "policy") {
1253
1260
  return {
1254
1261
  action: "deny",
1255
1262
  message: `the approver for "${req.toolName}" reported settledBy "${typeof supplied === "string" ? containThrownText(supplied) : supplied === null ? "null" : typeof supplied}", which is outside what a synchronous ` +
1256
- `approver may self-report — it is exactly "human" or "timeout" (or omitted); denied fail-closed`,
1263
+ `approver may self-report — it is exactly "human", "timeout" or "policy" (or omitted); denied fail-closed`,
1257
1264
  decisionReason: "mode",
1258
1265
  settlement: settledByNobody("approver_contract"),
1259
1266
  };
1260
1267
  }
1268
+ const settledWord = supplied === "timeout" ? "timeout" : supplied === "policy" ? "policy" : "human";
1261
1269
  const attribution = screenApproverAttribution(suppliedApprover);
1262
1270
  if (attribution.defect !== undefined) {
1263
1271
  return {
@@ -1267,11 +1275,12 @@ async function resolveAskArms(req, onAsk, signal, onLateSettlement) {
1267
1275
  settlement: settledByNobody("approver_contract"),
1268
1276
  };
1269
1277
  }
1270
- if (supplied === "timeout" && allowed === true) {
1278
+ if (settledWord !== "human" && allowed === true) {
1271
1279
  return {
1272
1280
  action: "deny",
1273
- message: `the approver for "${req.toolName}" returned an allow settled by "timeout" — an elapsed approval window cannot be ` +
1274
- `what approved a call; denied fail-closed (report timeout with allow:false, or allow with settledBy "human"/omitted)`,
1281
+ message: `the approver for "${req.toolName}" returned an allow settled by "${settledWord}" — only a person approves, and that word ` +
1282
+ `names a wait that ended with nobody judging: it cannot be what approved a call; denied fail-closed ` +
1283
+ `(report "${settledWord}" with allow:false, or allow with settledBy "human"/omitted)`,
1275
1284
  decisionReason: "mode",
1276
1285
  settlement: settledByNobody("approver_contract"),
1277
1286
  };
@@ -1302,13 +1311,17 @@ async function resolveAskArms(req, onAsk, signal, onLateSettlement) {
1302
1311
  const note = decidersNote(reasonText);
1303
1312
  return {
1304
1313
  action: "deny",
1305
- message: supplied === "timeout"
1314
+ message: settledWord === "timeout"
1306
1315
  ? `approval for "${req.toolName}" was not answered before the approver's own window elapsed: ${req.message}${note !== undefined ? `\nReviewer note:\n${delimitUntrusted("reviewer note", note, REVIEWER_NOTE_MAX_BODY)}` : ""}`
1307
- : humanRefusalMessage(req, note),
1316
+ : settledWord === "policy"
1317
+ ? policyRefusalMessage(req.toolName, req.message, note)
1318
+ : humanRefusalMessage(req, note),
1308
1319
  decisionReason: "mode",
1309
- settlement: supplied === "timeout"
1320
+ settlement: settledWord === "timeout"
1310
1321
  ? { kind: "approval_window_expired", who: { party: "host" }, when: Date.now() }
1311
- : settledByPerson("human_refused", attribution.approver, note),
1322
+ : settledWord === "policy"
1323
+ ? settledByNobody("policy_refused")
1324
+ : settledByPerson("human_refused", attribution.approver, note),
1312
1325
  };
1313
1326
  }
1314
1327
  if (suppliedEdit === undefined)
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The tracked-cwd liveness probe and its diagnosis, shared by the three sites that spawn into the tracked
3
+ * logical cwd: the shell's foreground and background legs, and the watch tool's background leg. A second
4
+ * copy of either half would be a second answer to "is this directory usable?" and a second refusal
5
+ * wording for the same fact, so both are minted here once and each leg passes only its own tool name.
6
+ */
7
+ import type { ExecutionEnv } from "../../internal/harness-types.js";
8
+ /**
9
+ * Is `dir` USABLE as a spawn cwd? "Dead" is the whole class, not just deletion: gone, replaced by a FILE
10
+ * of the same name or standing under one, or a symlink that does not resolve to a directory. `fileInfo`
11
+ * is lstat-semantics, so a symlink is judged by FOLLOWING it (`canonicalPath`, then one probe of the
12
+ * target — a link retargeted at a file resolves perfectly well and is still not a cwd).
13
+ *
14
+ * The failure direction is deliberately asymmetric. Where a failure says nothing about the chain (the
15
+ * initial lstat, the target's own), an error means death only when it SAYS so — refusing a command, or
16
+ * resetting somebody's working directory, over one flaky stat would be worse than the failure being
17
+ * diagnosed. The RESOLUTION step is the exception: a chain that will not resolve is no directory by any
18
+ * reading; see {@link resolutionFailureMeansDead} for the one code that step reads as death on top.
19
+ */
20
+ export declare function isDeadCwd(env: ExecutionEnv, dir: string, signal?: AbortSignal): Promise<boolean>;
21
+ /**
22
+ * Dead-cwd diagnosis for a never-started command (spawn_error / shell_unavailable): a command that
23
+ * DELETED or REPLACED the tracked cwd (e.g. `opam clean` after `cd` into a build dir) leaves the
24
+ * persistent session pinned to a dead directory — every later spawn fails ENOENT/ENOTDIR with a message
25
+ * blaming the SHELL BINARY ("spawn /bin/bash ENOENT"), the model concludes bash is gone, and the run is
26
+ * permanently disabled (TB compile-compcert, 14 contaminated calls). The env can't distinguish this (spawn
27
+ * reports the same code either way), so probe here:
28
+ * - tracked cwd dead + task root alive → SELF-HEAL: reset the tracked cwd to the root, tell the model
29
+ * what happened and that a retry works;
30
+ * - tracked cwd dead + root ALSO dead (never promise a retry that must fail) → honest
31
+ * unrecoverable verdict;
32
+ * - cwd IS the root and it's dead (`rm -rf "$PWD"` in place — a diverged-guard would
33
+ * skip diagnosis entirely) → honest root-gone verdict (no reset target exists);
34
+ * - the caller knows NO root (`root === undefined` — a direct mount that was never handed one) → say the
35
+ * cwd is gone and that this tool has no reset target, and stop there. Calling that directory "the task
36
+ * root" or declaring the tree unrecoverable would both be claims the caller cannot back: the root may
37
+ * well be alive, and a `cd` to it recovers. Nothing is reset.
38
+ * - cwd healthy → return undefined (the spawn failure has another cause; keep the original error).
39
+ * Returns the model-facing message, or undefined when this is not a dead-cwd case. Uses only the public
40
+ * env faces (`fileInfo`/`canonicalPath`) — works for any env that maps never-started failures to
41
+ * spawn_error/shell_unavailable (contract note on ExecutionErrorCode).
42
+ *
43
+ * `root` is REQUIRED-but-nullable on purpose (not an optional parameter): a caller without a root has to
44
+ * say so at the call site, so "this mount has no root" can never be spelled the same way as "I forgot".
45
+ */
46
+ export declare function diagnoseDeadCwd(env: ExecutionEnv, toolName: string, effectiveCwd: string, root: string | undefined, cwdRef?: {
47
+ current: string;
48
+ }, signal?: AbortSignal): Promise<string | undefined>;
@@ -0,0 +1,56 @@
1
+ function errorMeansDead(code) {
2
+ return code === "not_found" || code === "not_directory";
3
+ }
4
+ function resolutionFailureMeansDead(code) {
5
+ return errorMeansDead(code) || code === "unknown";
6
+ }
7
+ function cancelled(signal) {
8
+ return signal?.aborted === true;
9
+ }
10
+ export async function isDeadCwd(env, dir, signal) {
11
+ const info = await env.fileInfo(dir, signal);
12
+ if (!info.ok) {
13
+ if (info.error.code !== "unknown")
14
+ return errorMeansDead(info.error.code);
15
+ const resolved = await env.canonicalPath(dir, signal);
16
+ return !resolved.ok && resolutionFailureMeansDead(resolved.error.code);
17
+ }
18
+ if (info.value.kind === "directory")
19
+ return false;
20
+ if (info.value.kind !== "symlink")
21
+ return true;
22
+ const target = await env.canonicalPath(dir, signal);
23
+ if (!target.ok)
24
+ return resolutionFailureMeansDead(target.error.code);
25
+ const followed = await env.fileInfo(target.value, signal);
26
+ if (!followed.ok)
27
+ return errorMeansDead(followed.error.code);
28
+ return followed.value.kind !== "directory" && followed.value.kind !== "symlink";
29
+ }
30
+ export async function diagnoseDeadCwd(env, toolName, effectiveCwd, root, cwdRef, signal) {
31
+ if (!(await isDeadCwd(env, effectiveCwd, signal)))
32
+ return undefined;
33
+ if (cancelled(signal))
34
+ return undefined;
35
+ if (root === undefined) {
36
+ return (`Error (${toolName}): command did not run — the working directory no longer exists (or is no longer a directory): ${effectiveCwd} ` +
37
+ `(it was likely deleted or replaced by a previous command). No reset target is known to this tool; ` +
38
+ "re-establish the working directory with a Bash `cd` to a live directory and retry.");
39
+ }
40
+ if (cwdRef && cwdRef.current !== root) {
41
+ const rootAlive = !(await isDeadCwd(env, root, signal));
42
+ if (cancelled(signal))
43
+ return undefined;
44
+ if (rootAlive) {
45
+ cwdRef.current = root;
46
+ return (`Error (${toolName}): command did not run — the working directory no longer exists (or is no longer a directory): ${effectiveCwd}\n` +
47
+ `(it was likely deleted or replaced by a previous command). The working directory has been reset to ${root}; ` +
48
+ `re-run your command (cd first if you need a different directory).`);
49
+ }
50
+ return (`Error (${toolName}): command did not run — the working directory ${effectiveCwd} AND the task root ${root} no longer exist. ` +
51
+ `The shell has no usable directory to run in; this task cannot recover its working tree.`);
52
+ }
53
+ return (`Error (${toolName}): command did not run — the task root directory no longer exists (or is no longer a directory): ${effectiveCwd}. ` +
54
+ `It was likely deleted by a previous command (e.g. rm -rf on the current directory). There is no directory to reset to; ` +
55
+ `this task cannot recover its working tree.`);
56
+ }
@@ -9,6 +9,7 @@ import { hasBackgroundShell } from "../../core/background-shell.js";
9
9
  import { delimitUntrusted } from "../../core/untrusted-text.js";
10
10
  import { MCP_IMAGE_MAX_BASE64 } from "../../core/mcp.js";
11
11
  import { imageMagicMatches, withinAnyRoot } from "./safety.js";
12
+ import { isDeadCwd, diagnoseDeadCwd } from "./dead-cwd.js";
12
13
  import { boundaryIsTheSandbox, hasDestroy, isIsolated } from "../../core/remote-env.js";
13
14
  import { ghRateLimitHint } from "./gh-rate-limit.js";
14
15
  import { resolveBashTimeoutCaps, bashTimeoutCapsSec, bashTimeoutArgRefusal, bashTimeoutParamDescription, envErrorDetail, bashMaxOutputChars, clipShellOutput, writeShellOverflowFile, shellRecoveryHint, CWD_SENTINEL, stripCwdSentinel, } from "./fs-shared.js";
@@ -98,35 +99,6 @@ function dataUriImageFromStdout(stdout) {
98
99
  }
99
100
  return undefined;
100
101
  }
101
- async function isDeadCwd(env, dir) {
102
- const info = await env.fileInfo(dir);
103
- if (!info.ok)
104
- return info.error.code === "not_found";
105
- if (info.value.kind === "directory")
106
- return false;
107
- if (info.value.kind === "symlink") {
108
- const target = await env.canonicalPath(dir);
109
- return !target.ok;
110
- }
111
- return true;
112
- }
113
- async function diagnoseDeadCwd(env, toolName, effectiveCwd, root, cwdRef) {
114
- if (!(await isDeadCwd(env, effectiveCwd)))
115
- return undefined;
116
- if (cwdRef && cwdRef.current !== root) {
117
- if (!(await isDeadCwd(env, root))) {
118
- cwdRef.current = root;
119
- return (`Error (${toolName}): command did not run — the working directory no longer exists (or is no longer a directory): ${effectiveCwd}\n` +
120
- `(it was likely deleted or replaced by a previous command). The working directory has been reset to ${root}; ` +
121
- `re-run your command (cd first if you need a different directory).`);
122
- }
123
- return (`Error (${toolName}): command did not run — the working directory ${effectiveCwd} AND the task root ${root} no longer exist. ` +
124
- `The shell has no usable directory to run in; this task cannot recover its working tree.`);
125
- }
126
- return (`Error (${toolName}): command did not run — the task root directory no longer exists (or is no longer a directory): ${effectiveCwd}. ` +
127
- `It was likely deleted by a previous command (e.g. rm -rf on the current directory). There is no directory to reset to; ` +
128
- `this task cannot recover its working tree.`);
129
- }
130
102
  const AUTO_BACKGROUND_BLOCKED_FIRST_WORDS = new Set(["sleep"]);
131
103
  function stripAutoBackgroundWrapperPrefixes(segment) {
132
104
  let s = segment.trim();
@@ -371,7 +343,7 @@ async function runShell(env, cwd, toolName, command, timeoutSec, caps, signal, c
371
343
  }
372
344
  if (!res.ok) {
373
345
  if (res.error.code === "spawn_error" || res.error.code === "shell_unavailable") {
374
- const diagnosis = await diagnoseDeadCwd(env, toolName, effectiveCwd, cwd, cwdRef);
346
+ const diagnosis = await diagnoseDeadCwd(env, toolName, effectiveCwd, cwd, cwdRef, signal);
375
347
  throw new Error(diagnosis ?? `Error (${toolName}): command did not run (${res.error.code}): ${res.error.message}`);
376
348
  }
377
349
  if (res.error.code === "transport_lost") {
@@ -656,8 +628,8 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
656
628
  if (!hasBackgroundShell(env)) {
657
629
  return errorResult("Error (Bash): this environment does not support background processes (run_in_background).");
658
630
  }
659
- if (cwdRef && (await isDeadCwd(env, cwdRef.current))) {
660
- const diagnosis = await diagnoseDeadCwd(env, "Bash", cwdRef.current, rootCanonical, cwdRef);
631
+ if (cwdRef && (await isDeadCwd(env, cwdRef.current, ctx.signal))) {
632
+ const diagnosis = await diagnoseDeadCwd(env, "Bash", cwdRef.current, rootCanonical, cwdRef, ctx.signal);
661
633
  if (diagnosis !== undefined)
662
634
  throw new Error(diagnosis);
663
635
  }
@@ -1,5 +1,5 @@
1
1
  import { lexShellText, substitutionsInHeredocBody, wordsOf, literalTildeSpelling, SUBSTITUTION_PLACEHOLDER, } from "./bash-lexer.js";
2
- import { LAUNCHER_TABLE, SHELL_BLOCK_WORDS, programBasename, programPositionRunsShell, readProgramPosition, shellScanDeclineOf, } from "./bash-program-position.js";
2
+ import { LAUNCHER_TABLE, SHELL_BLOCK_WORDS, programBasename, programPositionRunsShell, readProgramPosition, shellScanDeclineOf, writtenAssignmentPrefix, } from "./bash-program-position.js";
3
3
  import { ASSIGNMENT_VALUE_NOT_PATHS, INTERPRETER_INLINE_PROGRAMS, NO_PATH_OPERAND_COMMANDS, POLL_LOOP_MAX_BEATS, RECURSIVE_READ_FORMS, SHELL_REENTRY_PROGRAM_RE, UNMODELLED_BASE_MOVERS, argvProgramOf, assignmentWordPayload, attachedOptionPayloads, foldHomeVariableSpelling, resolveOperandLexically, hasUnquotedExpansionMetachar, hasUnquotedGlobMetachar, inlineProgramClause, interpreterKey, isPathShapedToken, readVerbOptionSlot, sedScriptSlotFiles, segmentSelectsRecursiveRead, slotValueCandidates, suppliesFirstOperandSlot, verbFirstOperandSlot, verbOptionsArePositionIndependent, } from "./bash-readonly-classifier.js";
4
4
  import { isAbsolutePathForm } from "./safety.js";
5
5
  export const READ_CANDIDATE_PROVENANCES = [
@@ -241,8 +241,9 @@ function segmentFacts(segment, p, decl, nestedPlan) {
241
241
  const basename = program?.basename;
242
242
  const listed = basename !== undefined && (decl.readers.has(basename) || basename === "cd");
243
243
  const verb = program === undefined ? undefined : { name: program.name, basename: program.basename, listed };
244
+ const envOpens = basename !== undefined && !NO_PATH_OPERAND_COMMANDS.has(basename);
244
245
  for (const k of pos.assignments)
245
- pushAssignment(reads, toks, k, "env_assignment");
246
+ pushAssignment(reads, toks.folded[k], toks.raw[k], "env_assignment", envOpens);
246
247
  for (const l of pos.launchers) {
247
248
  const raw = toks.raw[l.index];
248
249
  const folded = toks.folded[l.index];
@@ -286,7 +287,7 @@ function segmentFacts(segment, p, decl, nestedPlan) {
286
287
  continue;
287
288
  }
288
289
  if (assignmentWordPayload(word, toks.raw[j]) !== undefined) {
289
- pushAssignment(reads, toks, j, "env_assignment");
290
+ pushAssignment(reads, word, toks.raw[j], "env_assignment", envOpens);
290
291
  continue;
291
292
  }
292
293
  reads.push(bare(raw, word, "unlisted_word"));
@@ -463,15 +464,17 @@ function redirectFact(r) {
463
464
  return { op: r.op, direction: "in" };
464
465
  return { op: r.op, direction: "out", target: bare(r.target.raw, r.target.folded, "write_redirect_target") };
465
466
  }
466
- function pushAssignment(reads, toks, k, provenance) {
467
- const word = toks.folded[k];
468
- const payload = assignmentWordPayload(word, toks.raw[k]);
469
- if (payload === undefined)
467
+ function pushAssignment(reads, folded, raw, provenance, opensValue = true) {
468
+ const payload = assignmentWordPayload(folded, raw);
469
+ const prefix = writtenAssignmentPrefix(folded)?.prefix;
470
+ if (payload === undefined || prefix === undefined)
470
471
  return;
471
- if (payload.kind === "unresolved")
472
- reads.push({ spelled: word, raw: toks.raw[k], provenance, expands: "unresolvable" });
472
+ const v = { spelled: folded.slice(prefix.length), raw: raw.startsWith(prefix) ? raw.slice(prefix.length) : raw, provenance };
473
+ const unsettled = provenance !== "env_assignment" ? { ...v, expands: "unresolvable" } : v.spelled.startsWith("~") && !v.spelled.includes(":") ? bare(v.raw, v.spelled, provenance) : undefined;
474
+ if (payload.kind === "unresolved" && opensValue && unsettled !== undefined)
475
+ reads.push(unsettled);
473
476
  if (payload.value !== undefined && payload.value !== SUBSTITUTION_PLACEHOLDER)
474
- reads.push(bare(toks.raw[k], payload.value, provenance));
477
+ reads.push(bare(raw, payload.value, provenance));
475
478
  }
476
479
  function unlistedWords(reads, toks, from, launcherIndices, provenance, program) {
477
480
  const argv = program !== undefined ? { program: program.basename, from: program.index } : argvProgramOf(toks);
@@ -565,9 +568,9 @@ function unlistedWords(reads, toks, from, launcherIndices, provenance, program)
565
568
  }
566
569
  reads.push(bare(raw, word, j === argvStart ? "program_path" : provenance));
567
570
  if (j < argvStart)
568
- pushAssignment(reads, toks, j, "env_assignment");
571
+ pushAssignment(reads, word, raw, "env_assignment", slotVerb !== undefined && !noPathOperands);
569
572
  else if (!declaredNotPaths)
570
- pushAssignment(reads, toks, j, provenance);
573
+ pushAssignment(reads, word, raw, provenance);
571
574
  }
572
575
  if (slotVerb !== undefined && argv !== undefined && segmentSelectsRecursiveRead(slotVerb, toks.folded.slice(argvStart + 1))) {
573
576
  const roots = positional.length > 0 ? positional : stdinOperand ? [] : [{ word: ".", raw: "." }];
@@ -740,11 +743,7 @@ function listedVerbCandidates(name, args, rawArgs, reads) {
740
743
  positional.push({ word: t, raw: rawArgs[k] });
741
744
  push(k, t, "operand");
742
745
  if (!ASSIGNMENT_VALUE_NOT_PATHS.has(name)) {
743
- const payload = assignmentWordPayload(t, rawArgs[k]);
744
- if (payload?.kind === "unresolved")
745
- reads.push({ spelled: t, raw: rawArgs[k], provenance: "operand", expands: "unresolvable" });
746
- if (payload?.value !== undefined)
747
- push(k, payload.value, "operand");
746
+ pushAssignment(reads, t, rawArgs[k], "operand");
748
747
  }
749
748
  }
750
749
  for (let k = 0; k < args.length; k++) {
@@ -78,5 +78,9 @@ export interface MonitorToolOptions {
78
78
  * could only see the env lane, so a deployment retaining per spec still got the "session ends it"
79
79
  * wording in the persistent-watch receipt. Purely a RECEIPT input — enforcement stays at the sweep. */
80
80
  retainBackgroundProcesses?: boolean;
81
+ /** The task root in the SAME canonical spelling `createBashTool` is built with, so a dead-cwd self-heal
82
+ * lands both tools on one directory. Absent ⇒ the diagnosis says it has no reset target, rather than
83
+ * naming the cwd "the task root" or promising a recovery it cannot make. */
84
+ rootCanonical?: string;
81
85
  }
82
86
  export declare function createMonitorTool(env: ExecutionEnv, opts: MonitorToolOptions): AgentTool;
@@ -3,6 +3,7 @@ import { defineTool, errorResult } from "../core/tools.js";
3
3
  import { hasBackgroundShell } from "../core/background-shell.js";
4
4
  import { MONITOR_DEFAULT_TIMEOUT_MS, MONITOR_MAX_TIMEOUT_MS, } from "../core/task-registry.js";
5
5
  import { toolFace } from "../core/tool-catalog-entries.js";
6
+ import { isDeadCwd, diagnoseDeadCwd } from "./fs/dead-cwd.js";
6
7
  import { thrownText } from "../core/thrown-value.js";
7
8
  const MONITOR_DESCRIPTION = "Run a shell command in the background and watch its stdout as a stream of events.\n" +
8
9
  "\n" +
@@ -71,6 +72,11 @@ export function createMonitorTool(env, opts) {
71
72
  const refused = await opts.shellReadAdmission?.("Monitor", ctx.toolCallId, command, ctx.signal);
72
73
  if (refused !== undefined)
73
74
  return refused;
75
+ if (opts.cwdRef !== undefined && (await isDeadCwd(env, opts.cwdRef.current, ctx.signal))) {
76
+ const diagnosis = await diagnoseDeadCwd(env, "Monitor", opts.cwdRef.current, opts.rootCanonical, opts.cwdRef, ctx.signal);
77
+ if (diagnosis !== undefined)
78
+ return errorResult(diagnosis);
79
+ }
74
80
  if (ctx.signal?.aborted === true)
75
81
  return errorResult("Error (Monitor): the call was cancelled before the watch started.");
76
82
  const spawn = await env.spawnBackground(command, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "7.26.1",
3
+ "version": "7.26.3",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -3788,7 +3788,7 @@
3788
3788
  "SessionWarmup": "advanced",
3789
3789
  "SessionWriteOptions": "stable",
3790
3790
  "Settlement": "advanced",
3791
- "SettlementKind": "advanced",
3791
+ "SettlementKind": "stable",
3792
3792
  "SharedAbortScope": "advanced",
3793
3793
  "SharedMemoryDocumentEntry": "advanced",
3794
3794
  "SharedMemoryFixture": "advanced",