@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
@@ -60,10 +60,32 @@ exports.TerminalSession = void 0;
60
60
  * front. Both are the same session; the difference is who answers.
61
61
  */
62
62
  const gateway_1 = require("../lib/gateway");
63
+ const fs = __importStar(require("fs"));
64
+ const path = __importStar(require("path"));
63
65
  const readline = __importStar(require("readline"));
64
66
  const executor_1 = require("../executor");
67
+ const project_1 = require("../lib/project");
65
68
  const ui = __importStar(require("./render"));
69
+ const jobs_1 = require("../commands/jobs");
70
+ /** The actions that change a file, whose change is shown as it is made. */
71
+ const CHANGES = new Set(["edit_file", "multi_edit", "write_file"]);
72
+ /** Files with changes in Git as the job starts: the person's, not the agent's. */
73
+ function dirtyFiles(root) {
74
+ try {
75
+ const { spawnSync } = require("child_process");
76
+ const r = spawnSync("git", ["status", "--porcelain", "-uall"], { cwd: root, encoding: "utf8", timeout: 5000 });
77
+ if (r.status !== 0)
78
+ return [];
79
+ return String(r.stdout).split("\n").filter(Boolean).map((l) => l.slice(3).split(" -> ").pop() ?? "")
80
+ .filter(Boolean).slice(0, 500);
81
+ }
82
+ catch {
83
+ return [];
84
+ }
85
+ }
66
86
  class TerminalSession {
87
+ /** The VS Code window this terminal is inside, when there is one (VS Code manual 9.12). */
88
+ editor = null;
67
89
  socket = null;
68
90
  executor;
69
91
  opts;
@@ -80,12 +102,13 @@ class TerminalSession {
80
102
  this.out = write;
81
103
  this.executor = new executor_1.NativeExecutor({
82
104
  root: opts.root,
105
+ added: opts.project?.added ?? [],
83
106
  // Streamed straight through while a command runs. A build that takes two
84
107
  // minutes must not look like a hang, and the output is the only thing
85
108
  // that proves it is not.
86
109
  onOutput: (chunk) => {
87
110
  if (!opts.headless)
88
- this.out(ui.gray(chunk));
111
+ this.stream(chunk);
89
112
  },
90
113
  });
91
114
  }
@@ -126,8 +149,18 @@ class TerminalSession {
126
149
  // include coding and an allowance that has been spent have different
127
150
  // fixes, and a caller told only "refused" looks in the wrong place.
128
151
  this.refusal = reply.message || "your workspace could not be reached";
152
+ // The reason word, for the exit number (Appendix B): the service's own
153
+ // when it answered, else what the missing answer means.
154
+ this.refusalReason = reply.reason
155
+ ?? (reply.httpStatus === 401 ? "signed_out"
156
+ : reply.ok || reply.httpStatus ? "incomplete" : "unreachable");
157
+ this.refusalResult = reply.result ?? undefined;
158
+ this.refusalTable = reply.exit_status ?? undefined;
129
159
  return null;
130
160
  }
161
+ this.jobId = reply.job.job_id;
162
+ if (process.env.COMPRISM_JOB_LOG && this.jobId)
163
+ (0, jobs_1.notePointer)(this.jobId, process.env.COMPRISM_JOB_LOG);
131
164
  return { ticket: reply.job.ticket, jobId: reply.job.job_id };
132
165
  }
133
166
  /** Where the stream lives, derived from the workspace URL.
@@ -154,8 +187,13 @@ class TerminalSession {
154
187
  project() {
155
188
  return this.opts.root.split("/").filter(Boolean).pop() || this.opts.root;
156
189
  }
190
+ /** Connections lost during this job, so reconnecting gives up in the end. */
191
+ drops = 0;
157
192
  /** Why the one service would not start this job, in its own words. */
158
193
  refusal = null;
194
+ refusalReason;
195
+ refusalResult;
196
+ refusalTable;
159
197
  /** The workspace does not carry the permission question yet, so fall back. */
160
198
  olderWorkspace = false;
161
199
  /** Set while one is actually running, so two never overlap. */
@@ -237,7 +275,7 @@ class TerminalSession {
237
275
  async run(request) {
238
276
  const result = {
239
277
  outcome: "failed", steps: 0, actions: 0, totalUsd: 0, savedUsd: null,
240
- receipt: "", models: [], text: "",
278
+ receipt: "", models: [], text: "", todos: [], lateNotes: [],
241
279
  };
242
280
  // ── the map of this project, brought up to date first ──────────────────
243
281
  //
@@ -286,6 +324,17 @@ class TerminalSession {
286
324
  // Permission first, from the one service. The stream opens only once the
287
325
  // job has been authorized there, so this socket carries no decision of its
288
326
  // own and the workspace credential is no longer the thing that opens it.
327
+ // Joined to VS Code when run in its terminal (VS Code manual 9.12): the
328
+ // window's selection goes with the request, and proposed changes open in
329
+ // its diff view. CompletionPrism's own connection; no other tool's.
330
+ if (!this.opts.headless) {
331
+ const { findEditor, selectionText } = await Promise.resolve().then(() => __importStar(require("../lib/ide")));
332
+ this.editor = findEditor(this.opts.root);
333
+ if (this.editor) {
334
+ this.out(ui.dim(" Connected to VS Code\n"));
335
+ request += selectionText(await this.editor.context());
336
+ }
337
+ }
289
338
  const granted = await this.pass(request);
290
339
  if (this.olderWorkspace) {
291
340
  // Straight on to the stream with the credential, silently. A person does
@@ -298,181 +347,632 @@ class TerminalSession {
298
347
  // service's own wording, because a plan that does not include coding and
299
348
  // an allowance that has been spent have different fixes.
300
349
  result.detail = this.refusal;
350
+ result.refusalReason = this.refusalReason;
351
+ if (this.refusalResult)
352
+ result.result = this.refusalResult;
353
+ if (this.refusalTable)
354
+ result.exitStatus = this.refusalTable;
355
+ this.out(ui.red(` ${this.refusal}\n`));
301
356
  return result;
302
357
  }
358
+ // What this machine did for the job, by action id: received, then done
359
+ // with its result. Sent back after a dropped connection so the service can
360
+ // match it, which is how nothing is ever done twice (manual 8.9).
361
+ const journal = new Map();
362
+ let finished = false;
303
363
  await new Promise((resolve, reject) => {
304
- const socket = new WebSocket(this.socketUrl(granted?.ticket));
305
- this.socket = socket;
306
- let ready = false;
307
- socket.onopen = () => {
308
- socket.send(JSON.stringify({
309
- type: "hello",
310
- client: this.opts.headless ? "cli-headless" : "cli",
311
- capabilities: this.executor.capabilities,
312
- }));
313
- };
314
- socket.onmessage = async (event) => {
315
- let frame;
316
- try {
317
- frame = JSON.parse(String(event.data));
318
- }
319
- catch {
320
- return;
321
- }
322
- if (frame.type === "ready" && !ready) {
323
- ready = true;
324
- this.announce(frame);
364
+ const open = (ticket, first) => {
365
+ const socket = new WebSocket(this.socketUrl(ticket));
366
+ this.socket = socket;
367
+ let ready = false;
368
+ socket.onopen = () => {
325
369
  socket.send(JSON.stringify({
326
- type: this.opts.resumeJobId ? "resume" : "run",
327
- job_id: this.opts.resumeJobId,
328
- continues_job: this.opts.continuesJob,
329
- request,
330
- workspace: this.opts.root.split("/").pop(),
331
- workspace_source: "local",
332
- workspace_root: this.opts.root,
370
+ type: "hello",
333
371
  client: this.opts.headless ? "cli-headless" : "cli",
334
- permission_mode: this.opts.permissionMode ?? "approve_writes",
335
- plan_only: Boolean(this.opts.planOnly),
336
- plan_approved: Boolean(this.opts.planApproved),
337
- model: this.opts.model,
338
- pin_model: Boolean(this.opts.pinModel),
339
- max_spend_usd: this.opts.maxSpendUsd,
340
- approved_commands: this.opts.approvedCommands ?? [],
341
- attachments: this.opts.attachmentIds ?? [],
342
- without_graph: Boolean(this.opts.withoutMap),
372
+ // `ask_user` says a person is here to answer questions (manual
373
+ // 4.3). Unattended, it is left out and the service tells the agent to
374
+ // choose the safest option instead.
375
+ // `reattach`: this tool reconnects after a drop, so the service
376
+ // holds the job for it rather than ending it (manual 8.9).
377
+ capabilities: this.opts.headless
378
+ ? [...this.executor.capabilities, "reattach"]
379
+ : [...this.executor.capabilities, "ask_user", "reattach"],
343
380
  }));
344
- return;
345
- }
346
- if (frame.type === "tool_call") {
347
- const r = await this.executor.execute(String(frame.name), frame.input ?? {});
348
- // THE MAP IS CAUGHT UP BEFORE THE RESULT GOES BACK.
381
+ };
382
+ socket.onmessage = async (event) => {
383
+ let frame;
384
+ try {
385
+ frame = JSON.parse(String(event.data));
386
+ }
387
+ catch {
388
+ return;
389
+ }
390
+ if (frame.type === "ready" && !ready) {
391
+ ready = true;
392
+ if (!first) {
393
+ // Back after a drop: what this machine did, for the service to match.
394
+ socket.send(JSON.stringify({
395
+ type: "reattach", job_id: this.jobId, journal: [...journal.values()],
396
+ }));
397
+ return;
398
+ }
399
+ this.announce(frame);
400
+ socket.send(JSON.stringify({
401
+ type: this.opts.resumeJobId ? "resume" : "run",
402
+ job_id: this.opts.resumeJobId,
403
+ continues_job: this.opts.continuesJob,
404
+ conversation_id: this.opts.conversationId,
405
+ request,
406
+ workspace: this.opts.root.split("/").pop(),
407
+ workspace_source: "local",
408
+ workspace_root: this.opts.root,
409
+ ...(this.opts.project ? (0, project_1.asWire)(this.opts.project) : {}),
410
+ client: process.env.COMPRISM_BACKGROUND === "1" ? "cli-background"
411
+ : this.opts.headless ? "cli-headless" : "cli",
412
+ // Absent when not chosen: the service takes it from the settings.
413
+ permission_mode: this.opts.permissionMode,
414
+ plan_only: Boolean(this.opts.planOnly),
415
+ plan_approved: Boolean(this.opts.planApproved),
416
+ model: this.opts.model,
417
+ pin_model: Boolean(this.opts.pinModel),
418
+ max_spend_usd: this.opts.maxSpendUsd,
419
+ ...(this.opts.maxSteps ? { max_steps: this.opts.maxSteps } : {}),
420
+ ...(this.opts.maxMinutes ? { max_minutes: this.opts.maxMinutes } : {}),
421
+ ...(this.opts.effort ? { effort: this.opts.effort } : {}),
422
+ sandbox: Boolean(this.opts.sandbox),
423
+ // Which sandbox this machine can offer, so the service can turn it
424
+ // on from settings too, or refuse when it cannot (manual 4.12).
425
+ sandbox_system: this.sandboxSystem(),
426
+ command_timeout_s: this.opts.commandTimeoutS,
427
+ ...(this.opts.issue ? { issue: this.opts.issue } : {}),
428
+ ...(this.opts.team ? { team: this.opts.team } : {}),
429
+ ...(this.opts.review ? { review: this.opts.review } : {}),
430
+ approved_commands: this.opts.approvedCommands ?? [],
431
+ attachments: this.opts.attachmentIds ?? [],
432
+ left_out: this.opts.leftOut ?? [],
433
+ without_graph: Boolean(this.opts.withoutMap),
434
+ ...(this.opts.answerSchema ? { answer_schema: this.opts.answerSchema } : {}),
435
+ ...(this.opts.settingsConfig ? { settings_config: this.opts.settingsConfig } : {}),
436
+ ...(this.opts.noPersonal ? { no_personal: true } : {}),
437
+ ...(this.opts.queuedJob ? { queued_job: this.opts.queuedJob } : {}),
438
+ // Files already changed before the job, so they are never counted
439
+ // as the agent's (manual 7.1).
440
+ dirty_files: dirtyFiles(this.opts.root),
441
+ // Which system runs the commands, so the agent writes for its shell
442
+ // (manual 1.2): PowerShell on Windows.
443
+ machine_os: process.platform,
444
+ }));
445
+ return;
446
+ }
447
+ if (frame.type === "tool_call") {
448
+ const id = String(frame.id);
449
+ const seen = journal.get(id);
450
+ if (seen?.state === "done") {
451
+ // Sent again after a drop, but already carried out here: the
452
+ // result is sent, the action is not repeated.
453
+ this.socket?.send(JSON.stringify({ type: "tool_result", id, ...seen.result }));
454
+ return;
455
+ }
456
+ if (seen)
457
+ return;
458
+ const input = frame.input ?? {};
459
+ const target = String(input.path ?? input.file_path ?? input.command ?? "");
460
+ // An approved action is checked against what the person approved
461
+ // immediately before it runs: the same action, the same arguments,
462
+ // the same folder (manual 4.8). Anything changed does not run.
463
+ const approved = this.approvedCalls.get(id);
464
+ if (approved !== undefined && approved !== this.fingerprint(String(frame.name), input)) {
465
+ const refused = {
466
+ content: "This action changed after it was approved (a different command, "
467
+ + "argument or folder), so it did not run. Ask again for the new one.",
468
+ is_error: true, summary: "changed after approval; not run",
469
+ };
470
+ this.socket?.send(JSON.stringify({ type: "tool_result", id, ...refused }));
471
+ return;
472
+ }
473
+ journal.set(id, { id, name: String(frame.name), target, state: "running" });
474
+ // The hooks the service named for this action (manual 10.9).
475
+ const plan = frame.hooks && typeof frame.hooks === "object"
476
+ ? frame.hooks : undefined;
477
+ const r = await this.executor.execute(String(frame.name), input, plan);
478
+ // THE MAP IS CAUGHT UP BEFORE THE RESULT GOES BACK.
479
+ //
480
+ // Before, not after, and that ordering is the whole of it. The server
481
+ // runs a step's actions one after another, so an agent that writes a
482
+ // file and then asks the map about it does both inside one step. A
483
+ // refresh started after this result is sent has not finished when the
484
+ // question runs, and the agent is told the thing it just wrote does
485
+ // not exist. Measured on a real job before this line existed.
486
+ if (!r.isError)
487
+ await this.noteChanged(String(frame.name), frame.input);
488
+ const answer = {
489
+ content: r.content, is_error: Boolean(r.isError), summary: r.summary,
490
+ facts: r.facts,
491
+ };
492
+ journal.set(id, { id, name: String(frame.name), target, state: "done", result: answer });
493
+ // On whichever connection is open now. If none is, the journal holds
494
+ // the result until the service asks for it.
495
+ if (this.socket?.readyState === WebSocket.OPEN) {
496
+ this.socket.send(JSON.stringify({ type: "tool_result", id, ...answer }));
497
+ }
498
+ return;
499
+ }
500
+ if (frame.type === "approval_ask") {
501
+ const callId = String(frame.call_id ?? frame.action_id ?? "");
502
+ this.asked.add(callId);
503
+ const input = frame.input ?? {};
504
+ // Answered in the browser while this terminal waits (web manual 6.15):
505
+ // the service already has the answer, so none is sent from here.
506
+ const remote = new Promise((resolve) => {
507
+ this.answeredElsewhere.set(String(frame.id), () => resolve("remote"));
508
+ });
509
+ if (this.editor && ["write_file", "edit_file", "multi_edit"].includes(String(frame.name))) {
510
+ void this.editor.diff(String(frame.name), input);
511
+ }
512
+ const local = this.ask(String(frame.name), input, String(frame.prompt ?? ""));
513
+ const first = await Promise.race([local, remote]);
514
+ this.answeredElsewhere.delete(String(frame.id));
515
+ if (first === "remote")
516
+ return;
517
+ const answer = first;
518
+ // The words go to the service, which decides what they mean (4.7 to
519
+ // 4.9). `granted` is kept for a service that reads only yes or no.
520
+ const word = (answer ?? "").trim().toLowerCase();
521
+ const granted = word === "y" || word === "yes" || word === "a";
522
+ if (granted)
523
+ this.approvedCalls.set(callId, this.fingerprint(String(frame.name), input));
524
+ this.socket?.send(JSON.stringify(answer === null
525
+ ? { type: "approval_reply", id: frame.id, granted: false, cancelled: true } // american-english-allowed: the service's field name
526
+ : { type: "approval_reply", id: frame.id, granted, answer,
527
+ // Nobody at the keyboard: the service records it as an approval
528
+ // nobody could give (manual 10.1, exit 4).
529
+ ...(this.opts.headless ? { unattended: true } : {}) }));
530
+ if (answer === null)
531
+ this.cancel();
532
+ return;
533
+ }
534
+ if (frame.type === "question") {
535
+ const answer = await this.question(frame);
536
+ this.socket?.send(JSON.stringify(answer === null
537
+ ? { type: "question_reply", id: frame.id, cancelled: true } // american-english-allowed: the service's field name
538
+ : { type: "question_reply", id: frame.id, answer }));
539
+ if (answer === null)
540
+ this.cancel();
541
+ return;
542
+ }
543
+ if (frame.type === "event" && frame.kind === "approval_answered") {
544
+ const done = this.answeredElsewhere.get(String(frame.id));
545
+ if (done) {
546
+ this.out(ui.dim(`\n ${String(frame.line ?? "answered in the browser")}\n`));
547
+ done();
548
+ }
549
+ return;
550
+ }
551
+ if (frame.type === "event" && frame.kind === "sandbox") {
552
+ // The service turned the sandbox on for this job: every command from
553
+ // here runs inside it, with its allowed addresses (manual 4.12).
554
+ const { Sandbox } = await Promise.resolve().then(() => __importStar(require("../executor/sandbox")));
555
+ const system = this.sandboxSystem();
556
+ if (system) {
557
+ const box = new Sandbox(system, this.opts.root, this.opts.project?.added ?? []);
558
+ box.setAllowed(Array.isArray(frame.allow) ? frame.allow : []);
559
+ await box.start();
560
+ this.executor.sandbox = box;
561
+ }
562
+ this.out(ui.yellow(` ${String(frame.line ?? "")}`) + "\n");
563
+ return;
564
+ }
565
+ if (frame.type === "event") {
566
+ // The published stream lines the service attached (manual 10.4).
567
+ if (this.opts.onStream && Array.isArray(frame.stream) && frame.stream.length) {
568
+ this.opts.onStream(frame.stream);
569
+ }
570
+ // The session's name, pushed by the service as soon as it exists, is
571
+ // shown as the terminal window's title, where it never interrupts
572
+ // the answer being printed.
573
+ if (frame.kind === "session_named" && typeof frame.title === "string"
574
+ && frame.title && process.stdout.isTTY) {
575
+ process.stdout.write(`\x1b]0;${frame.title.replace(/[\x00-\x1f\x7f]/g, "")}\x07`);
576
+ }
577
+ if (frame.kind === "job_opened" && frame.job_id) {
578
+ this.jobId = String(frame.job_id);
579
+ // A background job notes where its log is, for `jobs attach` (5.14).
580
+ if (process.env.COMPRISM_JOB_LOG)
581
+ (0, jobs_1.notePointer)(this.jobId, process.env.COMPRISM_JOB_LOG);
582
+ }
583
+ // The service recorded this action, so it is settled either way.
584
+ if (frame.kind === "action_finished" && frame.id) {
585
+ const j = journal.get(String(frame.id));
586
+ if (j)
587
+ j.confirmed = true;
588
+ }
589
+ if (frame.kind === "reconciled") {
590
+ // The service's own report of what was matched (manual 8.9).
591
+ for (const line of frame.lines ?? []) {
592
+ this.out(` ${line}\n`);
593
+ }
594
+ this.out("\n");
595
+ return;
596
+ }
597
+ this.narrate(frame);
598
+ return;
599
+ }
600
+ if (frame.type === "done") {
601
+ finished = true;
602
+ if (this.opts.onStream && Array.isArray(frame.stream)) {
603
+ this.opts.onStream(frame.stream);
604
+ }
605
+ result.outcome = String(frame.outcome ?? "failed");
606
+ result.detail = frame.detail ?? null;
607
+ result.jobId = frame.job_id;
608
+ result.steps = Number(frame.steps ?? 0);
609
+ result.actions = Number(frame.actions ?? 0);
610
+ result.totalUsd = Number(frame.total_usd ?? 0);
611
+ // The saving is the server's arithmetic, taken as it is sent. A
612
+ // client that subtracted its own would disagree with the stored
613
+ // receipt within a release, and the stored one is the record.
614
+ {
615
+ const economics = frame.economics;
616
+ const saved = economics?.saved_usd;
617
+ result.savedUsd = typeof saved === "number" ? saved : null;
618
+ const rows = economics?.models;
619
+ result.models = Array.isArray(rows) ? rows : [];
620
+ }
621
+ // The sentence the server wrote. Everything a reader is told about
622
+ // cost and saving comes from here.
623
+ result.receipt = String(frame.receipt ?? "");
624
+ result.reusedPct = ui.reusePct(0, frame.cached_tokens, Math.max(0, Number(frame.context_tokens ?? 0) - Number(frame.cached_tokens ?? 0)));
625
+ result.text = String(frame.text ?? "");
626
+ result.why = this.lastWhy;
627
+ result.todos = Array.isArray(frame.todos)
628
+ ? frame.todos : [];
629
+ if (frame.result && typeof frame.result === "object") {
630
+ result.result = frame.result;
631
+ }
632
+ if (frame.exit_status && typeof frame.exit_status === "object") {
633
+ result.exitStatus = frame.exit_status;
634
+ }
635
+ result.lateNotes = Array.isArray(frame.late_notes)
636
+ ? frame.late_notes.map(String) : [];
637
+ {
638
+ const ending = frame.ending;
639
+ if (ending && typeof ending === "object") {
640
+ result.ending = {
641
+ line: typeof ending.line === "string" ? ending.line : null,
642
+ proof: Array.isArray(ending.proof) ? ending.proof.map(String) : [],
643
+ exit: typeof ending.exit === "number" ? ending.exit : null,
644
+ ...(typeof ending.reason === "string" ? { reason: ending.reason } : {}),
645
+ };
646
+ result.testsPassed = typeof ending.tests_passed === "boolean"
647
+ ? ending.tests_passed : undefined;
648
+ }
649
+ else {
650
+ result.testsPassed = this.executor.lastTestsPassed;
651
+ }
652
+ }
653
+ // What the agent itself changed goes back into the map before the
654
+ // next job asks anything. Without this the second job of a session
655
+ // works from a picture of the code as it was before the first job
656
+ // edited it, which is the exact failure the freshness rule exists to
657
+ // prevent, and the most likely one: the agent that made the map stale
658
+ // is the agent about to trust it.
659
+ void this.remap();
660
+ socket.close();
661
+ return;
662
+ }
663
+ if (frame.type === "error") {
664
+ this.out(ui.red(`\n ${String(frame.message ?? "Something went wrong.")}\n`));
665
+ }
666
+ };
667
+ socket.onerror = () => {
668
+ // Say what actually failed.
349
669
  //
350
- // Before, not after, and that ordering is the whole of it. The server
351
- // runs a step's actions one after another, so an agent that writes a
352
- // file and then asks the map about it does both inside one step. A
353
- // refresh started after this result is sent has not finished when the
354
- // question runs, and the agent is told the thing it just wrote does
355
- // not exist. Measured on a real job before this line existed.
356
- if (!r.isError)
357
- await this.noteChanged(String(frame.name), frame.input);
358
- socket.send(JSON.stringify({
359
- type: "tool_result", id: frame.id,
360
- content: r.content, is_error: Boolean(r.isError), summary: r.summary,
361
- }));
362
- return;
363
- }
364
- if (frame.type === "approval_ask") {
365
- const granted = await this.ask(String(frame.name), frame.input ?? {});
366
- socket.send(JSON.stringify({ type: "approval_reply", id: frame.id, granted }));
367
- return;
368
- }
369
- if (frame.type === "event") {
370
- this.narrate(frame);
371
- return;
372
- }
373
- if (frame.type === "done") {
374
- result.outcome = String(frame.outcome ?? "failed");
375
- result.detail = frame.detail ?? null;
376
- result.jobId = frame.job_id;
377
- result.steps = Number(frame.steps ?? 0);
378
- result.actions = Number(frame.actions ?? 0);
379
- result.totalUsd = Number(frame.total_usd ?? 0);
380
- // The saving is the server's arithmetic, taken as it is sent. A
381
- // client that subtracted its own would disagree with the stored
382
- // receipt within a release, and the stored one is the record.
383
- {
384
- const economics = frame.economics;
385
- const saved = economics?.saved_usd;
386
- result.savedUsd = typeof saved === "number" ? saved : null;
387
- const rows = economics?.models;
388
- result.models = Array.isArray(rows) ? rows : [];
670
+ // This used to read "Check you are signed in", on a screen that had
671
+ // just printed the person's own email, their company and their plan,
672
+ // all of which came from the very server it claimed it could not reach.
673
+ // Being told to do the one thing you have visibly already done sends
674
+ // somebody to sign in again, which fixes nothing and costs an evening.
675
+ //
676
+ // By the time this fires the credential has already been accepted: the
677
+ // job was authorized and the server issued a pass for it. What failed
678
+ // is the live connection that carries the work, and the wording names
679
+ // that instead.
680
+ if (!ready && first) {
681
+ reject(new Error("The live connection to your workspace could not be opened. "
682
+ + "You are signed in; the work stream is what failed."));
389
683
  }
390
- // The sentence the server wrote. Everything a reader is told about
391
- // cost and saving comes from here.
392
- result.receipt = String(frame.receipt ?? "");
393
- result.reusedPct = ui.reusePct(0, frame.cached_tokens, Math.max(0, Number(frame.context_tokens ?? 0) - Number(frame.cached_tokens ?? 0)));
394
- result.text = String(frame.text ?? "");
395
- result.testsPassed = this.executor.lastTestsPassed;
396
- // What the agent itself changed goes back into the map before the
397
- // next job asks anything. Without this the second job of a session
398
- // works from a picture of the code as it was before the first job
399
- // edited it, which is the exact failure the freshness rule exists to
400
- // prevent, and the most likely one: the agent that made the map stale
401
- // is the agent about to trust it.
402
- void this.remap();
403
- socket.close();
404
- return;
405
- }
406
- if (frame.type === "error") {
407
- this.out(ui.red(`\n ${String(frame.message ?? "Something went wrong.")}\n`));
408
- }
409
- };
410
- socket.onerror = () => {
411
- // Say what actually failed.
412
- //
413
- // This used to read "Check you are signed in", on a screen that had
414
- // just printed the person's own email, their company and their plan,
415
- // all of which came from the very server it claimed it could not reach.
416
- // Being told to do the one thing you have visibly already done sends
417
- // somebody to sign in again, which fixes nothing and costs an evening.
418
- //
419
- // By the time this fires the credential has already been accepted: the
420
- // job was authorized and the server issued a pass for it. What failed
421
- // is the live connection that carries the work, and the wording names
422
- // that instead.
423
- if (!ready) {
424
- reject(new Error("The live connection to your workspace could not be opened. "
425
- + "You are signed in; the work stream is what failed."));
426
- }
427
- };
428
- socket.onclose = () => {
429
- (0, executor_1.stopEverything)();
430
- resolve();
684
+ };
685
+ socket.onclose = () => {
686
+ if (socket !== this.socket)
687
+ return;
688
+ if (finished || this.canceled || !this.jobId) {
689
+ // One job on its own is its session, so what it started ends with it
690
+ // and is listed (manual 5.13). Inside a session they carry on.
691
+ if (!this.opts.keepProcesses)
692
+ this.out(ui.stoppedLines((0, executor_1.stopEverything)()));
693
+ // The job's browser closes with it (manual 10.16).
694
+ this.executor.close();
695
+ // Tool servers on this machine end with the job (manual 9.10).
696
+ this.executor.toolServers.stopAll();
697
+ resolve();
698
+ return;
699
+ }
700
+ // Dropped mid job. Nothing running here is stopped: an action already
701
+ // under way finishes and its result waits in the journal.
702
+ this.drops += 1;
703
+ if (first || ready)
704
+ this.out(ui.yellow("\n The connection dropped. Reconnecting...\n"));
705
+ void (this.drops > 6 ? Promise.resolve(null) : this.reconnect()).then((ticket) => {
706
+ if (ticket !== null) {
707
+ open(ticket, false);
708
+ return;
709
+ }
710
+ // The service could not be reached again. What this machine knows,
711
+ // said plainly; nothing is repeated (manual 8.9, exit 7).
712
+ this.out(" The connection dropped and could not be restored.\n");
713
+ for (const j of [...journal.values()].filter((e) => !e.confirmed)) {
714
+ this.out(j.state === "done"
715
+ ? ` done ${j.name} ${j.target} (done here; the service has not confirmed it)\n`
716
+ : ` unknown ${j.name} ${j.target} (it may or may not have finished)\n`);
717
+ }
718
+ this.out(" Not repeated. Check the result, then tell the agent what happened.\n");
719
+ result.outcome = "unknown";
720
+ result.jobId = this.jobId;
721
+ // Nobody can receive a result now, so a command still running here
722
+ // is stopped rather than left to finish unseen.
723
+ (0, executor_1.stopForeground)();
724
+ (0, executor_1.stopEverything)();
725
+ this.executor.toolServers.stopAll();
726
+ resolve();
727
+ });
728
+ };
431
729
  };
730
+ open(granted?.ticket, true);
432
731
  });
433
732
  this.report(result);
434
733
  return result;
435
734
  }
436
- /** Stop the running job. Takes effect mid action, not at the end of the run. */
735
+ /**
736
+ * A pass to rejoin this job after a dropped connection, trying for about
737
+ * half a minute, inside the service's window (manual 8.9). Null when the
738
+ * service could not be reached again, or refused.
739
+ */
740
+ async reconnect() {
741
+ for (const wait of [1000, 2000, 3000, 5000, 8000, 10000]) {
742
+ await new Promise((r) => setTimeout(r, wait));
743
+ if (this.canceled)
744
+ return null;
745
+ const reply = await (0, gateway_1.ask)({ intent: "job_start", prompt: "", resumeJobId: this.jobId });
746
+ if (reply.served === false)
747
+ return undefined;
748
+ if (reply.ok && reply.job?.ticket)
749
+ return reply.job.ticket;
750
+ if (reply.ok && reply.granted === false)
751
+ return null;
752
+ }
753
+ return null;
754
+ }
755
+ /** Stop the running job. Takes effect mid action, not at the end of the run:
756
+ * a command already running on this machine is stopped too (manual 6.7). */
437
757
  cancel() {
438
758
  if (this.canceled)
439
759
  return;
440
760
  this.canceled = true;
761
+ this.paused = false;
441
762
  this.out(ui.yellow("\n stopping...\n"));
442
763
  this.socket?.send(JSON.stringify({ type: "cancel" }));
764
+ (0, executor_1.stopForeground)();
443
765
  }
444
- /** Something typed while it works. Joins the conversation at the next step. */
766
+ /** Something typed while it works. Joins the conversation at the next step;
767
+ * the service says so in the `note` event it sends back (6.8). */
445
768
  steer(text) {
446
769
  this.socket?.send(JSON.stringify({ type: "steer", text }));
447
- this.out(ui.dim(" noted, it will pick that up on the next step\n"));
448
770
  }
771
+ /** Esc: finish the current action, then wait (manual 6.7). */
772
+ pause() {
773
+ if (this.canceled || this.paused)
774
+ return;
775
+ this.paused = true;
776
+ this.socket?.send(JSON.stringify({ type: "pause" }));
777
+ }
778
+ /** Carry on after Esc, with a correction or without one. */
779
+ carryOn(text) {
780
+ this.paused = false;
781
+ this.socket?.send(JSON.stringify({ type: "carry_on", text }));
782
+ }
783
+ /** This job's id, once the service has issued it (`transcript`, 6.9). */
784
+ jobId;
785
+ /** Set from Esc until the job carries on or stops. */
786
+ paused = false;
787
+ /** Helpers running now, from the service's own events (manual 5.25). */
788
+ helpers = new Map();
789
+ /** `stop h2` (5.25): the service stops that one helper. */
790
+ stopHelper(id) {
791
+ this.socket?.send(JSON.stringify({ type: "stop_helper", helper: id }));
792
+ }
793
+ /** `queue` and `queue clear` (6.8): the service answers with the notes. */
794
+ queue(clear) {
795
+ this.socket?.send(JSON.stringify({ type: clear ? "queue_clear" : "queue" }));
796
+ }
797
+ /** Ctrl+O: each action's full input and output, or the short line (6.9). */
798
+ full = false;
449
799
  announce(frame) {
450
800
  const allowance = frame.allowance;
451
- const tools = frame.tools ?? [];
452
- this.out(ui.dim(` ${this.opts.root} ${tools.length} actions available\n`));
801
+ // The project, company and mode are the service's first line, printed when
802
+ // it arrives (manual 2.2). The allowance follows it.
453
803
  if (allowance && allowance.included && !allowance.unlimited) {
454
- this.out(ui.dim(` coding allowance left this month: ${ui.money(Number(allowance.remaining_usd))}\n`));
804
+ this.allowanceLine = ui.dim(` coding allowance left this month: ${ui.money(Number(allowance.remaining_usd))}\n`);
805
+ }
806
+ }
807
+ /** The allowance and plan notes, printed under the first line so it stays first. */
808
+ allowanceLine = "";
809
+ /** A running command's output on screen (manual 5.12): the first lines as
810
+ * they arrive, then held, and at the end either the rest or the last lines
811
+ * with a count of what was left out. How many is the service's number. */
812
+ screen = { limit: 0, edge: 0, shown: 0, held: [], partial: "" };
813
+ stream(chunk) {
814
+ const sc = this.screen;
815
+ if (!sc.limit) {
816
+ this.out(ui.gray(chunk));
817
+ return;
818
+ }
819
+ const parts = (sc.partial + chunk).split("\n");
820
+ sc.partial = parts.pop() ?? "";
821
+ for (const line of parts) {
822
+ if (sc.shown < sc.edge) {
823
+ this.out(ui.gray(`${line}\n`));
824
+ sc.shown += 1;
825
+ }
826
+ else
827
+ sc.held.push(line);
828
+ }
829
+ }
830
+ flushScreen() {
831
+ const sc = this.screen;
832
+ if (!sc.limit)
833
+ return;
834
+ if (sc.partial) {
835
+ sc.held.push(sc.partial);
836
+ sc.partial = "";
837
+ }
838
+ const room = sc.limit - sc.edge;
839
+ const show = sc.held.length <= room ? sc.held : sc.held.slice(-sc.edge);
840
+ const left = sc.held.length - show.length;
841
+ if (left > 0)
842
+ this.out(ui.dim(` ... ${left.toLocaleString()} lines not shown ...\n`));
843
+ for (const line of show)
844
+ this.out(ui.gray(`${line}\n`));
845
+ this.screen = { limit: 0, edge: 0, shown: 0, held: [], partial: "" };
846
+ }
847
+ /** Changes already put in front of the person to approve, so they are not
848
+ * shown twice. Every other change is shown as it is made (5.6 to 5.8). */
849
+ asked = new Set();
850
+ /** The sandbox this machine offers, worked out once (manual 4.12). */
851
+ sandboxChecked;
852
+ sandboxSystem() {
853
+ if (this.sandboxChecked === undefined) {
854
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
855
+ const { availability } = require("../executor/sandbox");
856
+ this.sandboxChecked = availability().system;
455
857
  }
456
- if (this.opts.planOnly)
457
- this.out(ui.yellow(" plan only: nothing will be changed\n"));
458
- if (this.opts.planApproved) {
459
- this.out(ui.dim(" plan approved: file changes will not be asked about one at a time\n"));
858
+ return (this.sandboxChecked ?? null);
859
+ }
860
+ /** The full reason for the last step, as the service wrote it (4.16). */
861
+ lastWhy = [];
862
+ /** An unattended run refused an action that needed approval (Appendix B, 4). */
863
+ refusedUnattended = false;
864
+ /** Approved actions by call, and what exactly was approved (manual 4.8). */
865
+ approvedCalls = new Map();
866
+ /** Approvals waiting here that the browser may answer first (web manual 6.15). */
867
+ answeredElsewhere = new Map();
868
+ /** An action as approved: its name, its arguments and the real folder it
869
+ * runs in, so a changed argument or a moved folder is caught. */
870
+ fingerprint(name, input) {
871
+ const pathMod = path;
872
+ const fsMod = fs;
873
+ let folder = this.opts.root;
874
+ if (typeof input.cwd === "string" && input.cwd)
875
+ folder = pathMod.resolve(this.opts.root, input.cwd);
876
+ try {
877
+ folder = fsMod.realpathSync(folder);
460
878
  }
879
+ catch { /* the folder is gone: still compared */ }
880
+ const sorted = (v) => (v && typeof v === "object" && !Array.isArray(v)
881
+ ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sorted(v[k])]))
882
+ : v);
883
+ return JSON.stringify([name, sorted(input), folder]);
461
884
  }
462
885
  narrate(frame) {
463
886
  switch (frame.kind) {
887
+ // Helpers, in the service's words (manual 5.23, 5.25).
888
+ case "helper_started":
889
+ this.helpers.set(String(frame.id), {
890
+ id: String(frame.id), question: String(frame.question ?? ""),
891
+ agent: frame.agent ?? null,
892
+ });
893
+ this.out(` ${ui.bold(String(frame.id))} ${String(frame.line ?? "")}\n`);
894
+ break;
895
+ case "helper_finished":
896
+ this.helpers.delete(String(frame.id));
897
+ this.out(ui.dim(` ${String(frame.line ?? "")}\n`));
898
+ if (frame.used_line)
899
+ this.out(ui.dim(` ${String(frame.used_line)}\n`));
900
+ break;
901
+ // A skill the job follows, in the service's words (manual 9.2, 9.5).
902
+ case "skill_used":
903
+ case "skill_file":
904
+ if (frame.line)
905
+ this.out((frame.ok === false ? ui.red : ui.dim)(` ${String(frame.line)}\n`));
906
+ break;
907
+ case "team":
908
+ this.out((frame.ok === false ? ui.red : ui.bold)(` ${String(frame.line ?? "")}`) + "\n");
909
+ break;
910
+ case "helper_conflict":
911
+ this.out(ui.red(` ${String(frame.line ?? "")}\n`));
912
+ if (frame.diff)
913
+ this.out(ui.dim(String(frame.diff).split("\n").map((l) => ` ${l}`).join("\n") + "\n"));
914
+ break;
915
+ case "helper_stopping":
916
+ this.out(ui.dim(` ${String(frame.line ?? "")}\n`));
917
+ break;
918
+ case "action_started":
919
+ this.flushScreen();
920
+ if (typeof frame.screen_lines === "number" && typeof frame.screen_edge === "number") {
921
+ this.screen = { limit: frame.screen_lines, edge: frame.screen_edge, shown: 0, held: [], partial: "" };
922
+ }
923
+ break;
924
+ // The first line of the job, exactly as the service wrote it.
925
+ case "project_line":
926
+ this.out(`${ui.bold(String(frame.line ?? ""))}\n`);
927
+ if (this.allowanceLine)
928
+ this.out(this.allowanceLine);
929
+ this.allowanceLine = "";
930
+ break;
931
+ // Planning and plan approval, in the service's words (manual 4.1, 4.2).
932
+ case "planning":
933
+ this.out(ui.yellow(` ${String(frame.line ?? "")}`) + "\n");
934
+ break;
935
+ // A warning the service wants on screen, in its words (manual 4.13).
936
+ case "notice":
937
+ this.out(ui.yellow(` ${String(frame.line ?? "")}`) + "\n");
938
+ break;
939
+ case "plan_approved":
940
+ this.out(ui.dim(` ${String(frame.reason ?? "")}\n`));
941
+ break;
464
942
  case "step_finished": {
465
- this.out(ui.step(Number(frame.index ?? 0), String(frame.model ?? ""), frame.usd, frame.latency_ms, ui.reusePct(frame.tokens_in, frame.cache_read_tokens, frame.cache_write_tokens)) + "\n");
943
+ this.out(ui.step(Number(frame.index ?? 0), String(frame.model ?? ""), frame.usd, frame.latency_ms, ui.reusePct(frame.tokens_in, frame.cache_read_tokens, frame.cache_write_tokens), String(frame.reason ?? "")) + "\n");
944
+ if (Array.isArray(frame.why_lines))
945
+ this.lastWhy = frame.why_lines;
466
946
  const said = String(frame.text ?? "").trim();
467
947
  // Markdown is for a browser. A terminal shows the asterisks.
468
948
  if (said) {
469
- this.out(`\n${ui.plain(said)}\n`);
949
+ // The plan is titled so it reads as the plan (manual 4.1).
950
+ const plain = ui.plain(said).replace(/^\s*plan\s*\n/i, "");
951
+ this.out(frame.plan ? ` ${ui.bold("Plan")}\n${plain}\n` : `\n${ui.plain(said)}\n`);
470
952
  this.lastSaid = said;
471
953
  }
472
954
  break;
473
955
  }
474
956
  case "action_finished":
957
+ this.flushScreen();
475
958
  this.out(ui.action(String(frame.name ?? ""), this.targetOf(frame), frame.ok !== false, frame.latency_ms) + "\n");
959
+ // The change itself, as it is made, unless it was just shown for
960
+ // approval (manual 5.6 to 5.8). A change that changed nothing has
961
+ // nothing to show.
962
+ if (frame.ok !== false && !this.full && CHANGES.has(String(frame.name))
963
+ && !this.asked.has(String(frame.id)) && !/ no change$/.test(String(frame.target ?? ""))) {
964
+ this.out(ui.diff(String(frame.name), frame.input ?? {}, false));
965
+ }
966
+ // The full view: exactly what went in and what came back (6.9).
967
+ if (this.full) {
968
+ this.out(ui.dim(` input ${JSON.stringify(frame.input ?? {})}\n`));
969
+ const got = String(frame.output ?? frame.summary ?? "");
970
+ this.out(ui.dim(` output\n${got.split("\n").map((l) => ` ${l}`).join("\n")}\n`));
971
+ }
972
+ // A refused or failed action says why, with the path (manual 2.4).
973
+ if (frame.ok === false && frame.summary) {
974
+ this.out(ui.dim(` ${String(frame.summary).split("\n")[0]}\n`));
975
+ }
476
976
  break;
477
977
  case "action_refused":
478
978
  this.out(ui.red(` refused ${String(frame.name ?? "")}`)
@@ -503,13 +1003,34 @@ class TerminalSession {
503
1003
  case "compacting":
504
1004
  this.out(ui.dim(" summarizing the conversation so far to keep it affordable\n"));
505
1005
  break;
1006
+ // Notes, pausing and the queue, in the service's words (6.7, 6.8).
506
1007
  case "steered":
1008
+ case "note":
1009
+ case "pausing":
1010
+ case "paused":
1011
+ case "resumed":
1012
+ case "queue":
1013
+ case "queue_clear":
1014
+ if (frame.kind === "paused")
1015
+ this.paused = true;
1016
+ if (frame.kind === "resumed")
1017
+ this.paused = false;
1018
+ if (frame.line)
1019
+ this.out(ui.dim(` ${String(frame.line)}\n`));
1020
+ if (frame.kind === "queue" && Array.isArray(frame.notes)) {
1021
+ for (const n of frame.notes)
1022
+ this.out(ui.dim(` ${String(n.text ?? "")}\n`));
1023
+ }
507
1024
  break;
508
1025
  default:
509
1026
  break;
510
1027
  }
511
1028
  }
512
1029
  targetOf(frame) {
1030
+ // The service's words when it sent them (manual 5.2 to 5.4); an older
1031
+ // service sends none and the path or pattern is shown as before.
1032
+ if (typeof frame.target === "string")
1033
+ return frame.target;
513
1034
  const input = frame.input ?? {};
514
1035
  return String(input.path ?? input.pattern ?? input.command ?? input.to ?? "");
515
1036
  }
@@ -524,7 +1045,7 @@ class TerminalSession {
524
1045
  * nobody ever saw, which is the opposite of what a permission mode is for:
525
1046
  * a script that needs to change files says so by choosing a mode that allows
526
1047
  * it, up front. */
527
- async ask(name, input) {
1048
+ async ask(name, input, prompt = "") {
528
1049
  if (this.opts.headless) {
529
1050
  // Name what was refused. A refusal that does not say WHICH command it
530
1051
  // turned down cannot be acted on: the operator's only remedy is to
@@ -535,22 +1056,73 @@ class TerminalSession {
535
1056
  this.out(ui.yellow(` ${name} needs approval and nobody is watching, so it was refused. `)
536
1057
  + (what ? ui.dim(`Refused: ${what}\n `) : "")
537
1058
  + ui.dim("Re-run with --allow \"<that exact command>\", or choose a permission mode that allows it.\n"));
538
- return false;
1059
+ this.refusedUnattended = true;
1060
+ return "n";
539
1061
  }
540
1062
  this.out(ui.diff(name, input));
541
- if (this.opts.confirm) {
542
- const granted = await this.opts.confirm(`${ui.bold("Apply this?")} ${ui.dim("[y/N]")}`);
543
- this.out(granted ? ui.green(" applied\n") : ui.dim(" skipped\n"));
544
- return granted;
1063
+ // A command shows the folder it will run in (manual 4.7).
1064
+ if (typeof input.command === "string") {
1065
+ const pathMod = path;
1066
+ const folder = typeof input.cwd === "string" && input.cwd
1067
+ ? pathMod.resolve(this.opts.root, input.cwd) : this.opts.root;
1068
+ const home = process.env.HOME || process.env.USERPROFILE || "";
1069
+ this.out(ui.dim(` in ${home && folder.startsWith(home) ? `~${folder.slice(home.length)}` : folder}\n`));
1070
+ }
1071
+ const question = prompt ? ui.bold(prompt) : `${ui.bold("Apply this?")} ${ui.dim("[y/N]")}`;
1072
+ let answer;
1073
+ if (this.opts.prompt) {
1074
+ answer = await this.opts.prompt(question);
545
1075
  }
1076
+ else if (this.opts.confirm) {
1077
+ answer = (await this.opts.confirm(question)) ? "y" : "n";
1078
+ }
1079
+ else {
1080
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1081
+ try {
1082
+ answer = await new Promise((resolve) => {
1083
+ rl.on("SIGINT", () => { this.out("\n"); resolve(null); });
1084
+ rl.question(` ${question} `, (a) => resolve(a));
1085
+ });
1086
+ }
1087
+ finally {
1088
+ rl.close();
1089
+ }
1090
+ }
1091
+ const word = (answer ?? "").trim().toLowerCase();
1092
+ if (answer === null)
1093
+ return null;
1094
+ if (word === "y" || word === "yes")
1095
+ this.out(ui.green(" applied\n"));
1096
+ else if (word === "a")
1097
+ this.out(ui.green(" applied, and yes to exactly this for the rest of the session\n"));
1098
+ else if (word === "" || word === "n" || word === "no")
1099
+ this.out(ui.dim(" skipped\n"));
1100
+ else
1101
+ this.out(ui.dim(" not applied; your words go to the agent\n"));
1102
+ return answer;
1103
+ }
1104
+ /** The plan question or one of the agent's questions (manual 4.2, 4.3).
1105
+ * Returns what the person typed, or null for Ctrl+C. The meaning of the
1106
+ * answer (y, n, a number, words) is decided by the service. */
1107
+ async question(frame) {
1108
+ const kind = String(frame.kind ?? "question");
1109
+ const options = Array.isArray(frame.options) ? frame.options : [];
1110
+ // The trust question lists the project's allow rules as they are (4.10).
1111
+ if (kind === "trust")
1112
+ this.out(`\n ${String(frame.question ?? "").split("\n").join("\n ")}\n`);
1113
+ else if (kind !== "plan")
1114
+ this.out(ui.question(String(frame.question ?? ""), options));
1115
+ const label = String(frame.prompt ?? "");
1116
+ const shown = kind === "plan" ? `${ui.bold(label)}` : label;
1117
+ if (this.opts.prompt)
1118
+ return this.opts.prompt(shown);
546
1119
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
547
1120
  try {
548
- const answer = await new Promise((resolve) => {
549
- rl.question(` ${ui.bold("Apply this?")} ${ui.dim("[y/N]")} `, resolve);
1121
+ return await new Promise((resolve) => {
1122
+ // Ctrl+C at a question stops the job, as it does anywhere else.
1123
+ rl.on("SIGINT", () => { this.out("\n"); resolve(null); });
1124
+ rl.question(` ${shown} `, (a) => resolve(a));
550
1125
  });
551
- const granted = /^y(es)?$/i.test(answer.trim());
552
- this.out(granted ? ui.green(" applied\n") : ui.dim(" skipped\n"));
553
- return granted;
554
1126
  }
555
1127
  finally {
556
1128
  rl.close();
@@ -561,8 +1133,22 @@ class TerminalSession {
561
1133
  // IS the step's text, and printing it again read as a stutter.
562
1134
  if (r.text && r.text.trim() !== this.lastSaid)
563
1135
  this.out(`\n${ui.plain(r.text)}\n`);
564
- this.out(ui.outcome(r.outcome, r.detail));
565
- this.out(ui.receipt(r.receipt, r.models, r.testsPassed));
1136
+ // An item the agent could not finish stays on screen, unticked (4.4).
1137
+ if (r.todos.some((t) => t.status !== "completed"))
1138
+ this.out(ui.todos(r.todos));
1139
+ if (r.ending?.line) {
1140
+ // The service's own words, decided from what ran (manual 5.15).
1141
+ this.out(ui.ending(r.ending.line, r.detail));
1142
+ for (const line of r.ending.proof)
1143
+ this.out(ui.dim(` ${line}\n`));
1144
+ this.out(ui.receipt(r.receipt, r.models));
1145
+ }
1146
+ else {
1147
+ this.out(ui.outcome(r.outcome, r.detail));
1148
+ for (const line of r.ending?.proof ?? [])
1149
+ this.out(ui.dim(` ${line}\n`));
1150
+ this.out(ui.receipt(r.receipt, r.models, r.ending ? undefined : r.testsPassed));
1151
+ }
566
1152
  if (r.jobId) {
567
1153
  // The job's own reference, so a person can quote it. The link to the
568
1154
  // account sits in the receipt above, where the server put it.