@illuminis/comprism 0.1.3 → 0.1.5

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 (142) hide show
  1. package/README.md +61 -2
  2. package/out/agent/command.d.ts +75 -4
  3. package/out/agent/command.js +221 -26
  4. package/out/agent/render.d.ts +56 -8
  5. package/out/agent/render.js +441 -39
  6. package/out/agent/session.d.ts +156 -2
  7. package/out/agent/session.js +795 -154
  8. package/out/commands/agents.d.ts +2 -0
  9. package/out/commands/agents.js +79 -0
  10. package/out/commands/ask.d.ts +1 -1
  11. package/out/commands/ask.js +88 -12
  12. package/out/commands/commands-thin.js +79 -17
  13. package/out/commands/config.d.ts +1 -0
  14. package/out/commands/config.js +138 -0
  15. package/out/commands/cost.d.ts +1 -0
  16. package/out/commands/cost.js +167 -0
  17. package/out/commands/hooks.d.ts +1 -0
  18. package/out/commands/hooks.js +83 -0
  19. package/out/commands/install.d.ts +44 -1
  20. package/out/commands/install.js +198 -4
  21. package/out/commands/instructions.d.ts +1 -0
  22. package/out/commands/instructions.js +113 -0
  23. package/out/commands/integrations.d.ts +3 -0
  24. package/out/commands/integrations.js +215 -0
  25. package/out/commands/jobs.d.ts +5 -0
  26. package/out/commands/jobs.js +157 -0
  27. package/out/commands/login.js +249 -43
  28. package/out/commands/memory.d.ts +3 -0
  29. package/out/commands/memory.js +113 -0
  30. package/out/commands/permissions.d.ts +1 -0
  31. package/out/commands/permissions.js +94 -0
  32. package/out/commands/plugins.d.ts +4 -0
  33. package/out/commands/plugins.js +192 -0
  34. package/out/commands/privacy.d.ts +1 -0
  35. package/out/commands/privacy.js +57 -0
  36. package/out/commands/providerKey.d.ts +32 -0
  37. package/out/commands/providerKey.js +108 -0
  38. package/out/commands/repl.d.ts +12 -1
  39. package/out/commands/repl.js +1649 -122
  40. package/out/commands/report.d.ts +39 -0
  41. package/out/commands/report.js +115 -0
  42. package/out/commands/review.d.ts +5 -0
  43. package/out/commands/review.js +223 -0
  44. package/out/commands/sessions.d.ts +23 -0
  45. package/out/commands/sessions.js +115 -0
  46. package/out/commands/settings.d.ts +3 -1
  47. package/out/commands/settings.js +18 -16
  48. package/out/commands/skills.d.ts +21 -0
  49. package/out/commands/skills.js +207 -0
  50. package/out/commands/unattended.d.ts +7 -0
  51. package/out/commands/unattended.js +351 -0
  52. package/out/commands/update.d.ts +1 -0
  53. package/out/commands/update.js +123 -0
  54. package/out/commands/welcome.d.ts +25 -1
  55. package/out/commands/welcome.js +71 -35
  56. package/out/commands/worktrees.d.ts +5 -0
  57. package/out/commands/worktrees.js +186 -0
  58. package/out/executor/browser.d.ts +14 -0
  59. package/out/executor/browser.js +270 -0
  60. package/out/executor/diagnostics.d.ts +2 -0
  61. package/out/executor/diagnostics.js +181 -0
  62. package/out/executor/files.js +270 -40
  63. package/out/executor/git.js +42 -29
  64. package/out/executor/hooks.d.ts +42 -58
  65. package/out/executor/hooks.js +89 -182
  66. package/out/executor/index.d.ts +21 -5
  67. package/out/executor/index.js +160 -14
  68. package/out/executor/paths.d.ts +6 -1
  69. package/out/executor/paths.js +34 -6
  70. package/out/executor/sandbox.d.ts +40 -0
  71. package/out/executor/sandbox.js +299 -0
  72. package/out/executor/shell.d.ts +49 -4
  73. package/out/executor/shell.js +302 -56
  74. package/out/executor/toolservers.d.ts +20 -0
  75. package/out/executor/toolservers.js +189 -0
  76. package/out/executor/worktree.d.ts +9 -0
  77. package/out/executor/worktree.js +119 -0
  78. package/out/graph/read-python.js +2 -1
  79. package/out/lib/attach.d.ts +56 -12
  80. package/out/lib/attach.js +230 -63
  81. package/out/lib/clipboard.d.ts +23 -0
  82. package/out/lib/clipboard.js +182 -0
  83. package/out/lib/commandlist.d.ts +20 -0
  84. package/out/lib/commandlist.js +58 -0
  85. package/out/lib/decision.d.ts +22 -0
  86. package/out/lib/decision.js +50 -0
  87. package/out/lib/fingerprint.d.ts +25 -0
  88. package/out/lib/fingerprint.js +58 -0
  89. package/out/lib/gateway.d.ts +190 -2
  90. package/out/lib/gateway.js +59 -4
  91. package/out/lib/history.d.ts +24 -0
  92. package/out/lib/history.js +137 -0
  93. package/out/lib/ide.d.ts +19 -0
  94. package/out/lib/ide.js +131 -0
  95. package/out/lib/keyboard.d.ts +98 -0
  96. package/out/lib/keyboard.js +390 -0
  97. package/out/lib/lastlogin.d.ts +14 -0
  98. package/out/lib/lastlogin.js +105 -0
  99. package/out/lib/machine.d.ts +21 -0
  100. package/out/lib/machine.js +91 -0
  101. package/out/lib/notify.d.ts +4 -0
  102. package/out/lib/notify.js +52 -0
  103. package/out/lib/output.d.ts +48 -0
  104. package/out/lib/output.js +108 -0
  105. package/out/lib/picker.d.ts +1 -0
  106. package/out/lib/picker.js +158 -0
  107. package/out/lib/project-ops.d.ts +19 -0
  108. package/out/lib/project-ops.js +146 -0
  109. package/out/lib/project.d.ts +28 -0
  110. package/out/lib/project.js +114 -0
  111. package/out/lib/prompt.d.ts +3 -0
  112. package/out/lib/prompt.js +36 -2
  113. package/out/lib/queue.d.ts +13 -0
  114. package/out/lib/queue.js +116 -0
  115. package/out/lib/readiness.d.ts +19 -0
  116. package/out/lib/readiness.js +170 -1
  117. package/out/lib/self.d.ts +23 -0
  118. package/out/lib/self.js +124 -0
  119. package/out/lib/sessions.d.ts +19 -0
  120. package/out/lib/sessions.js +221 -0
  121. package/out/lib/startcache.d.ts +13 -0
  122. package/out/lib/startcache.js +78 -0
  123. package/out/lib/stdin.d.ts +32 -0
  124. package/out/lib/stdin.js +117 -0
  125. package/out/lib/store.d.ts +40 -0
  126. package/out/lib/store.js +138 -0
  127. package/out/lib/sync.d.ts +18 -0
  128. package/out/lib/sync.js +81 -0
  129. package/out/lib/ui.d.ts +10 -4
  130. package/out/lib/ui.js +65 -25
  131. package/out/lib/voice.d.ts +36 -0
  132. package/out/lib/voice.js +160 -1
  133. package/out/native/dictate +0 -0
  134. package/out/postinstall.js +42 -17
  135. package/out/providers/anthropic.d.ts +22 -0
  136. package/out/providers/anthropic.js +80 -0
  137. package/out/providers/index.d.ts +8 -0
  138. package/out/providers/index.js +83 -0
  139. package/out/providers/openai.d.ts +11 -0
  140. package/out/providers/openai.js +57 -0
  141. package/out/thin.js +595 -33
  142. package/package.json +11 -50
@@ -67,12 +67,20 @@ exports.runRepl = runRepl;
67
67
  * session sends the person to `keys` first and will not start until one is
68
68
  * stored. Being stopped early and told why beats being stopped late.
69
69
  */
70
+ const node_crypto_1 = require("node:crypto");
71
+ const path = __importStar(require("node:path"));
70
72
  const node_readline_1 = __importDefault(require("node:readline"));
71
73
  const connection_1 = require("../lib/connection");
72
74
  const keys_1 = require("./keys");
73
75
  const welcome_1 = require("./welcome");
74
76
  const attach = __importStar(require("../lib/attach"));
77
+ const commandlist_1 = require("../lib/commandlist");
78
+ const history_1 = require("../lib/history");
79
+ const keyboard_1 = require("../lib/keyboard");
80
+ const machine_1 = require("../lib/machine");
75
81
  const ui = __importStar(require("../lib/ui"));
82
+ const render_1 = require("../agent/render");
83
+ const shell_1 = require("../executor/shell");
76
84
  const voice = __importStar(require("../lib/voice"));
77
85
  const out = (s = '') => process.stdout.write(s + '\n');
78
86
  /**
@@ -85,29 +93,274 @@ const out = (s = '') => process.stdout.write(s + '\n');
85
93
  */
86
94
  let reader = null;
87
95
  let onLine = null;
96
+ /** Earlier requests, for Up, Down and Ctrl+R (manual 6.5). */
97
+ const earlier = new history_1.History();
98
+ /** Pastes, Alt+Enter and Ctrl+R, between the keyboard and the reader (6.2,
99
+ * 6.3, 6.5). Only in a terminal: piped input is read as lines, as before. */
100
+ const keyboard = new keyboard_1.Keyboard(() => earlier.entries);
101
+ /** Where the session's keystrokes arrive, for a key the session handles
102
+ * itself such as Shift+Tab. Nothing arrives while a command has the keyboard. */
103
+ const keys = () => (process.stdin.isTTY ? keyboard.input : process.stdin);
88
104
  function releaseTerminal() {
89
105
  reader?.close();
90
106
  reader = null;
107
+ // The keyboard goes back with the reader, so the command that asks its own
108
+ // question is the only thing reading it (manual 6.1).
109
+ keyboard.bind(null);
110
+ keyboard.detach();
91
111
  }
92
112
  function takeTerminal() {
93
113
  if (reader || !onLine)
94
114
  return;
115
+ const tty = process.stdin.isTTY === true;
116
+ if (tty)
117
+ keyboard.attach();
95
118
  reader = node_readline_1.default.createInterface({
96
- input: process.stdin,
119
+ input: tty ? keyboard.input : process.stdin,
97
120
  output: process.stdout,
98
- terminal: process.stdin.isTTY === true,
121
+ terminal: tty,
122
+ // The session's own list, walked by Up and Down. Size 0 so the reader
123
+ // never adds to it itself: it would add each half of a request continued
124
+ // with a backslash, and the session adds the whole request once sent.
125
+ history: earlier.entries,
126
+ historySize: 0,
127
+ completer: complete,
128
+ });
129
+ keyboard.bind(reader);
130
+ // Enter has already moved the cursor onto the first line of the live list,
131
+ // so clearing from here down removes it before anything else prints.
132
+ reader.on('line', () => {
133
+ if (popupShown)
134
+ process.stdout.write(`${CSI}J`);
135
+ popupShown = 0;
99
136
  });
100
137
  reader.on('line', onLine);
101
- reader.on('SIGINT', () => leaveSession());
138
+ reader.on('SIGINT', () => interrupt());
139
+ }
140
+ /**
141
+ * Tab (manual 3.1 and 6.4). After `/` it offers the service's list of
142
+ * commands and skills, one line each; after `@` the project's file names.
143
+ * Anywhere else it offers nothing, so it never rewrites a sentence.
144
+ */
145
+ function complete(line, done) {
146
+ clearPopup();
147
+ const slash = /^\/(\S*)$/.exec(line);
148
+ if (!slash) {
149
+ done(null, attach.completeAt(line, () => {
150
+ if (!completion.files || Date.now() - completion.at > 30_000) {
151
+ completion.files = attach.projectFiles(completion.root);
152
+ completion.at = Date.now();
153
+ }
154
+ return completion.files;
155
+ }));
156
+ return;
157
+ }
158
+ const typed = slash[1].toLowerCase();
159
+ const hits = listedItems().filter((i) => i.name.startsWith(typed));
160
+ if (hits.length === 1) {
161
+ done(null, [[`/${hits[0].name} `], line]);
162
+ return;
163
+ }
164
+ done(null, [[], line]);
165
+ if (hits.length === 0 || !reader)
166
+ return;
167
+ out();
168
+ drawList(hits);
169
+ // Filled in as far as every match agrees, then the prompt drawn again under
170
+ // the list with the cursor at the end.
171
+ const common = hits.map((h) => h.name).reduce((a, b) => {
172
+ let n = 0;
173
+ while (n < a.length && a[n] === b[n])
174
+ n += 1;
175
+ return a.slice(0, n);
176
+ });
177
+ const edit = reader;
178
+ if (common.length > typed.length)
179
+ edit.line = `/${common}`;
180
+ edit.cursor = edit.line.length;
181
+ reader.prompt(true);
182
+ }
183
+ const CSI = `${String.fromCharCode(27)}[`;
184
+ const SAVE = `${String.fromCharCode(27)}7`;
185
+ const RESTORE = `${String.fromCharCode(27)}8`;
186
+ /** How many lines the live list under the prompt holds now, 0 when none. */
187
+ let popupShown = 0;
188
+ const POPUP_ROWS = 8;
189
+ /** What the list under the prompt holds, and which of it is marked (-1: none). */
190
+ let popupHits = [];
191
+ let popupAt = -1;
192
+ /**
193
+ * Up and Down move through the list under the prompt; Enter runs the marked
194
+ * command, Tab fills it in to add to. With nothing marked, or no list showing,
195
+ * every key does what it always did (Up and Down recall earlier requests).
196
+ */
197
+ keyboard.onMenuKey = (key) => {
198
+ if (!popupShown || !reader || popupHits.length === 0)
199
+ return false;
200
+ const edit = reader;
201
+ if (key === 'up' || key === 'down') {
202
+ popupAt = key === 'down' ? Math.min(popupHits.length - 1, popupAt + 1) : Math.max(-1, popupAt - 1);
203
+ updatePopup();
204
+ return true;
205
+ }
206
+ if (popupAt < 0)
207
+ return false;
208
+ const chosen = `/${popupHits[popupAt].name}`;
209
+ if (key === 'tab') {
210
+ edit.line = `${chosen} `;
211
+ edit.cursor = edit.line.length;
212
+ edit._refreshLine();
213
+ clearPopup();
214
+ return true;
215
+ }
216
+ // Enter: the marked command replaces what was typed, then Enter goes on.
217
+ edit.line = chosen;
218
+ edit.cursor = chosen.length;
219
+ popupAt = -1;
220
+ return false;
221
+ };
222
+ /** Takes the live list away, leaving the prompt and the cursor where they are. */
223
+ function clearPopup() {
224
+ if (!popupShown || !process.stdout.isTTY)
225
+ return;
226
+ // Each row the list used, blanked; never a clear to the end of the screen.
227
+ process.stdout.write(`${SAVE}${`${CSI}1E${CSI}2K`.repeat(popupShown)}${RESTORE}`);
228
+ popupShown = 0;
102
229
  }
230
+ /**
231
+ * The commands and skills that match what is typed, shown under the prompt
232
+ * while it is typed (manual 6.4). The same list Tab and `help` offer, from the
233
+ * service, so a skill added on the server appears here with no new build.
234
+ * Drawn only while the line is `/` and a word; anything else clears it.
235
+ */
236
+ function updatePopup() {
237
+ if (!reader || !process.stdout.isTTY)
238
+ return;
239
+ const line = reader.line ?? '';
240
+ const slash = /^\/(\S*)$/.exec(line);
241
+ const typed = slash ? slash[1].toLowerCase() : '';
242
+ const hits = slash ? listedItems().filter((i) => i.name.startsWith(typed)) : [];
243
+ // A different set of matches starts with nothing marked, so Enter still
244
+ // sends what was typed until Down is pressed.
245
+ if (hits.map((h) => h.name).join() !== popupHits.map((h) => h.name).join())
246
+ popupAt = -1;
247
+ popupHits = hits;
248
+ // Shown for a whole word too, so the list stays in sight while it is chosen.
249
+ if (!hits.length) {
250
+ clearPopup();
251
+ return;
252
+ }
253
+ const first = Math.max(0, Math.min(popupAt - POPUP_ROWS + 1, hits.length - POPUP_ROWS));
254
+ const shown = hits.slice(first, first + POPUP_ROWS);
255
+ const width = Math.min(18, Math.max(...shown.map((i) => i.name.length)) + 1);
256
+ const room = Math.max(20, (process.stdout.columns || 100) - width - 8);
257
+ const rows = shown.map((i, k) => {
258
+ const from = i.source === 'session' ? '' : ` ${i.source}`;
259
+ const words = `${i.description}${from}`;
260
+ const name = `/${i.name}`.padEnd(width + 1);
261
+ const marked = first + k === popupAt;
262
+ return `${marked ? ui.c.blue('\u25B8 ') : ' '}${marked ? ui.c.bold(ui.c.blue(name)) : ui.c.bold(name)} `
263
+ + ui.c.dim(words.length > room ? `${words.slice(0, room - 3)}...` : words);
264
+ });
265
+ if (hits.length > shown.length)
266
+ rows.push(ui.c.dim(` ${hits.length - shown.length} more, Down to see them`));
267
+ const col = reader.getCursorPos().cols;
268
+ // A shorter list than last time blanks the rows it no longer uses, rather
269
+ // than clearing to the end of the screen: on the bottom row there is no line
270
+ // below to move to, and that clear wiped the list's own last row.
271
+ const height = rows.length;
272
+ while (rows.length < popupShown)
273
+ rows.push('');
274
+ // Room is made first, so a prompt on the bottom row scrolls up rather than
275
+ // the list running off the screen, and the cursor comes back to its column.
276
+ process.stdout.write(`${'\n'.repeat(rows.length)}${CSI}${rows.length}A${CSI}${col + 1}G${SAVE}`
277
+ + rows.map((r) => `${CSI}1E${CSI}2K${r}`).join('') + RESTORE);
278
+ popupShown = Math.max(height, 1);
279
+ }
280
+ /** The service's list for this session, once fetched (manual 6.4). */
281
+ let listed = null;
282
+ /** The session words this tool runs. Sent to the service, which lists only
283
+ * these and counts the rest as needing a newer version. */
284
+ function sessionWords() {
285
+ return [...COMMANDS, 'project', 'folders', 'instructions', 'remember', 'detach', 'output', 'processes', 'stop', 'agents'];
286
+ }
287
+ async function refreshList() {
288
+ const { resolveProject } = await Promise.resolve().then(() => __importStar(require('../lib/project')));
289
+ const root = resolveProject(await sessionFolder(), place.added).root;
290
+ const got = await (0, commandlist_1.fetchCommandList)(root, sessionWords());
291
+ // A failed refresh keeps the list already held rather than emptying it.
292
+ if (got.ok || !listed)
293
+ listed = got;
294
+ }
295
+ /** What Tab and `help` offer: the service's list, or, when it could not be
296
+ * asked, this tool's own words with no descriptions. */
297
+ function listedItems() {
298
+ if (listed?.ok)
299
+ return listed.items;
300
+ return sessionWords().map((name) => ({ name, description: '', source: 'session' }));
301
+ }
302
+ function drawList(items) {
303
+ const width = Math.min(18, Math.max(...items.map((i) => i.name.length)) + 1);
304
+ for (const i of items) {
305
+ const from = i.source === 'session' ? '' : ` ${ui.c.dim(i.source)}`;
306
+ out(` ${ui.c.bold(`/${i.name}`.padEnd(width + 1))} ${ui.c.dim(i.description)}${from}`);
307
+ }
308
+ out();
309
+ }
310
+ /** The project whose files Tab offers after `@`, and the list, briefly kept. */
311
+ const completion = {
312
+ root: process.cwd(), files: null, at: 0,
313
+ };
103
314
  /** Set by the session so the handover can end it too. */
104
315
  let leaveSession = () => { };
316
+ /** Ctrl+C (manual 6.7): stop the job, clear the line, or leave. Set by the
317
+ * session; before one starts it leaves. */
318
+ let interrupt = () => leaveSession();
105
319
  /** Words that are commands. Anything else typed is work. */
106
320
  const COMMANDS = new Set([
107
- 'new', 'sessions', 'model', 'providers', 'keys', 'workspace', 'status',
108
- 'voice', 'help', 'exit', 'quit', 'clear', 'login', 'logout', 'signout',
109
- 'disconnect',
321
+ 'new', 'sessions', 'branch', 'remote', 'model', 'providers', 'keys', 'status',
322
+ 'voice', 'help', 'exit', 'quit', 'clear', 'login', 'logout', 'signout', 'history',
323
+ 'disconnect', 'cost', 'spend', 'attached', 'paste', 'plan', 'todo', 'mode',
324
+ 'queue', 'transcript', 'edit', 'context', 'compact', 'why', 'unpin', 'pin', 'effort',
325
+ 'optimize',
110
326
  ]);
327
+ /** `processes` and `stop pN` (manual 5.13): what this session started, and
328
+ * stopping one by its number. Works while a job runs, too. True if handled. */
329
+ function processWord(said) {
330
+ if (/^\/?processes$/i.test(said)) {
331
+ const rows = (0, shell_1.listProcesses)();
332
+ if (!rows.length)
333
+ out(ui.c.dim(' No background processes were started in this session.'));
334
+ for (const p of rows) {
335
+ const age = p.seconds < 60 ? `${p.seconds} s` : `${Math.round(p.seconds / 60)} min`;
336
+ out(` ${ui.c.bold(p.id)} ${p.command} ${ui.c.dim(p.running ? `running ${age}` : 'finished')}`
337
+ + (p.port ? ui.c.dim(` port ${p.port}`) : ''));
338
+ }
339
+ out();
340
+ return true;
341
+ }
342
+ const stop = /^\/?stop\s+(p\d+)$/i.exec(said);
343
+ if (stop) {
344
+ const r = (0, shell_1.stopProcess)(stop[1].toLowerCase());
345
+ out(r.isError ? ui.fail(r.content) : ui.c.dim(` ${r.content}`));
346
+ out();
347
+ return true;
348
+ }
349
+ return false;
350
+ }
351
+ /** `agents` between jobs: the helpers defined here (manual 5.24). */
352
+ async function showHelpers() {
353
+ const { helperLines } = await Promise.resolve().then(() => __importStar(require('./agents')));
354
+ for (const line of await helperLines(await sessionFolder()))
355
+ out(` ${line}`);
356
+ out();
357
+ }
358
+ /** The permission modes, in the order Shift+Tab moves through them (4.5). */
359
+ const MODES = ['read_only', 'approve_writes', 'auto_edit', 'full_auto'];
360
+ /** How the next job in this session runs: its mode and whether it plans first
361
+ * (manual 4.1, 4.5). Changed by `mode`, Shift+Tab, `plan` and `plan off`, and
362
+ * applied from the next job; the service still decides what each mode allows. */
363
+ const steering_ = { mode: 'approve_writes', plan: false, pin: '', effort: '' };
111
364
  /**
112
365
  * The line as a command, with a leading slash taken off.
113
366
  *
@@ -152,51 +405,45 @@ async function withTerminal(run) {
152
405
  }
153
406
  }
154
407
  /**
155
- * What this account has actually done, newest first.
408
+ * This account's earlier sessions, from the service's record (manual 8.5).
156
409
  *
157
410
  * The in-memory list only ever knew about the current run, so closing the
158
- * window lost every thread: a person who came back the next morning and typed
159
- * `sessions` was shown one line, the session they were standing in, and had no
160
- * way to pick up yesterday's work. The record has held all of it the whole
161
- * time. Reading it is the difference between a session and a scratchpad.
162
- *
163
- * Returns nothing when the workspace cannot be reached. A list is not worth
164
- * stopping a session over, and the person is told in the line above it.
411
+ * window lost every thread. The record has held all of it the whole time.
412
+ * Sessions of this run are left out, since they are listed above it, and their
413
+ * names are taken from the record, which names a session from its first
414
+ * request. Returns nothing when the workspace cannot be reached: a list is not
415
+ * worth stopping a session over.
165
416
  */
166
- async function pastWork(limit = 10) {
167
- const conn = (0, connection_1.readConnection)();
168
- // The SESSION token, not the workspace key. The workspace key is a machine
169
- // credential for intercepted requests and the portal's own routes refuse it,
170
- // which reads as "no earlier work" rather than as a wrong credential.
171
- if (!conn?.url || !conn.token)
417
+ async function earlierSessions(run, limit = 10) {
418
+ const { sessions } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
419
+ const reply = await sessions('list', { limit });
420
+ if (!reply.ok || !reply.sessions?.sessions)
172
421
  return [];
173
- try {
174
- const headers = {
175
- authorization: `Bearer ${conn.token}`,
176
- };
177
- if (conn.tenant)
178
- headers['X-Tenant-ID'] = conn.tenant;
179
- const res = await fetch(`${conn.url.replace(/\/+$/, '')}/api/v1/agent/jobs?limit=${limit}`, { headers });
180
- if (!res.ok)
181
- return [];
182
- const body = await res.json();
183
- return (body.jobs ?? []).map((j) => ({
184
- id: String(j.id ?? ''),
185
- request: String(j.request ?? ''),
186
- name: j.name ?? null,
187
- when: String(j.creation_date ?? ''),
188
- usd: Number(j.total_usd ?? 0),
189
- workspace: j.workspace ?? null,
190
- })).filter((j) => j.id);
191
- }
192
- catch {
193
- return [];
194
- }
422
+ const rows = reply.sessions.sessions;
423
+ const lines = reply.sessions.lines;
424
+ const found = [];
425
+ rows.forEach((r, i) => {
426
+ const here = run.find((s) => s.serverId === r.session_id);
427
+ if (here) {
428
+ if (r.title)
429
+ here.title = r.title;
430
+ here.id = r.number;
431
+ return;
432
+ }
433
+ found.push({ number: r.number, line: lines[i] ?? r.number, card: r.card });
434
+ });
435
+ return found;
195
436
  }
196
437
  function newSession() {
438
+ // A UUID, because that is what the job record's session column holds. A
439
+ // dated `cli_...` string was refused by the database, so no job started
440
+ // in a session was ever recorded (found 23 September 2026, prompt 03).
441
+ const serverId = (0, node_crypto_1.randomUUID)();
197
442
  return {
198
- // Short and readable, because a person reads it in a list and types it back.
199
- id: new Date().toISOString().slice(11, 19).replace(/:/g, ''),
443
+ // The session's number, the same one the portal shows (manual 8.1). The
444
+ // service writes it; `numbered` asks for it as soon as the session opens.
445
+ id: serverId,
446
+ serverId,
200
447
  startedAt: new Date().toISOString(),
201
448
  jobs: [],
202
449
  spentUsd: 0,
@@ -205,56 +452,335 @@ function newSession() {
205
452
  };
206
453
  }
207
454
  const money = (n) => `$${n.toFixed(4)}`;
455
+ /**
456
+ * Ask the service for a new session's number and its opening line (8.2).
457
+ * Returns the line to print, or null when the service could not be asked, in
458
+ * which case the session keeps its full id until the next answer names it.
459
+ */
460
+ async function numbered(s) {
461
+ const { sessions } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
462
+ const reply = await sessions('opened', { session_id: s.serverId });
463
+ if (!reply.ok || !reply.sessions?.number)
464
+ return null;
465
+ s.id = reply.sessions.number;
466
+ return reply.sessions.lines?.[0] ?? null;
467
+ }
468
+ /**
469
+ * A session the service already holds, opened again (manual 8.4, 8.5).
470
+ *
471
+ * Same id, so every new job files under the same record; its jobs and its cost
472
+ * so far, so the running total carries on without counting anything twice; and
473
+ * the last job to continue from only when the service says the conversation
474
+ * was kept. Session approvals (`a` answers) belong to the run that gave them
475
+ * and are not carried over.
476
+ */
477
+ function reopened(r) {
478
+ return {
479
+ id: r.number ?? r.session_id ?? '',
480
+ serverId: r.session_id ?? '',
481
+ title: r.title ?? null,
482
+ startedAt: new Date().toISOString(),
483
+ jobs: Array.from({ length: Number(r.jobs ?? 0) }, () => ({ request: '' })),
484
+ spentUsd: Number(r.cost_usd ?? 0),
485
+ savedUsd: 0,
486
+ compared: 0,
487
+ ...(r.continues_job ? { lastJobId: r.continues_job } : {}),
488
+ };
489
+ }
490
+ /** Ask the service for the session to open, choosing from the list when no
491
+ * number was given. Prints the resume lines, or why there are none. */
492
+ async function opening(how) {
493
+ const { sessions, pick, failure, exitFor } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
494
+ let key = how.resume;
495
+ if (how.pick) {
496
+ const listed = await sessions('list', { limit: 20 });
497
+ if (!listed.ok || !listed.sessions) {
498
+ out(ui.fail(failure(listed)));
499
+ process.exitCode = exitFor(listed);
500
+ return null;
501
+ }
502
+ const rows = listed.sessions.sessions ?? [];
503
+ if (rows.length === 0) {
504
+ for (const line of listed.sessions.lines)
505
+ out(line);
506
+ return null;
507
+ }
508
+ const chosen = await pick(listed.sessions.lines);
509
+ if (chosen === null)
510
+ return null;
511
+ key = rows[chosen]?.number;
512
+ }
513
+ const reply = how.latest
514
+ ? await sessions('latest')
515
+ : await sessions('resume', { key: key ?? '' });
516
+ if (!reply.ok || !reply.sessions?.session_id) {
517
+ out(ui.fail(failure(reply)));
518
+ process.exitCode = exitFor(reply);
519
+ return null;
520
+ }
521
+ out();
522
+ for (const line of reply.sessions.lines)
523
+ out(line);
524
+ out();
525
+ // What the session was about, before the next question goes in.
526
+ await showConversation(reply.sessions.session_id, false);
527
+ return reopened(reply.sessions);
528
+ }
529
+ /**
530
+ * A session's earlier questions and answers, read back (manual 8.4).
531
+ *
532
+ * On reopening, the last three exchanges, each answer cut to its first lines,
533
+ * so the screen says what the session was about without filling it. `history`
534
+ * shows every turn in full. Answers are drawn the way a new answer is: tables
535
+ * as tables, emphasis as emphasis.
536
+ */
537
+ async function showConversation(key, full) {
538
+ const { sessions } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
539
+ const { plain } = await Promise.resolve().then(() => __importStar(require('../agent/render')));
540
+ const reply = await sessions('read', { key });
541
+ let turns = (reply.sessions?.turns) ?? [];
542
+ // A service released before it sent the turns still hands over the whole
543
+ // session on export, every request and answer in full. Read from there, so
544
+ // the answers are drawn with their headings and tables rather than as the
545
+ // one line summaries an older service lists.
546
+ if (reply.ok && turns.length === 0)
547
+ turns = await exportedTurns(key);
548
+ if (!reply.ok || turns.length === 0) {
549
+ if (full) {
550
+ out(ui.c.dim(' Nothing earlier in this session was kept to show.'));
551
+ out();
552
+ }
553
+ return;
554
+ }
555
+ const exchanges = turns.filter((t) => t.role === 'you').length;
556
+ let shown = turns;
557
+ if (!full) {
558
+ // From the third question from the end, so what is shown is whole exchanges.
559
+ let asked = 0;
560
+ let from = turns.length;
561
+ for (let i = turns.length - 1; i >= 0; i--) {
562
+ if (turns[i].role === 'you' && ++asked === 3) {
563
+ from = i;
564
+ break;
565
+ }
566
+ if (i === 0)
567
+ from = 0;
568
+ }
569
+ shown = turns.slice(from);
570
+ }
571
+ out(ui.c.dim(full ? ` The whole session, ${exchanges} question(s):`
572
+ : ` Earlier in this session${exchanges > 3 ? `, the last 3 of ${exchanges} questions` : ''}:`));
573
+ out();
574
+ for (const t of shown) {
575
+ if (t.role === 'you') {
576
+ out(` ${ui.c.bold(ui.c.blue('you'))} ${ui.c.bold(t.text.trim())}`);
577
+ out();
578
+ continue;
579
+ }
580
+ // Drawn exactly as a new answer is, rail and all, never indented further.
581
+ const lines = plain(t.text.trim()).split('\n');
582
+ const keep = full ? lines : lines.slice(0, openingCut(lines));
583
+ for (const l of keep)
584
+ out(l);
585
+ if (keep.length < lines.length)
586
+ out(ui.c.dim(` ... ${lines.length - keep.length} more lines. Type /history to read it all.`));
587
+ out();
588
+ }
589
+ }
590
+ /**
591
+ * Where to cut an earlier answer on reopening: the last paragraph break in its
592
+ * first dozen lines, so a table or a list is never shown half drawn. An answer
593
+ * with no break that early is cut at eight lines, as before.
594
+ */
595
+ function openingCut(lines) {
596
+ if (lines.length <= 12)
597
+ return lines.length;
598
+ // A break is the rail on its own; a blank row inside a table has more rules.
599
+ const isBreak = (l) => /^│?$/.test(l.replace(/\x1b\[[0-9;]*m/g, '').trim());
600
+ for (let i = 12; i >= 3; i--)
601
+ if (isBreak(lines[i]))
602
+ return i;
603
+ return 8;
604
+ }
605
+ /** A session's questions and answers in full, from the service's export. */
606
+ async function exportedTurns(key) {
607
+ const { sessions } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
608
+ const reply = await sessions('export', { key, format: 'json' });
609
+ const doc = reply.sessions?.document;
610
+ const turns = [];
611
+ for (const job of doc?.work ?? []) {
612
+ if (job.request)
613
+ turns.push({ role: 'you', text: job.request });
614
+ if (job.answer)
615
+ turns.push({ role: 'agent', text: job.answer });
616
+ }
617
+ return turns;
618
+ }
619
+ /** The command and skill list, as the service sent it (manual 6.4). */
208
620
  function printHelp() {
209
621
  out();
210
622
  out(ui.c.dim(' Type a question and press enter. Or one of these:'));
211
623
  out();
212
- const rows = [
213
- ['new', 'start a fresh session'],
214
- ['sessions', 'this run and earlier work, and pick one up'],
215
- ['model', 'the model your savings are measured against'],
216
- ['providers', 'every vendor and model you can reach'],
217
- ['voice', 'say it instead of typing it'],
218
- ['keys', 'add or replace a provider key'],
219
- ['workspace', 'the folder to work in'],
220
- ['status', 'where you are signed in'],
221
- ['logout', 'sign out, and revoke this machine\'s key'],
222
- ['clear', 'clear the screen'],
223
- ['exit', 'leave, or press Ctrl-C'],
224
- ];
225
- for (const [cmd, what] of rows) {
226
- out(` ${ui.c.bold(cmd.padEnd(11))}${ui.c.dim(what)}`);
624
+ drawList(listedItems());
625
+ if (!listed?.ok) {
626
+ out(ui.c.amber(' Your workspace could not be asked for its skills, so only this tool\'s own words are shown.'));
627
+ out();
227
628
  }
228
- out();
229
- out(ui.c.dim(' A slash is fine as well, so `/model` and `model` do the same thing.'));
629
+ else if (listed.needsUpdate.length > 0) {
630
+ out(ui.c.dim(` ${listed.needsUpdate.length} more need a newer comprism: npm install -g @illuminis/comprism`));
631
+ out();
632
+ }
633
+ out(ui.c.dim(' A slash is fine as well, so `/model` and `model` do the same thing. Type / and press Tab to pick one.'));
230
634
  out(ui.c.dim(' Drag a file onto this window, or type @ and a path, to have it read.'));
635
+ out(ui.c.dim(' End a line with \\ or press Alt+Enter for a new line. Up and Down recall earlier requests; Ctrl+R searches them.'));
231
636
  out();
232
637
  }
233
638
  /**
234
639
  * Hand a request to the coding agent, in the folder this session is working in.
235
640
  *
236
- * The agent reads the project, proposes every change before making it, and runs
641
+ * The agent reads the project, shows every change as it makes it, and runs
237
642
  * the tests. It is the same agent the standalone command runs; there is one
238
643
  * implementation, so the session and the command cannot behave differently.
239
644
  */
240
- async function build(session, request, confirm, onStarted, attachmentIds = []) {
241
- const { runAgent } = await Promise.resolve().then(() => __importStar(require('../agent/command')));
645
+ /**
646
+ * The folders this session works in (manual 2.1 and 2.3). `project <path>`
647
+ * changes the folder for the rest of the session; `folders add <path>` allows
648
+ * another. Neither changes the saved default, which is `comprism project`.
649
+ */
650
+ const place = { folder: null, added: [] };
651
+ async function sessionFolder() {
652
+ if (place.folder)
653
+ return place.folder;
242
654
  const { currentWorkspace } = await Promise.resolve().then(() => __importStar(require('./settings')));
243
- const folder = currentWorkspace();
655
+ return currentWorkspace().path;
656
+ }
657
+ /** The line this session begins with, as the service wrote it (manual 2.2). */
658
+ async function projectLine() {
659
+ const { resolveProject, asWire } = await Promise.resolve().then(() => __importStar(require('../lib/project')));
660
+ const { ask } = await Promise.resolve().then(() => __importStar(require('../lib/gateway')));
661
+ const facts = resolveProject(await sessionFolder(), place.added);
662
+ const reply = await ask({ intent: 'project', prompt: '', project: asWire(facts, 'approve_writes') });
663
+ // Drawn as sent. When the service cannot answer, the folder alone, so the
664
+ // person still knows where they are.
665
+ return reply.ok && reply.project_line ? reply.project_line : ` ${facts.root}`;
666
+ }
667
+ /** `asked` is the same line already on its way, when startup asked early. */
668
+ async function showProjectLine(asked) {
669
+ out(ui.c.bold(await (asked ?? projectLine())));
244
670
  out();
245
- out(ui.c.dim(` working in ${folder.path}`));
671
+ }
672
+ /** Ctrl+O's choice, kept for the next job too, and notes left over from the
673
+ * last one (manual 6.8, 6.9). */
674
+ const view = { full: false, late: [] };
675
+ /** `context` and `compact [what to keep]`, answered and worded by the
676
+ * service for the session's latest job (manual 6.13, 6.15). */
677
+ async function contextOf(intent, jobId, keep = '', sessionKey) {
678
+ const { ask } = await Promise.resolve().then(() => __importStar(require('../lib/gateway')));
679
+ out();
680
+ if (intent === 'compact')
681
+ out(ui.c.dim(' summarizing the conversation so far to keep it affordable'));
682
+ // The session is named too, so a session with no job yet (questions asked in
683
+ // Work) gets the same figure the web meter shows (web manual 3.11).
684
+ const reply = await ask({
685
+ intent, prompt: keep, ...(jobId ? { jobId } : {}), ...(sessionKey ? { spendSession: sessionKey } : {}),
686
+ });
687
+ if (!reply.ok || !reply.context) {
688
+ out(ui.fail(reply.message || 'Your workspace could not answer that.'));
689
+ }
690
+ else {
691
+ for (const line of reply.context.lines)
692
+ out(line);
693
+ }
694
+ out();
695
+ }
696
+ /** One job's full record, as the service writes it (manual 6.9). */
697
+ async function showTranscript(jobId) {
698
+ out();
699
+ if (!jobId) {
700
+ out(ui.c.dim(' Nothing has run in this session yet, so there is no transcript.'));
701
+ out();
702
+ return;
703
+ }
704
+ const { ask } = await Promise.resolve().then(() => __importStar(require('../lib/gateway')));
705
+ const reply = await ask({ intent: 'transcript', prompt: '', jobId });
706
+ if (!reply.ok || !reply.transcript) {
707
+ out(ui.fail(reply.message || 'The transcript could not be read from your workspace.'));
708
+ }
709
+ else {
710
+ for (const line of reply.transcript.lines)
711
+ out(line);
712
+ }
713
+ out();
714
+ }
715
+ /**
716
+ * Open the request in the person's editor and put what they saved back at the
717
+ * prompt, unsent (manual 6.6). The file exists only while the editor is open,
718
+ * in a folder only this account can read, and is removed the moment it closes.
719
+ */
720
+ async function editRequest(current) {
721
+ const chosen = process.env.VISUAL || process.env.EDITOR || (0, machine_1.machineSettings)().editor;
722
+ const put = (text) => {
723
+ if (!reader)
724
+ return;
725
+ reader.setPrompt(`${ui.c.bold(ui.c.blue('comprism'))} ${ui.c.dim('>')} `);
726
+ reader.prompt();
727
+ reader.write((0, keyboard_1.asOneLine)(text));
728
+ };
729
+ if (!chosen) {
730
+ out();
731
+ out(ui.fail('No editor is set. Set VISUAL or EDITOR, or run: comprism config set editor <command>'));
732
+ put(current);
733
+ return;
734
+ }
735
+ const fs = await Promise.resolve().then(() => __importStar(require('node:fs')));
736
+ const os = await Promise.resolve().then(() => __importStar(require('node:os')));
737
+ const { spawnSync } = await Promise.resolve().then(() => __importStar(require('node:child_process')));
738
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'comprism-edit-'));
739
+ const file = path.join(dir, 'request.md');
740
+ fs.writeFileSync(file, current, { mode: 0o600 });
741
+ releaseTerminal();
742
+ let back = current;
743
+ try {
744
+ const [program, ...extra] = chosen.split(/\s+/).filter(Boolean);
745
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
746
+ spawnSync(program, [...extra, file], { stdio: 'inherit' });
747
+ const saved = fs.readFileSync(file, 'utf8');
748
+ // Closed without saving leaves the prompt exactly as it was.
749
+ if (saved !== current)
750
+ back = saved.replace(/\r?\n$/, '');
751
+ }
752
+ catch {
753
+ out(ui.fail(`${chosen} could not be opened, so the request is as it was.`));
754
+ }
755
+ finally {
756
+ fs.rmSync(dir, { recursive: true, force: true });
757
+ takeTerminal();
758
+ }
759
+ put(back);
760
+ }
761
+ async function build(session, request, confirm, onStarted, attachmentIds = [], leftOut = [], ask) {
762
+ const { runAgent } = await Promise.resolve().then(() => __importStar(require('../agent/command')));
246
763
  out();
247
764
  // Counted the moment it starts, not when it returns. A person who leaves
248
765
  // part-way through a job was told "nothing asked in that session" over work
249
766
  // that had already been paid for, which is the one number this product cannot
250
767
  // get wrong.
251
768
  session.jobs.push({ request });
769
+ const began = Date.now();
770
+ let busy = false;
252
771
  const code = await runAgent({
253
772
  request: [request],
254
- cwd: folder.path,
773
+ cwd: await sessionFolder(),
774
+ addFolders: place.added,
255
775
  // Carry on from the last question rather than starting again. `new` clears
256
776
  // it, which is what `new` is for.
257
777
  continuesJob: session.lastJobId,
778
+ // The session the person is actually in. Without it the server files every
779
+ // job of the day under one row, which is why `sessions` here listed three
780
+ // and the Sessions screen showed one.
781
+ conversationId: session.serverId,
782
+ // A server started by one question is still there for the next (5.13).
783
+ keepProcesses: true,
258
784
  // The session's own keyboard, handed to the agent.
259
785
  //
260
786
  // The agent has to put a change in front of the person and wait. It used to
@@ -264,24 +790,47 @@ async function build(session, request, confirm, onStarted, attachmentIds = []) {
264
790
  // than by the person. One reader, owned by whoever started the process,
265
791
  // passed down.
266
792
  confirm,
793
+ prompt: ask,
267
794
  onStarted,
795
+ fullView: view.full,
268
796
  // Files the person attached, already read and waiting on the server.
269
797
  attachments: attachmentIds,
798
+ // Files named that could not be attached, so the agent is told (3.10).
799
+ leftOut,
270
800
  // Ask before a change and before a command, because now the person can
271
801
  // actually answer. This was auto_edit only because approving a write was
272
802
  // impossible from inside a session; it is the mode the standalone command
273
803
  // uses, so the two behave the same way.
274
- mode: 'approve_writes',
804
+ mode: steering_.mode,
805
+ plan: steering_.plan,
806
+ // A pin and effort set with `pin` and `effort` are the session's, held by
807
+ // the service (manual 4.17, 4.18; web manual 4.5, 4.6), which applies them
808
+ // to this job. So a pin set in the browser applies here, and the reverse.
275
809
  // The running total is the whole point of a session, so it is added up
276
810
  // from what each job actually cost. It used to be declared and never
277
811
  // touched, so a session ended by reporting zero spent and zero saved
278
812
  // directly underneath a job that had just charged three cents.
279
813
  onFinished: (r) => {
814
+ // Busy in another place (manual 8.8): nothing ran, nothing was charged.
815
+ if (r.outcome === 'busy') {
816
+ busy = true;
817
+ return;
818
+ }
280
819
  // Recorded even when the job did not finish. A run that was stopped or
281
820
  // ran out of steps still said things, and the next question is usually
282
821
  // about exactly that.
283
822
  if (r.jobId)
284
823
  session.lastJobId = r.jobId;
824
+ // Which job answered which request, for `rewind` (manual 7.4 to 7.6).
825
+ const asked = session.jobs[session.jobs.length - 1];
826
+ if (asked && r.jobId && !asked.jobId)
827
+ asked.jobId = r.jobId;
828
+ session.todos = r.todos;
829
+ if (r.why && r.why.length)
830
+ session.why = r.why;
831
+ // Notes that arrived after the last step become requests of their own
832
+ // (manual 6.8); the service has already said so on screen.
833
+ view.late = r.lateNotes;
285
834
  session.spentUsd += r.totalUsd;
286
835
  if (r.savedUsd !== null) {
287
836
  session.savedUsd += r.savedUsd;
@@ -289,12 +838,108 @@ async function build(session, request, confirm, onStarted, attachmentIds = []) {
289
838
  }
290
839
  },
291
840
  });
841
+ if (busy) {
842
+ session.jobs.pop();
843
+ return whileBusy(session, request, confirm, onStarted, attachmentIds, leftOut, ask);
844
+ }
292
845
  out();
293
846
  if (code !== 0)
294
847
  out(ui.c.amber(' the job did not finish'));
848
+ // A job long enough to switch windows during is worth being told about
849
+ // (manual 6.11).
850
+ if (Date.now() - began > 30_000) {
851
+ const { notify } = await Promise.resolve().then(() => __importStar(require('../lib/notify')));
852
+ notify(code === 0 ? 'Your job finished.' : 'Your job stopped before finishing.', (l) => out(ui.c.dim(l)));
853
+ }
854
+ }
855
+ /**
856
+ * The session is busy in another place (manual 8.8). Offer to wait, or to read
857
+ * it here. Waiting asks the service until the session is free, then runs the
858
+ * request; reading prints the session's turns and runs nothing.
859
+ */
860
+ async function whileBusy(session, request, confirm, onStarted, attachmentIds, leftOut, ask) {
861
+ const { sessions } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
862
+ const said = ((await ask?.('w to wait for it, r to read it here: ')) ?? 'r').trim().toLowerCase();
863
+ if (said.startsWith('w')) {
864
+ out(ui.c.dim(' waiting for the session to be free...'));
865
+ for (let i = 0; i < 120; i++) {
866
+ await new Promise((r) => setTimeout(r, 3000));
867
+ const now = await sessions('lease', { key: session.serverId });
868
+ if (now.ok && now.sessions && !now.sessions.busy_in) {
869
+ return build(session, request, confirm, onStarted, attachmentIds, leftOut, ask);
870
+ }
871
+ }
872
+ out(ui.c.amber(' still busy after six minutes, so nothing was run.'));
873
+ return;
874
+ }
875
+ const read = await sessions('read', { key: session.serverId });
876
+ out();
877
+ for (const line of read.sessions?.lines ?? [read.message ?? ''])
878
+ out(line);
879
+ out(ui.c.dim(' Read only: nothing was run.'));
880
+ out();
881
+ }
882
+ /** What `remote` holds while it is on (manual 8.11). */
883
+ const remote = { timer: null, session: null, take: () => undefined, idle: () => false };
884
+ async function remoteOn(session) {
885
+ const { sessions, failure } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
886
+ const reply = await sessions('remote_on', { key: session.serverId });
887
+ if (!reply.ok || !reply.sessions) {
888
+ out(ui.fail(failure(reply)));
889
+ out();
890
+ return;
891
+ }
892
+ for (const line of reply.sessions.lines)
893
+ out(line);
894
+ out();
895
+ remote.session = session.serverId;
896
+ if (remote.timer)
897
+ clearInterval(remote.timer);
898
+ let asking = false;
899
+ remote.timer = setInterval(() => {
900
+ if (asking || !remote.idle() || !remote.session)
901
+ return;
902
+ asking = true;
903
+ void sessions('remote_poll', { key: remote.session }).then((got) => {
904
+ const body = got.sessions;
905
+ if (body?.off) {
906
+ stopRemoteTimer();
907
+ return;
908
+ }
909
+ for (const text of body?.requests ?? [])
910
+ remote.take(text);
911
+ }).finally(() => { asking = false; });
912
+ }, 2000);
913
+ remote.timer.unref();
914
+ }
915
+ function stopRemoteTimer() {
916
+ if (remote.timer)
917
+ clearInterval(remote.timer);
918
+ remote.timer = null;
919
+ remote.session = null;
920
+ }
921
+ async function remoteOff(session, quiet = false) {
922
+ const key = remote.session ?? session.serverId;
923
+ const was = remote.session;
924
+ stopRemoteTimer();
925
+ if (quiet && !was)
926
+ return;
927
+ const { sessions } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
928
+ const reply = await sessions('remote_off', { key });
929
+ if (!quiet) {
930
+ for (const line of reply.sessions?.lines ?? [' Remote was not on.'])
931
+ out(line);
932
+ out();
933
+ }
295
934
  }
296
935
  /** The running total, shown when a session ends or another is opened. */
297
936
  function summarize(s) {
937
+ // The session is over: where the company keeps no content, its checkpoints
938
+ // end with it (manual 7.3). The service decides; nothing is kept here.
939
+ if (s.jobs.length > 0) {
940
+ void Promise.resolve().then(() => __importStar(require('./review'))).then((m) => m.review('session_ended', { session_id: s.serverId }))
941
+ .catch(() => undefined);
942
+ }
298
943
  if (s.jobs.length === 0) {
299
944
  out(ui.c.dim(' nothing asked in that session'));
300
945
  return;
@@ -308,17 +953,55 @@ function summarize(s) {
308
953
  out(` ${ui.c.dim('session')} ${ui.c.bold(s.id)} ${s.jobs.length} job(s)`
309
954
  + ` ${money(s.spentUsd)} ${ui.c.dim('spent')}${saving}`);
310
955
  }
311
- async function runRepl() {
956
+ async function runRepl(how = {}) {
312
957
  // Resolved when the person leaves. The reader is closed and reopened around
313
958
  // every job, so waiting on it closing would end the session at the first one.
314
959
  let done = () => { };
315
960
  const finished = new Promise((resolve) => { done = resolve; });
316
961
  const conn = (0, connection_1.readConnection)();
317
962
  if (!conn?.url || !conn.workspaceKey) {
318
- await (0, welcome_1.showWelcome)();
963
+ await (0, welcome_1.showWelcome)(undefined, process.stdin.isTTY === true && !how.noSignIn);
964
+ // Signed out: sign in right here rather than naming another command to
965
+ // type. The last person is offered, so it is usually Enter and a password.
966
+ if (process.stdin.isTTY === true && !how.noSignIn) {
967
+ const { cmdLogin } = await Promise.resolve().then(() => __importStar(require('./login')));
968
+ await cmdLogin(['--into-session']);
969
+ const { closePrompt } = await Promise.resolve().then(() => __importStar(require('../lib/prompt')));
970
+ closePrompt();
971
+ if ((0, connection_1.readConnection)()?.workspaceKey)
972
+ return runRepl({ ...how, noSignIn: true, noMark: true });
973
+ }
319
974
  return;
320
975
  }
321
- await (0, welcome_1.showWelcome)();
976
+ // Everything the opening screen needs is asked for AT ONCE, then drawn in
977
+ // order as it arrives. Asked one after another it was five round trips end
978
+ // to end, about fifteen seconds on a laptop before the prompt appeared.
979
+ //
980
+ // And what the service said LAST time is drawn at once, so the prompt is
981
+ // ready in well under a second. The same questions are still asked; their
982
+ // answers are kept for next time, and a change that matters is said as soon
983
+ // as it arrives. A first start, or another workspace, waits as before.
984
+ const { readStart, saveStart } = await Promise.resolve().then(() => __importStar(require('../lib/startcache')));
985
+ const { session: whoAmI } = await Promise.resolve().then(() => __importStar(require('./welcome')));
986
+ const known = readStart(conn.url);
987
+ const here = await sessionFolder();
988
+ const meAsked = whoAmI().then((me) => { if (me)
989
+ saveStart(conn.url, { me }); return me; });
990
+ const stateAsked = (0, keys_1.keyState)().then((s) => {
991
+ if (s.reached)
992
+ saveStart(conn.url, { hasKey: s.hasKey });
993
+ return s;
994
+ });
995
+ const lineAsked = projectLine()
996
+ .then((l) => { saveStart(conn.url, { lines: { [here]: l } }); return l; })
997
+ .catch(() => '');
998
+ const listAsked = refreshList()
999
+ .then(() => { if (listed?.ok)
1000
+ saveStart(conn.url, { list: listed }); })
1001
+ .catch(() => undefined);
1002
+ if (known.list?.ok && !listed)
1003
+ listed = known.list;
1004
+ await (0, welcome_1.showWelcome)(known.me ? known.me : await meAsked, false, !how.noMark);
322
1005
  // A key, before anything else. With none there is nothing to bill and nothing
323
1006
  // to measure, so a prompt accepted here could only ever be refused later.
324
1007
  //
@@ -326,7 +1009,18 @@ async function runRepl() {
326
1009
  // could not ask your account" is the whole point: on 17 September a person
327
1010
  // whose workspace held four working keys was shown the first message and sent
328
1011
  // to a form, because an unanswered question was read as an answer of none.
329
- const state = await (0, keys_1.keyState)();
1012
+ const state = known.hasKey === true ? { reached: true, hasKey: true } : await stateAsked;
1013
+ if (known.hasKey === true) {
1014
+ // Drawn from the last answer; the fresh one says so if the key has gone.
1015
+ void stateAsked.then((fresh) => {
1016
+ if (fresh.reached && !fresh.hasKey) {
1017
+ out();
1018
+ out(ui.c.amber(' Your account no longer has a provider key stored, so requests will be refused.'));
1019
+ out(` ${ui.c.dim('run')} ${ui.c.bold('keys')} ${ui.c.dim('to add one')}`);
1020
+ reader?.prompt(true);
1021
+ }
1022
+ });
1023
+ }
330
1024
  if (!state.reached) {
331
1025
  out(ui.fail(`Your workspace could not be reached, so ${ui.c.bold('nothing is known')} about your keys.`));
332
1026
  out(` ${ui.c.dim(state.why)}`);
@@ -352,6 +1046,35 @@ async function runRepl() {
352
1046
  }
353
1047
  const history = [];
354
1048
  let session = newSession();
1049
+ /** Read, or change, this session's pin and effort on the service (manual
1050
+ * 4.17, 4.18). Says the service's refusal and returns null on one. */
1051
+ const steer = async (change) => {
1052
+ const { sessions, failure } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
1053
+ const changing = Object.keys(change).length > 0;
1054
+ const reply = await sessions(changing ? 'steer' : 'steering', { session_id: session.serverId, ...change });
1055
+ const said = reply.sessions;
1056
+ if (!reply.ok || !said || reply.granted === false) {
1057
+ out(ui.fail(failure(reply)));
1058
+ return null;
1059
+ }
1060
+ steering_.pin = said.pin ?? '';
1061
+ steering_.effort = said.effort === 'normal' ? '' : said.effort;
1062
+ return said;
1063
+ };
1064
+ if (how.latest || how.resume || how.pick) {
1065
+ const found = await opening(how);
1066
+ if (!found) {
1067
+ out();
1068
+ if (!process.exitCode)
1069
+ process.exitCode = 1;
1070
+ return;
1071
+ }
1072
+ session = found;
1073
+ }
1074
+ else {
1075
+ // The session's number arrives while the person is already typing.
1076
+ void numbered(session);
1077
+ }
355
1078
  history.push(session);
356
1079
  // Ctrl-C leaves, and says what the session cost on the way out. A product
357
1080
  // that vanishes without telling you what it spent is the thing this one
@@ -360,7 +1083,21 @@ async function runRepl() {
360
1083
  // answer, not the next request, so it is handed straight to whoever is
361
1084
  // waiting instead of joining the queue.
362
1085
  let answering = null;
1086
+ /** Rings or notifies when an approval has waited 30 seconds (6.11). */
1087
+ let waitingTimer = null;
1088
+ const startWaiting = () => {
1089
+ if (waitingTimer)
1090
+ clearTimeout(waitingTimer);
1091
+ waitingTimer = setTimeout(() => {
1092
+ void Promise.resolve().then(() => __importStar(require('../lib/notify'))).then(({ notify }) => notify('comprism is waiting for your answer.', (l) => out(ui.c.dim(l))));
1093
+ }, 30_000);
1094
+ waitingTimer.unref();
1095
+ };
363
1096
  const takeAnswer = (line) => {
1097
+ if (waitingTimer) {
1098
+ clearTimeout(waitingTimer);
1099
+ waitingTimer = null;
1100
+ }
364
1101
  if (!answering)
365
1102
  return false;
366
1103
  const waiting = answering;
@@ -381,7 +1118,20 @@ async function runRepl() {
381
1118
  resolve(false);
382
1119
  return;
383
1120
  }
384
- answering = (line) => resolve(/^y(es)?$/i.test(line.trim()));
1121
+ answering = (line) => resolve(/^y(es)?$/i.test((line ?? '').trim()));
1122
+ startWaiting();
1123
+ reader.setPrompt(` ${question} `);
1124
+ reader.prompt();
1125
+ });
1126
+ /** The same lent keyboard, for a line of text: the plan question and the
1127
+ * agent's questions (manual 4.2, 4.3). Null when the person left. */
1128
+ const askLine = (question) => new Promise((resolve) => {
1129
+ if (leaving || !reader) {
1130
+ resolve(null);
1131
+ return;
1132
+ }
1133
+ answering = resolve;
1134
+ startWaiting();
385
1135
  reader.setPrompt(` ${question} `);
386
1136
  reader.prompt();
387
1137
  });
@@ -393,7 +1143,7 @@ async function runRepl() {
393
1143
  resolve('');
394
1144
  return;
395
1145
  }
396
- answering = resolve;
1146
+ answering = (line) => resolve(line ?? '');
397
1147
  reader.setPrompt('');
398
1148
  reader.prompt();
399
1149
  });
@@ -411,6 +1161,52 @@ async function runRepl() {
411
1161
  * the person saw their sentence twice and could edit only the second copy.
412
1162
  */
413
1163
  const speak = async () => {
1164
+ // On a Mac, the words appear as they are spoken, the way the web app shows
1165
+ // them, from the Mac's own recogniser. Anywhere it cannot listen (no
1166
+ // helper, or permission not given) this falls through to recording and
1167
+ // having the server write it down when Enter is pressed.
1168
+ if (process.stdout.isTTY) {
1169
+ const live = voice.listenLive();
1170
+ if (live) {
1171
+ await new Promise((r) => setTimeout(r, 400));
1172
+ if (live.problem() && !live.words()) {
1173
+ await live.stop();
1174
+ out(ui.c.dim(` ${live.problem()}`));
1175
+ }
1176
+ else {
1177
+ out();
1178
+ out(ui.c.green(' listening...') + ui.c.dim(' say it, then press enter'));
1179
+ const draw = () => {
1180
+ const bars = Math.round(live.level() * 12);
1181
+ const meter = ui.c.green('█'.repeat(bars)) + ui.c.dim('░'.repeat(12 - bars));
1182
+ const room = Math.max(20, (process.stdout.columns || 100) - 20);
1183
+ const heard = live.words();
1184
+ const shown = heard.length > room ? `...${heard.slice(-(room - 3))}` : heard;
1185
+ process.stdout.write(`\r${CSI}2K ${meter} ${shown}`);
1186
+ };
1187
+ const ticking = setInterval(draw, 80);
1188
+ await nextLine();
1189
+ clearInterval(ticking);
1190
+ process.stdout.write(`${CSI}1A\r${CSI}2K`);
1191
+ const said = await live.stop();
1192
+ if (said === null) {
1193
+ out(ui.c.dim(` ${live.problem() || 'dictation stopped'}`));
1194
+ out();
1195
+ return prompt();
1196
+ }
1197
+ if (!said) {
1198
+ out(ui.c.dim(' no words in that one'));
1199
+ out();
1200
+ return prompt();
1201
+ }
1202
+ // Into the prompt, not sent: the person reads it, fixes what was
1203
+ // misheard, and presses Enter. Nothing is asked or charged until then.
1204
+ prompt();
1205
+ reader?.write(said);
1206
+ return;
1207
+ }
1208
+ }
1209
+ }
414
1210
  const short = voice.missing();
415
1211
  if (short) {
416
1212
  out();
@@ -429,11 +1225,53 @@ async function runRepl() {
429
1225
  }
430
1226
  out();
431
1227
  out(ui.c.green(' listening...') + ui.c.dim(' say it, then press enter'));
1228
+ // What it hears, while it hears it: a meter that moves with the voice at
1229
+ // once, and the words so far, refreshed every few seconds. Without these
1230
+ // a person talks into a blank line with no idea whether anything is
1231
+ // arriving. The words so far are a preview; the whole recording is
1232
+ // written down again when they press Enter, and that is what they get.
1233
+ const tty = process.stdout.isTTY === true;
1234
+ let heard = '';
1235
+ let asking = false;
1236
+ let lastAsked = 0;
1237
+ const draw = () => {
1238
+ const bars = Math.round(voice.level(live.file) * 12);
1239
+ const meter = ui.c.green('█'.repeat(bars)) + ui.c.dim('░'.repeat(12 - bars));
1240
+ const room = Math.max(20, (process.stdout.columns || 100) - 20);
1241
+ const words = heard.length > room ? `...${heard.slice(-(room - 3))}` : heard;
1242
+ process.stdout.write(`\r${CSI}2K ${meter} ${words}`);
1243
+ };
1244
+ const preview = async () => {
1245
+ if (asking || Date.now() - lastAsked < 2500)
1246
+ return;
1247
+ const part = voice.soFar(live.file);
1248
+ if (!part)
1249
+ return;
1250
+ asking = true;
1251
+ lastAsked = Date.now();
1252
+ try {
1253
+ const got = await voice.transcribe(conn.url, conn.workspaceKey, part);
1254
+ if (got.text)
1255
+ heard = got.text.trim();
1256
+ }
1257
+ catch { /* a preview that fails leaves the last one showing */ }
1258
+ asking = false;
1259
+ };
1260
+ const ticking = tty ? setInterval(() => { draw(); void preview(); }, 120) : null;
432
1261
  await nextLine();
1262
+ if (ticking)
1263
+ clearInterval(ticking);
1264
+ // The line the typed Enter left behind is the meter's; clear it.
1265
+ if (tty)
1266
+ process.stdout.write(`${CSI}1A\r${CSI}2K`);
433
1267
  const { file, bytes } = await live.stop();
434
1268
  try {
435
1269
  if (bytes < 2000) {
436
- out(ui.c.dim(' nothing was recorded'));
1270
+ // Nothing heard is usually the microphone permission, never asked for
1271
+ // or refused, so it is named (manual 6.16).
1272
+ out(ui.c.dim(process.platform === 'darwin'
1273
+ ? ' nothing was recorded. Your terminal may not have microphone permission: System Settings, Privacy and Security, Microphone.'
1274
+ : ' nothing was recorded. Check that a microphone is connected and not muted.'));
437
1275
  out();
438
1276
  return prompt();
439
1277
  }
@@ -462,31 +1300,94 @@ async function runRepl() {
462
1300
  voice.discard(file);
463
1301
  }
464
1302
  };
1303
+ /** Files attached and not yet sent: they go with the next request
1304
+ * (manual 3.6). `attached` lists them and `detach N` takes one off. */
1305
+ let pending = [];
1306
+ /** Pastes this session, so each is `clipboard-N.png` with its own N. */
1307
+ let pastes = 0;
1308
+ /**
1309
+ * The clipboard's picture, attached to the next request (manual 3.4). Saved
1310
+ * to a temporary file, uploaded like any file, and the file removed
1311
+ * whatever happened.
1312
+ */
1313
+ const pasteImage = async () => {
1314
+ const { readClipboardImage, discard } = await Promise.resolve().then(() => __importStar(require('../lib/clipboard')));
1315
+ const clip = await readClipboardImage(pastes + 1);
1316
+ out();
1317
+ if (!clip.ok) {
1318
+ out(ui.c.amber(` ${clip.why}`));
1319
+ out();
1320
+ return;
1321
+ }
1322
+ pastes += 1;
1323
+ try {
1324
+ const { attached, refused } = await attach.upload(conn.url, conn.workspaceKey, [clip.file]);
1325
+ for (const line of attach.describe(attached))
1326
+ out(ui.c.dim(` ${line}`));
1327
+ for (const r of refused)
1328
+ out(ui.c.amber(` ${r.why}`));
1329
+ pending = [...pending, ...attached];
1330
+ }
1331
+ finally {
1332
+ discard(clip.folder);
1333
+ }
1334
+ out();
1335
+ };
465
1336
  /**
466
1337
  * Files the person pointed at, read and ready, and the question without the
467
1338
  * long paths in it.
468
1339
  *
469
1340
  * Uploading happens before the job starts and is reported before anything is
470
1341
  * charged, because the commonest complaint about any assistant is that it
471
- * answered without looking at the thing you gave it.
1342
+ * answered without looking at the thing you gave it. A file with a problem
1343
+ * gets one line saying so and is never sent (manual 3.10).
1344
+ *
1345
+ * `send` is false when the line named files and said nothing else: that is
1346
+ * a drag and drop, and the files wait for the question that follows.
472
1347
  */
473
1348
  const gather = async (typed) => {
474
- const { currentWorkspace } = await Promise.resolve().then(() => __importStar(require('./settings')));
475
- const found = attach.mentions(typed, currentWorkspace().path);
1349
+ const { resolveProject } = await Promise.resolve().then(() => __importStar(require('../lib/project')));
1350
+ const here = await sessionFolder();
1351
+ const root = resolveProject(here, place.added).root;
1352
+ completion.root = root;
1353
+ const found = attach.mentions(typed, root, here);
1354
+ const waiting = () => pending.map((a) => a.id);
476
1355
  if (found.length === 0)
477
- return { text: typed, ids: [] };
1356
+ return { text: typed, ids: waiting(), leftOut: [], send: true };
478
1357
  out();
479
- out(ui.c.dim(` reading ${found.length} file(s)...`));
480
- const { attached, refused } = await attach.upload(conn.url, conn.workspaceKey, found.map((m) => m.file));
481
- for (const a of attached)
482
- out(ui.c.dim(` ${attach.describe(a)}`));
1358
+ const problems = found.filter((m) => m.problem);
1359
+ const readable = found.filter((m) => !m.problem);
1360
+ const { attached, refused } = readable.length > 0
1361
+ ? await attach.upload(conn.url, conn.workspaceKey, readable.map((m) => m.file))
1362
+ : { attached: [], refused: [] };
1363
+ for (const line of attach.describe(attached))
1364
+ out(ui.c.dim(` ${line}`));
1365
+ for (const m of problems)
1366
+ out(ui.c.amber(` ${m.problem?.message}`));
483
1367
  for (const r of refused)
484
- out(ui.c.amber(` ${r.file} was not read: ${r.why}`));
485
- // Only the files that actually arrived come out of the sentence. One that
486
- // was refused stays written where the person put it, so the question still
487
- // says what it was about rather than silently losing it.
488
- const landed = found.filter((m) => !refused.some((r) => r.file === m.file));
489
- return { text: attach.withoutPaths(typed, landed), ids: attached.map((a) => a.id) };
1368
+ out(ui.c.amber(` ${r.why}`));
1369
+ const failed = [...problems.map((m) => m.file), ...refused.map((r) => r.file)];
1370
+ const leftOut = failed.map((f) => path.basename(f));
1371
+ const landed = found.filter((m) => !failed.includes(m.file));
1372
+ const words = attach.withoutMentions(typed, found);
1373
+ pending = [...pending, ...attached];
1374
+ if (!words) {
1375
+ if (pending.length > 0)
1376
+ out(ui.c.dim(' Waiting for your question. `attached` lists what will go with it.'));
1377
+ out();
1378
+ return { text: '', ids: [], leftOut, send: false };
1379
+ }
1380
+ // Every file failed and the request was about them: nothing is sent until
1381
+ // the person says so (manual 3.10).
1382
+ if (attached.length === 0 && pending.length === 0) {
1383
+ const go = await confirm('None of those files could be attached. Send the request anyway? (y/n)');
1384
+ if (!go) {
1385
+ out(ui.c.dim(' Nothing was sent.'));
1386
+ out();
1387
+ return { text: '', ids: [], leftOut, send: false };
1388
+ }
1389
+ }
1390
+ return { text: attach.withoutPaths(typed, landed), ids: waiting(), leftOut, send: true };
490
1391
  };
491
1392
  // A way to stop the job that is running, while it is running. Null between
492
1393
  // jobs, which is also the answer to "is anything running".
@@ -497,6 +1398,60 @@ async function runRepl() {
497
1398
  /** Set while a job is running, so a line typed mid job reaches it instead of
498
1399
  * queueing up behind it as the next question. */
499
1400
  let steerJob = null;
1401
+ /** Everything else a running job can be asked: pause, carry on, the queue,
1402
+ * the full view (manual 6.7 to 6.9). Null between jobs. */
1403
+ let job = null;
1404
+ // Esc pauses a running job; at the prompt it does nothing (6.7).
1405
+ keyboard.onEscape = () => {
1406
+ if (!job || job.isPaused())
1407
+ return false;
1408
+ job.pause();
1409
+ return true;
1410
+ };
1411
+ // Ctrl+O, the short or the full view, from now on (6.9).
1412
+ keyboard.onFullView = () => {
1413
+ view.full = !view.full;
1414
+ job?.setFull(view.full);
1415
+ out();
1416
+ out(ui.c.dim(view.full ? ' full view: each action\'s input and output in full' : ' short view'));
1417
+ if (!job)
1418
+ prompt();
1419
+ };
1420
+ // Ctrl+G: the request so far, in your own editor (6.6). At the prompt only.
1421
+ keyboard.onEditor = () => {
1422
+ if (job || working)
1423
+ return;
1424
+ const edit = reader;
1425
+ const now = [...draft, edit?.line ?? ''].join('\n');
1426
+ draft = [];
1427
+ void editRequest(now);
1428
+ };
1429
+ // Ctrl+V: the clipboard's picture, as `paste` (3.4). At the prompt only;
1430
+ // what is typed so far stays on the line.
1431
+ keyboard.onPasteImage = () => {
1432
+ if (job || working)
1433
+ return;
1434
+ void pasteImage().then(() => prompt());
1435
+ };
1436
+ keyboard.vi = (0, machine_1.machineSettings)().keys === 'vi';
1437
+ // Ctrl+C: the job, then the line, then the session (6.7).
1438
+ interrupt = () => {
1439
+ if (job) {
1440
+ // An approval on screen is answered no, a question as stopped.
1441
+ takeAnswer(null);
1442
+ job.cancel();
1443
+ return;
1444
+ }
1445
+ const edit = reader;
1446
+ if (draft.length > 0 || (edit && edit.line)) {
1447
+ draft = [];
1448
+ if (edit)
1449
+ edit.line = '';
1450
+ out();
1451
+ return prompt();
1452
+ }
1453
+ leave();
1454
+ };
500
1455
  let leaving = false;
501
1456
  /**
502
1457
  * Ctrl-C, or `exit`.
@@ -515,13 +1470,21 @@ async function runRepl() {
515
1470
  if (leaving)
516
1471
  return;
517
1472
  leaving = true;
518
- takeAnswer('n');
1473
+ // The portal link ends with the session (manual 8.11).
1474
+ if (remote.session)
1475
+ void remoteOff(session, true);
1476
+ // A question on screen is answered as stopped, not as a word (4.3).
1477
+ takeAnswer(null);
519
1478
  cancelJob?.();
520
1479
  const close = () => {
521
1480
  out();
1481
+ const said = (0, render_1.stoppedLines)((0, shell_1.stopEverything)());
1482
+ if (said)
1483
+ process.stdout.write(said);
522
1484
  summarize(session);
523
1485
  out();
524
1486
  releaseTerminal();
1487
+ earlier.finish();
525
1488
  done();
526
1489
  };
527
1490
  if (!working)
@@ -541,7 +1504,20 @@ async function runRepl() {
541
1504
  reader.setPrompt(`${ui.c.bold(ui.c.blue('comprism'))} ${ui.c.dim('>')} `);
542
1505
  reader.prompt();
543
1506
  };
544
- out(ui.c.dim(' Type what you want done, or `help`. Ctrl-C to leave.'));
1507
+ const lastLine = known.lines?.[here];
1508
+ await showProjectLine(lastLine ? Promise.resolve(lastLine) : lineAsked);
1509
+ // The command and skill list, and whether this company lets typed requests
1510
+ // be kept on this machine (manual 6.4, 6.5). Unknown keeps them for this
1511
+ // session only and leaves any file alone. The last list serves until the
1512
+ // fresh one lands.
1513
+ if (!known.list?.ok)
1514
+ await listAsked;
1515
+ // `config set history off` keeps nothing at all, not even for this session.
1516
+ const historyOff = (0, machine_1.machineSettings)().history === 'off';
1517
+ earlier.start(historyOff ? false : listed?.ok ? listed.keepHistory : null);
1518
+ if (historyOff)
1519
+ earlier.disable();
1520
+ out(ui.c.dim(' Type what you want done. / lists commands and skills, voice dictates, Ctrl+C leaves.'));
545
1521
  out();
546
1522
  // One line at a time, in the order they arrived.
547
1523
  //
@@ -563,11 +1539,41 @@ async function runRepl() {
563
1539
  }
564
1540
  working = false;
565
1541
  };
566
- onLine = (line) => {
1542
+ // Requests sent from the portal while `remote` is on (manual 8.11). This
1543
+ // terminal asks the service for them every two seconds between jobs, which
1544
+ // is also how the service knows the session is still running here; they are
1545
+ // run here, on this machine, exactly as if typed, approvals included.
1546
+ remote.take = (text) => {
1547
+ out(`\n ${ui.c.bold('from the portal:')} ${text}`);
1548
+ queue.push(text);
1549
+ void drain();
1550
+ };
1551
+ remote.idle = () => !job && !working && !leaving;
1552
+ /** The lines of a request continued with `\\` or Alt+Enter, so far. */
1553
+ let draft = [];
1554
+ onLine = (typedLine) => {
1555
+ // Up and Down start again from the newest request after every line.
1556
+ if (reader)
1557
+ reader.historyIndex = -1;
1558
+ const continued = keyboard.takeContinued();
567
1559
  // An answer the agent is waiting for jumps the queue, because it is not a
568
1560
  // request. Queued, it would sit behind the very job that is waiting on it.
569
- if (takeAnswer(line))
1561
+ if (draft.length === 0 && takeAnswer(keyboard.expand(typedLine)))
1562
+ return;
1563
+ // A request on more than one line (manual 6.2). Held until a line ends
1564
+ // without a continuation, then sent once with its line breaks.
1565
+ if (continued || typedLine.endsWith('\\')) {
1566
+ draft.push(continued ? typedLine : typedLine.slice(0, -1));
1567
+ reader?.setPrompt(ui.c.dim(' ... '));
1568
+ reader?.prompt();
570
1569
  return;
1570
+ }
1571
+ const shown = [...draft, typedLine].join('\n');
1572
+ draft = [];
1573
+ // Paste labels swapped back for what was pasted (6.3).
1574
+ const line = keyboard.expand(shown);
1575
+ if (line.trim())
1576
+ earlier.add(shown, line);
571
1577
  // Something said while a job is RUNNING goes to that job, not into the
572
1578
  // queue behind it. A person who sees the agent going the wrong way at step
573
1579
  // three had two options before this: stop it and lose everything, or watch
@@ -575,6 +1581,38 @@ async function runRepl() {
575
1581
  // conversation before the next step, which is the earliest moment it can
576
1582
  // change anything.
577
1583
  const said = line.trim();
1584
+ // Paused with Esc: whatever comes next, even an empty Enter, is the answer
1585
+ // to "carry on how" (manual 6.7).
1586
+ if (job?.isPaused()) {
1587
+ job.carryOn(said);
1588
+ return;
1589
+ }
1590
+ // `queue`, `queue clear` and `transcript` while it runs are for the
1591
+ // session, not notes for the agent (6.8, 6.9).
1592
+ if (job && /^\/?queue(\s+clear)?$/i.test(said)) {
1593
+ job.queue(/clear$/i.test(said));
1594
+ return;
1595
+ }
1596
+ if (job && /^\/?transcript$/i.test(said)) {
1597
+ void showTranscript(job.jobId());
1598
+ return;
1599
+ }
1600
+ if (job && processWord(said))
1601
+ return;
1602
+ // `agents` and `stop h2` while helpers run (manual 5.25).
1603
+ if (job && /^\/?agents$/i.test(said)) {
1604
+ const running = job.helpers();
1605
+ if (!running.length)
1606
+ out(ui.c.dim(' No helpers are running.'));
1607
+ for (const h of running)
1608
+ out(` ${ui.c.bold(h.id)} ${h.agent ? `${h.agent}: ` : ''}${h.question}`);
1609
+ return;
1610
+ }
1611
+ const helperStop = /^\/?stop\s+(h\d+)$/i.exec(said);
1612
+ if (job && helperStop) {
1613
+ job.stopHelper(helperStop[1].toLowerCase());
1614
+ return;
1615
+ }
578
1616
  if (steerJob && said) {
579
1617
  // The session says nothing back. `steer` already prints its own
580
1618
  // acknowledgement, and two lines for one thing typed reads as a stutter.
@@ -588,13 +1626,355 @@ async function runRepl() {
588
1626
  // existed printed nothing, and the session looked dead on arrival.
589
1627
  takeTerminal();
590
1628
  prompt();
1629
+ // Shift+Tab moves to the next permission mode (manual 4.5). Only at the
1630
+ // prompt: during a job the change would apply from the next job anyway, and
1631
+ // redrawing over a running job's output is unreadable.
1632
+ // The live list of commands and skills, redrawn after each key at the prompt,
1633
+ // once the reader has applied the key to the line.
1634
+ keys().on('keypress', (_s, key) => {
1635
+ if (working || answering || leaving || key?.name === 'return' || key?.name === 'tab')
1636
+ return;
1637
+ setImmediate(updatePopup);
1638
+ });
1639
+ keys().on('keypress', (_s, key) => {
1640
+ if (!key || key.name !== 'tab' || !key.shift || working || answering || leaving)
1641
+ return;
1642
+ const next = MODES[(MODES.indexOf(steering_.mode) + 1) % MODES.length];
1643
+ steering_.mode = next;
1644
+ out();
1645
+ out(ui.c.dim(` mode ${ui.c.bold(next)}, from the next job`));
1646
+ prompt();
1647
+ });
591
1648
  async function handle(line) {
592
1649
  const typed = line.trim();
593
1650
  if (!typed)
594
1651
  return prompt();
595
- const { word, slashed } = commandWord(typed);
1652
+ const { word, slashed: slashWord } = commandWord(typed);
1653
+ // A dragged file starting with `/` is a path, not a slashed command
1654
+ // (manual 3.3). Only when the whole first word is something on the disk.
1655
+ const slashed = slashWord && !attach.startsWithPath(typed);
1656
+ // Where the session works (manual 2.1 to 2.3). Read off the typed line, not
1657
+ // `word`, because a path keeps its case.
1658
+ const bare = typed.replace(/^\//, '').replace(/^comprism\s+/i, '').trim();
1659
+ // The instructions in force, and a note for this project (manual 2.8, 2.9).
1660
+ if (/^instructions$/i.test(bare)) {
1661
+ const { cmdInstructions } = await Promise.resolve().then(() => __importStar(require('./instructions')));
1662
+ await withTerminal(async () => { await cmdInstructions([]); });
1663
+ return prompt();
1664
+ }
1665
+ const remember = /^remember\s+(.+)$/i.exec(bare);
1666
+ if (remember) {
1667
+ const { addNote } = await Promise.resolve().then(() => __importStar(require('./memory')));
1668
+ await addNote(remember[1]);
1669
+ out();
1670
+ return prompt();
1671
+ }
1672
+ const where = /^(project|workspace)(?:\s+(.+))?$/i.exec(bare);
1673
+ const addFolder = /^folders\s+add\s+(.+)$/i.exec(bare);
1674
+ if (where || addFolder || /^folders$/i.test(bare)) {
1675
+ const { checkFolder, resolveProject } = await Promise.resolve().then(() => __importStar(require('../lib/project')));
1676
+ const named = where ? where[2] : addFolder ? addFolder[1] : undefined;
1677
+ if (named) {
1678
+ const found = checkFolder(named.trim());
1679
+ if (!found.ok) {
1680
+ out(ui.fail(found.why));
1681
+ out();
1682
+ return prompt();
1683
+ }
1684
+ if (where) {
1685
+ place.folder = found.path;
1686
+ (await Promise.resolve().then(() => __importStar(require('../lib/project-ops')))).useSessionFolder(found.path);
1687
+ }
1688
+ else if (!place.added.includes(found.path))
1689
+ place.added.push(found.path);
1690
+ }
1691
+ if (/^folders$/i.test(bare)) {
1692
+ const facts = resolveProject(await sessionFolder(), place.added);
1693
+ out();
1694
+ out(` ${ui.c.bold(facts.root)} ${ui.c.dim('project')}`);
1695
+ for (const f of facts.added)
1696
+ out(` ${ui.c.bold(f)} ${ui.c.dim('added for this session')}`);
1697
+ out();
1698
+ return prompt();
1699
+ }
1700
+ out();
1701
+ await showProjectLine();
1702
+ // Another folder has other skills.
1703
+ await refreshList();
1704
+ return prompt();
1705
+ }
1706
+ // `pin <model>` and `effort <level>` (manual 4.17, 4.18), kept by the
1707
+ // service for this session, so the browser shows the same pin.
1708
+ const pinTo = /^pin\s+(\S+)$/.exec(word);
1709
+ if (pinTo) {
1710
+ const said = await steer({ pin: pinTo[1] });
1711
+ if (said)
1712
+ out(ui.c.dim(` pinned ${ui.c.bold(said.pin_label ?? steering_.pin)} for the next requests. \`unpin\` hands the choice back.`));
1713
+ out();
1714
+ return prompt();
1715
+ }
1716
+ // `optimize on` and `optimize off <model>` (manual 4.17): the same switch
1717
+ // as beside the model in the browser and VS Code. Off is the session pin.
1718
+ const optimizeTo = /^optimize\s+(on|off)(?:\s+(\S+))?$/i.exec(word);
1719
+ if (optimizeTo) {
1720
+ const on = optimizeTo[1].toLowerCase() === 'on';
1721
+ const hold = optimizeTo[2] ?? steering_.pin;
1722
+ if (on) {
1723
+ if (await steer({ pin: '' }))
1724
+ out(ui.c.dim(' Optimize is on: CompletionPrism picks the model for each step'));
1725
+ }
1726
+ else if (!hold) {
1727
+ out(ui.fail('Name the model to keep, such as `optimize off sonnet`.'));
1728
+ }
1729
+ else {
1730
+ const said = await steer({ pin: hold });
1731
+ if (said)
1732
+ out(ui.c.dim(` Optimize is off: ${ui.c.bold(said.pin_label ?? hold)} answers every request. Work is not optimized. \`optimize on\` hands the choice back.`));
1733
+ }
1734
+ out();
1735
+ return prompt();
1736
+ }
1737
+ const effortTo = /^effort\s+(\S+)$/.exec(word);
1738
+ if (effortTo) {
1739
+ const level = effortTo[1];
1740
+ if (!['low', 'medium', 'normal', 'high'].includes(level))
1741
+ out(ui.fail('Effort is low, normal or high.'));
1742
+ else {
1743
+ const said = await steer({ effort: level === 'medium' ? 'normal' : level });
1744
+ if (said) {
1745
+ out(ui.c.dim(` effort ${ui.c.bold(said.effort)} for the next requests`));
1746
+ if (said.effort_note)
1747
+ out(ui.c.dim(` ${said.effort_note}`));
1748
+ }
1749
+ }
1750
+ out();
1751
+ return prompt();
1752
+ }
1753
+ // `mode <name>` and `plan off` (manual 4.1, 4.5).
1754
+ const modeTo = /^mode\s+(\S+)$/.exec(word);
1755
+ if (modeTo) {
1756
+ const named = modeTo[1];
1757
+ if (!MODES.includes(named)) {
1758
+ out(ui.fail(`${named} is not a permission mode. One of: ${MODES.join(', ')}`));
1759
+ }
1760
+ else {
1761
+ steering_.mode = named;
1762
+ out(ui.c.dim(` mode ${ui.c.bold(named)}, from the next job`));
1763
+ }
1764
+ out();
1765
+ return prompt();
1766
+ }
1767
+ if (word === 'plan off') {
1768
+ steering_.plan = false;
1769
+ out(ui.c.dim(' planning off: the next requests are carried out'));
1770
+ out();
1771
+ return prompt();
1772
+ }
1773
+ if (processWord(word))
1774
+ return prompt();
1775
+ if (/^\/?agents$/i.test(word)) {
1776
+ await showHelpers();
1777
+ return prompt();
1778
+ }
1779
+ if (/^\/?stop\s+h\d+$/i.test(word)) {
1780
+ out(ui.c.dim(' No job is running, so there are no helpers to stop.'));
1781
+ out();
1782
+ return prompt();
1783
+ }
1784
+ // `output 3`: a command's whole output, kept on this machine (5.12).
1785
+ const outputOf = /^output(?:\s+(\d+))?$/.exec(word);
1786
+ if (outputOf) {
1787
+ const { keptOutput } = await Promise.resolve().then(() => __importStar(require('../executor/shell')));
1788
+ const kept = outputOf[1] ? keptOutput(Number(outputOf[1])) : undefined;
1789
+ if (!outputOf[1])
1790
+ out(ui.c.dim(' output N shows the whole output of command N, the number on its line.'));
1791
+ else if (!kept)
1792
+ out(ui.fail(`No output ${outputOf[1]} is kept in this session.`));
1793
+ else {
1794
+ out(ui.c.dim(` output ${outputOf[1]}: ${kept.command}`));
1795
+ out(kept.text.replace(/\n$/, '') || ui.c.dim(' (no output)'));
1796
+ }
1797
+ out();
1798
+ return prompt();
1799
+ }
1800
+ // `detach 2` takes the second waiting file off the list (manual 3.6).
1801
+ const detach = /^detach\s+(\d+)$/.exec(word);
1802
+ if (detach) {
1803
+ const n = Number(detach[1]);
1804
+ const gone = pending[n - 1];
1805
+ if (!gone) {
1806
+ out(ui.fail(pending.length === 0
1807
+ ? 'Nothing is waiting to be attached.'
1808
+ : `There is no attachment ${n}. Type \`attached\` for the list.`));
1809
+ }
1810
+ else {
1811
+ pending = pending.filter((_, i) => i !== n - 1);
1812
+ out(ui.c.dim(` detached ${gone.filename}`));
1813
+ }
1814
+ out();
1815
+ return prompt();
1816
+ }
1817
+ // `changes`, `diff` and `rewind`, with what follows them (manual 7.1 to 7.6).
1818
+ const reviewWord = /^(changes|diff|rewind)(?:\s+(.*))?$/.exec(word);
1819
+ if (reviewWord) {
1820
+ const w = reviewWord[1];
1821
+ const rest = reviewWord[2] ?? '';
1822
+ // What the agent changed in this session, its diff, and putting it
1823
+ // back, files and conversation (manual 7.1 to 7.6).
1824
+ if (w === 'changes' || w === 'diff') {
1825
+ const done = session.jobs.filter((j) => j.jobId);
1826
+ if (!done.length) {
1827
+ out(ui.c.dim(' Nothing has run in this session yet.'));
1828
+ out();
1829
+ return prompt();
1830
+ }
1831
+ const { cmdChanges, cmdDiff } = await Promise.resolve().then(() => __importStar(require('./review')));
1832
+ for (const j of done) {
1833
+ if (w === 'changes')
1834
+ await cmdChanges([j.jobId]);
1835
+ else
1836
+ await cmdDiff([j.jobId, ...rest.split(/\s+/).filter(Boolean)]);
1837
+ }
1838
+ return prompt();
1839
+ }
1840
+ if (w === 'rewind') {
1841
+ const done = session.jobs.filter((j) => j.jobId);
1842
+ if (!done.length) {
1843
+ out(ui.c.dim(' Nothing has run in this session yet, so there is nothing to rewind.'));
1844
+ out();
1845
+ return prompt();
1846
+ }
1847
+ const conversation = /--conversation|--both/.test(rest);
1848
+ const files = !/--conversation/.test(rest) || /--both/.test(rest);
1849
+ out();
1850
+ out(` ${conversation ? 'Your requests in this session' : 'The steps of this session'}:`);
1851
+ done.forEach((j, i) => out(` ${i + 1} ${j.request.slice(0, 70)}`));
1852
+ const picked = await askLine(` Go back to before which request? [1 to ${done.length}, Enter for ${done.length}] `);
1853
+ if (picked === null) {
1854
+ out();
1855
+ return prompt();
1856
+ }
1857
+ const n = picked.trim() ? Number(picked.trim()) : done.length;
1858
+ if (!Number.isInteger(n) || n < 1 || n > done.length) {
1859
+ out(ui.fail(`Choose a number from 1 to ${done.length}.`));
1860
+ out();
1861
+ return prompt();
1862
+ }
1863
+ if (files) {
1864
+ // The files of every job from that request on, latest first.
1865
+ const { cmdRewind } = await Promise.resolve().then(() => __importStar(require('./review')));
1866
+ const yesNo = async (q) => /^y(es)?$/i.test(((await askLine(q)) ?? '').trim());
1867
+ for (const j of done.slice(n - 1).reverse())
1868
+ await cmdRewind([j.jobId], yesNo);
1869
+ }
1870
+ if (conversation) {
1871
+ const { sessions: ask8, failure } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
1872
+ const before = done[n - 2]?.jobId ?? '';
1873
+ const reply = await ask8('branch', { key: session.serverId, at_job: before });
1874
+ if (!reply.ok || !reply.sessions?.session_id) {
1875
+ out(ui.fail(failure(reply)));
1876
+ out();
1877
+ return prompt();
1878
+ }
1879
+ summarize(session);
1880
+ const kept = done.slice(0, n - 1);
1881
+ session = reopened(reply.sessions);
1882
+ session.jobs = kept.map((j) => ({ ...j }));
1883
+ history.push(session);
1884
+ out(ui.ok(`The conversation is back to before request ${n}, as a new branch.`));
1885
+ for (const line of reply.sessions.lines)
1886
+ out(line);
1887
+ out(ui.c.dim(' The original, with everything it cost, is kept and can be reopened.'));
1888
+ out();
1889
+ }
1890
+ return prompt();
1891
+ }
1892
+ return prompt();
1893
+ }
596
1894
  if (COMMANDS.has(word)) {
597
1895
  switch (word) {
1896
+ case 'plan':
1897
+ steering_.plan = true;
1898
+ out(ui.c.dim(' planning on: the next requests are planned first and change nothing. `plan off` stops.'));
1899
+ out();
1900
+ break;
1901
+ case 'mode':
1902
+ out(` ${ui.c.dim('mode')} ${ui.c.bold(steering_.mode)}${steering_.plan ? ui.c.dim(', planning first') : ''}`);
1903
+ out(ui.c.dim(` one of ${MODES.join(', ')}. \`mode <name>\` or Shift+Tab changes it.`));
1904
+ out();
1905
+ break;
1906
+ case 'pin': {
1907
+ const said = await steer({});
1908
+ out(said?.pin
1909
+ ? ` ${ui.c.dim('pinned')} ${ui.c.bold(said.pin_label ?? said.pin)}`
1910
+ : ui.c.dim(' Nothing is pinned; the service chooses the model for each step. `pin <model>` pins one.'));
1911
+ out();
1912
+ break;
1913
+ }
1914
+ case 'optimize': {
1915
+ const said = await steer({});
1916
+ out(said?.pin
1917
+ ? ` ${ui.c.dim('Optimize is off:')} ${ui.c.bold(said.pin_label ?? said.pin)} ${ui.c.dim('answers every request. `optimize on` turns it back on.')}`
1918
+ : ui.c.dim(' Optimize is on: CompletionPrism picks the model for each step. `optimize off <model>` keeps one.'));
1919
+ out();
1920
+ break;
1921
+ }
1922
+ case 'unpin':
1923
+ if (await steer({ pin: '' }))
1924
+ out(ui.c.dim(' unpinned: the service chooses the model for each step again'));
1925
+ out();
1926
+ break;
1927
+ case 'effort': {
1928
+ const said = await steer({});
1929
+ out(ui.c.dim(said ? ` effort ${said.effort}` : ' effort not known.'));
1930
+ if (said?.effort_note)
1931
+ out(ui.c.dim(` ${said.effort_note}`));
1932
+ out();
1933
+ break;
1934
+ }
1935
+ case 'why':
1936
+ if (!session.why || session.why.length === 0)
1937
+ out(ui.c.dim(' No step has run in this session yet.'));
1938
+ else
1939
+ for (const line of session.why)
1940
+ out(line);
1941
+ out();
1942
+ break;
1943
+ case 'todo':
1944
+ if (!session.todos || session.todos.length === 0)
1945
+ out(ui.c.dim(' No checklist yet.'));
1946
+ else
1947
+ out((0, render_1.todos)(session.todos).trimEnd());
1948
+ out();
1949
+ break;
1950
+ case 'edit':
1951
+ // Returns without the redraw below: the editor puts the text back at
1952
+ // the prompt itself.
1953
+ return editRequest('');
1954
+ case 'context':
1955
+ await contextOf('context', session.lastJobId, '', session.serverId || session.id);
1956
+ break;
1957
+ case 'compact':
1958
+ await contextOf('compact', session.lastJobId);
1959
+ break;
1960
+ case 'queue':
1961
+ // Between jobs nothing can be waiting: a note typed now is a request.
1962
+ out(ui.c.dim(' No notes are waiting. Notes typed while a job runs join it at its next step.'));
1963
+ out();
1964
+ break;
1965
+ case 'transcript':
1966
+ await showTranscript(session.lastJobId);
1967
+ break;
1968
+ case 'paste':
1969
+ await pasteImage();
1970
+ break;
1971
+ case 'attached':
1972
+ if (pending.length === 0)
1973
+ out(ui.c.dim(' Nothing is waiting to be attached.'));
1974
+ for (const row of attach.listing(pending))
1975
+ out(` ${row}`);
1976
+ out();
1977
+ break;
598
1978
  case 'exit':
599
1979
  case 'quit':
600
1980
  return leave();
@@ -602,34 +1982,102 @@ async function runRepl() {
602
1982
  process.stdout.write('\x1b[2J\x1b[H');
603
1983
  break;
604
1984
  case 'help':
1985
+ // Asked again, so a skill saved a minute ago is on the list.
1986
+ await refreshList();
605
1987
  printHelp();
606
1988
  break;
1989
+ // A new session from a copy of this one's conversation (manual 8.6).
1990
+ case 'branch': {
1991
+ if (session.jobs.length === 0) {
1992
+ out(ui.c.dim(' Nothing has run in this session yet, so there is nothing to branch.'));
1993
+ out();
1994
+ break;
1995
+ }
1996
+ const { sessions: ask8, failure } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
1997
+ const reply = await ask8('branch', { key: session.serverId });
1998
+ if (!reply.ok || !reply.sessions?.session_id) {
1999
+ out(ui.fail(failure(reply)));
2000
+ out();
2001
+ break;
2002
+ }
2003
+ summarize(session);
2004
+ session = reopened(reply.sessions);
2005
+ history.push(session);
2006
+ for (const line of reply.sessions.lines)
2007
+ out(line);
2008
+ out();
2009
+ break;
2010
+ }
2011
+ case 'remote': {
2012
+ await remoteOn(session);
2013
+ break;
2014
+ }
607
2015
  case 'new': {
2016
+ await remoteOff(session, true);
608
2017
  summarize(session);
609
2018
  session = newSession();
610
2019
  history.push(session);
611
- out(ui.ok(`New session ${ui.c.bold(session.id)}.`));
2020
+ const line = await numbered(session);
2021
+ out(line ? ui.c.bold(line) : ui.ok('New session.'));
612
2022
  out();
613
2023
  break;
614
2024
  }
615
2025
  case 'sessions': {
616
2026
  out();
2027
+ // What this account did before this window was opened, from the
2028
+ // service's record of sessions (manual 8.5). Kept numbered
2029
+ // separately so a number never means two different things.
2030
+ // Every session the service keeps a record of for this person, up to
2031
+ // its limit, when they can be moved through and picked by number; a
2032
+ // typed list stays short enough to read.
2033
+ const onTerminal = process.stdin.isTTY === true && process.stdout.isTTY === true;
2034
+ recent = await earlierSessions(history, onTerminal ? 200 : 10);
2035
+ // On a terminal, the list is picked from with the arrow keys; the
2036
+ // typed forms below still work everywhere, and in a pipe are the
2037
+ // only way.
2038
+ if (onTerminal && history.length + recent.length > 1) {
2039
+ // Names, dates and cost only. The ids are for typing a session
2040
+ // back, and nobody types in a list they move through with arrows.
2041
+ const noId = (line) => line.trimStart().replace(/^(?:srv|s)_[0-9a-f]+\s+/i, '');
2042
+ const rows = [
2043
+ ...history.map((s) => [
2044
+ `${s.title ?? 'this session'}${s === session ? ' (now)' : ''}`,
2045
+ `this window \u00b7 ${s.jobs.length} job(s) \u00b7 ${money(s.spentUsd)}`,
2046
+ ]),
2047
+ // The service's two lines where it sent them; its one line otherwise.
2048
+ ...recent.map((r) => (r.card?.length ? r.card : [noId(r.line)])),
2049
+ ];
2050
+ const { pick } = await Promise.resolve().then(() => __importStar(require('../lib/picker')));
2051
+ let choice = null;
2052
+ out(ui.c.dim(' choose a session:'));
2053
+ await withTerminal(async () => {
2054
+ choice = await pick(rows, 'Up and Down to choose, or type its number, Enter to open, Esc to stay here', history.indexOf(session), true);
2055
+ });
2056
+ out();
2057
+ if (choice === null)
2058
+ return prompt();
2059
+ const n = choice;
2060
+ if (n < history.length) {
2061
+ if (history[n] === session)
2062
+ return prompt();
2063
+ return handle(`sessions ${n + 1}`);
2064
+ }
2065
+ return handle(`sessions w${n - history.length + 1}`);
2066
+ }
617
2067
  history.forEach((s, i) => {
618
2068
  const here = s === session ? ui.c.green(' now') : ' ';
2069
+ // The NAME where our server has made one, and the id beside it
2070
+ // rather than instead of it: the id is what a person types back to
2071
+ // reopen the session, so it can never be replaced by the name.
2072
+ const named = s.title ? ` ${ui.c.text(s.title)}` : '';
619
2073
  out(`${here} ${String(i + 1).padStart(2)}. ${ui.c.bold(s.id)}`
620
- + ` ${s.jobs.length} job(s) ${money(s.spentUsd)}`);
2074
+ + ` ${s.jobs.length} job(s) ${money(s.spentUsd)}${named}`);
621
2075
  });
622
- // And what this account did before this window was opened. Kept
623
- // numbered separately so a number never means two different things.
624
- recent = await pastWork();
625
2076
  if (recent.length > 0) {
626
2077
  out();
627
2078
  out(ui.c.dim(' earlier work on this account:'));
628
- recent.forEach((j, i) => {
629
- const said = (j.name || j.request || '(no request recorded)')
630
- .replace(/\s+/g, ' ').slice(0, 58);
631
- out(` ${ui.c.bold(`w${i + 1}`)}. ${said}`
632
- + ui.c.dim(` ${j.when.slice(0, 16).replace('T', ' ')} ${money(j.usd)}`));
2079
+ recent.forEach((r, i) => {
2080
+ out(` ${ui.c.bold(`w${i + 1}`.padStart(3))}. ${r.line.trimStart()}`);
633
2081
  });
634
2082
  }
635
2083
  out();
@@ -643,6 +2091,24 @@ async function runRepl() {
643
2091
  await cmdStatus();
644
2092
  break;
645
2093
  }
2094
+ // The session being stood in, unless an id is typed after it. Asking
2095
+ // the server for "the newest" here would answer about a different
2096
+ // session the moment two are open, and this one is on the screen.
2097
+ case 'cost':
2098
+ case 'spend': {
2099
+ // Nothing sent is nothing charged, and the service has no record of
2100
+ // a session before its first job, so it would answer "no such
2101
+ // session" to a person checking that a refusal cost them nothing
2102
+ // (manual 6.1).
2103
+ if (session.jobs.length === 0) {
2104
+ out(` ${ui.c.dim('session')} ${ui.c.bold(session.id)} ${ui.c.dim('nothing has run yet, so it has cost')} $0.00`);
2105
+ out();
2106
+ break;
2107
+ }
2108
+ const { cmdCost } = await Promise.resolve().then(() => __importStar(require('./cost')));
2109
+ await cmdCost([session.id]);
2110
+ break;
2111
+ }
646
2112
  case 'model': {
647
2113
  const { cmdModel } = await Promise.resolve().then(() => __importStar(require('./settings')));
648
2114
  await withTerminal(() => cmdModel([]));
@@ -660,14 +2126,36 @@ async function runRepl() {
660
2126
  case 'keys':
661
2127
  await withTerminal(() => (0, keys_1.cmdKeys)([]));
662
2128
  break;
663
- case 'workspace': {
664
- const { cmdWorkspace } = await Promise.resolve().then(() => __importStar(require('./settings')));
665
- await withTerminal(() => cmdWorkspace([]));
666
- break;
667
- }
668
2129
  // Signing out ends the session, because everything after it would be
669
2130
  // refused anyway. It used to answer "not available inside a session",
670
2131
  // which left quitting and running a second command as the only way.
2132
+ case 'history':
2133
+ // Every earlier question and answer in the session being stood in.
2134
+ out();
2135
+ await showConversation(session.serverId, true);
2136
+ break;
2137
+ case 'login': {
2138
+ // Signing in again, or as somebody else, from inside a session. The
2139
+ // session was opened with the old sign in, so it ends and the next
2140
+ // `comprism` opens with the new one.
2141
+ const { cmdLogin } = await Promise.resolve().then(() => __importStar(require('./login')));
2142
+ const before = (0, connection_1.readConnection)()?.token;
2143
+ await withTerminal(async () => {
2144
+ // The Enter that sent `/login` is still in the terminal's input.
2145
+ // Given a moment it goes to the session, not to the email question.
2146
+ await new Promise((r) => setTimeout(r, 150));
2147
+ // Anything already typed but not yet read is thrown away, so the
2148
+ // first question waits for a real answer.
2149
+ while (process.stdin.read() !== null) { /* discard */ }
2150
+ await cmdLogin([]);
2151
+ });
2152
+ // Canceled or refused: nothing changed, so the session carries on.
2153
+ if ((0, connection_1.readConnection)()?.token === before)
2154
+ break;
2155
+ out(ui.c.dim(` type ${ui.c.bold('comprism')} to start a session with this sign in`));
2156
+ out();
2157
+ return leave();
2158
+ }
671
2159
  case 'logout':
672
2160
  case 'signout':
673
2161
  case 'disconnect': {
@@ -681,6 +2169,11 @@ async function runRepl() {
681
2169
  }
682
2170
  return prompt();
683
2171
  }
2172
+ // `remote off` ends the link (manual 8.11).
2173
+ if (/^\/?remote\s+off$/.test(word)) {
2174
+ await remoteOff(session);
2175
+ return prompt();
2176
+ }
684
2177
  // `sessions 2` reopens the second one from this run.
685
2178
  const reopen = /^sessions\s+(\d+)$/.exec(word);
686
2179
  if (reopen) {
@@ -696,6 +2189,22 @@ async function runRepl() {
696
2189
  out();
697
2190
  return prompt();
698
2191
  }
2192
+ // `compact keep the requirement that ...` keeps those words exactly (6.15).
2193
+ // Read off `typed`, because what to keep keeps its case.
2194
+ const compactKeep = /^\/?compact\s+(.+)$/is.exec(typed);
2195
+ if (compactKeep) {
2196
+ await contextOf('compact', session.lastJobId, compactKeep[1].replace(/^keep\s+/i, '').trim());
2197
+ return prompt();
2198
+ }
2199
+ // `cost <session>` breaks down a session other than the one being stood in.
2200
+ // Read off `typed` rather than `word`, because `word` is lowercased so that
2201
+ // command matching is forgiving, and a session id is not a word.
2202
+ const costOf = /^\/?(?:cost|spend)\s+(\S+)$/i.exec(typed);
2203
+ if (costOf) {
2204
+ const { cmdCost } = await Promise.resolve().then(() => __importStar(require('./cost')));
2205
+ await cmdCost([costOf[1]]);
2206
+ return prompt();
2207
+ }
699
2208
  // `sessions w1` picks up work from before this window was opened. A new
700
2209
  // session is started rather than an old one edited: what it inherits is the
701
2210
  // conversation, not the spending, and adding yesterday's cost to today's
@@ -703,27 +2212,37 @@ async function runRepl() {
703
2212
  const carryOn = /^sessions\s+w(\d+)$/.exec(word);
704
2213
  if (carryOn) {
705
2214
  if (recent.length === 0)
706
- recent = await pastWork();
2215
+ recent = await earlierSessions(history);
707
2216
  const target = recent[Number(carryOn[1]) - 1];
708
2217
  if (!target) {
709
- out(ui.fail(`There is no earlier job w${carryOn[1]}. Type \`sessions\` for the list.`));
2218
+ out(ui.fail(`There is no earlier session w${carryOn[1]}. Type \`sessions\` for the list.`));
2219
+ out();
2220
+ return prompt();
710
2221
  }
711
- else {
712
- summarize(session);
713
- session = newSession();
714
- session.lastJobId = target.id;
2222
+ // The same session, resumed: its conversation and its running cost carry
2223
+ // on, and it never becomes a new session that has lost them (8.5).
2224
+ summarize(session);
2225
+ const found = await opening({ resume: target.number });
2226
+ if (found) {
2227
+ session = found;
715
2228
  history.push(session);
716
- out(ui.ok('Picking up where that left off.'));
717
- out(ui.c.dim(` ${(target.name || target.request).replace(/\s+/g, ' ').slice(0, 70)}`));
718
- out(ui.c.dim(' Ask your next question and it will know what was already said.'));
719
2229
  }
720
- out();
721
2230
  return prompt();
722
2231
  }
723
2232
  // A slash means "this is a command", so an unknown one is a typo and not a
724
2233
  // question. Sending it on would answer the typo and charge for the answer,
725
2234
  // which is exactly what `/model` used to do.
726
- if (slashed) {
2235
+ // A skill or saved command on the service's list is work for the agent,
2236
+ // slash and all: the service expands it (chapter 9).
2237
+ const isListed = () => Boolean(listed?.ok && listed.items.some((i) => i.source !== 'session' && i.name === word.split(/\s+/)[0]));
2238
+ let offered = isListed();
2239
+ if (slashed && !offered) {
2240
+ // A skill written since the list was read is found by asking again,
2241
+ // once, before the word is called a typo (manual 9.1, 9.2).
2242
+ await refreshList();
2243
+ offered = isListed();
2244
+ }
2245
+ if (slashed && !offered) {
727
2246
  out(ui.fail(`\`${typed}\` is not a command, so nothing ran and nothing was charged.`));
728
2247
  out(ui.c.dim(' Type `help` for the list, or ask without a slash to have work done.'));
729
2248
  out();
@@ -738,14 +2257,22 @@ async function runRepl() {
738
2257
  // act, so the second path was never worth the confusion it caused.
739
2258
  const explicit = /^work\s+(.+)$/i.exec(typed);
740
2259
  const asked = explicit ? explicit[1] : typed;
741
- const { text, ids } = await gather(asked);
2260
+ const { text, ids, leftOut, send } = await gather(asked);
2261
+ if (!send)
2262
+ return prompt();
2263
+ // Sent with this request, so no longer waiting for the next one.
2264
+ pending = [];
742
2265
  try {
743
- await build(session, text || asked, confirm, (controls) => { cancelJob = controls.cancel; steerJob = controls.steer; }, ids);
2266
+ await build(session, text || asked, confirm, (controls) => { job = controls; cancelJob = controls.cancel; steerJob = controls.steer; }, ids, leftOut, askLine);
744
2267
  }
745
2268
  finally {
2269
+ job = null;
746
2270
  cancelJob = null;
747
2271
  steerJob = null;
748
2272
  }
2273
+ // A note that arrived after the last step is the next request (6.8).
2274
+ queue.unshift(...view.late);
2275
+ view.late = [];
749
2276
  prompt();
750
2277
  }
751
2278
  await finished;