nomoremeatproxy 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +122 -3
- package/bin/nomeat.mjs +365 -0
- package/docs/assets/not-a-meat-proxy.png +0 -0
- package/docs/usage.md +466 -0
- package/package.json +59 -6
- package/src/adapters/claude-attended.mjs +51 -0
- package/src/adapters/claude.mjs +158 -0
- package/src/adapters/codex-app.mjs +58 -0
- package/src/adapters/codex-cli.mjs +38 -0
- package/src/adapters/codex-daemon.mjs +47 -0
- package/src/adapters/codex-transport.mjs +90 -0
- package/src/adapters/codex.mjs +268 -0
- package/src/adapters/opener.mjs +29 -0
- package/src/call.mjs +193 -0
- package/src/check.mjs +46 -0
- package/src/doctor-live.mjs +123 -0
- package/src/doctor.mjs +213 -0
- package/src/history/claude.mjs +263 -0
- package/src/history/codex.mjs +164 -0
- package/src/history/compact.mjs +149 -0
- package/src/history/folder.mjs +10 -0
- package/src/history/index.mjs +35 -0
- package/src/history/match.mjs +20 -0
- package/src/history/render.mjs +23 -0
- package/src/ledger.mjs +118 -0
- package/src/listen.mjs +36 -0
- package/src/listener-lease.mjs +88 -0
- package/src/mailbox.mjs +145 -0
- package/src/orient.mjs +72 -0
- package/src/peers.mjs +18 -0
- package/src/remote-control-store.mjs +116 -0
- package/src/remote-control.mjs +64 -0
- package/src/standing.mjs +54 -0
- package/src/stats.mjs +65 -0
- package/src/usage.mjs +44 -0
- package/src/writable.mjs +49 -0
- package/templates/peer-calls.md +136 -0
- package/tools/doctor.mjs +109 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NOMOREMEATPROXY contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,122 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="docs/assets/not-a-meat-proxy.png" width="420" alt="Meme: a man points at his own reflection in a mirror. Caption: You're not a meat proxy. You're an AI assisted senior software engineer.">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# NOMOREMEATPROXY
|
|
6
|
+
|
|
7
|
+
Stop being the courier between your OpenAI and Anthropic assistants.
|
|
8
|
+
|
|
9
|
+
The goal: a coordinator session on one side and a reviewer session on the other find each
|
|
10
|
+
other's history, exchange work, spin off their own coding workers, and share one project
|
|
11
|
+
repo, on existing ChatGPT and Claude subscriptions with no paid API access.
|
|
12
|
+
|
|
13
|
+
## Status
|
|
14
|
+
|
|
15
|
+
Bridge v0 works on the development machine (2026-11-10): `nomeat call` lets one side call the
|
|
16
|
+
other, start or continue an exact thread, pick the model and thinking level, and get the reply
|
|
17
|
+
back. Calls continue one conversation, with desktop listing limits below. `nomeat find` and
|
|
18
|
+
`nomeat read` look up a chat on the other side by title or recent message content; `nomeat
|
|
19
|
+
orient` inspects routes without sending. It uses only the vendors' own command lines. Web
|
|
20
|
+
chats and fully automatic setup are not supported.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
For a published release, install the CLI with:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install --global nomoremeatproxy@0.1.0
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Then `nomeat --help`, `nomeat peers` and `nomeat orient --from astra` are read-only starting
|
|
31
|
+
points. For a one-off invocation, use `npx --yes nomoremeatproxy@0.1.0 --help`.
|
|
32
|
+
These registry commands require that exact version to be available on npm; preparing an
|
|
33
|
+
unpublished candidate does not make it available. To review a candidate before publication,
|
|
34
|
+
pack the source and install the resulting tarball locally instead.
|
|
35
|
+
|
|
36
|
+
This installs only the bridge CLI. **Install Node.js 22.15 or later, Codex CLI and Claude
|
|
37
|
+
Code CLI yourself**, then sign in to both vendor CLIs with your supported subscriptions.
|
|
38
|
+
The bridge does not install prerequisites, sign you in, configure your assistants, or prove
|
|
39
|
+
delivery. Desktop apps are needed to see chats there, with the platform limits below.
|
|
40
|
+
Peer names and default models remain fixed; see the usage guide for per-call overrides.
|
|
41
|
+
|
|
42
|
+
With the npm command on your PATH, use `nomeat` in place of `node bin/nomeat.mjs` in the
|
|
43
|
+
source-checkout examples:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
nomeat call --to astra --from fable --effort ultra --message "Review the project plan"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Documents
|
|
50
|
+
|
|
51
|
+
| File | Contents |
|
|
52
|
+
|---|---|
|
|
53
|
+
| [docs/usage.md](docs/usage.md) | How to call a peer, results, retries, state |
|
|
54
|
+
| [templates/peer-calls.md](templates/peer-calls.md) | Instructions to give both assistants |
|
|
55
|
+
|
|
56
|
+
Project records, historical evidence and development-machine snapshots are source-only and
|
|
57
|
+
are deliberately omitted from the npm package.
|
|
58
|
+
|
|
59
|
+
## Check your machine
|
|
60
|
+
|
|
61
|
+
From the installed package directory (or source checkout), run the included prerequisite
|
|
62
|
+
check. A global install adds `nomeat`, not a separate `doctor` command. This check prints
|
|
63
|
+
versions and counts, no paths or content, and sends no messages:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
npm run doctor
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Built and tested on Windows 11. The app and browser probes are Windows-only for now.
|
|
70
|
+
|
|
71
|
+
## Known limits
|
|
72
|
+
|
|
73
|
+
**New chats can be late in the Codex app's list.** A chat the bridge creates on the Codex side
|
|
74
|
+
works at once: the message is delivered, the answer comes back, and `nomeat find --side codex`
|
|
75
|
+
lists it. The Codex app itself may not show the chat in its list until the app re-reads it.
|
|
76
|
+
|
|
77
|
+
The bridge now opens each new Codex chat by its link, `codex://threads/<id>`, once the call's
|
|
78
|
+
turn has ended and the bridge's own Codex server has stopped (`--no-open` skips that). Opening
|
|
79
|
+
the link switches the app's main area to that chat, seen on Linux: if you are in another chat
|
|
80
|
+
there, you are moved out of it. Whether the window is raised or takes keyboard focus was not
|
|
81
|
+
checked.
|
|
82
|
+
|
|
83
|
+
- Windows: the bridge's opener ran twice, before it waited for its own server to stop, and
|
|
84
|
+
both chats showed in the app on their own, the second during an update of the app; a third run, with the wait for the bridge's own server in place, showed its chat too. Opened by
|
|
85
|
+
hand, the link made the app show and list a chat twice: once after a delay that was not
|
|
86
|
+
timed, once within seconds. With the app closed, a link opened by hand started the app, which
|
|
87
|
+
came up on that new chat; seen once.
|
|
88
|
+
- macOS and Linux: only the same commands run by hand, on older code without the opener, once
|
|
89
|
+
each. On macOS the chat was listed in under 3 seconds; on Linux it was open one second later,
|
|
90
|
+
first in Recents. The bridge's opener itself has run only on Windows.
|
|
91
|
+
|
|
92
|
+
Not tested: the link while the app is minimized, or closed on macOS and Linux; for a chat whose
|
|
93
|
+
turn is still running; from a shell without a desktop session, or from inside Codex's sandbox.
|
|
94
|
+
Where it does not work, the chat appears as before:
|
|
95
|
+
|
|
96
|
+
- Linux: it appears after you start any new chat in the Codex app.
|
|
97
|
+
- macOS: it appears after you quit and reopen the Codex app.
|
|
98
|
+
- Windows: it appears after you quit and reopen the Codex app. A folder the app does not know
|
|
99
|
+
is not added to its Projects; the chat shows under Recents.
|
|
100
|
+
|
|
101
|
+
Messages and answers are not lost while the chat is missing from the list. This is observed
|
|
102
|
+
behaviour: the cause is not proven.
|
|
103
|
+
|
|
104
|
+
**A question card in such a chat can be lost.** Seen on a Mac, and on Windows in three runs.
|
|
105
|
+
On the Mac, the assistant in a bridge chat the
|
|
106
|
+
Codex app did not list yet asked the owner with its question card. The card tool answered that
|
|
107
|
+
it was accepted, the owner saw nothing, the card was still missing once the chat was listed,
|
|
108
|
+
and the bridge reported the call as answered. Seen twice; in the same chat once listed, a card
|
|
109
|
+
was shown. On Windows, a card raised in a follow-up message, in a chat that was already up in
|
|
110
|
+
the app after its link was opened, was shown; cards raised before the chat was up were lost. On
|
|
111
|
+
macOS and Linux, in the runs where the link was opened by hand, a follow-up sent with
|
|
112
|
+
`--no-wait` that asked for a card showed it, and the owner's yes arrived; a card raised before
|
|
113
|
+
the link was sent was not tested there. The bridge now tells the assistant in a Codex chat it made to
|
|
114
|
+
put a question for the owner in its answer instead.
|
|
115
|
+
|
|
116
|
+
**On Linux a bridge chat cannot be listed in the Claude app.** The vendor's opener works on
|
|
117
|
+
macOS and Windows only. A bridge conversation shows in the Claude app on Linux when a chat
|
|
118
|
+
there is listening.
|
|
119
|
+
|
|
120
|
+
## Licence
|
|
121
|
+
|
|
122
|
+
MIT. See [LICENSE](LICENSE).
|
package/bin/nomeat.mjs
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Entry point: parse arguments, wire the pieces, print one JSON result.
|
|
3
|
+
// Sections: help / call and standing / history / listener and reply / follow-up / dispatch.
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { parseArgs } from 'node:util';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { callClaude, openInClaudeApp } from '../src/adapters/claude.mjs';
|
|
9
|
+
import { recoverAttendedReply, routeClaude } from '../src/adapters/claude-attended.mjs';
|
|
10
|
+
import { callCodex, checkCodexRequest, recoverCodexReply } from '../src/adapters/codex.mjs';
|
|
11
|
+
import { openNewCodexThread } from '../src/adapters/codex-app.mjs';
|
|
12
|
+
import { callPeer } from '../src/call.mjs';
|
|
13
|
+
import { checkRequest, sentWithoutWaiting } from '../src/check.mjs';
|
|
14
|
+
import { findThreads, readConversation, readThread } from '../src/history/index.mjs';
|
|
15
|
+
import { validateCompactOptions } from '../src/history/compact.mjs';
|
|
16
|
+
import { defaultStateDir, openLedger } from '../src/ledger.mjs';
|
|
17
|
+
import { requestLine, runListener } from '../src/listen.mjs';
|
|
18
|
+
import { openMailbox } from '../src/mailbox.mjs';
|
|
19
|
+
import { orient } from '../src/orient.mjs';
|
|
20
|
+
import { DEFAULT_PEERS, resolvePeer } from '../src/peers.mjs';
|
|
21
|
+
import { notifyRemoteWork, provisionNewChat, remoteCommand } from '../src/remote-control.mjs';
|
|
22
|
+
import { remoteStore } from '../src/remote-control-store.mjs';
|
|
23
|
+
import { attendedChats, nameStandingThread, standingFeedback, standingSnapshot } from '../src/standing.mjs';
|
|
24
|
+
import { summarizeCalls } from '../src/stats.mjs';
|
|
25
|
+
|
|
26
|
+
const USAGE = `usage:
|
|
27
|
+
nomeat call --to <peer> --from <caller> [--message <text>] [options]
|
|
28
|
+
nomeat orient [--cwd <project>] [--from <caller>]
|
|
29
|
+
nomeat standing --to <peer> --from <caller> [--thread <id> | --clear]
|
|
30
|
+
nomeat find --side <codex|claude> [--search <title words>] [--text <message words>] [--project] [--limit <n>]
|
|
31
|
+
nomeat read --side <codex|claude> --thread <id> [--turns <n>] [--budget <bytes>] [--cursor <cursor>] [--tools | --raw]
|
|
32
|
+
nomeat listen --as <peer> [--thread <this chat's id>] [--once]
|
|
33
|
+
nomeat reply --request-id <id> [--message <text>]
|
|
34
|
+
nomeat check [--request-id <id>] [--since <ISO date>]
|
|
35
|
+
nomeat stats [--since <ISO date>]
|
|
36
|
+
nomeat peers
|
|
37
|
+
nomeat remote-control enable --as <peer> --thread <listening Desktop chat ID> [--cwd <project>]
|
|
38
|
+
nomeat remote-control status [--target <id>] | disable | retry --target <id>
|
|
39
|
+
|
|
40
|
+
call: the message is read from stdin when --message is omitted. Without --thread, the call
|
|
41
|
+
goes to a Claude chat that is listening (see listen), or else continues the standing thread
|
|
42
|
+
between this caller and peer in this project. The first call names that thread.
|
|
43
|
+
--thread <id> send into this exact thread; the standing thread stays as it is
|
|
44
|
+
--new start a side thread; later calls still continue the standing one
|
|
45
|
+
--standing make the thread of this call the standing one from now on
|
|
46
|
+
--effort <level> thinking level, passed to the vendor as given (default: the peer's)
|
|
47
|
+
--model <id> override the peer's default model
|
|
48
|
+
--write let the peer edit files in the working directory (default: read-only)
|
|
49
|
+
--no-open do not open a new chat in the peer's desktop app to list it there
|
|
50
|
+
--no-remote-control skip automatic Remote Control setup for this new chat
|
|
51
|
+
--no-wait send and return at once; the reply does not come back to this call
|
|
52
|
+
(Codex peers, into a thread the Codex app has open)
|
|
53
|
+
--request-id <id> reuse on a retry so the message is not sent twice
|
|
54
|
+
--force resend although the ledger shows an earlier attempt
|
|
55
|
+
--hop <n> position in the exchange, starting at 1
|
|
56
|
+
--max-hops <n> hop limit (default 4)
|
|
57
|
+
--cwd <dir> project directory for the peer (default: current)
|
|
58
|
+
|
|
59
|
+
listen: run by a Claude chat in its app so that calls to that peer name in this project reach
|
|
60
|
+
the chat itself. Prints one JSON line per incoming request; answer each with reply.
|
|
61
|
+
Only one chat can listen per project and peer. Pass --thread to allow that chat to restart
|
|
62
|
+
without waiting for its old heartbeat to expire. --once (needs --thread) exits after the first
|
|
63
|
+
output, for running as a background command; start it again at once to keep listening.
|
|
64
|
+
|
|
65
|
+
standing: show which thread calls between this caller and peer continue in this project.
|
|
66
|
+
With --thread, name that thread as the standing one; --clear restores unnamed fallback
|
|
67
|
+
behavior. Both append ledger events, send nothing and print a restoration command.
|
|
68
|
+
|
|
69
|
+
orient: resolved project (--cwd or current directory), state access advisory, outgoing and
|
|
70
|
+
incoming routes, listener registrations and role-specific next steps. No writes or messages.
|
|
71
|
+
Registrations and permission checks do not prove delivery or sandbox write access.
|
|
72
|
+
|
|
73
|
+
read: compact user/assistant conversation by default, newest 2 visible turns in chronological
|
|
74
|
+
order. --tools includes tool details. Synthetic notifications/compaction context are counted
|
|
75
|
+
but excluded. --budget bounds the entire minified JSON plus newline in UTF-8 bytes (default
|
|
76
|
+
16000, minimum 2048). --max-chars limits each text fragment (default 4000 UTF-16 units).
|
|
77
|
+
Use nextCursor with --cursor (same side/thread/--tools) to continue, including inside long
|
|
78
|
+
messages; once complete, olderCursor reads the preceding visible-turn window. --turns is
|
|
79
|
+
ignored on continuation. --raw restores the old tool-inclusive shape,
|
|
80
|
+
raw-turn selection and per-item --max-chars clipping; it cannot use --tools/--budget/--cursor.
|
|
81
|
+
|
|
82
|
+
find: --search matches titles. --text finds chats where one message of the person or the
|
|
83
|
+
assistant holds all the words, in the most recent chats only; with both, both must match.
|
|
84
|
+
--project keeps only chats whose folder is this project (or --cwd), so a chat is found even
|
|
85
|
+
when its title does not mention the project.
|
|
86
|
+
|
|
87
|
+
check: follow up on a message sent with --no-wait. Says whether the peer has not started on
|
|
88
|
+
it, is working on it, or has finished, and gives the reply when there is one. Without
|
|
89
|
+
--request-id it checks every such message of this project from the last 24 hours.
|
|
90
|
+
|
|
91
|
+
exit codes: 0 done, 1 failed, 2 usage, 3 not sent (duplicate, uncertain or refused)`;
|
|
92
|
+
|
|
93
|
+
const NOT_SENT = new Set(['duplicate', 'uncertain', 'refused']);
|
|
94
|
+
// How long a sent call is assumed to be still running: the adapters' own time limit.
|
|
95
|
+
const OPEN_CALL_MAX_AGE_MS = 10 * 60 * 1000;
|
|
96
|
+
|
|
97
|
+
class UsageError extends Error {}
|
|
98
|
+
|
|
99
|
+
function positiveInteger(value, name) {
|
|
100
|
+
if (value === undefined) return undefined;
|
|
101
|
+
const number = Number(value);
|
|
102
|
+
if (!Number.isSafeInteger(number) || number < 1) throw new UsageError(`${name} must be a positive safe integer`);
|
|
103
|
+
return number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const print = (value) => console.log(JSON.stringify(value, null, 2));
|
|
107
|
+
|
|
108
|
+
async function runCall(values) {
|
|
109
|
+
if (!values.to) throw new UsageError('--to is required');
|
|
110
|
+
if (!values.from) throw new UsageError('--from is required: name the assistant that is calling');
|
|
111
|
+
const message = values.message ?? readFileSync(0, 'utf8');
|
|
112
|
+
if (!message.trim()) throw new UsageError('the message is empty');
|
|
113
|
+
const peer = resolvePeer(values.to, { model: values.model, effort: values.effort });
|
|
114
|
+
const project = path.resolve(values.cwd ?? process.cwd());
|
|
115
|
+
const stateDir = defaultStateDir();
|
|
116
|
+
const ledger = openLedger(stateDir);
|
|
117
|
+
const mailbox = openMailbox(stateDir);
|
|
118
|
+
const claude = routeClaude({
|
|
119
|
+
mailbox,
|
|
120
|
+
headless: callClaude,
|
|
121
|
+
isWaitingOn: (route) => Boolean(ledger.openCall({ ...route, maxAgeMs: OPEN_CALL_MAX_AGE_MS })),
|
|
122
|
+
});
|
|
123
|
+
const outcome = await callPeer(
|
|
124
|
+
{
|
|
125
|
+
peer,
|
|
126
|
+
message,
|
|
127
|
+
from: values.from,
|
|
128
|
+
project,
|
|
129
|
+
threadId: values.thread,
|
|
130
|
+
newThread: values.new,
|
|
131
|
+
makeStanding: values.standing,
|
|
132
|
+
noWait: values.wait === false,
|
|
133
|
+
requestId: values['request-id'],
|
|
134
|
+
effort: peer.effort,
|
|
135
|
+
// Only what the caller typed: a dropped peer default is not worth reporting.
|
|
136
|
+
named: ['model', 'effort'].filter((name) => values[name] !== undefined),
|
|
137
|
+
write: values.write,
|
|
138
|
+
force: values.force,
|
|
139
|
+
hop: positiveInteger(values.hop, '--hop'),
|
|
140
|
+
maxHops: positiveInteger(values['max-hops'], '--max-hops'),
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
ledger,
|
|
144
|
+
adapters: { codex: callCodex, claude },
|
|
145
|
+
recoverers: { codex: recoverCodexReply, claude: recoverAttendedReply(mailbox) },
|
|
146
|
+
attendedChat: attendedChats({ ledger, mailbox }),
|
|
147
|
+
},
|
|
148
|
+
);
|
|
149
|
+
// New Claude sessions are listed in the app by default, so the owner sees them as chats.
|
|
150
|
+
const open = values.open ?? !outcome.continued;
|
|
151
|
+
if (open && outcome.state === 'answered' && peer.vendor === 'claude') {
|
|
152
|
+
outcome.app = await openInClaudeApp(outcome.threadId, { cwd: project });
|
|
153
|
+
provisionNewChat({
|
|
154
|
+
outcome, vendor: peer.vendor, optOut: values['remote-control'] === false, store: remoteStore(stateDir),
|
|
155
|
+
createdForRequest: id => ledger.history(outcome.requestId).some(r => r.state === 'thread' && r.threadId === id),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
// A Codex thread the call created is opened by its link, so that the Codex app lists it.
|
|
159
|
+
if (peer.vendor === 'codex') await openNewCodexThread({ outcome, optOut: values.open === false });
|
|
160
|
+
print(outcome);
|
|
161
|
+
return NOT_SENT.has(outcome.state) ? 3 : 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function runStanding(values) {
|
|
165
|
+
if (!values.to) throw new UsageError('--to is required');
|
|
166
|
+
if (!values.from) throw new UsageError('--from is required: name the assistant that calls');
|
|
167
|
+
if (values.clear && values.thread !== undefined) throw new UsageError('--clear and --thread cannot be used together');
|
|
168
|
+
if (values.thread !== undefined && !values.thread.trim()) throw new UsageError('--thread is empty');
|
|
169
|
+
const route = { project: path.resolve(values.cwd ?? process.cwd()), from: values.from, peer: resolvePeer(values.to).name };
|
|
170
|
+
const stateDir = defaultStateDir();
|
|
171
|
+
const ledger = openLedger(stateDir);
|
|
172
|
+
const before = standingSnapshot(ledger, route);
|
|
173
|
+
if (values.thread || values.clear) {
|
|
174
|
+
nameStandingThread({ ledger, attendedChat: attendedChats({ ledger, mailbox: openMailbox(stateDir) }), route, threadId: values.clear ? null : values.thread });
|
|
175
|
+
}
|
|
176
|
+
const threadId = ledger.standingThread(route);
|
|
177
|
+
// Until a standing thread is named, calls continue the last answered one.
|
|
178
|
+
const untilNamed = threadId ? undefined : ledger.lastThread(route);
|
|
179
|
+
const changed = Boolean(values.thread || values.clear);
|
|
180
|
+
const missing = threadId || untilNamed ? null : ledger.notFound('no standing or answered thread for this route');
|
|
181
|
+
print({ ...route, threadId, ...(threadId ? {} : { untilNamed }),
|
|
182
|
+
...(missing ? { stateDir: missing.stateDir, [changed ? 'stateNote' : 'note']: missing.note } : {}),
|
|
183
|
+
...(changed ? standingFeedback({ route, before, after: standingSnapshot(ledger, route), executable: process.execPath, cli: fileURLToPath(import.meta.url), stateDir }) : {}),
|
|
184
|
+
});
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function runFind(values) {
|
|
189
|
+
const project = values.project ? path.resolve(values.cwd ?? process.cwd()) : undefined;
|
|
190
|
+
if (values.text !== undefined && !values.text.trim()) throw new UsageError('--text is empty');
|
|
191
|
+
print(await findThreads({ side: values.side, search: values.search, text: values.text, limit: positiveInteger(values.limit, '--limit'), project }));
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function runRead(values) {
|
|
196
|
+
const side = values.side?.toLowerCase();
|
|
197
|
+
if (!['codex', 'claude'].includes(side)) throw new UsageError('--side must be one of: codex, claude');
|
|
198
|
+
if (!values.thread) throw new UsageError('--thread is required');
|
|
199
|
+
if (values.raw && (values.tools || values.budget !== undefined || values.cursor !== undefined)) throw new UsageError('--raw cannot use --tools, --budget or --cursor');
|
|
200
|
+
const options = { side, threadId: values.thread,
|
|
201
|
+
turns: positiveInteger(values.turns, '--turns'), maxChars: positiveInteger(values['max-chars'], '--max-chars'),
|
|
202
|
+
budget: positiveInteger(values.budget, '--budget'), cursor: values.cursor, tools: values.tools,
|
|
203
|
+
};
|
|
204
|
+
if (values.raw) print(await readThread(options));
|
|
205
|
+
else {
|
|
206
|
+
try { validateCompactOptions(options); } catch (error) { throw new UsageError(error.message); }
|
|
207
|
+
process.stdout.write(`${JSON.stringify(await readConversation(options))}\n`);
|
|
208
|
+
}
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function runListen(values) {
|
|
213
|
+
if (!values.as) throw new UsageError('--as is required: the peer name this chat answers to');
|
|
214
|
+
const box = { project: path.resolve(values.cwd ?? process.cwd()), peer: resolvePeer(values.as).name };
|
|
215
|
+
// The claim kept after a --once exit can only be taken over by a start that names the chat.
|
|
216
|
+
if (values.once && !values.thread) throw new UsageError('--once needs --thread, so that the next start can take over');
|
|
217
|
+
const mailbox = openMailbox(defaultStateDir());
|
|
218
|
+
let stopped = false;
|
|
219
|
+
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => (stopped = true));
|
|
220
|
+
let printed = false;
|
|
221
|
+
const line = (value) => {
|
|
222
|
+
console.log(JSON.stringify(value));
|
|
223
|
+
printed = true;
|
|
224
|
+
};
|
|
225
|
+
// With --once: stop after the round that printed something, and keep the claim for the next start.
|
|
226
|
+
const done = () => values.once && printed;
|
|
227
|
+
await runListener({
|
|
228
|
+
mailbox,
|
|
229
|
+
box,
|
|
230
|
+
session: values.thread ?? null,
|
|
231
|
+
onHeartbeat: notifyRemoteWork({
|
|
232
|
+
store: remoteStore(defaultStateDir()), box, session: values.thread,
|
|
233
|
+
emit: line, warn: (note) => console.error(note),
|
|
234
|
+
}),
|
|
235
|
+
stopped: () => stopped || done(),
|
|
236
|
+
holdClaim: () => !stopped && done(),
|
|
237
|
+
// One line per request: the app turns each line into a notification for the chat.
|
|
238
|
+
emit: (request) => line(requestLine(request)),
|
|
239
|
+
});
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function runReply(values) {
|
|
244
|
+
if (!values['request-id']) throw new UsageError('--request-id is required');
|
|
245
|
+
const reply = values.message ?? readFileSync(0, 'utf8');
|
|
246
|
+
if (!reply.trim()) throw new UsageError('the reply is empty');
|
|
247
|
+
openMailbox(defaultStateDir()).answer(values['request-id'], reply);
|
|
248
|
+
print({ requestId: values['request-id'], state: 'replied' });
|
|
249
|
+
return 0;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
253
|
+
|
|
254
|
+
async function runCheck(values) {
|
|
255
|
+
const ledger = openLedger(defaultStateDir());
|
|
256
|
+
const checkers = { codex: checkCodexRequest };
|
|
257
|
+
if (values['request-id']) {
|
|
258
|
+
print(await checkRequest(values['request-id'], { ledger, checkers }));
|
|
259
|
+
return 0;
|
|
260
|
+
}
|
|
261
|
+
const project = path.resolve(values.cwd ?? process.cwd());
|
|
262
|
+
const since = values.since ?? new Date(Date.now() - DAY_MS).toISOString();
|
|
263
|
+
const results = [];
|
|
264
|
+
for (const requestId of sentWithoutWaiting(ledger.entries(), { project, since })) {
|
|
265
|
+
results.push(await checkRequest(requestId, { ledger, checkers }));
|
|
266
|
+
}
|
|
267
|
+
print({ project, since, requests: results });
|
|
268
|
+
return 0;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function runStats(values) {
|
|
272
|
+
const project = path.resolve(values.cwd ?? process.cwd());
|
|
273
|
+
print({
|
|
274
|
+
project,
|
|
275
|
+
since: values.since ?? null,
|
|
276
|
+
groups: summarizeCalls(openLedger(defaultStateDir()).entries(), { project, since: values.since }),
|
|
277
|
+
note: 'Token figures exist only where the vendor reports them to the bridge: direct Codex calls and headless Claude calls. They cover the peer that answered, not the caller.',
|
|
278
|
+
});
|
|
279
|
+
return 0;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const COMMANDS = Object.freeze({
|
|
283
|
+
orient: async values => { print(orient({ cwd: values.cwd, from: values.from })); return 0; },
|
|
284
|
+
call: runCall,
|
|
285
|
+
standing: runStanding,
|
|
286
|
+
find: runFind,
|
|
287
|
+
read: runRead,
|
|
288
|
+
listen: runListen,
|
|
289
|
+
reply: runReply,
|
|
290
|
+
check: runCheck,
|
|
291
|
+
stats: runStats,
|
|
292
|
+
peers: async () => {
|
|
293
|
+
print(DEFAULT_PEERS);
|
|
294
|
+
return 0;
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
async function main(argv) {
|
|
299
|
+
const { values, positionals } = parseArgs({
|
|
300
|
+
args: argv,
|
|
301
|
+
allowPositionals: true,
|
|
302
|
+
allowNegative: true,
|
|
303
|
+
options: {
|
|
304
|
+
to: { type: 'string' },
|
|
305
|
+
from: { type: 'string' },
|
|
306
|
+
as: { type: 'string' },
|
|
307
|
+
message: { type: 'string' },
|
|
308
|
+
thread: { type: 'string' },
|
|
309
|
+
effort: { type: 'string' },
|
|
310
|
+
model: { type: 'string' },
|
|
311
|
+
'request-id': { type: 'string' },
|
|
312
|
+
hop: { type: 'string' },
|
|
313
|
+
'max-hops': { type: 'string' },
|
|
314
|
+
cwd: { type: 'string' },
|
|
315
|
+
side: { type: 'string' },
|
|
316
|
+
search: { type: 'string' },
|
|
317
|
+
text: { type: 'string' },
|
|
318
|
+
since: { type: 'string' },
|
|
319
|
+
limit: { type: 'string' },
|
|
320
|
+
turns: { type: 'string' },
|
|
321
|
+
'max-chars': { type: 'string' },
|
|
322
|
+
budget: { type: 'string' },
|
|
323
|
+
cursor: { type: 'string' },
|
|
324
|
+
tools: { type: 'boolean', default: false },
|
|
325
|
+
raw: { type: 'boolean', default: false },
|
|
326
|
+
clear: { type: 'boolean', default: false },
|
|
327
|
+
open: { type: 'boolean' },
|
|
328
|
+
'remote-control': { type: 'boolean' },
|
|
329
|
+
target: { type: 'string' },
|
|
330
|
+
receipt: { type: 'string' },
|
|
331
|
+
result: { type: 'string' },
|
|
332
|
+
wait: { type: 'boolean' },
|
|
333
|
+
new: { type: 'boolean', default: false },
|
|
334
|
+
standing: { type: 'boolean', default: false },
|
|
335
|
+
project: { type: 'boolean', default: false },
|
|
336
|
+
write: { type: 'boolean', default: false },
|
|
337
|
+
force: { type: 'boolean', default: false },
|
|
338
|
+
once: { type: 'boolean', default: false },
|
|
339
|
+
help: { type: 'boolean', default: false },
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
const command = positionals[0];
|
|
343
|
+
if (values.help) {
|
|
344
|
+
console.log(USAGE);
|
|
345
|
+
return 0;
|
|
346
|
+
}
|
|
347
|
+
if (command === 'remote-control') {
|
|
348
|
+
const dir = defaultStateDir();
|
|
349
|
+
print(remoteCommand(positionals[1], values, { store: remoteStore(dir), mailbox: openMailbox(dir) }));
|
|
350
|
+
return 0;
|
|
351
|
+
}
|
|
352
|
+
if (!command || !Object.hasOwn(COMMANDS, command)) {
|
|
353
|
+
throw new UsageError(command ? `unknown command "${command}"` : 'a command is required');
|
|
354
|
+
}
|
|
355
|
+
return COMMANDS[command](values);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
main(process.argv.slice(2)).then(
|
|
359
|
+
(code) => process.exit(code),
|
|
360
|
+
(error) => {
|
|
361
|
+
const usage = error instanceof UsageError || error.code?.startsWith?.('ERR_PARSE_ARGS');
|
|
362
|
+
console.error(usage ? `${error.message}\n\n${USAGE}` : `nomeat failed: ${error.message}`);
|
|
363
|
+
process.exit(usage ? 2 : 1);
|
|
364
|
+
},
|
|
365
|
+
);
|
|
Binary file
|