@webinson/peer 0.4.15 → 0.4.17
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 +57 -8
- package/bin/codex-thread-mark.mjs +232 -0
- package/bin/peer.mjs +85 -40
- package/mod/codex/.agents/plugins/marketplace.json +12 -0
- package/mod/codex/client/.gitattributes +1 -0
- package/mod/codex/client/0001-memory.patch +327 -0
- package/mod/codex/client/0002-terminal.patch +308 -0
- package/mod/codex/client/CODEX-LICENSE +201 -0
- package/mod/codex/client/CODEX-NOTICE +6 -0
- package/mod/codex/client/README.md +20 -0
- package/mod/codex/client/build.sh +67 -0
- package/mod/codex/client/run.sh +22 -0
- package/mod/codex/client/src/actions.rs +217 -0
- package/mod/codex/client/src/band.rs +469 -0
- package/mod/codex/client/src/openviking.rs +110 -0
- package/mod/codex/client/src/policy.rs +132 -0
- package/mod/codex/peer-thread-mark/.codex-plugin/plugin.json +12 -0
- package/mod/codex/peer-thread-mark/hooks/hooks.json +9 -0
- package/mod/codex/peer-thread-mark/hooks/peer.mjs +17 -0
- package/mod/peer-thread-mark/.claude-plugin/plugin.json +1 -1
- package/mod/peer-thread-mark/hooks/register.tsx +50 -24
- package/mod/peer-thread-mark/tests/band.test.tsx +5 -0
- package/mod/peer-thread-mark/tests/modes.test.tsx +1 -0
- package/mod/peer-thread-mark/tests/saved.test.tsx +2 -2
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Peer
|
|
2
2
|
|
|
3
3
|
See which people on your team have agents running, on what they work, and which stopped lately.
|
|
4
|
-
Only the Claude Code threads
|
|
4
|
+
Only the Claude Code and Codex threads explicitly shared through Peer appear in the list.
|
|
5
5
|
|
|
6
6
|
```bash
|
|
7
7
|
npm install -g @webinson/peer
|
|
@@ -19,12 +19,12 @@ In a Claude Code thread:
|
|
|
19
19
|
- `/peer stop` ends sharing. Quitting or restarting Claude Code only pauses it: the thread shows as stopped and
|
|
20
20
|
is shared again when the session is resumed. `/clear` and signing out of Claude Code end it.
|
|
21
21
|
- A band above the prompt shows the thread's memories (`17 memories`), `Use Elephant` and `Use Jellyfish`, and
|
|
22
|
-
`● Active` (shared) or `○ Inactive` at the right;
|
|
23
|
-
the thread uses:
|
|
24
|
-
- **Use Elephant** (shared): your OpenViking
|
|
25
|
-
top of Claude Code's own memory. It saves only the turns from the moment it was chosen.
|
|
26
|
-
- **Inactive** (not shared): Claude Code's own memory alone;
|
|
27
|
-
- **Use Jellyfish** (shared): no memory at all, neither
|
|
22
|
+
`● Active` (shared) or `○ Inactive` at the right; a press says in a toast what the thread now uses. The modes
|
|
23
|
+
decide what memory the thread uses:
|
|
24
|
+
- **Use Elephant** (shared): PeerMemory (your personal OpenViking), recalled with each prompt and saved as the
|
|
25
|
+
thread goes, on top of Claude Code's own memory. It saves only the turns from the moment it was chosen.
|
|
26
|
+
- **Inactive** (not shared): Claude Code's own memory alone; PeerMemory stays off.
|
|
27
|
+
- **Use Jellyfish** (shared): no memory at all, neither PeerMemory nor Claude Code's own (`MEMORY.md`).
|
|
28
28
|
`CLAUDE.md` instructions stay.
|
|
29
29
|
|
|
30
30
|
Pressing the lit mode again stops sharing (Inactive); pressing either while Inactive shares again. Every thread
|
|
@@ -79,7 +79,56 @@ a terminal does.
|
|
|
79
79
|
|
|
80
80
|
- A resumed or forked session can get a new session id. It is then not shared until `/peer` again.
|
|
81
81
|
- Subagents do not appear: the hooks Peer uses fire for the main thread only.
|
|
82
|
-
|
|
82
|
+
|
|
83
|
+
## Codex
|
|
84
|
+
|
|
85
|
+
Install the npm package and set up the Peer terminal with:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
npm install -g @webinson/peer
|
|
89
|
+
peer login --codex --client you@company.com
|
|
90
|
+
peer-codex
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
If you already signed in to Peer, use `peer install --codex --client` in place of the login
|
|
94
|
+
command. Review and trust the Peer Thread Mark hooks in Codex's `/hooks` menu.
|
|
95
|
+
|
|
96
|
+
The `--client` setup needs Git, Rust/Cargo, `curl`, `shasum`, and a C/C++ toolchain on
|
|
97
|
+
the machine. It builds the pinned Codex client under `~/.peer/codex-client`. If you already
|
|
98
|
+
built it, set `PEER_CODEX_CLIENT` to its executable when you run setup. The `peer-codex`
|
|
99
|
+
launcher uses your existing Codex settings and sign-in with its own app server. `PEER_HOME`
|
|
100
|
+
moves the default checkout and Peer state.
|
|
101
|
+
|
|
102
|
+
For the plugin alone, omit `--client`. The Codex plugin lives under [`mod/codex`](mod/codex)
|
|
103
|
+
in its own marketplace.
|
|
104
|
+
|
|
105
|
+
In stock Codex, send `peer: start`, `peer: stop`, `peer: list`, `peer: thread`, or
|
|
106
|
+
`peer: all [words] [here] [--newest | --oldest] [page N]`. The hook answers these locally.
|
|
107
|
+
This plugin uses the [Codex hook API](https://learn.chatgpt.com/docs/hooks); stock Codex does
|
|
108
|
+
not expose a composer band or memory switches to plugins.
|
|
109
|
+
|
|
110
|
+
From a source checkout, use `node bin/peer.mjs install --codex --client` and
|
|
111
|
+
`bash mod/codex/client/run.sh` from `cli/`.
|
|
112
|
+
|
|
113
|
+
The terminal band shows the thread's memory count, **Use Elephant**, **Use Jellyfish**, and
|
|
114
|
+
**Active** or **Inactive**. Press the lit mode again to stop sharing. Use `/peer-mode` for
|
|
115
|
+
keyboard controls. `/peer [message]` shares the thread and submits an optional message;
|
|
116
|
+
`/peer stop` stops sharing. `/peer-list`, `/peer-thread` and `/peer-all` open terminal panels.
|
|
117
|
+
The threads panel refreshes while open; the memory search accepts the same words and filters
|
|
118
|
+
as Claude Code.
|
|
119
|
+
|
|
120
|
+
- **Elephant** shares the thread and allows the installed OpenViking plugin and Codex's own
|
|
121
|
+
memory. OpenViking receives only the turns from this thread's Elephant intervals.
|
|
122
|
+
- **Jellyfish** shares the thread with both memory integrations off. Instructions such as
|
|
123
|
+
`AGENTS.md` stay available. Codex's own memory can be read again in another mode. Its native
|
|
124
|
+
generation remains off for that thread so another Codex client sharing the same history
|
|
125
|
+
cannot extract the Jellyfish turns later.
|
|
126
|
+
- **Inactive** stops sharing. Codex's own memory follows your usual settings; the OpenViking
|
|
127
|
+
plugin is off in this client.
|
|
128
|
+
|
|
129
|
+
The mode applies to future turns. Context already present in the chat stays there. Use the
|
|
130
|
+
Peer terminal client to enforce these modes; stock Codex and separately configured memory tools
|
|
131
|
+
follow their own settings. `peer uninstall --codex` removes the plugin from Codex.
|
|
83
132
|
|
|
84
133
|
## Team memory (optional)
|
|
85
134
|
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
// Codex's lifecycle hooks use their own event and output shapes. Keep Claude's
|
|
6
|
+
// title updates and UI out of this adapter.
|
|
7
|
+
const local = (reason) => ({ decision: "block", reason });
|
|
8
|
+
const MARKETPLACE = "webinson-peer-codex";
|
|
9
|
+
const PLUGIN = `peer-thread-mark@${MARKETPLACE}`;
|
|
10
|
+
const setupFile = (peer) => path.join(peer.home, "codex-cli.json");
|
|
11
|
+
|
|
12
|
+
export function installCodex(peer, { client = false } = {}) {
|
|
13
|
+
if (peer.cli.includes(`${path.sep}_npx${path.sep}`)) {
|
|
14
|
+
throw new Error("Codex needs a lasting `peer`: run `npm install -g @webinson/peer`, then `peer install --codex`.");
|
|
15
|
+
}
|
|
16
|
+
const marketplace = fs.realpathSync(path.join(peer.mod, "codex"));
|
|
17
|
+
let clientPath = peer.readJson(setupFile(peer))?.client;
|
|
18
|
+
if (client) {
|
|
19
|
+
if (process.env.PEER_CODEX_CLIENT) {
|
|
20
|
+
clientPath = path.resolve(process.env.PEER_CODEX_CLIENT);
|
|
21
|
+
fs.accessSync(clientPath, fs.constants.X_OK);
|
|
22
|
+
if (!fs.statSync(clientPath).isFile()) throw new Error("PEER_CODEX_CLIENT must point to the Codex executable");
|
|
23
|
+
} else {
|
|
24
|
+
const checkout = path.resolve(peer.home, "codex-client");
|
|
25
|
+
execFileSync("bash", [path.join(marketplace, "client", "build.sh"), checkout], { stdio: "inherit" });
|
|
26
|
+
clientPath = path.join(checkout, "codex-rs", "target", "debug", "codex");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const binary = process.env.PEER_CODEX || (client ? clientPath : "codex");
|
|
31
|
+
const runCodex = (args, capture = false) => codex(args, { binary, capture });
|
|
32
|
+
const listed = runCodex(["plugin", "marketplace", "list", "--json"], true);
|
|
33
|
+
const previous = JSON.parse(listed).marketplaces?.find((item) => item.name === MARKETPLACE);
|
|
34
|
+
const moving = previous && previous.root !== marketplace;
|
|
35
|
+
if (moving && previous.marketplaceSource?.sourceType !== "local") {
|
|
36
|
+
throw new Error(`Codex marketplace ${MARKETPLACE} already uses a non-local source`);
|
|
37
|
+
}
|
|
38
|
+
if (moving) runCodex(["plugin", "marketplace", "remove", MARKETPLACE]);
|
|
39
|
+
let added = false;
|
|
40
|
+
try {
|
|
41
|
+
runCodex(["plugin", "marketplace", "add", marketplace]);
|
|
42
|
+
added = true;
|
|
43
|
+
runCodex(["plugin", "add", PLUGIN]);
|
|
44
|
+
peer.writeJson(setupFile(peer), { node: process.execPath, peer: peer.cli, client: clientPath });
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (moving) {
|
|
47
|
+
try {
|
|
48
|
+
if (added) runCodex(["plugin", "marketplace", "remove", MARKETPLACE]);
|
|
49
|
+
runCodex(["plugin", "marketplace", "add", previous.marketplaceSource.source]);
|
|
50
|
+
} catch {
|
|
51
|
+
console.error(`Could not restore the previous ${MARKETPLACE} marketplace.`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
console.log("Codex: Peer installed. Review and trust its hooks in /hooks.");
|
|
57
|
+
if (client) console.log("Start the Peer terminal with `peer-codex`.");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function uninstallCodex(peer) {
|
|
61
|
+
const clientPath = peer.readJson(setupFile(peer))?.client;
|
|
62
|
+
const binary = process.env.PEER_CODEX || (clientPath && fs.existsSync(clientPath) ? clientPath : "codex");
|
|
63
|
+
codex(["plugin", "remove", PLUGIN], { binary });
|
|
64
|
+
fs.rmSync(setupFile(peer), { force: true });
|
|
65
|
+
console.log("Codex: Peer's plugin removed.");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function codex(args, { binary, capture = false }) {
|
|
69
|
+
return execFileSync(binary, args, {
|
|
70
|
+
stdio: capture ? ["ignore", "pipe", "inherit"] : "inherit",
|
|
71
|
+
encoding: "utf8",
|
|
72
|
+
timeout: 60_000,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const modeOf = (peer, session) => {
|
|
77
|
+
const mark = peer.readJson(peer.markerOf(session));
|
|
78
|
+
return mark?.workspace ? mark.mode === "jellyfish" ? "jellyfish" : "elephant" : "inactive";
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// The capture hook sees only these windows. Closing one freezes the transcript
|
|
82
|
+
// that OpenViking can recover on a later resume, even if a worker missed a turn.
|
|
83
|
+
function recordMode(peer, session, before, after) {
|
|
84
|
+
if (before === after) return;
|
|
85
|
+
const file = path.join(peer.home, "codex-capture", `${session}.json`);
|
|
86
|
+
const saved = peer.readJson(file);
|
|
87
|
+
const windows = Array.isArray(saved) ? saved : [];
|
|
88
|
+
const now = Date.now();
|
|
89
|
+
const last = windows.at(-1);
|
|
90
|
+
if (last?.end === null) {
|
|
91
|
+
last.end = now;
|
|
92
|
+
if (before === "inactive" && last.mode === "elephant") {
|
|
93
|
+
// Logout or expiry may have removed a marker without a final hook. Only
|
|
94
|
+
// keep the old window through its last known opt-in projection.
|
|
95
|
+
const snapshot = path.join(peer.home, "codex-capture", "rollouts", `${session}.jsonl`);
|
|
96
|
+
const observed = fs.statSync(snapshot, { throwIfNoEntry: false })?.mtimeMs;
|
|
97
|
+
last.end = Math.max(last.start, Math.min(now, Math.ceil(observed ?? last.start)));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (after !== "inactive") windows.push({ mode: after, start: now, end: null });
|
|
101
|
+
peer.writeJson(file, windows);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function setMode(peer, id, cwd, target, { toggle = false, working = false } = {}) {
|
|
105
|
+
const session = `codex-${id}`;
|
|
106
|
+
const before = modeOf(peer, session);
|
|
107
|
+
const mode = toggle && before === target ? "inactive" : target;
|
|
108
|
+
const saved = peer.signedIn();
|
|
109
|
+
recordMode(peer, session, before, mode);
|
|
110
|
+
if (mode === "inactive") return peer.stopSharing(saved, session, cwd, "codex");
|
|
111
|
+
return (await peer.shareThread(saved, session, cwd, working, "codex", mode)).said;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const countMemories = ({ saves }) => {
|
|
115
|
+
if (!saves) return null;
|
|
116
|
+
const items = saves.flatMap((save) => save.items);
|
|
117
|
+
const updated = new Set(items.filter((item) => item.change === "updated" && !item.systemText).map((item) => `${item.type}:${item.name}`));
|
|
118
|
+
return items.filter((item) => item.change === "added" || item.systemText).length + updated.size;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export async function codexControl(input, peer) {
|
|
122
|
+
const { action, session_id: id, cwd, query = "" } = input;
|
|
123
|
+
const session = `codex-${id}`;
|
|
124
|
+
if (!peer.validSession(id) || !peer.validSession(session)) throw new Error("invalid Codex session id");
|
|
125
|
+
|
|
126
|
+
let output;
|
|
127
|
+
let memoryCount;
|
|
128
|
+
let memoryPage;
|
|
129
|
+
switch (action) {
|
|
130
|
+
case "status":
|
|
131
|
+
memoryCount = await peer.threadMemories(id, true).then(countMemories).catch(() => null);
|
|
132
|
+
break;
|
|
133
|
+
case "elephant":
|
|
134
|
+
case "jellyfish":
|
|
135
|
+
output = await setMode(peer, id, cwd, action, { toggle: true });
|
|
136
|
+
break;
|
|
137
|
+
case "start":
|
|
138
|
+
output = await setMode(peer, id, cwd, modeOf(peer, session) === "jellyfish" ? "jellyfish" : "elephant", { working: input.working === true });
|
|
139
|
+
break;
|
|
140
|
+
case "stop":
|
|
141
|
+
output = await setMode(peer, id, cwd, "inactive");
|
|
142
|
+
break;
|
|
143
|
+
case "list":
|
|
144
|
+
output = await peer.listText(peer.signedIn());
|
|
145
|
+
break;
|
|
146
|
+
case "thread": {
|
|
147
|
+
const memories = await peer.threadMemories(id, true);
|
|
148
|
+
output = peer.threadMemoryText(memories);
|
|
149
|
+
memoryCount = countMemories(memories);
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
case "all": {
|
|
153
|
+
const filters = peer.memoryQuery(query.split(/\s+/).filter(Boolean), cwd || process.cwd());
|
|
154
|
+
memoryPage = { ...await peer.memoryPage(filters), here: filters.here };
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
default:
|
|
158
|
+
throw new Error("unknown Codex Peer action");
|
|
159
|
+
}
|
|
160
|
+
return { mode: modeOf(peer, session), output, memoryCount, memoryPage };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function codexHook(input, peer) {
|
|
164
|
+
const { hook_event_name: event, session_id: id, cwd } = input;
|
|
165
|
+
const session = `codex-${id}`;
|
|
166
|
+
if (input.agent_id || !peer.validSession(id) || !peer.validSession(session)) return;
|
|
167
|
+
|
|
168
|
+
switch (event) {
|
|
169
|
+
case "SessionStart":
|
|
170
|
+
peer.forgetOldThreads();
|
|
171
|
+
await peer.teamHook("team-start", { ...input, session_id: session });
|
|
172
|
+
if (input.source === "resume") await peer.reportQuietly(session, "idle", cwd, "codex");
|
|
173
|
+
return;
|
|
174
|
+
case "Stop":
|
|
175
|
+
case "Interrupt":
|
|
176
|
+
await peer.reportQuietly(session, "idle", cwd, "codex", 1500);
|
|
177
|
+
return;
|
|
178
|
+
case "SessionEnd":
|
|
179
|
+
await peer.reportQuietly(session, "stopped", cwd, "codex", 1500);
|
|
180
|
+
return;
|
|
181
|
+
case "UserPromptSubmit":
|
|
182
|
+
return promptHook(String(input.prompt ?? "").trim(), id, cwd, peer);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function promptHook(prompt, id, cwd, peer) {
|
|
187
|
+
const session = `codex-${id}`;
|
|
188
|
+
// The text form also works in clients that handle slash commands before submitting a turn.
|
|
189
|
+
const all = /^(?:\/peer-all|peer:\s+all)(?:\s+([\s\S]*))?$/.exec(prompt);
|
|
190
|
+
if (all) {
|
|
191
|
+
const query = peer.memoryQuery((all[1] ?? "").split(/\s+/).filter(Boolean), cwd || process.cwd());
|
|
192
|
+
return local(await peer.memoryText(query).catch((error) => `Peer: your memories did not load (${error.message})`));
|
|
193
|
+
}
|
|
194
|
+
if (/^(?:\/peer-list|peer:\s+list)$/.test(prompt)) {
|
|
195
|
+
try {
|
|
196
|
+
return local(await peer.listText(peer.signedIn()));
|
|
197
|
+
} catch (error) {
|
|
198
|
+
return local(`Peer: ${error.message}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (/^(?:\/peer-thread|peer:\s+thread)$/.test(prompt)) {
|
|
202
|
+
try {
|
|
203
|
+
return local(peer.threadMemoryText(await peer.threadMemories(id, true)));
|
|
204
|
+
} catch (error) {
|
|
205
|
+
return local(`Peer: your thread memories did not load (${error.message})`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const share = /^(?:\/peer|peer:\s+start)(?:\s+([\s\S]*))?$/.exec(prompt);
|
|
210
|
+
const message = share?.[1]?.trim() ?? "";
|
|
211
|
+
const stop = (share && message === "stop") || /^peer:\s+stop$/.test(prompt);
|
|
212
|
+
if (share || stop) {
|
|
213
|
+
try {
|
|
214
|
+
if (stop) return local(await setMode(peer, id, cwd, "inactive"));
|
|
215
|
+
const mode = modeOf(peer, session);
|
|
216
|
+
const said = await setMode(peer, id, cwd, mode === "inactive" ? "elephant" : mode, { working: Boolean(message) });
|
|
217
|
+
if (!message) return local(said);
|
|
218
|
+
return {
|
|
219
|
+
hookSpecificOutput: {
|
|
220
|
+
hookEventName: "UserPromptSubmit",
|
|
221
|
+
additionalContext: `${said}\nThe user included a message after their Peer command. Continue with that message.`,
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
} catch (error) {
|
|
225
|
+
return local(`Peer: ${error.message}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
await peer.reportQuietly(session, "working", cwd, "codex");
|
|
229
|
+
if (modeOf(peer, session) === "elephant") {
|
|
230
|
+
return peer.teamHook("team-prompt", { session_id: session, cwd, prompt });
|
|
231
|
+
}
|
|
232
|
+
}
|
package/bin/peer.mjs
CHANGED
|
@@ -30,8 +30,8 @@ const MARKETPLACE = "webinson-peer";
|
|
|
30
30
|
|
|
31
31
|
const HELP = `peer: share chosen agent threads with your team
|
|
32
32
|
|
|
33
|
-
peer login [email] [--hub <url>] [--workspace <slug>]
|
|
34
|
-
sign in once and set up Claude Code
|
|
33
|
+
peer login [email] [--hub <url>] [--workspace <slug>] [--codex [--client]]
|
|
34
|
+
sign in once and set up Claude Code or Codex
|
|
35
35
|
peer list people in the workspace, their shared threads and what they work on
|
|
36
36
|
peer status who you are, the workspace and the threads this computer shares
|
|
37
37
|
peer doctor why Peer's hook did not answer /peer
|
|
@@ -43,8 +43,9 @@ const HELP = `peer: share chosen agent threads with your team
|
|
|
43
43
|
peer all [words] [here] [--newest | --oldest] [page N]
|
|
44
44
|
your own memories in your personal OpenViking, or the ones with these words
|
|
45
45
|
(full text); here: only this repository's. /peer-all in Claude Code
|
|
46
|
-
peer install
|
|
47
|
-
|
|
46
|
+
peer install [--codex [--client]]
|
|
47
|
+
set up the thread mark; --client also builds the Peer Codex terminal
|
|
48
|
+
peer uninstall [--codex] remove the integration from Claude Code or Codex
|
|
48
49
|
peer logout forget the sign-in on this computer
|
|
49
50
|
|
|
50
51
|
In Claude Code: /peer shares the thread (/peer <message> also sends the message on), /peer stop
|
|
@@ -133,8 +134,8 @@ function workingOn(cwd) {
|
|
|
133
134
|
return { repository, branch: branch && branch !== "HEAD" ? branch : undefined, repoKey };
|
|
134
135
|
}
|
|
135
136
|
|
|
136
|
-
async function report(saved, session, state, cwd) {
|
|
137
|
-
await call(saved, "PUT", `/workspaces/${saved.workspace}/agents/${session}`, { agent
|
|
137
|
+
async function report(saved, session, state, cwd, agent = "claude-code", timeoutMs = HUB_TIMEOUT_MS) {
|
|
138
|
+
await call(saved, "PUT", `/workspaces/${saved.workspace}/agents/${session}`, { agent, state, ...workingOn(cwd) }, timeoutMs);
|
|
138
139
|
}
|
|
139
140
|
|
|
140
141
|
// ---- the list ----
|
|
@@ -235,6 +236,28 @@ function log(line) {
|
|
|
235
236
|
} catch {}
|
|
236
237
|
}
|
|
237
238
|
|
|
239
|
+
async function shareThread(saved, session, cwd, working, agent, mode) {
|
|
240
|
+
writeJson(markerOf(session), { workspace: saved.workspace, sharedAt: new Date().toISOString(), mode });
|
|
241
|
+
const where = workingOn(cwd);
|
|
242
|
+
const what = [where.repository, where.branch].filter(Boolean).map(plain).join(" @ ");
|
|
243
|
+
let said = `Peer: this thread is shared in ${saved.workspace}${what ? ` as ${what}` : ""}. Teammates see its repository, branch and state, nothing of the conversation. /peer stop ends it.`;
|
|
244
|
+
try {
|
|
245
|
+
await report(saved, session, working ? "working" : "idle", cwd, agent);
|
|
246
|
+
} catch (error) {
|
|
247
|
+
said = `Peer: shared, but the hub did not answer yet (${error.message}). The next turn tries again.`;
|
|
248
|
+
}
|
|
249
|
+
return { said, what };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function stopSharing(saved, session, cwd, agent) {
|
|
253
|
+
const marker = markerOf(session);
|
|
254
|
+
const shared = readJson(marker);
|
|
255
|
+
fs.rmSync(marker, { force: true });
|
|
256
|
+
if (!shared?.workspace) return "Peer: this thread was not shared.";
|
|
257
|
+
await report({ ...saved, workspace: shared.workspace }, session, "stopped", cwd, agent).catch(() => {});
|
|
258
|
+
return "Peer: this thread is no longer shared.";
|
|
259
|
+
}
|
|
260
|
+
|
|
238
261
|
async function hook(event) {
|
|
239
262
|
const input = await readStdin();
|
|
240
263
|
const session = input.session_id;
|
|
@@ -268,19 +291,10 @@ async function hook(event) {
|
|
|
268
291
|
return block(`Peer: ${error.message}`);
|
|
269
292
|
}
|
|
270
293
|
if (argument === "stop") {
|
|
271
|
-
|
|
272
|
-
if (!shared) return block("Peer: this thread was not shared.", retitle(input, false));
|
|
273
|
-
await report(saved, session, "stopped", input.cwd).catch(() => {});
|
|
274
|
-
return block("Peer: this thread is no longer shared.", retitle(input, false));
|
|
294
|
+
return block(await stopSharing(saved, session, input.cwd), retitle(input, false));
|
|
275
295
|
}
|
|
276
296
|
// `/peer <message>` shares the thread and lets the message through: the command hands it to the model.
|
|
277
|
-
|
|
278
|
-
const where = workingOn(input.cwd);
|
|
279
|
-
const what = [where.repository, where.branch].filter(Boolean).join(" @ ");
|
|
280
|
-
const sharedText = `Peer: this thread is shared in ${saved.workspace}${what ? ` as ${what}` : ""}. Teammates see its repository, branch and state, nothing of the conversation. /peer stop ends it.`;
|
|
281
|
-
let hubError;
|
|
282
|
-
await report(saved, session, argument ? "working" : "idle", input.cwd).catch((error) => (hubError = error));
|
|
283
|
-
const said = hubError ? `Peer: shared, but the hub did not answer yet (${hubError.message}). The next turn tries again.` : sharedText;
|
|
297
|
+
const { said, what } = await shareThread(saved, session, input.cwd, Boolean(argument));
|
|
284
298
|
const sessionTitle = retitle(input, true, what);
|
|
285
299
|
// A bare /peer reaches the agent too, which confirms in one line: a block would show the person Claude
|
|
286
300
|
// Code's "hook blocked your prompt" warning for what went right.
|
|
@@ -319,11 +333,11 @@ function forgetOldThreads() {
|
|
|
319
333
|
}
|
|
320
334
|
|
|
321
335
|
// A turn never waits on or fails because of the hub: a report that does not go through is dropped.
|
|
322
|
-
async function reportQuietly(session, state, cwd) {
|
|
336
|
+
async function reportQuietly(session, state, cwd, agent, timeoutMs) {
|
|
323
337
|
const saved = config();
|
|
324
338
|
const marker = readJson(markerOf(session));
|
|
325
339
|
if (!saved?.session || !marker?.workspace) return;
|
|
326
|
-
await report({ ...saved, workspace: marker.workspace }, session, state, cwd).catch(() => {});
|
|
340
|
+
await report({ ...saved, workspace: marker.workspace }, session, state, cwd, agent, timeoutMs).catch(() => {});
|
|
327
341
|
}
|
|
328
342
|
|
|
329
343
|
// ---- Claude Code setup ----
|
|
@@ -458,6 +472,10 @@ function option(args, name) {
|
|
|
458
472
|
}
|
|
459
473
|
|
|
460
474
|
async function login(args) {
|
|
475
|
+
const forCodex = args.includes("--codex");
|
|
476
|
+
const client = args.includes("--client");
|
|
477
|
+
if (client && !forCodex) throw new Error("--client requires --codex");
|
|
478
|
+
args = args.filter((arg) => arg !== "--codex" && arg !== "--client");
|
|
461
479
|
const hub = (option(args, "--hub") ?? process.env.PEER_HUB ?? config()?.hub ?? DEFAULT_HUB).replace(/\/+$/, "");
|
|
462
480
|
let workspace = option(args, "--workspace");
|
|
463
481
|
const ask = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
@@ -493,7 +511,8 @@ async function login(args) {
|
|
|
493
511
|
} finally {
|
|
494
512
|
ask.close();
|
|
495
513
|
}
|
|
496
|
-
|
|
514
|
+
if (forCodex) (await import("./codex-thread-mark.mjs")).installCodex(codexPeer, { client });
|
|
515
|
+
else install();
|
|
497
516
|
}
|
|
498
517
|
|
|
499
518
|
async function logout() {
|
|
@@ -823,19 +842,20 @@ function threadUses(session, prompts) {
|
|
|
823
842
|
return uses.slice(-200);
|
|
824
843
|
}
|
|
825
844
|
|
|
826
|
-
async function threadMemories(session) {
|
|
845
|
+
async function threadMemories(session, codex = false) {
|
|
827
846
|
const ov = readJson(OV_CONF);
|
|
828
847
|
if (!ov?.url) return { connected: false, reason: `no OpenViking client config at ${OV_CONF}` };
|
|
829
|
-
const base = `viking://user/${ov.user}/sessions
|
|
848
|
+
const base = `viking://user/${ov.user}/sessions/${codex ? "cx" : "cc"}-${session}`;
|
|
849
|
+
const uses = (prompts) => codex ? [] : threadUses(session, prompts);
|
|
830
850
|
let archives;
|
|
831
851
|
try {
|
|
832
852
|
archives = await ovRead(ov, `/fs/ls?uri=${encodeURIComponent(`${base}/history`)}`);
|
|
833
853
|
} catch (error) {
|
|
834
|
-
if (/not found|no such|404/i.test(error.message)) return { connected: true, saves: [], uses:
|
|
854
|
+
if (/not found|no such|404/i.test(error.message)) return { connected: true, saves: [], uses: uses(codex ? [] : transcriptPrompts(session)) };
|
|
835
855
|
throw error;
|
|
836
856
|
}
|
|
837
857
|
const saves = [];
|
|
838
|
-
const prompts = transcriptPrompts(session);
|
|
858
|
+
const prompts = codex ? [] : transcriptPrompts(session);
|
|
839
859
|
for (const archive of archives.filter((entry) => entry.isDir)) {
|
|
840
860
|
let diff;
|
|
841
861
|
try {
|
|
@@ -872,7 +892,15 @@ async function threadMemories(session) {
|
|
|
872
892
|
items.forEach((item, index) => (item.body = String(bodies[index] ?? "").slice(0, 3000)));
|
|
873
893
|
saves.push({ at: diff.extracted_at ?? archive.modTime, items });
|
|
874
894
|
}
|
|
875
|
-
return { connected: true, saves: saves.sort((a, b) => String(b.at).localeCompare(String(a.at))), uses:
|
|
895
|
+
return { connected: true, saves: saves.sort((a, b) => String(b.at).localeCompare(String(a.at))), uses: uses(prompts) };
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function threadMemoryText(memories) {
|
|
899
|
+
if (!memories.connected) return `Peer: ${memories.reason}`;
|
|
900
|
+
if (!memories.saves.length) return "This thread has not saved anything to memory yet.";
|
|
901
|
+
return memories.saves.flatMap((save) => [save.at, ...save.items.map((item) =>
|
|
902
|
+
` ${item.change} ${item.type} "${item.name}": ${item.lines.join(" / ")}${item.systemText ? " [holds Claude Code's own text]" : ""}`,
|
|
903
|
+
)]).join("\n");
|
|
876
904
|
}
|
|
877
905
|
|
|
878
906
|
// ---- team memory: teammates' published project knowledge at the start of a thread ----
|
|
@@ -973,8 +1001,7 @@ function changesSince(cwd, paths, date) {
|
|
|
973
1001
|
return log ? log.split("\n").filter(Boolean).length : 0;
|
|
974
1002
|
}
|
|
975
1003
|
|
|
976
|
-
async function teamHook(event) {
|
|
977
|
-
const input = await readStdin();
|
|
1004
|
+
async function teamHook(event, input) {
|
|
978
1005
|
const team = readJson(TEAM_FILE);
|
|
979
1006
|
const session = input.session_id;
|
|
980
1007
|
if (!team || !validSession(session)) return;
|
|
@@ -1033,7 +1060,7 @@ async function teamHook(event) {
|
|
|
1033
1060
|
...entries,
|
|
1034
1061
|
"</teammates-project-knowledge>",
|
|
1035
1062
|
].join("\n");
|
|
1036
|
-
|
|
1063
|
+
return { hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: block } };
|
|
1037
1064
|
}
|
|
1038
1065
|
|
|
1039
1066
|
// ---- your own memory: what your personal OpenViking remembered, listed and searched ----
|
|
@@ -1191,6 +1218,13 @@ async function memoryText(query) {
|
|
|
1191
1218
|
|
|
1192
1219
|
// ---- main ----
|
|
1193
1220
|
|
|
1221
|
+
const codexPeer = {
|
|
1222
|
+
home: HOME, cli: SELF, mod: MOD_DIR,
|
|
1223
|
+
validSession, readJson, writeJson, markerOf, config, forgetOldThreads,
|
|
1224
|
+
reportQuietly, signedIn, shareThread, stopSharing, listText, memoryQuery, memoryPage, memoryText,
|
|
1225
|
+
threadMemories, threadMemoryText, teamHook,
|
|
1226
|
+
};
|
|
1227
|
+
|
|
1194
1228
|
const [command, ...args] = process.argv.slice(2);
|
|
1195
1229
|
try {
|
|
1196
1230
|
switch (command) {
|
|
@@ -1211,16 +1245,10 @@ try {
|
|
|
1211
1245
|
case "thread": {
|
|
1212
1246
|
// `peer thread --json <session id>` is for the thread-mark mod's pane.
|
|
1213
1247
|
const session = args.find((arg) => !arg.startsWith("--"));
|
|
1214
|
-
if (!validSession(session)) throw new Error("usage: peer thread [--json] <
|
|
1215
|
-
const memories = await threadMemories(session);
|
|
1248
|
+
if (!validSession(session)) throw new Error("usage: peer thread [--json] [--codex] <session id>");
|
|
1249
|
+
const memories = await threadMemories(session, args.includes("--codex"));
|
|
1216
1250
|
if (args.includes("--json")) console.log(JSON.stringify(memories));
|
|
1217
|
-
else
|
|
1218
|
-
else if (!memories.saves.length) console.log("This thread has not saved anything to memory yet.");
|
|
1219
|
-
else
|
|
1220
|
-
for (const save of memories.saves) {
|
|
1221
|
-
console.log(save.at);
|
|
1222
|
-
for (const item of save.items) console.log(` ${item.change} ${item.type} "${item.name}": ${item.lines.join(" / ")}${item.systemText ? " [holds Claude Code's own text]" : ""}`);
|
|
1223
|
-
}
|
|
1251
|
+
else console.log(threadMemoryText(memories));
|
|
1224
1252
|
break;
|
|
1225
1253
|
}
|
|
1226
1254
|
case "status":
|
|
@@ -1231,14 +1259,31 @@ try {
|
|
|
1231
1259
|
break;
|
|
1232
1260
|
case "install":
|
|
1233
1261
|
signedIn();
|
|
1234
|
-
|
|
1262
|
+
if (args.includes("--codex")) {
|
|
1263
|
+
(await import("./codex-thread-mark.mjs")).installCodex(codexPeer, { client: args.includes("--client") });
|
|
1264
|
+
} else if (args.includes("--client")) throw new Error("--client requires --codex");
|
|
1265
|
+
else install();
|
|
1235
1266
|
break;
|
|
1236
1267
|
case "uninstall":
|
|
1237
|
-
|
|
1268
|
+
if (args.includes("--codex")) (await import("./codex-thread-mark.mjs")).uninstallCodex(codexPeer);
|
|
1269
|
+
else uninstall();
|
|
1270
|
+
break;
|
|
1271
|
+
case "codex-control":
|
|
1272
|
+
answer(await (await import("./codex-thread-mark.mjs")).codexControl(await readStdin(), codexPeer));
|
|
1238
1273
|
break;
|
|
1239
1274
|
case "hook":
|
|
1240
1275
|
// A hook never breaks the person's session: whatever goes wrong, it exits 0.
|
|
1241
|
-
await (
|
|
1276
|
+
await (async () => {
|
|
1277
|
+
if (args[0] === "codex") {
|
|
1278
|
+
const { codexHook } = await import("./codex-thread-mark.mjs");
|
|
1279
|
+
const output = await codexHook(await readStdin(), codexPeer);
|
|
1280
|
+
if (output) answer(output);
|
|
1281
|
+
} else if (args[0]?.startsWith("team-")) {
|
|
1282
|
+
const output = await teamHook(args[0], await readStdin());
|
|
1283
|
+
if (output) answer(output);
|
|
1284
|
+
}
|
|
1285
|
+
else await hook(args[0]);
|
|
1286
|
+
})().catch((error) => log(`hook ${args[0]} failed: ${error?.message ?? error}`));
|
|
1242
1287
|
break;
|
|
1243
1288
|
case "all": {
|
|
1244
1289
|
// `peer all --json …` is for the thread-mark mod's pane; without a personal OpenViking it says so.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "webinson-peer-codex",
|
|
3
|
+
"interface": { "displayName": "Peer" },
|
|
4
|
+
"plugins": [
|
|
5
|
+
{
|
|
6
|
+
"name": "peer-thread-mark",
|
|
7
|
+
"source": { "source": "local", "path": "./peer-thread-mark" },
|
|
8
|
+
"policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" },
|
|
9
|
+
"category": "Productivity"
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*.patch whitespace=-blank-at-eol
|