@vincemakes/kiso-tools-node 0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kiso contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,8 @@
1
+ # @vincemakes/kiso-tools-node
2
+
3
+ Coding tools for Node hosts, bound to an explicit workspace root:
4
+ read_file, list_dir, search_text (idempotent reads), write_file,
5
+ edit_file (safe replacement — external hard links are never
6
+ overwritten), and shell (process-tree kill on timeout/abort).
7
+
8
+ See the repository README for the framework overview.
@@ -0,0 +1,71 @@
1
+ /**
2
+ * kiso coding tools — the reference toolset for Node hosts.
3
+ *
4
+ * Exactly-once discipline (ADR-0024): reads, listing, and search are
5
+ * `idempotent` (safe to repeat); write, edit, and shell are NOT — the
6
+ * kernel replays a confirmed success and blocks an interrupted attempt.
7
+ * The CLI puts write/edit/shell behind the approval policy.
8
+ *
9
+ * Everything is relative to the process cwd; results are text (the model's
10
+ * only medium). No shell flags the kernel doesn't need: `shell` carries an
11
+ * explicit timeout and an output cap so a runaway command cannot flood the
12
+ * context.
13
+ */
14
+ import { type Tool, type ToolResult } from "@vincemakes/kiso-core";
15
+ /**
16
+ * A path that the workspace boundary refuses — never attempted, reported as
17
+ * a precondition (the tool COULD run it, the gate refused it).
18
+ */
19
+ export declare function escapeResult(reason: string): ToolResult;
20
+ /**
21
+ * Resolve an input path strictly inside the workspace (Area 5):
22
+ * - absolute inputs are refused (paths are workspace-relative);
23
+ * - `..` components cannot escape because the joined path is re-checked
24
+ * against the canonical root;
25
+ * - SYMLINKS cannot escape: the deepest existing ancestor is realpath'd
26
+ * and must stay inside the canonical root (this covers symlinked files,
27
+ * symlinked directories, and the parents of files to be created);
28
+ * - returns the canonical absolute path, or throws PathEscapeError.
29
+ */
30
+ export declare class PathEscapeError extends Error {
31
+ constructor(message: string);
32
+ }
33
+ export declare function resolveWithinRoot(root: string, input: string): string;
34
+ /**
35
+ * 十: the canonical path a tool will actually touch, as an ABSOLUTE path.
36
+ * Symlinks in the deepest EXISTING ancestor are resolved — a file to be
37
+ * created under a symlinked directory lands in the TARGET, not in the
38
+ * link — and the not-yet-existing tail is re-appended. Shared by the
39
+ * tools (which then re-verify the workspace boundary) and the CLI
40
+ * approval UI, so the human and the tool see THE SAME path.
41
+ */
42
+ export declare function canonicalTargetPath(input: string): string;
43
+ export interface WorkspaceToolsOptions {
44
+ /** The workspace the tools may touch; everything else is refused. */
45
+ readonly workspaceRoot: string;
46
+ }
47
+ export declare function readFileTool(opts: WorkspaceToolsOptions): Tool<{
48
+ path: string;
49
+ }>;
50
+ export declare function listDirTool(opts: WorkspaceToolsOptions): Tool<{
51
+ path?: string;
52
+ }>;
53
+ export declare function searchTextTool(opts: WorkspaceToolsOptions): Tool<{
54
+ pattern: string;
55
+ path?: string;
56
+ }>;
57
+ export declare function writeFileTool(opts: WorkspaceToolsOptions): Tool<{
58
+ path: string;
59
+ content: string;
60
+ }>;
61
+ export declare function editFileTool(opts: WorkspaceToolsOptions): Tool<{
62
+ path: string;
63
+ search: string;
64
+ replace: string;
65
+ }>;
66
+ export declare function shellTool(opts: WorkspaceToolsOptions): Tool<{
67
+ command: string;
68
+ timeoutMs?: number;
69
+ }>;
70
+ /** The full coding toolset, bound to one workspace root (Area 5). */
71
+ export declare function createCodingTools(opts: WorkspaceToolsOptions): readonly Tool<any>[];
package/dist/index.js ADDED
@@ -0,0 +1,665 @@
1
+ /**
2
+ * kiso coding tools — the reference toolset for Node hosts.
3
+ *
4
+ * Exactly-once discipline (ADR-0024): reads, listing, and search are
5
+ * `idempotent` (safe to repeat); write, edit, and shell are NOT — the
6
+ * kernel replays a confirmed success and blocks an interrupted attempt.
7
+ * The CLI puts write/edit/shell behind the approval policy.
8
+ *
9
+ * Everything is relative to the process cwd; results are text (the model's
10
+ * only medium). No shell flags the kernel doesn't need: `shell` carries an
11
+ * explicit timeout and an output cap so a runaway command cannot flood the
12
+ * context.
13
+ */
14
+ import { execFileSync, spawn } from "node:child_process";
15
+ import { chmodSync, existsSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
16
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
17
+ import { defineTool } from "@vincemakes/kiso-core";
18
+ const OUTPUT_CAP = 100_000; // chars of output a tool result may carry
19
+ const DEFAULT_SHELL_TIMEOUT_MS = 30_000;
20
+ function cap(text) {
21
+ return text.length > OUTPUT_CAP ? `${text.slice(0, OUTPUT_CAP)}\n…[truncated]` : text;
22
+ }
23
+ /**
24
+ * A path that the workspace boundary refuses — never attempted, reported as
25
+ * a precondition (the tool COULD run it, the gate refused it).
26
+ */
27
+ export function escapeResult(reason) {
28
+ return { content: `[path denied] ${reason}`, isError: true, errorKind: "precondition" };
29
+ }
30
+ function isWithin(root, target) {
31
+ const rel = relative(root, target);
32
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
33
+ }
34
+ /**
35
+ * Resolve an input path strictly inside the workspace (Area 5):
36
+ * - absolute inputs are refused (paths are workspace-relative);
37
+ * - `..` components cannot escape because the joined path is re-checked
38
+ * against the canonical root;
39
+ * - SYMLINKS cannot escape: the deepest existing ancestor is realpath'd
40
+ * and must stay inside the canonical root (this covers symlinked files,
41
+ * symlinked directories, and the parents of files to be created);
42
+ * - returns the canonical absolute path, or throws PathEscapeError.
43
+ */
44
+ export class PathEscapeError extends Error {
45
+ constructor(message) {
46
+ super(message);
47
+ this.name = "PathEscapeError";
48
+ }
49
+ }
50
+ export function resolveWithinRoot(root, input) {
51
+ if (isAbsolute(input)) {
52
+ throw new PathEscapeError(`absolute paths are not allowed — use workspace-relative paths: ${input}`);
53
+ }
54
+ const rootReal = realpathSync(root);
55
+ const candidate = resolve(root, input);
56
+ // Walk up to the deepest EXISTING ancestor (files to be created have a
57
+ // not-yet-existing tail), then canonicalize it.
58
+ let ancestor = candidate;
59
+ const missingTail = [];
60
+ while (!existsSync(ancestor)) {
61
+ const parent = dirname(ancestor);
62
+ if (parent === ancestor)
63
+ break;
64
+ missingTail.unshift(basename(ancestor));
65
+ ancestor = parent;
66
+ }
67
+ const ancestorReal = realpathSync(ancestor);
68
+ if (!isWithin(rootReal, ancestorReal)) {
69
+ throw new PathEscapeError(`path escapes the workspace (${input})`);
70
+ }
71
+ const canonical = missingTail.length > 0 ? join(ancestorReal, ...missingTail) : ancestorReal;
72
+ if (!isWithin(rootReal, canonical)) {
73
+ throw new PathEscapeError(`path escapes the workspace (${input})`);
74
+ }
75
+ return canonical;
76
+ }
77
+ /**
78
+ * 十: the canonical path a tool will actually touch, as an ABSOLUTE path.
79
+ * Symlinks in the deepest EXISTING ancestor are resolved — a file to be
80
+ * created under a symlinked directory lands in the TARGET, not in the
81
+ * link — and the not-yet-existing tail is re-appended. Shared by the
82
+ * tools (which then re-verify the workspace boundary) and the CLI
83
+ * approval UI, so the human and the tool see THE SAME path.
84
+ */
85
+ export function canonicalTargetPath(input) {
86
+ const candidate = resolve(input);
87
+ let ancestor = candidate;
88
+ const missingTail = [];
89
+ while (!existsSync(ancestor)) {
90
+ const parent = dirname(ancestor);
91
+ if (parent === ancestor)
92
+ break;
93
+ missingTail.unshift(basename(ancestor));
94
+ ancestor = parent;
95
+ }
96
+ let ancestorReal;
97
+ try {
98
+ ancestorReal = realpathSync(ancestor);
99
+ }
100
+ catch {
101
+ return candidate; // unresolvable — show the plain resolved path
102
+ }
103
+ return missingTail.length > 0 ? join(ancestorReal, ...missingTail) : ancestorReal;
104
+ }
105
+ /**
106
+ * The inode-boundary policy for READS (八): a hard link inside the workspace
107
+ * may point at an inode whose OTHER links live outside (e.g. /etc/passwd) —
108
+ * reading it would silently exfiltrate external content. Policy:
109
+ * - regular, single-link files: read;
110
+ * - multi-link files whose EVERY link is inside the workspace: read;
111
+ * - multi-link files with ANY link outside the workspace: refused
112
+ * (fail-closed when the link count cannot be verified);
113
+ * - non-regular files (sockets, devices, fifos): refused.
114
+ * Returns a denial reason, or null when the file is safe to read.
115
+ */
116
+ function inodeReadPolicy(root, full) {
117
+ const st = statSync(full);
118
+ if (!st.isFile())
119
+ return `not a regular file — refusing to read (${full})`;
120
+ if (st.nlink <= 1)
121
+ return null;
122
+ // 第四轮: the link count is verified STRUCTURALLY, never by counting
123
+ // newline-split text. `find -print0` emits NUL-separated paths — a file
124
+ // named "inside\nspoof" is ONE path, not two — and every match is then
125
+ // re-statted and checked for the EXACT dev+ino pair (an inode number
126
+ // alone is not identity across devices). Any failure to verify every
127
+ // link is fail-closed: the file is refused.
128
+ // 第五轮(P2-3): the workspace root is CANONICALIZED before the scan —
129
+ // find on a symlinked root would not follow the symlink into the real
130
+ // tree, undercounting the in-workspace links and misjudging a legal
131
+ // hard link as an external escape.
132
+ let inside = 0;
133
+ try {
134
+ const rootReal = realpathSync(root);
135
+ const out = execFileSync("find", [rootReal, "-xdev", "-inum", String(st.ino), "-print0"], { encoding: "utf8", maxBuffer: 1 << 20 });
136
+ for (const path of out.split("\0")) {
137
+ if (path === "")
138
+ continue;
139
+ try {
140
+ const match = statSync(path);
141
+ if (match.dev === st.dev && match.ino === st.ino)
142
+ inside += 1;
143
+ }
144
+ catch {
145
+ inside = -1; // an unverifiable match — fail closed
146
+ break;
147
+ }
148
+ }
149
+ }
150
+ catch {
151
+ inside = -1; // cannot verify — refuse (fail-closed)
152
+ }
153
+ if (inside < 0 || inside < st.nlink) {
154
+ const verified = inside < 0 ? "unverifiable" : `${inside}/${st.nlink}`;
155
+ return `file has hard links outside the workspace (${verified} inside) — refusing to read (${full})`;
156
+ }
157
+ return null;
158
+ }
159
+ export function readFileTool(opts) {
160
+ return defineTool({
161
+ name: "read_file",
162
+ description: "Read a file's content from disk. Relative to the workspace root.",
163
+ parameters: {
164
+ type: "object",
165
+ properties: { path: { type: "string", description: "Workspace-relative path of the file to read" } },
166
+ required: ["path"],
167
+ },
168
+ idempotent: true,
169
+ execute: async ({ path }) => {
170
+ try {
171
+ const full = resolveWithinRoot(opts.workspaceRoot, path);
172
+ const denied = inodeReadPolicy(opts.workspaceRoot, full);
173
+ if (denied !== null)
174
+ return escapeResult(denied);
175
+ const content = readFileSync(full, "utf8");
176
+ return { content: cap(content), isError: false };
177
+ }
178
+ catch (err) {
179
+ if (err instanceof PathEscapeError)
180
+ return escapeResult(err.message);
181
+ return { content: `read_file failed: ${err.message}`, isError: true, errorKind: "fatal" };
182
+ }
183
+ },
184
+ });
185
+ }
186
+ export function listDirTool(opts) {
187
+ return defineTool({
188
+ name: "list_dir",
189
+ description: "List the entries of a directory. Omit path to list the workspace root.",
190
+ parameters: {
191
+ type: "object",
192
+ properties: { path: { type: "string", description: "Workspace-relative directory to list" } },
193
+ },
194
+ idempotent: true,
195
+ execute: async ({ path }) => {
196
+ try {
197
+ const dir = resolveWithinRoot(opts.workspaceRoot, path ?? ".");
198
+ const entries = readdirSync(dir, { withFileTypes: true }).map((e) => {
199
+ const isDir = e.isDirectory();
200
+ return `${isDir ? "dir " : "file"} ${e.name}${isDir ? "/" : ""}`;
201
+ });
202
+ return { content: entries.length ? cap(entries.join("\n")) : "(empty directory)", isError: false };
203
+ }
204
+ catch (err) {
205
+ if (err instanceof PathEscapeError)
206
+ return escapeResult(err.message);
207
+ return { content: `list_dir failed: ${err.message}`, isError: true, errorKind: "fatal" };
208
+ }
209
+ },
210
+ });
211
+ }
212
+ export function searchTextTool(opts) {
213
+ return defineTool({
214
+ name: "search_text",
215
+ description: "Search files under a workspace directory (recursive) for a regular expression. Returns matching file:line excerpts, capped.",
216
+ parameters: {
217
+ type: "object",
218
+ properties: {
219
+ pattern: { type: "string", description: "Regular expression to search for" },
220
+ path: { type: "string", description: "Workspace-relative root directory (default: workspace root)" },
221
+ },
222
+ required: ["pattern"],
223
+ },
224
+ idempotent: true,
225
+ execute: async ({ pattern, path }) => {
226
+ let root;
227
+ try {
228
+ root = resolveWithinRoot(opts.workspaceRoot, path ?? ".");
229
+ }
230
+ catch (err) {
231
+ if (err instanceof PathEscapeError)
232
+ return escapeResult(err.message);
233
+ throw err;
234
+ }
235
+ const regex = new RegExp(pattern, "i");
236
+ const matches = [];
237
+ const walk = (dir, depth) => {
238
+ if (depth > 8 || matches.length > 200)
239
+ return;
240
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
241
+ if (entry.name.startsWith(".") || entry.name === "node_modules")
242
+ continue;
243
+ const full = join(dir, entry.name);
244
+ if (entry.isDirectory()) {
245
+ walk(full, depth + 1);
246
+ }
247
+ else if (entry.isFile()) {
248
+ try {
249
+ // 八: same inode boundary as read_file — a hard link
250
+ // to an external inode is not searched. 第四轮(对抗):
251
+ // the link count is verified against the WORKSPACE
252
+ // root, not the search subroot — a link that lives
253
+ // inside the workspace but outside the search dir is
254
+ // legal and must not be silently skipped.
255
+ if (inodeReadPolicy(opts.workspaceRoot, full) !== null)
256
+ continue;
257
+ const text = readFileSync(full, "utf8");
258
+ for (const [i, line] of text.split("\n").entries()) {
259
+ if (regex.test(line)) {
260
+ matches.push(`${full}:${i + 1}: ${line.trim().slice(0, 160)}`);
261
+ if (matches.length >= 200)
262
+ return;
263
+ }
264
+ }
265
+ }
266
+ catch {
267
+ // unreadable file — skip
268
+ }
269
+ }
270
+ }
271
+ };
272
+ try {
273
+ walk(root, 0);
274
+ }
275
+ catch (err) {
276
+ return { content: `search_text failed: ${err.message}`, isError: true, errorKind: "fatal" };
277
+ }
278
+ return { content: matches.length ? cap(matches.join("\n")) : "(no matches)", isError: false };
279
+ },
280
+ });
281
+ }
282
+ export function writeFileTool(opts) {
283
+ return defineTool({
284
+ name: "write_file",
285
+ description: "Write content to a file inside the workspace, replacing it entirely. A side effect — approval required.",
286
+ parameters: {
287
+ type: "object",
288
+ properties: {
289
+ path: { type: "string", description: "Workspace-relative file to write" },
290
+ content: { type: "string", description: "Full new content" },
291
+ },
292
+ required: ["path", "content"],
293
+ },
294
+ execute: async ({ path, content }) => {
295
+ let full;
296
+ try {
297
+ full = resolveWithinRoot(opts.workspaceRoot, path);
298
+ }
299
+ catch (err) {
300
+ if (err instanceof PathEscapeError)
301
+ return escapeResult(err.message);
302
+ throw err;
303
+ }
304
+ const tmp = `${full}.kiso-tmp-${process.pid}-${crypto.randomUUID()}`;
305
+ let preservedMode;
306
+ try {
307
+ // E 组: SAFE REPLACEMENT — write a temp file next to the
308
+ // target and rename it over the directory entry. A hard link
309
+ // inside the workspace that shares an EXTERNAL inode is
310
+ // therefore never overwritten: rename replaces the entry,
311
+ // not the shared inode.
312
+ // 八: an existing file keeps its mode — a 0755 script stays
313
+ // 0755 after replacement (rename drops the temp's default
314
+ // mode, so it is copied onto the temp first).
315
+ if (existsSync(full))
316
+ preservedMode = statSync(full).mode & 0o7777;
317
+ writeFileSync(tmp, content, "utf8");
318
+ if (preservedMode !== undefined)
319
+ chmodSync(tmp, preservedMode);
320
+ renameSync(tmp, full);
321
+ // Post-write re-check (review finding 8): if a concurrent
322
+ // swap turned the verified path into a symlink mid-write,
323
+ // the write landed outside the workspace — say so instead of
324
+ // claiming success. (The write itself cannot be undone.)
325
+ const written = realpathSync(full);
326
+ if (!isWithin(realpathSync(opts.workspaceRoot), written)) {
327
+ return escapeResult(`write escaped the workspace via a swapped path (${path})`);
328
+ }
329
+ return { content: `wrote ${path} (${content.length} chars)`, isError: false };
330
+ }
331
+ catch (err) {
332
+ // 八: a failed write never leaves a temp file with the FULL
333
+ // content behind — it is unlinked in every failure path.
334
+ try {
335
+ unlinkSync(tmp);
336
+ }
337
+ catch {
338
+ // already renamed or never created
339
+ }
340
+ if (err instanceof PathEscapeError)
341
+ return escapeResult(err.message);
342
+ return { content: `write_file failed: ${err.message}`, isError: true, errorKind: "fatal" };
343
+ }
344
+ },
345
+ });
346
+ }
347
+ export function editFileTool(opts) {
348
+ return defineTool({
349
+ name: "edit_file",
350
+ description: "Replace the FIRST occurrence of a literal string in a workspace file. A side effect — approval required.",
351
+ parameters: {
352
+ type: "object",
353
+ properties: {
354
+ path: { type: "string", description: "Workspace-relative file" },
355
+ search: { type: "string", description: "Exact literal text to find" },
356
+ replace: { type: "string", description: "Replacement text" },
357
+ },
358
+ required: ["path", "search", "replace"],
359
+ },
360
+ execute: async ({ path, search, replace }) => {
361
+ let full;
362
+ try {
363
+ full = resolveWithinRoot(opts.workspaceRoot, path);
364
+ }
365
+ catch (err) {
366
+ if (err instanceof PathEscapeError)
367
+ return escapeResult(err.message);
368
+ throw err;
369
+ }
370
+ const tmp = `${full}.kiso-tmp-${process.pid}-${crypto.randomUUID()}`;
371
+ let preservedMode;
372
+ try {
373
+ const text = readFileSync(full, "utf8");
374
+ const index = text.indexOf(search);
375
+ if (index === -1) {
376
+ return { content: `edit_file: pattern not found in ${path}`, isError: true, errorKind: "invalid_input" };
377
+ }
378
+ // E 组: safe replacement — never rewrite a shared external inode via a hard link.
379
+ // 八: the edited file keeps its mode.
380
+ preservedMode = statSync(full).mode & 0o7777;
381
+ writeFileSync(tmp, text.slice(0, index) + replace + text.slice(index + search.length), "utf8");
382
+ chmodSync(tmp, preservedMode);
383
+ renameSync(tmp, full);
384
+ const written = realpathSync(full);
385
+ if (!isWithin(realpathSync(opts.workspaceRoot), written)) {
386
+ return escapeResult(`edit escaped the workspace via a swapped path (${path})`);
387
+ }
388
+ return { content: `edited ${path}`, isError: false };
389
+ }
390
+ catch (err) {
391
+ // 八: a failed edit never leaves a temp file behind.
392
+ try {
393
+ unlinkSync(tmp);
394
+ }
395
+ catch {
396
+ // already renamed or never created
397
+ }
398
+ return { content: `edit_file failed: ${err.message}`, isError: true, errorKind: "fatal" };
399
+ }
400
+ },
401
+ });
402
+ }
403
+ export function shellTool(opts) {
404
+ return defineTool({
405
+ name: "shell",
406
+ description: "Run a shell command with the workspace as the working directory. A side effect — approval required. Fails loudly on timeout or non-zero exit.",
407
+ parameters: {
408
+ type: "object",
409
+ properties: {
410
+ command: { type: "string", description: "The command to run" },
411
+ timeoutMs: { type: "number", description: "Timeout in ms (default 30000)" },
412
+ },
413
+ required: ["command"],
414
+ },
415
+ execute: async ({ command, timeoutMs }, ctx) => {
416
+ const timeout = timeoutMs ?? DEFAULT_SHELL_TIMEOUT_MS;
417
+ // E 组: a PRE-aborted signal never spawns the command.
418
+ if (ctx.signal.aborted) {
419
+ return { content: "shell aborted before start", isError: true, errorKind: "fatal" };
420
+ }
421
+ return new Promise((resolvePromise) => {
422
+ // detached: the command gets its OWN process group, so a
423
+ // timeout/abort can kill the WHOLE TREE (children included),
424
+ // not just the outer shell (Area 4). cwd is the workspace.
425
+ const child = spawn(command, {
426
+ shell: true,
427
+ detached: true,
428
+ cwd: opts.workspaceRoot,
429
+ stdio: ["ignore", "pipe", "pipe"],
430
+ });
431
+ let stdout = "";
432
+ let stderr = "";
433
+ let exited = false;
434
+ let settled = false;
435
+ let killing = false;
436
+ const settle = (result) => {
437
+ if (settled)
438
+ return;
439
+ settled = true;
440
+ clearTimeout(timer);
441
+ // E 组: the abort listener is removed once settled — it
442
+ // must not accumulate across runs.
443
+ ctx.signal.removeEventListener("abort", onAbort);
444
+ resolvePromise(result);
445
+ };
446
+ /**
447
+ * Kill the whole tree and CONFIRM it exited (八/十一):
448
+ *
449
+ * 1. FREEZE the root (SIGSTOP) FIRST — a stopped shell cannot
450
+ * fork new descendants while we enumerate;
451
+ * 2. repeatedly discover AND freeze descendants (pid-table
452
+ * sweep — the only way to see a setsid()-escaped process)
453
+ * until the set is STABLE (two identical scans), so the
454
+ * enumeration cannot miss a mid-sweep fork;
455
+ * 3. SIGKILL the process group and every tracked pid;
456
+ * 4. poll every tracked pid to death. If ANY tracked pid is
457
+ * still alive at the deadline, the verdict is NOT
458
+ * "aborted"/"timed out" — it is an explicit UNCERTAIN
459
+ * error naming the survivors: the side effect may have
460
+ * outlived the tool, and the caller must not assume it
461
+ * was killed.
462
+ */
463
+ const killTree = () => new Promise((resolveKill) => {
464
+ const tracked = new Set();
465
+ // 十一(对抗): the ROOT itself is tracked too — the
466
+ // verdict must not read "aborted" while the root
467
+ // survives. DOCUMENTED LIMITS: (1) a process that
468
+ // forks between SIGSTOP delivery and the next scan,
469
+ // setsids, and is then reparented when its parent is
470
+ // killed can escape the enumeration entirely — it is
471
+ // untracked and unknowable from the pid table; the
472
+ // platform cannot confirm it. (2) if THIS process is
473
+ // killed between the first SIGSTOP and the SIGKILL
474
+ // sweep, the stopped descendants stay permanently
475
+ // stopped (nobody SIGCONTs orphans) — the inherent
476
+ // cost of freeze-first. Both limits are recorded here
477
+ // so no claim of "the whole tree is gone" is ever
478
+ // stronger than what the platform can prove.
479
+ if (child.pid !== undefined && child.pid > 0) {
480
+ tracked.add(child.pid);
481
+ try {
482
+ process.kill(child.pid, "SIGSTOP"); // freeze the root
483
+ }
484
+ catch {
485
+ // already gone
486
+ }
487
+ }
488
+ // Stable discovery: freeze as we go; stop when two
489
+ // consecutive scans are identical.
490
+ let previous = new Set();
491
+ for (let i = 0; i < 10; i++) {
492
+ const current = new Set(descendantsOf(child.pid ?? 0));
493
+ for (const pid of current) {
494
+ tracked.add(pid);
495
+ try {
496
+ process.kill(pid, "SIGSTOP"); // freeze each descendant
497
+ }
498
+ catch {
499
+ // already gone
500
+ }
501
+ }
502
+ if (current.size === previous.size && [...current].every((pid) => previous.has(pid))) {
503
+ break;
504
+ }
505
+ previous = current;
506
+ }
507
+ // The process group (E 组: never kill an undefined/0
508
+ // pid), which also takes the frozen root down.
509
+ if (child.pid !== undefined && child.pid > 0) {
510
+ try {
511
+ process.kill(-child.pid, "SIGKILL");
512
+ }
513
+ catch {
514
+ try {
515
+ child.kill("SIGKILL");
516
+ }
517
+ catch {
518
+ // already gone
519
+ }
520
+ }
521
+ }
522
+ for (const pid of tracked) {
523
+ try {
524
+ process.kill(pid, "SIGKILL");
525
+ }
526
+ catch {
527
+ // already gone
528
+ }
529
+ }
530
+ const confirm = () => {
531
+ void waitAllDead([...tracked]).then((unconfirmed) => resolveKill({ unconfirmed }));
532
+ };
533
+ if (exited) {
534
+ confirm();
535
+ return;
536
+ }
537
+ const fallback = setTimeout(confirm, 2000);
538
+ child.once("close", () => {
539
+ clearTimeout(fallback);
540
+ confirm();
541
+ });
542
+ });
543
+ child.stdout?.on("data", (d) => {
544
+ stdout = cap(stdout + d.toString());
545
+ });
546
+ child.stderr?.on("data", (d) => {
547
+ stderr = cap(stderr + d.toString());
548
+ });
549
+ child.on("error", (err) => {
550
+ settle({ content: `shell failed: ${err.message}`, isError: true, errorKind: "fatal" });
551
+ });
552
+ child.on("close", (code) => {
553
+ exited = true;
554
+ if (killing)
555
+ return; // the timeout/abort verdict owns the result
556
+ const combined = (stdout + (stderr ? `\n[stderr] ${stderr}` : "")).trim();
557
+ settle(code === 0
558
+ ? { content: combined || "(no output)", isError: false }
559
+ : { content: `exit ${code}: ${combined}`, isError: true, errorKind: "fatal" });
560
+ });
561
+ // The kernel's abort reaches the command AND its whole tree.
562
+ // The listener is removed by settle (E 组).
563
+ const uncertainVerdict = (unconfirmed) => unconfirmed.length > 0
564
+ ? `could not confirm ${unconfirmed.length} descendant(s) exited (pids ${unconfirmed.join(", ")}) — treat the side effect as UNCERTAIN`
565
+ : "";
566
+ const onAbort = () => {
567
+ killing = true;
568
+ void killTree().then(({ unconfirmed }) => settle({
569
+ content: `shell aborted${uncertainVerdict(unconfirmed) ? ` — ${uncertainVerdict(unconfirmed)}` : ""}`,
570
+ isError: true,
571
+ errorKind: "fatal",
572
+ }));
573
+ };
574
+ ctx.signal.addEventListener("abort", onAbort);
575
+ const timer = setTimeout(() => {
576
+ killing = true;
577
+ void killTree().then(({ unconfirmed }) => settle({
578
+ content: `shell timed out after ${timeout}ms${uncertainVerdict(unconfirmed) ? ` — ${uncertainVerdict(unconfirmed)}` : ""}`,
579
+ isError: true,
580
+ errorKind: "fatal",
581
+ }));
582
+ }, timeout);
583
+ timer.unref?.();
584
+ });
585
+ },
586
+ });
587
+ }
588
+ /**
589
+ * All live pids whose ancestor chain includes `pid`, from the pid table
590
+ * (八: `ps -axo pid=,ppid=` — the ONLY way to see a setsid()-escaped
591
+ * process, which is in its own group and invisible to a group kill).
592
+ */
593
+ function descendantsOf(pid) {
594
+ if (pid <= 0)
595
+ return [];
596
+ let table;
597
+ try {
598
+ table = execFileSync("ps", ["-axo", "pid=,ppid="], { encoding: "utf8", maxBuffer: 1 << 20 });
599
+ }
600
+ catch {
601
+ return [];
602
+ }
603
+ const children = new Map();
604
+ for (const line of table.split("\n")) {
605
+ const m = line.trim().match(/^(\d+)\s+(\d+)$/);
606
+ if (m === null)
607
+ continue;
608
+ const child = Number(m[1]);
609
+ const parent = Number(m[2]);
610
+ if (!children.has(parent))
611
+ children.set(parent, []);
612
+ children.get(parent).push(child);
613
+ }
614
+ const out = [];
615
+ const queue = [pid];
616
+ while (queue.length > 0) {
617
+ const current = queue.shift();
618
+ for (const c of children.get(current) ?? []) {
619
+ out.push(c);
620
+ queue.push(c);
621
+ }
622
+ }
623
+ return out;
624
+ }
625
+ /**
626
+ * Poll the pid table until NONE of the tracked pids is alive (bounded).
627
+ * Returns the pids still alive at the deadline — the caller MUST NOT
628
+ * report "aborted"/"timed out" while any tracked pid survives (十一).
629
+ */
630
+ function waitAllDead(pids) {
631
+ if (pids.length === 0)
632
+ return Promise.resolve([]);
633
+ return new Promise((resolve) => {
634
+ const deadline = Date.now() + 2000;
635
+ const poll = () => {
636
+ const alive = [];
637
+ for (const pid of pids) {
638
+ try {
639
+ process.kill(pid, 0);
640
+ alive.push(pid);
641
+ }
642
+ catch (err) {
643
+ if (err.code === "EPERM")
644
+ alive.push(pid);
645
+ // ESRCH — gone
646
+ }
647
+ }
648
+ if (alive.length === 0 || Date.now() > deadline)
649
+ return resolve(alive);
650
+ setTimeout(poll, 50);
651
+ };
652
+ poll();
653
+ });
654
+ }
655
+ /** The full coding toolset, bound to one workspace root (Area 5). */
656
+ export function createCodingTools(opts) {
657
+ return [
658
+ readFileTool(opts),
659
+ listDirTool(opts),
660
+ searchTextTool(opts),
661
+ writeFileTool(opts),
662
+ editFileTool(opts),
663
+ shellTool(opts),
664
+ ];
665
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@vincemakes/kiso-tools-node",
3
+ "version": "0.1.0",
4
+ "description": "kiso coding tools for Node hosts \u2014 read file, list directory, search text, write/edit file, shell command.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.build.json",
20
+ "typecheck": "tsc -p tsconfig.json",
21
+ "test": "vitest run"
22
+ },
23
+ "dependencies": {
24
+ "@vincemakes/kiso-core": "0.1.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^26.1.2",
28
+ "typescript": "^5.7.2",
29
+ "vitest": "^3.0.0"
30
+ },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "https://github.com/vincemakes/kiso.git",
34
+ "directory": "packages/tools-node"
35
+ },
36
+ "bugs": {
37
+ "url": "https://github.com/vincemakes/kiso/issues"
38
+ },
39
+ "homepage": "https://github.com/vincemakes/kiso/tree/main/packages/tools-node#readme"
40
+ }