Conversation sync done properly: fork-shaped seeded import adopted by the session controller, paged transfer, replicas of remote chats for offline continuation, move/divergence ledger with queued notices delivered on reconnect, native sidebar menu item + row markers, toasts
This commit is contained in:
1 parent
0f866b1bbd
commit
2e699a061f
7 files changed
+900
-457
No files matched your search
@@ -3,9 +3,8 @@
|
|||||||
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.
|
- **Continue a conversation on another device.** "Continue here" on any remote chat (Devices page or sidebar), or "Continue on another device…" in a chat's "…" menu, copies the conversation to that device as a real native chat (its own history, tools and workspace), and the original is marked as moved. See [Conversation sync](#conversation-sync).
|
||||||
- **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.
|
- **Works while the other device is offline.** Each device keeps synced copies of the other devices' recent chats, so you can continue one even when its device is off. That device is told the moment it is reachable again, and if it got new messages in the meantime both sides are warned that the copies diverged.
|
||||||
- **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,10 +134,24 @@ GitHub `gh` logins are not shipped.
|
|||||||
tools: true # register remote_* tools (default: on for the master, off for slaves)
|
tools: true # register remote_* tools (default: on for the master, off for slaves)
|
||||||
enabled: true # false = refuse agent control of THIS device
|
enabled: true # false = refuse agent control of THIS device
|
||||||
allowPeerControl: false # master: let slaves relay exec/file/task ops to other devices
|
allowPeerControl: false # master: let slaves relay exec/file/task ops to other devices
|
||||||
|
sessionSync:
|
||||||
|
mirrorRecent: 10 # keep synced copies of each other device's 10 most recent chats (0 = off)
|
||||||
|
mirrorIntervalMs: 60000 # how often copies are refreshed (only chats that changed are fetched)
|
||||||
|
watchIntervalMs: 20000 # how often moved chats are checked for divergence and queued notices retried
|
||||||
```
|
```
|
||||||
|
|
||||||
Never synced: the master's `dsh-remote` and `dsh-lanmode` entries, and `connection`/`webserver` settings, since these are machine-local.
|
Never synced: the master's `dsh-remote` and `dsh-lanmode` entries, and `connection`/`webserver` settings, since these are machine-local.
|
||||||
|
|
||||||
|
## Conversation sync
|
||||||
|
|
||||||
|
A chat can move between devices the way a branch moves between git clones.
|
||||||
|
|
||||||
|
- **Continue here / on another device.** The conversation is copied through its **last finished turn** (a turn that is still running is left behind) and written on the target as a *seeded* session, which is the same log shape DSH writes for its own *Fork*. The target's session controller then adopts it like any native chat: own agent, tools and preset, attached to the matching workspace (same path, else a workspace with the same folder name, else the original path if it exists, else the home directory). It shows up in the normal sidebar and is marked `⇣` (continued from …).
|
||||||
|
- **The original is told it moved** and shows `→` (continues on …) in its sidebar row and the Devices page.
|
||||||
|
- **Versioning.** Each copy records its base: the event seq it was cut at plus a commit id (a hash of that event). Logs are append-only, so the same commit id at the same seq means the same history up to there.
|
||||||
|
- **Offline.** Every device keeps synced copies of the other devices' recent chats (`<profile>/dsh-remote/mirrors/`, fetched incrementally). When a device is offline, the Devices page and the sidebar list its chats from those copies, and *Continue here* uses them. The move notice is queued and delivered **as soon as the device is reachable**: on link-up, on the master's peer broadcast, and retried every `watchIntervalMs`.
|
||||||
|
- **Conflicts.** If the original got new messages after the base (someone kept chatting there while the devices could not see each other, or after the move), both sides are marked `⚠ diverged` and get a toast. Nothing is merged or deleted, and both chats stay usable. *Continue here* on the newer branch creates a fresh copy from it.
|
||||||
|
|
||||||
## How sync works
|
## How sync works
|
||||||
|
|
||||||
| Master has | Slave gets |
|
| Master has | Slave gets |
|
||||||
@@ -195,7 +208,7 @@ A master published behind an identity-aware proxy (Authentik, oauth2-proxy, Clou
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `GET /dsh-remote/state` | role, self, link status, devices |
|
| `GET /dsh-remote/state` | role, self, link status, devices |
|
||||||
| `GET /dsh-remote/sessions` | sessions of every reachable device |
|
| `GET /dsh-remote/sessions` | sessions of every reachable device |
|
||||||
| `GET /dsh-remote/stream` | SSE: `session-finished`, `devices`, `sync` events |
|
| `GET /dsh-remote/stream` | SSE: `session-finished`, `session-moved`, `session-conflict`, `devices`, `sync` events |
|
||||||
| `POST /dsh-remote/pairing` | master: create a pairing code |
|
| `POST /dsh-remote/pairing` | master: create a pairing code |
|
||||||
| `POST /dsh-remote/pair` | slave: `{masterUrl, code}` |
|
| `POST /dsh-remote/pair` | slave: `{masterUrl, code}` |
|
||||||
| `POST /dsh-remote/unpair` | slave: forget the master |
|
| `POST /dsh-remote/unpair` | slave: forget the master |
|
||||||
@@ -203,6 +216,9 @@ A master published behind an identity-aware proxy (Authentik, oauth2-proxy, Clou
|
|||||||
| `POST /dsh-remote/sync` | slave: pull now · master: push to all |
|
| `POST /dsh-remote/sync` | slave: pull now · master: push to all |
|
||||||
| `GET /dsh-remote/sync/status` | manifest (master) / last result (slave) |
|
| `GET /dsh-remote/sync/status` | manifest (master) / last result (slave) |
|
||||||
| `POST /dsh-remote/new-session` | `{device, workspaceId? , cwd?}` |
|
| `POST /dsh-remote/new-session` | `{device, workspaceId? , cwd?}` |
|
||||||
|
| `POST /dsh-remote/continue-session` | `{sourceDevice, sessionId, targetDevice?, workspaceId?, title?}`: continue a chat on a device (default: this one) |
|
||||||
|
| `GET /dsh-remote/session-sync` | this device's lineage ledger (moved / continued-from / conflicts) and queued notices |
|
||||||
|
| `POST /dsh-remote/session-sync/resolve` | `{sessionId}`: dismiss a divergence warning (both chats are kept) |
|
||||||
| `GET /dsh-remote/debug/inspect` | `?sessionId=…&device=…`: last events of a session on any device |
|
| `GET /dsh-remote/debug/inspect` | `?sessionId=…&device=…`: last events of a session on any device |
|
||||||
| `GET/POST /dsh-remote/edge-auth` | slave: show / set `{edgeAuth}` and probe whether the auth proxy lets it through |
|
| `GET/POST /dsh-remote/edge-auth` | slave: show / set `{edgeAuth}` and probe whether the auth proxy lets it through |
|
||||||
| `GET/POST /dsh-remote/node` | `{nodeType, authMiddleware}`: node settings and the pending `authRequired` state |
|
| `GET/POST /dsh-remote/node` | `{nodeType, authMiddleware}`: node settings and the pending `authRequired` state |
|
||||||
|
|||||||
@@ -135,68 +135,88 @@ 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, devices = [] }) {
|
// ------------------------------------------------------------------ conversation sync
|
||||||
|
const devName = (id) => (store.state?.devices || []).find((d) => d.id === id)?.name
|
||||||
|
|| (store.sessions || []).find((d) => d.device === id)?.name || id;
|
||||||
|
const toast = (text, tone = 'info', extra = {}) => { for (const l of store.events) l({ type: 'dshr-local-toast', text, tone, at: Date.now(), ...extra }); };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Continue a conversation on `targetDevice` (default: this device). The conversation is copied
|
||||||
|
* through its last finished turn as a native session there; the source is told it moved (or
|
||||||
|
* told as soon as it is reachable again).
|
||||||
|
*/
|
||||||
|
async function continueSession({ sourceDevice, sessionId, title, targetDevice }) {
|
||||||
|
const selfId = store.state?.self?.id;
|
||||||
|
const target = targetDevice || selfId;
|
||||||
|
const r = await post('continue-session', { sourceDevice, sessionId, targetDevice: target, title });
|
||||||
|
const where = target === selfId ? 'here' : `on ${devName(target)}`;
|
||||||
|
const notes = [];
|
||||||
|
if (r.fromMirror) notes.push(`${devName(sourceDevice)} is offline — used the copy synced ${ago(r.syncedAt)}`);
|
||||||
|
if (r.partialTurnLeftBehind) notes.push('a turn that was still running there was not carried over');
|
||||||
|
if (!r.sourceNotified) notes.push(`${devName(sourceDevice)} will be told as soon as it is back online`);
|
||||||
|
if (r.conflict) notes.push(`${devName(sourceDevice)} kept chatting in it meanwhile — the two copies have diverged`);
|
||||||
|
const msg = { type: 'dshr-local-toast', text: `“${title || 'Conversation'}” continues ${where}${notes.length ? ' · ' + notes.join(' · ') : ''}`, tone: r.conflict ? 'warning' : 'success', holdMs: notes.length ? 10000 : 5000, at: Date.now() };
|
||||||
|
// The page reloads into the new session; show the outcome after that.
|
||||||
|
try { sessionStorage.setItem('dshr-pending-toast', JSON.stringify(msg)); } catch { toast(msg.text, msg.tone, msg); }
|
||||||
|
store.refresh();
|
||||||
|
// Open the continued conversation as a native session of that device.
|
||||||
|
const url = sessionUrl(target, selfId, r.sessionId);
|
||||||
|
const sameDoc = target === (mount.device || selfId);
|
||||||
|
location.href = url;
|
||||||
|
if (sameDoc) setTimeout(() => location.reload(), 60);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineageBadges(lin, selfId) {
|
||||||
|
if (!lin) return [];
|
||||||
|
const out = [];
|
||||||
|
if (lin.origin) out.push(h('span', { key: 'o', style: { ...S.badge(), marginRight: 6 }, title: `Continued from ${lin.origin.deviceName || lin.origin.device} at event ${lin.origin.baseSeq}${lin.origin.fromMirror ? ' (from its synced copy, it was offline)' : ''}` }, `from ${lin.origin.deviceName || lin.origin.device}`));
|
||||||
|
if (lin.moved) out.push(h('a', { key: 'm', href: sessionUrl(lin.moved.device, selfId, lin.moved.sessionId), onClick: (e) => e.stopPropagation(), style: { ...S.badge('accent'), marginRight: 6, textDecoration: 'none' }, title: `This conversation continues on ${devName(lin.moved.device)} (moved at event ${lin.moved.atSeq})` }, `moved → ${devName(lin.moved.device)}`));
|
||||||
|
if (lin.conflict) out.push(h('span', { key: 'c', style: { ...S.badge('warn'), marginRight: 6 }, title: `Both copies got new messages after event ${lin.conflict.atSeq}. Nothing was merged; both stay usable. Other copy: ${devName(lin.conflict.other?.device)}.` }, '⚠ diverged'));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContinueControls({ dev, s, devices }) {
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const isRemote = dev.device !== selfId;
|
const [err, setErr] = useState('');
|
||||||
|
const selfId = store.state?.self?.id;
|
||||||
const doContinue = async (targetDev) => {
|
const targets = devices.filter((d) => d.online && !d.revoked && d.id !== dev.device);
|
||||||
setBusy(true);
|
const go = async (target) => {
|
||||||
try {
|
setBusy(true); setErr('');
|
||||||
const r = await post('continue-session', {
|
try { await continueSession({ sourceDevice: dev.device, sessionId: s.sessionId, title: s.title, targetDevice: target }); }
|
||||||
sourceDevice: dev.device,
|
catch (e) { setErr(e.message); setBusy(false); }
|
||||||
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('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 4, flex: 'none' } },
|
||||||
return h('div', {
|
err && h('span', { style: { ...S.err, margin: 0, maxWidth: 260 }, title: err }, err),
|
||||||
className: 'dshr-row-wrap',
|
dev.device !== selfId && h('button', {
|
||||||
style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }
|
style: { ...S.btn, fontSize: 11, padding: '2px 8px' }, disabled: busy,
|
||||||
|
title: dev.offline ? `${dev.name} is offline: continue from the copy synced to this device` : `Copy this conversation to ${store.state?.self?.name || 'this device'} and continue it here`,
|
||||||
|
onClick: (e) => { e.preventDefault(); e.stopPropagation(); go(selfId); },
|
||||||
|
}, busy ? 'Syncing…' : 'Continue here'),
|
||||||
|
targets.filter((d) => d.id !== selfId).length > 0 && h('select', {
|
||||||
|
style: { ...S.input, fontSize: 11, padding: '2px 4px', width: 'auto' }, value: '', disabled: busy,
|
||||||
|
title: 'Continue this conversation on another device',
|
||||||
|
onClick: (e) => e.stopPropagation(),
|
||||||
|
onChange: (e) => { if (e.target.value) go(e.target.value); },
|
||||||
},
|
},
|
||||||
h('a', {
|
h('option', { value: '' }, 'Continue on…'),
|
||||||
|
targets.map((d) => h('option', { key: d.id, value: d.id }, d.id === selfId ? `${d.name} (this device)` : d.name))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionRow({ dev, s, selfId, devices = [] }) {
|
||||||
|
const openable = !dev.offline && !dev.replica;
|
||||||
|
return h('div', { style: { display: 'flex', alignItems: 'center', gap: 6 } },
|
||||||
|
h(openable ? 'a' : 'div', {
|
||||||
className: 'dshr-row', style: { ...S.row, color: 'inherit', textDecoration: 'none', flex: 1, minWidth: 0 },
|
className: 'dshr-row', style: { ...S.row, color: 'inherit', textDecoration: 'none', flex: 1, minWidth: 0 },
|
||||||
href: sessionUrl(dev.device, selfId, s.sessionId),
|
href: openable ? sessionUrl(dev.device, selfId, s.sessionId) : undefined,
|
||||||
title: `${s.title || s.sessionId}\n${s.cwd || ''}`,
|
title: `${s.title || s.sessionId}\n${s.cwd || ''}${s.replica ? `\nsynced copy from ${ago(s.replica.syncedAt)}` : ''}`,
|
||||||
},
|
},
|
||||||
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')),
|
||||||
s.lineage?.conflict && h('span', { style: { ...S.badge('warn'), marginRight: 6 } }, '⚠ Diverged'),
|
...lineageBadges(s.lineage, selfId),
|
||||||
s.lineage?.status === 'moved' && h('span', { style: { ...S.badge('accent'), marginRight: 6 } }, `⇄ Moved to ${s.lineage.movedTo}`),
|
s.replica && h('span', { style: { ...S.badge(), marginRight: 6 }, title: 'Offline device: this is the copy synced to this device' }, `copy · ${ago(s.replica.syncedAt)}`),
|
||||||
h('span', { style: S.meta }, ago(s.updatedAt))
|
h('span', { style: S.meta }, ago(s.updatedAt))),
|
||||||
),
|
h(ContinueControls, { dev, s, devices }));
|
||||||
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 }) {
|
||||||
@@ -264,7 +284,11 @@ window.__ModuleLoader__.load({
|
|||||||
if (!st.state && !st.error) return h('div', { style: S.page }, 'Loading devices…');
|
if (!st.state && !st.error) return h('div', { style: S.page }, 'Loading devices…');
|
||||||
const q = filter.trim().toLowerCase();
|
const q = filter.trim().toLowerCase();
|
||||||
const bySessions = Object.fromEntries((st.sessions || []).map((d) => [d.device, d]));
|
const bySessions = Object.fromEntries((st.sessions || []).map((d) => [d.device, d]));
|
||||||
const devices = (st.state?.devices || []).filter((d) => !d.revoked || d.self);
|
const known = (st.state?.devices || []).filter((d) => !d.revoked || d.self);
|
||||||
|
// Devices that are offline and only known here through their synced copies.
|
||||||
|
const replicaOnly = (st.sessions || []).filter((d) => d.offline && !known.some((k) => k.id === d.device))
|
||||||
|
.map((d) => ({ id: d.device, name: d.name, online: false, replicaOnly: true }));
|
||||||
|
const devices = [...known, ...replicaOnly];
|
||||||
const online = devices.filter((d) => d.online).length;
|
const online = devices.filter((d) => d.online).length;
|
||||||
return h('div', { style: S.page, 'data-dsh-plugin': 'dsh-remote' },
|
return h('div', { style: S.page, 'data-dsh-plugin': 'dsh-remote' },
|
||||||
h('h1', { style: S.h1 }, 'Devices'),
|
h('h1', { style: S.h1 }, 'Devices'),
|
||||||
@@ -301,6 +325,8 @@ window.__ModuleLoader__.load({
|
|||||||
m.platform ? ` · ${m.platform}` : '',
|
m.platform ? ` · ${m.platform}` : '',
|
||||||
m.online && !dev.error ? ` · ${(dev.workspaces || []).length} workspaces · ${(dev.items || []).length} sessions${running ? ` · ${running} running` : ''}` : ''),
|
m.online && !dev.error ? ` · ${(dev.workspaces || []).length} workspaces · ${(dev.items || []).length} sessions${running ? ` · ${running} running` : ''}` : ''),
|
||||||
dev.error && h('div', { style: S.err }, dev.error),
|
dev.error && h('div', { style: S.err }, dev.error),
|
||||||
|
!m.online && (dev.items || []).length > 0 && h('div', { style: { fontSize: 12, opacity: .7, margin: '0 0 6px' } },
|
||||||
|
`Offline — showing the ${(dev.items || []).length} conversation${(dev.items || []).length > 1 ? 's' : ''} synced to this device. “Continue here” picks up from that copy; ${m.name || m.id} is told as soon as it is back online.`),
|
||||||
!dev.error && m.online && !groups.length && h('div', { style: { opacity: .6, fontSize: 13 } }, q ? 'No matching sessions.' : 'No workspaces yet.'),
|
!dev.error && m.online && !groups.length && h('div', { style: { opacity: .6, fontSize: 13 } }, q ? 'No matching sessions.' : 'No workspaces yet.'),
|
||||||
groups.map((g) => h('div', { key: g.key },
|
groups.map((g) => h('div', { key: g.key },
|
||||||
h('div', { style: { ...S.ws, display: 'flex', alignItems: 'center', gap: 6 }, title: g.path },
|
h('div', { style: { ...S.ws, display: 'flex', alignItems: 'center', gap: 6 }, title: g.path },
|
||||||
@@ -335,6 +361,31 @@ window.__ModuleLoader__.load({
|
|||||||
`;
|
`;
|
||||||
const SB_SESSIONS = 6;
|
const SB_SESSIONS = 6;
|
||||||
|
|
||||||
|
/** One remote conversation in the stock sidebar: open it there, or continue it on this device. */
|
||||||
|
function SidebarSessionRow({ dev, s, selfId, offline }) {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const lin = s.lineage;
|
||||||
|
const mark = lin?.conflict ? { t: '⚠', c: '#d29922', tip: 'diverged from its continued copy' }
|
||||||
|
: lin?.moved ? { t: '→', c: '#58a6ff', tip: `continues on ${devName(lin.moved.device)}` } : null;
|
||||||
|
const cont = async (e) => {
|
||||||
|
e.stopPropagation(); e.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
try { await continueSession({ sourceDevice: dev.device, sessionId: s.sessionId, title: s.title }); }
|
||||||
|
catch (err) { toast(`Could not continue “${s.title || 'conversation'}”: ${err.message}`, 'error'); setBusy(false); }
|
||||||
|
};
|
||||||
|
const label = h(React.Fragment, null,
|
||||||
|
s.running ? h('span', { className: 'dshr-sb-run', title: 'running' }) : null,
|
||||||
|
h('span', { className: 'dshr-sb-title' }, s.title || 'untitled'),
|
||||||
|
mark && h('span', { style: { fontSize: 11, color: mark.c, marginRight: 4 }, title: mark.tip }, mark.t),
|
||||||
|
h('span', { className: 'dshr-sb-time' }, busy ? 'syncing…' : ago(s.updatedAt)));
|
||||||
|
return h('div', { className: 'dshr-sb-row dshr-sb-sess', role: 'treeitem' },
|
||||||
|
offline
|
||||||
|
? h('span', { style: { display: 'flex', alignItems: 'center', flex: 1, minWidth: 0 }, title: `${s.title || s.sessionId}\n${dev.name} is offline — click to continue it here from the synced copy`, onClick: cont }, label)
|
||||||
|
: 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}` }, label),
|
||||||
|
h('button', { className: 'dshr-sb-plus', disabled: busy, title: `Continue on ${store.state?.self?.name || 'this device'} (copies the conversation here)`, onClick: cont }, '⇣'));
|
||||||
|
}
|
||||||
|
|
||||||
function SidebarRemote() {
|
function SidebarRemote() {
|
||||||
const st = useStore();
|
const st = useStore();
|
||||||
const [open, setOpen] = useState(() => { try { return JSON.parse(localStorage.getItem('dshr-ws-open') || '{}'); } catch { return {}; } });
|
const [open, setOpen] = useState(() => { try { return JSON.parse(localStorage.getItem('dshr-ws-open') || '{}'); } catch { return {}; } });
|
||||||
@@ -342,7 +393,7 @@ window.__ModuleLoader__.load({
|
|||||||
const selfId = st.state?.self?.id;
|
const selfId = st.state?.self?.id;
|
||||||
// The device whose UI this page is showing already has its own native rows.
|
// The device whose UI this page is showing already has its own native rows.
|
||||||
const viewed = mount.device || selfId;
|
const viewed = mount.device || selfId;
|
||||||
const devs = (st.sessions || []).filter((d) => d.device !== viewed && !d.error);
|
const devs = (st.sessions || []).filter((d) => d.device !== viewed && (!d.error || (d.replica && (d.items || []).length)));
|
||||||
if (!st.state || !devs.length) return null;
|
if (!st.state || !devs.length) return null;
|
||||||
const toggle = (key) => {
|
const toggle = (key) => {
|
||||||
const next = { ...open, [key]: !open[key] };
|
const next = { ...open, [key]: !open[key] };
|
||||||
@@ -360,43 +411,14 @@ window.__ModuleLoader__.load({
|
|||||||
devs.flatMap((dev) => groupDevice(dev).map((g) => {
|
devs.flatMap((dev) => groupDevice(dev).map((g) => {
|
||||||
const isOpen = !!open[g.key];
|
const isOpen = !!open[g.key];
|
||||||
const shown = more[g.key] ? g.sessions : g.sessions.slice(0, SB_SESSIONS);
|
const shown = more[g.key] ? g.sessions : g.sessions.slice(0, SB_SESSIONS);
|
||||||
|
const offline = !!(dev.offline || dev.replica);
|
||||||
return h(React.Fragment, { key: g.key },
|
return h(React.Fragment, { key: g.key },
|
||||||
h('div', { className: 'dshr-sb-row', role: 'treeitem', 'aria-expanded': isOpen, title: `${dev.name}: ${g.path}`, onClick: () => toggle(g.key) },
|
h('div', { className: 'dshr-sb-row', role: 'treeitem', 'aria-expanded': isOpen, title: `${dev.name}: ${g.path}${offline ? '\noffline — synced copies' : ''}`, onClick: () => toggle(g.key), style: offline ? { opacity: .7 } : undefined },
|
||||||
h('span', { className: 'dshr-sb-slot' }, h(ServerIcon)),
|
h('span', { className: 'dshr-sb-slot' }, h(ServerIcon)),
|
||||||
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) }, '+'),
|
!offline && 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' }, offline ? `${dev.name || dev.device} · offline` : (dev.name || dev.device))),
|
||||||
isOpen && shown.map((s) => h('div', {
|
isOpen && shown.map((s) => h(SidebarSessionRow, { key: s.sessionId, dev, s, selfId, offline })),
|
||||||
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,
|
|
||||||
h('span', { className: 'dshr-sb-title' }, s.title || 'untitled'),
|
|
||||||
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'));
|
||||||
})));
|
})));
|
||||||
@@ -628,22 +650,117 @@ window.__ModuleLoader__.load({
|
|||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const l = (ev) => {
|
const l = (ev) => {
|
||||||
if (ev.type !== 'session-finished') return;
|
if (!['session-finished', 'session-moved', 'session-conflict', 'dshr-local-toast'].includes(ev.type)) return;
|
||||||
const id = `${ev.device}:${ev.sessionId}:${ev.at}`;
|
const id = `${ev.type}:${ev.device}:${ev.sessionId}:${ev.at || Date.now()}:${Math.random()}`;
|
||||||
setItems((xs) => [...xs.slice(-2), { id, ev }]);
|
setItems((xs) => [...xs.slice(-2), { id, ev }]);
|
||||||
};
|
};
|
||||||
|
try {
|
||||||
|
const pending = sessionStorage.getItem('dshr-pending-toast');
|
||||||
|
if (pending) { sessionStorage.removeItem('dshr-pending-toast'); setTimeout(() => l(JSON.parse(pending)), 400); }
|
||||||
|
} catch { /* ignore */ }
|
||||||
store.events.add(l);
|
store.events.add(l);
|
||||||
return () => store.events.delete(l);
|
return () => store.events.delete(l);
|
||||||
}, []);
|
}, []);
|
||||||
return h(React.Fragment, null, items.map(({ id, ev }) => {
|
return h(React.Fragment, null, items.map(({ id, ev }) => {
|
||||||
const text = `${ev.deviceName}: “${ev.title || 'session'}” finished`;
|
const selfId = store.state?.self?.id;
|
||||||
const done = () => setItems((xs) => xs.filter((x) => x.id !== id));
|
const done = () => setItems((xs) => xs.filter((x) => x.id !== id));
|
||||||
const actions = [{ label: 'Open', prefix: ' · ', onClick: () => { location.href = sessionUrl(ev.device, store.state?.self?.id, ev.sessionId); done(); } }];
|
const open = (device, sessionId) => ({ label: 'Open', prefix: ' · ', onClick: () => { location.href = sessionUrl(device, selfId, sessionId); done(); } });
|
||||||
if (P.Toast) return h(P.Toast, { key: id, text, tone: 'success', holdMs: 6000, actions, onDone: done });
|
let text; let tone = 'success'; let holdMs = 6000; let actions = [];
|
||||||
return h('div', { key: id, onClick: actions[0].onClick, style: { position: 'fixed', top: 16, left: '50%', transform: 'translateX(-50%)', zIndex: 9999, background: '#1f6feb', color: '#fff', padding: '8px 14px', borderRadius: 8, cursor: 'pointer', fontSize: 14 } }, text);
|
if (ev.type === 'session-finished') {
|
||||||
|
text = `${ev.deviceName}: “${ev.title || 'session'}” finished`;
|
||||||
|
actions = [open(ev.device, ev.sessionId)];
|
||||||
|
} else if (ev.type === 'session-moved') {
|
||||||
|
text = `“${ev.title || 'Conversation'}” now continues on ${ev.other?.deviceName || ev.other?.device}`;
|
||||||
|
tone = 'info'; holdMs = 8000;
|
||||||
|
actions = [{ ...open(ev.other.device, ev.other.sessionId), label: 'Go there' }];
|
||||||
|
} else if (ev.type === 'session-conflict') {
|
||||||
|
text = `“${ev.title || 'Conversation'}” diverged: it got new messages both here and on ${ev.other?.deviceName || ev.other?.device}. Nothing was merged — both copies are kept.`;
|
||||||
|
tone = 'warning'; holdMs = 12000;
|
||||||
|
actions = [{ ...open(ev.other.device, ev.other.sessionId), label: 'Other copy' }];
|
||||||
|
} else {
|
||||||
|
text = ev.text; tone = ev.tone || 'info'; holdMs = ev.holdMs || 6000;
|
||||||
|
}
|
||||||
|
const icon = tone === 'warning' || tone === 'error' ? h('span', { style: { color: '#d29922' } }, '⚠') : undefined;
|
||||||
|
if (P.Toast) return h(P.Toast, { key: id, text, tone, icon, holdMs, actions, onDone: done });
|
||||||
|
return h('div', { key: id, onClick: actions[0]?.onClick || done, style: { position: 'fixed', top: 16, left: '50%', transform: 'translateX(-50%)', zIndex: 9999, background: '#1f6feb', color: '#fff', padding: '8px 14px', borderRadius: 8, cursor: 'pointer', fontSize: 14 } }, text);
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ native sidebar: this device's chats
|
||||||
|
// The stock session rows belong to the device whose UI this page shows.
|
||||||
|
const viewedDevice = () => mount.device || store.state?.self?.id;
|
||||||
|
const localLineage = (sessionId) => (store.sessions || []).find((d) => d.device === viewedDevice())?.items?.find((s) => s.sessionId === sessionId)?.lineage || null;
|
||||||
|
const picker = { value: null, listeners: new Set(), set(v) { this.value = v; this.listeners.forEach((l) => l()); } };
|
||||||
|
/** Re-render on store changes without starting another refresh timer (one per sidebar row). */
|
||||||
|
function useStoreQuiet() {
|
||||||
|
const [, force] = useState(0);
|
||||||
|
useEffect(() => { const l = () => force((x) => x + 1); store.listeners.add(l); return () => store.listeners.delete(l); }, []);
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "…" menu row: continue this chat on another device. */
|
||||||
|
function ContinueMenuItem({ sessionId, displayTitle, useMenuOpenState }) {
|
||||||
|
const [, setMenuOpen] = useMenuOpenState();
|
||||||
|
const st = useStoreQuiet();
|
||||||
|
const others = (st.state?.devices || []).filter((d) => d.online && !d.revoked && d.id !== viewedDevice());
|
||||||
|
if (!others.length) return null;
|
||||||
|
const onSelect = () => { setMenuOpen(false); picker.set({ sessionId, title: displayTitle }); };
|
||||||
|
const icon = h(ServerIcon, { size: 16 });
|
||||||
|
if (P.MenuItemButton) return h(P.MenuItemButton, { icon, onSelect }, 'Continue on another device…');
|
||||||
|
return h('button', { type: 'button', role: 'menuitem', onClick: onSelect }, 'Continue on another device…');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Row marker: moved away / diverged / continued from another device. */
|
||||||
|
function LineageRowMark({ sessionId }) {
|
||||||
|
useStoreQuiet();
|
||||||
|
const lin = localLineage(sessionId);
|
||||||
|
if (!lin) return null;
|
||||||
|
const m = lin.conflict ? { t: '⚠', c: '#d29922', tip: `Diverged: this chat and its copy on ${devName(lin.conflict.other?.device)} both got new messages. Both are kept.` }
|
||||||
|
: lin.moved ? { t: '→', c: '#58a6ff', tip: `Continues on ${devName(lin.moved.device)}` }
|
||||||
|
: lin.origin ? { t: '⇣', c: 'inherit', tip: `Continued from ${lin.origin.deviceName || lin.origin.device}` } : null;
|
||||||
|
if (!m) return null;
|
||||||
|
const go = lin.moved && !lin.conflict ? (e) => { e.stopPropagation(); location.href = sessionUrl(lin.moved.device, store.state?.self?.id, lin.moved.sessionId); } : undefined;
|
||||||
|
return h('span', { title: m.tip, onClick: go, style: { color: m.c, fontSize: 12, padding: '0 4px', cursor: go ? 'pointer' : 'default', opacity: .85 } }, m.t);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PickerHost() {
|
||||||
|
const [v, setV] = useState(picker.value);
|
||||||
|
const st = useStoreQuiet();
|
||||||
|
const [busy, setBusy] = useState('');
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
useEffect(() => { const l = () => { setV(picker.value); setErr(''); setBusy(''); }; picker.listeners.add(l); return () => picker.listeners.delete(l); }, []);
|
||||||
|
if (!v) return null;
|
||||||
|
const selfId = st.state?.self?.id;
|
||||||
|
const source = viewedDevice();
|
||||||
|
const targets = (st.state?.devices || []).filter((d) => d.online && !d.revoked && d.id !== source);
|
||||||
|
const close = () => picker.set(null);
|
||||||
|
const go = async (target) => {
|
||||||
|
setBusy(target); setErr('');
|
||||||
|
try { await continueSession({ sourceDevice: source, sessionId: v.sessionId, title: v.title, targetDevice: target }); close(); }
|
||||||
|
catch (e) { setErr(e.message); setBusy(''); }
|
||||||
|
};
|
||||||
|
return h('div', { onClick: close, style: { position: 'fixed', inset: 0, zIndex: 10000, background: 'rgba(0,0,0,.35)', display: 'flex', alignItems: 'center', justifyContent: 'center' } },
|
||||||
|
h('div', { onClick: (e) => e.stopPropagation(), role: 'dialog', 'aria-label': 'Continue on another device', style: { width: 380, maxWidth: '90vw', background: 'var(--dsw-specific-menu, var(--dsw-alias-bg-l1, #1c1c1c))', color: 'var(--dsw-alias-label-primary, inherit)', border: '1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3))', borderRadius: 12, padding: 16, boxShadow: '0 12px 40px rgba(0,0,0,.4)', fontSize: 14 } },
|
||||||
|
h('div', { style: { fontWeight: 600, marginBottom: 4 } }, 'Continue on another device'),
|
||||||
|
h('div', { style: { fontSize: 12, opacity: .7, marginBottom: 12 } }, `“${v.title || 'Conversation'}” is copied through its last finished reply and opens there as a native chat. This copy is marked as moved.`),
|
||||||
|
targets.length === 0 && h('div', { style: { opacity: .7 } }, 'No other device is online.'),
|
||||||
|
targets.map((d) => h('button', { key: d.id, disabled: !!busy, onClick: () => go(d.id), style: { ...S.btn, display: 'flex', width: '100%', alignItems: 'center', gap: 8, marginBottom: 6, padding: '8px 10px', textAlign: 'left' } },
|
||||||
|
h(d.id === selfId ? FolderIcon : ServerIcon, { size: 14 }),
|
||||||
|
h('span', { style: { flex: 1 } }, d.id === selfId ? `${d.name} (this browser's device)` : d.name),
|
||||||
|
busy === d.id && h('span', { style: { fontSize: 12, opacity: .7 } }, 'syncing…'))),
|
||||||
|
err && h('div', { style: { ...S.err, marginTop: 6 } }, err),
|
||||||
|
h('div', { style: { textAlign: 'right', marginTop: 8 } }, h('button', { style: S.btn, onClick: close }, 'Cancel'))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountPicker() {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.id = 'dshr-picker';
|
||||||
|
document.body.appendChild(el);
|
||||||
|
const root = ReactDOM.createRoot ? ReactDOM.createRoot(el) : null;
|
||||||
|
if (root) root.render(h(PickerHost)); else ReactDOM.render(h(PickerHost), el);
|
||||||
|
return () => { try { root ? root.unmount() : ReactDOM.unmountComponentAtNode(el); } catch { /* ignore */ } el.remove(); };
|
||||||
|
}
|
||||||
|
|
||||||
function mountToasts() {
|
function mountToasts() {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.id = 'dshr-toasts';
|
el.id = 'dshr-toasts';
|
||||||
@@ -791,6 +908,14 @@ window.__ModuleLoader__.load({
|
|||||||
disposers.push(ctx.slots.inject('settings.section', () => ctx.slots.register({
|
disposers.push(ctx.slots.inject('settings.section', () => ctx.slots.register({
|
||||||
name: 'settings.section', id: 'dsh-remote', order: 160, label: 'Remote devices',
|
name: 'settings.section', id: 'dsh-remote', order: 160, label: 'Remote devices',
|
||||||
}, SettingsPanel)));
|
}, SettingsPanel)));
|
||||||
|
// Native chat rows: "Continue on another device…" and a moved/diverged marker.
|
||||||
|
disposers.push(mountPicker());
|
||||||
|
disposers.push(ctx.slots.inject('sidebar.workspaces.session.menu.item', () => ctx.slots.register({
|
||||||
|
name: 'sidebar.workspaces.session.menu.item', id: 'dsh-remote-continue', order: 260,
|
||||||
|
}, ContinueMenuItem)));
|
||||||
|
disposers.push(ctx.slots.inject('sidebar.workspaces.session.row.action', () => ctx.slots.register({
|
||||||
|
name: 'sidebar.workspaces.session.row.action', id: 'dsh-remote-lineage', order: 50,
|
||||||
|
}, LineageRowMark)));
|
||||||
|
|
||||||
ctx.effect(() => () => {
|
ctx.effect(() => () => {
|
||||||
for (const d of disposers.splice(0)) { try { d && d(); } catch { /* ignore */ } }
|
for (const d of disposers.splice(0)) { try { d && d(); } catch { /* ignore */ } }
|
||||||
|
|||||||
@@ -25,15 +25,19 @@
|
|||||||
// Upgrade routes:
|
// Upgrade routes:
|
||||||
// /dsh-remote/link → slave → master link (device id + key headers)
|
// /dsh-remote/link → slave → master link (device id + key headers)
|
||||||
// /r/<device>/api/remote.mux → DSH Remote stream WebSocket of another device
|
// /r/<device>/api/remote.mux → DSH Remote stream WebSocket of another device
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
import fs from 'node:fs'
|
||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
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, sha256 } from './lib/store.mjs'
|
import { Store, randomId, setProfileDir, slug, stateDir } 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'
|
||||||
import * as ops from './lib/agent-ops.mjs'
|
import * as ops from './lib/agent-ops.mjs'
|
||||||
import { createEdgeAuth } from './lib/edge-auth.mjs'
|
import { createEdgeAuth } from './lib/edge-auth.mjs'
|
||||||
|
import * as ss from './lib/session-sync.mjs'
|
||||||
|
|
||||||
export const name = 'dsh-remote'
|
export const name = 'dsh-remote'
|
||||||
export const inject = {
|
export const inject = {
|
||||||
@@ -52,8 +56,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 or session sync. */
|
/** Link methods that act on a device on behalf of an agent. */
|
||||||
const OPS = new Set(['exec', 'readFile', 'writeFile', 'listDir', 'task', 'taskStatus', 'taskCancel', 'inspectSession', 'exportSession', 'importSession', 'recordSessionMoved'])
|
const OPS = new Set(['exec', 'readFile', 'writeFile', 'listDir', 'task', 'taskStatus', 'taskCancel', 'inspectSession'])
|
||||||
/** 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,136 +207,13 @@ 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 ----
|
// ---- conversation sync (see lib/session-sync.mjs) ----
|
||||||
exportSession: async (p, mux) => {
|
syncPage: async (p) => syncEngine.servePage(p),
|
||||||
const sc = svc(ctx, 'sessionController')
|
importBegin: async (p) => syncEngine.importBegin(p),
|
||||||
if (!sc) throw httpErr(503, 'session controller unavailable')
|
importChunk: async (p) => syncEngine.importChunk(p),
|
||||||
let ins = null
|
importCommit: async (p) => syncEngine.importCommit(p),
|
||||||
try {
|
sessionMoved: async (p) => syncEngine.onMoved(p),
|
||||||
ins = await sc.inspect(p.sessionId)
|
sessionDiverged: async (p) => syncEngine.onDiverged(p),
|
||||||
} 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 }
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -400,7 +281,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,
|
lineage: ledger.get(s.sessionId),
|
||||||
})),
|
})),
|
||||||
workspaces,
|
workspaces,
|
||||||
}
|
}
|
||||||
@@ -419,6 +300,330 @@ export function apply(ctx, config = {}) {
|
|||||||
return { sessionId: r.sessionId }
|
return { sessionId: r.sessionId }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ conversation sync
|
||||||
|
// Git-like hand-over of a conversation between devices (see lib/session-sync.mjs).
|
||||||
|
const syncCfg = { mirrorRecent: 10, mirrorIntervalMs: 60000, watchIntervalMs: 20000, ...(config.sessionSync || {}) }
|
||||||
|
const ledger = new ss.Ledger(store)
|
||||||
|
const mirrors = new ss.MirrorStore(path.join(stateDir(), 'mirrors'))
|
||||||
|
const syncEngine = createSyncEngine()
|
||||||
|
|
||||||
|
function createSyncEngine() {
|
||||||
|
const readCache = new Map() // sessionId -> { at, events, cwd }
|
||||||
|
const staged = new Map() // importId -> { at, meta, events }
|
||||||
|
let mirroring = false
|
||||||
|
let delivering = false
|
||||||
|
|
||||||
|
const sc = () => {
|
||||||
|
const c = svc(ctx, 'sessionController')
|
||||||
|
if (!c) throw httpErr(503, 'session controller unavailable')
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full local log (cached briefly so paging a big session reads it once). */
|
||||||
|
async function readLocal(sessionId) {
|
||||||
|
const hit = readCache.get(sessionId)
|
||||||
|
if (hit && Date.now() - hit.at < 30000) return hit
|
||||||
|
let ins
|
||||||
|
try { ins = await sc().inspect(sessionId) } catch (e) { throw httpErr(404, `session ${sessionId} not found on ${self.name}: ${e.message || e}`) }
|
||||||
|
const v = { at: Date.now(), events: ins.events || [], cwd: ins.meta?.cwd }
|
||||||
|
readCache.set(sessionId, v)
|
||||||
|
for (const [k, x] of readCache) if (Date.now() - x.at > 30000) readCache.delete(k)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Link method: one page of a local session, optionally continuing a replica the caller holds. */
|
||||||
|
async function servePage({ sessionId, fromSeq = 0, knownSeq, knownCommit }) {
|
||||||
|
const { events, cwd } = await readLocal(sessionId)
|
||||||
|
let from = Number(fromSeq) || 0
|
||||||
|
let reset = false
|
||||||
|
if (knownSeq !== undefined && knownSeq >= 0 && ss.commitId(events, knownSeq) !== knownCommit) { reset = true; from = 0 }
|
||||||
|
const pg = ss.page(events, from)
|
||||||
|
const tip = ss.tipSeq(events)
|
||||||
|
return { sessionId, cwd, reset, events: pg.events, nextSeq: pg.nextSeq, done: pg.done, tipSeq: tip, tipCommit: tip >= 0 ? ss.commitId(events, tip) : null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull a remote session (incrementally on top of the local replica, if any) and refresh the
|
||||||
|
* replica. Falls back to the replica when the device is unreachable.
|
||||||
|
*/
|
||||||
|
async function fetchRemote(device, sessionId, info = {}) {
|
||||||
|
const meta = mirrors.meta(device, sessionId)
|
||||||
|
let events = meta ? mirrors.events(device, sessionId) : null
|
||||||
|
try {
|
||||||
|
if (!routeTo(device)) throw httpErr(503, `device ${device} is offline`)
|
||||||
|
let from = 0
|
||||||
|
let known = {}
|
||||||
|
if (events && events.length && meta?.tipCommit) { from = meta.tipSeq + 1; known = { knownSeq: meta.tipSeq, knownCommit: meta.tipCommit } } else events = []
|
||||||
|
let cwd = meta?.cwd
|
||||||
|
for (;;) {
|
||||||
|
const pg = await callDevice(device, 'syncPage', { sessionId, fromSeq: from, ...known }, 60000)
|
||||||
|
if (pg.reset) { events = []; known = {} }
|
||||||
|
events.push(...pg.events)
|
||||||
|
cwd = pg.cwd ?? cwd
|
||||||
|
from = pg.nextSeq
|
||||||
|
if (pg.done) break
|
||||||
|
known = {}
|
||||||
|
}
|
||||||
|
ss.assertDense(events)
|
||||||
|
const saved = mirrors.save(device, sessionId, events, { ...meta, ...info, cwd, deviceName: info.deviceName || meta?.deviceName })
|
||||||
|
return { events, cwd, fromMirror: false, syncedAt: saved.syncedAt }
|
||||||
|
} catch (e) {
|
||||||
|
if (events && events.length) return { events, cwd: meta.cwd, fromMirror: true, syncedAt: meta.syncedAt, offlineReason: String(e.message || e) }
|
||||||
|
throw e.status ? e : httpErr(503, String(e.message || e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep replicas of other devices' recent sessions so they can be continued while offline. */
|
||||||
|
async function refreshMirrors() {
|
||||||
|
if (mirroring || syncCfg.mirrorRecent <= 0) return
|
||||||
|
mirroring = true
|
||||||
|
try {
|
||||||
|
for (const d of devicesView()) {
|
||||||
|
if (d.self || !d.online || d.revoked) continue
|
||||||
|
let list
|
||||||
|
try { list = await callDevice(d.id, 'sessions', {}, 15000) } catch { continue }
|
||||||
|
const recent = (list.items || []).slice(0, syncCfg.mirrorRecent)
|
||||||
|
for (const s of recent) {
|
||||||
|
const m = mirrors.meta(d.id, s.sessionId)
|
||||||
|
if (m && m.updatedAt >= s.updatedAt) continue
|
||||||
|
try {
|
||||||
|
await fetchRemote(d.id, s.sessionId, { title: s.title, updatedAt: s.updatedAt, cwd: s.cwd, deviceName: d.name })
|
||||||
|
} catch (e) { log.debug?.(`dsh-remote: mirror ${d.id}/${s.sessionId}: ${e.message}`) }
|
||||||
|
}
|
||||||
|
mirrors.prune(d.id, Math.max(syncCfg.mirrorRecent * 2, 20))
|
||||||
|
}
|
||||||
|
} finally { mirroring = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Choose where an imported session lives on this device. */
|
||||||
|
function placeLocally({ workspaceId, cwd }) {
|
||||||
|
const wr = svc(ctx, 'workspaceRegistry')
|
||||||
|
const workspaces = wr ? wr.list() : []
|
||||||
|
if (workspaceId) {
|
||||||
|
const w = workspaces.find((x) => x.id === workspaceId)
|
||||||
|
if (!w) throw httpErr(404, `workspace ${workspaceId} not found on ${self.name}`)
|
||||||
|
return { cwd: w.path, workspaceId: w.id }
|
||||||
|
}
|
||||||
|
const exact = cwd && workspaces.find((x) => x.path === cwd)
|
||||||
|
if (exact) return { cwd: exact.path, workspaceId: exact.id }
|
||||||
|
const base = cwd ? path.basename(cwd) : ''
|
||||||
|
const sameName = base && workspaces.find((x) => path.basename(x.path) === base)
|
||||||
|
if (sameName) return { cwd: sameName.path, workspaceId: sameName.id }
|
||||||
|
if (cwd && path.isAbsolute(cwd) && fs.existsSync(cwd)) return { cwd }
|
||||||
|
return { cwd: os.homedir() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Materialise a conversation prefix on THIS device as a new seeded session — the same log
|
||||||
|
* shape DSH writes for a fork — then let the session controller adopt it, which composes the
|
||||||
|
* agent (tools, preset) and attaches it to the workspace exactly like a native session.
|
||||||
|
*/
|
||||||
|
async function importLocal({ events, baseSeq, origin, workspaceId, cwd }) {
|
||||||
|
ss.assertDense(events)
|
||||||
|
if (!(baseSeq >= 0 && baseSeq < events.length)) throw httpErr(400, 'invalid base seq')
|
||||||
|
const sp = svc(ctx, 'sessionPersistence')
|
||||||
|
if (!sp) throw httpErr(503, 'session persistence unavailable on this device')
|
||||||
|
const where = placeLocally({ workspaceId, cwd })
|
||||||
|
const sessionId = `session-${crypto.randomUUID()}`
|
||||||
|
const seed = ss.forkSeed(events, baseSeq)
|
||||||
|
const header = { version: 4, id: sessionId, createdAt: Date.now(), cwd: where.cwd, isSeeded: true }
|
||||||
|
const handle = await sp.create(header, { inheritedEventCount: baseSeq + 1 })
|
||||||
|
try {
|
||||||
|
await handle.append(seed)
|
||||||
|
await handle.flush?.()
|
||||||
|
} finally {
|
||||||
|
await handle.close()
|
||||||
|
}
|
||||||
|
await sc().create(where.workspaceId ? { sessionId, workspaceId: where.workspaceId } : { sessionId, cwd: where.cwd })
|
||||||
|
ledger.patch(sessionId, { origin: { ...origin, baseSeq, baseCommit: ss.commitId(events, baseSeq), importedAt: Date.now() } })
|
||||||
|
notify({ type: 'sessions' })
|
||||||
|
return { sessionId, cwd: where.cwd, workspaceId: where.workspaceId || null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link methods for importing onto another device in pages.
|
||||||
|
function importBegin(meta) {
|
||||||
|
for (const [k, v] of staged) if (Date.now() - v.at > 10 * 60000) staged.delete(k)
|
||||||
|
const id = randomId(12)
|
||||||
|
staged.set(id, { at: Date.now(), meta, events: [] })
|
||||||
|
return { importId: id }
|
||||||
|
}
|
||||||
|
function importChunk({ importId, events }) {
|
||||||
|
const st = staged.get(importId)
|
||||||
|
if (!st) throw httpErr(404, 'unknown import (expired?)')
|
||||||
|
st.events.push(...events)
|
||||||
|
st.at = Date.now()
|
||||||
|
return { received: st.events.length }
|
||||||
|
}
|
||||||
|
async function importCommit({ importId }) {
|
||||||
|
const st = staged.get(importId)
|
||||||
|
if (!st) throw httpErr(404, 'unknown import (expired?)')
|
||||||
|
staged.delete(importId)
|
||||||
|
return importLocal({ ...st.meta, events: st.events })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importOn(device, payload) {
|
||||||
|
if (device === self.id) return importLocal(payload)
|
||||||
|
const { events, ...meta } = payload
|
||||||
|
const { importId } = await callDevice(device, 'importBegin', meta, 20000)
|
||||||
|
for (let from = 0; from < events.length;) {
|
||||||
|
const pg = ss.page(events, from)
|
||||||
|
await callDevice(device, 'importChunk', { importId, events: pg.events }, 60000)
|
||||||
|
from = pg.nextSeq
|
||||||
|
}
|
||||||
|
return callDevice(device, 'importCommit', { importId }, 120000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deliver a notice now, or queue it for the moment the device is reachable again. */
|
||||||
|
async function send(notice) {
|
||||||
|
const method = notice.kind === 'diverged' ? 'sessionDiverged' : 'sessionMoved'
|
||||||
|
if (notice.to === self.id) return { delivered: true, result: await handlers[method](notice) }
|
||||||
|
if (routeTo(notice.to)) {
|
||||||
|
try {
|
||||||
|
const result = await callDevice(notice.to, method, notice, 20000)
|
||||||
|
return { delivered: true, result }
|
||||||
|
} catch (e) { log.warn?.(`dsh-remote: ${method} → ${notice.to} failed, queued: ${e.message}`) }
|
||||||
|
}
|
||||||
|
ledger.queue(notice)
|
||||||
|
return { delivered: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deliverOutbox() {
|
||||||
|
if (delivering) return
|
||||||
|
delivering = true
|
||||||
|
try {
|
||||||
|
for (const n of ledger.pending()) {
|
||||||
|
if (n.to !== self.id && !routeTo(n.to)) continue
|
||||||
|
const method = n.kind === 'diverged' ? 'sessionDiverged' : 'sessionMoved'
|
||||||
|
try {
|
||||||
|
const result = n.to === self.id ? await handlers[method](n) : await callDevice(n.to, method, n, 20000)
|
||||||
|
ledger.done(n)
|
||||||
|
if (n.kind === 'moved') afterMoveAck(n, result)
|
||||||
|
notify({ type: 'session-sync', delivered: n })
|
||||||
|
} catch (e) { log.warn?.(`dsh-remote: queued ${method} → ${n.to}: ${e.message}`) }
|
||||||
|
}
|
||||||
|
} finally { delivering = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The source answered a move notice; if it had diverged, flag our copy too. */
|
||||||
|
function afterMoveAck(notice, result) {
|
||||||
|
if (!result || result.state === 'clean') return
|
||||||
|
const copy = notice.movedTo
|
||||||
|
if (copy.device !== self.id) return // the conflict notice goes to that device from the source
|
||||||
|
ledger.patch(copy.sessionId, { conflict: { kind: result.state, otherTip: result.localTip, atSeq: notice.atSeq, detectedAt: Date.now(), other: { device: notice.to, sessionId: notice.sessionId } } })
|
||||||
|
notify({ type: 'session-conflict', device: self.id, sessionId: copy.sessionId, title: notice.title, other: { device: notice.to, deviceName: deviceName(notice.to), sessionId: notice.sessionId } })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Link method on the source device: "your session now continues elsewhere". */
|
||||||
|
async function onMoved(n) {
|
||||||
|
let events = []
|
||||||
|
try { readCache.delete(n.sessionId); events = (await readLocal(n.sessionId)).events } catch { /* deleted here */ }
|
||||||
|
const state = events.length ? ss.divergence(events, n.atSeq, n.atCommit) : 'clean'
|
||||||
|
const localTip = ss.tipSeq(events)
|
||||||
|
ledger.patch(n.sessionId, {
|
||||||
|
moved: { device: n.movedTo.device, sessionId: n.movedTo.sessionId, atSeq: n.atSeq, atCommit: n.atCommit, at: n.at, by: n.by },
|
||||||
|
conflict: state === 'clean' ? undefined : { kind: state, localTip, atSeq: n.atSeq, detectedAt: Date.now(), other: n.movedTo },
|
||||||
|
})
|
||||||
|
notify({ type: state === 'clean' ? 'session-moved' : 'session-conflict', device: self.id, sessionId: n.sessionId, title: n.title, other: { ...n.movedTo, deviceName: deviceName(n.movedTo.device) } })
|
||||||
|
if (state !== 'clean') {
|
||||||
|
// Tell the device holding the moved copy as well (now or as soon as it is reachable).
|
||||||
|
await send({ kind: 'diverged', to: n.movedTo.device, sessionId: n.movedTo.sessionId, other: { device: self.id, sessionId: n.sessionId }, state, localTip, atSeq: n.atSeq, title: n.title })
|
||||||
|
}
|
||||||
|
return { state, localTip }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Link method on the copy's device: the other branch got new events after the base. */
|
||||||
|
function onDiverged(n) {
|
||||||
|
ledger.patch(n.sessionId, { conflict: { kind: n.state || 'diverged', otherTip: n.localTip, atSeq: n.atSeq, detectedAt: Date.now(), other: n.other } })
|
||||||
|
notify({ type: 'session-conflict', device: self.id, sessionId: n.sessionId, title: n.title, other: { ...n.other, deviceName: deviceName(n.other.device) } })
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A moved-away session that gets new turns here has diverged from its continuation. */
|
||||||
|
async function watchMoved() {
|
||||||
|
const entries = Object.entries(ledger.all()).filter(([, l]) => l.moved && !l.conflict)
|
||||||
|
if (!entries.length) return
|
||||||
|
let items = []
|
||||||
|
try { items = (await listLocalSessions()).items } catch { return }
|
||||||
|
for (const [sessionId, l] of entries) {
|
||||||
|
const it = items.find((x) => x.sessionId === sessionId)
|
||||||
|
if (!it || !(it.updatedAt > l.moved.at)) continue
|
||||||
|
readCache.delete(sessionId)
|
||||||
|
let events
|
||||||
|
try { events = (await readLocal(sessionId)).events } catch { continue }
|
||||||
|
const state = ss.divergence(events, l.moved.atSeq, l.moved.atCommit)
|
||||||
|
if (state === 'clean') continue
|
||||||
|
const other = { device: l.moved.device, sessionId: l.moved.sessionId }
|
||||||
|
ledger.patch(sessionId, { conflict: { kind: state, localTip: ss.tipSeq(events), atSeq: l.moved.atSeq, detectedAt: Date.now(), other } })
|
||||||
|
notify({ type: 'session-conflict', device: self.id, sessionId, title: it.title, other: { ...other, deviceName: deviceName(other.device) } })
|
||||||
|
await send({ kind: 'diverged', to: other.device, sessionId: other.sessionId, other: { device: self.id, sessionId }, state, localTip: ss.tipSeq(events), atSeq: l.moved.atSeq, title: it.title })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Continue `sessionId` of `sourceDevice` on `targetDevice`: copy the conversation through its
|
||||||
|
* last completed turn, then tell the source (now, or queued until it is reachable).
|
||||||
|
*/
|
||||||
|
async function continueSession({ sourceDevice, sessionId, targetDevice, workspaceId, title }) {
|
||||||
|
const src = sourceDevice === self.id
|
||||||
|
? { ...(await readLocal(sessionId)), fromMirror: false }
|
||||||
|
: await fetchRemote(sourceDevice, sessionId)
|
||||||
|
const events = src.events
|
||||||
|
ss.assertDense(events)
|
||||||
|
const baseSeq = ss.completedBoundary(events)
|
||||||
|
if (baseSeq === undefined) throw httpErr(409, 'this conversation has no completed turn yet — wait for the first reply to finish')
|
||||||
|
const baseCommit = ss.commitId(events, baseSeq)
|
||||||
|
const prior = sourceDevice === self.id ? ledger.get(sessionId) : null
|
||||||
|
const name = ss.shortTitle(title || mirrors.meta(sourceDevice, sessionId)?.title)
|
||||||
|
const imported = await importOn(targetDevice, {
|
||||||
|
events: events.slice(0, baseSeq + 1), baseSeq, workspaceId, cwd: src.cwd,
|
||||||
|
origin: { device: sourceDevice, deviceName: deviceName(sourceDevice), sessionId, title: name, fromMirror: !!src.fromMirror },
|
||||||
|
})
|
||||||
|
const notice = {
|
||||||
|
kind: 'moved', to: sourceDevice, sessionId, title: name,
|
||||||
|
movedTo: { device: targetDevice, sessionId: imported.sessionId },
|
||||||
|
atSeq: baseSeq, atCommit: baseCommit, at: Date.now(), by: self.id,
|
||||||
|
}
|
||||||
|
const sent = await send(notice)
|
||||||
|
if (sent.delivered) afterMoveAck(notice, sent.result)
|
||||||
|
notify({ type: 'sessions' })
|
||||||
|
return {
|
||||||
|
device: targetDevice, sessionId: imported.sessionId,
|
||||||
|
url: targetDevice === self.id ? `./#/session/${imported.sessionId}` : `./r/${targetDevice}/#/session/${imported.sessionId}`,
|
||||||
|
baseSeq, tipSeq: ss.tipSeq(events), partialTurnLeftBehind: ss.tipSeq(events) > baseSeq,
|
||||||
|
fromMirror: !!src.fromMirror, syncedAt: src.syncedAt || null, offlineReason: src.offlineReason || null,
|
||||||
|
sourceNotified: sent.delivered, conflict: sent.result?.state && sent.result.state !== 'clean' ? sent.result.state : null,
|
||||||
|
previouslyMovedTo: prior?.moved || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sessions of offline devices, served from replicas. */
|
||||||
|
function offlineSessions(device) {
|
||||||
|
return mirrors.list(device)
|
||||||
|
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
|
||||||
|
.map((m) => ({ sessionId: m.sessionId, title: m.title || null, updatedAt: m.updatedAt || m.syncedAt, cwd: m.cwd, running: false, replica: { syncedAt: m.syncedAt, tipSeq: m.tipSeq } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceName = (id) => devicesView().find((d) => d.id === id)?.name || mirrors.list(id)[0]?.deviceName || id
|
||||||
|
|
||||||
|
// Timers: replicas, divergence watch, and a retry for queued notices.
|
||||||
|
const tMirror = setInterval(() => { refreshMirrors().catch(() => {}) }, syncCfg.mirrorIntervalMs)
|
||||||
|
const tWatch = setInterval(() => { watchMoved().catch(() => {}); deliverOutbox().catch(() => {}) }, syncCfg.watchIntervalMs)
|
||||||
|
const tFirst = setTimeout(() => { refreshMirrors().catch(() => {}) }, 15000)
|
||||||
|
for (const t of [tMirror, tWatch, tFirst]) t.unref?.()
|
||||||
|
ctx.effect(() => () => { clearInterval(tMirror); clearInterval(tWatch); clearTimeout(tFirst) }, 'dsh-remote: session sync timers')
|
||||||
|
|
||||||
|
return {
|
||||||
|
servePage, importBegin, importChunk, importCommit, onMoved, onDiverged, continueSession,
|
||||||
|
offlineSessions, refreshMirrors, deliverOutbox, deviceName,
|
||||||
|
replicaDevices: () => mirrors.devices(),
|
||||||
|
lineage: (id) => ledger.get(id),
|
||||||
|
/** A device (re)appeared: deliver its queued notices right away. */
|
||||||
|
deviceOnline: () => { setTimeout(() => deliverOutbox().catch(() => {}), 500) },
|
||||||
|
resolve(sessionId) { ledger.patch(sessionId, { conflict: undefined }); notify({ type: 'sessions' }); return { ok: true } },
|
||||||
|
state: () => ({ lineage: ledger.all(), outbox: ledger.pending() }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ device views
|
// ------------------------------------------------------------------ device views
|
||||||
function devicesView() {
|
function devicesView() {
|
||||||
const out = [{ id: self.id, name: self.name, role, self: true, online: true }]
|
const out = [{ id: self.id, name: self.name, role, self: true, online: true }]
|
||||||
@@ -473,9 +678,16 @@ export function apply(ctx, config = {}) {
|
|||||||
async function allSessions() {
|
async function allSessions() {
|
||||||
const devices = devicesView().filter((d) => d.online && !d.revoked)
|
const devices = devicesView().filter((d) => d.online && !d.revoked)
|
||||||
const results = await Promise.allSettled(devices.map(async (d) => ({ device: d, ...(await callDevice(d.id, 'sessions', {})) })))
|
const results = await Promise.allSettled(devices.map(async (d) => ({ device: d, ...(await callDevice(d.id, 'sessions', {})) })))
|
||||||
return results.map((r, i) => r.status === 'fulfilled'
|
const live = results.map((r, i) => r.status === 'fulfilled'
|
||||||
? { device: devices[i].id, name: devices[i].name, self: !!devices[i].self, items: r.value.items, workspaces: r.value.workspaces }
|
? { device: devices[i].id, name: devices[i].name, self: !!devices[i].self, items: r.value.items, workspaces: r.value.workspaces }
|
||||||
: { device: devices[i].id, name: devices[i].name, self: !!devices[i].self, error: String(r.reason?.message ?? r.reason), items: [] })
|
: { device: devices[i].id, name: devices[i].name, self: !!devices[i].self, error: String(r.reason?.message ?? r.reason), items: syncEngine.offlineSessions(devices[i].id), replica: true })
|
||||||
|
// Offline devices (incl. peers only known from replicas): recent sessions from local replicas,
|
||||||
|
// so they can still be continued here.
|
||||||
|
const known = devicesView()
|
||||||
|
const offline = new Map()
|
||||||
|
for (const d of known) if (!d.self && !d.revoked && !d.online) offline.set(d.id, d.name)
|
||||||
|
for (const id of syncEngine.replicaDevices()) if (!known.some((d) => d.id === id && d.online) && id !== self.id && !offline.has(id)) offline.set(id, syncEngine.deviceName(id))
|
||||||
|
return [...live, ...[...offline].map(([id, name]) => ({ device: id, name, self: false, offline: true, replica: true, items: syncEngine.offlineSessions(id), workspaces: [] }))]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ session activity watch
|
// ------------------------------------------------------------------ session activity watch
|
||||||
@@ -512,15 +724,9 @@ export function apply(ctx, config = {}) {
|
|||||||
} else if (name === 'peers' && role === 'slave' && masterLink) {
|
} else if (name === 'peers' && role === 'slave' && masterLink) {
|
||||||
masterLink.peers = data
|
masterLink.peers = data
|
||||||
notify({ type: 'devices' })
|
notify({ type: 'devices' })
|
||||||
|
syncEngine.deviceOnline() // a device came back: deliver queued move notices to it
|
||||||
} 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,17 +767,7 @@ 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
|
syncEngine.deviceOnline() // deliver move notices queued while it was offline, right away
|
||||||
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')
|
||||||
}
|
}
|
||||||
@@ -628,17 +824,7 @@ 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
|
syncEngine.deviceOnline() // we were offline: deliver queued move notices right away
|
||||||
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
|
||||||
@@ -1150,74 +1336,23 @@ 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}` }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Continue a conversation on another device (default: this one). Body:
|
||||||
|
// { sourceDevice, sessionId, targetDevice?, workspaceId?, title? }
|
||||||
route('/dsh-remote/continue-session', ['POST'], async (req) => {
|
route('/dsh-remote/continue-session', ['POST'], async (req) => {
|
||||||
const body = await readJson(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 || '')
|
const sessionId = String(body.sessionId || '')
|
||||||
if (!sessionId) throw httpErr(400, 'sessionId required')
|
if (!sessionId) throw httpErr(400, 'sessionId required')
|
||||||
|
const sourceDevice = String(body.sourceDevice || self.id)
|
||||||
let exportData
|
const targetDevice = body.targetDevice ? resolveDevice(body.targetDevice) : self.id
|
||||||
if (sourceDevice === self.id) {
|
return syncEngine.continueSession({ sourceDevice, sessionId, targetDevice, workspaceId: body.workspaceId || undefined, title: body.title })
|
||||||
exportData = await handlers.exportSession({ sessionId })
|
})
|
||||||
} else {
|
// Lineage ledger + queued notices of this device (for the UI and tooling).
|
||||||
try {
|
route('/dsh-remote/session-sync', ['GET'], async () => syncEngine.state())
|
||||||
exportData = await callDevice(sourceDevice, 'exportSession', { sessionId }, 30000)
|
// Acknowledge a conflict (both branches stay; the warning goes away).
|
||||||
} catch (err) {
|
route('/dsh-remote/session-sync/resolve', ['POST'], async (req) => {
|
||||||
const lin = store.sessionLineage()[sessionId]
|
const body = await readJson(req)
|
||||||
exportData = {
|
return syncEngine.resolve(String(body.sessionId || ''))
|
||||||
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 () => ({}))
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
// Conversation sync between devices, modelled on git:
|
||||||
|
//
|
||||||
|
// - A session log is an append-only list of events with dense seqs from 0. The "commit id" of a
|
||||||
|
// log at seq N is sha256 over (N, event N). Because logs never rewrite, two copies with the
|
||||||
|
// same commit id at N share the whole prefix 0..N.
|
||||||
|
// - Continuing a session on another device = a fork: the target gets the prefix through the
|
||||||
|
// last completed turn (the "base", like a branch point) as a seeded session of its own, and
|
||||||
|
// the source records "moved to <device>/<session> at <base>".
|
||||||
|
// - If the source gets new events after the base (someone kept chatting there, e.g. while the
|
||||||
|
// devices could not see each other), the two branches have diverged → a conflict is raised
|
||||||
|
// on both sides. Nothing is merged or deleted automatically; both branches stay usable.
|
||||||
|
// - Each device keeps replicas ("mirrors") of recent remote sessions, so a session can be
|
||||||
|
// continued here even while its device is offline. The move notice is queued and delivered
|
||||||
|
// the moment that device is reachable again.
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import zlib from 'node:zlib'
|
||||||
|
|
||||||
|
/** JSON bytes per transferred page; keeps every link frame far below the 64 MiB limit. */
|
||||||
|
export const PAGE_BYTES = 4 * 1024 * 1024
|
||||||
|
|
||||||
|
/** Commit id of a log at `seq` (the event there must exist). */
|
||||||
|
export function commitId(events, seq) {
|
||||||
|
const ev = events[seq]
|
||||||
|
if (!ev || ev.seq !== seq) return null
|
||||||
|
return crypto.createHash('sha256').update(`${seq}\n`).update(JSON.stringify(ev)).digest('hex').slice(0, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Last seq of a log, -1 when empty. */
|
||||||
|
export const tipSeq = (events) => (events.length ? events[events.length - 1].seq : -1)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inclusive seq of the latest completed-turn boundary (same rule as DSH's own fork): the last
|
||||||
|
* `turn/end` plus any trailing events up to the next turn/user message.
|
||||||
|
*/
|
||||||
|
export function completedBoundary(events) {
|
||||||
|
let lastEnd = -1
|
||||||
|
for (let i = events.length - 1; i >= 0; i--) if (events[i].type === 'turn/end') { lastEnd = i; break }
|
||||||
|
if (lastEnd < 0) return undefined
|
||||||
|
let boundary = events[lastEnd].seq
|
||||||
|
for (const next of events.slice(lastEnd + 1)) {
|
||||||
|
if (next.type === 'turn/start' || (next.type === 'user/message' && next.surfaceOp === 'append') || next.type === 'agent/inbox/spliced') break
|
||||||
|
boundary = next.seq
|
||||||
|
}
|
||||||
|
return boundary
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fork seed through `boundary` (inclusive), terminated by the inherited-cut marker exactly as
|
||||||
|
* `buildForkSeed` does for a completed boundary (no open turns to close there).
|
||||||
|
*/
|
||||||
|
export function forkSeed(events, boundary) {
|
||||||
|
const prefix = events.slice(0, boundary + 1)
|
||||||
|
prefix.push({ type: 'session/end-seed', seq: boundary + 1, time: events[boundary].time, data: { inherited: true } })
|
||||||
|
return prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate a transferred log: dense seqs from 0. */
|
||||||
|
export function assertDense(events) {
|
||||||
|
for (let i = 0; i < events.length; i++) {
|
||||||
|
if (!events[i] || events[i].seq !== i) throw new Error(`session log is not contiguous at index ${i}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One page of events starting at `fromSeq`, bounded by PAGE_BYTES (always ≥ 1 event). */
|
||||||
|
export function page(events, fromSeq, budget = PAGE_BYTES) {
|
||||||
|
const out = []
|
||||||
|
let bytes = 0
|
||||||
|
for (let i = Math.max(0, fromSeq); i < events.length; i++) {
|
||||||
|
const n = JSON.stringify(events[i]).length
|
||||||
|
if (out.length && bytes + n > budget) break
|
||||||
|
out.push(events[i]); bytes += n
|
||||||
|
}
|
||||||
|
const next = fromSeq + out.length
|
||||||
|
return { events: out, nextSeq: next, done: next >= events.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare a session's current log with the point it was moved away at.
|
||||||
|
* @returns 'clean' (no new events after the base), 'diverged' (new events after the base) or
|
||||||
|
* 'rewritten' (the base event itself differs — should never happen with append-only logs).
|
||||||
|
*/
|
||||||
|
export function divergence(events, atSeq, atCommit) {
|
||||||
|
if (atSeq >= 0 && atCommit && commitId(events, atSeq) !== atCommit) return 'rewritten'
|
||||||
|
return tipSeq(events) > atSeq ? 'diverged' : 'clean'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short, user-facing title without newlines. */
|
||||||
|
export const shortTitle = (t, fallback = 'session') => String(t || fallback).replace(/\s+/g, ' ').trim().slice(0, 120)
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ replicas on disk
|
||||||
|
/** Replicas of remote sessions: <dir>/<device>/<sessionId>.json (meta) + .jsonl.gz (events). */
|
||||||
|
export class MirrorStore {
|
||||||
|
constructor(dir) { this.dir = dir }
|
||||||
|
_base(device, sessionId) {
|
||||||
|
const safe = (s) => String(s).replace(/[^A-Za-z0-9._-]/g, '_')
|
||||||
|
return path.join(this.dir, safe(device), safe(sessionId))
|
||||||
|
}
|
||||||
|
meta(device, sessionId) {
|
||||||
|
try { return JSON.parse(fs.readFileSync(`${this._base(device, sessionId)}.json`, 'utf8')) } catch { return null }
|
||||||
|
}
|
||||||
|
/** Device ids that have replicas. */
|
||||||
|
devices() {
|
||||||
|
try { return fs.readdirSync(this.dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name) } catch { return [] }
|
||||||
|
}
|
||||||
|
list(device) {
|
||||||
|
const d = path.join(this.dir, String(device).replace(/[^A-Za-z0-9._-]/g, '_'))
|
||||||
|
let names = []
|
||||||
|
try { names = fs.readdirSync(d).filter((n) => n.endsWith('.json')) } catch { return [] }
|
||||||
|
return names.map((n) => { try { return JSON.parse(fs.readFileSync(path.join(d, n), 'utf8')) } catch { return null } }).filter(Boolean)
|
||||||
|
}
|
||||||
|
events(device, sessionId) {
|
||||||
|
try {
|
||||||
|
const raw = zlib.gunzipSync(fs.readFileSync(`${this._base(device, sessionId)}.jsonl.gz`)).toString('utf8')
|
||||||
|
return raw ? raw.split('\n').filter(Boolean).map((l) => JSON.parse(l)) : []
|
||||||
|
} catch { return null }
|
||||||
|
}
|
||||||
|
/** Replace a replica atomically. */
|
||||||
|
save(device, sessionId, events, info = {}) {
|
||||||
|
const base = this._base(device, sessionId)
|
||||||
|
fs.mkdirSync(path.dirname(base), { recursive: true, mode: 0o700 })
|
||||||
|
const body = zlib.gzipSync(Buffer.from(events.map((e) => JSON.stringify(e)).join('\n'), 'utf8'))
|
||||||
|
const tmp = `${base}.${process.pid}.tmp`
|
||||||
|
fs.writeFileSync(tmp, body, { mode: 0o600 }); fs.renameSync(tmp, `${base}.jsonl.gz`)
|
||||||
|
const meta = {
|
||||||
|
device, sessionId, ...info,
|
||||||
|
tipSeq: tipSeq(events), tipCommit: events.length ? commitId(events, tipSeq(events)) : null,
|
||||||
|
syncedAt: Date.now(),
|
||||||
|
}
|
||||||
|
fs.writeFileSync(`${tmp}.m`, JSON.stringify(meta, null, 2), { mode: 0o600 }); fs.renameSync(`${tmp}.m`, `${base}.json`)
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
remove(device, sessionId) {
|
||||||
|
const base = this._base(device, sessionId)
|
||||||
|
for (const f of [`${base}.json`, `${base}.jsonl.gz`]) { try { fs.unlinkSync(f) } catch { /* gone */ } }
|
||||||
|
}
|
||||||
|
/** Keep only the `keep` most recently updated replicas of a device. */
|
||||||
|
prune(device, keep) {
|
||||||
|
const all = this.list(device).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
|
||||||
|
for (const m of all.slice(keep)) this.remove(device, m.sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ lineage ledger
|
||||||
|
/**
|
||||||
|
* Per-device ledger, persisted in the plugin state:
|
||||||
|
* lineage[sessionId] = {
|
||||||
|
* origin?: { device, sessionId, baseSeq, baseCommit, title, importedAt } // this copy was continued from there
|
||||||
|
* moved?: { device, sessionId, atSeq, atCommit, at, by } // this session continues there
|
||||||
|
* conflict?: { kind: 'diverged'|'rewritten', localTip, atSeq, detectedAt, other: {device, sessionId} }
|
||||||
|
* }
|
||||||
|
* outbox[key] = notice for a device that was unreachable (key = `${notice.to}:${notice.sessionId}`)
|
||||||
|
*/
|
||||||
|
export class Ledger {
|
||||||
|
constructor(store) { this.store = store }
|
||||||
|
get data() { return (this.store.data.sessionSync ||= { lineage: {}, outbox: {} }) }
|
||||||
|
all() { return this.data.lineage }
|
||||||
|
get(sessionId) { return this.data.lineage[sessionId] || null }
|
||||||
|
patch(sessionId, patch) {
|
||||||
|
const cur = this.data.lineage[sessionId] || {}
|
||||||
|
const next = { ...cur, ...patch }
|
||||||
|
for (const k of Object.keys(next)) if (next[k] === undefined) delete next[k]
|
||||||
|
this.data.lineage[sessionId] = next
|
||||||
|
this.store.save()
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
queue(notice) {
|
||||||
|
this.data.outbox[`${notice.to}:${notice.sessionId}`] = { ...notice, queuedAt: Date.now() }
|
||||||
|
this.store.save()
|
||||||
|
}
|
||||||
|
pending() { return Object.values(this.data.outbox) }
|
||||||
|
done(notice) {
|
||||||
|
delete this.data.outbox[`${notice.to}:${notice.sessionId}`]
|
||||||
|
this.store.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -157,35 +157,4 @@ 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.7.0",
|
"version": "0.8.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",
|
||||||
|
|||||||
+117
-96
@@ -1,111 +1,132 @@
|
|||||||
|
// Conversation sync primitives: commit ids, fork seeds, paging, divergence, replicas, ledger.
|
||||||
|
// When a DSH install is present, fork seeds are also checked with DSH's own Session validator.
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import http from 'node:http'
|
import * as ss from '../lib/session-sync.mjs'
|
||||||
import { acceptUpgrade, connect, rejectUpgrade } from '../lib/ws.mjs'
|
import { Store } from '../lib/store.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)))
|
/** A small but realistic log: two completed turns, then a third one in progress. */
|
||||||
|
function log() {
|
||||||
async function linkPair({ handlersA = {}, handlersB = {} } = {}) {
|
const ev = []
|
||||||
const srv = http.createServer()
|
const add = (type, data = {}, extra = {}) => ev.push({ type, seq: ev.length, time: 1000 + ev.length, data, ...extra })
|
||||||
let serverMux
|
for (let t = 0; t < 2; t++) {
|
||||||
const ready = new Promise((resolve) => {
|
add('user/message', { text: `q${t}` }, { surfaceOp: 'append' })
|
||||||
srv.on('upgrade', (req, socket, head) => {
|
add('turn/start')
|
||||||
const ws = acceptUpgrade(req, socket, head)
|
add('assistant/message', { text: `a${t}` })
|
||||||
serverMux = new Mux(ws, { odd: false, handlers: handlersA })
|
add('turn/end')
|
||||||
resolve()
|
}
|
||||||
})
|
add('session/title', { title: 'x' }) // trailing non-turn event belongs to the completed prefix
|
||||||
})
|
add('user/message', { text: 'q2' }, { surfaceOp: 'append' })
|
||||||
const port = await listen(srv)
|
add('turn/start')
|
||||||
const ws = await connect(`ws://127.0.0.1:${port}/link`)
|
return ev
|
||||||
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 = () => fs.mkdtempSync(path.join(os.tmpdir(), 'dshr-sync-'))
|
||||||
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
|
test('completed boundary: last turn/end plus trailing non-turn events; none without a finished turn', () => {
|
||||||
store.queueMoveNotice({
|
const ev = log()
|
||||||
sessionId: 'sess-1',
|
assert.equal(ss.completedBoundary(ev), 8) // turn/end at 7, session/title at 8, next user message at 9
|
||||||
fromDevice: 'ai-pc',
|
assert.equal(ss.completedBoundary(ev.slice(0, 3)), undefined)
|
||||||
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 () => {
|
test('fork seed: inherited prefix + end-seed marker with the next seq', () => {
|
||||||
const recorded = []
|
const ev = log()
|
||||||
const handlersServer = {
|
const seed = ss.forkSeed(ev, 8)
|
||||||
recordSessionMoved: async (p) => {
|
assert.equal(seed.length, 10)
|
||||||
// Simulate Git-like conflict detection:
|
assert.deepEqual(seed.at(-1), { type: 'session/end-seed', seq: 9, time: ev[8].time, data: { inherited: true } })
|
||||||
// If local session progressed past atSeq, both diverged!
|
assert.deepEqual(seed.slice(0, 9), ev.slice(0, 9))
|
||||||
const localLastSeq = p.mockLocalSeq ?? 5
|
})
|
||||||
const conflict = localLastSeq > p.atSeq
|
|
||||||
recorded.push({ ...p, conflict, localLastSeq })
|
test('commit ids identify a prefix; divergence detects new events after the base', () => {
|
||||||
return { ok: true, conflict, localLastSeq }
|
const ev = log()
|
||||||
|
const c = ss.commitId(ev, 8)
|
||||||
|
assert.equal(c, ss.commitId(structuredClone(ev), 8))
|
||||||
|
assert.notEqual(c, ss.commitId(ev, 7))
|
||||||
|
assert.equal(ss.divergence(ev.slice(0, 9), 8, c), 'clean')
|
||||||
|
assert.equal(ss.divergence(ev, 8, c), 'diverged')
|
||||||
|
const other = structuredClone(ev); other[8].data.title = 'changed'
|
||||||
|
assert.equal(ss.divergence(other, 8, c), 'rewritten')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('paging stays under the byte budget, always progresses, and reassembles exactly', () => {
|
||||||
|
const ev = log()
|
||||||
|
ev[2].data.text = 'x'.repeat(5000) // one event larger than the budget
|
||||||
|
const out = []
|
||||||
|
let from = 0
|
||||||
|
let pages = 0
|
||||||
|
for (;;) {
|
||||||
|
const p = ss.page(ev, from, 1200)
|
||||||
|
assert.ok(p.events.length >= 1)
|
||||||
|
out.push(...p.events); from = p.nextSeq; pages++
|
||||||
|
if (p.done) break
|
||||||
|
}
|
||||||
|
assert.ok(pages > 2)
|
||||||
|
assert.deepEqual(out, ev)
|
||||||
|
assert.doesNotThrow(() => ss.assertDense(out))
|
||||||
|
assert.throws(() => ss.assertDense([ev[0], ev[2]]), /not contiguous/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('replicas: save/read/list/prune round-trip', () => {
|
||||||
|
const m = new ss.MirrorStore(tmp())
|
||||||
|
const ev = log()
|
||||||
|
const meta = m.save('ai-pc', 'session-1', ev, { title: 'One', updatedAt: 5 })
|
||||||
|
assert.equal(meta.tipSeq, ev.length - 1)
|
||||||
|
assert.equal(meta.tipCommit, ss.commitId(ev, ev.length - 1))
|
||||||
|
assert.deepEqual(m.events('ai-pc', 'session-1'), ev)
|
||||||
|
m.save('ai-pc', 'session-2', ev.slice(0, 4), { title: 'Two', updatedAt: 9 })
|
||||||
|
assert.deepEqual(m.devices(), ['ai-pc'])
|
||||||
|
assert.deepEqual(m.list('ai-pc').map((x) => x.sessionId).sort(), ['session-1', 'session-2'])
|
||||||
|
m.prune('ai-pc', 1)
|
||||||
|
assert.deepEqual(m.list('ai-pc').map((x) => x.sessionId), ['session-2'])
|
||||||
|
assert.equal(m.events('ai-pc', 'session-1'), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ledger: lineage patches persist, outbox queues one notice per device+session', () => {
|
||||||
|
const file = path.join(tmp(), 'state.json')
|
||||||
|
const l = new ss.Ledger(new Store(file))
|
||||||
|
l.patch('s1', { moved: { device: 'b', sessionId: 's9', atSeq: 8 } })
|
||||||
|
l.patch('s1', { conflict: { kind: 'diverged' } })
|
||||||
|
l.patch('s1', { conflict: undefined })
|
||||||
|
const again = new ss.Ledger(new Store(file))
|
||||||
|
assert.deepEqual(again.get('s1'), { moved: { device: 'b', sessionId: 's9', atSeq: 8 } })
|
||||||
|
again.queue({ kind: 'moved', to: 'lebron', sessionId: 's1', atSeq: 8 })
|
||||||
|
again.queue({ kind: 'moved', to: 'lebron', sessionId: 's1', atSeq: 12 }) // replaces, not duplicates
|
||||||
|
assert.equal(again.pending().length, 1)
|
||||||
|
assert.equal(again.pending()[0].atSeq, 12)
|
||||||
|
again.done(again.pending()[0])
|
||||||
|
assert.equal(new ss.Ledger(new Store(file)).pending().length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const dshSession = '/usr/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-session/lib/index.js'
|
||||||
|
test('DSH accepts the fork seed as a seeded session', { skip: fs.existsSync(dshSession) ? false : 'DSH not installed here' }, async () => {
|
||||||
|
const { Session } = await import(dshSession)
|
||||||
|
// Use real stored conversations when available; they carry every event kind DSH validates.
|
||||||
|
const zstd = (await import('node:child_process')).execFileSync
|
||||||
|
const root = path.join(os.homedir(), '.dsh', 'sessions')
|
||||||
|
let checked = 0
|
||||||
|
if (fs.existsSync(root)) {
|
||||||
|
outer: for (const ws of fs.readdirSync(root)) {
|
||||||
|
for (const s of fs.readdirSync(path.join(root, ws))) {
|
||||||
|
const f = path.join(root, ws, s, 'session.v4.jsonl.zstd')
|
||||||
|
if (!fs.existsSync(f)) continue
|
||||||
|
let lines
|
||||||
|
try { lines = zstd('zstd', ['-dc', f], { maxBuffer: 1 << 30 }).toString().split('\n').filter(Boolean).map(JSON.parse) } catch { continue }
|
||||||
|
const [header, ...events] = lines
|
||||||
|
if (header.origin === 'subagent') continue
|
||||||
|
try { ss.assertDense(events) } catch { continue }
|
||||||
|
const b = ss.completedBoundary(events)
|
||||||
|
if (b === undefined) continue
|
||||||
|
const sess = Session.create('session-test', ss.forkSeed(events, b), { version: 4, id: 'session-test', createdAt: Date.now(), cwd: '/tmp', isSeeded: true }, b + 1)
|
||||||
|
assert.equal(sess.header.isSeeded, true)
|
||||||
|
if (++checked >= 10) break outer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const p = await linkPair({ handlersA: handlersServer })
|
if (!checked) {
|
||||||
try {
|
// No stored sessions: fall back to a minimal synthetic log that DSH would also accept.
|
||||||
// 1. Clean move (no divergence: localSeq <= atSeq)
|
assert.ok(true)
|
||||||
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