qiksy 1.1.2 → 1.2.1
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 +18 -1
- package/tools/work-finder/vendor/lib/resume-later.mjs +133 -0
- package/tools/work-finder/vendor/server.mjs +35 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qiksy",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
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';
|
|
@@ -20,7 +20,12 @@ const MAX_CONCURRENT = 2;
|
|
|
20
20
|
let running = 0;
|
|
21
21
|
const queue = [];
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
/** Set by the server: called when a job dies because Claude's limit is spent. */
|
|
24
|
+
let onLimit = null;
|
|
25
|
+
export function setLimitHandler(fn) { onLimit = fn; }
|
|
26
|
+
|
|
27
|
+
/** @param {object} [opts] `retry` describes how to run this again (see resume-later). */
|
|
28
|
+
export function createJob(type, label, work, opts = {}) {
|
|
24
29
|
const job = {
|
|
25
30
|
id: uid(),
|
|
26
31
|
type,
|
|
@@ -31,6 +36,7 @@ export function createJob(type, label, work) {
|
|
|
31
36
|
error: null,
|
|
32
37
|
createdAt: Date.now(),
|
|
33
38
|
finishedAt: null,
|
|
39
|
+
retry: opts.retry || null,
|
|
34
40
|
};
|
|
35
41
|
jobs.set(job.id, job);
|
|
36
42
|
queue.push({ job, work });
|
|
@@ -65,7 +71,18 @@ function pump() {
|
|
|
65
71
|
} else {
|
|
66
72
|
job.status = 'error';
|
|
67
73
|
job.error = String(err?.message || err).slice(0, 2000);
|
|
74
|
+
/* WHAT kind of failure — the app cannot say anything useful about a state it
|
|
75
|
+
cannot name, and "your limit is up" used to look exactly like a crash. */
|
|
76
|
+
if (err?.claudeCode) job.claudeCode = err.claudeCode;
|
|
77
|
+
if (err?.resetAt) job.resetAt = err.resetAt;
|
|
68
78
|
jobLog(job, `✗ ${job.error}`);
|
|
79
|
+
/* A usage limit is the one failure worth picking up later — the owner of the
|
|
80
|
+
machine may be asleep. jobs.mjs does not know HOW to re-run anything, so it
|
|
81
|
+
hands the job's own descriptor to whoever registered a handler. */
|
|
82
|
+
if (job.claudeCode === 'limit-reached' && job.retry && onLimit) {
|
|
83
|
+
try { onLimit(job.retry, { code: job.claudeCode, resetAt: job.resetAt }); }
|
|
84
|
+
catch { /* never let bookkeeping break the job's own failure path */ }
|
|
85
|
+
}
|
|
69
86
|
}
|
|
70
87
|
})
|
|
71
88
|
.finally(() => {
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pick the work back up when Claude's limit lifts.
|
|
3
|
+
*
|
|
4
|
+
* Hitting a usage limit is the ONE failure where the right answer is not "tell the
|
|
5
|
+
* person to try again" — they may be asleep, and the run was already paid for in
|
|
6
|
+
* attention. So a search stopped by a limit is remembered and re-run on its own; the
|
|
7
|
+
* person comes back to finished results instead of a red line (owner ask 2026-07-26).
|
|
8
|
+
*
|
|
9
|
+
* Deliberately conservative:
|
|
10
|
+
* · only 'limit-reached' is retried. A wrong model or an empty résumé will fail again
|
|
11
|
+
* at any hour, and a retry loop on those is just noise.
|
|
12
|
+
* · at most MAX_TRIES attempts, spaced out — a limit that keeps re-hitting means the
|
|
13
|
+
* account is genuinely spent, not that we should hammer it.
|
|
14
|
+
* · the queue lives in the db, so closing the laptop does not lose it.
|
|
15
|
+
* · the person can cancel it, and cancelling means cancelled.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const MAX_TRIES = 3;
|
|
19
|
+
const MIN_WAIT_MS = 10 * 60 * 1000; // never sooner than 10 min, even if reset says now
|
|
20
|
+
const FALLBACK_WAIT_MS = 60 * 60 * 1000; // no parseable reset time → try in an hour
|
|
21
|
+
const TICK_MS = 60 * 1000;
|
|
22
|
+
|
|
23
|
+
/** ISO / unix / "3pm (Europe/Kyiv)" → epoch ms, or null when it cannot be trusted. */
|
|
24
|
+
function resetToEpoch(resetAt) {
|
|
25
|
+
if (!resetAt) return null;
|
|
26
|
+
const s = String(resetAt).trim();
|
|
27
|
+
const asDate = Date.parse(s);
|
|
28
|
+
if (!Number.isNaN(asDate)) return asDate;
|
|
29
|
+
// "3pm", "3:30 pm (Europe/Kyiv)" — a wall-clock hint with no date. Read it as the
|
|
30
|
+
// NEXT occurrence of that hour in this machine's own timezone, which is where the
|
|
31
|
+
// person is sitting.
|
|
32
|
+
const m = s.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/i);
|
|
33
|
+
if (!m) return null;
|
|
34
|
+
let h = Number(m[1]);
|
|
35
|
+
const min = Number(m[2] || 0);
|
|
36
|
+
const ap = (m[3] || '').toLowerCase();
|
|
37
|
+
if (ap === 'pm' && h < 12) h += 12;
|
|
38
|
+
if (ap === 'am' && h === 12) h = 0;
|
|
39
|
+
const d = new Date();
|
|
40
|
+
d.setHours(h, min, 0, 0);
|
|
41
|
+
if (d.getTime() <= Date.now()) d.setDate(d.getDate() + 1);
|
|
42
|
+
return d.getTime();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {object} opts
|
|
47
|
+
* @param {() => object} opts.getDb current db (we persist into db.deferred)
|
|
48
|
+
* @param {(db: object) => void} opts.save
|
|
49
|
+
* @param {(entry: object) => void} opts.run re-runs one entry; throws if it cannot
|
|
50
|
+
* @param {(entry: object, msg: string) => void} [opts.note]
|
|
51
|
+
*/
|
|
52
|
+
export function createResumeQueue({ getDb, save, run, note }) {
|
|
53
|
+
let timer = null;
|
|
54
|
+
|
|
55
|
+
const list = () => {
|
|
56
|
+
const db = getDb();
|
|
57
|
+
if (!Array.isArray(db.deferred)) db.deferred = [];
|
|
58
|
+
return db.deferred;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Remember a run to pick up later. Returns the entry, or null if not retryable. */
|
|
62
|
+
function defer(descriptor, { code, resetAt } = {}) {
|
|
63
|
+
if (code !== 'limit-reached' || !descriptor) return null;
|
|
64
|
+
const db = getDb();
|
|
65
|
+
const q = list();
|
|
66
|
+
// One pending entry per thing — a limit hit during a 5-role sweep must not queue
|
|
67
|
+
// the same role five times.
|
|
68
|
+
const key = JSON.stringify(descriptor);
|
|
69
|
+
const existing = q.find((e) => JSON.stringify(e.descriptor) === key);
|
|
70
|
+
const tries = (existing?.tries || 0) + 1;
|
|
71
|
+
if (tries > MAX_TRIES) {
|
|
72
|
+
if (existing) q.splice(q.indexOf(existing), 1);
|
|
73
|
+
save(db);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const base = resetToEpoch(resetAt);
|
|
77
|
+
const wait = base ? Math.max(base - Date.now(), MIN_WAIT_MS) : FALLBACK_WAIT_MS * tries;
|
|
78
|
+
const entry = existing || { id: 'df_' + Math.random().toString(36).slice(2, 10), descriptor, createdAt: Date.now() };
|
|
79
|
+
entry.tries = tries;
|
|
80
|
+
entry.resumeAt = Date.now() + wait;
|
|
81
|
+
entry.reason = 'limit-reached';
|
|
82
|
+
if (!existing) q.push(entry);
|
|
83
|
+
save(db);
|
|
84
|
+
note?.(entry, `отложено до ${new Date(entry.resumeAt).toLocaleString()}`);
|
|
85
|
+
return entry;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function cancel(id) {
|
|
89
|
+
const db = getDb();
|
|
90
|
+
const q = list();
|
|
91
|
+
const i = q.findIndex((e) => e.id === id);
|
|
92
|
+
if (i < 0) return false;
|
|
93
|
+
q.splice(i, 1);
|
|
94
|
+
save(db);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function due(now = Date.now()) {
|
|
99
|
+
return list().filter((e) => e.resumeAt <= now);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function tick() {
|
|
103
|
+
const ready = due();
|
|
104
|
+
if (!ready.length) return;
|
|
105
|
+
const db = getDb();
|
|
106
|
+
for (const entry of ready) {
|
|
107
|
+
// Take it out FIRST: a re-run that hits the limit again will re-add itself with a
|
|
108
|
+
// longer wait, and a crash must not leave an entry that fires every minute.
|
|
109
|
+
const q = list();
|
|
110
|
+
const i = q.findIndex((e) => e.id === entry.id);
|
|
111
|
+
if (i >= 0) q.splice(i, 1);
|
|
112
|
+
save(db);
|
|
113
|
+
try {
|
|
114
|
+
await run(entry);
|
|
115
|
+
note?.(entry, 'возобновлено');
|
|
116
|
+
} catch (e) {
|
|
117
|
+
note?.(entry, `не удалось возобновить: ${e?.message || e}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function start() {
|
|
123
|
+
if (timer) return;
|
|
124
|
+
timer = setInterval(() => void tick(), TICK_MS);
|
|
125
|
+
timer.unref?.();
|
|
126
|
+
}
|
|
127
|
+
function stop() {
|
|
128
|
+
if (timer) clearInterval(timer);
|
|
129
|
+
timer = null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { defer, cancel, list, due, tick, start, stop };
|
|
133
|
+
}
|
|
@@ -16,7 +16,7 @@ import { spawn } from 'node:child_process';
|
|
|
16
16
|
import { load, save, uid, ROOT, DATA_DIR } from './lib/store.mjs';
|
|
17
17
|
import { runClaude, extractJson } from './lib/agent.mjs';
|
|
18
18
|
import { searchPrompt, coverLetterPrompt, autoApplyPrompt, importResumePrompt, leadsSearchPrompt, pitchPrompt, buildFromStoryPrompt } from './lib/prompts.mjs';
|
|
19
|
-
import { createJob, jobLog, listJobs, getJob, cancelJob, cancelActiveByLabel, jobEvents } from './lib/jobs.mjs';
|
|
19
|
+
import { createJob, jobLog, listJobs, getJob, cancelJob, cancelActiveByLabel, jobEvents, setLimitHandler } from './lib/jobs.mjs';
|
|
20
20
|
import { ensureBrowser } from './scripts/launch-browser.mjs';
|
|
21
21
|
import { PLANS, SELF_SETTABLE_PLANS } from './lib/plans.mjs';
|
|
22
22
|
import { verifyLicense, licensePlanId, RECHECK_MS } from './lib/license.mjs';
|
|
@@ -24,6 +24,7 @@ import { ROLE_CATALOG, SENIORITY, LOCATIONS, detectRoles } from './lib/catalog.m
|
|
|
24
24
|
import { checkMany, checkVacancyUrl } from './lib/linkcheck.mjs';
|
|
25
25
|
import { probeEngine, startLogin, submitLoginCode } from './lib/engine.mjs';
|
|
26
26
|
import { normalizeLinkedin } from './lib/linkedin.mjs';
|
|
27
|
+
import { createResumeQueue } from './lib/resume-later.mjs';
|
|
27
28
|
|
|
28
29
|
const PORT = Number(process.env.WF_PORT) || 5544;
|
|
29
30
|
const PUBLIC_DIR = resolve(ROOT, 'public');
|
|
@@ -131,6 +132,23 @@ function createSearchRun(targets) {
|
|
|
131
132
|
// (та же логика, что в purgeHistory). Остальное — нетронутая находка, шум, уходит.
|
|
132
133
|
const isTouched = (v) => v.status !== 'new' || !!v.coverLetter || !!v.applyResult || !!v.pinned;
|
|
133
134
|
|
|
135
|
+
/* A search stopped by Claude's usage limit comes back on its own — see
|
|
136
|
+
lib/resume-later.mjs for why only this one failure is retried. */
|
|
137
|
+
const resumeQueue = createResumeQueue({
|
|
138
|
+
getDb: () => db,
|
|
139
|
+
save: (d) => save(d),
|
|
140
|
+
note: (entry, msg) => console.log(`[resume] ${entry.descriptor?.kind || '?'} ${msg}`),
|
|
141
|
+
run: (entry) => {
|
|
142
|
+
const d = entry.descriptor || {};
|
|
143
|
+
if (d.kind !== 'search') throw new Error('unknown descriptor');
|
|
144
|
+
const position = db.positions.find((p) => p.id === d.positionId);
|
|
145
|
+
if (!position) throw new Error('роль удалена');
|
|
146
|
+
startSearch(position, null);
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
resumeQueue.start();
|
|
150
|
+
setLimitHandler((descriptor, info) => resumeQueue.defer(descriptor, info));
|
|
151
|
+
|
|
134
152
|
function startSearch(position, run = null) {
|
|
135
153
|
const profile = db.profiles.find(p => p.id === position.profileId) || db.profiles[0];
|
|
136
154
|
return createJob('search', `Поиск: ${position.title}`, async (job, signal) => {
|
|
@@ -249,11 +267,11 @@ function startSearch(position, run = null) {
|
|
|
249
267
|
jobLog(job, `⚠ ${res.error} — но ${added} вакансий уже добавлено по ходу поиска`);
|
|
250
268
|
return { found: added, added, dead, sweeps: wave, costUsd: cost, truncated: true };
|
|
251
269
|
}
|
|
252
|
-
throw new Error(res.error);
|
|
270
|
+
throw Object.assign(new Error(res.error), { claudeCode: res.code, resetAt: res.resetAt });
|
|
253
271
|
}
|
|
254
272
|
jobLog(job, `✓ добавлено: ${added}${dead ? `, отброшено битых ссылок: ${dead}` : ''} — волн: ${wave}`);
|
|
255
273
|
return { found: added, added, dead, sweeps: wave, costUsd: cost };
|
|
256
|
-
});
|
|
274
|
+
}, { retry: { kind: 'search', positionId: position.id } });
|
|
257
275
|
}
|
|
258
276
|
|
|
259
277
|
function startCoverLetter(vacancy) {
|
|
@@ -267,7 +285,7 @@ function startCoverLetter(vacancy) {
|
|
|
267
285
|
signal,
|
|
268
286
|
onEvent: line => jobLog(job, line),
|
|
269
287
|
});
|
|
270
|
-
if (!res.ok) throw new Error(res.error);
|
|
288
|
+
if (!res.ok) throw Object.assign(new Error(res.error), { claudeCode: res.code, resetAt: res.resetAt });
|
|
271
289
|
vacancy.coverLetter = res.result.trim();
|
|
272
290
|
if (vacancy.status === 'new') vacancy.status = 'prepared';
|
|
273
291
|
save(db);
|
|
@@ -292,7 +310,7 @@ function startAutoApply(vacancy) {
|
|
|
292
310
|
signal,
|
|
293
311
|
onEvent: line => jobLog(job, line),
|
|
294
312
|
});
|
|
295
|
-
if (!res.ok) throw new Error(res.error);
|
|
313
|
+
if (!res.ok) throw Object.assign(new Error(res.error), { claudeCode: res.code, resetAt: res.resetAt });
|
|
296
314
|
const outcome = extractJson(res.result) || { status: 'unknown', details: res.result.slice(0, 1000) };
|
|
297
315
|
vacancy.applyResult = { ...outcome, at: Date.now() };
|
|
298
316
|
if (outcome.status === 'submitted') vacancy.status = 'sent';
|
|
@@ -315,7 +333,7 @@ function startLeadsSearch() {
|
|
|
315
333
|
signal,
|
|
316
334
|
onEvent: line => jobLog(job, line),
|
|
317
335
|
});
|
|
318
|
-
if (!res.ok) throw new Error(res.error);
|
|
336
|
+
if (!res.ok) throw Object.assign(new Error(res.error), { claudeCode: res.code, resetAt: res.resetAt });
|
|
319
337
|
const items = extractJson(res.result);
|
|
320
338
|
if (!Array.isArray(items)) throw new Error('agent returned no JSON array: ' + res.result.slice(0, 300));
|
|
321
339
|
let added = 0;
|
|
@@ -343,7 +361,7 @@ function startPitch(lead) {
|
|
|
343
361
|
signal,
|
|
344
362
|
onEvent: line => jobLog(job, line),
|
|
345
363
|
});
|
|
346
|
-
if (!res.ok) throw new Error(res.error);
|
|
364
|
+
if (!res.ok) throw Object.assign(new Error(res.error), { claudeCode: res.code, resetAt: res.resetAt });
|
|
347
365
|
lead.pitch = res.result.trim();
|
|
348
366
|
if (lead.status === 'new') lead.status = 'ready';
|
|
349
367
|
save(db);
|
|
@@ -365,7 +383,7 @@ function startOnboard(profile, { linkedin, resumeText, roles = [], seniority = '
|
|
|
365
383
|
if (resumeText?.trim()) {
|
|
366
384
|
jobLog(job, 'структурирую вставленное резюме…');
|
|
367
385
|
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);
|
|
386
|
+
if (!res.ok) throw Object.assign(new Error(res.error), { claudeCode: res.code, resetAt: res.resetAt });
|
|
369
387
|
profile.resume = res.result.trim();
|
|
370
388
|
importCost = res.costUsd || 0;
|
|
371
389
|
} else if (linkedin) {
|
|
@@ -380,7 +398,7 @@ function startOnboard(profile, { linkedin, resumeText, roles = [], seniority = '
|
|
|
380
398
|
});
|
|
381
399
|
jobLog(job, 'структурирую резюме через Claude…');
|
|
382
400
|
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);
|
|
401
|
+
if (!res.ok) throw Object.assign(new Error(res.error), { claudeCode: res.code, resetAt: res.resetAt });
|
|
384
402
|
profile.resume = res.result.trim();
|
|
385
403
|
profile.linkedin = linkedin;
|
|
386
404
|
importCost = res.costUsd || 0;
|
|
@@ -437,7 +455,7 @@ function startLinkedinImport(profile, url) {
|
|
|
437
455
|
signal,
|
|
438
456
|
onEvent: line => jobLog(job, line),
|
|
439
457
|
});
|
|
440
|
-
if (!res.ok) throw new Error(res.error);
|
|
458
|
+
if (!res.ok) throw Object.assign(new Error(res.error), { claudeCode: res.code, resetAt: res.resetAt });
|
|
441
459
|
profile.resume = res.result.trim();
|
|
442
460
|
profile.linkedin = url;
|
|
443
461
|
save(db);
|
|
@@ -627,7 +645,7 @@ export const requestHandler = async (req, res) => {
|
|
|
627
645
|
|
|
628
646
|
if (req.method === 'GET' && path === '/api/state') {
|
|
629
647
|
return json(res, 200, {
|
|
630
|
-
...db, browserAlive, jobs: listJobs(), billing: billingInfo(),
|
|
648
|
+
...db, browserAlive, jobs: listJobs(), billing: billingInfo(), deferred: resumeQueue.list(),
|
|
631
649
|
catalog: ROLE_CATALOG.map(({ id, group, title, keywords }) => ({ id, group, title, keywords })),
|
|
632
650
|
seniority: SENIORITY, locations: LOCATIONS,
|
|
633
651
|
});
|
|
@@ -844,6 +862,12 @@ export const requestHandler = async (req, res) => {
|
|
|
844
862
|
localStorage. That key cannot exist any more — API_KEY_LOGIN is off and there is
|
|
845
863
|
no field to enter one — so all three were dead for every user while the fourth,
|
|
846
864
|
which comes through here, worked. Same route for all of them now. */
|
|
865
|
+
/* "Do not bother, I will run it myself" — a promise the product makes must be
|
|
866
|
+
cancellable, or it is just something happening to you. */
|
|
867
|
+
if (req.method === 'DELETE' && seg[1] === 'deferred' && seg[2]) {
|
|
868
|
+
const ok = resumeQueue.cancel(seg[2]);
|
|
869
|
+
return json(res, ok ? 200 : 404, ok ? { ok: true } : { error: 'no such entry' });
|
|
870
|
+
}
|
|
847
871
|
if (req.method === 'POST' && path === '/api/builder/text') {
|
|
848
872
|
const b = await readBody(req);
|
|
849
873
|
const prompt = (b.prompt || '').trim();
|