@cspeach/cli 1.1.30 → 1.1.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/.cspeach/project.json +4 -0
  2. package/README.md +11 -1
  3. package/dist/agent/loop.js +121 -8
  4. package/dist/cli.js +36 -14
  5. package/dist/commands/config-set.js +1 -1
  6. package/dist/commands/plan-continue.js +1 -1
  7. package/dist/commands/plan-gate.js +8 -1
  8. package/dist/commands/plan-resume.js +24 -18
  9. package/dist/commands/plan-startup.js +49 -6
  10. package/dist/commands/team-discover.js +195 -0
  11. package/dist/commands/team-plan.js +585 -0
  12. package/dist/commands/team.js +375 -41
  13. package/dist/journey/chain.js +728 -0
  14. package/dist/journey/clean-core.js +213 -0
  15. package/dist/journey/flow.js +166 -0
  16. package/dist/journey/run.js +435 -0
  17. package/dist/journey/session-deps.js +77 -0
  18. package/dist/journey/trigger.js +153 -0
  19. package/dist/lane/app-chain.js +9 -7
  20. package/dist/lane/chain-token.js +9 -5
  21. package/dist/lane/roots.js +97 -41
  22. package/dist/one-shot.js +20 -1
  23. package/dist/projects/clean-core-confirm.js +156 -0
  24. package/dist/projects/detail-path.js +9 -4
  25. package/dist/projects/extract-clean-core.js +3 -0
  26. package/dist/projects/migration.js +4 -105
  27. package/dist/projects/plan-lane.js +67 -17
  28. package/dist/projects/plan-sources.js +13 -7
  29. package/dist/projects/project-folder.js +131 -0
  30. package/dist/projects/promote-command.js +39 -0
  31. package/dist/projects/save-command.js +83 -12
  32. package/dist/projects/saved-lines.js +17 -3
  33. package/dist/projects/sort-command.js +499 -0
  34. package/dist/projects/sort-core.js +248 -0
  35. package/dist/projects/workspace-retire.js +48 -0
  36. package/dist/projects/workspace.js +182 -121
  37. package/dist/register/discover.js +901 -0
  38. package/dist/register/findings.js +5 -2
  39. package/dist/register/offer.js +100 -8
  40. package/dist/register/picture.js +6 -2
  41. package/dist/register/plan-import.js +325 -0
  42. package/dist/register/records.js +25 -0
  43. package/dist/register/status-file/estate-parts.js +168 -0
  44. package/dist/register/status-file/plan-parts.js +68 -0
  45. package/dist/register/status-file/progress-chart.js +110 -0
  46. package/dist/register/status-file/register-map.js +11 -3
  47. package/dist/register/status-file/register-model.js +40 -6
  48. package/dist/register/status-file/register-parts.js +57 -11
  49. package/dist/register/status-file/register-status-file.js +24 -6
  50. package/dist/register/status-file/team-css.js +1 -1
  51. package/dist/register/status-file-sync.js +3 -0
  52. package/dist/register/status-html.js +3 -1
  53. package/dist/register/store.js +40 -2
  54. package/dist/register/workbooks.js +27 -7
  55. package/dist/register/xlsx.js +13 -3
  56. package/dist/register/xref.js +117 -0
  57. package/dist/renderer/startup-lines.js +11 -0
  58. package/dist/renderer/tool-labels.js +2 -1
  59. package/dist/renderer/tool-widget.js +15 -1
  60. package/dist/repl/at-completer.js +6 -2
  61. package/dist/repl/at-picker.js +1 -1
  62. package/dist/repl/journey-entry.js +25 -0
  63. package/dist/repl.js +157 -39
  64. package/dist/session/backfill.js +342 -0
  65. package/dist/session/project-match.js +63 -0
  66. package/dist/session/resume.js +72 -24
  67. package/dist/session/schema.js +3 -1
  68. package/dist/session/store.js +96 -51
  69. package/dist/skill-catalog.js +2 -2
  70. package/dist/skills/bundled-skills.js +1 -1
  71. package/dist/tools/_filesystem-shared.js +174 -12
  72. package/dist/tools/_team-work-path.js +133 -0
  73. package/dist/tools/approval.js +7 -1
  74. package/dist/tools/filesystem/extract-xlsx.js +27 -3
  75. package/dist/tools/filesystem/file-edit.js +12 -4
  76. package/dist/tools/filesystem/file-read.js +8 -4
  77. package/dist/tools/filesystem/file-write.js +12 -4
  78. package/dist/tools/filesystem/glob.js +33 -8
  79. package/dist/tools/filesystem/grep.js +9 -4
  80. package/dist/tools/filesystem/read-document.js +6 -3
  81. package/dist/tools/project/cca_merge.js +8 -2
  82. package/dist/tools/project/team_estate.js +91 -0
  83. package/dist/tools/project/team_findings.js +77 -4
  84. package/dist/tools/project/upgrade_merge_progress.js +8 -2
  85. package/dist/tools/sap-read.js +21 -4
  86. package/dist/tools/sap-write.js +200 -10
  87. package/dist/tools/subagent/read_agent.js +3 -0
  88. package/dist/tools/tool-loading.js +1 -1
  89. package/dist/tools/transport-fit-check.js +117 -0
  90. package/dist/tools/transport-fit.js +15 -7
  91. package/dist/tools/transport.js +57 -2
  92. package/dist/ui/app.js +2 -2
  93. package/dist/ui/approval-modal.js +74 -6
  94. package/dist/ui/widgets/confirm-card.js +68 -5
  95. package/dist/util/open-in-browser.js +143 -0
  96. package/package.json +120 -120
@@ -0,0 +1,342 @@
1
+ /**
2
+ * 2026-10-08 (sessions/folders, Task 12) — one-off tagging of old sessions.
3
+ *
4
+ * Sessions saved before folders existed have no `project_dir`, so they only
5
+ * show under "Show all folders". This pass tags each one ONCE with the folder
6
+ * its own content points at, and changes nothing else in the file:
7
+ *
8
+ * 1. paths it wrote with file_write / file_edit (absolute only): the folder
9
+ * above the first `.cspeach` segment, else the file's folder; the most
10
+ * frequent folder wins;
11
+ * 2. its plan in flight: the folder above the plan's `.cspeach`;
12
+ * 3. `<attached file="NAME">` in its user messages: the ONE folder, among
13
+ * those found by 1–2 across all sessions, whose `.cspeach` holds NAME;
14
+ * 4. else left alone.
15
+ *
16
+ * Best effort: never throws to the caller, never rewrites a file that does
17
+ * not parse, never touches reader-* files or sessions that already have a
18
+ * folder. A file is rewritten with the store's own serialisation (tmp +
19
+ * rename) and keeps its file time, which the session lists sort by.
20
+ * Files saved in the last 5 minutes (likely open in a running CLI) and files
21
+ * that change between read and rename are not written now: they are kept in
22
+ * a deferred list and retried on later starts.
23
+ * Each start spends at most a small time budget. When it runs out, the place
24
+ * reached is saved and the next start goes on from there; the done-marker is
25
+ * written only once every session file has been looked at.
26
+ */
27
+ import fs from 'node:fs/promises';
28
+ import { existsSync, readdirSync } from 'node:fs';
29
+ import path from 'node:path';
30
+ import { sessionsDir } from '../config/paths.js';
31
+ import { readSoloProjectId, teamProjectId } from '../projects/project-folder.js';
32
+ import { normDir } from './project-match.js';
33
+ export const BACKFILL_MARKER = '.backfilled-v1';
34
+ export const BACKFILL_PROGRESS = '.backfill-progress';
35
+ /** Start-up never waits on this for longer than this. */
36
+ const BUDGET_MS = 2000;
37
+ const WRITE_TOOLS = new Set(['file_write', 'file_edit', 'write_file', 'edit_file']);
38
+ /** The folder above the first `.cspeach` segment of `p`, or null. */
39
+ export function folderAboveCspeach(p) {
40
+ const parts = p.split(/[\\/]/);
41
+ const i = parts.findIndex((s) => s.toLowerCase() === '.cspeach');
42
+ if (i < 1)
43
+ return null;
44
+ let head = parts.slice(0, i).join(path.sep);
45
+ if (head === '' || /^[A-Za-z]:$/.test(head))
46
+ head += path.sep;
47
+ return path.resolve(head);
48
+ }
49
+ /** Step 1: the most frequent folder among absolute written paths. */
50
+ function folderFromWrites(toolCalls) {
51
+ if (!Array.isArray(toolCalls))
52
+ return null;
53
+ const counts = new Map();
54
+ for (const c of toolCalls) {
55
+ const tool = c?.tool ?? c?.name;
56
+ const p = c?.args?.path;
57
+ if (!WRITE_TOOLS.has(tool) || typeof p !== 'string' || !path.isAbsolute(p))
58
+ continue;
59
+ const dir = folderAboveCspeach(p) ?? path.dirname(path.resolve(p));
60
+ const key = normDir(dir);
61
+ const hit = counts.get(key);
62
+ if (hit)
63
+ hit.n++;
64
+ else
65
+ counts.set(key, { dir, n: 1 });
66
+ }
67
+ let best = null;
68
+ for (const v of counts.values())
69
+ if (!best || v.n > best.n)
70
+ best = v;
71
+ return best?.dir ?? null;
72
+ }
73
+ /** Step 2: the folder above the plan in flight's `.cspeach`. */
74
+ function folderFromPlan(plan) {
75
+ const p = plan?.path;
76
+ return typeof p === 'string' && path.isAbsolute(p) ? folderAboveCspeach(p) : null;
77
+ }
78
+ /** Attached file names in the user messages. */
79
+ function attachedNames(messages) {
80
+ if (!Array.isArray(messages))
81
+ return [];
82
+ const names = new Set();
83
+ const scan = (t) => {
84
+ if (typeof t !== 'string')
85
+ return;
86
+ for (const m of t.matchAll(/<attached file="([^"]+)">/g))
87
+ names.add(m[1]);
88
+ };
89
+ for (const msg of messages) {
90
+ if (msg?.role !== 'user')
91
+ continue;
92
+ if (typeof msg.content === 'string')
93
+ scan(msg.content);
94
+ else if (Array.isArray(msg.content))
95
+ for (const b of msg.content)
96
+ if (b?.type === 'text')
97
+ scan(b.text);
98
+ }
99
+ return [...names];
100
+ }
101
+ /** Does `<dir>/.cspeach` hold a file called `name` (a few levels deep)? */
102
+ function cspeachHolds(dir, name) {
103
+ const walk = (d, depth) => {
104
+ let entries;
105
+ try {
106
+ entries = readdirSync(d, { withFileTypes: true });
107
+ }
108
+ catch {
109
+ return false;
110
+ }
111
+ for (const e of entries)
112
+ if (e.isFile() && e.name.toLowerCase() === name.toLowerCase())
113
+ return true;
114
+ if (depth <= 0)
115
+ return false;
116
+ for (const e of entries)
117
+ if (e.isDirectory() && walk(path.join(d, e.name), depth - 1))
118
+ return true;
119
+ return false;
120
+ };
121
+ return walk(path.join(dir, '.cspeach'), 3);
122
+ }
123
+ async function readProgress(file) {
124
+ try {
125
+ const p = JSON.parse(await fs.readFile(file, 'utf-8'));
126
+ if (p && Array.isArray(p.known) && Array.isArray(p.pending)) {
127
+ return {
128
+ cursor: typeof p.cursor === 'string' ? p.cursor : null, scanned: p.scanned === true,
129
+ known: p.known, pending: p.pending, deferred: Array.isArray(p.deferred) ? p.deferred : [],
130
+ };
131
+ }
132
+ }
133
+ catch { /* none yet, or unreadable: start over */ }
134
+ return { cursor: null, scanned: false, known: [], pending: [], deferred: [] };
135
+ }
136
+ async function writeProgress(file, p) {
137
+ const tmp = `${file}.bf-${process.pid}.tmp`;
138
+ try {
139
+ await fs.writeFile(tmp, JSON.stringify(p), 'utf-8');
140
+ await fs.rename(tmp, file);
141
+ }
142
+ catch (err) {
143
+ await fs.rm(tmp, { force: true }).catch(() => { });
144
+ throw err;
145
+ }
146
+ }
147
+ /** A session file that may be tagged: parses, is an object, not a reader. */
148
+ async function readSession(file) {
149
+ let st;
150
+ let state;
151
+ try {
152
+ // Stat BEFORE reading: a save in between shows up as a changed stamp
153
+ // at rename time, so the tag is skipped rather than written over it.
154
+ st = await fs.stat(file);
155
+ state = JSON.parse(await fs.readFile(file, 'utf-8'));
156
+ }
157
+ catch {
158
+ return null;
159
+ }
160
+ if (!state || typeof state !== 'object' || Array.isArray(state))
161
+ return null;
162
+ if (typeof state.id === 'string' && state.id.startsWith('reader-'))
163
+ return null;
164
+ return { state, mtimeMs: st.mtimeMs, size: st.size, atime: st.atime, mtime: st.mtime };
165
+ }
166
+ /** Saved in the last few minutes: likely open in a running CLI, leave it. */
167
+ const RECENT_MS = 5 * 60 * 1000;
168
+ const isRecent = (s) => Date.now() - s.mtimeMs < RECENT_MS;
169
+ /**
170
+ * Tag one session file. Returns false (nothing written) when the file
171
+ * changed since it was read — a running CLI saved it; its newer content wins.
172
+ * The tmp name is our own, never the store's `<file>.tmp`.
173
+ */
174
+ async function writeTagged(file, s, dir, beforeRename) {
175
+ const state = { ...s.state, project_dir: dir };
176
+ // A team folder matches by its team id (final fix wave, item 2); never the
177
+ // home-folder rule, which would read another folder's id.
178
+ const id = teamProjectId(dir) ?? readSoloProjectId(dir);
179
+ if (id)
180
+ state.project_id = id;
181
+ const tmp = `${file}.bf-${process.pid}.tmp`;
182
+ try {
183
+ await fs.writeFile(tmp, JSON.stringify(state, null, 2), 'utf-8');
184
+ if (beforeRename)
185
+ await beforeRename(file);
186
+ const now = await fs.stat(file);
187
+ if (now.mtimeMs !== s.mtimeMs || now.size !== s.size) {
188
+ await fs.rm(tmp, { force: true });
189
+ return false;
190
+ }
191
+ await fs.rename(tmp, file);
192
+ }
193
+ catch {
194
+ await fs.rm(tmp, { force: true }).catch(() => { });
195
+ return false;
196
+ }
197
+ await fs.utimes(file, s.atime, s.mtime).catch(() => { });
198
+ return true;
199
+ }
200
+ /** One pass per process: `--resume` then the REPL start must not pay twice. */
201
+ let ranThisProcess = false;
202
+ /** Tests only: allow another pass in the same process. */
203
+ export function resetBackfillRunFlag() { ranThisProcess = false; }
204
+ export async function backfillSessionFolders(opts = {}) {
205
+ const now = opts.now ?? (() => new Date());
206
+ const result = { tagged: 0, unknown: 0 };
207
+ if (ranThisProcess)
208
+ return result;
209
+ ranThisProcess = true;
210
+ const dir = sessionsDir();
211
+ const marker = path.join(dir, BACKFILL_MARKER);
212
+ const progressFile = path.join(dir, BACKFILL_PROGRESS);
213
+ try {
214
+ if (!existsSync(dir) || existsSync(marker))
215
+ return result;
216
+ const start = now().getTime();
217
+ // Every start finishes at least one whole file before the budget counts.
218
+ let processed = 0;
219
+ const overBudget = () => processed > 0 && now().getTime() - start > BUDGET_MS;
220
+ const prog = await readProgress(progressFile);
221
+ const known = new Map(prog.known.map((f) => [normDir(f), f]));
222
+ const pending = [...prog.pending];
223
+ const carried = [...prog.deferred]; // deferred by an earlier start: retry now
224
+ const deferred = []; // deferred in this start: retry next start
225
+ const save = () => writeProgress(progressFile, {
226
+ cursor: prog.cursor, scanned: prog.scanned, known: [...known.values()], pending,
227
+ deferred: [...carried, ...deferred],
228
+ });
229
+ /**
230
+ * Steps 1–2 for one file. A file that is live (saved recently) or that
231
+ * changed under us is deferred, never dropped; one with no 1–2 evidence
232
+ * waits in `pending` for step 3.
233
+ */
234
+ const evaluate = async (f) => {
235
+ const file = path.join(dir, f);
236
+ const s = await readSession(file);
237
+ if (!s)
238
+ return;
239
+ const found = folderFromWrites(s.state.toolCalls) ?? folderFromPlan(s.state.plan_in_flight);
240
+ if (found)
241
+ known.set(normDir(found), found);
242
+ if (s.state.project_dir != null)
243
+ return;
244
+ if (isRecent(s)) {
245
+ deferred.push(f);
246
+ return;
247
+ }
248
+ if (!found) {
249
+ pending.push(f);
250
+ return;
251
+ }
252
+ if (await writeTagged(file, s, found, opts.beforeRename))
253
+ result.tagged++;
254
+ else
255
+ deferred.push(f);
256
+ };
257
+ if (!prog.scanned) {
258
+ const files = (await fs.readdir(dir))
259
+ .filter((f) => f.endsWith('.json') && !f.startsWith('.') && !f.includes('-reader-'))
260
+ .sort().reverse()
261
+ .filter((f) => prog.cursor === null || f < prog.cursor);
262
+ for (const f of files) {
263
+ await evaluate(f);
264
+ prog.cursor = f;
265
+ processed++;
266
+ if (overBudget()) {
267
+ await save();
268
+ return result;
269
+ }
270
+ }
271
+ prog.scanned = true;
272
+ }
273
+ while (carried.length > 0) {
274
+ await evaluate(carried[0]);
275
+ carried.shift();
276
+ processed++;
277
+ if (overBudget()) {
278
+ await save();
279
+ return result;
280
+ }
281
+ }
282
+ const holds = new Map(); // `${folderKey}\0${name}` → found
283
+ const holdsCached = (key, folder, name) => {
284
+ const k = `${key}\0${name.toLowerCase()}`;
285
+ let v = holds.get(k);
286
+ if (v === undefined) {
287
+ v = cspeachHolds(folder, name);
288
+ holds.set(k, v);
289
+ }
290
+ return v;
291
+ };
292
+ while (pending.length > 0) {
293
+ const f = pending[0];
294
+ const file = path.join(dir, f);
295
+ const s = await readSession(file);
296
+ if (s && s.state.project_dir == null) {
297
+ if (isRecent(s)) {
298
+ deferred.push(f);
299
+ }
300
+ else {
301
+ const hits = new Set();
302
+ let outOfTime = false;
303
+ for (const name of attachedNames(s.state.messages)) {
304
+ for (const [key, folder] of known)
305
+ if (holdsCached(key, folder, name))
306
+ hits.add(key);
307
+ if (overBudget()) {
308
+ outOfTime = true;
309
+ break;
310
+ }
311
+ }
312
+ // Out of time mid-match: keep this file first in line for next start.
313
+ if (outOfTime) {
314
+ await save();
315
+ return result;
316
+ }
317
+ if (hits.size !== 1)
318
+ result.unknown++;
319
+ else if (await writeTagged(file, s, known.get([...hits][0]), opts.beforeRename))
320
+ result.tagged++;
321
+ else
322
+ deferred.push(f);
323
+ }
324
+ }
325
+ pending.shift();
326
+ processed++;
327
+ if (overBudget()) {
328
+ await save();
329
+ return result;
330
+ }
331
+ }
332
+ // Done only when every file was looked at and nothing waits.
333
+ if (deferred.length > 0) {
334
+ await save();
335
+ return result;
336
+ }
337
+ await fs.writeFile(marker, `${now().toISOString()}\n`, 'utf-8');
338
+ await fs.rm(progressFile, { force: true });
339
+ }
340
+ catch { /* best effort — never blocks start-up */ }
341
+ return result;
342
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * 2026-10-08 (sessions/folders, Task 6) — the ONE rule that says whether a
3
+ * saved session belongs to the folder the CLI runs in.
4
+ *
5
+ * - Team folder: match by project id only (each person syncs the shared
6
+ * folder to a different local path).
7
+ * - Otherwise: the same project id (both set) matches, so a renamed folder
8
+ * keeps its sessions; else the normalised folder paths must be equal.
9
+ * A sub-folder is a different folder.
10
+ */
11
+ import { realpathSync } from 'node:fs';
12
+ import path from 'node:path';
13
+ import { resolveProjectFolderSync } from '../projects/project-folder.js';
14
+ /** Real path when it exists, else resolved; no trailing separator; lower-case on win32. */
15
+ export function normDir(p) {
16
+ let r;
17
+ try {
18
+ r = realpathSync.native(p);
19
+ }
20
+ catch {
21
+ r = path.resolve(p);
22
+ }
23
+ r = r.replace(/[\\/]+$/, '') || r;
24
+ return process.platform === 'win32' ? r.toLowerCase() : r;
25
+ }
26
+ export function currentFolderKey(cwd) {
27
+ const pf = resolveProjectFolderSync(cwd);
28
+ return { dir: pf.projectDir, id: pf.projectId, kind: pf.kind };
29
+ }
30
+ /** `hereDirNorm`: normDir(here.dir) computed once by a caller that checks many sessions. */
31
+ export function sessionMatchesFolder(s, here, hereDirNorm) {
32
+ if (here.kind === 'team')
33
+ return here.id !== null && s.projectId === here.id;
34
+ if (s.projectId && here.id && s.projectId === here.id)
35
+ return true;
36
+ return s.projectDir !== null && normDir(s.projectDir) === (hereDirNorm ?? normDir(here.dir));
37
+ }
38
+ /** The folder and id a new session is tagged with at creation. */
39
+ export function folderTag(cwd) {
40
+ const k = currentFolderKey(cwd);
41
+ return { dir: k.dir, id: k.id };
42
+ }
43
+ /**
44
+ * The first save hands over the folder's project id. Fill it in only when
45
+ * the session belongs to this folder:
46
+ * - an old untagged session (no folder) takes this folder and its id;
47
+ * - a session from this same folder without an id takes the id;
48
+ * - a session from another folder, or one that already has an id, is left as is.
49
+ * Returns true when the session changed (the caller saves it).
50
+ */
51
+ export function tagSessionOnSave(session, cwd, id) {
52
+ if (session.project_dir == null) {
53
+ session.project_dir = path.resolve(cwd);
54
+ session.project_id = id;
55
+ return true;
56
+ }
57
+ if (session.project_id)
58
+ return false;
59
+ if (normDir(session.project_dir) !== normDir(cwd))
60
+ return false;
61
+ session.project_id = id;
62
+ return true;
63
+ }
@@ -1,6 +1,9 @@
1
1
  import { select } from '../repl/themed-prompts.js';
2
2
  import chalk from 'chalk';
3
- import { listSessions, loadSession, RESUME_PICKER_LIMIT } from './store.js';
3
+ import path from 'node:path';
4
+ import { listSessions, loadSession, latestSessionHere, RESUME_PICKER_LIMIT } from './store.js';
5
+ import { currentFolderKey, sessionMatchesFolder } from './project-match.js';
6
+ import { backfillSessionFolders } from './backfill.js';
4
7
  import { formatTimeAgo } from './time-ago.js';
5
8
  import { runRepl } from '../repl.js';
6
9
  import { formatFailure, formatAttention } from '../renderer/notice-shape.js';
@@ -30,29 +33,61 @@ export function pickerSessions(sessions) {
30
33
  return b.lastTurnAt.localeCompare(a.lastTurnAt);
31
34
  });
32
35
  }
36
+ /** The picker's last choice in this folder's list: open the all-folders list. */
37
+ const ALL = '__all__';
38
+ /**
39
+ * 2026-10-08 — `cspeach --continue`: resume the latest session of this
40
+ * folder. Searches every session file, not only the startup window.
41
+ */
42
+ export async function runContinue(opts = {}) {
43
+ const here = currentFolderKey(opts.cwd ?? process.cwd());
44
+ await backfillSessionFolders().catch(() => { });
45
+ const s = await latestSessionHere(here, { scanAll: true });
46
+ if (!s) {
47
+ console.log('No session in this folder yet. cspeach --resume lists all folders.');
48
+ return 0;
49
+ }
50
+ return runResume(s.id, opts);
51
+ }
52
+ /** One resume picker; resolves to the chosen value (a session id or ALL). */
53
+ async function pick(sessions, withFolder, showAll) {
54
+ return select({
55
+ message: `Pick a session to resume:
56
+ ${formatSessionHeader(withFolder)}`,
57
+ choices: [
58
+ ...sessions.map((s) => ({
59
+ value: s.id,
60
+ name: formatSessionRow(s, undefined, withFolder),
61
+ // Round 6 item 4a — the answered line shows the id, not the whole row.
62
+ short: s.id,
63
+ description: s.interrupted
64
+ ? interruptedPickerLine(s.interruptedReason)
65
+ : `${s.userTurnCount} prior turn(s) — pick up from where you left off`,
66
+ })),
67
+ ...(showAll ? [{ value: ALL, name: 'Show all folders', short: 'all folders' }] : []),
68
+ ],
69
+ });
70
+ }
33
71
  export async function runResume(id, opts = {}) {
34
72
  let targetId = id;
73
+ const here = currentFolderKey(opts.cwd ?? process.cwd());
35
74
  if (!targetId) {
36
- const sessions = pickerSessions(await listSessions(RESUME_PICKER_LIMIT, { skipEmpty: true }));
37
- if (sessions.length === 0) {
38
- console.log('No recent sessions.');
39
- return 0;
40
- }
41
- const sorted = sessions;
75
+ await backfillSessionFolders().catch(() => { });
76
+ // 2026-10-08 — this folder's sessions first, with "Show all folders" last.
77
+ const local = pickerSessions(await listSessions(RESUME_PICKER_LIMIT, { skipEmpty: true, here, scanAll: true }));
42
78
  try {
43
- targetId = await select({
44
- message: `Pick a session to resume:
45
- ${formatSessionHeader()}`,
46
- choices: sorted.map((s) => ({
47
- value: s.id,
48
- name: formatSessionRow(s),
49
- // Round 6 item 4a — the answered line shows the id, not the whole row.
50
- short: s.id,
51
- description: s.interrupted
52
- ? interruptedPickerLine(s.interruptedReason)
53
- : `${s.userTurnCount} prior turn(s) — pick up from where you left off`,
54
- })),
55
- });
79
+ let choice = ALL;
80
+ if (local.length > 0)
81
+ choice = await pick(local, false, true);
82
+ if (choice === ALL) {
83
+ const sessions = pickerSessions(await listSessions(RESUME_PICKER_LIMIT, { skipEmpty: true }));
84
+ if (sessions.length === 0) {
85
+ console.log('No recent sessions.');
86
+ return 0;
87
+ }
88
+ choice = await pick(sessions, true, false);
89
+ }
90
+ targetId = choice;
56
91
  }
57
92
  catch (err) {
58
93
  // Ctrl+C in the picker: exit quietly, like Claude Code (fix round 3).
@@ -69,6 +104,10 @@ ${formatSessionHeader()}`,
69
104
  console.error('\n' + formatFailure({ what: 'Could not load the session', detail: [err.message], next: 'cspeach --resume (pick from the list)' }));
70
105
  return 1;
71
106
  }
107
+ // 2026-10-08 — resume by id works from any folder; say where it began.
108
+ if (session.project_dir && !sessionMatchesFolder({ projectDir: session.project_dir, projectId: session.project_id ?? null }, here)) {
109
+ console.log(chalk.dim(`This session was started in ${session.project_dir}. New files save here, in ${here.dir}.`));
110
+ }
72
111
  const unfinished = session.toolCalls.filter((c) => !c.completed);
73
112
  // Round 6 review I3 — moving a session with unfinished SAP changes to
74
113
  // another system would leave those changes unreconcilable: refuse.
@@ -126,21 +165,23 @@ ${formatSessionHeader()}`,
126
165
  return runRepl({ resumedSession: session, sapAlias: opts.sapAlias, noSap: opts.noSap });
127
166
  }
128
167
  // Column widths shared by the rows and the header so the labels line up.
129
- const W = { id: 8, time: 10, sap: 8, skill: 22, turns: 5 };
168
+ const W = { id: 8, time: 10, sap: 8, skill: 22, turns: 5, folder: 16 };
130
169
  /**
131
170
  * One picker row. Round 4 item 10 — the preview is cut to the terminal
132
171
  * width (a long one wrapped onto a second line at 100 columns and broke the
133
172
  * table): room = columns − the picker's 2-cell cursor gutter − the fixed
134
173
  * columns − 1 spare cell.
135
174
  */
136
- export function formatSessionRow(s, columns = process.stdout.columns ?? 80) {
175
+ export function formatSessionRow(s, columns = process.stdout.columns ?? 80, withFolder = false) {
137
176
  const flag = s.interrupted ? paint('warn', getGlyphs().attention) + ' ' : ' ';
138
177
  const idCol = chalk.cyan(s.id.padEnd(W.id));
139
178
  const timeCol = chalk.gray(formatTimeAgo(s.lastTurnAt).padEnd(W.time));
140
179
  const sapCol = chalk.gray((s.sapSystem ?? '-').padEnd(W.sap));
141
180
  const skillCol = chalk.gray(`/${s.skill ?? '-'}`.padEnd(W.skill));
142
181
  const turns = chalk.gray(`${s.userTurnCount}t`.padStart(W.turns));
143
- const prefix = `${flag}${idCol} ${timeCol} ${sapCol} ${skillCol} ${turns} `;
182
+ // 2026-10-08 — the all-folders picker adds a FOLDER column.
183
+ const folderCol = withFolder ? ' ' + chalk.gray(folderLabel(s.projectDir).padEnd(W.folder)) : '';
184
+ const prefix = `${flag}${idCol} ${timeCol}${folderCol} ${sapCol} ${skillCol} ${turns} `;
144
185
  // Measured (string-width ignores colour codes), not summed: a value wider
145
186
  // than its column pads nothing.
146
187
  const room = Math.max(8, columns - 2 - stringWidth(prefix) - 1);
@@ -152,14 +193,21 @@ export function formatSessionRow(s, columns = process.stdout.columns ?? 80) {
152
193
  * picker draws a 2-char cursor gutter before each choice but not before the
153
194
  * message, so the header adds it: gutter + flag = 4 leading spaces.
154
195
  */
155
- export function formatSessionHeader() {
196
+ export function formatSessionHeader(withFolder = false) {
156
197
  return chalk.dim(' ' +
157
198
  [
158
199
  'ID'.padEnd(W.id),
159
200
  'LAST USED'.padEnd(W.time),
201
+ ...(withFolder ? ['FOLDER'.padEnd(W.folder)] : []),
160
202
  'SYSTEM'.padEnd(W.sap),
161
203
  'SKILL'.padEnd(W.skill),
162
204
  'TURNS'.padStart(W.turns),
163
205
  ].join(' ') +
164
206
  ' LAST PROMPT');
165
207
  }
208
+ /** The folder column: the folder's name, cut to the column; "folder unknown" for older sessions. */
209
+ function folderLabel(projectDir) {
210
+ if (!projectDir)
211
+ return 'folder unknown';
212
+ return truncateToWidth(path.basename(projectDir) || projectDir, W.folder);
213
+ }
@@ -1,9 +1,11 @@
1
1
  export const SCHEMA_VERSION = 1;
2
- export function newSession(id, sapSystem, skill, model) {
2
+ export function newSession(id, sapSystem, skill, model, project) {
3
3
  const now = new Date().toISOString();
4
4
  return {
5
5
  schema_version: SCHEMA_VERSION,
6
6
  id,
7
+ project_dir: project?.dir ?? null,
8
+ project_id: project?.id ?? null,
7
9
  started_at: now,
8
10
  last_turn_at: now,
9
11
  sap_system: sapSystem,