@linkshell/gateway 0.5.3 → 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.
- package/Dockerfile +8 -22
- package/README.md +99 -44
- package/dist/gateway/src/accounts.js.map +1 -0
- package/dist/gateway/src/index.d.ts +4 -1
- package/dist/gateway/src/index.js +4 -863
- package/dist/gateway/src/index.js.map +1 -1
- package/dist/gateway/src/main.js +57 -0
- package/dist/gateway/src/main.js.map +1 -0
- package/dist/gateway/src/rate-limit.d.ts +22 -0
- package/dist/gateway/src/rate-limit.js +66 -0
- package/dist/gateway/src/rate-limit.js.map +1 -0
- package/dist/gateway/src/relay.d.ts +84 -5
- package/dist/gateway/src/relay.js +402 -529
- package/dist/gateway/src/relay.js.map +1 -1
- package/dist/gateway/src/serve.d.ts +34 -0
- package/dist/gateway/src/serve.js +102 -0
- package/dist/gateway/src/serve.js.map +1 -0
- package/dist/{gateway-v2 → gateway}/src/store.d.ts +5 -0
- package/dist/{gateway-v2 → gateway}/src/store.js +7 -0
- package/dist/gateway/src/store.js.map +1 -0
- package/dist/gateway/src/subscription.d.ts +18 -0
- package/dist/gateway/src/subscription.js +46 -0
- package/dist/gateway/src/subscription.js.map +1 -0
- package/dist/gateway/tsconfig.tsbuildinfo +1 -1
- package/dist/wire/src/direct.d.ts +22 -0
- package/dist/wire/src/direct.js +37 -0
- package/dist/wire/src/direct.js.map +1 -0
- 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 +287 -6
- package/dist/wire/src/model.js +33 -0
- package/dist/wire/src/model.js.map +1 -1
- package/dist/wire/src/relay.d.ts +8 -8
- package/dist/wire/src/rpc.d.ts +1780 -44
- package/dist/wire/src/rpc.js +48 -3
- package/dist/wire/src/rpc.js.map +1 -1
- package/dist/wire/src/updates.d.ts +356 -0
- package/dist/wire/src/updates.js +11 -1
- package/dist/wire/src/updates.js.map +1 -1
- package/package.json +12 -12
- package/src/accounts.ts +32 -0
- package/src/index.ts +4 -997
- package/src/main.ts +65 -0
- package/src/rate-limit.ts +72 -0
- package/src/relay.ts +435 -620
- package/src/serve.ts +130 -0
- package/src/store.ts +135 -0
- package/src/subscription.ts +51 -0
- package/dist/gateway/src/auth-middleware.d.ts +0 -44
- package/dist/gateway/src/auth-middleware.js +0 -204
- package/dist/gateway/src/auth-middleware.js.map +0 -1
- package/dist/gateway/src/cors.d.ts +0 -2
- package/dist/gateway/src/cors.js +0 -7
- package/dist/gateway/src/cors.js.map +0 -1
- package/dist/gateway/src/embedded.d.ts +0 -21
- package/dist/gateway/src/embedded.js +0 -438
- package/dist/gateway/src/embedded.js.map +0 -1
- package/dist/gateway/src/host-auth.d.ts +0 -32
- package/dist/gateway/src/host-auth.js +0 -78
- package/dist/gateway/src/host-auth.js.map +0 -1
- package/dist/gateway/src/pairings.d.ts +0 -38
- package/dist/gateway/src/pairings.js +0 -119
- package/dist/gateway/src/pairings.js.map +0 -1
- package/dist/gateway/src/presence.d.ts +0 -28
- package/dist/gateway/src/presence.js +0 -92
- package/dist/gateway/src/presence.js.map +0 -1
- package/dist/gateway/src/sessions.d.ts +0 -113
- package/dist/gateway/src/sessions.js +0 -352
- package/dist/gateway/src/sessions.js.map +0 -1
- package/dist/gateway/src/state-store.d.ts +0 -21
- package/dist/gateway/src/state-store.js +0 -103
- package/dist/gateway/src/state-store.js.map +0 -1
- package/dist/gateway/src/static-web.d.ts +0 -15
- package/dist/gateway/src/static-web.js +0 -129
- package/dist/gateway/src/static-web.js.map +0 -1
- package/dist/gateway/src/tokens.d.ts +0 -19
- package/dist/gateway/src/tokens.js +0 -151
- package/dist/gateway/src/tokens.js.map +0 -1
- package/dist/gateway/src/tunnel.d.ts +0 -61
- package/dist/gateway/src/tunnel.js +0 -576
- package/dist/gateway/src/tunnel.js.map +0 -1
- package/dist/gateway-v2/src/accounts.js.map +0 -1
- package/dist/gateway-v2/src/index.d.ts +0 -3
- package/dist/gateway-v2/src/index.js +0 -4
- package/dist/gateway-v2/src/index.js.map +0 -1
- package/dist/gateway-v2/src/main.js +0 -17
- package/dist/gateway-v2/src/main.js.map +0 -1
- package/dist/gateway-v2/src/server.d.ts +0 -84
- package/dist/gateway-v2/src/server.js +0 -426
- package/dist/gateway-v2/src/server.js.map +0 -1
- package/dist/gateway-v2/src/store.js.map +0 -1
- package/dist/shared-protocol/src/index.d.ts +0 -17029
- package/dist/shared-protocol/src/index.js +0 -1064
- package/dist/shared-protocol/src/index.js.map +0 -1
- package/src/auth-middleware.ts +0 -266
- package/src/cors.ts +0 -8
- package/src/embedded.ts +0 -526
- package/src/host-auth.ts +0 -88
- package/src/pairings.ts +0 -133
- package/src/presence.ts +0 -118
- package/src/sessions.ts +0 -432
- package/src/state-store.ts +0 -140
- package/src/static-web.ts +0 -133
- package/src/tokens.ts +0 -159
- package/src/tunnel.ts +0 -644
- /package/dist/{gateway-v2 → gateway}/src/accounts.d.ts +0 -0
- /package/dist/{gateway-v2 → gateway}/src/accounts.js +0 -0
- /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
|
-
}
|