qiksy 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/qiksy.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
  * Everything runs on the user's machine, on their own Claude. Nothing is uploaded.
14
14
  */
15
15
  import { spawn, spawnSync } from 'node:child_process';
16
- import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
16
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
17
17
  import { homedir } from 'node:os';
18
18
  import { dirname, join, resolve } from 'node:path';
19
19
  import { platform } from 'node:process';
@@ -40,6 +40,15 @@ try {
40
40
  /* unwritable home: each backend falls back to its own folder */
41
41
  }
42
42
 
43
+ /* The supervisor's own pid. `stop` used to kill the three tools by port — and the
44
+ supervisor, doing exactly what it was built for, brought them back two seconds later,
45
+ so stopping never actually stopped (found 2026-07-26 while upgrading a live install).
46
+ It records its pid here and `stop` ends the supervisor first. */
47
+ const PID_FILE = join(DATA_HOME, 'engine.pid');
48
+ const writePid = () => { try { writeFileSync(PID_FILE, String(process.pid)); } catch { /* unwritable home */ } };
49
+ const readPid = () => { try { return Number(readFileSync(PID_FILE, 'utf8').trim()) || 0; } catch { return 0; } };
50
+ const clearPid = () => { try { if (existsSync(PID_FILE)) rmSync(PID_FILE); } catch { /* ignore */ } };
51
+
43
52
  const TOOLS = [
44
53
  {
45
54
  id: 'work-finder',
@@ -180,14 +189,20 @@ async function cmdStatus() {
180
189
  function cmdStop() {
181
190
  console.log(`\n${c.blue(c.b(' ◆ Qiksy'))}${c.dim(' — stopping')}\n`);
182
191
  let killed = 0;
192
+ // Who was actually up BEFORE we touch anything — killing the supervisor takes its
193
+ // children with it, and reporting "was not running" about something we just stopped
194
+ // is the kind of small lie that makes people distrust the rest of the output.
195
+ const wasUp = new Map(TOOLS.map((t) => [t.id, pidsOn(t.port).length > 0]));
196
+ // The supervisor first, or it restarts every tool we are about to stop.
197
+ const sup = readPid();
198
+ if (sup && sup !== process.pid) { if (killPid(sup)) killed++; clearPid(); }
183
199
  for (const t of TOOLS) {
184
- const pids = pidsOn(t.port);
185
- if (!pids.length) {
186
- console.log(c.dim(` · ${t.name.padEnd(18)} was not running`));
187
- continue;
188
- }
189
- for (const p of pids) if (killPid(p)) killed++;
190
- console.log(c.green(` ✓ ${t.name.padEnd(18)} stopped`));
200
+ for (const p of pidsOn(t.port)) if (killPid(p)) killed++;
201
+ console.log(
202
+ wasUp.get(t.id)
203
+ ? c.green(` ✓ ${t.name.padEnd(18)} stopped`)
204
+ : c.dim(` · ${t.name.padEnd(18)} was not running`)
205
+ );
191
206
  }
192
207
  console.log(c.dim(`\n ${killed ? 'Everything is off.' : 'Nothing was running.'} Start again: npx qiksy\n`));
193
208
  }
@@ -448,10 +463,12 @@ function stopAll() {
448
463
  process.on('SIGINT', () => {
449
464
  console.log(c.dim('\n Stopping…'));
450
465
  stopAll();
466
+ clearPid();
451
467
  process.exit(0);
452
468
  });
453
469
  process.on('SIGTERM', () => {
454
470
  stopAll();
471
+ clearPid();
455
472
  process.exit(0);
456
473
  });
457
474
 
@@ -546,6 +563,7 @@ process.on('SIGTERM', () => {
546
563
  if (ok) up.push(tool);
547
564
  }
548
565
 
566
+ writePid();
549
567
  startWatchdog();
550
568
 
551
569
  console.log('');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qiksy",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
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": {
@@ -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 });
@@ -70,6 +76,13 @@ function pump() {
70
76
  if (err?.claudeCode) job.claudeCode = err.claudeCode;
71
77
  if (err?.resetAt) job.resetAt = err.resetAt;
72
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
+ }
73
86
  }
74
87
  })
75
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) => {
@@ -253,7 +271,7 @@ function startSearch(position, run = null) {
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) {
@@ -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();