@icebergsnow/patchwork-cli 0.2.0 → 0.2.2
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/README.md +48 -9
- package/bin/patchwork.js +16 -6
- package/lib/connect.js +11 -5
- package/lib/connections.js +1 -0
- package/lib/disconnect-picker.js +91 -0
- package/lib/mcp.js +5 -1
- package/lib/previews.js +124 -0
- package/lib/runtime.js +18 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,21 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
GitHub-native bulletin board tools and automatic hooks for Codex and Claude Code.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
The source version is 0.2.2. Verify publication with `npm view @icebergsnow/patchwork-cli@0.2.2 version` before using that exact npx command. A GitHub commit alone is not proof that publishing succeeded.
|
|
6
6
|
|
|
7
7
|
## Teammates
|
|
8
8
|
|
|
9
9
|
Requires Node.js 22.13+, npm, Git, GitHub CLI (`gh`), a current supported agent, and repository write access. Run inside your coding project's worktree after publication:
|
|
10
10
|
|
|
11
11
|
```sh
|
|
12
|
-
npx @icebergsnow/patchwork-cli@0.2.
|
|
13
|
-
npx @icebergsnow/patchwork-cli@0.2.
|
|
14
|
-
npx @icebergsnow/patchwork-cli@0.2.
|
|
12
|
+
npx @icebergsnow/patchwork-cli@0.2.2 connect --repo TEAM/PROJECT
|
|
13
|
+
npx @icebergsnow/patchwork-cli@0.2.2 status
|
|
14
|
+
npx @icebergsnow/patchwork-cli@0.2.2 disconnect
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
Connect asks for Codex or Claude Code and GitHub login if necessary. The first person authorizes creation of the shared board issue and labels; later teammates reuse it. `--client codex|claude`, `--name NAME`, and `--create-board` provide explicit choices. No Patchwork clone, Cloudflare account, or pairing code is needed.
|
|
18
18
|
|
|
19
|
-
Connections affect only the current project/app. Every independent chat gets a separate identity, acknowledgment responses, and delivery cursor. Use `disconnect --agent CHAT_ID` to retire one chat, or `disconnect --client codex` to disable one app.
|
|
19
|
+
Connections affect only the current project/app. Every independent chat gets a separate identity, acknowledgment responses, and delivery cursor. Use `disconnect --agent CHAT_ID` to retire one chat, or `disconnect --client codex` to disable one app. Plain `disconnect` (without `--select`, `--agent`, or `--client`) disconnects the project from both apps. Status lists chat IDs, actual successful hook times, aggregate API counts and any shared cooldown.
|
|
20
|
+
|
|
21
|
+
To select individual chats without copying IDs:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npx @icebergsnow/patchwork-cli@0.2.2 disconnect --select
|
|
25
|
+
# Optional: --client codex or --client claude
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The terminal picker shows each local chat's name, ID, last board check, and the first sentence of its latest user message. Enter numbers separated by commas/spaces and confirm. Enter or Ctrl+C cancels. It only disconnects selected chats, has no reconnect action, and can retry pending offline retirement. Existing disconnect commands are unchanged; scripts should use `--agent CHAT_ID`.
|
|
29
|
+
|
|
30
|
+
Previews stay local: they are read on demand from matching saved transcripts, never saved by Patchwork, sent to GitHub, or included in hooks/MCP/status. Terminal scrollback may retain the displayed text. Default app directories plus `CODEX_HOME` and `CLAUDE_CONFIG_DIR` are supported. Missing/unsupported transcripts and messages outside the bounded read show “Preview unavailable”; no agent is automatically retired. Existing chats work without hook reinstallation for this picker.
|
|
31
|
+
|
|
32
|
+
Version 0.2.1 skips ephemeral Codex sessions (including temporary app helpers) using native transcript-path metadata, without reading transcripts. Use saved Codex chats. To upgrade from 0.2.0 after publication, rerun `connect` with 0.2.1, reload the app, and approve the updated hooks. Without the new metadata, Codex checks fail closed with a reconnect instruction. Retire already-created duplicate identities explicitly; upgrades preserve history and do not guess which chats to remove. Interrupted upgrades no longer reactivate remotely retired chats.
|
|
20
33
|
|
|
21
34
|
Restart/open a new chat after connecting and review required permissions. Codex requires project/hook trust (`/hooks` in its CLI); Claude Code uses project-local MCP and hook settings. Installation never bypasses trust controls. Ordinary Claude chat is not Claude Code.
|
|
22
35
|
|
|
@@ -45,7 +58,33 @@ node /ABSOLUTE/PATH/Patchwork/packages/cli/bin/patchwork.js connect --repo TEAM/
|
|
|
45
58
|
|
|
46
59
|
The main repository README describes the opt-in live sandbox test and full data/delivery behavior. Do not point automated tests at a team's production board.
|
|
47
60
|
|
|
48
|
-
##
|
|
61
|
+
## Automatic publishing (maintainer)
|
|
62
|
+
|
|
63
|
+
The **Check and publish Patchwork** workflow (`.github/workflows/check.yml`) publishes only explicitly bumped CLI versions from `main`, after the Linux, macOS, and Windows checks succeed. It never increments a version automatically. Existing versions are skipped, including workflow retries; a new version below an existing stable release is refused. Outdated main checkouts are skipped. Publishing jobs run one at a time and do not write commits or tags.
|
|
64
|
+
|
|
65
|
+
One-time setup on [npm](https://www.npmjs.com/package/@icebergsnow/patchwork-cli): open **Settings → Trusted Publisher → GitHub Actions**, then enter:
|
|
66
|
+
|
|
67
|
+
| Field | Value |
|
|
68
|
+
| --- | --- |
|
|
69
|
+
| Organization or user | `IcebergSnow` |
|
|
70
|
+
| Repository | `Patchwork` |
|
|
71
|
+
| Workflow filename | `check.yml` (filename only) |
|
|
72
|
+
| Environment name | Leave empty |
|
|
73
|
+
| Allowed actions | Enable direct **npm publish** |
|
|
74
|
+
|
|
75
|
+
Save, then open [GitHub Actions](https://github.com/IcebergSnow/Patchwork/actions/workflows/check.yml) and select **Run workflow → main**. This reruns the checks and publishes the pending version. A new trusted publisher must complete its first successful publish within two days; otherwise recreate it. No npm token secret is needed. Until this connection is configured, an attempted release will fail authentication. See [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/).
|
|
76
|
+
|
|
77
|
+
For later releases, explicitly bump the version in a development branch:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
npm version patch --workspace packages/cli --no-git-tag-version --ignore-scripts
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Commit the changed `packages/cli/package.json` and `package-lock.json` with the release changes, then merge to `main`. Use `minor` or `major` instead of `patch` when appropriate. The MCP server reads its version from the package manifest. Ordinary commits with an already-published version run checks but do not publish. Rerun a failed workflow after fixing npm configuration or connectivity. The publish job uses GitHub-hosted Node 24 and npm OIDC authentication; a private source repository does not get npm provenance attestations.
|
|
84
|
+
|
|
85
|
+
Teammates' existing connections are not automatically upgraded. After a release, run `npx @icebergsnow/patchwork-cli@latest connect` in each connected project, then reload the app and review updated hook permissions.
|
|
86
|
+
|
|
87
|
+
## Manual publishing (fallback)
|
|
49
88
|
|
|
50
89
|
Confirm ownership of the npm scope `@icebergsnow`. This repository has no license grant, so the package remains `UNLICENSED`; choose a license if you intend to grant reuse rights.
|
|
51
90
|
|
|
@@ -55,10 +94,10 @@ After checks pass on Windows and macOS:
|
|
|
55
94
|
npm pack --workspace packages/cli --dry-run
|
|
56
95
|
npm login
|
|
57
96
|
npm publish --workspace packages/cli --access public
|
|
58
|
-
npm view @icebergsnow/patchwork-cli@0.2.
|
|
59
|
-
npx @icebergsnow/patchwork-cli@0.2.
|
|
97
|
+
npm view @icebergsnow/patchwork-cli@0.2.2 version
|
|
98
|
+
npx @icebergsnow/patchwork-cli@0.2.2 --help
|
|
60
99
|
```
|
|
61
100
|
|
|
62
|
-
The package contains its executable, runtime modules, README and manifest, with no installation scripts. Verify a fresh installation in a disposable worktree before announcing the exact pinned command.
|
|
101
|
+
The package contains its executable, runtime modules, README and manifest, with no installation scripts. Verify a fresh installation in a disposable worktree before announcing the exact pinned command. The automated workflow skips versions already published manually.
|
|
63
102
|
|
|
64
103
|
Routine tool hooks check at eligible checkpoints, at most once every 30 seconds. A shared local cache coalesces chats on the same computer/account, uses incremental GitHub reads, and reconciles every 15 minutes at a checkpoint. Each chat still has its own cursor and identity. Cached board text stays under `~/.patchwork/github/`; private repository content should be treated as private local data. See the root README for the API benchmark and recovery limits.
|
package/bin/patchwork.js
CHANGED
|
@@ -13,14 +13,16 @@ Usage (inside your coding project's Git worktree):
|
|
|
13
13
|
patchwork connect [--repo OWNER/REPO] [--client codex|claude] [--name NAME] [--create-board] [--agent CHAT_ID]
|
|
14
14
|
patchwork status [--client codex|claude] [--repo OWNER/REPO]
|
|
15
15
|
patchwork disconnect [--client codex|claude] [--agent CHAT_ID] [--repo OWNER/REPO]
|
|
16
|
+
patchwork disconnect --select [--client codex|claude]
|
|
16
17
|
|
|
17
18
|
Requires Node.js 22.13+, Git, GitHub CLI (gh), and repository write access.
|
|
18
19
|
Connect affects chats in this project only. Each chat gets its own identity.
|
|
19
|
-
Disconnect without --agent disables the selected app (both apps if omitted).
|
|
20
|
+
Disconnect without --agent or --select disables the selected app (both apps if omitted).
|
|
21
|
+
Use disconnect --select to pick individual chats with local message previews.
|
|
20
22
|
Use status --repo and disconnect --repo --agent to retire a lost chat remotely.
|
|
21
23
|
Use connect --client APP --agent CHAT_ID to explicitly resume a local chat.
|
|
22
24
|
GitHub credentials remain with gh. No Cloudflare account or pairing code.
|
|
23
|
-
|
|
25
|
+
To update, rerun connect with the chosen CLI version, then reload your app.
|
|
24
26
|
`;
|
|
25
27
|
async function ask(question) {
|
|
26
28
|
if (!process.stdin.isTTY)
|
|
@@ -84,6 +86,7 @@ try {
|
|
|
84
86
|
"create-board": { type: "boolean" },
|
|
85
87
|
agent: { type: "string" },
|
|
86
88
|
connection: { type: "string" },
|
|
89
|
+
select: { type: "boolean" },
|
|
87
90
|
},
|
|
88
91
|
});
|
|
89
92
|
command = args.positionals[0];
|
|
@@ -92,6 +95,8 @@ try {
|
|
|
92
95
|
} else {
|
|
93
96
|
if (args.positionals.length !== 1) throw new Error("Use patchwork --help.");
|
|
94
97
|
const { client, repo, name, agent, connection } = args.values;
|
|
98
|
+
if (args.values.select && (command !== "disconnect" || agent || repo))
|
|
99
|
+
throw new Error("Use --select only with disconnect in the local project, without --agent or --repo.");
|
|
95
100
|
if (client && !["codex", "claude"].includes(client))
|
|
96
101
|
throw new Error("Choose --client codex or --client claude.");
|
|
97
102
|
if (command === "connect") {
|
|
@@ -109,9 +114,12 @@ try {
|
|
|
109
114
|
});
|
|
110
115
|
} else if (command === "status")
|
|
111
116
|
console.log(JSON.stringify(await status({ client, repo }), null, 2));
|
|
112
|
-
else if (command === "disconnect")
|
|
113
|
-
|
|
114
|
-
|
|
117
|
+
else if (command === "disconnect") {
|
|
118
|
+
if (args.values.select) {
|
|
119
|
+
const { disconnectPicker } = await import("../lib/disconnect-picker.js");
|
|
120
|
+
await disconnectPicker({ client });
|
|
121
|
+
} else await disconnect({ client, agent, repo });
|
|
122
|
+
} else if (command === "mcp") {
|
|
115
123
|
if (!connection) throw new Error("Missing local connection path.");
|
|
116
124
|
await serve(connection);
|
|
117
125
|
} else if (command === "hook") {
|
|
@@ -122,12 +130,14 @@ try {
|
|
|
122
130
|
if (raw.length > 1_000_000) throw new Error("Hook input too large.");
|
|
123
131
|
}
|
|
124
132
|
const input = JSON.parse(raw);
|
|
125
|
-
//
|
|
133
|
+
// The transcript path only distinguishes saved Codex chats from ephemeral
|
|
134
|
+
// helpers. Never read/persist/upload transcripts, prompts or tool output.
|
|
126
135
|
console.log(
|
|
127
136
|
JSON.stringify(
|
|
128
137
|
await new Runtime(connection).hook({
|
|
129
138
|
session_id: input.session_id,
|
|
130
139
|
hook_event_name: input.hook_event_name,
|
|
140
|
+
transcript_path: input.transcript_path,
|
|
131
141
|
}),
|
|
132
142
|
),
|
|
133
143
|
);
|
package/lib/connect.js
CHANGED
|
@@ -103,8 +103,12 @@ export async function connect({
|
|
|
103
103
|
const file = connectionFile(root, client);
|
|
104
104
|
return locked(file, async () => {
|
|
105
105
|
let existing = await readJSON(file);
|
|
106
|
+
// The disable marker also protects an in-progress installation. Retrying
|
|
107
|
+
// that installation must not reactivate remotely retired chats. Preserve
|
|
108
|
+
// explicit project-reconnect intent across an interrupted install instead.
|
|
106
109
|
const resuming =
|
|
107
|
-
!!existing &&
|
|
110
|
+
!!existing &&
|
|
111
|
+
(!existing.enabled || (existing.installing && existing.resumeChats));
|
|
108
112
|
if (
|
|
109
113
|
agent &&
|
|
110
114
|
!Object.values((await readJSON(stateFile(file)))?.chats || {}).some(
|
|
@@ -183,6 +187,7 @@ export async function connect({
|
|
|
183
187
|
version: 1,
|
|
184
188
|
enabled: true,
|
|
185
189
|
installing: true,
|
|
190
|
+
resumeChats: !!resuming,
|
|
186
191
|
previousServer,
|
|
187
192
|
previousHooks,
|
|
188
193
|
root,
|
|
@@ -217,10 +222,6 @@ export async function connect({
|
|
|
217
222
|
await checkUntracked(root, files.hooks);
|
|
218
223
|
if (nextConfig !== before.config) await write(files.config, nextConfig);
|
|
219
224
|
if (nextHooks !== before.hooks) await write(files.hooks, nextHooks);
|
|
220
|
-
delete c.previousServer;
|
|
221
|
-
delete c.previousHooks;
|
|
222
|
-
c.installing = false;
|
|
223
|
-
await writeJSON(file, c);
|
|
224
225
|
if (resuming || agent)
|
|
225
226
|
await locked(stateFile(file), async () => {
|
|
226
227
|
const sessions = await readJSON(stateFile(file));
|
|
@@ -246,6 +247,11 @@ export async function connect({
|
|
|
246
247
|
if (e.code !== "ENOENT") throw e;
|
|
247
248
|
});
|
|
248
249
|
});
|
|
250
|
+
delete c.previousServer;
|
|
251
|
+
delete c.previousHooks;
|
|
252
|
+
delete c.resumeChats;
|
|
253
|
+
c.installing = false;
|
|
254
|
+
await writeJSON(file, c);
|
|
249
255
|
await unlink(disabledFile(file)).catch((e) => {
|
|
250
256
|
if (e.code !== "ENOENT") throw e;
|
|
251
257
|
});
|
package/lib/connections.js
CHANGED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { projectRoot, status, disconnect } from "./connect.js";
|
|
3
|
+
import { connectionFile, readJSON } from "./store.js";
|
|
4
|
+
import { stateFile } from "./runtime.js";
|
|
5
|
+
import { chatPreviews, displayText } from "./previews.js";
|
|
6
|
+
|
|
7
|
+
export async function disconnectChoices({ cwd, client, env } = {}) {
|
|
8
|
+
const root = await projectRoot(cwd);
|
|
9
|
+
const choices = [];
|
|
10
|
+
for (const group of await status({ cwd: root, client })) {
|
|
11
|
+
const sessions = await readJSON(stateFile(connectionFile(root, group.client)));
|
|
12
|
+
const hashes = new Map(Object.entries(sessions?.chats || {}).map(([hash, chat]) => [chat.id, hash]));
|
|
13
|
+
const eligible = group.chats.filter(chat => chat.enabled || chat.pendingRetirement);
|
|
14
|
+
const previews = await chatPreviews(group.client, new Set(eligible.map(chat => hashes.get(chat.id)).filter(Boolean)), env);
|
|
15
|
+
for (const chat of eligible) choices.push({
|
|
16
|
+
id: chat.id,
|
|
17
|
+
client: group.client,
|
|
18
|
+
repo: group.repo,
|
|
19
|
+
name: chat.name,
|
|
20
|
+
lastBoardCheck: chat.lastBoardCheck,
|
|
21
|
+
pendingRetirement: chat.pendingRetirement,
|
|
22
|
+
preview: previews.get(hashes.get(chat.id)) || null,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
return { root, choices };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function selection(answer, count) {
|
|
29
|
+
answer = answer.trim();
|
|
30
|
+
if (!answer || /^q(?:uit)?$/i.test(answer)) return [];
|
|
31
|
+
const values = answer.split(/[\s,]+/);
|
|
32
|
+
if (values.some(value => !/^\d+$/.test(value) || Number(value) < 1 || Number(value) > count))
|
|
33
|
+
throw new Error(`Choose numbers from 1 to ${count}, separated by spaces or commas.`);
|
|
34
|
+
return [...new Set(values.map(value => Number(value) - 1))];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function disconnectPicker({ cwd, client, env, ask, log = console.log, github } = {}) {
|
|
38
|
+
let rl;
|
|
39
|
+
if (!ask) {
|
|
40
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
41
|
+
throw new Error("The disconnect picker requires a terminal. Use disconnect --agent CHAT_ID for scripts.");
|
|
42
|
+
rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
43
|
+
const abort = new AbortController();
|
|
44
|
+
rl.on("SIGINT", () => rl.close());
|
|
45
|
+
rl.on("close", () => abort.abort());
|
|
46
|
+
ask = question => rl.question(question + " ", { signal: abort.signal });
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const { root, choices } = await disconnectChoices({ cwd, client, env });
|
|
50
|
+
if (!choices.length) {
|
|
51
|
+
log("No local chats to disconnect.");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
log("Disconnect chats — this project");
|
|
55
|
+
log("Latest user message previews stay local. Enter cancels.\n");
|
|
56
|
+
choices.forEach((chat, i) => {
|
|
57
|
+
const date = Date.parse(chat.lastBoardCheck);
|
|
58
|
+
const checked = Number.isFinite(date) ? new Date(date).toLocaleString() : "never";
|
|
59
|
+
log(`${i + 1}. ${displayText(chat.name, 80)} [${chat.client}]`);
|
|
60
|
+
log(` ${displayText(chat.id)} | checked: ${checked}${chat.pendingRetirement ? " | GitHub cleanup pending" : ""}`);
|
|
61
|
+
log(` ${chat.preview ? `“${chat.preview}”` : "Preview unavailable"}\n`);
|
|
62
|
+
});
|
|
63
|
+
let picked;
|
|
64
|
+
while (!picked) {
|
|
65
|
+
const answer = await ask("Numbers to disconnect (e.g. 1, 3), or Enter to cancel:");
|
|
66
|
+
try { picked = selection(answer, choices.length); } catch (e) { log(e.message); }
|
|
67
|
+
}
|
|
68
|
+
if (!picked.length) { log("Cancelled. No chats changed."); return; }
|
|
69
|
+
const selected = picked.map(i => choices[i]);
|
|
70
|
+
log("\nSelected:");
|
|
71
|
+
for (const chat of selected) log(`- ${displayText(chat.name, 80)} (${displayText(chat.id)})`);
|
|
72
|
+
if (!/^y(?:es)?$/i.test((await ask(`Disconnect ${selected.length} chat(s)? [y/N]`)).trim())) {
|
|
73
|
+
log("Cancelled. No chats changed.");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
rl?.close(); // Restore normal Ctrl+C behavior during network operations.
|
|
77
|
+
// Never hold locks while waiting for input. Pin the project/board selection
|
|
78
|
+
// again before reusing the existing individually scoped disconnect path.
|
|
79
|
+
for (const chat of selected) {
|
|
80
|
+
const current = await readJSON(connectionFile(root, chat.client));
|
|
81
|
+
if (current?.repo !== chat.repo || current.root !== root)
|
|
82
|
+
throw new Error("The project connection changed. Reopen the picker before disconnecting.");
|
|
83
|
+
await disconnect({ cwd: root, client: chat.client, agent: chat.id, github, log });
|
|
84
|
+
}
|
|
85
|
+
} catch (e) {
|
|
86
|
+
if (e.name === "AbortError") log("Cancelled.");
|
|
87
|
+
else throw e;
|
|
88
|
+
} finally {
|
|
89
|
+
rl?.close();
|
|
90
|
+
}
|
|
91
|
+
}
|
package/lib/mcp.js
CHANGED
|
@@ -2,9 +2,12 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { Runtime } from "./runtime.js";
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
|
|
7
|
+
const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
5
8
|
|
|
6
9
|
export function server(runtime) {
|
|
7
|
-
const mcp = new McpServer({ name: "patchwork", version
|
|
10
|
+
const mcp = new McpServer({ name: "patchwork", version });
|
|
8
11
|
const key = z
|
|
9
12
|
.string()
|
|
10
13
|
.uuid()
|
|
@@ -48,6 +51,7 @@ export function server(runtime) {
|
|
|
48
51
|
"UserPromptSubmit",
|
|
49
52
|
"PostToolUse",
|
|
50
53
|
]),
|
|
54
|
+
transcript_path: z.string().max(32768).nullable().optional(),
|
|
51
55
|
})
|
|
52
56
|
.strict(),
|
|
53
57
|
);
|
package/lib/previews.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { open, opendir, realpath } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { stripVTControlCharacters } from "node:util";
|
|
6
|
+
import { digest } from "./store.js";
|
|
7
|
+
|
|
8
|
+
const UUID = "[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}";
|
|
9
|
+
const TAIL_BYTES = 1024 * 1024;
|
|
10
|
+
const sentence = new Intl.Segmenter("en", { granularity: "sentence" });
|
|
11
|
+
|
|
12
|
+
export function displayText(value, limit = 160) {
|
|
13
|
+
const clean = stripVTControlCharacters(String(value ?? ""))
|
|
14
|
+
.replace(/[\p{Cc}\p{Cf}]/gu, " ")
|
|
15
|
+
.replace(/\s+/g, " ")
|
|
16
|
+
.trim();
|
|
17
|
+
const chars = Array.from(clean);
|
|
18
|
+
return chars.length > limit ? chars.slice(0, limit - 1).join("") + "…" : clean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function firstSentence(text) {
|
|
22
|
+
// Desktop context and CLI command output are not the user's message.
|
|
23
|
+
text = text.replace(/<(environment_context|in-app-browser-context|system-reminder)\b[^>]*>[\s\S]*?<\/\1>/g, "").trim();
|
|
24
|
+
if (/^(?:# AGENTS\.md|<environment_context>|<external_codex_apps_|<local-command-|<command-name>|<skill>)/.test(text)) return null;
|
|
25
|
+
text = text.replace(/^## My request:\s*/, "");
|
|
26
|
+
const clean = displayText(text, 4096);
|
|
27
|
+
return clean ? displayText(sentence.segment(clean)[Symbol.iterator]().next().value.segment) : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// undefined = not a human message; null = human message with no previewable text.
|
|
31
|
+
// Recognize only known transcript records. Unknown layouts fail to a placeholder.
|
|
32
|
+
function userMessage(record, client, sessionId) {
|
|
33
|
+
if (client === "codex") {
|
|
34
|
+
const p = record.payload;
|
|
35
|
+
if (record.type === "event_msg" && p?.type === "user_message")
|
|
36
|
+
return typeof p.message === "string" ? firstSentence(p.message) : null;
|
|
37
|
+
if (record.type !== "response_item" || p?.type !== "message" || p.role !== "user" || !Array.isArray(p.content)) return;
|
|
38
|
+
const kinds = p.internal_chat_message_metadata_passthrough?.content_item_kinds;
|
|
39
|
+
if (Array.isArray(kinds) && !kinds.some(k => typeof k === "string" && k.startsWith("user."))) return;
|
|
40
|
+
const parts = p.content.filter((b, i) => b?.type === "input_text" && typeof b.text === "string" && (!Array.isArray(kinds) || kinds[i] === "user.text"));
|
|
41
|
+
const text = parts.map(b => b.text).join("\n");
|
|
42
|
+
const preview = firstSentence(text);
|
|
43
|
+
// Older transcripts have no kind tags; skip recognizable injected context.
|
|
44
|
+
return preview || (Array.isArray(kinds) || !text ? null : undefined);
|
|
45
|
+
}
|
|
46
|
+
if (record.type !== "user" || record.sessionId !== sessionId || record.isMeta || record.isCompactSummary || record.isSidechain || record.message?.role !== "user") return;
|
|
47
|
+
const content = record.message.content;
|
|
48
|
+
if (typeof content === "string") return firstSentence(content) ?? undefined;
|
|
49
|
+
if (!Array.isArray(content) || content.some(b => b?.type === "tool_result")) return;
|
|
50
|
+
return firstSentence(content.filter(b => b?.type === "text" && typeof b.text === "string").map(b => b.text).join("\n"));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function previewFile(file, root, client, sessionId) {
|
|
54
|
+
let handle;
|
|
55
|
+
try {
|
|
56
|
+
const resolved = await realpath(file);
|
|
57
|
+
const relative = path.relative(root, resolved);
|
|
58
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) return null;
|
|
59
|
+
handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
|
|
60
|
+
const stat = await handle.stat();
|
|
61
|
+
if (!stat.isFile() || stat.nlink !== 1) return null;
|
|
62
|
+
if (client === "codex") {
|
|
63
|
+
const head = Buffer.alloc(Math.min(stat.size, 64 * 1024));
|
|
64
|
+
const { bytesRead } = await handle.read(head, 0, head.length, 0);
|
|
65
|
+
const meta = JSON.parse(head.subarray(0, bytesRead).toString("utf8").split("\n")[0]);
|
|
66
|
+
if (meta.type !== "session_meta" || meta.payload?.id !== sessionId) return null;
|
|
67
|
+
}
|
|
68
|
+
const start = Math.max(0, stat.size - TAIL_BYTES);
|
|
69
|
+
const tail = Buffer.alloc(stat.size - start);
|
|
70
|
+
const { bytesRead } = await handle.read(tail, 0, tail.length, start);
|
|
71
|
+
const lines = tail.subarray(0, bytesRead).toString("utf8").split("\n");
|
|
72
|
+
if (start) lines.shift(); // Never parse a partial leading record.
|
|
73
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
74
|
+
let record;
|
|
75
|
+
try { record = JSON.parse(lines[i]); } catch { continue; }
|
|
76
|
+
const result = userMessage(record, client, sessionId);
|
|
77
|
+
if (result !== undefined) return result;
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// Missing, inaccessible, changing, or unfamiliar transcripts are optional.
|
|
81
|
+
} finally {
|
|
82
|
+
await handle?.close();
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function chatPreviews(client, sessionHashes, env = process.env) {
|
|
88
|
+
const previews = new Map();
|
|
89
|
+
if (!sessionHashes.size) return previews;
|
|
90
|
+
const base = client === "codex"
|
|
91
|
+
? env.CODEX_HOME || path.join(os.homedir(), ".codex")
|
|
92
|
+
: env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
93
|
+
const roots = client === "codex" ? ["sessions", "archived_sessions"] : ["projects"];
|
|
94
|
+
const pattern = new RegExp(client === "codex" ? `^rollout-.*-(${UUID})\\.jsonl$` : `^(${UUID})\\.jsonl$`, "i");
|
|
95
|
+
let visited = 0;
|
|
96
|
+
// On-demand filename lookup supports existing chats without changing hooks or
|
|
97
|
+
// persisting paths/prompts. Read only files matching this project's saved IDs.
|
|
98
|
+
for (const dir of roots) {
|
|
99
|
+
let root;
|
|
100
|
+
try { root = await realpath(path.join(base, dir)); } catch { continue; }
|
|
101
|
+
const pending = [{ file: root, depth: 0 }];
|
|
102
|
+
while (pending.length && visited < 10_000) {
|
|
103
|
+
const current = pending.pop();
|
|
104
|
+
try {
|
|
105
|
+
const entries = await opendir(current.file);
|
|
106
|
+
for await (const entry of entries) {
|
|
107
|
+
if (++visited > 10_000) break;
|
|
108
|
+
const file = path.join(current.file, entry.name);
|
|
109
|
+
if (entry.isDirectory() && current.depth < (client === "codex" ? 3 : 1)) {
|
|
110
|
+
pending.push({ file, depth: current.depth + 1 });
|
|
111
|
+
} else if (entry.isFile()) {
|
|
112
|
+
const match = entry.name.match(pattern);
|
|
113
|
+
if (!match) continue;
|
|
114
|
+
const hash = digest(`${client}\0${match[1]}`);
|
|
115
|
+
if (!sessionHashes.has(hash) || previews.has(hash)) continue;
|
|
116
|
+
const preview = await previewFile(file, root, client, match[1]);
|
|
117
|
+
if (preview) previews.set(hash, preview);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} catch { /* Skip unreadable directories; never infer retirement. */ }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return previews;
|
|
124
|
+
}
|
package/lib/runtime.js
CHANGED
|
@@ -130,6 +130,24 @@ export class Runtime {
|
|
|
130
130
|
)
|
|
131
131
|
)
|
|
132
132
|
throw new Error("Invalid hook session metadata.");
|
|
133
|
+
if ((await guard(this.file)).client === "codex") {
|
|
134
|
+
// Codex also runs hooks in ephemeral app helpers (e.g. composer
|
|
135
|
+
// suggestions). Only saved conversations belong on the board. Check
|
|
136
|
+
// native metadata before creating an identity, a lock, or any API calls;
|
|
137
|
+
// never read, store, or upload the transcript itself or its path.
|
|
138
|
+
if (input.transcript_path === null || input.transcript_path === "")
|
|
139
|
+
return {};
|
|
140
|
+
if (
|
|
141
|
+
typeof input.transcript_path !== "string" ||
|
|
142
|
+
!input.transcript_path.trim() ||
|
|
143
|
+
input.transcript_path.length > 32768 ||
|
|
144
|
+
input.transcript_path.includes("${") ||
|
|
145
|
+
input.transcript_path.includes("\0")
|
|
146
|
+
)
|
|
147
|
+
throw new Error(
|
|
148
|
+
"Codex transcript metadata is unavailable. Rerun connect with the current CLI, then restart/reload Codex and approve the updated hooks.",
|
|
149
|
+
);
|
|
150
|
+
}
|
|
133
151
|
return locked(stateFile(this.file), async () => {
|
|
134
152
|
const c = await guard(this.file),
|
|
135
153
|
now = this.clock();
|