@linkshell/wire 0.2.30 → 0.2.32

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.
@@ -52,6 +52,8 @@ export interface RelayClientOptions {
52
52
  /** Errors no retry soon will fix (wrong key, no account support, no subscription). */
53
53
  const LASTING_ERRORS = new Set(["bad_identity", "accounts_disabled", "not_admitted"]);
54
54
  const LASTING_RETRY_MS = 2 * 60_000;
55
+ const HANDSHAKE_TIMEOUT_MS = 15_000;
56
+ const STABLE_ONLINE_MS = 15_000;
55
57
 
56
58
  export class RelayRequestError extends Error {
57
59
  constructor(
@@ -68,7 +70,11 @@ export class RelayClient {
68
70
  private socket?: RelaySocket;
69
71
  private statusValue: RelayStatus = "offline";
70
72
  private attempt = 0;
73
+ private replacedCount = 0;
74
+ private onlineAt = 0;
71
75
  private timer?: ReturnType<typeof setTimeout>;
76
+ private handshakeTimer?: ReturnType<typeof setTimeout>;
77
+ private stableTimer?: ReturnType<typeof setTimeout>;
72
78
  private pinger?: ReturnType<typeof setInterval>;
73
79
  private probeTimer?: ReturnType<typeof setTimeout>;
74
80
  /** When the current socket last received anything. */
@@ -77,7 +83,7 @@ export class RelayClient {
77
83
  private readonly pending = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> }>();
78
84
  private readonly statusListeners = new Set<(status: RelayStatus, error?: { code: string; message: string }) => void>();
79
85
  private readonly frameListeners = new Set<(from: PublicIdentity, via: "paired" | "account", frame: TunnelFrame) => void>();
80
- private readonly undeliverableListeners = new Set<(to: string, reason: string) => void>();
86
+ private readonly undeliverableListeners = new Set<(to: string, reason: string, channel?: string) => void>();
81
87
  private readonly eventListeners = new Map<string, Set<(data: unknown) => void>>();
82
88
  private readonly waiters = new Set<() => void>();
83
89
  /** Account the gateway recognised on the last connection. */
@@ -104,7 +110,7 @@ export class RelayClient {
104
110
  return () => this.frameListeners.delete(listener);
105
111
  }
106
112
 
107
- onUndeliverable(listener: (to: string, reason: string) => void): () => void {
113
+ onUndeliverable(listener: (to: string, reason: string, channel?: string) => void): () => void {
108
114
  this.undeliverableListeners.add(listener);
109
115
  return () => this.undeliverableListeners.delete(listener);
110
116
  }
@@ -119,12 +125,17 @@ export class RelayClient {
119
125
 
120
126
  start(): void {
121
127
  if (this.statusValue === "connecting" || this.statusValue === "online") return;
128
+ clearTimeout(this.timer);
129
+ this.replacedCount = 0;
122
130
  this.open();
123
131
  }
124
132
 
125
133
  stop(): void {
126
134
  this.setStatus("stopped");
127
135
  clearTimeout(this.timer);
136
+ this.clearHandshakeTimer();
137
+ this.clearStableTimer();
138
+ this.onlineAt = 0;
128
139
  this.stopPinging();
129
140
  this.socket?.close(1000, "stopped");
130
141
  this.socket = undefined;
@@ -137,6 +148,7 @@ export class RelayClient {
137
148
  if (this.statusValue === "stopped" || this.statusValue === "online" || this.statusValue === "connecting") return;
138
149
  clearTimeout(this.timer);
139
150
  this.attempt = 0;
151
+ this.replacedCount = 0;
140
152
  this.open();
141
153
  }
142
154
 
@@ -159,8 +171,12 @@ export class RelayClient {
159
171
  reauthenticate(): void {
160
172
  if (this.statusValue === "stopped") return;
161
173
  clearTimeout(this.timer);
174
+ this.clearHandshakeTimer();
175
+ this.clearStableTimer();
176
+ this.onlineAt = 0;
162
177
  this.stopPinging();
163
178
  this.attempt = 0;
179
+ this.replacedCount = 0;
164
180
  const socket = this.socket;
165
181
  this.socket = undefined;
166
182
  socket?.close(1000, "signing in again");
@@ -239,6 +255,24 @@ export class RelayClient {
239
255
  }
240
256
  this.socket = socket;
241
257
  this.heardAt = Date.now();
258
+ this.clearHandshakeTimer();
259
+ this.handshakeTimer = setTimeout(() => {
260
+ this.handshakeTimer = undefined;
261
+ if (this.socket !== socket || this.statusValue === "stopped") return;
262
+ this.socket = undefined;
263
+ this.clearStableTimer();
264
+ this.onlineAt = 0;
265
+ this.stopPinging();
266
+ socket.onclose = null;
267
+ socket.onmessage = null;
268
+ try {
269
+ socket.close(4002, "handshake timeout");
270
+ } catch {
271
+ // Already closed.
272
+ }
273
+ this.failPending("连接网关超时");
274
+ this.scheduleReconnect({ code: "handshake_timeout", message: "连接网关超时" });
275
+ }, HANDSHAKE_TIMEOUT_MS);
242
276
  let fatal: { code: string; message: string } | undefined;
243
277
  socket.onmessage = (event) => {
244
278
  if (this.socket !== socket) return;
@@ -256,10 +290,19 @@ export class RelayClient {
256
290
  void this.authenticate(frame.nonce, socket);
257
291
  return;
258
292
  case "ready":
293
+ this.clearHandshakeTimer();
259
294
  this.attempt = 0;
295
+ this.onlineAt = Date.now();
260
296
  this.account = frame.userId ? { userId: frame.userId, email: frame.email } : undefined;
261
297
  this.setStatus("online");
262
298
  this.startPinging(socket);
299
+ this.clearStableTimer();
300
+ if (this.replacedCount > 0) {
301
+ this.stableTimer = setTimeout(() => {
302
+ this.stableTimer = undefined;
303
+ if (this.socket === socket && this.statusValue === "online") this.replacedCount = 0;
304
+ }, STABLE_ONLINE_MS);
305
+ }
263
306
  return;
264
307
  case "res": {
265
308
  const entry = this.pending.get(frame.id);
@@ -286,7 +329,7 @@ export class RelayClient {
286
329
  return;
287
330
  }
288
331
  case "undeliverable":
289
- for (const listener of this.undeliverableListeners) listener(frame.to, frame.reason);
332
+ for (const listener of this.undeliverableListeners) listener(frame.to, frame.reason, frame.channel);
290
333
  return;
291
334
  case "error":
292
335
  fatal = { code: frame.code, message: frame.message };
@@ -299,18 +342,40 @@ export class RelayClient {
299
342
  socket.onclose = (event) => {
300
343
  if (this.socket !== socket) return;
301
344
  this.socket = undefined;
345
+ this.clearHandshakeTimer();
346
+ const wasOnline = this.statusValue === "online";
347
+ if (wasOnline && this.onlineAt > 0 && Date.now() - this.onlineAt >= STABLE_ONLINE_MS) {
348
+ this.replacedCount = 0;
349
+ }
350
+ this.onlineAt = 0;
351
+ this.clearStableTimer();
302
352
  this.stopPinging();
303
353
  this.failPending("与网关的连接断开了");
304
354
  if (this.statusValue === "stopped") return;
305
- // Another connection with our identity took over: don't fight it.
355
+ // Transient dual-socket handoffs can trigger 4000; retry a few times with wide jitter,
356
+ // and only stop permanently when another peer persistently reclaims the identity.
306
357
  if (event.code === 4000) {
358
+ this.replacedCount += 1;
307
359
  this.setStatus("offline", { code: "replaced", message: "这台设备已在别处连上网关" });
360
+ if (this.replacedCount > 3) return;
361
+ const delay = 5_000 + Math.random() * 10_000;
362
+ this.timer = setTimeout(() => this.open(), delay);
308
363
  return;
309
364
  }
310
- this.scheduleReconnect(fatal ?? { code: "disconnected", message: event.reason || "与网关的连接断开了" });
365
+ this.scheduleReconnect(fatal ?? { code: "disconnected", message: event.reason || "与网关的连接断开了" }, wasOnline);
311
366
  };
312
367
  }
313
368
 
369
+ private clearHandshakeTimer(): void {
370
+ clearTimeout(this.handshakeTimer);
371
+ this.handshakeTimer = undefined;
372
+ }
373
+
374
+ private clearStableTimer(): void {
375
+ clearTimeout(this.stableTimer);
376
+ this.stableTimer = undefined;
377
+ }
378
+
314
379
  private startPinging(socket: RelaySocket): void {
315
380
  this.stopPinging();
316
381
  const every = this.options.pingMs ?? 15_000;
@@ -348,6 +413,13 @@ export class RelayClient {
348
413
  private drop(socket: RelaySocket, message: string): void {
349
414
  if (this.socket !== socket) return;
350
415
  this.socket = undefined;
416
+ this.clearHandshakeTimer();
417
+ const wasOnline = this.statusValue === "online";
418
+ if (wasOnline && this.onlineAt > 0 && Date.now() - this.onlineAt >= STABLE_ONLINE_MS) {
419
+ this.replacedCount = 0;
420
+ }
421
+ this.onlineAt = 0;
422
+ this.clearStableTimer();
351
423
  this.stopPinging();
352
424
  socket.onclose = null;
353
425
  socket.onmessage = null;
@@ -361,7 +433,7 @@ export class RelayClient {
361
433
  this.failPending("与网关的连接断开了");
362
434
  if (this.statusValue === "stopped") return;
363
435
  this.attempt = 0;
364
- this.scheduleReconnect({ code: "disconnected", message });
436
+ this.scheduleReconnect({ code: "disconnected", message }, wasOnline);
365
437
  }
366
438
 
367
439
  private async authenticate(nonce: string, socket: RelaySocket): Promise<void> {
@@ -386,14 +458,18 @@ export class RelayClient {
386
458
  });
387
459
  }
388
460
 
389
- private scheduleReconnect(error: { code: string; message: string }): void {
461
+ private scheduleReconnect(error: { code: string; message: string }, wasOnline = this.statusValue === "online"): void {
390
462
  this.setStatus("offline", error);
391
463
  const min = this.options.minBackoffMs ?? 500;
392
464
  const max = this.options.maxBackoffMs ?? 30_000;
393
465
  // Still tried now and then (a subscription can be bought, a key re-registered), and at once
394
466
  // on reconnectNow, but not every 30 s for as long as the program runs.
395
467
  const floor = LASTING_ERRORS.has(error.code) ? LASTING_RETRY_MS : 0;
396
- const delay = Math.max(floor, Math.min(max, min * 2 ** this.attempt)) * (0.75 + Math.random() * 0.5);
468
+ // Spread out peers dropping together when a gateway restarts.
469
+ const delay =
470
+ wasOnline && this.attempt === 0 && !floor
471
+ ? Math.min(max, min + Math.random() * (min * 5))
472
+ : Math.max(floor, Math.min(max, min * 2 ** this.attempt)) * (0.75 + Math.random() * 0.5);
397
473
  this.attempt += 1;
398
474
  this.timer = setTimeout(() => this.open(), delay);
399
475
  }
package/src/relay.ts CHANGED
@@ -81,7 +81,7 @@ export const serverFrameSchema = z.discriminatedUnion("t", [
81
81
  /** An encrypted frame from a peer, with the keys the gateway verified it holds. */
82
82
  z.object({ t: z.literal("from"), from: publicIdentitySchema, via: z.enum(["paired", "account"]), d: z.string() }),
83
83
  /** A frame couldn't be delivered: the peer is offline or not reachable. */
84
- z.object({ t: z.literal("undeliverable"), to: z.string(), reason: z.string() }),
84
+ z.object({ t: z.literal("undeliverable"), to: z.string(), reason: z.string(), channel: z.string().optional() }),
85
85
  z.object({ t: z.literal("pong") }),
86
86
  z.object({ t: z.literal("error"), code: z.string(), message: z.string() }),
87
87
  ]);
package/src/rpc.ts CHANGED
@@ -3,12 +3,14 @@ import { usageQuerySchema, usageReportSchema } from "./usage.js";
3
3
  import { goalChangeSchema, sessionGoalSchema } from "./goal.js";
4
4
  import { acpAgentSettingsSchema, acpFeaturesSchema, acpRemoteAgentSchema, acpProviderSchema } from "./acp.js";
5
5
  import { editorRequestSchema, editorResultSchema } from "./editor.js";
6
- import { liveActivityIdSchema, liveActivitySubscriptionSchema } from "./live-activity.js";
6
+ import { liveActivityIdSchema, liveActivityPushToStartSchema, liveActivitySubscriptionSchema, wakeupNoticeSchema } from "./live-activity.js";
7
7
  import {
8
8
  backgroundTaskSchema,
9
9
  agentInfoSchema,
10
10
  gatewayStatusSchema,
11
+ keepAwakeModeSchema,
11
12
  machineInfoSchema,
13
+ machineKeepAwakeSchema,
12
14
  machineMetricsSnapshotSchema,
13
15
  portInfoSchema,
14
16
  projectSummarySchema,
@@ -24,6 +26,7 @@ import {
24
26
  import { contentBlockSchema, sessionConfigOptionSchema, sessionEventSchema, sessionNoticeSchema } from "./updates.js";
25
27
  import { sideChatSchema } from "./side.js";
26
28
  import { mcpMethods } from "./rpc-mcp.js";
29
+ import { lsaMethods } from "./lsa.js";
27
30
 
28
31
  // ── JSON-RPC 2.0 framing ─────────────────────────────────────────────
29
32
 
@@ -77,11 +80,51 @@ export const RPC_APP_ERROR = -32000;
77
80
  * 1: side conversations (`sessions.side`), `machine.info.protocol` itself.
78
81
  * 2: session terminals (`terminals.create` `sessionId`, `TerminalInfo.sessionId`).
79
82
  * 3: whole-computer CPU and physical memory sampling (`machine.metrics`).
83
+ * 4: smart keep-awake & battery telemetry (`machine.keepAwake`), real git working-tree
84
+ * diff/revert (`git.diff`, `git.revertFile`), fast `@file` search (`fs.projectFiles`),
85
+ * clipboard & screen privacy (`clipboard.*`, `screen.privacy`), push-to-start wakeup
86
+ * (`liveActivities.registerPushToStart`), and LSA Phase 1 steward RPCs (`lsa.*`).
80
87
  */
81
- export const HOST_PROTOCOL = 3;
88
+ export const HOST_PROTOCOL = 4;
82
89
 
83
90
  const empty = z.looseObject({});
84
91
 
92
+ export const gitDiffFileStatusSchema = z.enum(["modified", "added", "deleted", "renamed", "untracked"]);
93
+ export type GitDiffFileStatus = z.infer<typeof gitDiffFileStatusSchema>;
94
+
95
+ export const gitDiffFileSchema = z.object({
96
+ path: z.string(),
97
+ oldPath: z.string().optional(),
98
+ status: gitDiffFileStatusSchema,
99
+ oldText: z.string(),
100
+ newText: z.string(),
101
+ additions: z.number().int().nonnegative(),
102
+ deletions: z.number().int().nonnegative(),
103
+ binary: z.boolean().optional(),
104
+ });
105
+ export type GitDiffFile = z.infer<typeof gitDiffFileSchema>;
106
+
107
+ export const gitDiffResultSchema = z.object({
108
+ files: z.array(gitDiffFileSchema),
109
+ truncated: z.boolean(),
110
+ branch: z.string().optional(),
111
+ });
112
+ export type GitDiffResult = z.infer<typeof gitDiffResultSchema>;
113
+
114
+ export const projectFileEntrySchema = z.object({
115
+ path: z.string(),
116
+ relativePath: z.string(),
117
+ isDir: z.boolean().optional(),
118
+ });
119
+ export type ProjectFileEntry = z.infer<typeof projectFileEntrySchema>;
120
+
121
+ export const screenPrivacyStateSchema = z.object({
122
+ curtain: z.boolean(),
123
+ lockOnDisconnect: z.boolean(),
124
+ supported: z.boolean(),
125
+ });
126
+ export type ScreenPrivacyState = z.infer<typeof screenPrivacyStateSchema>;
127
+
85
128
  export const directoryEntrySchema = z.object({
86
129
  name: z.string(),
87
130
  path: z.string(),
@@ -124,6 +167,14 @@ const acpMethods = {
124
167
 
125
168
  const coreMethods = {
126
169
  "desktop.shutdown": { params: z.object({ pid: z.number().int().positive() }), result: empty },
170
+ /** Opens a local path in the chosen desktop editor, file manager, or terminal (local only). */
171
+ "desktop.openPath": {
172
+ params: z.object({
173
+ path: z.string().min(1),
174
+ app: z.enum(["default", "reveal", "cursor", "vscode", "zed", "terminal"]).optional(),
175
+ }),
176
+ result: z.object({ ok: z.boolean() }),
177
+ },
127
178
  "usage.summary": { params: usageQuerySchema, result: usageReportSchema },
128
179
  "liveActivities.subscribe": {
129
180
  params: liveActivitySubscriptionSchema,
@@ -133,11 +184,19 @@ const coreMethods = {
133
184
  params: z.object({ activityId: liveActivityIdSchema }),
134
185
  result: z.object({}),
135
186
  },
187
+ "liveActivities.registerPushToStart": {
188
+ params: liveActivityPushToStartSchema,
189
+ result: z.looseObject({ ok: z.boolean().optional() }),
190
+ },
136
191
  "machine.info": {
137
192
  params: empty,
138
193
  result: machineInfoSchema,
139
194
  },
140
195
  "machine.metrics": { params: empty, result: machineMetricsSnapshotSchema },
196
+ "machine.keepAwake": {
197
+ params: z.object({ mode: keepAwakeModeSchema.optional() }),
198
+ result: machineKeepAwakeSchema,
199
+ },
141
200
  "machine.rename": {
142
201
  /** Empty restores the configured default; names never change peer identities. */
143
202
  params: z.object({ name: z.string().trim().max(40) }),
@@ -152,13 +211,20 @@ const coreMethods = {
152
211
  limit: z.number().int().positive().max(200).optional(),
153
212
  /** Only sessions updated strictly before this timestamp (pagination). */
154
213
  before: z.number().optional(),
214
+ /** Tie-breaker session id when multiple sessions share the `before` timestamp. */
215
+ beforeId: z.string().optional(),
155
216
  includeArchived: z.boolean().optional(),
156
217
  }),
157
218
  result: z.object({
158
219
  sessions: z.array(sessionSummarySchema),
159
220
  nextBefore: z.number().optional(),
221
+ nextBeforeId: z.string().optional(),
160
222
  }),
161
223
  },
224
+ "sessions.get": {
225
+ params: z.object({ sessionId: z.string().min(1) }),
226
+ result: z.object({ session: sessionSummarySchema.optional() }),
227
+ },
162
228
  /**
163
229
  * The models a new session of `agent` can start on, for choosing one before
164
230
  * there is a session (whose own list comes later, in its `ls_config`).
@@ -192,6 +258,8 @@ const coreMethods = {
192
258
  cwd: z.string().min(1),
193
259
  prompt: z.array(contentBlockSchema).optional(),
194
260
  clientMessageId: z.string().optional(),
261
+ /** Client-generated idempotency key so a retried create does not spawn a duplicate session. */
262
+ requestId: z.string().max(128).optional(),
195
263
  /** One of `agents.models`' values; absent: the agent's own default. */
196
264
  model: z.string().optional(),
197
265
  /** Explicit choices from agents.models.config, applied before the first prompt. */
@@ -508,9 +576,38 @@ const coreMethods = {
508
576
  params: z.object({ password: z.string().min(4).max(128).optional() }),
509
577
  result: z.object({ password: z.boolean() }),
510
578
  },
511
- // ── Git: sessions in worktrees ──
579
+ // ── Git: sessions in worktrees & working-tree diffs ──
512
580
  /** What git says about `path`; no `git` when it isn't in a repository. */
513
581
  "git.info": { params: z.object({ path: z.string().min(1) }), result: z.object({ git: gitInfoSchema.optional() }) },
582
+ /** Real git working-tree diff (or against `base`), bounded by `maxBytes` and including untracked text files by default. */
583
+ "git.diff": {
584
+ params: z.object({
585
+ cwd: z.string().min(1),
586
+ base: z.string().optional(),
587
+ includeUntracked: z.boolean().optional(),
588
+ maxBytes: z.number().int().positive().max(10_000_000).optional(),
589
+ }),
590
+ result: gitDiffResultSchema,
591
+ },
592
+ /** Restores a tracked file or removes an untracked file strictly inside the repository root. */
593
+ "git.revertFile": {
594
+ params: z.object({ cwd: z.string().min(1), path: z.string().min(1) }),
595
+ result: z.object({ ok: z.boolean() }),
596
+ },
597
+ /** Commits staged or specified paths inside `cwd`'s repository. */
598
+ "git.commit": {
599
+ params: z.object({
600
+ cwd: z.string().min(1),
601
+ message: z.string().min(1).max(4000),
602
+ stageAll: z.boolean().optional(),
603
+ paths: z.array(z.string().min(1)).max(500).optional(),
604
+ }),
605
+ result: z.object({
606
+ sha: z.string(),
607
+ branch: z.string().optional(),
608
+ summary: z.string(),
609
+ }),
610
+ },
514
611
  /** The worktrees LinkShell made, newest first. */
515
612
  "worktrees.list": { params: empty, result: z.object({ worktrees: z.array(worktreeEntrySchema) }) },
516
613
  /** Removes a worktree and its branch. Refused (`busy`) while a session uses it, or (`dirty`) when work in it would be lost, unless `force`. */
@@ -579,14 +676,17 @@ const coreMethods = {
579
676
  },
580
677
  /**
581
678
  * Saves a file from the device into `dir`. A name that's taken gets a
582
- * numbered suffix ("photo 2.jpg") rather than overwriting.
679
+ * numbered suffix ("photo 2.jpg") rather than overwriting; subsequent chunks
680
+ * pass `appendPath` and `offset` so each chunk stays below the relay frame cap.
583
681
  */
584
682
  "fs.upload": {
585
683
  params: z.object({
586
684
  dir: z.string().min(1),
587
685
  name: z.string().min(1).max(255),
588
- /** base64; up to ~30 MB of file. */
589
- data: z.string().max(40_000_000),
686
+ /** base64; up to ~20 MB single-shot upload (smaller when chunked via `offset`/`appendPath`). */
687
+ data: z.string().max(30_000_000),
688
+ offset: z.number().int().nonnegative().optional(),
689
+ appendPath: z.string().optional(),
590
690
  }),
591
691
  result: z.object({ path: z.string(), size: z.number().int() }),
592
692
  },
@@ -600,6 +700,27 @@ const coreMethods = {
600
700
  params: z.object({ query: z.string().min(1).max(200), limit: z.number().int().min(1).max(200).default(60) }),
601
701
  result: z.object({ entries: z.array(directoryEntrySchema), truncated: z.boolean() }),
602
702
  },
703
+ /** Fast project file listing under `cwd` (`git ls-files` with directory walk fallback) for `@file` autocomplete. */
704
+ "fs.projectFiles": {
705
+ params: z.object({
706
+ cwd: z.string().min(1),
707
+ query: z.string().max(200).optional(),
708
+ limit: z.number().int().min(1).max(2000).optional(),
709
+ }),
710
+ result: z.object({
711
+ files: z.array(projectFileEntrySchema),
712
+ truncated: z.boolean(),
713
+ }),
714
+ },
715
+ // ── Clipboard: encrypted host clipboard access ──
716
+ "clipboard.read": {
717
+ params: empty,
718
+ result: z.object({ text: z.string() }),
719
+ },
720
+ "clipboard.write": {
721
+ params: z.object({ text: z.string().max(2_000_000) }),
722
+ result: z.object({ ok: z.boolean() }),
723
+ },
603
724
  // ── Terminals: plain shells on the host, any command, like SSH ──
604
725
  "terminals.list": {
605
726
  params: empty,
@@ -733,6 +854,14 @@ const coreMethods = {
733
854
  problem: z.string().optional(),
734
855
  }),
735
856
  },
857
+ /** Privacy curtain (dim/blank physical screen while viewing remotely) and auto-lock on disconnect. */
858
+ "screen.privacy": {
859
+ params: z.object({
860
+ curtain: z.boolean().optional(),
861
+ lockOnDisconnect: z.boolean().optional(),
862
+ }),
863
+ result: screenPrivacyStateSchema,
864
+ },
736
865
  /** Bytes to the host's end of a stream (base64). */
737
866
  "proxy.write": {
738
867
  params: z.object({ streamId: z.string().min(1), data: z.string().max(2_000_000) }),
@@ -744,7 +873,12 @@ const coreMethods = {
744
873
  },
745
874
  } as const;
746
875
 
747
- export const methods: typeof coreMethods & typeof acpMethods & typeof mcpMethods = { ...coreMethods, ...acpMethods, ...mcpMethods };
876
+ export const methods: typeof coreMethods & typeof acpMethods & typeof mcpMethods & typeof lsaMethods = {
877
+ ...coreMethods,
878
+ ...acpMethods,
879
+ ...mcpMethods,
880
+ ...lsaMethods,
881
+ };
748
882
 
749
883
  export type MethodName = keyof typeof methods;
750
884
  export type MethodParams<M extends MethodName> = z.input<(typeof methods)[M]["params"]>;
@@ -789,6 +923,8 @@ export const notifications = {
789
923
  "proxy.data": z.object({ streamId: z.string(), data: z.string() }),
790
924
  /** The host's end of a stream closed (the server hung up, or refused). */
791
925
  "proxy.closed": z.object({ streamId: z.string(), error: z.string().optional() }),
926
+ /** Zero-plaintext transition wakeup metadata. */
927
+ "liveActivities.wakeup": wakeupNoticeSchema,
792
928
  } as const;
793
929
 
794
930
  export type NotificationName = keyof typeof notifications;
package/src/updates.ts CHANGED
@@ -105,12 +105,16 @@ export const sessionUpdateSchema = z.discriminatedUnion("sessionUpdate", [
105
105
  parentToolCallId: z.string().optional(),
106
106
  messageId: z.string(),
107
107
  content: contentBlockSchema,
108
+ /** Replaces any buffered partial text for `messageId` (e.g. compacted turn chunks). */
109
+ replace: z.boolean().optional(),
108
110
  }),
109
111
  z.object({
110
112
  sessionUpdate: z.literal("agent_thought_chunk"),
111
113
  parentToolCallId: z.string().optional(),
112
114
  messageId: z.string(),
113
115
  content: contentBlockSchema,
116
+ /** Replaces any buffered partial thought for `messageId` (e.g. compacted turn chunks). */
117
+ replace: z.boolean().optional(),
114
118
  }),
115
119
  z.object({
116
120
  sessionUpdate: z.literal("tool_call"),
package/src/usage.ts CHANGED
@@ -4,21 +4,133 @@ export const usageQuerySchema = z.object({
4
4
  days: z.union([z.literal(1), z.literal(7), z.literal(30)]).default(7),
5
5
  agent: z.string().min(1).max(128).default("all"),
6
6
  });
7
+
7
8
  export const usageTotalsSchema = z.object({
8
- records: z.number(), sessions: z.number(), inputTokens: z.number(), outputTokens: z.number(),
9
- cacheReadTokens: z.number(), cacheWriteTokens: z.number(), totalTokens: z.number(),
9
+ records: z.number(),
10
+ sessions: z.number(),
11
+ inputTokens: z.number(),
12
+ outputTokens: z.number(),
13
+ cacheReadTokens: z.number(),
14
+ cacheWriteTokens: z.number(),
15
+ totalTokens: z.number(),
16
+ estimatedCostUsd: z.number().nonnegative().optional(),
17
+ });
18
+
19
+ export const usageRemoteStatsSchema = z.object({
20
+ remoteTurns: z.number().int().nonnegative(),
21
+ remoteApprovals: z.number().int().nonnegative(),
10
22
  });
23
+
11
24
  export const usageReportSchema = z.object({
12
- generatedAt: z.number(), days: z.number(), agent: z.string(), timezone: z.string(),
25
+ generatedAt: z.number(),
26
+ days: z.number(),
27
+ agent: z.string(),
28
+ timezone: z.string(),
13
29
  totals: usageTotalsSchema,
14
30
  daily: z.array(usageTotalsSchema.extend({ date: z.string() })),
15
31
  models: z.array(usageTotalsSchema.extend({ agent: z.string(), model: z.string() })),
16
32
  agents: z.array(z.object({
17
- id: z.string(), label: z.string(), source: z.enum(["native", "acp"]),
18
- status: z.enum(["available", "no_data"]), records: z.number(), totalTokens: z.number(), note: z.string(),
33
+ id: z.string(),
34
+ label: z.string(),
35
+ source: z.enum(["native", "acp"]),
36
+ status: z.enum(["available", "no_data"]),
37
+ records: z.number(),
38
+ totalTokens: z.number(),
39
+ note: z.string(),
40
+ estimatedCostUsd: z.number().nonnegative().optional(),
19
41
  })).optional(),
42
+ estimatedCostUsd: z.number().nonnegative().optional(),
43
+ remoteStats: usageRemoteStatsSchema.optional(),
20
44
  warnings: z.array(z.string()),
21
45
  });
46
+
22
47
  export type UsageQuery = z.infer<typeof usageQuerySchema>;
23
48
  export type UsageTotals = z.infer<typeof usageTotalsSchema>;
49
+ export type UsageRemoteStats = z.infer<typeof usageRemoteStatsSchema>;
24
50
  export type UsageReport = z.infer<typeof usageReportSchema>;
51
+
52
+ export interface ModelPricingPerMillion {
53
+ input: number;
54
+ output: number;
55
+ cacheRead: number;
56
+ cacheWrite: number;
57
+ }
58
+
59
+ export interface TokenCostInput {
60
+ inputTokens: number;
61
+ outputTokens: number;
62
+ cacheReadTokens?: number;
63
+ cacheWriteTokens?: number;
64
+ }
65
+
66
+ /**
67
+ * Reference public API pricing (USD per 1M tokens), ordered most-specific first.
68
+ * Subscription users (ChatGPT Plus/Pro, Claude Pro/Max) pay flat fees; this
69
+ * estimates the equivalent API value of work orchestrated through LinkShell.
70
+ */
71
+ const MODEL_PRICING_RULES: ReadonlyArray<{ match: RegExp; pricing: ModelPricingPerMillion }> = [
72
+ // Anthropic Claude
73
+ { match: /claude[-_.]?opus[-_.]?(?:4[._][56]|5)/i, pricing: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 } },
74
+ { match: /opus/i, pricing: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 } },
75
+ { match: /sonnet/i, pricing: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 } },
76
+ { match: /claude[-_.]?haiku[-_.]?4[._]5/i, pricing: { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 } },
77
+ { match: /haiku/i, pricing: { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 } },
78
+ // OpenAI / Codex
79
+ { match: /(?:gpt-5|o1|o3)[-_]pro/i, pricing: { input: 15, output: 60, cacheRead: 3.75, cacheWrite: 15 } },
80
+ { match: /gpt-5[-_.]?mini/i, pricing: { input: 0.25, output: 2, cacheRead: 0.025, cacheWrite: 0.25 } },
81
+ { match: /gpt-5[-_.]?nano/i, pricing: { input: 0.05, output: 0.4, cacheRead: 0.005, cacheWrite: 0.05 } },
82
+ { match: /codex[-_.]?mini/i, pricing: { input: 1.5, output: 6, cacheRead: 0.375, cacheWrite: 1.5 } },
83
+ { match: /gpt-5|codex/i, pricing: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 1.25 } },
84
+ { match: /gpt-4\.1[-_.]?mini/i, pricing: { input: 0.4, output: 1.6, cacheRead: 0.1, cacheWrite: 0.4 } },
85
+ { match: /gpt-4\.1[-_.]?nano/i, pricing: { input: 0.1, output: 0.4, cacheRead: 0.025, cacheWrite: 0.1 } },
86
+ { match: /gpt-4\.1/i, pricing: { input: 2, output: 8, cacheRead: 0.5, cacheWrite: 2 } },
87
+ { match: /gpt-4o[-_.]?mini/i, pricing: { input: 0.15, output: 0.6, cacheRead: 0.075, cacheWrite: 0.15 } },
88
+ { match: /gpt-4o/i, pricing: { input: 2.5, output: 10, cacheRead: 1.25, cacheWrite: 2.5 } },
89
+ { match: /o[34][-_.]?mini/i, pricing: { input: 1.1, output: 4.4, cacheRead: 0.275, cacheWrite: 1.1 } },
90
+ { match: /\bo3\b|\bo1\b/i, pricing: { input: 2, output: 8, cacheRead: 0.5, cacheWrite: 2 } },
91
+ // Google Gemini
92
+ { match: /gemini.*flash/i, pricing: { input: 0.3, output: 2.5, cacheRead: 0.075, cacheWrite: 0.3 } },
93
+ { match: /gemini/i, pricing: { input: 1.25, output: 10, cacheRead: 0.3125, cacheWrite: 1.25 } },
94
+ // xAI Grok & DeepSeek
95
+ { match: /grok/i, pricing: { input: 3, output: 15, cacheRead: 0.75, cacheWrite: 3 } },
96
+ { match: /deepseek/i, pricing: { input: 0.27, output: 1.1, cacheRead: 0.07, cacheWrite: 0.27 } },
97
+ ];
98
+
99
+ const DEFAULT_PRICING_PER_MILLION: ModelPricingPerMillion = {
100
+ input: 2,
101
+ output: 8,
102
+ cacheRead: 0.2,
103
+ cacheWrite: 2,
104
+ };
105
+
106
+ export function modelPricingPerMillionUsd(model: string): ModelPricingPerMillion {
107
+ const normalized = model.trim();
108
+ for (const rule of MODEL_PRICING_RULES) {
109
+ if (rule.match.test(normalized)) return rule.pricing;
110
+ }
111
+ return DEFAULT_PRICING_PER_MILLION;
112
+ }
113
+
114
+ function safeTokenCount(value: number | undefined): number {
115
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
116
+ }
117
+
118
+ /**
119
+ * Pure reference cost estimator (in USD) for a model and non-overlapping token bucket totals.
120
+ */
121
+ export function estimateModelCostUsd(model: string, totals: TokenCostInput): number {
122
+ const input = safeTokenCount(totals.inputTokens);
123
+ const output = safeTokenCount(totals.outputTokens);
124
+ const cacheRead = safeTokenCount(totals.cacheReadTokens);
125
+ const cacheWrite = safeTokenCount(totals.cacheWriteTokens);
126
+ if (input === 0 && output === 0 && cacheRead === 0 && cacheWrite === 0) return 0;
127
+
128
+ const rate = modelPricingPerMillionUsd(model);
129
+ const rawUsd =
130
+ (input * rate.input +
131
+ output * rate.output +
132
+ cacheRead * rate.cacheRead +
133
+ cacheWrite * rate.cacheWrite) /
134
+ 1_000_000;
135
+ return Math.round(rawUsd * 1_000_000) / 1_000_000;
136
+ }