@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,660 @@
1
+ /**
2
+ * Class-aware bash tool — Sprint α5.2 (ADR-0056 PR-PUGI-CLI-M1-GAP-B).
3
+ *
4
+ * The agent loop invokes this tool through the registry name `bash`.
5
+ * It supersedes `file-tools.ts::bashTool`, which used the legacy
6
+ * blocklist gate. The tool-bridge wires this new entry point so the
7
+ * registry entry (`registry.ts` `bash`) is not duplicated.
8
+ *
9
+ * Behavioural changes vs the legacy tool:
10
+ * 1. Permission decision routes through `evaluateBashPermission`
11
+ * (7-class taxonomy, mode-aware, destructive override gate).
12
+ * 2. Output cap is 32 KB combined stdout+stderr per call (down
13
+ * from 64 KB). Overflow is persisted to
14
+ * `.pugi/artifacts/<sessionId>/bash-<callId>.out` with the path
15
+ * returned as `artifactRef`.
16
+ * 3. Cwd carry-over: the tool receives `cwd` from the previous
17
+ * turn's session state and writes the new cwd back when the
18
+ * command was a `cd <path>` that landed inside
19
+ * `workspaceRoot ∪ additionalDirectories`. Escapes reset the
20
+ * cwd to workspaceRoot and emit `bash.cwd_escape`.
21
+ * 4. Background jobs: when `background: true`, spawn detached,
22
+ * track in `~/.pugi/jobs.json`, return immediately with
23
+ * `jobId`. `listJobs()` and `killJob(jobId)` are exported.
24
+ * 5. 60s default timeout. SIGTERM at deadline, SIGKILL 5s later.
25
+ * Emit `bash.timeout`.
26
+ * 6. POSIX-only (`/bin/sh`). The non-goal in ADR-0056 explicitly
27
+ * drops Windows shell support for M1.
28
+ */
29
+ import { randomUUID } from 'node:crypto';
30
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
31
+ import { homedir } from 'node:os';
32
+ import { isAbsolute, join, resolve } from 'node:path';
33
+ import { spawn, spawnSync } from 'node:child_process';
34
+ import { classifyBash } from '../core/bash-classifier.js';
35
+ import { evaluateBashPermission } from '../core/permission.js';
36
+ import { getJobRegistry, } from '../core/jobs/registry.js';
37
+ import { recordToolCall, recordToolResult } from '../core/session.js';
38
+ export const BASH_OUTPUT_CAP_BYTES = 32 * 1024;
39
+ export const BASH_DEFAULT_TIMEOUT_MS = 60_000;
40
+ export const BASH_SIGKILL_GRACE_MS = 5_000;
41
+ /**
42
+ * Mid-stream cap. The 32 KB BASH_OUTPUT_CAP_BYTES is the report cap;
43
+ * this is the in-memory ceiling beyond which we stop buffering and
44
+ * SIGTERM the child to prevent a `yes`-style stream from pinning
45
+ * 60+ MB before the timeout watchdog fires.
46
+ *
47
+ * Code Reviewer P1 retro 2026-05-24: the async path previously
48
+ * accumulated stdout chunks without bound; only spawnSync had a
49
+ * 10 MB maxBuffer ceiling. Aligning the async path closes the gap.
50
+ */
51
+ export const BASH_LIVE_OUTPUT_CAP_BYTES = 1024 * 1024;
52
+ /**
53
+ * Bash tool entry point. Returns the standard shape the engine loop
54
+ * consumes; throws only on argument-shape errors (e.g. negative
55
+ * timeouts) and otherwise surfaces the failure through
56
+ * `{ exitCode: 126, stderr }`.
57
+ */
58
+ export async function bashTool(input, ctx) {
59
+ const cmd = input.cmd ?? '';
60
+ const additionalDirectories = ctx.additionalDirectories ?? [];
61
+ const source = ctx.source ?? 'agent';
62
+ const toolCallId = recordToolCall(ctx.session, 'bash', cmd);
63
+ // Permission gate via the new class-aware engine.
64
+ const decision = evaluateBashPermission(cmd, ctx.settings.permissions.mode, {
65
+ workspaceRoot: ctx.root,
66
+ additionalDirectories,
67
+ source,
68
+ });
69
+ if (decision.decision !== 'allow') {
70
+ const reason = `Permission ${decision.decision}: ${decision.reason}`;
71
+ recordToolResult(ctx.session, toolCallId, 'error', reason);
72
+ return {
73
+ stdout: '',
74
+ stderr: `Permission denied: ${decision.reason}`,
75
+ exitCode: 126,
76
+ nextCwd: ctx.lastBashCwd ?? ctx.root,
77
+ truncated: false,
78
+ timedOut: false,
79
+ };
80
+ }
81
+ // Cwd carry-over decision (also re-checked post-run).
82
+ const startCwd = resolveStartCwd(input.cwd ?? ctx.lastBashCwd, ctx.root, additionalDirectories);
83
+ // Background job branch.
84
+ if (input.background === true) {
85
+ return runBackground({ cmd, ctx, toolCallId, startCwd, additionalDirectories });
86
+ }
87
+ // Foreground branch with timeout watchdog.
88
+ const timeoutMs = sanitizeTimeout(input.timeoutMs);
89
+ const childEnv = buildChildEnv();
90
+ // POSIX-only `/bin/sh -c <cmd>`. The ADR-0056 non-goals explicitly
91
+ // exclude Windows for M1.
92
+ const child = spawn('/bin/sh', ['-c', cmd], {
93
+ cwd: startCwd,
94
+ env: childEnv,
95
+ stdio: ['ignore', 'pipe', 'pipe'],
96
+ detached: false,
97
+ });
98
+ const stdoutChunks = [];
99
+ const stderrChunks = [];
100
+ let stdoutBytes = 0;
101
+ let stderrBytes = 0;
102
+ // We keep collecting beyond the report cap (BASH_OUTPUT_CAP_BYTES)
103
+ // for the artifact-overflow file but flag `truncated` so the
104
+ // agent-facing payload is the head. To prevent a runaway producer
105
+ // (`yes`, `cat /dev/urandom`) from pinning hundreds of megabytes
106
+ // before the timeout watchdog fires, we enforce a live ceiling
107
+ // (BASH_LIVE_OUTPUT_CAP_BYTES) and SIGTERM the child when crossed.
108
+ let truncatedMidStream = false;
109
+ const enforceLiveCap = () => {
110
+ if (truncatedMidStream)
111
+ return;
112
+ if (stdoutBytes + stderrBytes <= BASH_LIVE_OUTPUT_CAP_BYTES)
113
+ return;
114
+ truncatedMidStream = true;
115
+ try {
116
+ child.kill('SIGTERM');
117
+ }
118
+ catch {
119
+ // child already exited; the close handler will run
120
+ }
121
+ };
122
+ child.stdout?.on('data', (chunk) => {
123
+ if (truncatedMidStream)
124
+ return;
125
+ stdoutChunks.push(chunk);
126
+ stdoutBytes += chunk.length;
127
+ enforceLiveCap();
128
+ });
129
+ child.stderr?.on('data', (chunk) => {
130
+ if (truncatedMidStream)
131
+ return;
132
+ stderrChunks.push(chunk);
133
+ stderrBytes += chunk.length;
134
+ enforceLiveCap();
135
+ });
136
+ const timeoutOutcome = await waitWithTimeout(child, timeoutMs);
137
+ const stdoutFull = Buffer.concat(stdoutChunks).toString('utf8');
138
+ const stderrFull = Buffer.concat(stderrChunks).toString('utf8');
139
+ const combinedBytes = stdoutBytes + stderrBytes;
140
+ const truncated = combinedBytes > BASH_OUTPUT_CAP_BYTES || truncatedMidStream;
141
+ // Cwd carry-over: detect `cd <path> && <rest>` shapes from the
142
+ // command itself (we cannot observe the child's final cwd without
143
+ // a wrapper script). The classifier already flagged escapes; we
144
+ // re-validate here against allowed roots.
145
+ const nextCwd = computeNextCwd(cmd, startCwd, ctx.root, additionalDirectories, ctx.session);
146
+ // Overflow artifact when needed.
147
+ let artifactRef;
148
+ let stdoutOut = stdoutFull;
149
+ let stderrOut = stderrFull;
150
+ if (truncated) {
151
+ artifactRef = persistOverflow({
152
+ root: ctx.root,
153
+ sessionId: ctx.session.id,
154
+ toolCallId,
155
+ stdout: stdoutFull,
156
+ stderr: stderrFull,
157
+ });
158
+ stdoutOut = capToCombined(stdoutFull, stderrFull).stdout;
159
+ stderrOut = capToCombined(stdoutFull, stderrFull).stderr;
160
+ }
161
+ if (truncatedMidStream) {
162
+ // We killed the child because output cap exceeded mid-stream.
163
+ // Report that as the failure cause rather than as a timeout —
164
+ // the watchdog never fired, only our cap enforcer did.
165
+ const reason = `bash output cap exceeded mid-stream (cap=${BASH_LIVE_OUTPUT_CAP_BYTES} bytes)`;
166
+ emitEvent(ctx.session, 'bash.output_cap_exceeded', {
167
+ cmd,
168
+ capBytes: BASH_LIVE_OUTPUT_CAP_BYTES,
169
+ });
170
+ recordToolResult(ctx.session, toolCallId, 'error', reason);
171
+ return {
172
+ stdout: stdoutOut,
173
+ stderr: stderrOut === '' ? reason : `${stderrOut}\n${reason}`,
174
+ exitCode: 137,
175
+ artifactRef,
176
+ nextCwd,
177
+ truncated: true,
178
+ timedOut: false,
179
+ };
180
+ }
181
+ if (timeoutOutcome.timedOut) {
182
+ emitEvent(ctx.session, 'bash.timeout', { cmd, timeoutMs });
183
+ recordToolResult(ctx.session, toolCallId, 'error', `bash timed out after ${timeoutMs}ms`);
184
+ return {
185
+ stdout: stdoutOut,
186
+ stderr: stderrOut === '' ? `bash timed out after ${timeoutMs}ms` : `${stderrOut}\nbash timed out after ${timeoutMs}ms`,
187
+ exitCode: 124,
188
+ artifactRef,
189
+ nextCwd,
190
+ truncated,
191
+ timedOut: true,
192
+ };
193
+ }
194
+ const exitCode = timeoutOutcome.exitCode;
195
+ recordToolResult(ctx.session, toolCallId, 'success', `bash exit=${exitCode} bytes=${combinedBytes}${artifactRef ? ` overflow=${artifactRef}` : ''}`);
196
+ return {
197
+ stdout: stdoutOut,
198
+ stderr: stderrOut,
199
+ exitCode,
200
+ artifactRef,
201
+ nextCwd,
202
+ truncated,
203
+ timedOut: false,
204
+ };
205
+ }
206
+ function sanitizeTimeout(value) {
207
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
208
+ return BASH_DEFAULT_TIMEOUT_MS;
209
+ }
210
+ // Cap user-supplied timeouts at 15 minutes so a runaway tool call
211
+ // cannot wedge the engine loop.
212
+ return Math.min(value, 15 * 60 * 1000);
213
+ }
214
+ function buildChildEnv() {
215
+ const childEnv = {};
216
+ const SAFE_ENV_ALLOW = new Set([
217
+ 'PATH',
218
+ 'HOME',
219
+ 'USER',
220
+ 'LOGNAME',
221
+ 'SHELL',
222
+ 'LANG',
223
+ 'TZ',
224
+ 'TERM',
225
+ 'PWD',
226
+ ]);
227
+ for (const [key, value] of Object.entries(process.env)) {
228
+ if (value === undefined)
229
+ continue;
230
+ if (SAFE_ENV_ALLOW.has(key) || key.startsWith('LC_')) {
231
+ childEnv[key] = value;
232
+ }
233
+ }
234
+ return childEnv;
235
+ }
236
+ function resolveStartCwd(requested, root, additionalDirectories) {
237
+ if (!requested)
238
+ return root;
239
+ const absolute = isAbsolute(requested) ? requested : resolve(root, requested);
240
+ const allowedRoots = [root, ...additionalDirectories];
241
+ for (const allowedRaw of allowedRoots) {
242
+ const allowed = allowedRaw.endsWith('/') ? allowedRaw.slice(0, -1) : allowedRaw;
243
+ if (absolute === allowed || absolute.startsWith(`${allowed}/`)) {
244
+ return absolute;
245
+ }
246
+ }
247
+ return root;
248
+ }
249
+ function computeNextCwd(cmd, startCwd, root, additionalDirectories, session) {
250
+ // We mirror the classifier's view: only `cd <path>` at the head of
251
+ // the command updates cwd for the next turn. We do not attempt to
252
+ // chase `cd` calls that fired inside subshells or compound chains
253
+ // (`(cd foo && ls)` leaves the parent cwd untouched).
254
+ const firstComponent = cmd.trim().split(/\s*(?:&&|\|\||;|\|)\s*/)[0]?.trim() ?? '';
255
+ const match = firstComponent.match(/^cd(?:\s+(\S+))?\s*$/);
256
+ if (!match)
257
+ return startCwd;
258
+ const target = match[1];
259
+ if (target === undefined || target === '-' || target === '~') {
260
+ emitEvent(session, 'bash.cwd_escape', { cmd, reason: 'cd to HOME or last dir' });
261
+ return root;
262
+ }
263
+ const resolved = isAbsolute(target) || target.startsWith('~')
264
+ ? resolve(target.startsWith('~') ? target.replace(/^~/, homedir()) : target)
265
+ : resolve(startCwd, target);
266
+ const allowedRoots = [root, ...additionalDirectories];
267
+ for (const allowedRaw of allowedRoots) {
268
+ const allowed = allowedRaw.endsWith('/') ? allowedRaw.slice(0, -1) : allowedRaw;
269
+ if (resolved === allowed || resolved.startsWith(`${allowed}/`)) {
270
+ return resolved;
271
+ }
272
+ }
273
+ emitEvent(session, 'bash.cwd_escape', { cmd, reason: 'cd target outside workspace' });
274
+ return root;
275
+ }
276
+ function emitEvent(session, name, body) {
277
+ if (!session.enabled)
278
+ return;
279
+ const line = JSON.stringify({
280
+ id: randomUUID(),
281
+ sessionId: session.id,
282
+ timestamp: new Date().toISOString(),
283
+ type: 'bash',
284
+ name,
285
+ ...body,
286
+ });
287
+ try {
288
+ appendFileSync(session.eventsPath, `${line}\n`, { encoding: 'utf8', mode: 0o600 });
289
+ }
290
+ catch {
291
+ // Event log is best-effort; never crash the tool because of it.
292
+ }
293
+ }
294
+ function persistOverflow(input) {
295
+ const dir = join(input.root, '.pugi', 'artifacts', input.sessionId);
296
+ try {
297
+ mkdirSync(dir, { recursive: true });
298
+ const path = join(dir, `bash-${input.toolCallId}.out`);
299
+ const body = `--- stdout ---\n${input.stdout}\n--- stderr ---\n${input.stderr}\n`;
300
+ writeFileSync(path, body, { encoding: 'utf8', mode: 0o600 });
301
+ return path;
302
+ }
303
+ catch {
304
+ return '';
305
+ }
306
+ }
307
+ function capToCombined(stdout, stderr) {
308
+ // Split the budget proportionally so the head of each stream is
309
+ // preserved. When one stream is empty the other gets the full
310
+ // budget.
311
+ if (stdout.length + stderr.length <= BASH_OUTPUT_CAP_BYTES) {
312
+ return { stdout, stderr };
313
+ }
314
+ if (stdout.length === 0) {
315
+ return { stdout: '', stderr: trimWithMarker(stderr, BASH_OUTPUT_CAP_BYTES) };
316
+ }
317
+ if (stderr.length === 0) {
318
+ return { stdout: trimWithMarker(stdout, BASH_OUTPUT_CAP_BYTES), stderr: '' };
319
+ }
320
+ const total = stdout.length + stderr.length;
321
+ const stdoutBudget = Math.max(1024, Math.floor((stdout.length / total) * BASH_OUTPUT_CAP_BYTES));
322
+ const stderrBudget = BASH_OUTPUT_CAP_BYTES - stdoutBudget;
323
+ return {
324
+ stdout: trimWithMarker(stdout, stdoutBudget),
325
+ stderr: trimWithMarker(stderr, stderrBudget),
326
+ };
327
+ }
328
+ function trimWithMarker(text, budget) {
329
+ if (text.length <= budget)
330
+ return text;
331
+ return `${text.slice(0, budget)}\n(...truncated at ${budget} bytes; full output in artifactRef)`;
332
+ }
333
+ async function waitWithTimeout(child, timeoutMs) {
334
+ return await new Promise((resolvePromise) => {
335
+ let settled = false;
336
+ const sigtermTimer = setTimeout(() => {
337
+ if (settled)
338
+ return;
339
+ try {
340
+ child.kill('SIGTERM');
341
+ }
342
+ catch {
343
+ // child already exited
344
+ }
345
+ const sigkillTimer = setTimeout(() => {
346
+ if (settled)
347
+ return;
348
+ try {
349
+ child.kill('SIGKILL');
350
+ }
351
+ catch {
352
+ // already gone
353
+ }
354
+ }, BASH_SIGKILL_GRACE_MS);
355
+ sigkillTimer.unref();
356
+ }, timeoutMs);
357
+ sigtermTimer.unref();
358
+ const onClose = (code, signal) => {
359
+ if (settled)
360
+ return;
361
+ settled = true;
362
+ clearTimeout(sigtermTimer);
363
+ if (signal === 'SIGTERM' || signal === 'SIGKILL') {
364
+ // Heuristic: if we sent the signal because of the timer,
365
+ // report timeout. If the child raced and exited with the
366
+ // signal anyway (e.g. user pressed ^C through SIGINT trap)
367
+ // we still report timeout when the wall-clock crossed.
368
+ resolvePromise({ timedOut: true, exitCode: 124 });
369
+ return;
370
+ }
371
+ resolvePromise({ timedOut: false, exitCode: code ?? 1 });
372
+ };
373
+ child.on('close', onClose);
374
+ child.on('error', () => {
375
+ if (settled)
376
+ return;
377
+ settled = true;
378
+ clearTimeout(sigtermTimer);
379
+ resolvePromise({ timedOut: false, exitCode: 1 });
380
+ });
381
+ });
382
+ }
383
+ function runBackground(input) {
384
+ const { cmd, ctx, toolCallId, startCwd } = input;
385
+ const childEnv = buildChildEnv();
386
+ const child = spawn('/bin/sh', ['-c', cmd], {
387
+ cwd: startCwd,
388
+ env: childEnv,
389
+ stdio: 'ignore',
390
+ detached: true,
391
+ });
392
+ child.unref();
393
+ const jobId = `pj-${randomUUID()}`;
394
+ const classification = classifyBash(cmd, {
395
+ workspaceRoot: ctx.root,
396
+ additionalDirectories: input.additionalDirectories,
397
+ });
398
+ const registry = getJobRegistry();
399
+ // Persist into the new registry. The promise is intentionally not
400
+ // awaited — `runBackground` is a synchronous control path inside the
401
+ // bash tool's async wrapper. The registry's atomic write is
402
+ // synchronous under the hood so the ledger is consistent before the
403
+ // caller observes the returned jobId, even when we drop the
404
+ // promise. Wire as a `.catch` so an unhandled-rejection never
405
+ // crashes the engine loop.
406
+ void registry
407
+ .add({
408
+ id: jobId,
409
+ pid: child.pid ?? -1,
410
+ command: cmd,
411
+ bashClass: classification.class,
412
+ cwd: startCwd,
413
+ sessionId: ctx.session.id,
414
+ })
415
+ .catch(() => {
416
+ // Best-effort persistence; the in-process tool still returned
417
+ // the jobId so the engine loop knows the spawn succeeded.
418
+ });
419
+ emitEvent(ctx.session, 'bash.background_started', {
420
+ jobId,
421
+ pid: child.pid ?? -1,
422
+ cmd,
423
+ });
424
+ recordToolResult(ctx.session, toolCallId, 'success', `bash background jobId=${jobId} pid=${child.pid ?? -1}`);
425
+ return {
426
+ stdout: `bash started in background as ${jobId}`,
427
+ stderr: '',
428
+ exitCode: 0,
429
+ jobId,
430
+ nextCwd: ctx.lastBashCwd ?? ctx.root,
431
+ truncated: false,
432
+ timedOut: false,
433
+ };
434
+ }
435
+ /**
436
+ * Legacy export preserved for α5.2 callers / tests. Delegates to the
437
+ * new JobRegistry and projects entries back into the historical
438
+ * `PugiJob` shape.
439
+ */
440
+ export function listJobs() {
441
+ const entries = readRegistryEntriesSync();
442
+ return entries.map(entryToLegacyJob);
443
+ }
444
+ /**
445
+ * Legacy export preserved for α5.2 callers / tests. Delegates to the
446
+ * new JobRegistry. Returns the same `{ killed, reason? }` shape so the
447
+ * existing bash-tool test suite continues to pass without an
448
+ * end-to-end rewrite.
449
+ */
450
+ export function killJob(jobId) {
451
+ const entries = readRegistryEntriesSync();
452
+ const target = entries.find((entry) => entry.id === jobId);
453
+ if (!target)
454
+ return { killed: false, reason: `unknown jobId: ${jobId}` };
455
+ // Mirror the legacy semantics: synchronous SIGTERM + best-effort
456
+ // SIGKILL escalation + remove the entry from the ledger so the
457
+ // bash-tool test suite's `listJobs().find(...) === undefined`
458
+ // assertion keeps holding. The richer `JobRegistry.kill` (status
459
+ // transitions, async exit polling) is what the new `pugi jobs kill`
460
+ // CLI command uses.
461
+ try {
462
+ process.kill(target.pid, 'SIGTERM');
463
+ }
464
+ catch (error) {
465
+ const code = error.code;
466
+ if (code === 'ESRCH') {
467
+ removeRegistryEntrySync(jobId);
468
+ return { killed: false, reason: 'job already exited' };
469
+ }
470
+ return { killed: false, reason: `kill failed: ${error.message}` };
471
+ }
472
+ setTimeout(() => {
473
+ try {
474
+ process.kill(target.pid, 0);
475
+ try {
476
+ process.kill(target.pid, 'SIGKILL');
477
+ }
478
+ catch {
479
+ // gone between the check and the signal
480
+ }
481
+ }
482
+ catch {
483
+ // already dead
484
+ }
485
+ }, BASH_SIGKILL_GRACE_MS).unref();
486
+ removeRegistryEntrySync(jobId);
487
+ return { killed: true };
488
+ }
489
+ function entryToLegacyJob(entry) {
490
+ return {
491
+ jobId: entry.id,
492
+ pid: entry.pid,
493
+ cwd: entry.cwd,
494
+ cmd: entry.command,
495
+ class: entry.bashClass,
496
+ startedAt: entry.startedAt,
497
+ sessionId: entry.sessionId,
498
+ };
499
+ }
500
+ /**
501
+ * Synchronous read of the registry file. Used by the legacy
502
+ * `listJobs()` / `killJob()` exports because they cannot return a
503
+ * promise without a breaking signature change. The new `JobRegistry`
504
+ * interface is the async path.
505
+ */
506
+ function readRegistryEntriesSync() {
507
+ const path = join(homedir(), '.pugi', 'jobs.json');
508
+ // Inline read so the legacy listJobs/killJob entry points do not
509
+ // require an async hop into JobRegistry. The shape parsing mirrors
510
+ // `normalizeEntry` inside `core/jobs/registry.ts`.
511
+ if (!existsSync(path))
512
+ return [];
513
+ try {
514
+ const raw = readFileSync(path, 'utf8');
515
+ if (raw.trim() === '')
516
+ return [];
517
+ const parsed = JSON.parse(raw);
518
+ if (!Array.isArray(parsed))
519
+ return [];
520
+ const out = [];
521
+ for (const candidate of parsed) {
522
+ if (typeof candidate !== 'object' || candidate === null)
523
+ continue;
524
+ const c = candidate;
525
+ const id = typeof c['id'] === 'string'
526
+ ? c['id']
527
+ : typeof c['jobId'] === 'string'
528
+ ? c['jobId']
529
+ : undefined;
530
+ const pid = typeof c['pid'] === 'number' ? c['pid'] : undefined;
531
+ const command = typeof c['command'] === 'string'
532
+ ? c['command']
533
+ : typeof c['cmd'] === 'string'
534
+ ? c['cmd']
535
+ : undefined;
536
+ if (!id || pid === undefined || command === undefined)
537
+ continue;
538
+ const bashClassRaw = typeof c['bashClass'] === 'string'
539
+ ? c['bashClass']
540
+ : typeof c['class'] === 'string'
541
+ ? c['class']
542
+ : 'unknown';
543
+ const status = c['status'] === 'finished' ||
544
+ c['status'] === 'killed' ||
545
+ c['status'] === 'failed' ||
546
+ c['status'] === 'abandoned'
547
+ ? c['status']
548
+ : 'running';
549
+ out.push({
550
+ id,
551
+ pid,
552
+ command,
553
+ bashClass: bashClassRaw,
554
+ cwd: typeof c['cwd'] === 'string' ? c['cwd'] : '',
555
+ startedAt: typeof c['startedAt'] === 'string'
556
+ ? c['startedAt']
557
+ : new Date().toISOString(),
558
+ status,
559
+ sessionId: typeof c['sessionId'] === 'string' ? c['sessionId'] : 'unknown',
560
+ });
561
+ }
562
+ return out;
563
+ }
564
+ catch {
565
+ return [];
566
+ }
567
+ }
568
+ function removeRegistryEntrySync(jobId) {
569
+ const path = join(homedir(), '.pugi', 'jobs.json');
570
+ const entries = readRegistryEntriesSync().filter((entry) => entry.id !== jobId);
571
+ try {
572
+ mkdirSync(join(homedir(), '.pugi'), { recursive: true });
573
+ writeFileSync(path, `${JSON.stringify(entries, null, 2)}\n`, {
574
+ encoding: 'utf8',
575
+ mode: 0o600,
576
+ });
577
+ }
578
+ catch {
579
+ // best-effort
580
+ }
581
+ }
582
+ /**
583
+ * Synchronous helper used by the legacy tool-bridge path. It wraps
584
+ * `spawnSync` for the simplest case (no background, no overflow
585
+ * artifact, default timeout) so callers that cannot await a promise
586
+ * still get the class-aware permission gate. Returns the same shape
587
+ * as the async tool minus the cwd carry-over (since spawnSync
588
+ * cannot stream we approximate the cap by post-truncation).
589
+ */
590
+ export function bashToolSync(input, ctx) {
591
+ const cmd = input.cmd ?? '';
592
+ const additionalDirectories = ctx.additionalDirectories ?? [];
593
+ const source = ctx.source ?? 'agent';
594
+ const toolCallId = recordToolCall(ctx.session, 'bash', cmd);
595
+ const decision = evaluateBashPermission(cmd, ctx.settings.permissions.mode, {
596
+ workspaceRoot: ctx.root,
597
+ additionalDirectories,
598
+ source,
599
+ });
600
+ if (decision.decision !== 'allow') {
601
+ const reason = `Permission ${decision.decision}: ${decision.reason}`;
602
+ recordToolResult(ctx.session, toolCallId, 'error', reason);
603
+ return {
604
+ stdout: '',
605
+ stderr: `Permission denied: ${decision.reason}`,
606
+ exitCode: 126,
607
+ nextCwd: ctx.lastBashCwd ?? ctx.root,
608
+ truncated: false,
609
+ timedOut: false,
610
+ };
611
+ }
612
+ const startCwd = resolveStartCwd(input.cwd ?? ctx.lastBashCwd, ctx.root, additionalDirectories);
613
+ const timeoutMs = sanitizeTimeout(input.timeoutMs);
614
+ const childEnv = buildChildEnv();
615
+ const result = spawnSync('/bin/sh', ['-c', cmd], {
616
+ cwd: startCwd,
617
+ env: childEnv,
618
+ encoding: 'utf8',
619
+ stdio: ['ignore', 'pipe', 'pipe'],
620
+ timeout: timeoutMs,
621
+ maxBuffer: 10 * 1024 * 1024,
622
+ });
623
+ const stdoutFull = (result.stdout ?? '').toString();
624
+ const stderrFull = (result.stderr ?? '').toString();
625
+ const truncated = stdoutFull.length + stderrFull.length > BASH_OUTPUT_CAP_BYTES;
626
+ let artifactRef;
627
+ let stdoutOut = stdoutFull;
628
+ let stderrOut = stderrFull;
629
+ if (truncated) {
630
+ artifactRef = persistOverflow({
631
+ root: ctx.root,
632
+ sessionId: ctx.session.id,
633
+ toolCallId,
634
+ stdout: stdoutFull,
635
+ stderr: stderrFull,
636
+ });
637
+ ({ stdout: stdoutOut, stderr: stderrOut } = capToCombined(stdoutFull, stderrFull));
638
+ }
639
+ const timedOut = result.error?.code === 'ETIMEDOUT' ||
640
+ result.signal === 'SIGTERM';
641
+ const exitCode = timedOut ? 124 : result.status ?? 1;
642
+ const nextCwd = computeNextCwd(cmd, startCwd, ctx.root, additionalDirectories, ctx.session);
643
+ if (timedOut) {
644
+ emitEvent(ctx.session, 'bash.timeout', { cmd, timeoutMs });
645
+ recordToolResult(ctx.session, toolCallId, 'error', `bash timed out after ${timeoutMs}ms`);
646
+ }
647
+ else {
648
+ recordToolResult(ctx.session, toolCallId, 'success', `bash exit=${exitCode} bytes=${stdoutFull.length + stderrFull.length}${artifactRef ? ` overflow=${artifactRef}` : ''}`);
649
+ }
650
+ return {
651
+ stdout: stdoutOut,
652
+ stderr: stderrOut,
653
+ exitCode,
654
+ artifactRef,
655
+ nextCwd,
656
+ truncated,
657
+ timedOut,
658
+ };
659
+ }
660
+ //# sourceMappingURL=bash.js.map