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 +138 -54
- package/dist/hook.cjs +200 -12
- package/dist/opencode.mjs +29 -2
- package/hook/hook.cjs +200 -12
- package/hook/opencode.d.mts +2 -0
- package/hook/opencode.mjs +29 -2
- package/package.json +1 -1
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
|
|
1078
|
-
import { homedir as
|
|
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
|
|
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:
|
|
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
|
-
|
|
1281
|
-
for (const
|
|
1282
|
-
const
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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) =>
|
|
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 :
|
|
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
|
|
6650
|
+
import { homedir as homedir7 } from "os";
|
|
6567
6651
|
import { isAbsolute as isAbsolute2, resolve as resolve3 } from "path";
|
|
6568
|
-
function normalizePath(raw, home =
|
|
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
|
|
6751
|
-
return
|
|
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
|
|
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([
|
|
7424
|
-
const inSkip = (file) => skipKeys.has(
|
|
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
|
|
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 =
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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 === '
|
|
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
|
-
|
|
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) ||
|
|
1162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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 === '
|
|
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
|
-
|
|
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) ||
|
|
1162
|
-
|
|
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
|
-
|
|
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/hook/opencode.d.mts
CHANGED
|
@@ -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
|
-
|
|
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
|