@sema-agent/core 5.4.0 → 5.6.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.6.0 (2026-08-03)
4
+
5
+ _A hardening tail: numeric knobs refuse what they cannot honor, the binary gate reads bytes instead of trusting statistics, and the MCP SDK takes its last zero-cost step before the v2 jump._
6
+
7
+ ### Fixed
8
+
9
+ - Stall-timeout knobs (`connectTimeoutMs` / `firstTokenTimeoutMs` / `idleTimeoutMs`) refuse invalid numbers loudly (`config.stall_timeout_invalid`) instead of silently disarming — a NaN (units fumble: `"120s"` → `Number` → NaN) used to read as "unset", switching every watchdog OFF exactly when a deployment meant to tighten them. `0` stays legal as an explicit off; absent stays absent.
10
+
11
+ - Read's binary-extension list gains magic-byte criteria of its own (~40 formats, one data-driven signature table): a file whose bytes carry a real binary header (ZIP/gzip/7z/ELF/tar/RIFF…) is now refused by CONTENT even when printable padding used to slip it past the statistical sniffer as mojibake "text". Names still never decide: a `.zip` holding plain text still reads. The Edit/Write/NotebookEdit read-refusal texts all name the same escape route (one constant; NotebookEdit used to render the bare sentence with no hint at all).
12
+
13
+ ### Changed
14
+
15
+ - `@modelcontextprotocol/sdk` 1.29.0 → 1.30.0 (exact pin). Public surface is purely additive upstream (stdio `maxBufferSize`, SSE `keepAliveMs`); `types` is byte-identical; the only behavior delta is a richer zod parse-error message the engine never pins on. Groundwork for the v2 three-package migration (spiked, GO, waiting out the first patch release).
16
+
17
+ ## 5.5.0 (2026-08-03)
18
+
19
+ _Three follow-through items: the deferred-placeholder wrong-guess round trip halves, durable cron jobs survive their creating session, and the redaction findings channel closes its last three audit gaps._
20
+
21
+ ### Fixed
22
+
23
+ - Deferred-tool placeholders answer a wrong guess with the real schema. A placeholder card carries a name and one hint line, so a model that has to guess a shape copies the local pattern of the sibling tools it can see — and a wrong guess used to cost four rounds (call → a rejection teaching a `ToolSearch` round trip → search → real call). A shape-invalid direct call now comes back as structured invalid-arguments **with the real declared parameter schema inlined**, and activates the tool on the spot — the same activation the `ToolSearch` `select:` lane performs (full schema materialized into the next request, fingerprint/boundary-announce refresh, listing ride), because both lanes run one activation closure. Wrong guess: four rounds → two. Right guess: still zero — the schema-valid direct lane is untouched, the shape gate still refuses to run the real tool body on a call it never declared, and adjudication is unchanged (policy/approval decide before execute, so a denied call activates nothing). Two consequences stated plainly: the rejection is flagged as an error, so a later leg's transcript replay does not credit that activation (the name reverts to a placeholder and the corrected call re-activates it — self-healing, never a widening); and a withdrawn name, or a declaration that cannot be serialized, keeps the previous "activate it first" rejection.
24
+ - Scheduler isolation keys fork by lifetime. A durable cron job was filed under the same key as a session-scoped one — minted `sessionId ?? principal ?? taskId ?? "default"` — so on any host with sessions a durable job belonged to the session that created it: it kept firing, but every later session got `not_found` from `CronList`/`CronDelete` and could not even learn it existed. Durable jobs are now isolated by the stable cross-session identity (`principal`, or a single `"default"` bucket where the deployment carries no principal); session-scoped jobs keep the session chain unchanged. `CronList` reads both lanes (deduplicated, and a lane whose listing fails surfaces the error instead of a half-empty list), `CronDelete` acts on the lane that actually holds the id, and `ScheduleWakeup` stays on the session lane (one session's `stop: true` never touches another's pending wakeup). The cross-tenant refusal is unchanged: another principal's durable job answers `not_found`, never an existence leak. **Deployment note**: durable rows written by earlier versions are still keyed by their originating session — a one-time re-key of existing rows belongs to the scheduler backend; the engine does no migration.
25
+
26
+ ### Added
27
+
28
+ - The redaction findings channel completes (external-audit architecture item): findings now carry `source` (a closed provenance vocabulary — `arg-summary` / `untrusted-egress` / `secret-env` — stamped by the minting rule table, with no public finding constructor to forge); `summarizeRedactions(findings)` renders the one-line disclosure a host can announce ("3 redactions: prefixed-token (high) x1, …" — pure function, never auto-injected); and the env arm joins the channel (`scrubSecretEnv(env, findings?)` reports each deleted entry as a `SecretEnvFinding {key, kind, confidence, source}` — deletion, not replacement, so it is its own shape; one summarizer renders both). Absent the optional collector every path is byte-identical to before.
29
+
3
30
  ## 5.4.0 (2026-08-03)
4
31
 
5
32
  _The external-audit dunning batch closes end-to-end (small cases + all three cleared A-group debts), and the engine grows its outer brain-call guardrail. All additive or louder; major stays locked at 5._
@@ -2,7 +2,7 @@ import { createAssistantMessageEventStream, } from "../internal/llm.js";
2
2
  import { BrainError, classifyHttp, describeNetworkError } from "./errors.js";
3
3
  import { retryBackoffMs } from "./retry.js";
4
4
  import { emitBrainStatus, emitBrainTelemetry } from "./status-sink.js";
5
- import { createConnectController } from "./timeout.js";
5
+ import { createConnectController, resolveStallTimeoutMs } from "./timeout.js";
6
6
  import { WALLTIME_CUTOFF_MESSAGE } from "./walltime.js";
7
7
  const DEFAULT_MAX_RETRIES = 10;
8
8
  const MAX_RETRIES_ENV_CEILING = 15;
@@ -113,9 +113,9 @@ export function runStreamingBrain(args) {
113
113
  const req = buildRequest();
114
114
  const maxRetries = resolveMaxRetries(config.maxRetries);
115
115
  const baseDelay = config.retryDelayMs ?? 500;
116
- const firstTokenTimeoutMs = config.firstTokenTimeoutMs ?? stallTimeouts?.firstTokenMs;
117
- const idleTimeoutMs = config.idleTimeoutMs ?? stallTimeouts?.idleMs;
118
- const connectTimeoutMs = config.connectTimeoutMs ?? stallTimeouts?.connectMs;
116
+ const firstTokenTimeoutMs = resolveStallTimeoutMs(config.firstTokenTimeoutMs ?? stallTimeouts?.firstTokenMs, "firstTokenTimeoutMs");
117
+ const idleTimeoutMs = resolveStallTimeoutMs(config.idleTimeoutMs ?? stallTimeouts?.idleMs, "idleTimeoutMs");
118
+ const connectTimeoutMs = resolveStallTimeoutMs(config.connectTimeoutMs ?? stallTimeouts?.connectMs, "connectTimeoutMs");
119
119
  let attempt = 0;
120
120
  let thinkingRetries = 0;
121
121
  let startEmitted = false;
@@ -1,4 +1,5 @@
1
1
  import type { StreamFn } from "../internal/llm.js";
2
+ export declare function resolveStallTimeoutMs(value: number | undefined, knob: string): number | undefined;
2
3
  export interface BrainTimeoutConfig {
3
4
  connectTimeoutMs?: number;
4
5
  firstTokenTimeoutMs?: number;
@@ -1,3 +1,13 @@
1
+ export function resolveStallTimeoutMs(value, knob) {
2
+ if (value === undefined)
3
+ return undefined;
4
+ if (!Number.isFinite(value) || value < 0) {
5
+ const e = new Error(`${knob} must be a non-negative finite number of milliseconds (got ${String(value)})`);
6
+ e.code = "config.stall_timeout_invalid";
7
+ throw e;
8
+ }
9
+ return value;
10
+ }
1
11
  export function createConnectController(connectTimeoutMs, outerSignal, deadlineAtMs) {
2
12
  const ac = new AbortController();
3
13
  let timedOut = false;
@@ -1,10 +1,17 @@
1
1
  export type RedactionConfidence = "high" | "medium" | "low";
2
+ export type RedactionSource = "arg-summary" | "untrusted-egress" | "secret-env";
2
3
  export interface RedactionFinding {
3
4
  kind: string;
4
5
  span: readonly [number, number];
5
6
  confidence: RedactionConfidence;
6
7
  marker: string;
8
+ source: RedactionSource;
7
9
  }
10
+ export interface SummarizableFinding {
11
+ kind: string;
12
+ confidence: RedactionConfidence;
13
+ }
14
+ export declare function summarizeRedactions(findings: readonly SummarizableFinding[]): string;
8
15
  export interface RedactionReport {
9
16
  findings: RedactionFinding[];
10
17
  preexistingMarkers?: number;
@@ -13,6 +20,7 @@ export interface RedactionPass {
13
20
  kind: string;
14
21
  confidence: RedactionConfidence;
15
22
  marker: string;
23
+ source: RedactionSource;
16
24
  re: RegExp;
17
25
  replace: string | ((match: string, ...groups: string[]) => string);
18
26
  }
@@ -1,5 +1,16 @@
1
1
  const ACTIVITY_ARG_MAX = 80;
2
2
  const SENSITIVE_KEY = /token|secret|key|password|passwd|credential|auth/i;
3
+ export function summarizeRedactions(findings) {
4
+ if (findings.length === 0)
5
+ return "no redactions";
6
+ const counts = new Map();
7
+ for (const f of findings) {
8
+ const label = `${f.kind} (${f.confidence})`;
9
+ counts.set(label, (counts.get(label) ?? 0) + 1);
10
+ }
11
+ const parts = [...counts].map(([label, n]) => `${label} x${n}`);
12
+ return `${findings.length} redaction${findings.length === 1 ? "" : "s"}: ${parts.join(", ")}`;
13
+ }
3
14
  function mapBackOnePass(edits, pos) {
4
15
  let delta = 0;
5
16
  for (const e of edits) {
@@ -37,7 +48,13 @@ export function runRedactionPasses(input, passes, report) {
37
48
  s0 = mapBackOnePass(batches[i], s0);
38
49
  e0 = mapBackOnePass(batches[i], e0);
39
50
  }
40
- report.findings.push({ kind: pass.kind, confidence: pass.confidence, span: [s0, e0], marker: pass.marker });
51
+ report.findings.push({
52
+ kind: pass.kind,
53
+ confidence: pass.confidence,
54
+ span: [s0, e0],
55
+ marker: pass.marker,
56
+ source: pass.source,
57
+ });
41
58
  }
42
59
  edits.push({ at: offset, removedLen: match.length, insertedLen: inserted.length });
43
60
  }
@@ -48,18 +65,19 @@ export function runRedactionPasses(input, passes, report) {
48
65
  return cur;
49
66
  }
50
67
  export const SECRET_PASSES = [
51
- { kind: "prefixed-token", confidence: "high", marker: "[redacted]", re: /(?<![A-Za-z0-9])(?:sk|pk|rk|gh[opsur])[-_][A-Za-z0-9_-]{8,}/g, replace: "[redacted]" },
52
- { kind: "prefixed-token", confidence: "high", marker: "[redacted]", re: /(?<![A-Za-z0-9])AIza[A-Za-z0-9_-]{20,}/g, replace: "[redacted]" },
53
- { kind: "prefixed-token", confidence: "high", marker: "[redacted]", re: /(?<![A-Za-z0-9])xox[baprs]-[A-Za-z0-9-]{10,}/g, replace: "[redacted]" },
54
- { kind: "prefixed-token", confidence: "high", marker: "[redacted]", re: /(?<![A-Za-z0-9])AKIA[0-9A-Z]{12,}/g, replace: "[redacted]" },
55
- { kind: "prefixed-token", confidence: "high", marker: "[redacted]", re: /(?<![A-Za-z0-9])hf_[A-Za-z0-9]{20,}/g, replace: "[redacted]" },
56
- { kind: "jwt", confidence: "high", marker: "[redacted]", re: /(?<![A-Za-z0-9])eyJ[A-Za-z0-9._-]{20,}/g, replace: "[redacted]" },
57
- { kind: "private-key-block", confidence: "high", marker: "[redacted]", re: /-----BEGIN[ A-Z]*PRIVATE KEY-----[\s\S]*?(?:-----END[ A-Z]*PRIVATE KEY-----|$)/g, replace: "[redacted]" },
58
- { kind: "bearer-token", confidence: "high", marker: "[redacted]", re: /(?<![A-Za-z0-9])Bearer\s+[A-Za-z0-9._~+/-]{8,}=*/gi, replace: "[redacted]" },
68
+ { kind: "prefixed-token", confidence: "high", marker: "[redacted]", source: "arg-summary", re: /(?<![A-Za-z0-9])(?:sk|pk|rk|gh[opsur])[-_][A-Za-z0-9_-]{8,}/g, replace: "[redacted]" },
69
+ { kind: "prefixed-token", confidence: "high", marker: "[redacted]", source: "arg-summary", re: /(?<![A-Za-z0-9])AIza[A-Za-z0-9_-]{20,}/g, replace: "[redacted]" },
70
+ { kind: "prefixed-token", confidence: "high", marker: "[redacted]", source: "arg-summary", re: /(?<![A-Za-z0-9])xox[baprs]-[A-Za-z0-9-]{10,}/g, replace: "[redacted]" },
71
+ { kind: "prefixed-token", confidence: "high", marker: "[redacted]", source: "arg-summary", re: /(?<![A-Za-z0-9])AKIA[0-9A-Z]{12,}/g, replace: "[redacted]" },
72
+ { kind: "prefixed-token", confidence: "high", marker: "[redacted]", source: "arg-summary", re: /(?<![A-Za-z0-9])hf_[A-Za-z0-9]{20,}/g, replace: "[redacted]" },
73
+ { kind: "jwt", confidence: "high", marker: "[redacted]", source: "arg-summary", re: /(?<![A-Za-z0-9])eyJ[A-Za-z0-9._-]{20,}/g, replace: "[redacted]" },
74
+ { kind: "private-key-block", confidence: "high", marker: "[redacted]", source: "arg-summary", re: /-----BEGIN[ A-Z]*PRIVATE KEY-----[\s\S]*?(?:-----END[ A-Z]*PRIVATE KEY-----|$)/g, replace: "[redacted]" },
75
+ { kind: "bearer-token", confidence: "high", marker: "[redacted]", source: "arg-summary", re: /(?<![A-Za-z0-9])Bearer\s+[A-Za-z0-9._~+/-]{8,}=*/gi, replace: "[redacted]" },
59
76
  {
60
77
  kind: "keyword-value",
61
78
  confidence: "medium",
62
79
  marker: "[redacted]",
80
+ source: "arg-summary",
63
81
  re: /(?<![A-Za-z0-9])((?:token|secret|key|password|passwd|credential|authorization)["']?\s*[=:]\s*["']?)[^\s"';|&]{4,}/gi,
64
82
  replace: "$1[redacted]",
65
83
  },
@@ -1469,9 +1469,9 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1469
1469
  if (spec.interactiveTools === true || (spec.interactiveTools !== false && (onQuestion !== undefined || durableQuestionFace)))
1470
1470
  tools.push(createAskUserQuestionTool(onQuestion, { principal: spec.principal, sourceTaskId: sessionId }));
1471
1471
  if (spec.handsReadOnly !== true) {
1472
- const schedScope = sessionId ?? spec.principal ?? spec.taskId ?? "default";
1472
+ const sessionScope = sessionId ?? spec.principal ?? spec.taskId ?? "default";
1473
1473
  tools.push(...createSchedulerTools(executionEnv, {
1474
- scope: schedScope,
1474
+ scope: sessionScope,
1475
1475
  ...(spec.principal !== undefined ? { principal: spec.principal } : {}),
1476
1476
  ...(sessionId !== undefined ? { sessionId } : {}),
1477
1477
  requestStopAfterTurn,
@@ -1,6 +1,7 @@
1
1
  import { Type } from "typebox";
2
2
  import { Value } from "typebox/value";
3
3
  import { defineTool, errorResult } from "../tools.js";
4
+ import { formatZodValidationError, truncateError } from "../tool-errors.js";
4
5
  export const TOOL_SEARCH_NAME = "ToolSearch";
5
6
  const DEFER_AUTO_FRACTION = 0.1;
6
7
  const CHARS_PER_TOKEN = 4;
@@ -61,12 +62,32 @@ export function buildDeferredRegistry(deferred, tools) {
61
62
  }
62
63
  return reg;
63
64
  }
65
+ function renderSchemaForModel(schema) {
66
+ let json;
67
+ try {
68
+ json = JSON.stringify(schema);
69
+ }
70
+ catch {
71
+ return undefined;
72
+ }
73
+ return json === undefined ? undefined : truncateError(json);
74
+ }
64
75
  export function createPlaceholderTool(info, direct) {
65
76
  const sn = safeName(info.name);
66
77
  const teachingRejection = () => {
67
78
  throw new Error(`Tool "${sn}" is not active yet. Call ${TOOL_SEARCH_NAME} with {"query":"select:${sn}"} ` +
68
79
  `(or a keyword \`query\`) to load its full schema, then call ${sn} with the proper arguments.`);
69
80
  };
81
+ const invalidArgumentsRejection = (target, params, schemaJson, ride) => {
82
+ const text = `Invalid arguments for \`${sn}\`: ${formatZodValidationError(target.parameters, params)} ` +
83
+ `\`${sn}\` is now active — its full parameter schema is below (and rides the next request). ` +
84
+ `Call \`${sn}\` again with arguments matching it.\nParameter schema: ${schemaJson}`;
85
+ return {
86
+ content: ride === undefined || ride === "" ? [{ type: "text", text }] : [{ type: "text", text }, { type: "text", text: ride }],
87
+ details: { invalidArguments: true },
88
+ isError: true,
89
+ };
90
+ };
70
91
  if (direct !== undefined) {
71
92
  return {
72
93
  name: info.name,
@@ -78,14 +99,20 @@ export function createPlaceholderTool(info, direct) {
78
99
  ...(direct.executionMode !== undefined ? { executionMode: direct.executionMode } : {}),
79
100
  execute: async (toolCallId, params, signal, onUpdate) => {
80
101
  const real = direct.resolveReal();
81
- if (real !== undefined && Value.Check(real.parameters, params)) {
102
+ if (real === undefined)
103
+ return teachingRejection();
104
+ if (Value.Check(real.parameters, params)) {
82
105
  const ride = await direct.activate();
83
106
  const result = await real.invoke(toolCallId, params, signal, onUpdate);
84
107
  if (ride === undefined || ride === "")
85
108
  return result;
86
109
  return { ...result, content: [...result.content, { type: "text", text: ride }] };
87
110
  }
88
- return teachingRejection();
111
+ const schemaJson = renderSchemaForModel(real.parameters);
112
+ if (schemaJson === undefined)
113
+ return teachingRejection();
114
+ const ride = await direct.activate();
115
+ return invalidArgumentsRejection(real, params, schemaJson, ride);
89
116
  },
90
117
  };
91
118
  }
@@ -1,2 +1,10 @@
1
+ import type { RedactionConfidence, RedactionSource } from "./arg-summary.js";
2
+ export type SecretEnvFindingKind = "suffix-rule" | "exact-name";
3
+ export interface SecretEnvFinding {
4
+ key: string;
5
+ kind: SecretEnvFindingKind;
6
+ confidence: RedactionConfidence;
7
+ source: RedactionSource;
8
+ }
1
9
  export declare function isSecretEnvKey(key: string): boolean;
2
- export declare function scrubSecretEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
10
+ export declare function scrubSecretEnv(env: NodeJS.ProcessEnv, findings?: SecretEnvFinding[]): NodeJS.ProcessEnv;
@@ -4,14 +4,31 @@ const SECRET_ENV_EXACT_NAMES = new Set([
4
4
  "PGPASSWORD", "MYSQL_PWD", "SSHPASS", "GPG_PASSPHRASE", "GH_PAT", "GITHUB_PAT",
5
5
  "NPM_CONFIG__AUTH", "NPM_CONFIG__AUTHTOKEN",
6
6
  ]);
7
+ const SECRET_ENV_KIND_CONFIDENCE = {
8
+ "exact-name": "high",
9
+ "suffix-rule": "medium",
10
+ };
11
+ function classifySecretEnvKey(key) {
12
+ if (SECRET_ENV_RE.test(key))
13
+ return "suffix-rule";
14
+ if (SECRET_ENV_EXACT_NAMES.has(key.toUpperCase()))
15
+ return "exact-name";
16
+ return undefined;
17
+ }
7
18
  export function isSecretEnvKey(key) {
8
- return SECRET_ENV_RE.test(key) || SECRET_ENV_EXACT_NAMES.has(key.toUpperCase());
19
+ return classifySecretEnvKey(key) !== undefined;
9
20
  }
10
- export function scrubSecretEnv(env) {
21
+ export function scrubSecretEnv(env, findings) {
11
22
  const out = {};
12
23
  for (const k of Object.keys(env)) {
13
- if (!isSecretEnvKey(k))
24
+ const kind = classifySecretEnvKey(k);
25
+ if (kind === undefined) {
14
26
  out[k] = env[k];
27
+ continue;
28
+ }
29
+ if (findings !== undefined) {
30
+ findings.push({ key: k, kind, confidence: SECRET_ENV_KIND_CONFIDENCE[kind], source: "secret-env" });
31
+ }
15
32
  }
16
33
  return out;
17
34
  }
@@ -1,5 +1,6 @@
1
1
  import { type RedactionReport } from "./arg-summary.js";
2
- export type { RedactionFinding, RedactionReport, RedactionConfidence } from "./arg-summary.js";
2
+ export type { RedactionFinding, RedactionReport, RedactionConfidence, RedactionSource, SummarizableFinding, } from "./arg-summary.js";
3
+ export { summarizeRedactions } from "./arg-summary.js";
3
4
  export declare function redactSecrets(s: string, report?: RedactionReport): string;
4
5
  export declare function redactHostLeaks(s: string, report?: RedactionReport): string;
5
6
  export declare function boundedRedactedSummary(value: unknown, max: number): string;
@@ -1,6 +1,7 @@
1
1
  import { delimitUntrusted } from "./untrusted-text.js";
2
2
  import { scrubSecrets, SECRET_PASSES, runRedactionPasses } from "./arg-summary.js";
3
3
  import { sliceHeadSafe } from "./surrogate-safe-slice.js";
4
+ export { summarizeRedactions } from "./arg-summary.js";
4
5
  const URI_SCHEME = String.raw `[A-Za-z][A-Za-z0-9+.\-_]{0,63}`;
5
6
  const CREDENTIALS_IN_AUTHORITY = new RegExp(String.raw `(${URI_SCHEME}:\/\/)((?:[^/\s"'@]*@)+)`, "gi");
6
7
  const URL_OF_ANY_PROTOCOL = new RegExp(String.raw `${URI_SCHEME}:\/\/[^\s"'\`]+`, "gi");
@@ -21,18 +22,19 @@ const SECRET_TIER_PASSES = [
21
22
  kind: "url-credentials",
22
23
  confidence: "medium",
23
24
  marker: "[redacted-credentials]",
25
+ source: "untrusted-egress",
24
26
  re: CREDENTIALS_IN_AUTHORITY,
25
27
  replace: (_m, scheme, userinfo) => redactUserinfo(scheme, userinfo),
26
28
  },
27
- { kind: "signed-url-param", confidence: "high", marker: "[redacted]", re: /([?&#](?:sig|signature|sas|code)=)[^&\s"'#]+/gi, replace: "$1[redacted]" },
29
+ { kind: "signed-url-param", confidence: "high", marker: "[redacted]", source: "untrusted-egress", re: /([?&#](?:sig|signature|sas|code)=)[^&\s"'#]+/gi, replace: "$1[redacted]" },
28
30
  ];
29
31
  export function redactSecrets(s, report) {
30
32
  return runRedactionPasses(s, SECRET_TIER_PASSES, report);
31
33
  }
32
34
  const HOST_LEAK_PASSES = [
33
35
  ...SECRET_TIER_PASSES,
34
- { kind: "url", confidence: "medium", marker: "[redacted-url]", re: URL_OF_ANY_PROTOCOL, replace: "[redacted-url]" },
35
- { kind: "fs-path", confidence: "low", marker: "[redacted-path]", re: /(?:\/[A-Za-z0-9_.-]+){2,}/g, replace: "[redacted-path]" },
36
+ { kind: "url", confidence: "medium", marker: "[redacted-url]", source: "untrusted-egress", re: URL_OF_ANY_PROTOCOL, replace: "[redacted-url]" },
37
+ { kind: "fs-path", confidence: "low", marker: "[redacted-path]", source: "untrusted-egress", re: /(?:\/[A-Za-z0-9_.-]+){2,}/g, replace: "[redacted-path]" },
36
38
  ];
37
39
  export function redactHostLeaks(s, report) {
38
40
  return runRedactionPasses(s, HOST_LEAK_PASSES, report);
package/dist/index.d.ts CHANGED
@@ -57,6 +57,7 @@ export { killProcessTree, signalProcessTree } from "./engine/execution-env/kill-
57
57
  export type { KillProcessTreeOptions } from "./engine/execution-env/kill-tree.js";
58
58
  export { getShellConfig, isWslBashLauncher } from "./engine/execution-env/node-execution-env.js";
59
59
  export { isSecretEnvKey, scrubSecretEnv } from "./core/secret-env.js";
60
+ export type { SecretEnvFinding, SecretEnvFindingKind } from "./core/secret-env.js";
60
61
  export { MAX_EXEC_OUTPUT_BYTES, RollingTailBuffer, markTruncated } from "./core/exec-output-tail.js";
61
62
  export type { ExecutionEnv, FileInfo, Result, FileErrorCode, ExecutionErrorCode } from "./internal/harness.js";
62
63
  export type { ExecResult } from "./internal/harness.js";
@@ -172,7 +173,8 @@ export { FileBackgroundAgentStore, type FileBackgroundAgentStoreOptions } from "
172
173
  export { A2A_TASK_STATES, type A2ATaskState, type A2ATaskStateReversal, type A2ATaskStateReversalFaithful, type A2ATaskStateReversalLossy, toA2ATaskState, fromA2ATaskState, } from "./core/a2a-task-state.js";
173
174
  export { type WorkflowJournalStore, type WorkflowJournalEntry, InMemoryWorkflowJournalStore, callKeyOrdinal, JOURNAL_OVERSIZE_ERROR_CODE, } from "./core/workflow-journal-store.js";
174
175
  export { untrustedEgressForHuman, redactHostLeaks, redactSecrets, boundedRedactedSummary } from "./core/untrusted-egress.js";
175
- export type { RedactionFinding, RedactionReport, RedactionConfidence } from "./core/untrusted-egress.js";
176
+ export type { RedactionFinding, RedactionReport, RedactionConfidence, RedactionSource, SummarizableFinding, } from "./core/untrusted-egress.js";
177
+ export { summarizeRedactions } from "./core/untrusted-egress.js";
176
178
  export { MemoryRosterStore, FileRosterStore, type RosterStore, type RosterEntry, type RosterAccess, type RosterGcOptions } from "./agents/roster-store.js";
177
179
  export { CONFIG_CATALOG_VERSION, describeConfigCatalog, resolveEffectiveConfig, type ConfigKnob, type ConfigOverrideDeclaration, type ConfigProvenance, type EffectiveConfigField, } from "./config/catalog.js";
178
180
  export { normalizeAgentName } from "./core/task-registry.js";
package/dist/index.js CHANGED
@@ -157,6 +157,7 @@ export { FileBackgroundAgentStore } from "./stores/file/background-agent-store.j
157
157
  export { A2A_TASK_STATES, toA2ATaskState, fromA2ATaskState, } from "./core/a2a-task-state.js";
158
158
  export { InMemoryWorkflowJournalStore, callKeyOrdinal, JOURNAL_OVERSIZE_ERROR_CODE, } from "./core/workflow-journal-store.js";
159
159
  export { untrustedEgressForHuman, redactHostLeaks, redactSecrets, boundedRedactedSummary } from "./core/untrusted-egress.js";
160
+ export { summarizeRedactions } from "./core/untrusted-egress.js";
160
161
  export { MemoryRosterStore, FileRosterStore } from "./agents/roster-store.js";
161
162
  export { CONFIG_CATALOG_VERSION, describeConfigCatalog, resolveEffectiveConfig, } from "./config/catalog.js";
162
163
  export { normalizeAgentName } from "./core/task-registry.js";
@@ -1,6 +1,6 @@
1
1
  import { Type } from "typebox";
2
2
  import { defineTool, errorResult } from "../../core/tools.js";
3
- import { sha256, resolveKey, violationText, violationDetails, hasBinaryExtension, isBinaryContent, fileArgPath, imageMimeForRead, imageMagicMatches, } from "./safety.js";
3
+ import { sha256, resolveKey, violationText, violationDetails, binaryMagicFormat, hasBinaryExtension, isBinaryContent, fileArgPath, imageMimeForRead, imageMagicMatches, } from "./safety.js";
4
4
  import { decodeTextBytes } from "./encoding.js";
5
5
  import { isNotebookPath, parseNotebookCells, renderNotebookCells, stripNotebookImageData, NOTEBOOK_IMAGE_BASE64_BUDGET } from "./notebook.js";
6
6
  import { MCP_IMAGE_MAX_BASE64, IMAGE_TARGET_RAW_SIZE } from "../../core/mcp.js";
@@ -166,6 +166,11 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
166
166
  if (pdfMagicMatches(readBin.value)) {
167
167
  return pdfResultToToolReturn(await readPdfFile(env, path, r.key, pages, ctx.signal, imageDownsampler, cwdRef?.current ?? rootCanonical, readBin.value, pdfCapabilities));
168
168
  }
169
+ const magicFormat = binaryMagicFormat(readBin.value);
170
+ if (magicFormat !== undefined) {
171
+ return errorResult(`Error (Read): "${path}" appears to be a binary file (${magicFormat}, identified by its file signature — whatever its extension says); ` +
172
+ `this tool reads UTF-8 and BOM-marked UTF-16LE text only. Inspect or convert it with bash instead.`);
173
+ }
169
174
  const decoded = decodeTextBytes(readBin.value);
170
175
  if (decoded.malformed) {
171
176
  return errorResult(`Error (Read): "${path}" has a UTF-16 BOM but a truncated (odd-length) body — the file is corrupt or mis-labelled; repair/convert it with bash (e.g. \`iconv\`) first.`);
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { Type } from "typebox";
3
3
  import { defineTool, errorResult } from "../../core/tools.js";
4
- import { sha256, resolveKey, violationText, violationDetails, requireRead, checkStale, checkEditMatch, checkNoChange, fileArgPath, resolveQuoteMatch, adaptNewStringQuotes, resolveEscapeMatch, adaptNewStringEscapes, escapeMatchWasAttempted, ESCAPE_MATCH_MISS_NOTE, deletionOldString, countOccurrences, WRITE_ENCODING_DEADLOCK_ESCAPE_HINT, } from "./safety.js";
4
+ import { sha256, resolveKey, violationText, violationDetails, requireRead, checkStale, checkEditMatch, checkNoChange, fileArgPath, resolveQuoteMatch, adaptNewStringQuotes, resolveEscapeMatch, adaptNewStringEscapes, escapeMatchWasAttempted, ESCAPE_MATCH_MISS_NOTE, deletionOldString, countOccurrences, READ_REFUSED_ESCAPE_HINT, } from "./safety.js";
5
5
  import { decodeTextBytes, encodeTextForFile, normalizeEditText, normalizeFileText } from "./encoding.js";
6
6
  import { MAX_EDIT_BYTES, formatByteSize, decodeEditBytes, persistedTextOf, notReadRefusalText, enoentMessage, FILE_STATE_TRAILER, FILE_PATH_PARAMS, ipynbRedirect, countLines, } from "./fs-shared.js";
7
7
  async function gateToolWrite(hook, tool, path, key, content) {
@@ -136,7 +136,7 @@ export function createEditFileTool(env, state, rootCanonical, cwdRef, additional
136
136
  return errorResult(ipynb);
137
137
  const notRead = requireRead(state, r.key);
138
138
  if (notRead)
139
- return errorResult(await notReadRefusalText(env, "Edit", r.key, notRead, ctx.signal));
139
+ return errorResult(await notReadRefusalText(env, "Edit", r.key, notRead, ctx.signal, READ_REFUSED_ESCAPE_HINT));
140
140
  const readBin = await env.readBinaryFile(r.key, ctx.signal);
141
141
  if (!readBin.ok)
142
142
  return errorResult(`Error (Edit): cannot read "${path}": ${readBin.error.message}`);
@@ -249,7 +249,7 @@ export function createWriteFileTool(env, state, rootCanonical, cwdRef, additiona
249
249
  if (exists.value) {
250
250
  const notRead = requireRead(state, r.key);
251
251
  if (notRead) {
252
- return errorResult(await notReadRefusalText(env, "Write", r.key, notRead, ctx.signal, WRITE_ENCODING_DEADLOCK_ESCAPE_HINT));
252
+ return errorResult(await notReadRefusalText(env, "Write", r.key, notRead, ctx.signal, READ_REFUSED_ESCAPE_HINT));
253
253
  }
254
254
  const readBin = await env.readBinaryFile(r.key, ctx.signal);
255
255
  if (!readBin.ok)
@@ -334,7 +334,7 @@ export function createNotebookEditTool(env, state, rootCanonical, cwdRef, additi
334
334
  return errorResult(violationText("NotebookEdit", r.violation), violationDetails(r.violation));
335
335
  const notRead = requireRead(state, r.key);
336
336
  if (notRead)
337
- return errorResult(await notReadRefusalText(env, "NotebookEdit", r.key, notRead, ctx.signal));
337
+ return errorResult(await notReadRefusalText(env, "NotebookEdit", r.key, notRead, ctx.signal, READ_REFUSED_ESCAPE_HINT));
338
338
  const readBin = await env.readBinaryFile(r.key, ctx.signal);
339
339
  if (!readBin.ok)
340
340
  return errorResult(`Error (NotebookEdit): cannot read "${notebook_path}": ${readBin.error.message}`);
@@ -30,6 +30,16 @@ export declare function foldRedundantPathSegments(p: string): string;
30
30
  export declare function hasBinaryExtension(path: string): boolean;
31
31
  export declare function imageMimeForRead(path: string): string | undefined;
32
32
  export declare function imageMagicMatches(bytes: Uint8Array, mimeType: string): boolean;
33
+ export interface BinaryMagicSegment {
34
+ readonly offset: number;
35
+ readonly bytes: readonly number[];
36
+ }
37
+ export interface BinaryMagicSignature {
38
+ readonly format: string;
39
+ readonly segments: readonly BinaryMagicSegment[];
40
+ }
41
+ export declare const BINARY_MAGIC_SIGNATURES: readonly BinaryMagicSignature[];
42
+ export declare function binaryMagicFormat(bytes: Uint8Array): string | undefined;
33
43
  export declare function isBinaryContent(sample: string): boolean;
34
44
  export declare function isUncPath(path: string): boolean;
35
45
  export declare function resolveKey(env: ExecutionEnv, rootCanonical: string, path: string, signal?: AbortSignal, baseCwd?: string, additionalRootsCanonical?: readonly string[], exactFileReadExemption?: (canonicalKey: string) => boolean): Promise<{
@@ -59,7 +69,7 @@ export declare const PATH_NOT_IN_ROOT_ESCAPE_HINT = "(This boundary applies to t
59
69
  export declare function requireRead(state: ReadFileState, key: string): FsViolation | undefined;
60
70
  export declare const OVERSIZE_READ_ESCAPE_HINT = "(This file is over the Read tool's whole-file byte cap, so a default Read is refused \u2014 read it in slices with explicit offset/limit to satisfy the read-first rule, or inspect/transform it with bash (e.g. `sed -n`, `grep`) instead.)";
61
71
  export declare const PARTIAL_VIEW_READ_ESCAPE_HINT = "(Your last Read of this file returned only a PARTIAL view \u2014 the output token cap paginated it, so a default Read will keep returning the same page. Re-read it with explicit offset/limit (start from the page marker's next-page hint) until you have seen the part you are about to change; an explicit slice that fits satisfies the read-first rule. Or inspect/transform it with bash (e.g. `sed -n`, `grep`) instead.)";
62
- export declare const WRITE_ENCODING_DEADLOCK_ESCAPE_HINT = "(If the Read tool refuses this file (binary/unknown encoding), overwrite or convert it with bash instead \u2014 e.g. `rm` + rewrite, or `iconv`.)";
72
+ export declare const READ_REFUSED_ESCAPE_HINT = "(If the Read tool refuses this file (binary/unknown encoding), overwrite or convert it with bash instead \u2014 e.g. `rm` + rewrite, or `iconv`.)";
63
73
  export declare function checkNoChange(oldString: string, newString: string): FsViolation | undefined;
64
74
  export declare function checkStale(entry: ReadEntry | undefined, currentHash: string): FsViolation | undefined;
65
75
  export declare function countOccurrences(haystack: string, needle: string): number;
@@ -150,6 +150,89 @@ export function imageMagicMatches(bytes, mimeType) {
150
150
  return false;
151
151
  }
152
152
  }
153
+ const asciiBytes = (s) => Array.from(s, (c) => c.charCodeAt(0));
154
+ export const BINARY_MAGIC_SIGNATURES = [
155
+ { format: "ZIP archive", segments: [{ offset: 0, bytes: [0x50, 0x4b, 0x03, 0x04] }] },
156
+ { format: "ZIP archive", segments: [{ offset: 0, bytes: [0x50, 0x4b, 0x05, 0x06] }] },
157
+ { format: "ZIP archive", segments: [{ offset: 0, bytes: [0x50, 0x4b, 0x07, 0x08] }] },
158
+ { format: "gzip archive", segments: [{ offset: 0, bytes: [0x1f, 0x8b, 0x08] }] },
159
+ ...[0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39].map((level) => ({
160
+ format: "bzip2 archive",
161
+ segments: [{ offset: 0, bytes: [0x42, 0x5a, 0x68, level] }],
162
+ })),
163
+ { format: "xz archive", segments: [{ offset: 0, bytes: [0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00] }] },
164
+ { format: "zstd archive", segments: [{ offset: 0, bytes: [0x28, 0xb5, 0x2f, 0xfd] }] },
165
+ { format: "lz4 archive", segments: [{ offset: 0, bytes: [0x04, 0x22, 0x4d, 0x18] }] },
166
+ { format: "compress archive", segments: [{ offset: 0, bytes: [0x1f, 0x9d] }] },
167
+ { format: "7-Zip archive", segments: [{ offset: 0, bytes: [0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c] }] },
168
+ { format: "RAR archive", segments: [{ offset: 0, bytes: [0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x00] }] },
169
+ { format: "RAR archive", segments: [{ offset: 0, bytes: [0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00] }] },
170
+ { format: "tar archive", segments: [{ offset: 257, bytes: asciiBytes("ustar") }] },
171
+ { format: "ar archive (static library or Debian package)", segments: [{ offset: 0, bytes: asciiBytes("!<arch>\n") }] },
172
+ { format: "RPM package", segments: [{ offset: 0, bytes: [0xed, 0xab, 0xee, 0xdb] }] },
173
+ { format: "ISO 9660 disc image", segments: [{ offset: 32769, bytes: asciiBytes("CD001") }] },
174
+ { format: "PNG image", segments: [{ offset: 0, bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] }] },
175
+ { format: "JPEG image", segments: [{ offset: 0, bytes: [0xff, 0xd8, 0xff] }] },
176
+ { format: "GIF image", segments: [{ offset: 0, bytes: asciiBytes("GIF87a") }] },
177
+ { format: "GIF image", segments: [{ offset: 0, bytes: asciiBytes("GIF89a") }] },
178
+ { format: "TIFF image", segments: [{ offset: 0, bytes: [0x49, 0x49, 0x2a, 0x00] }] },
179
+ { format: "TIFF image", segments: [{ offset: 0, bytes: [0x4d, 0x4d, 0x00, 0x2a] }] },
180
+ { format: "BMP image", segments: [{ offset: 0, bytes: asciiBytes("BM") }, { offset: 6, bytes: [0x00, 0x00, 0x00, 0x00] }] },
181
+ { format: "ICO image", segments: [{ offset: 0, bytes: [0x00, 0x00, 0x01, 0x00] }] },
182
+ { format: "Photoshop document", segments: [{ offset: 0, bytes: asciiBytes("8BPS") }] },
183
+ { format: "WebP image", segments: [{ offset: 0, bytes: asciiBytes("RIFF") }, { offset: 8, bytes: asciiBytes("WEBP") }] },
184
+ { format: "RIFF audio (WAV)", segments: [{ offset: 0, bytes: asciiBytes("RIFF") }, { offset: 8, bytes: asciiBytes("WAVE") }] },
185
+ { format: "RIFF video (AVI)", segments: [{ offset: 0, bytes: asciiBytes("RIFF") }, { offset: 8, bytes: asciiBytes("AVI ") }] },
186
+ { format: "AIFF audio", segments: [{ offset: 0, bytes: asciiBytes("FORM") }, { offset: 8, bytes: asciiBytes("AIFF") }] },
187
+ { format: "Ogg container", segments: [{ offset: 0, bytes: asciiBytes("OggS") }, { offset: 4, bytes: [0x00] }] },
188
+ { format: "FLAC audio", segments: [{ offset: 0, bytes: asciiBytes("fLaC") }] },
189
+ ...[0x02, 0x03, 0x04].map((major) => ({
190
+ format: "MP3 audio (ID3 tag)",
191
+ segments: [{ offset: 0, bytes: [0x49, 0x44, 0x33, major] }],
192
+ })),
193
+ { format: "ISO base media container (MP4/MOV/HEIC)", segments: [{ offset: 4, bytes: asciiBytes("ftyp") }] },
194
+ { format: "Matroska/WebM container", segments: [{ offset: 0, bytes: [0x1a, 0x45, 0xdf, 0xa3] }] },
195
+ { format: "ASF container (WMV/WMA)", segments: [{ offset: 0, bytes: [0x30, 0x26, 0xb2, 0x75, 0x8e, 0x66, 0xcf, 0x11, 0xa6, 0xd9, 0x00, 0xaa, 0x00, 0x62, 0xce, 0x6c] }] },
196
+ { format: "FLV video", segments: [{ offset: 0, bytes: [0x46, 0x4c, 0x56, 0x01] }] },
197
+ { format: "MPEG program stream", segments: [{ offset: 0, bytes: [0x00, 0x00, 0x01, 0xba] }] },
198
+ { format: "MPEG video stream", segments: [{ offset: 0, bytes: [0x00, 0x00, 0x01, 0xb3] }] },
199
+ { format: "ELF binary", segments: [{ offset: 0, bytes: [0x7f, 0x45, 0x4c, 0x46] }] },
200
+ { format: "Mach-O binary", segments: [{ offset: 0, bytes: [0xfe, 0xed, 0xfa, 0xce] }] },
201
+ { format: "Mach-O binary", segments: [{ offset: 0, bytes: [0xfe, 0xed, 0xfa, 0xcf] }] },
202
+ { format: "Mach-O binary", segments: [{ offset: 0, bytes: [0xce, 0xfa, 0xed, 0xfe] }] },
203
+ { format: "Mach-O binary", segments: [{ offset: 0, bytes: [0xcf, 0xfa, 0xed, 0xfe] }] },
204
+ { format: "Java class file or Mach-O universal binary", segments: [{ offset: 0, bytes: [0xca, 0xfe, 0xba, 0xbe] }] },
205
+ { format: "WebAssembly module", segments: [{ offset: 0, bytes: [0x00, 0x61, 0x73, 0x6d] }] },
206
+ { format: "OLE compound document (Office 97-2003 or MSI)", segments: [{ offset: 0, bytes: [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1] }] },
207
+ { format: "SQLite database", segments: [{ offset: 0, bytes: [...asciiBytes("SQLite format 3"), 0x00] }] },
208
+ { format: "WOFF font", segments: [{ offset: 0, bytes: asciiBytes("wOFF") }] },
209
+ { format: "WOFF2 font", segments: [{ offset: 0, bytes: asciiBytes("wOF2") }] },
210
+ { format: "OpenType font", segments: [{ offset: 0, bytes: asciiBytes("OTTO") }] },
211
+ { format: "TrueType font", segments: [{ offset: 0, bytes: [0x00, 0x01, 0x00, 0x00] }] },
212
+ { format: "TrueType font collection", segments: [{ offset: 0, bytes: asciiBytes("ttcf") }] },
213
+ ];
214
+ export function binaryMagicFormat(bytes) {
215
+ for (const signature of BINARY_MAGIC_SIGNATURES) {
216
+ let matched = true;
217
+ for (const segment of signature.segments) {
218
+ if (bytes.length < segment.offset + segment.bytes.length) {
219
+ matched = false;
220
+ break;
221
+ }
222
+ for (let i = 0; i < segment.bytes.length; i++) {
223
+ if (bytes[segment.offset + i] !== segment.bytes[i]) {
224
+ matched = false;
225
+ break;
226
+ }
227
+ }
228
+ if (!matched)
229
+ break;
230
+ }
231
+ if (matched)
232
+ return signature.format;
233
+ }
234
+ return undefined;
235
+ }
153
236
  export function isBinaryContent(sample) {
154
237
  if (sample.length === 0)
155
238
  return false;
@@ -297,7 +380,7 @@ export function requireRead(state, key) {
297
380
  }
298
381
  export const OVERSIZE_READ_ESCAPE_HINT = "(This file is over the Read tool's whole-file byte cap, so a default Read is refused — read it in slices with explicit offset/limit to satisfy the read-first rule, or inspect/transform it with bash (e.g. `sed -n`, `grep`) instead.)";
299
382
  export const PARTIAL_VIEW_READ_ESCAPE_HINT = "(Your last Read of this file returned only a PARTIAL view — the output token cap paginated it, so a default Read will keep returning the same page. Re-read it with explicit offset/limit (start from the page marker's next-page hint) until you have seen the part you are about to change; an explicit slice that fits satisfies the read-first rule. Or inspect/transform it with bash (e.g. `sed -n`, `grep`) instead.)";
300
- export const WRITE_ENCODING_DEADLOCK_ESCAPE_HINT = "(If the Read tool refuses this file (binary/unknown encoding), overwrite or convert it with bash instead — e.g. `rm` + rewrite, or `iconv`.)";
383
+ export const READ_REFUSED_ESCAPE_HINT = "(If the Read tool refuses this file (binary/unknown encoding), overwrite or convert it with bash instead — e.g. `rm` + rewrite, or `iconv`.)";
301
384
  export function checkNoChange(oldString, newString) {
302
385
  if (oldString === newString) {
303
386
  return { code: "invalid", message: "No changes to make: old_string and new_string are exactly the same." };
@@ -27,9 +27,12 @@ The runtime clamps to [60, 3600], so you don't need to clamp yourself.
27
27
 
28
28
  One short sentence on what you chose and why. Goes to telemetry and is shown back to the user. "watching CI run" beats "waiting." The user reads this to understand what you're doing without having to predict your cadence in advance — make it specific.
29
29
  `;
30
- function toSchedulerContext(c) {
30
+ function durableScopeOf(c) {
31
+ return c.principal ?? "default";
32
+ }
33
+ function toSchedulerContext(c, scope) {
31
34
  return {
32
- scope: c.scope,
35
+ scope,
33
36
  ...(c.principal !== undefined ? { principal: c.principal } : {}),
34
37
  ...(c.sessionId !== undefined ? { sessionId: c.sessionId } : {}),
35
38
  ...(c.taskConfig !== undefined ? { taskConfig: c.taskConfig } : {}),
@@ -157,7 +160,9 @@ export function createSchedulerTools(env, ctx) {
157
160
  if (!hasScheduler(env))
158
161
  return [];
159
162
  const sched = env;
160
- const schedCtx = toSchedulerContext(ctx);
163
+ const sessionCtx = toSchedulerContext(ctx, ctx.scope);
164
+ const durableCtx = toSchedulerContext(ctx, durableScopeOf(ctx));
165
+ const lanesDiffer = sessionCtx.scope !== durableCtx.scope;
161
166
  let cronCreateChain = Promise.resolve();
162
167
  const serializedCronCreateOp = (fn) => {
163
168
  const next = cronCreateChain.then(fn, fn);
@@ -255,9 +260,10 @@ Only use minute 0 or 30 when the user names that exact time and clearly means it
255
260
  ...(!durable ? { lifetime: "session" } : {}),
256
261
  ...(a.recurring !== undefined ? { recurring: a.recurring } : {}),
257
262
  };
258
- const before = await sched.list(schedCtx);
263
+ const opCtx = durable ? durableCtx : sessionCtx;
264
+ const before = await sched.list(opCtx);
259
265
  const knownIds = before.ok ? new Set(before.value.map((s) => s.id)) : undefined;
260
- const r = await sched.schedule(intent, schedCtx);
266
+ const r = await sched.schedule(intent, opCtx);
261
267
  if (!r.ok)
262
268
  return errorResult(`Error (CronCreate): ${r.error.message}`);
263
269
  const replaced = knownIds?.has(r.value.id);
@@ -285,6 +291,16 @@ Only use minute 0 or 30 when the user names that exact time and clearly means it
285
291
  };
286
292
  }),
287
293
  });
294
+ const cancelLanesFor = async (id) => {
295
+ if (!lanesDiffer)
296
+ return [sessionCtx];
297
+ for (const lane of [sessionCtx, durableCtx]) {
298
+ const listed = await sched.list(lane);
299
+ if (listed.ok && listed.value.some((s) => s.id === id))
300
+ return [lane];
301
+ }
302
+ return [sessionCtx, durableCtx];
303
+ };
288
304
  const cronCancel = defineTool({
289
305
  name: "CronDelete",
290
306
  contract: { contractId: "core.cron_delete@1", implementationRevision: "1" },
@@ -293,7 +309,14 @@ Only use minute 0 or 30 when the user names that exact time and clearly means it
293
309
  effect: "idempotent",
294
310
  execute: async (args) => {
295
311
  const { id } = args;
296
- const r = await sched.cancel(id, schedCtx);
312
+ const taskId = id;
313
+ const [firstLane, ...otherLanes] = await cancelLanesFor(taskId);
314
+ let r = await sched.cancel(taskId, firstLane);
315
+ for (const lane of otherLanes) {
316
+ if (r.ok || r.error.code !== "not_found")
317
+ break;
318
+ r = await sched.cancel(taskId, lane);
319
+ }
297
320
  if (!r.ok) {
298
321
  if (r.error.code === "not_found")
299
322
  return `CronDelete: no scheduled task "${id}" (already gone or not yours).`;
@@ -309,13 +332,23 @@ Only use minute 0 or 30 when the user names that exact time and clearly means it
309
332
  parameters: Type.Object({}),
310
333
  effect: "read",
311
334
  execute: async () => {
312
- const r = await sched.list(schedCtx);
313
- if (!r.ok)
314
- return errorResult(`Error (CronList): ${r.error.message}`);
315
- if (r.value.length === 0) {
335
+ const rows = [];
336
+ const seen = new Set();
337
+ for (const lane of lanesDiffer ? [sessionCtx, durableCtx] : [sessionCtx]) {
338
+ const listed = await sched.list(lane);
339
+ if (!listed.ok)
340
+ return errorResult(`Error (CronList): ${listed.error.message}`);
341
+ for (const s of listed.value) {
342
+ if (seen.has(s.id))
343
+ continue;
344
+ seen.add(s.id);
345
+ rows.push(s);
346
+ }
347
+ }
348
+ if (rows.length === 0) {
316
349
  return { content: "No scheduled tasks.", details: { type: "cron-list", jobs: [] } };
317
350
  }
318
- const content = r.value
351
+ const content = rows
319
352
  .map((s) => {
320
353
  const shown = cronExprFromSummary(s.when);
321
354
  const human = cronToHuman(s.when);
@@ -324,7 +357,7 @@ Only use minute 0 or 30 when the user names that exact time and clearly means it
324
357
  return `- ${s.id}: ${shown}${human !== s.when ? ` — ${human}` : ""}${s.label ? ` (${s.label})` : ""}${tier}${once}`;
325
358
  })
326
359
  .join("\n");
327
- const jobs = r.value.map((s) => {
360
+ const jobs = rows.map((s) => {
328
361
  const human = cronToHuman(s.when);
329
362
  return {
330
363
  id: s.id,
@@ -339,7 +372,7 @@ Only use minute 0 or 30 when the user names that exact time and clearly means it
339
372
  },
340
373
  });
341
374
  const listPendingWakeups = async () => {
342
- const r = await sched.list(schedCtx);
375
+ const r = await sched.list(sessionCtx);
343
376
  if (!r.ok)
344
377
  return { err: r.error.message };
345
378
  return {
@@ -351,7 +384,7 @@ Only use minute 0 or 30 when the user names that exact time and clearly means it
351
384
  const cancelWakeups = async (ids) => {
352
385
  let n = 0;
353
386
  for (const id of ids) {
354
- const r = await sched.cancel(id, schedCtx);
387
+ const r = await sched.cancel(id, sessionCtx);
355
388
  if (r.ok)
356
389
  n += 1;
357
390
  }
@@ -405,7 +438,7 @@ Only use minute 0 or 30 when the user names that exact time and clearly means it
405
438
  const pending = await listPendingWakeups();
406
439
  if (!("err" in pending))
407
440
  await cancelWakeups(pending.ids);
408
- const r = await sched.schedule({ prompt: a.prompt, when: { kind: "delay", delaySec: clampedDelaySeconds }, label: WAKEUP_LABEL, mode: "session-wakeup" }, schedCtx);
441
+ const r = await sched.schedule({ prompt: a.prompt, when: { kind: "delay", delaySec: clampedDelaySeconds }, label: WAKEUP_LABEL, mode: "session-wakeup" }, sessionCtx);
409
442
  if (!r.ok)
410
443
  return errorResult(`Error (${SCHEDULE_WAKEUP_TOOL_NAME}): ${r.error.message}`);
411
444
  const scheduledFor = Date.now() + clampedDelaySeconds * 1000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "5.4.0",
3
+ "version": "5.6.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -67,7 +67,7 @@
67
67
  "gate:single-mint": "node scripts/verify-single-mint.mjs"
68
68
  },
69
69
  "dependencies": {
70
- "@modelcontextprotocol/sdk": "1.29.0",
70
+ "@modelcontextprotocol/sdk": "1.30.0",
71
71
  "typebox": "1.1.39"
72
72
  },
73
73
  "optionalDependencies": {