@pugi/cli 0.1.0-beta.1 → 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.
@@ -1,8 +1,19 @@
1
1
  const registry = [
2
+ // α7.7: unified-diff patch apply. Routes through the same security
3
+ // gate as Layer A/B/C, so the risk class matches `edit`/`write`
4
+ // (medium — writes inside the workspace, never to protected files).
5
+ { name: 'apply_patch', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: true },
2
6
  { name: 'bash', permission: 'bash', risk: 'high', concurrencySafe: false, m1: true },
3
7
  { name: 'edit', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: true },
4
8
  { name: 'glob', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
5
9
  { name: 'grep', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
10
+ // α7.7: LSP read-only surface. Server runs locally, no Anvil
11
+ // round-trip. Concurrency-safe because every operation reads
12
+ // server state without mutating workspace files.
13
+ { name: 'lsp_definition', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
14
+ { name: 'lsp_diagnostics', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
15
+ { name: 'lsp_hover', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
16
+ { name: 'lsp_references', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
6
17
  { name: 'question', permission: 'none', risk: 'low', concurrencySafe: false, m1: true },
7
18
  { name: 'read', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
8
19
  { name: 'skill', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
@@ -11,6 +22,21 @@ const registry = [
11
22
  { name: 'task_list', permission: 'none', risk: 'low', concurrencySafe: true, m1: true },
12
23
  { name: 'task_update', permission: 'none', risk: 'low', concurrencySafe: false, m1: true },
13
24
  { name: 'web_fetch', permission: 'network', risk: 'medium', concurrencySafe: true, m1: true },
25
+ // α7.7: scratch worktree management. `worktree_create` writes nothing
26
+ // dangerous (a clone under `.pugi/worktrees/`); `worktree_promote`
27
+ // applies a diff back to the main tree, so it shares the `edit`
28
+ // risk class. `worktree_drop` is the cleanup primitive.
29
+ //
30
+ // R1 fix (2026-05-26, PR #413 r1, Fix 9): raised `worktree_create`
31
+ // and `worktree_drop` from `low` to `medium`. `worktree_drop` runs
32
+ // `rmSync` on its target — even with the new path-containment gate
33
+ // in `core/edits/worktree.ts::dropWorktree`, a destructive primitive
34
+ // belongs in `medium` so the permission FSM prompts on every call.
35
+ // `worktree_create` is raised for disk-pressure parity (a runaway
36
+ // agent loop could fill the disk with abandoned scratch worktrees).
37
+ { name: 'worktree_create', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: true },
38
+ { name: 'worktree_drop', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: true },
39
+ { name: 'worktree_promote', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: true },
14
40
  { name: 'write', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: true },
15
41
  ];
16
42
  export const toolRegistry = registry.sort((a, b) => a.name.localeCompare(b.name));
@@ -31,6 +31,25 @@ import { WorkingSet, buildRepoSkeleton, loadPugiIgnore, PugiWatcher, } from '../
31
31
  * `pugi resume <sessionId>` once that command exists).
32
32
  */
33
33
  export async function renderRepl(options) {
34
+ // beta.9 CEO dogfood 2026-05-26: claim stdin raw mode + alt-screen
35
+ // BEFORE any async bootstrap step so keystrokes typed during the
36
+ // [launch -> Ink mount] window cannot echo into the terminal in
37
+ // cooked mode. Previously openLocalStore (SQLite open) +
38
+ // bootstrapContext (chokidar start) could take hundreds of ms to
39
+ // multiple seconds on a fresh install / large repo; during that
40
+ // window stdin stayed in cooked mode and the terminal echoed
41
+ // every typed character literally onto the screen below the
42
+ // pre-printed mascot/header. The visible result was the operator's
43
+ // "ssssss" landing on the rendered status-bar bottom row (CEO
44
+ // screenshot 2026-05-26: beta.8 REPL bug 2).
45
+ //
46
+ // The claim is idempotent with Ink's own raw-mode enable: Ink
47
+ // ref-counts setRawMode calls, and Node's stdin.setRawMode is
48
+ // safe to call twice with the same value. The pre-Ink claim acts
49
+ // as a "raw-mode floor" - whatever Ink does after mount layers on
50
+ // top, and our finally{} restore drops the floor only after Ink
51
+ // has cleanly torn down (or never mounted on a bootstrap crash).
52
+ const bootstrap = claimTerminalForRepl();
34
53
  const transport = createProductionTransport();
35
54
  // Auto-bind the workspace context from process.cwd() so Mira knows
36
55
  // which repo the operator launched the CLI in. The resolver is
@@ -86,14 +105,23 @@ export async function renderRepl(options) {
86
105
  // Kick off the connect; the Repl renders the connecting state until
87
106
  // the session pushes `connection: 'on_watch'` from the SSE onOpen.
88
107
  void session.start();
108
+ // beta.9: drain any keystrokes that landed in stdin between the
109
+ // pre-Ink raw-mode claim and now. Without this, the queued bytes
110
+ // would feed Ink's first useInput tick as a flood of "stale"
111
+ // characters once the InputBox mounts - the operator would see
112
+ // their pre-typed input materialise in the prompt as if they had
113
+ // typed it after the REPL became interactive. Idempotent: no-op
114
+ // when stdin is not a TTY or no bytes were buffered.
115
+ drainBufferedStdin(process.stdin);
89
116
  // α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.
117
+ // stdout BEFORE Ink mounts (but AFTER alt-screen enter). Ink's
118
+ // layout engine would mis-measure the truecolor escape sequences,
119
+ // so the pug must land verbatim. The flag is passed into <Repl />
120
+ // so the splash component knows to skip its own hand-crafted
121
+ // PUG_MASCOT column - otherwise the operator sees both the chafa
122
+ // pug AND the ASCII fallback stacked. When skipSplash is true
123
+ // (operator opted out via --no-splash), we suppress the pre-print
124
+ // too so the boot stays silent.
97
125
  const mascotPrePrinted = options.skipSplash === true ? false : printPugMascotPreInk(process.stdout);
98
126
  const instance = render(React.createElement(Repl, {
99
127
  session,
@@ -102,10 +130,16 @@ export async function renderRepl(options) {
102
130
  hideToolStream: options.hideToolStream === true,
103
131
  mascotPrePrinted,
104
132
  }));
133
+ // Make sure we leave the alt screen on abrupt exits too. Without
134
+ // this the operator's shell stays "frozen" on the Pugi splash.
135
+ process.once('exit', bootstrap.restore);
136
+ process.once('SIGINT', bootstrap.restore);
137
+ process.once('SIGTERM', bootstrap.restore);
105
138
  try {
106
139
  await instance.waitUntilExit();
107
140
  }
108
141
  finally {
142
+ bootstrap.restore();
109
143
  session.close();
110
144
  if (store) {
111
145
  try {
@@ -125,6 +159,89 @@ export async function renderRepl(options) {
125
159
  }
126
160
  }
127
161
  }
162
+ export function claimTerminalForRepl(stdin = process.stdin, stdout = process.stdout) {
163
+ const isStdoutTty = stdout.isTTY === true;
164
+ const isStdinTty = stdin.isTTY === true && typeof stdin.setRawMode === 'function';
165
+ let altScreenEntered = false;
166
+ if (isStdoutTty) {
167
+ try {
168
+ stdout.write('\x1b[?1049h');
169
+ stdout.write('\x1b[H');
170
+ altScreenEntered = true;
171
+ }
172
+ catch {
173
+ /* terminal already detached */
174
+ }
175
+ }
176
+ let rawModeClaimed = false;
177
+ if (isStdinTty) {
178
+ try {
179
+ stdin.setEncoding('utf8');
180
+ stdin.setRawMode(true);
181
+ // Resume so the kernel actually delivers bytes to Node's event
182
+ // loop. Without resume, raw mode is set but data does not flow
183
+ // until something else (e.g. Ink) attaches a 'data' listener.
184
+ stdin.resume();
185
+ rawModeClaimed = true;
186
+ }
187
+ catch {
188
+ /* raw mode unsupported - the operator's shell still works */
189
+ }
190
+ }
191
+ let restored = false;
192
+ const restore = () => {
193
+ if (restored)
194
+ return;
195
+ restored = true;
196
+ if (rawModeClaimed && isStdinTty) {
197
+ try {
198
+ stdin.setRawMode(false);
199
+ }
200
+ catch {
201
+ /* terminal already detached */
202
+ }
203
+ }
204
+ if (altScreenEntered) {
205
+ try {
206
+ stdout.write('\x1b[?1049l');
207
+ }
208
+ catch {
209
+ /* shutdown race - terminal already detached */
210
+ }
211
+ }
212
+ };
213
+ return { altScreenEntered, rawModeClaimed, restore };
214
+ }
215
+ /**
216
+ * Read and discard any bytes buffered in stdin between
217
+ * `claimTerminalForRepl()` and the Ink mount. Returns the number of
218
+ * bytes drained so tests can assert the behaviour without intercepting
219
+ * the side effect.
220
+ *
221
+ * `stdin.read()` is a no-op when no data is buffered, so this is safe
222
+ * to call whether or not the operator actually typed during bootstrap.
223
+ * Wrapped in try/catch because a closed / piped stdin will throw on
224
+ * read in some Node versions.
225
+ */
226
+ export function drainBufferedStdin(stdin = process.stdin) {
227
+ if (stdin.isTTY !== true)
228
+ return 0;
229
+ try {
230
+ let bytesDrained = 0;
231
+ // Loop until read() returns null - readable streams may chunk
232
+ // buffered bytes across multiple read() calls when the operator
233
+ // typed faster than the kernel could deliver to Node's loop.
234
+ for (;;) {
235
+ const chunk = stdin.read();
236
+ if (chunk === null)
237
+ return bytesDrained;
238
+ bytesDrained += typeof chunk === 'string' ? chunk.length : chunk.byteLength;
239
+ }
240
+ }
241
+ catch {
242
+ return 0;
243
+ }
244
+ }
128
245
  /**
129
246
  * Open the local SessionStore for the REPL bootstrap. Returns
130
247
  * `{ store: null, openedSessionId: undefined }` on any error so the
@@ -216,7 +333,7 @@ async function bootstrapContext(input) {
216
333
  /* ------------------------------------------------------------------ */
217
334
  /* Production transport */
218
335
  /* ------------------------------------------------------------------ */
219
- function createProductionTransport() {
336
+ export function createProductionTransport() {
220
337
  return {
221
338
  async createSession({ apiUrl, apiKey, workspace }) {
222
339
  // Forward the workspace bundle in the POST body so admin-api can
@@ -275,6 +392,31 @@ function createProductionTransport() {
275
392
  if (lastEventId) {
276
393
  headers['Last-Event-ID'] = lastEventId;
277
394
  }
395
+ // beta.9 CEO dogfood 2026-05-26: hard timeout on the SSE
396
+ // handshake so a CDN/proxy that buffers the response (or an
397
+ // admin-api that accepted the route but never flushed headers)
398
+ // cannot freeze the REPL in `connecting` forever. The 5s budget
399
+ // is generous - admin-api routinely responds in <500ms when
400
+ // healthy - but tight enough that an operator who launched
401
+ // `pugi` and is staring at the screen will see the status flip
402
+ // to `reconnecting` instead of an indefinite hang. The
403
+ // AbortController bound to the fetch aborts the in-flight
404
+ // request when the timer fires, which surfaces as an
405
+ // `AbortError` and routes through the existing onError handler
406
+ // (which calls scheduleReconnect via the session). The timer
407
+ // is cleared the moment onOpen fires so a slow-but-eventually-
408
+ // successful handshake still works.
409
+ const handshakeDeadlineMs = 5_000;
410
+ const handshakeTimer = setTimeout(() => {
411
+ controller.abort();
412
+ // onError is called from the catch block below (the abort
413
+ // synthesises an AbortError that consumeSseStream / fetch
414
+ // will throw). No explicit onError call here - we let the
415
+ // catch path normalise the error message so the operator
416
+ // sees the consistent "SSE handshake timed out (5s)" prose
417
+ // through the same plumbing that surfaces every other
418
+ // transport failure.
419
+ }, handshakeDeadlineMs);
278
420
  void (async () => {
279
421
  try {
280
422
  const response = await fetch(url, {
@@ -288,6 +430,9 @@ function createProductionTransport() {
288
430
  if (!response.body) {
289
431
  throw new Error('SSE response has no body');
290
432
  }
433
+ // Handshake survived; cancel the deadline so a slow
434
+ // first-event stream does not get aborted later.
435
+ clearTimeout(handshakeTimer);
291
436
  onOpen();
292
437
  await consumeSseStream(response.body, onEvent);
293
438
  // Server closed the stream cleanly. Treat as an error so
@@ -297,13 +442,27 @@ function createProductionTransport() {
297
442
  onError(new Error('SSE stream ended'));
298
443
  }
299
444
  catch (error) {
300
- if (controller.signal.aborted)
445
+ clearTimeout(handshakeTimer);
446
+ if (controller.signal.aborted) {
447
+ // Distinguish operator-driven close (session.close())
448
+ // from the handshake-deadline abort. The session sets a
449
+ // `closed` flag before calling controller.abort(); the
450
+ // handshake-deadline abort fires while the session is
451
+ // still expecting onOpen. We cannot read session state
452
+ // from here, so we surface a single error class with a
453
+ // clear message - the session-side onError handler
454
+ // already short-circuits when `closed=true`.
455
+ onError(new Error(`SSE handshake timed out after ${handshakeDeadlineMs}ms`));
301
456
  return;
457
+ }
302
458
  onError(error instanceof Error ? error : new Error(String(error)));
303
459
  }
304
460
  })();
305
461
  return {
306
- close: () => controller.abort(),
462
+ close: () => {
463
+ clearTimeout(handshakeTimer);
464
+ controller.abort();
465
+ },
307
466
  };
308
467
  },
309
468
  };
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,7 +183,15 @@ 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.
@@ -0,0 +1,10 @@
1
+ {
2
+ "servers": {
3
+ "codegraph": {
4
+ "command": "codegraph",
5
+ "args": ["serve", "--mcp"],
6
+ "env": {},
7
+ "trust": "pending"
8
+ }
9
+ }
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pugi/cli",
3
- "version": "0.1.0-beta.1",
3
+ "version": "0.1.0-beta.10",
4
4
  "description": "Pugi CLI - terminal-native software execution system",
5
5
  "homepage": "https://pugi.io",
6
6
  "repository": {
@@ -29,8 +29,10 @@
29
29
  "bin/run.js",
30
30
  "dist/**/*.js",
31
31
  "assets/**/*.ansi",
32
+ "docs/examples/**/*.json",
32
33
  "README.md",
33
- "LICENSE"
34
+ "LICENSE",
35
+ "THIRD_PARTY_NOTICES.md"
34
36
  ],
35
37
  "engines": {
36
38
  "node": ">=22.5.0"
@@ -51,8 +53,8 @@
51
53
  "turndown": "^7.2.4",
52
54
  "undici": "^8.3.0",
53
55
  "zod": "^3.23.0",
54
- "@pugi/personas": "0.1.1",
55
- "@pugi/sdk": "0.1.0-beta.1"
56
+ "@pugi/personas": "0.1.2",
57
+ "@pugi/sdk": "0.1.0-beta.9"
56
58
  },
57
59
  "devDependencies": {
58
60
  "@types/node": "^22.0.0",