allaboard 0.0.0-stage → 0.5.0

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 (141) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +43 -2
  3. package/dist/agent/claude/backend.js +124 -0
  4. package/dist/agent/claude/capabilities.js +30 -0
  5. package/dist/agent/claude/chrome.js +42 -0
  6. package/dist/agent/claude/hooks.js +37 -0
  7. package/dist/agent/claude/info.js +61 -0
  8. package/dist/agent/claude/listing.js +230 -0
  9. package/dist/agent/claude/module.js +82 -0
  10. package/dist/agent/claude/prompt.js +24 -0
  11. package/dist/agent/claude/record.js +9 -0
  12. package/dist/agent/claude/requests.js +122 -0
  13. package/dist/agent/claude/session.js +441 -0
  14. package/dist/agent/claude/translate.js +581 -0
  15. package/dist/agent/claude/trust.js +576 -0
  16. package/dist/agent/claude/usage-probe.js +103 -0
  17. package/dist/agent/claude/usage.js +110 -0
  18. package/dist/agent/claude/wire.js +22 -0
  19. package/dist/agent/seam.js +21 -0
  20. package/dist/chat/seam.js +10 -0
  21. package/dist/chat/slack/app/answers.js +209 -0
  22. package/dist/chat/slack/app/app.js +262 -0
  23. package/dist/chat/slack/app/clip.js +191 -0
  24. package/dist/chat/slack/app/guards.js +162 -0
  25. package/dist/chat/slack/app/home-controls.js +91 -0
  26. package/dist/chat/slack/app/messages.js +181 -0
  27. package/dist/chat/slack/app/open.js +392 -0
  28. package/dist/chat/slack/app/order.js +37 -0
  29. package/dist/chat/slack/app/requests.js +482 -0
  30. package/dist/chat/slack/app/resume-bind.js +71 -0
  31. package/dist/chat/slack/app/wire.js +83 -0
  32. package/dist/chat/slack/attachments.js +348 -0
  33. package/dist/chat/slack/bind.js +67 -0
  34. package/dist/chat/slack/channel.js +67 -0
  35. package/dist/chat/slack/channels.js +43 -0
  36. package/dist/chat/slack/delete.js +278 -0
  37. package/dist/chat/slack/footer.js +43 -0
  38. package/dist/chat/slack/hold.js +37 -0
  39. package/dist/chat/slack/home.js +1069 -0
  40. package/dist/chat/slack/module.js +174 -0
  41. package/dist/chat/slack/notice.js +64 -0
  42. package/dist/chat/slack/openfile/modal.js +362 -0
  43. package/dist/chat/slack/openfile/read.js +29 -0
  44. package/dist/chat/slack/quiet-logger.js +52 -0
  45. package/dist/chat/slack/repair.js +123 -0
  46. package/dist/chat/slack/reply/blocks.js +372 -0
  47. package/dist/chat/slack/reply/chars.js +59 -0
  48. package/dist/chat/slack/reply/clients.js +171 -0
  49. package/dist/chat/slack/reply/errors.js +125 -0
  50. package/dist/chat/slack/reply/escape.js +25 -0
  51. package/dist/chat/slack/reply/limiter.js +86 -0
  52. package/dist/chat/slack/reply/markdown.js +210 -0
  53. package/dist/chat/slack/reply/places.js +262 -0
  54. package/dist/chat/slack/reply/sinks.js +2403 -0
  55. package/dist/chat/slack/reply/status.js +433 -0
  56. package/dist/chat/slack/reply/tasks.js +11 -0
  57. package/dist/chat/slack/requests.js +552 -0
  58. package/dist/chat/slack/resume.js +127 -0
  59. package/dist/chat/slack/setup.js +114 -0
  60. package/dist/chat/slack/texts.js +185 -0
  61. package/dist/chat/slack/thread.js +537 -0
  62. package/dist/chat/slack/update.js +71 -0
  63. package/dist/chat/slack/voice.js +84 -0
  64. package/dist/cli.js +326 -0
  65. package/dist/clock.js +21 -0
  66. package/dist/core/cleanup.js +145 -0
  67. package/dist/core/commands.js +172 -0
  68. package/dist/core/config.js +174 -0
  69. package/dist/core/files/changed.js +125 -0
  70. package/dist/core/files/listing.js +382 -0
  71. package/dist/core/files/paths.js +9 -0
  72. package/dist/core/files/read.js +191 -0
  73. package/dist/core/files/waiting.js +34 -0
  74. package/dist/core/flows/answers.js +133 -0
  75. package/dist/core/flows/ask-owner.js +95 -0
  76. package/dist/core/flows/asking.js +14 -0
  77. package/dist/core/flows/bind.js +95 -0
  78. package/dist/core/flows/contract.js +1 -0
  79. package/dist/core/flows/flows.js +35 -0
  80. package/dist/core/flows/hold.js +22 -0
  81. package/dist/core/flows/inbound.js +24 -0
  82. package/dist/core/flows/lists.js +21 -0
  83. package/dist/core/flows/messages.js +293 -0
  84. package/dist/core/flows/notices.js +51 -0
  85. package/dist/core/flows/repair.js +106 -0
  86. package/dist/core/flows/resume.js +163 -0
  87. package/dist/core/flows/setup.js +130 -0
  88. package/dist/core/flows/status.js +98 -0
  89. package/dist/core/flows/talk.js +207 -0
  90. package/dist/core/flows/update.js +370 -0
  91. package/dist/core/flows/words.js +171 -0
  92. package/dist/core/folders.js +93 -0
  93. package/dist/core/footer.js +313 -0
  94. package/dist/core/guide.js +50 -0
  95. package/dist/core/hold.js +126 -0
  96. package/dist/core/instance.js +46 -0
  97. package/dist/core/lock.js +412 -0
  98. package/dist/core/models.js +10 -0
  99. package/dist/core/paths.js +75 -0
  100. package/dist/core/port/file-flows.js +1 -0
  101. package/dist/core/port/home.js +1 -0
  102. package/dist/core/port/requests.js +1 -0
  103. package/dist/core/port/threads.js +1 -0
  104. package/dist/core/port/updates.js +1 -0
  105. package/dist/core/port.js +17 -0
  106. package/dist/core/reply/fold.js +175 -0
  107. package/dist/core/reply/previews.js +258 -0
  108. package/dist/core/reply/renderer.js +846 -0
  109. package/dist/core/reply/tail.js +24 -0
  110. package/dist/core/reply/words.js +32 -0
  111. package/dist/core/requests.js +158 -0
  112. package/dist/core/sessions/constants.js +61 -0
  113. package/dist/core/sessions/deps.js +1 -0
  114. package/dist/core/sessions/directory.js +86 -0
  115. package/dist/core/sessions/errors.js +79 -0
  116. package/dist/core/sessions/manager.js +378 -0
  117. package/dist/core/sessions/session.js +3297 -0
  118. package/dist/core/sessions/tasks.js +266 -0
  119. package/dist/core/sessions/turn.js +45 -0
  120. package/dist/core/setup.js +28 -0
  121. package/dist/core/state.js +724 -0
  122. package/dist/core/texts.js +451 -0
  123. package/dist/core/updates.js +332 -0
  124. package/dist/daemon.js +566 -0
  125. package/dist/host/npm.js +134 -0
  126. package/dist/host/program.js +49 -0
  127. package/dist/host/run.js +52 -0
  128. package/dist/host/service/launchd.js +385 -0
  129. package/dist/host/service/listing.js +42 -0
  130. package/dist/host/service/seam.js +1 -0
  131. package/dist/log.js +37 -0
  132. package/dist/main.js +29 -0
  133. package/dist/node-version.js +40 -0
  134. package/dist/registry.js +12 -0
  135. package/dist/semver.js +53 -0
  136. package/dist/text.js +89 -0
  137. package/dist/updates-host.js +113 -0
  138. package/dist/values.js +30 -0
  139. package/dist/version.js +54 -0
  140. package/package.json +32 -4
  141. package/slack-app-manifest.json +50 -0
@@ -0,0 +1,3297 @@
1
+ /**
2
+ * One agent session per chat thread: its agent process, its queue of turns, and the reader that
3
+ * turns the agent's events into replies.
4
+ *
5
+ * The session knows neither library. It starts its agent through the agent seam
6
+ * (`AgentBackend.start`) and reads `SessionEvent`s; it writes its thread through the chat seam
7
+ * (`ThreadChat`). What it keeps in `state.json` for crash repair it writes itself through
8
+ * `StateStore`: the root's status when it asks for one, a request once the chat has named its
9
+ * message, and a reply's open message when the reply says it changed.
10
+ *
11
+ * How waits and cancels are made. One convention, used everywhere in this folder:
12
+ *
13
+ * 1. Concurrent work is a `Task` (`tasks.ts`): the worker that sends one prompt at a time, the
14
+ * reader that follows the agent's events, and the timers (`expireInjectedTurn`,
15
+ * `expireUnreported`, `closeWhenIdle`). A `Task` starts on the next turn of the event loop,
16
+ * never inside the call that made it, and the waiters of an `Event` wake on the next turn
17
+ * too: the code that made them ready reaches a real wait first, and this class sets state
18
+ * right after creating a task and right after releasing a turn (`Turn.done` is set before
19
+ * the turn stops being the active one). `cancel()` aborts the task's `AbortSignal` with
20
+ * `Cancelled`.
21
+ * 2. Every wait a cancel could interrupt takes that signal: `Event.wait`, `Mutex.acquire`,
22
+ * `Queue.get`, `Clock.sleep` (every pause goes through the injected clock), the wait for
23
+ * the agent's next event, and, through `cancellable`, the wait for a call that cannot be
24
+ * cut. The wait then rejects with `Cancelled`, which unwinds through the same `try` and
25
+ * `finally` blocks as any error. A catch-all rethrows `Cancelled` first.
26
+ * 3. A call that has begun is never cut: a write to the chat (the rule of the Slack sink), the
27
+ * agent's start, a git call. The signal only releases whoever waited for it. Where the
28
+ * result of such a call would be lost, an explicit cleanup picks it up: a session whose
29
+ * start was given up is closed when the start returns (`connect`).
30
+ * 4. An aborted signal stays aborted, so a wait in a `finally` that ran after the cancel would
31
+ * be interrupted at once. A `finally` therefore hands on `live(signal)`, which is no signal
32
+ * at all once it has aborted.
33
+ * 5. What a cancelled task leaves to clean up is explicit state plus an explicit call: the
34
+ * request handlers of a closing session are resolved by `Approvals.denyAll` and awaited
35
+ * (`asking`).
36
+ * 6. Where nothing may happen between a check and an act, the stretch is synchronous and
37
+ * marked `(sync)` with the reason. JavaScript yields at every `await`, also on a call that
38
+ * never waits: so around a call that only looks asynchronous (making a reply, showing the
39
+ * running list) there is no `await` at all, and a turn is made the active one before its
40
+ * first write.
41
+ * 7. A reaction is asked for at once and not awaited (`react`): the chat makes the changes in
42
+ * the order they were asked.
43
+ */
44
+ import { basename } from "node:path";
45
+ import { ResumeRefused } from "../../agent/seam.js";
46
+ import { getLogger } from "../../log.js";
47
+ import { strip } from "../../text.js";
48
+ import { errorName } from "../../values.js";
49
+ import { footerFields, formatStatusFields, gitState, sessionTokens, } from "../footer.js";
50
+ import { modelName } from "../models.js";
51
+ import { resolvePath } from "../paths.js";
52
+ import { endedLine, TurnRenderer, taskTitle, } from "../reply/renderer.js";
53
+ import { oneLine, TITLE_LIMIT } from "../reply/words.js";
54
+ import { DENY, toAnswer } from "../requests.js";
55
+ import { DEFAULT } from "../setup.js";
56
+ import * as texts from "../texts.js";
57
+ import { FAILURE_LIMIT, HOLD_MARKER, IDLE_CLOSE_SECONDS, INJECTED_TURN_WAIT, PROCESS_EXITED, STOP_TAIL_WAIT, SUMMARY_IS_END_LINE, TASK_EVENTS, TASK_REPLIES_KEPT, TASK_WORDS, TASKS_KEPT, TURN_EVENTS, } from "./constants.js";
58
+ import { checkDirectory, repositoryBy, terminalLine, trustedBy } from "./directory.js";
59
+ import { BypassUnavailable, DirectoryUnavailable, SessionClosed, SessionGone, StartsBlocked, } from "./errors.js";
60
+ import { Cancelled, cancellable, Event, Mutex, Queue, Task } from "./tasks.js";
61
+ import { notSent, Turn, takenNote } from "./turn.js";
62
+ export const logger = getLogger("allaboard.core.sessions");
63
+ /** The signal for a `finally`: none once it has aborted, since an aborted signal stays aborted. */
64
+ function live(signal) {
65
+ return signal?.aborted ? undefined : signal;
66
+ }
67
+ function isTaskEvent(event) {
68
+ return TASK_EVENTS.has(event.type);
69
+ }
70
+ /**
71
+ * The call an event belongs to: the one a subagent's event runs under, or the one that started
72
+ * a task. Either leads to the reply holding that call when a background subagent works on after
73
+ * its turn.
74
+ */
75
+ function callOf(event) {
76
+ if ("parentCallId" in event)
77
+ return event.parentCallId;
78
+ if ("callId" in event && isTaskEvent(event))
79
+ return event.callId;
80
+ return null;
81
+ }
82
+ export class ThreadSession {
83
+ channelId;
84
+ threadId;
85
+ directory;
86
+ // Resolved once here, not by `SessionManager.workingIn` on every live session for every
87
+ // message (`directory` never changes after this object is built: a rebind only takes effect
88
+ // for a thread not open yet).
89
+ resolved;
90
+ deps;
91
+ // One status on the session's root message, which `threadId` always is, and the line under
92
+ // the thread's last message (issues #83 and #95): both are the chat's to draw.
93
+ thread;
94
+ // Set by `stop()` while it denies pending approvals, and by `read` while it settles the
95
+ // requests of a lost process, so `askOwner`'s own finally does not race its closing ✅ (or
96
+ // the ❌) back to working; `finish` and `abandon` clear it, once that turn's own tail ends,
97
+ // whichever way.
98
+ interrupting = false;
99
+ /** Set once the turn `stop()` interrupted has closed its reply (`stopLanded`). */
100
+ stopTail = null;
101
+ // True from `reactError` until new work starts (`submit`, `startTurn`), so a standing ❌ is
102
+ // never mistaken for idle-and-done. What the chat shows cannot serve this alone: it only
103
+ // changes once the chat has answered, which a quick turn can easily outrun.
104
+ errorStanding = false;
105
+ // The reply the session's latest work wrote into (a turn, a report, a standalone update), while
106
+ // the footer of its end is owed: cleared only once the chat says the footer landed under it.
107
+ // A prompt that could not start wrote no work, and is never named here.
108
+ footerOwedTo = null;
109
+ client = null;
110
+ reader = null;
111
+ worker = null;
112
+ queue = new Queue();
113
+ /** Out of the queue, not sent yet. */
114
+ taken = null;
115
+ sent = [];
116
+ active = null;
117
+ connectLock = new Mutex();
118
+ /** Set once close starts: no client starts after it, and no word stores a setting. */
119
+ closedFlag = false;
120
+ // The predecessor this object replaced at its key (the manager evicts a closed session as
121
+ // soon as closing starts, not once it finishes). Awaited once, inside the connect lock,
122
+ // before this session's own first connect: the agent's process needs real time to flush
123
+ // after EOF, and a resume of the same session id must not start while that is in flight.
124
+ predecessor;
125
+ // Set once close() (or the SessionGone branch) has fully finished: the predecessor signal
126
+ // above, for whoever replaces this session next, and what `closeAll` waits on.
127
+ doneClosing = new Event();
128
+ // Set by the manager that created this session; called once, right when `doneClosing` is
129
+ // set, so a session nobody ever looks up again does not sit in the manager's map forever.
130
+ onClosed = null;
131
+ /** A line of the daemon's own that opens the next reply. */
132
+ notice = null;
133
+ // `expireUnreported`'s own timers only: this session's alone, so `close` can cancel them.
134
+ // What the session only starts and never waits on (a usage refresh, a reaction) is not kept:
135
+ // a usage refresh shares one probe across every session, and is never cancelled.
136
+ expiring = new Set();
137
+ // Approval ids open right now, waiting on the owner's decision (`waitingForOwner`); a hold
138
+ // adds `HOLD_MARKER` here too.
139
+ waiting = new Set();
140
+ // The request handlers in flight (`askOwner`). A close resolves them and waits for each:
141
+ // none may write the root's status after the close has settled it.
142
+ asking = new Set();
143
+ // The starts a caller gave up (its signal aborted) with the process still launching, each as
144
+ // the promise of its disconnect. `close` waits for them before `doneClosing`: the seam's
145
+ // `start` takes no signal, so the process can only be closed once it is there.
146
+ abandoned = new Set();
147
+ /** Task events that arrived between turns, shown in the next turn's reply. */
148
+ held = [];
149
+ // Tasks that outlived their turn, and the reply whose line each one keeps up to date, for
150
+ // as long as the agent's process lives: an agent can report more than once.
151
+ taskReplies = new Map();
152
+ /** The reply each renderer writes, as the session holds it. */
153
+ replyOf = new WeakMap();
154
+ // Replies whose end did not land: no checkmark until the one retry does, a cross if it
155
+ // fails too. Each is settled once more when the session closes.
156
+ unlanded = new Set();
157
+ // What the running turn's reply says when it ends: how many queued messages a restart
158
+ // dropped, since they get no reply of their own.
159
+ endNotes = [];
160
+ // Tasks a subagent started and left running (a command in the background). Their end is told
161
+ // to the subagent, which reports it: the terminal prints no line for it in the conversation
162
+ // (captured 2026-10-14, Claude Code 2.1.296), and neither does a reply.
163
+ ownedBySubagent = new Set();
164
+ /** End lines of agents with no card that ended while a message waited for its turn. */
165
+ endedAhead = [];
166
+ // Each task's type and description, for the list of what still runs and the task's end line.
167
+ tasks = new Map();
168
+ // How many times each task started, kept past its end: a resumed task keeps its id (SDK
169
+ // 0.3.300, `SDKTaskStartedMessage.run_id`), and nothing else says it is one. A reply asks
170
+ // here, so that an agent a message continues gets no second launch line.
171
+ starts = new Map();
172
+ // Ended tasks not yet opened into a report turn's reply: (task id, its formatted end line).
173
+ // The task id finds the reply that started it (the report renders there when that reply is
174
+ // the last thing in the thread).
175
+ ended = [];
176
+ // The end lines the turn that has just begun reports, set aside when the agent starts it. A
177
+ // task that ends after that start is told to the agent in a later turn, and the terminal
178
+ // prints its line there (measured 2026-10-14, Claude Code 2.1.296: of two agents ending 0.3 s
179
+ // apart, the second was notified after the first report turn began, and that turn knew of
180
+ // the first alone).
181
+ opening = null;
182
+ // Tasks whose end arrived without their notification yet, by the clock time it arrived: the
183
+ // notification starts the turn that reports them. The CLI can suppress it (SDK
184
+ // TaskUpdatedMessage docstring), so a stop waits for it only INJECTED_TURN_WAIT.
185
+ unreported = new Map();
186
+ // Tasks `!stop` ended. Claude Code starts no turn to report such a task: its notification
187
+ // stays queued (measured twice on 2026-09-27, Claude Code 2.1.283), so none is awaited.
188
+ stopped = new Set();
189
+ // The replies already logged as ended with no report under them: `closeOut` sets its flag
190
+ // after an await, so two timers waking together would log one reply twice.
191
+ endedUnreported = new WeakSet();
192
+ // Clear while a background notification's own turn is expected or running: the owner's
193
+ // next prompt waits, so the two replies never share a thread.
194
+ settled = new Event();
195
+ injectedExpected = false;
196
+ // The prompt whose replay arrived with no turn running: the turn that starts next is its
197
+ // own (`acknowledge`, `whoseTurn`).
198
+ announced = null;
199
+ // A report turn was awaited when a prompt's own turn started in its place: it is awaited
200
+ // again once that turn ends (`settle`).
201
+ reportDue = false;
202
+ // Tasks that ended inside a running turn, by the reply that shows them, whose notification
203
+ // that turn has not taken (`notification_folded`). One still standing when the turn ends is
204
+ // reported in a turn the agent opens by itself (issue #317): its reply rests with no footer
205
+ // until that turn renders into it, or `INJECTED_TURN_WAIT` passes (`expectUnfoldedReports`).
206
+ // Empty for an agent that does not declare `foldedNotifications`, which counts such a task as
207
+ // reported by the turn it ended in.
208
+ unfolded = new Map();
209
+ /**
210
+ * The replies that rest while the session waits on a task of another reply (`stillOwed`):
211
+ * they own no task, so `taskReplies` does not hold them, and `restingLast` still must find them.
212
+ */
213
+ resting = new Set();
214
+ // A task's notification arrived with no turn running and no report turn has started since:
215
+ // the agent has a report to make (`whoseTurn`).
216
+ notificationWaits = false;
217
+ // The task the agent said its next turn reports (`report_started`): that turn is no prompt's,
218
+ // and it renders in the reply that started the task when that is the last thing in the thread,
219
+ // whether or not the task's end came yet.
220
+ reportOf = null;
221
+ expiry = null;
222
+ /** The agent said it is compacting the conversation and has not said it ended. */
223
+ compacting = false;
224
+ /** What the agent last said it is doing (`state_changed`); null before it said anything. */
225
+ agentState = null;
226
+ /** A turn that ended under `Working…`, not stopped, whose end, ✅ and footer are being drawn. */
227
+ turnEnding = false;
228
+ // Armed while idle with nothing pending; any turn, task event, approval or question cancels
229
+ // it, and being idle with nothing pending again (re)starts it.
230
+ idleExpiry = null;
231
+ // A count, not a flag, since two `submit` calls can overlap. Nonzero makes `idle` false for
232
+ // the span between a `submit` call starting and its turn actually being queued, so the timer
233
+ // is genuinely cancelled there, not reset to fire again mid-await.
234
+ pendingSubmits = 0;
235
+ commands = [];
236
+ /** The agent's own model list, for the setup. */
237
+ models = [];
238
+ // A prompt was ever queued on this object; with no stored session id it tells a thread that
239
+ // never ran a turn from one whose first turn is still starting.
240
+ submitted = false;
241
+ /** `applySetup` changed something that `forgetSetup` has not undone. */
242
+ setupApplied = false;
243
+ /** Held while Start is applied, so a `!bypass` typed meanwhile acts after it. */
244
+ setupLock = new Mutex();
245
+ /** `applySetup` is running: a model command may be in flight in the agent. */
246
+ applying = false;
247
+ // What the live client was started with or switched to: the effort, and whether it
248
+ // effectively runs in bypass (the switch, or the folder's own settings starting it so).
249
+ // `applySetup` compares the owner's choice with these, not with what was stored, which a
250
+ // restart or a `!bypass on` can have moved.
251
+ clientEffort = null;
252
+ clientBypass = false;
253
+ nativeMode;
254
+ // The permission mode the client runs in now, for `!status`: what the connect left it in,
255
+ // then whatever the agent reports (`dispatch`).
256
+ mode;
257
+ cliVersion = null;
258
+ // The level the owner fixed (setup, `/effort`, `/model ... with X effort`, the stored one), or
259
+ // null for the agent's default. Kept apart from `effort`: a default setting still runs at some
260
+ // level, and the footer says both (`auto (medium)`), as the terminal's `/effort` does.
261
+ effortSetting = null;
262
+ // The level the last turn ran at (`effort_observed`), cleared by a command that sets the
263
+ // effort. Null for a model without effort, and until a turn has reported one.
264
+ effort = null;
265
+ // Whether an agent without a word for its default has said anything about the level since the
266
+ // client started (a report or a command): until then it is not known, since the settings do
267
+ // not decide it (measured 2026-09-25).
268
+ effortReported = false;
269
+ // Whether a turn has reported that the model takes no effort (the Stop hook's level is null):
270
+ // the footer then says so whatever the setting. Cleared by anything that may change the model
271
+ // or the level, and by a turn that reports one.
272
+ effortUnsupported = false;
273
+ // The model the agent last said it switched to (`model_changed`), kept until the context
274
+ // usage names it too: after a command the usage still names the model before it. Null when
275
+ // none is awaited.
276
+ modelSwitched = null;
277
+ // Whether that switch came inside a turn: such a turn's usage may still name the model it
278
+ // started on, so the turn's end keeps the switch; the next turn that went to the model drops it.
279
+ modelSwitchedInTurn = false;
280
+ // Where the session works as its hooks last reported (`cwd` follows a `cd` and a worktree;
281
+ // Stop and PostToolUse measured 2026-09-27 on 2.1.283); null until then: `directory`.
282
+ workingDirectory = null;
283
+ /** The session's token count as the client's last result reported it, for `!status`. */
284
+ sessionTokens = null;
285
+ /** Set when the daemon stops: the turns already sent finish, no other one starts. */
286
+ draining = false;
287
+ /**
288
+ * Set while an install replaces allaboard's own copy: this session starts no agent process
289
+ * (`ensureConnected`) and no usage probe, and one already connected goes on.
290
+ */
291
+ startsBlocked = false;
292
+ // Whether this stop has said, in a message, which background tasks it waits for: only where
293
+ // the thread's activity line cannot (`showRestartWait`).
294
+ toldWaiting = false;
295
+ // Set when a stop begins while this session has a turn running. The signal names no sender,
296
+ // and the session that sent it has its turn running then; a background task it starts from
297
+ // that point is often its own wait for the new process, which cannot end before this one
298
+ // exits (issue #87). Those tasks are not waited for: the shutdown ends them with the agent's
299
+ // process. "After" means read after: a task the turn started just before the signal, whose
300
+ // start event the reader had not reached yet, counts as started after it. A single
301
+ // background command that sends the signal and then waits started before it, and is still
302
+ // waited for.
303
+ mayHaveOrderedRestart = false;
304
+ afterRestart = new Set();
305
+ /** The names the texts of this session take from its agent and its chat. */
306
+ names;
307
+ // Tasks whose start the agent had no capability for, by the capability: their later events
308
+ // are ignored with them.
309
+ refusedTasks = new Map();
310
+ constructor(channelId, threadId, directory, deps, options = {}) {
311
+ this.channelId = channelId;
312
+ this.threadId = threadId;
313
+ this.directory = directory;
314
+ this.resolved = resolvePath(directory);
315
+ this.deps = deps;
316
+ this.names = texts.namesOf(deps.agent, deps.chat);
317
+ this.thread = deps.chat.thread({ channel: channelId, thread: threadId });
318
+ this.predecessor = options.predecessor ?? null;
319
+ this.settled.set();
320
+ this.nativeMode = deps.agent.capabilities.permissionModes.default;
321
+ this.mode = this.nativeMode;
322
+ }
323
+ get where() {
324
+ return `${this.channelId}/${this.threadId}`;
325
+ }
326
+ /** The agent's own name for the mode that skips permission requests; null when it has none. */
327
+ get bypassMode() {
328
+ return this.deps.agent.capabilities.permissionModes.bypass;
329
+ }
330
+ /** The account's usage limits; null when the agent reports none (`usageLimits`). */
331
+ get usage() {
332
+ return this.deps.agent.capabilities.usageLimits ? this.deps.usage : null;
333
+ }
334
+ /**
335
+ * Whether a task can outlive its turn, the agent reporting it in a turn of its own
336
+ * (`backgroundTasks`): without it no report is awaited and no task is stopped.
337
+ */
338
+ get background() {
339
+ return this.deps.agent.capabilities.backgroundTasks;
340
+ }
341
+ /** Whether the chat shows a status on the thread's root (`reactions`). */
342
+ get reacts() {
343
+ return this.deps.chat.capabilities.reactions;
344
+ }
345
+ /**
346
+ * The owner's last word on bypass in this thread, as state.json holds it (a restart keeps
347
+ * it): true on, false off, null never chosen.
348
+ */
349
+ get bypassChoice() {
350
+ return this.deps.state.thread(this.channelId, this.threadId)?.bypass ?? null;
351
+ }
352
+ /**
353
+ * Whether a client runs in bypass given `choice`: what the owner chose, else what the agent
354
+ * itself started in (`nativeMode`, as it reported it).
355
+ */
356
+ bypassRuns(choice) {
357
+ return choice ?? (this.bypassMode !== null && this.nativeMode === this.bypassMode);
358
+ }
359
+ /**
360
+ * Whether this thread runs in bypass: the one answer the footer's ⚡, `!status`, the channel
361
+ * list and the setup's checkbox read.
362
+ */
363
+ get bypass() {
364
+ return this.bypassRuns(this.bypassChoice);
365
+ }
366
+ /**
367
+ * The permission mode that means `on`; off is the native mode, or the default one when the
368
+ * process itself started in bypass.
369
+ */
370
+ modeFor(on) {
371
+ const modes = this.deps.agent.capabilities.permissionModes;
372
+ if (on) {
373
+ if (modes.bypass === null)
374
+ throw new BypassUnavailable();
375
+ return modes.bypass;
376
+ }
377
+ return this.nativeMode === modes.bypass ? modes.default : this.nativeMode;
378
+ }
379
+ /**
380
+ * `directory`, resolved once at construction and kept (`SessionManager.workingIn` reads this
381
+ * across sessions, instead of resolving every live session's folder on every message).
382
+ */
383
+ get resolvedDirectory() {
384
+ return this.resolved;
385
+ }
386
+ get busy() {
387
+ return this.active !== null || this.sent.length > 0;
388
+ }
389
+ /** An approval or a question is open in this thread, waiting on the owner's answer. */
390
+ get waitingForOwner() {
391
+ return this.waiting.size > 0;
392
+ }
393
+ /**
394
+ * What the thread's activity line says, empty for nothing, and the same after the app's
395
+ * name. `Working…` while a prompt of the owner's is owed its answer (`promptOwed`), however
396
+ * long its turn takes (issue #83), and while the agent says it works (`state_changed`): a
397
+ * turn it starts to report a task, from before that turn's first event, and the wait for a
398
+ * subagent it left in the background (issue #301), and from a turn's end until its ✅ and
399
+ * footer are drawn (`turnEnding`), so the line gives way to the footer. With neither the
400
+ * line nor the list of what still runs under the thread's last reply, the session has
401
+ * ended. An agent that
402
+ * declares no state shows the line for the owner's prompts alone. `Compacting conversation…`
403
+ * while any turn compacts, in the terminal's words (issue #169). The line never counts what
404
+ * still runs: the list does (issue #295). Nothing while an approval, a question or a hold
405
+ * waits on the owner, while `!stop` winds a turn down, and once the session is closed. A
406
+ * restart waiting on tasks says which.
407
+ */
408
+ threadLine() {
409
+ if (this.closedFlag || this.waitingForOwner || this.interrupting)
410
+ return ["", ""];
411
+ if (this.compacting && (this.busy || this.promptOwed())) {
412
+ return [texts.THREAD_COMPACTING, texts.THREAD_COMPACTING_STATUS];
413
+ }
414
+ if (this.promptOwed() || this.turnEnding) {
415
+ return [texts.THREAD_WORKING, texts.THREAD_WORKING_STATUS];
416
+ }
417
+ const held = this.draining ? this.runningKindsOf({ awaitedOnly: true }) : "";
418
+ if (held) {
419
+ // A stop that only background tasks hold: said here whatever the list shows, since
420
+ // `!stop` is what ends the wait. It comes before the agent's own word, which says
421
+ // `working` all through a subagent left in the background.
422
+ const them = held.startsWith("1 ") && !held.includes(" · ") ? "it" : "them";
423
+ return [
424
+ texts.fill(texts.RESTART_WAITS, { counts: held, them }),
425
+ texts.fill(texts.RESTART_WAITS_STATUS, { counts: held }),
426
+ ];
427
+ }
428
+ if (this.agentState === "working")
429
+ return [texts.THREAD_WORKING, texts.THREAD_WORKING_STATUS];
430
+ return ["", ""];
431
+ }
432
+ /**
433
+ * A prompt of the owner's has no finished answer: it is being submitted, queued, taken by the
434
+ * worker, sent and not yet started, or its turn runs. A prompt leaves `sent` when its turn
435
+ * starts, in the step that sets `active`, so neither alone covers it; `active.turn` is null for
436
+ * a turn the agent started itself.
437
+ */
438
+ promptOwed() {
439
+ return (this.pendingSubmits > 0 ||
440
+ !this.queue.empty ||
441
+ this.taken !== null ||
442
+ this.sent.length > 0 ||
443
+ this.active?.turn != null);
444
+ }
445
+ /**
446
+ * Bring the thread's activity line to `threadLine`, after anything that can change it: it
447
+ * never waits on the chat.
448
+ */
449
+ showThreadStatus() {
450
+ if (!this.deps.chat.capabilities.activityLine)
451
+ return;
452
+ this.thread.showActivity(...this.threadLine());
453
+ }
454
+ /**
455
+ * `1 shell · 2 agents`, the tasks that outlived their turn and still run; empty when none
456
+ * does. The channel-level `!status` shows this beside a session's own state.
457
+ */
458
+ get runningKinds() {
459
+ return this.runningKindsOf();
460
+ }
461
+ /**
462
+ * Nothing running, sent, taken or queued, no `submit` call still on its way to queuing one,
463
+ * and no background task still working: the session can be closed without a loss (closing it
464
+ * ends its agent process). `bind` waits for every thread of a channel to be idle before it
465
+ * stores a new folder.
466
+ */
467
+ get idle() {
468
+ return this.quiet({ awaitedOnly: false });
469
+ }
470
+ /**
471
+ * `idle` as a stop reads it: a background task started after the stop began, in a session
472
+ * that may have sent the signal (`mayHaveOrderedRestart`), does not count.
473
+ */
474
+ get restartReady() {
475
+ return this.quiet({ awaitedOnly: true });
476
+ }
477
+ /**
478
+ * What a stop still waits for in this thread, in words the owner can act on; empty when the
479
+ * thread does not hold it (`SessionManager.drain` reads the same two flags).
480
+ */
481
+ get restartHold() {
482
+ if (this.restartReady && !this.reporting)
483
+ return "";
484
+ if (this.waitingForOwner)
485
+ return texts.RESTART_HOLD_OWNER;
486
+ const held = this.runningKindsOf({ awaitedOnly: true });
487
+ if (held) {
488
+ if (!this.busy)
489
+ return texts.fill(texts.RESTART_HOLD_TASKS, { counts: held });
490
+ }
491
+ else if (!this.busy && this.reporting) {
492
+ return texts.RESTART_HOLD_REPORT;
493
+ }
494
+ return texts.RESTART_HOLD_TURN;
495
+ }
496
+ quiet(options) {
497
+ return (!this.busy &&
498
+ this.pendingSubmits === 0 &&
499
+ !this.runningKindsOf(options) &&
500
+ this.taken === null &&
501
+ this.queue.empty &&
502
+ this.settled.isSet);
503
+ }
504
+ /**
505
+ * Closing has started (`close()`'s first line, a shutdown, the idle close, or the SessionGone
506
+ * branch of `ensureConnected`); the teardown itself may still be running. The manager
507
+ * discards this object rather than hand it out again, from its next lookup; `doneClosing`
508
+ * says when the teardown is over.
509
+ */
510
+ get closed() {
511
+ return this.closedFlag;
512
+ }
513
+ /** Whether the agent's process is up: what a message needs to be sent without starting one. */
514
+ get connected() {
515
+ return this.client !== null;
516
+ }
517
+ /**
518
+ * A task ended less than INJECTED_TURN_WAIT ago and the notification that makes the agent
519
+ * report it has not come yet.
520
+ */
521
+ get reporting() {
522
+ const now = this.deps.clock.time();
523
+ return [...this.unreported.values()].some((ended) => now - ended < INJECTED_TURN_WAIT);
524
+ }
525
+ /**
526
+ * Cancel or (re)arm the idle-close timer for the state right now. The manager calls this
527
+ * wherever a session is handed out (`get`/`open`), synchronously, before any await a caller
528
+ * might do on the way to its own `submit`: otherwise the timer could still fire, and close
529
+ * the session, in the gap between the lookup and the turn actually being queued.
530
+ */
531
+ touch() {
532
+ this.idleTimerCheck();
533
+ }
534
+ /**
535
+ * Mark this thread's "send anyway?" question as waiting on the owner, the same way an
536
+ * approval does (`waitingForOwner` covers both, so the idle-close timer stays off and the
537
+ * root shows ✋, including through any report turn a background task starts while held:
538
+ * `startTurn` reacts waiting, not working, whenever `waitingForOwner` is true). At most one
539
+ * hold is ever open on a thread at once, so a caller of this always pairs it with one
540
+ * `holdEnd`. `react` would otherwise read waiting as "no error stands": a hold is not new
541
+ * work, so a standing ❌ must outlive it (Cancel restores it; Continue's own `submit` clears
542
+ * it, same as any new work).
543
+ */
544
+ holdStart() {
545
+ const standing = this.errorStanding;
546
+ this.waiting.add(HOLD_MARKER);
547
+ this.idleTimerCheck();
548
+ this.react("waiting");
549
+ this.errorStanding = standing;
550
+ }
551
+ /**
552
+ * Undo `holdStart`. `continued`: Continue was chosen, so the status is left alone (the
553
+ * `submit()` right after this returns reacts ⏳ on its own); otherwise (Cancel, `!stop`, a
554
+ * drain) the root reflects the session's live state now, not a snapshot from when the hold
555
+ * started (a report turn during the hold can have changed it in the meantime).
556
+ */
557
+ async holdEnd(options) {
558
+ this.waiting.delete(HOLD_MARKER);
559
+ this.idleTimerCheck();
560
+ if (!options.continued)
561
+ await this.reactAfterHold();
562
+ }
563
+ /**
564
+ * The status a hold not continued restores: none for a thread that never ran a turn (read
565
+ * from the stored session id: an idle close or a restart hands the thread a fresh object
566
+ * with its history intact, so "no client" alone is not "never ran"); otherwise ❌ when an
567
+ * error stands (`holdStart` kept it standing through the hold; this is what restores it,
568
+ * since `holdStart`'s own waiting status already overwrote whatever ❌ was showing), ✅
569
+ * through `reactDoneIfIdle` when idle and no error stands, ⏳ or ✋ (whichever this thread
570
+ * still holds) otherwise.
571
+ */
572
+ async reactAfterHold() {
573
+ const stored = this.deps.state.thread(this.channelId, this.threadId);
574
+ if (stored === null || stored.sessionId === null) {
575
+ this.recordStatus(() => this.deps.state.setStatusPending(this.channelId, this.threadId, null));
576
+ if (this.reacts)
577
+ await this.thread.clearStatus();
578
+ return;
579
+ }
580
+ if (this.errorStanding)
581
+ this.reactError();
582
+ else if (this.idle)
583
+ await this.reactDoneIfIdle();
584
+ else
585
+ this.reactWaitingOrWorking();
586
+ }
587
+ /**
588
+ * A Continue whose `submit()` never ran: `holdEnd({ continued: true })` already dropped the
589
+ * hold and left the ✋ standing, betting on that `submit()` to show ⏳ right after; a caller
590
+ * that instead exits without ever calling it (the manager draining, or `ensureConnected`
591
+ * rejecting) must restore the status itself, or the ✋ stands forever. `error`: ❌, the same
592
+ * a turn that reached the queue and then failed this way gets; otherwise whatever a Cancel
593
+ * would show.
594
+ */
595
+ async reactHoldAbandoned(options) {
596
+ if (options.error) {
597
+ this.reactError();
598
+ return;
599
+ }
600
+ await this.reactAfterHold();
601
+ }
602
+ /**
603
+ * Cancel a hold open in this thread, as the owner's own Cancel would: `stop()` and
604
+ * `SessionManager.drain` both call this (a drain, unlike an approval or a question, never
605
+ * leaves a hold open: nobody could still be typing a reply to a question that names a session
606
+ * about to close). The button message is removed here, silently; whoever waits on the hold
607
+ * tells the owner `Not sent.` once it wakes.
608
+ */
609
+ async cancelHold() {
610
+ const pending = this.deps.holds.cancel(this.channelId, this.threadId);
611
+ if (pending === null)
612
+ return false;
613
+ await this.deleteRequest(pending.messageTs);
614
+ return true;
615
+ }
616
+ /**
617
+ * End every queued turn's reply with `line`, and release whoever waits on them: the turns a
618
+ * thread whose session is gone could not start.
619
+ */
620
+ async failQueued(line) {
621
+ while (!this.queue.empty) {
622
+ try {
623
+ await this.fail(this.queue.getNowait(), line);
624
+ }
625
+ catch {
626
+ // one reply that cannot be ended must not keep the others waiting
627
+ }
628
+ }
629
+ }
630
+ /**
631
+ * A restart will never send the queued turns: release whoever waits on them. They get no
632
+ * reply of their own; one note names them, at the end of the running turn's reply, or, with
633
+ * nothing running, as a message of its own. `error` reacts ❌ (a restart drain dropping a
634
+ * queued turn): a plain `close()` never needs it here, since its own cut-short check already
635
+ * reacts once for the whole close.
636
+ */
637
+ async dropQueued(options = {}) {
638
+ const queued = [];
639
+ while (!this.queue.empty)
640
+ queued.push(this.queue.getNowait());
641
+ await this.drop(queued, texts.BECAUSE_RESTARTED, options);
642
+ }
643
+ /** Release `turns` that will never be sent, and say so once: see `dropQueued`. */
644
+ async drop(turns, because, options = {}) {
645
+ if (turns.length === 0)
646
+ return;
647
+ try {
648
+ if (options.error)
649
+ this.reactError();
650
+ const note = notSent(turns, because);
651
+ if (this.busy)
652
+ this.endNotes.push(note);
653
+ else
654
+ await this.post(note);
655
+ }
656
+ finally {
657
+ for (const turn of turns)
658
+ turn.done.set();
659
+ this.showThreadStatus();
660
+ }
661
+ }
662
+ /** Say, at the end of the reply that just ran, what a restart dropped meanwhile. */
663
+ async feedEndNotes(renderer) {
664
+ const notes = this.endNotes;
665
+ this.endNotes = [];
666
+ for (const note of notes)
667
+ await renderer.feedError(note);
668
+ }
669
+ /**
670
+ * Queue a prompt; its reply starts when the agent has something to show, not before.
671
+ * Rejects with `SessionClosed` if the session closed (an idle close, most likely) between
672
+ * the caller's lookup and this call; the caller retries once, against a freshly looked-up
673
+ * session.
674
+ */
675
+ async submit(prompt) {
676
+ if (this.closedFlag)
677
+ throw new SessionClosed();
678
+ // Counted, not just checked-and-cancelled: `idle` reads false for as long as this stays
679
+ // above zero, so the timer is genuinely cancelled here, not reset only to fire again
680
+ // during the awaits below.
681
+ this.pendingSubmits += 1;
682
+ this.submitted = true;
683
+ let turn;
684
+ try {
685
+ this.idleTimerCheck();
686
+ // (sync) The prompt is queued in the same step it arrived in, ahead of whatever the
687
+ // reader had not read yet. So the reply is awaited here only when making it does wait,
688
+ // which is what the check below is for.
689
+ const made = this.sink();
690
+ turn = new Turn(prompt, made instanceof Promise ? await made : made);
691
+ if (this.closedFlag) {
692
+ // The session closed (an idle close or a restart, or `ensureConnected`'s SessionGone
693
+ // branch, which already drained the queue) while the reply was made: no worker will
694
+ // ever take this turn from the queue, so it is resolved right here instead of left
695
+ // waiting for one that will not come. Only a SessionGone close also removes the
696
+ // thread's own entry: that alone is what tells the two apart, since both leave the
697
+ // session closed the same.
698
+ const gone = this.deps.state.thread(this.channelId, this.threadId) === null;
699
+ await this.fail(turn, gone ? texts.named(texts.SESSION_GONE, this.names) : texts.SESSION_CLOSED, { error: true });
700
+ return turn;
701
+ }
702
+ // (sync) from here to the end: the turn is queued, the worker made and the idle timer
703
+ // read with nothing in between.
704
+ this.queue.putNowait(turn);
705
+ // A submitted turn is working, unless an approval or question already open in this
706
+ // thread still holds it (that state stands until it is answered). New work, so any
707
+ // standing ❌ ends here.
708
+ if (!this.waitingForOwner) {
709
+ this.errorStanding = false;
710
+ this.react("working");
711
+ }
712
+ }
713
+ finally {
714
+ this.pendingSubmits -= 1;
715
+ }
716
+ if (this.worker === null || this.worker.done) {
717
+ this.worker = new Task((signal) => this.work(signal));
718
+ }
719
+ this.idleTimerCheck(); // a queued turn is not idle
720
+ return turn;
721
+ }
722
+ /**
723
+ * The live client, started when there is none. `signal` is the calling task's: a cancel
724
+ * leaves the wait, and a start it gave up is closed when it returns.
725
+ */
726
+ async ensureConnected(signal) {
727
+ const release = await this.connectLock.acquire(signal);
728
+ try {
729
+ if (this.closedFlag)
730
+ throw new SessionClosed();
731
+ if (this.client !== null)
732
+ return this.client;
733
+ // The reader of the process before this one may still be ending (it withdraws the
734
+ // requests that process left, then releases its turns): a process started now would have
735
+ // its turn released by that reader, which ends whatever turn is active when it gets there.
736
+ // Nothing that reader awaits takes this lock, and a reader that has finished settles at once.
737
+ if (this.reader !== null)
738
+ await cancellable(this.reader.settled(), signal);
739
+ if (this.predecessor !== null) {
740
+ // The agent process this thread had before may still be exiting: it needs time to
741
+ // flush the session file after EOF, and starting with the same id to resume before that
742
+ // is done would race it. The Python SDK needed up to about 20 seconds; with the
743
+ // TypeScript SDK 0.3.296 `close()` resolved in 282 ms and a resume of the same id right
744
+ // after it worked (measured 2026-10-10). The wait stays, since it costs nothing then.
745
+ await this.predecessor.wait(signal);
746
+ this.predecessor = null;
747
+ }
748
+ await cancellable(checkDirectory(this.directory, trustedBy(this.deps.agent), this.names), signal);
749
+ // Another process updating allaboard (another instance on the copy, or the terminal's
750
+ // `allaboard update`) replaces the copy this process would start from.
751
+ if (await cancellable(this.updatingElsewhere(), signal))
752
+ throw new StartsBlocked();
753
+ // After the last await before `connect`: a level the owner set meanwhile is the one the
754
+ // process starts with.
755
+ const stored = this.deps.state.thread(this.channelId, this.threadId);
756
+ const sessionId = stored?.sessionId ?? null;
757
+ const effort = stored?.effort ?? null;
758
+ let client;
759
+ // (sync) With the call of `connect` that starts the process: a block raised meanwhile
760
+ // (an install began) must not be passed by a start that checked before it.
761
+ if (this.startsBlocked)
762
+ throw new StartsBlocked();
763
+ try {
764
+ client = await this.connect(sessionId, effort, signal);
765
+ }
766
+ catch (error) {
767
+ if (!(error instanceof ResumeRefused) || sessionId === null)
768
+ throw error;
769
+ // The stored session is gone (its transcript was deleted): the thread cannot continue.
770
+ // Its entry is dropped and the session ends itself here, so no later turn (already
771
+ // queued, or the next one taken by the worker) starts a second, unrecorded session in
772
+ // its place: it fails the same way instead.
773
+ logger.warning(`could not resume the stored session in ${this.where}`);
774
+ this.deps.state.removeThread(this.channelId, this.threadId);
775
+ this.closedFlag = true;
776
+ // No client was ever connected in this branch (`connect` rejected): nothing to wait
777
+ // on, so the "fully closed" signal fires at once, same as `close()`'s tail.
778
+ this.doneClosing.set();
779
+ this.onClosed?.();
780
+ this.idleTimerCheck(); // cancels any armed timer; closed now, so none rearms
781
+ const worker = this.worker;
782
+ if (worker !== null && worker.signal !== signal) {
783
+ // Called directly (`!status`, `!bypass`), not from this session's own worker: an
784
+ // idle worker left blocked on the queue (most likely on this very connect lock,
785
+ // still held here) would never learn to stop otherwise. It is cancelled and awaited
786
+ // first, and its taken turn (if any) rescued only once it is confirmed stopped:
787
+ // cancelling it while it still held a turn, with nothing rescuing that turn, left
788
+ // that reply saying "writing" forever. When the worker IS the caller (the common
789
+ // case: this branch runs inside the worker's own call), its own catch up the stack
790
+ // already fails the taken turn itself; failing it again here too would double the
791
+ // reply's text.
792
+ worker.cancel();
793
+ await worker.settled();
794
+ const taken = this.taken;
795
+ this.taken = null;
796
+ if (taken !== null) {
797
+ try {
798
+ await this.fail(taken, texts.named(texts.SESSION_GONE, this.names), { error: true });
799
+ }
800
+ catch {
801
+ // the queued ones below are still told
802
+ }
803
+ }
804
+ }
805
+ await this.failQueued(texts.named(texts.SESSION_GONE, this.names));
806
+ throw new SessionGone(this.names, { cause: error });
807
+ }
808
+ let choice;
809
+ let mode;
810
+ try {
811
+ const info = await cancellable(client.info(), signal);
812
+ const offered = this.deps.agent.capabilities;
813
+ this.commands = offered.commands ? [...info.commands] : [];
814
+ this.models = offered.models ? [...info.models] : [];
815
+ const modes = this.deps.agent.capabilities.permissionModes;
816
+ this.nativeMode = info.permissionMode || modes.default;
817
+ // Read once: a Start writing the switch meanwhile must not make the client's mode and
818
+ // what is recorded for it disagree.
819
+ choice = this.bypassChoice;
820
+ mode = this.nativeMode;
821
+ if (choice === true) {
822
+ mode = this.modeFor(true);
823
+ await cancellable(client.setPermissionMode(mode), signal);
824
+ }
825
+ else if (choice === false && (this.nativeMode === modes.bypass || !info.permissionMode)) {
826
+ // An explicit off outlives a rebuild: the folder's own bypass would return. An agent
827
+ // that reports no mode (a release can drop the field) may have started in it.
828
+ if (!info.permissionMode) {
829
+ logger.warning(`the agent reported no permission mode for ${this.where}, setting ${modes.default}`);
830
+ }
831
+ mode = modes.default;
832
+ await cancellable(client.setPermissionMode(mode), signal);
833
+ }
834
+ }
835
+ catch (error) {
836
+ // A client nobody holds would leave its agent process running.
837
+ await this.disconnect(client);
838
+ throw error;
839
+ }
840
+ this.client = client;
841
+ this.clientEffort = effort;
842
+ this.clientBypass = this.bypassRuns(choice);
843
+ this.mode = mode;
844
+ // A resumed session runs at the settings' level (measured), unknown until reported,
845
+ // unless the daemon itself just asked for a stored level: that request is shown at once,
846
+ // until the agent's own report (every turn ends with one) corrects it.
847
+ const requested = this.validEffort(effort);
848
+ this.effortSetting = requested;
849
+ this.effort = null;
850
+ this.effortReported = requested !== null;
851
+ this.effortUnsupported = false;
852
+ this.workingDirectory = null; // the new process starts in the bound folder
853
+ this.modelSwitched = null; // the new client starts from its own state
854
+ this.sessionTokens = null; // counted by the client process, which starts at zero
855
+ this.reader = new Task((reading) => this.read(client, reading));
856
+ this.idleTimerCheck(); // a daemon word (`!status`, say) connects with no turn
857
+ return client;
858
+ }
859
+ finally {
860
+ release();
861
+ }
862
+ }
863
+ /** `effort` when the agent takes it at a start; null for none, and for a level it does not know. */
864
+ validEffort(effort) {
865
+ return effort !== null && this.deps.agent.capabilities.effortLevels.includes(effort)
866
+ ? effort
867
+ : null;
868
+ }
869
+ async setBypass(on) {
870
+ const client = await this.ensureConnected();
871
+ const modes = this.deps.agent.capabilities.permissionModes;
872
+ let mode = this.modeFor(on);
873
+ try {
874
+ try {
875
+ await client.setPermissionMode(mode);
876
+ }
877
+ catch (error) {
878
+ if (mode !== modes.auto || this.closedFlag)
879
+ throw error;
880
+ // Claude Code refuses auto mode to a session that does not meet its requirements (a
881
+ // model without it, measured), and the refusal leaves bypass running: off then lands
882
+ // on the mode the agent itself starts in when auto mode is unavailable.
883
+ logger.warning(`could not return ${this.where} to auto mode, setting default: ${errorName(error)}`);
884
+ mode = modes.default;
885
+ await client.setPermissionMode(mode);
886
+ }
887
+ }
888
+ catch (error) {
889
+ if (this.closedFlag)
890
+ throw new SessionClosed(); // the close disconnected the client under the call
891
+ throw error;
892
+ }
893
+ this.clientBypass = on;
894
+ this.mode = mode;
895
+ // The session closed while the mode was being set: the thread this would write to may
896
+ // already be gone, so the switch is never recorded after the fact.
897
+ if (this.closedFlag)
898
+ throw new SessionClosed();
899
+ this.deps.state.setBypass(this.channelId, this.threadId, on);
900
+ }
901
+ /**
902
+ * `!bypass on` or `off` typed in the thread: switch and store it, true; or false, changing
903
+ * nothing, before Start (`beforeStart`). A Start being applied is waited for, so the word,
904
+ * typed after the click, is what holds; one that failed has undone itself by then, and the
905
+ * word is refused.
906
+ */
907
+ async switchBypass(on) {
908
+ // Before anything is stored or sent: there is no mode to switch to, or to come back from.
909
+ if (this.bypassMode === null)
910
+ throw new BypassUnavailable();
911
+ const release = await this.setupLock.acquire();
912
+ try {
913
+ if (this.beforeStart)
914
+ return false;
915
+ await this.setBypass(on);
916
+ return true;
917
+ }
918
+ finally {
919
+ release();
920
+ }
921
+ }
922
+ /**
923
+ * No turn was ever queued in this thread: its next message is a first prompt, which asks for
924
+ * the setup again (after a cancelled setup or a hold's Cancel, nothing was sent).
925
+ */
926
+ get neverRan() {
927
+ const stored = this.deps.state.thread(this.channelId, this.threadId);
928
+ return !this.submitted && (stored === null || stored.sessionId === null);
929
+ }
930
+ /**
931
+ * No first prompt was sent and no Start applied: the setup's Bypass box is what sets bypass,
932
+ * and `applySetup` writes it over anything set before.
933
+ */
934
+ get beforeStart() {
935
+ return this.neverRan && !this.setupApplied;
936
+ }
937
+ /**
938
+ * Apply the owner's session setup before the first prompt. Start is authoritative: the
939
+ * effort and the bypass switch are written from the choice whatever state.json held (a
940
+ * restart can have left either; `!bypass` is refused before Start), so what runs is what the
941
+ * summary line says. An effort the live client does not run at is set on it when the back end
942
+ * can (`liveEffort`), and goes through a fresh client when it cannot (no prompt has been
943
+ * sent, so no session is lost); then the model on the live client, then the permission mode:
944
+ * when the choice differs from what the client effectively runs (the switch, or a folder
945
+ * whose own settings start it in bypass), `setBypass` moves it. Unticking in such a folder is
946
+ * an explicit off with the semantics of `!bypass off`: the mode it returns to is the default
947
+ * one. Measured 2026-09-30 (CLI 2.1.285): a model set on the live session survives a resume
948
+ * and leaves the owner's own default alone, so the daemon does not store the model. A failure
949
+ * undoes what was applied (`forgetSetup`) and goes on. All of it under the setup lock, which
950
+ * a word's `switchBypass` waits on.
951
+ */
952
+ async applySetup(choice) {
953
+ const release = await this.setupLock.acquire();
954
+ try {
955
+ this.setupApplied = true;
956
+ this.applying = true;
957
+ const effort = choice.effort === DEFAULT ? null : choice.effort;
958
+ try {
959
+ this.deps.state.setEffort(this.channelId, this.threadId, effort);
960
+ this.deps.state.setBypass(this.channelId, this.threadId, choice.bypass);
961
+ let client = await this.ensureConnected();
962
+ if (effort !== this.clientEffort) {
963
+ if (this.deps.agent.capabilities.liveEffort)
964
+ await this.changeEffort(client, effort);
965
+ else
966
+ client = await this.reconnect();
967
+ }
968
+ if (choice.model !== DEFAULT && this.deps.agent.capabilities.models) {
969
+ await client.setModel(choice.model);
970
+ }
971
+ if (choice.bypass !== this.clientBypass) {
972
+ // A client connected above applies the stored switch itself: this only moves one that
973
+ // was already live, off meaning the mode it would have had without bypass.
974
+ await this.setBypass(choice.bypass);
975
+ }
976
+ }
977
+ catch (error) {
978
+ try {
979
+ await this.forgetSetup();
980
+ }
981
+ catch (undo) {
982
+ logger.warning(`could not undo a failed setup in ${this.where}: ${errorName(undo)}`);
983
+ }
984
+ throw error;
985
+ }
986
+ }
987
+ finally {
988
+ this.applying = false;
989
+ release();
990
+ }
991
+ }
992
+ /**
993
+ * Set the stored effort on the live client: what the session knows of the level is left as a
994
+ * connect with that stored level leaves it.
995
+ */
996
+ async changeEffort(client, effort) {
997
+ const level = this.validEffort(effort);
998
+ if (effort !== null && level === null) {
999
+ logger.warning(`dropped an unrecognized stored effort level in ${this.where}`);
1000
+ }
1001
+ await client.setEffort(level);
1002
+ this.clientEffort = effort;
1003
+ this.effortSetting = level;
1004
+ this.effort = null;
1005
+ this.effortReported = level !== null;
1006
+ this.effortUnsupported = false;
1007
+ }
1008
+ /**
1009
+ * Undo a Start whose message was not sent (a failure, a stop, a hold's Cancel, or a restart
1010
+ * that kept what it wrote): the stored effort and bypass go back to never chosen, and the
1011
+ * client is dropped, since it carries the effort, the model and the mode, which the next
1012
+ * connect rebuilds. A no-op for a thread that ran a turn (its choices are the owner's) and
1013
+ * when there is nothing to undo.
1014
+ */
1015
+ async forgetSetup() {
1016
+ const stored = this.deps.state.thread(this.channelId, this.threadId);
1017
+ const left = stored !== null && (stored.effort !== null || stored.bypass !== null);
1018
+ if (!this.neverRan || !(this.setupApplied || left))
1019
+ return;
1020
+ this.setupApplied = false;
1021
+ this.deps.state.setEffort(this.channelId, this.threadId, null);
1022
+ this.deps.state.setBypass(this.channelId, this.threadId, null);
1023
+ await this.dropClient();
1024
+ }
1025
+ /**
1026
+ * Close the live client and connect again, so `ensureConnected` starts it from state as it
1027
+ * is now: the way to a new effort for a back end that cannot change it live.
1028
+ */
1029
+ async reconnect() {
1030
+ await this.dropClient();
1031
+ return this.ensureConnected();
1032
+ }
1033
+ async dropClient() {
1034
+ const release = await this.connectLock.acquire();
1035
+ try {
1036
+ const reader = this.reader;
1037
+ this.reader = null;
1038
+ const client = this.client;
1039
+ this.client = null;
1040
+ if (reader !== null) {
1041
+ reader.cancel();
1042
+ await reader.settled();
1043
+ }
1044
+ if (client !== null)
1045
+ await this.disconnect(client);
1046
+ }
1047
+ finally {
1048
+ release();
1049
+ }
1050
+ }
1051
+ /**
1052
+ * During a stop: bring the thread's activity line up to date. Once only background tasks are
1053
+ * left it says which ones the restart waits for (`threadLine`), since only the owner knows
1054
+ * whether a task (a dev server, a watcher) ever ends. A line and not a message: a message
1055
+ * would notify, and would stay in the thread after the restart. Where the chat refuses this
1056
+ * app an activity line, a message says it, once: a restart that waits without a word is worse
1057
+ * than one notification.
1058
+ */
1059
+ async showRestartWait() {
1060
+ this.showThreadStatus();
1061
+ if (!this.deps.chat.capabilities.activityLine)
1062
+ return;
1063
+ if (!this.thread.activityRefused || this.toldWaiting || this.busy)
1064
+ return;
1065
+ const held = this.runningKindsOf({ awaitedOnly: true });
1066
+ if (held) {
1067
+ this.toldWaiting = true;
1068
+ await this.post(texts.fill(texts.RESTART_WAITS_MESSAGE, { counts: held }));
1069
+ }
1070
+ }
1071
+ /**
1072
+ * The thread as this session writes it. What a flow posts through it, outside the session,
1073
+ * counts as written here: the chat sets the activity line again.
1074
+ */
1075
+ get threadChat() {
1076
+ return this.thread;
1077
+ }
1078
+ /**
1079
+ * A message of the app's was posted in this thread outside the session: the chat clears a
1080
+ * thread's activity line when the app replies, so it is set again.
1081
+ */
1082
+ threadWritten() {
1083
+ this.thread.written();
1084
+ }
1085
+ /**
1086
+ * Interrupt the running turn, deny its pending approvals and stop this thread's background
1087
+ * tasks; queued turns stay queued. A hold open in this thread is always cancelled too,
1088
+ * silently (whoever waits on it tells the owner `Not sent.` once it wakes). Null when that
1089
+ * hold was the only thing here: the caller adds no further notice of its own then, since a
1090
+ * hold with nothing else running stops nothing the agent itself was doing, and `Not sent.`
1091
+ * alone already answers `!stop`.
1092
+ */
1093
+ async stop() {
1094
+ // (sync) With no hold open, nothing waits between the call and the checks below.
1095
+ const hold = this.deps.holds.cancel(this.channelId, this.threadId);
1096
+ const held = hold !== null;
1097
+ if (hold !== null)
1098
+ await this.deleteRequest(hold.messageTs);
1099
+ const client = this.client;
1100
+ if (client === null)
1101
+ return held ? null : false;
1102
+ const tasks = this.background ? this.runningTaskIds() : [];
1103
+ if (!this.busy && tasks.length === 0) {
1104
+ // A Start being applied may be inside the agent's own model command, a turn no reply
1105
+ // shows: the interrupt ends it, and the setup then fails into `forgetSetup`, which the
1106
+ // cancelled hold turns into `Not sent.`
1107
+ if (held && this.applying)
1108
+ await this.interruptApplied(client);
1109
+ return held ? null : false;
1110
+ }
1111
+ // What the agent said it was doing is void once the owner stops it. It says `idle` only
1112
+ // after an interrupted turn's result (recorded 2026-10-14), and no recording holds what it
1113
+ // says after a task stopped with no turn running. Its next word counts again.
1114
+ this.agentState = null;
1115
+ this.showThreadStatus();
1116
+ if (this.busy) {
1117
+ // A denial `denyAll` triggers below resolves `askOwner`'s own wait, whose `finally`
1118
+ // would otherwise race this method's own closing ✅ back to working; this flag makes it
1119
+ // skip that instead. `finish` or `abandon` clears it once this turn's own tail ends.
1120
+ this.interrupting = true;
1121
+ this.stopTail = this.stopTail ?? new Event();
1122
+ for (const pending of this.deps.approvals.denyAll(this.channelId, this.threadId)) {
1123
+ await this.deleteRequest(pending.messageTs);
1124
+ }
1125
+ try {
1126
+ await client.interrupt();
1127
+ }
1128
+ catch (error) {
1129
+ // A query already closed refuses the request; the tasks below still stop.
1130
+ logger.warning(`could not interrupt ${this.where}: ${errorName(error)}`);
1131
+ }
1132
+ }
1133
+ for (const taskId of tasks) {
1134
+ this.stopped.add(taskId);
1135
+ try {
1136
+ await client.stopTask(taskId);
1137
+ }
1138
+ catch (error) {
1139
+ // it may have ended meanwhile; the others still stop
1140
+ logger.warning(`could not stop a task in ${this.where}: ${errorName(error)}`);
1141
+ }
1142
+ }
1143
+ // A stop the owner gave is not an error. `react` clears `errorStanding`, so a later idle
1144
+ // sweep shows the same ✅ and `noteStatus` drops the persisted ⏳.
1145
+ this.react("done");
1146
+ return true;
1147
+ }
1148
+ async interruptApplied(client) {
1149
+ try {
1150
+ await client.interrupt();
1151
+ }
1152
+ catch (error) {
1153
+ logger.warning(`could not interrupt a setup in ${this.where}: ${errorName(error)}`);
1154
+ }
1155
+ }
1156
+ /**
1157
+ * Return once the reply `stop()` cut short has ended, so what is posted next sits under its
1158
+ * ending and its footer. Waits at most STOP_TAIL_WAIT, and not at all when the stop
1159
+ * interrupted no turn (it stopped background tasks only).
1160
+ */
1161
+ async stopLanded() {
1162
+ const tail = this.stopTail;
1163
+ if (tail === null || tail.isSet)
1164
+ return;
1165
+ const waiting = new AbortController();
1166
+ try {
1167
+ await Promise.race([
1168
+ tail.wait(waiting.signal),
1169
+ this.deps.clock.sleep(STOP_TAIL_WAIT, waiting.signal),
1170
+ ]);
1171
+ }
1172
+ finally {
1173
+ waiting.abort(new Cancelled()); // whichever of the two still waits leaves
1174
+ }
1175
+ }
1176
+ stopTailEnded() {
1177
+ const tail = this.stopTail;
1178
+ this.stopTail = null;
1179
+ tail?.set();
1180
+ }
1181
+ /**
1182
+ * The channel's directory, session and mode, then the footer's values one per line, or why
1183
+ * the agent cannot start. Starts the client like `!help` does, since model and context come
1184
+ * from it.
1185
+ */
1186
+ async status() {
1187
+ let data = null;
1188
+ let unavailable = [];
1189
+ let gone = false;
1190
+ let usable = true;
1191
+ try {
1192
+ await this.ensureConnected();
1193
+ this.refreshUsage();
1194
+ data = await this.footerData(this.sessionTokens);
1195
+ }
1196
+ catch (error) {
1197
+ // Not an answer to give: the word is refused as a message is, and nothing changed.
1198
+ if (error instanceof StartsBlocked)
1199
+ throw error;
1200
+ if (error instanceof DirectoryUnavailable) {
1201
+ unavailable = [error.message];
1202
+ usable = false;
1203
+ }
1204
+ else if (error instanceof SessionGone) {
1205
+ // This call is the one that found it gone: `ensureConnected` already closed the
1206
+ // session over it, which is not the race the check below guards against.
1207
+ unavailable = [error.message];
1208
+ gone = true;
1209
+ }
1210
+ else {
1211
+ // the status still answers, with what a prompt would get
1212
+ logger.warning(`could not read the footer's values for the status in ${this.where}: ${errorName(error)}`);
1213
+ unavailable = [
1214
+ texts.fill(texts.named(texts.ERROR_REPLY, this.names), { error: errorName(error) }),
1215
+ ];
1216
+ }
1217
+ }
1218
+ // The daemon closed the session (shutdown, an idle close) while this call was reading it.
1219
+ if (this.closedFlag && !gone)
1220
+ throw new SessionClosed();
1221
+ const fields = data === null ? [] : formatStatusFields(data, this.now());
1222
+ const here = this.workingDirectory;
1223
+ if (data !== null && here && here !== this.directory) {
1224
+ // The branch and the changes below describe this folder, not the thread's own.
1225
+ fields.unshift(texts.fill(texts.STATUS_WORKING, { directory: here }));
1226
+ }
1227
+ const running = this.runningKindsOf();
1228
+ if (running)
1229
+ fields.push(texts.fill(texts.STATUS_BACKGROUND, { counts: running }));
1230
+ const stored = this.deps.state.thread(this.channelId, this.threadId);
1231
+ const activity = this.busy
1232
+ ? texts.fill(texts.ACTIVITY_BUSY, { queued: this.queue.size })
1233
+ : texts.ACTIVITY_IDLE;
1234
+ const sessionId = stored?.sessionId ?? null;
1235
+ const head = [
1236
+ texts.fill(texts.STATUS, { directory: this.directory, session: sessionId || "new" }),
1237
+ ];
1238
+ // Left out with a folder the line above cannot use: its `cd` would fail there.
1239
+ if (sessionId && usable)
1240
+ head.push(terminalLine(this.deps.agent.words.terminal, this.directory, sessionId, (text) => this.deps.chat.escape(text, "reference")));
1241
+ const state = texts.fill(texts.named(texts.STATUS_STATE, this.names), {
1242
+ mode: this.bypass ? (this.bypassMode ?? this.mode) : this.mode,
1243
+ // Only a turn's first event carries the version, never the start (measured).
1244
+ version: this.cliVersion || (this.client !== null ? texts.VERSION_PENDING : "not started"),
1245
+ activity,
1246
+ });
1247
+ return [...head, state, ...fields, ...unavailable].join("\n");
1248
+ }
1249
+ now() {
1250
+ return (this.deps.now ?? Date.now)();
1251
+ }
1252
+ /**
1253
+ * Refresh the usage limits in the background when stale; the next footer shows them. A task
1254
+ * of its own, started after what is already due (the reader of a client just started reads
1255
+ * its first turn's limits event before the refresh asks), and never cancelled: a refresh
1256
+ * shares one probe across every session.
1257
+ */
1258
+ refreshUsage() {
1259
+ const usage = this.usage;
1260
+ // The probe starts an agent process of its own.
1261
+ if (usage === null || this.startsBlocked)
1262
+ return;
1263
+ new Task(async () => {
1264
+ if (await this.updatingElsewhere())
1265
+ return;
1266
+ await usage.refreshIfStale();
1267
+ });
1268
+ }
1269
+ /** Whether another process of the machine is updating allaboard (`SessionDeps.updating`). */
1270
+ async updatingElsewhere() {
1271
+ return (await this.deps.updating?.()) ?? false;
1272
+ }
1273
+ /**
1274
+ * Stop the session. The running reply ends with a line saying why, and one note names the
1275
+ * messages that were sent or queued and will never be answered, so no stream is left open.
1276
+ *
1277
+ * `doneClosing` always fires, from a `finally`: a step below failing must not leave a
1278
+ * rebuilt session's `ensureConnected` waiting on it forever, or `closeAll` hanging at
1279
+ * shutdown, or the manager's map holding a session nothing can ever evict.
1280
+ */
1281
+ async close(reason = texts.ENDED_SHUTDOWN) {
1282
+ // (sync) Read before anything below settles it back to idle. A close that cuts anything
1283
+ // short (a shutdown or a restart's drain, most likely: `busy` alone misses a queued or
1284
+ // taken turn and a task that outlived its own turn, which `idle` already accounts for)
1285
+ // gets ❌; the idle close, always called on an idle session, never does, and only finishes
1286
+ // a change of the status still on its way (`ThreadChat.settleStatus`).
1287
+ const cutShort = !this.idle;
1288
+ // Issue #87: at the end of a stop, a session whose only unfinished work is a background
1289
+ // task the stop does not wait for (`mayHaveOrderedRestart`) ended its turn well; the task
1290
+ // is most likely its own wait for the new process. ✅, as `!stop` would show.
1291
+ const onlySkipped = cutShort && this.draining && this.restartReady;
1292
+ // Issue #263: the one thing this close cut was the close of a reply whose turn had ended.
1293
+ // Its answer is whole: ✅, as that turn would have shown.
1294
+ let endedWell = false;
1295
+ this.closedFlag = true;
1296
+ try {
1297
+ // A snapshot taken before any cancellation below, not `denyAll`'s own return list read
1298
+ // afterward: a request whose handler ends first has left `Approvals` by then, and its
1299
+ // message must go either way. Deleting each message here does not depend on the handler
1300
+ // ever seeing a decision.
1301
+ const requestsToDelete = this.requestMessages();
1302
+ // The worker may hold the connect lock while the agent starts: cancelled first.
1303
+ await this.cancelTasks();
1304
+ // A daemon word may be starting the client on its own. Its start finishes, then the
1305
+ // reader it started is cancelled and its client closed below: no process outlives the
1306
+ // session, and none resumes the session id the next one will resume.
1307
+ const release = await this.connectLock.acquire();
1308
+ try {
1309
+ await this.cancelTasks();
1310
+ }
1311
+ finally {
1312
+ release();
1313
+ }
1314
+ await this.settleRequests(requestsToDelete);
1315
+ // A turn the worker took but had not sent yet is in no queue.
1316
+ const taken = this.taken;
1317
+ this.taken = null;
1318
+ const unsent = taken === null ? [] : [taken];
1319
+ while (!this.queue.empty)
1320
+ unsent.push(this.queue.getNowait());
1321
+ endedWell = await this.abandon(texts.fill(texts.ENDED, { reason }), {
1322
+ more: unsent,
1323
+ because: texts.BECAUSE_SHUTDOWN,
1324
+ });
1325
+ if (this.client !== null) {
1326
+ const client = this.client;
1327
+ this.client = null;
1328
+ await this.disconnect(client);
1329
+ }
1330
+ }
1331
+ finally {
1332
+ if (this.predecessor !== null) {
1333
+ // This object's own predecessor may still be mid-teardown: `ensureConnected` never ran
1334
+ // here (this session closed with no turn ever submitted), so nothing else has waited
1335
+ // on it yet. `doneClosing` must never fire before it does, or a session built after
1336
+ // this one could resume the same id while an earlier one is still exiting (the chain,
1337
+ // not just the direct predecessor, must be honoured).
1338
+ await this.predecessor.wait();
1339
+ this.predecessor = null;
1340
+ }
1341
+ // Only now, with every task cancelled: a round trip to the chat must not hold those up.
1342
+ await this.thread.close();
1343
+ // The last chance a change still only debounced gets, and the last chance of a reply
1344
+ // whose end failed and whose retry is still waiting: each reply once.
1345
+ let lost = false;
1346
+ const replies = this.openReplies();
1347
+ for (const reply of replies) {
1348
+ try {
1349
+ lost = !(await reply.settle()) || lost;
1350
+ }
1351
+ catch {
1352
+ lost = true;
1353
+ }
1354
+ }
1355
+ if ((onlySkipped || endedWell) && !lost) {
1356
+ this.noteStatus("done");
1357
+ if (this.reacts)
1358
+ await this.thread.showStatus("done");
1359
+ }
1360
+ else if (cutShort || lost) {
1361
+ // Awaited, not `react`. Before `doneClosing`/`onClosed`, not after: those let the
1362
+ // manager evict this session and hand the same root to a freshly built one right away,
1363
+ // whose own first status strips every other one: this ❌ must already be on the root
1364
+ // before that race can even start. An answer that never reached the chat keeps the
1365
+ // persisted status (below), so the next start's repair still says so.
1366
+ if (!lost)
1367
+ this.noteStatus("error");
1368
+ if (this.reacts)
1369
+ await this.thread.showStatus("error");
1370
+ }
1371
+ else if (this.reacts) {
1372
+ // Issue #104: a session reads idle as soon as its turn ends, while its reader may
1373
+ // still be between the two calls that move ⏳ to ✅, and `cancelTasks` above stops it
1374
+ // there. No call when the root already reads as asked.
1375
+ await this.thread.settleStatus();
1376
+ }
1377
+ // Crash repair (issue #19): a graceful close is not what repair is for, whatever is
1378
+ // still live in the chat when it ends, so this thread's fields end up empty either way.
1379
+ // Best-effort: a failed write here must never skip `doneClosing`/`onClosed`.
1380
+ try {
1381
+ this.deps.state.clearRepair(this.channelId, this.threadId, { keepOpen: lost });
1382
+ if (lost) {
1383
+ this.deps.state.setStatusPending(this.channelId, this.threadId, "working", "error");
1384
+ }
1385
+ }
1386
+ catch (error) {
1387
+ logger.warning(`could not clear state.json's crash-repair fields for ${this.where}: ${errorName(error)}`);
1388
+ }
1389
+ // A start the worker gave up may still be launching: its process is closed before
1390
+ // `doneClosing`, or a session built after this one could resume the same id meanwhile.
1391
+ // Waited for last, so the chat is settled while the agent is still coming up.
1392
+ await Promise.allSettled([...this.abandoned]);
1393
+ // Only now: the agent's process (if any) has had its chance to flush and exit, or
1394
+ // closing failed partway through and there is nothing left worth waiting for either way.
1395
+ this.doneClosing.set();
1396
+ this.onClosed?.();
1397
+ }
1398
+ }
1399
+ async cancelTasks() {
1400
+ // Every task is cancelled before any is awaited: a reader left running while the worker
1401
+ // stops could still end a turn and record its session after a rebind. `expiring`'s own
1402
+ // timers go too: none should outlive the session, and a stray one firing after would try
1403
+ // to post a closing through one that is already gone.
1404
+ const tasks = [this.worker, this.reader, this.expiry, this.idleExpiry, ...this.expiring].filter((task) => task !== null);
1405
+ for (const task of tasks)
1406
+ task.cancel();
1407
+ for (const task of tasks)
1408
+ await task.settled();
1409
+ }
1410
+ async disconnect(client) {
1411
+ try {
1412
+ await client.close();
1413
+ }
1414
+ catch (error) {
1415
+ logger.warning(`could not close ${this.deps.agent.name} in ${this.where}: ${errorName(error)}`);
1416
+ }
1417
+ }
1418
+ async connect(sessionId, effort, signal) {
1419
+ const valid = this.validEffort(effort);
1420
+ if (effort !== null && valid === null) {
1421
+ logger.warning(`dropped an unrecognized stored effort level in ${this.where}`);
1422
+ }
1423
+ const starting = this.deps.agent.start({
1424
+ folder: this.directory,
1425
+ resume: sessionId,
1426
+ settingsSources: ["user", "project", "local"],
1427
+ model: null,
1428
+ // A model set by command survives a resume by itself (measured); the effort a command
1429
+ // set does not, so the daemon stores it per thread and passes it back here.
1430
+ effort: valid,
1431
+ }, {
1432
+ permission: (request, signal) => this.answering(request, signal),
1433
+ question: (request, signal) => this.answering(request, signal),
1434
+ });
1435
+ try {
1436
+ return await cancellable(starting, signal);
1437
+ }
1438
+ catch (error) {
1439
+ if (signal?.aborted) {
1440
+ // The start goes on by itself: a process nobody holds is closed when it is there, and
1441
+ // `close` waits for that.
1442
+ const closing = starting.then((client) => this.disconnect(client), () => { });
1443
+ this.abandoned.add(closing);
1444
+ closing.finally(() => this.abandoned.delete(closing));
1445
+ }
1446
+ throw error;
1447
+ }
1448
+ }
1449
+ /** The messages of this thread's open requests, read before anything settles them. */
1450
+ requestMessages() {
1451
+ return this.deps.approvals
1452
+ .pendingIn(this.channelId, this.threadId)
1453
+ .map((pending) => pending.messageTs);
1454
+ }
1455
+ /**
1456
+ * Deny this thread's open requests and withdraw their messages (`messages`, a snapshot
1457
+ * `requestMessages` took earlier: a handler that ends first has left `Approvals` by then, and
1458
+ * its message must go either way).
1459
+ */
1460
+ async settleRequests(messages) {
1461
+ this.deps.approvals.denyAll(this.channelId, this.threadId);
1462
+ // The denial above resolves each handler of a request, and each is awaited to its end.
1463
+ await Promise.allSettled([...this.asking]);
1464
+ for (const messageTs of messages)
1465
+ await this.deleteRequest(messageTs);
1466
+ }
1467
+ /** A request handler in flight, tracked so that a close can wait for it. */
1468
+ answering(request, signal) {
1469
+ const answer = this.askOwner(request, signal);
1470
+ this.asking.add(answer);
1471
+ const forget = () => this.asking.delete(answer);
1472
+ answer.then(forget, forget);
1473
+ return answer;
1474
+ }
1475
+ async work(signal) {
1476
+ // Closed (the SessionGone branch of `ensureConnected`, most likely) ends the loop instead
1477
+ // of looping back to an empty queue nothing will ever fill again.
1478
+ while (!this.closedFlag) {
1479
+ const turn = await this.queue.get(signal);
1480
+ this.taken = turn;
1481
+ try {
1482
+ const client = await this.ensureConnected(signal);
1483
+ this.refreshUsage();
1484
+ if (!this.settled.isSet)
1485
+ await this.settled.wait(signal);
1486
+ // (sync) from the check to the send: a drain raises every flag before its first
1487
+ // await, so no turn is sent in between.
1488
+ if (this.draining) {
1489
+ this.taken = null;
1490
+ // A prompt genuinely dropped by the drain, reacting ❌; like the queued ones, it
1491
+ // gets no reply of its own, only a note.
1492
+ await this.drop([turn], texts.BECAUSE_RESTARTED, { error: true });
1493
+ this.idleTimerCheck(); // only cancels: draining itself blocks it from arming
1494
+ continue;
1495
+ }
1496
+ this.sent.push(turn);
1497
+ this.taken = null;
1498
+ await cancellable(client.send({ id: turn.uuid, content: turn.prompt }), signal);
1499
+ await turn.done.wait(signal);
1500
+ }
1501
+ catch (error) {
1502
+ if (error instanceof Cancelled)
1503
+ throw error;
1504
+ this.taken = null;
1505
+ if (error instanceof DirectoryUnavailable ||
1506
+ error instanceof SessionGone ||
1507
+ error instanceof SessionClosed ||
1508
+ error instanceof StartsBlocked) {
1509
+ await this.fail(turn, error.message, { error: true });
1510
+ this.idleTimerCheck(); // a no-op if this also closed the session
1511
+ }
1512
+ else {
1513
+ // a failed turn must not stop this thread's queue
1514
+ logger.error(`turn failed in ${this.where}: ${errorName(error)}`);
1515
+ const at = this.sent.indexOf(turn);
1516
+ if (at !== -1)
1517
+ this.sent.splice(at, 1);
1518
+ await this.fail(turn, texts.fill(texts.named(texts.ERROR_REPLY, this.names), { error: errorName(error) }), {
1519
+ error: true,
1520
+ });
1521
+ this.idleTimerCheck(); // the session stays alive; may need arming again
1522
+ }
1523
+ // The prompt that held the session's end back is gone: the footer an earlier end owes
1524
+ // is drawn now, on the reply of that work (a prompt that never started is never named).
1525
+ await this.showFooterIfEnded(signal);
1526
+ }
1527
+ }
1528
+ }
1529
+ /** Follow the client's events until they end; whatever ends them, release the thread. */
1530
+ async read(client, signal) {
1531
+ let reason = texts.named(PROCESS_EXITED, this.names);
1532
+ const events = client.events[Symbol.asyncIterator]();
1533
+ try {
1534
+ for (;;) {
1535
+ const next = await cancellable(events.next(), signal);
1536
+ if (next.done)
1537
+ break;
1538
+ const event = next.value;
1539
+ if (event.type === "process_lost") {
1540
+ reason = event.reason;
1541
+ break;
1542
+ }
1543
+ try {
1544
+ await this.dispatch(event, signal);
1545
+ }
1546
+ catch (error) {
1547
+ if (error instanceof Cancelled)
1548
+ throw error;
1549
+ // one event that fails to render must not end it all
1550
+ logger.error(`could not render a message in ${this.where}: ${errorName(error)}`);
1551
+ }
1552
+ // A task event or a turn's own end (through `dispatch`) may leave the session idle
1553
+ // again, or may end it: re-armed or cancelled here either way.
1554
+ this.idleTimerCheck();
1555
+ }
1556
+ }
1557
+ catch (error) {
1558
+ if (error instanceof Cancelled) {
1559
+ // Whoever cancelled the reader closes the client: the events it still held are dropped.
1560
+ events.return?.().catch(() => { });
1561
+ throw error;
1562
+ }
1563
+ reason = errorName(error);
1564
+ }
1565
+ logger.error(`session reader stopped in ${this.where}: ${reason}`);
1566
+ if (this.client === client)
1567
+ this.client = null;
1568
+ try {
1569
+ // Nobody is left to take an answer: the requests of the lost process go as `close` sends
1570
+ // them, with `interrupting` up so no handler's `finally` shows working again over the ❌
1571
+ // (`abandon` clears it). A request that waited is a loss: `abandon` may find nothing else.
1572
+ const requests = this.requestMessages();
1573
+ if (requests.length > 0)
1574
+ this.reactError();
1575
+ this.interrupting = true;
1576
+ try {
1577
+ await this.settleRequests(requests);
1578
+ }
1579
+ finally {
1580
+ // Whatever the chat did meanwhile, the turn is released.
1581
+ await this.abandon(texts.fill(texts.named(texts.ERROR_REPLY, this.names), { error: reason }), {
1582
+ error: true,
1583
+ because: texts.named(texts.BECAUSE_STOPPED, this.names),
1584
+ });
1585
+ }
1586
+ }
1587
+ finally {
1588
+ await client.close().catch(() => { });
1589
+ // The session stays alive (not closed): the next turn reconnects. `abandon` settles it
1590
+ // from outside this loop's own per-event check above, so it needs its own call.
1591
+ this.idleTimerCheck();
1592
+ }
1593
+ }
1594
+ /**
1595
+ * Whether `promptId` names a prompt this session sent and the agent took: it renders nothing
1596
+ * and starts no turn. One taken with no turn running comes right before the events of the
1597
+ * turn that prompt gets of its own. One that arrives inside a running turn means the agent
1598
+ * took the prompt into that turn, which then ends with one result for both (measured
1599
+ * 2026-10-06, CLI 2.1.286): `finish` releases it.
1600
+ */
1601
+ acknowledge(promptId) {
1602
+ const active = this.active;
1603
+ const sent = [...this.sent, ...(active?.turn ? [active.turn] : [])];
1604
+ const turn = sent.find((candidate) => candidate.uuid === promptId);
1605
+ if (turn === undefined)
1606
+ return false;
1607
+ if (active === null)
1608
+ this.announced = turn;
1609
+ else if (!active.taken.includes(turn))
1610
+ active.taken.push(turn);
1611
+ return true;
1612
+ }
1613
+ /**
1614
+ * The capability `event` needs that the agent does not declare, or null. Such an event is
1615
+ * not shown or acted on: a back end that sends one is at fault.
1616
+ */
1617
+ undeclared(event) {
1618
+ const declared = this.deps.agent.capabilities;
1619
+ if ("parentCallId" in event && event.parentCallId !== null) {
1620
+ return declared.subagents ? null : "subagents";
1621
+ }
1622
+ switch (event.type) {
1623
+ case "compaction_started":
1624
+ case "compaction_ended":
1625
+ case "compacted":
1626
+ return declared.compaction ? null : "compaction";
1627
+ case "report_started":
1628
+ return declared.backgroundTasks ? null : "backgroundTasks";
1629
+ case "notification_folded":
1630
+ return declared.backgroundTasks && declared.foldedNotifications
1631
+ ? null
1632
+ : "foldedNotifications";
1633
+ case "state_changed":
1634
+ return declared.declaredState ? null : "declaredState";
1635
+ case "limits_changed":
1636
+ return declared.usageLimits ? null : "usageLimits";
1637
+ case "model_changed":
1638
+ return declared.models ? null : "models";
1639
+ case "task_started": {
1640
+ const needs = event.kind === "subagent" ? "subagents" : "backgroundTasks";
1641
+ if (declared[needs])
1642
+ return null;
1643
+ this.refusedTasks.set(event.taskId, needs);
1644
+ return needs;
1645
+ }
1646
+ case "task_progress":
1647
+ case "task_updated":
1648
+ return this.refusedTasks.get(event.taskId) ?? null;
1649
+ case "task_ended": {
1650
+ const refused = this.refusedTasks.get(event.taskId) ?? null;
1651
+ this.refusedTasks.delete(event.taskId);
1652
+ return refused;
1653
+ }
1654
+ default:
1655
+ return null;
1656
+ }
1657
+ }
1658
+ async dispatch(event, signal) {
1659
+ const missing = this.undeclared(event);
1660
+ if (missing !== null) {
1661
+ logger.warning(`ignored a ${event.type} event in ${this.where}: the agent has no ${missing}`);
1662
+ return;
1663
+ }
1664
+ switch (event.type) {
1665
+ case "limits_changed":
1666
+ this.usage?.invalidate();
1667
+ return;
1668
+ case "prompt_taken":
1669
+ this.acknowledge(event.promptId);
1670
+ return;
1671
+ case "state_changed":
1672
+ if (event.state !== this.agentState) {
1673
+ this.agentState = event.state;
1674
+ this.showThreadStatus();
1675
+ }
1676
+ return;
1677
+ case "report_started":
1678
+ // Said with no turn running, right before the turn it names. Inside a running turn it
1679
+ // names no turn to come.
1680
+ if (this.active === null)
1681
+ this.reportOf = event.taskId;
1682
+ return;
1683
+ case "notification_folded":
1684
+ this.unfolded.delete(event.taskId);
1685
+ return;
1686
+ case "effort_observed":
1687
+ // The Stop hook's input carries the level, absent when the model takes none.
1688
+ this.effort = event.level;
1689
+ this.effortReported = true;
1690
+ this.effortUnsupported = event.level === null;
1691
+ return;
1692
+ case "effort_changed":
1693
+ // What ran before the command is no longer what runs: the next turn reports the level.
1694
+ this.effort = null;
1695
+ this.effortReported = true;
1696
+ this.effortUnsupported = false;
1697
+ // Stored so the next connect can pass it back (a model set by command survives a
1698
+ // resume by itself; effort does not). A null level means "unknown" (a model change
1699
+ // that names no effort), not "back to default": the stored override, if any, is left
1700
+ // alone. The agent's word for its default (`effortDefault`) IS an explicit reset,
1701
+ // stored as none.
1702
+ if (event.level !== null) {
1703
+ const reset = event.level === this.deps.agent.capabilities.effortDefault;
1704
+ this.effortSetting = reset ? null : event.level;
1705
+ this.deps.state.setEffort(this.channelId, this.threadId, this.effortSetting);
1706
+ }
1707
+ return;
1708
+ case "model_changed":
1709
+ this.modelSwitched = event.model;
1710
+ this.modelSwitchedInTurn = this.active !== null;
1711
+ return;
1712
+ case "folder_changed":
1713
+ this.workingDirectory = event.folder;
1714
+ return;
1715
+ case "session_started":
1716
+ this.cliVersion = event.agentVersion;
1717
+ // Recorded as soon as the agent reports it, not only at the turn's end, so a
1718
+ // still-running first turn is already this session id's holder and `!resume` cannot
1719
+ // put a second process on the same transcript.
1720
+ if (event.sessionId !== null) {
1721
+ this.deps.state.setSession(this.channelId, this.threadId, event.sessionId);
1722
+ }
1723
+ // A turn of the agent's own begins: the ends told so far are the ones it reports.
1724
+ if (this.sent.length === 0 && this.opening === null && this.ended.length > 0) {
1725
+ this.opening = this.ended;
1726
+ this.ended = [];
1727
+ }
1728
+ break;
1729
+ case "mode_changed":
1730
+ // Claude Code reports the permission mode after a change (measured after each
1731
+ // `set_permission_mode`): the mode shown follows what it says.
1732
+ this.mode = event.mode;
1733
+ break;
1734
+ case "compaction_started":
1735
+ case "compaction_ended": {
1736
+ // A compaction opens with a status the agent repeats while it lasts, and ends 14 to 37
1737
+ // seconds later in the recordings (2026-10-08, CLI 2.1.292). The back end says each
1738
+ // once.
1739
+ const compacting = event.type === "compaction_started";
1740
+ if (compacting !== this.compacting) {
1741
+ this.compacting = compacting;
1742
+ this.showThreadStatus();
1743
+ }
1744
+ break;
1745
+ }
1746
+ default:
1747
+ break;
1748
+ }
1749
+ const task = isTaskEvent(event) ? event : null;
1750
+ if (task !== null) {
1751
+ const inner = this.nestingReply(task);
1752
+ if (inner !== null) {
1753
+ // A task of a call inside another call (a long command a subagent runs) is that call's
1754
+ // root's work: the agent reports it to the subagent, so no turn follows it. None of
1755
+ // what a task of the conversation sets up (its record, its end line, a wait for its
1756
+ // report, a held event) applies; the reply keeps the event off its cards.
1757
+ await inner.feed(task);
1758
+ return;
1759
+ }
1760
+ }
1761
+ if (event.type === "task_started") {
1762
+ // A task whose parent is a subagent this session knows is that subagent's, even when its
1763
+ // call is one this process never saw: a subagent continued after a restart still names
1764
+ // its first call, and its task. An unknown parent counts as none (`sdk.d.ts` 0.3.300).
1765
+ if (event.parentTaskId !== null && this.tasks.has(event.parentTaskId)) {
1766
+ this.ownedBySubagent.add(event.taskId);
1767
+ }
1768
+ this.recordTask(event);
1769
+ }
1770
+ const stopped = task !== null && this.stopped.has(task.taskId);
1771
+ const owned = task !== null && this.ownedBySubagent.has(task.taskId);
1772
+ if (event.type === "task_ended" &&
1773
+ this.active === null &&
1774
+ this.sent.length === 0 &&
1775
+ !stopped &&
1776
+ !owned &&
1777
+ this.background) {
1778
+ this.ended.push([event.taskId, this.endedLine(event)]);
1779
+ }
1780
+ if (event.type === "task_ended" && !stopped && !owned) {
1781
+ // An agent in the background that ends while a turn runs: with no card to complete, its
1782
+ // end is said where the conversation is, as the terminal prints `Agent "..." finished`
1783
+ // among the turn's calls (captured 2026-10-13 on Claude Code 2.1.296). One that ends
1784
+ // with a message of the owner's already waiting says it in that message's reply.
1785
+ const writing = this.active?.renderer ?? null;
1786
+ const reply = this.taskReplies.get(event.taskId) ?? writing;
1787
+ if (reply?.lineless(event.taskId)) {
1788
+ if (writing !== null &&
1789
+ event.foldNamed &&
1790
+ this.deps.agent.capabilities.foldedNotifications) {
1791
+ this.unfolded.set(event.taskId, reply);
1792
+ }
1793
+ if (writing !== null)
1794
+ await writing.feedLine(this.endedLine(event));
1795
+ else if (this.sent.length > 0)
1796
+ this.endedAhead.push(this.endedLine(event));
1797
+ }
1798
+ }
1799
+ if (event.type === "task_updated" &&
1800
+ event.terminal &&
1801
+ this.background &&
1802
+ !this.unreported.has(event.taskId) &&
1803
+ !stopped) {
1804
+ this.unreported.set(event.taskId, this.deps.clock.time());
1805
+ // The CLI can suppress the notification altogether (SDK TaskUpdatedMessage docstring);
1806
+ // nothing else rechecks this reply once the wait that holds it passes, so this
1807
+ // schedules that recheck itself.
1808
+ const taskId = event.taskId;
1809
+ const expiry = new Task((waiting) => this.expireUnreported(taskId, waiting));
1810
+ this.expiring.add(expiry);
1811
+ expiry.settled().then(() => this.expiring.delete(expiry));
1812
+ }
1813
+ if (event.type === "task_ended") {
1814
+ if (this.active === null && !stopped && this.background)
1815
+ this.notificationWaits = true;
1816
+ this.unreported.delete(event.taskId);
1817
+ this.stopped.delete(event.taskId);
1818
+ this.ownedBySubagent.delete(event.taskId);
1819
+ // Read above for the end line, which comes after the terminal task_updated (recorded
1820
+ // order); a task whose notification never comes goes with the process.
1821
+ this.tasks.delete(event.taskId);
1822
+ }
1823
+ const holder = task === null ? undefined : this.taskReplies.get(task.taskId);
1824
+ if (task !== null && holder !== undefined) {
1825
+ // A task that outlived its turn shows only on its own line, wherever it started.
1826
+ await holder.feed(task);
1827
+ this.showRunning();
1828
+ if (this.active === null && event.type === "task_ended" && !stopped)
1829
+ this.notified();
1830
+ // The task that just ended may have been the last thing keeping this reply's stream
1831
+ // open; `notified` above, if it fired, already re-armed the wait.
1832
+ const owed = this.stillOwed(holder) || this.injectedExpected || !this.settled.isSet;
1833
+ // A task promoted out of a subagent's call sits here while its turn still runs: that
1834
+ // turn's own `closeReply` ends the reply, with the footer, as `sweepClosedOut` also
1835
+ // leaves it to.
1836
+ const writing = this.active?.renderer ?? null;
1837
+ if (!owed && holder !== writing) {
1838
+ this.trackLanding(await holder.closeOut(signal), this.reply(holder));
1839
+ this.showThreadStatus(); // the task's end may change what the line says
1840
+ // `!stop` already reacted ✅ itself; skipped so a stopped task's end does not show it
1841
+ // a second time. The footer is the stop's end all the same.
1842
+ if (stopped)
1843
+ await this.showFooterIfEnded(signal);
1844
+ else
1845
+ await this.reactDoneIfIdle(signal);
1846
+ }
1847
+ return;
1848
+ }
1849
+ const call = callOf(event);
1850
+ const origin = call ? this.originOf(call) : null;
1851
+ if (origin !== null) {
1852
+ if (event.type === "agent_error") {
1853
+ // A subagent's failed request: the category only, as for the turn's own below.
1854
+ logger.warning(`${this.deps.agent.name} reported a subagent's error in ${this.where}: ${event.category}`);
1855
+ }
1856
+ await origin.feed(event);
1857
+ this.adoptPromoted(origin);
1858
+ if (event.type === "task_started") {
1859
+ this.taskReplies.set(event.taskId, origin);
1860
+ this.showRunning();
1861
+ }
1862
+ return;
1863
+ }
1864
+ if (this.active === null) {
1865
+ if (event.type === "turn_ended" && this.saidNothing(event)) {
1866
+ await this.nothingReported(event, signal);
1867
+ return;
1868
+ }
1869
+ // A task started by no call, while an owner prompt is sent and no report turn is
1870
+ // expected, is that prompt's: a skill with `context: fork` typed as a command (`!review`
1871
+ // → `/review`) runs its agent before the turn's first message (recorded:
1872
+ // `skill-fork-command.jsonl`, CLI 2.1.283, the only recording with no call id). Its
1873
+ // turn starts now, so its line shows while it works.
1874
+ const startsOwnerTurn = event.type === "task_started" &&
1875
+ event.callId === null &&
1876
+ this.sent.length > 0 &&
1877
+ !this.injectedExpected;
1878
+ // Whether the turn opens with its own words: the case in which a prompt's replay is
1879
+ // known to come first (`whoseTurn`).
1880
+ let worded = false;
1881
+ if (!startsOwnerTurn) {
1882
+ if (task !== null) {
1883
+ // A task that ends between turns is reported in a turn of the agent's own, or not at all.
1884
+ if (!this.background)
1885
+ return;
1886
+ this.held.push(task);
1887
+ if (event.type === "task_ended" && !stopped)
1888
+ this.notified();
1889
+ return;
1890
+ }
1891
+ // A compaction comes before every event of its turn that shows something, on
1892
+ // `/compact` and on an automatic compaction at a turn's start (recorded:
1893
+ // `compact.jsonl`, `auto-compact.jsonl`, CLI 2.1.292). The turn starts when the
1894
+ // compaction does, so a report turn that compacts first is not given up on after
1895
+ // `INJECTED_TURN_WAIT`, and at the latest with the boundary, whose line the reply
1896
+ // shows. Only a turn that is due starts so: with no prompt sent and no report awaited,
1897
+ // either event would open a reply that no result is known to end.
1898
+ const compaction = (event.type === "compacted" || event.type === "compaction_started") &&
1899
+ (this.sent.length > 0 || this.injectedExpected);
1900
+ if (!TURN_EVENTS.has(event.type) && !compaction)
1901
+ return;
1902
+ worded = !compaction;
1903
+ if (call) {
1904
+ // A subagent's own event, whose call no reply holds: an agent continued with
1905
+ // `SendMessage` in a daemon that never saw the call that first started it (recorded:
1906
+ // `report-turn-agent-resume.jsonl`, CLI 2.1.286, whose events name that first call,
1907
+ // not the `SendMessage` one). It is no turn of the conversation's, so it starts none
1908
+ // and takes no owner prompt's.
1909
+ return;
1910
+ }
1911
+ }
1912
+ await this.startTurn(worded);
1913
+ // A report turn has no prompt behind it: only now does `threadLine` read it.
1914
+ this.showThreadStatus();
1915
+ }
1916
+ const active = this.active;
1917
+ if (active === null)
1918
+ return; // abandoned while the turn's opening was written
1919
+ if (event.type === "agent_error") {
1920
+ // The category only: the words Claude Code wrote about it go to the thread.
1921
+ logger.warning(`${this.deps.agent.name} reported an error in ${this.where}: ${event.category}`);
1922
+ }
1923
+ await active.renderer.feed(event);
1924
+ this.adoptPromoted(active.renderer);
1925
+ // A call's end can leave its task running in the background, which the list then counts.
1926
+ if (event.type === "call_ended" && active.renderer.backgroundTasks.length > 0)
1927
+ this.showRunning();
1928
+ if (event.type === "turn_ended") {
1929
+ // A turn that went to the model leaves the usage naming the model it ran on, whatever
1930
+ // the id a switch before it said (`model_changed`). A switch inside this turn is newer
1931
+ // than that usage and waits for the next one.
1932
+ if (event.steps !== null && event.steps > 0 && !this.modelSwitchedInTurn)
1933
+ this.modelSwitched = null;
1934
+ this.modelSwitchedInTurn = false;
1935
+ // The turn stays active until its reply is closed: the footer is read first, and a
1936
+ // drain that saw this thread idle meanwhile would exit before the reply's end, leaving
1937
+ // its stream open (#25).
1938
+ let turnStopped = false;
1939
+ // A `Working…` that shows stays until the footer is drawn, so the thread's bottom never
1940
+ // goes blank between the two; a line that shows something else, or nothing, is not turned
1941
+ // into it, and a stop clears it at once, as `interrupting` already does.
1942
+ this.turnEnding = this.threadLine()[0] === texts.THREAD_WORKING;
1943
+ try {
1944
+ try {
1945
+ turnStopped = await this.finish(active, event, signal);
1946
+ }
1947
+ finally {
1948
+ if (turnStopped)
1949
+ this.turnEnding = false;
1950
+ // A turn cut before its reply closed stays the active one, for `abandon` to end.
1951
+ if (!active.cut) {
1952
+ this.active = null;
1953
+ this.compacting = false;
1954
+ this.showThreadStatus(); // whichever way it ended, `!stop` included
1955
+ // An end told after this turn had begun is reported in a turn to come, which
1956
+ // nothing waits for yet: the turn's start cancelled the wait.
1957
+ if (this.ended.length > 0)
1958
+ this.notified();
1959
+ }
1960
+ }
1961
+ // Checked only now, with the turn no longer active: `finish` alone still reads busy.
1962
+ // `!stop` already reacted ✅ itself; the stop's end is the session's end all the same.
1963
+ if (!turnStopped)
1964
+ await this.reactDoneIfIdle(signal);
1965
+ else
1966
+ await this.showFooterIfEnded(signal);
1967
+ }
1968
+ finally {
1969
+ this.turnEnding = false;
1970
+ this.showThreadStatus();
1971
+ }
1972
+ }
1973
+ }
1974
+ recordTask(event) {
1975
+ this.tasks.set(event.taskId, [event.kind, event.description, event.agentType]);
1976
+ if (this.mayHaveOrderedRestart)
1977
+ this.afterRestart.add(event.taskId);
1978
+ const starts = (this.starts.get(event.taskId) ?? 0) + 1;
1979
+ this.starts.delete(event.taskId); // the latest start is the newest entry
1980
+ this.starts.set(event.taskId, starts);
1981
+ const kept = this.deps.tasksKept ?? TASKS_KEPT;
1982
+ for (const known of [this.tasks, this.starts]) {
1983
+ for (const taskId of known.keys()) {
1984
+ if (known.size <= kept)
1985
+ break;
1986
+ known.delete(taskId);
1987
+ }
1988
+ }
1989
+ }
1990
+ /**
1991
+ * Tasks that just outlived the nested call that started them are ordinary background tasks
1992
+ * from now on: recorded and counted as one that outlived its turn.
1993
+ */
1994
+ adoptPromoted(renderer) {
1995
+ const promoted = renderer.takePromoted();
1996
+ for (const started of promoted) {
1997
+ this.recordTask(started);
1998
+ this.taskReplies.set(started.taskId, renderer);
1999
+ this.ownedBySubagent.add(started.taskId);
2000
+ }
2001
+ if (promoted.length > 0)
2002
+ this.showRunning();
2003
+ }
2004
+ /**
2005
+ * The terminal's line for a task's end: a command's own summary, or `Agent "..." finished`
2006
+ * from the task's description, plus the duration when the task reports one. An agent that
2007
+ * failed says why, as the terminal's line does (`Agent "..." failed: <reason>`, captured
2008
+ * 2026-10-13 on Claude Code 2.1.296): its summary is the reason then, not a report.
2009
+ */
2010
+ endedLine(event) {
2011
+ const [kind, description] = this.tasks.get(event.taskId) ?? ["other", ""];
2012
+ let text;
2013
+ if (SUMMARY_IS_END_LINE.has(kind) && event.summary) {
2014
+ text = oneLine(event.summary, 200);
2015
+ }
2016
+ else {
2017
+ const origin = this.taskReplies.get(event.taskId);
2018
+ const label = description || (origin?.taskTitle(event.taskId) ?? null);
2019
+ const name = TASK_WORDS[kind][1];
2020
+ const outcome = event.status === "completed" ? "finished" : event.status;
2021
+ text = `${name} "${oneLine(label || event.taskId, 120)}" ${outcome}`;
2022
+ if (kind === "subagent" && event.status === "failed" && event.summary) {
2023
+ text += `: ${oneLine(event.summary, FAILURE_LIMIT)}`;
2024
+ }
2025
+ }
2026
+ return endedLine(text, event.status, event.durationMs);
2027
+ }
2028
+ /**
2029
+ * What opens a report of the agent's own turn (the end of each task it reports), and the
2030
+ * reply of the first of those tasks (then of the task the agent named) that is still tracked,
2031
+ * not ended and the last thing in the thread: the report renders there. When none is (a
2032
+ * restart or an idle close dropped it, it already ended because a notification later than
2033
+ * INJECTED_TURN_WAIT let `expireUnreported` end it first, or something sits below it), the
2034
+ * report renders into the thread's last reply if it is at rest (`restingLast`). Null when that
2035
+ * fails too: the report then gets a reply of its own at the bottom, rather than an edit of a
2036
+ * reply that ended and will not notify again or one the owner has scrolled past. The text is null when
2037
+ * no end line opens the report: the SDK says nothing of what woke a turn with none (a
2038
+ * Monitor's event, issue #311), and a report the agent named the task of needs none.
2039
+ */
2040
+ openingTarget() {
2041
+ // The lines set aside when this turn began, when it began with any; a task that ended
2042
+ // since waits for the turn that reports it.
2043
+ const lines = this.opening ?? this.ended;
2044
+ if (this.opening === null)
2045
+ this.ended = [];
2046
+ this.opening = null;
2047
+ // The task the agent named for this turn, when no end line names one: a subagent's
2048
+ // hand-back can come before its task's end, or after an end that came inside a turn.
2049
+ const reported = this.reportOf;
2050
+ this.reportOf = null;
2051
+ // The report belongs at the bottom of the thread: only a reply that is still the last thing
2052
+ // in it takes the report in; one with a message of the owner's or of another reply below
2053
+ // it would hide the report above them (issue #295).
2054
+ const candidates = [
2055
+ ...lines.map(([taskId]) => taskId),
2056
+ ...this.unfolded.keys(),
2057
+ ...(reported === null ? [] : [reported]),
2058
+ ];
2059
+ let target = null;
2060
+ for (const taskId of candidates) {
2061
+ const reply = this.unfolded.get(taskId) ?? this.taskReplies.get(taskId) ?? this.showing(taskId);
2062
+ if (reply !== null && !reply.closedOut && this.reply(reply).last) {
2063
+ target = reply;
2064
+ break;
2065
+ }
2066
+ }
2067
+ // None of those replies qualifies (or the turn names no task, a Monitor's event): the
2068
+ // report goes where the owner is looking, into the last reply when it is at rest.
2069
+ target ??= this.restingLast();
2070
+ // The tasks that ended inside a turn are reported now, or given up on.
2071
+ this.unfolded.clear();
2072
+ const ends = lines.map(([, line]) => line).join("\n");
2073
+ // End lines are lines about the work.
2074
+ return [ends || null, target, ends !== ""];
2075
+ }
2076
+ /**
2077
+ * The thread's last reply when it is at rest: its turn ended, it still owes a task or a
2078
+ * report (tracked, not ended) and nothing sits below it. A report no reply of its own tasks
2079
+ * can take renders there, as the terminal prints everything in order, instead of opening a
2080
+ * message and a push of its own. Null when the last thing in the thread is a message of the
2081
+ * owner's or a reply that has ended.
2082
+ */
2083
+ restingLast() {
2084
+ for (const reply of this.resting)
2085
+ if (reply.closedOut)
2086
+ this.resting.delete(reply);
2087
+ const tracked = new Set([
2088
+ ...this.taskReplies.values(),
2089
+ ...this.unfolded.values(),
2090
+ ...this.resting,
2091
+ ]);
2092
+ for (const reply of tracked) {
2093
+ if (!reply.closedOut && this.reply(reply).last)
2094
+ return reply;
2095
+ }
2096
+ return null;
2097
+ }
2098
+ /**
2099
+ * A reply still tracked that shows `taskId` though the task is not its to wait for: a
2100
+ * task that ended inside the turn that started it is tracked nowhere (`closeReply` records the
2101
+ * ones that outlive the turn), and its reply stays open only for another task of that turn.
2102
+ */
2103
+ showing(taskId) {
2104
+ for (const reply of this.taskReplies.values()) {
2105
+ if (reply.shows(taskId))
2106
+ return reply;
2107
+ }
2108
+ return null;
2109
+ }
2110
+ /**
2111
+ * Whether a turn's end is all there is of a turn the agent started and nothing waits to be
2112
+ * said with it: no step taken, no text, no task's end line, no held task event, no notice.
2113
+ * Claude Code gives a task's notification such a turn once the task's report was handed back
2114
+ * (measured 2026-10-10, `subagent-handback.jsonl`). With an end line or a held event waiting,
2115
+ * the turn is the report that says them.
2116
+ */
2117
+ saidNothing(event) {
2118
+ return (event.startedBy === "agent" &&
2119
+ event.steps === 0 &&
2120
+ !event.finalText &&
2121
+ event.ending === "done" &&
2122
+ this.ended.length === 0 &&
2123
+ this.opening === null &&
2124
+ this.held.length === 0 &&
2125
+ this.notice === null);
2126
+ }
2127
+ /**
2128
+ * The turn the agent started and that did nothing has ended: no reply is opened for it, and
2129
+ * what waited on a report turn stops waiting, as at the end of one (`startTurn`, `settle`).
2130
+ */
2131
+ async nothingReported(event, signal) {
2132
+ if (event.sessionId) {
2133
+ this.deps.state.setSession(this.channelId, this.threadId, event.sessionId);
2134
+ }
2135
+ this.reportOf = null;
2136
+ this.injectedExpected = false;
2137
+ this.expiry?.cancel();
2138
+ this.notificationWaits = false;
2139
+ this.settled.set();
2140
+ // A reply that waited only for this report has nothing more coming.
2141
+ await this.sweepClosedOut(live(signal));
2142
+ await this.reactDoneIfIdle(signal);
2143
+ }
2144
+ /**
2145
+ * A task ended while no turn runs. The agent starts a turn to report it, unless an owner
2146
+ * prompt already sits in its queue: that turn comes first and carries the report (measured
2147
+ * 2026-09-24, Claude Code 2.1.280), and no turn of its own follows.
2148
+ */
2149
+ notified() {
2150
+ if (this.background && this.sent.length === 0)
2151
+ this.expectInjectedTurn();
2152
+ }
2153
+ expectInjectedTurn() {
2154
+ this.injectedExpected = true;
2155
+ this.settled.clear();
2156
+ if (this.expiry === null || this.expiry.done) {
2157
+ this.expiry = new Task((signal) => this.expireInjectedTurn(signal));
2158
+ }
2159
+ }
2160
+ async expireInjectedTurn(signal) {
2161
+ await this.deps.clock.sleep(INJECTED_TURN_WAIT, signal);
2162
+ if (!this.injectedExpected || this.active !== null)
2163
+ return;
2164
+ logger.warning(`no turn followed a task notification in ${this.where}`);
2165
+ this.injectedExpected = false;
2166
+ const held = this.held;
2167
+ this.held = [];
2168
+ // An end line waits to be said: of a task with no card (a command or an agent in the
2169
+ // background), nothing else in the reply says that it ended.
2170
+ const pending = this.opening ?? this.ended;
2171
+ const unsaid = pending.map(([, line]) => line);
2172
+ const said = unsaid.length > 0;
2173
+ // The reply that holds the first of those tasks, whatever sits below it.
2174
+ const [first] = pending;
2175
+ const holder = first === undefined ? null : (this.taskReplies.get(first[0]) ?? this.showing(first[0]));
2176
+ const [text, target, aside] = this.openingTarget();
2177
+ try {
2178
+ if (held.length > 0) {
2179
+ const renderer = target ?? this.newRenderer(this.newReply());
2180
+ await this.standalone(held, text, aside, renderer, signal);
2181
+ }
2182
+ else if (said && target !== null && text !== null) {
2183
+ // Into the reply that started the task, which the sweep below then ends. A reply that
2184
+ // is no longer the last thing in the thread gets no line: none is posted for it.
2185
+ await target.feedLine(text);
2186
+ }
2187
+ else if (said && this.promptOwed()) {
2188
+ // A message of the owner's waits below that reply: its own reply says the line.
2189
+ this.endedAhead.push(...unsaid);
2190
+ }
2191
+ else if (said && holder !== null && !holder.closedOut) {
2192
+ // Something sits below that reply and nothing is coming: the line is written where the
2193
+ // task started, by an edit that notifies nobody, as its card ended in place before.
2194
+ await holder.feedLine(unsaid.join("\n"));
2195
+ }
2196
+ }
2197
+ catch (error) {
2198
+ if (error instanceof Cancelled)
2199
+ throw error;
2200
+ logger.warning(`could not post a background update in ${this.where}: ${errorName(error)}`);
2201
+ }
2202
+ finally {
2203
+ // A notification that arrived while this wrote is expected on a wait of its own, armed
2204
+ // below: `expectInjectedTurn` found this task still running and started none, and it
2205
+ // cleared `settled`, which stays so. (`startTurn` cancelling this task leaves
2206
+ // `injectedExpected` false.)
2207
+ if (!this.injectedExpected)
2208
+ this.settled.set();
2209
+ try {
2210
+ // Catches `target` (nothing more coming for it either, when `held` was empty) and any
2211
+ // other reply a joint report turn left stranded, since a report only ever renders into
2212
+ // one reply, the first of the tasks it covers that is the last thing in the thread.
2213
+ // A standalone update was posted for `held`: something sits under the replies it left.
2214
+ await this.sweepClosedOut(live(signal), { unreported: held.length === 0 });
2215
+ await this.reactDoneIfIdle(live(signal));
2216
+ }
2217
+ finally {
2218
+ // (sync) Whatever the sweep did: a notification that arrived during its own writes is
2219
+ // waiting on a timer this task alone can arm, since nothing awaits between here and its
2220
+ // end. None once the session is closed: `close` has cancelled this task and a timer
2221
+ // made now would outlive `cancelTasks`.
2222
+ if (this.injectedExpected && !this.closedFlag) {
2223
+ this.expiry = new Task((waiting) => this.expireInjectedTurn(waiting));
2224
+ }
2225
+ // Runs outside `read`'s loop (its own INJECTED_TURN_WAIT timer), so nothing else
2226
+ // re-checks idleness for this transition: it may need to arm the timer itself.
2227
+ this.idleTimerCheck();
2228
+ }
2229
+ }
2230
+ }
2231
+ /**
2232
+ * Show `state` on the thread's root, asked for at once and never awaited: a status must
2233
+ * never delay a turn, and the chat makes the changes in the order asked and swallows their
2234
+ * failures. `errorStanding` follows the state asked for last, set here synchronously, since
2235
+ * what the chat shows changes only once it has answered.
2236
+ */
2237
+ react(state) {
2238
+ this.errorStanding = state === "error";
2239
+ this.noteStatus(state);
2240
+ this.showThreadStatus();
2241
+ if (this.reacts)
2242
+ this.thread.showStatus(state).catch(() => { });
2243
+ }
2244
+ /**
2245
+ * Crash repair (issue #19): the root's status while it is ⏳ or ✋, cleared once ✅ or ❌ is
2246
+ * requested; that one is kept as the thread's ended status, which the session index shows.
2247
+ * Called from `react` and from the two other places that ask the chat for a status directly
2248
+ * (`close`'s ❌, `reactDoneIfIdle`'s ✅).
2249
+ */
2250
+ noteStatus(state) {
2251
+ const pending = state === "working" || state === "waiting";
2252
+ this.recordStatus(() => this.deps.state.setStatusPending(this.channelId, this.threadId, pending ? state : null, pending ? null : state));
2253
+ }
2254
+ /**
2255
+ * Run a write of the root's status to `state.json`. Best-effort, like the other crash-repair
2256
+ * writes: a failed one (a folder that cannot be written, a full disk) is logged by the
2257
+ * error's name and the session goes on, since the status is bookkeeping for a repair that
2258
+ * may never be needed and must not fail a prompt, an approval or a close.
2259
+ */
2260
+ recordStatus(write) {
2261
+ try {
2262
+ write();
2263
+ }
2264
+ catch (error) {
2265
+ logger.warning(`could not record the root's status in state.json for ${this.where}: ${errorName(error)}`);
2266
+ }
2267
+ }
2268
+ /**
2269
+ * ❌ is shown while the persisted ⏳ or ✋ stays for crash repair (an answer never reached the
2270
+ * chat): kept as the ended status, which is what the session index shows.
2271
+ */
2272
+ noteCrossOverStatus() {
2273
+ const stored = this.deps.state.thread(this.channelId, this.threadId);
2274
+ if (stored !== null) {
2275
+ this.recordStatus(() => this.deps.state.setStatusPending(this.channelId, this.threadId, stored.status, "error"));
2276
+ }
2277
+ }
2278
+ /**
2279
+ * ❌. It stands until another state is asked for: `react` records it in `errorStanding` at
2280
+ * once, before the chat answers, so a quick turn's own idle sweep cannot mistake this session
2281
+ * for done.
2282
+ */
2283
+ reactError() {
2284
+ this.react("error");
2285
+ }
2286
+ /**
2287
+ * Back to waiting, or working, right after an approval or question settles: whichever the
2288
+ * thread still holds, since a parallel tool call can leave another one open.
2289
+ */
2290
+ reactWaitingOrWorking() {
2291
+ this.react(this.waitingForOwner ? "waiting" : "working");
2292
+ }
2293
+ /**
2294
+ * ✅, once the reply that just ended turns out to have been the last thing the session owed:
2295
+ * awaited, unlike `react`, so the checkmark never shows before the reply's end has landed. A
2296
+ * no-op while another prompt, a running task or an unreported one still keeps the session
2297
+ * going, or while `errorStanding` says a ❌ from `abandon` already stands: that lasts until
2298
+ * new work starts (a submit or a report turn shows ⏳ again, both clearing it), not until
2299
+ * some unrelated task's own sweep decides the session reads idle again. The footer is read
2300
+ * and drawn beside it (`showFooterIfEnded`).
2301
+ */
2302
+ async reactDoneIfIdle(signal) {
2303
+ this.showThreadStatus();
2304
+ let done = Promise.resolve();
2305
+ if (this.sessionEnded() && !this.errorStanding) {
2306
+ this.noteStatus("done");
2307
+ if (this.reacts)
2308
+ done = this.thread.showStatus("done", signal);
2309
+ }
2310
+ else {
2311
+ logger.info(`no done reaction in ${this.where}, held by: ${this.doneHeldBy().join(", ")}`);
2312
+ }
2313
+ // Beside the checkmark, which never waits on the agent the footer asks; and not held back
2314
+ // by an error: an error ends the session too, and its footer says how it stood.
2315
+ await Promise.all([done, this.showFooterIfEnded(signal)]);
2316
+ }
2317
+ /**
2318
+ * Whether the session has ended everything it was doing: nobody to answer, nothing running,
2319
+ * owed, queued or expected, and every reply's end landed. The one definition behind the
2320
+ * checkmark and the footer.
2321
+ */
2322
+ sessionEnded() {
2323
+ return !this.waitingForOwner && this.unlanded.size === 0 && this.quiet({ awaitedOnly: false });
2324
+ }
2325
+ /**
2326
+ * The footer, once the session has ended everything it was doing (`sessionEnded`): under the
2327
+ * reply its latest work wrote into, by an edit. Owed until the chat says it landed there; work
2328
+ * that starts while it is read owes its own, on its own reply, and this one is not drawn.
2329
+ * `live` false leaves the agent unasked (see `readFooter`).
2330
+ */
2331
+ async showFooterIfEnded(signal, options = {}) {
2332
+ const reply = this.footerOwedTo;
2333
+ if (reply === null || !this.sessionEnded())
2334
+ return;
2335
+ const footer = await this.readFooter(signal, options.live ?? true);
2336
+ if (footer === null || this.footerOwedTo !== reply || !this.sessionEnded())
2337
+ return;
2338
+ if (await reply.showFooter(footer, signal)) {
2339
+ if (this.footerOwedTo === reply)
2340
+ this.footerOwedTo = null;
2341
+ }
2342
+ }
2343
+ /**
2344
+ * The footer as the session stands now; null when it would say nothing or cannot be read.
2345
+ * `live` false leaves the agent unasked (a forced end: its process is lost, closing or busy
2346
+ * with a turn that was cut, and would not answer).
2347
+ */
2348
+ async readFooter(signal, live = true) {
2349
+ try {
2350
+ return await cancellable(this.footer(this.sessionTokens, live), signal);
2351
+ }
2352
+ catch (error) {
2353
+ if (error instanceof Cancelled)
2354
+ throw error;
2355
+ // the end still lands, with no footer
2356
+ logger.warning(`could not build the footer in ${this.where}: ${errorName(error)}`);
2357
+ return null;
2358
+ }
2359
+ }
2360
+ /**
2361
+ * What keeps `reactDoneIfIdle` from ✅ right now, as flag names and counts, never content:
2362
+ * every condition it and `quiet` read, so a root left on ⏳ can be traced to one of them from
2363
+ * the log.
2364
+ */
2365
+ doneHeldBy() {
2366
+ const counts = [
2367
+ ["waiting_for_owner", this.waiting.size],
2368
+ ["sent", this.sent.length],
2369
+ ["pending_submits", this.pendingSubmits],
2370
+ ["queued", this.queue.size],
2371
+ ["unlanded", this.unlanded.size],
2372
+ ];
2373
+ const flags = [
2374
+ ["active", this.active !== null],
2375
+ ["taken", this.taken !== null],
2376
+ ["unsettled", !this.settled.isSet],
2377
+ ["error_standing", this.errorStanding],
2378
+ ];
2379
+ const held = counts.filter(([, count]) => count > 0).map(([name, count]) => `${name}=${count}`);
2380
+ held.push(...flags.filter(([, on]) => on).map(([name]) => name));
2381
+ const running = this.runningKindsOf();
2382
+ if (running)
2383
+ held.push(`running=${running}`);
2384
+ return held;
2385
+ }
2386
+ /**
2387
+ * A reply whose end failed is not done: no ✅ until its one retry lands, ❌ if that fails
2388
+ * too. The persisted status stays as it is meanwhile (and after a final failure), so a crash
2389
+ * still gets the repair's notice.
2390
+ */
2391
+ trackLanding(landed, reply) {
2392
+ if (landed)
2393
+ return;
2394
+ this.unlanded.add(reply);
2395
+ if (this.closedFlag)
2396
+ return; // `close` settles every unlanded reply itself
2397
+ this.awaitLanding(reply).catch(() => { });
2398
+ }
2399
+ async awaitLanding(reply) {
2400
+ const landed = await reply.waitLanded();
2401
+ this.unlanded.delete(reply);
2402
+ if (this.closedFlag)
2403
+ return;
2404
+ if (landed) {
2405
+ await this.reactDoneIfIdle();
2406
+ }
2407
+ else {
2408
+ // ❌ shown, but `state.json` keeps ⏳ or ✋: the answer never reached the chat. Its reply
2409
+ // takes no footer (`Reply.showFooter`).
2410
+ this.errorStanding = true;
2411
+ this.noteCrossOverStatus();
2412
+ if (this.reacts)
2413
+ this.thread.showStatus("error").catch(() => { });
2414
+ // It no longer holds the session's end back: a later reply may have ended meanwhile.
2415
+ await this.showFooterIfEnded();
2416
+ }
2417
+ }
2418
+ /**
2419
+ * (Re)arm the idle-close timer when the session is now idle with no approval or question
2420
+ * pending; cancel it otherwise. Cheap and idempotent, so every place that could change either
2421
+ * just calls it again.
2422
+ */
2423
+ idleTimerCheck() {
2424
+ if (this.idleExpiry !== null) {
2425
+ this.idleExpiry.cancel();
2426
+ this.idleExpiry = null;
2427
+ }
2428
+ if (this.closedFlag || this.draining || this.waitingForOwner || !this.idle)
2429
+ return;
2430
+ this.idleExpiry = new Task((signal) => this.closeWhenIdle(signal));
2431
+ }
2432
+ /**
2433
+ * Close an agent process nothing has needed for IDLE_CLOSE_SECONDS. Silent: idle means
2434
+ * nothing is running, sent or queued for `close()` to end with a line.
2435
+ */
2436
+ async closeWhenIdle(signal) {
2437
+ await this.deps.clock.sleep(IDLE_CLOSE_SECONDS, signal);
2438
+ if (this.closedFlag || this.draining || this.waitingForOwner || !this.idle) {
2439
+ return; // something started again before the hour was up
2440
+ }
2441
+ logger.info(`closing an idle session in ${this.where}`);
2442
+ // Cleared first: `close()` cancels its tasks through `cancelTasks`, and a task cannot
2443
+ // await itself.
2444
+ this.idleExpiry = null;
2445
+ await this.close(texts.ENDED_IDLE);
2446
+ }
2447
+ /**
2448
+ * The prompt the turn that starts now answers, taken out of `sent`; null for a turn the
2449
+ * agent starts itself, to report a task.
2450
+ *
2451
+ * Claude Code replays a prompt of plain text after its turn's `init` and before the turn's
2452
+ * first words, and replays nothing before a report turn: so in every turn of the fixtures
2453
+ * `prompt-replay-*`, `compact` and `auto-compact*` (CLI 2.1.286 and 2.1.292). A replay
2454
+ * therefore names the turn's prompt, whatever was awaited. And when a task's notification is
2455
+ * waiting to be reported, a turn that opens with words (`worded`) and no replay is that
2456
+ * report, not the plain prompt that waits beside it.
2457
+ *
2458
+ * Everywhere else the answer is the older guess, which `settle` corrects at the turn's end.
2459
+ * No recording covers those turns: one that opens with a compaction starts before its
2460
+ * replay, a command (`/compact`) is not replayed as a prompt is, and a prompt with an image,
2461
+ * a turn that fails before its first word (a logged-out CLI, a limit reached) and a turn with
2462
+ * no notification waiting were never recorded without their replay.
2463
+ */
2464
+ whoseTurn(worded) {
2465
+ const announced = this.announced;
2466
+ this.announced = null;
2467
+ if (announced !== null && this.sent.includes(announced)) {
2468
+ this.sent.splice(this.sent.indexOf(announced), 1);
2469
+ this.reportDue = this.injectedExpected;
2470
+ return announced;
2471
+ }
2472
+ // The agent said whose report this turn is: no prompt's, even with one waiting.
2473
+ if (this.reportOf !== null)
2474
+ return null;
2475
+ const first = this.sent[0];
2476
+ if (this.injectedExpected || first === undefined)
2477
+ return null;
2478
+ const head = first.prompt;
2479
+ const plain = typeof head === "string" && !strip(head).startsWith("/");
2480
+ if (worded && this.notificationWaits && plain)
2481
+ return null;
2482
+ this.sent.shift();
2483
+ return first;
2484
+ }
2485
+ async startTurn(worded) {
2486
+ // Skipped while `stop()` is still winding an interrupt down (`finish` clears the flag once
2487
+ // that very turn's own end says so): this can be that turn's own trailing events, not a
2488
+ // new one, and `stop()`'s own ✅ must stand. `read` raises it too, while it settles a lost
2489
+ // process's requests, for the same reason.
2490
+ if (!this.interrupting) {
2491
+ this.errorStanding = false; // a turn is sent, or a report turn starts: new work
2492
+ // A background task's own report turn can start while a hold waits on this very
2493
+ // session; ✋ must stand through it, not be overwritten by ⏳ (`waitingForOwner` covers a
2494
+ // hold the same way it covers an open approval or question).
2495
+ this.react(this.waitingForOwner ? "waiting" : "working");
2496
+ }
2497
+ const turn = this.whoseTurn(worded);
2498
+ // An agent that takes no prompt into a running turn is sent the next one once this turn has
2499
+ // ended: `work` waits on `settled`, as it does for a report turn that was expected.
2500
+ if (turn === null && !this.deps.agent.capabilities.promptDuringTurn)
2501
+ this.settled.clear();
2502
+ this.injectedExpected = false;
2503
+ this.expiry?.cancel();
2504
+ let renderer;
2505
+ let opening = null;
2506
+ let openingAside = false;
2507
+ if (turn === null) {
2508
+ this.notificationWaits = false;
2509
+ // A report turn renders into the reply that started a task it reports, so no new message
2510
+ // follows for it, but only a reply that is still tracked, not ended and the last thing in
2511
+ // the thread (`openingTarget`); otherwise into the last reply when at rest, and with none
2512
+ // it gets a reply of its own at the bottom.
2513
+ const [text, target, aside] = this.openingTarget();
2514
+ renderer = target ?? this.newRenderer(this.newReply());
2515
+ opening = text;
2516
+ openingAside = aside;
2517
+ }
2518
+ else {
2519
+ this.reportOf = null; // the prompt's own turn: what was named is not the next turn
2520
+ renderer = this.newRenderer(turn.sink);
2521
+ // The ends told since the last turn and not reported: the agent takes their notifications
2522
+ // into this turn, and the terminal prints their lines before its answer.
2523
+ const unsaid = [...(this.opening ?? []), ...this.ended].map(([, line]) => line);
2524
+ this.opening = null;
2525
+ this.ended = [];
2526
+ this.endedAhead.unshift(...unsaid);
2527
+ }
2528
+ // (sync) The turn is the active one before its first write: every `await` below yields, and
2529
+ // a turn already out of `sent` and not yet active would read as idle to a drain, a `!stop`
2530
+ // or a timer.
2531
+ const active = {
2532
+ turn,
2533
+ renderer,
2534
+ taken: [],
2535
+ cut: false,
2536
+ released: [],
2537
+ takenNoted: false,
2538
+ unanswered: null,
2539
+ };
2540
+ this.active = active;
2541
+ this.footerOwedTo = this.reply(renderer);
2542
+ if (opening !== null)
2543
+ await renderer.feedOpening(opening, openingAside);
2544
+ const ahead = this.endedAhead;
2545
+ this.endedAhead = [];
2546
+ if (ahead.length > 0)
2547
+ await renderer.feedLine(ahead.join("\n"));
2548
+ if (this.notice !== null) {
2549
+ const notice = this.notice;
2550
+ this.notice = null;
2551
+ await renderer.feedNotice(notice);
2552
+ }
2553
+ const held = this.held;
2554
+ this.held = [];
2555
+ for (const event of held)
2556
+ await renderer.feed(event);
2557
+ return active;
2558
+ }
2559
+ /**
2560
+ * The turn the CLI never started to report an ended task, on its own reply (usually the one
2561
+ * that started the task, `renderer`; `expireInjectedTurn` resolves it).
2562
+ */
2563
+ async standalone(events, text, aside, renderer, signal) {
2564
+ this.footerOwedTo = this.reply(renderer);
2565
+ if (text !== null)
2566
+ await renderer.feedOpening(text, aside);
2567
+ for (const event of events)
2568
+ await renderer.feed(event);
2569
+ await this.closeReply(renderer, { signal });
2570
+ }
2571
+ async closeReply(renderer, options = {}) {
2572
+ for (const taskId of renderer.runningTasks)
2573
+ this.taskReplies.set(taskId, renderer);
2574
+ // Forgetting one whose reply has not ended yet would strand it exactly as a joint report
2575
+ // turn can: `stillOwed`, and the sweep that acts on it, both read this map.
2576
+ const ended = [...this.taskReplies]
2577
+ .filter(([taskId, reply]) => !reply.runningTasks.includes(taskId) && reply.closedOut)
2578
+ .map(([taskId]) => taskId);
2579
+ const kept = this.deps.taskRepliesKept ?? TASK_REPLIES_KEPT;
2580
+ for (const taskId of ended.slice(0, Math.max(0, this.taskReplies.size - kept))) {
2581
+ this.taskReplies.delete(taskId);
2582
+ }
2583
+ // Before the close, so the reply's last write already carries the list.
2584
+ this.showRunning();
2585
+ await renderer.close(options.signal);
2586
+ // The reply ends at once unless a task this renderer started outlives this very turn;
2587
+ // `force` is a stop, an error or a restart, which never waits for one.
2588
+ if (options.force || !this.stillOwed(renderer)) {
2589
+ this.trackLanding(await renderer.closeOut(options.signal), this.reply(renderer));
2590
+ }
2591
+ else {
2592
+ // Still owed a report: the reply rests, its stream stopped with no footer, so that the
2593
+ // list of what still runs shows under it.
2594
+ this.reply(renderer).rest();
2595
+ this.resting.add(renderer);
2596
+ }
2597
+ }
2598
+ /**
2599
+ * The agent's process is going away with its tasks: no reply keeps showing one, and none is
2600
+ * left waiting on an end that will now never come. Each ends at once, with whatever footer
2601
+ * its own turn already decided.
2602
+ */
2603
+ async stopTaskReplies() {
2604
+ const renderers = new Set(this.taskReplies.values());
2605
+ this.taskReplies.clear();
2606
+ this.resting.clear();
2607
+ this.tasks.clear();
2608
+ this.ended = [];
2609
+ this.opening = null;
2610
+ this.endedAhead = [];
2611
+ this.ownedBySubagent.clear();
2612
+ this.unreported.clear();
2613
+ this.notificationWaits = false;
2614
+ this.unfolded.clear();
2615
+ this.stopped.clear();
2616
+ this.refusedTasks.clear();
2617
+ for (const renderer of renderers) {
2618
+ await renderer.stopRunning().catch(() => { });
2619
+ }
2620
+ // Before closeOut: the list must already read empty, or the reply that holds it would end
2621
+ // with a stale card.
2622
+ this.showRunning();
2623
+ for (const renderer of renderers) {
2624
+ try {
2625
+ this.trackLanding(await renderer.closeOut(), this.reply(renderer));
2626
+ }
2627
+ catch {
2628
+ // the others still end
2629
+ }
2630
+ }
2631
+ // The cards `stopRunning` just ended, and the list above, only debounce: a caller closing
2632
+ // everything down cannot wait a whole debounce for them.
2633
+ for (const renderer of renderers) {
2634
+ await this.reply(renderer)
2635
+ .settle()
2636
+ .catch(() => { });
2637
+ }
2638
+ }
2639
+ /**
2640
+ * The reply that holds the root of the call this task event's task belongs to, when a call
2641
+ * inside another call started it. Null when no tracked reply can tell (a restart dropped
2642
+ * it): the task is then treated as any other, since a top-level task of a call no reply holds
2643
+ * looks the same.
2644
+ */
2645
+ nestingReply(event) {
2646
+ const replies = new Set(this.taskReplies.values());
2647
+ if (this.active !== null)
2648
+ replies.add(this.active.renderer);
2649
+ for (const reply of replies)
2650
+ if (reply.nests(event))
2651
+ return reply;
2652
+ return null;
2653
+ }
2654
+ originOf(callId) {
2655
+ for (const reply of this.taskReplies.values())
2656
+ if (reply.owns(callId))
2657
+ return reply;
2658
+ return null;
2659
+ }
2660
+ /**
2661
+ * Whether one of `renderer`'s own tasks still keeps its reply's stream open: one still runs,
2662
+ * or ended with its end line still to be said, or ended less than INJECTED_TURN_WAIT ago with
2663
+ * no notification yet (its report, if any, not in yet). The CLI can suppress the notification altogether (SDK TaskUpdatedMessage
2664
+ * docstring), so past that wait this stops counting it: `expireUnreported` rechecks then,
2665
+ * since nothing else would.
2666
+ */
2667
+ stillOwed(renderer) {
2668
+ if (this.owesItself(renderer))
2669
+ return true;
2670
+ // The thread's last reply also waits while a reply above it does: that reply is no longer
2671
+ // last, so its report renders here (`restingLast`), and closing this one out now would
2672
+ // leave the report a reply of its own. The expiry (`expireUnreported`) and the sweep end
2673
+ // such a wait when no report comes.
2674
+ if (!this.reply(renderer).last)
2675
+ return false;
2676
+ for (const other of new Set([...this.taskReplies.values(), ...this.unfolded.values()])) {
2677
+ if (other !== renderer && !other.closedOut && this.owesItself(other))
2678
+ return true;
2679
+ }
2680
+ return false;
2681
+ }
2682
+ /** Whether one of `renderer`'s own tasks keeps it open (`stillOwed` says which). */
2683
+ owesItself(renderer) {
2684
+ if (renderer.runningTasks.length > 0)
2685
+ return true;
2686
+ // An end line not yet said belongs to a turn to come, or to the wait for one: the reply
2687
+ // that started its task stays open to take it.
2688
+ for (const [taskId] of [...(this.opening ?? []), ...this.ended]) {
2689
+ if (this.taskReplies.get(taskId) === renderer)
2690
+ return true;
2691
+ }
2692
+ // A task that ended inside a turn without that turn taking its notification: the report
2693
+ // turn is due and renders into this reply.
2694
+ for (const reply of this.unfolded.values())
2695
+ if (reply === renderer)
2696
+ return true;
2697
+ const now = this.deps.clock.time();
2698
+ for (const [taskId, ended] of this.unreported) {
2699
+ if (now - ended < INJECTED_TURN_WAIT && this.taskReplies.get(taskId) === renderer) {
2700
+ return true;
2701
+ }
2702
+ }
2703
+ return false;
2704
+ }
2705
+ /**
2706
+ * A report turn renders into one reply only (the first of the tasks it covers that is the
2707
+ * last thing in the thread, `openingTarget`) when several end together, so every other reply
2708
+ * whose own tasks also finished is checked here instead, since nothing else rechecks it once
2709
+ * the wait that deferred it lifts. `options.unreported` says that no report followed (the
2710
+ * notification never came): a reply closed out then with a message of the owner's or of another
2711
+ * reply below it is logged. Only while nothing is still expected or running session-wide:
2712
+ * `closeReply` already handles the renderer whose own turn or report just ended, and a
2713
+ * currently active one is skipped outright, whatever it reads: its own turn has not closed it
2714
+ * yet, so nothing here is its call to make.
2715
+ */
2716
+ async sweepClosedOut(signal, options = {}) {
2717
+ const active = this.active?.renderer ?? null;
2718
+ for (const renderer of new Set(this.taskReplies.values())) {
2719
+ if (this.injectedExpected || !this.settled.isSet) {
2720
+ return; // a notification arrived meanwhile: its report may render into this reply
2721
+ }
2722
+ if (renderer !== active && !this.stillOwed(renderer)) {
2723
+ const reply = this.reply(renderer);
2724
+ // Read before the close: closing it out changes what the thread shows below it.
2725
+ if (options.unreported &&
2726
+ !renderer.closedOut &&
2727
+ !this.endedUnreported.has(renderer) &&
2728
+ !reply.last) {
2729
+ this.endedUnreported.add(renderer);
2730
+ logger.info(`a reply ended with no report under it in ${this.where}`);
2731
+ }
2732
+ this.trackLanding(await renderer.closeOut(signal), reply);
2733
+ }
2734
+ }
2735
+ }
2736
+ /**
2737
+ * Give up waiting on a task's notification after INJECTED_TURN_WAIT (the CLI can suppress
2738
+ * it) and sweep for whatever that frees.
2739
+ */
2740
+ async expireUnreported(taskId, signal) {
2741
+ await this.deps.clock.sleep(INJECTED_TURN_WAIT, signal);
2742
+ this.unreported.delete(taskId);
2743
+ await this.sweepClosedOut(signal, { unreported: true });
2744
+ await this.reactDoneIfIdle(signal);
2745
+ }
2746
+ /**
2747
+ * The replies this session still tracks, which hold a change that only debounced: the ones
2748
+ * whose end did not land, those a task outlives their turn in, and the active turn's.
2749
+ */
2750
+ openReplies() {
2751
+ const replies = new Set(this.unlanded);
2752
+ for (const renderer of this.taskReplies.values())
2753
+ replies.add(this.reply(renderer));
2754
+ if (this.active !== null)
2755
+ replies.add(this.reply(this.active.renderer));
2756
+ return replies;
2757
+ }
2758
+ /** The tasks still running: those that outlived their turn, and the running turn's. */
2759
+ runningTaskIds() {
2760
+ const outlived = [...this.taskReplies]
2761
+ .filter(([taskId, reply]) => reply.backgroundTasks.includes(taskId))
2762
+ .map(([taskId]) => taskId);
2763
+ // What runs in the background, as the terminal counts it: a command the turn is still
2764
+ // waiting for shows on its own line and is no shell left running.
2765
+ const current = this.active?.renderer.backgroundTasks ?? [];
2766
+ return [...new Set([...outlived, ...current])];
2767
+ }
2768
+ /**
2769
+ * `1 shell · 2 agents`, or empty when no task outlived its turn. `awaitedOnly` leaves out
2770
+ * the tasks a stop does not wait for (`mayHaveOrderedRestart`).
2771
+ */
2772
+ runningKindsOf(options = {}) {
2773
+ const counts = new Map();
2774
+ for (const [taskId, renderer] of this.taskReplies) {
2775
+ if (options.awaitedOnly && this.afterRestart.has(taskId))
2776
+ continue;
2777
+ if (renderer.backgroundTasks.includes(taskId)) {
2778
+ const counted = TASK_WORDS[this.tasks.get(taskId)?.[0] ?? "other"][0];
2779
+ counts.set(counted, (counts.get(counted) ?? 0) + 1);
2780
+ }
2781
+ }
2782
+ return [...counts].map(([kind, n]) => `${n} ${kind}${n > 1 ? "s" : ""}`).join(" · ");
2783
+ }
2784
+ showRunning() {
2785
+ this.showThreadStatus(); // the activity line follows the same tasks (it never counts them)
2786
+ this.thread.showRunning(this.runningList());
2787
+ }
2788
+ /**
2789
+ * What the chat draws above the footer under the thread's last reply at rest: `2 agents · 1
2790
+ * shell still running` and a line per task, in the order they started. Null when none runs.
2791
+ */
2792
+ runningList() {
2793
+ const counts = this.runningKindsOf();
2794
+ if (!counts)
2795
+ return null;
2796
+ const order = [...this.tasks.keys()];
2797
+ const rank = (taskId) => {
2798
+ const at = order.indexOf(taskId);
2799
+ return at === -1 ? Number.POSITIVE_INFINITY : at;
2800
+ };
2801
+ const lines = [...this.taskReplies]
2802
+ .filter(([taskId, renderer]) => renderer.backgroundTasks.includes(taskId))
2803
+ .sort(([a], [b]) => rank(a) - rank(b))
2804
+ .map(([taskId, renderer]) => {
2805
+ const [kind, description, agentType] = this.tasks.get(taskId) ?? ["other", "", null];
2806
+ let label;
2807
+ if (description && kind === "subagent") {
2808
+ // The terminal's row: the agent's type, then what it does.
2809
+ if (agentType !== null) {
2810
+ const type = oneLine(agentType, TITLE_LIMIT / 2);
2811
+ return `${type} ${oneLine(description, TITLE_LIMIT - Array.from(type).length - 2)}`;
2812
+ }
2813
+ label = `${TASK_WORDS.subagent[1]}: ${description}`;
2814
+ }
2815
+ else if (description) {
2816
+ label = description;
2817
+ }
2818
+ else {
2819
+ label = renderer.taskTitle(taskId) || taskId;
2820
+ }
2821
+ return oneLine(label, TITLE_LIMIT);
2822
+ });
2823
+ return { title: texts.fill(texts.STILL_RUNNING, { counts }), lines };
2824
+ }
2825
+ newRenderer(reply) {
2826
+ const renderer = new TurnRenderer(reply, this.names, this.directory, this.deps.chat.capabilities.nativeStreaming,
2827
+ // Recorded before any reply sees the start (`dispatch`), so a first start counts one.
2828
+ (taskId) => (this.starts.get(taskId) ?? 0) > 1, (id) => modelName(this.models, id),
2829
+ // Started in an earlier reply and ended: a message to it resumes it.
2830
+ (taskId) => this.starts.has(taskId) && !this.tasks.has(taskId), () => this.deps.clock.time());
2831
+ this.replyOf.set(renderer, reply);
2832
+ return renderer;
2833
+ }
2834
+ /** The reply `renderer` writes: a renderer of this session is always made by `newRenderer`. */
2835
+ reply(renderer) {
2836
+ const reply = this.replyOf.get(renderer);
2837
+ if (reply === undefined)
2838
+ throw new Error("a renderer this session did not make");
2839
+ return reply;
2840
+ }
2841
+ /** A new reply. It writes nothing until the agent has something to show. (sync) */
2842
+ newReply() {
2843
+ return this.thread.openReply((old, fresh) => this.deps.state.replaceOpenReply(this.channelId, this.threadId, old, fresh));
2844
+ }
2845
+ /**
2846
+ * `newReply`, as the step of `submit` that makes the reply. It does not wait; a test replaces
2847
+ * it with one that does, to hold a prompt between its arrival and its queue.
2848
+ */
2849
+ sink() {
2850
+ return this.newReply();
2851
+ }
2852
+ /**
2853
+ * Close the turn's reply and release it; true when `!stop` cut it short, which the caller
2854
+ * reads once the turn is no longer active (it still is, for `closeReply`'s own checks), to
2855
+ * skip the ✅, since `stop()` already showed its own.
2856
+ */
2857
+ async finish(active, result, signal) {
2858
+ let stopped = false;
2859
+ let takenNoted = false;
2860
+ const injected = result.startedBy === "agent";
2861
+ // The agent ends a turn it took prompts into with one result of its own for all of them.
2862
+ const released = injected ? active.taken : [];
2863
+ try {
2864
+ if (result.sessionId) {
2865
+ this.deps.state.setSession(this.channelId, this.threadId, result.sessionId);
2866
+ }
2867
+ // Kept for `!status`, even when this result reports none (`/usage`, `/clear`): the two
2868
+ // lines never disagree.
2869
+ this.sessionTokens = sessionTokens(result.tokens);
2870
+ stopped = result.ending === "interrupted";
2871
+ // A turn that did not end well is no ground to wait for a report under its reply.
2872
+ if (result.ending !== "done")
2873
+ this.unfolded.clear();
2874
+ this.interrupting = false; // this turn's own tail, whichever way it ended
2875
+ if (released.length > 0) {
2876
+ this.endNotes.push(takenNote(released.length, this.names));
2877
+ takenNoted = true;
2878
+ }
2879
+ // What a restart dropped meanwhile is said where the running turn ends.
2880
+ await this.feedEndNotes(active.renderer);
2881
+ // `!stop` cut this turn short, the owner's own or a report's: its reply ends like any
2882
+ // other, and never waits on a task it may still owe (`force`: `!stop` never does).
2883
+ await this.closeReply(active.renderer, { force: stopped, signal });
2884
+ }
2885
+ catch (error) {
2886
+ active.cut = this.cutBeforeClose(active, error);
2887
+ if (active.cut) {
2888
+ active.released = released;
2889
+ active.takenNoted = takenNoted;
2890
+ // `settle` sends such a prompt back to wait for its own turn, which will not come.
2891
+ const unanswered = injected && active.turn !== null && !released.includes(active.turn);
2892
+ active.unanswered = unanswered ? active.turn : null;
2893
+ }
2894
+ throw error;
2895
+ }
2896
+ finally {
2897
+ // Not settled when the cancel came before the reply closed: the turn stays active and
2898
+ // `abandon` ends the reply and releases the turn, as for a close while the turn runs.
2899
+ if (!active.cut) {
2900
+ this.settle(active.turn, result, released);
2901
+ this.expectUnfoldedReports();
2902
+ // A report turn's own reply is `closeReply`'s concern above; this catches every other
2903
+ // reply a joint one left stranded.
2904
+ await this.sweepClosedOut(live(signal));
2905
+ this.stopTailEnded();
2906
+ }
2907
+ }
2908
+ return stopped;
2909
+ }
2910
+ /**
2911
+ * A turn ended with a task that ended inside it and whose notification it did not take
2912
+ * (`unfolded`): the agent opens a turn of its own to report it, as for a task that ends between
2913
+ * turns, and the wait for that turn is the same one (`expectInjectedTurn`).
2914
+ */
2915
+ expectUnfoldedReports() {
2916
+ if (this.unfolded.size === 0)
2917
+ return;
2918
+ // Tracked as a reply a task outlived its turn in: the sweep that closes it out, if no report
2919
+ // turn comes, reads this map.
2920
+ for (const [taskId, reply] of this.unfolded)
2921
+ this.taskReplies.set(taskId, reply);
2922
+ this.notificationWaits = true;
2923
+ this.notified();
2924
+ }
2925
+ /**
2926
+ * Whether `error` is the cancel of the reader by a close (`closedFlag` is set before `close`
2927
+ * cancels it) before the turn's reply was closed out. `dropClient` cancels the reader too, and
2928
+ * leaves nothing to end the turn: it settles as for any other error. A reply whose end was
2929
+ * asked for is `close`'s to settle, so a cancel after that point keeps the turn ending as any
2930
+ * other.
2931
+ */
2932
+ cutBeforeClose(active, error) {
2933
+ return error instanceof Cancelled && this.closedFlag && !active.renderer.closedOut;
2934
+ }
2935
+ /**
2936
+ * Release whoever waits on this turn. The result says whose turn it really was: when an
2937
+ * owner prompt and a task notification cross, the guess made at the turn's start can be
2938
+ * wrong; this puts the queue back in order (that one reply carries the other's text).
2939
+ * `released`: prompts the agent took into this turn; no result of their own follows.
2940
+ */
2941
+ settle(turn, result, released) {
2942
+ const injected = result.startedBy === "agent";
2943
+ // Read once and lowered: only the turn it was raised for may await the report again.
2944
+ const reportDue = this.reportDue;
2945
+ this.reportDue = false;
2946
+ for (const taken of released) {
2947
+ const at = this.sent.indexOf(taken);
2948
+ if (at !== -1)
2949
+ this.sent.splice(at, 1);
2950
+ taken.done.set();
2951
+ }
2952
+ const waiting = this.sent[0];
2953
+ if (turn === null && !injected && waiting !== undefined) {
2954
+ logger.warning(`an owner reply in ${this.where} went to a background reply`);
2955
+ // Only misrouted, not failed, so no ❌. Its answer is already in the reply that was
2956
+ // guessed to be the report: the owner turn's own reply is never written.
2957
+ this.sent.shift();
2958
+ waiting.done.set();
2959
+ this.expectInjectedTurn();
2960
+ }
2961
+ else if (turn !== null && injected && !released.includes(turn)) {
2962
+ logger.warning(`a background reply in ${this.where} went to an owner reply`);
2963
+ // That reply is spent: the owner's own turn gets a fresh one.
2964
+ turn.sink = this.newReply();
2965
+ this.sent.unshift(turn);
2966
+ this.settled.set();
2967
+ }
2968
+ else if (turn !== null && !injected) {
2969
+ turn.done.set();
2970
+ if (reportDue) {
2971
+ // The report that was awaited when this prompt's turn started is still to come.
2972
+ this.expectInjectedTurn();
2973
+ }
2974
+ }
2975
+ else {
2976
+ this.settled.set();
2977
+ }
2978
+ }
2979
+ /**
2980
+ * The client is gone: end the reply that was open with `line`, and release whoever waits on
2981
+ * it, on the turns sent and on `more` (queued ones, when the session closes). Those get no
2982
+ * reply of their own: one note says they were not sent, `because` of what. With no turn
2983
+ * active, an `error` says `line` in each reply that still waited for a task, and says nothing
2984
+ * at all, the cross included, when nothing ran and nothing waited.
2985
+ *
2986
+ * A turn whose result had arrived when a close cut its reply's close (`ActiveTurn.cut`) is no
2987
+ * turn cut short: its answer is whole. Its reply ends without `line`, and the prompts its
2988
+ * result released get the note of a normal end, since the agent did take them (issue #263).
2989
+ * True when that was all there was to end: nothing was dropped and no task of the turn's still
2990
+ * ran, so the thread's status is the one of a turn that ended.
2991
+ */
2992
+ async abandon(line, options = {}) {
2993
+ const { error = false, more = [], because = texts.named(texts.BECAUSE_STOPPED, this.names), } = options;
2994
+ // (sync) The turn, the sent prompts and every flag of a turn in progress are taken before
2995
+ // the first write: whatever reads the session meanwhile sees it with no turn.
2996
+ const active = this.active;
2997
+ this.active = null;
2998
+ const ended = active?.cut ? active : null;
2999
+ const whole = ended !== null;
3000
+ const released = ended?.released ?? [];
3001
+ if (ended !== null && released.length > 0 && !ended.takenNoted) {
3002
+ this.endNotes.push(takenNote(released.length, this.names));
3003
+ }
3004
+ const unanswered = ended?.unanswered ?? null;
3005
+ const dropped = [
3006
+ ...(unanswered === null ? [] : [unanswered]),
3007
+ ...this.sent.filter((turn) => !released.includes(turn) && turn !== unanswered),
3008
+ ...more,
3009
+ ];
3010
+ this.sent = [];
3011
+ const waiting = [...(active?.turn ? [active.turn] : []), ...released, ...dropped];
3012
+ // What `reactDoneIfIdle` reads at a normal end, before anything below changes it: an error
3013
+ // that stands (a restart dropped prompts) and a task of any reply's that still runs keep the
3014
+ // root from the status of a turn that ended.
3015
+ const endedWell = ended !== null &&
3016
+ dropped.length === 0 &&
3017
+ !this.errorStanding &&
3018
+ ended.renderer.runningTasks.length === 0 &&
3019
+ !this.runningKindsOf();
3020
+ this.injectedExpected = false;
3021
+ this.announced = null;
3022
+ this.reportDue = false;
3023
+ this.notificationWaits = false;
3024
+ this.unfolded.clear();
3025
+ this.interrupting = false; // whatever it was waiting on, this ends it
3026
+ this.compacting = false; // the process that was compacting is gone
3027
+ this.agentState = null; // and it will never say that it rests
3028
+ this.showThreadStatus();
3029
+ // The replies still waiting for a task of theirs, when no turn is active: a process lost
3030
+ // then leaves them the only place that can say so (issue #202). `taskReplies` also keeps
3031
+ // replies whose tasks all ended (TASK_REPLIES_KEPT): only one that has not closed still
3032
+ // waits.
3033
+ const waitingReplies = new Set();
3034
+ if (active === null) {
3035
+ for (const reply of this.taskReplies.values()) {
3036
+ if (!reply.closedOut)
3037
+ waitingReplies.add(reply);
3038
+ }
3039
+ }
3040
+ const owed = active !== null || dropped.length > 0 || this.endNotes.length > 0 || waitingReplies.size > 0;
3041
+ if (error && owed) {
3042
+ // Not for a session that was idle with nothing running: its last reply ended well,
3043
+ // nothing is lost, and the next message connects a new process.
3044
+ this.reactError();
3045
+ }
3046
+ try {
3047
+ if (error) {
3048
+ for (const renderer of waitingReplies)
3049
+ await renderer.feedEnding(line).catch(() => { });
3050
+ }
3051
+ if (active !== null) {
3052
+ try {
3053
+ if (!whole)
3054
+ await active.renderer.feedEnding(line);
3055
+ if (dropped.length > 0)
3056
+ await active.renderer.feedError(notSent(dropped, because));
3057
+ await this.feedEndNotes(active.renderer);
3058
+ await this.closeReply(active.renderer, { force: true });
3059
+ }
3060
+ catch {
3061
+ // the tasks' replies below still end, and the waiters are still released
3062
+ }
3063
+ }
3064
+ else if (dropped.length > 0 || this.endNotes.length > 0) {
3065
+ const notes = this.endNotes;
3066
+ this.endNotes = [];
3067
+ if (dropped.length > 0)
3068
+ notes.push(notSent(dropped, because));
3069
+ await this.post(notes.join("\n")).catch(() => { });
3070
+ }
3071
+ await this.stopTaskReplies();
3072
+ }
3073
+ finally {
3074
+ for (const turn of waiting)
3075
+ turn.done.set();
3076
+ this.settled.set();
3077
+ this.stopTailEnded();
3078
+ }
3079
+ // A forced end is an end: the footer reads what it can, under the reply of the latest work.
3080
+ await this.showFooterIfEnded(undefined, { live: false }).catch(() => { });
3081
+ return endedWell;
3082
+ }
3083
+ /** The reply's footer, or null when it would say nothing. */
3084
+ async footer(tokens, live = true) {
3085
+ const data = await this.footerData(tokens, live);
3086
+ const says = data.bypass ||
3087
+ (data.folder !== null && basename(data.folder) !== "") ||
3088
+ footerFields(data, this.now()).length > 0;
3089
+ return says ? data : null;
3090
+ }
3091
+ /**
3092
+ * What the footer says of the effort. A fixed level shows as it is, unless a turn ran at
3093
+ * another one (that is what ran). The default shows as the agent's word for it, with the level
3094
+ * of the last turn beside it once there is one, as the terminal's `/effort` reads
3095
+ * `auto (currently medium)`. An agent with no such word shows the level alone, once known, and
3096
+ * `default` for a model that takes none. Once a turn has reported that the model takes no
3097
+ * effort, the footer says so whatever the setting, as the terminal's `/model` picker does.
3098
+ */
3099
+ effortFields() {
3100
+ if (this.effortUnsupported)
3101
+ return { effort: texts.EFFORT_NOT_SUPPORTED, effortObserved: null };
3102
+ const word = this.deps.agent.capabilities.effortDefault;
3103
+ if (this.effortSetting !== null) {
3104
+ return { effort: this.effort ?? this.effortSetting, effortObserved: null };
3105
+ }
3106
+ if (word !== null)
3107
+ return { effort: word, effortObserved: this.effort };
3108
+ return {
3109
+ effort: this.effortReported ? this.effort || "default" : null,
3110
+ effortObserved: null,
3111
+ };
3112
+ }
3113
+ /**
3114
+ * The footer's values from what the session knows now and `tokens`: the one source for both
3115
+ * the footer and `!status`, so the two never disagree.
3116
+ */
3117
+ async footerData(tokens, live = true) {
3118
+ let context = { model: null, percentage: null };
3119
+ if (live && this.client !== null) {
3120
+ try {
3121
+ context = await this.client.contextUsage();
3122
+ }
3123
+ catch (error) {
3124
+ // model and context are left out of the footer
3125
+ logger.warning(`could not read the context usage in ${this.channelId}: ${errorName(error)}`);
3126
+ }
3127
+ }
3128
+ const here = this.workingDirectory || this.directory;
3129
+ const [branch, changes] = await gitState(here, repositoryBy(this.deps.agent));
3130
+ const usage = this.usage?.current ?? null;
3131
+ // A switch the usage does not name yet is the newer word; once a request has gone to the
3132
+ // new model the usage names it, and from then on the usage alone speaks.
3133
+ if (context.model === this.modelSwitched)
3134
+ this.modelSwitched = null;
3135
+ const model = this.modelSwitched ?? context.model;
3136
+ return {
3137
+ bypass: this.bypass,
3138
+ model: model === null ? null : modelName(this.models, model),
3139
+ ...this.effortFields(),
3140
+ folder: this.directory,
3141
+ branch,
3142
+ changes,
3143
+ sessionTokens: tokens,
3144
+ contextPercent: context.percentage,
3145
+ sessionLimit: usage?.session ?? null,
3146
+ weekLimit: usage?.week ?? null,
3147
+ };
3148
+ }
3149
+ /**
3150
+ * A request of the agent's, put to the owner: a tool's permission, or its questions. Resolves
3151
+ * with what the owner chose, for as long as that takes; with a refusal when the chat could
3152
+ * not show the request, since nobody can answer one that was never shown. `signal` is the
3153
+ * agent's: aborted, the request is refused and its message withdrawn; aborted before the
3154
+ * request arrives, nothing is posted. Nothing is posted either for a request that arrives once
3155
+ * the session is closing: it is refused at once.
3156
+ */
3157
+ async askOwner(request, signal) {
3158
+ const questions = request.type === "question" ? request.questions : null;
3159
+ if (signal.aborted)
3160
+ return toAnswer(DENY, questions, this.names);
3161
+ // A session that is closing has denied its open requests (`close`), and the agent can ask
3162
+ // again on that denial while the close still waits for the first handler: this one would be
3163
+ // shown in a thread whose session is going away, and nothing would ever settle it. It gets
3164
+ // the answer the others got.
3165
+ if (this.closedFlag)
3166
+ return toAnswer(DENY, questions, this.names);
3167
+ const title = request.title ||
3168
+ (request.type === "question" ? request.toolName : taskTitle(request.toolName, request.input));
3169
+ const [approvalId, pending] = this.deps.approvals.open(this.channelId, this.threadId, title, questions);
3170
+ this.waiting.add(approvalId);
3171
+ this.idleTimerCheck(); // an open approval or question holds the session
3172
+ this.react("waiting");
3173
+ // Set only when the agent's abort settled a request still open, so an abort after the answer
3174
+ // changes nothing.
3175
+ let givenUp = false;
3176
+ let withdrawn = false;
3177
+ const giveUp = () => {
3178
+ givenUp = this.deps.approvals.giveUp(approvalId) !== null;
3179
+ };
3180
+ signal.addEventListener("abort", giveUp, { once: true });
3181
+ let messageTs;
3182
+ let decision;
3183
+ try {
3184
+ try {
3185
+ messageTs = await this.thread.ask(approvalId, request, title);
3186
+ }
3187
+ catch (error) {
3188
+ // Nobody can answer a request that was never shown: deny it, and say why.
3189
+ logger.error(`could not post an approval request in ${this.where}: ${errorName(error)}`);
3190
+ return questions === null
3191
+ ? { allow: false, message: texts.named(texts.APPROVAL_UNPOSTED, this.names) }
3192
+ : { answered: false, message: texts.named(texts.APPROVAL_UNPOSTED, this.names) };
3193
+ }
3194
+ logger.info(`posted ${questions === null ? "an approval" : "a question"} request in ` +
3195
+ `${this.where}: message ${messageTs}`);
3196
+ if (this.deps.approvals.posted(approvalId, messageTs)) {
3197
+ // Crash repair (issue #19): still carrying buttons, until it is answered. Best-effort:
3198
+ // the message is already live either way, so a failed write here is logged loudly
3199
+ // rather than left silently unrecorded.
3200
+ try {
3201
+ this.deps.state.addRequest(this.channelId, this.threadId, messageTs, questions === null ? "approval" : "question");
3202
+ }
3203
+ catch (error) {
3204
+ logger.error(`posted a request in ${this.where} that state.json could not record: ${errorName(error)}`);
3205
+ }
3206
+ }
3207
+ else {
3208
+ withdrawn = true;
3209
+ await this.deleteRequest(messageTs); // decided while it was posted
3210
+ }
3211
+ decision = await pending.decision;
3212
+ }
3213
+ finally {
3214
+ signal.removeEventListener("abort", giveUp);
3215
+ this.deps.approvals.discard(approvalId);
3216
+ this.waiting.delete(approvalId);
3217
+ this.idleTimerCheck(); // may (re)start the idle-close timer
3218
+ if (!this.interrupting)
3219
+ this.reactWaitingOrWorking();
3220
+ }
3221
+ // After the bookkeeping above, so a turn that ends during the chat round trip finds the
3222
+ // request gone and the root free to settle on done.
3223
+ if (givenUp && !withdrawn)
3224
+ await this.deleteRequest(messageTs);
3225
+ if (questions !== null && "answered" in decision && decision.answered) {
3226
+ await this.keepAnswers(request.callId, messageTs, questions, decision.answers);
3227
+ }
3228
+ return decision;
3229
+ }
3230
+ /**
3231
+ * Where an answered question's answers stay. In the reply, under the line of the call, where
3232
+ * the question was asked (issue #82): the request has done its job and goes. When the reply
3233
+ * has no line of its own for that call, in the request itself, rewritten with nothing left to
3234
+ * press, so the answers are never lost. The permission request names its call (measured on
3235
+ * Claude Code 2.1.286, 2026-10-03).
3236
+ */
3237
+ async keepAnswers(callId, messageTs, questions, answers) {
3238
+ const active = this.active;
3239
+ if (callId !== null && active?.renderer.answered(callId, questions, answers)) {
3240
+ await this.deleteRequest(messageTs);
3241
+ return;
3242
+ }
3243
+ try {
3244
+ await this.thread.keepAnswers(messageTs, questions, answers);
3245
+ }
3246
+ catch (error) {
3247
+ // The request must not keep buttons that no longer work: remove it.
3248
+ logger.warning(`could not record an answer in ${this.where}: ${errorName(error)}`);
3249
+ await this.deleteRequest(messageTs);
3250
+ return;
3251
+ }
3252
+ // Crash repair (issue #19): it carries no buttons now, so it leaves the tracked list.
3253
+ // Outside the try above: a failed write here must never delete a message just rewritten.
3254
+ try {
3255
+ this.deps.state.removeRequest(this.channelId, this.threadId, messageTs);
3256
+ }
3257
+ catch (error) {
3258
+ logger.warning(`could not clear an answered request from state.json in ${this.where}: ${errorName(error)}`);
3259
+ }
3260
+ }
3261
+ /**
3262
+ * End a turn's reply with a line saying why, and release whoever waits on it. `error` reacts
3263
+ * ❌.
3264
+ */
3265
+ async fail(turn, text, options = {}) {
3266
+ try {
3267
+ await turn.sink.text(text, { notice: true });
3268
+ await turn.sink.finish([]);
3269
+ this.trackLanding(await turn.sink.closeOut(), turn.sink);
3270
+ if (options.error)
3271
+ this.reactError();
3272
+ }
3273
+ finally {
3274
+ turn.done.set();
3275
+ this.showThreadStatus();
3276
+ }
3277
+ }
3278
+ /** A notice of the daemon's own, small and grey as the footer. */
3279
+ async post(text) {
3280
+ await this.thread.notice(text);
3281
+ }
3282
+ /** Remove a decided request: the tool's line in the reply records what happened. */
3283
+ async deleteRequest(messageTs) {
3284
+ if (messageTs === null)
3285
+ return;
3286
+ await this.thread.withdraw(messageTs);
3287
+ try {
3288
+ // Crash repair (issue #19): the request is spoken for either way, so a startup repair
3289
+ // never retries a delete this call already made (or gave up on). Best-effort: a failed
3290
+ // write here must never surface past this point.
3291
+ this.deps.state.removeRequest(this.channelId, this.threadId, messageTs);
3292
+ }
3293
+ catch (error) {
3294
+ logger.warning(`could not clear a deleted request from state.json in ${this.where}: ${errorName(error)}`);
3295
+ }
3296
+ }
3297
+ }