@tagma/sdk 0.1.3 → 0.1.4
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 +139 -139
- package/package.json +4 -4
- package/src/adapters/stdin-approval.ts +117 -117
- package/src/adapters/websocket-approval.ts +144 -144
- package/src/completions/exit-code.ts +19 -19
- package/src/completions/file-exists.ts +39 -39
- package/src/completions/output-check.ts +57 -57
- package/src/dag.ts +137 -137
- package/src/drivers/claude-code.ts +207 -207
- package/src/engine.ts +637 -598
- package/src/hooks.ts +138 -138
- package/src/logger.ts +107 -100
- package/src/middlewares/static-context.ts +29 -29
- package/src/runner.ts +193 -193
- package/src/schema.ts +260 -260
- package/src/triggers/file.ts +94 -94
- package/src/triggers/manual.ts +61 -61
- package/src/utils.ts +147 -147
package/src/runner.ts
CHANGED
|
@@ -1,194 +1,194 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs';
|
|
2
|
-
import { isAbsolute, join } from 'node:path';
|
|
3
|
-
import type { SpawnSpec, DriverPlugin, TaskResult, TaskConfig } from './types';
|
|
4
|
-
import { shellArgs } from './utils';
|
|
5
|
-
|
|
6
|
-
export interface RunOptions {
|
|
7
|
-
readonly timeoutMs?: number;
|
|
8
|
-
readonly signal?: AbortSignal; // pipeline-level abort
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* On Windows, Bun.spawn does NOT auto-append PATHEXT extensions like
|
|
13
|
-
* CreateProcess does. A bare command like `claude` fails with ENOENT if the
|
|
14
|
-
* actual file on disk is `claude.cmd` / `claude.bat` / `claude.ps1`. We
|
|
15
|
-
* manually resolve the command against PATH + PATHEXT here so Drivers can
|
|
16
|
-
* keep using short names (`claude`, `npx`, etc.) cross-platform.
|
|
17
|
-
*
|
|
18
|
-
* Returns the original name if resolution fails; Bun will raise the same
|
|
19
|
-
* ENOENT it would have otherwise.
|
|
20
|
-
*/
|
|
21
|
-
function resolveWindowsExe(
|
|
22
|
-
args: readonly string[],
|
|
23
|
-
envPath: string,
|
|
24
|
-
): readonly string[] {
|
|
25
|
-
if (process.platform !== 'win32' || args.length === 0) return args;
|
|
26
|
-
const cmd = args[0]!;
|
|
27
|
-
// Already a full path or has an extension → trust caller.
|
|
28
|
-
if (isAbsolute(cmd) || /\.[a-z0-9]+$/i.test(cmd)) return args;
|
|
29
|
-
|
|
30
|
-
const exts = (
|
|
31
|
-
process.env.PATHEXT ??
|
|
32
|
-
'.COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC'
|
|
33
|
-
)
|
|
34
|
-
.split(';')
|
|
35
|
-
.filter(Boolean);
|
|
36
|
-
const dirs = envPath.split(';').filter(Boolean);
|
|
37
|
-
|
|
38
|
-
for (const dir of dirs) {
|
|
39
|
-
for (const ext of exts) {
|
|
40
|
-
const candidate = join(dir, cmd + ext);
|
|
41
|
-
if (existsSync(candidate)) {
|
|
42
|
-
return [candidate, ...args.slice(1)];
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
return args;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Build a "failed before spawn" result. */
|
|
50
|
-
function failResult(stderr: string, durationMs: number): TaskResult {
|
|
51
|
-
return {
|
|
52
|
-
exitCode: -1,
|
|
53
|
-
stdout: '',
|
|
54
|
-
stderr,
|
|
55
|
-
outputPath: null,
|
|
56
|
-
stderrPath: null,
|
|
57
|
-
durationMs,
|
|
58
|
-
sessionId: null,
|
|
59
|
-
normalizedOutput: null,
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export async function runSpawn(
|
|
64
|
-
spec: SpawnSpec,
|
|
65
|
-
driver: DriverPlugin | null,
|
|
66
|
-
opts: RunOptions = {},
|
|
67
|
-
): Promise<TaskResult> {
|
|
68
|
-
const { timeoutMs, signal } = opts;
|
|
69
|
-
const start = performance.now();
|
|
70
|
-
const elapsed = () => Math.round(performance.now() - start);
|
|
71
|
-
|
|
72
|
-
const mergedEnv = { ...process.env, ...(spec.env ?? {}) };
|
|
73
|
-
const resolvedArgs = resolveWindowsExe(
|
|
74
|
-
spec.args,
|
|
75
|
-
mergedEnv.PATH ?? process.env.PATH ?? '',
|
|
76
|
-
);
|
|
77
|
-
|
|
78
|
-
// ── 1. Spawn (catch ENOENT / bad-cwd up front) ────────────────────────
|
|
79
|
-
let proc: ReturnType<typeof Bun.spawn>;
|
|
80
|
-
try {
|
|
81
|
-
proc = Bun.spawn(resolvedArgs as string[], {
|
|
82
|
-
cwd: spec.cwd,
|
|
83
|
-
env: mergedEnv,
|
|
84
|
-
stdout: 'pipe',
|
|
85
|
-
stderr: 'pipe',
|
|
86
|
-
stdin: spec.stdin ? 'pipe' : undefined,
|
|
87
|
-
});
|
|
88
|
-
} catch (err) {
|
|
89
|
-
return failResult(String(err), elapsed());
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// ── 2. Write stdin ─────────────────────────────────────────────────────
|
|
93
|
-
// Child may exit before reading (e.g. quick-fail commands that don't
|
|
94
|
-
// touch stdin) → swallow EPIPE rather than surfacing it as an
|
|
95
|
-
// engine-level error.
|
|
96
|
-
if (spec.stdin && proc.stdin && typeof proc.stdin !== 'number') {
|
|
97
|
-
try {
|
|
98
|
-
proc.stdin.write(spec.stdin);
|
|
99
|
-
proc.stdin.end();
|
|
100
|
-
} catch {
|
|
101
|
-
/* ignore EPIPE / closed-pipe errors */
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// ── 3. Timeout & abort handling ────────────────────────────────────────
|
|
106
|
-
let killedByUs = false;
|
|
107
|
-
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
108
|
-
let forceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
109
|
-
|
|
110
|
-
const killGracefully = () => {
|
|
111
|
-
if (killedByUs) return;
|
|
112
|
-
killedByUs = true;
|
|
113
|
-
proc.kill('SIGTERM');
|
|
114
|
-
// If the child ignores SIGTERM, escalate to SIGKILL after 3 s.
|
|
115
|
-
forceTimer = setTimeout(() => {
|
|
116
|
-
try {
|
|
117
|
-
proc.kill('SIGKILL');
|
|
118
|
-
} catch {
|
|
119
|
-
/* already exited */
|
|
120
|
-
}
|
|
121
|
-
}, 3_000);
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
if (timeoutMs && timeoutMs > 0) {
|
|
125
|
-
timer = setTimeout(killGracefully, timeoutMs);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const onAbort = () => killGracefully();
|
|
129
|
-
if (signal) {
|
|
130
|
-
if (signal.aborted) {
|
|
131
|
-
killGracefully();
|
|
132
|
-
} else {
|
|
133
|
-
signal.addEventListener('abort', onAbort, { once: true });
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// ── 4. Collect output & wait (parallel to avoid pipe-buffer deadlock) ─
|
|
138
|
-
const stdoutStream = typeof proc.stdout === 'object' ? proc.stdout : undefined;
|
|
139
|
-
const stderrStream = typeof proc.stderr === 'object' ? proc.stderr : undefined;
|
|
140
|
-
|
|
141
|
-
const [exitCode, stdout, stderr] = await Promise.all([
|
|
142
|
-
proc.exited,
|
|
143
|
-
stdoutStream ? new Response(stdoutStream).text() : Promise.resolve(''),
|
|
144
|
-
stderrStream ? new Response(stderrStream).text() : Promise.resolve(''),
|
|
145
|
-
]);
|
|
146
|
-
|
|
147
|
-
// ── 5. Cleanup timers & listeners ──────────────────────────────────────
|
|
148
|
-
if (timer) clearTimeout(timer);
|
|
149
|
-
if (forceTimer) clearTimeout(forceTimer);
|
|
150
|
-
if (signal) signal.removeEventListener('abort', onAbort);
|
|
151
|
-
|
|
152
|
-
const durationMs = elapsed();
|
|
153
|
-
|
|
154
|
-
// If we killed the process but it had already exited with a real code
|
|
155
|
-
// before our signal landed, don't treat it as a timeout.
|
|
156
|
-
if (killedByUs && exitCode !== 0) {
|
|
157
|
-
return {
|
|
158
|
-
exitCode: -1,
|
|
159
|
-
stdout,
|
|
160
|
-
stderr,
|
|
161
|
-
outputPath: null,
|
|
162
|
-
stderrPath: null,
|
|
163
|
-
durationMs,
|
|
164
|
-
sessionId: null,
|
|
165
|
-
normalizedOutput: null,
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// ── 6. Let driver extract metadata ─────────────────────────────────────
|
|
170
|
-
const meta = driver?.parseResult?.(stdout, stderr) ?? {};
|
|
171
|
-
|
|
172
|
-
return {
|
|
173
|
-
exitCode,
|
|
174
|
-
stdout,
|
|
175
|
-
stderr,
|
|
176
|
-
outputPath: null,
|
|
177
|
-
stderrPath: null,
|
|
178
|
-
durationMs,
|
|
179
|
-
sessionId: meta.sessionId ?? null,
|
|
180
|
-
normalizedOutput: meta.normalizedOutput ?? null,
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
export async function runCommand(
|
|
185
|
-
command: string,
|
|
186
|
-
cwd: string,
|
|
187
|
-
opts: RunOptions = {},
|
|
188
|
-
): Promise<TaskResult> {
|
|
189
|
-
const spec: SpawnSpec = {
|
|
190
|
-
args: shellArgs(command),
|
|
191
|
-
cwd,
|
|
192
|
-
};
|
|
193
|
-
return runSpawn(spec, null, opts);
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, join } from 'node:path';
|
|
3
|
+
import type { SpawnSpec, DriverPlugin, TaskResult, TaskConfig } from './types';
|
|
4
|
+
import { shellArgs } from './utils';
|
|
5
|
+
|
|
6
|
+
export interface RunOptions {
|
|
7
|
+
readonly timeoutMs?: number;
|
|
8
|
+
readonly signal?: AbortSignal; // pipeline-level abort
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* On Windows, Bun.spawn does NOT auto-append PATHEXT extensions like
|
|
13
|
+
* CreateProcess does. A bare command like `claude` fails with ENOENT if the
|
|
14
|
+
* actual file on disk is `claude.cmd` / `claude.bat` / `claude.ps1`. We
|
|
15
|
+
* manually resolve the command against PATH + PATHEXT here so Drivers can
|
|
16
|
+
* keep using short names (`claude`, `npx`, etc.) cross-platform.
|
|
17
|
+
*
|
|
18
|
+
* Returns the original name if resolution fails; Bun will raise the same
|
|
19
|
+
* ENOENT it would have otherwise.
|
|
20
|
+
*/
|
|
21
|
+
function resolveWindowsExe(
|
|
22
|
+
args: readonly string[],
|
|
23
|
+
envPath: string,
|
|
24
|
+
): readonly string[] {
|
|
25
|
+
if (process.platform !== 'win32' || args.length === 0) return args;
|
|
26
|
+
const cmd = args[0]!;
|
|
27
|
+
// Already a full path or has an extension → trust caller.
|
|
28
|
+
if (isAbsolute(cmd) || /\.[a-z0-9]+$/i.test(cmd)) return args;
|
|
29
|
+
|
|
30
|
+
const exts = (
|
|
31
|
+
process.env.PATHEXT ??
|
|
32
|
+
'.COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC'
|
|
33
|
+
)
|
|
34
|
+
.split(';')
|
|
35
|
+
.filter(Boolean);
|
|
36
|
+
const dirs = envPath.split(';').filter(Boolean);
|
|
37
|
+
|
|
38
|
+
for (const dir of dirs) {
|
|
39
|
+
for (const ext of exts) {
|
|
40
|
+
const candidate = join(dir, cmd + ext);
|
|
41
|
+
if (existsSync(candidate)) {
|
|
42
|
+
return [candidate, ...args.slice(1)];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return args;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Build a "failed before spawn" result. */
|
|
50
|
+
function failResult(stderr: string, durationMs: number): TaskResult {
|
|
51
|
+
return {
|
|
52
|
+
exitCode: -1,
|
|
53
|
+
stdout: '',
|
|
54
|
+
stderr,
|
|
55
|
+
outputPath: null,
|
|
56
|
+
stderrPath: null,
|
|
57
|
+
durationMs,
|
|
58
|
+
sessionId: null,
|
|
59
|
+
normalizedOutput: null,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function runSpawn(
|
|
64
|
+
spec: SpawnSpec,
|
|
65
|
+
driver: DriverPlugin | null,
|
|
66
|
+
opts: RunOptions = {},
|
|
67
|
+
): Promise<TaskResult> {
|
|
68
|
+
const { timeoutMs, signal } = opts;
|
|
69
|
+
const start = performance.now();
|
|
70
|
+
const elapsed = () => Math.round(performance.now() - start);
|
|
71
|
+
|
|
72
|
+
const mergedEnv = { ...process.env, ...(spec.env ?? {}) };
|
|
73
|
+
const resolvedArgs = resolveWindowsExe(
|
|
74
|
+
spec.args,
|
|
75
|
+
mergedEnv.PATH ?? process.env.PATH ?? '',
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// ── 1. Spawn (catch ENOENT / bad-cwd up front) ────────────────────────
|
|
79
|
+
let proc: ReturnType<typeof Bun.spawn>;
|
|
80
|
+
try {
|
|
81
|
+
proc = Bun.spawn(resolvedArgs as string[], {
|
|
82
|
+
cwd: spec.cwd,
|
|
83
|
+
env: mergedEnv,
|
|
84
|
+
stdout: 'pipe',
|
|
85
|
+
stderr: 'pipe',
|
|
86
|
+
stdin: spec.stdin ? 'pipe' : undefined,
|
|
87
|
+
});
|
|
88
|
+
} catch (err) {
|
|
89
|
+
return failResult(String(err), elapsed());
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── 2. Write stdin ─────────────────────────────────────────────────────
|
|
93
|
+
// Child may exit before reading (e.g. quick-fail commands that don't
|
|
94
|
+
// touch stdin) → swallow EPIPE rather than surfacing it as an
|
|
95
|
+
// engine-level error.
|
|
96
|
+
if (spec.stdin && proc.stdin && typeof proc.stdin !== 'number') {
|
|
97
|
+
try {
|
|
98
|
+
proc.stdin.write(spec.stdin);
|
|
99
|
+
proc.stdin.end();
|
|
100
|
+
} catch {
|
|
101
|
+
/* ignore EPIPE / closed-pipe errors */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── 3. Timeout & abort handling ────────────────────────────────────────
|
|
106
|
+
let killedByUs = false;
|
|
107
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
108
|
+
let forceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
109
|
+
|
|
110
|
+
const killGracefully = () => {
|
|
111
|
+
if (killedByUs) return;
|
|
112
|
+
killedByUs = true;
|
|
113
|
+
proc.kill('SIGTERM');
|
|
114
|
+
// If the child ignores SIGTERM, escalate to SIGKILL after 3 s.
|
|
115
|
+
forceTimer = setTimeout(() => {
|
|
116
|
+
try {
|
|
117
|
+
proc.kill('SIGKILL');
|
|
118
|
+
} catch {
|
|
119
|
+
/* already exited */
|
|
120
|
+
}
|
|
121
|
+
}, 3_000);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
if (timeoutMs && timeoutMs > 0) {
|
|
125
|
+
timer = setTimeout(killGracefully, timeoutMs);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const onAbort = () => killGracefully();
|
|
129
|
+
if (signal) {
|
|
130
|
+
if (signal.aborted) {
|
|
131
|
+
killGracefully();
|
|
132
|
+
} else {
|
|
133
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── 4. Collect output & wait (parallel to avoid pipe-buffer deadlock) ─
|
|
138
|
+
const stdoutStream = typeof proc.stdout === 'object' ? proc.stdout : undefined;
|
|
139
|
+
const stderrStream = typeof proc.stderr === 'object' ? proc.stderr : undefined;
|
|
140
|
+
|
|
141
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
142
|
+
proc.exited,
|
|
143
|
+
stdoutStream ? new Response(stdoutStream).text() : Promise.resolve(''),
|
|
144
|
+
stderrStream ? new Response(stderrStream).text() : Promise.resolve(''),
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
// ── 5. Cleanup timers & listeners ──────────────────────────────────────
|
|
148
|
+
if (timer) clearTimeout(timer);
|
|
149
|
+
if (forceTimer) clearTimeout(forceTimer);
|
|
150
|
+
if (signal) signal.removeEventListener('abort', onAbort);
|
|
151
|
+
|
|
152
|
+
const durationMs = elapsed();
|
|
153
|
+
|
|
154
|
+
// If we killed the process but it had already exited with a real code
|
|
155
|
+
// before our signal landed, don't treat it as a timeout.
|
|
156
|
+
if (killedByUs && exitCode !== 0) {
|
|
157
|
+
return {
|
|
158
|
+
exitCode: -1,
|
|
159
|
+
stdout,
|
|
160
|
+
stderr,
|
|
161
|
+
outputPath: null,
|
|
162
|
+
stderrPath: null,
|
|
163
|
+
durationMs,
|
|
164
|
+
sessionId: null,
|
|
165
|
+
normalizedOutput: null,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── 6. Let driver extract metadata ─────────────────────────────────────
|
|
170
|
+
const meta = driver?.parseResult?.(stdout, stderr) ?? {};
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
exitCode,
|
|
174
|
+
stdout,
|
|
175
|
+
stderr,
|
|
176
|
+
outputPath: null,
|
|
177
|
+
stderrPath: null,
|
|
178
|
+
durationMs,
|
|
179
|
+
sessionId: meta.sessionId ?? null,
|
|
180
|
+
normalizedOutput: meta.normalizedOutput ?? null,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function runCommand(
|
|
185
|
+
command: string,
|
|
186
|
+
cwd: string,
|
|
187
|
+
opts: RunOptions = {},
|
|
188
|
+
): Promise<TaskResult> {
|
|
189
|
+
const spec: SpawnSpec = {
|
|
190
|
+
args: shellArgs(command),
|
|
191
|
+
cwd,
|
|
192
|
+
};
|
|
193
|
+
return runSpawn(spec, null, opts);
|
|
194
194
|
}
|