linkshell-cli 0.6.2 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +47 -132
  2. package/dist/cli/src/auth.js +1 -1
  3. package/dist/cli/src/auth.js.map +1 -1
  4. package/dist/cli/src/commands/doctor.d.ts +1 -1
  5. package/dist/cli/src/commands/doctor.js +76 -36
  6. package/dist/cli/src/commands/doctor.js.map +1 -1
  7. package/dist/cli/src/commands/host.d.ts +14 -0
  8. package/dist/cli/src/commands/host.js +94 -15
  9. package/dist/cli/src/commands/host.js.map +1 -1
  10. package/dist/cli/src/commands/logout.d.ts +1 -1
  11. package/dist/cli/src/commands/logout.js +10 -2
  12. package/dist/cli/src/commands/logout.js.map +1 -1
  13. package/dist/cli/src/commands/upgrade.js +11 -0
  14. package/dist/cli/src/commands/upgrade.js.map +1 -1
  15. package/dist/cli/src/config.js +1 -1
  16. package/dist/cli/src/config.js.map +1 -1
  17. package/dist/cli/src/index.js +94 -114
  18. package/dist/cli/src/index.js.map +1 -1
  19. package/dist/cli/tsconfig.tsbuildinfo +1 -1
  20. package/dist/host/src/carry.d.ts +15 -0
  21. package/dist/host/src/carry.js +83 -0
  22. package/dist/host/src/carry.js.map +1 -0
  23. package/dist/host/src/drivers/acp/driver.d.ts +13 -3
  24. package/dist/host/src/drivers/acp/driver.js +43 -8
  25. package/dist/host/src/drivers/acp/driver.js.map +1 -1
  26. package/dist/host/src/drivers/acp/mapper.d.ts +5 -1
  27. package/dist/host/src/drivers/acp/mapper.js +53 -5
  28. package/dist/host/src/drivers/acp/mapper.js.map +1 -1
  29. package/dist/host/src/drivers/claude/driver.d.ts +53 -8
  30. package/dist/host/src/drivers/claude/driver.js +357 -40
  31. package/dist/host/src/drivers/claude/driver.js.map +1 -1
  32. package/dist/host/src/drivers/claude/holders.d.ts +9 -0
  33. package/dist/host/src/drivers/claude/holders.js +61 -0
  34. package/dist/host/src/drivers/claude/holders.js.map +1 -0
  35. package/dist/host/src/drivers/claude/transcript.d.ts +100 -62
  36. package/dist/host/src/drivers/claude/transcript.js +246 -26
  37. package/dist/host/src/drivers/claude/transcript.js.map +1 -1
  38. package/dist/host/src/drivers/codex/commands.d.ts +27 -0
  39. package/dist/host/src/drivers/codex/commands.js +23 -0
  40. package/dist/host/src/drivers/codex/commands.js.map +1 -0
  41. package/dist/host/src/drivers/codex/driver.d.ts +15 -1
  42. package/dist/host/src/drivers/codex/driver.js +110 -1
  43. package/dist/host/src/drivers/codex/driver.js.map +1 -1
  44. package/dist/host/src/drivers/codex/mapper.d.ts +4 -0
  45. package/dist/host/src/drivers/codex/mapper.js +29 -5
  46. package/dist/host/src/drivers/codex/mapper.js.map +1 -1
  47. package/dist/host/src/drivers/types.d.ts +34 -3
  48. package/dist/host/src/fs.d.ts +26 -4
  49. package/dist/host/src/fs.js +71 -17
  50. package/dist/host/src/fs.js.map +1 -1
  51. package/dist/host/src/gateway.d.ts +3 -0
  52. package/dist/host/src/gateway.js +9 -0
  53. package/dist/host/src/gateway.js.map +1 -1
  54. package/dist/host/src/host.d.ts +7 -3
  55. package/dist/host/src/host.js +42 -18
  56. package/dist/host/src/host.js.map +1 -1
  57. package/dist/host/src/hub.d.ts +91 -15
  58. package/dist/host/src/hub.js +556 -33
  59. package/dist/host/src/hub.js.map +1 -1
  60. package/dist/host/src/rpc/server.d.ts +4 -2
  61. package/dist/host/src/rpc/server.js +51 -6
  62. package/dist/host/src/rpc/server.js.map +1 -1
  63. package/dist/host/src/screen.d.ts +11 -0
  64. package/dist/host/src/screen.js +57 -3
  65. package/dist/host/src/screen.js.map +1 -1
  66. package/dist/host/src/slim.d.ts +17 -0
  67. package/dist/host/src/slim.js +66 -0
  68. package/dist/host/src/slim.js.map +1 -0
  69. package/dist/host/src/store.d.ts +75 -3
  70. package/dist/host/src/store.js +216 -16
  71. package/dist/host/src/store.js.map +1 -1
  72. package/dist/host/src/terminals.d.ts +4 -4
  73. package/dist/host/src/worktrees.d.ts +36 -0
  74. package/dist/host/src/worktrees.js +96 -0
  75. package/dist/host/src/worktrees.js.map +1 -0
  76. package/dist/wire/src/model.d.ts +301 -153
  77. package/dist/wire/src/model.js +55 -0
  78. package/dist/wire/src/model.js.map +1 -1
  79. package/dist/wire/src/relay-client.d.ts +2 -0
  80. package/dist/wire/src/relay-client.js +12 -0
  81. package/dist/wire/src/relay-client.js.map +1 -1
  82. package/dist/wire/src/rpc.d.ts +6740 -1022
  83. package/dist/wire/src/rpc.js +130 -7
  84. package/dist/wire/src/rpc.js.map +1 -1
  85. package/dist/wire/src/updates.d.ts +498 -498
  86. package/package.json +5 -5
  87. package/src/auth.ts +1 -1
  88. package/src/commands/doctor.ts +76 -34
  89. package/src/commands/host.ts +98 -14
  90. package/src/commands/logout.ts +10 -2
  91. package/src/commands/upgrade.ts +11 -0
  92. package/src/config.ts +1 -1
  93. package/src/index.ts +93 -131
@@ -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
- listProjects(limit) {
164
- return this.store.listProjects(limit);
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
- if (!activity && !first && !live.queue)
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 (live.queue)
183
- decorated.queue = live.queue;
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 discovered = await driver.createSession({ cwd: input.cwd, model: input.model }).catch(async (error) => {
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
- * Sends everything after `fromSeq`, then streams live events. The backlog read
215
- * and the subscriber registration happen in one synchronous step, so no event
216
- * can fall between them.
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
- let cursor = fromSeq;
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
- this.liveFor(sessionId).subscribers.add(subscriber);
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 driver.prompt(summary.nativeId, content, clientMessageId);
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 removed = driver.unqueue?.(summary.nativeId, clientMessageId) ?? false;
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
- async delete(sessionId) {
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 session's last activity, never "now".
482
- let ts = summary.createdAt;
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 = false;
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
- patch.updatedAt = Date.now();
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) {