qiksy 1.0.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.
Files changed (78) hide show
  1. package/ARCHITECTURE.md +78 -0
  2. package/INSTALL-PROMPT.md +53 -0
  3. package/bin/qiksy.mjs +302 -0
  4. package/package.json +32 -0
  5. package/tools/client-finder/vendor/README.md +70 -0
  6. package/tools/client-finder/vendor/find.mjs +121 -0
  7. package/tools/client-finder/vendor/icp/assist-pl-shops.json +30 -0
  8. package/tools/client-finder/vendor/icp/assist-ua-shops.json +30 -0
  9. package/tools/client-finder/vendor/knowledge/assist.md +68 -0
  10. package/tools/client-finder/vendor/knowledge/custom.md +36 -0
  11. package/tools/client-finder/vendor/knowledge/multilogin.md +29 -0
  12. package/tools/client-finder/vendor/knowledge/pay.md +26 -0
  13. package/tools/client-finder/vendor/knowledge/qa-copilot.md +43 -0
  14. package/tools/client-finder/vendor/knowledge/travel.md +43 -0
  15. package/tools/client-finder/vendor/knowledge/work-finder.md +52 -0
  16. package/tools/client-finder/vendor/lib/access.mjs +108 -0
  17. package/tools/client-finder/vendor/lib/agent.mjs +204 -0
  18. package/tools/client-finder/vendor/lib/approver-mcp.mjs +78 -0
  19. package/tools/client-finder/vendor/lib/browser.mjs +108 -0
  20. package/tools/client-finder/vendor/lib/catalog.mjs +86 -0
  21. package/tools/client-finder/vendor/lib/channels.mjs +54 -0
  22. package/tools/client-finder/vendor/lib/deliver.mjs +89 -0
  23. package/tools/client-finder/vendor/lib/engine.mjs +283 -0
  24. package/tools/client-finder/vendor/lib/formats.mjs +108 -0
  25. package/tools/client-finder/vendor/lib/jobs.mjs +75 -0
  26. package/tools/client-finder/vendor/lib/knowledge.mjs +26 -0
  27. package/tools/client-finder/vendor/lib/prompts.mjs +183 -0
  28. package/tools/client-finder/vendor/lib/report.mjs +75 -0
  29. package/tools/client-finder/vendor/lib/scope.mjs +162 -0
  30. package/tools/client-finder/vendor/lib/spawn-claude.mjs +44 -0
  31. package/tools/client-finder/vendor/lib/verify.mjs +96 -0
  32. package/tools/client-finder/vendor/package.json +8 -0
  33. package/tools/client-finder/vendor/server.mjs +390 -0
  34. package/tools/travel/vendor/README.md +58 -0
  35. package/tools/travel/vendor/docs/CONTRACT.md +174 -0
  36. package/tools/travel/vendor/docs/ENGINE-MIGRATION.md +33 -0
  37. package/tools/travel/vendor/lib/agent.mjs +190 -0
  38. package/tools/travel/vendor/lib/engine.mjs +283 -0
  39. package/tools/travel/vendor/lib/jobs.mjs +125 -0
  40. package/tools/travel/vendor/lib/license.mjs +41 -0
  41. package/tools/travel/vendor/lib/plans.mjs +28 -0
  42. package/tools/travel/vendor/lib/prompts.mjs +394 -0
  43. package/tools/travel/vendor/lib/proposal.mjs +207 -0
  44. package/tools/travel/vendor/lib/spawn-claude.mjs +44 -0
  45. package/tools/travel/vendor/lib/store.mjs +75 -0
  46. package/tools/travel/vendor/package.json +12 -0
  47. package/tools/travel/vendor/public/app.js +996 -0
  48. package/tools/travel/vendor/public/index.html +112 -0
  49. package/tools/travel/vendor/public/styles.css +760 -0
  50. package/tools/travel/vendor/scripts/approver-mcp.mjs +80 -0
  51. package/tools/travel/vendor/scripts/launch-browser.mjs +77 -0
  52. package/tools/travel/vendor/server.mjs +755 -0
  53. package/tools/work-finder/vendor/.claude/settings.local.json +27 -0
  54. package/tools/work-finder/vendor/.mcp.json +9 -0
  55. package/tools/work-finder/vendor/CLAUDE.md +24 -0
  56. package/tools/work-finder/vendor/README.md +44 -0
  57. package/tools/work-finder/vendor/bin/qiksy-work-finder.mjs +127 -0
  58. package/tools/work-finder/vendor/lib/agent.mjs +159 -0
  59. package/tools/work-finder/vendor/lib/catalog.mjs +41 -0
  60. package/tools/work-finder/vendor/lib/channels.mjs +73 -0
  61. package/tools/work-finder/vendor/lib/engine.mjs +283 -0
  62. package/tools/work-finder/vendor/lib/jobs.mjs +127 -0
  63. package/tools/work-finder/vendor/lib/license.mjs +60 -0
  64. package/tools/work-finder/vendor/lib/linkcheck.mjs +72 -0
  65. package/tools/work-finder/vendor/lib/linkedin.mjs +22 -0
  66. package/tools/work-finder/vendor/lib/plans.mjs +33 -0
  67. package/tools/work-finder/vendor/lib/prompts.mjs +217 -0
  68. package/tools/work-finder/vendor/lib/spawn-claude.mjs +44 -0
  69. package/tools/work-finder/vendor/lib/store.mjs +125 -0
  70. package/tools/work-finder/vendor/package.json +41 -0
  71. package/tools/work-finder/vendor/public/app.js +1863 -0
  72. package/tools/work-finder/vendor/public/index.html +518 -0
  73. package/tools/work-finder/vendor/public/styles.css +930 -0
  74. package/tools/work-finder/vendor/scripts/approver-mcp.mjs +69 -0
  75. package/tools/work-finder/vendor/scripts/extract-linkedin.mjs +81 -0
  76. package/tools/work-finder/vendor/scripts/launch-browser.mjs +78 -0
  77. package/tools/work-finder/vendor/scripts/screenshot.mjs +17 -0
  78. package/tools/work-finder/vendor/server.mjs +1006 -0
@@ -0,0 +1,33 @@
1
+ # Travel → Qiksy Engine: what this backend brings with it
2
+
3
+ Travel is folded into [products/qiksy-engine](../../qiksy-engine/ARCHITECTURE.md) **last**
4
+ (stage 4 there), because this folder was being rewritten while the engine was being built.
5
+ This file is the handover: what changed here on 2026-07-25, so the port picks it up instead
6
+ of copying an older shape.
7
+
8
+ ## The product changed, not just the code
9
+
10
+ Travel is no longer "an HR assistant for company trips". It is **one person planning their
11
+ own trips (free) and a travel agency running other people's trips (Pro)** — owner decision
12
+ 2026-07-25. That is why the paywall moved from *number of travellers* to *number of groups*.
13
+
14
+ ## What the module must carry over
15
+
16
+ | Piece | Where | Why it matters |
17
+ |---|---|---|
18
+ | **Parties (groups)** | `lib/store.mjs` (`parties`, `SELF_PARTY_ID`, migration), `server.mjs` `/api/parties` CRUD | The whole billing axis. A traveller and a trip each carry `partyId`; the migration back-fills existing rows into the owner's own group, so an upgrade needs no manual re-assignment. |
19
+ | **Plan shape** | `lib/plans.mjs` | `partyLimit` (free 1 · pro ∞) is the paywall; `travelerLimit` (free 8) only stops a runaway list inside one family. `teamLimit` stays as a compat alias. |
20
+ | **Machine-code errors** | `server.mjs` (`PARTY_LIMIT:1`, `TRAVELER_LIMIT:8`) | The UI translates them; the backend must NOT send prose for these, or a Ukrainian user gets a Russian sentence. The frontend suppresses the toast for `^[A-Z_]+:` errors. |
21
+ | **Agent steps** | `lib/jobs.mjs` (`steps`, `jobStep`), `lib/agent.mjs` (`onStep`, `reportStep`) | Feeds the live "theatre" — what the agent is doing in words. **The WF copy of `jobs.mjs` does NOT have this** (it has the `jobEvents` SSE bus instead). The core version needs BOTH: the bus pushes instantly, the steps say what happened. |
22
+ | **Interrupted-trip recovery** | `server.mjs` (`recoverInterruptedTrips`) | Jobs are in memory, statuses are on disk, so every restart used to freeze trips in `searching` forever. On boot, `searching → draft` (or `options` if partials survived), `booking → options`, with a line in the trip's activity. Any tool with long jobs wants this in the core. |
23
+ | **Client proposal** | `lib/proposal.mjs`, `GET /api/trips/:id/proposal?lang=` | The Pro feature: the whole selection as one self-contained HTML page under the agency's own name. Pure rendering, no deps, `402` below Pro. Brand header comes from `settings.brandName/brandContact/brandNote`. |
24
+
25
+ ## Frontend side
26
+
27
+ `apps/travel-assistant/src/app/api.ts` is the single place the base URL lives
28
+ (`API_BASE`, `ENGINE_URL`, `apiUrl()`). Mounting Travel at `/api/travel` on port 5544 is a
29
+ one-line change there — the controller, the app shell and the engine probe all read it.
30
+
31
+ `apiUrl()` exists because a relative `fetch('/api/…')` silently resolves against
32
+ `qiksy.app` in the production build (that bug shipped for `/api/parse-route`). Anything the
33
+ browser opens directly — a new tab, a download — must go through it.
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Headless Claude Code runner: spawns `claude -p` with stream-json output,
3
+ * feeds tool-use events to onEvent (job log), returns the final result text.
4
+ * Калька с work-finder/lib/agent.mjs; партиалы здесь — строки `OPTION: {...}`.
5
+ */
6
+ import { spawnClaude } from './spawn-claude.mjs';
7
+ import { resolve } from 'node:path';
8
+ import { ROOT } from './store.mjs';
9
+
10
+ // Все живые дочерние агенты: убиваем при остановке сервера, чтобы
11
+ // осиротевшие claude-процессы не дожигали токены в пустоту.
12
+ const activeChildren = new Set();
13
+ function killAllChildren() {
14
+ for (const child of activeChildren) {
15
+ try { child.kill('SIGKILL'); } catch { /* already dead */ }
16
+ }
17
+ activeChildren.clear();
18
+ }
19
+ for (const sig of ['SIGINT', 'SIGTERM']) {
20
+ process.on(sig, () => { killAllChildren(); process.exit(0); });
21
+ }
22
+ process.on('exit', killAllChildren);
23
+
24
+ const APPROVER_SERVER = {
25
+ type: 'stdio',
26
+ command: process.execPath,
27
+ args: [resolve(ROOT, 'scripts/approver-mcp.mjs')],
28
+ };
29
+ const PLAYWRIGHT_SERVER = {
30
+ type: 'stdio',
31
+ command: 'npx',
32
+ args: ['@playwright/mcp@latest', '--cdp-endpoint', 'http://localhost:9222'],
33
+ };
34
+
35
+ /** Hostname of a URL, without the www — the label a source chip shows. */
36
+ function hostOf(url) {
37
+ try { return new URL(String(url)).hostname.replace(/^www\./, ''); } catch { return ''; }
38
+ }
39
+
40
+ /**
41
+ * A tool call as a sentence a traveller can read: which site is being opened, what
42
+ * is being looked up. Anything unrecognised is deliberately dropped rather than
43
+ * shown raw — "ToolSearch {"query":"select:WebSearch,WebFetch"}" tells the person
44
+ * waiting for their flights precisely nothing.
45
+ */
46
+ function reportStep(block, onStep) {
47
+ const name = block.name || '';
48
+ const inp = block.input || {};
49
+ if (name === 'WebSearch') return onStep('search', inp.query || '');
50
+ if (name === 'WebFetch') return onStep('read', inp.prompt || '', hostOf(inp.url));
51
+ if (name.startsWith('mcp__playwright__browser_navigate')) return onStep('browse', '', hostOf(inp.url));
52
+ if (name.startsWith('mcp__playwright__browser_')) {
53
+ const what = name.replace('mcp__playwright__browser_', '');
54
+ return onStep('browse', what === 'snapshot' ? '' : what);
55
+ }
56
+ }
57
+
58
+ export function runClaude({
59
+ prompt,
60
+ allowedTools = [], // built-in tools the agent may use; [] = no built-ins
61
+ withBrowser = false,
62
+ model = 'sonnet',
63
+ timeoutMs = 10 * 60_000,
64
+ onEvent = () => {},
65
+ onStep = null, // (kind, text, host) => void — читаемый ход агента для «театра» в UI
66
+ onPartial = null, // (obj) => void — строки `OPTION: {...}` из текста агента, по мере готовности
67
+ signal = null, // AbortSignal — отмена задачи пользователем
68
+ }) {
69
+ return new Promise(resolvePromise => {
70
+ // The user's global settings put WebSearch/WebFetch in the "ask" list,
71
+ // which auto-denies in -p mode and overrides --allowedTools and even
72
+ // --dangerously-skip-permissions. So permissions go through our own
73
+ // approver MCP server (--permission-prompt-tool) with a whitelist,
74
+ // and --tools hard-limits the built-in toolset (no Bash/Edit/Write).
75
+ const mcpServers = { approver: APPROVER_SERVER };
76
+ if (withBrowser) mcpServers.playwright = PLAYWRIGHT_SERVER;
77
+ const args = [
78
+ '-p', prompt,
79
+ '--output-format', 'stream-json', '--verbose',
80
+ '--model', model,
81
+ '--tools', allowedTools.length ? [...allowedTools, 'ToolSearch'].join(',') : '',
82
+ '--mcp-config', JSON.stringify({ mcpServers }),
83
+ '--strict-mcp-config',
84
+ '--permission-prompt-tool', 'mcp__approver__approve',
85
+ ];
86
+ // Playwright is intentionally NOT pre-approved here: listing it in
87
+ // --allowedTools would skip the permission-prompt-tool, so every browser
88
+ // call is gated by the approver (which denies evaluate/file_upload/run_code_unsafe).
89
+ if (allowedTools.length) args.push('--allowedTools', allowedTools.join(','));
90
+
91
+ // A nested interactive session is refused, but `-p` is fine; strip the
92
+ // markers anyway so the child never thinks it is inside another session.
93
+ const env = { ...process.env };
94
+ delete env.CLAUDECODE;
95
+ delete env.CLAUDE_CODE_ENTRYPOINT;
96
+
97
+ const child = spawnClaude(args, { cwd: ROOT, env, stdio: ['ignore', 'pipe', 'pipe'] });
98
+ activeChildren.add(child);
99
+ child.on('close', () => activeChildren.delete(child));
100
+
101
+ let resultText = '';
102
+ let errText = '';
103
+ let costUsd = null;
104
+ let settled = false;
105
+ let partialBuf = '';
106
+ let partialsEmitted = 0;
107
+ const t0 = Date.now();
108
+
109
+ const finish = out => {
110
+ if (settled) return;
111
+ settled = true;
112
+ clearTimeout(timer);
113
+ resolvePromise({ durationMs: Date.now() - t0, costUsd, ...out });
114
+ };
115
+
116
+ const timer = setTimeout(() => {
117
+ try { child.kill('SIGKILL'); } catch { /* already dead */ }
118
+ finish({ ok: false, error: `timeout after ${Math.round(timeoutMs / 1000)}s` });
119
+ }, timeoutMs);
120
+
121
+ if (signal) {
122
+ const onAbort = () => {
123
+ try { child.kill('SIGKILL'); } catch { /* already dead */ }
124
+ finish({ ok: false, error: 'отменено' });
125
+ };
126
+ if (signal.aborted) onAbort();
127
+ else signal.addEventListener('abort', onAbort, { once: true });
128
+ }
129
+
130
+ let buf = '';
131
+ child.stdout.on('data', chunk => {
132
+ buf += chunk;
133
+ let nl;
134
+ while ((nl = buf.indexOf('\n')) >= 0) {
135
+ const line = buf.slice(0, nl).trim();
136
+ buf = buf.slice(nl + 1);
137
+ if (!line) continue;
138
+ let ev;
139
+ try { ev = JSON.parse(line); } catch { continue; }
140
+ if (ev.type === 'assistant') {
141
+ for (const block of ev.message?.content || []) {
142
+ if (block.type === 'tool_use') {
143
+ const input = JSON.stringify(block.input || {});
144
+ onEvent(`⚙ ${block.name} ${input.length > 160 ? input.slice(0, 160) + '…' : input}`);
145
+ if (onStep) reportStep(block, onStep);
146
+ } else if (block.type === 'text' && block.text && onPartial) {
147
+ partialBuf += block.text + '\n';
148
+ const matches = [...partialBuf.matchAll(/^OPTION:\s*(\{.*\})\s*$/gm)];
149
+ for (let i = partialsEmitted; i < matches.length; i++) {
150
+ try { onPartial(JSON.parse(matches[i][1])); } catch { /* побитый партиал — придёт в финальном JSON */ }
151
+ }
152
+ partialsEmitted = Math.max(partialsEmitted, matches.length);
153
+ }
154
+ }
155
+ } else if (ev.type === 'result') {
156
+ resultText = ev.result || '';
157
+ costUsd = ev.total_cost_usd ?? null;
158
+ if (ev.is_error) errText = resultText || ev.subtype || 'agent error';
159
+ }
160
+ }
161
+ });
162
+ child.stderr.on('data', chunk => { errText += chunk; });
163
+
164
+ child.on('error', err => finish({ ok: false, error: `spawn failed: ${err.message}` }));
165
+ child.on('close', code => {
166
+ if (code === 0 && resultText) finish({ ok: true, result: resultText });
167
+ else finish({ ok: false, error: (errText || `claude exited with code ${code}`).slice(0, 2000) });
168
+ });
169
+ });
170
+ }
171
+
172
+ /** Pull a JSON value out of agent prose: direct parse → fenced block → object slice → array slice. */
173
+ export function extractJson(text) {
174
+ if (!text) return null;
175
+ const attempts = [text.trim()];
176
+ const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/);
177
+ if (fence) attempts.push(fence[1].trim());
178
+ // Object slice first: all agents return a top-level {options,summary,...}
179
+ // object; slicing [...] first would grab the inner options array and drop it.
180
+ const firstObj = text.indexOf('{');
181
+ const lastObj = text.lastIndexOf('}');
182
+ if (firstObj >= 0 && lastObj > firstObj) attempts.push(text.slice(firstObj, lastObj + 1));
183
+ const firstArr = text.indexOf('[');
184
+ const lastArr = text.lastIndexOf(']');
185
+ if (firstArr >= 0 && lastArr > firstArr) attempts.push(text.slice(firstArr, lastArr + 1));
186
+ for (const candidate of attempts) {
187
+ try { return JSON.parse(candidate); } catch { /* next */ }
188
+ }
189
+ return null;
190
+ }
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Can this machine actually run the agent — and how is it paying?
3
+ *
4
+ * The tools are local-first: the work is done by the `claude` CLI on the user's own
5
+ * machine. That CLI authenticates in one of two ways — a Claude subscription it was
6
+ * signed into once, or an Anthropic API key. Both work, and they are BILLED FROM
7
+ * DIFFERENT WALLETS: a claude.ai subscription grants no API credit, and an API key
8
+ * gives no subscription.
9
+ *
10
+ * This matters because the gate used to demand an API key from everyone, and two of
11
+ * the three tools never even forwarded it — they spawn `claude` with the ambient
12
+ * environment. So a Max subscriber with a working setup was sent to buy credits for a
13
+ * credential the engine ignored. The question is not "do you have a key" but "can this
14
+ * machine run it", and only the machine can answer.
15
+ *
16
+ * `claude auth status` answers it for free and instantly — no tokens, no round trip to
17
+ * Anthropic's inference API. It is also the only way to see the SUBSCRIPTION behind an
18
+ * API key: with ANTHROPIC_API_KEY set the CLI reports `apiKeySource` and hides the
19
+ * account, so the probe deliberately runs without it.
20
+ */
21
+
22
+ import { spawnClaude } from './spawn-claude.mjs';
23
+
24
+ const TTL_MS = 60 * 1000;
25
+
26
+ let cached = null;
27
+ let inflight = null;
28
+
29
+ function cleanEnv() {
30
+ const env = { ...process.env };
31
+ // The same strip the agent runner does: a CLI spawned from inside Claude Code
32
+ // otherwise inherits markers that change how it behaves.
33
+ delete env.CLAUDECODE;
34
+ delete env.CLAUDE_CODE_ENTRYPOINT;
35
+ // Deliberately removed: the machine's own auth is exactly what we are asking
36
+ // about. With a key in the environment the CLI reports the key and nothing about
37
+ // the subscription underneath it.
38
+ delete env.ANTHROPIC_API_KEY;
39
+ return env;
40
+ }
41
+
42
+ function run(args, { timeoutMs = 20_000, detached = false } = {}) {
43
+ return new Promise((resolve) => {
44
+ const env = cleanEnv();
45
+
46
+ let out = '';
47
+ let err = '';
48
+ let done = false;
49
+ let child;
50
+ try {
51
+ child = spawnClaude(args, {
52
+ env,
53
+ detached,
54
+ stdio: detached ? 'ignore' : ['ignore', 'pipe', 'pipe'],
55
+ });
56
+ } catch (e) {
57
+ return resolve({ code: null, out, err: e.code === 'ENOENT' ? 'not-installed' : e.message });
58
+ }
59
+ if (detached) {
60
+ child.unref();
61
+ return resolve({ code: 0, out, err });
62
+ }
63
+
64
+ const timer = setTimeout(() => {
65
+ if (done) return;
66
+ done = true;
67
+ child.kill('SIGKILL');
68
+ resolve({ code: null, out, err: 'timeout' });
69
+ }, timeoutMs);
70
+
71
+ child.stdout.on('data', (d) => (out += d));
72
+ child.stderr.on('data', (d) => (err += d));
73
+ child.on('error', (e) => {
74
+ if (done) return;
75
+ done = true;
76
+ clearTimeout(timer);
77
+ resolve({ code: null, out, err: e.code === 'ENOENT' ? 'not-installed' : e.message });
78
+ });
79
+ child.on('close', (code) => {
80
+ if (done) return;
81
+ done = true;
82
+ clearTimeout(timer);
83
+ resolve({ code, out, err });
84
+ });
85
+ });
86
+ }
87
+
88
+ function parse(out) {
89
+ try {
90
+ const i = out.indexOf('{');
91
+ const j = out.lastIndexOf('}');
92
+ return i >= 0 && j > i ? JSON.parse(out.slice(i, j + 1)) : null;
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ async function measure() {
99
+ const r = await run(['auth', 'status']);
100
+ if (r.err === 'not-installed') {
101
+ return { ok: false, mode: 'none', reason: 'cli-missing', account: null, plan: null };
102
+ }
103
+ const st = parse(r.out);
104
+ if (!st) {
105
+ return {
106
+ ok: false,
107
+ mode: 'none',
108
+ reason: 'cli-broken',
109
+ account: null,
110
+ plan: null,
111
+ detail: (r.err || r.out || '').slice(0, 300),
112
+ };
113
+ }
114
+ if (!st.loggedIn) {
115
+ return { ok: false, mode: 'none', reason: 'not-authenticated', account: null, plan: null };
116
+ }
117
+ // Signed in with a claude.ai account → the subscription pays, no key needed.
118
+ // Anything else means the CLI is leaning on API billing, which needs credit.
119
+ const bySubscription = st.authMethod === 'claude.ai' && !st.apiKeySource;
120
+ return {
121
+ ok: true,
122
+ mode: bySubscription ? 'subscription' : 'key',
123
+ reason: null,
124
+ account: st.email ?? null,
125
+ plan: st.subscriptionType ?? null,
126
+ };
127
+ }
128
+
129
+ /**
130
+ * @param {{force?: boolean}} [opts]
131
+ * @returns {Promise<{ok:boolean, mode:'subscription'|'key'|'none', reason:string|null,
132
+ * account:string|null, plan:string|null, checkedAt:number}>}
133
+ */
134
+ export async function probeEngine({ force = false } = {}) {
135
+ if (cached && !force && Date.now() - cached.checkedAt < TTL_MS) return cached;
136
+ // One probe at a time: three panels opening at once must not spawn three CLIs.
137
+ if (inflight) return inflight;
138
+ inflight = measure()
139
+ .then((r) => {
140
+ cached = { ...r, checkedAt: Date.now() };
141
+ return cached;
142
+ })
143
+ .finally(() => {
144
+ inflight = null;
145
+ });
146
+ return inflight;
147
+ }
148
+
149
+ /* ── sign-in ─────────────────────────────────────────────────────────────────
150
+ * We do not build a "connect with Claude" of our own — there is no such thing for a
151
+ * third-party app, and faking one would be exactly the wrong instinct. What we do is
152
+ * run Anthropic's own `claude auth login` here and HAND THE USER ITS LINK.
153
+ *
154
+ * The CLI prints the authorize URL on its first line of output and then waits for the
155
+ * flow to land either way: its loopback callback fires (the browser is on this machine
156
+ * and can reach it), or the person pastes back the code the callback page shows them.
157
+ * Both endings are kept open, which is why stdout is READ instead of thrown away and
158
+ * stdin stays a live pipe — the old version spawned this detached with `stdio:'ignore'`
159
+ * and so could neither show the link nor accept the code, leaving the user with a
160
+ * browser tab and a button that had visibly done nothing.
161
+ */
162
+
163
+ /** The one login in flight: { child, url, done, ok, startedAt }. */
164
+ let login = null;
165
+ /** An abandoned sign-in must not leave a CLI sitting on the machine forever. */
166
+ const LOGIN_TTL_MS = 10 * 60 * 1000;
167
+ const URL_RE = /(https?:\/\/[^\s"'<>]+)/;
168
+
169
+ function endLogin() {
170
+ if (!login || login.done) return;
171
+ login.done = true;
172
+ try {
173
+ login.child.kill('SIGKILL');
174
+ } catch {
175
+ /* already gone */
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Launch the sign-in and return the URL to put in front of the user.
181
+ *
182
+ * @returns {Promise<{ok:boolean, url?:string, reason?:string}>}
183
+ */
184
+ export async function startLogin() {
185
+ /* Idempotent while one is running. Every spawn mints a FRESH PKCE challenge and
186
+ invalidates the previous one, so re-opening the dialog (or a second panel asking)
187
+ must hand back the SAME link — otherwise the URL the user already copied dies in
188
+ their clipboard and the sign-in fails with nothing on screen to explain why. */
189
+ if (login && !login.done && login.url && Date.now() - login.startedAt < LOGIN_TTL_MS) {
190
+ return { ok: true, url: login.url };
191
+ }
192
+ endLogin();
193
+
194
+ const probe = await run(['--version'], { timeoutMs: 10_000 });
195
+ if (probe.err === 'not-installed') return { ok: false, reason: 'cli-missing' };
196
+
197
+ let child;
198
+ try {
199
+ child = spawnClaude(['auth', 'login', '--claudeai'], {
200
+ env: cleanEnv(),
201
+ stdio: ['pipe', 'pipe', 'pipe'],
202
+ });
203
+ } catch {
204
+ return { ok: false, reason: 'login-failed' };
205
+ }
206
+
207
+ const state = { child, url: null, done: false, ok: false, startedAt: Date.now() };
208
+ login = state;
209
+ cached = null; // the answer is about to change
210
+
211
+ const ttl = setTimeout(endLogin, LOGIN_TTL_MS);
212
+ ttl.unref?.();
213
+
214
+ return new Promise((resolve) => {
215
+ let out = '';
216
+ let settled = false;
217
+ const give = (r) => {
218
+ if (settled) return;
219
+ settled = true;
220
+ clearTimeout(wait);
221
+ resolve(r);
222
+ };
223
+ /* The URL is printed within a second; if it somehow is not, say so rather than
224
+ hang the dialog — the CLI may still be opening the browser by itself, which is
225
+ the pre-existing behaviour and a survivable outcome. */
226
+ const wait = setTimeout(() => give({ ok: true, url: null }), 12_000);
227
+
228
+ const read = (chunk) => {
229
+ out += chunk;
230
+ if (!state.url) {
231
+ // "If the browser didn't open, visit: https://claude.com/cai/oauth/authorize?…"
232
+ const line = out.split('\n').find((l) => /visit:/i.test(l) && URL_RE.test(l));
233
+ const m = line?.match(URL_RE);
234
+ if (m) {
235
+ state.url = m[1];
236
+ give({ ok: true, url: m[1] });
237
+ }
238
+ }
239
+ /* The CLI says so out loud when the loopback callback lands. Busting the cache
240
+ here means the panel's next poll turns green immediately instead of serving a
241
+ 60-second-old "not signed in". */
242
+ if (/login successful/i.test(out)) {
243
+ state.ok = true;
244
+ cached = null;
245
+ }
246
+ };
247
+ child.stdout.on('data', read);
248
+ child.stderr.on('data', read);
249
+ child.on('error', () => {
250
+ state.done = true;
251
+ give({ ok: false, reason: 'login-failed' });
252
+ });
253
+ child.on('close', () => {
254
+ state.done = true;
255
+ clearTimeout(ttl);
256
+ cached = null;
257
+ give({ ok: !!state.url, url: state.url ?? undefined, reason: state.url ? undefined : 'login-failed' });
258
+ });
259
+ });
260
+ }
261
+
262
+ /**
263
+ * Hand the CLI the code from the callback page.
264
+ *
265
+ * Only needed when the browser could not reach the loopback callback — a different
266
+ * machine, or a browser that refuses to open localhost. Then Anthropic's page shows a
267
+ * code instead of redirecting, and this is where it goes.
268
+ *
269
+ * @param {string} code
270
+ * @returns {{ok:boolean, reason?:string}}
271
+ */
272
+ export function submitLoginCode(code) {
273
+ const value = String(code ?? '').trim();
274
+ if (!value) return { ok: false, reason: 'empty-code' };
275
+ if (!login || login.done || !login.child.stdin.writable) return { ok: false, reason: 'no-login' };
276
+ try {
277
+ login.child.stdin.write(value + '\n');
278
+ } catch {
279
+ return { ok: false, reason: 'no-login' };
280
+ }
281
+ cached = null;
282
+ return { ok: true };
283
+ }
@@ -0,0 +1,125 @@
1
+ /** In-memory job registry + tiny concurrency queue (max 2 claude processes). */
2
+ import { uid } from './store.mjs';
3
+
4
+ const jobs = new Map();
5
+ const controllers = new Map(); // job.id → AbortController (только у выполняющихся)
6
+ const MAX_CONCURRENT = 2;
7
+ let running = 0;
8
+ const queue = [];
9
+
10
+ export function createJob(type, tripId, label, work, onCancelled = null) {
11
+ const job = {
12
+ id: uid(),
13
+ type,
14
+ tripId,
15
+ label,
16
+ status: 'queued',
17
+ log: [],
18
+ /* What the agent is DOING, in words a traveller understands — the raw log
19
+ (`⚙ WebSearch {"query":…}`) is for us, not for the person waiting. Each step
20
+ is {at, kind:'search'|'read'|'browse'|'found', text, host}. */
21
+ steps: [],
22
+ result: null,
23
+ error: null,
24
+ startedAt: Date.now(),
25
+ finishedAt: null,
26
+ };
27
+ jobs.set(job.id, job);
28
+ queue.push({ job, work, onCancelled });
29
+ pump();
30
+ // Keep the registry bounded.
31
+ if (jobs.size > 100) {
32
+ const finished = [...jobs.values()].filter(j => j.finishedAt).sort((a, b) => a.finishedAt - b.finishedAt);
33
+ for (const old of finished.slice(0, jobs.size - 100)) jobs.delete(old.id);
34
+ }
35
+ return job;
36
+ }
37
+
38
+ function pump() {
39
+ while (running < MAX_CONCURRENT && queue.length) {
40
+ const { job, work } = queue.shift();
41
+ running++;
42
+ job.status = 'running';
43
+ const ac = new AbortController();
44
+ controllers.set(job.id, ac);
45
+ jobLog(job, `старт: ${job.label}`);
46
+ Promise.resolve()
47
+ .then(() => work(job, ac.signal))
48
+ .then(result => {
49
+ job.status = 'done';
50
+ job.result = result ?? null;
51
+ })
52
+ .catch(err => {
53
+ if (ac.signal.aborted) {
54
+ job.status = 'cancelled';
55
+ jobLog(job, '⏹ отменено');
56
+ } else {
57
+ job.status = 'error';
58
+ job.error = String(err?.message || err).slice(0, 2000);
59
+ jobLog(job, `✗ ${job.error}`);
60
+ }
61
+ })
62
+ .finally(() => {
63
+ controllers.delete(job.id);
64
+ job.finishedAt = Date.now();
65
+ running--;
66
+ pump();
67
+ });
68
+ }
69
+ }
70
+
71
+ /** Отмена: из очереди — мгновенно, выполняющуюся — через abort (kill агента). */
72
+ export function cancelJob(id) {
73
+ const job = jobs.get(id);
74
+ if (!job) return false;
75
+ if (job.status === 'queued') {
76
+ const idx = queue.findIndex(q => q.job.id === id);
77
+ const entry = idx >= 0 ? queue.splice(idx, 1)[0] : null;
78
+ job.status = 'cancelled';
79
+ job.finishedAt = Date.now();
80
+ jobLog(job, '⏹ отменено (не начиналось)');
81
+ // джоб так и не запустился — воркерный откат не сработает, катим статус здесь
82
+ entry?.onCancelled?.(job);
83
+ return true;
84
+ }
85
+ if (job.status === 'running') {
86
+ controllers.get(id)?.abort();
87
+ return true;
88
+ }
89
+ return false;
90
+ }
91
+
92
+ export function cancelActiveByTrip(tripId) {
93
+ let n = 0;
94
+ for (const j of jobs.values()) {
95
+ if (j.tripId === tripId && (j.status === 'running' || j.status === 'queued') && cancelJob(j.id)) n++;
96
+ }
97
+ return n;
98
+ }
99
+
100
+ export function jobLog(job, line) {
101
+ job.log.push(`${new Date().toLocaleTimeString('ru-RU')} ${line}`);
102
+ if (job.log.length > 300) job.log.splice(0, job.log.length - 300);
103
+ }
104
+
105
+ /** One readable move of the agent. `kind` decides the icon, `host` the source chip. */
106
+ export function jobStep(job, kind, text = '', host = '') {
107
+ if (!job) return;
108
+ const last = job.steps[job.steps.length - 1];
109
+ // Agents repeat themselves (three fetches of the same host in a row); collapsing
110
+ // duplicates keeps the theatre readable instead of a stuttering list.
111
+ if (last && last.kind === kind && last.text === text && last.host === host) return;
112
+ job.steps.push({ at: Date.now(), kind, text: String(text).slice(0, 160), host: String(host).slice(0, 60) });
113
+ if (job.steps.length > 60) job.steps.splice(0, job.steps.length - 60);
114
+ }
115
+
116
+ export function listJobs() {
117
+ return [...jobs.values()]
118
+ .sort((a, b) => b.startedAt - a.startedAt)
119
+ .slice(0, 25)
120
+ .map(({ log, steps, ...j }) => ({ ...j, logTail: log.slice(-12), steps: steps.slice(-24) }));
121
+ }
122
+
123
+ export function getJob(id) {
124
+ return jobs.get(id) || null;
125
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * License verification against the Qiksy licensing worker (products/licenses).
3
+ * Identical contract to the Work Finder copy — see that file for the rationale.
4
+ * Local-first: paid tiers are unlocked by a key validated HERE, not by the plan
5
+ * stored in the user's own db.json. Best-effort + offline-grace cache.
6
+ */
7
+ const LICENSE_API = (process.env.QIKSY_LICENSE_API || 'https://qiksy-licenses.qiksy.workers.dev').replace(/\/+$/, '');
8
+ const PRODUCT = 'travel';
9
+ export const RECHECK_MS = 6 * 3600 * 1000;
10
+ export const OFFLINE_GRACE_MS = 7 * 86400 * 1000;
11
+
12
+ /**
13
+ * @param {string} key
14
+ * @returns {Promise<{plan:string, active:boolean, paidUntil:number, trial:boolean, checkedAt:number, invalid?:boolean}|null>}
15
+ * null when the check couldn't complete (offline) → keep the old cache.
16
+ */
17
+ export async function verifyLicense(key) {
18
+ if (!key) return { plan: 'free', active: false, paidUntil: 0, trial: false, checkedAt: Date.now() };
19
+ try {
20
+ const signal = typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(4000) : undefined;
21
+ const r = await fetch(`${LICENSE_API}/v?key=${encodeURIComponent(key)}&product=${PRODUCT}`, { signal });
22
+ if (!r.ok) return null;
23
+ const j = await r.json();
24
+ if (!j || j.ok === false) return { plan: 'free', active: false, paidUntil: 0, trial: false, checkedAt: Date.now(), invalid: true };
25
+ return { plan: j.plan || 'free', active: !!j.active, paidUntil: j.paidUntil || 0, trial: !!j.trial, checkedAt: Date.now() };
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ /** Effective plan id from a cached licenseInfo ('dev' is decided by the caller first). */
32
+ export function licensePlanId(licenseInfo) {
33
+ const li = licenseInfo;
34
+ if (!li || !li.plan || li.plan === 'free') return 'free';
35
+ const now = Date.now();
36
+ const notExpired = !li.paidUntil || li.paidUntil > now;
37
+ if (li.active && notExpired) return li.plan;
38
+ const offlineOk = li.checkedAt && now - li.checkedAt < OFFLINE_GRACE_MS;
39
+ if (offlineOk && li.paidUntil > now) return li.plan;
40
+ return 'free';
41
+ }