@illuminis/comprism 0.1.3 → 0.1.5

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