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.
Files changed (108) 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 +15 -0
  8. package/dist/cli/src/commands/host.js +101 -20
  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 +105 -120
  18. package/dist/cli/src/index.js.map +1 -1
  19. package/dist/cli/src/utils/daemon.d.ts +2 -0
  20. package/dist/cli/src/utils/daemon.js +4 -0
  21. package/dist/cli/src/utils/daemon.js.map +1 -1
  22. package/dist/cli/tsconfig.tsbuildinfo +1 -1
  23. package/dist/host/src/carry.d.ts +15 -0
  24. package/dist/host/src/carry.js +83 -0
  25. package/dist/host/src/carry.js.map +1 -0
  26. package/dist/host/src/drivers/acp/driver.d.ts +21 -3
  27. package/dist/host/src/drivers/acp/driver.js +99 -13
  28. package/dist/host/src/drivers/acp/driver.js.map +1 -1
  29. package/dist/host/src/drivers/acp/mapper.d.ts +5 -1
  30. package/dist/host/src/drivers/acp/mapper.js +58 -6
  31. package/dist/host/src/drivers/acp/mapper.js.map +1 -1
  32. package/dist/host/src/drivers/claude/driver.d.ts +76 -8
  33. package/dist/host/src/drivers/claude/driver.js +510 -29
  34. package/dist/host/src/drivers/claude/driver.js.map +1 -1
  35. package/dist/host/src/drivers/claude/holders.d.ts +9 -0
  36. package/dist/host/src/drivers/claude/holders.js +61 -0
  37. package/dist/host/src/drivers/claude/holders.js.map +1 -0
  38. package/dist/host/src/drivers/claude/transcript.d.ts +105 -61
  39. package/dist/host/src/drivers/claude/transcript.js +260 -22
  40. package/dist/host/src/drivers/claude/transcript.js.map +1 -1
  41. package/dist/host/src/drivers/codex/commands.d.ts +27 -0
  42. package/dist/host/src/drivers/codex/commands.js +23 -0
  43. package/dist/host/src/drivers/codex/commands.js.map +1 -0
  44. package/dist/host/src/drivers/codex/driver.d.ts +21 -1
  45. package/dist/host/src/drivers/codex/driver.js +140 -1
  46. package/dist/host/src/drivers/codex/driver.js.map +1 -1
  47. package/dist/host/src/drivers/codex/mapper.d.ts +4 -0
  48. package/dist/host/src/drivers/codex/mapper.js +29 -5
  49. package/dist/host/src/drivers/codex/mapper.js.map +1 -1
  50. package/dist/host/src/drivers/types.d.ts +45 -4
  51. package/dist/host/src/fs.d.ts +26 -4
  52. package/dist/host/src/fs.js +71 -17
  53. package/dist/host/src/fs.js.map +1 -1
  54. package/dist/host/src/gateway.d.ts +3 -0
  55. package/dist/host/src/gateway.js +10 -1
  56. package/dist/host/src/gateway.js.map +1 -1
  57. package/dist/host/src/host.d.ts +7 -3
  58. package/dist/host/src/host.js +53 -21
  59. package/dist/host/src/host.js.map +1 -1
  60. package/dist/host/src/hub.d.ts +98 -14
  61. package/dist/host/src/hub.js +639 -27
  62. package/dist/host/src/hub.js.map +1 -1
  63. package/dist/host/src/index.d.ts +1 -1
  64. package/dist/host/src/index.js +1 -1
  65. package/dist/host/src/index.js.map +1 -1
  66. package/dist/host/src/legacy.d.ts +7 -0
  67. package/dist/host/src/legacy.js +36 -0
  68. package/dist/host/src/legacy.js.map +1 -0
  69. package/dist/host/src/rpc/server.d.ts +4 -2
  70. package/dist/host/src/rpc/server.js +62 -5
  71. package/dist/host/src/rpc/server.js.map +1 -1
  72. package/dist/host/src/screen.d.ts +11 -0
  73. package/dist/host/src/screen.js +54 -2
  74. package/dist/host/src/screen.js.map +1 -1
  75. package/dist/host/src/shell-env.d.ts +6 -0
  76. package/dist/host/src/shell-env.js +48 -0
  77. package/dist/host/src/shell-env.js.map +1 -1
  78. package/dist/host/src/slim.d.ts +17 -0
  79. package/dist/host/src/slim.js +66 -0
  80. package/dist/host/src/slim.js.map +1 -0
  81. package/dist/host/src/store.d.ts +81 -3
  82. package/dist/host/src/store.js +249 -16
  83. package/dist/host/src/store.js.map +1 -1
  84. package/dist/host/src/terminals.d.ts +4 -4
  85. package/dist/host/src/terminals.js +19 -4
  86. package/dist/host/src/terminals.js.map +1 -1
  87. package/dist/host/src/worktrees.d.ts +36 -0
  88. package/dist/host/src/worktrees.js +96 -0
  89. package/dist/host/src/worktrees.js.map +1 -0
  90. package/dist/wire/src/model.d.ts +334 -139
  91. package/dist/wire/src/model.js +68 -0
  92. package/dist/wire/src/model.js.map +1 -1
  93. package/dist/wire/src/relay-client.d.ts +2 -0
  94. package/dist/wire/src/relay-client.js +12 -0
  95. package/dist/wire/src/relay-client.js.map +1 -1
  96. package/dist/wire/src/rpc.d.ts +7273 -890
  97. package/dist/wire/src/rpc.js +154 -6
  98. package/dist/wire/src/rpc.js.map +1 -1
  99. package/dist/wire/src/updates.d.ts +498 -498
  100. package/package.json +5 -5
  101. package/src/auth.ts +1 -1
  102. package/src/commands/doctor.ts +76 -34
  103. package/src/commands/host.ts +109 -19
  104. package/src/commands/logout.ts +10 -2
  105. package/src/commands/upgrade.ts +11 -0
  106. package/src/config.ts +1 -1
  107. package/src/index.ts +104 -137
  108. package/src/utils/daemon.ts +5 -0
@@ -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
- listProjects(limit) {
154
- 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;
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
- if (!activity && !first)
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 discovered = await driver.createSession({ cwd: input.cwd, model: input.model });
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
- * Sends everything after `fromSeq`, then streams live events. The backlog read
198
- * and the subscriber registration happen in one synchronous step, so no event
199
- * 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.
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
- 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;
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
- this.liveFor(sessionId).subscribers.add(subscriber);
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 driver.prompt(summary.nativeId, content, clientMessageId);
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 session's last activity, never "now".
396
- 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);
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 = false;
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
- patch.updatedAt = Date.now();
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) {