wezard 2.5.0 → 2.6.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-plugin/plugin.json +1 -1
- package/CLAUDE.md +3 -0
- package/README.md +43 -1
- package/cli/wezard.sh +9 -1
- package/config.example.jsonc +22 -5
- package/dist/cli/codex-hook.js +91 -0
- package/dist/cli/codex-hook.js.map +1 -0
- package/dist/cli/codex-sync.js +64 -0
- package/dist/cli/codex-sync.js.map +1 -0
- package/dist/cli/init.js +7 -3
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/sync.js +16 -5
- package/dist/cli/sync.js.map +1 -1
- package/dist/daemon/aibot-media.js +6 -2
- package/dist/daemon/aibot-media.js.map +1 -1
- package/dist/daemon/approval.js +44 -21
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/bot-mux.js +157 -0
- package/dist/daemon/bot-mux.js.map +1 -0
- package/dist/daemon/cc-session.js +8 -1
- package/dist/daemon/cc-session.js.map +1 -1
- package/dist/daemon/cfg-sync.js +1 -0
- package/dist/daemon/cfg-sync.js.map +1 -1
- package/dist/daemon/claim.js +45 -1
- package/dist/daemon/claim.js.map +1 -1
- package/dist/daemon/config-api.js +59 -23
- package/dist/daemon/config-api.js.map +1 -1
- package/dist/daemon/detail.js +4 -0
- package/dist/daemon/detail.js.map +1 -1
- package/dist/daemon/http.js +9 -0
- package/dist/daemon/http.js.map +1 -1
- package/dist/daemon/inbound.js +54 -28
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +181 -139
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +1 -1
- package/dist/daemon/jobs.js.map +1 -1
- package/dist/daemon/last-response.js +12 -6
- package/dist/daemon/last-response.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +351 -144
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/peers.js +12 -16
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/picker-policy.js +46 -0
- package/dist/daemon/picker-policy.js.map +1 -0
- package/dist/daemon/quota.js +10 -7
- package/dist/daemon/quota.js.map +1 -1
- package/dist/daemon/reach.js +5 -1
- package/dist/daemon/reach.js.map +1 -1
- package/dist/daemon/receipts.js +1 -1
- package/dist/daemon/receipts.js.map +1 -1
- package/dist/daemon/route.js +67 -26
- package/dist/daemon/route.js.map +1 -1
- package/dist/daemon/session-scan.js +34 -10
- package/dist/daemon/session-scan.js.map +1 -1
- package/dist/daemon/spawn-tmux.js +181 -76
- package/dist/daemon/spawn-tmux.js.map +1 -1
- package/dist/daemon/steward-model.js +24 -0
- package/dist/daemon/steward-model.js.map +1 -0
- package/dist/daemon/subagent-tail.js +5 -0
- package/dist/daemon/subagent-tail.js.map +1 -1
- package/dist/daemon/task-registry.js +6 -1
- package/dist/daemon/task-registry.js.map +1 -1
- package/dist/daemon/tasks.js +1 -1
- package/dist/daemon/tasks.js.map +1 -1
- package/dist/daemon/tips.js +1 -1
- package/dist/daemon/tips.js.map +1 -1
- package/dist/daemon/wizard-memory.js +12 -6
- package/dist/daemon/wizard-memory.js.map +1 -1
- package/dist/daemon/wizard.js +7 -5
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/daemon/ws.js +79 -10
- package/dist/daemon/ws.js.map +1 -1
- package/dist/mcp/server.js +19 -18
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/chat-http.js +63 -29
- package/dist/shared/chat-http.js.map +1 -1
- package/dist/shared/chat-view.js +10 -3
- package/dist/shared/chat-view.js.map +1 -1
- package/dist/shared/cli-backends.js +11 -0
- package/dist/shared/cli-backends.js.map +1 -1
- package/dist/shared/codex-approval.js +57 -0
- package/dist/shared/codex-approval.js.map +1 -0
- package/dist/shared/codex-hooks.js +134 -0
- package/dist/shared/codex-hooks.js.map +1 -0
- package/dist/shared/codex.js +125 -0
- package/dist/shared/codex.js.map +1 -0
- package/dist/shared/config.js +85 -21
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/detail-render.js +24 -16
- package/dist/shared/detail-render.js.map +1 -1
- package/dist/shared/detail-store.js +11 -10
- package/dist/shared/detail-store.js.map +1 -1
- package/dist/shared/exec-path.js +12 -0
- package/dist/shared/exec-path.js.map +1 -1
- package/dist/shared/modal-pane.js +19 -1
- package/dist/shared/modal-pane.js.map +1 -1
- package/dist/shared/model-tier-migration.js +43 -0
- package/dist/shared/model-tier-migration.js.map +1 -0
- package/dist/shared/model-tiers.js +35 -0
- package/dist/shared/model-tiers.js.map +1 -0
- package/dist/shared/reminder.js +19 -4
- package/dist/shared/reminder.js.map +1 -1
- package/dist/shared/session-label.js +27 -2
- package/dist/shared/session-label.js.map +1 -1
- package/dist/shared/tool-hints.js +144 -0
- package/dist/shared/tool-hints.js.map +1 -0
- package/dist/shared/workspace-trust.js +69 -0
- package/dist/shared/workspace-trust.js.map +1 -0
- package/dist/shared/world.js +5 -4
- package/dist/shared/world.js.map +1 -1
- package/dist/svr/index.js +3 -0
- package/dist/svr/index.js.map +1 -1
- package/hooks/pre-tool-use.sh +1 -1
- package/package.json +6 -1
package/dist/daemon/index.js
CHANGED
|
@@ -5,13 +5,14 @@ import { timingSafeEqual } from "node:crypto";
|
|
|
5
5
|
import { loadOrCreateSvrToken } from "../shared/svr-token.js";
|
|
6
6
|
import { DAEMON_TOKEN_FILE, DAEMON_TOKEN_HEADER } from "../shared/daemon-token.js";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
|
-
import { TIERS, loadConfig } from "../shared/config.js";
|
|
8
|
+
import { TIERS, defaultChatFor, tiersForBackend, loadConfig } from "../shared/config.js";
|
|
9
9
|
import { makeLogger } from "../shared/log.js";
|
|
10
10
|
import { clipLine, sleep, truncate } from "../shared/std.js";
|
|
11
11
|
import { patchJsonc } from "../shared/config-writer.js";
|
|
12
12
|
import { EFFORTS, parseEffort } from "../shared/effort.js";
|
|
13
13
|
import { bindCliBackends, projectDirsFor } from "../shared/cli-backends.js";
|
|
14
|
-
import {
|
|
14
|
+
import { bindCodexStateDir } from "../shared/codex.js";
|
|
15
|
+
import { startMux } from "./bot-mux.js";
|
|
15
16
|
import { startWeixin } from "./weixin.js";
|
|
16
17
|
import { installWeixinPort } from "./weixin-port.js";
|
|
17
18
|
import { bindWxCommands, makeOnBound, makeWxCommands, routeSendMedia, wecomBindSink, weixinSendMedia, wxAdmins, wxRoutes } from "./weixin-cmd.js";
|
|
@@ -20,13 +21,13 @@ import { aibotSendMedia } from "./aibot-media.js";
|
|
|
20
21
|
import { startNetWatch } from "./net-watch.js";
|
|
21
22
|
import { startHttp, json, readBody } from "./http.js";
|
|
22
23
|
import { parsePath } from "../shared/config-meta.js";
|
|
23
|
-
import { applyHot, patchedTop, configGet, fromLegacy, legacyGet, pickTier, planSet, renderPlan, tierEvidence } from "./config-api.js";
|
|
24
|
+
import { applyHot, cfgSeenBy, patchedTop, pinChatKeys, configGet, fromLegacy, legacyGet, pickTier, planSet, renderPlan, tierEvidence } from "./config-api.js";
|
|
24
25
|
import { computeUsage, costOf, fmtCost, fmtTokens, sumTotal } from "./usage.js";
|
|
25
26
|
import { installInboundRouter } from "./inbound.js";
|
|
26
27
|
import { loadMirrorStore } from "./mirror-store.js";
|
|
27
28
|
import { startMirror, installMirrorEventListener } from "./mirror-bridge.js";
|
|
28
|
-
import { runTmux, setTmuxTimeoutReporter, spawnTmuxClaude } from "./spawn-tmux.js";
|
|
29
|
-
import { installApprovalEventListener, makeApproveHandler, requestConfirm } from "./approval.js";
|
|
29
|
+
import { backendFor, runTmux, setTmuxTimeoutReporter, spawnTmuxClaude } from "./spawn-tmux.js";
|
|
30
|
+
import { bindCardChannel, installApprovalEventListener, makeApproveHandler, requestConfirm } from "./approval.js";
|
|
30
31
|
import { initDetailPersistence, makeDetailHandler, chatHandlers, configureRemoteForward, chatUrlFor, setWorldFactsProvider, currentHumanQueryOf, recordPost, recordCharter, backfillCharter } from "./detail.js";
|
|
31
32
|
import { EMPTY_FACTS } from "../shared/world.js";
|
|
32
33
|
import { clearAutoWindow, initAutoWindowPersistence, setAutoWindow } from "./session-cache.js";
|
|
@@ -41,7 +42,7 @@ import { startScheduler, migrateLegacySchedules, promptWantsFreshWizard, renderT
|
|
|
41
42
|
import { openTaskRegistry } from "./task-registry.js";
|
|
42
43
|
import { describeTrigger, nextFire, parseTrigger, WHEN_HELP } from "../shared/trigger.js";
|
|
43
44
|
import { isQuiet, slugify, uniqueId } from "../shared/task-file.js";
|
|
44
|
-
import { baseOfKey, bindTagLinker, headed, ownHead, isInternalKey, keyOf, linkTags, normalizeTag, speakerHead, tagFromCwd, tagHead, tagLink, tagOfKey, uniqueTag, withTagHeader } from "../shared/session-label.js";
|
|
45
|
+
import { baseOfKey, bindMultiBot, bindTagLinker, botOfKey, headed, ownHead, isInternalKey, keyOf, linkTags, normalizeTag, sameBot, speakerHead, tagFromCwd, tagHead, tagLink, tagOfKey, uniqueTag, withTagHeader } from "../shared/session-label.js";
|
|
45
46
|
import { appendEpisode, clipForCharter, cwdOfMd, episodePath, inboxPath, mdsOf, proposedCwds, memoryPath, memoryRoot, proposeMemory, readMemory } from "./wizard-memory.js";
|
|
46
47
|
import { retireStewardTask, startSteward, stewardEnvelope, STEWARD_ID, STEWARD_RUN_MS, STEWARD_TARGET } from "./memory-steward.js";
|
|
47
48
|
import { applyChatNames, chatBaseOf, chatNameOf, chatPolicyOf, clearChatName, listChatNames, normChatName, peerAddress, planChatNames, setChatName, stewardTierOf } from "./chat-name.js";
|
|
@@ -58,9 +59,9 @@ import { loadJsonMap } from "../shared/json-map-store.js";
|
|
|
58
59
|
import { createReceipts, deadlineOf, kAttr, kOfAttr, newTurn } from "./receipts.js";
|
|
59
60
|
import { renderInFlight } from "../shared/turn-state.js";
|
|
60
61
|
import { startIdleNudge } from "./idle-nudge.js";
|
|
61
|
-
import { renderReminder } from "../shared/reminder.js";
|
|
62
|
+
import { askerFromEnvelope, renderReminder } from "../shared/reminder.js";
|
|
62
63
|
import { createHandoffs, handedOff, handingOff } from "./handoff.js";
|
|
63
|
-
import { coldGateOf, decide, rankCandidates, renderCandidates, tierFor, wakeCostOf, wakeNoteOf } from "./route.js";
|
|
64
|
+
import { coldGateOf, decide, rankCandidates, renderCandidates, routeDutiesOf, tierFor, wakeCostOf, wakeNoteOf } from "./route.js";
|
|
64
65
|
import { parseWhen, renderChatLog, UNKNOWN_HUMAN } from "./chat-log.js";
|
|
65
66
|
import { audienceOf } from "../shared/role-view.js";
|
|
66
67
|
import { startGraph, stopRun, getRun, listRuns, validateSpec, waitForIdle, waitForQuorum, } from "./graph.js";
|
|
@@ -73,6 +74,7 @@ const fatalExit = (msg, extra) => {
|
|
|
73
74
|
};
|
|
74
75
|
const main = async () => {
|
|
75
76
|
const { config: cfg, sourcePath } = loadConfig();
|
|
77
|
+
bindMultiBot(cfg.bots.length > 0);
|
|
76
78
|
const log = makeLogger({
|
|
77
79
|
logFile: cfg.daemon.logFile,
|
|
78
80
|
logLevel: cfg.daemon.logLevel,
|
|
@@ -91,6 +93,7 @@ const main = async () => {
|
|
|
91
93
|
// derives its dialect from its own jsonl path. Must run BEFORE any mirror
|
|
92
94
|
// attach / tmux spawn.
|
|
93
95
|
const { primary, backends } = bindCliBackends({ ...cfg.wrc, projectsDirOverride: cfg.wrc.mirror.projectsDir });
|
|
96
|
+
bindCodexStateDir(cfg.daemon.stateDir);
|
|
94
97
|
log.info({ primary: primary.name, bin: primary.bin, backends: backends.map((b) => b.name) }, "cli backends bound");
|
|
95
98
|
// A killed-on-timeout tmux call is the ONLY externally visible symptom of a
|
|
96
99
|
// wedged tmux server, and the exec helper has no logger of its own. Wire it
|
|
@@ -103,7 +106,9 @@ const main = async () => {
|
|
|
103
106
|
// links from messages already on the user's WeCom timeline.
|
|
104
107
|
initDetailPersistence(cfg.daemon.stateDir, log.child({ mod: "detail" }));
|
|
105
108
|
configureRemoteForward(cfg.daemon.detailRemoteBase, cfg.daemon.detailRemoteToken);
|
|
106
|
-
|
|
109
|
+
// 一个或多个企微机器人 (bot + bots): 对下面所有模块只露主机器人那个 client, 分流在 bot-mux 里。
|
|
110
|
+
// 必须先于 weixin-port / response-tracker 建好 —— 它们包在 mux 外层。
|
|
111
|
+
const ws = startMux(cfg, log);
|
|
107
112
|
// 切网 (换 WiFi / VPN 起停) 后原地重建 WS, 等价于自动做了一次 reload 的
|
|
108
113
|
// 联通性部分 — 但保留全部内存态 (graph 运行、pending 长轮询、镜像绑定),
|
|
109
114
|
// 也不依赖 launchd/systemd 的 respawn 策略 (nohup fallback 没有 supervisor)。
|
|
@@ -166,9 +171,9 @@ const main = async () => {
|
|
|
166
171
|
// 名册在下面的 mirror 块里才绑 (bindWizardStore) —— 这里全走 wizardStore() 惰性取。
|
|
167
172
|
bindTagLinker({
|
|
168
173
|
urlOf: (t) => chatUrlFor(cfg.daemon, t),
|
|
169
|
-
resolve: (name) => {
|
|
174
|
+
resolve: (name, from) => {
|
|
170
175
|
const t = wizardStore()?.byName(name)?.target;
|
|
171
|
-
return t && known(t) ? t : undefined;
|
|
176
|
+
return t && known(t) && sameBot(from, t) ? t : undefined;
|
|
172
177
|
},
|
|
173
178
|
nameOf: (t) => settleName(wizardStore(), chatNameOf(cfg, t), t),
|
|
174
179
|
voiceOf: (t) => wizardStore()?.get(t)?.voiceOf,
|
|
@@ -334,7 +339,7 @@ const main = async () => {
|
|
|
334
339
|
return;
|
|
335
340
|
}
|
|
336
341
|
const spawnLog = log.child({ mod: "mirror", sub: "spawn-init", target });
|
|
337
|
-
const r = await spawnTmuxClaude({ cfg, log: spawnLog, windowName: target, systemPrompt: charterFor(target, {}) });
|
|
342
|
+
const r = await spawnTmuxClaude({ cfg, log: spawnLog, windowName: target, systemPrompt: charterFor(target, { cli: backendFor(cfg).name }) });
|
|
338
343
|
if (!r.ok) {
|
|
339
344
|
json(res, 500, { ok: false, reason: r.reason });
|
|
340
345
|
return;
|
|
@@ -342,21 +347,16 @@ const main = async () => {
|
|
|
342
347
|
const att = m.attach({ sessionId: r.sessionId, jsonlPath: r.jsonlPath, target, tmuxPane: r.tmuxPane, tmuxSession: r.tmuxSession });
|
|
343
348
|
json(res, att.ok ? 200 : 500, att.ok ? { ok: true, sessionId: r.sessionId, tmuxSession: r.tmuxSession, tmuxPane: r.tmuxPane, target } : { ok: false, reason: att.reason });
|
|
344
349
|
});
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
// → tmuxPane (stable across `/clear`, which rotates sessionId) → the
|
|
348
|
-
// configured defaultChat. Same precedence as /mirror/workspace.
|
|
350
|
+
// A missing agent identity is an error, never an invitation to impersonate defaultChat.
|
|
351
|
+
// Pane wins across native session switches; the MCP session id is a spawn-time snapshot.
|
|
349
352
|
const resolveSelf = (b) => {
|
|
350
353
|
const explicit = (b.target ?? "").trim();
|
|
351
354
|
if (explicit)
|
|
352
355
|
return explicit;
|
|
353
|
-
const
|
|
354
|
-
if (
|
|
355
|
-
return
|
|
356
|
-
|
|
357
|
-
if (byPane)
|
|
358
|
-
return byPane;
|
|
359
|
-
return (cfg.defaultChat ?? "").trim();
|
|
356
|
+
const pane = b.tmuxPane?.trim();
|
|
357
|
+
if (pane)
|
|
358
|
+
return m.targetForPane(pane) ?? "";
|
|
359
|
+
return b.sessionId?.trim() ? m.targetForSession(b.sessionId.trim()) ?? "" : "";
|
|
360
360
|
};
|
|
361
361
|
// ── Session discovery / switching (conversational, via MCP tools) ───────
|
|
362
362
|
// GET /sessions/list — enumerate live claude sessions in tmux + a one-line
|
|
@@ -395,7 +395,8 @@ const main = async () => {
|
|
|
395
395
|
return;
|
|
396
396
|
}
|
|
397
397
|
const sessions = await scanClaudeSessions();
|
|
398
|
-
|
|
398
|
+
// 已绑在别的机器人聊天上的会话与不存在同形。
|
|
399
|
+
const hit = sessions.find((s) => s.sessionId === sessionId && ((t) => !t || sameBot(target, t))(m.targetForSession(s.sessionId)));
|
|
399
400
|
if (!hit) {
|
|
400
401
|
json(res, 404, { ok: false, reason: `session ${sessionId} not found among live tmux sessions` });
|
|
401
402
|
return;
|
|
@@ -437,16 +438,16 @@ const main = async () => {
|
|
|
437
438
|
}
|
|
438
439
|
const self = resolveSelf(body);
|
|
439
440
|
if (!self) {
|
|
440
|
-
json(res, 400, { ok: false, reason: "cannot resolve caller chat (pass target/sessionId/tmuxPane
|
|
441
|
+
json(res, 400, { ok: false, reason: "cannot resolve caller chat (pass target/sessionId/tmuxPane)" });
|
|
441
442
|
return;
|
|
442
443
|
}
|
|
443
444
|
const wantChat = (body.chat ?? "").toString().trim();
|
|
444
|
-
const base = wantChat ? chatBaseOf(cfg, wantChat) : baseOfKey(self);
|
|
445
|
+
const base = ((b) => (b && sameBot(self, b) ? b : ""))(wantChat ? chatBaseOf(cfg, wantChat) : baseOfKey(self));
|
|
445
446
|
if (!base) {
|
|
446
447
|
json(res, 404, {
|
|
447
448
|
ok: false,
|
|
448
449
|
reason: `unknown chat '${wantChat}' — only NAMED chats can be spawned into; have someone run \`/name ${normChatName(wantChat) || "<name>"}\` there first`,
|
|
449
|
-
candidates:
|
|
450
|
+
candidates: chatNamesFor(self),
|
|
450
451
|
});
|
|
451
452
|
return;
|
|
452
453
|
}
|
|
@@ -508,29 +509,14 @@ const main = async () => {
|
|
|
508
509
|
// One-shot project switch: bind the chat-wide cwd AND apply it right here
|
|
509
510
|
// by walking the exact /new path (kill pane → respawn in pendingCwd →
|
|
510
511
|
// attach → "📂 当前项目" push). Resolved target
|
|
511
|
-
// precedence
|
|
512
|
-
// it) → tmuxPane (stable across /clear) → cfg.defaultChat. When the caller
|
|
512
|
+
// precedence follows resolveSelf (explicit target, then stable pane, then session). When the caller
|
|
513
513
|
// IS the session being replaced, its pane dies mid-tool-call — the
|
|
514
514
|
// project-info bubble is the receipt. On spawn failure the pendingCwd
|
|
515
515
|
// stays queued, so a manual /new from WeCom still completes the switch.
|
|
516
516
|
http.register("POST /mirror/workspace", async (req, res) => {
|
|
517
517
|
const body = (await readBody(req));
|
|
518
518
|
const cwd = (body.cwd ?? "").toString();
|
|
519
|
-
|
|
520
|
-
if (!target && body.sessionId) {
|
|
521
|
-
const t = m.targetForSession(body.sessionId.trim());
|
|
522
|
-
if (t)
|
|
523
|
-
target = t;
|
|
524
|
-
}
|
|
525
|
-
// sessionId rotates on /clear; the MCP caller's env sessionId may be stale.
|
|
526
|
-
// Pane id is stable, so resolve by it before falling back to defaultChat.
|
|
527
|
-
if (!target && body.tmuxPane) {
|
|
528
|
-
const t = m.targetForPane(body.tmuxPane.trim());
|
|
529
|
-
if (t)
|
|
530
|
-
target = t;
|
|
531
|
-
}
|
|
532
|
-
if (!target)
|
|
533
|
-
target = (cfg.defaultChat ?? "").trim();
|
|
519
|
+
const target = resolveSelf(body);
|
|
534
520
|
if (!target) {
|
|
535
521
|
json(res, 400, { ok: false, reason: "target required (or pass sessionId of an attached chat)" });
|
|
536
522
|
return;
|
|
@@ -567,12 +553,18 @@ const main = async () => {
|
|
|
567
553
|
// 地址语义见 mirror-bridge.resolvePeerTag: "" = 本聊天的默认 wizard (它是正当的
|
|
568
554
|
// 协作对象 ——「回去跟主会话汇报」); 裸 `fix` 先找本聊天、再退回全机唯一的那个;
|
|
569
555
|
// `daily#fix` 直接指名聊天, 不要求任何唯一性。
|
|
556
|
+
// 多机器人隔离: 别的机器人名下的 wizard 由 resolvePeerTag 当作查无此名 (同形、candidates 只列
|
|
557
|
+
// 同机器人的) —— 点名寻址 (peek / tell / wait / stop / clone from / handoff / set_model / pending /
|
|
558
|
+
// schedule) 全过这一道。
|
|
570
559
|
const resolvePeer = (self, tag) => {
|
|
571
560
|
const r = m.resolvePeerTag(self, tag);
|
|
572
561
|
if (r.ok)
|
|
573
562
|
return r;
|
|
574
563
|
return { ok: false, status: 404, reason: r.reason, candidates: r.candidates };
|
|
575
564
|
};
|
|
565
|
+
/** 聊天名候选只列与调用方同机器人的 —— 别的机器人的聊天名也是它的存在。指名别的机器人的聊天,
|
|
566
|
+
* 各路由一律走它们自己「认不出这个聊天」的分支 (同状态码同原话), 不说「那边有, 但归别人」。 */
|
|
567
|
+
const chatNamesFor = (self) => listChatNames(cfg).filter((c) => sameBot(self, c.base)).map((c) => c.name);
|
|
576
568
|
// ── Chat naming ────────────────────────────────────────────────────
|
|
577
569
|
// A WeCom chat's identity is an unreadable `chat:wrkS…` id. Naming it is
|
|
578
570
|
// what makes cross-chat addressing (`daily#fix`) and cross-chat spawning
|
|
@@ -581,7 +573,7 @@ const main = async () => {
|
|
|
581
573
|
const { self } = await readPeerBody(req);
|
|
582
574
|
if (self)
|
|
583
575
|
ensureChatNames(self);
|
|
584
|
-
const roster = self ? m.chatRoster(self) : listChatNames(cfg).map((c) => ({ ...c, self: false, targets: [] }));
|
|
576
|
+
const roster = self ? m.chatRoster(self).filter((c) => sameBot(self, c.base)) : listChatNames(cfg).map((c) => ({ ...c, self: false, targets: [] }));
|
|
585
577
|
json(res, 200, {
|
|
586
578
|
ok: true,
|
|
587
579
|
self,
|
|
@@ -735,7 +727,7 @@ const main = async () => {
|
|
|
735
727
|
rebound: async (x, why) => (await m.injectText(x.to, `(守护进程代 ${displayName(x.from)} 打回一次: 这件活是工单里的一份, 你的回复${why}。请按约定收口 —— 末尾一行 \`RESULT: <结论>\`, 交付了文件每个加一行 \`ARTIFACT: <路径> — 一句话\`; 真缺信息就 \`NEED: <问题>\`。)`, undefined, {
|
|
736
728
|
from: { kind: "peer", from: x.from, turn: x.turn, ...(x.asker ? { asker: x.asker } : {}) },
|
|
737
729
|
channel: x.channel,
|
|
738
|
-
envelope: envelopeFor(x.from, x.channel, { turn: x.turn, legs: x.legs, re: true }),
|
|
730
|
+
envelope: envelopeFor(x.from, x.channel, { turn: x.turn, legs: x.legs, re: true, asker: x.asker }),
|
|
739
731
|
})).ok,
|
|
740
732
|
untilIdle: m.untilIdle,
|
|
741
733
|
paneLive: m.paneLive,
|
|
@@ -760,6 +752,7 @@ const main = async () => {
|
|
|
760
752
|
envelope: renderReceiptEnvelope(displayName(meta.from), meta.channel ? chatNameOf(cfg, meta.channel) : undefined, meta.job ? { job: meta.job, done: meta.done, total: meta.total, closed: jobs.get(meta.job)?.status !== "open", ...(jobs.get(meta.job)?.kind === "req" ? { req: true } : {}) } : undefined, meta.status, meta.turn || undefined, {
|
|
761
753
|
...(meta.replyTo ? { replyTo: meta.replyTo.kind === "chat" ? chatNameOf(cfg, meta.replyTo.channel) : displayName(meta.replyTo.from) } : {}),
|
|
762
754
|
...(meta.k ? { k: kAttr(meta.k) } : {}),
|
|
755
|
+
...(meta.asker ? { asker: meta.asker } : {}),
|
|
763
756
|
...(meta.pending ? { pending: meta.pending } : {}),
|
|
764
757
|
...(audienceName(meta.channel, meta.asker) ? { audience: audienceName(meta.channel, meta.asker) } : {}),
|
|
765
758
|
}),
|
|
@@ -815,7 +808,7 @@ const main = async () => {
|
|
|
815
808
|
return {
|
|
816
809
|
from: { kind: "peer", from: o.from, ...(o.turn ? { turn: o.turn } : {}), ...(o.channel ? { public: true } : {}), ...(o.asker ? { asker: o.asker } : {}) },
|
|
817
810
|
channel: o.channel,
|
|
818
|
-
envelope: envelopeFor(o.from, o.channel, o.turn
|
|
811
|
+
envelope: envelopeFor(o.from, o.channel, { turn: o.turn ?? "", asker: o.asker }),
|
|
819
812
|
};
|
|
820
813
|
}
|
|
821
814
|
/** 简报那一轮之后, 余下的欠账一份一轮: 等它接住上一句 (至多 20s 动起来) 并答完, 再贴下一份。 */
|
|
@@ -873,13 +866,12 @@ const main = async () => {
|
|
|
873
866
|
const env = openingOfSelf(self)?.env;
|
|
874
867
|
const home = baseOfKey(self);
|
|
875
868
|
if (!env)
|
|
876
|
-
return
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
return chat ? { who: `user:${env.from}`, chat } : undefined;
|
|
880
|
-
}
|
|
881
|
-
return env.kind === "peer" ? receipts.askerOf(self, env) : undefined;
|
|
869
|
+
return m.currentAsker(self);
|
|
870
|
+
return askerFromEnvelope(env, (chat) => chat ? chatBaseOf(cfg, chat) : home)
|
|
871
|
+
?? (env.kind === "peer" ? receipts.askerOf(self, env) : undefined);
|
|
882
872
|
};
|
|
873
|
+
// A private dispatch keeps its human origin even though its output channel is empty.
|
|
874
|
+
bindCardChannel((key) => askerOf(key)?.chat || m.currentChannel(key));
|
|
883
875
|
// 交接先续做: 它的闸要在回执续守之前立起来, 否则续守的 watcher 会读到换了一半的会话。
|
|
884
876
|
void m.restored.then(() => { handoffs.resume(); receipts.resume(); });
|
|
885
877
|
// 闲置追问 (见 idle-nudge.ts): 对方持续闲着又没回执 → 代发话方同件号问一句进展, 答复照常回执。
|
|
@@ -898,7 +890,7 @@ const main = async () => {
|
|
|
898
890
|
return (await m.injectText(to, text, undefined, {
|
|
899
891
|
from: { kind: "peer", from, turn, ...(x.asker ? { asker: x.asker } : {}) },
|
|
900
892
|
channel: x.channel,
|
|
901
|
-
envelope: envelopeFor(from, x.channel, { turn, legs: x.legs, re: true }),
|
|
893
|
+
envelope: envelopeFor(from, x.channel, { turn, legs: x.legs, re: true, asker: x.asker }),
|
|
902
894
|
})).ok;
|
|
903
895
|
},
|
|
904
896
|
nameOf: displayName,
|
|
@@ -927,6 +919,11 @@ const main = async () => {
|
|
|
927
919
|
const claimSlot = async (base, want, fallback, keep = []) => {
|
|
928
920
|
const asked = normalizeTag(want);
|
|
929
921
|
const clash = asked ? wizards.byName(asked) : undefined;
|
|
922
|
+
// 名字全机唯一, 撞上别的机器人的 wizard 照样拒 —— 但不顶掉它、不复用它的槽, 也不报它的死活:
|
|
923
|
+
// 那边的 wizard 在这个机器人眼里不存在, 只剩「名字被占了」这一个事实。
|
|
924
|
+
if (clash && !sameBot(base, clash.target)) {
|
|
925
|
+
return { ok: false, status: 409, body: { ok: false, reason: `名字 '.${clash.name}' 已被占用 (名字全局唯一) —— 换个名字`, name: clash.name } };
|
|
926
|
+
}
|
|
930
927
|
const bound = !!clash && m.chatTargets(baseOfKey(clash.target)).includes(clash.target);
|
|
931
928
|
if (clash && !bound && baseOfKey(clash.target) === base && tagOfKey(clash.target)) {
|
|
932
929
|
return { ok: true, target: clash.target, slot: tagOfKey(clash.target) };
|
|
@@ -996,7 +993,7 @@ const main = async () => {
|
|
|
996
993
|
// 认不出的名字原样报回去, 绝不静默跳过 ——「发了但没人收到」是这类工具最难查
|
|
997
994
|
// 的故障; 有一个收件人错了整条就不发, 部分送达比不送达更难对账。
|
|
998
995
|
const refs = (b.to ?? []).map((r) => (r ?? "").trim()).filter(Boolean);
|
|
999
|
-
const bad = refs.filter((r) => !chatBaseOf(cfg, r));
|
|
996
|
+
const bad = refs.filter((r) => ((b) => !b || !sameBot(self, b))(chatBaseOf(cfg, r)));
|
|
1000
997
|
if (bad.length) {
|
|
1001
998
|
json(res, 400, { ok: false, reason: `认不出这些聊天: ${bad.join(", ")} (wizard_roster 每行有 home 聊天名, 人侧 /chats; 没起名的聊天寻址不到)` });
|
|
1002
999
|
return;
|
|
@@ -1040,7 +1037,7 @@ const main = async () => {
|
|
|
1040
1037
|
ensureChatNames(self);
|
|
1041
1038
|
const ref = (b.to ?? "").trim();
|
|
1042
1039
|
const dest = ref ? chatBaseOf(cfg, ref) : channelOf(self);
|
|
1043
|
-
if (!dest) {
|
|
1040
|
+
if (!dest || !sameBot(self, dest)) {
|
|
1044
1041
|
json(res, 400, { ok: false, reason: `认不出聊天: ${ref} (wizard_roster 每行有 home 聊天名, 人侧 /chats; 没起名的聊天寻址不到)` });
|
|
1045
1042
|
return;
|
|
1046
1043
|
}
|
|
@@ -1137,7 +1134,8 @@ const main = async () => {
|
|
|
1137
1134
|
const [roleRef, chatRef, targetRef, sinceRef, untilRef] = [b.role, b.chat, b.with, b.since, b.until].map((x) => (x ?? "").toString().trim());
|
|
1138
1135
|
// 什么都没给 = 调用方这一轮所在的群; 只给 target = 调用方与它的往来。
|
|
1139
1136
|
const base = chatRef ? chatBaseOf(cfg, chatRef) : roleRef || targetRef ? "" : channelOf(self);
|
|
1140
|
-
|
|
1137
|
+
// 别的机器人的聊天与认不出同形 (不说它在)。
|
|
1138
|
+
if (chatRef && (!base || !sameBot(self, base))) {
|
|
1141
1139
|
json(res, 404, { ok: false, reason: `认不出聊天 '${chatRef}' (wizard_roster 每行有 home 聊天名, 人侧 /chats)` });
|
|
1142
1140
|
return;
|
|
1143
1141
|
}
|
|
@@ -1152,7 +1150,8 @@ const main = async () => {
|
|
|
1152
1150
|
const limit = Math.min(Math.max(Number(b.limit ?? 30) || 30, 1), 200);
|
|
1153
1151
|
const per = Math.min(Math.max(Number(b.per ?? 500) || 500, 40), 4000);
|
|
1154
1152
|
const pinned = new Set([self, role?.target, target?.target]);
|
|
1155
|
-
|
|
1153
|
+
// 只读与调用方同机器人的会话 —— 别的机器人那边的往来连条数都不该露出来。
|
|
1154
|
+
const all = m.transcripts().filter((t) => sameBot(self, t.target));
|
|
1156
1155
|
// 两方之间的话只可能落在这两方里的 wizard 自己的 transcript 里 (人没有 transcript)
|
|
1157
1156
|
// —— 不必去翻别的会话, 「往回还有没有」也因此答得准。
|
|
1158
1157
|
const between = [role?.target, target?.target].filter((t) => !!t);
|
|
@@ -1234,7 +1233,10 @@ const main = async () => {
|
|
|
1234
1233
|
// 件号先于一切: `re` 续问沿用那件活的件号、工单与频道 (见 receipts.prepare)。
|
|
1235
1234
|
const re = (body.re ?? "").toString().trim();
|
|
1236
1235
|
// 放行一件停放着的 (after): 件号 / 频道 / 父 k / 链头都沿用停放那一刻定的 —— 依赖边不改 k。
|
|
1237
|
-
const turn = held ? { turn: held.turn, legs: 1, channel: held.channel } : receipts.prepare(self, target, re || undefined);
|
|
1236
|
+
const turn = held ? { turn: held.turn, legs: 1, channel: held.channel, asker: held.asker, k: held.k } : receipts.prepare(self, target, re || undefined);
|
|
1237
|
+
// Capture the origin before waiting for another pane; our own turn may change meanwhile.
|
|
1238
|
+
const asker = held ? held.asker : turn.legs > 1 ? turn.asker : askerOf(self);
|
|
1239
|
+
const k = held ? held.k : body.chain === false ? undefined : turn.legs > 1 ? turn.k : parentKOf(self);
|
|
1238
1240
|
// 续问只在那个工单还开着时沿用它: 收了工的工单不再收成员, 续问照样能发。
|
|
1239
1241
|
// 没带工单的一句顶掉一份还没落定的工单活, 也算在那张工单上 (receipts.register 同理
|
|
1240
1242
|
// 继承): 不然换个不带 job 的 tell_peer 就绕开了预算。
|
|
@@ -1337,11 +1339,9 @@ const main = async () => {
|
|
|
1337
1339
|
// 放行时原样沿用; 工单成员与预算也现在就记, 「齐了吗」照常数。
|
|
1338
1340
|
if (parkIt) {
|
|
1339
1341
|
const bp = body;
|
|
1340
|
-
const pk = bp.chain === false ? undefined : parentKOf(self);
|
|
1341
|
-
const pasker = askerOf(self);
|
|
1342
1342
|
receipts.hold({
|
|
1343
1343
|
from: self, to: target, turn: turn.turn, at: Date.now(), after: afters, channel, job: jobId,
|
|
1344
|
-
...(
|
|
1344
|
+
...(k ? { k } : {}), ...(asker ? { asker } : {}),
|
|
1345
1345
|
// force: 冷门控上面已过一遍, 放行时缓存又凉了也不再退回。
|
|
1346
1346
|
body: { name: peerAddress(cfg, self, target), text, kind, force: true, ...(jobId ? { job: jobId } : {}), ...(bp.role ? { role: bp.role } : {}), ...(bp.priority ? { priority: bp.priority } : {}), ...(bp.waitSec ? { waitSec: bp.waitSec } : {}), ...(bp.deadline !== undefined ? { deadline: bp.deadline } : {}), ...(bp.receipt === false ? { receipt: false } : {}) },
|
|
1347
1347
|
});
|
|
@@ -1390,12 +1390,12 @@ const main = async () => {
|
|
|
1390
1390
|
// 是"放出去就不管了"的那种派活; fyi 一定不要。对方的信封据此改口, 不再让它收口。
|
|
1391
1391
|
const wantReceipt = kind !== "fyi" && body.receipt !== false;
|
|
1392
1392
|
const deadlineAt = deadlineOf(at, deadlineSec === undefined ? undefined : Number(deadlineSec));
|
|
1393
|
-
const asker = held ? held.asker : askerOf(self);
|
|
1394
1393
|
const inj = await m.injectText(target, text, undefined, {
|
|
1395
1394
|
from: { kind: "peer", from: self, turn: turn.turn, ...(jobId ? { job: jobId } : {}), ...(isPublic ? { public: true } : {}), ...(asker ? { asker } : {}) },
|
|
1396
1395
|
channel,
|
|
1397
1396
|
envelope: envelopeFor(self, channel, {
|
|
1398
1397
|
turn: turn.turn,
|
|
1398
|
+
asker,
|
|
1399
1399
|
legs: turn.legs,
|
|
1400
1400
|
...(turn.legs > 1 ? { re: true } : {}),
|
|
1401
1401
|
// 期限只在发话方明说时写给对方看: 默认的一小时不值得每轮多一句。
|
|
@@ -1408,7 +1408,6 @@ const main = async () => {
|
|
|
1408
1408
|
// 一份不守的槽会以同一对的 key 顶掉发话方还在等它答的那件活, 那件的回执就再也来不了。
|
|
1409
1409
|
// 父 k: 默认这件活算发话方此刻在答的那件的子活 (链式续回); `chain:false` = 旁支,
|
|
1410
1410
|
// 回执照常回来, 但不挂住它给上游的交代 —— 只有发话方知道这件活是不是为上游派的。
|
|
1411
|
-
const k = held ? held.k : body.chain === false ? undefined : parentKOf(self);
|
|
1412
1411
|
if (inj.ok && wantReceipt)
|
|
1413
1412
|
receipts.register({ from: self, to: target, channel, job: jobId, at, turn: turn.turn, legs: turn.legs, deadlineAt, ...(k ? { k } : {}), ...(asker ? { asker } : {}) });
|
|
1414
1413
|
if (inj.ok && wantReceipt) {
|
|
@@ -1543,12 +1542,14 @@ const main = async () => {
|
|
|
1543
1542
|
});
|
|
1544
1543
|
// 交接的「重开」一步: 走 /new (杀旧 pane、起新进程) 而不是往原 pane 注入 /clear。
|
|
1545
1544
|
// /clear 只换会话不换进程 —— MCP 子进程、charter 都还是旧的, 新工具拿不到; 镜像
|
|
1546
|
-
// 还得靠 sid 轮换探测才跟得上。CLI
|
|
1545
|
+
// 还得靠 sid 轮换探测才跟得上。CLI 使用 cfg 的默认 backend; cwd / keepalive 照旧沿用。
|
|
1546
|
+
// 同 backend 时模型也沿用, 管家除外 ——
|
|
1547
1547
|
// 它换上此刻的管家档 (newSession 里定), 改了 models.steward 后交接一次就是让它生效的那一下。
|
|
1548
1548
|
const restartFresh = (target) => {
|
|
1549
1549
|
const info = m.sessionInfo(target);
|
|
1550
|
-
const
|
|
1551
|
-
|
|
1550
|
+
const cli = cfg.wrc.defaultCli ?? primary.name;
|
|
1551
|
+
const model = stewardTierOf(cfg, target) || info?.cli !== cli ? undefined : info?.model || undefined;
|
|
1552
|
+
return m.newSession(target, displayName(target) || tagOfKey(target) || target, cli, { model, silent: true, warm: true });
|
|
1552
1553
|
};
|
|
1553
1554
|
// POST /handoff — 交接一个 pane 的会话给一个全新会话。先让目标会话把当前工作
|
|
1554
1555
|
// 压成一份"零上下文也能接手"的交接简报,等它写完并抓取,再 restartFresh 换一个
|
|
@@ -1575,7 +1576,8 @@ const main = async () => {
|
|
|
1575
1576
|
let target;
|
|
1576
1577
|
if (pane) {
|
|
1577
1578
|
target = m.targetForPane(pane);
|
|
1578
|
-
|
|
1579
|
+
// 别的机器人的 pane 与没绑定同形 —— pane 号是另一条绕过 resolvePeer 的寻址。
|
|
1580
|
+
if (!target || !sameBot(self, target)) {
|
|
1579
1581
|
json(res, 404, { ok: false, reason: `no mirror session bound to pane ${pane}` });
|
|
1580
1582
|
return;
|
|
1581
1583
|
}
|
|
@@ -1752,7 +1754,8 @@ const main = async () => {
|
|
|
1752
1754
|
log.warn({ err: e.message }, "accept nudge failed");
|
|
1753
1755
|
} }, NUDGE_TICK_MS).unref();
|
|
1754
1756
|
/** `who` = 这次变动的当事人 (见 hears): 不给 = 聊天自己的事 (改名), 全群都听。 */
|
|
1755
|
-
|
|
1757
|
+
// 听众只在主角那个聊天里 (同 base ⇒ 同机器人); sameBot 再写一遍是隔离的明文, 不靠 key 的拼法兜着。
|
|
1758
|
+
const postRoster = (base, except, n, who) => notices.post(chatAudience(m.chatTargets(base), base, except).filter((t) => sameBot(base, t)).filter(who ? hears(who) : () => true), n);
|
|
1756
1759
|
/** 注册表里的当事人。要在 forget 抹掉记录之前取; 没有记录 = 谁也不告诉。 */
|
|
1757
1760
|
const subjectOf = (w) => (w
|
|
1758
1761
|
? { parent: w.parent, description: w.description, owners: jobs.all().filter((j) => j.status === "open" && j.members.some((x) => x.target === w.target)).map((j) => j.owner) }
|
|
@@ -1923,7 +1926,7 @@ const main = async () => {
|
|
|
1923
1926
|
const loadError = tasks.errors()[x.id];
|
|
1924
1927
|
return {
|
|
1925
1928
|
id: x.id,
|
|
1926
|
-
target: x.target || (cfg.defaultChat ?? ""),
|
|
1929
|
+
target: x.target || defaultChatFor(cfg, x.owner || (cfg.defaultChat ?? "")),
|
|
1927
1930
|
when: describeTrigger(x.trigger),
|
|
1928
1931
|
nextAt: nextFire(x.trigger, sinceOf(st), nowDate),
|
|
1929
1932
|
lastFired: st.lastFired,
|
|
@@ -1971,8 +1974,8 @@ const main = async () => {
|
|
|
1971
1974
|
});
|
|
1972
1975
|
/** 自己的地址 = 全局名字, 与住在哪个群无关。 */
|
|
1973
1976
|
const selfAddress = (target) => settleName(wizards, chatNameOf(cfg, target), target);
|
|
1974
|
-
const sharedMemory = (scope, key) => {
|
|
1975
|
-
const path = memoryPath(cfg.daemon.stateDir, scope, key);
|
|
1977
|
+
const sharedMemory = (scope, key, bot = "") => {
|
|
1978
|
+
const path = memoryPath(cfg.daemon.stateDir, scope, key, bot);
|
|
1976
1979
|
return clipForCharter(readMemory(path), path);
|
|
1977
1980
|
};
|
|
1978
1981
|
/** transcript 最后写入的时刻; 没有会话 / 读不到 = 0。 */
|
|
@@ -2007,17 +2010,18 @@ const main = async () => {
|
|
|
2007
2010
|
// 最近动过的排前面: 名册只点名前几个, 截掉的该是早就停了的那些。
|
|
2008
2011
|
// 没有 transcript 的冷记录按 bornAt 算, 刚生下还没开口的照样在。
|
|
2009
2012
|
siblings: m.chatTargets(baseOfKey(target))
|
|
2010
|
-
|
|
2013
|
+
// 同 base 即同机器人; 显式再滤一道, 宪章名册的隔离不依赖 key 的拼法。
|
|
2014
|
+
.filter((t) => t !== target && sameBot(target, t))
|
|
2011
2015
|
.map((t) => [t, Math.max(lastTouched(t), wizards.get(t)?.bornAt ?? 0)])
|
|
2012
2016
|
.filter(([, at]) => Date.now() - at <= ROSTER_FRESH_MS)
|
|
2013
2017
|
.sort((x, y) => y[1] - x[1])
|
|
2014
2018
|
.map(([t]) => briefOf(target, t)),
|
|
2015
2019
|
memory: wizards.get(target)?.memory ?? [],
|
|
2016
|
-
tiers: forDiff ? [] : TIERS.map((n) => { const s = cfg.
|
|
2020
|
+
tiers: forDiff ? [] : TIERS.map((n) => { const s = tiersForBackend(cfg, o.cli ?? m.sessionInfo(target)?.cli)[n]; return `${n}=${s.model || "默认"}${s.effort ? `·${s.effort}` : ""}`; }),
|
|
2017
2021
|
chatMemory: sharedMemory("chat", baseOfKey(target)),
|
|
2018
2022
|
workspaceMemory: (() => {
|
|
2019
2023
|
const cwd = o.cwd || m.getCwd(target).runningCwd || m.getCwd(target).defaultCwd;
|
|
2020
|
-
return cwd ? sharedMemory("workspace", cwd) : "";
|
|
2024
|
+
return cwd ? sharedMemory("workspace", cwd, botOfKey(target)) : "";
|
|
2021
2025
|
})(),
|
|
2022
2026
|
steward: !tagOfKey(target),
|
|
2023
2027
|
stewardBudget: chatPolicyOf(cfg, target).stewardBudget,
|
|
@@ -2062,7 +2066,7 @@ const main = async () => {
|
|
|
2062
2066
|
// 信箱里装的是相对上一份宪章的变化; 新宪章一渲染就都过期了 (名册、记忆都已在里面)。
|
|
2063
2067
|
notices.drain(target);
|
|
2064
2068
|
notices.forget(target);
|
|
2065
|
-
return charterFor(target, charterOptsOf(target, ctx?.cwd));
|
|
2069
|
+
return charterFor(target, { ...charterOptsOf(target, ctx?.cwd), cli: ctx?.cli });
|
|
2066
2070
|
});
|
|
2067
2071
|
// 宪章记录是后来才有的: 已经在跑的 wizard 拿 spawn 落盘的那份 (state/charters/<sid>.md) 补上,
|
|
2068
2072
|
// 否则要等它下次重生, rolepage 的「宪章」入口才出现。与自动命名同一个延迟: 等附着表恢复完。
|
|
@@ -2089,10 +2093,11 @@ const main = async () => {
|
|
|
2089
2093
|
log.warn({ err: e.message }, "boot auto-name failed");
|
|
2090
2094
|
}
|
|
2091
2095
|
}, 15_000).unref();
|
|
2096
|
+
// 家谱只列与观察者同机器人的 (跨机器人的分身生不出来, 这是给老数据 / 手改注册表兜的底)。
|
|
2092
2097
|
const kinOf = (self, all, target) => ({
|
|
2093
|
-
parent:
|
|
2094
|
-
ancestors: ancestorsOf(all, target).map((t) => briefOf(self, t)),
|
|
2095
|
-
clones: childrenOf(all, target).map((w) => briefOf(self, w.target)),
|
|
2098
|
+
parent: ((p) => (p && sameBot(self, p) ? briefOf(self, p) : undefined))(wizards.get(target)?.parent),
|
|
2099
|
+
ancestors: ancestorsOf(all, target).filter((t) => sameBot(self, t)).map((t) => briefOf(self, t)),
|
|
2100
|
+
clones: childrenOf(all, target).filter((w) => sameBot(self, w.target)).map((w) => briefOf(self, w.target)),
|
|
2096
2101
|
});
|
|
2097
2102
|
const identityOf = (self, target) => {
|
|
2098
2103
|
const rec = wizards.get(target);
|
|
@@ -2143,8 +2148,9 @@ const main = async () => {
|
|
|
2143
2148
|
};
|
|
2144
2149
|
});
|
|
2145
2150
|
const seen = new Set(live.map((r) => r.target));
|
|
2151
|
+
// live 已由 peers (同 base) / foreignPeers (sameBot) 收窄; 冷记录在这里按机器人滤。
|
|
2146
2152
|
const cold = all
|
|
2147
|
-
.filter((w) => !seen.has(w.target))
|
|
2153
|
+
.filter((w) => !seen.has(w.target) && sameBot(self, w.target))
|
|
2148
2154
|
.map((w) => ({
|
|
2149
2155
|
...briefOf(self, w.target),
|
|
2150
2156
|
target: w.target,
|
|
@@ -2269,13 +2275,18 @@ const main = async () => {
|
|
|
2269
2275
|
const routeRowsOf = async (self) => {
|
|
2270
2276
|
ensureChatNames(self);
|
|
2271
2277
|
const warm = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping);
|
|
2272
|
-
|
|
2278
|
+
const roster = await rosterOf(self);
|
|
2279
|
+
const openJobs = jobs.all();
|
|
2280
|
+
const inflight = receipts.states();
|
|
2281
|
+
return roster
|
|
2273
2282
|
.filter((r) => !r.self)
|
|
2274
2283
|
.map((r) => {
|
|
2275
2284
|
const live = r.jsonlPath ? r.jsonlPath : "";
|
|
2276
2285
|
const ttl = live ? cacheTtlSec(live) : 0;
|
|
2277
2286
|
return {
|
|
2278
2287
|
...r,
|
|
2288
|
+
lead: wizards.get(r.target)?.lead === true,
|
|
2289
|
+
duties: routeDutiesOf(openJobs, r.target, inflight),
|
|
2279
2290
|
contextTokens: live ? lastContextTokens(live) : 0,
|
|
2280
2291
|
...(ttl ? { cacheTtlMs: ttl * 1000 } : {}),
|
|
2281
2292
|
...(live ? { cacheAt: cacheTouchMs(live) } : {}),
|
|
@@ -2321,15 +2332,20 @@ const main = async () => {
|
|
|
2321
2332
|
const to = (b.to ?? "").toString().trim();
|
|
2322
2333
|
const now = Date.now();
|
|
2323
2334
|
const ttlMs = cfg.wrc.mirror.keepalive.ttlSec * 1000;
|
|
2324
|
-
//
|
|
2325
|
-
//
|
|
2326
|
-
const
|
|
2327
|
-
const
|
|
2328
|
-
|
|
2335
|
+
// 占用保护由 decide 统一判断, 留在候选表里说明为什么不能自动复用。
|
|
2336
|
+
// 显式 to 保留人为选定; force 只影响成本门, 不绕 lead/工单/连续性保护。
|
|
2337
|
+
const pool = to || b.spawn || lead ? [] : (await routeRowsOf(self)).filter((r) => r.alive && !!tagOfKey(r.target));
|
|
2338
|
+
const tierArg = (b.tier ?? "").toString().trim();
|
|
2339
|
+
if (tierArg && !TIERS.includes(tierArg)) {
|
|
2340
|
+
json(res, 400, { ok: false, reason: `unknown tier: ${tierArg}` });
|
|
2341
|
+
return;
|
|
2342
|
+
}
|
|
2343
|
+
const taskTier = (tierArg || tierFor(task, lead));
|
|
2344
|
+
const cands = rankCandidates(task, m.getCwd(self).runningCwd, pool, { now, ttlMs });
|
|
2329
2345
|
let d = to ? { kind: "existing", why: `你点名了 ${to}` }
|
|
2330
2346
|
: b.spawn ? { kind: "spawn", why: "你要了新的" }
|
|
2331
2347
|
: lead ? { kind: "spawn", why: "复杂活: 白板起一个 hard 档的 lead, 由它组队" }
|
|
2332
|
-
: decide(task, cands, now, ttlMs, force);
|
|
2348
|
+
: decide(task, cands, now, ttlMs, force, taskTier);
|
|
2333
2349
|
// 复杂活 (lead:true) 是一件「需求」: 先开需求根单 (账本, 不占 pane), 这一句带它派下去, lead 记成根单成员。
|
|
2334
2350
|
// 小活不开单。dryRun 不落账, 所以放在它后面开 (见下)。
|
|
2335
2351
|
const criteria = (b.criteria ?? "").toString().trim();
|
|
@@ -2342,7 +2358,7 @@ const main = async () => {
|
|
|
2342
2358
|
}, extra);
|
|
2343
2359
|
const shown = cands.slice(0, 3).map((e) => `.${e.row.address || e.row.name}${e.row.busy ? " 忙" : ""}`);
|
|
2344
2360
|
if (b.dryRun) {
|
|
2345
|
-
json(res, 200, { ok: true, dryRun: true, decision: d.kind, reason: d.why, ...(d.kind === "existing" ? { name: to || (d.kind === "existing" && d.row ? d.row.address || d.row.name : "") } : { tier:
|
|
2361
|
+
json(res, 200, { ok: true, dryRun: true, decision: d.kind, reason: d.why, ...(d.kind === "existing" ? { name: to || (d.kind === "existing" && d.row ? d.row.address || d.row.name : "") } : { tier: taskTier }), ...(shown.length ? { candidates: shown } : {}) });
|
|
2346
2362
|
return;
|
|
2347
2363
|
}
|
|
2348
2364
|
if (lead) {
|
|
@@ -2388,7 +2404,7 @@ const main = async () => {
|
|
|
2388
2404
|
json(res, 400, { ok: false, decision: "spawn", reason: `${d.why} —— 要新生一个, 带上 name (这件事的短名) 与 description (它的职责) 重发`, ...(shown.length ? { candidates: shown } : {}) });
|
|
2389
2405
|
return;
|
|
2390
2406
|
}
|
|
2391
|
-
const tier =
|
|
2407
|
+
const tier = taskTier;
|
|
2392
2408
|
const born = await bearAs(self, { inherit: false, name, description, tier, ...(lead ? { lead: true } : {}) });
|
|
2393
2409
|
if (born.status !== 200) {
|
|
2394
2410
|
settleRoot(false);
|
|
@@ -2434,8 +2450,10 @@ const main = async () => {
|
|
|
2434
2450
|
json(res, 400, { ok: false, reason: "note 和 forget 至少给一个" });
|
|
2435
2451
|
return;
|
|
2436
2452
|
}
|
|
2437
|
-
|
|
2438
|
-
const
|
|
2453
|
+
// 工作区记忆按机器人分份 (见 wizard-memory.fileOf); chat 的 key 本身就带机器人。
|
|
2454
|
+
const bot = botOfKey(self);
|
|
2455
|
+
const path = memoryPath(cfg.daemon.stateDir, scope, key, bot);
|
|
2456
|
+
const queued = proposeMemory(inboxPath(cfg.daemon.stateDir, scope, key, bot), {
|
|
2439
2457
|
at: Date.now(),
|
|
2440
2458
|
by: selfAddress(self),
|
|
2441
2459
|
...(scope === "workspace" ? { cwd: key } : {}),
|
|
@@ -2476,15 +2494,12 @@ const main = async () => {
|
|
|
2476
2494
|
/** spawn_wizard / clone_wizard 的本体 —— HTTP 路由与 dispatch 共用。 */
|
|
2477
2495
|
const bearAs = async (self, body) => {
|
|
2478
2496
|
const b = body;
|
|
2497
|
+
const asker = b.task?.trim() ? askerOf(self) : undefined;
|
|
2498
|
+
const k = body.chain === false ? undefined : parentKOf(self);
|
|
2479
2499
|
const askedEffort = parseEffort(b.effort);
|
|
2480
2500
|
if (b.effort !== undefined && !askedEffort) {
|
|
2481
2501
|
return { status: 400, body: { ok: false, reason: `effort 只有这几档: ${EFFORTS.join(" / ")}` } };
|
|
2482
2502
|
}
|
|
2483
|
-
const pick = pickTier(cfg.models, b.tier, { cli: b.cli, model: b.model, effort: askedEffort }, b.inherit === true);
|
|
2484
|
-
if (!pick.ok) {
|
|
2485
|
-
return { status: 400, body: pick };
|
|
2486
|
-
}
|
|
2487
|
-
const effort = pick.effort;
|
|
2488
2503
|
// 工单先验: 生完分身才发现工单号打错了, 那个分身就成了没人认领的孤儿。
|
|
2489
2504
|
const jobId = (b.job ?? "").trim();
|
|
2490
2505
|
if (jobId) {
|
|
@@ -2524,6 +2539,12 @@ const main = async () => {
|
|
|
2524
2539
|
}
|
|
2525
2540
|
const source = fromR.target;
|
|
2526
2541
|
const sourceInfo = m.sessionInfo(source);
|
|
2542
|
+
const forkBackend = inherit && sourceInfo && existsSync(sourceInfo.jsonlPath)
|
|
2543
|
+
&& (!b.cwd?.trim() || expandHome(b.cwd.trim()) === sourceInfo.cwd) ? sourceInfo.cli : undefined;
|
|
2544
|
+
const pick = pickTier(cfg, b.tier, { cli: b.cli, model: b.model, effort: askedEffort }, inherit, forkBackend);
|
|
2545
|
+
if (!pick.ok)
|
|
2546
|
+
return { status: 400, body: pick };
|
|
2547
|
+
const effort = pick.effort;
|
|
2527
2548
|
// 别人的会话 fork 不出来就是失败, 不能像克隆自己那样悄悄退化成白板 —— 调用方
|
|
2528
2549
|
// 要的恰恰是它的上下文, 给一张白纸等于答非所问。
|
|
2529
2550
|
if (source !== self && !sourceInfo?.sessionId) {
|
|
@@ -2554,12 +2575,12 @@ const main = async () => {
|
|
|
2554
2575
|
log.info({ self, slept: idle.map((pp) => pp.target) }, "clone cap: idle clones put to sleep");
|
|
2555
2576
|
}
|
|
2556
2577
|
const wantChat = (b.chat ?? "").toString().trim();
|
|
2557
|
-
const base = wantChat ? chatBaseOf(cfg, wantChat) : baseOfKey(self);
|
|
2578
|
+
const base = ((b) => (b && sameBot(self, b) ? b : ""))(wantChat ? chatBaseOf(cfg, wantChat) : baseOfKey(self));
|
|
2558
2579
|
if (!base) {
|
|
2559
2580
|
return { status: 404, body: {
|
|
2560
2581
|
ok: false,
|
|
2561
2582
|
reason: `unknown chat '${wantChat}' — 只有起过名字的聊天能被指名; 让那边先 /name`,
|
|
2562
|
-
candidates:
|
|
2583
|
+
candidates: chatNamesFor(self),
|
|
2563
2584
|
} };
|
|
2564
2585
|
}
|
|
2565
2586
|
const askedName = String(b.name ?? b.tag ?? "");
|
|
@@ -2592,7 +2613,7 @@ const main = async () => {
|
|
|
2592
2613
|
spawning: BOOT_ID,
|
|
2593
2614
|
});
|
|
2594
2615
|
const name = wizards.rename(target, normalizeTag(askedName) || tag);
|
|
2595
|
-
const charter = charterFor(target, { ...(detached ? {} : { parent: self }), forkOf, inherited: inherit });
|
|
2616
|
+
const charter = charterFor(target, { ...(detached ? {} : { parent: self }), forkOf, inherited: inherit, cli: pick.cli });
|
|
2596
2617
|
const task = (b.task ?? "").toString().trim();
|
|
2597
2618
|
// 没点名就按配置的 keepalive.spawnDefault 来 —— 调用方明说的永远优先。
|
|
2598
2619
|
const keepalive = typeof b.keepalive === "boolean" ? b.keepalive : cfg.wrc.mirror.keepalive.spawnDefault;
|
|
@@ -2600,7 +2621,6 @@ const main = async () => {
|
|
|
2600
2621
|
// wait_peer 才不会把它们当成分身对这件活的答复。
|
|
2601
2622
|
const taskAt = Date.now();
|
|
2602
2623
|
const taskTurn = newTurn();
|
|
2603
|
-
const asker = task ? askerOf(self) : undefined;
|
|
2604
2624
|
const r = await m.cloneSession({
|
|
2605
2625
|
parent: source,
|
|
2606
2626
|
target,
|
|
@@ -2614,7 +2634,7 @@ const main = async () => {
|
|
|
2614
2634
|
// 继承路径上第一句话是分叉的触发器, 所以直接把活当开场白 —— 少一次往返,
|
|
2615
2635
|
// 也少一次"就位了但没事干"的空转。
|
|
2616
2636
|
// 子 lead 的宪章只有层级规矩, 打法跟第一句的信封走 (同 dispatch 对已有 wizard 的做法)。
|
|
2617
|
-
bootstrap: task ? task + envelopeFor(self, "", { turn: taskTurn }) + (b.lead === true ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : "") : undefined,
|
|
2637
|
+
bootstrap: task ? task + envelopeFor(self, "", { turn: taskTurn, asker }) + (b.lead === true ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : "") : undefined,
|
|
2618
2638
|
...(task ? { bootstrapFrom: { from: { kind: "peer", from: self, turn: taskTurn, ...(jobId ? { job: jobId } : {}), ...(asker ? { asker } : {}) }, query: task } } : {}),
|
|
2619
2639
|
keepalive,
|
|
2620
2640
|
}).catch((e) => ({ ok: false, reason: `spawn threw: ${String(e)}`, inherited: false }));
|
|
@@ -2641,7 +2661,7 @@ const main = async () => {
|
|
|
2641
2661
|
// 继承路径上活已经随开场白进去了, 空白分身才需要在这里补一次注入 (私聊)。
|
|
2642
2662
|
let dispatched = r.inherited && !!task;
|
|
2643
2663
|
if (task && !r.inherited) {
|
|
2644
|
-
const inj = await m.injectText(target, task, undefined, { from: { kind: "peer", from: self, turn: taskTurn, ...(jobId ? { job: jobId } : {}), ...(asker ? { asker } : {}) }, channel: "", envelope: envelopeFor(self, "", { turn: taskTurn }) });
|
|
2664
|
+
const inj = await m.injectText(target, task, undefined, { from: { kind: "peer", from: self, turn: taskTurn, ...(jobId ? { job: jobId } : {}), ...(asker ? { asker } : {}) }, channel: "", envelope: envelopeFor(self, "", { turn: taskTurn, asker }) });
|
|
2645
2665
|
dispatched = inj.ok;
|
|
2646
2666
|
}
|
|
2647
2667
|
if (jobId) {
|
|
@@ -2653,7 +2673,6 @@ const main = async () => {
|
|
|
2653
2673
|
jobs.spend(jobId);
|
|
2654
2674
|
// 分身的第一件活也守回执: fan-out 最常见的形状就是 clone_wizard({task}) × N,
|
|
2655
2675
|
// 让它们干完自己把结论送回来, 发起方不必挂在 wait_peer 上。
|
|
2656
|
-
const k = body.chain === false ? undefined : parentKOf(self);
|
|
2657
2676
|
if (dispatched)
|
|
2658
2677
|
receipts.register({ from: self, to: target, channel: "", job: jobId, at: taskAt, turn: taskTurn, ...(k ? { k } : {}), ...(asker ? { asker } : {}) });
|
|
2659
2678
|
if (dispatched)
|
|
@@ -2687,7 +2706,7 @@ const main = async () => {
|
|
|
2687
2706
|
const r = resolvePeer(self, addrOf(b));
|
|
2688
2707
|
// 有身份没会话 (冷记录 / 生到一半的僵尸): resolvePeer 够不着它, 但收它不需要
|
|
2689
2708
|
// 会话 —— end 视为早已结束, forget 删掉记录、腾出名字。interrupt 无从谈起。
|
|
2690
|
-
const found = r.ok || r.candidates?.length ? undefined : wizards.byName(addrOf(b));
|
|
2709
|
+
const found = r.ok || r.candidates?.length ? undefined : ((w) => (w && sameBot(self, w.target) ? w : undefined))(wizards.byName(addrOf(b)));
|
|
2691
2710
|
const cold = found && !m.chatTargets(baseOfKey(found.target)).includes(found.target) ? found : undefined;
|
|
2692
2711
|
if (cold && (b.mode ?? "end") === "end") {
|
|
2693
2712
|
const detached = b.forget ? forgetWizard(cold.target) : [];
|
|
@@ -2999,7 +3018,7 @@ const main = async () => {
|
|
|
2999
3018
|
openedAt: j.openedAt,
|
|
3000
3019
|
...jobs.tally(j.id),
|
|
3001
3020
|
...(j.maxTurns ? { budget: `${j.turns ?? 0}/${j.maxTurns}` } : {}),
|
|
3002
|
-
members: j.members.map((mm) => ({
|
|
3021
|
+
members: j.members.filter((mm) => sameBot(self, mm.target)).map((mm) => ({
|
|
3003
3022
|
address: peerAddress(cfg, self, mm.target),
|
|
3004
3023
|
role: mm.role ?? "exec",
|
|
3005
3024
|
task: mm.task.split("\n")[0] ?? "",
|
|
@@ -3047,6 +3066,11 @@ const main = async () => {
|
|
|
3047
3066
|
}
|
|
3048
3067
|
target = r.target;
|
|
3049
3068
|
}
|
|
3069
|
+
// owner = self; resolvePeer 已拦住别的机器人的名字, 这一道是给以后别的取 target 的路兜底。
|
|
3070
|
+
if (!sameBot(self, target)) {
|
|
3071
|
+
json(res, 404, { ok: false, reason: `no wizard named '.${tag}' — wizard_roster lists every name` });
|
|
3072
|
+
return;
|
|
3073
|
+
}
|
|
3050
3074
|
// 到点是新建还是续用, 在**排班时**就定死。两条都指向新建:
|
|
3051
3075
|
// · 没点名 wizard —— 定时的 prompt 本就要求零上下文自洽, 没理由挑一个会话挤进去;
|
|
3052
3076
|
// · prompt 自己就在说「建两个 wizard 去干 xxx」—— 这时 tag 只是「在谁的聊天/
|
|
@@ -3078,10 +3102,10 @@ const main = async () => {
|
|
|
3078
3102
|
const now = new Date();
|
|
3079
3103
|
const errs = tasks.errors();
|
|
3080
3104
|
const rows = tasks.list()
|
|
3081
|
-
.filter((t) => !mineOnly || ownerOf(t) === self)
|
|
3105
|
+
.filter((t) => sameBot(self, ownerOf(t)) && (!mineOnly || ownerOf(t) === self))
|
|
3082
3106
|
.map((t) => ({
|
|
3083
3107
|
...renderTask(t, tasks.stateOf(t.id), now, errs[t.id]),
|
|
3084
|
-
address: peerAddress(cfg, self, t.target || (cfg
|
|
3108
|
+
address: peerAddress(cfg, self, t.target || defaultChatFor(cfg, ownerOf(t))),
|
|
3085
3109
|
owner: peerAddress(cfg, self, ownerOf(t)),
|
|
3086
3110
|
}));
|
|
3087
3111
|
// 加载失败的那些没有记录可回显, 但必须出现在列表里 —— 否则 wizard 改错了
|
|
@@ -3102,6 +3126,12 @@ const main = async () => {
|
|
|
3102
3126
|
json(res, 400, { ok: false, reason: "id required (list_tasks 里那个 id)" });
|
|
3103
3127
|
return;
|
|
3104
3128
|
}
|
|
3129
|
+
// 别的机器人的日程与不存在同形。
|
|
3130
|
+
const rec = tasks.get(id);
|
|
3131
|
+
if (rec && !sameBot(self, ownerOf(rec))) {
|
|
3132
|
+
json(res, 404, { ok: false, reason: `no schedule with id ${id}` });
|
|
3133
|
+
return;
|
|
3134
|
+
}
|
|
3105
3135
|
const state = tasks.stateOf(id);
|
|
3106
3136
|
const gone = tasks.remove(id);
|
|
3107
3137
|
if (!gone) {
|
|
@@ -3141,7 +3171,7 @@ const main = async () => {
|
|
|
3141
3171
|
];
|
|
3142
3172
|
};
|
|
3143
3173
|
const configEvidence = (p) => [
|
|
3144
|
-
...tierEvidence(cfg
|
|
3174
|
+
...tierEvidence(cfg, p, tierUsage, (tier) => wizards.all().filter((w) => w.tier === tier).length, { tokens: fmtTokens, cost: fmtCost }),
|
|
3145
3175
|
...chatPolicyEvidence(p),
|
|
3146
3176
|
];
|
|
3147
3177
|
// ── 改配置会不会动到宪章 ─────────────────────────────────────────
|
|
@@ -3190,10 +3220,14 @@ const main = async () => {
|
|
|
3190
3220
|
const others = hits.filter((h) => h.target !== c.self);
|
|
3191
3221
|
others.forEach((h) => notices.post([h.target], `${c.what} 改动了你的宪章 (系统提示, 变了的段: ${h.sections.join(" / ")}); 已在跑的你看不见新规矩 —— ${ask}${lag(h.target)}`));
|
|
3192
3222
|
const mine = hits.find((h) => h.target === c.self);
|
|
3223
|
+
// 提醒照旧全机发 (各收各的); 回给调用方的名单与人数只含它看得见的 (sameBot) —— 别的机器人的 wizard
|
|
3224
|
+
// 连名字带个数都不报。没有调用方 (人 /name、自动补名) = 主机器人视角, 单机器人时就是全部。
|
|
3225
|
+
const shown = hits.filter((h) => sameBot(c.self ?? "", h.target));
|
|
3226
|
+
const shownOthers = shown.filter((h) => h.target !== c.self);
|
|
3193
3227
|
return [
|
|
3194
3228
|
"",
|
|
3195
|
-
`⚠ 这次改动进了宪章 (系统提示): 已在跑的 wizard 看不见, 要 handoff 才换上。受影响 ${
|
|
3196
|
-
...(
|
|
3229
|
+
`⚠ 这次改动进了宪章 (系统提示): 已在跑的 wizard 看不见, 要 handoff 才换上。受影响 ${shown.length} 个: ${shown.map((h) => displayName(h.target)).join(" ")}`,
|
|
3230
|
+
...(shownOthers.length ? [`已给其余 ${shownOthers.length} 个各挂一条提醒 (随它们下一条消息到), 让它们${ask}`] : []),
|
|
3197
3231
|
...(mine ? [`**你自己也在其中** (变了的段: ${mine.sections.join(" / ")}): 这一轮收尾后${ask}${lag(mine.target)}`] : []),
|
|
3198
3232
|
].join("\n");
|
|
3199
3233
|
};
|
|
@@ -3219,41 +3253,24 @@ const main = async () => {
|
|
|
3219
3253
|
return { r, note: Promise.resolve(fail(e)) };
|
|
3220
3254
|
}
|
|
3221
3255
|
};
|
|
3256
|
+
/** 配置的调用方: 只认 sessionId / pane, 不回落 defaultChat (同 config_set 的 caller)。认不出 = "" (主机器人)。 */
|
|
3257
|
+
const configCaller = (b) => (b.sessionId?.trim() && m.targetForSession(b.sessionId.trim())) || (b.tmuxPane?.trim() && m.targetForPane(b.tmuxPane.trim())) || "";
|
|
3222
3258
|
// POST /config/get —— 新 MCP 带 `path` (可空) 拿渲染好的文本; 老 MCP 带 `key` 拿 {key, value}。
|
|
3223
3259
|
http.register("POST /config/get", async (req, res) => {
|
|
3224
3260
|
const body = (await readBody(req));
|
|
3261
|
+
const seen = cfgSeenBy(cfg, configCaller(body));
|
|
3225
3262
|
if (typeof body.key === "string") {
|
|
3226
|
-
const r = legacyGet(
|
|
3263
|
+
const r = legacyGet(seen, body.key);
|
|
3227
3264
|
json(res, r.ok ? 200 : 400, r);
|
|
3228
3265
|
return;
|
|
3229
3266
|
}
|
|
3230
3267
|
// chatPolicy.<聊天名>… 换成它指向的 principal 再查 (同 config_set 的 pinChatKeys); 认不出的原样查。
|
|
3268
|
+
// 名字按调用方看得见的表解析: 别的机器人的聊天名认不出, principal 写法在 seen 里也查无此键。
|
|
3231
3269
|
const p = parsePath(body.path);
|
|
3232
|
-
const base = p[0] === "chatPolicy" && p[1] ? chatBaseOf(
|
|
3233
|
-
const r = configGet(
|
|
3270
|
+
const base = p[0] === "chatPolicy" && p[1] ? chatBaseOf(seen, p[1]) : "";
|
|
3271
|
+
const r = configGet(seen, sourcePath, base ? ["chatPolicy", base, ...p.slice(2)].join(".") : body.path, configEvidence);
|
|
3234
3272
|
json(res, r.ok ? 200 : 400, r);
|
|
3235
3273
|
});
|
|
3236
|
-
// chatPolicy 的键一律落成 base principal: 聊天名会改, 拿它当键一改名策略就悄悄失效。写名字照收,
|
|
3237
|
-
// 换成它此刻指向的 principal; 认不出的拒写 (写错的键静默无效, 比报错难查)。unset 一个盘上原样存在的
|
|
3238
|
-
// 键不换 —— 留给清理老写法 / 失效的键。
|
|
3239
|
-
const pinChatKeys = (req) => {
|
|
3240
|
-
const p = parsePath(req.path);
|
|
3241
|
-
if (p[0] !== "chatPolicy")
|
|
3242
|
-
return req;
|
|
3243
|
-
const bad = (k) => ({ ok: false, reason: `认不出聊天 \`${k}\`: 键写 base principal (\`chat:wr…\` / \`user:…\`) 或聊天名 (/chats)` });
|
|
3244
|
-
if (p.length === 1) {
|
|
3245
|
-
if (req.value === undefined || typeof req.value !== "object" || Array.isArray(req.value))
|
|
3246
|
-
return req;
|
|
3247
|
-
const pairs = Object.entries(req.value).map(([k, v]) => [k, chatBaseOf(cfg, k), v]);
|
|
3248
|
-
const miss = pairs.find(([, b]) => !b);
|
|
3249
|
-
return miss ? bad(miss[0]) : { ...req, value: Object.fromEntries(pairs.map(([, b, v]) => [b, v])) };
|
|
3250
|
-
}
|
|
3251
|
-
const key = p[1];
|
|
3252
|
-
if (req.op === "unset" && p.length === 2 && key in cfg.chatPolicy)
|
|
3253
|
-
return req;
|
|
3254
|
-
const base = chatBaseOf(cfg, key);
|
|
3255
|
-
return base ? { ...req, path: ["chatPolicy", base, ...p.slice(2)].join(".") } : bad(key);
|
|
3256
|
-
};
|
|
3257
3274
|
// POST /config/set —— 口令门见 `guarded`。分级在这里而不在 hook: `card` 项由守护进程
|
|
3258
3275
|
// 自己推确认卡, 所以 danger.skipAll / ⏱窗口 都放不掉它。卡等 CONFIRM_INLINE_MS 还没人点
|
|
3259
3276
|
// 就先回 pending (fetch 默认 5 分钟收不到头就断), 卡仍有效, 迟到的确认照常落盘并捎一句给调用方。
|
|
@@ -3265,7 +3282,7 @@ const main = async () => {
|
|
|
3265
3282
|
json(res, 400, asked);
|
|
3266
3283
|
return;
|
|
3267
3284
|
}
|
|
3268
|
-
const ask = pinChatKeys(asked);
|
|
3285
|
+
const ask = pinChatKeys(cfg, asked, configCaller(body));
|
|
3269
3286
|
if ("ok" in ask) {
|
|
3270
3287
|
json(res, 400, ask);
|
|
3271
3288
|
return;
|
|
@@ -3284,8 +3301,7 @@ const main = async () => {
|
|
|
3284
3301
|
}
|
|
3285
3302
|
// 落盘时重算一遍: 等卡期间文件可能被别人改过, 按那一刻的文件打补丁、再校验。
|
|
3286
3303
|
// 宪章快照取在落盘之前 (守卫里): hot 项一 applyHot, 活的 cfg 就已经是新的了。
|
|
3287
|
-
|
|
3288
|
-
const caller = (body.sessionId?.trim() && m.targetForSession(body.sessionId.trim())) || (body.tmuxPane?.trim() && m.targetForPane(body.tmuxPane.trim())) || undefined;
|
|
3304
|
+
const caller = resolveSelf({ sessionId: body.sessionId, tmuxPane: body.tmuxPane }) || undefined;
|
|
3289
3305
|
// 人发起 = 调用方这一轮的链头是人 (人在群里 / 单聊里叫它改的)。
|
|
3290
3306
|
const byHuman = !!caller && !!askerOf(caller)?.who.startsWith("user:");
|
|
3291
3307
|
const commit = async () => {
|
|
@@ -3368,6 +3384,12 @@ const main = async () => {
|
|
|
3368
3384
|
json(res, 400, { ok: false, reason: bad });
|
|
3369
3385
|
return;
|
|
3370
3386
|
}
|
|
3387
|
+
// 节点是 base 下的 tag, 构造上与调用方同机器人; 这一道防的是 tag 里夹带别的机器人的 key。
|
|
3388
|
+
const xnode = spec.nodes.find((n) => !sameBot(self, keyOf(spec.base, n.tag)));
|
|
3389
|
+
if (xnode) {
|
|
3390
|
+
json(res, 400, { ok: false, reason: `node.tag \`${xnode.tag}\` 不是一个合法的 tag` });
|
|
3391
|
+
return;
|
|
3392
|
+
}
|
|
3371
3393
|
const graphLog = log.child({ mod: "graph", base: spec.base });
|
|
3372
3394
|
// 每一步、每条进度都在发起这次 run 的那一轮的频道里: 人在群里叫它发起的才进群,
|
|
3373
3395
|
// 私聊轮里发起的整场只进 rolepage —— 步骤不带频道就等于在节点的 home 群里开口。
|
|
@@ -3413,6 +3435,12 @@ const main = async () => {
|
|
|
3413
3435
|
json(res, 400, { ok: false, reason: "runId required" });
|
|
3414
3436
|
return;
|
|
3415
3437
|
}
|
|
3438
|
+
// 别的机器人那边跑的流水线与不存在同形。
|
|
3439
|
+
const run = getRun(runId);
|
|
3440
|
+
if (run && !sameBot(resolveSelf(body), run.base)) {
|
|
3441
|
+
json(res, 404, { ok: false, reason: `run ${runId} not found or already finished` });
|
|
3442
|
+
return;
|
|
3443
|
+
}
|
|
3416
3444
|
const stopped = stopRun(runId);
|
|
3417
3445
|
json(res, stopped ? 200 : 404, stopped ? { ok: true, runId } : { ok: false, reason: `run ${runId} not found or already finished` });
|
|
3418
3446
|
});
|
|
@@ -3446,6 +3474,10 @@ const main = async () => {
|
|
|
3446
3474
|
// 跑完自动收掉。只有排班时点名了某个 wizard (fresh=false) 且它此刻闲着,
|
|
3447
3475
|
// 才把这句话直接投进它那一轮 —— 「在已有会话里继续」是要求出来的, 不是默认。
|
|
3448
3476
|
scheduledTaskInject = async (target, text, { taskId, fresh, quiet }) => {
|
|
3477
|
+
// 日程文件是可手改的源码: target 与排班者不同机器人就不放枪 (schedule_task 那头已拦, 这里拦手改的)。
|
|
3478
|
+
const owner = ((r) => (r ? ownerOf(r) : target))(tasks.get(taskId));
|
|
3479
|
+
if (!sameBot(owner, target))
|
|
3480
|
+
return { ok: false, reason: `日程 ${taskId} 的目标与排班者归不同的企微机器人, 不跨机器人放枪` };
|
|
3449
3481
|
// 安静任务私下跑 (channel ""): 镜像不推它的任何一句, 终句由 relayUnlessQuiet 判断要不要进群。
|
|
3450
3482
|
const inNamed = async () => {
|
|
3451
3483
|
const at = Date.now();
|
|
@@ -3605,7 +3637,7 @@ const main = async () => {
|
|
|
3605
3637
|
refsOf: (md) => {
|
|
3606
3638
|
// cwd 候选: 提议里带的 (提议者可能早收工了) + 在场绑定的; 再正向算出 md 比对。
|
|
3607
3639
|
const cwds = [...new Set([...proposedCwds(stateDir), ...boundTargets().map(cwdOfTarget)].filter(Boolean))];
|
|
3608
|
-
const cwd = cwdOfMd(stateDir, md, cwds);
|
|
3640
|
+
const cwd = cwdOfMd(stateDir, md, cwds, ["", ...cfg.bots.map((b) => b.id)]);
|
|
3609
3641
|
if (!cwd)
|
|
3610
3642
|
return [];
|
|
3611
3643
|
return [...PROJECT_DOCS.map((f) => join(cwd, f)), ...projectDirsFor(cwd).map((d) => join(d.dir, "memory", "MEMORY.md"))].filter((f) => existsSync(f));
|
|
@@ -3638,7 +3670,8 @@ const main = async () => {
|
|
|
3638
3670
|
inject: scheduledTaskInject,
|
|
3639
3671
|
// gate 里的 `sh` 默认就在目标 wizard 此刻的工作区跑 —— 任务文件里不必写死绝对路径。
|
|
3640
3672
|
cwdOf: (t) => { const c = bridge.getCwd(t); return c.runningCwd || c.pendingCwd || c.defaultCwd; },
|
|
3641
|
-
|
|
3673
|
+
// 没写 target 的日程兜底到排班者那个机器人的默认聊天 (副机器人没配就不放枪, 不冒用主机器人)。
|
|
3674
|
+
fallbackTarget: (t) => defaultChatFor(cfg, t.owner),
|
|
3642
3675
|
});
|
|
3643
3676
|
// 共享记忆整理 — daemon 内建, 不进任务表 / 日程, 不出任何气泡。
|
|
3644
3677
|
const steward = stewardRun
|
|
@@ -3664,6 +3697,15 @@ const main = async () => {
|
|
|
3664
3697
|
log.info({ reaped: r.reaped.length, targets: r.reaped, orphans: r.orphans.length, max }, "pane cap sweep");
|
|
3665
3698
|
};
|
|
3666
3699
|
bridge.onSpawn(() => void sweep().catch((e) => log.warn({ err: e.message }, "pane cap sweep failed")));
|
|
3700
|
+
// 定时清扫: 无绑定孤儿 (restore 丢绑定留下的老 window) 不靠 spawn 也会攒, 只在
|
|
3701
|
+
// onSpawn 数等于没人收。绑定在 store 里启动即读, 新生 pane 有 ORPHAN_GRACE 宽限,
|
|
3702
|
+
// 所以启动后扫一次是安全的。
|
|
3703
|
+
const periodSec = cfg.wrc.mirror.paneSweepSec;
|
|
3704
|
+
if (periodSec > 0) {
|
|
3705
|
+
const timer = setInterval(() => void sweep().catch((e) => log.warn({ err: e.message }, "pane cap sweep failed")), periodSec * 1000);
|
|
3706
|
+
timer.unref();
|
|
3707
|
+
}
|
|
3708
|
+
setTimeout(() => void sweep().catch((e) => log.warn({ err: e.message }, "pane cap sweep failed")), 15_000).unref();
|
|
3667
3709
|
}
|
|
3668
3710
|
const shutdown = async (signal) => {
|
|
3669
3711
|
log.info({ signal }, "shutdown signal");
|