@cspeach/cli 1.1.14 → 1.1.16

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 (38) hide show
  1. package/README.md +63 -0
  2. package/dist/agent/loop.js +32 -7
  3. package/dist/agent/tool-dispatch.js +13 -9
  4. package/dist/cli.js +81 -27
  5. package/dist/commands/compact.js +6 -2
  6. package/dist/commands/project-context-impact.js +2 -2
  7. package/dist/commands/project.js +301 -0
  8. package/dist/commands/team.js +1166 -0
  9. package/dist/one-shot.js +2 -0
  10. package/dist/projects/image-attachments.js +155 -0
  11. package/dist/projects/save-command.js +20 -0
  12. package/dist/register/atc-file.js +802 -0
  13. package/dist/register/baseline.js +106 -0
  14. package/dist/register/measure.js +380 -0
  15. package/dist/register/records.js +159 -0
  16. package/dist/register/store.js +227 -0
  17. package/dist/repl/post-turn-status.js +2 -1
  18. package/dist/repl.js +16 -2
  19. package/dist/rewind/candidates.js +14 -1
  20. package/dist/rewind/restore.js +53 -19
  21. package/dist/router/classifier.js +24 -4
  22. package/dist/session/recap.js +8 -5
  23. package/dist/session/resume.js +37 -17
  24. package/dist/session/store.js +5 -3
  25. package/dist/session/user-prompt.js +23 -0
  26. package/dist/skill-catalog.js +33 -0
  27. package/dist/skills/bundled-skills.js +1 -1
  28. package/dist/tools/filesystem/extract-document.js +6 -0
  29. package/dist/tools/filesystem/extract-xlsx.js +139 -0
  30. package/dist/tools/filesystem/file-write.js +20 -0
  31. package/dist/tools/filesystem/read-document.js +15 -6
  32. package/dist/tools/include-snapshot.js +24 -0
  33. package/dist/tools/sap-write.js +19 -9
  34. package/dist/tools/snapshot.js +27 -0
  35. package/dist/ui/clipboard-image.js +125 -0
  36. package/dist/ui/footer.js +11 -1
  37. package/dist/ui/text-input.js +30 -2
  38. package/package.json +7 -4
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Project Register on disk (spec §3, §3a).
3
+ *
4
+ * Two places, both chosen by a person, never by the model:
5
+ * - the TEAM PROJECT FOLDER the lead creates: cspeach-team.json, baseline/,
6
+ * lanes.json, rulings/. Collected records sit under writers/.
7
+ * - this laptop's OUTBOX: where this machine's records are written. It mirrors
8
+ * the project-folder layout (writers/<id>/records/…) so that handing records
9
+ * in is a plain folder copy. By default it lives under ~/.cspeach; a developer
10
+ * may point it inside their own OneDrive-synced folder instead.
11
+ *
12
+ * CSPeach writes only to paths on this list. It never writes to a network
13
+ * location on its own initiative.
14
+ */
15
+ import { createHash, randomBytes } from 'node:crypto';
16
+ import fs from 'node:fs';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+ import { cspeachRoot } from '../config/paths.js';
20
+ export const TEAM_FILE = 'cspeach-team.json';
21
+ export const BASELINE_FILE = path.join('baseline', 'objects.json');
22
+ export const LANES_FILE = 'lanes.json';
23
+ export class RegisterError extends Error {
24
+ }
25
+ export const defaultRand = (bytes) => randomBytes(bytes).toString('hex');
26
+ const stateFile = () => path.join(cspeachRoot(), 'register.json');
27
+ export function loadState() {
28
+ try {
29
+ const s = JSON.parse(stripBom(fs.readFileSync(stateFile(), 'utf8')));
30
+ if (s && typeof s === 'object' && s.projects)
31
+ return { active: s.active ?? null, projects: s.projects };
32
+ }
33
+ catch { /* first use, or unreadable: start empty */ }
34
+ return { active: null, projects: {} };
35
+ }
36
+ export function saveState(state) {
37
+ fs.mkdirSync(cspeachRoot(), { recursive: true });
38
+ fs.writeFileSync(stateFile(), `${JSON.stringify(state, null, 2)}\n`);
39
+ }
40
+ /** The team project this laptop is recording against, or null. Cheap: one small file read. */
41
+ export function activeProject() {
42
+ const s = loadState();
43
+ if (!s.active || !s.projects[s.active])
44
+ return null;
45
+ return { projectId: s.active, ...s.projects[s.active] };
46
+ }
47
+ export function defaultOutboxDir(projectId) {
48
+ return path.join(cspeachRoot(), 'register', projectId, 'outbox');
49
+ }
50
+ /** `mike-khan-7f3a`: the person plus this machine, so one file never has two writers. */
51
+ export function writerIdFor(name, machine = os.hostname()) {
52
+ const slug = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'writer';
53
+ return `${slug}-${createHash('sha256').update(machine.toLowerCase()).digest('hex').slice(0, 4)}`;
54
+ }
55
+ export function projectIdFor(name, rand = defaultRand) {
56
+ const slug = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'project';
57
+ return `${slug}-${rand(2)}`;
58
+ }
59
+ export const sha256 = (text) => createHash('sha256').update(text).digest('hex');
60
+ /**
61
+ * A byte-order mark, taken off by NUMBER. Notepad, Excel and a few sync tools
62
+ * put one in front of a file they save as UTF-8; `JSON.parse` then refuses the
63
+ * whole file. Written as a character in a regex it is invisible in the source —
64
+ * which is how two of these were copied around and a third was forgotten — so
65
+ * this is the one place that knows about it, and it names the code point.
66
+ */
67
+ export const stripBom = (text) => (text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
68
+ function readJson(file) {
69
+ let text;
70
+ try {
71
+ text = fs.readFileSync(file, 'utf8');
72
+ }
73
+ catch {
74
+ throw new RegisterError(`Cannot read ${file}`);
75
+ }
76
+ try {
77
+ return JSON.parse(stripBom(text));
78
+ }
79
+ catch {
80
+ throw new RegisterError(`${file} is not valid JSON`);
81
+ }
82
+ }
83
+ export function readProjectFile(projectDir) {
84
+ const p = readJson(path.join(projectDir, TEAM_FILE));
85
+ if (p?.kind !== 'team-project' || typeof p.projectId !== 'string' || typeof p.lead?.name !== 'string') {
86
+ throw new RegisterError(`${path.join(projectDir, TEAM_FILE)} is not a CSPeach team project file`);
87
+ }
88
+ return p;
89
+ }
90
+ /**
91
+ * What SAP accepts as a check variant name. Checked by every caller BEFORE a
92
+ * run, not only at the moment of writing: a name that cannot be written must
93
+ * not be measured with either.
94
+ */
95
+ export const VARIANT_NAME = /^[A-Z0-9_/]{1,40}$/;
96
+ /**
97
+ * The one field of the team project file that is set after `init`: which ATC
98
+ * check variant this team project measures with. Written whole, then renamed,
99
+ * like a record — a sync tool must never pick up half a team project file.
100
+ */
101
+ export function writeProjectVariant(projectDir, variant) {
102
+ if (!VARIANT_NAME.test(variant))
103
+ throw new RegisterError(`"${variant}" is not an ATC check variant name.`);
104
+ const file = path.join(projectDir, TEAM_FILE);
105
+ const next = { ...readProjectFile(projectDir), atcVariant: variant };
106
+ fs.writeFileSync(`${file}.tmp`, `${JSON.stringify(next, null, 2)}\n`);
107
+ fs.renameSync(`${file}.tmp`, file);
108
+ }
109
+ /**
110
+ * One read, one sentence. `existsSync` then `readFileSync` answers only the
111
+ * easy failure: a folder halfway through a sync also gives EISDIR (a folder
112
+ * where the file goes), EBUSY and EPERM (a file the sync tool still holds, or
113
+ * one OneDrive has not fetched). None of those may reach a person as a stack.
114
+ */
115
+ function readTextOrSay(file, missing) {
116
+ try {
117
+ return fs.readFileSync(file, 'utf8');
118
+ }
119
+ catch (e) {
120
+ if (e?.code === 'ENOENT')
121
+ throw new RegisterError(missing);
122
+ throw new RegisterError(`${file} could not be read. It may still be syncing.`);
123
+ }
124
+ }
125
+ export function readBaseline(projectDir) {
126
+ const project = readProjectFile(projectDir);
127
+ const file = path.join(projectDir, project.baseline?.file ?? BASELINE_FILE);
128
+ const text = readTextOrSay(file, `The baseline file is missing: ${file}`);
129
+ if (project.baseline?.sha256 && sha256(text) !== project.baseline.sha256) {
130
+ throw new RegisterError(`${file} does not match the checksum in the team project file. It was changed or only partly copied.`);
131
+ }
132
+ // A folder halfway through a sync is the everyday reason this file is not
133
+ // whole JSON. A person reads a sentence about their own folder here, never a
134
+ // parser's stack trace about a position in a byte stream.
135
+ let b;
136
+ try {
137
+ b = JSON.parse(stripBom(text));
138
+ }
139
+ catch {
140
+ throw new RegisterError(`${file} could not be read. It may still be syncing.`);
141
+ }
142
+ if (b?.kind !== 'baseline' || !Array.isArray(b.objects))
143
+ throw new RegisterError(`${file} is not a baseline file`);
144
+ // Every caller reads `o.type` and `o.name` straight away. One hand-edited or
145
+ // half-copied entry that is not an object would come out as a TypeError with
146
+ // a stack, from whichever command happened to touch it first.
147
+ if (b.objects.some((o) => !o || typeof o.type !== 'string' || typeof o.name !== 'string')) {
148
+ throw new RegisterError(`${file} could not be read. It may still be syncing.`);
149
+ }
150
+ return b.objects;
151
+ }
152
+ export function readLanes(projectDir) {
153
+ const file = path.join(projectDir, LANES_FILE);
154
+ if (!fs.existsSync(file))
155
+ return [];
156
+ const l = readJson(file);
157
+ return Array.isArray(l?.lanes) ? l.lanes : [];
158
+ }
159
+ export function writeLanes(projectDir, projectId, lanes) {
160
+ fs.writeFileSync(path.join(projectDir, LANES_FILE), `${JSON.stringify({ kind: 'lanes', formatVersion: 1, projectId, lanes }, null, 2)}\n`);
161
+ }
162
+ /**
163
+ * A record is a short list of object names. Anything this big is a dump, a log
164
+ * or something that is not a record at all, and reading it would cost a
165
+ * gigabyte of memory before `JSON.parse` could say so.
166
+ */
167
+ const MAX_RECORD_BYTES = 64 * 1024 * 1024;
168
+ /** One record file, or nothing. Counted as unreadable rather than thrown, whatever is wrong with it. */
169
+ function readRecord(file) {
170
+ try {
171
+ if (fs.statSync(file).size > MAX_RECORD_BYTES)
172
+ return { ok: false };
173
+ return { ok: true, value: JSON.parse(stripBom(fs.readFileSync(file, 'utf8'))) };
174
+ }
175
+ catch {
176
+ // A file can be listed before the sync tool has finished writing it, or be a
177
+ // folder, or be held open: skip and count, never fail.
178
+ return { ok: false };
179
+ }
180
+ }
181
+ /** Every record-shaped JSON under a folder: writers/<id>/records, rulings, baseline/measurement-*.json. */
182
+ export function collectRecords(root) {
183
+ const records = [];
184
+ let unreadable = 0;
185
+ let files = 0;
186
+ const take = (file) => {
187
+ files += 1;
188
+ const read = readRecord(file);
189
+ if (read.ok)
190
+ records.push(read.value);
191
+ else
192
+ unreadable += 1;
193
+ };
194
+ const readDir = (dir) => {
195
+ let names = [];
196
+ try {
197
+ names = fs.readdirSync(dir);
198
+ }
199
+ catch {
200
+ return;
201
+ }
202
+ for (const name of names) {
203
+ if (!name.toLowerCase().endsWith('.json'))
204
+ continue;
205
+ take(path.join(dir, name));
206
+ }
207
+ };
208
+ let writers = [];
209
+ try {
210
+ writers = fs.readdirSync(path.join(root, 'writers'));
211
+ }
212
+ catch { /* none handed in yet */ }
213
+ for (const w of writers)
214
+ readDir(path.join(root, 'writers', w, 'records'));
215
+ readDir(path.join(root, 'rulings'));
216
+ try {
217
+ for (const name of fs.readdirSync(path.join(root, 'baseline'))) {
218
+ if (!/^measurement-.*\.json$/i.test(name))
219
+ continue;
220
+ // Measurement zero lands here, written by the lead and then synced: the
221
+ // same hazards as any other record, read the same way.
222
+ take(path.join(root, 'baseline', name));
223
+ }
224
+ }
225
+ catch { /* no baseline folder here (an outbox has none) */ }
226
+ return { records, unreadable, files };
227
+ }
@@ -23,6 +23,7 @@ import { globalStore } from '../ui/sap-state-store.js';
23
23
  import { globalCredits } from '../cost/credits.js';
24
24
  import { printStatusFooter, formatStatusFooter } from '../renderer/status-footer.js';
25
25
  import { shouldUseInk } from '../renderer/tty.js';
26
+ import { isUserPrompt } from '../session/user-prompt.js';
26
27
  export function snapshotTurnStart(session) {
27
28
  return {
28
29
  input: session.usage.input_tokens,
@@ -34,7 +35,7 @@ export function snapshotTurnStart(session) {
34
35
  }
35
36
  export function pushPostTurnStatus(params) {
36
37
  const { session, selectedAlias, snapshot, chunkEmitter, turnModel } = params;
37
- const userTurns = session.messages.filter((m) => m.role === 'user' && typeof m.content === 'string').length;
38
+ const userTurns = session.messages.filter(isUserPrompt).length;
38
39
  const lifetimeTokensAfter = session.usage.input_tokens + session.usage.output_tokens;
39
40
  const lifetimeTokensBefore = snapshot.input + snapshot.output;
40
41
  const turnTokens = Math.max(0, lifetimeTokensAfter - lifetimeTokensBefore);
package/dist/repl.js CHANGED
@@ -1014,6 +1014,10 @@ export async function runRepl(opts) {
1014
1014
  // from regex, not the LLM, and surface the real cause.
1015
1015
  chunkEmitter.emit('chunk', chalk.dim(`↪ [offline classifier — proxy unreachable: ${c.transport_error ?? 'unknown'}]\n`));
1016
1016
  }
1017
+ if (c.screenshot) {
1018
+ chunkEmitter.emit('chunk', chalk.dim(`┄ Screenshot: ${c.screenshot}
1019
+ `));
1020
+ }
1017
1021
  const decision = decideRouting(c);
1018
1022
  // 2026-05-17 — pass the just-used skill so the picker can
1019
1023
  // default cursor to it (one-Enter "continue" UX for follow-up
@@ -2482,8 +2486,16 @@ export async function runRepl(opts) {
2482
2486
  // user re-submits the empty pre-fill, just silently re-fill
2483
2487
  // without re-spamming the hint line. Tracked via lastEmptyPrefill.
2484
2488
  if (lastEmptyPrefill !== head) {
2485
- console.log(chalk.dim(`↪ ${head} — type your prompt and press Enter ` +
2486
- chalk.dim(`(or "/cancel" to abandon)`)));
2489
+ // 2026-09-23 (Glenn's first run): "type your prompt" never said WHAT
2490
+ // to type, and our own skills suggest a bare next command
2491
+ // ("/abap-estimate next"), so the user is told to run something and
2492
+ // then asked an unanswerable question. Every skill now carries an
2493
+ // `asks` line with an example (skill-catalog.ts).
2494
+ const asks = SKILL_CATALOG.find((e) => `/${e.name}` === head)?.asks;
2495
+ console.log(chalk.dim(asks
2496
+ ? `↪ ${head} — ${asks}`
2497
+ : `${'↪'} ${head} — type your prompt and press Enter`));
2498
+ console.log(chalk.dim(' Enter to send · /cancel to abandon'));
2487
2499
  lastEmptyPrefill = head;
2488
2500
  }
2489
2501
  // 2026-05-02: bake the pre-fill into the next prompt string
@@ -2569,6 +2581,8 @@ export async function runRepl(opts) {
2569
2581
  if (c.offline) {
2570
2582
  console.log(chalk.dim(`↪ [offline classifier — proxy unreachable: ${c.transport_error ?? 'unknown'}]`));
2571
2583
  }
2584
+ if (c.screenshot)
2585
+ console.log(chalk.dim(`┄ Screenshot: ${c.screenshot}`));
2572
2586
  const decision = decideRouting(c);
2573
2587
  // 2026-05-17 — pass the just-used skill so the picker can
2574
2588
  // default cursor to it. session.skill = immediately-previous
@@ -24,6 +24,7 @@
24
24
  * - sap_delete_object ⇒ restorable when a pre-delete snapshot exists (the
25
25
  * snapshot IS the prior source; the executor recreates it).
26
26
  */
27
+ import { includeSnapshotType } from '../tools/include-snapshot.js';
27
28
  /** The five write tools whose effects can (potentially) be rewound. */
28
29
  const WRITE_TOOLS = new Set([
29
30
  'sap_set_source',
@@ -116,7 +117,19 @@ function extractIdentity(tool, rawArgs) {
116
117
  const args = (rawArgs ?? {});
117
118
  if (IMPLICIT_CLASS_TOOLS.has(tool)) {
118
119
  const object = pickString(args, 'className') ?? pickString(args, 'class_name');
119
- return object ? { object: object.toUpperCase(), objType: 'CLAS' } : null;
120
+ if (!object)
121
+ return null;
122
+ // An include write is rewound from the INCLUDE's own snapshot
123
+ // (type CLAS.<INCLUDE>), never from the class main source — restoring the
124
+ // main source would report success while the include change stayed.
125
+ // No includeType recorded ⇒ unknown target ⇒ not rewindable.
126
+ if (tool === 'sap_set_class_include') {
127
+ const includeType = pickString(args, 'includeType');
128
+ return includeType
129
+ ? { object: object.toUpperCase(), objType: includeSnapshotType(includeType) }
130
+ : null;
131
+ }
132
+ return { object: object.toUpperCase(), objType: 'CLAS' };
120
133
  }
121
134
  const object = pickString(args, 'name') ?? pickString(args, 'object_name');
122
135
  if (!object)
@@ -27,6 +27,7 @@ import { getEffectiveWriteMode } from '../repl/mode-cycle.js';
27
27
  import { effectiveRisk } from '../approvals/risk-floor.js';
28
28
  import { verifySyntax, verifyActive } from '../tools/verify.js';
29
29
  import { renderDiff } from '../repl/diff-display.js';
30
+ import { parseIncludeSnapshotType } from '../tools/include-snapshot.js';
30
31
  /** Compact single-line failure text for receipts / error envelopes. */
31
32
  function shortError(err) {
32
33
  const msg = err instanceof Error ? err.message : String(err);
@@ -55,6 +56,10 @@ export async function restoreSnapshot(c, deps) {
55
56
  if (c.snapshotId === null) {
56
57
  return { ok: false, steps, error: 'no snapshot to restore from' };
57
58
  }
59
+ // A class-include candidate (objType CLAS.<INCLUDE>) restores that include;
60
+ // syntax check / activation / verify then run on the CLASS.
61
+ const include = parseIncludeSnapshotType(c.objType);
62
+ const repoType = include ? 'CLAS' : c.objType;
58
63
  // ── Step 2: Read the snapshot source (the content we will restore) ────────
59
64
  let snapshotSource;
60
65
  try {
@@ -67,12 +72,23 @@ export async function restoreSnapshot(c, deps) {
67
72
  // A 404 means the object was deleted this session; current = null and we
68
73
  // will recreate it. Any OTHER read failure is NOT proof of absence — stop
69
74
  // rather than blindly treat it as a create.
75
+ // Class include candidates (objType CLAS.<INCLUDE>) read and write the
76
+ // include itself; there is no delete-restore for an include, so a 404 stops.
70
77
  let currentSource;
71
78
  try {
72
- currentSource = await deps.adt.getSource(c.objType, c.object);
79
+ if (include) {
80
+ const inc = await deps.adt.classIncludes(c.object, include);
81
+ if (typeof inc !== 'string') {
82
+ return { ok: false, steps, error: 'could not read current include source' };
83
+ }
84
+ currentSource = inc;
85
+ }
86
+ else {
87
+ currentSource = await deps.adt.getSource(c.objType, c.object);
88
+ }
73
89
  }
74
90
  catch (err) {
75
- if (err instanceof SapError && err.httpStatus === 404) {
91
+ if (err instanceof SapError && err.httpStatus === 404 && !include) {
76
92
  currentSource = null;
77
93
  }
78
94
  else {
@@ -87,7 +103,7 @@ export async function restoreSnapshot(c, deps) {
87
103
  const change = {
88
104
  op,
89
105
  object: c.object,
90
- type: c.objType,
106
+ type: repoType,
91
107
  diff: renderDiff(currentSource ?? '', snapshotSource, {
92
108
  label: `${c.objType}/${c.object}`,
93
109
  syntaxHighlight: false,
@@ -143,20 +159,38 @@ export async function restoreSnapshot(c, deps) {
143
159
  return { ok: false, steps, error: `recreate failed: ${created.error ?? `HTTP ${created.httpStatus}`}` };
144
160
  }
145
161
  }
146
- let writeResult;
147
- try {
148
- writeResult = await deps.adt.setSource(c.objType, c.object, snapshotSource, deps.transport);
149
- }
150
- catch (err) {
151
- record('② written ✗');
152
- return { ok: false, steps, error: `write failed: ${shortError(err)}` };
162
+ if (include) {
163
+ // Include restore: write the include back. setClassInclude reads its own
164
+ // write back and returns written:false on a discarded or refused PUT.
165
+ let incResult;
166
+ try {
167
+ incResult = await deps.adt.setClassInclude(c.object, include, snapshotSource, deps.transport);
168
+ }
169
+ catch (err) {
170
+ record('② written ✗');
171
+ return { ok: false, steps, error: `write failed: ${shortError(err)}` };
172
+ }
173
+ if (!incResult.written) {
174
+ record('② written ✗');
175
+ return { ok: false, steps, error: `write failed: ${incResult.error ?? `HTTP ${incResult.httpStatus}`}` };
176
+ }
153
177
  }
154
- // setSource RETURNS (does not throw) on HTTP 4xx — check the return value,
155
- // exactly as sap_set_source does.
156
- if (writeResult.error || (writeResult.writeStatus !== undefined && writeResult.writeStatus >= 400)) {
157
- const detail = writeResult.error ?? `HTTP ${writeResult.writeStatus}`;
158
- record('② written ✗');
159
- return { ok: false, steps, error: `write failed: ${detail}` };
178
+ else {
179
+ let writeResult;
180
+ try {
181
+ writeResult = await deps.adt.setSource(c.objType, c.object, snapshotSource, deps.transport);
182
+ }
183
+ catch (err) {
184
+ record('② written ✗');
185
+ return { ok: false, steps, error: `write failed: ${shortError(err)}` };
186
+ }
187
+ // setSource RETURNS (does not throw) on HTTP 4xx — check the return value,
188
+ // exactly as sap_set_source does.
189
+ if (writeResult.error || (writeResult.writeStatus !== undefined && writeResult.writeStatus >= 400)) {
190
+ const detail = writeResult.error ?? `HTTP ${writeResult.writeStatus}`;
191
+ record('② written ✗');
192
+ return { ok: false, steps, error: `write failed: ${detail}` };
193
+ }
160
194
  }
161
195
  record('② written ✓');
162
196
  // ── Step 7: Syntax check (Rule 10) — errors ⇒ STOP, do NOT activate ───────
@@ -167,7 +201,7 @@ export async function restoreSnapshot(c, deps) {
167
201
  // WRITE HAPPENED; only the verification/activation call failed to run.
168
202
  let syntax;
169
203
  try {
170
- syntax = await verifySyntax(deps.adt, c.object, c.objType);
204
+ syntax = await verifySyntax(deps.adt, c.object, repoType);
171
205
  }
172
206
  catch (err) {
173
207
  record(`③ syntax — check failed to run: ${shortError(err)}`);
@@ -191,7 +225,7 @@ export async function restoreSnapshot(c, deps) {
191
225
  // ── Step 8: Activate + verify (Rule 10) ───────────────────────────────────
192
226
  let activation;
193
227
  try {
194
- activation = await deps.adt.activate(c.objType, c.object);
228
+ activation = await deps.adt.activate(repoType, c.object);
195
229
  }
196
230
  catch (err) {
197
231
  record(`④ activate — call failed: ${shortError(err)}`);
@@ -217,7 +251,7 @@ export async function restoreSnapshot(c, deps) {
217
251
  // past the receipt.
218
252
  let active;
219
253
  try {
220
- active = await verifyActive(deps.adt, c.object, c.objType);
254
+ active = await verifyActive(deps.adt, c.object, repoType);
221
255
  }
222
256
  catch (err) {
223
257
  active = { active: false, verifyError: shortError(err) };
@@ -3,6 +3,8 @@ import { getStore } from '../auth/api-key.js';
3
3
  import { classify as classifyLocal } from '../classifier/client.js';
4
4
  import { SKILL_CATALOG, SKILL_ALIASES, resolveSkillAlias } from '../skill-catalog.js';
5
5
  import { isBuiltinCommandHead } from '../repl/builtin-commands.js';
6
+ import { expandImageAttachments } from '../projects/image-attachments.js';
7
+ import { expandImageChips } from '../ui/clipboard-image.js';
6
8
  // 8s — sized to cover the observed Fly cold-start (~3.2s on the 2026-05-20
7
9
  // probe) plus headroom for a slow network. Tighter values risk timing out
8
10
  // legitimate cold-start hits and burning the retry budget on the same.
@@ -25,7 +27,7 @@ function extractCause(err) {
25
27
  }
26
28
  return e.message;
27
29
  }
28
- async function tryFetchOnce(proxyUrl, key, prompt, context) {
30
+ async function tryFetchOnce(proxyUrl, key, prompt, context, images = []) {
29
31
  const ctrl = new AbortController();
30
32
  const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
31
33
  try {
@@ -35,7 +37,7 @@ async function tryFetchOnce(proxyUrl, key, prompt, context) {
35
37
  'Authorization': `Bearer ${key}`,
36
38
  'Content-Type': 'application/json',
37
39
  },
38
- body: JSON.stringify({ prompt, context }),
40
+ body: JSON.stringify(images.length > 0 ? { prompt, context, images } : { prompt, context }),
39
41
  signal: ctrl.signal,
40
42
  });
41
43
  }
@@ -43,7 +45,24 @@ async function tryFetchOnce(proxyUrl, key, prompt, context) {
43
45
  clearTimeout(timer);
44
46
  }
45
47
  }
46
- export async function classifyPrompt(prompt, context) {
48
+ /**
49
+ * Screenshot-aware routing (2026-09-23): images in the prompt (pasted
50
+ * [Image #N] chips or image paths) go to the router so it reads the picture
51
+ * with the text. Any problem (too large, too many, unreadable) → classify on
52
+ * text; the turn itself reports the error when it runs.
53
+ */
54
+ async function promptImages(prompt, cwd) {
55
+ try {
56
+ const r = await expandImageAttachments(expandImageChips(prompt), cwd);
57
+ if (r.error)
58
+ return [];
59
+ return r.images.map((im) => ({ media_type: im.source.media_type, data: im.source.data }));
60
+ }
61
+ catch {
62
+ return [];
63
+ }
64
+ }
65
+ export async function classifyPrompt(prompt, context, opts = {}) {
47
66
  // D22/D35 structural invariant (2026-06-11) — an explicit slash command
48
67
  // must NEVER reach the classifier. The S3 battery proved the failure mode:
49
68
  // `/abap-plan <multiline>` failed the old parseShortcut regex, the whole
@@ -61,6 +80,7 @@ export async function classifyPrompt(prompt, context) {
61
80
  const key = await store.get();
62
81
  if (!key)
63
82
  throw new Error('no_api_key');
83
+ const images = await promptImages(prompt, opts.cwd ?? process.cwd());
64
84
  let r;
65
85
  let lastErr;
66
86
  // Two attempts total: the second masks the common case of a stale
@@ -71,7 +91,7 @@ export async function classifyPrompt(prompt, context) {
71
91
  // they bubble through as before.
72
92
  for (let attempt = 0; attempt < 2; attempt++) {
73
93
  try {
74
- r = await tryFetchOnce(cfg.proxy_url, key, prompt, context);
94
+ r = await tryFetchOnce(cfg.proxy_url, key, prompt, context, images);
75
95
  break;
76
96
  }
77
97
  catch (err) {
@@ -26,6 +26,7 @@ import { readProjectFile } from '../projects/status.js';
26
26
  import { parsePlanContent } from '../projects/plan-schema.js';
27
27
  import { statusesFromItems, isPhaseSatisfied } from '../projects/plan-run.js';
28
28
  import { defaultWorkspaceFor } from '../projects/workspace.js';
29
+ import { userPromptText } from './user-prompt.js';
29
30
  /** Wave-1 truncation rule (see /reroute): ≤60 chars total, '...' suffix. */
30
31
  function truncatePrompt(s) {
31
32
  return s.length > 60 ? s.slice(0, 57) + '...' : s;
@@ -37,10 +38,12 @@ function truncatePrompt(s) {
37
38
  export function buildRecap(session, cwd, opts) {
38
39
  const lines = [];
39
40
  // ── line 1: session orientation ─────────────────────────────────────────
40
- // "User turns" = plain-string user messages (tool_result rounds are arrays)
41
- // — same counting rule as the resume picker (session/store.ts).
42
- const userMessages = session.messages.filter((m) => m.role === 'user' && typeof m.content === 'string');
43
- const turns = userMessages.length;
41
+ // "User turns" = typed prompts, not tool_result rounds — same counting
42
+ // rule as the resume picker (session/store.ts); see session/user-prompt.ts.
43
+ const userTexts = session.messages
44
+ .map(userPromptText)
45
+ .filter((t) => t !== null);
46
+ const turns = userTexts.length;
44
47
  if (turns === 0)
45
48
  return [];
46
49
  const skillLabel = session.skill ? `/${session.skill}` : 'no skill';
@@ -50,7 +53,7 @@ export function buildRecap(session, cwd, opts) {
50
53
  // rule as the picker's promptSnippet).
51
54
  let prompt = session.lastUserPrompt;
52
55
  if (!prompt) {
53
- const last = userMessages[userMessages.length - 1].content;
56
+ const last = userTexts[userTexts.length - 1];
54
57
  prompt = last.replace(/<session_context>[\s\S]*?<\/session_context>\s*/g, '').trim() || null;
55
58
  }
56
59
  const quoted = prompt ? `"${truncatePrompt(prompt)}"` : '(no prompt)';
@@ -19,23 +19,15 @@ export async function runResume(id) {
19
19
  return b.lastTurnAt.localeCompare(a.lastTurnAt);
20
20
  });
21
21
  targetId = await select({
22
- message: 'Pick a session to resume:',
23
- choices: sorted.map((s) => {
24
- const flag = s.interrupted ? chalk.yellow('⚠ ') : ' ';
25
- const idCol = chalk.cyan(s.id.padEnd(8));
26
- const timeCol = chalk.gray(formatTimeAgo(s.lastTurnAt).padEnd(10));
27
- const sapCol = chalk.gray((s.sapSystem ?? '-').padEnd(8));
28
- const skillCol = chalk.gray(`/${s.skill ?? '-'}`.padEnd(22));
29
- const turns = chalk.gray(`${s.userTurnCount}t`.padStart(3));
30
- const snippet = s.promptSnippet ?? chalk.dim('(no prompt)');
31
- return {
32
- value: s.id,
33
- name: `${flag}${idCol} ${timeCol} ${sapCol} ${skillCol} ${turns} ${snippet}`,
34
- description: s.interrupted
35
- ? `Interrupted (${s.interruptedReason ?? 'unknown'}) — type "continue" after resume`
36
- : `${s.userTurnCount} prior turn(s) — pick up from where you left off`,
37
- };
38
- }),
22
+ message: `Pick a session to resume:
23
+ ${formatSessionHeader()}`,
24
+ choices: sorted.map((s) => ({
25
+ value: s.id,
26
+ name: formatSessionRow(s),
27
+ description: s.interrupted
28
+ ? `Interrupted (${s.interruptedReason ?? 'unknown'}) — type "continue" after resume`
29
+ : `${s.userTurnCount} prior turn(s) — pick up from where you left off`,
30
+ })),
39
31
  });
40
32
  }
41
33
  let session;
@@ -75,3 +67,31 @@ export async function runResume(id) {
75
67
  // to the persistence work in agent/loop.ts.
76
68
  return runRepl({ resumedSession: session });
77
69
  }
70
+ // Column widths shared by the rows and the header so the labels line up.
71
+ const W = { id: 8, time: 10, sap: 8, skill: 22, turns: 5 };
72
+ export function formatSessionRow(s) {
73
+ const flag = s.interrupted ? chalk.yellow('⚠ ') : ' ';
74
+ const idCol = chalk.cyan(s.id.padEnd(W.id));
75
+ const timeCol = chalk.gray(formatTimeAgo(s.lastTurnAt).padEnd(W.time));
76
+ const sapCol = chalk.gray((s.sapSystem ?? '-').padEnd(W.sap));
77
+ const skillCol = chalk.gray(`/${s.skill ?? '-'}`.padEnd(W.skill));
78
+ const turns = chalk.gray(`${s.userTurnCount}t`.padStart(W.turns));
79
+ const snippet = s.promptSnippet ?? chalk.dim('(no prompt)');
80
+ return `${flag}${idCol} ${timeCol} ${sapCol} ${skillCol} ${turns} ${snippet}`;
81
+ }
82
+ /**
83
+ * One dim line naming the row columns (2026-09-23 — rows had no labels). The
84
+ * picker draws a 2-char cursor gutter before each choice but not before the
85
+ * message, so the header adds it: gutter + flag = 4 leading spaces.
86
+ */
87
+ export function formatSessionHeader() {
88
+ return chalk.dim(' ' +
89
+ [
90
+ 'ID'.padEnd(W.id),
91
+ 'LAST USED'.padEnd(W.time),
92
+ 'SYSTEM'.padEnd(W.sap),
93
+ 'SKILL'.padEnd(W.skill),
94
+ 'TURNS'.padStart(W.turns),
95
+ ].join(' ') +
96
+ ' LAST PROMPT');
97
+ }
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
4
  import { sessionsDir, pinnedSessionsDir } from '../config/paths.js';
5
5
  import { SCHEMA_VERSION } from './schema.js';
6
+ import { userPromptText, isUserPrompt } from './user-prompt.js';
6
7
  export function makeSessionId() {
7
8
  return crypto.randomBytes(4).toString('hex');
8
9
  }
@@ -107,16 +108,17 @@ export async function listSessions(limit = 10) {
107
108
  let promptSnippet = null;
108
109
  for (let i = messages.length - 1; i >= 0; i--) {
109
110
  const m = messages[i];
110
- if (m && m.role === 'user' && typeof m.content === 'string') {
111
+ const text = m ? userPromptText(m) : null;
112
+ if (text !== null) {
111
113
  // Strip <session_context>...</session_context> wrapper that the
112
114
  // first turn injects, leaving only the user-typed prompt visible
113
115
  // in the picker.
114
- const stripped = m.content.replace(/<session_context>[\s\S]*?<\/session_context>\s*/g, '').trim();
116
+ const stripped = text.replace(/<session_context>[\s\S]*?<\/session_context>\s*/g, '').trim();
115
117
  promptSnippet = (stripped || '<empty>').slice(0, 80);
116
118
  break;
117
119
  }
118
120
  }
119
- const userTurnCount = messages.filter((m) => m?.role === 'user' && typeof m.content === 'string').length;
121
+ const userTurnCount = messages.filter((m) => m && isUserPrompt(m)).length;
120
122
  results.push({
121
123
  id: parsed.id,
122
124
  startedAt: parsed.started_at,