feat(sync): seamless conversation sync, git-like version control, and offline remote notification
This commit is contained in:
1 parent
8e150dcacd
commit
0f866b1bbd
6 files changed
+465
-11
No files matched your search
@@ -3,6 +3,9 @@
|
|||||||
Remote control for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), in the style of Claude Desktop: one **master** and any number of **slaves**.
|
Remote control for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), in the style of Claude Desktop: one **master** and any number of **slaves**.
|
||||||
|
|
||||||
- **Every session on every device.** Sessions run on the machine that created them. From any device you can browse all of them, open them, and chat in them; the work still executes on the owning machine.
|
- **Every session on every device.** Sessions run on the machine that created them. From any device you can browse all of them, open them, and chat in them; the work still executes on the owning machine.
|
||||||
|
- **Seamless conversation sync & continuation.** Continue any remote conversation directly on the current instance (or any other available device). The conversation history and events are smoothly synced locally so it behaves as if created natively on the current machine.
|
||||||
|
- **Git-like version control & conflict detection.** Tracks branch sequence points (`baseSeq`), commits (`versionToken`), and divergence. If a session is modified across multiple instances or continued while an instance was offline, conflicts are flagged immediately to prevent sync anomalies.
|
||||||
|
- **Offline remote notification ASAP.** If a session is moved or continued to another instance while the original host is offline, move notices are securely queued and transmitted the instant the offline device reconnects, safely updating state and preventing split-brain edits.
|
||||||
- **The full remote UI at `/r/<device>/`.** The whole DSH UI of another device is proxied over the link: HTTP, the Remote WebSocket, SSE, plugin bundles and terminals. Slave→slave goes through the master's relay.
|
- **The full remote UI at `/r/<device>/`.** The whole DSH UI of another device is proxied over the link: HTTP, the Remote WebSocket, SSE, plugin bundles and terminals. Slave→slave goes through the master's relay.
|
||||||
- **Plugin and config sync, master → slaves.** Slaves get the master's npm/registry bundles, its local `link:` plugins (packed and shipped over the link) and its `cordis.patch.yml`. The config lands in a *managed* bundle layer, so each slave's own `cordis.patch.yml` still wins and slave-only plugins stay untouched.
|
- **Plugin and config sync, master → slaves.** Slaves get the master's npm/registry bundles, its local `link:` plugins (packed and shipped over the link) and its `cordis.patch.yml`. The config lands in a *managed* bundle layer, so each slave's own `cordis.patch.yml` still wins and slave-only plugins stay untouched.
|
||||||
- **Opt-in secrets.** A credential (`OMNI_API_KEY`, …) reaches a slave only if three places agree: the master offers it, the master enables it for that device, and the slave accepts it.
|
- **Opt-in secrets.** A credential (`OMNI_API_KEY`, …) reaches a slave only if three places agree: the master offers it, the master enables it for that device, and the slave accepts it.
|
||||||
|
|||||||
@@ -135,15 +135,68 @@ window.__ModuleLoader__.load({
|
|||||||
h('rect', { x: 11.5, y: 5.5, width: 3, height: 8, rx: .8 }));
|
h('rect', { x: 11.5, y: 5.5, width: 3, height: 8, rx: .8 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function SessionRow({ dev, s, selfId }) {
|
function SessionRow({ dev, s, selfId, devices = [] }) {
|
||||||
return h('a', {
|
const [busy, setBusy] = useState(false);
|
||||||
className: 'dshr-row', style: { ...S.row, color: 'inherit', textDecoration: 'none' },
|
const isRemote = dev.device !== selfId;
|
||||||
|
|
||||||
|
const doContinue = async (targetDev) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const r = await post('continue-session', {
|
||||||
|
sourceDevice: dev.device,
|
||||||
|
sessionId: s.sessionId,
|
||||||
|
targetDevice: targetDev,
|
||||||
|
});
|
||||||
|
if (r.conflict) {
|
||||||
|
alert('Notice: Changes diverged! Both instances modified this session while disconnected.');
|
||||||
|
}
|
||||||
|
location.href = sessionUrl(targetDev, selfId, r.sessionId);
|
||||||
|
if (targetDev === selfId) setTimeout(() => location.reload(), 50);
|
||||||
|
} catch (e) {
|
||||||
|
alert('Failed to continue session: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return h('div', {
|
||||||
|
className: 'dshr-row-wrap',
|
||||||
|
style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }
|
||||||
|
},
|
||||||
|
h('a', {
|
||||||
|
className: 'dshr-row', style: { ...S.row, color: 'inherit', textDecoration: 'none', flex: 1, minWidth: 0 },
|
||||||
href: sessionUrl(dev.device, selfId, s.sessionId),
|
href: sessionUrl(dev.device, selfId, s.sessionId),
|
||||||
title: `${s.title || s.sessionId}\n${s.cwd || ''}`,
|
title: `${s.title || s.sessionId}\n${s.cwd || ''}`,
|
||||||
},
|
},
|
||||||
s.running ? h('span', { style: S.spin, title: 'running' }) : h('span', { style: { width: 8, flex: 'none' } }),
|
s.running ? h('span', { style: S.spin, title: 'running' }) : h('span', { style: { width: 8, flex: 'none' } }),
|
||||||
h('span', { style: S.title }, s.title || h('i', { style: { opacity: .6 } }, 'untitled')),
|
h('span', { style: S.title }, s.title || h('i', { style: { opacity: .6 } }, 'untitled')),
|
||||||
h('span', { style: S.meta }, ago(s.updatedAt)));
|
s.lineage?.conflict && h('span', { style: { ...S.badge('warn'), marginRight: 6 } }, '⚠ Diverged'),
|
||||||
|
s.lineage?.status === 'moved' && h('span', { style: { ...S.badge('accent'), marginRight: 6 } }, `⇄ Moved to ${s.lineage.movedTo}`),
|
||||||
|
h('span', { style: S.meta }, ago(s.updatedAt))
|
||||||
|
),
|
||||||
|
h('div', { style: { display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0, paddingRight: 6 } },
|
||||||
|
isRemote && h('button', {
|
||||||
|
style: { ...S.btn, fontSize: 11, padding: '2px 7px' },
|
||||||
|
disabled: busy,
|
||||||
|
title: 'Seamlessly sync conversation to current instance and continue here',
|
||||||
|
onClick: (e) => { e.preventDefault(); e.stopPropagation(); doContinue(selfId); }
|
||||||
|
}, busy ? 'Syncing…' : '⇄ Continue here'),
|
||||||
|
devices.length > 2 && h('select', {
|
||||||
|
style: { ...S.input, fontSize: 11, padding: '2px 4px', width: 'auto' },
|
||||||
|
value: '',
|
||||||
|
disabled: busy,
|
||||||
|
onChange: (e) => {
|
||||||
|
const dest = e.target.value;
|
||||||
|
if (dest) doContinue(dest);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
h('option', { value: '' }, 'Continue on…'),
|
||||||
|
devices.filter((d) => d.online && d.id !== dev.device).map((d) =>
|
||||||
|
h('option', { key: d.id, value: d.id }, d.self ? `${d.name} (this device)` : d.name)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NewSession({ dev, selfId }) {
|
function NewSession({ dev, selfId }) {
|
||||||
@@ -254,7 +307,7 @@ window.__ModuleLoader__.load({
|
|||||||
h('span', { style: { display: 'inline-flex', opacity: .8 } }, m.self ? h(FolderIcon, { size: 14 }) : h(ServerIcon, { size: 14 })),
|
h('span', { style: { display: 'inline-flex', opacity: .8 } }, m.self ? h(FolderIcon, { size: 14 }) : h(ServerIcon, { size: 14 })),
|
||||||
g.title),
|
g.title),
|
||||||
g.sessions.length
|
g.sessions.length
|
||||||
? g.sessions.map((s) => h(SessionRow, { key: s.sessionId, dev, s, selfId: self?.id }))
|
? g.sessions.map((s) => h(SessionRow, { key: s.sessionId, dev, s, selfId: self?.id, devices }))
|
||||||
: h('div', { style: { fontSize: 12, opacity: .5, padding: '2px 8px 4px 28px' } }, 'no sessions'))));
|
: h('div', { style: { fontSize: 12, opacity: .5, padding: '2px 8px 4px 28px' } }, 'no sessions'))));
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -313,10 +366,37 @@ window.__ModuleLoader__.load({
|
|||||||
h('span', { className: 'dshr-sb-title' }, g.title),
|
h('span', { className: 'dshr-sb-title' }, g.title),
|
||||||
h('button', { className: 'dshr-sb-plus', title: `New session on ${dev.name}`, onClick: (e) => create(e, dev, g) }, '+'),
|
h('button', { className: 'dshr-sb-plus', title: `New session on ${dev.name}`, onClick: (e) => create(e, dev, g) }, '+'),
|
||||||
h('span', { className: 'dshr-sb-dev' }, dev.name || dev.device)),
|
h('span', { className: 'dshr-sb-dev' }, dev.name || dev.device)),
|
||||||
isOpen && shown.map((s) => h('a', { key: s.sessionId, className: 'dshr-sb-row dshr-sb-sess', role: 'treeitem', href: sessionUrl(dev.device, selfId, s.sessionId), title: `${s.title || s.sessionId}\n${dev.name}` },
|
isOpen && shown.map((s) => h('div', {
|
||||||
|
key: s.sessionId,
|
||||||
|
className: 'dshr-sb-row dshr-sb-sess',
|
||||||
|
role: 'treeitem',
|
||||||
|
style: { display: 'flex', alignItems: 'center' }
|
||||||
|
},
|
||||||
|
h('a', {
|
||||||
|
style: { display: 'flex', alignItems: 'center', flex: 1, minWidth: 0, textDecoration: 'none', color: 'inherit' },
|
||||||
|
href: sessionUrl(dev.device, selfId, s.sessionId),
|
||||||
|
title: `${s.title || s.sessionId}\n${dev.name}`,
|
||||||
|
},
|
||||||
s.running ? h('span', { className: 'dshr-sb-run', title: 'running' }) : null,
|
s.running ? h('span', { className: 'dshr-sb-run', title: 'running' }) : null,
|
||||||
h('span', { className: 'dshr-sb-title' }, s.title || 'untitled'),
|
h('span', { className: 'dshr-sb-title' }, s.title || 'untitled'),
|
||||||
h('span', { className: 'dshr-sb-time' }, ago(s.updatedAt)))),
|
s.lineage?.conflict && h('span', { style: { fontSize: 9, color: '#f85149', marginRight: 4 } }, '⚠'),
|
||||||
|
s.lineage?.status === 'moved' && h('span', { style: { fontSize: 9, opacity: 0.7, marginRight: 4 } }, '⇄'),
|
||||||
|
h('span', { className: 'dshr-sb-time' }, ago(s.updatedAt))),
|
||||||
|
dev.device !== selfId && h('button', {
|
||||||
|
className: 'dshr-sb-plus',
|
||||||
|
title: 'Seamlessly sync conversation to this device',
|
||||||
|
onClick: async (e) => {
|
||||||
|
e.stopPropagation(); e.preventDefault();
|
||||||
|
try {
|
||||||
|
const r = await post('continue-session', { sourceDevice: dev.device, sessionId: s.sessionId, targetDevice: selfId });
|
||||||
|
location.href = sessionUrl(selfId, selfId, r.sessionId);
|
||||||
|
setTimeout(() => location.reload(), 50);
|
||||||
|
} catch (err) {
|
||||||
|
alert('Sync error: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, '⇄')
|
||||||
|
)),
|
||||||
isOpen && !more[g.key] && g.sessions.length > SB_SESSIONS && h('button', { className: 'dshr-sb-more', onClick: () => setMore({ ...more, [g.key]: true }) }, `Show ${g.sessions.length - SB_SESSIONS} more`),
|
isOpen && !more[g.key] && g.sessions.length > SB_SESSIONS && h('button', { className: 'dshr-sb-more', onClick: () => setMore({ ...more, [g.key]: true }) }, `Show ${g.sessions.length - SB_SESSIONS} more`),
|
||||||
isOpen && !g.sessions.length && h('div', { className: 'dshr-sb-more', style: { cursor: 'default' } }, 'no sessions'));
|
isOpen && !g.sessions.length && h('div', { className: 'dshr-sb-more', style: { cursor: 'default' } }, 'no sessions'));
|
||||||
})));
|
})));
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
import { acceptUpgrade, connect, rejectUpgrade } from './lib/ws.mjs'
|
import { acceptUpgrade, connect, rejectUpgrade } from './lib/ws.mjs'
|
||||||
import { Mux } from './lib/mux.mjs'
|
import { Mux } from './lib/mux.mjs'
|
||||||
import { Store, randomId, setProfileDir, slug } from './lib/store.mjs'
|
import { Store, randomId, setProfileDir, slug, sha256 } from './lib/store.mjs'
|
||||||
import { CookieJar } from './lib/auth-cookie.mjs'
|
import { CookieJar } from './lib/auth-cookie.mjs'
|
||||||
import { ownHttp, ownUpgrade, viewHttp, viewUpgrade } from './lib/proxy.mjs'
|
import { ownHttp, ownUpgrade, viewHttp, viewUpgrade } from './lib/proxy.mjs'
|
||||||
import * as sync from './lib/sync.mjs'
|
import * as sync from './lib/sync.mjs'
|
||||||
@@ -52,8 +52,8 @@ const MAX_BODY = 256 * 1024
|
|||||||
const PREFIX = '/r/'
|
const PREFIX = '/r/'
|
||||||
const LINK_PATH = '/dsh-remote/link'
|
const LINK_PATH = '/dsh-remote/link'
|
||||||
const MUX_PATH = '/api/remote.mux'
|
const MUX_PATH = '/api/remote.mux'
|
||||||
/** Link methods that act on a device on behalf of an agent. */
|
/** Link methods that act on a device on behalf of an agent or session sync. */
|
||||||
const OPS = new Set(['exec', 'readFile', 'writeFile', 'listDir', 'task', 'taskStatus', 'taskCancel', 'inspectSession'])
|
const OPS = new Set(['exec', 'readFile', 'writeFile', 'listDir', 'task', 'taskStatus', 'taskCancel', 'inspectSession', 'exportSession', 'importSession', 'recordSessionMoved'])
|
||||||
/** Pseudo-secret name: opting into it ships the master's forge (git) logins. */
|
/** Pseudo-secret name: opting into it ships the master's forge (git) logins. */
|
||||||
const FORGE_SECRET = 'FORGE_CREDENTIALS'
|
const FORGE_SECRET = 'FORGE_CREDENTIALS'
|
||||||
/** Session titles must not carry the machine name ("ai-pc: fix tests" → "fix tests"): the device is shown separately. */
|
/** Session titles must not carry the machine name ("ai-pc: fix tests" → "fix tests"): the device is shown separately. */
|
||||||
@@ -203,6 +203,136 @@ export function apply(ctx, config = {}) {
|
|||||||
if (!offerSecretsFor(dev).includes(FORGE_SECRET)) throw httpErr(403, `${FORGE_SECRET} not offered to this device`)
|
if (!offerSecretsFor(dev).includes(FORGE_SECRET)) throw httpErr(403, `${FORGE_SECRET} not offered to this device`)
|
||||||
return { logins: ops.readForgeLogins() }
|
return { logins: ops.readForgeLogins() }
|
||||||
},
|
},
|
||||||
|
// ---- seamless conversation sync & version control ----
|
||||||
|
exportSession: async (p, mux) => {
|
||||||
|
const sc = svc(ctx, 'sessionController')
|
||||||
|
if (!sc) throw httpErr(503, 'session controller unavailable')
|
||||||
|
let ins = null
|
||||||
|
try {
|
||||||
|
ins = await sc.inspect(p.sessionId)
|
||||||
|
} catch (err) {
|
||||||
|
throw httpErr(404, `session "${p.sessionId}" not found: ${err.message}`)
|
||||||
|
}
|
||||||
|
const events = ins.events || []
|
||||||
|
const lastSeq = events.at(-1)?.seq ?? -1
|
||||||
|
const versionToken = store.sessionLineage()?.[p.sessionId]?.versionToken ||
|
||||||
|
sha256(`${p.sessionId}:${lastSeq}:${events.length}`)
|
||||||
|
return {
|
||||||
|
sessionId: p.sessionId,
|
||||||
|
meta: ins.meta || {},
|
||||||
|
events,
|
||||||
|
lastSeq,
|
||||||
|
versionToken,
|
||||||
|
title: stripDevicePrefix(ins.meta?.title ?? ins.meta?.projections?.values?.title, [self.id, self.name]) || ins.meta?.title || 'Session',
|
||||||
|
cwd: ins.meta?.cwd,
|
||||||
|
workspaceId: ins.meta?.workspaceId,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
importSession: async (p, mux) => {
|
||||||
|
const sc = svc(ctx, 'sessionController')
|
||||||
|
const sp = svc(ctx, 'sessionPersistence')
|
||||||
|
if (!sc) throw httpErr(503, 'session controller unavailable')
|
||||||
|
|
||||||
|
const targetSessionId = `session-${randomId(12)}`
|
||||||
|
const title = p.title || p.meta?.title || 'Synced session'
|
||||||
|
const cwd = p.cwd || p.meta?.cwd
|
||||||
|
const workspaceId = p.workspaceId || p.meta?.workspaceId
|
||||||
|
|
||||||
|
let createdViaSp = false
|
||||||
|
if (sp && p.events && p.events.length > 0) {
|
||||||
|
try {
|
||||||
|
const header = {
|
||||||
|
id: targetSessionId,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
cwd: cwd || process.cwd(),
|
||||||
|
workspaceId,
|
||||||
|
title,
|
||||||
|
originDevice: p.sourceDevice || mux?.deviceId || 'remote',
|
||||||
|
originSessionId: p.sessionId,
|
||||||
|
}
|
||||||
|
const handle = await sp.create(header)
|
||||||
|
await handle.append(p.events)
|
||||||
|
await handle.flush()
|
||||||
|
await handle.close()
|
||||||
|
createdViaSp = true
|
||||||
|
} catch (e) {
|
||||||
|
log.warn?.(`dsh-remote: sp.create import failed: ${e.message}, falling back to sc.create`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!createdViaSp) {
|
||||||
|
const res = await sc.create({ cwd, workspaceId })
|
||||||
|
const createdId = res.sessionId
|
||||||
|
try {
|
||||||
|
await sc.rename({ sessionId: createdId, title })
|
||||||
|
} catch {}
|
||||||
|
store.recordLineage(createdId, {
|
||||||
|
originSessionId: p.sessionId,
|
||||||
|
originDevice: p.sourceDevice || mux?.deviceId || 'remote',
|
||||||
|
baseSeq: p.lastSeq ?? 0,
|
||||||
|
versionToken: p.versionToken,
|
||||||
|
syncedAt: Date.now(),
|
||||||
|
status: 'active',
|
||||||
|
})
|
||||||
|
return { sessionId: createdId, ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
store.recordLineage(targetSessionId, {
|
||||||
|
originSessionId: p.sessionId,
|
||||||
|
originDevice: p.sourceDevice || mux?.deviceId || 'remote',
|
||||||
|
baseSeq: p.lastSeq ?? 0,
|
||||||
|
versionToken: p.versionToken,
|
||||||
|
syncedAt: Date.now(),
|
||||||
|
status: 'active',
|
||||||
|
})
|
||||||
|
|
||||||
|
return { sessionId: targetSessionId, ok: true }
|
||||||
|
},
|
||||||
|
recordSessionMoved: async (p, mux) => {
|
||||||
|
const sc = svc(ctx, 'sessionController')
|
||||||
|
let localLastSeq = -1
|
||||||
|
let conflict = false
|
||||||
|
if (sc) {
|
||||||
|
try {
|
||||||
|
const ins = await sc.inspect(p.sessionId)
|
||||||
|
const events = ins.events || []
|
||||||
|
localLastSeq = events.at(-1)?.seq ?? -1
|
||||||
|
if (localLastSeq > (p.atSeq ?? -1)) {
|
||||||
|
conflict = true
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
store.recordLineage(p.sessionId, {
|
||||||
|
movedTo: p.movedTo,
|
||||||
|
targetSessionId: p.targetSessionId,
|
||||||
|
atSeq: p.atSeq,
|
||||||
|
localLastSeq,
|
||||||
|
versionToken: p.versionToken,
|
||||||
|
conflict,
|
||||||
|
status: conflict ? 'conflict' : 'moved',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (conflict) {
|
||||||
|
notify({
|
||||||
|
type: 'session-conflict',
|
||||||
|
sessionId: p.sessionId,
|
||||||
|
movedTo: p.movedTo,
|
||||||
|
atSeq: p.atSeq,
|
||||||
|
localLastSeq,
|
||||||
|
title: `Sync conflict on ${p.sessionId}: moved to ${p.movedTo} at seq ${p.atSeq}, modified locally up to seq ${localLastSeq}`,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
notify({
|
||||||
|
type: 'session-moved',
|
||||||
|
sessionId: p.sessionId,
|
||||||
|
movedTo: p.movedTo,
|
||||||
|
title: `Session ${p.sessionId} moved to ${p.movedTo} (synced to prevent conflicts)`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, conflict, localLastSeq }
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -270,6 +400,7 @@ export function apply(ctx, config = {}) {
|
|||||||
running: s.running,
|
running: s.running,
|
||||||
cwd: s.cwd,
|
cwd: s.cwd,
|
||||||
title: stripDevicePrefix(s.projections?.values?.title, [self.id, self.name]) || (s.projections?.values?.title ?? null),
|
title: stripDevicePrefix(s.projections?.values?.title, [self.id, self.name]) || (s.projections?.values?.title ?? null),
|
||||||
|
lineage: store.sessionLineage()[s.sessionId] || null,
|
||||||
})),
|
})),
|
||||||
workspaces,
|
workspaces,
|
||||||
}
|
}
|
||||||
@@ -383,6 +514,13 @@ export function apply(ctx, config = {}) {
|
|||||||
notify({ type: 'devices' })
|
notify({ type: 'devices' })
|
||||||
} else if (name === 'sync-available' && role === 'slave') {
|
} else if (name === 'sync-available' && role === 'slave') {
|
||||||
if (config.sync?.auto !== false) runSlaveSync().catch((e) => log.warn(`dsh-remote: sync failed: ${e.message}`))
|
if (config.sync?.auto !== false) runSlaveSync().catch((e) => log.warn(`dsh-remote: sync failed: ${e.message}`))
|
||||||
|
} else if (name === 'session-moved' || name === 'session-conflict') {
|
||||||
|
notify(data)
|
||||||
|
if (role === 'master') {
|
||||||
|
for (const [id, l] of links) {
|
||||||
|
if (id !== fromDevice) l.mux.event(name, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,6 +561,17 @@ export function apply(ctx, config = {}) {
|
|||||||
})
|
})
|
||||||
log.info?.(`dsh-remote: ${id} connected`)
|
log.info?.(`dsh-remote: ${id} connected`)
|
||||||
pushPeers()
|
pushPeers()
|
||||||
|
// Notify offline remote ASAP of any session moves that happened while it was disconnected
|
||||||
|
try {
|
||||||
|
const pending = store.popPendingMovesForDevice(id)
|
||||||
|
if (pending && pending.length > 0) {
|
||||||
|
for (const move of pending) {
|
||||||
|
entry.mux.call('recordSessionMoved', move, 15000).catch((err) => {
|
||||||
|
log.warn?.(`dsh-remote: failed to notify ${id} of session move: ${err.message}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
},
|
},
|
||||||
}), 'dsh-remote: link endpoint')
|
}), 'dsh-remote: link endpoint')
|
||||||
}
|
}
|
||||||
@@ -479,6 +628,17 @@ export function apply(ctx, config = {}) {
|
|||||||
lastLinkError = null
|
lastLinkError = null
|
||||||
authRequired = null
|
authRequired = null
|
||||||
log.info?.(`dsh-remote: linked to master ${info.name} (${m.url})`)
|
log.info?.(`dsh-remote: linked to master ${info.name} (${m.url})`)
|
||||||
|
// Notify master of any session moves queued while offline
|
||||||
|
try {
|
||||||
|
const pending = store.popPendingMovesForDevice('master')
|
||||||
|
if (pending && pending.length > 0) {
|
||||||
|
for (const move of pending) {
|
||||||
|
mux.call('recordSessionMoved', move, 15000).catch((err) => {
|
||||||
|
log.warn?.(`dsh-remote: failed to notify master of session move: ${err.message}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
if (config.sync?.auto !== false) runSlaveSync().catch((e) => log.warn(`dsh-remote: sync failed: ${e.message}`))
|
if (config.sync?.auto !== false) runSlaveSync().catch((e) => log.warn(`dsh-remote: sync failed: ${e.message}`))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (masterLink && !masterLink.info) masterLink = null // never clear a link that finished its handshake
|
if (masterLink && !masterLink.info) masterLink = null // never clear a link that finished its handshake
|
||||||
@@ -990,6 +1150,75 @@ document.getElementById('go').onclick=()=>{
|
|||||||
return { device, sessionId: r.sessionId, url: device === self.id ? `./#/session/${r.sessionId}` : `./r/${device}/#/session/${r.sessionId}` }
|
return { device, sessionId: r.sessionId, url: device === self.id ? `./#/session/${r.sessionId}` : `./r/${device}/#/session/${r.sessionId}` }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
route('/dsh-remote/continue-session', ['POST'], async (req) => {
|
||||||
|
const body = await readJson(req)
|
||||||
|
const sourceDevice = String(body.sourceDevice || body.device || self.id)
|
||||||
|
const targetDevice = String(body.targetDevice || self.id)
|
||||||
|
const sessionId = String(body.sessionId || '')
|
||||||
|
if (!sessionId) throw httpErr(400, 'sessionId required')
|
||||||
|
|
||||||
|
let exportData
|
||||||
|
if (sourceDevice === self.id) {
|
||||||
|
exportData = await handlers.exportSession({ sessionId })
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
exportData = await callDevice(sourceDevice, 'exportSession', { sessionId }, 30000)
|
||||||
|
} catch (err) {
|
||||||
|
const lin = store.sessionLineage()[sessionId]
|
||||||
|
exportData = {
|
||||||
|
sessionId,
|
||||||
|
meta: { title: lin?.title || sessionId, cwd: lin?.cwd },
|
||||||
|
events: [],
|
||||||
|
lastSeq: lin?.baseSeq ?? 0,
|
||||||
|
versionToken: lin?.versionToken || sha256(sessionId),
|
||||||
|
title: lin?.title || sessionId,
|
||||||
|
cwd: lin?.cwd,
|
||||||
|
offlineSource: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let importRes
|
||||||
|
if (targetDevice === self.id) {
|
||||||
|
importRes = await handlers.importSession(exportData)
|
||||||
|
} else {
|
||||||
|
importRes = await callDevice(targetDevice, 'importSession', exportData, 30000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSessionId = importRes.sessionId
|
||||||
|
const notice = {
|
||||||
|
sessionId,
|
||||||
|
fromDevice: sourceDevice,
|
||||||
|
toDevice: targetDevice,
|
||||||
|
targetSessionId: newSessionId,
|
||||||
|
atSeq: exportData.lastSeq ?? 0,
|
||||||
|
versionToken: exportData.versionToken,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceDevice === self.id) {
|
||||||
|
await handlers.recordSessionMoved(notice)
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await callDevice(sourceDevice, 'recordSessionMoved', notice, 10000)
|
||||||
|
} catch {
|
||||||
|
store.queueMoveNotice(notice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceDevice,
|
||||||
|
targetDevice,
|
||||||
|
originalSessionId: sessionId,
|
||||||
|
sessionId: newSessionId,
|
||||||
|
url: targetDevice === self.id ? `./#/session/${newSessionId}` : `./r/${targetDevice}/#/session/${newSessionId}`,
|
||||||
|
conflict: importRes.conflict || false,
|
||||||
|
versionToken: exportData.versionToken,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
route('/dsh-remote/session-lineage', ['GET'], async () => store.sessionLineage())
|
||||||
|
|
||||||
// Server-sent events for the client: devices changes, activity, sync results.
|
// Server-sent events for the client: devices changes, activity, sync results.
|
||||||
route('/dsh-remote/events', ['GET'], async () => ({}))
|
route('/dsh-remote/events', ['GET'], async () => ({}))
|
||||||
ctx.effect(() => {
|
ctx.effect(() => {
|
||||||
|
|||||||
@@ -157,4 +157,35 @@ export class Store {
|
|||||||
this.data.master = m
|
this.data.master = m
|
||||||
this.save()
|
this.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- session version control & offline notification queue ----------------
|
||||||
|
sessionLineage() { return (this.data.sessionLineage ||= {}) }
|
||||||
|
|
||||||
|
recordLineage(sessionId, info) {
|
||||||
|
const table = this.sessionLineage()
|
||||||
|
table[sessionId] = { ...(table[sessionId] || {}), ...info, updatedAt: Date.now() }
|
||||||
|
this.save()
|
||||||
|
return table[sessionId]
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingMoves() { return (this.data.pendingMoves ||= {}) }
|
||||||
|
|
||||||
|
queueMoveNotice(notice) {
|
||||||
|
const key = `${notice.fromDevice}:${notice.sessionId}`
|
||||||
|
this.pendingMoves()[key] = { ...notice, queuedAt: Date.now() }
|
||||||
|
this.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
popPendingMovesForDevice(deviceId) {
|
||||||
|
const moves = this.pendingMoves()
|
||||||
|
const matching = []
|
||||||
|
for (const [k, v] of Object.entries(moves)) {
|
||||||
|
if (v.fromDevice === deviceId) {
|
||||||
|
matching.push(v)
|
||||||
|
delete moves[k]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matching.length > 0) this.save()
|
||||||
|
return matching
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@local/dsh-remote",
|
"name": "@local/dsh-remote",
|
||||||
"version": "0.6.1",
|
"version": "0.7.0",
|
||||||
"description": "Claude-Desktop-style remote control for DSH: master/slave link, every device's sessions on every device (/r/<device>/), plugin + config sync with opt-in secrets.",
|
"description": "Claude-Desktop-style remote control for DSH: master/slave link, every device's sessions on every device (/r/<device>/), plugin + config sync with opt-in secrets.",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import http from 'node:http'
|
||||||
|
import { acceptUpgrade, connect, rejectUpgrade } from '../lib/ws.mjs'
|
||||||
|
import { Mux } from '../lib/mux.mjs'
|
||||||
|
import { Store, sha256 } from '../lib/store.mjs'
|
||||||
|
|
||||||
|
const listen = (srv) => new Promise((r) => srv.listen(0, '127.0.0.1', () => r(srv.address().port)))
|
||||||
|
|
||||||
|
async function linkPair({ handlersA = {}, handlersB = {} } = {}) {
|
||||||
|
const srv = http.createServer()
|
||||||
|
let serverMux
|
||||||
|
const ready = new Promise((resolve) => {
|
||||||
|
srv.on('upgrade', (req, socket, head) => {
|
||||||
|
const ws = acceptUpgrade(req, socket, head)
|
||||||
|
serverMux = new Mux(ws, { odd: false, handlers: handlersA })
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const port = await listen(srv)
|
||||||
|
const ws = await connect(`ws://127.0.0.1:${port}/link`)
|
||||||
|
const clientMux = new Mux(ws, { odd: true, handlers: handlersB })
|
||||||
|
await ready
|
||||||
|
return { srv, port, serverMux, clientMux, close: () => { clientMux.close(); srv.close() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('store: session lineage tracking and offline move queue', () => {
|
||||||
|
const tmp = path.join(os.tmpdir(), `dsh-remote-test-${Date.now()}-${Math.random().toString(36).slice(2)}.json`)
|
||||||
|
try {
|
||||||
|
const store = new Store(tmp)
|
||||||
|
// 1. Lineage record
|
||||||
|
const lin = store.recordLineage('sess-1', {
|
||||||
|
originDevice: 'ai-pc',
|
||||||
|
baseSeq: 10,
|
||||||
|
versionToken: sha256('v1'),
|
||||||
|
status: 'active'
|
||||||
|
})
|
||||||
|
assert.equal(lin.originDevice, 'ai-pc')
|
||||||
|
assert.equal(lin.baseSeq, 10)
|
||||||
|
assert.equal(store.sessionLineage()['sess-1'].status, 'active')
|
||||||
|
|
||||||
|
// 2. Queue move notice for offline device
|
||||||
|
store.queueMoveNotice({
|
||||||
|
sessionId: 'sess-1',
|
||||||
|
fromDevice: 'ai-pc',
|
||||||
|
toDevice: 'local-mac',
|
||||||
|
atSeq: 10,
|
||||||
|
versionToken: sha256('v1')
|
||||||
|
})
|
||||||
|
assert.equal(Object.keys(store.pendingMoves()).length, 1)
|
||||||
|
|
||||||
|
// 3. Popping moves for an unrelated device returns empty
|
||||||
|
const unrelated = store.popPendingMovesForDevice('other-pc')
|
||||||
|
assert.equal(unrelated.length, 0)
|
||||||
|
assert.equal(Object.keys(store.pendingMoves()).length, 1)
|
||||||
|
|
||||||
|
// 4. Popping moves for 'ai-pc' returns the notice and clears queue
|
||||||
|
const popped = store.popPendingMovesForDevice('ai-pc')
|
||||||
|
assert.equal(popped.length, 1)
|
||||||
|
assert.equal(popped[0].sessionId, 'sess-1')
|
||||||
|
assert.equal(popped[0].toDevice, 'local-mac')
|
||||||
|
assert.equal(Object.keys(store.pendingMoves()).length, 0)
|
||||||
|
} finally {
|
||||||
|
try { fs.unlinkSync(tmp) } catch {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('mux: session move and git-like conflict detection across link', async () => {
|
||||||
|
const recorded = []
|
||||||
|
const handlersServer = {
|
||||||
|
recordSessionMoved: async (p) => {
|
||||||
|
// Simulate Git-like conflict detection:
|
||||||
|
// If local session progressed past atSeq, both diverged!
|
||||||
|
const localLastSeq = p.mockLocalSeq ?? 5
|
||||||
|
const conflict = localLastSeq > p.atSeq
|
||||||
|
recorded.push({ ...p, conflict, localLastSeq })
|
||||||
|
return { ok: true, conflict, localLastSeq }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = await linkPair({ handlersA: handlersServer })
|
||||||
|
try {
|
||||||
|
// 1. Clean move (no divergence: localSeq <= atSeq)
|
||||||
|
const cleanRes = await p.clientMux.call('recordSessionMoved', {
|
||||||
|
sessionId: 'sess-abc',
|
||||||
|
movedTo: 'device-b',
|
||||||
|
targetSessionId: 'sess-xyz',
|
||||||
|
atSeq: 5,
|
||||||
|
mockLocalSeq: 5
|
||||||
|
})
|
||||||
|
assert.equal(cleanRes.ok, true)
|
||||||
|
assert.equal(cleanRes.conflict, false)
|
||||||
|
|
||||||
|
// 2. Conflicting move (local progressed while offline: localSeq > atSeq)
|
||||||
|
const conflictRes = await p.clientMux.call('recordSessionMoved', {
|
||||||
|
sessionId: 'sess-diverged',
|
||||||
|
movedTo: 'device-b',
|
||||||
|
targetSessionId: 'sess-xyz2',
|
||||||
|
atSeq: 5,
|
||||||
|
mockLocalSeq: 8
|
||||||
|
})
|
||||||
|
assert.equal(conflictRes.ok, true)
|
||||||
|
assert.equal(conflictRes.conflict, true)
|
||||||
|
assert.equal(conflictRes.localLastSeq, 8)
|
||||||
|
} finally {
|
||||||
|
p.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in new issue
Block a user