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.de.md +3 -3
- package/README.es.md +3 -3
- package/README.fr.md +3 -3
- package/README.hi.md +3 -3
- package/README.id.md +3 -3
- package/README.ja.md +3 -3
- package/README.ko.md +3 -3
- package/README.md +3 -3
- package/README.pt-BR.md +3 -3
- package/README.ru.md +3 -3
- package/README.tr.md +3 -3
- package/README.vi.md +3 -3
- package/README.zh.md +3 -3
- package/client.js +27 -4
- package/index.js +47 -3
- package/package.json +2 -2
- package/release-notes.json +14 -14
- package/tether/gateway/server.mjs +129 -8
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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)
|
|
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.
|
|
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"
|
package/release-notes.json
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.
|
|
2
|
+
"version": "1.22.0",
|
|
3
3
|
"notes": {
|
|
4
|
-
"en": "##
|
|
5
|
-
"zh": "##
|
|
6
|
-
"tr": "##
|
|
7
|
-
"es": "##
|
|
8
|
-
"pt-BR": "##
|
|
9
|
-
"fr": "##
|
|
10
|
-
"de": "##
|
|
11
|
-
"ru": "##
|
|
12
|
-
"ja": "##
|
|
13
|
-
"ko": "##
|
|
14
|
-
"id": "##
|
|
15
|
-
"vi": "##
|
|
16
|
-
"hi": "##
|
|
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 {
|
|
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) || !
|
|
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 (!
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
|