@pugi/cli 0.1.0-alpha.17 → 0.1.0-alpha.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -0
- package/dist/commands/deploy.js +439 -0
- package/dist/core/context/index.js +21 -0
- package/dist/core/context/pugiignore.js +316 -0
- package/dist/core/context/repo-skeleton.js +533 -0
- package/dist/core/context/watcher.js +342 -0
- package/dist/core/context/working-set.js +165 -0
- package/dist/core/edits/dispatch.js +185 -0
- package/dist/core/edits/index.js +15 -0
- package/dist/core/edits/layer-a-apply.js +217 -0
- package/dist/core/edits/layer-b-apply.js +211 -0
- package/dist/core/edits/layer-c-apply.js +160 -0
- package/dist/core/edits/layer-d-ast.js +29 -0
- package/dist/core/edits/marker-parser.js +401 -0
- package/dist/core/edits/security-gate.js +223 -0
- package/dist/core/repl/cancellation.js +98 -0
- package/dist/core/repl/dispatch-fsm.js +204 -0
- package/dist/core/repl/privacy-banner.js +4 -4
- package/dist/core/repl/session.js +366 -31
- package/dist/core/repl/slash-commands.js +37 -32
- package/dist/runtime/cli.js +193 -1
- package/dist/runtime/commands/config.js +136 -0
- package/dist/tui/repl-render.js +72 -0
- package/package.json +6 -3
|
@@ -42,14 +42,33 @@ const MAX_TOOL_CALLS = 200;
|
|
|
42
42
|
const MAX_RECONNECT_ATTEMPTS = 10;
|
|
43
43
|
const RECONNECT_BASE_MS = 250;
|
|
44
44
|
const RECONNECT_MAX_MS = 5_000;
|
|
45
|
+
/**
|
|
46
|
+
* α6.5 filewatch throttle: minimum gap between two file-change
|
|
47
|
+
* system lines surfaced in the conversation pane. Per the sprint
|
|
48
|
+
* spec, a noisy save burst should not flood the transcript - we
|
|
49
|
+
* coalesce all chokidar batches that arrive inside the window into
|
|
50
|
+
* a single "file changed: ..." line.
|
|
51
|
+
*/
|
|
52
|
+
const FILEWATCH_SYSTEM_LINE_GAP_MS = 5_000;
|
|
53
|
+
/**
|
|
54
|
+
* Hard cap on the size of `pendingFilewatchBatches`. The throttle window
|
|
55
|
+
* is 5s, but a `tsc --watch` style tool can fire dozens of `change`
|
|
56
|
+
* events per second for hours on end if the operator leaves the REPL
|
|
57
|
+
* up. Without a cap, every batch arriving inside the throttle window
|
|
58
|
+
* would accumulate forever, holding refs to thousands of FilewatchBatch
|
|
59
|
+
* objects (each carrying its own events array). On overflow we drop
|
|
60
|
+
* the OLDEST batch and surface a one-shot system warning so the
|
|
61
|
+
* operator knows the buffer is shedding. triple-review P1 (PR #380).
|
|
62
|
+
*/
|
|
63
|
+
const PENDING_FILEWATCH_BATCH_CAP = 100;
|
|
45
64
|
/**
|
|
46
65
|
* Cap on silent session-recreate attempts on HTTP 404 from the SSE
|
|
47
66
|
* stream. When admin-api restarts it drops its in-memory session
|
|
48
67
|
* store, so the saved sessionId returns 404 on every subscribe. The
|
|
49
68
|
* CLI mints a fresh server session, swaps the consumer over, and
|
|
50
|
-
* keeps running
|
|
69
|
+
* keeps running - but we cap the recovery to 3 attempts inside 60s
|
|
51
70
|
* so a truly down admin-api fails loud instead of spinning forever.
|
|
52
|
-
* (α6.14.2 wave 5
|
|
71
|
+
* (α6.14.2 wave 5 - CEO dogfood fix.)
|
|
53
72
|
*/
|
|
54
73
|
const MAX_SESSION_RECREATE_ATTEMPTS = 3;
|
|
55
74
|
const SESSION_RECREATE_WINDOW_MS = 60_000;
|
|
@@ -84,19 +103,19 @@ export class ReplSession {
|
|
|
84
103
|
* `detail` field carries the cumulative model output. `agent.completed`
|
|
85
104
|
* arrives last and previously overwrote the visible detail to the
|
|
86
105
|
* literal string `'shipped'` while the transcript line said only
|
|
87
|
-
* `shipped.`
|
|
106
|
+
* `shipped.` - the actual reply text was lost. By caching the last
|
|
88
107
|
* non-trivial detail here, we can flush it into the transcript when
|
|
89
108
|
* the agent completes so the operator sees what the persona actually
|
|
90
109
|
* said. CEO wave-2 fix 2026-05-25.
|
|
91
110
|
*/
|
|
92
111
|
lastStepDetail = new Map();
|
|
93
112
|
/**
|
|
94
|
-
* Optional local SessionStore
|
|
113
|
+
* Optional local SessionStore - α6.4. When non-null, every
|
|
95
114
|
* appendRow() call mirrors the row into the JSONL log so the
|
|
96
115
|
* conversation can be restored via `/resume`. Errors from the store
|
|
97
116
|
* are swallowed to a single system line (degradation, not crash).
|
|
98
117
|
* The store is opened by the CLI bootstrap and closed via
|
|
99
|
-
* `ReplSession.close()`. The store ownership is shared
|
|
118
|
+
* `ReplSession.close()`. The store ownership is shared - the
|
|
100
119
|
* SqliteSessionStore is process-wide singleton-ish under the
|
|
101
120
|
* lockfile, so close-on-quit is safe.
|
|
102
121
|
*/
|
|
@@ -111,15 +130,85 @@ export class ReplSession {
|
|
|
111
130
|
localSessionId;
|
|
112
131
|
/**
|
|
113
132
|
* One-shot guard so a store error only emits ONE system line per
|
|
114
|
-
* session
|
|
133
|
+
* session - without this, a stuck filesystem would spam the operator
|
|
115
134
|
* with `[store]` errors on every keystroke.
|
|
116
135
|
*/
|
|
117
136
|
storeErrorEmitted = false;
|
|
137
|
+
/**
|
|
138
|
+
* Privacy mode fetched on bootstrap from /api/admin/privacy/mode and
|
|
139
|
+
* surfaced via `renderPrivacyBanner` (one-line system message after
|
|
140
|
+
* splash). Cached on the session so the in-REPL `/privacy` slash
|
|
141
|
+
* command can render the live mode without a second round-trip on
|
|
142
|
+
* the input box's thread. Null means "not yet fetched" (still
|
|
143
|
+
* connecting) OR "fetch failed" (offline / unauthenticated). The
|
|
144
|
+
* `/privacy` slash falls back to the contract doc with an "unknown"
|
|
145
|
+
* banner when null.
|
|
146
|
+
*
|
|
147
|
+
* Triple-review P1 fix (2026-05-25): the prior build defined
|
|
148
|
+
* `renderPrivacyBanner` but never called it, and `/privacy` always
|
|
149
|
+
* rendered with `null` mode. The contract was advertised but the
|
|
150
|
+
* operator had no mode visibility.
|
|
151
|
+
*/
|
|
152
|
+
privacyMode = null;
|
|
153
|
+
/**
|
|
154
|
+
* α6.5 Tier 0 / Tier 1 / chokidar wiring. The bootstrap builds the
|
|
155
|
+
* skeleton + working set + watcher once and hands them to the
|
|
156
|
+
* session. The session uses them to:
|
|
157
|
+
*
|
|
158
|
+
* - render `/context` (count + cap + total bytes + skeleton size).
|
|
159
|
+
* - emit throttled "file changed" system lines on watcher batches.
|
|
160
|
+
* - forget removed files from the working set on `unlink`.
|
|
161
|
+
*
|
|
162
|
+
* All three are optional - tests and minimal callers pass null /
|
|
163
|
+
* undefined and the session degrades to "no three-tier integration"
|
|
164
|
+
* silently. The watcher's own lifecycle is owned by the bootstrap
|
|
165
|
+
* (we do NOT close it in `close()`).
|
|
166
|
+
*/
|
|
167
|
+
repoSkeleton;
|
|
168
|
+
workingSet;
|
|
169
|
+
watcher;
|
|
170
|
+
/**
|
|
171
|
+
* Epoch ms of the last filewatch system line. Initialised to 0 so
|
|
172
|
+
* the FIRST batch always emits; subsequent batches inside the gap
|
|
173
|
+
* are coalesced into the next emit window.
|
|
174
|
+
*/
|
|
175
|
+
lastFilewatchLineAtEpochMs = 0;
|
|
176
|
+
/**
|
|
177
|
+
* Buffer of batches whose emission was throttled. Drained on the
|
|
178
|
+
* next within-window batch by overwriting the throttled line with
|
|
179
|
+
* a summary that mentions how many additional files were touched.
|
|
180
|
+
* Capped at PENDING_FILEWATCH_BATCH_CAP to bound memory growth
|
|
181
|
+
* under long-running noisy filewatch sources (tsc --watch on a
|
|
182
|
+
* 200-file project hammering for hours). triple-review P1 (PR #380).
|
|
183
|
+
*/
|
|
184
|
+
pendingFilewatchBatches = [];
|
|
185
|
+
/**
|
|
186
|
+
* One-shot guard so the overflow-warning system line emits only once
|
|
187
|
+
* per session rather than spamming the operator with `[filewatch]
|
|
188
|
+
* shedding` on every dropped batch.
|
|
189
|
+
*/
|
|
190
|
+
pendingFilewatchOverflowWarned = false;
|
|
191
|
+
/**
|
|
192
|
+
* Bound subscriber refs so close() can detach the listeners from the
|
|
193
|
+
* shared watcher. The bootstrap owns the watcher lifecycle (it calls
|
|
194
|
+
* watcher.close() on REPL teardown), but the session MUST detach its
|
|
195
|
+
* own listeners on close() so any chokidar event landing between
|
|
196
|
+
* session.close() and watcher.close() does not run handlers on a
|
|
197
|
+
* dead session. Without detachment, recordFilewatchBatch would
|
|
198
|
+
* touch this.workingSet / this.transcript on a closed session.
|
|
199
|
+
* triple-review P1 (PR #380).
|
|
200
|
+
*/
|
|
201
|
+
filewatchBatchHandler = (batch) => {
|
|
202
|
+
this.recordFilewatchBatch(batch);
|
|
203
|
+
};
|
|
204
|
+
filewatchCapHandler = (info) => {
|
|
205
|
+
this.recordFilewatchCapExceeded(info);
|
|
206
|
+
};
|
|
118
207
|
/**
|
|
119
208
|
* Rolling dedupe set for `<pugi-ask>` and `<pugi-plan-review>`
|
|
120
209
|
* signatures. The persona may emit the same envelope twice on network
|
|
121
210
|
* retry; we suppress the duplicate so the operator does not see two
|
|
122
|
-
* stacked modals. Capped at 32 entries
|
|
211
|
+
* stacked modals. Capped at 32 entries - generous for a real session,
|
|
123
212
|
* defensive against a hostile flood. (α6.3.)
|
|
124
213
|
*/
|
|
125
214
|
seenTagSignatures = [];
|
|
@@ -144,6 +233,16 @@ export class ReplSession {
|
|
|
144
233
|
this.options = options;
|
|
145
234
|
this.store = options.store ?? null;
|
|
146
235
|
this.localSessionId = options.localSessionId;
|
|
236
|
+
this.repoSkeleton = options.repoSkeleton ?? null;
|
|
237
|
+
this.workingSet = options.workingSet ?? null;
|
|
238
|
+
this.watcher = options.watcher ?? null;
|
|
239
|
+
// Subscribe to the chokidar watcher when present. Late-binding
|
|
240
|
+
// happens here so the bootstrap can construct the session and
|
|
241
|
+
// attach the watcher in one pass without re-validating shape.
|
|
242
|
+
if (this.watcher) {
|
|
243
|
+
this.watcher.on('batch', this.filewatchBatchHandler);
|
|
244
|
+
this.watcher.on('capExceeded', this.filewatchCapHandler);
|
|
245
|
+
}
|
|
147
246
|
this.state = {
|
|
148
247
|
sessionId: undefined,
|
|
149
248
|
workspaceLabel: options.workspaceLabel,
|
|
@@ -187,12 +286,58 @@ export class ReplSession {
|
|
|
187
286
|
});
|
|
188
287
|
this.patch({ sessionId, connection: 'connecting' });
|
|
189
288
|
this.openStream();
|
|
289
|
+
// alpha 6.13 privacy banner. Fire-and-forget - never blocks the
|
|
290
|
+
// input box on the network round-trip. The banner is a single
|
|
291
|
+
// system-line so the operator sees the active mode under the
|
|
292
|
+
// splash without an extra slash command. Mode is cached on the
|
|
293
|
+
// session so `/privacy` later renders the live value without a
|
|
294
|
+
// second fetch. Failure to fetch (offline, unauthenticated,
|
|
295
|
+
// admin-api down) is silent - the operator can still type
|
|
296
|
+
// `/privacy` to see the contract.
|
|
297
|
+
void this.fetchAndAnnouncePrivacyMode().catch(() => undefined);
|
|
190
298
|
}
|
|
191
299
|
catch (error) {
|
|
192
300
|
this.appendSystemLine(`Could not open Pugi session: ${this.errorMessage(error)}`);
|
|
193
301
|
this.patch({ connection: 'offline' });
|
|
194
302
|
}
|
|
195
303
|
}
|
|
304
|
+
/**
|
|
305
|
+
* Fetch the tenant's current privacy mode from
|
|
306
|
+
* `GET /api/admin/privacy/mode`, cache it on the session, and emit
|
|
307
|
+
* a one-line system banner so the operator sees their active mode
|
|
308
|
+
* right under the bootstrap splash. Failure is silent - missing
|
|
309
|
+
* banner is preferable to a noisy "could not fetch privacy mode"
|
|
310
|
+
* line on every login.
|
|
311
|
+
*
|
|
312
|
+
* Triple-review P1 fix (2026-05-25): without this call,
|
|
313
|
+
* `renderPrivacyBanner` was defined but never reached the wire, and
|
|
314
|
+
* `/privacy` always rendered with `null` mode.
|
|
315
|
+
*/
|
|
316
|
+
async fetchAndAnnouncePrivacyMode() {
|
|
317
|
+
const { renderPrivacyBanner, isPrivacyMode } = await import('./privacy-banner.js');
|
|
318
|
+
try {
|
|
319
|
+
const url = `${this.options.apiUrl.replace(/\/+$/, '')}/api/admin/privacy/mode`;
|
|
320
|
+
const res = await fetch(url, {
|
|
321
|
+
headers: {
|
|
322
|
+
authorization: `Bearer ${this.options.apiKey}`,
|
|
323
|
+
accept: 'application/json',
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
if (!res.ok) {
|
|
327
|
+
// Silent fail - banner is decoration, not a blocking surface.
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const payload = (await res.json());
|
|
331
|
+
const mode = payload.mode;
|
|
332
|
+
if (typeof mode === 'string' && isPrivacyMode(mode)) {
|
|
333
|
+
this.privacyMode = mode;
|
|
334
|
+
this.appendSystemLine(renderPrivacyBanner(mode));
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
// Silent fail - offline / DNS / unauth all collapse to no banner.
|
|
339
|
+
}
|
|
340
|
+
}
|
|
196
341
|
/**
|
|
197
342
|
* Tear down the SSE stream and stop the reconnect timer. The session
|
|
198
343
|
* id stays valid server-side; `pugi resume <id>` reopens later.
|
|
@@ -207,6 +352,16 @@ export class ReplSession {
|
|
|
207
352
|
clearTimeout(this.reconnectTimer);
|
|
208
353
|
this.reconnectTimer = undefined;
|
|
209
354
|
}
|
|
355
|
+
// Detach watcher listeners so any chokidar event landing between
|
|
356
|
+
// session.close() and the bootstrap-owned watcher.close() does NOT
|
|
357
|
+
// run a handler on a dead session. The handlers themselves also
|
|
358
|
+
// hard-guard on `this.closed`, but detaching is the load-bearing
|
|
359
|
+
// fix - it severs the strong reference the watcher held on the
|
|
360
|
+
// session callback, which otherwise blocks GC. triple-review P1 (PR #380).
|
|
361
|
+
if (this.watcher) {
|
|
362
|
+
this.watcher.off('batch', this.filewatchBatchHandler);
|
|
363
|
+
this.watcher.off('capExceeded', this.filewatchCapHandler);
|
|
364
|
+
}
|
|
210
365
|
}
|
|
211
366
|
/* ------------- input handling -------------- */
|
|
212
367
|
/**
|
|
@@ -284,6 +439,10 @@ export class ReplSession {
|
|
|
284
439
|
await this.dispatchResume();
|
|
285
440
|
return verdict;
|
|
286
441
|
}
|
|
442
|
+
case 'context': {
|
|
443
|
+
this.dispatchContext();
|
|
444
|
+
return verdict;
|
|
445
|
+
}
|
|
287
446
|
case 'ask': {
|
|
288
447
|
// α6.3: synthesise a local yes/no `<pugi-ask>` modal so the
|
|
289
448
|
// operator can exercise the question UI without a persona-side
|
|
@@ -299,6 +458,15 @@ export class ReplSession {
|
|
|
299
458
|
this.patch({ pendingAsk: askTag, pendingAskSource: 'local' });
|
|
300
459
|
return verdict;
|
|
301
460
|
}
|
|
461
|
+
case 'privacy': {
|
|
462
|
+
// alpha 6.13: print the full mode contract + current banner
|
|
463
|
+
// inline. The current mode is resolved lazily by the helper -
|
|
464
|
+
// when unauthenticated or offline the banner falls back to
|
|
465
|
+
// "(unknown - mode lookup pending)" and the contract doc still
|
|
466
|
+
// renders so the operator can read the alternatives.
|
|
467
|
+
await this.dispatchPrivacy();
|
|
468
|
+
return verdict;
|
|
469
|
+
}
|
|
302
470
|
case 'stub': {
|
|
303
471
|
this.appendSystemLine(verdict.message);
|
|
304
472
|
return verdict;
|
|
@@ -306,7 +474,26 @@ export class ReplSession {
|
|
|
306
474
|
}
|
|
307
475
|
}
|
|
308
476
|
/**
|
|
309
|
-
* In-REPL `/
|
|
477
|
+
* In-REPL `/privacy` - alpha 6.13. Prints the full 3-mode contract
|
|
478
|
+
* doc + the current mode banner inline. The current mode is fetched
|
|
479
|
+
* via the admin-api /api/admin/privacy/mode endpoint when the
|
|
480
|
+
* operator is authenticated; otherwise the banner falls back to
|
|
481
|
+
* "(unknown)" and the contract doc still renders so the operator
|
|
482
|
+
* can compare modes without leaving the REPL.
|
|
483
|
+
*/
|
|
484
|
+
async dispatchPrivacy() {
|
|
485
|
+
const { renderPrivacyContractDoc } = await import('./privacy-banner.js');
|
|
486
|
+
// Triple-review P1 fix (2026-05-25): use the bootstrap-cached mode
|
|
487
|
+
// so the operator sees the LIVE current mode in the banner header
|
|
488
|
+
// instead of "(unknown)". The fetch happens once on session start;
|
|
489
|
+
// if it failed (offline / unauth) the cache stays null and the
|
|
490
|
+
// banner falls back to "(unknown)" - same UX as before, just with
|
|
491
|
+
// the happy path actually delivering the mode.
|
|
492
|
+
const doc = renderPrivacyContractDoc(this.privacyMode);
|
|
493
|
+
this.appendSystemLine(doc);
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* In-REPL `/resume` - α6.4. Lists the 10 most recent sessions from
|
|
310
497
|
* the local SessionStore and prints them as a numbered system menu.
|
|
311
498
|
* The Ink-side picker UI is deferred to the next sprint; today the
|
|
312
499
|
* operator gets a deterministic list + the exact command to relaunch
|
|
@@ -352,12 +539,12 @@ export class ReplSession {
|
|
|
352
539
|
/* ------------- α6.3 office-hours surface -------------- */
|
|
353
540
|
/**
|
|
354
541
|
* Surface an `<pugi-ask>` modal manually. Returned promise resolves
|
|
355
|
-
* with the operator's verdict
|
|
542
|
+
* with the operator's verdict - used by the `pugi ask "<q>"` shell
|
|
356
543
|
* command and by the `/ask` slash. The resolver is wired into the
|
|
357
544
|
* session state via `pendingAsk` so the REPL UI can render the modal
|
|
358
545
|
* and forward `onResolve` back through `resolveAsk()`.
|
|
359
546
|
*
|
|
360
|
-
* NOTE: idempotent on a duplicate signature
|
|
547
|
+
* NOTE: idempotent on a duplicate signature - a second presentAsk
|
|
361
548
|
* with the same question + option values returns the first
|
|
362
549
|
* outstanding promise rather than stacking two modals.
|
|
363
550
|
*/
|
|
@@ -370,7 +557,7 @@ export class ReplSession {
|
|
|
370
557
|
return this.outstandingAskPromise;
|
|
371
558
|
}
|
|
372
559
|
// If a DIFFERENT ask is open, reject the new one with a clear
|
|
373
|
-
// error rather than silently queueing
|
|
560
|
+
// error rather than silently queueing - the persona should never
|
|
374
561
|
// emit two concurrent asks, and surfacing the bug fails loud.
|
|
375
562
|
if (this.outstandingAskPromise) {
|
|
376
563
|
return Promise.reject(new Error('presentAsk: another ask is already pending. Resolve it first.'));
|
|
@@ -584,6 +771,129 @@ export class ReplSession {
|
|
|
584
771
|
this.appendSystemLine(`Workspace: ${this.state.workspaceLabel}.`);
|
|
585
772
|
this.appendSystemLine(`CLI: pugi ${this.state.cliVersion}.`);
|
|
586
773
|
}
|
|
774
|
+
/**
|
|
775
|
+
* α6.5 `/context` slash handler. Surfaces the three-tier context
|
|
776
|
+
* summary as a stack of system lines. Sections (in order):
|
|
777
|
+
*
|
|
778
|
+
* 1. Tier 0 (repo skeleton) - size in bytes, branch, package
|
|
779
|
+
* manager, languages. Skipped when no skeleton was injected
|
|
780
|
+
* (REPL launched outside a workspace or with --no-context).
|
|
781
|
+
*
|
|
782
|
+
* 2. Tier 1 (working set) - `count / capacity` plus the total
|
|
783
|
+
* size in bytes plus the oldest entry's age in seconds.
|
|
784
|
+
* Always emits even when empty so the operator can confirm
|
|
785
|
+
* the tier is wired.
|
|
786
|
+
*
|
|
787
|
+
* 3. Tier 2 (RAG) - one-line heads-up that the Anvil-side
|
|
788
|
+
* workspace lands in α6.5b.
|
|
789
|
+
*
|
|
790
|
+
* The renderer never mutates state.
|
|
791
|
+
*/
|
|
792
|
+
dispatchContext() {
|
|
793
|
+
if (this.repoSkeleton) {
|
|
794
|
+
const parts = [`Tier 0 skeleton: ${this.repoSkeleton.totalSize} bytes`];
|
|
795
|
+
if (this.repoSkeleton.branch)
|
|
796
|
+
parts.push(`branch ${this.repoSkeleton.branch}`);
|
|
797
|
+
if (this.repoSkeleton.packageManager)
|
|
798
|
+
parts.push(this.repoSkeleton.packageManager);
|
|
799
|
+
if (this.repoSkeleton.primaryLanguages.length > 0) {
|
|
800
|
+
parts.push(`langs ${this.repoSkeleton.primaryLanguages.slice(0, 3).join('/')}`);
|
|
801
|
+
}
|
|
802
|
+
this.appendSystemLine(parts.join(' - '));
|
|
803
|
+
}
|
|
804
|
+
else {
|
|
805
|
+
this.appendSystemLine('Tier 0 skeleton: not loaded (run pugi init or launch in a workspace).');
|
|
806
|
+
}
|
|
807
|
+
if (this.workingSet) {
|
|
808
|
+
const summary = this.workingSet.summary();
|
|
809
|
+
const ageLine = summary.oldestTouchedAtEpochMs !== null
|
|
810
|
+
? ` - oldest touch ${formatAgeSeconds(this.now() - summary.oldestTouchedAtEpochMs)} ago`
|
|
811
|
+
: '';
|
|
812
|
+
this.appendSystemLine(`Tier 1 working set: ${summary.count}/${summary.capacity} files, ${summary.totalSizeBytes} bytes${ageLine}.`);
|
|
813
|
+
}
|
|
814
|
+
else {
|
|
815
|
+
this.appendSystemLine('Tier 1 working set: not wired.');
|
|
816
|
+
}
|
|
817
|
+
this.appendSystemLine('Tier 2 RAG: deferred to α6.5b (Anvil-side per-tenant workspace).');
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* α6.5 chokidar batch handler. Forwards each event to the working
|
|
821
|
+
* set tracker (so `unlink` evicts and `add`/`change` bump the
|
|
822
|
+
* recency) and emits at most one throttled system line per
|
|
823
|
+
* `FILEWATCH_SYSTEM_LINE_GAP_MS` window.
|
|
824
|
+
*
|
|
825
|
+
* The transcript surface intentionally shows ONE filename + the
|
|
826
|
+
* count of additional changes (`file changed: src/foo.ts (+3 more)`).
|
|
827
|
+
* The full event list is preserved in the buffer for future
|
|
828
|
+
* `/context --files` deep-dive (not in α6.5 Phase 1).
|
|
829
|
+
*/
|
|
830
|
+
recordFilewatchBatch(batch) {
|
|
831
|
+
// Hard-guard against post-close invocation. close() detaches the
|
|
832
|
+
// watcher listeners, but the EventEmitter contract allows the
|
|
833
|
+
// currently-dispatching emit() call to finish delivering to every
|
|
834
|
+
// listener captured at the start of emit(). If the session closes
|
|
835
|
+
// mid-emit, the handler can still fire on a dead session. Returning
|
|
836
|
+
// early keeps the working set + transcript untouched.
|
|
837
|
+
// triple-review P1 (PR #380).
|
|
838
|
+
if (this.closed)
|
|
839
|
+
return;
|
|
840
|
+
if (this.workingSet) {
|
|
841
|
+
for (const event of batch.events) {
|
|
842
|
+
if (event.kind === 'unlink') {
|
|
843
|
+
this.workingSet.forget(event.absPath);
|
|
844
|
+
}
|
|
845
|
+
// Note: we do NOT auto-track add/change here. The working set
|
|
846
|
+
// reflects files the AGENT touched - filewatch is informational.
|
|
847
|
+
// Future wiring: bash/read/edit tools will call track() directly.
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
const nowMs = this.now();
|
|
851
|
+
const sinceLast = nowMs - this.lastFilewatchLineAtEpochMs;
|
|
852
|
+
if (sinceLast < FILEWATCH_SYSTEM_LINE_GAP_MS && this.lastFilewatchLineAtEpochMs !== 0) {
|
|
853
|
+
// Inside the throttle window - buffer for future deep-dive but
|
|
854
|
+
// do not emit a system line. Cap the buffer at
|
|
855
|
+
// PENDING_FILEWATCH_BATCH_CAP and drop the oldest on overflow so
|
|
856
|
+
// a noisy filewatch source cannot drive unbounded memory growth
|
|
857
|
+
// across a long REPL session. triple-review P1 (PR #380).
|
|
858
|
+
if (this.pendingFilewatchBatches.length >= PENDING_FILEWATCH_BATCH_CAP) {
|
|
859
|
+
this.pendingFilewatchBatches.shift();
|
|
860
|
+
if (!this.pendingFilewatchOverflowWarned) {
|
|
861
|
+
this.pendingFilewatchOverflowWarned = true;
|
|
862
|
+
this.appendSystemLine(`Filewatch buffer at cap (${PENDING_FILEWATCH_BATCH_CAP} batches) - shedding oldest. Source may be a build watcher in a tight loop.`);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
this.pendingFilewatchBatches.push(batch);
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
const totalEvents = this.pendingFilewatchBatches.reduce((acc, b) => acc + b.events.length, 0) + batch.events.length;
|
|
869
|
+
const head = batch.events[0];
|
|
870
|
+
if (!head) {
|
|
871
|
+
// Empty batch - should not happen given the watcher guards,
|
|
872
|
+
// but defensive.
|
|
873
|
+
this.pendingFilewatchBatches = [];
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
const wsLine = this.workingSet
|
|
877
|
+
? ` (working set: ${this.workingSet.size()}/${this.workingSet.capacityLimit()})`
|
|
878
|
+
: '';
|
|
879
|
+
const tail = totalEvents > 1 ? ` (+${totalEvents - 1} more)` : '';
|
|
880
|
+
this.appendSystemLine(`file ${head.kind}: ${head.path}${tail}${wsLine}`);
|
|
881
|
+
this.lastFilewatchLineAtEpochMs = nowMs;
|
|
882
|
+
this.pendingFilewatchBatches = [];
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* α6.5 chokidar cap-exceeded handler. The watcher closes itself
|
|
886
|
+
* when it crosses the watched-paths cap; the session surfaces a
|
|
887
|
+
* single system line so the operator knows live updates are off.
|
|
888
|
+
* The conversation stays usable - we just lose the file-changed
|
|
889
|
+
* badge for the rest of the session.
|
|
890
|
+
*/
|
|
891
|
+
recordFilewatchCapExceeded(info) {
|
|
892
|
+
// Same post-close guard as recordFilewatchBatch. triple-review P1 (PR #380).
|
|
893
|
+
if (this.closed)
|
|
894
|
+
return;
|
|
895
|
+
this.appendSystemLine(`Filewatch off: ${info.watchedCount} watched paths exceeded cap (${info.cap}). Falling back to manual stat-on-read.`);
|
|
896
|
+
}
|
|
587
897
|
/**
|
|
588
898
|
* Fetch one URL via the web_fetch tool and inject the resulting
|
|
589
899
|
* Markdown into the transcript as an operator-attributed brief. The
|
|
@@ -716,11 +1026,11 @@ export class ReplSession {
|
|
|
716
1026
|
// synchronously in some transports). If that second 404 arrives
|
|
717
1027
|
// with recreatingSession === true, we must SHORT-CIRCUIT it too
|
|
718
1028
|
// rather than fall through to the legacy "Stream interrupted"
|
|
719
|
-
// path
|
|
1029
|
+
// path - otherwise the operator sees the exact 404 line the
|
|
720
1030
|
// recreate is trying to suppress.
|
|
721
1031
|
if (this.isSessionNotFoundError(error)) {
|
|
722
1032
|
if (this.recreatingSession) {
|
|
723
|
-
// Recreate already in flight
|
|
1033
|
+
// Recreate already in flight - drop the duplicate 404 on the
|
|
724
1034
|
// floor. The first recreate will either succeed (new stream
|
|
725
1035
|
// opens, this dead handle is gone) or fall through to the
|
|
726
1036
|
// loud "keeps dropping" / "session recreate refused" paths
|
|
@@ -770,7 +1080,7 @@ export class ReplSession {
|
|
|
770
1080
|
this.recentRecreateAtMs.shift();
|
|
771
1081
|
}
|
|
772
1082
|
if (this.recentRecreateAtMs.length >= MAX_SESSION_RECREATE_ATTEMPTS) {
|
|
773
|
-
// Cap exceeded
|
|
1083
|
+
// Cap exceeded - fall back to the loud "give up" path so the
|
|
774
1084
|
// operator sees something is actually wrong.
|
|
775
1085
|
this.appendSystemLine('Admin API session keeps dropping (HTTP 404 x3). Type /quit and `pugi resume` to retry.');
|
|
776
1086
|
this.patch({ connection: 'offline' });
|
|
@@ -784,7 +1094,7 @@ export class ReplSession {
|
|
|
784
1094
|
this.streamHandle.close();
|
|
785
1095
|
this.streamHandle = undefined;
|
|
786
1096
|
}
|
|
787
|
-
// Reset reconnect attempt + lastEventId
|
|
1097
|
+
// Reset reconnect attempt + lastEventId - the new session is a
|
|
788
1098
|
// fresh stream, not a continuation of the dead one.
|
|
789
1099
|
this.reconnectAttempt = 0;
|
|
790
1100
|
this.lastEventId = undefined;
|
|
@@ -804,7 +1114,7 @@ export class ReplSession {
|
|
|
804
1114
|
this.openStream();
|
|
805
1115
|
}
|
|
806
1116
|
catch (error) {
|
|
807
|
-
// The recreate POST itself failed
|
|
1117
|
+
// The recreate POST itself failed - fall back to the existing
|
|
808
1118
|
// backoff reconnect so the operator still sees retry progress.
|
|
809
1119
|
this.appendSystemLine(`Session recreate refused (${this.errorMessage(error)}). Reconnecting.`);
|
|
810
1120
|
this.scheduleReconnect();
|
|
@@ -964,7 +1274,7 @@ export class ReplSession {
|
|
|
964
1274
|
// splits per line so word-wrap stays correct.
|
|
965
1275
|
//
|
|
966
1276
|
// Claude triple-review P1 (PR #369): the prior `includes('```')`
|
|
967
|
-
// gate only caught fences
|
|
1277
|
+
// gate only caught fences - multi-line bullets fragmented
|
|
968
1278
|
// per row showed as `▸ Mira • read PUGI.md / ▸ Mira • patched
|
|
969
1279
|
// bug / ...` instead of a single grouped bullet block.
|
|
970
1280
|
if (looksLikeMarkdown(finalDetail)) {
|
|
@@ -1076,7 +1386,7 @@ export class ReplSession {
|
|
|
1076
1386
|
// colon / whitespace) so the prefix the pane adds is the only one
|
|
1077
1387
|
// visible. We also drop any leaked `<workspace-context-NONCE>`
|
|
1078
1388
|
// wrapper the model sometimes echoes back at the head of its
|
|
1079
|
-
// first turn
|
|
1389
|
+
// first turn - that envelope is for prompt scaffolding, not for
|
|
1080
1390
|
// the operator's eyes.
|
|
1081
1391
|
const stripped = stripPersonaPrefixEcho(personaSlug, text);
|
|
1082
1392
|
this.appendRow({ source: 'persona', text: stripped, personaSlug });
|
|
@@ -1106,7 +1416,7 @@ export class ReplSession {
|
|
|
1106
1416
|
* Best-effort write of one transcript row into the local
|
|
1107
1417
|
* SessionStore. Swallows errors after emitting one system line so a
|
|
1108
1418
|
* broken store never blocks the conversation. Public callers go
|
|
1109
|
-
* through `appendRow`
|
|
1419
|
+
* through `appendRow` - this method is private on purpose.
|
|
1110
1420
|
*/
|
|
1111
1421
|
persistRow(row) {
|
|
1112
1422
|
if (!this.store)
|
|
@@ -1138,14 +1448,14 @@ export class ReplSession {
|
|
|
1138
1448
|
});
|
|
1139
1449
|
}
|
|
1140
1450
|
/**
|
|
1141
|
-
* Restore a transcript from a stored event log
|
|
1451
|
+
* Restore a transcript from a stored event log - α6.4. Called by
|
|
1142
1452
|
* the CLI bootstrap when the operator runs `pugi resume <id>` or
|
|
1143
1453
|
* picks an entry from the `/resume` picker. Replays each event into
|
|
1144
1454
|
* the local transcript WITHOUT writing back to the store so the
|
|
1145
1455
|
* restore is idempotent.
|
|
1146
1456
|
*
|
|
1147
1457
|
* Implementation note: we briefly disable persistence by setting
|
|
1148
|
-
* `storeErrorEmitted` BEFORE the replay and clearing it after
|
|
1458
|
+
* `storeErrorEmitted` BEFORE the replay and clearing it after - but
|
|
1149
1459
|
* the cleaner path is to bypass `appendRow` entirely and patch
|
|
1150
1460
|
* state directly. We do the latter so persistRow does not double-
|
|
1151
1461
|
* write the restored events.
|
|
@@ -1157,7 +1467,7 @@ export class ReplSession {
|
|
|
1157
1467
|
if (row)
|
|
1158
1468
|
rows.push(row);
|
|
1159
1469
|
}
|
|
1160
|
-
// Cap at MAX_TRANSCRIPT_ROWS
|
|
1470
|
+
// Cap at MAX_TRANSCRIPT_ROWS - the same cap appendRow uses so the
|
|
1161
1471
|
// window math stays consistent post-restore.
|
|
1162
1472
|
const capped = rows.slice(-MAX_TRANSCRIPT_ROWS);
|
|
1163
1473
|
this.patch({ transcript: capped });
|
|
@@ -1193,7 +1503,7 @@ export class ReplSession {
|
|
|
1193
1503
|
// a fresh `agent.step` carries the full body up to the current
|
|
1194
1504
|
// token (matches the wave-2 caching contract above). We pass the
|
|
1195
1505
|
// raw detail through the extractors directly rather than keeping a
|
|
1196
|
-
// separate buffer
|
|
1506
|
+
// separate buffer - but we still record the pre-extraction body so
|
|
1197
1507
|
// a partial open tag is preserved when the next chunk arrives.
|
|
1198
1508
|
this.askBuffer.set(taskId, detail);
|
|
1199
1509
|
const askResult = extractAskTags(detail);
|
|
@@ -1202,7 +1512,7 @@ export class ReplSession {
|
|
|
1202
1512
|
if (this.seenTagSignatures.includes(tag.signature))
|
|
1203
1513
|
continue;
|
|
1204
1514
|
this.recordSeenTag(tag.signature);
|
|
1205
|
-
// Only one pending ask at a time
|
|
1515
|
+
// Only one pending ask at a time - drop additional tags in the
|
|
1206
1516
|
// same step into the cleaned body as a system warning. The
|
|
1207
1517
|
// persona's prompt forbids concurrent asks, so this branch is a
|
|
1208
1518
|
// defensive guard against a misbehaving model.
|
|
@@ -1287,7 +1597,7 @@ export class ReplSession {
|
|
|
1287
1597
|
/**
|
|
1288
1598
|
* Map a stored SessionEvent back into a TranscriptRow for `/resume`
|
|
1289
1599
|
* replay. Returns null when the event has no operator-visible body
|
|
1290
|
-
* (e.g. tool.start without a text payload
|
|
1600
|
+
* (e.g. tool.start without a text payload - those land back as
|
|
1291
1601
|
* tool stream rows, not transcript rows). The shape mirrors the
|
|
1292
1602
|
* `persistRow` mapping in reverse:
|
|
1293
1603
|
*
|
|
@@ -1345,7 +1655,7 @@ function eventToTranscriptRow(event) {
|
|
|
1345
1655
|
/**
|
|
1346
1656
|
* Heuristic: does this text contain Markdown structures that benefit
|
|
1347
1657
|
* from atomic grouping? Code fences, bullet lists, numbered lists,
|
|
1348
|
-
* headings
|
|
1658
|
+
* headings - anything where per-line splitting would fragment visual
|
|
1349
1659
|
* grouping (Claude triple-review P1 PR #369).
|
|
1350
1660
|
*/
|
|
1351
1661
|
function looksLikeMarkdown(text) {
|
|
@@ -1375,6 +1685,31 @@ function safePersonaName(role) {
|
|
|
1375
1685
|
return role;
|
|
1376
1686
|
}
|
|
1377
1687
|
}
|
|
1688
|
+
/**
|
|
1689
|
+
* Render a millisecond delta as a compact human-readable age. Used by
|
|
1690
|
+
* `/context` to surface the oldest working-set entry's age:
|
|
1691
|
+
*
|
|
1692
|
+
* < 60s -> `45s`
|
|
1693
|
+
* < 1h -> `4m`
|
|
1694
|
+
* < 24h -> `2h`
|
|
1695
|
+
* >= 24h -> `3d`
|
|
1696
|
+
*
|
|
1697
|
+
* Negative deltas (clock skew) clamp to `0s`.
|
|
1698
|
+
*/
|
|
1699
|
+
function formatAgeSeconds(deltaMs) {
|
|
1700
|
+
const ms = Math.max(0, deltaMs);
|
|
1701
|
+
const seconds = Math.floor(ms / 1000);
|
|
1702
|
+
if (seconds < 60)
|
|
1703
|
+
return `${seconds}s`;
|
|
1704
|
+
const minutes = Math.floor(seconds / 60);
|
|
1705
|
+
if (minutes < 60)
|
|
1706
|
+
return `${minutes}m`;
|
|
1707
|
+
const hours = Math.floor(minutes / 60);
|
|
1708
|
+
if (hours < 24)
|
|
1709
|
+
return `${hours}h`;
|
|
1710
|
+
const days = Math.floor(hours / 24);
|
|
1711
|
+
return `${days}d`;
|
|
1712
|
+
}
|
|
1378
1713
|
/**
|
|
1379
1714
|
* Convenience: list the legal role slugs the operator can target with
|
|
1380
1715
|
* `/stop`. Surfaced in the slash command help overlay and in the
|
|
@@ -1474,7 +1809,7 @@ function parseStatusFromTail(tail) {
|
|
|
1474
1809
|
/* */
|
|
1475
1810
|
/* Mirrors `tui/ask-modal.tsx#encodeAskVerdict` so the session can */
|
|
1476
1811
|
/* synthesise the operator-side echo without dragging an Ink module */
|
|
1477
|
-
/* into the test surface. The two encoders MUST agree byte-for-byte
|
|
1812
|
+
/* into the test surface. The two encoders MUST agree byte-for-byte - */
|
|
1478
1813
|
/* a divergence would silently mis-prefix the persona's follow-up. */
|
|
1479
1814
|
/* ------------------------------------------------------------------ */
|
|
1480
1815
|
function encodeAskVerdictLocal(verdict) {
|
|
@@ -1629,10 +1964,10 @@ export function synthesiseLocalAskTag(question) {
|
|
|
1629
1964
|
* "<workspace-context-abc>Pugi, привет" -> "привет"
|
|
1630
1965
|
* "обычный ответ без префикса" -> "обычный ответ без префикса"
|
|
1631
1966
|
*
|
|
1632
|
-
* The strip is conservative
|
|
1967
|
+
* The strip is conservative - we only remove the display name when it
|
|
1633
1968
|
* is followed by a separator (comma, colon, dash, space) so a sentence
|
|
1634
1969
|
* that legitimately contains the name mid-text ("спроси у Pugi") is
|
|
1635
|
-
* not mangled. (α6.14.2 wave 5
|
|
1970
|
+
* not mangled. (α6.14.2 wave 5 - CEO dogfood fix.)
|
|
1636
1971
|
*/
|
|
1637
1972
|
export function stripPersonaPrefixEcho(personaSlug, text) {
|
|
1638
1973
|
let working = text.trimStart();
|
|
@@ -1652,7 +1987,7 @@ export function stripPersonaPrefixEcho(personaSlug, text) {
|
|
|
1652
1987
|
}
|
|
1653
1988
|
// Resolve the display name from the canonical roster. Unknown slugs
|
|
1654
1989
|
// (forward-compat with future personas streamed by a newer server)
|
|
1655
|
-
// skip the strip
|
|
1990
|
+
// skip the strip - better to leave the text alone than to mis-strip.
|
|
1656
1991
|
const persona = getPersona(personaSlug);
|
|
1657
1992
|
if (!persona)
|
|
1658
1993
|
return working;
|
|
@@ -1671,7 +2006,7 @@ export function stripPersonaPrefixEcho(personaSlug, text) {
|
|
|
1671
2006
|
// name two or three times back-to-back when the pane prefix also
|
|
1672
2007
|
// injects "▸ Pugi"; without the loop, only the first token would be
|
|
1673
2008
|
// peeled and the operator would still see "▸ Pugi Pugi, координатор".
|
|
1674
|
-
// Cap at 3 iterations
|
|
2009
|
+
// Cap at 3 iterations - beyond that the text is either pathological
|
|
1675
2010
|
// or unrelated and we should not keep chewing it. Bail when an
|
|
1676
2011
|
// iteration makes no progress to avoid infinite loops on a regex that
|
|
1677
2012
|
// matches an empty string (defence-in-depth even though the current
|