wezard 2.5.1 → 2.6.1
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 +5 -0
- 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 +331 -148
- 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 +31 -7
- package/dist/daemon/session-scan.js.map +1 -1
- package/dist/daemon/spawn-tmux.js +167 -73
- 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/tmux-buffer.js +50 -0
- package/dist/daemon/tmux-buffer.js.map +1 -0
- 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/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
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { codexSessions, codexSessionForPane } from "../shared/codex.js";
|
|
2
|
+
import { codexHookLaunchArgs, codexHookCommand } from "../shared/codex-hooks.js";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
1
4
|
// Mirror-mode bridge.
|
|
2
5
|
//
|
|
3
6
|
// Strategy: a running interactive `claude` exposes no local IPC. Its only
|
|
@@ -14,31 +17,33 @@
|
|
|
14
17
|
// while a `--resume` injection is in flight; Claude Code locks aren't strict.
|
|
15
18
|
import { spawn } from "node:child_process";
|
|
16
19
|
import { randomUUID } from "node:crypto";
|
|
20
|
+
import { pasteTmuxBuffer } from "./tmux-buffer.js";
|
|
17
21
|
import { existsSync, readdirSync, readFileSync, statSync, watch, openSync, readSync, closeSync } from "node:fs";
|
|
18
22
|
import { join, dirname } from "node:path";
|
|
23
|
+
import { newStewardSpec, resumedStewardSpec } from "./steward-model.js";
|
|
19
24
|
import { expandHome, sanitizeId } from "../shared/paths.js";
|
|
20
25
|
import { augmentedPath } from "../shared/exec-path.js";
|
|
21
26
|
import { sleep, truncateWithCount, mapLimit } from "../shared/std.js";
|
|
22
|
-
import { activeBackends, backendForPath, projectDirFor, projectDirsFor, } from "../shared/cli-backends.js";
|
|
23
|
-
import { isModalPane, isAskqSubmitPage, parseModalOptions, pickModalAnswer, pickAutoAllowAnswer, parseConfirmContext, looksLikePicker } from "../shared/modal-pane.js";
|
|
24
|
-
import {
|
|
27
|
+
import { activeBackends, resolveCliBackend, backendForPath, projectDirFor, projectDirsFor, } from "../shared/cli-backends.js";
|
|
28
|
+
import { isModalPane, isAskqSubmitPage, parseModalOptions, pickModalAnswer, pickAutoAllowAnswer, parseConfirmContext, modalFingerprint, looksLikePicker } from "../shared/modal-pane.js";
|
|
29
|
+
import { hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow, hasMirrorPicker, mootMirrorPicker, runMirrorPickerFlow } from "./approval.js";
|
|
25
30
|
import { isAutoWindowActive } from "./session-cache.js";
|
|
26
31
|
import { noticeSuffixFor } from "./notices.js";
|
|
27
|
-
import {
|
|
32
|
+
import { dispatchPickerPolicy, pickerPolicy } from "./picker-policy.js";
|
|
28
33
|
import { renameWindow, runTmux, spawnTmuxClaude } from "./spawn-tmux.js";
|
|
29
34
|
import { selectEffort, selectModel } from "./model-select.js";
|
|
30
35
|
import { parseEffort } from "../shared/effort.js";
|
|
31
|
-
import { hasRegistry, markTranscript, probeOf, sessionOnPane,
|
|
36
|
+
import { hasRegistry, liveSessionPids, markTranscript, probeOf, sessionOnPane, submittedSince, watchRegistry } from "./cc-session.js";
|
|
32
37
|
import { waitForIdle } from "./graph.js";
|
|
33
38
|
import { wizardStore } from "./wizard.js";
|
|
34
39
|
import { startSubagentWatch } from "./subagent-tail.js";
|
|
35
40
|
import { knowsToolUse, recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnQuery, recordTurnInject, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, lastChannelOf, openTurnsOf, buildDetailUrl, buildChatUrl, roleUniq } from "./detail.js";
|
|
36
41
|
import { errText } from "./last-response.js";
|
|
37
|
-
import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, withTagHeader, withLinkedTagHeader, chatHead, headed, tagBadge, linkTags, parseTagHeader, MAX_BODY_LINKS, isInternalKey } from "../shared/session-label.js";
|
|
42
|
+
import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, withTagHeader, withLinkedTagHeader, chatHead, headed, tagBadge, linkTags, parseTagHeader, MAX_BODY_LINKS, isInternalKey, sameBot } from "../shared/session-label.js";
|
|
38
43
|
import { splitMarkdown } from "../shared/md-chunk.js";
|
|
39
44
|
import { isWxFrame } from "../shared/wx-text.js";
|
|
40
45
|
import { randomTip } from "./tips.js";
|
|
41
|
-
import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress
|
|
46
|
+
import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress } from "./chat-name.js";
|
|
42
47
|
import { stripAnsi, paneIsBusy, summarizeTail, lastAssistantText, lastReply, replyToPeer as replyToPeerIn, unwrapPasted, lastContextTokens, lastModel, cacheTouchMs, cacheTtlSec, keepaliveStamps, openKeepalivePing, lastUserTurn, talkRounds, openToolUses, renderDialog } from "./peers.js";
|
|
43
48
|
import { keepalivePingSigs, isKeepalivePingText } from "../shared/keepalive.js";
|
|
44
49
|
// PATH augmentation: launchd / systemd start the daemon
|
|
@@ -92,7 +97,9 @@ const readCwdFromJsonl = (path) => {
|
|
|
92
97
|
// parse (wrong jsonl dialect). Only genuine discovery searches the union.
|
|
93
98
|
const rankedJsonlsForCwd = (cwd, only, exclude) => projectDirsFor(expandHome(cwd))
|
|
94
99
|
.filter(({ backend }) => !only || backend.name === only.name)
|
|
95
|
-
.flatMap(({ dir }) => {
|
|
100
|
+
.flatMap(({ backend, dir }) => {
|
|
101
|
+
if (backend.name === "codex")
|
|
102
|
+
return codexSessions(dir, expandHome(cwd)).filter((x) => !exclude?.has(x.sessionId)).map((x) => ({ ...x, mtime: statSync(x.jsonlPath).mtimeMs }));
|
|
96
103
|
let names;
|
|
97
104
|
try {
|
|
98
105
|
names = readdirSync(dir).filter((n) => n.endsWith(".jsonl"));
|
|
@@ -133,6 +140,8 @@ const findJsonlBySid = (sid, only) => {
|
|
|
133
140
|
return (only ? [only] : activeBackends())
|
|
134
141
|
.flatMap((b) => {
|
|
135
142
|
const root = expandHome(b.projectsDir);
|
|
143
|
+
if (b.name === "codex")
|
|
144
|
+
return codexSessions(root).filter((x) => x.sessionId === sid).map((x) => x.jsonlPath);
|
|
136
145
|
try {
|
|
137
146
|
return readdirSync(root).map((d) => join(root, d, sidFile));
|
|
138
147
|
}
|
|
@@ -719,6 +728,11 @@ const renderLine = (raw, deps) => {
|
|
|
719
728
|
},
|
|
720
729
|
});
|
|
721
730
|
}
|
|
731
|
+
// Codex turn_context reports the real tier before any token usage.
|
|
732
|
+
// Forward metadata without inventing an API call or resetting context counts.
|
|
733
|
+
if (!u && (line.message?.model || line.effort)) {
|
|
734
|
+
out.push({ kind: "turn_usage", model: line.message?.model, effort: line.effort });
|
|
735
|
+
}
|
|
722
736
|
// Terminal stop_reason → emit turn_end so onItem closes the live bubble.
|
|
723
737
|
// `tool_use` is intentionally excluded — more turns will follow once the
|
|
724
738
|
// tool result lands; finalizing now would split a single logical reply.
|
|
@@ -730,7 +744,7 @@ const renderLine = (raw, deps) => {
|
|
|
730
744
|
// 且此后整轮的 tool/text 全部退化成散装气泡。终态消息里必然有 text 块 (含
|
|
731
745
|
// tool_use 的消息 stop_reason 恒为 "tool_use"), 且实测终态消息从不拆出第二个
|
|
732
746
|
// text 行 —— 所以"本行带 text 块"精确等价于"本消息的最后一行"。
|
|
733
|
-
if (isFinal && blocks.some((b) => b?.type === "text"))
|
|
747
|
+
if (isFinal && !line.awaitTurnEnd && blocks.some((b) => b?.type === "text"))
|
|
734
748
|
out.push({ kind: "turn_end" });
|
|
735
749
|
// 软收口 (codebuddy): 这条消息写完了, 但说不出后面还有没有 function_call。
|
|
736
750
|
else if (line.softTurnEnd)
|
|
@@ -774,6 +788,23 @@ const renderLine = (raw, deps) => {
|
|
|
774
788
|
const TAG_HEADER_BUDGET = 256 * (2 + MAX_BODY_LINKS);
|
|
775
789
|
export const startMirrorTail = (deps) => {
|
|
776
790
|
const { log } = deps;
|
|
791
|
+
// Codex records model/effort separately from per-response usage. Keep that
|
|
792
|
+
// context local to this tail (never share it between sessions/normalizers).
|
|
793
|
+
if (backendForPath(deps.jsonlPath).name === "codex" && deps.normalizeLine) {
|
|
794
|
+
const normalize = deps.normalizeLine;
|
|
795
|
+
let model = deps.startOffset === 0 ? "" : lastModel(deps.jsonlPath);
|
|
796
|
+
let effort;
|
|
797
|
+
deps = { ...deps, normalizeLine: (raw) => {
|
|
798
|
+
const line = normalize(raw);
|
|
799
|
+
if (line?.message?.model) {
|
|
800
|
+
model = line.message.model;
|
|
801
|
+
effort = line.effort;
|
|
802
|
+
}
|
|
803
|
+
return line?.message?.usage
|
|
804
|
+
? { ...line, effort, message: { ...line.message, model } }
|
|
805
|
+
: line;
|
|
806
|
+
} };
|
|
807
|
+
}
|
|
777
808
|
// A single logical tail follows ONE sessionId whose transcript file may move
|
|
778
809
|
// between sibling project dirs — Claude Code's EnterWorktree/ExitWorktree
|
|
779
810
|
// rename <sid>.jsonl into/out of <cwd>/.claude/worktrees/<name>, each cwd
|
|
@@ -827,6 +858,11 @@ export const startMirrorTail = (deps) => {
|
|
|
827
858
|
watchedPath = path;
|
|
828
859
|
try {
|
|
829
860
|
watcher = watch(path, { persistent: false }, () => drain());
|
|
861
|
+
const active = watcher;
|
|
862
|
+
active.on("error", (err) => {
|
|
863
|
+
active.close();
|
|
864
|
+
log.warn({ err: err.message }, "fs.watch failed; relying on poll");
|
|
865
|
+
});
|
|
830
866
|
}
|
|
831
867
|
catch (e) {
|
|
832
868
|
watcher = undefined;
|
|
@@ -1100,19 +1136,19 @@ const extractPaneAssistantTail = (pane) => {
|
|
|
1100
1136
|
// long after the pane went back to accepting input — wedging the mirror shut
|
|
1101
1137
|
// permanently. Bare `-p` captures only what is currently on screen.
|
|
1102
1138
|
//
|
|
1103
|
-
//
|
|
1104
|
-
//
|
|
1105
|
-
//
|
|
1106
|
-
|
|
1107
|
-
// `screen` = 实际参与判定的那一屏文本, 一并返回给调用方做选项解析 —— 再 capture
|
|
1108
|
-
// 一次会拿到"下一瞬间"的屏幕, 与 verdict 不同源(框可能已被本地按掉), 按键就按错了。
|
|
1139
|
+
// Trim trailing blank rows so the bottom 15 rows locate the active picker.
|
|
1140
|
+
// Keep the full current screen from that same capture for operation context;
|
|
1141
|
+
// a second capture might already be a different invocation. -J joins terminal
|
|
1142
|
+
// wraps without reading scrollback or dropping long command arguments.
|
|
1109
1143
|
const detectModalPicker = async (target) => {
|
|
1110
|
-
const r = await runTmux(["capture-pane", "-t", target, "-p"]);
|
|
1144
|
+
const r = await runTmux(["capture-pane", "-t", target, "-p", "-J"]);
|
|
1111
1145
|
if (!r.ok)
|
|
1112
1146
|
return { modal: false, screen: "" };
|
|
1113
1147
|
const lines = r.stdout.replace(/\s+$/u, "").split("\n");
|
|
1114
|
-
const screen = lines.
|
|
1115
|
-
|
|
1148
|
+
const screen = lines.join("\n");
|
|
1149
|
+
// Only the bottom picker may block input. Earlier completed pickers on the
|
|
1150
|
+
// current screen remain useful context, but must not look like a live modal.
|
|
1151
|
+
return { ...isModalPane(lines.slice(-15).join("\n")), screen };
|
|
1116
1152
|
};
|
|
1117
1153
|
// 按下确认框的第 index 号选项。Claude Code 的 picker 数字键即选即确认; CodeBuddy
|
|
1118
1154
|
// 的框数字键不一定生效, 且高亮可能停在别的选项上 (实测见过默认高亮在 "Yes, and
|
|
@@ -1124,10 +1160,14 @@ const MODAL_PRESS_SETTLE_MS = 400;
|
|
|
1124
1160
|
const MODAL_ARROW_SETTLE_MS = 150;
|
|
1125
1161
|
const highlightedIndex = (screen) => parseModalOptions(screen).find((o) => o.selected)?.index;
|
|
1126
1162
|
const pressModalIndex = async (pane, before, index) => {
|
|
1163
|
+
const fingerprint = modalFingerprint(before.screen);
|
|
1164
|
+
const current = await detectModalPicker(pane);
|
|
1165
|
+
if (!current.modal || modalFingerprint(current.screen) !== fingerprint)
|
|
1166
|
+
return current;
|
|
1127
1167
|
await runTmux(["send-keys", "-t", pane, String(index)]);
|
|
1128
1168
|
await sleep(MODAL_PRESS_SETTLE_MS);
|
|
1129
1169
|
let after = await detectModalPicker(pane);
|
|
1130
|
-
if (!after.modal || after.
|
|
1170
|
+
if (!after.modal || modalFingerprint(after.screen) !== fingerprint)
|
|
1131
1171
|
return after;
|
|
1132
1172
|
// 数字键没关掉框: 把高亮挪到目标项。guard 上限盖过任何确认框的选项数。
|
|
1133
1173
|
for (let guard = 0; guard < 8; guard++) {
|
|
@@ -1137,7 +1177,7 @@ const pressModalIndex = async (pane, before, index) => {
|
|
|
1137
1177
|
await runTmux(["send-keys", "-t", pane, cur < index ? "Down" : "Up"]);
|
|
1138
1178
|
await sleep(MODAL_ARROW_SETTLE_MS);
|
|
1139
1179
|
after = await detectModalPicker(pane);
|
|
1140
|
-
if (!after.modal || after.
|
|
1180
|
+
if (!after.modal || modalFingerprint(after.screen) !== fingerprint)
|
|
1141
1181
|
return after;
|
|
1142
1182
|
}
|
|
1143
1183
|
if (highlightedIndex(after.screen) !== index)
|
|
@@ -1246,10 +1286,39 @@ const BOX_PROMPT_RE = /^\s*[❯>]\s?/u;
|
|
|
1246
1286
|
/** 底 ruler 之下只该剩 cwd/模式两行状态; 多于此说明框到的不是输入框。 */
|
|
1247
1287
|
const BOX_FOOTER_ROWS = 6;
|
|
1248
1288
|
const UNKNOWN_BOX = { known: false, body: "" };
|
|
1249
|
-
export const parseInputBox = (cap) => {
|
|
1289
|
+
export const parseInputBox = (cap, styledLayout = cap) => {
|
|
1250
1290
|
const lines = cap.split("\n");
|
|
1251
1291
|
while (lines.length && lines[lines.length - 1].trim() === "")
|
|
1252
1292
|
lines.pop();
|
|
1293
|
+
// Codex has no rulers. Anchor the composer to its bottom shortcuts footer,
|
|
1294
|
+
// then use the LAST › prompt: earlier prompts belong to the transcript.
|
|
1295
|
+
// Requiring a status row and a blank separator avoids treating arbitrary
|
|
1296
|
+
// output containing › as editable input. Collapsed pastes remain visible
|
|
1297
|
+
// as a body change even though the original text fingerprints disappear.
|
|
1298
|
+
const styledLines = styledLayout.split("\n");
|
|
1299
|
+
const layout = styledLines.map((line) => line.replace(/\x1b\[[0-9;:]*m/gu, "").replace(OTHER_ESC_RE, ""));
|
|
1300
|
+
while (layout.length && !layout[layout.length - 1].trim())
|
|
1301
|
+
layout.pop();
|
|
1302
|
+
const footer = layout.length - 1;
|
|
1303
|
+
// Codex hides the shortcuts row while the composer contains text.
|
|
1304
|
+
const shortcuts = /\? for shortcuts\s*$/u.test(layout[footer] ?? "");
|
|
1305
|
+
if (shortcuts || (layout[footer] ?? "").includes("·")) {
|
|
1306
|
+
let status = shortcuts ? footer - 1 : footer;
|
|
1307
|
+
while (status >= 0 && !layout[status].trim())
|
|
1308
|
+
status--;
|
|
1309
|
+
if (status >= 0 && footer - status <= BOX_FOOTER_ROWS && layout[status].includes("·") && status > 0 && !layout[status - 1].trim()) {
|
|
1310
|
+
let prompt = status - 2;
|
|
1311
|
+
// Continuation rows are indented; a › inside pasted content is not a
|
|
1312
|
+
// new composer prompt.
|
|
1313
|
+
while (prompt >= 0 && !/^›(?:\s|$)/u.test(layout[prompt]))
|
|
1314
|
+
prompt--;
|
|
1315
|
+
if (prompt >= 0 && (shortcuts || /\x1b\[[0-9;:]*48[;:]/u.test(styledLines[prompt]))) {
|
|
1316
|
+
const body = dropDim(styledLines.slice(prompt, status).join("\n"))
|
|
1317
|
+
.replace(/^\s*›\s?/u, "").replace(/[\s\u200b\ufeff]+/gu, " ").trim();
|
|
1318
|
+
return { known: true, body };
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1253
1322
|
const rulers = lines.reduce((acc, l, i) => (BOX_RULER_RE.test(l) ? [...acc, i] : acc), []);
|
|
1254
1323
|
const bottom = rulers[rulers.length - 1];
|
|
1255
1324
|
const top = rulers[rulers.length - 2];
|
|
@@ -1287,10 +1356,18 @@ export const dropDim = (styled) => {
|
|
|
1287
1356
|
};
|
|
1288
1357
|
const captureUndimmed = async (target, rows) => {
|
|
1289
1358
|
const r = await runTmux(["capture-pane", "-t", target, "-p", "-e", "-S", `-${rows}`]);
|
|
1290
|
-
|
|
1359
|
+
// Negative -S includes scrollback plus the entire visible pane, not its
|
|
1360
|
+
// last N rows. Crop before removing dim text so row boundaries survive.
|
|
1361
|
+
return r.ok ? dropDim(r.stdout.replace(/\n$/u, "").split("\n").slice(-rows).join("\n")) : "";
|
|
1291
1362
|
};
|
|
1292
1363
|
// 24 行足够容纳展开后的多行粘贴 (折叠态只占一行)。
|
|
1293
|
-
const
|
|
1364
|
+
const readPaneInput = async (target) => {
|
|
1365
|
+
const r = await runTmux(["capture-pane", "-t", target, "-p", "-e", "-S", "-24"]);
|
|
1366
|
+
const raw = r.ok ? r.stdout : "";
|
|
1367
|
+
const cap = dropDim(raw);
|
|
1368
|
+
return { cap, box: parseInputBox(cap, raw) };
|
|
1369
|
+
};
|
|
1370
|
+
const readInputBox = async (target) => (await readPaneInput(target)).box;
|
|
1294
1371
|
// ── 注入门控的会话证人 (cc-session) ─────────────────────────────────
|
|
1295
1372
|
// 能从会话自己写的 json 读到的判据, 就不读 pane: 就绪看注册表的 status, 提交看
|
|
1296
1373
|
// 注册表的 busy 翻转或 jsonl 里新落的那一行。输入框里「贴进去没有」是唯一只有
|
|
@@ -1445,8 +1522,6 @@ const withImageRefs = (text, images) => {
|
|
|
1445
1522
|
// 证人不作声 (忙时排队、slash 命令、无注册表的后端) 才看框回没回到贴之前的样子。
|
|
1446
1523
|
// 框认得出来时这条判据可信, 于是允许多补回车 (空框上的回车在 CLI 里是 no-op);
|
|
1447
1524
|
// 每次补之前再问一遍证人, 已提交就不补。
|
|
1448
|
-
/** 具名粘贴 buffer 的序号 (见 loadAndPaste)。 */
|
|
1449
|
-
let pasteSeq = 0;
|
|
1450
1525
|
const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS) => {
|
|
1451
1526
|
// Warm pane: tight timings, low latency. Fresh spawn (claude --resume just
|
|
1452
1527
|
// started, transcript still loading): extended timings — bracketed-paste
|
|
@@ -1484,29 +1559,16 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1484
1559
|
const baseBody = alreadyTyped ? "" : preBox.body;
|
|
1485
1560
|
/** 框里还是贴之前那样 (或彻底空了) = 我们的文本不在框里。 */
|
|
1486
1561
|
const boxIntact = (b) => boxVerify && b.known && (b.body === "" || b.body === baseBody);
|
|
1487
|
-
const loadAndPaste =
|
|
1488
|
-
// stdin variant of the shared exec path — it carries the same hard timeout,
|
|
1489
|
-
// which a hand-rolled spawn here did not.
|
|
1490
|
-
// 每次注入用自己的具名 buffer: 不同 pane 的注入并发时, 共用 tmux 的默认 buffer 会让
|
|
1491
|
-
// 一方 load 之后、paste 之前被另一方覆盖 —— 两段文本互换 pane (实测: 同一毫秒投的两份
|
|
1492
|
-
// 回执, 发给 A 的进了 B)。同一 pane 的串行管不到跨 pane。
|
|
1493
|
-
const buf = `wezard-${process.pid}-${++pasteSeq}`;
|
|
1494
|
-
const loaded = await runTmux(["load-buffer", "-b", buf, "-"], { stdin: text });
|
|
1495
|
-
if (!loaded.ok)
|
|
1496
|
-
return { ok: false, reason: `tmux load-buffer failed: ${loaded.stderr.slice(-200) || loaded.code}` };
|
|
1497
|
-
const pasted = await runTmux(["paste-buffer", "-b", buf, "-p", "-d", "-t", target]);
|
|
1498
|
-
if (!pasted.ok)
|
|
1499
|
-
return { ok: false, reason: `tmux paste-buffer failed: ${pasted.stderr.slice(-200)}` };
|
|
1500
|
-
return { ok: true };
|
|
1501
|
-
};
|
|
1562
|
+
const loadAndPaste = () => pasteTmuxBuffer(target, text);
|
|
1502
1563
|
// 框认得出来 → 看它变没变; 认不出来 → 退回宽窗口找 headFp (长文本会换行,
|
|
1503
1564
|
// 头部可能在框顶, 所以窗口要宽)。
|
|
1504
1565
|
const sawLanded = async (timeoutMs) => {
|
|
1505
1566
|
const t0 = Date.now();
|
|
1506
1567
|
while (Date.now() - t0 < timeoutMs) {
|
|
1507
|
-
const cap = await
|
|
1508
|
-
|
|
1509
|
-
|
|
1568
|
+
const { cap, box } = await readPaneInput(target);
|
|
1569
|
+
// A redraw can hide the baseline. Once the composer reappears, its
|
|
1570
|
+
// nonempty body also proves landing of a collapsed paste.
|
|
1571
|
+
if (box.known ? (boxVerify ? !boxIntact(box) : box.body !== "") : Boolean(headFp) && stripWs(cap).includes(headFp))
|
|
1510
1572
|
return true;
|
|
1511
1573
|
await sleep(POLL_MS);
|
|
1512
1574
|
}
|
|
@@ -1519,6 +1581,7 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1519
1581
|
return Boolean(tailFp) && stripWs(pane).includes(tailFp);
|
|
1520
1582
|
};
|
|
1521
1583
|
let pasteSeen = alreadyTyped;
|
|
1584
|
+
let pastedAt = Date.now();
|
|
1522
1585
|
if (alreadyTyped) {
|
|
1523
1586
|
log.warn({ target, headFp }, "mirror inject: 输入框里已有这段文本 (上次未提交) — 不重贴, 直接提交");
|
|
1524
1587
|
}
|
|
@@ -1526,7 +1589,11 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1526
1589
|
const r = await loadAndPaste();
|
|
1527
1590
|
if (!r.ok)
|
|
1528
1591
|
return r;
|
|
1529
|
-
|
|
1592
|
+
pastedAt = Date.now();
|
|
1593
|
+
// An unknown layout cannot verify collapsed text by fingerprint. Bound
|
|
1594
|
+
// that inconclusive probe by the catch-up budget instead of waiting 6s
|
|
1595
|
+
// and then sleeping another 2.5s before the first Enter.
|
|
1596
|
+
pasteSeen = await sawLanded(boxVerify ? PASTE_VERIFY_MS : POST_PASTE_SETTLE_FALLBACK_MS);
|
|
1530
1597
|
}
|
|
1531
1598
|
if (!pasteSeen) {
|
|
1532
1599
|
const box = await readInputBox(target);
|
|
@@ -1540,7 +1607,7 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1540
1607
|
log.warn({ target, headFp, boxKnown: box.known }, "mirror inject: 粘贴未验证但输入框非空 — 首次粘贴已落地, 不重贴");
|
|
1541
1608
|
pasteSeen = true;
|
|
1542
1609
|
}
|
|
1543
|
-
else {
|
|
1610
|
+
else if (boxVerify && box.known) {
|
|
1544
1611
|
log.warn({ target, headFp, boxKnown: box.known }, "mirror inject: 输入框确认为空, 重贴一次");
|
|
1545
1612
|
await sleep(RETRY_SETTLE_MS);
|
|
1546
1613
|
if (submitted())
|
|
@@ -1548,8 +1615,14 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1548
1615
|
const r = await loadAndPaste();
|
|
1549
1616
|
if (!r.ok)
|
|
1550
1617
|
return r;
|
|
1618
|
+
pastedAt = Date.now();
|
|
1551
1619
|
pasteSeen = await sawLanded(PASTE_VERIFY_MS);
|
|
1552
1620
|
}
|
|
1621
|
+
else {
|
|
1622
|
+
// Missing fingerprints are not proof of an empty composer: other TUIs
|
|
1623
|
+
// may collapse pasted text. Never duplicate a paste on that evidence.
|
|
1624
|
+
log.warn({ target, headFp }, "mirror inject: 输入框布局未知, 不重贴未确认的粘贴");
|
|
1625
|
+
}
|
|
1553
1626
|
}
|
|
1554
1627
|
// 粘贴落定 = 框里是我们的内容, 且连续两次读到同一个样子 —— TUI 已把整段粘贴
|
|
1555
1628
|
// (bracketed-paste end 在内) 消化完, 紧跟的回车不会被并进粘贴。实测冷启动的
|
|
@@ -1568,10 +1641,10 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1568
1641
|
// Bracketed-paste end + TUI catch-up. 框认不出来 / 粘贴没被看见时没有条件可查,
|
|
1569
1642
|
// 维持固定等待 (fresh respawn: claude --resume is still loading the transcript)。
|
|
1570
1643
|
if (!alreadyTyped) {
|
|
1571
|
-
if (pasteSeen
|
|
1644
|
+
if (pasteSeen)
|
|
1572
1645
|
await pasteSettled(POST_PASTE_SETTLE_MS);
|
|
1573
1646
|
else
|
|
1574
|
-
await sleep(
|
|
1647
|
+
await sleep(Math.max(0, POST_PASTE_SETTLE_FALLBACK_MS - (Date.now() - pastedAt)));
|
|
1575
1648
|
}
|
|
1576
1649
|
const sendEnter = async () => {
|
|
1577
1650
|
const e = await runTmux(["send-keys", "-t", target, "Enter"]);
|
|
@@ -1579,7 +1652,7 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1579
1652
|
};
|
|
1580
1653
|
const cleared = async () => {
|
|
1581
1654
|
const box = await readInputBox(target);
|
|
1582
|
-
return
|
|
1655
|
+
return box.known ? (boxVerify ? boxIntact(box) : box.body === "") : !(await inputBoxStillHasTail());
|
|
1583
1656
|
};
|
|
1584
1657
|
/** 证人先答; 它的窗口过了还不作声, 才每轮加看一眼框。 */
|
|
1585
1658
|
const waitForSubmit = async (timeoutMs) => {
|
|
@@ -1612,8 +1685,10 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1612
1685
|
return { ok: true }; // 迟到的确认 —— 补的这一下会落在空框上, 省了
|
|
1613
1686
|
log.warn({ target, tailFp, attempt: i + 1 }, "mirror inject: 回车后输入框未清空, 再补一次");
|
|
1614
1687
|
}
|
|
1615
|
-
if (i === 0)
|
|
1688
|
+
if (i === 0) {
|
|
1616
1689
|
enterAt = Date.now();
|
|
1690
|
+
log.info({ target, pasteToEnterMs: enterAt - pastedAt, pasteSeen, boxTrusted, freshSpawn }, "mirror inject: first Enter");
|
|
1691
|
+
}
|
|
1617
1692
|
const e = await sendEnter();
|
|
1618
1693
|
if (!e.ok)
|
|
1619
1694
|
return e;
|
|
@@ -1628,14 +1703,42 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1628
1703
|
log.warn({ target, tailFp, freshSpawn, boxTrusted, witness: witness.status }, "mirror inject: clear not observed, trusting submit");
|
|
1629
1704
|
return { ok: true, uncertain: true, reason: "目标会话可能正忙或未消费输入(回车后输入框未清空)" };
|
|
1630
1705
|
};
|
|
1631
|
-
const injectViaSpawn = (args) => {
|
|
1706
|
+
const injectViaSpawn = async (args) => {
|
|
1632
1707
|
const { text, images = [], cfg, log, sessionId, jsonlPath } = args;
|
|
1633
|
-
const
|
|
1708
|
+
const backend = backendForPath(jsonlPath);
|
|
1709
|
+
const bin = backend.bin;
|
|
1710
|
+
let cwd = expandHome(cfg.wrc.cwd);
|
|
1711
|
+
let codexArgs = [];
|
|
1712
|
+
if (backend.name === "codex") {
|
|
1713
|
+
try {
|
|
1714
|
+
const fd = openSync(jsonlPath, "r");
|
|
1715
|
+
let head;
|
|
1716
|
+
try {
|
|
1717
|
+
const buf = Buffer.alloc(256 * 1024);
|
|
1718
|
+
head = buf.subarray(0, readSync(fd, buf, 0, buf.length, 0)).toString("utf8");
|
|
1719
|
+
}
|
|
1720
|
+
finally {
|
|
1721
|
+
closeSync(fd);
|
|
1722
|
+
}
|
|
1723
|
+
const meta = JSON.parse(head.split("\n", 1)[0]).payload;
|
|
1724
|
+
if (typeof meta?.cwd === "string")
|
|
1725
|
+
cwd = meta.cwd;
|
|
1726
|
+
const hookEntry = fileURLToPath(new URL("../cli/codex-hook.js", import.meta.url));
|
|
1727
|
+
const { loadConfig } = await import("../shared/config.js");
|
|
1728
|
+
const command = codexHookCommand(loadConfig().sourcePath, `http://127.0.0.1:${cfg.daemon.port}`, hookEntry);
|
|
1729
|
+
codexArgs = await codexHookLaunchArgs(bin, cwd, command, cfg.approval.hookTimeoutSec + 10, cfg.approval.enabled);
|
|
1730
|
+
}
|
|
1731
|
+
catch (e) {
|
|
1732
|
+
return { ok: false, reason: `Codex resume setup failed: ${e.message}` };
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1634
1735
|
// Spawn-mode (no live TTY) can't do clipboard+C-v — fall back to `@<path>`,
|
|
1635
1736
|
// which Claude parses at submit time and inlines as image content blocks
|
|
1636
1737
|
// without a model-decided Read tool turn.
|
|
1637
1738
|
const finalText = withImageRefs(text, images);
|
|
1638
|
-
const cliArgs = [
|
|
1739
|
+
const cliArgs = backend.name === "codex" ? ["exec", "resume", "--json", ...codexArgs,
|
|
1740
|
+
...cfg.wrc.extraArgs.filter((arg) => !arg.startsWith("--disallowedTools")),
|
|
1741
|
+
...images.flatMap((path) => ["--image", path]), sessionId, text] : [
|
|
1639
1742
|
"-p",
|
|
1640
1743
|
finalText,
|
|
1641
1744
|
"--resume",
|
|
@@ -1648,7 +1751,7 @@ const injectViaSpawn = (args) => {
|
|
|
1648
1751
|
log.info({ sessionId, bin, len: text.length }, "mirror inject");
|
|
1649
1752
|
return new Promise((resolve) => {
|
|
1650
1753
|
const proc = spawn(bin, cliArgs, {
|
|
1651
|
-
cwd
|
|
1754
|
+
cwd,
|
|
1652
1755
|
env: { ...process.env, PATH: augmentedPath(process.env.PATH) },
|
|
1653
1756
|
stdio: ["ignore", "ignore", "pipe"],
|
|
1654
1757
|
});
|
|
@@ -1693,6 +1796,30 @@ const INJECT_JOB_TIMEOUT_MS = 90_000;
|
|
|
1693
1796
|
/** 没有绑定的 pane 静默多久才算孤儿。刚 spawn、还没 attach 的 pane (并发 fan-out 时
|
|
1694
1797
|
* 别人的 sweep 会撞见它) 在这段时间里一直有输出, 远大于一次 spawn → attach 的耗时。 */
|
|
1695
1798
|
const ORPHAN_GRACE_MS = 10 * 60_000;
|
|
1799
|
+
const psSnap = () => new Promise((resolve) => {
|
|
1800
|
+
const p = spawn("ps", ["-axo", "pid=,ppid=,etimes="], { env: { ...process.env, PATH: augmentedPath(process.env.PATH) }, stdio: ["ignore", "pipe", "ignore"] });
|
|
1801
|
+
let out = "";
|
|
1802
|
+
p.stdout?.on("data", (c) => (out += c.toString("utf8")));
|
|
1803
|
+
const done = () => {
|
|
1804
|
+
const kids = new Map();
|
|
1805
|
+
const etimes = new Map();
|
|
1806
|
+
for (const line of out.split("\n")) {
|
|
1807
|
+
const [pid, ppid, et] = line.trim().split(/\s+/).map(Number);
|
|
1808
|
+
if (!pid || !ppid)
|
|
1809
|
+
continue;
|
|
1810
|
+
const arr = kids.get(ppid);
|
|
1811
|
+
if (arr)
|
|
1812
|
+
arr.push(pid);
|
|
1813
|
+
else
|
|
1814
|
+
kids.set(ppid, [pid]);
|
|
1815
|
+
if (et !== undefined && !Number.isNaN(et))
|
|
1816
|
+
etimes.set(pid, et);
|
|
1817
|
+
}
|
|
1818
|
+
resolve({ kids, etimes });
|
|
1819
|
+
};
|
|
1820
|
+
p.on("close", done);
|
|
1821
|
+
p.on("error", () => resolve({ kids: new Map(), etimes: new Map() }));
|
|
1822
|
+
});
|
|
1696
1823
|
/** Drop a session's queue chain. A hung job can't be cancelled (it's parked in a
|
|
1697
1824
|
* tmux read), but detaching the chain means the NEXT message doesn't inherit
|
|
1698
1825
|
* its deadlock. Pair with an injectGen bump so the zombie can't act when it
|
|
@@ -2401,11 +2528,11 @@ export const startMirror = (deps) => {
|
|
|
2401
2528
|
if (!env)
|
|
2402
2529
|
return undefined;
|
|
2403
2530
|
const home = baseOfKey(a.target);
|
|
2404
|
-
const from = env.kind === "peer" ? { kind: "peer", from: wizardStore()?.byName(stripSigil(env.from))?.target, ...(env.private ? {} : { public: true }), ...(env.receipt ? { receipt: true } : {}), ...(env.status ? { status: env.status } : {}), ...(env.turn ? { turn: env.turn } : {}) }
|
|
2531
|
+
const from = env.kind === "peer" ? { kind: "peer", from: wizardStore()?.byName(stripSigil(env.from))?.target, ...(env.private ? {} : { public: true }), ...(env.receipt ? { receipt: true } : {}), ...(env.status ? { status: env.status } : {}), ...(env.turn ? { turn: env.turn } : {}), ...(env.asker ? { asker: env.asker } : {}) }
|
|
2405
2532
|
: env.kind === "task" ? { kind: "task", taskId: env.from.replace(/^定时 /, "") }
|
|
2406
2533
|
: undefined;
|
|
2407
2534
|
return {
|
|
2408
|
-
channel: env.private ? "" : (env.chat && chatBaseOf(cfg, env.chat)) || home,
|
|
2535
|
+
channel: env.private ? "" : env.chatKey || (env.chat && chatBaseOf(cfg, env.chat)) || home,
|
|
2409
2536
|
speaker: env.kind === "human" ? `user:${env.from}` : undefined,
|
|
2410
2537
|
from,
|
|
2411
2538
|
query: t.text,
|
|
@@ -2464,8 +2591,6 @@ export const startMirror = (deps) => {
|
|
|
2464
2591
|
const isPrivate = (a) => a.channel === "";
|
|
2465
2592
|
/** 本轮回复该推去的 chatId。 */
|
|
2466
2593
|
const replyChatId = (a) => stripPrincipalPrefix(a.channel || a.target);
|
|
2467
|
-
// 审批/提问卡同样回本轮的频道; 私聊轮 ("") 的卡回 home —— 要点它的人总得在某个群里。
|
|
2468
|
-
bindCardChannel((key) => byTarget.get(key)?.channel || "");
|
|
2469
2594
|
// 详情页要按后端的名字称呼它 ("Claude 正在思考" / "CodeBuddy 正在思考") —— 后端
|
|
2470
2595
|
// 由 transcript 落盘路径唯一确定, 所以每次开 turn 时现算, 不必额外记在 AttachState 上。
|
|
2471
2596
|
const cliOf = (a) => a.jsonlPath ? backendForPath(a.jsonlPath).name : undefined;
|
|
@@ -2520,7 +2645,7 @@ export const startMirror = (deps) => {
|
|
|
2520
2645
|
a.pendingBriefQuery = undefined;
|
|
2521
2646
|
a.pendingChannel = { channel: s.channel ?? baseOfKey(a.target), speaker: s.speaker, at: Date.now() };
|
|
2522
2647
|
consumeChannel(a, true, undefined);
|
|
2523
|
-
recordTurnStart({ id: q.turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: s.query.trim() || undefined, createdAt: at, ...channelFields(a) });
|
|
2648
|
+
recordTurnStart({ id: q.turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, cwd: a.runningCwd || undefined, userQuery: s.query.trim() || undefined, createdAt: at, ...channelFields(a) });
|
|
2524
2649
|
if (a.briefTurnId)
|
|
2525
2650
|
closeBriefTurn(a);
|
|
2526
2651
|
openBriefTurn(a, q);
|
|
@@ -2585,7 +2710,7 @@ export const startMirror = (deps) => {
|
|
|
2585
2710
|
const queued = !isSlash && idleVerdict(a.target) === false;
|
|
2586
2711
|
pushSeal(a, seal);
|
|
2587
2712
|
if (queued)
|
|
2588
|
-
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, channel: channel ?? baseOfKey(a.target), speaker });
|
|
2713
|
+
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, channel: channel ?? baseOfKey(a.target), speaker });
|
|
2589
2714
|
else
|
|
2590
2715
|
activateDispatch(a, seal);
|
|
2591
2716
|
// 新消息 = 对话边界: 立刻收掉上一 turn, 新 turn 直接激活、不排队。收口语义见
|
|
@@ -2657,7 +2782,7 @@ export const startMirror = (deps) => {
|
|
|
2657
2782
|
const fresh = consumeFrom(a);
|
|
2658
2783
|
const opened = consumeChannel(a, fromCli, fresh);
|
|
2659
2784
|
const from = fresh ?? a.convFrom;
|
|
2660
|
-
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: query || opened || undefined, origin: consumeOrigin(a), from, ...channelFields(a), ...(query && at ? { createdAt: at } : {}) });
|
|
2785
|
+
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, cwd: a.runningCwd || undefined, userQuery: query || opened || undefined, origin: consumeOrigin(a), from, ...channelFields(a), ...(query && at ? { createdAt: at } : {}) });
|
|
2661
2786
|
a.briefTurnId = turnId;
|
|
2662
2787
|
a.briefBubble = undefined;
|
|
2663
2788
|
a.briefIsSlash = false;
|
|
@@ -3164,7 +3289,7 @@ export const startMirror = (deps) => {
|
|
|
3164
3289
|
if (!a.keepaliveTurnId) {
|
|
3165
3290
|
const turnId = newTurnId();
|
|
3166
3291
|
a.keepaliveTurnId = turnId;
|
|
3167
|
-
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), userQuery: text });
|
|
3292
|
+
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, userQuery: text });
|
|
3168
3293
|
}
|
|
3169
3294
|
};
|
|
3170
3295
|
// A keepalive ping turn is swallowed wholesale — the reply content doesn't
|
|
@@ -3817,8 +3942,9 @@ export const startMirror = (deps) => {
|
|
|
3817
3942
|
// with the primary dialect would report a phantom drift for every
|
|
3818
3943
|
// session belonging to a non-primary CLI.
|
|
3819
3944
|
const paneDir = projectDirFor(jsonlAbs, expandHome(paneCwd));
|
|
3820
|
-
|
|
3821
|
-
|
|
3945
|
+
const codexPane = backendForPath(jsonlAbs).name === "codex" ? codexSessionForPane(livePane) : undefined;
|
|
3946
|
+
if (codexPane ? codexPane.sessionId !== rec.sessionId : backendForPath(jsonlAbs).name !== "codex" && paneDir !== dirname(jsonlAbs)) {
|
|
3947
|
+
const live = codexPane?.cwd === expandHome(paneCwd) ? codexPane : backendForPath(jsonlAbs).name === "codex" ? undefined : liveSessionForCwd(paneCwd, backendForPath(jsonlAbs), sidsClaimedByOthers(principal));
|
|
3822
3948
|
if (live && live.sessionId !== rec.sessionId) {
|
|
3823
3949
|
log.info({ principal, from: rec.sessionId, to: live.sessionId, paneCwd }, "mirror restore: pane in worktree, re-homed to live session");
|
|
3824
3950
|
rec.sessionId = live.sessionId;
|
|
@@ -4131,6 +4257,8 @@ export const startMirror = (deps) => {
|
|
|
4131
4257
|
// `exclusive` (the /clear flavor) refuses to claim on ambiguous evidence —
|
|
4132
4258
|
// see claimIsAttributable.
|
|
4133
4259
|
const startMigrationWatcher = (a, baseline, isChild, startOffset, exclusive) => {
|
|
4260
|
+
if (backendForPath(a.jsonlPath).name === "codex")
|
|
4261
|
+
return;
|
|
4134
4262
|
if (a.migrationWatcher)
|
|
4135
4263
|
a.migrationWatcher.cancel(); // re-armed (e.g. /clear twice, or respawn during a pending watch)
|
|
4136
4264
|
const projectDir = dirname(a.jsonlPath);
|
|
@@ -4230,6 +4358,8 @@ export const startMirror = (deps) => {
|
|
|
4230
4358
|
// narrows to transcripts born after the clear, which makes a short
|
|
4231
4359
|
// fingerprint safe enough to accept.
|
|
4232
4360
|
const armSilentForkRebind = (a, text) => {
|
|
4361
|
+
if (backendForPath(a.jsonlPath).name === "codex")
|
|
4362
|
+
return; // SessionStart owns exact pane identity.
|
|
4233
4363
|
const pendingClear = a.clearRebind;
|
|
4234
4364
|
if (pendingClear) {
|
|
4235
4365
|
// Hand over: the fingerprint is pane-certain, the dir-scan is not.
|
|
@@ -4375,8 +4505,16 @@ export const startMirror = (deps) => {
|
|
|
4375
4505
|
const cwd = paneCwd.get(a.tmuxPane);
|
|
4376
4506
|
if (!cwd)
|
|
4377
4507
|
continue; // pane gone
|
|
4508
|
+
if (backendForPath(a.jsonlPath).name === "codex") {
|
|
4509
|
+
const registered = codexSessionForPane(a.tmuxPane);
|
|
4510
|
+
if (registered && registered.cwd === expandHome(cwd) && registered.sessionId !== a.sessionId && !sidsClaimedByOthers(a.target).has(registered.sessionId)) {
|
|
4511
|
+
a.runningCwd = registered.cwd;
|
|
4512
|
+
migrateAttachment(a, registered.sessionId, registered.jsonlPath);
|
|
4513
|
+
}
|
|
4514
|
+
continue;
|
|
4515
|
+
}
|
|
4378
4516
|
const paneDir = projectDirFor(a.jsonlPath, expandHome(cwd));
|
|
4379
|
-
if (paneDir === dirname(a.jsonlPath))
|
|
4517
|
+
if (backendForPath(a.jsonlPath).name === "codex" ? expandHome(cwd) === a.runningCwd : paneDir === dirname(a.jsonlPath))
|
|
4380
4518
|
continue; // same project dir — no drift
|
|
4381
4519
|
const live = liveSessionForCwd(cwd, backendForPath(a.jsonlPath), sidsClaimedByOthers(a.target));
|
|
4382
4520
|
if (!live || live.sessionId === a.sessionId)
|
|
@@ -4468,14 +4606,11 @@ export const startMirror = (deps) => {
|
|
|
4468
4606
|
return undefined;
|
|
4469
4607
|
}
|
|
4470
4608
|
};
|
|
4471
|
-
/** 管家该跑的那一档 (模型 + effort); 不是管家 = undefined。 */
|
|
4472
|
-
const stewardSpec = (target) => {
|
|
4473
|
-
const tier = stewardTierOf(cfg, target);
|
|
4474
|
-
return tier ? { model: cfg.models.tiers[tier].model || undefined, effort: cfg.models.tiers[tier].effort } : undefined;
|
|
4475
|
-
};
|
|
4476
4609
|
/** 死 pane 重生 (`--resume` 同一个会话) 的模型: 绑定里记着的优先 —— set_model 换上的也记在那;
|
|
4477
4610
|
* 没记 (老绑定 / CLI 默认) 的管家补上管家档。 */
|
|
4478
|
-
const respawnSpec = (a) =>
|
|
4611
|
+
const respawnSpec = (a) => {
|
|
4612
|
+
return resumedStewardSpec(cfg, a.target, backendForPath(a.jsonlPath).name, a);
|
|
4613
|
+
};
|
|
4479
4614
|
const newSession = async (target, windowName, cli, opts) => {
|
|
4480
4615
|
const prev = byTarget.get(target);
|
|
4481
4616
|
// Resolution precedence (all chat-scoped except the running-cwd fallback):
|
|
@@ -4497,22 +4632,16 @@ export const startMirror = (deps) => {
|
|
|
4497
4632
|
(rec?.cwd?.trim()) ||
|
|
4498
4633
|
chat.running ||
|
|
4499
4634
|
expandedDefaultCwd;
|
|
4500
|
-
//
|
|
4501
|
-
//
|
|
4502
|
-
|
|
4503
|
-
// is chat-scoped like cwd — a FIRST `/new #tag` has no record of its own,
|
|
4504
|
-
// so it falls back to the base session's binding instead of `defaultCli`
|
|
4505
|
-
// (otherwise a tagged sibling silently forks onto a different CLI).
|
|
4635
|
+
// Fresh panes always use the configured backend, regardless of old bindings
|
|
4636
|
+
// or per-call / tier CLI values. Keep the old path only for model/effort reset.
|
|
4637
|
+
const boundPath = prev?.jsonlPath ?? rec?.jsonlPath;
|
|
4506
4638
|
const base = baseOfKey(target);
|
|
4507
|
-
const
|
|
4508
|
-
const boundPath = prev?.jsonlPath ?? rec?.jsonlPath ?? baseBound;
|
|
4509
|
-
const effCli = cli ?? (boundPath ? backendForPath(expandHome(boundPath)).name : undefined);
|
|
4639
|
+
const effCli = resolveCliBackend(cfg.wrc).name;
|
|
4510
4640
|
// 管家 (群的默认会话) 新起的会话按管家档起 (models.steward / chatPolicy), 档压过绑定里记下的
|
|
4511
4641
|
// effort —— `/new`、换目录、交接重开都是让改过的档位生效的那一下。点名了模型就整档让开。
|
|
4512
4642
|
// 已在跑的管家不动 (换模型会让缓存整份重读); 死 pane 重生见 respawnSpec。
|
|
4513
|
-
const
|
|
4514
|
-
const effModel =
|
|
4515
|
-
const effEffort = opts?.effort ?? steward?.effort ?? parseEffort(prev?.effort ?? deps.store.get(target)?.effort);
|
|
4643
|
+
const backendChanged = Boolean(boundPath && effCli && backendForPath(expandHome(boundPath)).name !== effCli);
|
|
4644
|
+
const { model: effModel, effort: effEffort } = newStewardSpec(cfg, target, effCli, opts, backendChanged ? undefined : prev?.effort ?? deps.store.get(target)?.effort);
|
|
4516
4645
|
if (prev?.tmuxPane) {
|
|
4517
4646
|
// Best-effort kill; ignore errors (pane may already be dead).
|
|
4518
4647
|
void runTmux(["kill-pane", "-t", prev.tmuxPane]);
|
|
@@ -4531,7 +4660,7 @@ export const startMirror = (deps) => {
|
|
|
4531
4660
|
cli: effCli,
|
|
4532
4661
|
model: effModel,
|
|
4533
4662
|
effort: effEffort,
|
|
4534
|
-
systemPrompt: opts?.systemPrompt ?? charterFor(target, { cwd: eff }),
|
|
4663
|
+
systemPrompt: opts?.systemPrompt ?? charterFor(target, { cwd: eff, cli: effCli }),
|
|
4535
4664
|
}).catch((e) => { inFlightSids.delete(freshSid); throw e; });
|
|
4536
4665
|
if (!r.ok) {
|
|
4537
4666
|
inFlightSids.delete(freshSid);
|
|
@@ -4695,14 +4824,14 @@ export const startMirror = (deps) => {
|
|
|
4695
4824
|
rememberInject(hello);
|
|
4696
4825
|
const boot = await inject({
|
|
4697
4826
|
text: hello, images: [], cfg, log: lg,
|
|
4698
|
-
sessionId: parent.sessionId, jsonlPath: parent.jsonlPath,
|
|
4827
|
+
sessionId: parentCli === "codex" ? r.sessionId : parent.sessionId, jsonlPath: parentCli === "codex" ? r.jsonlPath : parent.jsonlPath,
|
|
4699
4828
|
tmuxTarget: r.tmuxPane, freshSpawn: true,
|
|
4700
4829
|
});
|
|
4701
4830
|
if (!boot.ok) {
|
|
4702
4831
|
await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
|
|
4703
4832
|
return { ok: false, reason: `分身开场白注入失败: ${boot.reason ?? "unknown"}`, inherited: false };
|
|
4704
4833
|
}
|
|
4705
|
-
const fork = await awaitFork(projectDir, baseline, sidsClaimedByOthers(args.target), parent.jsonlPath, {
|
|
4834
|
+
const fork = parentCli === "codex" ? { sessionId: r.sessionId, jsonlPath: r.jsonlPath } : await awaitFork(projectDir, baseline, sidsClaimedByOthers(args.target), parent.jsonlPath, {
|
|
4706
4835
|
homeDir: backendForPath(parent.jsonlPath).homeDir, pane: r.tmuxPane ?? "", parentSid: parent.sessionId,
|
|
4707
4836
|
});
|
|
4708
4837
|
// 等不到分叉就必须收手: 此刻唯一已知的 sid 是父亲的, 认下去两个 target 会指向
|
|
@@ -4921,14 +5050,16 @@ export const startMirror = (deps) => {
|
|
|
4921
5050
|
// `daily#fix` 改名前的老写法 —— 名字取自 tag 迁移而来, 照旧认得。
|
|
4922
5051
|
const resolvePeerTag = (self, ref) => {
|
|
4923
5052
|
const raw = stripSigil(ref);
|
|
4924
|
-
|
|
5053
|
+
// 多机器人隔离: 别的机器人名下的 wizard 在这里与「不存在」同形 —— 不说它在、不进 candidates。
|
|
5054
|
+
// 单机器人时 sameBot 恒真, 这一行什么也不滤。
|
|
5055
|
+
const all = allTargets().filter((k) => sameBot(self, k));
|
|
4925
5056
|
const hit = (target) => ({ ok: true, target, foreign: baseOfKey(target) !== baseOfKey(self) });
|
|
4926
5057
|
if (!raw)
|
|
4927
5058
|
return hit(baseOfKey(self));
|
|
4928
5059
|
if (/^(user|chat|group):/.test(raw)) {
|
|
4929
5060
|
return all.includes(raw) ? hit(raw) : { ok: false, reason: `no running session '${raw}'` };
|
|
4930
5061
|
}
|
|
4931
|
-
const named = wizardStore()?.byName(raw)?.target;
|
|
5062
|
+
const named = ((t) => (t && sameBot(self, t) ? t : undefined))(wizardStore()?.byName(raw)?.target);
|
|
4932
5063
|
if (named && all.includes(named))
|
|
4933
5064
|
return hit(named);
|
|
4934
5065
|
if (named)
|
|
@@ -5121,7 +5252,7 @@ export const startMirror = (deps) => {
|
|
|
5121
5252
|
const foreignPeers = async (self) => {
|
|
5122
5253
|
const selfBase = baseOfKey(self);
|
|
5123
5254
|
// 只列活着的 / 有身份的 —— 冷绑定可能有几百个, 每个探活是两次 tmux。
|
|
5124
|
-
const foreign = allTargets().filter((k) => baseOfKey(k) !== selfBase && (byTarget.has(k) || !!wizardStore()?.get(k)));
|
|
5255
|
+
const foreign = allTargets().filter((k) => baseOfKey(k) !== selfBase && sameBot(self, k) && (byTarget.has(k) || !!wizardStore()?.get(k)));
|
|
5125
5256
|
return (await Promise.all(foreign.map((t) => peerInfoOf(t, self)))).sort(byRecent);
|
|
5126
5257
|
};
|
|
5127
5258
|
// peerInfoOf 每个 target 要两次 tmux (pane 存活 + capture tail)。跑了几个月的
|
|
@@ -5255,7 +5386,7 @@ export const startMirror = (deps) => {
|
|
|
5255
5386
|
if (!a.keepaliveTurnId) {
|
|
5256
5387
|
const turnId = newTurnId();
|
|
5257
5388
|
a.keepaliveTurnId = turnId;
|
|
5258
|
-
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), userQuery: text });
|
|
5389
|
+
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, userQuery: text });
|
|
5259
5390
|
}
|
|
5260
5391
|
};
|
|
5261
5392
|
let keepaliveTicking = false;
|
|
@@ -5376,10 +5507,10 @@ export const startMirror = (deps) => {
|
|
|
5376
5507
|
// 触发 / 根本不触发 (与 AskUserQuestion 的「本地面板先行」同源; 实测命中: 子代理
|
|
5377
5508
|
// 里经 ToolSearch→DeferExecuteTool 派发的 MCP 工具)。hook 链路对这类调用完全
|
|
5378
5509
|
// 失明 —— daemon 零日志、无卡可发, pane 就地阻塞。解法与 askq/plan 同源:
|
|
5379
|
-
// mirror 读屏兜底, 按三件套形状 (pickAutoAllowAnswer)
|
|
5380
|
-
// -
|
|
5381
|
-
// -
|
|
5382
|
-
// -
|
|
5510
|
+
// mirror 读屏兜底, 按三件套形状 (pickAutoAllowAnswer) 认权限确认框:
|
|
5511
|
+
// - 上下文完整且现有工具策略放行 → 只代按一次性裸 Yes;
|
|
5512
|
+
// - denyRules 命中 → 拒绝; 未知参数 / askRules / 强制审批不吃窗口;
|
|
5513
|
+
// - 其它 → 把框转成标准审批卡发到本 chat (runMirrorPickerFlow), 点击后代按
|
|
5383
5514
|
// 对应选项; 本地先按掉则 moot 作废卡。代按/发卡都发 chat 回执留痕。
|
|
5384
5515
|
// WEZARD_PICKER_SENTINEL=0 整体关闭。
|
|
5385
5516
|
let pickerTicking = false;
|
|
@@ -5393,6 +5524,10 @@ export const startMirror = (deps) => {
|
|
|
5393
5524
|
for (const a of byTarget.values()) {
|
|
5394
5525
|
if (!a.tmuxPane || a.migrationWatcher)
|
|
5395
5526
|
continue;
|
|
5527
|
+
// Codex PermissionRequest has its own independent native gate. Screen
|
|
5528
|
+
// options, tool windows and skipAll never authorize its sandbox.
|
|
5529
|
+
if (cliOf(a) === "codex")
|
|
5530
|
+
continue;
|
|
5396
5531
|
if (!(await tmuxPaneAlive(a.tmuxPane)))
|
|
5397
5532
|
continue;
|
|
5398
5533
|
const v = await detectModalPicker(a.tmuxPane);
|
|
@@ -5423,48 +5558,54 @@ export const startMirror = (deps) => {
|
|
|
5423
5558
|
continue;
|
|
5424
5559
|
}
|
|
5425
5560
|
const ctx = parseConfirmContext(v.screen);
|
|
5426
|
-
const
|
|
5427
|
-
|
|
5428
|
-
|
|
5429
|
-
const auto = cfg.approval.danger.skipAll
|
|
5430
|
-
? "skipAll"
|
|
5431
|
-
: isAutoWindowActive(a.target) && !danger ? "⏱自动窗口" : undefined;
|
|
5432
|
-
if (auto) {
|
|
5433
|
-
const after = await pressModalIndex(a.tmuxPane, v, pick.index);
|
|
5434
|
-
if (after.modal) {
|
|
5435
|
-
// 没按掉: 指纹换成当前屏 — pane 静止就不再重试, 免得往一个不认识的框里刷数字键。
|
|
5436
|
-
a.pickerSentinelFp = after.screen;
|
|
5437
|
-
log.warn({ target: a.target, title: after.title, pressed: pick.index }, "picker sentinel: modal still on pane after press");
|
|
5438
|
-
continue;
|
|
5439
|
-
}
|
|
5440
|
-
a.pickerSentinelFp = undefined;
|
|
5441
|
-
log.info({ target: a.target, title: v.title, via: auto, pressed: `${pick.index}. ${pick.label}` }, "picker sentinel: native permission picker auto-allowed");
|
|
5442
|
-
sendStandalone(a, `[mirror] 🔓 ${auto} 代按了 CLI 原生权限确认${v.title ? `「${v.title}」` : ""} → ${pick.index}. ${pick.label}`);
|
|
5443
|
-
continue;
|
|
5561
|
+
const policy = pickerPolicy(cfg, cliOf(a), ctx, isAutoWindowActive(a.target));
|
|
5562
|
+
if (policy.decision === "leave") {
|
|
5563
|
+
log.info({ target: a.target, reason: policy.reason }, "picker sentinel: independent approval left on CLI");
|
|
5444
5564
|
}
|
|
5445
|
-
|
|
5446
|
-
|
|
5447
|
-
|
|
5448
|
-
|
|
5449
|
-
|
|
5450
|
-
|
|
5451
|
-
|
|
5452
|
-
|
|
5453
|
-
|
|
5454
|
-
|
|
5455
|
-
|
|
5456
|
-
|
|
5457
|
-
|
|
5458
|
-
|
|
5459
|
-
danger: danger?.rule,
|
|
5460
|
-
voteTimeoutMs: cfg.approval.longPollSec * 1000,
|
|
5461
|
-
press: (what) => pressNativePicker(a, ctx.toolName, what),
|
|
5462
|
-
}).then((sent) => {
|
|
5463
|
-
// 卡没发出去 (WS 断/发送失败): 清指纹, 让下一 tick 对同屏重试发卡,
|
|
5464
|
-
// 否则这只框会被指纹永久静默。
|
|
5465
|
-
if (!sent)
|
|
5565
|
+
await dispatchPickerPolicy(policy, {
|
|
5566
|
+
deny: async () => {
|
|
5567
|
+
const result = await pressNativePicker(a, modalFingerprint(v.screen), "no");
|
|
5568
|
+
log.info({ target: a.target, reason: policy.reason, result }, "picker sentinel: deny rule");
|
|
5569
|
+
sendStandalone(a, `[mirror] 🚫 ${policy.reason},${result.ok ? "已拒绝 CLI 确认" : "确认框已变更,请回 CLI 处理"}`);
|
|
5570
|
+
},
|
|
5571
|
+
allow: async () => {
|
|
5572
|
+
const after = await pressModalIndex(a.tmuxPane, v, pick.index);
|
|
5573
|
+
if (after.modal) {
|
|
5574
|
+
// 没按掉: 指纹换成当前屏 — pane 静止就不再重试, 免得往一个不认识的框里刷数字键。
|
|
5575
|
+
a.pickerSentinelFp = after.screen;
|
|
5576
|
+
log.warn({ target: a.target, title: after.title, pressed: pick.index }, "picker sentinel: modal still on pane after press");
|
|
5577
|
+
return;
|
|
5578
|
+
}
|
|
5466
5579
|
a.pickerSentinelFp = undefined;
|
|
5467
|
-
|
|
5580
|
+
log.info({ target: a.target, title: v.title, via: policy.reason, pressed: `${pick.index}. ${pick.label}` }, "picker sentinel: native permission picker auto-allowed");
|
|
5581
|
+
sendStandalone(a, `[mirror] 🔓 ${policy.reason} 代按了 CLI 原生权限确认${v.title ? `「${v.title}」` : ""} → ${pick.index}. ${pick.label}`);
|
|
5582
|
+
},
|
|
5583
|
+
manual: () => {
|
|
5584
|
+
// 卡片路径: fire-and-forget — 长轮询在 flow 里, 哨兵循环不能被一张卡挂住;
|
|
5585
|
+
// 防重靠 hasMirrorPicker 槽位 (含代按窗口期, 见 flow 内 finally)。
|
|
5586
|
+
log.info({ target: a.target, toolName: ctx.toolName, agent: ctx.agent, reason: policy.reason }, "picker sentinel: forwarding native picker as approval card");
|
|
5587
|
+
runMirrorPickerFlow({
|
|
5588
|
+
log: log.child({ target: a.target, sessionId: a.sessionId, sub: "picker-card" }),
|
|
5589
|
+
client,
|
|
5590
|
+
cfg,
|
|
5591
|
+
sessionId: a.sessionId,
|
|
5592
|
+
chatKey: a.target,
|
|
5593
|
+
cwd: a.runningCwd,
|
|
5594
|
+
jsonlPath: a.jsonlPath,
|
|
5595
|
+
toolName: ctx.toolName,
|
|
5596
|
+
toolInput: ctx.toolInput,
|
|
5597
|
+
agent: ctx.agent,
|
|
5598
|
+
danger: policy.forceSingle,
|
|
5599
|
+
voteTimeoutMs: cfg.approval.longPollSec * 1000,
|
|
5600
|
+
press: (what) => pressNativePicker(a, modalFingerprint(v.screen), what),
|
|
5601
|
+
}).then((sent) => {
|
|
5602
|
+
// 卡没发出去 (WS 断/发送失败): 清指纹, 让下一 tick 对同屏重试发卡,
|
|
5603
|
+
// 否则这只框会被指纹永久静默。
|
|
5604
|
+
if (!sent)
|
|
5605
|
+
a.pickerSentinelFp = undefined;
|
|
5606
|
+
}).catch((e) => log.warn({ err: e.message, target: a.target }, "mirror picker flow failed"));
|
|
5607
|
+
},
|
|
5608
|
+
});
|
|
5468
5609
|
}
|
|
5469
5610
|
}
|
|
5470
5611
|
catch (e) {
|
|
@@ -5478,15 +5619,19 @@ export const startMirror = (deps) => {
|
|
|
5478
5619
|
// 审批卡点击后的代按回调。点击与本地按键之间隔着人的反应时间, 必须重读屏并
|
|
5479
5620
|
// 校验仍是**同一个调用**的确认框 —— 期间本地按掉旧框又弹了新框的话, 盲按就是
|
|
5480
5621
|
// 替用户批准了他没看过的东西。
|
|
5481
|
-
const pressNativePicker = async (a,
|
|
5622
|
+
const pressNativePicker = async (a, expectFingerprint, what) => {
|
|
5482
5623
|
if (!a.tmuxPane || !(await tmuxPaneAlive(a.tmuxPane)))
|
|
5483
5624
|
return { ok: false, reason: "pane 已不在" };
|
|
5625
|
+
if (cliOf(a) === "codex")
|
|
5626
|
+
return { ok: false, reason: "Codex 原生沙箱审批必须单独处理" };
|
|
5484
5627
|
const v = await detectModalPicker(a.tmuxPane);
|
|
5485
5628
|
if (!v.modal)
|
|
5486
5629
|
return { ok: false, reason: "确认框已不在(可能已在本地处理)" };
|
|
5487
|
-
|
|
5488
|
-
|
|
5489
|
-
|
|
5630
|
+
if (pickerPolicy(cfg, cliOf(a), parseConfirmContext(v.screen), false).decision === "leave") {
|
|
5631
|
+
return { ok: false, reason: "独立原生或交互审批,请在 CLI 中处理" };
|
|
5632
|
+
}
|
|
5633
|
+
if (modalFingerprint(v.screen) !== expectFingerprint) {
|
|
5634
|
+
return { ok: false, reason: "当前确认框的操作或参数已变更,不代按" };
|
|
5490
5635
|
}
|
|
5491
5636
|
if (what === "no") {
|
|
5492
5637
|
await runTmux(["send-keys", "-t", a.tmuxPane, "Escape"]);
|
|
@@ -5523,6 +5668,8 @@ export const startMirror = (deps) => {
|
|
|
5523
5668
|
const a = byTarget.get(target);
|
|
5524
5669
|
if (!a?.tmuxPane || !(await tmuxPaneAlive(a.tmuxPane)))
|
|
5525
5670
|
return { ok: false, reason: "它没有活着的 pane —— 模型是在 pane 里切的, 先叫醒它" };
|
|
5671
|
+
if (backendForPath(a.jsonlPath).name === "codex")
|
|
5672
|
+
return { ok: false, reason: "Codex 运行中的模型请在终端 /model 切换;新开会话可指定 model" };
|
|
5526
5673
|
const r = await selectModel(a.tmuxPane, wanted, log.child({ sub: "set-model", target }), scope);
|
|
5527
5674
|
if (r.ok && r.applied) {
|
|
5528
5675
|
a.model = r.applied;
|
|
@@ -5536,6 +5683,8 @@ export const startMirror = (deps) => {
|
|
|
5536
5683
|
const a = byTarget.get(target);
|
|
5537
5684
|
if (!a?.tmuxPane || !(await tmuxPaneAlive(a.tmuxPane)))
|
|
5538
5685
|
return { ok: false, reason: "它没有活着的 pane —— 档位是在 pane 里切的, 先叫醒它" };
|
|
5686
|
+
if (backendForPath(a.jsonlPath).name === "codex")
|
|
5687
|
+
return { ok: false, reason: "Codex 运行中的推理强度请在终端 /model 切换;新开会话可指定 effort" };
|
|
5539
5688
|
const r = await selectEffort(a.tmuxPane, effort, log.child({ sub: "set-effort", target }));
|
|
5540
5689
|
if (r.ok && r.applied) {
|
|
5541
5690
|
a.effort = r.applied;
|
|
@@ -5761,7 +5910,7 @@ export const startMirror = (deps) => {
|
|
|
5761
5910
|
// Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
|
|
5762
5911
|
// onto the forked jsonl once it appears (EOF offset — fork is seeded).
|
|
5763
5912
|
const resumeBaseline = listJsonls(dirname(a.jsonlPath));
|
|
5764
|
-
const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn-init", sessionId: sid }), resumeSessionId: sid, windowName: displayName(target) || target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, ...respawnSpec(a), systemPrompt: charterFor(target, { cwd: a.runningCwd }) });
|
|
5913
|
+
const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn-init", sessionId: sid }), resumeSessionId: sid, windowName: displayName(target) || target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, ...respawnSpec(a), systemPrompt: charterFor(target, { cwd: a.runningCwd, cli: backendForPath(a.jsonlPath).name }) });
|
|
5765
5914
|
if (!r.ok || !r.tmuxPane)
|
|
5766
5915
|
return { ok: false, reason: `respawn failed: ${r.reason ?? "unknown"}` };
|
|
5767
5916
|
a.tmuxPane = r.tmuxPane;
|
|
@@ -5892,7 +6041,7 @@ export const startMirror = (deps) => {
|
|
|
5892
6041
|
// allTargets 是几百个 key, 见 target 就问 tmux 会打穿 fd (同 worldPeers 的
|
|
5893
6042
|
// WORLD_PROBE 取舍)。
|
|
5894
6043
|
const orphans = [];
|
|
5895
|
-
const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}\t#{window_active}:#{session_attached}\t#{session_name}\t#{
|
|
6044
|
+
const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}\t#{window_active}:#{session_attached}\t#{session_name}\t#{pane_pid}"]);
|
|
5896
6045
|
if (snap.code !== 0)
|
|
5897
6046
|
return { reaped, orphans, skipped };
|
|
5898
6047
|
const rows = snap.stdout.split("\n").filter(Boolean).map((l) => l.split("\t"));
|
|
@@ -5918,15 +6067,38 @@ export const startMirror = (deps) => {
|
|
|
5918
6067
|
// 丢了 pane id (restore 认不出 → 下一条消息另起新 pane, 旧 pane 不杀) 之后留下的。
|
|
5919
6068
|
// 没有地址能再叫到它, 却不在下面 `live` 的账上, 攒多少都触不到上限; 所以不占
|
|
5920
6069
|
// 名额、不等超限, 见到就收。刚起的 pane 在 attach 之前也长这样 (并发 spawn),
|
|
5921
|
-
// 用 window_activity 留一段宽限。只收会话 pane:
|
|
5922
|
-
//
|
|
5923
|
-
//
|
|
6070
|
+
// 用 window_activity 留一段宽限。只收会话 pane: pane 的进程树里有注册表登记且
|
|
6071
|
+
// 还活着的 CLI 进程才算 (pane_pid 是 shell, CLI 是它的子孙, 所以沿 ps 快照往下
|
|
6072
|
+
// 找)。不看注册表的 `tmux` 字段 —— codebuddy 的注册表没有它, 那条路对 codebuddy
|
|
6073
|
+
// 全盲。普通 pane (人自己开的 shell / ssh / 编辑器, 会话已退回 shell 的) 树里
|
|
6074
|
+
// 没有注册表 pid, 一律跳过 —— 认不出就不动。
|
|
5924
6075
|
const bound = new Set(targets.map(paneOf).filter(Boolean));
|
|
5925
|
-
const
|
|
5926
|
-
|
|
5927
|
-
|
|
6076
|
+
const regPids = new Set(activeBackends().flatMap((b) => [...liveSessionPids(b.homeDir)]));
|
|
6077
|
+
const ps = await psSnap();
|
|
6078
|
+
// 沿进程树找注册表 pid; 找到返回那个 pid (它的 etimes 供宽限判), 找不到返回 0。
|
|
6079
|
+
const hostedBy = (pid) => {
|
|
6080
|
+
const seen = new Set();
|
|
6081
|
+
const stack = [pid];
|
|
6082
|
+
while (stack.length) {
|
|
6083
|
+
const p = stack.pop() ?? 0;
|
|
6084
|
+
if (regPids.has(p))
|
|
6085
|
+
return p;
|
|
6086
|
+
if (seen.has(p))
|
|
6087
|
+
continue;
|
|
6088
|
+
seen.add(p);
|
|
6089
|
+
for (const k of ps.kids.get(p) ?? [])
|
|
6090
|
+
stack.push(k);
|
|
6091
|
+
}
|
|
6092
|
+
return 0;
|
|
6093
|
+
};
|
|
6094
|
+
for (const [pane = "", , session, pid] of rows) {
|
|
6095
|
+
if (session !== cfg.wrc.tmuxPrefix || bound.has(pane))
|
|
6096
|
+
continue;
|
|
6097
|
+
const cliPid = hostedBy(Number(pid) || 0);
|
|
6098
|
+
if (!cliPid)
|
|
5928
6099
|
continue;
|
|
5929
|
-
|
|
6100
|
+
// 宽限按 CLI 进程年龄, 不按 window_activity: TUI 重绘会让它永远「刚动过」。
|
|
6101
|
+
if ((ps.etimes.get(cliPid) ?? Number.POSITIVE_INFINITY) * 1000 < ORPHAN_GRACE_MS)
|
|
5930
6102
|
continue;
|
|
5931
6103
|
const why = await putDown(pane);
|
|
5932
6104
|
if (why) {
|
|
@@ -5934,7 +6106,7 @@ export const startMirror = (deps) => {
|
|
|
5934
6106
|
continue;
|
|
5935
6107
|
}
|
|
5936
6108
|
orphans.push(pane);
|
|
5937
|
-
log.info({ pane,
|
|
6109
|
+
log.info({ pane, cliPid, cliAgeHours: Math.round((ps.etimes.get(cliPid) ?? 0) / 3600) }, "mirror pane cap — orphan pane killed (no binding)");
|
|
5938
6110
|
}
|
|
5939
6111
|
const live = targets.filter((t) => watchedBy.has(paneOf(t)));
|
|
5940
6112
|
let over = live.length - max;
|
|
@@ -6029,6 +6201,8 @@ export const startMirror = (deps) => {
|
|
|
6029
6201
|
hasLivePane: (sessionId) => Boolean(bySessionId.get(sessionId)?.tmuxPane),
|
|
6030
6202
|
answerNativeModal: async (sessionId, opts) => {
|
|
6031
6203
|
const a = bySessionId.get(sessionId);
|
|
6204
|
+
if (a && cliOf(a) === "codex")
|
|
6205
|
+
return { status: "no_modal", reason: "Codex native approval is independent" };
|
|
6032
6206
|
if (!a?.tmuxPane)
|
|
6033
6207
|
return { status: "no_pane", reason: "no live tmux pane for session" };
|
|
6034
6208
|
if (!(await tmuxPaneAlive(a.tmuxPane)))
|
|
@@ -6046,6 +6220,9 @@ export const startMirror = (deps) => {
|
|
|
6046
6220
|
// 没等到: 这次 CC 没弹框(会话内已授权过 / 命中盲点判断失误)。无事可做。
|
|
6047
6221
|
if (!v.modal)
|
|
6048
6222
|
return { status: "no_modal" };
|
|
6223
|
+
if (pickerPolicy(cfg, cliOf(a), parseConfirmContext(v.screen), false).decision === "leave") {
|
|
6224
|
+
return { status: "no_modal", reason: "independent native approval left on CLI" };
|
|
6225
|
+
}
|
|
6049
6226
|
const pick = pickModalAnswer(parseModalOptions(v.screen), v.title);
|
|
6050
6227
|
if (!pick) {
|
|
6051
6228
|
lg.warn({ title: v.title }, "native modal: no trustworthy one-shot option, not pressing");
|
|
@@ -6086,6 +6263,12 @@ export const startMirror = (deps) => {
|
|
|
6086
6263
|
bySessionId.clear();
|
|
6087
6264
|
byTarget.clear();
|
|
6088
6265
|
},
|
|
6266
|
+
currentAsker: (target) => {
|
|
6267
|
+
const a = byTarget.get(target);
|
|
6268
|
+
if (a?.convFrom)
|
|
6269
|
+
return a.convFrom.asker;
|
|
6270
|
+
return a?.speaker ? { who: a.speaker, chat: a.channel ?? baseOfKey(target) } : undefined;
|
|
6271
|
+
},
|
|
6089
6272
|
currentChannel: (target) => {
|
|
6090
6273
|
const a = byTarget.get(target);
|
|
6091
6274
|
return a?.channel ?? baseOfKey(target);
|
|
@@ -6268,7 +6451,7 @@ export const startMirror = (deps) => {
|
|
|
6268
6451
|
const resumeBaseline = !armMigration ? listJsonls(dirname(a.jsonlPath)) : undefined;
|
|
6269
6452
|
// Respawn in the binding's runningCwd (pendingCwd doesn't apply to a
|
|
6270
6453
|
// mid-turn reincarnation — only /new and /clear-with-pending swap cwd).
|
|
6271
|
-
const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn", sessionId: sid }), resumeSessionId: sid, windowName: displayName(a.target) || a.target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, ...respawnSpec(a), systemPrompt: charterFor(a.target, { cwd: a.runningCwd }) });
|
|
6454
|
+
const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn", sessionId: sid }), resumeSessionId: sid, windowName: displayName(a.target) || a.target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, ...respawnSpec(a), systemPrompt: charterFor(a.target, { cwd: a.runningCwd, cli: backendForPath(a.jsonlPath).name }) });
|
|
6272
6455
|
if (r.ok && r.tmuxPane && r.tmuxSession) {
|
|
6273
6456
|
a.tmuxPane = r.tmuxPane;
|
|
6274
6457
|
a.tmuxSession = r.tmuxSession;
|