memoir-cli 3.6.1 → 3.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/memoir.js CHANGED
@@ -22,6 +22,19 @@ import { projectsListCommand, projectsTodoCommand } from '../src/commands/projec
22
22
  import { upgradeCommand } from '../src/commands/upgrade.js';
23
23
  import { activateCommand, deactivateCommand } from '../src/commands/activate.js';
24
24
  import { consolidateCommand } from '../src/commands/consolidate.js';
25
+ import {
26
+ goalCommand,
27
+ nextCommand,
28
+ doneCommand,
29
+ noteCommand,
30
+ askCommand,
31
+ sessionShowCommand,
32
+ sessionClearCommand,
33
+ } from '../src/commands/session.js';
34
+ import { autopushCommand } from '../src/commands/autopush.js';
35
+ import { whyCommand } from '../src/commands/why.js';
36
+ import { autoRefreshCommand } from '../src/commands/auto-refresh.js';
37
+ import { hooksInstallCommand, hooksUninstallCommand, hooksStatusCommand } from '../src/commands/hooks.js';
25
38
  import { createRequire } from 'module';
26
39
 
27
40
  const require = createRequire(import.meta.url);
@@ -153,6 +166,106 @@ program
153
166
  }
154
167
  });
155
168
 
169
+ // ── Session continuity ──────────────────────────────────────────
170
+ program
171
+ .command('goal <text...>')
172
+ .description('Set your current goal (pinned into CLAUDE.md, syncs across machines)')
173
+ .action(async (text) => {
174
+ try { await goalCommand(text.join(' ')); }
175
+ catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
176
+ });
177
+
178
+ program
179
+ .command('next <text...>')
180
+ .description('Add a next action')
181
+ .action(async (text) => {
182
+ try { await nextCommand(text.join(' ')); }
183
+ catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
184
+ });
185
+
186
+ program
187
+ .command('done <text...>')
188
+ .description('Mark a next action complete (substring match)')
189
+ .action(async (text) => {
190
+ try { await doneCommand(text.join(' ')); }
191
+ catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
192
+ });
193
+
194
+ program
195
+ .command('note <text...>')
196
+ .description('Record a decision with rationale (--why) and rejected alternative (--rejected)')
197
+ .option('--why <rationale>', 'Why this decision was made')
198
+ .option('--rejected <alternative>', 'The alternative you considered and rejected')
199
+ .action(async (text, options) => {
200
+ try { await noteCommand(text.join(' '), options); }
201
+ catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
202
+ });
203
+
204
+ program
205
+ .command('ask <text...>')
206
+ .description('Capture an open question for later')
207
+ .action(async (text) => {
208
+ try { await askCommand(text.join(' ')); }
209
+ catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
210
+ });
211
+
212
+ program
213
+ .command('why [query...]')
214
+ .description('Look up decisions by keyword — returns what was decided + why + what was rejected')
215
+ .action(async (query) => {
216
+ try { await whyCommand((query || []).join(' ')); }
217
+ catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
218
+ });
219
+
220
+ program
221
+ .command('session')
222
+ .description('Show the current session state')
223
+ .action(async () => {
224
+ try { await sessionShowCommand(); }
225
+ catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
226
+ });
227
+
228
+ program
229
+ .command('session-clear')
230
+ .description('Clear the current session (history retained)')
231
+ .action(async () => {
232
+ try { await sessionClearCommand(); }
233
+ catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
234
+ });
235
+
236
+ // ── Hooks + auto-sync (called by Claude Code hooks or the user) ─────
237
+ program
238
+ .command('autopush')
239
+ .description('Debounced auto-push — called by the Claude Code Stop hook')
240
+ .option('--debounce <seconds>', 'Minimum seconds between auto-pushes', '30')
241
+ .option('-v, --verbose', 'Print debounce state')
242
+ .action(async (options) => {
243
+ try { await autopushCommand(options); }
244
+ catch (err) { if (options.verbose) console.error(chalk.red(err.message)); /* silent by default */ }
245
+ });
246
+
247
+ program
248
+ .command('auto-refresh')
249
+ .description('Re-render the pinned session block — called by the SessionStart hook')
250
+ .option('-v, --verbose', 'Print what changed')
251
+ .action(async (options) => {
252
+ try { await autoRefreshCommand(options); }
253
+ catch (err) { if (options.verbose) console.error(chalk.red(err.message)); }
254
+ });
255
+
256
+ program
257
+ .command('hooks')
258
+ .description('Manage Claude Code hooks (install | uninstall | status)')
259
+ .argument('[subcommand]', 'install, uninstall, or status', 'status')
260
+ .option('-y, --yes', 'Skip confirmation prompts')
261
+ .action(async (subcommand, options) => {
262
+ try {
263
+ if (subcommand === 'install') await hooksInstallCommand(options);
264
+ else if (subcommand === 'uninstall') await hooksUninstallCommand(options);
265
+ else await hooksStatusCommand();
266
+ } catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
267
+ });
268
+
156
269
  program
157
270
  .command('doctor')
158
271
  .alias('diagnose')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memoir-cli",
3
- "version": "3.6.1",
3
+ "version": "3.7.1",
4
4
  "mcpName": "io.github.camgitt/memoir",
5
5
  "description": "MCP server that gives Claude, Cursor, and Gemini long-term memory across sessions. Your AI remembers your codebase, decisions, and preferences — across tools and machines.",
6
6
  "main": "src/index.js",
@@ -9,6 +9,15 @@
9
9
  "memoir": "bin/memoir.js",
10
10
  "memoir-mcp": "src/mcp.js"
11
11
  },
12
+ "files": [
13
+ "bin/",
14
+ "src/",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
12
21
  "repository": {
13
22
  "type": "git",
14
23
  "url": "https://github.com/camgitt/memoir.git"
@@ -19,8 +28,9 @@
19
28
  },
20
29
  "scripts": {
21
30
  "start": "node bin/memoir.js",
22
- "test": "node test-cross-machine.mjs && bash test-cross-machine-e2e.sh",
31
+ "test": "node run-tests.mjs",
23
32
  "test:legacy": "bash test-local.sh",
33
+ "prepublishOnly": "npm test",
24
34
  "postinstall": "node -e \"try{const c='\\x1b[36m',r='\\x1b[0m',g='\\x1b[90m';console.log('\\n '+c+'memoir'+r+' installed.\\n Run '+c+'memoir activate'+r+' in any project to give your AI long-term memory.\\n '+g+'https://memoir.sh'+r+'\\n')}catch{}\""
25
35
  },
26
36
  "keywords": [
@@ -0,0 +1,31 @@
1
+ // Auto-refresh — called by the Claude Code SessionStart hook.
2
+ // Reads current session.json (local) and re-injects the pinned block into
3
+ // CLAUDE.md. Instant, no network, no side effects beyond that file.
4
+ //
5
+ // For cross-machine pull-on-start, that's a separate concern — handled by
6
+ // periodic auto-restore or explicit `memoir restore`. This hook only ensures
7
+ // the pinned block matches the current local session state.
8
+
9
+ import { readSession } from '../session/state.js';
10
+ import { renderSession } from '../session/render.js';
11
+ import { injectInto, detectAvailableTargets } from '../session/inject.js';
12
+
13
+ export async function autoRefreshCommand(options = {}) {
14
+ const verbose = !!options.verbose;
15
+ try {
16
+ const state = await readSession();
17
+ const rendered = renderSession(state);
18
+ const targets = detectAvailableTargets();
19
+ for (const [tool, target] of Object.entries(targets)) {
20
+ try {
21
+ const res = await injectInto(target, rendered);
22
+ if (verbose) console.log(`memoir auto-refresh: ${res.replaced ? 'updated' : 'created'} ${tool} → ${res.path}`);
23
+ } catch (err) {
24
+ if (verbose) console.error(`memoir auto-refresh: ${tool} failed: ${err.message}`);
25
+ }
26
+ }
27
+ } catch (err) {
28
+ if (verbose) console.error(`memoir auto-refresh: ${err.message}`);
29
+ // Never fail the hook — session start must proceed.
30
+ }
31
+ }
@@ -0,0 +1,57 @@
1
+ // Auto-push command — called by the Claude Code Stop hook after every response.
2
+ //
3
+ // Rules of engagement:
4
+ // - Debounced: won't run more than once per DEBOUNCE_SECONDS.
5
+ // - Non-blocking: detaches a background subprocess and exits immediately
6
+ // so Claude's response pipeline is never held up.
7
+ // - Silent: no stdout unless --verbose is passed.
8
+ //
9
+ // The actual push work happens in a fully detached child — the hook process
10
+ // itself just records a timestamp and returns.
11
+
12
+ import fs from 'fs-extra';
13
+ import path from 'path';
14
+ import os from 'os';
15
+ import { spawn } from 'child_process';
16
+
17
+ const home = os.homedir();
18
+ const STAMP_FILE = path.join(home, '.config', 'memoir', 'last-autopush.timestamp');
19
+ const DEBOUNCE_SECONDS_DEFAULT = 30;
20
+
21
+ export async function autopushCommand(options = {}) {
22
+ const debounce = parseInt(options.debounce || DEBOUNCE_SECONDS_DEFAULT, 10);
23
+ const verbose = !!options.verbose;
24
+
25
+ try {
26
+ await fs.ensureDir(path.dirname(STAMP_FILE));
27
+ } catch {}
28
+
29
+ const now = Date.now();
30
+ let last = 0;
31
+ try {
32
+ const raw = await fs.readFile(STAMP_FILE, 'utf8');
33
+ last = parseInt(raw.trim(), 10) || 0;
34
+ } catch {}
35
+
36
+ const elapsed = (now - last) / 1000;
37
+ if (last && elapsed < debounce) {
38
+ if (verbose) console.log(`memoir autopush: skipped (${Math.floor(elapsed)}s since last, debounce=${debounce}s)`);
39
+ return;
40
+ }
41
+
42
+ // Stamp BEFORE spawning so rapid repeat calls don't all race through.
43
+ try {
44
+ await fs.writeFile(STAMP_FILE, String(now));
45
+ } catch {}
46
+
47
+ // Detach a background push. Parent exits immediately so Claude isn't blocked.
48
+ const memoirBin = process.argv[1]; // path to this same memoir CLI
49
+ const child = spawn(process.execPath, [memoirBin, 'push'], {
50
+ detached: true,
51
+ stdio: verbose ? 'inherit' : 'ignore',
52
+ env: { ...process.env, MEMOIR_AUTOPUSH: '1' },
53
+ });
54
+ child.unref();
55
+
56
+ if (verbose) console.log('memoir autopush: triggered (background)');
57
+ }
@@ -0,0 +1,171 @@
1
+ // Install / uninstall / status for Claude Code hooks.
2
+ //
3
+ // Hooks are configured in ~/.claude/settings.json under the `hooks` object:
4
+ // {
5
+ // "hooks": {
6
+ // "Stop": [{ "matcher": "", "hooks": [{ "type": "command", "command": "..." }] }],
7
+ // "SessionStart": [{ "matcher": "", "hooks": [{ "type": "command", "command": "..." }] }]
8
+ // }
9
+ // }
10
+ //
11
+ // We merge into any existing hooks the user has — never clobber. Our entries
12
+ // are identified by a marker in the command string so we can find and remove
13
+ // them on uninstall.
14
+
15
+ import fs from 'fs-extra';
16
+ import path from 'path';
17
+ import os from 'os';
18
+ import chalk from 'chalk';
19
+ import boxen from 'boxen';
20
+ import inquirer from 'inquirer';
21
+
22
+ const home = os.homedir();
23
+ const CLAUDE_SETTINGS = path.join(home, '.claude', 'settings.json');
24
+ const MARKER = 'memoir'; // any command containing this is ours
25
+
26
+ const OUR_HOOKS = {
27
+ Stop: {
28
+ type: 'command',
29
+ command: 'memoir autopush --debounce 30',
30
+ },
31
+ SessionStart: {
32
+ type: 'command',
33
+ command: 'memoir auto-refresh',
34
+ },
35
+ };
36
+
37
+ async function readSettings() {
38
+ if (!await fs.pathExists(CLAUDE_SETTINGS)) return {};
39
+ try {
40
+ return JSON.parse(await fs.readFile(CLAUDE_SETTINGS, 'utf8'));
41
+ } catch {
42
+ return {};
43
+ }
44
+ }
45
+
46
+ async function writeSettings(settings) {
47
+ await fs.ensureDir(path.dirname(CLAUDE_SETTINGS));
48
+ const tmp = `${CLAUDE_SETTINGS}.tmp-${process.pid}`;
49
+ await fs.writeFile(tmp, JSON.stringify(settings, null, 2));
50
+ await fs.move(tmp, CLAUDE_SETTINGS, { overwrite: true });
51
+ }
52
+
53
+ function findOurEntry(hookList) {
54
+ // hookList is array of { matcher, hooks: [{type, command}] }
55
+ return (hookList || []).findIndex(entry =>
56
+ (entry.hooks || []).some(h => h.type === 'command' && (h.command || '').includes(MARKER))
57
+ );
58
+ }
59
+
60
+ function ensureOurHook(settings, eventName, hook) {
61
+ settings.hooks = settings.hooks || {};
62
+ settings.hooks[eventName] = settings.hooks[eventName] || [];
63
+ const list = settings.hooks[eventName];
64
+
65
+ const existing = findOurEntry(list);
66
+ if (existing >= 0) {
67
+ // Replace (idempotent — same shape every time)
68
+ list[existing] = { matcher: '', hooks: [hook] };
69
+ } else {
70
+ list.push({ matcher: '', hooks: [hook] });
71
+ }
72
+ }
73
+
74
+ function removeOurHook(settings, eventName) {
75
+ if (!settings?.hooks?.[eventName]) return false;
76
+ const list = settings.hooks[eventName];
77
+ const idx = findOurEntry(list);
78
+ if (idx < 0) return false;
79
+ list.splice(idx, 1);
80
+ if (list.length === 0) delete settings.hooks[eventName];
81
+ return true;
82
+ }
83
+
84
+ export async function hooksInstallCommand(options = {}) {
85
+ const settings = await readSettings();
86
+ const fresh = JSON.parse(JSON.stringify(settings)); // snapshot for diff
87
+
88
+ ensureOurHook(fresh, 'Stop', OUR_HOOKS.Stop);
89
+ ensureOurHook(fresh, 'SessionStart', OUR_HOOKS.SessionStart);
90
+
91
+ const isNoop = JSON.stringify(settings) === JSON.stringify(fresh);
92
+ if (isNoop) {
93
+ console.log('\n' + chalk.green(' ✓ Hooks already installed — nothing to do.\n'));
94
+ return;
95
+ }
96
+
97
+ // Show what will change
98
+ console.log('\n' + boxen(
99
+ chalk.cyan('memoir will add these hooks to ') + chalk.white.bold('~/.claude/settings.json') + chalk.cyan(':') + '\n\n' +
100
+ chalk.gray(' Stop: ') + chalk.white(OUR_HOOKS.Stop.command) + '\n' +
101
+ chalk.gray(' fires after every response; auto-pushes (debounced 30s)') + '\n\n' +
102
+ chalk.gray(' SessionStart: ') + chalk.white(OUR_HOOKS.SessionStart.command) + '\n' +
103
+ chalk.gray(' fires at session open; refreshes pinned block from session.json') + '\n\n' +
104
+ chalk.gray(' Your existing settings (including other hooks) will be preserved.'),
105
+ { padding: 1, borderStyle: 'round', borderColor: 'cyan', dimBorder: true }
106
+ ) + '\n');
107
+
108
+ if (!options.yes) {
109
+ const { confirm } = await inquirer.prompt([{
110
+ type: 'confirm',
111
+ name: 'confirm',
112
+ message: 'Install hooks?',
113
+ default: true,
114
+ }]);
115
+ if (!confirm) {
116
+ console.log(chalk.yellow('\n Cancelled.\n'));
117
+ return;
118
+ }
119
+ }
120
+
121
+ await writeSettings(fresh);
122
+ console.log('\n' + chalk.green(' ✓ Hooks installed. Restart Claude Code to activate.\n'));
123
+ }
124
+
125
+ export async function hooksUninstallCommand(options = {}) {
126
+ const settings = await readSettings();
127
+ const fresh = JSON.parse(JSON.stringify(settings));
128
+ const removedStop = removeOurHook(fresh, 'Stop');
129
+ const removedStart = removeOurHook(fresh, 'SessionStart');
130
+
131
+ if (!removedStop && !removedStart) {
132
+ console.log('\n' + chalk.gray(' No memoir hooks installed.\n'));
133
+ return;
134
+ }
135
+
136
+ if (!options.yes) {
137
+ const { confirm } = await inquirer.prompt([{
138
+ type: 'confirm',
139
+ name: 'confirm',
140
+ message: 'Remove memoir hooks from ~/.claude/settings.json?',
141
+ default: true,
142
+ }]);
143
+ if (!confirm) {
144
+ console.log(chalk.yellow('\n Cancelled.\n'));
145
+ return;
146
+ }
147
+ }
148
+
149
+ await writeSettings(fresh);
150
+ console.log('\n' + chalk.green(' ✓ memoir hooks removed.\n'));
151
+ }
152
+
153
+ export async function hooksStatusCommand() {
154
+ const settings = await readSettings();
155
+ const hasStop = (settings.hooks?.Stop || []).some(entry =>
156
+ (entry.hooks || []).some(h => (h.command || '').includes(MARKER))
157
+ );
158
+ const hasStart = (settings.hooks?.SessionStart || []).some(entry =>
159
+ (entry.hooks || []).some(h => (h.command || '').includes(MARKER))
160
+ );
161
+
162
+ const mark = (b) => b ? chalk.green(' ✓ ') : chalk.gray(' ✗ ');
163
+ console.log('\n' + boxen(
164
+ chalk.cyan.bold('memoir hooks status') + '\n\n' +
165
+ mark(hasStop) + chalk.white('Stop hook') + chalk.gray(' (auto-push)') + '\n' +
166
+ mark(hasStart) + chalk.white('SessionStart hook') + chalk.gray(' (auto-refresh)') + '\n\n' +
167
+ chalk.gray(' Settings: ') + chalk.white(CLAUDE_SETTINGS) + '\n' +
168
+ chalk.gray(' Run ') + chalk.cyan('memoir hooks install') + chalk.gray(' to add missing hooks.'),
169
+ { padding: 1, borderStyle: 'round', borderColor: 'cyan', dimBorder: true }
170
+ ) + '\n');
171
+ }
@@ -15,6 +15,9 @@ import { encryptDirectory, createVerifyToken } from '../security/encryption.js';
15
15
  import { getRawConfig, saveConfig, migrateConfigToV2 } from '../config.js';
16
16
  import { scanWorkspace } from '../workspace/tracker.js';
17
17
  import { promptActivate } from './activate.js';
18
+ import { paths as sessionPaths, readSession, addNote, recordSessionEnd } from '../session/state.js';
19
+ import { renderSession } from '../session/render.js';
20
+ import { injectInto, detectAvailableTargets } from '../session/inject.js';
18
21
 
19
22
  export async function pushCommand(options = {}) {
20
23
  let config = await getConfig(options.profile);
@@ -93,6 +96,54 @@ export async function pushCommand(options = {}) {
93
96
  } catch {}
94
97
  }
95
98
 
99
+ // Also feed structured decisions into session.json so they appear in
100
+ // the pinned block and sync cross-machine. Dedupe against anything
101
+ // the AI already captured via MCP tools or the user via `memoir note`.
102
+ try {
103
+ const current = await readSession();
104
+ const existingTexts = new Set(
105
+ current.current.decisions.map(d => (d.text || '').trim().toLowerCase())
106
+ );
107
+ // Quality filter: auto-extracted decisions come from regex patterns
108
+ // that sometimes catch table cells or prose fragments. Keep only
109
+ // substantive-looking entries.
110
+ const isQuality = (text) => {
111
+ if (!text) return false;
112
+ if (text.length < 15) return false; // too short to be a real decision
113
+ if (text.length > 200) return false; // probably a snippet, not a decision
114
+ if (/\|/.test(text)) return false; // markdown table fragment
115
+ if (/[_*`]{3,}/.test(text)) return false; // markdown formatting leaked in
116
+ if (!/[a-zA-Z]/.test(text)) return false; // no actual words
117
+ const words = text.split(/\s+/).length;
118
+ if (words < 3) return false; // less than 3 words isn't a decision
119
+ return true;
120
+ };
121
+ for (const d of parsed.decisions.slice(0, 10)) {
122
+ const text = String(d.value || '').trim();
123
+ if (!isQuality(text)) continue;
124
+ if (existingTexts.has(text.toLowerCase())) continue;
125
+ await addNote(text, { why: d.context ? `auto-captured: ${d.context.slice(0, 80)}` : undefined });
126
+ }
127
+ // Record a session summary in history for "recent sessions" section
128
+ const filesList = Array.from(parsed.filesWritten || []).slice(0, 10);
129
+ const durationMin = (parsed.firstTimestamp && parsed.lastTimestamp)
130
+ ? Math.floor((new Date(parsed.lastTimestamp) - new Date(parsed.firstTimestamp)) / 60000)
131
+ : null;
132
+ const summary = parsed.slug ? `Worked on ${parsed.slug}` : `${filesList.length} file(s) touched`;
133
+ await recordSessionEnd({ summary, filesTouched: filesList, durationMin });
134
+ // Re-render into every detected tool so the pinned block reflects
135
+ // what was just auto-captured from the .jsonl
136
+ try {
137
+ const state = await readSession();
138
+ const rendered = renderSession(state);
139
+ for (const target of Object.values(detectAvailableTargets())) {
140
+ try { await injectInto(target, rendered); } catch {}
141
+ }
142
+ } catch {}
143
+ } catch {
144
+ // Session.json capture is best-effort
145
+ }
146
+
96
147
  contextCaptured = true;
97
148
  sessionInfo = {
98
149
  slug: parsed.slug,
@@ -128,6 +179,17 @@ export async function pushCommand(options = {}) {
128
179
  // Workspace scan is best-effort
129
180
  }
130
181
 
182
+ // Include session.json (continuity state) so it syncs across machines
183
+ let sessionIncluded = false;
184
+ try {
185
+ if (await fs.pathExists(sessionPaths.session)) {
186
+ await fs.copy(sessionPaths.session, path.join(stagingDir, 'session.json'));
187
+ sessionIncluded = true;
188
+ }
189
+ } catch {
190
+ // Best-effort — don't fail the push over this
191
+ }
192
+
131
193
  // Count what was found
132
194
  const found = [];
133
195
  for (const adapter of adapters) {
@@ -14,6 +14,9 @@ import { restoreWorkspace } from '../workspace/tracker.js';
14
14
  import { getSession } from '../cloud/auth.js';
15
15
  import { unbundleToDir } from '../cloud/storage.js';
16
16
  import { SUPABASE_URL, SUPABASE_ANON_KEY, STORAGE_BUCKET } from '../cloud/constants.js';
17
+ import { readSession, writeSession, mergeSessions, paths as sessionPaths } from '../session/state.js';
18
+ import { renderSession } from '../session/render.js';
19
+ import { injectInto, detectAvailableTargets } from '../session/inject.js';
17
20
 
18
21
  const home = os.homedir();
19
22
 
@@ -119,6 +122,39 @@ export async function restoreCommand(options = {}) {
119
122
 
120
123
  spinner.stop();
121
124
 
125
+ // Merge session.json (continuity state) from backup into local
126
+ let sessionMerged = false;
127
+ let sessionNewMachine = false;
128
+ try {
129
+ const remoteSessionPath = path.join(stagingDir, 'session.json');
130
+ if (await fs.pathExists(remoteSessionPath)) {
131
+ const remote = JSON.parse(await fs.readFile(remoteSessionPath, 'utf8'));
132
+ const local = await readSession();
133
+ const beforeMachines = Object.keys(local.machines || {}).length;
134
+ const merged = mergeSessions(local, remote);
135
+ await writeSession(merged);
136
+ // Re-render + inject into every detected tool so the pinned block
137
+ // reflects the merged state right away across Claude/Cursor/Windsurf/Gemini
138
+ try {
139
+ const rendered = renderSession(merged);
140
+ for (const target of Object.values(detectAvailableTargets())) {
141
+ try { await injectInto(target, rendered); } catch {}
142
+ }
143
+ } catch {}
144
+ sessionMerged = true;
145
+ sessionNewMachine = Object.keys(merged.machines || {}).length > beforeMachines;
146
+ }
147
+ } catch {
148
+ // Best-effort — don't fail the restore over this
149
+ }
150
+
151
+ if (sessionMerged) {
152
+ const msg = sessionNewMachine
153
+ ? chalk.cyan(' 🔄 Session state merged from another machine')
154
+ : chalk.gray(' ✔ Session state up to date');
155
+ console.log(msg);
156
+ }
157
+
122
158
  // Auto-inject session handoff if available
123
159
  let handoffInjected = false;
124
160
  let handoffInfo = null;