dsh-composer-plus 1.24.0 → 1.25.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 +18 -9
- package/README.es.md +18 -9
- package/README.fr.md +18 -9
- package/README.hi.md +18 -9
- package/README.id.md +18 -9
- package/README.ja.md +18 -9
- package/README.ko.md +18 -9
- package/README.md +18 -9
- package/README.pt-BR.md +18 -9
- package/README.ru.md +18 -9
- package/README.tr.md +18 -9
- package/README.vi.md +18 -9
- package/README.zh.md +18 -9
- package/assets/features/assistant.webp +0 -0
- package/assets/features/assistantApart.webp +0 -0
- package/assets/features/assistantChatView.webp +0 -0
- package/assets/features/assistantFree.webp +0 -0
- package/assets/features/assistantNotify.webp +0 -0
- package/assets/features/memorySuggest.webp +0 -0
- package/client.js +265 -29
- package/index.js +212 -8
- package/package.json +2 -2
- package/release-notes.json +14 -14
- package/tether/gateway/admin.html +5 -0
- package/tether/gateway/server.mjs +45 -8
|
@@ -193,6 +193,11 @@ function render(s) {
|
|
|
193
193
|
put('devices', s.devices.map(d => '<tr><td><span class="dot ' + (d.connections ? 'ok' : '') + '"></span>' + esc(d.name) +
|
|
194
194
|
' <span class="mute">' + (d.connections ? 'online' : d.lastSeenAt ? 'last seen ' + esc(when(d.lastSeenAt)) : 'never connected') + '</span>' +
|
|
195
195
|
(d.createdAt ? '<br><span class="sub">Paired ' + esc(new Date(d.createdAt).toLocaleDateString()) + '</span>' : '') +
|
|
196
|
+
// Notifications while Tether is closed: on, off (the phone sent no device token), or why the last push failed.
|
|
197
|
+
(s.pushKey ? '<br><span class="sub">' + (d.pushFail ? 'Last push failed: ' + esc(d.pushFail.reason) + ' (' + esc(when(d.pushFail.at)) + ')'
|
|
198
|
+
: d.push ? 'Notifications while Tether is closed: on'
|
|
199
|
+
: d.pushReason ? 'Notifications while Tether is closed: off. The iPhone says: ' + esc(d.pushReason)
|
|
200
|
+
: 'Notifications while Tether is closed: off. In Tether, turn on Settings › Send notifications.') + '</span>' : '') +
|
|
196
201
|
'</td><td><button class="link" data-revoke="' + esc(d.id) + '" data-name="' + esc(d.name) + '" aria-label="Remove ' + esc(d.name) + '">Remove</button></td></tr>').join('')
|
|
197
202
|
|| '<tr><td class="mute">No iPhone yet.</td></tr>')
|
|
198
203
|
// An older gateway (before a dsh restart) has no Tailscale only setting and no log: hide them.
|
|
@@ -51,7 +51,7 @@ export const PLUS = new Map([
|
|
|
51
51
|
['queue-move', ['POST']], ['queue-edit', ['POST']], ['branches', ['GET']], ['predict', ['POST']], ['complete', ['POST']], ['improve', ['POST']], ['unread', ['GET', 'POST']],
|
|
52
52
|
['worktrees', ['GET', 'POST']], ['new-session-workspace', ['POST']], ['memory-edit', ['GET', 'POST']], ['memory-history', ['GET', 'POST']], ['memory-export', ['POST']], ['memory-copy', ['GET', 'POST']], ['memory-stale', ['GET']], ['memory-chat', ['GET', 'POST']], ['memory-suggest', ['POST']], ['handoff', ['POST']], ['context', ['GET']],
|
|
53
53
|
['board-chats', ['GET', 'POST']], ['board-progress', ['GET']], ['secret-answer', ['GET', 'POST']], ['side-chat', ['GET', 'POST']],
|
|
54
|
-
['issue-report', ['POST']], ['assistant', ['GET']],
|
|
54
|
+
['issue-report', ['POST']], ['assistant', ['GET']], ['assistant-status', ['GET']],
|
|
55
55
|
])
|
|
56
56
|
/** Allow for this chat: an Auto Review ask (`approval/request` with this reason) for a tool that the chat granted. */
|
|
57
57
|
export const AUTO_REVIEW_ASK = 'Auto review denied tool '
|
|
@@ -125,6 +125,26 @@ function reply(res, status, obj) {
|
|
|
125
125
|
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' })
|
|
126
126
|
res.end(JSON.stringify(obj))
|
|
127
127
|
}
|
|
128
|
+
const escHtml = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`)
|
|
129
|
+
/** A message of the relay as a page: in the middle of the window, in dsh's light or dark colors, with no script.
|
|
130
|
+
* `retry`: a link that loads the same address again (for a computer that was asleep). */
|
|
131
|
+
function messagePage(title, hint, retry) {
|
|
132
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escHtml(title)}</title><style>
|
|
133
|
+
:root{color-scheme:light dark;--bg:#fff;--fg:#0f1115;--sub:#61666b;--line:rgba(0,0,0,.12)}
|
|
134
|
+
@media (prefers-color-scheme:dark){:root{--bg:#151517;--fg:#f9fafb;--sub:#cfd3d6;--line:rgba(255,255,255,.16)}}
|
|
135
|
+
html,body{height:100%;margin:0}
|
|
136
|
+
body{display:grid;place-items:center;padding:24px;box-sizing:border-box;background:var(--bg);color:var(--fg);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}
|
|
137
|
+
main{max-width:420px;text-align:center}
|
|
138
|
+
svg{color:var(--sub)}
|
|
139
|
+
h1{font-size:17px;font-weight:600;line-height:1.35;margin:12px 0 6px}
|
|
140
|
+
p{margin:0;color:var(--sub)}
|
|
141
|
+
a{display:inline-block;margin-top:20px;padding:6px 16px;border:.5px solid var(--line);border-radius:8px;background:rgba(127,127,127,.12);color:var(--fg);font-size:13px;font-weight:500;text-decoration:none}
|
|
142
|
+
a:hover{background:rgba(127,127,127,.2)}
|
|
143
|
+
</style></head><body><main>
|
|
144
|
+
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4.5" width="18" height="12" rx="2"/><path d="M8.5 20h7M12 16.5V20"/></svg>
|
|
145
|
+
<h1>${escHtml(title)}</h1>${hint ? `<p>${escHtml(hint)}</p>` : ''}${retry ? '<a href="">Try again</a>' : ''}
|
|
146
|
+
</main></body></html>`
|
|
147
|
+
}
|
|
128
148
|
function readBody(req, max) {
|
|
129
149
|
return new Promise((ok, bad) => {
|
|
130
150
|
const parts = []; let n = 0
|
|
@@ -337,11 +357,16 @@ export function createRelay({ host, port, fp, token, ticketTtlMs = 60e3, onUnaut
|
|
|
337
357
|
return site === undefined || site === 'same-origin' || site === 'none'
|
|
338
358
|
}
|
|
339
359
|
const ancestors = ["'self'", ...frameAncestors].join(' ')
|
|
340
|
-
|
|
360
|
+
// The relay's own message: a page for a browser that asks for HTML (the window, dsh's Browser tab), else plain text.
|
|
361
|
+
const text = (res, status, title, hint = '', retry = false) => {
|
|
362
|
+
const html = /text\/html/.test(res.req?.headers?.accept ?? '')
|
|
363
|
+
if (!res.headersSent) res.writeHead(status, html ? { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': `default-src 'none'; style-src 'unsafe-inline'; frame-ancestors ${ancestors}` } : { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
|
|
364
|
+
res.end(html ? messagePage(title, hint, retry) : hint ? `${title} ${hint}` : title)
|
|
365
|
+
}
|
|
341
366
|
const next = (req) => ({ agent, headers: hopHeaders(req.headers, { host: `${host}:${port}`, authorization: `Bearer ${token}`, ...(req.headers.origin !== undefined ? { origin: `https://${host}:${port}` } : {}) }) })
|
|
342
367
|
// The computer no longer knows this Mac (it removed it, or the token is too old): say so, and drop the computer's answer.
|
|
343
368
|
const status = (code) => { if (code === 401) { onUnauthorized?.(); return 'skip' } }
|
|
344
|
-
const UNPAIRED = 'That Mac removed this one, or the pairing is too old. Remove it in Settings → Composer Plus → Remote computers and pair again.'
|
|
369
|
+
const UNPAIRED = ['That Mac removed this one, or the pairing is too old.', 'Remove it in Settings → Composer Plus → Remote computers and pair again.']
|
|
345
370
|
async function onRequest(req, res) {
|
|
346
371
|
if (!trusted(req)) return text(res, 403, 'Forbidden')
|
|
347
372
|
const url = new URL(req.url, 'http://x')
|
|
@@ -349,7 +374,7 @@ export function createRelay({ host, port, fp, token, ticketTtlMs = 60e3, onUnaut
|
|
|
349
374
|
if (ticket !== null) {
|
|
350
375
|
const exp = tickets.get(ticket)
|
|
351
376
|
tickets.delete(ticket) // one use, also when it is too old
|
|
352
|
-
if (req.method !== 'GET' || url.pathname !== '/' || exp === undefined || exp < Date.now()) return text(res, 403, 'This link was already used or is too old. Pick the computer again.')
|
|
377
|
+
if (req.method !== 'GET' || url.pathname !== '/' || exp === undefined || exp < Date.now()) return text(res, 403, 'This link was already used or is too old.', 'Pick the computer again.')
|
|
353
378
|
res.writeHead(303, { location: './', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer', 'content-security-policy': `frame-ancestors ${ancestors}`, 'set-cookie': `${cookieName()}=${cookieValue}; Path=/; HttpOnly; SameSite=Strict` })
|
|
354
379
|
return res.end()
|
|
355
380
|
}
|
|
@@ -360,8 +385,13 @@ export function createRelay({ host, port, fp, token, ticketTtlMs = 60e3, onUnaut
|
|
|
360
385
|
if (url.pathname.startsWith(RELAY_OWN)) return ownPath(req, res, url.pathname.slice(RELAY_OWN.length))
|
|
361
386
|
let unpaired = false
|
|
362
387
|
await pipeHttp(req, res, next(req), { onStatus: (code) => { const r = status(code); if (r === 'skip') unpaired = true; return r },
|
|
363
|
-
onError: (r) =>
|
|
364
|
-
|
|
388
|
+
onError: (r) => {
|
|
389
|
+
// A page (not a fetch) got the relay's own page in place of dsh: this Mac loads the computer again at the next pick.
|
|
390
|
+
if (/text\/html/.test(req.headers.accept ?? '')) peer?.signal({ failed: fail ?? 'unreachable' })
|
|
391
|
+
return fail === 'pin' ? text(r, 502, 'This computer shows another certificate.', 'Pair it again in dsh.')
|
|
392
|
+
: text(r, 502, 'This computer does not answer over Tailscale.', 'Make sure that it is awake and that Tailscale is on.', true)
|
|
393
|
+
} })
|
|
394
|
+
if (unpaired) text(res, 401, ...UNPAIRED)
|
|
365
395
|
}
|
|
366
396
|
/** The other Mac's page (in this Mac's window) reads this Mac's computers and switches the window: nothing else. */
|
|
367
397
|
async function ownPath(req, res, name) {
|
|
@@ -594,6 +624,7 @@ export function createGateway(opts = {}) {
|
|
|
594
624
|
// A Remote computers front door has no phones.
|
|
595
625
|
const apns = computer ? {} : opts.apns ? { push: apnsSender(opts.apns, { origin: opts.apns.origin }) } : loadApns(join(dir, 'apns.json'))
|
|
596
626
|
const pushTo = () => apns.push ? devices.filter(d => d.push?.on === true && typeof d.push.token === 'string') : []
|
|
627
|
+
const pushFails = new Map() // device id → { reason, at } of its last failed push (memory only)
|
|
597
628
|
/** One alert to each phone that wants this Mac's notifications now (switched on, inside its hours). The subtitle names
|
|
598
629
|
* this Mac and the chat's project; the body is the chat's title, then `detail` (the reply, tool, question or error).
|
|
599
630
|
* `opts.about(sessionId)` gives `{ chat, project }` (the Host half); without it the subtitle is the Mac name only. */
|
|
@@ -610,6 +641,8 @@ export function createGateway(opts = {}) {
|
|
|
610
641
|
for (const d of to) {
|
|
611
642
|
const token = d.push.token
|
|
612
643
|
apns.push.send(token, d.push.sandbox === true, { aps: { alert: { title, subtitle, body }, sound: 'default' } }).then(r => {
|
|
644
|
+
// The Mac page shows why the last push to this phone failed (no secret in it: APNs' reason or the HTTP status).
|
|
645
|
+
if (r.status === 200) pushFails.delete(d.id); else pushFails.set(d.id, { reason: String(r.reason || (r.status ? 'HTTP ' + r.status : 'no answer')).slice(0, 100), at: new Date().toISOString() })
|
|
613
646
|
// The app left the iPhone (or the token is not for this app): forget the token; the phone sends a new one.
|
|
614
647
|
if ((r.status === 410 || r.reason === 'BadDeviceToken') && d.push?.token === token) { delete d.push.token; saveDevices() }
|
|
615
648
|
}, () => {})
|
|
@@ -622,7 +655,9 @@ export function createGateway(opts = {}) {
|
|
|
622
655
|
const mins = v => Number.isInteger(v) && v >= 0 && v < 1440
|
|
623
656
|
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
|
|
624
657
|
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' })
|
|
625
|
-
|
|
658
|
+
// Switched on but no device token: the phone says why (iOS's error), and the Mac page shows it.
|
|
659
|
+
const reason = !b.on && typeof b.reason === 'string' && b.reason.trim() !== '' ? b.reason.trim().slice(0, 200) : undefined
|
|
660
|
+
dev.push = b.on ? { on: true, token: b.token.toLowerCase(), sandbox: b.sandbox, hours } : { on: false, ...(reason ? { reason } : {}) }
|
|
626
661
|
saveDevices()
|
|
627
662
|
reply(res, 200, { push: b.on })
|
|
628
663
|
}
|
|
@@ -1027,7 +1062,9 @@ export function createGateway(opts = {}) {
|
|
|
1027
1062
|
pending: [...pending.values()].map(({ id, name, ip, at }) => ({ id, name, ip, at, expiresAt: new Date(at + approveTtl).toISOString() })),
|
|
1028
1063
|
last: last && Date.now() - last.at < 60e3 ? { ...last, at: new Date(last.at).toISOString() } : null,
|
|
1029
1064
|
// The APNs device token stays here: the Mac page shows only whether the phone gets pushes.
|
|
1030
|
-
|
|
1065
|
+
// `pushKey`: this Mac has an APNs key (else no phone gets pushes). `pushFail`: why the last push to a phone failed.
|
|
1066
|
+
devices: devices.map(({ tokenHash, push, ...d }) => ({ ...d, push: push?.on === true && typeof push.token === 'string', ...(pushFails.has(d.id) ? { pushFail: pushFails.get(d.id) } : {}), ...(push?.reason ? { pushReason: push.reason } : {}), connections: devSockets.get(d.id)?.size ?? 0 })),
|
|
1067
|
+
pushKey: apns.push !== undefined,
|
|
1031
1068
|
...(apns.error ? { pushError: apns.error } : {}),
|
|
1032
1069
|
})
|
|
1033
1070
|
|