claude-code-autoconfig 1.0.193 → 1.0.194

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.
@@ -41,18 +41,23 @@ const GLYPH = {
41
41
  // Per-invocation context for the optional debug log (populated in handle, read by titleLog).
42
42
  let logCtx = null;
43
43
 
44
- let input = '';
45
- process.stdin.setEncoding('utf8');
46
- process.stdin.on('data', chunk => (input += chunk));
47
- process.stdin.on('end', () => {
48
- try {
49
- handle(JSON.parse(input));
50
- } catch (err) {
51
- process.exit(0); // never break the turn on a title error — emit nothing
52
- }
53
- });
44
+ // Only drive from stdin when run AS the hook (`node terminal-title.js`). When require()'d by a test the
45
+ // functions below are exported instead, so the stdin read doesn't hang the runner. async because the
46
+ // Stop branch may await a short re-read beat (see the flush-race guard in handle).
47
+ if (require.main === module) {
48
+ let input = '';
49
+ process.stdin.setEncoding('utf8');
50
+ process.stdin.on('data', chunk => (input += chunk));
51
+ process.stdin.on('end', async () => {
52
+ try {
53
+ await handle(JSON.parse(input));
54
+ } catch (err) {
55
+ process.exit(0); // never break the turn on a title error — emit nothing
56
+ }
57
+ });
58
+ }
54
59
 
55
- function handle(data) {
60
+ async function handle(data) {
56
61
  const event = data.hook_event_name || '';
57
62
  const sid = data.session_id || '';
58
63
  const cwd = data.cwd || process.cwd();
@@ -105,11 +110,32 @@ function handle(data) {
105
110
  // so VS Code paints the (otherwise bell-less) tab gold. "Ended on a question" = last visible
106
111
  // assistant text ends in '?' (transcript heuristic) OR an explicit {sid}.ask flag (consumed here).
107
112
  const askFile = path.join(dir, `${sid}.ask`);
108
- let pending = lastResponseEndsWithQuestion(data.transcript_path);
113
+ const askPresent = fileExists(askFile);
114
+
115
+ // Grade the transcript, guarding the flush race: the final assistant text can land in the JSONL a beat
116
+ // AFTER Stop fires (the screen renders from the live stream; the file append lags by ~200ms). The tell
117
+ // is `suspectRace`: the freshest on-disk assistant block is text-less (thinking/tool_use only), or no
118
+ // text was found at all — both mean the real final message is still flushing. When that's the case,
119
+ // re-read a few times (~120ms apart) before grading, so a turn that actually ended on '?' isn't painted
120
+ // ✻ idle off a stale earlier block. A fully-flushed turn hits the text block first → suspectRace=false →
121
+ // zero delay. The {sid}.ask flag remains the race-proof backstop.
122
+ let q = inspectLastResponse(data.transcript_path);
123
+ let reread = 0;
124
+ while (!q.ends && (q.suspectRace || !q.found) && reread < 7) {
125
+ await delay(120);
126
+ reread++;
127
+ q = inspectLastResponse(data.transcript_path);
128
+ if (q.ends || (q.found && !q.suspectRace)) break;
129
+ }
130
+
131
+ let pending = q.ends;
109
132
  let note = pending ? 'q-mark' : 'idle';
110
- if (!pending && fileExists(askFile)) { pending = true; note = 'ask-flag'; }
111
- if (fileExists(askFile)) { try { fs.unlinkSync(askFile); } catch (_) { /* ignore */ } }
112
- if (logCtx) logCtx.note = note;
133
+ if (!pending && askPresent) { pending = true; note = 'ask-flag'; }
134
+ if (askPresent) { try { fs.unlinkSync(askFile); } catch (_) { /* ignore */ } }
135
+ if (logCtx) {
136
+ logCtx.note = note;
137
+ logCtx.diag = `ask=${askPresent ? 1 : 0} qmark=${q.ends ? 1 : 0} found=${q.found ? 1 : 0} reread=${reread} tail="${q.tail}"`;
138
+ }
113
139
  const glyph = pending ? GLYPH.awaiting : GLYPH.idle;
114
140
  emit(setTitle(glyph, normalize(readTitle(file) || folderName(cwd)), pending));
115
141
  }
@@ -135,9 +161,10 @@ function titleLog(glyph, title, ring) {
135
161
  const name = glyph === GLYPH.working ? 'working'
136
162
  : glyph === GLYPH.awaiting ? 'awaiting'
137
163
  : glyph === GLYPH.idle ? 'idle' : 'other';
164
+ const diag = logCtx.diag ? ` ${logCtx.diag}` : '';
138
165
  const line = `${new Date().toISOString()} ${logCtx.event.padEnd(16)} `
139
166
  + `${name.padEnd(8)} ring=${ring ? 1 : 0} note=${(logCtx.note || '-').padEnd(8)} `
140
- + `sid=${logCtx.sid} | ${title}\n`;
167
+ + `sid=${logCtx.sid} | ${title}${diag}\n`;
141
168
  const f = path.join(logCtx.dir, '_debug.log');
142
169
  try { if (fs.statSync(f).size > 512 * 1024) fs.renameSync(f, `${f}.1`); } catch (_) { /* none yet */ }
143
170
  fs.appendFileSync(f, line);
@@ -150,16 +177,26 @@ function fileExists(file) {
150
177
 
151
178
  // Stop heuristic: did the turn end on a question? Read the JSONL transcript, find the most-recent
152
179
  // assistant message with VISIBLE text (skip pure tool_use turns so a final title/memory Write doesn't
153
- // mask the question), test whether it ends in '?' (allowing trailing whitespace / ) * _ "). Any error → false.
154
- function lastResponseEndsWithQuestion(transcriptPath) {
155
- if (!transcriptPath) return false;
180
+ // mask the question), test whether it ends in '?' (allowing trailing whitespace / ) * _ "). Returns a
181
+ // diagnostic record { ends, found, tail }: `ends` is the old boolean the caller branches on; `found`
182
+ // and `tail` feed the debug log so a missing half-circle can be told apart — a transcript-flush race
183
+ // shows found=0 (or a stale tail), a genuine regex miss shows a tail that's present but doesn't end
184
+ // in '?'. Any error → a blank record (treated as "no question"), matching the old false return.
185
+ function inspectLastResponse(transcriptPath) {
186
+ const blank = { ends: false, found: false, tail: '', suspectRace: false };
187
+ if (!transcriptPath) return blank;
156
188
  let content;
157
189
  try {
158
190
  content = fs.readFileSync(transcriptPath, 'utf8');
159
191
  } catch (_) {
160
- return false;
192
+ return blank;
161
193
  }
162
194
  const lines = content.split('\n');
195
+ // Did we pass a TEXT-LESS assistant block (thinking-only / tool_use-only) on the way back to the last
196
+ // text block? At a fully-flushed Stop the final assistant message HAS text, so we hit it first and this
197
+ // stays false. If it's true, the turn's real final text is most likely still being appended to the
198
+ // JSONL (the flush race) — the caller re-reads after a beat before grading rather than trust this block.
199
+ let sawTextlessAssistant = false;
163
200
  for (let i = lines.length - 1; i >= 0; i--) {
164
201
  const line = lines[i].trim();
165
202
  if (!line) continue;
@@ -176,11 +213,21 @@ function lastResponseEndsWithQuestion(transcriptPath) {
176
213
  .map(b => b.text)
177
214
  .join('\n');
178
215
  }
179
- if (text.trim()) return /\?[\s)*_"]*$/.test(text);
216
+ if (text.trim()) {
217
+ // last ~60 chars, collapsed to one line and quote-stripped so it can't break the log framing
218
+ const tail = text.trim().slice(-60).replace(/\s+/g, ' ').replace(/"/g, "'");
219
+ return { ends: /\?[\s)*_"]*$/.test(text), found: true, tail, suspectRace: sawTextlessAssistant };
220
+ }
221
+ // assistant message with no visible text = a thinking-only or tool_use-only block sitting AFTER the
222
+ // last text we'll grade — a strong hint the final text line hasn't flushed yet.
223
+ sawTextlessAssistant = true;
180
224
  }
181
- return false;
225
+ return blank;
182
226
  }
183
227
 
228
+ // Event-loop-friendly sleep for the Stop flush-race re-read beat (handle awaits this; no busy-wait).
229
+ function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
230
+
184
231
  function readTitle(file) {
185
232
  try {
186
233
  if (fs.existsSync(file)) return fs.readFileSync(file, 'utf8').trim();
@@ -239,3 +286,6 @@ function extractBlock(tpl, name) {
239
286
  const m = tpl.match(re);
240
287
  return m ? m[1].trim() : '';
241
288
  }
289
+
290
+ // Exported for tests (require()'d when require.main !== module). The hook itself never reads these.
291
+ module.exports = { inspectLastResponse, normalize, GLYPH };
package/CHANGELOG.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # Changelog
2
2
 
3
+ ## v1.0.194
4
+ - fix(terminal-title): guard the Stop question-grade against the transcript-flush race
5
+
3
6
  ## v1.0.193
4
7
  - fix(installer): always refresh cca-managed title hooks on upgrade
5
8
  - fix(terminal-title): arm the {sid}.ask flag so awaiting ◐ is race-free
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-autoconfig",
3
- "version": "1.0.193",
3
+ "version": "1.0.194",
4
4
  "description": "Intelligent, self-configuring setup for Claude Code. One command analyzes your project, configures Claude, and shows you what it did.",
5
5
  "author": "ADAC 1001 <info@adac1001.com>",
6
6
  "license": "MIT",