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 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, "dsh");
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 dshLiveFor(sessionId, d0 = {}) {
13378
- if (!SID_RE3.test(sessionId)) return null;
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(sub(d.home, "alive"));
13383
+ names = readdirSync11(dir);
13383
13384
  } catch {
13384
- return null;
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(sub(d.home, "alive"), n), "utf8"));
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
- return hb;
13400
+ out.push(hb);
13399
13401
  }
13400
- return null;
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
- if (!dshLiveFor(sessionId, d)) return { ok: false, reason: "not_live" };
13406
- const inbox = sub(d.home, "inbox");
13407
- const ackDir = sub(d.home, "ack");
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
- const inbox = sub(home, "inbox");
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
- if (!v || !boxMatches(v, body)) {
15984
- this.#d.log?.("\u7C98\u8D34\u540E\u8F93\u5165\u6846\u5BF9\u4E0D\u4E0A\uFF0C\u653E\u5F03\u8FD9\u6761");
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
- view: () => term ? inspectLines(screenLines(term), screenLinesUndimmed(term)) : null,
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 { appendFileSync } from 'node:fs'
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) children.add(p.info.id)
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)
@@ -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 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型
@@ -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 { appendFileSync } from 'node:fs'
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) children.add(p.info.id)
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deskbuddy",
3
- "version": "0.2.0-beta.21",
3
+ "version": "0.2.0-beta.22",
4
4
  "description": "把 Claude Code、Codex 等 AI 编码智能体每个会话窗口的状态,实时同步到桌面硬件屏幕",
5
5
  "keywords": [
6
6
  "ai",