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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qiksy",
3
- "version": "1.1.2",
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) => finish({ ok: false, error: `spawn failed: ${err.message}` }));
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 finish({ ok: false, error: (errText || `claude exited with code ${code}`).slice(0, 2000) });
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 => finish({ ok: false, error: `spawn failed: ${err.message}` }));
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 finish({ ok: false, error: (errText || `claude exited with code ${code}`).slice(0, 2000) });
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 => finish({ ok: false, error: `spawn failed: ${err.message}` }));
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 finish({ ok: false, error: (errText || `claude exited with code ${code}`).slice(0, 2000) });
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);