@pugi/cli 0.1.0-beta.25 → 0.1.0-beta.26

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,118 @@
1
+ /**
2
+ * `/resume` runtime — leak L9 (2026-05-27).
3
+ *
4
+ * Light runner that augments the existing `pugi resume` surface in
5
+ * `runtime/cli.ts` with rewind-aware session listings. The full
6
+ * REPL-mount path stays in cli.ts (it owns the credential resolver +
7
+ * Ink renderer); this module exposes the data-access helpers the slash
8
+ * dispatcher + the cli.ts handler share.
9
+ *
10
+ * Why a separate runner rather than inlining inside cli.ts: the in-REPL
11
+ * `/resume` slash already holds the writer lock, so it cannot use the
12
+ * read-only view path the top-level command relies on. The split lets
13
+ * the slash code call `listResumableSessionsForRepl` (which routes
14
+ * through the live store) while the shell code calls
15
+ * `listResumableSessionsReadOnly` (which uses the no-lock view).
16
+ */
17
+ import { homedir } from 'node:os';
18
+ import { slugForCwd } from '../../core/repl/history.js';
19
+ import { applyAllMasks, listResumableSessions, } from '../../core/checkpoint/resumer.js';
20
+ import { findLatestActiveRewind } from '../../core/checkpoint/rewinder.js';
21
+ /**
22
+ * Read-only `pugi resume --list` path. Uses the no-lock view so a live
23
+ * REPL writing in the same project does not block the listing.
24
+ */
25
+ export async function runResumeList(ctx) {
26
+ const slug = slugForCwd(ctx.workspaceRoot);
27
+ const baseInput = {
28
+ projectSlug: slug,
29
+ limit: ctx.limit ?? 10,
30
+ home: ctx.home ?? homedir(),
31
+ };
32
+ const sessions = await listResumableSessions(baseInput);
33
+ const rows = sessions.map(toResumeListRow);
34
+ const text = renderResumeList(rows, slug);
35
+ ctx.writeOutput({
36
+ command: 'resume',
37
+ status: rows.length === 0 ? 'empty' : 'listed',
38
+ projectSlug: slug,
39
+ sessions: rows,
40
+ }, text);
41
+ return {
42
+ command: 'resume',
43
+ status: rows.length === 0 ? 'empty' : 'listed',
44
+ projectSlug: slug,
45
+ sessions: rows,
46
+ };
47
+ }
48
+ /**
49
+ * In-REPL `/resume` slash variant. The live REPL holds the writer
50
+ * lockfile so we cannot reuse the read-only view path; this routes
51
+ * through the store the session module already opened. Returns the
52
+ * sessions list directly so the slash handler can render system lines.
53
+ */
54
+ export async function runResumeListForRepl(input) {
55
+ const slug = slugForCwd(input.workspaceRoot);
56
+ const limit = input.limit ?? 10;
57
+ const sessionRows = await input.store.listSessions({
58
+ project: slug,
59
+ limit,
60
+ status: 'active+archived',
61
+ });
62
+ const out = [];
63
+ for (const row of sessionRows) {
64
+ const events = await input.store.loadEvents(row.id);
65
+ const visible = applyAllMasks(events);
66
+ const latest = findLatestActiveRewind(events);
67
+ out.push({
68
+ id: row.id,
69
+ title: row.title,
70
+ branch: row.branch,
71
+ turnCount: row.turnCount,
72
+ eventCount: row.eventCount,
73
+ visibleEventCount: visible.length,
74
+ hasActiveRewind: latest !== null,
75
+ updatedAt: row.updatedAt,
76
+ });
77
+ }
78
+ return out;
79
+ }
80
+ function toResumeListRow(input) {
81
+ return {
82
+ id: input.row.id,
83
+ title: input.row.title,
84
+ branch: input.row.branch,
85
+ turnCount: input.row.turnCount,
86
+ eventCount: input.row.eventCount,
87
+ visibleEventCount: input.visibleEventCount,
88
+ hasActiveRewind: input.hasActiveRewind,
89
+ updatedAt: input.row.updatedAt,
90
+ };
91
+ }
92
+ /**
93
+ * Pretty-print a resume list. Adds a small "rewound" tag when the
94
+ * session carries an unfinished rewind so the operator knows the
95
+ * transcript will load with a masked range.
96
+ */
97
+ export function renderResumeList(rows, projectSlug) {
98
+ if (rows.length === 0) {
99
+ return `No stored sessions for project '${projectSlug}' yet.`;
100
+ }
101
+ const lines = [
102
+ `Recent local sessions for '${projectSlug}' (${rows.length}):`,
103
+ '',
104
+ ];
105
+ for (let i = 0; i < rows.length; i += 1) {
106
+ const row = rows[i];
107
+ const title = (row.title ?? '(untitled)').slice(0, 64);
108
+ const idShort = row.id.slice(0, 13);
109
+ const branch = (row.branch ?? 'no-branch').padEnd(16);
110
+ const turns = `${row.turnCount}t`.padStart(4);
111
+ const events = `${row.visibleEventCount}e`.padStart(5);
112
+ const tag = row.hasActiveRewind ? ' [rewound]' : '';
113
+ lines.push(` ${idShort} ${branch} ${turns} ${events}${tag} ${title}`);
114
+ }
115
+ lines.push('', 'Resume with: pugi resume <id>');
116
+ return lines.join('\n');
117
+ }
118
+ //# sourceMappingURL=resume.js.map
@@ -0,0 +1,333 @@
1
+ /**
2
+ * `/rewind` runtime — leak L9 (2026-05-27).
3
+ *
4
+ * Three invocation modes, sharing one runner:
5
+ *
6
+ * - `/rewind N` drop the last N operator turns + every
7
+ * tool call that landed between.
8
+ * - `/rewind --to <id>` rewind to a specific event index from the
9
+ * visible (post-mask) transcript.
10
+ * - `/rewind` interactive picker — surfaces the last 10
11
+ * user-turn boundaries (newest-first) and
12
+ * returns a payload the caller can use to
13
+ * mount a select prompt.
14
+ *
15
+ * The rewind is APPEND-ONLY: we never delete events. `applyRewindMask`
16
+ * elides the masked range on read; `pugi sessions undo-rewind` appends
17
+ * an inverse marker that nullifies the latest rewind so operators have
18
+ * a reliable escape hatch.
19
+ *
20
+ * Surface contract (same shape as `runCompactCommand`):
21
+ *
22
+ * - Returns a structured result for the JSON path.
23
+ * - Calls `ctx.writeOutput(payload, text)` once per invocation.
24
+ * - Throws ONLY on programmer-error. Store failures, missing
25
+ * sessions, etc. are surfaced as `failed_*` statuses.
26
+ *
27
+ * Exit codes (mapped by the dispatcher in cli.ts):
28
+ *
29
+ * 0 — marker appended OR picker surfaced
30
+ * 1 — store unavailable / session not found
31
+ * 2 — noop (asked to drop 0 turns, nothing to rewind, etc.)
32
+ */
33
+ import { homedir } from 'node:os';
34
+ import { slugForCwd } from '../../core/repl/history.js';
35
+ import { appendRewindMarker, buildRewindPickerRows, pickRewindTargetForTurns, resolveEventIdToIndex, } from '../../core/checkpoint/rewinder.js';
36
+ import { loadFromStore } from '../../core/checkpoint/resumer.js';
37
+ import { SqliteSessionStore, resolveProjectStoreDir, } from '../../core/repl/store/session-store.js';
38
+ /**
39
+ * Entry point reused by the slash command + the top-level dispatcher.
40
+ *
41
+ * `args` accepts:
42
+ * - `[]` picker mode
43
+ * - `["N"]` drop last N turns
44
+ * - `["--to", "<id>"]` rewind to event id
45
+ *
46
+ * Both `-N` and `--turns N` are accepted for parity with Claude Code's
47
+ * `--turns` flag.
48
+ */
49
+ export async function runRewindCommand(args, ctx) {
50
+ const parsed = parseRewindArgs(args);
51
+ if (parsed.kind === 'error') {
52
+ return emit(ctx, {
53
+ command: 'rewind',
54
+ status: 'failed_parse',
55
+ reason: parsed.message,
56
+ }, parsed.message);
57
+ }
58
+ // Resolve session + store.
59
+ const slug = slugForCwd(ctx.workspaceRoot);
60
+ let store = ctx.store ?? null;
61
+ let sessionId = ctx.sessionId ?? null;
62
+ let storeOpenedHere = false;
63
+ if (store === null) {
64
+ sessionId = sessionId ?? (await pickMostRecentSessionIdReadOnly(slug));
65
+ if (!sessionId) {
66
+ return emit(ctx, {
67
+ command: 'rewind',
68
+ status: 'failed_no_session',
69
+ reason: 'No active session to rewind. Start a REPL with `pugi`.',
70
+ });
71
+ }
72
+ const opened = await openLiveStore(slug, sessionId);
73
+ if (!opened) {
74
+ return emit(ctx, {
75
+ command: 'rewind',
76
+ status: 'failed_store',
77
+ sessionId,
78
+ reason: 'Could not open local session store (lock held by another REPL?).',
79
+ });
80
+ }
81
+ store = opened;
82
+ storeOpenedHere = true;
83
+ }
84
+ else if (sessionId === null) {
85
+ const rows = await store.listSessions({ project: slug, limit: 1, status: 'active' });
86
+ if (rows.length === 0) {
87
+ return emit(ctx, {
88
+ command: 'rewind',
89
+ status: 'failed_no_session',
90
+ reason: 'No active session to rewind.',
91
+ });
92
+ }
93
+ sessionId = rows[0].id;
94
+ }
95
+ try {
96
+ const loaded = await loadFromStore(store, sessionId);
97
+ if (!loaded) {
98
+ return emit(ctx, {
99
+ command: 'rewind',
100
+ status: 'failed_no_session',
101
+ sessionId,
102
+ reason: `Session '${sessionId}' not found.`,
103
+ });
104
+ }
105
+ // Picker mode: surface the last 10 user-turn boundaries.
106
+ if (parsed.kind === 'picker') {
107
+ const rows = buildRewindPickerRows(loaded.rawEvents, 10);
108
+ if (rows.length === 0) {
109
+ return emit(ctx, {
110
+ command: 'rewind',
111
+ status: 'noop_empty',
112
+ sessionId,
113
+ reason: 'No operator turns to rewind to.',
114
+ }, 'Nothing to rewind — no operator turns yet.');
115
+ }
116
+ const pickerRows = rows.map((r) => ({
117
+ visibleIndex: r.visibleIndex,
118
+ turnsAgo: r.turnsAgo,
119
+ preview: r.preview,
120
+ timestampEpochMs: r.timestampEpochMs,
121
+ }));
122
+ const text = renderPicker(pickerRows);
123
+ return emit(ctx, {
124
+ command: 'rewind',
125
+ status: 'picker',
126
+ sessionId,
127
+ pickerRows,
128
+ }, text);
129
+ }
130
+ // Resolve target index.
131
+ let toEventIndex;
132
+ let turnsRewound;
133
+ if (parsed.kind === 'turns') {
134
+ if (parsed.n <= 0) {
135
+ return emit(ctx, {
136
+ command: 'rewind',
137
+ status: 'noop_zero',
138
+ sessionId,
139
+ reason: 'Asked to drop 0 turns — nothing to do.',
140
+ }, 'Asked to drop 0 turns — nothing to do.');
141
+ }
142
+ const target = pickRewindTargetForTurns(loaded.rawEvents, parsed.n);
143
+ if (target.turnsRewound === 0) {
144
+ return emit(ctx, {
145
+ command: 'rewind',
146
+ status: 'noop_empty',
147
+ sessionId,
148
+ reason: 'No operator turns to rewind.',
149
+ }, 'No operator turns to rewind.');
150
+ }
151
+ toEventIndex = target.toEventIndex;
152
+ turnsRewound = target.turnsRewound;
153
+ }
154
+ else {
155
+ // mode === 'to-event'
156
+ const resolvedIdx = resolveEventIdToIndex(loaded.rawEvents, parsed.eventId);
157
+ if (resolvedIdx === null) {
158
+ return emit(ctx, {
159
+ command: 'rewind',
160
+ status: 'failed_parse',
161
+ sessionId,
162
+ reason: `Could not resolve event id '${parsed.eventId}'. Try \`/rewind\` for the picker.`,
163
+ });
164
+ }
165
+ toEventIndex = resolvedIdx;
166
+ // Count user turns in the masked range to report turnsRewound.
167
+ turnsRewound = countUserTurnsAfter(loaded.rawEvents, toEventIndex);
168
+ }
169
+ const fromEventIndex = loaded.rawEvents.length;
170
+ await appendRewindMarker({
171
+ store,
172
+ toEventIndex,
173
+ fromEventIndex,
174
+ turnsRewound,
175
+ reason: parsed.kind === 'turns' ? 'manual' : 'to-event',
176
+ ...(ctx.now !== undefined ? { now: ctx.now } : {}),
177
+ });
178
+ // Reload + recompute visible count for the operator banner.
179
+ const after = await loadFromStore(store, sessionId);
180
+ const visibleAfter = after?.visibleEvents.length ?? 0;
181
+ const banner = `Rewound ${turnsRewound} turn${turnsRewound === 1 ? '' : 's'} ` +
182
+ `(to event ${toEventIndex < 0 ? 'start' : `#${toEventIndex + 1}`}). ` +
183
+ `${visibleAfter} event${visibleAfter === 1 ? '' : 's'} now visible. ` +
184
+ `Undo with \`pugi sessions undo-rewind\`.`;
185
+ return emit(ctx, {
186
+ command: 'rewind',
187
+ status: 'rewound',
188
+ sessionId,
189
+ turnsRewound,
190
+ toEventIndex,
191
+ fromEventIndex,
192
+ visibleAfter,
193
+ }, banner);
194
+ }
195
+ finally {
196
+ if (storeOpenedHere && store) {
197
+ try {
198
+ await store.close();
199
+ }
200
+ catch {
201
+ /* idempotent */
202
+ }
203
+ }
204
+ }
205
+ }
206
+ /**
207
+ * Accepts:
208
+ * pugi rewind -> picker
209
+ * pugi rewind 3 -> drop 3 turns
210
+ * pugi rewind --turns 3 -> same
211
+ * pugi rewind --to 12 -> rewind to event index 12 (1-based visible)
212
+ * pugi rewind --to #12 -> rewind to event index 12 (0-based hidden)
213
+ */
214
+ function parseRewindArgs(args) {
215
+ if (args.length === 0)
216
+ return { kind: 'picker' };
217
+ const head = args[0];
218
+ // --to <id>
219
+ if (head === '--to' || head === '-t') {
220
+ const eventId = args[1];
221
+ if (!eventId) {
222
+ return {
223
+ kind: 'error',
224
+ message: 'Usage: pugi rewind --to <event-id>',
225
+ };
226
+ }
227
+ return { kind: 'to-event', eventId };
228
+ }
229
+ if (head.startsWith('--to=')) {
230
+ const eventId = head.slice('--to='.length);
231
+ if (eventId.length === 0) {
232
+ return {
233
+ kind: 'error',
234
+ message: 'Usage: pugi rewind --to <event-id>',
235
+ };
236
+ }
237
+ return { kind: 'to-event', eventId };
238
+ }
239
+ // --turns N OR -N OR positional N
240
+ if (head === '--turns' || head === '-n') {
241
+ const n = Number.parseInt(args[1] ?? '', 10);
242
+ if (!Number.isFinite(n) || n < 0) {
243
+ return {
244
+ kind: 'error',
245
+ message: 'Usage: pugi rewind --turns <N>',
246
+ };
247
+ }
248
+ return { kind: 'turns', n };
249
+ }
250
+ if (head.startsWith('--turns=')) {
251
+ const n = Number.parseInt(head.slice('--turns='.length), 10);
252
+ if (!Number.isFinite(n) || n < 0) {
253
+ return {
254
+ kind: 'error',
255
+ message: 'Usage: pugi rewind --turns <N>',
256
+ };
257
+ }
258
+ return { kind: 'turns', n };
259
+ }
260
+ // Bare integer: positional turns count.
261
+ const positional = Number.parseInt(head, 10);
262
+ if (Number.isFinite(positional) && positional >= 0) {
263
+ return { kind: 'turns', n: positional };
264
+ }
265
+ return {
266
+ kind: 'error',
267
+ message: `Unknown argument '${head}'. Try \`pugi rewind\`, \`pugi rewind <N>\`, or \`pugi rewind --to <id>\`.`,
268
+ };
269
+ }
270
+ function emit(ctx, payload, text) {
271
+ const human = text ?? payload.reason ?? `rewind: ${payload.status}`;
272
+ ctx.writeOutput(payload, human);
273
+ return payload;
274
+ }
275
+ function renderPicker(rows) {
276
+ const lines = ['Rewind picker — pick a turn boundary:', ''];
277
+ for (const r of rows) {
278
+ const tag = `[#${r.visibleIndex.toString().padStart(3)}]`;
279
+ const ago = `${r.turnsAgo}t ago`.padStart(8);
280
+ lines.push(` ${tag} ${ago} ${r.preview}`);
281
+ }
282
+ lines.push('', 'Rewind with: pugi rewind --to <#N> (or `pugi rewind <turnsToDrop>`).');
283
+ return lines.join('\n');
284
+ }
285
+ function countUserTurnsAfter(events, toEventIndex) {
286
+ let count = 0;
287
+ for (let i = toEventIndex + 1; i < events.length; i += 1) {
288
+ if (events[i].kind === 'user')
289
+ count += 1;
290
+ }
291
+ return count;
292
+ }
293
+ /**
294
+ * Open the SqliteSessionStore for the workspace's project slug, bound
295
+ * to `sessionId`. Returns null when the lock is held by another REPL.
296
+ */
297
+ async function openLiveStore(projectSlug, sessionId) {
298
+ try {
299
+ const store = new SqliteSessionStore({ projectSlug, home: homedir() });
300
+ await store.open({
301
+ id: sessionId,
302
+ workspaceRoot: process.cwd(),
303
+ projectSlug,
304
+ });
305
+ return store;
306
+ }
307
+ catch {
308
+ return null;
309
+ }
310
+ }
311
+ /**
312
+ * Discover the most recent active session id for a project slug,
313
+ * without taking the writer lockfile. Used by the standalone CLI path
314
+ * (no `--session` flag) so a live REPL holding the lock does not block
315
+ * the lookup.
316
+ */
317
+ async function pickMostRecentSessionIdReadOnly(projectSlug) {
318
+ try {
319
+ const dir = resolveProjectStoreDir(projectSlug, homedir());
320
+ const view = await SqliteSessionStore.openReadOnly(dir);
321
+ try {
322
+ const rows = await view.list({ project: projectSlug, limit: 1, status: 'active' });
323
+ return rows.length > 0 ? rows[0].id : null;
324
+ }
325
+ finally {
326
+ await view.close();
327
+ }
328
+ }
329
+ catch {
330
+ return null;
331
+ }
332
+ }
333
+ //# sourceMappingURL=rewind.js.map
@@ -0,0 +1,163 @@
1
+ /**
2
+ * `pugi sessions` extensions — leak L9 (2026-05-27).
3
+ *
4
+ * The legacy `pugi sessions` handler lives inline in `runtime/cli.ts`
5
+ * (it predates the per-command runner pattern). This module exposes the
6
+ * new sub-commands the L9 sprint adds:
7
+ *
8
+ * - `pugi sessions undo-rewind [<session-id>]`
9
+ * Append an inverse marker that nullifies the latest 'rewind'
10
+ * marker on the target session. Operator escape hatch: nothing
11
+ * is destroyed, the masked range simply becomes visible again.
12
+ *
13
+ * Wire pattern matches `runCompactCommand` / `runRewindCommand`: one
14
+ * structured payload + one human line per invocation, returned to the
15
+ * caller for the JSON path.
16
+ */
17
+ import { homedir } from 'node:os';
18
+ import { slugForCwd } from '../../core/repl/history.js';
19
+ import { appendRewindMarker, findLatestActiveRewind, } from '../../core/checkpoint/rewinder.js';
20
+ import { loadFromStore } from '../../core/checkpoint/resumer.js';
21
+ import { SqliteSessionStore, resolveProjectStoreDir, } from '../../core/repl/store/session-store.js';
22
+ /**
23
+ * Sub-command router. The caller passes the full positional args; we
24
+ * peel `args[0]` as the sub-command name and forward the tail. Unknown
25
+ * sub-commands surface a usage hint at exit code 2.
26
+ */
27
+ export async function runSessionsCommand(args, ctx) {
28
+ const sub = args[0];
29
+ if (sub === 'undo-rewind') {
30
+ return runUndoRewind(args.slice(1), ctx);
31
+ }
32
+ // Other sub-commands (the legacy `pugi sessions` list path) are
33
+ // owned by runtime/cli.ts — return null so the caller knows to fall
34
+ // through to that handler.
35
+ return null;
36
+ }
37
+ async function runUndoRewind(args, ctx) {
38
+ const explicitId = args[0] && !args[0].startsWith('--') ? args[0] : undefined;
39
+ const slug = slugForCwd(ctx.workspaceRoot);
40
+ let store = ctx.store ?? null;
41
+ let sessionId = ctx.sessionId ?? explicitId ?? null;
42
+ let storeOpenedHere = false;
43
+ if (store === null) {
44
+ sessionId = sessionId ?? (await pickMostRecentSessionIdReadOnly(slug));
45
+ if (!sessionId) {
46
+ return emit(ctx, {
47
+ command: 'sessions',
48
+ sub: 'undo-rewind',
49
+ status: 'failed_no_session',
50
+ reason: 'No session to undo-rewind. Start a REPL with `pugi`.',
51
+ });
52
+ }
53
+ const opened = await openLiveStore(slug, sessionId);
54
+ if (!opened) {
55
+ return emit(ctx, {
56
+ command: 'sessions',
57
+ sub: 'undo-rewind',
58
+ status: 'failed_store',
59
+ sessionId,
60
+ reason: 'Could not open local session store (lock held by another REPL?).',
61
+ });
62
+ }
63
+ store = opened;
64
+ storeOpenedHere = true;
65
+ }
66
+ else if (sessionId === null) {
67
+ const rows = await store.listSessions({ project: slug, limit: 1, status: 'active' });
68
+ if (rows.length === 0) {
69
+ return emit(ctx, {
70
+ command: 'sessions',
71
+ sub: 'undo-rewind',
72
+ status: 'failed_no_session',
73
+ reason: 'No active session to undo-rewind.',
74
+ });
75
+ }
76
+ sessionId = rows[0].id;
77
+ }
78
+ try {
79
+ const loaded = await loadFromStore(store, sessionId);
80
+ if (!loaded) {
81
+ return emit(ctx, {
82
+ command: 'sessions',
83
+ sub: 'undo-rewind',
84
+ status: 'failed_no_session',
85
+ sessionId,
86
+ reason: `Session '${sessionId}' not found.`,
87
+ });
88
+ }
89
+ const latest = findLatestActiveRewind(loaded.rawEvents);
90
+ if (latest === null) {
91
+ return emit(ctx, {
92
+ command: 'sessions',
93
+ sub: 'undo-rewind',
94
+ status: 'noop_no_rewind',
95
+ sessionId,
96
+ reason: 'No active rewind to undo on this session.',
97
+ }, 'No active rewind to undo.');
98
+ }
99
+ const fromEventIndex = loaded.rawEvents.length;
100
+ await appendRewindMarker({
101
+ store,
102
+ mode: 'undo-rewind',
103
+ toEventIndex: latest.payload.toEventIndex,
104
+ fromEventIndex,
105
+ turnsRewound: latest.payload.turnsRewound,
106
+ reason: 'undo',
107
+ ...(ctx.now !== undefined ? { now: ctx.now } : {}),
108
+ });
109
+ return emit(ctx, {
110
+ command: 'sessions',
111
+ sub: 'undo-rewind',
112
+ status: 'undone',
113
+ sessionId,
114
+ undoneTurns: latest.payload.turnsRewound,
115
+ }, `Undid the latest rewind (${latest.payload.turnsRewound} turn${latest.payload.turnsRewound === 1 ? '' : 's'} restored).`);
116
+ }
117
+ finally {
118
+ if (storeOpenedHere && store) {
119
+ try {
120
+ await store.close();
121
+ }
122
+ catch {
123
+ /* idempotent */
124
+ }
125
+ }
126
+ }
127
+ }
128
+ function emit(ctx, payload, text) {
129
+ const human = text ?? payload.reason ?? `sessions ${payload.sub}: ${payload.status}`;
130
+ ctx.writeOutput(payload, human);
131
+ return payload;
132
+ }
133
+ async function openLiveStore(projectSlug, sessionId) {
134
+ try {
135
+ const store = new SqliteSessionStore({ projectSlug, home: homedir() });
136
+ await store.open({
137
+ id: sessionId,
138
+ workspaceRoot: process.cwd(),
139
+ projectSlug,
140
+ });
141
+ return store;
142
+ }
143
+ catch {
144
+ return null;
145
+ }
146
+ }
147
+ async function pickMostRecentSessionIdReadOnly(projectSlug) {
148
+ try {
149
+ const dir = resolveProjectStoreDir(projectSlug, homedir());
150
+ const view = await SqliteSessionStore.openReadOnly(dir);
151
+ try {
152
+ const rows = await view.list({ project: projectSlug, limit: 1, status: 'active' });
153
+ return rows.length > 0 ? rows[0].id : null;
154
+ }
155
+ finally {
156
+ await view.close();
157
+ }
158
+ }
159
+ catch {
160
+ return null;
161
+ }
162
+ }
163
+ //# sourceMappingURL=sessions.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.25');
47
+ export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.26');
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.
@@ -50,6 +50,7 @@ import { z } from 'zod';
50
50
  import { randomUUID } from 'node:crypto';
51
51
  import { relative as relativePath } from 'node:path';
52
52
  import { spawnSubagentWithOutcome } from '../core/subagents/spawn.js';
53
+ import { inheritCacheContext } from '../core/dispatch/cache-handoff.js';
53
54
  /**
54
55
  * Argument schema. `isolation: 'auto'` defers to the role-default
55
56
  * isolation tier (set by `isolationForRole` in dispatcher.ts). The
@@ -157,6 +158,28 @@ export async function agentTool(args, ctx) {
157
158
  // restriction layer regardless of permissionMode.
158
159
  permissionMode: 'auto',
159
160
  };
161
+ // L10 (2026-05-27): synthesize a prompt-cache inheritance handle for
162
+ // the child before we dispatch. The handle is persisted under
163
+ // `.pugi/cache-refs/<childAgentId>.json` so:
164
+ // - The child engine loop's first turn (dispatcher-real.ts) can
165
+ // forward parent_cache_id onto Anvil — provider-dependent honour
166
+ // (Anthropic cache_control breakpoints today; OpenAI/Gemini
167
+ // silently ignore until Anvil grows per-provider adapters).
168
+ // - Operators can introspect via `pugi dispatch list-cache-refs`.
169
+ // - `pugi dispatch clear-cache-refs --older-than 1h` can GC after
170
+ // a long session.
171
+ // Failure to persist must NOT block the dispatch — the handle is a
172
+ // best-effort optimisation; if disk is full or the workspace root is
173
+ // read-only, we degrade silently to a cache-miss dispatch.
174
+ try {
175
+ inheritCacheContext(ctx.session.id, task.id, {
176
+ workspaceRoot: ctx.session.root,
177
+ ...(ctx.now ? { now: ctx.now } : {}),
178
+ });
179
+ }
180
+ catch {
181
+ // Silent degrade: cache inheritance is forward-compat, not load-bearing.
182
+ }
160
183
  const useWorktree = validated.isolation === 'worktree'
161
184
  ? true
162
185
  : validated.isolation === 'shared_fs'