@pugi/cli 0.1.0-alpha.3 → 0.1.0-alpha.5

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/README.md +20 -0
  2. package/dist/commands/jobs.js +245 -0
  3. package/dist/core/agents/registry.js +69 -0
  4. package/dist/core/bash-classifier.js +1001 -0
  5. package/dist/core/context/builder.js +114 -0
  6. package/dist/core/context/compaction-events.js +99 -0
  7. package/dist/core/context/compaction.js +602 -0
  8. package/dist/core/context/invariants.js +250 -0
  9. package/dist/core/context/markdown-loader.js +270 -0
  10. package/dist/core/engine/compaction-hook.js +154 -0
  11. package/dist/core/engine/index.js +5 -0
  12. package/dist/core/engine/prompts.js +42 -0
  13. package/dist/core/engine/tool-bridge.js +159 -61
  14. package/dist/core/hooks.js +415 -0
  15. package/dist/core/jobs/registry.js +462 -0
  16. package/dist/core/mcp/client.js +316 -0
  17. package/dist/core/mcp/registry.js +171 -0
  18. package/dist/core/mcp/trust.js +91 -0
  19. package/dist/core/permission.js +221 -116
  20. package/dist/core/repl/cap-warning.js +91 -0
  21. package/dist/core/repl/session.js +399 -0
  22. package/dist/core/repl/slash-commands.js +116 -0
  23. package/dist/core/session.js +168 -0
  24. package/dist/core/subagents/dispatcher.js +258 -0
  25. package/dist/core/subagents/index.js +26 -0
  26. package/dist/core/subagents/spawn.js +86 -0
  27. package/dist/core/trust.js +109 -0
  28. package/dist/runtime/cli.js +157 -45
  29. package/dist/runtime/commands/budget.js +192 -0
  30. package/dist/runtime/commands/config.js +231 -0
  31. package/dist/runtime/commands/privacy.js +107 -0
  32. package/dist/runtime/commands/undo.js +329 -0
  33. package/dist/tools/bash.js +660 -0
  34. package/dist/tui/agent-tree.js +66 -0
  35. package/dist/tui/conversation-pane.js +45 -0
  36. package/dist/tui/input-box.js +91 -0
  37. package/dist/tui/login-picker.js +69 -0
  38. package/dist/tui/render.js +68 -0
  39. package/dist/tui/repl-render.js +218 -0
  40. package/dist/tui/repl.js +152 -0
  41. package/dist/tui/splash-data.js +61 -0
  42. package/dist/tui/splash.js +31 -0
  43. package/dist/tui/status-bar.js +58 -0
  44. 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