dsh-deeppilot 0.6.1 → 0.6.2

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/COMPATIBILITY.md CHANGED
@@ -7,7 +7,7 @@ This file separates tested evidence from intended behavior. Passing unit tests d
7
7
  | Component | Baseline | Evidence |
8
8
  |---|---|---|
9
9
  | Node.js | 22 or newer | package engine and CI |
10
- | DSH CLI and Host API | `0.1.2-rc.1` minimum for plugin `0.6.0-alpha.4` | rc.1 package-family typecheck/build; bridge and `/phone` protocol tests |
10
+ | DSH CLI and Host API | `0.1.5-rc.1` minimum for plugin `0.6.x` | rc.1 package-family typecheck/build; bridge and `/phone` protocol tests |
11
11
  | Host OS | macOS, Linux, Windows on packaged amd64/arm64 helper targets | helper checksums plus user-confirmed Windows/Linux Funnel launch and connection; local LAN validation remains part of alpha testing |
12
12
  | Remote access | Tailscale Funnel, ports 443/8443/10000 | helper and supervisor tests |
13
13
  | iOS | native DeepPilot client, protocol v2 | simulator build and v2 pairing/challenge evidence |
@@ -18,8 +18,8 @@ This file separates tested evidence from intended behavior. Passing unit tests d
18
18
  - Bearer authentication, URL credentials, and first-frame shared tokens are rejected. A supported client registers a P-256 public key through `/phone/pair` and signs each WebSocket challenge.
19
19
  - Without a compatible embedded helper, the core bridge and trusted-LAN mode can still run; remote Funnel reports `unavailable`.
20
20
  - If a DSH Host API is missing, only the dependent capability should be disabled. The plugin must not crash the Host.
21
- - Plugin `0.6.0-alpha.4` requires DSH `0.1.2-rc.1` or newer. Older plugin
22
- alphas used developer-preview DSH builds and remain historical artifacts.
21
+ - Plugin `0.6.x` requires DSH `0.1.5-rc.1` or newer. Older plugin
22
+ versions used developer-preview DSH builds and remain historical artifacts.
23
23
  - The compatibility facade converts the
24
24
  current Session controller's raw event arrays into the stable wrapped
25
25
  history entries consumed by the phone bridge. Sessions persisted by earlier
@@ -31,7 +31,7 @@ This file separates tested evidence from intended behavior. Passing unit tests d
31
31
 
32
32
  - Intel macOS support for the embedded helper;
33
33
  - signed/notarized helper distribution;
34
- - repair or migration of persisted history rejected by the `0.1.2-rc.1`
34
+ - repair or migration of persisted history rejected by the `0.1.5-rc.1`
35
35
  Host's own session reader;
36
36
  - every DSH developer-preview revision;
37
37
  - physical-device performance and every carrier/network combination;
package/PRIVACY.md CHANGED
@@ -7,7 +7,7 @@ DeepPilot connects directly to the DSH Host controlled by the user. The plugin d
7
7
  By default, runtime state is stored under `$DSH_HOME/deeppilot/`:
8
8
 
9
9
  - `host-id`: a random, non-secret stable audience identifier;
10
- - `devices-v2.json`: paired public keys, fingerprints, scopes, device metadata, revocation/last-seen timestamps, notification preferences, APNs alert registrations, and WidgetKit push registrations (a separate token per device used only for content-free widget refresh invalidations);
10
+ - `devices-v2.json`: paired public keys, fingerprints, scopes, device metadata, revocation/last-seen timestamps, notification preferences, APNs alert registrations, WidgetKit push registrations (a separate token per device used only for content-free widget refresh invalidations), and at most one Live Activity registration per device (activity identifier, session identifier, APNs Live Activity update token, environment, and expiry);
11
11
  - `tailscale/`: local state for the optional embedded tsnet node.
12
12
 
13
13
  The directory is owner-only mode `0700`. The Mac never receives or stores iPhone private signing keys. Pairing codes exist only in plugin memory for up to five minutes and are not written to disk.
@@ -35,6 +35,14 @@ The relay does not receive complete history, attachments, live token streams, un
35
35
 
36
36
  The iOS home-screen widget may register a separate WidgetKit push token (iOS 26+). These invalidations are content-free: the APNs payload is fixed to `{ "aps": { "content-changed": true } }` with no session, title, or body fields, so neither Apple nor the relay learns anything about Host activity beyond "something changed, please refetch". The widget then pulls data over its own short-lived, authenticated read-only connection directly to the user's DSH Host. Widget tokens are stored alongside the alert token in `devices-v2.json`, are throttled to at most one invalidation per Host per 30 seconds, expire after 7 days without re-registration, and are deleted when the device is revoked.
37
37
 
38
+ ## Live Activity push
39
+
40
+ A Live Activity on the Lock Screen or in the Dynamic Island may register a separate ActivityKit **update** token. Unlike the WidgetKit path, Live Activity updates are **not content-free**: each push carries a bounded progress projection — the session title (up to 100 Unicode code points), the current in-progress or pending todo item's text (up to 160 code points), the todo done/total counts, and a fixed phase value (`running`, `approval`, `question`, `ended`, or `unavailable`). This projection goes to Apple in direct APNs mode and to the relay in relay mode. It never contains conversation history, message bodies, prompt text, attachments, full todo lists, file paths, or model output.
41
+
42
+ The activity must be started by the user on the iPhone; there is no push-to-start, and the activity opens no network connection of its own. Only one Live Activity registration is kept per device, registrations expire after eight hours, and the registration is deleted on unregister, token rotation, or device revocation. Relay mode carries only this bounded projection and rejects unknown content-state fields, oversized text, invalid counts, stale timestamps, and event/phase mismatches; the updated relay must be deployed before background Live Activity updates work through relay mode. System delivery and refresh budgets still apply, so updates may be coalesced, delayed, or dropped by iOS.
43
+
44
+ `push.contentMode: generic` applies to alert notifications only. Live Activity content states always carry the bounded title and current-task text described above, because the activity is rendered from this push; do not register a Live Activity on a device whose Lock Screen should never show session text.
45
+
38
46
  ## Logs
39
47
 
40
48
  Routine logs contain lifecycle state, delivery outcomes, and process-local salted hashes for source/device identifiers. Logs are designed not to contain pairing codes, key material, APNs tokens, or message bodies. Frame diagnostics appear only with `debug`.
package/README.md CHANGED
@@ -37,21 +37,21 @@ does not require the helper.
37
37
 
38
38
  | Plugin version | Required DSH | How to install |
39
39
  |---|---|---|
40
- | `0.6.0-alpha.4` (new, `alpha` tag) | DSH `0.1.2-rc.1` or newer | `dsh plugin --profile web add dsh-deeppilot@alpha` |
41
- | `0.5.x` (previous stable, `latest`) | DSH `0.1.1-rc.2`–`0.1.2-alpha.1` | `dsh plugin --profile web add dsh-deeppilot` |
40
+ | `0.6.x` (current stable, `latest`) | DSH `0.1.5-rc.1` or newer | `dsh plugin --profile web add dsh-deeppilot` |
41
+ | `0.5.x` (previous stable) | DSH `0.1.1-rc.2`–`0.1.2-alpha.1` | `dsh plugin --profile web add dsh-deeppilot@0.5.0` |
42
42
 
43
- `0.6.0-alpha.4` is built and typechecked against the DSH
44
- [0.1.2-rc.1](https://www.npmjs.com/package/@deepseek-ai/dsh/v/0.1.2-rc.1)
43
+ `0.6.x` is built and typechecked against the DSH
44
+ [0.1.5-rc.1](https://www.npmjs.com/package/@deepseek-ai/dsh/v/0.1.5-rc.1)
45
45
  Host and client package family. It uses the Gateway multi-client Remote Events
46
46
  routing that keeps Web and DeepPilot independently answerable. Earlier plugin
47
- alphas remain historical artifacts; users installing the current `alpha` tag
48
- should update DSH to `0.1.2-rc.1` or newer.
47
+ alphas remain historical artifacts; users installing the `alpha` tag for
48
+ pre-release testing should update DSH to `0.1.5-rc.1` or newer.
49
49
 
50
50
  ```sh
51
- # DSH 0.1.2-rc.1 or newer (recommended):
52
- dsh plugin --profile web add dsh-deeppilot@alpha
53
- # DSH 0.1.1-rc.2 through 0.1.2-alpha.1 (previous stable):
51
+ # DSH 0.1.5-rc.1 or newer (recommended):
54
52
  dsh plugin --profile web add dsh-deeppilot
53
+ # DSH 0.1.1-rc.2 through 0.1.2-alpha.1 (previous stable):
54
+ dsh plugin --profile web add dsh-deeppilot@0.5.0
55
55
  dsh web
56
56
  ```
57
57
 
@@ -78,33 +78,33 @@ DeepPilot state under `$DSH_HOME/deeppilot/`.
78
78
 
79
79
  ## Publishing (maintainers)
80
80
 
81
- `0.6.0-alpha.4` targets DSH `0.1.2-rc.1`+; `0.5.x` stays compatible with
81
+ `0.6.x` targets DSH `0.1.5-rc.1`+; `0.5.x` stays compatible with
82
82
  DSH `0.1.1-rc.2`–`0.1.2-alpha.1`. Keep both published:
83
83
 
84
84
  1. Bump `version` in `package.json` and in the root `""` entry of
85
85
  `package-lock.json`, then run `npm test && npm run typecheck && npm run build`
86
86
  and inspect `npm pack --dry-run --json` (the check
87
- `tests/compatibility-metadata.test.ts` enforces the `^0.1.2-rc.1` peer
87
+ `tests/compatibility-metadata.test.ts` enforces the `^0.1.5-rc.1` peer
88
88
  ranges).
89
89
  2. Commit the release and push it. `npm publish` runs `prepack` (build) and
90
90
  `prepublishOnly` (test + typecheck) automatically.
91
- 3. Publish the alpha line without touching `latest`:
91
+ 3. Publish pre-releases without touching `latest`:
92
92
 
93
93
  ```sh
94
94
  npm publish --tag alpha
95
95
  ```
96
96
 
97
97
  After a successful publish, `npm view dsh-deeppilot dist-tags --json` shows
98
- `"latest": "0.5.x"` and `"alpha": "0.6.0-alpha.x"`. Verify the published
99
- package by installing it into a DSH `0.1.2-rc.1` profile before pointing
98
+ `"latest": "0.6.x"` and `"alpha": "0.6.x-alpha.y"`. Verify the published
99
+ package by installing it into a DSH `0.1.5-rc.1` profile before pointing
100
100
  users at it.
101
- 4. Tag the release commit `v0.6.0-alpha.x` and prepare a GitHub Release
101
+ 4. Tag the release commit `vX.Y.Z` and prepare a GitHub Release
102
102
  (English + 简体中文 notes) that links this README section.
103
- 5. When the alpha graduates to stable, bump to `0.6.0` and publish with
104
- `npm publish --tag latest`, which moves `latest` to the new line. Stable
105
- releases must never be published with `--tag alpha`.
103
+ 5. Publish stable releases with `npm publish --tag latest`, which moves
104
+ `latest` to the new version. Stable releases must never be published
105
+ with `--tag alpha`.
106
106
 
107
- Never run `npm publish` from a copy that still has the old `0.5.x` version.
107
+ Never run `npm publish` from a copy that still has an older version.
108
108
 
109
109
 
110
110
  ## Connection and privacy
package/README.zh-CN.md CHANGED
@@ -34,20 +34,20 @@
34
34
 
35
35
  | 插件版本 | 所需 DSH | 安装命令 |
36
36
  |---|---|---|
37
- | `0.6.0-alpha.4`(新版,`alpha` tag) | DSH `0.1.2-rc.1` 或更高 | `dsh plugin --profile web add dsh-deeppilot@alpha` |
38
- | `0.5.x`(旧版稳定版,`latest`) | DSH `0.1.1-rc.2`–`0.1.2-alpha.1` | `dsh plugin --profile web add dsh-deeppilot` |
37
+ | `0.6.x`(当前稳定版,`latest`) | DSH `0.1.5-rc.1` 或更高 | `dsh plugin --profile web add dsh-deeppilot` |
38
+ | `0.5.x`(旧版稳定版) | DSH `0.1.1-rc.2`–`0.1.2-alpha.1` | `dsh plugin --profile web add dsh-deeppilot@0.5.0` |
39
39
 
40
- `0.6.0-alpha.4` 基于 DSH
41
- [0.1.2-rc.1](https://www.npmjs.com/package/@deepseek-ai/dsh/v/0.1.2-rc.1)
40
+ `0.6.x` 基于 DSH
41
+ [0.1.5-rc.1](https://www.npmjs.com/package/@deepseek-ai/dsh/v/0.1.5-rc.1)
42
42
  的 Host 与 client 包族构建并完成类型检查。它使用 Gateway 多 Client Remote Events
43
43
  路由,使 Web 与 DeepPilot 能独立处理同一交互。旧 alpha 仅保留为历史产物;安装
44
- 当前 `alpha` tag 的用户应先把 DSH 更新到 `0.1.2-rc.1` 或更高版本。
44
+ `alpha` tag 进行预发布测试的用户应先把 DSH 更新到 `0.1.5-rc.1` 或更高版本。
45
45
 
46
46
  ```sh
47
- # DSH 0.1.2-rc.1 或更高(推荐):
48
- dsh plugin --profile web add dsh-deeppilot@alpha
49
- # DSH 0.1.1-rc.2 至 0.1.2-alpha.1(旧版稳定):
47
+ # DSH 0.1.5-rc.1 或更高(推荐):
50
48
  dsh plugin --profile web add dsh-deeppilot
49
+ # DSH 0.1.1-rc.2 至 0.1.2-alpha.1(旧版稳定):
50
+ dsh plugin --profile web add dsh-deeppilot@0.5.0
51
51
  dsh web
52
52
  ```
53
53
 
@@ -71,30 +71,30 @@ dsh plugin --profile web remove dsh-deeppilot
71
71
 
72
72
  ## 发布说明(维护者)
73
73
 
74
- `0.6.0-alpha.4` 面向 DSH `0.1.2-rc.1`+;`0.5.x` 保持兼容 DSH
74
+ `0.6.x` 面向 DSH `0.1.5-rc.1`+;`0.5.x` 保持兼容 DSH
75
75
  `0.1.1-rc.2`–`0.1.2-alpha.1`,两个版本线都要保持发布:
76
76
 
77
77
  1. 同步修改 `package.json` 与 `package-lock.json` 根 `""` 条目中的
78
78
  `version`,然后运行 `npm test && npm run typecheck && npm run build`,
79
79
  并检查 `npm pack --dry-run --json`(`tests/compatibility-metadata.test.ts`
80
- 会强制校验 `^0.1.2-rc.1` 的 peer 范围)。
80
+ 会强制校验 `^0.1.5-rc.1` 的 peer 范围)。
81
81
  2. 提交发布并推送。`npm publish` 会自动执行 `prepack`(构建)与
82
82
  `prepublishOnly`(测试 + 类型检查)。
83
- 3. 发布 alpha 版本线,不要动 `latest`:
83
+ 3. 发布预发布版本,不要动 `latest`:
84
84
 
85
85
  ```sh
86
86
  npm publish --tag alpha
87
87
  ```
88
88
 
89
89
  发布成功后,`npm view dsh-deeppilot dist-tags --json` 应显示
90
- `"latest": "0.5.x"` 与 `"alpha": "0.6.0-alpha.x"`。向用户推荐前,请先在
91
- DSH `0.1.2-rc.1` profile 中安装验证发布的包。
92
- 4. 为发布提交打 `v0.6.0-alpha.x` tag,并准备包含英文与简体中文说明的
90
+ `"latest": "0.6.x"` 与 `"alpha": "0.6.x-alpha.y"`。向用户推荐前,请先在
91
+ DSH `0.1.5-rc.1` profile 中安装验证发布的包。
92
+ 4. 为发布提交打 `vX.Y.Z` tag,并准备包含英文与简体中文说明的
93
93
  GitHub Release,链接本 README 的发布说明。
94
- 5. alpha 转正时升级到 `0.6.0`,用 `npm publish --tag latest` 发布,使
95
- `latest` 切换到新版线。稳定版绝不使用 `--tag alpha` 发布。
94
+ 5. 稳定版用 `npm publish --tag latest` 发布,使 `latest` 切换到新版本。
95
+ 稳定版绝不使用 `--tag alpha` 发布。
96
96
 
97
- 绝不要从仍是旧 `0.5.x` 版本的副本执行 `npm publish`。
97
+ 绝不要从仍是旧版本的副本执行 `npm publish`。
98
98
 
99
99
 
100
100
  ## 连接与隐私
package/lib/index.d.ts CHANGED
@@ -474,6 +474,7 @@ interface BridgeSink {
474
474
  interface PushOutlet {
475
475
  fanOut(notification: PushNotification): void;
476
476
  widgetChanged?(): void;
477
+ liveActivityChanged?(sessions: SessionSummary[]): void;
477
478
  /**
478
479
  * Whether offline push is currently configured and usable. Drives the
479
480
  * welcome capability bit: advertising push while no APNs credentials are
@@ -530,6 +531,7 @@ declare class HostBridge {
530
531
  projectSelection: boolean;
531
532
  push: boolean;
532
533
  widgetPush: boolean;
534
+ liveActivityPush: boolean;
533
535
  };
534
536
  diagnostic(message: string): void;
535
537
  currentCursor(): number;
@@ -568,6 +570,7 @@ declare class HostBridge {
568
570
  * frames back (R1/P2).
569
571
  */
570
572
  resumeFrom(cursor: number, target?: BridgeSink): boolean;
573
+ refreshLiveActivities(): void;
571
574
  private record;
572
575
  /** Start consuming host + mux streams. Idempotent; aborts on dispose(). */
573
576
  start(): void;
package/lib/index.js CHANGED
@@ -243,6 +243,10 @@ function validateRequest(type, value) {
243
243
  ids.add(answer.id);
244
244
  }
245
245
  }
246
+ if (type === "c2s.liveActivity.register" || type === "c2s.liveActivity.unregister") {
247
+ if (!text(p.activityId, 128)) return "invalid activityId";
248
+ if (type === "c2s.liveActivity.register" && (!text(p.sessionId) || typeof p.deviceToken !== "string" || !/^[0-9a-fA-F]{32,512}$/.test(p.deviceToken) || !["development", "production"].includes(p.environment) || !optional("enrollKey", (v) => text(v, 128)))) return "invalid live activity registration";
249
+ }
246
250
  if (type === "c2s.push.register" || type === "c2s.widget.push.register") {
247
251
  if (p.environment !== void 0 && p.environment !== "production" && p.environment !== "development") return "invalid APNs environment";
248
252
  if (!optional("enrollKey", (v) => text(v, 128))) return "invalid enrollKey";
@@ -462,7 +466,8 @@ var DeviceStore = class DeviceStore {
462
466
  firstSeenTs: existing?.firstSeenTs ?? now,
463
467
  lastSeenTs: now,
464
468
  ...existing?.apns ? { apns: existing.apns } : {},
465
- ...existing?.widgetApns ? { widgetApns: existing.widgetApns } : {}
469
+ ...existing?.widgetApns ? { widgetApns: existing.widgetApns } : {},
470
+ ...existing?.liveActivity ? { liveActivity: existing.liveActivity } : {}
466
471
  };
467
472
  this.devices.set(deviceId, next);
468
473
  this.flush();
@@ -488,6 +493,7 @@ var DeviceStore = class DeviceStore {
488
493
  record.revokedAt = now;
489
494
  delete record.apns;
490
495
  delete record.widgetApns;
496
+ delete record.liveActivity;
491
497
  this.flush();
492
498
  return true;
493
499
  }
@@ -533,6 +539,29 @@ var DeviceStore = class DeviceStore {
533
539
  delete record.apns;
534
540
  this.flush();
535
541
  }
542
+ setLiveActivity(deviceId, registration) {
543
+ const record = this.authorized(deviceId);
544
+ if (!record || !isValidApnsToken(registration.token)) return;
545
+ const old = record.liveActivity;
546
+ record.liveActivity = old?.activityId === registration.activityId ? {
547
+ ...registration,
548
+ expiresAt: old.expiresAt,
549
+ endedState: old.endedState
550
+ } : registration;
551
+ this.flush();
552
+ }
553
+ endLiveActivity(deviceId, token, state) {
554
+ const registration = this.devices.get(deviceId)?.liveActivity;
555
+ if (!registration || registration.token !== token || registration.endedState) return;
556
+ registration.endedState = state;
557
+ this.flush();
558
+ }
559
+ clearLiveActivity(deviceId, activityId, token) {
560
+ const record = this.devices.get(deviceId);
561
+ if (record?.liveActivity?.activityId !== activityId || token !== void 0 && record.liveActivity.token !== token) return;
562
+ delete record.liveActivity;
563
+ this.flush();
564
+ }
536
565
  setWidgetPushToken(deviceId, token, environment, now) {
537
566
  const record = this.authorized(deviceId);
538
567
  if (!record || !isValidApnsToken(token)) return;
@@ -613,6 +642,7 @@ function requiredScope(type) {
613
642
  if (type === "c2s.session.sendPrompt" || type === "c2s.session.delivery") return "prompt.send";
614
643
  if (type === "c2s.pending.list") return "interactions.respond";
615
644
  if (type === "c2s.approval.respond" || type === "c2s.question.respond") return "interactions.respond";
645
+ if (type === "c2s.liveActivity.register" || type === "c2s.liveActivity.unregister") return "notifications.register";
616
646
  if (type === "c2s.push.register" || type === "c2s.widget.push.register") return "notifications.register";
617
647
  if (type === "c2s.workspace.create" || type === "c2s.session.create" || type === "c2s.session.rename" || type === "c2s.session.archive" || type === "c2s.session.cancel" || type === "c2s.session.selectModel") return "sessions.manage";
618
648
  if (type.startsWith("c2s.")) return "sessions.read";
@@ -693,6 +723,8 @@ var BridgeConnection = class {
693
723
  deviceId;
694
724
  scopes = /* @__PURE__ */ new Set();
695
725
  widgetClient = false;
726
+ liveActivityRegistrationGeneration = 0;
727
+ pendingLiveActivityId;
696
728
  authChallenge;
697
729
  constructor(ws, deps) {
698
730
  this.ws = ws;
@@ -1119,6 +1151,49 @@ var BridgeConnection = class {
1119
1151
  this.send("s2c.ack", {}, env.id);
1120
1152
  return;
1121
1153
  }
1154
+ case "c2s.liveActivity.unregister": {
1155
+ const p = env.payload;
1156
+ if (this.pendingLiveActivityId === p.activityId) {
1157
+ this.liveActivityRegistrationGeneration += 1;
1158
+ this.pendingLiveActivityId = void 0;
1159
+ }
1160
+ this.deps.devices.clearLiveActivity(this.deviceId, p.activityId);
1161
+ this.send("s2c.ack", {}, env.id);
1162
+ return;
1163
+ }
1164
+ case "c2s.liveActivity.register": {
1165
+ const p = env.payload;
1166
+ const initial = this.deviceId && this.deps.devices.authorized(this.deviceId);
1167
+ if (!initial || ![
1168
+ "notifications.register",
1169
+ "sessions.read",
1170
+ "interactions.respond"
1171
+ ].every((scope) => initial.scopes?.includes(scope))) return this.fail(env.id, "E_FORBIDDEN", "live activity permissions required");
1172
+ const generation = ++this.liveActivityRegistrationGeneration;
1173
+ this.pendingLiveActivityId = p.activityId;
1174
+ if (p.enrollKey) await this.deps.onPushEnrollKey?.(p.enrollKey);
1175
+ if (generation !== this.liveActivityRegistrationGeneration) return this.fail(env.id, "E_BUSY", "live activity registration superseded");
1176
+ const record = this.deviceId && this.deps.devices.authorized(this.deviceId);
1177
+ if (this.closed || !record || ![
1178
+ "notifications.register",
1179
+ "sessions.read",
1180
+ "interactions.respond"
1181
+ ].every((scope) => record.scopes?.includes(scope))) return this.fail(env.id, "E_FORBIDDEN", "live activity permissions required");
1182
+ if (!this.deps.bridge.listSessions().some((row) => row.id === p.sessionId)) return this.fail(env.id, "E_NOT_FOUND", "session not found");
1183
+ const previous = record.liveActivity;
1184
+ if (previous?.activityId === p.activityId && previous.sessionId !== p.sessionId) return this.fail(env.id, "E_PROTOCOL", "activity is bound to another session");
1185
+ this.deps.devices.setLiveActivity(record.deviceId, {
1186
+ activityId: p.activityId,
1187
+ sessionId: p.sessionId,
1188
+ token: p.deviceToken.toLowerCase(),
1189
+ environment: p.environment,
1190
+ updatedAt: Date.now(),
1191
+ expiresAt: Date.now() + 288e5
1192
+ });
1193
+ this.deps.bridge.refreshLiveActivities();
1194
+ this.send("s2c.ack", { enabled: this.deps.bridge.capabilities.push }, env.id);
1195
+ return;
1196
+ }
1122
1197
  case "c2s.widget.push.register":
1123
1198
  case "c2s.push.register": {
1124
1199
  const widget = env.type === "c2s.widget.push.register";
@@ -1298,6 +1373,7 @@ function projectEvent(sessionId, event) {
1298
1373
  kind: "turn.end",
1299
1374
  data: { ok: event.data?.reason?.kind === "completed" }
1300
1375
  };
1376
+ case "system/message": return null;
1301
1377
  case "user/message": return {
1302
1378
  kind: "message.final",
1303
1379
  data: { ...limitMessageProjection({
@@ -1560,6 +1636,7 @@ function projectHistory(events) {
1560
1636
  ts: tsOf(event)
1561
1637
  };
1562
1638
  switch (event.type) {
1639
+ case "system/message": break;
1563
1640
  case "user/message":
1564
1641
  messages.push({
1565
1642
  ...base,
@@ -1777,7 +1854,8 @@ var HostBridge = class {
1777
1854
  sessionManagement: typeof this.apiProxy.sessions.rename === "function" && typeof this.apiProxy.workspace?.archiveSession === "function",
1778
1855
  projectSelection: typeof this.apiProxy.workspace?.list === "function" && typeof this.apiProxy.workspace?.create === "function",
1779
1856
  push: this.pushOutlet?.isAvailable() === true,
1780
- widgetPush: true
1857
+ widgetPush: true,
1858
+ liveActivityPush: true
1781
1859
  };
1782
1860
  }
1783
1861
  diagnostic(message) {
@@ -1890,16 +1968,23 @@ var HostBridge = class {
1890
1968
  for (const sink of receivers) sink.replayDone();
1891
1969
  return true;
1892
1970
  }
1971
+ refreshLiveActivities() {
1972
+ try {
1973
+ this.pushOutlet?.liveActivityChanged?.(this.listSessions());
1974
+ } catch {}
1975
+ }
1893
1976
  record(type, payload, except) {
1894
1977
  if (this.disposed) return;
1978
+ if (type === "s2c.sessions.delta") this.refreshLiveActivities();
1895
1979
  if (type === "s2c.sessions.delta" || type.startsWith("s2c.pending.")) {
1896
1980
  const fingerprint = JSON.stringify([
1897
- this.listSessions().map(({ id, title, status, lastActivityTs, todos, pendingApproval, pendingQuestion }) => ({
1981
+ this.listSessions().map(({ id, title, status, lastActivityTs, todos, todoItems, pendingApproval, pendingQuestion }) => ({
1898
1982
  id,
1899
1983
  title,
1900
1984
  status,
1901
1985
  lastActivityTs,
1902
1986
  todos,
1987
+ todoItems,
1903
1988
  pendingApproval,
1904
1989
  pendingQuestion
1905
1990
  })),
@@ -4094,6 +4179,12 @@ function p8ToDer(pem) {
4094
4179
  }
4095
4180
  /** Pure payload builder so tests can assert the wire format without sockets. */
4096
4181
  function apnsPayload(notification) {
4182
+ if ("kind" in notification && notification.kind === "liveactivity") return { aps: {
4183
+ timestamp: notification.timestamp,
4184
+ event: notification.event,
4185
+ "content-state": notification.contentState,
4186
+ ...notification.event === "end" ? { "dismissal-date": notification.timestamp + 300 } : { "stale-date": notification.timestamp + 180 }
4187
+ } };
4097
4188
  if ("kind" in notification && notification.kind === "widget") return { aps: { "content-changed": true } };
4098
4189
  return alertPayload(notification);
4099
4190
  }
@@ -4117,6 +4208,12 @@ function alertPayload(notification) {
4117
4208
  };
4118
4209
  }
4119
4210
  function pushHeaders(bundleId, notification) {
4211
+ if ("kind" in notification && notification.kind === "liveactivity") return {
4212
+ "apns-topic": bundleId + ".push-type.liveactivity",
4213
+ "apns-push-type": "liveactivity",
4214
+ "apns-priority": notification.event === "end" ? "10" : "5",
4215
+ "apns-expiration": String(notification.timestamp + 180)
4216
+ };
4120
4217
  if ("kind" in notification && notification.kind === "widget") return {
4121
4218
  "apns-topic": bundleId + ".push-type.widgets",
4122
4219
  "apns-push-type": "widgets",
@@ -5331,6 +5428,79 @@ var WidgetPushScheduler = class {
5331
5428
  }
5332
5429
  };
5333
5430
  //#endregion
5431
+ //#region src/live-activity.ts
5432
+ function liveActivityState(session) {
5433
+ const total = Math.max(0, session?.todos?.total ?? 0);
5434
+ const done = Math.min(total, Math.max(0, session?.todos?.done ?? 0));
5435
+ const task = session?.todoItems?.find((item) => item.status === "in_progress") ?? session?.todoItems?.find((item) => item.status === "pending");
5436
+ return {
5437
+ title: Array.from(session?.title ?? "").slice(0, 100).join(""),
5438
+ task: Array.from(task?.content ?? "").slice(0, 160).join(""),
5439
+ done,
5440
+ total,
5441
+ phase: !session ? "unavailable" : session.pendingApproval ? "approval" : session.pendingQuestion ? "question" : session.status === "running" ? "running" : session.status === "idle" ? "ended" : "unavailable"
5442
+ };
5443
+ }
5444
+ /** Persist terminal state before coalescing so a rapid next round cannot revive an activity. */
5445
+ var LiveActivityPushManager = class {
5446
+ devices;
5447
+ send;
5448
+ latest = /* @__PURE__ */ new Map();
5449
+ sent = /* @__PURE__ */ new Map();
5450
+ scheduler;
5451
+ constructor(devices, send, intervalMs = 15e3) {
5452
+ this.devices = devices;
5453
+ this.send = send;
5454
+ this.scheduler = new WidgetPushScheduler(() => this.flush(), intervalMs);
5455
+ }
5456
+ changed(sessions) {
5457
+ this.latest = new Map(sessions.map((session) => [session.id, session]));
5458
+ for (const device of this.devices()?.list() ?? []) {
5459
+ const r = device.liveActivity;
5460
+ if (!r) continue;
5461
+ const state = liveActivityState(this.latest.get(r.sessionId));
5462
+ if (state.phase === "ended" || state.phase === "unavailable") this.devices()?.endLiveActivity(device.deviceId, r.token, state);
5463
+ }
5464
+ this.scheduler.changed();
5465
+ }
5466
+ async flush() {
5467
+ const devices = this.devices();
5468
+ if (!devices) return;
5469
+ const valid = /* @__PURE__ */ new Set();
5470
+ for (const device of devices.list()) {
5471
+ const r = device.liveActivity;
5472
+ if (!r) continue;
5473
+ const key = device.deviceId + ":" + r.token;
5474
+ valid.add(key);
5475
+ if (device.revokedAt !== void 0 || ![
5476
+ "notifications.register",
5477
+ "sessions.read",
5478
+ "interactions.respond"
5479
+ ].every((scope) => device.scopes?.some((value) => value === scope)) || r.expiresAt <= Date.now()) {
5480
+ devices.clearLiveActivity(device.deviceId, r.activityId, r.token);
5481
+ continue;
5482
+ }
5483
+ const state = r.endedState ?? liveActivityState(this.latest.get(r.sessionId));
5484
+ const fingerprint = JSON.stringify(state);
5485
+ if (this.sent.get(key) === fingerprint) continue;
5486
+ if (devices.authorized(device.deviceId)?.liveActivity?.token !== r.token) continue;
5487
+ const result = await this.send(r.token, r.environment, {
5488
+ kind: "liveactivity",
5489
+ event: r.endedState ? "end" : "update",
5490
+ timestamp: Math.floor(Date.now() / 1e3),
5491
+ contentState: state
5492
+ }).catch(() => ({ outcome: "failed" }));
5493
+ if (result.outcome === "sent") this.sent.set(key, fingerprint);
5494
+ else if (result.outcome === "invalid-token") devices.clearLiveActivity(device.deviceId, r.activityId, r.token);
5495
+ else this.scheduler.changed();
5496
+ }
5497
+ for (const key of this.sent.keys()) if (!valid.has(key)) this.sent.delete(key);
5498
+ }
5499
+ dispose() {
5500
+ this.scheduler.dispose();
5501
+ }
5502
+ };
5503
+ //#endregion
5334
5504
  //#region src/phone-server.ts
5335
5505
  /**
5336
5506
  * A deliberately narrow transport listener. Both the LAN endpoint and the
@@ -5818,8 +5988,27 @@ function apply(ctx, options) {
5818
5988
  }
5819
5989
  }
5820
5990
  });
5991
+ const liveActivityPush = new LiveActivityPushManager(() => auth.devices ?? void 0, async (deviceToken, environment, notification) => {
5992
+ if (!enabledNow()) return { outcome: "failed" };
5993
+ const resolved = resolvePushConfig(currentConfig());
5994
+ if (!resolved.ok) return { outcome: "failed" };
5995
+ const send = await senderFor(resolved.value);
5996
+ if (!send) return { outcome: "failed" };
5997
+ const result = await send({
5998
+ deviceToken,
5999
+ environment,
6000
+ notification
6001
+ });
6002
+ if (resolved.value.kind === "relay" && result.reason === "HTTP 401" && enrollmentCell.token === resolved.value.token && enrollmentCell.enrollKey) {
6003
+ enrollmentCell.token = void 0;
6004
+ persistEnrollment();
6005
+ await ensureRelayEnrolled(resolved.value.url);
6006
+ }
6007
+ return result;
6008
+ });
5821
6009
  const makePushOutlet = () => ({
5822
6010
  widgetChanged: () => widgetPush.changed(),
6011
+ liveActivityChanged: (sessions) => liveActivityPush.changed(sessions),
5823
6012
  isAvailable: () => {
5824
6013
  const resolved = resolvePushConfig(currentConfig());
5825
6014
  if (!resolved.ok) return false;
@@ -6455,6 +6644,7 @@ function apply(ctx, options) {
6455
6644
  cachedSender = void 0;
6456
6645
  updateChecker.dispose();
6457
6646
  widgetPush.dispose();
6647
+ liveActivityPush.dispose();
6458
6648
  const wssClosed = new Promise((resolve) => wss.close(() => resolve()));
6459
6649
  await Promise.allSettled([
6460
6650
  enrollmentWriteTail,