@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.
@@ -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 but we cap the recovery to 3 attempts inside 60s
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 CEO dogfood fix.)
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.` the actual reply text was lost. By caching the last
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 α6.4. When non-null, every
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 the
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 without this, a stuck filesystem would spam the operator
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 generous for a real session,
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 `/resume` α6.4. Lists the 10 most recent sessions from
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 used by the `pugi ask "<q>"` shell
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 a second presentAsk
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 the persona should never
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 otherwise the operator sees the exact 404 line the
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 drop the duplicate 404 on the
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 fall back to the loud "give up" path so the
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 the new session is a
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 fall back to the existing
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 multi-line bullets fragmented
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 that envelope is for prompt scaffolding, not for
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` this method is private on purpose.
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 α6.4. Called by
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 but
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 the same cap appendRow uses so the
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 but we still record the pre-extraction body so
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 drop additional tags in the
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 those land back as
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 anything where per-line splitting would fragment visual
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 we only remove the display name when it
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 CEO dogfood fix.)
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 better to leave the text alone than to mis-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 beyond that the text is either pathological
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