@vincemakes/kiso-tools-node 0.47.3 → 0.48.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<{
package/dist/index.js CHANGED
@@ -1367,7 +1367,14 @@ export function shellTool(opts) {
1367
1367
  execute: async ({ command, timeoutMs, foregroundMs, background, readyWhen }, ctx) => {
1368
1368
  // the session's tasks, when wired and this call has a session
1369
1369
  const tasks = opts.tasks?.(ctx.sessionId);
1370
- const timeout = foregroundMs ?? timeoutMs ?? (tasks !== undefined ? DEFAULT_FOREGROUND_MS : DEFAULT_SHELL_TIMEOUT_MS);
1370
+ // ADR-0058 Amendment 10: with tasks wired a wait shorter than the
1371
+ // floor only moves the command to the background sooner, so the
1372
+ // model may lengthen the wait, never shorten it. Without tasks the
1373
+ // wait is a kill timeout and is kept as asked.
1374
+ const asked = [foregroundMs, timeoutMs].find((v) => typeof v === "number" && Number.isFinite(v));
1375
+ const timeout = tasks !== undefined
1376
+ ? Math.max(asked ?? DEFAULT_FOREGROUND_MS, opts.limits?.minForegroundMs ?? DEFAULT_FOREGROUND_MS)
1377
+ : (asked ?? DEFAULT_SHELL_TIMEOUT_MS);
1371
1378
  // E group: a PRE-aborted signal never spawns the command.
1372
1379
  if (ctx.signal.aborted) {
1373
1380
  return { content: "shell aborted before start", isError: true, errorKind: "fatal" };
@@ -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.48.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.48.0",
29
29
  "ignore": "^7.0.9"
30
30
  },
31
31
  "devDependencies": {