linkshell-cli 0.6.1 → 0.6.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/README.md +47 -132
- package/dist/cli/src/auth.js +1 -1
- package/dist/cli/src/auth.js.map +1 -1
- package/dist/cli/src/commands/doctor.d.ts +1 -1
- package/dist/cli/src/commands/doctor.js +76 -36
- package/dist/cli/src/commands/doctor.js.map +1 -1
- package/dist/cli/src/commands/host.d.ts +15 -0
- package/dist/cli/src/commands/host.js +101 -20
- package/dist/cli/src/commands/host.js.map +1 -1
- package/dist/cli/src/commands/logout.d.ts +1 -1
- package/dist/cli/src/commands/logout.js +10 -2
- package/dist/cli/src/commands/logout.js.map +1 -1
- package/dist/cli/src/commands/upgrade.js +11 -0
- package/dist/cli/src/commands/upgrade.js.map +1 -1
- package/dist/cli/src/config.js +1 -1
- package/dist/cli/src/config.js.map +1 -1
- package/dist/cli/src/index.js +105 -120
- package/dist/cli/src/index.js.map +1 -1
- package/dist/cli/src/utils/daemon.d.ts +2 -0
- package/dist/cli/src/utils/daemon.js +4 -0
- package/dist/cli/src/utils/daemon.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- 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 +21 -3
- package/dist/host/src/drivers/acp/driver.js +99 -13
- 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 +58 -6
- package/dist/host/src/drivers/acp/mapper.js.map +1 -1
- package/dist/host/src/drivers/claude/driver.d.ts +76 -8
- package/dist/host/src/drivers/claude/driver.js +510 -29
- 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 +105 -61
- package/dist/host/src/drivers/claude/transcript.js +260 -22
- 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 +21 -1
- package/dist/host/src/drivers/codex/driver.js +140 -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 +45 -4
- 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 +10 -1
- package/dist/host/src/gateway.js.map +1 -1
- package/dist/host/src/host.d.ts +7 -3
- package/dist/host/src/host.js +53 -21
- package/dist/host/src/host.js.map +1 -1
- package/dist/host/src/hub.d.ts +98 -14
- package/dist/host/src/hub.js +639 -27
- package/dist/host/src/hub.js.map +1 -1
- package/dist/host/src/index.d.ts +1 -1
- package/dist/host/src/index.js +1 -1
- package/dist/host/src/index.js.map +1 -1
- package/dist/host/src/legacy.d.ts +7 -0
- package/dist/host/src/legacy.js +36 -0
- package/dist/host/src/legacy.js.map +1 -0
- package/dist/host/src/rpc/server.d.ts +4 -2
- package/dist/host/src/rpc/server.js +62 -5
- 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 +54 -2
- package/dist/host/src/screen.js.map +1 -1
- package/dist/host/src/shell-env.d.ts +6 -0
- package/dist/host/src/shell-env.js +48 -0
- package/dist/host/src/shell-env.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 +81 -3
- package/dist/host/src/store.js +249 -16
- package/dist/host/src/store.js.map +1 -1
- package/dist/host/src/terminals.d.ts +4 -4
- package/dist/host/src/terminals.js +19 -4
- package/dist/host/src/terminals.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/wire/src/model.d.ts +334 -139
- package/dist/wire/src/model.js +68 -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 +7273 -890
- package/dist/wire/src/rpc.js +154 -6
- package/dist/wire/src/rpc.js.map +1 -1
- package/dist/wire/src/updates.d.ts +498 -498
- package/package.json +5 -5
- package/src/auth.ts +1 -1
- package/src/commands/doctor.ts +76 -34
- package/src/commands/host.ts +109 -19
- package/src/commands/logout.ts +10 -2
- package/src/commands/upgrade.ts +11 -0
- package/src/config.ts +1 -1
- package/src/index.ts +104 -137
- package/src/utils/daemon.ts +5 -0
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,17 +82,21 @@ 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();
|
|
89
|
+
removedListeners = new Set();
|
|
28
90
|
auth = new Map();
|
|
29
91
|
/** Terminals (`linkshell <agent>`) currently driving handoff sessions. */
|
|
30
92
|
desktops = new Map();
|
|
31
93
|
discoveryTimer;
|
|
94
|
+
branches = new Map();
|
|
95
|
+
worktreeCache;
|
|
32
96
|
authRefreshing;
|
|
33
97
|
driverHost = {
|
|
34
98
|
sessionSeen: (agent, session) => this.recordDiscovered(agent, session),
|
|
35
|
-
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),
|
|
36
100
|
follow: (agent, nativeId) => {
|
|
37
101
|
const sessionId = sessionIdFor(agent, nativeId);
|
|
38
102
|
if (!this.store.getSession(sessionId))
|
|
@@ -44,6 +108,15 @@ export class SessionHub {
|
|
|
44
108
|
if (live)
|
|
45
109
|
live.attached = false;
|
|
46
110
|
},
|
|
111
|
+
queue: (agent, nativeId, items) => {
|
|
112
|
+
const sessionId = sessionIdFor(agent, nativeId);
|
|
113
|
+
const stored = this.store.getSession(sessionId);
|
|
114
|
+
if (!stored)
|
|
115
|
+
return;
|
|
116
|
+
this.liveFor(sessionId).queue = items.length ? items : undefined;
|
|
117
|
+
this.emitSummary(stored);
|
|
118
|
+
},
|
|
119
|
+
removed: (agent, nativeId) => this.forget(sessionIdFor(agent, nativeId)),
|
|
47
120
|
desktop: (agent, nativeId) => this.desktops.get(sessionIdFor(agent, nativeId)),
|
|
48
121
|
state: (agent, nativeId) => {
|
|
49
122
|
const sessionId = sessionIdFor(agent, nativeId);
|
|
@@ -54,9 +127,12 @@ export class SessionHub {
|
|
|
54
127
|
},
|
|
55
128
|
log: (message) => this.log(message),
|
|
56
129
|
};
|
|
57
|
-
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")) {
|
|
58
133
|
this.store = store;
|
|
59
134
|
this.log = log;
|
|
135
|
+
this.home = home;
|
|
60
136
|
for (const driver of drivers)
|
|
61
137
|
this.drivers.set(driver.id, driver);
|
|
62
138
|
}
|
|
@@ -150,8 +226,22 @@ export class SessionHub {
|
|
|
150
226
|
const last = sessions[sessions.length - 1];
|
|
151
227
|
return { sessions, nextBefore: sessions.length === limit && last ? last.updatedAt : undefined };
|
|
152
228
|
}
|
|
153
|
-
|
|
154
|
-
|
|
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;
|
|
155
245
|
}
|
|
156
246
|
getSession(sessionId) {
|
|
157
247
|
const summary = this.store.getSession(sessionId);
|
|
@@ -160,15 +250,43 @@ export class SessionHub {
|
|
|
160
250
|
return this.decorate(summary);
|
|
161
251
|
}
|
|
162
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
|
+
}
|
|
163
262
|
decorate(summary) {
|
|
164
263
|
const live = this.live.get(summary.id);
|
|
165
264
|
if (!live)
|
|
166
|
-
return summary;
|
|
265
|
+
return this.withWorktree(summary);
|
|
167
266
|
const activity = live.turnActive ? live.activity : undefined;
|
|
168
267
|
const first = live.permissions.values().next().value;
|
|
169
|
-
|
|
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)
|
|
170
271
|
return summary;
|
|
171
272
|
const decorated = { ...summary };
|
|
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
|
+
}
|
|
172
290
|
if (activity)
|
|
173
291
|
decorated.activity = activity;
|
|
174
292
|
if (first) {
|
|
@@ -184,7 +302,15 @@ export class SessionHub {
|
|
|
184
302
|
}
|
|
185
303
|
async createSession(input) {
|
|
186
304
|
const driver = this.requireDriver(input.agent);
|
|
187
|
-
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) => {
|
|
307
|
+
// The app shows "not logged in" from the agent list; make it current.
|
|
308
|
+
if (error instanceof RpcError && error.appCode === "not_logged_in")
|
|
309
|
+
await this.refreshAuth(driver);
|
|
310
|
+
if (worktree)
|
|
311
|
+
await this.dropWorktree(worktree.path);
|
|
312
|
+
throw error;
|
|
313
|
+
});
|
|
188
314
|
this.recordDiscovered(driver.id, discovered);
|
|
189
315
|
const sessionId = sessionIdFor(driver.id, discovered.nativeId);
|
|
190
316
|
await this.ensureAttached(sessionId);
|
|
@@ -194,9 +320,140 @@ export class SessionHub {
|
|
|
194
320
|
return this.getSession(sessionId);
|
|
195
321
|
}
|
|
196
322
|
/**
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
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.
|
|
200
457
|
*/
|
|
201
458
|
async subscribe(sessionId, fromSeq, subscriber) {
|
|
202
459
|
const summary = this.getSession(sessionId);
|
|
@@ -212,22 +469,175 @@ export class SessionHub {
|
|
|
212
469
|
}
|
|
213
470
|
}
|
|
214
471
|
}
|
|
215
|
-
|
|
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;
|
|
216
486
|
for (;;) {
|
|
217
|
-
const batch = this.store.readEvents(sessionId, cursor);
|
|
487
|
+
const batch = this.store.readEvents(sessionId, cursor, 200);
|
|
218
488
|
for (const event of batch)
|
|
219
489
|
subscriber.event(event);
|
|
220
490
|
if (batch.length === 0)
|
|
221
491
|
break;
|
|
222
492
|
cursor = batch[batch.length - 1].seq;
|
|
223
493
|
}
|
|
224
|
-
|
|
225
|
-
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);
|
|
226
636
|
}
|
|
227
637
|
unsubscribe(sessionId, subscriber) {
|
|
228
638
|
this.live.get(sessionId)?.subscribers.delete(subscriber);
|
|
229
639
|
}
|
|
230
|
-
async prompt(sessionId, clientMessageId, content) {
|
|
640
|
+
async prompt(sessionId, clientMessageId, content, whenBusy) {
|
|
231
641
|
const summary = this.getSession(sessionId);
|
|
232
642
|
const driver = this.requireDriver(summary.agent);
|
|
233
643
|
let joinAfterSend = false;
|
|
@@ -242,9 +652,15 @@ export class SessionHub {
|
|
|
242
652
|
}
|
|
243
653
|
if (!this.store.claimClientMessage(sessionId, clientMessageId))
|
|
244
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
|
+
}
|
|
245
661
|
let delivery;
|
|
246
662
|
try {
|
|
247
|
-
delivery = await
|
|
663
|
+
delivery = await this.deliver(summary, driver, content, clientMessageId);
|
|
248
664
|
}
|
|
249
665
|
catch (error) {
|
|
250
666
|
this.store.releaseClientMessage(sessionId, clientMessageId);
|
|
@@ -270,8 +686,178 @@ export class SessionHub {
|
|
|
270
686
|
}
|
|
271
687
|
}
|
|
272
688
|
}
|
|
689
|
+
unqueue(sessionId, clientMessageId) {
|
|
690
|
+
const summary = this.getSession(sessionId);
|
|
691
|
+
const driver = this.requireDriver(summary.agent);
|
|
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);
|
|
699
|
+
// Sent again later, it should go through.
|
|
700
|
+
if (removed)
|
|
701
|
+
this.store.releaseClientMessage(sessionId, clientMessageId);
|
|
702
|
+
return removed;
|
|
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
|
+
}
|
|
773
|
+
onRemoved(listener) {
|
|
774
|
+
this.removedListeners.add(listener);
|
|
775
|
+
return () => this.removedListeners.delete(listener);
|
|
776
|
+
}
|
|
777
|
+
async archive(sessionId, archived) {
|
|
778
|
+
const summary = this.getSession(sessionId);
|
|
779
|
+
const driver = this.drivers.get(summary.agent);
|
|
780
|
+
if (driver?.archive)
|
|
781
|
+
await driver.archive(summary.nativeId, archived);
|
|
782
|
+
const updated = this.store.patchSession(sessionId, { archived });
|
|
783
|
+
this.emitSummary(updated);
|
|
784
|
+
return this.decorate(updated);
|
|
785
|
+
}
|
|
786
|
+
async rename(sessionId, title) {
|
|
787
|
+
const summary = this.getSession(sessionId);
|
|
788
|
+
const name = compact(title, TITLE_LENGTH);
|
|
789
|
+
const driver = this.drivers.get(summary.agent);
|
|
790
|
+
if (name && driver?.rename) {
|
|
791
|
+
try {
|
|
792
|
+
await driver.rename(summary.nativeId, name);
|
|
793
|
+
}
|
|
794
|
+
catch (error) {
|
|
795
|
+
// The name still holds in LinkShell.
|
|
796
|
+
this.log(`[hub] ${summary.agent} rename failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
797
|
+
}
|
|
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 }));
|
|
802
|
+
const updated = this.store.patchSession(sessionId, { customTitle: name || null });
|
|
803
|
+
this.emitSummary(updated);
|
|
804
|
+
return this.decorate(updated);
|
|
805
|
+
}
|
|
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) {
|
|
811
|
+
const summary = this.getSession(sessionId);
|
|
812
|
+
const live = this.live.get(sessionId);
|
|
813
|
+
if (live?.turnActive || summary.state === "running")
|
|
814
|
+
throw RpcError.app("busy", "这个会话正在运行:先停止,再删除");
|
|
815
|
+
if (this.desktops.has(sessionId))
|
|
816
|
+
throw RpcError.app("busy", "这个会话正在电脑终端里使用:先在电脑上退出,再删除");
|
|
817
|
+
const driver = this.drivers.get(summary.agent);
|
|
818
|
+
if (driver) {
|
|
819
|
+
await driver.detach(summary.nativeId).catch(() => { });
|
|
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
|
+
}
|
|
827
|
+
}
|
|
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);
|
|
835
|
+
}
|
|
836
|
+
/** Drops a session from LinkShell and tells every client. */
|
|
837
|
+
forget(sessionId) {
|
|
838
|
+
if (!this.store.getSession(sessionId))
|
|
839
|
+
return;
|
|
840
|
+
this.live.delete(sessionId);
|
|
841
|
+
this.store.removeSession(sessionId);
|
|
842
|
+
for (const listener of this.removedListeners) {
|
|
843
|
+
try {
|
|
844
|
+
listener(sessionId);
|
|
845
|
+
}
|
|
846
|
+
catch (error) {
|
|
847
|
+
this.log(`[hub] removal listener failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
273
851
|
async cancel(sessionId) {
|
|
274
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
|
+
}
|
|
275
861
|
await this.requireDriver(summary.agent).cancel(summary.nativeId);
|
|
276
862
|
}
|
|
277
863
|
async respondPermission(sessionId, requestId, optionId) {
|
|
@@ -362,6 +948,7 @@ export class SessionHub {
|
|
|
362
948
|
messageText: new Map(),
|
|
363
949
|
permissions: new Map(),
|
|
364
950
|
turnActive: false,
|
|
951
|
+
held: [],
|
|
365
952
|
};
|
|
366
953
|
this.live.set(sessionId, live);
|
|
367
954
|
}
|
|
@@ -392,8 +979,9 @@ export class SessionHub {
|
|
|
392
979
|
live.importing = true;
|
|
393
980
|
live.importChanged = false;
|
|
394
981
|
try {
|
|
395
|
-
// Items without a recorded time get the
|
|
396
|
-
|
|
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);
|
|
397
985
|
for (const item of history) {
|
|
398
986
|
ts = Math.min(Math.max(item.ts ?? ts, ts), summary.updatedAt);
|
|
399
987
|
if (this.store.isItemLogged(sessionId, item.itemId))
|
|
@@ -415,11 +1003,11 @@ export class SessionHub {
|
|
|
415
1003
|
finally {
|
|
416
1004
|
const buffered = live.buffer ?? [];
|
|
417
1005
|
live.buffer = undefined;
|
|
418
|
-
for (const { update, itemId } of buffered) {
|
|
1006
|
+
for (const { update, itemId, ts } of buffered) {
|
|
419
1007
|
// History already covered this item; skip its completion to avoid a duplicate.
|
|
420
1008
|
if (itemId && this.store.isItemLogged(sessionId, itemId))
|
|
421
1009
|
continue;
|
|
422
|
-
this.commit(sessionId, update, itemId);
|
|
1010
|
+
this.commit(sessionId, update, itemId, ts);
|
|
423
1011
|
}
|
|
424
1012
|
live.attaching = undefined;
|
|
425
1013
|
}
|
|
@@ -427,7 +1015,19 @@ export class SessionHub {
|
|
|
427
1015
|
return live.attaching;
|
|
428
1016
|
}
|
|
429
1017
|
recordDiscovered(agent, session) {
|
|
1018
|
+
// Deleted in LinkShell: an agent without native delete keeps listing it.
|
|
1019
|
+
if (this.store.isRemoved(sessionIdFor(agent, session.nativeId)))
|
|
1020
|
+
return;
|
|
430
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
|
+
}
|
|
431
1031
|
const { summary, created } = this.store.upsertSession({
|
|
432
1032
|
id: sessionIdFor(agent, session.nativeId),
|
|
433
1033
|
agent,
|
|
@@ -449,23 +1049,23 @@ export class SessionHub {
|
|
|
449
1049
|
if (created || changed)
|
|
450
1050
|
this.emitSummary(summary);
|
|
451
1051
|
}
|
|
452
|
-
ingest(sessionId, update, itemId) {
|
|
1052
|
+
ingest(sessionId, update, itemId, ts) {
|
|
453
1053
|
if (!this.store.getSession(sessionId)) {
|
|
454
1054
|
this.log(`[hub] dropping update for unknown session ${sessionId}`);
|
|
455
1055
|
return;
|
|
456
1056
|
}
|
|
457
1057
|
const live = this.liveFor(sessionId);
|
|
458
1058
|
if (live.buffer) {
|
|
459
|
-
live.buffer.push({ update, itemId });
|
|
1059
|
+
live.buffer.push({ update, itemId, ts });
|
|
460
1060
|
return;
|
|
461
1061
|
}
|
|
462
|
-
this.commit(sessionId, update, itemId);
|
|
1062
|
+
this.commit(sessionId, update, itemId, ts);
|
|
463
1063
|
}
|
|
464
1064
|
commit(sessionId, update, itemId, ts) {
|
|
465
1065
|
const live = this.liveFor(sessionId);
|
|
466
1066
|
if (update.sessionUpdate === "ls_permission_resolved" && !live.permissions.has(update.requestId))
|
|
467
1067
|
return;
|
|
468
|
-
const event = this.store.appendEvent(sessionId, update, ts);
|
|
1068
|
+
const event = this.store.appendEvent(sessionId, update, ts, isActivity(update));
|
|
469
1069
|
if (itemId)
|
|
470
1070
|
this.store.markItemLogged(sessionId, itemId);
|
|
471
1071
|
this.applyToSummary(sessionId, live, update);
|
|
@@ -478,10 +1078,12 @@ export class SessionHub {
|
|
|
478
1078
|
}
|
|
479
1079
|
}
|
|
480
1080
|
// A finished turn can't still be waiting on approvals; clear stale cards.
|
|
481
|
-
if (update.sessionUpdate === "ls_turn" && update.state === "ended") {
|
|
1081
|
+
if (update.sessionUpdate === "ls_turn" && update.state === "ended" && !update.parentToolCallId) {
|
|
482
1082
|
for (const requestId of [...live.permissions.keys()]) {
|
|
483
1083
|
this.commit(sessionId, { sessionUpdate: "ls_permission_resolved", requestId });
|
|
484
1084
|
}
|
|
1085
|
+
if (!live.importing)
|
|
1086
|
+
void this.sendHeld(sessionId);
|
|
485
1087
|
}
|
|
486
1088
|
}
|
|
487
1089
|
applyToSummary(sessionId, live, update) {
|
|
@@ -489,7 +1091,7 @@ export class SessionHub {
|
|
|
489
1091
|
if (!before)
|
|
490
1092
|
return;
|
|
491
1093
|
const patch = {};
|
|
492
|
-
let activityChanged =
|
|
1094
|
+
let activityChanged = this.trackSubagent(live, update) && !live.importing;
|
|
493
1095
|
const setActivity = (next) => {
|
|
494
1096
|
if (live.importing || sameActivity(live.activity, next))
|
|
495
1097
|
return;
|
|
@@ -528,6 +1130,9 @@ export class SessionHub {
|
|
|
528
1130
|
break;
|
|
529
1131
|
}
|
|
530
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;
|
|
531
1136
|
live.turnActive = update.state === "started";
|
|
532
1137
|
setActivity(live.turnActive ? { kind: "thinking" } : undefined);
|
|
533
1138
|
// Leftover permissions are cleared right after a turn ends (see commit).
|
|
@@ -574,9 +1179,16 @@ export class SessionHub {
|
|
|
574
1179
|
live.importChanged = true;
|
|
575
1180
|
return;
|
|
576
1181
|
}
|
|
577
|
-
|
|
1182
|
+
if (isActivity(update))
|
|
1183
|
+
patch.updatedAt = Date.now();
|
|
578
1184
|
this.emitSummary(this.store.patchSession(sessionId, patch));
|
|
579
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
|
+
}
|
|
580
1192
|
emitSummary(stored) {
|
|
581
1193
|
const summary = this.decorate(stored);
|
|
582
1194
|
for (const listener of this.summaryListeners) {
|