viveworker 0.4.6 → 0.4.8
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/package.json +1 -1
- package/scripts/a2a-executor.mjs +157 -19
- package/scripts/a2a-handler.mjs +2 -1
- package/scripts/a2a-relay-client.mjs +9 -0
- package/scripts/moltbook-api.mjs +209 -0
- package/scripts/moltbook-cli.mjs +12 -397
- package/scripts/viveworker-bridge.mjs +917 -47
- package/scripts/viveworker-claude-hook.mjs +74 -1
- package/web/app.css +123 -1
- package/web/app.js +367 -37
- package/web/i18n.js +75 -3
|
@@ -17,7 +17,8 @@ import { DEFAULT_LOCALE, SUPPORTED_LOCALES, localeDisplayName, normalizeLocale,
|
|
|
17
17
|
import { generatePairingCredentials, shouldRotatePairing, upsertEnvText } from "./lib/pairing.mjs";
|
|
18
18
|
import { renderMarkdownHtml } from "./lib/markdown-render.mjs";
|
|
19
19
|
import { buildAgentCard, handleA2ARequest, resolveA2ATaskDecision, completeA2ATask, failA2ATask } from "./a2a-handler.mjs";
|
|
20
|
-
import { registerWithRelay, startRelayPolling, stopRelayPolling, postRelayResult, getRelayStatus } from "./a2a-relay-client.mjs";
|
|
20
|
+
import { registerWithRelay, startRelayPolling, stopRelayPolling, postRelayResult, getRelayStatus, updatePublicTasksFlag } from "./a2a-relay-client.mjs";
|
|
21
|
+
import { createMoltbookClient, readScoutState, writeScoutState, rollScoutDayIfNeeded, markPostSeen, recordComposeAttempt, writeDraft, readDraft, deleteDraft, listPendingDrafts, solveVerificationPuzzle, solvePuzzleWithLLM } from "./moltbook-api.mjs";
|
|
21
22
|
|
|
22
23
|
const __filename = fileURLToPath(import.meta.url);
|
|
23
24
|
const __dirname = path.dirname(__filename);
|
|
@@ -26,9 +27,9 @@ const webRoot = path.join(workspaceRoot, "web");
|
|
|
26
27
|
const appPackageVersion = readPackageVersion();
|
|
27
28
|
const sessionCookieName = "viveworker_session";
|
|
28
29
|
const deviceCookieName = "viveworker_device";
|
|
29
|
-
const historyKinds = new Set(["completion", "plan_ready", "approval", "plan", "choice", "info"]);
|
|
30
|
+
const historyKinds = new Set(["completion", "assistant_final", "plan_ready", "approval", "plan", "choice", "info", "moltbook_reply", "moltbook_draft", "thread_share", "a2a_task", "a2a_task_result"]);
|
|
30
31
|
const timelineMessageKinds = new Set(["user_message", "assistant_commentary", "assistant_final"]);
|
|
31
|
-
const timelineKinds = new Set([...timelineMessageKinds, "approval", "plan", "choice", "
|
|
32
|
+
const timelineKinds = new Set([...timelineMessageKinds, "approval", "plan", "choice", "plan_ready", "file_event", "moltbook_reply", "moltbook_draft", "thread_share", "a2a_task", "a2a_task_result"]);
|
|
32
33
|
const SQLITE_COMPLETION_BATCH_SIZE = 200;
|
|
33
34
|
const DEFAULT_DEVICE_TRUST_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
34
35
|
const MAX_PAIRED_DEVICES = 200;
|
|
@@ -85,6 +86,8 @@ const runtime = {
|
|
|
85
86
|
moltbookItemsByToken: new Map(),
|
|
86
87
|
moltbookDraftsByToken: new Map(),
|
|
87
88
|
a2aTasksByToken: new Map(),
|
|
89
|
+
threadSharesByToken: new Map(),
|
|
90
|
+
threadRegistry: new Map(),
|
|
88
91
|
planDetailsByToken: new Map(),
|
|
89
92
|
recentHistoryItems: [],
|
|
90
93
|
recentTimelineEntries: [],
|
|
@@ -94,10 +97,55 @@ const runtime = {
|
|
|
94
97
|
stopping: false,
|
|
95
98
|
};
|
|
96
99
|
const state = await loadState(config.stateFile);
|
|
100
|
+
runtime.threadRegistry = await loadThreadRegistry(config.threadRegistryFile);
|
|
97
101
|
await syncClaudeAwayModeSentinel(config, state.claudeAwayMode === true);
|
|
98
102
|
const migratedPairedDevicesStateChanged = migratePairedDevicesState({ config, state });
|
|
99
103
|
const restoredPendingPlanStateChanged = restorePendingPlanRequests({ config, runtime, state });
|
|
100
104
|
const restoredPendingUserInputStateChanged = restorePendingUserInputRequests({ config, runtime, state });
|
|
105
|
+
|
|
106
|
+
// Detect available A2A executors (codex / claude CLIs).
|
|
107
|
+
try {
|
|
108
|
+
const { detectAvailableExecutors } = await import("./a2a-executor.mjs");
|
|
109
|
+
runtime.a2aAvailableExecutors = detectAvailableExecutors();
|
|
110
|
+
console.log(`[a2a-executors] codex=${runtime.a2aAvailableExecutors.codex}, claude=${runtime.a2aAvailableExecutors.claude}`);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
runtime.a2aAvailableExecutors = { codex: false, claude: false };
|
|
113
|
+
console.error(`[a2a-executors] Detection failed: ${error.message}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Restore persisted moltbook drafts that haven't expired.
|
|
117
|
+
try {
|
|
118
|
+
const pendingDrafts = await listPendingDrafts();
|
|
119
|
+
const now = Date.now();
|
|
120
|
+
for (const draft of pendingDrafts) {
|
|
121
|
+
const age = now - (draft.createdAtMs || 0);
|
|
122
|
+
const ttl = draft.ttlMs || 86400000;
|
|
123
|
+
if (age > ttl) {
|
|
124
|
+
await deleteDraft(draft.token).catch(() => {});
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
runtime.moltbookDraftsByToken.set(draft.token, { ...draft, decisionWaiters: [] });
|
|
128
|
+
}
|
|
129
|
+
if (runtime.moltbookDraftsByToken.size) {
|
|
130
|
+
console.log(`[moltbook-drafts] Restored ${runtime.moltbookDraftsByToken.size} pending draft(s) from disk`);
|
|
131
|
+
}
|
|
132
|
+
} catch (error) {
|
|
133
|
+
console.error(`[moltbook-drafts-restore] ${error.message}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Periodic TTL sweeper for expired drafts (every 60s).
|
|
137
|
+
setInterval(async () => {
|
|
138
|
+
const now = Date.now();
|
|
139
|
+
for (const [token, draft] of runtime.moltbookDraftsByToken) {
|
|
140
|
+
if (draft.decision) continue;
|
|
141
|
+
const age = now - (draft.createdAtMs || 0);
|
|
142
|
+
if (age > (draft.ttlMs || 86400000)) {
|
|
143
|
+
runtime.moltbookDraftsByToken.delete(token);
|
|
144
|
+
await deleteDraft(token).catch(() => {});
|
|
145
|
+
console.log(`[moltbook-draft-expired] ${token} (age=${Math.round(age / 1000)}s)`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}, 60_000).unref?.();
|
|
101
149
|
const initialHistoryItems = normalizeHistoryItems(state.recentHistoryItems ?? [], config.maxHistoryItems);
|
|
102
150
|
const initialTimelineEntries = normalizeTimelineEntries(state.recentTimelineEntries ?? [], config.maxTimelineEntries);
|
|
103
151
|
const normalizedHistoryStateChanged =
|
|
@@ -221,6 +269,8 @@ function buildSessionLocalePayload(config, state, deviceId) {
|
|
|
221
269
|
moltbookEnabled: Boolean(config.moltbookApiKey),
|
|
222
270
|
a2aEnabled: Boolean(config.a2aApiKey),
|
|
223
271
|
a2aRelayEnabled: Boolean(config.a2aRelayUrl && config.a2aRelayUserId),
|
|
272
|
+
a2aExecutors: runtime.a2aAvailableExecutors || { codex: false, claude: false },
|
|
273
|
+
a2aExecutorPreference: state.a2aExecutorPreference || "ask",
|
|
224
274
|
};
|
|
225
275
|
}
|
|
226
276
|
|
|
@@ -246,6 +296,15 @@ function kindTitle(locale, kind) {
|
|
|
246
296
|
return t(locale, "common.fileEvent");
|
|
247
297
|
case "diff_thread":
|
|
248
298
|
return t(locale, "common.diff");
|
|
299
|
+
case "a2a_task":
|
|
300
|
+
return "A2A";
|
|
301
|
+
case "a2a_task_result":
|
|
302
|
+
return "A2A";
|
|
303
|
+
case "moltbook_reply":
|
|
304
|
+
case "moltbook_draft":
|
|
305
|
+
return "Moltbook";
|
|
306
|
+
case "thread_share":
|
|
307
|
+
return t(locale, "server.title.threadShare") || "Thread Share";
|
|
249
308
|
default:
|
|
250
309
|
return t(locale, "common.item");
|
|
251
310
|
}
|
|
@@ -290,6 +349,7 @@ function notificationIconPrefix(kind) {
|
|
|
290
349
|
case "completion":
|
|
291
350
|
return "✅";
|
|
292
351
|
case "a2a_task":
|
|
352
|
+
case "a2a_task_result":
|
|
293
353
|
return "🤝";
|
|
294
354
|
default:
|
|
295
355
|
return "";
|
|
@@ -2003,6 +2063,7 @@ function normalizeProvider(value) {
|
|
|
2003
2063
|
if (normalized === "claude") return "claude";
|
|
2004
2064
|
if (normalized === "moltbook") return "moltbook";
|
|
2005
2065
|
if (normalized === "a2a") return "a2a";
|
|
2066
|
+
if (normalized === "viveworker") return "viveworker";
|
|
2006
2067
|
return "codex";
|
|
2007
2068
|
}
|
|
2008
2069
|
|
|
@@ -2047,7 +2108,12 @@ function normalizeHistoryItem(raw) {
|
|
|
2047
2108
|
const kind = cleanText(raw.kind ?? "");
|
|
2048
2109
|
const threadId = cleanText(raw.threadId ?? extractConversationIdFromStableId(stableId) ?? "");
|
|
2049
2110
|
const rawThreadLabel = cleanText(raw.threadLabel ?? "");
|
|
2050
|
-
const
|
|
2111
|
+
const historyProvider = normalizeProvider(raw.provider);
|
|
2112
|
+
const skipHistoryThreadLabelRewrite = historyProvider === "moltbook" || historyProvider === "a2a" || historyProvider === "viveworker"
|
|
2113
|
+
|| kind === "moltbook_reply" || kind === "moltbook_draft" || kind === "a2a_task" || kind === "a2a_task_result" || kind === "thread_share";
|
|
2114
|
+
const threadLabel = skipHistoryThreadLabelRewrite
|
|
2115
|
+
? rawThreadLabel
|
|
2116
|
+
: preferTitleOnlyJsonThreadLabel(rawThreadLabel, threadId, raw.messageText, raw.summary, raw.detailText, raw.message);
|
|
2051
2117
|
const title =
|
|
2052
2118
|
cleanText(raw.title ?? "") || (threadLabel ? formatTitle(kindTitle(DEFAULT_LOCALE, kind), threadLabel) : kindTitle(DEFAULT_LOCALE, kind));
|
|
2053
2119
|
const messageText = normalizeTimelineMessageText(raw.messageText ?? "");
|
|
@@ -2081,6 +2147,11 @@ function normalizeHistoryItem(raw) {
|
|
|
2081
2147
|
primaryLabel: cleanText(raw.primaryLabel ?? "") || "詳細",
|
|
2082
2148
|
tone: cleanText(raw.tone ?? "") || "secondary",
|
|
2083
2149
|
provider: normalizeProvider(raw.provider),
|
|
2150
|
+
// Moltbook draft-specific
|
|
2151
|
+
...(raw.draftType != null ? { draftType: cleanText(raw.draftType) } : {}),
|
|
2152
|
+
// A2A task-specific
|
|
2153
|
+
...(raw.instruction != null ? { instruction: cleanText(raw.instruction) } : {}),
|
|
2154
|
+
...(raw.taskStatus != null ? { taskStatus: cleanText(raw.taskStatus) } : {}),
|
|
2084
2155
|
};
|
|
2085
2156
|
}
|
|
2086
2157
|
|
|
@@ -2109,7 +2180,7 @@ function normalizeTimelineEntries(rawItems, maxItems) {
|
|
|
2109
2180
|
const deduped = [];
|
|
2110
2181
|
const seen = new Set();
|
|
2111
2182
|
const perProviderCount = {};
|
|
2112
|
-
const knownProviders = new Set(["codex", "claude", "moltbook", "a2a"]);
|
|
2183
|
+
const knownProviders = new Set(["codex", "claude", "moltbook", "a2a", "viveworker"]);
|
|
2113
2184
|
const saturatedProviders = new Set();
|
|
2114
2185
|
for (const item of normalized) {
|
|
2115
2186
|
if (seen.has(item.stableId)) {
|
|
@@ -2207,8 +2278,10 @@ function normalizeTimelineEntry(raw) {
|
|
|
2207
2278
|
(kind === "file_event" ? "" : cleanText(raw.title ?? "")) ||
|
|
2208
2279
|
"";
|
|
2209
2280
|
const rawProvider = normalizeProvider(raw.provider);
|
|
2281
|
+
const skipThreadLabelRewrite = rawProvider === "moltbook" || rawProvider === "a2a" || rawProvider === "viveworker"
|
|
2282
|
+
|| kind === "moltbook_reply" || kind === "moltbook_draft" || kind === "a2a_task" || kind === "a2a_task_result" || kind === "thread_share";
|
|
2210
2283
|
const threadLabel =
|
|
2211
|
-
|
|
2284
|
+
skipThreadLabelRewrite
|
|
2212
2285
|
? cleanText(raw.threadLabel ?? "")
|
|
2213
2286
|
: preferTitleOnlyJsonThreadLabel(
|
|
2214
2287
|
cleanText(raw.threadLabel ?? ""),
|
|
@@ -2266,6 +2339,9 @@ function normalizeTimelineEntry(raw) {
|
|
|
2266
2339
|
...(raw.draftType != null ? { draftType: cleanText(raw.draftType) } : {}),
|
|
2267
2340
|
...(raw.submoltName != null ? { submoltName: cleanText(raw.submoltName) } : {}),
|
|
2268
2341
|
...(raw.slot != null ? { slot: cleanText(raw.slot) } : {}),
|
|
2342
|
+
// A2A task-specific fields
|
|
2343
|
+
...(raw.instruction != null ? { instruction: cleanText(raw.instruction) } : {}),
|
|
2344
|
+
...(raw.taskStatus != null ? { taskStatus: cleanText(raw.taskStatus) } : {}),
|
|
2269
2345
|
};
|
|
2270
2346
|
}
|
|
2271
2347
|
|
|
@@ -3234,8 +3310,19 @@ async function processClaudeTranscriptFile({ filePath, config, runtime, state, n
|
|
|
3234
3310
|
// conversation that should not appear on the user-facing timeline.
|
|
3235
3311
|
if (/^<(?:task-notification|system-reminder)\b/i.test(text.trim())) continue;
|
|
3236
3312
|
|
|
3237
|
-
|
|
3238
|
-
|
|
3313
|
+
// Classify assistant messages using Claude's stop_reason:
|
|
3314
|
+
// "end_turn" → final answer (like Codex phase "final_answer")
|
|
3315
|
+
// "tool_use" → intermediate, about to call tools (commentary)
|
|
3316
|
+
// null/other → intermediate thinking (commentary)
|
|
3317
|
+
const stopReason = msg.stop_reason || "";
|
|
3318
|
+
const kind = type === "user"
|
|
3319
|
+
? "user_message"
|
|
3320
|
+
: stopReason === "end_turn"
|
|
3321
|
+
? "assistant_final"
|
|
3322
|
+
: "assistant_commentary";
|
|
3323
|
+
// Use kind-independent stableId so re-scans with corrected classification
|
|
3324
|
+
// replace old entries rather than creating duplicates.
|
|
3325
|
+
const stableId = `claude_msg:${threadId}:${uuid}`;
|
|
3239
3326
|
const entry = normalizeTimelineEntry({
|
|
3240
3327
|
stableId,
|
|
3241
3328
|
token: historyToken(stableId),
|
|
@@ -3252,6 +3339,46 @@ async function processClaudeTranscriptFile({ filePath, config, runtime, state, n
|
|
|
3252
3339
|
});
|
|
3253
3340
|
if (entry) {
|
|
3254
3341
|
dirty = recordTimelineEntry({ config, runtime, state, entry }) || dirty;
|
|
3342
|
+
|
|
3343
|
+
// Also record Claude assistant_final as a history item for the completed list
|
|
3344
|
+
if (kind === "assistant_final") {
|
|
3345
|
+
dirty = recordHistoryItem({
|
|
3346
|
+
config, runtime, state,
|
|
3347
|
+
item: {
|
|
3348
|
+
stableId: entry.stableId,
|
|
3349
|
+
kind: entry.kind,
|
|
3350
|
+
threadId: entry.threadId,
|
|
3351
|
+
title: entry.title,
|
|
3352
|
+
threadLabel: entry.threadLabel,
|
|
3353
|
+
summary: entry.summary,
|
|
3354
|
+
messageText: entry.messageText,
|
|
3355
|
+
createdAtMs: entry.createdAtMs,
|
|
3356
|
+
readOnly: true,
|
|
3357
|
+
provider: "claude",
|
|
3358
|
+
},
|
|
3359
|
+
}) || dirty;
|
|
3360
|
+
}
|
|
3361
|
+
|
|
3362
|
+
// Push notification for Claude final answers (skip during startup replay)
|
|
3363
|
+
if (kind === "assistant_final" && !fileState.startupCutoffMs) {
|
|
3364
|
+
try {
|
|
3365
|
+
await deliverWebPushItem({
|
|
3366
|
+
config,
|
|
3367
|
+
state,
|
|
3368
|
+
kind: "assistant_final",
|
|
3369
|
+
token: entry.token,
|
|
3370
|
+
stableId: entry.stableId,
|
|
3371
|
+
title: threadLabel || "Claude",
|
|
3372
|
+
body: truncate(singleLine(text), 160),
|
|
3373
|
+
buildLocalizedContent: ({ locale }) => ({
|
|
3374
|
+
title: formatLocalizedTitle(locale, "server.title.assistantFinal", threadLabel),
|
|
3375
|
+
body: truncate(singleLine(text), 160),
|
|
3376
|
+
}),
|
|
3377
|
+
});
|
|
3378
|
+
} catch (pushError) {
|
|
3379
|
+
console.error(`[claude-push-error] ${pushError.message}`);
|
|
3380
|
+
}
|
|
3381
|
+
}
|
|
3255
3382
|
}
|
|
3256
3383
|
}
|
|
3257
3384
|
|
|
@@ -3397,6 +3524,29 @@ async function processSqliteTimelineLog({ config, runtime, state, now }) {
|
|
|
3397
3524
|
state,
|
|
3398
3525
|
entry,
|
|
3399
3526
|
}) || dirty;
|
|
3527
|
+
|
|
3528
|
+
// Also record Codex assistant_final as a history item so it appears
|
|
3529
|
+
// in the completed inbox and supports reply (replaces "completion").
|
|
3530
|
+
if (entry.kind === "assistant_final") {
|
|
3531
|
+
dirty =
|
|
3532
|
+
recordHistoryItem({
|
|
3533
|
+
config,
|
|
3534
|
+
runtime,
|
|
3535
|
+
state,
|
|
3536
|
+
item: {
|
|
3537
|
+
stableId: entry.stableId,
|
|
3538
|
+
kind: entry.kind,
|
|
3539
|
+
threadId: entry.threadId,
|
|
3540
|
+
title: entry.title,
|
|
3541
|
+
threadLabel: entry.threadLabel,
|
|
3542
|
+
summary: entry.summary,
|
|
3543
|
+
messageText: entry.messageText,
|
|
3544
|
+
createdAtMs: entry.createdAtMs,
|
|
3545
|
+
readOnly: true,
|
|
3546
|
+
provider: "codex",
|
|
3547
|
+
},
|
|
3548
|
+
}) || dirty;
|
|
3549
|
+
}
|
|
3400
3550
|
}
|
|
3401
3551
|
|
|
3402
3552
|
state.sqliteMessageCursorId = cursorId;
|
|
@@ -6970,6 +7120,20 @@ class NativeIpcClient {
|
|
|
6970
7120
|
|
|
6971
7121
|
this.runtime.threadStates.set(normalized.conversationId, nextState);
|
|
6972
7122
|
|
|
7123
|
+
// Register / update in the persistent thread registry.
|
|
7124
|
+
const regLabel = extractThreadLabelFromState(nextState)
|
|
7125
|
+
|| getThreadName(this.runtime.sessionIndex, this.runtime.rolloutThreadLabels, normalized.conversationId, nextState.cwd || "", "");
|
|
7126
|
+
if (registerThread(this.runtime, {
|
|
7127
|
+
id: normalized.conversationId,
|
|
7128
|
+
label: regLabel,
|
|
7129
|
+
tool: "codex",
|
|
7130
|
+
cwd: nextState.cwd || "",
|
|
7131
|
+
lastSeenAtMs: Date.now(),
|
|
7132
|
+
active: true,
|
|
7133
|
+
})) {
|
|
7134
|
+
saveThreadRegistry(this.config.threadRegistryFile, this.runtime.threadRegistry).catch(() => {});
|
|
7135
|
+
}
|
|
7136
|
+
|
|
6973
7137
|
await this.onThreadStateChanged({
|
|
6974
7138
|
conversationId: normalized.conversationId,
|
|
6975
7139
|
previousRequests,
|
|
@@ -9039,16 +9203,18 @@ function buildPendingInboxItems(runtime, state, config, locale) {
|
|
|
9039
9203
|
for (const draft of runtime.moltbookDraftsByToken.values()) {
|
|
9040
9204
|
if (draft.decision) continue;
|
|
9041
9205
|
const title = draft.postTitle ? `draft → ${draft.postTitle}` : "Moltbook draft";
|
|
9206
|
+
const draftThreadLabel = draft.postTitle || "Moltbook";
|
|
9042
9207
|
items.push({
|
|
9043
9208
|
kind: "moltbook_draft",
|
|
9044
9209
|
token: draft.token,
|
|
9045
9210
|
threadId: "moltbook",
|
|
9046
|
-
threadLabel:
|
|
9211
|
+
threadLabel: draftThreadLabel,
|
|
9047
9212
|
title,
|
|
9048
9213
|
summary: draft.contextSummary || String(draft.draftText || "").slice(0, 160),
|
|
9049
9214
|
primaryLabel: t(locale, "server.action.review"),
|
|
9050
9215
|
createdAtMs: Number(draft.createdAtMs) || now,
|
|
9051
9216
|
provider: "moltbook",
|
|
9217
|
+
draftType: draft.draftType || "reply",
|
|
9052
9218
|
});
|
|
9053
9219
|
}
|
|
9054
9220
|
|
|
@@ -9058,7 +9224,7 @@ function buildPendingInboxItems(runtime, state, config, locale) {
|
|
|
9058
9224
|
kind: "a2a_task",
|
|
9059
9225
|
token: task.token,
|
|
9060
9226
|
threadId: "a2a",
|
|
9061
|
-
threadLabel: "
|
|
9227
|
+
threadLabel: cleanText(task.instruction || "").slice(0, 80),
|
|
9062
9228
|
title: `A2A: ${cleanText(task.instruction || "").slice(0, 80)}`,
|
|
9063
9229
|
summary: cleanText(task.instruction || "").slice(0, 160),
|
|
9064
9230
|
primaryLabel: t(locale, "server.action.review"),
|
|
@@ -9067,6 +9233,24 @@ function buildPendingInboxItems(runtime, state, config, locale) {
|
|
|
9067
9233
|
});
|
|
9068
9234
|
}
|
|
9069
9235
|
|
|
9236
|
+
for (const share of runtime.threadSharesByToken.values()) {
|
|
9237
|
+
if (share.decision) continue;
|
|
9238
|
+
const title = share.sourceLabel
|
|
9239
|
+
? `${share.sourceLabel} → ${share.targetLabel || share.targetTool}`
|
|
9240
|
+
: `→ ${share.targetLabel || share.targetTool}`;
|
|
9241
|
+
items.push({
|
|
9242
|
+
kind: "thread_share",
|
|
9243
|
+
token: share.token,
|
|
9244
|
+
threadId: "thread_share",
|
|
9245
|
+
threadLabel: "Thread Share",
|
|
9246
|
+
title,
|
|
9247
|
+
summary: String(share.content || "").slice(0, 160),
|
|
9248
|
+
primaryLabel: t(locale, "server.action.review"),
|
|
9249
|
+
createdAtMs: Number(share.createdAtMs) || now,
|
|
9250
|
+
provider: "viveworker",
|
|
9251
|
+
});
|
|
9252
|
+
}
|
|
9253
|
+
|
|
9070
9254
|
// Moltbook reply items intentionally do not appear in the unhandled list.
|
|
9071
9255
|
// Reply drafting is delegated to Codex/Claude Desktop via the
|
|
9072
9256
|
// `viveworker moltbook` CLI, so they live in the timeline only.
|
|
@@ -9077,8 +9261,15 @@ function buildPendingInboxItems(runtime, state, config, locale) {
|
|
|
9077
9261
|
function buildCompletedInboxItems(runtime, state, config, locale) {
|
|
9078
9262
|
const items = normalizeHistoryItems(state.recentHistoryItems ?? runtime.recentHistoryItems, config.maxHistoryItems);
|
|
9079
9263
|
runtime.recentHistoryItems = items;
|
|
9264
|
+
const completedKinds = new Set(["assistant_final", "approval", "moltbook_reply", "moltbook_draft", "thread_share", "a2a_task_result"]);
|
|
9080
9265
|
return items
|
|
9081
|
-
.filter((item) =>
|
|
9266
|
+
.filter((item) => {
|
|
9267
|
+
const k = cleanText(item?.kind || "");
|
|
9268
|
+
if (!completedKinds.has(k)) return false;
|
|
9269
|
+
// Only resolved approvals (readOnly = true)
|
|
9270
|
+
if (k === "approval" && !item.readOnly) return false;
|
|
9271
|
+
return true;
|
|
9272
|
+
})
|
|
9082
9273
|
.slice()
|
|
9083
9274
|
.sort((left, right) => Number(right.createdAtMs ?? 0) - Number(left.createdAtMs ?? 0))
|
|
9084
9275
|
.map((item) => ({
|
|
@@ -9086,12 +9277,19 @@ function buildCompletedInboxItems(runtime, state, config, locale) {
|
|
|
9086
9277
|
token: item.token,
|
|
9087
9278
|
threadId: cleanText(item.threadId || extractConversationIdFromStableId(item.stableId) || ""),
|
|
9088
9279
|
threadLabel: item.threadLabel || "",
|
|
9089
|
-
title: item.
|
|
9280
|
+
title: item.kind === "assistant_final"
|
|
9281
|
+
? (item.threadLabel ? formatTitle(kindTitle(locale, item.kind), item.threadLabel) : item.title)
|
|
9282
|
+
: (item.kind === "a2a_task_result" && item.instruction)
|
|
9283
|
+
? cleanText(item.instruction).slice(0, 80)
|
|
9284
|
+
: (item.kind === "moltbook_reply" || item.kind === "moltbook_draft")
|
|
9285
|
+
? cleanText(item.summary || item.messageText || "").slice(0, 80) || item.title
|
|
9286
|
+
: item.title || kindTitle(locale, item.kind),
|
|
9090
9287
|
summary: item.summary,
|
|
9091
9288
|
fileRefs: normalizeTimelineFileRefs(item.fileRefs ?? []),
|
|
9092
9289
|
primaryLabel: t(locale, "server.action.detail"),
|
|
9093
9290
|
createdAtMs: item.createdAtMs,
|
|
9094
9291
|
provider: normalizeProvider(item.provider),
|
|
9292
|
+
...(item.draftType != null ? { draftType: item.draftType } : {}),
|
|
9095
9293
|
}));
|
|
9096
9294
|
}
|
|
9097
9295
|
|
|
@@ -9445,6 +9643,7 @@ function buildTimelineResponse(runtime, state, config, locale) {
|
|
|
9445
9643
|
outcome: entry.outcome || "",
|
|
9446
9644
|
createdAtMs: entry.createdAtMs,
|
|
9447
9645
|
provider: normalizeProvider(entry.provider),
|
|
9646
|
+
...(entry.draftType != null ? { draftType: entry.draftType } : {}),
|
|
9448
9647
|
}));
|
|
9449
9648
|
|
|
9450
9649
|
return {
|
|
@@ -9661,8 +9860,11 @@ function historyItemThreadId(item) {
|
|
|
9661
9860
|
return cleanText(item?.threadId || extractConversationIdFromStableId(item?.stableId) || "");
|
|
9662
9861
|
}
|
|
9663
9862
|
|
|
9664
|
-
|
|
9665
|
-
|
|
9863
|
+
const REPLYABLE_HISTORY_KINDS = new Set(["assistant_final"]);
|
|
9864
|
+
|
|
9865
|
+
function isLatestReplyableHistoryItem(runtime, item) {
|
|
9866
|
+
const itemKind = cleanText(item?.kind || "");
|
|
9867
|
+
if (!runtime || !REPLYABLE_HISTORY_KINDS.has(itemKind)) {
|
|
9666
9868
|
return false;
|
|
9667
9869
|
}
|
|
9668
9870
|
|
|
@@ -9672,14 +9874,24 @@ function isLatestCompletionHistoryItem(runtime, item) {
|
|
|
9672
9874
|
return false;
|
|
9673
9875
|
}
|
|
9674
9876
|
|
|
9675
|
-
|
|
9676
|
-
|
|
9677
|
-
|
|
9877
|
+
// Find the latest replyable item (completion or assistant_final) for this thread
|
|
9878
|
+
// Sort by createdAtMs descending since array order is not guaranteed
|
|
9879
|
+
let latestForThread = null;
|
|
9880
|
+
let latestTs = 0;
|
|
9881
|
+
for (const entry of runtime.recentHistoryItems) {
|
|
9882
|
+
if (!REPLYABLE_HISTORY_KINDS.has(cleanText(entry?.kind || "")) || historyItemThreadId(entry) !== threadId) continue;
|
|
9883
|
+
const ts = Number(entry.createdAtMs) || 0;
|
|
9884
|
+
if (!latestForThread || ts > latestTs) {
|
|
9885
|
+
latestForThread = entry;
|
|
9886
|
+
latestTs = ts;
|
|
9887
|
+
}
|
|
9888
|
+
}
|
|
9678
9889
|
return cleanText(latestForThread?.token || "") === token;
|
|
9679
9890
|
}
|
|
9680
9891
|
|
|
9681
9892
|
function findNewerThreadMessageAfterCompletion(runtime, completionItem) {
|
|
9682
|
-
|
|
9893
|
+
const itemKind = cleanText(completionItem?.kind || "");
|
|
9894
|
+
if (!runtime || !REPLYABLE_HISTORY_KINDS.has(itemKind)) {
|
|
9683
9895
|
return null;
|
|
9684
9896
|
}
|
|
9685
9897
|
|
|
@@ -9705,10 +9917,10 @@ function findNewerThreadMessageAfterCompletion(runtime, completionItem) {
|
|
|
9705
9917
|
function buildHistoryDetail(item, locale, runtime = null) {
|
|
9706
9918
|
const threadId = historyItemThreadId(item);
|
|
9707
9919
|
const replyEnabled =
|
|
9708
|
-
item.kind
|
|
9920
|
+
REPLYABLE_HISTORY_KINDS.has(item.kind) &&
|
|
9709
9921
|
Boolean(threadId) &&
|
|
9710
9922
|
Boolean(runtime?.ipcClient) &&
|
|
9711
|
-
|
|
9923
|
+
isLatestReplyableHistoryItem(runtime, item);
|
|
9712
9924
|
return {
|
|
9713
9925
|
kind: item.kind,
|
|
9714
9926
|
token: item.token,
|
|
@@ -10162,7 +10374,20 @@ async function handleCompletionReply({
|
|
|
10162
10374
|
if (!conversationId) {
|
|
10163
10375
|
throw new Error("completion-reply-unavailable");
|
|
10164
10376
|
}
|
|
10165
|
-
|
|
10377
|
+
// For assistant_final, check against timeline entries (avoids maxHistoryItems eviction).
|
|
10378
|
+
// For legacy completion, check against history items.
|
|
10379
|
+
const itemKind = cleanText(completionItem?.kind || "");
|
|
10380
|
+
if (itemKind === "assistant_final") {
|
|
10381
|
+
const itemTs = Number(completionItem.createdAtMs) || 0;
|
|
10382
|
+
const hasNewer = runtime.recentTimelineEntries.some(
|
|
10383
|
+
(e) => e.kind === "assistant_final" &&
|
|
10384
|
+
cleanText(e.threadId || "") === conversationId &&
|
|
10385
|
+
(Number(e.createdAtMs) || 0) > itemTs
|
|
10386
|
+
);
|
|
10387
|
+
if (hasNewer) {
|
|
10388
|
+
throw new Error("completion-reply-unavailable");
|
|
10389
|
+
}
|
|
10390
|
+
} else if (!isLatestReplyableHistoryItem(runtime, completionItem)) {
|
|
10166
10391
|
throw new Error("completion-reply-unavailable");
|
|
10167
10392
|
}
|
|
10168
10393
|
const newerThreadMessage = findNewerThreadMessageAfterCompletion(runtime, completionItem);
|
|
@@ -10449,7 +10674,7 @@ async function handleNativeApprovalDecision({ config, runtime, state, approval,
|
|
|
10449
10674
|
title: approval.title,
|
|
10450
10675
|
threadLabel: approval.threadLabel || "",
|
|
10451
10676
|
messageText: `${approvalDecisionMessage(decision, config.defaultLocale, approval.provider)}\n\n${approval.messageText}`,
|
|
10452
|
-
summary: approvalDecisionMessage(decision, config.defaultLocale, approval.provider),
|
|
10677
|
+
summary: formatNotificationBody(approval.messageText, 160) || approvalDecisionMessage(decision, config.defaultLocale, approval.provider),
|
|
10453
10678
|
fileRefs: normalizeTimelineFileRefs(approval.fileRefs ?? []),
|
|
10454
10679
|
diffText: normalizeTimelineDiffText(approval.diffText ?? ""),
|
|
10455
10680
|
diffSource: normalizeTimelineDiffSource(approval.diffSource ?? ""),
|
|
@@ -10476,7 +10701,32 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
|
|
|
10476
10701
|
}
|
|
10477
10702
|
if (timelineMessageKinds.has(kind)) {
|
|
10478
10703
|
const entry = timelineEntryByToken(runtime, token, kind);
|
|
10479
|
-
|
|
10704
|
+
if (!entry) return null;
|
|
10705
|
+
const detail = buildTimelineMessageDetail(entry, locale, runtime);
|
|
10706
|
+
// Add reply support for Codex assistant_final entries only (replaces completion reply).
|
|
10707
|
+
// Check directly against timeline entries (not history items) to avoid
|
|
10708
|
+
// maxHistoryItems eviction issues.
|
|
10709
|
+
if (kind === "assistant_final") {
|
|
10710
|
+
const entryProvider = normalizeProvider(entry.provider);
|
|
10711
|
+
if (entryProvider === "codex" && runtime?.ipcClient) {
|
|
10712
|
+
const entryThreadId = cleanText(entry.threadId || "");
|
|
10713
|
+
const entryTs = Number(entry.createdAtMs) || 0;
|
|
10714
|
+
let isLatestForThread = true;
|
|
10715
|
+
for (const other of runtime.recentTimelineEntries) {
|
|
10716
|
+
if (other.kind === "assistant_final" &&
|
|
10717
|
+
cleanText(other.threadId || "") === entryThreadId &&
|
|
10718
|
+
(Number(other.createdAtMs) || 0) > entryTs) {
|
|
10719
|
+
isLatestForThread = false;
|
|
10720
|
+
break;
|
|
10721
|
+
}
|
|
10722
|
+
}
|
|
10723
|
+
if (isLatestForThread) {
|
|
10724
|
+
detail.reply = { enabled: true, supportsPlanMode: true, supportsImages: true };
|
|
10725
|
+
detail.provider = entryProvider;
|
|
10726
|
+
}
|
|
10727
|
+
}
|
|
10728
|
+
}
|
|
10729
|
+
return detail;
|
|
10480
10730
|
}
|
|
10481
10731
|
if (kind === "approval") {
|
|
10482
10732
|
const approval = runtime.nativeApprovalsByToken.get(token);
|
|
@@ -10579,20 +10829,21 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
|
|
|
10579
10829
|
};
|
|
10580
10830
|
}
|
|
10581
10831
|
|
|
10582
|
-
if (kind === "a2a_task") {
|
|
10583
|
-
const task = runtime.a2aTasksByToken.get(token);
|
|
10832
|
+
if (kind === "a2a_task" || kind === "a2a_task_result") {
|
|
10833
|
+
const task = kind === "a2a_task" ? runtime.a2aTasksByToken.get(token) : null;
|
|
10584
10834
|
const entry = task
|
|
10585
10835
|
? null
|
|
10586
|
-
: runtime.recentTimelineEntries.find((e) => e.kind ===
|
|
10836
|
+
: runtime.recentTimelineEntries.find((e) => e.kind === kind && e.token === token);
|
|
10587
10837
|
const source = task || entry;
|
|
10588
10838
|
if (!source) return null;
|
|
10589
|
-
const instruction = task ? task.instruction : entry.
|
|
10839
|
+
const instruction = task ? task.instruction : entry.instruction || entry.summary || "";
|
|
10840
|
+
const responseText = kind === "a2a_task_result" && !task ? entry.messageText || "" : "";
|
|
10590
10841
|
const messageHtml = escapeHtml(instruction)
|
|
10591
10842
|
.split("\n")
|
|
10592
10843
|
.map((line) => `<p>${line}</p>`)
|
|
10593
10844
|
.join("");
|
|
10594
10845
|
return {
|
|
10595
|
-
kind
|
|
10846
|
+
kind,
|
|
10596
10847
|
token,
|
|
10597
10848
|
threadId: "a2a",
|
|
10598
10849
|
threadLabel: "A2A",
|
|
@@ -10601,8 +10852,9 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
|
|
|
10601
10852
|
messageHtml,
|
|
10602
10853
|
provider: "a2a",
|
|
10603
10854
|
instruction,
|
|
10855
|
+
messageText: responseText,
|
|
10604
10856
|
taskId: task?.id || "",
|
|
10605
|
-
taskStatus: task?.status || entry?.taskStatus || "
|
|
10857
|
+
taskStatus: task?.status || entry?.taskStatus || (kind === "a2a_task_result" ? "completed" : "submitted"),
|
|
10606
10858
|
callerInfo: task?.callerInfo || {},
|
|
10607
10859
|
createdAtMs: source.createdAtMs || Date.now(),
|
|
10608
10860
|
readOnly: !task || task.status !== "submitted",
|
|
@@ -10611,6 +10863,42 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
|
|
|
10611
10863
|
};
|
|
10612
10864
|
}
|
|
10613
10865
|
|
|
10866
|
+
if (kind === "thread_share") {
|
|
10867
|
+
const share = runtime.threadSharesByToken.get(token);
|
|
10868
|
+
const entry = share
|
|
10869
|
+
? null
|
|
10870
|
+
: runtime.recentTimelineEntries.find((e) => e.kind === "thread_share" && e.token === token);
|
|
10871
|
+
const source = share || entry;
|
|
10872
|
+
if (!source) return null;
|
|
10873
|
+
const content = share ? share.content : entry.messageText || entry.summary || "";
|
|
10874
|
+
const messageHtml = escapeHtml(content)
|
|
10875
|
+
.split("\n")
|
|
10876
|
+
.map((line) => `<p>${line}</p>`)
|
|
10877
|
+
.join("");
|
|
10878
|
+
return {
|
|
10879
|
+
kind: "thread_share",
|
|
10880
|
+
token,
|
|
10881
|
+
threadId: "thread_share",
|
|
10882
|
+
threadLabel: "Thread Share",
|
|
10883
|
+
title: source.title || "Thread Share",
|
|
10884
|
+
summary: source.summary || cleanText(content).slice(0, 160),
|
|
10885
|
+
messageHtml,
|
|
10886
|
+
provider: "viveworker",
|
|
10887
|
+
shareContent: content,
|
|
10888
|
+
shareType: share?.shareType || entry?.shareType || "message",
|
|
10889
|
+
sourceTool: share?.sourceTool || entry?.sourceTool || "",
|
|
10890
|
+
sourceLabel: share?.sourceLabel || entry?.sourceLabel || "",
|
|
10891
|
+
targetTool: share?.targetTool || entry?.targetTool || "",
|
|
10892
|
+
targetLabel: share?.targetLabel || entry?.targetLabel || "",
|
|
10893
|
+
targetConversationId: share?.targetConversationId || entry?.targetConversationId || "",
|
|
10894
|
+
contextFiles: share?.contextFiles || entry?.contextFiles || [],
|
|
10895
|
+
createdAtMs: source.createdAtMs || Date.now(),
|
|
10896
|
+
readOnly: !share || Boolean(share?.decision),
|
|
10897
|
+
actions: [],
|
|
10898
|
+
threadShareEnabled: Boolean(share) && !share.decision,
|
|
10899
|
+
};
|
|
10900
|
+
}
|
|
10901
|
+
|
|
10614
10902
|
const historyItem = historyItemByToken(runtime, kind, token);
|
|
10615
10903
|
return historyItem ? buildHistoryDetail(historyItem, locale, runtime) : null;
|
|
10616
10904
|
}
|
|
@@ -10871,14 +11159,25 @@ function createNativeApprovalServer({ config, runtime, state }) {
|
|
|
10871
11159
|
}
|
|
10872
11160
|
const action = String(body.action || "") === "approve" ? "approve" : "deny";
|
|
10873
11161
|
const instruction = cleanText(body.instruction || "");
|
|
11162
|
+
const requestedExecutor = cleanText(body.executor || "");
|
|
10874
11163
|
resolveA2ATaskDecision(task, { action, instruction });
|
|
10875
11164
|
|
|
10876
11165
|
if (action === "approve") {
|
|
10877
|
-
//
|
|
11166
|
+
// Resolve which executor to use.
|
|
11167
|
+
const available = runtime.a2aAvailableExecutors || { codex: false, claude: false };
|
|
11168
|
+
const preference = requestedExecutor || state.a2aExecutorPreference || "ask";
|
|
11169
|
+
let executor;
|
|
11170
|
+
if (preference === "codex" && available.codex) executor = "codex";
|
|
11171
|
+
else if (preference === "claude" && available.claude) executor = "claude";
|
|
11172
|
+
else if (available.codex) executor = "codex";
|
|
11173
|
+
else if (available.claude) executor = "claude";
|
|
11174
|
+
else executor = "codex"; // fallback
|
|
11175
|
+
|
|
11176
|
+
// Execute task asynchronously
|
|
10878
11177
|
(async () => {
|
|
10879
11178
|
try {
|
|
10880
11179
|
const { executeA2ATask } = await import("./a2a-executor.mjs");
|
|
10881
|
-
await executeA2ATask(task, config, runtime, state, { recordTimelineEntry, saveState });
|
|
11180
|
+
await executeA2ATask(task, config, runtime, state, { recordTimelineEntry, saveState }, executor);
|
|
10882
11181
|
} catch (error) {
|
|
10883
11182
|
console.error(`[a2a-execute-error] ${error.message}`);
|
|
10884
11183
|
failA2ATask(task, error.message);
|
|
@@ -11094,6 +11393,24 @@ function createNativeApprovalServer({ config, runtime, state }) {
|
|
|
11094
11393
|
return writeJson(res, 200, { ok: true, enabled });
|
|
11095
11394
|
}
|
|
11096
11395
|
|
|
11396
|
+
if (url.pathname === "/api/settings/a2a-executor" && req.method === "POST") {
|
|
11397
|
+
const session = requireMutatingApiSession(req, res, config, state);
|
|
11398
|
+
if (!session) return;
|
|
11399
|
+
let payload;
|
|
11400
|
+
try {
|
|
11401
|
+
payload = await parseJsonBody(req);
|
|
11402
|
+
} catch {
|
|
11403
|
+
return writeJson(res, 400, { error: "invalid-json-body" });
|
|
11404
|
+
}
|
|
11405
|
+
const valid = new Set(["auto", "codex", "claude", "ask"]);
|
|
11406
|
+
const preference = valid.has(payload?.preference) ? payload.preference : "auto";
|
|
11407
|
+
if (state.a2aExecutorPreference !== preference) {
|
|
11408
|
+
state.a2aExecutorPreference = preference;
|
|
11409
|
+
await saveState(config.stateFile, state);
|
|
11410
|
+
}
|
|
11411
|
+
return writeJson(res, 200, { ok: true, preference });
|
|
11412
|
+
}
|
|
11413
|
+
|
|
11097
11414
|
if (url.pathname === "/api/moltbook/scout-status" && req.method === "GET") {
|
|
11098
11415
|
const session = requireApiSession(req, res, config, state);
|
|
11099
11416
|
if (!session) {
|
|
@@ -11146,9 +11463,347 @@ function createNativeApprovalServer({ config, runtime, state }) {
|
|
|
11146
11463
|
userId: config.a2aRelayUserId,
|
|
11147
11464
|
relayUrl: config.a2aRelayUrl,
|
|
11148
11465
|
apiKeyConfigured: Boolean(config.a2aApiKey),
|
|
11466
|
+
acceptPublicTasks: config.a2aAcceptPublicTasks === true,
|
|
11149
11467
|
});
|
|
11150
11468
|
}
|
|
11151
11469
|
|
|
11470
|
+
// Toggle acceptPublicTasks on the A2A relay.
|
|
11471
|
+
if (url.pathname === "/api/a2a/public-tasks" && req.method === "POST") {
|
|
11472
|
+
const session = requireApiSession(req, res, config, state);
|
|
11473
|
+
if (!session) return;
|
|
11474
|
+
const body = await parseJsonBody(req);
|
|
11475
|
+
const accept = body?.accept === true;
|
|
11476
|
+
config.a2aAcceptPublicTasks = accept;
|
|
11477
|
+
state.a2aAcceptPublicTasks = accept;
|
|
11478
|
+
try {
|
|
11479
|
+
await saveState(config.stateFile, state);
|
|
11480
|
+
} catch (error) {
|
|
11481
|
+
console.error(`[a2a-public-tasks-save] ${error.message}`);
|
|
11482
|
+
}
|
|
11483
|
+
// Re-register with relay to propagate the flag.
|
|
11484
|
+
if (config.a2aRelayUrl && config.a2aRelayUserId) {
|
|
11485
|
+
try {
|
|
11486
|
+
await updatePublicTasksFlag({ config, buildAgentCard, acceptPublicTasks: accept });
|
|
11487
|
+
console.log(`[a2a-relay] acceptPublicTasks updated to ${accept}`);
|
|
11488
|
+
} catch (error) {
|
|
11489
|
+
console.error(`[a2a-relay-public-update] ${error.message}`);
|
|
11490
|
+
}
|
|
11491
|
+
}
|
|
11492
|
+
return writeJson(res, 200, { ok: true, acceptPublicTasks: accept });
|
|
11493
|
+
}
|
|
11494
|
+
|
|
11495
|
+
// ─── Thread sharing ──────────────────────────────────────────────
|
|
11496
|
+
|
|
11497
|
+
function buildThreadShareContent(shareType, body) {
|
|
11498
|
+
if (shareType === "plan_review") {
|
|
11499
|
+
const sections = [];
|
|
11500
|
+
if (body.context) sections.push(`## Context\n${body.context}`);
|
|
11501
|
+
if (body.plan) sections.push(`## Plan\n${body.plan}`);
|
|
11502
|
+
if (Array.isArray(body.files) && body.files.length) {
|
|
11503
|
+
sections.push(`## Relevant files\n${body.files.map((f) => `- ${f}`).join("\n")}`);
|
|
11504
|
+
}
|
|
11505
|
+
sections.push(`## Instruction\n${body.instruction || "共有されたプランの内容を把握し、実現可能性や改善点を検討してください。"}`);
|
|
11506
|
+
return sections.join("\n\n");
|
|
11507
|
+
}
|
|
11508
|
+
if (shareType === "handoff") {
|
|
11509
|
+
const sections = [];
|
|
11510
|
+
if (body.summary) sections.push(`## Summary\n${body.summary}`);
|
|
11511
|
+
if (Array.isArray(body.completed) && body.completed.length) {
|
|
11512
|
+
sections.push(`## Completed\n${body.completed.map((t) => `- ${t}`).join("\n")}`);
|
|
11513
|
+
}
|
|
11514
|
+
if (Array.isArray(body.remaining) && body.remaining.length) {
|
|
11515
|
+
sections.push(`## Remaining\n${body.remaining.map((t) => `- ${t}`).join("\n")}`);
|
|
11516
|
+
}
|
|
11517
|
+
if (Array.isArray(body.decisions) && body.decisions.length) {
|
|
11518
|
+
sections.push(`## Key decisions\n${body.decisions.map((d) => `- ${d}`).join("\n")}`);
|
|
11519
|
+
}
|
|
11520
|
+
if (Array.isArray(body.modifiedFiles) && body.modifiedFiles.length) {
|
|
11521
|
+
sections.push(`## Modified files\n${body.modifiedFiles.map((f) => `- ${f}`).join("\n")}`);
|
|
11522
|
+
}
|
|
11523
|
+
sections.push(`## Instruction\n${body.instruction || "これまでの作業内容と経緯を把握した上で、次に何をすべきか検討してください。"}`);
|
|
11524
|
+
return sections.join("\n\n");
|
|
11525
|
+
}
|
|
11526
|
+
// Default: message type — use raw content.
|
|
11527
|
+
return cleanText(body?.content || "");
|
|
11528
|
+
}
|
|
11529
|
+
|
|
11530
|
+
// List known threads (active + registry) for the share target picker.
|
|
11531
|
+
if (url.pathname === "/api/threads/list" && req.method === "GET") {
|
|
11532
|
+
const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
|
|
11533
|
+
const hookAuth = config.sessionSecret && hookSecret === config.sessionSecret;
|
|
11534
|
+
if (!hookAuth) { const session = requireApiSession(req, res, config, state); if (!session) return; }
|
|
11535
|
+
const codexConnected = Boolean(runtime.ipcClient?.clientId);
|
|
11536
|
+
const activeIds = new Set();
|
|
11537
|
+
const threads = [];
|
|
11538
|
+
// Active Codex threads (from IPC).
|
|
11539
|
+
for (const [conversationId, threadState] of runtime.threadStates) {
|
|
11540
|
+
activeIds.add(conversationId);
|
|
11541
|
+
const label = getThreadName(
|
|
11542
|
+
runtime.sessionIndex,
|
|
11543
|
+
runtime.rolloutThreadLabels,
|
|
11544
|
+
conversationId,
|
|
11545
|
+
cleanText(threadState?.cwd || ""),
|
|
11546
|
+
""
|
|
11547
|
+
);
|
|
11548
|
+
threads.push({
|
|
11549
|
+
conversationId,
|
|
11550
|
+
label: label || conversationId.slice(0, 8),
|
|
11551
|
+
tool: "codex",
|
|
11552
|
+
cwd: cleanText(threadState?.cwd || ""),
|
|
11553
|
+
hasOwner: runtime.threadOwnerClientIds.has(conversationId),
|
|
11554
|
+
active: true,
|
|
11555
|
+
});
|
|
11556
|
+
}
|
|
11557
|
+
// Registry entries not already covered by active threads.
|
|
11558
|
+
for (const entry of runtime.threadRegistry.values()) {
|
|
11559
|
+
if (!activeIds.has(entry.id)) {
|
|
11560
|
+
threads.push({
|
|
11561
|
+
conversationId: entry.id,
|
|
11562
|
+
label: entry.label || entry.id.slice(0, 8),
|
|
11563
|
+
tool: entry.tool || "codex",
|
|
11564
|
+
cwd: entry.cwd || "",
|
|
11565
|
+
hasOwner: false,
|
|
11566
|
+
active: false,
|
|
11567
|
+
lastSeenAtMs: entry.lastSeenAtMs || 0,
|
|
11568
|
+
});
|
|
11569
|
+
}
|
|
11570
|
+
}
|
|
11571
|
+
return writeJson(res, 200, { threads, codexConnected });
|
|
11572
|
+
}
|
|
11573
|
+
|
|
11574
|
+
// Submit a thread share request (creates an approval item on the phone).
|
|
11575
|
+
if (url.pathname === "/api/threads/share" && req.method === "POST") {
|
|
11576
|
+
const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
|
|
11577
|
+
const hookAuth = config.sessionSecret && hookSecret === config.sessionSecret;
|
|
11578
|
+
if (!hookAuth) { const session = requireApiSession(req, res, config, state); if (!session) return; }
|
|
11579
|
+
const body = await parseJsonBody(req);
|
|
11580
|
+
const shareType = cleanText(body?.shareType || "message");
|
|
11581
|
+
const sourceTool = cleanText(body?.sourceTool || "");
|
|
11582
|
+
const sourceLabel = cleanText(body?.sourceLabel || "");
|
|
11583
|
+
const targetConversationId = cleanText(body?.targetConversationId || "");
|
|
11584
|
+
let targetTool = cleanText(body?.targetTool || "");
|
|
11585
|
+
let targetCwd = cleanText(body?.targetCwd || "");
|
|
11586
|
+
const contextFiles = Array.isArray(body?.contextFiles)
|
|
11587
|
+
? body.contextFiles.map((f) => cleanText(String(f))).filter(Boolean)
|
|
11588
|
+
: [];
|
|
11589
|
+
|
|
11590
|
+
// Auto-resolve targetTool and targetCwd from registry if not provided.
|
|
11591
|
+
if (targetConversationId) {
|
|
11592
|
+
const regEntry = runtime.threadRegistry.get(targetConversationId);
|
|
11593
|
+
if (regEntry) {
|
|
11594
|
+
if (!targetTool) targetTool = regEntry.tool || "";
|
|
11595
|
+
if (!targetCwd) targetCwd = regEntry.cwd || "";
|
|
11596
|
+
}
|
|
11597
|
+
}
|
|
11598
|
+
|
|
11599
|
+
// Build content from structured fields depending on shareType.
|
|
11600
|
+
const content = buildThreadShareContent(shareType, body);
|
|
11601
|
+
if (!content && contextFiles.length === 0) {
|
|
11602
|
+
return writeJson(res, 400, { error: "missing-content" });
|
|
11603
|
+
}
|
|
11604
|
+
if (!targetConversationId && !targetTool) {
|
|
11605
|
+
return writeJson(res, 400, { error: "missing-target" });
|
|
11606
|
+
}
|
|
11607
|
+
const shareId = crypto.randomUUID();
|
|
11608
|
+
const token = historyToken(`thread_share:${shareId}`);
|
|
11609
|
+
const targetLabel = targetConversationId
|
|
11610
|
+
? (getThreadName(runtime.sessionIndex, runtime.rolloutThreadLabels, targetConversationId, runtime.threadStates.get(targetConversationId)?.cwd || "", "") || runtime.threadRegistry.get(targetConversationId)?.label || "")
|
|
11611
|
+
: targetTool;
|
|
11612
|
+
const share = {
|
|
11613
|
+
token,
|
|
11614
|
+
shareId,
|
|
11615
|
+
shareType,
|
|
11616
|
+
content: content || "",
|
|
11617
|
+
contextFiles,
|
|
11618
|
+
sourceTool,
|
|
11619
|
+
sourceLabel,
|
|
11620
|
+
targetConversationId,
|
|
11621
|
+
targetTool: targetTool || "codex",
|
|
11622
|
+
targetCwd,
|
|
11623
|
+
targetLabel,
|
|
11624
|
+
createdAtMs: Date.now(),
|
|
11625
|
+
decision: null,
|
|
11626
|
+
decisionWaiters: [],
|
|
11627
|
+
};
|
|
11628
|
+
runtime.threadSharesByToken.set(token, share);
|
|
11629
|
+
try {
|
|
11630
|
+
const title = sourceLabel
|
|
11631
|
+
? `${sourceLabel} → ${targetLabel || targetTool}`
|
|
11632
|
+
: `→ ${targetLabel || targetTool}`;
|
|
11633
|
+
recordTimelineEntry({
|
|
11634
|
+
config, runtime, state,
|
|
11635
|
+
entry: {
|
|
11636
|
+
stableId: `thread_share:${shareId}`,
|
|
11637
|
+
token,
|
|
11638
|
+
kind: "thread_share",
|
|
11639
|
+
threadId: "thread_share",
|
|
11640
|
+
threadLabel: "Thread Share",
|
|
11641
|
+
title,
|
|
11642
|
+
summary: String(content).slice(0, 160),
|
|
11643
|
+
messageText: content,
|
|
11644
|
+
createdAtMs: share.createdAtMs,
|
|
11645
|
+
readOnly: false,
|
|
11646
|
+
provider: "viveworker",
|
|
11647
|
+
},
|
|
11648
|
+
});
|
|
11649
|
+
await saveState(config.stateFile, state);
|
|
11650
|
+
} catch (error) {
|
|
11651
|
+
console.error(`[thread-share-timeline] ${error.message}`);
|
|
11652
|
+
}
|
|
11653
|
+
try {
|
|
11654
|
+
await deliverWebPushItem({
|
|
11655
|
+
config, state,
|
|
11656
|
+
kind: "thread_share",
|
|
11657
|
+
token,
|
|
11658
|
+
stableId: `thread_share:${shareId}`,
|
|
11659
|
+
title: `Thread Share: ${sourceLabel || sourceTool || "agent"} → ${targetLabel || targetTool}`,
|
|
11660
|
+
body: String(content).slice(0, 160),
|
|
11661
|
+
});
|
|
11662
|
+
} catch (error) {
|
|
11663
|
+
console.error(`[thread-share-push] ${error.message}`);
|
|
11664
|
+
}
|
|
11665
|
+
return writeJson(res, 200, { ok: true, token, shareId });
|
|
11666
|
+
}
|
|
11667
|
+
|
|
11668
|
+
// Decision endpoint for thread share (approve / deny from phone).
|
|
11669
|
+
const threadShareDecisionMatch = url.pathname.match(/^\/api\/threads\/share\/([^/]+)\/decision$/);
|
|
11670
|
+
if (threadShareDecisionMatch && req.method === "POST") {
|
|
11671
|
+
const session = requireApiSession(req, res, config, state);
|
|
11672
|
+
if (!session) return;
|
|
11673
|
+
const token = cleanText(threadShareDecisionMatch[1]);
|
|
11674
|
+
const share = runtime.threadSharesByToken.get(token);
|
|
11675
|
+
if (!share) {
|
|
11676
|
+
return writeJson(res, 404, { error: "not-found" });
|
|
11677
|
+
}
|
|
11678
|
+
if (share.decision) {
|
|
11679
|
+
return writeJson(res, 200, { ok: true, decision: share.decision });
|
|
11680
|
+
}
|
|
11681
|
+
const body = await parseJsonBody(req);
|
|
11682
|
+
const decision = cleanText(body?.decision || "");
|
|
11683
|
+
const editedContent = typeof body?.editedContent === "string" ? body.editedContent : null;
|
|
11684
|
+
if (decision !== "approve" && decision !== "deny") {
|
|
11685
|
+
return writeJson(res, 400, { error: "invalid-decision" });
|
|
11686
|
+
}
|
|
11687
|
+
share.decision = decision;
|
|
11688
|
+
if (editedContent !== null) {
|
|
11689
|
+
share.content = editedContent;
|
|
11690
|
+
}
|
|
11691
|
+
|
|
11692
|
+
// Deliver the shared content to the target.
|
|
11693
|
+
let deliveryStatus = "delivered";
|
|
11694
|
+
if (decision === "approve") {
|
|
11695
|
+
const prefix = share.sourceLabel ? `[Shared from ${share.sourceLabel}]\n\n` : "[Shared from another thread]\n\n";
|
|
11696
|
+
// Append context file references if present.
|
|
11697
|
+
if (share.contextFiles && share.contextFiles.length > 0) {
|
|
11698
|
+
const fileList = share.contextFiles.map((f) => `- ${f}`).join("\n");
|
|
11699
|
+
share.content = (share.content ? share.content + "\n\n" : "")
|
|
11700
|
+
+ `## Context files\n\nRead the following files for full conversation context:\n${fileList}`;
|
|
11701
|
+
}
|
|
11702
|
+
let injectedViaIpc = false;
|
|
11703
|
+
|
|
11704
|
+
// Try Codex IPC injection if targeting a specific thread.
|
|
11705
|
+
if (share.targetConversationId && runtime.ipcClient?.clientId) {
|
|
11706
|
+
const ownerClientId = runtime.threadOwnerClientIds.get(share.targetConversationId) ?? null;
|
|
11707
|
+
const turnStartParams = { input: buildTurnInput(prefix + share.content) };
|
|
11708
|
+
// Try direct first, then fall back to thread-follower.
|
|
11709
|
+
for (const transport of ["direct", "follower"]) {
|
|
11710
|
+
try {
|
|
11711
|
+
if (transport === "direct" && ownerClientId) {
|
|
11712
|
+
await runtime.ipcClient.startTurnDirect(share.targetConversationId, turnStartParams, ownerClientId);
|
|
11713
|
+
} else {
|
|
11714
|
+
await runtime.ipcClient.startTurn(share.targetConversationId, turnStartParams, ownerClientId);
|
|
11715
|
+
}
|
|
11716
|
+
injectedViaIpc = true;
|
|
11717
|
+
console.log(`[thread-share] Delivered to Codex thread ${share.targetConversationId} via ${transport}`);
|
|
11718
|
+
break;
|
|
11719
|
+
} catch (error) {
|
|
11720
|
+
console.error(`[thread-share-ipc-${transport}] ${error.message}`);
|
|
11721
|
+
}
|
|
11722
|
+
}
|
|
11723
|
+
}
|
|
11724
|
+
|
|
11725
|
+
// Write to file inbox (always for claude-code targets, fallback for failed Codex delivery).
|
|
11726
|
+
if (!injectedViaIpc) {
|
|
11727
|
+
try {
|
|
11728
|
+
const inboxDir = path.join(os.homedir(), ".viveworker", "thread-inbox");
|
|
11729
|
+
await fs.mkdir(inboxDir, { recursive: true });
|
|
11730
|
+
const inboxFile = path.join(inboxDir, `${share.shareId}.json`);
|
|
11731
|
+
await fs.writeFile(inboxFile, JSON.stringify({
|
|
11732
|
+
shareId: share.shareId,
|
|
11733
|
+
content: share.content,
|
|
11734
|
+
contextFiles: share.contextFiles || [],
|
|
11735
|
+
targetCwd: share.targetCwd || "",
|
|
11736
|
+
sourceTool: share.sourceTool,
|
|
11737
|
+
sourceLabel: share.sourceLabel,
|
|
11738
|
+
targetTool: share.targetTool,
|
|
11739
|
+
createdAtMs: share.createdAtMs,
|
|
11740
|
+
deliveredAtMs: Date.now(),
|
|
11741
|
+
}, null, 2), "utf8");
|
|
11742
|
+
console.log(`[thread-share] Written to inbox: ${inboxFile}`);
|
|
11743
|
+
} catch (error) {
|
|
11744
|
+
console.error(`[thread-share-inbox-write] ${error.message}`);
|
|
11745
|
+
}
|
|
11746
|
+
|
|
11747
|
+
// If the user intended Codex but it wasn't reachable, notify.
|
|
11748
|
+
if (share.targetConversationId && share.targetTool !== "claude-code") {
|
|
11749
|
+
deliveryStatus = "inbox_fallback";
|
|
11750
|
+
try {
|
|
11751
|
+
await deliverWebPushItem({
|
|
11752
|
+
config, state,
|
|
11753
|
+
kind: "thread_share",
|
|
11754
|
+
token: share.token,
|
|
11755
|
+
stableId: `thread_share_fallback:${share.shareId}`,
|
|
11756
|
+
title: "Thread Share: target unreachable",
|
|
11757
|
+
body: `Codex thread "${share.targetLabel || share.targetConversationId.slice(0, 8)}" is not connected. Content saved to inbox.`,
|
|
11758
|
+
});
|
|
11759
|
+
} catch (error) {
|
|
11760
|
+
console.error(`[thread-share-fallback-push] ${error.message}`);
|
|
11761
|
+
}
|
|
11762
|
+
}
|
|
11763
|
+
}
|
|
11764
|
+
}
|
|
11765
|
+
|
|
11766
|
+
// Wake any long-poll waiters.
|
|
11767
|
+
for (const waiter of share.decisionWaiters) {
|
|
11768
|
+
waiter({ decision: share.decision, content: share.content });
|
|
11769
|
+
}
|
|
11770
|
+
share.decisionWaiters = [];
|
|
11771
|
+
return writeJson(res, 200, { ok: true, decision: share.decision });
|
|
11772
|
+
}
|
|
11773
|
+
|
|
11774
|
+
// Long-poll: wait for a thread share decision (used by the CLI or hook).
|
|
11775
|
+
const threadSharePollMatch = url.pathname.match(/^\/api\/threads\/share\/([^/]+)\/poll$/);
|
|
11776
|
+
if (threadSharePollMatch && req.method === "GET") {
|
|
11777
|
+
const session = requireApiSession(req, res, config, state);
|
|
11778
|
+
if (!session) return;
|
|
11779
|
+
const token = cleanText(threadSharePollMatch[1]);
|
|
11780
|
+
const share = runtime.threadSharesByToken.get(token);
|
|
11781
|
+
if (!share) {
|
|
11782
|
+
return writeJson(res, 404, { error: "not-found" });
|
|
11783
|
+
}
|
|
11784
|
+
if (share.decision) {
|
|
11785
|
+
return writeJson(res, 200, { decision: share.decision, content: share.content });
|
|
11786
|
+
}
|
|
11787
|
+
const timeoutMs = Math.min(Number(url.searchParams.get("timeout")) || 120_000, 300_000);
|
|
11788
|
+
await new Promise((resolve) => {
|
|
11789
|
+
const timer = setTimeout(() => {
|
|
11790
|
+
const idx = share.decisionWaiters.indexOf(done);
|
|
11791
|
+
if (idx >= 0) share.decisionWaiters.splice(idx, 1);
|
|
11792
|
+
resolve();
|
|
11793
|
+
}, timeoutMs);
|
|
11794
|
+
timer.unref?.();
|
|
11795
|
+
function done(result) {
|
|
11796
|
+
clearTimeout(timer);
|
|
11797
|
+
resolve(result);
|
|
11798
|
+
}
|
|
11799
|
+
share.decisionWaiters.push(done);
|
|
11800
|
+
});
|
|
11801
|
+
if (share.decision) {
|
|
11802
|
+
return writeJson(res, 200, { decision: share.decision, content: share.content });
|
|
11803
|
+
}
|
|
11804
|
+
return writeJson(res, 200, { decision: null, timeout: true });
|
|
11805
|
+
}
|
|
11806
|
+
|
|
11152
11807
|
// Activity summary for compose (original post) drafting.
|
|
11153
11808
|
// Supports ?slot=morning|noon|evening and ?date=YYYY-MM-DD for
|
|
11154
11809
|
// time-slot-based compose. Defaults to full-day today.
|
|
@@ -11432,6 +12087,27 @@ function createNativeApprovalServer({ config, runtime, state }) {
|
|
|
11432
12087
|
return writeJson(res, 200, {});
|
|
11433
12088
|
}
|
|
11434
12089
|
|
|
12090
|
+
// Register Claude Code thread in persistent registry on every event.
|
|
12091
|
+
{
|
|
12092
|
+
const regThreadId = String(body.threadId || body.sessionId || "");
|
|
12093
|
+
const regCwd = String(body.cwd || "");
|
|
12094
|
+
if (regThreadId) {
|
|
12095
|
+
const regLabel = runtime.claudeSessionTitles.get(regThreadId)
|
|
12096
|
+
|| (regCwd ? path.basename(regCwd) : "")
|
|
12097
|
+
|| regThreadId.slice(0, 40);
|
|
12098
|
+
if (registerThread(runtime, {
|
|
12099
|
+
id: regThreadId,
|
|
12100
|
+
label: regLabel,
|
|
12101
|
+
tool: "claude-code",
|
|
12102
|
+
cwd: regCwd,
|
|
12103
|
+
lastSeenAtMs: Date.now(),
|
|
12104
|
+
active: eventType !== "Stop" && eventType !== "SessionEnd",
|
|
12105
|
+
})) {
|
|
12106
|
+
saveThreadRegistry(config.threadRegistryFile, runtime.threadRegistry).catch(() => {});
|
|
12107
|
+
}
|
|
12108
|
+
}
|
|
12109
|
+
}
|
|
12110
|
+
|
|
11435
12111
|
if (eventType !== "approval_request") {
|
|
11436
12112
|
// Non-approval events (Notification, Stop, etc.) — acknowledge immediately
|
|
11437
12113
|
return writeJson(res, 200, {});
|
|
@@ -11670,10 +12346,12 @@ function createNativeApprovalServer({ config, runtime, state }) {
|
|
|
11670
12346
|
// ---------------------------------------------------------------
|
|
11671
12347
|
// Moltbook scout draft channel
|
|
11672
12348
|
//
|
|
11673
|
-
// The
|
|
11674
|
-
//
|
|
11675
|
-
//
|
|
11676
|
-
// phone
|
|
12349
|
+
// The CLI submits a draft via POST /api/providers/moltbook/draft
|
|
12350
|
+
// and exits immediately (fire-and-forget). The draft is persisted
|
|
12351
|
+
// to ~/.viveworker/moltbook-drafts/ and survives bridge restarts.
|
|
12352
|
+
// When the phone approves via POST /api/items/moltbook-draft/:token/decision,
|
|
12353
|
+
// the bridge posts to the Moltbook API and solves the verification
|
|
12354
|
+
// puzzle inline.
|
|
11677
12355
|
// ---------------------------------------------------------------
|
|
11678
12356
|
|
|
11679
12357
|
if (url.pathname === "/api/providers/moltbook/draft" && req.method === "POST") {
|
|
@@ -11706,6 +12384,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
|
|
|
11706
12384
|
const postBody = typeof body.postBody === "string" ? body.postBody : "";
|
|
11707
12385
|
const intent = typeof body.intent === "string" ? body.intent : "";
|
|
11708
12386
|
const slot = cleanText(body.slot || "");
|
|
12387
|
+
const ttlMs = Math.max(60000, Math.min(Number(body.ttlMs) || 86400000, 86400000));
|
|
11709
12388
|
const draft = {
|
|
11710
12389
|
token,
|
|
11711
12390
|
sourceId,
|
|
@@ -11721,11 +12400,13 @@ function createNativeApprovalServer({ config, runtime, state }) {
|
|
|
11721
12400
|
intent,
|
|
11722
12401
|
slot,
|
|
11723
12402
|
contextSummary,
|
|
12403
|
+
ttlMs,
|
|
11724
12404
|
createdAtMs: Date.now(),
|
|
11725
12405
|
decisionWaiters: [],
|
|
11726
12406
|
decision: null,
|
|
11727
12407
|
};
|
|
11728
12408
|
runtime.moltbookDraftsByToken.set(token, draft);
|
|
12409
|
+
await writeDraft(draft).catch((e) => console.error(`[moltbook-draft-persist] ${e.message}`));
|
|
11729
12410
|
try {
|
|
11730
12411
|
recordTimelineEntry({
|
|
11731
12412
|
config,
|
|
@@ -11736,7 +12417,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
|
|
|
11736
12417
|
token,
|
|
11737
12418
|
kind: "moltbook_draft",
|
|
11738
12419
|
threadId: "moltbook",
|
|
11739
|
-
threadLabel: "Moltbook",
|
|
12420
|
+
threadLabel: postTitle || "Moltbook",
|
|
11740
12421
|
title: draftType === "original_post"
|
|
11741
12422
|
? (postTitle || "Moltbook new post")
|
|
11742
12423
|
: (postTitle ? `draft → ${postTitle}` : "Moltbook draft"),
|
|
@@ -11854,9 +12535,33 @@ function createNativeApprovalServer({ config, runtime, state }) {
|
|
|
11854
12535
|
// ignore
|
|
11855
12536
|
}
|
|
11856
12537
|
}
|
|
11857
|
-
//
|
|
11858
|
-
|
|
11859
|
-
|
|
12538
|
+
// Persist decision to disk.
|
|
12539
|
+
await writeDraft(draft).catch((e) => console.error(`[moltbook-draft-persist-decision] ${e.message}`));
|
|
12540
|
+
|
|
12541
|
+
if (action === "approve") {
|
|
12542
|
+
// Execute posting asynchronously — fire-and-forget from the HTTP handler.
|
|
12543
|
+
(async () => {
|
|
12544
|
+
try {
|
|
12545
|
+
await executeMoltbookDraftPost(draft, config, runtime, state);
|
|
12546
|
+
} catch (postError) {
|
|
12547
|
+
console.error(`[moltbook-draft-post-error] ${postError.message}`);
|
|
12548
|
+
try {
|
|
12549
|
+
await deliverWebPushItem({
|
|
12550
|
+
config, state, kind: "moltbook_draft", token: draft.token,
|
|
12551
|
+
stableId: `moltbook_draft_failed:${draft.sourceId}`,
|
|
12552
|
+
title: "Draft post failed",
|
|
12553
|
+
body: String(postError.message || "").slice(0, 160),
|
|
12554
|
+
});
|
|
12555
|
+
} catch { /* ignore push error */ }
|
|
12556
|
+
}
|
|
12557
|
+
await deleteDraft(token).catch(() => {});
|
|
12558
|
+
setTimeout(() => runtime.moltbookDraftsByToken.delete(token), 120_000).unref?.();
|
|
12559
|
+
})();
|
|
12560
|
+
} else {
|
|
12561
|
+
// Deny — clean up.
|
|
12562
|
+
await deleteDraft(token).catch(() => {});
|
|
12563
|
+
setTimeout(() => runtime.moltbookDraftsByToken.delete(token), 120_000).unref?.();
|
|
12564
|
+
}
|
|
11860
12565
|
return writeJson(res, 200, { ok: true, action });
|
|
11861
12566
|
}
|
|
11862
12567
|
|
|
@@ -11922,15 +12627,19 @@ function createNativeApprovalServer({ config, runtime, state }) {
|
|
|
11922
12627
|
return writeJson(res, 200, detail);
|
|
11923
12628
|
}
|
|
11924
12629
|
|
|
11925
|
-
const apiCompletionReplyMatch = url.pathname.match(/^\/api\/items\/completion\/([^/]+)\/reply$/u);
|
|
12630
|
+
const apiCompletionReplyMatch = url.pathname.match(/^\/api\/items\/(completion|assistant_final)\/([^/]+)\/reply$/u);
|
|
11926
12631
|
if (apiCompletionReplyMatch && req.method === "POST") {
|
|
11927
12632
|
const session = requireMutatingApiSession(req, res, config, state);
|
|
11928
12633
|
if (!session) {
|
|
11929
12634
|
return;
|
|
11930
12635
|
}
|
|
11931
12636
|
|
|
11932
|
-
const
|
|
11933
|
-
const
|
|
12637
|
+
const replyKind = apiCompletionReplyMatch[1];
|
|
12638
|
+
const token = decodeURIComponent(apiCompletionReplyMatch[2]);
|
|
12639
|
+
// Try history items first, fall back to timeline entries for assistant_final
|
|
12640
|
+
// (history items may be evicted by maxHistoryItems limit)
|
|
12641
|
+
const completionItem = historyItemByToken(runtime, replyKind, token)
|
|
12642
|
+
|| (replyKind === "assistant_final" ? timelineEntryByToken(runtime, token, replyKind) : null);
|
|
11934
12643
|
if (!completionItem) {
|
|
11935
12644
|
return writeJson(res, 404, { error: "item-not-found" });
|
|
11936
12645
|
}
|
|
@@ -13800,6 +14509,105 @@ function readMoltbookEnvKey() {
|
|
|
13800
14509
|
return "";
|
|
13801
14510
|
}
|
|
13802
14511
|
|
|
14512
|
+
// Post an approved Moltbook draft (reply or original post), solve the
|
|
14513
|
+
// verification puzzle, and update scout state. Called asynchronously from
|
|
14514
|
+
// the decision handler.
|
|
14515
|
+
async function executeMoltbookDraftPost(draft, config, runtime, state) {
|
|
14516
|
+
if (!config.moltbookApiKey) throw new Error("MOLTBOOK_API_KEY not configured");
|
|
14517
|
+
const mb = createMoltbookClient(config.moltbookApiKey);
|
|
14518
|
+
|
|
14519
|
+
const finalText = draft.decision.text || draft.draftText;
|
|
14520
|
+
const finalTitle = draft.decision.title || draft.postTitle;
|
|
14521
|
+
|
|
14522
|
+
let verification;
|
|
14523
|
+
|
|
14524
|
+
if (draft.draftType === "original_post") {
|
|
14525
|
+
const result = await mb("/posts", {
|
|
14526
|
+
method: "POST",
|
|
14527
|
+
body: JSON.stringify({
|
|
14528
|
+
submolt_name: draft.submoltName,
|
|
14529
|
+
submolt: draft.submoltName,
|
|
14530
|
+
title: finalTitle,
|
|
14531
|
+
content: finalText,
|
|
14532
|
+
}),
|
|
14533
|
+
});
|
|
14534
|
+
const post = result?.post || null;
|
|
14535
|
+
verification = post?.verification || null;
|
|
14536
|
+
console.log(`[moltbook-draft-post] Posted original post (id=${post?.id})`);
|
|
14537
|
+
|
|
14538
|
+
const scoutState = rollScoutDayIfNeeded(await readScoutState());
|
|
14539
|
+
recordComposeAttempt(scoutState, finalTitle, post?.id);
|
|
14540
|
+
await writeScoutState(scoutState);
|
|
14541
|
+
} else {
|
|
14542
|
+
const result = await mb(`/posts/${draft.postId}/comments`, {
|
|
14543
|
+
method: "POST",
|
|
14544
|
+
body: JSON.stringify({
|
|
14545
|
+
content: finalText,
|
|
14546
|
+
...(draft.parentCommentId ? { parent_id: draft.parentCommentId } : {}),
|
|
14547
|
+
}),
|
|
14548
|
+
});
|
|
14549
|
+
const comment = result?.comment || null;
|
|
14550
|
+
verification = comment?.verification || null;
|
|
14551
|
+
console.log(`[moltbook-draft-post] Posted reply (commentId=${comment?.id}) to post ${draft.postId}`);
|
|
14552
|
+
|
|
14553
|
+
const scoutState = rollScoutDayIfNeeded(await readScoutState());
|
|
14554
|
+
scoutState.sentToday += 1;
|
|
14555
|
+
markPostSeen(scoutState, draft.postId, "published");
|
|
14556
|
+
await writeScoutState(scoutState);
|
|
14557
|
+
}
|
|
14558
|
+
|
|
14559
|
+
// Solve verification puzzle if present.
|
|
14560
|
+
if (verification) {
|
|
14561
|
+
let answer = solveVerificationPuzzle(verification.challenge_text);
|
|
14562
|
+
const source = answer != null ? "solver" : null;
|
|
14563
|
+
if (answer == null) {
|
|
14564
|
+
answer = await solvePuzzleWithLLM(verification.challenge_text);
|
|
14565
|
+
}
|
|
14566
|
+
if (answer != null) {
|
|
14567
|
+
try {
|
|
14568
|
+
await mb("/verify", {
|
|
14569
|
+
method: "POST",
|
|
14570
|
+
body: JSON.stringify({ verification_code: verification.verification_code, answer }),
|
|
14571
|
+
});
|
|
14572
|
+
console.log(`[moltbook-draft-verify] Verified with answer ${answer}`);
|
|
14573
|
+
} catch (verifyError) {
|
|
14574
|
+
// Wrong answer from solver — retry with LLM.
|
|
14575
|
+
if (/incorrect/i.test(verifyError.message) && source === "solver") {
|
|
14576
|
+
const llmAnswer = await solvePuzzleWithLLM(verification.challenge_text);
|
|
14577
|
+
if (llmAnswer && llmAnswer !== answer) {
|
|
14578
|
+
try {
|
|
14579
|
+
await mb("/verify", {
|
|
14580
|
+
method: "POST",
|
|
14581
|
+
body: JSON.stringify({ verification_code: verification.verification_code, answer: llmAnswer }),
|
|
14582
|
+
});
|
|
14583
|
+
console.log(`[moltbook-draft-verify] Verified with LLM retry answer ${llmAnswer}`);
|
|
14584
|
+
} catch (retryError) {
|
|
14585
|
+
console.error(`[moltbook-draft-verify] LLM retry failed: ${retryError.message}`);
|
|
14586
|
+
}
|
|
14587
|
+
}
|
|
14588
|
+
} else {
|
|
14589
|
+
console.error(`[moltbook-draft-verify] Failed: ${verifyError.message}`);
|
|
14590
|
+
}
|
|
14591
|
+
}
|
|
14592
|
+
} else {
|
|
14593
|
+
console.error(`[moltbook-draft-verify] Could not solve puzzle for draft ${draft.token}`);
|
|
14594
|
+
}
|
|
14595
|
+
}
|
|
14596
|
+
|
|
14597
|
+
// Push notification for successful post.
|
|
14598
|
+
try {
|
|
14599
|
+
const pushTitle = draft.draftType === "original_post" ? finalTitle : `Reply → ${draft.postTitle || "Moltbook"}`;
|
|
14600
|
+
await deliverWebPushItem({
|
|
14601
|
+
config, state, kind: "moltbook_draft", token: draft.token,
|
|
14602
|
+
stableId: `moltbook_draft_posted:${draft.sourceId}`,
|
|
14603
|
+
title: "Moltbook posted",
|
|
14604
|
+
body: truncate(singleLine(pushTitle), 160),
|
|
14605
|
+
});
|
|
14606
|
+
} catch { /* ignore push error */ }
|
|
14607
|
+
|
|
14608
|
+
console.log(`[moltbook-draft] Successfully posted draft ${draft.token} (type=${draft.draftType})`);
|
|
14609
|
+
}
|
|
14610
|
+
|
|
13803
14611
|
function buildConfig(cli) {
|
|
13804
14612
|
const codexHome = resolvePath(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"));
|
|
13805
14613
|
const stateFile = resolvePath(process.env.STATE_FILE || path.join(workspaceRoot, ".viveworker-state.json"));
|
|
@@ -13828,6 +14636,7 @@ function buildConfig(cli) {
|
|
|
13828
14636
|
historyFile: resolvePath(process.env.HISTORY_FILE || path.join(codexHome, "history.jsonl")),
|
|
13829
14637
|
codexLogsDbFile: resolvePath(process.env.CODEX_LOGS_DB_FILE || ""),
|
|
13830
14638
|
stateFile,
|
|
14639
|
+
threadRegistryFile: resolvePath(process.env.THREAD_REGISTRY_FILE || path.join(os.homedir(), ".viveworker", "thread-registry.json")),
|
|
13831
14640
|
replyUploadsDir: resolvePath(process.env.REPLY_UPLOADS_DIR || path.join(path.dirname(stateFile), "uploads")),
|
|
13832
14641
|
timelineAttachmentsDir: resolvePath(
|
|
13833
14642
|
process.env.TIMELINE_ATTACHMENTS_DIR || path.join(path.dirname(stateFile), "timeline-attachments")
|
|
@@ -14084,6 +14893,7 @@ async function loadState(stateFile) {
|
|
|
14084
14893
|
pairingConsumedAt: Number(parsed.pairingConsumedAt) || 0,
|
|
14085
14894
|
pairingConsumedCredential: cleanText(parsed.pairingConsumedCredential ?? ""),
|
|
14086
14895
|
claudeAwayMode: parsed.claudeAwayMode === true,
|
|
14896
|
+
a2aAcceptPublicTasks: parsed.a2aAcceptPublicTasks === true,
|
|
14087
14897
|
};
|
|
14088
14898
|
} catch {
|
|
14089
14899
|
return {
|
|
@@ -14140,6 +14950,59 @@ async function saveState(stateFile, state) {
|
|
|
14140
14950
|
await fs.writeFile(stateFile, `${output}\n`, "utf8");
|
|
14141
14951
|
}
|
|
14142
14952
|
|
|
14953
|
+
// ---------------------------------------------------------------------------
|
|
14954
|
+
// Thread registry — persistent record of all known threads (survives restarts)
|
|
14955
|
+
// ---------------------------------------------------------------------------
|
|
14956
|
+
|
|
14957
|
+
async function loadThreadRegistry(filePath) {
|
|
14958
|
+
const registry = new Map();
|
|
14959
|
+
try {
|
|
14960
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
14961
|
+
const arr = JSON.parse(raw);
|
|
14962
|
+
if (Array.isArray(arr)) {
|
|
14963
|
+
for (const entry of arr) {
|
|
14964
|
+
if (entry && entry.id) {
|
|
14965
|
+
registry.set(entry.id, entry);
|
|
14966
|
+
}
|
|
14967
|
+
}
|
|
14968
|
+
}
|
|
14969
|
+
} catch {
|
|
14970
|
+
// Missing or corrupt file — start with empty registry.
|
|
14971
|
+
}
|
|
14972
|
+
return registry;
|
|
14973
|
+
}
|
|
14974
|
+
|
|
14975
|
+
async function saveThreadRegistry(filePath, registry) {
|
|
14976
|
+
const arr = [...registry.values()].sort((a, b) => (b.lastSeenAtMs || 0) - (a.lastSeenAtMs || 0));
|
|
14977
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
14978
|
+
await fs.writeFile(filePath, JSON.stringify(arr, null, 2) + "\n", "utf8");
|
|
14979
|
+
}
|
|
14980
|
+
|
|
14981
|
+
/** Upsert a thread in the registry. Returns true if something changed. */
|
|
14982
|
+
function registerThread(runtime, entry) {
|
|
14983
|
+
const existing = runtime.threadRegistry.get(entry.id);
|
|
14984
|
+
const merged = {
|
|
14985
|
+
id: entry.id,
|
|
14986
|
+
label: entry.label || existing?.label || "",
|
|
14987
|
+
tool: entry.tool || existing?.tool || "codex",
|
|
14988
|
+
cwd: entry.cwd || existing?.cwd || "",
|
|
14989
|
+
lastSeenAtMs: Math.max(entry.lastSeenAtMs || Date.now(), existing?.lastSeenAtMs || 0),
|
|
14990
|
+
active: entry.active !== undefined ? entry.active : (existing?.active ?? true),
|
|
14991
|
+
};
|
|
14992
|
+
const changed = !existing
|
|
14993
|
+
|| existing.label !== merged.label
|
|
14994
|
+
|| existing.tool !== merged.tool
|
|
14995
|
+
|| existing.cwd !== merged.cwd
|
|
14996
|
+
|| existing.active !== merged.active
|
|
14997
|
+
|| existing.lastSeenAtMs !== merged.lastSeenAtMs;
|
|
14998
|
+
if (changed) {
|
|
14999
|
+
runtime.threadRegistry.set(entry.id, merged);
|
|
15000
|
+
}
|
|
15001
|
+
return changed;
|
|
15002
|
+
}
|
|
15003
|
+
|
|
15004
|
+
// ---------------------------------------------------------------------------
|
|
15005
|
+
|
|
14143
15006
|
async function loadSessionIndex(filePath) {
|
|
14144
15007
|
const result = new Map();
|
|
14145
15008
|
try {
|
|
@@ -14576,11 +15439,17 @@ function refreshResolvedThreadLabels({ config, runtime, state }) {
|
|
|
14576
15439
|
|
|
14577
15440
|
const nextHistoryItems = normalizeHistoryItems(
|
|
14578
15441
|
runtime.recentHistoryItems.map((item) => {
|
|
15442
|
+
// Moltbook / A2A / thread-share entries carry their own titles — skip relabel.
|
|
15443
|
+
const itemKind = item.kind || "";
|
|
15444
|
+
if (itemKind === "moltbook_reply" || itemKind === "moltbook_draft" || itemKind === "a2a_task" || itemKind === "a2a_task_result" || itemKind === "thread_share") {
|
|
15445
|
+
return item;
|
|
15446
|
+
}
|
|
14579
15447
|
const conversationId = extractConversationIdFromStableId(item.stableId);
|
|
14580
15448
|
if (!conversationId) {
|
|
14581
15449
|
return item;
|
|
14582
15450
|
}
|
|
14583
|
-
const
|
|
15451
|
+
const claudeTitle = runtime.claudeSessionTitles.get(conversationId) || "";
|
|
15452
|
+
const nativeThreadLabel = claudeTitle || getNativeThreadLabel({
|
|
14584
15453
|
runtime,
|
|
14585
15454
|
conversationId,
|
|
14586
15455
|
cwd: "",
|
|
@@ -14611,10 +15480,10 @@ function refreshResolvedThreadLabels({ config, runtime, state }) {
|
|
|
14611
15480
|
|
|
14612
15481
|
const nextTimelineEntries = normalizeTimelineEntries(
|
|
14613
15482
|
runtime.recentTimelineEntries.map((entry) => {
|
|
14614
|
-
// Moltbook entries carry their own
|
|
14615
|
-
//
|
|
14616
|
-
|
|
14617
|
-
if (
|
|
15483
|
+
// Moltbook / A2A / thread-share entries carry their own titles.
|
|
15484
|
+
// Skip the native-thread relabel pass so we don't overwrite them.
|
|
15485
|
+
const entryKind = entry.kind || "";
|
|
15486
|
+
if (entryKind === "moltbook_reply" || entryKind === "moltbook_draft" || entryKind === "a2a_task" || entryKind === "a2a_task_result" || entryKind === "thread_share") {
|
|
14618
15487
|
return entry;
|
|
14619
15488
|
}
|
|
14620
15489
|
const threadId = cleanText(entry.threadId || "");
|
|
@@ -15193,6 +16062,7 @@ async function main() {
|
|
|
15193
16062
|
|
|
15194
16063
|
// --- A2A Relay ---
|
|
15195
16064
|
if (config.a2aRelayUrl && config.a2aRelayUserId) {
|
|
16065
|
+
config.a2aAcceptPublicTasks = state.a2aAcceptPublicTasks === true;
|
|
15196
16066
|
const regResult = await registerWithRelay({ config, buildAgentCard });
|
|
15197
16067
|
if (regResult.ok) {
|
|
15198
16068
|
startRelayPolling({
|