wezard 2.2.2 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +9 -6
- package/README.md +5 -5
- package/config.example.jsonc +12 -0
- package/dist/cli/init.js +2 -1
- package/dist/cli/init.js.map +1 -1
- package/dist/daemon/approval.js +90 -6
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/chat-log.js +6 -1
- package/dist/daemon/chat-log.js.map +1 -1
- package/dist/daemon/chat-name.js +15 -0
- package/dist/daemon/chat-name.js.map +1 -1
- package/dist/daemon/config-api.js +265 -70
- package/dist/daemon/config-api.js.map +1 -1
- package/dist/daemon/danger.js +15 -0
- package/dist/daemon/danger.js.map +1 -1
- package/dist/daemon/detail.js +49 -0
- package/dist/daemon/detail.js.map +1 -1
- package/dist/daemon/handoff.js +12 -5
- package/dist/daemon/handoff.js.map +1 -1
- package/dist/daemon/idle-nudge.js +55 -0
- package/dist/daemon/idle-nudge.js.map +1 -0
- package/dist/daemon/inbound.js +19 -14
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +1509 -259
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +180 -32
- package/dist/daemon/jobs.js.map +1 -1
- package/dist/daemon/last-response.js +15 -0
- package/dist/daemon/last-response.js.map +1 -1
- package/dist/daemon/memory-steward.js +124 -68
- package/dist/daemon/memory-steward.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +506 -124
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/mirror-store.js.map +1 -1
- package/dist/daemon/model-select.js +102 -0
- package/dist/daemon/model-select.js.map +1 -1
- package/dist/daemon/notices.js +147 -33
- package/dist/daemon/notices.js.map +1 -1
- package/dist/daemon/peers.js +261 -124
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/pending-items.js +171 -0
- package/dist/daemon/pending-items.js.map +1 -0
- package/dist/daemon/receipts.js +428 -80
- package/dist/daemon/receipts.js.map +1 -1
- package/dist/daemon/route.js +133 -19
- package/dist/daemon/route.js.map +1 -1
- package/dist/daemon/spawn-tmux.js +24 -13
- package/dist/daemon/spawn-tmux.js.map +1 -1
- package/dist/daemon/steward-guard.js +32 -0
- package/dist/daemon/steward-guard.js.map +1 -0
- package/dist/daemon/task-registry.js +1 -1
- package/dist/daemon/task-registry.js.map +1 -1
- package/dist/daemon/tasks.js +12 -4
- package/dist/daemon/tasks.js.map +1 -1
- package/dist/daemon/usage.js +4 -11
- package/dist/daemon/usage.js.map +1 -1
- package/dist/daemon/wizard-memory.js +36 -4
- package/dist/daemon/wizard-memory.js.map +1 -1
- package/dist/daemon/wizard.js +174 -87
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/mcp/server.js +209 -132
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/charter-view.js +35 -0
- package/dist/shared/charter-view.js.map +1 -0
- package/dist/shared/chat-http.js +134 -32
- package/dist/shared/chat-http.js.map +1 -1
- package/dist/shared/chat-render.js +6 -3
- package/dist/shared/chat-render.js.map +1 -1
- package/dist/shared/chat-view.js +17 -6
- package/dist/shared/chat-view.js.map +1 -1
- package/dist/shared/cli-backends.js +2 -0
- package/dist/shared/cli-backends.js.map +1 -1
- package/dist/shared/config-meta.js +91 -0
- package/dist/shared/config-meta.js.map +1 -0
- package/dist/shared/config-writer.js +5 -6
- package/dist/shared/config-writer.js.map +1 -1
- package/dist/shared/config.js +273 -294
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/daemon-token.js +18 -0
- package/dist/shared/daemon-token.js.map +1 -0
- package/dist/shared/detail-render.js +233 -20
- package/dist/shared/detail-render.js.map +1 -1
- package/dist/shared/detail-store.js +33 -12
- package/dist/shared/detail-store.js.map +1 -1
- package/dist/shared/effort.js +11 -0
- package/dist/shared/effort.js.map +1 -0
- package/dist/shared/keepalive.js +5 -5
- package/dist/shared/keepalive.js.map +1 -1
- package/dist/shared/model-prices.js +258 -0
- package/dist/shared/model-prices.js.map +1 -0
- package/dist/shared/pricing.js +55 -0
- package/dist/shared/pricing.js.map +1 -0
- package/dist/shared/reminder.js +18 -4
- package/dist/shared/reminder.js.map +1 -1
- package/dist/shared/role-render.js +210 -8
- package/dist/shared/role-render.js.map +1 -1
- package/dist/shared/role-view.js +104 -26
- package/dist/shared/role-view.js.map +1 -1
- package/dist/shared/session-label.js +4 -0
- package/dist/shared/session-label.js.map +1 -1
- package/dist/shared/task-file.js +16 -1
- package/dist/shared/task-file.js.map +1 -1
- package/dist/shared/turn-state.js +24 -0
- package/dist/shared/turn-state.js.map +1 -0
- package/dist/shared/world.js +116 -13
- package/dist/shared/world.js.map +1 -1
- package/dist/svr/index.js +2 -1
- package/dist/svr/index.js.map +1 -1
- package/hooks/pre-tool-use.sh +15 -3
- package/package.json +1 -1
- package/web/chat.css +226 -74
- package/web/chat.js +1075 -370
package/dist/daemon/index.js
CHANGED
|
@@ -1,46 +1,61 @@
|
|
|
1
1
|
// Daemon entry. Resident process — exits only on signal or fatal WS auth failure.
|
|
2
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { timingSafeEqual } from "node:crypto";
|
|
5
|
+
import { loadOrCreateSvrToken } from "../shared/svr-token.js";
|
|
6
|
+
import { DAEMON_TOKEN_FILE, DAEMON_TOKEN_HEADER } from "../shared/daemon-token.js";
|
|
2
7
|
import { homedir } from "node:os";
|
|
3
|
-
import { loadConfig } from "../shared/config.js";
|
|
8
|
+
import { TIERS, loadConfig } from "../shared/config.js";
|
|
4
9
|
import { makeLogger } from "../shared/log.js";
|
|
5
|
-
import {
|
|
10
|
+
import { clipLine, sleep, truncate } from "../shared/std.js";
|
|
11
|
+
import { patchJsonc } from "../shared/config-writer.js";
|
|
12
|
+
import { EFFORTS, parseEffort } from "../shared/effort.js";
|
|
13
|
+
import { bindCliBackends, projectDirsFor } from "../shared/cli-backends.js";
|
|
6
14
|
import { startWs } from "./ws.js";
|
|
7
15
|
import { startNetWatch } from "./net-watch.js";
|
|
8
16
|
import { startHttp, json, readBody } from "./http.js";
|
|
9
|
-
import {
|
|
17
|
+
import { parsePath } from "../shared/config-meta.js";
|
|
18
|
+
import { applyHot, patchedTop, configGet, fromLegacy, legacyGet, pickTier, planSet, renderPlan, tierEvidence } from "./config-api.js";
|
|
19
|
+
import { computeUsage, costOf, fmtCost, fmtTokens, sumTotal } from "./usage.js";
|
|
10
20
|
import { installInboundRouter } from "./inbound.js";
|
|
11
21
|
import { loadMirrorStore } from "./mirror-store.js";
|
|
12
22
|
import { startMirror, installMirrorEventListener } from "./mirror-bridge.js";
|
|
13
|
-
import { setTmuxTimeoutReporter, spawnTmuxClaude } from "./spawn-tmux.js";
|
|
14
|
-
import { installApprovalEventListener, makeApproveHandler } from "./approval.js";
|
|
15
|
-
import { initDetailPersistence, makeDetailHandler, chatHandlers, configureRemoteForward, chatUrlFor, setWorldFactsProvider, recordPost } from "./detail.js";
|
|
23
|
+
import { runTmux, setTmuxTimeoutReporter, spawnTmuxClaude } from "./spawn-tmux.js";
|
|
24
|
+
import { installApprovalEventListener, makeApproveHandler, requestConfirm } from "./approval.js";
|
|
25
|
+
import { initDetailPersistence, makeDetailHandler, chatHandlers, configureRemoteForward, chatUrlFor, setWorldFactsProvider, currentHumanQueryOf, recordPost, recordCharter, backfillCharter } from "./detail.js";
|
|
16
26
|
import { EMPTY_FACTS } from "../shared/world.js";
|
|
17
|
-
import { initAutoWindowPersistence } from "./session-cache.js";
|
|
27
|
+
import { clearAutoWindow, initAutoWindowPersistence, setAutoWindow } from "./session-cache.js";
|
|
18
28
|
import { makeMessageHandler } from "./outbound.js";
|
|
19
29
|
import { makeCardHandler, makeAskHandler, installAskEventListener } from "./ask.js";
|
|
20
30
|
import { drainForReload, listPending } from "./pending.js";
|
|
21
31
|
import { makeClaimStartHandler, makeClaimStatusHandler, makeClaimResetHandler, } from "./claim.js";
|
|
22
32
|
import { makeWedocBridge } from "./wedoc.js";
|
|
23
|
-
import { installResponseTracker } from "./last-response.js";
|
|
33
|
+
import { errText, installResponseTracker } from "./last-response.js";
|
|
24
34
|
import { scanClaudeSessions } from "./session-scan.js";
|
|
25
|
-
import { startScheduler, migrateLegacySchedules, promptWantsFreshWizard, renderTask, sinceOf, } from "./tasks.js";
|
|
35
|
+
import { startScheduler, migrateLegacySchedules, promptWantsFreshWizard, renderTask, sinceOf, TASK_POSTPONE_MS, TASK_QUIET_WAIT_MS, } from "./tasks.js";
|
|
26
36
|
import { openTaskRegistry } from "./task-registry.js";
|
|
27
37
|
import { describeTrigger, nextFire, parseTrigger, WHEN_HELP } from "../shared/trigger.js";
|
|
28
|
-
import { slugify, uniqueId } from "../shared/task-file.js";
|
|
29
|
-
import { baseOfKey, bindTagLinker, keyOf, linkTags, normalizeTag, tagFromCwd, tagHead, tagLink, tagOfKey, uniqueTag, withTagHeader } from "../shared/session-label.js";
|
|
30
|
-
import { clipForCharter, inboxPath, memoryPath, memoryRoot, proposeMemory, readMemory } from "./wizard-memory.js";
|
|
31
|
-
import {
|
|
32
|
-
import { applyChatNames, chatBaseOf, chatNameOf, clearChatName, listChatNames, normChatName, peerAddress, planChatNames, setChatName } from "./chat-name.js";
|
|
33
|
-
import { bindWizardStore, loadWizardStore, wizardStore, settleName, settleAll, evictStaleName, childrenOf, ancestorsOf, renderCharter, renderRoster,
|
|
34
|
-
import { bindNoticeBox, createNoticeBox, chatAudience } from "./notices.js";
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
38
|
+
import { isQuiet, slugify, uniqueId } from "../shared/task-file.js";
|
|
39
|
+
import { baseOfKey, bindTagLinker, isInternalKey, keyOf, linkTags, normalizeTag, tagFromCwd, tagHead, tagLink, tagOfKey, uniqueTag, withTagHeader } from "../shared/session-label.js";
|
|
40
|
+
import { appendEpisode, clipForCharter, cwdOfMd, episodePath, inboxPath, mdsOf, proposedCwds, memoryPath, memoryRoot, proposeMemory, readMemory } from "./wizard-memory.js";
|
|
41
|
+
import { retireStewardTask, startSteward, stewardEnvelope, STEWARD_ID, STEWARD_RUN_MS, STEWARD_TARGET } from "./memory-steward.js";
|
|
42
|
+
import { applyChatNames, chatBaseOf, chatNameOf, chatPolicyOf, clearChatName, listChatNames, normChatName, peerAddress, planChatNames, setChatName } from "./chat-name.js";
|
|
43
|
+
import { bindWizardStore, loadWizardStore, wizardStore, settleName, settleAll, backfillBorn, sweepUnborn, BOOT_ID, evictStaleName, childrenOf, ancestorsOf, renderCharter, renderLead, renderLeadPlaybook, ROSTER_FRESH_MS, renderRoster, handoffAt, memoryNudgeAt, MEMORY_NUDGE_FLOOR, MEMORY_MAX, NOTE_MAX, } from "./wizard.js";
|
|
44
|
+
import { bindNoticeBox, createNoticeBox, createSeenRing, gcSeen, chatAudience, hears } from "./notices.js";
|
|
45
|
+
import { createLedger, digest, pendingGc, renderDigest, renderTable, renderAwaiting, renderStalled, renderShelved, ago as agoMs, HEARD_EVERY_MS } from "./pending-items.js";
|
|
46
|
+
import { loadJobStore, dutyLine, awaitingAccept, stalledRoots, shelvedRoots, nudgeDue, dayClamp, childrenOf as jobChildren, jobEpisode, rejectReason, ACCEPTS, JOB_MEMBER_MAX, JOB_DEPTH_MAX, depthOf, ancestorsOf as jobAncestors, treeOrder, jobStage } from "./jobs.js";
|
|
47
|
+
import { cacheTouchMs, cacheTtlSec, clipMiddle, contextFiles, firstStamp, parseClosing, lastContextTokens, lastExchange, lastModel, openingTurn, replyClosedBefore, talkTurns, renderPeerEnvelope, renderReceiptEnvelope, renderTaskEnvelope } from "./peers.js";
|
|
37
48
|
import { keepalivePingSigs } from "../shared/keepalive.js";
|
|
38
49
|
import { expandHome } from "../shared/paths.js";
|
|
39
50
|
import { loadJsonMap } from "../shared/json-map-store.js";
|
|
40
|
-
import { createReceipts } from "./receipts.js";
|
|
51
|
+
import { createReceipts, deadlineOf, kAttr, kOfAttr, newTurn } from "./receipts.js";
|
|
52
|
+
import { renderInFlight } from "../shared/turn-state.js";
|
|
53
|
+
import { startIdleNudge } from "./idle-nudge.js";
|
|
54
|
+
import { renderReminder } from "../shared/reminder.js";
|
|
41
55
|
import { createHandoffs, handedOff, handingOff } from "./handoff.js";
|
|
42
|
-
import { rankCandidates, renderCandidates } from "./route.js";
|
|
43
|
-
import { parseWhen, renderChatLog } from "./chat-log.js";
|
|
56
|
+
import { coldGateOf, decide, rankCandidates, renderCandidates, tierFor, wakeNoteOf } from "./route.js";
|
|
57
|
+
import { parseWhen, renderChatLog, UNKNOWN_HUMAN } from "./chat-log.js";
|
|
58
|
+
import { audienceOf } from "../shared/role-view.js";
|
|
44
59
|
import { startGraph, stopRun, getRun, listRuns, validateSpec, waitForIdle, waitForQuorum, } from "./graph.js";
|
|
45
60
|
// pino's file transport is async; without flush, log.fatal before process.exit
|
|
46
61
|
// vanishes. Mirror anything fatal to stderr too so launchd's stderr.log captures it.
|
|
@@ -59,6 +74,9 @@ const main = async () => {
|
|
|
59
74
|
log.info({ sourcePath, pid: process.pid }, "daemon start");
|
|
60
75
|
// 定时调度器的 `inject` 真正实现, 赋值在 mirror-mode 的大块里 (要用到那里面的
|
|
61
76
|
// wizards / m / notifyChat), 但 startScheduler 在那块外面接线 —— 见文件尾。
|
|
77
|
+
// 记忆整理者的执行体, 同样只有 mirror 模式给得出 —— 赋值见 scheduledTaskInject 旁边。
|
|
78
|
+
let stewardRun;
|
|
79
|
+
let stewardWiring = {};
|
|
62
80
|
let scheduledTaskInject;
|
|
63
81
|
// Bind the CLI backend registry. `primary` (= defaultCli) drives new-session
|
|
64
82
|
// spawns; `backends` is every installed CLI whose transcript root exists, so
|
|
@@ -90,12 +108,15 @@ const main = async () => {
|
|
|
90
108
|
const mirrorStore = loadMirrorStore(cfg.wrc.mirror.attachmentsFile);
|
|
91
109
|
// 定时任务表 —— 每条任务是 ~/.wezard/tasks/<id>.task.mjs 一份可注入代码的配置
|
|
92
110
|
// (见 shared/task-file.ts)。目录是热加载的: wizard 改完文件不用 reload 守护进程。
|
|
93
|
-
//
|
|
111
|
+
// 共享记忆的整理者改成了 daemon 内建定时器 (memory-steward.ts); 老版本写下的那条
|
|
112
|
+
// 定时任务要在任务表加载前停用, 否则两路并跑。
|
|
94
113
|
try {
|
|
95
|
-
|
|
114
|
+
const retired = retireStewardTask();
|
|
115
|
+
if (retired)
|
|
116
|
+
log.info({ retired }, "memory steward task file retired");
|
|
96
117
|
}
|
|
97
118
|
catch (e) {
|
|
98
|
-
log.warn({ err: e.message }, "memory steward task not
|
|
119
|
+
log.warn({ err: e.message }, "memory steward task not retired");
|
|
99
120
|
}
|
|
100
121
|
const tasks = await openTaskRegistry(log.child({ mod: "tasks" }), (added, removed) => log.info({ added, removed }, "task files changed"));
|
|
101
122
|
const bridge = startMirror({ cfg, log: log.child({ mod: "mirror" }), client: ws.client, store: mirrorStore });
|
|
@@ -135,9 +156,10 @@ const main = async () => {
|
|
|
135
156
|
const t = bridge.targetForSession(sid);
|
|
136
157
|
return t ? await bridge.interruptPane(t) : { ok: false, reason: "no mirror target for session" };
|
|
137
158
|
},
|
|
159
|
+
// 告知那一句接着被取消的那一轮说: 频道照它的走 —— 不带频道就等于在 home 群里开口。
|
|
138
160
|
tell: async (sid, text) => {
|
|
139
161
|
const t = bridge.targetForSession(sid);
|
|
140
|
-
return t ? await bridge.injectText(t, text) : { ok: false, reason: "no mirror target for session" };
|
|
162
|
+
return t ? await bridge.injectText(t, text, undefined, { channel: bridge.currentChannel(t) }) : { ok: false, reason: "no mirror target for session" };
|
|
141
163
|
},
|
|
142
164
|
};
|
|
143
165
|
const http = startHttp({ cfg, ws, log, sourcePath });
|
|
@@ -145,9 +167,23 @@ const main = async () => {
|
|
|
145
167
|
http.register("POST /message", makeMessageHandler(ws.client, log.child({ mod: "outbound" })));
|
|
146
168
|
http.register("POST /card", makeCardHandler(ws.client, log.child({ mod: "outbound" })));
|
|
147
169
|
http.register("POST /ask", makeAskHandler(ws.client, log.child({ mod: "ask" })));
|
|
148
|
-
|
|
170
|
+
// 写敏感状态的路由只认出示口令的 —— wezard 的 MCP server 与 CLI 读得到 0600 的口令文件
|
|
171
|
+
// (见 shared/daemon-token.ts), 本机别的进程 curl 不进来: 写配置、改 allowFrom 的认领
|
|
172
|
+
// 口令、落一份 daemon 会执行其 gate 的任务文件, 都不该是谁连上 127.0.0.1 就能做的。
|
|
173
|
+
const daemonToken = loadOrCreateSvrToken(DAEMON_TOKEN_FILE);
|
|
174
|
+
const tokenOk = (req) => {
|
|
175
|
+
const got = Buffer.from(String(req.headers[DAEMON_TOKEN_HEADER] ?? ""));
|
|
176
|
+
const want = Buffer.from(daemonToken);
|
|
177
|
+
return got.length === want.length && timingSafeEqual(got, want);
|
|
178
|
+
};
|
|
179
|
+
const guarded = (h) => async (req, res, url, ctx) => {
|
|
180
|
+
if (tokenOk(req))
|
|
181
|
+
return h(req, res, url, ctx);
|
|
182
|
+
json(res, 403, { ok: false, reason: `这条路由要出示 ${DAEMON_TOKEN_FILE} 里的口令 (header ${DAEMON_TOKEN_HEADER}); 正在跑的 wizard 若是旧版 MCP 进程, 重开会话即可` });
|
|
183
|
+
};
|
|
184
|
+
http.register("POST /claim/start", guarded(makeClaimStartHandler({ log: log.child({ mod: "claim" }) })));
|
|
149
185
|
http.register("GET /claim/status", makeClaimStatusHandler());
|
|
150
|
-
http.register("POST /claim/reset", makeClaimResetHandler());
|
|
186
|
+
http.register("POST /claim/reset", guarded(makeClaimResetHandler()));
|
|
151
187
|
http.register("GET /detail", makeDetailHandler(log.child({ mod: "detail" })));
|
|
152
188
|
for (const [key, handler] of Object.entries(chatHandlers()))
|
|
153
189
|
http.register(key, handler);
|
|
@@ -369,13 +405,8 @@ const main = async () => {
|
|
|
369
405
|
const r = await m.newSession(target, name, body.cli, { cwd, model, keepalive, silent: true });
|
|
370
406
|
if (!r.ok && !existed)
|
|
371
407
|
wizards.drop(target);
|
|
372
|
-
// r.model 是 spawnTmuxClaude 通过 /model 实测确认落地的那个 —— 可能跟调用方
|
|
373
|
-
// 传的原始字符串不一样 (口语化 → 目录里匹配到的关键词), 播报要报实情。
|
|
374
|
-
const modelNote = r.model
|
|
375
|
-
? (r.modelWarning ? ` · 模型 ${r.model} (⚠️ ${r.modelWarning})` : ` · 模型 ${r.model}`)
|
|
376
|
-
: "";
|
|
377
408
|
if (r.ok)
|
|
378
|
-
postRoster(base, [target, self],
|
|
409
|
+
postRoster(base, [target, self], { op: "born", name: `.${name}`, by: displayName(self), cwd: r.cwd }, subjectOf(wizards.get(target)));
|
|
379
410
|
json(res, r.ok ? 200 : 500, r.ok
|
|
380
411
|
? {
|
|
381
412
|
ok: true,
|
|
@@ -532,6 +563,8 @@ const main = async () => {
|
|
|
532
563
|
const home = baseOfKey(self);
|
|
533
564
|
const cur = wizards.get(home)?.name ?? "";
|
|
534
565
|
const wizardRenamed = previous && cur.toLowerCase() === previous.toLowerCase() ? wizards.rename(home, r.name) : undefined;
|
|
566
|
+
if (wizardRenamed)
|
|
567
|
+
void m.retitlePane(home);
|
|
535
568
|
json(res, 200, { ok: true, base: r.base, name: r.name, previous, ...(wizardRenamed ? { wizardRenamed } : {}) });
|
|
536
569
|
});
|
|
537
570
|
const readPeerBody = async (req) => {
|
|
@@ -559,7 +592,7 @@ const main = async () => {
|
|
|
559
592
|
const chatId = chatIdOf(base);
|
|
560
593
|
void ws.client
|
|
561
594
|
.sendMessage(chatId, { msgtype: "markdown", markdown: { content: linkTags(base, markdown) } })
|
|
562
|
-
.catch((e) => log.warn({ err: e
|
|
595
|
+
.catch((e) => log.warn({ chatId, err: errText(e) }, "chat notify failed"));
|
|
563
596
|
};
|
|
564
597
|
// wizard 在群里的称呼, 只用在**正文**里。气泡的头照旧交给 withTagHeader ——
|
|
565
598
|
// 那一段是路由信息 (`emoji #tag`), parseTagHeader 靠它反解, 群里引用一条气泡
|
|
@@ -589,13 +622,33 @@ const main = async () => {
|
|
|
589
622
|
* 私聊轮或无记录 → 它的 home 群。notify 与 public send_peer 默认发到这里。 */
|
|
590
623
|
const channelOf = (self) => m.currentChannel(self) || baseOfKey(self);
|
|
591
624
|
/** 同伴注入的信封: 公开轮写上那个群的名字, 私聊不写 (见 peers.renderPeerEnvelope)。 */
|
|
592
|
-
const envelopeFor = (from, channel) => renderPeerEnvelope(displayName(from), channel ? chatNameOf(cfg, channel) : undefined);
|
|
625
|
+
const envelopeFor = (from, channel, t) => renderPeerEnvelope(displayName(from), channel ? chatNameOf(cfg, channel) : undefined, t);
|
|
593
626
|
// 回执 (见 receipts.ts): 一次 tell_peer 之后对方干完那一轮, 它的结论自动 paste
|
|
594
627
|
// 回发话方的输入框, 发话方全程不阻塞。这里只提供四样能力给那个模块 —— 忙闲、
|
|
595
628
|
// 死活、「它答我那一句的是哪段话」、以及怎么把一段话送进去 —— 投递逻辑在那边。
|
|
629
|
+
// 工单账本 (见 jobs.ts)。只在**显式传了 `job`** 时起作用 —— 不用工单的调用方
|
|
630
|
+
// 行为与从前一模一样。回执要数它的成员, 所以先于回执载入。
|
|
631
|
+
const jobs = loadJobStore(cfg.wrc.mirror.jobsFile);
|
|
632
|
+
/** 回执那一轮在群里的终句答给谁 (userid) —— 与 rolepage 同一个判定, 写进信封给模型与
|
|
633
|
+
* read_chat; 认不出 = ""。 */
|
|
634
|
+
const audienceName = (channel, asker) => {
|
|
635
|
+
const id = audienceOf(channel, asker);
|
|
636
|
+
return id === "human:" ? "" : id.slice("human:".length);
|
|
637
|
+
};
|
|
638
|
+
// 挂起事项表 (见 pending-items.ts): 派活时记一行, 回执落定时由下面的 onOutcome 记账。
|
|
639
|
+
const ledger = createLedger(loadJsonMap(cfg.wrc.mirror.pendingFile, pendingGc));
|
|
596
640
|
const receipts = createReceipts({
|
|
597
|
-
isBusy: m.isBusy,
|
|
598
641
|
idleNow: m.idleNow,
|
|
642
|
+
onOutcome: (s, status, bodyText, bySender) => ledger.outcome(s.from, s.turn ?? "", status, parseClosing(bodyText).text || bodyText, bySender),
|
|
643
|
+
jobTally: (id) => jobs.tally(id),
|
|
644
|
+
parkedNow: m.parkedNow,
|
|
645
|
+
settleJob: (id, target, outcome, artifacts) => { jobs.settle(id, target, outcome, artifacts); },
|
|
646
|
+
accept: (id, body) => { const j = jobs.get(id); return j?.status === "open" ? rejectReason(j.accept ?? "result", parseClosing(body)) : undefined; },
|
|
647
|
+
rebound: async (x, why) => (await m.injectText(x.to, `(守护进程代 ${displayName(x.from)} 打回一次: 这件活是工单里的一份, 你的回复${why}。请按约定收口 —— 末尾一行 \`RESULT: <结论>\`, 交付了文件每个加一行 \`ARTIFACT: <路径> — 一句话\`; 真缺信息就 \`NEED: <问题>\`。)`, undefined, {
|
|
648
|
+
from: { kind: "peer", from: x.from, turn: x.turn, ...(x.asker ? { asker: x.asker } : {}) },
|
|
649
|
+
channel: x.channel,
|
|
650
|
+
envelope: envelopeFor(x.from, x.channel, { turn: x.turn, legs: x.legs, re: true }),
|
|
651
|
+
})).ok,
|
|
599
652
|
untilIdle: m.untilIdle,
|
|
600
653
|
paneLive: m.paneLive,
|
|
601
654
|
nameOf: displayName,
|
|
@@ -603,39 +656,50 @@ const main = async () => {
|
|
|
603
656
|
store: loadJsonMap(cfg.wrc.mirror.receiptsFile),
|
|
604
657
|
handingOff,
|
|
605
658
|
handedOff,
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
replyFor: (to, fromName, since) => m.replyToPeer(to, fromName, since),
|
|
659
|
+
replyFor: (to, fromName, since, turn) => m.replyToPeer(to, fromName, since, turn),
|
|
660
|
+
lastWords: m.lastReply,
|
|
609
661
|
deliver: (to, bodyText, meta) => m.injectText(to, clipMiddle(bodyText), undefined, {
|
|
610
662
|
// receipt:true 是「这一轮不该再生回执」的记录 —— 回环在结构上就不成立:
|
|
611
663
|
// 只有 tell_peer 那条路登记 watcher, 这里是直接注入。
|
|
612
|
-
from: { kind: "peer", from: meta.from, receipt: true, ...(meta.job ? { job: meta.job } : {}) },
|
|
664
|
+
from: { kind: "peer", from: meta.from, receipt: true, status: meta.status, ...(meta.turn ? { turn: meta.turn } : {}), ...(meta.job ? { job: meta.job, done: meta.done, total: meta.total } : {}), ...(meta.asker ? { asker: meta.asker } : {}) },
|
|
613
665
|
channel: meta.channel,
|
|
614
|
-
envelope: renderReceiptEnvelope(displayName(meta.from), meta.channel ? chatNameOf(cfg, meta.channel) : undefined, meta.job ? { job: meta.job, done: meta.done, total: meta.total } : undefined
|
|
666
|
+
envelope: renderReceiptEnvelope(displayName(meta.from), meta.channel ? chatNameOf(cfg, meta.channel) : undefined, meta.job ? { job: meta.job, done: meta.done, total: meta.total, closed: jobs.get(meta.job)?.status !== "open", ...(jobs.get(meta.job)?.kind === "req" ? { req: true } : {}) } : undefined, meta.status, meta.turn || undefined, {
|
|
667
|
+
...(meta.replyTo ? { replyTo: meta.replyTo.kind === "chat" ? chatNameOf(cfg, meta.replyTo.channel) : displayName(meta.replyTo.from) } : {}),
|
|
668
|
+
...(meta.k ? { k: kAttr(meta.k) } : {}),
|
|
669
|
+
...(meta.pending ? { pending: meta.pending } : {}),
|
|
670
|
+
...(audienceName(meta.channel, meta.asker) ? { audience: audienceName(meta.channel, meta.asker) } : {}),
|
|
671
|
+
}),
|
|
615
672
|
}),
|
|
616
673
|
});
|
|
674
|
+
const turnsLine = (xs, target) => renderInFlight(xs, target, displayName);
|
|
617
675
|
// reload 前在飞的回执: 等 mirror 把绑定都恢复了再续守 —— 早一步读 target 会拿不到
|
|
618
676
|
// transcript, 退回按时刻取就可能把上一件事的结论当回执。
|
|
619
677
|
// 交接 (见 handoff.ts): 换会话不换身份。这里给它重开、贴话、读旧 transcript 的能力,
|
|
620
678
|
// 闸、回执清点与断点续做在那边。
|
|
621
679
|
const pingSigs = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping);
|
|
680
|
+
/** 情景记忆 (memory/episodes/<name>.jsonl) 按名字归档; 默认会话没名字时退回 slot / "default"。 */
|
|
681
|
+
const episodeName = (t) => wizards.get(t)?.name || tagOfKey(t) || "default";
|
|
622
682
|
const handoffs = createHandoffs({
|
|
623
683
|
isBusy: m.isBusy,
|
|
624
684
|
sessionId: (t) => m.sessionInfo(t)?.sessionId ?? "",
|
|
625
685
|
restart: (t) => restartFresh(t),
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
686
|
+
// 欠着回执的发话方挂上它的信封: 新会话那一轮就是在答它, 回执照常按信封定位, 频道也接回
|
|
687
|
+
// 原来那一句所在的地方 (私聊不进群)。欠好几份时每份各开一轮 (见 carryRest): 并成一句,
|
|
688
|
+
// 那一轮就只有一个去向, 别的上游拿到的是答给第一份的话。
|
|
689
|
+
inject: async (t, text, owe, channel) => {
|
|
690
|
+
const [first, ...rest] = owe;
|
|
691
|
+
if (!first)
|
|
692
|
+
return m.injectText(t, text, undefined, { channel });
|
|
693
|
+
const r = await m.injectText(t, text, undefined, carryOpts(first));
|
|
694
|
+
if (r.ok && rest.length)
|
|
695
|
+
void carryRest(t, rest);
|
|
696
|
+
return r;
|
|
697
|
+
},
|
|
698
|
+
channelOf: m.currentChannel,
|
|
635
699
|
lastText: m.lastText,
|
|
636
|
-
answeredBefore: (to, from, since, until) => {
|
|
700
|
+
answeredBefore: (to, from, since, until, turn) => {
|
|
637
701
|
const p = m.sessionInfo(to)?.jsonlPath;
|
|
638
|
-
return p ? replyClosedBefore(expandHome(p), displayName(from), since, until, pingSigs) : undefined;
|
|
702
|
+
return p ? replyClosedBefore(expandHome(p), displayName(from), since, until, pingSigs, turn) : undefined;
|
|
639
703
|
},
|
|
640
704
|
receipts,
|
|
641
705
|
// 交接是生命周期事件, 不进群; 失败只捎给它自己 (它调完 handoff_self 拿到的是
|
|
@@ -643,9 +707,110 @@ const main = async () => {
|
|
|
643
707
|
notify: (t, text) => notices.post([t], text),
|
|
644
708
|
log,
|
|
645
709
|
store: loadJsonMap(cfg.wrc.mirror.handoffsFile),
|
|
710
|
+
archive: (e) => {
|
|
711
|
+
if (!e.brief.trim())
|
|
712
|
+
return;
|
|
713
|
+
const name = episodeName(e.target);
|
|
714
|
+
const ok = appendEpisode(episodePath(cfg.daemon.stateDir, name), { at: e.at, kind: "handoff", name, sid: e.sid, nextSid: e.nextSid, text: e.brief });
|
|
715
|
+
if (!ok)
|
|
716
|
+
log.warn({ target: e.target, name }, "handoff episode not archived");
|
|
717
|
+
},
|
|
646
718
|
});
|
|
719
|
+
/** 交接续答一份欠账的那一句: 挂发话方的信封, 出处与频道都是它当初那一句的。 */
|
|
720
|
+
function carryOpts(o) {
|
|
721
|
+
return {
|
|
722
|
+
from: { kind: "peer", from: o.from, ...(o.turn ? { turn: o.turn } : {}), ...(o.channel ? { public: true } : {}), ...(o.asker ? { asker: o.asker } : {}) },
|
|
723
|
+
channel: o.channel,
|
|
724
|
+
envelope: envelopeFor(o.from, o.channel, o.turn ? { turn: o.turn } : undefined),
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
/** 简报那一轮之后, 余下的欠账一份一轮: 等它接住上一句 (至多 20s 动起来) 并答完, 再贴下一份。 */
|
|
728
|
+
async function carryRest(t, owe) {
|
|
729
|
+
for (const o of owe) {
|
|
730
|
+
for (const end = Date.now() + 20_000; Date.now() < end && (await m.idleNow(t));)
|
|
731
|
+
await sleep(1000);
|
|
732
|
+
await m.untilIdle(t, 3600_000);
|
|
733
|
+
const r = await m.injectText(t, `(交接续答)接着上面的简报: 你交接前还欠 ${displayName(o.from)} 一个交代${o.turn ? ` (件 \`${o.turn}\`)` : ""} —— 现在只就这件收口。`, undefined, carryOpts(o));
|
|
734
|
+
log.info({ mod: "handoff", target: t, from: o.from, turn: o.turn, ok: r.ok, reason: r.reason }, "handoff: 欠账续答");
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
/** `self` 此刻这一轮派出去的活, 结论该交给谁 (见 receipts.parentOf)。只读它自己
|
|
738
|
+
* transcript 里开这一轮的那句 user 行的信封: attachment 上的 channel 在注入时就被
|
|
739
|
+
* 排队的下一句改写了, transcript 才记着「这一轮是谁发起的」, reload 后也读得到。 */
|
|
740
|
+
/** `target` 手上这一轮是哪件 peer 活 —— 认开头那句的信封: 有件号按件号, 老信封按发话方。 */
|
|
741
|
+
/** `target` 手上这一轮若是某个 wizard 派来的活: 发话方的称呼 (`.x`) 与件号。人 / 定时 / 回执轮 = undefined。 */
|
|
742
|
+
const openingAsk = (target) => {
|
|
743
|
+
const p = m.sessionInfo(target)?.jsonlPath;
|
|
744
|
+
const env = p ? openingTurn(expandHome(p), pingSigs)?.env : undefined;
|
|
745
|
+
return env?.kind === "peer" && !env.receipt ? { from: env.from, ...(env.turn ? { turn: env.turn } : {}) } : undefined;
|
|
746
|
+
};
|
|
747
|
+
const currentAsk = (target) => {
|
|
748
|
+
const env = openingAsk(target);
|
|
749
|
+
if (!env)
|
|
750
|
+
return () => false;
|
|
751
|
+
return (x) => (env.turn ? x.turn === env.turn : displayName(x.from) === env.from);
|
|
752
|
+
};
|
|
753
|
+
/** 打断 `target` 会不会误伤别人的活: 它正忙, 且这一轮不是调用方派的 —— 别的 wizard 派的、
|
|
754
|
+
* 人说的、定时任务放的 → 那一方的称呼。回执轮是它自己那件活的延续, 不算别人的。 */
|
|
755
|
+
const othersTurn = async (target, self) => {
|
|
756
|
+
if (await m.idleNow(target))
|
|
757
|
+
return undefined;
|
|
758
|
+
const p = m.sessionInfo(target)?.jsonlPath;
|
|
759
|
+
const env = p ? openingTurn(expandHome(p), pingSigs)?.env : undefined;
|
|
760
|
+
if (env?.receipt)
|
|
761
|
+
return undefined;
|
|
762
|
+
if (env?.kind === "peer")
|
|
763
|
+
return env.from === displayName(self) ? undefined : { from: env.from, ...(env.turn ? { turn: env.turn } : {}) };
|
|
764
|
+
return { from: env?.kind === "task" ? env.from : env?.from ? `人 (${env.from})` : "人" };
|
|
765
|
+
};
|
|
766
|
+
const openingOfSelf = (self) => {
|
|
767
|
+
const p = m.sessionInfo(self)?.jsonlPath;
|
|
768
|
+
return p ? openingTurn(expandHome(p), pingSigs) : undefined;
|
|
769
|
+
};
|
|
770
|
+
const parentKOf = (self) => {
|
|
771
|
+
const opening = openingOfSelf(self);
|
|
772
|
+
// 不用 channelOf: 它把私聊轮 ("") 回落成 home, 私聊轮派出的活就会续回 home 群。
|
|
773
|
+
return receipts.parentOf(self, opening?.env, m.currentChannel(self), String(opening?.ms ?? 0));
|
|
774
|
+
};
|
|
775
|
+
/** `self` 此刻这一轮的链头 (见 Asker), 同样认开头那句的信封: 人在群里说的 → 信封上的
|
|
776
|
+
* userid 与群; 与人的单聊里没挂信封 → 那个人; 同伴派的活 / 回执 → 登记那件活时记下的;
|
|
777
|
+
* 定时任务、CLI 敲字、认不出 → undefined (如实不知道, 不猜)。 */
|
|
778
|
+
const askerOf = (self) => {
|
|
779
|
+
const env = openingOfSelf(self)?.env;
|
|
780
|
+
const home = baseOfKey(self);
|
|
781
|
+
if (!env)
|
|
782
|
+
return home.startsWith("user:") ? { who: home, chat: home } : undefined;
|
|
783
|
+
if (env.kind === "human") {
|
|
784
|
+
const chat = env.chat ? chatBaseOf(cfg, env.chat) : home;
|
|
785
|
+
return chat ? { who: `user:${env.from}`, chat } : undefined;
|
|
786
|
+
}
|
|
787
|
+
return env.kind === "peer" ? receipts.askerOf(self, env) : undefined;
|
|
788
|
+
};
|
|
647
789
|
// 交接先续做: 它的闸要在回执续守之前立起来, 否则续守的 watcher 会读到换了一半的会话。
|
|
648
790
|
void m.restored.then(() => { handoffs.resume(); receipts.resume(); });
|
|
791
|
+
// 闲置追问 (见 idle-nudge.ts): 对方持续闲着又没回执 → 代发话方同件号问一句进展, 答复照常回执。
|
|
792
|
+
// 注入照抄工单打回 (rebound) 那条: 私聊信封挂发话方的名义与件号, 父 k 等由 reask 沿用原件。
|
|
793
|
+
void m.restored.then(() => startIdleNudge({
|
|
794
|
+
inflight: () => receipts.states(),
|
|
795
|
+
idleNow: m.idleNow,
|
|
796
|
+
handingOff,
|
|
797
|
+
jobOpen: (id) => jobs.get(id)?.status === "open",
|
|
798
|
+
policy: () => ({ afterMs: cfg.wrc.mirror.idleNudge.afterMin * 60_000, max: cfg.wrc.mirror.idleNudge.max }),
|
|
799
|
+
ask: async ({ from, to, turn, text }) => {
|
|
800
|
+
const x = receipts.reask(from, to, turn);
|
|
801
|
+
if (!x)
|
|
802
|
+
return false;
|
|
803
|
+
ledger.touch(from, turn);
|
|
804
|
+
return (await m.injectText(to, text, undefined, {
|
|
805
|
+
from: { kind: "peer", from, turn, ...(x.asker ? { asker: x.asker } : {}) },
|
|
806
|
+
channel: x.channel,
|
|
807
|
+
envelope: envelopeFor(from, x.channel, { turn, legs: x.legs, re: true }),
|
|
808
|
+
})).ok;
|
|
809
|
+
},
|
|
810
|
+
nameOf: displayName,
|
|
811
|
+
store: loadJsonMap(join(cfg.daemon.stateDir, "idle-nudges.json")),
|
|
812
|
+
log: log.child({ mod: "idle-nudge" }),
|
|
813
|
+
}));
|
|
649
814
|
/** 入参里的地址: 新字段 `name`, 老 MCP 进程 (正在跑的 wizard) 仍在传 `tag`。 */
|
|
650
815
|
const addrOf = (b) => String(b.name ?? b.tag ?? "");
|
|
651
816
|
/** 撞上的名字属于一个静默超过一天的 wizard → 顶掉它 (见 evictStaleName), 同群的
|
|
@@ -656,7 +821,7 @@ const main = async () => {
|
|
|
656
821
|
return false;
|
|
657
822
|
const slot = !!tagOfKey(gone.target);
|
|
658
823
|
log.child({ mod: "wizard" }).info({ target: gone.target, name: gone.name, slot }, "stale name evicted");
|
|
659
|
-
postRoster(baseOfKey(gone.target), [],
|
|
824
|
+
postRoster(baseOfKey(gone.target), [], { op: "ended", name: `.${gone.name}`, why: slot ? "evicted (idle >1d, name reused)" : "name yielded (idle >1d)" }, subjectOf(gone));
|
|
660
825
|
return true;
|
|
661
826
|
};
|
|
662
827
|
/** 给一个要出生的 wizard 挑槽位 (target key) 并落定名字。名字全局唯一:
|
|
@@ -728,13 +893,24 @@ const main = async () => {
|
|
|
728
893
|
const refs = (b.to ?? []).map((r) => (r ?? "").trim()).filter(Boolean);
|
|
729
894
|
const bad = refs.filter((r) => !chatBaseOf(cfg, r));
|
|
730
895
|
if (bad.length) {
|
|
731
|
-
json(res, 400, { ok: false, reason: `认不出这些聊天: ${bad.join(", ")} (
|
|
896
|
+
json(res, 400, { ok: false, reason: `认不出这些聊天: ${bad.join(", ")} (wizard_roster 每行有 home 聊天名, 人侧 /chats; 没起名的聊天寻址不到)` });
|
|
732
897
|
return;
|
|
733
898
|
}
|
|
734
899
|
const dests = [...new Set(refs.length ? refs.map((r) => chatBaseOf(cfg, r)) : [channelOf(self)])];
|
|
900
|
+
// 顶层模式: 只有顶层 wizard 对人说话。拦的是「这一轮在为同伴干活」的: 同伴私聊派来的活, 或
|
|
901
|
+
// 回执轮但那件活是为上游同伴派的 (k 是 peer)。人说的、定时任务放的、旁支 (chain:false, 无 k)
|
|
902
|
+
// 的回执轮都不拦; 管家只在自己的群里豁免。
|
|
903
|
+
const env = openingOfSelf(self)?.env;
|
|
904
|
+
const belowTop = env?.kind === "peer" && (env.receipt ? kOfAttr(env.k ?? "")?.kind === "peer" : env.private);
|
|
905
|
+
const muted = dests.filter((d) => chatPolicyOf(cfg, d).topOnly && !(!tagOfKey(self) && baseOfKey(self) === d));
|
|
906
|
+
if (muted.length && belowTop) {
|
|
907
|
+
json(res, 409, { ok: false, topOnly: true, reason: `${muted.map((d) => chatNameOf(cfg, d) || d).join(", ")} 开着顶层模式: 你这一轮是同伴派来的活, 不对人说话 —— 要说的写进终句 (\`RESULT:\` / \`NEED:\`), 由派你的那个代你向人交代` });
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
const asker = askerOf(self);
|
|
735
911
|
for (const dest of dests) {
|
|
736
912
|
notifyChat(dest, `${relayLabel(self)}\n\n${content}`);
|
|
737
|
-
recordPost({ target: self, channel: dest, body: content });
|
|
913
|
+
recordPost({ target: self, channel: dest, body: content, ...(asker ? { asker } : {}) });
|
|
738
914
|
}
|
|
739
915
|
json(res, 200, { ok: true, sent: dests.map((d) => chatNameOf(cfg, d) || d) });
|
|
740
916
|
});
|
|
@@ -755,6 +931,7 @@ const main = async () => {
|
|
|
755
931
|
// 截断的视口加一层 TUI 装饰, 而「它卡在哪」transcript 答得更准 —— 不在转圈
|
|
756
932
|
// 却悬着工具调用, 就是停在审批卡 / 本地弹窗上等人点。
|
|
757
933
|
const peek = await m.peekTurns(target, turns);
|
|
934
|
+
const inflight = turnsLine(receipts.states(), target);
|
|
758
935
|
const waiting = peek.waiting ?? [];
|
|
759
936
|
const state = peek.busy
|
|
760
937
|
? "正在生成"
|
|
@@ -766,7 +943,7 @@ const main = async () => {
|
|
|
766
943
|
name: peerAddress(cfg, self, target),
|
|
767
944
|
busy: peek.busy ?? false,
|
|
768
945
|
...(waiting.length ? { waiting } : {}),
|
|
769
|
-
text: [`${displayName(target)} · ${state}${foreign ? ` · 住在群 ${chatNameOf(cfg, target) || "(未命名)"}` : ""}`, peek.dialog ?? `(${peek.reason})`].join("\n"),
|
|
946
|
+
text: [`${displayName(target)} · ${state}${foreign ? ` · 住在群 ${chatNameOf(cfg, target) || "(未命名)"}` : ""}`, ...(inflight ? [inflight] : []), peek.dialog ?? `(${peek.reason})`].join("\n"),
|
|
770
947
|
});
|
|
771
948
|
});
|
|
772
949
|
// ── 聊天记录 ────────────────────────────────────────────────────────
|
|
@@ -785,7 +962,7 @@ const main = async () => {
|
|
|
785
962
|
name: displayName(t.target),
|
|
786
963
|
jsonlPath: t.jsonlPath,
|
|
787
964
|
homeChat: chatNameOf(cfg, home),
|
|
788
|
-
homeHuman: home.startsWith("user:") ? home.slice(5) :
|
|
965
|
+
homeHuman: home.startsWith("user:") ? home.slice(5) : UNKNOWN_HUMAN,
|
|
789
966
|
since: rec?.clonedFrom ? rec.bornAt : 0,
|
|
790
967
|
};
|
|
791
968
|
};
|
|
@@ -809,7 +986,7 @@ const main = async () => {
|
|
|
809
986
|
// 什么都没给 = 调用方这一轮所在的群; 只给 target = 调用方与它的往来。
|
|
810
987
|
const base = chatRef ? chatBaseOf(cfg, chatRef) : roleRef || targetRef ? "" : channelOf(self);
|
|
811
988
|
if (chatRef && !base) {
|
|
812
|
-
json(res, 404, { ok: false, reason: `认不出聊天 '${chatRef}' (
|
|
989
|
+
json(res, 404, { ok: false, reason: `认不出聊天 '${chatRef}' (wizard_roster 每行有 home 聊天名, 人侧 /chats)` });
|
|
813
990
|
return;
|
|
814
991
|
}
|
|
815
992
|
const role = roleRef ? logRole(self, roleRef) : targetRef ? { name: displayName(self), target: self } : undefined;
|
|
@@ -855,96 +1032,219 @@ const main = async () => {
|
|
|
855
1032
|
// 等待」: 调用方注入完就返回, 对方干完那一轮的结论由守护进程自动送回来 (receipts)。
|
|
856
1033
|
// `/peers/send` 保留为同一个处理函数: 正在跑的 wizard 的 MCP 进程是旧代码, 换名字
|
|
857
1034
|
// 不能把它们的通路掐断。
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
1035
|
+
// 冷与否 = 缓存最近一次被碰 (最近一次真实请求的开始; 读不到退回 mtime) 距今多久 vs 它实际按多长 TTL 写的
|
|
1036
|
+
// (与 route_candidates 同一口径)。
|
|
1037
|
+
const touchOf = (p) => { try {
|
|
1038
|
+
return cacheTouchMs(p) || statSync(p).mtimeMs;
|
|
1039
|
+
}
|
|
1040
|
+
catch {
|
|
1041
|
+
return 0;
|
|
1042
|
+
} };
|
|
1043
|
+
const ttlOf = (p) => (cacheTtlSec(p) || cfg.wrc.mirror.keepalive.ttlSec) * 1000;
|
|
1044
|
+
// 被冷门控退回过的 `self\0target` → 到期时刻: 老 MCP 进程原样重发即视为 force。
|
|
1045
|
+
const coldRefused = new Map();
|
|
1046
|
+
/** tell_peer 的本体: 以 `self` 的名义说一句 —— HTTP 路由与 dispatch 共用。`extra` = 挂在信封后面的
|
|
1047
|
+
* 一段 (只进对方输入框, 不进 `text`: 公开时 text 会成群气泡)。 */
|
|
1048
|
+
const tellPeerAs = async (self, body, extra = "") => {
|
|
864
1049
|
const text = (body.text ?? "").toString();
|
|
865
1050
|
if (!text.trim()) {
|
|
866
|
-
|
|
867
|
-
return;
|
|
868
|
-
}
|
|
869
|
-
const jobId = (body.job ?? "").trim();
|
|
870
|
-
if (jobId) {
|
|
871
|
-
const jc = checkJob(jobId);
|
|
872
|
-
if (!jc.ok) {
|
|
873
|
-
json(res, jc.status, { ok: false, reason: jc.reason });
|
|
874
|
-
return;
|
|
875
|
-
}
|
|
1051
|
+
return { status: 400, body: { ok: false, reason: "text required" } };
|
|
876
1052
|
}
|
|
1053
|
+
// 这句话要对方怎么接 (priority 管何时投, receipt 管要不要回执, 这个管它怎么接):
|
|
1054
|
+
// task 一件活 (默认) / ask 只答一问 / fyi 知会 —— fyi 不要回执、不进工单、不进群。
|
|
1055
|
+
const rawKind = body.kind;
|
|
1056
|
+
const kind = rawKind === "ask" || rawKind === "fyi" ? rawKind : "task";
|
|
877
1057
|
const r = resolvePeer(self, addrOf(body));
|
|
878
1058
|
if (!r.ok) {
|
|
879
|
-
|
|
880
|
-
return;
|
|
1059
|
+
return { status: r.status, body: { ok: false, reason: r.reason, candidates: r.candidates } };
|
|
881
1060
|
}
|
|
882
1061
|
const { target, foreign } = r;
|
|
1062
|
+
// 件号先于一切: `re` 续问沿用那件活的件号、工单与频道 (见 receipts.prepare)。
|
|
1063
|
+
const re = (body.re ?? "").toString().trim();
|
|
1064
|
+
const turn = receipts.prepare(self, target, re || undefined);
|
|
1065
|
+
// 续问只在那个工单还开着时沿用它: 收了工的工单不再收成员, 续问照样能发。
|
|
1066
|
+
// 没带工单的一句顶掉一份还没落定的工单活, 也算在那张工单上 (receipts.register 同理
|
|
1067
|
+
// 继承): 不然换个不带 job 的 tell_peer 就绕开了预算。
|
|
1068
|
+
const open = receipts.pending(self, target);
|
|
1069
|
+
const isOpen = (id) => (id && jobs.get(id)?.status === "open" ? id : "");
|
|
1070
|
+
const askedJob = (body.job ?? "").trim();
|
|
1071
|
+
// 挂起等子活的那份不算被顶掉: 它会按件号停放、自己计数, 新的一件不继承它的工单。
|
|
1072
|
+
// 返工兜底: 回执槽 24h 过期 / 被顶掉后, 带了 re 却落空成新件, 不带 job 就和根单断了 ——
|
|
1073
|
+
// 只在这种情形下, 发话方是某张开着的需求根单的 owner、目标恰是它的 lead 时才补上;
|
|
1074
|
+
// 没带 re 的普通 task / ask / dispatch 一律不并进根单。
|
|
1075
|
+
const leadJob = jobs.all().find((j) => j.kind === "req" && j.status === "open" && j.owner === self && j.members.some((x) => x.target === target && x.role === "lead"))?.id ?? "";
|
|
1076
|
+
const jobId = kind === "fyi" ? "" : askedJob || isOpen(turn.job) || (open?.deferred ? "" : isOpen(open?.job)) || (re && turn.reUnknown ? leadJob : "");
|
|
1077
|
+
if (jobId) {
|
|
1078
|
+
const jc = checkJob(jobId, target, !!open?.need && open.turn === turn.turn);
|
|
1079
|
+
if (!jc.ok) {
|
|
1080
|
+
return { status: jc.status, body: { ok: false, reason: jc.reason } };
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
883
1083
|
// 公开与否由发话方 (LLM) 判断: 公开 = 在它这一轮的公开频道里说, 气泡进群、对方
|
|
884
1084
|
// 那一轮的回复也发进这个群; 私聊 (默认) = 只落双方的 rolepage, 回复靠 wait_peer 取。
|
|
885
1085
|
// 只有两端都是登记在册的 wizard 才能当着人说: 裸 target 冒充的发话方、forget
|
|
886
1086
|
// 掉的分身不是谁, 它们的往来进群只会留下一段人找不到主的对话 —— 降为私聊。
|
|
887
|
-
|
|
888
|
-
|
|
1087
|
+
// 工单里的派活一律私聊: 人看不懂 fan-out 的过程, 群里只留发起者自己的收口。
|
|
1088
|
+
// 知会不进群: 公开信封要对方「照对人说话的方式答」, 一句「收到」会被发给一群人。
|
|
1089
|
+
// 顶层模式 (chatPolicy.topOnly): 那个群里 wizard 之间不当着人说话 —— 公开与否不归模型判断,
|
|
1090
|
+
// 续问沿用的公开频道也一样降为私聊; 人只从顶层 wizard 的收口里听到结论。
|
|
1091
|
+
const wantPublic = !jobId && kind !== "fyi" && (turn.channel !== undefined ? !!turn.channel : body.public === true) && !!wizards.get(self) && !!wizards.get(target);
|
|
1092
|
+
const topOnly = wantPublic && chatPolicyOf(cfg, turn.channel || channelOf(self)).topOnly;
|
|
1093
|
+
const isPublic = wantPublic && !topOnly;
|
|
1094
|
+
const channel = isPublic ? turn.channel || channelOf(self) : "";
|
|
889
1095
|
// Injecting into your own pane would type into the box you're generating
|
|
890
1096
|
// from — Claude Code queues it and the caller deadlocks waiting for itself.
|
|
891
1097
|
if (target === self) {
|
|
892
|
-
|
|
893
|
-
return;
|
|
1098
|
+
return { status: 400, body: { ok: false, reason: "refusing to inject into the calling session itself" } };
|
|
894
1099
|
}
|
|
895
1100
|
// 它正在交接: 这句话等它换完会话再进 —— 投进旧会话会被交接一并带走, 投进重开中的
|
|
896
1101
|
// 空 pane 会 resume 回旧 sid (见 handoff.ts)。发话时刻取在这之后。
|
|
897
1102
|
await handedOff(target);
|
|
898
|
-
//
|
|
899
|
-
//
|
|
900
|
-
//
|
|
901
|
-
//
|
|
902
|
-
|
|
1103
|
+
// 投递策略。对方闲着三者一样 —— 立刻投; 只在它正忙时分岔:
|
|
1104
|
+
// normal (默认) 等它这一轮结束再投 —— 派新活的常态: 两个 wizard 同时找第三个时,
|
|
1105
|
+
// 两段文本会挤进同一个输入框、被当成一轮读掉, 这在协同网络里不是边角情况而是常态;
|
|
1106
|
+
// urgent 先打断它这一轮 (与 stop_wizard 的 interrupt 同一下 Esc) 再投 —— 紧急改道;
|
|
1107
|
+
// now 不等不打断, 落进输入框由 CLI 并进它这一轮 —— 插话、补一句、答它的问。
|
|
1108
|
+
// 老 MCP 进程只认 `when` (默认 now): idle ≡ normal, now ≡ now。都没给时, `re` 续问默认 now ——
|
|
1109
|
+
// 续问是同一场来回里的应答, 对方可能正挂在 wait_peer 上等你, 等它闲下来就互相干等到超时。
|
|
1110
|
+
const bp = body;
|
|
1111
|
+
const asked = bp.priority === "urgent" ? "urgent"
|
|
1112
|
+
: bp.priority === "normal" ? "normal"
|
|
1113
|
+
: bp.priority === "now" || bp.when === "now" ? "now"
|
|
1114
|
+
: bp.when === "idle" ? "normal"
|
|
1115
|
+
: re ? "now" : "normal";
|
|
903
1116
|
const waitSec = Math.min(Math.max(Number(body.waitSec ?? 600) || 600, 10), 3600);
|
|
1117
|
+
// 冷门控: 派新活 (task、不是续问、不是插话) 给一个冷且大的 wizard, 先退回一行让派活的
|
|
1118
|
+
// 算账 —— 唤醒它要整段重写缓存, 点名或职责对口只说明相关, 不说明划算。`force:true`
|
|
1119
|
+
// 是「真依赖它那段上下文」的明说。老 MCP 进程的 schema 里没有 force (请求里压根没这个键,
|
|
1120
|
+
// 新的一律显式带上): 同一对 10 分钟内原样再发一次就算 force, 不然它们被拦下就没有出路。
|
|
1121
|
+
const force = body.force;
|
|
1122
|
+
if (kind === "task" && !re && asked !== "now" && force !== true) {
|
|
1123
|
+
const gk = `${self}\0${target}`;
|
|
1124
|
+
const retried = force === undefined && (coldRefused.get(gk) ?? 0) > Date.now();
|
|
1125
|
+
coldRefused.delete(gk);
|
|
1126
|
+
const gi = retried ? undefined : m.sessionInfo(target);
|
|
1127
|
+
const gate = gi ? coldGateOf(`.${displayName(target).replace(/^\./, "")}`, gi.contextTokens, touchOf(gi.jsonlPath), Date.now(), ttlOf(gi.jsonlPath)) : "";
|
|
1128
|
+
if (gate) {
|
|
1129
|
+
if (force === undefined)
|
|
1130
|
+
coldRefused.set(gk, Date.now() + 10 * 60_000);
|
|
1131
|
+
return { status: 409, body: { ok: false, gated: "cold", reason: gate + (force === undefined ? " (你的 MCP 是旧版、没有 force 参数: 10 分钟内原样再发一次即视为 force)" : "") } };
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
904
1134
|
// 「忙」= 这一轮还没结束, 或停在审批上等人 —— 都不是能接新活的时候。
|
|
905
1135
|
const wasBusy = !(await m.idleNow(target));
|
|
1136
|
+
// urgent 只打断你自己派的那一轮 (或它自己的回执轮): 手上若是别的 wizard / 人 / 定时任务的活,
|
|
1137
|
+
// 打断就误伤了那件 —— 降成 normal, 等它这一轮做完再投。
|
|
1138
|
+
const others = asked === "urgent" && wasBusy ? await othersTurn(target, self) : undefined;
|
|
1139
|
+
const policy = others ? "normal" : asked;
|
|
906
1140
|
let waitedMs = 0;
|
|
907
|
-
|
|
1141
|
+
let interrupted;
|
|
1142
|
+
if (policy === "normal" && wasBusy) {
|
|
908
1143
|
const t0 = Date.now();
|
|
909
1144
|
// 它那一轮一结束就投: 注册表 status 翻 idle 是事件, 不抽样也不等稳定期。
|
|
910
1145
|
const wr = await m.untilIdle(target, waitSec * 1000);
|
|
911
1146
|
waitedMs = Date.now() - t0;
|
|
912
1147
|
if (!wr.idle) {
|
|
913
|
-
|
|
914
|
-
return;
|
|
1148
|
+
return { status: 409, body: { ok: false, target, foreign, wasBusy, waitedMs, reason: `它一直在忙, ${waitSec}s 内没闲下来 —— peek_peer 看看它卡在哪; 真急就 priority:"urgent" 打断它` } };
|
|
915
1149
|
}
|
|
916
1150
|
}
|
|
1151
|
+
if (policy === "urgent" && wasBusy) {
|
|
1152
|
+
// 只按 Esc, 不走 /stop 的 teardown: 那会清掉它的注入队列 (别人排着的话一起丢)
|
|
1153
|
+
// 并暂停 keepalive —— 这里打断它是为了马上接着干, 不是让它安静。
|
|
1154
|
+
const t0 = Date.now();
|
|
1155
|
+
const esc = await m.interruptPane(target);
|
|
1156
|
+
// 等 Esc 生效、这一轮真收住再投; 收不住也照投 —— 落进输入框排在后面, 退化成 now。
|
|
1157
|
+
interrupted = esc.ok && (await m.untilIdle(target, 15_000)).idle;
|
|
1158
|
+
waitedMs = Date.now() - t0;
|
|
1159
|
+
}
|
|
917
1160
|
// 时刻取在注入**之前**: 晚于那一句落盘的话, 回执定位的下界就偏了。
|
|
918
1161
|
const at = Date.now();
|
|
1162
|
+
// 唤醒代价同样要在注入之前量: 投进去 transcript 一动, 就看不出它冷了多久。
|
|
1163
|
+
const info = m.sessionInfo(target);
|
|
1164
|
+
const wake = info?.jsonlPath
|
|
1165
|
+
? wakeNoteOf(info.contextTokens, touchOf(info.jsonlPath), at, ttlOf(info.jsonlPath))
|
|
1166
|
+
: "";
|
|
1167
|
+
const deadlineSec = body.deadline;
|
|
1168
|
+
// 回执: 对方干完那一轮, 守护进程把它的结论自动送回来 (默认开)。`receipt:false`
|
|
1169
|
+
// 是"放出去就不管了"的那种派活; fyi 一定不要。对方的信封据此改口, 不再让它收口。
|
|
1170
|
+
const wantReceipt = kind !== "fyi" && body.receipt !== false;
|
|
1171
|
+
const deadlineAt = deadlineOf(at, deadlineSec === undefined ? undefined : Number(deadlineSec));
|
|
1172
|
+
const asker = askerOf(self);
|
|
919
1173
|
const inj = await m.injectText(target, text, undefined, {
|
|
920
|
-
from: { kind: "peer", from: self, ...(jobId ? { job: jobId } : {}), ...(isPublic ? { public: true } : {}) },
|
|
1174
|
+
from: { kind: "peer", from: self, turn: turn.turn, ...(jobId ? { job: jobId } : {}), ...(isPublic ? { public: true } : {}), ...(asker ? { asker } : {}) },
|
|
921
1175
|
channel,
|
|
922
|
-
envelope: envelopeFor(self, channel
|
|
1176
|
+
envelope: envelopeFor(self, channel, {
|
|
1177
|
+
turn: turn.turn,
|
|
1178
|
+
legs: turn.legs,
|
|
1179
|
+
...(turn.legs > 1 ? { re: true } : {}),
|
|
1180
|
+
// 期限只在发话方明说时写给对方看: 默认的一小时不值得每轮多一句。
|
|
1181
|
+
...(deadlineSec !== undefined ? { deadline: deadlineAt } : {}),
|
|
1182
|
+
...(kind !== "task" ? { act: kind } : {}),
|
|
1183
|
+
...(wantReceipt ? {} : { quiet: true }),
|
|
1184
|
+
}) + extra,
|
|
923
1185
|
});
|
|
924
|
-
//
|
|
925
|
-
//
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
1186
|
+
// 注入失败就不守 —— 没有问话, 也不会有回答。不要回执 (receipt:false / fyi) 就不登记:
|
|
1187
|
+
// 一份不守的槽会以同一对的 key 顶掉发话方还在等它答的那件活, 那件的回执就再也来不了。
|
|
1188
|
+
// 父 k: 默认这件活算发话方此刻在答的那件的子活 (链式续回); `chain:false` = 旁支,
|
|
1189
|
+
// 回执照常回来, 但不挂住它给上游的交代 —— 只有发话方知道这件活是不是为上游派的。
|
|
1190
|
+
const k = body.chain === false ? undefined : parentKOf(self);
|
|
1191
|
+
if (inj.ok && wantReceipt)
|
|
1192
|
+
receipts.register({ from: self, to: target, channel, job: jobId, at, turn: turn.turn, legs: turn.legs, deadlineAt, ...(k ? { k } : {}), ...(asker ? { asker } : {}) });
|
|
1193
|
+
if (inj.ok && wantReceipt) {
|
|
1194
|
+
if (turn.legs > 1)
|
|
1195
|
+
ledger.touch(self, turn.turn);
|
|
1196
|
+
else
|
|
1197
|
+
ledger.open({ turn: turn.turn, owner: self, to: target, text, at, ...(jobId ? { job: jobId } : {}), ...(k ? { for: k } : {}), ...(asker ? { asker } : {}) });
|
|
1198
|
+
}
|
|
929
1199
|
// 工单成员照旧记账 (收工那一条会列出各自那段活); 公开的那一句在群里成气泡。
|
|
930
|
-
|
|
931
|
-
|
|
1200
|
+
// 续问 (re) 不是一段新活: 工单页与留档里该列的仍是当初派的那段。
|
|
1201
|
+
if (inj.ok && jobId) {
|
|
1202
|
+
const role = roleParam(body.role);
|
|
1203
|
+
// G2: 需求根单的 owner 答了 lead 的 NEED —— 方案闸过了 (只记第一次)。
|
|
1204
|
+
const rj = jobs.get(jobId);
|
|
1205
|
+
if (rj?.kind === "req" && self === rj.owner && !rj.gate?.g2 && !!open?.need && open.turn === turn.turn && rj.members.some((x) => x.target === target && x.role === "lead"))
|
|
1206
|
+
jobs.mark(jobId, { gate: { g2: Date.now() } });
|
|
1207
|
+
jobs.attach(jobId, { target, task: turn.legs > 1 ? "" : text, spawned: false, ...(role ? { role } : {}) }, kind === "ask" ? "ask" : "task");
|
|
1208
|
+
}
|
|
1209
|
+
const spent = inj.ok && jobId ? jobs.spend(jobId) : undefined;
|
|
932
1210
|
if (inj.ok && isPublic)
|
|
933
1211
|
relayPeer(self, target, text, channel);
|
|
934
1212
|
// `wasBusy` 是给调用方的判断依据: 立刻投给一个正在生成的会话, 这句话会排在
|
|
935
1213
|
// 它这一轮后面, 而不是马上被读到。
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
: {}),
|
|
947
|
-
|
|
1214
|
+
return { status: inj.ok ? 200 : 502, body: {
|
|
1215
|
+
...inj,
|
|
1216
|
+
name: peerAddress(cfg, self, target),
|
|
1217
|
+
public: isPublic,
|
|
1218
|
+
...(topOnly ? { topOnly: "这个群开着顶层模式: wizard 之间一律私聊, 已按私聊投出; 结论回到你这儿, 由你 (或你的上游) 对人收口" } : {}),
|
|
1219
|
+
priority: policy,
|
|
1220
|
+
...(kind !== "task" ? { kind } : {}),
|
|
1221
|
+
...(kind === "fyi" && askedJob ? { jobIgnored: true } : {}),
|
|
1222
|
+
...(others ? { urgentDowngraded: `它此刻在做 ${others.from} 派的活, 没打断它 —— 按 normal 等它这一轮做完再投` } : {}),
|
|
1223
|
+
wasBusy,
|
|
1224
|
+
...(interrupted !== undefined ? { interrupted } : {}),
|
|
1225
|
+
...(waitedMs ? { waitedMs } : {}),
|
|
1226
|
+
...(jobId ? { job: jobId } : {}),
|
|
1227
|
+
...(inj.ok ? { turn: turn.turn } : {}),
|
|
1228
|
+
...(spent?.maxTurns ? { budget: `${spent.turns}/${spent.maxTurns}${spent.turns >= spent.maxTurns ? " —— 用完了, 这是最后一次派活, 下一步收口" : ""}` } : {}),
|
|
1229
|
+
...(inj.ok && wake ? { wakeCost: wake } : {}),
|
|
1230
|
+
...(turn.reUnknown ? { reUnknown: true, reNote: `re "${re}" 不是你正在等 ${displayName(target)} 答的那件, 按新活发出` } : {}),
|
|
1231
|
+
// 回执怎么回来 —— 写在回包里, 调用方 (模型) 不必从工具描述里回忆。
|
|
1232
|
+
...(inj.ok && wantReceipt
|
|
1233
|
+
? { receipt: "它干完那一轮, 结论会作为新的一轮自动进到你这里 —— 不要挂在 wait_peer 上等, 接着干你自己的事" }
|
|
1234
|
+
: {}),
|
|
1235
|
+
...(inj.ok && wantReceipt && k?.kind === "peer"
|
|
1236
|
+
? { chained: `这件活算在你答 ${displayName(k.from)} 的那件 (${k.turn}) 名下: 它回来之前, 你这一轮的终句不会先交给 ${displayName(k.from)}; 与那件活无关就带 chain:false 发` }
|
|
1237
|
+
: {}),
|
|
1238
|
+
} };
|
|
1239
|
+
};
|
|
1240
|
+
const tellPeer = async (req, res) => {
|
|
1241
|
+
const { self, body } = await readPeerBody(req);
|
|
1242
|
+
if (!self) {
|
|
1243
|
+
json(res, 400, { ok: false, reason: "cannot resolve caller session" });
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
const r = await tellPeerAs(self, body);
|
|
1247
|
+
json(res, r.status, r.body);
|
|
948
1248
|
};
|
|
949
1249
|
http.register("POST /peers/tell", tellPeer);
|
|
950
1250
|
http.register("POST /peers/send", tellPeer); // 老 MCP 进程还在调这个名字
|
|
@@ -994,13 +1294,16 @@ const main = async () => {
|
|
|
994
1294
|
// 取走就占位: 同一段话不会再作为回执 paste 进来一遍 (见 receipts.claim)。
|
|
995
1295
|
const full = wr.idle ? m.lastReply(h.target, receipts.sentAt(self, h.target)) : "";
|
|
996
1296
|
const already = full ? receipts.claim(self, h.target) : false;
|
|
997
|
-
const
|
|
1297
|
+
const closing = parseClosing(full);
|
|
1298
|
+
const result = closing.kind === "result" ? closing.text : "";
|
|
998
1299
|
return {
|
|
999
1300
|
name: h.address,
|
|
1000
1301
|
idle: wr.idle,
|
|
1001
1302
|
// 回执早一步到了: 这段话已经作为新的一轮进过你的会话, 别再处理第二遍。
|
|
1002
1303
|
...(already ? { delivered: true } : {}),
|
|
1003
1304
|
...(wr.reason ? { reason: wr.reason } : {}),
|
|
1305
|
+
...(closing.kind === "need" ? { need: closing.text } : {}),
|
|
1306
|
+
...(closing.artifacts.length ? { artifacts: closing.artifacts } : {}),
|
|
1004
1307
|
...(result
|
|
1005
1308
|
? { result, ...(full.length > result.length + 200 ? { omitted: full.length - result.length } : {}) }
|
|
1006
1309
|
: full
|
|
@@ -1027,6 +1330,17 @@ const main = async () => {
|
|
|
1027
1330
|
// 压成一份"零上下文也能接手"的交接简报,等它写完并抓取,再 restartFresh 换一个
|
|
1028
1331
|
// 全新的进程(同名、同 cwd),把简报作为新会话的首条消息贴进去。拒绝对调用方
|
|
1029
1332
|
// 自身操作(会 deadlock,同 /peers/send)。
|
|
1333
|
+
/** 交接自己: 简报由调用方给, 排在它这一轮说完之后 (见 handoff.ts)。/handoff 点名点到
|
|
1334
|
+
* 自己与老路由 /wizard/handoff-self 共用。 */
|
|
1335
|
+
const handoffSelf = (self, brief) => {
|
|
1336
|
+
if (brief.length < 40)
|
|
1337
|
+
return { status: 400, body: { ok: false, reason: "brief 太短 —— 要写到零上下文也能接手: 目标 / 已完成 / 当前状态 / 下一步 / 关键文件与坑" } };
|
|
1338
|
+
const info = m.sessionInfo(self);
|
|
1339
|
+
const r = handoffs.self(self, brief);
|
|
1340
|
+
return r.ok
|
|
1341
|
+
? { status: 200, body: { ok: true, target: self, contextTokens: info?.contextTokens ?? 0, scheduled: true } }
|
|
1342
|
+
: { status: 409, body: { ok: false, reason: r.reason } };
|
|
1343
|
+
};
|
|
1030
1344
|
http.register("POST /handoff", async (req, res) => {
|
|
1031
1345
|
const { self, body } = await readPeerBody(req);
|
|
1032
1346
|
if (!self) {
|
|
@@ -1043,6 +1357,12 @@ const main = async () => {
|
|
|
1043
1357
|
}
|
|
1044
1358
|
}
|
|
1045
1359
|
else {
|
|
1360
|
+
// 不点名不能落成「本聊天的默认 wizard」(resolvePeer 对 "" 的语义): 交接是把人家的
|
|
1361
|
+
// 上下文清掉重开, 一个漏传的参数不该把群管家交接掉。不点名的交接只能是交接自己。
|
|
1362
|
+
if (!addrOf(body).trim()) {
|
|
1363
|
+
json(res, 400, { ok: false, reason: "要点名 (name) —— 交接你自己走 /wizard/handoff-self (MCP: handoff 不传 name, 带 brief)" });
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1046
1366
|
const r = resolvePeer(self, addrOf(body));
|
|
1047
1367
|
if (!r.ok) {
|
|
1048
1368
|
json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
|
|
@@ -1050,10 +1370,16 @@ const main = async () => {
|
|
|
1050
1370
|
}
|
|
1051
1371
|
target = r.target;
|
|
1052
1372
|
}
|
|
1053
|
-
//
|
|
1054
|
-
//
|
|
1373
|
+
// 点名点到自己: 不能走「让它写简报」那条 (往正在生成的输入框里打字, 等自己等成
|
|
1374
|
+
// 死锁), 改走交接自己 —— 简报得由调用方自己给。
|
|
1055
1375
|
if (target === self) {
|
|
1056
|
-
|
|
1376
|
+
const brief = (body.brief ?? "").toString().trim();
|
|
1377
|
+
if (!brief) {
|
|
1378
|
+
json(res, 400, { ok: false, reason: "这是交接你自己: 把简报写在 brief 里 (不传 name 即可; 旧会话的 handoff 没有 brief 参数, 用 wizard_handoff_self)" });
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
const r = handoffSelf(self, brief);
|
|
1382
|
+
json(res, r.status, r.body);
|
|
1057
1383
|
return;
|
|
1058
1384
|
}
|
|
1059
1385
|
const focus = (body.focus ?? "").toString().trim();
|
|
@@ -1068,13 +1394,132 @@ const main = async () => {
|
|
|
1068
1394
|
// 上下文窗口也挤不掉。
|
|
1069
1395
|
// 这里只做三件事: 读身份、改身份、按身份生/收分身。tmux 一概不碰, 动作全在
|
|
1070
1396
|
// mirror-bridge。
|
|
1071
|
-
const
|
|
1397
|
+
const bornOf = (t) => firstStamp(m.sessionInfo(t)?.jsonlPath ?? "");
|
|
1398
|
+
const wizards = bindWizardStore(loadWizardStore(cfg.wrc.mirror.wizardsFile, bornOf));
|
|
1072
1399
|
// 名册增量的投递面 (见 notices.ts)。charter 只是出生那一刻的快照; 此后的成员
|
|
1073
1400
|
// 变动从这里搭下一次注入的车进到每个在场 wizard 的上下文里, 不占一轮。
|
|
1074
1401
|
// 一条变动只投给**同一个聊天**里在场的其他 wizard: 群成员变动是那个群的事,
|
|
1075
1402
|
// 当事人自己做的自己知道, 所以排除在外。
|
|
1076
|
-
|
|
1077
|
-
const
|
|
1403
|
+
// 上下文逼近自动压缩时提醒一句「先记」: 压缩 / 交接都会丢细节。每段会话 (sid) 只提一次。
|
|
1404
|
+
const nudged = new Set();
|
|
1405
|
+
// 线按模型的窗口算 (memoryNudgeAt); 模型取 transcript 里实际跑的那个 id, pane 记的常是空串或 `opus` 这种别名。
|
|
1406
|
+
const memoryNudge = (t) => {
|
|
1407
|
+
const i = m.sessionInfo(t);
|
|
1408
|
+
// 最低的那条线先挡: 不到它就不必再读一次 transcript 取模型。
|
|
1409
|
+
if (!i?.sessionId || nudged.has(i.sessionId) || i.contextTokens < MEMORY_NUDGE_FLOOR)
|
|
1410
|
+
return [];
|
|
1411
|
+
if (i.contextTokens < memoryNudgeAt(lastModel(i.jsonlPath) || i.model))
|
|
1412
|
+
return [];
|
|
1413
|
+
nudged.add(i.sessionId);
|
|
1414
|
+
return [`你的上下文已到 ${Math.round(i.contextTokens / 1000)}k, 快到自动压缩了: 这段里学到的、值得跨会话活下来的东西, 先 \`wizard_remember\` 记下 (自己的选 self, 属于群 / 仓库的选 chat / workspace), 再考虑 \`handoff\``];
|
|
1415
|
+
};
|
|
1416
|
+
// 越过交接判断线 (handoffAt) 也只提一次: 交接不是压缩, 不该等快满。同样每段会话一次 ——
|
|
1417
|
+
// 交接后换了 sid, 新会话再越线会再提。
|
|
1418
|
+
const handoffNudged = new Set();
|
|
1419
|
+
const handoffNudge = (t) => {
|
|
1420
|
+
const i = m.sessionInfo(t);
|
|
1421
|
+
if (!i?.sessionId || handoffNudged.has(i.sessionId) || i.contextTokens < handoffAt(lastModel(i.jsonlPath) || i.model))
|
|
1422
|
+
return [];
|
|
1423
|
+
handoffNudged.add(i.sessionId);
|
|
1424
|
+
return [`你的上下文已到 ${Math.round(i.contextTokens / 1000)}k: 该判断要不要 \`handoff\` 了 —— 手上这摊活告一段落、或往后的活不再依赖前面的大段材料, 就交接 (值得跨会话留下的先 \`wizard_remember\`); 还在一件离不开它们的活中间, 做完这一段再交`];
|
|
1425
|
+
};
|
|
1426
|
+
// 挂起事项的提醒 (见 pending-items.digest): 只给管家 —— 各群的默认会话, 同时挂着多件活、
|
|
1427
|
+
// 又被人随时打断的就是它。上次全表时的样子纯内存: reload 后头一次见到只立基线。
|
|
1428
|
+
const pendingMarks = new Map();
|
|
1429
|
+
/** 上次注入之后被 /clear 或压缩截断过的 (onContextCut 记, 下一次注入取走)。 */
|
|
1430
|
+
const pendingCuts = new Map();
|
|
1431
|
+
const liveOfTurns = () => {
|
|
1432
|
+
let byTurn;
|
|
1433
|
+
return (turn) => (byTurn ??= new Map(receipts.states().map((x) => [x.turn, x]))).get(turn);
|
|
1434
|
+
};
|
|
1435
|
+
/** 管家等人验收的根单行 (需求已交付、单还开着)。 */
|
|
1436
|
+
const stalledRows = (owner) => stalledRoots(jobs.all(), owner, jobLive()).map(({ job, status }) => renderStalled(job.title, job.id, status));
|
|
1437
|
+
const awaitingRows = (owner, now) => awaitingAccept(jobs.all(), owner, jobLive()).map(({ job, at }) => renderAwaiting(job.title, job.id, at, now));
|
|
1438
|
+
const shelvedRows = (owner, now) => shelvedRoots(jobs.all(), owner).map((j) => renderShelved(j.title, j.id, j.hold ?? j.openedAt, now));
|
|
1439
|
+
const pendingNames = { nameOf: displayName, chatOf: (b) => chatNameOf(cfg, b) || b };
|
|
1440
|
+
/** 人在某个群最近一次开口 (内存; reload 丢了就当没说过): 重试冒泡避开人在场的时候。 */
|
|
1441
|
+
const humanAt = new Map();
|
|
1442
|
+
/** 「人这句可能在验收某张」同一张至多隔 HEARD_EVERY_MS 提一次。 */
|
|
1443
|
+
const acceptHeard = new Map();
|
|
1444
|
+
const pendingDigest = (t, via) => (seen) => {
|
|
1445
|
+
const cut = pendingCuts.get(t);
|
|
1446
|
+
pendingCuts.delete(t);
|
|
1447
|
+
const now = Date.now();
|
|
1448
|
+
if (via.human)
|
|
1449
|
+
humanAt.set(via.human.channel, now);
|
|
1450
|
+
if (tagOfKey(t))
|
|
1451
|
+
return { text: "", keys: [] };
|
|
1452
|
+
// 人在等验收那件需求的群里开了口: 这句可能就是验收 (是不是由管家判断)。与 digest 的 heard 同一个节流。
|
|
1453
|
+
const acceptLines = (via.human ? awaitingAccept(jobs.all(), t, jobLive()) : [])
|
|
1454
|
+
.filter(({ job }) => job.origin?.chat === via.human?.channel && now - (acceptHeard.get(job.id) ?? 0) >= HEARD_EVERY_MS)
|
|
1455
|
+
.map(({ job, at }) => {
|
|
1456
|
+
acceptHeard.set(job.id, now);
|
|
1457
|
+
return `heard? accept ${job.id} "${job.title}" delivered ${agoMs(now - at)} ago, awaiting the human's verdict: if this line is the verdict → close_job (ok) / tell_peer re to lead (not ok); else ignore`;
|
|
1458
|
+
});
|
|
1459
|
+
const rule = !seen("rule:pending");
|
|
1460
|
+
const items = ledger.openOf(t);
|
|
1461
|
+
// 没挂着的事: 不读会话, 也不留基线 —— 有了第一件再从那一刻立。
|
|
1462
|
+
if (!items.length) {
|
|
1463
|
+
pendingMarks.delete(t);
|
|
1464
|
+
const text = acceptLines.length ? renderDigest({ full: "", lines: acceptLines, touched: [], mark: { sid: "", ctx: 0, ctxAtFull: 0, drains: 0 } }, "", rule) : "";
|
|
1465
|
+
return { text, keys: text && rule ? ["rule:pending"] : [] };
|
|
1466
|
+
}
|
|
1467
|
+
const i = m.sessionInfo(t);
|
|
1468
|
+
const o = digest({ items, mark: pendingMarks.get(t), ...(cut ? { cut } : {}), session: i ? { sid: i.sessionId, ctx: i.contextTokens } : undefined, ...(via.human ? { human: via.human } : {}), now, nameOf: displayName });
|
|
1469
|
+
pendingMarks.set(t, o.mark);
|
|
1470
|
+
o.touched.forEach(ledger.put);
|
|
1471
|
+
const text = renderDigest({ ...o, lines: [...o.lines, ...acceptLines] }, o.full ? renderTable(items, liveOfTurns(), pendingNames, now, awaitingRows(t, now), stalledRows(t), shelvedRows(t, now)) : "", rule);
|
|
1472
|
+
return { text, keys: text && rule ? ["rule:pending"] : [] };
|
|
1473
|
+
};
|
|
1474
|
+
const notices = bindNoticeBox(createNoticeBox({
|
|
1475
|
+
probe: (t) => [...handoffNudge(t), ...memoryNudge(t)],
|
|
1476
|
+
digest: pendingDigest,
|
|
1477
|
+
seen: createSeenRing(loadJsonMap(cfg.wrc.mirror.noticeSeenFile, gcSeen), (t) => m.sessionInfo(t)?.sessionId ?? ""),
|
|
1478
|
+
cwdOf: (t) => ((c) => c.runningCwd || c.defaultCwd)(m.getCwd(t)),
|
|
1479
|
+
}));
|
|
1480
|
+
// 重试冒泡 (设计文档 §3.3.1): 做完了没人验收的需求, 守护进程以管家的名义往原群提醒一句, 不占管家一轮、
|
|
1481
|
+
// 不带单号 (人只认标题); 同时往管家的信箱挂一行带单号的, 人下一句进来时它知道可能在答哪张。
|
|
1482
|
+
// 节奏由根单上的 nudge {n, lastAt} 现算 (见 jobs.nextNudgeAt), 只在白天, 人刚说过话的群顺延 —— reload 不丢、不重发。
|
|
1483
|
+
const NUDGE_TICK_MS = 5 * 60_000;
|
|
1484
|
+
const HUMAN_QUIET_MS = 30 * 60_000;
|
|
1485
|
+
const agoZh = (ms) => ms < 3_600_000 ? `${Math.max(1, Math.round(ms / 60_000))} 分钟前` : ms < 86_400_000 ? `${Math.round(ms / 3_600_000)} 小时前` : `${Math.round(ms / 86_400_000)} 天前`;
|
|
1486
|
+
const nudgeTick = (now = Date.now()) => {
|
|
1487
|
+
if (dayClamp(now) !== now)
|
|
1488
|
+
return 0;
|
|
1489
|
+
const owners = [...new Set(jobs.all().filter((j) => j.kind === "req" && j.status === "open").map((j) => j.owner))];
|
|
1490
|
+
// 只冒群默认管家 (无 tag) 自己的根单: lead 自己 dispatch 出来的子需求不该推给人; 与 pendingDigest 只服务默认会话一致。
|
|
1491
|
+
// owner 须是已登记的 wizard: 伪造的 owner/群 (调试、回归) 不得往 turn store 落 post, 否则 rolepage 凭空长出一个群和一个「人」。
|
|
1492
|
+
const due = owners
|
|
1493
|
+
.filter((o) => !tagOfKey(o) && !!wizards.get(o))
|
|
1494
|
+
.flatMap((o) => awaitingAccept(jobs.all(), o, jobLive()))
|
|
1495
|
+
.filter(({ job, at }) => !!job.origin?.chat && baseOfKey(job.owner) === job.origin.chat && nudgeDue(at, job.nudge, now) && now - (humanAt.get(job.origin.chat) ?? 0) >= HUMAN_QUIET_MS);
|
|
1496
|
+
// 按 (群, owner) 分组: 署名与信箱都跟着 owner。
|
|
1497
|
+
const byChat = due.reduce((acc, x) => { const k = `${x.job.origin.chat}\0${x.job.owner}`; return acc.set(k, [...(acc.get(k) ?? []), x]); }, new Map());
|
|
1498
|
+
byChat.forEach((xs) => {
|
|
1499
|
+
const owner = xs[0].job.owner;
|
|
1500
|
+
const chat = xs[0].job.origin.chat;
|
|
1501
|
+
const one = xs.length === 1;
|
|
1502
|
+
const mark = (i) => (one ? "" : "①②③④⑤⑥⑦⑧⑨⑩"[i] ?? `${i + 1}.`);
|
|
1503
|
+
const text = `有 ${xs.length} 件做完了等你看: ${xs.map(({ job, at }, i) => `${mark(i)}「${job.title}」(${agoZh(now - at)}交付)`).join(" ")} —— 回一句「好」就收, 或者说哪里不对。`;
|
|
1504
|
+
notifyChat(chat, `${relayLabel(owner)}\n\n${text}`);
|
|
1505
|
+
recordPost({ target: owner, channel: chat, body: text });
|
|
1506
|
+
xs.forEach(({ job }) => jobs.mark(job.id, { nudge: { n: (job.nudge?.n ?? 0) + 1, lastAt: now } }));
|
|
1507
|
+
notices.post([owner], `nudged the human (daemon, in your name) about finished requests; their next line may be the verdict on one: ${xs.map(({ job }) => `${job.id} "${job.title}"`).join(" · ")}`);
|
|
1508
|
+
});
|
|
1509
|
+
return due.length;
|
|
1510
|
+
};
|
|
1511
|
+
setInterval(() => { try {
|
|
1512
|
+
nudgeTick();
|
|
1513
|
+
}
|
|
1514
|
+
catch (e) {
|
|
1515
|
+
log.warn({ err: e.message }, "accept nudge failed");
|
|
1516
|
+
} }, NUDGE_TICK_MS).unref();
|
|
1517
|
+
/** `who` = 这次变动的当事人 (见 hears): 不给 = 聊天自己的事 (改名), 全群都听。 */
|
|
1518
|
+
const postRoster = (base, except, n, who) => notices.post(chatAudience(m.chatTargets(base), base, except).filter(who ? hears(who) : () => true), n);
|
|
1519
|
+
/** 注册表里的当事人。要在 forget 抹掉记录之前取; 没有记录 = 谁也不告诉。 */
|
|
1520
|
+
const subjectOf = (w) => (w
|
|
1521
|
+
? { parent: w.parent, description: w.description, owners: jobs.all().filter((j) => j.status === "open" && j.members.some((x) => x.target === w.target)).map((j) => j.owner) }
|
|
1522
|
+
: {});
|
|
1078
1523
|
/** 一个聊天的工作区: 它名下各会话跑在哪个目录, 取最多的那个。聊天与工作区是
|
|
1079
1524
|
* 一对多, 但绝大多数聊天只围着一个项目转。 */
|
|
1080
1525
|
const chatCwd = (targets) => {
|
|
@@ -1108,26 +1553,50 @@ const main = async () => {
|
|
|
1108
1553
|
log.info({ base, name }, "chat: auto-named from workspace");
|
|
1109
1554
|
// 群里不发气泡 (一次补名会命中十几个群, 那是刷屏), 但住在里面的 wizard
|
|
1110
1555
|
// 得知道自己的地址变了 —— 它的 charter 里写的还是「(未命名)」。
|
|
1111
|
-
postRoster(base, [],
|
|
1556
|
+
postRoster(base, [], { op: "chat", name });
|
|
1112
1557
|
}
|
|
1113
1558
|
return named;
|
|
1114
1559
|
};
|
|
1115
1560
|
// 名字全局唯一 (见 wizard.ts): 先给没名字的聊天补名, 再按「默认会话先挑」把
|
|
1116
1561
|
// 每个已知 wizard 的名字落定 —— 老记录 (名字还跟着聊天走的那一代) 在这里一次迁完。
|
|
1117
1562
|
ensureChatNames(cfg.defaultChat || "");
|
|
1563
|
+
const reborn = backfillBorn(wizards, bornOf);
|
|
1564
|
+
if (reborn.length)
|
|
1565
|
+
log.child({ mod: "wizard" }).info({ n: reborn.length }, "bornAt backfilled from transcript heads");
|
|
1118
1566
|
settleAll(wizards, [...m.chatRoster("").flatMap((c) => c.targets), ...wizards.all().map((w) => w.target)], (t) => chatNameOf(cfg, t));
|
|
1119
|
-
//
|
|
1120
|
-
//
|
|
1121
|
-
|
|
1567
|
+
// 上一个进程生到一半就没了的身份 (spawn 途中 reload): 没会话的删掉, 腾出名字。
|
|
1568
|
+
// 它们的 tmux window 也一并收掉 (没人叫得到它, 只会占着 pane): 等绑定恢复完再看,
|
|
1569
|
+
// 免得把还没恢复的活 pane 当成没绑定; 期间名字已被新 spawn 认领的不碰。
|
|
1570
|
+
const unborn = sweepUnborn(wizards, (t) => m.chatTargets(baseOfKey(t)).includes(t));
|
|
1571
|
+
unborn.forEach((w) => log.child({ mod: "wizard" }).warn({ target: w.target, name: w.name }, "unborn identity swept"));
|
|
1572
|
+
if (unborn.length) {
|
|
1573
|
+
void m.restored
|
|
1574
|
+
.then(() => m.reapUnborn(unborn.map((w) => w.name).filter((n) => n && !wizards.byName(n))))
|
|
1575
|
+
.then((killed) => { if (killed.length)
|
|
1576
|
+
log.child({ mod: "wizard" }).info({ killed }, "unborn windows killed"); })
|
|
1577
|
+
.catch((e) => log.warn({ err: e.message }, "unborn window reap failed"));
|
|
1578
|
+
}
|
|
1579
|
+
/** 成员角色参数: 只认 exec / reviewer / expert (lead 只由 dispatch 在派成之后记, 免得谁都能自封 lead); 不认识的 = 不给。 */
|
|
1580
|
+
const roleParam = (v) => ["exec", "reviewer", "expert"].find((x) => x === v);
|
|
1581
|
+
/** 成员此刻的态: 定论 > 在飞的实时态。每次取一份回执快照。 */
|
|
1582
|
+
const jobLive = () => {
|
|
1583
|
+
const inflight = receipts.states();
|
|
1584
|
+
return (j, mm) => mm.outcome ?? inflight.find((x) => x.to === mm.target && x.job === j.id)?.state;
|
|
1585
|
+
};
|
|
1122
1586
|
/** 派活前先验工单: 生完分身才发现工单号打错了, 那个分身就成了没人认领的孤儿。 */
|
|
1123
|
-
|
|
1587
|
+
/** `free` = 这次不花预算 (不带 task 的 spawn; 答一份停在 NEED 上的反问 —— 拒了它就只能干等到超时)。 */
|
|
1588
|
+
const checkJob = (id, target, free = false) => {
|
|
1124
1589
|
const j = jobs.get(id);
|
|
1125
1590
|
if (!j)
|
|
1126
1591
|
return { ok: false, status: 404, reason: `没有工单 '${id}' —— open_job 先开一个, 或者 list_jobs 看还开着哪些` };
|
|
1127
1592
|
if (j.status !== "open")
|
|
1128
1593
|
return { ok: false, status: 409, reason: `工单 '${id}' 已经收工了` };
|
|
1129
|
-
|
|
1130
|
-
|
|
1594
|
+
// 已在册的成员 (续问 / 再派一句) 不占新名额。
|
|
1595
|
+
if (j.members.length >= JOB_MEMBER_MAX && !j.members.some((x) => x.target === target))
|
|
1596
|
+
return { ok: false, status: 409, reason: `工单 '${id}' 的成员已经满了 (${JOB_MEMBER_MAX} 个直接成员) —— 管辖幅度有限: 把一块子活交给现有成员 (它自己开子单、长一层子 lead), 或者先收工回收掉一批` };
|
|
1597
|
+
// 预算是硬的: 与单件活的 legs 不同, 这里拒了也无路可绕 —— 换个人派照样记在这张工单上。
|
|
1598
|
+
if (!free && j.maxTurns && (j.turns ?? 0) >= j.maxTurns)
|
|
1599
|
+
return { ok: false, status: 409, reason: `工单 '${id}' 的派活预算用完了 (${j.turns}/${j.maxTurns} 次) —— 拿手上已有的结论 close_job 收口, 缺的如实写; 真要接着干就 open_job 开一张新的` };
|
|
1131
1600
|
return { ok: true };
|
|
1132
1601
|
};
|
|
1133
1602
|
// ── 关系视图的数据面 ──────────────────────────────────────────────
|
|
@@ -1148,8 +1617,18 @@ const main = async () => {
|
|
|
1148
1617
|
const known = wizards.all();
|
|
1149
1618
|
const byTarget = new Map(known.map((w) => [w.target, w]));
|
|
1150
1619
|
const seen = new Set(peers.map((p) => p.target));
|
|
1620
|
+
// 「卡在审批」以 daemon 手里的 pending 为准 —— 审批长轮询期间 pane 照样在转圈,
|
|
1621
|
+
// 拿 busy 去推会把它当成「执行中」。归属走 approval 认卡的同一口径 (getMirrorTarget):
|
|
1622
|
+
// hook 的 sid 在 /clear 之后先变, 镜像绑定的 sid 要等新 jsonl 出现才跟上。
|
|
1623
|
+
// ask_user 的 pending 不带 sessionId —— 它是 MCP 工具在等, 不归哪张卡, 不亮。
|
|
1624
|
+
const waitingOf = listPending().reduce((m, { meta }) => {
|
|
1625
|
+
const t = meta.sessionId ? getMirrorTarget(meta.sessionId) : undefined;
|
|
1626
|
+
const tool = meta.toolName || meta.kind;
|
|
1627
|
+
return t ? m.set(t, [...new Set([...(m.get(t) ?? []), tool])]) : m;
|
|
1628
|
+
}, new Map());
|
|
1151
1629
|
const live = peers.map((p) => {
|
|
1152
1630
|
const w = byTarget.get(p.target);
|
|
1631
|
+
const waiting = waitingOf.get(p.target);
|
|
1153
1632
|
return {
|
|
1154
1633
|
target: p.target,
|
|
1155
1634
|
name: settleName(wizards, chatNameOf(cfg, p.target), p.target),
|
|
@@ -1157,9 +1636,11 @@ const main = async () => {
|
|
|
1157
1636
|
chat: chatNameOf(cfg, p.target) || p.chat,
|
|
1158
1637
|
cwd: p.cwd,
|
|
1159
1638
|
model: p.model,
|
|
1639
|
+
...((e) => (e ? { effort: e } : {}))(mirrorStore.get(p.target)?.effort),
|
|
1160
1640
|
cli: p.cli,
|
|
1161
1641
|
busy: p.busy,
|
|
1162
1642
|
alive: p.paneAlive,
|
|
1643
|
+
...(waiting ? { waiting } : {}),
|
|
1163
1644
|
parent: w?.parent,
|
|
1164
1645
|
clonedFrom: w?.clonedFrom,
|
|
1165
1646
|
forkOf: w?.forkOf,
|
|
@@ -1189,11 +1670,15 @@ const main = async () => {
|
|
|
1189
1670
|
const nowDate = new Date();
|
|
1190
1671
|
return {
|
|
1191
1672
|
wizards: [...live, ...cold],
|
|
1192
|
-
jobs:
|
|
1673
|
+
jobs: ((all, live) => all.map((j) => ({
|
|
1193
1674
|
id: j.id, base: j.base, owner: j.owner, title: j.title, status: j.status,
|
|
1194
|
-
openedAt: j.openedAt, closedAt: j.closedAt, summary: j.summary,
|
|
1195
|
-
|
|
1196
|
-
|
|
1675
|
+
openedAt: j.openedAt, closedAt: j.closedAt, summary: j.summary, ...(j.expect ? { expect: j.expect } : {}), ...(j.plan ? { plan: j.plan } : {}),
|
|
1676
|
+
...(j.parent ? { parent: j.parent } : {}), ...(j.kind ? { kind: j.kind } : {}), ...(j.criteria ? { criteria: j.criteria } : {}), ...(j.origin ? { origin: j.origin } : {}), ...(j.end ? { end: j.end } : {}),
|
|
1677
|
+
stage: jobStage(j, all, live),
|
|
1678
|
+
members: j.members.map((mm) => ({ target: mm.target, task: mm.task, spawned: mm.spawned, ...(mm.outcome ? { outcome: mm.outcome } : {}), ...(mm.artifacts?.length ? { artifacts: mm.artifacts } : {}), ...(mm.role ? { role: mm.role } : {}), ...(mm.forkOf ? { forkOf: mm.forkOf } : {}) })),
|
|
1679
|
+
})))(jobs.all(), jobLive()),
|
|
1680
|
+
// rolepage 的待办托盘: 与名册 / peek 的「在等 / 欠着」同一份 (receipts.states)。
|
|
1681
|
+
inflight: receipts.states().map(({ ageMs, ...x }) => ({ ...x, at: Date.now() - ageMs })),
|
|
1197
1682
|
schedules: tasks.list().filter((x) => x.enabled).map((x) => {
|
|
1198
1683
|
const st = tasks.stateOf(x.id);
|
|
1199
1684
|
const loadError = tasks.errors()[x.id];
|
|
@@ -1244,9 +1729,27 @@ const main = async () => {
|
|
|
1244
1729
|
const path = memoryPath(cfg.daemon.stateDir, scope, key);
|
|
1245
1730
|
return clipForCharter(readMemory(path), path);
|
|
1246
1731
|
};
|
|
1247
|
-
/**
|
|
1248
|
-
|
|
1249
|
-
|
|
1732
|
+
/** transcript 最后写入的时刻; 没有会话 / 读不到 = 0。 */
|
|
1733
|
+
const lastTouched = (t) => {
|
|
1734
|
+
const p = m.sessionInfo(t)?.jsonlPath;
|
|
1735
|
+
try {
|
|
1736
|
+
return p ? statSync(p).mtimeMs : 0;
|
|
1737
|
+
}
|
|
1738
|
+
catch {
|
|
1739
|
+
return 0;
|
|
1740
|
+
}
|
|
1741
|
+
};
|
|
1742
|
+
// 每个会话的宪章里写进了哪些 self 记忆。`/clear` 抹掉对话却不重写系统提示, 此后
|
|
1743
|
+
// 新记的那些就只活在被抹掉的对话里 —— onContextCut 据这份快照补发差集。纯内存: reload
|
|
1744
|
+
// 之后没有快照, 只指路不补发。
|
|
1745
|
+
const chartered = new Map();
|
|
1746
|
+
const charterOptsOf = (target, cwd) => {
|
|
1747
|
+
const rec = wizards.get(target);
|
|
1748
|
+
return { parent: rec?.parent, forkOf: rec?.forkOf, inherited: !!rec?.clonedFrom, cwd };
|
|
1749
|
+
};
|
|
1750
|
+
/** 宪章正文, 只读 —— 只由 cfg 与此刻的注册表 / 附着表算出, config_set 据此比对前后。`forDiff` 去掉
|
|
1751
|
+
* 宪章里自认是出生快照的配置 (档位那一行, 真相在 config_get): 改档位不该叫全员 handoff。 */
|
|
1752
|
+
const charterText = (target, o, forDiff = false) => renderCharter({
|
|
1250
1753
|
// o.cwd = 正在启动的那个 pane 的目录; 没给才退回"现在记着的那个"。
|
|
1251
1754
|
self: { ...briefOf(target, target), address: selfAddress(target), ...(o.cwd ? { cwd: o.cwd } : {}) },
|
|
1252
1755
|
chat: chatNameOf(cfg, target),
|
|
@@ -1255,14 +1758,53 @@ const main = async () => {
|
|
|
1255
1758
|
inherited: !!o.inherited,
|
|
1256
1759
|
forkOf: o.forkOf ? briefOf(target, o.forkOf) : undefined,
|
|
1257
1760
|
cwdUnconfirmed: m.cwdUnconfirmed(target, o.cwd),
|
|
1258
|
-
|
|
1761
|
+
// 最近动过的排前面: 名册只点名前几个, 截掉的该是早就停了的那些。
|
|
1762
|
+
// 没有 transcript 的冷记录按 bornAt 算, 刚生下还没开口的照样在。
|
|
1763
|
+
siblings: m.chatTargets(baseOfKey(target))
|
|
1764
|
+
.filter((t) => t !== target)
|
|
1765
|
+
.map((t) => [t, Math.max(lastTouched(t), wizards.get(t)?.bornAt ?? 0)])
|
|
1766
|
+
.filter(([, at]) => Date.now() - at <= ROSTER_FRESH_MS)
|
|
1767
|
+
.sort((x, y) => y[1] - x[1])
|
|
1768
|
+
.map(([t]) => briefOf(target, t)),
|
|
1259
1769
|
memory: wizards.get(target)?.memory ?? [],
|
|
1770
|
+
tiers: forDiff ? [] : TIERS.map((n) => { const s = cfg.models.tiers[n]; return `${n}=${s.model || "默认"}${s.effort ? `·${s.effort}` : ""}`; }),
|
|
1260
1771
|
chatMemory: sharedMemory("chat", baseOfKey(target)),
|
|
1261
1772
|
workspaceMemory: (() => {
|
|
1262
1773
|
const cwd = o.cwd || m.getCwd(target).runningCwd || m.getCwd(target).defaultCwd;
|
|
1263
1774
|
return cwd ? sharedMemory("workspace", cwd) : "";
|
|
1264
1775
|
})(),
|
|
1265
1776
|
steward: !tagOfKey(target),
|
|
1777
|
+
stewardBudget: chatPolicyOf(cfg, target).stewardBudget,
|
|
1778
|
+
lead: !!wizards.get(target)?.lead,
|
|
1779
|
+
topOnly: chatPolicyOf(cfg, target).topOnly,
|
|
1780
|
+
});
|
|
1781
|
+
/** 开局宪章。出生时的兄弟只是快照 —— 名册随时可查, 写进系统提示的那份只为了
|
|
1782
|
+
* 让它一睁眼就知道自己不是一个人在跑。 */
|
|
1783
|
+
const charterFor = (target, o) => {
|
|
1784
|
+
chartered.set(target, wizards.get(target)?.memory ?? []);
|
|
1785
|
+
const text = charterText(target, o);
|
|
1786
|
+
recordCharter(target, text, o.parent ? { parent: o.parent, kind: o.inherited ? "clone" : "spawn", ...(o.forkOf ? { forkOf: o.forkOf } : {}) } : undefined);
|
|
1787
|
+
return text;
|
|
1788
|
+
};
|
|
1789
|
+
m.onContextCut((t, cut) => {
|
|
1790
|
+
pendingCuts.set(t, cut);
|
|
1791
|
+
// 之前挂过的提示随对话一起没了: 去重窗口清零, 下一次该给的规矩照给。
|
|
1792
|
+
notices.forget(t);
|
|
1793
|
+
const now = wizards.get(t)?.memory ?? [];
|
|
1794
|
+
const seen = chartered.get(t);
|
|
1795
|
+
// 没有快照 (reload 之后) 就不猜差集, 只指路 —— 整份塞进一行能有几万字。
|
|
1796
|
+
if (!seen) {
|
|
1797
|
+
if (now.length)
|
|
1798
|
+
notices.post([t], `你有 ${now.length} 条 self 记忆, 宪章里那份可能是旧的: \`wizard_whoami\` 看全的`);
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
const fresh = now.filter((x) => !seen.includes(x));
|
|
1802
|
+
const gone = seen.filter((x) => !now.includes(x)).length;
|
|
1803
|
+
const shown = fresh.slice(-10).map((x) => `「${clipLine(x, 200)}」`).join(" ");
|
|
1804
|
+
if (fresh.length)
|
|
1805
|
+
notices.post([t], `你出生之后记下的 self 记忆 (${cut === "clear" ? "/clear 抹掉了对话" : "上下文压缩把对话换成了摘要"}, 宪章里没有它们)${fresh.length > 10 ? `, 最新 10 条` : ""}: ${shown}`);
|
|
1806
|
+
if (gone)
|
|
1807
|
+
notices.post([t], `宪章「我的记忆」里有 ${gone} 条已被你 forget, 别再照它们办`);
|
|
1266
1808
|
});
|
|
1267
1809
|
// 所有 spawn 路径 (群里手打 /new、pane 自愈重生、编排出来的分身) 都从这里
|
|
1268
1810
|
// 取身份, 于是「是谁」不再取决于是哪段代码把它生出来的。
|
|
@@ -1271,9 +1813,25 @@ const main = async () => {
|
|
|
1271
1813
|
// 终身的 —— 一个在聊天还没名字时出生的 wizard, 会一辈子以为自己住在一个
|
|
1272
1814
|
// 「(未命名)」的地方。所以补名要发生在渲染之前, 不能等它以后自己去查。
|
|
1273
1815
|
ensureChatNames(target);
|
|
1274
|
-
|
|
1275
|
-
|
|
1816
|
+
// 信箱里装的是相对上一份宪章的变化; 新宪章一渲染就都过期了 (名册、记忆都已在里面)。
|
|
1817
|
+
notices.drain(target);
|
|
1818
|
+
notices.forget(target);
|
|
1819
|
+
return charterFor(target, charterOptsOf(target, ctx?.cwd));
|
|
1276
1820
|
});
|
|
1821
|
+
// 宪章记录是后来才有的: 已经在跑的 wizard 拿 spawn 落盘的那份 (state/charters/<sid>.md) 补上,
|
|
1822
|
+
// 否则要等它下次重生, rolepage 的「宪章」入口才出现。与自动命名同一个延迟: 等附着表恢复完。
|
|
1823
|
+
setTimeout(() => {
|
|
1824
|
+
const dir = join(expandHome(cfg.daemon.stateDir), "charters");
|
|
1825
|
+
wizards.all().forEach((w) => {
|
|
1826
|
+
const sid = m.sessionInfo(w.target)?.sessionId;
|
|
1827
|
+
const file = sid ? join(dir, `${sid}.md`) : "";
|
|
1828
|
+
try {
|
|
1829
|
+
if (file && existsSync(file))
|
|
1830
|
+
backfillCharter(w.target, readFileSync(file, "utf8"), statSync(file).mtimeMs);
|
|
1831
|
+
}
|
|
1832
|
+
catch { /* 补不上就等它重生 */ }
|
|
1833
|
+
});
|
|
1834
|
+
}, 15_000).unref();
|
|
1277
1835
|
// 开机也补一次: IM 侧的 `/peers`、`/help` 直接读名字, 不经过任何 MCP 路由。
|
|
1278
1836
|
// 延后是因为附着表要先从盘上恢复完, 否则这一刻还看不见那些聊天; 补不全也无妨,
|
|
1279
1837
|
// 上面每一道拦截都会再补。
|
|
@@ -1310,8 +1868,9 @@ const main = async () => {
|
|
|
1310
1868
|
sessionId: info?.sessionId ?? "",
|
|
1311
1869
|
cli: info?.cli,
|
|
1312
1870
|
model: info?.model ?? "",
|
|
1871
|
+
...(info?.effort ? { effort: info.effort } : {}),
|
|
1313
1872
|
contextTokens: info?.contextTokens ?? 0,
|
|
1314
|
-
handoffSuggested:
|
|
1873
|
+
handoffSuggested: !!info && info.contextTokens > handoffAt(lastModel(info.jsonlPath) || info.model),
|
|
1315
1874
|
};
|
|
1316
1875
|
};
|
|
1317
1876
|
/** 全体名册: 活着的 (peers + 跨聊天可寻址的) 在前, 注册表里有记录但此刻不在
|
|
@@ -1392,7 +1951,9 @@ const main = async () => {
|
|
|
1392
1951
|
// 职责是别人决定"该不该找你"的依据, 改了就得让同群的知道 —— 否则他们照着
|
|
1393
1952
|
// 出生快照里那句旧的 (或者空的) 职责派活。
|
|
1394
1953
|
if (asked || description)
|
|
1395
|
-
postRoster(baseOfKey(self), [self],
|
|
1954
|
+
postRoster(baseOfKey(self), [self], before && before !== name
|
|
1955
|
+
? { op: "renamed", name: `.${name}`, was: `.${before}`, ...(description ? { job: description } : {}) }
|
|
1956
|
+
: { op: "job", name: `.${name}`, job: description }, subjectOf(wizards.get(self)));
|
|
1396
1957
|
json(res, 200, {
|
|
1397
1958
|
ok: true,
|
|
1398
1959
|
...me,
|
|
@@ -1429,8 +1990,11 @@ const main = async () => {
|
|
|
1429
1990
|
// 的箭头 (读名册的是另一个 wizard), 模型取它真正跑的那个 —— 绑定里的 model 只在
|
|
1430
1991
|
// 有人显式选过时才有。
|
|
1431
1992
|
const warm = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping);
|
|
1993
|
+
const inflight = receipts.states();
|
|
1432
1994
|
const shown = ranked.slice(0, limit).map((r) => ({
|
|
1433
1995
|
...r,
|
|
1996
|
+
turns: turnsLine(inflight, r.target),
|
|
1997
|
+
duty: dutyLine(jobs.all(), r.target),
|
|
1434
1998
|
model: r.model || (r.jsonlPath ? lastModel(r.jsonlPath) : ""),
|
|
1435
1999
|
summary: r.jsonlPath ? lastExchange(r.jsonlPath, 80, warm) : "",
|
|
1436
2000
|
contextTokens: r.jsonlPath ? lastContextTokens(r.jsonlPath) : 0,
|
|
@@ -1451,35 +2015,151 @@ const main = async () => {
|
|
|
1451
2015
|
});
|
|
1452
2016
|
// 管家分派前的候选表: 事实在这里算 (交集、读过的文件、ctx), 权衡留给管家。
|
|
1453
2017
|
// 只为活着的读 transcript —— 冷的没有 jsonl 可读, 靠职责命中进表。
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
if (!self) {
|
|
1457
|
-
json(res, 400, { ok: false, reason: "cannot resolve caller session" });
|
|
1458
|
-
return;
|
|
1459
|
-
}
|
|
1460
|
-
const b = body;
|
|
1461
|
-
const task = (b.task ?? "").trim();
|
|
1462
|
-
if (!task) {
|
|
1463
|
-
json(res, 400, { ok: false, reason: "task is required" });
|
|
1464
|
-
return;
|
|
1465
|
-
}
|
|
2018
|
+
/** 候选的原料: 除自己之外每个 wizard 的上下文事实 (文件集 / 最近的话 / ctx / 缓存 TTL)。 */
|
|
2019
|
+
const routeRowsOf = async (self) => {
|
|
1466
2020
|
ensureChatNames(self);
|
|
1467
2021
|
const warm = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping);
|
|
1468
|
-
|
|
2022
|
+
return (await rosterOf(self))
|
|
1469
2023
|
.filter((r) => !r.self)
|
|
1470
2024
|
.map((r) => {
|
|
1471
2025
|
const live = r.jsonlPath ? r.jsonlPath : "";
|
|
2026
|
+
const ttl = live ? cacheTtlSec(live) : 0;
|
|
1472
2027
|
return {
|
|
1473
2028
|
...r,
|
|
1474
2029
|
contextTokens: live ? lastContextTokens(live) : 0,
|
|
2030
|
+
...(ttl ? { cacheTtlMs: ttl * 1000 } : {}),
|
|
2031
|
+
...(live ? { cacheAt: cacheTouchMs(live) } : {}),
|
|
1475
2032
|
files: live ? contextFiles(live) : [],
|
|
1476
2033
|
asks: live ? talkTurns(live, 12, warm).filter((t) => t.role === "user").map((t) => t.text) : [],
|
|
1477
2034
|
summary: live ? lastExchange(live, 80, warm) : "",
|
|
1478
2035
|
};
|
|
1479
2036
|
});
|
|
2037
|
+
};
|
|
2038
|
+
http.register("POST /wizard/route", async (req, res) => {
|
|
2039
|
+
const { self, body } = await readPeerBody(req);
|
|
2040
|
+
if (!self) {
|
|
2041
|
+
json(res, 400, { ok: false, reason: "cannot resolve caller session" });
|
|
2042
|
+
return;
|
|
2043
|
+
}
|
|
2044
|
+
const b = body;
|
|
2045
|
+
const task = (b.task ?? "").trim();
|
|
2046
|
+
if (!task) {
|
|
2047
|
+
json(res, 400, { ok: false, reason: "task is required" });
|
|
2048
|
+
return;
|
|
2049
|
+
}
|
|
1480
2050
|
const limit = Math.min(Math.max(Number(b.limit ?? 5) || 5, 1), 20);
|
|
1481
|
-
const cands = rankCandidates(task, (b.cwd ?? "").trim() || m.getCwd(self).runningCwd,
|
|
1482
|
-
json(res, 200, { ok: true, text: renderCandidates(cands, Date.now(), homedir()) });
|
|
2051
|
+
const cands = rankCandidates(task, (b.cwd ?? "").trim() || m.getCwd(self).runningCwd, await routeRowsOf(self)).slice(0, limit);
|
|
2052
|
+
json(res, 200, { ok: true, text: renderCandidates(task, cands, Date.now(), cfg.wrc.mirror.keepalive.ttlSec * 1000, homedir()) });
|
|
2053
|
+
});
|
|
2054
|
+
// 派活收成一步 (见 route.decide): 查候选 → 过成本门控 → 转给已有的或按档白板 spawn →
|
|
2055
|
+
// tell_peer 投出去, 回决定与理由。默认决定由守护进程算; 管家不同意就 `to` 点名、
|
|
2056
|
+
// `force` 越过冷门控、`spawn:true` 硬要新的 —— 推翻要显式说。
|
|
2057
|
+
http.register("POST /dispatch", async (req, res) => {
|
|
2058
|
+
const { self, body } = await readPeerBody(req);
|
|
2059
|
+
if (!self) {
|
|
2060
|
+
json(res, 400, { ok: false, reason: "cannot resolve caller session" });
|
|
2061
|
+
return;
|
|
2062
|
+
}
|
|
2063
|
+
const b = body;
|
|
2064
|
+
const task = (b.task ?? "").toString().trim();
|
|
2065
|
+
if (!task) {
|
|
2066
|
+
json(res, 400, { ok: false, reason: "task is required" });
|
|
2067
|
+
return;
|
|
2068
|
+
}
|
|
2069
|
+
const lead = b.lead === true;
|
|
2070
|
+
const force = b.force === true;
|
|
2071
|
+
const to = (b.to ?? "").toString().trim();
|
|
2072
|
+
const now = Date.now();
|
|
2073
|
+
const ttlMs = cfg.wrc.mirror.keepalive.ttlSec * 1000;
|
|
2074
|
+
// 自动选只在这些里选: 在线的 (没 pane 的 ctx 量不出, 成本门会盲过)、不是哪个群的管家、
|
|
2075
|
+
// 不是别人开着的工单里的队员 —— 点名 (`to`) 不受此限。
|
|
2076
|
+
const inOthersJob = (t) => jobs.openOf(baseOfKey(t)).some((j) => j.owner !== self && j.members.some((mm) => mm.target === t));
|
|
2077
|
+
const pool = to || b.spawn || lead ? [] : (await routeRowsOf(self)).filter((r) => r.alive && !!tagOfKey(r.target) && !inOthersJob(r.target));
|
|
2078
|
+
const cands = rankCandidates(task, m.getCwd(self).runningCwd, pool).slice(0, 5);
|
|
2079
|
+
let d = to ? { kind: "existing", why: `你点名了 ${to}` }
|
|
2080
|
+
: b.spawn ? { kind: "spawn", why: "你要了新的" }
|
|
2081
|
+
: lead ? { kind: "spawn", why: "复杂活: 白板起一个 hard 档的 lead, 由它组队" }
|
|
2082
|
+
: decide(task, cands, now, ttlMs, force);
|
|
2083
|
+
// 复杂活 (lead:true) 是一件「需求」: 先开需求根单 (账本, 不占 pane), 这一句带它派下去, lead 记成根单成员。
|
|
2084
|
+
// 小活不开单。dryRun 不落账, 所以放在它后面开 (见下)。
|
|
2085
|
+
const criteria = (b.criteria ?? "").toString().trim();
|
|
2086
|
+
let rootId = "";
|
|
2087
|
+
const tell = (name, text, extra = "") => tellPeerAs(self, {
|
|
2088
|
+
name, text, public: b.public !== false, force,
|
|
2089
|
+
// lead 的活远超默认的 1h: 不给 deadline 就按 12h (回执的上限)。
|
|
2090
|
+
...(b.deadline !== undefined ? { deadline: b.deadline } : lead ? { deadline: 43200 } : {}),
|
|
2091
|
+
...(rootId ? { job: rootId } : {}),
|
|
2092
|
+
}, extra);
|
|
2093
|
+
const shown = cands.slice(0, 3).map((e) => `.${e.row.address || e.row.name}${e.row.busy ? " 忙" : ""}`);
|
|
2094
|
+
if (b.dryRun) {
|
|
2095
|
+
json(res, 200, { ok: true, dryRun: true, decision: d.kind, reason: d.why, ...(d.kind === "existing" ? { name: to || (d.kind === "existing" && d.row ? d.row.address || d.row.name : "") } : { tier: (b.tier ?? "").toString().trim() || tierFor(task, lead) }), ...(shown.length ? { candidates: shown } : {}) });
|
|
2096
|
+
return;
|
|
2097
|
+
}
|
|
2098
|
+
if (lead) {
|
|
2099
|
+
const head = (b.description ?? "").toString().trim() || task.split("\n")[0].trim();
|
|
2100
|
+
rootId = jobs.open(baseOfKey(self), self, truncate(head, 60), {
|
|
2101
|
+
kind: "req", ...(criteria ? { criteria, gate: { g1: now } } : {}),
|
|
2102
|
+
origin: { text: (currentHumanQueryOf(self) || task).trim(), chat: channelOf(self) },
|
|
2103
|
+
}).id;
|
|
2104
|
+
}
|
|
2105
|
+
// 没投成的根单当场收掉: 留着只是一张没人干的空单。
|
|
2106
|
+
const settleRoot = (ok) => { if (rootId && !ok)
|
|
2107
|
+
jobs.close(rootId, "派活没投成"); };
|
|
2108
|
+
const withRoot = (body) => rootId
|
|
2109
|
+
? { ...body, job: rootId, hint: "需求根单已开, 对人**别提单号**。lead 交付后根单仍开着, 等人验收: 人认可 → close_job(job) 归档并回收 lead; 人说不对 → tell_peer({name, re, job}) 给 lead 返工 (同一张单, 不开新单); 人说不要了 → close_job({job, as:\"cancel\"}); 先放着 → close_job({job, as:\"shelve\"}) (停提醒, 不关单)。" }
|
|
2110
|
+
: body;
|
|
2111
|
+
if (d.kind === "existing") {
|
|
2112
|
+
const name = to || (d.kind === "existing" && d.row ? d.row.address || d.row.name : "");
|
|
2113
|
+
const r = resolvePeer(self, name);
|
|
2114
|
+
if (!r.ok) {
|
|
2115
|
+
settleRoot(false);
|
|
2116
|
+
json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
|
|
2117
|
+
return;
|
|
2118
|
+
}
|
|
2119
|
+
// 已有 wizard 当 lead: 宪章是出生时定的 (里面没有 lead 一节), 规矩加打法挂在信封后面带过去;
|
|
2120
|
+
// 已是 lead 的宪章里已有层级规矩, 只带打法。投成了才记 lead, 它下次重生宪章里就有。
|
|
2121
|
+
const t = await tell(name, task, lead ? renderReminder({ wezard: "lead" }, wizards.get(r.target)?.lead ? renderLeadPlaybook() : renderLead()) : "");
|
|
2122
|
+
if (lead && t.status === 200)
|
|
2123
|
+
wizards.upsert(r.target, { lead: true });
|
|
2124
|
+
if (rootId && t.status === 200)
|
|
2125
|
+
jobs.attach(rootId, { target: r.target, task: "", spawned: false, role: "lead" });
|
|
2126
|
+
// 自动选中的被冷门控退回 (decide 用名册的活动时刻, 门控用 transcript 的, 口径差一点): 改走 spawn。
|
|
2127
|
+
if (!(t.body.gated === "cold" && !to)) {
|
|
2128
|
+
settleRoot(t.status === 200);
|
|
2129
|
+
json(res, t.status, withRoot({ ...t.body, decision: "existing", reason: d.why, ...(shown.length ? { candidates: shown } : {}) }));
|
|
2130
|
+
return;
|
|
2131
|
+
}
|
|
2132
|
+
d = { kind: "spawn", why: `${d.why}; 但它被冷门控退回 (${String(t.body.reason)}) —— 改为白板 spawn` };
|
|
2133
|
+
}
|
|
2134
|
+
const name = (b.name ?? "").toString().trim();
|
|
2135
|
+
const description = (b.description ?? "").toString().trim();
|
|
2136
|
+
if (!name || !description) {
|
|
2137
|
+
settleRoot(false);
|
|
2138
|
+
json(res, 400, { ok: false, decision: "spawn", reason: `${d.why} —— 要新生一个, 带上 name (这件事的短名) 与 description (它的职责) 重发`, ...(shown.length ? { candidates: shown } : {}) });
|
|
2139
|
+
return;
|
|
2140
|
+
}
|
|
2141
|
+
const tier = (b.tier ?? "").toString().trim() || tierFor(task, lead);
|
|
2142
|
+
const born = await bearAs(self, { inherit: false, name, description, tier, ...(lead ? { lead: true } : {}) });
|
|
2143
|
+
if (born.status !== 200) {
|
|
2144
|
+
settleRoot(false);
|
|
2145
|
+
json(res, born.status, { ...born.body, decision: "spawn", reason: d.why });
|
|
2146
|
+
return;
|
|
2147
|
+
}
|
|
2148
|
+
// 宪章里只有层级规矩, 打法随信封带 (宪章每轮都在上下文里, 打法只这一件活用得上)。
|
|
2149
|
+
const t = await tell(String(born.body.name), task, lead ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : "");
|
|
2150
|
+
settleRoot(t.status === 200);
|
|
2151
|
+
// 为这张根单生出来的: 归档 close_job 时要回收它 (tell 记的是 spawned:false, 再 attach 一次补上; lead 角色也在这里记)。
|
|
2152
|
+
if (rootId && t.status === 200)
|
|
2153
|
+
jobs.attach(rootId, { target: String(born.body.target), task: "", spawned: true, role: "lead" });
|
|
2154
|
+
json(res, t.status, withRoot({
|
|
2155
|
+
...t.body,
|
|
2156
|
+
decision: "spawn",
|
|
2157
|
+
reason: d.why,
|
|
2158
|
+
tier,
|
|
2159
|
+
...(born.body.model ? { model: born.body.model } : {}),
|
|
2160
|
+
...(born.body.effort ? { effort: born.body.effort } : {}),
|
|
2161
|
+
...(shown.length ? { candidates: shown } : {}),
|
|
2162
|
+
}));
|
|
1483
2163
|
});
|
|
1484
2164
|
// 记忆三种作用域: self 跟着自己 (wizards.json), 直写; chat / workspace 是共享的 md,
|
|
1485
2165
|
// 每个在那个群 / 那个目录出生的 wizard 都会读到 —— 它们只收提议, 由整理者合并
|
|
@@ -1508,6 +2188,7 @@ const main = async () => {
|
|
|
1508
2188
|
const queued = proposeMemory(inboxPath(cfg.daemon.stateDir, scope, key), {
|
|
1509
2189
|
at: Date.now(),
|
|
1510
2190
|
by: selfAddress(self),
|
|
2191
|
+
...(scope === "workspace" ? { cwd: key } : {}),
|
|
1511
2192
|
...(note ? { note } : {}),
|
|
1512
2193
|
...(forget ? { forget } : {}),
|
|
1513
2194
|
});
|
|
@@ -1515,161 +2196,220 @@ const main = async () => {
|
|
|
1515
2196
|
json(res, 500, { ok: false, reason: "提议没写进收件箱" });
|
|
1516
2197
|
return;
|
|
1517
2198
|
}
|
|
2199
|
+
// 不回全文: 每提一条就把整份 md 吐回上下文, 连交几条就是几倍的 token。
|
|
2200
|
+
// forget 是子串, 回它此刻命中几行 —— 0 行就是写错了, 当场能改。
|
|
2201
|
+
const lines = readMemory(path).split("\n").filter((l) => l.trim());
|
|
1518
2202
|
json(res, 200, {
|
|
1519
|
-
ok: true, scope, queued, file: path,
|
|
1520
|
-
|
|
2203
|
+
ok: true, scope, queued, file: path, lines: lines.length,
|
|
2204
|
+
...(forget ? { forgetHits: lines.filter((l) => l.includes(forget)).length } : {}),
|
|
2205
|
+
hint: "已提交给记忆整理者, 下一轮整理 (半小时内) 合并进 file; 此后出生的 wizard 才读得到",
|
|
1521
2206
|
});
|
|
1522
2207
|
return;
|
|
1523
2208
|
}
|
|
1524
2209
|
const cur = wizards.get(self)?.memory ?? [];
|
|
1525
2210
|
// 忘记按子串匹配: 模型记不住自己当初一字不差写了什么, 但记得大意。
|
|
1526
2211
|
const next = forget ? cur.filter((x) => !x.includes(forget)) : cur;
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
2212
|
+
// 先截再比: 注册表存的是截到 NOTE_MAX 的那版, 拿原文比就永远去不了重。
|
|
2213
|
+
const n = note.slice(0, NOTE_MAX);
|
|
2214
|
+
const want = n ? [...next.filter((x) => x !== n), n] : next;
|
|
2215
|
+
// 满额时注册表从最旧的挤掉 —— 挤掉的、截短的都要明说, 不让它以为全记住了。
|
|
2216
|
+
const dropped = want.slice(0, Math.max(0, want.length - MEMORY_MAX));
|
|
2217
|
+
const { memory } = wizards.upsert(self, { memory: want });
|
|
2218
|
+
json(res, 200, {
|
|
2219
|
+
ok: true, scope: "self", count: memory.length, added: !!note, forgotten: cur.length - next.length,
|
|
2220
|
+
...(dropped.length ? { dropped, hint: `self 记忆满 ${MEMORY_MAX} 条, 最旧的 ${dropped.length} 条被挤掉了 (见 dropped); 还要的话 forget 掉不重要的再记回来, 或合并成一条` } : {}),
|
|
2221
|
+
...(note.length > NOTE_MAX ? { truncated: `这条超过 ${NOTE_MAX} 字, 只记下了前 ${NOTE_MAX} 字` } : {}),
|
|
2222
|
+
});
|
|
1530
2223
|
});
|
|
1531
2224
|
// 生分身。默认 fork 调用方此刻的上下文 —— 这是"先把公共材料读进来, 再分出 N
|
|
1532
2225
|
// 个干活的"之所以省事的原因: 材料只读一遍, 却进了 N 份上下文。
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
if (!self) {
|
|
1536
|
-
json(res, 400, { ok: false, reason: "cannot resolve caller session" });
|
|
1537
|
-
return;
|
|
1538
|
-
}
|
|
2226
|
+
/** spawn_wizard / clone_wizard 的本体 —— HTTP 路由与 dispatch 共用。 */
|
|
2227
|
+
const bearAs = async (self, body) => {
|
|
1539
2228
|
const b = body;
|
|
2229
|
+
const askedEffort = parseEffort(b.effort);
|
|
2230
|
+
if (b.effort !== undefined && !askedEffort) {
|
|
2231
|
+
return { status: 400, body: { ok: false, reason: `effort 只有这几档: ${EFFORTS.join(" / ")}` } };
|
|
2232
|
+
}
|
|
2233
|
+
const pick = pickTier(cfg.models, b.tier, { cli: b.cli, model: b.model, effort: askedEffort }, b.inherit === true);
|
|
2234
|
+
if (!pick.ok) {
|
|
2235
|
+
return { status: 400, body: pick };
|
|
2236
|
+
}
|
|
2237
|
+
const effort = pick.effort;
|
|
1540
2238
|
// 工单先验: 生完分身才发现工单号打错了, 那个分身就成了没人认领的孤儿。
|
|
1541
2239
|
const jobId = (b.job ?? "").trim();
|
|
1542
2240
|
if (jobId) {
|
|
1543
|
-
const jc = checkJob(jobId);
|
|
2241
|
+
const jc = checkJob(jobId, undefined, !(b.task ?? "").toString().trim());
|
|
1544
2242
|
if (!jc.ok) {
|
|
1545
|
-
|
|
1546
|
-
return;
|
|
2243
|
+
return { status: jc.status, body: { ok: false, reason: jc.reason } };
|
|
1547
2244
|
}
|
|
1548
2245
|
}
|
|
1549
2246
|
// `inherit` 没有默认值 —— 继承与否是两种完全不同的分身 (一种开局就带着你
|
|
1550
2247
|
// 读过的一切, 另一种白纸一张), 猜错了要么白烧一份上下文, 要么让它从零重读。
|
|
1551
2248
|
// 逼调用方每次自己说。
|
|
1552
2249
|
if (typeof b.inherit !== "boolean") {
|
|
1553
|
-
|
|
1554
|
-
return;
|
|
2250
|
+
return { status: 400, body: { ok: false, reason: "inherit 必填: true = fork 你此刻的上下文 (它开局就有你读过的材料, 必须留在同一个 cwd); false = 空白分身, 只继承身份" } };
|
|
1555
2251
|
}
|
|
1556
2252
|
const inherit = b.inherit;
|
|
2253
|
+
// detached = 独立长住 (原 new_claude_session / 人在群里 `/new .x`): 不挂家谱、不占
|
|
2254
|
+
// 调用方的分身名额、不随工单回收。它和「fork 我的上下文」「归某张工单」都互斥。
|
|
2255
|
+
const detached = b.detached === true;
|
|
2256
|
+
if (detached && (inherit || (b.job ?? "").trim())) {
|
|
2257
|
+
return { status: 400, body: { ok: false, reason: "detached 只用于白板 (inherit:false) 且不带 job —— 独立长住的 wizard 不归你管, 也不随工单回收" } };
|
|
2258
|
+
}
|
|
1557
2259
|
// 克隆的源头: 默认是调用方自己; `from` 点名就 fork 那个 wizard 此刻的上下文。
|
|
1558
2260
|
// 分身仍归调用方管 (parent = self: 占它的预算、随它的工单回收), 只有上下文来自别处。
|
|
1559
2261
|
// 空串不能交给 resolvePeer —— 那是「本聊天的默认 wizard」, 不是「我自己」。
|
|
1560
2262
|
const fromAddr = (b.from ?? "").toString().trim();
|
|
1561
2263
|
if (fromAddr && !inherit) {
|
|
1562
|
-
|
|
1563
|
-
return;
|
|
2264
|
+
return { status: 400, body: { ok: false, reason: "from 只对克隆有意义 —— 白板生出来的 wizard 不带任何人的上下文" } };
|
|
1564
2265
|
}
|
|
1565
2266
|
const fromR = fromAddr ? resolvePeer(self, fromAddr) : { ok: true, target: self };
|
|
1566
2267
|
if (!fromR.ok) {
|
|
1567
|
-
|
|
1568
|
-
return;
|
|
2268
|
+
return { status: fromR.status, body: { ok: false, reason: fromR.reason, ...(fromR.candidates ? { candidates: fromR.candidates } : {}) } };
|
|
1569
2269
|
}
|
|
1570
2270
|
const source = fromR.target;
|
|
1571
2271
|
const sourceInfo = m.sessionInfo(source);
|
|
1572
2272
|
// 别人的会话 fork 不出来就是失败, 不能像克隆自己那样悄悄退化成白板 —— 调用方
|
|
1573
2273
|
// 要的恰恰是它的上下文, 给一张白纸等于答非所问。
|
|
1574
2274
|
if (source !== self && !sourceInfo?.sessionId) {
|
|
1575
|
-
|
|
1576
|
-
return;
|
|
2275
|
+
return { status: 409, body: { ok: false, reason: `${displayName(source)} 没有可 fork 的会话 (还没说过话, 或绑定已失效)` } };
|
|
1577
2276
|
}
|
|
1578
|
-
const alivePeers = await m.peers(self);
|
|
2277
|
+
const alivePeers = detached ? [] : await m.peers(self);
|
|
1579
2278
|
// 预算。撞到上限不是"不许再分", 是"先把干完活的收掉": stop_wizard 收单个,
|
|
1580
2279
|
// close_job 整批回收一个工单的临时分身。只数**本聊天里**活着的 ——
|
|
1581
2280
|
// 生到别的聊天去的归那边管, 为了数它们再探一遍 tmux 不值当。
|
|
1582
2281
|
const aliveKids = childrenOf(wizards.all(), self).filter((k) => alivePeers.some((pp) => pp.target === k.target && pp.paneAlive));
|
|
1583
|
-
if (aliveKids.length >= cfg.wrc.mirror.cloneMax) {
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
return;
|
|
2282
|
+
if (!detached && aliveKids.length >= cfg.wrc.mirror.cloneMax) {
|
|
2283
|
+
return { status: 429, body: {
|
|
2284
|
+
ok: false,
|
|
2285
|
+
reason: `你名下已经有 ${aliveKids.length} 个活着的分身 (上限 ${cfg.wrc.mirror.cloneMax}) —— 先 stop_wizard 收掉干完活的那些, 或者 close_job 整批回收一个工单`,
|
|
2286
|
+
clones: aliveKids.map((k) => peerAddress(cfg, self, k.target)),
|
|
2287
|
+
} };
|
|
1590
2288
|
}
|
|
1591
2289
|
const wantChat = (b.chat ?? "").toString().trim();
|
|
1592
2290
|
const base = wantChat ? chatBaseOf(cfg, wantChat) : baseOfKey(self);
|
|
1593
2291
|
if (!base) {
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
return;
|
|
2292
|
+
return { status: 404, body: {
|
|
2293
|
+
ok: false,
|
|
2294
|
+
reason: `unknown chat '${wantChat}' — 只有起过名字的聊天能被指名; 让那边先 /name`,
|
|
2295
|
+
candidates: listChatNames(cfg).map((c) => c.name),
|
|
2296
|
+
} };
|
|
1600
2297
|
}
|
|
1601
2298
|
const askedName = String(b.name ?? b.tag ?? "");
|
|
1602
|
-
|
|
2299
|
+
// 没起名: 独立长住的按目录名 (同 /new 与 new_claude_session), 分身按职责的首词。
|
|
2300
|
+
const fallback = (detached && b.cwd ? tagFromCwd(b.cwd) : "") || normalizeTag(b.description?.split(/\s+/)[0]) || "clone";
|
|
2301
|
+
const slotR = await claimSlot(base, askedName, fallback, [self, source]);
|
|
1603
2302
|
if (!slotR.ok) {
|
|
1604
|
-
|
|
1605
|
-
return;
|
|
2303
|
+
return { status: slotR.status, body: slotR.body };
|
|
1606
2304
|
}
|
|
1607
2305
|
const { target, slot: tag } = slotR;
|
|
2306
|
+
// claimSlot 会复用同名的冷记录 (detached 正是「叫回一个老名字」的场景): 生不出来时
|
|
2307
|
+
// 只回滚这一次的新记录, 不能把那个老 wizard 的记忆和家谱一起删掉。
|
|
2308
|
+
const existed = !!wizards.get(target);
|
|
1608
2309
|
const forkOf = inherit && source !== self ? source : undefined;
|
|
1609
2310
|
// 先落身份再 spawn: 宪章是从注册表渲染出来的, 记录不在就渲染出一个无名分身。
|
|
1610
2311
|
wizards.upsert(target, {
|
|
1611
2312
|
description: (b.description ?? "").toString().trim(),
|
|
1612
|
-
|
|
2313
|
+
// 显式写 undefined: upsert 是合并写, 复用的冷记录里旧的 parent 会留下来。
|
|
2314
|
+
parent: detached ? undefined : self,
|
|
1613
2315
|
bornAt: Date.now(),
|
|
1614
2316
|
clonedFrom: inherit ? sourceInfo?.sessionId ?? "" : "",
|
|
1615
2317
|
...(forkOf ? { forkOf } : {}),
|
|
2318
|
+
// fork 了上下文就一并继承 self 记忆: 系统提示不随 fork 走 (分身拿的是自己的宪章),
|
|
2319
|
+
// 不拷的话它开局就没有, /clear 或交接重开后更是彻底没了。标上来源; 已经是继承来的不再套一层。
|
|
2320
|
+
...(inherit ? { memory: (wizards.get(source)?.memory ?? []).map((x) => x.startsWith("(继承自 ") ? x : `(继承自 ${displayName(source)}) ${x}`) } : {}),
|
|
2321
|
+
tier: pick.tier,
|
|
2322
|
+
// 显式写 undefined: 复用的冷记录可能当过 lead。
|
|
2323
|
+
lead: b.lead === true ? true : undefined,
|
|
2324
|
+
// 生完才清; 这个进程中途没了, 下次开机由 sweepUnborn 认出并收掉。
|
|
2325
|
+
spawning: BOOT_ID,
|
|
1616
2326
|
});
|
|
1617
2327
|
const name = wizards.rename(target, normalizeTag(askedName) || tag);
|
|
1618
|
-
const charter = charterFor(target, { parent: self, forkOf, inherited: inherit });
|
|
2328
|
+
const charter = charterFor(target, { ...(detached ? {} : { parent: self }), forkOf, inherited: inherit });
|
|
1619
2329
|
const task = (b.task ?? "").toString().trim();
|
|
1620
2330
|
// 没点名就按配置的 keepalive.spawnDefault 来 —— 调用方明说的永远优先。
|
|
1621
2331
|
const keepalive = typeof b.keepalive === "boolean" ? b.keepalive : cfg.wrc.mirror.keepalive.spawnDefault;
|
|
1622
2332
|
// 先盖章再生: 分叉出来的 transcript 里躺着被克隆者的旧回复, 时刻早于这一枚章,
|
|
1623
2333
|
// wait_peer 才不会把它们当成分身对这件活的答复。
|
|
1624
2334
|
const taskAt = Date.now();
|
|
2335
|
+
const taskTurn = newTurn();
|
|
2336
|
+
const asker = task ? askerOf(self) : undefined;
|
|
1625
2337
|
const r = await m.cloneSession({
|
|
1626
2338
|
parent: source,
|
|
1627
2339
|
target,
|
|
1628
2340
|
windowName: name,
|
|
1629
|
-
cli:
|
|
1630
|
-
model:
|
|
2341
|
+
cli: pick.cli,
|
|
2342
|
+
model: pick.model,
|
|
2343
|
+
effort,
|
|
1631
2344
|
cwd: b.cwd,
|
|
1632
2345
|
systemPrompt: charter,
|
|
1633
2346
|
inherit,
|
|
1634
2347
|
// 继承路径上第一句话是分叉的触发器, 所以直接把活当开场白 —— 少一次往返,
|
|
1635
2348
|
// 也少一次"就位了但没事干"的空转。
|
|
1636
|
-
|
|
2349
|
+
// 子 lead 的宪章只有层级规矩, 打法跟第一句的信封走 (同 dispatch 对已有 wizard 的做法)。
|
|
2350
|
+
bootstrap: task ? task + envelopeFor(self, "", { turn: taskTurn }) + (b.lead === true ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : "") : undefined,
|
|
2351
|
+
...(task ? { bootstrapFrom: { from: { kind: "peer", from: self, turn: taskTurn, ...(jobId ? { job: jobId } : {}), ...(asker ? { asker } : {}) }, query: task } } : {}),
|
|
1637
2352
|
keepalive,
|
|
1638
|
-
});
|
|
2353
|
+
}).catch((e) => ({ ok: false, reason: `spawn threw: ${String(e)}`, inherited: false }));
|
|
2354
|
+
// 生不出来就回滚身份 —— 否则名字被一个永远没有会话的记录占住。
|
|
1639
2355
|
if (!r.ok) {
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
2356
|
+
if (existed)
|
|
2357
|
+
wizards.upsert(target, { spawning: undefined });
|
|
2358
|
+
else
|
|
2359
|
+
wizards.drop(target);
|
|
2360
|
+
return { status: 500, body: { ok: false, reason: r.reason } };
|
|
1643
2361
|
}
|
|
1644
|
-
wizards.upsert(target, { clonedFrom: r.inherited ? sourceInfo?.sessionId ?? "" : "", ...(r.inherited ? {} : { forkOf: undefined }) });
|
|
2362
|
+
wizards.upsert(target, { spawning: undefined, clonedFrom: r.inherited ? sourceInfo?.sessionId ?? "" : "", ...(r.inherited ? {} : { forkOf: undefined }) });
|
|
1645
2363
|
const kid = briefOf(self, target);
|
|
1646
|
-
// r.model 是 spawnTmuxClaude 通过 /model 实测确认落地的那个 —— 可能跟调用方
|
|
1647
|
-
// 传的原始字符串不一样 (口语化 → 目录里匹配到的关键词), 播报要报实情。
|
|
1648
|
-
const modelNote = r.model
|
|
1649
|
-
? (r.modelWarning ? ` · 模型 ${r.model} (⚠️ ${r.modelWarning})` : ` · 模型 ${r.model}`)
|
|
1650
|
-
: "";
|
|
1651
2364
|
// 出生不再发群气泡 (人要看的是结论, 不是谁生了谁 —— 过程在 rolepage 里);
|
|
1652
2365
|
// 同群的 wizard 仍要知道群里多了一个成员。工单里的临时工连这条也省掉。
|
|
1653
2366
|
if (!jobId) {
|
|
1654
|
-
|
|
2367
|
+
// 模型 / keepalive 不报: 那是生它的那位的事, 旁人要时 wizard_roster 有。
|
|
2368
|
+
postRoster(base, [target, self], {
|
|
2369
|
+
op: "born", name: `.${name}`, by: displayName(self), clone: !detached,
|
|
2370
|
+
...(r.inherited ? { ctx: forkOf ? displayName(forkOf) : displayName(self) } : {}),
|
|
2371
|
+
...(kid.description ? { job: kid.description } : {}), cwd: r.cwd,
|
|
2372
|
+
}, subjectOf(wizards.get(target)));
|
|
1655
2373
|
}
|
|
1656
2374
|
// 继承路径上活已经随开场白进去了, 空白分身才需要在这里补一次注入 (私聊)。
|
|
1657
2375
|
let dispatched = r.inherited && !!task;
|
|
1658
2376
|
if (task && !r.inherited) {
|
|
1659
|
-
const inj = await m.injectText(target, task, undefined, { from: { kind: "peer", from: self, ...(jobId ? { job: jobId } : {}) }, channel: "", envelope: envelopeFor(self, "") });
|
|
2377
|
+
const inj = await m.injectText(target, task, undefined, { from: { kind: "peer", from: self, turn: taskTurn, ...(jobId ? { job: jobId } : {}), ...(asker ? { asker } : {}) }, channel: "", envelope: envelopeFor(self, "", { turn: taskTurn }) });
|
|
1660
2378
|
dispatched = inj.ok;
|
|
1661
2379
|
}
|
|
1662
|
-
if (jobId)
|
|
1663
|
-
|
|
2380
|
+
if (jobId) {
|
|
2381
|
+
const role = roleParam(body.role);
|
|
2382
|
+
const from = inherit ? source : undefined;
|
|
2383
|
+
jobs.attach(jobId, { target, task, spawned: true, ...(role ? { role } : {}), ...(from ? { forkOf: from } : {}) });
|
|
2384
|
+
}
|
|
2385
|
+
if (jobId && dispatched)
|
|
2386
|
+
jobs.spend(jobId);
|
|
1664
2387
|
// 分身的第一件活也守回执: fan-out 最常见的形状就是 clone_wizard({task}) × N,
|
|
1665
2388
|
// 让它们干完自己把结论送回来, 发起方不必挂在 wait_peer 上。
|
|
2389
|
+
const k = body.chain === false ? undefined : parentKOf(self);
|
|
2390
|
+
if (dispatched)
|
|
2391
|
+
receipts.register({ from: self, to: target, channel: "", job: jobId, at: taskAt, turn: taskTurn, ...(k ? { k } : {}), ...(asker ? { asker } : {}) });
|
|
1666
2392
|
if (dispatched)
|
|
1667
|
-
|
|
1668
|
-
|
|
2393
|
+
ledger.open({ turn: taskTurn, owner: self, to: target, text: task, at: taskAt, ...(jobId ? { job: jobId } : {}), ...(k ? { for: k } : {}), ...(asker ? { asker } : {}) });
|
|
2394
|
+
return { status: 200, body: { ok: true, target, name, address: name, inherited: r.inherited, sessionId: r.sessionId, cwd: r.cwd, dispatched, ...(dispatched ? { turn: taskTurn } : {}), keepalive, ...(r.model ? { model: r.model } : {}), ...(r.modelWarning ? { modelWarning: r.modelWarning } : {}), ...(r.effort ? { effort: r.effort } : {}), ...(pick.tier ? { tier: pick.tier } : {}), via: pick.via, ...(pick.note ? { tierNote: pick.note } : {}), ...(jobId ? { job: jobId } : {}), ...(detached ? { detached: true } : {}) } };
|
|
2395
|
+
};
|
|
2396
|
+
http.register("POST /wizard/clone", async (req, res) => {
|
|
2397
|
+
const { self, body } = await readPeerBody(req);
|
|
2398
|
+
if (!self) {
|
|
2399
|
+
json(res, 400, { ok: false, reason: "cannot resolve caller session" });
|
|
2400
|
+
return;
|
|
2401
|
+
}
|
|
2402
|
+
const r = await bearAs(self, body);
|
|
2403
|
+
json(res, r.status, r.body);
|
|
1669
2404
|
});
|
|
1670
2405
|
// 收掉一个 wizard。interrupt = 打断它这一轮 (Esc); end = 结束它并回收 pane。
|
|
1671
2406
|
// 自己终结自己是合法的 (分身干完活自我了结), 代价是这次工具调用不会返回 ——
|
|
1672
2407
|
// 回执只在 rolepage (生命周期事件不进群); 同群的 wizard 从名册增量里得知。
|
|
2408
|
+
/** 抹掉一个 wizard 的身份; 它在开着的工单里没落定的那一份记成 canceled —— 名字都没了, 不会再落定。 */
|
|
2409
|
+
const forgetWizard = (target) => {
|
|
2410
|
+
wizards.drop(target);
|
|
2411
|
+
return jobs.detach(target);
|
|
2412
|
+
};
|
|
1673
2413
|
http.register("POST /wizard/stop", async (req, res) => {
|
|
1674
2414
|
const { self, body } = await readPeerBody(req);
|
|
1675
2415
|
if (!self) {
|
|
@@ -1678,31 +2418,78 @@ const main = async () => {
|
|
|
1678
2418
|
}
|
|
1679
2419
|
const b = body;
|
|
1680
2420
|
const r = resolvePeer(self, addrOf(b));
|
|
2421
|
+
// 有身份没会话 (冷记录 / 生到一半的僵尸): resolvePeer 够不着它, 但收它不需要
|
|
2422
|
+
// 会话 —— end 视为早已结束, forget 删掉记录、腾出名字。interrupt 无从谈起。
|
|
2423
|
+
const found = r.ok || r.candidates?.length ? undefined : wizards.byName(addrOf(b));
|
|
2424
|
+
const cold = found && !m.chatTargets(baseOfKey(found.target)).includes(found.target) ? found : undefined;
|
|
2425
|
+
if (cold && (b.mode ?? "end") === "end") {
|
|
2426
|
+
const detached = b.forget ? forgetWizard(cold.target) : [];
|
|
2427
|
+
json(res, 200, { ok: true, target: cold.target, name: cold.name, mode: "end", forgotten: !!b.forget, note: "它本来就没有会话在跑", ...(detached.length ? { canceledIn: detached } : {}) });
|
|
2428
|
+
return;
|
|
2429
|
+
}
|
|
1681
2430
|
if (!r.ok) {
|
|
1682
2431
|
json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
|
|
1683
2432
|
return;
|
|
1684
2433
|
}
|
|
1685
2434
|
const { target } = r;
|
|
1686
2435
|
const victim = briefOf(self, target);
|
|
2436
|
+
// 当事人要在 forget 抹掉记录之前取 (见 subjectOf)。
|
|
2437
|
+
const victimRec = wizards.get(target);
|
|
1687
2438
|
const end = (b.mode ?? "end") === "end";
|
|
1688
2439
|
if (!end && target === self) {
|
|
1689
2440
|
json(res, 400, { ok: false, reason: "打断自己没有意义 —— 你就是正在生成的那一个" });
|
|
1690
2441
|
return;
|
|
1691
2442
|
}
|
|
2443
|
+
// 按件号撤回自己派的那一件: 它此刻正在做的就是这件才按 Esc; 否则 (排着 / 在做别人的)
|
|
2444
|
+
// 只把这件记成 canceled —— 它之后答出来也不再回执, 手上别人的那一轮不受影响。
|
|
2445
|
+
const turn = (b.turn ?? "").replace(/[`\s]/g, "");
|
|
2446
|
+
if (!end && turn) {
|
|
2447
|
+
const mine = receipts.pending(self, target);
|
|
2448
|
+
if (mine?.turn !== turn) {
|
|
2449
|
+
json(res, 404, { ok: false, reason: `你没有在等 ${victim.name ? `.${victim.name}` : target} 答 '${turn}' 这件 (已落定、件号写错, 或不是你派的)` });
|
|
2450
|
+
return;
|
|
2451
|
+
}
|
|
2452
|
+
// 先落 canceled 再按 Esc: 被打断那一轮的半截话不该被 watcher 当成答案投回来。
|
|
2453
|
+
const busy = !(await m.idleNow(target));
|
|
2454
|
+
const canceled = receipts.cancel(target, self, (x) => x.from === self && x.turn === turn);
|
|
2455
|
+
// 按 Esc 之前再认一次手上这一轮: 上面那次 await 期间它可能已经做完这件、接了别人的下一件。
|
|
2456
|
+
const current = busy && openingAsk(target)?.turn === turn;
|
|
2457
|
+
// 还没轮到 / 排在后面: 捎一句给它, 轮到那句话时别再做了 (notices 挂在下一条注入的尾巴上)。
|
|
2458
|
+
if (!current)
|
|
2459
|
+
notices.post([target], `${displayName(self)} 撤回了它派给你的 ${turn} —— 那件活不用做了; 读到它时直接略过, 不必回复。`);
|
|
2460
|
+
// 只按 Esc, 不 teardown: teardown 会清掉它的注入队列, 别人排着的话一起丢。
|
|
2461
|
+
const esc = current ? await m.interruptPane(target) : undefined;
|
|
2462
|
+
// Esc 没按成也不回滚撤回: 这件已经不等了, 只是它手上那一轮还在跑 —— 如实说。
|
|
2463
|
+
json(res, 200, {
|
|
2464
|
+
ok: true, target, name: victim.name, mode: "interrupt", turn, canceled, interrupted: !!esc?.ok,
|
|
2465
|
+
...(esc && !esc.ok ? { note: `这件已撤回 (不会再回执给你), 但 Esc 没按成 (${esc.reason ?? "unknown"}), 它这一轮可能还在跑` } : {}),
|
|
2466
|
+
...(current ? {} : { note: "它此刻没在做这件 (还排着, 或在做别人的活), 没按 Esc —— 这件已撤回, 它之后答出来也不会再回执给你" }),
|
|
2467
|
+
});
|
|
2468
|
+
return;
|
|
2469
|
+
}
|
|
2470
|
+
// 不带件号的打断会停掉它手上整轮: 那一轮若是别的 wizard 派的, 先拒 —— 误伤的是别人的活。
|
|
2471
|
+
const others = !end && !b.force ? await othersTurn(target, self) : undefined;
|
|
2472
|
+
if (others) {
|
|
2473
|
+
json(res, 409, { ok: false, target, reason: `它此刻在做 ${others.from} 派的活${others.turn ? ` (${others.turn})` : ""}, 打断会连那件一起停掉 —— 只想撤回你自己派的那件, 传 turn (件号); 确要打断它这一轮, 加 force:true` });
|
|
2474
|
+
return;
|
|
2475
|
+
}
|
|
2476
|
+
// 先落 canceled 再动 pane: pane 一死, 守着的 watcher 会把同一份报成 dead。
|
|
2477
|
+
// 打断只停它手上这一轮: 只掐这一轮开头那句派来的活 (人 / 定时 / 回执轮不掐任何一份)。
|
|
2478
|
+
const canceled = receipts.cancel(target, self, end ? undefined : currentAsk(target));
|
|
1692
2479
|
const done = end ? await m.killPane(target) : await m.interruptPane(target, { teardown: true });
|
|
1693
2480
|
if (!done.ok) {
|
|
1694
2481
|
json(res, 502, { ok: false, target, reason: done.reason });
|
|
1695
2482
|
return;
|
|
1696
2483
|
}
|
|
1697
|
-
|
|
1698
|
-
wizards.drop(target);
|
|
2484
|
+
const detached = end && b.forget ? forgetWizard(target) : [];
|
|
1699
2485
|
// 打断只是停了它这一轮, 它还在; 只有结束才是名册变了。
|
|
1700
2486
|
if (end)
|
|
1701
|
-
postRoster(baseOfKey(target), [target, self],
|
|
1702
|
-
json(res, 200, { ok: true, target, name: victim.name, mode: end ? "end" : "interrupt", forgotten: !!(end && b.forget) });
|
|
2487
|
+
postRoster(baseOfKey(target), [target, self], { op: "ended", name: victim.name ? `.${victim.name}` : target, by: displayName(self), ...(b.forget ? { why: "forgotten" } : {}) }, subjectOf(victimRec));
|
|
2488
|
+
json(res, 200, { ok: true, target, name: victim.name, mode: end ? "end" : "interrupt", forgotten: !!(end && b.forget), ...(canceled ? { canceled } : {}), ...(detached.length ? { canceledIn: detached } : {}) });
|
|
1703
2489
|
});
|
|
1704
|
-
//
|
|
1705
|
-
//
|
|
2490
|
+
// 换模型 / effort: 省略名字 = 换自己 (选择器盖在正在跑的这一轮上面照样能开), 点名 =
|
|
2491
|
+
// 换那个 wizard。与 spawn 时的 `model` 是同一条路 (model-select.ts); effort 走
|
|
2492
|
+
// `/effort` 档位条的「只本会话」键, 不碰全机默认。两样都给就先模型后档位。
|
|
1706
2493
|
http.register("POST /wizard/model", async (req, res) => {
|
|
1707
2494
|
const { self, body } = await readPeerBody(req);
|
|
1708
2495
|
if (!self) {
|
|
@@ -1711,8 +2498,13 @@ const main = async () => {
|
|
|
1711
2498
|
}
|
|
1712
2499
|
const b = body;
|
|
1713
2500
|
const model = (b.model ?? "").toString().trim();
|
|
1714
|
-
|
|
1715
|
-
|
|
2501
|
+
const effort = parseEffort(b.effort);
|
|
2502
|
+
if (b.effort !== undefined && !effort) {
|
|
2503
|
+
json(res, 400, { ok: false, reason: `effort 只有这几档: ${EFFORTS.join(" / ")}` });
|
|
2504
|
+
return;
|
|
2505
|
+
}
|
|
2506
|
+
if (!model && !effort) {
|
|
2507
|
+
json(res, 400, { ok: false, reason: "model / effort 至少给一个 —— model 口语化写就行 ('opus' / 'haiku' / 'sonnet 5')" });
|
|
1716
2508
|
return;
|
|
1717
2509
|
}
|
|
1718
2510
|
const scope = (b.scope ?? "session").toString().trim();
|
|
@@ -1727,8 +2519,18 @@ const main = async () => {
|
|
|
1727
2519
|
json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
|
|
1728
2520
|
return;
|
|
1729
2521
|
}
|
|
1730
|
-
const done = await m.setModel(r.target, model, scope);
|
|
1731
|
-
|
|
2522
|
+
const done = model ? await m.setModel(r.target, model, scope) : undefined;
|
|
2523
|
+
const eff = effort && (done?.ok ?? true) ? await m.setEffort(r.target, effort) : undefined;
|
|
2524
|
+
const ok = (done?.ok ?? true) && (eff?.ok ?? true);
|
|
2525
|
+
json(res, ok ? 200 : 502, {
|
|
2526
|
+
...done,
|
|
2527
|
+
ok,
|
|
2528
|
+
target: r.target,
|
|
2529
|
+
name: briefOf(self, r.target).name,
|
|
2530
|
+
...(done?.ok ? { model: done.applied } : {}),
|
|
2531
|
+
...(eff?.ok ? { effort: eff.applied } : {}),
|
|
2532
|
+
...(eff && !eff.ok ? { reason: [done?.reason, `effort: ${eff.reason}`].filter(Boolean).join("; ") } : {}),
|
|
2533
|
+
});
|
|
1732
2534
|
});
|
|
1733
2535
|
// 自我交接: 上下文撑不住了, 自己把工作压成简报, /new 换一个全新进程, 再把简报贴
|
|
1734
2536
|
// 回去。和 /handoff 的区别是**简报由调用方自己写在入参里** —— 它没法在自己
|
|
@@ -1740,15 +2542,8 @@ const main = async () => {
|
|
|
1740
2542
|
json(res, 400, { ok: false, reason: "cannot resolve caller session" });
|
|
1741
2543
|
return;
|
|
1742
2544
|
}
|
|
1743
|
-
const
|
|
1744
|
-
|
|
1745
|
-
json(res, 400, { ok: false, reason: "brief 太短 —— 要写到零上下文也能接手: 目标 / 已完成 / 当前状态 / 下一步 / 关键文件与坑" });
|
|
1746
|
-
return;
|
|
1747
|
-
}
|
|
1748
|
-
const info = m.sessionInfo(self);
|
|
1749
|
-
// 应答之后再干活: 调用方此刻还在生成, 交接要等它把话说完 (见 handoff.ts)。
|
|
1750
|
-
const r = handoffs.self(self, brief);
|
|
1751
|
-
json(res, r.ok ? 200 : 409, r.ok ? { ok: true, target: self, contextTokens: info?.contextTokens ?? 0, scheduled: true } : { ok: false, reason: r.reason });
|
|
2545
|
+
const r = handoffSelf(self, (body.brief ?? "").toString().trim());
|
|
2546
|
+
json(res, r.status, r.body);
|
|
1752
2547
|
});
|
|
1753
2548
|
// ── Job: 一次 fan-out 的工单 ─────────────────────────────────────────
|
|
1754
2549
|
// 账本, 不是编排器: 控制流留在发起的那个 wizard 手里 (它自己 spawn / wait /
|
|
@@ -1762,19 +2557,48 @@ const main = async () => {
|
|
|
1762
2557
|
}
|
|
1763
2558
|
const b = body;
|
|
1764
2559
|
const title = (b.title ?? "").toString().trim();
|
|
2560
|
+
const expect = Math.min(JOB_MEMBER_MAX, Math.max(0, Math.floor(Number(b.expect) || 0)));
|
|
1765
2561
|
if (!title) {
|
|
1766
2562
|
json(res, 400, { ok: false, reason: "title required —— 一句话说清这个工单要干成什么" });
|
|
1767
2563
|
return;
|
|
1768
2564
|
}
|
|
1769
2565
|
const base = baseOfKey(self);
|
|
1770
|
-
const
|
|
1771
|
-
|
|
2566
|
+
const maxTurns = Math.min(200, Math.max(0, Math.floor(Number(b.maxTurns) || 0)));
|
|
2567
|
+
const plan = (b.plan ?? "").toString().trim();
|
|
2568
|
+
const criteria = (b.criteria ?? "").toString().trim();
|
|
2569
|
+
// 上级工单: 显式传的要求自己是它的成员或开单者; 不传 = 自己作为未落定成员所在的、最近开的那张
|
|
2570
|
+
// (lead 在需求根单里领了活, 开的第一张子单自然挂在根单下, 不用自己记号)。
|
|
2571
|
+
const all = jobs.all();
|
|
2572
|
+
const mineIn = (j) => j.owner === self || j.members.some((x) => x.target === self);
|
|
2573
|
+
const askedParent = (b.parent ?? "").toString().trim();
|
|
2574
|
+
// 推断: 同一个群里、我作为未落定成员的开着的单, 取我**最近一次领活** (mm.at) 的那张; 它已在最深一层就不挂了 (退成根单, 不 409)。
|
|
2575
|
+
const inferred = all
|
|
2576
|
+
.filter((j) => j.base === base && j.status === "open")
|
|
2577
|
+
.flatMap((j) => j.members.filter((x) => x.target === self && !x.outcome).map((x) => ({ j, at: x.at })))
|
|
2578
|
+
.sort((x, y) => y.at - x.at)[0]?.j;
|
|
2579
|
+
const parentJob = askedParent ? jobs.get(askedParent) : inferred && depthOf(jobs.get, inferred) < JOB_DEPTH_MAX ? inferred : undefined;
|
|
2580
|
+
if (askedParent && !parentJob) {
|
|
2581
|
+
json(res, 404, { ok: false, reason: `没有上级工单 '${askedParent}'` });
|
|
2582
|
+
return;
|
|
2583
|
+
}
|
|
2584
|
+
if (askedParent && parentJob && (parentJob.status !== "open" || !mineIn(parentJob))) {
|
|
2585
|
+
json(res, 409, { ok: false, reason: `上级工单 '${askedParent}' 已收工, 或你既不是它的成员也不是它的开单者` });
|
|
2586
|
+
return;
|
|
2587
|
+
}
|
|
2588
|
+
if (parentJob && depthOf(jobs.get, parentJob) + 1 > JOB_DEPTH_MAX) {
|
|
2589
|
+
json(res, 409, { ok: false, reason: `工单树最深 ${JOB_DEPTH_MAX} 层, 你这张 (挂在 ${parentJob.id} 下) 会是第 ${depthOf(jobs.get, parentJob) + 1} 层 —— 子活交给现有成员, 别再往下长` });
|
|
2590
|
+
return;
|
|
2591
|
+
}
|
|
2592
|
+
// 不出群气泡: 工单是私下的过程, 人看到的只有发起者那一轮的最终回复。
|
|
2593
|
+
const accept = ACCEPTS.find((x) => x === b.accept);
|
|
2594
|
+
const job = jobs.open(base, self, title, { ...(plan ? { plan } : {}), ...(accept ? { accept } : {}), ...(expect ? { expect } : {}), ...(maxTurns ? { maxTurns } : {}), ...(parentJob ? { parent: parentJob.id } : {}), ...(criteria ? { criteria } : {}) });
|
|
1772
2595
|
json(res, 200, {
|
|
1773
2596
|
ok: true,
|
|
1774
2597
|
job: job.id,
|
|
1775
2598
|
title,
|
|
2599
|
+
...(parentJob ? { parent: parentJob.id } : {}),
|
|
1776
2600
|
memberMax: JOB_MEMBER_MAX,
|
|
1777
|
-
hint: "把这个 id 传给 spawn_wizard / clone_wizard /
|
|
2601
|
+
hint: "把这个 id 传给 spawn_wizard / clone_wizard / tell_peer 的 `job` 参数, 它们就归到这个工单名下 (一律私聊, 不进群); 活干完调 close_job 留档并回收临时分身, 给人的结论写在你自己这一轮的最终回复里。",
|
|
1778
2602
|
});
|
|
1779
2603
|
});
|
|
1780
2604
|
http.register("POST /jobs/close", async (req, res) => {
|
|
@@ -1794,14 +2618,97 @@ const main = async () => {
|
|
|
1794
2618
|
json(res, 409, { ok: false, reason: `工单 '${id}' 已经收工了`, closedAt: job.closedAt });
|
|
1795
2619
|
return;
|
|
1796
2620
|
}
|
|
1797
|
-
//
|
|
1798
|
-
|
|
2621
|
+
// 只有这张单或它某个祖先单的 owner 能关 (成员、同群的旁人不行)。
|
|
2622
|
+
if (![job, ...jobAncestors(jobs.get, job)].some((j) => j.owner === self)) {
|
|
2623
|
+
json(res, 403, { ok: false, reason: `工单 '${id}' 不是你开的, 也不是你开的某张上级单的后代 —— 只有开单者 (或上级单的开单者) 能收工` });
|
|
2624
|
+
return;
|
|
2625
|
+
}
|
|
2626
|
+
// 四种收法 (只 accept / cancel 真关单): shelve / resume 只动需求根单的搁置标记, 单照旧开着。
|
|
2627
|
+
const how = ["accept", "cancel", "shelve", "resume"].find((x) => x === (b.as ?? "accept"));
|
|
2628
|
+
if (!how) {
|
|
2629
|
+
json(res, 400, { ok: false, reason: `as 只认 accept / cancel / shelve / resume, 收到 '${b.as}'` });
|
|
2630
|
+
return;
|
|
2631
|
+
}
|
|
2632
|
+
if ((how === "shelve" || how === "resume") && job.kind !== "req") {
|
|
2633
|
+
json(res, 400, { ok: false, reason: `只有需求根单能搁置 / 恢复, '${id}' 不是 (它不是 dispatch({lead:true}) 开的根单)` });
|
|
2634
|
+
return;
|
|
2635
|
+
}
|
|
2636
|
+
if (how === "shelve" || how === "resume") {
|
|
2637
|
+
// 恢复不是「又交付了」: 保留 n, lastAt 记成此刻, 下一次冒泡从现在起算, 不一恢复就立刻冒。
|
|
2638
|
+
jobs.mark(id, how === "shelve" ? { hold: Date.now() } : { hold: undefined, nudge: { n: job.nudge?.n ?? 0, lastAt: Date.now() } });
|
|
2639
|
+
json(res, 200, { ok: true, job: id, [how === "shelve" ? "shelved" : "resumed"]: true });
|
|
2640
|
+
return;
|
|
2641
|
+
}
|
|
2642
|
+
// 关一张单连它还开着的后代单一起关 (深的先关): 否则子 lead 的分身留在原地、账本上挂着没人收的单。
|
|
2643
|
+
// 回收只针对**为这个工单生出来的**分身: 被拉来帮忙的长期 wizard 不该因为一次活结束就被杀掉。
|
|
2644
|
+
// 调用方自己也不收 —— 那会在这次工具调用里把自己干掉。
|
|
1799
2645
|
const recycle = b.stop !== false;
|
|
1800
|
-
const
|
|
1801
|
-
const
|
|
1802
|
-
const
|
|
1803
|
-
|
|
1804
|
-
|
|
2646
|
+
const openKids = (j) => jobChildren(jobs.all(), j.id).filter((c) => c.status === "open").flatMap((c) => [...openKids(c), c]);
|
|
2647
|
+
const cascade = openKids(job);
|
|
2648
|
+
const closeOne = async (j, summary, mark) => {
|
|
2649
|
+
const victims = recycle ? j.members.filter((mm) => mm.spawned && mm.target !== self) : [];
|
|
2650
|
+
// 收掉之前把它们手上没落定的活记成 canceled: 不然发起者收工之后还会收到一串 dead 回执。
|
|
2651
|
+
victims.forEach((mm) => receipts.cancel(mm.target, j.owner));
|
|
2652
|
+
// 留档要的 sessionId 得在回收之前取: killPane 连绑定一起删。
|
|
2653
|
+
const sids = new Map([j.owner, ...j.members.map((mm) => mm.target)].map((t) => [t, m.sessionInfo(t)?.sessionId ?? ""]));
|
|
2654
|
+
const killed = await victims.reduce(async (acc, mm) => (await acc) + ((await m.killPane(mm.target)).ok ? 1 : 0), Promise.resolve(0));
|
|
2655
|
+
// 取消: 还没落定、又不是为这张单生的成员 (长住 wizard) 不杀, 但它们名下这张单派的活记成 canceled, 发起者不会再收到一串回执。
|
|
2656
|
+
if (how === "cancel")
|
|
2657
|
+
j.members.filter((mm) => !mm.outcome && !victims.includes(mm)).forEach((mm) => receipts.cancel(mm.target, j.owner, (x) => x.from === j.owner));
|
|
2658
|
+
const closed = jobs.close(j.id, summary, mark);
|
|
2659
|
+
ledger.closeJob(j.id);
|
|
2660
|
+
// 收工结论留档到开单者名下 (与交接简报同一份 jsonl); 空工单 (没人、没结论) 不记。
|
|
2661
|
+
if (closed.members.length || closed.summary?.trim()) {
|
|
2662
|
+
const ep = jobEpisode(closed, { nameOf: episodeName, sidOf: (t) => sids.get(t) ?? "", chat: chatNameOf(cfg, j.base) || j.base });
|
|
2663
|
+
if (!appendEpisode(episodePath(cfg.daemon.stateDir, ep.name), ep))
|
|
2664
|
+
log.warn({ job: j.id, name: ep.name }, "job episode not archived");
|
|
2665
|
+
}
|
|
2666
|
+
return { members: closed.members.length, killed, kept: victims.length - killed };
|
|
2667
|
+
};
|
|
2668
|
+
const rs = await [...cascade.map((c) => [c, `随上级单 ${id} 收工`]), [job, how === "cancel" ? `取消: ${(b.summary ?? "").toString()}` : (b.summary ?? "").toString()]]
|
|
2669
|
+
// 根单收工: accept = G3 验收过闸 (人认可), cancel = 取消; 非根单没有闸。
|
|
2670
|
+
.reduce(async (acc, [j, sum]) => [...(await acc), await closeOne(j, sum, j !== job ? undefined : how === "cancel" ? { end: "cancel" } : job.kind === "req" ? { gate: { g3: Date.now() }, end: "accept" } : undefined)], Promise.resolve([]));
|
|
2671
|
+
const sum = (f) => rs.reduce((n, r) => n + f(r), 0);
|
|
2672
|
+
json(res, 200, { ok: true, job: id, members: rs[rs.length - 1].members, recycled: sum((r) => r.killed), kept: sum((r) => r.kept), ...(cascade.length ? { cascaded: cascade.map((c) => c.id) } : {}) });
|
|
2673
|
+
});
|
|
2674
|
+
// 挂起事项全表 (见 pending-items.ts): 你派出去、还没了结的事。`drop` 显式消项;
|
|
2675
|
+
// `name` 看别人的 (只读)。读过全表就算刚给过一次: 自动的全表提醒从此刻重新计。
|
|
2676
|
+
http.register("POST /pending", async (req, res) => {
|
|
2677
|
+
const { self, body } = await readPeerBody(req);
|
|
2678
|
+
if (!self) {
|
|
2679
|
+
json(res, 400, { ok: false, reason: "cannot resolve caller session" });
|
|
2680
|
+
return;
|
|
2681
|
+
}
|
|
2682
|
+
const b = body;
|
|
2683
|
+
const who = (b.name ?? "").toString().trim();
|
|
2684
|
+
const r = who ? resolvePeer(self, who) : undefined;
|
|
2685
|
+
if (r && !r.ok) {
|
|
2686
|
+
json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
|
|
2687
|
+
return;
|
|
2688
|
+
}
|
|
2689
|
+
const owner = r?.ok ? r.target : self;
|
|
2690
|
+
const drop = Array.isArray(b.drop) ? b.drop.map(String) : typeof b.drop === "string" ? [b.drop] : [];
|
|
2691
|
+
if (drop.length && owner !== self) {
|
|
2692
|
+
json(res, 403, { ok: false, reason: "只能消自己派出去的" });
|
|
2693
|
+
return;
|
|
2694
|
+
}
|
|
2695
|
+
const dropped = drop.length ? ledger.drop(self, drop, (b.why ?? "").toString().trim()) : [];
|
|
2696
|
+
const now = Date.now();
|
|
2697
|
+
const items = ledger.openOf(owner);
|
|
2698
|
+
if (owner === self) {
|
|
2699
|
+
const i = m.sessionInfo(self);
|
|
2700
|
+
if (i)
|
|
2701
|
+
pendingMarks.set(self, { sid: i.sessionId, ctx: i.contextTokens, ctxAtFull: i.contextTokens, drains: 0 });
|
|
2702
|
+
}
|
|
2703
|
+
const missed = drop.filter((t) => !dropped.some((x) => x.turn === t.replace(/[`\s]/g, "")));
|
|
2704
|
+
json(res, 200, {
|
|
2705
|
+
ok: true,
|
|
2706
|
+
text: [
|
|
2707
|
+
...(dropped.length ? [`已消掉: ${dropped.map((x) => `\`${x.turn}\``).join(" ")}`] : []),
|
|
2708
|
+
...(missed.length ? [`没找到 (不是你开着的): ${missed.join(" ")}`] : []),
|
|
2709
|
+
renderTable(items, liveOfTurns(), pendingNames, now, awaitingRows(owner, now), stalledRows(owner), shelvedRows(owner, now)),
|
|
2710
|
+
].join("\n"),
|
|
2711
|
+
});
|
|
1805
2712
|
});
|
|
1806
2713
|
http.register("POST /jobs/list", async (req, res) => {
|
|
1807
2714
|
const { self } = await readPeerBody(req);
|
|
@@ -1809,15 +2716,36 @@ const main = async () => {
|
|
|
1809
2716
|
json(res, 400, { ok: false, reason: "cannot resolve caller session" });
|
|
1810
2717
|
return;
|
|
1811
2718
|
}
|
|
2719
|
+
const inflight = receipts.states();
|
|
2720
|
+
const live = jobLive();
|
|
2721
|
+
const everything = jobs.all();
|
|
2722
|
+
// 本群还开着的, 外加它们名下已收工的子单 (账本留着, 面包屑 / 树才不断)。
|
|
2723
|
+
const shown = everything.filter((j) => j.base === baseOfKey(self) && (j.status === "open" || jobAncestors(jobs.get, j).some((a) => a.status === "open")));
|
|
1812
2724
|
json(res, 200, {
|
|
1813
2725
|
ok: true,
|
|
1814
|
-
jobs:
|
|
2726
|
+
jobs: treeOrder(shown).map(([j, depth]) => ({
|
|
1815
2727
|
job: j.id,
|
|
1816
2728
|
title: j.title,
|
|
2729
|
+
depth,
|
|
2730
|
+
...(j.parent ? { parent: j.parent } : {}),
|
|
2731
|
+
...(j.kind ? { kind: j.kind } : {}),
|
|
2732
|
+
stage: jobStage(j, everything, live),
|
|
2733
|
+
...(j.criteria ? { criteria: j.criteria } : {}),
|
|
2734
|
+
status: j.status,
|
|
1817
2735
|
owner: displayName(j.owner),
|
|
1818
2736
|
mine: j.owner === self,
|
|
1819
2737
|
openedAt: j.openedAt,
|
|
1820
|
-
|
|
2738
|
+
...jobs.tally(j.id),
|
|
2739
|
+
...(j.maxTurns ? { budget: `${j.turns ?? 0}/${j.maxTurns}` } : {}),
|
|
2740
|
+
members: j.members.map((mm) => ({
|
|
2741
|
+
address: peerAddress(cfg, self, mm.target),
|
|
2742
|
+
role: mm.role ?? "exec",
|
|
2743
|
+
task: mm.task.split("\n")[0] ?? "",
|
|
2744
|
+
spawned: mm.spawned,
|
|
2745
|
+
state: mm.outcome ?? inflight.find((x) => x.to === mm.target && x.job === j.id)?.state ?? "unknown",
|
|
2746
|
+
...(mm.forkOf ? { forkOf: peerAddress(cfg, self, mm.forkOf) } : {}),
|
|
2747
|
+
...(mm.artifacts?.length ? { artifacts: mm.artifacts } : {}),
|
|
2748
|
+
})),
|
|
1821
2749
|
})),
|
|
1822
2750
|
});
|
|
1823
2751
|
});
|
|
@@ -1826,7 +2754,7 @@ const main = async () => {
|
|
|
1826
2754
|
// (injectText), 所以 pane 死了会被拉起来, 输出照常落进群和详情页。
|
|
1827
2755
|
/** 日程归谁: 排班那个 wizard (createdBy); 老文件没写就归执行它的那个。 */
|
|
1828
2756
|
const ownerOf = (t) => t.owner || (cfg.defaultChat ?? "");
|
|
1829
|
-
http.register("POST /tasks/schedule", async (req, res) => {
|
|
2757
|
+
http.register("POST /tasks/schedule", guarded(async (req, res) => {
|
|
1830
2758
|
const { self, body } = await readPeerBody(req);
|
|
1831
2759
|
if (!self) {
|
|
1832
2760
|
json(res, 400, { ok: false, reason: "cannot resolve caller session" });
|
|
@@ -1865,7 +2793,7 @@ const main = async () => {
|
|
|
1865
2793
|
const fresh = typeof b.fresh === "boolean" ? b.fresh : !tag || promptWantsFreshWizard(prompt);
|
|
1866
2794
|
const note = (b.note ?? "").toString();
|
|
1867
2795
|
const id = uniqueId(slugify((b.id ?? "").toString() || note || prompt.slice(0, 24), "task"), tasks.takenIds());
|
|
1868
|
-
const created = tasks.create({ id, trigger, prompt, target, fresh, note, createdBy: self });
|
|
2796
|
+
const created = tasks.create({ id, trigger, prompt, target, fresh, quiet: b.quiet === true, note, createdBy: self });
|
|
1869
2797
|
if ("error" in created) {
|
|
1870
2798
|
json(res, 400, { ok: false, reason: created.error });
|
|
1871
2799
|
return;
|
|
@@ -1877,7 +2805,7 @@ const main = async () => {
|
|
|
1877
2805
|
address: peerAddress(cfg, self, target),
|
|
1878
2806
|
owner: peerAddress(cfg, self, self),
|
|
1879
2807
|
});
|
|
1880
|
-
});
|
|
2808
|
+
}));
|
|
1881
2809
|
http.register("POST /tasks/list", async (req, res) => {
|
|
1882
2810
|
const { self, body } = await readPeerBody(req);
|
|
1883
2811
|
if (!self) {
|
|
@@ -1920,17 +2848,199 @@ const main = async () => {
|
|
|
1920
2848
|
}
|
|
1921
2849
|
json(res, 200, { ok: true, removed: renderTask(gone, state) });
|
|
1922
2850
|
});
|
|
1923
|
-
//
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
2851
|
+
// 确认卡在请求里最多等 4 分钟 (留出 fetch 5 分钟收头的余量), 卡本身半小时有效。
|
|
2852
|
+
const CONFIRM_INLINE_MS = 240_000;
|
|
2853
|
+
const CONFIRM_TIMEOUT_MS = 30 * 60_000;
|
|
2854
|
+
// computeUsage 要扫一周的 transcript, 一分钟内的连问共用一份。
|
|
2855
|
+
let usageMemo;
|
|
2856
|
+
const tierUsage = () => {
|
|
2857
|
+
if (usageMemo && Date.now() - usageMemo.at < 60_000)
|
|
2858
|
+
return usageMemo.u;
|
|
2859
|
+
const r = computeUsage();
|
|
2860
|
+
const fold = (m) => new Map([...m].map(([id, x]) => [id, { tokens: sumTotal(x), cost: costOf(id, x) }]));
|
|
2861
|
+
usageMemo = { at: Date.now(), u: { today: fold(r.today), week: fold(r.week) } };
|
|
2862
|
+
return usageMemo.u;
|
|
2863
|
+
};
|
|
2864
|
+
const configEvidence = (p) => tierEvidence(cfg.models, p, tierUsage, (tier) => wizards.all().filter((w) => w.tier === tier).length, { tokens: fmtTokens, cost: fmtCost });
|
|
2865
|
+
// ── 改配置会不会动到宪章 ─────────────────────────────────────────
|
|
2866
|
+
// 宪章是 --append-system-prompt 压进去的, 随进程终身: 改了进宪章的配置, 已在跑的 wizard
|
|
2867
|
+
// 看不见, 要 handoff 重开 (重新渲染宪章) 才换上。不按字段清单判 —— 宪章读哪些配置随代码变,
|
|
2868
|
+
// 清单迟早漏; 改前改后各渲染一遍有会话的 wizard 的宪章, 变了的就是受影响的。
|
|
2869
|
+
const charterSections = (text) => new Map(text.split(/^(?=## )/m).map((sec) => [sec.split("\n", 1)[0].replace(/^## /, ""), sec]));
|
|
2870
|
+
const changedSections = (a, b) => {
|
|
2871
|
+
const x = charterSections(a), y = charterSections(b);
|
|
2872
|
+
return [...new Set([...x.keys(), ...y.keys()])].filter((k) => x.get(k) !== y.get(k));
|
|
2873
|
+
};
|
|
2874
|
+
/** pane 活着的 wizard —— 宪章在效的只有它们; 冷的醒来时宪章重渲染, 自然是新的。一次 list-panes 答完。 */
|
|
2875
|
+
const liveWizards = async () => {
|
|
2876
|
+
const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}"]);
|
|
2877
|
+
const live = new Set(snap.ok ? snap.stdout.split("\n").map((l) => l.trim()).filter(Boolean) : []);
|
|
2878
|
+
return wizards.all().map((w) => w.target).filter((t) => live.has(m.sessionInfo(t)?.tmuxPane ?? ""));
|
|
2879
|
+
};
|
|
2880
|
+
/** 这些 wizard 此刻的宪章正文。 */
|
|
2881
|
+
const charterSnapshot = (targets) => new Map(targets.map((t) => [t, charterText(t, charterOptsOf(t, m.getCwd(t).runningCwd || undefined), true)]));
|
|
2882
|
+
/** 把 `next` 那一节换进活的 cfg 渲染一遍再换回 —— 同步完成, 中间没有 await, 别的模块看不见这一瞬。
|
|
2883
|
+
* reload 项落盘后活值不变, 只有这样才算得出它 reload 之后的宪章。 */
|
|
2884
|
+
const charterUnder = (pl, targets) => {
|
|
2885
|
+
const live = cfg;
|
|
2886
|
+
const key = pl.path[0];
|
|
2887
|
+
const saved = live[key];
|
|
2888
|
+
live[key] = patchedTop(cfg, pl);
|
|
2889
|
+
try {
|
|
2890
|
+
return charterSnapshot(targets);
|
|
2891
|
+
}
|
|
2892
|
+
finally {
|
|
2893
|
+
live[key] = saved;
|
|
2894
|
+
}
|
|
2895
|
+
};
|
|
2896
|
+
const charterImpact = (before, pl) => {
|
|
2897
|
+
const after = charterUnder(pl, [...before.keys()]);
|
|
2898
|
+
return [...before].flatMap(([t, a]) => { const secs = changedSections(a, after.get(t) ?? a); return secs.length ? [{ target: t, sections: secs }] : []; });
|
|
2899
|
+
};
|
|
2900
|
+
/** 受影响的 wizard 各挂一条提醒 (随它下一条消息到); 给调用方的那段话。调用方自己受影响就直说要它 handoff。 */
|
|
2901
|
+
const announceCharterImpact = (pl, hits, self) => {
|
|
2902
|
+
if (!hits.length)
|
|
2903
|
+
return "";
|
|
2904
|
+
const when = pl.apply === "reload" ? "等守护进程 reload 之后再" : "";
|
|
2905
|
+
const ask = `${when}调 \`handoff({brief})\` 原地重开 (手上这件告一段落就交; 宪章在 handoff 时重新生成)`;
|
|
2906
|
+
const others = hits.filter((h) => h.target !== self);
|
|
2907
|
+
others.forEach((h) => notices.post([h.target], `\`config_set ${pl.path.join(".")}\` 改动了你的宪章 (系统提示, 变了的段: ${h.sections.join(" / ")}); 已在跑的你看不见新规矩 —— ${ask}`));
|
|
2908
|
+
const mine = hits.find((h) => h.target === self);
|
|
2909
|
+
return [
|
|
2910
|
+
"",
|
|
2911
|
+
`⚠ 这次改动进了宪章 (系统提示): 已在跑的 wizard 看不见, 要 handoff 才换上。受影响 ${hits.length} 个: ${hits.map((h) => displayName(h.target)).join(" ")}`,
|
|
2912
|
+
...(others.length ? [`已给其余 ${others.length} 个各挂一条提醒 (随它们下一条消息到), 让它们${ask}`] : []),
|
|
2913
|
+
...(mine ? [`**你自己也在其中** (变了的段: ${mine.sections.join(" / ")}): 这一轮收尾后${ask}`] : []),
|
|
2914
|
+
].join("\n");
|
|
2915
|
+
};
|
|
2916
|
+
// POST /config/get —— 新 MCP 带 `path` (可空) 拿渲染好的文本; 老 MCP 带 `key` 拿 {key, value}。
|
|
1929
2917
|
http.register("POST /config/get", async (req, res) => {
|
|
1930
2918
|
const body = (await readBody(req));
|
|
1931
|
-
|
|
2919
|
+
if (typeof body.key === "string") {
|
|
2920
|
+
const r = legacyGet(cfg, body.key);
|
|
2921
|
+
json(res, r.ok ? 200 : 400, r);
|
|
2922
|
+
return;
|
|
2923
|
+
}
|
|
2924
|
+
const r = configGet(cfg, sourcePath, body.path, configEvidence);
|
|
1932
2925
|
json(res, r.ok ? 200 : 400, r);
|
|
1933
2926
|
});
|
|
2927
|
+
// chatPolicy 的键一律落成 base principal: 聊天名会改, 拿它当键一改名策略就悄悄失效。写名字照收,
|
|
2928
|
+
// 换成它此刻指向的 principal; 认不出的拒写 (写错的键静默无效, 比报错难查)。unset 一个盘上原样存在的
|
|
2929
|
+
// 键不换 —— 留给清理老写法 / 失效的键。
|
|
2930
|
+
const pinChatKeys = (req) => {
|
|
2931
|
+
const p = parsePath(req.path);
|
|
2932
|
+
if (p[0] !== "chatPolicy")
|
|
2933
|
+
return req;
|
|
2934
|
+
const bad = (k) => ({ ok: false, reason: `认不出聊天 \`${k}\`: 键写 base principal (\`chat:wr…\` / \`user:…\`) 或聊天名 (/chats)` });
|
|
2935
|
+
if (p.length === 1) {
|
|
2936
|
+
if (req.value === undefined || typeof req.value !== "object" || Array.isArray(req.value))
|
|
2937
|
+
return req;
|
|
2938
|
+
const pairs = Object.entries(req.value).map(([k, v]) => [k, chatBaseOf(cfg, k), v]);
|
|
2939
|
+
const miss = pairs.find(([, b]) => !b);
|
|
2940
|
+
return miss ? bad(miss[0]) : { ...req, value: Object.fromEntries(pairs.map(([, b, v]) => [b, v])) };
|
|
2941
|
+
}
|
|
2942
|
+
const key = p[1];
|
|
2943
|
+
if (req.op === "unset" && p.length === 2 && key in cfg.chatPolicy)
|
|
2944
|
+
return req;
|
|
2945
|
+
const base = chatBaseOf(cfg, key);
|
|
2946
|
+
return base ? { ...req, path: ["chatPolicy", base, ...p.slice(2)].join(".") } : bad(key);
|
|
2947
|
+
};
|
|
2948
|
+
// POST /config/set —— 口令门见 `guarded`。分级在这里而不在 hook: `card` 项由守护进程
|
|
2949
|
+
// 自己推确认卡, 所以 danger.skipAll / ⏱窗口 都放不掉它。卡等 CONFIRM_INLINE_MS 还没人点
|
|
2950
|
+
// 就先回 pending (fetch 默认 5 分钟收不到头就断), 卡仍有效, 迟到的确认照常落盘并捎一句给调用方。
|
|
2951
|
+
http.register("POST /config/set", guarded(async (req, res) => {
|
|
2952
|
+
const body = (await readBody(req));
|
|
2953
|
+
const legacy = typeof body.key === "string";
|
|
2954
|
+
const asked = legacy ? fromLegacy(body.key, body.value, body.action) : body;
|
|
2955
|
+
if ("ok" in asked) {
|
|
2956
|
+
json(res, 400, asked);
|
|
2957
|
+
return;
|
|
2958
|
+
}
|
|
2959
|
+
const ask = pinChatKeys(asked);
|
|
2960
|
+
if ("ok" in ask) {
|
|
2961
|
+
json(res, 400, ask);
|
|
2962
|
+
return;
|
|
2963
|
+
}
|
|
2964
|
+
const reply = (pl, text, extra = {}) => json(res, 200, legacy
|
|
2965
|
+
? { ok: true, key: body.key, before: pl.before, after: pl.after, ...extra }
|
|
2966
|
+
: { ok: true, text, ...extra });
|
|
2967
|
+
const pl = planSet(cfg, sourcePath, ask);
|
|
2968
|
+
if (!pl.ok) {
|
|
2969
|
+
json(res, 400, pl);
|
|
2970
|
+
return;
|
|
2971
|
+
}
|
|
2972
|
+
if (ask.dryRun || !pl.changed) {
|
|
2973
|
+
reply(pl, `${ask.dryRun ? "dryRun, 没写:\n" : ""}${renderPlan(pl)}`);
|
|
2974
|
+
return;
|
|
2975
|
+
}
|
|
2976
|
+
// 落盘时重算一遍: 等卡期间文件可能被别人改过, 按那一刻的文件打补丁、再校验。
|
|
2977
|
+
// 宪章快照取在落盘之前: hot 项一 applyHot, 活的 cfg 就已经是新的了。
|
|
2978
|
+
// 不用 resolveSelf: 它最后回落到 defaultChat, 会把认不出的调用方当成那个群的管家。
|
|
2979
|
+
const caller = (body.sessionId?.trim() && m.targetForSession(body.sessionId.trim())) || (body.tmuxPane?.trim() && m.targetForPane(body.tmuxPane.trim())) || undefined;
|
|
2980
|
+
const commit = async () => {
|
|
2981
|
+
const targets = await liveWizards();
|
|
2982
|
+
// 从这里到 applyHot 之间不能有 await: 快照与落盘之间别人的 config_set 插进来, 前后就对不上了。
|
|
2983
|
+
const now = planSet(cfg, sourcePath, ask);
|
|
2984
|
+
if (!now.ok)
|
|
2985
|
+
return now;
|
|
2986
|
+
const before = charterSnapshot(targets);
|
|
2987
|
+
patchJsonc(sourcePath, [{ path: now.jsonPath, value: now.value }]);
|
|
2988
|
+
applyHot(cfg, now);
|
|
2989
|
+
// 已经落盘了: 比对失败只少一句提醒, 不能让调用方以为没写而重发。
|
|
2990
|
+
try {
|
|
2991
|
+
return { ...now, charter: announceCharterImpact(now, charterImpact(before, now), caller) };
|
|
2992
|
+
}
|
|
2993
|
+
catch (e) {
|
|
2994
|
+
log.warn({ err: errText(e), path: now.path.join(".") }, "config_set: 宪章影响比对失败");
|
|
2995
|
+
return { ...now, charter: `\n(宪章影响没比对出来: ${errText(e)} —— 改的若是进宪章的配置, 受影响的 wizard 要 handoff 才换上)` };
|
|
2996
|
+
}
|
|
2997
|
+
};
|
|
2998
|
+
const done = (now) => `已写入${now.apply === "hot" ? ", 已生效" : "; 需 reload (`./cli/wezard.sh reload`) 才生效"}:\n${renderPlan(now)}${now.charter}`;
|
|
2999
|
+
if (pl.gate !== "card") {
|
|
3000
|
+
const now = await commit();
|
|
3001
|
+
if (!now.ok) {
|
|
3002
|
+
json(res, 400, now);
|
|
3003
|
+
return;
|
|
3004
|
+
}
|
|
3005
|
+
reply(now, done(now));
|
|
3006
|
+
return;
|
|
3007
|
+
}
|
|
3008
|
+
const self = resolveSelf(body);
|
|
3009
|
+
if (!self) {
|
|
3010
|
+
json(res, 400, { ok: false, reason: "cannot resolve caller session; 这一项要发确认卡给人点" });
|
|
3011
|
+
return;
|
|
3012
|
+
}
|
|
3013
|
+
let decided;
|
|
3014
|
+
try {
|
|
3015
|
+
({ decided } = await requestConfirm({
|
|
3016
|
+
client: ws.client, principal: self, who: displayName(self),
|
|
3017
|
+
title: `配置变更待确认 · ${pl.path.join(".")}`,
|
|
3018
|
+
body: `${displayName(self)} 要改配置:\n${renderPlan(pl)}`,
|
|
3019
|
+
timeoutMs: CONFIRM_TIMEOUT_MS,
|
|
3020
|
+
}));
|
|
3021
|
+
}
|
|
3022
|
+
catch (e) {
|
|
3023
|
+
json(res, 503, { ok: false, reason: `确认卡发不出去 (${errText(e)}), 没写` });
|
|
3024
|
+
return;
|
|
3025
|
+
}
|
|
3026
|
+
const settle = async (yes) => {
|
|
3027
|
+
if (!yes)
|
|
3028
|
+
return `人没确认 (拒绝或超时), \`${pl.path.join(".")}\` 没写`;
|
|
3029
|
+
const now = await commit();
|
|
3030
|
+
return now.ok ? done(now) : `人确认了, 但落盘前重算没过, 没写: ${now.reason}`;
|
|
3031
|
+
};
|
|
3032
|
+
const inline = await Promise.race([decided, sleep(CONFIRM_INLINE_MS).then(() => undefined)]);
|
|
3033
|
+
if (inline !== undefined) {
|
|
3034
|
+
const text = await settle(inline);
|
|
3035
|
+
json(res, 200, legacy ? { ok: inline, key: body.key, before: pl.before, after: inline ? pl.after : pl.before, note: text } : { ok: true, text, confirmed: inline });
|
|
3036
|
+
return;
|
|
3037
|
+
}
|
|
3038
|
+
void decided.then(async (yes) => notices.post([self], `config_set 的确认卡有结果了: ${await settle(yes)}`))
|
|
3039
|
+
.catch((e) => log.warn({ err: errText(e) }, "config_set: 迟到的确认落盘失败"));
|
|
3040
|
+
json(res, 200, legacy
|
|
3041
|
+
? { ok: false, reason: "确认卡已发到群里, 还没人点; 点了之后自动落盘" }
|
|
3042
|
+
: { ok: true, pending: true, text: `确认卡已发到群里, 还没人点 (卡 ${CONFIRM_TIMEOUT_MS / 60_000} 分钟内有效)。点了之后守护进程自动落盘, 结果会捎在你下一条收到的消息上 —— 不用重发。\n${renderPlan(pl)}` });
|
|
3043
|
+
}));
|
|
1934
3044
|
// POST /graph/run — declare a loop graph over this chat's tagged sessions
|
|
1935
3045
|
// and start walking it. Fire-and-forget: returns a runId immediately, then
|
|
1936
3046
|
// narrates progress into the chat while it advances.
|
|
@@ -1955,6 +3065,9 @@ const main = async () => {
|
|
|
1955
3065
|
return;
|
|
1956
3066
|
}
|
|
1957
3067
|
const graphLog = log.child({ mod: "graph", base: spec.base });
|
|
3068
|
+
// 每一步、每条进度都在发起这次 run 的那一轮的频道里: 人在群里叫它发起的才进群,
|
|
3069
|
+
// 私聊轮里发起的整场只进 rolepage —— 步骤不带频道就等于在节点的 home 群里开口。
|
|
3070
|
+
const channel = m.currentChannel(self);
|
|
1958
3071
|
const run = startGraph(spec, {
|
|
1959
3072
|
// Reuse a live tagged pane; only spawn when the node doesn't exist yet
|
|
1960
3073
|
// (or its pane died). Re-spawning a healthy node would throw away the
|
|
@@ -1969,10 +3082,11 @@ const main = async () => {
|
|
|
1969
3082
|
const r = await m.newSession(target, node.tag, node.cli, { model: node.model, cwd: node.cwd, silent: true });
|
|
1970
3083
|
return { ok: r.ok, reason: r.reason };
|
|
1971
3084
|
},
|
|
1972
|
-
send: async (target, text, origin) => (await handedOff(target), m.injectText(target, text, origin)),
|
|
3085
|
+
send: async (target, text, origin) => (await handedOff(target), m.injectText(target, text, origin, { channel })),
|
|
1973
3086
|
isBusy: m.isBusy,
|
|
1974
3087
|
lastText: async (target) => m.lastText(target),
|
|
1975
|
-
notify:
|
|
3088
|
+
notify: (_base, markdown) => { if (channel)
|
|
3089
|
+
notifyChat(channel, markdown); },
|
|
1976
3090
|
log: graphLog,
|
|
1977
3091
|
});
|
|
1978
3092
|
json(res, 200, { ok: true, runId: run.runId, base: run.base, nodes: spec.nodes.length, steps: spec.steps.length, rounds: spec.rounds ?? 1 });
|
|
@@ -2027,12 +3141,74 @@ const main = async () => {
|
|
|
2027
3141
|
// 还要连触发时间点一起被它那一轮拖走。所以到点起一个白板 wizard 单独执行,
|
|
2028
3142
|
// 跑完自动收掉。只有排班时点名了某个 wizard (fresh=false) 且它此刻闲着,
|
|
2029
3143
|
// 才把这句话直接投进它那一轮 —— 「在已有会话里继续」是要求出来的, 不是默认。
|
|
2030
|
-
scheduledTaskInject = async (target, text, { taskId, fresh }) => {
|
|
3144
|
+
scheduledTaskInject = async (target, text, { taskId, fresh, quiet }) => {
|
|
3145
|
+
// 安静任务私下跑 (channel ""): 镜像不推它的任何一句, 终句由 relayUnlessQuiet 判断要不要进群。
|
|
3146
|
+
const inNamed = async () => {
|
|
3147
|
+
const at = Date.now();
|
|
3148
|
+
const turn = newTurn();
|
|
3149
|
+
const inj = await m.injectText(target, text, undefined, quiet
|
|
3150
|
+
? { from: { kind: "task", taskId }, channel: "", envelope: renderTaskEnvelope(taskId, { turn }) }
|
|
3151
|
+
: { fromChat: true, from: { kind: "task", taskId }, envelope: renderTaskEnvelope(taskId) });
|
|
3152
|
+
if (inj.ok && quiet)
|
|
3153
|
+
void relayUnlessQuiet(target, at, turn, baseOfKey(target));
|
|
3154
|
+
return inj;
|
|
3155
|
+
};
|
|
2031
3156
|
if (!fresh)
|
|
2032
3157
|
await handedOff(target); // 交接中途 pane 是空的, 别当成闲着投进去
|
|
2033
3158
|
const busy = fresh ? false : await m.isBusy(target);
|
|
2034
3159
|
if (!fresh && !busy)
|
|
2035
|
-
return
|
|
3160
|
+
return inNamed();
|
|
3161
|
+
// 点名的目标正忙: 顺延到它这一轮结束再投 (与 tell_peer 默认的 normal 同一口径) ——
|
|
3162
|
+
// 「在它那儿继续」是排班时要求的, 不该因为撞上它在干活就换成白板。后台等, 别拖住
|
|
3163
|
+
// 调度器的其它任务; 等满 30 分钟仍忙才退回起白板。
|
|
3164
|
+
const inRunner = (wasBusy) => runTaskInRunner(target, text, taskId, quiet, wasBusy);
|
|
3165
|
+
if (!fresh && busy) {
|
|
3166
|
+
// 同一条任务已有一枪在等这个目标: 这一枪并进去 —— 等它闲下来时几枪一起投, 会挤进同一个输入框。
|
|
3167
|
+
if (postponed.has(taskId))
|
|
3168
|
+
return { ok: true, reason: "上一枪还在等它闲下来, 这一枪并入" };
|
|
3169
|
+
postponed.add(taskId);
|
|
3170
|
+
void (async () => {
|
|
3171
|
+
const wr = await m.untilIdle(target, TASK_POSTPONE_MS);
|
|
3172
|
+
postponed.delete(taskId);
|
|
3173
|
+
const r = wr.idle ? await inNamed() : await inRunner(true);
|
|
3174
|
+
log.info({ taskId, target, postponed: true, idle: wr.idle, ok: r.ok, reason: r.reason }, "scheduled task: 目标正忙, 顺延");
|
|
3175
|
+
if (!r.ok)
|
|
3176
|
+
notifyChat(baseOfKey(target), withTagHeader(target, `⏰ 定时任务 \`${taskId}\` 顺延后注入失败: ${r.reason ?? "unknown"}`));
|
|
3177
|
+
})();
|
|
3178
|
+
return { ok: true, reason: "目标正忙, 顺延到它闲下来" };
|
|
3179
|
+
}
|
|
3180
|
+
return inRunner(busy);
|
|
3181
|
+
};
|
|
3182
|
+
/** 顺延中的任务 (内存即可: reload 丢了只是少放一枪, 下一次到点照常)。 */
|
|
3183
|
+
const postponed = new Set();
|
|
3184
|
+
/** 安静任务那一轮的终句: 它私下跑, 镜像不推; 等它这一轮跑完, 终句不是 `QUIET` 就替它
|
|
3185
|
+
* 发进群 (头写它的名字, 人知道是谁说的)。按信封件号定位答句 (同回执): 按时刻取会把紧随
|
|
3186
|
+
* 其后的私聊 / 人话那一轮的答案当成它的, 把私下内容发进群。 */
|
|
3187
|
+
const relayUnlessQuiet = async (t, at, turn, base) => {
|
|
3188
|
+
quietRelays.set(turn, { target: t, at, base });
|
|
3189
|
+
const end = at + TASK_QUIET_WAIT_MS;
|
|
3190
|
+
// 先等它动起来: 刚注入时注册表还报 idle。
|
|
3191
|
+
await sleep(5000);
|
|
3192
|
+
while (Date.now() < end) {
|
|
3193
|
+
await m.untilIdle(t, end - Date.now());
|
|
3194
|
+
const reply = (m.replyToPeer(t, "", at, turn)?.text ?? "").trim();
|
|
3195
|
+
if (reply && (await m.idleNow(t))) {
|
|
3196
|
+
if (!isQuiet(reply))
|
|
3197
|
+
notifyChat(base, withTagHeader(t, reply));
|
|
3198
|
+
log.info({ target: t, quiet: isQuiet(reply), len: reply.length }, "quiet task: 一轮结束");
|
|
3199
|
+
quietRelays.drop(turn);
|
|
3200
|
+
return;
|
|
3201
|
+
}
|
|
3202
|
+
await sleep(5000);
|
|
3203
|
+
}
|
|
3204
|
+
log.warn({ target: t }, "quiet task: 等满仍没收口, 不转发");
|
|
3205
|
+
quietRelays.drop(turn);
|
|
3206
|
+
};
|
|
3207
|
+
// 落盘: 这个仓库里 reload 是常态, 守在内存里的那一轮一 reload 就既不转发也不说 QUIET 了。
|
|
3208
|
+
const quietRelays = loadJsonMap(join(cfg.daemon.stateDir, "quiet-relays.json"), (rows) => Object.fromEntries(Object.entries(rows).filter(([, r]) => Date.now() - r.at < TASK_QUIET_WAIT_MS)));
|
|
3209
|
+
void m.restored.then(() => Object.entries(quietRelays.all()).forEach(([turn, r]) => void relayUnlessQuiet(r.target, r.at, turn, r.base)));
|
|
3210
|
+
/** 到点起一个白板 wizard 执行这一枪, 跑完自动收掉。 */
|
|
3211
|
+
const runTaskInRunner = async (target, text, taskId, quiet, busy) => {
|
|
2036
3212
|
// 执行体挂在日程的主人名下: 名字 `<主人>-task-xxxx`, parent = 主人 —— 它的
|
|
2037
3213
|
// rolepage 里看得见这一枪是谁的日程放的。落在执行目标的群/目录里。
|
|
2038
3214
|
const base = baseOfKey(target);
|
|
@@ -2056,10 +3232,16 @@ const main = async () => {
|
|
|
2056
3232
|
wizards.drop(runner);
|
|
2057
3233
|
return { ok: false, reason: `起白板 wizard 失败: ${spawned.reason ?? "unknown"}` };
|
|
2058
3234
|
}
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
3235
|
+
if (!quiet) {
|
|
3236
|
+
notifyChat(base, withTagHeader(target, busy
|
|
3237
|
+
? `⏰ 定时任务到点时正忙, 已起白板 wizard .${runnerName} 单独执行, 完成后自动收掉`
|
|
3238
|
+
: `⏰ 定时任务已起白板 wizard .${runnerName} 执行, 完成后自动收掉`));
|
|
3239
|
+
}
|
|
3240
|
+
const at = Date.now();
|
|
3241
|
+
const turn = newTurn();
|
|
3242
|
+
const inj = await m.injectText(runner, text, undefined, quiet
|
|
3243
|
+
? { from: { kind: "task", taskId }, channel: "", envelope: renderTaskEnvelope(taskId, { turn }) }
|
|
3244
|
+
: { fromChat: true, from: { kind: "task", taskId }, envelope: renderTaskEnvelope(taskId) });
|
|
2063
3245
|
if (!inj.ok) {
|
|
2064
3246
|
wizards.drop(runner);
|
|
2065
3247
|
return inj;
|
|
@@ -2068,6 +3250,9 @@ const main = async () => {
|
|
|
2068
3250
|
// 内没闲下来就放弃自动回收 (它大概率还在干一个长活), 留给人手动处理 ——
|
|
2069
3251
|
// 比杀掉一个还在跑的 pane 安全。
|
|
2070
3252
|
void (async () => {
|
|
3253
|
+
// 安静任务先把终句取走 (收掉 pane 之后就读不到了)。
|
|
3254
|
+
if (quiet)
|
|
3255
|
+
await relayUnlessQuiet(runner, at, turn, base);
|
|
2071
3256
|
const idle = await waitForIdle(runner, m.isBusy, 30 * 60_000, () => false);
|
|
2072
3257
|
if (!idle.idle) {
|
|
2073
3258
|
log.warn({ runner, reason: idle.reason }, "task runner: 30min 未闲下来, 放弃自动回收");
|
|
@@ -2078,6 +3263,66 @@ const main = async () => {
|
|
|
2078
3263
|
})();
|
|
2079
3264
|
return inj;
|
|
2080
3265
|
};
|
|
3266
|
+
// 记忆整理者的一轮: 在内部 key 上起白板 wizard, 私聊注入, 闲下来就收 —— 全程不碰
|
|
3267
|
+
// 任何群 (silent spawn、channel ""、无 notifyChat)。它没有人能点审批卡, 所以这一轮
|
|
3268
|
+
// 给它的 key 开一个 ⏱ 窗口; 危险名单照旧逐条要卡, 而它的活 (读收件箱、改 md) 不在其列。
|
|
3269
|
+
stewardRun = async (prompt) => {
|
|
3270
|
+
const target = STEWARD_TARGET;
|
|
3271
|
+
wizards.upsert(target, { description: "共享记忆整理的一次性执行体 (守护进程内建, 不属于任何群)", bornAt: Date.now() });
|
|
3272
|
+
const name = wizards.rename(target, "memsteward-run");
|
|
3273
|
+
setAutoWindow(target, STEWARD_RUN_MS + 5 * 60_000);
|
|
3274
|
+
const done = async (r) => {
|
|
3275
|
+
await m.killPane(target).catch(() => undefined);
|
|
3276
|
+
wizards.drop(target);
|
|
3277
|
+
clearAutoWindow(target);
|
|
3278
|
+
return r;
|
|
3279
|
+
};
|
|
3280
|
+
const spawned = await m.newSession(target, name, undefined, { cwd: memoryRoot(cfg.daemon.stateDir), silent: true, keepalive: false });
|
|
3281
|
+
if (!spawned.ok)
|
|
3282
|
+
return done({ ok: false, reason: `spawn: ${spawned.reason ?? "unknown"}` });
|
|
3283
|
+
const inj = await m.injectText(target, prompt, undefined, { from: { kind: "task", taskId: STEWARD_ID }, channel: "", envelope: stewardEnvelope() });
|
|
3284
|
+
if (!inj.ok)
|
|
3285
|
+
return done(inj);
|
|
3286
|
+
// 超时也收: 没人会对它喊 stop_wizard; 认领留着, 孤儿回收会再派一轮。
|
|
3287
|
+
const idle = await waitForIdle(target, m.isBusy, STEWARD_RUN_MS, () => false);
|
|
3288
|
+
return done(idle.idle ? { ok: true } : { ok: false, reason: idle.reason });
|
|
3289
|
+
};
|
|
3290
|
+
// 整理者与在场 wizard 之间的两根线 (memory.md ①②): 合并前给它看 CLAUDE.md /
|
|
3291
|
+
// auto-memory 好去重, 合并后告诉读这份 md 的 wizard —— 它们的宪章是出生时的快照。
|
|
3292
|
+
// md ↔ wizard 一律正向算 (mdsOf / cwdOfMd), 不从文件名反推。
|
|
3293
|
+
const stateDir = cfg.daemon.stateDir;
|
|
3294
|
+
// 全量绑定 (含冷的), 与 settleAll 同一份 —— 不依赖身份记录在不在。
|
|
3295
|
+
const boundTargets = () => m.chatRoster("").flatMap((c) => c.targets).filter((t) => !isInternalKey(t));
|
|
3296
|
+
const cwdOfTarget = (t) => { const c = m.getCwd(t); return c.runningCwd || c.defaultCwd; };
|
|
3297
|
+
// 人和各家 CLI 自己读的那几份项目说明 + 各 backend 的 auto-memory 索引。
|
|
3298
|
+
const PROJECT_DOCS = ["CLAUDE.md", "CODEBUDDY.md", "AGENTS.md", join(".claude", "CLAUDE.md")];
|
|
3299
|
+
stewardWiring = {
|
|
3300
|
+
refsOf: (md) => {
|
|
3301
|
+
// cwd 候选: 提议里带的 (提议者可能早收工了) + 在场绑定的; 再正向算出 md 比对。
|
|
3302
|
+
const cwds = [...new Set([...proposedCwds(stateDir), ...boundTargets().map(cwdOfTarget)].filter(Boolean))];
|
|
3303
|
+
const cwd = cwdOfMd(stateDir, md, cwds);
|
|
3304
|
+
if (!cwd)
|
|
3305
|
+
return [];
|
|
3306
|
+
return [...PROJECT_DOCS.map((f) => join(cwd, f)), ...projectDirsFor(cwd).map((d) => join(d.dir, "memory", "MEMORY.md"))].filter((f) => existsSync(f));
|
|
3307
|
+
},
|
|
3308
|
+
// 只投给 pane 活着的: 冷的那些醒来时宪章重渲染, 新记忆已在里面, 再提一句就重复了。
|
|
3309
|
+
onMerged: (mds) => void (async () => {
|
|
3310
|
+
const changed = new Set(mds);
|
|
3311
|
+
const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}"]);
|
|
3312
|
+
if (!snap.ok)
|
|
3313
|
+
return;
|
|
3314
|
+
const live = new Set(snap.stdout.split("\n").map((l) => l.trim()).filter(Boolean));
|
|
3315
|
+
for (const t of boundTargets()) {
|
|
3316
|
+
const pane = m.sessionInfo(t)?.tmuxPane;
|
|
3317
|
+
if (!pane || !live.has(pane))
|
|
3318
|
+
continue;
|
|
3319
|
+
const where = (scope) => (scope === "workspace" ? "本工作区" : baseOfKey(t).startsWith("user:") ? "本单聊" : "本群");
|
|
3320
|
+
for (const h of mdsOf(stateDir, baseOfKey(t), cwdOfTarget(t)).filter((x) => changed.has(x.md))) {
|
|
3321
|
+
notices.post([t], `${where(h.scope)}记忆已更新, 全文见 \`${h.md}\``);
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
})().catch((e) => log.warn({ err: e.message }, "memory steward: notice fan-out failed")),
|
|
3325
|
+
};
|
|
2081
3326
|
}
|
|
2082
3327
|
// 定时调度器 — 每 20s 检查任务表, 到点把 prompt 注入目标 wizard。
|
|
2083
3328
|
migrateLegacySchedules(cfg, sourcePath, tasks, log.child({ mod: "tasks" }));
|
|
@@ -2090,6 +3335,10 @@ const main = async () => {
|
|
|
2090
3335
|
cwdOf: (t) => { const c = bridge.getCwd(t); return c.runningCwd || c.pendingCwd || c.defaultCwd; },
|
|
2091
3336
|
fallbackTarget: () => (cfg.defaultChat ?? "").trim(),
|
|
2092
3337
|
});
|
|
3338
|
+
// 共享记忆整理 — daemon 内建, 不进任务表 / 日程, 不出任何气泡。
|
|
3339
|
+
const steward = stewardRun
|
|
3340
|
+
? startSteward({ root: memoryRoot(cfg.daemon.stateDir), log: log.child({ mod: "steward" }), run: stewardRun, ...stewardWiring })
|
|
3341
|
+
: undefined;
|
|
2093
3342
|
// pane 上限 — 每新建一个 wizard 数一次: 活着的会话 pane 超过 wrc.mirror.maxPanes
|
|
2094
3343
|
// (默认 20, 0=关) 就从最久没动的收起。数量只在出生时增长, 所以不设定时器。只收
|
|
2095
3344
|
// pane 不收绑定: 下一条消息 `--resume` 复活。豁免: 名下有定时任务的 wizard (调度
|
|
@@ -2114,6 +3363,7 @@ const main = async () => {
|
|
|
2114
3363
|
const shutdown = async (signal) => {
|
|
2115
3364
|
log.info({ signal }, "shutdown signal");
|
|
2116
3365
|
scheduler.stop();
|
|
3366
|
+
steward?.stop();
|
|
2117
3367
|
tasks.stop();
|
|
2118
3368
|
netWatch.stop();
|
|
2119
3369
|
// 同 POST /shutdown: 先把挂着的审批长轮询了结成「稍后续接」, 再关连接。
|