@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.
- package/dist/host/src/drivers/acp/driver.d.ts +9 -1
- package/dist/host/src/drivers/acp/driver.js +44 -2
- package/dist/host/src/drivers/acp/driver.js.map +1 -1
- package/dist/host/src/drivers/claude/driver.d.ts +20 -1
- package/dist/host/src/drivers/claude/driver.js +144 -4
- package/dist/host/src/drivers/claude/driver.js.map +1 -1
- package/dist/host/src/drivers/claude/transcript.d.ts +15 -0
- package/dist/host/src/drivers/claude/transcript.js +86 -1
- package/dist/host/src/drivers/claude/transcript.js.map +1 -1
- package/dist/host/src/drivers/codex/driver.d.ts +30 -2
- package/dist/host/src/drivers/codex/driver.js +241 -9
- package/dist/host/src/drivers/codex/driver.js.map +1 -1
- package/dist/host/src/drivers/types.d.ts +34 -0
- package/dist/host/src/host.js +2 -1
- package/dist/host/src/host.js.map +1 -1
- package/dist/host/src/hub.d.ts +19 -1
- package/dist/host/src/hub.js +153 -11
- package/dist/host/src/hub.js.map +1 -1
- package/dist/host/src/live-activities.d.ts +2 -1
- package/dist/host/src/live-activities.js +25 -2
- package/dist/host/src/live-activities.js.map +1 -1
- package/dist/host/src/rpc/server.js +11 -1
- package/dist/host/src/rpc/server.js.map +1 -1
- package/dist/host/src/side.d.ts +28 -0
- package/dist/host/src/side.js +142 -0
- package/dist/host/src/side.js.map +1 -0
- package/dist/host/src/store.d.ts +10 -0
- package/dist/host/src/store.js +59 -2
- package/dist/host/src/store.js.map +1 -1
- package/dist/host/tsconfig.tsbuildinfo +1 -1
- package/dist/wire/src/index.d.ts +1 -0
- package/dist/wire/src/index.js +1 -0
- package/dist/wire/src/index.js.map +1 -1
- package/dist/wire/src/model.d.ts +22 -0
- package/dist/wire/src/model.js +4 -0
- package/dist/wire/src/model.js.map +1 -1
- package/dist/wire/src/rpc.d.ts +330 -3
- package/dist/wire/src/rpc.js +35 -0
- package/dist/wire/src/rpc.js.map +1 -1
- package/dist/wire/src/side.d.ts +83 -0
- package/dist/wire/src/side.js +26 -0
- package/dist/wire/src/side.js.map +1 -0
- package/package.json +3 -3
package/dist/host/src/hub.js
CHANGED
|
@@ -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.
|
|
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.
|
|
653
|
-
this.
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
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 {
|