wezard 1.4.2 → 2.0.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 +12 -7
- package/README.md +121 -67
- package/dist/daemon/approval.js +9 -3
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/cc-bridge.js.map +1 -1
- package/dist/daemon/chat-name.js +8 -18
- package/dist/daemon/chat-name.js.map +1 -1
- package/dist/daemon/detail.js +53 -12
- 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 +135 -158
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +381 -192
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +2 -2
- package/dist/daemon/mirror-bridge.js +221 -134
- 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/spawn-tmux.js +27 -4
- 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 +57 -0
- package/dist/daemon/wizard-memory.js.map +1 -0
- package/dist/daemon/wizard.js +84 -38
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/mcp/server.js +138 -76
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/chat-http.js +185 -108
- package/dist/shared/chat-http.js.map +1 -1
- package/dist/shared/chat-render.js +26 -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/config.js +23 -4
- 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.map +1 -1
- package/dist/shared/role-render.js +52 -0
- package/dist/shared/role-render.js.map +1 -0
- package/dist/shared/role-view.js +210 -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 +7 -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/package.json +1 -1
- package/web/chat.css +407 -212
- package/web/chat.js +932 -321
package/dist/daemon/index.js
CHANGED
|
@@ -13,21 +13,24 @@ import { makeBridge } from "./cc-bridge.js";
|
|
|
13
13
|
import { startMirror, installMirrorEventListener } from "./mirror-bridge.js";
|
|
14
14
|
import { setTmuxTimeoutReporter, spawnTmuxClaude } from "./spawn-tmux.js";
|
|
15
15
|
import { installApprovalEventListener, makeApproveHandler } from "./approval.js";
|
|
16
|
-
import { initDetailPersistence, makeDetailHandler, chatHandlers, configureRemoteForward,
|
|
16
|
+
import { initDetailPersistence, makeDetailHandler, chatHandlers, configureRemoteForward, chatUrlFor, setWorldFactsProvider } from "./detail.js";
|
|
17
17
|
import { EMPTY_FACTS } from "../shared/world.js";
|
|
18
18
|
import { initAutoWindowPersistence } from "./session-cache.js";
|
|
19
19
|
import { makeMessageHandler } from "./outbound.js";
|
|
20
20
|
import { makeCardHandler, makeAskHandler, installAskEventListener } from "./ask.js";
|
|
21
|
-
import { drainForReload } from "./pending.js";
|
|
21
|
+
import { drainForReload, listPending } from "./pending.js";
|
|
22
22
|
import { makeClaimStartHandler, makeClaimStatusHandler, makeClaimResetHandler, } from "./claim.js";
|
|
23
23
|
import { makeWedocBridge } from "./wedoc.js";
|
|
24
24
|
import { installResponseTracker } from "./last-response.js";
|
|
25
25
|
import { scanClaudeSessions } from "./session-scan.js";
|
|
26
|
-
import { startScheduler,
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
26
|
+
import { startScheduler, migrateLegacySchedules, promptWantsFreshWizard, renderTask, sinceOf, } from "./tasks.js";
|
|
27
|
+
import { openTaskRegistry } from "./task-registry.js";
|
|
28
|
+
import { describeTrigger, nextFire, parseTrigger, WHEN_HELP } from "../shared/trigger.js";
|
|
29
|
+
import { slugify, uniqueId } from "../shared/task-file.js";
|
|
30
|
+
import { baseOfKey, bindTagLinker, keyOf, linkTags, normalizeTag, tagFromCwd, tagHead, tagLink, tagOfKey, uniqueTag, withTagHeader } from "../shared/session-label.js";
|
|
31
|
+
import { appendMemory, forgetMemory, memoryPath, readMemory } from "./wizard-memory.js";
|
|
29
32
|
import { applyChatNames, chatBaseOf, chatNameOf, clearChatName, listChatNames, normChatName, peerAddress, planChatNames, setChatName } from "./chat-name.js";
|
|
30
|
-
import { bindWizardStore, loadWizardStore,
|
|
33
|
+
import { bindWizardStore, loadWizardStore, wizardStore, settleName, settleAll, childrenOf, ancestorsOf, renderCharter, } from "./wizard.js";
|
|
31
34
|
import { bindNoticeBox, createNoticeBox, chatAudience } from "./notices.js";
|
|
32
35
|
import { loadJobStore, renderJobOpen, renderJobClose, JOB_MEMBER_MAX } from "./jobs.js";
|
|
33
36
|
import { extractResult } from "./peers.js";
|
|
@@ -47,6 +50,9 @@ const main = async () => {
|
|
|
47
50
|
name: "wezard-daemon",
|
|
48
51
|
});
|
|
49
52
|
log.info({ sourcePath, pid: process.pid }, "daemon start");
|
|
53
|
+
// 定时调度器的 `inject` 真正实现, 赋值在 mirror-mode 的大块里 (要用到那里面的
|
|
54
|
+
// wizards / m / notifyChat), 但 startScheduler 在那块外面接线 —— 见文件尾。
|
|
55
|
+
let scheduledTaskInject;
|
|
50
56
|
// Bind the CLI backend registry. `primary` (= defaultCli) drives new-session
|
|
51
57
|
// spawns; `backends` is every installed CLI whose transcript root exists, so
|
|
52
58
|
// sessions from all of them can be mirrored concurrently — each attachment
|
|
@@ -76,6 +82,9 @@ const main = async () => {
|
|
|
76
82
|
installResponseTracker(ws.client, log.child({ mod: "chat-gate" }));
|
|
77
83
|
const sessions = loadSessionStore(cfg.wrc.sessionMapFile);
|
|
78
84
|
const mirrorStore = loadMirrorStore(cfg.wrc.mirror.attachmentsFile);
|
|
85
|
+
// 定时任务表 —— 每条任务是 ~/.wezard/tasks/<id>.task.mjs 一份可注入代码的配置
|
|
86
|
+
// (见 shared/task-file.ts)。目录是热加载的: wizard 改完文件不用 reload 守护进程。
|
|
87
|
+
const tasks = await openTaskRegistry(log.child({ mod: "tasks" }), (added, removed) => log.info({ added, removed }, "task files changed"));
|
|
79
88
|
const bridge = cfg.wrc.mode === "mirror"
|
|
80
89
|
? startMirror({ cfg, log: log.child({ mod: "mirror" }), client: ws.client, store: mirrorStore })
|
|
81
90
|
: makeBridge({ cfg, log: log.child({ mod: "bridge" }), client: ws.client, sessions });
|
|
@@ -83,6 +92,19 @@ const main = async () => {
|
|
|
83
92
|
log.fatal("bridge failed to start");
|
|
84
93
|
fatalExit("bridge failed to start");
|
|
85
94
|
}
|
|
95
|
+
// 群里每个 `.name` 都挂它的 rolepage (见 session-label 的 TagLinker)。正文提及
|
|
96
|
+
// 只认真有其人的 —— 随手写的 `.gitignore` 不该变成一个开在空栏上的链接; headless
|
|
97
|
+
// 没有 wizard 名册, 只挂头。
|
|
98
|
+
const known = cfg.wrc.mode === "mirror" ? (t) => bridge.chatTargets(t).includes(t) : () => false;
|
|
99
|
+
// 名册在 mirror 块里才绑 (bindWizardStore) —— 这里全走 wizardStore() 惰性取。
|
|
100
|
+
bindTagLinker({
|
|
101
|
+
urlOf: (t) => chatUrlFor(cfg.daemon, t, baseOfKey(t).replace(/^(user|chat|group):/, "")),
|
|
102
|
+
resolve: (name) => {
|
|
103
|
+
const t = wizardStore()?.byName(name)?.target;
|
|
104
|
+
return t && known(t) ? t : undefined;
|
|
105
|
+
},
|
|
106
|
+
nameOf: (t) => settleName(wizardStore(), chatNameOf(cfg, t), t),
|
|
107
|
+
});
|
|
86
108
|
installInboundRouter(ws.client, cfg, log, bridge, sourcePath);
|
|
87
109
|
// approval click → finalize 当前 liveStream, 后续 tool/text 落到 standalone。
|
|
88
110
|
// 规则 2: 用户点击授权那一刻就是"上一段对话"的边界, 截断 stream 让授权后的
|
|
@@ -353,21 +375,28 @@ const main = async () => {
|
|
|
353
375
|
return;
|
|
354
376
|
}
|
|
355
377
|
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 });
|
|
378
|
+
const slotR = await claimSlot(base, String(body.name ?? body.tag ?? ""), tagFromCwd(cwd) || "peer");
|
|
379
|
+
if (!slotR.ok) {
|
|
380
|
+
json(res, slotR.status, slotR.body);
|
|
362
381
|
return;
|
|
363
382
|
}
|
|
364
|
-
const
|
|
365
|
-
const
|
|
383
|
+
const { target, slot: tag } = slotR;
|
|
384
|
+
const existed = !!wizards.get(target);
|
|
385
|
+
const name = wizards.rename(target, normalizeTag(String(body.name ?? body.tag ?? "")) || tag);
|
|
366
386
|
const model = (body.model ?? "").toString().trim();
|
|
367
|
-
|
|
368
|
-
const
|
|
387
|
+
// 没点名就按配置的 keepalive.spawnDefault 来 —— 调用方明说的永远优先。
|
|
388
|
+
const keepalive = typeof body.keepalive === "boolean" ? body.keepalive : cfg.wrc.mirror.keepalive.spawnDefault;
|
|
389
|
+
log.child({ mod: "mirror", sub: "sessions-new", target }).info({ self, cwd, foreign, cli: body.cli, model, keepalive }, "spawning peer session");
|
|
390
|
+
const r = await m.newSession(target, name, body.cli, { cwd, model, keepalive });
|
|
391
|
+
if (!r.ok && !existed)
|
|
392
|
+
wizards.drop(target);
|
|
393
|
+
// r.model 是 spawnTmuxClaude 通过 /model 实测确认落地的那个 —— 可能跟调用方
|
|
394
|
+
// 传的原始字符串不一样 (口语化 → 目录里匹配到的关键词), 播报要报实情。
|
|
395
|
+
const modelNote = r.model
|
|
396
|
+
? (r.modelWarning ? ` · 模型 ${r.model} (⚠️ ${r.modelWarning})` : ` · 模型 ${r.model}`)
|
|
397
|
+
: "";
|
|
369
398
|
if (r.ok)
|
|
370
|
-
postRoster(base, [target, self], `新 wizard
|
|
399
|
+
postRoster(base, [target, self], `新 wizard **.${name}** 就位${r.cwd ? ` · 工作区 ${r.cwd}` : ""}${modelNote}${keepalive ? "" : " · 已关闭 keepalive"} —— 空白起步, 由 ${displayName(self)} 造的`);
|
|
371
400
|
json(res, r.ok ? 200 : 500, r.ok
|
|
372
401
|
? {
|
|
373
402
|
ok: true,
|
|
@@ -375,10 +404,12 @@ const main = async () => {
|
|
|
375
404
|
self,
|
|
376
405
|
target,
|
|
377
406
|
base,
|
|
378
|
-
|
|
407
|
+
name,
|
|
379
408
|
cwd: r.cwd,
|
|
380
|
-
...(model ? { model } : {}),
|
|
409
|
+
...(r.model ? { model: r.model } : {}),
|
|
410
|
+
...(r.modelWarning ? { modelWarning: r.modelWarning } : {}),
|
|
381
411
|
foreign,
|
|
412
|
+
keepalive,
|
|
382
413
|
// 调用方之后拿这个串 send_peer / peek_peer 驱动它。
|
|
383
414
|
address: peerAddress(cfg, self, target),
|
|
384
415
|
}
|
|
@@ -432,8 +463,15 @@ const main = async () => {
|
|
|
432
463
|
json(res, 400, { ok: false, reason: "target required (or pass sessionId of an attached chat)" });
|
|
433
464
|
return;
|
|
434
465
|
}
|
|
466
|
+
// keep = 人回答了「不用换, 就用现在这个」。只落一笔确认, 不碰 pane —— 把
|
|
467
|
+
// 「保持现状」也走成 setPendingCwd + newSession 的话, 代价是白杀一个会话。
|
|
468
|
+
if (body.keep === true) {
|
|
469
|
+
const c = m.confirmCwd(target);
|
|
470
|
+
json(res, c.ok ? 200 : 400, c.ok ? { ok: true, target, cwd: c.cwd, confirmed: true } : { ok: false, reason: c.reason, target });
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
435
473
|
if (!cwd) {
|
|
436
|
-
json(res, 400, { ok: false, reason: "cwd required" });
|
|
474
|
+
json(res, 400, { ok: false, reason: "cwd required (or keep:true to confirm the current one)" });
|
|
437
475
|
return;
|
|
438
476
|
}
|
|
439
477
|
const set = m.setPendingCwd(target, cwd);
|
|
@@ -484,7 +522,7 @@ const main = async () => {
|
|
|
484
522
|
target: t,
|
|
485
523
|
tag: tagOfKey(t),
|
|
486
524
|
address: self ? peerAddress(cfg, self, t) : t,
|
|
487
|
-
name:
|
|
525
|
+
name: settleName(wizards, chatNameOf(cfg, t), t),
|
|
488
526
|
description: wizards.get(t)?.description ?? "",
|
|
489
527
|
cwd: m.getCwd(t).runningCwd || m.getCwd(t).defaultCwd,
|
|
490
528
|
})),
|
|
@@ -507,7 +545,15 @@ const main = async () => {
|
|
|
507
545
|
}
|
|
508
546
|
const previous = chatNameOf(cfg, self);
|
|
509
547
|
const r = setChatName(cfg, sourcePath, self, raw);
|
|
510
|
-
|
|
548
|
+
if (!r.ok) {
|
|
549
|
+
json(res, 409, { ok: false, reason: r.reason });
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
// 默认 wizard 的名字取自聊天名; 它还叫旧聊天名 (没被单独改过) 就跟着改。
|
|
553
|
+
const home = baseOfKey(self);
|
|
554
|
+
const cur = wizards.get(home)?.name ?? "";
|
|
555
|
+
const wizardRenamed = previous && cur.toLowerCase() === previous.toLowerCase() ? wizards.rename(home, r.name) : undefined;
|
|
556
|
+
json(res, 200, { ok: true, base: r.base, name: r.name, previous, ...(wizardRenamed ? { wizardRenamed } : {}) });
|
|
511
557
|
});
|
|
512
558
|
const readPeerBody = async (req) => {
|
|
513
559
|
const body = (await readBody(req));
|
|
@@ -525,63 +571,87 @@ const main = async () => {
|
|
|
525
571
|
});
|
|
526
572
|
// Push a plain markdown bubble into a chat. `base` may carry a `#tag` — a
|
|
527
573
|
// tagged key strips down to the same WeCom chatid as its base.
|
|
574
|
+
// 头不走 withTagHeader 的那些 (relay / notify / 工单) 正文里的 tag 在这里挂链;
|
|
575
|
+
// 已挂过的 linkTags 认得出, 不会再套一层。
|
|
528
576
|
const chatIdOf = (t) => baseOfKey(t).replace(/^(user|chat|group):/, "");
|
|
529
577
|
const notifyChat = (base, markdown) => {
|
|
530
578
|
const chatId = chatIdOf(base);
|
|
531
579
|
void ws.client
|
|
532
|
-
.sendMessage(chatId, { msgtype: "markdown", markdown: { content: markdown } })
|
|
580
|
+
.sendMessage(chatId, { msgtype: "markdown", markdown: { content: linkTags(base, markdown) } })
|
|
533
581
|
.catch((e) => log.warn({ err: e.message }, "chat notify failed"));
|
|
534
582
|
};
|
|
535
583
|
// wizard 在群里的称呼, 只用在**正文**里。气泡的头照旧交给 withTagHeader ——
|
|
536
584
|
// 那一段是路由信息 (`emoji #tag`), parseTagHeader 靠它反解, 群里引用一条气泡
|
|
537
585
|
// 就能直接跟那个 wizard 说话; 把它换成名字会把这条通路弄断。
|
|
538
586
|
const displayName = (t) => {
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
return name || (tag ? `#${tag}` : "默认 wizard");
|
|
587
|
+
const name = settleName(wizards, chatNameOf(cfg, t), t);
|
|
588
|
+
return name ? `.${name}` : "默认 wizard";
|
|
542
589
|
};
|
|
543
|
-
// wizard
|
|
544
|
-
//
|
|
590
|
+
// wizard 之间的往返默认是私聊, 不进群。只有发话方判断「该当着人说」的那一次
|
|
591
|
+
// (send_peer public:true) 在公开频道里成一条气泡, 头写成 `.a → .b`, 方向一眼可读。
|
|
545
592
|
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) => {
|
|
593
|
+
/** 一个 wizard 在群里的称呼: `emoji .name`, 挂它的 rolepage。名字全局唯一, 不再
|
|
594
|
+
* 因为落在哪个群而换写法 —— 引用这一段就能跟它说话 (parseTagHeader 认得)。 */
|
|
595
|
+
const relayLabel = (t, _dest) => tagLink(t, tagHead(t));
|
|
596
|
+
/** 公开的 wizard 间对话: 落在 `channel` 这个群。 */
|
|
597
|
+
const relayPeer = (from, to, body, channel) => {
|
|
575
598
|
const text = body.trim();
|
|
576
|
-
if (!text)
|
|
599
|
+
if (!text || !channel)
|
|
577
600
|
return;
|
|
578
|
-
const
|
|
579
|
-
const head = `${relayLabel(from, dest)} → ${relayLabel(to, dest)}`;
|
|
601
|
+
const head = `${relayLabel(from)} → ${relayLabel(to)}`;
|
|
580
602
|
const clipped = text.length > RELAY_MAX ? `${text.slice(0, RELAY_MAX)}…` : text;
|
|
581
603
|
// 头独占一行, 正文自成一个块 —— 只隔一个换行的话, markdown 会把正文首行
|
|
582
604
|
// 当成头那一段的续行; 表格因此整张塌成一行带竖线的文字 (表格不能打断段落)。
|
|
583
|
-
|
|
584
|
-
|
|
605
|
+
notifyChat(channel, `${head}\n\n${clipped}`);
|
|
606
|
+
};
|
|
607
|
+
/** 调用方这一轮所在的公开频道: 人从哪个群叫的它 / 公开 peer 轮的那个群;
|
|
608
|
+
* 私聊轮或无记录 → 它的 home 群。notify 与 public send_peer 默认发到这里。 */
|
|
609
|
+
const channelOf = (self) => m.currentChannel(self) || baseOfKey(self);
|
|
610
|
+
/** 入参里的地址: 新字段 `name`, 老 MCP 进程 (正在跑的 wizard) 仍在传 `tag`。 */
|
|
611
|
+
const addrOf = (b) => String(b.name ?? b.tag ?? "");
|
|
612
|
+
/** 给一个要出生的 wizard 挑槽位 (target key) 并落定名字。名字全局唯一:
|
|
613
|
+
* - 撞上的是一个还绑着会话的 wizard → 409, 附上它的死活, 调用方据此换名或先收掉它;
|
|
614
|
+
* - 撞上的只是目标群里同一个槽位的冷记录 (会话早没了) → 复用这个槽, 即原名重生,
|
|
615
|
+
* 与从前「同一个 tag 重开」一样, 家谱/记忆都还在;
|
|
616
|
+
* - 撞上别的群里的冷记录 → 409: 名字仍被那条记录占着。
|
|
617
|
+
* 槽位 (key 里的 `#k`) 只是内部 id: 默认取名字本身, 被占了就加序号, 与名字无关。 */
|
|
618
|
+
const claimSlot = async (base, want, fallback) => {
|
|
619
|
+
const asked = normalizeTag(want);
|
|
620
|
+
const clash = asked ? wizards.byName(asked) : undefined;
|
|
621
|
+
if (clash) {
|
|
622
|
+
const bound = m.chatTargets(baseOfKey(clash.target)).includes(clash.target);
|
|
623
|
+
const reusable = !bound && baseOfKey(clash.target) === base && !!tagOfKey(clash.target);
|
|
624
|
+
if (reusable)
|
|
625
|
+
return { ok: true, target: clash.target, slot: tagOfKey(clash.target) };
|
|
626
|
+
const info = bound ? (await m.peers(clash.target)).find((p) => p.target === clash.target) : undefined;
|
|
627
|
+
const alive = info?.paneAlive ?? false;
|
|
628
|
+
const idleForMs = info?.lastActivity ? Date.now() - info.lastActivity : undefined;
|
|
629
|
+
const idleDesc = idleForMs !== undefined ? `静默 ${Math.round(idleForMs / 60000)} 分钟` : "从没动过";
|
|
630
|
+
const advice = alive
|
|
631
|
+
? "它的 pane 还活着 —— 除非这就是同一件事的延续, 否则换个名字重新生, 别把不相关的活塞给一个已经有职责的 wizard"
|
|
632
|
+
: bound
|
|
633
|
+
? `pane 已经不在了 (${idleDesc}), 真要复用这个名字就先 stop_wizard({name:"${clash.name}", mode:"end"}) 收掉, 再用同一个名字重新生 —— 拿到干净的上下文; 别直接 send_peer 唤醒它接手, 它会带着上一件事的记忆答你这件`
|
|
634
|
+
: "这个名字被别的群里一个已经不在跑的 wizard 占着 —— 换个名字";
|
|
635
|
+
return {
|
|
636
|
+
ok: false,
|
|
637
|
+
status: 409,
|
|
638
|
+
body: {
|
|
639
|
+
ok: false,
|
|
640
|
+
reason: `名字 '.${clash.name}' 已经属于一个${alive ? "活着的" : "不再活跃的"} wizard (名字全局唯一) —— ${advice}`,
|
|
641
|
+
name: clash.name,
|
|
642
|
+
address: clash.name,
|
|
643
|
+
alive,
|
|
644
|
+
busy: info?.busy ?? false,
|
|
645
|
+
idleForMs,
|
|
646
|
+
},
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
const taken = new Set([
|
|
650
|
+
...m.chatTargets(base).map(tagOfKey),
|
|
651
|
+
...wizards.all().filter((w) => baseOfKey(w.target) === base).map((w) => tagOfKey(w.target)),
|
|
652
|
+
].filter(Boolean));
|
|
653
|
+
const slot = uniqueTag(asked || normalizeTag(fallback) || "wizard", taken);
|
|
654
|
+
return { ok: true, target: keyOf(base, slot), slot };
|
|
585
655
|
};
|
|
586
656
|
// ── 给人看的消息 ──────────────────────────────────────────────────
|
|
587
657
|
// send_peer 把话塞进另一个 agent 的输入框 (驱动它干活); 这条把话贴进一个聊天
|
|
@@ -611,9 +681,9 @@ const main = async () => {
|
|
|
611
681
|
json(res, 400, { ok: false, reason: `认不出这些聊天: ${bad.join(", ")} (list_chats 看有哪些; 没起名的聊天寻址不到)` });
|
|
612
682
|
return;
|
|
613
683
|
}
|
|
614
|
-
const dests = [...new Set(refs.length ? refs.map((r) => chatBaseOf(cfg, r)) : [
|
|
684
|
+
const dests = [...new Set(refs.length ? refs.map((r) => chatBaseOf(cfg, r)) : [channelOf(self)])];
|
|
615
685
|
for (const dest of dests)
|
|
616
|
-
notifyChat(dest, `${relayLabel(self
|
|
686
|
+
notifyChat(dest, `${relayLabel(self)}\n\n${content}`);
|
|
617
687
|
json(res, 200, { ok: true, sent: dests.map((d) => chatNameOf(cfg, d) || d) });
|
|
618
688
|
});
|
|
619
689
|
http.register("POST /peers/peek", async (req, res) => {
|
|
@@ -622,7 +692,7 @@ const main = async () => {
|
|
|
622
692
|
json(res, 400, { ok: false, reason: "cannot resolve caller session" });
|
|
623
693
|
return;
|
|
624
694
|
}
|
|
625
|
-
const r = resolvePeer(self, body
|
|
695
|
+
const r = resolvePeer(self, addrOf(body));
|
|
626
696
|
if (!r.ok) {
|
|
627
697
|
json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
|
|
628
698
|
return;
|
|
@@ -663,12 +733,16 @@ const main = async () => {
|
|
|
663
733
|
return;
|
|
664
734
|
}
|
|
665
735
|
}
|
|
666
|
-
const r = resolvePeer(self, body
|
|
736
|
+
const r = resolvePeer(self, addrOf(body));
|
|
667
737
|
if (!r.ok) {
|
|
668
738
|
json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
|
|
669
739
|
return;
|
|
670
740
|
}
|
|
671
741
|
const { target, foreign } = r;
|
|
742
|
+
// 公开与否由发话方 (LLM) 判断: 公开 = 在它这一轮的公开频道里说, 气泡进群、对方
|
|
743
|
+
// 那一轮的回复也发进这个群; 私聊 (默认) = 只落双方的 rolepage, 回复靠 wait_peer 取。
|
|
744
|
+
const isPublic = body.public === true;
|
|
745
|
+
const channel = isPublic ? channelOf(self) : "";
|
|
672
746
|
// Injecting into your own pane would type into the box you're generating
|
|
673
747
|
// from — Claude Code queues it and the caller deadlocks waiting for itself.
|
|
674
748
|
if (target === self) {
|
|
@@ -694,17 +768,18 @@ const main = async () => {
|
|
|
694
768
|
return;
|
|
695
769
|
}
|
|
696
770
|
}
|
|
697
|
-
const inj = await m.injectText(target, text, undefined, {
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
771
|
+
const inj = await m.injectText(target, text, undefined, {
|
|
772
|
+
from: { kind: "peer", from: self, ...(jobId ? { job: jobId } : {}), ...(isPublic ? { public: true } : {}) },
|
|
773
|
+
channel,
|
|
774
|
+
});
|
|
775
|
+
// 工单成员照旧记账 (收工那一条会列出各自那段活); 公开的那一句在群里成气泡。
|
|
701
776
|
if (inj.ok && jobId)
|
|
702
777
|
jobs.attach(jobId, { target, task: text, spawned: false });
|
|
703
|
-
if (inj.ok &&
|
|
704
|
-
relayPeer(self, target, text);
|
|
778
|
+
if (inj.ok && isPublic)
|
|
779
|
+
relayPeer(self, target, text, channel);
|
|
705
780
|
// `wasBusy` 是给调用方的判断依据: 立刻投给一个正在生成的会话, 这句话会排在
|
|
706
781
|
// 它这一轮后面, 而不是马上被读到。
|
|
707
|
-
json(res, inj.ok ? 200 : 502, { ...inj, target, foreign, when, wasBusy, waitedMs, ...(jobId ? { job: jobId } : {}) });
|
|
782
|
+
json(res, inj.ok ? 200 : 502, { ...inj, target, name: peerAddress(cfg, self, target), foreign, public: isPublic, when, wasBusy, waitedMs, ...(jobId ? { job: jobId } : {}) });
|
|
708
783
|
});
|
|
709
784
|
// 等一个 wizard, 或者等一**组**。fan-out 之后 join 必须是并行的: 串行地等五个
|
|
710
785
|
// 分身, 墙钟是五个之和, 而它们本来就在同时干活。`need` 把 all / any / 过半收进
|
|
@@ -716,7 +791,8 @@ const main = async () => {
|
|
|
716
791
|
return;
|
|
717
792
|
}
|
|
718
793
|
const b = body;
|
|
719
|
-
const
|
|
794
|
+
const many = Array.isArray(b.names) && b.names.length > 0 ? b.names : b.tags;
|
|
795
|
+
const asked = (Array.isArray(many) && many.length > 0 ? many : [addrOf(b)]).map((x) => String(x ?? ""));
|
|
720
796
|
if (asked.length > 16) {
|
|
721
797
|
json(res, 400, { ok: false, reason: "一次最多等 16 个 wizard" });
|
|
722
798
|
return;
|
|
@@ -741,13 +817,7 @@ const main = async () => {
|
|
|
741
817
|
const results = uniq.map((h, i) => {
|
|
742
818
|
const wr = wrs[i];
|
|
743
819
|
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);
|
|
820
|
+
// 回程不再进群: 私聊的结论由调用方自己收口给人; 公开轮的回复 mirror 早已发进那个群。
|
|
751
821
|
// `result` 是它自己收口的那一行 (见 peers.extractResult); 捞不到就是 "",
|
|
752
822
|
// 调用方照旧读 lastText。
|
|
753
823
|
return { address: h.address, target: h.target, foreign: h.foreign, idle: wr.idle, reason: wr.reason, result: extractResult(lastText), lastText };
|
|
@@ -784,7 +854,7 @@ const main = async () => {
|
|
|
784
854
|
}
|
|
785
855
|
}
|
|
786
856
|
else {
|
|
787
|
-
const r = resolvePeer(self, body
|
|
857
|
+
const r = resolvePeer(self, addrOf(body));
|
|
788
858
|
if (!r.ok) {
|
|
789
859
|
json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
|
|
790
860
|
return;
|
|
@@ -839,8 +909,8 @@ const main = async () => {
|
|
|
839
909
|
: { ok: false, target, brief, reason: `handoff carry inject failed: ${inj2.reason}` });
|
|
840
910
|
});
|
|
841
911
|
// ── Wizard: 会话的身份层 ────────────────────────────────────────────
|
|
842
|
-
// 一个会话 (`chat:xxx#
|
|
843
|
-
//
|
|
912
|
+
// 一个会话 (`chat:xxx#k`) 从此是一个 wizard: 一个全局唯一的名字 (默认会话出生时
|
|
913
|
+
// 取聊天名, 分身取自己的名字)、一句职责、一份跨会话的记忆、一条家谱。身份不靠"在对话里讲一遍"
|
|
844
914
|
// 维持 —— spawn 时以 `--append-system-prompt` 压进那个进程, `/clear` 抹不掉、
|
|
845
915
|
// 上下文窗口也挤不掉。
|
|
846
916
|
// 这里只做三件事: 读身份、改身份、按身份生/收分身。tmux 一概不碰, 动作全在
|
|
@@ -885,10 +955,14 @@ const main = async () => {
|
|
|
885
955
|
log.info({ base, name }, "chat: auto-named from workspace");
|
|
886
956
|
// 群里不发气泡 (一次补名会命中十几个群, 那是刷屏), 但住在里面的 wizard
|
|
887
957
|
// 得知道自己的地址变了 —— 它的 charter 里写的还是「(未命名)」。
|
|
888
|
-
postRoster(base, [], `这个聊天现在叫 **${name}** (按工作区自动起的) ——
|
|
958
|
+
postRoster(base, [], `这个聊天现在叫 **${name}** (按工作区自动起的) —— notify / new_claude_session 可以用这个名字指到这里。`);
|
|
889
959
|
}
|
|
890
960
|
return named;
|
|
891
961
|
};
|
|
962
|
+
// 名字全局唯一 (见 wizard.ts): 先给没名字的聊天补名, 再按「默认会话先挑」把
|
|
963
|
+
// 每个已知 wizard 的名字落定 —— 老记录 (名字还跟着聊天走的那一代) 在这里一次迁完。
|
|
964
|
+
ensureChatNames(cfg.defaultChat || "");
|
|
965
|
+
settleAll(wizards, [...m.chatRoster("").flatMap((c) => c.targets), ...wizards.all().map((w) => w.target)], (t) => chatNameOf(cfg, t));
|
|
892
966
|
// 工单账本 (见 jobs.ts)。只在**显式传了 `job`** 时起作用 —— 不用工单的调用方
|
|
893
967
|
// 行为与从前一模一样。
|
|
894
968
|
const jobs = loadJobStore(cfg.wrc.mirror.jobsFile);
|
|
@@ -925,7 +999,7 @@ const main = async () => {
|
|
|
925
999
|
const w = byTarget.get(p.target);
|
|
926
1000
|
return {
|
|
927
1001
|
target: p.target,
|
|
928
|
-
name:
|
|
1002
|
+
name: settleName(wizards, chatNameOf(cfg, p.target), p.target),
|
|
929
1003
|
description: w?.description ?? "",
|
|
930
1004
|
chat: chatNameOf(cfg, p.target) || p.chat,
|
|
931
1005
|
cwd: p.cwd,
|
|
@@ -946,7 +1020,7 @@ const main = async () => {
|
|
|
946
1020
|
.filter((w) => !seen.has(w.target))
|
|
947
1021
|
.map((w) => ({
|
|
948
1022
|
target: w.target,
|
|
949
|
-
name:
|
|
1023
|
+
name: settleName(wizards, chatNameOf(cfg, w.target), w.target),
|
|
950
1024
|
description: w.description,
|
|
951
1025
|
chat: chatNameOf(cfg, w.target),
|
|
952
1026
|
cwd: "", model: "", cli: "",
|
|
@@ -965,16 +1039,20 @@ const main = async () => {
|
|
|
965
1039
|
openedAt: j.openedAt, closedAt: j.closedAt, summary: j.summary,
|
|
966
1040
|
members: j.members.map((mm) => ({ target: mm.target, task: mm.task, spawned: mm.spawned })),
|
|
967
1041
|
})),
|
|
968
|
-
schedules:
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1042
|
+
schedules: tasks.list().filter((x) => x.enabled).map((x) => {
|
|
1043
|
+
const st = tasks.stateOf(x.id);
|
|
1044
|
+
return {
|
|
1045
|
+
id: x.id,
|
|
1046
|
+
target: x.target || (cfg.defaultChat ?? ""),
|
|
1047
|
+
when: describeTrigger(x.trigger),
|
|
1048
|
+
nextAt: nextFire(x.trigger, sinceOf(st), nowDate),
|
|
1049
|
+
lastFired: st.lastFired,
|
|
1050
|
+
prompt: x.prompt,
|
|
1051
|
+
note: x.note,
|
|
1052
|
+
createdBy: x.createdBy,
|
|
1053
|
+
owner: x.owner || (cfg.defaultChat ?? ""),
|
|
1054
|
+
};
|
|
1055
|
+
}),
|
|
978
1056
|
chatNames: Object.fromEntries(listChatNames(cfg).map((c) => [c.base, c.name])),
|
|
979
1057
|
};
|
|
980
1058
|
};
|
|
@@ -989,39 +1067,40 @@ const main = async () => {
|
|
|
989
1067
|
* 给交接那一轮本身 —— 提示而已, 决定权在 wizard 自己。 */
|
|
990
1068
|
const HANDOFF_HINT_TOKENS = 140_000;
|
|
991
1069
|
const briefOf = (self, target) => ({
|
|
992
|
-
name:
|
|
1070
|
+
name: settleName(wizards, chatNameOf(cfg, target), target),
|
|
993
1071
|
address: peerAddress(cfg, self, target),
|
|
994
1072
|
description: wizards.get(target)?.description ?? "",
|
|
995
1073
|
cwd: m.getCwd(target).runningCwd,
|
|
996
1074
|
});
|
|
997
|
-
/**
|
|
998
|
-
|
|
999
|
-
* 在全机唯一时才碰巧能解析。聊天没起名时只能退回裸 tag。 */
|
|
1000
|
-
const selfAddress = (target) => {
|
|
1001
|
-
const chat = chatNameOf(cfg, target);
|
|
1002
|
-
const tag = tagOfKey(target);
|
|
1003
|
-
return chat ? `${chat}#${tag}` : tag;
|
|
1004
|
-
};
|
|
1075
|
+
/** 自己的地址 = 全局名字, 与住在哪个群无关。 */
|
|
1076
|
+
const selfAddress = (target) => settleName(wizards, chatNameOf(cfg, target), target);
|
|
1005
1077
|
/** 开局宪章。出生时的兄弟只是快照 —— 名册随时可查, 写进系统提示的那份只为了
|
|
1006
1078
|
* 让它一睁眼就知道自己不是一个人在跑。 */
|
|
1007
1079
|
const charterFor = (target, o) => renderCharter({
|
|
1008
|
-
|
|
1080
|
+
// o.cwd = 正在启动的那个 pane 的目录; 没给才退回"现在记着的那个"。
|
|
1081
|
+
self: { ...briefOf(target, target), address: selfAddress(target), ...(o.cwd ? { cwd: o.cwd } : {}) },
|
|
1009
1082
|
chat: chatNameOf(cfg, target),
|
|
1010
1083
|
principal: baseOfKey(target),
|
|
1011
1084
|
parent: o.parent ? briefOf(target, o.parent) : undefined,
|
|
1012
1085
|
inherited: !!o.inherited,
|
|
1086
|
+
cwdUnconfirmed: m.cwdUnconfirmed(target, o.cwd),
|
|
1013
1087
|
siblings: m.chatTargets(baseOfKey(target)).filter((t) => t !== target).map((t) => briefOf(target, t)),
|
|
1014
1088
|
memory: wizards.get(target)?.memory ?? [],
|
|
1089
|
+
chatMemory: readMemory(memoryPath(cfg.daemon.stateDir, "chat", baseOfKey(target))),
|
|
1090
|
+
workspaceMemory: (() => {
|
|
1091
|
+
const cwd = o.cwd || m.getCwd(target).runningCwd || m.getCwd(target).defaultCwd;
|
|
1092
|
+
return cwd ? readMemory(memoryPath(cfg.daemon.stateDir, "workspace", cwd)) : "";
|
|
1093
|
+
})(),
|
|
1015
1094
|
});
|
|
1016
1095
|
// 所有 spawn 路径 (群里手打 /new、pane 自愈重生、编排出来的分身) 都从这里
|
|
1017
1096
|
// 取身份, 于是「是谁」不再取决于是哪段代码把它生出来的。
|
|
1018
|
-
m.setCharterProvider((target) => {
|
|
1097
|
+
m.setCharterProvider((target, ctx) => {
|
|
1019
1098
|
// 这里是最要紧的那道拦截: 宪章把「所在聊天」写进系统提示, 而系统提示是随进程
|
|
1020
1099
|
// 终身的 —— 一个在聊天还没名字时出生的 wizard, 会一辈子以为自己住在一个
|
|
1021
1100
|
// 「(未命名)」的地方。所以补名要发生在渲染之前, 不能等它以后自己去查。
|
|
1022
1101
|
ensureChatNames(target);
|
|
1023
1102
|
const rec = wizards.get(target);
|
|
1024
|
-
return charterFor(target, { parent: rec?.parent, inherited: !!rec?.clonedFrom });
|
|
1103
|
+
return charterFor(target, { parent: rec?.parent, inherited: !!rec?.clonedFrom, cwd: ctx?.cwd });
|
|
1025
1104
|
});
|
|
1026
1105
|
// 开机也补一次: IM 侧的 `/peers`、`/help` 直接读名字, 不经过任何 MCP 路由。
|
|
1027
1106
|
// 延后是因为附着表要先从盘上恢复完, 否则这一刻还看不见那些聊天; 补不全也无妨,
|
|
@@ -1050,7 +1129,7 @@ const main = async () => {
|
|
|
1050
1129
|
chat: chatNameOf(cfg, target),
|
|
1051
1130
|
principal: baseOfKey(target),
|
|
1052
1131
|
tag: tagOfKey(target),
|
|
1053
|
-
named: !!(rec?.name ?? "").trim()
|
|
1132
|
+
named: !!(rec?.name ?? "").trim(),
|
|
1054
1133
|
bornAt: rec?.bornAt,
|
|
1055
1134
|
inheritedFrom: rec?.clonedFrom || "",
|
|
1056
1135
|
memory: rec?.memory ?? [],
|
|
@@ -1114,9 +1193,8 @@ const main = async () => {
|
|
|
1114
1193
|
ensureChatNames(self);
|
|
1115
1194
|
json(res, 200, { ok: true, ...identityOf(self, self), peers: (await m.peers(self)).filter((p) => !p.self).length });
|
|
1116
1195
|
});
|
|
1117
|
-
//
|
|
1118
|
-
//
|
|
1119
|
-
// 分身只写自己的记录, 它的地址本来就是 `chat#tag`。
|
|
1196
|
+
// 改名字 / 写职责。名字全局唯一、与聊天名脱钩 (默认会话只是出生时取了聊天名):
|
|
1197
|
+
// 撞名自动挂 `-N`, 返回里的 `name` 才是落定的那个。
|
|
1120
1198
|
http.register("POST /wizard/identity", async (req, res) => {
|
|
1121
1199
|
const { self, body } = await readPeerBody(req);
|
|
1122
1200
|
if (!self) {
|
|
@@ -1124,31 +1202,26 @@ const main = async () => {
|
|
|
1124
1202
|
return;
|
|
1125
1203
|
}
|
|
1126
1204
|
const b = body;
|
|
1127
|
-
const
|
|
1205
|
+
const asked = normalizeTag((b.name ?? "").toString());
|
|
1128
1206
|
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;
|
|
1207
|
+
if ((b.name ?? "").toString().trim() && !asked) {
|
|
1208
|
+
json(res, 400, { ok: false, reason: "名字只能是 1-32 位字母/数字/`_`/`-`" });
|
|
1209
|
+
return;
|
|
1138
1210
|
}
|
|
1139
|
-
wizards
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1211
|
+
const before = settleName(wizards, chatNameOf(cfg, self), self);
|
|
1212
|
+
const name = asked ? wizards.rename(self, asked) : before;
|
|
1213
|
+
if (description)
|
|
1214
|
+
wizards.upsert(self, { description });
|
|
1143
1215
|
const me = identityOf(self, self);
|
|
1144
|
-
//
|
|
1145
|
-
if (name && !wasNamed)
|
|
1146
|
-
notifyChat(self, withTagHeader(self, `我是 **${me.name}**${description ? ` · ${description}` : ""}`));
|
|
1147
|
-
// 职责是别人决定"该不该找你"的依据, 改了就得让同群的人知道 —— 否则他们照着
|
|
1216
|
+
// 职责是别人决定"该不该找你"的依据, 改了就得让同群的知道 —— 否则他们照着
|
|
1148
1217
|
// 出生快照里那句旧的 (或者空的) 职责派活。
|
|
1149
|
-
if (
|
|
1150
|
-
postRoster(baseOfKey(self), [self],
|
|
1151
|
-
json(res, 200, {
|
|
1218
|
+
if (asked || description)
|
|
1219
|
+
postRoster(baseOfKey(self), [self], `${before && before !== name ? `**.${before}** 改名为 **.${name}**` : `**.${name}** 改了身份`}${description ? ` · 职责: ${description}` : ""}`);
|
|
1220
|
+
json(res, 200, {
|
|
1221
|
+
ok: true,
|
|
1222
|
+
...me,
|
|
1223
|
+
...(asked && name.toLowerCase() !== asked.toLowerCase() ? { renamed: `'.${asked}' 已被占用, 落定为 '.${name}'` } : {}),
|
|
1224
|
+
});
|
|
1152
1225
|
});
|
|
1153
1226
|
// 名册是**索引**, 不是转储: 这台机器上现在有 300+ 个会话, 整表吐出来是三万
|
|
1154
1227
|
// token —— 一个 wizard 每次想找人都付这个价, 等于找不到人。所以服务端过滤
|
|
@@ -1188,6 +1261,8 @@ const main = async () => {
|
|
|
1188
1261
|
: {}),
|
|
1189
1262
|
});
|
|
1190
1263
|
});
|
|
1264
|
+
// 记忆三种作用域: self 跟着自己 (wizards.json); chat / workspace 是共享的 md,
|
|
1265
|
+
// 每个在那个群 / 那个目录出生的 wizard 都会读到 (见 wizard-memory.ts)。
|
|
1191
1266
|
http.register("POST /wizard/remember", async (req, res) => {
|
|
1192
1267
|
const { self, body } = await readPeerBody(req);
|
|
1193
1268
|
if (!self) {
|
|
@@ -1195,14 +1270,27 @@ const main = async () => {
|
|
|
1195
1270
|
return;
|
|
1196
1271
|
}
|
|
1197
1272
|
const b = body;
|
|
1198
|
-
const cur = wizards.get(self)?.memory ?? [];
|
|
1199
1273
|
const note = (b.note ?? "").toString().trim();
|
|
1200
1274
|
const forget = (b.forget ?? "").toString().trim();
|
|
1275
|
+
const scope = (b.scope ?? "self").toString();
|
|
1276
|
+
if (scope === "chat" || scope === "workspace") {
|
|
1277
|
+
const key = scope === "chat" ? baseOfKey(self) : (m.getCwd(self).runningCwd || m.getCwd(self).defaultCwd);
|
|
1278
|
+
if (!key) {
|
|
1279
|
+
json(res, 400, { ok: false, reason: "这个 wizard 没有工作区, 记不了 workspace 记忆" });
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
const path = memoryPath(cfg.daemon.stateDir, scope, key);
|
|
1283
|
+
const forgotten = forget ? forgetMemory(path, forget) : 0;
|
|
1284
|
+
const added = note ? appendMemory(path, note) : false;
|
|
1285
|
+
json(res, 200, { ok: true, scope, file: path, memory: readMemory(path), added, forgotten });
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
const cur = wizards.get(self)?.memory ?? [];
|
|
1201
1289
|
// 忘记按子串匹配: 模型记不住自己当初一字不差写了什么, 但记得大意。
|
|
1202
1290
|
const next = forget ? cur.filter((x) => !x.includes(forget)) : cur;
|
|
1203
1291
|
const memory = note ? [...next.filter((x) => x !== note), note] : next;
|
|
1204
1292
|
wizards.upsert(self, { memory });
|
|
1205
|
-
json(res, 200, { ok: true, memory, added: !!note, forgotten: cur.length - next.length });
|
|
1293
|
+
json(res, 200, { ok: true, scope: "self", memory, added: !!note, forgotten: cur.length - next.length });
|
|
1206
1294
|
});
|
|
1207
1295
|
// 生分身。默认 fork 调用方此刻的上下文 —— 这是"先把公共材料读进来, 再分出 N
|
|
1208
1296
|
// 个干活的"之所以省事的原因: 材料只读一遍, 却进了 N 份上下文。
|
|
@@ -1253,34 +1341,13 @@ const main = async () => {
|
|
|
1253
1341
|
});
|
|
1254
1342
|
return;
|
|
1255
1343
|
}
|
|
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
|
-
});
|
|
1344
|
+
const askedName = String(b.name ?? b.tag ?? "");
|
|
1345
|
+
const slotR = await claimSlot(base, askedName, normalizeTag(b.description?.split(/\s+/)[0]) || "clone");
|
|
1346
|
+
if (!slotR.ok) {
|
|
1347
|
+
json(res, slotR.status, slotR.body);
|
|
1280
1348
|
return;
|
|
1281
1349
|
}
|
|
1282
|
-
const
|
|
1283
|
-
const target = keyOf(base, tag);
|
|
1350
|
+
const { target, slot: tag } = slotR;
|
|
1284
1351
|
const parentInfo = m.sessionInfo(self);
|
|
1285
1352
|
// 先落身份再 spawn: 宪章是从注册表渲染出来的, 记录不在就渲染出一个无名分身。
|
|
1286
1353
|
wizards.upsert(target, {
|
|
@@ -1289,12 +1356,15 @@ const main = async () => {
|
|
|
1289
1356
|
bornAt: Date.now(),
|
|
1290
1357
|
clonedFrom: inherit ? parentInfo?.sessionId ?? "" : "",
|
|
1291
1358
|
});
|
|
1359
|
+
const name = wizards.rename(target, normalizeTag(askedName) || tag);
|
|
1292
1360
|
const charter = charterFor(target, { parent: self, inherited: inherit });
|
|
1293
1361
|
const task = (b.task ?? "").toString().trim();
|
|
1362
|
+
// 没点名就按配置的 keepalive.spawnDefault 来 —— 调用方明说的永远优先。
|
|
1363
|
+
const keepalive = typeof b.keepalive === "boolean" ? b.keepalive : cfg.wrc.mirror.keepalive.spawnDefault;
|
|
1294
1364
|
const r = await m.cloneSession({
|
|
1295
1365
|
parent: self,
|
|
1296
1366
|
target,
|
|
1297
|
-
windowName:
|
|
1367
|
+
windowName: name,
|
|
1298
1368
|
cli: b.cli,
|
|
1299
1369
|
model: b.model,
|
|
1300
1370
|
cwd: b.cwd,
|
|
@@ -1303,6 +1373,7 @@ const main = async () => {
|
|
|
1303
1373
|
// 继承路径上第一句话是分叉的触发器, 所以直接把活当开场白 —— 少一次往返,
|
|
1304
1374
|
// 也少一次"就位了但没事干"的空转。
|
|
1305
1375
|
bootstrap: task || undefined,
|
|
1376
|
+
keepalive,
|
|
1306
1377
|
});
|
|
1307
1378
|
if (!r.ok) {
|
|
1308
1379
|
wizards.drop(target);
|
|
@@ -1311,26 +1382,25 @@ const main = async () => {
|
|
|
1311
1382
|
}
|
|
1312
1383
|
wizards.upsert(target, { clonedFrom: r.inherited ? parentInfo?.sessionId ?? "" : "" });
|
|
1313
1384
|
const kid = briefOf(self, target);
|
|
1314
|
-
//
|
|
1315
|
-
//
|
|
1316
|
-
|
|
1317
|
-
|
|
1385
|
+
// r.model 是 spawnTmuxClaude 通过 /model 实测确认落地的那个 —— 可能跟调用方
|
|
1386
|
+
// 传的原始字符串不一样 (口语化 → 目录里匹配到的关键词), 播报要报实情。
|
|
1387
|
+
const modelNote = r.model
|
|
1388
|
+
? (r.modelWarning ? ` · 模型 ${r.model} (⚠️ ${r.modelWarning})` : ` · 模型 ${r.model}`)
|
|
1389
|
+
: "";
|
|
1390
|
+
// 出生不再发群气泡 (人要看的是结论, 不是谁生了谁 —— 过程在 rolepage 里);
|
|
1391
|
+
// 同群的 wizard 仍要知道群里多了一个成员。工单里的临时工连这条也省掉。
|
|
1318
1392
|
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 ? " (继承了它的上下文)" : ""}`);
|
|
1393
|
+
postRoster(base, [target, self], `新 wizard **.${name}** 就位${kid.description ? ` · ${kid.description}` : ""}${r.cwd ? ` · 工作区 ${r.cwd}` : ""}${modelNote}${keepalive ? "" : " · 已关闭 keepalive"} —— ${displayName(self)} 的分身${r.inherited ? " (继承了它的上下文)" : ""}`);
|
|
1321
1394
|
}
|
|
1322
|
-
//
|
|
1323
|
-
// 才需要在这里补一次注入。
|
|
1395
|
+
// 继承路径上活已经随开场白进去了, 空白分身才需要在这里补一次注入 (私聊)。
|
|
1324
1396
|
let dispatched = r.inherited && !!task;
|
|
1325
1397
|
if (task && !r.inherited) {
|
|
1326
|
-
const inj = await m.injectText(target, task, undefined, { from: { kind: "peer", from: self, ...(jobId ? { job: jobId } : {}) } });
|
|
1398
|
+
const inj = await m.injectText(target, task, undefined, { from: { kind: "peer", from: self, ...(jobId ? { job: jobId } : {}) }, channel: "" });
|
|
1327
1399
|
dispatched = inj.ok;
|
|
1328
1400
|
}
|
|
1329
1401
|
if (jobId)
|
|
1330
1402
|
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 } : {}) });
|
|
1403
|
+
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
1404
|
});
|
|
1335
1405
|
// 收掉一个 wizard。interrupt = 打断它这一轮 (Esc); end = 结束它并回收 pane。
|
|
1336
1406
|
// 自己终结自己是合法的 (分身干完活自我了结), 代价是这次工具调用不会返回 ——
|
|
@@ -1342,7 +1412,7 @@ const main = async () => {
|
|
|
1342
1412
|
return;
|
|
1343
1413
|
}
|
|
1344
1414
|
const b = body;
|
|
1345
|
-
const r = resolvePeer(self, b
|
|
1415
|
+
const r = resolvePeer(self, addrOf(b));
|
|
1346
1416
|
if (!r.ok) {
|
|
1347
1417
|
json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
|
|
1348
1418
|
return;
|
|
@@ -1390,6 +1460,9 @@ const main = async () => {
|
|
|
1390
1460
|
const idle = await waitForIdle(self, m.isBusy, 10 * 60_000, () => false);
|
|
1391
1461
|
if (!idle.idle) {
|
|
1392
1462
|
lg.warn({ reason: idle.reason }, "self-handoff: 目标一直忙, 放弃");
|
|
1463
|
+
// 之前这里只写日志: 调用方早就拿到 `scheduled:true` 挂了, 交接静悄悄
|
|
1464
|
+
// 放弃, 群里没有任何气泡告诉人。补一条 notify, 对齐它成功时的那条。
|
|
1465
|
+
notifyChat(self, withTagHeader(self, `上下文交接放弃: 一直没能闲下来 (${idle.reason}), 上下文和之前一样没动, 有空再试一次`));
|
|
1393
1466
|
return;
|
|
1394
1467
|
}
|
|
1395
1468
|
const clr = await m.injectText(self, "/clear");
|
|
@@ -1478,6 +1551,8 @@ const main = async () => {
|
|
|
1478
1551
|
// ── 定时任务 ────────────────────────────────────────────────────
|
|
1479
1552
|
// 到点把一句 prompt 注入一个 wizard 会话 —— 和人在群里对它说话走的是同一条路
|
|
1480
1553
|
// (injectText), 所以 pane 死了会被拉起来, 输出照常落进群和详情页。
|
|
1554
|
+
/** 日程归谁: 排班那个 wizard (createdBy); 老文件没写就归执行它的那个。 */
|
|
1555
|
+
const ownerOf = (t) => t.owner || (cfg.defaultChat ?? "");
|
|
1481
1556
|
http.register("POST /tasks/schedule", async (req, res) => {
|
|
1482
1557
|
const { self, body } = await readPeerBody(req);
|
|
1483
1558
|
if (!self) {
|
|
@@ -1491,15 +1566,15 @@ const main = async () => {
|
|
|
1491
1566
|
json(res, 400, { ok: false, reason: "prompt required" });
|
|
1492
1567
|
return;
|
|
1493
1568
|
}
|
|
1494
|
-
const
|
|
1495
|
-
if (!
|
|
1496
|
-
json(res, 400, { ok: false, reason:
|
|
1569
|
+
const trigger = parseTrigger(whenText, new Date());
|
|
1570
|
+
if (!trigger) {
|
|
1571
|
+
json(res, 400, { ok: false, reason: WHEN_HELP(whenText) });
|
|
1497
1572
|
return;
|
|
1498
1573
|
}
|
|
1499
1574
|
// tag 省略 = 排给调用者自己。这是最常见的用法: wizard 给自己定一个夜里跑的活。
|
|
1500
1575
|
// 注入自身在**当下**是死锁 (往正在生成的输入框里打字), 但定时是未来的事,
|
|
1501
1576
|
// 那时这一轮早已收工, 所以这里不套 /peers/send 的自我保护。
|
|
1502
|
-
const tag = (body
|
|
1577
|
+
const tag = addrOf(body).trim();
|
|
1503
1578
|
let target = self;
|
|
1504
1579
|
if (tag) {
|
|
1505
1580
|
const r = resolvePeer(self, tag);
|
|
@@ -1509,8 +1584,26 @@ const main = async () => {
|
|
|
1509
1584
|
}
|
|
1510
1585
|
target = r.target;
|
|
1511
1586
|
}
|
|
1512
|
-
|
|
1513
|
-
|
|
1587
|
+
// 到点是新建还是续用, 在**排班时**就定死。两条都指向新建:
|
|
1588
|
+
// · 没点名 wizard —— 定时的 prompt 本就要求零上下文自洽, 没理由挑一个会话挤进去;
|
|
1589
|
+
// · prompt 自己就在说「建两个 wizard 去干 xxx」—— 这时 tag 只是「在谁的聊天/
|
|
1590
|
+
// 目录下办」, 不是「在它的上下文里续」。
|
|
1591
|
+
// `fresh` 显式传入压过这两条推断 (要强行在 tag 那一轮里续就传 false)。
|
|
1592
|
+
const fresh = typeof b.fresh === "boolean" ? b.fresh : !tag || promptWantsFreshWizard(prompt);
|
|
1593
|
+
const note = (b.note ?? "").toString();
|
|
1594
|
+
const id = uniqueId(slugify((b.id ?? "").toString() || note || prompt.slice(0, 24), "task"), tasks.takenIds());
|
|
1595
|
+
const created = tasks.create({ id, trigger, prompt, target, fresh, note, createdBy: self });
|
|
1596
|
+
if ("error" in created) {
|
|
1597
|
+
json(res, 400, { ok: false, reason: created.error });
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
tasks.patchState(id, { createdAt: Date.now() });
|
|
1601
|
+
json(res, 200, {
|
|
1602
|
+
ok: true,
|
|
1603
|
+
...renderTask(created, tasks.stateOf(id)),
|
|
1604
|
+
address: peerAddress(cfg, self, target),
|
|
1605
|
+
owner: peerAddress(cfg, self, self),
|
|
1606
|
+
});
|
|
1514
1607
|
});
|
|
1515
1608
|
http.register("POST /tasks/list", async (req, res) => {
|
|
1516
1609
|
const { self, body } = await readPeerBody(req);
|
|
@@ -1520,9 +1613,20 @@ const main = async () => {
|
|
|
1520
1613
|
}
|
|
1521
1614
|
const mineOnly = body.mine === true;
|
|
1522
1615
|
const now = new Date();
|
|
1523
|
-
const
|
|
1524
|
-
|
|
1525
|
-
|
|
1616
|
+
const errs = tasks.errors();
|
|
1617
|
+
const rows = tasks.list()
|
|
1618
|
+
.filter((t) => !mineOnly || ownerOf(t) === self)
|
|
1619
|
+
.map((t) => ({
|
|
1620
|
+
...renderTask(t, tasks.stateOf(t.id), now, errs[t.id]),
|
|
1621
|
+
address: peerAddress(cfg, self, t.target || (cfg.defaultChat ?? "")),
|
|
1622
|
+
owner: peerAddress(cfg, self, ownerOf(t)),
|
|
1623
|
+
}));
|
|
1624
|
+
// 加载失败的那些没有记录可回显, 但必须出现在列表里 —— 否则 wizard 改错了
|
|
1625
|
+
// 一个字, 那条定时就像凭空消失了。
|
|
1626
|
+
const broken = Object.entries(errs)
|
|
1627
|
+
.filter(([id]) => !tasks.get(id))
|
|
1628
|
+
.map(([id, loadError]) => ({ id, file: `${tasks.dir}/${id}.task.mjs`, loadError }));
|
|
1629
|
+
json(res, 200, { ok: true, self, dir: tasks.dir, tasks: [...rows, ...broken] });
|
|
1526
1630
|
});
|
|
1527
1631
|
http.register("POST /tasks/cancel", async (req, res) => {
|
|
1528
1632
|
const { self, body } = await readPeerBody(req);
|
|
@@ -1535,12 +1639,13 @@ const main = async () => {
|
|
|
1535
1639
|
json(res, 400, { ok: false, reason: "id required (list_tasks 里那个 id)" });
|
|
1536
1640
|
return;
|
|
1537
1641
|
}
|
|
1538
|
-
const
|
|
1642
|
+
const state = tasks.stateOf(id);
|
|
1643
|
+
const gone = tasks.remove(id);
|
|
1539
1644
|
if (!gone) {
|
|
1540
1645
|
json(res, 404, { ok: false, reason: `no schedule with id ${id}` });
|
|
1541
1646
|
return;
|
|
1542
1647
|
}
|
|
1543
|
-
json(res, 200, { ok: true, removed:
|
|
1648
|
+
json(res, 200, { ok: true, removed: renderTask(gone, state) });
|
|
1544
1649
|
});
|
|
1545
1650
|
// POST /config/set — modify daemon config from MCP
|
|
1546
1651
|
http.register("POST /config/set", async (req, res) => {
|
|
@@ -1643,22 +1748,106 @@ const main = async () => {
|
|
|
1643
1748
|
}
|
|
1644
1749
|
json(res, 200, { ok: true, target, ...m.getCwd(target) });
|
|
1645
1750
|
});
|
|
1751
|
+
// 定时任务的执行体。schedule_task 的 prompt 设计上本就要求"零上下文也能执行"
|
|
1752
|
+
// (到点时目标可能早已 /clear 过, 它只看得见这一句) —— 既然如此, 默认就不在任何
|
|
1753
|
+
// 已有会话里跑: 硬塞进去会把两件不相关的事挤进同一个 transcript, 目标正忙时
|
|
1754
|
+
// 还要连触发时间点一起被它那一轮拖走。所以到点起一个白板 wizard 单独执行,
|
|
1755
|
+
// 跑完自动收掉。只有排班时点名了某个 wizard (fresh=false) 且它此刻闲着,
|
|
1756
|
+
// 才把这句话直接投进它那一轮 —— 「在已有会话里继续」是要求出来的, 不是默认。
|
|
1757
|
+
scheduledTaskInject = async (target, text, { taskId, fresh }) => {
|
|
1758
|
+
const busy = fresh ? false : await m.isBusy(target);
|
|
1759
|
+
if (!fresh && !busy)
|
|
1760
|
+
return m.injectText(target, text, undefined, { fromChat: true, from: { kind: "task", taskId } });
|
|
1761
|
+
// 执行体挂在日程的主人名下: 名字 `<主人>-task-xxxx`, parent = 主人 —— 它的
|
|
1762
|
+
// rolepage 里看得见这一枪是谁的日程放的。落在执行目标的群/目录里。
|
|
1763
|
+
const base = baseOfKey(target);
|
|
1764
|
+
const rec = tasks.get(taskId);
|
|
1765
|
+
const owner = rec ? ownerOf(rec) : target;
|
|
1766
|
+
const ownerName = settleName(wizards, chatNameOf(cfg, owner), owner) || "task";
|
|
1767
|
+
const slotR = await claimSlot(base, "", `${ownerName}-task-${taskId.slice(0, 4)}`);
|
|
1768
|
+
if (!slotR.ok)
|
|
1769
|
+
return { ok: false, reason: "起白板 wizard 失败: 挑不出槽位" };
|
|
1770
|
+
const runner = slotR.target;
|
|
1771
|
+
const info = m.sessionInfo(target);
|
|
1772
|
+
const why = busy ? ` (起因: ${displayName(target)} 当时正忙)` : "";
|
|
1773
|
+
wizards.upsert(runner, {
|
|
1774
|
+
description: `定时任务 ${taskId} 的一次性执行体${why}`,
|
|
1775
|
+
parent: owner,
|
|
1776
|
+
bornAt: Date.now(),
|
|
1777
|
+
});
|
|
1778
|
+
const runnerName = wizards.rename(runner, `${ownerName}-task-${taskId.slice(0, 4)}`);
|
|
1779
|
+
const spawned = await m.newSession(runner, runnerName, info?.cli, { cwd: info?.cwd, model: info?.model, silent: true });
|
|
1780
|
+
if (!spawned.ok) {
|
|
1781
|
+
wizards.drop(runner);
|
|
1782
|
+
return { ok: false, reason: `起白板 wizard 失败: ${spawned.reason ?? "unknown"}` };
|
|
1783
|
+
}
|
|
1784
|
+
notifyChat(base, withTagHeader(target, busy
|
|
1785
|
+
? `⏰ 定时任务到点时正忙, 已起白板 wizard .${runnerName} 单独执行, 完成后自动收掉`
|
|
1786
|
+
: `⏰ 定时任务已起白板 wizard .${runnerName} 执行, 完成后自动收掉`));
|
|
1787
|
+
const inj = await m.injectText(runner, text, undefined, { fromChat: true, from: { kind: "task", taskId } });
|
|
1788
|
+
if (!inj.ok) {
|
|
1789
|
+
wizards.drop(runner);
|
|
1790
|
+
return inj;
|
|
1791
|
+
}
|
|
1792
|
+
// 没有人会对这个一次性分身喊 stop_wizard, 只能自己等它闲下来再收。30min
|
|
1793
|
+
// 内没闲下来就放弃自动回收 (它大概率还在干一个长活), 留给人手动处理 ——
|
|
1794
|
+
// 比杀掉一个还在跑的 pane 安全。
|
|
1795
|
+
void (async () => {
|
|
1796
|
+
const idle = await waitForIdle(runner, m.isBusy, 30 * 60_000, () => false);
|
|
1797
|
+
if (!idle.idle) {
|
|
1798
|
+
log.warn({ runner, reason: idle.reason }, "task runner: 30min 未闲下来, 放弃自动回收");
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
await m.killPane(runner);
|
|
1802
|
+
wizards.drop(runner);
|
|
1803
|
+
})();
|
|
1804
|
+
return inj;
|
|
1805
|
+
};
|
|
1646
1806
|
}
|
|
1647
|
-
// 定时调度器 — 每 20s
|
|
1807
|
+
// 定时调度器 — 每 20s 检查任务表, 到点把 prompt 注入目标 wizard。
|
|
1648
1808
|
// inject 只在 mirror 模式给得出 (headless 模式没有常驻会话可注入)。
|
|
1809
|
+
migrateLegacySchedules(cfg, sourcePath, tasks, log.child({ mod: "tasks" }));
|
|
1649
1810
|
const scheduler = startScheduler({
|
|
1650
1811
|
client: ws.client,
|
|
1651
|
-
|
|
1652
|
-
sourcePath,
|
|
1812
|
+
registry: tasks,
|
|
1653
1813
|
log: log.child({ mod: "tasks" }),
|
|
1654
|
-
inject: cfg.wrc.mode === "mirror"
|
|
1655
|
-
|
|
1814
|
+
inject: cfg.wrc.mode === "mirror" ? scheduledTaskInject : undefined,
|
|
1815
|
+
// gate 里的 `sh` 默认就在目标 wizard 此刻的工作区跑 —— 任务文件里不必写死绝对路径。
|
|
1816
|
+
cwdOf: cfg.wrc.mode === "mirror"
|
|
1817
|
+
? (t) => { const c = bridge.getCwd(t); return c.runningCwd || c.pendingCwd || c.defaultCwd; }
|
|
1656
1818
|
: undefined,
|
|
1819
|
+
fallbackTarget: () => (cfg.defaultChat ?? "").trim(),
|
|
1657
1820
|
});
|
|
1821
|
+
// 闲置 pane 收割机 — 默认 48h 静默才动 (wrc.mirror.idleReapHours, 0=关)。
|
|
1822
|
+
// 只收 pane 不收绑定: 下一条消息 `--resume` 复活。豁免: 名下有定时任务的
|
|
1823
|
+
// wizard (调度本身会唤醒/需要它), 挂着审批长轮询的会话 (hook 还停在等点击),
|
|
1824
|
+
// 忙着的 / 人正盯着的 (bridge 内部判)。keepalive 会话靠 ping 刷新 transcript,
|
|
1825
|
+
// 天然到不了阈值。
|
|
1826
|
+
let reapTimer;
|
|
1827
|
+
if (cfg.wrc.mode === "mirror" && cfg.wrc.mirror.idleReapHours > 0) {
|
|
1828
|
+
const ttlMs = cfg.wrc.mirror.idleReapHours * 3.6e6;
|
|
1829
|
+
const sweep = async () => {
|
|
1830
|
+
const taskOwners = new Set(tasks.list()
|
|
1831
|
+
.filter((t) => t.enabled)
|
|
1832
|
+
.map((t) => wizardStore()?.byName(t.owner)?.target)
|
|
1833
|
+
.filter((t) => !!t));
|
|
1834
|
+
const pendingSids = new Set(listPending().map((p) => p.meta.sessionId).filter((s) => !!s));
|
|
1835
|
+
const r = await bridge.reapIdle(ttlMs, (target, sid) => taskOwners.has(target) ? "owns scheduled task"
|
|
1836
|
+
: pendingSids.has(sid) ? "pending approval"
|
|
1837
|
+
: undefined);
|
|
1838
|
+
if (r.reaped.length)
|
|
1839
|
+
log.info({ reaped: r.reaped.length, targets: r.reaped }, "idle reap sweep");
|
|
1840
|
+
};
|
|
1841
|
+
reapTimer = setInterval(() => void sweep().catch((e) => log.warn({ err: e.message }, "idle reap sweep failed")), 30 * 60_000);
|
|
1842
|
+
reapTimer.unref();
|
|
1843
|
+
}
|
|
1658
1844
|
const shutdown = async (signal) => {
|
|
1659
1845
|
log.info({ signal }, "shutdown signal");
|
|
1660
1846
|
scheduler.stop();
|
|
1847
|
+
tasks.stop();
|
|
1661
1848
|
netWatch.stop();
|
|
1849
|
+
if (reapTimer)
|
|
1850
|
+
clearInterval(reapTimer);
|
|
1662
1851
|
// 同 POST /shutdown: 先把挂着的审批长轮询了结成「稍后续接」, 再关连接。
|
|
1663
1852
|
log.info(drainForReload(), "pending drained for reload");
|
|
1664
1853
|
// Hard-exit watchdog: http.close() blocks until every in-flight connection
|