@linkshell/host 0.4.67 → 0.4.69

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 (43) hide show
  1. package/dist/host/src/drivers/acp/driver.d.ts +9 -1
  2. package/dist/host/src/drivers/acp/driver.js +44 -2
  3. package/dist/host/src/drivers/acp/driver.js.map +1 -1
  4. package/dist/host/src/drivers/claude/driver.d.ts +20 -1
  5. package/dist/host/src/drivers/claude/driver.js +144 -4
  6. package/dist/host/src/drivers/claude/driver.js.map +1 -1
  7. package/dist/host/src/drivers/claude/transcript.d.ts +15 -0
  8. package/dist/host/src/drivers/claude/transcript.js +86 -1
  9. package/dist/host/src/drivers/claude/transcript.js.map +1 -1
  10. package/dist/host/src/drivers/codex/driver.d.ts +30 -2
  11. package/dist/host/src/drivers/codex/driver.js +241 -9
  12. package/dist/host/src/drivers/codex/driver.js.map +1 -1
  13. package/dist/host/src/drivers/types.d.ts +34 -0
  14. package/dist/host/src/host.js +2 -1
  15. package/dist/host/src/host.js.map +1 -1
  16. package/dist/host/src/hub.d.ts +19 -1
  17. package/dist/host/src/hub.js +153 -11
  18. package/dist/host/src/hub.js.map +1 -1
  19. package/dist/host/src/live-activities.d.ts +2 -1
  20. package/dist/host/src/live-activities.js +25 -2
  21. package/dist/host/src/live-activities.js.map +1 -1
  22. package/dist/host/src/rpc/server.js +11 -1
  23. package/dist/host/src/rpc/server.js.map +1 -1
  24. package/dist/host/src/side.d.ts +28 -0
  25. package/dist/host/src/side.js +142 -0
  26. package/dist/host/src/side.js.map +1 -0
  27. package/dist/host/src/store.d.ts +10 -0
  28. package/dist/host/src/store.js +59 -2
  29. package/dist/host/src/store.js.map +1 -1
  30. package/dist/host/tsconfig.tsbuildinfo +1 -1
  31. package/dist/wire/src/index.d.ts +1 -0
  32. package/dist/wire/src/index.js +1 -0
  33. package/dist/wire/src/index.js.map +1 -1
  34. package/dist/wire/src/model.d.ts +22 -0
  35. package/dist/wire/src/model.js +4 -0
  36. package/dist/wire/src/model.js.map +1 -1
  37. package/dist/wire/src/rpc.d.ts +330 -3
  38. package/dist/wire/src/rpc.js +35 -0
  39. package/dist/wire/src/rpc.js.map +1 -1
  40. package/dist/wire/src/side.d.ts +83 -0
  41. package/dist/wire/src/side.js +26 -0
  42. package/dist/wire/src/side.js.map +1 -0
  43. package/package.json +3 -3
@@ -10,6 +10,7 @@ import { slimEvent } from "./slim.js";
10
10
  import { outputRange } from "./task-output.js";
11
11
  import { ComputerPreviews } from "./computer-preview.js";
12
12
  import { MacPreviewCapture } from "./computer-preview-capture.js";
13
+ import { SideChats } from "./side.js";
13
14
  /**
14
15
  * Whether an update is something happening in the session. Names, settings
15
16
  * and usage figures arrive when a session is merely opened or renamed, and
@@ -68,6 +69,8 @@ const STANDING = [
68
69
  "session_info_update",
69
70
  "ls_driver",
70
71
  ];
72
+ /** How long a session nobody has open stays attached to a driver that pays to follow it. */
73
+ const IDLE_DETACH_MS = 10 * 60_000;
71
74
  const PREVIEW_LENGTH = 160;
72
75
  const TITLE_LENGTH = 60;
73
76
  function compact(text, max) {
@@ -99,6 +102,9 @@ export class SessionHub {
99
102
  removedListeners = new Set();
100
103
  interactionListeners = new Set();
101
104
  agentListeners = new Set();
105
+ sideListeners = new Set();
106
+ sides;
107
+ idleTimers = new Map();
102
108
  auth = new Map();
103
109
  /** Terminals (`linkshell <agent>`) currently driving handoff sessions. */
104
110
  desktops = new Map();
@@ -195,8 +201,17 @@ export class SessionHub {
195
201
  });
196
202
  for (const driver of drivers)
197
203
  this.drivers.set(driver.id, driver);
204
+ this.sides = new SideChats((sessionId, side, closed) => {
205
+ for (const listener of this.sideListeners)
206
+ listener(sessionId, side, closed);
207
+ }, log);
198
208
  }
199
209
  async start(options = {}) {
210
+ for (const session of this.store.errorSessions()) {
211
+ const state = this.store.historyFailureRecoveryState(session.id);
212
+ if (state && state !== session.state)
213
+ this.commit(session.id, { sessionUpdate: "ls_status", state });
214
+ }
200
215
  for (const id of this.store.resetLiveSessions())
201
216
  this.pendingQuestions.delete(id);
202
217
  await Promise.all([...this.drivers.values()].map(async (driver) => {
@@ -249,7 +264,11 @@ export class SessionHub {
249
264
  archived: true, state: "idle", asyncQuestions: [],
250
265
  });
251
266
  }
252
- if (driver.sessionExists) {
267
+ if (driver.nativeSessionAvailability) {
268
+ for (const session of this.store.sessionsForAgent(driver.id))
269
+ this.reconcileNativeSession(driver, session);
270
+ }
271
+ else if (driver.sessionExists) {
253
272
  for (const session of this.store.sessionsForAgent(driver.id)) {
254
273
  const live = this.live.get(session.id);
255
274
  if (live?.turnActive || session.state === "running" || session.state === "waiting")
@@ -260,12 +279,69 @@ export class SessionHub {
260
279
  }
261
280
  }
262
281
  }
282
+ reconcileNativeSession(driver, session) {
283
+ if (!driver.nativeSessionAvailability)
284
+ return;
285
+ const live = this.live.get(session.id);
286
+ if (live?.turnActive || live?.held.length || this.desktops.has(session.id))
287
+ return;
288
+ const availability = driver.nativeSessionAvailability(session.nativeId, session.cwd);
289
+ if (availability === "missing")
290
+ this.hideMissingSession(session);
291
+ else if (availability === "available" && this.store.isNativeMissing(session.id)) {
292
+ this.store.setNativeMissing(session.id, false);
293
+ this.emitSummary(session);
294
+ }
295
+ }
296
+ hideMissingSession(session) {
297
+ if (this.store.isNativeMissing(session.id))
298
+ return;
299
+ this.store.setNativeMissing(session.id, true);
300
+ this.commit(session.id, { sessionUpdate: "ls_status", state: "offline", turnActive: false });
301
+ this.store.patchSession(session.id, { pendingPermissions: 0 });
302
+ this.store.setDriverState(session.id, "asyncQuestions", "[]");
303
+ this.pendingQuestions.delete(session.id);
304
+ const driver = this.drivers.get(session.agent);
305
+ driver?.watched?.(session.nativeId, false);
306
+ this.live.delete(session.id);
307
+ this.emitRemoved(session.id);
308
+ void driver?.detach(session.nativeId).catch((error) => this.log(`[hub] detach missing ${session.id}: ${String(error)}`));
309
+ }
263
310
  async stop() {
264
311
  if (this.discoveryTimer)
265
312
  clearInterval(this.discoveryTimer);
313
+ for (const timer of this.idleTimers.values())
314
+ clearTimeout(timer);
315
+ this.idleTimers.clear();
316
+ await this.sides.stop((sessionId) => this.sideTarget(sessionId));
266
317
  await Promise.allSettled([...this.drivers.values()].map((driver) => driver.stop()));
267
318
  await this.previews.stop();
268
319
  }
320
+ // ── side conversations ────────────────────────────────────────────
321
+ onSide(listener) {
322
+ this.sideListeners.add(listener);
323
+ return () => this.sideListeners.delete(listener);
324
+ }
325
+ side(sessionId, sideId, text) {
326
+ const summary = this.getSession(sessionId);
327
+ return this.sides.ask(sessionId, { driver: this.requireDriver(summary.agent), nativeId: summary.nativeId, cwd: summary.cwd }, sideId, text);
328
+ }
329
+ listSides(sessionId) {
330
+ this.getSession(sessionId);
331
+ return this.sides.list(sessionId);
332
+ }
333
+ cancelSide(sessionId, sideId) {
334
+ this.sides.cancel(sessionId, sideId);
335
+ }
336
+ async closeSide(sessionId, sideId) {
337
+ const target = this.sideTarget(sessionId);
338
+ await this.sides.close(sessionId, sideId, target?.driver, target?.nativeId ?? "");
339
+ }
340
+ sideTarget(sessionId) {
341
+ const summary = this.store.getSession(sessionId);
342
+ const driver = summary && this.drivers.get(summary.agent);
343
+ return summary && driver ? { driver, nativeId: summary.nativeId } : undefined;
344
+ }
269
345
  agents() {
270
346
  return [...this.drivers.values()].map((driver) => {
271
347
  const status = driver.status();
@@ -413,6 +489,8 @@ export class SessionHub {
413
489
  const summary = this.store.getSession(sessionId);
414
490
  if (!summary)
415
491
  throw RpcError.app("not_found", `session ${sessionId} not found`);
492
+ if (this.store.isNativeMissing(sessionId))
493
+ throw RpcError.app("not_found", "这个会话的原始记录已不存在,已从列表隐藏;本地缓存仍保留。");
416
494
  return this.decorate(summary);
417
495
  }
418
496
  /** Adds the live-only fields (current activity, first pending permission) to a stored summary. */
@@ -642,6 +720,8 @@ export class SessionHub {
642
720
  const summary = this.getSession(sessionId);
643
721
  const driver = this.drivers.get(summary.agent);
644
722
  if (driver && driver.status().installed) {
723
+ this.reconcileNativeSession(driver, summary);
724
+ this.getSession(sessionId);
645
725
  try {
646
726
  await this.ensureAttached(sessionId);
647
727
  }
@@ -649,19 +729,21 @@ export class SessionHub {
649
729
  // Serve the log we have; not_ready sessions attach once they get a turn.
650
730
  if (!(error instanceof RpcError && error.appCode === "not_ready")) {
651
731
  this.log(`[hub] attach ${sessionId} failed: ${error instanceof Error ? error.message : String(error)}`);
652
- this.commit(sessionId, { sessionUpdate: "ls_status", state: "error" });
653
- this.commit(sessionId, {
654
- sessionUpdate: "ls_error",
655
- code: error instanceof RpcError ? error.appCode ?? "history_unavailable" : "history_unavailable",
656
- message: `读取会话消息失败:${error instanceof Error ? error.message : String(error)}`,
657
- hint: "重新打开会话可重试",
658
- }, undefined, summary.updatedAt);
732
+ this.reconcileNativeSession(driver, summary);
733
+ this.getSession(sessionId);
734
+ subscriber.notice?.({ severity: "warning", title: "暂时无法读取会话历史",
735
+ description: `${error instanceof Error ? error.message : String(error)}\n已缓存的消息仍可查看,重新打开会话可重试。` });
659
736
  }
660
737
  }
661
738
  }
662
739
  const live = this.liveFor(sessionId);
663
740
  if (driver?.behavior.desktop === "handoff")
664
741
  this.lendCommands(this.getSession(sessionId));
742
+ // Old clients may still hold the synthetic error cards in memory; replay a clean window.
743
+ if (fromSeq > 0 && this.store.hasHistoryLoadErrors(sessionId)) {
744
+ fromSeq = 0;
745
+ subscriber.window?.(0);
746
+ }
665
747
  const lastSeq = this.store.getSession(sessionId)?.lastSeq ?? 0;
666
748
  let startSeq = fromSeq;
667
749
  const missed = fromSeq > 0 && fromSeq <= lastSeq ? this.store.sizeAfter(sessionId, fromSeq) : undefined;
@@ -681,12 +763,15 @@ export class SessionHub {
681
763
  for (;;) {
682
764
  const batch = this.store.readEvents(sessionId, cursor, 200);
683
765
  for (const event of batch)
684
- subscriber.event(event);
766
+ if (!this.store.isHistoryLoadEvent(event))
767
+ subscriber.event(event);
685
768
  if (batch.length === 0)
686
769
  break;
687
770
  cursor = batch[batch.length - 1].seq;
688
771
  }
689
772
  live.subscribers.add(subscriber);
773
+ clearTimeout(this.idleTimers.get(sessionId));
774
+ this.idleTimers.delete(sessionId);
690
775
  if (live.subscribers.size === 1)
691
776
  driver?.watched?.(summary.nativeId, true);
692
777
  return { session: this.getSession(sessionId), startSeq };
@@ -724,7 +809,7 @@ export class SessionHub {
724
809
  if (upTo < 1)
725
810
  return { events: [], startSeq: 0 };
726
811
  const startSeq = this.store.pageStart(sessionId, upTo);
727
- return { events: this.store.readEvents(sessionId, startSeq, PAGE.maxEvents + 1, upTo), startSeq };
812
+ return { events: this.store.readEvents(sessionId, startSeq, PAGE.maxEvents + 1, upTo).filter((event) => !this.store.isHistoryLoadEvent(event)), startSeq };
728
813
  }
729
814
  tasks(sessionId) {
730
815
  if (!this.store.getSession(sessionId))
@@ -918,6 +1003,38 @@ export class SessionHub {
918
1003
  const summary = this.store.getSession(sessionId);
919
1004
  if (summary)
920
1005
  this.drivers.get(summary.agent)?.watched?.(summary.nativeId, false);
1006
+ this.detachWhenIdle(sessionId);
1007
+ }
1008
+ /**
1009
+ * Detaches a session nobody has open, from a driver that pays to follow it, once it has been
1010
+ * left alone a while with nothing under way. Without this every session opened since the host
1011
+ * started stays followed for good.
1012
+ */
1013
+ detachWhenIdle(sessionId, delay = IDLE_DETACH_MS) {
1014
+ const summary = this.store.getSession(sessionId);
1015
+ const driver = summary && this.drivers.get(summary.agent);
1016
+ if (!summary || !driver?.detachWhenIdle)
1017
+ return;
1018
+ clearTimeout(this.idleTimers.get(sessionId));
1019
+ const timer = setTimeout(() => {
1020
+ this.idleTimers.delete(sessionId);
1021
+ const live = this.live.get(sessionId);
1022
+ if (!live || live.subscribers.size > 0 || !live.attached || live.attaching || live.detaching)
1023
+ return;
1024
+ const tasks = this.tasksOf(sessionId, live);
1025
+ const working = live.turnActive || live.permissions.size > 0 || !!live.queue?.length || live.held.length > 0
1026
+ || [...tasks.values()].some((task) => task.state === "running")
1027
+ || [...(live.subagents?.values() ?? [])].some((state) => subagentRunning(state));
1028
+ // Something is under way: looked at again later.
1029
+ if (working)
1030
+ return this.detachWhenIdle(sessionId, delay);
1031
+ live.attached = false;
1032
+ live.detaching = driver.detach(summary.nativeId)
1033
+ .catch((error) => this.log(`[hub] idle detach ${sessionId}: ${String(error)}`))
1034
+ .finally(() => { live.detaching = undefined; });
1035
+ }, delay);
1036
+ timer.unref?.();
1037
+ this.idleTimers.set(sessionId, timer);
921
1038
  }
922
1039
  async prompt(sessionId, clientMessageId, content, whenBusy) {
923
1040
  const summary = this.getSession(sessionId);
@@ -1058,6 +1175,9 @@ export class SessionHub {
1058
1175
  }
1059
1176
  onRemoved(listener) {
1060
1177
  this.removedListeners.add(listener);
1178
+ // A reconnecting client can retain sessions outside the first discovery page.
1179
+ for (const id of this.store.nativeMissingSessionIds())
1180
+ listener(id);
1061
1181
  return () => this.removedListeners.delete(listener);
1062
1182
  }
1063
1183
  async archive(sessionId, archived) {
@@ -1123,9 +1243,15 @@ export class SessionHub {
1123
1243
  forget(sessionId) {
1124
1244
  if (!this.store.getSession(sessionId))
1125
1245
  return;
1246
+ clearTimeout(this.idleTimers.get(sessionId));
1247
+ this.idleTimers.delete(sessionId);
1126
1248
  this.live.delete(sessionId);
1127
1249
  this.previews.forget(sessionId);
1250
+ this.sides.forget(sessionId);
1128
1251
  this.store.removeSession(sessionId);
1252
+ this.emitRemoved(sessionId);
1253
+ }
1254
+ emitRemoved(sessionId) {
1129
1255
  for (const listener of this.removedListeners) {
1130
1256
  try {
1131
1257
  listener(sessionId);
@@ -1278,6 +1404,8 @@ export class SessionHub {
1278
1404
  /** Never throws synchronously: failures always arrive as a rejected promise. */
1279
1405
  ensureAttached(sessionId) {
1280
1406
  const live = this.liveFor(sessionId);
1407
+ if (live.detaching)
1408
+ return live.detaching.then(() => this.ensureAttached(sessionId));
1281
1409
  if (live.attached)
1282
1410
  return Promise.resolve();
1283
1411
  if (live.attaching)
@@ -1340,6 +1468,16 @@ export class SessionHub {
1340
1468
  if (this.store.isRemoved(sessionIdFor(agent, session.nativeId)))
1341
1469
  return;
1342
1470
  const before = this.store.getSession(sessionIdFor(agent, session.nativeId));
1471
+ const driver = this.drivers.get(agent);
1472
+ const availability = driver?.nativeSessionAvailability?.(session.nativeId, session.cwd);
1473
+ if (availability === "missing") {
1474
+ if (before)
1475
+ this.reconcileNativeSession(driver, before);
1476
+ return;
1477
+ }
1478
+ const restored = availability === "available" && !!before && this.store.isNativeMissing(before.id);
1479
+ if (restored)
1480
+ this.store.setNativeMissing(before.id, false);
1343
1481
  if (before && session.updatedAt > before.updatedAt) {
1344
1482
  // Renamed just now: the session keeps its place in the list.
1345
1483
  const renamed = this.store.getDriverState(before.id, RENAMED);
@@ -1379,10 +1517,12 @@ export class SessionHub {
1379
1517
  if (questionsChanged)
1380
1518
  this.store.setDriverState(summary.id, "asyncQuestions", JSON.stringify(session.asyncQuestions));
1381
1519
  }
1382
- if (created || changed || questionsChanged)
1520
+ if (created || changed || questionsChanged || restored)
1383
1521
  this.emitSummary(summary);
1384
1522
  }
1385
1523
  ingest(sessionId, update, itemId, ts) {
1524
+ if (this.store.isNativeMissing(sessionId))
1525
+ return;
1386
1526
  if (!this.store.getSession(sessionId)) {
1387
1527
  this.log(`[hub] dropping update for unknown session ${sessionId}`);
1388
1528
  return;
@@ -1564,6 +1704,8 @@ export class SessionHub {
1564
1704
  this.emitSummary(stored);
1565
1705
  }
1566
1706
  emitSummary(stored) {
1707
+ if (this.store.isNativeMissing(stored.id))
1708
+ return;
1567
1709
  const summary = this.decorate(stored);
1568
1710
  for (const listener of this.summaryListeners) {
1569
1711
  try {