wezard 2.5.1 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CLAUDE.md +3 -0
- package/README.md +43 -1
- package/cli/wezard.sh +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 +351 -144
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/peers.js +12 -16
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/picker-policy.js +46 -0
- package/dist/daemon/picker-policy.js.map +1 -0
- package/dist/daemon/quota.js +10 -7
- package/dist/daemon/quota.js.map +1 -1
- package/dist/daemon/reach.js +5 -1
- package/dist/daemon/reach.js.map +1 -1
- package/dist/daemon/receipts.js +1 -1
- package/dist/daemon/receipts.js.map +1 -1
- package/dist/daemon/route.js +67 -26
- package/dist/daemon/route.js.map +1 -1
- package/dist/daemon/session-scan.js +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/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
|
|
@@ -16,29 +19,30 @@ import { spawn } from "node:child_process";
|
|
|
16
19
|
import { randomUUID } from "node:crypto";
|
|
17
20
|
import { existsSync, readdirSync, readFileSync, statSync, watch, openSync, readSync, closeSync } from "node:fs";
|
|
18
21
|
import { join, dirname } from "node:path";
|
|
22
|
+
import { newStewardSpec, resumedStewardSpec } from "./steward-model.js";
|
|
19
23
|
import { expandHome, sanitizeId } from "../shared/paths.js";
|
|
20
24
|
import { augmentedPath } from "../shared/exec-path.js";
|
|
21
25
|
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 {
|
|
26
|
+
import { activeBackends, resolveCliBackend, backendForPath, projectDirFor, projectDirsFor, } from "../shared/cli-backends.js";
|
|
27
|
+
import { isModalPane, isAskqSubmitPage, parseModalOptions, pickModalAnswer, pickAutoAllowAnswer, parseConfirmContext, modalFingerprint, looksLikePicker } from "../shared/modal-pane.js";
|
|
28
|
+
import { hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow, hasMirrorPicker, mootMirrorPicker, runMirrorPickerFlow } from "./approval.js";
|
|
25
29
|
import { isAutoWindowActive } from "./session-cache.js";
|
|
26
30
|
import { noticeSuffixFor } from "./notices.js";
|
|
27
|
-
import {
|
|
31
|
+
import { dispatchPickerPolicy, pickerPolicy } from "./picker-policy.js";
|
|
28
32
|
import { renameWindow, runTmux, spawnTmuxClaude } from "./spawn-tmux.js";
|
|
29
33
|
import { selectEffort, selectModel } from "./model-select.js";
|
|
30
34
|
import { parseEffort } from "../shared/effort.js";
|
|
31
|
-
import { hasRegistry, markTranscript, probeOf, sessionOnPane,
|
|
35
|
+
import { hasRegistry, liveSessionPids, markTranscript, probeOf, sessionOnPane, submittedSince, watchRegistry } from "./cc-session.js";
|
|
32
36
|
import { waitForIdle } from "./graph.js";
|
|
33
37
|
import { wizardStore } from "./wizard.js";
|
|
34
38
|
import { startSubagentWatch } from "./subagent-tail.js";
|
|
35
39
|
import { knowsToolUse, recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnQuery, recordTurnInject, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, lastChannelOf, openTurnsOf, buildDetailUrl, buildChatUrl, roleUniq } from "./detail.js";
|
|
36
40
|
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";
|
|
41
|
+
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
42
|
import { splitMarkdown } from "../shared/md-chunk.js";
|
|
39
43
|
import { isWxFrame } from "../shared/wx-text.js";
|
|
40
44
|
import { randomTip } from "./tips.js";
|
|
41
|
-
import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress
|
|
45
|
+
import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress } from "./chat-name.js";
|
|
42
46
|
import { stripAnsi, paneIsBusy, summarizeTail, lastAssistantText, lastReply, replyToPeer as replyToPeerIn, unwrapPasted, lastContextTokens, lastModel, cacheTouchMs, cacheTtlSec, keepaliveStamps, openKeepalivePing, lastUserTurn, talkRounds, openToolUses, renderDialog } from "./peers.js";
|
|
43
47
|
import { keepalivePingSigs, isKeepalivePingText } from "../shared/keepalive.js";
|
|
44
48
|
// PATH augmentation: launchd / systemd start the daemon
|
|
@@ -92,7 +96,9 @@ const readCwdFromJsonl = (path) => {
|
|
|
92
96
|
// parse (wrong jsonl dialect). Only genuine discovery searches the union.
|
|
93
97
|
const rankedJsonlsForCwd = (cwd, only, exclude) => projectDirsFor(expandHome(cwd))
|
|
94
98
|
.filter(({ backend }) => !only || backend.name === only.name)
|
|
95
|
-
.flatMap(({ dir }) => {
|
|
99
|
+
.flatMap(({ backend, dir }) => {
|
|
100
|
+
if (backend.name === "codex")
|
|
101
|
+
return codexSessions(dir, expandHome(cwd)).filter((x) => !exclude?.has(x.sessionId)).map((x) => ({ ...x, mtime: statSync(x.jsonlPath).mtimeMs }));
|
|
96
102
|
let names;
|
|
97
103
|
try {
|
|
98
104
|
names = readdirSync(dir).filter((n) => n.endsWith(".jsonl"));
|
|
@@ -133,6 +139,8 @@ const findJsonlBySid = (sid, only) => {
|
|
|
133
139
|
return (only ? [only] : activeBackends())
|
|
134
140
|
.flatMap((b) => {
|
|
135
141
|
const root = expandHome(b.projectsDir);
|
|
142
|
+
if (b.name === "codex")
|
|
143
|
+
return codexSessions(root).filter((x) => x.sessionId === sid).map((x) => x.jsonlPath);
|
|
136
144
|
try {
|
|
137
145
|
return readdirSync(root).map((d) => join(root, d, sidFile));
|
|
138
146
|
}
|
|
@@ -719,6 +727,11 @@ const renderLine = (raw, deps) => {
|
|
|
719
727
|
},
|
|
720
728
|
});
|
|
721
729
|
}
|
|
730
|
+
// Codex turn_context reports the real tier before any token usage.
|
|
731
|
+
// Forward metadata without inventing an API call or resetting context counts.
|
|
732
|
+
if (!u && (line.message?.model || line.effort)) {
|
|
733
|
+
out.push({ kind: "turn_usage", model: line.message?.model, effort: line.effort });
|
|
734
|
+
}
|
|
722
735
|
// Terminal stop_reason → emit turn_end so onItem closes the live bubble.
|
|
723
736
|
// `tool_use` is intentionally excluded — more turns will follow once the
|
|
724
737
|
// tool result lands; finalizing now would split a single logical reply.
|
|
@@ -730,7 +743,7 @@ const renderLine = (raw, deps) => {
|
|
|
730
743
|
// 且此后整轮的 tool/text 全部退化成散装气泡。终态消息里必然有 text 块 (含
|
|
731
744
|
// tool_use 的消息 stop_reason 恒为 "tool_use"), 且实测终态消息从不拆出第二个
|
|
732
745
|
// text 行 —— 所以"本行带 text 块"精确等价于"本消息的最后一行"。
|
|
733
|
-
if (isFinal && blocks.some((b) => b?.type === "text"))
|
|
746
|
+
if (isFinal && !line.awaitTurnEnd && blocks.some((b) => b?.type === "text"))
|
|
734
747
|
out.push({ kind: "turn_end" });
|
|
735
748
|
// 软收口 (codebuddy): 这条消息写完了, 但说不出后面还有没有 function_call。
|
|
736
749
|
else if (line.softTurnEnd)
|
|
@@ -774,6 +787,23 @@ const renderLine = (raw, deps) => {
|
|
|
774
787
|
const TAG_HEADER_BUDGET = 256 * (2 + MAX_BODY_LINKS);
|
|
775
788
|
export const startMirrorTail = (deps) => {
|
|
776
789
|
const { log } = deps;
|
|
790
|
+
// Codex records model/effort separately from per-response usage. Keep that
|
|
791
|
+
// context local to this tail (never share it between sessions/normalizers).
|
|
792
|
+
if (backendForPath(deps.jsonlPath).name === "codex" && deps.normalizeLine) {
|
|
793
|
+
const normalize = deps.normalizeLine;
|
|
794
|
+
let model = deps.startOffset === 0 ? "" : lastModel(deps.jsonlPath);
|
|
795
|
+
let effort;
|
|
796
|
+
deps = { ...deps, normalizeLine: (raw) => {
|
|
797
|
+
const line = normalize(raw);
|
|
798
|
+
if (line?.message?.model) {
|
|
799
|
+
model = line.message.model;
|
|
800
|
+
effort = line.effort;
|
|
801
|
+
}
|
|
802
|
+
return line?.message?.usage
|
|
803
|
+
? { ...line, effort, message: { ...line.message, model } }
|
|
804
|
+
: line;
|
|
805
|
+
} };
|
|
806
|
+
}
|
|
777
807
|
// A single logical tail follows ONE sessionId whose transcript file may move
|
|
778
808
|
// between sibling project dirs — Claude Code's EnterWorktree/ExitWorktree
|
|
779
809
|
// rename <sid>.jsonl into/out of <cwd>/.claude/worktrees/<name>, each cwd
|
|
@@ -827,6 +857,11 @@ export const startMirrorTail = (deps) => {
|
|
|
827
857
|
watchedPath = path;
|
|
828
858
|
try {
|
|
829
859
|
watcher = watch(path, { persistent: false }, () => drain());
|
|
860
|
+
const active = watcher;
|
|
861
|
+
active.on("error", (err) => {
|
|
862
|
+
active.close();
|
|
863
|
+
log.warn({ err: err.message }, "fs.watch failed; relying on poll");
|
|
864
|
+
});
|
|
830
865
|
}
|
|
831
866
|
catch (e) {
|
|
832
867
|
watcher = undefined;
|
|
@@ -1100,19 +1135,19 @@ const extractPaneAssistantTail = (pane) => {
|
|
|
1100
1135
|
// long after the pane went back to accepting input — wedging the mirror shut
|
|
1101
1136
|
// permanently. Bare `-p` captures only what is currently on screen.
|
|
1102
1137
|
//
|
|
1103
|
-
//
|
|
1104
|
-
//
|
|
1105
|
-
//
|
|
1106
|
-
|
|
1107
|
-
// `screen` = 实际参与判定的那一屏文本, 一并返回给调用方做选项解析 —— 再 capture
|
|
1108
|
-
// 一次会拿到"下一瞬间"的屏幕, 与 verdict 不同源(框可能已被本地按掉), 按键就按错了。
|
|
1138
|
+
// Trim trailing blank rows so the bottom 15 rows locate the active picker.
|
|
1139
|
+
// Keep the full current screen from that same capture for operation context;
|
|
1140
|
+
// a second capture might already be a different invocation. -J joins terminal
|
|
1141
|
+
// wraps without reading scrollback or dropping long command arguments.
|
|
1109
1142
|
const detectModalPicker = async (target) => {
|
|
1110
|
-
const r = await runTmux(["capture-pane", "-t", target, "-p"]);
|
|
1143
|
+
const r = await runTmux(["capture-pane", "-t", target, "-p", "-J"]);
|
|
1111
1144
|
if (!r.ok)
|
|
1112
1145
|
return { modal: false, screen: "" };
|
|
1113
1146
|
const lines = r.stdout.replace(/\s+$/u, "").split("\n");
|
|
1114
|
-
const screen = lines.
|
|
1115
|
-
|
|
1147
|
+
const screen = lines.join("\n");
|
|
1148
|
+
// Only the bottom picker may block input. Earlier completed pickers on the
|
|
1149
|
+
// current screen remain useful context, but must not look like a live modal.
|
|
1150
|
+
return { ...isModalPane(lines.slice(-15).join("\n")), screen };
|
|
1116
1151
|
};
|
|
1117
1152
|
// 按下确认框的第 index 号选项。Claude Code 的 picker 数字键即选即确认; CodeBuddy
|
|
1118
1153
|
// 的框数字键不一定生效, 且高亮可能停在别的选项上 (实测见过默认高亮在 "Yes, and
|
|
@@ -1124,10 +1159,14 @@ const MODAL_PRESS_SETTLE_MS = 400;
|
|
|
1124
1159
|
const MODAL_ARROW_SETTLE_MS = 150;
|
|
1125
1160
|
const highlightedIndex = (screen) => parseModalOptions(screen).find((o) => o.selected)?.index;
|
|
1126
1161
|
const pressModalIndex = async (pane, before, index) => {
|
|
1162
|
+
const fingerprint = modalFingerprint(before.screen);
|
|
1163
|
+
const current = await detectModalPicker(pane);
|
|
1164
|
+
if (!current.modal || modalFingerprint(current.screen) !== fingerprint)
|
|
1165
|
+
return current;
|
|
1127
1166
|
await runTmux(["send-keys", "-t", pane, String(index)]);
|
|
1128
1167
|
await sleep(MODAL_PRESS_SETTLE_MS);
|
|
1129
1168
|
let after = await detectModalPicker(pane);
|
|
1130
|
-
if (!after.modal || after.
|
|
1169
|
+
if (!after.modal || modalFingerprint(after.screen) !== fingerprint)
|
|
1131
1170
|
return after;
|
|
1132
1171
|
// 数字键没关掉框: 把高亮挪到目标项。guard 上限盖过任何确认框的选项数。
|
|
1133
1172
|
for (let guard = 0; guard < 8; guard++) {
|
|
@@ -1137,7 +1176,7 @@ const pressModalIndex = async (pane, before, index) => {
|
|
|
1137
1176
|
await runTmux(["send-keys", "-t", pane, cur < index ? "Down" : "Up"]);
|
|
1138
1177
|
await sleep(MODAL_ARROW_SETTLE_MS);
|
|
1139
1178
|
after = await detectModalPicker(pane);
|
|
1140
|
-
if (!after.modal || after.
|
|
1179
|
+
if (!after.modal || modalFingerprint(after.screen) !== fingerprint)
|
|
1141
1180
|
return after;
|
|
1142
1181
|
}
|
|
1143
1182
|
if (highlightedIndex(after.screen) !== index)
|
|
@@ -1246,10 +1285,39 @@ const BOX_PROMPT_RE = /^\s*[❯>]\s?/u;
|
|
|
1246
1285
|
/** 底 ruler 之下只该剩 cwd/模式两行状态; 多于此说明框到的不是输入框。 */
|
|
1247
1286
|
const BOX_FOOTER_ROWS = 6;
|
|
1248
1287
|
const UNKNOWN_BOX = { known: false, body: "" };
|
|
1249
|
-
export const parseInputBox = (cap) => {
|
|
1288
|
+
export const parseInputBox = (cap, styledLayout = cap) => {
|
|
1250
1289
|
const lines = cap.split("\n");
|
|
1251
1290
|
while (lines.length && lines[lines.length - 1].trim() === "")
|
|
1252
1291
|
lines.pop();
|
|
1292
|
+
// Codex has no rulers. Anchor the composer to its bottom shortcuts footer,
|
|
1293
|
+
// then use the LAST › prompt: earlier prompts belong to the transcript.
|
|
1294
|
+
// Requiring a status row and a blank separator avoids treating arbitrary
|
|
1295
|
+
// output containing › as editable input. Collapsed pastes remain visible
|
|
1296
|
+
// as a body change even though the original text fingerprints disappear.
|
|
1297
|
+
const styledLines = styledLayout.split("\n");
|
|
1298
|
+
const layout = styledLines.map((line) => line.replace(/\x1b\[[0-9;:]*m/gu, "").replace(OTHER_ESC_RE, ""));
|
|
1299
|
+
while (layout.length && !layout[layout.length - 1].trim())
|
|
1300
|
+
layout.pop();
|
|
1301
|
+
const footer = layout.length - 1;
|
|
1302
|
+
// Codex hides the shortcuts row while the composer contains text.
|
|
1303
|
+
const shortcuts = /\? for shortcuts\s*$/u.test(layout[footer] ?? "");
|
|
1304
|
+
if (shortcuts || (layout[footer] ?? "").includes("·")) {
|
|
1305
|
+
let status = shortcuts ? footer - 1 : footer;
|
|
1306
|
+
while (status >= 0 && !layout[status].trim())
|
|
1307
|
+
status--;
|
|
1308
|
+
if (status >= 0 && footer - status <= BOX_FOOTER_ROWS && layout[status].includes("·") && status > 0 && !layout[status - 1].trim()) {
|
|
1309
|
+
let prompt = status - 2;
|
|
1310
|
+
// Continuation rows are indented; a › inside pasted content is not a
|
|
1311
|
+
// new composer prompt.
|
|
1312
|
+
while (prompt >= 0 && !/^›(?:\s|$)/u.test(layout[prompt]))
|
|
1313
|
+
prompt--;
|
|
1314
|
+
if (prompt >= 0 && (shortcuts || /\x1b\[[0-9;:]*48[;:]/u.test(styledLines[prompt]))) {
|
|
1315
|
+
const body = dropDim(styledLines.slice(prompt, status).join("\n"))
|
|
1316
|
+
.replace(/^\s*›\s?/u, "").replace(/[\s\u200b\ufeff]+/gu, " ").trim();
|
|
1317
|
+
return { known: true, body };
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1253
1321
|
const rulers = lines.reduce((acc, l, i) => (BOX_RULER_RE.test(l) ? [...acc, i] : acc), []);
|
|
1254
1322
|
const bottom = rulers[rulers.length - 1];
|
|
1255
1323
|
const top = rulers[rulers.length - 2];
|
|
@@ -1287,10 +1355,18 @@ export const dropDim = (styled) => {
|
|
|
1287
1355
|
};
|
|
1288
1356
|
const captureUndimmed = async (target, rows) => {
|
|
1289
1357
|
const r = await runTmux(["capture-pane", "-t", target, "-p", "-e", "-S", `-${rows}`]);
|
|
1290
|
-
|
|
1358
|
+
// Negative -S includes scrollback plus the entire visible pane, not its
|
|
1359
|
+
// last N rows. Crop before removing dim text so row boundaries survive.
|
|
1360
|
+
return r.ok ? dropDim(r.stdout.replace(/\n$/u, "").split("\n").slice(-rows).join("\n")) : "";
|
|
1291
1361
|
};
|
|
1292
1362
|
// 24 行足够容纳展开后的多行粘贴 (折叠态只占一行)。
|
|
1293
|
-
const
|
|
1363
|
+
const readPaneInput = async (target) => {
|
|
1364
|
+
const r = await runTmux(["capture-pane", "-t", target, "-p", "-e", "-S", "-24"]);
|
|
1365
|
+
const raw = r.ok ? r.stdout : "";
|
|
1366
|
+
const cap = dropDim(raw);
|
|
1367
|
+
return { cap, box: parseInputBox(cap, raw) };
|
|
1368
|
+
};
|
|
1369
|
+
const readInputBox = async (target) => (await readPaneInput(target)).box;
|
|
1294
1370
|
// ── 注入门控的会话证人 (cc-session) ─────────────────────────────────
|
|
1295
1371
|
// 能从会话自己写的 json 读到的判据, 就不读 pane: 就绪看注册表的 status, 提交看
|
|
1296
1372
|
// 注册表的 busy 翻转或 jsonl 里新落的那一行。输入框里「贴进去没有」是唯一只有
|
|
@@ -1445,8 +1521,18 @@ const withImageRefs = (text, images) => {
|
|
|
1445
1521
|
// 证人不作声 (忙时排队、slash 命令、无注册表的后端) 才看框回没回到贴之前的样子。
|
|
1446
1522
|
// 框认得出来时这条判据可信, 于是允许多补回车 (空框上的回车在 CLI 里是 no-op);
|
|
1447
1523
|
// 每次补之前再问一遍证人, 已提交就不补。
|
|
1448
|
-
|
|
1449
|
-
|
|
1524
|
+
// tmux < 2.0 的 buffer 命令只认内部名 (bufferNNNN): 自定义名在 load/show/delete 上
|
|
1525
|
+
// 一律 "buffer invalid" (devcloud tmux 1.8 实测, 与内容无关), 默认 buffer (不带 -b)
|
|
1526
|
+
// 正常 —— 跨 pane 并发隔离不能靠具名 buffer。改用默认 buffer + 进程内串行: load→paste
|
|
1527
|
+
// 这段临界区里不许第二个注入插进来, 否则共用 buffer 会让两段文本互换 pane (具名 buffer
|
|
1528
|
+
// 防的正是这个实测坑; 守护进程单进程, 一把 promise 锁就够)。临界区只有两条 tmux 命令
|
|
1529
|
+
// (<100ms), 串行不构成分延迟。
|
|
1530
|
+
let pasteQueue = Promise.resolve();
|
|
1531
|
+
const withPasteLock = (fn) => {
|
|
1532
|
+
const r = pasteQueue.then(fn);
|
|
1533
|
+
pasteQueue = r.then(() => undefined, () => undefined);
|
|
1534
|
+
return r;
|
|
1535
|
+
};
|
|
1450
1536
|
const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS) => {
|
|
1451
1537
|
// Warm pane: tight timings, low latency. Fresh spawn (claude --resume just
|
|
1452
1538
|
// started, transcript still loading): extended timings — bracketed-paste
|
|
@@ -1487,26 +1573,26 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1487
1573
|
const loadAndPaste = async () => {
|
|
1488
1574
|
// stdin variant of the shared exec path — it carries the same hard timeout,
|
|
1489
1575
|
// which a hand-rolled spawn here did not.
|
|
1490
|
-
//
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
return { ok: true };
|
|
1576
|
+
// 默认 buffer (不带 -b) 是新旧 tmux 都支持的唯一公共形式; 隔离由 withPasteLock 保证。
|
|
1577
|
+
return withPasteLock(async () => {
|
|
1578
|
+
const loaded = await runTmux(["load-buffer", "-"], { stdin: text });
|
|
1579
|
+
if (!loaded.ok)
|
|
1580
|
+
return { ok: false, reason: `tmux load-buffer failed: ${loaded.stderr.slice(-200) || loaded.code}` };
|
|
1581
|
+
const pasted = await runTmux(["paste-buffer", "-p", "-d", "-t", target]);
|
|
1582
|
+
if (!pasted.ok)
|
|
1583
|
+
return { ok: false, reason: `tmux paste-buffer failed: ${pasted.stderr.slice(-200)}` };
|
|
1584
|
+
return { ok: true };
|
|
1585
|
+
});
|
|
1501
1586
|
};
|
|
1502
1587
|
// 框认得出来 → 看它变没变; 认不出来 → 退回宽窗口找 headFp (长文本会换行,
|
|
1503
1588
|
// 头部可能在框顶, 所以窗口要宽)。
|
|
1504
1589
|
const sawLanded = async (timeoutMs) => {
|
|
1505
1590
|
const t0 = Date.now();
|
|
1506
1591
|
while (Date.now() - t0 < timeoutMs) {
|
|
1507
|
-
const cap = await
|
|
1508
|
-
|
|
1509
|
-
|
|
1592
|
+
const { cap, box } = await readPaneInput(target);
|
|
1593
|
+
// A redraw can hide the baseline. Once the composer reappears, its
|
|
1594
|
+
// nonempty body also proves landing of a collapsed paste.
|
|
1595
|
+
if (box.known ? (boxVerify ? !boxIntact(box) : box.body !== "") : Boolean(headFp) && stripWs(cap).includes(headFp))
|
|
1510
1596
|
return true;
|
|
1511
1597
|
await sleep(POLL_MS);
|
|
1512
1598
|
}
|
|
@@ -1519,6 +1605,7 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1519
1605
|
return Boolean(tailFp) && stripWs(pane).includes(tailFp);
|
|
1520
1606
|
};
|
|
1521
1607
|
let pasteSeen = alreadyTyped;
|
|
1608
|
+
let pastedAt = Date.now();
|
|
1522
1609
|
if (alreadyTyped) {
|
|
1523
1610
|
log.warn({ target, headFp }, "mirror inject: 输入框里已有这段文本 (上次未提交) — 不重贴, 直接提交");
|
|
1524
1611
|
}
|
|
@@ -1526,7 +1613,11 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1526
1613
|
const r = await loadAndPaste();
|
|
1527
1614
|
if (!r.ok)
|
|
1528
1615
|
return r;
|
|
1529
|
-
|
|
1616
|
+
pastedAt = Date.now();
|
|
1617
|
+
// An unknown layout cannot verify collapsed text by fingerprint. Bound
|
|
1618
|
+
// that inconclusive probe by the catch-up budget instead of waiting 6s
|
|
1619
|
+
// and then sleeping another 2.5s before the first Enter.
|
|
1620
|
+
pasteSeen = await sawLanded(boxVerify ? PASTE_VERIFY_MS : POST_PASTE_SETTLE_FALLBACK_MS);
|
|
1530
1621
|
}
|
|
1531
1622
|
if (!pasteSeen) {
|
|
1532
1623
|
const box = await readInputBox(target);
|
|
@@ -1540,7 +1631,7 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1540
1631
|
log.warn({ target, headFp, boxKnown: box.known }, "mirror inject: 粘贴未验证但输入框非空 — 首次粘贴已落地, 不重贴");
|
|
1541
1632
|
pasteSeen = true;
|
|
1542
1633
|
}
|
|
1543
|
-
else {
|
|
1634
|
+
else if (boxVerify && box.known) {
|
|
1544
1635
|
log.warn({ target, headFp, boxKnown: box.known }, "mirror inject: 输入框确认为空, 重贴一次");
|
|
1545
1636
|
await sleep(RETRY_SETTLE_MS);
|
|
1546
1637
|
if (submitted())
|
|
@@ -1548,8 +1639,14 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1548
1639
|
const r = await loadAndPaste();
|
|
1549
1640
|
if (!r.ok)
|
|
1550
1641
|
return r;
|
|
1642
|
+
pastedAt = Date.now();
|
|
1551
1643
|
pasteSeen = await sawLanded(PASTE_VERIFY_MS);
|
|
1552
1644
|
}
|
|
1645
|
+
else {
|
|
1646
|
+
// Missing fingerprints are not proof of an empty composer: other TUIs
|
|
1647
|
+
// may collapse pasted text. Never duplicate a paste on that evidence.
|
|
1648
|
+
log.warn({ target, headFp }, "mirror inject: 输入框布局未知, 不重贴未确认的粘贴");
|
|
1649
|
+
}
|
|
1553
1650
|
}
|
|
1554
1651
|
// 粘贴落定 = 框里是我们的内容, 且连续两次读到同一个样子 —— TUI 已把整段粘贴
|
|
1555
1652
|
// (bracketed-paste end 在内) 消化完, 紧跟的回车不会被并进粘贴。实测冷启动的
|
|
@@ -1568,10 +1665,10 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1568
1665
|
// Bracketed-paste end + TUI catch-up. 框认不出来 / 粘贴没被看见时没有条件可查,
|
|
1569
1666
|
// 维持固定等待 (fresh respawn: claude --resume is still loading the transcript)。
|
|
1570
1667
|
if (!alreadyTyped) {
|
|
1571
|
-
if (pasteSeen
|
|
1668
|
+
if (pasteSeen)
|
|
1572
1669
|
await pasteSettled(POST_PASTE_SETTLE_MS);
|
|
1573
1670
|
else
|
|
1574
|
-
await sleep(
|
|
1671
|
+
await sleep(Math.max(0, POST_PASTE_SETTLE_FALLBACK_MS - (Date.now() - pastedAt)));
|
|
1575
1672
|
}
|
|
1576
1673
|
const sendEnter = async () => {
|
|
1577
1674
|
const e = await runTmux(["send-keys", "-t", target, "Enter"]);
|
|
@@ -1579,7 +1676,7 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1579
1676
|
};
|
|
1580
1677
|
const cleared = async () => {
|
|
1581
1678
|
const box = await readInputBox(target);
|
|
1582
|
-
return
|
|
1679
|
+
return box.known ? (boxVerify ? boxIntact(box) : box.body === "") : !(await inputBoxStillHasTail());
|
|
1583
1680
|
};
|
|
1584
1681
|
/** 证人先答; 它的窗口过了还不作声, 才每轮加看一眼框。 */
|
|
1585
1682
|
const waitForSubmit = async (timeoutMs) => {
|
|
@@ -1612,8 +1709,10 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1612
1709
|
return { ok: true }; // 迟到的确认 —— 补的这一下会落在空框上, 省了
|
|
1613
1710
|
log.warn({ target, tailFp, attempt: i + 1 }, "mirror inject: 回车后输入框未清空, 再补一次");
|
|
1614
1711
|
}
|
|
1615
|
-
if (i === 0)
|
|
1712
|
+
if (i === 0) {
|
|
1616
1713
|
enterAt = Date.now();
|
|
1714
|
+
log.info({ target, pasteToEnterMs: enterAt - pastedAt, pasteSeen, boxTrusted, freshSpawn }, "mirror inject: first Enter");
|
|
1715
|
+
}
|
|
1617
1716
|
const e = await sendEnter();
|
|
1618
1717
|
if (!e.ok)
|
|
1619
1718
|
return e;
|
|
@@ -1628,14 +1727,42 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1628
1727
|
log.warn({ target, tailFp, freshSpawn, boxTrusted, witness: witness.status }, "mirror inject: clear not observed, trusting submit");
|
|
1629
1728
|
return { ok: true, uncertain: true, reason: "目标会话可能正忙或未消费输入(回车后输入框未清空)" };
|
|
1630
1729
|
};
|
|
1631
|
-
const injectViaSpawn = (args) => {
|
|
1730
|
+
const injectViaSpawn = async (args) => {
|
|
1632
1731
|
const { text, images = [], cfg, log, sessionId, jsonlPath } = args;
|
|
1633
|
-
const
|
|
1732
|
+
const backend = backendForPath(jsonlPath);
|
|
1733
|
+
const bin = backend.bin;
|
|
1734
|
+
let cwd = expandHome(cfg.wrc.cwd);
|
|
1735
|
+
let codexArgs = [];
|
|
1736
|
+
if (backend.name === "codex") {
|
|
1737
|
+
try {
|
|
1738
|
+
const fd = openSync(jsonlPath, "r");
|
|
1739
|
+
let head;
|
|
1740
|
+
try {
|
|
1741
|
+
const buf = Buffer.alloc(256 * 1024);
|
|
1742
|
+
head = buf.subarray(0, readSync(fd, buf, 0, buf.length, 0)).toString("utf8");
|
|
1743
|
+
}
|
|
1744
|
+
finally {
|
|
1745
|
+
closeSync(fd);
|
|
1746
|
+
}
|
|
1747
|
+
const meta = JSON.parse(head.split("\n", 1)[0]).payload;
|
|
1748
|
+
if (typeof meta?.cwd === "string")
|
|
1749
|
+
cwd = meta.cwd;
|
|
1750
|
+
const hookEntry = fileURLToPath(new URL("../cli/codex-hook.js", import.meta.url));
|
|
1751
|
+
const { loadConfig } = await import("../shared/config.js");
|
|
1752
|
+
const command = codexHookCommand(loadConfig().sourcePath, `http://127.0.0.1:${cfg.daemon.port}`, hookEntry);
|
|
1753
|
+
codexArgs = await codexHookLaunchArgs(bin, cwd, command, cfg.approval.hookTimeoutSec + 10, cfg.approval.enabled);
|
|
1754
|
+
}
|
|
1755
|
+
catch (e) {
|
|
1756
|
+
return { ok: false, reason: `Codex resume setup failed: ${e.message}` };
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1634
1759
|
// Spawn-mode (no live TTY) can't do clipboard+C-v — fall back to `@<path>`,
|
|
1635
1760
|
// which Claude parses at submit time and inlines as image content blocks
|
|
1636
1761
|
// without a model-decided Read tool turn.
|
|
1637
1762
|
const finalText = withImageRefs(text, images);
|
|
1638
|
-
const cliArgs = [
|
|
1763
|
+
const cliArgs = backend.name === "codex" ? ["exec", "resume", "--json", ...codexArgs,
|
|
1764
|
+
...cfg.wrc.extraArgs.filter((arg) => !arg.startsWith("--disallowedTools")),
|
|
1765
|
+
...images.flatMap((path) => ["--image", path]), sessionId, text] : [
|
|
1639
1766
|
"-p",
|
|
1640
1767
|
finalText,
|
|
1641
1768
|
"--resume",
|
|
@@ -1648,7 +1775,7 @@ const injectViaSpawn = (args) => {
|
|
|
1648
1775
|
log.info({ sessionId, bin, len: text.length }, "mirror inject");
|
|
1649
1776
|
return new Promise((resolve) => {
|
|
1650
1777
|
const proc = spawn(bin, cliArgs, {
|
|
1651
|
-
cwd
|
|
1778
|
+
cwd,
|
|
1652
1779
|
env: { ...process.env, PATH: augmentedPath(process.env.PATH) },
|
|
1653
1780
|
stdio: ["ignore", "ignore", "pipe"],
|
|
1654
1781
|
});
|
|
@@ -1693,6 +1820,30 @@ const INJECT_JOB_TIMEOUT_MS = 90_000;
|
|
|
1693
1820
|
/** 没有绑定的 pane 静默多久才算孤儿。刚 spawn、还没 attach 的 pane (并发 fan-out 时
|
|
1694
1821
|
* 别人的 sweep 会撞见它) 在这段时间里一直有输出, 远大于一次 spawn → attach 的耗时。 */
|
|
1695
1822
|
const ORPHAN_GRACE_MS = 10 * 60_000;
|
|
1823
|
+
const psSnap = () => new Promise((resolve) => {
|
|
1824
|
+
const p = spawn("ps", ["-axo", "pid=,ppid=,etimes="], { env: { ...process.env, PATH: augmentedPath(process.env.PATH) }, stdio: ["ignore", "pipe", "ignore"] });
|
|
1825
|
+
let out = "";
|
|
1826
|
+
p.stdout?.on("data", (c) => (out += c.toString("utf8")));
|
|
1827
|
+
const done = () => {
|
|
1828
|
+
const kids = new Map();
|
|
1829
|
+
const etimes = new Map();
|
|
1830
|
+
for (const line of out.split("\n")) {
|
|
1831
|
+
const [pid, ppid, et] = line.trim().split(/\s+/).map(Number);
|
|
1832
|
+
if (!pid || !ppid)
|
|
1833
|
+
continue;
|
|
1834
|
+
const arr = kids.get(ppid);
|
|
1835
|
+
if (arr)
|
|
1836
|
+
arr.push(pid);
|
|
1837
|
+
else
|
|
1838
|
+
kids.set(ppid, [pid]);
|
|
1839
|
+
if (et !== undefined && !Number.isNaN(et))
|
|
1840
|
+
etimes.set(pid, et);
|
|
1841
|
+
}
|
|
1842
|
+
resolve({ kids, etimes });
|
|
1843
|
+
};
|
|
1844
|
+
p.on("close", done);
|
|
1845
|
+
p.on("error", () => resolve({ kids: new Map(), etimes: new Map() }));
|
|
1846
|
+
});
|
|
1696
1847
|
/** Drop a session's queue chain. A hung job can't be cancelled (it's parked in a
|
|
1697
1848
|
* tmux read), but detaching the chain means the NEXT message doesn't inherit
|
|
1698
1849
|
* its deadlock. Pair with an injectGen bump so the zombie can't act when it
|
|
@@ -2401,11 +2552,11 @@ export const startMirror = (deps) => {
|
|
|
2401
2552
|
if (!env)
|
|
2402
2553
|
return undefined;
|
|
2403
2554
|
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 } : {}) }
|
|
2555
|
+
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
2556
|
: env.kind === "task" ? { kind: "task", taskId: env.from.replace(/^定时 /, "") }
|
|
2406
2557
|
: undefined;
|
|
2407
2558
|
return {
|
|
2408
|
-
channel: env.private ? "" : (env.chat && chatBaseOf(cfg, env.chat)) || home,
|
|
2559
|
+
channel: env.private ? "" : env.chatKey || (env.chat && chatBaseOf(cfg, env.chat)) || home,
|
|
2409
2560
|
speaker: env.kind === "human" ? `user:${env.from}` : undefined,
|
|
2410
2561
|
from,
|
|
2411
2562
|
query: t.text,
|
|
@@ -2464,8 +2615,6 @@ export const startMirror = (deps) => {
|
|
|
2464
2615
|
const isPrivate = (a) => a.channel === "";
|
|
2465
2616
|
/** 本轮回复该推去的 chatId。 */
|
|
2466
2617
|
const replyChatId = (a) => stripPrincipalPrefix(a.channel || a.target);
|
|
2467
|
-
// 审批/提问卡同样回本轮的频道; 私聊轮 ("") 的卡回 home —— 要点它的人总得在某个群里。
|
|
2468
|
-
bindCardChannel((key) => byTarget.get(key)?.channel || "");
|
|
2469
2618
|
// 详情页要按后端的名字称呼它 ("Claude 正在思考" / "CodeBuddy 正在思考") —— 后端
|
|
2470
2619
|
// 由 transcript 落盘路径唯一确定, 所以每次开 turn 时现算, 不必额外记在 AttachState 上。
|
|
2471
2620
|
const cliOf = (a) => a.jsonlPath ? backendForPath(a.jsonlPath).name : undefined;
|
|
@@ -2520,7 +2669,7 @@ export const startMirror = (deps) => {
|
|
|
2520
2669
|
a.pendingBriefQuery = undefined;
|
|
2521
2670
|
a.pendingChannel = { channel: s.channel ?? baseOfKey(a.target), speaker: s.speaker, at: Date.now() };
|
|
2522
2671
|
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) });
|
|
2672
|
+
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
2673
|
if (a.briefTurnId)
|
|
2525
2674
|
closeBriefTurn(a);
|
|
2526
2675
|
openBriefTurn(a, q);
|
|
@@ -2585,7 +2734,7 @@ export const startMirror = (deps) => {
|
|
|
2585
2734
|
const queued = !isSlash && idleVerdict(a.target) === false;
|
|
2586
2735
|
pushSeal(a, seal);
|
|
2587
2736
|
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 });
|
|
2737
|
+
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
2738
|
else
|
|
2590
2739
|
activateDispatch(a, seal);
|
|
2591
2740
|
// 新消息 = 对话边界: 立刻收掉上一 turn, 新 turn 直接激活、不排队。收口语义见
|
|
@@ -2657,7 +2806,7 @@ export const startMirror = (deps) => {
|
|
|
2657
2806
|
const fresh = consumeFrom(a);
|
|
2658
2807
|
const opened = consumeChannel(a, fromCli, fresh);
|
|
2659
2808
|
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 } : {}) });
|
|
2809
|
+
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
2810
|
a.briefTurnId = turnId;
|
|
2662
2811
|
a.briefBubble = undefined;
|
|
2663
2812
|
a.briefIsSlash = false;
|
|
@@ -3164,7 +3313,7 @@ export const startMirror = (deps) => {
|
|
|
3164
3313
|
if (!a.keepaliveTurnId) {
|
|
3165
3314
|
const turnId = newTurnId();
|
|
3166
3315
|
a.keepaliveTurnId = turnId;
|
|
3167
|
-
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), userQuery: text });
|
|
3316
|
+
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, userQuery: text });
|
|
3168
3317
|
}
|
|
3169
3318
|
};
|
|
3170
3319
|
// A keepalive ping turn is swallowed wholesale — the reply content doesn't
|
|
@@ -3817,8 +3966,9 @@ export const startMirror = (deps) => {
|
|
|
3817
3966
|
// with the primary dialect would report a phantom drift for every
|
|
3818
3967
|
// session belonging to a non-primary CLI.
|
|
3819
3968
|
const paneDir = projectDirFor(jsonlAbs, expandHome(paneCwd));
|
|
3820
|
-
|
|
3821
|
-
|
|
3969
|
+
const codexPane = backendForPath(jsonlAbs).name === "codex" ? codexSessionForPane(livePane) : undefined;
|
|
3970
|
+
if (codexPane ? codexPane.sessionId !== rec.sessionId : backendForPath(jsonlAbs).name !== "codex" && paneDir !== dirname(jsonlAbs)) {
|
|
3971
|
+
const live = codexPane?.cwd === expandHome(paneCwd) ? codexPane : backendForPath(jsonlAbs).name === "codex" ? undefined : liveSessionForCwd(paneCwd, backendForPath(jsonlAbs), sidsClaimedByOthers(principal));
|
|
3822
3972
|
if (live && live.sessionId !== rec.sessionId) {
|
|
3823
3973
|
log.info({ principal, from: rec.sessionId, to: live.sessionId, paneCwd }, "mirror restore: pane in worktree, re-homed to live session");
|
|
3824
3974
|
rec.sessionId = live.sessionId;
|
|
@@ -4131,6 +4281,8 @@ export const startMirror = (deps) => {
|
|
|
4131
4281
|
// `exclusive` (the /clear flavor) refuses to claim on ambiguous evidence —
|
|
4132
4282
|
// see claimIsAttributable.
|
|
4133
4283
|
const startMigrationWatcher = (a, baseline, isChild, startOffset, exclusive) => {
|
|
4284
|
+
if (backendForPath(a.jsonlPath).name === "codex")
|
|
4285
|
+
return;
|
|
4134
4286
|
if (a.migrationWatcher)
|
|
4135
4287
|
a.migrationWatcher.cancel(); // re-armed (e.g. /clear twice, or respawn during a pending watch)
|
|
4136
4288
|
const projectDir = dirname(a.jsonlPath);
|
|
@@ -4230,6 +4382,8 @@ export const startMirror = (deps) => {
|
|
|
4230
4382
|
// narrows to transcripts born after the clear, which makes a short
|
|
4231
4383
|
// fingerprint safe enough to accept.
|
|
4232
4384
|
const armSilentForkRebind = (a, text) => {
|
|
4385
|
+
if (backendForPath(a.jsonlPath).name === "codex")
|
|
4386
|
+
return; // SessionStart owns exact pane identity.
|
|
4233
4387
|
const pendingClear = a.clearRebind;
|
|
4234
4388
|
if (pendingClear) {
|
|
4235
4389
|
// Hand over: the fingerprint is pane-certain, the dir-scan is not.
|
|
@@ -4375,8 +4529,16 @@ export const startMirror = (deps) => {
|
|
|
4375
4529
|
const cwd = paneCwd.get(a.tmuxPane);
|
|
4376
4530
|
if (!cwd)
|
|
4377
4531
|
continue; // pane gone
|
|
4532
|
+
if (backendForPath(a.jsonlPath).name === "codex") {
|
|
4533
|
+
const registered = codexSessionForPane(a.tmuxPane);
|
|
4534
|
+
if (registered && registered.cwd === expandHome(cwd) && registered.sessionId !== a.sessionId && !sidsClaimedByOthers(a.target).has(registered.sessionId)) {
|
|
4535
|
+
a.runningCwd = registered.cwd;
|
|
4536
|
+
migrateAttachment(a, registered.sessionId, registered.jsonlPath);
|
|
4537
|
+
}
|
|
4538
|
+
continue;
|
|
4539
|
+
}
|
|
4378
4540
|
const paneDir = projectDirFor(a.jsonlPath, expandHome(cwd));
|
|
4379
|
-
if (paneDir === dirname(a.jsonlPath))
|
|
4541
|
+
if (backendForPath(a.jsonlPath).name === "codex" ? expandHome(cwd) === a.runningCwd : paneDir === dirname(a.jsonlPath))
|
|
4380
4542
|
continue; // same project dir — no drift
|
|
4381
4543
|
const live = liveSessionForCwd(cwd, backendForPath(a.jsonlPath), sidsClaimedByOthers(a.target));
|
|
4382
4544
|
if (!live || live.sessionId === a.sessionId)
|
|
@@ -4468,14 +4630,11 @@ export const startMirror = (deps) => {
|
|
|
4468
4630
|
return undefined;
|
|
4469
4631
|
}
|
|
4470
4632
|
};
|
|
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
4633
|
/** 死 pane 重生 (`--resume` 同一个会话) 的模型: 绑定里记着的优先 —— set_model 换上的也记在那;
|
|
4477
4634
|
* 没记 (老绑定 / CLI 默认) 的管家补上管家档。 */
|
|
4478
|
-
const respawnSpec = (a) =>
|
|
4635
|
+
const respawnSpec = (a) => {
|
|
4636
|
+
return resumedStewardSpec(cfg, a.target, backendForPath(a.jsonlPath).name, a);
|
|
4637
|
+
};
|
|
4479
4638
|
const newSession = async (target, windowName, cli, opts) => {
|
|
4480
4639
|
const prev = byTarget.get(target);
|
|
4481
4640
|
// Resolution precedence (all chat-scoped except the running-cwd fallback):
|
|
@@ -4497,22 +4656,16 @@ export const startMirror = (deps) => {
|
|
|
4497
4656
|
(rec?.cwd?.trim()) ||
|
|
4498
4657
|
chat.running ||
|
|
4499
4658
|
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).
|
|
4659
|
+
// Fresh panes always use the configured backend, regardless of old bindings
|
|
4660
|
+
// or per-call / tier CLI values. Keep the old path only for model/effort reset.
|
|
4661
|
+
const boundPath = prev?.jsonlPath ?? rec?.jsonlPath;
|
|
4506
4662
|
const base = baseOfKey(target);
|
|
4507
|
-
const
|
|
4508
|
-
const boundPath = prev?.jsonlPath ?? rec?.jsonlPath ?? baseBound;
|
|
4509
|
-
const effCli = cli ?? (boundPath ? backendForPath(expandHome(boundPath)).name : undefined);
|
|
4663
|
+
const effCli = resolveCliBackend(cfg.wrc).name;
|
|
4510
4664
|
// 管家 (群的默认会话) 新起的会话按管家档起 (models.steward / chatPolicy), 档压过绑定里记下的
|
|
4511
4665
|
// effort —— `/new`、换目录、交接重开都是让改过的档位生效的那一下。点名了模型就整档让开。
|
|
4512
4666
|
// 已在跑的管家不动 (换模型会让缓存整份重读); 死 pane 重生见 respawnSpec。
|
|
4513
|
-
const
|
|
4514
|
-
const effModel =
|
|
4515
|
-
const effEffort = opts?.effort ?? steward?.effort ?? parseEffort(prev?.effort ?? deps.store.get(target)?.effort);
|
|
4667
|
+
const backendChanged = Boolean(boundPath && effCli && backendForPath(expandHome(boundPath)).name !== effCli);
|
|
4668
|
+
const { model: effModel, effort: effEffort } = newStewardSpec(cfg, target, effCli, opts, backendChanged ? undefined : prev?.effort ?? deps.store.get(target)?.effort);
|
|
4516
4669
|
if (prev?.tmuxPane) {
|
|
4517
4670
|
// Best-effort kill; ignore errors (pane may already be dead).
|
|
4518
4671
|
void runTmux(["kill-pane", "-t", prev.tmuxPane]);
|
|
@@ -4531,7 +4684,7 @@ export const startMirror = (deps) => {
|
|
|
4531
4684
|
cli: effCli,
|
|
4532
4685
|
model: effModel,
|
|
4533
4686
|
effort: effEffort,
|
|
4534
|
-
systemPrompt: opts?.systemPrompt ?? charterFor(target, { cwd: eff }),
|
|
4687
|
+
systemPrompt: opts?.systemPrompt ?? charterFor(target, { cwd: eff, cli: effCli }),
|
|
4535
4688
|
}).catch((e) => { inFlightSids.delete(freshSid); throw e; });
|
|
4536
4689
|
if (!r.ok) {
|
|
4537
4690
|
inFlightSids.delete(freshSid);
|
|
@@ -4695,14 +4848,14 @@ export const startMirror = (deps) => {
|
|
|
4695
4848
|
rememberInject(hello);
|
|
4696
4849
|
const boot = await inject({
|
|
4697
4850
|
text: hello, images: [], cfg, log: lg,
|
|
4698
|
-
sessionId: parent.sessionId, jsonlPath: parent.jsonlPath,
|
|
4851
|
+
sessionId: parentCli === "codex" ? r.sessionId : parent.sessionId, jsonlPath: parentCli === "codex" ? r.jsonlPath : parent.jsonlPath,
|
|
4699
4852
|
tmuxTarget: r.tmuxPane, freshSpawn: true,
|
|
4700
4853
|
});
|
|
4701
4854
|
if (!boot.ok) {
|
|
4702
4855
|
await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
|
|
4703
4856
|
return { ok: false, reason: `分身开场白注入失败: ${boot.reason ?? "unknown"}`, inherited: false };
|
|
4704
4857
|
}
|
|
4705
|
-
const fork = await awaitFork(projectDir, baseline, sidsClaimedByOthers(args.target), parent.jsonlPath, {
|
|
4858
|
+
const fork = parentCli === "codex" ? { sessionId: r.sessionId, jsonlPath: r.jsonlPath } : await awaitFork(projectDir, baseline, sidsClaimedByOthers(args.target), parent.jsonlPath, {
|
|
4706
4859
|
homeDir: backendForPath(parent.jsonlPath).homeDir, pane: r.tmuxPane ?? "", parentSid: parent.sessionId,
|
|
4707
4860
|
});
|
|
4708
4861
|
// 等不到分叉就必须收手: 此刻唯一已知的 sid 是父亲的, 认下去两个 target 会指向
|
|
@@ -4921,14 +5074,16 @@ export const startMirror = (deps) => {
|
|
|
4921
5074
|
// `daily#fix` 改名前的老写法 —— 名字取自 tag 迁移而来, 照旧认得。
|
|
4922
5075
|
const resolvePeerTag = (self, ref) => {
|
|
4923
5076
|
const raw = stripSigil(ref);
|
|
4924
|
-
|
|
5077
|
+
// 多机器人隔离: 别的机器人名下的 wizard 在这里与「不存在」同形 —— 不说它在、不进 candidates。
|
|
5078
|
+
// 单机器人时 sameBot 恒真, 这一行什么也不滤。
|
|
5079
|
+
const all = allTargets().filter((k) => sameBot(self, k));
|
|
4925
5080
|
const hit = (target) => ({ ok: true, target, foreign: baseOfKey(target) !== baseOfKey(self) });
|
|
4926
5081
|
if (!raw)
|
|
4927
5082
|
return hit(baseOfKey(self));
|
|
4928
5083
|
if (/^(user|chat|group):/.test(raw)) {
|
|
4929
5084
|
return all.includes(raw) ? hit(raw) : { ok: false, reason: `no running session '${raw}'` };
|
|
4930
5085
|
}
|
|
4931
|
-
const named = wizardStore()?.byName(raw)?.target;
|
|
5086
|
+
const named = ((t) => (t && sameBot(self, t) ? t : undefined))(wizardStore()?.byName(raw)?.target);
|
|
4932
5087
|
if (named && all.includes(named))
|
|
4933
5088
|
return hit(named);
|
|
4934
5089
|
if (named)
|
|
@@ -5121,7 +5276,7 @@ export const startMirror = (deps) => {
|
|
|
5121
5276
|
const foreignPeers = async (self) => {
|
|
5122
5277
|
const selfBase = baseOfKey(self);
|
|
5123
5278
|
// 只列活着的 / 有身份的 —— 冷绑定可能有几百个, 每个探活是两次 tmux。
|
|
5124
|
-
const foreign = allTargets().filter((k) => baseOfKey(k) !== selfBase && (byTarget.has(k) || !!wizardStore()?.get(k)));
|
|
5279
|
+
const foreign = allTargets().filter((k) => baseOfKey(k) !== selfBase && sameBot(self, k) && (byTarget.has(k) || !!wizardStore()?.get(k)));
|
|
5125
5280
|
return (await Promise.all(foreign.map((t) => peerInfoOf(t, self)))).sort(byRecent);
|
|
5126
5281
|
};
|
|
5127
5282
|
// peerInfoOf 每个 target 要两次 tmux (pane 存活 + capture tail)。跑了几个月的
|
|
@@ -5255,7 +5410,7 @@ export const startMirror = (deps) => {
|
|
|
5255
5410
|
if (!a.keepaliveTurnId) {
|
|
5256
5411
|
const turnId = newTurnId();
|
|
5257
5412
|
a.keepaliveTurnId = turnId;
|
|
5258
|
-
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), userQuery: text });
|
|
5413
|
+
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, userQuery: text });
|
|
5259
5414
|
}
|
|
5260
5415
|
};
|
|
5261
5416
|
let keepaliveTicking = false;
|
|
@@ -5376,10 +5531,10 @@ export const startMirror = (deps) => {
|
|
|
5376
5531
|
// 触发 / 根本不触发 (与 AskUserQuestion 的「本地面板先行」同源; 实测命中: 子代理
|
|
5377
5532
|
// 里经 ToolSearch→DeferExecuteTool 派发的 MCP 工具)。hook 链路对这类调用完全
|
|
5378
5533
|
// 失明 —— daemon 零日志、无卡可发, pane 就地阻塞。解法与 askq/plan 同源:
|
|
5379
|
-
// mirror 读屏兜底, 按三件套形状 (pickAutoAllowAnswer)
|
|
5380
|
-
// -
|
|
5381
|
-
// -
|
|
5382
|
-
// -
|
|
5534
|
+
// mirror 读屏兜底, 按三件套形状 (pickAutoAllowAnswer) 认权限确认框:
|
|
5535
|
+
// - 上下文完整且现有工具策略放行 → 只代按一次性裸 Yes;
|
|
5536
|
+
// - denyRules 命中 → 拒绝; 未知参数 / askRules / 强制审批不吃窗口;
|
|
5537
|
+
// - 其它 → 把框转成标准审批卡发到本 chat (runMirrorPickerFlow), 点击后代按
|
|
5383
5538
|
// 对应选项; 本地先按掉则 moot 作废卡。代按/发卡都发 chat 回执留痕。
|
|
5384
5539
|
// WEZARD_PICKER_SENTINEL=0 整体关闭。
|
|
5385
5540
|
let pickerTicking = false;
|
|
@@ -5393,6 +5548,10 @@ export const startMirror = (deps) => {
|
|
|
5393
5548
|
for (const a of byTarget.values()) {
|
|
5394
5549
|
if (!a.tmuxPane || a.migrationWatcher)
|
|
5395
5550
|
continue;
|
|
5551
|
+
// Codex PermissionRequest has its own independent native gate. Screen
|
|
5552
|
+
// options, tool windows and skipAll never authorize its sandbox.
|
|
5553
|
+
if (cliOf(a) === "codex")
|
|
5554
|
+
continue;
|
|
5396
5555
|
if (!(await tmuxPaneAlive(a.tmuxPane)))
|
|
5397
5556
|
continue;
|
|
5398
5557
|
const v = await detectModalPicker(a.tmuxPane);
|
|
@@ -5423,48 +5582,54 @@ export const startMirror = (deps) => {
|
|
|
5423
5582
|
continue;
|
|
5424
5583
|
}
|
|
5425
5584
|
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;
|
|
5585
|
+
const policy = pickerPolicy(cfg, cliOf(a), ctx, isAutoWindowActive(a.target));
|
|
5586
|
+
if (policy.decision === "leave") {
|
|
5587
|
+
log.info({ target: a.target, reason: policy.reason }, "picker sentinel: independent approval left on CLI");
|
|
5444
5588
|
}
|
|
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)
|
|
5589
|
+
await dispatchPickerPolicy(policy, {
|
|
5590
|
+
deny: async () => {
|
|
5591
|
+
const result = await pressNativePicker(a, modalFingerprint(v.screen), "no");
|
|
5592
|
+
log.info({ target: a.target, reason: policy.reason, result }, "picker sentinel: deny rule");
|
|
5593
|
+
sendStandalone(a, `[mirror] 🚫 ${policy.reason},${result.ok ? "已拒绝 CLI 确认" : "确认框已变更,请回 CLI 处理"}`);
|
|
5594
|
+
},
|
|
5595
|
+
allow: async () => {
|
|
5596
|
+
const after = await pressModalIndex(a.tmuxPane, v, pick.index);
|
|
5597
|
+
if (after.modal) {
|
|
5598
|
+
// 没按掉: 指纹换成当前屏 — pane 静止就不再重试, 免得往一个不认识的框里刷数字键。
|
|
5599
|
+
a.pickerSentinelFp = after.screen;
|
|
5600
|
+
log.warn({ target: a.target, title: after.title, pressed: pick.index }, "picker sentinel: modal still on pane after press");
|
|
5601
|
+
return;
|
|
5602
|
+
}
|
|
5466
5603
|
a.pickerSentinelFp = undefined;
|
|
5467
|
-
|
|
5604
|
+
log.info({ target: a.target, title: v.title, via: policy.reason, pressed: `${pick.index}. ${pick.label}` }, "picker sentinel: native permission picker auto-allowed");
|
|
5605
|
+
sendStandalone(a, `[mirror] 🔓 ${policy.reason} 代按了 CLI 原生权限确认${v.title ? `「${v.title}」` : ""} → ${pick.index}. ${pick.label}`);
|
|
5606
|
+
},
|
|
5607
|
+
manual: () => {
|
|
5608
|
+
// 卡片路径: fire-and-forget — 长轮询在 flow 里, 哨兵循环不能被一张卡挂住;
|
|
5609
|
+
// 防重靠 hasMirrorPicker 槽位 (含代按窗口期, 见 flow 内 finally)。
|
|
5610
|
+
log.info({ target: a.target, toolName: ctx.toolName, agent: ctx.agent, reason: policy.reason }, "picker sentinel: forwarding native picker as approval card");
|
|
5611
|
+
runMirrorPickerFlow({
|
|
5612
|
+
log: log.child({ target: a.target, sessionId: a.sessionId, sub: "picker-card" }),
|
|
5613
|
+
client,
|
|
5614
|
+
cfg,
|
|
5615
|
+
sessionId: a.sessionId,
|
|
5616
|
+
chatKey: a.target,
|
|
5617
|
+
cwd: a.runningCwd,
|
|
5618
|
+
jsonlPath: a.jsonlPath,
|
|
5619
|
+
toolName: ctx.toolName,
|
|
5620
|
+
toolInput: ctx.toolInput,
|
|
5621
|
+
agent: ctx.agent,
|
|
5622
|
+
danger: policy.forceSingle,
|
|
5623
|
+
voteTimeoutMs: cfg.approval.longPollSec * 1000,
|
|
5624
|
+
press: (what) => pressNativePicker(a, modalFingerprint(v.screen), what),
|
|
5625
|
+
}).then((sent) => {
|
|
5626
|
+
// 卡没发出去 (WS 断/发送失败): 清指纹, 让下一 tick 对同屏重试发卡,
|
|
5627
|
+
// 否则这只框会被指纹永久静默。
|
|
5628
|
+
if (!sent)
|
|
5629
|
+
a.pickerSentinelFp = undefined;
|
|
5630
|
+
}).catch((e) => log.warn({ err: e.message, target: a.target }, "mirror picker flow failed"));
|
|
5631
|
+
},
|
|
5632
|
+
});
|
|
5468
5633
|
}
|
|
5469
5634
|
}
|
|
5470
5635
|
catch (e) {
|
|
@@ -5478,15 +5643,19 @@ export const startMirror = (deps) => {
|
|
|
5478
5643
|
// 审批卡点击后的代按回调。点击与本地按键之间隔着人的反应时间, 必须重读屏并
|
|
5479
5644
|
// 校验仍是**同一个调用**的确认框 —— 期间本地按掉旧框又弹了新框的话, 盲按就是
|
|
5480
5645
|
// 替用户批准了他没看过的东西。
|
|
5481
|
-
const pressNativePicker = async (a,
|
|
5646
|
+
const pressNativePicker = async (a, expectFingerprint, what) => {
|
|
5482
5647
|
if (!a.tmuxPane || !(await tmuxPaneAlive(a.tmuxPane)))
|
|
5483
5648
|
return { ok: false, reason: "pane 已不在" };
|
|
5649
|
+
if (cliOf(a) === "codex")
|
|
5650
|
+
return { ok: false, reason: "Codex 原生沙箱审批必须单独处理" };
|
|
5484
5651
|
const v = await detectModalPicker(a.tmuxPane);
|
|
5485
5652
|
if (!v.modal)
|
|
5486
5653
|
return { ok: false, reason: "确认框已不在(可能已在本地处理)" };
|
|
5487
|
-
|
|
5488
|
-
|
|
5489
|
-
|
|
5654
|
+
if (pickerPolicy(cfg, cliOf(a), parseConfirmContext(v.screen), false).decision === "leave") {
|
|
5655
|
+
return { ok: false, reason: "独立原生或交互审批,请在 CLI 中处理" };
|
|
5656
|
+
}
|
|
5657
|
+
if (modalFingerprint(v.screen) !== expectFingerprint) {
|
|
5658
|
+
return { ok: false, reason: "当前确认框的操作或参数已变更,不代按" };
|
|
5490
5659
|
}
|
|
5491
5660
|
if (what === "no") {
|
|
5492
5661
|
await runTmux(["send-keys", "-t", a.tmuxPane, "Escape"]);
|
|
@@ -5523,6 +5692,8 @@ export const startMirror = (deps) => {
|
|
|
5523
5692
|
const a = byTarget.get(target);
|
|
5524
5693
|
if (!a?.tmuxPane || !(await tmuxPaneAlive(a.tmuxPane)))
|
|
5525
5694
|
return { ok: false, reason: "它没有活着的 pane —— 模型是在 pane 里切的, 先叫醒它" };
|
|
5695
|
+
if (backendForPath(a.jsonlPath).name === "codex")
|
|
5696
|
+
return { ok: false, reason: "Codex 运行中的模型请在终端 /model 切换;新开会话可指定 model" };
|
|
5526
5697
|
const r = await selectModel(a.tmuxPane, wanted, log.child({ sub: "set-model", target }), scope);
|
|
5527
5698
|
if (r.ok && r.applied) {
|
|
5528
5699
|
a.model = r.applied;
|
|
@@ -5536,6 +5707,8 @@ export const startMirror = (deps) => {
|
|
|
5536
5707
|
const a = byTarget.get(target);
|
|
5537
5708
|
if (!a?.tmuxPane || !(await tmuxPaneAlive(a.tmuxPane)))
|
|
5538
5709
|
return { ok: false, reason: "它没有活着的 pane —— 档位是在 pane 里切的, 先叫醒它" };
|
|
5710
|
+
if (backendForPath(a.jsonlPath).name === "codex")
|
|
5711
|
+
return { ok: false, reason: "Codex 运行中的推理强度请在终端 /model 切换;新开会话可指定 effort" };
|
|
5539
5712
|
const r = await selectEffort(a.tmuxPane, effort, log.child({ sub: "set-effort", target }));
|
|
5540
5713
|
if (r.ok && r.applied) {
|
|
5541
5714
|
a.effort = r.applied;
|
|
@@ -5761,7 +5934,7 @@ export const startMirror = (deps) => {
|
|
|
5761
5934
|
// Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
|
|
5762
5935
|
// onto the forked jsonl once it appears (EOF offset — fork is seeded).
|
|
5763
5936
|
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 }) });
|
|
5937
|
+
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
5938
|
if (!r.ok || !r.tmuxPane)
|
|
5766
5939
|
return { ok: false, reason: `respawn failed: ${r.reason ?? "unknown"}` };
|
|
5767
5940
|
a.tmuxPane = r.tmuxPane;
|
|
@@ -5892,7 +6065,7 @@ export const startMirror = (deps) => {
|
|
|
5892
6065
|
// allTargets 是几百个 key, 见 target 就问 tmux 会打穿 fd (同 worldPeers 的
|
|
5893
6066
|
// WORLD_PROBE 取舍)。
|
|
5894
6067
|
const orphans = [];
|
|
5895
|
-
const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}\t#{window_active}:#{session_attached}\t#{session_name}\t#{
|
|
6068
|
+
const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}\t#{window_active}:#{session_attached}\t#{session_name}\t#{pane_pid}"]);
|
|
5896
6069
|
if (snap.code !== 0)
|
|
5897
6070
|
return { reaped, orphans, skipped };
|
|
5898
6071
|
const rows = snap.stdout.split("\n").filter(Boolean).map((l) => l.split("\t"));
|
|
@@ -5918,15 +6091,38 @@ export const startMirror = (deps) => {
|
|
|
5918
6091
|
// 丢了 pane id (restore 认不出 → 下一条消息另起新 pane, 旧 pane 不杀) 之后留下的。
|
|
5919
6092
|
// 没有地址能再叫到它, 却不在下面 `live` 的账上, 攒多少都触不到上限; 所以不占
|
|
5920
6093
|
// 名额、不等超限, 见到就收。刚起的 pane 在 attach 之前也长这样 (并发 spawn),
|
|
5921
|
-
// 用 window_activity 留一段宽限。只收会话 pane:
|
|
5922
|
-
//
|
|
5923
|
-
//
|
|
6094
|
+
// 用 window_activity 留一段宽限。只收会话 pane: pane 的进程树里有注册表登记且
|
|
6095
|
+
// 还活着的 CLI 进程才算 (pane_pid 是 shell, CLI 是它的子孙, 所以沿 ps 快照往下
|
|
6096
|
+
// 找)。不看注册表的 `tmux` 字段 —— codebuddy 的注册表没有它, 那条路对 codebuddy
|
|
6097
|
+
// 全盲。普通 pane (人自己开的 shell / ssh / 编辑器, 会话已退回 shell 的) 树里
|
|
6098
|
+
// 没有注册表 pid, 一律跳过 —— 认不出就不动。
|
|
5924
6099
|
const bound = new Set(targets.map(paneOf).filter(Boolean));
|
|
5925
|
-
const
|
|
5926
|
-
|
|
5927
|
-
|
|
6100
|
+
const regPids = new Set(activeBackends().flatMap((b) => [...liveSessionPids(b.homeDir)]));
|
|
6101
|
+
const ps = await psSnap();
|
|
6102
|
+
// 沿进程树找注册表 pid; 找到返回那个 pid (它的 etimes 供宽限判), 找不到返回 0。
|
|
6103
|
+
const hostedBy = (pid) => {
|
|
6104
|
+
const seen = new Set();
|
|
6105
|
+
const stack = [pid];
|
|
6106
|
+
while (stack.length) {
|
|
6107
|
+
const p = stack.pop() ?? 0;
|
|
6108
|
+
if (regPids.has(p))
|
|
6109
|
+
return p;
|
|
6110
|
+
if (seen.has(p))
|
|
6111
|
+
continue;
|
|
6112
|
+
seen.add(p);
|
|
6113
|
+
for (const k of ps.kids.get(p) ?? [])
|
|
6114
|
+
stack.push(k);
|
|
6115
|
+
}
|
|
6116
|
+
return 0;
|
|
6117
|
+
};
|
|
6118
|
+
for (const [pane = "", , session, pid] of rows) {
|
|
6119
|
+
if (session !== cfg.wrc.tmuxPrefix || bound.has(pane))
|
|
6120
|
+
continue;
|
|
6121
|
+
const cliPid = hostedBy(Number(pid) || 0);
|
|
6122
|
+
if (!cliPid)
|
|
5928
6123
|
continue;
|
|
5929
|
-
|
|
6124
|
+
// 宽限按 CLI 进程年龄, 不按 window_activity: TUI 重绘会让它永远「刚动过」。
|
|
6125
|
+
if ((ps.etimes.get(cliPid) ?? Number.POSITIVE_INFINITY) * 1000 < ORPHAN_GRACE_MS)
|
|
5930
6126
|
continue;
|
|
5931
6127
|
const why = await putDown(pane);
|
|
5932
6128
|
if (why) {
|
|
@@ -5934,7 +6130,7 @@ export const startMirror = (deps) => {
|
|
|
5934
6130
|
continue;
|
|
5935
6131
|
}
|
|
5936
6132
|
orphans.push(pane);
|
|
5937
|
-
log.info({ pane,
|
|
6133
|
+
log.info({ pane, cliPid, cliAgeHours: Math.round((ps.etimes.get(cliPid) ?? 0) / 3600) }, "mirror pane cap — orphan pane killed (no binding)");
|
|
5938
6134
|
}
|
|
5939
6135
|
const live = targets.filter((t) => watchedBy.has(paneOf(t)));
|
|
5940
6136
|
let over = live.length - max;
|
|
@@ -6029,6 +6225,8 @@ export const startMirror = (deps) => {
|
|
|
6029
6225
|
hasLivePane: (sessionId) => Boolean(bySessionId.get(sessionId)?.tmuxPane),
|
|
6030
6226
|
answerNativeModal: async (sessionId, opts) => {
|
|
6031
6227
|
const a = bySessionId.get(sessionId);
|
|
6228
|
+
if (a && cliOf(a) === "codex")
|
|
6229
|
+
return { status: "no_modal", reason: "Codex native approval is independent" };
|
|
6032
6230
|
if (!a?.tmuxPane)
|
|
6033
6231
|
return { status: "no_pane", reason: "no live tmux pane for session" };
|
|
6034
6232
|
if (!(await tmuxPaneAlive(a.tmuxPane)))
|
|
@@ -6046,6 +6244,9 @@ export const startMirror = (deps) => {
|
|
|
6046
6244
|
// 没等到: 这次 CC 没弹框(会话内已授权过 / 命中盲点判断失误)。无事可做。
|
|
6047
6245
|
if (!v.modal)
|
|
6048
6246
|
return { status: "no_modal" };
|
|
6247
|
+
if (pickerPolicy(cfg, cliOf(a), parseConfirmContext(v.screen), false).decision === "leave") {
|
|
6248
|
+
return { status: "no_modal", reason: "independent native approval left on CLI" };
|
|
6249
|
+
}
|
|
6049
6250
|
const pick = pickModalAnswer(parseModalOptions(v.screen), v.title);
|
|
6050
6251
|
if (!pick) {
|
|
6051
6252
|
lg.warn({ title: v.title }, "native modal: no trustworthy one-shot option, not pressing");
|
|
@@ -6086,6 +6287,12 @@ export const startMirror = (deps) => {
|
|
|
6086
6287
|
bySessionId.clear();
|
|
6087
6288
|
byTarget.clear();
|
|
6088
6289
|
},
|
|
6290
|
+
currentAsker: (target) => {
|
|
6291
|
+
const a = byTarget.get(target);
|
|
6292
|
+
if (a?.convFrom)
|
|
6293
|
+
return a.convFrom.asker;
|
|
6294
|
+
return a?.speaker ? { who: a.speaker, chat: a.channel ?? baseOfKey(target) } : undefined;
|
|
6295
|
+
},
|
|
6089
6296
|
currentChannel: (target) => {
|
|
6090
6297
|
const a = byTarget.get(target);
|
|
6091
6298
|
return a?.channel ?? baseOfKey(target);
|
|
@@ -6268,7 +6475,7 @@ export const startMirror = (deps) => {
|
|
|
6268
6475
|
const resumeBaseline = !armMigration ? listJsonls(dirname(a.jsonlPath)) : undefined;
|
|
6269
6476
|
// Respawn in the binding's runningCwd (pendingCwd doesn't apply to a
|
|
6270
6477
|
// 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 }) });
|
|
6478
|
+
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
6479
|
if (r.ok && r.tmuxPane && r.tmuxSession) {
|
|
6273
6480
|
a.tmuxPane = r.tmuxPane;
|
|
6274
6481
|
a.tmuxSession = r.tmuxSession;
|