@pugi/cli 0.1.0-beta.17 → 0.1.0-beta.19

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 (51) hide show
  1. package/dist/core/compact/auto-trigger.js +96 -0
  2. package/dist/core/compact/buffer-rewriter.js +115 -0
  3. package/dist/core/compact/summarizer.js +196 -0
  4. package/dist/core/compact/token-counter.js +108 -0
  5. package/dist/core/denial-tracking/index.js +8 -0
  6. package/dist/core/denial-tracking/state.js +264 -0
  7. package/dist/core/diagnostics/probe-runner.js +93 -0
  8. package/dist/core/diagnostics/probes/api.js +46 -0
  9. package/dist/core/diagnostics/probes/auth.js +86 -0
  10. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  11. package/dist/core/diagnostics/probes/config.js +72 -0
  12. package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
  13. package/dist/core/diagnostics/probes/disk.js +81 -0
  14. package/dist/core/diagnostics/probes/git.js +65 -0
  15. package/dist/core/diagnostics/probes/mcp.js +75 -0
  16. package/dist/core/diagnostics/probes/node.js +59 -0
  17. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  18. package/dist/core/diagnostics/probes/session.js +74 -0
  19. package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
  20. package/dist/core/diagnostics/probes/workspace.js +63 -0
  21. package/dist/core/diagnostics/types.js +70 -0
  22. package/dist/core/engine/native-pugi.js +20 -0
  23. package/dist/core/engine/strip-internal-fields.js +124 -0
  24. package/dist/core/engine/tool-bridge.js +251 -49
  25. package/dist/core/file-cache.js +113 -1
  26. package/dist/core/mcp/client.js +66 -6
  27. package/dist/core/mcp/registry.js +24 -2
  28. package/dist/core/permissions/gate.js +187 -0
  29. package/dist/core/permissions/index.js +18 -0
  30. package/dist/core/permissions/mode.js +102 -0
  31. package/dist/core/permissions/state.js +160 -0
  32. package/dist/core/permissions/tool-class.js +93 -0
  33. package/dist/core/repl/session.js +261 -9
  34. package/dist/core/repl/slash-commands.js +67 -4
  35. package/dist/runtime/cli.js +153 -58
  36. package/dist/runtime/commands/compact.js +296 -0
  37. package/dist/runtime/commands/doctor.js +369 -0
  38. package/dist/runtime/commands/mcp.js +290 -3
  39. package/dist/runtime/commands/permissions.js +87 -0
  40. package/dist/runtime/commands/status.js +178 -0
  41. package/dist/runtime/version.js +1 -1
  42. package/dist/tools/agent-tool.js +18 -4
  43. package/dist/tools/ask-user-question.js +213 -0
  44. package/dist/tools/file-tools.js +57 -14
  45. package/dist/tools/registry.js +7 -0
  46. package/dist/tui/ask-user-question-prompt.js +192 -0
  47. package/dist/tui/compact-banner.js +54 -0
  48. package/dist/tui/conversation-pane.js +68 -7
  49. package/dist/tui/doctor-table.js +31 -0
  50. package/dist/tui/status-table.js +7 -0
  51. package/package.json +2 -2
@@ -1,11 +1,12 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { isAbsolute, resolve } from 'node:path';
5
5
  import { FileReadCache } from '../../core/file-cache.js';
6
6
  import { openSession } from '../../core/session.js';
7
7
  import { loadSettings } from '../../core/settings.js';
8
- import { loadMcpRegistry } from '../../core/mcp/registry.js';
8
+ import { isAlive } from '../../core/mcp/client.js';
9
+ import { loadMcpRegistry, mcpLogPath } from '../../core/mcp/registry.js';
9
10
  import { listMcpTrust, setMcpTrust } from '../../core/mcp/trust.js';
10
11
  import { createPugiMcpServer, serveStdio } from '../../core/mcp/server.js';
11
12
  import { buildPugiMcpTools } from '../../core/mcp/server-tools.js';
@@ -22,6 +23,15 @@ export async function runMcpCommand(args, ctx) {
22
23
  return runMcpFlip(args.slice(1), ctx, 'denied');
23
24
  case 'install':
24
25
  return runMcpInstall(args.slice(1), ctx);
26
+ case 'remove':
27
+ case 'uninstall':
28
+ return runMcpRemove(args.slice(1), ctx);
29
+ case 'doctor':
30
+ return runMcpDoctor(args.slice(1), ctx);
31
+ case 'logs':
32
+ return runMcpLogs(args.slice(1), ctx);
33
+ case 'restart':
34
+ return runMcpRestart(args.slice(1), ctx);
25
35
  case 'serve':
26
36
  return runMcpServe(args.slice(1), ctx);
27
37
  case 'perms':
@@ -32,7 +42,7 @@ export async function runMcpCommand(args, ctx) {
32
42
  ctx.writeOutput({ command: 'mcp', usage: USAGE_LINES }, USAGE_LINES.join('\n'));
33
43
  return;
34
44
  default:
35
- throw new Error(`Unknown sub-command "pugi mcp ${sub}". Try one of: list, trust, deny, install, serve, perms.`);
45
+ throw new Error(`Unknown sub-command "pugi mcp ${sub}". Try one of: list, trust, deny, install, remove, doctor, logs, restart, serve, perms.`);
36
46
  }
37
47
  }
38
48
  const USAGE_LINES = [
@@ -44,6 +54,15 @@ const USAGE_LINES = [
44
54
  ' install <name> <command...> Add a server to .pugi/mcp.json (workspace scope).',
45
55
  ' <command> must be an absolute path OR a binary',
46
56
  ' resolvable via `which` on the operator PATH.',
57
+ ' remove <name> Remove a server from .pugi/mcp.json (workspace scope).',
58
+ ' Trust ledger entry is preserved — re-install reuses it.',
59
+ ' doctor [--connect] Print per-server health (handshake, tool count, last error).',
60
+ ' --connect actually spawns the children (slow, ~5s/server).',
61
+ ' Without --connect, reports the declared state only.',
62
+ ' logs <name> [--tail N] Tail the per-server log file at .pugi/logs/mcp-<name>.log.',
63
+ ' Default tail is 40 lines.',
64
+ ' restart <name> Bounce a server: tear down the connection, reload the',
65
+ ' config, re-handshake. Surfaces the new tool count.',
47
66
  ' serve [options] Run Pugi as an MCP server',
48
67
  ' --http :<port> HTTP+SSE transport (default: stdio)',
49
68
  ' --host <ip> HTTP bind host (default: 127.0.0.1)',
@@ -219,6 +238,274 @@ function containsShellMetachar(value) {
219
238
  // a shell pipeline.
220
239
  return /[;|&`$<>(){}\n\r]/.test(value);
221
240
  }
241
+ /* ---------- remove ---------------------------------------------------- */
242
+ async function runMcpRemove(args, ctx) {
243
+ const name = args[0];
244
+ if (!name) {
245
+ throw new Error('Usage: pugi mcp remove <name>');
246
+ }
247
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
248
+ throw new Error(`pugi mcp remove: server name "${name}" must be [a-zA-Z0-9_-]+`);
249
+ }
250
+ const mcpJsonPath = resolve(ctx.workspaceRoot, '.pugi/mcp.json');
251
+ if (!existsSync(mcpJsonPath)) {
252
+ throw new Error(`pugi mcp remove: no .pugi/mcp.json at ${mcpJsonPath}. Nothing to remove.`);
253
+ }
254
+ let existing;
255
+ try {
256
+ const raw = readFileSync(mcpJsonPath, 'utf8');
257
+ if (raw.trim().length === 0) {
258
+ existing = { servers: {} };
259
+ }
260
+ else {
261
+ const parsed = JSON.parse(raw);
262
+ existing = { servers: parsed.servers ?? {} };
263
+ }
264
+ }
265
+ catch (error) {
266
+ throw new Error(`pugi mcp remove: cannot parse .pugi/mcp.json: ${error.message}. ` +
267
+ `Fix the file by hand or delete it and re-run.`);
268
+ }
269
+ if (!existing.servers[name]) {
270
+ throw new Error(`pugi mcp remove: server "${name}" not declared in ${mcpJsonPath}. ` +
271
+ `Run \`pugi mcp list\` to see declared servers.`);
272
+ }
273
+ // Preserve the trust ledger entry on purpose — a re-install of the same
274
+ // server name should land back at its old trust state so the operator
275
+ // does not have to re-approve it. To wipe trust as well, run
276
+ // `pugi mcp deny <name>` (or edit ~/.pugi/trust-mcp.json by hand).
277
+ delete existing.servers[name];
278
+ writeFileSync(mcpJsonPath, `${JSON.stringify(existing, null, 2)}\n`, { mode: 0o600 });
279
+ ctx.writeOutput({
280
+ command: 'mcp.remove',
281
+ name,
282
+ configPath: mcpJsonPath,
283
+ remaining: Object.keys(existing.servers),
284
+ }, [
285
+ `Removed MCP server "${name}" from ${mcpJsonPath}.`,
286
+ `Trust ledger entry preserved — re-install reuses it.`,
287
+ `Remaining servers: ${Object.keys(existing.servers).length === 0 ? '(none)' : Object.keys(existing.servers).join(', ')}.`,
288
+ ].join('\n'));
289
+ }
290
+ /* ---------- doctor ---------------------------------------------------- */
291
+ async function runMcpDoctor(args, ctx) {
292
+ // `--connect` forces the doctor to actually spawn children + handshake.
293
+ // Default behaviour is dry-run (config + trust ledger only) so a routine
294
+ // `pugi doctor` does not block on a misbehaving server's 5s timeout per
295
+ // entry. Operators investigating an outage pass `--connect` explicitly.
296
+ const wantsConnect = args.includes('--connect');
297
+ const registry = await loadMcpRegistry(ctx.workspaceRoot, { connect: wantsConnect });
298
+ const ledger = await listMcpTrust();
299
+ const rows = [];
300
+ for (const state of registry.servers.values()) {
301
+ const conn = state.connection;
302
+ let handshake;
303
+ if (!wantsConnect) {
304
+ handshake = 'not-attempted';
305
+ }
306
+ else if (state.lastError) {
307
+ handshake = 'failed';
308
+ }
309
+ else if (conn && isAlive(conn)) {
310
+ handshake = 'ok';
311
+ }
312
+ else {
313
+ handshake = 'failed';
314
+ }
315
+ rows.push({
316
+ name: state.name,
317
+ trust: state.trust,
318
+ handshake,
319
+ pid: conn?.child.pid ?? null,
320
+ tools: state.surfacedTools.length,
321
+ uptimeMs: conn ? Date.now() - conn.startedAt : null,
322
+ lastError: state.lastError ?? null,
323
+ logFile: mcpLogPath(ctx.workspaceRoot, state.name),
324
+ });
325
+ }
326
+ rows.sort((a, b) => a.name.localeCompare(b.name));
327
+ await registry.shutdown();
328
+ if (rows.length === 0) {
329
+ ctx.writeOutput({ command: 'mcp.doctor', rows, ledger, connectAttempted: wantsConnect }, 'No MCP servers declared. Add one with `pugi mcp install <name> <command...>`.');
330
+ return;
331
+ }
332
+ const headerLines = [
333
+ `MCP doctor (${wantsConnect ? 'live handshake' : 'declared state only — pass --connect for live probe'}):`,
334
+ '',
335
+ ` ${'NAME'.padEnd(20)} ${'TRUST'.padEnd(8)} ${'HANDSHAKE'.padEnd(14)} ${'TOOLS'.padEnd(6)} ${'PID'.padEnd(7)} NOTE`,
336
+ ];
337
+ for (const row of rows) {
338
+ const note = row.lastError
339
+ ? `error: ${truncate(row.lastError, 60)}`
340
+ : row.handshake === 'ok'
341
+ ? `uptime ${formatUptime(row.uptimeMs ?? 0)}`
342
+ : row.handshake === 'failed'
343
+ ? 'see log file'
344
+ : '';
345
+ headerLines.push(` ${row.name.padEnd(20)} ${row.trust.padEnd(8)} ${row.handshake.padEnd(14)} ${String(row.tools).padEnd(6)} ${String(row.pid ?? '-').padEnd(7)} ${note}`);
346
+ }
347
+ headerLines.push('', `Log dir: ${resolve(ctx.workspaceRoot, '.pugi/logs')}`);
348
+ if (!wantsConnect) {
349
+ headerLines.push('Hint: pass --connect to actually spawn the children (slow, ~5s budget/server).');
350
+ }
351
+ ctx.writeOutput({ command: 'mcp.doctor', rows, ledger, connectAttempted: wantsConnect }, headerLines.join('\n'));
352
+ }
353
+ function truncate(value, max) {
354
+ if (value.length <= max)
355
+ return value;
356
+ return `${value.slice(0, max - 1)}…`;
357
+ }
358
+ function formatUptime(ms) {
359
+ if (ms < 1000)
360
+ return `${ms}ms`;
361
+ const sec = Math.floor(ms / 1000);
362
+ if (sec < 60)
363
+ return `${sec}s`;
364
+ const min = Math.floor(sec / 60);
365
+ if (min < 60)
366
+ return `${min}m${sec % 60}s`;
367
+ const hr = Math.floor(min / 60);
368
+ return `${hr}h${min % 60}m`;
369
+ }
370
+ /* ---------- logs ------------------------------------------------------ */
371
+ async function runMcpLogs(args, ctx) {
372
+ const name = args[0];
373
+ if (!name || name.startsWith('--')) {
374
+ throw new Error('Usage: pugi mcp logs <name> [--tail N]');
375
+ }
376
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
377
+ throw new Error(`pugi mcp logs: server name "${name}" must be [a-zA-Z0-9_-]+`);
378
+ }
379
+ // `--tail N` and `--tail=N` both supported. Default 40 lines — matches
380
+ // typical `tail -n 40` muscle memory.
381
+ let tail = 40;
382
+ for (let i = 1; i < args.length; i += 1) {
383
+ const arg = args[i] ?? '';
384
+ if (arg === '--tail') {
385
+ const next = args[i + 1];
386
+ if (!next)
387
+ throw new Error('pugi mcp logs: --tail requires a value');
388
+ const n = Number.parseInt(next, 10);
389
+ if (!Number.isInteger(n) || n <= 0) {
390
+ throw new Error(`pugi mcp logs: --tail must be a positive integer (got "${next}")`);
391
+ }
392
+ tail = n;
393
+ i += 1;
394
+ }
395
+ else if (arg.startsWith('--tail=')) {
396
+ const n = Number.parseInt(arg.slice('--tail='.length), 10);
397
+ if (!Number.isInteger(n) || n <= 0) {
398
+ throw new Error(`pugi mcp logs: --tail must be a positive integer (got "${arg}")`);
399
+ }
400
+ tail = n;
401
+ }
402
+ else {
403
+ throw new Error(`pugi mcp logs: unknown flag "${arg}"`);
404
+ }
405
+ }
406
+ const path = mcpLogPath(ctx.workspaceRoot, name);
407
+ if (!existsSync(path)) {
408
+ ctx.writeOutput({ command: 'mcp.logs', name, path, tail, lines: [] }, `No log file at ${path}. The server has not produced stderr output yet (or has never been started).`);
409
+ return;
410
+ }
411
+ let raw;
412
+ try {
413
+ raw = readFileSync(path, 'utf8');
414
+ }
415
+ catch (error) {
416
+ throw new Error(`pugi mcp logs: cannot read ${path}: ${error.message}.`);
417
+ }
418
+ const allLines = raw.split('\n');
419
+ // `split('\n')` of a trailing-newline file yields an empty last element.
420
+ // Drop it so the displayed tail matches `wc -l` expectations.
421
+ if (allLines.length > 0 && allLines[allLines.length - 1] === '') {
422
+ allLines.pop();
423
+ }
424
+ const tailed = allLines.slice(Math.max(0, allLines.length - tail));
425
+ const sizeBytes = (() => {
426
+ try {
427
+ return statSync(path).size;
428
+ }
429
+ catch {
430
+ return 0;
431
+ }
432
+ })();
433
+ ctx.writeOutput({
434
+ command: 'mcp.logs',
435
+ name,
436
+ path,
437
+ tail,
438
+ totalLines: allLines.length,
439
+ sizeBytes,
440
+ lines: tailed,
441
+ }, [
442
+ `pugi mcp logs ${name} (${path}, ${sizeBytes} bytes, ${allLines.length} total lines, showing last ${Math.min(tail, allLines.length)}):`,
443
+ ...tailed,
444
+ ].join('\n'));
445
+ }
446
+ /* ---------- restart --------------------------------------------------- */
447
+ async function runMcpRestart(args, ctx) {
448
+ const name = args[0];
449
+ if (!name) {
450
+ throw new Error('Usage: pugi mcp restart <name>');
451
+ }
452
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
453
+ throw new Error(`pugi mcp restart: server name "${name}" must be [a-zA-Z0-9_-]+`);
454
+ }
455
+ // `pugi mcp restart` is stateless from the CLI's perspective — the CLI
456
+ // process has no long-lived MCP registry of its own (the REPL owns it
457
+ // and tears it down on exit). What we DO here is: load the config,
458
+ // refuse if the server is not declared or not trusted, then probe-spawn
459
+ // the server with the live handshake. This proves it is reachable +
460
+ // surfaces the tool count for the operator. The REPL picks up the
461
+ // change on its next `loadMcpRegistry` cycle.
462
+ const registry = await loadMcpRegistry(ctx.workspaceRoot, { connect: false });
463
+ const state = registry.servers.get(name);
464
+ if (!state) {
465
+ await registry.shutdown();
466
+ throw new Error(`pugi mcp restart: server "${name}" not declared. Run \`pugi mcp list\` to see declared servers.`);
467
+ }
468
+ if (state.trust !== 'trusted') {
469
+ await registry.shutdown();
470
+ throw new Error(`pugi mcp restart: server "${name}" trust state is "${state.trust}". ` +
471
+ `Run \`pugi mcp trust ${name}\` first.`);
472
+ }
473
+ await registry.shutdown();
474
+ // Re-load WITH connect=true but scoped to a single-server probe via
475
+ // handshakeTimeoutMs (5s default keeps the CLI snappy). We use the same
476
+ // loadMcpRegistry path so log routing + error capture stay consistent.
477
+ const probe = await loadMcpRegistry(ctx.workspaceRoot, { connect: true });
478
+ const probed = probe.servers.get(name);
479
+ const lastError = probed?.lastError ?? null;
480
+ const toolCount = probed?.surfacedTools.length ?? 0;
481
+ const pid = probed?.connection?.child.pid ?? null;
482
+ await probe.shutdown();
483
+ if (lastError) {
484
+ ctx.writeOutput({
485
+ command: 'mcp.restart',
486
+ name,
487
+ ok: false,
488
+ error: lastError,
489
+ logFile: mcpLogPath(ctx.workspaceRoot, name),
490
+ }, [
491
+ `pugi mcp restart ${name}: FAILED`,
492
+ ` error: ${lastError}`,
493
+ ` log file: ${mcpLogPath(ctx.workspaceRoot, name)}`,
494
+ ].join('\n'));
495
+ return;
496
+ }
497
+ ctx.writeOutput({
498
+ command: 'mcp.restart',
499
+ name,
500
+ ok: true,
501
+ pid,
502
+ surfacedTools: toolCount,
503
+ }, [
504
+ `pugi mcp restart ${name}: OK`,
505
+ ` pid: ${pid ?? '-'}`,
506
+ ` surfaced tools: ${toolCount}`,
507
+ ].join('\n'));
508
+ }
222
509
  async function runMcpServe(args, ctx) {
223
510
  const flags = parseServeFlags(args);
224
511
  const session = openSession(ctx.workspaceRoot);
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `pugi permissions` / `/permissions` — Leak L6 4-mode gate control.
3
+ *
4
+ * Two entry points share one runtime helper:
5
+ * 1. `/permissions` in the REPL — forwarded by `core/repl/session.ts`.
6
+ * 2. `pugi permissions ...` top-level CLI command (handler in
7
+ * `runtime/cli.ts`).
8
+ *
9
+ * Both pass a `PermissionsCommand` payload describing the operator
10
+ * intent (show / flip / persist) and a `writeOutput` callback that
11
+ * lets the caller route the rendered lines into the right surface
12
+ * (REPL transcript vs. stdout). The helper is intentionally I/O-free
13
+ * itself — it produces lines and lets the caller stream them.
14
+ */
15
+ import { DEFAULT_PERMISSION_MODE, PERMISSION_MODES, PERMISSION_MODE_GLOSS, getCurrentMode, getGlobalDefaultMode, setCurrentMode, setGlobalDefaultMode, } from '../../core/permissions/index.js';
16
+ /**
17
+ * Run the `/permissions` or `pugi permissions` flow. Side effects:
18
+ * - When `command.mode` is undefined: prints the current mode + the
19
+ * 4-mode table (no writes).
20
+ * - When `command.mode === 'bypass'` without `confirmBypass`: prints
21
+ * a refusal + the safety copy, no writes.
22
+ * - When `command.mode` is set + valid: writes workspace session
23
+ * state; optionally writes global default when `persist` is true.
24
+ * - Always prints the new effective mode + a one-line confirmation.
25
+ */
26
+ export async function runPermissionsCommand(command, ctx) {
27
+ if (!command.mode) {
28
+ renderCurrentMode(ctx);
29
+ renderModeTable(ctx);
30
+ return;
31
+ }
32
+ if (command.mode === 'bypass' && !command.confirmBypass) {
33
+ ctx.writeOutput('Bypass mode disables policy hooks (skill steering, denial tracking).');
34
+ ctx.writeOutput('Run `/permissions bypass --confirm` to acknowledge before flipping.');
35
+ return;
36
+ }
37
+ setCurrentMode(ctx.workspaceRoot, command.mode);
38
+ if (command.persist) {
39
+ setGlobalDefaultMode(command.mode, ctx.homeDir);
40
+ }
41
+ const persistedHint = command.persist
42
+ ? ' Persisted to ~/.pugi/config.json for future sessions.'
43
+ : '';
44
+ ctx.writeOutput(`Permission mode set to '${command.mode}'.${persistedHint} ${PERMISSION_MODE_GLOSS[command.mode]}`);
45
+ if (command.mode === 'bypass') {
46
+ ctx.writeOutput('BYPASS MODE — all tools execute without prompts AND policy hooks disabled. Switch back with /permissions allow.');
47
+ }
48
+ }
49
+ /**
50
+ * Print the resolved current mode + the layered source. The merge
51
+ * order mirrors `resolveMode()`: workspace > global > default.
52
+ */
53
+ function renderCurrentMode(ctx) {
54
+ const workspace = getCurrentMode(ctx.workspaceRoot);
55
+ const global = getGlobalDefaultMode(ctx.homeDir);
56
+ const effective = workspace ?? global ?? DEFAULT_PERMISSION_MODE;
57
+ const source = workspace
58
+ ? 'workspace session.json'
59
+ : global
60
+ ? 'global ~/.pugi/config.json'
61
+ : 'default (no override)';
62
+ ctx.writeOutput(`Current permission mode: ${effective} (source: ${source})`);
63
+ }
64
+ /**
65
+ * Print the 4-mode reference table. Keeps the gloss + the side-effect
66
+ * matrix in one place so the operator can see the contract while they
67
+ * decide which mode to switch to.
68
+ */
69
+ function renderModeTable(ctx) {
70
+ ctx.writeOutput('');
71
+ ctx.writeOutput('Permission modes:');
72
+ for (const mode of PERMISSION_MODES) {
73
+ ctx.writeOutput(` ${mode.padEnd(7)} ${PERMISSION_MODE_GLOSS[mode]}`);
74
+ }
75
+ ctx.writeOutput('');
76
+ ctx.writeOutput('Switch with `/permissions <mode> [--persist]`. Bypass requires `--confirm`.');
77
+ }
78
+ /**
79
+ * Render the one-shot banner shown on session boot when the effective
80
+ * mode is `bypass`. The caller (engine adapter / REPL bootstrap) calls
81
+ * this once per session — repeated invocations are idempotent in copy
82
+ * but the caller is responsible for the once-only semantics.
83
+ */
84
+ export function renderBypassBanner(writeOutput) {
85
+ writeOutput('BYPASS MODE — all tools execute without prompts AND policy hooks disabled. Switch back with /permissions allow.');
86
+ }
87
+ //# sourceMappingURL=permissions.js.map
@@ -0,0 +1,178 @@
1
+ /**
2
+ * `pugi status` — concise session-state probe (Leak L34, 2026-05-27).
3
+ *
4
+ * Different from `pugi doctor` (environment health). The status
5
+ * command answers "what is this Pugi session doing right now?" —
6
+ * session id + age, cwd, permission mode, CLI version, token
7
+ * usage, dispatch counts, last command, compact boundaries, auth
8
+ * identity.
9
+ *
10
+ * # Module contract
11
+ *
12
+ * - This file owns the WIRING from CLI flags + ambient process
13
+ * state к the snapshot collector. The data collector itself
14
+ * lives in `core/diagnostics/probes/status-snapshot.ts` and
15
+ * has zero coupling к the CLI dispatch surface.
16
+ *
17
+ * - `runStatusCommand` is the single entry point. Both the
18
+ * top-level `pugi status` handler in `runtime/cli.ts` AND the
19
+ * in-REPL `/status` slash command call it. The function
20
+ * returns the `StatusSnapshot` so the REPL slash handler can
21
+ * mount the Ink renderer without re-collecting fields.
22
+ *
23
+ * - The credential resolver is captured behind a function so the
24
+ * spec can stub it without monkey-patching `core/credentials.ts`.
25
+ *
26
+ * - The permission state module is dynamic-imported with a
27
+ * try/catch so this command lands cleanly before the L6
28
+ * permission-mode work — when the module is absent the field
29
+ * degrades к "unknown" instead of crashing the snapshot.
30
+ *
31
+ * - Exit code is 0 unless the snapshot itself throws. The
32
+ * command is intentionally informational — even a fully
33
+ * "unavailable" snapshot (everything degraded к sentinels)
34
+ * exits 0 so monitoring scripts can rely on a stable contract.
35
+ */
36
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
37
+ import { homedir } from 'node:os';
38
+ import { resolveActiveCredential } from '../../core/credentials.js';
39
+ import { PUGI_CLI_VERSION } from '../version.js';
40
+ import { collectStatusSnapshot, } from '../../core/diagnostics/probes/status-snapshot.js';
41
+ /**
42
+ * Default production filesystem stubs. Wraps node:fs so the
43
+ * snapshot collector can stay sync + structurally testable.
44
+ */
45
+ const DEFAULT_FS = {
46
+ existsSync,
47
+ statSync,
48
+ readdirSync: (p) => readdirSync(p),
49
+ readFileSync: (p, encoding) => readFileSync(p, encoding),
50
+ };
51
+ /**
52
+ * Default credential resolver. Wraps `resolveActiveCredential` so
53
+ * the snapshot only sees the trimmed `ResolvedCredentialSummary`
54
+ * shape and не the full store record.
55
+ */
56
+ function defaultResolveCredential(env, home) {
57
+ const cred = resolveActiveCredential(env, home);
58
+ if (!cred)
59
+ return null;
60
+ // Tier is не yet recorded в the credentials file — set к null
61
+ // until a future sprint surfaces `tier` on the stored record.
62
+ // The renderer hides the parenthetical when tier is null.
63
+ return {
64
+ apiUrl: cred.apiUrl,
65
+ label: cred.label ?? null,
66
+ tier: null,
67
+ identity: cred.label ?? null,
68
+ };
69
+ }
70
+ /**
71
+ * Default permission-mode loader. Dynamic-imports
72
+ * `core/permissions/state.ts` (L6 in the leak-parity roadmap);
73
+ * returns null when the module is absent OR malformed, which the
74
+ * snapshot field degrades к the "unknown" sentinel.
75
+ */
76
+ async function defaultResolvePermissionMode() {
77
+ try {
78
+ const mod = (await import('../../core/permissions/state.js').catch(() => null));
79
+ if (!mod || typeof mod.getCurrentPermissionMode !== 'function') {
80
+ return null;
81
+ }
82
+ const mode = mod.getCurrentPermissionMode();
83
+ return typeof mode === 'string' && mode.length > 0 ? mode : null;
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ }
89
+ /**
90
+ * Collect the snapshot + emit the output via the supplied
91
+ * writeOutput sink. Returns the snapshot so REPL callers can
92
+ * route it к the Ink renderer instead of the plain-text fallback.
93
+ */
94
+ export async function runStatusCommand(ctx) {
95
+ // Resolve the permission mode upfront — the snapshot is sync but
96
+ // the L6 loader is async (dynamic import). We materialise the
97
+ // value here and hand the snapshot a sync getter.
98
+ const permissionMode = ctx.resolvePermissionMode === undefined
99
+ ? await defaultResolvePermissionMode()
100
+ : null;
101
+ const deps = {
102
+ cwd: ctx.cwd,
103
+ home: ctx.home,
104
+ cliVersion: PUGI_CLI_VERSION,
105
+ now: ctx.now ?? Date.now,
106
+ liveSessionId: ctx.liveSessionId ?? null,
107
+ sessionStartedAtEpochMs: ctx.sessionStartedAtEpochMs ?? null,
108
+ liveTokensUsed: ctx.liveTokensUsed ?? null,
109
+ lastCommand: ctx.lastCommand ?? null,
110
+ lastCommandAtEpochMs: ctx.lastCommandAtEpochMs ?? null,
111
+ fs: ctx.fs ?? DEFAULT_FS,
112
+ resolveCredential: ctx.resolveCredential ?? (() => defaultResolveCredential(ctx.env, ctx.home)),
113
+ resolvePermissionMode: ctx.resolvePermissionMode ?? (() => permissionMode),
114
+ };
115
+ let snapshot;
116
+ try {
117
+ snapshot = collectStatusSnapshot(deps);
118
+ }
119
+ catch (error) {
120
+ // Defensive: the snapshot collector is fail-soft per field, but
121
+ // a structural crash (e.g. a hostile env that throws on
122
+ // `process.version` access) must не bring down `pugi status`.
123
+ // We synthesise a minimal envelope and surface the error в the
124
+ // text output so the operator can file a bug.
125
+ const message = error instanceof Error ? error.message : String(error);
126
+ snapshot = {
127
+ command: 'status',
128
+ fields: [
129
+ {
130
+ key: 'session',
131
+ label: 'Session',
132
+ value: 'unknown',
133
+ available: false,
134
+ note: `snapshot collector crashed: ${message}`,
135
+ },
136
+ ],
137
+ meta: {
138
+ cliVersion: PUGI_CLI_VERSION,
139
+ nodeVersion: process.version,
140
+ cwd: ctx.cwd,
141
+ capturedAt: new Date((ctx.now ?? Date.now)()).toISOString(),
142
+ },
143
+ };
144
+ }
145
+ const text = renderStatusTable(snapshot);
146
+ ctx.writeOutput(snapshot, text);
147
+ // Always exit 0 — `pugi status` is informational, never a gate.
148
+ process.exitCode = 0;
149
+ return snapshot;
150
+ }
151
+ /**
152
+ * Plain-text table renderer. Lays out the snapshot as a two-column
153
+ * (LABEL / VALUE) table with the brand-voice header `Pugi status`.
154
+ * Matches the column-light pattern used by `renderDoctorTable` so
155
+ * narrow terminals stay legible without a layout library.
156
+ */
157
+ export function renderStatusTable(snapshot) {
158
+ const labelWidth = Math.max('Label'.length, ...snapshot.fields.map((f) => f.label.length));
159
+ const lines = [];
160
+ lines.push('Pugi status');
161
+ lines.push('═'.repeat(50));
162
+ for (const field of snapshot.fields) {
163
+ const labelPart = field.label.padEnd(labelWidth, ' ');
164
+ lines.push(`${labelPart} ${field.value}`);
165
+ }
166
+ lines.push('');
167
+ lines.push(`CLI ${snapshot.meta.cliVersion} Node ${snapshot.meta.nodeVersion} cwd ${snapshot.meta.cwd}`);
168
+ return lines.join('\n');
169
+ }
170
+ /**
171
+ * Default home dir resolver. Centralised so the CLI handler can
172
+ * call `runStatusCommand` without re-importing `os.homedir`
173
+ * everywhere.
174
+ */
175
+ export function defaultStatusHome() {
176
+ return homedir();
177
+ }
178
+ //# sourceMappingURL=status.js.map
@@ -44,7 +44,7 @@ export function sanitizeSemver(raw) {
44
44
  * during import). When bumping the CLI version BOTH literals must be
45
45
  * updated; the release smoke-test (`pack:smoke`) verifies they agree.
46
46
  */
47
- export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.17');
47
+ export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.19');
48
48
  /**
49
49
  * Outbound: the CLI's installed semver. Read at request time by
50
50
  * `version-interceptor.ts` and injected on every `fetch` call.
@@ -58,8 +58,16 @@ import { spawnSubagentWithOutcome } from '../core/subagents/spawn.js';
58
58
  * inverse (forces shared-fs even for roles whose default is `worktree`).
59
59
  *
60
60
  * The role enum mirrors the SDK's SubagentRole — keep both in lockstep.
61
+ *
62
+ * Leak P0 L2 (2026-05-27): `z.strictObject` rejects ANY additional or
63
+ * aliased fields at parse time. Matches the openclaude FileEditTool /
64
+ * FileWriteTool posture (research memo §1.1). The model-facing JSON
65
+ * schema already declares `additionalProperties: false`; the strict
66
+ * Zod variant is defense-in-depth — if the bridge ever bypasses the
67
+ * model-side gate (raw test fixture, internal dispatch), the runtime
68
+ * still refuses unknown keys instead of silently dropping them.
61
69
  */
62
- export const agentToolArgsSchema = z.object({
70
+ export const agentToolArgsSchema = z.strictObject({
63
71
  role: z.enum([
64
72
  'orchestrator',
65
73
  'architect',
@@ -70,12 +78,18 @@ export const agentToolArgsSchema = z.object({
70
78
  'release',
71
79
  'devops',
72
80
  'design_qa',
73
- ]),
81
+ ]).describe('SubagentRole — selects persona + isolation tier.'),
74
82
  brief: z
75
83
  .string()
76
84
  .min(1, 'brief must not be empty')
77
- .max(8000, 'brief must be ≤ 8000 chars'),
78
- isolation: z.enum(['worktree', 'shared_fs', 'auto']).optional(),
85
+ .max(8000, 'brief must be ≤ 8000 chars')
86
+ .describe('One-paragraph task description forwarded to the child as the user prompt. '
87
+ + 'Be concrete: include filenames, expected behavior, and acceptance criteria.'),
88
+ isolation: z
89
+ .enum(['worktree', 'shared_fs', 'auto'])
90
+ .optional()
91
+ .describe('Optional override. `worktree` forces a scratch git worktree for write isolation; '
92
+ + '`shared_fs` forces same-tree execution; `auto` (default) defers to the role tier.'),
79
93
  });
80
94
  /**
81
95
  * Dispatch a subagent via the `agent` tool. Returns the JSON envelope