@pugi/cli 0.1.0-alpha.9 → 0.1.0-beta.10

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 (74) hide show
  1. package/README.md +33 -0
  2. package/THIRD_PARTY_NOTICES.md +40 -0
  3. package/assets/pugi-mascot.ansi +16 -0
  4. package/dist/commands/deploy.js +439 -0
  5. package/dist/core/agents/loader.js +104 -0
  6. package/dist/core/agents/registry.js +1 -1
  7. package/dist/core/consensus/anvil-fanout.js +276 -0
  8. package/dist/core/consensus/diff-capture.js +382 -0
  9. package/dist/core/consensus/rubric.js +233 -0
  10. package/dist/core/context/index.js +21 -0
  11. package/dist/core/context/pugiignore.js +316 -0
  12. package/dist/core/context/repo-skeleton.js +533 -0
  13. package/dist/core/context/watcher.js +342 -0
  14. package/dist/core/context/working-set.js +165 -0
  15. package/dist/core/edits/dispatch.js +185 -0
  16. package/dist/core/edits/index.js +15 -0
  17. package/dist/core/edits/layer-a-apply.js +217 -0
  18. package/dist/core/edits/layer-b-apply.js +211 -0
  19. package/dist/core/edits/layer-c-apply.js +160 -0
  20. package/dist/core/edits/layer-d-ast.js +29 -0
  21. package/dist/core/edits/marker-parser.js +401 -0
  22. package/dist/core/edits/security-gate.js +223 -0
  23. package/dist/core/edits/worktree.js +322 -0
  24. package/dist/core/engine/native-pugi.js +6 -1
  25. package/dist/core/engine/prompts.js +8 -0
  26. package/dist/core/engine/tool-bridge.js +33 -1
  27. package/dist/core/lsp/client.js +719 -0
  28. package/dist/core/repl/ask.js +512 -0
  29. package/dist/core/repl/cancellation.js +98 -0
  30. package/dist/core/repl/dispatch-fsm.js +220 -0
  31. package/dist/core/repl/privacy-banner.js +71 -0
  32. package/dist/core/repl/session.js +1908 -13
  33. package/dist/core/repl/slash-commands.js +92 -32
  34. package/dist/core/repl/store/index.js +12 -0
  35. package/dist/core/repl/store/jsonl-log.js +321 -0
  36. package/dist/core/repl/store/lockfile.js +155 -0
  37. package/dist/core/repl/store/session-store.js +792 -0
  38. package/dist/core/repl/store/types.js +44 -0
  39. package/dist/core/repl/store/uuid-v7.js +68 -0
  40. package/dist/core/repl/workspace-context.js +72 -1
  41. package/dist/core/skills/defaults.js +457 -0
  42. package/dist/core/skills/loader.js +454 -0
  43. package/dist/core/skills/sources.js +480 -0
  44. package/dist/core/skills/trust.js +172 -0
  45. package/dist/runtime/cli.js +998 -12
  46. package/dist/runtime/commands/agents.js +385 -0
  47. package/dist/runtime/commands/config.js +338 -8
  48. package/dist/runtime/commands/delegate.js +289 -0
  49. package/dist/runtime/commands/lsp.js +206 -0
  50. package/dist/runtime/commands/patch.js +128 -0
  51. package/dist/runtime/commands/review-consensus.js +399 -0
  52. package/dist/runtime/commands/roster.js +117 -0
  53. package/dist/runtime/commands/skills.js +401 -0
  54. package/dist/runtime/commands/worktree.js +177 -0
  55. package/dist/runtime/plan-decompose.js +531 -0
  56. package/dist/tools/apply-patch.js +495 -0
  57. package/dist/tools/file-tools.js +90 -0
  58. package/dist/tools/lsp-tools.js +189 -0
  59. package/dist/tools/registry.js +26 -0
  60. package/dist/tools/web-fetch.js +1 -1
  61. package/dist/tui/agent-tree-pane.js +9 -0
  62. package/dist/tui/ask-cli.js +52 -0
  63. package/dist/tui/ask-modal.js +211 -0
  64. package/dist/tui/conversation-pane.js +48 -3
  65. package/dist/tui/input-box.js +48 -5
  66. package/dist/tui/markdown-render.js +266 -0
  67. package/dist/tui/repl-render.js +319 -3
  68. package/dist/tui/repl-splash-mascot.js +130 -0
  69. package/dist/tui/repl-splash.js +7 -1
  70. package/dist/tui/repl.js +96 -12
  71. package/dist/tui/status-bar.js +63 -3
  72. package/dist/tui/tool-stream-pane.js +91 -0
  73. package/docs/examples/codegraph.mcp.json +10 -0
  74. package/package.json +14 -6
@@ -4,7 +4,9 @@ import { dirname, resolve } from 'node:path';
4
4
  import { z } from 'zod';
5
5
  import { loadMcpRegistry } from '../../core/mcp/registry.js';
6
6
  import { listMcpTrust, setMcpTrust, } from '../../core/mcp/trust.js';
7
+ import { request } from 'undici';
7
8
  import { trustWorkspace } from '../../core/trust.js';
9
+ import { resolveActiveCredential } from '../../core/credentials.js';
8
10
  /**
9
11
  * `pugi config` — operator-level configuration surface.
10
12
  *
@@ -53,24 +55,90 @@ export async function runConfigCommand(args, ctx) {
53
55
  'pugi config mcp trust <name>',
54
56
  'pugi config mcp deny <name>',
55
57
  'pugi config mcp list',
58
+ 'pugi config get routing',
59
+ 'pugi config set routing.<tag>.<budget>=<model>',
60
+ 'pugi config unset routing.<tag>.<budget>',
61
+ 'pugi config get privacy',
62
+ 'pugi config set privacy=strict|balanced|permissive',
56
63
  ],
57
64
  }, [
58
65
  'Usage:',
59
- ' pugi config get <key> Read a config value.',
60
- ' pugi config set <key> <value> Write a config value.',
61
- ' pugi config list Show all config values.',
62
- ' pugi config trust . Trust the current workspace for hooks + MCP.',
63
- ' pugi config mcp trust <name> Mark an MCP server as trusted.',
64
- ' pugi config mcp deny <name> Block an MCP server.',
65
- ' pugi config mcp list Show declared MCP servers + trust state.',
66
+ ' pugi config get <key> Read a config value.',
67
+ ' pugi config set <key> <value> Write a config value.',
68
+ ' pugi config list Show all config values.',
69
+ ' pugi config trust . Trust the current workspace for hooks + MCP.',
70
+ ' pugi config mcp trust <name> Mark an MCP server as trusted.',
71
+ ' pugi config mcp deny <name> Block an MCP server.',
72
+ ' pugi config mcp list Show declared MCP servers + trust state.',
73
+ ' pugi config get routing Show effective routing table (defaults + tenant overrides).',
74
+ ' pugi config set routing.<tag>.<budget>=<model> Override the model for one (tag, budget) lane.',
75
+ ' pugi config unset routing.<tag>.<budget> Remove a routing override (revert to default).',
76
+ ' pugi config get privacy Show current tenant privacy mode + last-flip metadata.',
77
+ ' pugi config set privacy=<mode> Flip privacy mode (strict | balanced | permissive).',
66
78
  ].join('\n'));
67
79
  return;
68
80
  }
69
81
  switch (sub) {
70
82
  case 'get':
83
+ // Special form: `pugi config get routing` hits the admin-api surface,
84
+ // not the local config file. Anything else is a local-config read.
85
+ if (args[1] === 'routing') {
86
+ return runRoutingGet(ctx);
87
+ }
88
+ // alpha 6.13: `pugi config get privacy` hits the admin-api
89
+ // /api/admin/privacy/mode surface. The privacy mode is a tenant-
90
+ // scoped server-side setting (so the Anvil filter can enforce it),
91
+ // not a local-only preference.
92
+ if (args[1] === 'privacy') {
93
+ return runPrivacyGet(ctx);
94
+ }
71
95
  return runConfigGet(args.slice(1), ctx);
72
96
  case 'set':
97
+ // Special form: `pugi config set routing.<tag>.<budget>=<model>` hits
98
+ // the admin-api routing override surface.
99
+ if (args[1] && args[1].startsWith('routing.')) {
100
+ return runRoutingSet(args.slice(1), ctx);
101
+ }
102
+ // alpha 6.13: `pugi config set privacy=<mode>` (or `privacy <mode>`)
103
+ // flips the tenant-scoped server-side mode IFF <mode> is one of
104
+ // the new closed set (strict | balanced | permissive). Legacy
105
+ // values (local-only | metadata | full) still hit the local
106
+ // config schema via runConfigSet so existing operators do not
107
+ // get a surprise 4xx when their playbook still uses the old
108
+ // names. The unit spec for config.ts has a regression test for
109
+ // both code paths.
110
+ //
111
+ // Triple-review P2 fix (2026-05-25): the prior disambiguation
112
+ // only excluded the bare form (`privacy local-only`) - the `=`
113
+ // form (`privacy=local-only`) still routed to runPrivacySet and
114
+ // 4xx'd. We now check the value AFTER `=` and route legacy local
115
+ // values to runConfigSet; new-style values + unknown values
116
+ // continue to runPrivacySet so its client-side validator surfaces
117
+ // a structured "Unknown privacy mode" error (preserves the
118
+ // existing UX for typos like `privacy=paranoid`).
119
+ if (args[1]) {
120
+ const equalsForm = args[1].startsWith('privacy=');
121
+ const bareForm = args[1] === 'privacy';
122
+ if (equalsForm) {
123
+ const valueAfterEquals = args[1].slice('privacy='.length);
124
+ if (isLegacyLocalPrivacyValue(valueAfterEquals)) {
125
+ // Legacy local form (`privacy=local-only|metadata|full`) -
126
+ // split into `['privacy', '<value>']` so runConfigSet sees
127
+ // the same shape it would for the bare form.
128
+ return runConfigSet(['privacy', valueAfterEquals], ctx);
129
+ }
130
+ return runPrivacySet(args.slice(1), ctx);
131
+ }
132
+ if (bareForm && isNewPrivacyModeValue(args[2])) {
133
+ return runPrivacySet(args.slice(1), ctx);
134
+ }
135
+ }
73
136
  return runConfigSet(args.slice(1), ctx);
137
+ case 'unset':
138
+ if (args[1] && args[1].startsWith('routing.')) {
139
+ return runRoutingUnset(args.slice(1), ctx);
140
+ }
141
+ throw new Error(`Unknown sub-command "pugi config unset ${args[1] ?? ''}". Only routing.<tag>.<budget> is supported today.`);
74
142
  case 'list':
75
143
  return runConfigList(ctx);
76
144
  case 'trust':
@@ -78,7 +146,7 @@ export async function runConfigCommand(args, ctx) {
78
146
  case 'mcp':
79
147
  return runConfigMcp(args.slice(1), ctx);
80
148
  default:
81
- throw new Error(`Unknown sub-command "pugi config ${sub}". Expected get, set, list, trust, or mcp.`);
149
+ throw new Error(`Unknown sub-command "pugi config ${sub}". Expected get, set, unset, list, trust, or mcp.`);
82
150
  }
83
151
  }
84
152
  function configPath() {
@@ -228,4 +296,266 @@ async function runConfigMcpFlip(args, ctx, state) {
228
296
  ? `MCP server "${name}" is now trusted.`
229
297
  : `MCP server "${name}" is now denied.`);
230
298
  }
299
+ /* ------------------------------------------------------------------ */
300
+ /* α6.10 multi-model routing — config.routing.* subcommands */
301
+ /* ------------------------------------------------------------------ */
302
+ /**
303
+ * Closed sets — match
304
+ * `apps/admin-api/src/mira/routing/dispatch-tag.ts` verbatim. Pinning
305
+ * them in the CLI lets us reject typos client-side before round-tripping
306
+ * to the admin-api (better UX, smaller blast radius for a wrong typo on
307
+ * a flaky network).
308
+ */
309
+ const ROUTING_TAGS = [
310
+ 'classify',
311
+ 'reason',
312
+ 'codegen',
313
+ 'summarize',
314
+ 'vision',
315
+ 'embed',
316
+ ];
317
+ const ROUTING_BUDGETS = ['min', 'std', 'max'];
318
+ function isRoutingTag(value) {
319
+ return ROUTING_TAGS.includes(value);
320
+ }
321
+ function isRoutingBudget(value) {
322
+ return ROUTING_BUDGETS.includes(value);
323
+ }
324
+ /**
325
+ * Resolve the admin-api host + bearer token from the CLI credential
326
+ * store. Throws a structured "anonymous" error when the operator has
327
+ * not logged in — same shape as `pugi whoami` so the harness exit
328
+ * codes stay aligned.
329
+ */
330
+ function resolveAdminApi() {
331
+ const credential = resolveActiveCredential();
332
+ if (!credential) {
333
+ throw new Error('pugi config routing requires authentication. Run `pugi login` first.');
334
+ }
335
+ return { apiUrl: credential.apiUrl, apiKey: credential.apiKey };
336
+ }
337
+ /**
338
+ * `pugi config get routing` — fetch the static default table + the
339
+ * tenant's override table, merge, and render as `routing.<tag>.<budget> = <model>`.
340
+ * Shows which lanes are overridden vs default.
341
+ */
342
+ async function runRoutingGet(ctx) {
343
+ const { apiUrl, apiKey } = resolveAdminApi();
344
+ const [defaults, overrides] = await Promise.all([
345
+ fetchJson(`${apiUrl}/api/admin/model-routing/defaults`, apiKey),
346
+ fetchJson(`${apiUrl}/api/admin/model-routing/overrides`, apiKey),
347
+ ]);
348
+ const overrideMap = new Map();
349
+ for (const row of overrides.overrides) {
350
+ overrideMap.set(`${row.tag}.${row.budgetHint}`, row.model);
351
+ }
352
+ const cells = defaults.defaults.map((cell) => {
353
+ const overridden = overrideMap.get(`${cell.tag}.${cell.budgetHint}`);
354
+ return {
355
+ tag: cell.tag,
356
+ budgetHint: cell.budgetHint,
357
+ model: overridden ?? cell.model,
358
+ source: overridden ? 'override' : 'default',
359
+ };
360
+ });
361
+ const text = [
362
+ 'Routing table (effective = override | default):',
363
+ ...cells.map((cell) => ` routing.${cell.tag.padEnd(10)}.${cell.budgetHint.padEnd(3)} = ${cell.model.padEnd(28)} (${cell.source})`),
364
+ ].join('\n');
365
+ ctx.writeOutput({
366
+ command: 'config.routing.get',
367
+ apiUrl,
368
+ cells,
369
+ }, text);
370
+ }
371
+ /**
372
+ * `pugi config set routing.<tag>.<budget>=<model>` — PUT to the admin-api
373
+ * override surface. Validates tag + budget client-side before round-tripping
374
+ * so a typo fails fast.
375
+ */
376
+ async function runRoutingSet(args, ctx) {
377
+ // The original arg is `routing.<tag>.<budget>=<model>` — args[0] holds
378
+ // everything up to the first whitespace, but the value may have been
379
+ // split. Re-join and re-split on `=` to be robust against a
380
+ // model slug containing `/` or `-`.
381
+ const raw = args.join(' ').trim();
382
+ const eqIdx = raw.indexOf('=');
383
+ if (eqIdx === -1) {
384
+ throw new Error('pugi config set routing.<tag>.<budget>=<model> requires an =<model> suffix.');
385
+ }
386
+ const lhs = raw.slice(0, eqIdx).trim();
387
+ const value = raw.slice(eqIdx + 1).trim();
388
+ const lhsParts = lhs.split('.');
389
+ if (lhsParts.length !== 3 || lhsParts[0] !== 'routing') {
390
+ throw new Error(`Expected routing.<tag>.<budget>, got "${lhs}".`);
391
+ }
392
+ const tag = lhsParts[1] ?? '';
393
+ const budget = lhsParts[2] ?? '';
394
+ if (!isRoutingTag(tag)) {
395
+ throw new Error(`Unknown routing tag "${tag}". Allowed: ${ROUTING_TAGS.join(', ')}.`);
396
+ }
397
+ if (!isRoutingBudget(budget)) {
398
+ throw new Error(`Unknown routing budget "${budget}". Allowed: ${ROUTING_BUDGETS.join(', ')}.`);
399
+ }
400
+ if (value.length === 0) {
401
+ throw new Error('Model slug must be non-empty.');
402
+ }
403
+ const { apiUrl, apiKey } = resolveAdminApi();
404
+ await fetchJson(`${apiUrl}/api/admin/model-routing/overrides/${tag}/${budget}`, apiKey, { method: 'PUT', body: { model: value } });
405
+ ctx.writeOutput({
406
+ command: 'config.routing.set',
407
+ tag,
408
+ budget,
409
+ model: value,
410
+ }, `routing.${tag}.${budget} = ${value}`);
411
+ }
412
+ /**
413
+ * `pugi config unset routing.<tag>.<budget>` — DELETE the override and
414
+ * revert the lane to its static default. Idempotent.
415
+ */
416
+ async function runRoutingUnset(args, ctx) {
417
+ const lhs = (args[0] ?? '').trim();
418
+ const lhsParts = lhs.split('.');
419
+ if (lhsParts.length !== 3 || lhsParts[0] !== 'routing') {
420
+ throw new Error(`Expected routing.<tag>.<budget>, got "${lhs}".`);
421
+ }
422
+ const tag = lhsParts[1] ?? '';
423
+ const budget = lhsParts[2] ?? '';
424
+ if (!isRoutingTag(tag)) {
425
+ throw new Error(`Unknown routing tag "${tag}". Allowed: ${ROUTING_TAGS.join(', ')}.`);
426
+ }
427
+ if (!isRoutingBudget(budget)) {
428
+ throw new Error(`Unknown routing budget "${budget}". Allowed: ${ROUTING_BUDGETS.join(', ')}.`);
429
+ }
430
+ const { apiUrl, apiKey } = resolveAdminApi();
431
+ const result = await fetchJson(`${apiUrl}/api/admin/model-routing/overrides/${tag}/${budget}`, apiKey, { method: 'DELETE' });
432
+ ctx.writeOutput({
433
+ command: 'config.routing.unset',
434
+ tag,
435
+ budget,
436
+ removed: result.removed,
437
+ }, result.removed
438
+ ? `routing.${tag}.${budget} reverted to default.`
439
+ : `routing.${tag}.${budget} had no override (nothing to remove).`);
440
+ }
441
+ /* ------------------------------------------------------------------ */
442
+ /* alpha 6.13 privacy 3-mode - config.privacy.* subcommands */
443
+ /* ------------------------------------------------------------------ */
444
+ /**
445
+ * Closed mirror of the server-side PRIVACY_MODES enum
446
+ * (apps/admin-api/src/privacy/privacy-mode.ts). Pinning the literal
447
+ * set here lets the CLI reject typos client-side before the round-
448
+ * trip to admin-api (better UX, smaller blast radius on a flaky
449
+ * network).
450
+ */
451
+ const PRIVACY_MODES = ['strict', 'balanced', 'permissive'];
452
+ function isNewPrivacyModeValue(value) {
453
+ return typeof value === 'string' && PRIVACY_MODES.includes(value);
454
+ }
455
+ function isPrivacyMode(value) {
456
+ return PRIVACY_MODES.includes(value);
457
+ }
458
+ /**
459
+ * Legacy local-config privacy values from before alpha 6.13. Kept so
460
+ * `pugi config set privacy=local-only` continues to write to the local
461
+ * config file (matching the bare-form behaviour). Triple-review P2 fix
462
+ * (2026-05-25): the prior disambiguation only excluded the bare form;
463
+ * the `=` form routed to runPrivacySet and 4xx'd on the unknown mode.
464
+ */
465
+ const LEGACY_LOCAL_PRIVACY_VALUES = [
466
+ 'local-only',
467
+ 'metadata',
468
+ 'full',
469
+ ];
470
+ function isLegacyLocalPrivacyValue(value) {
471
+ return (typeof value === 'string' &&
472
+ LEGACY_LOCAL_PRIVACY_VALUES.includes(value));
473
+ }
474
+ /**
475
+ * `pugi config get privacy` - fetch the current privacy mode snapshot
476
+ * from /api/admin/privacy/mode + render it in human-readable form.
477
+ */
478
+ async function runPrivacyGet(ctx) {
479
+ const { apiUrl, apiKey } = resolveAdminApi();
480
+ const snapshot = await fetchJson(`${apiUrl}/api/admin/privacy/mode`, apiKey);
481
+ const lastUpdatedLine = snapshot.lastUpdated
482
+ ? `(last set by ${snapshot.lastUpdatedBy ?? 'unknown'} on ${snapshot.lastUpdated})`
483
+ : '(no flips recorded; on implicit default)';
484
+ const text = [
485
+ `privacy.mode = ${snapshot.mode}`,
486
+ `privacy.defaultMode = ${snapshot.defaultMode}`,
487
+ lastUpdatedLine,
488
+ ].join('\n');
489
+ ctx.writeOutput({
490
+ command: 'config.privacy.get',
491
+ apiUrl,
492
+ snapshot,
493
+ }, text);
494
+ }
495
+ /**
496
+ * `pugi config set privacy=<mode>` - PUT to /api/admin/privacy/mode.
497
+ * Validates the mode client-side against the closed set before
498
+ * round-tripping.
499
+ *
500
+ * Accepts both `privacy <mode>` and `privacy=<mode>` argument forms so
501
+ * the operator can type it either way.
502
+ */
503
+ async function runPrivacySet(args, ctx) {
504
+ const raw = args.join(' ').trim();
505
+ let mode;
506
+ if (raw.startsWith('privacy=')) {
507
+ mode = raw.slice('privacy='.length).trim();
508
+ }
509
+ else if (raw === 'privacy') {
510
+ throw new Error('pugi config set privacy requires a mode. Try: pugi config set privacy=balanced');
511
+ }
512
+ else if (raw.startsWith('privacy ')) {
513
+ mode = raw.slice('privacy '.length).trim();
514
+ }
515
+ else {
516
+ throw new Error(`pugi config set privacy: unrecognised argument "${raw}". Try: pugi config set privacy=balanced`);
517
+ }
518
+ if (mode.length === 0) {
519
+ throw new Error('pugi config set privacy requires a mode. Try: pugi config set privacy=balanced');
520
+ }
521
+ if (!isPrivacyMode(mode)) {
522
+ throw new Error(`Unknown privacy mode "${mode}". Allowed: ${PRIVACY_MODES.join(', ')}.`);
523
+ }
524
+ const { apiUrl, apiKey } = resolveAdminApi();
525
+ const snapshot = await fetchJson(`${apiUrl}/api/admin/privacy/mode`, apiKey, { method: 'PUT', body: { mode } });
526
+ ctx.writeOutput({
527
+ command: 'config.privacy.set',
528
+ mode: snapshot.mode,
529
+ snapshot,
530
+ }, `privacy.mode = ${snapshot.mode}`);
531
+ }
532
+ /**
533
+ * Thin authenticated fetch helper. Adds the bearer token + accepts JSON +
534
+ * surfaces structured errors. Uses undici `request` (not native `fetch`)
535
+ * because the test suite intercepts via `MockAgent` + `setGlobalDispatcher`
536
+ * — undici's `request` honours the global dispatcher reliably across the
537
+ * pinned undici version. Kept local (not shared with `pugi whoami`) so
538
+ * the routing surface is self-contained — extracting a common helper is
539
+ * α6.10b cleanup once we see two callers.
540
+ */
541
+ async function fetchJson(url, apiKey, options = {}) {
542
+ const method = options.method ?? 'GET';
543
+ const headers = {
544
+ authorization: `Bearer ${apiKey}`,
545
+ accept: 'application/json',
546
+ };
547
+ let body;
548
+ if (options.body !== undefined) {
549
+ body = JSON.stringify(options.body);
550
+ headers['content-type'] = 'application/json';
551
+ }
552
+ const res = await request(url, { method, headers, body });
553
+ if (res.statusCode < 200 || res.statusCode >= 300) {
554
+ const detail = await res.body.text().catch(() => '');
555
+ throw new Error(`pugi config routing: HTTP ${res.statusCode} on ${method} ${url}${detail ? ` -- ${detail.slice(0, 200)}` : ''}`);
556
+ }
557
+ // undici's `request` already returns `body` as a stream wrapped with
558
+ // helpers — `.json()` parses + closes for us.
559
+ return (await res.body.json());
560
+ }
231
561
  //# sourceMappingURL=config.js.map
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Run the delegate command. Parses args, validates the brief, opens a
3
+ * fresh session, and POSTs the delegate brief.
4
+ *
5
+ * Sets `process.exitCode` instead of throwing so the caller (cli.ts
6
+ * handlers table) does not need a try/catch wrapper.
7
+ */
8
+ export async function runDelegateCommand(args, ctx) {
9
+ // Extract the optional `--wait` flag from positional args. Accept it
10
+ // in any position so `pugi delegate --wait dev "..."` and
11
+ // `pugi delegate dev "..." --wait` both work; positional ordering of
12
+ // slug + brief is preserved by filtering the flag out before the
13
+ // slug/brief split (Claude P2 fix 2026-05-25).
14
+ const wait = args.some((a) => a === '--wait');
15
+ const positional = args.filter((a) => a !== '--wait');
16
+ const slug = positional[0];
17
+ const brief = positional.slice(1).join(' ').trim();
18
+ if (!slug || !brief) {
19
+ ctx.writeOutput({
20
+ ok: false,
21
+ error: 'Usage: pugi delegate [--wait] <persona-slug> "<one-sentence brief>"',
22
+ }, 'Usage: pugi delegate [--wait] <persona-slug> "<one-sentence brief>"');
23
+ process.exitCode = 2;
24
+ return;
25
+ }
26
+ // Brief size cap mirrors the server-side DTO (8000 chars). The local
27
+ // gate lets the operator know about the truncation before we waste a
28
+ // round-trip on a too-large brief.
29
+ if (brief.length > 8000) {
30
+ ctx.writeOutput({
31
+ ok: false,
32
+ error: `brief is ${brief.length} chars; max 8000`,
33
+ }, `pugi delegate: brief is ${brief.length} chars; max 8000.`);
34
+ process.exitCode = 2;
35
+ return;
36
+ }
37
+ const config = ctx.resolveConfig();
38
+ if (!config) {
39
+ ctx.writeOutput({
40
+ ok: false,
41
+ error: 'no Pugi credential configured; run `pugi login` first',
42
+ }, 'pugi delegate: no credential configured. Run `pugi login` first.');
43
+ process.exitCode = 1;
44
+ return;
45
+ }
46
+ const opened = await ctx.openSession(config, ctx.workspaceCwd);
47
+ if ('error' in opened) {
48
+ ctx.writeOutput({ ok: false, error: opened.error }, `pugi delegate: session open failed: ${opened.error}`);
49
+ process.exitCode = 1;
50
+ return;
51
+ }
52
+ const result = await ctx.submitDelegate(config, opened.sessionId, {
53
+ persona: slug,
54
+ brief,
55
+ });
56
+ switch (result.status) {
57
+ case 'ok': {
58
+ const ok = result.response;
59
+ if (!wait) {
60
+ ctx.writeOutput({
61
+ ok: true,
62
+ sessionId: opened.sessionId,
63
+ dispatchId: ok.dispatchId,
64
+ personaSlug: ok.personaSlug,
65
+ }, `dispatched ${ok.personaSlug} (dispatchId=${ok.dispatchId}); stream via GET /sessions/${opened.sessionId}/stream.`);
66
+ return;
67
+ }
68
+ // --wait: subscribe to SSE until the persona reaches a terminal
69
+ // event. The waiter contract is provider-shaped so tests can pass
70
+ // a fake without standing up fetch.
71
+ const waiter = ctx.waitForTerminal ?? waitForDelegateTerminal;
72
+ const outcome = await waiter(config, opened.sessionId, ok.personaSlug);
73
+ switch (outcome.kind) {
74
+ case 'completed':
75
+ ctx.writeOutput({
76
+ ok: true,
77
+ sessionId: opened.sessionId,
78
+ dispatchId: ok.dispatchId,
79
+ personaSlug: outcome.personaSlug,
80
+ status: 'completed',
81
+ }, `completed ${outcome.personaSlug} (dispatchId=${ok.dispatchId}).`);
82
+ return;
83
+ case 'blocked':
84
+ ctx.writeOutput({
85
+ ok: false,
86
+ sessionId: opened.sessionId,
87
+ dispatchId: ok.dispatchId,
88
+ personaSlug: outcome.personaSlug,
89
+ status: 'blocked',
90
+ detail: outcome.detail,
91
+ }, `blocked ${outcome.personaSlug}: ${outcome.detail}`);
92
+ process.exitCode = 5;
93
+ return;
94
+ case 'failed':
95
+ ctx.writeOutput({
96
+ ok: false,
97
+ sessionId: opened.sessionId,
98
+ dispatchId: ok.dispatchId,
99
+ personaSlug: outcome.personaSlug,
100
+ status: 'failed',
101
+ error: outcome.error,
102
+ }, `failed ${outcome.personaSlug}: ${outcome.error}`);
103
+ process.exitCode = 5;
104
+ return;
105
+ case 'stream_error':
106
+ ctx.writeOutput({
107
+ ok: false,
108
+ sessionId: opened.sessionId,
109
+ dispatchId: ok.dispatchId,
110
+ error: outcome.error,
111
+ }, `pugi delegate --wait: ${outcome.error}`);
112
+ process.exitCode = 1;
113
+ return;
114
+ }
115
+ return;
116
+ }
117
+ case 'unknown_persona':
118
+ ctx.writeOutput({ ok: false, error: result.message, code: result.code }, `pugi delegate: ${result.message}`);
119
+ process.exitCode = 3;
120
+ return;
121
+ case 'quota_exceeded':
122
+ ctx.writeOutput({ ok: false, error: result.message, code: result.code }, `pugi delegate: ${result.message}`);
123
+ process.exitCode = 4;
124
+ return;
125
+ case 'endpoint_missing':
126
+ ctx.writeOutput({
127
+ ok: false,
128
+ error: 'runtime does not expose POST /api/pugi/sessions/:id/delegate (upgrade admin-api to α7.5+)',
129
+ code: result.code,
130
+ }, 'pugi delegate: runtime does not expose the delegate endpoint. Upgrade admin-api to α7.5+.');
131
+ process.exitCode = 1;
132
+ return;
133
+ case 'unauthenticated':
134
+ case 'failed':
135
+ ctx.writeOutput({ ok: false, error: result.message, code: result.code }, `pugi delegate: ${result.message}`);
136
+ process.exitCode = 1;
137
+ return;
138
+ }
139
+ }
140
+ /* ------------------------------------------------------------------ */
141
+ /* --wait waiter */
142
+ /* ------------------------------------------------------------------ */
143
+ /**
144
+ * Subscribe to the session SSE stream and resolve when the named
145
+ * persona reaches a terminal event (agent.completed / agent.blocked /
146
+ * agent.failed). Falls back to a `stream_error` outcome when the
147
+ * stream closes before any terminal event arrives or when the
148
+ * underlying fetch fails.
149
+ *
150
+ * The waiter is intentionally a single-call helper (not a long-lived
151
+ * subscriber) - the scripted `pugi delegate --wait` caller wants to
152
+ * exit on the first terminal event for THIS persona, not maintain a
153
+ * persistent connection.
154
+ *
155
+ * Why we parse the lifecycle in this file (not via the repl-render
156
+ * subscribe helper): repl-render carries Ink/React deps the
157
+ * non-interactive delegate path does not want to load. Duplicating the
158
+ * minimal SSE-line parser here keeps the command lean (this is the same
159
+ * shape `apps/admin-api/src/pugi/sessions.controller.ts` writes to the
160
+ * wire).
161
+ */
162
+ export async function waitForDelegateTerminal(config, sessionId, targetPersonaSlug) {
163
+ const url = `${config.apiUrl.replace(/\/+$/, '')}/api/pugi/sessions/${encodeURIComponent(sessionId)}/stream`;
164
+ const controller = new AbortController();
165
+ // Hard-cap the waiter at 5 minutes so a stalled server cannot keep
166
+ // a scripted caller hanging forever; the dispatcher's per-turn budget
167
+ // is well under this.
168
+ const timeout = setTimeout(() => controller.abort(), 5 * 60 * 1000);
169
+ try {
170
+ const response = await fetch(url, {
171
+ method: 'GET',
172
+ headers: {
173
+ Accept: 'text/event-stream',
174
+ Authorization: `Bearer ${config.apiKey}`,
175
+ },
176
+ signal: controller.signal,
177
+ });
178
+ if (!response.ok) {
179
+ return { kind: 'stream_error', error: `HTTP ${response.status}` };
180
+ }
181
+ if (!response.body) {
182
+ return { kind: 'stream_error', error: 'no response body' };
183
+ }
184
+ // Track the most-recent persona slug seen on agent.spawned so the
185
+ // terminal event (which carries only taskId) can be matched to the
186
+ // delegate target without parsing the random nonce suffix.
187
+ const taskToPersona = new Map();
188
+ const reader = response.body.getReader();
189
+ const decoder = new TextDecoder('utf-8');
190
+ let buffer = '';
191
+ let currentData = '';
192
+ while (true) {
193
+ const { value, done } = await reader.read();
194
+ if (done) {
195
+ return { kind: 'stream_error', error: 'stream ended without terminal event' };
196
+ }
197
+ buffer += decoder.decode(value, { stream: true });
198
+ let newlineIndex;
199
+ while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
200
+ const rawLine = buffer.slice(0, newlineIndex).replace(/\r$/, '');
201
+ buffer = buffer.slice(newlineIndex + 1);
202
+ if (rawLine.length === 0) {
203
+ if (currentData.length > 0) {
204
+ const parsed = parseDelegateFrame(currentData);
205
+ if (parsed) {
206
+ if (parsed.type === 'agent.spawned') {
207
+ taskToPersona.set(parsed.taskId, parsed.personaSlug);
208
+ }
209
+ else if (parsed.type === 'agent.completed' ||
210
+ parsed.type === 'agent.blocked' ||
211
+ parsed.type === 'agent.failed') {
212
+ const slug = taskToPersona.get(parsed.taskId) ?? targetPersonaSlug;
213
+ if (slug === targetPersonaSlug) {
214
+ controller.abort();
215
+ if (parsed.type === 'agent.completed') {
216
+ return {
217
+ kind: 'completed',
218
+ personaSlug: slug,
219
+ taskId: parsed.taskId,
220
+ };
221
+ }
222
+ if (parsed.type === 'agent.blocked') {
223
+ return {
224
+ kind: 'blocked',
225
+ personaSlug: slug,
226
+ taskId: parsed.taskId,
227
+ detail: parsed.detail,
228
+ };
229
+ }
230
+ return {
231
+ kind: 'failed',
232
+ personaSlug: slug,
233
+ taskId: parsed.taskId,
234
+ error: parsed.error,
235
+ };
236
+ }
237
+ }
238
+ }
239
+ }
240
+ currentData = '';
241
+ continue;
242
+ }
243
+ if (rawLine.startsWith(':'))
244
+ continue;
245
+ const colonIndex = rawLine.indexOf(':');
246
+ const field = colonIndex === -1 ? rawLine : rawLine.slice(0, colonIndex);
247
+ const value = colonIndex === -1 ? '' : rawLine.slice(colonIndex + 1).replace(/^ /, '');
248
+ if (field === 'data') {
249
+ currentData = currentData.length === 0 ? value : `${currentData}\n${value}`;
250
+ }
251
+ }
252
+ }
253
+ }
254
+ catch (err) {
255
+ if (controller.signal.aborted) {
256
+ return { kind: 'stream_error', error: 'wait timed out' };
257
+ }
258
+ return { kind: 'stream_error', error: err.message };
259
+ }
260
+ finally {
261
+ clearTimeout(timeout);
262
+ }
263
+ }
264
+ function parseDelegateFrame(data) {
265
+ try {
266
+ const raw = JSON.parse(data);
267
+ const type = raw.type;
268
+ const taskId = typeof raw.taskId === 'string' ? raw.taskId : null;
269
+ if (!taskId)
270
+ return null;
271
+ if (type === 'agent.spawned' && typeof raw.personaSlug === 'string') {
272
+ return { type, taskId, personaSlug: raw.personaSlug };
273
+ }
274
+ if (type === 'agent.completed') {
275
+ return { type, taskId };
276
+ }
277
+ if (type === 'agent.blocked') {
278
+ return { type, taskId, detail: typeof raw.detail === 'string' ? raw.detail : '' };
279
+ }
280
+ if (type === 'agent.failed') {
281
+ return { type, taskId, error: typeof raw.error === 'string' ? raw.error : '' };
282
+ }
283
+ return null;
284
+ }
285
+ catch {
286
+ return null;
287
+ }
288
+ }
289
+ //# sourceMappingURL=delegate.js.map