@linkshell/gateway 0.4.49 → 0.4.51
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/gateway/src/embedded.js +24 -23
- package/dist/gateway/src/embedded.js.map +1 -1
- package/dist/gateway/src/index.js +28 -23
- package/dist/gateway/src/index.js.map +1 -1
- package/dist/gateway/src/presence.d.ts +28 -0
- package/dist/gateway/src/presence.js +92 -0
- package/dist/gateway/src/presence.js.map +1 -0
- package/dist/gateway/src/relay.d.ts +2 -0
- package/dist/gateway/src/relay.js +110 -1
- package/dist/gateway/src/relay.js.map +1 -1
- package/dist/gateway/src/sessions.d.ts +11 -1
- package/dist/gateway/src/sessions.js +59 -1
- package/dist/gateway/src/sessions.js.map +1 -1
- package/dist/gateway/tsconfig.tsbuildinfo +1 -1
- package/dist/shared-protocol/src/index.d.ts +541 -319
- package/dist/shared-protocol/src/index.js +43 -1
- package/dist/shared-protocol/src/index.js.map +1 -1
- package/package.json +2 -2
- package/src/embedded.ts +27 -24
- package/src/index.ts +33 -24
- package/src/presence.ts +118 -0
- package/src/relay.ts +120 -1
- package/src/sessions.ts +67 -2
package/src/relay.ts
CHANGED
|
@@ -109,6 +109,7 @@ type AgentConversationLike = {
|
|
|
109
109
|
status?: string;
|
|
110
110
|
provider?: string;
|
|
111
111
|
title?: string;
|
|
112
|
+
lastMessagePreview?: string;
|
|
112
113
|
lastActivityAt?: number;
|
|
113
114
|
usage?: {
|
|
114
115
|
inputTokens?: number;
|
|
@@ -120,6 +121,73 @@ type AgentConversationLike = {
|
|
|
120
121
|
};
|
|
121
122
|
};
|
|
122
123
|
|
|
124
|
+
function truncateDetail(text: string, max = 72): string {
|
|
125
|
+
const line = text.replace(/\s+/g, " ").trim();
|
|
126
|
+
return line.length > max ? `${line.slice(0, max - 1)}…` : line;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function commandOrPathFromToolInput(toolInput?: string): { command?: string; path?: string } {
|
|
130
|
+
if (!toolInput) return {};
|
|
131
|
+
try {
|
|
132
|
+
const parsed = JSON.parse(toolInput) as Record<string, unknown>;
|
|
133
|
+
if (parsed && typeof parsed === "object") {
|
|
134
|
+
return {
|
|
135
|
+
command: typeof parsed.command === "string" ? parsed.command : undefined,
|
|
136
|
+
path: typeof parsed.path === "string"
|
|
137
|
+
? parsed.path
|
|
138
|
+
: typeof parsed.file_path === "string"
|
|
139
|
+
? parsed.file_path
|
|
140
|
+
: undefined,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
const match = toolInput.match(/\$\s+([^\n]+)/);
|
|
145
|
+
if (match?.[1]) return { command: match[1].trim() };
|
|
146
|
+
}
|
|
147
|
+
return {};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** List-line for a live permission request: "运行命令 · pnpm test". */
|
|
151
|
+
function permissionAttentionDetail(p: {
|
|
152
|
+
toolName?: string;
|
|
153
|
+
toolInput?: string;
|
|
154
|
+
context?: string;
|
|
155
|
+
}): string | undefined {
|
|
156
|
+
const { command, path } = commandOrPathFromToolInput(p.toolInput);
|
|
157
|
+
const name = (p.toolName ?? "").toLowerCase();
|
|
158
|
+
if (command) {
|
|
159
|
+
return truncateDetail(
|
|
160
|
+
/bash|shell|command/.test(name) || !p.toolName
|
|
161
|
+
? `运行命令 · ${command}`
|
|
162
|
+
: `${p.toolName} · ${command}`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
if (path) {
|
|
166
|
+
const leaf = path.split("/").filter(Boolean).pop() ?? path;
|
|
167
|
+
if (/write|edit|multiedit/.test(name)) return truncateDetail(`改文件 · ${leaf}`);
|
|
168
|
+
if (/(^|_)read(_|$)|readfile/.test(name)) return truncateDetail(`读取 · ${leaf}`);
|
|
169
|
+
return truncateDetail(`${p.toolName || "文件"} · ${leaf}`);
|
|
170
|
+
}
|
|
171
|
+
if (p.context?.trim()) return truncateDetail(p.context.trim());
|
|
172
|
+
if (p.toolName) return truncateDetail(`需要授权 · ${p.toolName}`);
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function conversationAttentionDetail(
|
|
177
|
+
conversation: AgentConversationLike | undefined,
|
|
178
|
+
status: string | undefined,
|
|
179
|
+
current?: { agentStatus?: string; agentDetail?: string },
|
|
180
|
+
): string | null | undefined {
|
|
181
|
+
if (status === "waiting_permission") {
|
|
182
|
+
if (current?.agentStatus === "waiting_permission" && current.agentDetail) return undefined;
|
|
183
|
+
return conversation?.lastMessagePreview;
|
|
184
|
+
}
|
|
185
|
+
if (status === "running" || status === "error") {
|
|
186
|
+
return conversation?.lastMessagePreview ?? null;
|
|
187
|
+
}
|
|
188
|
+
return status ? null : undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
123
191
|
function pickConversationSummary(
|
|
124
192
|
conversations: AgentConversationLike[],
|
|
125
193
|
activeConversationId?: string,
|
|
@@ -169,14 +237,45 @@ function shouldPromoteTerminalStatus(status: string, current?: string): boolean
|
|
|
169
237
|
|
|
170
238
|
function cacheAgentEnvelope(envelope: Envelope, sessions: SessionManager): void {
|
|
171
239
|
try {
|
|
240
|
+
if (envelope.type === "agent.v2.conversation.list.result") {
|
|
241
|
+
const p = parseTypedPayload("agent.v2.conversation.list.result", envelope.payload);
|
|
242
|
+
const picked = pickConversationSummary(p.conversations);
|
|
243
|
+
const status = aggregateAgentStatus(p.conversations, picked?.status ?? "idle");
|
|
244
|
+
sessions.cacheAgentSummary(envelope.sessionId, {
|
|
245
|
+
status,
|
|
246
|
+
provider: picked?.provider,
|
|
247
|
+
conversationId: picked?.id,
|
|
248
|
+
title: picked?.title,
|
|
249
|
+
detail: conversationAttentionDetail(picked, status, sessions.get(envelope.sessionId)),
|
|
250
|
+
lastActivity: picked?.lastActivityAt ?? Date.now(),
|
|
251
|
+
usage: picked?.usage ?? null,
|
|
252
|
+
});
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (envelope.type === "agent.v2.conversation.opened") {
|
|
256
|
+
const p = parseTypedPayload("agent.v2.conversation.opened", envelope.payload);
|
|
257
|
+
const conversation = p.conversation;
|
|
258
|
+
sessions.cacheAgentSummary(envelope.sessionId, {
|
|
259
|
+
status: conversation.status,
|
|
260
|
+
provider: conversation.provider,
|
|
261
|
+
conversationId: conversation.id,
|
|
262
|
+
title: conversation.title,
|
|
263
|
+
detail: conversationAttentionDetail(conversation, conversation.status, sessions.get(envelope.sessionId)),
|
|
264
|
+
lastActivity: conversation.lastActivityAt ?? Date.now(),
|
|
265
|
+
usage: conversation.usage ?? null,
|
|
266
|
+
});
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
172
269
|
if (envelope.type === "agent.v2.snapshot") {
|
|
173
270
|
const p = parseTypedPayload("agent.v2.snapshot", envelope.payload);
|
|
174
271
|
const picked = pickConversationSummary(p.conversations, p.activeConversationId);
|
|
272
|
+
const status = aggregateAgentStatus(p.conversations, picked?.status ?? "idle");
|
|
175
273
|
sessions.cacheAgentSummary(envelope.sessionId, {
|
|
176
|
-
status
|
|
274
|
+
status,
|
|
177
275
|
provider: picked?.provider,
|
|
178
276
|
conversationId: picked?.id ?? p.activeConversationId,
|
|
179
277
|
title: picked?.title,
|
|
278
|
+
detail: conversationAttentionDetail(picked, status, sessions.get(envelope.sessionId)),
|
|
180
279
|
lastActivity: picked?.lastActivityAt ?? Date.now(),
|
|
181
280
|
usage: picked?.usage ?? null,
|
|
182
281
|
});
|
|
@@ -184,12 +283,14 @@ function cacheAgentEnvelope(envelope: Envelope, sessions: SessionManager): void
|
|
|
184
283
|
}
|
|
185
284
|
if (envelope.type === "agent.v2.event") {
|
|
186
285
|
const p = parseTypedPayload("agent.v2.event", envelope.payload);
|
|
286
|
+
const current = sessions.get(envelope.sessionId);
|
|
187
287
|
if (p.conversation) {
|
|
188
288
|
sessions.cacheAgentSummary(envelope.sessionId, {
|
|
189
289
|
status: p.conversation.status,
|
|
190
290
|
provider: p.conversation.provider,
|
|
191
291
|
conversationId: p.conversation.id,
|
|
192
292
|
title: p.conversation.title,
|
|
293
|
+
detail: conversationAttentionDetail(p.conversation, p.conversation.status, current),
|
|
193
294
|
lastActivity: p.conversation.lastActivityAt,
|
|
194
295
|
usage: p.conversation.usage ?? null,
|
|
195
296
|
});
|
|
@@ -197,6 +298,7 @@ function cacheAgentEnvelope(envelope: Envelope, sessions: SessionManager): void
|
|
|
197
298
|
sessions.cacheAgentSummary(envelope.sessionId, {
|
|
198
299
|
status: p.patch.status,
|
|
199
300
|
conversationId: p.conversationId,
|
|
301
|
+
detail: conversationAttentionDetail(undefined, p.patch.status, current),
|
|
200
302
|
lastActivity: Date.now(),
|
|
201
303
|
});
|
|
202
304
|
}
|
|
@@ -207,6 +309,7 @@ function cacheAgentEnvelope(envelope: Envelope, sessions: SessionManager): void
|
|
|
207
309
|
sessions.cacheAgentSummary(envelope.sessionId, {
|
|
208
310
|
status: "waiting_permission",
|
|
209
311
|
conversationId: p.conversationId,
|
|
312
|
+
detail: permissionAttentionDetail(p),
|
|
210
313
|
lastActivity: Date.now(),
|
|
211
314
|
});
|
|
212
315
|
return;
|
|
@@ -236,6 +339,7 @@ function cacheAgentEnvelope(envelope: Envelope, sessions: SessionManager): void
|
|
|
236
339
|
sessions.cacheAgentSummary(envelope.sessionId, {
|
|
237
340
|
status: "waiting_permission",
|
|
238
341
|
conversationId: p.agentSessionId,
|
|
342
|
+
detail: permissionAttentionDetail(p),
|
|
239
343
|
lastActivity: Date.now(),
|
|
240
344
|
});
|
|
241
345
|
return;
|
|
@@ -274,6 +378,7 @@ function handleHostMessage(
|
|
|
274
378
|
): void {
|
|
275
379
|
if (agentV2MessageRoute(envelope.type) === "host_to_client") {
|
|
276
380
|
cacheAgentEnvelope(envelope, sessions);
|
|
381
|
+
sessions.bufferAgentEnvelope(session.id, envelope);
|
|
277
382
|
broadcastToClients(session, envelope);
|
|
278
383
|
return;
|
|
279
384
|
}
|
|
@@ -463,6 +568,7 @@ function handleClientMessage(
|
|
|
463
568
|
for (const statusMsg of statusReplay) {
|
|
464
569
|
socket.send(serializeEnvelope(statusMsg));
|
|
465
570
|
}
|
|
571
|
+
replayAgentToSocket(socket, session.id, sessions);
|
|
466
572
|
// Also forward resume to host so it can fill gaps beyond gateway buffer.
|
|
467
573
|
sendToHost(session, session.machineId
|
|
468
574
|
? {
|
|
@@ -550,6 +656,19 @@ function broadcastToClients(
|
|
|
550
656
|
}
|
|
551
657
|
}
|
|
552
658
|
|
|
659
|
+
/** Replay the last agent snapshot + recent host→client agent envelopes. */
|
|
660
|
+
export function replayAgentToSocket(
|
|
661
|
+
socket: WebSocket,
|
|
662
|
+
sessionId: string,
|
|
663
|
+
sessions: SessionManager,
|
|
664
|
+
): void {
|
|
665
|
+
if (socket.readyState !== socket.OPEN) return;
|
|
666
|
+
for (const envelope of sessions.getAgentReplay(sessionId)) {
|
|
667
|
+
if (socket.bufferedAmount > MAX_CLIENT_BUFFER_BYTES) break;
|
|
668
|
+
socket.send(serializeEnvelope(envelope));
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
553
672
|
function sendToHost(
|
|
554
673
|
session: ReturnType<SessionManager["get"]> & {},
|
|
555
674
|
envelope: Envelope,
|
package/src/sessions.ts
CHANGED
|
@@ -36,6 +36,8 @@ export interface Session {
|
|
|
36
36
|
agentProvider: string | undefined;
|
|
37
37
|
agentConversationId: string | undefined;
|
|
38
38
|
agentTitle: string | undefined;
|
|
39
|
+
// Short "what it's doing / asking" line for the session list.
|
|
40
|
+
agentDetail: string | undefined;
|
|
39
41
|
agentLastActivity: number | undefined;
|
|
40
42
|
// Latest token/context usage for the picked conversation (for at-a-glance
|
|
41
43
|
// display on the session list, without opening the console).
|
|
@@ -44,6 +46,10 @@ export interface Session {
|
|
|
44
46
|
// on-disk transcripts). Sent automatically on connect; shown as summary cards
|
|
45
47
|
// on the session list page without entering the console.
|
|
46
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[];
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
export interface AgentUsageSummary {
|
|
@@ -57,6 +63,7 @@ export interface AgentUsageSummary {
|
|
|
57
63
|
|
|
58
64
|
const OUTPUT_BUFFER_CAPACITY = 200;
|
|
59
65
|
const OUTPUT_BUFFER_MAX_BYTES = 8 * 1024 * 1024; // 8MB per terminal buffer
|
|
66
|
+
const AGENT_REPLAY_CAPACITY = 80;
|
|
60
67
|
const HOST_RECONNECT_WINDOW = 60_000; // 60s
|
|
61
68
|
const CLEANUP_INTERVAL = 30_000;
|
|
62
69
|
|
|
@@ -74,11 +81,17 @@ function approxEnvelopeBytes(envelope: Envelope): number {
|
|
|
74
81
|
export class SessionManager {
|
|
75
82
|
private sessions = new Map<string, Session>();
|
|
76
83
|
private cleanupTimer: ReturnType<typeof setInterval>;
|
|
84
|
+
/** Fired when host liveness or visible agent summary fields change. */
|
|
85
|
+
onPresenceChange?: (session: Session) => void;
|
|
77
86
|
|
|
78
87
|
constructor() {
|
|
79
88
|
this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL);
|
|
80
89
|
}
|
|
81
90
|
|
|
91
|
+
private emitPresence(session: Session): void {
|
|
92
|
+
this.onPresenceChange?.(session);
|
|
93
|
+
}
|
|
94
|
+
|
|
82
95
|
getOrCreate(sessionId: string): Session {
|
|
83
96
|
let session = this.sessions.get(sessionId);
|
|
84
97
|
if (!session) {
|
|
@@ -105,9 +118,12 @@ export class SessionManager {
|
|
|
105
118
|
agentProvider: undefined,
|
|
106
119
|
agentConversationId: undefined,
|
|
107
120
|
agentTitle: undefined,
|
|
121
|
+
agentDetail: undefined,
|
|
108
122
|
agentLastActivity: undefined,
|
|
109
123
|
agentUsage: undefined,
|
|
110
124
|
agentUsageReport: undefined,
|
|
125
|
+
lastAgentSnapshot: undefined,
|
|
126
|
+
agentReplay: [],
|
|
111
127
|
};
|
|
112
128
|
this.sessions.set(sessionId, session);
|
|
113
129
|
}
|
|
@@ -124,6 +140,7 @@ export class SessionManager {
|
|
|
124
140
|
session.state = "active";
|
|
125
141
|
session.hostDisconnectedAt = undefined;
|
|
126
142
|
session.lastActivity = Date.now();
|
|
143
|
+
this.emitPresence(session);
|
|
127
144
|
}
|
|
128
145
|
|
|
129
146
|
addClient(sessionId: string, device: ConnectedDevice): void {
|
|
@@ -143,6 +160,7 @@ export class SessionManager {
|
|
|
143
160
|
session.host = undefined;
|
|
144
161
|
session.state = "host_disconnected";
|
|
145
162
|
session.hostDisconnectedAt = Date.now();
|
|
163
|
+
this.emitPresence(session);
|
|
146
164
|
return { clients: session.clients };
|
|
147
165
|
}
|
|
148
166
|
|
|
@@ -284,6 +302,7 @@ export class SessionManager {
|
|
|
284
302
|
agentProvider: session.agentProvider ?? null,
|
|
285
303
|
agentConversationId: session.agentConversationId ?? null,
|
|
286
304
|
agentTitle: session.agentTitle ?? null,
|
|
305
|
+
agentDetail: session.agentDetail ?? null,
|
|
287
306
|
agentLastActivity: session.agentLastActivity ?? null,
|
|
288
307
|
agentUsage: session.agentUsage ?? null,
|
|
289
308
|
agentUsageReport: session.agentUsageReport ?? null,
|
|
@@ -297,19 +316,65 @@ export class SessionManager {
|
|
|
297
316
|
provider?: string | null;
|
|
298
317
|
conversationId?: string | null;
|
|
299
318
|
title?: string | null;
|
|
319
|
+
detail?: string | null;
|
|
300
320
|
lastActivity?: number | null;
|
|
301
321
|
usage?: AgentUsageSummary | null;
|
|
302
322
|
},
|
|
303
|
-
):
|
|
323
|
+
): boolean {
|
|
304
324
|
const session = this.sessions.get(sessionId);
|
|
305
|
-
if (!session) return;
|
|
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
|
+
};
|
|
306
333
|
if (summary.status) session.agentStatus = summary.status;
|
|
307
334
|
if (summary.provider !== undefined) session.agentProvider = summary.provider ?? undefined;
|
|
308
335
|
if (summary.conversationId !== undefined) session.agentConversationId = summary.conversationId ?? undefined;
|
|
309
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
|
+
}
|
|
310
344
|
if (summary.lastActivity !== undefined) session.agentLastActivity = summary.lastActivity ?? undefined;
|
|
311
345
|
if (summary.usage !== undefined) session.agentUsage = summary.usage ?? undefined;
|
|
312
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
|
+
];
|
|
313
378
|
}
|
|
314
379
|
|
|
315
380
|
setMetadata(
|