@event4u/agent-switch 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/rebind.js CHANGED
@@ -13,9 +13,10 @@
13
13
  *
14
14
  * Move-semantics + safety: the target account's credential is moved INTO the
15
15
  * running profile's store and the target's own store is emptied, so the family
16
- * is live in exactly one store. Both original credentials are stashed in a
17
- * per-profile binding-marker BEFORE any mutation, so `--restore` fully reverses
18
- * it and a crash leaves a recoverable marker. Nothing is deleted without a stash.
16
+ * is live in exactly one store. Both original credentials are stashed in the
17
+ * Keychain BEFORE any mutation and a per-profile binding-marker names those
18
+ * entries (never the tokens), so `--restore` fully reverses it and a crash
19
+ * leaves a recoverable marker. Nothing is deleted without a stash.
19
20
  *
20
21
  * Deferred to later Phase-2 slices (see road-to-live-rebind.md), not built here:
21
22
  * live pid/process auto-detection, a global cross-profile binding registry +
@@ -29,7 +30,7 @@ import * as nodePath from "node:path";
29
30
  import { fetchProfile } from "./api.js";
30
31
  import * as keychain from "./keychain.js";
31
32
  import { withProperLock } from "./locks.js";
32
- import { configDir, ROOT, readRebindKillSwitch, writeRebindKillSwitch } from "./profiles.js";
33
+ import { configDir, ROOT, readRebindKillSwitch, sharesDefaultLogin, writeRebindKillSwitch } from "./profiles.js";
33
34
  import { freshenToken } from "./token-freshen.js";
34
35
  /** <10 min to expiry → refuse (2× Claude Code's own 5-min refresh buffer). */
35
36
  export const FRESHEN_FLOOR_MS = 10 * 60 * 1000;
@@ -126,6 +127,7 @@ export function defaultDeps() {
126
127
  }
127
128
  },
128
129
  resolveAccount: resolveAccountViaApi,
130
+ sharesDefaultLogin: (name) => sharesDefaultLogin("claude", name),
129
131
  };
130
132
  }
131
133
  function credFile(cfgDir) {
@@ -365,15 +367,30 @@ export function readBinding(cfgDir, d = defaultDeps()) {
365
367
  return null;
366
368
  try {
367
369
  const m = JSON.parse(raw);
368
- if (m && typeof m.boundToProfile === "string" && typeof m.runningOwnCredential === "string" && typeof m.targetOrigCredential === "string") {
370
+ if (!m || typeof m.boundToProfile !== "string")
371
+ return null;
372
+ const stashed = typeof m.runningOwnStash === "string" && typeof m.targetOrigStash === "string";
373
+ const legacy = typeof m.runningOwnCredential === "string" && typeof m.targetOrigCredential === "string";
374
+ if (stashed || legacy)
369
375
  return m;
370
- }
371
376
  }
372
377
  catch {
373
378
  /* corrupt marker → treat as none */
374
379
  }
375
380
  return null;
376
381
  }
382
+ /** Keychain services that hold a binding's originals while the rebind is live. */
383
+ function stashServices(pConfig, d) {
384
+ const base = d.serviceNameFor(pConfig);
385
+ return { own: `${base}-agent-switch-rebind-own`, target: `${base}-agent-switch-rebind-target` };
386
+ }
387
+ /** The two original credentials a binding needs for restore — from the Keychain
388
+ * stash, or inline for a marker written before D16. Null when unreadable. */
389
+ export function bindingCredentials(m, d = defaultDeps()) {
390
+ const runningOwn = m.runningOwnStash ? d.kcGet(m.runningOwnStash) : (m.runningOwnCredential ?? null);
391
+ const targetOrig = m.targetOrigStash ? d.kcGet(m.targetOrigStash) : (m.targetOrigCredential ?? null);
392
+ return runningOwn && targetOrig ? { runningOwn, targetOrig } : null;
393
+ }
377
394
  export class RebindError extends Error {
378
395
  }
379
396
  /**
@@ -533,6 +550,11 @@ async function performRebind(opts, d) {
533
550
  if (account === profile) {
534
551
  throw new RebindRefused(`cannot rebind "${profile}" to itself`);
535
552
  }
553
+ for (const name of [profile, account]) {
554
+ if (d.sharesDefaultLogin?.(name)) {
555
+ throw new RebindRefused(`"${name}" shares its login with the default install — rebinding would refresh or move it and sign the default install out. Give it its own login first: agent-switch login ${name}`);
556
+ }
557
+ }
536
558
  const pConfig = configDir("claude", profile);
537
559
  const aConfig = configDir("claude", account);
538
560
  // Already rebound → RE-SWITCH: put both profiles back on their own accounts
@@ -615,12 +637,21 @@ async function performRebind(opts, d) {
615
637
  // All guards passed — commit the global binding record.
616
638
  reg[account] = { runningProfile: profile, boundAt: new Date(d.now()).toISOString() };
617
639
  writeRegistry(reg, d);
618
- // 1. Recovery point BEFORE any store mutation: stash both originals.
640
+ // 1. Recovery point BEFORE any store mutation: stash both originals in the
641
+ // Keychain (never in the marker file), then point the marker at them.
642
+ const stash = stashServices(pConfig, d);
643
+ if (!d.kcAdd(stash.own, pCred) || !d.kcAdd(stash.target, credToMove)) {
644
+ d.kcDelete(stash.own);
645
+ d.kcDelete(stash.target);
646
+ delete reg[account];
647
+ writeRegistry(reg, d);
648
+ throw new RebindError(`could not stash the original credentials in the Keychain — no swap performed.`);
649
+ }
619
650
  const marker = {
620
651
  boundToProfile: account,
621
652
  boundAt: new Date(d.now()).toISOString(),
622
- runningOwnCredential: pCred,
623
- targetOrigCredential: credToMove,
653
+ runningOwnStash: stash.own,
654
+ targetOrigStash: stash.target,
624
655
  targetFileMoved: false,
625
656
  // Snapshot the running profile's OWN identity stamp before the swap: from
626
657
  // here on Claude Code will overwrite it with the borrowed account's.
@@ -648,6 +679,57 @@ async function performRebind(opts, d) {
648
679
  `Restore with: agent-switch rebind --restore --profile ${profile}`,
649
680
  };
650
681
  }
682
+ /**
683
+ * Drop an active rebind on `profile` WITHOUT restoring it — the `remove --force`
684
+ * path, where the running profile is about to be deleted. Deletes both Keychain
685
+ * stash entries (they would otherwise outlive the profile), releases the
686
+ * registry entry and the marker, and hands the borrowed account back to its own
687
+ * profile: the stashed original goes back into that store only while it is
688
+ * empty (a store that holds a login again is never overwritten), and a lent
689
+ * plaintext file is returned. Returns false when there was no rebind.
690
+ */
691
+ export function discardBinding(profile, d = defaultDeps()) {
692
+ const pConfig = configDir("claude", profile);
693
+ const marker = readBinding(pConfig, d);
694
+ if (!marker)
695
+ return false;
696
+ const originals = bindingCredentials(marker, d);
697
+ const aConfig = configDir("claude", marker.boundToProfile);
698
+ const aSvc = d.serviceNameFor(aConfig);
699
+ if (originals && d.kcGet(aSvc) === null)
700
+ d.kcAdd(aSvc, originals.targetOrig);
701
+ const aFile = credFile(aConfig);
702
+ if (marker.targetFileMoved && d.exists(aFile + LENT_SUFFIX) && !d.exists(aFile))
703
+ d.renameFile(aFile + LENT_SUFFIX, aFile);
704
+ const stash = stashServices(pConfig, d);
705
+ d.kcDelete(marker.runningOwnStash ?? stash.own);
706
+ d.kcDelete(marker.targetOrigStash ?? stash.target);
707
+ const reg = readRegistry(d);
708
+ if (reg[marker.boundToProfile]?.runningProfile === profile) {
709
+ delete reg[marker.boundToProfile];
710
+ writeRegistry(reg, d);
711
+ }
712
+ d.removeFile(markerFile(pConfig));
713
+ return true;
714
+ }
715
+ /** The profile currently serving `account`'s login through a rebind, or null. */
716
+ export function borrowerOf(account, d = defaultDeps()) {
717
+ return readRegistry(d)[account]?.runningProfile ?? null;
718
+ }
719
+ /**
720
+ * Before `account`'s profile is deleted (`remove --force`): put the profile
721
+ * borrowing it back on its own login through the normal restore path, so both
722
+ * stores stay consistent and the stash is cleared. Restore's provenance checks
723
+ * still apply — a refusal propagates and nothing is released. Returns the
724
+ * borrower that was restored, or null when nothing borrowed the account.
725
+ */
726
+ export async function releaseBorrowedAccount(account, d = defaultDeps()) {
727
+ const holder = borrowerOf(account, d);
728
+ if (!holder)
729
+ return null;
730
+ await restoreRebind({ profile: holder }, d);
731
+ return holder;
732
+ }
651
733
  /** Reverse an active rebind on `profile`: restore both profiles' own credentials. */
652
734
  export async function restoreRebind(opts, d = defaultDeps()) {
653
735
  const { profile } = opts;
@@ -658,6 +740,11 @@ export async function restoreRebind(opts, d = defaultDeps()) {
658
740
  const marker = readBinding(pConfig, d);
659
741
  if (!marker)
660
742
  throw new RebindRefused(`no active rebind on profile "${profile}".`);
743
+ const originals = bindingCredentials(marker, d);
744
+ if (!originals) {
745
+ throw new RebindRefused(`the stashed credentials for profile "${profile}"'s rebind are not readable — refusing restore (no mutation). ` +
746
+ `The binding marker is left in place for manual recovery.`);
747
+ }
661
748
  const aConfig = configDir("claude", marker.boundToProfile);
662
749
  const pSvc = d.serviceNameFor(pConfig);
663
750
  const aSvc = d.serviceNameFor(aConfig);
@@ -672,7 +759,7 @@ export async function restoreRebind(opts, d = defaultDeps()) {
672
759
  // account we bound INTO it. If someone re-logged the running profile, its
673
760
  // store now holds a DIFFERENT account — never clobber that fresh login.
674
761
  const liveRunning = readCred(pConfig, d);
675
- const branch = await compareAccounts(marker.targetOrigCredential, liveRunning, d);
762
+ const branch = await compareAccounts(originals.targetOrig, liveRunning, d);
676
763
  if (branch === "unparseable") {
677
764
  throw new RebindRefused(`running profile "${profile}"'s live credential is missing or unparseable — refusing restore (no mutation). ` +
678
765
  `The binding marker is left in place for manual recovery.`);
@@ -691,11 +778,23 @@ export async function restoreRebind(opts, d = defaultDeps()) {
691
778
  }
692
779
  // "same" / "rotated" → the bound account is still there → restore normally.
693
780
  // Restore the running profile to its own account.
694
- d.kcAdd(pSvc, marker.runningOwnCredential);
781
+ if (!d.kcAdd(pSvc, originals.runningOwn)) {
782
+ throw new RebindError(`could not write profile "${profile}"'s own credential back to the Keychain — restore aborted. ` +
783
+ `Nothing was changed; the marker and the stashed credentials are kept — retry the restore.`);
784
+ }
695
785
  // Restore the target profile's own store. On "rotated" the live credential
696
786
  // is the same account with a FRESHER (rotated) refresh token — hand that
697
787
  // back instead of the stale stash, or the target profile logs out.
698
- d.kcAdd(aSvc, branch === "rotated" ? liveRunning : marker.targetOrigCredential);
788
+ if (!d.kcAdd(aSvc, branch === "rotated" ? liveRunning : originals.targetOrig)) {
789
+ // Put the running store back on the bound account, so the provenance
790
+ // check of a retry still sees the account it was rebound to (otherwise
791
+ // the retry would read its own credential as a foreign re-login).
792
+ const rolledBack = d.kcAdd(pSvc, liveRunning);
793
+ throw new RebindError(`could not write "${marker.boundToProfile}"'s credential back to the Keychain — restore aborted. ` +
794
+ (rolledBack
795
+ ? `Profile "${profile}" still serves "${marker.boundToProfile}"; the marker and the stashed credentials are kept — retry the restore.`
796
+ : `Profile "${profile}" could not be rolled back either; the marker and the stashed credentials are kept for manual recovery.`));
797
+ }
699
798
  if (marker.targetFileMoved && d.exists(aFile + LENT_SUFFIX)) {
700
799
  d.renameFile(aFile + LENT_SUFFIX, aFile);
701
800
  }
@@ -703,6 +802,10 @@ export async function restoreRebind(opts, d = defaultDeps()) {
703
802
  // Claude Code stamped into its `.claude.json` while it served the other account.
704
803
  await restoreStampedIdentity(pConfig, marker.runningOwnIdentity, d);
705
804
  d.removeFile(markerFile(pConfig));
805
+ if (marker.runningOwnStash)
806
+ d.kcDelete(marker.runningOwnStash);
807
+ if (marker.targetOrigStash)
808
+ d.kcDelete(marker.targetOrigStash);
706
809
  // Release the global binding-registry entry for the bound account.
707
810
  const reg = readRegistry(d);
708
811
  if (reg[marker.boundToProfile]) {
package/dist/service.js CHANGED
@@ -68,21 +68,52 @@ export function schtasksCreateArgs(exec, args, label = LABEL) {
68
68
  return ["/create", "/tn", label, "/tr", cmd, "/sc", "onlogon", "/f"];
69
69
  }
70
70
  // ---------- manifest ----------
71
- function readManifest() {
71
+ function readManifestRaw() {
72
72
  try {
73
73
  const d = JSON.parse(fs.readFileSync(MANIFEST, "utf8"));
74
- return Array.isArray(d?.files) ? d.files : [];
74
+ const inv = d?.invocation;
75
+ const invocation = inv && typeof inv.exec === "string" && Array.isArray(inv.args) ? { exec: inv.exec, args: inv.args.map(String) } : null;
76
+ return { files: Array.isArray(d?.files) ? d.files : [], invocation };
75
77
  }
76
78
  catch {
77
- return [];
79
+ return { files: [], invocation: null };
78
80
  }
79
81
  }
82
+ function readManifest() {
83
+ return readManifestRaw().files;
84
+ }
80
85
  function writeManifest(files) {
81
86
  fs.mkdirSync(ROOT, { recursive: true });
82
- fs.writeFileSync(MANIFEST, JSON.stringify({ label: LABEL, files }, null, 2) + "\n", { mode: 0o600 });
87
+ // The invocation is what `activate` compares against to re-point the service.
88
+ fs.writeFileSync(MANIFEST, JSON.stringify({ label: LABEL, files, invocation: runInvocation() }, null, 2) + "\n", { mode: 0o600 });
89
+ }
90
+ /** Whether the installed service runs this very CLI. An install from before the
91
+ * manifest recorded its invocation counts as another one. */
92
+ export function serviceTarget() {
93
+ const m = readManifestRaw();
94
+ if (!m.files.length)
95
+ return "not-installed";
96
+ const me = runInvocation();
97
+ return m.invocation && m.invocation.exec === me.exec && m.invocation.args.join("\0") === me.args.join("\0") ? "this-cli" : "other-cli";
98
+ }
99
+ /** What `update` does with the service once the new version is on disk. Our
100
+ * own service is re-installed so it restarts on the new code; one that runs
101
+ * another CLI (a dev checkout) is not ours to re-point. */
102
+ export function serviceAfterUpdate(target, interactive) {
103
+ if (target === "this-cli")
104
+ return "reinstall";
105
+ if (target === "other-cli")
106
+ return "leave";
107
+ return interactive ? "ask" : "hint";
108
+ }
109
+ /** Runs `service install` through the CLI file on disk — after a self-update
110
+ * that is the new version, while this process is still the old one. */
111
+ export function serviceInstallFromDisk() {
112
+ const { exec, args } = runInvocation();
113
+ return spawnSync(exec, [args[0], "service", "install"], { stdio: "inherit" }).status ?? 1;
83
114
  }
84
115
  // ---------- install / uninstall ----------
85
- export function serviceInstall() {
116
+ export function serviceInstall(log = console.log) {
86
117
  const { exec, args } = runInvocation();
87
118
  fs.mkdirSync(ROOT, { recursive: true });
88
119
  if (process.platform === "darwin") {
@@ -94,7 +125,7 @@ export function serviceInstall() {
94
125
  if (r.status !== 0)
95
126
  die(`launchctl load failed: ${r.stderr || r.stdout}`);
96
127
  writeManifest([plist]);
97
- console.log(`Installed LaunchAgent ${plist} (loaded).`);
128
+ log(`Installed LaunchAgent ${plist} (loaded).`);
98
129
  }
99
130
  else if (process.platform === "linux") {
100
131
  const unit = path.join(os.homedir(), ".config", "systemd", "user", "agent-switch.service");
@@ -103,16 +134,16 @@ export function serviceInstall() {
103
134
  spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
104
135
  const r = spawnSync("systemctl", ["--user", "enable", "--now", "agent-switch.service"], { encoding: "utf8" });
105
136
  if (r.status !== 0)
106
- console.log(`(note: \`systemctl --user enable --now\` returned nonzero — unit written to ${unit}; enable manually if needed)`);
137
+ log(`(note: \`systemctl --user enable --now\` returned nonzero — unit written to ${unit}; enable manually if needed)`);
107
138
  writeManifest([unit]);
108
- console.log(`Installed systemd user unit ${unit}.`);
139
+ log(`Installed systemd user unit ${unit}.`);
109
140
  }
110
141
  else if (process.platform === "win32") {
111
142
  const r = spawnSync("schtasks", schtasksCreateArgs(exec, args), { encoding: "utf8" });
112
143
  if (r.status !== 0)
113
144
  die(`schtasks /create failed: ${r.stderr || r.stdout}`);
114
145
  writeManifest([`schtasks:${LABEL}`]); // a task, not a file
115
- console.log(`Installed Task Scheduler logon task ${LABEL}.`);
146
+ log(`Installed Task Scheduler logon task ${LABEL}.`);
116
147
  }
117
148
  else {
118
149
  die(`service install is not supported on ${process.platform}`);
package/dist/sessions.js CHANGED
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * Iron rules (roadmap: road-to-session-handoff):
12
12
  * - Transcripts are OPAQUE, version-unstable blobs FOR TRANSFER. The only
13
- * read in THIS module is `readSessionHeader` — first line, capped,
13
+ * read in THIS module is `readSessionHeader` — first 64 KiB, capped,
14
14
  * try/catch. There are exactly TWO sanctioned body readers, both guarded +
15
15
  * capped + fenced + degraded-mode, and NO others: read-only TELEMETRY
16
16
  * (context/token counts, `src/telemetry.ts`, road-to-agent-switch-session-
@@ -33,9 +33,9 @@ import { liveSessionPids } from "./api.js";
33
33
  export function encodeProjectDir(cwd) {
34
34
  return cwd.replace(/[^A-Za-z0-9]/g, "-");
35
35
  }
36
- /** Best-effort metadata from a transcript's FIRST line only. The line is
37
- * capped (a >64 KiB first line counts as unparseable), the parse is fenced,
38
- * and absence degrades to nulls — the format is internal and version-unstable,
36
+ /** Best-effort metadata (cwd, summary) from the complete lines in a
37
+ * transcript's first 64 KiB. Each parse is fenced, a line cut by the cap is
38
+ * ignored, and absence degrades to nulls — the format is internal and version-unstable,
39
39
  * so a failed parse must never fail a listing. */
40
40
  const HEADER_CAP = 64 * 1024;
41
41
  export function readSessionHeader(file) {
@@ -51,15 +51,30 @@ export function readSessionHeader(file) {
51
51
  const buf = Buffer.alloc(HEADER_CAP);
52
52
  const n = fs.readSync(fd, buf, 0, HEADER_CAP, 0);
53
53
  const chunk = buf.subarray(0, n).toString("utf8");
54
- const nl = chunk.indexOf("\n");
55
- if (nl < 0 && n === HEADER_CAP)
56
- return none; // first line larger than the cap
57
- const line = nl >= 0 ? chunk.slice(0, nl) : chunk;
58
- const parsed = JSON.parse(line);
59
- return {
60
- cwd: typeof parsed?.cwd === "string" ? parsed.cwd : null,
61
- summary: typeof parsed?.summary === "string" ? parsed.summary : null,
62
- };
54
+ // Claude Code 2.1.x opens with metadata lines that carry no cwd, so scan the
55
+ // complete lines inside the cap. A trailing partial line (cut by the cap) is
56
+ // dropped rather than guessed at.
57
+ const lines = chunk.split("\n");
58
+ if (n === HEADER_CAP)
59
+ lines.pop();
60
+ let cwd = null;
61
+ let summary = null;
62
+ for (const line of lines) {
63
+ if (cwd !== null && summary !== null)
64
+ break;
65
+ let parsed;
66
+ try {
67
+ parsed = JSON.parse(line);
68
+ }
69
+ catch {
70
+ continue;
71
+ }
72
+ if (cwd === null && typeof parsed?.cwd === "string")
73
+ cwd = parsed.cwd;
74
+ if (summary === null && typeof parsed?.summary === "string")
75
+ summary = parsed.summary;
76
+ }
77
+ return { cwd, summary };
63
78
  }
64
79
  catch {
65
80
  return none;
@@ -68,6 +83,60 @@ export function readSessionHeader(file) {
68
83
  fs.closeSync(fd);
69
84
  }
70
85
  }
86
+ /** Tools that always stop for the user's answer. Other tools can't be told
87
+ * apart from one that is still running. */
88
+ const QUESTION_TOOLS = new Set(["AskUserQuestion", "ExitPlanMode"]);
89
+ const TAIL_CAP = 64 * 1024;
90
+ /** Whether the transcript ends on an open question to the user. */
91
+ export function awaitingAnswer(file) {
92
+ return openQuestion(file) !== null;
93
+ }
94
+ /**
95
+ * The id of the question the transcript ends on — an AskUserQuestion /
96
+ * ExitPlanMode call with no tool_result after it — or null. The id tells one
97
+ * question from the next, so two in a row are two moments. Reads only the
98
+ * last {@link TAIL_CAP} bytes.
99
+ */
100
+ export function openQuestion(file) {
101
+ let text;
102
+ try {
103
+ const fd = fs.openSync(file, "r");
104
+ try {
105
+ const size = fs.fstatSync(fd).size;
106
+ const len = Math.min(size, TAIL_CAP);
107
+ const buf = Buffer.alloc(len);
108
+ fs.readSync(fd, buf, 0, len, size - len);
109
+ text = buf.toString("utf8");
110
+ }
111
+ finally {
112
+ fs.closeSync(fd);
113
+ }
114
+ }
115
+ catch {
116
+ return null;
117
+ }
118
+ const open = new Set();
119
+ // The first line may be cut off by the tail window; it fails to parse and is skipped.
120
+ for (const line of text.split("\n")) {
121
+ let d;
122
+ try {
123
+ d = JSON.parse(line);
124
+ }
125
+ catch {
126
+ continue;
127
+ }
128
+ const content = d?.message?.content;
129
+ if (!Array.isArray(content))
130
+ continue;
131
+ for (const p of content) {
132
+ if (p?.type === "tool_use" && p.id && p.name && QUESTION_TOOLS.has(p.name))
133
+ open.add(p.id);
134
+ else if (p?.type === "tool_result" && p.tool_use_id)
135
+ open.delete(p.tool_use_id);
136
+ }
137
+ }
138
+ return open.size ? [...open][open.size - 1] : null;
139
+ }
71
140
  /** Recent sessions of one profile config dir, newest first, capped at `limit`.
72
141
  * Pure directory scan — works on seeded fakes, shared trees, and win32. */
73
142
  export function listSessions(configDir, limit) {
package/dist/share.js CHANGED
@@ -15,9 +15,15 @@
15
15
  * re-links (last-sync-wins across profiles). On win32, file symlinks need
16
16
  * Developer Mode/admin, so file links degrade with a message there.
17
17
  *
18
+ * - Prompt history (`history.jsonl`, what ↑ and Ctrl+R search) — always
19
+ * linked: it holds no account state, and Claude Code only ever APPENDS to
20
+ * it (under a lock), so the link never forks. A profile's own file is
21
+ * merged into the shared one (chronological, de-duplicated) and kept as a
22
+ * `history.jsonl.pre-share*` backup instead of blocking the link.
23
+ *
18
24
  * Account-/instance-scoped items (`.claude.json`, `.credentials.json`,
19
25
  * `plugins/`, `sessions/`, `ide/`, `statsig/`) are deliberately never shared.
20
- * History (`projects/`, `history.jsonl`, `plans/`) is a POSIX-only opt-in.
26
+ * Conversations (`projects/`, `plans/`) are a POSIX-only opt-in (`--history`).
21
27
  * (`plans/` defaults to `<CLAUDE_CONFIG_DIR>/plans/` in Claude Code, so plan-mode
22
28
  * files are otherwise isolated per profile — verified against claude-code 2.1.215.)
23
29
  *
@@ -34,18 +40,21 @@ const BASE_ITEMS = [
34
40
  { name: "commands", kind: "dir" },
35
41
  { name: "agents", kind: "dir" },
36
42
  ];
37
- /** Conversation history + plan-mode files; opt-in — shares `claude --resume`
43
+ /** Append-only logs shared by every `share on`, but never part of a config
44
+ * export (`SHARED_ITEMS` is that export's allowlist). */
45
+ const LOG_ITEMS = [{ name: "history.jsonl", kind: "log" }];
46
+ /** Conversations + plan-mode files; opt-in — shares `claude --resume`
38
47
  * and the plan store across accounts. `plans/` is a `dir`, so writes inside it
39
48
  * reach the source (no atomic-rename fork), exactly like `projects/`. */
40
49
  const HISTORY_ITEMS = [
41
50
  { name: "projects", kind: "dir" },
42
- { name: "history.jsonl", kind: "file" },
43
51
  { name: "plans", kind: "dir" },
44
52
  ];
45
53
  /** Names exported for messaging/tests. */
46
54
  export const SHARED_ITEMS = BASE_ITEMS.map((i) => i.name);
47
- export const HISTORY_ITEM_NAMES = HISTORY_ITEMS.map((i) => i.name);
48
- const ITEM_KIND = new Map([...BASE_ITEMS, ...HISTORY_ITEMS].map((i) => [i.name, i.kind]));
55
+ export const PROMPT_HISTORY = LOG_ITEMS[0].name;
56
+ export const HISTORY_ITEM_NAMES = [...HISTORY_ITEMS, ...LOG_ITEMS].map((i) => i.name);
57
+ const ITEM_KIND = new Map([...BASE_ITEMS, ...LOG_ITEMS, ...HISTORY_ITEMS].map((i) => [i.name, i.kind]));
49
58
  const MANIFEST = ".agent-switch-shared.json";
50
59
  function readManifest(dir) {
51
60
  try {
@@ -72,6 +81,59 @@ function link(src, dst, kind) {
72
81
  fs.symlinkSync(src, dst, process.platform === "win32" ? "file" : undefined);
73
82
  }
74
83
  }
84
+ const stampOf = (l) => {
85
+ try {
86
+ const t = JSON.parse(l)?.timestamp;
87
+ return typeof t === "number" && Number.isFinite(t) ? t : null;
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ };
93
+ /**
94
+ * Merge the lines of `extra` into the prompt history at `src`: de-duplicated by
95
+ * exact line, ordered by timestamp (Claude Code reads the file newest-last, so
96
+ * ↑ walks back in time). Unparseable lines keep their place after their
97
+ * predecessor. Written via temp + rename; re-read when the source grew while we
98
+ * merged, so a concurrent append is not lost. Returns the lines added.
99
+ */
100
+ export function mergePromptHistory(src, extra) {
101
+ const linesOf = (f) => fs.existsSync(f) ? fs.readFileSync(f, "utf8").split("\n").filter((l) => l.trim() !== "") : [];
102
+ const incoming = linesOf(extra);
103
+ for (let attempt = 0;; attempt++) {
104
+ const before = fs.existsSync(src) ? fs.statSync(src).size : -1;
105
+ const own = linesOf(src);
106
+ const seen = new Set(own);
107
+ const added = incoming.filter((l) => !seen.has(l) && (seen.add(l), true));
108
+ if (added.length === 0)
109
+ return 0;
110
+ let last = 0;
111
+ const keyed = [...own, ...added].map((l, i) => {
112
+ last = stampOf(l) ?? last;
113
+ return { l, i, t: last };
114
+ });
115
+ keyed.sort((a, b) => a.t - b.t || a.i - b.i);
116
+ const tmp = `${src}.agent-switch-merge`;
117
+ fs.writeFileSync(tmp, keyed.map((k) => k.l).join("\n") + "\n", { mode: 0o600 });
118
+ const after = fs.existsSync(src) ? fs.statSync(src).size : -1;
119
+ if (after !== before && attempt < 3) {
120
+ fs.unlinkSync(tmp);
121
+ continue;
122
+ }
123
+ fs.renameSync(tmp, src);
124
+ return added.length;
125
+ }
126
+ }
127
+ /** Merge a profile's own prompt history into the shared one, keep the original
128
+ * as a backup next to it, and leave the slot free for the link. */
129
+ function adoptLog(src, dst, name) {
130
+ const n = mergePromptHistory(src, dst);
131
+ let backup = `${dst}.pre-share`;
132
+ if (fs.existsSync(backup))
133
+ backup = `${backup}-${Date.now()}`;
134
+ fs.renameSync(dst, backup);
135
+ return `merged ${n} prompt(s) from ${name} (original kept as ${path.basename(backup)})`;
136
+ }
75
137
  /** True when the existing symlink already points at src (junction paths on
76
138
  * win32 may carry a trailing separator / `\\?\` prefix, so compare loosely). */
77
139
  function pointsAt(dst, src) {
@@ -108,14 +170,12 @@ export function settingsSharingRisk(settingsPath) {
108
170
  /** Link items from sourceDir into targetDir. Returns human-readable actions. */
109
171
  export function applySharing(sourceDir, targetDir, withHistory) {
110
172
  const actions = [];
111
- const items = withHistory ? [...BASE_ITEMS, ...HISTORY_ITEMS] : [...BASE_ITEMS];
173
+ const items = withHistory ? [...BASE_ITEMS, ...LOG_ITEMS, ...HISTORY_ITEMS] : [...BASE_ITEMS, ...LOG_ITEMS];
112
174
  const managed = new Set(readManifest(targetDir));
113
175
  fs.mkdirSync(targetDir, { recursive: true, mode: 0o700 });
114
176
  for (const item of items) {
115
177
  const src = path.join(sourceDir, item.name);
116
178
  const dst = path.join(targetDir, item.name);
117
- if (!fs.existsSync(src))
118
- continue;
119
179
  let st = null;
120
180
  try {
121
181
  st = fs.lstatSync(dst);
@@ -123,6 +183,12 @@ export function applySharing(sourceDir, targetDir, withHistory) {
123
183
  catch {
124
184
  /* absent */
125
185
  }
186
+ // The shared prompt history starts empty when the source never had one.
187
+ if (item.kind === "log" && !fs.existsSync(src) && process.platform !== "win32") {
188
+ fs.writeFileSync(src, "", { mode: 0o600 });
189
+ }
190
+ if (!fs.existsSync(src))
191
+ continue;
126
192
  if (st?.isSymbolicLink()) {
127
193
  if (pointsAt(dst, src)) {
128
194
  managed.add(item.name);
@@ -136,14 +202,19 @@ export function applySharing(sourceDir, targetDir, withHistory) {
136
202
  }
137
203
  else if (st) {
138
204
  // A regular file/dir sits where a link would go.
139
- if (item.kind === "file" && managed.has(item.name)) {
205
+ if (item.kind === "log" && st.isFile() && process.platform !== "win32") {
206
+ actions.push(adoptLog(src, dst, item.name));
207
+ }
208
+ else if (item.kind === "file" && managed.has(item.name)) {
140
209
  // We linked it before; it's now a regular file → an in-profile write
141
210
  // forked it. Don't clobber — `share sync` reconciles it.
142
211
  actions.push(`forked ${item.name}: run 'agent-switch share sync' to re-link`);
143
212
  continue;
144
213
  }
145
- actions.push(`skipped ${item.name}: profile has its own copy (remove it to share)`);
146
- continue;
214
+ else {
215
+ actions.push(`skipped ${item.name}: profile has its own copy (remove it to share)`);
216
+ continue;
217
+ }
147
218
  }
148
219
  if (item.name === "settings.json") {
149
220
  const risk = settingsSharingRisk(src);
@@ -158,7 +229,7 @@ export function applySharing(sourceDir, targetDir, withHistory) {
158
229
  actions.push(`linked ${item.name}`);
159
230
  }
160
231
  catch (err) {
161
- if (process.platform === "win32" && item.kind === "file") {
232
+ if (process.platform === "win32" && item.kind !== "dir") {
162
233
  actions.push(`skipped ${item.name}: Windows file symlinks need Developer Mode or admin ` +
163
234
  `(directories are shared via junction regardless)`);
164
235
  }
@@ -199,7 +270,11 @@ export function syncSharing(sourceDir, targetDir) {
199
270
  }
200
271
  if (st.isSymbolicLink())
201
272
  continue; // intact
202
- if (st.isFile()) {
273
+ if (st.isFile() && kind === "log") {
274
+ actions.push(`${adoptLog(src, dst, name)}, re-linked`);
275
+ link(src, dst, kind);
276
+ }
277
+ else if (st.isFile()) {
203
278
  // Forked file: propagate the profile's edit to the source, then re-link.
204
279
  if (name === "settings.json") {
205
280
  const risk = settingsSharingRisk(dst);
@@ -244,7 +319,11 @@ export function removeSharing(targetDir) {
244
319
  // A symlink (POSIX) or a junction (win32, reported as a symlink by
245
320
  // lstat) is ours to remove; a forked regular file is the user's, leave it.
246
321
  if (st.isSymbolicLink()) {
322
+ const shared = ITEM_KIND.get(name) === "log" && fs.existsSync(dst) ? fs.realpathSync(dst) : null;
247
323
  fs.unlinkSync(dst);
324
+ // Unsharing must not empty a profile's prompt history: it keeps a copy.
325
+ if (shared)
326
+ fs.copyFileSync(shared, dst);
248
327
  actions.push(`unlinked ${name}`);
249
328
  }
250
329
  }