@sema-agent/core 3.0.0 → 4.0.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 (127) hide show
  1. package/CHANGELOG.md +2345 -0
  2. package/dist/agents/cascade.d.ts +2 -1
  3. package/dist/agents/cascade.js +31 -32
  4. package/dist/agents/cumulative-stats.d.ts +29 -0
  5. package/dist/agents/cumulative-stats.js +29 -0
  6. package/dist/agents/observer.d.ts +4 -0
  7. package/dist/agents/observer.js +11 -10
  8. package/dist/agents/repair-loop.js +27 -19
  9. package/dist/agents/retain-ledger.d.ts +2 -0
  10. package/dist/agents/retain-ledger.js +6 -5
  11. package/dist/agents/roster-store.js +3 -5
  12. package/dist/agents/subagent.js +66 -64
  13. package/dist/agents/teacher.d.ts +1 -9
  14. package/dist/agents/teacher.js +21 -24
  15. package/dist/agents/verify.js +15 -14
  16. package/dist/brain/anthropic.js +24 -21
  17. package/dist/brain/openai.js +24 -21
  18. package/dist/brain/stream-engine.js +5 -2
  19. package/dist/brain/stream-shared.d.ts +1 -0
  20. package/dist/brain/stream-shared.js +14 -0
  21. package/dist/brain/terminal-cause.d.ts +4 -0
  22. package/dist/brain/terminal-cause.js +12 -0
  23. package/dist/brain/tool-call-id.d.ts +1 -0
  24. package/dist/brain/tool-call-id.js +3 -0
  25. package/dist/brain/tool-call-repair.js +2 -1
  26. package/dist/core/auto-compaction.js +16 -25
  27. package/dist/core/background-agent-store.d.ts +22 -22
  28. package/dist/core/background-agent-store.js +19 -23
  29. package/dist/core/checkpoint-store.d.ts +6 -13
  30. package/dist/core/checkpoint-store.js +11 -5
  31. package/dist/core/file-snapshot-store.d.ts +8 -0
  32. package/dist/core/file-snapshot-store.js +4 -3
  33. package/dist/core/hooks.d.ts +3 -1
  34. package/dist/core/hooks.js +8 -18
  35. package/dist/core/lsp.js +4 -3
  36. package/dist/core/mailbox-store.d.ts +7 -10
  37. package/dist/core/mcp.d.ts +7 -10
  38. package/dist/core/mcp.js +7 -3
  39. package/dist/core/memory-engine/engine.d.ts +0 -1
  40. package/dist/core/memory-engine/engine.js +24 -34
  41. package/dist/core/memory-engine/file-backend.d.ts +1 -2
  42. package/dist/core/memory-engine/file-backend.js +25 -36
  43. package/dist/core/memory-engine/layout.d.ts +8 -0
  44. package/dist/core/memory-engine/layout.js +60 -4
  45. package/dist/core/memory-engine/scope-contract.js +9 -3
  46. package/dist/core/memory-engine/types.d.ts +1 -1
  47. package/dist/core/runner/active-skill-scope.d.ts +0 -1
  48. package/dist/core/runner/active-skill-scope.js +1 -15
  49. package/dist/core/runner/assemble-result.d.ts +2 -7
  50. package/dist/core/runner/assemble-result.js +3 -4
  51. package/dist/core/runner/compaction-call-options.d.ts +7 -0
  52. package/dist/core/runner/compaction-call-options.js +30 -0
  53. package/dist/core/runner/prepare-task.d.ts +6 -23
  54. package/dist/core/runner/prepare-task.js +20 -53
  55. package/dist/core/runner/runtask.d.ts +1 -0
  56. package/dist/core/runner/runtask.js +65 -156
  57. package/dist/core/runner/session-rule-policy.js +4 -1
  58. package/dist/core/runner/teardown-bounded.d.ts +7 -0
  59. package/dist/core/runner/teardown-bounded.js +36 -0
  60. package/dist/core/runner/tool-disclosure.d.ts +2 -5
  61. package/dist/core/runner/turn-attachments.d.ts +5 -15
  62. package/dist/core/runner/turn-attachments.js +1 -1
  63. package/dist/core/safe-notify.d.ts +17 -0
  64. package/dist/core/safe-notify.js +59 -0
  65. package/dist/core/sensitive-path-policy.js +2 -1
  66. package/dist/core/store-contracts/checkpoint-store-contract.js +1 -1
  67. package/dist/core/stub-env.d.ts +2 -6
  68. package/dist/core/surrogate-safe-slice.d.ts +4 -0
  69. package/dist/core/surrogate-safe-slice.js +18 -0
  70. package/dist/core/task-registry-monitor.js +8 -13
  71. package/dist/core/task-registry-shared.d.ts +22 -0
  72. package/dist/core/task-registry-shared.js +16 -0
  73. package/dist/core/task-registry.d.ts +1 -0
  74. package/dist/core/task-registry.js +40 -57
  75. package/dist/core/tool-errors.js +5 -4
  76. package/dist/core/tool-name-aliases.js +2 -1
  77. package/dist/core/tool-policy.js +10 -5
  78. package/dist/core/tool-result-store.d.ts +1 -0
  79. package/dist/core/tool-result-store.js +17 -1
  80. package/dist/core/trace.d.ts +2 -9
  81. package/dist/core/types.d.ts +15 -12
  82. package/dist/core/untrusted-egress.js +7 -5
  83. package/dist/core/workflow-journal-store.d.ts +9 -20
  84. package/dist/core/workflow-run-store.d.ts +11 -10
  85. package/dist/core/workflow-run-store.js +22 -16
  86. package/dist/engine/compaction/utils.js +4 -2
  87. package/dist/engine/execution-env/node-execution-env.d.ts +2 -24
  88. package/dist/engine/harness/agent-harness.d.ts +1 -0
  89. package/dist/engine/harness/agent-harness.js +13 -33
  90. package/dist/engine/harness/types.d.ts +29 -67
  91. package/dist/engine/llm/types.d.ts +1 -1
  92. package/dist/engine/loop/agent-loop.js +11 -4
  93. package/dist/engine/loop/types.d.ts +11 -9
  94. package/dist/engine/session/memory-repo.d.ts +2 -6
  95. package/dist/engine/session/session.d.ts +5 -30
  96. package/dist/index.d.ts +3 -1
  97. package/dist/index.js +2 -1
  98. package/dist/internal/harness-types.d.ts +3 -1
  99. package/dist/orchestration/run-spec.js +7 -4
  100. package/dist/orchestration/run-workflow-tool.js +3 -35
  101. package/dist/orchestration/workflow.d.ts +3 -11
  102. package/dist/orchestration/workflow.js +164 -204
  103. package/dist/prompt-assembly/tool-catalog.d.ts +10 -9
  104. package/dist/prompt-assembly/turn-snapshot.d.ts +2 -5
  105. package/dist/stores/file/background-agent-store.d.ts +3 -12
  106. package/dist/stores/file/background-agent-store.js +3 -41
  107. package/dist/stores/file/checkpoint-store.js +6 -6
  108. package/dist/stores/file/mailbox-store.d.ts +2 -6
  109. package/dist/stores/file/session-policy-store.js +15 -1
  110. package/dist/stores/file/session-store.d.ts +2 -6
  111. package/dist/stores/file/workflow-journal-store.d.ts +3 -11
  112. package/dist/stores/file/workflow-run-store.d.ts +3 -7
  113. package/dist/stores/file/workflow-run-store.js +4 -17
  114. package/dist/tools/fs/bash-readonly-classifier.d.ts +1 -0
  115. package/dist/tools/fs/bash-readonly-classifier.js +60 -25
  116. package/dist/tools/fs/fs-bash.js +324 -96
  117. package/dist/tools/fs/fs-pdf.d.ts +2 -12
  118. package/dist/tools/fs/fs-shared.js +2 -2
  119. package/dist/tools/fs/notebook.d.ts +2 -5
  120. package/dist/tools/fs/safety.d.ts +3 -0
  121. package/dist/tools/fs/safety.js +43 -14
  122. package/dist/tools/fs/search.js +5 -5
  123. package/dist/tools/monitor.d.ts +1 -0
  124. package/dist/tools/monitor.js +7 -5
  125. package/dist/tools/worktree.d.ts +2 -7
  126. package/dist/tools/worktree.js +3 -1
  127. package/package.json +6 -2
@@ -8,12 +8,13 @@ export interface MailboxLease {
8
8
  messages: MailboxMessage[];
9
9
  maxSeq: number;
10
10
  }
11
+ export interface MailboxAppendMessage {
12
+ from?: string;
13
+ content: string;
14
+ sentAt: number;
15
+ }
11
16
  export interface MailboxStore {
12
- append(scope: string, handle: string, msg: {
13
- from?: string;
14
- content: string;
15
- sentAt: number;
16
- }): Promise<number>;
17
+ append(scope: string, handle: string, msg: MailboxAppendMessage): Promise<number>;
17
18
  claimLease(scope: string, handle: string, owner: string, ttlMs: number, now?: number): Promise<MailboxLease | null>;
18
19
  ack(scope: string, handle: string, owner: string, upToSeq: number): Promise<void>;
19
20
  releaseLease(scope: string, handle: string, owner: string): Promise<void>;
@@ -28,11 +29,7 @@ export declare class InMemoryMailboxStore implements MailboxStore {
28
29
  private boxes;
29
30
  private key;
30
31
  private box;
31
- append(scope: string, handle: string, msg: {
32
- from?: string;
33
- content: string;
34
- sentAt: number;
35
- }): Promise<number>;
32
+ append(scope: string, handle: string, msg: MailboxAppendMessage): Promise<number>;
36
33
  claimLease(scope: string, handle: string, owner: string, ttlMs: number, now?: number): Promise<MailboxLease | null>;
37
34
  ack(scope: string, handle: string, owner: string, upToSeq: number): Promise<void>;
38
35
  releaseLease(scope: string, handle: string, owner: string): Promise<void>;
@@ -8,6 +8,11 @@ export interface McpToolAxis {
8
8
  egress?: true;
9
9
  effect?: ToolEffect;
10
10
  }
11
+ export interface McpDroppedTool {
12
+ server: string;
13
+ tool: string;
14
+ reason: string;
15
+ }
11
16
  export interface MaterializedMcp {
12
17
  tools: AgentTool[];
13
18
  toolAxes: McpToolAxis[];
@@ -23,11 +28,7 @@ export interface MaterializedMcp {
23
28
  pendingRemovals: string[];
24
29
  };
25
30
  warnings: Error[];
26
- droppedTools: Array<{
27
- server: string;
28
- tool: string;
29
- reason: string;
30
- }>;
31
+ droppedTools: McpDroppedTool[];
31
32
  statuses: McpServerStatus[];
32
33
  refresh: (server?: string) => Promise<McpRefreshResult[]>;
33
34
  dispose: () => Promise<void>;
@@ -41,11 +42,7 @@ export interface McpRefreshResult {
41
42
  removed: string[];
42
43
  tools?: AgentTool[];
43
44
  axes?: McpToolAxis[];
44
- dropped?: Array<{
45
- server: string;
46
- tool: string;
47
- reason: string;
48
- }>;
45
+ dropped?: McpDroppedTool[];
49
46
  error?: string;
50
47
  }
51
48
  export interface McpServerStatus {
package/dist/core/mcp.js CHANGED
@@ -7,6 +7,7 @@ import { tmpdir } from "node:os";
7
7
  import { join } from "node:path";
8
8
  import { CallToolResultSchema, ElicitRequestSchema, ErrorCode, ListResourcesResultSchema, McpError } from "@modelcontextprotocol/sdk/types.js";
9
9
  import { MCP_IMAGE_MAX_BASE64, sharpImageResizer } from "./image-downsample.js";
10
+ import { sliceHeadSafe, sliceTailSafe } from "./surrogate-safe-slice.js";
10
11
  import { truncateError } from "./tool-errors.js";
11
12
  import { delimitUntrusted, inlineUntrusted, sanitizeUntrustedText } from "./untrusted-text.js";
12
13
  import { validateJsonSchemaShape } from "./runner/strict-output-schema.js";
@@ -75,8 +76,10 @@ export function truncateMcpErrorText(s) {
75
76
  let head = MCP_ERROR_HEAD_CHARS;
76
77
  let out = "";
77
78
  for (let i = 0; i < 4; i++) {
78
- const removed = s.length - head - MCP_ERROR_TAIL_CHARS;
79
- out = `${s.slice(0, head)}\n\n... [${removed} characters truncated] ...\n\n${s.slice(s.length - MCP_ERROR_TAIL_CHARS)}`;
79
+ const headText = sliceHeadSafe(s, head);
80
+ const tailText = sliceTailSafe(s, MCP_ERROR_TAIL_CHARS);
81
+ const removed = s.length - headText.length - tailText.length;
82
+ out = `${headText}\n\n... [${removed} characters truncated] ...\n\n${tailText}`;
80
83
  if (out.length <= max)
81
84
  break;
82
85
  head = Math.max(0, head - (out.length - max));
@@ -1008,7 +1011,8 @@ function buildResourceTools(resourceServers) {
1008
1011
  const res = await rs.client
1009
1012
  .listResources(undefined, { signal: watchdog.combinedSignal, timeout: timeoutMs })
1010
1013
  .catch((err) => rethrowHonestMcpError(err, { server, what, timeoutMs, writeEffect: false, signal, attributeServer: true, idle: { signal: watchdog.idleSignal, idleMs } }));
1011
- const children = res.resources.filter((r) => typeof r.uri === "string" && r.uri !== uri && r.uri.startsWith(uri));
1014
+ const dirPrefix = uri.endsWith("/") ? uri : `${uri}/`;
1015
+ const children = res.resources.filter((r) => typeof r.uri === "string" && r.uri !== uri && r.uri.startsWith(dirPrefix));
1012
1016
  return renderDirChildren(server, uri, children);
1013
1017
  }
1014
1018
  finally {
@@ -60,7 +60,6 @@ export declare class MemoryEngine {
60
60
  private rebuildIndex;
61
61
  private committedContentFor;
62
62
  private gateDerivedIndex;
63
- private quarantineFile;
64
63
  private readonlyDirNamesUnderRoot;
65
64
  private writeIfChanged;
66
65
  private writeBackProjection;
@@ -1,12 +1,12 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
2
- import { basename, dirname, join, relative, sep } from "node:path";
2
+ import { dirname, join, relative, sep } from "node:path";
3
3
  import { uuidv7 } from "../../internal/harness.js";
4
4
  import { MAX_MEMORY_BYTES, composeMemoryBlock, firstSentence } from "../memory.js";
5
5
  import { inlineUntrusted } from "../untrusted-text.js";
6
6
  import { formatMemoryAge } from "../memory-recall.js";
7
7
  import { computeEntryRev, parseEntryFile, serializeEntryFile } from "./frontmatter.js";
8
8
  import { DEFAULT_MAX_ENTRY_DEPTH, MEMORY_INDEX_FILENAME, scanEntryFiles } from "./file-backend.js";
9
- import { SCAN_FUSE_THRESHOLD, readIndexRevs, writeIndexRevs, bumpScanFuse, canonicalize, claimRootScope, clearScanFuse, deriveControlPlaneDir, drainMemoryAnnouncements, enqueueMemoryAnnouncement, ensureDirExists, isContainedIn, registerScope, registeredScopes, resolveMemoryEngineRoot, scopeDirFor, } from "./layout.js";
9
+ import { QUARANTINE_DIR, SCAN_FUSE_THRESHOLD, quarantineAndTombstone, readIndexRevs, writeIndexRevs, bumpScanFuse, canonicalize, claimRootScope, clearScanFuse, deriveControlPlaneDir, drainMemoryAnnouncements, enqueueMemoryAnnouncement, ensureDirExists, isContainedIn, registerScope, registeredScopes, resolveMemoryEngineRoot, scopeDirFor, } from "./layout.js";
10
10
  import { scanMemoryFileName, scanMemoryWrite, scanRemediation } from "./scan.js";
11
11
  export const MEMORY_INSTRUCTION_TEMPLATE = `# Memory
12
12
 
@@ -290,6 +290,8 @@ export class MemoryEngine {
290
290
  report.rejections.push({ path: rel, code: "symlink", reason: "symlinks are not harvested into memory (containment gate)" });
291
291
  else if (kind === "depth")
292
292
  report.rejections.push({ path: rel, code: "nested_too_deep", reason: `memory files nested deeper than ${this.maxDepth} levels are not harvested` });
293
+ else if (kind === "unreadable")
294
+ report.rejections.push({ path: rel, code: "unreadable", reason: "the path could not be read (lstat/readdir failed) — its content was NOT harvested this pass" });
293
295
  },
294
296
  });
295
297
  const records = [];
@@ -414,7 +416,7 @@ export class MemoryEngine {
414
416
  scanFindings.push(...scanMemoryWrite(f.text));
415
417
  const finding = scanFindings[0];
416
418
  if (finding !== undefined) {
417
- const q = this.quarantineFile(f.canonical, f.text);
419
+ const q = quarantineAndTombstone(f.canonical, f.text, join(this.controlDir, QUARANTINE_DIR), this.now);
418
420
  let contained = q.removed;
419
421
  const priorId = idByBasePath.get(f.canonical);
420
422
  if (priorId !== undefined) {
@@ -704,8 +706,8 @@ export class MemoryEngine {
704
706
  return undefined;
705
707
  let detail = "";
706
708
  try {
707
- const dest = join(this.controlDir, "quarantine", `${this.now()}-${MEMORY_INDEX_FILENAME}`);
708
- mkdirSync(dirname(dest), { recursive: true });
709
+ const dest = join(this.controlDir, QUARANTINE_DIR, `${this.now()}-${MEMORY_INDEX_FILENAME}`);
710
+ ensureDirExists(dirname(dest));
709
711
  writeFileSync(dest, text, "utf8");
710
712
  }
711
713
  catch (err) {
@@ -725,35 +727,6 @@ export class MemoryEngine {
725
727
  reason: `memory index blocked: ${finding.reason} (${cleared ? "index cleared and rebuilt from entry frontmatter; the offending prose was captured to quarantine" : "index still carries the offending content"})${detail}`,
726
728
  };
727
729
  }
728
- quarantineFile(path, content) {
729
- const dest = join(this.controlDir, "quarantine", `${this.now()}-${basename(path)}`);
730
- let captured;
731
- let detail;
732
- try {
733
- mkdirSync(dirname(dest), { recursive: true });
734
- writeFileSync(dest, content, "utf8");
735
- captured = dest;
736
- }
737
- catch (err) {
738
- detail = `quarantine copy failed: ${err instanceof Error ? err.message : String(err)}`;
739
- }
740
- let removed = false;
741
- try {
742
- rmSync(path, { force: true });
743
- removed = true;
744
- }
745
- catch (rmErr) {
746
- try {
747
- writeFileSync(path, "---\ndeleted: true\n---\n", "utf8");
748
- removed = true;
749
- detail = `${detail !== undefined ? `${detail}; ` : ""}delete failed (${rmErr instanceof Error ? rmErr.message : String(rmErr)}) — tombstoned in place`;
750
- }
751
- catch (clearErr) {
752
- detail = `${detail !== undefined ? `${detail}; ` : ""}suspect file could not be deleted or cleared: ${clearErr instanceof Error ? clearErr.message : String(clearErr)}`;
753
- }
754
- }
755
- return { ...(captured !== undefined ? { dest: captured } : {}), removed, ...(detail !== undefined ? { detail } : {}) };
756
- }
757
730
  readonlyDirNamesUnderRoot(handle) {
758
731
  const names = new Set();
759
732
  for (const [scope, dirName] of Object.entries(registeredScopes(this.controlDir))) {
@@ -797,12 +770,14 @@ export class MemoryEngine {
797
770
  chmodScopeTree(dir, dirMode, fileMode, opts = {}) {
798
771
  if (!existsSync(dir))
799
772
  return;
773
+ const sweepSkipped = [];
800
774
  const walk = (d, isRoot) => {
801
775
  let names = [];
802
776
  try {
803
777
  names = readdirSync(d);
804
778
  }
805
779
  catch {
780
+ sweepSkipped.push(d);
806
781
  return;
807
782
  }
808
783
  for (const name of names) {
@@ -814,6 +789,7 @@ export class MemoryEngine {
814
789
  st = statSync(p);
815
790
  }
816
791
  catch {
792
+ sweepSkipped.push(p);
817
793
  continue;
818
794
  }
819
795
  if (st.isDirectory())
@@ -825,6 +801,20 @@ export class MemoryEngine {
825
801
  chmodSafe(d, dirMode);
826
802
  };
827
803
  walk(dir, true);
804
+ if (sweepSkipped.length > 0) {
805
+ try {
806
+ enqueueMemoryAnnouncement(this.controlDir, {
807
+ kind: "gate",
808
+ at: this.now(),
809
+ items: sweepSkipped
810
+ .slice(0, 12)
811
+ .map((p) => `mode sweep could not read ${JSON.stringify(inlineUntrusted(relative(dir, p) || "."))} — files under it keep their previous mode`)
812
+ .concat(sweepSkipped.length > 12 ? [`…and ${sweepSkipped.length - 12} more sweepSkipped path(s)`] : []),
813
+ });
814
+ }
815
+ catch {
816
+ }
817
+ }
828
818
  }
829
819
  }
830
820
  const MAX_ANNOUNCEMENT_ITEMS = 12;
@@ -9,7 +9,7 @@ export interface ScannedEntryFile {
9
9
  export declare function scanEntryFiles(dir: string, opts?: {
10
10
  maxDepth?: number;
11
11
  exclude?: ReadonlySet<string>;
12
- onSkip?: (path: string, kind: "symlink" | "depth" | "nonmd" | "dotfile") => void;
12
+ onSkip?: (path: string, kind: "symlink" | "depth" | "nonmd" | "dotfile" | "unreadable") => void;
13
13
  }): ScannedEntryFile[];
14
14
  export declare class FileMemoryEngineBackend implements MemoryBackend {
15
15
  readonly directoryRoot: string;
@@ -26,7 +26,6 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
26
26
  drainInboundFindings(): HarvestRejection[];
27
27
  readCommittedShadow(id: string): string | undefined;
28
28
  private shadowPath;
29
- private quarantineFile;
30
29
  private loadLedger;
31
30
  private saveLedger;
32
31
  private recoverJournal;
@@ -1,11 +1,11 @@
1
- import { lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, openSync, writeSync, fsyncSync, closeSync } from "node:fs";
1
+ import { lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, openSync, fsyncSync, closeSync } from "node:fs";
2
2
  import { randomUUID } from "node:crypto";
3
- import { basename, dirname, join, relative } from "node:path";
3
+ import { dirname, join, relative } from "node:path";
4
4
  import { jaccardDistance, termSet } from "../memory-vector.js";
5
5
  import { MAX_MEMORY_BYTES } from "../memory.js";
6
6
  import { inlineUntrusted } from "../untrusted-text.js";
7
7
  import { computeEntryRev, entryFromFile, isValidEntryId, parseEntryFile, serializeEntryFile } from "./frontmatter.js";
8
- import { ControlPlaneCorruptError, CURSORS_FILE, atomicWriteFileSync, claimRootScope, deriveControlPlaneDir, ensureDirExists, enqueueMemoryAnnouncement, registerScope, registeredScopes, resolveMemoryEngineRoot, scopeDirName, } from "./layout.js";
8
+ import { ControlPlaneCorruptError, CURSORS_FILE, QUARANTINE_DIR, atomicWriteFileSync, quarantineAndTombstone, claimRootScope, deriveControlPlaneDir, ensureDirExists, enqueueMemoryAnnouncement, registerScope, registeredScopes, resolveMemoryEngineRoot, scopeDirName, writeAllSync, } from "./layout.js";
9
9
  import { scanMemoryFileName, scanMemoryWrite } from "./scan.js";
10
10
  export const MEMORY_INDEX_FILENAME = "MEMORY.md";
11
11
  export const DEFAULT_MAX_ENTRY_DEPTH = 3;
@@ -18,6 +18,8 @@ export function scanEntryFiles(dir, opts = {}) {
18
18
  names = readdirSync(d);
19
19
  }
20
20
  catch {
21
+ if (d !== dir)
22
+ opts.onSkip?.(d, "unreadable");
21
23
  return;
22
24
  }
23
25
  for (const name of names.sort()) {
@@ -32,6 +34,7 @@ export function scanEntryFiles(dir, opts = {}) {
32
34
  st = lstatSync(p);
33
35
  }
34
36
  catch {
37
+ opts.onSkip?.(p, "unreadable");
35
38
  continue;
36
39
  }
37
40
  if (st.isSymbolicLink()) {
@@ -65,7 +68,6 @@ const TXN_LOCK_STALE_MS = 30_000;
65
68
  const TXN_LOCK_WAIT_MS = 15_000;
66
69
  const TXN_LOCK_STEAL_GRACE_MS = 250;
67
70
  const SHADOW_DIR = "shadow";
68
- const QUARANTINE_DIR = "quarantine";
69
71
  export class FileMemoryEngineBackend {
70
72
  directoryRoot;
71
73
  controlPlaneRoot;
@@ -98,35 +100,6 @@ export class FileMemoryEngineBackend {
98
100
  shadowPath(id) {
99
101
  return join(this.controlPlaneRoot, SHADOW_DIR, `${id}.md`);
100
102
  }
101
- quarantineFile(path, content) {
102
- const dest = join(this.controlPlaneRoot, QUARANTINE_DIR, `${Date.now()}-${basename(path)}`);
103
- let captured;
104
- let detail;
105
- try {
106
- ensureDirExists(dirname(dest));
107
- writeFileSync(dest, content, "utf8");
108
- captured = dest;
109
- }
110
- catch (err) {
111
- detail = `quarantine copy failed: ${err instanceof Error ? err.message : String(err)}`;
112
- }
113
- let removed = false;
114
- try {
115
- rmSync(path, { force: true });
116
- removed = true;
117
- }
118
- catch (rmErr) {
119
- try {
120
- writeFileSync(path, "---\ndeleted: true\n---\n", "utf8");
121
- removed = true;
122
- detail = `${detail !== undefined ? `${detail}; ` : ""}delete failed (${rmErr instanceof Error ? rmErr.message : String(rmErr)}) — tombstoned in place`;
123
- }
124
- catch (clearErr) {
125
- detail = `${detail !== undefined ? `${detail}; ` : ""}suspect file could not be deleted or cleared: ${clearErr instanceof Error ? clearErr.message : String(clearErr)}`;
126
- }
127
- }
128
- return { ...(captured !== undefined ? { dest: captured } : {}), removed, ...(detail !== undefined ? { detail } : {}) };
129
- }
130
103
  loadLedger() {
131
104
  if (this.ledger)
132
105
  return this.ledger;
@@ -278,12 +251,14 @@ export class FileMemoryEngineBackend {
278
251
  const ledger = sync ? this.loadLedger() : undefined;
279
252
  let ledgerChanged = false;
280
253
  const adoptedExternal = [];
254
+ const skippedUnreadable = [];
281
255
  for (const f of files) {
282
256
  let text;
283
257
  try {
284
258
  text = readFileSync(f.path, "utf8");
285
259
  }
286
260
  catch {
261
+ skippedUnreadable.push(relative(this.directoryRoot, f.path));
287
262
  continue;
288
263
  }
289
264
  const parsed = parseEntryFile(text);
@@ -312,7 +287,7 @@ export class FileMemoryEngineBackend {
312
287
  }
313
288
  if (finding) {
314
289
  this.inboundFindings.push(finding);
315
- const q = this.quarantineFile(f.path, text);
290
+ const q = quarantineAndTombstone(f.path, text, join(this.controlPlaneRoot, QUARANTINE_DIR), this.now);
316
291
  const shadowText = committed !== undefined ? this.readCommittedShadow(entry.id) : undefined;
317
292
  let restoredShadow = false;
318
293
  if (shadowText !== undefined) {
@@ -378,6 +353,20 @@ export class FileMemoryEngineBackend {
378
353
  catch {
379
354
  }
380
355
  }
356
+ if (skippedUnreadable.length > 0) {
357
+ try {
358
+ enqueueMemoryAnnouncement(this.controlPlaneRoot, {
359
+ kind: "external",
360
+ at: this.now(),
361
+ items: skippedUnreadable
362
+ .slice(0, 12)
363
+ .map((rel) => `committed memory entry could not be read and is MISSING from this session's memory: ${JSON.stringify(inlineUntrusted(rel))} — check the file (permissions/fs)`)
364
+ .concat(skippedUnreadable.length > 12 ? [`…and ${skippedUnreadable.length - 12} more unreadable entr(ies)`] : []),
365
+ });
366
+ }
367
+ catch {
368
+ }
369
+ }
381
370
  if (!sync && this.batchScan !== undefined)
382
371
  this.batchScan.set(scope, entries);
383
372
  return entries;
@@ -458,7 +447,7 @@ export class FileMemoryEngineBackend {
458
447
  try {
459
448
  const fd = openSync(ownerPath, "w", 0o600);
460
449
  try {
461
- writeSync(fd, token);
450
+ writeAllSync(fd, token);
462
451
  fsyncSync(fd);
463
452
  }
464
453
  finally {
@@ -584,7 +573,7 @@ export class FileMemoryEngineBackend {
584
573
  mkdirSync(dirname(op.target), { recursive: true });
585
574
  const fd = openSync(staged, "w", 0o600);
586
575
  try {
587
- writeSync(fd, op.content);
576
+ writeAllSync(fd, op.content);
588
577
  fsyncSync(fd);
589
578
  }
590
579
  finally {
@@ -23,6 +23,13 @@ export declare function scopeDirFor(memoryDir: string, controlDir: string, scope
23
23
  export declare function canonicalize(p: string): string;
24
24
  export declare function isContainedIn(root: string, child: string): boolean;
25
25
  export declare function ensureDirExists(dir: string): void;
26
+ export declare const QUARANTINE_DIR = "quarantine";
27
+ export interface QuarantineOutcome {
28
+ dest?: string;
29
+ removed: boolean;
30
+ detail?: string;
31
+ }
32
+ export declare function quarantineAndTombstone(path: string, content: string, quarantineDir: string, now: () => number): QuarantineOutcome;
26
33
  export declare const ANNOUNCEMENTS_FILE = "announcements.json";
27
34
  export declare const MEMORY_ANNOUNCEMENTS_MAX = 20;
28
35
  export declare const SCAN_FUSE_FILE = "scan-fuse.json";
@@ -39,5 +46,6 @@ export declare function readIndexRevs(controlDir: string): Record<string, string
39
46
  export declare function writeIndexRevs(controlDir: string, revs: Record<string, string>): void;
40
47
  export declare function scanFuseCount(controlDir: string, key: string): number;
41
48
  export declare function clearScanFuse(controlDir: string, keys: Iterable<string>): void;
49
+ export declare function writeAllSync(fd: number, data: string): void;
42
50
  export declare function atomicWriteFileSync(path: string, data: string): void;
43
51
  export {};
@@ -1,7 +1,7 @@
1
- import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeSync } from "node:fs";
1
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { createHash } from "node:crypto";
4
- import { dirname, isAbsolute, join, resolve, sep } from "node:path";
4
+ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
5
5
  const SCOPES_FILE = "scopes.json";
6
6
  export const CURSORS_FILE = "cursors.json";
7
7
  export class ControlPlaneCorruptError extends Error {
@@ -190,6 +190,52 @@ export function ensureDirExists(dir) {
190
190
  if (!existsSync(dir))
191
191
  mkdirSync(dir, { recursive: true, mode: 0o700 });
192
192
  }
193
+ export const QUARANTINE_DIR = "quarantine";
194
+ const DELETED_TOMBSTONE = "---\ndeleted: true\n---\n";
195
+ let quarantineSeq = 0;
196
+ export function quarantineAndTombstone(path, content, quarantineDir, now) {
197
+ let captured;
198
+ let detail;
199
+ try {
200
+ ensureDirExists(quarantineDir);
201
+ const base = `${now()}-${basename(path)}`;
202
+ for (let attempt = 0; captured === undefined && attempt < 10; attempt++) {
203
+ const candidate = join(quarantineDir, attempt === 0 ? base : `${base}.${attempt}`);
204
+ try {
205
+ writeFileSync(candidate, content, { encoding: "utf8", flag: "wx" });
206
+ captured = candidate;
207
+ }
208
+ catch (err) {
209
+ if (!(err instanceof Error && "code" in err && err.code === "EEXIST"))
210
+ throw err;
211
+ }
212
+ }
213
+ if (captured === undefined) {
214
+ const last = join(quarantineDir, `${base}.${process.pid}.${quarantineSeq++}`);
215
+ writeFileSync(last, content, { encoding: "utf8", flag: "wx" });
216
+ captured = last;
217
+ }
218
+ }
219
+ catch (err) {
220
+ detail = `quarantine copy failed: ${err instanceof Error ? err.message : String(err)}`;
221
+ }
222
+ let removed = false;
223
+ try {
224
+ rmSync(path, { force: true });
225
+ removed = true;
226
+ }
227
+ catch (rmErr) {
228
+ try {
229
+ writeFileSync(path, DELETED_TOMBSTONE, "utf8");
230
+ removed = true;
231
+ detail = `${detail !== undefined ? `${detail}; ` : ""}delete failed (${rmErr instanceof Error ? rmErr.message : String(rmErr)}) — tombstoned in place`;
232
+ }
233
+ catch (clearErr) {
234
+ detail = `${detail !== undefined ? `${detail}; ` : ""}suspect file could not be deleted or cleared: ${clearErr instanceof Error ? clearErr.message : String(clearErr)}`;
235
+ }
236
+ }
237
+ return { ...(captured !== undefined ? { dest: captured } : {}), removed, ...(detail !== undefined ? { detail } : {}) };
238
+ }
193
239
  export const ANNOUNCEMENTS_FILE = "announcements.json";
194
240
  export const MEMORY_ANNOUNCEMENTS_MAX = 20;
195
241
  export const SCAN_FUSE_FILE = "scan-fuse.json";
@@ -219,7 +265,7 @@ function acquireSidecarLock(lockDir, opts = {}) {
219
265
  let fd;
220
266
  try {
221
267
  fd = openSync(join(lockDir, "owner"), "w");
222
- writeSync(fd, token);
268
+ writeAllSync(fd, token);
223
269
  }
224
270
  catch {
225
271
  }
@@ -444,12 +490,22 @@ export function clearScanFuse(controlDir, keys) {
444
490
  return changed ? counts : undefined;
445
491
  });
446
492
  }
493
+ export function writeAllSync(fd, data) {
494
+ const buf = Buffer.from(data, "utf8");
495
+ let written = 0;
496
+ while (written < buf.length) {
497
+ const n = writeSync(fd, buf, written, buf.length - written);
498
+ if (n <= 0)
499
+ throw new Error(`writeAllSync: write made no progress (${written}/${buf.length} bytes) — refusing to leave a truncated file`);
500
+ written += n;
501
+ }
502
+ }
447
503
  export function atomicWriteFileSync(path, data) {
448
504
  ensureDirExists(dirname(path));
449
505
  const tmp = join(dirname(path), `.tmp-${process.pid}-${Math.random().toString(36).slice(2, 10)}`);
450
506
  const fd = openSync(tmp, "w", 0o600);
451
507
  try {
452
- writeSync(fd, data);
508
+ writeAllSync(fd, data);
453
509
  fsyncSync(fd);
454
510
  }
455
511
  finally {
@@ -1,5 +1,11 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
+ import { canonicalize } from "./layout.js";
4
+ function pathSegmentsDualFamily(p) {
5
+ return p.split(/[\\/]/).filter(Boolean);
6
+ }
7
+ const CASE_INSENSITIVE_FS = process.platform === "darwin" || process.platform === "win32";
8
+ const foldPathCase = (s) => (CASE_INSENSITIVE_FS ? s.toLowerCase() : s);
3
9
  export const SCOPE_SEGMENT_MAX_ENCODED = 64;
4
10
  const SEGMENT_SAFE = /^[A-Za-z0-9._-]$/;
5
11
  export function encodeScopeSegment(raw) {
@@ -117,9 +123,9 @@ export function assertScopeContractPlacement(args) {
117
123
  const personal = args.parsedScopes.filter(isCentralAuthorityScope);
118
124
  if (personal.length === 0)
119
125
  return;
120
- const root = resolve(args.repoRoot);
121
- const dir = resolve(args.memoryDir);
122
- if (dir === root || dir.startsWith(root + "/")) {
126
+ const rootSegs = pathSegmentsDualFamily(canonicalize(resolve(args.repoRoot))).map(foldPathCase);
127
+ const dirSegs = pathSegmentsDualFamily(canonicalize(resolve(args.memoryDir))).map(foldPathCase);
128
+ if (rootSegs.length <= dirSegs.length && rootSegs.every((s, i) => s === dirSegs[i])) {
123
129
  const e = new Error(`memory scope contract v2: central-authority scope(s) ${personal
124
130
  .map((p) => `"${p.kind}"`)
125
131
  .join(", ")} cannot materialize into an IN-REPO memory dir (${args.memoryDir}) — ` +
@@ -88,7 +88,7 @@ export interface MemorySessionHandle {
88
88
  indexBaselineLines: number;
89
89
  indexText: string;
90
90
  }
91
- export type HarvestRejectionCode = "outside_root" | "symlink" | "secret" | "injection" | "filename" | "too_large" | "file_cap" | "readonly_layer" | "stub_modified" | "nested_too_deep" | "quarantine_failed" | "invalid";
91
+ export type HarvestRejectionCode = "outside_root" | "symlink" | "secret" | "injection" | "filename" | "too_large" | "file_cap" | "readonly_layer" | "stub_modified" | "nested_too_deep" | "quarantine_failed" | "unreadable" | "invalid";
92
92
  export interface HarvestRejection {
93
93
  path: string;
94
94
  code: HarvestRejectionCode;
@@ -16,7 +16,6 @@ export declare class ActiveSkillScope {
16
16
  active(): readonly ActiveSkillFrame[];
17
17
  get size(): number;
18
18
  }
19
- export declare function isWithin(root: string, p: string): boolean;
20
19
  export declare function createActiveSkillScopePolicy(opts: {
21
20
  scope: ActiveSkillScope;
22
21
  env: ExecutionEnv;
@@ -1,4 +1,5 @@
1
1
  import { canonicalizeTarget, fileArgPath } from "../../tools/fs/safety.js";
2
+ import { PATH_WRITE_TOOLS, isWithin } from "./session-rule-policy.js";
2
3
  import { canonicalToolName } from "../tool-name-aliases.js";
3
4
  import { parseSkillToolEntry, skillSpecifierRejection } from "../skill-tool-specifier.js";
4
5
  export class ActiveSkillScope {
@@ -16,21 +17,6 @@ export class ActiveSkillScope {
16
17
  return this.frames.length;
17
18
  }
18
19
  }
19
- const PATH_WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit"]);
20
- export function isWithin(root, p) {
21
- const norm = (x) => {
22
- let n = x.replace(/\\/g, "/");
23
- if (/^[A-Za-z]:(\/|$)/.test(n))
24
- n = n[0].toLowerCase() + n.slice(1);
25
- return n;
26
- };
27
- const r = norm(root);
28
- const c = norm(p);
29
- if (c === r)
30
- return true;
31
- const base = r.endsWith("/") ? r : r + "/";
32
- return c.startsWith(base);
33
- }
34
20
  export function createActiveSkillScopePolicy(opts) {
35
21
  const { scope, env, rootPath, toolEffects } = opts;
36
22
  return {
@@ -1,5 +1,5 @@
1
1
  import type { AssistantMessage } from "../../internal/llm.js";
2
- import type { TaskResult, TaskSpec } from "../types.js";
2
+ import type { NestedUsage, TaskResult, TaskSpec } from "../types.js";
3
3
  export interface Stats {
4
4
  turns: number;
5
5
  tokens: number;
@@ -12,12 +12,7 @@ export interface Stats {
12
12
  cacheHitRate?: number;
13
13
  totalInputTokens: number;
14
14
  costMicroUsd: number;
15
- nested?: {
16
- tokens: number;
17
- turns: number;
18
- tasks: number;
19
- costMicroUsd?: number;
20
- };
15
+ nested?: NestedUsage;
21
16
  memory?: {
22
17
  tokens: number;
23
18
  costMicroUsd: number;
@@ -1,5 +1,4 @@
1
- import { DEGENERATE_MESSAGE } from "../../brain/repetition.js";
2
- import { WALLTIME_CUTOFF_MESSAGE } from "../../brain/walltime.js";
1
+ import { isDegenerateCutMessage, isWalltimeCutMessage } from "../../brain/terminal-cause.js";
3
2
  import { extractErrorCode, stripErrorCodePrefix } from "../../brain/errors.js";
4
3
  export function errorCodeOf(err) {
5
4
  let cur = err;
@@ -54,8 +53,8 @@ export function assembleResult(spec, sessionId, final, stats, flags) {
54
53
  let checkpointToken;
55
54
  let checkpointGate;
56
55
  let workspaceRestoreMode;
57
- const isDegenerate = final?.stopReason === "error" && final.errorMessage === DEGENERATE_MESSAGE;
58
- const isWalltimeCutoff = final?.stopReason === "error" && final.errorMessage === WALLTIME_CUTOFF_MESSAGE;
56
+ const isDegenerate = final?.stopReason === "error" && isDegenerateCutMessage(final);
57
+ const isWalltimeCutoff = final?.stopReason === "error" && isWalltimeCutMessage(final);
59
58
  if (flags.outputInvalid) {
60
59
  status = "failed";
61
60
  errorCode = "output.invalid";
@@ -0,0 +1,7 @@
1
+ import { type TracerHook } from "../trace.js";
2
+ import type { MaybeCompactOptions } from "../auto-compaction.js";
3
+ import type { TaskSpec } from "../types.js";
4
+ import type { Prepared } from "./prepare-task.js";
5
+ export declare function buildWorkingFileAttachments(spec: TaskSpec, prepared: Prepared): MaybeCompactOptions["workingFileAttachments"];
6
+ export declare function centerAdoptionOption(prepared: Prepared): Partial<Pick<MaybeCompactOptions, "centerAdoption">>;
7
+ export declare function emitInputTruncated(tracer: TracerHook | undefined, taskId: string): NonNullable<MaybeCompactOptions["onInputTruncated"]>;
@@ -0,0 +1,30 @@
1
+ import { emitTrace } from "../trace.js";
2
+ export function buildWorkingFileAttachments(spec, prepared) {
3
+ if (spec.compaction?.attachWorkingFiles === false || !prepared.readTaskFile)
4
+ return undefined;
5
+ return {
6
+ readFile: prepared.readTaskFile,
7
+ ...(prepared.recentlyReadFiles ? { recentlyReadFiles: prepared.recentlyReadFiles } : {}),
8
+ ...(prepared.normalizeAttachmentPath ? { normalizePath: prepared.normalizeAttachmentPath } : {}),
9
+ ...(prepared.isDedupStubResult ? { isDedupStubResult: prepared.isDedupStubResult } : {}),
10
+ ...(prepared.instructionSources
11
+ ? { instructionSourcePaths: prepared.instructionSources.filter((s) => s.contentHash !== null).map((s) => s.path) }
12
+ : {}),
13
+ ...(typeof spec.compaction?.attachWorkingFiles === "object" ? spec.compaction.attachWorkingFiles : undefined),
14
+ };
15
+ }
16
+ export function centerAdoptionOption(prepared) {
17
+ const ca = prepared.centerCompactionCandidate?.();
18
+ return ca !== undefined ? { centerAdoption: ca } : {};
19
+ }
20
+ export function emitInputTruncated(tracer, taskId) {
21
+ return (info) => emitTrace(tracer, () => ({
22
+ kind: "compaction.input_truncated",
23
+ version: 1,
24
+ taskId,
25
+ label: info.label,
26
+ droppedChars: info.droppedChars,
27
+ keptChars: info.keptChars,
28
+ ts: Date.now(),
29
+ }));
30
+ }