deskbuddy 0.2.0-beta.5 → 0.2.0-beta.7

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
@@ -953,6 +953,42 @@ function buildSnapshot(states, tool, cfg, nowMs, remote) {
953
953
  import { createHmac, randomBytes as randomBytes2 } from "crypto";
954
954
  import { chmodSync, existsSync as existsSync6, mkdirSync as mkdirSync7, readFileSync as readFileSync8, writeFileSync as writeFileSync7 } from "fs";
955
955
  import { basename as basename3, join as join7 } from "path";
956
+
957
+ // src/core/dirFilter.ts
958
+ import { homedir as homedir3, tmpdir } from "os";
959
+ import { posix, win32 } from "path";
960
+ function norm(p, win) {
961
+ if (win) return win32.resolve(p).replace(/[\\/]+$/, "").toLowerCase();
962
+ return posix.resolve(p).replace(/\/+$/, "");
963
+ }
964
+ function isUnder(p, base, win) {
965
+ return p === base || p.startsWith(base + (win ? "\\" : "/"));
966
+ }
967
+ function isAutoWorkspace(p, opts = {}) {
968
+ if (!p) return false;
969
+ const win = (opts.platform ?? process.platform) === "win32";
970
+ const P = win ? win32 : posix;
971
+ const home = norm(opts.home ?? homedir3(), win);
972
+ const full = norm(p, win);
973
+ const name = P.basename(full);
974
+ const parent = P.dirname(full);
975
+ if (parent === norm(P.join(home, "WorkBuddy"), win) && /^\d{4}-?\d{2}-?\d{2}/.test(name)) return true;
976
+ return P.dirname(parent) === norm(P.join(home, "Documents", "Qoder"), win) && /^\d{4}-\d{2}-\d{2}$/.test(P.basename(parent)) && /^[0-9a-f]{8}$/i.test(name);
977
+ }
978
+ function isReportableDir(p, opts = {}) {
979
+ if (!p) return false;
980
+ const win = (opts.platform ?? process.platform) === "win32";
981
+ const P = win ? win32 : posix;
982
+ const full = norm(p, win);
983
+ if (full === norm(P.parse(P.resolve(p)).root, win)) return false;
984
+ if (full === norm(opts.home ?? homedir3(), win)) return false;
985
+ const tmps = [opts.tmp ?? tmpdir()];
986
+ if (!win) tmps.push("/tmp", "/private/tmp", "/var/folders", "/private/var/folders");
987
+ for (const t of tmps) if (t && isUnder(full, norm(t, win), win)) return false;
988
+ return !isAutoWorkspace(p, opts);
989
+ }
990
+
991
+ // src/core/dirs.ts
956
992
  var DIRS_REPORT_MAX = 10;
957
993
  function dirsPath() {
958
994
  return join7(deskbuddyHome(), "dirs.json");
@@ -986,8 +1022,8 @@ function readMap() {
986
1022
  return {};
987
1023
  }
988
1024
  }
989
- function recentDirs(limit = DIRS_REPORT_MAX, exists = existsSync6) {
990
- const list = Object.entries(readMap()).filter(([p]) => p && exists(p)).sort((a, b) => b[1] - a[1]).slice(0, limit);
1025
+ function recentDirs(limit = DIRS_REPORT_MAX, exists = existsSync6, reportable = (p) => isReportableDir(p)) {
1026
+ const list = Object.entries(readMap()).filter(([p]) => p && reportable(p) && exists(p)).sort((a, b) => b[1] - a[1]).slice(0, limit);
991
1027
  const counts = /* @__PURE__ */ new Map();
992
1028
  for (const [p] of list) counts.set(basename3(p), (counts.get(basename3(p)) ?? 0) + 1);
993
1029
  return list.map(([p, at]) => {
@@ -1074,8 +1110,8 @@ function isExpired(cmd, now = Date.now()) {
1074
1110
  }
1075
1111
 
1076
1112
  // src/daemon/executor.ts
1077
- import { execFile as execFile2, execFileSync as execFileSync4 } from "child_process";
1078
- import { homedir as homedir4 } from "os";
1113
+ import { execFile as execFile2, execFileSync as execFileSync5 } from "child_process";
1114
+ import { homedir as homedir5 } from "os";
1079
1115
  import { join as join9 } from "path";
1080
1116
 
1081
1117
  // src/daemon/gate/settings.ts
@@ -1134,11 +1170,12 @@ function codexGateArgs(t) {
1134
1170
  }
1135
1171
 
1136
1172
  // src/daemon/locate.ts
1173
+ import { execFileSync as execFileSync4 } from "child_process";
1137
1174
  import { accessSync, constants, existsSync as existsSync8, lstatSync, readFileSync as readFileSync9, statSync as statSync2 } from "fs";
1138
- import { homedir as homedir3 } from "os";
1139
- import { delimiter, dirname as dirname4, join as join8 } from "path";
1175
+ import { homedir as homedir4 } from "os";
1176
+ import { delimiter, dirname as dirname4, join as join8, win32 as win322 } from "path";
1140
1177
  var defaultLocateDeps = () => ({
1141
- home: homedir3(),
1178
+ home: homedir4(),
1142
1179
  path: process.env.PATH ?? "",
1143
1180
  nodePath: process.execPath,
1144
1181
  isExecutable: (p) => {
@@ -1170,8 +1207,47 @@ var defaultLocateDeps = () => ({
1170
1207
  }
1171
1208
  },
1172
1209
  // 🔴 daemon 自己也不能找到垫片:远程任务要的是真程序(窗口接管 spec 2026-09-30 §3.1)
1173
- skipDirs: [join8(deskbuddyHome(), "bin")]
1210
+ skipDirs: [join8(deskbuddyHome(), "bin")],
1211
+ winAppHints: (product, exe) => winInstallDirs(product, exe)
1174
1212
  });
1213
+ function installDirsFromProcs(procs, exe) {
1214
+ const out = [];
1215
+ for (const p of procs ?? []) {
1216
+ const first = winSplitArgs(p.cmdline)[0] ?? "";
1217
+ if (win322.basename(first).toLowerCase() !== exe.toLowerCase()) continue;
1218
+ const dir = win322.dirname(first);
1219
+ if (win322.isAbsolute(dir) && !out.some((d) => d.toLowerCase() === dir.toLowerCase())) out.push(dir);
1220
+ }
1221
+ return out;
1222
+ }
1223
+ function parseUninstallDirs(out) {
1224
+ const dirs = [];
1225
+ for (const raw of out.split(/\r?\n/)) {
1226
+ let v = raw.trim().replace(/,\s*-?\d+$/, "").replace(/^"|"$/g, "").trim();
1227
+ if (!v || !win322.isAbsolute(v)) continue;
1228
+ if (/\.exe$/i.test(v)) v = win322.dirname(v);
1229
+ v = v.replace(/[\\/]+$/, "");
1230
+ if (!dirs.some((d) => d.toLowerCase() === v.toLowerCase())) dirs.push(v);
1231
+ }
1232
+ return dirs;
1233
+ }
1234
+ function winInstallDirs(product, exe) {
1235
+ const fromProcs = installDirsFromProcs(winProcs(), exe);
1236
+ let fromReg = [];
1237
+ try {
1238
+ const name = product.replace(/'/g, "''");
1239
+ const script = `[Console]::OutputEncoding=[Text.Encoding]::UTF8;Get-ItemProperty 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*','HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*','HKLM:\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*' -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like '${name}*' } | ForEach-Object { $_.InstallLocation; $_.DisplayIcon }`;
1240
+ const out = execFileSync4("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
1241
+ encoding: "utf8",
1242
+ stdio: ["ignore", "pipe", "ignore"],
1243
+ windowsHide: true,
1244
+ timeout: 1e4
1245
+ });
1246
+ fromReg = parseUninstallDirs(out);
1247
+ } catch {
1248
+ }
1249
+ return [...fromProcs, ...fromReg.filter((d) => !fromProcs.some((p) => p.toLowerCase() === d.toLowerCase()))];
1250
+ }
1175
1251
  var isWin = (d) => (d.platform ?? process.platform) === "win32";
1176
1252
  var WIN_EXTS = [".exe", ".cmd", ".bat"];
1177
1253
  function extraBinDirs(home, nodePath = "", win) {
@@ -1251,7 +1327,8 @@ function appDirs(home) {
1251
1327
  function winAppDirs(d, product) {
1252
1328
  return [
1253
1329
  ...d.localAppData ? [join8(d.localAppData, "Programs", product)] : [],
1254
- join8("C:\\Program Files", product)
1330
+ join8("C:\\Program Files", product),
1331
+ join8("C:\\Program Files (x86)", product)
1255
1332
  ];
1256
1333
  }
1257
1334
  var WORKBUDDY_CLI_IN_WIN_APP = "resources/app.asar.unpacked/cli/bin/codebuddy";
@@ -1277,16 +1354,19 @@ function locateWin(tool, d) {
1277
1354
  case "codex":
1278
1355
  return plain("codex");
1279
1356
  case "workbuddy": {
1280
- for (const dir of winAppDirs(d, "WorkBuddy")) {
1281
- for (const ext of [".exe", ".cmd", ""]) {
1282
- const p = join8(dir, WORKBUDDY_CLI_IN_WIN_APP + ext);
1283
- if (d.exists(p)) {
1284
- const r = resolveWinLaunch(p, d);
1285
- if (r) return r;
1357
+ const inApp = (dirs) => {
1358
+ for (const dir of dirs) {
1359
+ for (const ext of [".exe", ".cmd", ""]) {
1360
+ const p = join8(dir, WORKBUDDY_CLI_IN_WIN_APP + ext);
1361
+ if (d.exists(p)) {
1362
+ const r = resolveWinLaunch(p, d);
1363
+ if (r) return r;
1364
+ }
1286
1365
  }
1287
1366
  }
1288
- }
1289
- return plain("codebuddy");
1367
+ return null;
1368
+ };
1369
+ return inApp(winAppDirs(d, "WorkBuddy")) ?? plain("codebuddy") ?? inApp(d.winAppHints?.("WorkBuddy", "WorkBuddy.exe") ?? []);
1290
1370
  }
1291
1371
  case "qoder": {
1292
1372
  for (const dir of winAppDirs(d, "Qoder CN")) {
@@ -1424,7 +1504,7 @@ var TOOL_BINARY = {
1424
1504
  qoder: "qodercli",
1425
1505
  opencode: "opencode"
1426
1506
  };
1427
- function buildSpawnPlan(cmd, cwd, gate, home = homedir4()) {
1507
+ function buildSpawnPlan(cmd, cwd, gate, home = homedir5()) {
1428
1508
  const file = TOOL_BINARY[cmd.tool];
1429
1509
  if (!file) return null;
1430
1510
  switch (cmd.tool) {
@@ -1588,7 +1668,7 @@ function killTree(child) {
1588
1668
  if (!child) return;
1589
1669
  if (process.platform === "win32" && child.pid) {
1590
1670
  try {
1591
- execFileSync4("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
1671
+ execFileSync5("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
1592
1672
  return;
1593
1673
  } catch {
1594
1674
  }
@@ -1861,14 +1941,14 @@ function describe(toolName, toolInput, cwd) {
1861
1941
  }
1862
1942
 
1863
1943
  // src/daemon/gate/procs.ts
1864
- import { execFileSync as execFileSync5 } from "child_process";
1944
+ import { execFileSync as execFileSync6 } from "child_process";
1865
1945
  function listDescendants(rootPid) {
1866
1946
  if (process.platform === "win32") {
1867
1947
  return descendantsOf(rootPid, (winProcs() ?? []).map((p) => ({ pid: p.pid, ppid: p.ppid, args: p.cmdline })));
1868
1948
  }
1869
1949
  let out = "";
1870
1950
  try {
1871
- out = execFileSync5("ps", ["-A", "-ww", "-o", "pid=,ppid=,args="], {
1951
+ out = execFileSync6("ps", ["-A", "-ww", "-o", "pid=,ppid=,args="], {
1872
1952
  encoding: "utf8",
1873
1953
  stdio: ["ignore", "pipe", "ignore"],
1874
1954
  maxBuffer: 16 * 1024 * 1024
@@ -2746,7 +2826,7 @@ var SeenIds = class _SeenIds {
2746
2826
  };
2747
2827
 
2748
2828
  // src/daemon/codexWatch.ts
2749
- import { execFileSync as execFileSync6 } from "child_process";
2829
+ import { execFileSync as execFileSync7 } from "child_process";
2750
2830
  import WebSocket from "ws";
2751
2831
  var TURN_LIMIT_MS = 10 * 60 * 1e3;
2752
2832
  var QUEUE_MAX_MS = 10 * 60 * 1e3;
@@ -2791,7 +2871,7 @@ function discoverAppServers(configured) {
2791
2871
  return parseAppServerUrls((winProcs() ?? []).map((p) => p.cmdline).join("\n"));
2792
2872
  }
2793
2873
  try {
2794
- const out = execFileSync6("ps", ["-A", "-ww", "-o", "args="], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
2874
+ const out = execFileSync7("ps", ["-A", "-ww", "-o", "args="], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
2795
2875
  return parseAppServerUrls(out);
2796
2876
  } catch {
2797
2877
  return [];
@@ -3428,10 +3508,10 @@ import { chmodSync as chmodSync3, rmSync as rmSync7 } from "fs";
3428
3508
  import { connect, createServer as createServer2 } from "net";
3429
3509
 
3430
3510
  // src/core/wake.ts
3431
- import { execFileSync as execFileSync7 } from "child_process";
3511
+ import { execFileSync as execFileSync8 } from "child_process";
3432
3512
  import { createHash as createHash3, createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
3433
3513
  import { chmodSync as chmodSync2, closeSync as closeSync2, mkdirSync as mkdirSync14, openSync as openSync2, readdirSync as readdirSync3, readFileSync as readFileSync13, readSync as readSync2, renameSync as renameSync11, rmSync as rmSync6, statSync as statSync4, writeFileSync as writeFileSync13 } from "fs";
3434
- import { tmpdir } from "os";
3514
+ import { tmpdir as tmpdir2 } from "os";
3435
3515
  import { join as join17 } from "path";
3436
3516
  function wakeDir(home = deskbuddyHome()) {
3437
3517
  return join17(home, "wake");
@@ -3450,7 +3530,7 @@ function wakeChannelPath(platform = process.platform, home = deskbuddyHome()) {
3450
3530
  const primary = join17(wakeDir(home), "wake.sock");
3451
3531
  if (Buffer.byteLength(primary) <= 103) return primary;
3452
3532
  const uid = typeof process.getuid === "function" ? process.getuid() : 0;
3453
- return join17(tmpdir(), `deskbuddy-${uid}`, `wake-${homeTag(home)}.sock`);
3533
+ return join17(tmpdir2(), `deskbuddy-${uid}`, `wake-${homeTag(home)}.sock`);
3454
3534
  }
3455
3535
  function pidExists(pid) {
3456
3536
  if (!Number.isInteger(pid) || pid <= 0) return false;
@@ -3521,7 +3601,7 @@ function writeWakeSecret(hex, platform = process.platform) {
3521
3601
  writeFileSync13(tmp, hex, { encoding: "utf8", mode: 384 });
3522
3602
  if (platform === "win32") {
3523
3603
  try {
3524
- execFileSync7("icacls", [tmp, "/inheritance:r", "/grant:r", `${currentUserSid()}:F`], { stdio: "ignore", windowsHide: true });
3604
+ execFileSync8("icacls", [tmp, "/inheritance:r", "/grant:r", `${currentUserSid()}:F`], { stdio: "ignore", windowsHide: true });
3525
3605
  } catch {
3526
3606
  renameSync11(tmp, file);
3527
3607
  return "\u7A97\u53E3\u5524\u9192\u5BC6\u94A5\u6587\u4EF6\u7684\u6743\u9650\u6CA1\u80FD\u6536\u7D27\uFF08icacls \u5931\u8D25\uFF09";
@@ -3535,7 +3615,7 @@ function writeWakeSecret(hex, platform = process.platform) {
3535
3615
  function restrictToUser(file, platform = process.platform) {
3536
3616
  if (platform !== "win32") return true;
3537
3617
  try {
3538
- execFileSync7("icacls", [file, "/inheritance:r", "/grant:r", `${currentUserSid()}:F`], { stdio: "ignore", windowsHide: true });
3618
+ execFileSync8("icacls", [file, "/inheritance:r", "/grant:r", `${currentUserSid()}:F`], { stdio: "ignore", windowsHide: true });
3539
3619
  return true;
3540
3620
  } catch {
3541
3621
  return false;
@@ -3543,7 +3623,7 @@ function restrictToUser(file, platform = process.platform) {
3543
3623
  }
3544
3624
  function currentUserSid() {
3545
3625
  try {
3546
- const out = execFileSync7("whoami", ["/user", "/fo", "csv", "/nh"], { encoding: "utf8", windowsHide: true });
3626
+ const out = execFileSync8("whoami", ["/user", "/fo", "csv", "/nh"], { encoding: "utf8", windowsHide: true });
3547
3627
  const m = /"(S-1-[0-9-]+)"/.exec(out);
3548
3628
  if (m) return `*${m[1]}`;
3549
3629
  } catch {
@@ -3909,7 +3989,7 @@ function boxMatches(view, body) {
3909
3989
 
3910
3990
  // src/core/wrapWindows.ts
3911
3991
  import { chmodSync as chmodSync4, mkdirSync as mkdirSync15, readdirSync as readdirSync4, readFileSync as readFileSync14, renameSync as renameSync12, rmSync as rmSync8, statSync as statSync5, writeFileSync as writeFileSync14 } from "fs";
3912
- import { tmpdir as tmpdir2 } from "os";
3992
+ import { tmpdir as tmpdir3 } from "os";
3913
3993
  import { join as join18 } from "path";
3914
3994
  var WINDOW_ID_RE = /^[a-f0-9]{32}$/;
3915
3995
  function windowsDir() {
@@ -4001,7 +4081,7 @@ function channelPath(windowId, platform = process.platform, home = deskbuddyHome
4001
4081
  const primary = join18(home, "win", name);
4002
4082
  if (Buffer.byteLength(primary) <= 103) return primary;
4003
4083
  const uid = typeof process.getuid === "function" ? process.getuid() : 0;
4004
- return join18(tmpdir2(), `deskbuddy-${uid}`, name);
4084
+ return join18(tmpdir3(), `deskbuddy-${uid}`, name);
4005
4085
  }
4006
4086
  function ensureChannelDir(sockPath) {
4007
4087
  const dir = join18(sockPath, "..");
@@ -4077,7 +4157,7 @@ function launchPending(runId) {
4077
4157
  }
4078
4158
 
4079
4159
  // src/daemon/openWindow.ts
4080
- import { execFile as execFile3, execFileSync as execFileSync8, spawn as spawn2 } from "child_process";
4160
+ import { execFile as execFile3, execFileSync as execFileSync9, spawn as spawn2 } from "child_process";
4081
4161
  import { chmodSync as chmodSync5, mkdirSync as mkdirSync17, rmSync as rmSync10, writeFileSync as writeFileSync16 } from "fs";
4082
4162
  import { userInfo } from "os";
4083
4163
  import { join as join20 } from "path";
@@ -4128,14 +4208,14 @@ var defaultGuiDeps = () => ({
4128
4208
  env: process.env,
4129
4209
  macManager: () => {
4130
4210
  try {
4131
- return execFileSync8("launchctl", ["managername"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 3e3 });
4211
+ return execFileSync9("launchctl", ["managername"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 3e3 });
4132
4212
  } catch {
4133
4213
  return "";
4134
4214
  }
4135
4215
  },
4136
4216
  macConsoleUser: () => {
4137
4217
  try {
4138
- return execFileSync8("stat", ["-f", "%Su", "/dev/console"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 3e3 });
4218
+ return execFileSync9("stat", ["-f", "%Su", "/dev/console"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 3e3 });
4139
4219
  } catch {
4140
4220
  return "";
4141
4221
  }
@@ -4149,7 +4229,7 @@ var defaultGuiDeps = () => ({
4149
4229
  },
4150
4230
  winSessionId: () => {
4151
4231
  try {
4152
- const out = execFileSync8("tasklist", ["/FI", `PID eq ${process.pid}`, "/FO", "CSV", "/NH"], {
4232
+ const out = execFileSync9("tasklist", ["/FI", `PID eq ${process.pid}`, "/FO", "CSV", "/NH"], {
4153
4233
  encoding: "utf8",
4154
4234
  stdio: ["ignore", "pipe", "ignore"],
4155
4235
  windowsHide: true,
@@ -5567,8 +5647,8 @@ var FALLBACK = {
5567
5647
  format: "codex-toml",
5568
5648
  processNames: ["codex"],
5569
5649
  // codex-thread-name 是机会主义的一级:session_index.jsonl 里的 thread_name
5570
- // 原生只有 /rename 手动设,多数用户是空的,取不到就往下回落
5571
- titleSources: ["codex-thread-name", "git-branch", "project"],
5650
+ // 只有 /rename 或 VS Code 扩展起过名的才有,多数是空的,取不到用首条指令
5651
+ titleSources: ["codex-thread-name", "first-prompt", "git-branch", "project"],
5572
5652
  events: {
5573
5653
  UserPromptSubmit: "watch",
5574
5654
  PreToolUse: "watch",
@@ -5581,7 +5661,9 @@ var FALLBACK = {
5581
5661
  workbuddy: {
5582
5662
  format: "cc-hook",
5583
5663
  processNames: ["WorkBuddy"],
5584
- titleSources: ["git-branch", "project"],
5664
+ // WorkBuddy 的 transcript 与 Claude Code 同格式,也写 ai-title(2026-10-01 WorkBuddy 5.6.2 实测);
5665
+ // 工作目录是按时间自动建的,目录名不能当标题
5666
+ titleSources: ["cc-ai-title", "first-prompt", "git-branch", "project"],
5585
5667
  events: {
5586
5668
  UserPromptSubmit: "watch",
5587
5669
  PreToolUse: "watch",
@@ -5594,7 +5676,9 @@ var FALLBACK = {
5594
5676
  qoder: {
5595
5677
  format: "cc-hook",
5596
5678
  processNames: ["Qoder CN"],
5597
- titleSources: ["git-branch", "project"],
5679
+ // 桌面端会话库里的标题(先是首条指令,后被换成 AI 摘要);qodercli 命令行版的会话不在库里,落到首条指令
5680
+ // (CLI 1.1.61 实测 transcript 里没有 ai-title,UserPromptSubmit 带 prompt)
5681
+ titleSources: ["qoder-session-title", "first-prompt", "git-branch", "project"],
5598
5682
  events: {
5599
5683
  UserPromptSubmit: "watch",
5600
5684
  PreToolUse: "watch",
@@ -5607,8 +5691,8 @@ var FALLBACK = {
5607
5691
  opencode: {
5608
5692
  format: "oc-plugin",
5609
5693
  processNames: ["opencode"],
5610
- // opencode 自带的会话标题是模型对提问的摘要,违反隐私约束(spec §6.4),不用
5611
- titleSources: ["git-branch", "project"],
5694
+ // opencode 自己生成的会话标题(插件从 session.updated 取,随事件传 oc_session_title),没有就用首条指令
5695
+ titleSources: ["oc-session-title", "first-prompt", "git-branch", "project"],
5612
5696
  events: {
5613
5697
  UserPromptSubmit: "watch",
5614
5698
  PreToolUse: "watch",
@@ -5952,9 +6036,9 @@ function subscribedEvents(registry, tool, fallback) {
5952
6036
  import { dirname as dirname9, join as join26 } from "path";
5953
6037
 
5954
6038
  // src/wrap/shim.ts
5955
- import { execFileSync as execFileSync9 } from "child_process";
6039
+ import { execFileSync as execFileSync10 } from "child_process";
5956
6040
  import { chmodSync as chmodSync8, existsSync as existsSync14, mkdirSync as mkdirSync21, readdirSync as readdirSync5, readFileSync as readFileSync20, rmdirSync, rmSync as rmSync14, writeFileSync as writeFileSync21 } from "fs";
5957
- import { homedir as homedir5 } from "os";
6041
+ import { homedir as homedir6 } from "os";
5958
6042
  import { delimiter as delimiter2, join as join24, resolve as resolve2 } from "path";
5959
6043
  var SHIM_FALLBACK_EXIT = 213;
5960
6044
  var WIN_SHIM_ENABLED = false;
@@ -6122,7 +6206,7 @@ function removePathEntry(value, dir) {
6122
6206
  var PS_READ = "$k=[Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment');$v='';if($k){$v=$k.GetValue('Path','',[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)};[Console]::Out.Write([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$v)))";
6123
6207
  var PS_WRITE = "$k=[Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment');$kind=[Microsoft.Win32.RegistryValueKind]::ExpandString;try{$kind=$k.GetValueKind('Path')}catch{};if([string]::IsNullOrEmpty($env:DESKBUDDY_NEW_PATH)){$k.DeleteValue('Path',$false)}else{$k.SetValue('Path',$env:DESKBUDDY_NEW_PATH,$kind)};[Environment]::SetEnvironmentVariable('DESKBUDDY_PATH_REFRESH','1','User');[Environment]::SetEnvironmentVariable('DESKBUDDY_PATH_REFRESH',$null,'User')";
6124
6208
  function psWinPath() {
6125
- const ps = (script, env) => execFileSync9("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], {
6209
+ const ps = (script, env) => execFileSync10("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], {
6126
6210
  encoding: "utf8",
6127
6211
  stdio: ["ignore", "pipe", "ignore"],
6128
6212
  windowsHide: true,
@@ -6145,7 +6229,7 @@ function psWinPath() {
6145
6229
  function defaultShimEnv(nodePath, cliPath) {
6146
6230
  const env = process.env;
6147
6231
  const isolated = !!env.VITEST || !!env.DESKBUDDY_HOME;
6148
- const rcHome = env.DESKBUDDY_RC_HOME || (isolated ? null : homedir5());
6232
+ const rcHome = env.DESKBUDDY_RC_HOME || (isolated ? null : homedir6());
6149
6233
  return {
6150
6234
  platform: process.platform,
6151
6235
  binDir: shimDir(),
@@ -6563,9 +6647,9 @@ import qrcode from "qrcode-terminal";
6563
6647
  // src/commands/remoteCwd.ts
6564
6648
  import { existsSync as existsSync17, statSync as statSync9 } from "fs";
6565
6649
  import { createInterface } from "readline";
6566
- import { homedir as homedir6 } from "os";
6650
+ import { homedir as homedir7 } from "os";
6567
6651
  import { isAbsolute as isAbsolute2, resolve as resolve3 } from "path";
6568
- function normalizePath(raw, home = homedir6()) {
6652
+ function normalizePath(raw, home = homedir7()) {
6569
6653
  const s = (raw || "").trim();
6570
6654
  if (!s) return null;
6571
6655
  const expanded = s === "~" ? home : s.startsWith("~/") || process.platform === "win32" && s.startsWith("~\\") ? resolve3(home, s.slice(2)) : s;
@@ -6747,8 +6831,8 @@ var TOOL_LABELS = {
6747
6831
  opencode: "opencode"
6748
6832
  };
6749
6833
  function checkApiBase(raw, env = process.env) {
6750
- const norm2 = raw.replace(/\/+$/, "");
6751
- return norm2 === PROD_API_BASE || norm2 === TEST_API_BASE || env.DESKBUDDY_ALLOW_CUSTOM_API === "1";
6834
+ const norm3 = raw.replace(/\/+$/, "");
6835
+ return norm3 === PROD_API_BASE || norm3 === TEST_API_BASE || env.DESKBUDDY_ALLOW_CUSTOM_API === "1";
6752
6836
  }
6753
6837
  function parseCode(argv) {
6754
6838
  const eq = argv.find((a) => a.startsWith("--code="));
@@ -7407,7 +7491,7 @@ function passthroughReason(i) {
7407
7491
  if (firstPositional !== null && CLAUDE_SUBCOMMANDS.has(firstPositional)) return "subcommand";
7408
7492
  return null;
7409
7493
  }
7410
- function norm(p, win) {
7494
+ function norm2(p, win) {
7411
7495
  const r = resolve4(p).replace(/[\\/]+$/, "");
7412
7496
  return win ? r.replace(/\//g, "\\").toLowerCase() : r;
7413
7497
  }
@@ -7420,8 +7504,8 @@ function real(p) {
7420
7504
  }
7421
7505
  function findReal(tool, d = defaultLocateDeps(), skip = shimDir()) {
7422
7506
  const win = (d.platform ?? process.platform) === "win32";
7423
- const skipKeys = /* @__PURE__ */ new Set([norm(skip, win), norm(real(skip), win)]);
7424
- const inSkip = (file) => skipKeys.has(norm(dirname11(file), win)) || skipKeys.has(norm(dirname11(real(file)), win));
7507
+ const skipKeys = /* @__PURE__ */ new Set([norm2(skip, win), norm2(real(skip), win)]);
7508
+ const inSkip = (file) => skipKeys.has(norm2(dirname11(file), win)) || skipKeys.has(norm2(dirname11(real(file)), win));
7425
7509
  const dd = { ...d, skipDirs: [...d.skipDirs ?? [], skip] };
7426
7510
  const name = SHIM_COMMANDS[tool];
7427
7511
  if (!name) {
@@ -7507,14 +7591,14 @@ import { createServer as createServer4 } from "net";
7507
7591
  import { join as join28 } from "path";
7508
7592
 
7509
7593
  // src/core/codexServer.ts
7510
- import { execFileSync as execFileSync10 } from "child_process";
7594
+ import { execFileSync as execFileSync11 } from "child_process";
7511
7595
  function isAppServerArgs(args, url) {
7512
7596
  return /\bapp-server\b/.test(args) && new RegExp(`--listen[= ]${url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:\\s|$)`).test(args);
7513
7597
  }
7514
7598
  function processArgs(pid) {
7515
7599
  if (process.platform === "win32") return null;
7516
7600
  try {
7517
- const out = execFileSync10("ps", ["-ww", "-o", "args=", "-p", String(pid)], {
7601
+ const out = execFileSync11("ps", ["-ww", "-o", "args=", "-p", String(pid)], {
7518
7602
  encoding: "utf8",
7519
7603
  stdio: ["ignore", "pipe", "ignore"]
7520
7604
  });
package/dist/hook.cjs CHANGED
@@ -86,8 +86,8 @@ const FALLBACK_REGISTRY = {
86
86
  format: 'codex-toml',
87
87
  processNames: ['codex'],
88
88
  // codex-thread-name 是机会主义的一级:session_index.jsonl 里的 thread_name
89
- // 原生只有 /rename 手动设,多数用户是空的,取不到就往下回落
90
- titleSources: ['codex-thread-name', 'git-branch', 'project'],
89
+ // 只有 /rename 或 VS Code 扩展起过名的才有,多数是空的,取不到用首条指令
90
+ titleSources: ['codex-thread-name', 'first-prompt', 'git-branch', 'project'],
91
91
  events: {
92
92
  UserPromptSubmit: 'watch',
93
93
  PreToolUse: 'watch',
@@ -100,7 +100,9 @@ const FALLBACK_REGISTRY = {
100
100
  workbuddy: {
101
101
  format: 'cc-hook',
102
102
  processNames: ['WorkBuddy'],
103
- titleSources: ['git-branch', 'project'],
103
+ // WorkBuddy 的 transcript 与 Claude Code 同格式,也写 ai-title(2026-10-01 WorkBuddy 5.6.2 实测);
104
+ // 工作目录是按时间自动建的,目录名不能当标题
105
+ titleSources: ['cc-ai-title', 'first-prompt', 'git-branch', 'project'],
104
106
  events: {
105
107
  UserPromptSubmit: 'watch',
106
108
  PreToolUse: 'watch',
@@ -113,7 +115,9 @@ const FALLBACK_REGISTRY = {
113
115
  qoder: {
114
116
  format: 'cc-hook',
115
117
  processNames: ['Qoder CN'],
116
- titleSources: ['git-branch', 'project'],
118
+ // 桌面端会话库里的标题(先是首条指令,后被换成 AI 摘要);qodercli 命令行版的会话不在库里,落到首条指令
119
+ // (CLI 1.1.61 实测 transcript 里没有 ai-title,UserPromptSubmit 带 prompt)
120
+ titleSources: ['qoder-session-title', 'first-prompt', 'git-branch', 'project'],
117
121
  events: {
118
122
  UserPromptSubmit: 'watch',
119
123
  PreToolUse: 'watch',
@@ -126,8 +130,8 @@ const FALLBACK_REGISTRY = {
126
130
  opencode: {
127
131
  format: 'oc-plugin',
128
132
  processNames: ['opencode'],
129
- // opencode 自带的会话标题是模型对提问的摘要,违反隐私约束(spec §6.4),不用
130
- titleSources: ['git-branch', 'project'],
133
+ // opencode 自己生成的会话标题(插件从 session.updated 取,随事件传 oc_session_title),没有就用首条指令
134
+ titleSources: ['oc-session-title', 'first-prompt', 'git-branch', 'project'],
131
135
  events: {
132
136
  UserPromptSubmit: 'watch',
133
137
  PreToolUse: 'watch',
@@ -321,7 +325,137 @@ function readGitBranch(cwd, maxUp) {
321
325
  return null
322
326
  }
323
327
 
324
- /** 按 registry 给的顺序逐级回落。所有来源都不碰 prompt 原文。 */
328
+ /** 首条指令当标题时的最大字数(按字符),与 src/core/title.ts 一致 */
329
+ const FIRST_PROMPT_MAX = 30
330
+
331
+ /** 用户指令 → 可上屏的短标题,与 src/core/title.ts 的 cleanPrompt 一致 */
332
+ function cleanPrompt(text) {
333
+ if (typeof text !== 'string') return ''
334
+ // eslint-disable-next-line no-control-regex
335
+ const flat = text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim()
336
+ const chars = Array.from(flat)
337
+ return chars.length > FIRST_PROMPT_MAX ? chars.slice(0, FIRST_PROMPT_MAX).join('').trimEnd() + '…' : flat
338
+ }
339
+
340
+ /** 会话库标题的最大字数,与 src/core/title.ts 一致 */
341
+ const SESSION_TITLE_MAX = 100
342
+
343
+ /** 外部来的标题清洗,与 src/core/title.ts 的 cleanTitle 一致 */
344
+ function cleanTitle(text) {
345
+ if (typeof text !== 'string') return ''
346
+ // eslint-disable-next-line no-control-regex
347
+ const flat = text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim()
348
+ return Array.from(flat).slice(0, SESSION_TITLE_MAX).join('').trimEnd()
349
+ }
350
+
351
+ /**
352
+ * node:sqlite 是 Node 内置模块(22.13+ / 23.4+ 免 flag),更老的 Node require 直接抛,调用方跳过。
353
+ * 加载时的 ExperimentalWarning 吞掉,免得打进智能体的 hook 输出
354
+ */
355
+ function loadNodeSqlite() {
356
+ const orig = process.emitWarning
357
+ process.emitWarning = function () {
358
+ if (String(arguments[0] == null ? '' : arguments[0]).indexOf('SQLite') !== -1) return
359
+ return orig.apply(process, arguments)
360
+ }
361
+ try {
362
+ return require('node:sqlite')
363
+ } finally {
364
+ process.emitWarning = orig
365
+ }
366
+ }
367
+
368
+ /** Qoder 桌面端会话库候选路径,与 src/core/title.ts 的 qoderDbPaths 一致(来源与未核对项见那边注释) */
369
+ function qoderDbPaths(platform, home, env) {
370
+ const pf = platform || process.platform
371
+ const h = home || os.homedir()
372
+ const e = env || process.env
373
+ let root
374
+ if (pf === 'darwin') root = path.join(h, 'Library', 'Application Support')
375
+ else if (pf === 'win32') root = e.APPDATA || path.join(h, 'AppData', 'Roaming')
376
+ else root = e.XDG_CONFIG_HOME || path.join(h, '.config')
377
+ return ['com.qodercn.app.stable', 'com.qoder.app.stable'].map((d) => path.join(root, d, 'main.sqlite'))
378
+ }
379
+
380
+ /** Qoder 桌面端的会话标题(chat_sessions.title),与 src/core/title.ts 一致。任何失败返回 null */
381
+ function readQoderSessionTitle(sessionId, paths, load) {
382
+ if (!sessionId) return null
383
+ const existing = (paths || qoderDbPaths()).filter((p) => fs.existsSync(p))
384
+ if (existing.length === 0) return null
385
+ let mod
386
+ try {
387
+ mod = (load || loadNodeSqlite)()
388
+ } catch (e) {
389
+ return null
390
+ }
391
+ for (const p of existing) {
392
+ let db
393
+ try {
394
+ db = new mod.DatabaseSync(p, { readOnly: true })
395
+ const row = db.prepare('select title from chat_sessions where session_id = ?').get(sessionId)
396
+ const t = cleanTitle(row && row.title)
397
+ if (t) return t
398
+ } catch (e) {
399
+ /* 这个库读不了,试下一个 */
400
+ } finally {
401
+ try {
402
+ if (db) db.close()
403
+ } catch (e) {
404
+ /* ignore */
405
+ }
406
+ }
407
+ }
408
+ return null
409
+ }
410
+
411
+ // ---------- 非项目目录(与 src/core/dirFilter.ts 同构) ----------
412
+
413
+ function dirNorm(p, win) {
414
+ if (win) return path.win32.resolve(p).replace(/[\\/]+$/, '').toLowerCase()
415
+ return path.posix.resolve(p).replace(/\/+$/, '')
416
+ }
417
+
418
+ function dirUnder(p, base, win) {
419
+ return p === base || p.startsWith(base + (win ? '\\' : '/'))
420
+ }
421
+
422
+ /**
423
+ * 智能体按时间自动建的工作区。WorkBuddy:<主目录>/WorkBuddy/<日期开头>(5.6.2 实测 2026-09-24-18-50-03 /
424
+ * 20260430143452 / 2026-05-16-task-1);Qoder CN:<主目录>/Documents/Qoder/<YYYY-MM-DD>/<8 位十六进制>
425
+ */
426
+ function isAutoWorkspace(p, opts) {
427
+ if (!p) return false
428
+ const o = opts || {}
429
+ const win = (o.platform || process.platform) === 'win32'
430
+ const P = win ? path.win32 : path.posix
431
+ const home = dirNorm(o.home || os.homedir(), win)
432
+ const full = dirNorm(p, win)
433
+ const name = P.basename(full)
434
+ const parent = P.dirname(full)
435
+ if (parent === dirNorm(P.join(home, 'WorkBuddy'), win) && /^\d{4}-?\d{2}-?\d{2}/.test(name)) return true
436
+ return (
437
+ P.dirname(parent) === dirNorm(P.join(home, 'Documents', 'Qoder'), win) &&
438
+ /^\d{4}-\d{2}-\d{2}$/.test(P.basename(parent)) &&
439
+ /^[0-9a-f]{8}$/i.test(name)
440
+ )
441
+ }
442
+
443
+ /** 能不能作为最近项目目录上报:主目录 / 根目录 / 临时目录及其下 / 自动工作区都不报 */
444
+ function isReportableDir(p, opts) {
445
+ if (!p) return false
446
+ const o = opts || {}
447
+ const win = (o.platform || process.platform) === 'win32'
448
+ const P = win ? path.win32 : path.posix
449
+ const full = dirNorm(p, win)
450
+ if (full === dirNorm(P.parse(P.resolve(p)).root, win)) return false
451
+ if (full === dirNorm(o.home || os.homedir(), win)) return false
452
+ const tmps = [o.tmp || os.tmpdir()]
453
+ if (!win) tmps.push('/tmp', '/private/tmp', '/var/folders', '/private/var/folders')
454
+ for (const t of tmps) if (t && dirUnder(full, dirNorm(t, win), win)) return false
455
+ return !isAutoWorkspace(p, o)
456
+ }
457
+
458
+ /** 按 registry 给的顺序逐级回落。只有 first-prompt 用到 prompt(首条指令前 30 字),其余来源不碰 prompt 原文。 */
325
459
  function resolveTitle(sources, ctx) {
326
460
  const list = sources || []
327
461
  for (const source of list) {
@@ -329,7 +463,10 @@ function resolveTitle(sources, ctx) {
329
463
  if (source === 'cc-ai-title') value = readAiTitle(ctx.transcriptPath)
330
464
  else if (source === 'codex-thread-name') value = readCodexThreadName(ctx.sessionId, ctx.codexHome)
331
465
  else if (source === 'git-branch') value = readGitBranch(ctx.cwd)
332
- else if (source === 'project') value = path.basename(ctx.cwd || '') || null
466
+ else if (source === 'first-prompt') value = ctx.firstPrompt || null
467
+ else if (source === 'qoder-session-title') value = readQoderSessionTitle(ctx.sessionId, ctx.qoderDbPaths, ctx.loadSqlite)
468
+ else if (source === 'oc-session-title') value = typeof ctx.ocSessionTitle === 'string' ? ctx.ocSessionTitle.trim() || null : null
469
+ else if (source === 'project') value = ctx.cwd && !isAutoWorkspace(ctx.cwd) ? path.basename(ctx.cwd) || null : null
333
470
  // 不认识的来源直接跳过:新来源应该先发版再进 registry
334
471
  if (value) return value
335
472
  }
@@ -949,7 +1086,8 @@ const DIRS_KEEP = 30
949
1086
 
950
1087
  /** UserPromptSubmit 时记下工作目录:{全路径: 最近使用毫秒}。🔴 全路径只留本机 */
951
1088
  function recordDir(cwd, now) {
952
- if (!cwd) return
1089
+ // 主目录 / 临时目录 / 自动工作区不记,免得把真正的项目挤出 DIRS_KEEP
1090
+ if (!cwd || !isReportableDir(cwd)) return
953
1091
  const file = path.join(deskbuddyHome(), 'dirs.json')
954
1092
  let map = {}
955
1093
  try {
@@ -1077,6 +1215,19 @@ function codexRemoteHas(sessionId) {
1077
1215
  return !!r && r.has(sessionId)
1078
1216
  }
1079
1217
 
1218
+ /** 并发 hook 的先后差不会超过一次 hook 的执行时长;超出这个差值说明是时钟回拨,不能当作「更晚的事件」 */
1219
+ const SUPERSEDE_MAX_MS = 2 * 60 * 1000
1220
+
1221
+ /**
1222
+ * 状态里已是比这次事件更晚发生的事件写的(hook 并发、先发生的后写完),这次不能再覆盖。
1223
+ * 🔴 差值带上限(评审):开机后校时往回拨时,状态里的 updatedAt 落在「未来」,不设上限的话在墙钟追上之前该会话的事件全被丢掉
1224
+ */
1225
+ function superseded(cur, eventAt) {
1226
+ if (!cur || typeof cur.updatedAt !== 'number') return false
1227
+ const ahead = cur.updatedAt - eventAt
1228
+ return ahead > 0 && ahead < SUPERSEDE_MAX_MS
1229
+ }
1230
+
1080
1231
  async function main(argv) {
1081
1232
  // 同一份 hook 被 Claude Code 与 Codex 复用,靠命令行参数区分是哪个工具
1082
1233
  const tool = (argv && argv[2]) || 'claude_code'
@@ -1084,6 +1235,10 @@ async function main(argv) {
1084
1235
  const event = payload.hook_event_name || ''
1085
1236
  const sessionId = payload.session_id || ''
1086
1237
  if (!sessionId) return
1238
+ // 🔴 事件时间取在任何慢操作之前:Codex 的 hook 是 async(不等跑完),一轮很短时 UserPromptSubmit 与 Stop
1239
+ // 的 hook 并发跑;Windows 上新会话首个 hook 要起 PowerShell 查进程,常比 Stop 晚写完,
1240
+ // 按写完先后落盘就会把 done 覆盖回 watch,屏上一直「进行中」直到窗口关掉(10-01 WIN-LVT5M8H5670 实测)
1241
+ const eventAt = Date.now()
1087
1242
 
1088
1243
  if (NOTICE_EVENTS[event]) {
1089
1244
  const notice = takeNotice(sessionId)
@@ -1140,6 +1295,13 @@ async function main(argv) {
1140
1295
  const prev = readState(sessionId)
1141
1296
  const now = Date.now()
1142
1297
  if (event === 'UserPromptSubmit') recordDir(payload.cwd, now)
1298
+ // 首条指令(标题来源 first-prompt):只记第一次
1299
+ const firstPrompt = (prev && prev.firstPrompt) || (event === 'UserPromptSubmit' ? cleanPrompt(payload.prompt) : '') || undefined
1300
+ if (superseded(prev, eventAt)) {
1301
+ // 被并发的更晚事件(多半是同一轮的 Stop)抢先写了:状态不覆盖,但首条指令要补上,不然标题就成了第二条指令
1302
+ if (firstPrompt && !prev.firstPrompt) writeState(Object.assign({}, prev, { firstPrompt: firstPrompt }))
1303
+ return
1304
+ }
1143
1305
  // 状态里记的进程还活着才沿用(评审):先远程 -p 跑过、后又在窗口 resume 同一会话时,
1144
1306
  // 不能拿死掉的 -p 进程的 interactive=false 挡不住窗口
1145
1307
  const prevAlive = !!(prev && prev.pid && pidExists(prev.pid))
@@ -1158,8 +1320,9 @@ async function main(argv) {
1158
1320
  // ⚠️ 只落本机,绝不上报:快照里发出去的仍然只有 basename。
1159
1321
  cwd: payload.cwd || (prev && prev.cwd) || '',
1160
1322
  state: action,
1161
- startedAt: (prev && prev.startedAt) || now,
1162
- updatedAt: now,
1323
+ startedAt: (prev && prev.startedAt) || eventAt,
1324
+ // 记事件发生时间而不是写完时间,后来的事件才比得过先发生、却后写完的那个
1325
+ updatedAt: eventAt,
1163
1326
  pid: pid,
1164
1327
  pidName: pid ? (prevAlive && prev.pidName) || processName(pid) || undefined : undefined,
1165
1328
  // 二期 P1:窗口开着的会话,设备不能再往里下指令(两个进程会互相吞掉对话)。每个会话只判一次
@@ -1177,11 +1340,19 @@ async function main(argv) {
1177
1340
  at: now,
1178
1341
  },
1179
1342
  ])).slice(-RECENT_MAX),
1343
+ firstPrompt: firstPrompt,
1180
1344
  }
1181
1345
 
1182
1346
  // 进 / 出 DeskBuddy 窗口不一定伴随状态跃迁(窗口里 --resume 一个已完成的会话),要立刻报,否则设备最长 2 分钟仍被 8348 挡
1183
1347
  const windowChanged = !!(prev && prev.remoteWindow) !== !!next.remoteWindow
1184
- if (!windowChanged && !shouldReport(prev, next, now)) {
1348
+ // 跃迁要跟磁盘上的最新状态比,不能只跟开头读到的 prev 比(评审):上一轮停在 done 时 Stop 读到的 prev 是旧 done,
1349
+ // 并发的 UserPromptSubmit 已把 watch 写盘并上报了,Stop 若按 done→done 静默落盘,设备收到的最后一份就是 watch
1350
+ const latest = readState(sessionId)
1351
+ if (superseded(latest, eventAt)) {
1352
+ if (next.firstPrompt && !latest.firstPrompt) writeState(Object.assign({}, latest, { firstPrompt: next.firstPrompt }))
1353
+ return
1354
+ }
1355
+ if (!windowChanged && !shouldReport(latest || prev, next, now)) {
1185
1356
  writeState(next)
1186
1357
  return
1187
1358
  }
@@ -1193,10 +1364,18 @@ async function main(argv) {
1193
1364
  transcriptPath: payload.transcript_path,
1194
1365
  cwd: next.project,
1195
1366
  sessionId: sessionId,
1367
+ firstPrompt: next.firstPrompt,
1368
+ ocSessionTitle: payload.oc_session_title,
1196
1369
  }) ||
1197
1370
  next.title ||
1198
1371
  ''
1199
1372
  next.lastReportAt = now
1373
+ // 取标题 / 查进程期间可能已有更晚发生的事件写完了:那份更新,这次既不落盘也不上报
1374
+ const last = readState(sessionId)
1375
+ if (superseded(last, eventAt)) {
1376
+ if (next.firstPrompt && !last.firstPrompt) writeState(Object.assign({}, last, { firstPrompt: next.firstPrompt }))
1377
+ return
1378
+ }
1200
1379
  writeState(next)
1201
1380
 
1202
1381
  await reportIfAuthorized(tool, now)
@@ -1236,6 +1415,14 @@ module.exports = {
1236
1415
  readAliveStates,
1237
1416
  readCodexThreadName,
1238
1417
  readGitBranch,
1418
+ cleanPrompt,
1419
+ cleanTitle,
1420
+ FIRST_PROMPT_MAX,
1421
+ SESSION_TITLE_MAX,
1422
+ qoderDbPaths,
1423
+ readQoderSessionTitle,
1424
+ isAutoWorkspace,
1425
+ isReportableDir,
1239
1426
  readState,
1240
1427
  removeState,
1241
1428
  retireState,
@@ -1248,6 +1435,7 @@ module.exports = {
1248
1435
  markClear,
1249
1436
  pairClear,
1250
1437
  shouldReport,
1438
+ superseded,
1251
1439
  sign,
1252
1440
  titleSources,
1253
1441
  writeState,
package/dist/opencode.mjs CHANGED
@@ -130,6 +130,26 @@ export function openPermissions(cfg) {
130
130
  if (build && build.permission && typeof build.permission === 'object') open(build.permission)
131
131
  }
132
132
 
133
+ /** chat.message 的 output({ message, parts })里用户自己打的字:text 类 part,跳过 synthetic(插件 / 系统补的) */
134
+ export function userText(output) {
135
+ const parts = output && Array.isArray(output.parts) ? output.parts : []
136
+ return parts
137
+ .filter((p) => p && p.type === 'text' && !p.synthetic && typeof p.text === 'string')
138
+ .map((p) => p.text)
139
+ .join('\n')
140
+ }
141
+
142
+ /** opencode 起标题前的占位名(1.18.32 isDefaultTitle:`New session - ` / `Child session - ` + ISO 时间),不当标题 */
143
+ const OC_DEFAULT_TITLE_RE = /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
144
+
145
+ /** session.updated 的 info → 记下自动标题。子会话(subagent,带 parentID)、占位名不记 */
146
+ export function rememberTitle(titles, info) {
147
+ if (!info || typeof info.id !== 'string' || !info.id || info.parentID) return
148
+ const t = typeof info.title === 'string' ? info.title.trim() : ''
149
+ if (!t || OC_DEFAULT_TITLE_RE.test(t)) return
150
+ titles.set(info.id, t.slice(0, 100))
151
+ }
152
+
133
153
  /** 活着的子进程;opencode 退出时一并结束,不留孤儿 gate 挂着设备卡片(评审) */
134
154
  const LIVE = new Set()
135
155
  process.on('exit', () => {
@@ -183,9 +203,12 @@ export function make(opts = {}) {
183
203
 
184
204
  // ---- 状态上报:串行喂 hook.cjs,保证 PreToolUse / PostToolUse / Stop 的先后 ----
185
205
  let queue = Promise.resolve()
206
+ const titles = new Map() // sessionID → opencode 自动生成的标题(session.updated 里来)
186
207
  const report = (event, sessionID, extra) => {
187
208
  if (!sessionID) return
188
209
  const input = { hook_event_name: event, session_id: sessionID, cwd, ...extra }
210
+ const title = titles.get(sessionID)
211
+ if (title) input.oc_session_title = title
189
212
  queue = queue.then(() => runChild(node, HOOK, [TOOL], input, HOOK_KILL_MS)).catch(() => {})
190
213
  }
191
214
 
@@ -273,8 +296,9 @@ export function make(opts = {}) {
273
296
  if (runId) openPermissions(cfg)
274
297
  },
275
298
 
276
- 'chat.message': async (input) => {
277
- report('UserPromptSubmit', input && input.sessionID)
299
+ 'chat.message': async (input, output) => {
300
+ // prompt 给 hook 的 first-prompt 标题来源用(只记首条,见 hook.cjs)
301
+ report('UserPromptSubmit', input && input.sessionID, { prompt: userText(output) })
278
302
  },
279
303
 
280
304
  'tool.execute.before': async (input, output) => {
@@ -318,6 +342,9 @@ export function make(opts = {}) {
318
342
  case 'session.idle':
319
343
  report('Stop', p.sessionID)
320
344
  break
345
+ case 'session.updated':
346
+ rememberTitle(titles, p.info)
347
+ break
321
348
  case 'session.deleted':
322
349
  report('SessionEnd', (p.info && p.info.id) || p.sessionID)
323
350
  break
package/hook/hook.cjs CHANGED
@@ -86,8 +86,8 @@ const FALLBACK_REGISTRY = {
86
86
  format: 'codex-toml',
87
87
  processNames: ['codex'],
88
88
  // codex-thread-name 是机会主义的一级:session_index.jsonl 里的 thread_name
89
- // 原生只有 /rename 手动设,多数用户是空的,取不到就往下回落
90
- titleSources: ['codex-thread-name', 'git-branch', 'project'],
89
+ // 只有 /rename 或 VS Code 扩展起过名的才有,多数是空的,取不到用首条指令
90
+ titleSources: ['codex-thread-name', 'first-prompt', 'git-branch', 'project'],
91
91
  events: {
92
92
  UserPromptSubmit: 'watch',
93
93
  PreToolUse: 'watch',
@@ -100,7 +100,9 @@ const FALLBACK_REGISTRY = {
100
100
  workbuddy: {
101
101
  format: 'cc-hook',
102
102
  processNames: ['WorkBuddy'],
103
- titleSources: ['git-branch', 'project'],
103
+ // WorkBuddy 的 transcript 与 Claude Code 同格式,也写 ai-title(2026-10-01 WorkBuddy 5.6.2 实测);
104
+ // 工作目录是按时间自动建的,目录名不能当标题
105
+ titleSources: ['cc-ai-title', 'first-prompt', 'git-branch', 'project'],
104
106
  events: {
105
107
  UserPromptSubmit: 'watch',
106
108
  PreToolUse: 'watch',
@@ -113,7 +115,9 @@ const FALLBACK_REGISTRY = {
113
115
  qoder: {
114
116
  format: 'cc-hook',
115
117
  processNames: ['Qoder CN'],
116
- titleSources: ['git-branch', 'project'],
118
+ // 桌面端会话库里的标题(先是首条指令,后被换成 AI 摘要);qodercli 命令行版的会话不在库里,落到首条指令
119
+ // (CLI 1.1.61 实测 transcript 里没有 ai-title,UserPromptSubmit 带 prompt)
120
+ titleSources: ['qoder-session-title', 'first-prompt', 'git-branch', 'project'],
117
121
  events: {
118
122
  UserPromptSubmit: 'watch',
119
123
  PreToolUse: 'watch',
@@ -126,8 +130,8 @@ const FALLBACK_REGISTRY = {
126
130
  opencode: {
127
131
  format: 'oc-plugin',
128
132
  processNames: ['opencode'],
129
- // opencode 自带的会话标题是模型对提问的摘要,违反隐私约束(spec §6.4),不用
130
- titleSources: ['git-branch', 'project'],
133
+ // opencode 自己生成的会话标题(插件从 session.updated 取,随事件传 oc_session_title),没有就用首条指令
134
+ titleSources: ['oc-session-title', 'first-prompt', 'git-branch', 'project'],
131
135
  events: {
132
136
  UserPromptSubmit: 'watch',
133
137
  PreToolUse: 'watch',
@@ -321,7 +325,137 @@ function readGitBranch(cwd, maxUp) {
321
325
  return null
322
326
  }
323
327
 
324
- /** 按 registry 给的顺序逐级回落。所有来源都不碰 prompt 原文。 */
328
+ /** 首条指令当标题时的最大字数(按字符),与 src/core/title.ts 一致 */
329
+ const FIRST_PROMPT_MAX = 30
330
+
331
+ /** 用户指令 → 可上屏的短标题,与 src/core/title.ts 的 cleanPrompt 一致 */
332
+ function cleanPrompt(text) {
333
+ if (typeof text !== 'string') return ''
334
+ // eslint-disable-next-line no-control-regex
335
+ const flat = text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim()
336
+ const chars = Array.from(flat)
337
+ return chars.length > FIRST_PROMPT_MAX ? chars.slice(0, FIRST_PROMPT_MAX).join('').trimEnd() + '…' : flat
338
+ }
339
+
340
+ /** 会话库标题的最大字数,与 src/core/title.ts 一致 */
341
+ const SESSION_TITLE_MAX = 100
342
+
343
+ /** 外部来的标题清洗,与 src/core/title.ts 的 cleanTitle 一致 */
344
+ function cleanTitle(text) {
345
+ if (typeof text !== 'string') return ''
346
+ // eslint-disable-next-line no-control-regex
347
+ const flat = text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim()
348
+ return Array.from(flat).slice(0, SESSION_TITLE_MAX).join('').trimEnd()
349
+ }
350
+
351
+ /**
352
+ * node:sqlite 是 Node 内置模块(22.13+ / 23.4+ 免 flag),更老的 Node require 直接抛,调用方跳过。
353
+ * 加载时的 ExperimentalWarning 吞掉,免得打进智能体的 hook 输出
354
+ */
355
+ function loadNodeSqlite() {
356
+ const orig = process.emitWarning
357
+ process.emitWarning = function () {
358
+ if (String(arguments[0] == null ? '' : arguments[0]).indexOf('SQLite') !== -1) return
359
+ return orig.apply(process, arguments)
360
+ }
361
+ try {
362
+ return require('node:sqlite')
363
+ } finally {
364
+ process.emitWarning = orig
365
+ }
366
+ }
367
+
368
+ /** Qoder 桌面端会话库候选路径,与 src/core/title.ts 的 qoderDbPaths 一致(来源与未核对项见那边注释) */
369
+ function qoderDbPaths(platform, home, env) {
370
+ const pf = platform || process.platform
371
+ const h = home || os.homedir()
372
+ const e = env || process.env
373
+ let root
374
+ if (pf === 'darwin') root = path.join(h, 'Library', 'Application Support')
375
+ else if (pf === 'win32') root = e.APPDATA || path.join(h, 'AppData', 'Roaming')
376
+ else root = e.XDG_CONFIG_HOME || path.join(h, '.config')
377
+ return ['com.qodercn.app.stable', 'com.qoder.app.stable'].map((d) => path.join(root, d, 'main.sqlite'))
378
+ }
379
+
380
+ /** Qoder 桌面端的会话标题(chat_sessions.title),与 src/core/title.ts 一致。任何失败返回 null */
381
+ function readQoderSessionTitle(sessionId, paths, load) {
382
+ if (!sessionId) return null
383
+ const existing = (paths || qoderDbPaths()).filter((p) => fs.existsSync(p))
384
+ if (existing.length === 0) return null
385
+ let mod
386
+ try {
387
+ mod = (load || loadNodeSqlite)()
388
+ } catch (e) {
389
+ return null
390
+ }
391
+ for (const p of existing) {
392
+ let db
393
+ try {
394
+ db = new mod.DatabaseSync(p, { readOnly: true })
395
+ const row = db.prepare('select title from chat_sessions where session_id = ?').get(sessionId)
396
+ const t = cleanTitle(row && row.title)
397
+ if (t) return t
398
+ } catch (e) {
399
+ /* 这个库读不了,试下一个 */
400
+ } finally {
401
+ try {
402
+ if (db) db.close()
403
+ } catch (e) {
404
+ /* ignore */
405
+ }
406
+ }
407
+ }
408
+ return null
409
+ }
410
+
411
+ // ---------- 非项目目录(与 src/core/dirFilter.ts 同构) ----------
412
+
413
+ function dirNorm(p, win) {
414
+ if (win) return path.win32.resolve(p).replace(/[\\/]+$/, '').toLowerCase()
415
+ return path.posix.resolve(p).replace(/\/+$/, '')
416
+ }
417
+
418
+ function dirUnder(p, base, win) {
419
+ return p === base || p.startsWith(base + (win ? '\\' : '/'))
420
+ }
421
+
422
+ /**
423
+ * 智能体按时间自动建的工作区。WorkBuddy:<主目录>/WorkBuddy/<日期开头>(5.6.2 实测 2026-09-24-18-50-03 /
424
+ * 20260430143452 / 2026-05-16-task-1);Qoder CN:<主目录>/Documents/Qoder/<YYYY-MM-DD>/<8 位十六进制>
425
+ */
426
+ function isAutoWorkspace(p, opts) {
427
+ if (!p) return false
428
+ const o = opts || {}
429
+ const win = (o.platform || process.platform) === 'win32'
430
+ const P = win ? path.win32 : path.posix
431
+ const home = dirNorm(o.home || os.homedir(), win)
432
+ const full = dirNorm(p, win)
433
+ const name = P.basename(full)
434
+ const parent = P.dirname(full)
435
+ if (parent === dirNorm(P.join(home, 'WorkBuddy'), win) && /^\d{4}-?\d{2}-?\d{2}/.test(name)) return true
436
+ return (
437
+ P.dirname(parent) === dirNorm(P.join(home, 'Documents', 'Qoder'), win) &&
438
+ /^\d{4}-\d{2}-\d{2}$/.test(P.basename(parent)) &&
439
+ /^[0-9a-f]{8}$/i.test(name)
440
+ )
441
+ }
442
+
443
+ /** 能不能作为最近项目目录上报:主目录 / 根目录 / 临时目录及其下 / 自动工作区都不报 */
444
+ function isReportableDir(p, opts) {
445
+ if (!p) return false
446
+ const o = opts || {}
447
+ const win = (o.platform || process.platform) === 'win32'
448
+ const P = win ? path.win32 : path.posix
449
+ const full = dirNorm(p, win)
450
+ if (full === dirNorm(P.parse(P.resolve(p)).root, win)) return false
451
+ if (full === dirNorm(o.home || os.homedir(), win)) return false
452
+ const tmps = [o.tmp || os.tmpdir()]
453
+ if (!win) tmps.push('/tmp', '/private/tmp', '/var/folders', '/private/var/folders')
454
+ for (const t of tmps) if (t && dirUnder(full, dirNorm(t, win), win)) return false
455
+ return !isAutoWorkspace(p, o)
456
+ }
457
+
458
+ /** 按 registry 给的顺序逐级回落。只有 first-prompt 用到 prompt(首条指令前 30 字),其余来源不碰 prompt 原文。 */
325
459
  function resolveTitle(sources, ctx) {
326
460
  const list = sources || []
327
461
  for (const source of list) {
@@ -329,7 +463,10 @@ function resolveTitle(sources, ctx) {
329
463
  if (source === 'cc-ai-title') value = readAiTitle(ctx.transcriptPath)
330
464
  else if (source === 'codex-thread-name') value = readCodexThreadName(ctx.sessionId, ctx.codexHome)
331
465
  else if (source === 'git-branch') value = readGitBranch(ctx.cwd)
332
- else if (source === 'project') value = path.basename(ctx.cwd || '') || null
466
+ else if (source === 'first-prompt') value = ctx.firstPrompt || null
467
+ else if (source === 'qoder-session-title') value = readQoderSessionTitle(ctx.sessionId, ctx.qoderDbPaths, ctx.loadSqlite)
468
+ else if (source === 'oc-session-title') value = typeof ctx.ocSessionTitle === 'string' ? ctx.ocSessionTitle.trim() || null : null
469
+ else if (source === 'project') value = ctx.cwd && !isAutoWorkspace(ctx.cwd) ? path.basename(ctx.cwd) || null : null
333
470
  // 不认识的来源直接跳过:新来源应该先发版再进 registry
334
471
  if (value) return value
335
472
  }
@@ -949,7 +1086,8 @@ const DIRS_KEEP = 30
949
1086
 
950
1087
  /** UserPromptSubmit 时记下工作目录:{全路径: 最近使用毫秒}。🔴 全路径只留本机 */
951
1088
  function recordDir(cwd, now) {
952
- if (!cwd) return
1089
+ // 主目录 / 临时目录 / 自动工作区不记,免得把真正的项目挤出 DIRS_KEEP
1090
+ if (!cwd || !isReportableDir(cwd)) return
953
1091
  const file = path.join(deskbuddyHome(), 'dirs.json')
954
1092
  let map = {}
955
1093
  try {
@@ -1077,6 +1215,19 @@ function codexRemoteHas(sessionId) {
1077
1215
  return !!r && r.has(sessionId)
1078
1216
  }
1079
1217
 
1218
+ /** 并发 hook 的先后差不会超过一次 hook 的执行时长;超出这个差值说明是时钟回拨,不能当作「更晚的事件」 */
1219
+ const SUPERSEDE_MAX_MS = 2 * 60 * 1000
1220
+
1221
+ /**
1222
+ * 状态里已是比这次事件更晚发生的事件写的(hook 并发、先发生的后写完),这次不能再覆盖。
1223
+ * 🔴 差值带上限(评审):开机后校时往回拨时,状态里的 updatedAt 落在「未来」,不设上限的话在墙钟追上之前该会话的事件全被丢掉
1224
+ */
1225
+ function superseded(cur, eventAt) {
1226
+ if (!cur || typeof cur.updatedAt !== 'number') return false
1227
+ const ahead = cur.updatedAt - eventAt
1228
+ return ahead > 0 && ahead < SUPERSEDE_MAX_MS
1229
+ }
1230
+
1080
1231
  async function main(argv) {
1081
1232
  // 同一份 hook 被 Claude Code 与 Codex 复用,靠命令行参数区分是哪个工具
1082
1233
  const tool = (argv && argv[2]) || 'claude_code'
@@ -1084,6 +1235,10 @@ async function main(argv) {
1084
1235
  const event = payload.hook_event_name || ''
1085
1236
  const sessionId = payload.session_id || ''
1086
1237
  if (!sessionId) return
1238
+ // 🔴 事件时间取在任何慢操作之前:Codex 的 hook 是 async(不等跑完),一轮很短时 UserPromptSubmit 与 Stop
1239
+ // 的 hook 并发跑;Windows 上新会话首个 hook 要起 PowerShell 查进程,常比 Stop 晚写完,
1240
+ // 按写完先后落盘就会把 done 覆盖回 watch,屏上一直「进行中」直到窗口关掉(10-01 WIN-LVT5M8H5670 实测)
1241
+ const eventAt = Date.now()
1087
1242
 
1088
1243
  if (NOTICE_EVENTS[event]) {
1089
1244
  const notice = takeNotice(sessionId)
@@ -1140,6 +1295,13 @@ async function main(argv) {
1140
1295
  const prev = readState(sessionId)
1141
1296
  const now = Date.now()
1142
1297
  if (event === 'UserPromptSubmit') recordDir(payload.cwd, now)
1298
+ // 首条指令(标题来源 first-prompt):只记第一次
1299
+ const firstPrompt = (prev && prev.firstPrompt) || (event === 'UserPromptSubmit' ? cleanPrompt(payload.prompt) : '') || undefined
1300
+ if (superseded(prev, eventAt)) {
1301
+ // 被并发的更晚事件(多半是同一轮的 Stop)抢先写了:状态不覆盖,但首条指令要补上,不然标题就成了第二条指令
1302
+ if (firstPrompt && !prev.firstPrompt) writeState(Object.assign({}, prev, { firstPrompt: firstPrompt }))
1303
+ return
1304
+ }
1143
1305
  // 状态里记的进程还活着才沿用(评审):先远程 -p 跑过、后又在窗口 resume 同一会话时,
1144
1306
  // 不能拿死掉的 -p 进程的 interactive=false 挡不住窗口
1145
1307
  const prevAlive = !!(prev && prev.pid && pidExists(prev.pid))
@@ -1158,8 +1320,9 @@ async function main(argv) {
1158
1320
  // ⚠️ 只落本机,绝不上报:快照里发出去的仍然只有 basename。
1159
1321
  cwd: payload.cwd || (prev && prev.cwd) || '',
1160
1322
  state: action,
1161
- startedAt: (prev && prev.startedAt) || now,
1162
- updatedAt: now,
1323
+ startedAt: (prev && prev.startedAt) || eventAt,
1324
+ // 记事件发生时间而不是写完时间,后来的事件才比得过先发生、却后写完的那个
1325
+ updatedAt: eventAt,
1163
1326
  pid: pid,
1164
1327
  pidName: pid ? (prevAlive && prev.pidName) || processName(pid) || undefined : undefined,
1165
1328
  // 二期 P1:窗口开着的会话,设备不能再往里下指令(两个进程会互相吞掉对话)。每个会话只判一次
@@ -1177,11 +1340,19 @@ async function main(argv) {
1177
1340
  at: now,
1178
1341
  },
1179
1342
  ])).slice(-RECENT_MAX),
1343
+ firstPrompt: firstPrompt,
1180
1344
  }
1181
1345
 
1182
1346
  // 进 / 出 DeskBuddy 窗口不一定伴随状态跃迁(窗口里 --resume 一个已完成的会话),要立刻报,否则设备最长 2 分钟仍被 8348 挡
1183
1347
  const windowChanged = !!(prev && prev.remoteWindow) !== !!next.remoteWindow
1184
- if (!windowChanged && !shouldReport(prev, next, now)) {
1348
+ // 跃迁要跟磁盘上的最新状态比,不能只跟开头读到的 prev 比(评审):上一轮停在 done 时 Stop 读到的 prev 是旧 done,
1349
+ // 并发的 UserPromptSubmit 已把 watch 写盘并上报了,Stop 若按 done→done 静默落盘,设备收到的最后一份就是 watch
1350
+ const latest = readState(sessionId)
1351
+ if (superseded(latest, eventAt)) {
1352
+ if (next.firstPrompt && !latest.firstPrompt) writeState(Object.assign({}, latest, { firstPrompt: next.firstPrompt }))
1353
+ return
1354
+ }
1355
+ if (!windowChanged && !shouldReport(latest || prev, next, now)) {
1185
1356
  writeState(next)
1186
1357
  return
1187
1358
  }
@@ -1193,10 +1364,18 @@ async function main(argv) {
1193
1364
  transcriptPath: payload.transcript_path,
1194
1365
  cwd: next.project,
1195
1366
  sessionId: sessionId,
1367
+ firstPrompt: next.firstPrompt,
1368
+ ocSessionTitle: payload.oc_session_title,
1196
1369
  }) ||
1197
1370
  next.title ||
1198
1371
  ''
1199
1372
  next.lastReportAt = now
1373
+ // 取标题 / 查进程期间可能已有更晚发生的事件写完了:那份更新,这次既不落盘也不上报
1374
+ const last = readState(sessionId)
1375
+ if (superseded(last, eventAt)) {
1376
+ if (next.firstPrompt && !last.firstPrompt) writeState(Object.assign({}, last, { firstPrompt: next.firstPrompt }))
1377
+ return
1378
+ }
1200
1379
  writeState(next)
1201
1380
 
1202
1381
  await reportIfAuthorized(tool, now)
@@ -1236,6 +1415,14 @@ module.exports = {
1236
1415
  readAliveStates,
1237
1416
  readCodexThreadName,
1238
1417
  readGitBranch,
1418
+ cleanPrompt,
1419
+ cleanTitle,
1420
+ FIRST_PROMPT_MAX,
1421
+ SESSION_TITLE_MAX,
1422
+ qoderDbPaths,
1423
+ readQoderSessionTitle,
1424
+ isAutoWorkspace,
1425
+ isReportableDir,
1239
1426
  readState,
1240
1427
  removeState,
1241
1428
  retireState,
@@ -1248,6 +1435,7 @@ module.exports = {
1248
1435
  markClear,
1249
1436
  pairClear,
1250
1437
  shouldReport,
1438
+ superseded,
1251
1439
  sign,
1252
1440
  titleSources,
1253
1441
  writeState,
@@ -4,4 +4,6 @@ export function toCcTool(tool: string, args: unknown): { tool_name: string; tool
4
4
  export function askToCc(props: unknown): { tool_name: string; tool_input: Record<string, unknown> }
5
5
  export function openPermissions(cfg: unknown): void
6
6
  export const OC_GATED_KEYS: string[]
7
+ export function userText(output: unknown): string
8
+ export function rememberTitle(titles: Map<string, string>, info: unknown): void
7
9
  export function make(opts?: { node?: string }): (ctx: unknown) => Promise<Record<string, unknown>>
package/hook/opencode.mjs CHANGED
@@ -130,6 +130,26 @@ export function openPermissions(cfg) {
130
130
  if (build && build.permission && typeof build.permission === 'object') open(build.permission)
131
131
  }
132
132
 
133
+ /** chat.message 的 output({ message, parts })里用户自己打的字:text 类 part,跳过 synthetic(插件 / 系统补的) */
134
+ export function userText(output) {
135
+ const parts = output && Array.isArray(output.parts) ? output.parts : []
136
+ return parts
137
+ .filter((p) => p && p.type === 'text' && !p.synthetic && typeof p.text === 'string')
138
+ .map((p) => p.text)
139
+ .join('\n')
140
+ }
141
+
142
+ /** opencode 起标题前的占位名(1.18.32 isDefaultTitle:`New session - ` / `Child session - ` + ISO 时间),不当标题 */
143
+ const OC_DEFAULT_TITLE_RE = /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
144
+
145
+ /** session.updated 的 info → 记下自动标题。子会话(subagent,带 parentID)、占位名不记 */
146
+ export function rememberTitle(titles, info) {
147
+ if (!info || typeof info.id !== 'string' || !info.id || info.parentID) return
148
+ const t = typeof info.title === 'string' ? info.title.trim() : ''
149
+ if (!t || OC_DEFAULT_TITLE_RE.test(t)) return
150
+ titles.set(info.id, t.slice(0, 100))
151
+ }
152
+
133
153
  /** 活着的子进程;opencode 退出时一并结束,不留孤儿 gate 挂着设备卡片(评审) */
134
154
  const LIVE = new Set()
135
155
  process.on('exit', () => {
@@ -183,9 +203,12 @@ export function make(opts = {}) {
183
203
 
184
204
  // ---- 状态上报:串行喂 hook.cjs,保证 PreToolUse / PostToolUse / Stop 的先后 ----
185
205
  let queue = Promise.resolve()
206
+ const titles = new Map() // sessionID → opencode 自动生成的标题(session.updated 里来)
186
207
  const report = (event, sessionID, extra) => {
187
208
  if (!sessionID) return
188
209
  const input = { hook_event_name: event, session_id: sessionID, cwd, ...extra }
210
+ const title = titles.get(sessionID)
211
+ if (title) input.oc_session_title = title
189
212
  queue = queue.then(() => runChild(node, HOOK, [TOOL], input, HOOK_KILL_MS)).catch(() => {})
190
213
  }
191
214
 
@@ -273,8 +296,9 @@ export function make(opts = {}) {
273
296
  if (runId) openPermissions(cfg)
274
297
  },
275
298
 
276
- 'chat.message': async (input) => {
277
- report('UserPromptSubmit', input && input.sessionID)
299
+ 'chat.message': async (input, output) => {
300
+ // prompt 给 hook 的 first-prompt 标题来源用(只记首条,见 hook.cjs)
301
+ report('UserPromptSubmit', input && input.sessionID, { prompt: userText(output) })
278
302
  },
279
303
 
280
304
  'tool.execute.before': async (input, output) => {
@@ -318,6 +342,9 @@ export function make(opts = {}) {
318
342
  case 'session.idle':
319
343
  report('Stop', p.sessionID)
320
344
  break
345
+ case 'session.updated':
346
+ rememberTitle(titles, p.info)
347
+ break
321
348
  case 'session.deleted':
322
349
  report('SessionEnd', (p.info && p.info.id) || p.sessionID)
323
350
  break
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deskbuddy",
3
- "version": "0.2.0-beta.5",
3
+ "version": "0.2.0-beta.7",
4
4
  "description": "把 Claude Code、Codex 等 AI 编码智能体每个会话窗口的状态,实时同步到桌面硬件屏幕",
5
5
  "keywords": [
6
6
  "ai",