@fitlab-ai/agent-infra 0.7.7 → 0.8.1
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/README.md +2 -0
- package/README.zh-CN.md +2 -0
- package/bin/cli.ts +42 -15
- package/dist/bin/cli.js +46 -16
- package/dist/lib/decide.js +110 -0
- package/dist/lib/run/host.js +39 -0
- package/dist/lib/run/index.js +193 -0
- package/dist/lib/run/prompt.js +2 -0
- package/dist/lib/run/skills.js +29 -0
- package/dist/lib/run/tui.js +35 -0
- package/dist/lib/sandbox/capture.js +158 -0
- package/dist/lib/sandbox/commands/create.js +58 -9
- package/dist/lib/sandbox/commands/enter.js +7 -3
- package/dist/lib/sandbox/credentials.js +42 -3
- package/dist/lib/sandbox/runtimes/base.dockerfile +13 -0
- package/dist/lib/server/adapters/_contract.js +9 -0
- package/dist/lib/server/adapters/feishu/index.js +40 -0
- package/dist/lib/server/adapters/feishu/transport.js +95 -0
- package/dist/lib/server/auth.js +19 -0
- package/dist/lib/server/config.js +183 -0
- package/dist/lib/server/daemon.js +96 -0
- package/dist/lib/server/index.js +47 -0
- package/dist/lib/server/logger.js +34 -0
- package/dist/lib/server/plugin-loader.js +73 -0
- package/dist/lib/server/process-control.js +200 -0
- package/dist/lib/server/protocol.js +69 -0
- package/dist/lib/server/redact.js +12 -0
- package/dist/lib/server/runner.js +75 -0
- package/dist/lib/server/streamer.js +44 -0
- package/dist/lib/task/commands/decisions.js +258 -0
- package/dist/lib/task/commands/log.js +38 -41
- package/dist/lib/task/commands/status.js +144 -45
- package/dist/lib/task/index.js +9 -0
- package/dist/lib/task/ledger.js +66 -0
- package/dist/lib/task/sections.js +30 -1
- package/lib/decide.ts +124 -0
- package/lib/run/host.ts +47 -0
- package/lib/run/index.ts +237 -0
- package/lib/run/prompt.ts +1 -0
- package/lib/run/skills.ts +34 -0
- package/lib/run/tui.ts +40 -0
- package/lib/sandbox/capture.ts +215 -0
- package/lib/sandbox/commands/create.ts +64 -12
- package/lib/sandbox/commands/enter.ts +8 -2
- package/lib/sandbox/credentials.ts +57 -3
- package/lib/sandbox/runtimes/base.dockerfile +13 -0
- package/lib/server/adapters/_contract.ts +42 -0
- package/lib/server/adapters/feishu/index.ts +47 -0
- package/lib/server/adapters/feishu/transport.ts +128 -0
- package/lib/server/auth.ts +28 -0
- package/lib/server/config.ts +228 -0
- package/lib/server/daemon.ts +110 -0
- package/lib/server/index.ts +50 -0
- package/lib/server/logger.ts +48 -0
- package/lib/server/plugin-loader.ts +84 -0
- package/lib/server/process-control.ts +225 -0
- package/lib/server/protocol.ts +79 -0
- package/lib/server/redact.ts +12 -0
- package/lib/server/runner.ts +95 -0
- package/lib/server/server.schema.json +127 -0
- package/lib/server/streamer.ts +57 -0
- package/lib/task/commands/decisions.ts +272 -0
- package/lib/task/commands/log.ts +38 -39
- package/lib/task/commands/status.ts +200 -58
- package/lib/task/index.ts +9 -0
- package/lib/task/ledger.ts +73 -0
- package/lib/task/sections.ts +30 -1
- package/package.json +8 -3
- package/templates/.agents/QUICKSTART.zh-CN.md +2 -2
- package/templates/.agents/rules/issue-fields.github.en.md +1 -1
- package/templates/.agents/rules/issue-fields.github.zh-CN.md +1 -1
- package/templates/.agents/rules/next-step-output.en.md +5 -1
- package/templates/.agents/rules/next-step-output.zh-CN.md +5 -1
- package/templates/.agents/rules/no-mid-flow-questions.en.md +7 -2
- package/templates/.agents/rules/no-mid-flow-questions.zh-CN.md +7 -2
- package/templates/.agents/rules/pr-sync.github.en.md +3 -3
- package/templates/.agents/rules/pr-sync.github.zh-CN.md +3 -3
- package/templates/.agents/rules/review-handshake.en.md +5 -1
- package/templates/.agents/rules/review-handshake.zh-CN.md +5 -1
- package/templates/.agents/rules/task-management.en.md +2 -2
- package/templates/.agents/rules/task-management.zh-CN.md +2 -2
- package/templates/.agents/scripts/lib/post-review-commit.js +15 -18
- package/templates/.agents/scripts/validate-artifact.js +8 -0
- package/templates/.agents/skills/analyze-task/SKILL.en.md +15 -3
- package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +15 -3
- package/templates/.agents/skills/analyze-task/config/verify.en.json +2 -1
- package/templates/.agents/skills/analyze-task/config/verify.zh-CN.json +2 -1
- package/templates/.agents/skills/code-task/SKILL.en.md +2 -2
- package/templates/.agents/skills/code-task/SKILL.zh-CN.md +3 -3
- package/templates/.agents/skills/code-task/reference/dual-mode.en.md +1 -1
- package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +1 -1
- package/templates/.agents/skills/code-task/reference/fix-mode.en.md +3 -3
- package/templates/.agents/skills/code-task/reference/fix-mode.zh-CN.md +4 -4
- package/templates/.agents/skills/complete-task/SKILL.en.md +1 -0
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +1 -0
- package/templates/.agents/skills/complete-task/config/verify.en.json +2 -1
- package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +2 -1
- package/templates/.agents/skills/create-pr/reference/comment-publish.en.md +1 -1
- package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +1 -1
- package/templates/.agents/skills/create-task/SKILL.en.md +1 -3
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +1 -3
- package/templates/.agents/skills/import-codescan/SKILL.en.md +0 -3
- package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +0 -3
- package/templates/.agents/skills/import-dependabot/SKILL.en.md +0 -1
- package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +0 -1
- package/templates/.agents/skills/import-issue/SKILL.en.md +1 -2
- package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +1 -2
- package/templates/.agents/skills/plan-task/SKILL.en.md +1 -2
- package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +1 -2
- package/templates/.agents/skills/review-analysis/SKILL.en.md +2 -2
- package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/review-analysis/config/verify.en.json +1 -1
- package/templates/.agents/skills/review-analysis/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/review-analysis/reference/output-templates.en.md +11 -11
- package/templates/.agents/skills/review-analysis/reference/output-templates.zh-CN.md +11 -11
- package/templates/.agents/skills/review-analysis/reference/report-template.en.md +4 -4
- package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +4 -4
- package/templates/.agents/skills/review-analysis/reference/review-criteria.en.md +5 -5
- package/templates/.agents/skills/review-analysis/reference/review-criteria.zh-CN.md +5 -5
- package/templates/.agents/skills/review-code/SKILL.en.md +4 -4
- package/templates/.agents/skills/review-code/SKILL.zh-CN.md +4 -4
- package/templates/.agents/skills/review-code/config/verify.en.json +1 -1
- package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/review-code/reference/output-templates.en.md +16 -16
- package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +12 -12
- package/templates/.agents/skills/review-code/reference/report-template.en.md +4 -4
- package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +4 -4
- package/templates/.agents/skills/review-code/reference/review-criteria.en.md +5 -5
- package/templates/.agents/skills/review-code/reference/review-criteria.zh-CN.md +5 -5
- package/templates/.agents/skills/review-plan/SKILL.en.md +2 -2
- package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/review-plan/config/verify.en.json +1 -1
- package/templates/.agents/skills/review-plan/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/review-plan/reference/output-templates.en.md +11 -11
- package/templates/.agents/skills/review-plan/reference/output-templates.zh-CN.md +11 -11
- package/templates/.agents/skills/review-plan/reference/report-template.en.md +4 -4
- package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +4 -4
- package/templates/.agents/skills/review-plan/reference/review-criteria.en.md +5 -5
- package/templates/.agents/skills/review-plan/reference/review-criteria.zh-CN.md +5 -5
- package/templates/.agents/templates/task.en.md +3 -3
- package/templates/.agents/templates/task.zh-CN.md +3 -3
- package/templates/.github/workflows/metadata-sync.yml +0 -18
package/lib/run/index.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { loadServerConfig } from '../server/config.ts';
|
|
4
|
+
import { resolveTaskBranch } from '../sandbox/task-resolver.ts';
|
|
5
|
+
import { createRunId, runInSandbox, type SandboxRunMetadata } from '../sandbox/capture.ts';
|
|
6
|
+
import { loadShortIdByTaskId, normalizeShortIdInput } from '../task/short-id.ts';
|
|
7
|
+
import { buildTuiCommand, renderPrompt, selectTui } from './tui.ts';
|
|
8
|
+
import { getSkillRunSpec } from './skills.ts';
|
|
9
|
+
import { runHostCommand, type RunProcessResult } from './host.ts';
|
|
10
|
+
|
|
11
|
+
export type ParsedRunArgs = {
|
|
12
|
+
skill: string;
|
|
13
|
+
taskRef: string | null;
|
|
14
|
+
args: string[];
|
|
15
|
+
tui: string | null;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type SandboxRunRequest = {
|
|
19
|
+
taskRef: string;
|
|
20
|
+
branch: string;
|
|
21
|
+
command: string[];
|
|
22
|
+
runId?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type SandboxRunResult = RunProcessResult & {
|
|
26
|
+
run?: SandboxRunMetadata;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type RunSkillOptions = {
|
|
30
|
+
command?: Record<string, unknown>;
|
|
31
|
+
repoRoot?: string;
|
|
32
|
+
runHost?: (command: string[]) => Promise<RunProcessResult>;
|
|
33
|
+
runSandbox?: (request: SandboxRunRequest) => Promise<SandboxRunResult>;
|
|
34
|
+
writeStdout?: (chunk: string) => void;
|
|
35
|
+
writeStderr?: (chunk: string) => void;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const USAGE = `Usage: ai run <skill> [task-ref] [args...] [--tui <name>]
|
|
39
|
+
|
|
40
|
+
Task skills are scheduled inside the sandbox tmux session; ai run returns once
|
|
41
|
+
the tmux window is created.
|
|
42
|
+
|
|
43
|
+
Examples:
|
|
44
|
+
ai run create-task "describe the task" --tui codex
|
|
45
|
+
ai run code-task #7 --tui codex`;
|
|
46
|
+
|
|
47
|
+
function extractTui(args: string[]): { rest: string[]; tui: string | null } {
|
|
48
|
+
const rest: string[] = [];
|
|
49
|
+
let tui: string | null = null;
|
|
50
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
51
|
+
const arg = args[i] as string;
|
|
52
|
+
if (arg === '--tui') {
|
|
53
|
+
const value = args[i + 1];
|
|
54
|
+
if (!value) throw new Error('--tui requires a value');
|
|
55
|
+
tui = value;
|
|
56
|
+
i += 1;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
rest.push(arg);
|
|
60
|
+
}
|
|
61
|
+
return { rest, tui };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function parseRunArgs(args: string[]): ParsedRunArgs {
|
|
65
|
+
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
66
|
+
throw new Error(USAGE);
|
|
67
|
+
}
|
|
68
|
+
const { rest, tui } = extractTui(args);
|
|
69
|
+
const [skill = '', maybeTaskRef, ...remaining] = rest;
|
|
70
|
+
const spec = getSkillRunSpec(skill);
|
|
71
|
+
if (!spec) throw new Error(`Unknown skill '${skill}'`);
|
|
72
|
+
if (spec.kind === 'create') {
|
|
73
|
+
const createArgs = rest.slice(1);
|
|
74
|
+
if (createArgs.length === 0) throw new Error('create-task requires a description');
|
|
75
|
+
return { skill, taskRef: null, args: createArgs, tui };
|
|
76
|
+
}
|
|
77
|
+
if (!maybeTaskRef) throw new Error(`${skill} requires a task-ref`);
|
|
78
|
+
return { skill, taskRef: maybeTaskRef, args: remaining, tui };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function assertAllowedByConfig(skill: string, commandConfig: Record<string, unknown>): void {
|
|
82
|
+
const allowed = commandConfig.allowedSkills;
|
|
83
|
+
if (!Array.isArray(allowed)) return;
|
|
84
|
+
if (!allowed.every((entry) => typeof entry === 'string')) {
|
|
85
|
+
throw new Error('command.allowedSkills must be an array of skill names');
|
|
86
|
+
}
|
|
87
|
+
if (!allowed.includes(skill)) {
|
|
88
|
+
throw new Error(`Skill '${skill}' is not allowed by command.allowedSkills`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
|
|
93
|
+
|
|
94
|
+
function readShortIdLength(repoRoot: string): number {
|
|
95
|
+
try {
|
|
96
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(repoRoot, '.agents', '.airc.json'), 'utf8'));
|
|
97
|
+
const value = cfg?.task?.shortIdLength;
|
|
98
|
+
if (typeof value === 'number' && Number.isFinite(value) && value >= 1) return value;
|
|
99
|
+
} catch {
|
|
100
|
+
// Use the project default when config is absent in lightweight tests.
|
|
101
|
+
}
|
|
102
|
+
return 2;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function readActiveShortIdRegistry(repoRoot: string): Record<string, string> {
|
|
106
|
+
const registryPath = path.join(repoRoot, '.agents', 'workspace', 'active', '.short-ids.json');
|
|
107
|
+
try {
|
|
108
|
+
const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
|
109
|
+
return data && typeof data === 'object' && data.ids && typeof data.ids === 'object' ? data.ids : {};
|
|
110
|
+
} catch {
|
|
111
|
+
return {};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type ActiveTaskIdentity = {
|
|
116
|
+
taskId: string;
|
|
117
|
+
taskDir: string;
|
|
118
|
+
taskRef: string;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
function resolveActiveTaskIdentity(taskRef: string, repoRoot: string): ActiveTaskIdentity | null {
|
|
122
|
+
let taskId: string | null = null;
|
|
123
|
+
let resolvedTaskRef = taskRef;
|
|
124
|
+
|
|
125
|
+
if (TASK_ID_RE.test(taskRef)) {
|
|
126
|
+
taskId = taskRef;
|
|
127
|
+
resolvedTaskRef = loadShortIdByTaskId(repoRoot).get(taskId) ?? taskRef;
|
|
128
|
+
} else {
|
|
129
|
+
const normalized = normalizeShortIdInput(taskRef, { shortIdLength: readShortIdLength(repoRoot) });
|
|
130
|
+
if (normalized.kind !== 'shortId') return null;
|
|
131
|
+
resolvedTaskRef = normalized.value;
|
|
132
|
+
taskId = readActiveShortIdRegistry(repoRoot)[normalized.value.slice(1)] ?? null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!taskId) return null;
|
|
136
|
+
const taskDir = path.join(repoRoot, '.agents', 'workspace', 'active', taskId);
|
|
137
|
+
if (!fs.existsSync(path.join(taskDir, 'task.md'))) return null;
|
|
138
|
+
return { taskId, taskDir, taskRef: resolvedTaskRef };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function formatLocalTimestamp(date: Date = new Date()): string {
|
|
142
|
+
const pad = (n: number): string => String(n).padStart(2, '0');
|
|
143
|
+
const year = date.getFullYear();
|
|
144
|
+
const month = pad(date.getMonth() + 1);
|
|
145
|
+
const day = pad(date.getDate());
|
|
146
|
+
const hour = pad(date.getHours());
|
|
147
|
+
const minute = pad(date.getMinutes());
|
|
148
|
+
const second = pad(date.getSeconds());
|
|
149
|
+
const offsetMinutes = -date.getTimezoneOffset();
|
|
150
|
+
const sign = offsetMinutes >= 0 ? '+' : '-';
|
|
151
|
+
const offsetHour = pad(Math.floor(Math.abs(offsetMinutes) / 60));
|
|
152
|
+
const offsetMinute = pad(Math.abs(offsetMinutes) % 60);
|
|
153
|
+
return `${year}-${month}-${day} ${hour}:${minute}:${second}${sign}${offsetHour}:${offsetMinute}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function writeRunRecord(params: {
|
|
157
|
+
identity: ActiveTaskIdentity;
|
|
158
|
+
run: SandboxRunMetadata;
|
|
159
|
+
branch: string;
|
|
160
|
+
command: string[];
|
|
161
|
+
}): void {
|
|
162
|
+
const runsDir = path.join(params.identity.taskDir, 'runs');
|
|
163
|
+
fs.mkdirSync(runsDir, { recursive: true });
|
|
164
|
+
const record = {
|
|
165
|
+
version: 1,
|
|
166
|
+
run_id: params.run.runId,
|
|
167
|
+
task_id: params.identity.taskId,
|
|
168
|
+
task_ref: params.identity.taskRef,
|
|
169
|
+
branch: params.branch,
|
|
170
|
+
engine: params.run.engine,
|
|
171
|
+
container: params.run.container,
|
|
172
|
+
run_dir: params.run.runDir,
|
|
173
|
+
status_file: `${params.run.runDir}/status`,
|
|
174
|
+
log_file: `${params.run.runDir}/output.log`,
|
|
175
|
+
created_at: formatLocalTimestamp(),
|
|
176
|
+
command: params.command
|
|
177
|
+
};
|
|
178
|
+
fs.writeFileSync(
|
|
179
|
+
path.join(runsDir, `${params.run.runId}.json`),
|
|
180
|
+
`${JSON.stringify(record, null, 2)}\n`,
|
|
181
|
+
'utf8'
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function runSkill(args: string[], options: RunSkillOptions = {}): Promise<number> {
|
|
186
|
+
const parsed = parseRunArgs(args);
|
|
187
|
+
const config = options.command ? null : loadServerConfig({ rootDir: options.repoRoot });
|
|
188
|
+
const commandConfig = options.command ?? config?.command ?? {};
|
|
189
|
+
assertAllowedByConfig(parsed.skill, commandConfig);
|
|
190
|
+
const tui = selectTui(parsed.skill, { cliTui: parsed.tui, command: commandConfig });
|
|
191
|
+
const promptArgs = parsed.taskRef === null ? parsed.args : [parsed.taskRef, ...parsed.args];
|
|
192
|
+
const prompt = renderPrompt({ tui, skill: parsed.skill, args: promptArgs });
|
|
193
|
+
const [file, argv] = buildTuiCommand(tui, prompt);
|
|
194
|
+
const command = [file, ...argv];
|
|
195
|
+
|
|
196
|
+
if (parsed.taskRef === null) {
|
|
197
|
+
const result = await (options.runHost ?? runHostCommand)(command);
|
|
198
|
+
return result.exitCode ?? (result.signal ? 1 : 0);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const repoRoot = options.repoRoot ?? config?.repoRoot ?? process.cwd();
|
|
202
|
+
const branch = resolveTaskBranch(parsed.taskRef, repoRoot);
|
|
203
|
+
const identity = resolveActiveTaskIdentity(parsed.taskRef, repoRoot);
|
|
204
|
+
const runId = identity ? createRunId() : undefined;
|
|
205
|
+
const writeStdout = options.writeStdout ?? ((chunk: string) => process.stdout.write(chunk));
|
|
206
|
+
const writeStderr = options.writeStderr ?? ((chunk: string) => process.stderr.write(chunk));
|
|
207
|
+
const runSandbox =
|
|
208
|
+
options.runSandbox ??
|
|
209
|
+
((request: SandboxRunRequest) => runInSandbox(request));
|
|
210
|
+
const result = await runSandbox({ taskRef: parsed.taskRef, branch, command, runId });
|
|
211
|
+
if (result.stdout) {
|
|
212
|
+
writeStdout(result.stdout);
|
|
213
|
+
}
|
|
214
|
+
if (result.stderr) {
|
|
215
|
+
writeStderr(result.stderr);
|
|
216
|
+
}
|
|
217
|
+
if ((result.exitCode ?? (result.signal ? 1 : 0)) === 0 && identity && result.run) {
|
|
218
|
+
writeRunRecord({ identity, run: result.run, branch, command });
|
|
219
|
+
}
|
|
220
|
+
return result.exitCode ?? (result.signal ? 1 : 0);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function cmdRun(args: string[]): Promise<void> {
|
|
224
|
+
try {
|
|
225
|
+
const code = await runSkill(args);
|
|
226
|
+
process.exitCode = code;
|
|
227
|
+
} catch (error) {
|
|
228
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
229
|
+
if (message.startsWith('Usage:')) {
|
|
230
|
+
process.stdout.write(`${message}\n`);
|
|
231
|
+
process.exitCode = args.length === 0 ? 1 : 0;
|
|
232
|
+
} else {
|
|
233
|
+
process.stderr.write(`Error: ${message}\n`);
|
|
234
|
+
process.exitCode = 1;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { renderPrompt } from './tui.ts';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type SkillRunSpec =
|
|
2
|
+
| { kind: 'task'; skill: string; role: 'exec'; requiresSandbox: true }
|
|
3
|
+
| { kind: 'create'; skill: 'create-task'; role: 'exec'; requiresSandbox: false };
|
|
4
|
+
|
|
5
|
+
const TASK_SKILLS = new Set([
|
|
6
|
+
'analyze-task',
|
|
7
|
+
'block-task',
|
|
8
|
+
'cancel-task',
|
|
9
|
+
'code-task',
|
|
10
|
+
'commit',
|
|
11
|
+
'complete-task',
|
|
12
|
+
'create-pr',
|
|
13
|
+
'plan-task',
|
|
14
|
+
'review-analysis',
|
|
15
|
+
'review-code',
|
|
16
|
+
'review-plan',
|
|
17
|
+
'test',
|
|
18
|
+
'test-integration',
|
|
19
|
+
'watch-pr'
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
export function getSkillRunSpec(skill: string): SkillRunSpec | null {
|
|
23
|
+
if (skill === 'create-task') {
|
|
24
|
+
return { kind: 'create', skill: 'create-task', role: 'exec', requiresSandbox: false };
|
|
25
|
+
}
|
|
26
|
+
if (TASK_SKILLS.has(skill)) {
|
|
27
|
+
return { kind: 'task', skill, role: 'exec', requiresSandbox: true };
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function allowedSkillNames(): string[] {
|
|
33
|
+
return ['create-task', ...TASK_SKILLS].sort();
|
|
34
|
+
}
|
package/lib/run/tui.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type TuiName = 'claude' | 'codex' | 'gemini' | 'opencode';
|
|
2
|
+
|
|
3
|
+
const TUI_NAMES = new Set(['claude', 'codex', 'gemini', 'opencode']);
|
|
4
|
+
|
|
5
|
+
export type CommandConfig = {
|
|
6
|
+
defaultTui?: unknown;
|
|
7
|
+
skillTuiDefaults?: unknown;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function isTuiName(value: unknown): value is TuiName {
|
|
11
|
+
return typeof value === 'string' && TUI_NAMES.has(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function selectTui(
|
|
15
|
+
skill: string,
|
|
16
|
+
options: { cliTui?: string | null; command?: CommandConfig }
|
|
17
|
+
): TuiName {
|
|
18
|
+
if (isTuiName(options.cliTui)) return options.cliTui;
|
|
19
|
+
const defaults = options.command?.skillTuiDefaults;
|
|
20
|
+
if (defaults && typeof defaults === 'object' && !Array.isArray(defaults)) {
|
|
21
|
+
const value = (defaults as Record<string, unknown>)[skill];
|
|
22
|
+
if (isTuiName(value)) return value;
|
|
23
|
+
}
|
|
24
|
+
if (isTuiName(options.command?.defaultTui)) return options.command.defaultTui;
|
|
25
|
+
return 'codex';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function renderPrompt(params: { tui: TuiName; skill: string; args: string[] }): string {
|
|
29
|
+
const suffix = [params.skill, ...params.args].join(' ').trim();
|
|
30
|
+
if (params.tui === 'codex') return `$${suffix}`;
|
|
31
|
+
if (params.tui === 'gemini') return `/agent-infra:${suffix}`;
|
|
32
|
+
return `/${suffix}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function buildTuiCommand(tui: TuiName, prompt: string): [string, string[]] {
|
|
36
|
+
if (tui === 'claude') return ['claude', ['--dangerously-skip-permissions', '--print', prompt]];
|
|
37
|
+
if (tui === 'gemini') return ['gemini', ['--approval-mode', 'yolo', '--prompt', prompt]];
|
|
38
|
+
if (tui === 'opencode') return ['opencode', ['run', '--dangerously-skip-permissions', prompt]];
|
|
39
|
+
return ['codex', ['exec', '--dangerously-bypass-approvals-and-sandbox', prompt]];
|
|
40
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { loadConfig } from './config.ts';
|
|
3
|
+
import { containerNameCandidates, sandboxBranchLabel, sandboxLabel } from './constants.ts';
|
|
4
|
+
import { detectEngine } from './engine.ts';
|
|
5
|
+
import { hostTimezoneEnvFlags, terminalEnvFlags } from './commands/enter.ts';
|
|
6
|
+
import {
|
|
7
|
+
fetchSandboxRows,
|
|
8
|
+
selectSandboxContainer,
|
|
9
|
+
startSandboxContainer,
|
|
10
|
+
type SandboxRow
|
|
11
|
+
} from './commands/list-running.ts';
|
|
12
|
+
|
|
13
|
+
export type SandboxCaptureRequest = {
|
|
14
|
+
taskRef: string;
|
|
15
|
+
branch: string;
|
|
16
|
+
command: string[];
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type SandboxCaptureResult = {
|
|
21
|
+
exitCode: number | null;
|
|
22
|
+
signal: NodeJS.Signals | null;
|
|
23
|
+
stdout: string;
|
|
24
|
+
stderr: string;
|
|
25
|
+
run?: SandboxRunMetadata;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type SandboxRunMetadata = {
|
|
29
|
+
runId: string;
|
|
30
|
+
engine: string;
|
|
31
|
+
container: string;
|
|
32
|
+
runDir: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type SandboxCaptureOptions = {
|
|
36
|
+
engine?: string;
|
|
37
|
+
repoRoot?: string;
|
|
38
|
+
runId?: string;
|
|
39
|
+
containerCandidates?: string[];
|
|
40
|
+
rows?: SandboxRow[];
|
|
41
|
+
startContainer?: (name: string) => void;
|
|
42
|
+
spawn?: (file: string, args: string[]) => Promise<SandboxCaptureResult>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
async function spawnCapture(
|
|
46
|
+
file: string,
|
|
47
|
+
args: string[]
|
|
48
|
+
): Promise<SandboxCaptureResult> {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const child = spawn(file, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
51
|
+
let stdout = '';
|
|
52
|
+
let stderr = '';
|
|
53
|
+
let settled = false;
|
|
54
|
+
const rejectOnce = (error: unknown): void => {
|
|
55
|
+
if (settled) return;
|
|
56
|
+
settled = true;
|
|
57
|
+
reject(error);
|
|
58
|
+
};
|
|
59
|
+
const resolveOnce = (result: SandboxCaptureResult): void => {
|
|
60
|
+
if (settled) return;
|
|
61
|
+
settled = true;
|
|
62
|
+
resolve(result);
|
|
63
|
+
};
|
|
64
|
+
child.stdout.setEncoding('utf8');
|
|
65
|
+
child.stderr.setEncoding('utf8');
|
|
66
|
+
child.stdout.on('data', (chunk) => {
|
|
67
|
+
stdout += chunk;
|
|
68
|
+
});
|
|
69
|
+
child.stderr.on('data', (chunk) => {
|
|
70
|
+
stderr += chunk;
|
|
71
|
+
});
|
|
72
|
+
child.on('error', rejectOnce);
|
|
73
|
+
child.on('close', (exitCode, signal) => {
|
|
74
|
+
resolveOnce({ exitCode, signal, stdout, stderr });
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function shellQuote(value: string): string {
|
|
80
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function createRunId(date: Date = new Date()): string {
|
|
84
|
+
return `run-${date.toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/[-:]/g, '').replace('T', '-').replace('Z', '')}-${process.pid}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function buildRunScript(params: {
|
|
88
|
+
command: string[];
|
|
89
|
+
runDir: string;
|
|
90
|
+
runId: string;
|
|
91
|
+
}): string {
|
|
92
|
+
const command = params.command.map(shellQuote).join(' ');
|
|
93
|
+
const runDir = shellQuote(params.runDir);
|
|
94
|
+
const runId = shellQuote(params.runId);
|
|
95
|
+
return `#!/bin/sh
|
|
96
|
+
set -u
|
|
97
|
+
cd /workspace
|
|
98
|
+
date "+%Y-%m-%d %H:%M:%S%:z" > ${runDir}/started_at
|
|
99
|
+
printf '%s\\n' running > ${runDir}/status
|
|
100
|
+
${command}
|
|
101
|
+
code=$?
|
|
102
|
+
printf '%s\\n' "$code" > ${runDir}/exit_code
|
|
103
|
+
date "+%Y-%m-%d %H:%M:%S%:z" > ${runDir}/finished_at
|
|
104
|
+
if [ "$code" -eq 0 ]; then
|
|
105
|
+
printf '%s\\n' completed > ${runDir}/status
|
|
106
|
+
else
|
|
107
|
+
printf '%s\\n' failed > ${runDir}/status
|
|
108
|
+
fi
|
|
109
|
+
printf '\\n[agent-infra] run %s finished with exit code %s\\n' ${runId} "$code"
|
|
110
|
+
exec bash -l
|
|
111
|
+
`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function buildTmuxLauncher(params: {
|
|
115
|
+
request: SandboxCaptureRequest;
|
|
116
|
+
runId: string;
|
|
117
|
+
}): string {
|
|
118
|
+
const session = 'work';
|
|
119
|
+
const window = `ai-${params.runId.replace(/^run-/, '').slice(0, 18)}`;
|
|
120
|
+
const runRoot = '/tmp/agent-infra-runs';
|
|
121
|
+
const runDir = `${runRoot}/${params.runId}`;
|
|
122
|
+
const runScript = buildRunScript({ command: params.request.command, runDir, runId: params.runId });
|
|
123
|
+
const runScriptBase64 = Buffer.from(runScript, 'utf8').toString('base64');
|
|
124
|
+
const paneCommand = `cd /workspace && ${shellQuote(`${runDir}/run.sh`)}`;
|
|
125
|
+
|
|
126
|
+
return `set -eu
|
|
127
|
+
session=${shellQuote(session)}
|
|
128
|
+
window=${shellQuote(window)}
|
|
129
|
+
run_id=${shellQuote(params.runId)}
|
|
130
|
+
run_dir=${shellQuote(runDir)}
|
|
131
|
+
task_ref=${shellQuote(params.request.taskRef)}
|
|
132
|
+
branch=${shellQuote(params.request.branch)}
|
|
133
|
+
|
|
134
|
+
sandbox-dotfiles-link >/dev/null 2>&1 || true
|
|
135
|
+
mkdir -p "$run_dir"
|
|
136
|
+
printf '%s\\n' pending > "$run_dir/status"
|
|
137
|
+
printf '%s\\n' "$task_ref" > "$run_dir/task_ref"
|
|
138
|
+
printf '%s\\n' "$branch" > "$run_dir/branch"
|
|
139
|
+
printf '%s\\n' ${shellQuote(params.request.command.join(' '))} > "$run_dir/command"
|
|
140
|
+
printf '%s' ${shellQuote(runScriptBase64)} | base64 -d > "$run_dir/run.sh"
|
|
141
|
+
chmod +x "$run_dir/run.sh"
|
|
142
|
+
|
|
143
|
+
if ! command -v tmux >/dev/null 2>&1; then
|
|
144
|
+
printf '%s\\n' "tmux is not installed in this sandbox" >&2
|
|
145
|
+
exit 127
|
|
146
|
+
fi
|
|
147
|
+
|
|
148
|
+
if ! tmux has-session -t "$session" 2>/dev/null; then
|
|
149
|
+
tmux new-session -d -s "$session" -n shell
|
|
150
|
+
fi
|
|
151
|
+
|
|
152
|
+
if [ -n "\${TZ:-}" ]; then
|
|
153
|
+
tmux set-environment -t "$session" TZ "$TZ" 2>/dev/null || true
|
|
154
|
+
fi
|
|
155
|
+
|
|
156
|
+
pane=$(tmux new-window -d -P -F '#{pane_id}' -t "$session" -n "$window")
|
|
157
|
+
printf '%s\\n' "$session" > "$run_dir/session"
|
|
158
|
+
printf '%s\\n' "$window" > "$run_dir/window"
|
|
159
|
+
printf '%s\\n' "$pane" > "$run_dir/pane"
|
|
160
|
+
tmux pipe-pane -o -t "$pane" "cat > $run_dir/output.log"
|
|
161
|
+
tmux send-keys -t "$pane" ${shellQuote(paneCommand)} Enter
|
|
162
|
+
|
|
163
|
+
cat <<EOF
|
|
164
|
+
Started sandbox run $run_id in tmux session '$session', window '$window', pane '$pane'.
|
|
165
|
+
Attach with: ai sandbox enter $task_ref
|
|
166
|
+
Status file: $run_dir/status
|
|
167
|
+
Output log: $run_dir/output.log
|
|
168
|
+
EOF
|
|
169
|
+
`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function runInSandbox(
|
|
173
|
+
request: SandboxCaptureRequest,
|
|
174
|
+
options: SandboxCaptureOptions = {}
|
|
175
|
+
): Promise<SandboxCaptureResult> {
|
|
176
|
+
const config = options.engine ? null : loadConfig();
|
|
177
|
+
const engine = options.engine ?? detectEngine(config!);
|
|
178
|
+
const rows =
|
|
179
|
+
options.rows ??
|
|
180
|
+
(() => {
|
|
181
|
+
const fetched = fetchSandboxRows(engine, sandboxLabel(config!), sandboxBranchLabel(config!));
|
|
182
|
+
return [...fetched.running, ...fetched.nonRunning];
|
|
183
|
+
})();
|
|
184
|
+
const candidates = options.containerCandidates ?? containerNameCandidates(config!, request.branch);
|
|
185
|
+
const found = selectSandboxContainer(rows, candidates);
|
|
186
|
+
if (!found) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`Sandbox for ${request.branch} not found. Create it first with ai sandbox create ${request.taskRef}.`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
if (!found.running) {
|
|
192
|
+
(options.startContainer ?? ((name: string) => startSandboxContainer(engine, name)))(found.name);
|
|
193
|
+
}
|
|
194
|
+
const runId = options.runId ?? createRunId();
|
|
195
|
+
const runDir = `/tmp/agent-infra-runs/${runId}`;
|
|
196
|
+
const dockerArgs = [
|
|
197
|
+
'exec',
|
|
198
|
+
...terminalEnvFlags(),
|
|
199
|
+
...hostTimezoneEnvFlags(),
|
|
200
|
+
found.name,
|
|
201
|
+
'bash',
|
|
202
|
+
'-lc',
|
|
203
|
+
buildTmuxLauncher({ request, runId })
|
|
204
|
+
];
|
|
205
|
+
const result = await (options.spawn ?? spawnCapture)('docker', dockerArgs);
|
|
206
|
+
return {
|
|
207
|
+
...result,
|
|
208
|
+
run: {
|
|
209
|
+
runId,
|
|
210
|
+
engine,
|
|
211
|
+
container: found.name,
|
|
212
|
+
runDir
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
}
|
|
@@ -740,6 +740,63 @@ function readHostJsonSafe(filePath: string): JsonObject | null {
|
|
|
740
740
|
}
|
|
741
741
|
}
|
|
742
742
|
|
|
743
|
+
const CLAUDE_SETTINGS_INHERIT_TOP_LEVEL_KEYS = [
|
|
744
|
+
'model',
|
|
745
|
+
'fallbackModel',
|
|
746
|
+
'availableModels',
|
|
747
|
+
'modelOverrides',
|
|
748
|
+
'enforceAvailableModels',
|
|
749
|
+
'advisorModel',
|
|
750
|
+
'apiKeyHelper',
|
|
751
|
+
'effortLevel'
|
|
752
|
+
];
|
|
753
|
+
|
|
754
|
+
function isJsonObjectRecord(value: unknown): value is JsonObject {
|
|
755
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function mergeMissingStringEnvFields(target: JsonObject, source: JsonObject): boolean {
|
|
759
|
+
if (!isJsonObjectRecord(source.env)) {
|
|
760
|
+
return false;
|
|
761
|
+
}
|
|
762
|
+
if (Object.hasOwn(target, 'env') && !isJsonObjectRecord(target.env)) {
|
|
763
|
+
return false;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
let targetEnv = target.env as JsonObject | undefined;
|
|
767
|
+
let changed = false;
|
|
768
|
+
for (const [key, value] of Object.entries(source.env)) {
|
|
769
|
+
if (typeof value !== 'string' || value === '') {
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
if (!targetEnv) {
|
|
773
|
+
targetEnv = {};
|
|
774
|
+
target.env = targetEnv;
|
|
775
|
+
}
|
|
776
|
+
if (!Object.hasOwn(targetEnv, key)) {
|
|
777
|
+
targetEnv[key] = value;
|
|
778
|
+
changed = true;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
return changed;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function mergeMissingTopLevelSettings(target: JsonObject, source: JsonObject): boolean {
|
|
785
|
+
let changed = false;
|
|
786
|
+
for (const key of CLAUDE_SETTINGS_INHERIT_TOP_LEVEL_KEYS) {
|
|
787
|
+
if (!Object.hasOwn(source, key) || Object.hasOwn(target, key)) {
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
const value = source[key];
|
|
791
|
+
if (value === null || value === undefined || value === '') {
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
target[key] = value;
|
|
795
|
+
changed = true;
|
|
796
|
+
}
|
|
797
|
+
return changed;
|
|
798
|
+
}
|
|
799
|
+
|
|
743
800
|
export function ensureClaudeOnboarding(toolDir: string, hostHomeDir?: string): void {
|
|
744
801
|
const claudeJsonPath = path.join(toolDir, '.claude.json');
|
|
745
802
|
let data: JsonObject & {
|
|
@@ -810,7 +867,7 @@ export function ensureClaudeOnboarding(toolDir: string, hostHomeDir?: string): v
|
|
|
810
867
|
|
|
811
868
|
export function ensureClaudeSettings(toolDir: string, hostHomeDir?: string): void {
|
|
812
869
|
const settingsPath = path.join(toolDir, 'settings.json');
|
|
813
|
-
let data: JsonObject & { skipDangerousModePermissionPrompt?: boolean
|
|
870
|
+
let data: JsonObject & { skipDangerousModePermissionPrompt?: boolean } = {};
|
|
814
871
|
if (fs.existsSync(settingsPath)) {
|
|
815
872
|
try {
|
|
816
873
|
data = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as typeof data;
|
|
@@ -825,14 +882,9 @@ export function ensureClaudeSettings(toolDir: string, hostHomeDir?: string): voi
|
|
|
825
882
|
}
|
|
826
883
|
if (hostHomeDir) {
|
|
827
884
|
const hostSettings = readHostJsonSafe(path.join(hostHomeDir, '.claude', 'settings.json'));
|
|
828
|
-
if (
|
|
829
|
-
hostSettings
|
|
830
|
-
|
|
831
|
-
&& hostSettings.effortLevel !== ''
|
|
832
|
-
&& !Object.hasOwn(data, 'effortLevel')
|
|
833
|
-
) {
|
|
834
|
-
data.effortLevel = hostSettings.effortLevel;
|
|
835
|
-
changed = true;
|
|
885
|
+
if (hostSettings) {
|
|
886
|
+
changed = mergeMissingStringEnvFields(data, hostSettings) || changed;
|
|
887
|
+
changed = mergeMissingTopLevelSettings(data, hostSettings) || changed;
|
|
836
888
|
}
|
|
837
889
|
}
|
|
838
890
|
if (changed) {
|
|
@@ -1386,9 +1438,9 @@ export async function create(args: string[]): Promise<void> {
|
|
|
1386
1438
|
if (claudeCodeEntry) {
|
|
1387
1439
|
ensureClaudeOnboarding(claudeCodeEntry.dir, effectiveConfig.home);
|
|
1388
1440
|
ensureClaudeSettings(claudeCodeEntry.dir, effectiveConfig.home);
|
|
1389
|
-
// prepareClaudeCredentials wrote
|
|
1390
|
-
//
|
|
1391
|
-
// claude-code entry was removed
|
|
1441
|
+
// prepareClaudeCredentials wrote OAuth credentials or confirmed
|
|
1442
|
+
// provider/API settings are enough for Claude Code. If no usable
|
|
1443
|
+
// auth was present, the claude-code entry was removed above.
|
|
1392
1444
|
}
|
|
1393
1445
|
const codexEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'codex');
|
|
1394
1446
|
if (codexEntry) {
|
|
@@ -9,6 +9,7 @@ import { detectEngine } from '../engine.ts';
|
|
|
9
9
|
import {
|
|
10
10
|
formatCredentialWarnings,
|
|
11
11
|
formatRemaining,
|
|
12
|
+
hasClaudeProviderAuth,
|
|
12
13
|
reconcileClaudeCredentials,
|
|
13
14
|
redactCommandError,
|
|
14
15
|
validateClaudeCredentialsEnvOverride
|
|
@@ -93,8 +94,12 @@ export function runSandboxInteractive(params: {
|
|
|
93
94
|
|
|
94
95
|
export function formatCredentialSyncStatus(
|
|
95
96
|
result: ReturnType<typeof reconcileClaudeCredentials>,
|
|
96
|
-
isTTY = process.stderr.isTTY
|
|
97
|
+
isTTY = process.stderr.isTTY,
|
|
98
|
+
providerAuthAvailable = false
|
|
97
99
|
): string | null {
|
|
100
|
+
if (providerAuthAvailable && (result.status === 'STALE_ACCESS' || result.status === 'MISSING')) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
98
103
|
if (result.status === 'STALE_ACCESS') {
|
|
99
104
|
return 'Warning: Claude Code credentials on host appear stale. Run "ai sandbox refresh" or "claude /login" to renew.\n';
|
|
100
105
|
}
|
|
@@ -158,8 +163,9 @@ export async function enter(args: string[]): Promise<number> {
|
|
|
158
163
|
if (config.tools.includes('claude-code')) {
|
|
159
164
|
try {
|
|
160
165
|
// Scan all projects so a refresh from a neighbouring sandbox can still flow back to the host.
|
|
166
|
+
const providerAuthAvailable = hasClaudeProviderAuth(config.home);
|
|
161
167
|
const result = reconcileClaudeCredentials(config.home);
|
|
162
|
-
const message = formatCredentialSyncStatus(result);
|
|
168
|
+
const message = formatCredentialSyncStatus(result, process.stderr.isTTY, providerAuthAvailable);
|
|
163
169
|
if (message) {
|
|
164
170
|
process.stderr.write(message);
|
|
165
171
|
}
|