viveworker 0.4.6 → 0.4.7
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/viveworker-bridge.mjs +459 -1
- package/scripts/viveworker-claude-hook.mjs +74 -1
- package/web/app.js +126 -3
- package/web/i18n.js +22 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "viveworker",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.7",
|
|
4
4
|
"description": "Local mobile companion for Codex Desktop and Claude Desktop — approvals, code review, Moltbook drafts, and A2A (Agent-to-Agent) task relay on your LAN.",
|
|
5
5
|
"author": "Yuta Hoshino <hoshino.lireneo@gmail.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,7 +28,7 @@ const sessionCookieName = "viveworker_session";
|
|
|
28
28
|
const deviceCookieName = "viveworker_device";
|
|
29
29
|
const historyKinds = new Set(["completion", "plan_ready", "approval", "plan", "choice", "info"]);
|
|
30
30
|
const timelineMessageKinds = new Set(["user_message", "assistant_commentary", "assistant_final"]);
|
|
31
|
-
const timelineKinds = new Set([...timelineMessageKinds, "approval", "plan", "choice", "completion", "plan_ready", "file_event", "moltbook_reply", "moltbook_draft"]);
|
|
31
|
+
const timelineKinds = new Set([...timelineMessageKinds, "approval", "plan", "choice", "completion", "plan_ready", "file_event", "moltbook_reply", "moltbook_draft", "thread_share"]);
|
|
32
32
|
const SQLITE_COMPLETION_BATCH_SIZE = 200;
|
|
33
33
|
const DEFAULT_DEVICE_TRUST_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
34
34
|
const MAX_PAIRED_DEVICES = 200;
|
|
@@ -85,6 +85,8 @@ const runtime = {
|
|
|
85
85
|
moltbookItemsByToken: new Map(),
|
|
86
86
|
moltbookDraftsByToken: new Map(),
|
|
87
87
|
a2aTasksByToken: new Map(),
|
|
88
|
+
threadSharesByToken: new Map(),
|
|
89
|
+
threadRegistry: new Map(),
|
|
88
90
|
planDetailsByToken: new Map(),
|
|
89
91
|
recentHistoryItems: [],
|
|
90
92
|
recentTimelineEntries: [],
|
|
@@ -94,6 +96,7 @@ const runtime = {
|
|
|
94
96
|
stopping: false,
|
|
95
97
|
};
|
|
96
98
|
const state = await loadState(config.stateFile);
|
|
99
|
+
runtime.threadRegistry = await loadThreadRegistry(config.threadRegistryFile);
|
|
97
100
|
await syncClaudeAwayModeSentinel(config, state.claudeAwayMode === true);
|
|
98
101
|
const migratedPairedDevicesStateChanged = migratePairedDevicesState({ config, state });
|
|
99
102
|
const restoredPendingPlanStateChanged = restorePendingPlanRequests({ config, runtime, state });
|
|
@@ -6970,6 +6973,20 @@ class NativeIpcClient {
|
|
|
6970
6973
|
|
|
6971
6974
|
this.runtime.threadStates.set(normalized.conversationId, nextState);
|
|
6972
6975
|
|
|
6976
|
+
// Register / update in the persistent thread registry.
|
|
6977
|
+
const regLabel = extractThreadLabelFromState(nextState)
|
|
6978
|
+
|| getThreadName(this.runtime.sessionIndex, this.runtime.rolloutThreadLabels, normalized.conversationId, nextState.cwd || "", "");
|
|
6979
|
+
if (registerThread(this.runtime, {
|
|
6980
|
+
id: normalized.conversationId,
|
|
6981
|
+
label: regLabel,
|
|
6982
|
+
tool: "codex",
|
|
6983
|
+
cwd: nextState.cwd || "",
|
|
6984
|
+
lastSeenAtMs: Date.now(),
|
|
6985
|
+
active: true,
|
|
6986
|
+
})) {
|
|
6987
|
+
saveThreadRegistry(this.config.threadRegistryFile, this.runtime.threadRegistry).catch(() => {});
|
|
6988
|
+
}
|
|
6989
|
+
|
|
6973
6990
|
await this.onThreadStateChanged({
|
|
6974
6991
|
conversationId: normalized.conversationId,
|
|
6975
6992
|
previousRequests,
|
|
@@ -9067,6 +9084,24 @@ function buildPendingInboxItems(runtime, state, config, locale) {
|
|
|
9067
9084
|
});
|
|
9068
9085
|
}
|
|
9069
9086
|
|
|
9087
|
+
for (const share of runtime.threadSharesByToken.values()) {
|
|
9088
|
+
if (share.decision) continue;
|
|
9089
|
+
const title = share.sourceLabel
|
|
9090
|
+
? `${share.sourceLabel} → ${share.targetLabel || share.targetTool}`
|
|
9091
|
+
: `→ ${share.targetLabel || share.targetTool}`;
|
|
9092
|
+
items.push({
|
|
9093
|
+
kind: "thread_share",
|
|
9094
|
+
token: share.token,
|
|
9095
|
+
threadId: "thread_share",
|
|
9096
|
+
threadLabel: "Thread Share",
|
|
9097
|
+
title,
|
|
9098
|
+
summary: String(share.content || "").slice(0, 160),
|
|
9099
|
+
primaryLabel: t(locale, "server.action.review"),
|
|
9100
|
+
createdAtMs: Number(share.createdAtMs) || now,
|
|
9101
|
+
provider: "viveworker",
|
|
9102
|
+
});
|
|
9103
|
+
}
|
|
9104
|
+
|
|
9070
9105
|
// Moltbook reply items intentionally do not appear in the unhandled list.
|
|
9071
9106
|
// Reply drafting is delegated to Codex/Claude Desktop via the
|
|
9072
9107
|
// `viveworker moltbook` CLI, so they live in the timeline only.
|
|
@@ -10611,6 +10646,42 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
|
|
|
10611
10646
|
};
|
|
10612
10647
|
}
|
|
10613
10648
|
|
|
10649
|
+
if (kind === "thread_share") {
|
|
10650
|
+
const share = runtime.threadSharesByToken.get(token);
|
|
10651
|
+
const entry = share
|
|
10652
|
+
? null
|
|
10653
|
+
: runtime.recentTimelineEntries.find((e) => e.kind === "thread_share" && e.token === token);
|
|
10654
|
+
const source = share || entry;
|
|
10655
|
+
if (!source) return null;
|
|
10656
|
+
const content = share ? share.content : entry.messageText || entry.summary || "";
|
|
10657
|
+
const messageHtml = escapeHtml(content)
|
|
10658
|
+
.split("\n")
|
|
10659
|
+
.map((line) => `<p>${line}</p>`)
|
|
10660
|
+
.join("");
|
|
10661
|
+
return {
|
|
10662
|
+
kind: "thread_share",
|
|
10663
|
+
token,
|
|
10664
|
+
threadId: "thread_share",
|
|
10665
|
+
threadLabel: "Thread Share",
|
|
10666
|
+
title: source.title || "Thread Share",
|
|
10667
|
+
summary: source.summary || cleanText(content).slice(0, 160),
|
|
10668
|
+
messageHtml,
|
|
10669
|
+
provider: "viveworker",
|
|
10670
|
+
shareContent: content,
|
|
10671
|
+
shareType: share?.shareType || entry?.shareType || "message",
|
|
10672
|
+
sourceTool: share?.sourceTool || entry?.sourceTool || "",
|
|
10673
|
+
sourceLabel: share?.sourceLabel || entry?.sourceLabel || "",
|
|
10674
|
+
targetTool: share?.targetTool || entry?.targetTool || "",
|
|
10675
|
+
targetLabel: share?.targetLabel || entry?.targetLabel || "",
|
|
10676
|
+
targetConversationId: share?.targetConversationId || entry?.targetConversationId || "",
|
|
10677
|
+
contextFiles: share?.contextFiles || entry?.contextFiles || [],
|
|
10678
|
+
createdAtMs: source.createdAtMs || Date.now(),
|
|
10679
|
+
readOnly: !share || Boolean(share?.decision),
|
|
10680
|
+
actions: [],
|
|
10681
|
+
threadShareEnabled: Boolean(share) && !share.decision,
|
|
10682
|
+
};
|
|
10683
|
+
}
|
|
10684
|
+
|
|
10614
10685
|
const historyItem = historyItemByToken(runtime, kind, token);
|
|
10615
10686
|
return historyItem ? buildHistoryDetail(historyItem, locale, runtime) : null;
|
|
10616
10687
|
}
|
|
@@ -11149,6 +11220,318 @@ function createNativeApprovalServer({ config, runtime, state }) {
|
|
|
11149
11220
|
});
|
|
11150
11221
|
}
|
|
11151
11222
|
|
|
11223
|
+
// ─── Thread sharing ──────────────────────────────────────────────
|
|
11224
|
+
|
|
11225
|
+
function buildThreadShareContent(shareType, body) {
|
|
11226
|
+
if (shareType === "plan_review") {
|
|
11227
|
+
const sections = [];
|
|
11228
|
+
if (body.context) sections.push(`## Context\n${body.context}`);
|
|
11229
|
+
if (body.plan) sections.push(`## Plan\n${body.plan}`);
|
|
11230
|
+
if (Array.isArray(body.files) && body.files.length) {
|
|
11231
|
+
sections.push(`## Relevant files\n${body.files.map((f) => `- ${f}`).join("\n")}`);
|
|
11232
|
+
}
|
|
11233
|
+
sections.push(`## Instruction\n${body.instruction || "共有されたプランの内容を把握し、実現可能性や改善点を検討してください。"}`);
|
|
11234
|
+
return sections.join("\n\n");
|
|
11235
|
+
}
|
|
11236
|
+
if (shareType === "handoff") {
|
|
11237
|
+
const sections = [];
|
|
11238
|
+
if (body.summary) sections.push(`## Summary\n${body.summary}`);
|
|
11239
|
+
if (Array.isArray(body.completed) && body.completed.length) {
|
|
11240
|
+
sections.push(`## Completed\n${body.completed.map((t) => `- ${t}`).join("\n")}`);
|
|
11241
|
+
}
|
|
11242
|
+
if (Array.isArray(body.remaining) && body.remaining.length) {
|
|
11243
|
+
sections.push(`## Remaining\n${body.remaining.map((t) => `- ${t}`).join("\n")}`);
|
|
11244
|
+
}
|
|
11245
|
+
if (Array.isArray(body.decisions) && body.decisions.length) {
|
|
11246
|
+
sections.push(`## Key decisions\n${body.decisions.map((d) => `- ${d}`).join("\n")}`);
|
|
11247
|
+
}
|
|
11248
|
+
if (Array.isArray(body.modifiedFiles) && body.modifiedFiles.length) {
|
|
11249
|
+
sections.push(`## Modified files\n${body.modifiedFiles.map((f) => `- ${f}`).join("\n")}`);
|
|
11250
|
+
}
|
|
11251
|
+
sections.push(`## Instruction\n${body.instruction || "これまでの作業内容と経緯を把握した上で、次に何をすべきか検討してください。"}`);
|
|
11252
|
+
return sections.join("\n\n");
|
|
11253
|
+
}
|
|
11254
|
+
// Default: message type — use raw content.
|
|
11255
|
+
return cleanText(body?.content || "");
|
|
11256
|
+
}
|
|
11257
|
+
|
|
11258
|
+
// List known threads (active + registry) for the share target picker.
|
|
11259
|
+
if (url.pathname === "/api/threads/list" && req.method === "GET") {
|
|
11260
|
+
const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
|
|
11261
|
+
const hookAuth = config.sessionSecret && hookSecret === config.sessionSecret;
|
|
11262
|
+
if (!hookAuth) { const session = requireApiSession(req, res, config, state); if (!session) return; }
|
|
11263
|
+
const codexConnected = Boolean(runtime.ipcClient?.clientId);
|
|
11264
|
+
const activeIds = new Set();
|
|
11265
|
+
const threads = [];
|
|
11266
|
+
// Active Codex threads (from IPC).
|
|
11267
|
+
for (const [conversationId, threadState] of runtime.threadStates) {
|
|
11268
|
+
activeIds.add(conversationId);
|
|
11269
|
+
const label = getThreadName(
|
|
11270
|
+
runtime.sessionIndex,
|
|
11271
|
+
runtime.rolloutThreadLabels,
|
|
11272
|
+
conversationId,
|
|
11273
|
+
cleanText(threadState?.cwd || ""),
|
|
11274
|
+
""
|
|
11275
|
+
);
|
|
11276
|
+
threads.push({
|
|
11277
|
+
conversationId,
|
|
11278
|
+
label: label || conversationId.slice(0, 8),
|
|
11279
|
+
tool: "codex",
|
|
11280
|
+
cwd: cleanText(threadState?.cwd || ""),
|
|
11281
|
+
hasOwner: runtime.threadOwnerClientIds.has(conversationId),
|
|
11282
|
+
active: true,
|
|
11283
|
+
});
|
|
11284
|
+
}
|
|
11285
|
+
// Registry entries not already covered by active threads.
|
|
11286
|
+
for (const entry of runtime.threadRegistry.values()) {
|
|
11287
|
+
if (!activeIds.has(entry.id)) {
|
|
11288
|
+
threads.push({
|
|
11289
|
+
conversationId: entry.id,
|
|
11290
|
+
label: entry.label || entry.id.slice(0, 8),
|
|
11291
|
+
tool: entry.tool || "codex",
|
|
11292
|
+
cwd: entry.cwd || "",
|
|
11293
|
+
hasOwner: false,
|
|
11294
|
+
active: false,
|
|
11295
|
+
lastSeenAtMs: entry.lastSeenAtMs || 0,
|
|
11296
|
+
});
|
|
11297
|
+
}
|
|
11298
|
+
}
|
|
11299
|
+
return writeJson(res, 200, { threads, codexConnected });
|
|
11300
|
+
}
|
|
11301
|
+
|
|
11302
|
+
// Submit a thread share request (creates an approval item on the phone).
|
|
11303
|
+
if (url.pathname === "/api/threads/share" && req.method === "POST") {
|
|
11304
|
+
const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
|
|
11305
|
+
const hookAuth = config.sessionSecret && hookSecret === config.sessionSecret;
|
|
11306
|
+
if (!hookAuth) { const session = requireApiSession(req, res, config, state); if (!session) return; }
|
|
11307
|
+
const body = await parseJsonBody(req);
|
|
11308
|
+
const shareType = cleanText(body?.shareType || "message");
|
|
11309
|
+
const sourceTool = cleanText(body?.sourceTool || "");
|
|
11310
|
+
const sourceLabel = cleanText(body?.sourceLabel || "");
|
|
11311
|
+
const targetConversationId = cleanText(body?.targetConversationId || "");
|
|
11312
|
+
let targetTool = cleanText(body?.targetTool || "");
|
|
11313
|
+
let targetCwd = cleanText(body?.targetCwd || "");
|
|
11314
|
+
const contextFiles = Array.isArray(body?.contextFiles)
|
|
11315
|
+
? body.contextFiles.map((f) => cleanText(String(f))).filter(Boolean)
|
|
11316
|
+
: [];
|
|
11317
|
+
|
|
11318
|
+
// Auto-resolve targetTool and targetCwd from registry if not provided.
|
|
11319
|
+
if (targetConversationId) {
|
|
11320
|
+
const regEntry = runtime.threadRegistry.get(targetConversationId);
|
|
11321
|
+
if (regEntry) {
|
|
11322
|
+
if (!targetTool) targetTool = regEntry.tool || "";
|
|
11323
|
+
if (!targetCwd) targetCwd = regEntry.cwd || "";
|
|
11324
|
+
}
|
|
11325
|
+
}
|
|
11326
|
+
|
|
11327
|
+
// Build content from structured fields depending on shareType.
|
|
11328
|
+
const content = buildThreadShareContent(shareType, body);
|
|
11329
|
+
if (!content && contextFiles.length === 0) {
|
|
11330
|
+
return writeJson(res, 400, { error: "missing-content" });
|
|
11331
|
+
}
|
|
11332
|
+
if (!targetConversationId && !targetTool) {
|
|
11333
|
+
return writeJson(res, 400, { error: "missing-target" });
|
|
11334
|
+
}
|
|
11335
|
+
const shareId = crypto.randomUUID();
|
|
11336
|
+
const token = historyToken(`thread_share:${shareId}`);
|
|
11337
|
+
const targetLabel = targetConversationId
|
|
11338
|
+
? (getThreadName(runtime.sessionIndex, runtime.rolloutThreadLabels, targetConversationId, runtime.threadStates.get(targetConversationId)?.cwd || "", "") || runtime.threadRegistry.get(targetConversationId)?.label || "")
|
|
11339
|
+
: targetTool;
|
|
11340
|
+
const share = {
|
|
11341
|
+
token,
|
|
11342
|
+
shareId,
|
|
11343
|
+
shareType,
|
|
11344
|
+
content: content || "",
|
|
11345
|
+
contextFiles,
|
|
11346
|
+
sourceTool,
|
|
11347
|
+
sourceLabel,
|
|
11348
|
+
targetConversationId,
|
|
11349
|
+
targetTool: targetTool || "codex",
|
|
11350
|
+
targetCwd,
|
|
11351
|
+
targetLabel,
|
|
11352
|
+
createdAtMs: Date.now(),
|
|
11353
|
+
decision: null,
|
|
11354
|
+
decisionWaiters: [],
|
|
11355
|
+
};
|
|
11356
|
+
runtime.threadSharesByToken.set(token, share);
|
|
11357
|
+
try {
|
|
11358
|
+
const title = sourceLabel
|
|
11359
|
+
? `${sourceLabel} → ${targetLabel || targetTool}`
|
|
11360
|
+
: `→ ${targetLabel || targetTool}`;
|
|
11361
|
+
recordTimelineEntry({
|
|
11362
|
+
config, runtime, state,
|
|
11363
|
+
entry: {
|
|
11364
|
+
stableId: `thread_share:${shareId}`,
|
|
11365
|
+
token,
|
|
11366
|
+
kind: "thread_share",
|
|
11367
|
+
threadId: "thread_share",
|
|
11368
|
+
threadLabel: "Thread Share",
|
|
11369
|
+
title,
|
|
11370
|
+
summary: String(content).slice(0, 160),
|
|
11371
|
+
messageText: content,
|
|
11372
|
+
createdAtMs: share.createdAtMs,
|
|
11373
|
+
readOnly: false,
|
|
11374
|
+
provider: "viveworker",
|
|
11375
|
+
},
|
|
11376
|
+
});
|
|
11377
|
+
await saveState(config.stateFile, state);
|
|
11378
|
+
} catch (error) {
|
|
11379
|
+
console.error(`[thread-share-timeline] ${error.message}`);
|
|
11380
|
+
}
|
|
11381
|
+
try {
|
|
11382
|
+
await deliverWebPushItem({
|
|
11383
|
+
config, state,
|
|
11384
|
+
kind: "thread_share",
|
|
11385
|
+
token,
|
|
11386
|
+
stableId: `thread_share:${shareId}`,
|
|
11387
|
+
title: `Thread Share: ${sourceLabel || sourceTool || "agent"} → ${targetLabel || targetTool}`,
|
|
11388
|
+
body: String(content).slice(0, 160),
|
|
11389
|
+
});
|
|
11390
|
+
} catch (error) {
|
|
11391
|
+
console.error(`[thread-share-push] ${error.message}`);
|
|
11392
|
+
}
|
|
11393
|
+
return writeJson(res, 200, { ok: true, token, shareId });
|
|
11394
|
+
}
|
|
11395
|
+
|
|
11396
|
+
// Decision endpoint for thread share (approve / deny from phone).
|
|
11397
|
+
const threadShareDecisionMatch = url.pathname.match(/^\/api\/threads\/share\/([^/]+)\/decision$/);
|
|
11398
|
+
if (threadShareDecisionMatch && req.method === "POST") {
|
|
11399
|
+
const session = requireApiSession(req, res, config, state);
|
|
11400
|
+
if (!session) return;
|
|
11401
|
+
const token = cleanText(threadShareDecisionMatch[1]);
|
|
11402
|
+
const share = runtime.threadSharesByToken.get(token);
|
|
11403
|
+
if (!share) {
|
|
11404
|
+
return writeJson(res, 404, { error: "not-found" });
|
|
11405
|
+
}
|
|
11406
|
+
if (share.decision) {
|
|
11407
|
+
return writeJson(res, 200, { ok: true, decision: share.decision });
|
|
11408
|
+
}
|
|
11409
|
+
const body = await parseJsonBody(req);
|
|
11410
|
+
const decision = cleanText(body?.decision || "");
|
|
11411
|
+
const editedContent = typeof body?.editedContent === "string" ? body.editedContent : null;
|
|
11412
|
+
if (decision !== "approve" && decision !== "deny") {
|
|
11413
|
+
return writeJson(res, 400, { error: "invalid-decision" });
|
|
11414
|
+
}
|
|
11415
|
+
share.decision = decision;
|
|
11416
|
+
if (editedContent !== null) {
|
|
11417
|
+
share.content = editedContent;
|
|
11418
|
+
}
|
|
11419
|
+
|
|
11420
|
+
// Deliver the shared content to the target.
|
|
11421
|
+
let deliveryStatus = "delivered";
|
|
11422
|
+
if (decision === "approve") {
|
|
11423
|
+
const prefix = share.sourceLabel ? `[Shared from ${share.sourceLabel}]\n\n` : "[Shared from another thread]\n\n";
|
|
11424
|
+
// Append context file references if present.
|
|
11425
|
+
if (share.contextFiles && share.contextFiles.length > 0) {
|
|
11426
|
+
const fileList = share.contextFiles.map((f) => `- ${f}`).join("\n");
|
|
11427
|
+
share.content = (share.content ? share.content + "\n\n" : "")
|
|
11428
|
+
+ `## Context files\n\nRead the following files for full conversation context:\n${fileList}`;
|
|
11429
|
+
}
|
|
11430
|
+
let injectedViaIpc = false;
|
|
11431
|
+
|
|
11432
|
+
// Try Codex IPC injection if targeting a specific thread.
|
|
11433
|
+
if (share.targetConversationId && runtime.ipcClient?.clientId) {
|
|
11434
|
+
const ownerClientId = runtime.threadOwnerClientIds.get(share.targetConversationId) ?? null;
|
|
11435
|
+
const turnStartParams = { input: buildTurnInput(prefix + share.content) };
|
|
11436
|
+
// Try direct first, then fall back to thread-follower.
|
|
11437
|
+
for (const transport of ["direct", "follower"]) {
|
|
11438
|
+
try {
|
|
11439
|
+
if (transport === "direct" && ownerClientId) {
|
|
11440
|
+
await runtime.ipcClient.startTurnDirect(share.targetConversationId, turnStartParams, ownerClientId);
|
|
11441
|
+
} else {
|
|
11442
|
+
await runtime.ipcClient.startTurn(share.targetConversationId, turnStartParams, ownerClientId);
|
|
11443
|
+
}
|
|
11444
|
+
injectedViaIpc = true;
|
|
11445
|
+
console.log(`[thread-share] Delivered to Codex thread ${share.targetConversationId} via ${transport}`);
|
|
11446
|
+
break;
|
|
11447
|
+
} catch (error) {
|
|
11448
|
+
console.error(`[thread-share-ipc-${transport}] ${error.message}`);
|
|
11449
|
+
}
|
|
11450
|
+
}
|
|
11451
|
+
}
|
|
11452
|
+
|
|
11453
|
+
// Write to file inbox (always for claude-code targets, fallback for failed Codex delivery).
|
|
11454
|
+
if (!injectedViaIpc) {
|
|
11455
|
+
try {
|
|
11456
|
+
const inboxDir = path.join(os.homedir(), ".viveworker", "thread-inbox");
|
|
11457
|
+
await fs.mkdir(inboxDir, { recursive: true });
|
|
11458
|
+
const inboxFile = path.join(inboxDir, `${share.shareId}.json`);
|
|
11459
|
+
await fs.writeFile(inboxFile, JSON.stringify({
|
|
11460
|
+
shareId: share.shareId,
|
|
11461
|
+
content: share.content,
|
|
11462
|
+
contextFiles: share.contextFiles || [],
|
|
11463
|
+
targetCwd: share.targetCwd || "",
|
|
11464
|
+
sourceTool: share.sourceTool,
|
|
11465
|
+
sourceLabel: share.sourceLabel,
|
|
11466
|
+
targetTool: share.targetTool,
|
|
11467
|
+
createdAtMs: share.createdAtMs,
|
|
11468
|
+
deliveredAtMs: Date.now(),
|
|
11469
|
+
}, null, 2), "utf8");
|
|
11470
|
+
console.log(`[thread-share] Written to inbox: ${inboxFile}`);
|
|
11471
|
+
} catch (error) {
|
|
11472
|
+
console.error(`[thread-share-inbox-write] ${error.message}`);
|
|
11473
|
+
}
|
|
11474
|
+
|
|
11475
|
+
// If the user intended Codex but it wasn't reachable, notify.
|
|
11476
|
+
if (share.targetConversationId && share.targetTool !== "claude-code") {
|
|
11477
|
+
deliveryStatus = "inbox_fallback";
|
|
11478
|
+
try {
|
|
11479
|
+
await deliverWebPushItem({
|
|
11480
|
+
config, state,
|
|
11481
|
+
kind: "thread_share",
|
|
11482
|
+
token: share.token,
|
|
11483
|
+
stableId: `thread_share_fallback:${share.shareId}`,
|
|
11484
|
+
title: "Thread Share: target unreachable",
|
|
11485
|
+
body: `Codex thread "${share.targetLabel || share.targetConversationId.slice(0, 8)}" is not connected. Content saved to inbox.`,
|
|
11486
|
+
});
|
|
11487
|
+
} catch (error) {
|
|
11488
|
+
console.error(`[thread-share-fallback-push] ${error.message}`);
|
|
11489
|
+
}
|
|
11490
|
+
}
|
|
11491
|
+
}
|
|
11492
|
+
}
|
|
11493
|
+
|
|
11494
|
+
// Wake any long-poll waiters.
|
|
11495
|
+
for (const waiter of share.decisionWaiters) {
|
|
11496
|
+
waiter({ decision: share.decision, content: share.content });
|
|
11497
|
+
}
|
|
11498
|
+
share.decisionWaiters = [];
|
|
11499
|
+
return writeJson(res, 200, { ok: true, decision: share.decision });
|
|
11500
|
+
}
|
|
11501
|
+
|
|
11502
|
+
// Long-poll: wait for a thread share decision (used by the CLI or hook).
|
|
11503
|
+
const threadSharePollMatch = url.pathname.match(/^\/api\/threads\/share\/([^/]+)\/poll$/);
|
|
11504
|
+
if (threadSharePollMatch && req.method === "GET") {
|
|
11505
|
+
const session = requireApiSession(req, res, config, state);
|
|
11506
|
+
if (!session) return;
|
|
11507
|
+
const token = cleanText(threadSharePollMatch[1]);
|
|
11508
|
+
const share = runtime.threadSharesByToken.get(token);
|
|
11509
|
+
if (!share) {
|
|
11510
|
+
return writeJson(res, 404, { error: "not-found" });
|
|
11511
|
+
}
|
|
11512
|
+
if (share.decision) {
|
|
11513
|
+
return writeJson(res, 200, { decision: share.decision, content: share.content });
|
|
11514
|
+
}
|
|
11515
|
+
const timeoutMs = Math.min(Number(url.searchParams.get("timeout")) || 120_000, 300_000);
|
|
11516
|
+
await new Promise((resolve) => {
|
|
11517
|
+
const timer = setTimeout(() => {
|
|
11518
|
+
const idx = share.decisionWaiters.indexOf(done);
|
|
11519
|
+
if (idx >= 0) share.decisionWaiters.splice(idx, 1);
|
|
11520
|
+
resolve();
|
|
11521
|
+
}, timeoutMs);
|
|
11522
|
+
timer.unref?.();
|
|
11523
|
+
function done(result) {
|
|
11524
|
+
clearTimeout(timer);
|
|
11525
|
+
resolve(result);
|
|
11526
|
+
}
|
|
11527
|
+
share.decisionWaiters.push(done);
|
|
11528
|
+
});
|
|
11529
|
+
if (share.decision) {
|
|
11530
|
+
return writeJson(res, 200, { decision: share.decision, content: share.content });
|
|
11531
|
+
}
|
|
11532
|
+
return writeJson(res, 200, { decision: null, timeout: true });
|
|
11533
|
+
}
|
|
11534
|
+
|
|
11152
11535
|
// Activity summary for compose (original post) drafting.
|
|
11153
11536
|
// Supports ?slot=morning|noon|evening and ?date=YYYY-MM-DD for
|
|
11154
11537
|
// time-slot-based compose. Defaults to full-day today.
|
|
@@ -11432,6 +11815,27 @@ function createNativeApprovalServer({ config, runtime, state }) {
|
|
|
11432
11815
|
return writeJson(res, 200, {});
|
|
11433
11816
|
}
|
|
11434
11817
|
|
|
11818
|
+
// Register Claude Code thread in persistent registry on every event.
|
|
11819
|
+
{
|
|
11820
|
+
const regThreadId = String(body.threadId || body.sessionId || "");
|
|
11821
|
+
const regCwd = String(body.cwd || "");
|
|
11822
|
+
if (regThreadId) {
|
|
11823
|
+
const regLabel = runtime.claudeSessionTitles.get(regThreadId)
|
|
11824
|
+
|| (regCwd ? path.basename(regCwd) : "")
|
|
11825
|
+
|| regThreadId.slice(0, 40);
|
|
11826
|
+
if (registerThread(runtime, {
|
|
11827
|
+
id: regThreadId,
|
|
11828
|
+
label: regLabel,
|
|
11829
|
+
tool: "claude-code",
|
|
11830
|
+
cwd: regCwd,
|
|
11831
|
+
lastSeenAtMs: Date.now(),
|
|
11832
|
+
active: eventType !== "Stop" && eventType !== "SessionEnd",
|
|
11833
|
+
})) {
|
|
11834
|
+
saveThreadRegistry(config.threadRegistryFile, runtime.threadRegistry).catch(() => {});
|
|
11835
|
+
}
|
|
11836
|
+
}
|
|
11837
|
+
}
|
|
11838
|
+
|
|
11435
11839
|
if (eventType !== "approval_request") {
|
|
11436
11840
|
// Non-approval events (Notification, Stop, etc.) — acknowledge immediately
|
|
11437
11841
|
return writeJson(res, 200, {});
|
|
@@ -13828,6 +14232,7 @@ function buildConfig(cli) {
|
|
|
13828
14232
|
historyFile: resolvePath(process.env.HISTORY_FILE || path.join(codexHome, "history.jsonl")),
|
|
13829
14233
|
codexLogsDbFile: resolvePath(process.env.CODEX_LOGS_DB_FILE || ""),
|
|
13830
14234
|
stateFile,
|
|
14235
|
+
threadRegistryFile: resolvePath(process.env.THREAD_REGISTRY_FILE || path.join(os.homedir(), ".viveworker", "thread-registry.json")),
|
|
13831
14236
|
replyUploadsDir: resolvePath(process.env.REPLY_UPLOADS_DIR || path.join(path.dirname(stateFile), "uploads")),
|
|
13832
14237
|
timelineAttachmentsDir: resolvePath(
|
|
13833
14238
|
process.env.TIMELINE_ATTACHMENTS_DIR || path.join(path.dirname(stateFile), "timeline-attachments")
|
|
@@ -14140,6 +14545,59 @@ async function saveState(stateFile, state) {
|
|
|
14140
14545
|
await fs.writeFile(stateFile, `${output}\n`, "utf8");
|
|
14141
14546
|
}
|
|
14142
14547
|
|
|
14548
|
+
// ---------------------------------------------------------------------------
|
|
14549
|
+
// Thread registry — persistent record of all known threads (survives restarts)
|
|
14550
|
+
// ---------------------------------------------------------------------------
|
|
14551
|
+
|
|
14552
|
+
async function loadThreadRegistry(filePath) {
|
|
14553
|
+
const registry = new Map();
|
|
14554
|
+
try {
|
|
14555
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
14556
|
+
const arr = JSON.parse(raw);
|
|
14557
|
+
if (Array.isArray(arr)) {
|
|
14558
|
+
for (const entry of arr) {
|
|
14559
|
+
if (entry && entry.id) {
|
|
14560
|
+
registry.set(entry.id, entry);
|
|
14561
|
+
}
|
|
14562
|
+
}
|
|
14563
|
+
}
|
|
14564
|
+
} catch {
|
|
14565
|
+
// Missing or corrupt file — start with empty registry.
|
|
14566
|
+
}
|
|
14567
|
+
return registry;
|
|
14568
|
+
}
|
|
14569
|
+
|
|
14570
|
+
async function saveThreadRegistry(filePath, registry) {
|
|
14571
|
+
const arr = [...registry.values()].sort((a, b) => (b.lastSeenAtMs || 0) - (a.lastSeenAtMs || 0));
|
|
14572
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
14573
|
+
await fs.writeFile(filePath, JSON.stringify(arr, null, 2) + "\n", "utf8");
|
|
14574
|
+
}
|
|
14575
|
+
|
|
14576
|
+
/** Upsert a thread in the registry. Returns true if something changed. */
|
|
14577
|
+
function registerThread(runtime, entry) {
|
|
14578
|
+
const existing = runtime.threadRegistry.get(entry.id);
|
|
14579
|
+
const merged = {
|
|
14580
|
+
id: entry.id,
|
|
14581
|
+
label: entry.label || existing?.label || "",
|
|
14582
|
+
tool: entry.tool || existing?.tool || "codex",
|
|
14583
|
+
cwd: entry.cwd || existing?.cwd || "",
|
|
14584
|
+
lastSeenAtMs: Math.max(entry.lastSeenAtMs || Date.now(), existing?.lastSeenAtMs || 0),
|
|
14585
|
+
active: entry.active !== undefined ? entry.active : (existing?.active ?? true),
|
|
14586
|
+
};
|
|
14587
|
+
const changed = !existing
|
|
14588
|
+
|| existing.label !== merged.label
|
|
14589
|
+
|| existing.tool !== merged.tool
|
|
14590
|
+
|| existing.cwd !== merged.cwd
|
|
14591
|
+
|| existing.active !== merged.active
|
|
14592
|
+
|| existing.lastSeenAtMs !== merged.lastSeenAtMs;
|
|
14593
|
+
if (changed) {
|
|
14594
|
+
runtime.threadRegistry.set(entry.id, merged);
|
|
14595
|
+
}
|
|
14596
|
+
return changed;
|
|
14597
|
+
}
|
|
14598
|
+
|
|
14599
|
+
// ---------------------------------------------------------------------------
|
|
14600
|
+
|
|
14143
14601
|
async function loadSessionIndex(filePath) {
|
|
14144
14602
|
const result = new Map();
|
|
14145
14603
|
try {
|
|
@@ -107,8 +107,11 @@ switch (hookEventName) {
|
|
|
107
107
|
case "PostToolUseFailure":
|
|
108
108
|
await handlePostToolUse();
|
|
109
109
|
break;
|
|
110
|
+
case "UserPromptSubmit":
|
|
111
|
+
await handleUserPromptSubmit();
|
|
112
|
+
break;
|
|
110
113
|
default:
|
|
111
|
-
// Notification, Stop,
|
|
114
|
+
// Notification, Stop, SessionEnd
|
|
112
115
|
await postEvent(hookEventName, { sessionId, toolName, cwd }, DEFAULT_EVENT_TIMEOUT_MS);
|
|
113
116
|
break;
|
|
114
117
|
}
|
|
@@ -399,6 +402,76 @@ async function handlePostToolUse() {
|
|
|
399
402
|
);
|
|
400
403
|
}
|
|
401
404
|
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
// UserPromptSubmit — thread inbox auto-read
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
|
|
409
|
+
async function handleUserPromptSubmit() {
|
|
410
|
+
// Check ~/.viveworker/thread-inbox/ for pending shared content from other
|
|
411
|
+
// AI tool sessions. If files are found, inject their content as additional
|
|
412
|
+
// context so the assistant sees them automatically.
|
|
413
|
+
//
|
|
414
|
+
// Targeting: if an inbox file has a `targetCwd` field, only consume it when
|
|
415
|
+
// the current session's cwd starts with that path (project match). Files
|
|
416
|
+
// without targetCwd are consumed by any session.
|
|
417
|
+
const inboxDir = path.join(os.homedir(), ".viveworker", "thread-inbox");
|
|
418
|
+
let inboxFiles = [];
|
|
419
|
+
try {
|
|
420
|
+
const entries = await fs.readdir(inboxDir);
|
|
421
|
+
inboxFiles = entries.filter((e) => e.endsWith(".json"));
|
|
422
|
+
} catch {
|
|
423
|
+
// Inbox dir doesn't exist or is unreadable — nothing to inject.
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (inboxFiles.length > 0) {
|
|
427
|
+
const parts = [];
|
|
428
|
+
for (const file of inboxFiles) {
|
|
429
|
+
const filePath = path.join(inboxDir, file);
|
|
430
|
+
try {
|
|
431
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
432
|
+
const share = JSON.parse(raw);
|
|
433
|
+
// Skip files targeted at a different project.
|
|
434
|
+
if (share.targetCwd && !cwd.startsWith(share.targetCwd)) {
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
const sourceLabel = share.sourceLabel || share.sourceTool || "another session";
|
|
438
|
+
const header = `[Shared from ${sourceLabel}]`;
|
|
439
|
+
let body = share.content || "";
|
|
440
|
+
// Append context file references if the content doesn't already include them.
|
|
441
|
+
const ctxFiles = Array.isArray(share.contextFiles) ? share.contextFiles.filter(Boolean) : [];
|
|
442
|
+
if (ctxFiles.length > 0 && !body.includes("Context files")) {
|
|
443
|
+
const fileList = ctxFiles.map((f) => `- ${f}`).join("\n");
|
|
444
|
+
body += `\n\n## Context files\n\nRead the following files for full conversation context:\n${fileList}`;
|
|
445
|
+
}
|
|
446
|
+
parts.push(`${header}\n\n${body}`);
|
|
447
|
+
// Mark as consumed by removing the file.
|
|
448
|
+
await fs.unlink(filePath);
|
|
449
|
+
} catch {
|
|
450
|
+
// Skip malformed or unreadable files.
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (parts.length > 0) {
|
|
455
|
+
// Cap at 10,000 chars (Claude Code hook output limit).
|
|
456
|
+
let combined = parts.join("\n\n---\n\n");
|
|
457
|
+
if (combined.length > 9800) {
|
|
458
|
+
combined = combined.slice(0, 9800) + "\n\n… (truncated)";
|
|
459
|
+
}
|
|
460
|
+
process.stdout.write(
|
|
461
|
+
JSON.stringify({
|
|
462
|
+
hookSpecificOutput: {
|
|
463
|
+
hookEventName: "UserPromptSubmit",
|
|
464
|
+
additionalContext: combined,
|
|
465
|
+
},
|
|
466
|
+
}) + "\n"
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Forward event to bridge as usual.
|
|
472
|
+
await postEvent("UserPromptSubmit", { sessionId, toolName, cwd }, DEFAULT_EVENT_TIMEOUT_MS);
|
|
473
|
+
}
|
|
474
|
+
|
|
402
475
|
// ---------------------------------------------------------------------------
|
|
403
476
|
// Diff computation
|
|
404
477
|
// ---------------------------------------------------------------------------
|
package/web/app.js
CHANGED
|
@@ -993,6 +993,9 @@ function shouldDeferRenderForActiveInteraction() {
|
|
|
993
993
|
if (state.currentDetail?.kind === "moltbook_draft") {
|
|
994
994
|
return true;
|
|
995
995
|
}
|
|
996
|
+
if (state.currentDetail?.kind === "thread_share" && state.currentDetail?.threadShareEnabled) {
|
|
997
|
+
return true;
|
|
998
|
+
}
|
|
996
999
|
if (
|
|
997
1000
|
activeElement instanceof HTMLTextAreaElement &&
|
|
998
1001
|
activeElement.matches("[data-claude-question-note]")
|
|
@@ -3459,14 +3462,15 @@ function renderStandardDetailDesktop(detail) {
|
|
|
3459
3462
|
<div class="detail-shell">
|
|
3460
3463
|
${renderDetailMetaRow(detail, kindInfo)}
|
|
3461
3464
|
<h2 class="detail-title detail-title--desktop">${renderDetailTitle(detail)}</h2>
|
|
3462
|
-
${detail.readOnly || detail.kind === "approval" || detail.kind === "moltbook_draft" || detail.kind === "moltbook_reply" ? "" : renderDetailLead(detail, kindInfo)}
|
|
3465
|
+
${detail.readOnly || detail.kind === "approval" || detail.kind === "moltbook_draft" || detail.kind === "moltbook_reply" || detail.kind === "thread_share" ? "" : renderDetailLead(detail, kindInfo)}
|
|
3463
3466
|
${renderPreviousContextCard(detail)}
|
|
3464
3467
|
${renderInterruptedDetailNotice(detail)}
|
|
3465
3468
|
${renderMoltbookReplyComposer(detail)}
|
|
3466
3469
|
${renderMoltbookDraftComposer(detail)}
|
|
3467
3470
|
${renderA2ATaskDetail(detail)}
|
|
3471
|
+
${renderThreadShareDetail(detail)}
|
|
3468
3472
|
${
|
|
3469
|
-
detail.kind === "moltbook_draft" || detail.kind === "moltbook_reply" || detail.kind === "a2a_task"
|
|
3473
|
+
detail.kind === "moltbook_draft" || detail.kind === "moltbook_reply" || detail.kind === "a2a_task" || detail.kind === "thread_share"
|
|
3470
3474
|
? ""
|
|
3471
3475
|
: plainIntro
|
|
3472
3476
|
? plainIntro
|
|
@@ -3502,8 +3506,9 @@ function renderStandardDetailMobile(detail) {
|
|
|
3502
3506
|
${renderMoltbookReplyComposer(detail, { mobile: true })}
|
|
3503
3507
|
${renderMoltbookDraftComposer(detail, { mobile: true })}
|
|
3504
3508
|
${renderA2ATaskDetail(detail, { mobile: true })}
|
|
3509
|
+
${renderThreadShareDetail(detail, { mobile: true })}
|
|
3505
3510
|
${
|
|
3506
|
-
detail.kind === "moltbook_draft" || detail.kind === "moltbook_reply" || detail.kind === "a2a_task"
|
|
3511
|
+
detail.kind === "moltbook_draft" || detail.kind === "moltbook_reply" || detail.kind === "a2a_task" || detail.kind === "thread_share"
|
|
3507
3512
|
? ""
|
|
3508
3513
|
: plainIntro
|
|
3509
3514
|
? plainIntro
|
|
@@ -4112,6 +4117,56 @@ function renderA2ATaskDetail(detail, options = {}) {
|
|
|
4112
4117
|
`;
|
|
4113
4118
|
}
|
|
4114
4119
|
|
|
4120
|
+
function renderThreadShareDetail(detail, options = {}) {
|
|
4121
|
+
if (detail.kind !== "thread_share") return "";
|
|
4122
|
+
const enabled = detail.threadShareEnabled !== false && detail.readOnly !== true;
|
|
4123
|
+
const content = detail.shareContent || "";
|
|
4124
|
+
const fromLabel = detail.sourceLabel || detail.sourceTool || "agent";
|
|
4125
|
+
const toLabel = detail.targetLabel || detail.targetConversationId?.slice(0, 8) || detail.targetTool || "thread";
|
|
4126
|
+
const contextFiles = Array.isArray(detail.contextFiles) ? detail.contextFiles : [];
|
|
4127
|
+
const shareTypeLabel = {
|
|
4128
|
+
plan_review: L("threadShare.type.planReview"),
|
|
4129
|
+
handoff: L("threadShare.type.handoff"),
|
|
4130
|
+
message: L("threadShare.type.message"),
|
|
4131
|
+
}[detail.shareType] || L("threadShare.type.message");
|
|
4132
|
+
|
|
4133
|
+
const contextFilesHtml = contextFiles.length > 0
|
|
4134
|
+
? `<div class="reply-composer__instruction">
|
|
4135
|
+
<label class="field-label">${escapeHtml(L("threadShare.contextFiles"))}</label>
|
|
4136
|
+
<ul class="context-files-list">${contextFiles.map((f) => `<li class="context-file-item"><code>${escapeHtml(f)}</code></li>`).join("")}</ul>
|
|
4137
|
+
</div>`
|
|
4138
|
+
: "";
|
|
4139
|
+
|
|
4140
|
+
const buttons = enabled
|
|
4141
|
+
? `
|
|
4142
|
+
<div class="actions actions--stack${options.mobile ? " actions--sticky" : ""}">
|
|
4143
|
+
<button type="submit" data-action="approve" class="primary primary--wide">${escapeHtml(L("threadShare.approve"))}</button>
|
|
4144
|
+
<button type="submit" data-action="deny" class="danger danger--wide">${escapeHtml(L("threadShare.deny"))}</button>
|
|
4145
|
+
</div>
|
|
4146
|
+
`
|
|
4147
|
+
: `<p class="muted reply-composer__description">${escapeHtml(L("threadShare.resolved"))}</p>`;
|
|
4148
|
+
const buttonsWrapped = enabled && options.mobile ? `<div class="detail-action-bar">${buttons}</div>` : buttons;
|
|
4149
|
+
|
|
4150
|
+
return `
|
|
4151
|
+
<section class="detail-card detail-card--reply ${options.mobile ? "detail-card--mobile" : ""}">
|
|
4152
|
+
<form class="reply-composer" data-thread-share-form data-token="${escapeHtml(detail.token || "")}">
|
|
4153
|
+
<div class="reply-composer__copy">
|
|
4154
|
+
<span class="eyebrow-pill eyebrow-pill--quiet">${escapeHtml(L("threadShare.eyebrow"))}</span>
|
|
4155
|
+
<span class="eyebrow-pill eyebrow-pill--subtle">${escapeHtml(shareTypeLabel)}</span>
|
|
4156
|
+
<p class="reply-composer__author-meta muted">${escapeHtml(fromLabel)} → ${escapeHtml(toLabel)}</p>
|
|
4157
|
+
<p class="muted reply-composer__description">${enabled ? escapeHtml(L("threadShare.editHint")) : ""}</p>
|
|
4158
|
+
</div>
|
|
4159
|
+
${contextFilesHtml}
|
|
4160
|
+
<div class="reply-composer__instruction">
|
|
4161
|
+
<label class="field-label">${escapeHtml(L("threadShare.content"))}</label>
|
|
4162
|
+
<textarea name="shareContent" class="reply-composer__textarea" rows="12" ${enabled ? "" : "readonly"}>${escapeHtml(content)}</textarea>
|
|
4163
|
+
</div>
|
|
4164
|
+
${buttonsWrapped}
|
|
4165
|
+
</form>
|
|
4166
|
+
</section>
|
|
4167
|
+
`;
|
|
4168
|
+
}
|
|
4169
|
+
|
|
4115
4170
|
function renderCompletionReplyComposer(detail, options = {}) {
|
|
4116
4171
|
if (detail.kind !== "completion" || detail.reply?.enabled !== true) {
|
|
4117
4172
|
return "";
|
|
@@ -5305,6 +5360,72 @@ function bindShellInteractions() {
|
|
|
5305
5360
|
});
|
|
5306
5361
|
}
|
|
5307
5362
|
|
|
5363
|
+
const threadShareForm = document.querySelector("[data-thread-share-form]");
|
|
5364
|
+
if (threadShareForm) {
|
|
5365
|
+
const token = threadShareForm.dataset.token || "";
|
|
5366
|
+
let submittedAction = "approve";
|
|
5367
|
+
threadShareForm.querySelectorAll("button[type='submit']").forEach((btn) => {
|
|
5368
|
+
btn.addEventListener("click", () => {
|
|
5369
|
+
submittedAction = btn.dataset.action || "approve";
|
|
5370
|
+
});
|
|
5371
|
+
});
|
|
5372
|
+
threadShareForm.addEventListener("submit", async (event) => {
|
|
5373
|
+
event.preventDefault();
|
|
5374
|
+
if (threadShareForm.dataset.submitting === "1") return;
|
|
5375
|
+
threadShareForm.dataset.submitting = "1";
|
|
5376
|
+
const buttons = threadShareForm.querySelectorAll("button[type='submit']");
|
|
5377
|
+
const textarea = threadShareForm.querySelector("textarea");
|
|
5378
|
+
const labelCache = new Map();
|
|
5379
|
+
buttons.forEach((btn) => {
|
|
5380
|
+
labelCache.set(btn, btn.innerHTML);
|
|
5381
|
+
btn.disabled = true;
|
|
5382
|
+
if (btn.dataset.action === submittedAction) {
|
|
5383
|
+
btn.innerHTML = submittedAction === "approve" ? "Sharing…" : "Denying…";
|
|
5384
|
+
btn.classList.add("is-loading");
|
|
5385
|
+
} else {
|
|
5386
|
+
btn.classList.add("is-dimmed");
|
|
5387
|
+
}
|
|
5388
|
+
});
|
|
5389
|
+
if (textarea) textarea.readOnly = true;
|
|
5390
|
+
const editedContent = normalizeClientText(new FormData(threadShareForm).get("shareContent"));
|
|
5391
|
+
try {
|
|
5392
|
+
const res = await fetch(`/api/threads/share/${encodeURIComponent(token)}/decision`, {
|
|
5393
|
+
method: "POST",
|
|
5394
|
+
headers: { "content-type": "application/json" },
|
|
5395
|
+
credentials: "same-origin",
|
|
5396
|
+
body: JSON.stringify({ decision: submittedAction, editedContent }),
|
|
5397
|
+
});
|
|
5398
|
+
if (!res.ok) {
|
|
5399
|
+
const errBody = await res.json().catch(() => ({}));
|
|
5400
|
+
alert(`Thread share ${submittedAction} failed: ${errBody.error || res.status}`);
|
|
5401
|
+
buttons.forEach((btn) => {
|
|
5402
|
+
btn.disabled = false;
|
|
5403
|
+
btn.classList.remove("is-loading", "is-dimmed");
|
|
5404
|
+
btn.innerHTML = labelCache.get(btn);
|
|
5405
|
+
});
|
|
5406
|
+
if (textarea) textarea.readOnly = false;
|
|
5407
|
+
threadShareForm.dataset.submitting = "";
|
|
5408
|
+
return;
|
|
5409
|
+
}
|
|
5410
|
+
if (state.currentDetail?.kind === "thread_share") {
|
|
5411
|
+
state.currentDetail.threadShareEnabled = false;
|
|
5412
|
+
state.currentDetail.readOnly = true;
|
|
5413
|
+
}
|
|
5414
|
+
await refreshAuthenticatedState();
|
|
5415
|
+
await renderShell();
|
|
5416
|
+
} catch (error) {
|
|
5417
|
+
alert(`Thread share error: ${error.message}`);
|
|
5418
|
+
buttons.forEach((btn) => {
|
|
5419
|
+
btn.disabled = false;
|
|
5420
|
+
btn.classList.remove("is-loading", "is-dimmed");
|
|
5421
|
+
btn.innerHTML = labelCache.get(btn);
|
|
5422
|
+
});
|
|
5423
|
+
if (textarea) textarea.readOnly = false;
|
|
5424
|
+
threadShareForm.dataset.submitting = "";
|
|
5425
|
+
}
|
|
5426
|
+
});
|
|
5427
|
+
}
|
|
5428
|
+
|
|
5308
5429
|
const replyForm = document.querySelector("[data-completion-reply-form]");
|
|
5309
5430
|
if (replyForm) {
|
|
5310
5431
|
const token = replyForm.dataset.token || "";
|
|
@@ -5786,6 +5907,8 @@ function kindMeta(kind) {
|
|
|
5786
5907
|
case "moltbook_reply":
|
|
5787
5908
|
case "moltbook_draft":
|
|
5788
5909
|
return { label: L("common.sns"), tone: "neutral", icon: "item" };
|
|
5910
|
+
case "thread_share":
|
|
5911
|
+
return { label: L("common.threadShare"), tone: "neutral", icon: "link" };
|
|
5789
5912
|
default:
|
|
5790
5913
|
return { label: L("common.item"), tone: "neutral", icon: "item" };
|
|
5791
5914
|
}
|
package/web/i18n.js
CHANGED
|
@@ -49,6 +49,7 @@ const translations = {
|
|
|
49
49
|
"common.assistantFinal": "Final reply",
|
|
50
50
|
"common.item": "Item",
|
|
51
51
|
"common.sns": "SNS",
|
|
52
|
+
"common.threadShare": "Thread Share",
|
|
52
53
|
"common.untitledItem": "Untitled item",
|
|
53
54
|
"common.useDeviceLanguage": "Use device language",
|
|
54
55
|
"language.option.en": "English",
|
|
@@ -400,6 +401,16 @@ const translations = {
|
|
|
400
401
|
"a2a.task.statusFailed": "Failed",
|
|
401
402
|
"a2a.task.statusCanceled": "Canceled",
|
|
402
403
|
"a2a.task.statusRejected": "Rejected",
|
|
404
|
+
"threadShare.eyebrow": "Thread Share",
|
|
405
|
+
"threadShare.content": "Shared content",
|
|
406
|
+
"threadShare.approve": "Approve & Share",
|
|
407
|
+
"threadShare.deny": "Deny",
|
|
408
|
+
"threadShare.editHint": "Review the content below. Edit if needed, then approve to share or deny to skip.",
|
|
409
|
+
"threadShare.resolved": "This share request has been resolved.",
|
|
410
|
+
"threadShare.contextFiles": "Context files (read by recipient)",
|
|
411
|
+
"threadShare.type.message": "Message",
|
|
412
|
+
"threadShare.type.planReview": "Plan Review",
|
|
413
|
+
"threadShare.type.handoff": "Handoff",
|
|
403
414
|
"notice.notificationsEnabled": "Notifications are enabled on this device.",
|
|
404
415
|
"notice.notificationsDisabled": "Notifications are disabled on this device.",
|
|
405
416
|
"notice.testNotificationSent": "Test notification sent.",
|
|
@@ -666,6 +677,7 @@ const translations = {
|
|
|
666
677
|
"common.assistantFinal": "最終回答",
|
|
667
678
|
"common.item": "項目",
|
|
668
679
|
"common.sns": "SNS",
|
|
680
|
+
"common.threadShare": "スレッド共有",
|
|
669
681
|
"common.untitledItem": "無題の項目",
|
|
670
682
|
"common.useDeviceLanguage": "端末の言語を使う",
|
|
671
683
|
"language.option.en": "English",
|
|
@@ -1013,6 +1025,16 @@ const translations = {
|
|
|
1013
1025
|
"a2a.task.statusFailed": "失敗",
|
|
1014
1026
|
"a2a.task.statusCanceled": "キャンセル",
|
|
1015
1027
|
"a2a.task.statusRejected": "拒否済み",
|
|
1028
|
+
"threadShare.eyebrow": "スレッド共有",
|
|
1029
|
+
"threadShare.content": "共有内容",
|
|
1030
|
+
"threadShare.approve": "承認して共有",
|
|
1031
|
+
"threadShare.deny": "拒否",
|
|
1032
|
+
"threadShare.editHint": "内容を確認してください。必要に応じて編集し、承認で共有、拒否でスキップします。",
|
|
1033
|
+
"threadShare.resolved": "この共有リクエストは処理済みです。",
|
|
1034
|
+
"threadShare.contextFiles": "コンテキストファイル(受信側が読み込みます)",
|
|
1035
|
+
"threadShare.type.message": "メッセージ",
|
|
1036
|
+
"threadShare.type.planReview": "プランレビュー",
|
|
1037
|
+
"threadShare.type.handoff": "引き継ぎ",
|
|
1016
1038
|
"notice.notificationsEnabled": "この端末で通知を有効にしました。",
|
|
1017
1039
|
"notice.notificationsDisabled": "この端末の通知を無効にしました。",
|
|
1018
1040
|
"notice.testNotificationSent": "テスト通知を送信しました。",
|