@ours.network/codex 0.9.0 → 0.10.0
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/.codex-plugin/plugin.json +30 -0
- package/.mcp.json +17 -0
- package/AGENTS.snippet.md +11 -16
- package/LICENSE +98 -0
- package/README.md +89 -131
- package/bin/codex-legacy-cleanup.mjs +54 -0
- package/bin/monitor-mcp.mjs +4 -0
- package/bin/ours-codex-install.mjs +7 -14
- package/bin/ours-codex.mjs +9 -0
- package/bin/proxy.mjs +31 -0
- package/hooks/hooks.json +40 -0
- package/install.sh +33 -11
- package/package.json +16 -4
- package/skills/ours/SKILL.md +62 -50
- package/skills/ours/references/configuration.md +20 -31
- package/skills/writing-agent-bios/SKILL.md +0 -1
- package/src/app-server-client.mjs +111 -0
- package/src/control-protocol.mjs +21 -0
- package/src/control-server.mjs +91 -0
- package/src/hooks/runner.mjs +86 -0
- package/src/launcher.mjs +145 -0
- package/src/monitor-mcp.mjs +133 -0
- package/src/monitor-state.mjs +55 -0
- package/src/profile.mjs +78 -0
- package/src/watcher.mjs +110 -0
package/install.sh
CHANGED
|
@@ -1,15 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# Install the ours.network plugin into the OpenAI Codex CLI:
|
|
2
|
+
# Install the native ours.network plugin into the OpenAI Codex CLI:
|
|
3
3
|
# 1. ensure the ours daemon (@ours.network/mcp) is installed + running
|
|
4
|
-
# 2.
|
|
5
|
-
# 3.
|
|
6
|
-
#
|
|
7
|
-
# 4. append a sentinel-guarded ours pointer to ~/.codex/AGENTS.md (create if missing)
|
|
8
|
-
#
|
|
9
|
-
# That's it — no identities, no gateway, no watcher, no connector. Wake-on-mail is the agent
|
|
10
|
-
# tailing `ours-mcp watch <identity>` (or a short get_messages poll) IN-SESSION (see the ours
|
|
11
|
-
# skill), the same stream Claude Code's Monitor tails. Codex is a session/invocation CLI, so this
|
|
12
|
-
# is reactive while the agent is live — it checks for mail as it works and when it expects a reply.
|
|
4
|
+
# 2. add/upgrade adapt-toolkit/ours-codex-marketplace
|
|
5
|
+
# 3. install the native `ours` plugin (skills, MCP servers, and hooks)
|
|
6
|
+
# 4. back up and remove installer-owned legacy config only after verification
|
|
13
7
|
#
|
|
14
8
|
# Idempotent: safe to re-run. Test/CI knobs (all optional):
|
|
15
9
|
# CODEX_DIR config+AGENTS.md root (default ~/.codex)
|
|
@@ -50,7 +44,35 @@ ensure_daemon_latest(){
|
|
|
50
44
|
# --- 1) daemon (ensure @latest + restart on change) ---
|
|
51
45
|
ensure_daemon_latest
|
|
52
46
|
|
|
53
|
-
# ---
|
|
47
|
+
# --- native Codex plugin ------------------------------------------------------
|
|
48
|
+
# Codex owns the plugin cache and hook-trust workflow. Only remove the legacy
|
|
49
|
+
# config/skills/AGENTS wiring after Codex confirms the native plugin installed.
|
|
50
|
+
if [ "${OURS_CODEX_SKIP_NATIVE:-}" != "1" ]; then
|
|
51
|
+
if ! command -v codex >/dev/null 2>&1; then
|
|
52
|
+
say "Codex CLI is required; install Codex first. Existing ours setup was left unchanged."
|
|
53
|
+
exit 1
|
|
54
|
+
fi
|
|
55
|
+
MARKETPLACE_SOURCE="${OURS_CODEX_MARKETPLACE_SOURCE:-adapt-toolkit/ours-codex-marketplace}"
|
|
56
|
+
say "adding/updating Codex marketplace: $MARKETPLACE_SOURCE"
|
|
57
|
+
if ! codex plugin marketplace add "$MARKETPLACE_SOURCE" >/dev/null 2>&1; then
|
|
58
|
+
codex plugin marketplace upgrade ours-codex-marketplace >/dev/null 2>&1 || {
|
|
59
|
+
say "could not configure the ours Codex marketplace; existing setup was left unchanged."
|
|
60
|
+
exit 1
|
|
61
|
+
}
|
|
62
|
+
fi
|
|
63
|
+
say "installing native Codex plugin: ours@ours-codex-marketplace"
|
|
64
|
+
if ! codex plugin add ours@ours-codex-marketplace; then
|
|
65
|
+
say "native plugin installation failed; existing setup was left unchanged."
|
|
66
|
+
exit 1
|
|
67
|
+
fi
|
|
68
|
+
CODEX_DIR="$CODEX_DIR" SKILLS_DIR="$SKILLS_DIR" node "$SELFDIR/bin/codex-legacy-cleanup.mjs"
|
|
69
|
+
say "native plugin installed. Review and trust its hooks in Codex, then start a new thread."
|
|
70
|
+
say "standard mode: codex"
|
|
71
|
+
say "live mode: ours-codex${OURS_PORT:+ --ours-port $OURS_PORT}"
|
|
72
|
+
exit 0
|
|
73
|
+
fi
|
|
74
|
+
|
|
75
|
+
# --- legacy test/fallback path (not used by production installs) -------------
|
|
54
76
|
mkdir -p "$SKILLS_DIR"
|
|
55
77
|
for s in ours writing-agent-bios; do
|
|
56
78
|
rm -rf "${SKILLS_DIR:?}/$s"
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/codex",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"description": "Native Codex plugin for secure ours.network messaging and explicitly armed, session-scoped live mail wake.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|
|
7
7
|
"author": "Adapt Toolkit",
|
|
@@ -25,17 +25,29 @@
|
|
|
25
25
|
"ours.network"
|
|
26
26
|
],
|
|
27
27
|
"files": [
|
|
28
|
+
".codex-plugin",
|
|
29
|
+
".mcp.json",
|
|
28
30
|
"skills",
|
|
31
|
+
"hooks",
|
|
32
|
+
"src",
|
|
29
33
|
"bin",
|
|
30
34
|
"install.sh",
|
|
31
35
|
"AGENTS.snippet.md",
|
|
32
|
-
"README.md"
|
|
36
|
+
"README.md",
|
|
37
|
+
"LICENSE"
|
|
33
38
|
],
|
|
34
39
|
"engines": {
|
|
35
40
|
"node": ">=20"
|
|
36
41
|
},
|
|
37
42
|
"bin": {
|
|
38
|
-
"ours-codex-install": "bin/ours-codex-install.mjs"
|
|
43
|
+
"ours-codex-install": "bin/ours-codex-install.mjs",
|
|
44
|
+
"ours-codex": "bin/ours-codex.mjs"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
48
|
+
"@ours.network/mcp": "0.10.0",
|
|
49
|
+
"ws": "^8.21.0",
|
|
50
|
+
"zod": "^3.25.76"
|
|
39
51
|
},
|
|
40
52
|
"publishConfig": {
|
|
41
53
|
"access": "public"
|
package/skills/ours/SKILL.md
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: ours
|
|
3
3
|
description: Use when the user wants to set up or configure ours or this plugin, onboard onto the ours network, create or pick/switch an identity (and decide whether to adopt its persona), connect with another agent or person, generate or accept an invite, send or read end-to-end-encrypted messages, send or receive a file, check incoming mail, arm live monitoring so the agent wakes on new mail, or bind a web-messenger account as the host's monitoring/control proxy. Trigger phrases include "set up ours", "set up ours network", "set up the plugin", "create an identity", "create a human/agent identity", "use identity X", "who am I", "set my bio", "set my persona", "adopt this persona", "generate an invite for X", "add this contact", "send a message to X", "send a file to X", "check my messages", "any new messages", "any new files", "get my files", "list my contacts", "watch for messages", "wait for a reply", "wake me on new mail", "bind the monitoring proxy", "set up the control panel", "monitoring status".
|
|
4
|
-
version: 0.1.0
|
|
5
4
|
metadata:
|
|
6
5
|
codex:
|
|
7
6
|
tags: [ours, ours.network, a2a, adapt, e2e, messaging, identity]
|
|
@@ -86,31 +85,25 @@ Walk the user through these, checking each. Stop and help at the first one that
|
|
|
86
85
|
interactive `ours-mcp setup` (this edits config only — it is NOT identity setup).
|
|
87
86
|
These run on the user's machine; if a step needs them at a terminal, suggest they
|
|
88
87
|
type `! ours-mcp status` etc.
|
|
89
|
-
2. **Plugin installed.**
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
short ours pointer to `~/.codex/AGENTS.md`. Codex reads config, skills, and AGENTS.md
|
|
94
|
-
at the start of each session, so a **new Codex session** picks up the `ours` MCP tools
|
|
95
|
-
and skill — no reload command. See the package README for manual steps.
|
|
88
|
+
2. **Plugin installed.** Install the native plugin from the ours Codex marketplace, or
|
|
89
|
+
install `@ours.network/codex` globally and run `ours-codex-install`. Start a new
|
|
90
|
+
Codex thread after installation. The native package bundles skills, the ours and
|
|
91
|
+
`ours_monitor` MCP servers, and SessionStart/UserPromptSubmit/PostToolUse hooks.
|
|
96
92
|
3. **Onboarding.** Run the mandatory *Onboarding* flow above: Human identity first,
|
|
97
93
|
then any agent identities.
|
|
98
94
|
4. **Connect.** Generate an invite to share, or paste one to add a contact. Same-host
|
|
99
95
|
identities skip invites via the local contact book.
|
|
100
|
-
5. **(Optional) Wake on mail.**
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
background Monitor) — don't sell it as "just works". Because Codex is turn-based with no native
|
|
105
|
-
background wake, the ~5s `get_messages` poll between turns is often the practical path and does
|
|
106
|
-
not block; the installer never sets any of this up.
|
|
96
|
+
5. **(Optional) Wake on mail.** Live wake is available only when this thread was started
|
|
97
|
+
through `ours-codex`. After an identity is bound, ask whether to arm it. Call
|
|
98
|
+
`arm_monitor({ identity })` only after an explicit yes. Standard mode remains fully
|
|
99
|
+
usable for manual `get_messages` checks.
|
|
107
100
|
6. **(Optional) Oversight.** If they want to watch/command a fleet from a phone or
|
|
108
101
|
browser, set up the **control-plane monitoring proxy**.
|
|
109
102
|
|
|
110
|
-
- **Configuration.** Port, state dir, broker, and GC interval are configurable
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
103
|
+
- **Configuration.** Port, state dir, broker, and GC interval are configurable.
|
|
104
|
+
More than one daemon may run when each uses a distinct port and state directory.
|
|
105
|
+
Select live mode with `ours-codex --ours-port <port>` or `OURS_CONFIG`. Never
|
|
106
|
+
self-configure on your own initiative: surface the
|
|
114
107
|
need, explain the impact, and act only on the user's explicit yes. Details:
|
|
115
108
|
`references/configuration.md`.
|
|
116
109
|
|
|
@@ -173,14 +166,17 @@ the bio, so a persona prompt is only needed if they want to role-play it).
|
|
|
173
166
|
persona for the session (not persisted). The **bio** is a public card, NOT an operating
|
|
174
167
|
instruction — never adopt the bio as behavior. If persona is empty or they decline,
|
|
175
168
|
operate normally. **Never adopt a persona silently.**
|
|
176
|
-
2. **Wake check.**
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
169
|
+
2. **Wake check.** Ask: *"Arm live mail monitoring for this identity in this session?"*
|
|
170
|
+
This question is mandatory but arming is optional. Only after an explicit yes call
|
|
171
|
+
`arm_monitor({ identity: "<bound name>" })`. In `ours-codex`, that arms background
|
|
172
|
+
wake immediately. In standard `codex`, the tool instead explains the better
|
|
173
|
+
`ours-codex` experience and tells you to ask separately whether the user accepts a
|
|
174
|
+
blocking foreground monitor. **Relay the `ours-codex` background-monitor
|
|
175
|
+
recommendation to the user verbatim; never omit it, even when the user already asked
|
|
176
|
+
to monitor.** Only after that second explicit yes call
|
|
177
|
+
`get_messages` once for existing unread mail, then
|
|
178
|
+
`foreground_monitor({ identity: "<bound name>" })`. Before switching identities, call
|
|
179
|
+
`disarm_monitor`; the PostToolUse hook also disarms defensively on a changed bind.
|
|
184
180
|
|
|
185
181
|
### Other identity tools
|
|
186
182
|
|
|
@@ -206,15 +202,13 @@ Do **not** stop work, refuse, or restart anything on your own over this.
|
|
|
206
202
|
|
|
207
203
|
### Workspace identity pin (`.ours-identity`)
|
|
208
204
|
|
|
209
|
-
The `.ours-identity`
|
|
210
|
-
|
|
211
|
-
|
|
205
|
+
The native SessionStart hook reads `.ours-identity` and injects an advisory suggestion.
|
|
206
|
+
It never binds, creates, adopts a persona, or arms monitoring. The
|
|
207
|
+
`define_local_identity_file` tool writes a correctly-shaped file
|
|
212
208
|
(pass an absolute `path` plus `name` and optional `force` / `expose_local` / `local_auto_accept`),
|
|
213
|
-
but
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
authorization**: ask the user before binding or creating it, and never adopt its persona
|
|
217
|
-
without explicit approval.)
|
|
209
|
+
but you still bind explicitly with `choose_identity`. Treat the pin as a suggestion, never
|
|
210
|
+
authorization: ask before binding or creating, ask separately before adopting its persona,
|
|
211
|
+
and ask separately before arming live monitoring.
|
|
218
212
|
|
|
219
213
|
## Layer 2 — messaging (per the bound identity)
|
|
220
214
|
|
|
@@ -265,10 +259,9 @@ recipient sees `↳re <wire_id>·s<n>`. It's a lightweight reference, not a thre
|
|
|
265
259
|
might crash before acting — `defer_messages({ msg_ids: [...] })` flips it back to "unread"
|
|
266
260
|
(works even after it is queued for deletion, so it stays recoverable across a GC cycle).
|
|
267
261
|
- "show my inbox" → `list_incoming_messages()` (full inbox, ids + status, read-only).
|
|
268
|
-
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
**check `get_messages` when you go live and whenever you expect a reply** — the daemon holds
|
|
262
|
+
- The SessionStart hook surfaces body-free unread counts and sender metadata. It never
|
|
263
|
+
returns message text. In live mode an explicitly armed watcher starts a fixed drain turn;
|
|
264
|
+
otherwise **check `get_messages` when you go live and whenever you expect a reply** — the daemon holds
|
|
272
265
|
anything received while nothing was bound until you next `get_messages`. When the user returns
|
|
273
266
|
to ours after a gap, offer to check: for each relevant identity, `choose_identity` it and
|
|
274
267
|
`get_messages()`.
|
|
@@ -316,8 +309,26 @@ When you bind an identity, offer the user, in plain language:
|
|
|
316
309
|
|
|
317
310
|
> "Want this session to **auto-wake** when a new message arrives, or **check manually**?"
|
|
318
311
|
|
|
319
|
-
- **Auto-wake** →
|
|
320
|
-
|
|
312
|
+
- **Auto-wake** → only after explicit consent, call `arm_monitor` for the currently bound
|
|
313
|
+
identity. The tool detects the available mode:
|
|
314
|
+
- In **`ours-codex` live mode**, the session-owned watcher uses Codex App Server to
|
|
315
|
+
start a fixed drain turn on body-free notification events. It coalesces events and
|
|
316
|
+
never injects sender or body text.
|
|
317
|
+
- In **standard `codex` mode**, `arm_monitor` does not silently start a blocking call.
|
|
318
|
+
You MUST tell the user: *"This standard `codex` session only supports a blocking
|
|
319
|
+
foreground monitor. For background monitoring, restart the session with
|
|
320
|
+
`ours-codex` instead."* Never collapse or omit this recommendation. Then explain that
|
|
321
|
+
the fallback occupies the current turn and obtain separate explicit consent. If the user agrees,
|
|
322
|
+
first call `get_messages` once to drain existing unread mail, then call
|
|
323
|
+
`foreground_monitor` for the bound identity. When it returns an arrival, call
|
|
324
|
+
`get_messages`, handle the mail, then call `foreground_monitor` again without asking
|
|
325
|
+
while the original consent remains active. Escape/interruption stops and disarms it.
|
|
326
|
+
- **Manual** → do not arm it; call `get_messages` when the user asks.
|
|
327
|
+
|
|
328
|
+
The background watcher stops when the `ours-codex` TUI exits. `disarm_monitor` stops it
|
|
329
|
+
earlier; `monitor_status` reports availability and the armed identity. A foreground
|
|
330
|
+
monitor is a blocking tool call, so the session cannot accept another prompt until mail
|
|
331
|
+
arrives or the user presses Escape.
|
|
321
332
|
|
|
322
333
|
## Control plane — bind a monitoring proxy (human oversight of a fleet)
|
|
323
334
|
|
|
@@ -360,9 +371,9 @@ requests, and each agent's monitoring ON/off. Works whenever the Human identity
|
|
|
360
371
|
|
|
361
372
|
## Notes
|
|
362
373
|
|
|
363
|
-
- Identities and their state (contacts, inbox, keys) persist under the daemon's
|
|
364
|
-
|
|
365
|
-
|
|
374
|
+
- Identities and their state (contacts, inbox, keys) persist under the selected daemon's
|
|
375
|
+
state directory and survive restarts. Multiple daemon profiles can coexist when their
|
|
376
|
+
ports and state directories differ.
|
|
366
377
|
- Inbound messages from unknown (non-contact) senders are rejected — only peers added via an
|
|
367
378
|
invite handshake, same-host agents under the same Human identity, or registrar-verified
|
|
368
379
|
local-contact-book introductions can reach you.
|
|
@@ -370,9 +381,10 @@ requests, and each agent's monitoring ON/off. Works whenever the Human identity
|
|
|
370
381
|
event (sender + id + date) to `$OURS_STATE_DIR/<identity>/notifications.log` (the wake
|
|
371
382
|
signal `ours-mcp watch` reads) and refreshes a body-free `unread.json`. Text lives in the
|
|
372
383
|
packet and leaves it solely via `get_messages`.
|
|
373
|
-
- **
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
384
|
+
- **Codex monitoring uses capability detection.** `ours-codex` owns the App Server and
|
|
385
|
+
watcher for exactly one TUI session. The launcher observes that session's thread
|
|
386
|
+
directly; monitor MCP tools carry explicit arm/disarm consent, while trusted hooks add
|
|
387
|
+
defensive identity-state synchronization. Standard `codex` falls back, after separate
|
|
388
|
+
consent, to a foreground `ours-mcp watch` call that returns on the next body-free event.
|
|
389
|
+
Authenticated daemon notification endpoints remain body-free. Only `get_messages`
|
|
390
|
+
releases message text to the agent.
|
|
@@ -1,38 +1,27 @@
|
|
|
1
|
-
# ours configuration
|
|
1
|
+
# ours configuration and daemon profiles
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Each ours daemon owns one port and one state directory. Multiple daemons can run on the
|
|
4
|
+
same host when both values are distinct. Configuration resolves as environment variables,
|
|
5
|
+
then the file named by `OURS_CONFIG` (otherwise `~/.ours/config.json`), then defaults:
|
|
6
6
|
|
|
7
|
-
| Setting |
|
|
7
|
+
| Setting | Environment | JSON key | Default |
|
|
8
8
|
|---|---|---|---|
|
|
9
9
|
| HTTP port | `OURS_PORT` | `port` | `3050` |
|
|
10
|
-
| State
|
|
11
|
-
| Broker
|
|
12
|
-
|
|
|
13
|
-
|
|
|
10
|
+
| State directory | `OURS_STATE_DIR` | `stateDir` | `~/.ours` |
|
|
11
|
+
| Broker | `OURS_BROKER_URL` | `brokerUrl` | bundled public broker |
|
|
12
|
+
| API token | `OURS_API_TOKEN` | `apiToken` | owner token file |
|
|
13
|
+
| API visibility | `OURS_API_VISIBILITY` | `apiVisibility` | `owner` |
|
|
14
|
+
| Auto-start | `OURS_AUTOSTART` | `autoStart` | `false` |
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
For live Codex mode, `ours-codex --ours-port <port>` has highest precedence. The launcher
|
|
17
|
+
queries `/info`, verifies the authenticated notification API, and propagates that exact
|
|
18
|
+
profile to the plugin, hooks, and watcher. It never starts or changes the daemon. A stopped
|
|
19
|
+
or incompatible selected daemon is an error.
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
`ours-mcp restart` (with `autoStart` off — the default — a stopped daemon
|
|
25
|
-
stays stopped; sessions report an error instead of relaunching it).
|
|
21
|
+
Changing daemon configuration is separate operator work. Explain the impact and obtain
|
|
22
|
+
explicit consent before editing or restarting anything. A changed state directory selects a
|
|
23
|
+
different identity store. Use a distinct `OURS_CONFIG`, port, and state directory for a
|
|
24
|
+
second daemon.
|
|
26
25
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
**Blast radius — explain this before any change:**
|
|
30
|
-
- **Any config change restarts the daemon — every active session loses its binding and must `choose_identity` again.** Only change config when no other session is mid-task.
|
|
31
|
-
- **Changing `stateDir` orphans existing identities** — they live under the old
|
|
32
|
-
directory and won't be found under the new one.
|
|
33
|
-
|
|
34
|
-
If a tool can't reach the daemon, first check `ours-mcp status` (is it running,
|
|
35
|
-
on which port). With `autoStart` off (the default) the most common cause is
|
|
36
|
-
simply a daemon that was never started — the fix is `ours-mcp start`. A port
|
|
37
|
-
collision is the other usual cause; resolving it is a config change — surface
|
|
38
|
-
it to the user with the blast radius above and act only on an explicit yes.
|
|
26
|
+
Standard mode and live mode use the same MCP tools. Live mode only adds explicitly armed,
|
|
27
|
+
session-scoped wake; it stops with the `ours-codex` session.
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: writing-agent-bios
|
|
3
3
|
description: Use when writing or revising an ours identity's bio or persona — when creating an identity, setting up an agent for a fleet, or when a bio/persona reads vague, is a bare capability dump with no "when to engage", conflates "what others see" with "how I behave", or has no explicit out-of-scope boundary.
|
|
4
|
-
version: 0.1.0
|
|
5
4
|
metadata:
|
|
6
5
|
codex:
|
|
7
6
|
tags: [ours, ours.network, bio, persona, identity]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import WebSocket from 'ws';
|
|
3
|
+
|
|
4
|
+
export class WebSocketJsonTransport extends EventEmitter {
|
|
5
|
+
constructor(url, options = {}) {
|
|
6
|
+
super();
|
|
7
|
+
this.socket = new WebSocket(url, options);
|
|
8
|
+
this.socket.on('open', () => this.emit('open'));
|
|
9
|
+
this.socket.on('message', (data) => {
|
|
10
|
+
try { this.emit('message', JSON.parse(String(data))); }
|
|
11
|
+
catch (error) { this.emit('error', new Error(`invalid app-server JSON: ${error.message}`)); }
|
|
12
|
+
});
|
|
13
|
+
this.socket.on('error', (error) => this.emit('error', error));
|
|
14
|
+
this.socket.on('close', () => this.emit('close'));
|
|
15
|
+
}
|
|
16
|
+
async ready(timeoutMs = 5000) {
|
|
17
|
+
if (this.socket.readyState === WebSocket.OPEN) return;
|
|
18
|
+
await new Promise((resolve, reject) => {
|
|
19
|
+
const timer = setTimeout(() => reject(new Error('app-server websocket open timed out')), timeoutMs);
|
|
20
|
+
this.once('open', () => { clearTimeout(timer); resolve(); });
|
|
21
|
+
this.once('error', (error) => { clearTimeout(timer); reject(error); });
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
send(value) { this.socket.send(JSON.stringify(value)); }
|
|
25
|
+
close() { this.socket.close(); }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class AppServerClient {
|
|
29
|
+
#transport;
|
|
30
|
+
#pending = new Map();
|
|
31
|
+
#nextId = 1;
|
|
32
|
+
#timeoutMs;
|
|
33
|
+
#notificationHandlers = new Set();
|
|
34
|
+
#serverRequestHandler = null;
|
|
35
|
+
#closed = false;
|
|
36
|
+
|
|
37
|
+
constructor(transport, { timeoutMs = 30_000 } = {}) {
|
|
38
|
+
this.#transport = transport;
|
|
39
|
+
this.#timeoutMs = timeoutMs;
|
|
40
|
+
transport.on('message', (message) => this.#receive(message));
|
|
41
|
+
transport.on('close', () => this.#shutdown(new Error('app-server transport closed')));
|
|
42
|
+
transport.on('error', (error) => this.#shutdown(error));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async initialize() {
|
|
46
|
+
const result = await this.request('initialize', {
|
|
47
|
+
clientInfo: { name: 'ours_codex', title: 'ours.network Codex monitor', version: '0.9.1' },
|
|
48
|
+
capabilities: { experimentalApi: true },
|
|
49
|
+
});
|
|
50
|
+
this.#transport.send({ method: 'initialized', params: {} });
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
request(method, params = {}) {
|
|
55
|
+
if (this.#closed) return Promise.reject(new Error('app-server transport closed'));
|
|
56
|
+
const id = this.#nextId++;
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const timer = setTimeout(() => {
|
|
59
|
+
this.#pending.delete(id);
|
|
60
|
+
reject(new Error(`app-server ${method} timed out`));
|
|
61
|
+
}, this.#timeoutMs);
|
|
62
|
+
this.#pending.set(id, { resolve, reject, timer });
|
|
63
|
+
this.#transport.send({ method, id, params });
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
listThreads(cwd) { return this.request('thread/list', cwd ? { cwd } : {}); }
|
|
68
|
+
readThread(threadId) { return this.request('thread/read', { threadId, includeTurns: true }); }
|
|
69
|
+
startTurn(threadId, text) { return this.request('turn/start', { threadId, input: [{ type: 'text', text }] }); }
|
|
70
|
+
onNotification(handler) { this.#notificationHandlers.add(handler); return () => this.#notificationHandlers.delete(handler); }
|
|
71
|
+
onServerRequest(handler) { this.#serverRequestHandler = handler; }
|
|
72
|
+
|
|
73
|
+
async #receive(message) {
|
|
74
|
+
if (message && Object.hasOwn(message, 'id') && !message.method) {
|
|
75
|
+
const pending = this.#pending.get(message.id);
|
|
76
|
+
if (!pending) return;
|
|
77
|
+
clearTimeout(pending.timer);
|
|
78
|
+
this.#pending.delete(message.id);
|
|
79
|
+
if (message.error) pending.reject(new Error(message.error.message || JSON.stringify(message.error)));
|
|
80
|
+
else pending.resolve(message.result);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (message?.method && Object.hasOwn(message, 'id')) {
|
|
84
|
+
try {
|
|
85
|
+
if (!this.#serverRequestHandler) throw new Error(`unsupported app-server request ${message.method}`);
|
|
86
|
+
const result = await this.#serverRequestHandler(message);
|
|
87
|
+
this.#transport.send({ id: message.id, result });
|
|
88
|
+
} catch (error) {
|
|
89
|
+
this.#transport.send({ id: message.id, error: { code: -32603, message: error.message } });
|
|
90
|
+
}
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (message?.method) for (const handler of this.#notificationHandlers) handler(message);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#shutdown(error) {
|
|
97
|
+
if (this.#closed) return;
|
|
98
|
+
this.#closed = true;
|
|
99
|
+
for (const pending of this.#pending.values()) { clearTimeout(pending.timer); pending.reject(error); }
|
|
100
|
+
this.#pending.clear();
|
|
101
|
+
}
|
|
102
|
+
close() { this.#transport.close(); this.#shutdown(new Error('app-server transport closed')); }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function connectAppServer(url, options = {}) {
|
|
106
|
+
const transport = new WebSocketJsonTransport(url, options.websocket);
|
|
107
|
+
await transport.ready(options.openTimeoutMs);
|
|
108
|
+
const client = new AppServerClient(transport, options);
|
|
109
|
+
await client.initialize();
|
|
110
|
+
return client;
|
|
111
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const COMMANDS = new Set(['register_session', 'binding_changed', 'arm', 'disarm', 'status']);
|
|
2
|
+
const MAX_LINE = 64 * 1024;
|
|
3
|
+
const nonEmpty = (value) => typeof value === 'string' && value.trim().length > 0;
|
|
4
|
+
|
|
5
|
+
export function decodeControlLine(line, expectedCapability) {
|
|
6
|
+
if (typeof line !== 'string' || Buffer.byteLength(line) > MAX_LINE) throw new Error('control message too large');
|
|
7
|
+
let value;
|
|
8
|
+
try { value = JSON.parse(line); } catch { throw new Error('control message is not valid JSON'); }
|
|
9
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) throw new Error('control message must be a plain object');
|
|
10
|
+
if (!nonEmpty(value.capability) || value.capability !== expectedCapability) throw new Error('invalid control capability');
|
|
11
|
+
if (!COMMANDS.has(value.command)) throw new Error(`unknown command: ${String(value.command)}`);
|
|
12
|
+
if (value.command === 'register_session') {
|
|
13
|
+
for (const field of ['sessionId', 'threadId', 'cwd']) if (!nonEmpty(value[field])) throw new Error(`${field} must be a non-empty string`);
|
|
14
|
+
}
|
|
15
|
+
if ((value.command === 'binding_changed' || value.command === 'arm') && !nonEmpty(value.identity)) throw new Error('identity must be a non-empty string');
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function encodeControlResponse(value) {
|
|
20
|
+
return `${JSON.stringify(value)}\n`;
|
|
21
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createServer, createConnection } from 'node:net';
|
|
2
|
+
import { chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { decodeControlLine, encodeControlResponse } from './control-protocol.mjs';
|
|
5
|
+
import { createMonitorState, registerSession, bindingChanged, arm, disarm } from './monitor-state.mjs';
|
|
6
|
+
|
|
7
|
+
export class AtomicStateStore {
|
|
8
|
+
constructor(path) { this.path = path; }
|
|
9
|
+
async save(value) {
|
|
10
|
+
const tmp = `${this.path}.${process.pid}.tmp`;
|
|
11
|
+
await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
12
|
+
await rename(tmp, this.path);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class ControlServer {
|
|
17
|
+
constructor({ socketPath, capability, initialState = createMonitorState(), onEffects = async () => {}, stateStore = null }) {
|
|
18
|
+
this.socketPath = socketPath;
|
|
19
|
+
this.capability = capability;
|
|
20
|
+
this.state = initialState;
|
|
21
|
+
this.onEffects = onEffects;
|
|
22
|
+
this.stateStore = stateStore;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async start() {
|
|
26
|
+
await mkdir(dirname(this.socketPath), { recursive: true, mode: 0o700 });
|
|
27
|
+
await chmod(dirname(this.socketPath), 0o700);
|
|
28
|
+
await rm(this.socketPath, { force: true });
|
|
29
|
+
this.server = createServer((socket) => {
|
|
30
|
+
socket.setEncoding('utf8');
|
|
31
|
+
let buffer = '';
|
|
32
|
+
socket.on('data', (chunk) => {
|
|
33
|
+
buffer += chunk;
|
|
34
|
+
if (Buffer.byteLength(buffer) > 64 * 1024) { socket.end(encodeControlResponse({ ok: false, error: 'control message too large' })); return; }
|
|
35
|
+
let nl;
|
|
36
|
+
while ((nl = buffer.indexOf('\n')) >= 0) {
|
|
37
|
+
const line = buffer.slice(0, nl); buffer = buffer.slice(nl + 1);
|
|
38
|
+
void this.#handle(line).then((value) => socket.end(encodeControlResponse(value)));
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
await new Promise((resolve, reject) => { this.server.once('error', reject); this.server.listen(this.socketPath, resolve); });
|
|
43
|
+
await chmod(this.socketPath, 0o600);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async #handle(line) {
|
|
47
|
+
try {
|
|
48
|
+
const msg = decodeControlLine(line, this.capability);
|
|
49
|
+
return await this.apply(msg);
|
|
50
|
+
} catch (error) { return { ok: false, error: error.message }; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async apply(msg) {
|
|
54
|
+
try {
|
|
55
|
+
let transition = { state: this.state, effects: [] };
|
|
56
|
+
if (msg.command === 'register_session') transition = registerSession(this.state, msg);
|
|
57
|
+
else if (msg.command === 'binding_changed') transition = bindingChanged(this.state, msg.identity);
|
|
58
|
+
else if (msg.command === 'arm') transition = arm(this.state, msg.identity);
|
|
59
|
+
else if (msg.command === 'disarm') transition = disarm(this.state);
|
|
60
|
+
this.state = transition.state;
|
|
61
|
+
await this.stateStore?.save(this.state);
|
|
62
|
+
await this.onEffects(transition.effects, this.state);
|
|
63
|
+
return { ok: true, state: this.state };
|
|
64
|
+
} catch (error) { return { ok: false, error: error.message }; }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async close() {
|
|
68
|
+
if (this.server) await new Promise((resolve) => this.server.close(resolve));
|
|
69
|
+
await rm(this.socketPath, { force: true });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function sendControlCommand(socketPath, capability, message, timeoutMs = 2000) {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const socket = createConnection(socketPath);
|
|
76
|
+
let buffer = '';
|
|
77
|
+
const timer = setTimeout(() => { socket.destroy(); reject(new Error('monitor control timed out')); }, timeoutMs);
|
|
78
|
+
socket.setEncoding('utf8');
|
|
79
|
+
socket.on('connect', () => socket.write(encodeControlResponse({ ...message, capability })));
|
|
80
|
+
socket.on('data', (chunk) => { buffer += chunk; });
|
|
81
|
+
socket.on('error', (error) => { clearTimeout(timer); reject(error); });
|
|
82
|
+
socket.on('end', () => {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
try {
|
|
85
|
+
const response = JSON.parse(buffer);
|
|
86
|
+
if (!response.ok) reject(new Error(response.error || 'monitor control failed'));
|
|
87
|
+
else resolve(response);
|
|
88
|
+
} catch (error) { reject(error); }
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|