@event4u/agent-switch 2.0.0 → 2.2.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 +170 -68
- package/dist/activity.js +97 -0
- package/dist/args.js +23 -3
- package/dist/auth-status.js +149 -0
- package/dist/balance.js +52 -0
- package/dist/browsers.js +90 -0
- package/dist/config-transfer.js +12 -2
- package/dist/daemon.js +89 -38
- package/dist/desktop-sessions.js +115 -0
- package/dist/dir-resolve.js +151 -0
- package/dist/doctor.js +209 -52
- package/dist/gui-settings.js +67 -0
- package/dist/hooks.js +21 -0
- package/dist/index.js +921 -71
- package/dist/mappings.js +48 -9
- package/dist/pools.js +144 -0
- package/dist/profiles.js +84 -5
- package/dist/protection.js +93 -0
- package/dist/providers.js +3 -0
- package/dist/rebind.js +108 -11
- package/dist/release-notes.js +154 -0
- package/dist/service.js +24 -9
- package/dist/sessions.js +101 -13
- package/dist/share.js +92 -13
- package/dist/shellenv.js +131 -18
- package/dist/single-load.js +176 -0
- package/dist/tooling.js +73 -0
- package/dist/updates.js +37 -3
- 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 +
|
|
@@ -365,15 +366,30 @@ export function readBinding(cfgDir, d = defaultDeps()) {
|
|
|
365
366
|
return null;
|
|
366
367
|
try {
|
|
367
368
|
const m = JSON.parse(raw);
|
|
368
|
-
if (m
|
|
369
|
+
if (!m || typeof m.boundToProfile !== "string")
|
|
370
|
+
return null;
|
|
371
|
+
const stashed = typeof m.runningOwnStash === "string" && typeof m.targetOrigStash === "string";
|
|
372
|
+
const legacy = typeof m.runningOwnCredential === "string" && typeof m.targetOrigCredential === "string";
|
|
373
|
+
if (stashed || legacy)
|
|
369
374
|
return m;
|
|
370
|
-
}
|
|
371
375
|
}
|
|
372
376
|
catch {
|
|
373
377
|
/* corrupt marker → treat as none */
|
|
374
378
|
}
|
|
375
379
|
return null;
|
|
376
380
|
}
|
|
381
|
+
/** Keychain services that hold a binding's originals while the rebind is live. */
|
|
382
|
+
function stashServices(pConfig, d) {
|
|
383
|
+
const base = d.serviceNameFor(pConfig);
|
|
384
|
+
return { own: `${base}-agent-switch-rebind-own`, target: `${base}-agent-switch-rebind-target` };
|
|
385
|
+
}
|
|
386
|
+
/** The two original credentials a binding needs for restore — from the Keychain
|
|
387
|
+
* stash, or inline for a marker written before D16. Null when unreadable. */
|
|
388
|
+
export function bindingCredentials(m, d = defaultDeps()) {
|
|
389
|
+
const runningOwn = m.runningOwnStash ? d.kcGet(m.runningOwnStash) : (m.runningOwnCredential ?? null);
|
|
390
|
+
const targetOrig = m.targetOrigStash ? d.kcGet(m.targetOrigStash) : (m.targetOrigCredential ?? null);
|
|
391
|
+
return runningOwn && targetOrig ? { runningOwn, targetOrig } : null;
|
|
392
|
+
}
|
|
377
393
|
export class RebindError extends Error {
|
|
378
394
|
}
|
|
379
395
|
/**
|
|
@@ -615,12 +631,21 @@ async function performRebind(opts, d) {
|
|
|
615
631
|
// All guards passed — commit the global binding record.
|
|
616
632
|
reg[account] = { runningProfile: profile, boundAt: new Date(d.now()).toISOString() };
|
|
617
633
|
writeRegistry(reg, d);
|
|
618
|
-
// 1. Recovery point BEFORE any store mutation: stash both originals
|
|
634
|
+
// 1. Recovery point BEFORE any store mutation: stash both originals in the
|
|
635
|
+
// Keychain (never in the marker file), then point the marker at them.
|
|
636
|
+
const stash = stashServices(pConfig, d);
|
|
637
|
+
if (!d.kcAdd(stash.own, pCred) || !d.kcAdd(stash.target, credToMove)) {
|
|
638
|
+
d.kcDelete(stash.own);
|
|
639
|
+
d.kcDelete(stash.target);
|
|
640
|
+
delete reg[account];
|
|
641
|
+
writeRegistry(reg, d);
|
|
642
|
+
throw new RebindError(`could not stash the original credentials in the Keychain — no swap performed.`);
|
|
643
|
+
}
|
|
619
644
|
const marker = {
|
|
620
645
|
boundToProfile: account,
|
|
621
646
|
boundAt: new Date(d.now()).toISOString(),
|
|
622
|
-
|
|
623
|
-
|
|
647
|
+
runningOwnStash: stash.own,
|
|
648
|
+
targetOrigStash: stash.target,
|
|
624
649
|
targetFileMoved: false,
|
|
625
650
|
// Snapshot the running profile's OWN identity stamp before the swap: from
|
|
626
651
|
// here on Claude Code will overwrite it with the borrowed account's.
|
|
@@ -648,6 +673,57 @@ async function performRebind(opts, d) {
|
|
|
648
673
|
`Restore with: agent-switch rebind --restore --profile ${profile}`,
|
|
649
674
|
};
|
|
650
675
|
}
|
|
676
|
+
/**
|
|
677
|
+
* Drop an active rebind on `profile` WITHOUT restoring it — the `remove --force`
|
|
678
|
+
* path, where the running profile is about to be deleted. Deletes both Keychain
|
|
679
|
+
* stash entries (they would otherwise outlive the profile), releases the
|
|
680
|
+
* registry entry and the marker, and hands the borrowed account back to its own
|
|
681
|
+
* profile: the stashed original goes back into that store only while it is
|
|
682
|
+
* empty (a store that holds a login again is never overwritten), and a lent
|
|
683
|
+
* plaintext file is returned. Returns false when there was no rebind.
|
|
684
|
+
*/
|
|
685
|
+
export function discardBinding(profile, d = defaultDeps()) {
|
|
686
|
+
const pConfig = configDir("claude", profile);
|
|
687
|
+
const marker = readBinding(pConfig, d);
|
|
688
|
+
if (!marker)
|
|
689
|
+
return false;
|
|
690
|
+
const originals = bindingCredentials(marker, d);
|
|
691
|
+
const aConfig = configDir("claude", marker.boundToProfile);
|
|
692
|
+
const aSvc = d.serviceNameFor(aConfig);
|
|
693
|
+
if (originals && d.kcGet(aSvc) === null)
|
|
694
|
+
d.kcAdd(aSvc, originals.targetOrig);
|
|
695
|
+
const aFile = credFile(aConfig);
|
|
696
|
+
if (marker.targetFileMoved && d.exists(aFile + LENT_SUFFIX) && !d.exists(aFile))
|
|
697
|
+
d.renameFile(aFile + LENT_SUFFIX, aFile);
|
|
698
|
+
const stash = stashServices(pConfig, d);
|
|
699
|
+
d.kcDelete(marker.runningOwnStash ?? stash.own);
|
|
700
|
+
d.kcDelete(marker.targetOrigStash ?? stash.target);
|
|
701
|
+
const reg = readRegistry(d);
|
|
702
|
+
if (reg[marker.boundToProfile]?.runningProfile === profile) {
|
|
703
|
+
delete reg[marker.boundToProfile];
|
|
704
|
+
writeRegistry(reg, d);
|
|
705
|
+
}
|
|
706
|
+
d.removeFile(markerFile(pConfig));
|
|
707
|
+
return true;
|
|
708
|
+
}
|
|
709
|
+
/** The profile currently serving `account`'s login through a rebind, or null. */
|
|
710
|
+
export function borrowerOf(account, d = defaultDeps()) {
|
|
711
|
+
return readRegistry(d)[account]?.runningProfile ?? null;
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Before `account`'s profile is deleted (`remove --force`): put the profile
|
|
715
|
+
* borrowing it back on its own login through the normal restore path, so both
|
|
716
|
+
* stores stay consistent and the stash is cleared. Restore's provenance checks
|
|
717
|
+
* still apply — a refusal propagates and nothing is released. Returns the
|
|
718
|
+
* borrower that was restored, or null when nothing borrowed the account.
|
|
719
|
+
*/
|
|
720
|
+
export async function releaseBorrowedAccount(account, d = defaultDeps()) {
|
|
721
|
+
const holder = borrowerOf(account, d);
|
|
722
|
+
if (!holder)
|
|
723
|
+
return null;
|
|
724
|
+
await restoreRebind({ profile: holder }, d);
|
|
725
|
+
return holder;
|
|
726
|
+
}
|
|
651
727
|
/** Reverse an active rebind on `profile`: restore both profiles' own credentials. */
|
|
652
728
|
export async function restoreRebind(opts, d = defaultDeps()) {
|
|
653
729
|
const { profile } = opts;
|
|
@@ -658,6 +734,11 @@ export async function restoreRebind(opts, d = defaultDeps()) {
|
|
|
658
734
|
const marker = readBinding(pConfig, d);
|
|
659
735
|
if (!marker)
|
|
660
736
|
throw new RebindRefused(`no active rebind on profile "${profile}".`);
|
|
737
|
+
const originals = bindingCredentials(marker, d);
|
|
738
|
+
if (!originals) {
|
|
739
|
+
throw new RebindRefused(`the stashed credentials for profile "${profile}"'s rebind are not readable — refusing restore (no mutation). ` +
|
|
740
|
+
`The binding marker is left in place for manual recovery.`);
|
|
741
|
+
}
|
|
661
742
|
const aConfig = configDir("claude", marker.boundToProfile);
|
|
662
743
|
const pSvc = d.serviceNameFor(pConfig);
|
|
663
744
|
const aSvc = d.serviceNameFor(aConfig);
|
|
@@ -672,7 +753,7 @@ export async function restoreRebind(opts, d = defaultDeps()) {
|
|
|
672
753
|
// account we bound INTO it. If someone re-logged the running profile, its
|
|
673
754
|
// store now holds a DIFFERENT account — never clobber that fresh login.
|
|
674
755
|
const liveRunning = readCred(pConfig, d);
|
|
675
|
-
const branch = await compareAccounts(
|
|
756
|
+
const branch = await compareAccounts(originals.targetOrig, liveRunning, d);
|
|
676
757
|
if (branch === "unparseable") {
|
|
677
758
|
throw new RebindRefused(`running profile "${profile}"'s live credential is missing or unparseable — refusing restore (no mutation). ` +
|
|
678
759
|
`The binding marker is left in place for manual recovery.`);
|
|
@@ -691,11 +772,23 @@ export async function restoreRebind(opts, d = defaultDeps()) {
|
|
|
691
772
|
}
|
|
692
773
|
// "same" / "rotated" → the bound account is still there → restore normally.
|
|
693
774
|
// Restore the running profile to its own account.
|
|
694
|
-
d.kcAdd(pSvc,
|
|
775
|
+
if (!d.kcAdd(pSvc, originals.runningOwn)) {
|
|
776
|
+
throw new RebindError(`could not write profile "${profile}"'s own credential back to the Keychain — restore aborted. ` +
|
|
777
|
+
`Nothing was changed; the marker and the stashed credentials are kept — retry the restore.`);
|
|
778
|
+
}
|
|
695
779
|
// Restore the target profile's own store. On "rotated" the live credential
|
|
696
780
|
// is the same account with a FRESHER (rotated) refresh token — hand that
|
|
697
781
|
// back instead of the stale stash, or the target profile logs out.
|
|
698
|
-
d.kcAdd(aSvc, branch === "rotated" ? liveRunning :
|
|
782
|
+
if (!d.kcAdd(aSvc, branch === "rotated" ? liveRunning : originals.targetOrig)) {
|
|
783
|
+
// Put the running store back on the bound account, so the provenance
|
|
784
|
+
// check of a retry still sees the account it was rebound to (otherwise
|
|
785
|
+
// the retry would read its own credential as a foreign re-login).
|
|
786
|
+
const rolledBack = d.kcAdd(pSvc, liveRunning);
|
|
787
|
+
throw new RebindError(`could not write "${marker.boundToProfile}"'s credential back to the Keychain — restore aborted. ` +
|
|
788
|
+
(rolledBack
|
|
789
|
+
? `Profile "${profile}" still serves "${marker.boundToProfile}"; the marker and the stashed credentials are kept — retry the restore.`
|
|
790
|
+
: `Profile "${profile}" could not be rolled back either; the marker and the stashed credentials are kept for manual recovery.`));
|
|
791
|
+
}
|
|
699
792
|
if (marker.targetFileMoved && d.exists(aFile + LENT_SUFFIX)) {
|
|
700
793
|
d.renameFile(aFile + LENT_SUFFIX, aFile);
|
|
701
794
|
}
|
|
@@ -703,6 +796,10 @@ export async function restoreRebind(opts, d = defaultDeps()) {
|
|
|
703
796
|
// Claude Code stamped into its `.claude.json` while it served the other account.
|
|
704
797
|
await restoreStampedIdentity(pConfig, marker.runningOwnIdentity, d);
|
|
705
798
|
d.removeFile(markerFile(pConfig));
|
|
799
|
+
if (marker.runningOwnStash)
|
|
800
|
+
d.kcDelete(marker.runningOwnStash);
|
|
801
|
+
if (marker.targetOrigStash)
|
|
802
|
+
d.kcDelete(marker.targetOrigStash);
|
|
706
803
|
// Release the global binding-registry entry for the bound account.
|
|
707
804
|
const reg = readRegistry(d);
|
|
708
805
|
if (reg[marker.boundToProfile]) {
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release-note normalisation — turn a GitHub release body into something a
|
|
3
|
+
* card or a terminal can show without it reading as broken.
|
|
4
|
+
*
|
|
5
|
+
* Mirrored in `gui/src/release-notes.ts` (same boundary reason as updates.ts:
|
|
6
|
+
* the GUI's tsconfig only includes `gui/src`, so pure helpers are duplicated
|
|
7
|
+
* rather than imported across it). `tests/release-notes.test.ts` byte-compares
|
|
8
|
+
* the two below their header comment, so a fix applied to one and not the other
|
|
9
|
+
* fails there — and it asserts against the real captured body
|
|
10
|
+
* (`tests/fixtures/agent-config-release-14.2.0.md`), which the GUI mirror's own
|
|
11
|
+
* suite cannot read: that project ships no `@types/node` on purpose.
|
|
12
|
+
*
|
|
13
|
+
* Why a normaliser and not `body.slice(0, 500)`: measured on agent-config
|
|
14
|
+
* 14.2.0, the first 500 characters are a heading, an HTML comment addressed to
|
|
15
|
+
* the release author, and five `_none_` placeholders. The shapes handled here
|
|
16
|
+
* are the ones that release template actually emits — headings, `*` items with
|
|
17
|
+
* a `**scope:**` prefix, `([sha](commit-url))` suffixes, `**bold**` / `_italic_`
|
|
18
|
+
* and horizontal rules. Everything is pure; no I/O, no markdown dependency.
|
|
19
|
+
*/
|
|
20
|
+
/** Drop HTML comments, including multi-line ones. This is what removes the
|
|
21
|
+
* release template's author-facing instruction line ("fill before merge …") —
|
|
22
|
+
* a comment is invisible on GitHub and must stay invisible here. */
|
|
23
|
+
export function stripHtmlComments(md) {
|
|
24
|
+
return String(md).replace(/<!--[\s\S]*?-->/g, "");
|
|
25
|
+
}
|
|
26
|
+
/** Inline cleanup applied to every line's text: commit-link suffixes go, real
|
|
27
|
+
* links collapse to their label, bold/italic markers are unwrapped, and runs
|
|
28
|
+
* of whitespace collapse. Inline code keeps its backticks on purpose — they
|
|
29
|
+
* mark a literal name, which is information, not noise. Pure. */
|
|
30
|
+
export function normalizeInline(s) {
|
|
31
|
+
return String(s)
|
|
32
|
+
// ` ([abc1234](https://…/commit/abc…))` — the generated changelog's sha
|
|
33
|
+
// suffix. Removed before generic link handling, which would otherwise
|
|
34
|
+
// leave a bare `(abc1234)` behind.
|
|
35
|
+
.replace(/\s*\(\[[0-9a-f]{7,40}\]\([^)\s]*\)\)/gi, "")
|
|
36
|
+
// `[label](url)` → `label`
|
|
37
|
+
.replace(/\[([^\]]*)\]\([^)\s]*\)/g, "$1")
|
|
38
|
+
// `**bold**` / `__bold__` → bold
|
|
39
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
40
|
+
.replace(/__([^_]+)__/g, "$1")
|
|
41
|
+
// `_italic_` → italic (this is what turns `_none_` into `none`).
|
|
42
|
+
// Underscore-delimited only at word boundaries so `snake_case_names` and
|
|
43
|
+
// `file_name.ts` survive untouched.
|
|
44
|
+
.replace(/(^|[\s(])_([^_]+)_(?=[\s).,;:!?]|$)/g, "$1$2")
|
|
45
|
+
.replace(/[ \t]{2,}/g, " ")
|
|
46
|
+
.trim();
|
|
47
|
+
}
|
|
48
|
+
/** True for a markdown horizontal rule (`---`, `***`, `___`). */
|
|
49
|
+
function isHorizontalRule(line) {
|
|
50
|
+
return /^\s*([-*_])\s*(\1\s*){2,}$/.test(line);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Parse a release body into ordered blocks. Blank lines carry no block — the
|
|
54
|
+
* renderers space by `kind` instead, so a body with erratic blank runs comes
|
|
55
|
+
* out evenly spaced. An empty or comment-only body yields `[]`, which every
|
|
56
|
+
* caller must treat as "no notes" (render nothing — never a placeholder).
|
|
57
|
+
*/
|
|
58
|
+
export function releaseNoteBlocks(md) {
|
|
59
|
+
const blocks = [];
|
|
60
|
+
for (const raw of stripHtmlComments(md).split(/\r?\n/)) {
|
|
61
|
+
if (!raw.trim() || isHorizontalRule(raw))
|
|
62
|
+
continue;
|
|
63
|
+
const heading = /^\s*#{1,6}\s+(.*)$/.exec(raw);
|
|
64
|
+
if (heading) {
|
|
65
|
+
const text = normalizeInline(heading[1]);
|
|
66
|
+
if (text)
|
|
67
|
+
blocks.push({ kind: "heading", text });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const item = /^\s*[-*+]\s+(.*)$/.exec(raw);
|
|
71
|
+
if (item) {
|
|
72
|
+
const text = normalizeInline(item[1]);
|
|
73
|
+
if (text)
|
|
74
|
+
blocks.push({ kind: "item", text });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const text = normalizeInline(raw);
|
|
78
|
+
if (text)
|
|
79
|
+
blocks.push({ kind: "text", text });
|
|
80
|
+
}
|
|
81
|
+
return blocks;
|
|
82
|
+
}
|
|
83
|
+
/** A `_none_`-style empty value — the release template's way of saying "this
|
|
84
|
+
* section genuinely has nothing", which must not be offered as a highlight. */
|
|
85
|
+
function isEmptyValue(value) {
|
|
86
|
+
return /^(none|n\/a|-|—)$/i.test(value.trim());
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The one line worth putting in a notification: the first non-empty bullet
|
|
90
|
+
* under a "highlights"-ish heading, else the first bullet in the body. Returns
|
|
91
|
+
* null when the body carries no bullet with content — callers then say nothing
|
|
92
|
+
* rather than inventing a summary. Pure.
|
|
93
|
+
*/
|
|
94
|
+
export function firstHighlight(blocks) {
|
|
95
|
+
const start = blocks.findIndex((b) => b.kind === "heading" && /highlight/i.test(b.text));
|
|
96
|
+
const scan = (from, stopAtHeading) => {
|
|
97
|
+
for (let i = from; i < blocks.length; i++) {
|
|
98
|
+
const b = blocks[i];
|
|
99
|
+
if (b.kind === "heading" && stopAtHeading && i > from)
|
|
100
|
+
return null;
|
|
101
|
+
if (b.kind !== "item")
|
|
102
|
+
continue;
|
|
103
|
+
// `Behaviour changes: none` → value `none`; a bullet with no colon is
|
|
104
|
+
// its own value.
|
|
105
|
+
const colon = b.text.indexOf(":");
|
|
106
|
+
const value = colon >= 0 ? b.text.slice(colon + 1) : b.text;
|
|
107
|
+
if (!isEmptyValue(value))
|
|
108
|
+
return b.text;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
};
|
|
112
|
+
return start >= 0 ? (scan(start + 1, true) ?? scan(0, false)) : scan(0, false);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Render blocks as terminal lines: a blank line before each heading (never as
|
|
116
|
+
* the first line), items bulleted, text as-is. `omitted` is how many lines the
|
|
117
|
+
* cap dropped, so the caller can print an honest "… N more" plus the release
|
|
118
|
+
* URL instead of silently truncating. Pure.
|
|
119
|
+
*/
|
|
120
|
+
export function releaseNotesText(blocks, opts = {}) {
|
|
121
|
+
const bullet = opts.bullet ?? "•";
|
|
122
|
+
const indent = opts.indent ?? "";
|
|
123
|
+
const all = [];
|
|
124
|
+
for (const b of blocks) {
|
|
125
|
+
if (b.kind === "heading") {
|
|
126
|
+
if (all.length)
|
|
127
|
+
all.push("");
|
|
128
|
+
all.push(`${indent}${b.text}`);
|
|
129
|
+
}
|
|
130
|
+
else if (b.kind === "item") {
|
|
131
|
+
all.push(`${indent} ${bullet} ${b.text}`);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
all.push(`${indent} ${b.text}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const max = opts.maxLines;
|
|
138
|
+
if (max === undefined || all.length <= max)
|
|
139
|
+
return { lines: all, omitted: 0 };
|
|
140
|
+
return { lines: all.slice(0, max), omitted: all.length - max };
|
|
141
|
+
}
|
|
142
|
+
/** Clamp to `max` characters on a word boundary, appending an ellipsis when
|
|
143
|
+
* anything was cut. Used for the one-line notification summary, where a raw
|
|
144
|
+
* bullet can run to several hundred characters. Pure. */
|
|
145
|
+
export function clampText(s, max) {
|
|
146
|
+
const t = String(s).trim();
|
|
147
|
+
if (t.length <= max)
|
|
148
|
+
return t;
|
|
149
|
+
const cut = t.slice(0, max);
|
|
150
|
+
const space = cut.lastIndexOf(" ");
|
|
151
|
+
// Prefer the word boundary, but only when it still keeps at least half the
|
|
152
|
+
// budget — otherwise a long first word would collapse the summary to nothing.
|
|
153
|
+
return `${(space > max * 0.5 ? cut.slice(0, space) : cut).replace(/[\s,;:.\-]+$/, "")}…`;
|
|
154
|
+
}
|
package/dist/service.js
CHANGED
|
@@ -68,21 +68,36 @@ 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";
|
|
83
98
|
}
|
|
84
99
|
// ---------- install / uninstall ----------
|
|
85
|
-
export function serviceInstall() {
|
|
100
|
+
export function serviceInstall(log = console.log) {
|
|
86
101
|
const { exec, args } = runInvocation();
|
|
87
102
|
fs.mkdirSync(ROOT, { recursive: true });
|
|
88
103
|
if (process.platform === "darwin") {
|
|
@@ -94,7 +109,7 @@ export function serviceInstall() {
|
|
|
94
109
|
if (r.status !== 0)
|
|
95
110
|
die(`launchctl load failed: ${r.stderr || r.stdout}`);
|
|
96
111
|
writeManifest([plist]);
|
|
97
|
-
|
|
112
|
+
log(`Installed LaunchAgent ${plist} (loaded).`);
|
|
98
113
|
}
|
|
99
114
|
else if (process.platform === "linux") {
|
|
100
115
|
const unit = path.join(os.homedir(), ".config", "systemd", "user", "agent-switch.service");
|
|
@@ -103,16 +118,16 @@ export function serviceInstall() {
|
|
|
103
118
|
spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
|
|
104
119
|
const r = spawnSync("systemctl", ["--user", "enable", "--now", "agent-switch.service"], { encoding: "utf8" });
|
|
105
120
|
if (r.status !== 0)
|
|
106
|
-
|
|
121
|
+
log(`(note: \`systemctl --user enable --now\` returned nonzero — unit written to ${unit}; enable manually if needed)`);
|
|
107
122
|
writeManifest([unit]);
|
|
108
|
-
|
|
123
|
+
log(`Installed systemd user unit ${unit}.`);
|
|
109
124
|
}
|
|
110
125
|
else if (process.platform === "win32") {
|
|
111
126
|
const r = spawnSync("schtasks", schtasksCreateArgs(exec, args), { encoding: "utf8" });
|
|
112
127
|
if (r.status !== 0)
|
|
113
128
|
die(`schtasks /create failed: ${r.stderr || r.stdout}`);
|
|
114
129
|
writeManifest([`schtasks:${LABEL}`]); // a task, not a file
|
|
115
|
-
|
|
130
|
+
log(`Installed Task Scheduler logon task ${LABEL}.`);
|
|
116
131
|
}
|
|
117
132
|
else {
|
|
118
133
|
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) {
|
|
@@ -174,6 +243,25 @@ export function markLive(configDir, rows, cwdOf = pidCwd) {
|
|
|
174
243
|
row.live = true;
|
|
175
244
|
}
|
|
176
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* Auto-detect which Claude profile has a live session, for `rebind`'s
|
|
248
|
+
* no-`--profile` resolution. A profile is "running" when its config dir carries
|
|
249
|
+
* a live session pid (`<configDir>/sessions/<pid>.json` whose pid is alive).
|
|
250
|
+
*
|
|
251
|
+
* Returns `single` ONLY when exactly one profile is running — rebind writes a
|
|
252
|
+
* credential store, so the caller must never guess a target across ambiguity;
|
|
253
|
+
* `none` / `ambiguous` leave the caller on its explicit-`--profile` / active
|
|
254
|
+
* fallback. Pure (`livePidsOf` injected) so every branch is unit-tested without
|
|
255
|
+
* touching real processes.
|
|
256
|
+
*/
|
|
257
|
+
export function detectRunningClaudeProfile(profiles, configDirOf, livePidsOf = liveSessionPids) {
|
|
258
|
+
const running = profiles.filter((p) => livePidsOf(configDirOf(p)).length > 0);
|
|
259
|
+
if (running.length === 1)
|
|
260
|
+
return { kind: "single", profile: running[0] };
|
|
261
|
+
if (running.length > 1)
|
|
262
|
+
return { kind: "ambiguous", profiles: running };
|
|
263
|
+
return { kind: "none" };
|
|
264
|
+
}
|
|
177
265
|
/** Where a session id lives inside ONE profile config dir, or null. */
|
|
178
266
|
export function locateSession(configDir, sessionId) {
|
|
179
267
|
const projects = path.join(configDir, "projects");
|