@idl3/claude-control 1.1.0 → 1.4.3

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.
@@ -12,6 +12,20 @@ import os from 'node:os';
12
12
 
13
13
  const PANES_DIR = path.join(os.homedir(), '.claude-control', 'panes');
14
14
 
15
+ /**
16
+ * Reset GC state. Exported FOR TESTS ONLY; current GC is transcript-existence
17
+ * based and has no mutable state, so this is intentionally a no-op.
18
+ */
19
+ export function _resetGcStateForTest() {
20
+ // no-op
21
+ }
22
+
23
+ /** %5 → "5"; tolerate any tmux pane-id form, keep it filename-safe. */
24
+ function paneFile(tmuxPane, dir = PANES_DIR) {
25
+ const safe = String(tmuxPane || '').replace(/[^A-Za-z0-9_-]/g, '');
26
+ return safe ? path.join(dir, `${safe}.json`) : null;
27
+ }
28
+
15
29
  /**
16
30
  * @typedef {Object} PaneRecord
17
31
  * @property {string} paneId tmux %N (matches a pane's paneId)
@@ -55,31 +69,65 @@ export async function readPaneRegistry(dir = PANES_DIR) {
55
69
  }
56
70
 
57
71
  /**
58
- * Remove registry files for panes that no longer exist (best-effort GC, e.g.
59
- * when SessionEnd didn't fire on a crash). `livePaneIds` is the set of tmux %N
60
- * currently present.
72
+ * Persist an exact pane→transcript binding for transports that discover the
73
+ * rollout path programmatically instead of via Claude's hook.
61
74
  *
62
- * @param {Set<string>} livePaneIds
75
+ * @param {{paneId:string, sessionId?:string|null, transcriptPath:string, cwd?:string|null}} rec
76
+ * @param {string} [dir] Override the registry dir (tests).
63
77
  * @returns {Promise<void>}
64
78
  */
65
- export async function gcPaneRegistry(livePaneIds) {
79
+ export async function writePaneRegistryRecord(rec, dir = PANES_DIR) {
80
+ if (!rec || typeof rec.paneId !== 'string' || typeof rec.transcriptPath !== 'string') return;
81
+ const file = paneFile(rec.paneId, dir);
82
+ if (!file) return;
83
+ await fsp.mkdir(dir, { recursive: true }).catch(() => {});
84
+ const record = {
85
+ paneId: rec.paneId,
86
+ sessionId: rec.sessionId ?? null,
87
+ transcriptPath: rec.transcriptPath,
88
+ cwd: rec.cwd ?? null,
89
+ ts: Date.now(),
90
+ };
91
+ await fsp.writeFile(file, JSON.stringify(record), { mode: 0o600 });
92
+ }
93
+
94
+ /**
95
+ * Remove registry files whose transcript files no longer exist.
96
+ *
97
+ * It deliberately does NOT use the live tmux pane set. That scan flickers
98
+ * (transient `list-panes` hiccups, a session momentarily not enumerated on a
99
+ * busy socket), and a flaky "pane absent" reading looks identical to a genuine
100
+ * pane close — so keying deletion off it wrongly nukes pins for panes that are
101
+ * very much alive (the long-lived window-1 binding kept vanishing this way).
102
+ *
103
+ * A pin for a closed pane whose transcript still lingers is harmless: there is
104
+ * no live pane to bind it to, and if the pane id is later reused the hook
105
+ * overwrites the file. It self-expires here once its transcript is removed.
106
+ *
107
+ * @param {string} [dir] Override registry dir (tests).
108
+ * @returns {Promise<void>}
109
+ */
110
+ export async function gcPaneRegistry(dir = PANES_DIR) {
66
111
  let entries;
67
112
  try {
68
- entries = await fsp.readdir(PANES_DIR);
113
+ entries = await fsp.readdir(dir);
69
114
  } catch {
70
115
  return;
71
116
  }
117
+
72
118
  await Promise.all(
73
119
  entries
74
120
  .filter((f) => f.endsWith('.json'))
75
121
  .map(async (f) => {
76
122
  try {
77
- const rec = JSON.parse(await fsp.readFile(path.join(PANES_DIR, f), 'utf8'));
78
- if (rec && typeof rec.paneId === 'string' && !livePaneIds.has(rec.paneId)) {
79
- await fsp.rm(path.join(PANES_DIR, f), { force: true });
123
+ const filePath = path.join(dir, f);
124
+ const rec = JSON.parse(await fsp.readFile(filePath, 'utf8'));
125
+ if (!rec || typeof rec.transcriptPath !== 'string') return;
126
+ if (!fs.existsSync(rec.transcriptPath)) {
127
+ await fsp.rm(filePath, { force: true }); // transcript gone → stale
80
128
  }
81
129
  } catch {
82
- // ignore
130
+ // ignore unreadable/partial files
83
131
  }
84
132
  }),
85
133
  );
package/lib/prompt.js CHANGED
@@ -15,16 +15,24 @@ function stripAnsi(s) {
15
15
  }
16
16
 
17
17
  // A numbered option line, optionally preceded by the TUI cursor (❯/›).
18
- const OPTION_RE = /^\s*([❯›]?)\s*(\d)[.)]\s+(.+?)\s*$/;
18
+ // `\d+` (not `\d`) so pickers with ≥10 rows parse their two-digit numbers.
19
+ const OPTION_RE = /^\s*([❯›]?)\s*(\d+)[.)]\s+(.+?)\s*$/;
19
20
  // A checkbox marker at the START of an option label, e.g. "[ ] Label" or "[x] Label".
20
21
  // Matches the bracket content: space = unchecked; x/✓/✗ = checked.
21
22
  const CHECKBOX_RE = /^\[([✓x✗ ])\]\s*(.*)/;
22
- // The footer real Claude Code prompts render under the options.
23
- const ESC_HINT_RE = /\besc\b[^\n]*(cancel|interrupt|exit|reject|keep|quit)|ctrl\+[a-z]\b/i;
24
- // How many lines from the bottom to consider the active prompt always renders
25
- // at the bottom of the pane, so a numbered list higher up (assistant prose) is
26
- // out of scope.
27
- const BOTTOM_REGION = 26;
23
+ // The footer a real Claude Code SELECTION prompt renders under the options.
24
+ // Deterministically EXCLUDES "esc to interrupt" — that is the working-state
25
+ // footer (Claude is generating), not a prompt. A numbered list in assistant
26
+ // prose shown while Claude works would otherwise false-positive as a prompt.
27
+ // Real selection prompts say "esc to cancel / reject / keep".
28
+ const ESC_HINT_RE = /\besc\b[^\n]*(cancel|reject|keep)/i;
29
+ // How many lines from the bottom to consider. The active prompt always renders
30
+ // at the bottom of the pane; the cursor/Esc-footer guard (not this window) is
31
+ // what rejects assistant prose, so this can be generous. It must be large
32
+ // enough to contain a tall AskUserQuestion (long question + 5 options each with
33
+ // a multi-line description + footer) — otherwise the question + first options
34
+ // scroll out and the header heuristic grabs an option-description fragment.
35
+ const BOTTOM_REGION = 80;
28
36
  const MAX_LABEL = 80;
29
37
 
30
38
  /**
@@ -104,25 +112,56 @@ export function parsePanePrompt(capture) {
104
112
  // Require a genuine interactive-prompt signal — not just numbered prose.
105
113
  if (!hasCursor && !hasEsc) return null;
106
114
 
107
- // Question = the contiguous non-empty block immediately above the options (the
108
- // prompt can wrap onto several lines), joined into one string.
109
- const qLines = [];
110
- for (let i = firstLine - 1; i >= 0 && i >= firstLine - 4; i--) {
111
- const t = lines[i].trim();
112
- if (!t) break;
113
- qLines.unshift(t);
115
+ // Question = the contiguous block above the option run. Only trust it when the
116
+ // run starts at key 1 i.e. the WHOLE picker is in view. If it starts higher
117
+ // (1/2 scrolled off despite the large window), the lines above the first
118
+ // visible option are a prior option's wrapped DESCRIPTION, not the question, so
119
+ // we emit no header rather than a misleading fragment.
120
+ let question = null;
121
+ if (Number(options[0].key) === 1) {
122
+ let i = firstLine - 1;
123
+ while (i >= 0 && !lines[i].trim()) i--; // skip the blank separator(s)
124
+ const qLines = [];
125
+ for (; i >= 0; i--) {
126
+ const t = lines[i].trim();
127
+ if (!t) break; // stop at the blank above the question block
128
+ if (OPTION_RE.test(lines[i])) break; // don't bleed into a prior option
129
+ qLines.unshift(t);
130
+ }
131
+ question = qLines.join(' ').slice(0, 400) || null;
114
132
  }
115
- const question = qLines.join(' ').slice(0, 240);
133
+
134
+ // Each option may carry a wrapped DESCRIPTION — the indented sub-text the TUI
135
+ // renders under the label (e.g. "Tear down the proxy/gateway…"). It lives on
136
+ // the contiguous non-blank lines between this option's line and the next
137
+ // option's line (or the footer), so capture those so the cockpit shows the
138
+ // same context the TUI does instead of just the bare label.
139
+ const descFor = (idx) => {
140
+ const start = options[idx].line + 1;
141
+ const end = idx + 1 < options.length ? options[idx + 1].line : lines.length;
142
+ const out = [];
143
+ for (let i = start; i < end; i++) {
144
+ const t = lines[i].trim();
145
+ if (!t || OPTION_RE.test(lines[i]) || ESC_HINT_RE.test(lines[i])) break;
146
+ out.push(t);
147
+ }
148
+ const desc = out.join(' ').slice(0, 300);
149
+ return desc || undefined;
150
+ };
116
151
 
117
152
  const hasCheckboxes = options.some((o) => o.checked !== undefined);
118
153
  return {
119
154
  question: question || 'Make a selection',
120
155
  ...(hasCheckboxes ? { multiSelect: true } : {}),
121
- options: options.map((o) => ({
122
- key: o.key,
123
- label: o.label,
124
- selected: o.cursor,
125
- ...(o.checked !== undefined ? { checked: o.checked } : {}),
126
- })),
156
+ options: options.map((o, idx) => {
157
+ const description = descFor(idx);
158
+ return {
159
+ key: o.key,
160
+ label: o.label,
161
+ selected: o.cursor,
162
+ ...(description ? { description } : {}),
163
+ ...(o.checked !== undefined ? { checked: o.checked } : {}),
164
+ };
165
+ }),
127
166
  };
128
167
  }