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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qiksy",
3
- "version": "1.1.2",
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) => 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';
@@ -20,7 +20,12 @@ const MAX_CONCURRENT = 2;
20
20
  let running = 0;
21
21
  const queue = [];
22
22
 
23
- export function createJob(type, label, work) {
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();