viveworker 0.8.9 → 0.9.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.
@@ -0,0 +1,179 @@
1
+ const WORK_ITEM_SCHEMA_VERSION = 1;
2
+
3
+ const TERMINAL_STATES = new Set(["completed", "failed", "canceled"]);
4
+ const STATE_PRIORITY = new Map([
5
+ ["in_progress", 1],
6
+ ["needs_action", 2],
7
+ ["completed", 3],
8
+ ["failed", 3],
9
+ ["canceled", 3],
10
+ ]);
11
+
12
+ function cleanText(value) {
13
+ return String(value ?? "").trim();
14
+ }
15
+
16
+ function normalizeProvider(value) {
17
+ const provider = cleanText(value).toLowerCase();
18
+ if (["claude", "moltbook", "a2a", "viveworker", "mcp"].includes(provider)) {
19
+ return provider;
20
+ }
21
+ return "codex";
22
+ }
23
+
24
+ function workItemCategory(item) {
25
+ switch (cleanText(item?.kind)) {
26
+ case "approval":
27
+ return cleanText(item?.approvalKind).includes("payment") ? "payment" : "approval";
28
+ case "plan":
29
+ case "plan_ready":
30
+ return "plan";
31
+ case "choice":
32
+ return "question";
33
+ case "moltbook_draft":
34
+ return "draft";
35
+ case "moltbook_reply":
36
+ return "reply";
37
+ case "thread_share":
38
+ return "handoff";
39
+ case "a2a_task":
40
+ return "delegation";
41
+ case "a2a_task_result":
42
+ return "delegation_result";
43
+ case "activity_status":
44
+ return "agent_run";
45
+ case "completion":
46
+ case "assistant_final":
47
+ return "result";
48
+ default:
49
+ return "work";
50
+ }
51
+ }
52
+
53
+ function terminalState(item) {
54
+ const taskStatus = cleanText(item?.taskStatus).toLowerCase();
55
+ if (["failed", "error"].includes(taskStatus)) {
56
+ return "failed";
57
+ }
58
+ if (["canceled", "cancelled", "rejected"].includes(taskStatus)) {
59
+ return "canceled";
60
+ }
61
+ return "completed";
62
+ }
63
+
64
+ function workItemPriority(item, state) {
65
+ if (workItemCategory(item) === "payment") {
66
+ return "urgent";
67
+ }
68
+ if (state === "needs_action") {
69
+ return cleanText(item?.kind) === "thread_share" ? "normal" : "high";
70
+ }
71
+ return state === "in_progress" ? "normal" : "low";
72
+ }
73
+
74
+ function workItemId(item) {
75
+ const provider = normalizeProvider(item?.provider);
76
+ const kind = cleanText(item?.kind) || "item";
77
+ const token = cleanText(item?.token);
78
+ const fallback = [item?.threadId, item?.createdAtMs, item?.title]
79
+ .map(cleanText)
80
+ .filter(Boolean)
81
+ .join(":");
82
+ return `${provider}:${kind}:${token || fallback || "unknown"}`;
83
+ }
84
+
85
+ function workItemThreadKey(item) {
86
+ const threadId = cleanText(item?.threadId);
87
+ return threadId ? `${normalizeProvider(item?.provider)}:${threadId}` : "";
88
+ }
89
+
90
+ function enrichWorkItem(item, state) {
91
+ const normalizedState = TERMINAL_STATES.has(state) ? state : cleanText(state) || "needs_action";
92
+ const requiresAction = normalizedState === "needs_action" && item?.supported !== false;
93
+ return {
94
+ ...item,
95
+ provider: normalizeProvider(item?.provider),
96
+ work: {
97
+ schemaVersion: WORK_ITEM_SCHEMA_VERSION,
98
+ id: workItemId(item),
99
+ state: normalizedState,
100
+ category: workItemCategory(item),
101
+ priority: workItemPriority(item, normalizedState),
102
+ requiresAction,
103
+ sourceRef: {
104
+ provider: normalizeProvider(item?.provider),
105
+ kind: cleanText(item?.kind),
106
+ token: cleanText(item?.token),
107
+ threadId: cleanText(item?.threadId),
108
+ },
109
+ occurredAtMs: Math.max(0, Number(item?.createdAtMs) || 0),
110
+ },
111
+ };
112
+ }
113
+
114
+ function compareWorkItems(left, right) {
115
+ const priorityRank = { urgent: 3, high: 2, normal: 1, low: 0 };
116
+ const priorityDelta = (priorityRank[right?.work?.priority] ?? 0) - (priorityRank[left?.work?.priority] ?? 0);
117
+ if (priorityDelta !== 0) {
118
+ return priorityDelta;
119
+ }
120
+ return Number(right?.createdAtMs ?? 0) - Number(left?.createdAtMs ?? 0);
121
+ }
122
+
123
+ function normalizeBucket(items, state) {
124
+ return (Array.isArray(items) ? items : [])
125
+ .filter((item) => item && typeof item === "object")
126
+ .map((item) => enrichWorkItem(item, state));
127
+ }
128
+
129
+ function dedupeAcrossStates(items) {
130
+ const byId = new Map();
131
+ for (const item of items) {
132
+ const id = cleanText(item?.work?.id);
133
+ if (!id) {
134
+ continue;
135
+ }
136
+ const previous = byId.get(id);
137
+ if (!previous) {
138
+ byId.set(id, item);
139
+ continue;
140
+ }
141
+ const previousRank = STATE_PRIORITY.get(previous.work.state) ?? 0;
142
+ const nextRank = STATE_PRIORITY.get(item.work.state) ?? 0;
143
+ if (nextRank > previousRank || (nextRank === previousRank && Number(item.createdAtMs) > Number(previous.createdAtMs))) {
144
+ byId.set(id, item);
145
+ }
146
+ }
147
+ return [...byId.values()];
148
+ }
149
+
150
+ export function buildWorkItemResponse({ pending = [], inProgress = [], completed = [], generatedAtMs = Date.now() } = {}) {
151
+ const pendingItems = normalizeBucket(pending, "needs_action");
152
+ const blockedThreadKeys = new Set(pendingItems.map(workItemThreadKey).filter(Boolean));
153
+ const activeItems = normalizeBucket(inProgress, "in_progress")
154
+ .filter((item) => !blockedThreadKeys.has(workItemThreadKey(item)));
155
+ const completedItems = (Array.isArray(completed) ? completed : [])
156
+ .filter((item) => item && typeof item === "object")
157
+ .map((item) => enrichWorkItem(item, terminalState(item)));
158
+
159
+ const deduped = dedupeAcrossStates([...activeItems, ...pendingItems, ...completedItems]);
160
+ const needsAction = deduped.filter((item) => item.work.state === "needs_action").sort(compareWorkItems);
161
+ const active = deduped.filter((item) => item.work.state === "in_progress").sort(compareWorkItems);
162
+ const terminal = deduped.filter((item) => TERMINAL_STATES.has(item.work.state)).sort(compareWorkItems);
163
+
164
+ return {
165
+ schemaVersion: WORK_ITEM_SCHEMA_VERSION,
166
+ generatedAtMs: Math.max(0, Number(generatedAtMs) || Date.now()),
167
+ counts: {
168
+ needsAction: needsAction.length,
169
+ inProgress: active.length,
170
+ completed: terminal.filter((item) => item.work.state === "completed").length,
171
+ failed: terminal.filter((item) => item.work.state === "failed").length,
172
+ },
173
+ pending: needsAction,
174
+ inProgress: active,
175
+ completed: terminal,
176
+ };
177
+ }
178
+
179
+ export { WORK_ITEM_SCHEMA_VERSION };
@@ -127,7 +127,7 @@ class ViveworkerMcpServer {
127
127
  websiteUrl: "https://viveworker.com",
128
128
  },
129
129
  instructions:
130
- "Use viveworker tools when you need to notify, ask, request approval, share a file, hand off context, or delegate an A2A task through the user's paired mobile control plane.",
130
+ "Use viveworker tools when you need to inspect viveworker state, notify, ask, request approval, share a file, hand off context, or delegate an A2A task through the user's paired mobile control plane.",
131
131
  };
132
132
  }
133
133
  }
@@ -138,6 +138,20 @@ async function callTool(params) {
138
138
  switch (name) {
139
139
  case "viveworker_status":
140
140
  return toolOk(await bridgeEvent({ eventType: "status" }, SHORT_TIMEOUT_MS));
141
+ case "viveworker_stats":
142
+ return toolOk(await toolStats(args));
143
+ case "viveworker_share_list":
144
+ return toolOk(await toolShareList(args));
145
+ case "viveworker_a2a_activity":
146
+ return toolOk(await toolA2AActivity(args));
147
+ case "viveworker_a2a_card":
148
+ return toolOk(await toolA2ACard(args));
149
+ case "viveworker_moltbook_list":
150
+ return toolOk(await toolMoltbookList(args));
151
+ case "viveworker_moltbook_show":
152
+ return toolOk(await toolMoltbookShow(args));
153
+ case "viveworker_moltbook_thread":
154
+ return toolOk(await toolMoltbookThread(args));
141
155
  case "viveworker_notify":
142
156
  return toolOk(await toolNotify(args));
143
157
  case "viveworker_ask":
@@ -146,6 +160,8 @@ async function callTool(params) {
146
160
  return toolOk(await toolRequestApproval(args));
147
161
  case "viveworker_share_file":
148
162
  return toolOk(await toolShareFile(args));
163
+ case "viveworker_share_replace":
164
+ return toolOk(await toolShareReplace(args));
149
165
  case "viveworker_share_link":
150
166
  return toolOk(await toolShareLink(args));
151
167
  case "viveworker_thread_share":
@@ -157,6 +173,52 @@ async function callTool(params) {
157
173
  }
158
174
  }
159
175
 
176
+ async function toolStats(args) {
177
+ const pkg = optionalString(args.pkg || args.packageName);
178
+ const cliArgs = ["stats", "--json"];
179
+ if (pkg) {
180
+ assertSafeNpmPackageName(pkg);
181
+ cliArgs.push("--pkg", pkg);
182
+ }
183
+ return runViveworkerCliJson(cliArgs, 60_000);
184
+ }
185
+
186
+ async function toolShareList(args) {
187
+ const cliArgs = ["share", "list", "--json"];
188
+ if (optionalBoolean(args.metrics)) {
189
+ cliArgs.push("--metrics");
190
+ }
191
+ return runViveworkerCliJson(cliArgs, 45_000);
192
+ }
193
+
194
+ async function toolA2AActivity(_args) {
195
+ return runViveworkerCliJson(["a2a", "activity"], 30_000);
196
+ }
197
+
198
+ async function toolA2ACard(_args) {
199
+ const output = await runViveworkerCliText(["a2a", "card"], 30_000);
200
+ return { ok: true, output: output.stdout.trim() };
201
+ }
202
+
203
+ async function toolMoltbookList(args) {
204
+ const cliArgs = ["moltbook", "list"];
205
+ if (optionalBoolean(args.all)) {
206
+ cliArgs.push("--all");
207
+ }
208
+ const output = await runViveworkerCliText(cliArgs, 30_000);
209
+ return { ok: true, output: output.stdout.trim() };
210
+ }
211
+
212
+ async function toolMoltbookShow(args) {
213
+ const commentId = requiredSafeIdentifier(args.commentId, "commentId");
214
+ return runViveworkerCliJson(["moltbook", "show", commentId], 30_000);
215
+ }
216
+
217
+ async function toolMoltbookThread(args) {
218
+ const commentId = requiredSafeIdentifier(args.commentId, "commentId");
219
+ return runViveworkerCliJson(["moltbook", "thread", commentId], 45_000);
220
+ }
221
+
160
222
  async function toolNotify(args) {
161
223
  const title = requiredString(args.title, "title");
162
224
  const message = requiredString(args.message, "message");
@@ -245,11 +307,46 @@ async function toolShareFile(args) {
245
307
  return { approved: true, share: upload, tokenizedLink: link };
246
308
  }
247
309
 
248
- async function toolShareLink(args) {
249
- const slug = requiredString(args.slug, "slug");
250
- if (!/^[A-Za-z0-9]+$/.test(slug)) {
251
- throw rpcInvalidParams("slug must contain only letters and numbers");
310
+ async function toolShareReplace(args) {
311
+ const slug = requiredShareSlug(args.slug);
312
+ const requestedPath = requiredString(args.path, "path");
313
+ const workspaceRoot = optionalString(args.workspaceRoot) || process.env.VIVEWORKER_MCP_WORKSPACE_ROOT || process.cwd();
314
+ const file = await validateSharePath(requestedPath, workspaceRoot, "share_replace");
315
+ const stat = await fs.stat(file.realPath);
316
+ const expiresDays = optionalString(args.expiresDays ?? args["expires-days"]);
317
+ const noOptimize = optionalBoolean(args.noOptimize ?? args["no-optimize"]);
318
+ const approval = await bridgeEvent({
319
+ eventType: "approval_request",
320
+ title: "Replace viveworker File Share file",
321
+ message: [
322
+ "An MCP client wants to replace the file behind an existing viveworker File Share URL.",
323
+ "",
324
+ `Share slug: ${slug}`,
325
+ `New file: ${file.displayPath}`,
326
+ `Size: ${stat.size} bytes`,
327
+ expiresDays ? `Expires days: ${expiresDays}` : "Expires days: keep existing/default",
328
+ noOptimize ? "HTML optimization: disabled for this replace" : "",
329
+ "",
330
+ "This changes what existing recipients see at the same share link.",
331
+ "Approve only if this replacement file is safe to send outside this Mac.",
332
+ ].filter(Boolean).join("\n"),
333
+ approvalKind: "file_share_replace",
334
+ fileRefs: [file.displayPath],
335
+ timeoutMs: clampTimeout(args.timeoutMs),
336
+ }, clampTimeout(args.timeoutMs));
337
+ if (!approval.approved) {
338
+ return { approved: false, decision: approval.decision || "rejected" };
252
339
  }
340
+
341
+ const replaceArgs = ["share", "replace", slug, file.realPath, "--json"];
342
+ if (expiresDays) replaceArgs.push("--expires-days", expiresDays);
343
+ if (noOptimize) replaceArgs.push("--no-optimize");
344
+ const replace = await runViveworkerCliJson(replaceArgs, 90_000);
345
+ return { approved: true, share: replace };
346
+ }
347
+
348
+ async function toolShareLink(args) {
349
+ const slug = requiredShareSlug(args.slug);
253
350
  const password = requiredString(args.password, "password");
254
351
  if (password.length > 256) {
255
352
  throw rpcInvalidParams("password is too long (max 256 characters)");
@@ -424,7 +521,7 @@ function httpJson(endpoint, options) {
424
521
  });
425
522
  }
426
523
 
427
- async function validateSharePath(filePath, workspaceRoot) {
524
+ async function validateSharePath(filePath, workspaceRoot, toolName = "share_file") {
428
525
  const root = path.resolve(String(workspaceRoot || ""));
429
526
  const candidate = path.resolve(String(filePath || ""));
430
527
  const [rootReal, fileReal] = await Promise.all([
@@ -433,16 +530,16 @@ async function validateSharePath(filePath, workspaceRoot) {
433
530
  ]);
434
531
  const rel = path.relative(rootReal, fileReal);
435
532
  if (rel.startsWith("..") || path.isAbsolute(rel)) {
436
- throw new Error("share_file is limited to the current workspace root");
533
+ throw new Error(`${toolName} is limited to the current workspace root`);
437
534
  }
438
535
  const stat = await fs.stat(fileReal);
439
536
  if (!stat.isFile()) {
440
- throw new Error("share_file path must be a regular file");
537
+ throw new Error(`${toolName} path must be a regular file`);
441
538
  }
442
539
  assertNotSensitivePath(rel || path.basename(fileReal));
443
540
  const ext = path.extname(fileReal).toLowerCase();
444
541
  if (!SHARE_FILE_EXTENSIONS.has(ext)) {
445
- throw new Error(`share_file accepts only ${Array.from(SHARE_FILE_EXTENSIONS).join(" / ")} files. Got: ${ext || "(no extension)"}`);
542
+ throw new Error(`${toolName} accepts only ${Array.from(SHARE_FILE_EXTENSIONS).join(" / ")} files. Got: ${ext || "(no extension)"}`);
446
543
  }
447
544
  return {
448
545
  realPath: fileReal,
@@ -476,7 +573,16 @@ function assertNotSensitivePath(relPath) {
476
573
  }
477
574
  }
478
575
 
479
- function runViveworkerCliJson(args, timeoutMs) {
576
+ async function runViveworkerCliJson(args, timeoutMs) {
577
+ const { stdout } = await runViveworkerCliText(args, timeoutMs);
578
+ try {
579
+ return JSON.parse(stdout);
580
+ } catch {
581
+ throw new Error(`viveworker CLI returned non-JSON: ${stdout.slice(0, 200)}`);
582
+ }
583
+ }
584
+
585
+ function runViveworkerCliText(args, timeoutMs) {
480
586
  return new Promise((resolve, reject) => {
481
587
  const child = spawn(process.execPath, [viveworkerCli, ...args], {
482
588
  cwd: packageRoot,
@@ -502,11 +608,7 @@ function runViveworkerCliJson(args, timeoutMs) {
502
608
  reject(new Error((stderr || stdout || `viveworker CLI failed with code ${code}`).trim()));
503
609
  return;
504
610
  }
505
- try {
506
- resolve(JSON.parse(stdout));
507
- } catch {
508
- reject(new Error(`viveworker CLI returned non-JSON: ${stdout.slice(0, 200)}`));
509
- }
611
+ resolve({ stdout, stderr });
510
612
  });
511
613
  });
512
614
  }
@@ -657,6 +759,29 @@ function slugFromShareUrl(value) {
657
759
  }
658
760
  }
659
761
 
762
+ function assertSafeNpmPackageName(value) {
763
+ const text = optionalString(value);
764
+ if (!/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u.test(text)) {
765
+ throw rpcInvalidParams("pkg must be a valid npm package name");
766
+ }
767
+ }
768
+
769
+ function requiredSafeIdentifier(value, name) {
770
+ const text = requiredString(value, name);
771
+ if (!/^[A-Za-z0-9._:-]{1,160}$/u.test(text)) {
772
+ throw rpcInvalidParams(`${name} contains unsupported characters`);
773
+ }
774
+ return text;
775
+ }
776
+
777
+ function requiredShareSlug(value) {
778
+ const slug = requiredString(value, "slug");
779
+ if (!/^[A-Za-z0-9]+$/.test(slug)) {
780
+ throw rpcInvalidParams("slug must contain only letters and numbers");
781
+ }
782
+ return slug;
783
+ }
784
+
660
785
  async function createShareTokenLink(slug, password, ttlHours) {
661
786
  const linkArgs = ["share", "link", slug, "--password", password, "--json"];
662
787
  if (ttlHours !== undefined) {
@@ -808,6 +933,87 @@ const TOOLS = [
808
933
  description: "Return bridge, pairing, remote connection, A2A, and File Share status.",
809
934
  inputSchema: { type: "object", additionalProperties: false },
810
935
  },
936
+ {
937
+ name: "viveworker_stats",
938
+ title: "Read viveworker stats",
939
+ description: "Read viveworker package adoption and usage stats. Read-only.",
940
+ inputSchema: {
941
+ type: "object",
942
+ additionalProperties: false,
943
+ properties: {
944
+ pkg: { type: "string" },
945
+ },
946
+ },
947
+ annotations: { readOnlyHint: true },
948
+ },
949
+ {
950
+ name: "viveworker_share_list",
951
+ title: "List File Share uploads",
952
+ description: "List File Share uploads and optionally include usage metrics. Read-only.",
953
+ inputSchema: {
954
+ type: "object",
955
+ additionalProperties: false,
956
+ properties: {
957
+ metrics: { type: "boolean" },
958
+ },
959
+ },
960
+ annotations: { readOnlyHint: true },
961
+ },
962
+ {
963
+ name: "viveworker_a2a_activity",
964
+ title: "Read A2A activity",
965
+ description: "Read local A2A activity from the default viveworker state. Read-only.",
966
+ inputSchema: { type: "object", additionalProperties: false },
967
+ annotations: { readOnlyHint: true },
968
+ },
969
+ {
970
+ name: "viveworker_a2a_card",
971
+ title: "Read A2A card",
972
+ description: "Read the current local A2A agent card settings. Read-only.",
973
+ inputSchema: { type: "object", additionalProperties: false },
974
+ annotations: { readOnlyHint: true },
975
+ },
976
+ {
977
+ name: "viveworker_moltbook_list",
978
+ title: "List Moltbook inbox",
979
+ description: "List Moltbook inbox comments. Read-only.",
980
+ inputSchema: {
981
+ type: "object",
982
+ additionalProperties: false,
983
+ properties: {
984
+ all: { type: "boolean" },
985
+ },
986
+ },
987
+ annotations: { readOnlyHint: true },
988
+ },
989
+ {
990
+ name: "viveworker_moltbook_show",
991
+ title: "Read Moltbook comment",
992
+ description: "Show one Moltbook comment by commentId. Read-only.",
993
+ inputSchema: {
994
+ type: "object",
995
+ additionalProperties: false,
996
+ properties: {
997
+ commentId: { type: "string" },
998
+ },
999
+ required: ["commentId"],
1000
+ },
1001
+ annotations: { readOnlyHint: true },
1002
+ },
1003
+ {
1004
+ name: "viveworker_moltbook_thread",
1005
+ title: "Read Moltbook thread",
1006
+ description: "Show the Moltbook thread for one commentId. Read-only.",
1007
+ inputSchema: {
1008
+ type: "object",
1009
+ additionalProperties: false,
1010
+ properties: {
1011
+ commentId: { type: "string" },
1012
+ },
1013
+ required: ["commentId"],
1014
+ },
1015
+ annotations: { readOnlyHint: true },
1016
+ },
811
1017
  {
812
1018
  name: "viveworker_notify",
813
1019
  title: "Notify phone",
@@ -893,6 +1099,24 @@ const TOOLS = [
893
1099
  required: ["path"],
894
1100
  },
895
1101
  },
1102
+ {
1103
+ name: "viveworker_share_replace",
1104
+ title: "Replace File Share file",
1105
+ description: "After phone approval, replace the file behind an existing viveworker File Share slug.",
1106
+ inputSchema: {
1107
+ type: "object",
1108
+ additionalProperties: false,
1109
+ properties: {
1110
+ slug: { type: "string" },
1111
+ path: { type: "string" },
1112
+ workspaceRoot: { type: "string" },
1113
+ expiresDays: { type: "string" },
1114
+ noOptimize: { type: "boolean" },
1115
+ timeoutMs: { type: "number" },
1116
+ },
1117
+ required: ["slug", "path"],
1118
+ },
1119
+ },
896
1120
  {
897
1121
  name: "viveworker_share_link",
898
1122
  title: "Create File Share token URL",
@@ -977,7 +1201,7 @@ const PROMPTS = [
977
1201
  title: "Share deliverable",
978
1202
  description: "Package a local deliverable through viveworker File Share with phone approval.",
979
1203
  },
980
- text: "When the user asks to share a report, prototype, screenshot, CSV, or standalone HTML deliverable, use viveworker_share_file. Only share files inside the workspace and never share secrets or credentials. If the user wants a password-protected share but the recipient should not know the password, set password plus tokenize=true, or use viveworker_share_link for an existing password-protected slug to mint a short-lived ?t= URL.",
1204
+ text: "When the user asks to share a report, prototype, screenshot, CSV, or standalone HTML deliverable, use viveworker_share_file. When the user asks to replace the file behind an existing File Share slug, use viveworker_share_replace. Only share files inside the workspace and never share secrets or credentials. If the user wants a password-protected share but the recipient should not know the password, set password plus tokenize=true, or use viveworker_share_link for an existing password-protected slug to mint a short-lived ?t= URL.",
981
1205
  },
982
1206
  {
983
1207
  definition: {
@@ -62,6 +62,33 @@ export const DEFAULT_INBOX_DIR = path.join(os.homedir(), ".viveworker", "moltboo
62
62
  export const DEFAULT_SCOUT_STATE_FILE = path.join(os.homedir(), ".viveworker", "moltbook-scout-state.json");
63
63
  export const DEFAULT_DRAFTS_DIR = path.join(os.homedir(), ".viveworker", "moltbook-drafts");
64
64
 
65
+ const PROVIDER_ERROR_TEXT_PATTERNS = [
66
+ /\bfailed to authenticate\b/iu,
67
+ /\binvalid authentication credentials\b/iu,
68
+ /\bauthentication_error\b/iu,
69
+ /\bapi error:\s*(?:401|403)\b/iu,
70
+ /\bincorrect api key\b/iu,
71
+ /\binvalid api key\b/iu,
72
+ ];
73
+
74
+ export function looksLikeProviderErrorText(value) {
75
+ const text = String(value || "").trim();
76
+ if (!text) return false;
77
+ const compact = text.replace(/\s+/gu, " ");
78
+ if (PROVIDER_ERROR_TEXT_PATTERNS.some((pattern) => pattern.test(compact))) {
79
+ return true;
80
+ }
81
+ if (!compact.startsWith("{")) return false;
82
+ try {
83
+ const parsed = JSON.parse(compact);
84
+ const type = String(parsed?.error?.type || parsed?.type || "").toLowerCase();
85
+ const message = String(parsed?.error?.message || parsed?.message || "").toLowerCase();
86
+ return type === "authentication_error" || message.includes("invalid authentication credentials");
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
65
92
  export async function loadMoltbookEnv(envFile = DEFAULT_ENV_FILE) {
66
93
  const env = {};
67
94
  try {
@@ -36,6 +36,7 @@ import {
36
36
  pickInboxReplyCandidate,
37
37
  reconcileInboxDraftMarkers,
38
38
  getMoltbookReplyQuotaState,
39
+ looksLikeProviderErrorText,
39
40
  loopbackFetch,
40
41
  } from "./moltbook-api.mjs";
41
42
 
@@ -572,6 +573,9 @@ async function cmdPropose(postId, flags) {
572
573
  if (!postId) fail("usage: viveworker moltbook propose <postId> --text \"...\"");
573
574
  const text = typeof flags.text === "string" ? flags.text : "";
574
575
  if (!text.trim()) fail("--text is required and must be non-empty");
576
+ if (looksLikeProviderErrorText(text)) {
577
+ fail("--text looks like an LLM/provider authentication error; refusing to create a Moltbook draft");
578
+ }
575
579
  const ttlSec = Number(flags.ttl) || Number(flags.timeout) || 86400;
576
580
  const parentCommentId = typeof flags["parent-id"] === "string" ? flags["parent-id"] : "";
577
581
  const postTitle = typeof flags.title === "string" ? flags.title : "";
@@ -948,10 +952,6 @@ async function cmdCompose(flags) {
948
952
  return;
949
953
  }
950
954
 
951
- // Mark slot as attempted (even before drafting, to avoid re-proposal on deny).
952
- state.composeSlotsAttempted = [...attempted, slot];
953
- await writeScoutState(state);
954
-
955
955
  // Load persona.
956
956
  let persona = "";
957
957
  try {
@@ -1020,6 +1020,14 @@ async function cmdComposePropose(flags) {
1020
1020
  }
1021
1021
  const token = submitRes?.token;
1022
1022
  if (!token) fail(`bridge did not return a token: ${JSON.stringify(submitRes)}`);
1023
+ if (slot) {
1024
+ const state = rollScoutDayIfNeeded(await readScoutState());
1025
+ const attempted = Array.isArray(state.composeSlotsAttempted) ? state.composeSlotsAttempted : [];
1026
+ if (!attempted.includes(slot)) {
1027
+ state.composeSlotsAttempted = [...attempted, slot];
1028
+ await writeScoutState(state);
1029
+ }
1030
+ }
1023
1031
 
1024
1032
  console.log(JSON.stringify({ ok: true, token, ttlSec, fireAndForget: true }));
1025
1033
  }