@pugi/cli 0.1.0-beta.2 → 0.1.0-beta.20

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 (130) hide show
  1. package/THIRD_PARTY_NOTICES.md +40 -0
  2. package/assets/pugi-mascot.ansi +15 -40
  3. package/bin/run.js +33 -1
  4. package/dist/commands/jobs-watch.js +201 -0
  5. package/dist/commands/jobs.js +15 -0
  6. package/dist/core/agent-progress/cleanup.js +134 -0
  7. package/dist/core/agent-progress/schema.js +144 -0
  8. package/dist/core/agent-progress/writer.js +101 -0
  9. package/dist/core/compact/auto-trigger.js +96 -0
  10. package/dist/core/compact/buffer-rewriter.js +115 -0
  11. package/dist/core/compact/summarizer.js +196 -0
  12. package/dist/core/compact/token-counter.js +108 -0
  13. package/dist/core/consensus/diff-capture.js +73 -0
  14. package/dist/core/context/index.js +7 -0
  15. package/dist/core/context/markdown-traverse.js +255 -0
  16. package/dist/core/cost/rate-card.js +129 -0
  17. package/dist/core/cost/tracker.js +221 -0
  18. package/dist/core/denial-tracking/index.js +8 -0
  19. package/dist/core/denial-tracking/state.js +264 -0
  20. package/dist/core/diagnostics/probe-runner.js +93 -0
  21. package/dist/core/diagnostics/probes/api.js +46 -0
  22. package/dist/core/diagnostics/probes/auth.js +86 -0
  23. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  24. package/dist/core/diagnostics/probes/config.js +72 -0
  25. package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
  26. package/dist/core/diagnostics/probes/disk.js +81 -0
  27. package/dist/core/diagnostics/probes/git.js +65 -0
  28. package/dist/core/diagnostics/probes/mcp.js +75 -0
  29. package/dist/core/diagnostics/probes/node.js +59 -0
  30. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  31. package/dist/core/diagnostics/probes/session.js +74 -0
  32. package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
  33. package/dist/core/diagnostics/probes/workspace.js +63 -0
  34. package/dist/core/diagnostics/types.js +70 -0
  35. package/dist/core/edits/dispatch.js +218 -2
  36. package/dist/core/edits/journal.js +199 -0
  37. package/dist/core/edits/layer-d-ast.js +557 -14
  38. package/dist/core/edits/verify-hook.js +273 -0
  39. package/dist/core/edits/worktree.js +111 -18
  40. package/dist/core/engine/anvil-client.js +115 -5
  41. package/dist/core/engine/budgets.js +89 -0
  42. package/dist/core/engine/context-prefix.js +155 -0
  43. package/dist/core/engine/intent.js +260 -0
  44. package/dist/core/engine/native-pugi.js +744 -210
  45. package/dist/core/engine/prompts.js +61 -6
  46. package/dist/core/engine/strip-internal-fields.js +124 -0
  47. package/dist/core/engine/tool-bridge.js +818 -31
  48. package/dist/core/file-cache.js +113 -1
  49. package/dist/core/init/scaffold.js +195 -0
  50. package/dist/core/lsp/client.js +174 -29
  51. package/dist/core/mcp/client.js +75 -6
  52. package/dist/core/mcp/http-server.js +553 -0
  53. package/dist/core/mcp/permission.js +190 -0
  54. package/dist/core/mcp/registry.js +24 -2
  55. package/dist/core/mcp/server-tools.js +219 -0
  56. package/dist/core/mcp/server.js +397 -0
  57. package/dist/core/permissions/gate.js +187 -0
  58. package/dist/core/permissions/index.js +18 -0
  59. package/dist/core/permissions/mode.js +102 -0
  60. package/dist/core/permissions/state.js +160 -0
  61. package/dist/core/permissions/tool-class.js +93 -0
  62. package/dist/core/repl/codebase-survey.js +308 -0
  63. package/dist/core/repl/history.js +11 -1
  64. package/dist/core/repl/init-interview.js +457 -0
  65. package/dist/core/repl/model-pricing.js +135 -0
  66. package/dist/core/repl/onboarding-state.js +297 -0
  67. package/dist/core/repl/session.js +719 -29
  68. package/dist/core/repl/slash-commands.js +133 -9
  69. package/dist/core/retry-budget/budget.js +284 -0
  70. package/dist/core/retry-budget/index.js +5 -0
  71. package/dist/core/settings.js +71 -0
  72. package/dist/core/skills/defaults.js +457 -0
  73. package/dist/core/subagents/dispatcher-real.js +600 -0
  74. package/dist/core/subagents/dispatcher.js +113 -24
  75. package/dist/core/subagents/index.js +18 -5
  76. package/dist/core/subagents/isolation-matrix.js +213 -0
  77. package/dist/core/subagents/spawn.js +19 -4
  78. package/dist/core/transport/version-interceptor.js +166 -0
  79. package/dist/index.js +28 -0
  80. package/dist/runtime/bootstrap.js +190 -0
  81. package/dist/runtime/cli.js +1588 -266
  82. package/dist/runtime/commands/compact.js +296 -0
  83. package/dist/runtime/commands/cost.js +199 -0
  84. package/dist/runtime/commands/delegate.js +289 -0
  85. package/dist/runtime/commands/doctor.js +369 -0
  86. package/dist/runtime/commands/lsp.js +187 -5
  87. package/dist/runtime/commands/mcp.js +824 -0
  88. package/dist/runtime/commands/patch.js +17 -0
  89. package/dist/runtime/commands/permissions.js +87 -0
  90. package/dist/runtime/commands/report.js +299 -0
  91. package/dist/runtime/commands/review-consensus.js +17 -2
  92. package/dist/runtime/commands/roster.js +117 -0
  93. package/dist/runtime/commands/status.js +178 -0
  94. package/dist/runtime/commands/worktree.js +50 -6
  95. package/dist/runtime/headless.js +543 -0
  96. package/dist/runtime/load-hooks-or-exit.js +71 -0
  97. package/dist/runtime/plan-decompose.js +531 -0
  98. package/dist/runtime/version.js +65 -0
  99. package/dist/tools/agent-tool.js +206 -0
  100. package/dist/tools/apply-patch.js +281 -39
  101. package/dist/tools/ask-user-question.js +213 -0
  102. package/dist/tools/ask-user.js +115 -0
  103. package/dist/tools/file-tools.js +85 -14
  104. package/dist/tools/mcp-tool.js +260 -0
  105. package/dist/tools/multi-edit.js +361 -0
  106. package/dist/tools/registry.js +22 -2
  107. package/dist/tools/skill-tool.js +96 -0
  108. package/dist/tools/tasks.js +208 -0
  109. package/dist/tools/web-fetch.js +147 -2
  110. package/dist/tools/web-search.js +458 -0
  111. package/dist/tui/agent-progress-card.js +111 -0
  112. package/dist/tui/agent-tree.js +10 -0
  113. package/dist/tui/ask-modal.js +2 -2
  114. package/dist/tui/ask-user-question-prompt.js +192 -0
  115. package/dist/tui/compact-banner.js +54 -0
  116. package/dist/tui/conversation-pane.js +69 -8
  117. package/dist/tui/cost-table.js +111 -0
  118. package/dist/tui/doctor-table.js +31 -0
  119. package/dist/tui/input-box.js +1 -1
  120. package/dist/tui/markdown-render.js +4 -4
  121. package/dist/tui/repl-render.js +276 -37
  122. package/dist/tui/repl-splash.js +2 -2
  123. package/dist/tui/repl.js +25 -6
  124. package/dist/tui/splash.js +1 -1
  125. package/dist/tui/status-bar.js +94 -16
  126. package/dist/tui/status-table.js +7 -0
  127. package/dist/tui/tool-stream-pane.js +7 -0
  128. package/dist/tui/update-banner.js +20 -2
  129. package/docs/examples/codegraph.mcp.json +10 -0
  130. package/package.json +9 -6
@@ -16,6 +16,8 @@
16
16
  * a tiny `event:`/`data:`/`id:` parser. This keeps the dependency
17
17
  * graph at zero new packages.
18
18
  */
19
+ import { existsSync } from 'node:fs';
20
+ import { resolve } from 'node:path';
19
21
  import React from 'react';
20
22
  import { render } from 'ink';
21
23
  import { Repl } from './repl.js';
@@ -24,6 +26,7 @@ import { ReplSession, } from '../core/repl/session.js';
24
26
  import { resolveWorkspaceContext } from '../core/repl/workspace-context.js';
25
27
  import { SqliteSessionStore } from '../core/repl/store/index.js';
26
28
  import { slugForCwd } from '../core/repl/history.js';
29
+ import { loadSettings } from '../core/settings.js';
27
30
  import { WorkingSet, buildRepoSkeleton, loadPugiIgnore, PugiWatcher, } from '../core/context/index.js';
28
31
  /**
29
32
  * Mount the REPL and resolve when the user exits via Ctrl+C × 2 or
@@ -31,12 +34,77 @@ import { WorkingSet, buildRepoSkeleton, loadPugiIgnore, PugiWatcher, } from '../
31
34
  * `pugi resume <sessionId>` once that command exists).
32
35
  */
33
36
  export async function renderRepl(options) {
37
+ // beta.9 CEO dogfood 2026-05-26: claim stdin raw mode + alt-screen
38
+ // BEFORE any async bootstrap step so keystrokes typed during the
39
+ // [launch -> Ink mount] window cannot echo into the terminal in
40
+ // cooked mode. Previously openLocalStore (SQLite open) +
41
+ // bootstrapContext (chokidar start) could take hundreds of ms to
42
+ // multiple seconds on a fresh install / large repo; during that
43
+ // window stdin stayed in cooked mode and the terminal echoed
44
+ // every typed character literally onto the screen below the
45
+ // pre-printed mascot/header. The visible result was the operator's
46
+ // "ssssss" landing on the rendered status-bar bottom row (CEO
47
+ // screenshot 2026-05-26: beta.8 REPL bug 2).
48
+ //
49
+ // The claim is idempotent with Ink's own raw-mode enable: Ink
50
+ // ref-counts setRawMode calls, and Node's stdin.setRawMode is
51
+ // safe to call twice with the same value. The pre-Ink claim acts
52
+ // as a "raw-mode floor" - whatever Ink does after mount layers on
53
+ // top, and our finally{} restore drops the floor only after Ink
54
+ // has cleanly torn down (or never mounted on a bootstrap crash).
55
+ const bootstrap = claimTerminalForRepl();
56
+ // beta.13 auto-init wire (CEO dogfood 2026-05-26): scaffold the
57
+ // `.pugi/` workspace silently on REPL boot so launching `pugi` in a
58
+ // fresh cwd no longer demands an explicit `pugi init` round-trip.
59
+ // Idempotent — every helper inside scaffoldPugiWorkspace is a
60
+ // `*_IfMissing` write, so re-running over an existing workspace is
61
+ // a no-op. Fail-safe: any FS / perms error never blocks REPL launch.
62
+ // Operator escape hatch: PUGI_NO_AUTO_INIT=1.
63
+ //
64
+ // Beta.13 P2 fix 2026-05-26: gate the scaffold on project-root markers
65
+ // so launching `pugi` from `$HOME` / `/tmp` / arbitrary dirs does NOT
66
+ // sprinkle `.pugi/` directories all over the filesystem. The gate
67
+ // mirrors `isBoundWorkspace` from workspace-context.ts but also
68
+ // accepts non-JS roots (Cargo / pyproject / go.mod) because the CLI
69
+ // is language-agnostic and an operator working in a Rust repo deserves
70
+ // the same auto-init UX as a Node operator. Already-bound `.pugi/`
71
+ // dirs also opt back in so the scaffold can fill any missing
72
+ // sub-artifacts the operator deleted.
73
+ if (process.env.PUGI_NO_AUTO_INIT !== '1' && isProjectRoot(process.cwd())) {
74
+ try {
75
+ const { scaffoldPugiWorkspace } = await import('../runtime/cli.js');
76
+ await scaffoldPugiWorkspace({
77
+ cwd: process.cwd(),
78
+ noDefaults: true,
79
+ log: () => {
80
+ /* silent — never leak scaffold progress into the REPL alt-screen */
81
+ },
82
+ });
83
+ }
84
+ catch (err) {
85
+ // Fail-safe: read-only FS or perms error never blocks REPL launch.
86
+ // Beta.13 P2 fix 2026-05-26: bare-catch swallowed the diagnostic;
87
+ // surface it on stderr under PUGI_DEBUG=1 so operator-triage on
88
+ // "why isn't .pugi/ being created?" has a starting point without
89
+ // having to re-instrument the bootstrap.
90
+ if (process.env.PUGI_DEBUG === '1') {
91
+ const msg = err instanceof Error ? err.message : String(err);
92
+ process.stderr.write(`[pugi-debug] auto-init failed: ${msg}\n`);
93
+ }
94
+ }
95
+ }
34
96
  const transport = createProductionTransport();
35
97
  // Auto-bind the workspace context from process.cwd() so Mira knows
36
98
  // which repo the operator launched the CLI in. The resolver is
37
99
  // best-effort — any FS error falls back to a basename-only summary,
38
100
  // never blocks REPL launch. Wave 4 fix 2026-05-25.
39
101
  const workspace = options.workspace ?? resolveWorkspaceContext(process.cwd());
102
+ // Beta.13 P1 fix 2026-05-26: read `ui.cyberZoo` from
103
+ // `.pugi/settings.json` so the operator's splash posture flows to
104
+ // admin-api on session open. Without this, the renderer's `cyberZoo`
105
+ // parameter (added beta.13) was always defaulted to 'on' regardless
106
+ // of the operator's actual setting.
107
+ const cyberZoo = readCyberZooSetting(process.cwd());
40
108
  // α6.4: open the local SessionStore for `/resume` persistence. The
41
109
  // store lives under `~/.pugi/projects/<slug>/`; failure is fail-safe
42
110
  // — we log a one-line warning to stderr and continue with the REPL
@@ -64,6 +132,7 @@ export async function renderRepl(options) {
64
132
  cliVersion: options.cliVersion,
65
133
  transport,
66
134
  workspace,
135
+ cyberZoo,
67
136
  store,
68
137
  localSessionId: openedSessionId,
69
138
  repoSkeleton: skeleton,
@@ -86,27 +155,24 @@ export async function renderRepl(options) {
86
155
  // Kick off the connect; the Repl renders the connecting state until
87
156
  // the session pushes `connection: 'on_watch'` from the SSE onOpen.
88
157
  void session.start();
158
+ // beta.9: drain any keystrokes that landed in stdin between the
159
+ // pre-Ink raw-mode claim and now. Without this, the queued bytes
160
+ // would feed Ink's first useInput tick as a flood of "stale"
161
+ // characters once the InputBox mounts - the operator would see
162
+ // their pre-typed input materialise in the prompt as if they had
163
+ // typed it after the REPL became interactive. Idempotent: no-op
164
+ // when stdin is not a TTY or no bytes were buffered.
165
+ drainBufferedStdin(process.stdin);
89
166
  // α6.14.2 wave 5: paint the chafa-baked brand-pug ANSI render to
90
- // stdout BEFORE Ink mounts. Ink's layout engine would mis-measure
91
- // the truecolor escape sequences, so the pug must land verbatim.
92
- // The flag is passed into <Repl /> so the splash component knows to
93
- // skip its own hand-crafted PUG_MASCOT column — otherwise the
94
- // operator sees both the chafa pug AND the ASCII fallback stacked.
95
- // When skipSplash is true (operator opted out via --no-splash), we
96
- // suppress the pre-print too so the boot stays silent.
167
+ // stdout BEFORE Ink mounts (but AFTER alt-screen enter). Ink's
168
+ // layout engine would mis-measure the truecolor escape sequences,
169
+ // so the pug must land verbatim. The flag is passed into <Repl />
170
+ // so the splash component knows to skip its own hand-crafted
171
+ // PUG_MASCOT column - otherwise the operator sees both the chafa
172
+ // pug AND the ASCII fallback stacked. When skipSplash is true
173
+ // (operator opted out via --no-splash), we suppress the pre-print
174
+ // too so the boot stays silent.
97
175
  const mascotPrePrinted = options.skipSplash === true ? false : printPugMascotPreInk(process.stdout);
98
- // α6.14.4 CEO dogfood 2026-05-25 (parity with Claude Code): enter
99
- // the terminal's alternate screen buffer so the REPL renders on a
100
- // fresh "screen" the operator cannot scroll above. On exit, leave
101
- // restores the previous terminal contents — the conversation does
102
- // not pollute the operator's shell history. Skipped under --no-tty
103
- // and when stdout is not a TTY (pipe/CI), where the escapes would
104
- // appear as literal characters.
105
- const supportsAltScreen = process.stdout.isTTY === true;
106
- if (supportsAltScreen) {
107
- process.stdout.write('\x1b[?1049h');
108
- process.stdout.write('\x1b[H');
109
- }
110
176
  const instance = render(React.createElement(Repl, {
111
177
  session,
112
178
  updateBanner: options.updateBanner ?? null,
@@ -114,26 +180,16 @@ export async function renderRepl(options) {
114
180
  hideToolStream: options.hideToolStream === true,
115
181
  mascotPrePrinted,
116
182
  }));
117
- const restoreAltScreen = () => {
118
- if (supportsAltScreen) {
119
- try {
120
- process.stdout.write('\x1b[?1049l');
121
- }
122
- catch {
123
- /* shutdown race — terminal already detached */
124
- }
125
- }
126
- };
127
183
  // Make sure we leave the alt screen on abrupt exits too. Without
128
184
  // this the operator's shell stays "frozen" on the Pugi splash.
129
- process.once('exit', restoreAltScreen);
130
- process.once('SIGINT', restoreAltScreen);
131
- process.once('SIGTERM', restoreAltScreen);
185
+ process.once('exit', bootstrap.restore);
186
+ process.once('SIGINT', bootstrap.restore);
187
+ process.once('SIGTERM', bootstrap.restore);
132
188
  try {
133
189
  await instance.waitUntilExit();
134
190
  }
135
191
  finally {
136
- restoreAltScreen();
192
+ bootstrap.restore();
137
193
  session.close();
138
194
  if (store) {
139
195
  try {
@@ -153,6 +209,138 @@ export async function renderRepl(options) {
153
209
  }
154
210
  }
155
211
  }
212
+ export function claimTerminalForRepl(stdin = process.stdin, stdout = process.stdout) {
213
+ const isStdoutTty = stdout.isTTY === true;
214
+ const isStdinTty = stdin.isTTY === true && typeof stdin.setRawMode === 'function';
215
+ let altScreenEntered = false;
216
+ if (isStdoutTty) {
217
+ try {
218
+ stdout.write('\x1b[?1049h');
219
+ stdout.write('\x1b[H');
220
+ altScreenEntered = true;
221
+ }
222
+ catch {
223
+ /* terminal already detached */
224
+ }
225
+ }
226
+ let rawModeClaimed = false;
227
+ if (isStdinTty) {
228
+ try {
229
+ stdin.setEncoding('utf8');
230
+ stdin.setRawMode(true);
231
+ // Resume so the kernel actually delivers bytes to Node's event
232
+ // loop. Without resume, raw mode is set but data does not flow
233
+ // until something else (e.g. Ink) attaches a 'data' listener.
234
+ stdin.resume();
235
+ rawModeClaimed = true;
236
+ }
237
+ catch {
238
+ /* raw mode unsupported - the operator's shell still works */
239
+ }
240
+ }
241
+ let restored = false;
242
+ const restore = () => {
243
+ if (restored)
244
+ return;
245
+ restored = true;
246
+ if (rawModeClaimed && isStdinTty) {
247
+ try {
248
+ stdin.setRawMode(false);
249
+ }
250
+ catch {
251
+ /* terminal already detached */
252
+ }
253
+ }
254
+ if (altScreenEntered) {
255
+ try {
256
+ stdout.write('\x1b[?1049l');
257
+ }
258
+ catch {
259
+ /* shutdown race - terminal already detached */
260
+ }
261
+ }
262
+ };
263
+ return { altScreenEntered, rawModeClaimed, restore };
264
+ }
265
+ /**
266
+ * Read and discard any bytes buffered in stdin between
267
+ * `claimTerminalForRepl()` and the Ink mount. Returns the number of
268
+ * bytes drained so tests can assert the behaviour without intercepting
269
+ * the side effect.
270
+ *
271
+ * `stdin.read()` is a no-op when no data is buffered, so this is safe
272
+ * to call whether or not the operator actually typed during bootstrap.
273
+ * Wrapped in try/catch because a closed / piped stdin will throw on
274
+ * read in some Node versions.
275
+ */
276
+ export function drainBufferedStdin(stdin = process.stdin) {
277
+ if (stdin.isTTY !== true)
278
+ return 0;
279
+ try {
280
+ let bytesDrained = 0;
281
+ // Loop until read() returns null - readable streams may chunk
282
+ // buffered bytes across multiple read() calls when the operator
283
+ // typed faster than the kernel could deliver to Node's loop.
284
+ for (;;) {
285
+ const chunk = stdin.read();
286
+ if (chunk === null)
287
+ return bytesDrained;
288
+ bytesDrained += typeof chunk === 'string' ? chunk.length : chunk.byteLength;
289
+ }
290
+ }
291
+ catch {
292
+ return 0;
293
+ }
294
+ }
295
+ /**
296
+ * Project-root probe — beta.13 P2 fix 2026-05-26.
297
+ *
298
+ * Beta.13 auto-init was unconditional and silently created `.pugi/` in
299
+ * every cwd the REPL was launched from, including `$HOME` and `/tmp`.
300
+ * Operators who ran `pugi` to ask a quick question outside of any
301
+ * project ended up with stray `.pugi/` directories polluting their
302
+ * filesystem. The gate looks for any of six project-root markers
303
+ * before scaffolding:
304
+ *
305
+ * - `package.json` — JS / TS workspaces
306
+ * - `.git` — any cloned repo regardless of language
307
+ * - `.pugi` — already-bound Pugi workspace (re-scaffold
308
+ * fills any missing artifacts the operator
309
+ * deleted, idempotent over existing files)
310
+ * - `Cargo.toml` — Rust crates
311
+ * - `pyproject.toml` — Python projects (PEP 518)
312
+ * - `go.mod` — Go modules
313
+ *
314
+ * The probe is six cheap `existsSync` calls; the cost is negligible
315
+ * compared with the alt-screen + Ink mount that follows. Exported so a
316
+ * future unit spec can lock the contract.
317
+ */
318
+ export function isProjectRoot(cwd) {
319
+ // ESM static imports — `require()` is not defined in a `"type": "module"`
320
+ // bundle and would throw `ReferenceError: require is not defined` the
321
+ // moment the REPL bootstrap calls this gate. Beta.16 P0 fix 2026-05-27.
322
+ return (existsSync(resolve(cwd, 'package.json')) ||
323
+ existsSync(resolve(cwd, '.git')) ||
324
+ existsSync(resolve(cwd, '.pugi')) ||
325
+ existsSync(resolve(cwd, 'Cargo.toml')) ||
326
+ existsSync(resolve(cwd, 'pyproject.toml')) ||
327
+ existsSync(resolve(cwd, 'go.mod')));
328
+ }
329
+ /**
330
+ * Read the operator's cyber-zoo posture from `.pugi/settings.json`.
331
+ * Best-effort: when the file is missing / malformed, fall through to
332
+ * the historical 'on' default so the REPL never refuses to launch on
333
+ * a settings error. Beta.13 P1 fix 2026-05-26.
334
+ */
335
+ function readCyberZooSetting(cwd) {
336
+ try {
337
+ const settings = loadSettings(cwd);
338
+ return settings.ui?.cyberZoo ?? 'on';
339
+ }
340
+ catch {
341
+ return 'on';
342
+ }
343
+ }
156
344
  /**
157
345
  * Open the local SessionStore for the REPL bootstrap. Returns
158
346
  * `{ store: null, openedSessionId: undefined }` on any error so the
@@ -244,13 +432,20 @@ async function bootstrapContext(input) {
244
432
  /* ------------------------------------------------------------------ */
245
433
  /* Production transport */
246
434
  /* ------------------------------------------------------------------ */
247
- function createProductionTransport() {
435
+ export function createProductionTransport() {
248
436
  return {
249
- async createSession({ apiUrl, apiKey, workspace }) {
437
+ async createSession({ apiUrl, apiKey, workspace, cyberZoo }) {
250
438
  // Forward the workspace bundle in the POST body so admin-api can
251
439
  // surface `<workspace-context>` in Mira's prompt. Older admin-api
252
440
  // builds ignore unknown fields, so this stays forward-compatible.
253
441
  // Wave 4 fix 2026-05-25.
442
+ //
443
+ // Beta.13 P1 fix 2026-05-26: also forward `cyberZoo` so admin-api
444
+ // can render Mira's `<cyber-zoo>` marker matching the operator's
445
+ // `.pugi/settings.json::ui.cyberZoo` toggle instead of the
446
+ // historical 'on' default. Only included on the wire when set
447
+ // explicitly so a missing setting still survives older admin-api
448
+ // builds that do not declare the DTO field.
254
449
  const body = {};
255
450
  if (workspace?.workspaceCwd)
256
451
  body.workspaceCwd = workspace.workspaceCwd;
@@ -258,6 +453,8 @@ function createProductionTransport() {
258
453
  body.workspaceSlug = workspace.workspaceSlug;
259
454
  if (workspace?.workspaceSummary)
260
455
  body.workspaceSummary = workspace.workspaceSummary;
456
+ if (cyberZoo === 'on' || cyberZoo === 'off')
457
+ body.cyberZoo = cyberZoo;
261
458
  const response = await fetch(joinUrl(apiUrl, '/api/pugi/sessions'), {
262
459
  method: 'POST',
263
460
  headers: jsonHeaders(apiKey),
@@ -303,6 +500,31 @@ function createProductionTransport() {
303
500
  if (lastEventId) {
304
501
  headers['Last-Event-ID'] = lastEventId;
305
502
  }
503
+ // beta.9 CEO dogfood 2026-05-26: hard timeout on the SSE
504
+ // handshake so a CDN/proxy that buffers the response (or an
505
+ // admin-api that accepted the route but never flushed headers)
506
+ // cannot freeze the REPL in `connecting` forever. The 5s budget
507
+ // is generous - admin-api routinely responds in <500ms when
508
+ // healthy - but tight enough that an operator who launched
509
+ // `pugi` and is staring at the screen will see the status flip
510
+ // to `reconnecting` instead of an indefinite hang. The
511
+ // AbortController bound to the fetch aborts the in-flight
512
+ // request when the timer fires, which surfaces as an
513
+ // `AbortError` and routes through the existing onError handler
514
+ // (which calls scheduleReconnect via the session). The timer
515
+ // is cleared the moment onOpen fires so a slow-but-eventually-
516
+ // successful handshake still works.
517
+ const handshakeDeadlineMs = 5_000;
518
+ const handshakeTimer = setTimeout(() => {
519
+ controller.abort();
520
+ // onError is called from the catch block below (the abort
521
+ // synthesises an AbortError that consumeSseStream / fetch
522
+ // will throw). No explicit onError call here - we let the
523
+ // catch path normalise the error message so the operator
524
+ // sees the consistent "SSE handshake timed out (5s)" prose
525
+ // through the same plumbing that surfaces every other
526
+ // transport failure.
527
+ }, handshakeDeadlineMs);
306
528
  void (async () => {
307
529
  try {
308
530
  const response = await fetch(url, {
@@ -316,6 +538,9 @@ function createProductionTransport() {
316
538
  if (!response.body) {
317
539
  throw new Error('SSE response has no body');
318
540
  }
541
+ // Handshake survived; cancel the deadline so a slow
542
+ // first-event stream does not get aborted later.
543
+ clearTimeout(handshakeTimer);
319
544
  onOpen();
320
545
  await consumeSseStream(response.body, onEvent);
321
546
  // Server closed the stream cleanly. Treat as an error so
@@ -325,13 +550,27 @@ function createProductionTransport() {
325
550
  onError(new Error('SSE stream ended'));
326
551
  }
327
552
  catch (error) {
328
- if (controller.signal.aborted)
553
+ clearTimeout(handshakeTimer);
554
+ if (controller.signal.aborted) {
555
+ // Distinguish operator-driven close (session.close())
556
+ // from the handshake-deadline abort. The session sets a
557
+ // `closed` flag before calling controller.abort(); the
558
+ // handshake-deadline abort fires while the session is
559
+ // still expecting onOpen. We cannot read session state
560
+ // from here, so we surface a single error class with a
561
+ // clear message - the session-side onError handler
562
+ // already short-circuits when `closed=true`.
563
+ onError(new Error(`SSE handshake timed out after ${handshakeDeadlineMs}ms`));
329
564
  return;
565
+ }
330
566
  onError(error instanceof Error ? error : new Error(String(error)));
331
567
  }
332
568
  })();
333
569
  return {
334
- close: () => controller.abort(),
570
+ close: () => {
571
+ clearTimeout(handshakeTimer);
572
+ controller.abort();
573
+ },
335
574
  };
336
575
  },
337
576
  };
@@ -67,7 +67,7 @@ export function ReplSplash(props) {
67
67
  // pugs. The header card still renders inline so wordmark + status
68
68
  // rows stay attached to the splash flow.
69
69
  const showHandCraftedMascot = props.mascotPrePrinted !== true;
70
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsxs(Box, { flexDirection: "row", children: [showHandCraftedMascot ? _jsx(MascotColumn, {}) : null, _jsxs(Box, { flexDirection: "column", marginLeft: showHandCraftedMascot ? 2 : 0, marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Pugi" }), _jsx(Text, { bold: true, color: "cyan", children: ".io" }), _jsx(Text, { dimColor: true, children: ` v${props.cliVersion}` })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(HeaderRow, { label: "Plan", value: props.plan ?? PLACEHOLDER }), _jsx(HeaderRow, { label: "Model", value: props.model ?? PLACEHOLDER }), _jsx(HeaderRow, { label: "Tenant", value: props.tenant ?? PLACEHOLDER }), _jsx(HeaderRow, { label: "Workspace", value: props.workspaceLabel })] })] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: '─'.repeat(40) }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Tips for getting started:" }), _jsx(TipRow, { index: 1, text: "Type a brief, the workforce dispatches" }), _jsx(TipRow, { index: 2, text: "/help for slash commands, /web <url> to pull a page" }), _jsx(TipRow, { index: 3, text: "/skills install <name> for Anthropic / OpenClaw skills" })] })] }));
70
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsxs(Box, { flexDirection: "row", children: [showHandCraftedMascot ? _jsx(MascotColumn, {}) : null, _jsxs(Box, { flexDirection: "column", marginLeft: showHandCraftedMascot ? 2 : 0, marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Pugi" }), _jsx(Text, { bold: true, color: "#3da9fc", children: ".io" }), _jsx(Text, { dimColor: true, children: ` v${props.cliVersion}` })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(HeaderRow, { label: "Plan", value: props.plan ?? PLACEHOLDER }), _jsx(HeaderRow, { label: "Model", value: props.model ?? PLACEHOLDER }), _jsx(HeaderRow, { label: "Tenant", value: props.tenant ?? PLACEHOLDER }), _jsx(HeaderRow, { label: "Workspace", value: props.workspaceLabel })] })] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: '─'.repeat(40) }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Tips for getting started:" }), _jsx(TipRow, { index: 1, text: "Type a brief, the workforce dispatches" }), _jsx(TipRow, { index: 2, text: "/help for slash commands, /web <url> to pull a page" }), _jsx(TipRow, { index: 3, text: "/skills install <name> for Anthropic / OpenClaw skills" })] })] }));
71
71
  }
72
72
  /**
73
73
  * Renders the multi-line ASCII pug. Each row is split into colored
@@ -105,7 +105,7 @@ function MascotRow({ row, mask, }) {
105
105
  if (buffer.length > 0) {
106
106
  runs.push({ text: buffer, cyan: bufferCyan });
107
107
  }
108
- return (_jsx(Text, { children: runs.map((run, runIndex) => run.cyan ? (_jsx(Text, { color: "cyan", children: run.text }, runIndex)) : (_jsx(Text, { color: "gray", children: run.text }, runIndex))) }));
108
+ return (_jsx(Text, { children: runs.map((run, runIndex) => run.cyan ? (_jsx(Text, { color: "#3da9fc", children: run.text }, runIndex)) : (_jsx(Text, { color: "gray", children: run.text }, runIndex))) }));
109
109
  }
110
110
  function HeaderRow({ label, value }) {
111
111
  const padded = `${label}:`.padEnd(11, ' ');
package/dist/tui/repl.js CHANGED
@@ -49,7 +49,12 @@ export function Repl(props) {
49
49
  // α6.14 wave 3: boot splash visible until first input, first
50
50
  // `agent.spawned` event, or 10s idle. The host gates the initial
51
51
  // visibility on `--no-splash` / PUGI_SKIP_SPLASH via `skipSplash`.
52
- const [splashVisible, setSplashVisible] = useState(props.skipSplash !== true);
52
+ // α6.14.6 CEO dogfood 2026-05-25: default splash to HIDDEN at boot
53
+ // (parity with Claude Code's minimal one-line banner). Operator can
54
+ // opt back in via `/splash` slash. The chafa pug pre-print + header
55
+ // line already give the brand cue without the multi-row Plan/Model/
56
+ // Tenant block crowding the top.
57
+ const [splashVisible, setSplashVisible] = useState(false);
53
58
  const dismissSplash = useCallback(() => setSplashVisible(false), []);
54
59
  // α6.14 wave 3: workspace context snapshot for the status bar. We
55
60
  // read once at mount and freeze; a brand-new PUGI.md or skill is
@@ -178,14 +183,26 @@ export function Repl(props) {
178
183
  return undefined;
179
184
  return props.session.cancel();
180
185
  }, [props.session, modalActive]);
181
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [props.updateBanner ? _jsx(UpdateBanner, { result: props.updateBanner }) : null, splashVisible ? (_jsx(ReplSplash, { cliVersion: state.cliVersion, workspaceLabel: state.workspaceLabel, plan: props.splashPlan, model: props.splashModel, tenant: props.splashTenant, onDismiss: dismissSplash, mascotPrePrinted: props.mascotPrePrinted === true })) : null, _jsx(Header, { state: state }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: overlay === 'help' ? (_jsx(HelpOverlay, {})) : overlay === 'roster' ? (_jsx(RosterOverlay, {})) : overlay === 'farewell' ? (_jsx(FarewellOverlay, {})) : (_jsx(MainArea, { state: state, personaNames: personaNames, nowEpochMs: tickNow, hideToolStream: props.hideToolStream === true, toolStreamCollapsed: toolStreamCollapsed })) }), state.pendingAsk ? (_jsx(Box, { marginTop: 1, children: _jsx(AskModal, { tag: state.pendingAsk, onResolve: handleAskResolve }) })) : null, state.pendingPlanReview ? (_jsx(Box, { marginTop: 1, children: _jsx(PlanReviewModal, { tag: state.pendingPlanReview, onResolve: handlePlanReviewResolve }) })) : null, _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [overlay === 'farewell' || modalActive ? null : (_jsx(InputBox, { onSubmit: handleSubmit, onExit: handleExit, onCancel: handleCancel, now: props.now,
186
+ // α6.14.5 CEO dogfood 2026-05-25 (parity with Claude Code): input
187
+ // box pinned to alt-screen BOTTOM, conversation grows above it.
188
+ // Beta.3's height={rows} fix broke keystroke focus - raw echo at
189
+ // viewport bottom. The right pattern is minHeight on the root +
190
+ // flexGrow=1 on the MainArea Box: empty alt-screen lives ABOVE the
191
+ // input, and the input stays the sole focusable surface adjacent
192
+ // to the cursor row, so all keystrokes route through it.
193
+ const altScreenRows = process.stdout.rows ?? 24;
194
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, minHeight: altScreenRows, children: [props.updateBanner ? _jsx(UpdateBanner, { result: props.updateBanner }) : null, splashVisible ? (_jsx(ReplSplash, { cliVersion: state.cliVersion, workspaceLabel: state.workspaceLabel, plan: props.splashPlan, model: props.splashModel, tenant: props.splashTenant, onDismiss: dismissSplash, mascotPrePrinted: props.mascotPrePrinted === true })) : null, _jsx(Header, { state: state }), _jsx(Box, { flexDirection: "column", marginTop: 1, flexGrow: 1, justifyContent: "flex-end", children: overlay === 'help' ? (_jsx(HelpOverlay, {})) : overlay === 'roster' ? (_jsx(RosterOverlay, {})) : overlay === 'farewell' ? (_jsx(FarewellOverlay, {})) : (_jsx(MainArea, { state: state, personaNames: personaNames, nowEpochMs: tickNow, hideToolStream: props.hideToolStream === true, toolStreamCollapsed: toolStreamCollapsed })) }), state.pendingAsk ? (_jsx(Box, { marginTop: 1, children: _jsx(AskModal, { tag: state.pendingAsk, onResolve: handleAskResolve }) })) : null, state.pendingPlanReview ? (_jsx(Box, { marginTop: 1, children: _jsx(PlanReviewModal, { tag: state.pendingPlanReview, onResolve: handlePlanReviewResolve }) })) : null, _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [overlay === 'farewell' || modalActive ? null : (_jsx(InputBox, { onSubmit: handleSubmit, onExit: handleExit, onCancel: handleCancel, now: props.now,
182
195
  // Slug from process.cwd() (full path) so two workspaces with
183
196
  // the same basename do not share history. state.workspaceLabel
184
197
  // is the basename only. Codex review P2.
185
- workspaceSlug: slugForCwd(process.cwd()) })), _jsx(StatusBar, { connection: state.connection, activeAgentCount: countActive(state), tokensDownstreamTotal: state.tokensDownstreamTotal, briefStartedAtEpochMs: state.briefStartedAtEpochMs, nowEpochMs: tickNow, pulsePhase: pulsePhase, pugiMdCount: workspaceContext.pugiMdCount, mcpServerCount: workspaceContext.mcpServerCount, skillCount: workspaceContext.skillCount, quotaPct: props.quotaPct, dispatchState: state.dispatchState, dispatchToolLabel: state.dispatchToolLabel })] })] }));
198
+ workspaceSlug: slugForCwd(process.cwd()) })), _jsx(StatusBar, { connection: state.connection, activeAgentCount: countActive(state), tokensDownstreamTotal: state.tokensDownstreamTotal, briefStartedAtEpochMs: state.briefStartedAtEpochMs, nowEpochMs: tickNow, pulsePhase: pulsePhase, pugiMdCount: workspaceContext.pugiMdCount, mcpServerCount: workspaceContext.mcpServerCount, skillCount: workspaceContext.skillCount, quotaPct: props.quotaPct, dispatchState: state.dispatchState, dispatchToolLabel: state.dispatchToolLabel, lastCompletedOutcome: state.lastCompletedOutcome,
199
+ // α7 cost-meter sprint — surface accumulated session totals
200
+ // + per-turn delta flash on the status bar's top row. The
201
+ // session module owns accumulation; the bar is a pure render.
202
+ sessionTokensIn: state.sessionTokensIn, sessionTokensOut: state.sessionTokensOut, sessionCostUsd: state.sessionCostUsd, sessionStartedAtEpochMs: state.sessionStartedAtEpochMs, lastTurnDelta: state.lastTurnDelta })] })] }));
186
203
  }
187
204
  function Header({ state }) {
188
- return (_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Pugi" }), _jsx(Text, { bold: true, color: "cyan", children: ".io" }), _jsx(Text, { dimColor: true, children: ` · workspace: ${state.workspaceLabel} · v${state.cliVersion} · ` }), _jsx(Text, { color: "cyan", children: state.connection === 'on_watch' ? 'on watch' : state.connection.replace('_', ' ') })] }));
205
+ return (_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Pugi" }), _jsx(Text, { bold: true, color: "#3da9fc", children: ".io" }), _jsx(Text, { dimColor: true, children: ` · workspace: ${state.workspaceLabel} · v${state.cliVersion} · ` }), _jsx(Text, { color: "#3da9fc", children: state.connection === 'on_watch' ? 'on watch' : state.connection.replace('_', ' ') })] }));
189
206
  }
190
207
  function MainArea({ state, personaNames, nowEpochMs, hideToolStream, toolStreamCollapsed, }) {
191
208
  // α6.12: three vertical panes stacked above the input box.
@@ -225,14 +242,14 @@ function HelpOverlay() {
225
242
  const rows = grouped.get(group);
226
243
  if (!rows || rows.length === 0)
227
244
  return null;
228
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { dimColor: true, children: ` -- ${group} --` }), rows.map((row) => (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: ` /${row.name} ${row.args}`.padEnd(22, ' ') }), _jsx(Text, { dimColor: true, children: row.gloss })] }, row.name)))] }, group));
245
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { dimColor: true, children: ` -- ${group} --` }), rows.map((row) => (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "#3da9fc", children: ` /${row.name} ${row.args}`.padEnd(22, ' ') }), _jsx(Text, { dimColor: true, children: row.gloss })] }, row.name)))] }, group));
229
246
  }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: `${PUGI_TAGLINE}` }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: 'Press any key to dismiss.' }) })] }));
230
247
  }
231
248
  function RosterOverlay() {
232
249
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "On-watch roster" }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: THE_TEN.map((persona) => (_jsxs(Box, { children: [_jsx(Text, { bold: true, children: ` ${persona.name.padEnd(10, ' ')}` }), _jsx(Text, { dimColor: true, children: `${persona.role.padEnd(20, ' ')}` }), _jsx(Text, { children: persona.oneLiner })] }, persona.slug))) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: 'Press any key to dismiss.' }) })] }));
233
250
  }
234
251
  function FarewellOverlay() {
235
- return (_jsx(Box, { flexDirection: "column", alignItems: "center", paddingY: 2, children: _jsx(Text, { bold: true, color: "cyan", children: PUGI_TAGLINE }) }));
252
+ return (_jsx(Box, { flexDirection: "column", alignItems: "center", paddingY: 2, children: _jsx(Text, { bold: true, color: "#3da9fc", children: PUGI_TAGLINE }) }));
236
253
  }
237
254
  function applyVerdictSideEffects(verdict, handlers) {
238
255
  switch (verdict.kind) {
@@ -257,8 +274,10 @@ function applyVerdictSideEffects(verdict, handlers) {
257
274
  case 'consensus':
258
275
  case 'diff':
259
276
  case 'cost':
277
+ case 'quota':
260
278
  case 'status':
261
279
  case 'resume':
280
+ case 'mcp':
262
281
  case 'stub':
263
282
  // All non-overlay verdicts: the session module already appended
264
283
  // any operator-visible system lines (and, for `ask`, set
@@ -21,7 +21,7 @@ export function Splash({ data }) {
21
21
  cmd: 'pugi login',
22
22
  gloss: 'Connect this terminal to your Pugi account',
23
23
  };
24
- return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [_jsx(Box, { children: _jsx(Text, { bold: true, color: "cyan", children: "pugi.io" }) }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `v${data.cliVersion} · ${data.apiUrl}` }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Account: " }), _jsx(Text, { children: accountLine })] }), data.isAuthenticated && data.plan ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Plan: " }), _jsx(Text, { children: data.plan })] })) : null] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Quick start:" }), _jsx(HintRow, { command: primaryHint.cmd, gloss: primaryHint.gloss }), data.isAuthenticated ? (_jsx(HintRow, { command: 'pugi login', gloss: 'Re-authenticate or switch accounts' })) : (_jsx(HintRow, { command: 'pugi code "fix the bug"', gloss: 'Run a one-shot coding task' })), _jsx(HintRow, { command: 'pugi review --triple', gloss: 'Run the Anvil triple-review gate' }), _jsx(HintRow, { command: 'pugi help', gloss: 'Full command reference' })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Docs: https://pugi.dev \u00B7 Status: https://pugi.io/status" }) })] }));
24
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [_jsx(Box, { children: _jsx(Text, { bold: true, color: "#3da9fc", children: "pugi.io" }) }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `v${data.cliVersion} · ${data.apiUrl}` }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Account: " }), _jsx(Text, { children: accountLine })] }), data.isAuthenticated && data.plan ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Plan: " }), _jsx(Text, { children: data.plan })] })) : null] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Quick start:" }), _jsx(HintRow, { command: primaryHint.cmd, gloss: primaryHint.gloss }), data.isAuthenticated ? (_jsx(HintRow, { command: 'pugi login', gloss: 'Re-authenticate or switch accounts' })) : (_jsx(HintRow, { command: 'pugi code "fix the bug"', gloss: 'Run a one-shot coding task' })), _jsx(HintRow, { command: 'pugi review --triple', gloss: 'Run the Anvil triple-review gate' }), _jsx(HintRow, { command: 'pugi help', gloss: 'Full command reference' })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Docs: https://pugi.dev \u00B7 Status: https://pugi.io/status" }) })] }));
25
25
  }
26
26
  function HintRow({ command, gloss }) {
27
27
  // Pad command names so the gloss column lines up across rows.