@nia-teams/four-tools 0.0.0-stage → 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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -2
  3. package/README_CN.md +144 -0
  4. package/dist/core/contracts.d.ts +118 -0
  5. package/dist/core/contracts.js +125 -0
  6. package/dist/core/edit-engine.d.ts +11 -0
  7. package/dist/core/edit-engine.js +93 -0
  8. package/dist/core/env.d.ts +149 -0
  9. package/dist/core/env.js +1 -0
  10. package/dist/core/index.d.ts +23 -0
  11. package/dist/core/index.js +33 -0
  12. package/dist/core/mutation-queue.d.ts +6 -0
  13. package/dist/core/mutation-queue.js +24 -0
  14. package/dist/core/tools/edit.d.ts +4 -0
  15. package/dist/core/tools/edit.js +43 -0
  16. package/dist/core/tools/read.d.ts +10 -0
  17. package/dist/core/tools/read.js +45 -0
  18. package/dist/core/tools/shared.d.ts +20 -0
  19. package/dist/core/tools/shared.js +51 -0
  20. package/dist/core/tools/shell.d.ts +10 -0
  21. package/dist/core/tools/shell.js +166 -0
  22. package/dist/core/tools/write.d.ts +4 -0
  23. package/dist/core/tools/write.js +33 -0
  24. package/dist/core/types.d.ts +65 -0
  25. package/dist/core/types.js +1 -0
  26. package/dist/index.d.ts +1 -0
  27. package/dist/index.js +1 -0
  28. package/dist/node/index.d.ts +16 -0
  29. package/dist/node/index.js +16 -0
  30. package/dist/node/posix-env.d.ts +46 -0
  31. package/dist/node/posix-env.js +239 -0
  32. package/dist/node/process-group.d.ts +2 -0
  33. package/dist/node/process-group.js +30 -0
  34. package/dist/node/process-runner.d.ts +43 -0
  35. package/dist/node/process-runner.js +221 -0
  36. package/dist/node/read-range.d.ts +7 -0
  37. package/dist/node/read-range.js +69 -0
  38. package/dist/node/terminal-manager.d.ts +36 -0
  39. package/dist/node/terminal-manager.js +228 -0
  40. package/examples/local-agent.ts +164 -0
  41. package/examples/quickstart.ts +30 -0
  42. package/examples/web-agent.ts +479 -0
  43. package/package.json +44 -4
  44. package/skills/four-tools/SKILL.md +21 -0
@@ -0,0 +1,166 @@
1
+ import { toolContracts } from '../contracts.js';
2
+ import { safe, schema } from './shared.js';
3
+ const signals = ['SIGINT', 'SIGTERM', 'SIGKILL'];
4
+ const wireStatus = (status) => status === 'running' || status === 'error' ? status : 'stopped';
5
+ export function signalExitCode(exitCode, signal) {
6
+ return signal === 'SIGINT'
7
+ ? 130
8
+ : signal === 'SIGTERM'
9
+ ? 143
10
+ : signal === 'SIGKILL'
11
+ ? 137
12
+ : (exitCode ?? 1);
13
+ }
14
+ export function createShellTool(env, options = {}) {
15
+ const background = (options.allowBackground ?? Boolean(env.terminals)) &&
16
+ Boolean(env.terminals);
17
+ const full = structuredClone(toolContracts.shell);
18
+ const common = {
19
+ command: full.inputSchema.properties.command,
20
+ cwd: full.inputSchema.properties.cwd,
21
+ timeout: full.inputSchema.properties.timeout,
22
+ };
23
+ return safe({
24
+ name: options.name ?? 'shell',
25
+ description: background
26
+ ? full.description
27
+ : 'Execute shell commands in the workspace. Run a one-shot command and wait for output. Timeout or cancellation terminates the process group.',
28
+ concurrency: 'parallel',
29
+ inputSchema: background ? full.inputSchema : schema(common, ['command']),
30
+ async execute(input, ctx = {}) {
31
+ const action = input.action || 'exec';
32
+ if (!background && action !== 'exec')
33
+ return 'Error: background terminals are unavailable';
34
+ const backend = background ? env.terminals : undefined;
35
+ if (action === 'terminal_list') {
36
+ const terminals = await backend.list();
37
+ if (!terminals.length)
38
+ return 'No active terminals';
39
+ return terminals
40
+ .map((t) => `${t.status === 'running' ? '[running]' : '[stopped]'} ${t.id} - ${t.name || '(unnamed)'}${t.command ? ` (${t.command})` : ''} [${t.linesBuffered || 0} lines]`)
41
+ .join('\n');
42
+ }
43
+ if (action === 'terminal_read') {
44
+ if (!input.id)
45
+ return 'Error: id is required for terminal_read';
46
+ const data = await backend.read({
47
+ id: String(input.id),
48
+ offset: input.offset,
49
+ limit: input.limit,
50
+ });
51
+ const lines = data.lines ??
52
+ (data.content.length
53
+ ? data.content.replace(/\n$/, '').split('\n')
54
+ : []);
55
+ let out = lines.length ? lines.join('\n') + '\n' : '';
56
+ out += `---\nstatus: ${wireStatus(data.status)}, total_lines: ${data.totalLines}`;
57
+ const next = data.nextOffset ??
58
+ (data.endLine < data.totalLines
59
+ ? Math.max(0, data.endLine)
60
+ : undefined);
61
+ if (next !== undefined)
62
+ out += `, next_offset: ${next}`;
63
+ return out;
64
+ }
65
+ if (action === 'terminal_signal') {
66
+ if (!input.id || !input.signal)
67
+ return 'Error: id and signal are required for terminal_signal';
68
+ if (!signals.includes(input.signal))
69
+ return 'Error: invalid signal';
70
+ await backend.signal({
71
+ id: String(input.id),
72
+ signal: input.signal,
73
+ });
74
+ return `Sent ${input.signal}`;
75
+ }
76
+ if (action === 'terminal_destroy') {
77
+ if (!input.id)
78
+ return 'Error: id is required for terminal_destroy';
79
+ await backend.destroy({ id: String(input.id) });
80
+ return 'Terminal destroyed';
81
+ }
82
+ if (action !== 'exec' && action !== 'terminal_create')
83
+ return `Unknown action: ${action}`;
84
+ if (!input.command)
85
+ return `Error: command is required for ${action}`;
86
+ if (typeof input.command !== 'string')
87
+ return 'Error: command must be a string';
88
+ const command = input.command;
89
+ if (action === 'terminal_create' &&
90
+ (ctx.subagentId || ctx.metadata?.subagentId))
91
+ return 'Subagents cannot create background terminals. Run foreground shell exec work within this turn or report the blocker to the parent agent.';
92
+ const cwd = typeof input.cwd === 'string'
93
+ ? (await env.resolvePath(input.cwd, { mustExist: true })).resolvedPath
94
+ : undefined;
95
+ const environment = await options.hooks?.resolveEnv?.(ctx);
96
+ if (action === 'terminal_create') {
97
+ const info = await backend.create({
98
+ command,
99
+ cwd,
100
+ env: environment,
101
+ name: input.name,
102
+ notifyOnExit: input.notifyOnExit !== false,
103
+ });
104
+ return `Terminal created\n ID: ${info.id}\n Name: ${info.name || '(unnamed)'}\n Status: ${wireStatus(info.status)}${info.command ? `\n Command: ${info.command}` : ''}`;
105
+ }
106
+ if (input.timeout !== undefined &&
107
+ (typeof input.timeout !== 'number' ||
108
+ !Number.isFinite(input.timeout) ||
109
+ input.timeout < 0))
110
+ return 'Error: timeout must be a finite, non-negative number of seconds';
111
+ // The production contract treats zero as the default foreground wait.
112
+ const seconds = input.timeout || 30;
113
+ const timeoutMs = Math.min(Math.trunc(seconds * 1000), 2 ** 31 - 1);
114
+ const started = Date.now();
115
+ const result = backend
116
+ ? await backend.execWithHandoff({
117
+ command,
118
+ cwd,
119
+ env: environment,
120
+ handoffAfterMs: timeoutMs,
121
+ signal: ctx.signal,
122
+ maxOutputBytes: options.maxOutputBytes,
123
+ })
124
+ : await env.exec(command, {
125
+ cwd,
126
+ env: environment,
127
+ timeoutMs,
128
+ signal: ctx.signal,
129
+ maxOutputBytes: options.maxOutputBytes,
130
+ });
131
+ if ('completed' in result && !result.completed) {
132
+ const id = result.terminal.id;
133
+ const reason = (result.handoffReason ??
134
+ (ctx.signal?.aborted ? 'abort' : 'timeout')) === 'abort'
135
+ ? 'Foreground turn ended'
136
+ : `Command timed out after ${seconds}s`;
137
+ let out = `${reason} — process still running in background.\nTerminal ID: ${id}\n`;
138
+ if (result.partialOutput)
139
+ out += `\nPartial output so far:\n${result.partialOutput.length > 2000 ? result.partialOutput.substring(0, 2000) + '\n... (truncated)' : result.partialOutput}\n`;
140
+ out += `\nUse terminal_read with id="${id}" to check progress.`;
141
+ out += `\nUse terminal_signal with id="${id}" to send signals (SIGINT/SIGTERM/SIGKILL).`;
142
+ out += `\nUse terminal_destroy with id="${id}" to clean up when done.`;
143
+ return out;
144
+ }
145
+ let out = `$ ${command}${input.cwd ? ` (cwd: ${input.cwd})` : ''}\n---\n`;
146
+ const stdout = result.stdout ?? result.output, stderr = result.stderr;
147
+ if (stdout)
148
+ out += stdout + '\n';
149
+ if (stderr)
150
+ out += `[stderr] ${stderr}\n`;
151
+ if (result.spillPath)
152
+ out += `[Full output: ${result.spillPath}]\n`;
153
+ const exitCode = signalExitCode(result.exitCode, result.signal);
154
+ out += `---\nexit_code: ${exitCode}, duration: ${result.durationMs ?? Date.now() - started}ms`;
155
+ const guidance = exitCode === 0
156
+ ? await options.hooks?.afterExec?.({
157
+ command,
158
+ cwd,
159
+ exitCode,
160
+ context: ctx,
161
+ })
162
+ : undefined;
163
+ return guidance ? { content: out, guidance } : out;
164
+ },
165
+ });
166
+ }
@@ -0,0 +1,4 @@
1
+ import type { ExecutionEnv } from '../env.js';
2
+ import type { FourTool } from '../types.js';
3
+ import { type FileToolOptions } from './shared.js';
4
+ export declare function createWriteTool(env: ExecutionEnv, options?: FileToolOptions): FourTool;
@@ -0,0 +1,33 @@
1
+ import { toolContracts } from '../contracts.js';
2
+ import { defaultMutationQueue } from '../mutation-queue.js';
3
+ import { aliases, safe, text } from './shared.js';
4
+ export function createWriteTool(env, options = {}) {
5
+ return safe({
6
+ name: 'write',
7
+ ...structuredClone(toolContracts.write),
8
+ concurrency: 'parallel',
9
+ async execute(raw, ctx = {}) {
10
+ ctx.signal?.throwIfAborted();
11
+ const input = aliases(raw), content = text(input, 'content');
12
+ let displayPath = text(input, 'path');
13
+ const path = await env.resolvePath(displayPath);
14
+ if (displayPath === '~' || displayPath.startsWith('~/'))
15
+ displayPath = path.resolvedPath;
16
+ return (options.queue ?? defaultMutationQueue).run(env.id, path.canonicalPath, async () => {
17
+ await env.writeText(path.resolvedPath, content, {
18
+ signal: ctx.signal,
19
+ });
20
+ const message = `File written: ${displayPath} (${content.length} chars)`;
21
+ const guidance = await options.hooks?.afterMutate?.({
22
+ path: path.resolvedPath,
23
+ canonicalPath: path.canonicalPath,
24
+ operation: 'write',
25
+ charCount: content.length,
26
+ content: content,
27
+ context: ctx,
28
+ });
29
+ return guidance ? { content: message, guidance } : message;
30
+ }, ctx.signal);
31
+ },
32
+ });
33
+ }
@@ -0,0 +1,65 @@
1
+ /** Mirrors the public ai/protocol contract without a runtime or type dependency. */
2
+ export type Guidance = string | {
3
+ kind: 'text';
4
+ text: string;
5
+ } | {
6
+ kind: 'tool_loop_warning';
7
+ warningType: string;
8
+ toolName: string;
9
+ count: number;
10
+ message?: string;
11
+ };
12
+ export interface FourToolContext {
13
+ signal?: AbortSignal;
14
+ workspaceId?: string;
15
+ teamId?: string;
16
+ model?: string;
17
+ subagentId?: string;
18
+ metadata?: Record<string, unknown>;
19
+ sessionId?: string;
20
+ turnId?: string;
21
+ toolCallId?: string;
22
+ aiPostContext?: Guidance[];
23
+ }
24
+ export type FourToolResult = string | {
25
+ content: string;
26
+ guidance?: Guidance[];
27
+ };
28
+ /** Hosts using a string-only tool bridge receive guidance via aiPostContext. */
29
+ export interface NativeToolContext extends FourToolContext {
30
+ workspaceId: string;
31
+ teamId: string;
32
+ model: string;
33
+ }
34
+ export interface FourTool {
35
+ name: string;
36
+ description: string;
37
+ inputSchema: Record<string, unknown>;
38
+ concurrency?: 'parallel' | 'serial' | 'deferred';
39
+ execute(input: Record<string, unknown>, ctx: NativeToolContext): Promise<string>;
40
+ execute(input: Record<string, unknown>, ctx?: FourToolContext): Promise<FourToolResult>;
41
+ }
42
+ export interface MutationEvent {
43
+ path: string;
44
+ canonicalPath: string;
45
+ operation: 'write' | 'edit';
46
+ context: FourToolContext;
47
+ charCount?: number;
48
+ content?: string;
49
+ }
50
+ export interface ToolHooks {
51
+ afterRead?: (event: {
52
+ path: string;
53
+ canonicalPath: string;
54
+ content?: string;
55
+ context: FourToolContext;
56
+ }) => void | Guidance[] | Promise<void | Guidance[]>;
57
+ afterExec?: (event: {
58
+ command: string;
59
+ cwd?: string;
60
+ exitCode: number;
61
+ context: FourToolContext;
62
+ }) => void | Guidance[] | Promise<void | Guidance[]>;
63
+ afterMutate?: (event: MutationEvent) => void | Guidance[] | Promise<void | Guidance[]>;
64
+ resolveEnv?: (ctx: FourToolContext) => Record<string, string> | Promise<Record<string, string>>;
65
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export * from './core/index.js';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export * from './core/index.js';
@@ -0,0 +1,16 @@
1
+ export * from './posix-env.js';
2
+ export * from './terminal-manager.js';
3
+ export { signalProcessGroup } from './process-group.js';
4
+ import { PosixNodeExecutionEnv, type NodeEnvOptions } from './posix-env.js';
5
+ import { type FourToolsOptions, type FourTool } from '../index.js';
6
+ export type NodeFourToolsOptions = NodeEnvOptions & FourToolsOptions;
7
+ export declare function createNodeEnv(options?: NodeEnvOptions): PosixNodeExecutionEnv;
8
+ export declare function createNodeFourTools(options?: NodeFourToolsOptions): {
9
+ env: PosixNodeExecutionEnv;
10
+ tools: FourTool[];
11
+ };
12
+ export type NodeTools = FourTool[] & {
13
+ env: PosixNodeExecutionEnv;
14
+ dispose(): Promise<void>;
15
+ };
16
+ export declare function createNodeTools(options?: NodeFourToolsOptions): NodeTools;
@@ -0,0 +1,16 @@
1
+ export * from './posix-env.js';
2
+ export * from './terminal-manager.js';
3
+ export { signalProcessGroup } from './process-group.js';
4
+ import { PosixNodeExecutionEnv } from './posix-env.js';
5
+ import { createFourTools, } from '../index.js';
6
+ export function createNodeEnv(options = {}) {
7
+ return new PosixNodeExecutionEnv(options);
8
+ }
9
+ export function createNodeFourTools(options = {}) {
10
+ const env = createNodeEnv(options);
11
+ return { env, tools: createFourTools(env, options) };
12
+ }
13
+ export function createNodeTools(options = {}) {
14
+ const { env, tools } = createNodeFourTools(options);
15
+ return Object.assign(tools, { env, dispose: () => env.dispose() });
16
+ }
@@ -0,0 +1,46 @@
1
+ import type { ExecutionEnv, Platform, FileStat, ExecOptions, ExecResult, TerminalExitEvent } from '../core/env.js';
2
+ import { readRange } from './read-range.js';
3
+ import { PosixTerminalManager } from './terminal-manager.js';
4
+ export interface NodeEnvOptions {
5
+ id?: string;
6
+ cwd?: string;
7
+ homeDir?: string;
8
+ platform?: string;
9
+ shellPath?: string;
10
+ spillDir?: string;
11
+ terminalDir?: string;
12
+ terminals?: boolean;
13
+ onTerminalExit?: (event: TerminalExitEvent) => void | Promise<void>;
14
+ onTerminalError?: (error: unknown) => void;
15
+ }
16
+ export declare class PosixNodeExecutionEnv implements ExecutionEnv {
17
+ readonly id: string;
18
+ readonly platform: Platform;
19
+ readonly cwd: string;
20
+ readonly homeDir: string;
21
+ readonly spillDir: string;
22
+ readonly terminals?: PosixTerminalManager;
23
+ private readonly shellPath?;
24
+ constructor(options?: NodeEnvOptions);
25
+ getShell(): Promise<string>;
26
+ resolvePath(inputPath: string, options?: {
27
+ cwd?: string;
28
+ mustExist?: boolean;
29
+ }): Promise<{
30
+ resolvedPath: string;
31
+ canonicalPath: string;
32
+ }>;
33
+ stat(file: string): Promise<FileStat>;
34
+ readRange: typeof readRange;
35
+ readText(file: string, options?: {
36
+ maxBytes?: number;
37
+ signal?: AbortSignal;
38
+ }): Promise<string>;
39
+ writeText(file: string, content: string, options?: {
40
+ signal?: AbortSignal;
41
+ }): Promise<{
42
+ bytesWritten: number;
43
+ }>;
44
+ exec(command: string, options?: ExecOptions): Promise<ExecResult>;
45
+ dispose(): Promise<void>;
46
+ }
@@ -0,0 +1,239 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import * as path from 'node:path/posix';
4
+ import * as os from 'node:os';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { readRange } from './read-range.js';
7
+ import { startProcess } from './process-runner.js';
8
+ import { PosixTerminalManager } from './terminal-manager.js';
9
+ function missing(error) {
10
+ return ['ENOENT', 'ENOTDIR'].includes(error.code ?? '');
11
+ }
12
+ async function exists(file) {
13
+ try {
14
+ await fs.lstat(file);
15
+ return true;
16
+ }
17
+ catch (error) {
18
+ if (missing(error))
19
+ return false;
20
+ throw error;
21
+ }
22
+ }
23
+ async function canonical(file) {
24
+ try {
25
+ return await fs.realpath(file);
26
+ }
27
+ catch (error) {
28
+ if (!missing(error))
29
+ throw error;
30
+ // A dangling symlink still identifies its future target, not the symlink itself.
31
+ try {
32
+ const stat = await fs.lstat(file);
33
+ if (stat.isSymbolicLink())
34
+ return canonical(path.resolve(path.dirname(file), await fs.readlink(file)));
35
+ }
36
+ catch (e) {
37
+ if (!missing(e))
38
+ throw e;
39
+ }
40
+ const parent = path.dirname(file);
41
+ if (parent === file)
42
+ throw error;
43
+ return path.join(await canonical(parent), path.basename(file));
44
+ }
45
+ }
46
+ const variantKey = (value) => value
47
+ .normalize('NFC')
48
+ .replace(/\u202F/g, ' ')
49
+ .replace(/\u2019/g, "'");
50
+ export class PosixNodeExecutionEnv {
51
+ id;
52
+ platform;
53
+ cwd;
54
+ homeDir;
55
+ spillDir;
56
+ terminals;
57
+ shellPath;
58
+ constructor(options = {}) {
59
+ const platform = options.platform ?? process.platform;
60
+ if (platform !== 'linux' && platform !== 'darwin')
61
+ throw new Error(`Unsupported platform: ${platform}; expected linux or darwin`);
62
+ this.platform = platform;
63
+ this.id = options.id ?? randomUUID();
64
+ this.cwd = path.resolve(options.cwd ?? process.cwd());
65
+ this.homeDir = options.homeDir ?? os.homedir();
66
+ this.spillDir =
67
+ options.spillDir ?? path.join(os.tmpdir(), `four-tools-${this.id}`);
68
+ this.shellPath = options.shellPath;
69
+ if (options.terminals !== false)
70
+ this.terminals = new PosixTerminalManager({
71
+ cwd: this.cwd,
72
+ platform,
73
+ terminalDir: options.terminalDir ?? path.join(this.spillDir, 'terminals'),
74
+ shell: () => this.getShell(),
75
+ onExit: options.onTerminalExit,
76
+ onError: options.onTerminalError,
77
+ });
78
+ }
79
+ async getShell() {
80
+ if (this.shellPath) {
81
+ await fs.access(this.shellPath, constants.X_OK);
82
+ return this.shellPath;
83
+ }
84
+ for (const shell of [
85
+ '/bin/bash',
86
+ ...(this.platform === 'darwin' ? ['/bin/zsh'] : []),
87
+ '/bin/sh',
88
+ ]) {
89
+ try {
90
+ await fs.access(shell, constants.X_OK);
91
+ return shell;
92
+ }
93
+ catch {
94
+ /* Probe the next POSIX shell. */
95
+ }
96
+ }
97
+ throw new Error('No executable POSIX shell found');
98
+ }
99
+ async resolvePath(inputPath, options = {}) {
100
+ const expand = (value) => path.resolve(options.cwd ?? this.cwd, value === '~'
101
+ ? this.homeDir
102
+ : value.startsWith('~/')
103
+ ? path.join(this.homeDir, value.slice(2))
104
+ : value);
105
+ let resolvedPath = expand(inputPath);
106
+ if (inputPath.startsWith('@') && !(await exists(resolvedPath)))
107
+ resolvedPath = expand(inputPath.slice(1));
108
+ if (options.mustExist && !(await exists(resolvedPath))) {
109
+ const names = await fs.readdir(path.dirname(resolvedPath));
110
+ const candidates = names.filter((name) => variantKey(name) === variantKey(path.basename(resolvedPath)));
111
+ if (candidates.length > 1)
112
+ throw new Error(`Ambiguous filename: ${inputPath}`);
113
+ if (!candidates.length)
114
+ throw new Error(`File not found: ${inputPath}`);
115
+ resolvedPath = path.join(path.dirname(resolvedPath), candidates[0]);
116
+ }
117
+ return { resolvedPath, canonicalPath: await canonical(resolvedPath) };
118
+ }
119
+ async stat(file) {
120
+ try {
121
+ const s = await fs.lstat(file);
122
+ return {
123
+ exists: true,
124
+ isDirectory: s.isDirectory(),
125
+ isSymbolicLink: s.isSymbolicLink(),
126
+ size: s.size,
127
+ mode: s.mode,
128
+ mtimeMs: s.mtimeMs,
129
+ };
130
+ }
131
+ catch (error) {
132
+ if (!missing(error))
133
+ throw error;
134
+ return {
135
+ exists: false,
136
+ isDirectory: false,
137
+ isSymbolicLink: false,
138
+ size: 0,
139
+ mode: 0,
140
+ mtimeMs: 0,
141
+ };
142
+ }
143
+ }
144
+ readRange = readRange;
145
+ async readText(file, options = {}) {
146
+ options.signal?.throwIfAborted();
147
+ const max = options.maxBytes ?? 16 * 1024 * 1024;
148
+ const handle = await fs.open(file, 'r');
149
+ try {
150
+ const parts = [];
151
+ let size = 0;
152
+ for (;;) {
153
+ options.signal?.throwIfAborted();
154
+ const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, max - size + 1));
155
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
156
+ if (!bytesRead)
157
+ break;
158
+ size += bytesRead;
159
+ if (size > max)
160
+ throw new Error(`File exceeds readText maxBytes (${max})`);
161
+ parts.push(chunk.subarray(0, bytesRead));
162
+ }
163
+ return Buffer.concat(parts, size).toString('utf8');
164
+ }
165
+ finally {
166
+ await handle.close();
167
+ }
168
+ }
169
+ async writeText(file, content, options = {}) {
170
+ options.signal?.throwIfAborted();
171
+ const actualWritePath = await canonical(file);
172
+ await fs.mkdir(path.dirname(actualWritePath), { recursive: true });
173
+ let existingMode;
174
+ try {
175
+ existingMode = (await fs.stat(actualWritePath)).mode & 0o7777;
176
+ }
177
+ catch (error) {
178
+ if (!missing(error))
179
+ throw error;
180
+ }
181
+ const tmpPath = path.join(path.dirname(actualWritePath), `.four-tools-${process.pid}-${randomUUID()}.tmp`);
182
+ const data = Buffer.from(content, 'utf8');
183
+ try {
184
+ const handle = await fs.open(tmpPath, 'wx', 0o666);
185
+ try {
186
+ await handle.writeFile(data, { signal: options.signal });
187
+ if (existingMode !== undefined)
188
+ await handle.chmod(existingMode);
189
+ await handle.datasync();
190
+ }
191
+ finally {
192
+ await handle.close();
193
+ }
194
+ options.signal?.throwIfAborted();
195
+ try {
196
+ await fs.rename(tmpPath, actualWritePath);
197
+ }
198
+ catch (error) {
199
+ if (!['EXDEV', 'EACCES', 'EPERM'].includes(error.code ?? ''))
200
+ throw error;
201
+ await fs.unlink(tmpPath);
202
+ await fs.writeFile(actualWritePath, data, {
203
+ signal: options.signal,
204
+ mode: existingMode ?? 0o666,
205
+ });
206
+ if (existingMode !== undefined)
207
+ await fs.chmod(actualWritePath, existingMode);
208
+ }
209
+ }
210
+ catch (error) {
211
+ await fs.unlink(tmpPath).catch(() => { });
212
+ throw error;
213
+ }
214
+ return { bytesWritten: data.length };
215
+ }
216
+ async exec(command, options = {}) {
217
+ const running = await startProcess({
218
+ command,
219
+ cwd: options.cwd ?? this.cwd,
220
+ shell: await this.getShell(),
221
+ platform: this.platform,
222
+ logDir: this.spillDir,
223
+ options: { ...options, timeoutMs: options.timeoutMs ?? 30_000 },
224
+ });
225
+ try {
226
+ const result = await running.done;
227
+ if (!result.truncated)
228
+ await fs.unlink(running.logPath);
229
+ return result;
230
+ }
231
+ catch (error) {
232
+ await fs.unlink(running.logPath).catch(() => { });
233
+ throw error;
234
+ }
235
+ }
236
+ async dispose() {
237
+ await this.terminals?.dispose();
238
+ }
239
+ }
@@ -0,0 +1,2 @@
1
+ import type { Platform } from '../core/env.js';
2
+ export declare function signalProcessGroup(pid: number, signal: NodeJS.Signals, platform: Platform): void;
@@ -0,0 +1,30 @@
1
+ export function signalProcessGroup(pid, signal, platform) {
2
+ const send = (sig) => {
3
+ try {
4
+ process.kill(-pid, sig);
5
+ }
6
+ catch (error) {
7
+ const code = error.code;
8
+ if (code !== 'ESRCH' && code !== 'EPERM')
9
+ throw error;
10
+ try {
11
+ process.kill(pid, sig);
12
+ }
13
+ catch (fallback) {
14
+ if (fallback.code !== 'ESRCH')
15
+ throw fallback;
16
+ }
17
+ }
18
+ };
19
+ if (signal === 'SIGKILL' && platform === 'darwin') {
20
+ send('SIGSTOP');
21
+ try {
22
+ send('SIGKILL');
23
+ }
24
+ finally {
25
+ send('SIGCONT');
26
+ }
27
+ }
28
+ else
29
+ send(signal);
30
+ }
@@ -0,0 +1,43 @@
1
+ import { type ChildProcess } from 'node:child_process';
2
+ import type { ExecResult, ExecOptions, Platform } from '../core/env.js';
3
+ export declare class OutputBuffer {
4
+ private head;
5
+ private tail;
6
+ total: number;
7
+ readonly max: number;
8
+ private readonly headMax;
9
+ constructor(max?: number);
10
+ add(chunk: Buffer): void;
11
+ get truncated(): boolean;
12
+ get text(): string;
13
+ }
14
+ export interface RunningProcess {
15
+ child: ChildProcess;
16
+ output: OutputBuffer;
17
+ logPath: string;
18
+ done: Promise<ExecResult>;
19
+ kill(signal?: NodeJS.Signals): void;
20
+ }
21
+ export declare function startProcess(input: {
22
+ command: string;
23
+ cwd: string;
24
+ shell: string;
25
+ platform: Platform;
26
+ logDir: string;
27
+ logId?: string;
28
+ options?: ExecOptions;
29
+ }): Promise<RunningProcess>;
30
+ /** Managed groups inherit regular log FDs; descendant output survives shell exit. */
31
+ export interface ManagedProcess extends Omit<RunningProcess, 'output'> {
32
+ snapshot(): Promise<OutputBuffer>;
33
+ groupAlive(): boolean;
34
+ }
35
+ export declare function startManagedProcess(input: {
36
+ command: string;
37
+ cwd: string;
38
+ shell: string;
39
+ platform: Platform;
40
+ logDir: string;
41
+ logId: string;
42
+ options?: ExecOptions;
43
+ }): Promise<ManagedProcess>;