allaboard 0.4.0 → 0.6.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/README.md +9 -7
- package/dist/agent/claude/backend.js +73 -2
- package/dist/agent/claude/capabilities.js +4 -0
- package/dist/agent/claude/module.js +55 -0
- package/dist/agent/claude/translate.js +6 -2
- package/dist/agent/claude/trust.js +22 -0
- package/dist/chat/seam.js +3 -0
- package/dist/chat/slack/app/app.js +52 -4
- package/dist/chat/slack/app/delete-thread.js +90 -0
- package/dist/chat/slack/app/guards.js +44 -0
- package/dist/chat/slack/app/home-controls.js +8 -1
- package/dist/chat/slack/channels.js +23 -5
- package/dist/chat/slack/delete-thread.js +37 -0
- package/dist/chat/slack/delete.js +13 -7
- package/dist/chat/slack/home.js +194 -35
- package/dist/chat/slack/module.js +64 -14
- package/dist/chat/slack/onboarding.js +159 -0
- package/dist/chat/slack/reply/blocks.js +27 -11
- package/dist/chat/slack/reply/sinks.js +3 -1
- package/dist/chat/slack/tag.js +317 -0
- package/dist/chat/slack/texts.js +8 -0
- package/dist/chat/slack/thread.js +62 -2
- package/dist/chat/slack/tokens.js +15 -0
- package/dist/cli.js +43 -4
- package/dist/core/cleanup.js +66 -3
- package/dist/core/commands.js +40 -0
- package/dist/core/config.js +19 -11
- package/dist/core/files/listing.js +19 -0
- package/dist/core/flows/bind.js +29 -6
- package/dist/core/flows/flows.js +2 -0
- package/dist/core/flows/gone.js +39 -0
- package/dist/core/flows/inbound.js +3 -0
- package/dist/core/flows/messages.js +4 -2
- package/dist/core/flows/status.js +98 -48
- package/dist/core/flows/words.js +80 -1
- package/dist/core/guide.js +2 -0
- package/dist/core/port/cleanup.js +1 -0
- package/dist/core/port.js +1 -0
- package/dist/core/sessions/directory.js +47 -8
- package/dist/core/sessions/manager.js +32 -17
- package/dist/core/sessions/quote.js +57 -0
- package/dist/core/sessions/session.js +324 -35
- package/dist/core/sessions/title.js +98 -0
- package/dist/core/state.js +25 -0
- package/dist/core/texts.js +58 -3
- package/dist/core/wizard.js +306 -0
- package/dist/daemon.js +23 -11
- package/dist/host/program.js +22 -7
- package/dist/host/prompter.js +61 -0
- package/dist/module-setup.js +6 -0
- package/dist/version.js +4 -0
- package/package.json +5 -2
- package/slack-app-manifest.json +2 -1
|
@@ -49,15 +49,18 @@ import { errorName } from "../../values.js";
|
|
|
49
49
|
import { footerFields, formatStatusFields, gitState, sessionTokens, } from "../footer.js";
|
|
50
50
|
import { modelName } from "../models.js";
|
|
51
51
|
import { resolvePath } from "../paths.js";
|
|
52
|
+
import { described } from "../reply/previews.js";
|
|
52
53
|
import { endedLine, TurnRenderer, taskTitle, } from "../reply/renderer.js";
|
|
53
54
|
import { oneLine, TITLE_LIMIT } from "../reply/words.js";
|
|
54
55
|
import { DENY, toAnswer } from "../requests.js";
|
|
55
56
|
import { DEFAULT } from "../setup.js";
|
|
56
57
|
import * as texts from "../texts.js";
|
|
57
58
|
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 { checkDirectory, repositoryBy, terminalLine, trustedBy, untrustedNotice, } from "./directory.js";
|
|
59
60
|
import { BypassUnavailable, DirectoryUnavailable, SessionClosed, SessionGone, StartsBlocked, } from "./errors.js";
|
|
61
|
+
import { quoted } from "./quote.js";
|
|
60
62
|
import { Cancelled, cancellable, Event, Mutex, Queue, Task } from "./tasks.js";
|
|
63
|
+
import { ThreadTitle } from "./title.js";
|
|
61
64
|
import { notSent, Turn, takenNote } from "./turn.js";
|
|
62
65
|
export const logger = getLogger("allaboard.core.sessions");
|
|
63
66
|
/** The signal for a `finally`: none once it has aborted, since an aborted signal stays aborted. */
|
|
@@ -88,6 +91,8 @@ export class ThreadSession {
|
|
|
88
91
|
// for a thread not open yet).
|
|
89
92
|
resolved;
|
|
90
93
|
deps;
|
|
94
|
+
// The session's title on the root, asked for at the end of each turn.
|
|
95
|
+
title;
|
|
91
96
|
// One status on the session's root message, which `threadId` always is, and the line under
|
|
92
97
|
// the thread's last message (issues #83 and #95): both are the chat's to draw.
|
|
93
98
|
thread;
|
|
@@ -165,6 +170,11 @@ export class ThreadSession {
|
|
|
165
170
|
endedAhead = [];
|
|
166
171
|
// Each task's type and description, for the list of what still runs and the task's end line.
|
|
167
172
|
tasks = new Map();
|
|
173
|
+
// A subagent's tasks by the call that started them, and the step each is at: when it started,
|
|
174
|
+
// the call it made last as the thread words it, and how many calls it has made. They follow
|
|
175
|
+
// `tasks`: an entry goes with its task's end and with the task a cap drops.
|
|
176
|
+
agentCalls = new Map();
|
|
177
|
+
steps = new Map();
|
|
168
178
|
// How many times each task started, kept past its end: a resumed task keeps its id (SDK
|
|
169
179
|
// 0.3.300, `SDKTaskStartedMessage.run_id`), and nothing else says it is one. A reply asks
|
|
170
180
|
// here, so that an agent a message continues gets no second launch line.
|
|
@@ -186,6 +196,23 @@ export class ThreadSession {
|
|
|
186
196
|
// Tasks `!stop` ended. Claude Code starts no turn to report such a task: its notification
|
|
187
197
|
// stays queued (measured twice on 2026-09-27, Claude Code 2.1.283), so none is awaited.
|
|
188
198
|
stopped = new Set();
|
|
199
|
+
// True from the start of a process that resumes a session until its first turn. Such a process
|
|
200
|
+
// tells first of the tasks the session before it lost ("didn't finish before the previous
|
|
201
|
+
// session ended", measured 2026-10-18, Claude Code 2.1.302, as the cut of `!stop` or of a forced
|
|
202
|
+
// restart leaves them) and ends an empty turn; an event for a task this daemon never saw is
|
|
203
|
+
// dropped then, so neither shows nor is awaited as a report.
|
|
204
|
+
resumedAtStart = false;
|
|
205
|
+
// The footer's model and context, read from the process `!stop` is about to close.
|
|
206
|
+
closingContext = null;
|
|
207
|
+
// The last model and context share a live read gave (a footer or `!status`). A footer drawn
|
|
208
|
+
// with no process to ask (after a `!stop` whose own read did not answer) shows it; a process
|
|
209
|
+
// that resumes the session replaces it with its first read. A model switch or a compaction
|
|
210
|
+
// drops it, since its share no longer holds. Never shared with another session.
|
|
211
|
+
lastContext = null;
|
|
212
|
+
// Counts the invalidations of `lastContext`: a read that started before one is not kept.
|
|
213
|
+
contextGeneration = 0;
|
|
214
|
+
// Whether this turn's end already asked for the read `keepContext` makes: one per turn.
|
|
215
|
+
contextKept = false;
|
|
189
216
|
// The replies already logged as ended with no report under them: `closeOut` sets its flag
|
|
190
217
|
// after an await, so two timers waking together would log one reply twice.
|
|
191
218
|
endedUnreported = new WeakSet();
|
|
@@ -313,6 +340,7 @@ export class ThreadSession {
|
|
|
313
340
|
this.directory = directory;
|
|
314
341
|
this.resolved = resolvePath(directory);
|
|
315
342
|
this.deps = deps;
|
|
343
|
+
this.title = new ThreadTitle(deps, channelId, threadId, directory, options.stopped);
|
|
316
344
|
this.names = texts.namesOf(deps.agent, deps.chat);
|
|
317
345
|
this.thread = deps.chat.thread({ channel: channelId, thread: threadId });
|
|
318
346
|
this.predecessor = options.predecessor ?? null;
|
|
@@ -390,6 +418,10 @@ export class ThreadSession {
|
|
|
390
418
|
get waitingForOwner() {
|
|
391
419
|
return this.waiting.size > 0;
|
|
392
420
|
}
|
|
421
|
+
/** Working or waiting for the owner: a thread that is never deleted (`SessionManager.release`). */
|
|
422
|
+
get inUse() {
|
|
423
|
+
return this.waitingForOwner || !this.idle;
|
|
424
|
+
}
|
|
393
425
|
/**
|
|
394
426
|
* What the thread's activity line says, empty for nothing, and the same after the app's
|
|
395
427
|
* name. `Working…` while a prompt of the owner's is owed its answer (`promptOwed`), however
|
|
@@ -838,6 +870,7 @@ export class ThreadSession {
|
|
|
838
870
|
throw error;
|
|
839
871
|
}
|
|
840
872
|
this.client = client;
|
|
873
|
+
this.resumedAtStart = sessionId !== null;
|
|
841
874
|
this.clientEffort = effort;
|
|
842
875
|
this.clientBypass = this.bypassRuns(choice);
|
|
843
876
|
this.mode = mode;
|
|
@@ -1033,21 +1066,25 @@ export class ThreadSession {
|
|
|
1033
1066
|
async dropClient() {
|
|
1034
1067
|
const release = await this.connectLock.acquire();
|
|
1035
1068
|
try {
|
|
1036
|
-
|
|
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);
|
|
1069
|
+
await this.dropClientLocked();
|
|
1046
1070
|
}
|
|
1047
1071
|
finally {
|
|
1048
1072
|
release();
|
|
1049
1073
|
}
|
|
1050
1074
|
}
|
|
1075
|
+
/** `dropClient` for a caller that holds the connect lock. */
|
|
1076
|
+
async dropClientLocked() {
|
|
1077
|
+
const reader = this.reader;
|
|
1078
|
+
this.reader = null;
|
|
1079
|
+
const client = this.client;
|
|
1080
|
+
this.client = null;
|
|
1081
|
+
if (reader !== null) {
|
|
1082
|
+
reader.cancel();
|
|
1083
|
+
await reader.settled();
|
|
1084
|
+
}
|
|
1085
|
+
if (client !== null)
|
|
1086
|
+
await this.disconnect(client);
|
|
1087
|
+
}
|
|
1051
1088
|
/**
|
|
1052
1089
|
* During a stop: bring the thread's activity line up to date. Once only background tasks are
|
|
1053
1090
|
* left it says which ones the restart waits for (`threadLine`), since only the owner knows
|
|
@@ -1100,7 +1137,11 @@ export class ThreadSession {
|
|
|
1100
1137
|
if (client === null)
|
|
1101
1138
|
return held ? null : false;
|
|
1102
1139
|
const tasks = this.background ? this.runningTaskIds() : [];
|
|
1103
|
-
|
|
1140
|
+
// A task that has just ended by itself leaves the agent about to open the turn that reports
|
|
1141
|
+
// it (1.3 to 1.8 s later, measured for #338 on Claude Code 2.1.302), and carry on from
|
|
1142
|
+
// there: that is still work to stop (issue #375).
|
|
1143
|
+
const reportOwed = this.injectedExpected || this.reporting;
|
|
1144
|
+
if (!this.busy && tasks.length === 0 && !reportOwed) {
|
|
1104
1145
|
// A Start being applied may be inside the agent's own model command, a turn no reply
|
|
1105
1146
|
// shows: the interrupt ends it, and the setup then fails into `forgetSetup`, which the
|
|
1106
1147
|
// cancelled hold turns into `Not sent.`
|
|
@@ -1113,22 +1154,27 @@ export class ThreadSession {
|
|
|
1113
1154
|
// says after a task stopped with no turn running. Its next word counts again.
|
|
1114
1155
|
this.agentState = null;
|
|
1115
1156
|
this.showThreadStatus();
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
this.
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1157
|
+
// Only background tasks, or the report of one, are left. Stopping them one by one makes the
|
|
1158
|
+
// agent open a turn of its own to react 1-2 s later and carry on (issue #338); closing its
|
|
1159
|
+
// process ends them all for good, and the owner's next message resumes the session.
|
|
1160
|
+
if (!this.busy && (await this.endWithProcess(client, tasks))) {
|
|
1161
|
+
this.react("done");
|
|
1162
|
+
return true;
|
|
1163
|
+
}
|
|
1164
|
+
// A denial `denyAll` triggers below resolves `askOwner`'s own wait, whose `finally`
|
|
1165
|
+
// would otherwise race this method's own closing ✅ back to working; this flag makes it
|
|
1166
|
+
// skip that instead. `finish` or `abandon` clears it once this turn's own tail ends.
|
|
1167
|
+
this.interrupting = true;
|
|
1168
|
+
this.stopTail = this.stopTail ?? new Event();
|
|
1169
|
+
for (const pending of this.deps.approvals.denyAll(this.channelId, this.threadId)) {
|
|
1170
|
+
await this.deleteRequest(pending.messageTs);
|
|
1171
|
+
}
|
|
1172
|
+
try {
|
|
1173
|
+
await client.interrupt();
|
|
1174
|
+
}
|
|
1175
|
+
catch (error) {
|
|
1176
|
+
// A query already closed refuses the request; the tasks below still stop.
|
|
1177
|
+
logger.warning(`could not interrupt ${this.where}: ${errorName(error)}`);
|
|
1132
1178
|
}
|
|
1133
1179
|
for (const taskId of tasks) {
|
|
1134
1180
|
this.stopped.add(taskId);
|
|
@@ -1145,6 +1191,82 @@ export class ThreadSession {
|
|
|
1145
1191
|
this.react("done");
|
|
1146
1192
|
return true;
|
|
1147
1193
|
}
|
|
1194
|
+
/**
|
|
1195
|
+
* Close the agent's process, which ends its background tasks (measured 2026-10-18, Claude
|
|
1196
|
+
* Code 2.1.302: none survives), and draw each task's end as the agent would report it. The
|
|
1197
|
+
* session keeps its thread, id, bypass and effort: `ensureConnected` resumes it. False, with
|
|
1198
|
+
* nothing done, when a turn started while this waited for the lock.
|
|
1199
|
+
*/
|
|
1200
|
+
async endWithProcess(client, tasks) {
|
|
1201
|
+
// Held until the ends are drawn: a process resumed meanwhile would deliver its own word on
|
|
1202
|
+
// the lost tasks into the middle of them.
|
|
1203
|
+
const release = await this.connectLock.acquire();
|
|
1204
|
+
// The reader kept dispatching while the lock was taken: a report turn that started meanwhile
|
|
1205
|
+
// is a running turn, which the caller interrupts instead. From here to the reader's cancel in
|
|
1206
|
+
// `dropClientLocked` nothing awaits, so no turn can start in between.
|
|
1207
|
+
if (this.busy) {
|
|
1208
|
+
release();
|
|
1209
|
+
return false;
|
|
1210
|
+
}
|
|
1211
|
+
try {
|
|
1212
|
+
for (const taskId of tasks)
|
|
1213
|
+
this.stopped.add(taskId);
|
|
1214
|
+
// The footer the stop draws names the model and the context. The read starts while the
|
|
1215
|
+
// process lives, and the close does not wait for it: a CLI that never answers a control
|
|
1216
|
+
// request would hold the lock for good, and the SDK rejects a request still pending when
|
|
1217
|
+
// the query closes (sdk.mjs 0.3.302). Used only if it answered before the process ended.
|
|
1218
|
+
let answered = false;
|
|
1219
|
+
void client.contextUsage().then((usage) => {
|
|
1220
|
+
this.closingContext = usage;
|
|
1221
|
+
answered = true;
|
|
1222
|
+
}, () => { });
|
|
1223
|
+
this.interrupting = true;
|
|
1224
|
+
await this.dropClientLocked();
|
|
1225
|
+
// An approval a background agent waits on goes with the process, as in a stopped turn.
|
|
1226
|
+
for (const pending of this.deps.approvals.denyAll(this.channelId, this.threadId)) {
|
|
1227
|
+
await this.deleteRequest(pending.messageTs);
|
|
1228
|
+
}
|
|
1229
|
+
if (!answered) {
|
|
1230
|
+
this.closingContext = null;
|
|
1231
|
+
logger.warning(`the context usage did not answer before the stop closed ${this.where}`);
|
|
1232
|
+
}
|
|
1233
|
+
for (const taskId of tasks) {
|
|
1234
|
+
await this.dispatch({
|
|
1235
|
+
type: "task_ended",
|
|
1236
|
+
taskId,
|
|
1237
|
+
status: "stopped",
|
|
1238
|
+
summary: "",
|
|
1239
|
+
durationMs: null,
|
|
1240
|
+
callId: null,
|
|
1241
|
+
foldNamed: false,
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
// A report the agent was about to open, or a task's notification still on its way, went
|
|
1245
|
+
// with the process (issue #375): what waited on them ends now, as when none comes. A
|
|
1246
|
+
// resumed process's word on such a task is not shown, as for a stopped one.
|
|
1247
|
+
for (const taskId of this.unreported.keys())
|
|
1248
|
+
this.stopped.add(taskId);
|
|
1249
|
+
this.unreported.clear();
|
|
1250
|
+
for (const expiry of this.expiring)
|
|
1251
|
+
expiry.cancel();
|
|
1252
|
+
this.notificationWaits = false;
|
|
1253
|
+
if (this.injectedExpected) {
|
|
1254
|
+
this.expiry?.cancel();
|
|
1255
|
+
await this.reportNotComing();
|
|
1256
|
+
}
|
|
1257
|
+
else {
|
|
1258
|
+
await this.sweepClosedOut(undefined, { unreported: true });
|
|
1259
|
+
await this.reactDoneIfIdle();
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
finally {
|
|
1263
|
+
this.interrupting = false;
|
|
1264
|
+
this.closingContext = null;
|
|
1265
|
+
release();
|
|
1266
|
+
}
|
|
1267
|
+
this.idleTimerCheck();
|
|
1268
|
+
return true;
|
|
1269
|
+
}
|
|
1148
1270
|
async interruptApplied(client) {
|
|
1149
1271
|
try {
|
|
1150
1272
|
await client.interrupt();
|
|
@@ -1215,6 +1337,16 @@ export class ThreadSession {
|
|
|
1215
1337
|
];
|
|
1216
1338
|
}
|
|
1217
1339
|
}
|
|
1340
|
+
// Not with a folder no session can start in: that is what the owner acts on first.
|
|
1341
|
+
const untrusted = usable ? await this.untrustedLines() : [];
|
|
1342
|
+
// The agent's title is read with the footer's values, before the check below: it is the last
|
|
1343
|
+
// await of this call, so a close that came while it ran is seen.
|
|
1344
|
+
const stored = this.deps.state.thread(this.channelId, this.threadId);
|
|
1345
|
+
const sessionId = stored?.sessionId ?? null;
|
|
1346
|
+
const title = sessionId ? await this.readTitle(sessionId) : null;
|
|
1347
|
+
// A title the turn's end missed (written a moment after it) reaches the root now too.
|
|
1348
|
+
if (title !== null)
|
|
1349
|
+
void this.title.refresh();
|
|
1218
1350
|
// The daemon closed the session (shutdown, an idle close) while this call was reading it.
|
|
1219
1351
|
if (this.closedFlag && !gone)
|
|
1220
1352
|
throw new SessionClosed();
|
|
@@ -1227,24 +1359,68 @@ export class ThreadSession {
|
|
|
1227
1359
|
const running = this.runningKindsOf();
|
|
1228
1360
|
if (running)
|
|
1229
1361
|
fields.push(texts.fill(texts.STATUS_BACKGROUND, { counts: running }));
|
|
1230
|
-
const stored = this.deps.state.thread(this.channelId, this.threadId);
|
|
1231
1362
|
const activity = this.busy
|
|
1232
1363
|
? texts.fill(texts.ACTIVITY_BUSY, { queued: this.queue.size })
|
|
1233
1364
|
: texts.ACTIVITY_IDLE;
|
|
1234
|
-
const sessionId = stored?.sessionId ?? null;
|
|
1235
1365
|
const head = [
|
|
1236
1366
|
texts.fill(texts.STATUS, { directory: this.directory, session: sessionId || "new" }),
|
|
1237
1367
|
];
|
|
1238
1368
|
// Left out with a folder the line above cannot use: its `cd` would fail there.
|
|
1239
1369
|
if (sessionId && usable)
|
|
1240
1370
|
head.push(terminalLine(this.deps.agent.words.terminal, this.directory, sessionId, (text) => this.deps.chat.escape(text, "reference")));
|
|
1371
|
+
if (title) {
|
|
1372
|
+
head.push(
|
|
1373
|
+
// On one line, as the root shows it: a title set from a pasted text may hold a break.
|
|
1374
|
+
texts.fill(texts.STATUS_TITLE, {
|
|
1375
|
+
title: this.deps.chat.escape(title.replace(/\s+/g, " ").trim(), "reference"),
|
|
1376
|
+
}));
|
|
1377
|
+
}
|
|
1241
1378
|
const state = texts.fill(texts.named(texts.STATUS_STATE, this.names), {
|
|
1242
1379
|
mode: this.bypass ? (this.bypassMode ?? this.mode) : this.mode,
|
|
1243
1380
|
// Only a turn's first event carries the version, never the start (measured).
|
|
1244
1381
|
version: this.cliVersion || (this.client !== null ? texts.VERSION_PENDING : "not started"),
|
|
1245
1382
|
activity,
|
|
1246
1383
|
});
|
|
1247
|
-
|
|
1384
|
+
// Last: a line after a quote would read as part of it. The root's text is the chat's own
|
|
1385
|
+
// markup, shown as it was written. A message past the chat's limit is split by the chat at
|
|
1386
|
+
// line breaks, and every line of the quote carries its mark.
|
|
1387
|
+
const prompt = stored?.rootPrompt
|
|
1388
|
+
? [
|
|
1389
|
+
texts.fill(texts.STATUS_PROMPT, {
|
|
1390
|
+
quote: quoted(stored.rootPrompt, (piece) => this.deps.chat.escape(piece, "words")),
|
|
1391
|
+
}),
|
|
1392
|
+
]
|
|
1393
|
+
: [];
|
|
1394
|
+
return [...head, state, ...fields, ...unavailable, ...untrusted, ...prompt].join("\n");
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* What tells the owner which repositories below the thread's folder still need the agent's
|
|
1398
|
+
* trust (issue #247); nothing for an agent that keeps no record of trust.
|
|
1399
|
+
*/
|
|
1400
|
+
async untrustedLines() {
|
|
1401
|
+
try {
|
|
1402
|
+
return await untrustedNotice(this.deps.agent, this.directory, this.names, (text) => this.deps.chat.escape(text, "reference"));
|
|
1403
|
+
}
|
|
1404
|
+
catch (error) {
|
|
1405
|
+
if (error instanceof Cancelled)
|
|
1406
|
+
throw error;
|
|
1407
|
+
// The status still answers, without the list.
|
|
1408
|
+
logger.warning(`could not look for repositories to trust in ${this.where}: ${errorName(error)}`);
|
|
1409
|
+
return [];
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
/**
|
|
1413
|
+
* The agent's title for the session, or null when it has none or cannot be read: `!status` then
|
|
1414
|
+
* leaves the line out and says nothing of it beyond a log line.
|
|
1415
|
+
*/
|
|
1416
|
+
async readTitle(sessionId) {
|
|
1417
|
+
try {
|
|
1418
|
+
return await this.title.read(sessionId);
|
|
1419
|
+
}
|
|
1420
|
+
catch (error) {
|
|
1421
|
+
logger.warning(`could not read the session's title for ${this.where}: ${errorName(error)}`);
|
|
1422
|
+
return null;
|
|
1423
|
+
}
|
|
1248
1424
|
}
|
|
1249
1425
|
now() {
|
|
1250
1426
|
return (this.deps.now ?? Date.now)();
|
|
@@ -1708,6 +1884,8 @@ export class ThreadSession {
|
|
|
1708
1884
|
case "model_changed":
|
|
1709
1885
|
this.modelSwitched = event.model;
|
|
1710
1886
|
this.modelSwitchedInTurn = this.active !== null;
|
|
1887
|
+
// The share was measured against the old model's window.
|
|
1888
|
+
this.forgetContext();
|
|
1711
1889
|
return;
|
|
1712
1890
|
case "folder_changed":
|
|
1713
1891
|
this.workingDirectory = event.folder;
|
|
@@ -1737,6 +1915,9 @@ export class ThreadSession {
|
|
|
1737
1915
|
// seconds later in the recordings (2026-10-08, CLI 2.1.292). The back end says each
|
|
1738
1916
|
// once.
|
|
1739
1917
|
const compacting = event.type === "compaction_started";
|
|
1918
|
+
// A compaction shrinks the context: a share read before it is no longer true.
|
|
1919
|
+
if (compacting)
|
|
1920
|
+
this.forgetContext();
|
|
1740
1921
|
if (compacting !== this.compacting) {
|
|
1741
1922
|
this.compacting = compacting;
|
|
1742
1923
|
this.showThreadStatus();
|
|
@@ -1746,7 +1927,16 @@ export class ThreadSession {
|
|
|
1746
1927
|
default:
|
|
1747
1928
|
break;
|
|
1748
1929
|
}
|
|
1930
|
+
this.followStep(event);
|
|
1749
1931
|
const task = isTaskEvent(event) ? event : null;
|
|
1932
|
+
if (this.resumedAtStart) {
|
|
1933
|
+
if (TURN_EVENTS.has(event.type)) {
|
|
1934
|
+
this.resumedAtStart = false;
|
|
1935
|
+
}
|
|
1936
|
+
else if (task !== null && event.type !== "task_started" && !this.tasks.has(task.taskId)) {
|
|
1937
|
+
return;
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1750
1940
|
if (task !== null) {
|
|
1751
1941
|
const inner = this.nestingReply(task);
|
|
1752
1942
|
if (inner !== null) {
|
|
@@ -1819,6 +2009,7 @@ export class ThreadSession {
|
|
|
1819
2009
|
// Read above for the end line, which comes after the terminal task_updated (recorded
|
|
1820
2010
|
// order); a task whose notification never comes goes with the process.
|
|
1821
2011
|
this.tasks.delete(event.taskId);
|
|
2012
|
+
this.forgetSteps(event.taskId);
|
|
1822
2013
|
}
|
|
1823
2014
|
const holder = task === null ? undefined : this.taskReplies.get(task.taskId);
|
|
1824
2015
|
if (task !== null && holder !== undefined) {
|
|
@@ -1968,11 +2159,17 @@ export class ThreadSession {
|
|
|
1968
2159
|
finally {
|
|
1969
2160
|
this.turnEnding = false;
|
|
1970
2161
|
this.showThreadStatus();
|
|
2162
|
+
void this.title.refresh();
|
|
1971
2163
|
}
|
|
1972
2164
|
}
|
|
1973
2165
|
}
|
|
1974
2166
|
recordTask(event) {
|
|
1975
2167
|
this.tasks.set(event.taskId, [event.kind, event.description, event.agentType]);
|
|
2168
|
+
if (event.kind === "subagent" && event.callId !== null) {
|
|
2169
|
+
this.agentCalls.set(event.callId, event.taskId);
|
|
2170
|
+
// A resumed subagent runs again from its latest start, its calls counted anew.
|
|
2171
|
+
this.steps.set(event.taskId, { since: this.deps.clock.time(), words: null, calls: 0 });
|
|
2172
|
+
}
|
|
1976
2173
|
if (this.mayHaveOrderedRestart)
|
|
1977
2174
|
this.afterRestart.add(event.taskId);
|
|
1978
2175
|
const starts = (this.starts.get(event.taskId) ?? 0) + 1;
|
|
@@ -1986,6 +2183,41 @@ export class ThreadSession {
|
|
|
1986
2183
|
known.delete(taskId);
|
|
1987
2184
|
}
|
|
1988
2185
|
}
|
|
2186
|
+
for (const taskId of [...this.steps.keys(), ...this.agentCalls.values()]) {
|
|
2187
|
+
if (!this.tasks.has(taskId))
|
|
2188
|
+
this.forgetSteps(taskId);
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
/** Drops what is kept of a subagent's calls. */
|
|
2192
|
+
forgetSteps(taskId) {
|
|
2193
|
+
this.steps.delete(taskId);
|
|
2194
|
+
for (const [callId, owner] of this.agentCalls) {
|
|
2195
|
+
if (owner === taskId)
|
|
2196
|
+
this.agentCalls.delete(callId);
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
/**
|
|
2200
|
+
* Keeps the step of each subagent up to date from the calls it starts, which arrive while it
|
|
2201
|
+
* runs and after its turn has ended: a call whose parent is the call that started the
|
|
2202
|
+
* subagent. A call nested deeper has the nested agent's call as parent and is none of this
|
|
2203
|
+
* subagent's. The list is redrawn when the subagent shows on it; the activity line is not.
|
|
2204
|
+
*/
|
|
2205
|
+
followStep(event) {
|
|
2206
|
+
if (event.type !== "call_started" || event.parentCallId === null)
|
|
2207
|
+
return;
|
|
2208
|
+
const taskId = this.agentCalls.get(event.parentCallId);
|
|
2209
|
+
const before = taskId === undefined ? undefined : this.steps.get(taskId);
|
|
2210
|
+
if (taskId === undefined || before === undefined)
|
|
2211
|
+
return;
|
|
2212
|
+
const words = described(event.toolName, event.input) ?? taskTitle(event.toolName, event.input);
|
|
2213
|
+
this.steps.set(taskId, {
|
|
2214
|
+
...before,
|
|
2215
|
+
words: oneLine(words, TITLE_LIMIT),
|
|
2216
|
+
calls: before.calls + 1,
|
|
2217
|
+
});
|
|
2218
|
+
if (this.taskReplies.get(taskId)?.backgroundTasks.includes(taskId)) {
|
|
2219
|
+
this.thread.showRunning(this.runningList());
|
|
2220
|
+
}
|
|
1989
2221
|
}
|
|
1990
2222
|
/**
|
|
1991
2223
|
* Tasks that just outlived the nested call that started them are ordinary background tasks
|
|
@@ -2162,6 +2394,13 @@ export class ThreadSession {
|
|
|
2162
2394
|
if (!this.injectedExpected || this.active !== null)
|
|
2163
2395
|
return;
|
|
2164
2396
|
logger.warning(`no turn followed a task notification in ${this.where}`);
|
|
2397
|
+
await this.reportNotComing(signal);
|
|
2398
|
+
}
|
|
2399
|
+
/**
|
|
2400
|
+
* The report turn a task notification made the session expect will not come: say the end
|
|
2401
|
+
* lines it would have carried where they belong, and end the replies that waited on it.
|
|
2402
|
+
*/
|
|
2403
|
+
async reportNotComing(signal) {
|
|
2165
2404
|
this.injectedExpected = false;
|
|
2166
2405
|
const held = this.held;
|
|
2167
2406
|
this.held = [];
|
|
@@ -2330,8 +2569,12 @@ export class ThreadSession {
|
|
|
2330
2569
|
*/
|
|
2331
2570
|
async showFooterIfEnded(signal, options = {}) {
|
|
2332
2571
|
const reply = this.footerOwedTo;
|
|
2333
|
-
if (reply === null
|
|
2572
|
+
if (reply === null)
|
|
2334
2573
|
return;
|
|
2574
|
+
if (!this.sessionEnded()) {
|
|
2575
|
+
this.keepContext();
|
|
2576
|
+
return;
|
|
2577
|
+
}
|
|
2335
2578
|
const footer = await this.readFooter(signal, options.live ?? true);
|
|
2336
2579
|
if (footer === null || this.footerOwedTo !== reply || !this.sessionEnded())
|
|
2337
2580
|
return;
|
|
@@ -2340,6 +2583,32 @@ export class ThreadSession {
|
|
|
2340
2583
|
this.footerOwedTo = null;
|
|
2341
2584
|
}
|
|
2342
2585
|
}
|
|
2586
|
+
/** Drop the kept model and context share, and any read that is on its way to replace it. */
|
|
2587
|
+
forgetContext() {
|
|
2588
|
+
this.lastContext = null;
|
|
2589
|
+
this.contextGeneration += 1;
|
|
2590
|
+
}
|
|
2591
|
+
/**
|
|
2592
|
+
* A turn has ended and its footer waits for background work: ask for the context usage now,
|
|
2593
|
+
* while the process is alive and idle, and keep it for the footer a `!stop` draws after it
|
|
2594
|
+
* closed the process (a read made at the stop loses the race against the close, measured
|
|
2595
|
+
* 2026-10-18, Claude Code 2.1.302). One request per turn; it delays nothing, and a model
|
|
2596
|
+
* switch or a compaction that comes first voids it.
|
|
2597
|
+
*/
|
|
2598
|
+
keepContext() {
|
|
2599
|
+
const client = this.client;
|
|
2600
|
+
if (client === null || this.busy || this.contextKept || !this.runningKindsOf())
|
|
2601
|
+
return;
|
|
2602
|
+
this.contextKept = true;
|
|
2603
|
+
const generation = this.contextGeneration;
|
|
2604
|
+
client.contextUsage().then((usage) => {
|
|
2605
|
+
if (generation === this.contextGeneration && this.client === client) {
|
|
2606
|
+
this.lastContext = usage;
|
|
2607
|
+
}
|
|
2608
|
+
}, (error) => {
|
|
2609
|
+
logger.warning(`could not read the context usage in ${this.where}: ${errorName(error)}`);
|
|
2610
|
+
});
|
|
2611
|
+
}
|
|
2343
2612
|
/**
|
|
2344
2613
|
* The footer as the session stands now; null when it would say nothing or cannot be read.
|
|
2345
2614
|
* `live` false leaves the agent unasked (a forced end: its process is lost, closing or busy
|
|
@@ -2443,6 +2712,10 @@ export class ThreadSession {
|
|
|
2443
2712
|
// await itself.
|
|
2444
2713
|
this.idleExpiry = null;
|
|
2445
2714
|
await this.close(texts.ENDED_IDLE);
|
|
2715
|
+
// The agent may write its title a moment after the turn's end, when the turn's own read had
|
|
2716
|
+
// already found none: a session that rested an hour asks once more, after its close so that
|
|
2717
|
+
// nothing awaited sits between the idle check and the close. The read needs no live process.
|
|
2718
|
+
void this.title.refresh();
|
|
2446
2719
|
}
|
|
2447
2720
|
/**
|
|
2448
2721
|
* The prompt the turn that starts now answers, taken out of `sent`; null for a turn the
|
|
@@ -2605,6 +2878,8 @@ export class ThreadSession {
|
|
|
2605
2878
|
this.taskReplies.clear();
|
|
2606
2879
|
this.resting.clear();
|
|
2607
2880
|
this.tasks.clear();
|
|
2881
|
+
this.agentCalls.clear();
|
|
2882
|
+
this.steps.clear();
|
|
2608
2883
|
this.ended = [];
|
|
2609
2884
|
this.opening = null;
|
|
2610
2885
|
this.endedAhead = [];
|
|
@@ -2803,12 +3078,14 @@ export class ThreadSession {
|
|
|
2803
3078
|
.sort(([a], [b]) => rank(a) - rank(b))
|
|
2804
3079
|
.map(([taskId, renderer]) => {
|
|
2805
3080
|
const [kind, description, agentType] = this.tasks.get(taskId) ?? ["other", "", null];
|
|
3081
|
+
const agent = kind === "subagent" ? this.agentOf(taskId) : null;
|
|
2806
3082
|
let label;
|
|
2807
3083
|
if (description && kind === "subagent") {
|
|
2808
3084
|
// The terminal's row: the agent's type, then what it does.
|
|
2809
3085
|
if (agentType !== null) {
|
|
2810
3086
|
const type = oneLine(agentType, TITLE_LIMIT / 2);
|
|
2811
|
-
|
|
3087
|
+
const text = `${type} ${oneLine(description, TITLE_LIMIT - Array.from(type).length - 2)}`;
|
|
3088
|
+
return { text, agent };
|
|
2812
3089
|
}
|
|
2813
3090
|
label = `${TASK_WORDS.subagent[1]}: ${description}`;
|
|
2814
3091
|
}
|
|
@@ -2818,10 +3095,17 @@ export class ThreadSession {
|
|
|
2818
3095
|
else {
|
|
2819
3096
|
label = renderer.taskTitle(taskId) || taskId;
|
|
2820
3097
|
}
|
|
2821
|
-
return oneLine(label, TITLE_LIMIT);
|
|
3098
|
+
return { text: oneLine(label, TITLE_LIMIT), agent };
|
|
2822
3099
|
});
|
|
2823
3100
|
return { title: texts.fill(texts.STILL_RUNNING, { counts }), lines };
|
|
2824
3101
|
}
|
|
3102
|
+
/** A subagent as the list shows it; null before its first call. */
|
|
3103
|
+
agentOf(taskId) {
|
|
3104
|
+
const step = this.steps.get(taskId);
|
|
3105
|
+
if (step?.words === undefined || step.words === null)
|
|
3106
|
+
return null;
|
|
3107
|
+
return { since: step.since, calls: step.calls, step: step.words };
|
|
3108
|
+
}
|
|
2825
3109
|
newRenderer(reply) {
|
|
2826
3110
|
const renderer = new TurnRenderer(reply, this.names, this.directory, this.deps.chat.capabilities.nativeStreaming,
|
|
2827
3111
|
// Recorded before any reply sees the start (`dispatch`), so a first start counts one.
|
|
@@ -2867,6 +3151,7 @@ export class ThreadSession {
|
|
|
2867
3151
|
// Kept for `!status`, even when this result reports none (`/usage`, `/clear`): the two
|
|
2868
3152
|
// lines never disagree.
|
|
2869
3153
|
this.sessionTokens = sessionTokens(result.tokens);
|
|
3154
|
+
this.contextKept = false;
|
|
2870
3155
|
stopped = result.ending === "interrupted";
|
|
2871
3156
|
// A turn that did not end well is no ground to wait for a report under its reply.
|
|
2872
3157
|
if (result.ending !== "done")
|
|
@@ -3116,9 +3401,13 @@ export class ThreadSession {
|
|
|
3116
3401
|
*/
|
|
3117
3402
|
async footerData(tokens, live = true) {
|
|
3118
3403
|
let context = { model: null, percentage: null };
|
|
3119
|
-
if (live && this.client
|
|
3404
|
+
if (live && this.client === null) {
|
|
3405
|
+
context = this.closingContext ?? this.lastContext ?? context;
|
|
3406
|
+
}
|
|
3407
|
+
else if (live && this.client !== null) {
|
|
3120
3408
|
try {
|
|
3121
3409
|
context = await this.client.contextUsage();
|
|
3410
|
+
this.lastContext = context;
|
|
3122
3411
|
}
|
|
3123
3412
|
catch (error) {
|
|
3124
3413
|
// model and context are left out of the footer
|