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.
- package/LICENSE +21 -0
- package/README.md +43 -2
- package/dist/agent/claude/backend.js +124 -0
- package/dist/agent/claude/capabilities.js +30 -0
- package/dist/agent/claude/chrome.js +42 -0
- package/dist/agent/claude/hooks.js +37 -0
- package/dist/agent/claude/info.js +61 -0
- package/dist/agent/claude/listing.js +230 -0
- package/dist/agent/claude/module.js +82 -0
- package/dist/agent/claude/prompt.js +24 -0
- package/dist/agent/claude/record.js +9 -0
- package/dist/agent/claude/requests.js +122 -0
- package/dist/agent/claude/session.js +441 -0
- package/dist/agent/claude/translate.js +581 -0
- package/dist/agent/claude/trust.js +576 -0
- package/dist/agent/claude/usage-probe.js +103 -0
- package/dist/agent/claude/usage.js +110 -0
- package/dist/agent/claude/wire.js +22 -0
- package/dist/agent/seam.js +21 -0
- package/dist/chat/seam.js +10 -0
- package/dist/chat/slack/app/answers.js +209 -0
- package/dist/chat/slack/app/app.js +262 -0
- package/dist/chat/slack/app/clip.js +191 -0
- package/dist/chat/slack/app/guards.js +162 -0
- package/dist/chat/slack/app/home-controls.js +91 -0
- package/dist/chat/slack/app/messages.js +181 -0
- package/dist/chat/slack/app/open.js +392 -0
- package/dist/chat/slack/app/order.js +37 -0
- package/dist/chat/slack/app/requests.js +482 -0
- package/dist/chat/slack/app/resume-bind.js +71 -0
- package/dist/chat/slack/app/wire.js +83 -0
- package/dist/chat/slack/attachments.js +348 -0
- package/dist/chat/slack/bind.js +67 -0
- package/dist/chat/slack/channel.js +67 -0
- package/dist/chat/slack/channels.js +43 -0
- package/dist/chat/slack/delete.js +278 -0
- package/dist/chat/slack/footer.js +43 -0
- package/dist/chat/slack/hold.js +37 -0
- package/dist/chat/slack/home.js +1069 -0
- package/dist/chat/slack/module.js +174 -0
- package/dist/chat/slack/notice.js +64 -0
- package/dist/chat/slack/openfile/modal.js +362 -0
- package/dist/chat/slack/openfile/read.js +29 -0
- package/dist/chat/slack/quiet-logger.js +52 -0
- package/dist/chat/slack/repair.js +123 -0
- package/dist/chat/slack/reply/blocks.js +372 -0
- package/dist/chat/slack/reply/chars.js +59 -0
- package/dist/chat/slack/reply/clients.js +171 -0
- package/dist/chat/slack/reply/errors.js +125 -0
- package/dist/chat/slack/reply/escape.js +25 -0
- package/dist/chat/slack/reply/limiter.js +86 -0
- package/dist/chat/slack/reply/markdown.js +210 -0
- package/dist/chat/slack/reply/places.js +262 -0
- package/dist/chat/slack/reply/sinks.js +2403 -0
- package/dist/chat/slack/reply/status.js +433 -0
- package/dist/chat/slack/reply/tasks.js +11 -0
- package/dist/chat/slack/requests.js +552 -0
- package/dist/chat/slack/resume.js +127 -0
- package/dist/chat/slack/setup.js +114 -0
- package/dist/chat/slack/texts.js +185 -0
- package/dist/chat/slack/thread.js +537 -0
- package/dist/chat/slack/update.js +71 -0
- package/dist/chat/slack/voice.js +84 -0
- package/dist/cli.js +326 -0
- package/dist/clock.js +21 -0
- package/dist/core/cleanup.js +145 -0
- package/dist/core/commands.js +172 -0
- package/dist/core/config.js +174 -0
- package/dist/core/files/changed.js +125 -0
- package/dist/core/files/listing.js +382 -0
- package/dist/core/files/paths.js +9 -0
- package/dist/core/files/read.js +191 -0
- package/dist/core/files/waiting.js +34 -0
- package/dist/core/flows/answers.js +133 -0
- package/dist/core/flows/ask-owner.js +95 -0
- package/dist/core/flows/asking.js +14 -0
- package/dist/core/flows/bind.js +95 -0
- package/dist/core/flows/contract.js +1 -0
- package/dist/core/flows/flows.js +35 -0
- package/dist/core/flows/hold.js +22 -0
- package/dist/core/flows/inbound.js +24 -0
- package/dist/core/flows/lists.js +21 -0
- package/dist/core/flows/messages.js +293 -0
- package/dist/core/flows/notices.js +51 -0
- package/dist/core/flows/repair.js +106 -0
- package/dist/core/flows/resume.js +163 -0
- package/dist/core/flows/setup.js +130 -0
- package/dist/core/flows/status.js +98 -0
- package/dist/core/flows/talk.js +207 -0
- package/dist/core/flows/update.js +370 -0
- package/dist/core/flows/words.js +171 -0
- package/dist/core/folders.js +93 -0
- package/dist/core/footer.js +313 -0
- package/dist/core/guide.js +50 -0
- package/dist/core/hold.js +126 -0
- package/dist/core/instance.js +46 -0
- package/dist/core/lock.js +412 -0
- package/dist/core/models.js +10 -0
- package/dist/core/paths.js +75 -0
- package/dist/core/port/file-flows.js +1 -0
- package/dist/core/port/home.js +1 -0
- package/dist/core/port/requests.js +1 -0
- package/dist/core/port/threads.js +1 -0
- package/dist/core/port/updates.js +1 -0
- package/dist/core/port.js +17 -0
- package/dist/core/reply/fold.js +175 -0
- package/dist/core/reply/previews.js +258 -0
- package/dist/core/reply/renderer.js +846 -0
- package/dist/core/reply/tail.js +24 -0
- package/dist/core/reply/words.js +32 -0
- package/dist/core/requests.js +158 -0
- package/dist/core/sessions/constants.js +61 -0
- package/dist/core/sessions/deps.js +1 -0
- package/dist/core/sessions/directory.js +86 -0
- package/dist/core/sessions/errors.js +79 -0
- package/dist/core/sessions/manager.js +378 -0
- package/dist/core/sessions/session.js +3297 -0
- package/dist/core/sessions/tasks.js +266 -0
- package/dist/core/sessions/turn.js +45 -0
- package/dist/core/setup.js +28 -0
- package/dist/core/state.js +724 -0
- package/dist/core/texts.js +451 -0
- package/dist/core/updates.js +332 -0
- package/dist/daemon.js +566 -0
- package/dist/host/npm.js +134 -0
- package/dist/host/program.js +49 -0
- package/dist/host/run.js +52 -0
- package/dist/host/service/launchd.js +385 -0
- package/dist/host/service/listing.js +42 -0
- package/dist/host/service/seam.js +1 -0
- package/dist/log.js +37 -0
- package/dist/main.js +29 -0
- package/dist/node-version.js +40 -0
- package/dist/registry.js +12 -0
- package/dist/semver.js +53 -0
- package/dist/text.js +89 -0
- package/dist/updates-host.js +113 -0
- package/dist/values.js +30 -0
- package/dist/version.js +54 -0
- package/package.json +32 -4
- 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
|
+
}
|