@linkshell/host 0.1.1 → 0.1.3
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/dist/host/src/carry.d.ts +15 -0
- package/dist/host/src/carry.js +83 -0
- package/dist/host/src/carry.js.map +1 -0
- package/dist/host/src/drivers/acp/driver.d.ts +13 -3
- package/dist/host/src/drivers/acp/driver.js +43 -8
- package/dist/host/src/drivers/acp/driver.js.map +1 -1
- package/dist/host/src/drivers/acp/mapper.d.ts +5 -1
- package/dist/host/src/drivers/acp/mapper.js +53 -5
- package/dist/host/src/drivers/acp/mapper.js.map +1 -1
- package/dist/host/src/drivers/claude/driver.d.ts +53 -8
- package/dist/host/src/drivers/claude/driver.js +357 -40
- package/dist/host/src/drivers/claude/driver.js.map +1 -1
- package/dist/host/src/drivers/claude/holders.d.ts +9 -0
- package/dist/host/src/drivers/claude/holders.js +61 -0
- package/dist/host/src/drivers/claude/holders.js.map +1 -0
- package/dist/host/src/drivers/claude/transcript.d.ts +103 -65
- package/dist/host/src/drivers/claude/transcript.js +246 -26
- package/dist/host/src/drivers/claude/transcript.js.map +1 -1
- package/dist/host/src/drivers/codex/commands.d.ts +27 -0
- package/dist/host/src/drivers/codex/commands.js +23 -0
- package/dist/host/src/drivers/codex/commands.js.map +1 -0
- package/dist/host/src/drivers/codex/driver.d.ts +15 -1
- package/dist/host/src/drivers/codex/driver.js +110 -1
- package/dist/host/src/drivers/codex/driver.js.map +1 -1
- package/dist/host/src/drivers/codex/mapper.d.ts +4 -0
- package/dist/host/src/drivers/codex/mapper.js +29 -5
- package/dist/host/src/drivers/codex/mapper.js.map +1 -1
- package/dist/host/src/drivers/types.d.ts +34 -3
- package/dist/host/src/fs.d.ts +26 -4
- package/dist/host/src/fs.js +71 -17
- package/dist/host/src/fs.js.map +1 -1
- package/dist/host/src/gateway.d.ts +3 -0
- package/dist/host/src/gateway.js +9 -0
- package/dist/host/src/gateway.js.map +1 -1
- package/dist/host/src/host.d.ts +7 -3
- package/dist/host/src/host.js +42 -18
- package/dist/host/src/host.js.map +1 -1
- package/dist/host/src/hub.d.ts +91 -15
- package/dist/host/src/hub.js +556 -33
- package/dist/host/src/hub.js.map +1 -1
- package/dist/host/src/rpc/server.d.ts +4 -2
- package/dist/host/src/rpc/server.js +51 -6
- package/dist/host/src/rpc/server.js.map +1 -1
- package/dist/host/src/screen.d.ts +11 -0
- package/dist/host/src/screen.js +57 -3
- package/dist/host/src/screen.js.map +1 -1
- package/dist/host/src/slim.d.ts +17 -0
- package/dist/host/src/slim.js +66 -0
- package/dist/host/src/slim.js.map +1 -0
- package/dist/host/src/store.d.ts +75 -3
- package/dist/host/src/store.js +216 -16
- package/dist/host/src/store.js.map +1 -1
- package/dist/host/src/worktrees.d.ts +36 -0
- package/dist/host/src/worktrees.js +96 -0
- package/dist/host/src/worktrees.js.map +1 -0
- package/dist/host/tsconfig.tsbuildinfo +1 -1
- package/dist/wire/src/model.d.ts +148 -0
- package/dist/wire/src/model.js +55 -0
- package/dist/wire/src/model.js.map +1 -1
- package/dist/wire/src/relay-client.d.ts +2 -0
- package/dist/wire/src/relay-client.js +12 -0
- package/dist/wire/src/relay-client.js.map +1 -1
- package/dist/wire/src/rpc.d.ts +5977 -259
- package/dist/wire/src/rpc.js +130 -7
- package/dist/wire/src/rpc.js.map +1 -1
- package/dist/wire/src/updates.d.ts +79 -79
- package/package.json +2 -2
package/dist/host/src/hub.js
CHANGED
|
@@ -1,4 +1,64 @@
|
|
|
1
1
|
import { RpcError, sessionIdFor, } from "@linkshell/wire";
|
|
2
|
+
import { inOrder } from "./drivers/acp/driver.js";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { PAGE, worktreeOf } from "./store.js";
|
|
6
|
+
import { createWorktree, gitBranch, gitInfo, removeWorktree, worktreeState } from "./worktrees.js";
|
|
7
|
+
import { conversationDigest, copied, isConversation } from "./carry.js";
|
|
8
|
+
import { slimEvent } from "./slim.js";
|
|
9
|
+
/**
|
|
10
|
+
* Whether an update is something happening in the session. Names, settings
|
|
11
|
+
* and usage figures arrive when a session is merely opened or renamed, and
|
|
12
|
+
* must not move it to the top of the list.
|
|
13
|
+
*/
|
|
14
|
+
function isActivity(update) {
|
|
15
|
+
switch (update.sessionUpdate) {
|
|
16
|
+
case "session_info_update":
|
|
17
|
+
case "ls_config":
|
|
18
|
+
case "available_commands_update":
|
|
19
|
+
case "usage_update":
|
|
20
|
+
// What state a session is in and who drives it are said again whenever it
|
|
21
|
+
// is opened; a turn starting or ending is what counts.
|
|
22
|
+
case "ls_status":
|
|
23
|
+
case "ls_driver":
|
|
24
|
+
return false;
|
|
25
|
+
default:
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Whether a sub-agent is working. Its call says so while it is open; an agent
|
|
31
|
+
* that outlives its call (it reports turns of its own) counts while it keeps
|
|
32
|
+
* doing things — a turn left open by an agent that was killed doesn't run forever.
|
|
33
|
+
*/
|
|
34
|
+
function subagentRunning(state, now = Date.now()) {
|
|
35
|
+
if (!state.callDone)
|
|
36
|
+
return state.turnActive ?? true;
|
|
37
|
+
return state.turnActive === true && now - (state.lastChildTs ?? 0) < 15 * 60_000;
|
|
38
|
+
}
|
|
39
|
+
/** A short name for a worktree, from what the session is asked to do. */
|
|
40
|
+
function promptLabel(prompt) {
|
|
41
|
+
return prompt ? textOf(prompt).slice(0, 40) || "session" : "session";
|
|
42
|
+
}
|
|
43
|
+
function worktreeLossMessage(state) {
|
|
44
|
+
const parts = [state.dirty ? "未提交的改动" : "", state.ahead > 0 ? `${state.ahead} 个新提交` : ""].filter(Boolean);
|
|
45
|
+
return `这个 worktree 里有${parts.join("和")},删除会丢失`;
|
|
46
|
+
}
|
|
47
|
+
/** Driver-state key: when a session was renamed, and when it was last active before that. */
|
|
48
|
+
const RENAMED = "renamed";
|
|
49
|
+
/** Driver-state key: the conversation a forked session's agent is still to be told, with its first message. */
|
|
50
|
+
const CARRY = "carry";
|
|
51
|
+
/** A returning client catches up from where it was, unless it missed more than this. */
|
|
52
|
+
const CATCH_UP = { events: 800, bytes: 4 * 1024 * 1024 };
|
|
53
|
+
/** State a client needs from before its window: the latest of each applies. */
|
|
54
|
+
const STANDING = [
|
|
55
|
+
"ls_config",
|
|
56
|
+
"available_commands_update",
|
|
57
|
+
"current_mode_update",
|
|
58
|
+
"usage_update",
|
|
59
|
+
"session_info_update",
|
|
60
|
+
"ls_driver",
|
|
61
|
+
];
|
|
2
62
|
const PREVIEW_LENGTH = 160;
|
|
3
63
|
const TITLE_LENGTH = 60;
|
|
4
64
|
function compact(text, max) {
|
|
@@ -22,6 +82,7 @@ function textOf(content) {
|
|
|
22
82
|
export class SessionHub {
|
|
23
83
|
store;
|
|
24
84
|
log;
|
|
85
|
+
home;
|
|
25
86
|
drivers = new Map();
|
|
26
87
|
live = new Map();
|
|
27
88
|
summaryListeners = new Set();
|
|
@@ -30,10 +91,12 @@ export class SessionHub {
|
|
|
30
91
|
/** Terminals (`linkshell <agent>`) currently driving handoff sessions. */
|
|
31
92
|
desktops = new Map();
|
|
32
93
|
discoveryTimer;
|
|
94
|
+
branches = new Map();
|
|
95
|
+
worktreeCache;
|
|
33
96
|
authRefreshing;
|
|
34
97
|
driverHost = {
|
|
35
98
|
sessionSeen: (agent, session) => this.recordDiscovered(agent, session),
|
|
36
|
-
update: (agent, nativeId, update, itemId) => this.ingest(sessionIdFor(agent, nativeId), update, itemId),
|
|
99
|
+
update: (agent, nativeId, update, itemId, ts) => this.ingest(sessionIdFor(agent, nativeId), update, itemId, ts),
|
|
37
100
|
follow: (agent, nativeId) => {
|
|
38
101
|
const sessionId = sessionIdFor(agent, nativeId);
|
|
39
102
|
if (!this.store.getSession(sessionId))
|
|
@@ -64,9 +127,12 @@ export class SessionHub {
|
|
|
64
127
|
},
|
|
65
128
|
log: (message) => this.log(message),
|
|
66
129
|
};
|
|
67
|
-
constructor(store, drivers, log = () => { }
|
|
130
|
+
constructor(store, drivers, log = () => { },
|
|
131
|
+
/** Where the host keeps its own files: worktrees go under it. */
|
|
132
|
+
home = join(homedir(), ".linkshell")) {
|
|
68
133
|
this.store = store;
|
|
69
134
|
this.log = log;
|
|
135
|
+
this.home = home;
|
|
70
136
|
for (const driver of drivers)
|
|
71
137
|
this.drivers.set(driver.id, driver);
|
|
72
138
|
}
|
|
@@ -160,8 +226,22 @@ export class SessionHub {
|
|
|
160
226
|
const last = sessions[sessions.length - 1];
|
|
161
227
|
return { sessions, nextBefore: sessions.length === limit && last ? last.updatedAt : undefined };
|
|
162
228
|
}
|
|
163
|
-
|
|
164
|
-
|
|
229
|
+
/** Projects, each with the branch checked out there now. */
|
|
230
|
+
async listProjects(limit) {
|
|
231
|
+
const projects = this.store.listProjects(limit);
|
|
232
|
+
const branches = await Promise.all(projects.map((project) => this.branchOf(project.cwd)));
|
|
233
|
+
return projects.map((project, index) => (branches[index] ? { ...project, branch: branches[index] } : project));
|
|
234
|
+
}
|
|
235
|
+
/** Asked for every project each time the list is: remembered for a moment. */
|
|
236
|
+
branchOf(cwd) {
|
|
237
|
+
const known = this.branches.get(cwd);
|
|
238
|
+
if (known && Date.now() - known.at < 5000)
|
|
239
|
+
return known.branch;
|
|
240
|
+
const branch = gitBranch(cwd);
|
|
241
|
+
this.branches.set(cwd, { at: Date.now(), branch });
|
|
242
|
+
if (this.branches.size > 500)
|
|
243
|
+
this.branches.delete(this.branches.keys().next().value);
|
|
244
|
+
return branch;
|
|
165
245
|
}
|
|
166
246
|
getSession(sessionId) {
|
|
167
247
|
const summary = this.store.getSession(sessionId);
|
|
@@ -170,17 +250,43 @@ export class SessionHub {
|
|
|
170
250
|
return this.decorate(summary);
|
|
171
251
|
}
|
|
172
252
|
/** Adds the live-only fields (current activity, first pending permission) to a stored summary. */
|
|
253
|
+
/** Marks a session that works in one of LinkShell's worktrees. */
|
|
254
|
+
withWorktree(summary) {
|
|
255
|
+
const worktree = worktreeOf(summary.cwd, this.worktrees());
|
|
256
|
+
return worktree ? { ...summary, worktree: { branch: worktree.branch, source: worktree.sourceCwd } } : summary;
|
|
257
|
+
}
|
|
258
|
+
/** The worktrees table, read once and again whenever it changes. */
|
|
259
|
+
worktrees() {
|
|
260
|
+
return (this.worktreeCache ??= this.store.listWorktrees());
|
|
261
|
+
}
|
|
173
262
|
decorate(summary) {
|
|
174
263
|
const live = this.live.get(summary.id);
|
|
175
264
|
if (!live)
|
|
176
|
-
return summary;
|
|
265
|
+
return this.withWorktree(summary);
|
|
177
266
|
const activity = live.turnActive ? live.activity : undefined;
|
|
178
267
|
const first = live.permissions.values().next().value;
|
|
179
|
-
|
|
268
|
+
const subagents = live.attached ? this.subagentsOf(summary.id, live) : undefined;
|
|
269
|
+
summary = this.withWorktree(summary);
|
|
270
|
+
if (!activity && !first && !live.queue && live.held.length === 0 && !subagents?.size)
|
|
180
271
|
return summary;
|
|
181
272
|
const decorated = { ...summary };
|
|
182
|
-
if (
|
|
183
|
-
|
|
273
|
+
if (subagents?.size) {
|
|
274
|
+
let running = 0;
|
|
275
|
+
for (const state of subagents.values())
|
|
276
|
+
if (subagentRunning(state))
|
|
277
|
+
running += 1;
|
|
278
|
+
decorated.subagents = { total: subagents.size, running };
|
|
279
|
+
}
|
|
280
|
+
if (live.queue || live.held.length > 0) {
|
|
281
|
+
decorated.queue = [
|
|
282
|
+
...(live.queue ?? []),
|
|
283
|
+
...live.held.map((entry) => ({
|
|
284
|
+
clientMessageId: entry.clientMessageId,
|
|
285
|
+
text: textOf(entry.content),
|
|
286
|
+
images: entry.content.filter((block) => block.type === "image").length,
|
|
287
|
+
})),
|
|
288
|
+
];
|
|
289
|
+
}
|
|
184
290
|
if (activity)
|
|
185
291
|
decorated.activity = activity;
|
|
186
292
|
if (first) {
|
|
@@ -196,10 +302,13 @@ export class SessionHub {
|
|
|
196
302
|
}
|
|
197
303
|
async createSession(input) {
|
|
198
304
|
const driver = this.requireDriver(input.agent);
|
|
199
|
-
const
|
|
305
|
+
const worktree = input.worktree ? await this.newWorktree(input.cwd, promptLabel(input.prompt)) : undefined;
|
|
306
|
+
const discovered = await driver.createSession({ cwd: worktree?.cwd ?? input.cwd, model: input.model }).catch(async (error) => {
|
|
200
307
|
// The app shows "not logged in" from the agent list; make it current.
|
|
201
308
|
if (error instanceof RpcError && error.appCode === "not_logged_in")
|
|
202
309
|
await this.refreshAuth(driver);
|
|
310
|
+
if (worktree)
|
|
311
|
+
await this.dropWorktree(worktree.path);
|
|
203
312
|
throw error;
|
|
204
313
|
});
|
|
205
314
|
this.recordDiscovered(driver.id, discovered);
|
|
@@ -211,9 +320,140 @@ export class SessionHub {
|
|
|
211
320
|
return this.getSession(sessionId);
|
|
212
321
|
}
|
|
213
322
|
/**
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
323
|
+
* A new session that starts with this one's conversation: all of it, or
|
|
324
|
+
* through the turn `itemId` is in. With `worktree` it works in a new git
|
|
325
|
+
* worktree of the project.
|
|
326
|
+
*/
|
|
327
|
+
async fork(sessionId, options = {}) {
|
|
328
|
+
const summary = this.getSession(sessionId);
|
|
329
|
+
const driver = this.requireDriver(summary.agent);
|
|
330
|
+
if (driver.tier === "terminal")
|
|
331
|
+
throw RpcError.app("not_supported", `${driver.label} 不支持从会话分叉`);
|
|
332
|
+
let upTo;
|
|
333
|
+
let cut = summary.lastSeq;
|
|
334
|
+
if (options.itemId) {
|
|
335
|
+
const located = this.store.locateItem(sessionId, options.itemId);
|
|
336
|
+
if (!located)
|
|
337
|
+
throw RpcError.app("not_found", "找不到要分叉的那条消息");
|
|
338
|
+
upTo = { itemId: options.itemId, turn: located.turn };
|
|
339
|
+
cut = this.store.turnEnd(sessionId, located.seq);
|
|
340
|
+
}
|
|
341
|
+
// A fork of a worktree session made into a new worktree branches from the project, like its original did.
|
|
342
|
+
const origin = worktreeOf(summary.cwd, this.store.listWorktrees());
|
|
343
|
+
const worktree = options.worktree ? await this.newWorktree(origin?.sourceCwd ?? summary.cwd, summary.title ?? "fork") : undefined;
|
|
344
|
+
const cwd = worktree?.cwd ?? summary.cwd;
|
|
345
|
+
try {
|
|
346
|
+
let discovered;
|
|
347
|
+
if (driver.fork && driver.capabilities.fork) {
|
|
348
|
+
discovered = await driver.fork(summary.nativeId, { cwd, sourceCwd: summary.cwd, upTo }).catch((error) => {
|
|
349
|
+
// The agent forks, but not this way (only whole sessions, say): the fork is made by us instead.
|
|
350
|
+
if (error instanceof RpcError && error.appCode === "not_supported")
|
|
351
|
+
return undefined;
|
|
352
|
+
throw error;
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
if (!discovered)
|
|
356
|
+
return await this.forkByReplay(summary, driver, cwd, cut);
|
|
357
|
+
this.recordDiscovered(driver.id, { ...discovered, title: discovered.title ?? summary.title });
|
|
358
|
+
return this.getSession(sessionIdFor(driver.id, discovered.nativeId));
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
if (worktree)
|
|
362
|
+
await this.dropWorktree(worktree.path);
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* A fork for an agent that can't fork a session itself: a new session that
|
|
368
|
+
* shows the conversation up to `cut` (its latest page, from our log), whose
|
|
369
|
+
* agent is told that conversation with the first message sent to it.
|
|
370
|
+
*/
|
|
371
|
+
async forkByReplay(summary, driver, cwd, cut) {
|
|
372
|
+
const start = this.store.pageStart(summary.id, cut);
|
|
373
|
+
const events = this.store.readEvents(summary.id, start, PAGE.maxEvents + 1, cut).filter((event) => isConversation(event.update));
|
|
374
|
+
const discovered = await driver.createSession({ cwd });
|
|
375
|
+
this.recordDiscovered(driver.id, { ...discovered, title: discovered.title ?? summary.title });
|
|
376
|
+
const forkId = sessionIdFor(driver.id, discovered.nativeId);
|
|
377
|
+
await this.ensureAttached(forkId);
|
|
378
|
+
const live = this.liveFor(forkId);
|
|
379
|
+
live.importing = true;
|
|
380
|
+
try {
|
|
381
|
+
// Pictures and long output stay in the original; the fork's own log starts light.
|
|
382
|
+
for (const event of events)
|
|
383
|
+
this.commit(forkId, copied(slimEvent(event).update), undefined, event.ts);
|
|
384
|
+
}
|
|
385
|
+
finally {
|
|
386
|
+
live.importing = false;
|
|
387
|
+
}
|
|
388
|
+
this.commit(forkId, {
|
|
389
|
+
sessionUpdate: "ls_notice",
|
|
390
|
+
level: "info",
|
|
391
|
+
title: "分叉的会话",
|
|
392
|
+
detail: `之前的对话以文字交给 ${driver.label}${start > 0 ? ",更早的内容在原会话里" : ""}`,
|
|
393
|
+
});
|
|
394
|
+
const digest = conversationDigest(events);
|
|
395
|
+
if (digest)
|
|
396
|
+
this.store.setDriverState(forkId, CARRY, digest);
|
|
397
|
+
return this.getSession(forkId);
|
|
398
|
+
}
|
|
399
|
+
/** Hands a message to the agent — with the conversation a forked session still owes it, the first time. */
|
|
400
|
+
deliver(summary, driver, content, clientMessageId) {
|
|
401
|
+
const carry = this.store.getDriverState(summary.id, CARRY);
|
|
402
|
+
if (!carry)
|
|
403
|
+
return driver.prompt(summary.nativeId, content, clientMessageId);
|
|
404
|
+
return driver.prompt(summary.nativeId, content, clientMessageId, carry).then((delivery) => {
|
|
405
|
+
this.store.setDriverState(summary.id, CARRY, "");
|
|
406
|
+
return delivery;
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
// ── worktrees ─────────────────────────────────────────────────────
|
|
410
|
+
gitInfo(path) {
|
|
411
|
+
return gitInfo(path);
|
|
412
|
+
}
|
|
413
|
+
async newWorktree(cwd, label) {
|
|
414
|
+
const created = await createWorktree(cwd, this.home, label);
|
|
415
|
+
this.store.saveWorktree({ path: created.path, branch: created.branch, source: created.source, sourceCwd: cwd, base: created.base, createdAt: Date.now() });
|
|
416
|
+
this.worktreeCache = undefined;
|
|
417
|
+
return created;
|
|
418
|
+
}
|
|
419
|
+
async dropWorktree(path) {
|
|
420
|
+
const worktree = this.store.listWorktrees().find((entry) => entry.path === path);
|
|
421
|
+
if (!worktree)
|
|
422
|
+
return;
|
|
423
|
+
await removeWorktree(worktree).catch((error) => this.log(`[hub] couldn't remove worktree ${path}: ${String(error)}`));
|
|
424
|
+
this.store.deleteWorktree(path);
|
|
425
|
+
this.worktreeCache = undefined;
|
|
426
|
+
}
|
|
427
|
+
async listWorktrees() {
|
|
428
|
+
return Promise.all(this.store.listWorktrees().map(async (worktree) => ({
|
|
429
|
+
path: worktree.path,
|
|
430
|
+
branch: worktree.branch,
|
|
431
|
+
source: worktree.sourceCwd,
|
|
432
|
+
createdAt: worktree.createdAt,
|
|
433
|
+
sessions: this.store.sessionsUnder(worktree.path),
|
|
434
|
+
...(await worktreeState(worktree.path, worktree.base)),
|
|
435
|
+
})));
|
|
436
|
+
}
|
|
437
|
+
/** Removes a worktree and its branch, unless a session uses it or (without `force`) work in it would be lost. */
|
|
438
|
+
async removeWorktree(path, force = false) {
|
|
439
|
+
const worktree = this.store.listWorktrees().find((entry) => entry.path === path);
|
|
440
|
+
if (!worktree)
|
|
441
|
+
throw RpcError.app("not_found", "没有这个 worktree");
|
|
442
|
+
if (this.store.sessionsUnder(path).length > 0)
|
|
443
|
+
throw RpcError.app("busy", "还有会话在用这个 worktree:先删除那些会话");
|
|
444
|
+
if (!force) {
|
|
445
|
+
const state = await worktreeState(path, worktree.base);
|
|
446
|
+
if (state.dirty || state.ahead > 0)
|
|
447
|
+
throw RpcError.app("dirty", worktreeLossMessage(state));
|
|
448
|
+
}
|
|
449
|
+
await this.dropWorktree(path);
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Sends the backlog, then streams live events. A fresh subscriber (or one too
|
|
453
|
+
* far behind to catch up) gets the latest turns only, after the standing
|
|
454
|
+
* state from before them; earlier history is read in pages (`history`).
|
|
455
|
+
* The backlog read and the subscriber registration happen in one synchronous
|
|
456
|
+
* step, so no event can fall between them.
|
|
217
457
|
*/
|
|
218
458
|
async subscribe(sessionId, fromSeq, subscriber) {
|
|
219
459
|
const summary = this.getSession(sessionId);
|
|
@@ -229,22 +469,175 @@ export class SessionHub {
|
|
|
229
469
|
}
|
|
230
470
|
}
|
|
231
471
|
}
|
|
232
|
-
|
|
472
|
+
const live = this.liveFor(sessionId);
|
|
473
|
+
if (driver?.tier === "handoff")
|
|
474
|
+
this.lendCommands(this.getSession(sessionId));
|
|
475
|
+
const lastSeq = this.store.getSession(sessionId)?.lastSeq ?? 0;
|
|
476
|
+
let startSeq = fromSeq;
|
|
477
|
+
const missed = fromSeq > 0 && fromSeq <= lastSeq ? this.store.sizeAfter(sessionId, fromSeq) : undefined;
|
|
478
|
+
if (!missed || missed.events > CATCH_UP.events || missed.bytes > CATCH_UP.bytes) {
|
|
479
|
+
startSeq = this.store.pageStart(sessionId, lastSeq);
|
|
480
|
+
if (startSeq !== fromSeq)
|
|
481
|
+
subscriber.window?.(startSeq);
|
|
482
|
+
for (const event of this.standingBefore(sessionId, startSeq, live))
|
|
483
|
+
subscriber.event(event);
|
|
484
|
+
}
|
|
485
|
+
let cursor = startSeq;
|
|
233
486
|
for (;;) {
|
|
234
|
-
const batch = this.store.readEvents(sessionId, cursor);
|
|
487
|
+
const batch = this.store.readEvents(sessionId, cursor, 200);
|
|
235
488
|
for (const event of batch)
|
|
236
489
|
subscriber.event(event);
|
|
237
490
|
if (batch.length === 0)
|
|
238
491
|
break;
|
|
239
492
|
cursor = batch[batch.length - 1].seq;
|
|
240
493
|
}
|
|
241
|
-
|
|
242
|
-
return this.getSession(sessionId);
|
|
494
|
+
live.subscribers.add(subscriber);
|
|
495
|
+
return { session: this.getSession(sessionId), startSeq };
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* A handoff agent names its commands only once it runs here, so a session
|
|
499
|
+
* still driven at the desk has none to offer. It borrows the list of the
|
|
500
|
+
* agent's latest session in the same folder (or its latest anywhere) until
|
|
501
|
+
* it gets its own: `/compact` typed on the phone works from the first message.
|
|
502
|
+
*/
|
|
503
|
+
lendCommands(summary) {
|
|
504
|
+
const kind = "available_commands_update";
|
|
505
|
+
if (this.store.latestOfKind(summary.id, kind, summary.lastSeq).length > 0)
|
|
506
|
+
return;
|
|
507
|
+
let found;
|
|
508
|
+
for (const other of this.store.listSessions({ limit: 80, includeArchived: true })) {
|
|
509
|
+
if (other.agent !== summary.agent || other.id === summary.id)
|
|
510
|
+
continue;
|
|
511
|
+
const [event] = this.store.latestOfKind(other.id, kind, other.lastSeq);
|
|
512
|
+
if (!event)
|
|
513
|
+
continue;
|
|
514
|
+
found ??= event;
|
|
515
|
+
if (other.cwd === summary.cwd) {
|
|
516
|
+
found = event;
|
|
517
|
+
break;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (found)
|
|
521
|
+
this.commit(summary.id, found.update);
|
|
522
|
+
}
|
|
523
|
+
/** The page of history before `beforeSeq`, oldest first. */
|
|
524
|
+
history(sessionId, beforeSeq) {
|
|
525
|
+
this.getSession(sessionId);
|
|
526
|
+
const upTo = beforeSeq - 1;
|
|
527
|
+
if (upTo < 1)
|
|
528
|
+
return { events: [], startSeq: 0 };
|
|
529
|
+
const startSeq = this.store.pageStart(sessionId, upTo);
|
|
530
|
+
return { events: this.store.readEvents(sessionId, startSeq, PAGE.maxEvents + 1, upTo), startSeq };
|
|
531
|
+
}
|
|
532
|
+
/** The sub-agents the session started, newest first. */
|
|
533
|
+
subagents(sessionId) {
|
|
534
|
+
this.getSession(sessionId);
|
|
535
|
+
const agents = new Map();
|
|
536
|
+
for (const event of this.store.subagentCalls(sessionId)) {
|
|
537
|
+
const call = event.update;
|
|
538
|
+
// (A call still open when the session was opened again is logged again.)
|
|
539
|
+
if (agents.has(call.toolCallId))
|
|
540
|
+
continue;
|
|
541
|
+
const detail = call.detail?.type === "subagent" ? call.detail : undefined;
|
|
542
|
+
const state = this.store.toolState(sessionId, call.toolCallId);
|
|
543
|
+
const callDone = state.status === "completed" || state.status === "failed";
|
|
544
|
+
const running = subagentRunning({ callDone, turnActive: state.turnActive, lastChildTs: state.lastChildTs });
|
|
545
|
+
agents.set(call.toolCallId, {
|
|
546
|
+
toolCallId: call.toolCallId,
|
|
547
|
+
task: detail?.task ?? call.title,
|
|
548
|
+
agentType: detail?.agentType,
|
|
549
|
+
running,
|
|
550
|
+
failed: state.status === "failed" || undefined,
|
|
551
|
+
startedAt: event.ts,
|
|
552
|
+
endedAt: running ? undefined : callDone ? state.ts : state.lastChildTs,
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
return [...agents.values()].reverse();
|
|
556
|
+
}
|
|
557
|
+
subagentsOf(sessionId, live) {
|
|
558
|
+
if (!live.subagents) {
|
|
559
|
+
live.subagents = new Map();
|
|
560
|
+
for (const event of this.store.subagentCalls(sessionId)) {
|
|
561
|
+
const { toolCallId } = event.update;
|
|
562
|
+
const state = this.store.toolState(sessionId, toolCallId);
|
|
563
|
+
live.subagents.set(toolCallId, {
|
|
564
|
+
callDone: state.status === "completed" || state.status === "failed",
|
|
565
|
+
turnActive: state.turnActive,
|
|
566
|
+
lastChildTs: state.lastChildTs,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return live.subagents;
|
|
571
|
+
}
|
|
572
|
+
/** Keeps the sub-agent tally current; true when it changed. */
|
|
573
|
+
trackSubagent(live, update) {
|
|
574
|
+
const known = live.subagents;
|
|
575
|
+
if (!known)
|
|
576
|
+
return false;
|
|
577
|
+
if (update.sessionUpdate === "tool_call" && update.detail?.type === "subagent" && (update.detail.action ?? "spawn") === "spawn") {
|
|
578
|
+
if (known.has(update.toolCallId))
|
|
579
|
+
return false;
|
|
580
|
+
known.set(update.toolCallId, { callDone: update.status === "completed" || update.status === "failed" });
|
|
581
|
+
return true;
|
|
582
|
+
}
|
|
583
|
+
if (update.sessionUpdate === "tool_call_update" && !update.parentToolCallId) {
|
|
584
|
+
const state = known.get(update.toolCallId);
|
|
585
|
+
if (!state || state.callDone || (update.status !== "completed" && update.status !== "failed"))
|
|
586
|
+
return false;
|
|
587
|
+
state.callDone = true;
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
const parent = update.parentToolCallId;
|
|
591
|
+
const state = parent ? known.get(parent) : undefined;
|
|
592
|
+
if (!state)
|
|
593
|
+
return false;
|
|
594
|
+
const was = subagentRunning(state);
|
|
595
|
+
state.lastChildTs = Date.now();
|
|
596
|
+
if (update.sessionUpdate === "ls_turn")
|
|
597
|
+
state.turnActive = update.state === "started";
|
|
598
|
+
return subagentRunning(state) !== was;
|
|
599
|
+
}
|
|
600
|
+
/** A sub-agent's conversation: its call, then what happened under it. */
|
|
601
|
+
subagent(sessionId, toolCallId) {
|
|
602
|
+
this.getSession(sessionId);
|
|
603
|
+
const own = this.store.toolEvents(sessionId, toolCallId);
|
|
604
|
+
if (own.length === 0)
|
|
605
|
+
throw RpcError.app("not_found", "这个子 Agent 已经不在会话记录里");
|
|
606
|
+
return [...own, ...this.store.eventsUnder(sessionId, toolCallId, 2000)].sort((a, b) => a.seq - b.seq);
|
|
607
|
+
}
|
|
608
|
+
readEvent(sessionId, seq) {
|
|
609
|
+
this.getSession(sessionId);
|
|
610
|
+
return this.store.readEvent(sessionId, seq);
|
|
611
|
+
}
|
|
612
|
+
/** What still holds at `seq` from the events before it: settings, an open turn, unanswered approvals. */
|
|
613
|
+
standingBefore(sessionId, seq, live) {
|
|
614
|
+
if (seq < 1)
|
|
615
|
+
return [];
|
|
616
|
+
const events = STANDING.flatMap((kind) => this.store.latestOfKind(sessionId, kind, seq));
|
|
617
|
+
const [turn] = this.store.latestOfKind(sessionId, "ls_turn", seq);
|
|
618
|
+
if (turn?.update.sessionUpdate === "ls_turn" && turn.update.state === "started")
|
|
619
|
+
events.push(turn);
|
|
620
|
+
if (live.permissions.size > 0) {
|
|
621
|
+
for (const event of this.store.latestOfKind(sessionId, "ls_permission", seq, 50)) {
|
|
622
|
+
if (event.update.sessionUpdate === "ls_permission" && live.permissions.has(event.update.requestId))
|
|
623
|
+
events.push(event);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
// Sub-agents still working were started before the window: their calls, so
|
|
627
|
+
// what they do next has its card to go under.
|
|
628
|
+
for (const [toolCallId, state] of this.subagentsOf(sessionId, live)) {
|
|
629
|
+
if (!subagentRunning(state))
|
|
630
|
+
continue;
|
|
631
|
+
const call = this.store.toolEvents(sessionId, toolCallId)[0];
|
|
632
|
+
if (call && call.seq <= seq)
|
|
633
|
+
events.push(call);
|
|
634
|
+
}
|
|
635
|
+
return events.sort((a, b) => a.seq - b.seq);
|
|
243
636
|
}
|
|
244
637
|
unsubscribe(sessionId, subscriber) {
|
|
245
638
|
this.live.get(sessionId)?.subscribers.delete(subscriber);
|
|
246
639
|
}
|
|
247
|
-
async prompt(sessionId, clientMessageId, content) {
|
|
640
|
+
async prompt(sessionId, clientMessageId, content, whenBusy) {
|
|
248
641
|
const summary = this.getSession(sessionId);
|
|
249
642
|
const driver = this.requireDriver(summary.agent);
|
|
250
643
|
let joinAfterSend = false;
|
|
@@ -259,9 +652,15 @@ export class SessionHub {
|
|
|
259
652
|
}
|
|
260
653
|
if (!this.store.claimClientMessage(sessionId, clientMessageId))
|
|
261
654
|
return "duplicate";
|
|
655
|
+
const live = this.liveFor(sessionId);
|
|
656
|
+
if (whenBusy === "queue" && (live.turnActive || live.held.length > 0)) {
|
|
657
|
+
live.held.push({ clientMessageId, content });
|
|
658
|
+
this.announce(sessionId);
|
|
659
|
+
return "queued";
|
|
660
|
+
}
|
|
262
661
|
let delivery;
|
|
263
662
|
try {
|
|
264
|
-
delivery = await
|
|
663
|
+
delivery = await this.deliver(summary, driver, content, clientMessageId);
|
|
265
664
|
}
|
|
266
665
|
catch (error) {
|
|
267
666
|
this.store.releaseClientMessage(sessionId, clientMessageId);
|
|
@@ -290,12 +689,87 @@ export class SessionHub {
|
|
|
290
689
|
unqueue(sessionId, clientMessageId) {
|
|
291
690
|
const summary = this.getSession(sessionId);
|
|
292
691
|
const driver = this.requireDriver(summary.agent);
|
|
293
|
-
const
|
|
692
|
+
const live = this.liveFor(sessionId);
|
|
693
|
+
const held = live.held.findIndex((entry) => entry.clientMessageId === clientMessageId);
|
|
694
|
+
if (held >= 0) {
|
|
695
|
+
live.held.splice(held, 1);
|
|
696
|
+
this.announce(sessionId);
|
|
697
|
+
}
|
|
698
|
+
const removed = held >= 0 || (driver.unqueue?.(summary.nativeId, clientMessageId) ?? false);
|
|
294
699
|
// Sent again later, it should go through.
|
|
295
700
|
if (removed)
|
|
296
701
|
this.store.releaseClientMessage(sessionId, clientMessageId);
|
|
297
702
|
return removed;
|
|
298
703
|
}
|
|
704
|
+
reorderQueue(sessionId, clientMessageIds) {
|
|
705
|
+
const summary = this.getSession(sessionId);
|
|
706
|
+
const live = this.liveFor(sessionId);
|
|
707
|
+
live.held = inOrder(live.held, clientMessageIds);
|
|
708
|
+
this.requireDriver(summary.agent).reorderQueue?.(summary.nativeId, clientMessageIds);
|
|
709
|
+
this.announce(sessionId);
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Sends a queued message without waiting for the running turn to end: into
|
|
713
|
+
* that turn if the agent takes input mid-turn, otherwise by stopping it.
|
|
714
|
+
*/
|
|
715
|
+
async sendQueuedNow(sessionId, clientMessageId) {
|
|
716
|
+
const summary = this.getSession(sessionId);
|
|
717
|
+
const driver = this.requireDriver(summary.agent);
|
|
718
|
+
const live = this.liveFor(sessionId);
|
|
719
|
+
const index = clientMessageId ? live.held.findIndex((entry) => entry.clientMessageId === clientMessageId) : 0;
|
|
720
|
+
const item = live.held[index];
|
|
721
|
+
if (!item) {
|
|
722
|
+
// Held by the driver (another Claude is mid-turn in the session), or already gone.
|
|
723
|
+
await driver.sendQueuedNow?.(summary.nativeId);
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
live.held.splice(index, 1);
|
|
727
|
+
if (live.turnActive && !driver.capabilities.steer) {
|
|
728
|
+
// The turn has to end first: stop it, and this message is next.
|
|
729
|
+
live.held.unshift(item);
|
|
730
|
+
this.announce(sessionId);
|
|
731
|
+
await driver.cancel(summary.nativeId);
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
this.announce(sessionId);
|
|
735
|
+
try {
|
|
736
|
+
const delivery = await this.deliver(summary, driver, item.content, item.clientMessageId);
|
|
737
|
+
if (delivery === "queued")
|
|
738
|
+
await driver.sendQueuedNow?.(summary.nativeId);
|
|
739
|
+
}
|
|
740
|
+
catch (error) {
|
|
741
|
+
live.held.unshift(item);
|
|
742
|
+
this.announce(sessionId);
|
|
743
|
+
throw error;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
/** A turn ended: the next held message starts the following one. */
|
|
747
|
+
async sendHeld(sessionId) {
|
|
748
|
+
const live = this.live.get(sessionId);
|
|
749
|
+
const summary = this.store.getSession(sessionId);
|
|
750
|
+
if (!live || !summary || live.held.length === 0 || live.turnActive || live.sendingHeld)
|
|
751
|
+
return;
|
|
752
|
+
const driver = this.drivers.get(summary.agent);
|
|
753
|
+
if (!driver)
|
|
754
|
+
return;
|
|
755
|
+
const next = live.held.shift();
|
|
756
|
+
live.sendingHeld = true;
|
|
757
|
+
this.announce(sessionId);
|
|
758
|
+
try {
|
|
759
|
+
await this.deliver(summary, driver, next.content, next.clientMessageId);
|
|
760
|
+
}
|
|
761
|
+
catch (error) {
|
|
762
|
+
this.store.releaseClientMessage(sessionId, next.clientMessageId);
|
|
763
|
+
this.commit(sessionId, {
|
|
764
|
+
sessionUpdate: "ls_error",
|
|
765
|
+
code: error instanceof RpcError ? (error.appCode ?? "send_failed") : "send_failed",
|
|
766
|
+
message: `排队的消息没能发出:${error instanceof Error ? error.message : String(error)}`,
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
finally {
|
|
770
|
+
live.sendingHeld = false;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
299
773
|
onRemoved(listener) {
|
|
300
774
|
this.removedListeners.add(listener);
|
|
301
775
|
return () => this.removedListeners.delete(listener);
|
|
@@ -322,11 +796,18 @@ export class SessionHub {
|
|
|
322
796
|
this.log(`[hub] ${summary.agent} rename failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
323
797
|
}
|
|
324
798
|
}
|
|
799
|
+
// The agent writing its own title record touches the session: for the next
|
|
800
|
+
// few seconds a newer "last updated" from it is the rename, not activity.
|
|
801
|
+
this.store.setDriverState(sessionId, RENAMED, JSON.stringify({ updatedAt: summary.updatedAt, until: Date.now() + 5000 }));
|
|
325
802
|
const updated = this.store.patchSession(sessionId, { customTitle: name || null });
|
|
326
803
|
this.emitSummary(updated);
|
|
327
804
|
return this.decorate(updated);
|
|
328
805
|
}
|
|
329
|
-
|
|
806
|
+
/**
|
|
807
|
+
* Deletes a session. A worktree only it used goes too when nothing in it
|
|
808
|
+
* would be lost (or `worktree` says "remove"); "keep" leaves it.
|
|
809
|
+
*/
|
|
810
|
+
async delete(sessionId, worktree) {
|
|
330
811
|
const summary = this.getSession(sessionId);
|
|
331
812
|
const live = this.live.get(sessionId);
|
|
332
813
|
if (live?.turnActive || summary.state === "running")
|
|
@@ -336,10 +817,21 @@ export class SessionHub {
|
|
|
336
817
|
const driver = this.drivers.get(summary.agent);
|
|
337
818
|
if (driver) {
|
|
338
819
|
await driver.detach(summary.nativeId).catch(() => { });
|
|
339
|
-
if (driver.delete)
|
|
340
|
-
await driver.delete(summary.nativeId)
|
|
820
|
+
if (driver.delete) {
|
|
821
|
+
await driver.delete(summary.nativeId).catch((error) => {
|
|
822
|
+
// Already gone on the agent's side (deleted there): only our record is left to remove.
|
|
823
|
+
if (!/no rollout|not found|unknown (thread|session)|no conversation found/i.test(error instanceof Error ? error.message : String(error)))
|
|
824
|
+
throw error;
|
|
825
|
+
});
|
|
826
|
+
}
|
|
341
827
|
}
|
|
342
828
|
this.forget(sessionId);
|
|
829
|
+
const own = worktreeOf(summary.cwd, this.store.listWorktrees());
|
|
830
|
+
if (!own || worktree === "keep" || this.store.sessionsUnder(own.path).length > 0)
|
|
831
|
+
return;
|
|
832
|
+
const state = await worktreeState(own.path, own.base);
|
|
833
|
+
if (worktree === "remove" || (!state.dirty && state.ahead === 0))
|
|
834
|
+
await this.dropWorktree(own.path);
|
|
343
835
|
}
|
|
344
836
|
/** Drops a session from LinkShell and tells every client. */
|
|
345
837
|
forget(sessionId) {
|
|
@@ -358,6 +850,14 @@ export class SessionHub {
|
|
|
358
850
|
}
|
|
359
851
|
async cancel(sessionId) {
|
|
360
852
|
const summary = this.getSession(sessionId);
|
|
853
|
+
// Stopping stops what's waiting too; the apps put queued text back in the composer.
|
|
854
|
+
const live = this.liveFor(sessionId);
|
|
855
|
+
if (live.held.length > 0) {
|
|
856
|
+
for (const entry of live.held)
|
|
857
|
+
this.store.releaseClientMessage(sessionId, entry.clientMessageId);
|
|
858
|
+
live.held = [];
|
|
859
|
+
this.announce(sessionId);
|
|
860
|
+
}
|
|
361
861
|
await this.requireDriver(summary.agent).cancel(summary.nativeId);
|
|
362
862
|
}
|
|
363
863
|
async respondPermission(sessionId, requestId, optionId) {
|
|
@@ -448,6 +948,7 @@ export class SessionHub {
|
|
|
448
948
|
messageText: new Map(),
|
|
449
949
|
permissions: new Map(),
|
|
450
950
|
turnActive: false,
|
|
951
|
+
held: [],
|
|
451
952
|
};
|
|
452
953
|
this.live.set(sessionId, live);
|
|
453
954
|
}
|
|
@@ -478,8 +979,9 @@ export class SessionHub {
|
|
|
478
979
|
live.importing = true;
|
|
479
980
|
live.importChanged = false;
|
|
480
981
|
try {
|
|
481
|
-
// Items without a recorded time get the
|
|
482
|
-
|
|
982
|
+
// Items without a recorded time get the time of the one before, never "now".
|
|
983
|
+
// (An agent that only says when a session last changed gives that as its creation too.)
|
|
984
|
+
let ts = Math.min(summary.createdAt, history.find((item) => item.ts !== undefined)?.ts ?? summary.createdAt);
|
|
483
985
|
for (const item of history) {
|
|
484
986
|
ts = Math.min(Math.max(item.ts ?? ts, ts), summary.updatedAt);
|
|
485
987
|
if (this.store.isItemLogged(sessionId, item.itemId))
|
|
@@ -501,11 +1003,11 @@ export class SessionHub {
|
|
|
501
1003
|
finally {
|
|
502
1004
|
const buffered = live.buffer ?? [];
|
|
503
1005
|
live.buffer = undefined;
|
|
504
|
-
for (const { update, itemId } of buffered) {
|
|
1006
|
+
for (const { update, itemId, ts } of buffered) {
|
|
505
1007
|
// History already covered this item; skip its completion to avoid a duplicate.
|
|
506
1008
|
if (itemId && this.store.isItemLogged(sessionId, itemId))
|
|
507
1009
|
continue;
|
|
508
|
-
this.commit(sessionId, update, itemId);
|
|
1010
|
+
this.commit(sessionId, update, itemId, ts);
|
|
509
1011
|
}
|
|
510
1012
|
live.attaching = undefined;
|
|
511
1013
|
}
|
|
@@ -517,6 +1019,15 @@ export class SessionHub {
|
|
|
517
1019
|
if (this.store.isRemoved(sessionIdFor(agent, session.nativeId)))
|
|
518
1020
|
return;
|
|
519
1021
|
const before = this.store.getSession(sessionIdFor(agent, session.nativeId));
|
|
1022
|
+
if (before && session.updatedAt > before.updatedAt) {
|
|
1023
|
+
// Renamed just now: the session keeps its place in the list.
|
|
1024
|
+
const renamed = this.store.getDriverState(before.id, RENAMED);
|
|
1025
|
+
if (renamed) {
|
|
1026
|
+
const hold = JSON.parse(renamed);
|
|
1027
|
+
if (session.updatedAt <= hold.until)
|
|
1028
|
+
session = { ...session, updatedAt: before.updatedAt };
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
520
1031
|
const { summary, created } = this.store.upsertSession({
|
|
521
1032
|
id: sessionIdFor(agent, session.nativeId),
|
|
522
1033
|
agent,
|
|
@@ -538,23 +1049,23 @@ export class SessionHub {
|
|
|
538
1049
|
if (created || changed)
|
|
539
1050
|
this.emitSummary(summary);
|
|
540
1051
|
}
|
|
541
|
-
ingest(sessionId, update, itemId) {
|
|
1052
|
+
ingest(sessionId, update, itemId, ts) {
|
|
542
1053
|
if (!this.store.getSession(sessionId)) {
|
|
543
1054
|
this.log(`[hub] dropping update for unknown session ${sessionId}`);
|
|
544
1055
|
return;
|
|
545
1056
|
}
|
|
546
1057
|
const live = this.liveFor(sessionId);
|
|
547
1058
|
if (live.buffer) {
|
|
548
|
-
live.buffer.push({ update, itemId });
|
|
1059
|
+
live.buffer.push({ update, itemId, ts });
|
|
549
1060
|
return;
|
|
550
1061
|
}
|
|
551
|
-
this.commit(sessionId, update, itemId);
|
|
1062
|
+
this.commit(sessionId, update, itemId, ts);
|
|
552
1063
|
}
|
|
553
1064
|
commit(sessionId, update, itemId, ts) {
|
|
554
1065
|
const live = this.liveFor(sessionId);
|
|
555
1066
|
if (update.sessionUpdate === "ls_permission_resolved" && !live.permissions.has(update.requestId))
|
|
556
1067
|
return;
|
|
557
|
-
const event = this.store.appendEvent(sessionId, update, ts);
|
|
1068
|
+
const event = this.store.appendEvent(sessionId, update, ts, isActivity(update));
|
|
558
1069
|
if (itemId)
|
|
559
1070
|
this.store.markItemLogged(sessionId, itemId);
|
|
560
1071
|
this.applyToSummary(sessionId, live, update);
|
|
@@ -567,10 +1078,12 @@ export class SessionHub {
|
|
|
567
1078
|
}
|
|
568
1079
|
}
|
|
569
1080
|
// A finished turn can't still be waiting on approvals; clear stale cards.
|
|
570
|
-
if (update.sessionUpdate === "ls_turn" && update.state === "ended") {
|
|
1081
|
+
if (update.sessionUpdate === "ls_turn" && update.state === "ended" && !update.parentToolCallId) {
|
|
571
1082
|
for (const requestId of [...live.permissions.keys()]) {
|
|
572
1083
|
this.commit(sessionId, { sessionUpdate: "ls_permission_resolved", requestId });
|
|
573
1084
|
}
|
|
1085
|
+
if (!live.importing)
|
|
1086
|
+
void this.sendHeld(sessionId);
|
|
574
1087
|
}
|
|
575
1088
|
}
|
|
576
1089
|
applyToSummary(sessionId, live, update) {
|
|
@@ -578,7 +1091,7 @@ export class SessionHub {
|
|
|
578
1091
|
if (!before)
|
|
579
1092
|
return;
|
|
580
1093
|
const patch = {};
|
|
581
|
-
let activityChanged =
|
|
1094
|
+
let activityChanged = this.trackSubagent(live, update) && !live.importing;
|
|
582
1095
|
const setActivity = (next) => {
|
|
583
1096
|
if (live.importing || sameActivity(live.activity, next))
|
|
584
1097
|
return;
|
|
@@ -617,6 +1130,9 @@ export class SessionHub {
|
|
|
617
1130
|
break;
|
|
618
1131
|
}
|
|
619
1132
|
case "ls_turn":
|
|
1133
|
+
// A sub-agent's turn is its own; the session's is the one without a parent.
|
|
1134
|
+
if (update.parentToolCallId)
|
|
1135
|
+
break;
|
|
620
1136
|
live.turnActive = update.state === "started";
|
|
621
1137
|
setActivity(live.turnActive ? { kind: "thinking" } : undefined);
|
|
622
1138
|
// Leftover permissions are cleared right after a turn ends (see commit).
|
|
@@ -663,9 +1179,16 @@ export class SessionHub {
|
|
|
663
1179
|
live.importChanged = true;
|
|
664
1180
|
return;
|
|
665
1181
|
}
|
|
666
|
-
|
|
1182
|
+
if (isActivity(update))
|
|
1183
|
+
patch.updatedAt = Date.now();
|
|
667
1184
|
this.emitSummary(this.store.patchSession(sessionId, patch));
|
|
668
1185
|
}
|
|
1186
|
+
/** Sends the session's current summary to every client (its queue or activity changed). */
|
|
1187
|
+
announce(sessionId) {
|
|
1188
|
+
const stored = this.store.getSession(sessionId);
|
|
1189
|
+
if (stored)
|
|
1190
|
+
this.emitSummary(stored);
|
|
1191
|
+
}
|
|
669
1192
|
emitSummary(stored) {
|
|
670
1193
|
const summary = this.decorate(stored);
|
|
671
1194
|
for (const listener of this.summaryListeners) {
|