@pugi/cli 0.1.0-beta.11 → 0.1.0-beta.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/core/consensus/diff-capture.js +73 -0
  2. package/dist/core/context/index.js +7 -0
  3. package/dist/core/context/markdown-traverse.js +255 -0
  4. package/dist/core/edits/dispatch.js +218 -2
  5. package/dist/core/edits/journal.js +199 -0
  6. package/dist/core/edits/layer-d-ast.js +557 -14
  7. package/dist/core/edits/verify-hook.js +273 -0
  8. package/dist/core/engine/anvil-client.js +80 -5
  9. package/dist/core/engine/context-prefix.js +155 -0
  10. package/dist/core/engine/intent.js +260 -0
  11. package/dist/core/engine/native-pugi.js +663 -249
  12. package/dist/core/engine/prompts.js +52 -2
  13. package/dist/core/engine/tool-bridge.js +311 -9
  14. package/dist/core/lsp/client.js +57 -0
  15. package/dist/core/mcp/client.js +9 -0
  16. package/dist/core/mcp/http-server.js +553 -0
  17. package/dist/core/mcp/permission.js +190 -0
  18. package/dist/core/mcp/server-tools.js +219 -0
  19. package/dist/core/mcp/server.js +397 -0
  20. package/dist/core/repl/history.js +11 -1
  21. package/dist/core/repl/model-pricing.js +135 -0
  22. package/dist/core/repl/session.js +328 -12
  23. package/dist/core/repl/slash-commands.js +18 -4
  24. package/dist/core/settings.js +43 -0
  25. package/dist/core/subagents/dispatcher-real.js +600 -0
  26. package/dist/core/subagents/dispatcher.js +113 -24
  27. package/dist/core/subagents/index.js +18 -5
  28. package/dist/core/subagents/isolation-matrix.js +213 -0
  29. package/dist/core/subagents/spawn.js +19 -4
  30. package/dist/core/transport/version-interceptor.js +166 -0
  31. package/dist/index.js +28 -0
  32. package/dist/runtime/bootstrap.js +190 -0
  33. package/dist/runtime/cli.js +534 -268
  34. package/dist/runtime/commands/lsp.js +165 -5
  35. package/dist/runtime/commands/mcp.js +537 -0
  36. package/dist/runtime/headless.js +543 -0
  37. package/dist/runtime/load-hooks-or-exit.js +71 -0
  38. package/dist/runtime/version.js +65 -0
  39. package/dist/tools/agent-tool.js +192 -0
  40. package/dist/tools/apply-patch.js +62 -1
  41. package/dist/tools/mcp-tool.js +260 -0
  42. package/dist/tools/multi-edit.js +361 -0
  43. package/dist/tools/registry.js +5 -0
  44. package/dist/tools/web-fetch.js +147 -2
  45. package/dist/tools/web-search.js +458 -0
  46. package/dist/tui/agent-tree.js +10 -0
  47. package/dist/tui/repl-render.js +109 -1
  48. package/dist/tui/repl.js +7 -1
  49. package/dist/tui/status-bar.js +94 -16
  50. package/dist/tui/update-banner.js +20 -2
  51. package/package.json +5 -4
@@ -0,0 +1,458 @@
1
+ /**
2
+ * web_search tool — β1b T4 (2026-05-26).
3
+ *
4
+ * One-shot web search via the Anvil-proxied Brave Search API. Mirrors
5
+ * the gate + SSRF + sanitize + rate-limit posture of
6
+ * `tools/web-fetch.ts`. Returns the top-N {title, url, snippet}
7
+ * results wrapped in an `<untrusted-search-NONCE>` sentinel so the
8
+ * downstream prompt treats every snippet as data, never as
9
+ * instructions.
10
+ *
11
+ * Why an Anvil proxy and not a direct Brave API call:
12
+ * - The Brave Search subscription key is server-side. Shipping it in
13
+ * a customer CLI bundle would burn the rotation budget the first
14
+ * time the binary lands on a phishing repo.
15
+ * - The Anvil proxy already terminates the customer's `PUGI_API_KEY`
16
+ * for tenancy + per-tier rate-limiting + audit logging. Routing
17
+ * search through the same boundary keeps the security model
18
+ * uniform with `pugi engine` and `pugi sync`.
19
+ * - For local dev / offline testing the env var
20
+ * `PUGI_SEARCH_PROXY_URL` overrides the default proxy endpoint;
21
+ * unit tests inject `_setSearchFetchForTests()` so no network is
22
+ * touched.
23
+ *
24
+ * Gate: the tool refuses unless one of:
25
+ * 1. `settings.web.search.enabled === true` (persistent opt-in)
26
+ * 2. `allowSearch === true` (CLI flag `--allow-search`, single dispatch)
27
+ *
28
+ * SSRF: the proxy URL itself runs through `validateHostnameForFetch`
29
+ * from `web-fetch.ts` so a configured `PUGI_SEARCH_PROXY_URL=
30
+ * http://169.254.169.254/...` cannot ride to cloud metadata. We also
31
+ * apply the β1b #62 DNS-rebinding guard via the shared
32
+ * `pinnedAddressAgent` helper (web-fetch.ts) — the same SSRF window
33
+ * that fetch closes, search closes.
34
+ *
35
+ * Rate limit: in-memory session bucket — 5 requests per rolling 60 s
36
+ * keyed by `sessionId`. Burst above the cap returns
37
+ * `{ ok: false, error: 'rate_limited' }` immediately, no network
38
+ * dispatch. Per-session because a noisy session should not starve a
39
+ * quieter one in the same workstation process.
40
+ *
41
+ * Response size cap: hard 1 MiB ceiling on the proxy body. Anything
42
+ * larger drops the entire response with `oversized_response` — the
43
+ * proxy already normalizes Brave's payload to a small JSON shape, so
44
+ * 1 MiB is generous for the typical 10-result payload (~5 KiB).
45
+ *
46
+ * Sanitization: snippet text is stripped of HTML tags before being
47
+ * surfaced to the model. Brave returns raw HTML in `description` for
48
+ * highlight markup; we keep the visible text and drop every `<...>`
49
+ * span via a conservative regex. Cross-checked by the `script/style`
50
+ * spec which asserts a hostile `<script>alert(1)</script>` snippet
51
+ * lands as the inner text only.
52
+ *
53
+ * Brand voice: brief / dispatch / ship / sentinel only — brandbook §08.
54
+ */
55
+ import { request, Agent } from 'undici';
56
+ import { randomBytes } from 'node:crypto';
57
+ import { validateHostnameForFetch, validateIpLiteralForFetch } from './web-fetch.js';
58
+ let activeFetch = null;
59
+ /**
60
+ * Test seam — pass `null` to restore the production undici dispatcher.
61
+ * Mirrors the `_setLookupForTests` pattern from `web-fetch.ts`.
62
+ */
63
+ export function _setSearchFetchForTests(fn) {
64
+ activeFetch = fn;
65
+ }
66
+ const SEARCH_TIMEOUT_MS = 10_000;
67
+ const MAX_RESPONSE_BYTES = 1 * 1024 * 1024; // 1 MiB hard cap
68
+ const MAX_RESULTS = 10;
69
+ const DEFAULT_RESULTS = 10;
70
+ const MAX_QUERY_LEN = 256;
71
+ const RATE_LIMIT_WINDOW_MS = 60_000;
72
+ const RATE_LIMIT_MAX = 5;
73
+ const USER_AGENT = 'pugi-cli/0.1 (+https://pugi.dev)';
74
+ const DEFAULT_PROXY_URL = 'https://api.pugi.io/api/pugi/web-search';
75
+ export function isWebSearchEnabled(ctx) {
76
+ if (ctx.allowSearch === true)
77
+ return true;
78
+ return ctx.settings.web?.search?.enabled === true;
79
+ }
80
+ const rateBuckets = new Map();
81
+ /**
82
+ * β1b r1: Map sweep cap so a long-running engine session that recycles
83
+ * sessionIds (or a misconfigured caller that passes per-call random
84
+ * ids) cannot grow `rateBuckets` unbounded. 1024 is well above any
85
+ * realistic concurrent-session count for a single CLI process; when
86
+ * we cross the cap we sweep expired buckets first, and if the Map is
87
+ * still over the cap we evict the oldest-touched bucket. Pure LRU
88
+ * would need another timestamp; oldest-by-insertion (Map iteration
89
+ * order) is close enough for a defense-in-depth memory bound.
90
+ */
91
+ const RATE_BUCKETS_CAP = 1024;
92
+ /**
93
+ * Returns true when the dispatch is allowed and records the call;
94
+ * returns false when the per-session bucket is at cap. Pure
95
+ * in-process; CLI dispatch is short-lived so a Map is sufficient.
96
+ *
97
+ * Exported for spec reset between test cases.
98
+ */
99
+ export function _checkRateLimit(sessionId, now = Date.now()) {
100
+ if (!sessionId)
101
+ return true;
102
+ let bucket = rateBuckets.get(sessionId);
103
+ if (!bucket) {
104
+ // β1b r1: sweep before allocating a new bucket so we never grow
105
+ // past the cap. Sweep is O(n) but only runs when we are about to
106
+ // breach the cap, so per-call cost stays effectively constant.
107
+ if (rateBuckets.size >= RATE_BUCKETS_CAP) {
108
+ sweepRateBuckets(now);
109
+ }
110
+ bucket = { timestamps: [] };
111
+ rateBuckets.set(sessionId, bucket);
112
+ }
113
+ const cutoff = now - RATE_LIMIT_WINDOW_MS;
114
+ bucket.timestamps = bucket.timestamps.filter((ts) => ts > cutoff);
115
+ if (bucket.timestamps.length >= RATE_LIMIT_MAX)
116
+ return false;
117
+ bucket.timestamps.push(now);
118
+ return true;
119
+ }
120
+ /**
121
+ * Drop expired buckets (all timestamps older than the rate-limit
122
+ * window) and, if still over the cap, evict the oldest-inserted
123
+ * buckets until under cap. Memory-bound only — never affects rate
124
+ * limit correctness for live sessions.
125
+ */
126
+ function sweepRateBuckets(now) {
127
+ const cutoff = now - RATE_LIMIT_WINDOW_MS;
128
+ for (const [id, b] of rateBuckets) {
129
+ if (b.timestamps.every((ts) => ts <= cutoff)) {
130
+ rateBuckets.delete(id);
131
+ }
132
+ }
133
+ while (rateBuckets.size >= RATE_BUCKETS_CAP) {
134
+ const oldest = rateBuckets.keys().next();
135
+ if (oldest.done)
136
+ break;
137
+ rateBuckets.delete(oldest.value);
138
+ }
139
+ }
140
+ /** Test seam — clear rate buckets between specs. */
141
+ export function _resetRateLimitForTests() {
142
+ rateBuckets.clear();
143
+ }
144
+ /* ----------------------- sanitization ---------------------- */
145
+ /**
146
+ * Strip HTML tags + collapse whitespace. Brave's `description` carries
147
+ * highlight markup (`<strong>`); we keep the inner text only so the
148
+ * model never sees raw HTML it could mistake for a directive. The
149
+ * regex is conservative: `<[^>]*>` removes opening + closing tags +
150
+ * self-closing tags + comments + processing instructions. Script
151
+ * BODIES are removed via a second pass that drops the entire
152
+ * `<script>...</script>` and `<style>...</style>` block (case-
153
+ * insensitive) so even a server-rendered snippet cannot leak a
154
+ * `console.log` line into the model context.
155
+ */
156
+ export function sanitizeSnippet(input) {
157
+ if (!input)
158
+ return '';
159
+ // Drop full script + style blocks (with their bodies). The
160
+ // non-greedy `.*?` handles "..." with embedded newlines via the `s`
161
+ // flag.
162
+ const noScripts = input
163
+ .replace(/<script\b[^>]*>.*?<\/script>/gis, '')
164
+ .replace(/<style\b[^>]*>.*?<\/style>/gis, '');
165
+ // Drop any remaining tag.
166
+ const noTags = noScripts.replace(/<[^>]*>/g, '');
167
+ // Collapse whitespace runs. Brave already trims but defensive.
168
+ return noTags.replace(/\s+/g, ' ').trim();
169
+ }
170
+ /**
171
+ * HTML-escape for sentinel body — mirrors `escapeForSentinelBody` from
172
+ * web-fetch.ts. Kept local to avoid coupling the modules tighter than
173
+ * the SSRF helpers already do.
174
+ */
175
+ function escapeForSentinelBody(input) {
176
+ return input
177
+ .replace(/&/g, '&amp;')
178
+ .replace(/</g, '&lt;')
179
+ .replace(/>/g, '&gt;')
180
+ .replace(/"/g, '&quot;')
181
+ .replace(/'/g, '&#39;');
182
+ }
183
+ /* ----------------------- dispatcher ---------------------- */
184
+ /**
185
+ * Resolve the search proxy URL. `PUGI_SEARCH_PROXY_URL` env override
186
+ * lets local dev point at a fixture server; production hits
187
+ * `api.pugi.io`.
188
+ */
189
+ function resolveProxyUrl() {
190
+ const env = process.env.PUGI_SEARCH_PROXY_URL;
191
+ if (typeof env === 'string' && env.length > 0)
192
+ return env;
193
+ return DEFAULT_PROXY_URL;
194
+ }
195
+ /**
196
+ * Production fetch via undici with the β1b #62 DNS-rebinding guard
197
+ * applied. We resolve the proxy hostname, validate against the SSRF
198
+ * blocklist, then dispatch via an Agent pinned to the resolved IP so
199
+ * a hostile DNS server cannot answer a different address on the
200
+ * connect(2) than on the lookup.
201
+ */
202
+ async function productionFetch(url, init) {
203
+ // β1b #62 — pinned-address Dispatcher to close the lookup→connect
204
+ // race. The Agent's `connect.lookup` always returns the address we
205
+ // already validated, so DNS cannot flip between the SSRF guard and
206
+ // the socket creation. Agent is per-call (small object) so process
207
+ // teardown does not need to keep a long-lived dispatcher around.
208
+ const parsed = new URL(url);
209
+ const hostname = parsed.hostname.replace(/^\[|\]$/g, '');
210
+ const dnsGuard = await validateHostnameForFetch(hostname);
211
+ if (dnsGuard) {
212
+ throw new Error(`SSRF refused: ${dnsGuard}`);
213
+ }
214
+ const { lookup: dnsLookup } = await import('node:dns/promises');
215
+ const answers = await dnsLookup(hostname, { all: true, verbatim: true });
216
+ if (answers.length === 0) {
217
+ throw new Error(`DNS returned no answers for ${hostname}`);
218
+ }
219
+ // Pin the first answer of THIS lookup, then re-validate against the
220
+ // SSRF blocklist. β1b r1: closes the DNS rebinding window where a
221
+ // hostile resolver returns public IPs to `validateHostnameForFetch`
222
+ // above and private IPs here. The lookup that feeds the pin is the
223
+ // lookup whose answer the connect(2) will actually use; without a
224
+ // re-check on this literal the original guard's IP set can diverge
225
+ // from the IP we lock into `connect.lookup`.
226
+ const pinned = answers[0];
227
+ if (!pinned) {
228
+ throw new Error(`DNS returned no answers for ${hostname}`);
229
+ }
230
+ const ipCheck = validateIpLiteralForFetch(pinned.address, pinned.family);
231
+ if (ipCheck !== null) {
232
+ throw new Error(`SSRF refused: ssrf_pinned_address_blocked: ${ipCheck}`);
233
+ }
234
+ const agent = new Agent({
235
+ connect: {
236
+ // Force the connect path to use the pre-resolved address. undici
237
+ // accepts the standard Node `dns.LookupFunction` shape here.
238
+ lookup: (_h, _opts, cb) => {
239
+ cb(null, pinned.address, pinned.family);
240
+ },
241
+ },
242
+ });
243
+ try {
244
+ return await request(url, { ...init, dispatcher: agent });
245
+ }
246
+ finally {
247
+ await agent.close().catch(() => {
248
+ /* best-effort */
249
+ });
250
+ }
251
+ }
252
+ async function readBodyWithCap(body, controller) {
253
+ const chunks = [];
254
+ let total = 0;
255
+ try {
256
+ for await (const chunk of body) {
257
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
258
+ total += buf.length;
259
+ if (total > MAX_RESPONSE_BYTES) {
260
+ controller.abort();
261
+ try {
262
+ if (typeof body.destroy === 'function')
263
+ body.destroy();
264
+ }
265
+ catch {
266
+ /* ignore — already aborted */
267
+ }
268
+ return { ok: false, error: `oversized_response: > ${MAX_RESPONSE_BYTES} bytes` };
269
+ }
270
+ chunks.push(buf);
271
+ }
272
+ }
273
+ catch (error) {
274
+ const msg = error instanceof Error ? error.message : String(error);
275
+ return { ok: false, error: `body_read_failed: ${msg}` };
276
+ }
277
+ return { ok: true, buffer: Buffer.concat(chunks) };
278
+ }
279
+ /**
280
+ * Validate + clamp the optional `count` arg. Clamp into [1, MAX_RESULTS]
281
+ * and default to DEFAULT_RESULTS when missing. The bridge already
282
+ * validates the type — this enforces the bounds.
283
+ */
284
+ function resolveCount(raw) {
285
+ if (raw === undefined)
286
+ return DEFAULT_RESULTS;
287
+ if (raw < 1)
288
+ return 1;
289
+ if (raw > MAX_RESULTS)
290
+ return MAX_RESULTS;
291
+ return Math.floor(raw);
292
+ }
293
+ /**
294
+ * Sanitize the operator query before it crosses the proxy boundary.
295
+ * Trim, hard-cap at MAX_QUERY_LEN, reject empty after trim. The proxy
296
+ * also validates, but a CLI-side check keeps the model's mistake
297
+ * visible to the operator (the failed call shows up in
298
+ * `.pugi/events.jsonl`) rather than blamed on a 400 from the proxy.
299
+ */
300
+ function sanitizeQuery(raw) {
301
+ const trimmed = raw.trim();
302
+ if (trimmed.length === 0) {
303
+ throw new Error('empty_query');
304
+ }
305
+ if (trimmed.length > MAX_QUERY_LEN) {
306
+ return trimmed.slice(0, MAX_QUERY_LEN);
307
+ }
308
+ return trimmed;
309
+ }
310
+ export async function webSearchTool(input, ctx) {
311
+ if (!isWebSearchEnabled(ctx)) {
312
+ return {
313
+ ok: false,
314
+ error: 'web_search disabled. Enable with --allow-search or set web.search.enabled=true in .pugi/settings.json.',
315
+ };
316
+ }
317
+ let query;
318
+ try {
319
+ query = sanitizeQuery(input.query);
320
+ }
321
+ catch (error) {
322
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
323
+ }
324
+ if (!_checkRateLimit(ctx.sessionId)) {
325
+ return {
326
+ ok: false,
327
+ error: `rate_limited: max ${RATE_LIMIT_MAX} searches per ${RATE_LIMIT_WINDOW_MS / 1000}s per session`,
328
+ };
329
+ }
330
+ const count = resolveCount(input.count);
331
+ const proxyUrl = resolveProxyUrl();
332
+ // Validate the proxy URL parses + scheme + SSRF. Even though the
333
+ // env var is operator-controlled, a careless export of
334
+ // `http://localhost:8080` would otherwise let an unrelated local
335
+ // server intercept the call. Default URL is `api.pugi.io` and
336
+ // resolves clean.
337
+ let parsedProxy;
338
+ try {
339
+ parsedProxy = new URL(proxyUrl);
340
+ }
341
+ catch {
342
+ return { ok: false, error: `invalid_proxy_url: ${proxyUrl}` };
343
+ }
344
+ if (parsedProxy.protocol !== 'http:' && parsedProxy.protocol !== 'https:') {
345
+ return { ok: false, error: `unsupported_proxy_scheme: ${parsedProxy.protocol}` };
346
+ }
347
+ const proxyHost = parsedProxy.hostname.replace(/^\[|\]$/g, '');
348
+ // SSRF guard applies to the proxy hostname even when activeFetch is
349
+ // a test stub — keeps the spec contract honest. Tests that exercise
350
+ // the rate limiter use a stub that bypasses productionFetch but the
351
+ // guard still runs at the entry above test stubs.
352
+ const ssrfErr = await validateHostnameForFetch(proxyHost);
353
+ if (ssrfErr) {
354
+ return { ok: false, error: `SSRF refused: ${ssrfErr}` };
355
+ }
356
+ const controller = new AbortController();
357
+ const timer = setTimeout(() => controller.abort(), SEARCH_TIMEOUT_MS);
358
+ // β1b: lifted PUGI_API_KEY from the runtime credential store would
359
+ // belong here for production — for the proxy call we forward whatever
360
+ // PUGI_API_KEY the operator has exported. Anonymous proxy access is
361
+ // permitted; the proxy treats it as the free-tier search quota.
362
+ const apiKey = process.env.PUGI_API_KEY ?? process.env.PUGI_LOGIN_TOKEN;
363
+ const headers = {
364
+ 'user-agent': USER_AGENT,
365
+ 'content-type': 'application/json',
366
+ accept: 'application/json',
367
+ };
368
+ if (typeof apiKey === 'string' && apiKey.length > 0) {
369
+ headers.authorization = `Bearer ${apiKey}`;
370
+ }
371
+ let response;
372
+ try {
373
+ const fetcher = activeFetch ?? (async (u, i) => (await productionFetch(u, i)));
374
+ response = (await fetcher(proxyUrl, {
375
+ method: 'POST',
376
+ headers,
377
+ body: JSON.stringify({ query, count }),
378
+ signal: controller.signal,
379
+ }));
380
+ }
381
+ catch (error) {
382
+ clearTimeout(timer);
383
+ const message = error instanceof Error ? error.message : String(error);
384
+ return { ok: false, error: `search_failed: ${message}` };
385
+ }
386
+ finally {
387
+ clearTimeout(timer);
388
+ }
389
+ if (response.statusCode < 200 || response.statusCode >= 300) {
390
+ // Drain the body so the socket can be reused / released.
391
+ try {
392
+ if (typeof response.body.destroy === 'function')
393
+ response.body.destroy();
394
+ }
395
+ catch {
396
+ /* ignore */
397
+ }
398
+ return { ok: false, error: `proxy_http_${response.statusCode}` };
399
+ }
400
+ const bodyResult = await readBodyWithCap(response.body, controller);
401
+ if (!bodyResult.ok)
402
+ return bodyResult;
403
+ let parsed;
404
+ try {
405
+ parsed = JSON.parse(bodyResult.buffer.toString('utf8'));
406
+ }
407
+ catch (error) {
408
+ const msg = error instanceof Error ? error.message : String(error);
409
+ return { ok: false, error: `proxy_malformed_json: ${msg}` };
410
+ }
411
+ // Expect the proxy to normalize Brave's payload to `{results: [{title, url, snippet}]}`.
412
+ // When the proxy is missing the `results` array (or it is not an array)
413
+ // we surface a clean error instead of returning empty.
414
+ const obj = parsed;
415
+ if (!obj || typeof obj !== 'object') {
416
+ return { ok: false, error: 'proxy_malformed_response: not an object' };
417
+ }
418
+ const rawResults = obj.results;
419
+ if (!Array.isArray(rawResults)) {
420
+ return { ok: false, error: 'proxy_malformed_response: missing results array' };
421
+ }
422
+ const results = [];
423
+ for (const r of rawResults.slice(0, count)) {
424
+ if (!r || typeof r !== 'object')
425
+ continue;
426
+ const row = r;
427
+ const title = typeof row.title === 'string' ? sanitizeSnippet(row.title) : '';
428
+ const url = typeof row.url === 'string' ? row.url : '';
429
+ const snippet = typeof row.snippet === 'string' ? sanitizeSnippet(row.snippet) : '';
430
+ if (!url)
431
+ continue;
432
+ results.push({ title, url, snippet });
433
+ }
434
+ // Per-call nonce for the sentinel. Mirrors `web-fetch.ts` so the
435
+ // downstream prompt's nonce-matching escape logic works uniformly.
436
+ const nonce = randomBytes(8).toString('hex');
437
+ const renderedLines = [`<untrusted-search-${nonce}>`, `Query: ${escapeForSentinelBody(query)}`, ''];
438
+ for (let i = 0; i < results.length; i += 1) {
439
+ const r = results[i];
440
+ if (!r)
441
+ continue;
442
+ renderedLines.push(`${i + 1}. ${escapeForSentinelBody(r.title || r.url)}`);
443
+ renderedLines.push(` ${escapeForSentinelBody(r.url)}`);
444
+ if (r.snippet)
445
+ renderedLines.push(` ${escapeForSentinelBody(r.snippet)}`);
446
+ renderedLines.push('');
447
+ }
448
+ renderedLines.push(`</untrusted-search-${nonce}>`);
449
+ const content_md = renderedLines.join('\n').trimEnd();
450
+ return {
451
+ ok: true,
452
+ query,
453
+ results,
454
+ content_md,
455
+ fetched_at: new Date().toISOString(),
456
+ };
457
+ }
458
+ //# sourceMappingURL=web-search.js.map
@@ -26,6 +26,13 @@ function statusGlyph(status) {
26
26
  return '⏳';
27
27
  case 'shipped':
28
28
  return '✓';
29
+ // 2026-05-26 — `replied` = orchestrator finished talking but no
30
+ // tool/delegate side-effect emitted. Distinct arrow glyph so the
31
+ // operator can tell at a glance that no real work shipped, even
32
+ // though the LLM completed cleanly. Defeats the fake-shipped
33
+ // anti-pattern (memory feedback_no_fake_dispatch_promises).
34
+ case 'replied':
35
+ return '→';
29
36
  case 'blocked':
30
37
  return '✗';
31
38
  case 'failed':
@@ -40,6 +47,9 @@ function statusColor(status) {
40
47
  return 'cyan';
41
48
  case 'shipped':
42
49
  return 'green';
50
+ // Dim/grey-ish — succeeded technically but no work was shipped.
51
+ case 'replied':
52
+ return 'gray';
43
53
  case 'blocked':
44
54
  return 'yellow';
45
55
  case 'failed':
@@ -24,6 +24,7 @@ import { ReplSession, } from '../core/repl/session.js';
24
24
  import { resolveWorkspaceContext } from '../core/repl/workspace-context.js';
25
25
  import { SqliteSessionStore } from '../core/repl/store/index.js';
26
26
  import { slugForCwd } from '../core/repl/history.js';
27
+ import { loadSettings } from '../core/settings.js';
27
28
  import { WorkingSet, buildRepoSkeleton, loadPugiIgnore, PugiWatcher, } from '../core/context/index.js';
28
29
  /**
29
30
  * Mount the REPL and resolve when the user exits via Ctrl+C × 2 or
@@ -50,12 +51,58 @@ export async function renderRepl(options) {
50
51
  // top, and our finally{} restore drops the floor only after Ink
51
52
  // has cleanly torn down (or never mounted on a bootstrap crash).
52
53
  const bootstrap = claimTerminalForRepl();
54
+ // beta.13 auto-init wire (CEO dogfood 2026-05-26): scaffold the
55
+ // `.pugi/` workspace silently on REPL boot so launching `pugi` in a
56
+ // fresh cwd no longer demands an explicit `pugi init` round-trip.
57
+ // Idempotent — every helper inside scaffoldPugiWorkspace is a
58
+ // `*_IfMissing` write, so re-running over an existing workspace is
59
+ // a no-op. Fail-safe: any FS / perms error never blocks REPL launch.
60
+ // Operator escape hatch: PUGI_NO_AUTO_INIT=1.
61
+ //
62
+ // Beta.13 P2 fix 2026-05-26: gate the scaffold on project-root markers
63
+ // so launching `pugi` from `$HOME` / `/tmp` / arbitrary dirs does NOT
64
+ // sprinkle `.pugi/` directories all over the filesystem. The gate
65
+ // mirrors `isBoundWorkspace` from workspace-context.ts but also
66
+ // accepts non-JS roots (Cargo / pyproject / go.mod) because the CLI
67
+ // is language-agnostic and an operator working in a Rust repo deserves
68
+ // the same auto-init UX as a Node operator. Already-bound `.pugi/`
69
+ // dirs also opt back in so the scaffold can fill any missing
70
+ // sub-artifacts the operator deleted.
71
+ if (process.env.PUGI_NO_AUTO_INIT !== '1' && isProjectRoot(process.cwd())) {
72
+ try {
73
+ const { scaffoldPugiWorkspace } = await import('../runtime/cli.js');
74
+ await scaffoldPugiWorkspace({
75
+ cwd: process.cwd(),
76
+ noDefaults: true,
77
+ log: () => {
78
+ /* silent — never leak scaffold progress into the REPL alt-screen */
79
+ },
80
+ });
81
+ }
82
+ catch (err) {
83
+ // Fail-safe: read-only FS or perms error never blocks REPL launch.
84
+ // Beta.13 P2 fix 2026-05-26: bare-catch swallowed the diagnostic;
85
+ // surface it on stderr under PUGI_DEBUG=1 so operator-triage on
86
+ // "why isn't .pugi/ being created?" has a starting point without
87
+ // having to re-instrument the bootstrap.
88
+ if (process.env.PUGI_DEBUG === '1') {
89
+ const msg = err instanceof Error ? err.message : String(err);
90
+ process.stderr.write(`[pugi-debug] auto-init failed: ${msg}\n`);
91
+ }
92
+ }
93
+ }
53
94
  const transport = createProductionTransport();
54
95
  // Auto-bind the workspace context from process.cwd() so Mira knows
55
96
  // which repo the operator launched the CLI in. The resolver is
56
97
  // best-effort — any FS error falls back to a basename-only summary,
57
98
  // never blocks REPL launch. Wave 4 fix 2026-05-25.
58
99
  const workspace = options.workspace ?? resolveWorkspaceContext(process.cwd());
100
+ // Beta.13 P1 fix 2026-05-26: read `ui.cyberZoo` from
101
+ // `.pugi/settings.json` so the operator's splash posture flows to
102
+ // admin-api on session open. Without this, the renderer's `cyberZoo`
103
+ // parameter (added beta.13) was always defaulted to 'on' regardless
104
+ // of the operator's actual setting.
105
+ const cyberZoo = readCyberZooSetting(process.cwd());
59
106
  // α6.4: open the local SessionStore for `/resume` persistence. The
60
107
  // store lives under `~/.pugi/projects/<slug>/`; failure is fail-safe
61
108
  // — we log a one-line warning to stderr and continue with the REPL
@@ -83,6 +130,7 @@ export async function renderRepl(options) {
83
130
  cliVersion: options.cliVersion,
84
131
  transport,
85
132
  workspace,
133
+ cyberZoo,
86
134
  store,
87
135
  localSessionId: openedSessionId,
88
136
  repoSkeleton: skeleton,
@@ -242,6 +290,57 @@ export function drainBufferedStdin(stdin = process.stdin) {
242
290
  return 0;
243
291
  }
244
292
  }
293
+ /**
294
+ * Project-root probe — beta.13 P2 fix 2026-05-26.
295
+ *
296
+ * Beta.13 auto-init was unconditional and silently created `.pugi/` in
297
+ * every cwd the REPL was launched from, including `$HOME` and `/tmp`.
298
+ * Operators who ran `pugi` to ask a quick question outside of any
299
+ * project ended up with stray `.pugi/` directories polluting their
300
+ * filesystem. The gate looks for any of six project-root markers
301
+ * before scaffolding:
302
+ *
303
+ * - `package.json` — JS / TS workspaces
304
+ * - `.git` — any cloned repo regardless of language
305
+ * - `.pugi` — already-bound Pugi workspace (re-scaffold
306
+ * fills any missing artifacts the operator
307
+ * deleted, idempotent over existing files)
308
+ * - `Cargo.toml` — Rust crates
309
+ * - `pyproject.toml` — Python projects (PEP 518)
310
+ * - `go.mod` — Go modules
311
+ *
312
+ * The probe is six cheap `existsSync` calls; the cost is negligible
313
+ * compared with the alt-screen + Ink mount that follows. Exported so a
314
+ * future unit spec can lock the contract.
315
+ */
316
+ export function isProjectRoot(cwd) {
317
+ // Local import keeps the bootstrap free of top-of-file `fs` calls
318
+ // that would run at module-load time — Ink + the SSE transport are
319
+ // happier when this file's side-effect surface stays small.
320
+ const { existsSync } = require('node:fs');
321
+ const { resolve } = require('node:path');
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
+ }
245
344
  /**
246
345
  * Open the local SessionStore for the REPL bootstrap. Returns
247
346
  * `{ store: null, openedSessionId: undefined }` on any error so the
@@ -335,11 +434,18 @@ async function bootstrapContext(input) {
335
434
  /* ------------------------------------------------------------------ */
336
435
  export function createProductionTransport() {
337
436
  return {
338
- async createSession({ apiUrl, apiKey, workspace }) {
437
+ async createSession({ apiUrl, apiKey, workspace, cyberZoo }) {
339
438
  // Forward the workspace bundle in the POST body so admin-api can
340
439
  // surface `<workspace-context>` in Mira's prompt. Older admin-api
341
440
  // builds ignore unknown fields, so this stays forward-compatible.
342
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.
343
449
  const body = {};
344
450
  if (workspace?.workspaceCwd)
345
451
  body.workspaceCwd = workspace.workspaceCwd;
@@ -347,6 +453,8 @@ export function createProductionTransport() {
347
453
  body.workspaceSlug = workspace.workspaceSlug;
348
454
  if (workspace?.workspaceSummary)
349
455
  body.workspaceSummary = workspace.workspaceSummary;
456
+ if (cyberZoo === 'on' || cyberZoo === 'off')
457
+ body.cyberZoo = cyberZoo;
350
458
  const response = await fetch(joinUrl(apiUrl, '/api/pugi/sessions'), {
351
459
  method: 'POST',
352
460
  headers: jsonHeaders(apiKey),
package/dist/tui/repl.js CHANGED
@@ -195,7 +195,11 @@ export function Repl(props) {
195
195
  // Slug from process.cwd() (full path) so two workspaces with
196
196
  // the same basename do not share history. state.workspaceLabel
197
197
  // is the basename only. Codex review P2.
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 })] })] }));
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 })] })] }));
199
203
  }
200
204
  function Header({ state }) {
201
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('_', ' ') })] }));
@@ -270,8 +274,10 @@ function applyVerdictSideEffects(verdict, handlers) {
270
274
  case 'consensus':
271
275
  case 'diff':
272
276
  case 'cost':
277
+ case 'quota':
273
278
  case 'status':
274
279
  case 'resume':
280
+ case 'mcp':
275
281
  case 'stub':
276
282
  // All non-overlay verdicts: the session module already appended
277
283
  // any operator-visible system lines (and, for `ask`, set