qiksy 1.1.2 → 1.2.0
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/package.json +1 -1
- package/tools/client-finder/vendor/lib/agent.mjs +16 -5
- package/tools/client-finder/vendor/lib/claude-errors.mjs +73 -0
- package/tools/travel/vendor/lib/agent.mjs +16 -5
- package/tools/travel/vendor/lib/claude-errors.mjs +73 -0
- package/tools/work-finder/vendor/lib/agent.mjs +16 -5
- package/tools/work-finder/vendor/lib/claude-errors.mjs +73 -0
- package/tools/work-finder/vendor/lib/jobs.mjs +4 -0
- package/tools/work-finder/vendor/server.mjs +8 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qiksy",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Qiksy \u2014 one local engine for every Qiksy tool (Work Finder, Travel Assistant, Client Finder). Runs on your own Claude; nothing leaves your machine.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { spawnClaude } from './spawn-claude.mjs';
|
|
12
12
|
import { resolve, dirname } from 'node:path';
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { classifyClaudeError } from './claude-errors.mjs';
|
|
14
15
|
|
|
15
16
|
export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
16
17
|
|
|
@@ -92,6 +93,7 @@ export function runClaude({
|
|
|
92
93
|
child.on('close', () => activeChildren.delete(child));
|
|
93
94
|
|
|
94
95
|
let resultText = '';
|
|
96
|
+
let errSubtype = null;
|
|
95
97
|
let errText = '';
|
|
96
98
|
let costUsd = null;
|
|
97
99
|
let settled = false;
|
|
@@ -112,7 +114,7 @@ export function runClaude({
|
|
|
112
114
|
} catch {
|
|
113
115
|
/* already dead */
|
|
114
116
|
}
|
|
115
|
-
finish({ ok: false, error: `timeout after ${Math.round(timeoutMs / 1000)}s
|
|
117
|
+
finish({ ok: false, error: `timeout after ${Math.round(timeoutMs / 1000)}s`, code: 'timeout' });
|
|
116
118
|
}, timeoutMs);
|
|
117
119
|
|
|
118
120
|
if (signal) {
|
|
@@ -122,7 +124,7 @@ export function runClaude({
|
|
|
122
124
|
} catch {
|
|
123
125
|
/* already dead */
|
|
124
126
|
}
|
|
125
|
-
finish({ ok: false, error: 'cancelled' });
|
|
127
|
+
finish({ ok: false, error: 'cancelled', code: 'cancelled' });
|
|
126
128
|
};
|
|
127
129
|
if (signal.aborted) onAbort();
|
|
128
130
|
else signal.addEventListener('abort', onAbort, { once: true });
|
|
@@ -165,7 +167,7 @@ export function runClaude({
|
|
|
165
167
|
} else if (ev.type === 'result') {
|
|
166
168
|
resultText = ev.result || '';
|
|
167
169
|
costUsd = ev.total_cost_usd ?? null;
|
|
168
|
-
if (ev.is_error) errText = resultText || ev.subtype || 'agent error';
|
|
170
|
+
if (ev.is_error) { errText = resultText || ev.subtype || 'agent error'; errSubtype = ev.subtype || null; }
|
|
169
171
|
}
|
|
170
172
|
}
|
|
171
173
|
});
|
|
@@ -173,10 +175,19 @@ export function runClaude({
|
|
|
173
175
|
errText += chunk;
|
|
174
176
|
});
|
|
175
177
|
|
|
176
|
-
child.on('error', (err) =>
|
|
178
|
+
child.on('error', (err) => {
|
|
179
|
+
const c = classifyClaudeError(err.message, { subtype: errSubtype });
|
|
180
|
+
finish({ ok: false, error: `spawn failed: ${err.message}`, code: c.code, resetAt: c.resetAt });
|
|
181
|
+
});
|
|
177
182
|
child.on('close', (code) => {
|
|
178
183
|
if (code === 0 && resultText) finish({ ok: true, result: resultText });
|
|
179
|
-
else
|
|
184
|
+
else {
|
|
185
|
+
/* Name the failure so the app can say something useful — a spent usage limit
|
|
186
|
+
is not a crash, and used to look exactly like one. */
|
|
187
|
+
const raw = errText || `claude exited with code ${code}`;
|
|
188
|
+
const c = classifyClaudeError(raw, { exitCode: code, subtype: errSubtype });
|
|
189
|
+
finish({ ok: false, error: raw.slice(0, 2000), code: c.code, resetAt: c.resetAt });
|
|
190
|
+
}
|
|
180
191
|
});
|
|
181
192
|
});
|
|
182
193
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What went wrong with Claude — as a CODE, not a sentence.
|
|
3
|
+
*
|
|
4
|
+
* Until now every failure came back as raw CLI stderr and was shown to the person
|
|
5
|
+
* verbatim: "your 5-hour limit is up" and "the flag is wrong" arrived looking exactly
|
|
6
|
+
* the same, which reads as "the product is broken" (owner ask 2026-07-26). The apps
|
|
7
|
+
* cannot say anything useful about a state they cannot name, so this names them.
|
|
8
|
+
*
|
|
9
|
+
* The matching is deliberately loose — several phrasings per state, and the CLI's
|
|
10
|
+
* wording changes between versions. A miss falls through to 'unknown', which the UI
|
|
11
|
+
* still handles; a WRONG match would be worse, so each pattern is anchored on words
|
|
12
|
+
* that only appear for that one condition.
|
|
13
|
+
*
|
|
14
|
+
* Kept byte-identical across the three backends, like spawn-claude.mjs.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** @typedef {'limit-reached'|'credit-exhausted'|'not-authenticated'|'model-unavailable'|'overloaded'|'context-too-long'|'cli-missing'|'network'|'cancelled'|'timeout'|'unknown'} ClaudeErrorCode */
|
|
18
|
+
|
|
19
|
+
const PATTERNS = [
|
|
20
|
+
// The 5-hour / weekly subscription window. The CLI prints a reset time with it, which
|
|
21
|
+
// is the single most useful thing we can pass on.
|
|
22
|
+
{ code: 'limit-reached', re: /(usage|rate)\s*limit\s*(reached|exceeded)|limit\s*reached\s*[·|-]|you'?ve\s*(hit|reached)\s*your\s*.*limit|resets?\s+at\s+\d|rate_limit_error|\b429\b/i },
|
|
23
|
+
// Pay-as-you-go balance, not a subscription window — a different fix for the person.
|
|
24
|
+
{ code: 'credit-exhausted', re: /credit\s*balance\s*is\s*too\s*low|insufficient\s*(credit|funds|balance)|billing.*required|purchase\s*more\s*credits/i },
|
|
25
|
+
{ code: 'not-authenticated', re: /not\s*(logged\s*in|authenticated|signed\s*in)|please\s*(run\s*)?\/?login|authentication_error|invalid\s*api\s*key|oauth.*(expired|invalid)|session\s*expired|unauthoriz/i },
|
|
26
|
+
/* The real CLI wording, captured 2026-07-26: "There's an issue with the selected
|
|
27
|
+
model (…). It may not exist or you may not have access to it." — note "may not",
|
|
28
|
+
which the first version of this pattern missed. */
|
|
29
|
+
{ code: 'model-unavailable', re: /issue\s*with\s*the\s*selected\s*model|model.*(not\s*(found|available|supported)|(may\s*not|does\s*not)\s*exist)|not_found_error.*model|(no|not\s*have)\s*access\s*to\s*(it|.*model)/i },
|
|
30
|
+
{ code: 'overloaded', re: /overloaded_error|overloaded|server\s*is\s*busy|\b529\b|temporarily\s*unavailable/i },
|
|
31
|
+
{ code: 'context-too-long', re: /prompt\s*is\s*too\s*long|context.*(too\s*long|length\s*exceeded)|maximum\s*context/i },
|
|
32
|
+
{ code: 'cli-missing', re: /spawn\s+claude\s+ENOENT|command\s*not\s*found.*claude|claude:\s*not\s*found/i },
|
|
33
|
+
{ code: 'network', re: /ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AGAIN|network\s*error|fetch\s*failed/i },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Classify a failure from the CLI.
|
|
38
|
+
* @param {string} text stderr / result text, whatever we captured
|
|
39
|
+
* @param {{ exitCode?: number|null, subtype?: string|null }} [meta]
|
|
40
|
+
* @returns {{ code: ClaudeErrorCode, resetAt: string|null, raw: string }}
|
|
41
|
+
*/
|
|
42
|
+
export function classifyClaudeError(text, meta = {}) {
|
|
43
|
+
const raw = String(text || '').slice(0, 4000);
|
|
44
|
+
// The runner's own outcomes are already unambiguous — never re-guess them from text.
|
|
45
|
+
if (/^cancelled$|^отменено$|^скасовано$/i.test(raw.trim())) return { code: 'cancelled', resetAt: null, raw };
|
|
46
|
+
if (/^timeout after \d+s/i.test(raw.trim())) return { code: 'timeout', resetAt: null, raw };
|
|
47
|
+
|
|
48
|
+
for (const { code, re } of PATTERNS) {
|
|
49
|
+
if (re.test(raw)) return { code, resetAt: code === 'limit-reached' ? extractReset(raw) : null, raw };
|
|
50
|
+
}
|
|
51
|
+
// stream-json carries a machine-readable cause we used to throw away.
|
|
52
|
+
const sub = String(meta.subtype || '');
|
|
53
|
+
if (/max_turns/i.test(sub)) return { code: 'unknown', resetAt: null, raw };
|
|
54
|
+
return { code: 'unknown', resetAt: null, raw };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The reset time the CLI prints with a limit message ("resets at 3pm", an ISO stamp, or
|
|
59
|
+
* a unix seconds value). Returned verbatim-ish so the UI can show "try again after …";
|
|
60
|
+
* null when nothing parseable is there, and the UI then just says the limit is up.
|
|
61
|
+
*/
|
|
62
|
+
function extractReset(text) {
|
|
63
|
+
const iso = text.match(/\b(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2})?)/);
|
|
64
|
+
if (iso) return iso[1];
|
|
65
|
+
const unix = text.match(/resets?\s*(?:at)?\s*[:=]?\s*(\d{10})\b/i);
|
|
66
|
+
if (unix) return new Date(Number(unix[1]) * 1000).toISOString();
|
|
67
|
+
const clock = text.match(/resets?\s*at\s*([0-9]{1,2}(?::[0-9]{2})?\s*(?:am|pm)?(?:\s*\([^)]+\))?)/i);
|
|
68
|
+
if (clock) return clock[1].trim();
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** True when waiting is the fix — the caller may want to retry later rather than shout. */
|
|
73
|
+
export const isTransient = (code) => code === 'overloaded' || code === 'network' || code === 'limit-reached';
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { spawnClaude } from './spawn-claude.mjs';
|
|
7
7
|
import { resolve } from 'node:path';
|
|
8
8
|
import { ROOT } from './store.mjs';
|
|
9
|
+
import { classifyClaudeError } from './claude-errors.mjs';
|
|
9
10
|
|
|
10
11
|
// Все живые дочерние агенты: убиваем при остановке сервера, чтобы
|
|
11
12
|
// осиротевшие claude-процессы не дожигали токены в пустоту.
|
|
@@ -99,6 +100,7 @@ export function runClaude({
|
|
|
99
100
|
child.on('close', () => activeChildren.delete(child));
|
|
100
101
|
|
|
101
102
|
let resultText = '';
|
|
103
|
+
let errSubtype = null;
|
|
102
104
|
let errText = '';
|
|
103
105
|
let costUsd = null;
|
|
104
106
|
let settled = false;
|
|
@@ -115,13 +117,13 @@ export function runClaude({
|
|
|
115
117
|
|
|
116
118
|
const timer = setTimeout(() => {
|
|
117
119
|
try { child.kill('SIGKILL'); } catch { /* already dead */ }
|
|
118
|
-
finish({ ok: false, error: `timeout after ${Math.round(timeoutMs / 1000)}s
|
|
120
|
+
finish({ ok: false, error: `timeout after ${Math.round(timeoutMs / 1000)}s`, code: 'timeout' });
|
|
119
121
|
}, timeoutMs);
|
|
120
122
|
|
|
121
123
|
if (signal) {
|
|
122
124
|
const onAbort = () => {
|
|
123
125
|
try { child.kill('SIGKILL'); } catch { /* already dead */ }
|
|
124
|
-
finish({ ok: false, error: 'отменено' });
|
|
126
|
+
finish({ ok: false, error: 'отменено', code: 'cancelled' });
|
|
125
127
|
};
|
|
126
128
|
if (signal.aborted) onAbort();
|
|
127
129
|
else signal.addEventListener('abort', onAbort, { once: true });
|
|
@@ -155,16 +157,25 @@ export function runClaude({
|
|
|
155
157
|
} else if (ev.type === 'result') {
|
|
156
158
|
resultText = ev.result || '';
|
|
157
159
|
costUsd = ev.total_cost_usd ?? null;
|
|
158
|
-
if (ev.is_error) errText = resultText || ev.subtype || 'agent error';
|
|
160
|
+
if (ev.is_error) { errText = resultText || ev.subtype || 'agent error'; errSubtype = ev.subtype || null; }
|
|
159
161
|
}
|
|
160
162
|
}
|
|
161
163
|
});
|
|
162
164
|
child.stderr.on('data', chunk => { errText += chunk; });
|
|
163
165
|
|
|
164
|
-
child.on('error', err =>
|
|
166
|
+
child.on('error', (err) => {
|
|
167
|
+
const c = classifyClaudeError(err.message, { subtype: errSubtype });
|
|
168
|
+
finish({ ok: false, error: `spawn failed: ${err.message}`, code: c.code, resetAt: c.resetAt });
|
|
169
|
+
});
|
|
165
170
|
child.on('close', code => {
|
|
166
171
|
if (code === 0 && resultText) finish({ ok: true, result: resultText });
|
|
167
|
-
else
|
|
172
|
+
else {
|
|
173
|
+
/* Name the failure so the app can say something useful — a spent usage limit
|
|
174
|
+
is not a crash, and used to look exactly like one. */
|
|
175
|
+
const raw = errText || `claude exited with code ${code}`;
|
|
176
|
+
const c = classifyClaudeError(raw, { exitCode: code, subtype: errSubtype });
|
|
177
|
+
finish({ ok: false, error: raw.slice(0, 2000), code: c.code, resetAt: c.resetAt });
|
|
178
|
+
}
|
|
168
179
|
});
|
|
169
180
|
});
|
|
170
181
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What went wrong with Claude — as a CODE, not a sentence.
|
|
3
|
+
*
|
|
4
|
+
* Until now every failure came back as raw CLI stderr and was shown to the person
|
|
5
|
+
* verbatim: "your 5-hour limit is up" and "the flag is wrong" arrived looking exactly
|
|
6
|
+
* the same, which reads as "the product is broken" (owner ask 2026-07-26). The apps
|
|
7
|
+
* cannot say anything useful about a state they cannot name, so this names them.
|
|
8
|
+
*
|
|
9
|
+
* The matching is deliberately loose — several phrasings per state, and the CLI's
|
|
10
|
+
* wording changes between versions. A miss falls through to 'unknown', which the UI
|
|
11
|
+
* still handles; a WRONG match would be worse, so each pattern is anchored on words
|
|
12
|
+
* that only appear for that one condition.
|
|
13
|
+
*
|
|
14
|
+
* Kept byte-identical across the three backends, like spawn-claude.mjs.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** @typedef {'limit-reached'|'credit-exhausted'|'not-authenticated'|'model-unavailable'|'overloaded'|'context-too-long'|'cli-missing'|'network'|'cancelled'|'timeout'|'unknown'} ClaudeErrorCode */
|
|
18
|
+
|
|
19
|
+
const PATTERNS = [
|
|
20
|
+
// The 5-hour / weekly subscription window. The CLI prints a reset time with it, which
|
|
21
|
+
// is the single most useful thing we can pass on.
|
|
22
|
+
{ code: 'limit-reached', re: /(usage|rate)\s*limit\s*(reached|exceeded)|limit\s*reached\s*[·|-]|you'?ve\s*(hit|reached)\s*your\s*.*limit|resets?\s+at\s+\d|rate_limit_error|\b429\b/i },
|
|
23
|
+
// Pay-as-you-go balance, not a subscription window — a different fix for the person.
|
|
24
|
+
{ code: 'credit-exhausted', re: /credit\s*balance\s*is\s*too\s*low|insufficient\s*(credit|funds|balance)|billing.*required|purchase\s*more\s*credits/i },
|
|
25
|
+
{ code: 'not-authenticated', re: /not\s*(logged\s*in|authenticated|signed\s*in)|please\s*(run\s*)?\/?login|authentication_error|invalid\s*api\s*key|oauth.*(expired|invalid)|session\s*expired|unauthoriz/i },
|
|
26
|
+
/* The real CLI wording, captured 2026-07-26: "There's an issue with the selected
|
|
27
|
+
model (…). It may not exist or you may not have access to it." — note "may not",
|
|
28
|
+
which the first version of this pattern missed. */
|
|
29
|
+
{ code: 'model-unavailable', re: /issue\s*with\s*the\s*selected\s*model|model.*(not\s*(found|available|supported)|(may\s*not|does\s*not)\s*exist)|not_found_error.*model|(no|not\s*have)\s*access\s*to\s*(it|.*model)/i },
|
|
30
|
+
{ code: 'overloaded', re: /overloaded_error|overloaded|server\s*is\s*busy|\b529\b|temporarily\s*unavailable/i },
|
|
31
|
+
{ code: 'context-too-long', re: /prompt\s*is\s*too\s*long|context.*(too\s*long|length\s*exceeded)|maximum\s*context/i },
|
|
32
|
+
{ code: 'cli-missing', re: /spawn\s+claude\s+ENOENT|command\s*not\s*found.*claude|claude:\s*not\s*found/i },
|
|
33
|
+
{ code: 'network', re: /ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AGAIN|network\s*error|fetch\s*failed/i },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Classify a failure from the CLI.
|
|
38
|
+
* @param {string} text stderr / result text, whatever we captured
|
|
39
|
+
* @param {{ exitCode?: number|null, subtype?: string|null }} [meta]
|
|
40
|
+
* @returns {{ code: ClaudeErrorCode, resetAt: string|null, raw: string }}
|
|
41
|
+
*/
|
|
42
|
+
export function classifyClaudeError(text, meta = {}) {
|
|
43
|
+
const raw = String(text || '').slice(0, 4000);
|
|
44
|
+
// The runner's own outcomes are already unambiguous — never re-guess them from text.
|
|
45
|
+
if (/^cancelled$|^отменено$|^скасовано$/i.test(raw.trim())) return { code: 'cancelled', resetAt: null, raw };
|
|
46
|
+
if (/^timeout after \d+s/i.test(raw.trim())) return { code: 'timeout', resetAt: null, raw };
|
|
47
|
+
|
|
48
|
+
for (const { code, re } of PATTERNS) {
|
|
49
|
+
if (re.test(raw)) return { code, resetAt: code === 'limit-reached' ? extractReset(raw) : null, raw };
|
|
50
|
+
}
|
|
51
|
+
// stream-json carries a machine-readable cause we used to throw away.
|
|
52
|
+
const sub = String(meta.subtype || '');
|
|
53
|
+
if (/max_turns/i.test(sub)) return { code: 'unknown', resetAt: null, raw };
|
|
54
|
+
return { code: 'unknown', resetAt: null, raw };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The reset time the CLI prints with a limit message ("resets at 3pm", an ISO stamp, or
|
|
59
|
+
* a unix seconds value). Returned verbatim-ish so the UI can show "try again after …";
|
|
60
|
+
* null when nothing parseable is there, and the UI then just says the limit is up.
|
|
61
|
+
*/
|
|
62
|
+
function extractReset(text) {
|
|
63
|
+
const iso = text.match(/\b(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2})?)/);
|
|
64
|
+
if (iso) return iso[1];
|
|
65
|
+
const unix = text.match(/resets?\s*(?:at)?\s*[:=]?\s*(\d{10})\b/i);
|
|
66
|
+
if (unix) return new Date(Number(unix[1]) * 1000).toISOString();
|
|
67
|
+
const clock = text.match(/resets?\s*at\s*([0-9]{1,2}(?::[0-9]{2})?\s*(?:am|pm)?(?:\s*\([^)]+\))?)/i);
|
|
68
|
+
if (clock) return clock[1].trim();
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** True when waiting is the fix — the caller may want to retry later rather than shout. */
|
|
73
|
+
export const isTransient = (code) => code === 'overloaded' || code === 'network' || code === 'limit-reached';
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { spawnClaude } from './spawn-claude.mjs';
|
|
6
6
|
import { resolve } from 'node:path';
|
|
7
7
|
import { ROOT } from './store.mjs';
|
|
8
|
+
import { classifyClaudeError } from './claude-errors.mjs';
|
|
8
9
|
|
|
9
10
|
// Все живые дочерние агенты: убиваем при остановке сервера, чтобы
|
|
10
11
|
// осиротевшие claude-процессы не дожигали токены в пустоту.
|
|
@@ -71,6 +72,7 @@ export function runClaude({
|
|
|
71
72
|
child.on('close', () => activeChildren.delete(child));
|
|
72
73
|
|
|
73
74
|
let resultText = '';
|
|
75
|
+
let errSubtype = null;
|
|
74
76
|
let errText = '';
|
|
75
77
|
let costUsd = null;
|
|
76
78
|
let settled = false;
|
|
@@ -87,13 +89,13 @@ export function runClaude({
|
|
|
87
89
|
|
|
88
90
|
const timer = setTimeout(() => {
|
|
89
91
|
try { child.kill('SIGKILL'); } catch { /* already dead */ }
|
|
90
|
-
finish({ ok: false, error: `timeout after ${Math.round(timeoutMs / 1000)}s
|
|
92
|
+
finish({ ok: false, error: `timeout after ${Math.round(timeoutMs / 1000)}s`, code: 'timeout' });
|
|
91
93
|
}, timeoutMs);
|
|
92
94
|
|
|
93
95
|
if (signal) {
|
|
94
96
|
const onAbort = () => {
|
|
95
97
|
try { child.kill('SIGKILL'); } catch { /* already dead */ }
|
|
96
|
-
finish({ ok: false, error: 'отменено' });
|
|
98
|
+
finish({ ok: false, error: 'отменено', code: 'cancelled' });
|
|
97
99
|
};
|
|
98
100
|
if (signal.aborted) onAbort();
|
|
99
101
|
else signal.addEventListener('abort', onAbort, { once: true });
|
|
@@ -126,16 +128,25 @@ export function runClaude({
|
|
|
126
128
|
} else if (ev.type === 'result') {
|
|
127
129
|
resultText = ev.result || '';
|
|
128
130
|
costUsd = ev.total_cost_usd ?? null;
|
|
129
|
-
if (ev.is_error) errText = resultText || ev.subtype || 'agent error';
|
|
131
|
+
if (ev.is_error) { errText = resultText || ev.subtype || 'agent error'; errSubtype = ev.subtype || null; }
|
|
130
132
|
}
|
|
131
133
|
}
|
|
132
134
|
});
|
|
133
135
|
child.stderr.on('data', chunk => { errText += chunk; });
|
|
134
136
|
|
|
135
|
-
child.on('error', err =>
|
|
137
|
+
child.on('error', err => {
|
|
138
|
+
const c = classifyClaudeError(err.message, { subtype: errSubtype });
|
|
139
|
+
finish({ ok: false, error: `spawn failed: ${err.message}`, code: c.code, resetAt: c.resetAt });
|
|
140
|
+
});
|
|
136
141
|
child.on('close', code => {
|
|
137
142
|
if (code === 0 && resultText) finish({ ok: true, result: resultText });
|
|
138
|
-
else
|
|
143
|
+
else {
|
|
144
|
+
/* Name the failure. "Your 5-hour limit is up" and "a flag is wrong" used to
|
|
145
|
+
arrive identical — raw stderr — which reads as a broken product. */
|
|
146
|
+
const raw = errText || `claude exited with code ${code}`;
|
|
147
|
+
const c = classifyClaudeError(raw, { exitCode: code, subtype: errSubtype });
|
|
148
|
+
finish({ ok: false, error: raw.slice(0, 2000), code: c.code, resetAt: c.resetAt });
|
|
149
|
+
}
|
|
139
150
|
});
|
|
140
151
|
});
|
|
141
152
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What went wrong with Claude — as a CODE, not a sentence.
|
|
3
|
+
*
|
|
4
|
+
* Until now every failure came back as raw CLI stderr and was shown to the person
|
|
5
|
+
* verbatim: "your 5-hour limit is up" and "the flag is wrong" arrived looking exactly
|
|
6
|
+
* the same, which reads as "the product is broken" (owner ask 2026-07-26). The apps
|
|
7
|
+
* cannot say anything useful about a state they cannot name, so this names them.
|
|
8
|
+
*
|
|
9
|
+
* The matching is deliberately loose — several phrasings per state, and the CLI's
|
|
10
|
+
* wording changes between versions. A miss falls through to 'unknown', which the UI
|
|
11
|
+
* still handles; a WRONG match would be worse, so each pattern is anchored on words
|
|
12
|
+
* that only appear for that one condition.
|
|
13
|
+
*
|
|
14
|
+
* Kept byte-identical across the three backends, like spawn-claude.mjs.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** @typedef {'limit-reached'|'credit-exhausted'|'not-authenticated'|'model-unavailable'|'overloaded'|'context-too-long'|'cli-missing'|'network'|'cancelled'|'timeout'|'unknown'} ClaudeErrorCode */
|
|
18
|
+
|
|
19
|
+
const PATTERNS = [
|
|
20
|
+
// The 5-hour / weekly subscription window. The CLI prints a reset time with it, which
|
|
21
|
+
// is the single most useful thing we can pass on.
|
|
22
|
+
{ code: 'limit-reached', re: /(usage|rate)\s*limit\s*(reached|exceeded)|limit\s*reached\s*[·|-]|you'?ve\s*(hit|reached)\s*your\s*.*limit|resets?\s+at\s+\d|rate_limit_error|\b429\b/i },
|
|
23
|
+
// Pay-as-you-go balance, not a subscription window — a different fix for the person.
|
|
24
|
+
{ code: 'credit-exhausted', re: /credit\s*balance\s*is\s*too\s*low|insufficient\s*(credit|funds|balance)|billing.*required|purchase\s*more\s*credits/i },
|
|
25
|
+
{ code: 'not-authenticated', re: /not\s*(logged\s*in|authenticated|signed\s*in)|please\s*(run\s*)?\/?login|authentication_error|invalid\s*api\s*key|oauth.*(expired|invalid)|session\s*expired|unauthoriz/i },
|
|
26
|
+
/* The real CLI wording, captured 2026-07-26: "There's an issue with the selected
|
|
27
|
+
model (…). It may not exist or you may not have access to it." — note "may not",
|
|
28
|
+
which the first version of this pattern missed. */
|
|
29
|
+
{ code: 'model-unavailable', re: /issue\s*with\s*the\s*selected\s*model|model.*(not\s*(found|available|supported)|(may\s*not|does\s*not)\s*exist)|not_found_error.*model|(no|not\s*have)\s*access\s*to\s*(it|.*model)/i },
|
|
30
|
+
{ code: 'overloaded', re: /overloaded_error|overloaded|server\s*is\s*busy|\b529\b|temporarily\s*unavailable/i },
|
|
31
|
+
{ code: 'context-too-long', re: /prompt\s*is\s*too\s*long|context.*(too\s*long|length\s*exceeded)|maximum\s*context/i },
|
|
32
|
+
{ code: 'cli-missing', re: /spawn\s+claude\s+ENOENT|command\s*not\s*found.*claude|claude:\s*not\s*found/i },
|
|
33
|
+
{ code: 'network', re: /ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AGAIN|network\s*error|fetch\s*failed/i },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Classify a failure from the CLI.
|
|
38
|
+
* @param {string} text stderr / result text, whatever we captured
|
|
39
|
+
* @param {{ exitCode?: number|null, subtype?: string|null }} [meta]
|
|
40
|
+
* @returns {{ code: ClaudeErrorCode, resetAt: string|null, raw: string }}
|
|
41
|
+
*/
|
|
42
|
+
export function classifyClaudeError(text, meta = {}) {
|
|
43
|
+
const raw = String(text || '').slice(0, 4000);
|
|
44
|
+
// The runner's own outcomes are already unambiguous — never re-guess them from text.
|
|
45
|
+
if (/^cancelled$|^отменено$|^скасовано$/i.test(raw.trim())) return { code: 'cancelled', resetAt: null, raw };
|
|
46
|
+
if (/^timeout after \d+s/i.test(raw.trim())) return { code: 'timeout', resetAt: null, raw };
|
|
47
|
+
|
|
48
|
+
for (const { code, re } of PATTERNS) {
|
|
49
|
+
if (re.test(raw)) return { code, resetAt: code === 'limit-reached' ? extractReset(raw) : null, raw };
|
|
50
|
+
}
|
|
51
|
+
// stream-json carries a machine-readable cause we used to throw away.
|
|
52
|
+
const sub = String(meta.subtype || '');
|
|
53
|
+
if (/max_turns/i.test(sub)) return { code: 'unknown', resetAt: null, raw };
|
|
54
|
+
return { code: 'unknown', resetAt: null, raw };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The reset time the CLI prints with a limit message ("resets at 3pm", an ISO stamp, or
|
|
59
|
+
* a unix seconds value). Returned verbatim-ish so the UI can show "try again after …";
|
|
60
|
+
* null when nothing parseable is there, and the UI then just says the limit is up.
|
|
61
|
+
*/
|
|
62
|
+
function extractReset(text) {
|
|
63
|
+
const iso = text.match(/\b(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2})?)/);
|
|
64
|
+
if (iso) return iso[1];
|
|
65
|
+
const unix = text.match(/resets?\s*(?:at)?\s*[:=]?\s*(\d{10})\b/i);
|
|
66
|
+
if (unix) return new Date(Number(unix[1]) * 1000).toISOString();
|
|
67
|
+
const clock = text.match(/resets?\s*at\s*([0-9]{1,2}(?::[0-9]{2})?\s*(?:am|pm)?(?:\s*\([^)]+\))?)/i);
|
|
68
|
+
if (clock) return clock[1].trim();
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** True when waiting is the fix — the caller may want to retry later rather than shout. */
|
|
73
|
+
export const isTransient = (code) => code === 'overloaded' || code === 'network' || code === 'limit-reached';
|
|
@@ -65,6 +65,10 @@ function pump() {
|
|
|
65
65
|
} else {
|
|
66
66
|
job.status = 'error';
|
|
67
67
|
job.error = String(err?.message || err).slice(0, 2000);
|
|
68
|
+
/* WHAT kind of failure — the app cannot say anything useful about a state it
|
|
69
|
+
cannot name, and "your limit is up" used to look exactly like a crash. */
|
|
70
|
+
if (err?.claudeCode) job.claudeCode = err.claudeCode;
|
|
71
|
+
if (err?.resetAt) job.resetAt = err.resetAt;
|
|
68
72
|
jobLog(job, `✗ ${job.error}`);
|
|
69
73
|
}
|
|
70
74
|
})
|
|
@@ -249,7 +249,7 @@ function startSearch(position, run = null) {
|
|
|
249
249
|
jobLog(job, `⚠ ${res.error} — но ${added} вакансий уже добавлено по ходу поиска`);
|
|
250
250
|
return { found: added, added, dead, sweeps: wave, costUsd: cost, truncated: true };
|
|
251
251
|
}
|
|
252
|
-
throw new Error(res.error);
|
|
252
|
+
throw Object.assign(new Error(res.error), { claudeCode: res.code, resetAt: res.resetAt });
|
|
253
253
|
}
|
|
254
254
|
jobLog(job, `✓ добавлено: ${added}${dead ? `, отброшено битых ссылок: ${dead}` : ''} — волн: ${wave}`);
|
|
255
255
|
return { found: added, added, dead, sweeps: wave, costUsd: cost };
|
|
@@ -267,7 +267,7 @@ function startCoverLetter(vacancy) {
|
|
|
267
267
|
signal,
|
|
268
268
|
onEvent: line => jobLog(job, line),
|
|
269
269
|
});
|
|
270
|
-
if (!res.ok) throw new Error(res.error);
|
|
270
|
+
if (!res.ok) throw Object.assign(new Error(res.error), { claudeCode: res.code, resetAt: res.resetAt });
|
|
271
271
|
vacancy.coverLetter = res.result.trim();
|
|
272
272
|
if (vacancy.status === 'new') vacancy.status = 'prepared';
|
|
273
273
|
save(db);
|
|
@@ -292,7 +292,7 @@ function startAutoApply(vacancy) {
|
|
|
292
292
|
signal,
|
|
293
293
|
onEvent: line => jobLog(job, line),
|
|
294
294
|
});
|
|
295
|
-
if (!res.ok) throw new Error(res.error);
|
|
295
|
+
if (!res.ok) throw Object.assign(new Error(res.error), { claudeCode: res.code, resetAt: res.resetAt });
|
|
296
296
|
const outcome = extractJson(res.result) || { status: 'unknown', details: res.result.slice(0, 1000) };
|
|
297
297
|
vacancy.applyResult = { ...outcome, at: Date.now() };
|
|
298
298
|
if (outcome.status === 'submitted') vacancy.status = 'sent';
|
|
@@ -315,7 +315,7 @@ function startLeadsSearch() {
|
|
|
315
315
|
signal,
|
|
316
316
|
onEvent: line => jobLog(job, line),
|
|
317
317
|
});
|
|
318
|
-
if (!res.ok) throw new Error(res.error);
|
|
318
|
+
if (!res.ok) throw Object.assign(new Error(res.error), { claudeCode: res.code, resetAt: res.resetAt });
|
|
319
319
|
const items = extractJson(res.result);
|
|
320
320
|
if (!Array.isArray(items)) throw new Error('agent returned no JSON array: ' + res.result.slice(0, 300));
|
|
321
321
|
let added = 0;
|
|
@@ -343,7 +343,7 @@ function startPitch(lead) {
|
|
|
343
343
|
signal,
|
|
344
344
|
onEvent: line => jobLog(job, line),
|
|
345
345
|
});
|
|
346
|
-
if (!res.ok) throw new Error(res.error);
|
|
346
|
+
if (!res.ok) throw Object.assign(new Error(res.error), { claudeCode: res.code, resetAt: res.resetAt });
|
|
347
347
|
lead.pitch = res.result.trim();
|
|
348
348
|
if (lead.status === 'new') lead.status = 'ready';
|
|
349
349
|
save(db);
|
|
@@ -365,7 +365,7 @@ function startOnboard(profile, { linkedin, resumeText, roles = [], seniority = '
|
|
|
365
365
|
if (resumeText?.trim()) {
|
|
366
366
|
jobLog(job, 'структурирую вставленное резюме…');
|
|
367
367
|
const res = await runClaude({ prompt: importResumePrompt(resumeText), model: db.settings.model, signal, onEvent: l => jobLog(job, l) });
|
|
368
|
-
if (!res.ok) throw new Error(res.error);
|
|
368
|
+
if (!res.ok) throw Object.assign(new Error(res.error), { claudeCode: res.code, resetAt: res.resetAt });
|
|
369
369
|
profile.resume = res.result.trim();
|
|
370
370
|
importCost = res.costUsd || 0;
|
|
371
371
|
} else if (linkedin) {
|
|
@@ -380,7 +380,7 @@ function startOnboard(profile, { linkedin, resumeText, roles = [], seniority = '
|
|
|
380
380
|
});
|
|
381
381
|
jobLog(job, 'структурирую резюме через Claude…');
|
|
382
382
|
const res = await runClaude({ prompt: importResumePrompt(readFileSync(rawPath, 'utf8')), model: db.settings.model, signal, onEvent: l => jobLog(job, l) });
|
|
383
|
-
if (!res.ok) throw new Error(res.error);
|
|
383
|
+
if (!res.ok) throw Object.assign(new Error(res.error), { claudeCode: res.code, resetAt: res.resetAt });
|
|
384
384
|
profile.resume = res.result.trim();
|
|
385
385
|
profile.linkedin = linkedin;
|
|
386
386
|
importCost = res.costUsd || 0;
|
|
@@ -437,7 +437,7 @@ function startLinkedinImport(profile, url) {
|
|
|
437
437
|
signal,
|
|
438
438
|
onEvent: line => jobLog(job, line),
|
|
439
439
|
});
|
|
440
|
-
if (!res.ok) throw new Error(res.error);
|
|
440
|
+
if (!res.ok) throw Object.assign(new Error(res.error), { claudeCode: res.code, resetAt: res.resetAt });
|
|
441
441
|
profile.resume = res.result.trim();
|
|
442
442
|
profile.linkedin = url;
|
|
443
443
|
save(db);
|