wezard 1.4.2 → 2.0.1
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-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CLAUDE.md +17 -13
- package/README.md +130 -77
- package/cli/wezard.sh +0 -62
- package/config.example.jsonc +3 -9
- package/dist/cli/init.js +12 -21
- package/dist/cli/init.js.map +1 -1
- package/dist/daemon/approval.js +11 -7
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/cc-session.js +136 -0
- package/dist/daemon/cc-session.js.map +1 -0
- package/dist/daemon/chat-name.js +8 -18
- package/dist/daemon/chat-name.js.map +1 -1
- package/dist/daemon/detail.js +54 -13
- package/dist/daemon/detail.js.map +1 -1
- package/dist/daemon/graph.js +5 -7
- package/dist/daemon/graph.js.map +1 -1
- package/dist/daemon/inbound.js +188 -254
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +459 -258
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +2 -2
- package/dist/daemon/memory-steward.js +82 -0
- package/dist/daemon/memory-steward.js.map +1 -0
- package/dist/daemon/mirror-bridge.js +370 -170
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/mirror-store.js.map +1 -1
- package/dist/daemon/model-select.js +74 -0
- package/dist/daemon/model-select.js.map +1 -0
- package/dist/daemon/peers.js +10 -16
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/session-name.js +4 -27
- package/dist/daemon/session-name.js.map +1 -1
- package/dist/daemon/spawn-tmux.js +61 -16
- package/dist/daemon/spawn-tmux.js.map +1 -1
- package/dist/daemon/task-gate.js +97 -0
- package/dist/daemon/task-gate.js.map +1 -0
- package/dist/daemon/task-registry.js +132 -0
- package/dist/daemon/task-registry.js.map +1 -0
- package/dist/daemon/tasks.js +126 -54
- package/dist/daemon/tasks.js.map +1 -1
- package/dist/daemon/wizard-memory.js +52 -0
- package/dist/daemon/wizard-memory.js.map +1 -0
- package/dist/daemon/wizard.js +107 -41
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/mcp/server.js +181 -209
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/chat-http.js +187 -108
- package/dist/shared/chat-http.js.map +1 -1
- package/dist/shared/chat-render.js +25 -34
- package/dist/shared/chat-render.js.map +1 -1
- package/dist/shared/chat-view.js +14 -2
- package/dist/shared/chat-view.js.map +1 -1
- package/dist/shared/cli-backends.js +2 -7
- package/dist/shared/cli-backends.js.map +1 -1
- package/dist/shared/config.js +26 -9
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/detail-render.js +12 -6
- package/dist/shared/detail-render.js.map +1 -1
- package/dist/shared/detail-store.js +1 -1
- package/dist/shared/detail-store.js.map +1 -1
- package/dist/shared/role-render.js +55 -0
- package/dist/shared/role-render.js.map +1 -0
- package/dist/shared/role-view.js +228 -0
- package/dist/shared/role-view.js.map +1 -0
- package/dist/shared/schedule-spec.js +92 -116
- package/dist/shared/schedule-spec.js.map +1 -1
- package/dist/shared/session-label.js +103 -72
- package/dist/shared/session-label.js.map +1 -1
- package/dist/shared/std.js +3 -0
- package/dist/shared/std.js.map +1 -1
- package/dist/shared/task-file.js +120 -0
- package/dist/shared/task-file.js.map +1 -0
- package/dist/shared/trigger.js +232 -0
- package/dist/shared/trigger.js.map +1 -0
- package/dist/shared/world.js +17 -5
- package/dist/shared/world.js.map +1 -1
- package/dist/svr/index.js +67 -2
- package/dist/svr/index.js.map +1 -1
- package/hooks/pre-tool-use.sh +4 -4
- package/package.json +1 -2
- package/web/chat.css +424 -212
- package/web/chat.js +953 -323
- package/commands/wrc.md +0 -7
- package/dist/daemon/cc-bridge.js +0 -183
- package/dist/daemon/cc-bridge.js.map +0 -1
- package/dist/daemon/sessions.js +0 -4
- package/dist/daemon/sessions.js.map +0 -1
package/dist/daemon/index.js
CHANGED
|
@@ -7,27 +7,29 @@ import { startNetWatch } from "./net-watch.js";
|
|
|
7
7
|
import { startHttp, json, readBody } from "./http.js";
|
|
8
8
|
import { configGet, configSet } from "./config-api.js";
|
|
9
9
|
import { installInboundRouter } from "./inbound.js";
|
|
10
|
-
import { loadSessionStore } from "./sessions.js";
|
|
11
10
|
import { loadMirrorStore } from "./mirror-store.js";
|
|
12
|
-
import { makeBridge } from "./cc-bridge.js";
|
|
13
11
|
import { startMirror, installMirrorEventListener } from "./mirror-bridge.js";
|
|
14
12
|
import { setTmuxTimeoutReporter, spawnTmuxClaude } from "./spawn-tmux.js";
|
|
15
13
|
import { installApprovalEventListener, makeApproveHandler } from "./approval.js";
|
|
16
|
-
import { initDetailPersistence, makeDetailHandler, chatHandlers, configureRemoteForward,
|
|
14
|
+
import { initDetailPersistence, makeDetailHandler, chatHandlers, configureRemoteForward, chatUrlFor, setWorldFactsProvider } from "./detail.js";
|
|
17
15
|
import { EMPTY_FACTS } from "../shared/world.js";
|
|
18
16
|
import { initAutoWindowPersistence } from "./session-cache.js";
|
|
19
17
|
import { makeMessageHandler } from "./outbound.js";
|
|
20
18
|
import { makeCardHandler, makeAskHandler, installAskEventListener } from "./ask.js";
|
|
21
|
-
import { drainForReload } from "./pending.js";
|
|
19
|
+
import { drainForReload, listPending } from "./pending.js";
|
|
22
20
|
import { makeClaimStartHandler, makeClaimStatusHandler, makeClaimResetHandler, } from "./claim.js";
|
|
23
21
|
import { makeWedocBridge } from "./wedoc.js";
|
|
24
22
|
import { installResponseTracker } from "./last-response.js";
|
|
25
23
|
import { scanClaudeSessions } from "./session-scan.js";
|
|
26
|
-
import { startScheduler,
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
24
|
+
import { startScheduler, migrateLegacySchedules, promptWantsFreshWizard, renderTask, sinceOf, } from "./tasks.js";
|
|
25
|
+
import { openTaskRegistry } from "./task-registry.js";
|
|
26
|
+
import { describeTrigger, nextFire, parseTrigger, WHEN_HELP } from "../shared/trigger.js";
|
|
27
|
+
import { slugify, uniqueId } from "../shared/task-file.js";
|
|
28
|
+
import { baseOfKey, bindTagLinker, keyOf, linkTags, normalizeTag, tagFromCwd, tagHead, tagLink, tagOfKey, uniqueTag, withTagHeader } from "../shared/session-label.js";
|
|
29
|
+
import { clipForCharter, inboxPath, memoryPath, memoryRoot, proposeMemory, readMemory } from "./wizard-memory.js";
|
|
30
|
+
import { ensureStewardTask } from "./memory-steward.js";
|
|
29
31
|
import { applyChatNames, chatBaseOf, chatNameOf, clearChatName, listChatNames, normChatName, peerAddress, planChatNames, setChatName } from "./chat-name.js";
|
|
30
|
-
import { bindWizardStore, loadWizardStore,
|
|
32
|
+
import { bindWizardStore, loadWizardStore, wizardStore, settleName, settleAll, childrenOf, ancestorsOf, renderCharter, } from "./wizard.js";
|
|
31
33
|
import { bindNoticeBox, createNoticeBox, chatAudience } from "./notices.js";
|
|
32
34
|
import { loadJobStore, renderJobOpen, renderJobClose, JOB_MEMBER_MAX } from "./jobs.js";
|
|
33
35
|
import { extractResult } from "./peers.js";
|
|
@@ -47,6 +49,9 @@ const main = async () => {
|
|
|
47
49
|
name: "wezard-daemon",
|
|
48
50
|
});
|
|
49
51
|
log.info({ sourcePath, pid: process.pid }, "daemon start");
|
|
52
|
+
// 定时调度器的 `inject` 真正实现, 赋值在 mirror-mode 的大块里 (要用到那里面的
|
|
53
|
+
// wizards / m / notifyChat), 但 startScheduler 在那块外面接线 —— 见文件尾。
|
|
54
|
+
let scheduledTaskInject;
|
|
50
55
|
// Bind the CLI backend registry. `primary` (= defaultCli) drives new-session
|
|
51
56
|
// spawns; `backends` is every installed CLI whose transcript root exists, so
|
|
52
57
|
// sessions from all of them can be mirrored concurrently — each attachment
|
|
@@ -74,63 +79,57 @@ const main = async () => {
|
|
|
74
79
|
// last-response tracker enables inbound's `quote` dedup of bot self-replies,
|
|
75
80
|
// and its chat-gate drops header-only pushes (empty messages) daemon-wide.
|
|
76
81
|
installResponseTracker(ws.client, log.child({ mod: "chat-gate" }));
|
|
77
|
-
const sessions = loadSessionStore(cfg.wrc.sessionMapFile);
|
|
78
82
|
const mirrorStore = loadMirrorStore(cfg.wrc.mirror.attachmentsFile);
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
// 定时任务表 —— 每条任务是 ~/.wezard/tasks/<id>.task.mjs 一份可注入代码的配置
|
|
84
|
+
// (见 shared/task-file.ts)。目录是热加载的: wizard 改完文件不用 reload 守护进程。
|
|
85
|
+
// 共享记忆的整理者是一条定时任务 (memory-steward.ts), 缺了就补一份默认的。
|
|
86
|
+
try {
|
|
87
|
+
ensureStewardTask(memoryRoot(cfg.daemon.stateDir));
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
log.warn({ err: e.message }, "memory steward task not written");
|
|
85
91
|
}
|
|
92
|
+
const tasks = await openTaskRegistry(log.child({ mod: "tasks" }), (added, removed) => log.info({ added, removed }, "task files changed"));
|
|
93
|
+
const bridge = startMirror({ cfg, log: log.child({ mod: "mirror" }), client: ws.client, store: mirrorStore });
|
|
94
|
+
// 群里每个 `.name` 都挂它的 rolepage (见 session-label 的 TagLinker)。正文提及
|
|
95
|
+
// 只认真有其人的 —— 随手写的 `.gitignore` 不该变成一个开在空栏上的链接。
|
|
96
|
+
const known = (t) => bridge.chatTargets(t).includes(t);
|
|
97
|
+
// 名册在下面的 mirror 块里才绑 (bindWizardStore) —— 这里全走 wizardStore() 惰性取。
|
|
98
|
+
bindTagLinker({
|
|
99
|
+
urlOf: (t) => chatUrlFor(cfg.daemon, t, baseOfKey(t).replace(/^(user|chat|group):/, "")),
|
|
100
|
+
resolve: (name) => {
|
|
101
|
+
const t = wizardStore()?.byName(name)?.target;
|
|
102
|
+
return t && known(t) ? t : undefined;
|
|
103
|
+
},
|
|
104
|
+
nameOf: (t) => settleName(wizardStore(), chatNameOf(cfg, t), t),
|
|
105
|
+
});
|
|
86
106
|
installInboundRouter(ws.client, cfg, log, bridge, sourcePath);
|
|
87
107
|
// approval click → finalize 当前 liveStream, 后续 tool/text 落到 standalone。
|
|
88
108
|
// 规则 2: 用户点击授权那一刻就是"上一段对话"的边界, 截断 stream 让授权后的
|
|
89
109
|
// 工作单独成块, 比让 stream 一直长到下一个 inbound / hardTimer 更清晰。
|
|
90
|
-
|
|
91
|
-
const onApproved = cfg.wrc.mode === "mirror"
|
|
92
|
-
? (sid) => bridge.terminateLiveStream(sid)
|
|
93
|
-
: undefined;
|
|
110
|
+
const onApproved = (sid) => bridge.terminateLiveStream(sid);
|
|
94
111
|
installApprovalEventListener(ws.client, log.child({ mod: "approval" }), cfg, onApproved);
|
|
95
112
|
// Route approval cards to the WeCom chat bound to the requesting session.
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
const getMirrorTarget = cfg.wrc.mode === "mirror"
|
|
100
|
-
? (sid) => bridge.targetForSession(sid)
|
|
101
|
-
: (sid) => {
|
|
102
|
-
for (const [principal, s] of Object.entries(sessions.all())) {
|
|
103
|
-
if (s === sid)
|
|
104
|
-
return principal;
|
|
105
|
-
}
|
|
106
|
-
return undefined;
|
|
107
|
-
};
|
|
113
|
+
// — the chat this session's pane is attached to. Falls back to
|
|
114
|
+
// cfg.approval.approvers / cfg.defaultChat when nothing is bound.
|
|
115
|
+
const getMirrorTarget = (sid) => bridge.targetForSession(sid);
|
|
108
116
|
// Pre-card barrier: drain pending mirror text/tool markdown for this session
|
|
109
117
|
// and await its FIFO so vote/approval cards never overtake the "thinking" bubble.
|
|
110
|
-
|
|
111
|
-
const flushBeforeCard = cfg.wrc.mode === "mirror"
|
|
112
|
-
? (sid, expect) => bridge.flushBeforeCard(sid, expect)
|
|
113
|
-
: undefined;
|
|
118
|
+
const flushBeforeCard = (sid, expect) => bridge.flushBeforeCard(sid, expect);
|
|
114
119
|
// `.claude/**` 写守卫要用的四个 pane 原语。cancel/tell 复用现成的 target 级方法
|
|
115
120
|
// (先 sessionId → target 再调), 只有 hasPane / answerNativeModal 是 pane 级新增。
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const t = b.targetForSession(sid);
|
|
129
|
-
return t ? await b.injectText(t, text) : { ok: false, reason: "no mirror target for session" };
|
|
130
|
-
},
|
|
131
|
-
};
|
|
132
|
-
})()
|
|
133
|
-
: undefined;
|
|
121
|
+
const nativeModal = {
|
|
122
|
+
hasPane: (sid) => bridge.hasLivePane(sid),
|
|
123
|
+
answer: (sid, opts) => bridge.answerNativeModal(sid, opts),
|
|
124
|
+
cancel: async (sid) => {
|
|
125
|
+
const t = bridge.targetForSession(sid);
|
|
126
|
+
return t ? await bridge.interruptPane(t) : { ok: false, reason: "no mirror target for session" };
|
|
127
|
+
},
|
|
128
|
+
tell: async (sid, text) => {
|
|
129
|
+
const t = bridge.targetForSession(sid);
|
|
130
|
+
return t ? await bridge.injectText(t, text) : { ok: false, reason: "no mirror target for session" };
|
|
131
|
+
},
|
|
132
|
+
};
|
|
134
133
|
const http = startHttp({ cfg, ws, log, sourcePath });
|
|
135
134
|
http.register("POST /approve", makeApproveHandler({ cfg, log: log.child({ mod: "approval" }), client: ws.client, sourcePath, getMirrorTarget, flushBeforeCard, nativeModal }));
|
|
136
135
|
http.register("POST /message", makeMessageHandler(ws.client, log.child({ mod: "outbound" })));
|
|
@@ -209,20 +208,11 @@ const main = async () => {
|
|
|
209
208
|
});
|
|
210
209
|
wedocLog.info("wedoc bridge ready");
|
|
211
210
|
}
|
|
212
|
-
// Mirror-mode:
|
|
213
|
-
|
|
211
|
+
// Mirror-mode: status + the spawn/switch routes the chat side drives.
|
|
212
|
+
{
|
|
214
213
|
const m = bridge;
|
|
215
214
|
installMirrorEventListener(ws.client, m, log.child({ mod: "mirror" }));
|
|
216
215
|
http.register("GET /mirror/status", (_req, res) => json(res, 200, m.status()));
|
|
217
|
-
http.register("POST /mirror/attach", async (req, res) => {
|
|
218
|
-
const body = (await readBody(req));
|
|
219
|
-
if (!body.sessionId || !body.jsonlPath) {
|
|
220
|
-
json(res, 400, { ok: false, reason: "sessionId and jsonlPath required" });
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
|
-
const r = m.attach({ sessionId: body.sessionId, jsonlPath: body.jsonlPath, target: body.target, tmuxPane: body.tmuxPane, tmuxSession: body.tmuxSession });
|
|
224
|
-
json(res, r.ok ? 200 : 400, r);
|
|
225
|
-
});
|
|
226
216
|
// Manual auto-spawn trigger — used by `wezard init` to materialize a
|
|
227
217
|
// tmux+claude pane immediately after claim, instead of waiting for the
|
|
228
218
|
// first inbound. Body: { target?: "user:xxx" | "chat:xxx" }. Falls back
|
|
@@ -353,21 +343,28 @@ const main = async () => {
|
|
|
353
343
|
return;
|
|
354
344
|
}
|
|
355
345
|
const foreign = base !== baseOfKey(self);
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
// 「再来一个」, 不是「把那个重启」; 让它换个名字。
|
|
360
|
-
if (asked && taken.has(asked)) {
|
|
361
|
-
json(res, 409, { ok: false, reason: `peer '#${asked}' already exists in ${foreign ? `chat '${wantChat}'` : "this chat"} — pick a different tag, or drive that one with send_peer`, tag: asked });
|
|
346
|
+
const slotR = await claimSlot(base, String(body.name ?? body.tag ?? ""), tagFromCwd(cwd) || "peer");
|
|
347
|
+
if (!slotR.ok) {
|
|
348
|
+
json(res, slotR.status, slotR.body);
|
|
362
349
|
return;
|
|
363
350
|
}
|
|
364
|
-
const
|
|
365
|
-
const
|
|
351
|
+
const { target, slot: tag } = slotR;
|
|
352
|
+
const existed = !!wizards.get(target);
|
|
353
|
+
const name = wizards.rename(target, normalizeTag(String(body.name ?? body.tag ?? "")) || tag);
|
|
366
354
|
const model = (body.model ?? "").toString().trim();
|
|
367
|
-
|
|
368
|
-
const
|
|
355
|
+
// 没点名就按配置的 keepalive.spawnDefault 来 —— 调用方明说的永远优先。
|
|
356
|
+
const keepalive = typeof body.keepalive === "boolean" ? body.keepalive : cfg.wrc.mirror.keepalive.spawnDefault;
|
|
357
|
+
log.child({ mod: "mirror", sub: "sessions-new", target }).info({ self, cwd, foreign, cli: body.cli, model, keepalive }, "spawning peer session");
|
|
358
|
+
const r = await m.newSession(target, name, body.cli, { cwd, model, keepalive });
|
|
359
|
+
if (!r.ok && !existed)
|
|
360
|
+
wizards.drop(target);
|
|
361
|
+
// r.model 是 spawnTmuxClaude 通过 /model 实测确认落地的那个 —— 可能跟调用方
|
|
362
|
+
// 传的原始字符串不一样 (口语化 → 目录里匹配到的关键词), 播报要报实情。
|
|
363
|
+
const modelNote = r.model
|
|
364
|
+
? (r.modelWarning ? ` · 模型 ${r.model} (⚠️ ${r.modelWarning})` : ` · 模型 ${r.model}`)
|
|
365
|
+
: "";
|
|
369
366
|
if (r.ok)
|
|
370
|
-
postRoster(base, [target, self], `新 wizard
|
|
367
|
+
postRoster(base, [target, self], `新 wizard **.${name}** 就位${r.cwd ? ` · 工作区 ${r.cwd}` : ""}${modelNote}${keepalive ? "" : " · 已关闭 keepalive"} —— 空白起步, 由 ${displayName(self)} 造的`);
|
|
371
368
|
json(res, r.ok ? 200 : 500, r.ok
|
|
372
369
|
? {
|
|
373
370
|
ok: true,
|
|
@@ -375,10 +372,12 @@ const main = async () => {
|
|
|
375
372
|
self,
|
|
376
373
|
target,
|
|
377
374
|
base,
|
|
378
|
-
|
|
375
|
+
name,
|
|
379
376
|
cwd: r.cwd,
|
|
380
|
-
...(model ? { model } : {}),
|
|
377
|
+
...(r.model ? { model: r.model } : {}),
|
|
378
|
+
...(r.modelWarning ? { modelWarning: r.modelWarning } : {}),
|
|
381
379
|
foreign,
|
|
380
|
+
keepalive,
|
|
382
381
|
// 调用方之后拿这个串 send_peer / peek_peer 驱动它。
|
|
383
382
|
address: peerAddress(cfg, self, target),
|
|
384
383
|
}
|
|
@@ -432,8 +431,15 @@ const main = async () => {
|
|
|
432
431
|
json(res, 400, { ok: false, reason: "target required (or pass sessionId of an attached chat)" });
|
|
433
432
|
return;
|
|
434
433
|
}
|
|
434
|
+
// keep = 人回答了「不用换, 就用现在这个」。只落一笔确认, 不碰 pane —— 把
|
|
435
|
+
// 「保持现状」也走成 setPendingCwd + newSession 的话, 代价是白杀一个会话。
|
|
436
|
+
if (body.keep === true) {
|
|
437
|
+
const c = m.confirmCwd(target);
|
|
438
|
+
json(res, c.ok ? 200 : 400, c.ok ? { ok: true, target, cwd: c.cwd, confirmed: true } : { ok: false, reason: c.reason, target });
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
435
441
|
if (!cwd) {
|
|
436
|
-
json(res, 400, { ok: false, reason: "cwd required" });
|
|
442
|
+
json(res, 400, { ok: false, reason: "cwd required (or keep:true to confirm the current one)" });
|
|
437
443
|
return;
|
|
438
444
|
}
|
|
439
445
|
const set = m.setPendingCwd(target, cwd);
|
|
@@ -484,7 +490,7 @@ const main = async () => {
|
|
|
484
490
|
target: t,
|
|
485
491
|
tag: tagOfKey(t),
|
|
486
492
|
address: self ? peerAddress(cfg, self, t) : t,
|
|
487
|
-
name:
|
|
493
|
+
name: settleName(wizards, chatNameOf(cfg, t), t),
|
|
488
494
|
description: wizards.get(t)?.description ?? "",
|
|
489
495
|
cwd: m.getCwd(t).runningCwd || m.getCwd(t).defaultCwd,
|
|
490
496
|
})),
|
|
@@ -507,7 +513,15 @@ const main = async () => {
|
|
|
507
513
|
}
|
|
508
514
|
const previous = chatNameOf(cfg, self);
|
|
509
515
|
const r = setChatName(cfg, sourcePath, self, raw);
|
|
510
|
-
|
|
516
|
+
if (!r.ok) {
|
|
517
|
+
json(res, 409, { ok: false, reason: r.reason });
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
// 默认 wizard 的名字取自聊天名; 它还叫旧聊天名 (没被单独改过) 就跟着改。
|
|
521
|
+
const home = baseOfKey(self);
|
|
522
|
+
const cur = wizards.get(home)?.name ?? "";
|
|
523
|
+
const wizardRenamed = previous && cur.toLowerCase() === previous.toLowerCase() ? wizards.rename(home, r.name) : undefined;
|
|
524
|
+
json(res, 200, { ok: true, base: r.base, name: r.name, previous, ...(wizardRenamed ? { wizardRenamed } : {}) });
|
|
511
525
|
});
|
|
512
526
|
const readPeerBody = async (req) => {
|
|
513
527
|
const body = (await readBody(req));
|
|
@@ -525,63 +539,87 @@ const main = async () => {
|
|
|
525
539
|
});
|
|
526
540
|
// Push a plain markdown bubble into a chat. `base` may carry a `#tag` — a
|
|
527
541
|
// tagged key strips down to the same WeCom chatid as its base.
|
|
542
|
+
// 头不走 withTagHeader 的那些 (relay / notify / 工单) 正文里的 tag 在这里挂链;
|
|
543
|
+
// 已挂过的 linkTags 认得出, 不会再套一层。
|
|
528
544
|
const chatIdOf = (t) => baseOfKey(t).replace(/^(user|chat|group):/, "");
|
|
529
545
|
const notifyChat = (base, markdown) => {
|
|
530
546
|
const chatId = chatIdOf(base);
|
|
531
547
|
void ws.client
|
|
532
|
-
.sendMessage(chatId, { msgtype: "markdown", markdown: { content: markdown } })
|
|
548
|
+
.sendMessage(chatId, { msgtype: "markdown", markdown: { content: linkTags(base, markdown) } })
|
|
533
549
|
.catch((e) => log.warn({ err: e.message }, "chat notify failed"));
|
|
534
550
|
};
|
|
535
551
|
// wizard 在群里的称呼, 只用在**正文**里。气泡的头照旧交给 withTagHeader ——
|
|
536
552
|
// 那一段是路由信息 (`emoji #tag`), parseTagHeader 靠它反解, 群里引用一条气泡
|
|
537
553
|
// 就能直接跟那个 wizard 说话; 把它换成名字会把这条通路弄断。
|
|
538
554
|
const displayName = (t) => {
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
return name || (tag ? `#${tag}` : "默认 wizard");
|
|
555
|
+
const name = settleName(wizards, chatNameOf(cfg, t), t);
|
|
556
|
+
return name ? `.${name}` : "默认 wizard";
|
|
542
557
|
};
|
|
543
|
-
// wizard
|
|
544
|
-
//
|
|
558
|
+
// wizard 之间的往返默认是私聊, 不进群。只有发话方判断「该当着人说」的那一次
|
|
559
|
+
// (send_peer public:true) 在公开频道里成一条气泡, 头写成 `.a → .b`, 方向一眼可读。
|
|
545
560
|
const RELAY_MAX = 1200;
|
|
546
|
-
/** 一个 wizard
|
|
547
|
-
*
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
const
|
|
551
|
-
const name = baseOfKey(t) === dest
|
|
552
|
-
? withTagHeader(t, "").trim()
|
|
553
|
-
: `${tagOfKey(t) ? labelFor(tagOfKey(t)) : "🧙"} ${peerAddress(cfg, dest, t)}`;
|
|
554
|
-
const url = chatDetailUrl(t);
|
|
555
|
-
return url ? `[${name}](${url})` : name;
|
|
556
|
-
};
|
|
557
|
-
/** 头上的名字挂它自己的 chat 详情页 —— 看见「A → B」的人下一步想问的永远是
|
|
558
|
-
* 「A 那边在干嘛」, 链接就省掉他去翻群找 A 气泡这一步。票据优先取该 wizard 自己
|
|
559
|
-
* 最近那条 turn (mirror 的 linkedTagPrefix 同源), 取不到就退到它所在聊天的长期
|
|
560
|
-
* 票据 —— `?id=` 的授权范围本来就是整个聊天, 开在谁那一栏由 `target=` 明说, 所以
|
|
561
|
-
* 换票不多给权限也不会开错栏。少了这层兜底, `A → B` 里的 B 几乎总是不可点:
|
|
562
|
-
* detail store 只留 24h, 而被派活的那个 wizard 恰恰常常是刚出生 / 闲了一天的,
|
|
563
|
-
* 跨聊天派活时它所在的那个群更可能整个群都没有 turn 记录。 */
|
|
564
|
-
const chatDetailUrl = (t) => {
|
|
565
|
-
const id = latestTurnIdFor(t) ?? chatTicketFor(baseOfKey(t));
|
|
566
|
-
return id ? buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id, chatIdOf(t), t) : undefined;
|
|
567
|
-
};
|
|
568
|
-
/** 一条 relay 只落在**收信那一方**的群里, 从不两头都发:
|
|
569
|
-
* - 派活 → to 的群。同群时那就是双方共处的那个群 (行为照旧); 跨群时源头群
|
|
570
|
-
* 不再复述 —— 那句话本来就是它自己说出口的, 贴回自己群里只是噪音, 真正
|
|
571
|
-
* 需要看见的是 to 那边的人: 活是谁派来的。
|
|
572
|
-
* - 回程结论 (`relayPeer(target, self)`) → 问话人的群。答话方自己的群里,
|
|
573
|
-
* 它的回复早就以它自己的气泡出现过了。 */
|
|
574
|
-
const relayPeer = (from, to, body) => {
|
|
561
|
+
/** 一个 wizard 在群里的称呼: `emoji .name`, 挂它的 rolepage。名字全局唯一, 不再
|
|
562
|
+
* 因为落在哪个群而换写法 —— 引用这一段就能跟它说话 (parseTagHeader 认得)。 */
|
|
563
|
+
const relayLabel = (t, _dest) => tagLink(t, tagHead(t));
|
|
564
|
+
/** 公开的 wizard 间对话: 落在 `channel` 这个群。 */
|
|
565
|
+
const relayPeer = (from, to, body, channel) => {
|
|
575
566
|
const text = body.trim();
|
|
576
|
-
if (!text)
|
|
567
|
+
if (!text || !channel)
|
|
577
568
|
return;
|
|
578
|
-
const
|
|
579
|
-
const head = `${relayLabel(from, dest)} → ${relayLabel(to, dest)}`;
|
|
569
|
+
const head = `${relayLabel(from)} → ${relayLabel(to)}`;
|
|
580
570
|
const clipped = text.length > RELAY_MAX ? `${text.slice(0, RELAY_MAX)}…` : text;
|
|
581
571
|
// 头独占一行, 正文自成一个块 —— 只隔一个换行的话, markdown 会把正文首行
|
|
582
572
|
// 当成头那一段的续行; 表格因此整张塌成一行带竖线的文字 (表格不能打断段落)。
|
|
583
|
-
|
|
584
|
-
|
|
573
|
+
notifyChat(channel, `${head}\n\n${clipped}`);
|
|
574
|
+
};
|
|
575
|
+
/** 调用方这一轮所在的公开频道: 人从哪个群叫的它 / 公开 peer 轮的那个群;
|
|
576
|
+
* 私聊轮或无记录 → 它的 home 群。notify 与 public send_peer 默认发到这里。 */
|
|
577
|
+
const channelOf = (self) => m.currentChannel(self) || baseOfKey(self);
|
|
578
|
+
/** 入参里的地址: 新字段 `name`, 老 MCP 进程 (正在跑的 wizard) 仍在传 `tag`。 */
|
|
579
|
+
const addrOf = (b) => String(b.name ?? b.tag ?? "");
|
|
580
|
+
/** 给一个要出生的 wizard 挑槽位 (target key) 并落定名字。名字全局唯一:
|
|
581
|
+
* - 撞上的是一个还绑着会话的 wizard → 409, 附上它的死活, 调用方据此换名或先收掉它;
|
|
582
|
+
* - 撞上的只是目标群里同一个槽位的冷记录 (会话早没了) → 复用这个槽, 即原名重生,
|
|
583
|
+
* 与从前「同一个 tag 重开」一样, 家谱/记忆都还在;
|
|
584
|
+
* - 撞上别的群里的冷记录 → 409: 名字仍被那条记录占着。
|
|
585
|
+
* 槽位 (key 里的 `#k`) 只是内部 id: 默认取名字本身, 被占了就加序号, 与名字无关。 */
|
|
586
|
+
const claimSlot = async (base, want, fallback) => {
|
|
587
|
+
const asked = normalizeTag(want);
|
|
588
|
+
const clash = asked ? wizards.byName(asked) : undefined;
|
|
589
|
+
if (clash) {
|
|
590
|
+
const bound = m.chatTargets(baseOfKey(clash.target)).includes(clash.target);
|
|
591
|
+
const reusable = !bound && baseOfKey(clash.target) === base && !!tagOfKey(clash.target);
|
|
592
|
+
if (reusable)
|
|
593
|
+
return { ok: true, target: clash.target, slot: tagOfKey(clash.target) };
|
|
594
|
+
const info = bound ? (await m.peers(clash.target)).find((p) => p.target === clash.target) : undefined;
|
|
595
|
+
const alive = info?.paneAlive ?? false;
|
|
596
|
+
const idleForMs = info?.lastActivity ? Date.now() - info.lastActivity : undefined;
|
|
597
|
+
const idleDesc = idleForMs !== undefined ? `静默 ${Math.round(idleForMs / 60000)} 分钟` : "从没动过";
|
|
598
|
+
const advice = alive
|
|
599
|
+
? "它的 pane 还活着 —— 除非这就是同一件事的延续, 否则换个名字重新生, 别把不相关的活塞给一个已经有职责的 wizard"
|
|
600
|
+
: bound
|
|
601
|
+
? `pane 已经不在了 (${idleDesc}), 真要复用这个名字就先 stop_wizard({name:"${clash.name}", mode:"end"}) 收掉, 再用同一个名字重新生 —— 拿到干净的上下文; 别直接 send_peer 唤醒它接手, 它会带着上一件事的记忆答你这件`
|
|
602
|
+
: "这个名字被别的群里一个已经不在跑的 wizard 占着 —— 换个名字";
|
|
603
|
+
return {
|
|
604
|
+
ok: false,
|
|
605
|
+
status: 409,
|
|
606
|
+
body: {
|
|
607
|
+
ok: false,
|
|
608
|
+
reason: `名字 '.${clash.name}' 已经属于一个${alive ? "活着的" : "不再活跃的"} wizard (名字全局唯一) —— ${advice}`,
|
|
609
|
+
name: clash.name,
|
|
610
|
+
address: clash.name,
|
|
611
|
+
alive,
|
|
612
|
+
busy: info?.busy ?? false,
|
|
613
|
+
idleForMs,
|
|
614
|
+
},
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
const taken = new Set([
|
|
618
|
+
...m.chatTargets(base).map(tagOfKey),
|
|
619
|
+
...wizards.all().filter((w) => baseOfKey(w.target) === base).map((w) => tagOfKey(w.target)),
|
|
620
|
+
].filter(Boolean));
|
|
621
|
+
const slot = uniqueTag(asked || normalizeTag(fallback) || "wizard", taken);
|
|
622
|
+
return { ok: true, target: keyOf(base, slot), slot };
|
|
585
623
|
};
|
|
586
624
|
// ── 给人看的消息 ──────────────────────────────────────────────────
|
|
587
625
|
// send_peer 把话塞进另一个 agent 的输入框 (驱动它干活); 这条把话贴进一个聊天
|
|
@@ -611,9 +649,9 @@ const main = async () => {
|
|
|
611
649
|
json(res, 400, { ok: false, reason: `认不出这些聊天: ${bad.join(", ")} (list_chats 看有哪些; 没起名的聊天寻址不到)` });
|
|
612
650
|
return;
|
|
613
651
|
}
|
|
614
|
-
const dests = [...new Set(refs.length ? refs.map((r) => chatBaseOf(cfg, r)) : [
|
|
652
|
+
const dests = [...new Set(refs.length ? refs.map((r) => chatBaseOf(cfg, r)) : [channelOf(self)])];
|
|
615
653
|
for (const dest of dests)
|
|
616
|
-
notifyChat(dest, `${relayLabel(self
|
|
654
|
+
notifyChat(dest, `${relayLabel(self)}\n\n${content}`);
|
|
617
655
|
json(res, 200, { ok: true, sent: dests.map((d) => chatNameOf(cfg, d) || d) });
|
|
618
656
|
});
|
|
619
657
|
http.register("POST /peers/peek", async (req, res) => {
|
|
@@ -622,7 +660,7 @@ const main = async () => {
|
|
|
622
660
|
json(res, 400, { ok: false, reason: "cannot resolve caller session" });
|
|
623
661
|
return;
|
|
624
662
|
}
|
|
625
|
-
const r = resolvePeer(self, body
|
|
663
|
+
const r = resolvePeer(self, addrOf(body));
|
|
626
664
|
if (!r.ok) {
|
|
627
665
|
json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
|
|
628
666
|
return;
|
|
@@ -663,12 +701,16 @@ const main = async () => {
|
|
|
663
701
|
return;
|
|
664
702
|
}
|
|
665
703
|
}
|
|
666
|
-
const r = resolvePeer(self, body
|
|
704
|
+
const r = resolvePeer(self, addrOf(body));
|
|
667
705
|
if (!r.ok) {
|
|
668
706
|
json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
|
|
669
707
|
return;
|
|
670
708
|
}
|
|
671
709
|
const { target, foreign } = r;
|
|
710
|
+
// 公开与否由发话方 (LLM) 判断: 公开 = 在它这一轮的公开频道里说, 气泡进群、对方
|
|
711
|
+
// 那一轮的回复也发进这个群; 私聊 (默认) = 只落双方的 rolepage, 回复靠 wait_peer 取。
|
|
712
|
+
const isPublic = body.public === true;
|
|
713
|
+
const channel = isPublic ? channelOf(self) : "";
|
|
672
714
|
// Injecting into your own pane would type into the box you're generating
|
|
673
715
|
// from — Claude Code queues it and the caller deadlocks waiting for itself.
|
|
674
716
|
if (target === self) {
|
|
@@ -694,17 +736,18 @@ const main = async () => {
|
|
|
694
736
|
return;
|
|
695
737
|
}
|
|
696
738
|
}
|
|
697
|
-
const inj = await m.injectText(target, text, undefined, {
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
739
|
+
const inj = await m.injectText(target, text, undefined, {
|
|
740
|
+
from: { kind: "peer", from: self, ...(jobId ? { job: jobId } : {}), ...(isPublic ? { public: true } : {}) },
|
|
741
|
+
channel,
|
|
742
|
+
});
|
|
743
|
+
// 工单成员照旧记账 (收工那一条会列出各自那段活); 公开的那一句在群里成气泡。
|
|
701
744
|
if (inj.ok && jobId)
|
|
702
745
|
jobs.attach(jobId, { target, task: text, spawned: false });
|
|
703
|
-
if (inj.ok &&
|
|
704
|
-
relayPeer(self, target, text);
|
|
746
|
+
if (inj.ok && isPublic)
|
|
747
|
+
relayPeer(self, target, text, channel);
|
|
705
748
|
// `wasBusy` 是给调用方的判断依据: 立刻投给一个正在生成的会话, 这句话会排在
|
|
706
749
|
// 它这一轮后面, 而不是马上被读到。
|
|
707
|
-
json(res, inj.ok ? 200 : 502, { ...inj, target, foreign, when, wasBusy, waitedMs, ...(jobId ? { job: jobId } : {}) });
|
|
750
|
+
json(res, inj.ok ? 200 : 502, { ...inj, target, name: peerAddress(cfg, self, target), foreign, public: isPublic, when, wasBusy, waitedMs, ...(jobId ? { job: jobId } : {}) });
|
|
708
751
|
});
|
|
709
752
|
// 等一个 wizard, 或者等一**组**。fan-out 之后 join 必须是并行的: 串行地等五个
|
|
710
753
|
// 分身, 墙钟是五个之和, 而它们本来就在同时干活。`need` 把 all / any / 过半收进
|
|
@@ -716,7 +759,8 @@ const main = async () => {
|
|
|
716
759
|
return;
|
|
717
760
|
}
|
|
718
761
|
const b = body;
|
|
719
|
-
const
|
|
762
|
+
const many = Array.isArray(b.names) && b.names.length > 0 ? b.names : b.tags;
|
|
763
|
+
const asked = (Array.isArray(many) && many.length > 0 ? many : [addrOf(b)]).map((x) => String(x ?? ""));
|
|
720
764
|
if (asked.length > 16) {
|
|
721
765
|
json(res, 400, { ok: false, reason: "一次最多等 16 个 wizard" });
|
|
722
766
|
return;
|
|
@@ -741,13 +785,7 @@ const main = async () => {
|
|
|
741
785
|
const results = uniq.map((h, i) => {
|
|
742
786
|
const wr = wrs[i];
|
|
743
787
|
const lastText = m.lastText(h.target);
|
|
744
|
-
//
|
|
745
|
-
// 气泡出现 —— 再 relay 一条 `B → A` 就是同一句话在同一个群里出现两次, 正是
|
|
746
|
-
// 「A 告诉 B 之后不必再展示 B 收到了」要消灭的那种重复。跨聊天则相反: A 的群
|
|
747
|
-
// 里看不到 B 的任何气泡, 这条 relay 是那边唯一能看见结论的地方 —— 所以它落在
|
|
748
|
-
// self (问话人) 的群, 而不是答话方那边。
|
|
749
|
-
if (wr.idle && h.foreign)
|
|
750
|
-
relayPeer(h.target, self, lastText);
|
|
788
|
+
// 回程不再进群: 私聊的结论由调用方自己收口给人; 公开轮的回复 mirror 早已发进那个群。
|
|
751
789
|
// `result` 是它自己收口的那一行 (见 peers.extractResult); 捞不到就是 "",
|
|
752
790
|
// 调用方照旧读 lastText。
|
|
753
791
|
return { address: h.address, target: h.target, foreign: h.foreign, idle: wr.idle, reason: wr.reason, result: extractResult(lastText), lastText };
|
|
@@ -784,7 +822,7 @@ const main = async () => {
|
|
|
784
822
|
}
|
|
785
823
|
}
|
|
786
824
|
else {
|
|
787
|
-
const r = resolvePeer(self, body
|
|
825
|
+
const r = resolvePeer(self, addrOf(body));
|
|
788
826
|
if (!r.ok) {
|
|
789
827
|
json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
|
|
790
828
|
return;
|
|
@@ -839,8 +877,8 @@ const main = async () => {
|
|
|
839
877
|
: { ok: false, target, brief, reason: `handoff carry inject failed: ${inj2.reason}` });
|
|
840
878
|
});
|
|
841
879
|
// ── Wizard: 会话的身份层 ────────────────────────────────────────────
|
|
842
|
-
// 一个会话 (`chat:xxx#
|
|
843
|
-
//
|
|
880
|
+
// 一个会话 (`chat:xxx#k`) 从此是一个 wizard: 一个全局唯一的名字 (默认会话出生时
|
|
881
|
+
// 取聊天名, 分身取自己的名字)、一句职责、一份跨会话的记忆、一条家谱。身份不靠"在对话里讲一遍"
|
|
844
882
|
// 维持 —— spawn 时以 `--append-system-prompt` 压进那个进程, `/clear` 抹不掉、
|
|
845
883
|
// 上下文窗口也挤不掉。
|
|
846
884
|
// 这里只做三件事: 读身份、改身份、按身份生/收分身。tmux 一概不碰, 动作全在
|
|
@@ -885,10 +923,14 @@ const main = async () => {
|
|
|
885
923
|
log.info({ base, name }, "chat: auto-named from workspace");
|
|
886
924
|
// 群里不发气泡 (一次补名会命中十几个群, 那是刷屏), 但住在里面的 wizard
|
|
887
925
|
// 得知道自己的地址变了 —— 它的 charter 里写的还是「(未命名)」。
|
|
888
|
-
postRoster(base, [], `这个聊天现在叫 **${name}** (按工作区自动起的) ——
|
|
926
|
+
postRoster(base, [], `这个聊天现在叫 **${name}** (按工作区自动起的) —— notify / new_claude_session 可以用这个名字指到这里。`);
|
|
889
927
|
}
|
|
890
928
|
return named;
|
|
891
929
|
};
|
|
930
|
+
// 名字全局唯一 (见 wizard.ts): 先给没名字的聊天补名, 再按「默认会话先挑」把
|
|
931
|
+
// 每个已知 wizard 的名字落定 —— 老记录 (名字还跟着聊天走的那一代) 在这里一次迁完。
|
|
932
|
+
ensureChatNames(cfg.defaultChat || "");
|
|
933
|
+
settleAll(wizards, [...m.chatRoster("").flatMap((c) => c.targets), ...wizards.all().map((w) => w.target)], (t) => chatNameOf(cfg, t));
|
|
892
934
|
// 工单账本 (见 jobs.ts)。只在**显式传了 `job`** 时起作用 —— 不用工单的调用方
|
|
893
935
|
// 行为与从前一模一样。
|
|
894
936
|
const jobs = loadJobStore(cfg.wrc.mirror.jobsFile);
|
|
@@ -925,7 +967,7 @@ const main = async () => {
|
|
|
925
967
|
const w = byTarget.get(p.target);
|
|
926
968
|
return {
|
|
927
969
|
target: p.target,
|
|
928
|
-
name:
|
|
970
|
+
name: settleName(wizards, chatNameOf(cfg, p.target), p.target),
|
|
929
971
|
description: w?.description ?? "",
|
|
930
972
|
chat: chatNameOf(cfg, p.target) || p.chat,
|
|
931
973
|
cwd: p.cwd,
|
|
@@ -935,6 +977,7 @@ const main = async () => {
|
|
|
935
977
|
alive: p.paneAlive,
|
|
936
978
|
parent: w?.parent,
|
|
937
979
|
clonedFrom: w?.clonedFrom,
|
|
980
|
+
forkOf: w?.forkOf,
|
|
938
981
|
bornAt: w?.bornAt,
|
|
939
982
|
lastActivity: p.lastActivity,
|
|
940
983
|
summary: p.summary,
|
|
@@ -946,13 +989,14 @@ const main = async () => {
|
|
|
946
989
|
.filter((w) => !seen.has(w.target))
|
|
947
990
|
.map((w) => ({
|
|
948
991
|
target: w.target,
|
|
949
|
-
name:
|
|
992
|
+
name: settleName(wizards, chatNameOf(cfg, w.target), w.target),
|
|
950
993
|
description: w.description,
|
|
951
994
|
chat: chatNameOf(cfg, w.target),
|
|
952
995
|
cwd: "", model: "", cli: "",
|
|
953
996
|
busy: false, alive: false,
|
|
954
997
|
parent: w.parent,
|
|
955
998
|
clonedFrom: w.clonedFrom,
|
|
999
|
+
forkOf: w.forkOf,
|
|
956
1000
|
bornAt: w.bornAt,
|
|
957
1001
|
lastActivity: 0,
|
|
958
1002
|
summary: "(未运行)",
|
|
@@ -965,16 +1009,20 @@ const main = async () => {
|
|
|
965
1009
|
openedAt: j.openedAt, closedAt: j.closedAt, summary: j.summary,
|
|
966
1010
|
members: j.members.map((mm) => ({ target: mm.target, task: mm.task, spawned: mm.spawned })),
|
|
967
1011
|
})),
|
|
968
|
-
schedules:
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1012
|
+
schedules: tasks.list().filter((x) => x.enabled).map((x) => {
|
|
1013
|
+
const st = tasks.stateOf(x.id);
|
|
1014
|
+
return {
|
|
1015
|
+
id: x.id,
|
|
1016
|
+
target: x.target || (cfg.defaultChat ?? ""),
|
|
1017
|
+
when: describeTrigger(x.trigger),
|
|
1018
|
+
nextAt: nextFire(x.trigger, sinceOf(st), nowDate),
|
|
1019
|
+
lastFired: st.lastFired,
|
|
1020
|
+
prompt: x.prompt,
|
|
1021
|
+
note: x.note,
|
|
1022
|
+
createdBy: x.createdBy,
|
|
1023
|
+
owner: x.owner || (cfg.defaultChat ?? ""),
|
|
1024
|
+
};
|
|
1025
|
+
}),
|
|
978
1026
|
chatNames: Object.fromEntries(listChatNames(cfg).map((c) => [c.base, c.name])),
|
|
979
1027
|
};
|
|
980
1028
|
};
|
|
@@ -989,39 +1037,46 @@ const main = async () => {
|
|
|
989
1037
|
* 给交接那一轮本身 —— 提示而已, 决定权在 wizard 自己。 */
|
|
990
1038
|
const HANDOFF_HINT_TOKENS = 140_000;
|
|
991
1039
|
const briefOf = (self, target) => ({
|
|
992
|
-
name:
|
|
1040
|
+
name: settleName(wizards, chatNameOf(cfg, target), target),
|
|
993
1041
|
address: peerAddress(cfg, self, target),
|
|
994
1042
|
description: wizards.get(target)?.description ?? "",
|
|
995
1043
|
cwd: m.getCwd(target).runningCwd,
|
|
996
1044
|
});
|
|
997
|
-
/**
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
const tag = tagOfKey(target);
|
|
1003
|
-
return chat ? `${chat}#${tag}` : tag;
|
|
1045
|
+
/** 自己的地址 = 全局名字, 与住在哪个群无关。 */
|
|
1046
|
+
const selfAddress = (target) => settleName(wizards, chatNameOf(cfg, target), target);
|
|
1047
|
+
const sharedMemory = (scope, key) => {
|
|
1048
|
+
const path = memoryPath(cfg.daemon.stateDir, scope, key);
|
|
1049
|
+
return clipForCharter(readMemory(path), path);
|
|
1004
1050
|
};
|
|
1005
1051
|
/** 开局宪章。出生时的兄弟只是快照 —— 名册随时可查, 写进系统提示的那份只为了
|
|
1006
1052
|
* 让它一睁眼就知道自己不是一个人在跑。 */
|
|
1007
1053
|
const charterFor = (target, o) => renderCharter({
|
|
1008
|
-
|
|
1054
|
+
// o.cwd = 正在启动的那个 pane 的目录; 没给才退回"现在记着的那个"。
|
|
1055
|
+
self: { ...briefOf(target, target), address: selfAddress(target), ...(o.cwd ? { cwd: o.cwd } : {}) },
|
|
1009
1056
|
chat: chatNameOf(cfg, target),
|
|
1010
1057
|
principal: baseOfKey(target),
|
|
1011
1058
|
parent: o.parent ? briefOf(target, o.parent) : undefined,
|
|
1012
1059
|
inherited: !!o.inherited,
|
|
1060
|
+
forkOf: o.forkOf ? briefOf(target, o.forkOf) : undefined,
|
|
1061
|
+
cwdUnconfirmed: m.cwdUnconfirmed(target, o.cwd),
|
|
1013
1062
|
siblings: m.chatTargets(baseOfKey(target)).filter((t) => t !== target).map((t) => briefOf(target, t)),
|
|
1014
1063
|
memory: wizards.get(target)?.memory ?? [],
|
|
1064
|
+
chatMemory: sharedMemory("chat", baseOfKey(target)),
|
|
1065
|
+
workspaceMemory: (() => {
|
|
1066
|
+
const cwd = o.cwd || m.getCwd(target).runningCwd || m.getCwd(target).defaultCwd;
|
|
1067
|
+
return cwd ? sharedMemory("workspace", cwd) : "";
|
|
1068
|
+
})(),
|
|
1069
|
+
steward: !tagOfKey(target),
|
|
1015
1070
|
});
|
|
1016
1071
|
// 所有 spawn 路径 (群里手打 /new、pane 自愈重生、编排出来的分身) 都从这里
|
|
1017
1072
|
// 取身份, 于是「是谁」不再取决于是哪段代码把它生出来的。
|
|
1018
|
-
m.setCharterProvider((target) => {
|
|
1073
|
+
m.setCharterProvider((target, ctx) => {
|
|
1019
1074
|
// 这里是最要紧的那道拦截: 宪章把「所在聊天」写进系统提示, 而系统提示是随进程
|
|
1020
1075
|
// 终身的 —— 一个在聊天还没名字时出生的 wizard, 会一辈子以为自己住在一个
|
|
1021
1076
|
// 「(未命名)」的地方。所以补名要发生在渲染之前, 不能等它以后自己去查。
|
|
1022
1077
|
ensureChatNames(target);
|
|
1023
1078
|
const rec = wizards.get(target);
|
|
1024
|
-
return charterFor(target, { parent: rec?.parent, inherited: !!rec?.clonedFrom });
|
|
1079
|
+
return charterFor(target, { parent: rec?.parent, forkOf: rec?.forkOf, inherited: !!rec?.clonedFrom, cwd: ctx?.cwd });
|
|
1025
1080
|
});
|
|
1026
1081
|
// 开机也补一次: IM 侧的 `/peers`、`/help` 直接读名字, 不经过任何 MCP 路由。
|
|
1027
1082
|
// 延后是因为附着表要先从盘上恢复完, 否则这一刻还看不见那些聊天; 补不全也无妨,
|
|
@@ -1050,9 +1105,10 @@ const main = async () => {
|
|
|
1050
1105
|
chat: chatNameOf(cfg, target),
|
|
1051
1106
|
principal: baseOfKey(target),
|
|
1052
1107
|
tag: tagOfKey(target),
|
|
1053
|
-
named: !!(rec?.name ?? "").trim()
|
|
1108
|
+
named: !!(rec?.name ?? "").trim(),
|
|
1054
1109
|
bornAt: rec?.bornAt,
|
|
1055
1110
|
inheritedFrom: rec?.clonedFrom || "",
|
|
1111
|
+
...(rec?.forkOf ? { forkOf: briefOf(self, rec.forkOf) } : {}),
|
|
1056
1112
|
memory: rec?.memory ?? [],
|
|
1057
1113
|
...kinOf(self, wizards.all(), target),
|
|
1058
1114
|
sessionId: info?.sessionId ?? "",
|
|
@@ -1114,9 +1170,8 @@ const main = async () => {
|
|
|
1114
1170
|
ensureChatNames(self);
|
|
1115
1171
|
json(res, 200, { ok: true, ...identityOf(self, self), peers: (await m.peers(self)).filter((p) => !p.self).length });
|
|
1116
1172
|
});
|
|
1117
|
-
//
|
|
1118
|
-
//
|
|
1119
|
-
// 分身只写自己的记录, 它的地址本来就是 `chat#tag`。
|
|
1173
|
+
// 改名字 / 写职责。名字全局唯一、与聊天名脱钩 (默认会话只是出生时取了聊天名):
|
|
1174
|
+
// 撞名自动挂 `-N`, 返回里的 `name` 才是落定的那个。
|
|
1120
1175
|
http.register("POST /wizard/identity", async (req, res) => {
|
|
1121
1176
|
const { self, body } = await readPeerBody(req);
|
|
1122
1177
|
if (!self) {
|
|
@@ -1124,31 +1179,26 @@ const main = async () => {
|
|
|
1124
1179
|
return;
|
|
1125
1180
|
}
|
|
1126
1181
|
const b = body;
|
|
1127
|
-
const
|
|
1182
|
+
const asked = normalizeTag((b.name ?? "").toString());
|
|
1128
1183
|
const description = (b.description ?? "").toString().trim();
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
const r = setChatName(cfg, sourcePath, self, name);
|
|
1133
|
-
if (!r.ok) {
|
|
1134
|
-
json(res, 409, { ok: false, reason: r.reason });
|
|
1135
|
-
return;
|
|
1136
|
-
}
|
|
1137
|
-
chatNamed = r.name;
|
|
1184
|
+
if ((b.name ?? "").toString().trim() && !asked) {
|
|
1185
|
+
json(res, 400, { ok: false, reason: "名字只能是 1-32 位字母/数字/`_`/`-`" });
|
|
1186
|
+
return;
|
|
1138
1187
|
}
|
|
1139
|
-
wizards
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1188
|
+
const before = settleName(wizards, chatNameOf(cfg, self), self);
|
|
1189
|
+
const name = asked ? wizards.rename(self, asked) : before;
|
|
1190
|
+
if (description)
|
|
1191
|
+
wizards.upsert(self, { description });
|
|
1143
1192
|
const me = identityOf(self, self);
|
|
1144
|
-
//
|
|
1145
|
-
if (name && !wasNamed)
|
|
1146
|
-
notifyChat(self, withTagHeader(self, `我是 **${me.name}**${description ? ` · ${description}` : ""}`));
|
|
1147
|
-
// 职责是别人决定"该不该找你"的依据, 改了就得让同群的人知道 —— 否则他们照着
|
|
1193
|
+
// 职责是别人决定"该不该找你"的依据, 改了就得让同群的知道 —— 否则他们照着
|
|
1148
1194
|
// 出生快照里那句旧的 (或者空的) 职责派活。
|
|
1149
|
-
if (
|
|
1150
|
-
postRoster(baseOfKey(self), [self],
|
|
1151
|
-
json(res, 200, {
|
|
1195
|
+
if (asked || description)
|
|
1196
|
+
postRoster(baseOfKey(self), [self], `${before && before !== name ? `**.${before}** 改名为 **.${name}**` : `**.${name}** 改了身份`}${description ? ` · 职责: ${description}` : ""}`);
|
|
1197
|
+
json(res, 200, {
|
|
1198
|
+
ok: true,
|
|
1199
|
+
...me,
|
|
1200
|
+
...(asked && name.toLowerCase() !== asked.toLowerCase() ? { renamed: `'.${asked}' 已被占用, 落定为 '.${name}'` } : {}),
|
|
1201
|
+
});
|
|
1152
1202
|
});
|
|
1153
1203
|
// 名册是**索引**, 不是转储: 这台机器上现在有 300+ 个会话, 整表吐出来是三万
|
|
1154
1204
|
// token —— 一个 wizard 每次想找人都付这个价, 等于找不到人。所以服务端过滤
|
|
@@ -1188,6 +1238,9 @@ const main = async () => {
|
|
|
1188
1238
|
: {}),
|
|
1189
1239
|
});
|
|
1190
1240
|
});
|
|
1241
|
+
// 记忆三种作用域: self 跟着自己 (wizards.json), 直写; chat / workspace 是共享的 md,
|
|
1242
|
+
// 每个在那个群 / 那个目录出生的 wizard 都会读到 —— 它们只收提议, 由整理者合并
|
|
1243
|
+
// (见 wizard-memory.ts / memory-steward.ts)。
|
|
1191
1244
|
http.register("POST /wizard/remember", async (req, res) => {
|
|
1192
1245
|
const { self, body } = await readPeerBody(req);
|
|
1193
1246
|
if (!self) {
|
|
@@ -1195,14 +1248,42 @@ const main = async () => {
|
|
|
1195
1248
|
return;
|
|
1196
1249
|
}
|
|
1197
1250
|
const b = body;
|
|
1198
|
-
const cur = wizards.get(self)?.memory ?? [];
|
|
1199
1251
|
const note = (b.note ?? "").toString().trim();
|
|
1200
1252
|
const forget = (b.forget ?? "").toString().trim();
|
|
1253
|
+
const scope = (b.scope ?? "self").toString();
|
|
1254
|
+
if (scope === "chat" || scope === "workspace") {
|
|
1255
|
+
const key = scope === "chat" ? baseOfKey(self) : (m.getCwd(self).runningCwd || m.getCwd(self).defaultCwd);
|
|
1256
|
+
if (!key) {
|
|
1257
|
+
json(res, 400, { ok: false, reason: "这个 wizard 没有工作区, 记不了 workspace 记忆" });
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
if (!note && !forget) {
|
|
1261
|
+
json(res, 400, { ok: false, reason: "note 和 forget 至少给一个" });
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
const path = memoryPath(cfg.daemon.stateDir, scope, key);
|
|
1265
|
+
const queued = proposeMemory(inboxPath(cfg.daemon.stateDir, scope, key), {
|
|
1266
|
+
at: Date.now(),
|
|
1267
|
+
by: selfAddress(self),
|
|
1268
|
+
...(note ? { note } : {}),
|
|
1269
|
+
...(forget ? { forget } : {}),
|
|
1270
|
+
});
|
|
1271
|
+
if (!queued) {
|
|
1272
|
+
json(res, 500, { ok: false, reason: "提议没写进收件箱" });
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
json(res, 200, {
|
|
1276
|
+
ok: true, scope, queued, file: path, memory: readMemory(path),
|
|
1277
|
+
hint: "已提交给记忆整理者, 下一轮整理 (半小时内) 合并进上面这份记忆; 此后出生的 wizard 才读得到",
|
|
1278
|
+
});
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
const cur = wizards.get(self)?.memory ?? [];
|
|
1201
1282
|
// 忘记按子串匹配: 模型记不住自己当初一字不差写了什么, 但记得大意。
|
|
1202
1283
|
const next = forget ? cur.filter((x) => !x.includes(forget)) : cur;
|
|
1203
1284
|
const memory = note ? [...next.filter((x) => x !== note), note] : next;
|
|
1204
1285
|
wizards.upsert(self, { memory });
|
|
1205
|
-
json(res, 200, { ok: true, memory, added: !!note, forgotten: cur.length - next.length });
|
|
1286
|
+
json(res, 200, { ok: true, scope: "self", memory, added: !!note, forgotten: cur.length - next.length });
|
|
1206
1287
|
});
|
|
1207
1288
|
// 生分身。默认 fork 调用方此刻的上下文 —— 这是"先把公共材料读进来, 再分出 N
|
|
1208
1289
|
// 个干活的"之所以省事的原因: 材料只读一遍, 却进了 N 份上下文。
|
|
@@ -1229,8 +1310,29 @@ const main = async () => {
|
|
|
1229
1310
|
json(res, 400, { ok: false, reason: "inherit 必填: true = fork 你此刻的上下文 (它开局就有你读过的材料, 必须留在同一个 cwd); false = 空白分身, 只继承身份" });
|
|
1230
1311
|
return;
|
|
1231
1312
|
}
|
|
1232
|
-
const alivePeers = await m.peers(self);
|
|
1233
1313
|
const inherit = b.inherit;
|
|
1314
|
+
// 克隆的源头: 默认是调用方自己; `from` 点名就 fork 那个 wizard 此刻的上下文。
|
|
1315
|
+
// 分身仍归调用方管 (parent = self: 占它的预算、随它的工单回收), 只有上下文来自别处。
|
|
1316
|
+
// 空串不能交给 resolvePeer —— 那是「本聊天的默认 wizard」, 不是「我自己」。
|
|
1317
|
+
const fromAddr = (b.from ?? "").toString().trim();
|
|
1318
|
+
if (fromAddr && !inherit) {
|
|
1319
|
+
json(res, 400, { ok: false, reason: "from 只对克隆有意义 —— 白板生出来的 wizard 不带任何人的上下文" });
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
const fromR = fromAddr ? resolvePeer(self, fromAddr) : { ok: true, target: self };
|
|
1323
|
+
if (!fromR.ok) {
|
|
1324
|
+
json(res, fromR.status, { ok: false, reason: fromR.reason, ...(fromR.candidates ? { candidates: fromR.candidates } : {}) });
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
const source = fromR.target;
|
|
1328
|
+
const sourceInfo = m.sessionInfo(source);
|
|
1329
|
+
// 别人的会话 fork 不出来就是失败, 不能像克隆自己那样悄悄退化成白板 —— 调用方
|
|
1330
|
+
// 要的恰恰是它的上下文, 给一张白纸等于答非所问。
|
|
1331
|
+
if (source !== self && !sourceInfo?.sessionId) {
|
|
1332
|
+
json(res, 409, { ok: false, reason: `${displayName(source)} 没有可 fork 的会话 (还没说过话, 或绑定已失效)` });
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
const alivePeers = await m.peers(self);
|
|
1234
1336
|
// 预算。撞到上限不是"不许再分", 是"先把干完活的收掉": stop_wizard 收单个,
|
|
1235
1337
|
// close_job 整批回收一个工单的临时分身。只数**本聊天里**活着的 ——
|
|
1236
1338
|
// 生到别的聊天去的归那边管, 为了数它们再探一遍 tmux 不值当。
|
|
@@ -1253,48 +1355,31 @@ const main = async () => {
|
|
|
1253
1355
|
});
|
|
1254
1356
|
return;
|
|
1255
1357
|
}
|
|
1256
|
-
const
|
|
1257
|
-
const
|
|
1258
|
-
if (
|
|
1259
|
-
|
|
1260
|
-
// 断线的冷绑定也算数。真探一次活, 把结论 (alive/busy/多久没动过) 和能直接
|
|
1261
|
-
// 回传给 send_peer/stop_wizard 的完整地址一起吐回去, 调用方才判断得出这是
|
|
1262
|
-
// "该换个名字" 还是"值得 stop_wizard 收掉腾地方再复用"。
|
|
1263
|
-
const existing = keyOf(base, asked);
|
|
1264
|
-
const info = (await m.peers(existing)).find((p) => p.target === existing);
|
|
1265
|
-
const alive = info?.paneAlive ?? false;
|
|
1266
|
-
const idleForMs = info?.lastActivity ? Date.now() - info.lastActivity : undefined;
|
|
1267
|
-
const idleDesc = idleForMs !== undefined ? `静默 ${Math.round(idleForMs / 60000)} 分钟` : "从没动过";
|
|
1268
|
-
const advice = alive
|
|
1269
|
-
? "它的 pane 还活着 —— 除非这就是同一件事的延续, 否则换个 tag 重新 spawn_clone, 别把不相关的活塞给一个已经有职责的 wizard"
|
|
1270
|
-
: `pane 已经不在了 (${idleDesc}), 真要复用这个 tag 就先 stop_wizard({tag:"${asked}", mode:"end"}) 收掉腾出名字, 再用同一个 tag 重新 spawn_clone —— 拿到干净的上下文; 别直接 send_peer 唤醒它接手, 它会带着上一件事的记忆答你这件`;
|
|
1271
|
-
json(res, 409, {
|
|
1272
|
-
ok: false,
|
|
1273
|
-
reason: `'#${asked}' 已经是一个${alive ? "活着的" : "不再活跃的"} wizard —— ${advice}`,
|
|
1274
|
-
tag: asked,
|
|
1275
|
-
address: peerAddress(cfg, self, existing),
|
|
1276
|
-
alive,
|
|
1277
|
-
busy: info?.busy ?? false,
|
|
1278
|
-
idleForMs,
|
|
1279
|
-
});
|
|
1358
|
+
const askedName = String(b.name ?? b.tag ?? "");
|
|
1359
|
+
const slotR = await claimSlot(base, askedName, normalizeTag(b.description?.split(/\s+/)[0]) || "clone");
|
|
1360
|
+
if (!slotR.ok) {
|
|
1361
|
+
json(res, slotR.status, slotR.body);
|
|
1280
1362
|
return;
|
|
1281
1363
|
}
|
|
1282
|
-
const
|
|
1283
|
-
const
|
|
1284
|
-
const parentInfo = m.sessionInfo(self);
|
|
1364
|
+
const { target, slot: tag } = slotR;
|
|
1365
|
+
const forkOf = inherit && source !== self ? source : undefined;
|
|
1285
1366
|
// 先落身份再 spawn: 宪章是从注册表渲染出来的, 记录不在就渲染出一个无名分身。
|
|
1286
1367
|
wizards.upsert(target, {
|
|
1287
1368
|
description: (b.description ?? "").toString().trim(),
|
|
1288
1369
|
parent: self,
|
|
1289
1370
|
bornAt: Date.now(),
|
|
1290
|
-
clonedFrom: inherit ?
|
|
1371
|
+
clonedFrom: inherit ? sourceInfo?.sessionId ?? "" : "",
|
|
1372
|
+
...(forkOf ? { forkOf } : {}),
|
|
1291
1373
|
});
|
|
1292
|
-
const
|
|
1374
|
+
const name = wizards.rename(target, normalizeTag(askedName) || tag);
|
|
1375
|
+
const charter = charterFor(target, { parent: self, forkOf, inherited: inherit });
|
|
1293
1376
|
const task = (b.task ?? "").toString().trim();
|
|
1377
|
+
// 没点名就按配置的 keepalive.spawnDefault 来 —— 调用方明说的永远优先。
|
|
1378
|
+
const keepalive = typeof b.keepalive === "boolean" ? b.keepalive : cfg.wrc.mirror.keepalive.spawnDefault;
|
|
1294
1379
|
const r = await m.cloneSession({
|
|
1295
|
-
parent:
|
|
1380
|
+
parent: source,
|
|
1296
1381
|
target,
|
|
1297
|
-
windowName:
|
|
1382
|
+
windowName: name,
|
|
1298
1383
|
cli: b.cli,
|
|
1299
1384
|
model: b.model,
|
|
1300
1385
|
cwd: b.cwd,
|
|
@@ -1303,34 +1388,34 @@ const main = async () => {
|
|
|
1303
1388
|
// 继承路径上第一句话是分叉的触发器, 所以直接把活当开场白 —— 少一次往返,
|
|
1304
1389
|
// 也少一次"就位了但没事干"的空转。
|
|
1305
1390
|
bootstrap: task || undefined,
|
|
1391
|
+
keepalive,
|
|
1306
1392
|
});
|
|
1307
1393
|
if (!r.ok) {
|
|
1308
1394
|
wizards.drop(target);
|
|
1309
1395
|
json(res, 500, { ok: false, reason: r.reason });
|
|
1310
1396
|
return;
|
|
1311
1397
|
}
|
|
1312
|
-
wizards.upsert(target, { clonedFrom: r.inherited ?
|
|
1398
|
+
wizards.upsert(target, { clonedFrom: r.inherited ? sourceInfo?.sessionId ?? "" : "", ...(r.inherited ? {} : { forkOf: undefined }) });
|
|
1313
1399
|
const kid = briefOf(self, target);
|
|
1314
|
-
//
|
|
1315
|
-
//
|
|
1316
|
-
|
|
1317
|
-
|
|
1400
|
+
// r.model 是 spawnTmuxClaude 通过 /model 实测确认落地的那个 —— 可能跟调用方
|
|
1401
|
+
// 传的原始字符串不一样 (口语化 → 目录里匹配到的关键词), 播报要报实情。
|
|
1402
|
+
const modelNote = r.model
|
|
1403
|
+
? (r.modelWarning ? ` · 模型 ${r.model} (⚠️ ${r.modelWarning})` : ` · 模型 ${r.model}`)
|
|
1404
|
+
: "";
|
|
1405
|
+
// 出生不再发群气泡 (人要看的是结论, 不是谁生了谁 —— 过程在 rolepage 里);
|
|
1406
|
+
// 同群的 wizard 仍要知道群里多了一个成员。工单里的临时工连这条也省掉。
|
|
1318
1407
|
if (!jobId) {
|
|
1319
|
-
|
|
1320
|
-
postRoster(base, [target, self], `新 wizard **${kid.name || tag}** 就位 · 地址 \`${peerAddress(cfg, base, target)}\`${kid.description ? ` · ${kid.description}` : ""}${r.cwd ? ` · 工作区 ${r.cwd}` : ""}${b.model?.trim() ? ` · 模型 ${b.model.trim()}` : ""} —— ${displayName(self)} 的分身${r.inherited ? " (继承了它的上下文)" : ""}`);
|
|
1408
|
+
postRoster(base, [target, self], `新 wizard **.${name}** 就位${kid.description ? ` · ${kid.description}` : ""}${r.cwd ? ` · 工作区 ${r.cwd}` : ""}${modelNote}${keepalive ? "" : " · 已关闭 keepalive"} —— ${displayName(self)} 的分身${r.inherited ? ` (继承了${forkOf ? ` ${displayName(forkOf)} ` : "它"}的上下文)` : ""}`);
|
|
1321
1409
|
}
|
|
1322
|
-
//
|
|
1323
|
-
// 才需要在这里补一次注入。
|
|
1410
|
+
// 继承路径上活已经随开场白进去了, 空白分身才需要在这里补一次注入 (私聊)。
|
|
1324
1411
|
let dispatched = r.inherited && !!task;
|
|
1325
1412
|
if (task && !r.inherited) {
|
|
1326
|
-
const inj = await m.injectText(target, task, undefined, { from: { kind: "peer", from: self, ...(jobId ? { job: jobId } : {}) } });
|
|
1413
|
+
const inj = await m.injectText(target, task, undefined, { from: { kind: "peer", from: self, ...(jobId ? { job: jobId } : {}) }, channel: "" });
|
|
1327
1414
|
dispatched = inj.ok;
|
|
1328
1415
|
}
|
|
1329
1416
|
if (jobId)
|
|
1330
1417
|
jobs.attach(jobId, { target, task, spawned: true });
|
|
1331
|
-
|
|
1332
|
-
relayPeer(self, target, task);
|
|
1333
|
-
json(res, 200, { ok: true, target, tag, address: peerAddress(cfg, self, target), name: kid.name, inherited: r.inherited, sessionId: r.sessionId, cwd: r.cwd, dispatched, ...(jobId ? { job: jobId } : {}) });
|
|
1418
|
+
json(res, 200, { ok: true, target, name, address: name, inherited: r.inherited, sessionId: r.sessionId, cwd: r.cwd, dispatched, keepalive, ...(r.model ? { model: r.model } : {}), ...(r.modelWarning ? { modelWarning: r.modelWarning } : {}), ...(jobId ? { job: jobId } : {}) });
|
|
1334
1419
|
});
|
|
1335
1420
|
// 收掉一个 wizard。interrupt = 打断它这一轮 (Esc); end = 结束它并回收 pane。
|
|
1336
1421
|
// 自己终结自己是合法的 (分身干完活自我了结), 代价是这次工具调用不会返回 ——
|
|
@@ -1342,7 +1427,7 @@ const main = async () => {
|
|
|
1342
1427
|
return;
|
|
1343
1428
|
}
|
|
1344
1429
|
const b = body;
|
|
1345
|
-
const r = resolvePeer(self, b
|
|
1430
|
+
const r = resolvePeer(self, addrOf(b));
|
|
1346
1431
|
if (!r.ok) {
|
|
1347
1432
|
json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
|
|
1348
1433
|
return;
|
|
@@ -1390,6 +1475,9 @@ const main = async () => {
|
|
|
1390
1475
|
const idle = await waitForIdle(self, m.isBusy, 10 * 60_000, () => false);
|
|
1391
1476
|
if (!idle.idle) {
|
|
1392
1477
|
lg.warn({ reason: idle.reason }, "self-handoff: 目标一直忙, 放弃");
|
|
1478
|
+
// 之前这里只写日志: 调用方早就拿到 `scheduled:true` 挂了, 交接静悄悄
|
|
1479
|
+
// 放弃, 群里没有任何气泡告诉人。补一条 notify, 对齐它成功时的那条。
|
|
1480
|
+
notifyChat(self, withTagHeader(self, `上下文交接放弃: 一直没能闲下来 (${idle.reason}), 上下文和之前一样没动, 有空再试一次`));
|
|
1393
1481
|
return;
|
|
1394
1482
|
}
|
|
1395
1483
|
const clr = await m.injectText(self, "/clear");
|
|
@@ -1428,7 +1516,7 @@ const main = async () => {
|
|
|
1428
1516
|
job: job.id,
|
|
1429
1517
|
title,
|
|
1430
1518
|
memberMax: JOB_MEMBER_MAX,
|
|
1431
|
-
hint: "把这个 id 传给
|
|
1519
|
+
hint: "把这个 id 传给 spawn_wizard / clone_wizard / send_peer 的 `job` 参数, 它们就归到这个工单名下 (期间不再逐条出气泡); 活干完调 close_job 收尾并回收临时分身。",
|
|
1432
1520
|
});
|
|
1433
1521
|
});
|
|
1434
1522
|
http.register("POST /jobs/close", async (req, res) => {
|
|
@@ -1478,6 +1566,8 @@ const main = async () => {
|
|
|
1478
1566
|
// ── 定时任务 ────────────────────────────────────────────────────
|
|
1479
1567
|
// 到点把一句 prompt 注入一个 wizard 会话 —— 和人在群里对它说话走的是同一条路
|
|
1480
1568
|
// (injectText), 所以 pane 死了会被拉起来, 输出照常落进群和详情页。
|
|
1569
|
+
/** 日程归谁: 排班那个 wizard (createdBy); 老文件没写就归执行它的那个。 */
|
|
1570
|
+
const ownerOf = (t) => t.owner || (cfg.defaultChat ?? "");
|
|
1481
1571
|
http.register("POST /tasks/schedule", async (req, res) => {
|
|
1482
1572
|
const { self, body } = await readPeerBody(req);
|
|
1483
1573
|
if (!self) {
|
|
@@ -1491,15 +1581,15 @@ const main = async () => {
|
|
|
1491
1581
|
json(res, 400, { ok: false, reason: "prompt required" });
|
|
1492
1582
|
return;
|
|
1493
1583
|
}
|
|
1494
|
-
const
|
|
1495
|
-
if (!
|
|
1496
|
-
json(res, 400, { ok: false, reason:
|
|
1584
|
+
const trigger = parseTrigger(whenText, new Date());
|
|
1585
|
+
if (!trigger) {
|
|
1586
|
+
json(res, 400, { ok: false, reason: WHEN_HELP(whenText) });
|
|
1497
1587
|
return;
|
|
1498
1588
|
}
|
|
1499
1589
|
// tag 省略 = 排给调用者自己。这是最常见的用法: wizard 给自己定一个夜里跑的活。
|
|
1500
1590
|
// 注入自身在**当下**是死锁 (往正在生成的输入框里打字), 但定时是未来的事,
|
|
1501
1591
|
// 那时这一轮早已收工, 所以这里不套 /peers/send 的自我保护。
|
|
1502
|
-
const tag = (body
|
|
1592
|
+
const tag = addrOf(body).trim();
|
|
1503
1593
|
let target = self;
|
|
1504
1594
|
if (tag) {
|
|
1505
1595
|
const r = resolvePeer(self, tag);
|
|
@@ -1509,8 +1599,26 @@ const main = async () => {
|
|
|
1509
1599
|
}
|
|
1510
1600
|
target = r.target;
|
|
1511
1601
|
}
|
|
1512
|
-
|
|
1513
|
-
|
|
1602
|
+
// 到点是新建还是续用, 在**排班时**就定死。两条都指向新建:
|
|
1603
|
+
// · 没点名 wizard —— 定时的 prompt 本就要求零上下文自洽, 没理由挑一个会话挤进去;
|
|
1604
|
+
// · prompt 自己就在说「建两个 wizard 去干 xxx」—— 这时 tag 只是「在谁的聊天/
|
|
1605
|
+
// 目录下办」, 不是「在它的上下文里续」。
|
|
1606
|
+
// `fresh` 显式传入压过这两条推断 (要强行在 tag 那一轮里续就传 false)。
|
|
1607
|
+
const fresh = typeof b.fresh === "boolean" ? b.fresh : !tag || promptWantsFreshWizard(prompt);
|
|
1608
|
+
const note = (b.note ?? "").toString();
|
|
1609
|
+
const id = uniqueId(slugify((b.id ?? "").toString() || note || prompt.slice(0, 24), "task"), tasks.takenIds());
|
|
1610
|
+
const created = tasks.create({ id, trigger, prompt, target, fresh, note, createdBy: self });
|
|
1611
|
+
if ("error" in created) {
|
|
1612
|
+
json(res, 400, { ok: false, reason: created.error });
|
|
1613
|
+
return;
|
|
1614
|
+
}
|
|
1615
|
+
tasks.patchState(id, { createdAt: Date.now() });
|
|
1616
|
+
json(res, 200, {
|
|
1617
|
+
ok: true,
|
|
1618
|
+
...renderTask(created, tasks.stateOf(id)),
|
|
1619
|
+
address: peerAddress(cfg, self, target),
|
|
1620
|
+
owner: peerAddress(cfg, self, self),
|
|
1621
|
+
});
|
|
1514
1622
|
});
|
|
1515
1623
|
http.register("POST /tasks/list", async (req, res) => {
|
|
1516
1624
|
const { self, body } = await readPeerBody(req);
|
|
@@ -1520,9 +1628,20 @@ const main = async () => {
|
|
|
1520
1628
|
}
|
|
1521
1629
|
const mineOnly = body.mine === true;
|
|
1522
1630
|
const now = new Date();
|
|
1523
|
-
const
|
|
1524
|
-
|
|
1525
|
-
|
|
1631
|
+
const errs = tasks.errors();
|
|
1632
|
+
const rows = tasks.list()
|
|
1633
|
+
.filter((t) => !mineOnly || ownerOf(t) === self)
|
|
1634
|
+
.map((t) => ({
|
|
1635
|
+
...renderTask(t, tasks.stateOf(t.id), now, errs[t.id]),
|
|
1636
|
+
address: peerAddress(cfg, self, t.target || (cfg.defaultChat ?? "")),
|
|
1637
|
+
owner: peerAddress(cfg, self, ownerOf(t)),
|
|
1638
|
+
}));
|
|
1639
|
+
// 加载失败的那些没有记录可回显, 但必须出现在列表里 —— 否则 wizard 改错了
|
|
1640
|
+
// 一个字, 那条定时就像凭空消失了。
|
|
1641
|
+
const broken = Object.entries(errs)
|
|
1642
|
+
.filter(([id]) => !tasks.get(id))
|
|
1643
|
+
.map(([id, loadError]) => ({ id, file: `${tasks.dir}/${id}.task.mjs`, loadError }));
|
|
1644
|
+
json(res, 200, { ok: true, self, dir: tasks.dir, tasks: [...rows, ...broken] });
|
|
1526
1645
|
});
|
|
1527
1646
|
http.register("POST /tasks/cancel", async (req, res) => {
|
|
1528
1647
|
const { self, body } = await readPeerBody(req);
|
|
@@ -1535,12 +1654,13 @@ const main = async () => {
|
|
|
1535
1654
|
json(res, 400, { ok: false, reason: "id required (list_tasks 里那个 id)" });
|
|
1536
1655
|
return;
|
|
1537
1656
|
}
|
|
1538
|
-
const
|
|
1657
|
+
const state = tasks.stateOf(id);
|
|
1658
|
+
const gone = tasks.remove(id);
|
|
1539
1659
|
if (!gone) {
|
|
1540
1660
|
json(res, 404, { ok: false, reason: `no schedule with id ${id}` });
|
|
1541
1661
|
return;
|
|
1542
1662
|
}
|
|
1543
|
-
json(res, 200, { ok: true, removed:
|
|
1663
|
+
json(res, 200, { ok: true, removed: renderTask(gone, state) });
|
|
1544
1664
|
});
|
|
1545
1665
|
// POST /config/set — modify daemon config from MCP
|
|
1546
1666
|
http.register("POST /config/set", async (req, res) => {
|
|
@@ -1643,22 +1763,103 @@ const main = async () => {
|
|
|
1643
1763
|
}
|
|
1644
1764
|
json(res, 200, { ok: true, target, ...m.getCwd(target) });
|
|
1645
1765
|
});
|
|
1766
|
+
// 定时任务的执行体。schedule_task 的 prompt 设计上本就要求"零上下文也能执行"
|
|
1767
|
+
// (到点时目标可能早已 /clear 过, 它只看得见这一句) —— 既然如此, 默认就不在任何
|
|
1768
|
+
// 已有会话里跑: 硬塞进去会把两件不相关的事挤进同一个 transcript, 目标正忙时
|
|
1769
|
+
// 还要连触发时间点一起被它那一轮拖走。所以到点起一个白板 wizard 单独执行,
|
|
1770
|
+
// 跑完自动收掉。只有排班时点名了某个 wizard (fresh=false) 且它此刻闲着,
|
|
1771
|
+
// 才把这句话直接投进它那一轮 —— 「在已有会话里继续」是要求出来的, 不是默认。
|
|
1772
|
+
scheduledTaskInject = async (target, text, { taskId, fresh }) => {
|
|
1773
|
+
const busy = fresh ? false : await m.isBusy(target);
|
|
1774
|
+
if (!fresh && !busy)
|
|
1775
|
+
return m.injectText(target, text, undefined, { fromChat: true, from: { kind: "task", taskId } });
|
|
1776
|
+
// 执行体挂在日程的主人名下: 名字 `<主人>-task-xxxx`, parent = 主人 —— 它的
|
|
1777
|
+
// rolepage 里看得见这一枪是谁的日程放的。落在执行目标的群/目录里。
|
|
1778
|
+
const base = baseOfKey(target);
|
|
1779
|
+
const rec = tasks.get(taskId);
|
|
1780
|
+
const owner = rec ? ownerOf(rec) : target;
|
|
1781
|
+
const ownerName = settleName(wizards, chatNameOf(cfg, owner), owner) || "task";
|
|
1782
|
+
const slotR = await claimSlot(base, "", `${ownerName}-task-${taskId.slice(0, 4)}`);
|
|
1783
|
+
if (!slotR.ok)
|
|
1784
|
+
return { ok: false, reason: "起白板 wizard 失败: 挑不出槽位" };
|
|
1785
|
+
const runner = slotR.target;
|
|
1786
|
+
const info = m.sessionInfo(target);
|
|
1787
|
+
const why = busy ? ` (起因: ${displayName(target)} 当时正忙)` : "";
|
|
1788
|
+
wizards.upsert(runner, {
|
|
1789
|
+
description: `定时任务 ${taskId} 的一次性执行体${why}`,
|
|
1790
|
+
parent: owner,
|
|
1791
|
+
bornAt: Date.now(),
|
|
1792
|
+
});
|
|
1793
|
+
const runnerName = wizards.rename(runner, `${ownerName}-task-${taskId.slice(0, 4)}`);
|
|
1794
|
+
const spawned = await m.newSession(runner, runnerName, info?.cli, { cwd: info?.cwd, model: info?.model, silent: true });
|
|
1795
|
+
if (!spawned.ok) {
|
|
1796
|
+
wizards.drop(runner);
|
|
1797
|
+
return { ok: false, reason: `起白板 wizard 失败: ${spawned.reason ?? "unknown"}` };
|
|
1798
|
+
}
|
|
1799
|
+
notifyChat(base, withTagHeader(target, busy
|
|
1800
|
+
? `⏰ 定时任务到点时正忙, 已起白板 wizard .${runnerName} 单独执行, 完成后自动收掉`
|
|
1801
|
+
: `⏰ 定时任务已起白板 wizard .${runnerName} 执行, 完成后自动收掉`));
|
|
1802
|
+
const inj = await m.injectText(runner, text, undefined, { fromChat: true, from: { kind: "task", taskId } });
|
|
1803
|
+
if (!inj.ok) {
|
|
1804
|
+
wizards.drop(runner);
|
|
1805
|
+
return inj;
|
|
1806
|
+
}
|
|
1807
|
+
// 没有人会对这个一次性分身喊 stop_wizard, 只能自己等它闲下来再收。30min
|
|
1808
|
+
// 内没闲下来就放弃自动回收 (它大概率还在干一个长活), 留给人手动处理 ——
|
|
1809
|
+
// 比杀掉一个还在跑的 pane 安全。
|
|
1810
|
+
void (async () => {
|
|
1811
|
+
const idle = await waitForIdle(runner, m.isBusy, 30 * 60_000, () => false);
|
|
1812
|
+
if (!idle.idle) {
|
|
1813
|
+
log.warn({ runner, reason: idle.reason }, "task runner: 30min 未闲下来, 放弃自动回收");
|
|
1814
|
+
return;
|
|
1815
|
+
}
|
|
1816
|
+
await m.killPane(runner);
|
|
1817
|
+
wizards.drop(runner);
|
|
1818
|
+
})();
|
|
1819
|
+
return inj;
|
|
1820
|
+
};
|
|
1646
1821
|
}
|
|
1647
|
-
// 定时调度器 — 每 20s
|
|
1648
|
-
|
|
1822
|
+
// 定时调度器 — 每 20s 检查任务表, 到点把 prompt 注入目标 wizard。
|
|
1823
|
+
migrateLegacySchedules(cfg, sourcePath, tasks, log.child({ mod: "tasks" }));
|
|
1649
1824
|
const scheduler = startScheduler({
|
|
1650
1825
|
client: ws.client,
|
|
1651
|
-
|
|
1652
|
-
sourcePath,
|
|
1826
|
+
registry: tasks,
|
|
1653
1827
|
log: log.child({ mod: "tasks" }),
|
|
1654
|
-
inject:
|
|
1655
|
-
|
|
1656
|
-
|
|
1828
|
+
inject: scheduledTaskInject,
|
|
1829
|
+
// gate 里的 `sh` 默认就在目标 wizard 此刻的工作区跑 —— 任务文件里不必写死绝对路径。
|
|
1830
|
+
cwdOf: (t) => { const c = bridge.getCwd(t); return c.runningCwd || c.pendingCwd || c.defaultCwd; },
|
|
1831
|
+
fallbackTarget: () => (cfg.defaultChat ?? "").trim(),
|
|
1657
1832
|
});
|
|
1833
|
+
// 闲置 pane 收割机 — 默认 48h 静默才动 (wrc.mirror.idleReapHours, 0=关)。
|
|
1834
|
+
// 只收 pane 不收绑定: 下一条消息 `--resume` 复活。豁免: 名下有定时任务的
|
|
1835
|
+
// wizard (调度本身会唤醒/需要它), 挂着审批长轮询的会话 (hook 还停在等点击),
|
|
1836
|
+
// 忙着的 / 人正盯着的 (bridge 内部判)。keepalive 会话靠 ping 刷新 transcript,
|
|
1837
|
+
// 天然到不了阈值。
|
|
1838
|
+
let reapTimer;
|
|
1839
|
+
if (cfg.wrc.mirror.idleReapHours > 0) {
|
|
1840
|
+
const ttlMs = cfg.wrc.mirror.idleReapHours * 3.6e6;
|
|
1841
|
+
const sweep = async () => {
|
|
1842
|
+
const taskOwners = new Set(tasks.list()
|
|
1843
|
+
.filter((t) => t.enabled)
|
|
1844
|
+
.map((t) => wizardStore()?.byName(t.owner)?.target)
|
|
1845
|
+
.filter((t) => !!t));
|
|
1846
|
+
const pendingSids = new Set(listPending().map((p) => p.meta.sessionId).filter((s) => !!s));
|
|
1847
|
+
const r = await bridge.reapIdle(ttlMs, (target, sid) => taskOwners.has(target) ? "owns scheduled task"
|
|
1848
|
+
: pendingSids.has(sid) ? "pending approval"
|
|
1849
|
+
: undefined);
|
|
1850
|
+
if (r.reaped.length)
|
|
1851
|
+
log.info({ reaped: r.reaped.length, targets: r.reaped }, "idle reap sweep");
|
|
1852
|
+
};
|
|
1853
|
+
reapTimer = setInterval(() => void sweep().catch((e) => log.warn({ err: e.message }, "idle reap sweep failed")), 30 * 60_000);
|
|
1854
|
+
reapTimer.unref();
|
|
1855
|
+
}
|
|
1658
1856
|
const shutdown = async (signal) => {
|
|
1659
1857
|
log.info({ signal }, "shutdown signal");
|
|
1660
1858
|
scheduler.stop();
|
|
1859
|
+
tasks.stop();
|
|
1661
1860
|
netWatch.stop();
|
|
1861
|
+
if (reapTimer)
|
|
1862
|
+
clearInterval(reapTimer);
|
|
1662
1863
|
// 同 POST /shutdown: 先把挂着的审批长轮询了结成「稍后续接」, 再关连接。
|
|
1663
1864
|
log.info(drainForReload(), "pending drained for reload");
|
|
1664
1865
|
// Hard-exit watchdog: http.close() blocks until every in-flight connection
|