@sema-agent/core 5.26.0 → 5.28.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 (58) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/dist/agents/agent-transcript-tool.d.ts +5 -2
  3. package/dist/agents/agent-transcript-tool.js +2 -1
  4. package/dist/agents/send-message-tool.d.ts +4 -1
  5. package/dist/agents/subagent.d.ts +5 -2
  6. package/dist/core/checkpoint-store.d.ts +7 -2
  7. package/dist/core/hooks.d.ts +61 -4
  8. package/dist/core/hooks.js +37 -15
  9. package/dist/core/memory-engine/engine.d.ts +8 -5
  10. package/dist/core/memory-engine/engine.js +18 -6
  11. package/dist/core/memory-engine/file-backend.d.ts +144 -4
  12. package/dist/core/memory-engine/file-backend.js +304 -36
  13. package/dist/core/memory-engine/layout.d.ts +31 -2
  14. package/dist/core/memory-engine/layout.js +132 -8
  15. package/dist/core/memory-engine/types.d.ts +9 -1
  16. package/dist/core/memory-vector.d.ts +6 -1
  17. package/dist/core/memory-vector.js +14 -4
  18. package/dist/core/memory.js +1 -6
  19. package/dist/core/permission-rule-consent.d.ts +82 -8
  20. package/dist/core/permission-rule-consent.js +92 -1
  21. package/dist/core/permission-rule-model.d.ts +87 -6
  22. package/dist/core/permission-rule-model.js +79 -0
  23. package/dist/core/permission-rule-org.d.ts +22 -3
  24. package/dist/core/permission-rule-org.js +67 -20
  25. package/dist/core/permission-rule-store.js +2 -2
  26. package/dist/core/permission-rule-sync.d.ts +15 -1
  27. package/dist/core/permission-rule-sync.js +89 -47
  28. package/dist/core/runner/prepare-memory.js +14 -9
  29. package/dist/core/runner/prepare-task.d.ts +9 -3
  30. package/dist/core/runner/prepare-task.js +37 -11
  31. package/dist/core/runner/runtask.d.ts +8 -1
  32. package/dist/core/runner/runtask.js +8 -1
  33. package/dist/core/task-registry-agent.d.ts +13 -3
  34. package/dist/core/task-registry-agent.js +51 -21
  35. package/dist/core/task-registry-monitor.js +1 -1
  36. package/dist/core/task-registry-shared.d.ts +9 -0
  37. package/dist/core/task-registry.d.ts +6 -3
  38. package/dist/core/tool-policy.d.ts +44 -4
  39. package/dist/core/tool-policy.js +37 -3
  40. package/dist/core/tool-result-store.d.ts +108 -7
  41. package/dist/core/tool-result-store.js +95 -15
  42. package/dist/core/types.d.ts +115 -17
  43. package/dist/core/types.js +30 -1
  44. package/dist/engine/loop/types.d.ts +10 -3
  45. package/dist/index.d.ts +2 -2
  46. package/dist/index.js +1 -1
  47. package/dist/orchestration/run-workflow-tool.d.ts +5 -3
  48. package/dist/orchestration/workflow.d.ts +9 -6
  49. package/dist/stores/file/checkpoint-store.d.ts +2 -1
  50. package/dist/stores/file/index.d.ts +1 -1
  51. package/dist/stores/file/tool-result-store.d.ts +41 -1
  52. package/dist/stores/file/tool-result-store.js +107 -19
  53. package/dist/tools/fs/fs-bash.d.ts +7 -0
  54. package/dist/tools/fs/fs-shared.d.ts +5 -0
  55. package/dist/tools/fs/fs-shared.js +11 -7
  56. package/dist/tools/fs/index.d.ts +6 -0
  57. package/dist/tools/fs/index.js +2 -0
  58. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -68,7 +68,7 @@ export { deriveInvariants, checkInvariants } from "./core/property-harness.js";
68
68
  export { HAND_TOOL_EFFECTS, bashReversibilityProbe, BASH_READONLY_DEFAULT_ALLOW, parseLeadingCommandName, classifyCompoundReadonly, MAX_EDIT_BYTES } from "./tools/fs/index.js";
69
69
  export { classifyCompoundReadonlyDetailed, formatOutOfRootReadApprovalOption, } from "./tools/fs/index.js";
70
70
  export { resolveBashTimeoutCaps } from "./tools/fs/index.js";
71
- export { InMemoryToolResultStore, OFFLOAD_TOOL_NAME, DEFAULT_TOOL_RESULT_THRESHOLD_CHARS, assertSafeToolResultRef, buildToolResultRef, MAX_MINTED_TOOL_RESULT_REF_CHARS, assertToolResultProvenanceMatch, normalizeToolResultProvenance, toolResultProvenanceOf, ToolResultRefConflictError, TOOL_RESULT_REF_CONFLICT_CODE, } from "./core/tool-result-store.js";
71
+ export { InMemoryToolResultStore, OFFLOAD_TOOL_NAME, DEFAULT_TOOL_RESULT_THRESHOLD_CHARS, assertSafeToolResultRef, buildToolResultRef, toolResultContentSegment, MAX_MINTED_TOOL_RESULT_REF_CHARS, assertToolResultProvenanceMatch, normalizeToolResultProvenance, toolResultProvenanceOf, ToolResultRefConflictError, TOOL_RESULT_REF_CONFLICT_CODE, } from "./core/tool-result-store.js";
72
72
  export { InMemoryCheckpointStore, CheckpointError, mintCheckpointToken, checkpointVersionOf, CURRENT_CHECKPOINT_VERSION, MAX_SUPPORTED_CHECKPOINT_VERSION, ORG_ADMISSION_CHECKPOINT_VERSION, F012_CHECKPOINT_VERSION, REAL_APPROVAL_CHECKPOINT_VERSION, RESOURCE_CHECKPOINT_VERSION, TOKEN_CHECKPOINT_VERSION, debitLedger, remainingBudgetMicroUsd, remainingTokens, winnerFromOutcome, validatePendingSteer, readPendingSteerQueue, appendPendingSteer, MAX_PENDING_STEER_CHARS, MAX_PENDING_STEER_ENTRIES, PENDING_STEER_QUEUE_BYTE_BUDGET_BYTES, PENDING_STEER_FROZEN_FIELDS, ACTOR_ASSERTION_FROZEN_FIELDS, MAX_ACTOR_FIELD_CHARS, MAX_STEER_INPUT_ID_CHARS, LEGACY_PENDING_STEER_INPUT_ID, riskSeverity, buildRiskDescriptor, summarizeCheckpoint, } from "./core/checkpoint-store.js";
73
73
  export { InMemoryUsageWindowStore, GLOBAL_USAGE_KEY, EMPTY_USAGE_WINDOW_RECORD, chargeUsageRecord, readUsageRecord, usageRetryAfterMs, resolveUsageWindows, } from "./core/usage-window-store.js";
74
74
  export { FileUsageWindowStore } from "./stores/file/usage-window-store.js";
@@ -229,9 +229,11 @@ export interface RunWorkflowToolDeps {
229
229
  * has inherited this hook since [893]④a; the workflow lane never did — the governance whitelist
230
230
  * rightly blocks SCRIPTS from setting it, but host inheritance is a different lane. */
231
231
  parentGetApiKeyAndHeaders?: import("../core/types.js").TaskSpec["getApiKeyAndHeaders"];
232
- /** The HOST run's display sink (its `RunInternals.onForwardEvent`, already filtered to `task_progress`
233
- * by the runner's ctx wrapper)threaded via `startWorkflow` into every spawned agent's trusted
234
- * internals so a workflow child's progress ticks bubble to the deployment's one sink, the same
232
+ /** The HOST run's display sink (its `RunInternals.onForwardEvent` behind the runner's ctx wrapper:
233
+ * `task_progress` always, plus the children's content events — `text_delta`/`reasoning_delta`/
234
+ * `tool_start`/`tool_end`, UNTRUSTED-RAW: the consumer must redact when the HOST spec set
235
+ * `forwardSubagentEvents: true`) — threaded via `startWorkflow` into every spawned agent's trusted
236
+ * internals so a workflow child's events bubble to the deployment's one sink, the same
235
237
  * channel a `createSubagentTool` delegation threads. Display-only; absent ⇒ ticks stay in each
236
238
  * child's own stream. (A dep, not read off the execute ctx: the mounted tool's `AgentTool.execute`
237
239
  * wrapper builds a minimal `{toolCallId, signal}` ctx — the rich-ctx injection only wraps
@@ -31,8 +31,9 @@ export declare const WORKFLOW_SUBAGENT_APPEND_SCHEMA = "---\n\nNOTE: You are run
31
31
  * F4 agentType (CC 198 锚 pretty.js:446608-446627): resolve `opts.agentType` against the registry
32
32
  * (deployment SHADOW over built-ins) and fold the definition into the child spec — persona as
33
33
  * `systemPrompt` (so {@link withWorkflowChildPersona} composes the return-contract NOTE via the
34
- * custom-persona APPEND arm = CC `O0m` semantics), model/thinking/maxTurns/skills/memory when the spec
35
- * didn't pin them, and allow/denyTools as a ToolPolicy (combined deny-wins with any spec policy).
34
+ * custom-persona APPEND arm = CC `O0m` semantics), model/thinking/maxTurns/skills/memory/
35
+ * memoryPersistenceCapable when the spec didn't pin them, and allow/denyTools as a ToolPolicy
36
+ * (combined deny-wins with any spec policy).
36
37
  * The definition is DEPLOYMENT-TRUSTED (registry-declared, not script-authored), so its model bypasses
37
38
  * the script-facing modelName allowlist by design — same trust tier as the Agent tool's registry.
38
39
  */
@@ -382,10 +383,12 @@ export interface RunWorkflowOptions {
382
383
  * from ctx): every spawned agent composes the same closure (codex F2a). */
383
384
  parentCenterArtifactDigest?: string;
384
385
  parentCenterSourceRevision?: string;
385
- /** The launching run's display sink (`RunInternals.onForwardEvent`, already filtered by the runner to
386
- * `task_progress`) threaded into every spawned agent's trusted internals so the children's progress
387
- * ticks bubble out of their isolated streams to the deployment's one sink (fleet footer/monitor rows).
388
- * Display-only; absent ticks stay in each child's own stream. */
386
+ /** The launching run's display sink (`RunInternals.onForwardEvent` behind the runner's ctx wrapper:
387
+ * `task_progress` always, PLUS the children's content events `text_delta`/`reasoning_delta`/
388
+ * `tool_start`/`tool_end`, UNTRUSTED-RAW: the consumer must redact when the HOST spec set
389
+ * `forwardSubagentEvents: true`) threaded into every spawned agent's trusted internals so the
390
+ * children's events bubble out of their isolated streams to the deployment's one sink (fleet
391
+ * footer/monitor rows). Display-only; absent ⇒ ticks stay in each child's own stream. */
389
392
  onForwardEvent?: (event: TaskEvent) => void;
390
393
  /** Parent effective-policy inheritance (tighten-only): the HOST task's evaluated gate chain
391
394
  * (`ToolExecuteContext.inheritedGateForChildren()` — its session rules + toolPolicy/frozen onAsk +
@@ -1,7 +1,8 @@
1
1
  import { type Checkpoint, type PendingSteerInput, type CheckpointFaultMode, type CheckpointStore, type CheckpointSummary, type CheckpointToken, type ReopenReason, type ResolveExpectation, type ResumeOutcome } from "../../core/checkpoint-store.js";
2
2
  export interface FileCheckpointStoreOptions {
3
3
  /** When false, an `appendLine` for a state transition is NOT fsync'd. The checkpoint COMMIT POINT always
4
- * fsyncs regardless (its crash-safety depends on it); this only affects whether `put` fsyncs. Default true. */
4
+ * fsyncs regardless (its crash-safety depends on it); this governs the `put` and `setPendingSteer`
5
+ * (steer-append) commits only — `resolve`/`reopen`/`expire` fsync unconditionally. Default true. */
5
6
  fsync?: boolean;
6
7
  /** Compact the ledger into a snapshot once it exceeds this many events (then truncate). Default 1000. */
7
8
  compactEvery?: number;
@@ -52,7 +52,7 @@ export interface FileStorageBackendOptions {
52
52
  /** `TtlSessionStore` idle-eviction policy. Default `"forget"` (§7 decision 5 — durable history is never
53
53
  * deleted by an idle timer). */
54
54
  evict?: EvictPolicy;
55
- /** Checkpoint store tuning (fsync cadence for `put`, ledger compaction threshold). */
55
+ /** Checkpoint store tuning (fsync cadence for `put`/`setPendingSteer`, ledger compaction threshold). */
56
56
  checkpoint?: FileCheckpointStoreOptions;
57
57
  /** design/101 §E19 — file-snapshot enumerator bounds (maxFiles/maxBytes/ignoreDirs). Default
58
58
  * {@link DEFAULT_SNAPSHOT_BOUNDS}. */
@@ -1,4 +1,4 @@
1
- import type { ToolResultProvenance, ToolResultSlice, ToolResultStore } from "../../core/tool-result-store.js";
1
+ import type { ToolResultDeletionReport, ToolResultProvenance, ToolResultSlice, ToolResultStore } from "../../core/tool-result-store.js";
2
2
  /**
3
3
  * design/80 §2.5 — file {@link ToolResultStore}: write-once-idempotent, ONE file per ref.
4
4
  *
@@ -55,4 +55,44 @@ export declare class FileToolResultStore implements ToolResultStore {
55
55
  offset?: number;
56
56
  limit?: number;
57
57
  }): ToolResultSlice | undefined;
58
+ /**
59
+ * Erase every entry owned by `sessionId` (interface: {@link ToolResultStore.deleteBySession}). Without
60
+ * it a session deletion leaves this store's files on disk for the lifetime of the data root.
61
+ *
62
+ * Selection matches on the SIDECAR's `sessionId`, never on the filename. That is not a preference, it
63
+ * is the only thing available: the mint separates segments with `~`, which is outside this backend's
64
+ * filename charset, so every engine-minted ref folds to `<base>-<sha256 of the raw ref>` and the
65
+ * session segment is gone from the name (and the readable base is a lossy, many-to-one prefix, so
66
+ * matching on it would reach a neighbouring session's files). `taskId`, where an older write site
67
+ * stamped one, is ignored — it narrows the same session.
68
+ *
69
+ * Both namespaces are swept: identity-named files in the store dir (refs a caller minted inside the
70
+ * filename charset, and everything written before the injective mint) and {@link FOLDED_SUBDIR}.
71
+ *
72
+ * Removal order is CONTENT then SIDECAR, and it is load-bearing. Interrupted after the content unlink,
73
+ * the next sweep still finds the sidecar, matches the same session and converges; interrupted after a
74
+ * sidecar-first unlink, the content file would be left owner-less — readable, attributable to nobody,
75
+ * and beyond the reach of every future session sweep. Same reasoning as `put`'s publication order, in
76
+ * the opposite direction. The pair is then re-examined ONCE after both unlinks, which is what keeps
77
+ * a `put` racing the two from leaving bytes whose owner this sweep removed — and what keeps that
78
+ * re-examination from deleting a row a DIFFERENT session claimed in the same window (see the body).
79
+ *
80
+ * What the re-examination is NOT is atomic: it reads the owner and then acts, so a writer landing
81
+ * inside THAT gap is narrowed, not excluded. Stated rather than locked away, because the locking that
82
+ * would close it is ruled out one layer down — `fs-atomic.ts` §2.4 admits exactly one file lock, the
83
+ * boot lock, on the premise that a data root has a single writing process (`FileStorageBackend`
84
+ * acquires it before constructing this store; cross-process correctness is a database backend's job,
85
+ * as everywhere else in this family). Under that premise there is no gap at all: both `put` and this
86
+ * sweep are wholly synchronous, so nothing can interleave with either. The narrowing above is what
87
+ * remains worth doing for a root someone shares anyway, and it costs one `readFileSync`.
88
+ *
89
+ * Failure posture: an entry that vanished between `readdir` and the unlink is honest absence (ENOENT
90
+ * ok, the concurrent sweeper's row). Any OTHER unlink error throws — a deletion face that cannot
91
+ * delete has to be loud — and because the sweep is idempotent, the retry after the operator clears
92
+ * the cause resumes where it stopped. A sidecar that cannot be READ (EACCES, torn JSON, a shape that
93
+ * is not the whole record) is counted `unattributable` and its content is left alone: a file we could
94
+ * not read carries no evidence about whose it is, and this store's rows stay readable through `get`
95
+ * even with a damaged owner record, so removing one on a guess would destroy another session's output.
96
+ */
97
+ deleteBySession(sessionId: string): ToolResultDeletionReport;
58
98
  }
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
- import { readFileSync } from "node:fs";
2
+ import { readFileSync, readdirSync, unlinkSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { assertSafeToolResultRef, assertToolResultProvenanceMatch, normalizeToolResultProvenance, } from "../../core/tool-result-store.js";
4
+ import { assertSafeToolResultRef, assertToolResultProvenanceMatch, normalizeToolResultProvenance, ToolResultRefConflictError, } from "../../core/tool-result-store.js";
5
5
  import { ensureDir, sanitizePathComponent, writeThenLink } from "./fs-atomic.js";
6
6
  import { assertAdoptionBootGate } from "./adoption/marker.js";
7
7
  export class FileToolResultStore {
@@ -21,11 +21,11 @@ export class FileToolResultStore {
21
21
  }
22
22
  pathFor(ref) {
23
23
  const { dir, stem } = this.locate(ref);
24
- return join(dir, `${stem}.txt`);
24
+ return join(dir, `${stem}${CONTENT_SUFFIX}`);
25
25
  }
26
26
  ownerPathFor(ref) {
27
27
  const { dir, stem } = this.locate(ref);
28
- return join(dir, `${stem}.owner.json`);
28
+ return join(dir, `${stem}${OWNER_SUFFIX}`);
29
29
  }
30
30
  put(ref, content, provenance) {
31
31
  assertSafeToolResultRef(ref);
@@ -46,7 +46,14 @@ export class FileToolResultStore {
46
46
  }
47
47
  if (provenance === undefined)
48
48
  return;
49
- this.writeOwnerFile(ref, provenance);
49
+ try {
50
+ this.writeOwnerFile(ref, provenance);
51
+ }
52
+ catch (err) {
53
+ if (err instanceof ToolResultRefConflictError)
54
+ unlinkIfAbsentOk(this.pathFor(ref));
55
+ throw err;
56
+ }
50
57
  }
51
58
  ownerOf(ref) {
52
59
  return this.readOwnerFile(ref, { loud: false });
@@ -63,20 +70,9 @@ export class FileToolResultStore {
63
70
  throw err;
64
71
  return undefined;
65
72
  }
66
- const parsed = (() => {
67
- try {
68
- return JSON.parse(raw);
69
- }
70
- catch {
71
- return undefined;
72
- }
73
- })();
74
- if (typeof parsed === "object" && parsed !== null) {
75
- const { sessionId, taskId } = parsed;
76
- if (typeof sessionId === "string" && (taskId === undefined || typeof taskId === "string")) {
77
- return taskId === undefined ? { sessionId } : { sessionId, taskId };
78
- }
79
- }
73
+ const parsed = parseOwnerRecord(raw);
74
+ if (parsed !== undefined)
75
+ return parsed;
80
76
  if (opts.loud) {
81
77
  throw new Error(`tool-result store: owner record for ref ${JSON.stringify(ref)} is present but unreadable — refusing to write over it`);
82
78
  }
@@ -111,6 +107,98 @@ export class FileToolResultStore {
111
107
  const content = limit != null ? full.slice(offset, offset + limit) : full.slice(offset);
112
108
  return { content, offset, totalChars: full.length };
113
109
  }
110
+ deleteBySession(sessionId) {
111
+ let deleted = 0;
112
+ let unattributable = 0;
113
+ for (const dir of [this.dir, join(this.dir, FOLDED_SUBDIR)]) {
114
+ let names;
115
+ try {
116
+ names = readdirSync(dir);
117
+ }
118
+ catch (err) {
119
+ if (err.code === "ENOENT")
120
+ continue;
121
+ throw err;
122
+ }
123
+ const listed = new Set(names);
124
+ for (const name of names) {
125
+ if (!name.endsWith(OWNER_SUFFIX))
126
+ continue;
127
+ const owner = readOwnerAt(join(dir, name));
128
+ if (owner === "gone")
129
+ continue;
130
+ if (owner === "damaged") {
131
+ unattributable++;
132
+ continue;
133
+ }
134
+ if (owner.sessionId !== sessionId)
135
+ continue;
136
+ const stem = name.slice(0, -OWNER_SUFFIX.length);
137
+ const sidecarPath = join(dir, name);
138
+ const contentPath = join(dir, `${stem}${CONTENT_SUFFIX}`);
139
+ unlinkIfAbsentOk(contentPath);
140
+ unlinkIfAbsentOk(sidecarPath);
141
+ const republished = readOwnerAt(sidecarPath);
142
+ if (republished === "gone") {
143
+ unlinkIfAbsentOk(contentPath);
144
+ }
145
+ else if (republished === "damaged") {
146
+ unattributable++;
147
+ }
148
+ else if (republished.sessionId === sessionId) {
149
+ unlinkIfAbsentOk(contentPath);
150
+ unlinkIfAbsentOk(sidecarPath);
151
+ }
152
+ deleted++;
153
+ }
154
+ for (const name of names) {
155
+ if (!name.endsWith(CONTENT_SUFFIX))
156
+ continue;
157
+ if (listed.has(`${name.slice(0, -CONTENT_SUFFIX.length)}${OWNER_SUFFIX}`))
158
+ continue;
159
+ unattributable++;
160
+ }
161
+ }
162
+ return { deleted, unattributable };
163
+ }
164
+ }
165
+ const CONTENT_SUFFIX = ".txt";
166
+ const OWNER_SUFFIX = ".owner.json";
167
+ function parseOwnerRecord(raw) {
168
+ const parsed = (() => {
169
+ try {
170
+ return JSON.parse(raw);
171
+ }
172
+ catch {
173
+ return undefined;
174
+ }
175
+ })();
176
+ if (typeof parsed === "object" && parsed !== null) {
177
+ const { sessionId, taskId } = parsed;
178
+ if (typeof sessionId === "string" && (taskId === undefined || typeof taskId === "string")) {
179
+ return taskId === undefined ? { sessionId } : { sessionId, taskId };
180
+ }
181
+ }
182
+ return undefined;
183
+ }
184
+ function readOwnerAt(path) {
185
+ let raw;
186
+ try {
187
+ raw = readFileSync(path, "utf8");
188
+ }
189
+ catch (err) {
190
+ return err.code === "ENOENT" ? "gone" : "damaged";
191
+ }
192
+ return parseOwnerRecord(raw) ?? "damaged";
193
+ }
194
+ function unlinkIfAbsentOk(path) {
195
+ try {
196
+ unlinkSync(path);
197
+ }
198
+ catch (err) {
199
+ if (err.code !== "ENOENT")
200
+ throw err;
201
+ }
114
202
  }
115
203
  const NATIVE_FILENAME_CHARSET = /^[A-Za-z0-9_.-]+$/;
116
204
  const FOLDED_SUBDIR = "folded-v2";
@@ -110,6 +110,10 @@ export declare function createBashTool(env: ExecutionEnv, rootCanonical: string,
110
110
  /** RB-370 ② (cli [2088]): override of the `timeout` parameter's engine CEILING, in ms — only-widen
111
111
  * vs the resolved default; see {@link import("./index.js").HandsToolkitOptions.bashMaxTimeoutMs}. */
112
112
  bashMaxTimeoutMs?: number;
113
+ /** Structured sink for this leg's timeout-cap discard announcements — consumed by
114
+ * {@link import("./fs-shared.js").resolveBashTimeoutCaps} at mount (see
115
+ * {@link import("./index.js").HandsToolkitOptions.onNotice} for the replace-vs-warn contract). */
116
+ onNotice?: (n: import("../../core/types.js").EngineNotice) => void;
113
117
  /**
114
118
  * #181-F6 — whether the Monitor tool is on THIS run's roster (the Runner's own mount predicate is
115
119
  * `backgroundTaskToolsActive`; it, not this tool, mounts Monitor). Read by the SR-2 gh rate-limit
@@ -142,6 +146,9 @@ export declare function createBashReadonlyTool(env: ExecutionEnv, rootCanonical:
142
146
  * legs share runShell's clamp, so both must share the configurable resolution). */
143
147
  bashDefaultTimeoutMs?: number;
144
148
  bashMaxTimeoutMs?: number;
149
+ /** Structured sink for the timeout-cap discard announcements — same seat the full `bash` leg
150
+ * takes (both foreground legs share the resolver, so both must share the sink). */
151
+ onNotice?: (n: import("../../core/types.js").EngineNotice) => void;
145
152
  /** RB-413: the extra containment roots the structured file tools got (design/119 `--add-dir`,
146
153
  * canonical). A read this deployment already sanctions for Read/Grep/Glob is equally sanctioned
147
154
  * here — the two faces must not disagree about which directories exist for this session. */
@@ -3,6 +3,7 @@ import type { ExecutionEnv } from "../../internal/harness-types.js";
3
3
  import { type FsViolation, type ReadFileState } from "./safety.js";
4
4
  import { type DecodedTextFile } from "./encoding.js";
5
5
  import { type ImageDownsampler } from "../../core/mcp.js";
6
+ import { type EngineNotice } from "../../core/types.js";
6
7
  /**
7
8
  * The "hand" tool band (design/44 §3) — built per task over an injected {@link ExecutionEnv} and a
8
9
  * per-task {@link ReadFileState}, closure-captured (design/44 §11 ruling A: the codebase tool idiom; no
@@ -223,6 +224,10 @@ export declare function bashTimeoutArgRefusal(timeoutMs: number | undefined, cap
223
224
  export declare function resolveBashTimeoutCaps(opts?: {
224
225
  bashDefaultTimeoutMs?: number;
225
226
  bashMaxTimeoutMs?: number;
227
+ /** Structured seat for the discard announcements below — wired, it REPLACES their `console.warn`
228
+ * (same ledger, same lines as `message`); absent, the console line prints verbatim. Threaded from
229
+ * the toolkit assembly (ultimately `RunnerDeps.onNotice`); never a global. */
230
+ onNotice?: (n: EngineNotice) => void;
226
231
  }): {
227
232
  defaultMs: number;
228
233
  maxMs: number;
@@ -5,6 +5,7 @@ import { decodeTextBytes, normalizeFileText } from "./encoding.js";
5
5
  import { shellQuote } from "./search.js";
6
6
  import { isNotebookPath } from "./notebook.js";
7
7
  import { sharpImageDownsampler } from "../../core/mcp.js";
8
+ import { deliverEngineNotice } from "../../core/types.js";
8
9
  export const MAX_READ_BYTES = 256 * 1024;
9
10
  export const SLICED_READ_MAX_BYTES = 64 * 1024 * 1024;
10
11
  export const MAX_EDIT_BYTES = 1024 * 1024 * 1024;
@@ -88,7 +89,10 @@ export function envErrorDetail(message) {
88
89
  return trimmed === "" ? "the execution environment reported no reason" : trimmed;
89
90
  }
90
91
  const announcedTimeoutConfig = new Set();
91
- function announceDiscardedTimeout(knob, raw, usedMs) {
92
+ function emitTimeoutDiscardNotice(message, detail, onNotice) {
93
+ deliverEngineNotice(onNotice, { code: "config.env_timeout_discarded", message, detail });
94
+ }
95
+ function announceDiscardedTimeout(knob, raw, usedMs, onNotice) {
92
96
  if (typeof raw === "string" && raw === "")
93
97
  return;
94
98
  const n = typeof raw === "number" ? raw : Number(raw);
@@ -105,7 +109,7 @@ function announceDiscardedTimeout(knob, raw, usedMs) {
105
109
  if (announcedTimeoutConfig.has(line))
106
110
  return;
107
111
  announcedTimeoutConfig.add(line);
108
- console.warn(line);
112
+ emitTimeoutDiscardNotice(line, { knob, raw, usedMs }, onNotice);
109
113
  }
110
114
  export function bashTimeoutParamDescription(caps, withDefault) {
111
115
  const bounds = `${withDefault ? `default ${caps.defaultMs}, ` : ""}min ${MIN_BASH_TIMEOUT_MS}, max ${caps.maxMs}`;
@@ -131,13 +135,13 @@ export function resolveBashTimeoutCaps(opts) {
131
135
  BASH_DEFAULT_TIMEOUT_MS;
132
136
  const maxMs = Math.max(validTimeoutMs(opts?.bashMaxTimeoutMs) ?? validTimeoutMs(Number(process.env.BASH_MAX_TIMEOUT_MS)) ?? BASH_MAX_TIMEOUT_MS, defaultMs);
133
137
  if (opts?.bashDefaultTimeoutMs !== undefined)
134
- announceDiscardedTimeout("bashDefaultTimeoutMs", opts.bashDefaultTimeoutMs, defaultMs);
138
+ announceDiscardedTimeout("bashDefaultTimeoutMs", opts.bashDefaultTimeoutMs, defaultMs, opts?.onNotice);
135
139
  if (process.env.BASH_DEFAULT_TIMEOUT_MS !== undefined)
136
- announceDiscardedTimeout("BASH_DEFAULT_TIMEOUT_MS", process.env.BASH_DEFAULT_TIMEOUT_MS, defaultMs);
140
+ announceDiscardedTimeout("BASH_DEFAULT_TIMEOUT_MS", process.env.BASH_DEFAULT_TIMEOUT_MS, defaultMs, opts?.onNotice);
137
141
  if (opts?.bashMaxTimeoutMs !== undefined)
138
- announceDiscardedTimeout("bashMaxTimeoutMs", opts.bashMaxTimeoutMs, maxMs);
142
+ announceDiscardedTimeout("bashMaxTimeoutMs", opts.bashMaxTimeoutMs, maxMs, opts?.onNotice);
139
143
  if (process.env.BASH_MAX_TIMEOUT_MS !== undefined)
140
- announceDiscardedTimeout("BASH_MAX_TIMEOUT_MS", process.env.BASH_MAX_TIMEOUT_MS, maxMs);
144
+ announceDiscardedTimeout("BASH_MAX_TIMEOUT_MS", process.env.BASH_MAX_TIMEOUT_MS, maxMs, opts?.onNotice);
141
145
  const optsCap = validTimeoutMs(opts?.bashMaxTimeoutMs);
142
146
  const requestedCap = optsCap ?? validTimeoutMs(Number(process.env.BASH_MAX_TIMEOUT_MS));
143
147
  if (requestedCap !== undefined && requestedCap < defaultMs) {
@@ -145,7 +149,7 @@ export function resolveBashTimeoutCaps(opts) {
145
149
  const line = `${knob}=${requestedCap} is below the resolved default budget (${defaultMs}ms) — the ceiling was raised to ${maxMs}ms (the default always fits under the cap).`;
146
150
  if (!announcedTimeoutConfig.has(line)) {
147
151
  announcedTimeoutConfig.add(line);
148
- console.warn(line);
152
+ emitTimeoutDiscardNotice(line, { knob, raw: requestedCap, usedMs: maxMs }, opts?.onNotice);
149
153
  }
150
154
  }
151
155
  return { defaultMs, maxMs };
@@ -74,6 +74,12 @@ export interface HandsToolkitOptions {
74
74
  * only-widen semantic: a ceiling below the default is a narrowing intent = invalid, ignored in
75
75
  * favor of the default. Additive: absent ⇒ byte-compat 600s. */
76
76
  bashMaxTimeoutMs?: number;
77
+ /** Structured sink for the timeout-cap discard announcements both shell legs emit at mount (see
78
+ * {@link import("../../core/types.js").EngineNotice} / `RunnerDeps.onNotice`). Wired, it REPLACES
79
+ * their `console.warn` line (a forwarding host must not show every fact twice); absent, the
80
+ * console line prints verbatim (byte-compat loudness). Threaded per toolkit build — never a
81
+ * global. */
82
+ onNotice?: (n: import("../../core/types.js").EngineNotice) => void;
77
83
  /** RB-220 — see createBashTool's taskOpts.oneShot / createTaskOutputTool's TaskToolOptions.oneShot for
78
84
  * the full contract: this run has no later turn for an async background notification to land in.
79
85
  * Threaded to both the Bash background-launch receipt and (when this band mounts TaskOutput itself,
@@ -41,6 +41,7 @@ export function createHandsToolkit(env, readFileState, rootCanonical, opts = {})
41
41
  ? createBashReadonlyTool(env, rootCanonical, new Set(bashReadonlyAllow ?? BASH_READONLY_DEFAULT_ALLOW), {
42
42
  ...(opts.bashDefaultTimeoutMs !== undefined ? { bashDefaultTimeoutMs: opts.bashDefaultTimeoutMs } : {}),
43
43
  ...(opts.bashMaxTimeoutMs !== undefined ? { bashMaxTimeoutMs: opts.bashMaxTimeoutMs } : {}),
44
+ ...(opts.onNotice !== undefined ? { onNotice: opts.onNotice } : {}),
44
45
  ...(readFaceRoots !== undefined ? { additionalRoots: readFaceRoots } : {}),
45
46
  })
46
47
  : createBashTool(env, rootCanonical, commitCoAuthor, cwdRef, {
@@ -55,6 +56,7 @@ export function createHandsToolkit(env, readFileState, rootCanonical, opts = {})
55
56
  ...(additionalRoots !== undefined ? { additionalRoots } : {}),
56
57
  ...(opts.bashDefaultTimeoutMs !== undefined ? { bashDefaultTimeoutMs: opts.bashDefaultTimeoutMs } : {}),
57
58
  ...(opts.bashMaxTimeoutMs !== undefined ? { bashMaxTimeoutMs: opts.bashMaxTimeoutMs } : {}),
59
+ ...(opts.onNotice !== undefined ? { onNotice: opts.onNotice } : {}),
58
60
  ...(opts.monitorToolActive !== undefined ? { monitorToolActive: opts.monitorToolActive } : {}),
59
61
  }));
60
62
  if (!readOnly && mountBackgroundTaskTools && hasBackgroundShell(env)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "5.26.0",
3
+ "version": "5.28.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",