qiksy 1.0.0 → 1.1.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/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 } from 'node:fs';
16
+ import { existsSync, mkdirSync, 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';
@@ -140,6 +140,28 @@ async function isUp(port) {
140
140
  }
141
141
  }
142
142
 
143
+ /**
144
+ * Who holds this port: nobody, our own tool, or a stranger.
145
+ *
146
+ * Running the installer twice is the most likely thing a non-technical person does —
147
+ * and before this check the second run printed green ticks (the FIRST engine was
148
+ * answering) followed by a raw EADDRINUSE stack trace, which reads as "it broke".
149
+ * All three tools answer /api/state with JSON, so that is the fingerprint.
150
+ */
151
+ async function whoHolds(port) {
152
+ try {
153
+ const r = await fetch(`http://127.0.0.1:${port}/api/state`, { signal: AbortSignal.timeout(1500) });
154
+ if (r.ok) {
155
+ const ct = r.headers.get('content-type') || '';
156
+ if (ct.includes('json')) return 'ours';
157
+ }
158
+ return 'stranger';
159
+ } catch {
160
+ /* nothing answered on HTTP — but something may still hold the socket */
161
+ }
162
+ return pidsOn(port).length ? 'stranger' : 'free';
163
+ }
164
+
143
165
  async function cmdStatus() {
144
166
  console.log(`\n${c.blue(c.b(' ◆ Qiksy'))}${c.dim(' — status')}\n`);
145
167
  let anyUp = false;
@@ -191,9 +213,178 @@ async function cmdDoctor() {
191
213
  console.log(c.dim(' Your work is kept in the data folder above — restarting never deletes it.\n'));
192
214
  }
193
215
 
216
+ /* ══ Autostart ════════════════════════════════════════════════════════════════
217
+ Without this the engine lives exactly as long as the window Claude started it in
218
+ — close the laptop lid, reboot, and the tools "stop working" for reasons the
219
+ person cannot see. `install` registers it with the OS so it comes back on its own;
220
+ `uninstall` takes it back out. Everything is per-user: no sudo, no system files. */
221
+
222
+ const AGENT_LABEL = 'app.qiksy.engine';
223
+
224
+ function autostartPaths() {
225
+ const home = homedir();
226
+ return {
227
+ mac: join(home, 'Library', 'LaunchAgents', `${AGENT_LABEL}.plist`),
228
+ linux: join(home, '.config', 'systemd', 'user', 'qiksy.service'),
229
+ win: join(process.env.APPDATA || home, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', 'qiksy.cmd'),
230
+ };
231
+ }
232
+
233
+ /** The command the OS should run. Prefer this package's own entry over `npx`, which
234
+ * needs the network and a cache that gets cleaned. */
235
+ function selfCommand() {
236
+ return { node: process.execPath, script: join(PKG, 'bin', 'qiksy.mjs') };
237
+ }
238
+
239
+ function autostartInstalled() {
240
+ const p = autostartPaths();
241
+ try {
242
+ if (platform === 'darwin') return existsSync(p.mac);
243
+ if (isWin) return existsSync(p.win);
244
+ return existsSync(p.linux);
245
+ } catch {
246
+ return false;
247
+ }
248
+ }
249
+
250
+ function cmdInstall({ quiet = false } = {}) {
251
+ const p = autostartPaths();
252
+ const { node, script } = selfCommand();
253
+ const say = (line) => { if (!quiet) console.log(line); };
254
+ say(`\n${c.blue(c.b(' ◆ Qiksy'))}${c.dim(' — start automatically')}\n`);
255
+ try {
256
+ if (platform === 'darwin') {
257
+ mkdirSync(dirname(p.mac), { recursive: true });
258
+ writeFileSync(
259
+ p.mac,
260
+ `<?xml version="1.0" encoding="UTF-8"?>
261
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
262
+ <plist version="1.0"><dict>
263
+ <key>Label</key><string>${AGENT_LABEL}</string>
264
+ <key>ProgramArguments</key><array><string>${node}</string><string>${script}</string></array>
265
+ <key>EnvironmentVariables</key><dict><key>QIKSY_NO_OPEN</key><string>1</string></dict>
266
+ <key>RunAtLoad</key><true/>
267
+ <key>KeepAlive</key><true/>
268
+ <key>StandardOutPath</key><string>${join(DATA_HOME, 'engine.log')}</string>
269
+ <key>StandardErrorPath</key><string>${join(DATA_HOME, 'engine.log')}</string>
270
+ </dict></plist>\n`
271
+ );
272
+ spawnSync('launchctl', ['unload', p.mac], { stdio: 'ignore' });
273
+ spawnSync('launchctl', ['load', p.mac], { stdio: 'ignore' });
274
+ say(c.green(' ✓ Qiksy will start by itself when you log in.'));
275
+ } else if (isWin) {
276
+ mkdirSync(dirname(p.win), { recursive: true });
277
+ writeFileSync(p.win, `@echo off\r\nstart "" /min "${node}" "${script}"\r\n`);
278
+ say(c.green(' ✓ Qiksy will start by itself when you sign in.'));
279
+ } else {
280
+ mkdirSync(dirname(p.linux), { recursive: true });
281
+ writeFileSync(
282
+ p.linux,
283
+ `[Unit]\nDescription=Qiksy local engine\n\n[Service]\nExecStart=${node} ${script}\nEnvironment=QIKSY_NO_OPEN=1\nRestart=always\n\n[Install]\nWantedBy=default.target\n`
284
+ );
285
+ spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' });
286
+ spawnSync('systemctl', ['--user', 'enable', '--now', 'qiksy.service'], { stdio: 'ignore' });
287
+ say(c.green(' ✓ Qiksy will start by itself when you log in.'));
288
+ }
289
+ say(c.dim(' Undo any time with: npx qiksy uninstall\n'));
290
+ return true;
291
+ } catch (e) {
292
+ if (!quiet) {
293
+ console.log(c.amber(` ! Could not set that up: ${e.message}`));
294
+ console.log(c.dim(' You can still start it by hand with: npx qiksy\n'));
295
+ }
296
+ return false;
297
+ }
298
+ }
299
+
300
+ function cmdUninstall() {
301
+ const p = autostartPaths();
302
+ console.log(`\n${c.blue(c.b(' ◆ Qiksy'))}${c.dim(' — stop starting automatically')}\n`);
303
+ try {
304
+ if (platform === 'darwin') {
305
+ spawnSync('launchctl', ['unload', p.mac], { stdio: 'ignore' });
306
+ if (existsSync(p.mac)) rmSync(p.mac);
307
+ } else if (isWin) {
308
+ if (existsSync(p.win)) rmSync(p.win);
309
+ } else {
310
+ spawnSync('systemctl', ['--user', 'disable', '--now', 'qiksy.service'], { stdio: 'ignore' });
311
+ if (existsSync(p.linux)) rmSync(p.linux);
312
+ }
313
+ console.log(c.green(' ✓ It will no longer start on its own.'));
314
+ console.log(c.dim(' It may still be running now — turn it off with: npx qiksy stop\n'));
315
+ } catch (e) {
316
+ console.log(c.amber(` ! ${e.message}\n`));
317
+ }
318
+ }
319
+
320
+ /* ══ Staying alive ════════════════════════════════════════════════════════════
321
+ The people this is built for (recruiters, agencies) will not read a log, will not
322
+ know what a process is, and will not reason about ports. So the engine has to look
323
+ after itself: a tool that dies is restarted, a tool that stops answering is
324
+ restarted, and both are backed off so a genuinely broken tool cannot spin forever.
325
+ Nothing here ever asks the person to do anything. */
326
+
194
327
  const children = [];
328
+ /** id → { tool, child, restarts, downSince } */
329
+ const supervised = new Map();
330
+ const MAX_QUICK_RESTARTS = 5; // within RESTART_WINDOW, then we slow right down
331
+ const RESTART_WINDOW = 60_000;
332
+
333
+ function scheduleRestart(tool, why) {
334
+ const s = supervised.get(tool.id);
335
+ if (!s || s.stopping) return;
336
+ const now = Date.now();
337
+ if (!s.windowStart || now - s.windowStart > RESTART_WINDOW) {
338
+ s.windowStart = now;
339
+ s.restarts = 0;
340
+ }
341
+ s.restarts++;
342
+ // 1s, 2s, 4s, 8s… capped: a tool broken for a real reason must not burn the CPU.
343
+ const delay = Math.min(1000 * 2 ** Math.max(0, s.restarts - 1), 30_000);
344
+ if (s.restarts > MAX_QUICK_RESTARTS) {
345
+ if (!s.warned) {
346
+ console.log(c.amber(` ! ${tool.name} keeps stopping. It will keep retrying quietly in the background.`));
347
+ console.log(c.dim(` If you need it now: npx qiksy doctor`));
348
+ s.warned = true;
349
+ }
350
+ } else if (process.env.QIKSY_DEBUG) {
351
+ console.log(c.dim(` · restarting ${tool.name} (${why}) in ${Math.round(delay / 1000)}s`));
352
+ }
353
+ s.timer = setTimeout(() => {
354
+ if (!s.stopping) start(tool, true);
355
+ }, delay);
356
+ }
195
357
 
196
- function start(tool) {
358
+ /** Every 30s: is each tool still answering? A hung process holds its port but serves
359
+ * nothing, which looks exactly like "the tool broke" from the browser. */
360
+ function startWatchdog() {
361
+ const timer = setInterval(async () => {
362
+ for (const [, s] of supervised) {
363
+ if (s.stopping || !s.child) continue;
364
+ const alive = await isUp(s.tool.port);
365
+ if (alive) {
366
+ s.downSince = 0;
367
+ continue;
368
+ }
369
+ // Two consecutive misses before acting — one slow moment is not a death.
370
+ if (!s.downSince) {
371
+ s.downSince = Date.now();
372
+ continue;
373
+ }
374
+ if (Date.now() - s.downSince < 25_000) continue;
375
+ s.downSince = 0;
376
+ try {
377
+ s.child.kill('SIGKILL');
378
+ } catch {
379
+ /* already gone */
380
+ }
381
+ // its 'exit' handler schedules the restart
382
+ }
383
+ }, 30_000);
384
+ timer.unref?.();
385
+ }
386
+
387
+ function start(tool, isRestart = false) {
197
388
  const dir0 = join(PKG, 'tools', tool.id, 'vendor');
198
389
  const entry = join(dir0, 'server.mjs');
199
390
  if (!existsSync(entry)) return null;
@@ -212,15 +403,40 @@ function start(tool) {
212
403
  // three-way tail stays readable.
213
404
  const tag = c.dim(`[${tool.id}] `);
214
405
  child.stdout?.on('data', (d) => process.stdout.write(String(d).replace(/^/gm, tag)));
215
- child.stderr?.on('data', (d) => process.stderr.write(String(d).replace(/^/gm, tag)));
216
- child.on('exit', (code) => {
217
- if (code && code !== 0) console.log(c.red(` ✗ ${tool.name} stopped (exit ${code})`));
406
+ // A Node stack trace tells the person nothing and reads as "it broke". Keep the
407
+ // detail for QIKSY_DEBUG, and say the one useful sentence instead.
408
+ child.stderr?.on('data', (d) => {
409
+ const s = String(d);
410
+ if (process.env.QIKSY_DEBUG) return process.stderr.write(s.replace(/^/gm, tag));
411
+ if (/EADDRINUSE/.test(s)) {
412
+ console.log(c.amber(` ! ${tool.name} could not start — port ${tool.port} is already in use.`));
413
+ console.log(c.dim(' Check with: npx qiksy status'));
414
+ return;
415
+ }
416
+ if (/^\s*(at |node:|throw |\^|Error:)/m.test(s) && !/warn/i.test(s)) return; // swallow the trace
417
+ process.stderr.write(s.replace(/^/gm, tag));
218
418
  });
419
+ child.on('exit', (code, signal) => {
420
+ const s = supervised.get(tool.id);
421
+ if (!s || s.stopping) return;
422
+ s.child = null;
423
+ // Bring it back. This is the whole point: the person must never have to notice.
424
+ scheduleRestart(tool, signal || `exit ${code}`);
425
+ });
426
+
427
+ const rec = supervised.get(tool.id) || { tool, restarts: 0 };
428
+ rec.tool = tool;
429
+ rec.child = child;
430
+ supervised.set(tool.id, rec);
219
431
  children.push(child);
220
432
  return child;
221
433
  }
222
434
 
223
435
  function stopAll() {
436
+ for (const [, s] of supervised) {
437
+ s.stopping = true;
438
+ if (s.timer) clearTimeout(s.timer);
439
+ }
224
440
  for (const ch of children) {
225
441
  try {
226
442
  ch.kill('SIGTERM');
@@ -245,13 +461,17 @@ process.on('SIGTERM', () => {
245
461
  if (cmd === 'status') return cmdStatus();
246
462
  if (cmd === 'stop') return cmdStop();
247
463
  if (cmd === 'doctor' || cmd === 'check') return cmdDoctor();
464
+ if (cmd === 'install' || cmd === 'autostart') return cmdInstall();
465
+ if (cmd === 'uninstall') return cmdUninstall();
248
466
  if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
249
467
  console.log(`\n${c.blue(c.b(' ◆ Qiksy'))}${c.dim(' — your local engine')}\n`);
250
468
  console.log(' npx qiksy start everything (keep the window open)');
251
469
  console.log(' npx qiksy status what is running right now');
252
470
  console.log(' npx qiksy stop turn it off');
253
471
  console.log(' npx qiksy restart turn it off and on again');
254
- console.log(' npx qiksy doctor check what is wrong\n');
472
+ console.log(' npx qiksy doctor check what is wrong');
473
+ console.log(' npx qiksy install start automatically when you log in');
474
+ console.log(' npx qiksy uninstall stop starting automatically\n');
255
475
  return;
256
476
  }
257
477
  if (cmd === 'restart') {
@@ -270,8 +490,40 @@ process.on('SIGTERM', () => {
270
490
  console.log(c.dim(' The tools still open and show you how to connect it.\n'));
271
491
  }
272
492
 
273
- const started = [];
493
+ // Look before binding: an already-running engine must be reported as good news, and
494
+ // a port held by someone else must be explained, not crashed on.
495
+ const plan = [];
274
496
  for (const tool of TOOLS) {
497
+ plan.push({ tool, holder: await whoHolds(tool.port) });
498
+ }
499
+
500
+ const already = plan.filter((p) => p.holder === 'ours');
501
+ const blocked = plan.filter((p) => p.holder === 'stranger');
502
+
503
+ if (already.length === TOOLS.length) {
504
+ console.log(c.green(' ✓ Already running — nothing to do.\n'));
505
+ for (const { tool } of already) console.log(c.dim(` ${tool.name.padEnd(18)} http://localhost:${tool.port}`));
506
+ console.log(c.b('\n Go back to your Qiksy tab — it is connected.'));
507
+ console.log(c.dim(' Turn it off with: npx qiksy stop\n'));
508
+ return;
509
+ }
510
+
511
+ for (const { tool } of already) {
512
+ console.log(c.green(` ✓ ${tool.name.padEnd(18)} already running`));
513
+ }
514
+ for (const { tool } of blocked) {
515
+ console.log(c.amber(` ! ${tool.name.padEnd(18)} port ${tool.port} is used by another program`));
516
+ console.log(c.dim(` ${tool.name} will stay off until that program is closed. Everything else still works.`));
517
+ }
518
+
519
+ const toStart = plan.filter((p) => p.holder === 'free').map((p) => p.tool);
520
+ if (!toStart.length) {
521
+ console.log(c.dim('\n Nothing left to start.\n'));
522
+ return;
523
+ }
524
+
525
+ const started = [];
526
+ for (const tool of toStart) {
275
527
  const ch = start(tool);
276
528
  if (ch) started.push(tool);
277
529
  else console.log(c.dim(` – ${tool.name} is not bundled in this build`));
@@ -294,9 +546,20 @@ process.on('SIGTERM', () => {
294
546
  if (ok) up.push(tool);
295
547
  }
296
548
 
549
+ startWatchdog();
550
+
297
551
  console.log('');
298
552
  console.log(c.b(' Everything is running. Go back to your Qiksy tab — it connects on its own.'));
299
- console.log(c.dim(' Keep this window open while you work. Ctrl+C to stop.\n'));
553
+
554
+ // Register autostart on the FIRST successful run, without being asked. The target
555
+ // user will not run a second command, and an engine that dies with the window is
556
+ // an engine that "randomly stops working" — the single worst failure for them.
557
+ // Opt out with QIKSY_NO_AUTOSTART=1, undo with `npx qiksy uninstall`.
558
+ if (up.length && !process.env.QIKSY_NO_AUTOSTART && !autostartInstalled()) {
559
+ const ok = cmdInstall({ quiet: true });
560
+ if (ok) console.log(c.dim(' From now on it starts by itself when you log in (undo: npx qiksy uninstall).'));
561
+ }
562
+ console.log(c.dim(' You can close this window — it keeps running. Turn it off: npx qiksy stop\n'));
300
563
 
301
564
  if (up.length && !process.env.QIKSY_NO_OPEN) openBrowser('https://qiksy.app/');
302
565
  })();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "qiksy",
3
- "version": "1.0.0",
4
- "description": "Qiksy one local engine for every Qiksy tool (Work Finder, Travel Assistant, Client Finder). Runs on your own Claude; nothing leaves your machine.",
3
+ "version": "1.1.0",
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": {
7
7
  "qiksy": "bin/qiksy.mjs"
@@ -25,6 +25,11 @@ const SETTINGS_DEFAULTS = {
25
25
  model: 'sonnet', browserSearch: false, extraInstructions: '',
26
26
  plan: 'free', license: '', licenseInfo: null,
27
27
  brandName: '', brandContact: '', brandNote: '',
28
+ /* Renewal reminders are ON by default and can be turned OFF here — someone who
29
+ tried Pro for a month and went back to Free should not be nagged forever, and
30
+ the switch belongs next to the plan, not in the sidebar the badge appears in
31
+ (owner decision 2026-07-26). */
32
+ renewalNotice: true,
28
33
  };
29
34
 
30
35
  /** Своя группа — «я и моя семья». Есть всегда, создаётся молча: на free человек
@@ -93,6 +93,9 @@ function billingInfo() {
93
93
  travelerLimit: isFinite(p.travelerLimit) ? p.travelerLimit : null,
94
94
  teamLimit: isFinite(p.travelerLimit) ? p.travelerLimit : null,
95
95
  manualPlan: db.settings.plan,
96
+ // Days until the paid term ends (null = nothing paid / no expiry). The UI warns
97
+ // from a week out, because a tool that goes quiet without notice feels broken.
98
+ daysLeft: li && li.paidUntil ? Math.ceil((li.paidUntil - Date.now()) / 86400000) : null,
96
99
  license: {
97
100
  set: !!db.settings.license,
98
101
  key: db.settings.license ? db.settings.license.slice(0, 6) + '…' : '',
@@ -128,6 +131,18 @@ function rollback(trip, fromStatus, toStatus, activityText) {
128
131
 
129
132
  const tripTravelers = trip => trip.travelerIds.map(id => db.team.find(m => m.id === id)).filter(Boolean);
130
133
 
134
+ /* Когда оплаченный срок кончился, план сам падает на free (lib/license.mjs) — а
135
+ значит НОВАЯ работа по чужим клиентам останавливается: запустить поиск или
136
+ оформление для группы, которая не своя, больше нельзя (решение владельца
137
+ 26.07.2026). Уже найденное остаётся на месте: поездки, варианты, отчёты видны и
138
+ копируются. Данные — не заложник, платная — работа. Свои поездки идут как на free. */
139
+ const isOwnParty = trip => {
140
+ const self = db.parties[0];
141
+ return !trip.partyId || !self || trip.partyId === self.id;
142
+ };
143
+ const paidPlan = () => ['pro', 'dev'].includes(effectivePlanId());
144
+ const clientWorkAllowed = trip => paidPlan() || isOwnParty(trip);
145
+
131
146
  /* Jobs live in memory, trip statuses live on disk — so a server restart (an edit, a
132
147
  crash, a reboot) left trips frozen mid-flight: "searching" forever, with no job
133
148
  behind it and nothing but "Cancel trip" to press. Roll those back at boot and say
@@ -562,6 +577,7 @@ const server = createServer(async (req, res) => {
562
577
  for (const k of ['brandName', 'brandContact', 'brandNote']) {
563
578
  if (k in body) body[k] = String(body[k] || '').slice(0, 400);
564
579
  }
580
+ if ('renewalNotice' in body) body.renewalNotice = !!body.renewalNotice;
565
581
  // Платные планы включаются лицензией: руками только free/dev.
566
582
  if ('plan' in body && !SELF_SETTABLE_PLANS.includes(body.plan)) delete body.plan;
567
583
  delete body.license;
@@ -670,6 +686,7 @@ const server = createServer(async (req, res) => {
670
686
  if (!['draft', 'options'].includes(t.status)) {
671
687
  return json(res, 400, { error: `поиск доступен из статусов draft/options (сейчас: ${t.status})` });
672
688
  }
689
+ if (!clientWorkAllowed(t)) return json(res, 402, { error: 'PLAN_REQUIRED' });
673
690
  if (!tripTravelers(t).length) return json(res, 400, { error: 'выберите хотя бы одного путника из команды' });
674
691
  // повторный поиск перезаписывает прошлый подбор
675
692
  t.options = [];
@@ -690,6 +707,7 @@ const server = createServer(async (req, res) => {
690
707
  if (!['options', 'ready_to_pay'].includes(t.status)) {
691
708
  return json(res, 400, { error: `оформление доступно из статуса options (сейчас: ${t.status})` });
692
709
  }
710
+ if (!clientWorkAllowed(t)) return json(res, 402, { error: 'PLAN_REQUIRED' });
693
711
  if (!t.options.some(o => o.n === num)) return json(res, 400, { error: `варианта №${num} нет в подборе` });
694
712
  if (!db.team.some(m => m.id === t.travelerIds[0])) return json(res, 400, { error: 'у поездки нет путника с карточкой' });
695
713
  const prev = t.status;