@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/README.md +166 -73
- package/dist/activity.js +99 -0
- package/dist/args.js +23 -3
- package/dist/auth-status.js +159 -0
- package/dist/balance.js +55 -0
- package/dist/browsers.js +90 -0
- package/dist/daemon.js +95 -42
- package/dist/desktop-sessions.js +115 -0
- package/dist/desktop-settings.js +90 -0
- package/dist/dir-resolve.js +178 -0
- package/dist/doctor.js +239 -69
- package/dist/gui-settings.js +67 -0
- package/dist/history.js +20 -0
- package/dist/hooks.js +21 -0
- package/dist/index.js +879 -68
- package/dist/launchers.js +228 -0
- package/dist/locks.js +61 -0
- package/dist/mappings.js +48 -9
- package/dist/pools.js +157 -0
- package/dist/profiles.js +181 -67
- package/dist/protection.js +100 -0
- package/dist/rebind.js +115 -12
- package/dist/service.js +40 -9
- package/dist/sessions.js +82 -13
- package/dist/share.js +92 -13
- package/dist/shellenv.js +122 -21
- package/dist/single-load.js +5 -0
- package/dist/token-freshen.js +11 -0
- package/package.json +1 -1
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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
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
|
-
|
|
623
|
-
|
|
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(
|
|
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,
|
|
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 :
|
|
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
|
|
71
|
+
function readManifestRaw() {
|
|
72
72
|
try {
|
|
73
73
|
const d = JSON.parse(fs.readFileSync(MANIFEST, "utf8"));
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
+
log(`(note: \`systemctl --user enable --now\` returned nonzero — unit written to ${unit}; enable manually if needed)`);
|
|
107
138
|
writeManifest([unit]);
|
|
108
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
37
|
-
*
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
48
|
-
const
|
|
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 === "
|
|
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
|
-
|
|
146
|
-
|
|
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
|
|
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
|
}
|