n2n-nexus 0.9.3 → 0.9.5
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/CHANGELOG.md +30 -0
- package/README.md +2 -2
- package/SECURITY.md +1 -1
- package/build/cli/codex-trust.js +128 -0
- package/build/cli/doctor.js +35 -1
- package/build/cli/hook.js +43 -17
- package/build/cli/install.js +48 -11
- package/build/cli/title.js +126 -0
- package/build/contract.js +22 -2
- package/build/index.js +1 -1
- package/build/server/nexus.js +6 -2
- package/contract/errors.json +2 -2
- package/contract/hook.json +18 -1
- package/contract/tools.json +562 -14
- package/docs/ARCHITECTURE.md +9 -8
- package/docs/ARCHITECTURE_zh.md +9 -8
- package/docs/ASSISTANT_GUIDE.md +20 -11
- package/docs/ASSISTANT_GUIDE_zh.md +20 -11
- package/docs/CHANGELOG_zh.md +30 -0
- package/docs/INSTALL.md +13 -9
- package/docs/INSTALL_zh.md +13 -9
- package/docs/README_zh.md +2 -2
- package/docs/SECURITY_zh.md +1 -1
- package/docs/TOOLS_REFERENCE.md +87 -28
- package/docs/TOOLS_REFERENCE_zh.md +87 -28
- package/docs/USER_GUIDE.md +10 -7
- package/docs/USER_GUIDE_zh.md +10 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,36 @@
|
|
|
4
4
|
|
|
5
5
|
All notable changes to this project will be documented in this file.
|
|
6
6
|
|
|
7
|
+
## [0.9.5] - 2026-10-26
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- `n2n-nexus install codex` trusts the n2n-nexus hooks in Codex itself: it asks Codex (`codex app-server`, the copy the Codex app runs on Windows, else `codex` on `PATH`) for each hook's trust hash and stores it, so no `/hooks` step is needed. If that fails, it says how to trust them by hand (the Codex app's settings, or `/hooks` in the CLI). `n2n-nexus doctor codex` checks with Codex that the hooks are trusted, which a Codex update can undo.
|
|
12
|
+
|
|
13
|
+
## [0.9.4] - 2026-10-26
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Contract version 2: the npm client and the host must be upgraded together (`n2n-nexus doctor` reports a mismatch).
|
|
18
|
+
- Chat and meetings identify the conversation, not the MCP adapter instance, when the n2n-nexus hooks are installed. Several conversations of one IDE in one workspace share an instance; they now keep their own unread cursors, and their messages, a meeting's `initiator` and `participants` and the `actor` of `meeting.*` events carry their session id (`claude:<id>` / `codex:<id>`), which works with `send`. Clients without the hooks keep using the instance. If an instance has had a conversation identified within the last 30 minutes and a call of it cannot be identified, the call fails with a retry message instead of reading through the shared cursor. Results say which identity was used (`identity`).
|
|
19
|
+
- `read_messages` no longer returns your own messages in unread mode; a conversation's first unread read of global chat starts at the newest message. New history mode: `latest: true` or `afterId` read without moving a cursor and include your own messages. Each message has `senderName`, and `category` is always present (null without one).
|
|
20
|
+
- Meeting messages are numbered per meeting, from 1: `id` in `read_messages`, `messageId` from `send_message` (now returned for meetings too) and `afterId` in a meeting use that number, so a new meeting no longer continues the numbers of earlier ones.
|
|
21
|
+
|
|
22
|
+
- Meetings have members. `join_meeting(meetingId, mode?, approver?, since?)` and `leave_meeting(meetingId)`; the initiator joins its meeting. News of a meeting reaches its members through the hooks as one line per meeting: active members are woken (or their turn continues), async members see it at their next prompt or tool call, and nobody is told about their own messages. Meeting news is not an automatic turn and still arrives past `maxAutoTurns`, so a meeting runs until it is ended; ending it gives each other member one notice. `list_meetings` shows the members and how many wakes the meeting caused.
|
|
23
|
+
- Decisions by consensus. `send_message(..., draft: true)` publishes a meeting's current draft and fixes its required approvers (members that joined as `required` through the hooks); `stance(meetingId, messageId, endorse | object | withdraw, note?)` takes a stance on it, an objection with its reason. When at least two required approvers endorsed the current draft and nobody objects, the meeting closes by itself with the draft as a `DECISION` (`endReason: consensus`; `end_meeting` records `manual`). Ordinary proposals do not reset stances; a leaving or re-joining approver changes the list, which is posted to the meeting. Required approvers that have not taken a stance after a quiet `stanceReminderMinutes` (new `settings.json` field, default 10) are reminded once. `list_meetings` shows each meeting's `endReason` and current draft with its stances.
|
|
24
|
+
- Direct messages say what they ask: `send` and `reply` take `expectsReply` (false: the notification says no reply is needed and does not ask for one) and `wake` (false: the message never wakes the recipient or holds its turn, and waits for its next prompt or tool call). `read` returns `expectsReply`.
|
|
25
|
+
- Sessions have a readable `title`: the conversation's title as Claude Code or Codex shows it, read by the hooks and shown next to the name in `peers`, introductions, notifications and meeting members. `set_profile({ title })` sets your own (`""` returns to the client's). The name stays the handle for `send`.
|
|
26
|
+
- A direct message is listed in full once; while it stays unread, later notifications only count it.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- A failed `codex queue` no longer loses the wake: the Codex background wait reports the result and the host undoes a wake that was not delivered, and the wait tries again after a delay (1 s, doubling up to 5 minutes), so the same messages and notices wake the conversation again; the failed wake is not counted as an automatic turn. The session no longer stays `working` until it goes stale.
|
|
31
|
+
- A session that goes offline after 30 minutes without activity now tells its `notifyWhenIdle` subscribers, as a session that ends does, also when they only wait in the background.
|
|
32
|
+
- The prompt that starts a woken turn no longer announces again what the wake announced.
|
|
33
|
+
- The Codex SessionEnd hook fits Codex's 3-second limit: `n2n-nexus install codex` writes timeout 3 (run it again to update an existing installation, which removes Codex's "clamping SessionEnd hook timeout" warning), and the SessionEnd request gives up after 1.5 s without a second host lookup.
|
|
34
|
+
- `n2n-nexus uninstall <client>` no longer needs the client on PATH: it removes the hooks from the client's config file anyway. Without the client it skips `<client> mcp remove` and, when the MCP server is still registered, says so and how to remove it (run that command later, or delete the `n2n-nexus` entry from `~/.claude.json` / `config.toml`). `install` still requires the client.
|
|
35
|
+
- Tool calls are rate limited per conversation when the hooks identify it, so conversations that share an adapter instance (one client in one directory) no longer use up each other's budget; without the hooks calls are still limited per instance. The per-address limit is unchanged.
|
|
36
|
+
|
|
7
37
|
## [0.9.3] - 2026-10-24
|
|
8
38
|
|
|
9
39
|
### Changed
|
package/README.md
CHANGED
|
@@ -92,7 +92,7 @@ n2n-nexus gives AI assistants a shared coordination workspace. A long-running lo
|
|
|
92
92
|
|
|
93
93
|
1. Install and start the host, `n2n-nexus-host`, with Scoop, Homebrew or an archive from [Releases](https://github.com/n2ns/n2n-nexus/releases): [Installation guide, steps 1–2](docs/INSTALL.md#1-install-the-host).
|
|
94
94
|
2. Install the client and connect Claude Code or Codex (`npm i -g n2n-nexus@latest`, then `n2n-nexus install claude` or `n2n-nexus install codex`): [step 3](docs/INSTALL.md#3-install-the-client-for-claude-code-and-codex).
|
|
95
|
-
3. Codex only:
|
|
95
|
+
3. Codex only: `install codex` trusts the n2n-nexus hooks in Codex; if it says it could not, trust them by hand: [step 4](docs/INSTALL.md#4-turn-on-the-codex-hooks-one-time-required).
|
|
96
96
|
|
|
97
97
|
Other MCP clients and several machines: see the [Installation guide](docs/INSTALL.md). Using it, troubleshooting and configuration: see the [User guide](docs/USER_GUIDE.md).
|
|
98
98
|
|
|
@@ -142,7 +142,7 @@ No. `n2n-memory` is a local memory tool for a single repository. `n2n-nexus` is
|
|
|
142
142
|
|
|
143
143
|
### Does it support Claude Desktop, Cursor, VS Code and other clients?
|
|
144
144
|
|
|
145
|
-
Yes, as long as the client can run a local MCP command server. The IDE starts the adapter, and the adapter reaches the local host through `NEXUS_ENDPOINT`.
|
|
145
|
+
Yes, as long as the client can run a local MCP command server. The IDE starts the adapter, and the adapter reaches the local host through `NEXUS_ENDPOINT`. In the Claude desktop app, register it for Claude Code (`n2n-nexus install claude`) only, not in `claude_desktop_config.json`: the app would bridge it into the Code tab and drop the conversation identity ([User guide, section 2](docs/USER_GUIDE.md#2-claude-desktop-app)).
|
|
146
146
|
|
|
147
147
|
### Can Windows and WSL work together?
|
|
148
148
|
|
package/SECURITY.md
CHANGED
|
@@ -32,7 +32,7 @@ Key boundaries to be aware of:
|
|
|
32
32
|
|
|
33
33
|
- **Host network exposure**: The host binds to `127.0.0.1` by default. LAN access (the tray's **Allow LAN access**, `"host": "0.0.0.0"` in `settings.json`, or `--host 0.0.0.0`) exposes it to your local network, and it then answers LAN discovery on UDP `discoveryPort`. There is no authentication layer in the open-source baseline — only expose the host beyond localhost on networks you control.
|
|
34
34
|
- **Browser requests**: The host has no authentication, so it refuses requests that carry a browser `Origin` header or a cross-site `Sec-Fetch-Site` header (403), requires `Content-Type: application/json` on POST (415) and caps request bodies at 16 MB (413). It also rejects any `Host` header that is not an IP literal, `localhost`, `host.docker.internal` or a name listed in `allowedHosts` in `settings.json`, which closes DNS-rebinding attacks where a page reaches the host as a same-origin request under the attacker's domain. MCP adapters and scripts are unaffected.
|
|
35
|
-
- **Rate limiting**: Tool calls are limited per instance ID (default 20/s, burst 60, set with `rateLimit` / `rateBurst` in `settings.json`) and, because instance IDs are self-declared, per client address at five times that. Tool calls that name
|
|
35
|
+
- **Rate limiting**: Tool calls are limited per conversation, or per instance ID when no conversation is identified (default 20/s, burst 60, set with `rateLimit` / `rateBurst` in `settings.json`) and, because instance and session IDs are self-declared, per client address at five times that. Tool calls that name neither a conversation nor an instance ID are limited only per address. Hooks (`POST /api/hook`) are limited per conversation and per client address in buckets of their own. Over the limit the host answers `429` with `Retry-After`; `/health` and `GET /api/tools` are exempt.
|
|
36
36
|
- **Malformed requests**: Invalid request URLs or Host headers return 400 without terminating the host. Validly formed requests still pass through the browser and Host checks above.
|
|
37
37
|
- **Sensitive content in coordination data**: Project manifests, internal docs, meeting messages, and global documents are stored in plaintext in the host's local database. Do not store secrets, credentials, or customer data in these fields.
|
|
38
38
|
- **Destructive tools**: Destructive tools such as project deletion should be used through explicit review workflows.
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex runs a hook from hooks.json only once it is trusted: config.toml keeps
|
|
3
|
+
* a `hooks.state.<key>.trusted_hash` per hook, a hash Codex computes from the
|
|
4
|
+
* hook's whole configuration as its own version serializes it, so another
|
|
5
|
+
* Codex version may compute another hash and the value cannot be computed
|
|
6
|
+
* here. Codex's app server reports it instead: `hooks/list` gives each hook's
|
|
7
|
+
* key, current hash and trust status, and `config/value/write` stores the
|
|
8
|
+
* hash, as the CLI's /hooks screen and the app's settings do. The Codex the
|
|
9
|
+
* user runs decides, so the app's own copy is preferred to the one on PATH.
|
|
10
|
+
*/
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { pkg } from "../config/index.js";
|
|
16
|
+
import { clientCommand } from "./client-command.js";
|
|
17
|
+
const TIMEOUT_MS = 30_000;
|
|
18
|
+
/**
|
|
19
|
+
* The Codex to ask: NEXUS_CODEX_COMMAND when set; on Windows the newest copy
|
|
20
|
+
* the Codex app keeps in %LOCALAPPDATA%\OpenAI\Codex\bin\<build>\codex.exe;
|
|
21
|
+
* else `codex` on PATH. Null when none can be run.
|
|
22
|
+
*/
|
|
23
|
+
export function codexProgram() {
|
|
24
|
+
const override = process.env.NEXUS_CODEX_COMMAND;
|
|
25
|
+
if (override) {
|
|
26
|
+
const resolved = clientCommand(override);
|
|
27
|
+
return resolved && { ...resolved, label: override };
|
|
28
|
+
}
|
|
29
|
+
if (process.platform === "win32" && process.env.LOCALAPPDATA) {
|
|
30
|
+
const bin = path.join(process.env.LOCALAPPDATA, "OpenAI", "Codex", "bin");
|
|
31
|
+
let newest;
|
|
32
|
+
try {
|
|
33
|
+
for (const build of fs.readdirSync(bin)) {
|
|
34
|
+
const file = path.join(bin, build, "codex.exe");
|
|
35
|
+
const time = fs.statSync(file, { throwIfNoEntry: false })?.mtimeMs;
|
|
36
|
+
if (time !== undefined && (!newest || time > newest.time))
|
|
37
|
+
newest = { file, time };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch { /* no Codex app */ }
|
|
41
|
+
if (newest)
|
|
42
|
+
return { command: newest.file, args: [], label: newest.file };
|
|
43
|
+
}
|
|
44
|
+
const resolved = clientCommand("codex");
|
|
45
|
+
return resolved && { ...resolved, label: "codex" };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Ask `codex app-server` about our hooks in `hooksFile`
|
|
49
|
+
* and, with `trust`, store the trust of those it does not trust yet. Returns
|
|
50
|
+
* the state afterwards; throws when the app server cannot be used.
|
|
51
|
+
*/
|
|
52
|
+
export async function codexHookTrust(program, hooksFile, trust) {
|
|
53
|
+
const child = spawn(program.command, [...program.args, "app-server"], { stdio: ["pipe", "pipe", "ignore"], windowsHide: true });
|
|
54
|
+
const waiting = new Map();
|
|
55
|
+
let fail = () => { };
|
|
56
|
+
const failure = new Promise((_, reject) => { fail = reject; });
|
|
57
|
+
failure.catch(() => { });
|
|
58
|
+
child.on("error", (error) => fail(error));
|
|
59
|
+
// A write after the app server closed its input (it crashed) fails here, not as an uncaught error.
|
|
60
|
+
child.stdin.on("error", (error) => fail(error));
|
|
61
|
+
child.on("exit", (code) => fail(new Error(`codex app-server exited (${code})`)));
|
|
62
|
+
let buffer = "";
|
|
63
|
+
child.stdout.setEncoding("utf8");
|
|
64
|
+
child.stdout.on("data", (chunk) => {
|
|
65
|
+
buffer += chunk;
|
|
66
|
+
let end;
|
|
67
|
+
while ((end = buffer.indexOf("\n")) >= 0) {
|
|
68
|
+
const line = buffer.slice(0, end);
|
|
69
|
+
buffer = buffer.slice(end + 1);
|
|
70
|
+
let message;
|
|
71
|
+
try {
|
|
72
|
+
message = JSON.parse(line);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (typeof message.id === "number")
|
|
78
|
+
waiting.get(message.id)?.(message);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
let next = 1;
|
|
82
|
+
const call = async (method, params) => {
|
|
83
|
+
const id = next++;
|
|
84
|
+
const answer = new Promise((resolve) => waiting.set(id, resolve));
|
|
85
|
+
child.stdin.write(JSON.stringify({ id, method, params }) + "\n");
|
|
86
|
+
const message = await Promise.race([answer, failure]);
|
|
87
|
+
waiting.delete(id);
|
|
88
|
+
if (message.error)
|
|
89
|
+
throw new Error(`${method}: ${message.error.message ?? "failed"}`);
|
|
90
|
+
return message.result;
|
|
91
|
+
};
|
|
92
|
+
const file = path.resolve(hooksFile);
|
|
93
|
+
const list = async () => {
|
|
94
|
+
const result = await call("hooks/list", { cwds: [os.homedir()] });
|
|
95
|
+
return (result.data ?? []).flatMap((entry) => entry.hooks ?? [])
|
|
96
|
+
.filter((hook) => typeof hook.sourcePath === "string" && path.resolve(hook.sourcePath) === file && isOurCodexHook(hook.command ?? ""));
|
|
97
|
+
};
|
|
98
|
+
const timer = setTimeout(() => fail(new Error("codex app-server did not answer in time")), TIMEOUT_MS);
|
|
99
|
+
try {
|
|
100
|
+
await call("initialize", { clientInfo: { name: "n2n-nexus", version: pkg.version } });
|
|
101
|
+
child.stdin.write(JSON.stringify({ method: "initialized" }) + "\n");
|
|
102
|
+
let hooks = await list();
|
|
103
|
+
const pending = hooks.filter((hook) => hook.trustStatus === "untrusted" || hook.trustStatus === "modified");
|
|
104
|
+
if (trust && pending.length > 0) {
|
|
105
|
+
const state = Object.fromEntries(pending.map((hook) => [hook.key, { trusted_hash: hook.currentHash }]));
|
|
106
|
+
await call("config/value/write", { keyPath: "hooks.state", mergeStrategy: "upsert", value: state });
|
|
107
|
+
hooks = await list();
|
|
108
|
+
}
|
|
109
|
+
return { codex: program.label, ours: hooks.length, untrusted: hooks.filter((hook) => hook.trustStatus !== "trusted").map((hook) => hook.key) };
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
child.removeAllListeners("exit");
|
|
114
|
+
child.stdin.end();
|
|
115
|
+
child.kill();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Whether a command Codex lists is one of our hooks. Codex on Windows lists
|
|
120
|
+
* the PowerShell form install writes as `commandWindows` (`... 'hook' 'codex'`).
|
|
121
|
+
*/
|
|
122
|
+
export function isOurCodexHook(command) {
|
|
123
|
+
return command.includes("n2n-nexus") && (command.includes(" hook codex") || command.includes("'hook' 'codex'"));
|
|
124
|
+
}
|
|
125
|
+
/** Why Codex may list none of our hooks, for messages. */
|
|
126
|
+
export const NOT_LISTED = "Codex does not list the n2n-nexus hooks: check that hooks are on in Codex's config.toml ([features] hooks)";
|
|
127
|
+
/** How to trust the hooks by hand, for messages. */
|
|
128
|
+
export const MANUAL_TRUST = "trust the n2n-nexus hooks by hand: in the Codex app's settings (hooks), or with /hooks in the Codex CLI";
|
package/build/cli/doctor.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* script in the hook commands, and the Node path and npm's npx script in the
|
|
4
4
|
* MCP server command (`"<node>" "<npx>" -y n2n-nexus@latest mcp`), that
|
|
5
5
|
* `install` wrote still exist and run, that the host those commands talk to answers
|
|
6
|
-
* GET /health as n2n-nexus-host with this client's contract version,
|
|
6
|
+
* GET /health as n2n-nexus-host with this client's contract version, that
|
|
7
|
+
* Codex trusts the Codex hooks (asked through codex-trust.ts), and say
|
|
7
8
|
* how to fix what does not. Without a client both are checked; a client with
|
|
8
9
|
* nothing installed is only reported when it was named. Exit code 1 when a
|
|
9
10
|
* check failed.
|
|
@@ -147,6 +148,37 @@ function doctorClient(client, endpoints, defaultEndpoint) {
|
|
|
147
148
|
process.stdout.write(lines.join("\n") + "\n");
|
|
148
149
|
return !failed;
|
|
149
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* Whether Codex trusts our hooks (it runs untrusted or modified ones not at
|
|
153
|
+
* all); prints the result. A Codex that cannot be asked is only noted.
|
|
154
|
+
*/
|
|
155
|
+
async function doctorCodexTrust() {
|
|
156
|
+
const { MANUAL_TRUST, NOT_LISTED, codexHookTrust, codexProgram } = await import("./codex-trust.js");
|
|
157
|
+
const program = codexProgram();
|
|
158
|
+
if (!program) {
|
|
159
|
+
process.stdout.write(` note: hook trust not checked: the "codex" command could not be run.\n`);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
let state;
|
|
163
|
+
try {
|
|
164
|
+
state = await codexHookTrust(program, configFile("codex"), false);
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
process.stdout.write(` note: hook trust not checked with ${program.label}: ${error.message}\n`);
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
if (state.ours === 0) {
|
|
171
|
+
process.stdout.write(` FAIL hook trust (${program.label}): ${NOT_LISTED}, then run "n2n-nexus install codex" again.\n`);
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
if (state.untrusted.length === 0) {
|
|
175
|
+
process.stdout.write(` ok hook trust (${program.label}): ${state.ours} hooks trusted\n`);
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
process.stdout.write(` FAIL hook trust (${program.label}): not trusted: ${state.untrusted.join(", ")}\n`
|
|
179
|
+
+ ` fix: run "n2n-nexus install codex" again, or ${MANUAL_TRUST}.\n`);
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
150
182
|
/** Parse `[claude|codex]`, run the checks and return the exit code. */
|
|
151
183
|
export async function runDoctor(argv) {
|
|
152
184
|
const client = argv[0];
|
|
@@ -172,6 +204,8 @@ export async function runDoctor(argv) {
|
|
|
172
204
|
else if (!result) {
|
|
173
205
|
failed = true;
|
|
174
206
|
}
|
|
207
|
+
if (c === "codex" && result !== undefined && !await doctorCodexTrust())
|
|
208
|
+
failed = true;
|
|
175
209
|
}
|
|
176
210
|
if (endpoints.size === 0)
|
|
177
211
|
endpoints.add(defaultEndpoint);
|
package/build/cli/hook.js
CHANGED
|
@@ -2,15 +2,19 @@
|
|
|
2
2
|
* `n2n-nexus hook <claude|codex> [--endpoint <url>] [--wait]`: forward one client
|
|
3
3
|
* hook event to the daemon and print its output, or with `--wait` run the
|
|
4
4
|
* background wait that wakes an idle conversation. Runs on every tool call, so
|
|
5
|
-
* it loads only node built-ins (no MCP SDK
|
|
5
|
+
* it loads only node built-ins (no MCP SDK or zod; node:sqlite only to read a
|
|
6
|
+
* Codex conversation's title, see title.ts), and every failure
|
|
6
7
|
* is silent: exit 0 with nothing on stdout, so the client carries on unaffected.
|
|
7
8
|
*/
|
|
8
9
|
import { spawn } from "node:child_process";
|
|
9
|
-
import { BACKGROUND_WAIT_EVENT, HOOK_INPUT_FIELDS, HOOK_REQUEST_TIMEOUT_MS, HOOK_STOP_REQUEST_TIMEOUT_MS, WAIT_HOOK_TIMEOUT_SEC, noDaemonHint, isHookClient } from "../contract.js";
|
|
10
|
+
import { BACKGROUND_WAIT_EVENT, WAKE_RESULT_EVENT, HOOK_INPUT_FIELDS, HOOK_REQUEST_TIMEOUT_MS, HOOK_STOP_REQUEST_TIMEOUT_MS, SESSION_END_REQUEST_TIMEOUT_MS, WAIT_HOOK_TIMEOUT_SEC, WAKE_RETRY_FIRST_DELAY_MS, WAKE_RETRY_MAX_DELAY_MS, noDaemonHint, isHookClient } from "../contract.js";
|
|
10
11
|
import { NexusClient, NexusTransportError, REQUEST_TIMEOUT_CODE } from "../client/nexus-client.js";
|
|
11
12
|
import { DISCOVER_ENDPOINT, LOCAL_ENDPOINT, resolveEndpoint } from "../client/discovery.js";
|
|
13
|
+
import { readTitle } from "./title.js";
|
|
12
14
|
/** Errors after which a discovered daemon is looked up again: it was never reached, so resending is safe. */
|
|
13
15
|
const UNREACHABLE_CODES = new Set(["ECONNREFUSED", "EHOSTUNREACH", "ENETUNREACH", "ETIMEDOUT", "EADDRNOTAVAIL"]);
|
|
16
|
+
/** Events whose forwarded input carries the conversation's title. */
|
|
17
|
+
const TITLE_EVENTS = new Set(["SessionStart", "UserPromptSubmit", "Stop"]);
|
|
14
18
|
function isPlainObject(value) {
|
|
15
19
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
16
20
|
}
|
|
@@ -38,7 +42,15 @@ async function forward(argv) {
|
|
|
38
42
|
if (Object.prototype.hasOwnProperty.call(stdin, field))
|
|
39
43
|
input[field] = stdin[field];
|
|
40
44
|
}
|
|
41
|
-
|
|
45
|
+
// The title is read only where the host updates it, not on the frequent tool events.
|
|
46
|
+
if (typeof stdin.hook_event_name === "string" && TITLE_EVENTS.has(stdin.hook_event_name)) {
|
|
47
|
+
const title = await readTitle(client, stdin);
|
|
48
|
+
if (title !== undefined)
|
|
49
|
+
input.title = title;
|
|
50
|
+
}
|
|
51
|
+
// SessionEnd must finish within Codex's 3 s hook limit (discovery included): a short budget and a single attempt.
|
|
52
|
+
const sessionEnd = stdin.hook_event_name === "SessionEnd";
|
|
53
|
+
const timeoutMs = stdin.hook_event_name === "Stop" ? HOOK_STOP_REQUEST_TIMEOUT_MS : sessionEnd ? SESSION_END_REQUEST_TIMEOUT_MS : HOOK_REQUEST_TIMEOUT_MS;
|
|
42
54
|
/** Send to `endpoint`; nothing listening there is reported with what to do about it. */
|
|
43
55
|
const send = async (endpoint) => {
|
|
44
56
|
try {
|
|
@@ -60,7 +72,7 @@ async function forward(argv) {
|
|
|
60
72
|
// A timed-out Stop may have been waiting for messages on purpose, so only other events count a timeout.
|
|
61
73
|
const code = error instanceof NexusTransportError ? error.code ?? "" : "";
|
|
62
74
|
const unreachable = UNREACHABLE_CODES.has(code) || (code === REQUEST_TIMEOUT_CODE && stdin.hook_event_name !== "Stop");
|
|
63
|
-
if (configured !== DISCOVER_ENDPOINT || !unreachable)
|
|
75
|
+
if (configured !== DISCOVER_ENDPOINT || !unreachable || sessionEnd)
|
|
64
76
|
throw error;
|
|
65
77
|
const found = await resolveEndpoint(configured, { fresh: true });
|
|
66
78
|
if (found === endpoint)
|
|
@@ -79,8 +91,8 @@ async function forward(argv) {
|
|
|
79
91
|
/** The background wait ends before the client's hook timeout would kill it during a request. */
|
|
80
92
|
const WAIT_DEADLINE_MS = (WAIT_HOOK_TIMEOUT_SEC - 10) * 1000;
|
|
81
93
|
/**
|
|
82
|
-
* Ask the daemon until something arrives for the session (the
|
|
83
|
-
*
|
|
94
|
+
* Ask the daemon until something arrives for the session (the wake), the
|
|
95
|
+
* session becomes active again or the deadline passes (null).
|
|
84
96
|
*/
|
|
85
97
|
export async function waitForWake(client, sessionId, endpoint, deadlineMs = WAIT_DEADLINE_MS) {
|
|
86
98
|
const nexus = new NexusClient({ endpoint });
|
|
@@ -89,8 +101,8 @@ export async function waitForWake(client, sessionId, endpoint, deadlineMs = WAIT
|
|
|
89
101
|
while (Date.now() < deadline) {
|
|
90
102
|
const response = await nexus.hook({ client, input: { hook_event_name: BACKGROUND_WAIT_EVENT, session_id: sessionId } }, HOOK_STOP_REQUEST_TIMEOUT_MS);
|
|
91
103
|
const output = isPlainObject(response) ? response.output : null;
|
|
92
|
-
if (isPlainObject(output) && typeof output.wake === "string")
|
|
93
|
-
return output.wake;
|
|
104
|
+
if (isPlainObject(output) && typeof output.wake === "string" && typeof output.dispatchId === "string")
|
|
105
|
+
return { text: output.wake, dispatchId: output.dispatchId };
|
|
94
106
|
if (!(isPlainObject(output) && output.timeout === true))
|
|
95
107
|
return null;
|
|
96
108
|
}
|
|
@@ -101,7 +113,11 @@ export async function waitForWake(client, sessionId, endpoint, deadlineMs = WAIT
|
|
|
101
113
|
* asyncRewake hook: exit code 2 with the notification on stderr wakes the
|
|
102
114
|
* conversation. Codex has no such hook, so its Stop hook starts a detached copy
|
|
103
115
|
* (`--session <id>`) and returns at once; the copy hands the notification to
|
|
104
|
-
* `codex queue` (NEXUS_CODEX_COMMAND, default `codex`), which starts a turn
|
|
116
|
+
* `codex queue` (NEXUS_CODEX_COMMAND, default `codex`), which starts a turn,
|
|
117
|
+
* and reports to the host whether it did (WAKE_RESULT_EVENT): after a failed
|
|
118
|
+
* queue the host undoes the wake and the copy waits again after a delay that
|
|
119
|
+
* doubles on each failure, so the same messages wake the session on a later
|
|
120
|
+
* try without retrying a broken `codex` in a loop.
|
|
105
121
|
*/
|
|
106
122
|
async function runWait(argv) {
|
|
107
123
|
const client = argv[0];
|
|
@@ -127,16 +143,26 @@ async function runWait(argv) {
|
|
|
127
143
|
// Load what a wake needs before waiting: the wait lasts up to an hour, and an
|
|
128
144
|
// upgrade or a cleared npx cache may remove the package's files meanwhile.
|
|
129
145
|
const { spawnClientSync } = client === "codex" ? await import("./client-command.js") : { spawnClientSync: undefined };
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
146
|
+
const endpoint = await resolveEndpoint(configured);
|
|
147
|
+
const deadline = Date.now() + WAIT_DEADLINE_MS;
|
|
148
|
+
for (let delay = WAKE_RETRY_FIRST_DELAY_MS;; delay = Math.min(delay * 2, WAKE_RETRY_MAX_DELAY_MS)) {
|
|
149
|
+
const wake = await waitForWake(client, sessionId, endpoint, deadline - Date.now());
|
|
150
|
+
if (wake === null)
|
|
151
|
+
return 0;
|
|
152
|
+
if (!spawnClientSync) {
|
|
153
|
+
await new Promise((resolve) => process.stderr.write(wake.text + "\n", () => resolve()));
|
|
154
|
+
return 2;
|
|
155
|
+
}
|
|
134
156
|
// Never through cmd.exe: the text comes from another session and spans several lines.
|
|
135
|
-
spawnClientSync(process.env.NEXUS_CODEX_COMMAND || "codex", ["queue", "--thread", sessionId, "--message", text], { stdio: "ignore", windowsHide: true });
|
|
136
|
-
|
|
157
|
+
const queued = spawnClientSync(process.env.NEXUS_CODEX_COMMAND || "codex", ["queue", "--thread", sessionId, "--message", wake.text], { stdio: "ignore", windowsHide: true });
|
|
158
|
+
const ok = !queued.error && queued.status === 0;
|
|
159
|
+
const input = { hook_event_name: WAKE_RESULT_EVENT, session_id: sessionId, dispatch_id: wake.dispatchId, ok };
|
|
160
|
+
const reported = await new NexusClient({ endpoint }).hook({ client, input }, HOOK_REQUEST_TIMEOUT_MS).then(() => true, () => false);
|
|
161
|
+
// Unknown result (no report): the host keeps the wake, and waiting again would deliver nothing.
|
|
162
|
+
if (ok || !reported || Date.now() + delay >= deadline)
|
|
163
|
+
return 0;
|
|
164
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
137
165
|
}
|
|
138
|
-
await new Promise((resolve) => process.stderr.write(text + "\n", () => resolve()));
|
|
139
|
-
return 2;
|
|
140
166
|
}
|
|
141
167
|
/** Run the hook command; resolves to exit code 0, or 2 when a background wait wakes a Claude conversation. */
|
|
142
168
|
export async function runHook(argv) {
|
package/build/cli/install.js
CHANGED
|
@@ -17,7 +17,7 @@ import { spawnSync } from "node:child_process";
|
|
|
17
17
|
import fs from "node:fs";
|
|
18
18
|
import os from "node:os";
|
|
19
19
|
import path from "node:path";
|
|
20
|
-
import { CLAUDE_PRE_TOOL_MATCHER, HOOK_STOP_TIMEOUT_SEC, HOOK_TIMEOUT_SEC, MCP_SERVER_NAME, WAIT_HOOK_TIMEOUT_SEC, isHookClient } from "../contract.js";
|
|
20
|
+
import { CLAUDE_PRE_TOOL_MATCHER, CODEX_SESSION_END_TIMEOUT_SEC, HOOK_STOP_TIMEOUT_SEC, HOOK_TIMEOUT_SEC, MCP_SERVER_NAME, WAIT_HOOK_TIMEOUT_SEC, isHookClient } from "../contract.js";
|
|
21
21
|
import { pkg } from "../config/index.js";
|
|
22
22
|
import { spawnClientSync } from "./client-command.js";
|
|
23
23
|
import { entryScript } from "./entry.js";
|
|
@@ -210,7 +210,11 @@ function removeOurEntries(config, client, file) {
|
|
|
210
210
|
function addOurEntries(config, client, command, commandWindows) {
|
|
211
211
|
const hooks = (config.hooks ??= {});
|
|
212
212
|
for (const event of EVENTS[client]) {
|
|
213
|
-
|
|
213
|
+
// Codex clamps a SessionEnd hook timeout above 3 s (and warns in its settings), so ask for 3 s there.
|
|
214
|
+
const timeout = event === "Stop" ? HOOK_STOP_TIMEOUT_SEC
|
|
215
|
+
: client === "codex" && event === "SessionEnd" ? CODEX_SESSION_END_TIMEOUT_SEC
|
|
216
|
+
: HOOK_TIMEOUT_SEC;
|
|
217
|
+
const entry = { type: "command", command, timeout };
|
|
214
218
|
if (commandWindows)
|
|
215
219
|
entry.commandWindows = commandWindows;
|
|
216
220
|
const group = client === "claude" && event === "PreToolUse"
|
|
@@ -289,7 +293,9 @@ function runClient(client, args, stdio) {
|
|
|
289
293
|
/**
|
|
290
294
|
* Run `<client> mcp remove`. A failure is ignored only when `<client> mcp get`
|
|
291
295
|
* shows the server is not registered (never installed, or already removed);
|
|
292
|
-
* otherwise the removal really failed and we stop before `mcp add`.
|
|
296
|
+
* otherwise the removal really failed and we stop before `mcp add`. On
|
|
297
|
+
* uninstall, a client that cannot be run skips the step with a note on how to
|
|
298
|
+
* remove the registration that is still there.
|
|
293
299
|
*/
|
|
294
300
|
function removeMcpServer(client, action, hooksRemoved) {
|
|
295
301
|
const removeArgs = mcpRemoveArgs(client);
|
|
@@ -311,6 +317,13 @@ function removeMcpServer(client, action, hooksRemoved) {
|
|
|
311
317
|
}
|
|
312
318
|
if (!registered)
|
|
313
319
|
return;
|
|
320
|
+
if (action === "uninstall" && error.code === "ENOENT") {
|
|
321
|
+
const file = mcpConfigFile(client);
|
|
322
|
+
const what = client === "claude" ? `the "${MCP_SERVER_NAME}" entry under "mcpServers"` : `the [mcp_servers.${MCP_SERVER_NAME}] table`;
|
|
323
|
+
process.stdout.write(`Skipped "${client} ${removeArgs.join(" ")}": the "${client}" command could not be run (not on PATH, or not runnable without a shell). `
|
|
324
|
+
+ `The MCP server is still registered in ${file}; run that command once ${client} can be run, or delete ${what} from ${file}.\n`);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
314
327
|
const failure = error;
|
|
315
328
|
const stderr = typeof failure.stderr === "string" ? failure.stderr.trim() : "";
|
|
316
329
|
const detail = stderr || (typeof failure.status === "number" ? `exit code ${failure.status}` : failure.message);
|
|
@@ -328,9 +341,12 @@ export function runInstall(options) {
|
|
|
328
341
|
if (action === "install" && /[/\\]_npx[/\\]/.test(entry)) {
|
|
329
342
|
throw new InstallError(`n2n-nexus is running from an npx cache (${entry}), which can be cleared at any time. Install it first: npm i -g ${MCP_PACKAGE}`);
|
|
330
343
|
}
|
|
331
|
-
|
|
332
|
-
if (
|
|
333
|
-
|
|
344
|
+
// Uninstall does not need the client: our hooks are removed from its config file either way.
|
|
345
|
+
if (action === "install") {
|
|
346
|
+
const probe = spawnClientSync(client, ["--version"], { stdio: "ignore" });
|
|
347
|
+
if (probe.error || probe.status !== 0) {
|
|
348
|
+
throw new InstallError(`The "${client}" command was not found on PATH; install ${client === "claude" ? "Claude Code" : "Codex"} first.`);
|
|
349
|
+
}
|
|
334
350
|
}
|
|
335
351
|
const node = action === "install" ? (options.node ? checkNodePath(options.node) : stableNodePath()) : undefined;
|
|
336
352
|
const npm = node ? npxFor(node) : undefined;
|
|
@@ -357,9 +373,6 @@ export function runInstall(options) {
|
|
|
357
373
|
if (VERSIONED_NODE_DIR.test(node) || VERSIONED_NODE_DIR.test(npm.npx)) {
|
|
358
374
|
process.stdout.write(`Note: this Node or npx path contains a version directory that an upgrade may remove. After upgrading Node, run "n2n-nexus install ${client}" again, or install with --node <path> pointing at a stable entry such as /usr/local/bin/node.\n`);
|
|
359
375
|
}
|
|
360
|
-
if (client === "codex") {
|
|
361
|
-
process.stdout.write("Next: open Codex and run /hooks to trust the n2n-nexus hooks; until then they do not run.\n");
|
|
362
|
-
}
|
|
363
376
|
}
|
|
364
377
|
return 0;
|
|
365
378
|
}
|
|
@@ -369,7 +382,7 @@ export function runInstall(options) {
|
|
|
369
382
|
}
|
|
370
383
|
}
|
|
371
384
|
/** Parse `<claude|codex> [--endpoint <url>] [--node <path>]` and run. */
|
|
372
|
-
export function runInstallCommand(action, argv) {
|
|
385
|
+
export async function runInstallCommand(action, argv) {
|
|
373
386
|
const client = argv[0];
|
|
374
387
|
if (!isHookClient(client)) {
|
|
375
388
|
process.stderr.write(`Usage: n2n-nexus ${action} <claude|codex>${action === "install" ? " [--endpoint <url>] [--node <path>]" : ""}\n`);
|
|
@@ -392,5 +405,29 @@ export function runInstallCommand(action, argv) {
|
|
|
392
405
|
const node = option("--node", "a path");
|
|
393
406
|
if (node === null)
|
|
394
407
|
return 1;
|
|
395
|
-
|
|
408
|
+
const code = runInstall({ action, client, endpoint, node });
|
|
409
|
+
if (code === 0 && action === "install" && client === "codex")
|
|
410
|
+
await trustCodex();
|
|
411
|
+
return code;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Codex runs the hooks just written only once they are trusted: trust them
|
|
415
|
+
* through Codex itself, or say how to do it by hand. Never fails the install.
|
|
416
|
+
*/
|
|
417
|
+
async function trustCodex() {
|
|
418
|
+
const { MANUAL_TRUST, NOT_LISTED, codexHookTrust, codexProgram } = await import("./codex-trust.js");
|
|
419
|
+
const program = codexProgram();
|
|
420
|
+
try {
|
|
421
|
+
if (!program)
|
|
422
|
+
throw new Error(`the "codex" command could not be run`);
|
|
423
|
+
const state = await codexHookTrust(program, configFile("codex"), true);
|
|
424
|
+
if (state.ours === 0)
|
|
425
|
+
throw new Error(`${program.label}: ${NOT_LISTED}`);
|
|
426
|
+
if (state.untrusted.length > 0)
|
|
427
|
+
throw new Error(`Codex (${program.label}) still does not trust ${state.untrusted.join(", ")}`);
|
|
428
|
+
process.stdout.write(`Trusted the ${state.ours} n2n-nexus hooks in Codex (${program.label}); start a new Codex conversation to use them.\n`);
|
|
429
|
+
}
|
|
430
|
+
catch (error) {
|
|
431
|
+
process.stdout.write(`Could not trust the hooks automatically (${error.message}). Codex does not run them until you ${MANUAL_TRUST}.\n`);
|
|
432
|
+
}
|
|
396
433
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The conversation's title as its client shows it, which the hook command adds
|
|
3
|
+
* to the input it forwards as `title`. Neither source is documented, so every
|
|
4
|
+
* failure (missing file, unexpected format, locked database, time budget
|
|
5
|
+
* spent) yields undefined and the hook goes on without a title.
|
|
6
|
+
*
|
|
7
|
+
* Claude Code: the transcript (`transcript_path`, JSONL, can be very large)
|
|
8
|
+
* gets `{"type":"custom-title","customTitle":…}` records when the user renames
|
|
9
|
+
* the conversation and `{"type":"ai-title","aiTitle":…}` records for the
|
|
10
|
+
* automatic title, appended again and again; only its last and, failing that,
|
|
11
|
+
* first TRANSCRIPT_CHUNK_BYTES are read. Codex: `threads.name` (set by the
|
|
12
|
+
* user) or else `threads.title` in the newest `$CODEX_HOME/state_<N>.sqlite`.
|
|
13
|
+
*/
|
|
14
|
+
import { open, readdir } from "node:fs/promises";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { MAX_TITLE_CHARS } from "../contract.js";
|
|
18
|
+
/** How much of the transcript's end, and then of its start, is searched for a title. */
|
|
19
|
+
const TRANSCRIPT_CHUNK_BYTES = 256 * 1024;
|
|
20
|
+
/** Reading the title may not hold up the hook for longer than this. */
|
|
21
|
+
const TITLE_BUDGET_MS = 50;
|
|
22
|
+
/** Trimmed first line, at most MAX_TITLE_CHARS characters; undefined when nothing is left. */
|
|
23
|
+
export function cleanTitle(value) {
|
|
24
|
+
if (typeof value !== "string")
|
|
25
|
+
return undefined;
|
|
26
|
+
const line = value.trim().split(/\r\n|\r|\n/)[0].trim();
|
|
27
|
+
const chars = Array.from(line);
|
|
28
|
+
const title = chars.length > MAX_TITLE_CHARS ? chars.slice(0, MAX_TITLE_CHARS).join("").trimEnd() : line;
|
|
29
|
+
return title || undefined;
|
|
30
|
+
}
|
|
31
|
+
/** The last custom title in `text`, or else the last automatic one. */
|
|
32
|
+
function titleInJsonl(text) {
|
|
33
|
+
let custom;
|
|
34
|
+
let ai;
|
|
35
|
+
for (const line of text.split("\n")) {
|
|
36
|
+
if (!line.includes("-title"))
|
|
37
|
+
continue;
|
|
38
|
+
let record;
|
|
39
|
+
try {
|
|
40
|
+
record = JSON.parse(line);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
continue; // the chunk's cut-off first or last line
|
|
44
|
+
}
|
|
45
|
+
if (typeof record !== "object" || record === null)
|
|
46
|
+
continue;
|
|
47
|
+
const { type, customTitle, aiTitle } = record;
|
|
48
|
+
if (type === "custom-title")
|
|
49
|
+
custom = cleanTitle(customTitle) ?? custom;
|
|
50
|
+
else if (type === "ai-title")
|
|
51
|
+
ai = cleanTitle(aiTitle) ?? ai;
|
|
52
|
+
}
|
|
53
|
+
return custom ?? ai;
|
|
54
|
+
}
|
|
55
|
+
async function claudeTitle(transcriptPath) {
|
|
56
|
+
if (typeof transcriptPath !== "string" || !transcriptPath)
|
|
57
|
+
return undefined;
|
|
58
|
+
const file = await open(transcriptPath, "r");
|
|
59
|
+
try {
|
|
60
|
+
const { size } = await file.stat();
|
|
61
|
+
const read = async (position, length) => {
|
|
62
|
+
const buffer = Buffer.alloc(length);
|
|
63
|
+
const { bytesRead } = await file.read(buffer, 0, length, position);
|
|
64
|
+
return buffer.toString("utf8", 0, bytesRead);
|
|
65
|
+
};
|
|
66
|
+
const tailStart = Math.max(0, size - TRANSCRIPT_CHUNK_BYTES);
|
|
67
|
+
const title = titleInJsonl(await read(tailStart, size - tailStart));
|
|
68
|
+
if (title !== undefined || tailStart === 0)
|
|
69
|
+
return title;
|
|
70
|
+
return titleInJsonl(await read(0, TRANSCRIPT_CHUNK_BYTES));
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
await file.close();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** node:sqlite without the ExperimentalWarning older Node versions print on stderr when it loads. */
|
|
77
|
+
async function loadSqlite() {
|
|
78
|
+
const emitWarning = process.emitWarning;
|
|
79
|
+
process.emitWarning = function (warning, ...args) {
|
|
80
|
+
const type = typeof args[0] === "string" ? args[0] : args[0]?.type ?? (warning instanceof Error ? warning.name : undefined);
|
|
81
|
+
const message = warning instanceof Error ? warning.message : String(warning);
|
|
82
|
+
if (type === "ExperimentalWarning" && message.includes("SQLite"))
|
|
83
|
+
return;
|
|
84
|
+
return emitWarning.call(process, warning, ...args);
|
|
85
|
+
};
|
|
86
|
+
try {
|
|
87
|
+
return await import("node:sqlite");
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
process.emitWarning = emitWarning;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function codexTitle(sessionId) {
|
|
94
|
+
if (typeof sessionId !== "string" || !sessionId)
|
|
95
|
+
return undefined;
|
|
96
|
+
const home = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
|
97
|
+
let newest;
|
|
98
|
+
for (const name of await readdir(home)) {
|
|
99
|
+
const match = /^state_(\d+)\.sqlite$/.exec(name);
|
|
100
|
+
if (match && (newest === undefined || Number(match[1]) > newest.n))
|
|
101
|
+
newest = { n: Number(match[1]), name };
|
|
102
|
+
}
|
|
103
|
+
if (!newest)
|
|
104
|
+
return undefined;
|
|
105
|
+
const { DatabaseSync } = await loadSqlite();
|
|
106
|
+
const db = new DatabaseSync(path.join(home, newest.name), { readOnly: true });
|
|
107
|
+
try {
|
|
108
|
+
const row = db.prepare("SELECT name, title FROM threads WHERE id = ?").get(sessionId);
|
|
109
|
+
return row ? cleanTitle(row.name) ?? cleanTitle(row.title) : undefined;
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
db.close();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** The title of the conversation `input` (the hook's stdin) belongs to, or undefined; never throws. */
|
|
116
|
+
export async function readTitle(client, input) {
|
|
117
|
+
let timer;
|
|
118
|
+
const budget = new Promise((resolve) => { timer = setTimeout(resolve, TITLE_BUDGET_MS, undefined); });
|
|
119
|
+
const read = client === "claude" ? claudeTitle(input.transcript_path) : codexTitle(input.session_id);
|
|
120
|
+
try {
|
|
121
|
+
return await Promise.race([read.catch(() => undefined), budget]);
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
}
|
|
126
|
+
}
|