@pugi/cli 0.1.0-alpha.3 → 0.1.0-alpha.6
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 +20 -0
- package/dist/commands/jobs.js +245 -0
- package/dist/core/agents/registry.js +69 -0
- package/dist/core/bash-classifier.js +1001 -0
- package/dist/core/context/builder.js +114 -0
- package/dist/core/context/compaction-events.js +99 -0
- package/dist/core/context/compaction.js +602 -0
- package/dist/core/context/invariants.js +250 -0
- package/dist/core/context/markdown-loader.js +270 -0
- package/dist/core/engine/compaction-hook.js +154 -0
- package/dist/core/engine/index.js +5 -0
- package/dist/core/engine/prompts.js +42 -0
- package/dist/core/engine/tool-bridge.js +159 -61
- package/dist/core/hooks.js +415 -0
- package/dist/core/jobs/registry.js +462 -0
- package/dist/core/mcp/client.js +316 -0
- package/dist/core/mcp/registry.js +171 -0
- package/dist/core/mcp/trust.js +91 -0
- package/dist/core/permission.js +221 -116
- package/dist/core/repl/cap-warning.js +91 -0
- package/dist/core/repl/session.js +399 -0
- package/dist/core/repl/slash-commands.js +116 -0
- package/dist/core/session.js +168 -0
- package/dist/core/subagents/dispatcher.js +258 -0
- package/dist/core/subagents/index.js +26 -0
- package/dist/core/subagents/spawn.js +86 -0
- package/dist/core/trust.js +109 -0
- package/dist/runtime/cli.js +158 -46
- package/dist/runtime/commands/budget.js +192 -0
- package/dist/runtime/commands/config.js +231 -0
- package/dist/runtime/commands/privacy.js +107 -0
- package/dist/runtime/commands/undo.js +329 -0
- package/dist/tools/bash.js +660 -0
- package/dist/tui/agent-tree.js +66 -0
- package/dist/tui/conversation-pane.js +45 -0
- package/dist/tui/input-box.js +91 -0
- package/dist/tui/login-picker.js +69 -0
- package/dist/tui/render.js +68 -0
- package/dist/tui/repl-render.js +218 -0
- package/dist/tui/repl.js +152 -0
- package/dist/tui/splash-data.js +61 -0
- package/dist/tui/splash.js +31 -0
- package/dist/tui/status-bar.js +58 -0
- package/package.json +11 -5
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { recordHookInvoked, recordHookResult, recordHookSkipped, } from './session.js';
|
|
8
|
+
import { isTrustedWorkspace } from './trust.js';
|
|
9
|
+
export const ALL_HOOK_EVENTS = [
|
|
10
|
+
'SessionStart',
|
|
11
|
+
'UserPromptSubmit',
|
|
12
|
+
'PreToolUse',
|
|
13
|
+
'PermissionRequest',
|
|
14
|
+
'PostToolUse',
|
|
15
|
+
'PostToolUseFailure',
|
|
16
|
+
'Stop',
|
|
17
|
+
'SessionEnd',
|
|
18
|
+
];
|
|
19
|
+
const hookEventSchema = z.enum([
|
|
20
|
+
'SessionStart',
|
|
21
|
+
'UserPromptSubmit',
|
|
22
|
+
'PreToolUse',
|
|
23
|
+
'PermissionRequest',
|
|
24
|
+
'PostToolUse',
|
|
25
|
+
'PostToolUseFailure',
|
|
26
|
+
'Stop',
|
|
27
|
+
'SessionEnd',
|
|
28
|
+
]);
|
|
29
|
+
const hookMatchSchema = z
|
|
30
|
+
.object({
|
|
31
|
+
tool: z.string().min(1).optional(),
|
|
32
|
+
permission: z.enum(['read', 'edit', 'bash', 'network', 'mcp', 'subagent']).optional(),
|
|
33
|
+
pathGlob: z.string().min(1).optional(),
|
|
34
|
+
})
|
|
35
|
+
.strict();
|
|
36
|
+
const hookDefinitionSchema = z
|
|
37
|
+
.object({
|
|
38
|
+
event: hookEventSchema,
|
|
39
|
+
match: hookMatchSchema.optional(),
|
|
40
|
+
run: z.string().min(1),
|
|
41
|
+
timeoutMs: z.number().int().positive().max(60_000).optional(),
|
|
42
|
+
onFailure: z.enum(['warn', 'block']).optional(),
|
|
43
|
+
})
|
|
44
|
+
.strict();
|
|
45
|
+
const hooksFileSchema = z
|
|
46
|
+
.object({
|
|
47
|
+
hooks: z.array(hookDefinitionSchema).default([]),
|
|
48
|
+
})
|
|
49
|
+
.strict();
|
|
50
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
51
|
+
const SIGKILL_GRACE_MS = 2_000;
|
|
52
|
+
// Cap each captured stream at 1 MiB so a misbehaving hook (`yes`, `cat
|
|
53
|
+
// /dev/urandom | base64`) cannot OOM the parent CLI by buffering
|
|
54
|
+
// unbounded output between data events and the SIGTERM watchdog. 1 MiB
|
|
55
|
+
// is generous headroom over realistic logger output while bounded.
|
|
56
|
+
const HOOK_STREAM_CAP_BYTES = 1024 * 1024;
|
|
57
|
+
export class HookRegistry {
|
|
58
|
+
options;
|
|
59
|
+
sources = [];
|
|
60
|
+
loaded = false;
|
|
61
|
+
/**
|
|
62
|
+
* Per-batch dedup memory. The caller is expected to call `resetBatch()`
|
|
63
|
+
* between independent event batches so hooks fire once per batch even
|
|
64
|
+
* if multiple identical events are emitted in tight succession from
|
|
65
|
+
* the same tool dispatch.
|
|
66
|
+
*/
|
|
67
|
+
batchSeen = new Set();
|
|
68
|
+
trustedProjects = new Map();
|
|
69
|
+
constructor(options) {
|
|
70
|
+
this.options = options;
|
|
71
|
+
}
|
|
72
|
+
async load() {
|
|
73
|
+
this.sources.length = 0;
|
|
74
|
+
this.loaded = true;
|
|
75
|
+
const userPath = resolve(this.userHomeRoot(), 'hooks.json');
|
|
76
|
+
if (existsSync(userPath)) {
|
|
77
|
+
const userHooks = parseHooksFile(userPath);
|
|
78
|
+
this.sources.push({ origin: 'user', path: userPath, hooks: userHooks });
|
|
79
|
+
}
|
|
80
|
+
const projectPath = resolve(this.options.workspaceRoot, '.pugi/hooks.json');
|
|
81
|
+
if (existsSync(projectPath)) {
|
|
82
|
+
const trusted = await isTrustedWorkspace(this.options.workspaceRoot);
|
|
83
|
+
this.trustedProjects.set(this.options.workspaceRoot, trusted);
|
|
84
|
+
if (trusted) {
|
|
85
|
+
const projectHooks = parseHooksFile(projectPath);
|
|
86
|
+
this.sources.push({ origin: 'project', path: projectPath, hooks: projectHooks });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
list(event) {
|
|
91
|
+
this.assertLoaded();
|
|
92
|
+
const all = this.sources.flatMap((source) => source.hooks);
|
|
93
|
+
return event ? all.filter((hook) => hook.event === event) : all;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Hooks that match the given event and context. The caller can use this
|
|
97
|
+
* to correlate hook definitions with `fire()` results positionally for
|
|
98
|
+
* downstream policy checks (e.g. PreToolUse blocking).
|
|
99
|
+
*/
|
|
100
|
+
listMatching(ctx) {
|
|
101
|
+
return this.list(ctx.event).filter((hook) => matchesContext(hook, ctx));
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Reset the per-batch dedup memory. Call between independent event
|
|
105
|
+
* batches (typically once per tool dispatch) so identical hook entries
|
|
106
|
+
* fire once per batch, not just once per session.
|
|
107
|
+
*/
|
|
108
|
+
resetBatch() {
|
|
109
|
+
this.batchSeen.clear();
|
|
110
|
+
}
|
|
111
|
+
async fire(ctx) {
|
|
112
|
+
this.assertLoaded();
|
|
113
|
+
const candidates = this.listMatching(ctx);
|
|
114
|
+
// If the project hook file exists but the workspace is untrusted,
|
|
115
|
+
// emit a `hook.skipped: untrusted-project` once per `fire` so the
|
|
116
|
+
// audit log explains the gap. We only emit this if the user actually
|
|
117
|
+
// has a project hooks file on disk — otherwise there is nothing to
|
|
118
|
+
// skip.
|
|
119
|
+
const projectPath = resolve(this.options.workspaceRoot, '.pugi/hooks.json');
|
|
120
|
+
const projectHooksOnDisk = existsSync(projectPath);
|
|
121
|
+
const projectTrusted = this.trustedProjects.get(this.options.workspaceRoot) ?? false;
|
|
122
|
+
if (projectHooksOnDisk && !projectTrusted) {
|
|
123
|
+
this.recordSkipped(ctx, 'untrusted-project');
|
|
124
|
+
}
|
|
125
|
+
if (candidates.length === 0) {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
const results = [];
|
|
129
|
+
for (const hook of candidates) {
|
|
130
|
+
const key = dedupKey(ctx.event, hook);
|
|
131
|
+
if (this.batchSeen.has(key)) {
|
|
132
|
+
this.recordSkipped(ctx, 'dedup');
|
|
133
|
+
results.push({
|
|
134
|
+
ok: true,
|
|
135
|
+
stdout: '',
|
|
136
|
+
stderr: '',
|
|
137
|
+
exitCode: 0,
|
|
138
|
+
elapsedMs: 0,
|
|
139
|
+
skipped: 'dedup',
|
|
140
|
+
});
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
this.batchSeen.add(key);
|
|
144
|
+
this.recordInvoked(ctx, hook);
|
|
145
|
+
const result = await executeHook(hook, ctx);
|
|
146
|
+
this.recordResult(ctx, result);
|
|
147
|
+
results.push(result);
|
|
148
|
+
}
|
|
149
|
+
return results;
|
|
150
|
+
}
|
|
151
|
+
assertLoaded() {
|
|
152
|
+
if (!this.loaded) {
|
|
153
|
+
throw new Error('HookRegistry.load() must be called before use');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
userHomeRoot() {
|
|
157
|
+
const home = this.options.home ?? process.env.PUGI_HOME ?? resolve(homedir(), '.pugi');
|
|
158
|
+
return home;
|
|
159
|
+
}
|
|
160
|
+
recordInvoked(ctx, hook) {
|
|
161
|
+
if (!this.options.session)
|
|
162
|
+
return;
|
|
163
|
+
recordHookInvoked(this.options.session, {
|
|
164
|
+
event: ctx.event,
|
|
165
|
+
matchSummary: summariseMatch(hook.match),
|
|
166
|
+
runSummary: hook.run.slice(0, 200),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
recordResult(ctx, result) {
|
|
170
|
+
if (!this.options.session)
|
|
171
|
+
return;
|
|
172
|
+
recordHookResult(this.options.session, {
|
|
173
|
+
event: ctx.event,
|
|
174
|
+
ok: result.ok,
|
|
175
|
+
exitCode: result.exitCode,
|
|
176
|
+
elapsedMs: result.elapsedMs,
|
|
177
|
+
stdoutLen: result.stdout.length,
|
|
178
|
+
stderrLen: result.stderr.length,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
recordSkipped(ctx, reason) {
|
|
182
|
+
if (!this.options.session)
|
|
183
|
+
return;
|
|
184
|
+
recordHookSkipped(this.options.session, { event: ctx.event, reason });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Convenience helper for firing a SessionStart event from the CLI
|
|
189
|
+
* bootstrap path. Kept separate so the runtime entry point (cli.ts)
|
|
190
|
+
* does not need to construct a HookRegistry itself — that wiring lives
|
|
191
|
+
* in a follow-up PR by the TUI agent.
|
|
192
|
+
*/
|
|
193
|
+
export async function fireSessionStart(registry, ctx) {
|
|
194
|
+
return registry.fire({ ...ctx, event: 'SessionStart' });
|
|
195
|
+
}
|
|
196
|
+
function parseHooksFile(path) {
|
|
197
|
+
let raw;
|
|
198
|
+
try {
|
|
199
|
+
raw = readFileSync(path, 'utf8');
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
throw new Error(`hooks: cannot read ${path}: ${error.message}`);
|
|
203
|
+
}
|
|
204
|
+
let parsed;
|
|
205
|
+
try {
|
|
206
|
+
parsed = JSON.parse(raw);
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
throw new Error(`hooks: ${path} is not valid JSON: ${error.message}`);
|
|
210
|
+
}
|
|
211
|
+
const result = hooksFileSchema.safeParse(parsed);
|
|
212
|
+
if (!result.success) {
|
|
213
|
+
const issues = result.error.issues
|
|
214
|
+
.map((issue) => `${issue.path.join('.')} ${issue.message}`)
|
|
215
|
+
.join('; ');
|
|
216
|
+
throw new Error(`hooks: ${path} failed schema validation: ${issues}`);
|
|
217
|
+
}
|
|
218
|
+
return result.data.hooks;
|
|
219
|
+
}
|
|
220
|
+
function matchesContext(hook, ctx) {
|
|
221
|
+
if (!hook.match)
|
|
222
|
+
return true;
|
|
223
|
+
const { tool, permission, pathGlob } = hook.match;
|
|
224
|
+
if (tool !== undefined) {
|
|
225
|
+
if (!ctx.tool)
|
|
226
|
+
return false;
|
|
227
|
+
if (!globMatch(tool, ctx.tool))
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
if (permission !== undefined) {
|
|
231
|
+
if (ctx.permission !== permission)
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
if (pathGlob !== undefined) {
|
|
235
|
+
if (!ctx.path)
|
|
236
|
+
return false;
|
|
237
|
+
if (!globMatch(pathGlob, ctx.path))
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Tiny glob matcher: supports `*` (any chars except `/`) and `**` (any
|
|
244
|
+
* chars including `/`). Anchored at both ends. Plain strings without any
|
|
245
|
+
* wildcards must match exactly. We deliberately avoid pulling in a full
|
|
246
|
+
* glob lib here because the match grammar is intentionally narrow for M1.
|
|
247
|
+
*/
|
|
248
|
+
function globMatch(pattern, value) {
|
|
249
|
+
// Escape regex special chars except * and /, then translate ** and *
|
|
250
|
+
// into their regex equivalents. Order matters: handle ** before *.
|
|
251
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
|
252
|
+
const translated = escaped.replace(/\*\*/g, '<<DOUBLESTAR>>').replace(/\*/g, '[^/]*').replace(/<<DOUBLESTAR>>/g, '.*');
|
|
253
|
+
const regex = new RegExp(`^${translated}$`);
|
|
254
|
+
return regex.test(value);
|
|
255
|
+
}
|
|
256
|
+
function stableJsonHash(value) {
|
|
257
|
+
const stringified = stableStringify(value);
|
|
258
|
+
return createHash('sha256').update(stringified).digest('hex').slice(0, 16);
|
|
259
|
+
}
|
|
260
|
+
function stableStringify(value) {
|
|
261
|
+
if (value === null || typeof value !== 'object') {
|
|
262
|
+
return JSON.stringify(value);
|
|
263
|
+
}
|
|
264
|
+
if (Array.isArray(value)) {
|
|
265
|
+
return `[${value.map((v) => stableStringify(v)).join(',')}]`;
|
|
266
|
+
}
|
|
267
|
+
const obj = value;
|
|
268
|
+
const keys = Object.keys(obj).sort();
|
|
269
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(obj[key])}`).join(',')}}`;
|
|
270
|
+
}
|
|
271
|
+
function dedupKey(event, hook) {
|
|
272
|
+
return `${event}:${stableJsonHash(hook.match ?? null)}:${stableJsonHash(hook.run)}`;
|
|
273
|
+
}
|
|
274
|
+
function summariseMatch(match) {
|
|
275
|
+
if (!match)
|
|
276
|
+
return '*';
|
|
277
|
+
const parts = [];
|
|
278
|
+
if (match.tool)
|
|
279
|
+
parts.push(`tool=${match.tool}`);
|
|
280
|
+
if (match.permission)
|
|
281
|
+
parts.push(`permission=${match.permission}`);
|
|
282
|
+
if (match.pathGlob)
|
|
283
|
+
parts.push(`path=${match.pathGlob}`);
|
|
284
|
+
return parts.length ? parts.join(',') : '*';
|
|
285
|
+
}
|
|
286
|
+
async function executeHook(hook, ctx) {
|
|
287
|
+
const timeoutMs = hook.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
288
|
+
const startedAt = Date.now();
|
|
289
|
+
return new Promise((resolvePromise) => {
|
|
290
|
+
const payloadJson = JSON.stringify(ctx.payload ?? null);
|
|
291
|
+
const child = spawn('/bin/sh', ['-c', hook.run], {
|
|
292
|
+
env: {
|
|
293
|
+
...process.env,
|
|
294
|
+
PUGI_HOOK_PAYLOAD: payloadJson,
|
|
295
|
+
PUGI_HOOK_EVENT: ctx.event,
|
|
296
|
+
PUGI_HOOK_SESSION_ID: ctx.sessionId,
|
|
297
|
+
},
|
|
298
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
299
|
+
});
|
|
300
|
+
let stdout = '';
|
|
301
|
+
let stderr = '';
|
|
302
|
+
let killedForTimeout = false;
|
|
303
|
+
let killedForStreamCap = false;
|
|
304
|
+
let sigKillTimer;
|
|
305
|
+
const enforceStreamCap = () => {
|
|
306
|
+
if (killedForStreamCap)
|
|
307
|
+
return;
|
|
308
|
+
if (stdout.length + stderr.length <= HOOK_STREAM_CAP_BYTES)
|
|
309
|
+
return;
|
|
310
|
+
killedForStreamCap = true;
|
|
311
|
+
child.kill('SIGTERM');
|
|
312
|
+
// Reuse the same SIGKILL escalation as the timeout path.
|
|
313
|
+
if (!sigKillTimer) {
|
|
314
|
+
sigKillTimer = setTimeout(() => {
|
|
315
|
+
if (!child.killed)
|
|
316
|
+
child.kill('SIGKILL');
|
|
317
|
+
}, SIGKILL_GRACE_MS);
|
|
318
|
+
if (sigKillTimer.unref)
|
|
319
|
+
sigKillTimer.unref();
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
child.stdout?.on('data', (chunk) => {
|
|
323
|
+
if (killedForStreamCap)
|
|
324
|
+
return;
|
|
325
|
+
stdout += chunk.toString('utf8');
|
|
326
|
+
enforceStreamCap();
|
|
327
|
+
});
|
|
328
|
+
child.stderr?.on('data', (chunk) => {
|
|
329
|
+
if (killedForStreamCap)
|
|
330
|
+
return;
|
|
331
|
+
stderr += chunk.toString('utf8');
|
|
332
|
+
enforceStreamCap();
|
|
333
|
+
});
|
|
334
|
+
// Write the payload to stdin so hooks that prefer reading from stdin
|
|
335
|
+
// (e.g. `jq .`) work without depending on the env var. Hooks that
|
|
336
|
+
// do not consume stdin (e.g. `echo done`) close their end of the
|
|
337
|
+
// pipe immediately; writing then raises EPIPE which we swallow —
|
|
338
|
+
// payload-via-stdin is best-effort, not required.
|
|
339
|
+
if (child.stdin) {
|
|
340
|
+
child.stdin.on('error', () => {
|
|
341
|
+
// EPIPE / ECONNRESET when the child closed stdin before reading.
|
|
342
|
+
// Safe to ignore: PUGI_HOOK_PAYLOAD env var still carries the data.
|
|
343
|
+
});
|
|
344
|
+
child.stdin.end(payloadJson);
|
|
345
|
+
}
|
|
346
|
+
const timer = setTimeout(() => {
|
|
347
|
+
killedForTimeout = true;
|
|
348
|
+
child.kill('SIGTERM');
|
|
349
|
+
// Escalate to SIGKILL if the process refuses to exit.
|
|
350
|
+
sigKillTimer = setTimeout(() => {
|
|
351
|
+
if (!child.killed) {
|
|
352
|
+
child.kill('SIGKILL');
|
|
353
|
+
}
|
|
354
|
+
}, SIGKILL_GRACE_MS);
|
|
355
|
+
if (sigKillTimer.unref)
|
|
356
|
+
sigKillTimer.unref();
|
|
357
|
+
}, timeoutMs);
|
|
358
|
+
if (timer.unref)
|
|
359
|
+
timer.unref();
|
|
360
|
+
child.on('error', (error) => {
|
|
361
|
+
clearTimeout(timer);
|
|
362
|
+
if (sigKillTimer)
|
|
363
|
+
clearTimeout(sigKillTimer);
|
|
364
|
+
resolvePromise({
|
|
365
|
+
ok: false,
|
|
366
|
+
stdout,
|
|
367
|
+
stderr: stderr || `hook spawn error: ${error.message}`,
|
|
368
|
+
exitCode: -1,
|
|
369
|
+
elapsedMs: Date.now() - startedAt,
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
child.on('close', (code, signal) => {
|
|
373
|
+
clearTimeout(timer);
|
|
374
|
+
if (sigKillTimer)
|
|
375
|
+
clearTimeout(sigKillTimer);
|
|
376
|
+
const elapsedMs = Date.now() - startedAt;
|
|
377
|
+
// When killed by signal, child_process reports code=null, signal=<name>.
|
|
378
|
+
// Translate to a negative numeric exit code so callers can read it
|
|
379
|
+
// uniformly (-15 for SIGTERM, -9 for SIGKILL). This matches the
|
|
380
|
+
// convention several Node tools (cross-spawn, execa) use.
|
|
381
|
+
let exitCode;
|
|
382
|
+
if (code !== null) {
|
|
383
|
+
exitCode = code;
|
|
384
|
+
}
|
|
385
|
+
else if (signal === 'SIGTERM') {
|
|
386
|
+
exitCode = -15;
|
|
387
|
+
}
|
|
388
|
+
else if (signal === 'SIGKILL') {
|
|
389
|
+
exitCode = -9;
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
exitCode = -1;
|
|
393
|
+
}
|
|
394
|
+
// `ok` is true iff the hook exited cleanly (exit 0, not killed).
|
|
395
|
+
// Both `onFailure: 'warn'` and `onFailure: 'block'` produce ok=false
|
|
396
|
+
// on a non-zero exit — the difference is how the CALLER reacts.
|
|
397
|
+
// The caller reads `hook.onFailure` (via list()) to decide whether
|
|
398
|
+
// a non-ok result should warn or block the originating action.
|
|
399
|
+
// A stream-cap kill is also a failure: the hook produced too much
|
|
400
|
+
// output and we cannot trust whatever partial result we captured.
|
|
401
|
+
const ok = exitCode === 0 && !killedForTimeout && !killedForStreamCap;
|
|
402
|
+
const stderrFinal = killedForStreamCap
|
|
403
|
+
? `${stderr}\nhook output exceeded ${HOOK_STREAM_CAP_BYTES} bytes; killed by stream cap.`
|
|
404
|
+
: stderr;
|
|
405
|
+
resolvePromise({
|
|
406
|
+
ok,
|
|
407
|
+
stdout,
|
|
408
|
+
stderr: stderrFinal,
|
|
409
|
+
exitCode,
|
|
410
|
+
elapsedMs,
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
//# sourceMappingURL=hooks.js.map
|