Keep local Desktop Code history in sync with an inactive account.

Windows plugin for Claude Code Mods. Once loaded, a History sync menu button appears in the enabled Code-session locations. Click it, choose an inactive destination in Accounts, then open Sync to preview, copy once, or start automatic syncing. Copying is always from this session's account into the destination. The source's Desktop metadata is never changed. Archive indexes and cards in the destination get verified backups.
The mod matches the current session ID to local Desktop history cards. A unique match supplies the source; check the displayed account before syncing. If the session appears in several profiles (including previously copied sessions), or has no Desktop card yet, click Sync settings and confirm which account is open. That confirmation lasts only for this session. No global CLI login or saved source choice is treated as Desktop's current account. A new session card is discovered after a turn or on the next 30-second check.
Auto-sync checks saved files every 30 seconds and 1.5 seconds after a main-agent turn completes. It uses PowerShell, without model calls. It starts paused in each session and after hot reload. Pause before switching accounts. Choosing another destination waits for any running copy and pauses automation. It does not run at shutdown. The destination and Details preference are remembered, along with the last successful result and eight recent backup receipts. Source confirmation and automatic-sync permission stay session-local. Selecting another destination clears its preview but keeps previous receipts with their original account direction.
Version 0.4.0 defaults to Compare all three locations: an above-prompt button, a SessionMode-area button, and a PromptHint-area button, labeled Prompt, Mode, and Hint. A site appears only when the host asks the mod to render it; native fixture tests validate all three, but do not prove this Desktop build paints each one. Accounts → Launcher placement keeps one location or restores comparison. The existing contents of each site are preserved.
By default, only a menu button appears at each enabled site. Account details, results, and errors stay inside the pane; Pause also appears beside the button while automation is running. The above-prompt launcher is omitted in subagent transcript panels. Under Accounts → Prompt controls, choose Menu button only, Status and quick controls, or Hide. Hiding it leaves /history-sync available. This preference is remembered without starting or stopping a sync.
Three additional DOM launchers provide a sidebar entry, header menu, and floating button. They open this same pane by activating its existing native button; they do not add another sync engine. Their temporary loader supports a Store-build comparison through a loopback DevTools session instead of patching installed files. Live injection needs a Desktop restart; see the instructions and current verification status there.
The optional status band shows source → destination and the last sync time for that direction, with Sync now, Sync settings, and collapsible Details. The official API does not offer a global Desktop toolbar slot; the DOM experiment adds its own controls to the page. Claude controls where the band and pane are placed.
The menu has three sections:
Version 0.5.1 includes local conversations that spawned cloud tasks, provided their local transcript exists. New destination cards omit the source's cloud task links; existing cards keep their own links. Account-bound environment scopes remain excluded.
Version 0.5.0 replaces the filename-heavy preview with a table showing the real session title, destination state before, destination state after, and change. Renames show the old title as well. Unchanged sessions are included; skipped and conflicting sessions show the destination remaining unchanged. A session without a saved title is labeled Untitled session. File IDs are available under the row's Details button and in copied diagnostic data.
Filtering, searching, paging, and copying do not run another plan, copy history, or enable automatic sync. Comparisons use the original plan snapshot; constructing a proposed update no longer mutates the destination snapshot. Up to 1,000 comparison rows are returned, with an explicit notice if a larger inventory is truncated. Use Preview again to refresh the snapshot.
Version 0.4.1 adds Copy diagnostics, Copy profile list, Copy preview, and Copy backup path. These copy through the clipboard API for the surface where the button was pressed. Diagnostics retain the complete error; Copy preview includes the held report beyond the panel's text display limit. A clipboard failure shows feedback without changing sync permission or starting a helper. Native Text elements do not expose a selection property, so these buttons provide copying even when Desktop does not allow selecting the pane's text with the mouse.
No slash command is required. The interactive preview uses fixture accounts and simulated operations only. Claude controls the actual styling and pane placement; this preview does not demonstrate host compatibility.
For first-time persistent installation in Desktop, open PowerShell and run:
Set-Location C:\path\to\ClaudeSync
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\Build-Mod.ps1
claude plugin marketplace add C:\path\to\ClaudeSync
claude plugin install account-history-sync@account-history-tools --scope user
For an existing installation, update it instead:
claude plugin marketplace update account-history-tools
claude plugin update account-history-sync@account-history-tools --scope user
Installation can be completed even while the rollout blocks execution. Then:
/reload-plugins, or start a fresh Code session. In /plugin, verify account-history-sync is installed and enabled./history-sync is the fallback.If the controls are missing and /history-sync is unknown, check the rollout from PowerShell:
claude plugin test C:\path\to\ClaudeSync\mods\account-history-sync
For a temporary CLI session instead of persistent installation:
claude --plugin-dir C:\path\to\ClaudeSync\mods\account-history-sync
The menu button appears above Claude's prompt. /history-sync also opens its settings pane. Desktop WSL sessions do not support Mods according to the current docs.
If the plugin is enabled but both its UI and /history-sync are missing, check the session's environment first. Desktop can list the Windows-installed plugin while the selected Code session runs in WSL, where plugins are unavailable. Opening a native Windows Code session in the same signed-in account provides the controller. Keep the other account inactive and confirm the displayed source before copying. The controller can copy eligible WSL history cards from the Windows side; WSL execution itself does not host this Mod.
For a read-only profile query from PowerShell:
Set-Location C:\path\to\ClaudeSync
.\Sync-ClaudeHistory.ps1 -Mode Profiles
It lists full profile IDs, known email labels, their sources, and session counts. Use -ConfigPath "$env:LOCALAPPDATA\ClaudeHistorySync\profiles.json" to query the Mod's configured roots instead. Labels identify folders; this query does not authenticate which Desktop account is currently open.
Anthropic documents engine 2.1.287 or later as the Mods baseline. However, Desktop 2.19675.0, bundling 2.1.286, was observed rendering this mod and passed all three native fixture tests, including Desktop-surface menu interactions. Observed loading is stronger evidence than assuming a build cannot load solely from its version. The terminal CLI's rollout result is separate: CLI 2.1.288 still reported a cached disabled switch while Desktop's engine ran these tests. No real account history sync or full account-switch test was performed.
For a missing UI, check that Desktop's session loaded the plugin, and run tests with its actual worker executable. A terminal claude --version or test result does not establish Desktop's engine or rollout state. Updating the plugin does not update Desktop's Code engine. See the Desktop UI and loading review for evidence and ideas from the reference UI project.
/history-sync opens the settings pane (optional unless the launcher is hidden)./history-sync accounts opens account selection./history-sync backups opens recent backup receipts./history-sync profiles lists emails, IDs, and session counts./history-sync to <destination-email-or-id> selects the inactive destination./history-sync preview checks without writing Desktop history./history-sync sync copies once, declaring the source active and destination inactive./history-sync start enables automatic copying for this session with the same declaration./history-sync pause waits for the current operation and releases the controller./history-sync status prints status.Ambiguous emails require full account-GUID\organization-GUID profile IDs. CLI commands accept selectors without spaces. A compatibility pair <source> <destination> command can confirm an unresolved source, but cannot reverse a source already identified in this session. The destination is remembered; permission to auto-sync is not remembered. Multiple sessions share an exclusive controller lease. Pause the controller in its original session to switch control; after a crashed or ended session, its lease expires within three minutes.
The helper initializes %LOCALAPPDATA%\ClaudeHistorySync\profiles.json with automatic Desktop-root discovery. If discovery cannot choose a root, set SessionsRoot to the absolute history directory in that config, after checking which directory the running Desktop uses. Save a config backup before changing it. For this Store installation, that root was verified under %LOCALAPPDATA%\Packages\Claude_pzs8sxrjxfjjc\LocalCache\Roaming\Claude\claude-code-sessions. Discovery creates that private config but does not edit history. Put profile-labels.json alongside it for manual email labels. Settings, state, and backups live outside the plugin install/cache folder.
Desktop can save a correctly named card before its CLI session starts. A missing, null, or empty CLI session ID now marks that card Pending in inventory and Skipped in the plan. The card and its archive state stay untouched, including when it is in the destination. Incorrect nonempty CLI IDs, filename/session-ID mismatches, missing working directories, malformed JSON, and invalid archive flags still stop the plan. Preview returns up to 1,000 report entries and explicitly reports truncation beyond that limit.
For custom roots or existing labels, set ACCOUNT_HISTORY_SYNC_CONFIG to the full path of a profiles.json before starting Claude, or through Claude's environment settings. The script uses that config's directory for state and the mod controller. It must support private Windows ACLs. Configuration and labels use the same format as the standalone script. No credentials or tokens are copied into the plugin.
The Backups section shows up to eight recent receipts, and automatic failures pause syncing. Use the standalone engine's Undo with that config and printed backup, after closing Claude. Undo is deliberately not automated by the mod:
.\Sync-ClaudeHistory.ps1 -Mode Undo -ConfigPath "$env:LOCALAPPDATA\ClaudeHistorySync\profiles.json" -Backup 'C:\printed\backup\path' -WhatIf
After reviewing, remove -WhatIf to restore. A timed-out helper can leave a prepared receipt under state\pairs\PAIR_HASH\backups; inspect that before retrying. Backups include conversation summaries and should stay private.
Mods' published API does not provide a current account ID. Matching a session's local card is account-folder discovery, not authentication; check the source shown in the UI. If ambiguous, the mod relies on your session-local confirmation. Before preview, arming, or sync, the helper rejects a source outside the session's matching profiles, when matches exist. It cannot verify that the destination is inactive. File hashes and atomic replacement reduce write races but cannot prevent Desktop from later writing cached metadata. It syncs local history cards and environment references, not cloud session ownership, credentials, or transcripts. See the repository README for eligibility and policies.
node --test .\mods\account-history-sync\tests\controller.test.mjs
.\Test-ModBridge.ps1
claude plugin test .\mods\account-history-sync
Node is needed only for the independent controller tests, not to run the mod. PowerShell bridge tests use isolated local fixtures and never modify real history. Claude's native test runner requires Mods to load in the executable running the tests. Rebuild the fixture preview after changing the UI with node .\tools\Build-UiPreview.mjs. The DOM fixture suite runs separately with npm ci --ignore-scripts and npm test under experiments/dom-launchers; those packages are not shipped in the native plugin.
hooks/register.mjs 118 lines1import { createController } from './controller.mjs';
2import { renderBand, renderPane, renderLauncher, launcherVisible } from './ui.mjs';
3
4const PANE = 'history-sync';
5const HELP = 'Open History sync above the prompt or use /history-sync. Optional commands: accounts, backups, profiles, to <inactive-destination-email-or-id>, preview, sync, start, pause, status. Pause before switching accounts.';
6
7export function register(on) {
8 const controller = createController();
9 on('session.start', async ($, e, next) => {
10 const adapter = {
11 root: $.plugin.root,
12 windows: (await $.env.get('OS')) === 'Windows_NT',
13 processRun: (argv, options) => $.process.run(argv, options),
14 redraw: () => $.ui.invalidate('ui.render'),
15 log: text => $.ui.log(text),
16 now: () => $.clock.now(),
17 sessionId: () => $.session.id(),
18 envGet: () => $.env.get('ACCOUNT_HISTORY_SYNC_CONFIG'),
19 storeGet: key => $.store.get(key),
20 storeSet: (key, value) => $.store.set(key, value),
21 every: (ms, fn) => $.clock.every(ms, fn),
22 after: (ms, fn) => $.clock.after(ms, fn),
23 };
24 await controller.initialize(adapter);
25 await $.command.register({ name: 'history-sync', description: 'Manage local account history sync', argumentHint: '[accounts|backups|to|preview|sync|start|pause|status]', immediate: true });
26 return next(e);
27 });
28 on('session.end', async ($, e, next) => {
29 controller.stop();
30 // No last-minute writes on logout, resume, clear, or shutdown.
31 return next(e);
32 });
33 on('turn.complete', async ($, e, next) => {
34 const result = await next(e);
35 if (!e.agentId) { await controller.reopen(); controller.afterTurn(); }
36 return result;
37 });
38 on('command.run', { command: 'history-sync' }, async ($, e) => {
39 const args = (e.args || '').trim().split(/\s+/).filter(Boolean);
40 try {
41 await controller.reopen();
42 switch (args[0] || '') {
43 case '': case 'accounts': case 'backups':
44 if (args[0]) controller.showSection(args[0]);
45 if (!controller.view.source || !controller.view.target) controller.showSection(args[0] === 'backups' ? 'backups' : 'accounts');
46 await $.ui.open({ id: PANE, title: 'History sync', focus: true, closeOnEscape: true });
47 await controller.refresh();
48 return {};
49 case 'profiles': {
50 await controller.refresh();
51 return { text: controller.view.profiles.map(p => `${p.Email || p.Label} | ${p.Id} | ${p.Sessions} sessions`).join('\n') || controller.statusText() };
52 }
53 case 'to':
54 if (args.length !== 2) return { text: HELP };
55 await controller.refresh();
56 await controller.choose(controller.view.source, controller.resolveSelector(args[1]));
57 return { text: controller.statusText() };
58 case 'pair': // Compatibility: cannot replace an already identified current source.
59 if (args.length !== 3) return { text: HELP };
60 await controller.refresh();
61 await controller.choose(controller.resolveSelector(args[1]), controller.resolveSelector(args[2]));
62 return { text: controller.statusText() };
63 case 'preview': await controller.preview(); return { text: controller.statusText() };
64 case 'sync': await controller.sync(); return { text: controller.statusText() };
65 case 'start': await controller.start(); return { text: controller.statusText() };
66 case 'pause': await controller.pause(); return { text: controller.statusText() };
67 case 'status': return { text: controller.statusText() };
68 default: return { text: HELP };
69 }
70 } catch (error) { return { text: error.message }; }
71 });
72 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
73 if (e.requestId !== PANE) return next(e);
74 const { Box, Text, Button, Select, Input } = $.ui.resolve(e);
75 return renderPane({ Box, Text, Button, Select, Input }, controller,
76 (text, surface) => $.ui.copy({ text, surface: surface || e.surface }));
77 });
78 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
79 const existing = await next(e);
80 if (e.props?.hasSurvey || e.props?.view?.agentId || !launcherVisible(controller.view, 'band')) return existing;
81 const { Box, Text, Button } = $.ui.resolve(e);
82 return Box({ flexDirection: 'column', children: [existing,
83 renderBand({ Box, Text, Button }, controller, async () => {
84 await controller.reopen();
85 if (!controller.view.source || !controller.view.target) controller.showSection('accounts');
86 await $.ui.open({ id: PANE, title: 'History sync', focus: true, closeOnEscape: true });
87 await controller.refresh();
88 }),
89 ] });
90 });
91 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
92 const existing = await next(e);
93 if (!launcherVisible(controller.view, 'mode')) return existing;
94 const { Box, Button } = $.ui.resolve(e);
95 return Box({ flexDirection: 'row', flexWrap: 'wrap', gap: 1, children: [existing,
96 renderLauncher({ Box, Button }, controller, async () => {
97 await controller.reopen();
98 if (!controller.view.source || !controller.view.target) controller.showSection('accounts');
99 await $.ui.open({ id: PANE, title: 'History sync', focus: true, closeOnEscape: true });
100 await controller.refresh();
101 }, 'mode'),
102 ] });
103 });
104 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
105 const existing = await next(e);
106 if (!launcherVisible(controller.view, 'hint')) return existing;
107 const { Box, Button } = $.ui.resolve(e);
108 return Box({ flexDirection: 'row', flexWrap: 'wrap', gap: 1, children: [existing,
109 renderLauncher({ Box, Button }, controller, async () => {
110 await controller.reopen();
111 if (!controller.view.source || !controller.view.target) controller.showSection('accounts');
112 await $.ui.open({ id: PANE, title: 'History sync', focus: true, closeOnEscape: true });
113 await controller.refresh();
114 }, 'hint'),
115 ] });
116 });
117}
118hooks/controller.mjs 274 lines1const PROFILE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2
3export function describeResult(result) {
4 const mode = result.Action === 'Plan' ? 'Preview' : 'Synced';
5 return `${mode}: ${result.Additions ?? 0} added, ${result.Updates ?? 0} updated, ${result.Conflicts ?? 0} conflicts, ${result.Skipped ?? 0} skipped.`;
6}
7
8export function createController() {
9 const view = {
10 profiles: [], source: '', sourceCandidates: [], target: '', enabled: false, busy: false,
11 status: 'Finding the account for this session…', error: '',
12 backup: '', report: [], previewRows: [], previewFilter: 'all', previewChanges: 'all', previewSearch: '', previewPage: 0,
13 selectedPreview: '', configPath: '', lastSync: '', copyStatus: '',
14 section: 'accounts', expanded: false, promptDisplay: 'launcher', launcherPlacement: 'all', preview: null, lastResult: null, receipts: [], notice: '',
15 };
16 let api;
17 let owner = '';
18 let configOverride = '';
19 let running = null;
20 let ended = false;
21 let interval = null;
22 let debounce = null;
23 let revision = 0;
24
25 function redraw() { api.redraw('ui.render'); }
26 async function remember(key, value) {
27 try { await api.storeSet(key, value); }
28 catch { view.notice = 'Could not remember preferences or recent results. Any backup already created remains on disk.'; redraw(); }
29 }
30 function showSection(section) {
31 if (!['accounts', 'sync', 'backups'].includes(section)) return;
32 view.section = section; redraw();
33 }
34 function filterPreview(value) {
35 if (!['all', 'active', 'archived'].includes(value)) return;
36 view.previewFilter = value; view.previewPage = 0; redraw();
37 }
38 function filterPreviewChanges(value) {
39 if (!['all', 'changes', 'issues'].includes(value)) return;
40 view.previewChanges = value; view.previewPage = 0; redraw();
41 }
42 function searchPreview(value) { view.previewSearch = String(value).slice(0, 200); view.previewPage = 0; redraw(); }
43 function pagePreview(value) { if (Number.isInteger(value) && value >= 0) { view.previewPage = value; redraw(); } }
44 function selectPreview(file) { if (view.previewRows.some(r => r.File === file)) { view.selectedPreview = view.selectedPreview === file ? '' : file; redraw(); } }
45 function clearPreview() { view.report = []; view.previewRows = []; view.preview = null; view.previewPage = 0; view.selectedPreview = ''; }
46 async function copyText(text, writer, surface) {
47 try {
48 const result = await writer(text, surface);
49 view.copyStatus = result?.isCopied ? 'Copied to clipboard.' : `Could not copy to clipboard (${result?.reason || 'unavailable'}).`;
50 } catch (error) { view.copyStatus = `Copy failed: ${error.message}`; }
51 redraw();
52 }
53 async function toggleDetails() {
54 view.expanded = !view.expanded; redraw();
55 await remember('expanded', view.expanded);
56 }
57 async function choosePromptDisplay(value) {
58 if (!['launcher', 'compact', 'hidden'].includes(value)) return;
59 view.promptDisplay = value; redraw();
60 await remember('promptDisplay', value);
61 }
62 async function chooseLauncherPlacement(value) {
63 if (!['all', 'band', 'mode', 'hint'].includes(value)) return;
64 view.launcherPlacement = value; redraw();
65 await remember('launcherPlacement', value);
66 }
67 async function rememberBackup(result, pair) {
68 const receipt = { backup: result.Backup, source: pair?.source || '', target: pair?.target || '',
69 at: new Date(await api.now()).toISOString(), status: result.Ok ? 'complete' : 'prepared',
70 message: result.Ok ? describeResult(result) : result.Error || 'Sync interrupted; inspect this backup before retrying.' };
71 view.receipts = [receipt, ...view.receipts.filter(r => r.backup !== receipt.backup)].slice(0, 8);
72 await remember('receipts', view.receipts);
73 }
74 function selectedPair() {
75 if (!PROFILE_ID.test(view.source)) throw new Error('Confirm which account is open in Sync settings first.');
76 if (!PROFILE_ID.test(view.target)) throw new Error('Choose an inactive destination account first.');
77 if (view.source === view.target) throw new Error('Source and destination must be different.');
78 if (!view.profiles.some(p => p.Id === view.source) || !view.profiles.some(p => p.Id === view.target)) {
79 throw new Error('A selected profile is no longer available. Refresh profiles.');
80 }
81 return { source: view.source, target: view.target };
82 }
83 async function bridge(action, pair, keepLease = false) {
84 if (api.windows === false) throw new Error('Account history sync runs on native Windows sessions.');
85 const argv = ['powershell.exe', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
86 '-File', `${api.root}/scripts/HistorySync-Bridge.ps1`, '-Action', action];
87 if (configOverride) argv.push('-ConfigPath', configOverride);
88 if (pair) argv.push('-SourceProfile', pair.source, '-TargetProfile', pair.target);
89 if (action !== 'Release') argv.push('-SessionId', owner);
90 if (['Arm', 'Sync', 'Release'].includes(action)) argv.push('-OwnerSession', owner);
91 if (keepLease) argv.push('-KeepLease');
92 const processResult = await api.processRun(argv, { timeoutMs: 90_000 });
93 let result;
94 try { result = JSON.parse(processResult.stdout.trim()); }
95 catch { throw new Error(`History helper returned invalid output (exit ${processResult.exitCode}). ${processResult.stderr?.slice(0, 500) || ''}`); }
96 if (result.Backup) { view.backup = result.Backup; await rememberBackup({ ...result, Ok: result.Ok && processResult.exitCode === 0 }, pair); }
97 if (result.ConfigPath) view.configPath = result.ConfigPath;
98 if (processResult.exitCode !== 0 || !result.Ok) throw new Error(result.Error || processResult.stderr || 'History helper failed.');
99 return result;
100 }
101 async function release() {
102 if (!owner) return;
103 try { await bridge('Release'); }
104 catch (error) { view.error = `Paused; controller lease expires within three minutes. ${error.message}`; }
105 }
106 async function run(label, work) {
107 if (ended || running) return null;
108 view.busy = true; view.error = ''; view.status = label; redraw();
109 // Promise.resolve defers work until running is assigned, including synchronous failures.
110 running = Promise.resolve().then(work).catch(async error => {
111 view.enabled = false;
112 view.error = error.message;
113 view.status = 'Sync paused after an error.';
114 await release();
115 return null;
116 }).finally(() => { running = null; view.busy = false; redraw(); });
117 return running;
118 }
119 async function sync(automatic = false) {
120 if (automatic && !view.enabled) return null;
121 return run('Checking saved history…', async () => {
122 const pair = selectedPair();
123 const result = await bridge('Sync', pair, view.enabled);
124 view.status = describeResult(result);
125 const at = new Date(await api.now()).toISOString();
126 view.lastSync = new Date(at).toLocaleTimeString();
127 view.lastResult = { source: pair.source, target: pair.target, at, message: view.status, backup: result.Backup || '' };
128 await remember('lastResult', view.lastResult);
129 if (result.Backup) api.log(`History backup: ${result.Backup}`);
130 return result;
131 });
132 }
133 async function pause() {
134 revision += 1;
135 view.enabled = false;
136 debounce?.cancel(); debounce = null;
137 redraw();
138 if (running) await running;
139 view.enabled = false;
140 await release();
141 view.status = 'Auto-sync paused.';
142 redraw();
143 }
144 async function refresh() {
145 return run('Finding local profiles…', async () => {
146 const result = await bridge('Profiles');
147 view.profiles = result.Profiles || [];
148 view.sourceCandidates = result.SessionProfiles || [];
149 const previousSource = view.source;
150 const previousTarget = view.target;
151 const wasEnabled = view.enabled;
152 if (view.source && !view.profiles.some(p => p.Id === view.source)) view.source = '';
153 if (result.CurrentProfile) view.source = result.CurrentProfile;
154 else if (view.sourceCandidates.length && !view.sourceCandidates.includes(view.source)) view.source = '';
155 if (previousSource !== view.source) {
156 revision += 1; view.enabled = false;
157 debounce?.cancel(); debounce = null;
158 if (previousSource) await release();
159 }
160 if (view.target && !view.profiles.some(p => p.Id === view.target)) { view.target = ''; view.enabled = false; }
161 if (view.target === view.source) view.target = '';
162 if (previousSource === view.source && previousTarget !== view.target && wasEnabled) await release();
163 if (previousSource !== view.source || previousTarget !== view.target) clearPreview();
164 view.status = view.source ? 'This account → inactive destination. Choose a destination and sync.' :
165 view.sourceCandidates.length > 1 ? 'This session exists in several accounts. Confirm the open account in Sync settings.' :
166 'No account folder identifies this session yet. Confirm the open account in Sync settings, or refresh after a turn.';
167 return result;
168 });
169 }
170 async function choose(source, target) {
171 if (view.source && source !== view.source) throw new Error('Source is this session’s account. Switch accounts and open a fresh session to reverse the direction.');
172 if (source && (!view.profiles.some(p => p.Id === source) || (view.sourceCandidates.length && !view.sourceCandidates.includes(source)))) {
173 throw new Error('The chosen source does not contain this session. Refresh profiles.');
174 }
175 // Confirming an ambiguous source is session-local, never a saved login assumption.
176 await pause();
177 if (view.source && source !== view.source) throw new Error('The current source changed while waiting. Choose the destination again.');
178 view.source = source; view.target = target;
179 clearPreview();
180 await remember('destination', target);
181 view.status = source ? 'This account → selected destination. Destination must be inactive.' : 'Destination selected. Confirm the open account in Sync settings.';
182 redraw();
183 }
184 async function preview() {
185 return run('Building a read-only preview…', async () => {
186 const result = await bridge('Plan', selectedPair());
187 view.report = result.Report || [];
188 view.previewRows = Array.isArray(result.Rows) ? result.Rows : [];
189 view.previewPage = 0; view.selectedPreview = '';
190 view.status = describeResult(result);
191 view.preview = { source: view.source, target: view.target, message: view.status,
192 writes: result.Writes ?? 0, reportTotal: result.ReportTotal ?? view.report.length,
193 reportTruncated: result.ReportTruncated === true, rowsTotal: result.RowsTotal ?? view.previewRows.length,
194 rowsTruncated: result.RowsTruncated === true, at: new Date(await api.now()).toISOString() };
195 return result;
196 });
197 }
198 async function start() {
199 const startingRevision = revision;
200 const result = await run('Starting auto-sync…', async () => {
201 const pair = selectedPair();
202 await bridge('Arm', pair);
203 if (ended || startingRevision !== revision) { await release(); return null; }
204 view.enabled = true;
205 view.status = 'Auto-sync active: source → destination every 30 seconds.';
206 return true;
207 });
208 if (result && view.enabled) await sync(true);
209 }
210 function stop() {
211 revision += 1;
212 ended = true; view.enabled = false;
213 interval?.cancel(); debounce?.cancel();
214 }
215 async function initialize(adapter) {
216 api = adapter;
217 if (api.windows === false) {
218 view.error = 'Account history sync runs on native Windows sessions.';
219 return;
220 }
221 owner = await api.sessionId();
222 configOverride = (await api.envGet('ACCOUNT_HISTORY_SYNC_CONFIG')) || '';
223 view.source = ''; view.sourceCandidates = [];
224 const saved = await api.storeGet('destination');
225 view.target = typeof saved === 'string' ? saved : '';
226 view.expanded = (await api.storeGet('expanded')) === true;
227 const promptDisplay = await api.storeGet('promptDisplay');
228 view.promptDisplay = ['launcher', 'compact', 'hidden'].includes(promptDisplay) ? promptDisplay : 'launcher';
229 const placement = await api.storeGet('launcherPlacement');
230 view.launcherPlacement = ['all', 'band', 'mode', 'hint'].includes(placement) ? placement : 'all';
231 const receipts = await api.storeGet('receipts');
232 view.receipts = Array.isArray(receipts) ? receipts.filter(r => r && typeof r.backup === 'string' && r.backup && typeof r.message === 'string' &&
233 PROFILE_ID.test(r.source) && PROFILE_ID.test(r.target) && !Number.isNaN(Date.parse(r.at)) &&
234 ['complete', 'prepared'].includes(r.status)).slice(0, 8) : [];
235 view.backup = view.receipts[0]?.backup || '';
236 const lastResult = await api.storeGet('lastResult');
237 view.lastResult = lastResult && PROFILE_ID.test(lastResult.source) && PROFILE_ID.test(lastResult.target) &&
238 typeof lastResult.message === 'string' && !Number.isNaN(Date.parse(lastResult.at)) ? lastResult : null;
239 view.lastSync = view.lastResult ? new Date(view.lastResult.at).toLocaleTimeString() : '';
240 // Never persist auto-enable across sessions, login changes or hot reloads.
241 await release();
242 await refresh();
243 interval = api.every(30_000, async () => { if (ended) return; if (view.enabled) await sync(true); else if (!view.source) await refresh(); });
244 }
245 function afterTurn() {
246 if (ended) return;
247 if (!view.enabled && view.source) return;
248 debounce?.cancel();
249 debounce = api.after(1500, async () => { if (ended) return; if (view.enabled) await sync(true); else if (!view.source) await refresh(); });
250 }
251 async function reopen() {
252 if (!ended) return;
253 if (running) await running;
254 await release();
255 ended = false;
256 view.enabled = false; view.error = '';
257 interval = null; debounce = null;
258 await initialize(api);
259 view.status = `Session changed; auto-sync paused. ${view.status}`;
260 redraw();
261 }
262 function resolveSelector(selector) {
263 const matches = view.profiles.filter(p => p.Id === selector || p.Email?.toLowerCase() === selector.toLowerCase());
264 if (matches.length !== 1) throw new Error('Profile selector is unknown or ambiguous. Use a full profile ID from /history-sync profiles.');
265 return matches[0].Id;
266 }
267 function statusText() {
268 return `${view.enabled ? 'Auto-sync active' : 'Auto-sync paused'}; ${view.source || '(confirm open account)'} → ${view.target || '(choose destination)'}. ${view.status}${view.error ? ` ${view.error}` : ''}`;
269 }
270 return { view, initialize, refresh, choose, preview, start, pause, sync, stop, afterTurn, reopen, resolveSelector, statusText,
271 showSection, toggleDetails, choosePromptDisplay, chooseLauncherPlacement, copyText,
272 filterPreview, filterPreviewChanges, searchPreview, pagePreview, selectPreview };
273}
274hooks/ui.mjs 265 lines1export function accountLabel(view, id, empty = 'Choose destination') {
2 if (!id) return empty;
3 const profile = view.profiles.find(p => p.Id === id);
4 const [account, org] = id.split('\\');
5 const name = profile?.Email || (profile?.Label && profile.Label !== account ? profile.Label : `Account ${account.slice(0, 8)}`);
6 // Organizations can share an email; keep them distinguishable in every picker.
7 return `${name} · ${org?.slice(0, 8) || ''}`;
8}
9
10function mode(view) {
11 if (view.error) return 'Error · auto-sync paused';
12 if (view.busy) return 'Working…';
13 return view.enabled ? 'Auto · every 30s' : 'Paused';
14}
15
16function samePair(view, result) {
17 return result && result.source === view.source && result.target === view.target;
18}
19
20function time(at, full = false) {
21 return full ? new Date(at).toLocaleString() : new Date(at).toLocaleTimeString();
22}
23
24function action(Button, controller, key, label, work, needsPair = false) {
25 const v = controller.view;
26 const ready = !needsPair || Boolean(v.source && v.target && v.source !== v.target);
27 return Button({ key, label, dimColor: v.busy || !ready, onPress: async () => {
28 await controller.reopen();
29 if (!v.busy && (!needsPair || (v.source && v.target && v.source !== v.target))) await work();
30 } });
31}
32
33function automatic(Button, controller) {
34 const v = controller.view;
35 return Button({ key: 'auto', label: v.enabled ? 'Pause auto-sync' : 'Start auto-sync',
36 dimColor: !v.enabled && (v.busy || !v.source || !v.target || v.source === v.target), onPress: async () => {
37 await controller.reopen();
38 if (v.enabled) await controller.pause();
39 else if (!v.busy && v.source && v.target && v.source !== v.target) await controller.start();
40 } });
41}
42
43export function launcherVisible(view, site) {
44 return view.promptDisplay !== 'hidden' && (view.launcherPlacement === 'all' || view.launcherPlacement === site);
45}
46
47export function renderLauncher({ Box, Button }, controller, openSettings, site) {
48 const v = controller.view;
49 const location = v.launcherPlacement === 'all' ? ` · ${ {band:'Prompt', mode:'Mode', hint:'Hint'}[site] }` : '';
50 const status = v.error ? ' · needs attention' : v.enabled ? ' · Auto' : '';
51 return Box({ flexDirection: 'row', flexWrap: 'wrap', gap: 1, children: [
52 Button({ key: site === 'band' ? 'settings' : `settings-${site}`, label: `History sync${location}${status}`,
53 ...(site === 'band' ? {hotkey: 's'} : {}), onPress: openSettings }),
54 ...(v.enabled ? [automatic(Button, controller)] : []),
55 ] });
56}
57
58export function renderBand({ Box, Text, Button }, controller, openSettings) {
59 const v = controller.view;
60 if (v.promptDisplay === 'hidden') return null;
61 if (v.promptDisplay !== 'compact') return renderLauncher({ Box, Button }, controller, openSettings, 'band');
62 const direction = `${accountLabel(v, v.source, 'Confirm this account')} → ${accountLabel(v, v.target)}`;
63 const last = samePair(v, v.lastResult) ? ` · Last ${time(v.lastResult.at)}` : '';
64 return Box({ flexDirection: 'column', children: [
65 Box({ flexDirection: 'row', flexWrap: 'wrap', gap: 1, children: [
66 Text({ bold: true, children: `History sync · ${mode(v)}` }),
67 action(Button, controller, 'sync', 'Sync now', () => controller.sync(), true),
68 ...(v.enabled ? [automatic(Button, controller)] : []),
69 Button({ key: 'settings', label: 'Sync settings', hotkey: 's', onPress: openSettings }),
70 Button({ key: 'details', label: v.expanded ? 'Less' : 'Details', onPress: () => controller.toggleDetails() }),
71 ] }),
72 Text({ dimColor: true, children: `${direction}${last}` }),
73 ...(v.error ? [Text({ color: 'red', children: v.error })] : []),
74 ...(v.busy ? [Text({ children: v.status })] : []),
75 ...(v.expanded ? [
76 Text({ children: samePair(v, v.lastResult) ? v.lastResult.message : v.source && v.target ? 'Ready to preview or sync this direction.' : 'Choose an inactive destination in Sync settings.' }),
77 ...(samePair(v, v.lastResult) && v.lastResult.backup ? [Text({ dimColor: true, children: 'Verified backup saved · details in Backups' })] : []),
78 Text({ dimColor: true, children: 'Source stays untouched. Pause before switching accounts.' }),
79 ] : []),
80 ] });
81}
82
83export function profilesText(view) {
84 return view.profiles.map(p => `${accountLabel(view, p.Id)} | ${p.Id} | ${p.Sessions} sessions`).join('\n');
85}
86
87export function filteredPreviewRows(view) {
88 const search = view.previewSearch.trim().toLocaleLowerCase();
89 return view.previewRows.filter(r =>
90 (view.previewFilter === 'all' || (r.After && (view.previewFilter === 'archived' ? r.After.Archived : !r.After.Archived))) &&
91 (view.previewChanges === 'all' || (view.previewChanges === 'changes' ? ['Add','Update'].includes(r.Action) : ['Conflict','Skipped'].includes(r.Action))) &&
92 (!search || [r.Title,r.Before?.Title,r.After?.Title].some(t => t?.toLocaleLowerCase().includes(search)))
93 ).sort((a,b) => a.Title.localeCompare(b.Title) || a.File.localeCompare(b.File));
94}
95
96function archiveState(state) { return state ? state.Archived ? 'Archived' : 'Active' : 'Not present'; }
97
98export function previewTableText(rows) {
99 const cell = text => String(text ?? '').replace(/[\t\r\n]+/g,' ');
100 return [['Session','Change','Before title','Before state','After title','After state','Reason','Session file'],
101 ...rows.map(r => [r.Title,r.Action,r.Before?.Title,archiveState(r.Before),r.After?.Title,archiveState(r.After),r.Detail,r.File]),
102 ].map(row => row.map(cell).join('\t')).join('\n');
103}
104
105export function previewText(view) {
106 const p = view.preview;
107 if (!p) return '';
108 return ['Preview only — no history writes performed.',
109 `From: ${accountLabel(view, p.source)} | ${p.source}`,
110 `To: ${accountLabel(view, p.target)} | ${p.target}`, time(p.at, true), p.message,
111 `Planned operations: ${p.writes}`,
112 ...(view.previewRows.length ? [previewTableText(view.previewRows)] : view.report.map(r => `${r.Action}: ${r.File}\n${r.Detail}`)),
113 ...(p.reportTruncated ? [`Report limited to ${view.report.length} of ${p.reportTotal} entries.`] : []),
114 ...(p.rowsTruncated ? [`Comparison limited to ${view.previewRows.length} of ${p.rowsTotal} sessions.`] : []),
115 ].join('\n');
116}
117
118export function diagnosticsText(view) {
119 return ['History sync diagnostics', `From: ${accountLabel(view, view.source, 'Unconfirmed')} | ${view.source}`,
120 `To: ${accountLabel(view, view.target)} | ${view.target}`, mode(view), view.status,
121 ...(view.error ? [`Error: ${view.error}`] : []), `Config: ${view.configPath || '(default)'}`,
122 profilesText(view), ...(view.preview ? [previewText(view)] : []),
123 ...(view.backup ? [`Latest backup: ${view.backup}`] : []),
124 ].join('\n');
125}
126
127function renderPreviewTable({Box,Text,Button,Select,Input}, controller, copy) {
128 const v=controller.view;
129 const filtered=filteredPreviewRows(v);
130 const pageCount=Math.max(1,Math.ceil(filtered.length/15));
131 const page=Math.min(v.previewPage,pageCount-1);
132 const pageRows=filtered.slice(page*15,(page+1)*15);
133 const selected=v.previewRows.find(r=>r.File===v.selectedPreview);
134 const widths=['36%','23%','23%','18%'];
135 const cell=(content,index)=>Box({width:widths[index],flexShrink:0,flexDirection:'column',paddingX:1,children:content});
136 const stateCell=(state,row)=>[Text({bold:true,children:archiveState(state)}),
137 ...(state && state.Title!==row.Title ? [Text({dimColor:true,children:state.Title.slice(0,1000)})] : [])];
138 const filters=Box({flexDirection:'row',flexWrap:'wrap',gap:1,children:['all','active','archived'].map(key=>Button({
139 key:`filter-${key}`,label:`${v.previewFilter===key?'› ':''}${{all:'All',active:'Active',archived:'Archived'}[key]}`,
140 dimColor:v.previewFilter!==key,onPress:()=>controller.filterPreview(key),
141 }))});
142 const search=Input ? [Input({key:'preview-search',label:'Search titles',placeholder:'Session title…',value:v.previewSearch,
143 onInput:value=>controller.searchPreview(value),onSubmit:value=>controller.searchPreview(value)})] : [];
144 return [
145 Text({bold:true,children:'Destination before → after'}),
146 Text({dimColor:true,children:'Active and Archived filter the planned destination state. All also includes sessions left absent. Titles come from the source when available.'}),
147 filters,
148 Select({key:'preview-changes',label:'Changes',value:v.previewChanges,
149 options:[{value:'all',label:'All sessions'},{value:'changes',label:'Additions and updates'},{value:'issues',label:'Conflicts and skipped'}],
150 onSelect:value=>controller.filterPreviewChanges(value)}),
151 ...search,
152 copy('copy-filtered','Copy filtered table',()=>previewTableText(filtered)),
153 Text({dimColor:true,children:`${filtered.length} of ${v.previewRows.length} sessions · page ${page+1} of ${pageCount}`}),
154 ...(v.preview.rowsTruncated ? [Text({dimColor:true,children:`Comparison contains the first ${v.previewRows.length} of ${v.preview.rowsTotal} sessions.`})] : []),
155 Box({flexDirection:'column',borderStyle:'single',children:[
156 Box({flexDirection:'row',children:['Session','Before','After','Change'].map((label,i)=>cell([Text({bold:true,children:label})],i))}),
157 ...pageRows.map(r=>Box({key:`preview-row-${r.File}`,flexDirection:'row',paddingY:1,children:[
158 cell([Text({children:r.Title.slice(0,1000)})],0),cell(stateCell(r.Before,r),1),cell(stateCell(r.After,r),2),
159 cell([Text({children:r.Action}),Button({key:`details-${r.File}`,label:'Details',onPress:()=>controller.selectPreview(r.File)})],3),
160 ]})),
161 ...(!pageRows.length?[Text({dimColor:true,children:'No sessions match these filters.'})]:[]),
162 ]}),
163 Box({flexDirection:'row',gap:1,children:[
164 Button({key:'preview-prev',label:'Previous',dimColor:page===0,onPress:()=>{if(page>0)controller.pagePreview(page-1);}}),
165 Button({key:'preview-next',label:'Next',dimColor:page>=pageCount-1,onPress:()=>{if(page<pageCount-1)controller.pagePreview(page+1);}}),
166 ]}),
167 ...(selected?[
168 Text({bold:true,children:selected.Title.slice(0,1000)}),
169 Text({children:`${selected.Action}: ${selected.Detail}`}),
170 Text({children:`Before: ${selected.Before?.Title||'Not present'} · ${archiveState(selected.Before)}\nAfter: ${selected.After?.Title||'Not present'} · ${archiveState(selected.After)}`}),
171 Text({dimColor:true,children:`Changed fields: ${selected.ChangedFields?.join(', ')||'None'}\nSession file: ${selected.File}`}),
172 ]:[]),
173 ];
174}
175
176export function renderPane({ Box, Text, Button, Select, Input }, controller, writer = async () => ({isCopied:false,reason:'no-clipboard'})) {
177 const v = controller.view;
178 const row = children => Box({ flexDirection: 'row', flexWrap: 'wrap', gap: 1, children });
179 const heading = text => Text({ bold: true, children: text });
180 const note = text => Text({ dimColor: true, children: text });
181 const go = section => controller.showSection(section);
182 const copy = (key, label, getText) => Button({key, label, onPress: press => controller.copyText(getText(), writer, press?.surface)});
183 const section = v.section || 'accounts';
184 let content;
185 if (section === 'accounts') {
186 const sourceChoices = v.profiles.filter(p => !v.sourceCandidates.length || v.sourceCandidates.includes(p.Id));
187 content = [
188 heading('Accounts'),
189 Text({ children: `From · ${accountLabel(v, v.source, 'Confirm the account open in Claude')}` }),
190 ...(v.source ? [note('This session’s account is the source.')] : [
191 Text({ children: v.status }),
192 Select({ key: 'source-confirmation', label: 'Account open now', value: '',
193 options: [{ value: '', label: 'Choose the open account…' }, ...sourceChoices.map(p => ({ value: p.Id, label: accountLabel(v, p.Id) }))],
194 onSelect: async value => { await controller.reopen(); if (!v.busy && value) await controller.choose(value, v.target === value ? '' : v.target); } }),
195 ]),
196 Select({ key: 'target', label: 'To · inactive account', value: v.target,
197 options: [{ value: '', label: 'Choose destination…' }, ...v.profiles.filter(p => p.Id !== v.source).map(p => ({
198 value: p.Id, label: `${accountLabel(v, p.Id)} · ${p.Sessions} sessions`,
199 }))],
200 onSelect: async value => { await controller.reopen(); if (!v.busy) await controller.choose(v.source, value); } }),
201 note('Copy saved Code history from this account into the destination. Keep the destination account inactive.'),
202 copy('copy-profiles', 'Copy profile list', () => profilesText(v)),
203 row([action(Button, controller, 'refresh', 'Refresh accounts', () => controller.refresh()),
204 action(Button, controller, 'continue', 'Continue to Sync', () => go('sync'), true)]),
205 heading('Prompt controls'),
206 Select({ key: 'prompt-display', label: 'Show near the prompt', value: v.promptDisplay,
207 options: [{ value: 'launcher', label: 'Menu button only' }, { value: 'compact', label: 'Status and quick controls' },
208 { value: 'hidden', label: 'Hide — open with /history-sync' }],
209 onSelect: value => controller.choosePromptDisplay(value) }),
210 Select({ key: 'launcher-placement', label: 'Launcher placement', value: v.launcherPlacement,
211 options: [{ value: 'all', label: 'Compare all three locations' }, { value: 'band', label: 'Above the prompt' },
212 { value: 'mode', label: 'Session mode area' }, { value: 'hint', label: 'Prompt hint area' }],
213 onSelect: value => controller.chooseLauncherPlacement(value) }),
214 note('Compare shows Prompt, Mode, and Hint labels. A location appears only if this Desktop build asks the mod to draw it. /history-sync always opens the pane.'),
215 note('This launcher appears in the main conversation only. Claude decides where the settings pane opens.'),
216 ];
217 } else if (section === 'sync') {
218 content = [
219 heading('Sync'),
220 Text({ children: `${accountLabel(v, v.source, 'Confirm this account')} → ${accountLabel(v, v.target)}` }),
221 ...(!v.source || !v.target ? [note('Choose accounts before previewing or syncing.'),
222 Button({ key: 'choose-accounts', label: 'Choose accounts', onPress: () => go('accounts') })] : []),
223 row([action(Button, controller, 'preview', 'Preview', () => controller.preview(), true),
224 action(Button, controller, 'sync', 'Sync now', () => controller.sync(), true)]),
225 heading('Automatic sync'),
226 Text({ children: mode(v) }),
227 note('Checks saved history every 30 seconds and shortly after a turn finishes. Starts paused in each session.'),
228 row([automatic(Button, controller)]),
229 note('Pause before switching accounts. Changing the destination pauses automatic sync.'),
230 heading('Results'),
231 Text({ children: v.status }),
232 ...(samePair(v, v.lastResult) ? [Text({ children: `${v.lastResult.message}\nLast sync: ${time(v.lastResult.at, true)}` })] : [note('No completed sync for this direction yet.')]),
233 ...(v.preview ? [Text({ children: v.preview.message }),
234 copy('copy-preview', 'Copy preview', () => previewText(v)),
235 ...(v.preview.reportTruncated ? [note(`Showing ${v.report.length} of ${v.preview.reportTotal} report entries.`)] : []),
236 ...(v.previewRows.length ? renderPreviewTable({Box,Text,Button,Select,Input},controller,copy) :
237 v.report.length ? [Text({children:v.report.map(r=>`${r.Action}: ${r.File}\n${r.Detail}`).join('\n').slice(0,9500)})] : [note('No sessions in this preview.')])] : []),
238 ];
239 } else {
240 content = [
241 heading('Backups'),
242 note('Each modifying sync backs up the destination cards and archive index. Recent backups include their original direction.'),
243 ...(v.receipts.length ? v.receipts.flatMap((r, index) => [
244 heading(`${index === 0 ? 'Latest backup' : 'Backup'} · ${r.status === 'prepared' ? 'Interrupted — inspect before retrying' : 'Complete'}`),
245 Text({ children: `${accountLabel(v, r.source)} → ${accountLabel(v, r.target)}\n${time(r.at, true)}\n${r.message}` }),
246 Text({ children: r.backup }),
247 copy(`copy-backup-${index}`, 'Copy backup path', () => r.backup),
248 ]) : [note('No backup recorded yet. A preview or sync with no changes creates no backup.')]),
249 heading('Restore'),
250 note('Fully exit Claude before restoring a backup with the standalone Undo command. Review the restore with -WhatIf first.'),
251 ];
252 }
253 return Box({ flexDirection: 'column', gap: 1, padding: 1, children: [
254 heading('History sync'),
255 copy('copy-diagnostics', 'Copy diagnostics', () => diagnosticsText(v)),
256 row(['accounts', 'sync', 'backups'].map((key, i) => Button({ key: `nav-${key}`,
257 label: `${section === key ? '› ' : ''}${['Accounts', 'Sync', 'Backups'][i]}`, hotkey: ['a', 's', 'b'][i],
258 dimColor: section !== key, onPress: () => go(key) }))),
259 ...content,
260 ...(v.error ? [Text({ color: 'red', children: v.error })] : []),
261 ...(v.notice ? [note(v.notice)] : []),
262 ...(v.copyStatus ? [note(v.copyStatus)] : []),
263 ] });
264}
265