@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/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 +
@@ -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 && typeof m.boundToProfile === "string" && typeof m.runningOwnCredential === "string" && typeof m.targetOrigCredential === "string") {
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
- runningOwnCredential: pCred,
623
- targetOrigCredential: credToMove,
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(marker.targetOrigCredential, liveRunning, d);
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, marker.runningOwnCredential);
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 : marker.targetOrigCredential);
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 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";
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
- console.log(`Installed LaunchAgent ${plist} (loaded).`);
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
- console.log(`(note: \`systemctl --user enable --now\` returned nonzero — unit written to ${unit}; enable manually if needed)`);
121
+ log(`(note: \`systemctl --user enable --now\` returned nonzero — unit written to ${unit}; enable manually if needed)`);
107
122
  writeManifest([unit]);
108
- console.log(`Installed systemd user unit ${unit}.`);
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
- console.log(`Installed Task Scheduler logon task ${LABEL}.`);
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 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) {
@@ -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");