scalix 1.1.0 → 1.3.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.
package/PARITY-PLAN.md ADDED
@@ -0,0 +1,116 @@
1
+ # Scalix Coder — Claude Code Parity Implementation Plan
2
+
3
+ **Status:** In Progress
4
+ **Target:** Full feature and UX parity with Claude Code
5
+ **Estimated Total:** 12-15 days
6
+
7
+ ---
8
+
9
+ ## Phase 1: Security & Reliability (1-2 days) ✓ COMPLETE
10
+
11
+ - [x] **1.1 Secure credential storage** — macOS Keychain / Linux libsecret via `keychain.ts`, auto-strips key from config file
12
+ - [x] **1.2 Auto-retry with exponential backoff** — Already existed in agent.ts (backoffDelay + circuit breaker)
13
+ - [x] **1.3 Non-streaming fallback** — `forceNonStreaming` flag auto-retries with `stream: false` on stream errors
14
+
15
+ ## Phase 2: Chat Experience Parity (3-4 days) ✓ COMPLETE
16
+
17
+ - [x] **2.1 Syntax highlighting in code blocks** — `cli-highlight` integration for 100+ languages + render cache (128 entries)
18
+ - [x] **2.2 Collapsible tool groups** — Auto-groups 3+ consecutive tool items, Ctrl+O toggles expansion
19
+ - [x] **2.3 Multiline input** — Alt+Enter inserts newline (terminal limitation prevents Shift+Enter)
20
+ - [x] **2.4 "Always Allow" in permission prompts** — [A] key auto-allows tool for session, shown in prompt UI
21
+ - [x] **2.5 @-mention typeahead in terminal** — `@` triggers fuzzy file search from project index, Tab/Enter selects
22
+ - [x] **2.6 Ctrl+R reverse history search** — Incremental reverse search with visual indicator, Enter/Esc to accept/cancel
23
+ - [x] **2.7 External editor support** — Ctrl+X Ctrl+E opens $EDITOR with input buffer, loads edited content back
24
+ - [x] **2.8 Markdown render cache** — Content-hash cache (128 entries) prevents re-rendering identical content
25
+ - [x] **2.9 Rate limit warning banner** — Context % in status bar when >50%, rate limit warning on 429 errors, 2-min auto-clear
26
+ - [x] **2.10 Image paste from clipboard** — Ctrl+V detects clipboard images (macOS pngpaste/osascript, Linux xclip), shows `[Image #N]` chips, sends to vision model
27
+
28
+ ## Phase 3: Permission & Hook System (2-3 days) ✓ COMPLETE
29
+
30
+ - [x] **3.1 Scoped permissions** — `run_command(git:*)` prefix matching per tool, configurable allow/deny rules in `scopedPermissions` config
31
+ - [x] **3.2 Enhanced hooks — 9 events** — Added Stop, SessionEnd, Notification events (now 9 total)
32
+ - [x] **3.3 Prompt-type hooks** — `type: 'prompt'` hooks with `getPromptHooks()` API, separate from command hooks
33
+ - [x] **3.4 Structured hook I/O** — JSON stdin with event/tool/args, JSON stdout parsing, exit code 2 = block with message
34
+ - [x] **3.5 Matcher patterns** — Pipe-separated `run_command|edit_file`, wildcard `*`, regex `/pattern/` in hook match field
35
+ - [x] **3.6 Permission memory** — "Always Allow" ([A] key) persisted per session, scoped rules in config, shown in `/permissions`
36
+ - [x] **3.7 Recent denials view** — `/permissions` shows last 10 denied tools with timestamps and args
37
+
38
+ ## Phase 4: Command & Skills System (2-3 days) ✓ COMPLETE
39
+
40
+ - [x] **4.1 Rich command frontmatter** — Parse `allowed-tools`, `model`, `argument-hint`, `disable-model-invocation` from .md commands
41
+ - [x] **4.2 @file references in commands** — `@path/to/file` includes file content inline in command prompt
42
+ - [x] **4.3 Inline bash execution** — `` !`command` `` syntax executes shell and injects stdout into command prompt
43
+ - [x] **4.4 Positional arguments** — `$1`, `$2`, etc. in addition to existing `$ARGS`
44
+ - [x] **4.5 Skills framework** — `SKILL.md` with frontmatter (name, description, version, paths), progressive 3-level disclosure
45
+ - [x] **4.6 Skill discovery** — `/skills` command lists available skills with descriptions
46
+ - [x] **4.7 Path-triggered skills** — `paths:` frontmatter auto-loads skill when working in matching directories
47
+ - [x] **4.8 Skill resources** — `scripts/`, `references/`, `examples/`, `assets/` directories per skill
48
+ - [x] **4.9 Built-in skills** — feature-dev (7-phase workflow), code-review, commit-push-pr
49
+
50
+ ## Phase 5: Session & Conversation (1-2 days) ✓ COMPLETE
51
+
52
+ - [x] **5.1 Auto-save sessions** — Auto-saves crash state after each assistant turn via `saveCrashState()`
53
+ - [x] **5.2 Session resume picker** — `/resume` tries crash recovery first, falls back to interactive session picker
54
+ - [x] **5.3 Session forking** — `/fork [N]` branches conversation from message N in history
55
+ - [x] **5.4 Session diff** — `getSessionDiff()` tracks file modifications (edit_file/write_file) per session
56
+ - [x] **5.5 Crash recovery** — `.crash-recovery.json` auto-saved, detected on startup, resumable via `/resume`
57
+
58
+ ## Phase 6: Configuration & Settings (1 day) ✓ COMPLETE
59
+
60
+ - [x] **6.1 Local settings overrides** — `.scalix/settings.local.json` (gitignored) for per-project model/permission/limits overrides
61
+ - [x] **6.2 Managed/organization settings** — `managed-settings.json` for enterprise policy enforcement (permission mode, security, scoped permissions)
62
+ - [x] **6.3 Keybinding customization** — Covered by existing keyboard shortcuts system + Ctrl+X Ctrl+E editor support
63
+ - [x] **6.4 Theme support** — Covered by existing marked-terminal + cli-highlight integration (auto-adapts to terminal colors)
64
+ - [x] **6.5 .scalixignore file** — `.scalixignore` and `.scalix/.ignore` files for excluding paths from project indexing
65
+
66
+ ## Phase 7: Advanced Features (1-2 days) ✓ COMPLETE
67
+
68
+ - [x] **7.1 Worktree support** — `--worktree` CLI flag + `/worktree` command (list/create/remove/cleanup), backed by existing `worktree.ts`
69
+ - [x] **7.2 CJK/RTL text support** — `text-utils.ts` with CJK/RTL detection, double-width char handling, `wrapText()`, `applyRTLMarks()`
70
+ - [x] **7.3 IME composition support** — `stringWidth()` for accurate cursor positioning with CJK chars, combining mark handling
71
+ - [x] **7.4 Idle session nudge** — 75-minute idle timer suggests `/clear` or `/compact` when conversation has 10+ messages
72
+ - [x] **7.5 Terminal notification passthrough** — `terminal-notify.ts` with iTerm2/Kitty/Ghostty/tmux OSC support, auto-notify on task completion
73
+ - [x] **7.6 Plugin management commands** — `/plugin install/uninstall/enable/disable/list` with git clone + local symlink support
74
+ - [x] **7.7 Installation channels** — Homebrew formula, curl install script, PowerShell installer (all in `scripts/`)
75
+
76
+ ---
77
+
78
+ ## Summary
79
+
80
+ | Phase | Items | Est. Days | Priority |
81
+ |-------|-------|-----------|----------|
82
+ | 1. Security & Reliability | 3 | 1-2 | Critical |
83
+ | 2. Chat Experience | 10 | 3-4 | Critical |
84
+ | 3. Permission & Hooks | 7 | 2-3 | Critical |
85
+ | 4. Command & Skills | 9 | 2-3 | Important |
86
+ | 5. Session & Conversation | 5 | 1-2 | Important |
87
+ | 6. Configuration | 5 | 1 | Important |
88
+ | 7. Advanced Features | 7 | 1-2 | Nice to have |
89
+ | **Total** | **46** | **12-15** | |
90
+
91
+ ---
92
+
93
+ ## Already Ahead (Preserve These Advantages)
94
+
95
+ These features already exceed Claude Code — protect them during implementation:
96
+
97
+ - 40+ built-in tools (browser automation, multi-provider search, vision, voice)
98
+ - Model-agnostic with multi-model complexity routing
99
+ - Dynamic model discovery from API
100
+ - Auto-convention detection and project memory
101
+ - Injection detection with configurable sensitivity
102
+ - Checkpoint/undo system (`/checkpoint`, `/undo`)
103
+ - VS Code: inline chat, tab completion, context menu, 6 @-mention types
104
+ - Session replay (`/replay`)
105
+ - Message queueing during processing
106
+ - Shell escape (`!cmd`) prefix
107
+ - Input stashing (Ctrl+S)
108
+ - Auto-verification after file edits (build/lint check)
109
+ - Multiple output formats (text, json, json-schema, patch, markdown)
110
+ - Gradient banner with ink-big-text
111
+ - Informative status bar (model + tokens + tools + mode + elapsed time + version)
112
+ - Box-drawn diff frames with addition/deletion summary
113
+ - Smart error hints (pattern-matched suggestions)
114
+ - Scroll indicators (↑ more above / ↓ more below)
115
+ - Distinctive ❯ prompt with spinner animation
116
+ - Dynamic placeholder text
package/bin/scalix.js CHANGED
@@ -13,6 +13,7 @@ const needsHeadless =
13
13
  args.includes('--pipe') || args.includes('-p') ||
14
14
  args.includes('--ipc') ||
15
15
  args.includes('--help') || args.includes('-h') ||
16
+ args.includes('--worktree') || args.includes('-w') ||
16
17
  !process.stdin.isTTY;
17
18
 
18
19
  const entry = resolve(__dirname, '..', 'dist', needsHeadless ? 'headless.js' : 'cli.js');
package/dist/agent.js CHANGED
@@ -1,7 +1,7 @@
1
- import { ScalixClient } from './api-client.js';
1
+ import { ScalixClient, ApiError } from './api-client.js';
2
2
  import { EventEmitter } from 'eventemitter3';
3
3
  import { randomUUID } from 'node:crypto';
4
- import { getToolPermission } from './sandbox.js';
4
+ import { getToolPermissionWithArgs } from './sandbox.js';
5
5
  import { logger } from './logger.js';
6
6
  import { DEFAULT_MODELS as FALLBACK_MODELS, DEFAULT_INSTRUCTIONS, AGENT_DEFAULTS, TOOL_DEFAULTS, BACKOFF_DEFAULTS, modelSupportsVision, getModelSpec } from './constants.js';
7
7
  import { estimateMessageTokens as estimateTokenCount } from './token-counter.js';
@@ -156,6 +156,8 @@ export class Agent extends EventEmitter {
156
156
  groundingConfig = null;
157
157
  groundingNudgeCount = 0;
158
158
  retrievalConfig = null;
159
+ _sending = false;
160
+ outputFormatInstructionIndex = null;
159
161
  constructor(config) {
160
162
  super();
161
163
  this.agentId = config.agentId ?? generateAgentId();
@@ -185,6 +187,7 @@ export class Agent extends EventEmitter {
185
187
  circuitBreakerResetMs: config.circuitBreakerResetMs ?? AGENT_DEFAULTS.circuitBreakerResetMs,
186
188
  fileRefTruncation: config.fileRefTruncation ?? TOOL_DEFAULTS.fileRefTruncation,
187
189
  thinkingMode: config.thinkingMode ?? 'off',
190
+ workspaceRoot: config.workspaceRoot ?? process.cwd(),
188
191
  };
189
192
  this.contextManager = new ContextManager(config.contextManager);
190
193
  if (config.dynamicToolSelection) {
@@ -252,22 +255,22 @@ export class Agent extends EventEmitter {
252
255
  shouldFallbackToNext(err) {
253
256
  const e = err;
254
257
  const message = (e?.message || e?.error?.message || '').toLowerCase();
255
- const statusCode = e?.status || e?.statusCode || e?.error?.code;
256
- if (statusCode === 401 || statusCode === 403 || message.includes('unauthorized') || message.includes('forbidden') || message.includes('invalid api key')) {
258
+ // Prefer the structured status carried by ApiError; fall back to other shapes.
259
+ const status = err instanceof ApiError ? err.status : (e?.status ?? e?.statusCode ?? e?.error?.code);
260
+ if (status === 401 || status === 403 || message.includes('unauthorized') || message.includes('forbidden') || message.includes('invalid api key')) {
257
261
  return false;
258
262
  }
263
+ // Retry/fallback on transient upstream statuses.
264
+ if (status === 429 || status === 500 || status === 502 || status === 503 || status === 408 || status === 404) {
265
+ return true;
266
+ }
259
267
  return (message.includes('429') ||
260
268
  message.includes('rate limit') ||
261
269
  message.includes('provider returned error') ||
262
270
  message.includes('safety') ||
263
271
  message.includes('streaming failed') ||
264
272
  message.includes('stream error') ||
265
- message.includes('try again') ||
266
- statusCode === 429 ||
267
- statusCode === 500 ||
268
- statusCode === 502 ||
269
- statusCode === 503 ||
270
- statusCode === 404);
273
+ message.includes('try again'));
271
274
  }
272
275
  isModelCircuitOpen(model) {
273
276
  const state = this.modelFailures.get(model);
@@ -294,6 +297,10 @@ export class Agent extends EventEmitter {
294
297
  const keepCount = Math.floor(this.config.maxMessages * this.config.messageKeepRatio);
295
298
  const dropped = this.messages.length - keepCount;
296
299
  this.messages = this.messages.slice(dropped);
300
+ // Drop leading orphan tool messages — a tool result whose preceding
301
+ // assistant tool_call was sliced away would be rejected by the API.
302
+ while (this.messages[0]?.role === 'tool')
303
+ this.messages.shift();
297
304
  this.log.info(`Truncated ${dropped} oldest messages, keeping ${this.messages.length}`);
298
305
  }
299
306
  emitThinkingEnd() {
@@ -331,7 +338,7 @@ export class Agent extends EventEmitter {
331
338
  model,
332
339
  messages: apiMessages,
333
340
  stream: false,
334
- });
341
+ }, this._abortController?.signal);
335
342
  const summary = result.choices?.[0]?.message?.content?.trim() || '';
336
343
  if (!summary)
337
344
  return 'Compaction failed: no summary generated.';
@@ -386,7 +393,7 @@ export class Agent extends EventEmitter {
386
393
  { role: 'user', content: prompt },
387
394
  ],
388
395
  stream: false,
389
- });
396
+ }, this._abortController?.signal);
390
397
  const summary = result.choices?.[0]?.message?.content?.trim() || '';
391
398
  if (summary) {
392
399
  this.messages = [
@@ -451,6 +458,9 @@ export class Agent extends EventEmitter {
451
458
  deregisterFromRegistry(status) {
452
459
  if (!this.registeredInRegistry)
453
460
  return;
461
+ // Reset so a fresh send() re-registers, and guard against double-deregister
462
+ // (e.g. an aborted turn followed by the send() catch/finally path).
463
+ this.registeredInRegistry = false;
454
464
  AgentRegistry.getInstance().deregister(this.agentId, status);
455
465
  }
456
466
  checkInbox() {
@@ -540,7 +550,15 @@ export class Agent extends EventEmitter {
540
550
  else {
541
551
  instruction = 'IMPORTANT: Your final response MUST be valid JSON. Do not include any text outside the JSON object. No markdown code fences, no explanation — only the raw JSON.';
542
552
  }
553
+ // Inject the format instruction only once per session — refresh the existing
554
+ // system message in place if it's still valid, otherwise append once.
555
+ const existing = this.outputFormatInstructionIndex;
556
+ if (existing !== null && this.messages[existing]?.role === 'system') {
557
+ this.messages[existing] = { role: 'system', content: instruction };
558
+ return;
559
+ }
543
560
  this.messages.push({ role: 'system', content: instruction });
561
+ this.outputFormatInstructionIndex = this.messages.length - 1;
544
562
  }
545
563
  extractJsonFromResponse(response) {
546
564
  const trimmed = response.trim();
@@ -649,6 +667,7 @@ export class Agent extends EventEmitter {
649
667
  let retryAttempt = 0;
650
668
  let emptyTurnCount = 0;
651
669
  let disableTools = false;
670
+ let forceNonStreaming = false;
652
671
  while (turnCount < this.config.maxSteps) {
653
672
  if (this._aborted) {
654
673
  reqLog.info('Agent aborted by user');
@@ -704,7 +723,7 @@ export class Agent extends EventEmitter {
704
723
  const apiMessages = messagesToApi(this.messages, effectiveInstructions, vision);
705
724
  const thinkingLevel = this.config.thinkingMode !== 'off' ? this.config.thinkingMode : undefined;
706
725
  const hasToolMessages = this.messages.some(m => m.role === 'tool' || (m.role === 'assistant' && m.tool_calls && m.tool_calls.length > 0));
707
- const useStreaming = !hasToolMessages;
726
+ const useStreaming = !hasToolMessages && !forceNonStreaming;
708
727
  if (useStreaming) {
709
728
  const stream = await this.client.chatComplete({
710
729
  model: currentModel,
@@ -867,7 +886,28 @@ export class Agent extends EventEmitter {
867
886
  continue;
868
887
  }
869
888
  }
870
- const permission = getToolPermission(call.name, this.config.permissionMode);
889
+ // Parse tool arguments once, up front, so the permission decision,
890
+ // the confirmation prompt, and execution all see the same values.
891
+ let parsedArgs;
892
+ const rawArgs = (call.arguments ?? '').trim();
893
+ if (rawArgs === '') {
894
+ parsedArgs = {};
895
+ }
896
+ else {
897
+ try {
898
+ parsedArgs = JSON.parse(call.arguments);
899
+ }
900
+ catch {
901
+ result = { error: 'Malformed JSON arguments — resend the tool call with valid JSON' };
902
+ reqLog.warn('Malformed tool arguments', { tool: call.name });
903
+ this.emit('tool:blocked', call.name, 'Malformed arguments');
904
+ this.messages.push({ role: 'tool', content: JSON.stringify(result), tool_call_id: call.id, name: call.name });
905
+ this.emit('tool:result', call.name, result);
906
+ continue;
907
+ }
908
+ }
909
+ // Args-aware permission so scoped allow/deny rules are enforced.
910
+ const permission = getToolPermissionWithArgs(call.name, this.config.permissionMode, parsedArgs);
871
911
  if (permission === 'block') {
872
912
  result = { denied: true, message: `Tool ${call.name} is blocked in ${this.config.permissionMode} mode` };
873
913
  reqLog.info('Tool blocked by permission mode', { tool: call.name, mode: this.config.permissionMode });
@@ -883,8 +923,8 @@ export class Agent extends EventEmitter {
883
923
  }
884
924
  if (permission === 'ask') {
885
925
  if (!this.config.confirmToolCall) {
886
- if (this.config.permissionMode === 'auto') {
887
- reqLog.info('Auto-approving tool (no confirmation handler in auto mode)', { tool: call.name });
926
+ if (this.config.permissionMode === 'yolo') {
927
+ reqLog.info('Auto-approving tool (yolo mode, no confirmation handler)', { tool: call.name });
888
928
  }
889
929
  else {
890
930
  result = { denied: true, message: `Tool ${call.name} requires confirmation but no confirmation handler is available` };
@@ -901,13 +941,7 @@ export class Agent extends EventEmitter {
901
941
  }
902
942
  }
903
943
  else {
904
- let args;
905
- try {
906
- args = JSON.parse(call.arguments);
907
- }
908
- catch {
909
- args = call.arguments;
910
- }
944
+ const args = parsedArgs;
911
945
  const CONFIRM_TIMEOUT_MS = 300_000;
912
946
  const confirmed = await new Promise((resolve) => {
913
947
  const timer = setTimeout(() => resolve(false), CONFIRM_TIMEOUT_MS);
@@ -939,20 +973,14 @@ export class Agent extends EventEmitter {
939
973
  const execFn = tool?.function?.execute ?? tool?.execute;
940
974
  if (tool && typeof execFn === 'function') {
941
975
  try {
942
- let args;
943
- try {
944
- args = normalizeArgs(JSON.parse(call.arguments));
945
- }
946
- catch {
947
- args = {};
948
- }
976
+ const args = normalizeArgs(parsedArgs);
949
977
  reqLog.debug('Executing tool', { tool: call.name });
950
978
  const toolTimeout = (typeof args?.timeout === 'number' && args.timeout > 0)
951
979
  ? Math.min(args.timeout, TOOL_DEFAULTS.maxCommandTimeoutMs)
952
980
  : this.config.toolTimeoutMs;
953
981
  result = await withTimeout(execFn(args, {
954
982
  context: {},
955
- workspaceRoot: process.cwd(),
983
+ workspaceRoot: this.config.workspaceRoot ?? process.cwd(),
956
984
  permissionMode: this.config.permissionMode,
957
985
  agentId: this.agentId,
958
986
  agentConfig: {
@@ -1107,8 +1135,18 @@ export class Agent extends EventEmitter {
1107
1135
  catch (err) {
1108
1136
  this._abortController = null;
1109
1137
  this.recordModelFailure(currentModel);
1138
+ const errMsg = (err?.message || '').toLowerCase();
1139
+ const isStreamError = errMsg.includes('stream') || errMsg.includes('sse') || errMsg.includes('no response body');
1140
+ // On stream failure, retry once with non-streaming before giving up
1141
+ if (isStreamError && !forceNonStreaming) {
1142
+ forceNonStreaming = true;
1143
+ reqLog.warn('Streaming failed, retrying with non-streaming mode', { error: err.message });
1144
+ await sleep(500);
1145
+ continue;
1146
+ }
1110
1147
  if (this.shouldFallbackToNext(err) && retryAttempt < this.config.maxRetries && turnCount < this.config.maxSteps) {
1111
1148
  retryAttempt++;
1149
+ forceNonStreaming = false;
1112
1150
  const delay = backoffDelay(retryAttempt);
1113
1151
  reqLog.warn(`Retrying after ${Math.round(delay)}ms (attempt ${retryAttempt}/${this.config.maxRetries})`, {
1114
1152
  model: currentModel,
@@ -1,13 +1,28 @@
1
+ export class ApiError extends Error {
2
+ status;
3
+ body;
4
+ constructor(status, statusText, body) {
5
+ super(`API request failed: ${status} ${statusText}${body ? ` — ${body.slice(0, 200)}` : ''}`);
6
+ this.name = 'ApiError';
7
+ this.status = status;
8
+ this.body = body;
9
+ }
10
+ }
11
+ const DEFAULT_TIMEOUT_MS = 120_000;
1
12
  export class ScalixClient {
2
13
  baseURL;
3
14
  apiKey;
4
- constructor(baseURL, apiKey) {
15
+ timeoutMs;
16
+ constructor(baseURL, apiKey, timeoutMs = DEFAULT_TIMEOUT_MS) {
5
17
  this.baseURL = baseURL.replace(/\/+$/, '');
6
18
  this.apiKey = apiKey;
19
+ this.timeoutMs = timeoutMs;
7
20
  }
8
21
  async chatComplete(req, signal) {
9
22
  const url = `${this.baseURL}/chat/completions`;
10
23
  const body = JSON.stringify(req);
24
+ const timeoutSignal = AbortSignal.timeout(this.timeoutMs);
25
+ const combined = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
11
26
  const response = await fetch(url, {
12
27
  method: 'POST',
13
28
  headers: {
@@ -16,11 +31,11 @@ export class ScalixClient {
16
31
  'User-Agent': 'Scalix-Coder/1.0',
17
32
  },
18
33
  body,
19
- signal,
34
+ signal: combined,
20
35
  });
21
36
  if (!response.ok) {
22
37
  const text = await response.text().catch(() => '');
23
- throw new Error(`API request failed: ${response.status} ${response.statusText}${text ? ` — ${text.slice(0, 200)}` : ''}`);
38
+ throw new ApiError(response.status, response.statusText, text);
24
39
  }
25
40
  if (req.stream) {
26
41
  return this.parseSSEStream(response);
@@ -45,32 +60,52 @@ export class ScalixClient {
45
60
  const trimmed = line.trim();
46
61
  if (!trimmed || trimmed.startsWith(':'))
47
62
  continue;
48
- if (trimmed.startsWith('data: ')) {
49
- const data = trimmed.slice(6);
63
+ if (trimmed.startsWith('data:')) {
64
+ const data = trimmed.slice(5).replace(/^ /, '');
50
65
  if (data === '[DONE]')
51
66
  return;
67
+ let parsed;
52
68
  try {
53
- yield JSON.parse(data);
69
+ parsed = JSON.parse(data);
54
70
  }
55
71
  catch {
56
72
  // skip malformed chunks
73
+ continue;
74
+ }
75
+ if (parsed && parsed.error) {
76
+ const err = parsed.error;
77
+ throw new Error(`Stream error: ${typeof err === 'string' ? err : (err?.message || JSON.stringify(err))}`);
57
78
  }
79
+ yield parsed;
58
80
  }
59
81
  }
60
82
  }
61
- if (buffer.trim().startsWith('data: ')) {
62
- const data = buffer.trim().slice(6);
83
+ const tail = buffer.trim();
84
+ if (tail.startsWith('data:')) {
85
+ const data = tail.slice(5).replace(/^ /, '');
63
86
  if (data !== '[DONE]') {
87
+ let parsed;
64
88
  try {
65
- yield JSON.parse(data);
89
+ parsed = JSON.parse(data);
66
90
  }
67
91
  catch {
68
92
  // skip
69
93
  }
94
+ if (parsed) {
95
+ if (parsed.error) {
96
+ const err = parsed.error;
97
+ throw new Error(`Stream error: ${typeof err === 'string' ? err : (err?.message || JSON.stringify(err))}`);
98
+ }
99
+ yield parsed;
100
+ }
70
101
  }
71
102
  }
72
103
  }
73
104
  finally {
105
+ try {
106
+ await reader.cancel();
107
+ }
108
+ catch { }
74
109
  reader.releaseLock();
75
110
  }
76
111
  }
@@ -74,7 +74,8 @@ async function detectChecker(filePath, projectRoot) {
74
74
  }
75
75
  // Verify the tool is available
76
76
  try {
77
- await execFileAsync('which', [checker.command[0]], { timeout: 3000 });
77
+ const probe = process.platform === 'win32' ? 'where' : 'which';
78
+ await execFileAsync(probe, [checker.command[0]], { timeout: 3000, shell: process.platform === 'win32' });
78
79
  }
79
80
  catch {
80
81
  // Tool not installed, skip
@@ -100,6 +101,7 @@ export async function runVerification(filePath, projectRoot, config = VERIFY_DEF
100
101
  cwd: projectRoot,
101
102
  timeout: config.timeoutMs,
102
103
  maxBuffer: 512 * 1024,
104
+ shell: process.platform === 'win32',
103
105
  });
104
106
  const output = (stderr || stdout).trim();
105
107
  return {