wezard 1.3.22 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +4 -2
- package/README.md +43 -37
- package/dist/daemon/chat-name.js +65 -2
- package/dist/daemon/chat-name.js.map +1 -1
- package/dist/daemon/detail.js +7 -0
- package/dist/daemon/detail.js.map +1 -1
- package/dist/daemon/graph.js +25 -3
- package/dist/daemon/graph.js.map +1 -1
- package/dist/daemon/inbound.js +156 -89
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +773 -125
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +91 -0
- package/dist/daemon/jobs.js.map +1 -0
- package/dist/daemon/mirror-bridge.js +194 -14
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/mirror-store.js.map +1 -1
- package/dist/daemon/notices.js +70 -0
- package/dist/daemon/notices.js.map +1 -0
- package/dist/daemon/peers.js +93 -61
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/spawn-tmux.js +46 -4
- package/dist/daemon/spawn-tmux.js.map +1 -1
- package/dist/daemon/tasks.js +90 -0
- package/dist/daemon/tasks.js.map +1 -0
- package/dist/daemon/wizard.js +151 -0
- package/dist/daemon/wizard.js.map +1 -0
- package/dist/mcp/server.js +236 -102
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/cli-backends.js +10 -0
- package/dist/shared/cli-backends.js.map +1 -1
- package/dist/shared/config.js +54 -14
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/detail-render.js +3 -2
- package/dist/shared/detail-render.js.map +1 -1
- package/dist/shared/detail-store.js.map +1 -1
- package/dist/shared/schedule-spec.js +240 -0
- package/dist/shared/schedule-spec.js.map +1 -0
- package/dist/shared/session-label.js +8 -4
- package/dist/shared/session-label.js.map +1 -1
- package/package.json +1 -1
- package/dist/daemon/topics.js +0 -106
- package/dist/daemon/topics.js.map +0 -1
|
@@ -20,11 +20,12 @@ import { activeBackends, backendForPath, projectDirFor, projectDirsFor, } from "
|
|
|
20
20
|
import { isModalPane, isAskqSubmitPage, parseModalOptions, pickModalAnswer, pickAutoAllowAnswer, parseConfirmContext, looksLikePicker } from "../shared/modal-pane.js";
|
|
21
21
|
import { hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow, hasMirrorPicker, mootMirrorPicker, runMirrorPickerFlow } from "./approval.js";
|
|
22
22
|
import { isAutoWindowActive } from "./session-cache.js";
|
|
23
|
+
import { noticeSuffixFor } from "./notices.js";
|
|
23
24
|
import { dangerOf } from "./danger.js";
|
|
24
25
|
import { runTmux as runTmuxCmd, spawnTmuxClaude } from "./spawn-tmux.js";
|
|
25
26
|
import { startSubagentWatch } from "./subagent-tail.js";
|
|
26
27
|
import { recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, buildDetailUrl, buildChatUrl } from "./detail.js";
|
|
27
|
-
import { labelFor, tagOfKey, baseOfKey, keyOf, withTagHeader, parseTagHeader } from "../shared/session-label.js";
|
|
28
|
+
import { labelFor, tagOfKey, baseOfKey, keyOf, withTagHeader, headSep, parseTagHeader } from "../shared/session-label.js";
|
|
28
29
|
import { splitMarkdown } from "../shared/md-chunk.js";
|
|
29
30
|
import { randomTip } from "./tips.js";
|
|
30
31
|
import { chatBaseOf, chatNameOf, listChatNames, normChatName, parsePeerRef, peerAddress } from "./chat-name.js";
|
|
@@ -1683,7 +1684,7 @@ export const startMirror = (deps) => {
|
|
|
1683
1684
|
const prefix = linkedTagPrefix(a.target, turnId ?? a.briefTurnId);
|
|
1684
1685
|
if (prefix) {
|
|
1685
1686
|
const seqBit = seq ? ` ${seq}` : "";
|
|
1686
|
-
return `${prefix}${seqBit}
|
|
1687
|
+
return `${prefix}${seqBit}${headSep(content)}${content}`;
|
|
1687
1688
|
}
|
|
1688
1689
|
return withSessionTag(a.target, content, seq);
|
|
1689
1690
|
};
|
|
@@ -2109,12 +2110,15 @@ export const startMirror = (deps) => {
|
|
|
2109
2110
|
a.pendingOrigin = undefined;
|
|
2110
2111
|
return p && Date.now() - p.at <= ORIGIN_TTL_MS ? p.origin : undefined;
|
|
2111
2112
|
};
|
|
2113
|
+
// 详情页要按后端的名字称呼它 ("Claude 正在思考" / "CodeBuddy 正在思考") —— 后端
|
|
2114
|
+
// 由 transcript 落盘路径唯一确定, 所以每次开 turn 时现算, 不必额外记在 AttachState 上。
|
|
2115
|
+
const cliOf = (a) => a.jsonlPath ? backendForPath(a.jsonlPath).name : undefined;
|
|
2112
2116
|
const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "") => {
|
|
2113
2117
|
const turnId = newTurnId();
|
|
2114
2118
|
a.queryEpoch = (a.queryEpoch ?? 0) + 1; // WeCom 侧的新一轮同样是 query 边界
|
|
2115
2119
|
a.turnFromChat = true; // 出处确凿: 这一轮有 frame, 群里就是它的主场
|
|
2116
2120
|
a.pendingFromCli = false; // 人改从聊天里说话了, 之前那条 CLI 输入不再是出处
|
|
2117
|
-
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, origin: consumeOrigin(a) });
|
|
2121
|
+
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, origin: consumeOrigin(a) });
|
|
2118
2122
|
// hardTimer 兜底: turn 若无终句 / turn_end 收口 (卡死/漏收), 到点仍收气泡。
|
|
2119
2123
|
const bubble = { frame, streamId, hardTimer: undefined, done: false };
|
|
2120
2124
|
const q = { turnId, bubble, isSlash };
|
|
@@ -2160,7 +2164,7 @@ export const startMirror = (deps) => {
|
|
|
2160
2164
|
const fromCli = a.pendingFromCli === true;
|
|
2161
2165
|
a.pendingFromCli = false;
|
|
2162
2166
|
a.turnFromChat = fromCli ? false : a.turnFromChat ?? true;
|
|
2163
|
-
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cwd: a.runningCwd || undefined, userQuery: query || undefined, origin: consumeOrigin(a) });
|
|
2167
|
+
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: query || undefined, origin: consumeOrigin(a) });
|
|
2164
2168
|
a.briefTurnId = turnId;
|
|
2165
2169
|
a.briefBubble = undefined;
|
|
2166
2170
|
a.briefIsSlash = false;
|
|
@@ -2311,6 +2315,7 @@ export const startMirror = (deps) => {
|
|
|
2311
2315
|
id: turnId,
|
|
2312
2316
|
target: a.target,
|
|
2313
2317
|
sessionId: a.sessionId,
|
|
2318
|
+
cli: cliOf(a),
|
|
2314
2319
|
cwd: a.runningCwd || undefined,
|
|
2315
2320
|
userQuery: task,
|
|
2316
2321
|
agent: { id: agentId, type: resolved?.type, description: resolved?.description, parentTurnId: a.briefTurnId },
|
|
@@ -2665,7 +2670,7 @@ export const startMirror = (deps) => {
|
|
|
2665
2670
|
if (!a.keepaliveTurnId) {
|
|
2666
2671
|
const turnId = newTurnId();
|
|
2667
2672
|
a.keepaliveTurnId = turnId;
|
|
2668
|
-
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, userQuery: text });
|
|
2673
|
+
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), userQuery: text });
|
|
2669
2674
|
}
|
|
2670
2675
|
};
|
|
2671
2676
|
// A keepalive ping turn is swallowed wholesale — the reply content doesn't
|
|
@@ -3057,7 +3062,7 @@ export const startMirror = (deps) => {
|
|
|
3057
3062
|
const detailUrlFor = (id, principal) => cfg.daemon.detailLinksInMirror && id
|
|
3058
3063
|
? buildDetailUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id, principal ? stripPrincipalPrefix(principal) : undefined)
|
|
3059
3064
|
: "";
|
|
3060
|
-
const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession, cwd, pendingCwd }) => {
|
|
3065
|
+
const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession, cwd, model, pendingCwd }) => {
|
|
3061
3066
|
const target = resolveTarget(targetOverride);
|
|
3062
3067
|
if (!target)
|
|
3063
3068
|
return { ok: false, reason: "no target chat (set wrc.mirror.pushChat or defaultChat, or pass target)" };
|
|
@@ -3083,6 +3088,10 @@ export const startMirror = (deps) => {
|
|
|
3083
3088
|
// otherwise. `undefined` (omitted) → carry from prev (re-attach case);
|
|
3084
3089
|
// `""` → explicit clear (newSession just consumed it); `"/foo"` → set.
|
|
3085
3090
|
const carryPending = pendingCwd !== undefined ? pendingCwd : (prevByTarget?.pendingCwd ?? "");
|
|
3091
|
+
// Same carry rule as pendingCwd, and for the same reason: a spawn knows the
|
|
3092
|
+
// model it asked for (possibly "" = CLI default) and says so; a re-attach
|
|
3093
|
+
// onto an already-running pane doesn't, so it keeps what was recorded.
|
|
3094
|
+
const carryModel = model !== undefined ? model.trim() : (prevByTarget?.model ?? deps.store.get(target)?.model ?? "");
|
|
3086
3095
|
if (prevByTarget)
|
|
3087
3096
|
detach(prevByTarget, "target reassigned");
|
|
3088
3097
|
// Build the attachment first so the tail's onItem closure can capture it.
|
|
@@ -3097,6 +3106,7 @@ export const startMirror = (deps) => {
|
|
|
3097
3106
|
// collapsing to cfg.wrc.cwd (which would mislabel /pwd, /clear, /new).
|
|
3098
3107
|
runningCwd: expandHome(((cwd ?? "").trim()) || readCwdFromJsonl(jsonlPath) || cfg.wrc.cwd),
|
|
3099
3108
|
pendingCwd: carryPending,
|
|
3109
|
+
model: carryModel,
|
|
3100
3110
|
tail: { stop: () => undefined, drain: () => undefined, livePath: () => undefined }, // placeholder; replaced below
|
|
3101
3111
|
standalonePending: Promise.resolve(),
|
|
3102
3112
|
recentToolSigs: new Map(),
|
|
@@ -3133,6 +3143,7 @@ export const startMirror = (deps) => {
|
|
|
3133
3143
|
tmuxSession: a.tmuxSession || undefined,
|
|
3134
3144
|
tmuxPane: a.tmuxPane || undefined,
|
|
3135
3145
|
cwd: a.runningCwd || undefined,
|
|
3146
|
+
model: a.model || undefined,
|
|
3136
3147
|
pendingCwd: a.pendingCwd || undefined,
|
|
3137
3148
|
keepaliveOff: prevRec?.keepaliveOff,
|
|
3138
3149
|
keepaliveOffAt: prevRec?.keepaliveOffAt,
|
|
@@ -3271,6 +3282,7 @@ export const startMirror = (deps) => {
|
|
|
3271
3282
|
tmuxPane: livePane,
|
|
3272
3283
|
tmuxSession: rec.tmuxSession ?? "",
|
|
3273
3284
|
cwd: rec.cwd,
|
|
3285
|
+
model: rec.model,
|
|
3274
3286
|
pendingCwd: rec.pendingCwd,
|
|
3275
3287
|
});
|
|
3276
3288
|
if (!r.ok) {
|
|
@@ -3357,6 +3369,15 @@ export const startMirror = (deps) => {
|
|
|
3357
3369
|
const t = text.trim();
|
|
3358
3370
|
return t === "/clear" || t.startsWith("/clear ") || t.startsWith("/clear\n");
|
|
3359
3371
|
};
|
|
3372
|
+
/** mtime(ms), 0 when unreadable — "newest transcript wins" 的排序键。 */
|
|
3373
|
+
const mtimeOf = (path) => {
|
|
3374
|
+
try {
|
|
3375
|
+
return statSync(path).mtimeMs;
|
|
3376
|
+
}
|
|
3377
|
+
catch {
|
|
3378
|
+
return 0;
|
|
3379
|
+
}
|
|
3380
|
+
};
|
|
3360
3381
|
const listJsonls = (dir) => {
|
|
3361
3382
|
try {
|
|
3362
3383
|
return new Set(readdirSync(dir).filter((n) => n.endsWith(".jsonl")));
|
|
@@ -3832,6 +3853,20 @@ export const startMirror = (deps) => {
|
|
|
3832
3853
|
// /new path: kill the old pane (so we don't leak orphan tmux windows) and
|
|
3833
3854
|
// spawn a fresh claude in pendingCwd ?? runningCwd ?? default. Returns the
|
|
3834
3855
|
// new sessionId/cwd so callers can render the user-facing reply.
|
|
3856
|
+
// 身份提供者。newSession / 重生 / clone 全都从这里取「这个 target 是谁」, 于是
|
|
3857
|
+
// 无论是群里手打的 /new、pane 死了的自愈重生, 还是编排生出来的分身, 新进程一
|
|
3858
|
+
// 睁眼就知道自己是谁 —— 而不是只有走 wizard 路由那一条路才有身份。
|
|
3859
|
+
// 由 index.ts 在启动时装上 (注册表活在那边); 没装 = 退回无身份行为, 全链路无回归。
|
|
3860
|
+
let charterOf;
|
|
3861
|
+
const setCharterProvider = (fn) => { charterOf = fn; };
|
|
3862
|
+
const charterFor = (target) => {
|
|
3863
|
+
try {
|
|
3864
|
+
return charterOf?.(target);
|
|
3865
|
+
}
|
|
3866
|
+
catch {
|
|
3867
|
+
return undefined;
|
|
3868
|
+
}
|
|
3869
|
+
};
|
|
3835
3870
|
const newSession = async (target, windowName, cli, opts) => {
|
|
3836
3871
|
const prev = byTarget.get(target);
|
|
3837
3872
|
// Resolution precedence (all chat-scoped except the running-cwd fallback):
|
|
@@ -3876,6 +3911,7 @@ export const startMirror = (deps) => {
|
|
|
3876
3911
|
cwdOverride: eff,
|
|
3877
3912
|
cli: effCli,
|
|
3878
3913
|
model: opts?.model,
|
|
3914
|
+
systemPrompt: opts?.systemPrompt ?? charterFor(target),
|
|
3879
3915
|
});
|
|
3880
3916
|
if (!r.ok)
|
|
3881
3917
|
return { ok: false, reason: r.reason };
|
|
@@ -3888,6 +3924,9 @@ export const startMirror = (deps) => {
|
|
|
3888
3924
|
cwd: r.cwd,
|
|
3889
3925
|
// Explicit "" clears any carried-over pending — it has just been applied.
|
|
3890
3926
|
pendingCwd: "",
|
|
3927
|
+
// Likewise explicit: `/new` starts a NEW session, so an unnamed model
|
|
3928
|
+
// means this CLI's default, not the dead pane's model.
|
|
3929
|
+
model: opts?.model?.trim() ?? "",
|
|
3891
3930
|
});
|
|
3892
3931
|
if (!att.ok)
|
|
3893
3932
|
return { ok: false, reason: att.reason };
|
|
@@ -3935,6 +3974,133 @@ export const startMirror = (deps) => {
|
|
|
3935
3974
|
pushProjectInfo(target, "created");
|
|
3936
3975
|
return { ok: true, sessionId: r.sessionId, cwd: r.cwd };
|
|
3937
3976
|
};
|
|
3977
|
+
// ── Clone ───────────────────────────────────────────────────────────
|
|
3978
|
+
// 分身 = `--resume <父 sid> --fork-session` 起的新 pane: CLI 把父亲的 transcript
|
|
3979
|
+
// 复制成一份新的再继续写, 于是子进程开局就带着父亲此刻读过的一切 (规范、目录、
|
|
3980
|
+
// 刚啃完的那份文档), 而父亲毫发无损。这正是「先把公共材料载进一个基座, 再从它
|
|
3981
|
+
// 身上分出 N 个干活的」所需要的语义: 材料只读一遍, 却进了 N 份上下文。
|
|
3982
|
+
//
|
|
3983
|
+
// 两件事非做不可:
|
|
3984
|
+
// `--fork-session` —— 少了它, 这个 pane 会**续写父亲的 jsonl**。两个 pane 写
|
|
3985
|
+
// 同一个 transcript, 两个 target 指向同一个 sid, 群与群从此串线且无法回滚。
|
|
3986
|
+
// 先说话再 attach —— 分叉文件不是开机就有的, CLI 在收到第一条消息那一刻才写。
|
|
3987
|
+
// 所以 spawn 之后先注入开场白, 等分叉出现拿到它自己的 sid, 再 attach。等不到
|
|
3988
|
+
// 就把 pane 一起收掉: 那时唯一已知的 sid 是父亲的, 认下去就是上面那种串线
|
|
3989
|
+
// (attach 也会正当地拒绝), 一个干净的失败远比一条错绑好收拾。
|
|
3990
|
+
const FORK_POLL_MS = 400;
|
|
3991
|
+
const FORK_WAIT_MS = 45_000;
|
|
3992
|
+
const awaitFork = async (projectDir, baseline, claimed) => {
|
|
3993
|
+
const deadline = Date.now() + FORK_WAIT_MS;
|
|
3994
|
+
while (Date.now() < deadline) {
|
|
3995
|
+
const born = [...listJsonls(projectDir)]
|
|
3996
|
+
.filter((n) => !baseline.has(n) && !claimed.has(n.replace(/\.jsonl$/, "")))
|
|
3997
|
+
.map((n) => ({ n, path: join(projectDir, n) }))
|
|
3998
|
+
// 种子化的 fork 一落地就带着父亲的 user 行 —— 空文件 (刚 touch 出来的)
|
|
3999
|
+
// 还不能认, 认早了 tail 会从一个还没写完的复制过程中间开始读。
|
|
4000
|
+
.filter((c) => firstUserUuid(c.path) !== undefined)
|
|
4001
|
+
.sort((x, y) => mtimeOf(y.path) - mtimeOf(x.path));
|
|
4002
|
+
const hit = born[0];
|
|
4003
|
+
if (hit)
|
|
4004
|
+
return { sessionId: hit.n.replace(/\.jsonl$/, ""), jsonlPath: hit.path };
|
|
4005
|
+
await sleepMs(FORK_POLL_MS);
|
|
4006
|
+
}
|
|
4007
|
+
return undefined;
|
|
4008
|
+
};
|
|
4009
|
+
const cloneSession = async (args) => {
|
|
4010
|
+
const p = byTarget.get(args.parent) ?? (await restoreFromStore(args.parent));
|
|
4011
|
+
// 继承上下文要求父亲有一个活着的 transcript, 且分身必须待在同一个项目目录 ——
|
|
4012
|
+
// `--resume` 是按项目目录寻址 session 的。调用方点名换目录 = 明确放弃继承。
|
|
4013
|
+
const sameCwd = !args.cwd?.trim() || expandHome(args.cwd.trim()) === (p?.runningCwd ?? "");
|
|
4014
|
+
const canInherit = args.inherit !== false && !!p?.sessionId && !!p.jsonlPath && existsSync(p.jsonlPath) && sameCwd;
|
|
4015
|
+
if (!canInherit) {
|
|
4016
|
+
const r = await newSession(args.target, args.windowName, args.cli, {
|
|
4017
|
+
model: args.model, cwd: args.cwd, systemPrompt: args.systemPrompt, silent: true,
|
|
4018
|
+
});
|
|
4019
|
+
return { ...r, inherited: false };
|
|
4020
|
+
}
|
|
4021
|
+
const parent = p;
|
|
4022
|
+
const projectDir = dirname(parent.jsonlPath);
|
|
4023
|
+
const baseline = listJsonls(projectDir);
|
|
4024
|
+
const lg = log.child({ sub: "clone", target: args.target });
|
|
4025
|
+
const r = await spawnTmuxClaude({
|
|
4026
|
+
cfg,
|
|
4027
|
+
log: lg,
|
|
4028
|
+
resumeSessionId: parent.sessionId,
|
|
4029
|
+
forkSession: true,
|
|
4030
|
+
windowName: args.windowName ?? args.target,
|
|
4031
|
+
cwdOverride: parent.runningCwd,
|
|
4032
|
+
cli: backendForPath(parent.jsonlPath).name,
|
|
4033
|
+
model: args.model,
|
|
4034
|
+
systemPrompt: args.systemPrompt,
|
|
4035
|
+
});
|
|
4036
|
+
if (!r.ok)
|
|
4037
|
+
return { ok: false, reason: r.reason, inherited: false };
|
|
4038
|
+
// 分叉文件不是开机就有的: CLI 在这个 pane **收到第一条消息**的那一刻才把父亲的
|
|
4039
|
+
// transcript 复制成新文件。所以必须先说一句话, 分身才会有自己的 sid —— 这句话
|
|
4040
|
+
// 顺便就是它的开场白 (「你是谁、继承了什么、准备接什么」), 群里看到的第一条也是它。
|
|
4041
|
+
const hello = args.bootstrap?.trim()
|
|
4042
|
+
|| "(wezard 分身初始化)用一句话说明: 你是谁、从谁那里继承了哪些上下文、准备接什么活。不要复述这条指令。";
|
|
4043
|
+
rememberInject(hello);
|
|
4044
|
+
const boot = await inject({
|
|
4045
|
+
text: hello, images: [], cfg, log: lg,
|
|
4046
|
+
sessionId: parent.sessionId, jsonlPath: parent.jsonlPath,
|
|
4047
|
+
tmuxTarget: r.tmuxPane, freshSpawn: true,
|
|
4048
|
+
});
|
|
4049
|
+
if (!boot.ok) {
|
|
4050
|
+
await tmuxRun(["kill-pane", "-t", r.tmuxPane ?? ""]);
|
|
4051
|
+
return { ok: false, reason: `分身开场白注入失败: ${boot.reason ?? "unknown"}`, inherited: false };
|
|
4052
|
+
}
|
|
4053
|
+
const fork = await awaitFork(projectDir, baseline, sidsClaimedByOthers(args.target));
|
|
4054
|
+
// 等不到分叉就必须收手: 此刻唯一已知的 sid 是父亲的, 认下去两个 target 会指向
|
|
4055
|
+
// 同一个会话 (attach 也会正当地拒绝)。留一个孤儿 pane 比留一条错绑更好收拾 ——
|
|
4056
|
+
// 所以连 pane 一起杀掉, 调用方拿到的是一个干净的失败。
|
|
4057
|
+
if (!fork) {
|
|
4058
|
+
await tmuxRun(["kill-pane", "-t", r.tmuxPane ?? ""]);
|
|
4059
|
+
return { ok: false, reason: "分身没能在超时内分叉出自己的会话 (CLI 可能不支持 --fork-session)", inherited: false };
|
|
4060
|
+
}
|
|
4061
|
+
const att = attach({
|
|
4062
|
+
sessionId: fork.sessionId,
|
|
4063
|
+
jsonlPath: fork.jsonlPath,
|
|
4064
|
+
target: args.target,
|
|
4065
|
+
tmuxPane: r.tmuxPane,
|
|
4066
|
+
tmuxSession: r.tmuxSession,
|
|
4067
|
+
cwd: r.cwd,
|
|
4068
|
+
pendingCwd: "",
|
|
4069
|
+
model: args.model?.trim() ?? "",
|
|
4070
|
+
});
|
|
4071
|
+
if (!att.ok)
|
|
4072
|
+
return { ok: false, reason: att.reason, inherited: false };
|
|
4073
|
+
const spawned = byTarget.get(args.target);
|
|
4074
|
+
if (spawned) {
|
|
4075
|
+
// muteUntilInject 是给"全新 pane 的 greeting 噪音"准备的; 分身的开场白是正经
|
|
4076
|
+
// 内容 (群里第一条就该是它), 所以这里不静音。
|
|
4077
|
+
spawned.justSpawned = true;
|
|
4078
|
+
spawned.keepaliveOff = true;
|
|
4079
|
+
spawned.keepaliveOffAt = Date.now();
|
|
4080
|
+
spawned.keepalive = undefined;
|
|
4081
|
+
persistPause(spawned);
|
|
4082
|
+
}
|
|
4083
|
+
lg.info({ parent: args.parent, sessionId: fork.sessionId }, "clone: forked");
|
|
4084
|
+
return { ok: true, sessionId: fork.sessionId, cwd: r.cwd, inherited: true };
|
|
4085
|
+
};
|
|
4086
|
+
/** 一个会话此刻的硬事实 —— whoami / 交接判断要用的那几个数。 */
|
|
4087
|
+
const sessionInfo = (target) => {
|
|
4088
|
+
const a = byTarget.get(target);
|
|
4089
|
+
const rec = a ? undefined : deps.store.get(target);
|
|
4090
|
+
const sessionId = a?.sessionId ?? rec?.sessionId ?? "";
|
|
4091
|
+
const jsonlPath = a?.jsonlPath ?? (rec?.jsonlPath ? expandHome(rec.jsonlPath) : "");
|
|
4092
|
+
if (!sessionId || !jsonlPath)
|
|
4093
|
+
return undefined;
|
|
4094
|
+
return {
|
|
4095
|
+
sessionId,
|
|
4096
|
+
jsonlPath,
|
|
4097
|
+
cwd: a?.runningCwd || rec?.cwd || "",
|
|
4098
|
+
cli: backendForPath(jsonlPath).name,
|
|
4099
|
+
model: a?.model || rec?.model || "",
|
|
4100
|
+
tmuxPane: a?.tmuxPane ?? rec?.tmuxPane ?? "",
|
|
4101
|
+
contextTokens: lastContextTokens(jsonlPath),
|
|
4102
|
+
};
|
|
4103
|
+
};
|
|
3938
4104
|
const getCwd = (target) => {
|
|
3939
4105
|
// pendingCwd is chat-scoped — a `set_workspace` from any sibling session
|
|
3940
4106
|
// queues the switch for the whole chat. runningCwd stays per-session (each
|
|
@@ -4057,7 +4223,9 @@ export const startMirror = (deps) => {
|
|
|
4057
4223
|
return {
|
|
4058
4224
|
ok: false,
|
|
4059
4225
|
reason: `chat '${chat}' has no ${t ? `'#${t}'` : "default"} session — create it with new_claude_session({ chat: '${chat}'${t ? `, tag: '${t}'` : ""}, cwd })`,
|
|
4060
|
-
|
|
4226
|
+
// 候选给**地址**而不是裸 key —— 这份清单存在的意义就是让调用方照着改一个
|
|
4227
|
+
// 能用的串; 隔壁两个分支早就这么做了, 只有这一条漏掉。
|
|
4228
|
+
candidates: allTargets().filter((k) => basePrincipalOf(k) === c.base).map((k) => peerAddress(cfg, self, k)),
|
|
4061
4229
|
};
|
|
4062
4230
|
}
|
|
4063
4231
|
const local = keyOf(basePrincipalOf(self), t);
|
|
@@ -4167,6 +4335,7 @@ export const startMirror = (deps) => {
|
|
|
4167
4335
|
jsonlPath,
|
|
4168
4336
|
cwd: a?.runningCwd || rec?.cwd || expandedDefaultCwd,
|
|
4169
4337
|
cli: jsonlPath ? backendForPath(jsonlPath).name : (activeBackends()[0]?.name ?? "claude"),
|
|
4338
|
+
model: a?.model || rec?.model || "",
|
|
4170
4339
|
tmuxPane: pane,
|
|
4171
4340
|
attached: !!a,
|
|
4172
4341
|
paneAlive,
|
|
@@ -4289,7 +4458,7 @@ export const startMirror = (deps) => {
|
|
|
4289
4458
|
if (!a.keepaliveTurnId) {
|
|
4290
4459
|
const turnId = newTurnId();
|
|
4291
4460
|
a.keepaliveTurnId = turnId;
|
|
4292
|
-
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, userQuery: text });
|
|
4461
|
+
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), userQuery: text });
|
|
4293
4462
|
}
|
|
4294
4463
|
};
|
|
4295
4464
|
let keepaliveTicking = false;
|
|
@@ -4702,7 +4871,7 @@ export const startMirror = (deps) => {
|
|
|
4702
4871
|
// 队列内单条失败已在 sendStandalone 里 warn 过, 这里吞掉, 不阻塞发卡。
|
|
4703
4872
|
}
|
|
4704
4873
|
},
|
|
4705
|
-
injectText: async (target, text, origin) => {
|
|
4874
|
+
injectText: async (target, text, origin, opts) => {
|
|
4706
4875
|
// Lazy restore, same as dispatch: a peer session that hasn't been talked
|
|
4707
4876
|
// to in this process lifetime (or any session after a reload) has an empty
|
|
4708
4877
|
// in-memory slot but a perfectly good persisted binding. Without this, an
|
|
@@ -4712,10 +4881,17 @@ export const startMirror = (deps) => {
|
|
|
4712
4881
|
return { ok: false, reason: "no mirror attached for target" };
|
|
4713
4882
|
if (!text.trim())
|
|
4714
4883
|
return { ok: false, reason: "empty text" };
|
|
4884
|
+
// 出处改写要早于 inject: tail 可能在 promise resolve 之前就开出 turn。
|
|
4885
|
+
if (opts?.fromChat)
|
|
4886
|
+
a.turnFromChat = true;
|
|
4887
|
+
// 名册增量搭这趟车进去 —— 同伴派活 / 定时任务 / graph 步骤都走这里, 所以
|
|
4888
|
+
// 一个从不被人直接说话的分身也能知道群里多了谁、少了谁。人说的话那一条
|
|
4889
|
+
// 路径在 inbound.send 上挂 (dispatch 有自己的 inject)。
|
|
4890
|
+
const full = text + noticeSuffixFor(target, text);
|
|
4715
4891
|
// Pre-record so the tail's user-line emission is suppressed by the
|
|
4716
4892
|
// recentInjects dedupe (otherwise the user sees their own demo prompt
|
|
4717
4893
|
// echoed back as a quoted bubble).
|
|
4718
|
-
rememberInject(
|
|
4894
|
+
rememberInject(full);
|
|
4719
4895
|
// 印章要早于 inject 落地 —— tail 是独立轮询的, 它可能在 inject 的 promise
|
|
4720
4896
|
// resolve 之前就看到 user 行并开出 turn, 那时归因必须已经在位。
|
|
4721
4897
|
if (origin)
|
|
@@ -4727,14 +4903,14 @@ export const startMirror = (deps) => {
|
|
|
4727
4903
|
// Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
|
|
4728
4904
|
// onto the forked jsonl once it appears (EOF offset — fork is seeded).
|
|
4729
4905
|
const resumeBaseline = listJsonls(dirname(a.jsonlPath));
|
|
4730
|
-
const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn-init", sessionId: sid }), resumeSessionId: sid, windowName: tagOfTarget(target) || target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name });
|
|
4906
|
+
const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn-init", sessionId: sid }), resumeSessionId: sid, windowName: tagOfTarget(target) || target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, model: a.model || undefined, systemPrompt: charterFor(target) });
|
|
4731
4907
|
if (!r.ok || !r.tmuxPane)
|
|
4732
4908
|
return { ok: false, reason: `respawn failed: ${r.reason ?? "unknown"}` };
|
|
4733
4909
|
a.tmuxPane = r.tmuxPane;
|
|
4734
4910
|
a.tmuxSession = r.tmuxSession ?? a.tmuxSession;
|
|
4735
4911
|
if (r.cwd)
|
|
4736
4912
|
a.runningCwd = r.cwd;
|
|
4737
|
-
deps.store.set(target, { sessionId: sid, jsonlPath: a.jsonlPath, tmuxSession: a.tmuxSession, tmuxPane: a.tmuxPane, cwd: a.runningCwd || undefined, pendingCwd: a.pendingCwd || undefined });
|
|
4913
|
+
deps.store.set(target, { sessionId: sid, jsonlPath: a.jsonlPath, tmuxSession: a.tmuxSession, tmuxPane: a.tmuxPane, cwd: a.runningCwd || undefined, model: a.model || undefined, pendingCwd: a.pendingCwd || undefined });
|
|
4738
4914
|
startMigrationWatcher(a, resumeBaseline, (p) => firstUserUuid(p) !== undefined);
|
|
4739
4915
|
}
|
|
4740
4916
|
// freshSpawn: true — the pane was just minted by /mirror/spawn, the TUI
|
|
@@ -4743,7 +4919,7 @@ export const startMirror = (deps) => {
|
|
|
4743
4919
|
a.muteUntilInject = false;
|
|
4744
4920
|
a.justSpawned = false;
|
|
4745
4921
|
const r = await inject({
|
|
4746
|
-
text, images: [], cfg, log: log.child({ principal: target, sessionId: sid, sub: "init-demo" }),
|
|
4922
|
+
text: full, images: [], cfg, log: log.child({ principal: target, sessionId: sid, sub: "init-demo" }),
|
|
4747
4923
|
sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn: true,
|
|
4748
4924
|
});
|
|
4749
4925
|
return r;
|
|
@@ -4911,6 +5087,9 @@ export const startMirror = (deps) => {
|
|
|
4911
5087
|
getCwd,
|
|
4912
5088
|
setPendingCwd,
|
|
4913
5089
|
newSession,
|
|
5090
|
+
cloneSession,
|
|
5091
|
+
sessionInfo,
|
|
5092
|
+
setCharterProvider,
|
|
4914
5093
|
shutdown: () => {
|
|
4915
5094
|
clearInterval(paneDriftTimer);
|
|
4916
5095
|
clearInterval(keepaliveTimer);
|
|
@@ -5092,7 +5271,7 @@ export const startMirror = (deps) => {
|
|
|
5092
5271
|
const resumeBaseline = !armMigration ? listJsonls(dirname(a.jsonlPath)) : undefined;
|
|
5093
5272
|
// Respawn in the binding's runningCwd (pendingCwd doesn't apply to a
|
|
5094
5273
|
// mid-turn reincarnation — only /new and /clear-with-pending swap cwd).
|
|
5095
|
-
const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn", sessionId: sid }), resumeSessionId: sid, windowName: tagOfTarget(a.target) || a.target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name });
|
|
5274
|
+
const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn", sessionId: sid }), resumeSessionId: sid, windowName: tagOfTarget(a.target) || a.target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, model: a.model || undefined, systemPrompt: charterFor(a.target) });
|
|
5096
5275
|
if (r.ok && r.tmuxPane && r.tmuxSession) {
|
|
5097
5276
|
a.tmuxPane = r.tmuxPane;
|
|
5098
5277
|
a.tmuxSession = r.tmuxSession;
|
|
@@ -5105,6 +5284,7 @@ export const startMirror = (deps) => {
|
|
|
5105
5284
|
tmuxSession: a.tmuxSession,
|
|
5106
5285
|
tmuxPane: a.tmuxPane,
|
|
5107
5286
|
cwd: a.runningCwd || undefined,
|
|
5287
|
+
model: a.model || undefined,
|
|
5108
5288
|
pendingCwd: a.pendingCwd || undefined,
|
|
5109
5289
|
});
|
|
5110
5290
|
log.info({ target: a.target, sessionId: sid, newPane: a.tmuxPane, newSession: a.tmuxSession }, "mirror: tmux respawned");
|