clawmoney 0.17.45 → 0.17.47

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.
@@ -11,7 +11,7 @@ const LOG_FILE = join(homedir(), ".clawmoney", "relay.log");
11
11
  export async function relayRegisterCommand(options) {
12
12
  const config = requireConfig();
13
13
  // Validate CLI type
14
- const validClis = ["claude", "codex", "gemini", "antigravity"];
14
+ const validClis = ["claude", "codex", "gemini", "antigravity", "chatgpt-web"];
15
15
  if (!validClis.includes(options.cli)) {
16
16
  console.error(chalk.red(`Invalid CLI type "${options.cli}". Must be one of: ${validClis.join(", ")}`));
17
17
  process.exit(1);
@@ -36,14 +36,16 @@ export async function relayRegisterCommand(options) {
36
36
  }
37
37
  }
38
38
  else {
39
- const spinner = ora(`Checking if ${options.cli} is installed...`).start();
39
+ // chatgpt-web drives the browser via opencli — there's no "chatgpt-web" binary.
40
+ const probeBin = options.cli === "chatgpt-web" ? "opencli" : options.cli;
41
+ const spinner = ora(`Checking if ${probeBin} is installed...`).start();
40
42
  try {
41
- execSync(`which ${options.cli}`, { stdio: "pipe" });
42
- spinner.succeed(`${options.cli} is available`);
43
+ execSync(`which ${probeBin}`, { stdio: "pipe" });
44
+ spinner.succeed(`${probeBin} is available`);
43
45
  }
44
46
  catch {
45
- spinner.fail(chalk.red(`${options.cli} is not installed or not in PATH`));
46
- console.log(chalk.dim(` Make sure ${options.cli} CLI is installed and accessible.`));
47
+ spinner.fail(chalk.red(`${probeBin} is not installed or not in PATH`));
48
+ console.log(chalk.dim(` Make sure ${probeBin} CLI is installed and accessible.`));
47
49
  process.exit(1);
48
50
  }
49
51
  }
@@ -32,6 +32,13 @@ export const API_PRICES = {
32
32
  // 5 / 5.1 / 5.2-codex families were fully removed that day. Anything
33
33
  // below this comment that's deprecated was removed from the CLI-side
34
34
  // pricing table so `modelsForCli("codex")` no longer offers them.
35
+ //
36
+ // gpt-5.5 — current Codex CLI default (config.toml `model = "gpt-5.5"`)
37
+ // after the mid-2026 bump. Upgraded ChatGPT accounts now 404 the older
38
+ // 5.4/5.3-codex/5.2 ids with "not supported when using Codex with a
39
+ // ChatGPT account", so gpt-5.5 must be offered. Priced at the 5.4 tier
40
+ // pending an official LiteLLM entry.
41
+ "gpt-5.5": { input: 2.50, output: 15 },
35
42
  "gpt-5.4": { input: 2.50, output: 15 },
36
43
  "gpt-5.4-mini": { input: 0.75, output: 4.50 },
37
44
  "gpt-5.3-codex": { input: 1.75, output: 14 },
@@ -6,6 +6,7 @@ import { RelayWsClient } from "./ws-client.js";
6
6
  import { callClaudeApi, callClaudeApiPassthrough, preflightClaudeApi, getRateGuardSnapshot as getClaudeRateGuardSnapshot, } from "./upstream/claude-api.js";
7
7
  import { callCodexApi, callCodexApiPassthrough, preflightCodexApi, getRateGuardSnapshot as getCodexRateGuardSnapshot, } from "./upstream/codex-api.js";
8
8
  import { callGeminiApi, preflightGeminiApi, getGeminiRateGuardSnapshot, } from "./upstream/gemini-api.js";
9
+ import { callChatGPTWeb } from "./upstream/chatgpt-web.js";
9
10
  import { callAntigravityApi, preflightAntigravityApi, getAntigravityRateGuardSnapshot, } from "./upstream/antigravity-api.js";
10
11
  import { callMinimaxApi, preflightMinimaxApi, getMinimaxRateGuardSnapshot, } from "./upstream/minimax-api.js";
11
12
  import { callKimiCodingApi, preflightKimiCodingApi, getKimiCodingRateGuardSnapshot, } from "./upstream/kimi-coding-api.js";
@@ -221,6 +222,11 @@ function messagesToPrompt(messages) {
221
222
  const AUTH_ERROR_THRESHOLD = 3;
222
223
  const consecutiveAuthErrorsByCli = new Map();
223
224
  const cliAuthDisabled = new Set();
225
+ // chatgpt-web: buyer sessions we've already opened a ChatGPT tab for. First
226
+ // turn opens a temporary chat; later turns continue in the same tab so context
227
+ // accumulates. Keyed by cli_session_id (falls back to request_id for stateless
228
+ // single-shots, which just get a fresh temporary chat each time).
229
+ const chatgptWebSessions = new Set();
224
230
  const AUTH_BROKEN_PATTERNS = [
225
231
  // Anthropic 403: OAuth authentication is currently not allowed for
226
232
  // this organization. The new prod signal from 2026-04-15 incident.
@@ -313,11 +319,15 @@ async function executeRelayRequest(request, config, sendChunk) {
313
319
  // antigravity → daily-cloudcode-pa). Each handler has its own
314
320
  // fingerprint file and rate-guard instance.
315
321
  if (cliType === "codex") {
316
- // Same two-mode pattern as claude: passthrough when the Hub forwards
317
- // a real Responses API body (used by /v1/responses endpoint for
318
- // Codex CLI drop-in replacement), template mode otherwise (used by
319
- // the OpenAI-compat /v1/chat/completions classic endpoint).
320
- if (request.passthrough_body) {
322
+ // Passthrough ONLY when the Hub forwarded a real Responses API body —
323
+ // i.e. it carries an `input` array (the /v1/responses drop-in path).
324
+ // The OpenAI-compat /v1/chat/completions path forwards a body with
325
+ // `messages` (no `input`), so fall through to template mode, which
326
+ // builds a Responses request from the flattened `prompt`. Without this
327
+ // guard, chat/completions hit passthrough and 400'd with
328
+ // "Passthrough body missing `input` array".
329
+ const pb = request.passthrough_body;
330
+ if (pb && Array.isArray(pb.input) && pb.input.length > 0) {
321
331
  parsed = await callCodexApiPassthrough({
322
332
  clientBody: request.passthrough_body,
323
333
  model,
@@ -340,6 +350,26 @@ async function executeRelayRequest(request, config, sendChunk) {
340
350
  maxTokens: max_budget_usd ? undefined : 8192,
341
351
  });
342
352
  }
353
+ else if (cliType === "chatgpt-web") {
354
+ // Web send/read path — drive chatgpt.com via opencli (temporary chat)
355
+ // instead of the reverse-proxy API. Real-browser route, un-bannable.
356
+ // Per-buyer tab keyed by cli_session_id: first turn opens a temporary
357
+ // chat, later turns continue in the same tab so context accumulates.
358
+ const webKey = cliSessionId || request_id;
359
+ const seenBefore = chatgptWebSessions.has(webKey);
360
+ chatgptWebSessions.add(webKey);
361
+ // Image turn when the buyer asks for an image model (gpt-image-*) — that
362
+ // forces a regular chat + image grab. Text models stay on temporary chat.
363
+ const wantsImage = /image/i.test(model);
364
+ parsed = await callChatGPTWeb({
365
+ prompt,
366
+ model,
367
+ timeout: wantsImage ? 200 : 120,
368
+ sessionKey: webKey,
369
+ continueChat: seenBefore,
370
+ imageOut: wantsImage,
371
+ });
372
+ }
343
373
  else if (cliType === "antigravity") {
344
374
  parsed = await callAntigravityApi({
345
375
  prompt,
@@ -0,0 +1,51 @@
1
+ /**
2
+ * chatgpt-web upstream — serve a relay request by driving the ChatGPT *website*
3
+ * (not the reverse-proxy API). Sends the prompt into a ChatGPT temporary chat
4
+ * via opencli's CDP browser automation, waits for the reply, reads it back.
5
+ *
6
+ * Why this exists alongside the codex reverse-proxy path:
7
+ * - It's a real person typing in chatgpt.com → OpenAI can't tell it apart from
8
+ * normal use → effectively un-bannable (the reverse-proxy路径 mimics the
9
+ * Codex CLI fingerprint, which is safe but not bullet-proof at scale).
10
+ * - Temporary chat → no history pollution, clean per-session isolation, not
11
+ * used for training.
12
+ * - Trade-off: slower (seconds, UI render) and not token-streamed. Fine for
13
+ * conversation; the buyer still gets a standard reply.
14
+ *
15
+ * Requires the provider machine to have `@jackwener/opencli` installed (with the
16
+ * `chatgpt ask --temporary` support) and a logged-in ChatGPT session in the
17
+ * opencli-controlled Chrome. Point OPENCLI_BIN at a custom binary for local
18
+ * source runs.
19
+ */
20
+ import type { ParsedOutput } from "../types.js";
21
+ export interface ChatGPTWebOptions {
22
+ prompt: string;
23
+ model: string;
24
+ /** Max seconds to wait for the assistant reply. */
25
+ timeout?: number;
26
+ /** Continue an existing /c/<id> conversation (multi-turn, non-temporary). */
27
+ conversationId?: string;
28
+ /**
29
+ * Continue the CURRENT chat in place (no --new/--temporary). Used for
30
+ * stateful multi-turn inside a temporary chat: the first turn opens the
31
+ * temporary chat, later turns just send into the same tab so ChatGPT
32
+ * accumulates context. Temporary chats have no /c/<id> to resume by URL,
33
+ * so this in-place continuation is the only way to keep their context.
34
+ */
35
+ continueChat?: boolean;
36
+ /**
37
+ * Per-buyer browser session key → its own ChatGPT tab. Same key reuses the
38
+ * same tab (multi-turn context); different keys run concurrently in separate
39
+ * tabs. Maps to opencli `--session <key>`.
40
+ */
41
+ sessionKey?: string;
42
+ /**
43
+ * This turn is expected to produce an image (generate/edit). Forces a regular
44
+ * chat (ChatGPT temporary chat BLOCKS image generation) and waits for + grabs
45
+ * the image. Maps to opencli `--image-out` + `--new` (first turn).
46
+ */
47
+ imageOut?: boolean;
48
+ /** Local image paths to upload before the prompt (edit the buyer's own image). */
49
+ imagePaths?: string[];
50
+ }
51
+ export declare function callChatGPTWeb(opts: ChatGPTWebOptions): Promise<ParsedOutput>;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * chatgpt-web upstream — serve a relay request by driving the ChatGPT *website*
3
+ * (not the reverse-proxy API). Sends the prompt into a ChatGPT temporary chat
4
+ * via opencli's CDP browser automation, waits for the reply, reads it back.
5
+ *
6
+ * Why this exists alongside the codex reverse-proxy path:
7
+ * - It's a real person typing in chatgpt.com → OpenAI can't tell it apart from
8
+ * normal use → effectively un-bannable (the reverse-proxy路径 mimics the
9
+ * Codex CLI fingerprint, which is safe but not bullet-proof at scale).
10
+ * - Temporary chat → no history pollution, clean per-session isolation, not
11
+ * used for training.
12
+ * - Trade-off: slower (seconds, UI render) and not token-streamed. Fine for
13
+ * conversation; the buyer still gets a standard reply.
14
+ *
15
+ * Requires the provider machine to have `@jackwener/opencli` installed (with the
16
+ * `chatgpt ask --temporary` support) and a logged-in ChatGPT session in the
17
+ * opencli-controlled Chrome. Point OPENCLI_BIN at a custom binary for local
18
+ * source runs.
19
+ */
20
+ import { execFile } from "node:child_process";
21
+ import { promisify } from "node:util";
22
+ const execFileP = promisify(execFile);
23
+ const OPENCLI_BIN = process.env.OPENCLI_BIN || "opencli";
24
+ /** Roughly 4 chars per token — web send/read has no real token counts, so we
25
+ * estimate for billing parity with the API paths. */
26
+ function estimateTokens(text) {
27
+ return Math.max(1, Math.ceil(text.length / 4));
28
+ }
29
+ export async function callChatGPTWeb(opts) {
30
+ const timeout = opts.timeout ?? 120;
31
+ const args = ["chatgpt", "ask", opts.prompt, "--timeout", String(timeout), "-f", "json"];
32
+ // Turn routing:
33
+ // - continueChat → no flag; opencli's ensureOnChatGPT stays on the current
34
+ // page, so a temporary chat continues in place and keeps its context.
35
+ // - conversationId → resume a saved /c/<id> conversation.
36
+ // - otherwise → open a fresh temporary chat (first turn / single-shot).
37
+ if (opts.continueChat) {
38
+ // intentionally no flag — continue the current chat in place.
39
+ }
40
+ else if (opts.conversationId) {
41
+ args.push("--conversation", opts.conversationId);
42
+ }
43
+ else if (opts.imageOut) {
44
+ // Image generation is blocked in temporary chats → use a regular new chat.
45
+ args.push("--new");
46
+ }
47
+ else {
48
+ args.push("--temporary");
49
+ }
50
+ if (opts.imageOut) {
51
+ args.push("--image-out");
52
+ }
53
+ if (opts.imagePaths?.length) {
54
+ args.push("--image", opts.imagePaths.join(","));
55
+ }
56
+ // Per-buyer tab isolation: same key → same tab (context kept), different
57
+ // keys → concurrent tabs.
58
+ if (opts.sessionKey) {
59
+ args.push("--session", opts.sessionKey);
60
+ }
61
+ let stdout = "";
62
+ try {
63
+ const result = await execFileP(OPENCLI_BIN, args, {
64
+ timeout: (timeout + 30) * 1000,
65
+ maxBuffer: 96 * 1024 * 1024, // base64 images can be several MB each
66
+ env: { ...process.env },
67
+ });
68
+ stdout = result.stdout;
69
+ }
70
+ catch (err) {
71
+ const e = err;
72
+ const tail = `${e.stdout ?? ""}${e.stderr ?? ""}`.slice(0, 400);
73
+ throw new Error(`opencli chatgpt ask failed: ${e.message ?? String(err)} ${tail}`);
74
+ }
75
+ // opencli `-f json` prints a JSON array; tolerate any leading banner lines.
76
+ const start = stdout.indexOf("[");
77
+ if (start < 0) {
78
+ throw new Error(`opencli chatgpt ask: no JSON in output: ${stdout.slice(0, 300)}`);
79
+ }
80
+ let rows;
81
+ try {
82
+ rows = JSON.parse(stdout.slice(start));
83
+ }
84
+ catch {
85
+ throw new Error(`opencli chatgpt ask: bad JSON: ${stdout.slice(start, start + 300)}`);
86
+ }
87
+ const response = String(rows?.[0]?.response ?? "").trim();
88
+ const images = Array.isArray(rows?.[0]?.images) ? rows[0].images : [];
89
+ if (!response && !images.length) {
90
+ throw new Error(`opencli chatgpt ask empty (stdout=${stdout.length}b, keys=[${Object.keys(rows?.[0] ?? {}).join(",")}], imagesType=${typeof rows?.[0]?.images})`);
91
+ }
92
+ // Carry images back inside the chat.completion content as markdown data
93
+ // URLs — the buyer's front-end renders them directly. (Image turns often
94
+ // have little/no text, so the image IS the answer.)
95
+ let text = response;
96
+ if (images.length) {
97
+ const md = images.map((img, i) => `![image${i + 1}](${img})`).join("\n");
98
+ text = text ? `${text}\n\n${md}` : md;
99
+ }
100
+ return {
101
+ text,
102
+ sessionId: String(rows?.[0]?.conversationId ?? ""),
103
+ usage: {
104
+ input_tokens: estimateTokens(opts.prompt),
105
+ // Don't token-count the base64 image (it'd be ~375k "tokens"); bill each
106
+ // generated image as a flat token block instead.
107
+ output_tokens: estimateTokens(response) + images.length * 800,
108
+ cache_creation_tokens: 0,
109
+ cache_read_tokens: 0,
110
+ },
111
+ model: opts.model,
112
+ costUsd: 0,
113
+ };
114
+ }
@@ -24,6 +24,7 @@ import { readFileSync, writeFileSync, unlinkSync, existsSync, appendFileSync, mk
24
24
  import { join } from "node:path";
25
25
  import { homedir } from "node:os";
26
26
  import { fileURLToPath } from "node:url";
27
+ import { execFileSync } from "node:child_process";
27
28
  import YAML from "yaml";
28
29
  const TASK_LOG_FILE = join(homedir(), ".clawmoney", "task.log");
29
30
  function tsLine(level, msg) {
@@ -47,10 +48,12 @@ function installFileLogger() {
47
48
  console.error = (...args) => logToFile("ERROR", ...args);
48
49
  }
49
50
  import { TaskWsClient } from "./ws-client.js";
50
- import { getSkill, listSkills } from "./skills/index.js";
51
+ import { getSkill, listSkills, defaultAdvertiseSkills } from "./skills/index.js";
52
+ import { runPreflight, writePreflightReport } from "./preflight.js";
51
53
  const CONFIG_DIR = join(homedir(), ".clawmoney");
52
54
  const CONFIG_FILE = join(CONFIG_DIR, "config.yaml");
53
55
  const PID_FILE = join(CONFIG_DIR, "task.pid");
56
+ const TASK_STATE_FILE = join(CONFIG_DIR, "task-state.json");
54
57
  function loadYamlConfig() {
55
58
  if (!existsSync(CONFIG_FILE))
56
59
  return {};
@@ -75,7 +78,10 @@ function loadConfig() {
75
78
  .split(",")
76
79
  .map((s) => s.trim())
77
80
  .filter(Boolean);
78
- const skills = requested.length > 0 ? requested : listSkills();
81
+ // Default advertise set excludes skills the SpareAPI backend now fetches
82
+ // directly (YC / IndieHackers / Hacker News); an explicit SKILLS= can still
83
+ // opt into them since `supported` below is the full registry.
84
+ const skills = requested.length > 0 ? requested : defaultAdvertiseSkills();
79
85
  // Sanity-check: drop anything we can't actually serve so we don't
80
86
  // advertise dead skills to the hub.
81
87
  const supported = new Set(listSkills());
@@ -128,6 +134,26 @@ function removePid() {
128
134
  // ignore
129
135
  }
130
136
  }
137
+ // Lifecycle phase the desktop app reads to show "service running, checking
138
+ // logins…" during the (possibly slow first-run) preflight, vs "online" once
139
+ // the hub connection is up. Distinct from the pid file, which only says the
140
+ // process exists.
141
+ function writeTaskState(phase) {
142
+ try {
143
+ writeFileSync(TASK_STATE_FILE, JSON.stringify({ phase, pid: process.pid, ts: new Date().toISOString() }), "utf-8");
144
+ }
145
+ catch {
146
+ /* best effort */
147
+ }
148
+ }
149
+ function clearTaskState() {
150
+ try {
151
+ unlinkSync(TASK_STATE_FILE);
152
+ }
153
+ catch {
154
+ // ignore
155
+ }
156
+ }
131
157
  async function handleTaskRequest(ws, req) {
132
158
  const startedAtMs = Date.now();
133
159
  const handler = getSkill(req.skill_id);
@@ -186,10 +212,47 @@ async function handleTaskRequest(ws, req) {
186
212
  clearTimeout(timer);
187
213
  }
188
214
  }
189
- function main() {
215
+ function applySystemProxy() {
216
+ // bnbot's publicScrapers honor https_proxy/http_proxy/all_proxy to reach
217
+ // censored public APIs (wiki/google/...). The daemon's own WS uses the
218
+ // `ws` lib which ignores these env vars, so this only routes child bnbot
219
+ // fetches through the proxy. Auto-detect the macOS system proxy when the
220
+ // operator hasn't set one explicitly.
221
+ if (process.env.https_proxy || process.env.HTTPS_PROXY ||
222
+ process.env.all_proxy || process.env.ALL_PROXY) {
223
+ return;
224
+ }
225
+ if (process.platform !== "darwin")
226
+ return;
227
+ try {
228
+ const out = execFileSync("scutil", ["--proxy"], { encoding: "utf8", timeout: 3000 });
229
+ const get = (key) => out.match(new RegExp(`\\b${key}\\s*:\\s*(\\S+)`))?.[1];
230
+ let url;
231
+ if (get("HTTPSEnable") === "1" && get("HTTPSProxy")) {
232
+ url = `http://${get("HTTPSProxy")}:${get("HTTPSPort") ?? "0"}`;
233
+ }
234
+ else if (get("HTTPEnable") === "1" && get("HTTPProxy")) {
235
+ url = `http://${get("HTTPProxy")}:${get("HTTPPort") ?? "0"}`;
236
+ }
237
+ else if (get("SOCKSEnable") === "1" && get("SOCKSProxy")) {
238
+ url = `socks5://${get("SOCKSProxy")}:${get("SOCKSPort") ?? "0"}`;
239
+ }
240
+ if (url) {
241
+ process.env.https_proxy = url;
242
+ process.env.http_proxy = url;
243
+ process.env.all_proxy = url;
244
+ console.log(`[task] auto-detected system proxy ${url} (routing skill fetches through it)`);
245
+ }
246
+ }
247
+ catch (err) {
248
+ console.error(`[task] system proxy detection skipped: ${err instanceof Error ? err.message : String(err)}`);
249
+ }
250
+ }
251
+ async function main() {
190
252
  // Daemon was started as a script (stdio:"ignore"); from here on, all
191
253
  // log lines should land in ~/.clawmoney/task.log.
192
254
  installFileLogger();
255
+ applySystemProxy();
193
256
  const existing = readTaskPid();
194
257
  if (existing !== null && isPidAlive(existing)) {
195
258
  console.error(`[task] already running (PID ${existing})`);
@@ -200,12 +263,32 @@ function main() {
200
263
  console.error("[task] api_key missing — set API_KEY env or run `clawmoney setup`");
201
264
  process.exit(1);
202
265
  }
266
+ // Mark the service up the moment config checks out — BEFORE the (possibly
267
+ // slow first-run) preflight. Two reasons: the app can show "service running,
268
+ // checking logins…" instead of "not started", and the app's self-heal keys
269
+ // off the pid file, so writing it now stops it from re-spawning us mid-probe.
270
+ writePid();
271
+ writeTaskState("probing");
272
+ // Preflight: probe each platform's external dependency and drop the ones
273
+ // that would fail every task (Codex not installed, X not logged in, the
274
+ // Chrome extension down, …). Writes ~/.clawmoney/preflight.json so the
275
+ // desktop app can surface a home-screen notice. Re-runs every start, so
276
+ // fixing the dependency recovers the platform on the next boot.
277
+ console.log(`[task] preflight on ${config.skills.length} skills…`);
278
+ const { skills: keptSkills, report } = await runPreflight(config.skills);
279
+ writePreflightReport(report);
280
+ config.skills = keptSkills;
281
+ if (report.summary.droppedSkills > 0) {
282
+ console.warn(`[task] preflight dropped ${report.summary.droppedSkills} skill(s) across ` +
283
+ `${report.summary.failed} platform(s): ${report.dropped.join(", ")}`);
284
+ }
203
285
  console.log(`[task] starting daemon hub=${config.hub_url} skills=[${config.skills.join(",")}]`);
204
286
  if (config.skills.length === 0) {
205
287
  console.error("[task] no skills to advertise — refusing to start");
288
+ removePid();
289
+ clearTaskState();
206
290
  process.exit(1);
207
291
  }
208
- writePid();
209
292
  // Belt-and-suspenders: even if every other handle (WS, heartbeat,
210
293
  // reconnect timer) somehow goes away simultaneously, this interval
211
294
  // is unref-less and ref-counted into the event loop, so the daemon
@@ -225,6 +308,7 @@ function main() {
225
308
  switch (frame.event) {
226
309
  case "connected":
227
310
  console.log(`[task] connected agent_id=${frame.agent_id} name="${frame.agent_name}"`);
311
+ writeTaskState("online");
228
312
  break;
229
313
  case "task_request":
230
314
  void handleTaskRequest(ws, frame);
@@ -241,6 +325,7 @@ function main() {
241
325
  console.log(`[task] ${signal} — shutting down`);
242
326
  ws.stop();
243
327
  removePid();
328
+ clearTaskState();
244
329
  setTimeout(() => process.exit(0), 200);
245
330
  };
246
331
  process.on("SIGINT", () => shutdown("SIGINT"));
@@ -251,5 +336,8 @@ function main() {
251
336
  // the module is imported (e.g. by src/commands/task.ts which only wants
252
337
  // to use readTaskPid / isPidAlive helpers).
253
338
  if (process.argv[1] === fileURLToPath(import.meta.url)) {
254
- main();
339
+ main().catch((err) => {
340
+ console.error(`[task] fatal: ${err instanceof Error ? err.stack : String(err)}`);
341
+ process.exit(1);
342
+ });
255
343
  }
@@ -0,0 +1,47 @@
1
+ export type PreflightStatus = "ok" | "failed" | "unknown";
2
+ export interface PlatformPreflight {
3
+ /** Display name surfaced in the app notice. */
4
+ label: string;
5
+ /** "local-app" | "web-login" | "social-login" — drives the app hint copy. */
6
+ category: string;
7
+ status: PreflightStatus;
8
+ /** How many advertised skills sit under this platform prefix. */
9
+ skills: number;
10
+ /** Human-readable failure reason (only when failed/unknown). */
11
+ reason?: string;
12
+ /** Actionable fix shown to the operator. */
13
+ hint?: string;
14
+ /** false = nothing the operator can do (e.g. upstream app dropped the
15
+ * capability); the app keeps it off the notice banner. Default true. */
16
+ actionable?: boolean;
17
+ /** Login page the app can open for the operator to fix a logged-out
18
+ * browser platform in one click. */
19
+ loginUrl?: string;
20
+ }
21
+ export interface PreflightReport {
22
+ ts: string;
23
+ /** false when any probed platform failed. */
24
+ ok: boolean;
25
+ summary: {
26
+ checked: number;
27
+ failed: number;
28
+ droppedSkills: number;
29
+ };
30
+ /** Keyed by platform prefix (the segment before the first dot in a skill_id). */
31
+ platforms: Record<string, PlatformPreflight>;
32
+ /** skill_ids removed from the advertise set. */
33
+ dropped: string[];
34
+ }
35
+ export interface PreflightOutcome {
36
+ /** Skills that passed (or were never probed) — the advertise set. */
37
+ skills: string[];
38
+ report: PreflightReport;
39
+ }
40
+ /**
41
+ * Probe every platform that has a registered dependency, drop the failures
42
+ * from the advertise set, and build the report. `unknown` verdicts are
43
+ * kept advertising (conservative) but recorded so the app can hint.
44
+ */
45
+ export declare function runPreflight(skills: string[]): Promise<PreflightOutcome>;
46
+ /** Persist the verdict for the desktop app to read on its next dashboard load. */
47
+ export declare function writePreflightReport(report: PreflightReport): void;