dsh-composer-plus 1.21.1 → 1.22.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/README.zh.md CHANGED
@@ -29,7 +29,7 @@ dsh plugin --profile web add dsh-composer-plus
29
29
 
30
30
  然后重启 dsh。(CLI 无法安装到桌面版 profile 中;桌面版 profile 由应用自己管理。)
31
31
 
32
- > dsh 使用的 pnpm 会跳过发布不到 24 小时的版本。刚发布新版本时,请加上版本号:`dsh-composer-plus@1.21.1`。
32
+ > dsh 使用的 pnpm 会跳过发布不到 24 小时的版本。刚发布新版本时,请加上版本号:`dsh-composer-plus@1.22.0`。
33
33
 
34
34
  ## 功能
35
35
 
@@ -215,7 +215,7 @@ English · 中文 · Türkçe · Español · Português (Brasil) · Français ·
215
215
  - **对话行上没有分支标签:** 侧边栏不是“单列表”视图(在“按工作区”和“按工作区树”中,对话行都不显示标签);或者安装此版本后,该对话还没有新的一轮;或者它的文件夹不是 git 仓库;或者 **对话行显示 Git 分支** 已关闭。更新后,请重启 dsh(分支由 Host 端记录)。
216
216
  - **目标检查显示测试以代码 127 或 “not found” 退出:** 从 Finder 或程序坞启动的 dsh 的 PATH 可能比终端里的短,因此找不到 `npm`、`pnpm` 或 `cargo`。请从终端启动 dsh,或者在 **目标检查** → **编辑** 中用完整路径设置你自己的测试命令。**立即运行** 会马上显示结果。
217
217
  - **目标没有关闭,智能体一直在工作:** **目标检查** 发现了失败的测试或缺少的工作;目标下方的一行会显示原因(将鼠标悬停在结果上可查看详情)。请修正原因或测试命令,或者用 `/goal` 或目标栏自己完成目标(这不会被检查)。
218
- - **刚发布新版本时安装失败:** 请加上版本号(`dsh-composer-plus@1.21.1`),见上文的安装说明。
218
+ - **刚发布新版本时安装失败:** 请加上版本号(`dsh-composer-plus@1.22.0`),见上文的安装说明。
219
219
  - **立即备份显示“另一个备份操作正在进行”:** 另一个 dsh 窗口或救援控制台中正在运行一个操作(它们共用备份文件夹中的锁文件 `.composer-plus.lock`)。请等待它完成。如果没有操作在运行,下次点击时会接管已关闭进程留下的锁。
220
220
  - **救援控制台显示“dsh is running”:** 请先退出 dsh。如果 dsh 确实已关闭,消息中提到的文件是崩溃后留下的:删除它,然后重试。
221
221
  - **恢复后 dsh 无法启动或缺少插件:** 需要重新安装 profile 的插件包。恢复后,救援控制台会提供这一步;你也可以在它列出的 `$DSH_HOME/profiles/` 下的每个文件夹中运行 `pnpm install`。不在 vault 中的机密(另一台电脑)必须重新输入:控制台会列出它们。
@@ -246,7 +246,7 @@ English · 中文 · Türkçe · Español · Português (Brasil) · Français ·
246
246
  - **保持输入框焦点** 只在发送 `/` 命令或技能后才会把焦点还给输入框。dsh 解析命令时,输入框会有几毫秒处于只读状态,因此这段时间内按下的键会丢失。如果 dsh 的 `/` 菜单仍然打开,`Enter` 会选择高亮的行,而不会发送。
247
247
  - **问题卡片适应窗口** 只改变 dsh 的问题卡片,不改变计划审阅卡片。它通过 dsh 的标记(`data-question-key`、`data-question-scroll`)找到卡片;如果 dsh 更新改变了这些标记,卡片会按 dsh 自己的样子显示。卡片高度跟随窗口,而不是对话:在较窄侧边面板中的对话里,较高的卡片仍可能超出对话上方。
248
248
  - **对话标题栏适应窗口** 通过 dsh 生成的类名(`_titleRow`、`_titleCluster`、`_crumbs`、`_headerActions`、`_headerUtilities`)找到对话标题栏,因为 dsh 没有为它提供布局接口。如果 dsh 更新改了这些类名,标题栏按 dsh 原样显示。在很窄的对话栏中,标题栏可能占两到三行,所以消息上方的空间会变小。
249
- - **手机连接** 只适用于 macOS(它使用 `en0`、`openssl`、`swift` 和 `dns-sd`)。它使用固定端口 19390 和 19391,因此每台 Mac 上同一时间只能有一个 dsh 运行它(每台 Mac 是手机 Mac 列表中的一项)。只有在 Tether 打开或刚转入后台时,手机才能收到通知(没有推送服务)。在 Tether 中打开“Settings → Notifications → Send notifications”之前,Tether 不会发送通知。Tether 为每台已配对的 Mac 分别保存此开关和时间段。此开关在每台 Mac 上都默认关闭,打开时 iOS 会请求通知权限。iOS 应用尚未上架 App Store:请用 Xcode 从 `tether/ios` 构建它。“Tailscale only”仍然需要 Mac 有 Wi-Fi 或以太网地址(只有在有这样的地址时,网关才会启动)。访问日志按 Mac 分别保存,只保留最新的 200 次尝试;一分钟内重复的尝试不会新增一行,而是把同一行的次数加一。Tether 的应用锁不会隐藏 iPhone 锁定屏幕上的通知预览:请在 iOS 设置中关闭它们。Tether 的绿点跟随 Mac:在 dsh 中打开的对话,手机上的绿点也会消失。在 Tether 中打开的对话,只在手机上消失;Mac 上 dsh 自己的绿点会一直保留,直到你在 Mac 上打开该对话。
249
+ - **手机连接** 只适用于 macOS(它使用 `en0`、`openssl`、`swift` 和 `dns-sd`)。它使用固定端口 19390 和 19391,因此每台 Mac 上同一时间只能有一个 dsh 运行它(每台 Mac 是手机 Mac 列表中的一项)。在 `apns.json` 中配置 APNs 密钥后(见 `tether/README.md`),Mac 通过 Apple 推送服务发送通知,即使 Tether 已关闭也能收到;Mac 必须处于唤醒状态并打开 dsh。没有该密钥时,只有在 Tether 打开或刚转入后台时,手机才能收到通知。在 Tether 中打开“Settings → Notifications → Send notifications”之前,Tether 不会发送通知。Tether 为每台已配对的 Mac 分别保存此开关和时间段。此开关在每台 Mac 上都默认关闭,打开时 iOS 会请求通知权限。iOS 应用尚未上架 App Store:请用 Xcode 从 `tether/ios` 构建它。“Tailscale only”仍然需要 Mac 有 Wi-Fi 或以太网地址(只有在有这样的地址时,网关才会启动)。访问日志按 Mac 分别保存,只保留最新的 200 次尝试;一分钟内重复的尝试不会新增一行,而是把同一行的次数加一。每条通知都会显示 Mac 和对话所属的项目(`Studio Mac · recipe-box`)、对话标题和正文:Turn finished 显示智能体的最后一条回复。有 APNs 密钥时,这段文字会经过 Apple 的推送服务。Tether 的应用锁不会隐藏 iPhone 锁定屏幕上的通知预览:请在 iOS 设置中关闭它们。Tether 的绿点跟随 Mac:在 dsh 中打开的对话,手机上的绿点也会消失。在 Tether 中打开的对话,只在手机上消失;Mac 上 dsh 自己的绿点会一直保留,直到你在 Mac 上打开该对话。
250
250
  - **远程电脑** 需要两台 Mac 都有 Tailscale,并且被打开的 Mac 上要有开启了此功能的 Composer Plus。那台 Mac 必须处于唤醒状态,并且正在运行 dsh。另一台电脑会在屏幕上当前对话的右侧边栏浏览器标签页中打开:这个标签页属于该对话,所以其他对话会显示自己的侧边栏。没有对话时(或在没有浏览器标签页的 dsh web 中),电脑会在浏览器窗口中打开。只有主区域显示对话或新对话界面时,才会显示选择器;任务看板上不显示。它使用端口 19392(每台 Mac 上只有一个 dsh 能让其他电脑进入)。在另一台电脑的页面中打开的电脑没有选择器,所以你不能从那里打开第三台 Mac。
251
251
  - 会话链接在对话中有效;点击它不会打开对应的对话。用 **复制消息链接** 复制的链接,只在 dsh 0.2.1 及更高版本中、并且只从对话中的消息里才能打开对应的消息(dsh 0.2.0 没有用户文本规则,所以链接仍会被复制,但只显示为普通文本)。被回退移除的消息,或被压缩从 dsh 加载的历史中移除的消息,无法显示:此时链接会给出提示。🔗 按钮依附在 dsh 的消息行上;如果 dsh 更新改变了这部分标记,按钮就不会显示。
252
252
  - **全部标为已读** 通过 dsh 中一个不是公开 API 的部分(dsh 会话状态中的 `completionUnread` 列表)来更改 dsh 自己的未读圆点。如果 dsh 更新重命名了它,该菜单项就不会显示。该菜单项放在 dsh 项目的“…”菜单中,而这个菜单没有给插件的插槽;如果 dsh 修改了这个菜单,该菜单项也不会显示。
package/client.js CHANGED
@@ -381,6 +381,14 @@ ${row} > [class*="_headerUtilities"] { flex-shrink: 1; min-width: 0; }
381
381
  ${row} [data-composer-plus-grants] { flex: none; white-space: nowrap; }`;
382
382
  };
383
383
  const installHeaderFit = (doc, id, marker) => installStyle(doc, 'data-composer-plus-header-fit', headerFitCss(marker), id);
384
+ /** Approval card fit. dsh's card keeps its button row on one line (`display: flex`, no wrap). With our two buttons
385
+ * (Allow for this chat, Reject with note…) in a narrow chat, the four buttons shrink and each label breaks over
386
+ * several lines, out of its button (owner report). While one of our buttons is in the row, the row wraps and each
387
+ * button keeps its label on one line. Only dsh's markup attribute, no generated class names.
388
+ * ponytail: a single label wider than the whole card still overflows; then the card's own `overflow: hidden` cuts it. */
389
+ const APPROVAL_FIT_CSS = `[data-approval-key] :has(> [data-composer-plus-approval-action]) { flex-wrap: wrap; row-gap: 8px; }
390
+ [data-approval-key] :has(> [data-composer-plus-approval-action]) > button { flex: none; white-space: nowrap; }`;
391
+ const installApprovalFit = (doc, id) => installStyle(doc, 'data-composer-plus-approval-fit', APPROVAL_FIT_CSS, id);
384
392
 
385
393
  const primitives = require('@deepseek-ai/dsh-client-ui-primitives');
386
394
  /** Copy text with dsh's own clipboard helper; true when it worked. Call it inside the user's click. */
@@ -3722,6 +3730,7 @@ ${row} [data-composer-plus-grants] { flex: none; white-space: nowrap; }`;
3722
3730
  disabled: busy,
3723
3731
  title: grantTtl.ttlMs === null ? t('allowChatTitle', { toolName: ask.toolName }) : t('allowChatTitleFor', { toolName: ask.toolName, duration: formatDuration(grantTtl.ttlMs) }),
3724
3732
  style: { order: -2, marginRight: 'auto' }, // first in the row; Reject with note (order -1) stays next to Reject
3733
+ 'data-composer-plus-approval-action': '',
3725
3734
  onClick: allow,
3726
3735
  }, t('allowChat')), host, ask.key);
3727
3736
  return chip === null && button === null ? null : React.createElement(React.Fragment, null, chip, button);
@@ -3811,7 +3820,7 @@ ${row} [data-composer-plus-grants] { flex: none; white-space: nowrap; }`;
3811
3820
  React.createElement('div', { className: 'cp-rn-row' },
3812
3821
  React.createElement(primitives.Button, { variant: 'outline', disabled: state.busy, onClick: () => { store.close(ask.key); setText(''); } }, t('rejectNote.cancel')),
3813
3822
  React.createElement(primitives.Button, { variant: 'primary', disabled: state.busy || text.trim() === '', onClick: send }, t('rejectNote.send'))))
3814
- : React.createElement(primitives.Button, { variant: 'outline', title: t('rejectNote.title'), style: { order: -1 }, onClick: () => store.open(ask.key) }, t('rejectNote.button'));
3823
+ : React.createElement(primitives.Button, { variant: 'outline', title: t('rejectNote.title'), style: { order: -1 }, 'data-composer-plus-approval-action': '', onClick: () => store.open(ask.key) }, t('rejectNote.button'));
3815
3824
  return createPortal(view, host, `reject-note-${ask.key}`);
3816
3825
  }
3817
3826
 
@@ -3875,12 +3884,25 @@ ${row} [data-composer-plus-grants] { flex: none; white-space: nowrap; }`;
3875
3884
  try { const v = block?.args?.text?.(key); if (typeof v === 'string') return v; } catch { /* partial arguments */ }
3876
3885
  try { const v = JSON.parse(block?.argsRaw ?? block?.call?.argsRaw ?? '')?.[key]; return typeof v === 'string' ? v : undefined; } catch { return undefined; }
3877
3886
  }
3887
+ /** dsh folds the steps of a turn into a closed group (`hidden="until-found"`) even while the turn runs (the desktop
3888
+ * default "standard" view). The card waits for the user, so open each fold above it through dsh's own way: the
3889
+ * `beforematch` event that browser find sends (dsh's listener opens the group). After dsh's commit; no focus. */
3890
+ function revealSecretCard(card) {
3891
+ setTimeout(() => {
3892
+ if (!card.isConnected) return;
3893
+ for (let el = card.parentElement; el !== null; el = el.parentElement) {
3894
+ if (el.getAttribute('hidden') === 'until-found') el.dispatchEvent(new Event('beforematch'));
3895
+ }
3896
+ card.scrollIntoView?.({ block: 'nearest' });
3897
+ }, 0);
3898
+ }
3878
3899
  /** Secret input: dsh draws each `request_secret` call through this view (dsh's `tool.call.toolview` slot, keyed by the
3879
3900
  * tool name). While the call waits: the label, the purpose, a password field, **Save** and **Decline**. After it: one
3880
3901
  * line ("Saved" / "Declined" / the tool's own result line). The value is in the field and in the POST only. */
3881
3902
  function SecretRow({ sessionId, phase, block, callId, store, t }) {
3882
3903
  const state = React.useSyncExternalStore(store.subscribe, () => store.of(sessionId));
3883
3904
  const [value, setValue] = React.useState('');
3905
+ const revealed = React.useRef(null); // the ask whose folds this row opened: once per ask, a fold you close stays closed
3884
3906
  const running = phase === 'start';
3885
3907
  const ask = state.asks.find((a) => a.callId === callId);
3886
3908
  const found = ask !== undefined || state.done.has(callId);
@@ -3901,7 +3923,8 @@ ${row} [data-composer-plus-grants] { flex: none; white-space: nowrap; }`;
3901
3923
  const busy = state.busy.has(ask.askId);
3902
3924
  const save = () => { if (value !== '') store.answer(sessionId, ask, { value }).then((ok) => { if (ok) setValue(''); }); };
3903
3925
  const error = state.error.get(ask.askId);
3904
- return React.createElement('div', { className: 'cp-sec', 'data-tool': 'request_secret', 'data-state': 'running', 'data-composer-plus-secret': ask.askId },
3926
+ const reveal = (node) => { if (node !== null && revealed.current !== ask.askId) { revealed.current = ask.askId; revealSecretCard(node); } };
3927
+ return React.createElement('div', { ref: reveal, className: 'cp-sec', 'data-tool': 'request_secret', 'data-state': 'running', 'data-composer-plus-secret': ask.askId },
3905
3928
  React.createElement('style', null, SECRET_CSS),
3906
3929
  head,
3907
3930
  ask.purpose ? React.createElement('p', { className: 'cp-sec-p' }, ask.purpose) : null,
@@ -10370,7 +10393,7 @@ a.cpi-btn{text-decoration:none;color:var(--dsw-alias-state-business-primary,#3b6
10370
10393
  }));
10371
10394
  // The Host's grants and their timer belong to the feature: switching it off (or the unload) stops the timer.
10372
10395
  const startGrants = () => { grantsOn = true; if (typeof fetch === 'function') loadGrants(); return () => { grantsOn = false; clearTimeout(expiryTimer); }; };
10373
- feature('allowForChat', () => [startGrants(), installHeaderFit(globalThis.document, 'allowForChat', 'data-composer-plus-grants'), ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
10396
+ feature('allowForChat', () => [startGrants(), installHeaderFit(globalThis.document, 'allowForChat', 'data-composer-plus-grants'), installApprovalFit(globalThis.document, 'allowForChat'), ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
10374
10397
  name: 'conversation.session.header.utilities',
10375
10398
  id: 'chat-tool-grants',
10376
10399
  order: 0,
@@ -10395,7 +10418,7 @@ a.cpi-btn{text-decoration:none;color:var(--dsw-alias-state-business-primary,#3b6
10395
10418
  const result = await session.prompt([{ type: 'text', text }], 'steer');
10396
10419
  if (result?.ok === false) throw new Error(result.error?.message ?? String(result.error?.code ?? 'the note was not accepted'));
10397
10420
  });
10398
- feature('rejectNote', () => [ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
10421
+ feature('rejectNote', () => [installApprovalFit(globalThis.document, 'rejectNote'), ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
10399
10422
  name: 'conversation.session.header.utilities',
10400
10423
  id: 'reject-note',
10401
10424
  order: 1,
package/index.js CHANGED
@@ -855,7 +855,7 @@ export function unreadStore(file) {
855
855
 
856
856
  /** Mobile connect: one gateway at a time. Start and stop calls run in order, so a fast toggle never leaves two gateways or a
857
857
  * half-closed one. `upstreamUrl` gives dsh's authenticated loopback URL; it is asked again at each DSH login. */
858
- export function mobileGateway({ upstreamUrl, dir, make = createGateway, retryMs = 300 }) {
858
+ export function mobileGateway({ upstreamUrl, dir, about, make = createGateway, retryMs = 300 }) {
859
859
  let gateway, admin, error, closed = false, queue = Promise.resolve();
860
860
  const run = (fn) => (queue = queue.then(fn, fn));
861
861
  // A plugin reload starts the new gateway while the old one still closes its pairing page: wait for the port (about 3 s).
@@ -877,7 +877,7 @@ export function mobileGateway({ upstreamUrl, dir, make = createGateway, retryMs
877
877
  if (gateway !== undefined || closed) return;
878
878
  error = undefined;
879
879
  try {
880
- gateway = make({ dir, upstreamUrl });
880
+ gateway = make({ dir, upstreamUrl, about });
881
881
  admin = `http://127.0.0.1:${await listenAdmin(gateway)}/`;
882
882
  await gateway.start();
883
883
  } catch (e) { error = String(e?.message ?? e); if (admin === undefined) await stop(); }
@@ -887,6 +887,8 @@ export function mobileGateway({ upstreamUrl, dir, make = createGateway, retryMs
887
887
  close: () => { closed = true; return run(stop); },
888
888
  /** An event for the connected phones (`$events`); nothing while no gateway runs. */
889
889
  emit: (event, args) => { gateway?.emit?.(event, args); },
890
+ /** A push notification (APNs) for the phones that want one; nothing while no gateway runs. */
891
+ push: (title, detail, sessionId) => { gateway?.push?.(title, detail, sessionId); },
890
892
  state: () => {
891
893
  const running = gateway?.state().running === true;
892
894
  // The gateway retries by itself (for example when Wi-Fi comes back): a running gateway has no error to show.
@@ -2225,10 +2227,25 @@ export function apply(ctx) {
2225
2227
  let mobileOn; // the user's choice; the saved value applies only until the user changes it
2226
2228
  const mobileRead = mobileSaved.read().then((on) => { mobileOn ??= on; });
2227
2229
  let mobileQueue = Promise.resolve(); // one switch change at a time: save, then start or stop
2230
+ const serviceOf = (name) => { try { return ctx.get?.(name); } catch { return undefined; } };
2231
+ /** The words of a Tether push about a chat: its title and its project (dsh's project title, else the folder name; a
2232
+ * branch folder `<repo>/.worktrees/<name>` gives its repository's name). Empty for a chat that is not loaded. */
2233
+ const chatAbout = (sessionId) => {
2234
+ const session = serviceOf('agents')?.get?.(sessionId)?.session;
2235
+ if (session === undefined) return {};
2236
+ let chat, cwd, project;
2237
+ try { chat = ctx.sessionProjections?.snapshot?.(session, ['title'])?.values?.title; } catch { /* no title */ }
2238
+ try { cwd = serviceOf('workingDirectory')?.get?.(session); } catch { /* dsh 0.2.0 has none */ }
2239
+ if (!isText(cwd, 4096)) cwd = session.header?.cwd;
2240
+ let list;
2241
+ try { list = serviceOf('workspaceRegistry')?.list?.(); } catch { /* not ready */ }
2242
+ return { chat: isText(chat, 4096) ? chat : undefined, project: projectOf(cwd, list) };
2243
+ };
2228
2244
  ctx.inject?.(['webServer'], (webCtx) => {
2229
2245
  const gateway = mobileGateway({
2230
2246
  dir: process.env.DSH_MOBILE_DIR || join(home, 'storages', 'dsh-composer-plus', 'mobile'),
2231
2247
  upstreamUrl: () => ctx.connection.authenticatedUrl(`http://127.0.0.1:${webCtx.webServer.port}`),
2248
+ about: chatAbout,
2232
2249
  });
2233
2250
  mobile = gateway;
2234
2251
  webCtx.effect(() => {
@@ -2299,7 +2316,14 @@ export function apply(ctx) {
2299
2316
  ctx.on('agent/status', ({ agent, status }) => {
2300
2317
  if (agent?.session?.header?.origin === 'subagent') return; // the parent chat shows its subagents' work
2301
2318
  if (status === 'running') { busy.add(agent.id); if (mobileOn === true) changeUnread([], [agent.id]); }
2302
- else if (busy.delete(agent.id) && mobileOn === true) changeUnread([agent.id], []);
2319
+ else if (busy.delete(agent.id) && mobileOn === true) {
2320
+ changeUnread([agent.id], []);
2321
+ // Tether's "Turn finished" also while Tether is closed (APNs, when the gateway has a key): the agent's reply, under
2322
+ // the Mac, the project and the chat's title (chatAbout).
2323
+ let reply = '';
2324
+ try { reply = lastReply(agent.session?.snapshotEvents?.() ?? []); } catch { /* no log */ }
2325
+ mobile?.push('Turn finished', reply, agent.id);
2326
+ }
2303
2327
  });
2304
2328
  ctx.connection.fetch.register({
2305
2329
  path: UNREAD_PATH, methods: ['GET', 'POST'], requestBody: 'buffered',
@@ -3081,6 +3105,26 @@ export const predictStore = (file) => settingsStore(file, (v) => ({ on: v?.on !=
3081
3105
 
3082
3106
  /** Text blocks of a message content, joined. */
3083
3107
  const textBlocks = (content) => (Array.isArray(content) ? content.filter((b) => b?.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('') : '');
3108
+ /** A chat folder's project name in a Tether push: a branch folder `<repo>/.worktrees/<name>` gives its repository's
3109
+ * name, else dsh's project title (`workspaces`: `workspaceRegistry.list()`), else the folder name. */
3110
+ export function projectOf(cwd, workspaces) {
3111
+ if (!isText(cwd, 4096) || !isAbsolute(cwd)) return undefined;
3112
+ const folder = cwd.replace(/(.)[\\/]+$/, '$1');
3113
+ if (basename(dirname(folder)) === '.worktrees') return basename(dirname(dirname(folder))) || undefined;
3114
+ const w = Array.isArray(workspaces) ? workspaces.find((x) => x?.path === folder) : undefined;
3115
+ return isText(w?.title, 512) ? w.title : basename(folder) || undefined;
3116
+ }
3117
+ /** The agent's last reply text in the chat's last turn (Tether's Turn finished push), or '' when that turn has none.
3118
+ * A reply that a rollback withdrew does not count. */
3119
+ export function lastReply(events) {
3120
+ const start = events.findLastIndex((e) => e.type === 'turn/start');
3121
+ const gone = withdrawn(events);
3122
+ for (let i = events.length - 1; i > start; i--) {
3123
+ const text = events[i].type === 'assistant/message' && !gone(events[i].seq) ? textBlocks(events[i].data?.message?.content).trim() : '';
3124
+ if (text !== '') return text;
3125
+ }
3126
+ return '';
3127
+ }
3084
3128
  /** The recent conversation for the prediction: the user's own messages and the agent's replies (no tools, no hidden
3085
3129
  * context), oldest first, the newest kept when it is long. `undefined` when the chat has no reply after its last message. */
3086
3130
  export function predictTranscript(events) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-composer-plus",
3
- "version": "1.21.1",
3
+ "version": "1.22.0",
4
4
  "description": "DeepSeek Harness helpers: 11 extra UI languages (Türkçe, Español, Français, Deutsch, 日本語…), Changes panel, swipe-card feature ideas, sidebar search in message text, turn rollback with git file restore, delete chats to the Trash, input history, Shift+Tab /plan, one-click Continue, resume running chats after a restart, queue drag-and-drop, deeplinks, Auto Review allow-for-chat, notifications, iPhone access with the Tether app, automatic updates",
5
5
  "icon": "./assets/icon.svg",
6
6
  "license": "MIT",
@@ -79,7 +79,7 @@
79
79
  "assets/features/*.webp"
80
80
  ],
81
81
  "scripts": {
82
- "test": "node scripts/build-locales.mjs --check && node scripts/build-docs.mjs --check && node test/locale.test.mjs && node test/continue.test.mjs && node test/continue-host.test.mjs && node test/resume-restart.test.mjs && node test/content-search.test.mjs && node test/turn-rollback.test.mjs && node test/turn-rollback-client.test.mjs && node test/delete-chat.test.mjs && node test/export-chat.test.mjs && node test/project-name.test.mjs && node test/queue-drag.test.mjs && node test/queue-move.test.mjs && node test/queue-edit.test.mjs && node test/send-first.test.mjs && node test/message-link.test.mjs && node test/mark-read.test.mjs && node test/plan-toggle.test.mjs && node test/keep-focus.test.mjs && node test/question-fit.test.mjs && node test/header-fit.test.mjs && node test/mobile-connect.test.mjs && node test/unread.test.mjs && node test/unread-client.test.mjs && node test/tether-gateway.test.mjs && node test/tether-parity.test.mjs && node test/tether-notify-per-mac.test.mjs && node test/deeplink.test.mjs && node test/chat-grant.test.mjs && node test/reject-note.test.mjs && node test/grant-ttl.test.mjs && node test/notify.test.mjs && node test/idle-switch.test.mjs && node test/next-waiting.test.mjs && node test/option-picks.test.mjs && node test/option-picks-ui.test.mjs && node test/changes.test.mjs && node test/chat-changes.test.mjs && node test/changes-tree.test.mjs && node test/file-chats.test.mjs && node test/file-chats-client.test.mjs && node test/feature-ideas.test.mjs && node test/feature-ideas-client.test.mjs && node test/github-issue.test.mjs && node test/project-status.test.mjs && node test/session-branch.test.mjs && node test/recent-projects.test.mjs && node test/project-tools.test.mjs && node test/has-selectors.test.mjs && node test/project-picker.test.mjs && node test/worktree-chats.test.mjs && node test/worktree-chats-client.test.mjs && node test/branch-tree.test.mjs && node test/branch-tree-client.test.mjs && node test/default-workspace.test.mjs && node test/default-workspace-client.test.mjs && node test/find-in-chat.test.mjs && node test/predict.test.mjs && node test/predict-client.test.mjs && node test/complete.test.mjs && node test/complete-client.test.mjs && node test/prompt-improve.test.mjs && node test/prompt-improve-client.test.mjs && node test/goal-check.test.mjs && node test/goal-check-client.test.mjs && node test/backup.test.mjs && node test/backup-host.test.mjs && node test/backup-client.test.mjs && node test/project-memory.test.mjs && node test/memory-editor.test.mjs && node test/memory-base.test.mjs && node test/memory-history.test.mjs && node test/memory-export.test.mjs && node test/memory-source.test.mjs && node test/memory-copy.test.mjs && node test/memory-stale.test.mjs && node test/memory-per-chat.test.mjs && node test/memory-suggest.test.mjs && node test/status-order.test.mjs && node test/toggles.test.mjs && node test/update.test.mjs && node test/whats-new-current.test.mjs && node test/release-notes.test.mjs && node test/active-count.test.mjs && node test/meta.test.mjs && node test/shots.test.mjs && node test/readme.test.mjs && node test/readme-labels.test.mjs && node test/feature-docs.test.mjs && node test/routes.test.mjs && node test/protected-files.test.mjs && node test/blocked-commands.test.mjs && node test/blocked-commands-client.test.mjs && node test/handoff.test.mjs && node test/context-breakdown.test.mjs && node test/task-board.test.mjs && node test/task-board-client.test.mjs && node test/planned-steps.test.mjs && node --test --test-timeout=120000 test/computers.test.mjs test/computers-host.test.mjs && node test/computers-client.test.mjs && node test/secret-input.test.mjs && node test/secret-input-client.test.mjs && node test/quote-reply.test.mjs && node test/side-chat.test.mjs && node test/diff-comments.test.mjs",
82
+ "test": "node scripts/build-locales.mjs --check && node scripts/build-docs.mjs --check && node test/locale.test.mjs && node test/continue.test.mjs && node test/continue-host.test.mjs && node test/resume-restart.test.mjs && node test/content-search.test.mjs && node test/turn-rollback.test.mjs && node test/turn-rollback-client.test.mjs && node test/delete-chat.test.mjs && node test/export-chat.test.mjs && node test/project-name.test.mjs && node test/queue-drag.test.mjs && node test/queue-move.test.mjs && node test/queue-edit.test.mjs && node test/send-first.test.mjs && node test/message-link.test.mjs && node test/mark-read.test.mjs && node test/plan-toggle.test.mjs && node test/keep-focus.test.mjs && node test/question-fit.test.mjs && node test/header-fit.test.mjs && node test/mobile-connect.test.mjs && node test/unread.test.mjs && node test/unread-client.test.mjs && node test/tether-gateway.test.mjs && node test/tether-parity.test.mjs && node test/tether-notify-per-mac.test.mjs && node test/tether-push-words.test.mjs && node test/deeplink.test.mjs && node test/chat-grant.test.mjs && node test/reject-note.test.mjs && node test/grant-ttl.test.mjs && node test/notify.test.mjs && node test/idle-switch.test.mjs && node test/next-waiting.test.mjs && node test/option-picks.test.mjs && node test/option-picks-ui.test.mjs && node test/changes.test.mjs && node test/chat-changes.test.mjs && node test/changes-tree.test.mjs && node test/file-chats.test.mjs && node test/file-chats-client.test.mjs && node test/feature-ideas.test.mjs && node test/feature-ideas-client.test.mjs && node test/github-issue.test.mjs && node test/project-status.test.mjs && node test/session-branch.test.mjs && node test/recent-projects.test.mjs && node test/project-tools.test.mjs && node test/has-selectors.test.mjs && node test/project-picker.test.mjs && node test/worktree-chats.test.mjs && node test/worktree-chats-client.test.mjs && node test/branch-tree.test.mjs && node test/branch-tree-client.test.mjs && node test/default-workspace.test.mjs && node test/default-workspace-client.test.mjs && node test/find-in-chat.test.mjs && node test/predict.test.mjs && node test/predict-client.test.mjs && node test/complete.test.mjs && node test/complete-client.test.mjs && node test/prompt-improve.test.mjs && node test/prompt-improve-client.test.mjs && node test/goal-check.test.mjs && node test/goal-check-client.test.mjs && node test/backup.test.mjs && node test/backup-host.test.mjs && node test/backup-client.test.mjs && node test/project-memory.test.mjs && node test/memory-editor.test.mjs && node test/memory-base.test.mjs && node test/memory-history.test.mjs && node test/memory-export.test.mjs && node test/memory-source.test.mjs && node test/memory-copy.test.mjs && node test/memory-stale.test.mjs && node test/memory-per-chat.test.mjs && node test/memory-suggest.test.mjs && node test/status-order.test.mjs && node test/toggles.test.mjs && node test/update.test.mjs && node test/whats-new-current.test.mjs && node test/release-notes.test.mjs && node test/active-count.test.mjs && node test/meta.test.mjs && node test/shots.test.mjs && node test/readme.test.mjs && node test/readme-labels.test.mjs && node test/feature-docs.test.mjs && node test/routes.test.mjs && node test/protected-files.test.mjs && node test/blocked-commands.test.mjs && node test/blocked-commands-client.test.mjs && node test/handoff.test.mjs && node test/context-breakdown.test.mjs && node test/task-board.test.mjs && node test/task-board-client.test.mjs && node test/planned-steps.test.mjs && node --test --test-timeout=120000 test/computers.test.mjs test/computers-host.test.mjs && node test/computers-client.test.mjs && node test/secret-input.test.mjs && node test/secret-input-client.test.mjs && node test/quote-reply.test.mjs && node test/side-chat.test.mjs && node test/diff-comments.test.mjs",
83
83
  "build:locales": "node scripts/build-locales.mjs",
84
84
  "build:readme": "node scripts/build-docs.mjs",
85
85
  "prepublishOnly": "npm test"
@@ -1,18 +1,18 @@
1
1
  {
2
- "version": "1.21.1",
2
+ "version": "1.22.0",
3
3
  "notes": {
4
- "en": "## Changes\n\n- **Project memory**: each `MEMORY.md` (the memory of a project and the global memory) can now hold 8,000 characters instead of 4,000. Topics still hold 16,000 characters. All of `MEMORY.md` goes into the system prompt of each new chat, so a full memory makes each request a little longer.\n\n## Fixes\n\n- **Composer Plus updates**: the button **What’s new in this version** shows the notes of the version that runs now. When a newer version is already on disk, a notice asks you to restart dsh.",
5
- "zh": "## 变更\n\n- **项目记忆**:每个 `MEMORY.md`(项目记忆和全局记忆)现在最多可保存 8,000 个字符,而不是 4,000 个。主题仍最多保存 16,000 个字符。`MEMORY.md` 的全部内容会进入每个新对话的系统提示,因此记忆写满后每个请求会稍长一些。\n\n## 修复\n\n- **Composer Plus 更新**:**此版本的新功能** 按钮显示当前运行版本的说明。磁盘上已有更新的版本时,会提示你重启 dsh。",
6
- "tr": "## Değişiklikler\n\n- **Proje hafızası**: her `MEMORY.md` dosyası (bir projenin hafızası ve global hafıza) artık 4.000 yerine 8.000 karakter tutabilir. Konular yine 16.000 karakter tutar. `MEMORY.md` dosyasının tamamı her yeni sohbetin sistem istemine girer; bu yüzden dolu bir hafıza her isteği biraz uzatır.\n\n## Düzeltmeler\n\n- **Composer Plus güncellemeleri**: **Bu sürümdeki yenilikler** düğmesi şu an çalışan sürümün notlarını gösterir. Diskte daha yeni bir sürüm varsa bir uyarı dsh’yi yeniden başlatmanızı ister.",
7
- "es": "## Cambios\n\n- **Memoria del proyecto**: cada `MEMORY.md` (la memoria de un proyecto y la memoria global) ahora puede guardar 8.000 caracteres en lugar de 4.000. Los temas siguen guardando 16.000 caracteres. Todo `MEMORY.md` entra en el prompt del sistema de cada chat nuevo, así que una memoria llena hace cada solicitud un poco más larga.\n\n## Correcciones\n\n- **Actualizaciones de Composer Plus**: el botón **Novedades de esta versión** muestra las notas de la versión que se ejecuta ahora. Si ya hay una versión más nueva en el disco, un aviso le pide reiniciar dsh.",
8
- "pt-BR": "## Mudanças\n\n- **Memória do projeto**: cada `MEMORY.md` (a memória de um projeto e a memória global) agora pode guardar 8.000 caracteres em vez de 4.000. Os tópicos continuam guardando 16.000 caracteres. Todo o `MEMORY.md` entra no prompt de sistema de cada chat novo, então uma memória cheia deixa cada solicitação um pouco mais longa.\n\n## Correções\n\n- **Atualizações do Composer Plus**: o botão **Novidades desta versão** mostra as notas da versão que está em execução agora. Se já houver uma versão mais nova no disco, um aviso pede que você reinicie o dsh.",
9
- "fr": "## Changements\n\n- **Mémoire du projet** : chaque `MEMORY.md` (la mémoire d’un projet et la mémoire globale) peut maintenant contenir 8 000 caractères au lieu de 4 000. Les sujets contiennent toujours 16 000 caractères. Tout `MEMORY.md` entre dans le prompt système de chaque nouvelle discussion, donc une mémoire pleine rend chaque requête un peu plus longue.\n\n## Corrections\n\n- **Mises à jour de Composer Plus** : le bouton **Nouveautés de cette version** affiche les notes de la version qui s’exécute maintenant. Quand une version plus récente est déjà sur le disque, un avis vous demande de redémarrer dsh.",
10
- "de": "## Änderungen\n\n- **Projektgedächtnis**: Jede `MEMORY.md` (das Gedächtnis eines Projekts und das globale Gedächtnis) kann jetzt 8.000 statt 4.000 Zeichen enthalten. Themen enthalten weiterhin 16.000 Zeichen. Die ganze `MEMORY.md` kommt in den System-Prompt jedes neuen Chats, deshalb macht ein volles Gedächtnis jede Anfrage etwas länger.\n\n## Korrekturen\n\n- **Composer-Plus-Updates**: Die Schaltfläche **Neu in dieser Version** zeigt die Hinweise der Version, die jetzt läuft. Wenn schon eine neuere Version auf der Festplatte liegt, bittet ein Hinweis Sie, dsh neu zu starten.",
11
- "ru": "## Изменения\n\n- **Память проекта**: каждый `MEMORY.md` (память проекта и глобальная память) теперь может хранить 8 000 символов вместо 4 000. Темы по-прежнему хранят 16 000 символов. Весь `MEMORY.md` попадает в системный промпт каждого нового чата, поэтому полная память делает каждый запрос немного длиннее.\n\n## Исправления\n\n- **Обновления Composer Plus**: кнопка **Что нового в этой версии** показывает заметки версии, которая работает сейчас. Если на диске уже есть более новая версия, уведомление просит перезапустить dsh.",
12
- "ja": "## 変更\n\n- **プロジェクトメモリー**: 各 `MEMORY.md`(プロジェクトのメモリーとグローバルメモリー)に、4,000 文字ではなく 8,000 文字まで保存できるようになりました。トピックは引き続き 16,000 文字までです。`MEMORY.md` の全体が新しいチャットごとのシステムプロンプトに入るため、メモリーがいっぱいになると各リクエストが少し長くなります。\n\n## 修正\n\n- **Composer Plus のアップデート**: **このバージョンの新機能** ボタンは、いま実行中のバージョンのノートを表示します。より新しいバージョンがすでにディスクにある場合は、dsh の再起動を促す通知が表示されます。",
13
- "ko": "## 변경 사항\n\n- **프로젝트 메모리**: 각 `MEMORY.md`(프로젝트 메모리와 전역 메모리)에 이제 4,000자 대신 8,000자까지 저장할 수 있습니다. 주제는 그대로 16,000자까지입니다. `MEMORY.md` 전체가 새 채팅마다 시스템 프롬프트에 들어가므로, 메모리가 가득 차면 요청마다 조금 더 길어집니다.\n\n## 수정 사항\n\n- **Composer Plus 업데이트**: **이 버전의 새로운 기능** 버튼은 지금 실행 중인 버전의 노트를 보여 줍니다. 디스크에 더 새로운 버전이 이미 있으면 dsh를 다시 시작하라는 알림이 나타납니다.",
14
- "id": "## Perubahan\n\n- **Memori proyek**: setiap `MEMORY.md` (memori sebuah proyek dan memori global) kini dapat menyimpan 8.000 karakter, bukan 4.000. Topik tetap menyimpan 16.000 karakter. Seluruh `MEMORY.md` masuk ke prompt sistem setiap obrolan baru, jadi memori yang penuh membuat setiap permintaan sedikit lebih panjang.\n\n## Perbaikan\n\n- **Pembaruan Composer Plus**: tombol **Yang baru di versi ini** menampilkan catatan versi yang sedang berjalan sekarang. Jika versi yang lebih baru sudah ada di disk, sebuah pemberitahuan meminta Anda memulai ulang dsh.",
15
- "vi": "## Thay đổi\n\n- **Bộ nhớ dự án**: mỗi `MEMORY.md` (bộ nhớ của một dự án và bộ nhớ toàn cục) giờ có thể chứa 8.000 ký tự thay vì 4.000. Các chủ đề vẫn chứa 16.000 ký tự. Toàn bộ `MEMORY.md` được đưa vào lời nhắc hệ thống của mỗi cuộc trò chuyện mới, nên bộ nhớ đầy sẽ làm mỗi yêu cầu dài hơn một chút.\n\n## Sửa lỗi\n\n- **Cập nhật Composer Plus**: nút **Có gì mới trong phiên bản này** hiển thị ghi chú của phiên bản đang chạy. Khi đã có phiên bản mới hơn trên ổ đĩa, một thông báo sẽ yêu cầu bạn khởi động lại dsh.",
16
- "hi": "## बदलाव\n\n- **प्रोजेक्ट मेमोरी**: हर `MEMORY.md` (किसी प्रोजेक्ट की मेमोरी और ग्लोबल मेमोरी) में अब 4,000 के बजाय 8,000 अक्षर रखे जा सकते हैं। विषयों में पहले की तरह 16,000 अक्षर रहते हैं। पूरा `MEMORY.md` हर नई चैट के सिस्टम प्रॉम्प्ट में जाता है, इसलिए भरी हुई मेमोरी हर अनुरोध को थोड़ा लंबा कर देती है।\n\n## सुधार\n\n- **Composer Plus अपडेट**: **इस वर्शन में नया क्या है** बटन अभी चल रहे वर्शन के नोट्स दिखाता है। जब डिस्क पर पहले से नया वर्शन हो, तो एक सूचना आपसे dsh को फिर से शुरू करने के लिए कहती है।"
4
+ "en": "## New\n\n- **Mobile connect (iPhone)**: Tether notifications can come also while Tether is closed. Put an APNs key in `apns.json` on the Mac (see `tether/README.md`). The Mac then sends the approval, question, error and Turn finished notifications through Apple’s push service. The Mac must be awake with dsh open. Each notification shows the Mac and the chat’s project (`Studio Mac · recipe-box`) and the chat’s title. Turn finished shows the agent’s last reply. This needs a Tether build from this version.\n\n## Fixes\n\n- **Secret input**: the card of a secret request no longer stays inside a closed step group. In the desktop view, the card opens its group, so you see it at once.\n- In a narrow chat, the approval card with **Allow for this chat** and **Reject with note…** puts its buttons on more lines. Each label stays on one line in its button.",
5
+ "zh": "## 新功能\n\n- **手机连接(iPhone)**:Tether 关闭时也能收到 Tether 通知。在 Mac 上把 APNs 密钥放入 `apns.json`(见 `tether/README.md`)。之后 Mac 通过 Apple 推送服务发送审批、提问、错误和 Turn finished 通知。Mac 必须处于唤醒状态并打开 dsh。每条通知显示 Mac 和对话的项目(`Studio Mac · recipe-box`)以及对话标题。Turn finished 显示代理的最后一条回复。这需要此版本的 Tether 构建。\n\n## 修复\n\n- **密钥输入**:密钥请求的卡片不再停留在折叠的步骤组中。在桌面视图中,卡片会展开它所在的组,让你立即看到它。\n- 在较窄的对话中,带有 **本对话内允许** 和 **拒绝并附言…** 的审批卡片会把按钮分成多行。每个按钮的文字保持在一行内。",
6
+ "tr": "## Yeni\n\n- **Mobil bağlantı (iPhone)**: Tether bildirimleri Tether kapalıyken de gelebilir. Mac’te `apns.json` dosyasına bir APNs anahtarı koyun (bkz. `tether/README.md`). Mac bundan sonra onay, soru, hata ve Turn finished bildirimlerini Apple’ın push hizmetiyle gönderir. Mac uyanık ve dsh açık olmalıdır. Her bildirim Mac’i ve sohbetin projesini (`Studio Mac · recipe-box`) ve sohbetin başlığını gösterir. Turn finished, ajanın son yanıtını gösterir. Bunun için bu sürümden bir Tether derlemesi gerekir.\n\n## Düzeltmeler\n\n- **Gizli değer girişi**: gizli değer isteğinin kartı artık kapalı bir adım grubunun içinde kalmaz. Masaüstü görünümünde kart kendi grubunu açar; böylece kartı hemen görürsünüz.\n- Dar bir sohbette, **Bu sohbet boyunca izin ver** ve **Not ile reddet…** düğmelerinin olduğu onay kartı düğmelerini birden çok satıra dizer. Her düğmenin yazısı tek satırda kalır.",
7
+ "es": "## Novedades\n\n- **Conexión móvil (iPhone)**: las notificaciones de Tether pueden llegar también con Tether cerrado. Pon una clave de APNs en `apns.json` en el Mac (consulta `tether/README.md`). Entonces el Mac envía las notificaciones de aprobación, pregunta, error y Turn finished mediante el servicio de notificaciones push de Apple. El Mac debe estar activo y con dsh abierto. Cada notificación muestra el Mac y el proyecto del chat (`Studio Mac · recipe-box`) y el título del chat. Turn finished muestra la última respuesta del agente. Hace falta una compilación de Tether de esta versión.\n\n## Correcciones\n\n- **Entrada secreta**: la tarjeta de una solicitud de secreto ya no se queda dentro de un grupo de pasos cerrado. En la vista de escritorio, la tarjeta abre su grupo, así que la ves enseguida.\n- En un chat estrecho, la tarjeta de aprobación con **Permitir en este chat** y **Rechazar con nota…** reparte sus botones en varias líneas. Cada texto queda en una sola línea dentro de su botón.",
8
+ "pt-BR": "## Novidades\n\n- **Conexão móvel (iPhone)**: as notificações do Tether podem chegar também com o Tether fechado. Coloque uma chave de APNs em `apns.json` no Mac (veja `tether/README.md`). Então o Mac envia as notificações de aprovação, pergunta, erro e Turn finished pelo serviço de notificações push da Apple. O Mac precisa estar ativo e com o dsh aberto. Cada notificação mostra o Mac e o projeto do chat (`Studio Mac · recipe-box`) e o título do chat. Turn finished mostra a última resposta do agente. É preciso uma build do Tether desta versão.\n\n## Correções\n\n- **Entrada secreta**: o cartão de um pedido de segredo não fica mais dentro de um grupo de etapas fechado. Na visualização do desktop, o cartão abre o grupo dele, então você o vê na hora.\n- Em um chat estreito, o cartão de aprovação com **Permitir neste chat** e **Rejeitar com nota…** distribui os botões em mais linhas. Cada texto fica em uma linha dentro do botão.",
9
+ "fr": "## Nouveautés\n\n- **Connexion mobile (iPhone)** : les notifications de Tether peuvent arriver aussi quand Tether est fermé. Placez une clé APNs dans `apns.json` sur le Mac (voir `tether/README.md`). Le Mac envoie alors les notifications d’approbation, de question, d’erreur et Turn finished par le service de notifications push d’Apple. Le Mac doit être éveillé, avec dsh ouvert. Chaque notification indique le Mac et le projet de la discussion (`Studio Mac · recipe-box`) ainsi que le titre de la discussion. Turn finished affiche la dernière réponse de l’agent. Il faut une version de Tether compilée à partir de cette version.\n\n## Corrections\n\n- **Saisie secrète** : la carte d’une demande de secret ne reste plus dans un groupe d’étapes fermé. Dans la vue bureau, la carte ouvre son groupe, vous la voyez donc tout de suite.\n- Dans une discussion étroite, la carte d’approbation avec **Autoriser pour cette discussion** et **Refuser avec une note…** répartit ses boutons sur plusieurs lignes. Chaque libellé reste sur une ligne dans son bouton.",
10
+ "de": "## Neu\n\n- **Mobile Verbindung (iPhone)**: Tether-Benachrichtigungen kommen jetzt auch, wenn Tether geschlossen ist. Lege auf dem Mac einen APNs-Schlüssel in `apns.json` ab (siehe `tether/README.md`). Der Mac sendet dann die Benachrichtigungen zu Freigabe, Frage, Fehler und Turn finished über Apples Push-Dienst. Der Mac muss wach sein und dsh offen haben. Jede Benachrichtigung zeigt den Mac und das Projekt des Chats (`Studio Mac · recipe-box`) sowie den Titel des Chats. Turn finished zeigt die letzte Antwort des Agenten. Dafür ist ein Tether-Build aus dieser Version nötig.\n\n## Korrekturen\n\n- **Geheime Eingabe**: Die Karte einer Geheimnis-Anfrage bleibt nicht mehr in einer geschlossenen Schrittgruppe. In der Desktop-Ansicht öffnet die Karte ihre Gruppe, sodass du sie sofort siehst.\n- In einem schmalen Chat verteilt die Freigabekarte mit **Für diesen Chat zulassen** und **Mit Notiz ablehnen…** ihre Schaltflächen auf mehrere Zeilen. Jede Beschriftung bleibt einzeilig in ihrer Schaltfläche.",
11
+ "ru": "## Новое\n\n- **Мобильное подключение (iPhone)**: уведомления Tether приходят и тогда, когда Tether закрыт. Положите ключ APNs в `apns.json` на Mac (см. `tether/README.md`). После этого Mac отправляет уведомления об одобрении, вопросе, ошибке и Turn finished через push-сервис Apple. Mac должен быть активен, а dsh открыт. Каждое уведомление показывает Mac и проект чата (`Studio Mac · recipe-box`), а также название чата. Turn finished показывает последний ответ агента. Для этого нужна сборка Tether из этой версии.\n\n## Исправления\n\n- **Ввод секрета**: карточка запроса секрета больше не остаётся внутри свёрнутой группы шагов. В режиме настольного приложения карточка раскрывает свою группу, и вы сразу её видите.\n- В узком чате карточка одобрения с кнопками **Разрешить для этого чата** и **Отклонить с примечанием…** располагает кнопки в несколько строк. Надпись каждой кнопки остаётся в одной строке внутри кнопки.",
12
+ "ja": "## 新機能\n\n- **モバイル接続(iPhone)**:Tether を閉じていても Tether の通知が届くようになりました。Mac の `apns.json` に APNs キーを置いてください(`tether/README.md` を参照)。すると Mac は、承認、質問、エラー、Turn finished の通知を Apple のプッシュサービスで送ります。Mac はスリープしておらず、dsh が開いている必要があります。各通知には Mac とチャットのプロジェクト(`Studio Mac · recipe-box`)、チャットのタイトルが表示されます。Turn finished にはエージェントの最後の返信が表示されます。このバージョンからビルドした Tether が必要です。\n\n## 修正\n\n- **シークレット入力**:シークレット要求のカードが、閉じたステップグループの中に隠れたままになることはなくなりました。デスクトップ表示では、カードが自分のグループを開くので、すぐに見えます。\n- 狭いチャットでは、**このチャットで許可** と **メモを付けて拒否…** がある承認カードのボタンが複数行に並びます。各ボタンの文字はボタンの中で 1 行に収まります。",
13
+ "ko": "## 새 기능\n\n- **모바일 연결(iPhone)**: Tether를 닫아 두어도 Tether 알림을 받을 수 있습니다. Mac의 `apns.json`에 APNs 키를 넣으세요(`tether/README.md` 참고). 그러면 Mac이 승인, 질문, 오류, Turn finished 알림을 Apple 푸시 서비스로 보냅니다. Mac은 깨어 있고 dsh가 열려 있어야 합니다. 각 알림에는 Mac과 채팅의 프로젝트(`Studio Mac · recipe-box`), 채팅 제목이 표시됩니다. Turn finished에는 에이전트의 마지막 답변이 표시됩니다. 이 버전으로 빌드한 Tether가 필요합니다.\n\n## 수정 사항\n\n- **비밀 값 입력**: 비밀 값 요청 카드가 더 이상 닫힌 단계 그룹 안에 남아 있지 않습니다. 데스크톱 보기에서 카드가 자기 그룹을 열어 바로 보입니다.\n- 좁은 채팅에서 **이 채팅에서 허용**과 **메모와 함께 거부…**가 있는 승인 카드는 버튼을 여러 줄에 배치합니다. 각 버튼의 글자는 버튼 안에서 한 줄로 유지됩니다.",
14
+ "id": "## Baru\n\n- **Koneksi seluler (iPhone)**: notifikasi Tether kini bisa datang juga saat Tether tertutup. Taruh kunci APNs di `apns.json` pada Mac (lihat `tether/README.md`). Lalu Mac mengirim notifikasi persetujuan, pertanyaan, galat, dan Turn finished lewat layanan push Apple. Mac harus menyala (tidak tidur) dengan dsh terbuka. Setiap notifikasi menampilkan Mac dan proyek obrolan (`Studio Mac · recipe-box`) serta judul obrolan. Turn finished menampilkan balasan terakhir agen. Ini memerlukan build Tether dari versi ini.\n\n## Perbaikan\n\n- **Input rahasia**: kartu permintaan rahasia tidak lagi tetap berada di dalam grup langkah yang tertutup. Di tampilan desktop, kartu membuka grupnya sendiri sehingga Anda langsung melihatnya.\n- Di obrolan yang sempit, kartu persetujuan dengan **Izinkan untuk obrolan ini** dan **Tolak dengan catatan…** menyusun tombolnya dalam beberapa baris. Teks setiap tombol tetap satu baris di dalam tombolnya.",
15
+ "vi": "## Mới\n\n- **Kết nối di động (iPhone)**: thông báo của Tether giờ có thể đến cả khi Tether đã đóng. Đặt một khóa APNs vào `apns.json` trên Mac (xem `tether/README.md`). Khi đó Mac gửi các thông báo phê duyệt, câu hỏi, lỗi và Turn finished qua dịch vụ push của Apple. Mac phải đang thức và dsh phải đang mở. Mỗi thông báo hiển thị Mac và dự án của cuộc trò chuyện (`Studio Mac · recipe-box`) cùng tiêu đề cuộc trò chuyện. Turn finished hiển thị câu trả lời cuối cùng của agent. Cần một bản build Tether từ phiên bản này.\n\n## Sửa lỗi\n\n- **Nhập giá trị bí mật**: thẻ của một yêu cầu bí mật không còn nằm trong một nhóm bước đã thu gọn. Ở chế độ xem desktop, thẻ tự mở nhóm của nó nên bạn thấy thẻ ngay.\n- Trong một cuộc trò chuyện hẹp, thẻ phê duyệt có **Cho phép trong cuộc trò chuyện này** và **Từ chối kèm ghi chú…** xếp các nút thành nhiều dòng. Chữ của mỗi nút nằm trên một dòng trong nút của nó.",
16
+ "hi": "## नया\n\n- **मोबाइल कनेक्ट (iPhone)**: Tether बंद होने पर भी अब Tether सूचनाएँ आ सकती हैं। Mac पर `apns.json` में एक APNs कुंजी रखें (`tether/README.md` देखें)। इसके बाद Mac अनुमति, प्रश्न, त्रुटि और Turn finished सूचनाएँ Apple की पुश सेवा से भेजता है। Mac जागा हुआ होना चाहिए और dsh खुला होना चाहिए। हर सूचना Mac और चैट का प्रोजेक्ट (`Studio Mac · recipe-box`) और चैट का शीर्षक दिखाती है। Turn finished एजेंट का आखिरी जवाब दिखाता है। इसके लिए इस वर्शन से बना Tether बिल्ड चाहिए।\n\n## सुधार\n\n- **सीक्रेट इनपुट**: सीक्रेट अनुरोध का कार्ड अब किसी बंद स्टेप समूह के अंदर नहीं रहता। डेस्कटॉप व्यू में कार्ड अपना समूह खोल देता है, इसलिए आप उसे तुरंत देखते हैं।\n- संकरी चैट में, **इस चैट के लिए अनुमति दें** और **नोट के साथ अस्वीकार करें…** वाला अनुमति कार्ड अपने बटन कई पंक्तियों में रखता है। हर बटन का लेबल बटन के अंदर एक ही पंक्ति में रहता है।"
17
17
  }
18
18
  }
@@ -4,7 +4,8 @@
4
4
  import { createServer as createHttps, request as httpsRequest } from 'node:https'
5
5
  import { Agent as HttpAgent, createServer as createHttp, request as httpRequest } from 'node:http'
6
6
  import { connect as tlsConnect } from 'node:tls'
7
- import { createHash, randomBytes, randomUUID, timingSafeEqual, X509Certificate } from 'node:crypto'
7
+ import { connect as h2Connect } from 'node:http2'
8
+ import { createHash, createPrivateKey, randomBytes, randomUUID, sign as cryptoSign, timingSafeEqual, X509Certificate } from 'node:crypto'
8
9
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
9
10
  import { execFile, execFileSync, spawn } from 'node:child_process'
10
11
  import { homedir, hostname, networkInterfaces } from 'node:os'
@@ -389,6 +390,79 @@ export function createRelay({ host, port, fp, token, ticketTtlMs = 60e3, onUnaut
389
390
  }
390
391
  }
391
392
 
393
+ // ---- Push (APNs): Tether's notifications also while it is closed. ----
394
+ /** An APNs provider token: a JWT (ES256) of the `.p8` key `key`, its `keyId` and the `teamId`, issued at `iat` (seconds). */
395
+ export function apnsJwt({ key, keyId, teamId }, iat = Math.floor(Date.now() / 1000)) {
396
+ const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url')
397
+ const data = b64({ alg: 'ES256', kid: keyId }) + '.' + b64({ iss: teamId, iat })
398
+ return data + '.' + cryptoSign('sha256', Buffer.from(data), { key, dsaEncoding: 'ieee-p1363' }).toString('base64url')
399
+ }
400
+ /** Inside Tether's "Only between these hours" (`{from, to, tz}`, minutes after midnight in the phone's time zone) at `now`,
401
+ * with Tether's rule: To before From goes past midnight, the same time for both is all day. No hours: every hour. */
402
+ export function inHours(hours, now = new Date()) {
403
+ if (!hours || hours.from === hours.to) return true
404
+ let t
405
+ try {
406
+ const [h, m] = new Intl.DateTimeFormat('en-GB', { timeZone: hours.tz, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(now).split(':').map(Number)
407
+ t = h * 60 + m
408
+ } catch { return true } // an unknown time zone: every hour, as with no hours
409
+ return hours.from < hours.to ? t >= hours.from && t < hours.to : t >= hours.from || t < hours.to
410
+ }
411
+ /** Sends alerts to APNs over HTTP/2 with a provider token. `cfg`: `{ key, keyId, teamId, topic }` (`key` a private key
412
+ * object). `send(token, sandbox, payload)` answers `{ status, reason }` (200: APNs took it). `origin` replaces Apple's
413
+ * server (tests). */
414
+ export function apnsSender(cfg, { origin } = {}) {
415
+ const sessions = new Map()
416
+ let jwt, jwtAt = 0
417
+ const session = (sandbox) => {
418
+ const o = origin ?? (sandbox ? 'https://api.sandbox.push.apple.com' : 'https://api.push.apple.com')
419
+ let s = sessions.get(o)
420
+ if (!s || s.closed || s.destroyed) {
421
+ s = h2Connect(o)
422
+ s.unref()
423
+ s.on('error', () => {}).on('goaway', () => s.close()).on('close', () => { if (sessions.get(o) === s) sessions.delete(o) })
424
+ sessions.set(o, s)
425
+ }
426
+ return s
427
+ }
428
+ const send = (token, sandbox, payload) => new Promise((done) => {
429
+ const now = Date.now()
430
+ // APNs refuses a token older than an hour, and one that changes more often than every 20 minutes.
431
+ if (!jwt || now - jwtAt > 50 * 60e3) { jwt = apnsJwt(cfg); jwtAt = now }
432
+ let req
433
+ try {
434
+ req = session(sandbox).request({ ':method': 'POST', ':path': '/3/device/' + token, authorization: 'bearer ' + jwt,
435
+ 'apns-topic': cfg.topic, 'apns-push-type': 'alert', 'apns-priority': '10',
436
+ // APNs keeps it for an hour while the iPhone is off or has no network.
437
+ 'apns-expiration': String(Math.floor(now / 1000) + 3600), 'content-type': 'application/json' })
438
+ } catch (e) { return done({ status: 0, reason: e.message }) }
439
+ let status = 0, body = ''
440
+ const timer = setTimeout(() => req.close(), 10e3)
441
+ timer.unref()
442
+ req.setEncoding('utf8')
443
+ req.on('response', (h) => { status = h[':status'] }).on('data', (d) => { if (body.length < 4096) body += d }).on('error', () => {})
444
+ req.on('close', () => {
445
+ clearTimeout(timer)
446
+ let reason
447
+ try { reason = JSON.parse(body).reason } catch {}
448
+ if (reason === 'ExpiredProviderToken') jwt = undefined
449
+ done({ status, reason })
450
+ })
451
+ req.end(JSON.stringify(payload))
452
+ })
453
+ return { send, close: () => { for (const s of sessions.values()) s.close(); sessions.clear() } }
454
+ }
455
+ /** The APNs settings of this Mac: `apns.json` in the gateway folder, `{ keyFile, keyId, teamId, topic }` (the `.p8` key
456
+ * that Apple gives). No file: no push. A wrong file: no push, and `error` says why. */
457
+ function loadApns(file) {
458
+ if (!existsSync(file)) return {}
459
+ try {
460
+ const c = JSON.parse(readFileSync(file, 'utf8'))
461
+ if (typeof c?.keyFile !== 'string' || !/^[A-Z0-9]{10}$/.test(c.keyId) || !/^[A-Z0-9]{10}$/.test(c.teamId) || !/^[\w.-]{1,200}$/.test(c.topic)) throw new Error('needs keyFile, keyId, teamId and topic')
462
+ return { push: apnsSender({ key: createPrivateKey(readFileSync(c.keyFile)), keyId: c.keyId, teamId: c.teamId, topic: c.topic }) }
463
+ } catch (e) { return { error: 'apns.json: ' + e.message } }
464
+ }
465
+
392
466
  // ---- Upstream: the DSH host on loopback, authenticated with the URL that dsh-composer-plus (Mobile connect) gives. ----
393
467
  function send(method, url, body, headers = {}, signal) {
394
468
  return new Promise((ok, bad) => {
@@ -496,6 +570,43 @@ export function createGateway(opts = {}) {
496
570
  const conns = new Set(), devSockets = new Map(), pending = new Map()
497
571
  const reqHits = new Map(), fails = new Map()
498
572
  const evSubs = new Set(), pendingEv = new Map(), ev = { ws: null, clientId: null }
573
+ // Push (APNs): Tether's notifications also while it is closed. Each phone sends its device token and the notification
574
+ // settings of this Mac (`POST /push`); they stay in devices.json with the phone's pairing, so unpairing ends them.
575
+ // A Remote computers front door has no phones.
576
+ const apns = computer ? {} : opts.apns ? { push: apnsSender(opts.apns, { origin: opts.apns.origin }) } : loadApns(join(dir, 'apns.json'))
577
+ const pushTo = () => apns.push ? devices.filter(d => d.push?.on === true && typeof d.push.token === 'string') : []
578
+ /** One alert to each phone that wants this Mac's notifications now (switched on, inside its hours). The subtitle names
579
+ * this Mac and the chat's project; the body is the chat's title, then `detail` (the reply, tool, question or error).
580
+ * `opts.about(sessionId)` gives `{ chat, project }` (the Host half); without it the subtitle is the Mac name only. */
581
+ function pushAll(title, detail, sessionId) {
582
+ const to = pushTo().filter(d => inHours(d.push.hours))
583
+ if (to.length === 0) return
584
+ let about = {}
585
+ try { if (typeof sessionId === 'string') about = opts.about?.(sessionId) ?? {} } catch {}
586
+ const some = s => typeof s === 'string' && s.trim() !== ''
587
+ // ponytail: 160 + 600 characters keep the payload under APNs' 4 KB also for 3-byte (CJK) text.
588
+ const cut = (s, n) => [...s].slice(0, n).join('') // by code point: an emoji is not split in half
589
+ const subtitle = cut([mac, about.project].filter(some).join(' · '), 160)
590
+ const body = cut([about.chat, detail].filter(some).join('\n'), 600)
591
+ for (const d of to) {
592
+ const token = d.push.token
593
+ apns.push.send(token, d.push.sandbox === true, { aps: { alert: { title, subtitle, body }, sound: 'default' } }).then(r => {
594
+ // The app left the iPhone (or the token is not for this app): forget the token; the phone sends a new one.
595
+ if ((r.status === 410 || r.reason === 'BadDeviceToken') && d.push?.token === token) { delete d.push.token; saveDevices() }
596
+ }, () => {})
597
+ }
598
+ }
599
+ /** `POST /push` from a paired phone: `{ token, sandbox, on, hours: {from, to, tz} | null }`. */
600
+ async function setPush(req, res, dev) {
601
+ let b
602
+ try { b = JSON.parse(await readBody(req, 4096)) } catch { return reply(res, 400, { error: 'bad request' }) }
603
+ const mins = v => Number.isInteger(v) && v >= 0 && v < 1440
604
+ const hours = b?.hours === null ? null : mins(b?.hours?.from) && mins(b.hours.to) && typeof b.hours.tz === 'string' && b.hours.tz.length <= 64 ? { from: b.hours.from, to: b.hours.to, tz: b.hours.tz } : undefined
605
+ if (typeof b?.on !== 'boolean' || (b.on && (!/^[0-9a-f]{64,200}$/i.test(b.token ?? '') || typeof b.sandbox !== 'boolean' || hours === undefined))) return reply(res, 400, { error: 'bad request' })
606
+ dev.push = b.on ? { on: true, token: b.token.toLowerCase(), sandbox: b.sandbox, hours } : { on: false }
607
+ saveDevices()
608
+ reply(res, 200, { push: b.on })
609
+ }
499
610
 
500
611
  // Rate limits. shortcut: fixed in-memory windows per IP, fine for one household LAN; persist/upgrade if exposed wider.
501
612
  const bump = (map, k, ms) => { const now = Date.now(), b = map.get(k); if (!b || now - b.t > ms) { map.set(k, { t: now, n: 1 }); return 1 } return ++b.n }
@@ -619,14 +730,18 @@ export function createGateway(opts = {}) {
619
730
  if (f.type !== 'item') { ev.ws?.close(); return }
620
731
  const v = f.value ?? {}
621
732
  if (v.type === 'ready') { ev.clientId = v.clientId; upState = 'connected'; return } // host.home is dropped here
733
+ if (v.type === 'emit' && v.event === 'api-session/error' && typeof v.args?.[1] === 'string' && v.args[1]) pushAll('DSH error', v.args[1], v.args[0])
622
734
  if (v.type === 'emit' && EVENTS.has(v.event)) return toPhones({ type: 'emit', event: v.event, args: v.args })
623
735
  if (v.type === 'cancel') looking.delete(v.eventId)
624
736
  if (v.type === 'cancel' && dropPending(v.eventId)) return toPhones({ type: 'cancel', eventId: v.eventId })
625
737
  if (v.type !== 'waterfall') return
626
738
  if (v.event === 'approval/request') return grantedOrAsk(v)
627
- if (!EVENTS.has(v.event) || !evSubs.size) return answer(v.eventId, { kind: 'next' }).catch(() => {})
739
+ if (!EVENTS.has(v.event) || !holds()) return answer(v.eventId, { kind: 'next' }).catch(() => {})
628
740
  askPhones(v)
629
741
  }
742
+ /** The gateway keeps an ask for the phones while one is connected, or while a phone gets pushes: the push opens Tether,
743
+ * and Tether gets the ask when it connects. dsh's desktop can answer it at the same time. */
744
+ const holds = () => evSubs.size > 0 || pushTo().length > 0
630
745
  /** Allow for this chat, also with no dsh window open: the gateway allows a granted tool itself (the grants that the
631
746
  * desktop and the phone save in dsh-composer-plus). Any other approval goes to the phones. */
632
747
  async function grantedOrAsk(v) {
@@ -635,11 +750,13 @@ export function createGateway(opts = {}) {
635
750
  try { const r = await upstream.rpc('/api/composer-plus.grants', undefined, 'GET'); if (r.status === 200) grants = JSON.parse(r.body) } catch {}
636
751
  if (!looking.delete(v.eventId)) return // answered elsewhere (the desktop) while the grants were read
637
752
  if (isGrantedAsk(v, grants)) return answer(v.eventId, { kind: 'result', value: 'allowed-once' }).catch(() => {})
638
- if (!evSubs.size) return answer(v.eventId, { kind: 'next' }).catch(() => {})
753
+ if (!holds()) return answer(v.eventId, { kind: 'next' }).catch(() => {})
639
754
  askPhones(v)
640
755
  }
641
756
  function askPhones(v) {
642
757
  const frame = { type: 'waterfall', event: v.event, eventId: v.eventId, agentId: v.agentId, request: v.request }
758
+ if (v.event === 'approval/request') pushAll('Approval needed', v.request?.toolName, v.agentId)
759
+ else pushAll('DSH has a question', v.request?.questions?.[0]?.question, v.agentId)
643
760
  // shortcut: unanswered phone prompts fall back to 'next' (DSH desktop still decides) after a fixed timeout; make it configurable per event if users want longer.
644
761
  const timer = setTimeout(() => { if (dropPending(v.eventId, { kind: 'next' })) toPhones({ type: 'cancel', eventId: v.eventId }) }, waterfallTtl)
645
762
  pendingEv.set(v.eventId, { frame, timer })
@@ -687,7 +804,9 @@ export function createGateway(opts = {}) {
687
804
  if (!dev) { fail(ip); logAttempt(ip, null, 'refused', 'token'); return reply(res, 401, { error: 'unauthorized' }) }
688
805
  track(dev, req.socket)
689
806
  if (computer) return toDsh(req, res)
690
- if (req.method === 'GET' && path === '/me') return reply(res, 200, { deviceId: dev.id, name: dev.name, expiresAt: dev.expiresAt, upstream: upState, tailscale: ts.online ? ts.ip : null, mac, plus: [...PLUS.keys()] })
807
+ // `push`: this Mac can send Tether's notifications through APNs (it has an APNs key); an older gateway leaves it out.
808
+ if (req.method === 'GET' && path === '/me') return reply(res, 200, { deviceId: dev.id, name: dev.name, expiresAt: dev.expiresAt, upstream: upState, tailscale: ts.online ? ts.ip : null, mac, plus: [...PLUS.keys()], push: apns.push !== undefined })
809
+ if (req.method === 'POST' && path === '/push') return setPush(req, res, dev)
691
810
  if (path.startsWith('/plus/')) return plus(req, res, path.slice(6))
692
811
  if (req.method !== 'POST' || !path.startsWith('/api/')) return reply(res, 404, { error: 'not found' })
693
812
  const method = path.slice(5)
@@ -773,7 +892,7 @@ export function createGateway(opts = {}) {
773
892
  // A failed connect (DSH restarting) is forgotten so the next stream open retries instead of failing forever.
774
893
  const getUp = () => upP ??= upstream.mux().then(u => { up = u; u.onText = t => ws.send(t); u.onClose = () => ws.close(1011); return u }, e => { upP = null; throw e })
775
894
  const err = (streamId, message) => ws.send(JSON.stringify({ type: 'error', streamId, error: { code: 'mobile/forbidden', message } }))
776
- const unsub = id => { const s = subs.get(id); if (!s) return; subs.delete(id); evSubs.delete(s); if (!evSubs.size) for (const e of [...pendingEv.keys()]) dropPending(e, { kind: 'next' }) }
895
+ const unsub = id => { const s = subs.get(id); if (!s) return; subs.delete(id); evSubs.delete(s); if (!holds()) for (const e of [...pendingEv.keys()]) dropPending(e, { kind: 'next' }) }
777
896
  ws.onText = async text => {
778
897
  let f
779
898
  try { f = JSON.parse(text) } catch { return ws.close(1007) }
@@ -878,7 +997,9 @@ export function createGateway(opts = {}) {
878
997
  code: code && Date.now() < code.expires ? { payload: code.payload, expiresAt: new Date(code.expires).toISOString(), qr: qrReady } : null,
879
998
  pending: [...pending.values()].map(({ id, name, ip, at }) => ({ id, name, ip, at, expiresAt: new Date(at + approveTtl).toISOString() })),
880
999
  last: last && Date.now() - last.at < 60e3 ? { ...last, at: new Date(last.at).toISOString() } : null,
881
- devices: devices.map(({ tokenHash, ...d }) => ({ ...d, connections: devSockets.get(d.id)?.size ?? 0 })),
1000
+ // The APNs device token stays here: the Mac page shows only whether the phone gets pushes.
1001
+ devices: devices.map(({ tokenHash, push, ...d }) => ({ ...d, push: push?.on === true && typeof push.token === 'string', connections: devSockets.get(d.id)?.size ?? 0 })),
1002
+ ...(apns.error ? { pushError: apns.error } : {}),
882
1003
  })
883
1004
 
884
1005
  // ---- Mac admin page: loopback only, plain HTTP. ----
@@ -919,7 +1040,7 @@ export function createGateway(opts = {}) {
919
1040
  return new Promise((ok, bad) => { adminServer.once('error', bad); adminServer.listen(p, '127.0.0.1', () => ok(adminServer.address().port)) })
920
1041
  }
921
1042
  async function close() {
922
- clearInterval(watch); await stop(); for (const p of pending.values()) p.res.destroy(); adminServer?.close(); adminServer?.closeAllConnections()
1043
+ clearInterval(watch); await stop(); for (const p of pending.values()) p.res.destroy(); adminServer?.close(); adminServer?.closeAllConnections(); apns.push?.close()
923
1044
  if (logTimer) flushLog()
924
1045
  if (seenDirty) try { saveDevices() } catch {}
925
1046
  }
@@ -927,6 +1048,6 @@ export function createGateway(opts = {}) {
927
1048
  /** An event from dsh-composer-plus itself (not from dsh's `$events`), for every connected phone. */
928
1049
  const emit = (event, args) => toPhones({ type: 'emit', event, args })
929
1050
 
930
- return { start, stop, close, state, newCode, settle, revoke, listenAdmin, emit, get port() { return port }, get fingerprint() { return fp } }
1051
+ return { start, stop, close, state, newCode, settle, revoke, listenAdmin, emit, push: pushAll, get port() { return port }, get fingerprint() { return fp } }
931
1052
  }
932
1053