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.
Files changed (43) hide show
  1. package/CLAUDE.md +4 -2
  2. package/README.md +43 -37
  3. package/dist/daemon/chat-name.js +65 -2
  4. package/dist/daemon/chat-name.js.map +1 -1
  5. package/dist/daemon/detail.js +7 -0
  6. package/dist/daemon/detail.js.map +1 -1
  7. package/dist/daemon/graph.js +25 -3
  8. package/dist/daemon/graph.js.map +1 -1
  9. package/dist/daemon/inbound.js +156 -89
  10. package/dist/daemon/inbound.js.map +1 -1
  11. package/dist/daemon/index.js +773 -125
  12. package/dist/daemon/index.js.map +1 -1
  13. package/dist/daemon/jobs.js +91 -0
  14. package/dist/daemon/jobs.js.map +1 -0
  15. package/dist/daemon/mirror-bridge.js +194 -14
  16. package/dist/daemon/mirror-bridge.js.map +1 -1
  17. package/dist/daemon/mirror-store.js.map +1 -1
  18. package/dist/daemon/notices.js +70 -0
  19. package/dist/daemon/notices.js.map +1 -0
  20. package/dist/daemon/peers.js +93 -61
  21. package/dist/daemon/peers.js.map +1 -1
  22. package/dist/daemon/spawn-tmux.js +46 -4
  23. package/dist/daemon/spawn-tmux.js.map +1 -1
  24. package/dist/daemon/tasks.js +90 -0
  25. package/dist/daemon/tasks.js.map +1 -0
  26. package/dist/daemon/wizard.js +151 -0
  27. package/dist/daemon/wizard.js.map +1 -0
  28. package/dist/mcp/server.js +236 -102
  29. package/dist/mcp/server.js.map +1 -1
  30. package/dist/shared/cli-backends.js +10 -0
  31. package/dist/shared/cli-backends.js.map +1 -1
  32. package/dist/shared/config.js +54 -14
  33. package/dist/shared/config.js.map +1 -1
  34. package/dist/shared/detail-render.js +3 -2
  35. package/dist/shared/detail-render.js.map +1 -1
  36. package/dist/shared/detail-store.js.map +1 -1
  37. package/dist/shared/schedule-spec.js +240 -0
  38. package/dist/shared/schedule-spec.js.map +1 -0
  39. package/dist/shared/session-label.js +8 -4
  40. package/dist/shared/session-label.js.map +1 -1
  41. package/package.json +1 -1
  42. package/dist/daemon/topics.js +0 -106
  43. 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} ${content}`;
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
- candidates: allTargets().filter((k) => basePrincipalOf(k) === c.base),
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(text);
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");