@illuminis/comprism 0.1.2 → 0.1.4

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 (131) hide show
  1. package/README.md +98 -36
  2. package/out/agent/command.d.ts +75 -4
  3. package/out/agent/command.js +220 -25
  4. package/out/agent/render.d.ts +17 -2
  5. package/out/agent/render.js +157 -14
  6. package/out/agent/session.d.ts +141 -2
  7. package/out/agent/session.js +735 -149
  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 +78 -11
  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 +188 -36
  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 +8 -1
  39. package/out/commands/repl.js +1207 -118
  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/worktrees.d.ts +5 -0
  55. package/out/commands/worktrees.js +186 -0
  56. package/out/executor/browser.d.ts +14 -0
  57. package/out/executor/browser.js +270 -0
  58. package/out/executor/diagnostics.d.ts +2 -0
  59. package/out/executor/diagnostics.js +181 -0
  60. package/out/executor/files.js +270 -40
  61. package/out/executor/git.js +42 -29
  62. package/out/executor/hooks.d.ts +42 -58
  63. package/out/executor/hooks.js +89 -182
  64. package/out/executor/index.d.ts +21 -5
  65. package/out/executor/index.js +160 -14
  66. package/out/executor/paths.d.ts +6 -1
  67. package/out/executor/paths.js +34 -6
  68. package/out/executor/sandbox.d.ts +40 -0
  69. package/out/executor/sandbox.js +299 -0
  70. package/out/executor/shell.d.ts +49 -4
  71. package/out/executor/shell.js +302 -56
  72. package/out/executor/toolservers.d.ts +20 -0
  73. package/out/executor/toolservers.js +189 -0
  74. package/out/executor/worktree.d.ts +9 -0
  75. package/out/executor/worktree.js +119 -0
  76. package/out/graph/read-python.js +2 -1
  77. package/out/lib/attach.d.ts +56 -12
  78. package/out/lib/attach.js +230 -63
  79. package/out/lib/clipboard.d.ts +23 -0
  80. package/out/lib/clipboard.js +182 -0
  81. package/out/lib/commandlist.d.ts +20 -0
  82. package/out/lib/commandlist.js +58 -0
  83. package/out/lib/decision.d.ts +22 -0
  84. package/out/lib/decision.js +50 -0
  85. package/out/lib/fingerprint.d.ts +25 -0
  86. package/out/lib/fingerprint.js +58 -0
  87. package/out/lib/gateway.d.ts +186 -2
  88. package/out/lib/gateway.js +59 -4
  89. package/out/lib/history.d.ts +24 -0
  90. package/out/lib/history.js +137 -0
  91. package/out/lib/ide.d.ts +19 -0
  92. package/out/lib/ide.js +131 -0
  93. package/out/lib/keyboard.d.ts +95 -0
  94. package/out/lib/keyboard.js +383 -0
  95. package/out/lib/machine.d.ts +21 -0
  96. package/out/lib/machine.js +91 -0
  97. package/out/lib/notify.d.ts +4 -0
  98. package/out/lib/notify.js +52 -0
  99. package/out/lib/output.d.ts +48 -0
  100. package/out/lib/output.js +108 -0
  101. package/out/lib/project-ops.d.ts +19 -0
  102. package/out/lib/project-ops.js +146 -0
  103. package/out/lib/project.d.ts +28 -0
  104. package/out/lib/project.js +114 -0
  105. package/out/lib/prompt.js +15 -2
  106. package/out/lib/queue.d.ts +13 -0
  107. package/out/lib/queue.js +116 -0
  108. package/out/lib/readiness.d.ts +19 -0
  109. package/out/lib/readiness.js +170 -1
  110. package/out/lib/self.d.ts +23 -0
  111. package/out/lib/self.js +124 -0
  112. package/out/lib/sessions.d.ts +19 -0
  113. package/out/lib/sessions.js +221 -0
  114. package/out/lib/stdin.d.ts +32 -0
  115. package/out/lib/stdin.js +117 -0
  116. package/out/lib/store.d.ts +40 -0
  117. package/out/lib/store.js +138 -0
  118. package/out/lib/sync.d.ts +18 -0
  119. package/out/lib/sync.js +81 -0
  120. package/out/lib/ui.d.ts +2 -4
  121. package/out/lib/ui.js +31 -25
  122. package/out/lib/voice.js +24 -0
  123. package/out/postinstall.js +42 -14
  124. package/out/providers/anthropic.d.ts +22 -0
  125. package/out/providers/anthropic.js +80 -0
  126. package/out/providers/index.d.ts +8 -0
  127. package/out/providers/index.js +83 -0
  128. package/out/providers/openai.d.ts +11 -0
  129. package/out/providers/openai.js +57 -0
  130. package/out/thin.js +609 -21
  131. package/package.json +9 -49
@@ -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,168 @@ 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,
99
128
  });
129
+ keyboard.bind(reader);
100
130
  reader.on('line', onLine);
101
- reader.on('SIGINT', () => leaveSession());
131
+ reader.on('SIGINT', () => interrupt());
102
132
  }
133
+ /**
134
+ * Tab (manual 3.1 and 6.4). After `/` it offers the service's list of
135
+ * commands and skills, one line each; after `@` the project's file names.
136
+ * Anywhere else it offers nothing, so it never rewrites a sentence.
137
+ */
138
+ function complete(line, done) {
139
+ const slash = /^\/(\S*)$/.exec(line);
140
+ if (!slash) {
141
+ done(null, attach.completeAt(line, () => {
142
+ if (!completion.files || Date.now() - completion.at > 30_000) {
143
+ completion.files = attach.projectFiles(completion.root);
144
+ completion.at = Date.now();
145
+ }
146
+ return completion.files;
147
+ }));
148
+ return;
149
+ }
150
+ const typed = slash[1].toLowerCase();
151
+ const hits = listedItems().filter((i) => i.name.startsWith(typed));
152
+ if (hits.length === 1) {
153
+ done(null, [[`/${hits[0].name} `], line]);
154
+ return;
155
+ }
156
+ done(null, [[], line]);
157
+ if (hits.length === 0 || !reader)
158
+ return;
159
+ out();
160
+ drawList(hits);
161
+ // Filled in as far as every match agrees, then the prompt drawn again under
162
+ // the list with the cursor at the end.
163
+ const common = hits.map((h) => h.name).reduce((a, b) => {
164
+ let n = 0;
165
+ while (n < a.length && a[n] === b[n])
166
+ n += 1;
167
+ return a.slice(0, n);
168
+ });
169
+ const edit = reader;
170
+ if (common.length > typed.length)
171
+ edit.line = `/${common}`;
172
+ edit.cursor = edit.line.length;
173
+ reader.prompt(true);
174
+ }
175
+ /** The service's list for this session, once fetched (manual 6.4). */
176
+ let listed = null;
177
+ /** The session words this tool runs. Sent to the service, which lists only
178
+ * these and counts the rest as needing a newer version. */
179
+ function sessionWords() {
180
+ return [...COMMANDS, 'project', 'folders', 'instructions', 'remember', 'detach', 'output', 'processes', 'stop', 'agents'];
181
+ }
182
+ async function refreshList() {
183
+ const { resolveProject } = await Promise.resolve().then(() => __importStar(require('../lib/project')));
184
+ const root = resolveProject(await sessionFolder(), place.added).root;
185
+ const got = await (0, commandlist_1.fetchCommandList)(root, sessionWords());
186
+ // A failed refresh keeps the list already held rather than emptying it.
187
+ if (got.ok || !listed)
188
+ listed = got;
189
+ }
190
+ /** What Tab and `help` offer: the service's list, or, when it could not be
191
+ * asked, this tool's own words with no descriptions. */
192
+ function listedItems() {
193
+ if (listed?.ok)
194
+ return listed.items;
195
+ return sessionWords().map((name) => ({ name, description: '', source: 'session' }));
196
+ }
197
+ function drawList(items) {
198
+ const width = Math.min(18, Math.max(...items.map((i) => i.name.length)) + 1);
199
+ for (const i of items) {
200
+ const from = i.source === 'session' ? '' : ` ${ui.c.dim(i.source)}`;
201
+ out(` ${ui.c.bold(`/${i.name}`.padEnd(width + 1))} ${ui.c.dim(i.description)}${from}`);
202
+ }
203
+ out();
204
+ }
205
+ /** The project whose files Tab offers after `@`, and the list, briefly kept. */
206
+ const completion = {
207
+ root: process.cwd(), files: null, at: 0,
208
+ };
103
209
  /** Set by the session so the handover can end it too. */
104
210
  let leaveSession = () => { };
211
+ /** Ctrl+C (manual 6.7): stop the job, clear the line, or leave. Set by the
212
+ * session; before one starts it leaves. */
213
+ let interrupt = () => leaveSession();
105
214
  /** Words that are commands. Anything else typed is work. */
106
215
  const COMMANDS = new Set([
107
- 'new', 'sessions', 'model', 'providers', 'keys', 'workspace', 'status',
216
+ 'new', 'sessions', 'branch', 'remote', 'model', 'providers', 'keys', 'status',
108
217
  'voice', 'help', 'exit', 'quit', 'clear', 'login', 'logout', 'signout',
109
- 'disconnect',
218
+ 'disconnect', 'cost', 'spend', 'attached', 'paste', 'plan', 'todo', 'mode',
219
+ 'queue', 'transcript', 'edit', 'context', 'compact', 'why', 'unpin', 'pin', 'effort',
110
220
  ]);
221
+ /** `processes` and `stop pN` (manual 5.13): what this session started, and
222
+ * stopping one by its number. Works while a job runs, too. True if handled. */
223
+ function processWord(said) {
224
+ if (/^\/?processes$/i.test(said)) {
225
+ const rows = (0, shell_1.listProcesses)();
226
+ if (!rows.length)
227
+ out(ui.c.dim(' No background processes were started in this session.'));
228
+ for (const p of rows) {
229
+ const age = p.seconds < 60 ? `${p.seconds} s` : `${Math.round(p.seconds / 60)} min`;
230
+ out(` ${ui.c.bold(p.id)} ${p.command} ${ui.c.dim(p.running ? `running ${age}` : 'finished')}`
231
+ + (p.port ? ui.c.dim(` port ${p.port}`) : ''));
232
+ }
233
+ out();
234
+ return true;
235
+ }
236
+ const stop = /^\/?stop\s+(p\d+)$/i.exec(said);
237
+ if (stop) {
238
+ const r = (0, shell_1.stopProcess)(stop[1].toLowerCase());
239
+ out(r.isError ? ui.fail(r.content) : ui.c.dim(` ${r.content}`));
240
+ out();
241
+ return true;
242
+ }
243
+ return false;
244
+ }
245
+ /** `agents` between jobs: the helpers defined here (manual 5.24). */
246
+ async function showHelpers() {
247
+ const { helperLines } = await Promise.resolve().then(() => __importStar(require('./agents')));
248
+ for (const line of await helperLines(await sessionFolder()))
249
+ out(` ${line}`);
250
+ out();
251
+ }
252
+ /** The permission modes, in the order Shift+Tab moves through them (4.5). */
253
+ const MODES = ['read_only', 'approve_writes', 'auto_edit', 'full_auto'];
254
+ /** How the next job in this session runs: its mode and whether it plans first
255
+ * (manual 4.1, 4.5). Changed by `mode`, Shift+Tab, `plan` and `plan off`, and
256
+ * applied from the next job; the service still decides what each mode allows. */
257
+ const steering_ = { mode: 'approve_writes', plan: false, pin: '', effort: '' };
111
258
  /**
112
259
  * The line as a command, with a leading slash taken off.
113
260
  *
@@ -152,51 +299,45 @@ async function withTerminal(run) {
152
299
  }
153
300
  }
154
301
  /**
155
- * What this account has actually done, newest first.
302
+ * This account's earlier sessions, from the service's record (manual 8.5).
156
303
  *
157
304
  * 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.
305
+ * window lost every thread. The record has held all of it the whole time.
306
+ * Sessions of this run are left out, since they are listed above it, and their
307
+ * names are taken from the record, which names a session from its first
308
+ * request. Returns nothing when the workspace cannot be reached: a list is not
309
+ * worth stopping a session over.
165
310
  */
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)
172
- 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 {
311
+ async function earlierSessions(run) {
312
+ const { sessions } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
313
+ const reply = await sessions('list', { limit: 10 });
314
+ if (!reply.ok || !reply.sessions?.sessions)
193
315
  return [];
194
- }
316
+ const rows = reply.sessions.sessions;
317
+ const lines = reply.sessions.lines;
318
+ const found = [];
319
+ rows.forEach((r, i) => {
320
+ const here = run.find((s) => s.serverId === r.session_id);
321
+ if (here) {
322
+ if (r.title)
323
+ here.title = r.title;
324
+ here.id = r.number;
325
+ return;
326
+ }
327
+ found.push({ number: r.number, line: lines[i] ?? r.number });
328
+ });
329
+ return found;
195
330
  }
196
331
  function newSession() {
332
+ // A UUID, because that is what the job record's session column holds. A
333
+ // dated `cli_...` string was refused by the database, so no job started
334
+ // in a session was ever recorded (found 23 September 2026, prompt 03).
335
+ const serverId = (0, node_crypto_1.randomUUID)();
197
336
  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, ''),
337
+ // The session's number, the same one the portal shows (manual 8.1). The
338
+ // service writes it; `numbered` asks for it as soon as the session opens.
339
+ id: serverId,
340
+ serverId,
200
341
  startedAt: new Date().toISOString(),
201
342
  jobs: [],
202
343
  spentUsd: 0,
@@ -205,56 +346,239 @@ function newSession() {
205
346
  };
206
347
  }
207
348
  const money = (n) => `$${n.toFixed(4)}`;
349
+ /**
350
+ * Ask the service for a new session's number and its opening line (8.2).
351
+ * Returns the line to print, or null when the service could not be asked, in
352
+ * which case the session keeps its full id until the next answer names it.
353
+ */
354
+ async function numbered(s) {
355
+ const { sessions } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
356
+ const reply = await sessions('opened', { session_id: s.serverId });
357
+ if (!reply.ok || !reply.sessions?.number)
358
+ return null;
359
+ s.id = reply.sessions.number;
360
+ return reply.sessions.lines?.[0] ?? null;
361
+ }
362
+ /**
363
+ * A session the service already holds, opened again (manual 8.4, 8.5).
364
+ *
365
+ * Same id, so every new job files under the same record; its jobs and its cost
366
+ * so far, so the running total carries on without counting anything twice; and
367
+ * the last job to continue from only when the service says the conversation
368
+ * was kept. Session approvals (`a` answers) belong to the run that gave them
369
+ * and are not carried over.
370
+ */
371
+ function reopened(r) {
372
+ return {
373
+ id: r.number ?? r.session_id ?? '',
374
+ serverId: r.session_id ?? '',
375
+ title: r.title ?? null,
376
+ startedAt: new Date().toISOString(),
377
+ jobs: Array.from({ length: Number(r.jobs ?? 0) }, () => ({ request: '' })),
378
+ spentUsd: Number(r.cost_usd ?? 0),
379
+ savedUsd: 0,
380
+ compared: 0,
381
+ ...(r.continues_job ? { lastJobId: r.continues_job } : {}),
382
+ };
383
+ }
384
+ /** Ask the service for the session to open, choosing from the list when no
385
+ * number was given. Prints the resume lines, or why there are none. */
386
+ async function opening(how) {
387
+ const { sessions, pick, failure, exitFor } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
388
+ let key = how.resume;
389
+ if (how.pick) {
390
+ const listed = await sessions('list', { limit: 20 });
391
+ if (!listed.ok || !listed.sessions) {
392
+ out(ui.fail(failure(listed)));
393
+ process.exitCode = exitFor(listed);
394
+ return null;
395
+ }
396
+ const rows = listed.sessions.sessions ?? [];
397
+ if (rows.length === 0) {
398
+ for (const line of listed.sessions.lines)
399
+ out(line);
400
+ return null;
401
+ }
402
+ const chosen = await pick(listed.sessions.lines);
403
+ if (chosen === null)
404
+ return null;
405
+ key = rows[chosen]?.number;
406
+ }
407
+ const reply = how.latest
408
+ ? await sessions('latest')
409
+ : await sessions('resume', { key: key ?? '' });
410
+ if (!reply.ok || !reply.sessions?.session_id) {
411
+ out(ui.fail(failure(reply)));
412
+ process.exitCode = exitFor(reply);
413
+ return null;
414
+ }
415
+ out();
416
+ for (const line of reply.sessions.lines)
417
+ out(line);
418
+ out();
419
+ return reopened(reply.sessions);
420
+ }
421
+ /** The command and skill list, as the service sent it (manual 6.4). */
208
422
  function printHelp() {
209
423
  out();
210
424
  out(ui.c.dim(' Type a question and press enter. Or one of these:'));
211
425
  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)}`);
426
+ drawList(listedItems());
427
+ if (!listed?.ok) {
428
+ out(ui.c.amber(' Your workspace could not be asked for its skills, so only this tool\'s own words are shown.'));
429
+ out();
227
430
  }
228
- out();
229
- out(ui.c.dim(' A slash is fine as well, so `/model` and `model` do the same thing.'));
431
+ else if (listed.needsUpdate.length > 0) {
432
+ out(ui.c.dim(` ${listed.needsUpdate.length} more need a newer comprism: npm install -g @illuminis/comprism`));
433
+ out();
434
+ }
435
+ 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
436
  out(ui.c.dim(' Drag a file onto this window, or type @ and a path, to have it read.'));
437
+ 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
438
  out();
232
439
  }
233
440
  /**
234
441
  * Hand a request to the coding agent, in the folder this session is working in.
235
442
  *
236
- * The agent reads the project, proposes every change before making it, and runs
443
+ * The agent reads the project, shows every change as it makes it, and runs
237
444
  * the tests. It is the same agent the standalone command runs; there is one
238
445
  * implementation, so the session and the command cannot behave differently.
239
446
  */
240
- async function build(session, request, confirm, onStarted, attachmentIds = []) {
241
- const { runAgent } = await Promise.resolve().then(() => __importStar(require('../agent/command')));
447
+ /**
448
+ * The folders this session works in (manual 2.1 and 2.3). `project <path>`
449
+ * changes the folder for the rest of the session; `folders add <path>` allows
450
+ * another. Neither changes the saved default, which is `comprism project`.
451
+ */
452
+ const place = { folder: null, added: [] };
453
+ async function sessionFolder() {
454
+ if (place.folder)
455
+ return place.folder;
242
456
  const { currentWorkspace } = await Promise.resolve().then(() => __importStar(require('./settings')));
243
- const folder = currentWorkspace();
457
+ return currentWorkspace().path;
458
+ }
459
+ /** The line this session begins with, as the service wrote it (manual 2.2). */
460
+ async function showProjectLine() {
461
+ const { resolveProject, asWire } = await Promise.resolve().then(() => __importStar(require('../lib/project')));
462
+ const { ask } = await Promise.resolve().then(() => __importStar(require('../lib/gateway')));
463
+ const facts = resolveProject(await sessionFolder(), place.added);
464
+ const reply = await ask({ intent: 'project', prompt: '', project: asWire(facts, 'approve_writes') });
465
+ // Drawn as sent. When the service cannot answer, the folder alone, so the
466
+ // person still knows where they are.
467
+ out(ui.c.bold(reply.ok && reply.project_line ? reply.project_line : ` ${facts.root}`));
468
+ out();
469
+ }
470
+ /** Ctrl+O's choice, kept for the next job too, and notes left over from the
471
+ * last one (manual 6.8, 6.9). */
472
+ const view = { full: false, late: [] };
473
+ /** `context` and `compact [what to keep]`, answered and worded by the
474
+ * service for the session's latest job (manual 6.13, 6.15). */
475
+ async function contextOf(intent, jobId, keep = '', sessionKey) {
476
+ const { ask } = await Promise.resolve().then(() => __importStar(require('../lib/gateway')));
477
+ out();
478
+ if (intent === 'compact')
479
+ out(ui.c.dim(' summarizing the conversation so far to keep it affordable'));
480
+ // The session is named too, so a session with no job yet (questions asked in
481
+ // Work) gets the same figure the web meter shows (web manual 3.11).
482
+ const reply = await ask({
483
+ intent, prompt: keep, ...(jobId ? { jobId } : {}), ...(sessionKey ? { spendSession: sessionKey } : {}),
484
+ });
485
+ if (!reply.ok || !reply.context) {
486
+ out(ui.fail(reply.message || 'Your workspace could not answer that.'));
487
+ }
488
+ else {
489
+ for (const line of reply.context.lines)
490
+ out(line);
491
+ }
492
+ out();
493
+ }
494
+ /** One job's full record, as the service writes it (manual 6.9). */
495
+ async function showTranscript(jobId) {
496
+ out();
497
+ if (!jobId) {
498
+ out(ui.c.dim(' Nothing has run in this session yet, so there is no transcript.'));
499
+ out();
500
+ return;
501
+ }
502
+ const { ask } = await Promise.resolve().then(() => __importStar(require('../lib/gateway')));
503
+ const reply = await ask({ intent: 'transcript', prompt: '', jobId });
504
+ if (!reply.ok || !reply.transcript) {
505
+ out(ui.fail(reply.message || 'The transcript could not be read from your workspace.'));
506
+ }
507
+ else {
508
+ for (const line of reply.transcript.lines)
509
+ out(line);
510
+ }
244
511
  out();
245
- out(ui.c.dim(` working in ${folder.path}`));
512
+ }
513
+ /**
514
+ * Open the request in the person's editor and put what they saved back at the
515
+ * prompt, unsent (manual 6.6). The file exists only while the editor is open,
516
+ * in a folder only this account can read, and is removed the moment it closes.
517
+ */
518
+ async function editRequest(current) {
519
+ const chosen = process.env.VISUAL || process.env.EDITOR || (0, machine_1.machineSettings)().editor;
520
+ const put = (text) => {
521
+ if (!reader)
522
+ return;
523
+ reader.setPrompt(`${ui.c.bold(ui.c.blue('comprism'))} ${ui.c.dim('>')} `);
524
+ reader.prompt();
525
+ reader.write((0, keyboard_1.asOneLine)(text));
526
+ };
527
+ if (!chosen) {
528
+ out();
529
+ out(ui.fail('No editor is set. Set VISUAL or EDITOR, or run: comprism config set editor <command>'));
530
+ put(current);
531
+ return;
532
+ }
533
+ const fs = await Promise.resolve().then(() => __importStar(require('node:fs')));
534
+ const os = await Promise.resolve().then(() => __importStar(require('node:os')));
535
+ const { spawnSync } = await Promise.resolve().then(() => __importStar(require('node:child_process')));
536
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'comprism-edit-'));
537
+ const file = path.join(dir, 'request.md');
538
+ fs.writeFileSync(file, current, { mode: 0o600 });
539
+ releaseTerminal();
540
+ let back = current;
541
+ try {
542
+ const [program, ...extra] = chosen.split(/\s+/).filter(Boolean);
543
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
544
+ spawnSync(program, [...extra, file], { stdio: 'inherit' });
545
+ const saved = fs.readFileSync(file, 'utf8');
546
+ // Closed without saving leaves the prompt exactly as it was.
547
+ if (saved !== current)
548
+ back = saved.replace(/\r?\n$/, '');
549
+ }
550
+ catch {
551
+ out(ui.fail(`${chosen} could not be opened, so the request is as it was.`));
552
+ }
553
+ finally {
554
+ fs.rmSync(dir, { recursive: true, force: true });
555
+ takeTerminal();
556
+ }
557
+ put(back);
558
+ }
559
+ async function build(session, request, confirm, onStarted, attachmentIds = [], leftOut = [], ask) {
560
+ const { runAgent } = await Promise.resolve().then(() => __importStar(require('../agent/command')));
246
561
  out();
247
562
  // Counted the moment it starts, not when it returns. A person who leaves
248
563
  // part-way through a job was told "nothing asked in that session" over work
249
564
  // that had already been paid for, which is the one number this product cannot
250
565
  // get wrong.
251
566
  session.jobs.push({ request });
567
+ const began = Date.now();
568
+ let busy = false;
252
569
  const code = await runAgent({
253
570
  request: [request],
254
- cwd: folder.path,
571
+ cwd: await sessionFolder(),
572
+ addFolders: place.added,
255
573
  // Carry on from the last question rather than starting again. `new` clears
256
574
  // it, which is what `new` is for.
257
575
  continuesJob: session.lastJobId,
576
+ // The session the person is actually in. Without it the server files every
577
+ // job of the day under one row, which is why `sessions` here listed three
578
+ // and the Sessions screen showed one.
579
+ conversationId: session.serverId,
580
+ // A server started by one question is still there for the next (5.13).
581
+ keepProcesses: true,
258
582
  // The session's own keyboard, handed to the agent.
259
583
  //
260
584
  // The agent has to put a change in front of the person and wait. It used to
@@ -264,24 +588,47 @@ async function build(session, request, confirm, onStarted, attachmentIds = []) {
264
588
  // than by the person. One reader, owned by whoever started the process,
265
589
  // passed down.
266
590
  confirm,
591
+ prompt: ask,
267
592
  onStarted,
593
+ fullView: view.full,
268
594
  // Files the person attached, already read and waiting on the server.
269
595
  attachments: attachmentIds,
596
+ // Files named that could not be attached, so the agent is told (3.10).
597
+ leftOut,
270
598
  // Ask before a change and before a command, because now the person can
271
599
  // actually answer. This was auto_edit only because approving a write was
272
600
  // impossible from inside a session; it is the mode the standalone command
273
601
  // uses, so the two behave the same way.
274
- mode: 'approve_writes',
602
+ mode: steering_.mode,
603
+ plan: steering_.plan,
604
+ // A pin and effort set with `pin` and `effort` are the session's, held by
605
+ // the service (manual 4.17, 4.18; web manual 4.5, 4.6), which applies them
606
+ // to this job. So a pin set in the browser applies here, and the reverse.
275
607
  // The running total is the whole point of a session, so it is added up
276
608
  // from what each job actually cost. It used to be declared and never
277
609
  // touched, so a session ended by reporting zero spent and zero saved
278
610
  // directly underneath a job that had just charged three cents.
279
611
  onFinished: (r) => {
612
+ // Busy in another place (manual 8.8): nothing ran, nothing was charged.
613
+ if (r.outcome === 'busy') {
614
+ busy = true;
615
+ return;
616
+ }
280
617
  // Recorded even when the job did not finish. A run that was stopped or
281
618
  // ran out of steps still said things, and the next question is usually
282
619
  // about exactly that.
283
620
  if (r.jobId)
284
621
  session.lastJobId = r.jobId;
622
+ // Which job answered which request, for `rewind` (manual 7.4 to 7.6).
623
+ const asked = session.jobs[session.jobs.length - 1];
624
+ if (asked && r.jobId && !asked.jobId)
625
+ asked.jobId = r.jobId;
626
+ session.todos = r.todos;
627
+ if (r.why && r.why.length)
628
+ session.why = r.why;
629
+ // Notes that arrived after the last step become requests of their own
630
+ // (manual 6.8); the service has already said so on screen.
631
+ view.late = r.lateNotes;
285
632
  session.spentUsd += r.totalUsd;
286
633
  if (r.savedUsd !== null) {
287
634
  session.savedUsd += r.savedUsd;
@@ -289,12 +636,108 @@ async function build(session, request, confirm, onStarted, attachmentIds = []) {
289
636
  }
290
637
  },
291
638
  });
639
+ if (busy) {
640
+ session.jobs.pop();
641
+ return whileBusy(session, request, confirm, onStarted, attachmentIds, leftOut, ask);
642
+ }
292
643
  out();
293
644
  if (code !== 0)
294
645
  out(ui.c.amber(' the job did not finish'));
646
+ // A job long enough to switch windows during is worth being told about
647
+ // (manual 6.11).
648
+ if (Date.now() - began > 30_000) {
649
+ const { notify } = await Promise.resolve().then(() => __importStar(require('../lib/notify')));
650
+ notify(code === 0 ? 'Your job finished.' : 'Your job stopped before finishing.', (l) => out(ui.c.dim(l)));
651
+ }
652
+ }
653
+ /**
654
+ * The session is busy in another place (manual 8.8). Offer to wait, or to read
655
+ * it here. Waiting asks the service until the session is free, then runs the
656
+ * request; reading prints the session's turns and runs nothing.
657
+ */
658
+ async function whileBusy(session, request, confirm, onStarted, attachmentIds, leftOut, ask) {
659
+ const { sessions } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
660
+ const said = ((await ask?.('w to wait for it, r to read it here: ')) ?? 'r').trim().toLowerCase();
661
+ if (said.startsWith('w')) {
662
+ out(ui.c.dim(' waiting for the session to be free...'));
663
+ for (let i = 0; i < 120; i++) {
664
+ await new Promise((r) => setTimeout(r, 3000));
665
+ const now = await sessions('lease', { key: session.serverId });
666
+ if (now.ok && now.sessions && !now.sessions.busy_in) {
667
+ return build(session, request, confirm, onStarted, attachmentIds, leftOut, ask);
668
+ }
669
+ }
670
+ out(ui.c.amber(' still busy after six minutes, so nothing was run.'));
671
+ return;
672
+ }
673
+ const read = await sessions('read', { key: session.serverId });
674
+ out();
675
+ for (const line of read.sessions?.lines ?? [read.message ?? ''])
676
+ out(line);
677
+ out(ui.c.dim(' Read only: nothing was run.'));
678
+ out();
679
+ }
680
+ /** What `remote` holds while it is on (manual 8.11). */
681
+ const remote = { timer: null, session: null, take: () => undefined, idle: () => false };
682
+ async function remoteOn(session) {
683
+ const { sessions, failure } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
684
+ const reply = await sessions('remote_on', { key: session.serverId });
685
+ if (!reply.ok || !reply.sessions) {
686
+ out(ui.fail(failure(reply)));
687
+ out();
688
+ return;
689
+ }
690
+ for (const line of reply.sessions.lines)
691
+ out(line);
692
+ out();
693
+ remote.session = session.serverId;
694
+ if (remote.timer)
695
+ clearInterval(remote.timer);
696
+ let asking = false;
697
+ remote.timer = setInterval(() => {
698
+ if (asking || !remote.idle() || !remote.session)
699
+ return;
700
+ asking = true;
701
+ void sessions('remote_poll', { key: remote.session }).then((got) => {
702
+ const body = got.sessions;
703
+ if (body?.off) {
704
+ stopRemoteTimer();
705
+ return;
706
+ }
707
+ for (const text of body?.requests ?? [])
708
+ remote.take(text);
709
+ }).finally(() => { asking = false; });
710
+ }, 2000);
711
+ remote.timer.unref();
712
+ }
713
+ function stopRemoteTimer() {
714
+ if (remote.timer)
715
+ clearInterval(remote.timer);
716
+ remote.timer = null;
717
+ remote.session = null;
718
+ }
719
+ async function remoteOff(session, quiet = false) {
720
+ const key = remote.session ?? session.serverId;
721
+ const was = remote.session;
722
+ stopRemoteTimer();
723
+ if (quiet && !was)
724
+ return;
725
+ const { sessions } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
726
+ const reply = await sessions('remote_off', { key });
727
+ if (!quiet) {
728
+ for (const line of reply.sessions?.lines ?? [' Remote was not on.'])
729
+ out(line);
730
+ out();
731
+ }
295
732
  }
296
733
  /** The running total, shown when a session ends or another is opened. */
297
734
  function summarize(s) {
735
+ // The session is over: where the company keeps no content, its checkpoints
736
+ // end with it (manual 7.3). The service decides; nothing is kept here.
737
+ if (s.jobs.length > 0) {
738
+ void Promise.resolve().then(() => __importStar(require('./review'))).then((m) => m.review('session_ended', { session_id: s.serverId }))
739
+ .catch(() => undefined);
740
+ }
298
741
  if (s.jobs.length === 0) {
299
742
  out(ui.c.dim(' nothing asked in that session'));
300
743
  return;
@@ -308,7 +751,7 @@ function summarize(s) {
308
751
  out(` ${ui.c.dim('session')} ${ui.c.bold(s.id)} ${s.jobs.length} job(s)`
309
752
  + ` ${money(s.spentUsd)} ${ui.c.dim('spent')}${saving}`);
310
753
  }
311
- async function runRepl() {
754
+ async function runRepl(how = {}) {
312
755
  // Resolved when the person leaves. The reader is closed and reopened around
313
756
  // every job, so waiting on it closing would end the session at the first one.
314
757
  let done = () => { };
@@ -352,6 +795,34 @@ async function runRepl() {
352
795
  }
353
796
  const history = [];
354
797
  let session = newSession();
798
+ /** Read, or change, this session's pin and effort on the service (manual
799
+ * 4.17, 4.18). Says the service's refusal and returns null on one. */
800
+ const steer = async (change) => {
801
+ const { sessions, failure } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
802
+ const changing = Object.keys(change).length > 0;
803
+ const reply = await sessions(changing ? 'steer' : 'steering', { session_id: session.serverId, ...change });
804
+ const said = reply.sessions;
805
+ if (!reply.ok || !said || reply.granted === false) {
806
+ out(ui.fail(failure(reply)));
807
+ return null;
808
+ }
809
+ steering_.pin = said.pin ?? '';
810
+ steering_.effort = said.effort === 'normal' ? '' : said.effort;
811
+ return said;
812
+ };
813
+ if (how.latest || how.resume || how.pick) {
814
+ const found = await opening(how);
815
+ if (!found) {
816
+ out();
817
+ if (!process.exitCode)
818
+ process.exitCode = 1;
819
+ return;
820
+ }
821
+ session = found;
822
+ }
823
+ else {
824
+ await numbered(session);
825
+ }
355
826
  history.push(session);
356
827
  // Ctrl-C leaves, and says what the session cost on the way out. A product
357
828
  // that vanishes without telling you what it spent is the thing this one
@@ -360,7 +831,21 @@ async function runRepl() {
360
831
  // answer, not the next request, so it is handed straight to whoever is
361
832
  // waiting instead of joining the queue.
362
833
  let answering = null;
834
+ /** Rings or notifies when an approval has waited 30 seconds (6.11). */
835
+ let waitingTimer = null;
836
+ const startWaiting = () => {
837
+ if (waitingTimer)
838
+ clearTimeout(waitingTimer);
839
+ waitingTimer = setTimeout(() => {
840
+ void Promise.resolve().then(() => __importStar(require('../lib/notify'))).then(({ notify }) => notify('comprism is waiting for your answer.', (l) => out(ui.c.dim(l))));
841
+ }, 30_000);
842
+ waitingTimer.unref();
843
+ };
363
844
  const takeAnswer = (line) => {
845
+ if (waitingTimer) {
846
+ clearTimeout(waitingTimer);
847
+ waitingTimer = null;
848
+ }
364
849
  if (!answering)
365
850
  return false;
366
851
  const waiting = answering;
@@ -381,7 +866,20 @@ async function runRepl() {
381
866
  resolve(false);
382
867
  return;
383
868
  }
384
- answering = (line) => resolve(/^y(es)?$/i.test(line.trim()));
869
+ answering = (line) => resolve(/^y(es)?$/i.test((line ?? '').trim()));
870
+ startWaiting();
871
+ reader.setPrompt(` ${question} `);
872
+ reader.prompt();
873
+ });
874
+ /** The same lent keyboard, for a line of text: the plan question and the
875
+ * agent's questions (manual 4.2, 4.3). Null when the person left. */
876
+ const askLine = (question) => new Promise((resolve) => {
877
+ if (leaving || !reader) {
878
+ resolve(null);
879
+ return;
880
+ }
881
+ answering = resolve;
882
+ startWaiting();
385
883
  reader.setPrompt(` ${question} `);
386
884
  reader.prompt();
387
885
  });
@@ -393,7 +891,7 @@ async function runRepl() {
393
891
  resolve('');
394
892
  return;
395
893
  }
396
- answering = resolve;
894
+ answering = (line) => resolve(line ?? '');
397
895
  reader.setPrompt('');
398
896
  reader.prompt();
399
897
  });
@@ -433,7 +931,11 @@ async function runRepl() {
433
931
  const { file, bytes } = await live.stop();
434
932
  try {
435
933
  if (bytes < 2000) {
436
- out(ui.c.dim(' nothing was recorded'));
934
+ // Nothing heard is usually the microphone permission, never asked for
935
+ // or refused, so it is named (manual 6.16).
936
+ out(ui.c.dim(process.platform === 'darwin'
937
+ ? ' nothing was recorded. Your terminal may not have microphone permission: System Settings, Privacy and Security, Microphone.'
938
+ : ' nothing was recorded. Check that a microphone is connected and not muted.'));
437
939
  out();
438
940
  return prompt();
439
941
  }
@@ -462,31 +964,94 @@ async function runRepl() {
462
964
  voice.discard(file);
463
965
  }
464
966
  };
967
+ /** Files attached and not yet sent: they go with the next request
968
+ * (manual 3.6). `attached` lists them and `detach N` takes one off. */
969
+ let pending = [];
970
+ /** Pastes this session, so each is `clipboard-N.png` with its own N. */
971
+ let pastes = 0;
972
+ /**
973
+ * The clipboard's picture, attached to the next request (manual 3.4). Saved
974
+ * to a temporary file, uploaded like any file, and the file removed
975
+ * whatever happened.
976
+ */
977
+ const pasteImage = async () => {
978
+ const { readClipboardImage, discard } = await Promise.resolve().then(() => __importStar(require('../lib/clipboard')));
979
+ const clip = await readClipboardImage(pastes + 1);
980
+ out();
981
+ if (!clip.ok) {
982
+ out(ui.c.amber(` ${clip.why}`));
983
+ out();
984
+ return;
985
+ }
986
+ pastes += 1;
987
+ try {
988
+ const { attached, refused } = await attach.upload(conn.url, conn.workspaceKey, [clip.file]);
989
+ for (const line of attach.describe(attached))
990
+ out(ui.c.dim(` ${line}`));
991
+ for (const r of refused)
992
+ out(ui.c.amber(` ${r.why}`));
993
+ pending = [...pending, ...attached];
994
+ }
995
+ finally {
996
+ discard(clip.folder);
997
+ }
998
+ out();
999
+ };
465
1000
  /**
466
1001
  * Files the person pointed at, read and ready, and the question without the
467
1002
  * long paths in it.
468
1003
  *
469
1004
  * Uploading happens before the job starts and is reported before anything is
470
1005
  * charged, because the commonest complaint about any assistant is that it
471
- * answered without looking at the thing you gave it.
1006
+ * answered without looking at the thing you gave it. A file with a problem
1007
+ * gets one line saying so and is never sent (manual 3.10).
1008
+ *
1009
+ * `send` is false when the line named files and said nothing else: that is
1010
+ * a drag and drop, and the files wait for the question that follows.
472
1011
  */
473
1012
  const gather = async (typed) => {
474
- const { currentWorkspace } = await Promise.resolve().then(() => __importStar(require('./settings')));
475
- const found = attach.mentions(typed, currentWorkspace().path);
1013
+ const { resolveProject } = await Promise.resolve().then(() => __importStar(require('../lib/project')));
1014
+ const here = await sessionFolder();
1015
+ const root = resolveProject(here, place.added).root;
1016
+ completion.root = root;
1017
+ const found = attach.mentions(typed, root, here);
1018
+ const waiting = () => pending.map((a) => a.id);
476
1019
  if (found.length === 0)
477
- return { text: typed, ids: [] };
1020
+ return { text: typed, ids: waiting(), leftOut: [], send: true };
478
1021
  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)}`));
1022
+ const problems = found.filter((m) => m.problem);
1023
+ const readable = found.filter((m) => !m.problem);
1024
+ const { attached, refused } = readable.length > 0
1025
+ ? await attach.upload(conn.url, conn.workspaceKey, readable.map((m) => m.file))
1026
+ : { attached: [], refused: [] };
1027
+ for (const line of attach.describe(attached))
1028
+ out(ui.c.dim(` ${line}`));
1029
+ for (const m of problems)
1030
+ out(ui.c.amber(` ${m.problem?.message}`));
483
1031
  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) };
1032
+ out(ui.c.amber(` ${r.why}`));
1033
+ const failed = [...problems.map((m) => m.file), ...refused.map((r) => r.file)];
1034
+ const leftOut = failed.map((f) => path.basename(f));
1035
+ const landed = found.filter((m) => !failed.includes(m.file));
1036
+ const words = attach.withoutMentions(typed, found);
1037
+ pending = [...pending, ...attached];
1038
+ if (!words) {
1039
+ if (pending.length > 0)
1040
+ out(ui.c.dim(' Waiting for your question. `attached` lists what will go with it.'));
1041
+ out();
1042
+ return { text: '', ids: [], leftOut, send: false };
1043
+ }
1044
+ // Every file failed and the request was about them: nothing is sent until
1045
+ // the person says so (manual 3.10).
1046
+ if (attached.length === 0 && pending.length === 0) {
1047
+ const go = await confirm('None of those files could be attached. Send the request anyway? (y/n)');
1048
+ if (!go) {
1049
+ out(ui.c.dim(' Nothing was sent.'));
1050
+ out();
1051
+ return { text: '', ids: [], leftOut, send: false };
1052
+ }
1053
+ }
1054
+ return { text: attach.withoutPaths(typed, landed), ids: waiting(), leftOut, send: true };
490
1055
  };
491
1056
  // A way to stop the job that is running, while it is running. Null between
492
1057
  // jobs, which is also the answer to "is anything running".
@@ -497,6 +1062,60 @@ async function runRepl() {
497
1062
  /** Set while a job is running, so a line typed mid job reaches it instead of
498
1063
  * queueing up behind it as the next question. */
499
1064
  let steerJob = null;
1065
+ /** Everything else a running job can be asked: pause, carry on, the queue,
1066
+ * the full view (manual 6.7 to 6.9). Null between jobs. */
1067
+ let job = null;
1068
+ // Esc pauses a running job; at the prompt it does nothing (6.7).
1069
+ keyboard.onEscape = () => {
1070
+ if (!job || job.isPaused())
1071
+ return false;
1072
+ job.pause();
1073
+ return true;
1074
+ };
1075
+ // Ctrl+O, the short or the full view, from now on (6.9).
1076
+ keyboard.onFullView = () => {
1077
+ view.full = !view.full;
1078
+ job?.setFull(view.full);
1079
+ out();
1080
+ out(ui.c.dim(view.full ? ' full view: each action\'s input and output in full' : ' short view'));
1081
+ if (!job)
1082
+ prompt();
1083
+ };
1084
+ // Ctrl+G: the request so far, in your own editor (6.6). At the prompt only.
1085
+ keyboard.onEditor = () => {
1086
+ if (job || working)
1087
+ return;
1088
+ const edit = reader;
1089
+ const now = [...draft, edit?.line ?? ''].join('\n');
1090
+ draft = [];
1091
+ void editRequest(now);
1092
+ };
1093
+ // Ctrl+V: the clipboard's picture, as `paste` (3.4). At the prompt only;
1094
+ // what is typed so far stays on the line.
1095
+ keyboard.onPasteImage = () => {
1096
+ if (job || working)
1097
+ return;
1098
+ void pasteImage().then(() => prompt());
1099
+ };
1100
+ keyboard.vi = (0, machine_1.machineSettings)().keys === 'vi';
1101
+ // Ctrl+C: the job, then the line, then the session (6.7).
1102
+ interrupt = () => {
1103
+ if (job) {
1104
+ // An approval on screen is answered no, a question as stopped.
1105
+ takeAnswer(null);
1106
+ job.cancel();
1107
+ return;
1108
+ }
1109
+ const edit = reader;
1110
+ if (draft.length > 0 || (edit && edit.line)) {
1111
+ draft = [];
1112
+ if (edit)
1113
+ edit.line = '';
1114
+ out();
1115
+ return prompt();
1116
+ }
1117
+ leave();
1118
+ };
500
1119
  let leaving = false;
501
1120
  /**
502
1121
  * Ctrl-C, or `exit`.
@@ -515,13 +1134,21 @@ async function runRepl() {
515
1134
  if (leaving)
516
1135
  return;
517
1136
  leaving = true;
518
- takeAnswer('n');
1137
+ // The portal link ends with the session (manual 8.11).
1138
+ if (remote.session)
1139
+ void remoteOff(session, true);
1140
+ // A question on screen is answered as stopped, not as a word (4.3).
1141
+ takeAnswer(null);
519
1142
  cancelJob?.();
520
1143
  const close = () => {
521
1144
  out();
1145
+ const said = (0, render_1.stoppedLines)((0, shell_1.stopEverything)());
1146
+ if (said)
1147
+ process.stdout.write(said);
522
1148
  summarize(session);
523
1149
  out();
524
1150
  releaseTerminal();
1151
+ earlier.finish();
525
1152
  done();
526
1153
  };
527
1154
  if (!working)
@@ -541,7 +1168,17 @@ async function runRepl() {
541
1168
  reader.setPrompt(`${ui.c.bold(ui.c.blue('comprism'))} ${ui.c.dim('>')} `);
542
1169
  reader.prompt();
543
1170
  };
544
- out(ui.c.dim(' Type what you want done, or `help`. Ctrl-C to leave.'));
1171
+ await showProjectLine();
1172
+ // The command and skill list, and whether this company lets typed requests
1173
+ // be kept on this machine (manual 6.4, 6.5). Unknown keeps them for this
1174
+ // session only and leaves any file alone.
1175
+ await refreshList();
1176
+ // `config set history off` keeps nothing at all, not even for this session.
1177
+ const historyOff = (0, machine_1.machineSettings)().history === 'off';
1178
+ earlier.start(historyOff ? false : listed?.ok ? listed.keepHistory : null);
1179
+ if (historyOff)
1180
+ earlier.disable();
1181
+ out(ui.c.dim(' Type what you want done, or help. Ctrl+C to leave.'));
545
1182
  out();
546
1183
  // One line at a time, in the order they arrived.
547
1184
  //
@@ -563,11 +1200,41 @@ async function runRepl() {
563
1200
  }
564
1201
  working = false;
565
1202
  };
566
- onLine = (line) => {
1203
+ // Requests sent from the portal while `remote` is on (manual 8.11). This
1204
+ // terminal asks the service for them every two seconds between jobs, which
1205
+ // is also how the service knows the session is still running here; they are
1206
+ // run here, on this machine, exactly as if typed, approvals included.
1207
+ remote.take = (text) => {
1208
+ out(`\n ${ui.c.bold('from the portal:')} ${text}`);
1209
+ queue.push(text);
1210
+ void drain();
1211
+ };
1212
+ remote.idle = () => !job && !working && !leaving;
1213
+ /** The lines of a request continued with `\\` or Alt+Enter, so far. */
1214
+ let draft = [];
1215
+ onLine = (typedLine) => {
1216
+ // Up and Down start again from the newest request after every line.
1217
+ if (reader)
1218
+ reader.historyIndex = -1;
1219
+ const continued = keyboard.takeContinued();
567
1220
  // An answer the agent is waiting for jumps the queue, because it is not a
568
1221
  // request. Queued, it would sit behind the very job that is waiting on it.
569
- if (takeAnswer(line))
1222
+ if (draft.length === 0 && takeAnswer(keyboard.expand(typedLine)))
570
1223
  return;
1224
+ // A request on more than one line (manual 6.2). Held until a line ends
1225
+ // without a continuation, then sent once with its line breaks.
1226
+ if (continued || typedLine.endsWith('\\')) {
1227
+ draft.push(continued ? typedLine : typedLine.slice(0, -1));
1228
+ reader?.setPrompt(ui.c.dim(' ... '));
1229
+ reader?.prompt();
1230
+ return;
1231
+ }
1232
+ const shown = [...draft, typedLine].join('\n');
1233
+ draft = [];
1234
+ // Paste labels swapped back for what was pasted (6.3).
1235
+ const line = keyboard.expand(shown);
1236
+ if (line.trim())
1237
+ earlier.add(shown, line);
571
1238
  // Something said while a job is RUNNING goes to that job, not into the
572
1239
  // queue behind it. A person who sees the agent going the wrong way at step
573
1240
  // three had two options before this: stop it and lose everything, or watch
@@ -575,6 +1242,38 @@ async function runRepl() {
575
1242
  // conversation before the next step, which is the earliest moment it can
576
1243
  // change anything.
577
1244
  const said = line.trim();
1245
+ // Paused with Esc: whatever comes next, even an empty Enter, is the answer
1246
+ // to "carry on how" (manual 6.7).
1247
+ if (job?.isPaused()) {
1248
+ job.carryOn(said);
1249
+ return;
1250
+ }
1251
+ // `queue`, `queue clear` and `transcript` while it runs are for the
1252
+ // session, not notes for the agent (6.8, 6.9).
1253
+ if (job && /^\/?queue(\s+clear)?$/i.test(said)) {
1254
+ job.queue(/clear$/i.test(said));
1255
+ return;
1256
+ }
1257
+ if (job && /^\/?transcript$/i.test(said)) {
1258
+ void showTranscript(job.jobId());
1259
+ return;
1260
+ }
1261
+ if (job && processWord(said))
1262
+ return;
1263
+ // `agents` and `stop h2` while helpers run (manual 5.25).
1264
+ if (job && /^\/?agents$/i.test(said)) {
1265
+ const running = job.helpers();
1266
+ if (!running.length)
1267
+ out(ui.c.dim(' No helpers are running.'));
1268
+ for (const h of running)
1269
+ out(` ${ui.c.bold(h.id)} ${h.agent ? `${h.agent}: ` : ''}${h.question}`);
1270
+ return;
1271
+ }
1272
+ const helperStop = /^\/?stop\s+(h\d+)$/i.exec(said);
1273
+ if (job && helperStop) {
1274
+ job.stopHelper(helperStop[1].toLowerCase());
1275
+ return;
1276
+ }
578
1277
  if (steerJob && said) {
579
1278
  // The session says nothing back. `steer` already prints its own
580
1279
  // acknowledgement, and two lines for one thing typed reads as a stutter.
@@ -588,13 +1287,319 @@ async function runRepl() {
588
1287
  // existed printed nothing, and the session looked dead on arrival.
589
1288
  takeTerminal();
590
1289
  prompt();
1290
+ // Shift+Tab moves to the next permission mode (manual 4.5). Only at the
1291
+ // prompt: during a job the change would apply from the next job anyway, and
1292
+ // redrawing over a running job's output is unreadable.
1293
+ keys().on('keypress', (_s, key) => {
1294
+ if (!key || key.name !== 'tab' || !key.shift || working || answering || leaving)
1295
+ return;
1296
+ const next = MODES[(MODES.indexOf(steering_.mode) + 1) % MODES.length];
1297
+ steering_.mode = next;
1298
+ out();
1299
+ out(ui.c.dim(` mode ${ui.c.bold(next)}, from the next job`));
1300
+ prompt();
1301
+ });
591
1302
  async function handle(line) {
592
1303
  const typed = line.trim();
593
1304
  if (!typed)
594
1305
  return prompt();
595
- const { word, slashed } = commandWord(typed);
1306
+ const { word, slashed: slashWord } = commandWord(typed);
1307
+ // A dragged file starting with `/` is a path, not a slashed command
1308
+ // (manual 3.3). Only when the whole first word is something on the disk.
1309
+ const slashed = slashWord && !attach.startsWithPath(typed);
1310
+ // Where the session works (manual 2.1 to 2.3). Read off the typed line, not
1311
+ // `word`, because a path keeps its case.
1312
+ const bare = typed.replace(/^\//, '').replace(/^comprism\s+/i, '').trim();
1313
+ // The instructions in force, and a note for this project (manual 2.8, 2.9).
1314
+ if (/^instructions$/i.test(bare)) {
1315
+ const { cmdInstructions } = await Promise.resolve().then(() => __importStar(require('./instructions')));
1316
+ await withTerminal(async () => { await cmdInstructions([]); });
1317
+ return prompt();
1318
+ }
1319
+ const remember = /^remember\s+(.+)$/i.exec(bare);
1320
+ if (remember) {
1321
+ const { addNote } = await Promise.resolve().then(() => __importStar(require('./memory')));
1322
+ await addNote(remember[1]);
1323
+ out();
1324
+ return prompt();
1325
+ }
1326
+ const where = /^(project|workspace)(?:\s+(.+))?$/i.exec(bare);
1327
+ const addFolder = /^folders\s+add\s+(.+)$/i.exec(bare);
1328
+ if (where || addFolder || /^folders$/i.test(bare)) {
1329
+ const { checkFolder, resolveProject } = await Promise.resolve().then(() => __importStar(require('../lib/project')));
1330
+ const named = where ? where[2] : addFolder ? addFolder[1] : undefined;
1331
+ if (named) {
1332
+ const found = checkFolder(named.trim());
1333
+ if (!found.ok) {
1334
+ out(ui.fail(found.why));
1335
+ out();
1336
+ return prompt();
1337
+ }
1338
+ if (where) {
1339
+ place.folder = found.path;
1340
+ (await Promise.resolve().then(() => __importStar(require('../lib/project-ops')))).useSessionFolder(found.path);
1341
+ }
1342
+ else if (!place.added.includes(found.path))
1343
+ place.added.push(found.path);
1344
+ }
1345
+ if (/^folders$/i.test(bare)) {
1346
+ const facts = resolveProject(await sessionFolder(), place.added);
1347
+ out();
1348
+ out(` ${ui.c.bold(facts.root)} ${ui.c.dim('project')}`);
1349
+ for (const f of facts.added)
1350
+ out(` ${ui.c.bold(f)} ${ui.c.dim('added for this session')}`);
1351
+ out();
1352
+ return prompt();
1353
+ }
1354
+ out();
1355
+ await showProjectLine();
1356
+ // Another folder has other skills.
1357
+ await refreshList();
1358
+ return prompt();
1359
+ }
1360
+ // `pin <model>` and `effort <level>` (manual 4.17, 4.18), kept by the
1361
+ // service for this session, so the browser shows the same pin.
1362
+ const pinTo = /^pin\s+(\S+)$/.exec(word);
1363
+ if (pinTo) {
1364
+ const said = await steer({ pin: pinTo[1] });
1365
+ if (said)
1366
+ out(ui.c.dim(` pinned ${ui.c.bold(said.pin_label ?? steering_.pin)} for the next requests. \`unpin\` hands the choice back.`));
1367
+ out();
1368
+ return prompt();
1369
+ }
1370
+ const effortTo = /^effort\s+(\S+)$/.exec(word);
1371
+ if (effortTo) {
1372
+ const level = effortTo[1];
1373
+ if (!['low', 'medium', 'normal', 'high'].includes(level))
1374
+ out(ui.fail('Effort is low, normal or high.'));
1375
+ else {
1376
+ const said = await steer({ effort: level === 'medium' ? 'normal' : level });
1377
+ if (said) {
1378
+ out(ui.c.dim(` effort ${ui.c.bold(said.effort)} for the next requests`));
1379
+ if (said.effort_note)
1380
+ out(ui.c.dim(` ${said.effort_note}`));
1381
+ }
1382
+ }
1383
+ out();
1384
+ return prompt();
1385
+ }
1386
+ // `mode <name>` and `plan off` (manual 4.1, 4.5).
1387
+ const modeTo = /^mode\s+(\S+)$/.exec(word);
1388
+ if (modeTo) {
1389
+ const named = modeTo[1];
1390
+ if (!MODES.includes(named)) {
1391
+ out(ui.fail(`${named} is not a permission mode. One of: ${MODES.join(', ')}`));
1392
+ }
1393
+ else {
1394
+ steering_.mode = named;
1395
+ out(ui.c.dim(` mode ${ui.c.bold(named)}, from the next job`));
1396
+ }
1397
+ out();
1398
+ return prompt();
1399
+ }
1400
+ if (word === 'plan off') {
1401
+ steering_.plan = false;
1402
+ out(ui.c.dim(' planning off: the next requests are carried out'));
1403
+ out();
1404
+ return prompt();
1405
+ }
1406
+ if (processWord(word))
1407
+ return prompt();
1408
+ if (/^\/?agents$/i.test(word)) {
1409
+ await showHelpers();
1410
+ return prompt();
1411
+ }
1412
+ if (/^\/?stop\s+h\d+$/i.test(word)) {
1413
+ out(ui.c.dim(' No job is running, so there are no helpers to stop.'));
1414
+ out();
1415
+ return prompt();
1416
+ }
1417
+ // `output 3`: a command's whole output, kept on this machine (5.12).
1418
+ const outputOf = /^output(?:\s+(\d+))?$/.exec(word);
1419
+ if (outputOf) {
1420
+ const { keptOutput } = await Promise.resolve().then(() => __importStar(require('../executor/shell')));
1421
+ const kept = outputOf[1] ? keptOutput(Number(outputOf[1])) : undefined;
1422
+ if (!outputOf[1])
1423
+ out(ui.c.dim(' output N shows the whole output of command N, the number on its line.'));
1424
+ else if (!kept)
1425
+ out(ui.fail(`No output ${outputOf[1]} is kept in this session.`));
1426
+ else {
1427
+ out(ui.c.dim(` output ${outputOf[1]}: ${kept.command}`));
1428
+ out(kept.text.replace(/\n$/, '') || ui.c.dim(' (no output)'));
1429
+ }
1430
+ out();
1431
+ return prompt();
1432
+ }
1433
+ // `detach 2` takes the second waiting file off the list (manual 3.6).
1434
+ const detach = /^detach\s+(\d+)$/.exec(word);
1435
+ if (detach) {
1436
+ const n = Number(detach[1]);
1437
+ const gone = pending[n - 1];
1438
+ if (!gone) {
1439
+ out(ui.fail(pending.length === 0
1440
+ ? 'Nothing is waiting to be attached.'
1441
+ : `There is no attachment ${n}. Type \`attached\` for the list.`));
1442
+ }
1443
+ else {
1444
+ pending = pending.filter((_, i) => i !== n - 1);
1445
+ out(ui.c.dim(` detached ${gone.filename}`));
1446
+ }
1447
+ out();
1448
+ return prompt();
1449
+ }
1450
+ // `changes`, `diff` and `rewind`, with what follows them (manual 7.1 to 7.6).
1451
+ const reviewWord = /^(changes|diff|rewind)(?:\s+(.*))?$/.exec(word);
1452
+ if (reviewWord) {
1453
+ const w = reviewWord[1];
1454
+ const rest = reviewWord[2] ?? '';
1455
+ // What the agent changed in this session, its diff, and putting it
1456
+ // back, files and conversation (manual 7.1 to 7.6).
1457
+ if (w === 'changes' || w === 'diff') {
1458
+ const done = session.jobs.filter((j) => j.jobId);
1459
+ if (!done.length) {
1460
+ out(ui.c.dim(' Nothing has run in this session yet.'));
1461
+ out();
1462
+ return prompt();
1463
+ }
1464
+ const { cmdChanges, cmdDiff } = await Promise.resolve().then(() => __importStar(require('./review')));
1465
+ for (const j of done) {
1466
+ if (w === 'changes')
1467
+ await cmdChanges([j.jobId]);
1468
+ else
1469
+ await cmdDiff([j.jobId, ...rest.split(/\s+/).filter(Boolean)]);
1470
+ }
1471
+ return prompt();
1472
+ }
1473
+ if (w === 'rewind') {
1474
+ const done = session.jobs.filter((j) => j.jobId);
1475
+ if (!done.length) {
1476
+ out(ui.c.dim(' Nothing has run in this session yet, so there is nothing to rewind.'));
1477
+ out();
1478
+ return prompt();
1479
+ }
1480
+ const conversation = /--conversation|--both/.test(rest);
1481
+ const files = !/--conversation/.test(rest) || /--both/.test(rest);
1482
+ out();
1483
+ out(` ${conversation ? 'Your requests in this session' : 'The steps of this session'}:`);
1484
+ done.forEach((j, i) => out(` ${i + 1} ${j.request.slice(0, 70)}`));
1485
+ const picked = await askLine(` Go back to before which request? [1 to ${done.length}, Enter for ${done.length}] `);
1486
+ if (picked === null) {
1487
+ out();
1488
+ return prompt();
1489
+ }
1490
+ const n = picked.trim() ? Number(picked.trim()) : done.length;
1491
+ if (!Number.isInteger(n) || n < 1 || n > done.length) {
1492
+ out(ui.fail(`Choose a number from 1 to ${done.length}.`));
1493
+ out();
1494
+ return prompt();
1495
+ }
1496
+ if (files) {
1497
+ // The files of every job from that request on, latest first.
1498
+ const { cmdRewind } = await Promise.resolve().then(() => __importStar(require('./review')));
1499
+ const yesNo = async (q) => /^y(es)?$/i.test(((await askLine(q)) ?? '').trim());
1500
+ for (const j of done.slice(n - 1).reverse())
1501
+ await cmdRewind([j.jobId], yesNo);
1502
+ }
1503
+ if (conversation) {
1504
+ const { sessions: ask8, failure } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
1505
+ const before = done[n - 2]?.jobId ?? '';
1506
+ const reply = await ask8('branch', { key: session.serverId, at_job: before });
1507
+ if (!reply.ok || !reply.sessions?.session_id) {
1508
+ out(ui.fail(failure(reply)));
1509
+ out();
1510
+ return prompt();
1511
+ }
1512
+ summarize(session);
1513
+ const kept = done.slice(0, n - 1);
1514
+ session = reopened(reply.sessions);
1515
+ session.jobs = kept.map((j) => ({ ...j }));
1516
+ history.push(session);
1517
+ out(ui.ok(`The conversation is back to before request ${n}, as a new branch.`));
1518
+ for (const line of reply.sessions.lines)
1519
+ out(line);
1520
+ out(ui.c.dim(' The original, with everything it cost, is kept and can be reopened.'));
1521
+ out();
1522
+ }
1523
+ return prompt();
1524
+ }
1525
+ return prompt();
1526
+ }
596
1527
  if (COMMANDS.has(word)) {
597
1528
  switch (word) {
1529
+ case 'plan':
1530
+ steering_.plan = true;
1531
+ out(ui.c.dim(' planning on: the next requests are planned first and change nothing. `plan off` stops.'));
1532
+ out();
1533
+ break;
1534
+ case 'mode':
1535
+ out(` ${ui.c.dim('mode')} ${ui.c.bold(steering_.mode)}${steering_.plan ? ui.c.dim(', planning first') : ''}`);
1536
+ out(ui.c.dim(` one of ${MODES.join(', ')}. \`mode <name>\` or Shift+Tab changes it.`));
1537
+ out();
1538
+ break;
1539
+ case 'pin': {
1540
+ const said = await steer({});
1541
+ out(said?.pin
1542
+ ? ` ${ui.c.dim('pinned')} ${ui.c.bold(said.pin_label ?? said.pin)}`
1543
+ : ui.c.dim(' Nothing is pinned; the service chooses the model for each step. `pin <model>` pins one.'));
1544
+ out();
1545
+ break;
1546
+ }
1547
+ case 'unpin':
1548
+ if (await steer({ pin: '' }))
1549
+ out(ui.c.dim(' unpinned: the service chooses the model for each step again'));
1550
+ out();
1551
+ break;
1552
+ case 'effort': {
1553
+ const said = await steer({});
1554
+ out(ui.c.dim(said ? ` effort ${said.effort}` : ' effort not known.'));
1555
+ if (said?.effort_note)
1556
+ out(ui.c.dim(` ${said.effort_note}`));
1557
+ out();
1558
+ break;
1559
+ }
1560
+ case 'why':
1561
+ if (!session.why || session.why.length === 0)
1562
+ out(ui.c.dim(' No step has run in this session yet.'));
1563
+ else
1564
+ for (const line of session.why)
1565
+ out(line);
1566
+ out();
1567
+ break;
1568
+ case 'todo':
1569
+ if (!session.todos || session.todos.length === 0)
1570
+ out(ui.c.dim(' No checklist yet.'));
1571
+ else
1572
+ out((0, render_1.todos)(session.todos).trimEnd());
1573
+ out();
1574
+ break;
1575
+ case 'edit':
1576
+ // Returns without the redraw below: the editor puts the text back at
1577
+ // the prompt itself.
1578
+ return editRequest('');
1579
+ case 'context':
1580
+ await contextOf('context', session.lastJobId, '', session.serverId || session.id);
1581
+ break;
1582
+ case 'compact':
1583
+ await contextOf('compact', session.lastJobId);
1584
+ break;
1585
+ case 'queue':
1586
+ // Between jobs nothing can be waiting: a note typed now is a request.
1587
+ out(ui.c.dim(' No notes are waiting. Notes typed while a job runs join it at its next step.'));
1588
+ out();
1589
+ break;
1590
+ case 'transcript':
1591
+ await showTranscript(session.lastJobId);
1592
+ break;
1593
+ case 'paste':
1594
+ await pasteImage();
1595
+ break;
1596
+ case 'attached':
1597
+ if (pending.length === 0)
1598
+ out(ui.c.dim(' Nothing is waiting to be attached.'));
1599
+ for (const row of attach.listing(pending))
1600
+ out(` ${row}`);
1601
+ out();
1602
+ break;
598
1603
  case 'exit':
599
1604
  case 'quit':
600
1605
  return leave();
@@ -602,13 +1607,43 @@ async function runRepl() {
602
1607
  process.stdout.write('\x1b[2J\x1b[H');
603
1608
  break;
604
1609
  case 'help':
1610
+ // Asked again, so a skill saved a minute ago is on the list.
1611
+ await refreshList();
605
1612
  printHelp();
606
1613
  break;
1614
+ // A new session from a copy of this one's conversation (manual 8.6).
1615
+ case 'branch': {
1616
+ if (session.jobs.length === 0) {
1617
+ out(ui.c.dim(' Nothing has run in this session yet, so there is nothing to branch.'));
1618
+ out();
1619
+ break;
1620
+ }
1621
+ const { sessions: ask8, failure } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
1622
+ const reply = await ask8('branch', { key: session.serverId });
1623
+ if (!reply.ok || !reply.sessions?.session_id) {
1624
+ out(ui.fail(failure(reply)));
1625
+ out();
1626
+ break;
1627
+ }
1628
+ summarize(session);
1629
+ session = reopened(reply.sessions);
1630
+ history.push(session);
1631
+ for (const line of reply.sessions.lines)
1632
+ out(line);
1633
+ out();
1634
+ break;
1635
+ }
1636
+ case 'remote': {
1637
+ await remoteOn(session);
1638
+ break;
1639
+ }
607
1640
  case 'new': {
1641
+ await remoteOff(session, true);
608
1642
  summarize(session);
609
1643
  session = newSession();
610
1644
  history.push(session);
611
- out(ui.ok(`New session ${ui.c.bold(session.id)}.`));
1645
+ const line = await numbered(session);
1646
+ out(line ? ui.c.bold(line) : ui.ok('New session.'));
612
1647
  out();
613
1648
  break;
614
1649
  }
@@ -616,20 +1651,22 @@ async function runRepl() {
616
1651
  out();
617
1652
  history.forEach((s, i) => {
618
1653
  const here = s === session ? ui.c.green(' now') : ' ';
1654
+ // The NAME where our server has made one, and the id beside it
1655
+ // rather than instead of it: the id is what a person types back to
1656
+ // reopen the session, so it can never be replaced by the name.
1657
+ const named = s.title ? ` ${ui.c.text(s.title)}` : '';
619
1658
  out(`${here} ${String(i + 1).padStart(2)}. ${ui.c.bold(s.id)}`
620
- + ` ${s.jobs.length} job(s) ${money(s.spentUsd)}`);
1659
+ + ` ${s.jobs.length} job(s) ${money(s.spentUsd)}${named}`);
621
1660
  });
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();
1661
+ // And what this account did before this window was opened, from
1662
+ // the service's record of sessions (manual 8.5). Kept numbered
1663
+ // separately so a number never means two different things.
1664
+ recent = await earlierSessions(history);
625
1665
  if (recent.length > 0) {
626
1666
  out();
627
1667
  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)}`));
1668
+ recent.forEach((r, i) => {
1669
+ out(` ${ui.c.bold(`w${i + 1}`.padStart(3))}. ${r.line.trimStart()}`);
633
1670
  });
634
1671
  }
635
1672
  out();
@@ -643,6 +1680,24 @@ async function runRepl() {
643
1680
  await cmdStatus();
644
1681
  break;
645
1682
  }
1683
+ // The session being stood in, unless an id is typed after it. Asking
1684
+ // the server for "the newest" here would answer about a different
1685
+ // session the moment two are open, and this one is on the screen.
1686
+ case 'cost':
1687
+ case 'spend': {
1688
+ // Nothing sent is nothing charged, and the service has no record of
1689
+ // a session before its first job, so it would answer "no such
1690
+ // session" to a person checking that a refusal cost them nothing
1691
+ // (manual 6.1).
1692
+ if (session.jobs.length === 0) {
1693
+ out(` ${ui.c.dim('session')} ${ui.c.bold(session.id)} ${ui.c.dim('nothing has run yet, so it has cost')} $0.00`);
1694
+ out();
1695
+ break;
1696
+ }
1697
+ const { cmdCost } = await Promise.resolve().then(() => __importStar(require('./cost')));
1698
+ await cmdCost([session.id]);
1699
+ break;
1700
+ }
646
1701
  case 'model': {
647
1702
  const { cmdModel } = await Promise.resolve().then(() => __importStar(require('./settings')));
648
1703
  await withTerminal(() => cmdModel([]));
@@ -660,11 +1715,6 @@ async function runRepl() {
660
1715
  case 'keys':
661
1716
  await withTerminal(() => (0, keys_1.cmdKeys)([]));
662
1717
  break;
663
- case 'workspace': {
664
- const { cmdWorkspace } = await Promise.resolve().then(() => __importStar(require('./settings')));
665
- await withTerminal(() => cmdWorkspace([]));
666
- break;
667
- }
668
1718
  // Signing out ends the session, because everything after it would be
669
1719
  // refused anyway. It used to answer "not available inside a session",
670
1720
  // which left quitting and running a second command as the only way.
@@ -681,6 +1731,11 @@ async function runRepl() {
681
1731
  }
682
1732
  return prompt();
683
1733
  }
1734
+ // `remote off` ends the link (manual 8.11).
1735
+ if (/^\/?remote\s+off$/.test(word)) {
1736
+ await remoteOff(session);
1737
+ return prompt();
1738
+ }
684
1739
  // `sessions 2` reopens the second one from this run.
685
1740
  const reopen = /^sessions\s+(\d+)$/.exec(word);
686
1741
  if (reopen) {
@@ -696,6 +1751,22 @@ async function runRepl() {
696
1751
  out();
697
1752
  return prompt();
698
1753
  }
1754
+ // `compact keep the requirement that ...` keeps those words exactly (6.15).
1755
+ // Read off `typed`, because what to keep keeps its case.
1756
+ const compactKeep = /^\/?compact\s+(.+)$/is.exec(typed);
1757
+ if (compactKeep) {
1758
+ await contextOf('compact', session.lastJobId, compactKeep[1].replace(/^keep\s+/i, '').trim());
1759
+ return prompt();
1760
+ }
1761
+ // `cost <session>` breaks down a session other than the one being stood in.
1762
+ // Read off `typed` rather than `word`, because `word` is lowercased so that
1763
+ // command matching is forgiving, and a session id is not a word.
1764
+ const costOf = /^\/?(?:cost|spend)\s+(\S+)$/i.exec(typed);
1765
+ if (costOf) {
1766
+ const { cmdCost } = await Promise.resolve().then(() => __importStar(require('./cost')));
1767
+ await cmdCost([costOf[1]]);
1768
+ return prompt();
1769
+ }
699
1770
  // `sessions w1` picks up work from before this window was opened. A new
700
1771
  // session is started rather than an old one edited: what it inherits is the
701
1772
  // conversation, not the spending, and adding yesterday's cost to today's
@@ -703,27 +1774,37 @@ async function runRepl() {
703
1774
  const carryOn = /^sessions\s+w(\d+)$/.exec(word);
704
1775
  if (carryOn) {
705
1776
  if (recent.length === 0)
706
- recent = await pastWork();
1777
+ recent = await earlierSessions(history);
707
1778
  const target = recent[Number(carryOn[1]) - 1];
708
1779
  if (!target) {
709
- out(ui.fail(`There is no earlier job w${carryOn[1]}. Type \`sessions\` for the list.`));
1780
+ out(ui.fail(`There is no earlier session w${carryOn[1]}. Type \`sessions\` for the list.`));
1781
+ out();
1782
+ return prompt();
710
1783
  }
711
- else {
712
- summarize(session);
713
- session = newSession();
714
- session.lastJobId = target.id;
1784
+ // The same session, resumed: its conversation and its running cost carry
1785
+ // on, and it never becomes a new session that has lost them (8.5).
1786
+ summarize(session);
1787
+ const found = await opening({ resume: target.number });
1788
+ if (found) {
1789
+ session = found;
715
1790
  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
1791
  }
720
- out();
721
1792
  return prompt();
722
1793
  }
723
1794
  // A slash means "this is a command", so an unknown one is a typo and not a
724
1795
  // question. Sending it on would answer the typo and charge for the answer,
725
1796
  // which is exactly what `/model` used to do.
726
- if (slashed) {
1797
+ // A skill or saved command on the service's list is work for the agent,
1798
+ // slash and all: the service expands it (chapter 9).
1799
+ const isListed = () => Boolean(listed?.ok && listed.items.some((i) => i.source !== 'session' && i.name === word.split(/\s+/)[0]));
1800
+ let offered = isListed();
1801
+ if (slashed && !offered) {
1802
+ // A skill written since the list was read is found by asking again,
1803
+ // once, before the word is called a typo (manual 9.1, 9.2).
1804
+ await refreshList();
1805
+ offered = isListed();
1806
+ }
1807
+ if (slashed && !offered) {
727
1808
  out(ui.fail(`\`${typed}\` is not a command, so nothing ran and nothing was charged.`));
728
1809
  out(ui.c.dim(' Type `help` for the list, or ask without a slash to have work done.'));
729
1810
  out();
@@ -738,14 +1819,22 @@ async function runRepl() {
738
1819
  // act, so the second path was never worth the confusion it caused.
739
1820
  const explicit = /^work\s+(.+)$/i.exec(typed);
740
1821
  const asked = explicit ? explicit[1] : typed;
741
- const { text, ids } = await gather(asked);
1822
+ const { text, ids, leftOut, send } = await gather(asked);
1823
+ if (!send)
1824
+ return prompt();
1825
+ // Sent with this request, so no longer waiting for the next one.
1826
+ pending = [];
742
1827
  try {
743
- await build(session, text || asked, confirm, (controls) => { cancelJob = controls.cancel; steerJob = controls.steer; }, ids);
1828
+ await build(session, text || asked, confirm, (controls) => { job = controls; cancelJob = controls.cancel; steerJob = controls.steer; }, ids, leftOut, askLine);
744
1829
  }
745
1830
  finally {
1831
+ job = null;
746
1832
  cancelJob = null;
747
1833
  steerJob = null;
748
1834
  }
1835
+ // A note that arrived after the last step is the next request (6.8).
1836
+ queue.unshift(...view.late);
1837
+ view.late = [];
749
1838
  prompt();
750
1839
  }
751
1840
  await finished;