claude-code-autoconfig 1.0.193 → 1.0.195

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.
@@ -69,9 +69,14 @@ proceed, do BOTH of these as near-final actions so the tab flips to the AWAITING
69
69
  (instead of the idle asterisk):
70
70
  1. Write the flag file {{ASK_FILE}} (any short content, e.g. "1"). This is the RELIABLE trigger --
71
71
  it is on disk before the turn ends, so it never misses (the transcript-text check in 2 can race
72
- the turn-end write and silently miss). The flag is one-shot: it is consumed at turn end and
73
- auto-cleared next turn, so write it ONLY on a turn you are genuinely blocked on an answer.
74
- 2. Phrase your FINAL line so it ends with a question mark ('?') -- a backup signal, and good UX.
72
+ the turn-end write and silently miss, or miss on phrasing -- see below). The flag is one-shot: it
73
+ is consumed at turn end and auto-cleared next turn, so write it ONLY on a turn you are genuinely
74
+ blocked on an answer. Write it WHENEVER the turn genuinely needs the user's answer to proceed --
75
+ the flag doesn't parse your text, so it is correct even when your closing question is wrapped in
76
+ parens, phrased as a parenthetical aside, or is not the literal final character of the message.
77
+ 2. Phrase your FINAL line so it ends with a question mark ('?') -- a backup signal, and good UX. A
78
+ single trailing parenthetical aside after the '?' is fine ("...option A or B? (I lean B.)"), but
79
+ do not rely on phrasing for the signal -- the flag in step 1 is what reliably flips the tab.
75
80
  Make the closing question self-contained: answerable from the question alone, without re-reading
76
81
  the response above it. Only do this for a genuine blocking question, never a rhetorical one or a recap.
77
82
  <!-- /DIRECTIVE:PENDING -->
@@ -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,27 @@ 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 / ) * _ ", plus one
181
+ // trailing parenthetical aside — see the endsOnQuestion regex below). Returns a
182
+ // diagnostic record { ends, found, tail }: `ends` is the old boolean the caller branches on; `found`
183
+ // and `tail` feed the debug log so a missing half-circle can be told apart — a transcript-flush race
184
+ // shows found=0 (or a stale tail), a genuine regex miss shows a tail that's present but doesn't end
185
+ // in '?'. Any error → a blank record (treated as "no question"), matching the old false return.
186
+ function inspectLastResponse(transcriptPath) {
187
+ const blank = { ends: false, found: false, tail: '', suspectRace: false };
188
+ if (!transcriptPath) return blank;
156
189
  let content;
157
190
  try {
158
191
  content = fs.readFileSync(transcriptPath, 'utf8');
159
192
  } catch (_) {
160
- return false;
193
+ return blank;
161
194
  }
162
195
  const lines = content.split('\n');
196
+ // Did we pass a TEXT-LESS assistant block (thinking-only / tool_use-only) on the way back to the last
197
+ // text block? At a fully-flushed Stop the final assistant message HAS text, so we hit it first and this
198
+ // stays false. If it's true, the turn's real final text is most likely still being appended to the
199
+ // JSONL (the flush race) — the caller re-reads after a beat before grading rather than trust this block.
200
+ let sawTextlessAssistant = false;
163
201
  for (let i = lines.length - 1; i >= 0; i--) {
164
202
  const line = lines[i].trim();
165
203
  if (!line) continue;
@@ -176,11 +214,29 @@ function lastResponseEndsWithQuestion(transcriptPath) {
176
214
  .map(b => b.text)
177
215
  .join('\n');
178
216
  }
179
- if (text.trim()) return /\?[\s)*_"]*$/.test(text);
217
+ if (text.trim()) {
218
+ // last ~60 chars, collapsed to one line and quote-stripped so it can't break the log framing
219
+ const tail = text.trim().slice(-60).replace(/\s+/g, ' ').replace(/"/g, "'");
220
+ // Ends on a question? A '?' at the end, tolerating trailing whitespace / ) * _ " — AND an optional
221
+ // SINGLE trailing parenthetical aside after it ("How should we handle it? (I lean option 2.)"), a
222
+ // common shape: ask, then a bracketed recommendation/clarifier. The aside is one level only
223
+ // (`[^()]`, no nesting) and must sit at the very end, so a mid-message rhetorical '?' or a plain
224
+ // statement ending in ')' still won't match — only a genuine closing question does. Belt to the
225
+ // {sid}.ask flag's suspenders: the flag is the primary, parse-free path; this only hardens the
226
+ // fallback for turns that ended on a parenthetical question without writing one.
227
+ const endsOnQuestion = /\?[\s)*_"]*(\([^()]*\)[\s.*_"]*)?$/.test(text);
228
+ return { ends: endsOnQuestion, found: true, tail, suspectRace: sawTextlessAssistant };
229
+ }
230
+ // assistant message with no visible text = a thinking-only or tool_use-only block sitting AFTER the
231
+ // last text we'll grade — a strong hint the final text line hasn't flushed yet.
232
+ sawTextlessAssistant = true;
180
233
  }
181
- return false;
234
+ return blank;
182
235
  }
183
236
 
237
+ // Event-loop-friendly sleep for the Stop flush-race re-read beat (handle awaits this; no busy-wait).
238
+ function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
239
+
184
240
  function readTitle(file) {
185
241
  try {
186
242
  if (fs.existsSync(file)) return fs.readFileSync(file, 'utf8').trim();
@@ -239,3 +295,6 @@ function extractBlock(tpl, name) {
239
295
  const m = tpl.match(re);
240
296
  return m ? m[1].trim() : '';
241
297
  }
298
+
299
+ // Exported for tests (require()'d when require.main !== module). The hook itself never reads these.
300
+ module.exports = { inspectLastResponse, normalize, GLYPH };
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## v1.0.195
4
+ - fix(terminal-title): flip awaiting ◐ for a closing question with a trailing parenthetical aside
5
+
6
+ ## v1.0.194
7
+ - fix(terminal-title): guard the Stop question-grade against the transcript-flush race
8
+
3
9
  ## v1.0.193
4
10
  - fix(installer): always refresh cca-managed title hooks on upgrade
5
11
  - fix(terminal-title): arm the {sid}.ask flag so awaiting ◐ is race-free
@@ -141,9 +147,3 @@
141
147
  ## v1.0.146
142
148
  - style: improve inside-Claude block message formatting
143
149
 
144
- ## v1.0.145
145
- - fix: block npx install from inside Claude Code session
146
-
147
- ## v1.0.144
148
- - feat: swagger-style docs, wider layout, better install UX
149
-
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.195",
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",