@ngockhoale/ukit 2.4.1 → 2.4.2

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/package.json +1 -1
  3. package/scripts/index/refresh-index.mjs +10 -5
  4. package/src/cli/commands/doctor.js +59 -2
  5. package/src/core/gatewayProbe.js +143 -15
  6. package/src/core/gatewayResilienceEnv.js +136 -7
  7. package/src/index/buildIndex.js +74 -24
  8. package/templates/.claude/hooks/auto-allow-bash.sh +24 -1
  9. package/templates/.claude/hooks/auto-prune-bash.sh +4 -0
  10. package/templates/.claude/hooks/block-dangerous.sh +20 -1
  11. package/templates/.claude/hooks/completion-gate.sh +19 -2
  12. package/templates/.claude/hooks/compress-output.sh +17 -2
  13. package/templates/.claude/hooks/context-hardcap-gate.sh +22 -3
  14. package/templates/.claude/hooks/context-window-guard.sh +84 -61
  15. package/templates/.claude/hooks/handoff-model-guard.sh +24 -3
  16. package/templates/.claude/hooks/handoff-resume.sh +21 -3
  17. package/templates/.claude/hooks/post-edit-verify.sh +19 -2
  18. package/templates/.claude/hooks/pre-edit-backup.sh +19 -2
  19. package/templates/.claude/hooks/protect-files.sh +20 -1
  20. package/templates/.claude/hooks/record-execution.sh +20 -2
  21. package/templates/.claude/hooks/reinject-context.sh +1 -1
  22. package/templates/.claude/hooks/reset-compact-pressure.sh +4 -0
  23. package/templates/.claude/hooks/sensitive-data-guard.sh +64 -17
  24. package/templates/.claude/hooks/skill-router.sh +33 -5
  25. package/templates/.claude/hooks/stale-spec-guard.sh +21 -2
  26. package/templates/.claude/hooks/task-watchdog.sh +25 -7
  27. package/templates/.claude/hooks/verification-guard.sh +54 -19
  28. package/templates/.claude/hooks/vision-router.sh +96 -12
  29. package/templates/.claude/ukit/index/lib/index-core.mjs +78 -16
  30. package/templates/.claude/ukit/index/post-edit-verify.mjs +8 -0
  31. package/templates/.claude/ukit/index/pre-edit-backup.mjs +8 -0
  32. package/templates/.claude/ukit/index/refresh-index.mjs +10 -5
  33. package/templates/.claude/ukit/index/stale-spec-check.mjs +8 -0
  34. package/templates/.claude/ukit/runtime/execution-ledger.mjs +8 -0
  35. package/templates/.claude/ukit/runtime/hook-input.mjs +120 -0
  36. package/templates/.claude/ukit/runtime/hook-input.sh +60 -0
  37. package/templates/.claude/ukit/runtime/output-compression.mjs +8 -0
  38. package/templates/.claude/ukit/runtime/reinject-context.mjs +8 -0
  39. package/templates/.claude/ukit/runtime/transcript-tail.mjs +107 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,71 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 2.4.2 - 2026-09-16
6
+
7
+ Fix: the gateway resilience posture disabled the only recovery path a buffering gateway
8
+ leaves open — the direct client-side cause of the recurring "session stands still mid-turn
9
+ until the user interrupts and re-prompts" freeze. Live probes of the stall-class gateway on
10
+ 2026-09-16 proved the full chain: the gateway still buffers entire streams (a 16-token
11
+ probe returned 23 SSE events in ONE body read), so a long generation is silent until it
12
+ finishes; the non-streaming route is healthy (HTTP 200 + valid Anthropic Message); and the
13
+ managed env shipped since 2.3.16 kept the non-streaming fallback DISABLED with a 10-minute
14
+ stream-idle watchdog — meaning the client sat in dead air and, on abort, had no recovery
15
+ path at all. The managed posture now flips to fail-fast-and-recover.
16
+
17
+ - **Posture flip** (`src/core/gatewayResilienceEnv.js`): `CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK`
18
+ managed default `'1'` → `'0'` (fallback stays enabled — on a buffering gateway a
19
+ non-streaming POST is the one request shape without an idle watchdog), and
20
+ `CLAUDE_STREAM_IDLE_TIMEOUT_MS` `'600000'` → `'120000'` (a silent stream aborts after
21
+ 2 minutes and recovers via the healthy non-streaming route instead of hanging for 10).
22
+ Values equal to the previously shipped defaults are UKit's own earlier writes and are
23
+ migrated on the next `ukit install` (new `GATEWAY_RESILIENCE_ENV_LEGACY_DEFAULTS` map +
24
+ `migrated` report field, backup written as usual); genuinely user-tuned values are still
25
+ never touched.
26
+ - **Probe misdiagnosis fixed** (`src/core/gatewayProbe.js`): the probe 404'd healthy
27
+ alias-based gateways because it POSTed the built-in default `claude-sonnet-4-5`, which is
28
+ not provisioned there ("No active credentials for provider: claude", `model_not_found`,
29
+ reproduced live) while real sessions run on the `ANTHROPIC_DEFAULT_*_MODEL` lane aliases.
30
+ The probe now resolves `ANTHROPIC_MODEL` → `ANTHROPIC_DEFAULT_OPUS/SONNET/HAIKU_MODEL`
31
+ before the built-in default and reports the probed model. `anthropic-version` is now sent
32
+ for the Bearer scheme too (live-verified accepted), and the API key is resolved through
33
+ the same 3-tier order as the base URL (env → project settings → home settings) so plain
34
+ terminals no longer probe keyless (`resolveGatewayApiKey`).
35
+ - **request-id re-classified** (same file, `docs/GATEWAY.md` §3): a missing `request-id`
36
+ on an HTTP 200 + valid Anthropic Message is an advisory hint, not a FAIL — the real
37
+ gateway ships no request-id even on healthy responses and sessions complete fine. The
38
+ Message-shape hard gate still catches the 888-byte error-envelope class from 2.3.16.
39
+ - **Docs**: `docs/GATEWAY.md` intro/§1/§2/§3/§4/§5 updated for the fail-fast posture,
40
+ the 2026-09-16 buffering reconfirmation, the legacy-value migration, and the probe's
41
+ alias-model + key resolution.
42
+ - **Verification**: focused suites green (gatewayProbe 14, gatewayResilienceEnv 23,
43
+ packageVersion 3); full `yarn test` green; live re-probe after the fix reports the alias
44
+ model, a healthy non-streaming route, and the still-present buffering signature.
45
+ - **Orphan-leak class closed for the remaining hooks (2.4.1 follow-up)**: the 2.4.1 watchdog
46
+ shipped in exactly one hook (`skill-router.sh`); the other 18 node-spawning hooks could still
47
+ orphan a node grandchild to launchd when their work hung (wedged import, stalled mount — this
48
+ repo lives on an external volume). Every remaining hook now arms the same wall-clock
49
+ self-deadline: inline heredoc/`node -e` blocks carry
50
+ `setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref()` directly, wrapper hooks pass
51
+ `UKIT_HOOK_DEADLINE_MS` (default 3000 ms) and their runtime scripts (`execution-ledger`,
52
+ `output-compression`, `reinject-context`, `post-edit-verify`, `pre-edit-backup`,
53
+ `stale-spec-check`) self-exit only when that env var is present, so CLI usage is never
54
+ self-killed. Lock-mutation blocks with a 5000 ms bounded wait (auto-allow-bash,
55
+ auto-prune-bash, reset-compact-pressure) use an 8000 ms deadline so the watchdog can never
56
+ fire while the mutation is legally waiting for the lock.
57
+ `tests/consistency/hookWatchdogCoverage.test.js` enforces the coverage for every future hook.
58
+ - **Bounded stdin transport for the hook chain (C19 wave 1, H01)**: hook payloads no longer
59
+ materialize unbounded in a shell variable or the exec environment — the exec-env path that
60
+ made large PreToolUse payloads fail `exec` outright (E2BIG) on macOS. New
61
+ `templates/.claude/ukit/runtime/hook-input.sh` (`ukit_stage_hook_input` via `mktemp` +
62
+ byte-capped `head`, drained producer, overflow policies `truncate`/`temp-file`/`refuse`)
63
+ and `hook-input.mjs` (streaming reader + CLI) stage every payload to a bounded temp file;
64
+ all 16 node-spawning wrapper hooks now pass `INPUT_FILE`, never the payload, with a
65
+ bounded inline fallback when the helper is absent. Sensitive-data-guard overflow is
66
+ fail-closed (`refuse`, silent exit 2); 32 MiB receipts verified < 10 s.
67
+
68
+ - Machine-envelope lane in the sensitive-data gate: harness-injected notifications (task-notification / system-reminder / cross-session-message tags) now deliver with a redacted labels/counts advisory instead of being blocked when they quote secret-shaped test fixtures — ends the silent "Waiting for N background agents" stall. User-typed prompts, file/grep/bash channels, allowlist and overflow-refuse posture unchanged (fail-closed, default-ON, never echoes values).
69
+
5
70
  ## 2.4.1 - 2026-09-14
6
71
 
7
72
  Fix: the skill-router hook could hang the session and leak an orphaned process. `skill-router.sh`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.4.1",
3
+ "version": "2.4.2",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex, OpenCode, and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
 
5
5
  import {
6
6
  buildCodeIndex,
7
- isIndexStale,
7
+ inspectIndexStaleness,
8
8
  DEFAULT_INDEX_CACHE_MAX_AGE_MS,
9
9
  } from '../../src/index/buildIndex.js';
10
10
 
@@ -19,9 +19,10 @@ const changedFiles = changedArg
19
19
 
20
20
  const force = readBooleanFlag(args, '--force');
21
21
  const lastRefreshMs = await getLastRefreshTime(rootDir);
22
- const stale = force
23
- ? true
24
- : await isIndexStale({ rootDir, maxAgeMs: DEFAULT_INDEX_CACHE_MAX_AGE_MS });
22
+ const staleness = force
23
+ ? null
24
+ : await inspectIndexStaleness({ rootDir, maxAgeMs: DEFAULT_INDEX_CACHE_MAX_AGE_MS });
25
+ const stale = force || staleness.stale;
25
26
 
26
27
  if (!stale) {
27
28
  console.log('[index:refresh] skipped (cache fresh)');
@@ -33,7 +34,11 @@ if (!stale) {
33
34
  }
34
35
  console.log(`root: ${rootDir}`);
35
36
  } else {
36
- const summary = await buildCodeIndex({ rootDir });
37
+ // Reuse the staleness check's discovery snapshot: one enumeration per refresh.
38
+ const summary = await buildCodeIndex({
39
+ rootDir,
40
+ discoverySnapshot: staleness ? staleness.snapshot : null,
41
+ });
37
42
 
38
43
  console.log('[index:refresh] completed');
39
44
  if (lastRefreshMs !== null) {
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'node:fs/promises';
3
+ import os from 'node:os';
3
4
  import { pathExists, readJsonIfExists } from '../../core/fileOps.js';
4
5
  import { buildPathConfig } from '../../core/paths.js';
5
6
  import { buildRuntimePaths } from '../../core/runtimePaths.js';
@@ -10,8 +11,13 @@ import { detectProviders } from '../../context/detectProviders.js';
10
11
  import { profileSkills } from '../../core/skillProfile.js';
11
12
  import {
12
13
  resolveGatewayBaseUrl,
14
+ resolveGatewayApiKey,
13
15
  probeGateway,
14
16
  } from '../../core/gatewayProbe.js';
17
+ import {
18
+ GATEWAY_RESILIENCE_ENV_DEFAULTS,
19
+ scanProfileResilienceEnv,
20
+ } from '../../core/gatewayResilienceEnv.js';
15
21
 
16
22
  export const DOCTOR_HELP_FLAGS = new Set(['--help', '-h']);
17
23
  const KNOWN_FLAGS = new Set([...DOCTOR_HELP_FLAGS, '--skills', '--gateway']);
@@ -28,7 +34,7 @@ export function printDoctorHelp() {
28
34
  console.log(' --gateway Live gateway probe (streaming + non-streaming); advisory only');
29
35
  }
30
36
 
31
- export async function runDoctor({ packageRoot, projectRoot, argv = [] }) {
37
+ export async function runDoctor({ packageRoot, projectRoot, argv = [], homeDir = os.homedir() }) {
32
38
  const unknownFlags = argv.filter((flag) => !KNOWN_FLAGS.has(flag));
33
39
  if (unknownFlags.length > 0) {
34
40
  throw new Error(`Unknown option: ${unknownFlags[0]}. Supported: ${SUPPORTED_FLAGS_LIST}`);
@@ -158,6 +164,40 @@ export async function runDoctor({ packageRoot, projectRoot, argv = [] }) {
158
164
  if (argv.includes('--gateway')) {
159
165
  console.log('');
160
166
  console.log('[UKit] Gateway probe (advisory — does not affect exit code):');
167
+
168
+ // Per-profile resilience scan. A session launched with
169
+ // `--settings ~/.claude/profile/<name>.json` bypasses ~/.claude/settings.json, so the
170
+ // managed keys can be missing exactly where they matter while the main settings file
171
+ // looks healthy. Advisory only: this never enters `checks` and never touches exitCode.
172
+ console.log(
173
+ `[UKit] Profile resilience scan (advisory) — managed keys: ${Object.keys(GATEWAY_RESILIENCE_ENV_DEFAULTS).join(', ')}`,
174
+ );
175
+ let profileScan = null;
176
+ try {
177
+ profileScan = await scanProfileResilienceEnv({ homeDir });
178
+ } catch {
179
+ profileScan = null;
180
+ }
181
+ if (!profileScan || profileScan.scanned.length === 0) {
182
+ console.log('[UKit] - No profile files found (nothing to check).');
183
+ } else {
184
+ for (const entry of profileScan.scanned) {
185
+ if (entry.missing.length === 0) {
186
+ console.log(`[UKit] ✓ ${entry.file}`);
187
+ continue;
188
+ }
189
+ console.log(
190
+ `[UKit] ✗ ${entry.file} — ${entry.validJson ? `missing ${entry.missing.join(', ')}` : 'not valid JSON (assuming every managed key missing)'}`,
191
+ );
192
+ console.log(
193
+ `[UKit] remedy: add ${entry.missing.join(', ')} to the profile's "env" block; see docs/GATEWAY.md`,
194
+ );
195
+ }
196
+ if (profileScan.compliant) {
197
+ console.log('[UKit] All scanned profile files carry the managed resilience keys.');
198
+ }
199
+ }
200
+
161
201
  let resolved;
162
202
  try {
163
203
  resolved = await resolveGatewayBaseUrl({ projectRoot });
@@ -168,9 +208,25 @@ export async function runDoctor({ packageRoot, projectRoot, argv = [] }) {
168
208
  console.log('[UKit] - Gateway probe skipped — no custom gateway configured (ANTHROPIC_BASE_URL not set in env, project .claude/settings.json, or ~/.claude/settings.json).');
169
209
  } else {
170
210
  console.log(`[UKit] baseUrl: ${resolved.baseUrl} (source: ${resolved.source})`);
211
+ // Plain terminals do not export ANTHROPIC_* (the key lives in settings.json env and is
212
+ // only injected into Claude Code sessions), so resolve the key through the same 3-tier
213
+ // order as the baseUrl — otherwise the probe authenticates keyless and misreports.
214
+ let keyInfo = null;
215
+ try {
216
+ keyInfo = await resolveGatewayApiKey({ projectRoot });
217
+ } catch {
218
+ keyInfo = null;
219
+ }
220
+ if (!keyInfo?.value) {
221
+ console.log('[UKit] - No API key found (env, project settings, home settings) — probe runs unauthenticated and may 401/404.');
222
+ }
171
223
  let result;
172
224
  try {
173
- result = await probeGateway({ baseUrl: resolved.baseUrl });
225
+ result = await probeGateway({
226
+ baseUrl: resolved.baseUrl,
227
+ apiKey: keyInfo?.value ?? null,
228
+ apiKeySource: keyInfo?.scheme ?? null,
229
+ });
174
230
  } catch (error) {
175
231
  result = null;
176
232
  console.log(`[UKit] ✗ Gateway probe threw: ${error?.message ?? String(error)}`);
@@ -178,6 +234,7 @@ export async function runDoctor({ packageRoot, projectRoot, argv = [] }) {
178
234
  if (result) {
179
235
  const s = result.streaming;
180
236
  const n = result.nonStreaming;
237
+ console.log(`[UKit] probed model: ${result.model}`);
181
238
  const streamingLabel = s.ok
182
239
  ? `streaming OK (events=${s.eventsReceived}, chunks=${s.chunks})`
183
240
  : `streaming FAIL (events=${s.eventsReceived}, chunks=${s.chunks}, buffered=${s.buffered}${s.error ? `, error=${s.error}` : ''})`;
@@ -22,6 +22,7 @@ import os from 'node:os';
22
22
  import path from 'node:path';
23
23
 
24
24
  const GATEWAY_DOC = 'docs/GATEWAY.md';
25
+ const SETTINGS_RELATIVE_PROBE_PATH = path.join('.claude', 'settings.json');
25
26
 
26
27
  function safeReadFile(filePath) {
27
28
  try {
@@ -96,6 +97,53 @@ export async function resolveGatewayBaseUrl({
96
97
  return { baseUrl: null, source: null };
97
98
  }
98
99
 
100
+ function readSettingsEnvBlock(filePath) {
101
+ const json = safeReadJson(filePath);
102
+ if (!json || typeof json !== 'object') return null;
103
+ const envBlock = json.env;
104
+ if (!envBlock || typeof envBlock !== 'object' || Array.isArray(envBlock)) return null;
105
+ return envBlock;
106
+ }
107
+
108
+ /**
109
+ * Resolves the API key the probe should authenticate with, mirroring the same 3-tier probe
110
+ * order as `resolveGatewayBaseUrl`: env → project `.claude/settings.json` env → home
111
+ * `~/.claude/settings.json` env. Plain terminals do not export ANTHROPIC_* (Claude Code
112
+ * injects its settings env only into its own sessions), so without the settings tiers
113
+ * `ukit doctor --gateway` probes keyless and misreports an auth-gated gateway.
114
+ *
115
+ * The `scheme` field mirrors `pickApiKey`'s source (`'api-key'` → x-api-key,
116
+ * `'auth-token'` → Authorization: Bearer) so callers can pass it straight into
117
+ * `probeGateway` as `apiKeySource`. Never throws.
118
+ *
119
+ * @param {{ projectRoot: string, homeDir?: string, env?: object }} options
120
+ * @returns {{ value: string|null, scheme: 'api-key'|'auth-token'|null,
121
+ * source: 'env'|'claude-settings'|null }}
122
+ */
123
+ export function resolveGatewayApiKey({
124
+ projectRoot,
125
+ homeDir = os.homedir(),
126
+ env = process.env,
127
+ } = {}) {
128
+ const envKey = pickApiKey(env ?? {});
129
+ if (envKey) {
130
+ return { value: envKey.value, scheme: envKey.source, source: 'env' };
131
+ }
132
+ const settingsPaths = [
133
+ ...(projectRoot ? [path.join(projectRoot, SETTINGS_RELATIVE_PROBE_PATH)] : []),
134
+ path.join(homeDir, SETTINGS_RELATIVE_PROBE_PATH),
135
+ ];
136
+ for (const settingsPath of settingsPaths) {
137
+ const envBlock = readSettingsEnvBlock(settingsPath);
138
+ if (!envBlock) continue;
139
+ const key = pickApiKey(envBlock);
140
+ if (key) {
141
+ return { value: key.value, scheme: key.source, source: 'claude-settings' };
142
+ }
143
+ }
144
+ return { value: null, scheme: null, source: null };
145
+ }
146
+
99
147
  function pickApiKey(envLike) {
100
148
  if (!envLike || typeof envLike !== 'object') return null;
101
149
  // Source matters: ANTHROPIC_API_KEY is sent by Claude Code as `x-api-key`, while
@@ -112,7 +160,16 @@ function pickApiKey(envLike) {
112
160
 
113
161
  function pickModel(envLike) {
114
162
  if (!envLike || typeof envLike !== 'object') return null;
115
- return pickString(envLike.ANTHROPIC_MODEL);
163
+ // ANTHROPIC_MODEL wins, then the per-lane alias mappings Claude Code itself resolves
164
+ // (2026-09-16 live evidence: alias-based gateways provision ONLY the mapped lane models;
165
+ // probing the built-in default `claude-sonnet-4-5` returned HTTP 404 `model_not_found` /
166
+ // "No active credentials for provider: claude" on a gateway whose real sessions work).
167
+ return pickString(
168
+ envLike.ANTHROPIC_MODEL,
169
+ envLike.ANTHROPIC_DEFAULT_OPUS_MODEL,
170
+ envLike.ANTHROPIC_DEFAULT_SONNET_MODEL,
171
+ envLike.ANTHROPIC_DEFAULT_HAIKU_MODEL,
172
+ );
116
173
  }
117
174
 
118
175
  // Minimal VALID Anthropic Messages API body. The probe only measures transport shape
@@ -132,11 +189,14 @@ function buildRequestBody({ stream, model }) {
132
189
  // * ANTHROPIC_API_KEY -> x-api-key (Anthropic-native)
133
190
  // * ANTHROPIC_AUTH_TOKEN -> Authorization: Bearer (OpenAI-style / proxy gateways)
134
191
  // Without this split, token-auth gateways 401 the probe even when transport shape is
135
- // valid (Reviewer Round-1 important finding #3).
192
+ // valid (Reviewer Round-1 important finding #3). `anthropic-version` is sent for BOTH
193
+ // schemes: it is required by Anthropic-native gateways and ignored by OpenAI-style proxies
194
+ // (2026-09-16 live evidence: the token-auth UNIC gateway returns 200 + valid Message with
195
+ // the header present).
136
196
  function buildAuthHeaders(keyValue, keySource) {
137
197
  if (!keyValue) return {};
138
198
  if (keySource === 'auth-token') {
139
- return { Authorization: `Bearer ${keyValue}` };
199
+ return { Authorization: `Bearer ${keyValue}`, 'anthropic-version': '2023-06-01' };
140
200
  }
141
201
  // Default to the Anthropic-native scheme — covers explicit apiKey arg and
142
202
  // ANTHROPIC_API_KEY env (the dominant case).
@@ -161,6 +221,62 @@ function parseSseEvents(text) {
161
221
  return count;
162
222
  }
163
223
 
224
+ // TASK-010 (error-body surfacing): on a non-OK response the gateway body often carries the
225
+ // operator-actionable cause (2026-09-16 live evidence: HTTP 404 + `{"error":{"message":"No
226
+ // active credentials for provider: anthropic"}}`). The read is bounded and the surfaced
227
+ // message is capped at 300 chars (PLAN §3/§4 contract) so a giant HTML error page can never
228
+ // flood the doctor output.
229
+ const ERROR_DETAIL_MAX_CHARS = 300;
230
+ const ERROR_BODY_MAX_CHARS = 4096;
231
+
232
+ async function readBodyTextBounded(response) {
233
+ const body = response?.body;
234
+ if (!body || typeof body.getReader !== 'function') return '';
235
+ const reader = body.getReader();
236
+ const decoder = new TextDecoder();
237
+ let text = '';
238
+ try {
239
+ while (text.length < ERROR_BODY_MAX_CHARS) {
240
+ const { value, done } = await reader.read();
241
+ if (done) break;
242
+ if (value) text += decoder.decode(value, { stream: true });
243
+ }
244
+ } catch {
245
+ // A failing/unreadable error body must never turn into a probe crash — fall back to
246
+ // whatever was collected so far (possibly empty).
247
+ } finally {
248
+ try {
249
+ reader.releaseLock();
250
+ } catch {
251
+ // reader may already be released
252
+ }
253
+ }
254
+ return text;
255
+ }
256
+
257
+ /**
258
+ * Extracts the gateway error message from a non-OK response body:
259
+ * `JSON.parse(text).error.message`, truncated to `ERROR_DETAIL_MAX_CHARS`. Returns `null`
260
+ * when the body is missing, unreadable, non-JSON, or carries no error message — callers
261
+ * then leave `errorDetail` unset and keep today's `HTTP <status>` wording. Never throws.
262
+ */
263
+ async function extractErrorDetail(response) {
264
+ const text = await readBodyTextBounded(response);
265
+ if (!text) return null;
266
+ let parsed = null;
267
+ try {
268
+ parsed = JSON.parse(text);
269
+ } catch {
270
+ return null;
271
+ }
272
+ const message =
273
+ parsed && typeof parsed === 'object' && typeof parsed?.error?.message === 'string'
274
+ ? parsed.error.message
275
+ : null;
276
+ if (!message) return null;
277
+ return message.slice(0, ERROR_DETAIL_MAX_CHARS);
278
+ }
279
+
164
280
  /**
165
281
  * Streaming probe: posts `stream: true` and counts SSE frames + distinct body reads.
166
282
  *
@@ -169,7 +285,8 @@ function parseSseEvents(text) {
169
285
  * buffering gateway dumps everything in one read.
170
286
  *
171
287
  * @returns {Promise<{ ok: boolean, eventsReceived: number, firstEventMs: number|null,
172
- * chunks: number, buffered: boolean, error?: string }>}
288
+ * chunks: number, buffered: boolean, error?: string,
289
+ * errorDetail?: string|null }>}
173
290
  */
174
291
  async function runStreamingProbe({ url, headers, body: requestBody, fetchImpl, timeoutMs }) {
175
292
  const controller = new AbortController();
@@ -190,6 +307,7 @@ async function runStreamingProbe({ url, headers, body: requestBody, fetchImpl, t
190
307
  chunks: 0,
191
308
  buffered: false,
192
309
  error: `HTTP ${response?.status ?? 'unknown'}`,
310
+ errorDetail: await extractErrorDetail(response),
193
311
  };
194
312
  }
195
313
  const body = response.body;
@@ -278,7 +396,8 @@ async function runStreamingProbe({ url, headers, body: requestBody, fetchImpl, t
278
396
  * gateway error envelope slip through Claude Code's retry path and kill the user's turn.
279
397
  *
280
398
  * @returns {Promise<{ ok: boolean, httpOk: boolean, isAnthropicMessage: boolean,
281
- * requestIdPresent: boolean, bodyBytes: number|null, error?: string }>}
399
+ * requestIdPresent: boolean, bodyBytes: number|null, error?: string,
400
+ * errorDetail?: string|null }>}
282
401
  */
283
402
  async function runNonStreamingProbe({ url, headers, body: requestBody, fetchImpl, timeoutMs }) {
284
403
  const controller = new AbortController();
@@ -300,6 +419,7 @@ async function runNonStreamingProbe({ url, headers, body: requestBody, fetchImpl
300
419
  requestIdPresent,
301
420
  bodyBytes: null,
302
421
  error: `HTTP ${response?.status ?? 'unknown'}`,
422
+ errorDetail: await extractErrorDetail(response),
303
423
  };
304
424
  }
305
425
  const body = response.body;
@@ -341,7 +461,12 @@ async function runNonStreamingProbe({ url, headers, body: requestBody, fetchImpl
341
461
  typeof parsed === 'object' &&
342
462
  parsed.type === 'message' &&
343
463
  Array.isArray(parsed.content);
344
- const ok = isAnthropicMessage && requestIdPresent;
464
+ // 2026-09-16 live re-classification: the real UNIC gateway returns HTTP 200 + a valid
465
+ // Anthropic Message with NO request-id header, and real sessions complete fine — the
466
+ // hard pass/fail gate is the Message shape (which still catches the 888-byte error-
467
+ // envelope class). A missing request-id is reported as an advisory hint instead of a
468
+ // hard fail; see GATEWAY_DOC §3.
469
+ const ok = isAnthropicMessage;
345
470
  return {
346
471
  ok,
347
472
  httpOk: true,
@@ -406,11 +531,12 @@ export async function probeGateway({
406
531
  if (!streaming.ok) {
407
532
  if (streaming.buffered) {
408
533
  hints.push(
409
- `Set CLAUDE_STREAM_IDLE_TIMEOUT_MS=600000 in .claude/settings.json env — gateway appears to buffer the entire stream; see ${GATEWAY_DOC}.`,
534
+ `Gateway buffers the entire stream (the stall signature). Managed posture: keep CLAUDE_STREAM_IDLE_TIMEOUT_MS low so the idle watchdog fires, and keep the non-streaming fallback ENABLED so the aborted stream recovers via a non-streaming retry; see ${GATEWAY_DOC}.`,
410
535
  );
411
536
  } else if (streaming.error) {
537
+ const gatewayCause = streaming.errorDetail ? ` — ${streaming.errorDetail}` : '';
412
538
  hints.push(
413
- `Streaming probe failed: ${streaming.error} — see ${GATEWAY_DOC}.`,
539
+ `Streaming probe failed: ${streaming.error}${gatewayCause} (probed model: ${effectiveModel}) — see ${GATEWAY_DOC}.`,
414
540
  );
415
541
  } else {
416
542
  hints.push(
@@ -421,21 +547,23 @@ export async function probeGateway({
421
547
  if (!nonStreaming.ok) {
422
548
  if (nonStreaming.httpOk && !nonStreaming.isAnthropicMessage) {
423
549
  hints.push(
424
- `Set CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK=1 in .claude/settings.json env — non-streaming route returned JSON but not an Anthropic Message; see ${GATEWAY_DOC}.`,
425
- );
426
- } else if (nonStreaming.httpOk && !nonStreaming.requestIdPresent) {
427
- hints.push(
428
- `Gateway must set the request-id (or anthropic-request-id) response header — see ${GATEWAY_DOC}.`,
550
+ `Non-streaming route returned JSON but not an Anthropic Message — leave CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK=1 (fallback must stay off); see ${GATEWAY_DOC}.`,
429
551
  );
430
552
  } else if (nonStreaming.error) {
553
+ const gatewayCause = nonStreaming.errorDetail ? ` — ${nonStreaming.errorDetail}` : '';
431
554
  hints.push(
432
- `Non-streaming probe failed: ${nonStreaming.error} — see ${GATEWAY_DOC}.`,
555
+ `Non-streaming probe failed: ${nonStreaming.error}${gatewayCause} (probed model: ${effectiveModel}) — see ${GATEWAY_DOC}.`,
433
556
  );
434
557
  } else {
435
558
  hints.push(`Non-streaming probe failed — see ${GATEWAY_DOC}.`);
436
559
  }
437
560
  }
561
+ if (nonStreaming.httpOk && nonStreaming.isAnthropicMessage && !nonStreaming.requestIdPresent) {
562
+ hints.push(
563
+ `Gateway did not set a request-id (or anthropic-request-id) header — advisory only, diagnosability is degraded but transport works; see ${GATEWAY_DOC}.`,
564
+ );
565
+ }
438
566
 
439
567
  const verdict = streaming.ok && nonStreaming.ok ? 'pass' : 'fail';
440
- return { streaming, nonStreaming, verdict, hints };
568
+ return { streaming, nonStreaming, verdict, hints, model: effectiveModel };
441
569
  }