@vincemakes/kiso-tools-node 0.47.3 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/corpus.d.ts CHANGED
@@ -101,6 +101,18 @@ export declare function corpusSkips(declared: boolean, layers: readonly Layer[],
101
101
  /** The layers in force inside `dir`, given its parent's. */
102
102
  export declare function layersEntering(dir: string, parent: readonly Layer[]): readonly Layer[];
103
103
  export declare function walkCorpus(opts: CorpusOptions): CorpusWalk;
104
+ /** Directory entries by name, in UTF-16 code-unit order — the same on
105
+ * every platform, unlike readdir's own order (name order on APFS and
106
+ * NTFS, hash order on ext4). */
107
+ export declare function byName(a: {
108
+ name: string;
109
+ }, b: {
110
+ name: string;
111
+ }): number;
112
+ /** Workspace-relative POSIX paths by segment, each by code unit: `a/x`
113
+ * before `a-b` before `b`, which is the order a depth-first walk by name
114
+ * lists them in. */
115
+ export declare function bySegments(a: string, b: string): number;
104
116
  /** A minimal glob → RegExp, anchored, over workspace-relative POSIX paths.
105
117
  *
106
118
  * `*` any run except `/`, `?` one character except `/`, `**` any run
package/dist/corpus.js CHANGED
@@ -147,24 +147,43 @@ export function walkCorpus(opts) {
147
147
  let cutByDeadline = false;
148
148
  let visited = 0;
149
149
  const stopped = () => cutByCap || cutByVisits || cutByDeadline;
150
- const walk = (dir, depth, layers) => {
151
- if (stopped())
152
- return;
150
+ const start = opts.walkFrom ?? root;
151
+ // entering a subtree, the layers between root and it still apply
152
+ let startLayers = rootLayer === null ? [] : [rootLayer];
153
+ if (start !== root) {
154
+ let cur = root;
155
+ for (const part of relative(root, start).split(sep).filter(Boolean)) {
156
+ cur = join(cur, part);
157
+ startLayers = layersEntering(cur, startLayers);
158
+ }
159
+ }
160
+ // 0.48.0 (finding 0472-F2) — BREADTH-FIRST, files before directories.
161
+ // The walk was depth-first in readdir order, so under a budget a deep
162
+ // sibling directory spent it before a file one level up was looked at:
163
+ // `Desktop/devv/` sorts before `Desktop/generations.ts`, and the owner's
164
+ // glob for that file stopped inside devv. Level by level, each
165
+ // directory's entries in name order and its files first, the budget cuts
166
+ // the deepest part of the tree and never a file nearer the root.
167
+ const queue = [{ dir: start, depth: 0, layers: startLayers }];
168
+ for (let next = 0; next < queue.length && !stopped(); next += 1) {
169
+ const { dir, depth, layers } = queue[next];
153
170
  if (Date.now() > deadline) {
154
171
  cutByDeadline = true;
155
- return;
172
+ break;
156
173
  }
157
174
  let entries;
158
175
  try {
159
176
  entries = readdirSync(dir, { withFileTypes: true });
160
177
  }
161
178
  catch {
162
- return; // unreadable directory: skipped, as before
179
+ continue; // unreadable directory: skipped, as before
163
180
  }
181
+ entries.sort(byName);
164
182
  const here = depth === 0 ? layers : layersEntering(dir, layers);
183
+ const subdirs = [];
165
184
  for (const entry of entries) {
166
185
  if (stopped())
167
- return;
186
+ break;
168
187
  const name = entry.name;
169
188
  const full = join(dir, name);
170
189
  const isDir = entry.isDirectory();
@@ -177,42 +196,53 @@ export function walkCorpus(opts) {
177
196
  cutByDepth = true;
178
197
  continue;
179
198
  }
180
- walk(full, depth + 1, here);
199
+ subdirs.push({ dir: full, depth: depth + 1, layers: here });
181
200
  continue;
182
201
  }
183
202
  if (visited >= maxVisited) {
184
203
  cutByVisits = true;
185
- return;
204
+ break;
186
205
  }
187
206
  visited += 1;
188
207
  // the clock is read every 256 files, not per file
189
208
  if ((visited & 255) === 0 && Date.now() > deadline) {
190
209
  cutByDeadline = true;
191
- return;
210
+ break;
192
211
  }
193
212
  const rel = relative(root, full).split(sep).join("/");
194
213
  if (opts.accept !== undefined && !opts.accept(rel))
195
214
  continue;
196
215
  if (files.length >= maxEntries) {
197
216
  cutByCap = true;
198
- return;
217
+ break;
199
218
  }
200
219
  files.push(rel);
201
220
  }
202
- };
203
- const start = opts.walkFrom ?? root;
204
- // entering a subtree, the layers between root and it still apply
205
- let startLayers = rootLayer === null ? [] : [rootLayer];
206
- if (start !== root) {
207
- let cur = root;
208
- for (const part of relative(root, start).split(sep).filter(Boolean)) {
209
- cur = join(cur, part);
210
- startLayers = layersEntering(cur, startLayers);
211
- }
221
+ queue.push(...subdirs);
212
222
  }
213
- walk(start, 0, startLayers);
223
+ // what was walked, in an order that does not depend on the filesystem's
224
+ // readdir: by path segments, which is a depth-first listing by name
225
+ files.sort(bySegments);
214
226
  return { files, cutByDepth, cutByCap, cutByVisits, cutByDeadline, visited };
215
227
  }
228
+ /** Directory entries by name, in UTF-16 code-unit order — the same on
229
+ * every platform, unlike readdir's own order (name order on APFS and
230
+ * NTFS, hash order on ext4). */
231
+ export function byName(a, b) {
232
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
233
+ }
234
+ /** Workspace-relative POSIX paths by segment, each by code unit: `a/x`
235
+ * before `a-b` before `b`, which is the order a depth-first walk by name
236
+ * lists them in. */
237
+ export function bySegments(a, b) {
238
+ const x = a.split("/");
239
+ const y = b.split("/");
240
+ for (let i = 0; i < Math.min(x.length, y.length); i += 1) {
241
+ if (x[i] !== y[i])
242
+ return x[i] < y[i] ? -1 : 1;
243
+ }
244
+ return x.length - y.length;
245
+ }
216
246
  /** A minimal glob → RegExp, anchored, over workspace-relative POSIX paths.
217
247
  *
218
248
  * `*` any run except `/`, `?` one character except `/`, `**` any run
package/dist/index.d.ts CHANGED
@@ -156,6 +156,10 @@ export interface WorkspaceToolsOptions {
156
156
  readonly searchMaxMs?: number;
157
157
  /** read_file: refuse a file larger than this (default 64 MiB). */
158
158
  readonly readMaxFileBytes?: number;
159
+ /** shell, with tasks wired: the least foreground wait (default 60s,
160
+ * the default wait). A shorter `foregroundMs` is raised to it; 0
161
+ * keeps the model's value as asked (ADR-0058 Amendment 10). */
162
+ readonly minForegroundMs?: number;
159
163
  };
160
164
  }
161
165
  export declare function readFileTool(opts: WorkspaceToolsOptions): Tool<{
@@ -183,16 +187,23 @@ export declare function writeFileTool(opts: WorkspaceToolsOptions): Tool<{
183
187
  content: string;
184
188
  expectedRevision?: string;
185
189
  }>;
186
- export declare function editFileTool(opts: WorkspaceToolsOptions): Tool<{
190
+ /** One hunk as a caller may send it: the current vocabulary, or the
191
+ * legacy `search`/`replace` a durable log or a host may still hold. */
192
+ type EditHunkInput = {
193
+ readonly oldText?: string;
194
+ readonly newText?: string;
195
+ readonly search?: string;
196
+ readonly replace?: string;
197
+ readonly expectedRevision?: string;
198
+ };
199
+ type EditFileInput = {
187
200
  path: string;
201
+ edits?: readonly EditHunkInput[];
188
202
  search?: string;
189
203
  replace?: string;
190
- edits?: readonly {
191
- search: string;
192
- replace: string;
193
- }[];
194
204
  expectedRevision?: string;
195
- }>;
205
+ };
206
+ export declare function editFileTool(opts: WorkspaceToolsOptions): Tool<EditFileInput>;
196
207
  export { SHELL_STRIP_EXACT, strippedShellEnv } from "./secret-env.js";
197
208
  export { PROTECTED_REFUSAL, diskPath, isProtectedPath, protectedIdentity, protectedRefusalText, readUnlessProtected, type ProtectedIdentity } from "./protected.js";
198
209
  /** The search corpus's credential rule, by name — exported so the CLI's
package/dist/index.js CHANGED
@@ -382,7 +382,7 @@ export function readFileTool(opts) {
382
382
  // safety claim: the kernel enforces both, and had this line been
383
383
  // omitted the tool would simply have been slower, never less correct.
384
384
  effects: { precommitSafe: true, concurrency: "shared" },
385
- promptSnippet: "read_file — whole files or offset/limit ranges, workspace-relative paths",
385
+ promptSnippet: "read_file — read a file or a line range",
386
386
  promptGuidelines: ["read only the range you need — offset/limit beat whole-file reads"],
387
387
  execute: async ({ path, offset, limit }, ctx) => {
388
388
  const maxReadBytes = opts.limits?.readMaxFileBytes ?? READ_MAX_FILE_BYTES;
@@ -544,7 +544,7 @@ export function listDirTool(opts) {
544
544
  idempotent: true,
545
545
  // EC-1 ②: read-only, free, local — see read_file above.
546
546
  effects: { precommitSafe: true, concurrency: "shared" },
547
- promptSnippet: "list_dir — directory entries (the workspace ls)",
547
+ promptSnippet: "list_dir — list a directory",
548
548
  promptGuidelines: ["narrow to a subdirectory when the listing caps at 200 entries"],
549
549
  execute: async ({ path, glob }, ctx) => {
550
550
  try {
@@ -761,7 +761,7 @@ export function searchTextTool(opts) {
761
761
  // commit-required and exclusive, which is what closes the same-path
762
762
  // write race without asking their authors to remember anything.
763
763
  effects: { precommitSafe: true, concurrency: "shared" },
764
- promptSnippet: "search_text — regex search over workspace files",
764
+ promptSnippet: "search_text — regex search in files",
765
765
  promptGuidelines: ["narrow the pattern when the result caps — never re-run a broad search"],
766
766
  execute: async ({ pattern, path, caseSensitive }, ctx) => {
767
767
  let root;
@@ -868,8 +868,8 @@ export function writeFileTool(opts) {
868
868
  required: ["path", "content", "expectedRevision"],
869
869
  additionalProperties: false,
870
870
  },
871
- promptSnippet: "write_file — create or replace a whole file",
872
- promptGuidelines: ["write/edit: cite the file's latest revision as expectedRevision; each successful mutation returns the next one; use \"absent\" only to create"],
871
+ promptSnippet: "write_file — write a whole file",
872
+ promptGuidelines: ["cite the file's latest revision as expectedRevision; each successful mutation returns the next one; use \"absent\" only to create"],
873
873
  execute: async ({ path, content, expectedRevision: citedRevision }) => {
874
874
  const maxReadBytes = opts.limits?.readMaxFileBytes ?? READ_MAX_FILE_BYTES;
875
875
  // WR-1-F2: normalize every plausible copy of the token FIRST —
@@ -1086,26 +1086,64 @@ function staleReport(text, hunks, single) {
1086
1086
  }
1087
1087
  return out.join("\n");
1088
1088
  }
1089
+ /**
1090
+ * ADR-0061 — compatibility belongs in the executor; vocabulary belongs in
1091
+ * the schema. The model is shown one form (`edits` of `{oldText, newText}`):
1092
+ * `search`/`replace` was written SWAPPED in 80 of 86 failed T6 edits (the
1093
+ * 2026-10-08 replay), and old/new cannot be read backwards. Everything a
1094
+ * caller that skips schema validation may send — an approved call persisted
1095
+ * before the upgrade, a host, a test — is still taken here: legacy hunks,
1096
+ * the legacy top-level pair, a hunk-level `expectedRevision` (ignored, the
1097
+ * call's own governs). A hunk that MIXES the two vocabularies takes neither
1098
+ * half. The schema stays in the small portable subset by choice (no
1099
+ * `oneOf`), so the shape rules live here.
1100
+ */
1101
+ function editHunksOf(input) {
1102
+ const pair = input.search !== undefined || input.replace !== undefined;
1103
+ if (pair && input.edits !== undefined)
1104
+ return "edit_file: pass edits — never edits and a top-level search/replace together";
1105
+ if (pair) {
1106
+ if (input.search === undefined || input.replace === undefined)
1107
+ return "edit_file: the legacy top-level form needs BOTH search and replace";
1108
+ return { hunks: [{ search: input.search, replace: input.replace }], single: true };
1109
+ }
1110
+ if (input.edits === undefined)
1111
+ return "edit_file: pass edits \u2014 1\u201332 {oldText, newText} replacements";
1112
+ if (input.edits.length === 0 || input.edits.length > 32)
1113
+ return `edit_file: edits must carry 1\u201332 replacements (got ${input.edits.length})`;
1114
+ const hunks = [];
1115
+ for (let i = 0; i < input.edits.length; i += 1) {
1116
+ const h = input.edits[i];
1117
+ const current = h.oldText !== undefined || h.newText !== undefined;
1118
+ const legacy = h.search !== undefined || h.replace !== undefined;
1119
+ if (current && legacy)
1120
+ return `edit_file: hunk ${i + 1} mixes oldText/newText with search/replace \u2014 send oldText and newText`;
1121
+ const oldText = current ? h.oldText : h.search;
1122
+ const newText = current ? h.newText : h.replace;
1123
+ if (typeof oldText !== "string" || typeof newText !== "string")
1124
+ return `edit_file: hunk ${i + 1} needs both oldText and newText`;
1125
+ hunks.push({ search: oldText, replace: newText });
1126
+ }
1127
+ return { hunks, single: false };
1128
+ }
1089
1129
  export function editFileTool(opts) {
1090
1130
  return defineTool({
1091
1131
  name: "edit_file",
1092
- description: "Edit a file at its latest revision (expectedRevision): search+replace (must match once) OR edits (1-32 hunks, applied in order, all or nothing). A refusal shows the current text and ends on its [rev:X].",
1132
+ description: "Edit a file at its latest revision (expectedRevision): exact replacements, applied in order, all or nothing. Batch changes to this file that are already known into one call. A refusal ends on its [rev:X].",
1093
1133
  parameters: {
1094
1134
  type: "object",
1095
1135
  properties: {
1096
1136
  path: { type: "string", description: "Workspace-relative file" },
1097
- search: { type: "string", description: "Exact literal text to find (single-hunk form)" },
1098
- replace: { type: "string", description: "Replacement text (single-hunk form)" },
1099
1137
  edits: {
1100
1138
  type: "array",
1101
- description: "Batch form: 1-32 {search, replace} hunks, applied in order",
1139
+ description: "1-32 replacements",
1102
1140
  items: {
1103
1141
  type: "object",
1104
- // ACI-3: `expectedRevision` inside a hunk is tolerated and ignored —
1105
- // the call's own governs (5 refusals on the owner's disk, every
1106
- // one with the top-level token present too).
1107
- properties: { search: { type: "string" }, replace: { type: "string" }, expectedRevision: { type: "string" } },
1108
- required: ["search", "replace"],
1142
+ properties: {
1143
+ oldText: { type: "string", description: "Exact text to replace at this step; must match exactly once after the earlier edits in this call" },
1144
+ newText: { type: "string", description: "Replacement text for oldText" },
1145
+ },
1146
+ required: ["oldText", "newText"],
1109
1147
  additionalProperties: false,
1110
1148
  },
1111
1149
  minItems: 1,
@@ -1113,31 +1151,20 @@ export function editFileTool(opts) {
1113
1151
  },
1114
1152
  expectedRevision: { type: "string", description: "The file's latest revision token" },
1115
1153
  },
1116
- required: ["path", "expectedRevision"],
1154
+ required: ["path", "edits", "expectedRevision"],
1117
1155
  additionalProperties: false,
1118
1156
  },
1119
- promptSnippet: "edit_file — replace an exact old_string block (never rewrite whole files)",
1120
- promptGuidelines: ["write/edit: cite the file's latest revision as expectedRevision; each successful mutation returns the next one; use \"absent\" only to create"],
1121
- execute: async ({ path, search, replace, edits, expectedRevision: citedRevision }) => {
1157
+ promptSnippet: "edit_file — replace oldText with newText",
1158
+ promptGuidelines: ["cite the file's latest revision as expectedRevision; each successful mutation returns the next one; use \"absent\" only to create"],
1159
+ execute: async (input) => {
1160
+ const { path, expectedRevision: citedRevision } = input;
1122
1161
  const maxReadBytes = opts.limits?.readMaxFileBytes ?? READ_MAX_FILE_BYTES;
1123
1162
  // WR-1-F2: normalize the citation (see write_file).
1124
1163
  const expectedRevision = citedRevision === undefined ? undefined : normalizeRevision(citedRevision);
1125
- // WR-1E2 — the form XOR (the draft-07 subset has no oneOf; the
1126
- // SHAPE rule is enforced here): legacy single-hunk XOR batch.
1127
- const single = search !== undefined || replace !== undefined;
1128
- if (single && edits !== undefined) {
1129
- return { content: "edit_file: pass EITHER search+replace OR edits — never both", isError: true, errorKind: "invalid_input" };
1130
- }
1131
- if (!single && edits === undefined) {
1132
- return { content: "edit_file: pass search+replace, or edits (1\u201332 hunks)", isError: true, errorKind: "invalid_input" };
1133
- }
1134
- if (single && (search === undefined || replace === undefined)) {
1135
- return { content: "edit_file: the single-hunk form needs BOTH search and replace", isError: true, errorKind: "invalid_input" };
1136
- }
1137
- if (edits !== undefined && (edits.length === 0 || edits.length > 32)) {
1138
- return { content: `edit_file: edits must carry 1\u201332 hunks (got ${edits.length})`, isError: true, errorKind: "invalid_input" };
1139
- }
1140
- const hunks = edits ?? [{ search: search, replace: replace }];
1164
+ const shape = editHunksOf(input);
1165
+ if (typeof shape === "string")
1166
+ return { content: shape, isError: true, errorKind: "invalid_input" };
1167
+ const { hunks, single } = shape;
1141
1168
  let full;
1142
1169
  try {
1143
1170
  full = resolveWithinRoot(opts.workspaceRoot, path);
@@ -1191,16 +1218,18 @@ export function editFileTool(opts) {
1191
1218
  // ACI-3: the refusal carries the file as it is where each hunk
1192
1219
  // should land, and its current revision last — the retry needs
1193
1220
  // no read. The headline is unchanged (bench parsers read it).
1194
- return precondition(`edit_file: ${path} changed since ${expectedRevision} — the file as it is now is below; re-apply the change against it, citing the [rev:…] on the last line\n${staleReport(text, hunks, edits === undefined)}\n[${current}]`);
1221
+ return precondition(`edit_file: ${path} changed since ${expectedRevision} — the file as it is now is below; re-apply the change against it, citing the [rev:…] on the last line\n${staleReport(text, hunks, single)}\n[${current}]`);
1195
1222
  }
1196
1223
  // ACI-3 (superseding WR-1E2's one-snapshot rule): the hunks apply
1197
1224
  // IN ORDER, each against the result of the ones before it — the
1198
- // way a person writes a second change after the first, and the
1199
- // reference implementation's multi-edit. 57 refusals on the
1225
+ // way a person writes a second change after the first. (The
1226
+ // reference implementation does NOT do this: it matches every edit
1227
+ // against the original and refuses overlaps.) 57 refusals on the
1200
1228
  // owner's disk were "two hunks overlap": a second hunk written
1201
- // against the first one's output. Still all or nothing: nothing is
1202
- // written unless every hunk resolves, and each must match exactly
1203
- // once in the text it meets (ACI-2).
1229
+ // against the first one's output. The 2026-10-08 replay found 0 of
1230
+ // 86 failures caused by this rule (ADR-0061). Still all or nothing:
1231
+ // nothing is written unless every hunk resolves, and each must
1232
+ // match exactly once in the text it meets (ACI-2).
1204
1233
  // Windows P3: a uniformly CRLF file is matched on its LF form and
1205
1234
  // written back CRLF — the model's search text uses LF, and an edit
1206
1235
  // never changes the file's endings. A file mixing the two keeps
@@ -1211,7 +1240,7 @@ export function editFileTool(opts) {
1211
1240
  for (let i = 0; i < hunks.length; i += 1) {
1212
1241
  const h = { search: lf(hunks[i].search), replace: lf(hunks[i].replace) };
1213
1242
  const { count, offsets } = occurrencesOf(edited, h.search);
1214
- const which = hunks.length === 1 && edits === undefined ? "" : i === 0 ? " (hunk 1)" : ` (hunk ${i + 1}, after ${i === 1 ? "hunk 1" : `hunks 1–${i}`} applied)`;
1243
+ const which = single ? "" : i === 0 ? " (hunk 1)" : ` (hunk ${i + 1}, after ${i === 1 ? "hunk 1" : `hunks 1–${i}`} applied)`;
1215
1244
  if (count === 0) {
1216
1245
  // WR-1A ④: the WORLD lacks the pattern (the input is
1217
1246
  // fine) and nothing ran — precondition; the note never
@@ -1222,7 +1251,13 @@ export function editFileTool(opts) {
1222
1251
  const region = regionForSearch(edited, h.search);
1223
1252
  const seen = i === 0 ? "the file now has" : `the text hunk ${i + 1} met`;
1224
1253
  const block = region === null ? "" : `\n${seen}, lines ${region.from}–${region.to}:\n${region.body}`;
1225
- return precondition(`edit_file: pattern not found in ${path}${which}${detail ? `\n${detail}` : ""}${block}\n[${current}]`);
1254
+ // ADR-0061: the replay's shape — oldText nowhere, newText exactly
1255
+ // once. Two readings fit it and the tool cannot tell them apart, so
1256
+ // it names both and applies nothing (ACI-2: never a guess).
1257
+ const swap = h.replace.length > 0 && occurrencesOf(edited, h.replace).count === 1
1258
+ ? "\n newText is already in the file once and oldText is not: either this change is already applied, or oldText and newText are swapped"
1259
+ : "";
1260
+ return precondition(`edit_file: pattern not found in ${path}${which}${detail ? `\n${detail}` : ""}${swap}${block}\n[${current}]`);
1226
1261
  }
1227
1262
  // ACI-2: more than one resolution is a QUESTION, not an edit.
1228
1263
  // Taking the first one wrote the wrong place and reported
@@ -1348,7 +1383,10 @@ export function shellTool(opts) {
1348
1383
  description: "Run independently as a task. Use for services/watchers or work whose exit result is not needed next. If you need the result, keep it foreground and raise foregroundMs. You are notified when it ends; do not sleep/poll.",
1349
1384
  },
1350
1385
  readyWhen: { type: "string", description: "For a continuing service, wait up to foregroundMs for this literal output before returning; the task keeps running afterward." },
1351
- timeoutMs: { type: "number", description: "Deprecated: use foregroundMs" },
1386
+ // Plan B: timeoutMs (deprecated here since ADR-0058) leaves the
1387
+ // model-facing schema; the executor still reads it from a caller
1388
+ // that skips validation. The schema WITHOUT tasks keeps it — there
1389
+ // it is the shell's only timeout.
1352
1390
  },
1353
1391
  required: ["command"],
1354
1392
  additionalProperties: false,
@@ -1362,12 +1400,20 @@ export function shellTool(opts) {
1362
1400
  required: ["command"],
1363
1401
  additionalProperties: false,
1364
1402
  },
1403
+ // PR-1d row 3: this exact snippet is part of a MEASURED combination (weather 1/5 -> 5/5); Plan B leaves it as pinned
1365
1404
  promptSnippet: "shell — any command the task needs (builds, tests, git, curl, system queries)",
1366
1405
  promptGuidelines: ["commands run in the workspace root; on failure read the error and adjust — never repeat blindly"],
1367
1406
  execute: async ({ command, timeoutMs, foregroundMs, background, readyWhen }, ctx) => {
1368
1407
  // the session's tasks, when wired and this call has a session
1369
1408
  const tasks = opts.tasks?.(ctx.sessionId);
1370
- const timeout = foregroundMs ?? timeoutMs ?? (tasks !== undefined ? DEFAULT_FOREGROUND_MS : DEFAULT_SHELL_TIMEOUT_MS);
1409
+ // ADR-0058 Amendment 10: with tasks wired a wait shorter than the
1410
+ // floor only moves the command to the background sooner, so the
1411
+ // model may lengthen the wait, never shorten it. Without tasks the
1412
+ // wait is a kill timeout and is kept as asked.
1413
+ const asked = [foregroundMs, timeoutMs].find((v) => typeof v === "number" && Number.isFinite(v));
1414
+ const timeout = tasks !== undefined
1415
+ ? Math.max(asked ?? DEFAULT_FOREGROUND_MS, opts.limits?.minForegroundMs ?? DEFAULT_FOREGROUND_MS)
1416
+ : (asked ?? DEFAULT_SHELL_TIMEOUT_MS);
1371
1417
  // E group: a PRE-aborted signal never spawns the command.
1372
1418
  if (ctx.signal.aborted) {
1373
1419
  return { content: "shell aborted before start", isError: true, errorKind: "fatal" };
@@ -1680,7 +1726,7 @@ export function taskStopTool(opts) {
1680
1726
  required: ["id"],
1681
1727
  additionalProperties: false,
1682
1728
  },
1683
- promptSnippet: "task_stop — stop a background task by id",
1729
+ promptSnippet: "task_stop — stop a background task",
1684
1730
  execute: async ({ id }, ctx) => {
1685
1731
  const tasks = opts.tasks?.(ctx.sessionId);
1686
1732
  const info = tasks?.get(id);
@@ -83,13 +83,13 @@ export function describeSearchMiss(text, search) {
83
83
  if (endOfMatch >= text.length) {
84
84
  return [
85
85
  head,
86
- ` the file ENDS there — your search continues for ${rest.length} more characters: "${fragment(rest)}"`,
86
+ ` the file ENDS there — your oldText continues for ${rest.length} more characters: "${fragment(rest)}"`,
87
87
  ].join("\n");
88
88
  }
89
89
  return [
90
90
  head,
91
91
  ` the file then has: "${fragment(text.slice(endOfMatch))}"`,
92
- ` your search wanted: "${fragment(rest)}"`,
92
+ ` your oldText wanted: "${fragment(rest)}"`,
93
93
  ].join("\n");
94
94
  }
95
95
  export function regionForSearch(text, search, context = 6, maxChars = 1500) {
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { open, readdir } from "node:fs/promises";
19
19
  import { basename, join, relative, sep } from "node:path";
20
- import { corpusSkips, layersEntering, readLayer, walkCorpus } from "./corpus.js";
20
+ import { byName, bySegments, corpusSkips, layersEntering, readLayer, walkCorpus } from "./corpus.js";
21
21
  import { isMainThread, parentPort } from "node:worker_threads";
22
22
  import { diskPath, isProtectedDiskPath } from "./protected.js";
23
23
  /** A scanned file is protected by inode (the store itself, under any name
@@ -74,7 +74,7 @@ export function runGlob(req) {
74
74
  }
75
75
  export async function runSearch(req) {
76
76
  const regex = new RegExp(req.pattern, req.flags);
77
- const matches = [];
77
+ const found = [];
78
78
  let totalMatches = 0;
79
79
  let filesSeen = 0;
80
80
  let skippedFiles = 0;
@@ -136,8 +136,8 @@ export async function runSearch(req) {
136
136
  // WORKSPACE-RELATIVE, not absolute: `read_file` refuses an
137
137
  // absolute path, so an absolute hit here is a result the
138
138
  // model cannot feed back without rewriting it by hand.
139
- if (matches.length < req.maxMatches)
140
- matches.push(`${relative(req.workspaceRoot, full) || basename(full)}:${i + 1}: ${excerptAround(line, regex)}`);
139
+ if (found.length < req.maxMatches)
140
+ found.push({ path: relative(req.workspaceRoot, full) || basename(full), line: i + 1, text: excerptAround(line, regex) });
141
141
  }
142
142
  }
143
143
  }
@@ -149,50 +149,67 @@ export async function runSearch(req) {
149
149
  // workspace root — not by `.git`, and the walk never goes up.
150
150
  const rootLayer = readLayer(req.root);
151
151
  const declared = rootLayer !== null;
152
- const walk = async (dir, depth, layers) => {
153
- if (depth > 8 || outOfBudget())
154
- return;
155
- let entries;
156
- try {
157
- entries = await readdir(dir, { withFileTypes: true });
158
- }
159
- catch (err) {
160
- const code = err.code;
161
- if (code === "EACCES" || code === "EPERM") {
162
- unreadableDirs += 1;
163
- return;
164
- }
165
- throw err;
166
- }
167
- const here = depth === 0 ? layers : layersEntering(dir, layers);
168
- for (const entry of entries) {
152
+ // 0.48.0 (finding 0472-F2) — the same order as `walkCorpus`: breadth-
153
+ // first, each directory's entries by name and its files before any
154
+ // subdirectory, so the budget cuts the deepest part of the tree and
155
+ // never a file nearer the root. Depth-first, a deep sibling directory
156
+ // spent the budget before a shallow file was read.
157
+ const walk = async (start, startLayers) => {
158
+ const queue = [{ dir: start, depth: 0, layers: startLayers }];
159
+ for (let next = 0; next < queue.length; next += 1) {
169
160
  if (outOfBudget())
170
161
  return;
171
- const full = join(dir, entry.name);
172
- const isDir = entry.isDirectory();
173
- if (corpusSkips(declared, here, full, entry.name, isDir))
174
- continue;
175
- if (isDir) {
176
- if (isExcluded(full)) {
177
- excludedDirs += 1;
162
+ const { dir, depth, layers } = queue[next];
163
+ let entries;
164
+ try {
165
+ entries = await readdir(dir, { withFileTypes: true });
166
+ }
167
+ catch (err) {
168
+ const code = err.code;
169
+ if (code === "EACCES" || code === "EPERM") {
170
+ unreadableDirs += 1;
178
171
  continue;
179
172
  }
180
- await walk(full, depth + 1, here);
173
+ throw err;
174
+ }
175
+ entries.sort(byName);
176
+ const here = depth === 0 ? layers : layersEntering(dir, layers);
177
+ for (const entry of entries) {
178
+ if (outOfBudget())
179
+ return;
180
+ const full = join(dir, entry.name);
181
+ const isDir = entry.isDirectory();
182
+ if (corpusSkips(declared, here, full, entry.name, isDir))
183
+ continue;
184
+ if (isDir) {
185
+ if (isExcluded(full)) {
186
+ excludedDirs += 1;
187
+ continue;
188
+ }
189
+ if (depth + 1 <= 8)
190
+ queue.push({ dir: full, depth: depth + 1, layers: here });
191
+ }
192
+ else if (entry.isFile())
193
+ await scanFile(full);
181
194
  }
182
- else if (entry.isFile())
183
- await scanFile(full);
184
195
  }
185
196
  };
186
197
  try {
187
198
  if (req.single !== null)
188
199
  await scanFile(req.single);
189
200
  else
190
- await walk(req.root, 0, rootLayer === null ? [] : [rootLayer]);
201
+ await walk(req.root, rootLayer === null ? [] : [rootLayer]);
191
202
  }
192
203
  catch (err) {
193
- return { token: req.token, matches, totalMatches, filesSeen, skippedFiles, multiLink, unreadableDirs, excludedDirs, stopped, stoppedAt, error: err.message };
204
+ return { token: req.token, matches: listed(found), totalMatches, filesSeen, skippedFiles, multiLink, unreadableDirs, excludedDirs, stopped, stoppedAt, error: err.message };
194
205
  }
195
- return { token: req.token, matches, totalMatches, filesSeen, skippedFiles, multiLink, unreadableDirs, excludedDirs, stopped, stoppedAt };
206
+ return { token: req.token, matches: listed(found), totalMatches, filesSeen, skippedFiles, multiLink, unreadableDirs, excludedDirs, stopped, stoppedAt };
207
+ }
208
+ /** The matches as the tool prints them: by path segments, then by line —
209
+ * a listing that does not depend on the order the walk read them in. */
210
+ function listed(found) {
211
+ const key = (path) => path.split(sep).join("/");
212
+ return [...found].sort((a, b) => bySegments(key(a.path), key(b.path)) || a.line - b.line).map((m) => `${m.path}:${m.line}: ${m.text}`);
196
213
  }
197
214
  if (!isMainThread && parentPort !== null) {
198
215
  parentPort.once("message", (req) => {
@@ -69,17 +69,40 @@ function plannedOf(journal) {
69
69
  throw new Error(`no planned record in ${journal}`);
70
70
  }
71
71
  function main(dir) {
72
+ // The runner's FIRST act: TERM is handled from here on, by one handler
73
+ // that is never removed. The task reads running the moment
74
+ // command_started is written, so a stop may arrive before the command and
75
+ // its stop exist; without a handler, TERM's default disposition killed
76
+ // the runner with no record, and the task was lost. The handler is
77
+ // dispatched from the event loop, after this synchronous main returns,
78
+ // by which time `stopNow` is set. `heldTerm` is a guard, only reachable
79
+ // if main() ever awaits before the stop exists: such a TERM is held and
80
+ // performed as soon as the stop exists (below). The handler is never
81
+ // swapped: dropping the last listener between two moments would drop a
82
+ // pending signal with it.
83
+ let stopNow = null;
84
+ let heldTerm = false;
85
+ process.on("SIGTERM", () => {
86
+ if (stopNow !== null)
87
+ stopNow();
88
+ else
89
+ heldTerm = true;
90
+ });
72
91
  const journal = join(dir, "journal.jsonl");
73
92
  const planned = plannedOf(journal);
74
93
  // test-only crash points: the process dies right after a record, before
75
94
  // the step that record gates
76
95
  const dieAfter = process.env.KISO_TASK_RUNNER_DIE_AFTER;
96
+ // test-only: hold the runner right after a record, so a stop can land in
97
+ // the window the record opens
98
+ const pauseAfter = process.env.KISO_TASK_RUNNER_PAUSE_AFTER;
77
99
  // test-only: report a stop as unconfirmed (a survivor the platform could
78
100
  // not kill cannot be made on purpose)
79
101
  const forceUnconfirmed = process.env.KISO_TASK_RUNNER_STOP_UNCONFIRMED === "1";
80
102
  const cap = Number(process.env.KISO_TASK_OUTPUT_CAP ?? OUTPUT_CAP_DEFAULT);
81
103
  const env = { ...process.env };
82
104
  delete env.KISO_TASK_RUNNER_DIE_AFTER;
105
+ delete env.KISO_TASK_RUNNER_PAUSE_AFTER;
83
106
  delete env.KISO_TASK_RUNNER_STOP_UNCONFIRMED;
84
107
  delete env.KISO_TASK_OUTPUT_CAP;
85
108
  // "" when the start time cannot be read: the identity is then the pid
@@ -91,6 +114,8 @@ function main(dir) {
91
114
  append(journal, { type: "command_started", ts: Date.now() });
92
115
  if (dieAfter === "command_started")
93
116
  process.exit(99);
117
+ if (pauseAfter === "command_started")
118
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 600);
94
119
  const out = new RotatingOutput(join(dir, "output.log"), cap);
95
120
  let finished = false;
96
121
  /** The last record; the output closes first, and nothing is written after. */
@@ -163,7 +188,9 @@ function main(dir) {
163
188
  finish({ type: "terminal", ts: Date.now(), exitCode: e?.code ?? null, signal: e?.signal ?? null });
164
189
  });
165
190
  };
166
- process.on("SIGTERM", stop);
191
+ stopNow = stop;
192
+ if (heldTerm)
193
+ stop();
167
194
  // the journal is the stop channel: re-read only when it grew
168
195
  let journalSize = -1;
169
196
  const watch = setInterval(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tools-node",
3
- "version": "0.47.3",
3
+ "version": "0.49.0",
4
4
  "description": "kiso coding tools for Node hosts \u2014 read file, list directory, search text, write/edit file, shell command.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,7 +25,7 @@
25
25
  "test": "vitest run"
26
26
  },
27
27
  "dependencies": {
28
- "@vincemakes/kiso-core": "0.47.3",
28
+ "@vincemakes/kiso-core": "0.49.0",
29
29
  "ignore": "^7.0.9"
30
30
  },
31
31
  "devDependencies": {