@pugi/cli 0.1.0-beta.29 → 0.1.0-beta.30

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.
@@ -0,0 +1,416 @@
1
+ /**
2
+ * Pugi CLI dual-write client — Memory Phase 1 (2026-05-27, Wave 6).
3
+ *
4
+ * Promotes the per-session NDJSON event log (Memory Phase 0, PR #522)
5
+ * to a best-effort Prisma row store via the admin-api endpoint:
6
+ *
7
+ * POST /api/pugi/sessions/:sessionId/events
8
+ *
9
+ * Design contract:
10
+ *
11
+ * 1. Local NDJSON is the SOURCE OF TRUTH. The dual-write client is
12
+ * fire-and-forget — a network failure NEVER blocks the local
13
+ * append path, and the operator never loses data because the
14
+ * .pugi/sessions/<id>/events.<n>.jsonl file is always written
15
+ * first by the SessionStore.
16
+ *
17
+ * 2. Async + debounced. Events are buffered in memory and flushed on
18
+ * a timer (default 250 ms) or when the buffer crosses the batch
19
+ * cap (default 50). The debounce + buffer keeps the network cost
20
+ * proportional to operator activity, not per-event.
21
+ *
22
+ * 3. Retry with backoff. A failed flush retries up to 3 times with
23
+ * exponential backoff (250 ms / 750 ms / 2250 ms). Failure beyond
24
+ * that drops the batch; the next flush brings in the backlog from
25
+ * the local NDJSON via the resume path.
26
+ *
27
+ * 4. Resume marker. The client tracks `lastSyncedSeq` per session in
28
+ * `~/.pugi/memory-sync/<sessionId>.json`. On `flushBacklog()` it
29
+ * reads any events from the local NDJSON whose seq > lastSyncedSeq
30
+ * and posts them in order. The marker is updated only on a
31
+ * successful POST so a crash mid-flush retries the unsent batch.
32
+ *
33
+ * 5. Kill switch. `PUGI_MEMORY_PHASE1_PRISMA_PERSIST_ENABLED=false`
34
+ * shuts the dual-write off entirely (env var, no settings file
35
+ * round-trip). The client is also a no-op when no credentials are
36
+ * available — anon CLI sessions never POST.
37
+ *
38
+ * 6. Schema contract. The kinds mirror
39
+ * `apps/admin-api/src/pugi-session-events/pugi-session-events.types.ts`
40
+ * PUGI_SESSION_EVENT_KINDS. The CLI's broader NDJSON kind set
41
+ * (`user`, `persona`, `rewind-marker`, etc.) is REMAPPED to the
42
+ * Phase-1 closed set inside `eventToPhase1()` — adding a new
43
+ * Phase-1 kind requires a coordinated CLI + server bump.
44
+ *
45
+ * The client carries no Nest / Prisma deps — pure node:fs + global
46
+ * fetch so it boots inside the CLI's REPL workflow without dragging in
47
+ * the admin-api graph.
48
+ */
49
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from 'node:fs';
50
+ import { homedir } from 'node:os';
51
+ import { resolve } from 'node:path';
52
+ import { PUGI_SESSION_EVENT_KINDS, } from './phase1-kinds.js';
53
+ /* ------------------------------------------------------------------ */
54
+ /* Constants */
55
+ /* ------------------------------------------------------------------ */
56
+ export const DEFAULT_DEBOUNCE_MS = 250;
57
+ export const DEFAULT_MAX_BATCH_SIZE = 50;
58
+ export const DEFAULT_MAX_RETRIES = 3;
59
+ /** Server-side hard ceiling (mirror of admin-api MAX_BATCH_EVENTS). */
60
+ const SERVER_MAX_BATCH_EVENTS = 200;
61
+ /** Env kill-switch. Unset / 'true' / '1' = enabled. 'false' / '0' = off. */
62
+ export function isDualWriteEnabledFromEnv(env = process.env) {
63
+ const raw = env.PUGI_MEMORY_PHASE1_PRISMA_PERSIST_ENABLED;
64
+ if (raw === undefined || raw === '')
65
+ return true;
66
+ const v = raw.toLowerCase().trim();
67
+ return v !== 'false' && v !== '0' && v !== 'off' && v !== 'no';
68
+ }
69
+ /** Env override for debounce (ms). Falls back to DEFAULT_DEBOUNCE_MS. */
70
+ export function debounceMsFromEnv(env = process.env) {
71
+ const raw = env.PUGI_MEMORY_DUAL_WRITE_DEBOUNCE_MS;
72
+ if (!raw)
73
+ return DEFAULT_DEBOUNCE_MS;
74
+ const n = Number.parseInt(raw, 10);
75
+ if (!Number.isFinite(n) || n < 0 || n > 60_000)
76
+ return DEFAULT_DEBOUNCE_MS;
77
+ return n;
78
+ }
79
+ export function defaultStateDir(home = homedir()) {
80
+ return resolve(home, '.pugi', 'memory-sync');
81
+ }
82
+ export function syncStatePath(sessionId, stateDir) {
83
+ return resolve(stateDir, `${sessionId}.json`);
84
+ }
85
+ export function readSyncState(sessionId, stateDir = defaultStateDir()) {
86
+ const p = syncStatePath(sessionId, stateDir);
87
+ if (!existsSync(p))
88
+ return 0;
89
+ try {
90
+ const raw = readFileSync(p, 'utf-8');
91
+ const parsed = JSON.parse(raw);
92
+ if (parsed.schema === 1 &&
93
+ typeof parsed.lastSyncedSeq === 'number' &&
94
+ Number.isInteger(parsed.lastSyncedSeq) &&
95
+ parsed.lastSyncedSeq >= 0) {
96
+ return parsed.lastSyncedSeq;
97
+ }
98
+ }
99
+ catch {
100
+ // Best effort — a corrupt state file means "resync from zero".
101
+ return 0;
102
+ }
103
+ return 0;
104
+ }
105
+ export function writeSyncState(sessionId, lastSyncedSeq, stateDir = defaultStateDir()) {
106
+ if (!existsSync(stateDir)) {
107
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
108
+ }
109
+ const p = syncStatePath(sessionId, stateDir);
110
+ const record = {
111
+ schema: 1,
112
+ sessionId,
113
+ lastSyncedSeq,
114
+ updatedAt: new Date().toISOString(),
115
+ };
116
+ // Atomic write: temp + rename so a crash mid-write leaves either the
117
+ // old contents or the new — never a partial JSON document.
118
+ const tmp = `${p}.tmp-${process.pid}-${Date.now()}`;
119
+ writeFileSync(tmp, JSON.stringify(record), { encoding: 'utf-8', mode: 0o600 });
120
+ renameSync(tmp, p);
121
+ }
122
+ /* ------------------------------------------------------------------ */
123
+ /* DualWriteClient */
124
+ /* ------------------------------------------------------------------ */
125
+ /**
126
+ * Compute the next backoff delay (ms). Exponential growth from the
127
+ * debounce base — 1× / 3× / 9× — keeps the retry storm bounded.
128
+ */
129
+ export function nextBackoffMs(attempt, base) {
130
+ // attempt 1 -> base, attempt 2 -> 3×base, attempt 3 -> 9×base.
131
+ return base * Math.pow(3, Math.max(0, attempt - 1));
132
+ }
133
+ /**
134
+ * Tiny await-able sleeper. Public for tests that want a deterministic
135
+ * scheduler — production callers go through the internal debounce
136
+ * timer, not this helper.
137
+ */
138
+ function sleep(ms, scheduler = setTimeout) {
139
+ return new Promise((res) => scheduler(res, Math.max(0, ms)));
140
+ }
141
+ /**
142
+ * Buffered, debounced, retry-capable dual-write client.
143
+ *
144
+ * One instance per active session. The CLI's SessionStore creates one
145
+ * at `openSession()` and disposes it at `archive()` / `process.exit()`.
146
+ * The instance retains its own buffer + flushing promise so concurrent
147
+ * `enqueue()` calls from multiple producers (REPL turn + subagent
148
+ * dispatcher) coalesce into the same batch.
149
+ */
150
+ export class DualWriteClient {
151
+ cfg;
152
+ buffer = [];
153
+ flushTimer = null;
154
+ /**
155
+ * Active flush promise (null when idle). Re-using the in-flight
156
+ * promise lets `enqueue` callers tail-chain a pending flush instead
157
+ * of stacking N flushes when the CLI is in a hot loop.
158
+ */
159
+ flushing = null;
160
+ /** Disposed clients reject further enqueues quietly. */
161
+ disposed = false;
162
+ /** Track the highest server-acknowledged seq. */
163
+ highestSyncedSeq;
164
+ constructor(config) {
165
+ const stateDir = config.stateDir ?? defaultStateDir();
166
+ this.cfg = {
167
+ apiUrl: config.apiUrl.replace(/\/+$/, ''),
168
+ apiKey: config.apiKey,
169
+ sessionId: config.sessionId,
170
+ debounceMs: config.debounceMs ?? debounceMsFromEnv(),
171
+ maxBatchSize: Math.min(config.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE, SERVER_MAX_BATCH_EVENTS),
172
+ maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES,
173
+ fetchImpl: config.fetchImpl ?? globalThis.fetch,
174
+ stateDir,
175
+ };
176
+ this.highestSyncedSeq = readSyncState(config.sessionId, stateDir);
177
+ }
178
+ /** Currently-known synced seq (read-through for callers). */
179
+ get lastSyncedSeq() {
180
+ return this.highestSyncedSeq;
181
+ }
182
+ /**
183
+ * Buffer an event for the next flush. Returns immediately — the
184
+ * actual POST happens on the debounce timer.
185
+ *
186
+ * The producer (SessionStore) is expected to have ALREADY written
187
+ * the local NDJSON line before calling this. The dual-write client
188
+ * never touches the NDJSON itself.
189
+ */
190
+ enqueue(event) {
191
+ if (this.disposed)
192
+ return;
193
+ if (!isValidPhase1Kind(event.kind)) {
194
+ // Quietly ignore events the Phase-1 schema cannot represent. The
195
+ // local NDJSON keeps them; a future Phase-1.1 may widen the kind
196
+ // set.
197
+ return;
198
+ }
199
+ if (!Number.isInteger(event.seq) || event.seq <= 0)
200
+ return;
201
+ this.buffer.push(event);
202
+ // Crossed the batch cap — flush eagerly to bound buffer growth.
203
+ if (this.buffer.length >= this.cfg.maxBatchSize) {
204
+ this.scheduleFlush(0);
205
+ return;
206
+ }
207
+ this.scheduleFlush(this.cfg.debounceMs);
208
+ }
209
+ /**
210
+ * Force any pending events out. Used at session archive / shutdown
211
+ * so the trailing batch lands before the process exits.
212
+ */
213
+ async flush() {
214
+ if (this.flushTimer) {
215
+ clearTimeout(this.flushTimer);
216
+ this.flushTimer = null;
217
+ }
218
+ return this.doFlush();
219
+ }
220
+ /**
221
+ * Mark the client disposed. Subsequent enqueues are no-ops. Pending
222
+ * flush is awaited so the caller can `await client.dispose()` to
223
+ * fence completion.
224
+ */
225
+ async dispose() {
226
+ this.disposed = true;
227
+ if (this.flushTimer) {
228
+ clearTimeout(this.flushTimer);
229
+ this.flushTimer = null;
230
+ }
231
+ if (this.flushing) {
232
+ try {
233
+ await this.flushing;
234
+ }
235
+ catch {
236
+ // ignore
237
+ }
238
+ }
239
+ }
240
+ /**
241
+ * Backlog catch-up. The caller passes the local NDJSON's events
242
+ * (already filtered to the session) — the client posts any whose
243
+ * seq > lastSyncedSeq, in order, in batches of maxBatchSize.
244
+ *
245
+ * Returns the aggregate FlushResult across all batches (sums
246
+ * persisted + duplicate counts, lastSeq is the max).
247
+ */
248
+ async flushBacklog(allEvents) {
249
+ const pending = allEvents
250
+ .filter((e) => e.seq > this.highestSyncedSeq && isValidPhase1Kind(e.kind))
251
+ .sort((a, b) => a.seq - b.seq);
252
+ if (pending.length === 0) {
253
+ return {
254
+ persistedCount: 0,
255
+ duplicateCount: 0,
256
+ lastSeq: this.highestSyncedSeq,
257
+ };
258
+ }
259
+ let totalPersisted = 0;
260
+ let totalDuplicates = 0;
261
+ let maxLastSeq = this.highestSyncedSeq;
262
+ for (let i = 0; i < pending.length; i += this.cfg.maxBatchSize) {
263
+ const batch = pending.slice(i, i + this.cfg.maxBatchSize);
264
+ const result = await this.postBatchWithRetry(batch);
265
+ if (!result)
266
+ break; // surrender — backlog stays for next session
267
+ totalPersisted += result.persistedCount;
268
+ totalDuplicates += result.duplicateCount;
269
+ maxLastSeq = Math.max(maxLastSeq, result.lastSeq);
270
+ }
271
+ return {
272
+ persistedCount: totalPersisted,
273
+ duplicateCount: totalDuplicates,
274
+ lastSeq: maxLastSeq,
275
+ };
276
+ }
277
+ /* ---------------- internal ---------------- */
278
+ scheduleFlush(delay) {
279
+ if (this.flushTimer)
280
+ clearTimeout(this.flushTimer);
281
+ this.flushTimer = setTimeout(() => {
282
+ this.flushTimer = null;
283
+ // doFlush handles its own error swallowing; we drop the floating
284
+ // promise on the floor on purpose so the timer callback returns
285
+ // synchronously (Node requires that).
286
+ void this.doFlush();
287
+ }, delay);
288
+ }
289
+ async doFlush() {
290
+ if (this.flushing)
291
+ return this.flushing;
292
+ if (this.buffer.length === 0)
293
+ return null;
294
+ const drained = this.buffer.splice(0, this.buffer.length);
295
+ this.flushing = this.postBatchWithRetry(drained);
296
+ try {
297
+ return await this.flushing;
298
+ }
299
+ finally {
300
+ this.flushing = null;
301
+ }
302
+ }
303
+ async postBatchWithRetry(batch) {
304
+ if (batch.length === 0)
305
+ return null;
306
+ // Sort + within-batch dedup (defence in depth — the server also
307
+ // enforces monotonic seq + dedup, but enforcing here keeps the
308
+ // wire payload clean and avoids the 400 on a buggy producer).
309
+ const sorted = [...batch].sort((a, b) => a.seq - b.seq);
310
+ const deduped = [];
311
+ let prevSeq = -1;
312
+ for (const ev of sorted) {
313
+ if (ev.seq === prevSeq)
314
+ continue;
315
+ deduped.push(ev);
316
+ prevSeq = ev.seq;
317
+ }
318
+ for (let attempt = 1; attempt <= this.cfg.maxRetries; attempt++) {
319
+ try {
320
+ const result = await this.doPost(deduped);
321
+ // Persist the lastSyncedSeq marker so a crash before the next
322
+ // flush still rediscovers the high-water-mark on restart.
323
+ if (result.lastSeq > this.highestSyncedSeq) {
324
+ this.highestSyncedSeq = result.lastSeq;
325
+ try {
326
+ writeSyncState(this.cfg.sessionId, this.highestSyncedSeq, this.cfg.stateDir);
327
+ }
328
+ catch {
329
+ // Best effort — a marker write failure does not invalidate
330
+ // the on-server data, only the resume hint.
331
+ }
332
+ }
333
+ return result;
334
+ }
335
+ catch (err) {
336
+ if (attempt === this.cfg.maxRetries) {
337
+ // Final surrender — drop the batch silently. Local NDJSON is
338
+ // authoritative; the next session resume can replay the gap.
339
+ return null;
340
+ }
341
+ await sleep(nextBackoffMs(attempt, this.cfg.debounceMs));
342
+ }
343
+ }
344
+ return null;
345
+ }
346
+ async doPost(events) {
347
+ const url = `${this.cfg.apiUrl}/api/pugi/sessions/${encodeURIComponent(this.cfg.sessionId)}/events`;
348
+ const res = await this.cfg.fetchImpl(url, {
349
+ method: 'POST',
350
+ headers: {
351
+ authorization: `Bearer ${this.cfg.apiKey}`,
352
+ 'content-type': 'application/json',
353
+ accept: 'application/json',
354
+ },
355
+ body: JSON.stringify({ events }),
356
+ });
357
+ if (!res.ok) {
358
+ throw new Error(`dual-write POST ${url} returned ${res.status} ${res.statusText}`);
359
+ }
360
+ const body = (await res.json());
361
+ return {
362
+ persistedCount: body.persistedCount ?? 0,
363
+ duplicateCount: body.duplicateCount ?? 0,
364
+ lastSeq: body.lastSeq ?? 0,
365
+ };
366
+ }
367
+ }
368
+ /** Validate a kind string against the Phase-1 closed set. */
369
+ export function isValidPhase1Kind(value) {
370
+ return PUGI_SESSION_EVENT_KINDS.includes(value);
371
+ }
372
+ /* ------------------------------------------------------------------ */
373
+ /* Kind remap (CLI broad set -> Phase-1 closed set) */
374
+ /* ------------------------------------------------------------------ */
375
+ /**
376
+ * Map the CLI's broader NDJSON kind set to the Phase-1 server-side set.
377
+ * Returns `null` when the kind has no Phase-1 representation — the
378
+ * caller should drop that event from the dual-write (the local NDJSON
379
+ * still carries it for offline analysis).
380
+ *
381
+ * The CLI's kind set lives in
382
+ * `apps/pugi-cli/src/core/repl/store/types.ts`:
383
+ *
384
+ * 'user' -> 'turn.user'
385
+ * 'persona' -> 'turn.assistant'
386
+ * 'system' -> 'system'
387
+ * 'tool.start' -> 'tool.call'
388
+ * 'tool.result' -> 'tool.result'
389
+ * 'agent.spawned' -> 'dispatch.start'
390
+ * 'agent.completed'-> 'dispatch.end'
391
+ * 'compaction' -> 'compact.boundary'
392
+ * 'rewind-marker' -> null (Phase-1 has no analog yet)
393
+ */
394
+ export function cliKindToPhase1(cliKind) {
395
+ switch (cliKind) {
396
+ case 'user':
397
+ return 'turn.user';
398
+ case 'persona':
399
+ return 'turn.assistant';
400
+ case 'system':
401
+ return 'system';
402
+ case 'tool.start':
403
+ return 'tool.call';
404
+ case 'tool.result':
405
+ return 'tool.result';
406
+ case 'agent.spawned':
407
+ return 'dispatch.start';
408
+ case 'agent.completed':
409
+ return 'dispatch.end';
410
+ case 'compaction':
411
+ return 'compact.boundary';
412
+ default:
413
+ return null;
414
+ }
415
+ }
416
+ //# sourceMappingURL=dual-write.js.map
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Pugi CLI dual-write client spec — Memory Phase 1 (2026-05-27).
3
+ *
4
+ * Covers:
5
+ * 1. happy path — single batch posts to /api/pugi/sessions/:id/events
6
+ * 2. debounce + batch coalesces multiple enqueues into one POST
7
+ * 3. retry on transient failure with exponential backoff
8
+ * 4. surrender after maxRetries — local NDJSON stays authoritative
9
+ * 5. lastSyncedSeq marker is written + persisted across instances
10
+ * 6. flushBacklog catches up rows with seq > lastSyncedSeq
11
+ * 7. env kill-switch turns dual-write off
12
+ * 8. cliKindToPhase1 remap covers every CLI kind
13
+ * 9. invalid Phase-1 kinds quietly dropped from enqueue
14
+ * 10. dispose awaits the pending flush
15
+ */
16
+ import { afterEach, beforeEach, describe, it } from 'node:test';
17
+ import assert from 'node:assert/strict';
18
+ import { mkdtempSync, rmSync } from 'node:fs';
19
+ import { tmpdir } from 'node:os';
20
+ import { resolve } from 'node:path';
21
+ import { DualWriteClient, cliKindToPhase1, debounceMsFromEnv, isDualWriteEnabledFromEnv, isValidPhase1Kind, nextBackoffMs, readSyncState, writeSyncState, } from './dual-write.js';
22
+ import { PUGI_SESSION_EVENT_KINDS, } from './phase1-kinds.js';
23
+ let tmpDir = '';
24
+ beforeEach(() => {
25
+ tmpDir = mkdtempSync(resolve(tmpdir(), 'pugi-dual-write-'));
26
+ });
27
+ afterEach(() => {
28
+ try {
29
+ rmSync(tmpDir, { recursive: true, force: true });
30
+ }
31
+ catch {
32
+ /* ignore */
33
+ }
34
+ });
35
+ function makeStubFetch(responses) {
36
+ const calls = [];
37
+ let i = 0;
38
+ const fetchImpl = async (input, init) => {
39
+ const url = typeof input === 'string' ? input : input.toString();
40
+ const headers = {};
41
+ if (init?.headers) {
42
+ const h = init.headers;
43
+ for (const k of Object.keys(h))
44
+ headers[k.toLowerCase()] = h[k];
45
+ }
46
+ const bodyRaw = typeof init?.body === 'string' ? init.body : '';
47
+ let body = null;
48
+ try {
49
+ body = bodyRaw ? JSON.parse(bodyRaw) : null;
50
+ }
51
+ catch {
52
+ body = bodyRaw;
53
+ }
54
+ calls.push({ url, body, headers });
55
+ const spec = responses[i++] ?? responses[responses.length - 1] ?? { ok: true };
56
+ if (spec.throw)
57
+ throw spec.throw;
58
+ return {
59
+ ok: spec.ok,
60
+ status: spec.status ?? (spec.ok ? 200 : 500),
61
+ statusText: spec.ok ? 'OK' : 'Internal Server Error',
62
+ json: async () => spec.body ?? {
63
+ persistedCount: 0,
64
+ duplicateCount: 0,
65
+ lastSeq: 0,
66
+ },
67
+ };
68
+ };
69
+ return { fetchImpl, calls };
70
+ }
71
+ /* --------------------------------------------------------------- */
72
+ /* Pure helpers */
73
+ /* --------------------------------------------------------------- */
74
+ describe('dual-write: env helpers', () => {
75
+ it('isDualWriteEnabledFromEnv: defaults to ON', () => {
76
+ assert.equal(isDualWriteEnabledFromEnv({}), true);
77
+ assert.equal(isDualWriteEnabledFromEnv({ PUGI_MEMORY_PHASE1_PRISMA_PERSIST_ENABLED: 'true' }), true);
78
+ });
79
+ it('isDualWriteEnabledFromEnv: OFF on common false-ish values', () => {
80
+ assert.equal(isDualWriteEnabledFromEnv({ PUGI_MEMORY_PHASE1_PRISMA_PERSIST_ENABLED: 'false' }), false);
81
+ assert.equal(isDualWriteEnabledFromEnv({ PUGI_MEMORY_PHASE1_PRISMA_PERSIST_ENABLED: '0' }), false);
82
+ assert.equal(isDualWriteEnabledFromEnv({ PUGI_MEMORY_PHASE1_PRISMA_PERSIST_ENABLED: 'off' }), false);
83
+ });
84
+ it('debounceMsFromEnv: parses + clamps + falls back', () => {
85
+ assert.equal(debounceMsFromEnv({}), 250);
86
+ assert.equal(debounceMsFromEnv({ PUGI_MEMORY_DUAL_WRITE_DEBOUNCE_MS: '500' }), 500);
87
+ // Bogus -> default.
88
+ assert.equal(debounceMsFromEnv({ PUGI_MEMORY_DUAL_WRITE_DEBOUNCE_MS: 'abc' }), 250);
89
+ // Negative -> default.
90
+ assert.equal(debounceMsFromEnv({ PUGI_MEMORY_DUAL_WRITE_DEBOUNCE_MS: '-5' }), 250);
91
+ // Too large -> default.
92
+ assert.equal(debounceMsFromEnv({ PUGI_MEMORY_DUAL_WRITE_DEBOUNCE_MS: '120000' }), 250);
93
+ });
94
+ it('nextBackoffMs: 1x / 3x / 9x growth', () => {
95
+ assert.equal(nextBackoffMs(1, 100), 100);
96
+ assert.equal(nextBackoffMs(2, 100), 300);
97
+ assert.equal(nextBackoffMs(3, 100), 900);
98
+ });
99
+ it('cliKindToPhase1: covers every CLI kind we know about', () => {
100
+ assert.equal(cliKindToPhase1('user'), 'turn.user');
101
+ assert.equal(cliKindToPhase1('persona'), 'turn.assistant');
102
+ assert.equal(cliKindToPhase1('system'), 'system');
103
+ assert.equal(cliKindToPhase1('tool.start'), 'tool.call');
104
+ assert.equal(cliKindToPhase1('tool.result'), 'tool.result');
105
+ assert.equal(cliKindToPhase1('agent.spawned'), 'dispatch.start');
106
+ assert.equal(cliKindToPhase1('agent.completed'), 'dispatch.end');
107
+ assert.equal(cliKindToPhase1('compaction'), 'compact.boundary');
108
+ // rewind-marker and any other -> null (the CLI keeps these in NDJSON only).
109
+ assert.equal(cliKindToPhase1('rewind-marker'), null);
110
+ assert.equal(cliKindToPhase1('totally-unknown'), null);
111
+ });
112
+ it('isValidPhase1Kind matches the closed set', () => {
113
+ for (const k of PUGI_SESSION_EVENT_KINDS) {
114
+ assert.equal(isValidPhase1Kind(k), true);
115
+ }
116
+ assert.equal(isValidPhase1Kind('user'), false); // CLI kind, not Phase-1
117
+ assert.equal(isValidPhase1Kind('nope'), false);
118
+ });
119
+ });
120
+ /* --------------------------------------------------------------- */
121
+ /* lastSyncedSeq marker */
122
+ /* --------------------------------------------------------------- */
123
+ describe('dual-write: sync state marker', () => {
124
+ it('reads 0 when no marker on disk', () => {
125
+ assert.equal(readSyncState('sess-1', tmpDir), 0);
126
+ });
127
+ it('writes + reads back monotonic seq', () => {
128
+ writeSyncState('sess-1', 42, tmpDir);
129
+ assert.equal(readSyncState('sess-1', tmpDir), 42);
130
+ writeSyncState('sess-1', 100, tmpDir);
131
+ assert.equal(readSyncState('sess-1', tmpDir), 100);
132
+ });
133
+ it('isolates per-session', () => {
134
+ writeSyncState('sess-a', 5, tmpDir);
135
+ writeSyncState('sess-b', 10, tmpDir);
136
+ assert.equal(readSyncState('sess-a', tmpDir), 5);
137
+ assert.equal(readSyncState('sess-b', tmpDir), 10);
138
+ });
139
+ });
140
+ /* --------------------------------------------------------------- */
141
+ /* DualWriteClient end-to-end */
142
+ /* --------------------------------------------------------------- */
143
+ describe('DualWriteClient', () => {
144
+ it('flushes a single batch on demand', async () => {
145
+ const { fetchImpl, calls } = makeStubFetch([
146
+ { ok: true, body: { persistedCount: 2, duplicateCount: 0, lastSeq: 2 } },
147
+ ]);
148
+ const client = new DualWriteClient({
149
+ apiUrl: 'http://localhost',
150
+ apiKey: 'test-key',
151
+ sessionId: 'sess-1',
152
+ fetchImpl,
153
+ debounceMs: 5,
154
+ stateDir: tmpDir,
155
+ });
156
+ client.enqueue({ seq: 1, kind: 'turn.user', payload: { text: 'hi' } });
157
+ client.enqueue({ seq: 2, kind: 'turn.assistant', payload: { text: 'hello' } });
158
+ const result = await client.flush();
159
+ await client.dispose();
160
+ assert.equal(calls.length, 1);
161
+ assert.equal(calls[0].headers['authorization'], 'Bearer test-key');
162
+ const sentBody = calls[0].body;
163
+ assert.equal(sentBody.events.length, 2);
164
+ assert.equal(sentBody.events[0].seq, 1);
165
+ assert.equal(sentBody.events[1].kind, 'turn.assistant');
166
+ assert.equal(result?.persistedCount, 2);
167
+ assert.equal(result?.lastSeq, 2);
168
+ // Marker persisted.
169
+ assert.equal(readSyncState('sess-1', tmpDir), 2);
170
+ });
171
+ it('retries on transient failure with exponential backoff', async () => {
172
+ const { fetchImpl, calls } = makeStubFetch([
173
+ { ok: false, status: 503, body: {} },
174
+ { ok: false, status: 503, body: {} },
175
+ { ok: true, body: { persistedCount: 1, duplicateCount: 0, lastSeq: 5 } },
176
+ ]);
177
+ const client = new DualWriteClient({
178
+ apiUrl: 'http://localhost',
179
+ apiKey: 'k',
180
+ sessionId: 'sess-r',
181
+ fetchImpl,
182
+ debounceMs: 1, // base for backoff -> 1ms / 3ms / 9ms; fast test
183
+ maxRetries: 3,
184
+ stateDir: tmpDir,
185
+ });
186
+ client.enqueue({ seq: 5, kind: 'turn.user', payload: {} });
187
+ const result = await client.flush();
188
+ await client.dispose();
189
+ assert.equal(calls.length, 3);
190
+ assert.equal(result?.lastSeq, 5);
191
+ assert.equal(readSyncState('sess-r', tmpDir), 5);
192
+ });
193
+ it('surrenders after maxRetries — local NDJSON stays the truth', async () => {
194
+ const { fetchImpl, calls } = makeStubFetch([
195
+ { ok: false, status: 500, body: {} },
196
+ { ok: false, status: 500, body: {} },
197
+ { ok: false, status: 500, body: {} },
198
+ ]);
199
+ const client = new DualWriteClient({
200
+ apiUrl: 'http://localhost',
201
+ apiKey: 'k',
202
+ sessionId: 'sess-fail',
203
+ fetchImpl,
204
+ debounceMs: 1,
205
+ maxRetries: 3,
206
+ stateDir: tmpDir,
207
+ });
208
+ client.enqueue({ seq: 1, kind: 'turn.user', payload: {} });
209
+ const result = await client.flush();
210
+ await client.dispose();
211
+ assert.equal(calls.length, 3);
212
+ assert.equal(result, null); // surrender
213
+ // Marker NOT written — next session retries from zero.
214
+ assert.equal(readSyncState('sess-fail', tmpDir), 0);
215
+ });
216
+ it('drops events whose kind is not in the Phase-1 set', async () => {
217
+ const { fetchImpl, calls } = makeStubFetch([
218
+ { ok: true, body: { persistedCount: 1, duplicateCount: 0, lastSeq: 1 } },
219
+ ]);
220
+ const client = new DualWriteClient({
221
+ apiUrl: 'http://localhost',
222
+ apiKey: 'k',
223
+ sessionId: 'sess-d',
224
+ fetchImpl,
225
+ debounceMs: 5,
226
+ stateDir: tmpDir,
227
+ });
228
+ // The string is broader than Phase-1 — the client filters silently.
229
+ client.enqueue({ seq: 1, kind: 'rewind-marker', payload: {} });
230
+ client.enqueue({ seq: 2, kind: 'turn.user', payload: {} });
231
+ await client.flush();
232
+ await client.dispose();
233
+ const sent = calls[0].body;
234
+ assert.equal(sent.events.length, 1);
235
+ assert.equal(sent.events[0].seq, 2);
236
+ });
237
+ it('flushBacklog skips events <= lastSyncedSeq and posts the rest', async () => {
238
+ const { fetchImpl, calls } = makeStubFetch([
239
+ { ok: true, body: { persistedCount: 2, duplicateCount: 0, lastSeq: 10 } },
240
+ ]);
241
+ // Seed a marker: last synced was seq=5.
242
+ writeSyncState('sess-bk', 5, tmpDir);
243
+ const client = new DualWriteClient({
244
+ apiUrl: 'http://localhost',
245
+ apiKey: 'k',
246
+ sessionId: 'sess-bk',
247
+ fetchImpl,
248
+ stateDir: tmpDir,
249
+ });
250
+ const allEvents = [
251
+ { seq: 1, kind: 'turn.user', payload: {} }, // skip
252
+ { seq: 5, kind: 'turn.assistant', payload: {} }, // skip (<=5)
253
+ { seq: 7, kind: 'turn.user', payload: {} }, // POST
254
+ { seq: 10, kind: 'turn.assistant', payload: {} }, // POST
255
+ ];
256
+ const result = await client.flushBacklog(allEvents);
257
+ assert.equal(calls.length, 1);
258
+ const sent = calls[0].body;
259
+ assert.equal(sent.events.length, 2);
260
+ assert.equal(sent.events[0].seq, 7);
261
+ assert.equal(sent.events[1].seq, 10);
262
+ assert.equal(result.persistedCount, 2);
263
+ assert.equal(result.lastSeq, 10);
264
+ assert.equal(readSyncState('sess-bk', tmpDir), 10);
265
+ await client.dispose();
266
+ });
267
+ it('dispose awaits the in-flight flush', async () => {
268
+ // Use a fetch that resolves after a tiny delay so the dispose has to
269
+ // wait.
270
+ let resolved = false;
271
+ const fetchImpl = async () => {
272
+ await new Promise((r) => setTimeout(r, 10));
273
+ resolved = true;
274
+ return {
275
+ ok: true,
276
+ status: 200,
277
+ statusText: 'OK',
278
+ json: async () => ({ persistedCount: 1, duplicateCount: 0, lastSeq: 1 }),
279
+ };
280
+ };
281
+ const client = new DualWriteClient({
282
+ apiUrl: 'http://localhost',
283
+ apiKey: 'k',
284
+ sessionId: 'sess-disp',
285
+ fetchImpl,
286
+ debounceMs: 1,
287
+ stateDir: tmpDir,
288
+ });
289
+ client.enqueue({ seq: 1, kind: 'turn.user', payload: {} });
290
+ // Schedule a flush + immediately dispose; dispose must wait.
291
+ const flushPromise = client.flush();
292
+ await client.dispose();
293
+ await flushPromise;
294
+ assert.equal(resolved, true);
295
+ });
296
+ });
297
+ //# sourceMappingURL=dual-write.spec.js.map
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Phase-1 event kind closed set — mirror of
3
+ * `apps/admin-api/src/pugi-session-events/pugi-session-events.types.ts`
4
+ * PUGI_SESSION_EVENT_KINDS.
5
+ *
6
+ * Kept as a separate file so the CLI does not import from admin-api
7
+ * (would drag the whole Nest graph). The TWO lists must stay in
8
+ * lockstep — a new kind requires editing both files.
9
+ */
10
+ export const PUGI_SESSION_EVENT_KINDS = [
11
+ 'turn.user',
12
+ 'turn.assistant',
13
+ 'tool.call',
14
+ 'tool.result',
15
+ 'dispatch.start',
16
+ 'dispatch.end',
17
+ 'compact.boundary',
18
+ 'system',
19
+ ];
20
+ //# sourceMappingURL=phase1-kinds.js.map
@@ -580,6 +580,73 @@ export class ReplSession {
580
580
  getDispatchState() {
581
581
  return this.fsm.current;
582
582
  }
583
+ /**
584
+ * Wave 6 BT 8 (Claude Code parity): Esc-Esc walkback. Trim the last
585
+ * operator/persona turn pair from the in-memory transcript so the
586
+ * model's next call sees the conversation as if the most recent
587
+ * turn never happened. The local SessionStore still has the events
588
+ * on disk (append-only); the in-memory mask is advisory and the next
589
+ * `/compact` boundary will fold them naturally.
590
+ *
591
+ * Refusal modes:
592
+ * - `'no-turn'` - transcript has no operator/persona row to pop.
593
+ * - `'in-flight'` - dispatch is mid-flight; popping would race with
594
+ * the streaming persona row. The operator must
595
+ * cancel (Ctrl+C) before walking back.
596
+ *
597
+ * Success mode:
598
+ * - `'walked-back'` - the trailing persona row + the operator row
599
+ * that triggered it are gone from the transcript.
600
+ * A `↩ walked back 1 turn` status row is appended
601
+ * so the operator sees the state change without
602
+ * guessing.
603
+ *
604
+ * The mask is in-memory only on purpose. Disk-side rewind already has
605
+ * a separate first-class command (`/rewind`) with checkpoint
606
+ * semantics — the Esc-Esc shortcut is a one-tap "oops, undo that" for
607
+ * the live transcript, NOT a transactional rollback.
608
+ */
609
+ walkbackLastTurn() {
610
+ // Refuse while a dispatch is running. Popping the operator row that
611
+ // is currently driving the model's response would leave the persona
612
+ // line orphaned on the next streamed chunk; the FSM also lacks a
613
+ // clean teardown path here. The operator gets a one-line refusal
614
+ // and can Ctrl+C first if they really want to walk back.
615
+ const current = this.fsm.current;
616
+ if (current !== 'idle' && current !== 'completed'
617
+ && current !== 'aborted' && current !== 'failed') {
618
+ this.appendSystemLine('Walkback refused: dispatch in flight. Cancel with Ctrl+C, then Esc-Esc again.');
619
+ return 'in-flight';
620
+ }
621
+ // Find the trailing operator row. Walking backwards because the
622
+ // transcript is append-only and the most recent operator turn is
623
+ // by definition the last `source === 'operator'` row.
624
+ const transcript = this.state.transcript;
625
+ let operatorIdx = -1;
626
+ for (let i = transcript.length - 1; i >= 0; i -= 1) {
627
+ const row = transcript[i];
628
+ if (row.source === 'operator') {
629
+ operatorIdx = i;
630
+ break;
631
+ }
632
+ }
633
+ if (operatorIdx === -1) {
634
+ // No operator turn to pop. Quiet refusal — surfacing a "nothing
635
+ // to undo" line on every accidental double-Esc would be noisy.
636
+ return 'no-turn';
637
+ }
638
+ // Trim everything from the operator row onward (its echo + any
639
+ // persona/system rows that landed in response). The slice keeps
640
+ // every row BEFORE the operator turn, which is the conversation
641
+ // exactly as it stood right before the operator pressed Enter.
642
+ const trimmed = transcript.slice(0, operatorIdx);
643
+ this.patch({ transcript: trimmed });
644
+ // Status row so the operator sees the state change without
645
+ // guessing. Brand voice: single ASCII line, return-arrow glyph
646
+ // (U+21A9) which renders across every modern terminal.
647
+ this.appendSystemLine('↩ walked back 1 turn');
648
+ return 'walked-back';
649
+ }
583
650
  /**
584
651
  * Current cancellation token. Returned for the tool execution path
585
652
  * (file-tools.ts) so it can pass the token down into a ToolContext
@@ -1089,7 +1156,38 @@ export class ReplSession {
1089
1156
  // single-sourced. The session module owns the in-memory
1090
1157
  // transcript echo (system line + banner row) so the operator
1091
1158
  // sees the marker land without a fresh REPL bootstrap.
1092
- await this.dispatchCompact('manual');
1159
+ //
1160
+ // Wave 6 BT 8 (Claude Code parity): `--force` bypasses the
1161
+ // noop-empty guard so the operator can compact even short
1162
+ // sessions (useful before a manual checkpoint).
1163
+ await this.dispatchCompact('manual', { force: verdict.force });
1164
+ return verdict;
1165
+ }
1166
+ case 'model': {
1167
+ // Wave 6 BT 8 (Claude Code parity): /model lists OR selects the
1168
+ // active model. Slash + top-level CLI share `runModelCommand`.
1169
+ // The session module forwards writeOutput → appendSystemLine so
1170
+ // the menu + the confirmation line land inline in the
1171
+ // transcript. Tier override is undefined at the slash surface;
1172
+ // the runner defaults to 'team' so unauthenticated operators
1173
+ // see every model. Server-side calls enforce the real tier cap.
1174
+ try {
1175
+ const { runModelCommand } = await import('../../runtime/commands/model.js');
1176
+ await runModelCommand({ slug: verdict.slug }, {
1177
+ workspaceRoot: process.cwd(),
1178
+ writeOutput: (line) => {
1179
+ const trimmed = line.replace(/\n+$/u, '');
1180
+ if (trimmed.length > 0)
1181
+ this.appendSystemLine(trimmed);
1182
+ else
1183
+ this.appendSystemLine('');
1184
+ },
1185
+ });
1186
+ }
1187
+ catch (error) {
1188
+ const message = error instanceof Error ? error.message : String(error);
1189
+ this.appendSystemLine(`/model failed: ${message}`);
1190
+ }
1093
1191
  return verdict;
1094
1192
  }
1095
1193
  case 'rewind': {
@@ -1442,7 +1540,7 @@ export class ReplSession {
1442
1540
  * `trigger='auto'` for the threshold gate. The runner records the
1443
1541
  * trigger in the marker payload so the banner can distinguish them.
1444
1542
  */
1445
- async dispatchCompact(trigger) {
1543
+ async dispatchCompact(trigger, options = {}) {
1446
1544
  if (!this.store || !this.localSessionId) {
1447
1545
  this.appendSystemLine('Local session store is disabled — /compact is unavailable.');
1448
1546
  return;
@@ -1454,6 +1552,7 @@ export class ReplSession {
1454
1552
  sessionId: this.localSessionId,
1455
1553
  store: this.store,
1456
1554
  trigger,
1555
+ force: options.force === true,
1457
1556
  writeOutput: (_payload, text) => {
1458
1557
  if (text.length > 0)
1459
1558
  this.appendSystemLine(text);
@@ -65,7 +65,7 @@ export const SLASH_COMMAND_HELP = Object.freeze([
65
65
  { name: 'clear', args: '', gloss: 'Clear conversation pane', group: 'Session' },
66
66
  { name: 'resume', args: '', gloss: 'Pick a stored session to restore', group: 'Session' },
67
67
  { name: 'context', args: '', gloss: 'Show three-tier context summary (Tier 0 skeleton + Tier 1 working set)', group: 'Session' },
68
- { name: 'compact', args: '', gloss: 'Summarise older turns into a boundary marker (leak L8)', group: 'Session' },
68
+ { name: 'compact', args: '[--force]', gloss: 'Summarise older turns into a boundary marker (leak L8). --force bypasses the noop-empty guard', group: 'Session' },
69
69
  { name: 'rewind', args: '[N | --to <id>]', gloss: 'Roll the conversation back to a checkpoint (leak L9)', group: 'Session' },
70
70
  { name: 'memory', args: '', gloss: 'Session memory editor (α6.5b)', group: 'Session', stub: true },
71
71
  { name: 'init', args: '', gloss: 'Scaffold .pugi/ in the current workspace (β1 Sl11)', group: 'Session' },
@@ -82,6 +82,7 @@ export const SLASH_COMMAND_HELP = Object.freeze([
82
82
  { name: 'privacy', args: '', gloss: 'Show privacy mode + contract', group: 'Settings' },
83
83
  { name: 'permissions', args: '[mode] [--persist]', gloss: 'Show or flip permission mode (plan / ask / allow / bypass) (also: /plan)', group: 'Settings' },
84
84
  { name: 'plan', args: '[--back | --persist] [<prompt>]', gloss: 'Switch to plan mode (read-only). Same as /permissions plan, slicker UX.', group: 'Settings' },
85
+ { name: 'model', args: '[<slug>]', gloss: 'Show or select the active model. Bare /model lists tier-gated options', group: 'Settings' },
85
86
  { name: 'budget', args: '', gloss: 'Show usage budget', group: 'Settings', stub: true },
86
87
  { name: 'mcp', args: '[sub]', gloss: 'MCP servers — list / trust / deny / install / serve / perms', group: 'Settings' },
87
88
  { name: 'style', args: '[name] [--persist|--reset|--list]', gloss: 'Output-style preset (default / terse / explanatory / russian-formal / casual)', group: 'Settings' },
@@ -379,6 +380,27 @@ export function parseSlashCommand(input) {
379
380
  }
380
381
  return { kind: 'plan', back, persist, autoBack, prompt };
381
382
  }
383
+ case 'model': {
384
+ // Wave 6 BT 8 (Claude Code parity): `/model [<slug>]`. Bare form
385
+ // prints the tier-gated model menu + current selection; with a
386
+ // slug it flips workspace selection. Slug grammar (loose): alnum
387
+ // + dash + dot + slash. Anything outside that range becomes an
388
+ // error verdict so the operator sees a clear message instead of a
389
+ // silent no-op. Whitespace inside the tail = multiple tokens = we
390
+ // take the first; the help gloss documents single-slug usage.
391
+ const trimmedTail = tail.trim();
392
+ if (trimmedTail.length === 0) {
393
+ return { kind: 'model', slug: undefined };
394
+ }
395
+ const firstToken = trimmedTail.split(/\s+/)[0] ?? '';
396
+ if (!/^[A-Za-z0-9][A-Za-z0-9._\-\/]{0,63}$/.test(firstToken)) {
397
+ return {
398
+ kind: 'error',
399
+ message: `/model: invalid slug '${firstToken}'. Use letters / digits / '-' / '.' / '/' only.`,
400
+ };
401
+ }
402
+ return { kind: 'model', slug: firstToken };
403
+ }
382
404
  case 'mcp': {
383
405
  // β4 Sl7: tokenize the tail. Empty tail -> `list` (matches CLI).
384
406
  // Quoting / shell-escapes are NOT supported — the slash surface is
@@ -453,11 +475,14 @@ export function parseSlashCommand(input) {
453
475
  }
454
476
  case 'compact': {
455
477
  // Leak L8 (2026-05-27): graduated from stub. The session module
456
- // owns the summariser round-trip; tail args are ignored today
457
- // because the surface is parameterless. Operators wanting a
478
+ // owns the summariser round-trip. Wave 6 BT 8: `--force` overrides
479
+ // the noop-empty guard. Unknown flags fall through silently per
480
+ // the existing tail-tolerance behaviour (operators wanting a
458
481
  // per-session compact run `pugi compact --session <id>` from a
459
- // fresh shell.
460
- return { kind: 'compact' };
482
+ // fresh shell).
483
+ const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
484
+ const force = tokens.some((t) => t === '--force' || t === '-f');
485
+ return { kind: 'compact', force };
461
486
  }
462
487
  case 'rewind': {
463
488
  // Leak L9 (2026-05-27): `/rewind [N | --to <id>]`. Tokenize the
@@ -644,9 +644,14 @@ async function dispatchUndo(args, flags, session) {
644
644
  * sourced.
645
645
  */
646
646
  async function dispatchCompact(args, flags, _session) {
647
+ // Wave 6 BT 8 (Claude Code parity): parse `--force` / `-f` so the
648
+ // operator can produce a marker against a short session. Auto-trigger
649
+ // paths never pass this flag — only the explicit CLI / slash invocation.
650
+ const force = args.some((t) => t === '--force' || t === '-f');
647
651
  const result = await runCompactCommand(args, {
648
652
  workspaceRoot: process.cwd(),
649
653
  writeOutput: (payload, text) => writeOutput(flags, payload, text),
654
+ force,
650
655
  });
651
656
  if (result.status === 'failed_no_session'
652
657
  || result.status === 'failed_transport'
@@ -88,13 +88,14 @@ export async function runCompactCommand(_args, ctx) {
88
88
  reason: `Could not load events: ${errMsg(error)}`,
89
89
  });
90
90
  }
91
- if (events.length < MIN_EVENTS_TO_COMPACT) {
91
+ if (events.length < MIN_EVENTS_TO_COMPACT && ctx.force !== true) {
92
92
  return emit(ctx, {
93
93
  command: 'compact',
94
94
  status: 'noop_empty',
95
95
  sessionId,
96
96
  trigger,
97
- reason: `Only ${events.length} events on disk; need at least ${MIN_EVENTS_TO_COMPACT}.`,
97
+ reason: `Only ${events.length} events on disk; need at least ${MIN_EVENTS_TO_COMPACT}. `
98
+ + 'Pass --force to compact anyway.',
98
99
  });
99
100
  }
100
101
  // Locate the source slice: everything strictly after the latest
@@ -0,0 +1,237 @@
1
+ /**
2
+ * `/model` / `pugi model` — Wave 6 BT 8 (Claude Code parity).
3
+ *
4
+ * Lists OR selects the active model. The MVP uses a hardcoded
5
+ * tier-aware registry mirrored from the per-model price ladder in
6
+ * `core/repl/model-pricing.ts` so the operator sees the same models
7
+ * the cost meter understands. A future iteration can swap the static
8
+ * registry for an admin-api `GET /api/pugi/models` round-trip — the
9
+ * runner contract (`MODEL_REGISTRY` + `runModelCommand`) is the right
10
+ * shape for that swap because the renderer + writer paths are
11
+ * decoupled from the source.
12
+ *
13
+ * Persistence lands at `<workspaceRoot>/.pugi/settings.json` under a
14
+ * top-level `model.slug` key. We extend the file directly with a
15
+ * round-trip read-merge-write so we do not invalidate the existing
16
+ * Zod schema in `core/settings.ts` — the schema there reads only the
17
+ * keys it knows about, leaving `model.*` as a forward-compatible
18
+ * passthrough.
19
+ *
20
+ * Tier gating mirrors the four-tier pricing (Free / Founder $20 /
21
+ * Builder $99 / Team $199). The slugs here are the same ones the
22
+ * cost meter renders, so the operator can always swap to a cheaper
23
+ * fallback if they hit a quota wall.
24
+ */
25
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
26
+ import { dirname, resolve } from 'node:path';
27
+ /**
28
+ * Tier rank for comparing "user tier >= model.minTier". The order is
29
+ * Free < Founder < Builder < Team. The cabinet's pricing module owns
30
+ * the canonical pricing copy; the CLI just needs the rank to filter
31
+ * the menu.
32
+ */
33
+ export const TIER_RANK = Object.freeze({
34
+ free: 0,
35
+ founder: 1,
36
+ builder: 2,
37
+ team: 3,
38
+ });
39
+ /**
40
+ * Static model registry. Sources:
41
+ * - Anthropic Claude family (claude-opus, claude-sonnet, claude-haiku):
42
+ * 2026-05-26 list prices.
43
+ * - OpenAI (gpt-4o, gpt-4o-mini, o3, o3-mini): 2026-05-26 list prices.
44
+ * - Mistral (mistral-large, mistral-small): 2026-05-26 list prices.
45
+ *
46
+ * Family fallbacks (`claude-opus-4-7` falling back onto the
47
+ * `claude-opus-` family entry in `model-pricing.ts`) live in the price
48
+ * ladder, not here — the registry is the canonical selectable surface.
49
+ */
50
+ export const MODEL_REGISTRY = Object.freeze([
51
+ // Free tier — cheap defaults so the meter never blanks on a fresh signup.
52
+ {
53
+ slug: 'claude-haiku-4-5',
54
+ label: 'Claude Haiku 4.5',
55
+ minTier: 'free',
56
+ inputUsdPerM: 0.8,
57
+ outputUsdPerM: 4.0,
58
+ summary: 'Fast + cheap. Default for quick dispatches and the free tier.',
59
+ },
60
+ {
61
+ slug: 'gpt-4o-mini',
62
+ label: 'GPT-4o mini',
63
+ minTier: 'free',
64
+ inputUsdPerM: 0.15,
65
+ outputUsdPerM: 0.6,
66
+ summary: 'Cheapest mainstream model. Good for short turns.',
67
+ },
68
+ // Founder ($20) — mid-tier mainstream models.
69
+ {
70
+ slug: 'claude-sonnet-4-6',
71
+ label: 'Claude Sonnet 4.6',
72
+ minTier: 'founder',
73
+ inputUsdPerM: 3.0,
74
+ outputUsdPerM: 15.0,
75
+ summary: 'Balanced for code dispatch. Strong tool use.',
76
+ },
77
+ {
78
+ slug: 'gpt-4o',
79
+ label: 'GPT-4o',
80
+ minTier: 'founder',
81
+ inputUsdPerM: 2.5,
82
+ outputUsdPerM: 10.0,
83
+ summary: 'Multimodal-capable. Good general-purpose flagship.',
84
+ },
85
+ // Builder ($99) — mid-strong reasoning + Mistral options.
86
+ {
87
+ slug: 'o3-mini',
88
+ label: 'OpenAI o3-mini',
89
+ minTier: 'builder',
90
+ inputUsdPerM: 1.1,
91
+ outputUsdPerM: 4.4,
92
+ summary: 'Cheaper reasoning. Good for plan/review turns.',
93
+ },
94
+ {
95
+ slug: 'mistral-large',
96
+ label: 'Mistral Large',
97
+ minTier: 'builder',
98
+ inputUsdPerM: 2.0,
99
+ outputUsdPerM: 6.0,
100
+ summary: 'Strong open-weight reasoning. EU-hosted option.',
101
+ },
102
+ // Team ($199) — top-tier reasoning.
103
+ {
104
+ slug: 'claude-opus-4-7',
105
+ label: 'Claude Opus 4.7',
106
+ minTier: 'team',
107
+ inputUsdPerM: 15.0,
108
+ outputUsdPerM: 75.0,
109
+ summary: 'Flagship reasoning. Best for hard refactors + multi-file edits.',
110
+ },
111
+ {
112
+ slug: 'o3',
113
+ label: 'OpenAI o3',
114
+ minTier: 'team',
115
+ inputUsdPerM: 10.0,
116
+ outputUsdPerM: 40.0,
117
+ summary: 'Strong reasoning + deliberation. Good for plan/critique loops.',
118
+ },
119
+ ]);
120
+ /** Returns the registry filtered to models the operator's tier can use. */
121
+ export function modelsForTier(tier) {
122
+ const rank = TIER_RANK[tier];
123
+ return MODEL_REGISTRY.filter((m) => TIER_RANK[m.minTier] <= rank);
124
+ }
125
+ /** Looks up a descriptor by slug. */
126
+ export function lookupModel(slug) {
127
+ return MODEL_REGISTRY.find((m) => m.slug === slug);
128
+ }
129
+ /**
130
+ * Entry point for the slash + the top-level CLI. Side effects:
131
+ * - command.slug undefined: prints the tier-gated menu + current row.
132
+ * - command.slug set + unknown: prints a one-line error.
133
+ * - command.slug set + above tier: prints a tier-lock message.
134
+ * - command.slug set + valid + reachable: merges `model.slug` into
135
+ * <workspaceRoot>/.pugi/settings.json and prints a confirmation.
136
+ *
137
+ * The function never throws on a missing settings file — it creates
138
+ * `.pugi/` + writes a fresh object. Read failures (malformed JSON) are
139
+ * surfaced as a system line + the runner refuses to write so the
140
+ * operator can recover by hand.
141
+ */
142
+ export async function runModelCommand(command, ctx) {
143
+ const tier = ctx.tier ?? 'team';
144
+ const fs = ctx.fs ?? defaultFs(ctx.workspaceRoot);
145
+ if (command.slug === undefined) {
146
+ return renderMenu(ctx, fs, tier);
147
+ }
148
+ const descriptor = lookupModel(command.slug);
149
+ if (!descriptor) {
150
+ ctx.writeOutput(`/model: unknown slug '${command.slug}'. Run /model to see the menu.`);
151
+ return { command: 'model', status: 'unknown_slug', reason: command.slug };
152
+ }
153
+ if (TIER_RANK[descriptor.minTier] > TIER_RANK[tier]) {
154
+ ctx.writeOutput(`/model: '${descriptor.slug}' requires the ${descriptor.minTier} tier. `
155
+ + `You are on '${tier}'. Run /model to see available options.`);
156
+ return { command: 'model', status: 'tier_locked', slug: descriptor.slug };
157
+ }
158
+ // Merge + persist. Any IO error becomes a one-line warning; the
159
+ // session continues without crashing.
160
+ try {
161
+ const current = fs.readSettings() ?? {};
162
+ const nextSettings = {
163
+ ...current,
164
+ model: {
165
+ ...(typeof current.model === 'object' && current.model !== null
166
+ ? current.model
167
+ : {}),
168
+ slug: descriptor.slug,
169
+ },
170
+ };
171
+ fs.writeSettings(nextSettings);
172
+ }
173
+ catch (error) {
174
+ const message = error instanceof Error ? error.message : String(error);
175
+ ctx.writeOutput(`/model: persist failed — ${message}. Selection is session-only.`);
176
+ }
177
+ ctx.writeOutput(`Model set to '${descriptor.label}' (${descriptor.slug}). ` +
178
+ `Cost: $${descriptor.inputUsdPerM.toFixed(2)}/M in, $${descriptor.outputUsdPerM.toFixed(2)}/M out.`);
179
+ return { command: 'model', status: 'selected', slug: descriptor.slug };
180
+ }
181
+ function renderMenu(ctx, fs, tier) {
182
+ const current = readCurrentSlug(fs);
183
+ const visible = modelsForTier(tier);
184
+ ctx.writeOutput(`Current model: ${current ?? '(unset, default)'}. Your tier: ${tier}.`);
185
+ ctx.writeOutput('');
186
+ ctx.writeOutput('Available models:');
187
+ for (const m of visible) {
188
+ const mark = current === m.slug ? '*' : ' ';
189
+ const price = `$${m.inputUsdPerM.toFixed(2)}/M in, $${m.outputUsdPerM.toFixed(2)}/M out`;
190
+ ctx.writeOutput(` ${mark} ${m.slug.padEnd(22)} ${m.label.padEnd(22)} ${price}`);
191
+ ctx.writeOutput(` ${m.summary}`);
192
+ }
193
+ const locked = MODEL_REGISTRY.filter((m) => TIER_RANK[m.minTier] > TIER_RANK[tier]);
194
+ if (locked.length > 0) {
195
+ ctx.writeOutput('');
196
+ ctx.writeOutput(`Higher-tier models (upgrade required):`);
197
+ for (const m of locked) {
198
+ ctx.writeOutput(` - ${m.slug} (${m.minTier})`);
199
+ }
200
+ }
201
+ ctx.writeOutput('');
202
+ ctx.writeOutput('Switch with `/model <slug>`. The choice persists to .pugi/settings.json.');
203
+ return { command: 'model', status: 'listed', slug: current ?? undefined };
204
+ }
205
+ function readCurrentSlug(fs) {
206
+ try {
207
+ const data = fs.readSettings();
208
+ if (!data || typeof data !== 'object')
209
+ return null;
210
+ const model = data.model;
211
+ if (!model || typeof model !== 'object')
212
+ return null;
213
+ const slug = model.slug;
214
+ return typeof slug === 'string' && slug.length > 0 ? slug : null;
215
+ }
216
+ catch {
217
+ return null;
218
+ }
219
+ }
220
+ function defaultFs(workspaceRoot) {
221
+ const path = resolve(workspaceRoot, '.pugi/settings.json');
222
+ return {
223
+ readSettings: () => {
224
+ if (!existsSync(path))
225
+ return null;
226
+ const raw = readFileSync(path, 'utf8');
227
+ if (raw.trim().length === 0)
228
+ return null;
229
+ return JSON.parse(raw);
230
+ },
231
+ writeSettings: (next) => {
232
+ mkdirSync(dirname(path), { recursive: true });
233
+ writeFileSync(path, JSON.stringify(next, null, 2) + '\n', 'utf8');
234
+ },
235
+ };
236
+ }
237
+ //# sourceMappingURL=model.js.map
@@ -44,7 +44,7 @@ export function sanitizeSemver(raw) {
44
44
  * during import). When bumping the CLI version BOTH literals must be
45
45
  * updated; the release smoke-test (`pack:smoke`) verifies they agree.
46
46
  */
47
- export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.29');
47
+ export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.30');
48
48
  /**
49
49
  * Outbound: the CLI's installed semver. Read at request time by
50
50
  * `version-interceptor.ts` and injected on every `fetch` call.
@@ -33,6 +33,14 @@ import { SlashPalette, completePalette, filterPalette, } from './slash-palette.j
33
33
  import { EMPTY_KILL_RING, killToLineEnd, killToLineStart, killWordBackward, yankAtCursor, } from '../core/repl/kill-ring.js';
34
34
  import { readClipboard } from '../core/repl/clipboard-read.js';
35
35
  const CTRL_C_DOUBLE_TAP_MS = 1_000;
36
+ /**
37
+ * Wave 6 BT 8 (Claude Code parity): Esc-Esc walks the conversation back
38
+ * one turn. 500ms is tight enough that an operator clearing the buffer +
39
+ * later changing their mind does NOT accidentally pop a turn, while
40
+ * still feeling like one motion. Matches Claude Code's documented
41
+ * double-Esc window.
42
+ */
43
+ const ESCAPE_DOUBLE_TAP_MS = 500;
36
44
  /** Width subtracted from the terminal width so the border + padding fit. */
37
45
  const FRAME_OVERHEAD_COLUMNS = 4;
38
46
  /** Fallback width when ink cannot read stdout (e.g. test harness). */
@@ -75,6 +83,11 @@ export function InputBox(props) {
75
83
  const [history, setHistory] = useState(seededHistory);
76
84
  const [historyIndex, setHistoryIndex] = useState(-1);
77
85
  const [lastCtrlCAt, setLastCtrlCAt] = useState(undefined);
86
+ // Wave 6 BT 8: Esc-Esc walkback double-tap window. Tracks the epoch
87
+ // ms of the most recent Esc press so the next Esc within
88
+ // ESCAPE_DOUBLE_TAP_MS triggers the walkback handler instead of
89
+ // re-clearing the buffer.
90
+ const [lastEscapeAt, setLastEscapeAt] = useState(undefined);
78
91
  const [cursorVisible, setCursorVisible] = useState(true);
79
92
  // Ctrl+R / Ctrl+S reverse-search mode. Undefined when idle, a
80
93
  // HistorySearchState while the operator is searching.
@@ -375,10 +388,41 @@ export function InputBox(props) {
375
388
  if (key.escape) {
376
389
  if (paletteOpen) {
377
390
  // Close the palette without clearing the buffer so the operator
378
- // can still send `/help` as plain text if they want.
391
+ // can still send `/help` as plain text if they want. Palette
392
+ // takes precedence over walkback because the operator's mental
393
+ // model is "Esc closes the visible overlay first".
379
394
  setPaletteSuppressed(true);
395
+ setLastEscapeAt(undefined);
396
+ return;
397
+ }
398
+ // Wave 6 BT 8: Esc-Esc walkback. Two presses within
399
+ // ESCAPE_DOUBLE_TAP_MS step the conversation back by one turn.
400
+ // First press still clears the buffer (legacy behaviour for the
401
+ // single-Esc cancel UX); the second press calls the host's
402
+ // walkback handler. Buffer-clear on the first press is what makes
403
+ // the double-tap feel "free" - the operator did not have to
404
+ // memorise a new chord; they just have to keep pressing.
405
+ const tEsc = now();
406
+ const withinEscapeWindow = typeof lastEscapeAt === 'number'
407
+ && tEsc - lastEscapeAt <= ESCAPE_DOUBLE_TAP_MS;
408
+ if (withinEscapeWindow && props.onWalkback) {
409
+ // Second tap inside the window. Buffer was already cleared on
410
+ // the first press, so the host sees a clean input box AND the
411
+ // walkback result. We clear the window so a third tap restarts
412
+ // the cycle (no run-on walkbacks from a stuck Esc key).
413
+ const verdict = props.onWalkback();
414
+ setLastEscapeAt(undefined);
415
+ if (verdict !== 'walked-back') {
416
+ // Host refused (dispatch in flight, no turns to pop). The
417
+ // host owns the refusal copy via its own writeOutput path;
418
+ // we do not double-message here.
419
+ return;
420
+ }
380
421
  return;
381
422
  }
423
+ // First Esc (or no walkback wired). Arm the window + clear the
424
+ // buffer per the long-standing single-Esc cancel contract.
425
+ setLastEscapeAt(tEsc);
382
426
  setLine('');
383
427
  setCursor(0);
384
428
  setHistoryIndex(-1);
package/dist/tui/repl.js CHANGED
@@ -184,6 +184,18 @@ export function Repl(props) {
184
184
  return undefined;
185
185
  return props.session.cancel();
186
186
  }, [props.session, modalActive]);
187
+ // Wave 6 BT 8 (Claude Code parity): Esc-Esc walkback. Forwards to
188
+ // ReplSession.walkbackLastTurn which trims the trailing operator
189
+ // turn + its persona response from the in-memory transcript. Returns
190
+ // `'walked-back'` so the input box knows the host did the work;
191
+ // `'nothing'` covers both the empty-transcript and dispatch-in-flight
192
+ // refusals (the session module owns the refusal copy in both cases).
193
+ const handleWalkback = useCallback(() => {
194
+ if (modalActive)
195
+ return 'nothing';
196
+ const verdict = props.session.walkbackLastTurn();
197
+ return verdict === 'walked-back' ? 'walked-back' : 'nothing';
198
+ }, [props.session, modalActive]);
187
199
  // α6.14.5 CEO dogfood 2026-05-25 (parity with Claude Code): input
188
200
  // box pinned to alt-screen BOTTOM, conversation grows above it.
189
201
  // Beta.3's height={rows} fix broke keystroke focus - raw echo at
@@ -192,7 +204,7 @@ export function Repl(props) {
192
204
  // input, and the input stays the sole focusable surface adjacent
193
205
  // to the cursor row, so all keystrokes route through it.
194
206
  const altScreenRows = process.stdout.rows ?? 24;
195
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, minHeight: altScreenRows, children: [props.updateBanner ? _jsx(UpdateBanner, { result: props.updateBanner }) : null, splashVisible ? (_jsx(ReplSplash, { cliVersion: state.cliVersion, workspaceLabel: state.workspaceLabel, plan: props.splashPlan, model: props.splashModel, tenant: props.splashTenant, onDismiss: dismissSplash, mascotPrePrinted: props.mascotPrePrinted === true })) : null, _jsx(Header, { state: state }), _jsx(Box, { flexDirection: "column", marginTop: 1, flexGrow: 1, justifyContent: "flex-end", children: overlay === 'help' ? (_jsx(HelpOverlay, {})) : overlay === 'roster' ? (_jsx(RosterOverlay, {})) : overlay === 'farewell' ? (_jsx(FarewellOverlay, {})) : (_jsx(MainArea, { state: state, personaNames: personaNames, nowEpochMs: tickNow, hideToolStream: props.hideToolStream === true, toolStreamCollapsed: toolStreamCollapsed })) }), state.pendingAsk ? (_jsx(Box, { marginTop: 1, children: _jsx(AskModal, { tag: state.pendingAsk, onResolve: handleAskResolve }) })) : null, state.pendingPlanReview ? (_jsx(Box, { marginTop: 1, children: _jsx(PlanReviewModal, { tag: state.pendingPlanReview, onResolve: handlePlanReviewResolve }) })) : null, _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [overlay === 'farewell' || modalActive ? null : (_jsx(InputBox, { onSubmit: handleSubmit, onExit: handleExit, onCancel: handleCancel, now: props.now,
207
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, minHeight: altScreenRows, children: [props.updateBanner ? _jsx(UpdateBanner, { result: props.updateBanner }) : null, splashVisible ? (_jsx(ReplSplash, { cliVersion: state.cliVersion, workspaceLabel: state.workspaceLabel, plan: props.splashPlan, model: props.splashModel, tenant: props.splashTenant, onDismiss: dismissSplash, mascotPrePrinted: props.mascotPrePrinted === true })) : null, _jsx(Header, { state: state }), _jsx(Box, { flexDirection: "column", marginTop: 1, flexGrow: 1, justifyContent: "flex-end", children: overlay === 'help' ? (_jsx(HelpOverlay, {})) : overlay === 'roster' ? (_jsx(RosterOverlay, {})) : overlay === 'farewell' ? (_jsx(FarewellOverlay, {})) : (_jsx(MainArea, { state: state, personaNames: personaNames, nowEpochMs: tickNow, hideToolStream: props.hideToolStream === true, toolStreamCollapsed: toolStreamCollapsed })) }), state.pendingAsk ? (_jsx(Box, { marginTop: 1, children: _jsx(AskModal, { tag: state.pendingAsk, onResolve: handleAskResolve }) })) : null, state.pendingPlanReview ? (_jsx(Box, { marginTop: 1, children: _jsx(PlanReviewModal, { tag: state.pendingPlanReview, onResolve: handlePlanReviewResolve }) })) : null, _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [overlay === 'farewell' || modalActive ? null : (_jsx(InputBox, { onSubmit: handleSubmit, onExit: handleExit, onCancel: handleCancel, onWalkback: handleWalkback, now: props.now,
196
208
  // Slug from process.cwd() (full path) so two workspaces with
197
209
  // the same basename do not share history. state.workspaceLabel
198
210
  // is the basename only. Codex review P2.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pugi/cli",
3
- "version": "0.1.0-beta.29",
3
+ "version": "0.1.0-beta.30",
4
4
  "description": "Pugi CLI - terminal-native software execution system",
5
5
  "homepage": "https://pugi.io",
6
6
  "repository": {
@@ -54,7 +54,7 @@
54
54
  "undici": "^8.3.0",
55
55
  "zod": "^3.23.0",
56
56
  "@pugi/personas": "0.1.2",
57
- "@pugi/sdk": "0.1.0-beta.29"
57
+ "@pugi/sdk": "0.1.0-beta.30"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^22.0.0",