@sema-agent/core 2.0.0 → 2.1.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 (46) hide show
  1. package/dist/agents/observer.js +8 -3
  2. package/dist/agents/send-message-tool.js +112 -81
  3. package/dist/agents/subagent.d.ts +10 -4
  4. package/dist/agents/subagent.js +103 -53
  5. package/dist/core/memory-engine/dual-root.js +2 -0
  6. package/dist/core/memory-engine/engine.d.ts +4 -0
  7. package/dist/core/memory-engine/engine.js +6 -1
  8. package/dist/core/runner/prepare-memory.d.ts +4 -0
  9. package/dist/core/runner/prepare-memory.js +4 -1
  10. package/dist/core/runner/prepare-task.d.ts +8 -2
  11. package/dist/core/runner/prepare-task.js +39 -8
  12. package/dist/core/runner/runtask.js +910 -865
  13. package/dist/core/runner/turn-attachments.d.ts +15 -1
  14. package/dist/core/runner/turn-attachments.js +68 -9
  15. package/dist/core/tool-result-budget.js +2 -2
  16. package/dist/core/tool-result-store.d.ts +2 -0
  17. package/dist/core/tool-result-store.js +27 -2
  18. package/dist/core/tools.js +1 -0
  19. package/dist/core/types.d.ts +1 -1
  20. package/dist/core/workflow-journal-store.d.ts +13 -0
  21. package/dist/engine/loop/agent-loop.d.ts +1 -1
  22. package/dist/engine/loop/agent-loop.js +10 -9
  23. package/dist/engine/session/import-validate.js +29 -0
  24. package/dist/orchestration/workflow.js +28 -1
  25. package/dist/prompt-assembly/event-registry.js +1 -1
  26. package/dist/stores/cc/task-list-store.js +3 -3
  27. package/dist/stores/file/mailbox-store.js +33 -2
  28. package/dist/stores/file/memory-store.d.ts +3 -0
  29. package/dist/stores/file/memory-store.js +39 -12
  30. package/dist/stores/file/tool-result-store.js +16 -2
  31. package/dist/stores/file/workflow-journal-store.d.ts +17 -0
  32. package/dist/stores/file/workflow-journal-store.js +102 -2
  33. package/dist/tools/fs/bash-readonly-classifier.js +1 -1
  34. package/dist/tools/fs/fs-read.js +2 -2
  35. package/dist/tools/fs/fs-search-tools.js +3 -1
  36. package/dist/tools/fs/fs-shared.d.ts +2 -1
  37. package/dist/tools/fs/fs-shared.js +7 -3
  38. package/dist/tools/fs/fs-write.js +9 -11
  39. package/dist/tools/fs/safety.d.ts +3 -0
  40. package/dist/tools/fs/safety.js +20 -7
  41. package/dist/tools/fs/search.d.ts +1 -0
  42. package/dist/tools/fs/search.js +21 -3
  43. package/dist/tools/task-list.d.ts +1 -0
  44. package/dist/tools/task-list.js +13 -2
  45. package/dist/tools/web.js +79 -11
  46. package/package.json +1 -1
@@ -3,6 +3,7 @@ import { uuidv7 } from "../../internal/harness.js";
3
3
  import { firstSentence, lexicalSearchMatch, } from "../../core/memory.js";
4
4
  import { cosineDistance, jaccardDistance, termSet } from "../../core/memory-vector.js";
5
5
  import { canonicalStoreKey, AppendLog, atomicWriteFile, ensureDir, readJsonlRecords, sanitizeScope, } from "./fs-atomic.js";
6
+ const MAX_OPEN_MEMORY_SCOPES = 64;
6
7
  const sharedMemoryDirs = new Map();
7
8
  export class FileMemoryStore {
8
9
  root;
@@ -25,7 +26,7 @@ export class FileMemoryStore {
25
26
  this.sharedScopes = live;
26
27
  }
27
28
  else {
28
- this.sharedScopes = { scopes: new Map(), refs: 1 };
29
+ this.sharedScopes = { scopes: new Map(), refs: 1, openLogs: new Set() };
29
30
  sharedMemoryDirs.set(this.scopeKey, this.sharedScopes);
30
31
  }
31
32
  this.tmpDir = join(root, "tmp");
@@ -90,11 +91,36 @@ export class FileMemoryStore {
90
91
  vectors.set(ve.id, { embedding: ve.embedding, h: typeof ve.h === "string" ? ve.h : "" });
91
92
  }
92
93
  }
93
- const state = { entries, log: new AppendLog(notesPath), dir, cursor, vectors, vlog: new AppendLog(vpath) };
94
+ const state = { entries, log: new AppendLog(notesPath), dir, cursor, vectors, vpath };
94
95
  this.scopes.set(scope, state);
96
+ this.touchOpenLogs(scope);
95
97
  return state;
96
98
  }
97
- queueEmbed(state, id, body) {
99
+ touchOpenLogs(scope) {
100
+ const open = this.sharedScopes.openLogs;
101
+ open.delete(scope);
102
+ open.add(scope);
103
+ while (open.size > MAX_OPEN_MEMORY_SCOPES) {
104
+ const coldest = open.values().next().value;
105
+ if (coldest === undefined)
106
+ break;
107
+ open.delete(coldest);
108
+ const st = this.scopes.get(coldest);
109
+ st?.log.closeForSwap();
110
+ st?.vlog?.closeForSwap();
111
+ }
112
+ }
113
+ writeLog(scope, state) {
114
+ this.touchOpenLogs(scope);
115
+ return state.log;
116
+ }
117
+ vectorLog(scope, state) {
118
+ if (state.vlog === undefined)
119
+ state.vlog = new AppendLog(state.vpath);
120
+ this.touchOpenLogs(scope);
121
+ return state.vlog;
122
+ }
123
+ queueEmbed(scope, state, id, body) {
98
124
  const emb = this.embedder;
99
125
  if (!emb)
100
126
  return;
@@ -111,7 +137,7 @@ export class FileMemoryStore {
111
137
  const cur = state.entries.find((e) => e.id === id);
112
138
  if (!cur || hashBody(cur.text) !== h)
113
139
  return;
114
- state.vlog.append({ id, embedding: vec, h }, false);
140
+ this.vectorLog(scope, state).append({ id, embedding: vec, h }, false);
115
141
  state.vectors.set(id, { embedding: vec, h });
116
142
  })
117
143
  .catch(() => { });
@@ -155,14 +181,14 @@ export class FileMemoryStore {
155
181
  }
156
182
  commit(scope, entry) {
157
183
  const state = this.scopeState(scope);
158
- state.log.append({ op: "append", entry }, true);
184
+ this.writeLog(scope, state).append({ op: "append", entry }, true);
159
185
  state.entries.push(entry);
160
186
  this.writeProjection(state);
161
- this.queueEmbed(state, entry.id, entry.text);
187
+ this.queueEmbed(scope, state, entry.id, entry.text);
162
188
  }
163
189
  clear(scope) {
164
190
  const state = this.scopeState(scope);
165
- state.log.append({ op: "clear" }, true);
191
+ this.writeLog(scope, state).append({ op: "clear" }, true);
166
192
  state.entries.length = 0;
167
193
  state.cursor = undefined;
168
194
  state.vectors.clear();
@@ -173,7 +199,7 @@ export class FileMemoryStore {
173
199
  }
174
200
  setConsolidationCursor(scope, cursor) {
175
201
  const state = this.scopeState(scope);
176
- state.log.append({ op: "cursor", cursor }, true);
202
+ this.writeLog(scope, state).append({ op: "cursor", cursor }, true);
177
203
  state.cursor = cursor;
178
204
  }
179
205
  search(scope, query, limit = 10) {
@@ -241,20 +267,20 @@ export class FileMemoryStore {
241
267
  const ev = { op: "update", id, text: newBody, ts };
242
268
  if (newDesc !== undefined)
243
269
  ev.description = newDesc;
244
- state.log.append(ev, true);
270
+ this.writeLog(scope, state).append(ev, true);
245
271
  entry.text = newBody;
246
272
  entry.ts = ts;
247
273
  entry.description = newDesc;
248
274
  this.writeProjection(state);
249
275
  state.vectors.delete(id);
250
- this.queueEmbed(state, id, newBody);
276
+ this.queueEmbed(scope, state, id, newBody);
251
277
  }
252
278
  delete(scope, id) {
253
279
  const state = this.scopeState(scope);
254
280
  const i = state.entries.findIndex((e) => e.id === id);
255
281
  if (i === -1)
256
282
  return;
257
- state.log.append({ op: "delete", id }, true);
283
+ this.writeLog(scope, state).append({ op: "delete", id }, true);
258
284
  state.entries.splice(i, 1);
259
285
  this.writeProjection(state);
260
286
  }
@@ -269,8 +295,9 @@ export class FileMemoryStore {
269
295
  sharedMemoryDirs.delete(this.scopeKey);
270
296
  for (const s of this.scopes.values()) {
271
297
  s.log.close();
272
- s.vlog.close();
298
+ s.vlog?.close();
273
299
  }
300
+ this.sharedScopes.openLogs.clear();
274
301
  }
275
302
  }
276
303
  function renderBullet(e) {
@@ -1,5 +1,7 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { readFileSync } from "node:fs";
2
3
  import { join } from "node:path";
4
+ import { assertSafeToolResultRef } from "../../core/tool-result-store.js";
3
5
  import { ensureDir, sanitizePathComponent, writeThenLink } from "./fs-atomic.js";
4
6
  export class FileToolResultStore {
5
7
  dir;
@@ -8,9 +10,11 @@ export class FileToolResultStore {
8
10
  ensureDir(this.dir);
9
11
  }
10
12
  pathFor(ref) {
11
- return join(this.dir, `${sanitizePathComponent(ref)}.txt`);
13
+ assertSafeToolResultRef(ref);
14
+ return join(this.dir, `${sanitizePathComponent(encodeRefFilename(ref))}.txt`);
12
15
  }
13
16
  put(ref, content) {
17
+ assertSafeToolResultRef(ref);
14
18
  try {
15
19
  writeThenLink(this.pathFor(ref), content);
16
20
  }
@@ -28,8 +32,10 @@ export class FileToolResultStore {
28
32
  catch (err) {
29
33
  if (err.code === "ENOENT")
30
34
  return undefined;
31
- if (err instanceof Error && err.message.startsWith("file store: unsafe path component"))
35
+ if (err instanceof Error &&
36
+ (err.message.startsWith("file store: unsafe path component") || err.message.startsWith("tool-result store: unsafe ref"))) {
32
37
  return undefined;
38
+ }
33
39
  throw err;
34
40
  }
35
41
  const offset = Math.min(full.length, Math.max(0, intOr(opts?.offset, 0)));
@@ -38,6 +44,14 @@ export class FileToolResultStore {
38
44
  return { content, offset, totalChars: full.length };
39
45
  }
40
46
  }
47
+ const NATIVE_FILENAME_CHARSET = /^[A-Za-z0-9_.-]+$/;
48
+ const MAX_FILENAME_CHARS = 180;
49
+ function encodeRefFilename(ref) {
50
+ if (ref.length <= MAX_FILENAME_CHARS && NATIVE_FILENAME_CHARSET.test(ref))
51
+ return ref;
52
+ const base = ref.replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 64);
53
+ return `${base || "ref"}-${createHash("sha256").update(ref, "utf8").digest("hex")}`;
54
+ }
41
55
  function intOr(x, fallback) {
42
56
  return Number.isFinite(x) ? Math.floor(x) : fallback;
43
57
  }
@@ -1,8 +1,10 @@
1
1
  import { type WorkflowJournalEntry, type WorkflowJournalStore } from "../../core/workflow-journal-store.js";
2
2
  export { MAX_JOURNAL_RESULT_BYTES, oversizeJournalResult } from "../../core/workflow-journal-store.js";
3
+ export declare const RESUME_CLAIM_TTL_MS: number;
3
4
  export declare class FileWorkflowJournalStore implements WorkflowJournalStore {
4
5
  private readonly fsyncEnabled;
5
6
  private readonly dir;
7
+ private readonly claimsDir;
6
8
  private readonly shared;
7
9
  private readonly sharedKey;
8
10
  private closed;
@@ -14,6 +16,21 @@ export declare class FileWorkflowJournalStore implements WorkflowJournalStore {
14
16
  private replay;
15
17
  append(runId: string, scope: string, entry: WorkflowJournalEntry): Promise<void>;
16
18
  load(runId: string, scope: string): Promise<WorkflowJournalEntry[]>;
19
+ private claimPathFor;
20
+ private readClaim;
21
+ resumeClaim(input: {
22
+ sourceRunId: string;
23
+ newRunId: string;
24
+ scope: string;
25
+ }): Promise<{
26
+ granted: boolean;
27
+ holder?: string;
28
+ }>;
29
+ releaseResumeClaim(input: {
30
+ sourceRunId: string;
31
+ newRunId: string;
32
+ scope: string;
33
+ }): Promise<void>;
17
34
  deleteByRun(runId: string): Promise<number>;
18
35
  dispose(): void;
19
36
  }
@@ -1,14 +1,18 @@
1
- import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
1
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
2
3
  import { join } from "node:path";
3
4
  import { callKeyOrdinal } from "../../core/workflow-journal-store.js";
4
5
  import { AppendLog } from "./fs-atomic.js";
5
6
  import { canonicalStoreKey, sanitizePathComponent } from "./fs-atomic.js";
6
7
  import { oversizeJournalResult } from "../../core/workflow-journal-store.js";
7
8
  export { MAX_JOURNAL_RESULT_BYTES, oversizeJournalResult } from "../../core/workflow-journal-store.js";
9
+ export const RESUME_CLAIM_TTL_MS = 60 * 60 * 1000;
8
10
  const sharedJournalDirs = new Map();
11
+ const MAX_OPEN_JOURNAL_LOGS = 64;
9
12
  export class FileWorkflowJournalStore {
10
13
  fsyncEnabled;
11
14
  dir;
15
+ claimsDir;
12
16
  shared;
13
17
  sharedKey;
14
18
  closed = false;
@@ -22,6 +26,7 @@ export class FileWorkflowJournalStore {
22
26
  this.fsyncEnabled = fsyncEnabled;
23
27
  this.dir = join(root, "workflow-journal");
24
28
  mkdirSync(this.dir, { recursive: true, mode: 0o700 });
29
+ this.claimsDir = join(this.dir, "claims");
25
30
  this.sharedKey = canonicalStoreKey(this.dir);
26
31
  const existing = sharedJournalDirs.get(this.sharedKey);
27
32
  if (existing !== undefined) {
@@ -87,7 +92,22 @@ export class FileWorkflowJournalStore {
87
92
  let log = this.logs.get(runId);
88
93
  if (log === undefined) {
89
94
  log = new AppendLog(this.pathFor(runId));
90
- this.logs.set(runId, log);
95
+ }
96
+ else {
97
+ this.logs.delete(runId);
98
+ }
99
+ this.logs.set(runId, log);
100
+ while (this.logs.size > MAX_OPEN_JOURNAL_LOGS) {
101
+ const coldest = this.logs.keys().next().value;
102
+ if (coldest === undefined || coldest === runId)
103
+ break;
104
+ const stale = this.logs.get(coldest);
105
+ this.logs.delete(coldest);
106
+ try {
107
+ stale?.close();
108
+ }
109
+ catch {
110
+ }
91
111
  }
92
112
  const line = { scope, ordinal: callKeyOrdinal(entry.callKey), callKey: entry.callKey, result: entry.result };
93
113
  log.append(line, this.fsyncEnabled);
@@ -98,6 +118,86 @@ export class FileWorkflowJournalStore {
98
118
  return [];
99
119
  return [...rec.byOrdinal.entries()].sort((a, b) => a[0] - b[0]).map(([, e]) => e);
100
120
  }
121
+ claimPathFor(sourceRunId, scope) {
122
+ const scopeTag = createHash("sha256").update(scope).digest("hex").slice(0, 16);
123
+ return join(this.claimsDir, `${sanitizePathComponent(sourceRunId)}.${scopeTag}.json`);
124
+ }
125
+ readClaim(path) {
126
+ if (!existsSync(path))
127
+ return undefined;
128
+ try {
129
+ const rec = JSON.parse(readFileSync(path, "utf8"));
130
+ if (typeof rec.holder !== "string" || typeof rec.expiresAt !== "number" || !Number.isFinite(rec.expiresAt)) {
131
+ return undefined;
132
+ }
133
+ return rec;
134
+ }
135
+ catch {
136
+ return undefined;
137
+ }
138
+ }
139
+ async resumeClaim(input) {
140
+ const { sourceRunId, newRunId, scope } = input;
141
+ let path;
142
+ try {
143
+ path = this.claimPathFor(sourceRunId, scope);
144
+ }
145
+ catch {
146
+ return { granted: true };
147
+ }
148
+ mkdirSync(this.claimsDir, { recursive: true, mode: 0o700 });
149
+ for (let attempt = 0; attempt < 2; attempt++) {
150
+ const record = { sourceRunId, holder: newRunId, expiresAt: Date.now() + RESUME_CLAIM_TTL_MS };
151
+ try {
152
+ const fd = openSync(path, "wx", 0o600);
153
+ try {
154
+ writeSync(fd, JSON.stringify(record));
155
+ }
156
+ finally {
157
+ closeSync(fd);
158
+ }
159
+ return { granted: true };
160
+ }
161
+ catch (err) {
162
+ if (err.code !== "EEXIST")
163
+ throw err;
164
+ }
165
+ const existing = this.readClaim(path);
166
+ if (existing !== undefined && existing.holder === newRunId) {
167
+ return { granted: true };
168
+ }
169
+ if (existing === undefined || existing.expiresAt <= Date.now()) {
170
+ try {
171
+ unlinkSync(path);
172
+ }
173
+ catch {
174
+ }
175
+ continue;
176
+ }
177
+ return { granted: false, holder: existing.holder };
178
+ }
179
+ const winner = this.readClaim(path);
180
+ if (winner === undefined || winner.holder === newRunId)
181
+ return { granted: true };
182
+ return { granted: false, holder: winner.holder };
183
+ }
184
+ async releaseResumeClaim(input) {
185
+ let path;
186
+ try {
187
+ path = this.claimPathFor(input.sourceRunId, input.scope);
188
+ }
189
+ catch {
190
+ return;
191
+ }
192
+ const existing = this.readClaim(path);
193
+ if (existing === undefined || existing.holder !== input.newRunId)
194
+ return;
195
+ try {
196
+ unlinkSync(path);
197
+ }
198
+ catch {
199
+ }
200
+ }
101
201
  async deleteByRun(runId) {
102
202
  const log = this.logs.get(runId);
103
203
  if (log !== undefined) {
@@ -159,7 +159,7 @@ export function classifyCompoundReadonly(command, allow) {
159
159
  const deviceArgs = toks.slice(1).filter((t) => !t.startsWith("-")).map(normalizeAbsPathLexically).filter(isBlockedDevicePath);
160
160
  const rescuedByHead = headBoundIsSmall(name, toks) && deviceArgs.every((d) => GENERATOR_DEVICES.has(d));
161
161
  if (!rescuedByHead && deviceArgs.length > 0) {
162
- return "an unbounded device source (/dev/zero, /dev/stdin, /proc/<pid>/fd/0, …) would block the pipeline until the tool timeout — not auto-allowed";
162
+ return "reads a device/special file that is either unbounded (/dev/zero, /dev/stdin, /proc/<pid>/fd/0, … blocks the pipeline until the tool timeout) or process-private (/proc/<pid>/environ, /proc/<pid>/mem, …) — not auto-allowed";
163
163
  }
164
164
  }
165
165
  return undefined;
@@ -250,10 +250,10 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
250
250
  }
251
251
  const truncated = start > 1 || end < total || pageMarker !== undefined;
252
252
  const prev = state.get(r.key);
253
- if (total > 0 && prev?.seededFromContext && start === 1 && effLimit === undefined && prev.hash === hash) {
253
+ if (total > 0 && prev?.seededFromContext && !prev.isPartialView && start === 1 && effLimit === undefined && prev.hash === hash) {
254
254
  return seededFileUnchangedReminder(r.key);
255
255
  }
256
- if (total > 0 && prev && prev.hash === hash && prev.view && prev.view.start === start && prev.view.end === end) {
256
+ if (total > 0 && prev && !prev.isPartialView && prev.hash === hash && prev.view && prev.view.start === start && prev.view.end === end) {
257
257
  return `[${path}: unchanged since you last read it (lines ${start}-${end} of ${total}); content omitted to save context]`;
258
258
  }
259
259
  state.set(r.key, {
@@ -151,6 +151,8 @@ export function createGlobTool(env, rootCanonical, additionalRoots) {
151
151
  scoped = r.key;
152
152
  }
153
153
  const r2 = await runGlobDetailed(env, rootCanonical, pattern, { path: scoped, max: max_results }, ctx.signal);
154
+ if (r2.error !== undefined)
155
+ return errorResult(r2.error);
154
156
  return {
155
157
  content: r2.text,
156
158
  details: { type: "glob", filenames: r2.filenames, numFiles: r2.numFiles, truncated: r2.truncated, durationMs: r2.durationMs, totalMatches: r2.totalMatches, countIsComplete: r2.countIsComplete },
@@ -162,7 +164,7 @@ export const HAND_TOOL_EFFECTS = {
162
164
  Read: "read",
163
165
  Edit: "write",
164
166
  MultiEdit: "write",
165
- Write: "idempotent",
167
+ Write: "write",
166
168
  NotebookEdit: "write",
167
169
  Grep: "read",
168
170
  Glob: "read",
@@ -18,7 +18,8 @@ export declare function persistedTextOf(encoded: string | Uint8Array): string;
18
18
  export declare function notReadRefusalText(env: ExecutionEnv, toolName: string, key: string, v: {
19
19
  code: string;
20
20
  message: string;
21
- }, signal?: AbortSignal): Promise<string>;
21
+ partialView?: boolean;
22
+ }, signal?: AbortSignal, fallbackHint?: string): Promise<string>;
22
23
  export declare const MAX_IMAGE_READ_BYTES: number;
23
24
  export declare const MAX_IMAGE_DOWNSAMPLE_INPUT_BYTES: number;
24
25
  export type ReadImageDownsamplerOption = ImageDownsampler | false | undefined;
@@ -1,6 +1,6 @@
1
1
  import { Type } from "typebox";
2
2
  import { clipWithFilePointer } from "../../core/tool-errors.js";
3
- import { sha256, similarNameSuggestion, OVERSIZE_READ_ESCAPE_HINT, } from "./safety.js";
3
+ import { sha256, similarNameSuggestion, OVERSIZE_READ_ESCAPE_HINT, PARTIAL_VIEW_READ_ESCAPE_HINT, } from "./safety.js";
4
4
  import { decodeTextBytes, normalizeFileText } from "./encoding.js";
5
5
  import { shellQuote } from "./search.js";
6
6
  import { isNotebookPath } from "./notebook.js";
@@ -30,10 +30,14 @@ export function decodeEditBytes(bytes, path) {
30
30
  export function persistedTextOf(encoded) {
31
31
  return decodeTextBytes(typeof encoded === "string" ? Buffer.from(encoded, "utf8") : encoded).text;
32
32
  }
33
- export async function notReadRefusalText(env, toolName, key, v, signal) {
33
+ export async function notReadRefusalText(env, toolName, key, v, signal, fallbackHint) {
34
34
  const base = `Error (${toolName}): ${v.message}`;
35
+ if (v.partialView === true && !isNotebookPath(key))
36
+ return `${base} ${PARTIAL_VIEW_READ_ESCAPE_HINT}`;
35
37
  const info = await env.fileInfo(key, signal);
36
- return info.ok && info.value.kind !== "directory" && info.value.size > MAX_READ_BYTES && !isNotebookPath(key) ? `${base} ${OVERSIZE_READ_ESCAPE_HINT}` : base;
38
+ if (info.ok && info.value.kind !== "directory" && info.value.size > MAX_READ_BYTES && !isNotebookPath(key))
39
+ return `${base} ${OVERSIZE_READ_ESCAPE_HINT}`;
40
+ return fallbackHint === undefined ? base : `${base} ${fallbackHint}`;
37
41
  }
38
42
  export const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024;
39
43
  export const MAX_IMAGE_DOWNSAMPLE_INPUT_BYTES = 64 * 1024 * 1024;
@@ -1,8 +1,8 @@
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, requireRead, checkStale, checkEditMatch, checkNoChange, fileArgPath, resolveQuoteMatch, adaptNewStringQuotes, deletionOldString, countOccurrences, } from "./safety.js";
5
- import { decodeTextBytes, encodeTextForFile, normalizeEditText, normalizeFileText, splitLeadingBom } from "./encoding.js";
4
+ import { sha256, resolveKey, violationText, requireRead, checkStale, checkEditMatch, checkNoChange, fileArgPath, resolveQuoteMatch, adaptNewStringQuotes, deletionOldString, countOccurrences, WRITE_ENCODING_DEADLOCK_ESCAPE_HINT, } from "./safety.js";
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) {
8
8
  if (hook === undefined)
@@ -218,7 +218,7 @@ export function createWriteFileTool(env, state, rootCanonical, cwdRef, additiona
218
218
  ...FILE_PATH_PARAMS,
219
219
  content: Type.String({ description: "The content to write to the file" }),
220
220
  }),
221
- effect: "idempotent",
221
+ effect: "write",
222
222
  execute: async (args, ctx) => {
223
223
  const { content } = args;
224
224
  const path = fileArgPath(args);
@@ -237,8 +237,7 @@ export function createWriteFileTool(env, state, rootCanonical, cwdRef, additiona
237
237
  if (exists.value) {
238
238
  const notRead = requireRead(state, r.key);
239
239
  if (notRead) {
240
- return errorResult(`${violationText("Write", notRead)} ` +
241
- `(If the Read tool refuses this file (binary/unknown encoding), overwrite or convert it with bash instead — e.g. \`rm\` + rewrite, or \`iconv\`.)`);
240
+ return errorResult(await notReadRefusalText(env, "Write", r.key, notRead, ctx.signal, WRITE_ENCODING_DEADLOCK_ESCAPE_HINT));
242
241
  }
243
242
  const readBin = await env.readBinaryFile(r.key, ctx.signal);
244
243
  if (!readBin.ok)
@@ -253,16 +252,15 @@ export function createWriteFileTool(env, state, rootCanonical, cwdRef, additiona
253
252
  const gated = await gateToolWrite(beforeWrite, "Write", path, r.key, content);
254
253
  if (gated !== undefined)
255
254
  return errorResult(gated);
256
- const outgoing = splitLeadingBom(content);
257
- const writeEncoding = outgoing.hadBom && !decodedPrev.encoding.hadBom ? { ...decodedPrev.encoding, hadBom: true } : decodedPrev.encoding;
258
- const write = await env.writeFile(r.key, encodeTextForFile(outgoing.text, writeEncoding, "preserve"), ctx.signal);
255
+ const encodedWrite = encodeTextForFile(content, decodedPrev.encoding, "preserve");
256
+ const write = await env.writeFile(r.key, encodedWrite, ctx.signal);
259
257
  if (!write.ok)
260
258
  return errorResult(`Error (Write): cannot write "${path}": ${write.error.message}`);
261
- const totalLines = countLines(outgoing.text);
262
- state.set(r.key, { hash: sha256(normalizeFileText(content)), totalLines, truncated: false, lastReadAt: Date.now() });
259
+ const persistedWrite = persistedTextOf(encodedWrite);
260
+ state.set(r.key, { hash: sha256(persistedWrite), totalLines: countLines(persistedWrite), truncated: false, lastReadAt: Date.now() });
263
261
  return {
264
262
  content: `The file ${path} has been updated successfully.${FILE_STATE_TRAILER}`,
265
- details: { type: "update", filePath: path, content: normalizeFileText(content), originalFile },
263
+ details: { type: "update", filePath: path, content: persistedWrite, originalFile },
266
264
  };
267
265
  }
268
266
  const gatedCreate = await gateToolWrite(beforeWrite, "Write", path, r.key, content);
@@ -17,6 +17,7 @@ export type ReadFileState = Map<string, ReadEntry>;
17
17
  export declare function sha256(content: string): string;
18
18
  export interface FsViolation {
19
19
  code: "path_not_in_root" | "not_read" | "stale" | "ambiguous_edit" | "invalid";
20
+ partialView?: true;
20
21
  message: string;
21
22
  }
22
23
  export declare function isBlockedDevicePath(key: string): boolean;
@@ -44,6 +45,8 @@ export declare function canonicalizeTarget(env: ExecutionEnv, path: string, sign
44
45
  export declare function violationText(toolName: string, v: FsViolation): string;
45
46
  export declare function requireRead(state: ReadFileState, key: string): FsViolation | undefined;
46
47
  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.)";
48
+ 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.)";
49
+ 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`.)";
47
50
  export declare function checkNoChange(oldString: string, newString: string): FsViolation | undefined;
48
51
  export declare function checkStale(entry: ReadEntry, currentHash: string): FsViolation | undefined;
49
52
  export declare function countOccurrences(haystack: string, needle: string): number;
@@ -38,17 +38,23 @@ const BLOCKED_DEVICE_PATHS = new Set([
38
38
  "/dev/stdin", "/dev/stdout", "/dev/stderr", "/dev/tty", "/dev/console",
39
39
  "/dev/fd/0", "/dev/fd/1", "/dev/fd/2",
40
40
  ]);
41
- const PROC_FD_RE = /^\/proc\/[^/]+\/fd\/[0-2]$/;
41
+ function isProcStdioFd(key) {
42
+ return key.startsWith("/proc/") && (key.endsWith("/fd/0") || key.endsWith("/fd/1") || key.endsWith("/fd/2"));
43
+ }
44
+ const PROC_SENSITIVE_RE = /^\/proc\/[^/]+\/(environ|cmdline|auxv|maps|mem|stat)$/;
42
45
  const WIN_RESERVED_RE = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.[^\\/]*)?$/i;
46
+ function isWinFormPath(p) {
47
+ return /^[A-Za-z]:[\\/]/.test(p) || p.startsWith("\\\\") || (!p.startsWith("/") && p.includes("\\"));
48
+ }
43
49
  function isWinReservedDeviceKey(key) {
44
- if (!(/^[A-Za-z]:[\\/]/.test(key) || key.startsWith("\\\\") || (!key.startsWith("/") && key.includes("\\"))))
50
+ if (!isWinFormPath(key))
45
51
  return false;
46
52
  const t = key.replace(/[\\/]+$/, "");
47
53
  const base = t.slice(Math.max(t.lastIndexOf("/"), t.lastIndexOf("\\")) + 1);
48
54
  return WIN_RESERVED_RE.test(base);
49
55
  }
50
56
  export function isBlockedDevicePath(key) {
51
- return BLOCKED_DEVICE_PATHS.has(key) || PROC_FD_RE.test(key) || isWinReservedDeviceKey(key);
57
+ return BLOCKED_DEVICE_PATHS.has(key) || isProcStdioFd(key) || PROC_SENSITIVE_RE.test(key) || isWinReservedDeviceKey(key);
52
58
  }
53
59
  export function normalizeAbsPathLexically(p) {
54
60
  if (!p.startsWith("/"))
@@ -168,10 +174,11 @@ export async function resolveKey(env, rootCanonical, path, signal, baseCwd, addi
168
174
  return { ok: true, key };
169
175
  }
170
176
  export async function canonicalizeTarget(env, path, signal, baseCwd) {
171
- if (isUncPath(path)) {
172
- return { ok: false, message: `path "${path}" is a UNC/network path; refused (potential credential leak).` };
177
+ if (isUncPath(path) && isWinFormPath(path)) {
178
+ return { ok: true, key: path };
173
179
  }
174
- const target = baseCwd && !isAbsolutePathForm(path) ? `${baseCwd.replace(/[\\/]+$/, "")}/${path}` : path;
180
+ const spelled = path.startsWith("//") ? path.replace(/^\/+/, "/") : path;
181
+ const target = baseCwd && !isAbsolutePathForm(spelled) ? `${baseCwd.replace(/[\\/]+$/, "")}/${spelled}` : spelled;
175
182
  const absR = await env.absolutePath(target, signal);
176
183
  if (!absR.ok)
177
184
  return { ok: false, message: `cannot resolve path "${path}": ${absR.error.message}` };
@@ -230,11 +237,17 @@ export function violationText(toolName, v) {
230
237
  export function requireRead(state, key) {
231
238
  const entry = state.get(key);
232
239
  if (entry === undefined || entry.isPartialView) {
233
- return { code: "not_read", message: "File has not been read yet. Read it first before writing to it." };
240
+ return {
241
+ code: "not_read",
242
+ message: "File has not been read yet. Read it first before writing to it.",
243
+ ...(entry?.isPartialView ? { partialView: true } : {}),
244
+ };
234
245
  }
235
246
  return undefined;
236
247
  }
237
248
  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.)";
249
+ 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.)";
250
+ 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`.)";
238
251
  export function checkNoChange(oldString, newString) {
239
252
  if (oldString === newString) {
240
253
  return { code: "invalid", message: "No changes to make: old_string and new_string are exactly the same." };
@@ -69,4 +69,5 @@ export declare function runGlobDetailed(env: ExecutionEnv, root: string, pattern
69
69
  durationMs: number;
70
70
  totalMatches: number;
71
71
  countIsComplete: boolean;
72
+ error?: string;
72
73
  }>;
@@ -1089,6 +1089,13 @@ export async function runGlobDetailed(env, root, pattern, opts = {}, signal) {
1089
1089
  const baseIgnore = await buildIgnore(env, root, signal);
1090
1090
  const rootPrefix = root.replace(/[\\/]+$/, "") + (root.includes("\\") ? "\\" : "/");
1091
1091
  const start = opts.path ? (opts.path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(opts.path) ? opts.path : `${rootPrefix}${opts.path}`) : root;
1092
+ if (opts.path !== undefined) {
1093
+ const probe = await env.fileInfo(start, signal);
1094
+ if (!probe.ok && probe.error.code === "not_found") {
1095
+ const error = `Error (Glob): path ${JSON.stringify(opts.path)} does not exist — check the path, or omit \`path\` to search the whole root.`;
1096
+ return { text: error, error, filenames: [], numFiles: 0, truncated: false, durationMs: Date.now() - t0, totalMatches: 0, countIsComplete: false };
1097
+ }
1098
+ }
1092
1099
  const pat = normalizeGlobToken(pattern);
1093
1100
  const anchored = globIsAnchored(pattern);
1094
1101
  const re = globTokenToRegExp(pat, anchored, true);
@@ -1131,6 +1138,7 @@ export async function runGlobDetailed(env, root, pattern, opts = {}, signal) {
1131
1138
  return k < starStarIdx ? literalAt[k] === segs[k] : literalAfterStarStar.has(segs[k]);
1132
1139
  };
1133
1140
  let ignoredDirs = 0;
1141
+ let ignoredFiles = 0;
1134
1142
  const rescued = [];
1135
1143
  const ignore = (relPath, isDir) => {
1136
1144
  const sr = toStartRel(relPath);
@@ -1144,8 +1152,12 @@ export async function runGlobDetailed(env, root, pattern, opts = {}, signal) {
1144
1152
  if (rescued.length > 0 && rescued.some((r) => sr.startsWith(r)))
1145
1153
  return false;
1146
1154
  const ig = baseIgnore(relPath, isDir);
1147
- if (ig && isDir)
1148
- ignoredDirs++;
1155
+ if (ig) {
1156
+ if (isDir)
1157
+ ignoredDirs++;
1158
+ else
1159
+ ignoredFiles++;
1160
+ }
1149
1161
  return ig;
1150
1162
  };
1151
1163
  const walked = await walk(env, root, start, ignore, signal);
@@ -1173,6 +1185,12 @@ export async function runGlobDetailed(env, root, pattern, opts = {}, signal) {
1173
1185
  const ignoreNote = matched.length === 0 && ignoredDirs > 0
1174
1186
  ? `\n[note: ${ignoredDirs} ignored director${ignoredDirs === 1 ? "y was" : "ies were"} not searched (dependency/build/VCS trees and .gitignore) — name a directory in the pattern (e.g. "dist/**") or pass \`path\` to include it]`
1175
1187
  : "";
1176
- const text = matched.length === 0 ? "No files matched." + ignoreNote + caveat : matched.join("\n") + capNote + caveat;
1188
+ const ignoredFileNote = matched.length === 0 && ignoredFiles > 0
1189
+ ? `\n[note: ${ignoredFiles} file(s) matching an ignore rule (.gitignore) were skipped — an empty result here is NOT proof the file is absent; name it literally with a root-anchored path (e.g. "/notes.secret", "logs/app.log") to include it, or read it directly]`
1190
+ : "";
1191
+ const budgetNote = matched.length === 0 && walked.incomplete
1192
+ ? `\n[note: the file-walk budget (${WALK_MAX_FILES} files / ${WALK_MAX_DEPTH} directory levels) ran out before the whole tree was scanned — this empty result is NOT proof the file is absent; scope the search with \`path\`, or use a root-anchored pattern (e.g. "src/**/*.ts") so the walk is pruned toward it, then retry]`
1193
+ : "";
1194
+ const text = matched.length === 0 ? "No files matched." + ignoreNote + ignoredFileNote + budgetNote + caveat : matched.join("\n") + capNote + caveat;
1177
1195
  return { text, filenames: matched, numFiles: matched.length, truncated, durationMs: Date.now() - t0, totalMatches, countIsComplete };
1178
1196
  }
@@ -20,5 +20,6 @@ export interface TaskListStore {
20
20
  }
21
21
  export declare function normalizeTaskShape<T>(item: T): T;
22
22
  export declare function assertJsonMetadata(value: unknown, path?: string): void;
23
+ export declare function compareTaskIds(a: string, b: string): number;
23
24
  export declare function createMemoryTaskListStore(): TaskListStore;
24
25
  export declare function createTaskListTools(store?: TaskListStore): ToolSpec[];