@paradow/ledge 0.41.0 → 0.42.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/dist/cli/index.js CHANGED
@@ -365,11 +365,15 @@ function pendingKey(p) {
365
365
  const s = JSON.stringify([p.kind, p.text, p.body ?? null, p.tool ?? null, p.options ?? null, p.optionDetails ?? null, p.helperId ?? null]);
366
366
  return cyrb53(s, 0) + cyrb53(s, 1);
367
367
  }
368
- var DecisionSchema, NOTE_MAX, NOTE_NOT_TYPED, DECISION_OFFERS;
368
+ var DecisionSchema, DecidedBySchema, NOTE_MAX, NOTE_NOT_TYPED, DECISION_OFFERS;
369
369
  var init_decisions = __esm({
370
370
  "../protocol/src/decisions.ts"() {
371
371
  "use strict";
372
372
  DecisionSchema = z6.enum(["accept", "deny", "always"]);
373
+ DecidedBySchema = z6.discriminatedUnion("kind", [
374
+ z6.object({ kind: z6.literal("pc") }),
375
+ z6.object({ kind: z6.literal("device"), id: z6.string().min(1).max(100), name: z6.string().min(1).max(60) })
376
+ ]);
373
377
  NOTE_MAX = 4e3;
374
378
  NOTE_NOT_TYPED = "note_not_typed";
375
379
  DECISION_OFFERS = {
@@ -2056,6 +2060,7 @@ var init_thread = __esm({
2056
2060
  init_tasks();
2057
2061
  init_attachments();
2058
2062
  init_rigMessages();
2063
+ init_decisions();
2059
2064
  ToolStatusSchema = z22.enum(["running", "success", "failed", "unknown"]);
2060
2065
  FileChangeSchema = z22.object({ path: z22.string().min(1), from: z22.string().min(1).optional(), change: z22.enum(["add", "edit", "delete", "write"]), diff: z22.string().optional() });
2061
2066
  base = { id: z22.string().min(1), at: z22.string().datetime(), turn: z22.number().int().nonnegative(), helperId: z22.string().optional() };
@@ -2081,8 +2086,8 @@ var init_thread = __esm({
2081
2086
  imagesDropped: z22.literal(true).optional()
2082
2087
  }),
2083
2088
  /** `unknown`: answered in the terminal, the daemon only saw the prompt go away; `always`: allowed and not asked again;
2084
- * `byClose`: denied by Ledge closing the session. */
2085
- z22.object({ kind: z22.literal("permission"), ...base, tool: z22.string(), text: z22.string(), decision: z22.enum(["accept", "deny", "always", "unknown"]).optional(), byClose: z22.literal(true).optional() }),
2089
+ * `byClose`: denied by Ledge closing the session; `by`: who answered it from Ledge (the PC or a device, its name then). */
2090
+ z22.object({ kind: z22.literal("permission"), ...base, tool: z22.string(), text: z22.string(), decision: z22.enum(["accept", "deny", "always", "unknown"]).optional(), byClose: z22.literal(true).optional(), by: DecidedBySchema.optional() }),
2086
2091
  z22.object({ kind: z22.literal("reply"), ...base, text: z22.string() }),
2087
2092
  z22.object({ kind: z22.literal("helper"), ...base, task: z22.string(), agentType: z22.string().optional(), state: z22.enum(["working", "exited"]) }),
2088
2093
  /** A message this session sent to another agent; `id` is the tool_use_id, `to` the address it used, `peer` the Ledge session id, `received` the paired item's id in the peer's thread, `via` set when Ledge's mailbox carries it. */
@@ -2141,7 +2146,10 @@ var init_thread = __esm({
2141
2146
 
2142
2147
  // ../protocol/src/mobile.ts
2143
2148
  import { z as z23 } from "zod";
2144
- var DEFAULT_RELAY_URL, RelayUrlSchema, MobileConfigSchema, MobileLinkStateSchema, MobilePairingSchema, MobileStateSchema;
2149
+ function deviceKindOfLabel(label2) {
2150
+ return /\b(iPad|tablet|tablette)\b/i.test(label2) ? "tablet" : "phone";
2151
+ }
2152
+ var DEFAULT_RELAY_URL, RelayUrlSchema, MobileConfigSchema, MobileLinkStateSchema, DeviceKindSchema, DEVICE_NAME_MAX, DeviceNameSchema, MobilePairingSchema, MobileStateSchema;
2145
2153
  var init_mobile = __esm({
2146
2154
  "../protocol/src/mobile.ts"() {
2147
2155
  "use strict";
@@ -2172,6 +2180,9 @@ var init_mobile = __esm({
2172
2180
  /** The mobile service did not start (a broken mobile.json, a missing key store): nothing is linked until the daemon restarts. */
2173
2181
  z23.object({ state: z23.literal("unavailable"), error: z23.string().max(200) })
2174
2182
  ]);
2183
+ DeviceKindSchema = z23.enum(["phone", "tablet"]);
2184
+ DEVICE_NAME_MAX = 40;
2185
+ DeviceNameSchema = z23.string().trim().min(1).max(DEVICE_NAME_MAX).regex(/^[^\u0000-\u001f\u007f]+$/);
2175
2186
  MobilePairingSchema = z23.object({
2176
2187
  id: z23.string(),
2177
2188
  label: z23.string(),
@@ -2187,7 +2198,16 @@ var init_mobile = __esm({
2187
2198
  /** The last sign-in that did not end with a session; cleared by the next one. */
2188
2199
  signinError: z23.enum(["expired", "denied", "not_allowed", "failed"]).nullable(),
2189
2200
  link: MobileLinkStateSchema,
2190
- devices: z23.array(z23.object({ id: z23.string(), label: z23.string(), approvedAt: z23.string(), online: z23.boolean(), lastSeenAt: z23.string().optional() })),
2201
+ devices: z23.array(z23.object({
2202
+ id: z23.string(),
2203
+ label: z23.string(),
2204
+ approvedAt: z23.string(),
2205
+ online: z23.boolean(),
2206
+ lastSeenAt: z23.string().optional(),
2207
+ /** Absent from an older daemon; `name` absent: the user gave none, the front derives one from the label. */
2208
+ kind: DeviceKindSchema.optional(),
2209
+ name: z23.string().optional()
2210
+ })),
2191
2211
  pairings: z23.array(MobilePairingSchema),
2192
2212
  /** « Taper dans les terminaux depuis le téléphone »: absent from an older daemon, which has no phone terminal. */
2193
2213
  terminalInput: z23.boolean().optional()
@@ -2279,6 +2299,8 @@ var init_messages = __esm({
2279
2299
  decision: DecisionSchema.optional(),
2280
2300
  /** Decided: the request was refused by Ledge closing the session (its interrupt keys), not by the user's answer. */
2281
2301
  byClose: z24.literal(true).optional(),
2302
+ /** Decided: who answered, when the daemon typed it (absent: answered in the terminal, or an older daemon). */
2303
+ by: DecidedBySchema.optional(),
2282
2304
  /** The agent's own session id when the hook names it, so the daemon can resume that conversation. */
2283
2305
  agentSessionId: z24.string().optional()
2284
2306
  });
@@ -2555,7 +2577,9 @@ var init_messages = __esm({
2555
2577
  note: z24.string().trim().min(1).max(NOTE_MAX).optional(),
2556
2578
  /** The request the user saw when deciding: refused (the request changed) unless it is still the pending one. Left out by older fronts and the island.
2557
2579
  * `key` (pendingKey) covers its whole content; older fronts send kind and text only. */
2558
- expect: PendingSchema.pick({ kind: true, text: true }).extend({ key: z24.string().max(64).optional() }).optional()
2580
+ expect: PendingSchema.pick({ kind: true, text: true }).extend({ key: z24.string().max(64).optional() }).optional(),
2581
+ /** Who decides: set by the phone gateway for a device (over anything the device sent); left out by a local front, which means the PC. */
2582
+ by: DecidedBySchema.optional()
2559
2583
  }),
2560
2584
  z24.object({ type: z24.literal("session.focus"), id: z24.string() }),
2561
2585
  z24.object({ type: z24.literal("session.kill"), id: z24.string() }),
@@ -2609,10 +2633,13 @@ var init_messages = __esm({
2609
2633
  z24.object({ type: z24.literal("mobile.signout") }),
2610
2634
  /** While the link is offline: try again now. */
2611
2635
  z24.object({ type: z24.literal("mobile.retry") }),
2612
- z24.object({ type: z24.literal("mobile.pairing.approve"), id: z24.string().min(1).max(100) }),
2636
+ /** `replaces`: an approved device this one takes the place of (the same app reinstalled), revoked once this one is stored. */
2637
+ z24.object({ type: z24.literal("mobile.pairing.approve"), id: z24.string().min(1).max(100), replaces: z24.string().min(1).max(100).optional() }),
2613
2638
  z24.object({ type: z24.literal("mobile.pairing.deny"), id: z24.string().min(1).max(100) }),
2614
2639
  /** `id`: the device's fingerprint. */
2615
2640
  z24.object({ type: z24.literal("mobile.device.revoke"), id: z24.string().min(1).max(100) }),
2641
+ /** Names a device (`id`: its fingerprint); the device sees its new name in its next snapshot. */
2642
+ z24.object({ type: z24.literal("mobile.device.rename"), id: z24.string().min(1).max(100), name: DeviceNameSchema }),
2616
2643
  /** « Taper dans les terminaux depuis le téléphone »; turned off, every phone loses the hand. */
2617
2644
  z24.object({ type: z24.literal("mobile.terminal"), input: z24.boolean() }),
2618
2645
  /** « Reprendre la main »: the phone typing into the session's terminal goes back to reading it. */
@@ -2869,7 +2896,9 @@ var init_push = __esm({
2869
2896
  });
2870
2897
  PushPayloadSchema = z27.object({
2871
2898
  v: z27.literal(1),
2872
- kind: z27.enum(["request", "question", "finished", "chat", "decision"]),
2899
+ /** `resolved`: a request or question answered (anywhere), replacing its notification under the same tag; only sent to a
2900
+ * device whose subscription says its service worker knows it (`push.subscribe` `resolved`). */
2901
+ kind: z27.enum(["request", "question", "finished", "chat", "decision", "resolved"]),
2873
2902
  title: z27.string().max(80),
2874
2903
  body: z27.string().max(120),
2875
2904
  url: z27.string().regex(/^\/#\/[A-Za-z0-9/%._~-]*$/).max(300),
@@ -2902,6 +2931,7 @@ var init_phone = __esm({
2902
2931
  init_messages();
2903
2932
  init_broadcast();
2904
2933
  init_push();
2934
+ init_mobile();
2905
2935
  init_models();
2906
2936
  init_commands();
2907
2937
  init_openrig();
@@ -3173,8 +3203,11 @@ var init_phone = __esm({
3173
3203
  z28.object({ type: z28.literal("openrig.row"), qitem: QitemIdSchema }),
3174
3204
  /** The phone drops its passkey from the daemon. */
3175
3205
  z28.object({ type: z28.literal("device.forget"), op: OperationIdSchema }),
3176
- /** The phone's push subscription, with the VAPID key it was made with (so a key change is seen). */
3177
- z28.object({ type: z28.literal("push.subscribe"), op: OperationIdSchema, subscription: PushSubscriptionSchema, key: B64U2.length(87) }),
3206
+ /** The device names itself (on the PC and in its next snapshot); applied to the sender only. */
3207
+ z28.object({ type: z28.literal("device.rename"), op: OperationIdSchema, name: DeviceNameSchema }),
3208
+ /** The phone's push subscription, with the VAPID key it was made with (so a key change is seen). `resolved`: its service
3209
+ * worker shows a `resolved` push, which replaces a request's notification once it is answered elsewhere. */
3210
+ z28.object({ type: z28.literal("push.subscribe"), op: OperationIdSchema, subscription: PushSubscriptionSchema, key: B64U2.length(87), resolved: z28.literal(true).optional() }),
3178
3211
  z28.object({ type: z28.literal("push.unsubscribe"), op: OperationIdSchema }),
3179
3212
  /** The phone's own chat with Ledge. */
3180
3213
  z28.object({ type: z28.literal("chat.ask"), qid: OperationIdSchema, text: z28.string().trim().max(MESSAGE_MAX), attachments: z28.array(AttachmentIdSchema).max(ATTACHMENTS_MAX).optional() }),
@@ -3266,7 +3299,16 @@ var init_phone = __esm({
3266
3299
  openrig: z28.literal(true).optional(),
3267
3300
  /** The daemon takes `session.broadcast` (« Écrire au groupe »). */
3268
3301
  broadcast: z28.literal(true).optional()
3269
- }), device: z28.object({ confirm: z28.boolean(), push: z28.enum(["on", "off", "stale", "unavailable"]).optional(), chat: z28.literal(true).optional(), contextNear: ContextPrefsSchema.shape.near.optional() }).optional() }),
3302
+ }), device: z28.object({
3303
+ confirm: z28.boolean(),
3304
+ push: z28.enum(["on", "off", "stale", "unavailable"]).optional(),
3305
+ chat: z28.literal(true).optional(),
3306
+ contextNear: ContextPrefsSchema.shape.near.optional(),
3307
+ /** This device's id (its fingerprint, as decisions name it in `by`), its kind, and the name the user gave it (absent: none given). */
3308
+ id: z28.string().optional(),
3309
+ kind: DeviceKindSchema.optional(),
3310
+ name: z28.string().optional()
3311
+ }).optional() }),
3270
3312
  /** « À toi » on this PC: the answer to `openrig.asks`, then each change, whole. */
3271
3313
  z28.object({ type: z28.literal("openrig.asks"), channel: HumanChannelSchema, asks: z28.array(HumanAskSchema), partial: z28.literal(true).optional(), truncated: z28.literal(true).optional() }),
3272
3314
  /** An answer's end, as on the desktop (`openrig.answered`), under the phone's `op`. `rejected`: the device check refused it (`reason`). */
@@ -6563,7 +6605,7 @@ var init_history = __esm({
6563
6605
  return q ? this.put(id, { ...q, status: "unknown", byClose: true }) : void 0;
6564
6606
  }
6565
6607
  const perm = [...this.items(id)].reverse().find((i) => i.kind === "permission" && i.decision === void 0);
6566
- return perm && perm.kind === "permission" ? this.put(id, { ...perm, decision: ev.decision, ...ev.byClose ? { byClose: true } : {} }) : void 0;
6608
+ return perm && perm.kind === "permission" ? this.put(id, { ...perm, decision: ev.decision, ...ev.byClose ? { byClose: true } : {}, ...ev.by ? { by: ev.by } : {} }) : void 0;
6567
6609
  }
6568
6610
  case "AgentMessage": {
6569
6611
  if (!ev.messageId || !ev.text) return void 0;
@@ -9936,7 +9978,7 @@ var LEDGE_VERSION;
9936
9978
  var init_version = __esm({
9937
9979
  "src/version.ts"() {
9938
9980
  "use strict";
9939
- LEDGE_VERSION = true ? "0.41.0" : "0.0.0-dev";
9981
+ LEDGE_VERSION = true ? "0.42.0" : "0.0.0-dev";
9940
9982
  }
9941
9983
  });
9942
9984
 
@@ -15604,7 +15646,7 @@ function toPhoneItem(item) {
15604
15646
  break;
15605
15647
  }
15606
15648
  case "permission":
15607
- out = { kind: "permission", ...base3, tool: item.tool, text: text7(item.text), ...pickSet(item, ["decision"]) };
15649
+ out = { kind: "permission", ...base3, tool: item.tool, text: text7(item.text), ...pickSet(item, ["decision", "by"]) };
15608
15650
  break;
15609
15651
  case "helper":
15610
15652
  out = { kind: "helper", ...base3, task: text7(item.task), state: item.state, ...pickSet(item, ["agentType"]) };
@@ -15962,6 +16004,15 @@ function createPhoneGateway(deps) {
15962
16004
  if (!p) return "off";
15963
16005
  return key !== void 0 && p.key !== key ? "stale" : "on";
15964
16006
  };
16007
+ const ownInfo = (deviceId) => {
16008
+ const d = mobile.deviceInfo?.(deviceId);
16009
+ return d ? { kind: d.kind, ...d.name ? { name: d.name } : {} } : {};
16010
+ };
16011
+ const decidedBy = (deviceId) => {
16012
+ const d = mobile.deviceInfo?.(deviceId);
16013
+ const name = (d?.name ?? d?.label ?? "").trim();
16014
+ return { kind: "device", id: deviceId, name: name ? clip(name, 60).text : "Appareil" };
16015
+ };
15965
16016
  const snapshot = (deviceId) => {
15966
16017
  const push3 = pushStatus(deviceId);
15967
16018
  const f = followers.get(deviceId);
@@ -15986,7 +16037,7 @@ function createPhoneGateway(deps) {
15986
16037
  ...deps.openrig ? { openrig: true } : {},
15987
16038
  broadcast: true
15988
16039
  },
15989
- device: { confirm: deps.credential(deviceId) !== null, push: push3, ...deps.chats ? { chat: true } : {}, ...deps.contextNear ? { contextNear: deps.contextNear() } : {} }
16040
+ device: { confirm: deps.credential(deviceId) !== null, push: push3, ...deps.chats ? { chat: true } : {}, ...deps.contextNear ? { contextNear: deps.contextNear() } : {}, id: deviceId, ...ownInfo(deviceId) }
15990
16041
  };
15991
16042
  return deps.models ? { ...message, daemon: { ...message.daemon, models: true } } : message;
15992
16043
  };
@@ -16116,10 +16167,10 @@ function createPhoneGateway(deps) {
16116
16167
  return { status: "rejected", reason: s.state === "exited" ? "ended" : "not_launched" };
16117
16168
  }
16118
16169
  };
16119
- const local = (msg) => {
16170
+ const local = (deviceId, msg) => {
16120
16171
  switch (msg.type) {
16121
16172
  case "session.decide":
16122
- return { type: "session.decide", id: msg.id, decision: msg.decision, ...msg.note !== void 0 ? { note: msg.note } : {}, expect: { kind: msg.expect.kind, text: msg.expect.text, key: msg.expect.key } };
16173
+ return { type: "session.decide", id: msg.id, decision: msg.decision, ...msg.note !== void 0 ? { note: msg.note } : {}, expect: { kind: msg.expect.kind, text: msg.expect.text, key: msg.expect.key }, by: decidedBy(deviceId) };
16123
16174
  case "session.reply":
16124
16175
  return { type: "session.reply", id: msg.id, text: msg.text, ...msg.attachments?.length ? { attachments: msg.attachments } : {} };
16125
16176
  case "session.answer":
@@ -16495,9 +16546,14 @@ function createPhoneGateway(deps) {
16495
16546
  }
16496
16547
  if (msg.type === "credential.register") return register(deviceId, msg);
16497
16548
  if (msg.type === "device.forget") return { status: "applied" };
16549
+ if (msg.type === "device.rename") {
16550
+ if (!mobile.rename) return { status: "rejected", reason: "unavailable" };
16551
+ mobile.rename(deviceId, msg.name);
16552
+ return { status: "applied" };
16553
+ }
16498
16554
  if (msg.type === "push.subscribe") {
16499
16555
  if (!pushEndpointAllowed(msg.subscription.endpoint)) return { status: "rejected", reason: "invalid" };
16500
- mobile.setPush(deviceId, { endpoint: msg.subscription.endpoint, p256dh: msg.subscription.keys.p256dh, auth: msg.subscription.keys.auth, key: msg.key });
16556
+ mobile.setPush(deviceId, { endpoint: msg.subscription.endpoint, p256dh: msg.subscription.keys.p256dh, auth: msg.subscription.keys.auth, key: msg.key, ...msg.resolved ? { resolved: true } : {} });
16501
16557
  return { status: "applied" };
16502
16558
  }
16503
16559
  if (msg.type === "push.unsubscribe") {
@@ -16516,7 +16572,7 @@ function createPhoneGateway(deps) {
16516
16572
  if (msg.type === "openrig.answer") return rigAnswer(deviceId, msg);
16517
16573
  const refused = precheck(deviceId, msg);
16518
16574
  if (refused) return refused;
16519
- const out = local(msg);
16575
+ const out = local(deviceId, msg);
16520
16576
  if (out.type === "session.model") return modelChange(out);
16521
16577
  let error;
16522
16578
  await deps.dispatch(out, (m) => {
@@ -16542,7 +16598,7 @@ function createPhoneGateway(deps) {
16542
16598
  }
16543
16599
  registry.finish(msg.op, outcome.status, outcome.reason, outcome.how, outcome.report, outcome.detail);
16544
16600
  result(deviceId, msg.op, outcome.status, outcome.reason, outcome.how, outcome.report);
16545
- if (msg.type === "credential.register" && outcome.status === "applied" && approved(deviceId)) push2(deviceId, snapshot(deviceId), "snapshot");
16601
+ if ((msg.type === "credential.register" || msg.type === "device.rename") && outcome.status === "applied" && approved(deviceId)) push2(deviceId, snapshot(deviceId), "snapshot");
16546
16602
  if (msg.type === "device.forget" && outcome.status === "applied") {
16547
16603
  let timer2;
16548
16604
  await Promise.race([outbox.flush(deviceId), new Promise((r) => {
@@ -17038,6 +17094,12 @@ function pushFor(before2, after2, all2) {
17038
17094
  }
17039
17095
  return null;
17040
17096
  }
17097
+ function resolvedFor(before2, after2, all2, by) {
17098
+ if (!before2?.pending || after2.pending) return null;
17099
+ const badge = Math.min(999, all2.filter((s) => s.pending).length);
17100
+ const body = !by ? "" : by.kind === "pc" ? "sur le PC" : cut(`sur ${by.name}`, BODY_MAX2);
17101
+ return { v: 1, kind: "resolved", title: cut(`${after2.name} \xB7 trait\xE9e`, TITLE_MAX), body, url: `/#/thread/${encodeURIComponent(after2.id)}`, badge, tag: safeTag(after2.id) };
17102
+ }
17041
17103
  function chatEndPayload(code3, badge) {
17042
17104
  return {
17043
17105
  v: 1,
@@ -17094,6 +17156,15 @@ function createNotifier(deps) {
17094
17156
  const { mobile, log: log2 } = deps;
17095
17157
  const now2 = deps.now ?? Date.now;
17096
17158
  const lastFinished = /* @__PURE__ */ new Map();
17159
+ const told = /* @__PURE__ */ new Map();
17160
+ const tell = (id, tag) => {
17161
+ let s = told.get(id);
17162
+ if (!s) {
17163
+ s = /* @__PURE__ */ new Set();
17164
+ told.set(id, s);
17165
+ }
17166
+ s.add(tag);
17167
+ };
17097
17168
  return {
17098
17169
  async notify(payload, sessionId2, only) {
17099
17170
  if (!mobile.state().enabled) return;
@@ -17118,6 +17189,7 @@ function createNotifier(deps) {
17118
17189
  if (only !== void 0 && d.id !== only) return [];
17119
17190
  const sub = mobile.pushOf(d.id);
17120
17191
  if (!sub || relayKey !== void 0 && sub.key !== relayKey || deps.presence(d.id)) return [];
17192
+ if (p.kind === "resolved" && (!sub.resolved || !told.get(d.id)?.has(p.tag))) return [];
17121
17193
  return [{ id: d.id, online: d.online, sub }];
17122
17194
  });
17123
17195
  if (targets.length === 0) return;
@@ -17128,10 +17200,12 @@ function createNotifier(deps) {
17128
17200
  endpoint: sub.endpoint,
17129
17201
  body: Buffer.from(encryptPush(plain4, { p256dh: sub.p256dh, auth: sub.auth }, { padTo: PAD_TO })).toString("base64url"),
17130
17202
  ttl: TTL_S,
17131
- urgency: p.kind === "finished" || p.kind === "chat" ? "normal" : "high",
17203
+ urgency: p.kind === "finished" || p.kind === "chat" || p.kind === "resolved" ? "normal" : "high",
17132
17204
  topic: p.tag
17133
17205
  };
17134
17206
  const r = await mobile.sendPush(msg);
17207
+ if (p.kind === "resolved") told.get(id)?.delete(p.tag);
17208
+ else if ((p.kind === "request" || p.kind === "question") && r.result === "sent") tell(id, p.tag);
17135
17209
  if (r.result === "gone") {
17136
17210
  log2(`push: subscription of device ${id} is gone, removed`);
17137
17211
  mobile.setPush(id, null);
@@ -17437,7 +17511,7 @@ async function startServer(opts) {
17437
17511
  }
17438
17512
  });
17439
17513
  const terminalHolders = /* @__PURE__ */ new Map();
17440
- const holdersMsg = () => ({ type: "terminal.holders", holds: [...terminalHolders].map(([id, deviceId]) => ({ id, device: mobile?.state().devices.find((d) => d.id === deviceId)?.label ?? deviceId })) });
17514
+ const holdersMsg = () => ({ type: "terminal.holders", holds: [...terminalHolders].map(([id, deviceId]) => ({ id, device: ((d) => d?.name ?? d?.label)(mobile?.state().devices.find((d) => d.id === deviceId)) ?? deviceId })) });
17441
17515
  const typing = /* @__PURE__ */ new Set();
17442
17516
  const closing = /* @__PURE__ */ new Set();
17443
17517
  const cardThreads = opts.chat ? {
@@ -17586,9 +17660,9 @@ async function startServer(opts) {
17586
17660
  if (!a || a.settled || !answeredBy(a.pending, ev) || !cur || pendingKey(cur) !== pendingKey(a.pending)) return;
17587
17661
  a.settled = true;
17588
17662
  a.byHook = true;
17589
- store.apply(id, { name: "Decided", at: now2(), source: "daemon", ...a.decision ? { decision: a.decision } : {} });
17663
+ store.apply(id, { name: "Decided", at: now2(), source: "daemon", ...a.decision ? { decision: a.decision } : {}, ...a.by ? { by: a.by } : {} });
17590
17664
  }
17591
- async function injectAndVerify(id, planFor, requirePrompt, decision) {
17665
+ async function injectAndVerify(id, planFor, requirePrompt, decision, by) {
17592
17666
  const s = store.get(id);
17593
17667
  if (!s) throw new Refusal(`unknown session ${id}`, "unknown_session");
17594
17668
  if (inFlight.has(id)) throw new Refusal("a decision is already in progress", "busy");
@@ -17614,7 +17688,7 @@ async function startServer(opts) {
17614
17688
  const plan2 = typeof planFor === "function" ? planFor(before2 ?? "") : planFor;
17615
17689
  const stillShown = (p) => decided && adapter.promptGone ? !adapter.promptGone(p, decided) : adapter.promptStillVisible(bottomLines(p));
17616
17690
  const tool = s.tool === "claude" && decided?.source === "hook" ? pendingTool(decided) : void 0;
17617
- const answer = tool ? { pending: decided, ...decision ? { decision } : {}, settled: false, byHook: false } : void 0;
17691
+ const answer = tool ? { pending: decided, ...decision ? { decision } : {}, ...by ? { by } : {}, settled: false, byHook: false } : void 0;
17618
17692
  const transcript = tool && !decided.helperId && s.transcriptPath ? s.transcriptPath : void 0;
17619
17693
  const call = transcript ? claudeOpenCall(transcript, tool) : void 0;
17620
17694
  const promptGone3 = async () => {
@@ -17635,7 +17709,7 @@ async function startServer(opts) {
17635
17709
  if (e instanceof NoteNotTyped) {
17636
17710
  if (await promptGone3()) {
17637
17711
  if (answer) answer.settled = true;
17638
- if (!answer?.byHook && stillTheSame()) store.apply(id, { name: "Decided", at: now2(), source: "daemon", ...decision ? { decision } : {} });
17712
+ if (!answer?.byHook && stillTheSame()) store.apply(id, { name: "Decided", at: now2(), source: "daemon", ...decision ? { decision } : {}, ...by ? { by } : {} });
17639
17713
  throw e;
17640
17714
  }
17641
17715
  e = new Refusal(NOT_TAKEN, "not_taken");
@@ -17649,7 +17723,7 @@ async function startServer(opts) {
17649
17723
  if (answer?.byHook) return true;
17650
17724
  if (!stillTheSame()) return false;
17651
17725
  if (gone) {
17652
- store.apply(id, { name: "Decided", at: now2(), source: "daemon", ...decision ? { decision } : {} });
17726
+ store.apply(id, { name: "Decided", at: now2(), source: "daemon", ...decision ? { decision } : {}, ...by ? { by } : {} });
17653
17727
  return true;
17654
17728
  }
17655
17729
  store.apply(id, { name: "InjectFailed", at: now2(), source: "daemon", text: NOT_TAKEN });
@@ -18344,7 +18418,7 @@ async function startServer(opts) {
18344
18418
  if (msg.decision === "always" && !offers(s.tool, pending.kind, "always")) return refuse(new Refusal(NOT_OFFERED, "not_offered"), msg.id);
18345
18419
  if (msg.note !== void 0 && (msg.decision !== "deny" || !offers(s.tool, pending.kind, "note"))) return refuse(new Refusal(NOT_OFFERED, "not_offered"), msg.id);
18346
18420
  const { decision, note } = msg;
18347
- await injectAndVerify(msg.id, (pane) => adapter.keysFor(decision, pending, pane, note), true, decision);
18421
+ await injectAndVerify(msg.id, (pane) => adapter.keysFor(decision, pending, pane, note), true, decision, msg.by ?? { kind: "pc" });
18348
18422
  return;
18349
18423
  }
18350
18424
  case "session.reply": {
@@ -18680,10 +18754,22 @@ async function startServer(opts) {
18680
18754
  const badge = store.all().filter((s) => s.pending).length + tasks.all().filter(openDecision).length;
18681
18755
  void notifier.notify(decisionPayload(task, badge), void 0).catch((e) => log(`push failed (${e instanceof Error ? e.name : "error"})`));
18682
18756
  };
18683
- unsubscribePush = store.onApply((before2, after2) => {
18757
+ const answeredBy2 = /* @__PURE__ */ new Map();
18758
+ const unsubscribeDecided = store.onEvent((id, ev) => {
18759
+ if (ev.name === "Decided") answeredBy2.set(id, ev.by ?? null);
18760
+ });
18761
+ const unsubscribeApply = store.onApply((before2, after2) => {
18762
+ const failed = (e) => log(`push failed (${e instanceof Error ? e.name : "error"})`);
18763
+ const r = resolvedFor(before2, after2, store.all(), answeredBy2.get(after2.id));
18764
+ if (before2?.pending && !after2.pending) answeredBy2.delete(after2.id);
18765
+ if (r) void notifier.notify(r, after2.id).catch(failed);
18684
18766
  const p = pushFor(before2, after2, store.all());
18685
- if (p) void notifier.notify(p, after2.id).catch((e) => log(`push failed (${e instanceof Error ? e.name : "error"})`));
18767
+ if (p) void notifier.notify(p, after2.id).catch(failed);
18686
18768
  });
18769
+ unsubscribePush = () => {
18770
+ unsubscribeDecided();
18771
+ unsubscribeApply();
18772
+ };
18687
18773
  }
18688
18774
  wss.on("connection", (ws, req) => {
18689
18775
  ws.on("error", (e) => log(`socket error: ${e.message}`));
@@ -19810,7 +19896,7 @@ function readRevocations() {
19810
19896
  function writeRevocations(list3) {
19811
19897
  writePrivate(paths().revocations, JSON.stringify(z31.array(RevocationSchema).parse(list3), null, 2) + "\n");
19812
19898
  }
19813
- var RelaySessionSchema, DeviceCredentialSchema, ApprovedDeviceSchema, DEFAULT_CONFIG, LEGACY_RELAY_URL, RevocationSchema;
19899
+ var RelaySessionSchema, DeviceCredentialSchema, MAX_DEVICE_PEERS, ApprovedDeviceSchema, DEFAULT_CONFIG, LEGACY_RELAY_URL, RevocationSchema;
19814
19900
  var init_files2 = __esm({
19815
19901
  "src/mobile/files.ts"() {
19816
19902
  "use strict";
@@ -19827,6 +19913,7 @@ var init_files2 = __esm({
19827
19913
  rpId: z31.string().min(1),
19828
19914
  transports: z31.array(z31.string()).optional()
19829
19915
  });
19916
+ MAX_DEVICE_PEERS = 8;
19830
19917
  ApprovedDeviceSchema = z31.object({
19831
19918
  id: z31.string().min(1),
19832
19919
  publicKey: z31.string().min(1),
@@ -19835,8 +19922,14 @@ var init_files2 = __esm({
19835
19922
  lastPeer: z31.string().optional(),
19836
19923
  lastSeenAt: z31.string().optional(),
19837
19924
  credential: DeviceCredentialSchema.optional(),
19838
- /** The phone's push subscription and the relay's VAPID key it was made with. */
19839
- push: z31.object({ endpoint: z31.string().min(1), p256dh: z31.string().min(1), auth: z31.string().min(1), key: z31.string().min(1) }).optional()
19925
+ /** The phone's push subscription and the relay's VAPID key it was made with; `resolved`: its service worker shows a `resolved` push. */
19926
+ push: z31.object({ endpoint: z31.string().min(1), p256dh: z31.string().min(1), auth: z31.string().min(1), key: z31.string().min(1), resolved: z31.literal(true).optional() }).optional(),
19927
+ /** Phone or tablet, from the pairing label; absent on a device approved before it was kept. */
19928
+ kind: DeviceKindSchema.optional(),
19929
+ /** The name the user gave it; absent means the default name the fronts derive. */
19930
+ name: DeviceNameSchema.optional(),
19931
+ /** Every relay peer that confirmed a channel for it, newest last (at most MAX_DEVICE_PEERS): all are revoked with it. */
19932
+ peers: z31.array(z31.string().min(1)).max(MAX_DEVICE_PEERS).optional()
19840
19933
  });
19841
19934
  DEFAULT_CONFIG = () => ({ enabled: false, relayUrl: DEFAULT_RELAY_URL });
19842
19935
  LEGACY_RELAY_URL = "https://server.ledge.paradow.dev";
@@ -20480,6 +20573,10 @@ var init_channels = __esm({
20480
20573
  });
20481
20574
 
20482
20575
  // src/mobile/service.ts
20576
+ function peersOf(d) {
20577
+ const list3 = d.peers ?? [];
20578
+ return d.lastPeer && !list3.includes(d.lastPeer) ? [...list3, d.lastPeer] : list3;
20579
+ }
20483
20580
  async function createMobileService(opts = {}) {
20484
20581
  const now2 = opts.now ?? Date.now;
20485
20582
  const fetchImpl = opts.fetchImpl ?? fetch;
@@ -20510,6 +20607,13 @@ async function createMobileService(opts = {}) {
20510
20607
  const sendFrame = (peer, frame) => {
20511
20608
  link3?.send({ type: "send", to: peer, body: JSON.stringify(frame) });
20512
20609
  };
20610
+ const withPeer = (d, peer) => [...peersOf(d).filter((p) => p !== peer), peer].slice(-MAX_DEVICE_PEERS);
20611
+ const withoutPeer = (d, peer) => {
20612
+ if (d.lastPeer !== peer && !d.peers?.includes(peer)) return d;
20613
+ const { lastPeer, peers, ...rest } = d;
20614
+ const left2 = (peers ?? []).filter((p) => p !== peer);
20615
+ return { ...rest, ...lastPeer && lastPeer !== peer ? { lastPeer } : {}, ...left2.length > 0 ? { peers: left2 } : {} };
20616
+ };
20513
20617
  const saveDevices = (next4) => {
20514
20618
  writeDevices(next4);
20515
20619
  devices = next4;
@@ -20519,7 +20623,8 @@ async function createMobileService(opts = {}) {
20519
20623
  send: sendFrame,
20520
20624
  approve(d) {
20521
20625
  const old = devices.find((x) => x.id === d.id);
20522
- saveDevices(old ? devices.map((x) => x.id === d.id ? { ...x, label: d.label } : x) : [...devices, d]);
20626
+ const kind = deviceKindOfLabel(d.label);
20627
+ saveDevices(old ? devices.map((x) => x.id === d.id ? { ...x, label: d.label, kind } : x) : [...devices, { ...d, kind }]);
20523
20628
  },
20524
20629
  onChange: changed,
20525
20630
  now: now2,
@@ -20533,7 +20638,7 @@ async function createMobileService(opts = {}) {
20533
20638
  if (devices.some((d) => d.id === deviceId)) {
20534
20639
  const at = new Date(now2()).toISOString();
20535
20640
  try {
20536
- saveDevices(devices.map((d) => d.id === deviceId ? { ...d, lastPeer: peer, lastSeenAt: at } : d));
20641
+ saveDevices(devices.map((d) => d.id === deviceId ? { ...d, lastPeer: peer, lastSeenAt: at, peers: withPeer(d, peer) } : withoutPeer(d, peer)));
20537
20642
  } catch (e) {
20538
20643
  log(`mobile: devices.json not written (${e.message})`);
20539
20644
  }
@@ -20736,9 +20841,17 @@ async function createMobileService(opts = {}) {
20736
20841
  saveDevices(devices.filter((x) => x.id !== id));
20737
20842
  log(`mobile: revoked device ${id}`);
20738
20843
  channels.dropDevice(id);
20739
- if (d.lastPeer) sendFrame(d.lastPeer, { k: "denied" });
20844
+ const others = new Set(devices.flatMap(peersOf));
20845
+ const peers = peersOf(d).filter((p) => !others.has(p));
20846
+ for (const p of peers) sendFrame(p, { k: "denied" });
20847
+ changed();
20848
+ await Promise.all(peers.map((p) => revokeOnRelay(p)));
20849
+ };
20850
+ const rename7 = (id, raw) => {
20851
+ const name = DeviceNameSchema.parse(raw);
20852
+ if (!devices.some((d) => d.id === id)) throw new Error("no such device");
20853
+ saveDevices(devices.map((d) => d.id === id ? { ...d, name } : d));
20740
20854
  changed();
20741
- if (d.lastPeer) await revokeOnRelay(d.lastPeer);
20742
20855
  };
20743
20856
  const pairingIn = (id, states) => {
20744
20857
  const p = pairing.views().find((v2) => v2.id === id);
@@ -20760,13 +20873,20 @@ async function createMobileService(opts = {}) {
20760
20873
  label: d.label,
20761
20874
  approvedAt: d.approvedAt,
20762
20875
  online: online.has(d.id),
20763
- ...d.lastSeenAt ? { lastSeenAt: d.lastSeenAt } : {}
20876
+ ...d.lastSeenAt ? { lastSeenAt: d.lastSeenAt } : {},
20877
+ kind: d.kind ?? deviceKindOfLabel(d.label),
20878
+ ...d.name ? { name: d.name } : {}
20764
20879
  })),
20765
20880
  pairings: pairing.views(),
20766
20881
  terminalInput: config.terminalInput === true
20767
20882
  };
20768
20883
  },
20769
20884
  revoke,
20885
+ rename: rename7,
20886
+ deviceInfo(deviceId) {
20887
+ const d = devices.find((x) => x.id === deviceId);
20888
+ return d ? { label: d.label, kind: d.kind ?? deviceKindOfLabel(d.label), ...d.name ? { name: d.name } : {} } : null;
20889
+ },
20770
20890
  credential(deviceId) {
20771
20891
  const c = devices.find((d) => d.id === deviceId)?.credential;
20772
20892
  return c && c.rpId === rpOf(config.relayUrl)?.rpId ? c : null;
@@ -20867,17 +20987,24 @@ async function createMobileService(opts = {}) {
20867
20987
  case "mobile.retry":
20868
20988
  link3?.retryNow();
20869
20989
  return;
20870
- case "mobile.pairing.approve":
20990
+ case "mobile.pairing.approve": {
20871
20991
  pairingIn(msg.id, ["confirm"]);
20872
20992
  if (linkState.state !== "online") throw new Error("the relay is offline");
20993
+ const before2 = new Set(devices.map((d) => d.id));
20873
20994
  pairing.approve(msg.id);
20995
+ const added = devices.some((d) => !before2.has(d.id));
20996
+ if (msg.replaces !== void 0 && added && before2.has(msg.replaces)) await revoke(msg.replaces);
20874
20997
  return;
20998
+ }
20875
20999
  case "mobile.pairing.deny":
20876
21000
  pairingIn(msg.id, ["confirm", "waiting"]);
20877
21001
  pairing.deny(msg.id);
20878
21002
  return;
20879
21003
  case "mobile.device.revoke":
20880
21004
  return revoke(msg.id);
21005
+ case "mobile.device.rename":
21006
+ rename7(msg.id, msg.name);
21007
+ return;
20881
21008
  }
20882
21009
  },
20883
21010
  send: (deviceId, text7) => channels.send(deviceId, text7),
@@ -243,6 +243,10 @@ var TaskBlockSchema = z5.object({
243
243
  // ../protocol/src/decisions.ts
244
244
  import { z as z6 } from "zod";
245
245
  var DecisionSchema = z6.enum(["accept", "deny", "always"]);
246
+ var DecidedBySchema = z6.discriminatedUnion("kind", [
247
+ z6.object({ kind: z6.literal("pc") }),
248
+ z6.object({ kind: z6.literal("device"), id: z6.string().min(1).max(100), name: z6.string().min(1).max(60) })
249
+ ]);
246
250
  var NOTE_MAX = 4e3;
247
251
 
248
252
  // ../protocol/src/tasks.ts
@@ -1402,8 +1406,8 @@ var ThreadItemSchema = z22.discriminatedUnion("kind", [
1402
1406
  imagesDropped: z22.literal(true).optional()
1403
1407
  }),
1404
1408
  /** `unknown`: answered in the terminal, the daemon only saw the prompt go away; `always`: allowed and not asked again;
1405
- * `byClose`: denied by Ledge closing the session. */
1406
- z22.object({ kind: z22.literal("permission"), ...base, tool: z22.string(), text: z22.string(), decision: z22.enum(["accept", "deny", "always", "unknown"]).optional(), byClose: z22.literal(true).optional() }),
1409
+ * `byClose`: denied by Ledge closing the session; `by`: who answered it from Ledge (the PC or a device, its name then). */
1410
+ z22.object({ kind: z22.literal("permission"), ...base, tool: z22.string(), text: z22.string(), decision: z22.enum(["accept", "deny", "always", "unknown"]).optional(), byClose: z22.literal(true).optional(), by: DecidedBySchema.optional() }),
1407
1411
  z22.object({ kind: z22.literal("reply"), ...base, text: z22.string() }),
1408
1412
  z22.object({ kind: z22.literal("helper"), ...base, task: z22.string(), agentType: z22.string().optional(), state: z22.enum(["working", "exited"]) }),
1409
1413
  /** A message this session sent to another agent; `id` is the tool_use_id, `to` the address it used, `peer` the Ledge session id, `received` the paired item's id in the peer's thread, `via` set when Ledge's mailbox carries it. */
@@ -1486,6 +1490,9 @@ var MobileLinkStateSchema = z23.discriminatedUnion("state", [
1486
1490
  /** The mobile service did not start (a broken mobile.json, a missing key store): nothing is linked until the daemon restarts. */
1487
1491
  z23.object({ state: z23.literal("unavailable"), error: z23.string().max(200) })
1488
1492
  ]);
1493
+ var DeviceKindSchema = z23.enum(["phone", "tablet"]);
1494
+ var DEVICE_NAME_MAX = 40;
1495
+ var DeviceNameSchema = z23.string().trim().min(1).max(DEVICE_NAME_MAX).regex(/^[^\u0000-\u001f\u007f]+$/);
1489
1496
  var MobilePairingSchema = z23.object({
1490
1497
  id: z23.string(),
1491
1498
  label: z23.string(),
@@ -1501,7 +1508,16 @@ var MobileStateSchema = z23.object({
1501
1508
  /** The last sign-in that did not end with a session; cleared by the next one. */
1502
1509
  signinError: z23.enum(["expired", "denied", "not_allowed", "failed"]).nullable(),
1503
1510
  link: MobileLinkStateSchema,
1504
- devices: z23.array(z23.object({ id: z23.string(), label: z23.string(), approvedAt: z23.string(), online: z23.boolean(), lastSeenAt: z23.string().optional() })),
1511
+ devices: z23.array(z23.object({
1512
+ id: z23.string(),
1513
+ label: z23.string(),
1514
+ approvedAt: z23.string(),
1515
+ online: z23.boolean(),
1516
+ lastSeenAt: z23.string().optional(),
1517
+ /** Absent from an older daemon; `name` absent: the user gave none, the front derives one from the label. */
1518
+ kind: DeviceKindSchema.optional(),
1519
+ name: z23.string().optional()
1520
+ })),
1505
1521
  pairings: z23.array(MobilePairingSchema),
1506
1522
  /** « Taper dans les terminaux depuis le téléphone »: absent from an older daemon, which has no phone terminal. */
1507
1523
  terminalInput: z23.boolean().optional()
@@ -1556,6 +1572,8 @@ var SessionEventSchema = z24.object({
1556
1572
  decision: DecisionSchema.optional(),
1557
1573
  /** Decided: the request was refused by Ledge closing the session (its interrupt keys), not by the user's answer. */
1558
1574
  byClose: z24.literal(true).optional(),
1575
+ /** Decided: who answered, when the daemon typed it (absent: answered in the terminal, or an older daemon). */
1576
+ by: DecidedBySchema.optional(),
1559
1577
  /** The agent's own session id when the hook names it, so the daemon can resume that conversation. */
1560
1578
  agentSessionId: z24.string().optional()
1561
1579
  });
@@ -1832,7 +1850,9 @@ var ClientMessageSchema = z24.discriminatedUnion("type", [
1832
1850
  note: z24.string().trim().min(1).max(NOTE_MAX).optional(),
1833
1851
  /** The request the user saw when deciding: refused (the request changed) unless it is still the pending one. Left out by older fronts and the island.
1834
1852
  * `key` (pendingKey) covers its whole content; older fronts send kind and text only. */
1835
- expect: PendingSchema.pick({ kind: true, text: true }).extend({ key: z24.string().max(64).optional() }).optional()
1853
+ expect: PendingSchema.pick({ kind: true, text: true }).extend({ key: z24.string().max(64).optional() }).optional(),
1854
+ /** Who decides: set by the phone gateway for a device (over anything the device sent); left out by a local front, which means the PC. */
1855
+ by: DecidedBySchema.optional()
1836
1856
  }),
1837
1857
  z24.object({ type: z24.literal("session.focus"), id: z24.string() }),
1838
1858
  z24.object({ type: z24.literal("session.kill"), id: z24.string() }),
@@ -1886,10 +1906,13 @@ var ClientMessageSchema = z24.discriminatedUnion("type", [
1886
1906
  z24.object({ type: z24.literal("mobile.signout") }),
1887
1907
  /** While the link is offline: try again now. */
1888
1908
  z24.object({ type: z24.literal("mobile.retry") }),
1889
- z24.object({ type: z24.literal("mobile.pairing.approve"), id: z24.string().min(1).max(100) }),
1909
+ /** `replaces`: an approved device this one takes the place of (the same app reinstalled), revoked once this one is stored. */
1910
+ z24.object({ type: z24.literal("mobile.pairing.approve"), id: z24.string().min(1).max(100), replaces: z24.string().min(1).max(100).optional() }),
1890
1911
  z24.object({ type: z24.literal("mobile.pairing.deny"), id: z24.string().min(1).max(100) }),
1891
1912
  /** `id`: the device's fingerprint. */
1892
1913
  z24.object({ type: z24.literal("mobile.device.revoke"), id: z24.string().min(1).max(100) }),
1914
+ /** Names a device (`id`: its fingerprint); the device sees its new name in its next snapshot. */
1915
+ z24.object({ type: z24.literal("mobile.device.rename"), id: z24.string().min(1).max(100), name: DeviceNameSchema }),
1893
1916
  /** « Taper dans les terminaux depuis le téléphone »; turned off, every phone loses the hand. */
1894
1917
  z24.object({ type: z24.literal("mobile.terminal"), input: z24.boolean() }),
1895
1918
  /** « Reprendre la main »: the phone typing into the session's terminal goes back to reading it. */
@@ -2022,7 +2045,9 @@ var PushSubscriptionSchema = z27.object({
2022
2045
  });
2023
2046
  var PushPayloadSchema = z27.object({
2024
2047
  v: z27.literal(1),
2025
- kind: z27.enum(["request", "question", "finished", "chat", "decision"]),
2048
+ /** `resolved`: a request or question answered (anywhere), replacing its notification under the same tag; only sent to a
2049
+ * device whose subscription says its service worker knows it (`push.subscribe` `resolved`). */
2050
+ kind: z27.enum(["request", "question", "finished", "chat", "decision", "resolved"]),
2026
2051
  title: z27.string().max(80),
2027
2052
  body: z27.string().max(120),
2028
2053
  url: z27.string().regex(/^\/#\/[A-Za-z0-9/%._~-]*$/).max(300),
@@ -2289,8 +2314,11 @@ var PhoneClientMessageSchema = z28.discriminatedUnion("type", [
2289
2314
  z28.object({ type: z28.literal("openrig.row"), qitem: QitemIdSchema }),
2290
2315
  /** The phone drops its passkey from the daemon. */
2291
2316
  z28.object({ type: z28.literal("device.forget"), op: OperationIdSchema }),
2292
- /** The phone's push subscription, with the VAPID key it was made with (so a key change is seen). */
2293
- z28.object({ type: z28.literal("push.subscribe"), op: OperationIdSchema, subscription: PushSubscriptionSchema, key: B64U2.length(87) }),
2317
+ /** The device names itself (on the PC and in its next snapshot); applied to the sender only. */
2318
+ z28.object({ type: z28.literal("device.rename"), op: OperationIdSchema, name: DeviceNameSchema }),
2319
+ /** The phone's push subscription, with the VAPID key it was made with (so a key change is seen). `resolved`: its service
2320
+ * worker shows a `resolved` push, which replaces a request's notification once it is answered elsewhere. */
2321
+ z28.object({ type: z28.literal("push.subscribe"), op: OperationIdSchema, subscription: PushSubscriptionSchema, key: B64U2.length(87), resolved: z28.literal(true).optional() }),
2294
2322
  z28.object({ type: z28.literal("push.unsubscribe"), op: OperationIdSchema }),
2295
2323
  /** The phone's own chat with Ledge. */
2296
2324
  z28.object({ type: z28.literal("chat.ask"), qid: OperationIdSchema, text: z28.string().trim().max(MESSAGE_MAX), attachments: z28.array(AttachmentIdSchema).max(ATTACHMENTS_MAX).optional() }),
@@ -2382,7 +2410,16 @@ var PhoneServerMessageSchema = z28.discriminatedUnion("type", [
2382
2410
  openrig: z28.literal(true).optional(),
2383
2411
  /** The daemon takes `session.broadcast` (« Écrire au groupe »). */
2384
2412
  broadcast: z28.literal(true).optional()
2385
- }), device: z28.object({ confirm: z28.boolean(), push: z28.enum(["on", "off", "stale", "unavailable"]).optional(), chat: z28.literal(true).optional(), contextNear: ContextPrefsSchema.shape.near.optional() }).optional() }),
2413
+ }), device: z28.object({
2414
+ confirm: z28.boolean(),
2415
+ push: z28.enum(["on", "off", "stale", "unavailable"]).optional(),
2416
+ chat: z28.literal(true).optional(),
2417
+ contextNear: ContextPrefsSchema.shape.near.optional(),
2418
+ /** This device's id (its fingerprint, as decisions name it in `by`), its kind, and the name the user gave it (absent: none given). */
2419
+ id: z28.string().optional(),
2420
+ kind: DeviceKindSchema.optional(),
2421
+ name: z28.string().optional()
2422
+ }).optional() }),
2386
2423
  /** « À toi » on this PC: the answer to `openrig.asks`, then each change, whole. */
2387
2424
  z28.object({ type: z28.literal("openrig.asks"), channel: HumanChannelSchema, asks: z28.array(HumanAskSchema), partial: z28.literal(true).optional(), truncated: z28.literal(true).optional() }),
2388
2425
  /** An answer's end, as on the desktop (`openrig.answered`), under the phone's `op`. `rejected`: the device check refused it (`reason`). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paradow/ledge",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "description": "Follow and answer your WSL agent sessions (Claude Code, Codex, Cursor Agent) from the Ledge island on Windows.",
5
5
  "type": "module",
6
6
  "bin": { "ledge": "dist/cli/index.js" },