deskbuddy 0.2.0-beta.21 → 0.2.0-beta.22
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/dist/cli.js +191 -30
- package/dist/opencode.mjs +343 -2
- package/hook/hook-debug.log +10 -0
- package/hook/opencode.d.mts +21 -1
- package/hook/opencode.mjs +343 -2
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -13345,11 +13345,11 @@ var REVOKE_RETRIES = 3;
|
|
|
13345
13345
|
var REVOKE_RETRY_MS = 50;
|
|
13346
13346
|
var SID_RE3 = /^[A-Za-z0-9_-]{1,128}$/;
|
|
13347
13347
|
var IID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
13348
|
-
function dshMailboxDir(home = deskbuddyHome()) {
|
|
13349
|
-
return join40(home,
|
|
13348
|
+
function dshMailboxDir(home = deskbuddyHome(), channel = "dsh") {
|
|
13349
|
+
return join40(home, channel);
|
|
13350
13350
|
}
|
|
13351
|
-
function sub(home, name) {
|
|
13352
|
-
return join40(dshMailboxDir(home), name);
|
|
13351
|
+
function sub(home, name, channel = "dsh") {
|
|
13352
|
+
return join40(dshMailboxDir(home, channel), name);
|
|
13353
13353
|
}
|
|
13354
13354
|
function ensureDir2(p) {
|
|
13355
13355
|
mkdirSync28(p, { recursive: true, mode: 448 });
|
|
@@ -13372,22 +13372,24 @@ var defaultPidAlive = (pid) => {
|
|
|
13372
13372
|
}
|
|
13373
13373
|
};
|
|
13374
13374
|
function deps(d) {
|
|
13375
|
-
return { home: d.home ?? deskbuddyHome(), now: d.now ?? Date.now, pidAlive: d.pidAlive ?? defaultPidAlive };
|
|
13375
|
+
return { home: d.home ?? deskbuddyHome(), now: d.now ?? Date.now, pidAlive: d.pidAlive ?? defaultPidAlive, channel: d.channel ?? "dsh" };
|
|
13376
13376
|
}
|
|
13377
|
-
function
|
|
13378
|
-
if (!SID_RE3.test(sessionId)) return
|
|
13377
|
+
function liveHeartbeats(sessionId, d0 = {}) {
|
|
13378
|
+
if (!SID_RE3.test(sessionId)) return [];
|
|
13379
13379
|
const d = deps(d0);
|
|
13380
|
+
const dir = sub(d.home, "alive", d.channel);
|
|
13380
13381
|
let names;
|
|
13381
13382
|
try {
|
|
13382
|
-
names = readdirSync11(
|
|
13383
|
+
names = readdirSync11(dir);
|
|
13383
13384
|
} catch {
|
|
13384
|
-
return
|
|
13385
|
+
return [];
|
|
13385
13386
|
}
|
|
13387
|
+
const out = [];
|
|
13386
13388
|
for (const n of names) {
|
|
13387
13389
|
if (!/^\d+\.json$/.test(n)) continue;
|
|
13388
13390
|
let hb;
|
|
13389
13391
|
try {
|
|
13390
|
-
hb = JSON.parse(readFileSync29(join40(
|
|
13392
|
+
hb = JSON.parse(readFileSync29(join40(dir, n), "utf8"));
|
|
13391
13393
|
} catch {
|
|
13392
13394
|
continue;
|
|
13393
13395
|
}
|
|
@@ -13395,23 +13397,27 @@ function dshLiveFor(sessionId, d0 = {}) {
|
|
|
13395
13397
|
if (d.now() - hb.at > DSH_HEARTBEAT_FRESH_MS) continue;
|
|
13396
13398
|
if (!hb.sessions.includes(sessionId)) continue;
|
|
13397
13399
|
if (!d.pidAlive(hb.pid)) continue;
|
|
13398
|
-
|
|
13400
|
+
out.push(hb);
|
|
13399
13401
|
}
|
|
13400
|
-
return
|
|
13402
|
+
return out;
|
|
13403
|
+
}
|
|
13404
|
+
function dshLiveFor(sessionId, d0 = {}) {
|
|
13405
|
+
return liveHeartbeats(sessionId, d0)[0] ?? null;
|
|
13401
13406
|
}
|
|
13402
13407
|
async function dshDeliver(sessionId, msg, d0 = {}) {
|
|
13403
13408
|
const d = deps(d0);
|
|
13404
13409
|
if (!SID_RE3.test(sessionId) || !IID_RE.test(msg.instructionId)) return { ok: false, reason: "bad_id" };
|
|
13405
|
-
|
|
13406
|
-
|
|
13407
|
-
const
|
|
13410
|
+
const hb = (d0.pick ?? dshLiveFor)(sessionId, d);
|
|
13411
|
+
if (!hb) return { ok: false, reason: "not_live" };
|
|
13412
|
+
const inbox = sub(d.home, "inbox", d.channel);
|
|
13413
|
+
const ackDir = sub(d.home, "ack", d.channel);
|
|
13408
13414
|
ensureDir2(inbox);
|
|
13409
13415
|
ensureDir2(ackDir);
|
|
13410
13416
|
const letter = join40(inbox, `${sessionId}.${msg.instructionId}.json`);
|
|
13411
13417
|
const ackPath = join40(ackDir, `${msg.instructionId}.json`);
|
|
13412
13418
|
if (existsSync24(letter)) return { ok: false, reason: "inbox_busy" };
|
|
13413
13419
|
const writtenAt = d.now();
|
|
13414
|
-
writeJson(letter, { instructionId: msg.instructionId, text: msg.text, at: writtenAt });
|
|
13420
|
+
writeJson(letter, { instructionId: msg.instructionId, text: msg.text, at: writtenAt, ...d0.addressed ? { pid: hb.pid } : {} });
|
|
13415
13421
|
const sleep3 = d0.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
13416
13422
|
let deadline = writtenAt + (d0.timeoutMs ?? DSH_ACK_TIMEOUT_MS);
|
|
13417
13423
|
let claimed = false;
|
|
@@ -13465,8 +13471,13 @@ async function revoke(letter, sleep3) {
|
|
|
13465
13471
|
}
|
|
13466
13472
|
return existsSync24(letter) ? "failed" : "claimed";
|
|
13467
13473
|
}
|
|
13468
|
-
function dshClearInbox(home = deskbuddyHome()) {
|
|
13469
|
-
|
|
13474
|
+
function dshClearInbox(home = deskbuddyHome(), channel = "dsh") {
|
|
13475
|
+
return clearDir(sub(home, "inbox", channel));
|
|
13476
|
+
}
|
|
13477
|
+
function dshClearAcks(home = deskbuddyHome(), channel = "dsh") {
|
|
13478
|
+
return clearDir(sub(home, "ack", channel));
|
|
13479
|
+
}
|
|
13480
|
+
function clearDir(inbox) {
|
|
13470
13481
|
let names;
|
|
13471
13482
|
try {
|
|
13472
13483
|
names = readdirSync11(inbox);
|
|
@@ -13484,6 +13495,29 @@ function dshClearInbox(home = deskbuddyHome()) {
|
|
|
13484
13495
|
return n;
|
|
13485
13496
|
}
|
|
13486
13497
|
|
|
13498
|
+
// src/core/ocMailbox.ts
|
|
13499
|
+
var OC_CHANNEL = "opencode";
|
|
13500
|
+
function pickOcHeartbeat(hbs, sessionId) {
|
|
13501
|
+
if (hbs.length === 0) return null;
|
|
13502
|
+
const rank = (hb) => {
|
|
13503
|
+
const i = Array.isArray(hb.active) ? hb.active.indexOf(sessionId) : -1;
|
|
13504
|
+
return i < 0 ? Number.MAX_SAFE_INTEGER : i;
|
|
13505
|
+
};
|
|
13506
|
+
return [...hbs].sort((a, b) => rank(a) - rank(b) || b.at - a.at || a.pid - b.pid)[0];
|
|
13507
|
+
}
|
|
13508
|
+
function ocLiveFor(sessionId, d0 = {}) {
|
|
13509
|
+
return pickOcHeartbeat(liveHeartbeats(sessionId, { ...d0, channel: OC_CHANNEL }), sessionId);
|
|
13510
|
+
}
|
|
13511
|
+
function ocDeliver(sessionId, msg, d0 = {}) {
|
|
13512
|
+
return dshDeliver(sessionId, msg, { ...d0, channel: OC_CHANNEL, addressed: true, pick: (sid, d) => ocLiveFor(sid, d) });
|
|
13513
|
+
}
|
|
13514
|
+
function ocClearInbox(home = deskbuddyHome()) {
|
|
13515
|
+
return dshClearInbox(home, OC_CHANNEL);
|
|
13516
|
+
}
|
|
13517
|
+
function ocClearAcks(home = deskbuddyHome()) {
|
|
13518
|
+
return dshClearAcks(home, OC_CHANNEL);
|
|
13519
|
+
}
|
|
13520
|
+
|
|
13487
13521
|
// src/daemon/index.ts
|
|
13488
13522
|
var UPDATING_REASON = "updating";
|
|
13489
13523
|
var AGENT_NOT_LOGGED_IN = "agent_not_logged_in";
|
|
@@ -13652,6 +13686,8 @@ var Daemon = class {
|
|
|
13652
13686
|
qoderAppSync: deps2.qoderAppSync ?? ((job) => syncQoderApp(job)),
|
|
13653
13687
|
dshLive: deps2.dshLive ?? ((sid) => dshLiveFor(sid) != null),
|
|
13654
13688
|
dshDeliver: deps2.dshDeliver ?? ((sid, msg) => dshDeliver(sid, msg)),
|
|
13689
|
+
ocLive: deps2.ocLive ?? ((sid) => ocLiveFor(sid) != null),
|
|
13690
|
+
ocDeliver: deps2.ocDeliver ?? ((sid, msg) => ocDeliver(sid, msg)),
|
|
13655
13691
|
loginGuide: deps2.loginGuide ?? defaultLoginGuide(log, deps2.now ?? (() => Date.now()), () => {
|
|
13656
13692
|
const offer = defaultWorkbuddyOffer(log);
|
|
13657
13693
|
if (offer) void offer.maybeAsk("runtime").catch(() => {
|
|
@@ -14267,6 +14303,18 @@ var Daemon = class {
|
|
|
14267
14303
|
return;
|
|
14268
14304
|
}
|
|
14269
14305
|
}
|
|
14306
|
+
if (cmd.tool === "opencode" && !this.#queue.busy(sid)) {
|
|
14307
|
+
let live = false;
|
|
14308
|
+
try {
|
|
14309
|
+
live = this.#deps.ocLive(sid);
|
|
14310
|
+
} catch {
|
|
14311
|
+
live = false;
|
|
14312
|
+
}
|
|
14313
|
+
if (live) {
|
|
14314
|
+
void this.#track(this.#toOcWindow(cmd, sid));
|
|
14315
|
+
return;
|
|
14316
|
+
}
|
|
14317
|
+
}
|
|
14270
14318
|
}
|
|
14271
14319
|
this.#afterPty(cmd);
|
|
14272
14320
|
}
|
|
@@ -14568,6 +14616,32 @@ var Daemon = class {
|
|
|
14568
14616
|
const gone = ack.reason === "not_live" || ack.reason === "stale";
|
|
14569
14617
|
this.#report(cmd, sid, gone ? "rejected" : "failed", "window", gone ? "session_gone" : "window_failed");
|
|
14570
14618
|
}
|
|
14619
|
+
/**
|
|
14620
|
+
* 经插件信箱往开着这个会话的 opencode 进程里发一句话(spec 2026-10-08)。回报同 dsh:空闲 started / 正忙 queued(mode=window)。
|
|
14621
|
+
* 确定没发出去(not_live:心跳刚失效 / 进程里没这个会话;no_ack:没人认领、信已收回)→ 退回原来的后台续跑。
|
|
14622
|
+
* 🔴 其余失败(认领后没回执、promptAsync 出错 / 超时)不退回:可能已经发出去了,再跑一遍 = 执行两次
|
|
14623
|
+
*/
|
|
14624
|
+
async #toOcWindow(cmd, sid) {
|
|
14625
|
+
let ack;
|
|
14626
|
+
try {
|
|
14627
|
+
ack = await this.#deps.ocDeliver(sid, { instructionId: cmd.instructionId, text: cmd.text });
|
|
14628
|
+
} catch (e) {
|
|
14629
|
+
log(`\u6307\u4EE4 ${cmd.instructionId} \u6295\u9012\u7ED9 opencode \u51FA\u9519\uFF1A${e.message}`);
|
|
14630
|
+
ack = { ok: false, reason: "deliver_error" };
|
|
14631
|
+
}
|
|
14632
|
+
if (ack.ok) {
|
|
14633
|
+
log(`\u6307\u4EE4 ${cmd.instructionId} \u5DF2\u7ECF\u63D2\u4EF6\u53D1\u8FDB opencode \u4F1A\u8BDD ${sid.slice(0, 12)}${ack.busy ? "\uFF08\u6B63\u5FD9\uFF0C\u6392\u8FDB\u4E0B\u4E00\u8F6E\uFF09" : ""} len=${cmd.text.length}`);
|
|
14634
|
+
this.#report(cmd, sid, ack.busy ? "queued" : "started", "window");
|
|
14635
|
+
return;
|
|
14636
|
+
}
|
|
14637
|
+
if (ack.reason === "not_live" || ack.reason === "no_ack") {
|
|
14638
|
+
log(`\u6307\u4EE4 ${cmd.instructionId} opencode \u7A97\u53E3\u6CA1\u63A5\uFF08${ack.reason}\uFF09\uFF0C\u6539\u8D70\u540E\u53F0\u7EED\u8DD1`);
|
|
14639
|
+
this.#afterPty(cmd);
|
|
14640
|
+
return;
|
|
14641
|
+
}
|
|
14642
|
+
log(`\u6307\u4EE4 ${cmd.instructionId} \u6CA1\u80FD\u53D1\u8FDB opencode \u4F1A\u8BDD\uFF08${ack.reason ?? "\u672A\u77E5"}\uFF09`);
|
|
14643
|
+
this.#report(cmd, sid, "failed", "window", "window_failed");
|
|
14644
|
+
}
|
|
14571
14645
|
/** 回报一条指令的处理结果。2 秒超时、失败不重试,只记日志(seen 去重命中的不会走到这里) */
|
|
14572
14646
|
#report(cmd, sessionId, status, mode, reason = "") {
|
|
14573
14647
|
const payload = {
|
|
@@ -14991,6 +15065,10 @@ async function runDaemon(deps2 = {}) {
|
|
|
14991
15065
|
try {
|
|
14992
15066
|
const n = dshClearInbox();
|
|
14993
15067
|
if (n > 0) log(`\u6E05\u6389 ${n} \u5C01\u4E0A\u6B21\u9057\u7559\u7684 dsh \u4FE1`);
|
|
15068
|
+
const m = ocClearInbox();
|
|
15069
|
+
if (m > 0) log(`\u6E05\u6389 ${m} \u5C01\u4E0A\u6B21\u9057\u7559\u7684 opencode \u4FE1`);
|
|
15070
|
+
dshClearAcks();
|
|
15071
|
+
ocClearAcks();
|
|
14994
15072
|
} catch {
|
|
14995
15073
|
}
|
|
14996
15074
|
await d.startGate();
|
|
@@ -15879,6 +15957,15 @@ var Injector = class {
|
|
|
15879
15957
|
job.reply(job.finalMsg);
|
|
15880
15958
|
return;
|
|
15881
15959
|
}
|
|
15960
|
+
const unsafe = this.#d.rejectText?.(job.text) ?? null;
|
|
15961
|
+
if (unsafe) {
|
|
15962
|
+
this.#remember(job);
|
|
15963
|
+
job.done = true;
|
|
15964
|
+
this.#d.log?.(`\u4E0D\u5199 ${job.instructionId}\uFF1A${unsafe}`);
|
|
15965
|
+
job.finalMsg = { instructionId: job.instructionId, status: "rejected", mode: "window", reason: unsafe };
|
|
15966
|
+
job.reply(job.finalMsg);
|
|
15967
|
+
return;
|
|
15968
|
+
}
|
|
15882
15969
|
if (!this.#owns(job.sessionId)) {
|
|
15883
15970
|
this.#remember(job);
|
|
15884
15971
|
job.done = true;
|
|
@@ -15910,6 +15997,8 @@ var Injector = class {
|
|
|
15910
15997
|
this.#sawTrust = true;
|
|
15911
15998
|
}
|
|
15912
15999
|
if (v.permission) why.push("permission");
|
|
16000
|
+
if (v.busy) why.push("generating");
|
|
16001
|
+
if (v.dialog) why.push("dialog:" + v.dialog);
|
|
15913
16002
|
const pristine = this.#d.startupIdle === true && !this.#userKeyed && !this.#injectedOnce && !last;
|
|
15914
16003
|
if (v.input === null) why.push("no-input-box");
|
|
15915
16004
|
else if (v.input !== "" && !v.placeholder && !pristine) why.push("user-typing");
|
|
@@ -15980,8 +16069,9 @@ var Injector = class {
|
|
|
15980
16069
|
await this.#d.sleep(PASTE_SETTLE_MS);
|
|
15981
16070
|
if (job.done) return;
|
|
15982
16071
|
const v = this.#d.view();
|
|
15983
|
-
|
|
15984
|
-
|
|
16072
|
+
const blocked = !!v && (!!v.dialog || v.busy === true || v.trust === true);
|
|
16073
|
+
if (!v || blocked || !boxMatches(v, body)) {
|
|
16074
|
+
this.#d.log?.(blocked ? `\u7C98\u8D34\u540E\u5C4F\u5E55\u4E0D\u53EF\u5199\uFF08${v?.dialog ? "dialog:" + v.dialog : v?.busy ? "generating" : "trust-dialog"}\uFF09\uFF0C\u653E\u5F03\u8FD9\u6761` : "\u7C98\u8D34\u540E\u8F93\u5165\u6846\u5BF9\u4E0D\u4E0A\uFF0C\u653E\u5F03\u8FD9\u6761");
|
|
15985
16075
|
this.#final(job, { status: "failed", mode: "window", reason: "window_failed" });
|
|
15986
16076
|
return;
|
|
15987
16077
|
}
|
|
@@ -16174,6 +16264,61 @@ function runDirect(real2, args, env = process.env) {
|
|
|
16174
16264
|
});
|
|
16175
16265
|
}
|
|
16176
16266
|
|
|
16267
|
+
// src/wrap/screenRules.ts
|
|
16268
|
+
var Q_RULE_RE = /^─{20,}$/;
|
|
16269
|
+
var Q_TRUST_RE = /Do you trust the files in this folder\?/;
|
|
16270
|
+
var Q_PERMISSION_RE = /Permission Required|Apply this change\?|\d\. Allow once|\d\. Allow for this session/;
|
|
16271
|
+
var Q_LOGIN_RE = /Sign in to continue|Login with QoderCN|How would you like to authenticate/;
|
|
16272
|
+
var Q_BUSY_RE = /\(esc to cancel\b/;
|
|
16273
|
+
var Q_PLACEHOLDER_RE = /^Type your message or @\S*$/;
|
|
16274
|
+
var Q_FOOTER_RE = /· ctx\b/;
|
|
16275
|
+
function bottomRegion(lines3, rules) {
|
|
16276
|
+
const anchor = rules.length >= 3 ? rules[rules.length - 3] : rules[rules.length - 1];
|
|
16277
|
+
return lines3.slice(Math.max(0, anchor - 1)).join("\n");
|
|
16278
|
+
}
|
|
16279
|
+
function qoderCnRule(lines3) {
|
|
16280
|
+
const rules = [];
|
|
16281
|
+
lines3.forEach((l, i) => {
|
|
16282
|
+
if (Q_RULE_RE.test(l.trim())) rules.push(i);
|
|
16283
|
+
});
|
|
16284
|
+
if (rules.length === 0) return { permission: false, input: null };
|
|
16285
|
+
const text = bottomRegion(lines3, rules);
|
|
16286
|
+
if (Q_TRUST_RE.test(text)) return { permission: false, input: null, trust: true };
|
|
16287
|
+
if (Q_LOGIN_RE.test(text)) return { permission: false, input: null, dialog: "login" };
|
|
16288
|
+
const permission = Q_PERMISSION_RE.test(text);
|
|
16289
|
+
let input = null;
|
|
16290
|
+
if (rules.length >= 2) {
|
|
16291
|
+
const a = rules[rules.length - 2];
|
|
16292
|
+
const b = rules[rules.length - 1];
|
|
16293
|
+
const box = lines3.slice(a + 1, b).map((l) => l.trimEnd());
|
|
16294
|
+
if (box.length > 0 && /^\s*>/.test(box[0])) input = box.join("\n").replace(/^\s*>\s?/, "").trim();
|
|
16295
|
+
}
|
|
16296
|
+
const v = { permission, input };
|
|
16297
|
+
if (Q_BUSY_RE.test(text)) v.busy = true;
|
|
16298
|
+
if (input !== null && Q_PLACEHOLDER_RE.test(input)) v.placeholder = true;
|
|
16299
|
+
else if (input !== null && input.startsWith("/")) v.dialog = "slash-menu";
|
|
16300
|
+
if (!v.dialog && lines3.slice(rules[rules.length - 1] + 1).some((l) => l.trim() !== "" && !Q_FOOTER_RE.test(l))) v.dialog = "popup";
|
|
16301
|
+
return v;
|
|
16302
|
+
}
|
|
16303
|
+
function textRejectFor(tool) {
|
|
16304
|
+
if (tool !== "qoder_cn") return void 0;
|
|
16305
|
+
return (text) => /^[/!]/.test(oneLine(text)) ? "slash_command" : null;
|
|
16306
|
+
}
|
|
16307
|
+
var RULES = {
|
|
16308
|
+
claude_code: inspectLines,
|
|
16309
|
+
qoder_cn: qoderCnRule
|
|
16310
|
+
};
|
|
16311
|
+
function screenRuleFor(tool) {
|
|
16312
|
+
return RULES[tool] ?? (() => null);
|
|
16313
|
+
}
|
|
16314
|
+
var INJECT_READY = {
|
|
16315
|
+
claude_code: true,
|
|
16316
|
+
qoder_cn: true
|
|
16317
|
+
};
|
|
16318
|
+
function injectReady(tool) {
|
|
16319
|
+
return INJECT_READY[tool] === true;
|
|
16320
|
+
}
|
|
16321
|
+
|
|
16177
16322
|
// src/wrap/codexLaunch.ts
|
|
16178
16323
|
import { execFileSync as execFileSync16, spawn as spawn8 } from "child_process";
|
|
16179
16324
|
import { closeSync as closeSync10, mkdirSync as mkdirSync30, openSync as openSync10, readdirSync as readdirSync12, readFileSync as readFileSync30, rmSync as rmSync23, statSync as statSync19, truncateSync as truncateSync2, writeFileSync as writeFileSync29, renameSync as renameSync20 } from "fs";
|
|
@@ -17040,13 +17185,14 @@ var TICK_MS = 300;
|
|
|
17040
17185
|
var MAX_LOG_BYTES3 = 512 * 1024;
|
|
17041
17186
|
var WRAP_HINT = "DeskBuddy \u5305\u88C5\u51FA\u9519\uFF0C\u8BF7\u8BBE DESKBUDDY_WRAP_OFF=1 \u4E34\u65F6\u7ED5\u5F00\uFF08\u4F8B\u5982 DESKBUDDY_WRAP_OFF=1 claude\uFF09";
|
|
17042
17187
|
var SESSION_ENV = ["DESKBUDDY_SHIM", "CLAUDECODE", "CLAUDE_CODE_CHILD_SESSION", "CLAUDE_CODE_SESSION_ID", "CLAUDE_PID", "CLAUDE_CODE_ENTRYPOINT"];
|
|
17043
|
-
function childEnv(src, windowId) {
|
|
17188
|
+
function childEnv(src, windowId, takeover = true) {
|
|
17044
17189
|
const env = {};
|
|
17045
17190
|
for (const [k, v] of Object.entries(src)) {
|
|
17046
17191
|
if (SESSION_ENV.includes(k)) continue;
|
|
17047
17192
|
env[k] = v;
|
|
17048
17193
|
}
|
|
17049
|
-
env.DESKBUDDY_WINDOW = windowId;
|
|
17194
|
+
if (takeover) env.DESKBUDDY_WINDOW = windowId;
|
|
17195
|
+
else delete env.DESKBUDDY_WINDOW;
|
|
17050
17196
|
env.DESKBUDDY_WRAPPED = "1";
|
|
17051
17197
|
return scrubAgentEnv(env);
|
|
17052
17198
|
}
|
|
@@ -17191,6 +17337,7 @@ function defaultPromptDeps(deps2, cwd, launchId) {
|
|
|
17191
17337
|
})
|
|
17192
17338
|
);
|
|
17193
17339
|
}
|
|
17340
|
+
if (tool === "qoder_cn") return runQoderCnWindow(real2, args, env, cwd, deps2);
|
|
17194
17341
|
return runDirect(real2, args, env);
|
|
17195
17342
|
},
|
|
17196
17343
|
env: deps2.env,
|
|
@@ -17202,6 +17349,18 @@ function defaultPromptDeps(deps2, cwd, launchId) {
|
|
|
17202
17349
|
transcriptReady: (tool, sid) => launchTranscriptReady(tool, sid)
|
|
17203
17350
|
};
|
|
17204
17351
|
}
|
|
17352
|
+
async function runQoderCnWindow(real2, args, env, cwd, deps2, impl = {}) {
|
|
17353
|
+
const direct = impl.direct ?? runDirect;
|
|
17354
|
+
if (deps2.platform === "win32") return direct(real2, args, env);
|
|
17355
|
+
let r = null;
|
|
17356
|
+
try {
|
|
17357
|
+
r = await (impl.wrap ?? wrapInPty)("qoder_cn", real2, args, { ...deps2, env, cwd: () => cwd }, { launched: false, takeover: impl.takeover ?? injectReady("qoder_cn") });
|
|
17358
|
+
} catch (e) {
|
|
17359
|
+
deps2.log(`Qoder CN \u7A97\u53E3\u5305\u88C5\u5931\u8D25\uFF0C\u76F4\u63A5\u8FD0\u884C\uFF1A${e.message}`);
|
|
17360
|
+
r = null;
|
|
17361
|
+
}
|
|
17362
|
+
return r !== null ? r : direct(real2, args, env);
|
|
17363
|
+
}
|
|
17205
17364
|
async function runLaunch(argv, deps2 = defaultWrapDeps(), ld = defaultLaunchDeps()) {
|
|
17206
17365
|
const runId = argv[0] ?? "";
|
|
17207
17366
|
const hi = argv.indexOf("--home");
|
|
@@ -17269,15 +17428,15 @@ async function wrapInPty(tool, real2, args, deps2, opts = {}) {
|
|
|
17269
17428
|
cols,
|
|
17270
17429
|
rows,
|
|
17271
17430
|
cwd: deps2.cwd(),
|
|
17272
|
-
env: childEnv(deps2.env, windowId)
|
|
17431
|
+
env: childEnv(deps2.env, windowId, opts.takeover !== false)
|
|
17273
17432
|
});
|
|
17274
17433
|
} catch (e) {
|
|
17275
17434
|
deps2.log(`pty \u8D77\u4E0D\u6765\uFF0C\u653E\u884C\u76F4\u8DD1\uFF1A${e.message}`);
|
|
17276
17435
|
return null;
|
|
17277
17436
|
}
|
|
17278
|
-
return runAttached(tool, p, term, windowId, deps2, opts.launched === true);
|
|
17437
|
+
return runAttached(tool, p, term, windowId, deps2, opts.launched === true, opts.takeover !== false);
|
|
17279
17438
|
}
|
|
17280
|
-
function runAttached(tool, p, term0, windowId, deps2, launched = false) {
|
|
17439
|
+
function runAttached(tool, p, term0, windowId, deps2, launched = false, takeover = true) {
|
|
17281
17440
|
const { stdin, stdout: stdout7 } = deps2;
|
|
17282
17441
|
let term = term0;
|
|
17283
17442
|
let finished = false;
|
|
@@ -17307,13 +17466,15 @@ function runAttached(tool, p, term0, windowId, deps2, launched = false) {
|
|
|
17307
17466
|
};
|
|
17308
17467
|
const injector = new Injector({
|
|
17309
17468
|
now: () => Date.now(),
|
|
17310
|
-
|
|
17469
|
+
// 按工具取读屏规则(screenRules.ts);判断不了返回 null = 不可写
|
|
17470
|
+
view: () => term ? screenRuleFor(tool)(screenLines(term), screenLinesUndimmed(term)) : null,
|
|
17311
17471
|
lastEvent: () => readLastEvent(windowId),
|
|
17312
17472
|
write: toPty,
|
|
17313
17473
|
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
17314
17474
|
holdKeys,
|
|
17315
17475
|
log: (m) => deps2.log(`[${windowId.slice(0, 8)}] ${m}`),
|
|
17316
|
-
startupIdle: launched
|
|
17476
|
+
startupIdle: launched,
|
|
17477
|
+
rejectText: textRejectFor(tool)
|
|
17317
17478
|
});
|
|
17318
17479
|
p.onData((d) => {
|
|
17319
17480
|
try {
|
|
@@ -17381,7 +17542,7 @@ function runAttached(tool, p, term0, windowId, deps2, launched = false) {
|
|
|
17381
17542
|
const trustCtx = trust ? trustContext(tool, trust.forms, deps2.cwd()) : void 0;
|
|
17382
17543
|
let answerChain = Promise.resolve();
|
|
17383
17544
|
const secret = randomBytes10(32);
|
|
17384
|
-
try {
|
|
17545
|
+
if (takeover) try {
|
|
17385
17546
|
writeWindow(windowId, {
|
|
17386
17547
|
pid: process.pid,
|
|
17387
17548
|
childPid: p.pid,
|
|
@@ -17395,7 +17556,7 @@ function runAttached(tool, p, term0, windowId, deps2, launched = false) {
|
|
|
17395
17556
|
} catch (e) {
|
|
17396
17557
|
deps2.log(`\u767B\u8BB0\u7A97\u53E3\u5931\u8D25\uFF1A${e.message}`);
|
|
17397
17558
|
}
|
|
17398
|
-
const channelOk = deps2.platform === "win32" || ensureChannelDir(sock);
|
|
17559
|
+
const channelOk = takeover && (deps2.platform === "win32" || ensureChannelDir(sock));
|
|
17399
17560
|
if (channelOk) {
|
|
17400
17561
|
void startChannelServer(
|
|
17401
17562
|
sock,
|
|
@@ -17442,7 +17603,7 @@ function runAttached(tool, p, term0, windowId, deps2, launched = false) {
|
|
|
17442
17603
|
if (!s2) deps2.log("\u901A\u9053\u8D77\u4E0D\u6765\uFF0C\u8FD9\u4E2A\u7A97\u53E3\u6536\u4E0D\u5230\u8BBE\u5907\u6307\u4EE4");
|
|
17443
17604
|
});
|
|
17444
17605
|
}
|
|
17445
|
-
timer = setInterval(() => {
|
|
17606
|
+
if (takeover) timer = setInterval(() => {
|
|
17446
17607
|
injector.tick().catch(() => {
|
|
17447
17608
|
});
|
|
17448
17609
|
if (trust) {
|
package/dist/opencode.mjs
CHANGED
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
// 🔴 窗口模式反过来:任何失败都不表态,授权框照常由人处理。
|
|
22
22
|
|
|
23
23
|
import { spawn } from 'node:child_process'
|
|
24
|
-
import {
|
|
24
|
+
import { randomBytes } from 'node:crypto'
|
|
25
|
+
import { appendFileSync, chmodSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
25
26
|
import { dirname, join } from 'node:path'
|
|
26
27
|
import { fileURLToPath } from 'node:url'
|
|
27
28
|
import { alwaysSuggestions, askToCc, isPlainQuestion, ocReplyOf, questionToCc, rememberTitle, supportsPermissionReply, toCcTool } from './ocTranslate.mjs'
|
|
@@ -52,6 +53,329 @@ export const OC_GATED_KEYS = [
|
|
|
52
53
|
'external_directory', 'todowrite', 'todoread', 'lsp', 'skill', 'doom_loop', 'patch', 'apply_patch',
|
|
53
54
|
]
|
|
54
55
|
|
|
56
|
+
// ---- 窗口回复信箱(spec 2026-10-08 opencode-window-reply):与 src/core/ocMailbox.ts / dshMailbox.ts 的约定一致,改一边必须改另一边 ----
|
|
57
|
+
const OC_HEARTBEAT_MS = 1000
|
|
58
|
+
const OC_INBOX_POLL_MS = 500
|
|
59
|
+
/** 会话列表多久刷一次(session.list 读盘,不必每秒) */
|
|
60
|
+
const OC_LIST_MS = 10 * 1000
|
|
61
|
+
/** 只列最近这么久更新过的顶层会话、最多这么多个 */
|
|
62
|
+
const OC_LIST_MAX_AGE_MS = 7 * 24 * 3600 * 1000
|
|
63
|
+
const OC_LIST_MAX = 200
|
|
64
|
+
const OC_ACTIVE_MAX = 20
|
|
65
|
+
/** 与 src/core/dshMailbox.ts 的 DSH_STALE_MS 一致:daemon 早已按超时回报过的信不再投 */
|
|
66
|
+
export const OC_STALE_LETTER_MS = 12 * 1000
|
|
67
|
+
/** 判忙闲只决定回报 started 还是 queued,最多等这么久 */
|
|
68
|
+
const OC_STATUS_WAIT_MS = 1000
|
|
69
|
+
/** promptAsync 是立即返回的(204),最多等这么久 */
|
|
70
|
+
const OC_PROMPT_WAIT_MS = 3 * 1000
|
|
71
|
+
/**
|
|
72
|
+
* 回执最晚在信龄这么久时写出:daemon 8 秒没回执去收回、发现已被认领再宽限 3 秒(= 11 秒报 ack_timeout),
|
|
73
|
+
* 这里留 1 秒余量。同一会话的信串行发,排在后面的信剩余时间不够发一次就不发、回 backlog(确定没发出去)
|
|
74
|
+
*/
|
|
75
|
+
export const OC_ACK_BUDGET_MS = 10 * 1000
|
|
76
|
+
/** 剩余时间少于这个就不再发(至少够 promptAsync 等 1 秒 + 写回执) */
|
|
77
|
+
const OC_MIN_SEND_MS = 1500
|
|
78
|
+
const OC_ID_RE = /^[A-Za-z0-9_-]{1,128}$/
|
|
79
|
+
export const OC_LETTER_RE = /^([A-Za-z0-9_-]{1,128})\.([A-Za-z0-9_-]{1,128})\.json$/
|
|
80
|
+
/** 同一进程里插件会被加载两次(实测 1.18.34):信箱只起一份,挂在进程全局上 */
|
|
81
|
+
export const OC_MAILBOX_KEY = Symbol.for('deskbuddy.opencode.mailbox')
|
|
82
|
+
|
|
83
|
+
/** 不是 TUI 的子命令:这些进程里不起信箱(serve / web / acp 等由别的前端驱动,界面不归我们管;attach 的插件跑在它连的 server 里) */
|
|
84
|
+
const OC_NON_TUI_COMMANDS = new Set([
|
|
85
|
+
'completion', 'acp', 'mcp', 'attach', 'run', 'debug', 'providers', 'auth', 'agent', 'upgrade', 'uninstall',
|
|
86
|
+
'serve', 'web', 'models', 'stats', 'export', 'import', 'github', 'session', 'plugin', 'plug', 'db',
|
|
87
|
+
])
|
|
88
|
+
/** 带值的全局 / TUI 选项(`--port 4096` 的 4096 不是子命令)。`--x=v` 写法不占下一个参数 */
|
|
89
|
+
const OC_VALUE_FLAGS = new Set(['--log-level', '--port', '--hostname', '--mdns-domain', '--cors', '-m', '--model', '-s', '--session', '--prompt', '--agent', '--replay-limit'])
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 是不是 TUI 形态(`opencode`、`opencode <项目目录>`、`opencode pr <n>`):`--` 之前第一个位置参数不是非 TUI 子命令。
|
|
93
|
+
* 编译版 argv = [bun, 入口, …]
|
|
94
|
+
*/
|
|
95
|
+
export function isTuiProcess(argv = process.argv) {
|
|
96
|
+
const args = argv.slice(2)
|
|
97
|
+
const end = args.indexOf('--')
|
|
98
|
+
const head = end >= 0 ? args.slice(0, end) : args
|
|
99
|
+
for (let i = 0; i < head.length; i++) {
|
|
100
|
+
const a = String(head[i])
|
|
101
|
+
if (a.startsWith('-')) {
|
|
102
|
+
if (OC_VALUE_FLAGS.has(a)) i++
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
return !OC_NON_TUI_COMMANDS.has(a)
|
|
106
|
+
}
|
|
107
|
+
return true
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 同一类失败一分钟只记一行(心跳 / 轮询每秒都在跑,失败别刷屏) */
|
|
111
|
+
export function throttledLog(write, windowMs = 60 * 1000, now = Date.now) {
|
|
112
|
+
const last = new Map()
|
|
113
|
+
return (line) => {
|
|
114
|
+
const key = String(line).replace(/[::].*$/, '')
|
|
115
|
+
const t = now()
|
|
116
|
+
if (last.has(key) && t - last.get(key) < windowMs) return
|
|
117
|
+
last.set(key, t)
|
|
118
|
+
try {
|
|
119
|
+
write(line)
|
|
120
|
+
} catch (_) {
|
|
121
|
+
/* 日志写不了不影响 */
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function ocDebugLog(line) {
|
|
127
|
+
appendFileSync(join(HERE, 'hook-debug.log'), `${new Date().toISOString()} [opencode] ${line}\n`, 'utf8')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function ocMailboxHome(env = process.env) {
|
|
131
|
+
return (env && env.DESKBUDDY_HOME) || HERE
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** session.list 的结果 → 能续写的顶层会话(新的在前),顺带记下各自目录 */
|
|
135
|
+
export function listableSessions(list, now, dirs) {
|
|
136
|
+
if (!Array.isArray(list)) return []
|
|
137
|
+
const out = []
|
|
138
|
+
for (const s of list) {
|
|
139
|
+
if (!s || typeof s.id !== 'string' || !OC_ID_RE.test(s.id) || s.parentID) continue
|
|
140
|
+
const t = s.time && typeof s.time.updated === 'number' ? s.time.updated : 0
|
|
141
|
+
if (now - t > OC_LIST_MAX_AGE_MS) continue
|
|
142
|
+
if (dirs && typeof s.directory === 'string' && s.directory) dirs.set(s.id, s.directory)
|
|
143
|
+
out.push({ id: s.id, t })
|
|
144
|
+
}
|
|
145
|
+
return out.sort((a, b) => b.t - a.t).slice(0, OC_LIST_MAX).map((x) => x.id)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** SDK 调用的结果判定:没抛、没有 error、HTTP < 400 才算成 */
|
|
149
|
+
function sdkOk(r) {
|
|
150
|
+
if (!r || r.error) return false
|
|
151
|
+
const st = r.response && typeof r.response.status === 'number' ? r.response.status : 200
|
|
152
|
+
return st < 400
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 心跳:每秒写 alive/<pid>.json;取信:≤1 秒扫一次 inbox,地址是本进程、会话是自己列着的才 rename 认领
|
|
157
|
+
* (认领名带本实例随机串;与 daemon 收回、同进程另一份插件实例互斥)→ 读 → 删 → promptAsync → 写 ack。
|
|
158
|
+
* opts:client(opencode SDK)、directory、home、pid、now、log。返回句柄(测试用;进程退出时自动清心跳)
|
|
159
|
+
*/
|
|
160
|
+
export function startOcMailbox(opts) {
|
|
161
|
+
const client = opts.client
|
|
162
|
+
const home = opts.home || ocMailboxHome()
|
|
163
|
+
const pid = opts.pid || process.pid
|
|
164
|
+
const now = opts.now || Date.now
|
|
165
|
+
const log = opts.log || (() => {})
|
|
166
|
+
const directory = opts.directory || process.cwd()
|
|
167
|
+
const token = randomBytes(4).toString('hex')
|
|
168
|
+
const dir = (name) => join(home, 'opencode', name)
|
|
169
|
+
const alivePath = join(dir('alive'), `${pid}.json`)
|
|
170
|
+
const ensure = (p) => {
|
|
171
|
+
mkdirSync(p, { recursive: true, mode: 0o700 })
|
|
172
|
+
try {
|
|
173
|
+
chmodSync(p, 0o700)
|
|
174
|
+
} catch (_) {
|
|
175
|
+
/* 改不了权限也不挡 */
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const writeJson = (path, value) => {
|
|
179
|
+
const tmp = `${path}.${pid}.${token}.tmp`
|
|
180
|
+
writeFileSync(tmp, JSON.stringify(value), { encoding: 'utf8', mode: 0o600 })
|
|
181
|
+
renameSync(tmp, path)
|
|
182
|
+
}
|
|
183
|
+
let listed = []
|
|
184
|
+
const dirs = new Map() // sessionID → 目录
|
|
185
|
+
const active = [] // 新的在前
|
|
186
|
+
const children = new Set()
|
|
187
|
+
const done = new Set() // 处理过的 instructionId:同一封信绝不发第二次
|
|
188
|
+
const known = (sid) => listed.includes(sid) || active.includes(sid)
|
|
189
|
+
|
|
190
|
+
const noteActive = (sid, directory2) => {
|
|
191
|
+
if (typeof sid !== 'string' || !OC_ID_RE.test(sid) || children.has(sid)) return
|
|
192
|
+
if (typeof directory2 === 'string' && directory2) dirs.set(sid, directory2)
|
|
193
|
+
const i = active.indexOf(sid)
|
|
194
|
+
if (i >= 0) active.splice(i, 1)
|
|
195
|
+
active.unshift(sid)
|
|
196
|
+
if (active.length > OC_ACTIVE_MAX) active.length = OC_ACTIVE_MAX
|
|
197
|
+
}
|
|
198
|
+
const noteChild = (sid) => {
|
|
199
|
+
if (typeof sid !== 'string') return
|
|
200
|
+
children.add(sid)
|
|
201
|
+
const i = active.indexOf(sid)
|
|
202
|
+
if (i >= 0) active.splice(i, 1)
|
|
203
|
+
}
|
|
204
|
+
let listing = false
|
|
205
|
+
const refresh = async () => {
|
|
206
|
+
if (listing) return // 上一次还没回来(读盘慢):不叠请求
|
|
207
|
+
listing = true
|
|
208
|
+
try {
|
|
209
|
+
const r = await client.session.list({ query: { directory } })
|
|
210
|
+
if (sdkOk(r)) listed = listableSessions(r.data, now(), dirs)
|
|
211
|
+
else log(`读会话列表失败:status=${(r && r.response && r.response.status) || '?'}`)
|
|
212
|
+
} catch (e) {
|
|
213
|
+
log(`读会话列表失败:${(e && e.message) || e}`)
|
|
214
|
+
} finally {
|
|
215
|
+
listing = false
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const beat = () => {
|
|
219
|
+
try {
|
|
220
|
+
ensure(dir('alive'))
|
|
221
|
+
const sessions = [...new Set([...active, ...listed])]
|
|
222
|
+
writeJson(alivePath, { pid, at: now(), sessions, active: [...active] })
|
|
223
|
+
} catch (e) {
|
|
224
|
+
log(`写心跳失败:${(e && e.message) || e}`)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const writeAck = (iid, ack) => {
|
|
228
|
+
try {
|
|
229
|
+
ensure(dir('ack'))
|
|
230
|
+
writeJson(join(dir('ack'), `${iid}.json`), ack)
|
|
231
|
+
} catch (e) {
|
|
232
|
+
log(`写回执失败:${(e && e.message) || e}`)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const withTimeout = (p, ms) => {
|
|
236
|
+
let timer = null
|
|
237
|
+
return Promise.race([p, new Promise((x) => (timer = setTimeout(() => x('timeout'), ms)))]).finally(() => timer && clearTimeout(timer))
|
|
238
|
+
}
|
|
239
|
+
/** deadline:这封信的回执最晚要在这时写出(信龄 OC_ACK_BUDGET_MS) */
|
|
240
|
+
const deliver = async (sid, text, deadline) => {
|
|
241
|
+
if (!text.trim()) return { ok: false, reason: 'empty' }
|
|
242
|
+
const d = dirs.get(sid) || directory
|
|
243
|
+
const left = () => deadline - now()
|
|
244
|
+
let busy = false
|
|
245
|
+
// 判忙闲只影响 started / queued:时间不宽裕就不判,按闲报
|
|
246
|
+
if (left() >= OC_STATUS_WAIT_MS + OC_PROMPT_WAIT_MS + 500) {
|
|
247
|
+
try {
|
|
248
|
+
const st = await withTimeout(client.session.status({ query: { directory: d } }), OC_STATUS_WAIT_MS)
|
|
249
|
+
const t = st && st !== 'timeout' && st.data && st.data[sid] && st.data[sid].type
|
|
250
|
+
busy = t === 'busy' || t === 'retry'
|
|
251
|
+
} catch (_) {
|
|
252
|
+
busy = false
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// 剩余时间不够发一次:不发(确定没发出去),daemon 那头按失败回报
|
|
256
|
+
if (left() < OC_MIN_SEND_MS) return { ok: false, reason: 'backlog' }
|
|
257
|
+
try {
|
|
258
|
+
const r = await withTimeout(
|
|
259
|
+
client.session.promptAsync({ path: { id: sid }, query: { directory: d }, body: { parts: [{ type: 'text', text }] } }),
|
|
260
|
+
Math.min(OC_PROMPT_WAIT_MS, left() - 500)
|
|
261
|
+
)
|
|
262
|
+
if (r === 'timeout') return { ok: false, reason: 'prompt_timeout' }
|
|
263
|
+
if (!sdkOk(r)) {
|
|
264
|
+
// 404 = 这个进程里没有这个会话,确定没发出去,daemon 可以改走后台续跑
|
|
265
|
+
const st = r && r.response && r.response.status
|
|
266
|
+
return { ok: false, reason: st === 404 ? 'not_live' : 'prompt_failed' }
|
|
267
|
+
}
|
|
268
|
+
noteActive(sid)
|
|
269
|
+
return { ok: true, busy }
|
|
270
|
+
} catch (e) {
|
|
271
|
+
log(`promptAsync 失败:${(e && e.message) || e}`)
|
|
272
|
+
return { ok: false, reason: 'prompt_failed' }
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/** 会话 → 这个会话上一封信的处理链:同一会话的信串行发,前一封 promptAsync 返回了才发下一封(保证先后) */
|
|
276
|
+
const chains = new Map()
|
|
277
|
+
const enqueue = (sid, iid, text, at) => {
|
|
278
|
+
const prev = chains.get(sid) || Promise.resolve()
|
|
279
|
+
const next = prev
|
|
280
|
+
.then(() => deliver(sid, text, at + OC_ACK_BUDGET_MS))
|
|
281
|
+
.then(
|
|
282
|
+
(ack) => writeAck(iid, ack),
|
|
283
|
+
() => writeAck(iid, { ok: false, reason: 'prompt_failed' })
|
|
284
|
+
)
|
|
285
|
+
chains.set(sid, next)
|
|
286
|
+
next.finally(() => {
|
|
287
|
+
if (chains.get(sid) === next) chains.delete(sid)
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
const poll = () => {
|
|
291
|
+
let names
|
|
292
|
+
try {
|
|
293
|
+
names = readdirSync(dir('inbox'))
|
|
294
|
+
} catch (_) {
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
const got = [] // 这一轮认领到的信,按信里的 at 排序后再入链
|
|
298
|
+
for (const n of names.sort()) {
|
|
299
|
+
const m = OC_LETTER_RE.exec(n)
|
|
300
|
+
if (!m || !known(m[1])) continue
|
|
301
|
+
const sid = m[1]
|
|
302
|
+
const src = join(dir('inbox'), n)
|
|
303
|
+
// 先看地址:不是给本进程的不认领(同一会话可能被同项目的另一个 TUI 也列着)
|
|
304
|
+
let peek = null
|
|
305
|
+
try {
|
|
306
|
+
peek = JSON.parse(readFileSync(src, 'utf8'))
|
|
307
|
+
} catch (_) {
|
|
308
|
+
continue // 刚被收回 / 别人认领了 / 还没写完(原子写不会出现)
|
|
309
|
+
}
|
|
310
|
+
if (!peek || (peek.pid !== undefined && peek.pid !== pid)) continue
|
|
311
|
+
const claim = `${src}.${pid}.${token}.claim`
|
|
312
|
+
try {
|
|
313
|
+
renameSync(src, claim)
|
|
314
|
+
} catch (_) {
|
|
315
|
+
continue // daemon 收回了 / 另一份实例认领了
|
|
316
|
+
}
|
|
317
|
+
let letter = null
|
|
318
|
+
try {
|
|
319
|
+
letter = JSON.parse(readFileSync(claim, 'utf8'))
|
|
320
|
+
} catch (_) {
|
|
321
|
+
letter = null
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
unlinkSync(claim)
|
|
325
|
+
} catch (_) {
|
|
326
|
+
/* 删不掉也不影响 */
|
|
327
|
+
}
|
|
328
|
+
if (!letter || letter.instructionId !== m[2] || !OC_ID_RE.test(letter.instructionId)) continue
|
|
329
|
+
const iid = letter.instructionId
|
|
330
|
+
if (done.has(iid)) continue
|
|
331
|
+
done.add(iid)
|
|
332
|
+
if (done.size > 500) done.delete(done.values().next().value)
|
|
333
|
+
const at = typeof letter.at === 'number' ? letter.at : 0
|
|
334
|
+
if (now() - at > OC_STALE_LETTER_MS) {
|
|
335
|
+
writeAck(iid, { ok: false, reason: 'stale' })
|
|
336
|
+
continue
|
|
337
|
+
}
|
|
338
|
+
got.push({ sid, iid, at, text: typeof letter.text === 'string' ? letter.text : '' })
|
|
339
|
+
}
|
|
340
|
+
got.sort((a, b) => a.at - b.at)
|
|
341
|
+
for (const x of got) enqueue(x.sid, x.iid, x.text, x.at)
|
|
342
|
+
}
|
|
343
|
+
let t1 = null
|
|
344
|
+
let t2 = null
|
|
345
|
+
let t3 = null
|
|
346
|
+
const stop = () => {
|
|
347
|
+
if (t1) clearInterval(t1)
|
|
348
|
+
if (t2) clearInterval(t2)
|
|
349
|
+
if (t3) clearInterval(t3)
|
|
350
|
+
t1 = t2 = t3 = null
|
|
351
|
+
try {
|
|
352
|
+
unlinkSync(alivePath)
|
|
353
|
+
} catch (_) {
|
|
354
|
+
/* 已经没了 */
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const handle = { noteActive, noteChild, refresh, beat, poll, stop, pid, token }
|
|
358
|
+
if (opts.timers !== false) {
|
|
359
|
+
refresh().finally(beat)
|
|
360
|
+
t1 = setInterval(beat, OC_HEARTBEAT_MS)
|
|
361
|
+
t2 = setInterval(poll, OC_INBOX_POLL_MS)
|
|
362
|
+
t3 = setInterval(() => void refresh(), OC_LIST_MS)
|
|
363
|
+
for (const t of [t1, t2, t3]) if (t.unref) t.unref()
|
|
364
|
+
process.once('exit', stop)
|
|
365
|
+
}
|
|
366
|
+
return handle
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** 进程里只起一份信箱(插件实测会被加载两次);已有就复用。能力不够(老 SDK 没有 promptAsync)返回 null */
|
|
370
|
+
export function ensureOcMailbox(ctx, g = globalThis, log = throttledLog(ocDebugLog)) {
|
|
371
|
+
const client = ctx && ctx.client
|
|
372
|
+
if (!client || !client.session || typeof client.session.promptAsync !== 'function' || typeof client.session.list !== 'function') return null
|
|
373
|
+
if (g[OC_MAILBOX_KEY]) return g[OC_MAILBOX_KEY]
|
|
374
|
+
const h = startOcMailbox({ client, log, directory: (ctx && (ctx.directory || ctx.worktree)) || process.cwd() })
|
|
375
|
+
g[OC_MAILBOX_KEY] = h
|
|
376
|
+
return h
|
|
377
|
+
}
|
|
378
|
+
|
|
55
379
|
function runIdFromEnv() {
|
|
56
380
|
const v = process.env.DESKBUDDY_GATE_RUN || ''
|
|
57
381
|
return /^[0-9a-zA-Z_-]{1,64}$/.test(v) ? v : ''
|
|
@@ -193,6 +517,16 @@ export function make(opts = {}) {
|
|
|
193
517
|
const cwd = (ctx && (ctx.directory || ctx.worktree)) || process.cwd()
|
|
194
518
|
const runId = runIdFromEnv()
|
|
195
519
|
const headless = isHeadless()
|
|
520
|
+
// 窗口回复信箱:只在交互进程里起(远程 run / 一次性 run 里不收信)
|
|
521
|
+
// 只在 TUI 形态里起:serve / web / acp 等由别的前端驱动,不归我们(评审 2026-10-08 第 3 条)
|
|
522
|
+
const mailbox = runId || headless || opts.mailbox === false || !isTuiProcess(opts.argv || process.argv) ? null : ensureOcMailbox(ctx)
|
|
523
|
+
const touch = (sid, d) => {
|
|
524
|
+
try {
|
|
525
|
+
if (mailbox) mailbox.noteActive(sid, d)
|
|
526
|
+
} catch (_) {
|
|
527
|
+
/* 不影响别的 */
|
|
528
|
+
}
|
|
529
|
+
}
|
|
196
530
|
|
|
197
531
|
// ---- 状态上报:串行喂 hook.cjs,保证 PreToolUse / PostToolUse / Stop 的先后 ----
|
|
198
532
|
let queue = Promise.resolve()
|
|
@@ -401,6 +735,7 @@ export function make(opts = {}) {
|
|
|
401
735
|
'chat.message': async (input, output) => {
|
|
402
736
|
// prompt 给 hook 的 first-prompt 标题来源用(只记首条,见 hook.cjs)
|
|
403
737
|
report('UserPromptSubmit', input && input.sessionID, { prompt: userText(output) })
|
|
738
|
+
touch(input && input.sessionID)
|
|
404
739
|
// 切片 C:设备在这个窗口之外追加、daemon 后台跑完的轮次,同步取出补进这条消息(常规 report 是异步的,输出不读)
|
|
405
740
|
// daemon 起的远程 run(runId)不取:那是留给用户窗口的(hook 侧还有 DESKBUDDY_HEADLESS 兜一层)
|
|
406
741
|
if (runId || !(input && input.sessionID)) return
|
|
@@ -463,10 +798,16 @@ export function make(opts = {}) {
|
|
|
463
798
|
case 'session.idle':
|
|
464
799
|
report('Stop', p.sessionID)
|
|
465
800
|
break
|
|
801
|
+
case 'session.status':
|
|
802
|
+
if (p.status && p.status.type === 'busy' && !children.has(p.sessionID)) touch(p.sessionID)
|
|
803
|
+
break
|
|
466
804
|
case 'session.created':
|
|
467
805
|
case 'session.updated':
|
|
468
806
|
if (event.type === 'session.updated') rememberTitle(titles, p.info)
|
|
469
|
-
if (p.info && typeof p.info.id === 'string' && p.info.parentID)
|
|
807
|
+
if (p.info && typeof p.info.id === 'string' && p.info.parentID) {
|
|
808
|
+
children.add(p.info.id)
|
|
809
|
+
if (mailbox) mailbox.noteChild(p.info.id)
|
|
810
|
+
}
|
|
470
811
|
break
|
|
471
812
|
case 'session.deleted':
|
|
472
813
|
children.delete((p.info && p.info.id) || p.sessionID)
|
package/hook/hook-debug.log
CHANGED
|
@@ -66,3 +66,13 @@
|
|
|
66
66
|
2026-10-05T02:23:41.812Z [opencode] opencode 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型
|
|
67
67
|
2026-10-05T02:23:41.898Z [opencode] opencode 版本 读不出:带说明的拒绝不走新路由
|
|
68
68
|
2026-10-05T02:23:41.898Z [opencode] opencode 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型
|
|
69
|
+
2026-10-08T04:16:45.340Z [opencode] 回填选择题失败:/question/{requestID}/reply status=404
|
|
70
|
+
2026-10-08T04:16:45.767Z [opencode] opencode 版本 1.4.3:带说明的拒绝不走新路由
|
|
71
|
+
2026-10-08T04:16:45.767Z [opencode] opencode 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型
|
|
72
|
+
2026-10-08T04:16:45.863Z [opencode] opencode 版本 读不出:带说明的拒绝不走新路由
|
|
73
|
+
2026-10-08T04:16:45.863Z [opencode] opencode 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型
|
|
74
|
+
2026-10-08T05:57:20.374Z [opencode] 回填选择题失败:/question/{requestID}/reply status=404
|
|
75
|
+
2026-10-08T05:57:20.845Z [opencode] opencode 版本 1.4.3:带说明的拒绝不走新路由
|
|
76
|
+
2026-10-08T05:57:20.845Z [opencode] opencode 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型
|
|
77
|
+
2026-10-08T05:57:21.097Z [opencode] opencode 版本 读不出:带说明的拒绝不走新路由
|
|
78
|
+
2026-10-08T05:57:21.097Z [opencode] opencode 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型
|
package/hook/opencode.d.mts
CHANGED
|
@@ -6,7 +6,7 @@ export function openPermissions(cfg: unknown): void
|
|
|
6
6
|
export const OC_GATED_KEYS: string[]
|
|
7
7
|
export function userText(output: unknown): string
|
|
8
8
|
export function rememberTitle(titles: Map<string, string>, info: unknown): void
|
|
9
|
-
export function make(opts?: { node?: string; env?: Record<string, string | undefined>; execPath?: string }): (ctx: unknown) => Promise<Record<string, unknown>>
|
|
9
|
+
export function make(opts?: { node?: string; env?: Record<string, string | undefined>; execPath?: string; mailbox?: boolean; argv?: string[] }): (ctx: unknown) => Promise<Record<string, unknown>>
|
|
10
10
|
export function isDumateHost(env?: Record<string, string | undefined>, execPath?: string): boolean
|
|
11
11
|
export function injectAppended(output: unknown, raw: string): string
|
|
12
12
|
export function alwaysSuggestions(props: unknown): Array<Record<string, unknown>> | undefined
|
|
@@ -14,3 +14,23 @@ export function questionToCc(props: unknown): { tool_name: string; tool_input: {
|
|
|
14
14
|
export function isPlainQuestion(props: unknown, callTool: unknown): boolean
|
|
15
15
|
export function ocReplyOf(out: unknown): { reply: 'once' | 'always' | 'reject'; message?: string } | null
|
|
16
16
|
export function supportsPermissionReply(version: unknown): boolean
|
|
17
|
+
export const OC_STALE_LETTER_MS: number
|
|
18
|
+
export const OC_LETTER_RE: RegExp
|
|
19
|
+
export const OC_MAILBOX_KEY: symbol
|
|
20
|
+
export function ocMailboxHome(env?: Record<string, string | undefined>): string
|
|
21
|
+
export function listableSessions(list: unknown, now: number, dirs?: Map<string, string>): string[]
|
|
22
|
+
export interface OcMailboxHandle {
|
|
23
|
+
noteActive(sid: string, directory?: string): void
|
|
24
|
+
noteChild(sid: string): void
|
|
25
|
+
refresh(): Promise<void>
|
|
26
|
+
beat(): void
|
|
27
|
+
poll(): void
|
|
28
|
+
stop(): void
|
|
29
|
+
pid: number
|
|
30
|
+
token: string
|
|
31
|
+
}
|
|
32
|
+
export function startOcMailbox(opts: { client: unknown; directory?: string; home?: string; pid?: number; now?: () => number; log?: (m: string) => void; timers?: boolean }): OcMailboxHandle
|
|
33
|
+
export function ensureOcMailbox(ctx: unknown, g?: Record<symbol, unknown>, log?: (m: string) => void): OcMailboxHandle | null
|
|
34
|
+
export const OC_ACK_BUDGET_MS: number
|
|
35
|
+
export function isTuiProcess(argv?: string[]): boolean
|
|
36
|
+
export function throttledLog(write: (line: string) => void, windowMs?: number, now?: () => number): (line: string) => void
|
package/hook/opencode.mjs
CHANGED
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
// 🔴 窗口模式反过来:任何失败都不表态,授权框照常由人处理。
|
|
22
22
|
|
|
23
23
|
import { spawn } from 'node:child_process'
|
|
24
|
-
import {
|
|
24
|
+
import { randomBytes } from 'node:crypto'
|
|
25
|
+
import { appendFileSync, chmodSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
25
26
|
import { dirname, join } from 'node:path'
|
|
26
27
|
import { fileURLToPath } from 'node:url'
|
|
27
28
|
import { alwaysSuggestions, askToCc, isPlainQuestion, ocReplyOf, questionToCc, rememberTitle, supportsPermissionReply, toCcTool } from './ocTranslate.mjs'
|
|
@@ -52,6 +53,329 @@ export const OC_GATED_KEYS = [
|
|
|
52
53
|
'external_directory', 'todowrite', 'todoread', 'lsp', 'skill', 'doom_loop', 'patch', 'apply_patch',
|
|
53
54
|
]
|
|
54
55
|
|
|
56
|
+
// ---- 窗口回复信箱(spec 2026-10-08 opencode-window-reply):与 src/core/ocMailbox.ts / dshMailbox.ts 的约定一致,改一边必须改另一边 ----
|
|
57
|
+
const OC_HEARTBEAT_MS = 1000
|
|
58
|
+
const OC_INBOX_POLL_MS = 500
|
|
59
|
+
/** 会话列表多久刷一次(session.list 读盘,不必每秒) */
|
|
60
|
+
const OC_LIST_MS = 10 * 1000
|
|
61
|
+
/** 只列最近这么久更新过的顶层会话、最多这么多个 */
|
|
62
|
+
const OC_LIST_MAX_AGE_MS = 7 * 24 * 3600 * 1000
|
|
63
|
+
const OC_LIST_MAX = 200
|
|
64
|
+
const OC_ACTIVE_MAX = 20
|
|
65
|
+
/** 与 src/core/dshMailbox.ts 的 DSH_STALE_MS 一致:daemon 早已按超时回报过的信不再投 */
|
|
66
|
+
export const OC_STALE_LETTER_MS = 12 * 1000
|
|
67
|
+
/** 判忙闲只决定回报 started 还是 queued,最多等这么久 */
|
|
68
|
+
const OC_STATUS_WAIT_MS = 1000
|
|
69
|
+
/** promptAsync 是立即返回的(204),最多等这么久 */
|
|
70
|
+
const OC_PROMPT_WAIT_MS = 3 * 1000
|
|
71
|
+
/**
|
|
72
|
+
* 回执最晚在信龄这么久时写出:daemon 8 秒没回执去收回、发现已被认领再宽限 3 秒(= 11 秒报 ack_timeout),
|
|
73
|
+
* 这里留 1 秒余量。同一会话的信串行发,排在后面的信剩余时间不够发一次就不发、回 backlog(确定没发出去)
|
|
74
|
+
*/
|
|
75
|
+
export const OC_ACK_BUDGET_MS = 10 * 1000
|
|
76
|
+
/** 剩余时间少于这个就不再发(至少够 promptAsync 等 1 秒 + 写回执) */
|
|
77
|
+
const OC_MIN_SEND_MS = 1500
|
|
78
|
+
const OC_ID_RE = /^[A-Za-z0-9_-]{1,128}$/
|
|
79
|
+
export const OC_LETTER_RE = /^([A-Za-z0-9_-]{1,128})\.([A-Za-z0-9_-]{1,128})\.json$/
|
|
80
|
+
/** 同一进程里插件会被加载两次(实测 1.18.34):信箱只起一份,挂在进程全局上 */
|
|
81
|
+
export const OC_MAILBOX_KEY = Symbol.for('deskbuddy.opencode.mailbox')
|
|
82
|
+
|
|
83
|
+
/** 不是 TUI 的子命令:这些进程里不起信箱(serve / web / acp 等由别的前端驱动,界面不归我们管;attach 的插件跑在它连的 server 里) */
|
|
84
|
+
const OC_NON_TUI_COMMANDS = new Set([
|
|
85
|
+
'completion', 'acp', 'mcp', 'attach', 'run', 'debug', 'providers', 'auth', 'agent', 'upgrade', 'uninstall',
|
|
86
|
+
'serve', 'web', 'models', 'stats', 'export', 'import', 'github', 'session', 'plugin', 'plug', 'db',
|
|
87
|
+
])
|
|
88
|
+
/** 带值的全局 / TUI 选项(`--port 4096` 的 4096 不是子命令)。`--x=v` 写法不占下一个参数 */
|
|
89
|
+
const OC_VALUE_FLAGS = new Set(['--log-level', '--port', '--hostname', '--mdns-domain', '--cors', '-m', '--model', '-s', '--session', '--prompt', '--agent', '--replay-limit'])
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 是不是 TUI 形态(`opencode`、`opencode <项目目录>`、`opencode pr <n>`):`--` 之前第一个位置参数不是非 TUI 子命令。
|
|
93
|
+
* 编译版 argv = [bun, 入口, …]
|
|
94
|
+
*/
|
|
95
|
+
export function isTuiProcess(argv = process.argv) {
|
|
96
|
+
const args = argv.slice(2)
|
|
97
|
+
const end = args.indexOf('--')
|
|
98
|
+
const head = end >= 0 ? args.slice(0, end) : args
|
|
99
|
+
for (let i = 0; i < head.length; i++) {
|
|
100
|
+
const a = String(head[i])
|
|
101
|
+
if (a.startsWith('-')) {
|
|
102
|
+
if (OC_VALUE_FLAGS.has(a)) i++
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
return !OC_NON_TUI_COMMANDS.has(a)
|
|
106
|
+
}
|
|
107
|
+
return true
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 同一类失败一分钟只记一行(心跳 / 轮询每秒都在跑,失败别刷屏) */
|
|
111
|
+
export function throttledLog(write, windowMs = 60 * 1000, now = Date.now) {
|
|
112
|
+
const last = new Map()
|
|
113
|
+
return (line) => {
|
|
114
|
+
const key = String(line).replace(/[::].*$/, '')
|
|
115
|
+
const t = now()
|
|
116
|
+
if (last.has(key) && t - last.get(key) < windowMs) return
|
|
117
|
+
last.set(key, t)
|
|
118
|
+
try {
|
|
119
|
+
write(line)
|
|
120
|
+
} catch (_) {
|
|
121
|
+
/* 日志写不了不影响 */
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function ocDebugLog(line) {
|
|
127
|
+
appendFileSync(join(HERE, 'hook-debug.log'), `${new Date().toISOString()} [opencode] ${line}\n`, 'utf8')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function ocMailboxHome(env = process.env) {
|
|
131
|
+
return (env && env.DESKBUDDY_HOME) || HERE
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** session.list 的结果 → 能续写的顶层会话(新的在前),顺带记下各自目录 */
|
|
135
|
+
export function listableSessions(list, now, dirs) {
|
|
136
|
+
if (!Array.isArray(list)) return []
|
|
137
|
+
const out = []
|
|
138
|
+
for (const s of list) {
|
|
139
|
+
if (!s || typeof s.id !== 'string' || !OC_ID_RE.test(s.id) || s.parentID) continue
|
|
140
|
+
const t = s.time && typeof s.time.updated === 'number' ? s.time.updated : 0
|
|
141
|
+
if (now - t > OC_LIST_MAX_AGE_MS) continue
|
|
142
|
+
if (dirs && typeof s.directory === 'string' && s.directory) dirs.set(s.id, s.directory)
|
|
143
|
+
out.push({ id: s.id, t })
|
|
144
|
+
}
|
|
145
|
+
return out.sort((a, b) => b.t - a.t).slice(0, OC_LIST_MAX).map((x) => x.id)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** SDK 调用的结果判定:没抛、没有 error、HTTP < 400 才算成 */
|
|
149
|
+
function sdkOk(r) {
|
|
150
|
+
if (!r || r.error) return false
|
|
151
|
+
const st = r.response && typeof r.response.status === 'number' ? r.response.status : 200
|
|
152
|
+
return st < 400
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 心跳:每秒写 alive/<pid>.json;取信:≤1 秒扫一次 inbox,地址是本进程、会话是自己列着的才 rename 认领
|
|
157
|
+
* (认领名带本实例随机串;与 daemon 收回、同进程另一份插件实例互斥)→ 读 → 删 → promptAsync → 写 ack。
|
|
158
|
+
* opts:client(opencode SDK)、directory、home、pid、now、log。返回句柄(测试用;进程退出时自动清心跳)
|
|
159
|
+
*/
|
|
160
|
+
export function startOcMailbox(opts) {
|
|
161
|
+
const client = opts.client
|
|
162
|
+
const home = opts.home || ocMailboxHome()
|
|
163
|
+
const pid = opts.pid || process.pid
|
|
164
|
+
const now = opts.now || Date.now
|
|
165
|
+
const log = opts.log || (() => {})
|
|
166
|
+
const directory = opts.directory || process.cwd()
|
|
167
|
+
const token = randomBytes(4).toString('hex')
|
|
168
|
+
const dir = (name) => join(home, 'opencode', name)
|
|
169
|
+
const alivePath = join(dir('alive'), `${pid}.json`)
|
|
170
|
+
const ensure = (p) => {
|
|
171
|
+
mkdirSync(p, { recursive: true, mode: 0o700 })
|
|
172
|
+
try {
|
|
173
|
+
chmodSync(p, 0o700)
|
|
174
|
+
} catch (_) {
|
|
175
|
+
/* 改不了权限也不挡 */
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const writeJson = (path, value) => {
|
|
179
|
+
const tmp = `${path}.${pid}.${token}.tmp`
|
|
180
|
+
writeFileSync(tmp, JSON.stringify(value), { encoding: 'utf8', mode: 0o600 })
|
|
181
|
+
renameSync(tmp, path)
|
|
182
|
+
}
|
|
183
|
+
let listed = []
|
|
184
|
+
const dirs = new Map() // sessionID → 目录
|
|
185
|
+
const active = [] // 新的在前
|
|
186
|
+
const children = new Set()
|
|
187
|
+
const done = new Set() // 处理过的 instructionId:同一封信绝不发第二次
|
|
188
|
+
const known = (sid) => listed.includes(sid) || active.includes(sid)
|
|
189
|
+
|
|
190
|
+
const noteActive = (sid, directory2) => {
|
|
191
|
+
if (typeof sid !== 'string' || !OC_ID_RE.test(sid) || children.has(sid)) return
|
|
192
|
+
if (typeof directory2 === 'string' && directory2) dirs.set(sid, directory2)
|
|
193
|
+
const i = active.indexOf(sid)
|
|
194
|
+
if (i >= 0) active.splice(i, 1)
|
|
195
|
+
active.unshift(sid)
|
|
196
|
+
if (active.length > OC_ACTIVE_MAX) active.length = OC_ACTIVE_MAX
|
|
197
|
+
}
|
|
198
|
+
const noteChild = (sid) => {
|
|
199
|
+
if (typeof sid !== 'string') return
|
|
200
|
+
children.add(sid)
|
|
201
|
+
const i = active.indexOf(sid)
|
|
202
|
+
if (i >= 0) active.splice(i, 1)
|
|
203
|
+
}
|
|
204
|
+
let listing = false
|
|
205
|
+
const refresh = async () => {
|
|
206
|
+
if (listing) return // 上一次还没回来(读盘慢):不叠请求
|
|
207
|
+
listing = true
|
|
208
|
+
try {
|
|
209
|
+
const r = await client.session.list({ query: { directory } })
|
|
210
|
+
if (sdkOk(r)) listed = listableSessions(r.data, now(), dirs)
|
|
211
|
+
else log(`读会话列表失败:status=${(r && r.response && r.response.status) || '?'}`)
|
|
212
|
+
} catch (e) {
|
|
213
|
+
log(`读会话列表失败:${(e && e.message) || e}`)
|
|
214
|
+
} finally {
|
|
215
|
+
listing = false
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const beat = () => {
|
|
219
|
+
try {
|
|
220
|
+
ensure(dir('alive'))
|
|
221
|
+
const sessions = [...new Set([...active, ...listed])]
|
|
222
|
+
writeJson(alivePath, { pid, at: now(), sessions, active: [...active] })
|
|
223
|
+
} catch (e) {
|
|
224
|
+
log(`写心跳失败:${(e && e.message) || e}`)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const writeAck = (iid, ack) => {
|
|
228
|
+
try {
|
|
229
|
+
ensure(dir('ack'))
|
|
230
|
+
writeJson(join(dir('ack'), `${iid}.json`), ack)
|
|
231
|
+
} catch (e) {
|
|
232
|
+
log(`写回执失败:${(e && e.message) || e}`)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const withTimeout = (p, ms) => {
|
|
236
|
+
let timer = null
|
|
237
|
+
return Promise.race([p, new Promise((x) => (timer = setTimeout(() => x('timeout'), ms)))]).finally(() => timer && clearTimeout(timer))
|
|
238
|
+
}
|
|
239
|
+
/** deadline:这封信的回执最晚要在这时写出(信龄 OC_ACK_BUDGET_MS) */
|
|
240
|
+
const deliver = async (sid, text, deadline) => {
|
|
241
|
+
if (!text.trim()) return { ok: false, reason: 'empty' }
|
|
242
|
+
const d = dirs.get(sid) || directory
|
|
243
|
+
const left = () => deadline - now()
|
|
244
|
+
let busy = false
|
|
245
|
+
// 判忙闲只影响 started / queued:时间不宽裕就不判,按闲报
|
|
246
|
+
if (left() >= OC_STATUS_WAIT_MS + OC_PROMPT_WAIT_MS + 500) {
|
|
247
|
+
try {
|
|
248
|
+
const st = await withTimeout(client.session.status({ query: { directory: d } }), OC_STATUS_WAIT_MS)
|
|
249
|
+
const t = st && st !== 'timeout' && st.data && st.data[sid] && st.data[sid].type
|
|
250
|
+
busy = t === 'busy' || t === 'retry'
|
|
251
|
+
} catch (_) {
|
|
252
|
+
busy = false
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// 剩余时间不够发一次:不发(确定没发出去),daemon 那头按失败回报
|
|
256
|
+
if (left() < OC_MIN_SEND_MS) return { ok: false, reason: 'backlog' }
|
|
257
|
+
try {
|
|
258
|
+
const r = await withTimeout(
|
|
259
|
+
client.session.promptAsync({ path: { id: sid }, query: { directory: d }, body: { parts: [{ type: 'text', text }] } }),
|
|
260
|
+
Math.min(OC_PROMPT_WAIT_MS, left() - 500)
|
|
261
|
+
)
|
|
262
|
+
if (r === 'timeout') return { ok: false, reason: 'prompt_timeout' }
|
|
263
|
+
if (!sdkOk(r)) {
|
|
264
|
+
// 404 = 这个进程里没有这个会话,确定没发出去,daemon 可以改走后台续跑
|
|
265
|
+
const st = r && r.response && r.response.status
|
|
266
|
+
return { ok: false, reason: st === 404 ? 'not_live' : 'prompt_failed' }
|
|
267
|
+
}
|
|
268
|
+
noteActive(sid)
|
|
269
|
+
return { ok: true, busy }
|
|
270
|
+
} catch (e) {
|
|
271
|
+
log(`promptAsync 失败:${(e && e.message) || e}`)
|
|
272
|
+
return { ok: false, reason: 'prompt_failed' }
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/** 会话 → 这个会话上一封信的处理链:同一会话的信串行发,前一封 promptAsync 返回了才发下一封(保证先后) */
|
|
276
|
+
const chains = new Map()
|
|
277
|
+
const enqueue = (sid, iid, text, at) => {
|
|
278
|
+
const prev = chains.get(sid) || Promise.resolve()
|
|
279
|
+
const next = prev
|
|
280
|
+
.then(() => deliver(sid, text, at + OC_ACK_BUDGET_MS))
|
|
281
|
+
.then(
|
|
282
|
+
(ack) => writeAck(iid, ack),
|
|
283
|
+
() => writeAck(iid, { ok: false, reason: 'prompt_failed' })
|
|
284
|
+
)
|
|
285
|
+
chains.set(sid, next)
|
|
286
|
+
next.finally(() => {
|
|
287
|
+
if (chains.get(sid) === next) chains.delete(sid)
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
const poll = () => {
|
|
291
|
+
let names
|
|
292
|
+
try {
|
|
293
|
+
names = readdirSync(dir('inbox'))
|
|
294
|
+
} catch (_) {
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
const got = [] // 这一轮认领到的信,按信里的 at 排序后再入链
|
|
298
|
+
for (const n of names.sort()) {
|
|
299
|
+
const m = OC_LETTER_RE.exec(n)
|
|
300
|
+
if (!m || !known(m[1])) continue
|
|
301
|
+
const sid = m[1]
|
|
302
|
+
const src = join(dir('inbox'), n)
|
|
303
|
+
// 先看地址:不是给本进程的不认领(同一会话可能被同项目的另一个 TUI 也列着)
|
|
304
|
+
let peek = null
|
|
305
|
+
try {
|
|
306
|
+
peek = JSON.parse(readFileSync(src, 'utf8'))
|
|
307
|
+
} catch (_) {
|
|
308
|
+
continue // 刚被收回 / 别人认领了 / 还没写完(原子写不会出现)
|
|
309
|
+
}
|
|
310
|
+
if (!peek || (peek.pid !== undefined && peek.pid !== pid)) continue
|
|
311
|
+
const claim = `${src}.${pid}.${token}.claim`
|
|
312
|
+
try {
|
|
313
|
+
renameSync(src, claim)
|
|
314
|
+
} catch (_) {
|
|
315
|
+
continue // daemon 收回了 / 另一份实例认领了
|
|
316
|
+
}
|
|
317
|
+
let letter = null
|
|
318
|
+
try {
|
|
319
|
+
letter = JSON.parse(readFileSync(claim, 'utf8'))
|
|
320
|
+
} catch (_) {
|
|
321
|
+
letter = null
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
unlinkSync(claim)
|
|
325
|
+
} catch (_) {
|
|
326
|
+
/* 删不掉也不影响 */
|
|
327
|
+
}
|
|
328
|
+
if (!letter || letter.instructionId !== m[2] || !OC_ID_RE.test(letter.instructionId)) continue
|
|
329
|
+
const iid = letter.instructionId
|
|
330
|
+
if (done.has(iid)) continue
|
|
331
|
+
done.add(iid)
|
|
332
|
+
if (done.size > 500) done.delete(done.values().next().value)
|
|
333
|
+
const at = typeof letter.at === 'number' ? letter.at : 0
|
|
334
|
+
if (now() - at > OC_STALE_LETTER_MS) {
|
|
335
|
+
writeAck(iid, { ok: false, reason: 'stale' })
|
|
336
|
+
continue
|
|
337
|
+
}
|
|
338
|
+
got.push({ sid, iid, at, text: typeof letter.text === 'string' ? letter.text : '' })
|
|
339
|
+
}
|
|
340
|
+
got.sort((a, b) => a.at - b.at)
|
|
341
|
+
for (const x of got) enqueue(x.sid, x.iid, x.text, x.at)
|
|
342
|
+
}
|
|
343
|
+
let t1 = null
|
|
344
|
+
let t2 = null
|
|
345
|
+
let t3 = null
|
|
346
|
+
const stop = () => {
|
|
347
|
+
if (t1) clearInterval(t1)
|
|
348
|
+
if (t2) clearInterval(t2)
|
|
349
|
+
if (t3) clearInterval(t3)
|
|
350
|
+
t1 = t2 = t3 = null
|
|
351
|
+
try {
|
|
352
|
+
unlinkSync(alivePath)
|
|
353
|
+
} catch (_) {
|
|
354
|
+
/* 已经没了 */
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const handle = { noteActive, noteChild, refresh, beat, poll, stop, pid, token }
|
|
358
|
+
if (opts.timers !== false) {
|
|
359
|
+
refresh().finally(beat)
|
|
360
|
+
t1 = setInterval(beat, OC_HEARTBEAT_MS)
|
|
361
|
+
t2 = setInterval(poll, OC_INBOX_POLL_MS)
|
|
362
|
+
t3 = setInterval(() => void refresh(), OC_LIST_MS)
|
|
363
|
+
for (const t of [t1, t2, t3]) if (t.unref) t.unref()
|
|
364
|
+
process.once('exit', stop)
|
|
365
|
+
}
|
|
366
|
+
return handle
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** 进程里只起一份信箱(插件实测会被加载两次);已有就复用。能力不够(老 SDK 没有 promptAsync)返回 null */
|
|
370
|
+
export function ensureOcMailbox(ctx, g = globalThis, log = throttledLog(ocDebugLog)) {
|
|
371
|
+
const client = ctx && ctx.client
|
|
372
|
+
if (!client || !client.session || typeof client.session.promptAsync !== 'function' || typeof client.session.list !== 'function') return null
|
|
373
|
+
if (g[OC_MAILBOX_KEY]) return g[OC_MAILBOX_KEY]
|
|
374
|
+
const h = startOcMailbox({ client, log, directory: (ctx && (ctx.directory || ctx.worktree)) || process.cwd() })
|
|
375
|
+
g[OC_MAILBOX_KEY] = h
|
|
376
|
+
return h
|
|
377
|
+
}
|
|
378
|
+
|
|
55
379
|
function runIdFromEnv() {
|
|
56
380
|
const v = process.env.DESKBUDDY_GATE_RUN || ''
|
|
57
381
|
return /^[0-9a-zA-Z_-]{1,64}$/.test(v) ? v : ''
|
|
@@ -193,6 +517,16 @@ export function make(opts = {}) {
|
|
|
193
517
|
const cwd = (ctx && (ctx.directory || ctx.worktree)) || process.cwd()
|
|
194
518
|
const runId = runIdFromEnv()
|
|
195
519
|
const headless = isHeadless()
|
|
520
|
+
// 窗口回复信箱:只在交互进程里起(远程 run / 一次性 run 里不收信)
|
|
521
|
+
// 只在 TUI 形态里起:serve / web / acp 等由别的前端驱动,不归我们(评审 2026-10-08 第 3 条)
|
|
522
|
+
const mailbox = runId || headless || opts.mailbox === false || !isTuiProcess(opts.argv || process.argv) ? null : ensureOcMailbox(ctx)
|
|
523
|
+
const touch = (sid, d) => {
|
|
524
|
+
try {
|
|
525
|
+
if (mailbox) mailbox.noteActive(sid, d)
|
|
526
|
+
} catch (_) {
|
|
527
|
+
/* 不影响别的 */
|
|
528
|
+
}
|
|
529
|
+
}
|
|
196
530
|
|
|
197
531
|
// ---- 状态上报:串行喂 hook.cjs,保证 PreToolUse / PostToolUse / Stop 的先后 ----
|
|
198
532
|
let queue = Promise.resolve()
|
|
@@ -401,6 +735,7 @@ export function make(opts = {}) {
|
|
|
401
735
|
'chat.message': async (input, output) => {
|
|
402
736
|
// prompt 给 hook 的 first-prompt 标题来源用(只记首条,见 hook.cjs)
|
|
403
737
|
report('UserPromptSubmit', input && input.sessionID, { prompt: userText(output) })
|
|
738
|
+
touch(input && input.sessionID)
|
|
404
739
|
// 切片 C:设备在这个窗口之外追加、daemon 后台跑完的轮次,同步取出补进这条消息(常规 report 是异步的,输出不读)
|
|
405
740
|
// daemon 起的远程 run(runId)不取:那是留给用户窗口的(hook 侧还有 DESKBUDDY_HEADLESS 兜一层)
|
|
406
741
|
if (runId || !(input && input.sessionID)) return
|
|
@@ -463,10 +798,16 @@ export function make(opts = {}) {
|
|
|
463
798
|
case 'session.idle':
|
|
464
799
|
report('Stop', p.sessionID)
|
|
465
800
|
break
|
|
801
|
+
case 'session.status':
|
|
802
|
+
if (p.status && p.status.type === 'busy' && !children.has(p.sessionID)) touch(p.sessionID)
|
|
803
|
+
break
|
|
466
804
|
case 'session.created':
|
|
467
805
|
case 'session.updated':
|
|
468
806
|
if (event.type === 'session.updated') rememberTitle(titles, p.info)
|
|
469
|
-
if (p.info && typeof p.info.id === 'string' && p.info.parentID)
|
|
807
|
+
if (p.info && typeof p.info.id === 'string' && p.info.parentID) {
|
|
808
|
+
children.add(p.info.id)
|
|
809
|
+
if (mailbox) mailbox.noteChild(p.info.id)
|
|
810
|
+
}
|
|
470
811
|
break
|
|
471
812
|
case 'session.deleted':
|
|
472
813
|
children.delete((p.info && p.info.id) || p.sessionID)
|