@linkshell/gateway 0.5.2 → 0.6.0

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 (109) hide show
  1. package/Dockerfile +8 -22
  2. package/README.md +99 -44
  3. package/dist/gateway/src/accounts.js.map +1 -0
  4. package/dist/gateway/src/index.d.ts +4 -1
  5. package/dist/gateway/src/index.js +4 -863
  6. package/dist/gateway/src/index.js.map +1 -1
  7. package/dist/gateway/src/main.js +57 -0
  8. package/dist/gateway/src/main.js.map +1 -0
  9. package/dist/gateway/src/rate-limit.d.ts +22 -0
  10. package/dist/gateway/src/rate-limit.js +66 -0
  11. package/dist/gateway/src/rate-limit.js.map +1 -0
  12. package/dist/gateway/src/relay.d.ts +84 -5
  13. package/dist/gateway/src/relay.js +402 -529
  14. package/dist/gateway/src/relay.js.map +1 -1
  15. package/dist/gateway/src/serve.d.ts +34 -0
  16. package/dist/gateway/src/serve.js +102 -0
  17. package/dist/gateway/src/serve.js.map +1 -0
  18. package/dist/{gateway-v2 → gateway}/src/store.d.ts +5 -0
  19. package/dist/{gateway-v2 → gateway}/src/store.js +7 -0
  20. package/dist/gateway/src/store.js.map +1 -0
  21. package/dist/gateway/src/subscription.d.ts +18 -0
  22. package/dist/gateway/src/subscription.js +46 -0
  23. package/dist/gateway/src/subscription.js.map +1 -0
  24. package/dist/gateway/tsconfig.tsbuildinfo +1 -1
  25. package/dist/wire/src/direct.d.ts +22 -0
  26. package/dist/wire/src/direct.js +37 -0
  27. package/dist/wire/src/direct.js.map +1 -0
  28. package/dist/wire/src/index.d.ts +1 -0
  29. package/dist/wire/src/index.js +1 -0
  30. package/dist/wire/src/index.js.map +1 -1
  31. package/dist/wire/src/model.d.ts +387 -6
  32. package/dist/wire/src/model.js +74 -0
  33. package/dist/wire/src/model.js.map +1 -1
  34. package/dist/wire/src/relay.d.ts +8 -8
  35. package/dist/wire/src/rpc.d.ts +2524 -200
  36. package/dist/wire/src/rpc.js +77 -4
  37. package/dist/wire/src/rpc.js.map +1 -1
  38. package/dist/wire/src/updates.d.ts +390 -34
  39. package/dist/wire/src/updates.js +11 -1
  40. package/dist/wire/src/updates.js.map +1 -1
  41. package/package.json +12 -12
  42. package/src/accounts.ts +32 -0
  43. package/src/index.ts +4 -997
  44. package/src/main.ts +65 -0
  45. package/src/rate-limit.ts +72 -0
  46. package/src/relay.ts +435 -620
  47. package/src/serve.ts +130 -0
  48. package/src/store.ts +135 -0
  49. package/src/subscription.ts +51 -0
  50. package/dist/gateway/src/auth-middleware.d.ts +0 -44
  51. package/dist/gateway/src/auth-middleware.js +0 -204
  52. package/dist/gateway/src/auth-middleware.js.map +0 -1
  53. package/dist/gateway/src/cors.d.ts +0 -2
  54. package/dist/gateway/src/cors.js +0 -7
  55. package/dist/gateway/src/cors.js.map +0 -1
  56. package/dist/gateway/src/embedded.d.ts +0 -21
  57. package/dist/gateway/src/embedded.js +0 -438
  58. package/dist/gateway/src/embedded.js.map +0 -1
  59. package/dist/gateway/src/host-auth.d.ts +0 -32
  60. package/dist/gateway/src/host-auth.js +0 -78
  61. package/dist/gateway/src/host-auth.js.map +0 -1
  62. package/dist/gateway/src/pairings.d.ts +0 -38
  63. package/dist/gateway/src/pairings.js +0 -119
  64. package/dist/gateway/src/pairings.js.map +0 -1
  65. package/dist/gateway/src/presence.d.ts +0 -28
  66. package/dist/gateway/src/presence.js +0 -92
  67. package/dist/gateway/src/presence.js.map +0 -1
  68. package/dist/gateway/src/sessions.d.ts +0 -113
  69. package/dist/gateway/src/sessions.js +0 -352
  70. package/dist/gateway/src/sessions.js.map +0 -1
  71. package/dist/gateway/src/state-store.d.ts +0 -21
  72. package/dist/gateway/src/state-store.js +0 -103
  73. package/dist/gateway/src/state-store.js.map +0 -1
  74. package/dist/gateway/src/static-web.d.ts +0 -15
  75. package/dist/gateway/src/static-web.js +0 -129
  76. package/dist/gateway/src/static-web.js.map +0 -1
  77. package/dist/gateway/src/tokens.d.ts +0 -19
  78. package/dist/gateway/src/tokens.js +0 -151
  79. package/dist/gateway/src/tokens.js.map +0 -1
  80. package/dist/gateway/src/tunnel.d.ts +0 -61
  81. package/dist/gateway/src/tunnel.js +0 -576
  82. package/dist/gateway/src/tunnel.js.map +0 -1
  83. package/dist/gateway-v2/src/accounts.js.map +0 -1
  84. package/dist/gateway-v2/src/index.d.ts +0 -3
  85. package/dist/gateway-v2/src/index.js +0 -4
  86. package/dist/gateway-v2/src/index.js.map +0 -1
  87. package/dist/gateway-v2/src/main.js +0 -17
  88. package/dist/gateway-v2/src/main.js.map +0 -1
  89. package/dist/gateway-v2/src/server.d.ts +0 -84
  90. package/dist/gateway-v2/src/server.js +0 -426
  91. package/dist/gateway-v2/src/server.js.map +0 -1
  92. package/dist/gateway-v2/src/store.js.map +0 -1
  93. package/dist/shared-protocol/src/index.d.ts +0 -17029
  94. package/dist/shared-protocol/src/index.js +0 -1064
  95. package/dist/shared-protocol/src/index.js.map +0 -1
  96. package/src/auth-middleware.ts +0 -266
  97. package/src/cors.ts +0 -8
  98. package/src/embedded.ts +0 -526
  99. package/src/host-auth.ts +0 -88
  100. package/src/pairings.ts +0 -133
  101. package/src/presence.ts +0 -118
  102. package/src/sessions.ts +0 -432
  103. package/src/state-store.ts +0 -140
  104. package/src/static-web.ts +0 -133
  105. package/src/tokens.ts +0 -159
  106. package/src/tunnel.ts +0 -644
  107. /package/dist/{gateway-v2 → gateway}/src/accounts.d.ts +0 -0
  108. /package/dist/{gateway-v2 → gateway}/src/accounts.js +0 -0
  109. /package/dist/{gateway-v2 → gateway}/src/main.d.ts +0 -0
package/src/pairings.ts DELETED
@@ -1,133 +0,0 @@
1
- import { randomInt, randomUUID } from "node:crypto";
2
- import type { GatewayStateStore } from "./state-store.js";
3
-
4
- export interface PairingRecord {
5
- sessionId: string;
6
- pairingCode: string;
7
- expiresAt: number; // unix ms
8
- claimed: boolean;
9
- failedAttempts: number; // failed claim attempts against this code
10
- }
11
-
12
- const PAIRING_TTL = Number(process.env.PAIRING_TTL_MS ?? 10 * 60_000); // 10 minutes
13
- const CLEANUP_INTERVAL = 60_000;
14
- // Invalidate a code after this many failed claim attempts to cap brute-forcing.
15
- const MAX_FAILED_CLAIM_ATTEMPTS = Number(
16
- process.env.PAIRING_MAX_FAILED_ATTEMPTS ?? 5,
17
- );
18
-
19
- export class PairingManager {
20
- private pairings = new Map<string, PairingRecord>();
21
- private cleanupTimer: ReturnType<typeof setInterval>;
22
-
23
- constructor(private readonly store?: GatewayStateStore) {
24
- this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL);
25
- }
26
-
27
- async hydrate(): Promise<void> {
28
- if (!this.store) return;
29
- try {
30
- const records = await this.store.loadPairings();
31
- const now = Date.now();
32
- for (const record of records) {
33
- if (record.expiresAt < now) {
34
- void this.store.deletePairing(record.pairingCode).catch(() => {});
35
- continue;
36
- }
37
- // Stored records predate the failedAttempts field; default it.
38
- this.pairings.set(record.pairingCode, { ...record, failedAttempts: 0 });
39
- }
40
- } catch (err) {
41
- process.stderr.write(`[gateway] pairing store hydrate failed, using memory only: ${err}\n`);
42
- }
43
- }
44
-
45
- create(sessionId?: string): PairingRecord {
46
- const id = sessionId ?? randomUUID();
47
- const code = String(randomInt(100000, 999999));
48
- const record: PairingRecord = {
49
- sessionId: id,
50
- pairingCode: code,
51
- expiresAt: Date.now() + PAIRING_TTL,
52
- claimed: false,
53
- failedAttempts: 0,
54
- };
55
- this.pairings.set(code, record);
56
- this.persist(record);
57
- return record;
58
- }
59
-
60
- claim(pairingCode: string): PairingRecord | { error: string; status: number } {
61
- const record = this.pairings.get(pairingCode);
62
- if (!record) {
63
- return { error: "pairing_not_found", status: 404 };
64
- }
65
- if (record.expiresAt < Date.now()) {
66
- this.pairings.delete(pairingCode);
67
- void this.store?.deletePairing(pairingCode).catch(() => {});
68
- return { error: "pairing_expired", status: 410 };
69
- }
70
- if (record.claimed) {
71
- // Count repeated claim attempts on an existing code; once the cap is
72
- // exceeded, invalidate it so it can no longer be targeted.
73
- record.failedAttempts += 1;
74
- if (record.failedAttempts >= MAX_FAILED_CLAIM_ATTEMPTS) {
75
- this.pairings.delete(pairingCode);
76
- void this.store?.deletePairing(pairingCode).catch(() => {});
77
- } else {
78
- this.persist(record);
79
- }
80
- return { error: "pairing_already_claimed", status: 409 };
81
- }
82
- record.claimed = true;
83
- this.persist(record);
84
- return record;
85
- }
86
-
87
- /** Look up a code's session without consuming an attempt or mutating state.
88
- * Lets the claim endpoint stay idempotent for a device that already owns the
89
- * mapped session (it re-issues instead of erroring with already_claimed). */
90
- peek(pairingCode: string): { sessionId: string; claimed: boolean } | null {
91
- const record = this.pairings.get(pairingCode);
92
- if (!record) return null;
93
- if (record.expiresAt < Date.now()) return null;
94
- return { sessionId: record.sessionId, claimed: record.claimed };
95
- }
96
-
97
- getStatus(pairingCode: string): { status: string; expiresAt: number; sessionId: string } | { error: string; httpStatus: number } {
98
- const record = this.pairings.get(pairingCode);
99
- if (!record) {
100
- return { error: "pairing_not_found", httpStatus: 404 };
101
- }
102
- if (record.expiresAt < Date.now()) {
103
- this.pairings.delete(pairingCode);
104
- void this.store?.deletePairing(pairingCode).catch(() => {});
105
- return { error: "pairing_expired", httpStatus: 410 };
106
- }
107
- return {
108
- status: record.claimed ? "claimed" : "waiting",
109
- expiresAt: record.expiresAt,
110
- sessionId: record.sessionId,
111
- };
112
- }
113
-
114
- private cleanup(): void {
115
- const now = Date.now();
116
- for (const [code, record] of this.pairings) {
117
- if (record.expiresAt < now) {
118
- this.pairings.delete(code);
119
- void this.store?.deletePairing(code).catch(() => {});
120
- }
121
- }
122
- }
123
-
124
- private persist(record: PairingRecord): void {
125
- void this.store?.savePairing(record).catch((err) => {
126
- process.stderr.write(`[gateway] pairing store save failed: ${err}\n`);
127
- });
128
- }
129
-
130
- destroy(): void {
131
- clearInterval(this.cleanupTimer);
132
- }
133
- }
package/src/presence.ts DELETED
@@ -1,118 +0,0 @@
1
- import type WebSocket from "ws";
2
- import { createEnvelope, serializeEnvelope, PROTOCOL_VERSION } from "@linkshell/protocol";
3
- import type { Session, SessionManager } from "./sessions.js";
4
-
5
- interface Watcher {
6
- socket: WebSocket;
7
- /** Session ids this watcher is allowed to see. Re-evaluated per notify
8
- * so newly-bound tokens pick up sessions without reconnecting. */
9
- sessionIds: () => Set<string>;
10
- userId?: string;
11
- }
12
-
13
- /** Cross-session presence fan-out for the web session list. */
14
- export class PresenceHub {
15
- private watchers = new Set<Watcher>();
16
-
17
- add(watcher: Watcher): void {
18
- this.watchers.add(watcher);
19
- watcher.socket.on("close", () => this.watchers.delete(watcher));
20
- watcher.socket.on("error", () => this.watchers.delete(watcher));
21
- }
22
-
23
- /** Send the current summary for every visible session (connect snapshot). */
24
- snapshot(watcher: Watcher, sessions: SessionManager): void {
25
- const allowed = watcher.sessionIds();
26
- for (const session of sessions.listActive()) {
27
- if (!this.visible(watcher, session, allowed)) continue;
28
- this.send(watcher.socket, session);
29
- }
30
- }
31
-
32
- notify(session: Session): void {
33
- if (this.watchers.size === 0) return;
34
- for (const watcher of this.watchers) {
35
- if (watcher.socket.readyState !== watcher.socket.OPEN) continue;
36
- if (!this.visible(watcher, session, watcher.sessionIds())) continue;
37
- this.send(watcher.socket, session);
38
- }
39
- }
40
-
41
- private visible(watcher: Watcher, session: Session, allowed: Set<string>): boolean {
42
- if (allowed.has(session.id)) return true;
43
- if (watcher.userId && session.userId && watcher.userId === session.userId) return true;
44
- return false;
45
- }
46
-
47
- private send(socket: WebSocket, session: Session): void {
48
- if (socket.readyState !== socket.OPEN) return;
49
- socket.send(
50
- serializeEnvelope(
51
- createEnvelope({
52
- type: "session.presence",
53
- sessionId: session.id,
54
- payload: {
55
- hasHost:
56
- !!session.host &&
57
- session.host.socket.readyState === session.host.socket.OPEN,
58
- agentStatus: session.agentStatus ?? null,
59
- agentProvider: session.agentProvider ?? null,
60
- agentConversationId: session.agentConversationId ?? null,
61
- agentTitle: session.agentTitle ?? null,
62
- agentDetail: session.agentDetail ?? null,
63
- agentLastActivity: session.agentLastActivity ?? null,
64
- lastActivity: session.lastActivity,
65
- agentUsage: session.agentUsage ?? null,
66
- },
67
- }),
68
- ),
69
- );
70
- }
71
- }
72
-
73
- export function startPresenceWatcher(input: {
74
- socket: WebSocket;
75
- sessions: SessionManager;
76
- hub: PresenceHub;
77
- sessionIds: () => Set<string>;
78
- userId?: string;
79
- pingIntervalMs?: number;
80
- }): void {
81
- const { socket, sessions, hub, sessionIds, userId, pingIntervalMs = 20_000 } = input;
82
- const watcher = { socket, sessionIds, userId };
83
- hub.add(watcher);
84
-
85
- socket.send(
86
- serializeEnvelope(
87
- createEnvelope({
88
- type: "session.connect",
89
- sessionId: "_presence",
90
- payload: {
91
- role: "client",
92
- clientName: "watcher",
93
- protocolVersion: PROTOCOL_VERSION,
94
- },
95
- }),
96
- ),
97
- );
98
- hub.snapshot(watcher, sessions);
99
-
100
- const liveSocket = socket as WebSocket & { isAlive?: boolean };
101
- liveSocket.isAlive = true;
102
- socket.on("pong", () => {
103
- liveSocket.isAlive = true;
104
- });
105
- socket.on("message", () => {
106
- liveSocket.isAlive = true;
107
- });
108
- const pingTimer = setInterval(() => {
109
- if (socket.readyState !== socket.OPEN) return;
110
- if (liveSocket.isAlive === false) {
111
- socket.terminate();
112
- return;
113
- }
114
- liveSocket.isAlive = false;
115
- socket.ping();
116
- }, pingIntervalMs);
117
- socket.on("close", () => clearInterval(pingTimer));
118
- }
package/src/sessions.ts DELETED
@@ -1,432 +0,0 @@
1
- import type WebSocket from "ws";
2
- import type { Envelope } from "@linkshell/protocol";
3
-
4
- export type SessionState = "active" | "host_disconnected" | "terminated";
5
-
6
- export interface ConnectedDevice {
7
- socket: WebSocket;
8
- role: "host" | "client";
9
- deviceId: string;
10
- connectedAt: number;
11
- }
12
-
13
- export interface Session {
14
- id: string;
15
- state: SessionState;
16
- host: ConnectedDevice | undefined;
17
- clients: Map<string, ConnectedDevice>;
18
- controllerId: string | undefined;
19
- lastActivity: number;
20
- createdAt: number;
21
- outputBuffers: Map<string, Envelope[]>; // keyed by terminalId
22
- outputBufferBytes: Map<string, number>; // approx byte size per terminalId buffer
23
- lastStatusByTerminal: Map<string, Envelope>; // last terminal.status per terminal
24
- hostDisconnectedAt: number | undefined;
25
- // Metadata from host's session.connect
26
- provider: string | undefined;
27
- machineId: string | undefined;
28
- hostname: string | undefined;
29
- platform: string | undefined;
30
- cwd: string | undefined;
31
- projectName: string | undefined;
32
- // Auth: user who owns this session (set on AUTH_REQUIRED gateways)
33
- userId: string | undefined;
34
- // Latest agent/conversation state reported by the host.
35
- agentStatus: string | undefined;
36
- agentProvider: string | undefined;
37
- agentConversationId: string | undefined;
38
- agentTitle: string | undefined;
39
- // Short "what it's doing / asking" line for the session list.
40
- agentDetail: string | undefined;
41
- agentLastActivity: number | undefined;
42
- // Latest token/context usage for the picked conversation (for at-a-glance
43
- // display on the session list, without opening the console).
44
- agentUsage: AgentUsageSummary | undefined;
45
- // Latest full usage report from the host (cross-provider aggregate across ALL
46
- // on-disk transcripts). Sent automatically on connect; shown as summary cards
47
- // on the session list page without entering the console.
48
- agentUsageReport: unknown | undefined;
49
- // Last agent.v2.snapshot + recent host→client agent envelopes, so a
50
- // reconnecting console can catch up without waiting on the host.
51
- lastAgentSnapshot: Envelope | undefined;
52
- agentReplay: Envelope[];
53
- }
54
-
55
- export interface AgentUsageSummary {
56
- inputTokens?: number;
57
- outputTokens?: number;
58
- cacheReadTokens?: number;
59
- totalTokens?: number;
60
- contextWindow?: number;
61
- totalCostUsd?: number;
62
- }
63
-
64
- const OUTPUT_BUFFER_CAPACITY = 200;
65
- const OUTPUT_BUFFER_MAX_BYTES = 8 * 1024 * 1024; // 8MB per terminal buffer
66
- const AGENT_REPLAY_CAPACITY = 80;
67
- const HOST_RECONNECT_WINDOW = 60_000; // 60s
68
- const CLEANUP_INTERVAL = 30_000;
69
-
70
- /** Approximate the wire byte size of an envelope for buffer accounting. */
71
- function approxEnvelopeBytes(envelope: Envelope): number {
72
- const data = (envelope.payload as { data?: unknown } | undefined)?.data;
73
- if (typeof data === "string") return data.length;
74
- try {
75
- return JSON.stringify(envelope).length;
76
- } catch {
77
- return 0;
78
- }
79
- }
80
-
81
- export class SessionManager {
82
- private sessions = new Map<string, Session>();
83
- private cleanupTimer: ReturnType<typeof setInterval>;
84
- /** Fired when host liveness or visible agent summary fields change. */
85
- onPresenceChange?: (session: Session) => void;
86
-
87
- constructor() {
88
- this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL);
89
- }
90
-
91
- private emitPresence(session: Session): void {
92
- this.onPresenceChange?.(session);
93
- }
94
-
95
- getOrCreate(sessionId: string): Session {
96
- let session = this.sessions.get(sessionId);
97
- if (!session) {
98
- session = {
99
- id: sessionId,
100
- state: "active",
101
- host: undefined,
102
- clients: new Map(),
103
- controllerId: undefined,
104
- lastActivity: Date.now(),
105
- createdAt: Date.now(),
106
- outputBuffers: new Map(),
107
- outputBufferBytes: new Map(),
108
- lastStatusByTerminal: new Map(),
109
- hostDisconnectedAt: undefined,
110
- provider: undefined,
111
- machineId: undefined,
112
- hostname: undefined,
113
- platform: undefined,
114
- cwd: undefined,
115
- projectName: undefined,
116
- userId: undefined,
117
- agentStatus: undefined,
118
- agentProvider: undefined,
119
- agentConversationId: undefined,
120
- agentTitle: undefined,
121
- agentDetail: undefined,
122
- agentLastActivity: undefined,
123
- agentUsage: undefined,
124
- agentUsageReport: undefined,
125
- lastAgentSnapshot: undefined,
126
- agentReplay: [],
127
- };
128
- this.sessions.set(sessionId, session);
129
- }
130
- return session;
131
- }
132
-
133
- get(sessionId: string): Session | undefined {
134
- return this.sessions.get(sessionId);
135
- }
136
-
137
- setHost(sessionId: string, device: ConnectedDevice): void {
138
- const session = this.getOrCreate(sessionId);
139
- session.host = device;
140
- session.state = "active";
141
- session.hostDisconnectedAt = undefined;
142
- session.lastActivity = Date.now();
143
- this.emitPresence(session);
144
- }
145
-
146
- addClient(sessionId: string, device: ConnectedDevice): void {
147
- const session = this.getOrCreate(sessionId);
148
- session.clients.set(device.deviceId, device);
149
- if (!session.controllerId) {
150
- session.controllerId = device.deviceId;
151
- }
152
- session.lastActivity = Date.now();
153
- }
154
-
155
- removeHost(
156
- sessionId: string,
157
- ): { clients: Map<string, ConnectedDevice> } | undefined {
158
- const session = this.sessions.get(sessionId);
159
- if (!session) return undefined;
160
- session.host = undefined;
161
- session.state = "host_disconnected";
162
- session.hostDisconnectedAt = Date.now();
163
- this.emitPresence(session);
164
- return { clients: session.clients };
165
- }
166
-
167
- removeClient(sessionId: string, deviceId: string): void {
168
- const session = this.sessions.get(sessionId);
169
- if (!session) return;
170
- session.clients.delete(deviceId);
171
- if (session.controllerId === deviceId) {
172
- // Transfer control to next client or clear
173
- const next = session.clients.keys().next();
174
- session.controllerId = next.done ? undefined : next.value;
175
- }
176
- this.maybeDelete(sessionId);
177
- }
178
-
179
- /** Force-delete a session: disconnect host + all clients, remove from map. */
180
- forceDelete(sessionId: string): boolean {
181
- const session = this.sessions.get(sessionId);
182
- if (!session) return false;
183
- if (session.host) {
184
- try { session.host.socket.close(1000, "session deleted"); } catch {}
185
- }
186
- for (const [, client] of session.clients) {
187
- try { client.socket.close(1000, "session deleted"); } catch {}
188
- }
189
- this.sessions.delete(sessionId);
190
- return true;
191
- }
192
-
193
- bufferOutput(sessionId: string, envelope: Envelope): void {
194
- const session = this.sessions.get(sessionId);
195
- if (!session) return;
196
- const tid = (envelope as any).terminalId ?? "default";
197
- let buf = session.outputBuffers.get(tid);
198
- if (!buf) {
199
- buf = [];
200
- session.outputBuffers.set(tid, buf);
201
- session.outputBufferBytes.set(tid, 0);
202
- }
203
- buf.push(envelope);
204
- let bytes = (session.outputBufferBytes.get(tid) ?? 0) + approxEnvelopeBytes(envelope);
205
- // Evict oldest until under BOTH the count cap and the byte cap, so a
206
- // malicious/abnormal host can't pin unbounded memory via large frames.
207
- while (
208
- buf.length > OUTPUT_BUFFER_CAPACITY ||
209
- (bytes > OUTPUT_BUFFER_MAX_BYTES && buf.length > 1)
210
- ) {
211
- const removed = buf.shift();
212
- if (removed) bytes -= approxEnvelopeBytes(removed);
213
- }
214
- session.outputBufferBytes.set(tid, Math.max(0, bytes));
215
- session.lastActivity = Date.now();
216
- }
217
-
218
- cacheStatus(sessionId: string, envelope: Envelope): void {
219
- const session = this.sessions.get(sessionId);
220
- if (!session) return;
221
- const tid = (envelope as any).terminalId ?? "default";
222
- session.lastStatusByTerminal.set(tid, envelope);
223
- session.lastActivity = Date.now();
224
- }
225
-
226
- getStatusReplay(sessionId: string): Envelope[] {
227
- const session = this.sessions.get(sessionId);
228
- if (!session) return [];
229
- return [...session.lastStatusByTerminal.values()];
230
- }
231
-
232
- getReplayFrom(
233
- sessionId: string,
234
- afterSeqByTerminal: Record<string, number>,
235
- fallbackAfterSeq = -1,
236
- ): Envelope[] {
237
- const session = this.sessions.get(sessionId);
238
- if (!session) return [];
239
- const result: Envelope[] = [];
240
- for (const [terminalId, buf] of session.outputBuffers) {
241
- const afterSeq = afterSeqByTerminal[terminalId] ?? fallbackAfterSeq;
242
- for (const e of buf) {
243
- if (e.seq !== undefined && e.seq > afterSeq) result.push(e);
244
- }
245
- }
246
- return result.sort((a, b) => {
247
- const at = Date.parse(a.timestamp);
248
- const bt = Date.parse(b.timestamp);
249
- if (!Number.isNaN(at) && !Number.isNaN(bt) && at !== bt) return at - bt;
250
- return (a.seq ?? 0) - (b.seq ?? 0);
251
- });
252
- }
253
-
254
- claimControl(sessionId: string, deviceId: string): boolean {
255
- const session = this.sessions.get(sessionId);
256
- if (!session) return false;
257
- // Always allow takeover – last claimer wins
258
- session.controllerId = deviceId;
259
- return true;
260
- }
261
-
262
- releaseControl(sessionId: string, deviceId: string): boolean {
263
- const session = this.sessions.get(sessionId);
264
- if (!session) return false;
265
- if (session.controllerId !== deviceId) return false;
266
- session.controllerId = undefined;
267
- return true;
268
- }
269
-
270
- terminate(sessionId: string): void {
271
- const session = this.sessions.get(sessionId);
272
- if (!session) return;
273
- session.state = "terminated";
274
- }
275
-
276
- listActive(): Session[] {
277
- return [...this.sessions.values()].filter((s) => s.state !== "terminated");
278
- }
279
-
280
- getSummary(sessionId: string) {
281
- const session = this.sessions.get(sessionId);
282
- if (!session) return undefined;
283
- return {
284
- id: session.id,
285
- state: session.state,
286
- hasHost:
287
- !!session.host &&
288
- session.host.socket.readyState === session.host.socket.OPEN,
289
- clientCount: session.clients.size,
290
- controllerId: session.controllerId ?? null,
291
- lastActivity: session.lastActivity,
292
- createdAt: session.createdAt,
293
- bufferSize: [...session.outputBuffers.values()].reduce((sum, buf) => sum + buf.length, 0),
294
- provider: session.provider ?? null,
295
- machineId: session.machineId ?? null,
296
- hostname: session.hostname ?? null,
297
- platform: session.platform ?? null,
298
- cwd: session.cwd ?? null,
299
- projectName: session.projectName ?? null,
300
- userId: session.userId ?? null,
301
- agentStatus: session.agentStatus ?? null,
302
- agentProvider: session.agentProvider ?? null,
303
- agentConversationId: session.agentConversationId ?? null,
304
- agentTitle: session.agentTitle ?? null,
305
- agentDetail: session.agentDetail ?? null,
306
- agentLastActivity: session.agentLastActivity ?? null,
307
- agentUsage: session.agentUsage ?? null,
308
- agentUsageReport: session.agentUsageReport ?? null,
309
- };
310
- }
311
-
312
- cacheAgentSummary(
313
- sessionId: string,
314
- summary: {
315
- status?: string;
316
- provider?: string | null;
317
- conversationId?: string | null;
318
- title?: string | null;
319
- detail?: string | null;
320
- lastActivity?: number | null;
321
- usage?: AgentUsageSummary | null;
322
- },
323
- ): boolean {
324
- const session = this.sessions.get(sessionId);
325
- if (!session) return false;
326
- const prev = {
327
- status: session.agentStatus,
328
- provider: session.agentProvider,
329
- conversationId: session.agentConversationId,
330
- title: session.agentTitle,
331
- detail: session.agentDetail,
332
- };
333
- if (summary.status) session.agentStatus = summary.status;
334
- if (summary.provider !== undefined) session.agentProvider = summary.provider ?? undefined;
335
- if (summary.conversationId !== undefined) session.agentConversationId = summary.conversationId ?? undefined;
336
- if (summary.title !== undefined) session.agentTitle = summary.title ?? undefined;
337
- if (summary.detail !== undefined) {
338
- session.agentDetail = summary.detail ?? undefined;
339
- } else if (summary.status && summary.status !== "waiting_permission") {
340
- // Permission lines are only meaningful while waiting. A running/idle
341
- // status without a new preview must not keep "运行命令 · …" around.
342
- session.agentDetail = undefined;
343
- }
344
- if (summary.lastActivity !== undefined) session.agentLastActivity = summary.lastActivity ?? undefined;
345
- if (summary.usage !== undefined) session.agentUsage = summary.usage ?? undefined;
346
- session.lastActivity = Date.now();
347
- const changed =
348
- prev.status !== session.agentStatus ||
349
- prev.provider !== session.agentProvider ||
350
- prev.conversationId !== session.agentConversationId ||
351
- prev.title !== session.agentTitle ||
352
- prev.detail !== session.agentDetail;
353
- if (changed) this.emitPresence(session);
354
- return changed;
355
- }
356
-
357
- bufferAgentEnvelope(sessionId: string, envelope: Envelope): void {
358
- const session = this.sessions.get(sessionId);
359
- if (!session) return;
360
- if (envelope.type === "agent.v2.snapshot") {
361
- session.lastAgentSnapshot = envelope;
362
- session.agentReplay = [];
363
- return;
364
- }
365
- session.agentReplay.push(envelope);
366
- if (session.agentReplay.length > AGENT_REPLAY_CAPACITY) {
367
- session.agentReplay.splice(0, session.agentReplay.length - AGENT_REPLAY_CAPACITY);
368
- }
369
- }
370
-
371
- getAgentReplay(sessionId: string): Envelope[] {
372
- const session = this.sessions.get(sessionId);
373
- if (!session) return [];
374
- return [
375
- ...(session.lastAgentSnapshot ? [session.lastAgentSnapshot] : []),
376
- ...session.agentReplay,
377
- ];
378
- }
379
-
380
- setMetadata(
381
- sessionId: string,
382
- provider?: string,
383
- machineId?: string,
384
- hostname?: string,
385
- platform?: string,
386
- cwd?: string,
387
- projectName?: string,
388
- ): void {
389
- const session = this.sessions.get(sessionId);
390
- if (!session) return;
391
- if (provider) session.provider = provider;
392
- if (machineId) session.machineId = machineId;
393
- if (hostname) session.hostname = hostname;
394
- if (platform) session.platform = platform;
395
- if (cwd) session.cwd = cwd;
396
- if (projectName) session.projectName = projectName;
397
- }
398
-
399
- private maybeDelete(sessionId: string): void {
400
- const session = this.sessions.get(sessionId);
401
- if (!session) return;
402
- if (!session.host && session.clients.size === 0) {
403
- this.sessions.delete(sessionId);
404
- }
405
- }
406
-
407
- private cleanup(): void {
408
- const now = Date.now();
409
- for (const [id, session] of this.sessions) {
410
- // Remove sessions where host disconnected and window expired
411
- if (
412
- session.state === "host_disconnected" &&
413
- session.hostDisconnectedAt &&
414
- now - session.hostDisconnectedAt > HOST_RECONNECT_WINDOW
415
- ) {
416
- session.state = "terminated";
417
- }
418
- // Clean up terminated sessions with no connections
419
- if (
420
- session.state === "terminated" &&
421
- !session.host &&
422
- session.clients.size === 0
423
- ) {
424
- this.sessions.delete(id);
425
- }
426
- }
427
- }
428
-
429
- destroy(): void {
430
- clearInterval(this.cleanupTimer);
431
- }
432
- }