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 +116 -0
- package/bin/scalix.js +1 -0
- package/dist/agent.js +69 -31
- package/dist/api-client.js +44 -9
- package/dist/auto-verify.js +3 -1
- package/dist/cli.js +931 -183
- package/dist/commands.js +70 -2
- package/dist/constants.js +87 -6
- package/dist/context-retriever.js +1 -1
- package/dist/cost-tracker.js +21 -16
- package/dist/git-context.js +8 -5
- package/dist/grounding-checker.js +8 -1
- package/dist/headless.js +116 -33
- package/dist/hooks.js +195 -13
- package/dist/injection-detector.js +18 -1
- package/dist/ipc-server.js +7 -1
- package/dist/keychain.js +222 -0
- package/dist/markdown.js +49 -3
- package/dist/model-router.js +11 -0
- package/dist/plugins.js +132 -0
- package/dist/project-index.js +20 -1
- package/dist/sandbox.js +139 -9
- package/dist/scoped-rules.js +10 -1
- package/dist/sessions.js +65 -6
- package/dist/settings.js +124 -4
- package/dist/skills.js +269 -0
- package/dist/sub-agent.js +59 -2
- package/dist/terminal-notify.js +73 -0
- package/dist/text-utils.js +146 -0
- package/dist/token-counter.js +10 -1
- package/dist/tool-helper.js +12 -4
- package/dist/tools-browser.js +65 -6
- package/dist/tools-extra.js +44 -14
- package/dist/tools-web.js +76 -22
- package/dist/tools.js +27 -2
- package/dist/workspace-trust.js +90 -0
- package/package.json +3 -2
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 {
|
|
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
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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 === '
|
|
887
|
-
reqLog.info('Auto-approving tool (no confirmation handler
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/dist/api-client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/auto-verify.js
CHANGED
|
@@ -74,7 +74,8 @@ async function detectChecker(filePath, projectRoot) {
|
|
|
74
74
|
}
|
|
75
75
|
// Verify the tool is available
|
|
76
76
|
try {
|
|
77
|
-
|
|
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 {
|