@linkshell/gateway 0.4.49 → 0.4.50

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/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: aggregateAgentStatus(p.conversations, picked?.status ?? "idle"),
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
- ): void {
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(