wezard 2.0.0 → 2.0.2
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 +7 -8
- package/README.md +20 -32
- 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 +2 -4
- 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/detail.js +2 -3
- package/dist/daemon/detail.js.map +1 -1
- package/dist/daemon/inbound.js +90 -110
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +130 -111
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/memory-steward.js +82 -0
- package/dist/daemon/memory-steward.js.map +1 -0
- package/dist/daemon/mirror-bridge.js +201 -62
- package/dist/daemon/mirror-bridge.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 +35 -13
- package/dist/daemon/spawn-tmux.js.map +1 -1
- package/dist/daemon/wizard-memory.js +21 -26
- package/dist/daemon/wizard-memory.js.map +1 -1
- package/dist/daemon/wizard.js +54 -11
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/mcp/server.js +63 -153
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/chat-http.js +44 -16
- package/dist/shared/chat-http.js.map +1 -1
- package/dist/shared/chat-render.js +10 -8
- package/dist/shared/chat-render.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 +10 -12
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/detail-render.js +35 -16
- package/dist/shared/detail-render.js.map +1 -1
- package/dist/shared/detail-store.js +14 -3
- package/dist/shared/detail-store.js.map +1 -1
- package/dist/shared/role-render.js +4 -1
- package/dist/shared/role-render.js.map +1 -1
- package/dist/shared/role-view.js +49 -11
- package/dist/shared/role-view.js.map +1 -1
- package/dist/shared/world.js +11 -1
- package/dist/shared/world.js.map +1 -1
- package/hooks/pre-tool-use.sh +4 -4
- package/package.json +1 -2
- package/web/chat.css +103 -58
- package/web/chat.js +153 -82
- 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,9 +7,7 @@ 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";
|
|
@@ -28,9 +26,10 @@ import { openTaskRegistry } from "./task-registry.js";
|
|
|
28
26
|
import { describeTrigger, nextFire, parseTrigger, WHEN_HELP } from "../shared/trigger.js";
|
|
29
27
|
import { slugify, uniqueId } from "../shared/task-file.js";
|
|
30
28
|
import { baseOfKey, bindTagLinker, keyOf, linkTags, normalizeTag, tagFromCwd, tagHead, tagLink, tagOfKey, uniqueTag, withTagHeader } from "../shared/session-label.js";
|
|
31
|
-
import {
|
|
29
|
+
import { clipForCharter, inboxPath, memoryPath, memoryRoot, proposeMemory, readMemory } from "./wizard-memory.js";
|
|
30
|
+
import { ensureStewardTask } from "./memory-steward.js";
|
|
32
31
|
import { applyChatNames, chatBaseOf, chatNameOf, clearChatName, listChatNames, normChatName, peerAddress, planChatNames, setChatName } from "./chat-name.js";
|
|
33
|
-
import { bindWizardStore, loadWizardStore, wizardStore, settleName, settleAll, childrenOf, ancestorsOf, renderCharter, } from "./wizard.js";
|
|
32
|
+
import { bindWizardStore, loadWizardStore, wizardStore, settleName, settleAll, evictStaleName, childrenOf, ancestorsOf, renderCharter, } from "./wizard.js";
|
|
34
33
|
import { bindNoticeBox, createNoticeBox, chatAudience } from "./notices.js";
|
|
35
34
|
import { loadJobStore, renderJobOpen, renderJobClose, JOB_MEMBER_MAX } from "./jobs.js";
|
|
36
35
|
import { extractResult } from "./peers.js";
|
|
@@ -80,23 +79,22 @@ const main = async () => {
|
|
|
80
79
|
// last-response tracker enables inbound's `quote` dedup of bot self-replies,
|
|
81
80
|
// and its chat-gate drops header-only pushes (empty messages) daemon-wide.
|
|
82
81
|
installResponseTracker(ws.client, log.child({ mod: "chat-gate" }));
|
|
83
|
-
const sessions = loadSessionStore(cfg.wrc.sessionMapFile);
|
|
84
82
|
const mirrorStore = loadMirrorStore(cfg.wrc.mirror.attachmentsFile);
|
|
85
83
|
// 定时任务表 —— 每条任务是 ~/.wezard/tasks/<id>.task.mjs 一份可注入代码的配置
|
|
86
84
|
// (见 shared/task-file.ts)。目录是热加载的: wizard 改完文件不用 reload 守护进程。
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
: makeBridge({ cfg, log: log.child({ mod: "bridge" }), client: ws.client, sessions });
|
|
91
|
-
if (!bridge) {
|
|
92
|
-
log.fatal("bridge failed to start");
|
|
93
|
-
fatalExit("bridge failed to start");
|
|
85
|
+
// 共享记忆的整理者是一条定时任务 (memory-steward.ts), 缺了就补一份默认的。
|
|
86
|
+
try {
|
|
87
|
+
ensureStewardTask(memoryRoot(cfg.daemon.stateDir));
|
|
94
88
|
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
log.warn({ err: e.message }, "memory steward task not written");
|
|
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 });
|
|
95
94
|
// 群里每个 `.name` 都挂它的 rolepage (见 session-label 的 TagLinker)。正文提及
|
|
96
|
-
// 只认真有其人的 —— 随手写的 `.gitignore`
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
// 名册在 mirror 块里才绑 (bindWizardStore) —— 这里全走 wizardStore() 惰性取。
|
|
95
|
+
// 只认真有其人的 —— 随手写的 `.gitignore` 不该变成一个开在空栏上的链接。
|
|
96
|
+
const known = (t) => bridge.chatTargets(t).includes(t);
|
|
97
|
+
// 名册在下面的 mirror 块里才绑 (bindWizardStore) —— 这里全走 wizardStore() 惰性取。
|
|
100
98
|
bindTagLinker({
|
|
101
99
|
urlOf: (t) => chatUrlFor(cfg.daemon, t, baseOfKey(t).replace(/^(user|chat|group):/, "")),
|
|
102
100
|
resolve: (name) => {
|
|
@@ -109,50 +107,29 @@ const main = async () => {
|
|
|
109
107
|
// approval click → finalize 当前 liveStream, 后续 tool/text 落到 standalone。
|
|
110
108
|
// 规则 2: 用户点击授权那一刻就是"上一段对话"的边界, 截断 stream 让授权后的
|
|
111
109
|
// 工作单独成块, 比让 stream 一直长到下一个 inbound / hardTimer 更清晰。
|
|
112
|
-
|
|
113
|
-
const onApproved = cfg.wrc.mode === "mirror"
|
|
114
|
-
? (sid) => bridge.terminateLiveStream(sid)
|
|
115
|
-
: undefined;
|
|
110
|
+
const onApproved = (sid) => bridge.terminateLiveStream(sid);
|
|
116
111
|
installApprovalEventListener(ws.client, log.child({ mod: "approval" }), cfg, onApproved);
|
|
117
112
|
// Route approval cards to the WeCom chat bound to the requesting session.
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
const getMirrorTarget = cfg.wrc.mode === "mirror"
|
|
122
|
-
? (sid) => bridge.targetForSession(sid)
|
|
123
|
-
: (sid) => {
|
|
124
|
-
for (const [principal, s] of Object.entries(sessions.all())) {
|
|
125
|
-
if (s === sid)
|
|
126
|
-
return principal;
|
|
127
|
-
}
|
|
128
|
-
return undefined;
|
|
129
|
-
};
|
|
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);
|
|
130
116
|
// Pre-card barrier: drain pending mirror text/tool markdown for this session
|
|
131
117
|
// and await its FIFO so vote/approval cards never overtake the "thinking" bubble.
|
|
132
|
-
|
|
133
|
-
const flushBeforeCard = cfg.wrc.mode === "mirror"
|
|
134
|
-
? (sid, expect) => bridge.flushBeforeCard(sid, expect)
|
|
135
|
-
: undefined;
|
|
118
|
+
const flushBeforeCard = (sid, expect) => bridge.flushBeforeCard(sid, expect);
|
|
136
119
|
// `.claude/**` 写守卫要用的四个 pane 原语。cancel/tell 复用现成的 target 级方法
|
|
137
120
|
// (先 sessionId → target 再调), 只有 hasPane / answerNativeModal 是 pane 级新增。
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const t = b.targetForSession(sid);
|
|
151
|
-
return t ? await b.injectText(t, text) : { ok: false, reason: "no mirror target for session" };
|
|
152
|
-
},
|
|
153
|
-
};
|
|
154
|
-
})()
|
|
155
|
-
: 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
|
+
};
|
|
156
133
|
const http = startHttp({ cfg, ws, log, sourcePath });
|
|
157
134
|
http.register("POST /approve", makeApproveHandler({ cfg, log: log.child({ mod: "approval" }), client: ws.client, sourcePath, getMirrorTarget, flushBeforeCard, nativeModal }));
|
|
158
135
|
http.register("POST /message", makeMessageHandler(ws.client, log.child({ mod: "outbound" })));
|
|
@@ -231,20 +208,11 @@ const main = async () => {
|
|
|
231
208
|
});
|
|
232
209
|
wedocLog.info("wedoc bridge ready");
|
|
233
210
|
}
|
|
234
|
-
// Mirror-mode:
|
|
235
|
-
|
|
211
|
+
// Mirror-mode: status + the spawn/switch routes the chat side drives.
|
|
212
|
+
{
|
|
236
213
|
const m = bridge;
|
|
237
214
|
installMirrorEventListener(ws.client, m, log.child({ mod: "mirror" }));
|
|
238
215
|
http.register("GET /mirror/status", (_req, res) => json(res, 200, m.status()));
|
|
239
|
-
http.register("POST /mirror/attach", async (req, res) => {
|
|
240
|
-
const body = (await readBody(req));
|
|
241
|
-
if (!body.sessionId || !body.jsonlPath) {
|
|
242
|
-
json(res, 400, { ok: false, reason: "sessionId and jsonlPath required" });
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
const r = m.attach({ sessionId: body.sessionId, jsonlPath: body.jsonlPath, target: body.target, tmuxPane: body.tmuxPane, tmuxSession: body.tmuxSession });
|
|
246
|
-
json(res, r.ok ? 200 : 400, r);
|
|
247
|
-
});
|
|
248
216
|
// Manual auto-spawn trigger — used by `wezard init` to materialize a
|
|
249
217
|
// tmux+claude pane immediately after claim, instead of waiting for the
|
|
250
218
|
// first inbound. Body: { target?: "user:xxx" | "chat:xxx" }. Falls back
|
|
@@ -375,7 +343,7 @@ const main = async () => {
|
|
|
375
343
|
return;
|
|
376
344
|
}
|
|
377
345
|
const foreign = base !== baseOfKey(self);
|
|
378
|
-
const slotR = await claimSlot(base, String(body.name ?? body.tag ?? ""), tagFromCwd(cwd) || "peer");
|
|
346
|
+
const slotR = await claimSlot(base, String(body.name ?? body.tag ?? ""), tagFromCwd(cwd) || "peer", [self]);
|
|
379
347
|
if (!slotR.ok) {
|
|
380
348
|
json(res, slotR.status, slotR.body);
|
|
381
349
|
return;
|
|
@@ -609,20 +577,31 @@ const main = async () => {
|
|
|
609
577
|
const channelOf = (self) => m.currentChannel(self) || baseOfKey(self);
|
|
610
578
|
/** 入参里的地址: 新字段 `name`, 老 MCP 进程 (正在跑的 wizard) 仍在传 `tag`。 */
|
|
611
579
|
const addrOf = (b) => String(b.name ?? b.tag ?? "");
|
|
580
|
+
/** 撞上的名字属于一个静默超过一天的 wizard → 顶掉它 (见 evictStaleName), 同群的
|
|
581
|
+
* wizard 收到一行名册变动; 返回是否腾出了名字。 */
|
|
582
|
+
const displace = async (name, keep) => {
|
|
583
|
+
const gone = await evictStaleName(wizards, name, keep, { lastActivity: m.lastActivity, retire: m.killPane });
|
|
584
|
+
if (!gone)
|
|
585
|
+
return false;
|
|
586
|
+
const slot = !!tagOfKey(gone.target);
|
|
587
|
+
log.child({ mod: "wizard" }).info({ target: gone.target, name: gone.name, slot }, "stale name evicted");
|
|
588
|
+
postRoster(baseOfKey(gone.target), [], `**.${gone.name}** 静默超过一天, ${slot ? "已被同名的新 wizard 顶掉并收工" : "名字让给了同名的新 wizard"}`);
|
|
589
|
+
return true;
|
|
590
|
+
};
|
|
612
591
|
/** 给一个要出生的 wizard 挑槽位 (target key) 并落定名字。名字全局唯一:
|
|
613
|
-
* - 撞上的是一个还绑着会话的 wizard → 409, 附上它的死活, 调用方据此换名或先收掉它;
|
|
614
592
|
* - 撞上的只是目标群里同一个槽位的冷记录 (会话早没了) → 复用这个槽, 即原名重生,
|
|
615
593
|
* 与从前「同一个 tag 重开」一样, 家谱/记忆都还在;
|
|
616
|
-
* -
|
|
594
|
+
* - 撞上的 wizard 静默超过一天 (不在 `keep` 里) → 顶掉它, 名字归新的;
|
|
595
|
+
* - 其余 (还新鲜的) → 409, 附上它的死活, 调用方据此换名或先收掉它。
|
|
617
596
|
* 槽位 (key 里的 `#k`) 只是内部 id: 默认取名字本身, 被占了就加序号, 与名字无关。 */
|
|
618
|
-
const claimSlot = async (base, want, fallback) => {
|
|
597
|
+
const claimSlot = async (base, want, fallback, keep = []) => {
|
|
619
598
|
const asked = normalizeTag(want);
|
|
620
599
|
const clash = asked ? wizards.byName(asked) : undefined;
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
600
|
+
const bound = !!clash && m.chatTargets(baseOfKey(clash.target)).includes(clash.target);
|
|
601
|
+
if (clash && !bound && baseOfKey(clash.target) === base && tagOfKey(clash.target)) {
|
|
602
|
+
return { ok: true, target: clash.target, slot: tagOfKey(clash.target) };
|
|
603
|
+
}
|
|
604
|
+
if (clash && !(await displace(asked, keep))) {
|
|
626
605
|
const info = bound ? (await m.peers(clash.target)).find((p) => p.target === clash.target) : undefined;
|
|
627
606
|
const alive = info?.paneAlive ?? false;
|
|
628
607
|
const idleForMs = info?.lastActivity ? Date.now() - info.lastActivity : undefined;
|
|
@@ -631,7 +610,7 @@ const main = async () => {
|
|
|
631
610
|
? "它的 pane 还活着 —— 除非这就是同一件事的延续, 否则换个名字重新生, 别把不相关的活塞给一个已经有职责的 wizard"
|
|
632
611
|
: bound
|
|
633
612
|
? `pane 已经不在了 (${idleDesc}), 真要复用这个名字就先 stop_wizard({name:"${clash.name}", mode:"end"}) 收掉, 再用同一个名字重新生 —— 拿到干净的上下文; 别直接 send_peer 唤醒它接手, 它会带着上一件事的记忆答你这件`
|
|
634
|
-
: "
|
|
613
|
+
: "这个名字被一个一天内还活动过、眼下没有会话的 wizard 占着 —— 换个名字 (它静默满一天后名字会自动让出)";
|
|
635
614
|
return {
|
|
636
615
|
ok: false,
|
|
637
616
|
status: 409,
|
|
@@ -1009,6 +988,7 @@ const main = async () => {
|
|
|
1009
988
|
alive: p.paneAlive,
|
|
1010
989
|
parent: w?.parent,
|
|
1011
990
|
clonedFrom: w?.clonedFrom,
|
|
991
|
+
forkOf: w?.forkOf,
|
|
1012
992
|
bornAt: w?.bornAt,
|
|
1013
993
|
lastActivity: p.lastActivity,
|
|
1014
994
|
summary: p.summary,
|
|
@@ -1027,6 +1007,7 @@ const main = async () => {
|
|
|
1027
1007
|
busy: false, alive: false,
|
|
1028
1008
|
parent: w.parent,
|
|
1029
1009
|
clonedFrom: w.clonedFrom,
|
|
1010
|
+
forkOf: w.forkOf,
|
|
1030
1011
|
bornAt: w.bornAt,
|
|
1031
1012
|
lastActivity: 0,
|
|
1032
1013
|
summary: "(未运行)",
|
|
@@ -1074,6 +1055,10 @@ const main = async () => {
|
|
|
1074
1055
|
});
|
|
1075
1056
|
/** 自己的地址 = 全局名字, 与住在哪个群无关。 */
|
|
1076
1057
|
const selfAddress = (target) => settleName(wizards, chatNameOf(cfg, target), target);
|
|
1058
|
+
const sharedMemory = (scope, key) => {
|
|
1059
|
+
const path = memoryPath(cfg.daemon.stateDir, scope, key);
|
|
1060
|
+
return clipForCharter(readMemory(path), path);
|
|
1061
|
+
};
|
|
1077
1062
|
/** 开局宪章。出生时的兄弟只是快照 —— 名册随时可查, 写进系统提示的那份只为了
|
|
1078
1063
|
* 让它一睁眼就知道自己不是一个人在跑。 */
|
|
1079
1064
|
const charterFor = (target, o) => renderCharter({
|
|
@@ -1083,14 +1068,16 @@ const main = async () => {
|
|
|
1083
1068
|
principal: baseOfKey(target),
|
|
1084
1069
|
parent: o.parent ? briefOf(target, o.parent) : undefined,
|
|
1085
1070
|
inherited: !!o.inherited,
|
|
1071
|
+
forkOf: o.forkOf ? briefOf(target, o.forkOf) : undefined,
|
|
1086
1072
|
cwdUnconfirmed: m.cwdUnconfirmed(target, o.cwd),
|
|
1087
1073
|
siblings: m.chatTargets(baseOfKey(target)).filter((t) => t !== target).map((t) => briefOf(target, t)),
|
|
1088
1074
|
memory: wizards.get(target)?.memory ?? [],
|
|
1089
|
-
chatMemory:
|
|
1075
|
+
chatMemory: sharedMemory("chat", baseOfKey(target)),
|
|
1090
1076
|
workspaceMemory: (() => {
|
|
1091
1077
|
const cwd = o.cwd || m.getCwd(target).runningCwd || m.getCwd(target).defaultCwd;
|
|
1092
|
-
return cwd ?
|
|
1078
|
+
return cwd ? sharedMemory("workspace", cwd) : "";
|
|
1093
1079
|
})(),
|
|
1080
|
+
steward: !tagOfKey(target),
|
|
1094
1081
|
});
|
|
1095
1082
|
// 所有 spawn 路径 (群里手打 /new、pane 自愈重生、编排出来的分身) 都从这里
|
|
1096
1083
|
// 取身份, 于是「是谁」不再取决于是哪段代码把它生出来的。
|
|
@@ -1100,7 +1087,7 @@ const main = async () => {
|
|
|
1100
1087
|
// 「(未命名)」的地方。所以补名要发生在渲染之前, 不能等它以后自己去查。
|
|
1101
1088
|
ensureChatNames(target);
|
|
1102
1089
|
const rec = wizards.get(target);
|
|
1103
|
-
return charterFor(target, { parent: rec?.parent, inherited: !!rec?.clonedFrom, cwd: ctx?.cwd });
|
|
1090
|
+
return charterFor(target, { parent: rec?.parent, forkOf: rec?.forkOf, inherited: !!rec?.clonedFrom, cwd: ctx?.cwd });
|
|
1104
1091
|
});
|
|
1105
1092
|
// 开机也补一次: IM 侧的 `/peers`、`/help` 直接读名字, 不经过任何 MCP 路由。
|
|
1106
1093
|
// 延后是因为附着表要先从盘上恢复完, 否则这一刻还看不见那些聊天; 补不全也无妨,
|
|
@@ -1132,6 +1119,7 @@ const main = async () => {
|
|
|
1132
1119
|
named: !!(rec?.name ?? "").trim(),
|
|
1133
1120
|
bornAt: rec?.bornAt,
|
|
1134
1121
|
inheritedFrom: rec?.clonedFrom || "",
|
|
1122
|
+
...(rec?.forkOf ? { forkOf: briefOf(self, rec.forkOf) } : {}),
|
|
1135
1123
|
memory: rec?.memory ?? [],
|
|
1136
1124
|
...kinOf(self, wizards.all(), target),
|
|
1137
1125
|
sessionId: info?.sessionId ?? "",
|
|
@@ -1261,8 +1249,9 @@ const main = async () => {
|
|
|
1261
1249
|
: {}),
|
|
1262
1250
|
});
|
|
1263
1251
|
});
|
|
1264
|
-
// 记忆三种作用域: self 跟着自己 (wizards.json)
|
|
1265
|
-
// 每个在那个群 / 那个目录出生的 wizard 都会读到
|
|
1252
|
+
// 记忆三种作用域: self 跟着自己 (wizards.json), 直写; chat / workspace 是共享的 md,
|
|
1253
|
+
// 每个在那个群 / 那个目录出生的 wizard 都会读到 —— 它们只收提议, 由整理者合并
|
|
1254
|
+
// (见 wizard-memory.ts / memory-steward.ts)。
|
|
1266
1255
|
http.register("POST /wizard/remember", async (req, res) => {
|
|
1267
1256
|
const { self, body } = await readPeerBody(req);
|
|
1268
1257
|
if (!self) {
|
|
@@ -1279,10 +1268,25 @@ const main = async () => {
|
|
|
1279
1268
|
json(res, 400, { ok: false, reason: "这个 wizard 没有工作区, 记不了 workspace 记忆" });
|
|
1280
1269
|
return;
|
|
1281
1270
|
}
|
|
1271
|
+
if (!note && !forget) {
|
|
1272
|
+
json(res, 400, { ok: false, reason: "note 和 forget 至少给一个" });
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1282
1275
|
const path = memoryPath(cfg.daemon.stateDir, scope, key);
|
|
1283
|
-
const
|
|
1284
|
-
|
|
1285
|
-
|
|
1276
|
+
const queued = proposeMemory(inboxPath(cfg.daemon.stateDir, scope, key), {
|
|
1277
|
+
at: Date.now(),
|
|
1278
|
+
by: selfAddress(self),
|
|
1279
|
+
...(note ? { note } : {}),
|
|
1280
|
+
...(forget ? { forget } : {}),
|
|
1281
|
+
});
|
|
1282
|
+
if (!queued) {
|
|
1283
|
+
json(res, 500, { ok: false, reason: "提议没写进收件箱" });
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
json(res, 200, {
|
|
1287
|
+
ok: true, scope, queued, file: path, memory: readMemory(path),
|
|
1288
|
+
hint: "已提交给记忆整理者, 下一轮整理 (半小时内) 合并进上面这份记忆; 此后出生的 wizard 才读得到",
|
|
1289
|
+
});
|
|
1286
1290
|
return;
|
|
1287
1291
|
}
|
|
1288
1292
|
const cur = wizards.get(self)?.memory ?? [];
|
|
@@ -1317,8 +1321,29 @@ const main = async () => {
|
|
|
1317
1321
|
json(res, 400, { ok: false, reason: "inherit 必填: true = fork 你此刻的上下文 (它开局就有你读过的材料, 必须留在同一个 cwd); false = 空白分身, 只继承身份" });
|
|
1318
1322
|
return;
|
|
1319
1323
|
}
|
|
1320
|
-
const alivePeers = await m.peers(self);
|
|
1321
1324
|
const inherit = b.inherit;
|
|
1325
|
+
// 克隆的源头: 默认是调用方自己; `from` 点名就 fork 那个 wizard 此刻的上下文。
|
|
1326
|
+
// 分身仍归调用方管 (parent = self: 占它的预算、随它的工单回收), 只有上下文来自别处。
|
|
1327
|
+
// 空串不能交给 resolvePeer —— 那是「本聊天的默认 wizard」, 不是「我自己」。
|
|
1328
|
+
const fromAddr = (b.from ?? "").toString().trim();
|
|
1329
|
+
if (fromAddr && !inherit) {
|
|
1330
|
+
json(res, 400, { ok: false, reason: "from 只对克隆有意义 —— 白板生出来的 wizard 不带任何人的上下文" });
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
const fromR = fromAddr ? resolvePeer(self, fromAddr) : { ok: true, target: self };
|
|
1334
|
+
if (!fromR.ok) {
|
|
1335
|
+
json(res, fromR.status, { ok: false, reason: fromR.reason, ...(fromR.candidates ? { candidates: fromR.candidates } : {}) });
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
const source = fromR.target;
|
|
1339
|
+
const sourceInfo = m.sessionInfo(source);
|
|
1340
|
+
// 别人的会话 fork 不出来就是失败, 不能像克隆自己那样悄悄退化成白板 —— 调用方
|
|
1341
|
+
// 要的恰恰是它的上下文, 给一张白纸等于答非所问。
|
|
1342
|
+
if (source !== self && !sourceInfo?.sessionId) {
|
|
1343
|
+
json(res, 409, { ok: false, reason: `${displayName(source)} 没有可 fork 的会话 (还没说过话, 或绑定已失效)` });
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
const alivePeers = await m.peers(self);
|
|
1322
1347
|
// 预算。撞到上限不是"不许再分", 是"先把干完活的收掉": stop_wizard 收单个,
|
|
1323
1348
|
// close_job 整批回收一个工单的临时分身。只数**本聊天里**活着的 ——
|
|
1324
1349
|
// 生到别的聊天去的归那边管, 为了数它们再探一遍 tmux 不值当。
|
|
@@ -1342,27 +1367,28 @@ const main = async () => {
|
|
|
1342
1367
|
return;
|
|
1343
1368
|
}
|
|
1344
1369
|
const askedName = String(b.name ?? b.tag ?? "");
|
|
1345
|
-
const slotR = await claimSlot(base, askedName, normalizeTag(b.description?.split(/\s+/)[0]) || "clone");
|
|
1370
|
+
const slotR = await claimSlot(base, askedName, normalizeTag(b.description?.split(/\s+/)[0]) || "clone", [self, source]);
|
|
1346
1371
|
if (!slotR.ok) {
|
|
1347
1372
|
json(res, slotR.status, slotR.body);
|
|
1348
1373
|
return;
|
|
1349
1374
|
}
|
|
1350
1375
|
const { target, slot: tag } = slotR;
|
|
1351
|
-
const
|
|
1376
|
+
const forkOf = inherit && source !== self ? source : undefined;
|
|
1352
1377
|
// 先落身份再 spawn: 宪章是从注册表渲染出来的, 记录不在就渲染出一个无名分身。
|
|
1353
1378
|
wizards.upsert(target, {
|
|
1354
1379
|
description: (b.description ?? "").toString().trim(),
|
|
1355
1380
|
parent: self,
|
|
1356
1381
|
bornAt: Date.now(),
|
|
1357
|
-
clonedFrom: inherit ?
|
|
1382
|
+
clonedFrom: inherit ? sourceInfo?.sessionId ?? "" : "",
|
|
1383
|
+
...(forkOf ? { forkOf } : {}),
|
|
1358
1384
|
});
|
|
1359
1385
|
const name = wizards.rename(target, normalizeTag(askedName) || tag);
|
|
1360
|
-
const charter = charterFor(target, { parent: self, inherited: inherit });
|
|
1386
|
+
const charter = charterFor(target, { parent: self, forkOf, inherited: inherit });
|
|
1361
1387
|
const task = (b.task ?? "").toString().trim();
|
|
1362
1388
|
// 没点名就按配置的 keepalive.spawnDefault 来 —— 调用方明说的永远优先。
|
|
1363
1389
|
const keepalive = typeof b.keepalive === "boolean" ? b.keepalive : cfg.wrc.mirror.keepalive.spawnDefault;
|
|
1364
1390
|
const r = await m.cloneSession({
|
|
1365
|
-
parent:
|
|
1391
|
+
parent: source,
|
|
1366
1392
|
target,
|
|
1367
1393
|
windowName: name,
|
|
1368
1394
|
cli: b.cli,
|
|
@@ -1380,7 +1406,7 @@ const main = async () => {
|
|
|
1380
1406
|
json(res, 500, { ok: false, reason: r.reason });
|
|
1381
1407
|
return;
|
|
1382
1408
|
}
|
|
1383
|
-
wizards.upsert(target, { clonedFrom: r.inherited ?
|
|
1409
|
+
wizards.upsert(target, { clonedFrom: r.inherited ? sourceInfo?.sessionId ?? "" : "", ...(r.inherited ? {} : { forkOf: undefined }) });
|
|
1384
1410
|
const kid = briefOf(self, target);
|
|
1385
1411
|
// r.model 是 spawnTmuxClaude 通过 /model 实测确认落地的那个 —— 可能跟调用方
|
|
1386
1412
|
// 传的原始字符串不一样 (口语化 → 目录里匹配到的关键词), 播报要报实情。
|
|
@@ -1390,7 +1416,7 @@ const main = async () => {
|
|
|
1390
1416
|
// 出生不再发群气泡 (人要看的是结论, 不是谁生了谁 —— 过程在 rolepage 里);
|
|
1391
1417
|
// 同群的 wizard 仍要知道群里多了一个成员。工单里的临时工连这条也省掉。
|
|
1392
1418
|
if (!jobId) {
|
|
1393
|
-
postRoster(base, [target, self], `新 wizard **.${name}** 就位${kid.description ? ` · ${kid.description}` : ""}${r.cwd ? ` · 工作区 ${r.cwd}` : ""}${modelNote}${keepalive ? "" : " · 已关闭 keepalive"} —— ${displayName(self)} 的分身${r.inherited ?
|
|
1419
|
+
postRoster(base, [target, self], `新 wizard **.${name}** 就位${kid.description ? ` · ${kid.description}` : ""}${r.cwd ? ` · 工作区 ${r.cwd}` : ""}${modelNote}${keepalive ? "" : " · 已关闭 keepalive"} —— ${displayName(self)} 的分身${r.inherited ? ` (继承了${forkOf ? ` ${displayName(forkOf)} ` : "它"}的上下文)` : ""}`);
|
|
1394
1420
|
}
|
|
1395
1421
|
// 继承路径上活已经随开场白进去了, 空白分身才需要在这里补一次注入 (私聊)。
|
|
1396
1422
|
let dispatched = r.inherited && !!task;
|
|
@@ -1501,7 +1527,7 @@ const main = async () => {
|
|
|
1501
1527
|
job: job.id,
|
|
1502
1528
|
title,
|
|
1503
1529
|
memberMax: JOB_MEMBER_MAX,
|
|
1504
|
-
hint: "把这个 id 传给
|
|
1530
|
+
hint: "把这个 id 传给 spawn_wizard / clone_wizard / send_peer 的 `job` 参数, 它们就归到这个工单名下 (期间不再逐条出气泡); 活干完调 close_job 收尾并回收临时分身。",
|
|
1505
1531
|
});
|
|
1506
1532
|
});
|
|
1507
1533
|
http.register("POST /jobs/close", async (req, res) => {
|
|
@@ -1805,49 +1831,42 @@ const main = async () => {
|
|
|
1805
1831
|
};
|
|
1806
1832
|
}
|
|
1807
1833
|
// 定时调度器 — 每 20s 检查任务表, 到点把 prompt 注入目标 wizard。
|
|
1808
|
-
// inject 只在 mirror 模式给得出 (headless 模式没有常驻会话可注入)。
|
|
1809
1834
|
migrateLegacySchedules(cfg, sourcePath, tasks, log.child({ mod: "tasks" }));
|
|
1810
1835
|
const scheduler = startScheduler({
|
|
1811
1836
|
client: ws.client,
|
|
1812
1837
|
registry: tasks,
|
|
1813
1838
|
log: log.child({ mod: "tasks" }),
|
|
1814
|
-
inject:
|
|
1839
|
+
inject: scheduledTaskInject,
|
|
1815
1840
|
// gate 里的 `sh` 默认就在目标 wizard 此刻的工作区跑 —— 任务文件里不必写死绝对路径。
|
|
1816
|
-
cwdOf:
|
|
1817
|
-
? (t) => { const c = bridge.getCwd(t); return c.runningCwd || c.pendingCwd || c.defaultCwd; }
|
|
1818
|
-
: undefined,
|
|
1841
|
+
cwdOf: (t) => { const c = bridge.getCwd(t); return c.runningCwd || c.pendingCwd || c.defaultCwd; },
|
|
1819
1842
|
fallbackTarget: () => (cfg.defaultChat ?? "").trim(),
|
|
1820
1843
|
});
|
|
1821
|
-
//
|
|
1822
|
-
//
|
|
1823
|
-
//
|
|
1824
|
-
//
|
|
1825
|
-
//
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
const ttlMs = cfg.wrc.mirror.idleReapHours * 3.6e6;
|
|
1844
|
+
// pane 上限 — 每新建一个 wizard 数一次: 活着的会话 pane 超过 wrc.mirror.maxPanes
|
|
1845
|
+
// (默认 20, 0=关) 就从最久没动的收起。数量只在出生时增长, 所以不设定时器。只收
|
|
1846
|
+
// pane 不收绑定: 下一条消息 `--resume` 复活。豁免: 名下有定时任务的 wizard (调度
|
|
1847
|
+
// 本身会唤醒/需要它), 挂着审批长轮询的会话 (hook 还停在等点击), 忙着的 / 人正
|
|
1848
|
+
// 盯着的 (bridge 内部判)。
|
|
1849
|
+
if (cfg.wrc.mirror.maxPanes > 0) {
|
|
1850
|
+
const max = cfg.wrc.mirror.maxPanes;
|
|
1829
1851
|
const sweep = async () => {
|
|
1830
1852
|
const taskOwners = new Set(tasks.list()
|
|
1831
1853
|
.filter((t) => t.enabled)
|
|
1832
1854
|
.map((t) => wizardStore()?.byName(t.owner)?.target)
|
|
1833
1855
|
.filter((t) => !!t));
|
|
1834
1856
|
const pendingSids = new Set(listPending().map((p) => p.meta.sessionId).filter((s) => !!s));
|
|
1835
|
-
const r = await bridge.
|
|
1857
|
+
const r = await bridge.reapOverflow(max, (target, sid) => taskOwners.has(target) ? "owns scheduled task"
|
|
1836
1858
|
: pendingSids.has(sid) ? "pending approval"
|
|
1837
1859
|
: undefined);
|
|
1838
1860
|
if (r.reaped.length)
|
|
1839
|
-
log.info({ reaped: r.reaped.length, targets: r.reaped }, "
|
|
1861
|
+
log.info({ reaped: r.reaped.length, targets: r.reaped, max }, "pane cap sweep");
|
|
1840
1862
|
};
|
|
1841
|
-
|
|
1842
|
-
reapTimer.unref();
|
|
1863
|
+
bridge.onSpawn(() => void sweep().catch((e) => log.warn({ err: e.message }, "pane cap sweep failed")));
|
|
1843
1864
|
}
|
|
1844
1865
|
const shutdown = async (signal) => {
|
|
1845
1866
|
log.info({ signal }, "shutdown signal");
|
|
1846
1867
|
scheduler.stop();
|
|
1847
1868
|
tasks.stop();
|
|
1848
1869
|
netWatch.stop();
|
|
1849
|
-
if (reapTimer)
|
|
1850
|
-
clearInterval(reapTimer);
|
|
1851
1870
|
// 同 POST /shutdown: 先把挂着的审批长轮询了结成「稍后续接」, 再关连接。
|
|
1852
1871
|
log.info(drainForReload(), "pending drained for reload");
|
|
1853
1872
|
// Hard-exit watchdog: http.close() blocks until every in-flight connection
|