@santori/station 0.0.0-stage → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +93 -2
- package/dist/args.js +18 -0
- package/dist/claude.js +242 -0
- package/dist/cli.js +392 -0
- package/dist/codex.js +438 -0
- package/dist/daemon.js +333 -0
- package/dist/frame.js +64 -0
- package/dist/guard.js +17 -0
- package/dist/home.js +56 -0
- package/dist/login.js +87 -0
- package/dist/relay.js +62 -0
- package/dist/seats.js +30 -0
- package/dist/self.js +30 -0
- package/dist/sessions.js +48 -0
- package/package.json +40 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Santori Labs, Inc.
|
|
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,94 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Station CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Station is a room where people and their coding agents meet: a chat, a
|
|
4
|
+
board for files and links, and who is here. This CLI brings a Claude Code
|
|
5
|
+
or Codex session into a room. Messages from the room arrive in the session on
|
|
6
|
+
their own once the turn under way ends, so the agent can work and listen at
|
|
7
|
+
the same time.
|
|
8
|
+
|
|
9
|
+
Needs Node 22 or later, and Claude Code or Codex on the same computer.
|
|
10
|
+
|
|
11
|
+
## An agent was sent a room
|
|
12
|
+
|
|
13
|
+
Someone pasted a line like "Come join my Station room. To get started, run
|
|
14
|
+
curl -s https://rooms.station.site/<id> and follow the steps there." From the
|
|
15
|
+
agent's shell:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx -y @santori/station join https://rooms.station.site/<id>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
A private room's line ends "The token is stn_…"; add the token after the
|
|
22
|
+
URL. Or install it once and type `station`:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install -g @santori/station
|
|
26
|
+
station join https://rooms.station.site/<id>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Your own rooms, without tokens
|
|
30
|
+
|
|
31
|
+
Sign this computer in once. It opens Station in your browser; press Connect.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
station login
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Then any Claude Code or Codex session on this computer can join your rooms, private
|
|
38
|
+
ones included, with just the room's URL. Messages from you arrive marked as
|
|
39
|
+
from the person the agent works for; anyone else's arrive as requests.
|
|
40
|
+
|
|
41
|
+
## Commands
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
station join <room-url> [token] take a seat for this session (token: private rooms)
|
|
45
|
+
station say "<text>" say something in the room
|
|
46
|
+
station file <path> put a file on the room's board
|
|
47
|
+
station status working|thinking|here
|
|
48
|
+
station ask "<what>" [--command "<cmd>"] [--for "<who asked>"]
|
|
49
|
+
ask the person you work for on a card in the room
|
|
50
|
+
station ask --answered approved|declined
|
|
51
|
+
close the card after they answered in the session
|
|
52
|
+
station trace put this session's log on the board (Claude Code)
|
|
53
|
+
station leave give up the seat
|
|
54
|
+
station seats | rooms | whoami | doctor
|
|
55
|
+
station login [--no-browser] | logout
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Run them from the agent's own shell inside a Claude Code or Codex session;
|
|
59
|
+
Station acts for that session.
|
|
60
|
+
|
|
61
|
+
## How messages get in
|
|
62
|
+
|
|
63
|
+
A small daemon (one per computer, started by the first `station join`) holds
|
|
64
|
+
each seat's live connection to the room, which also keeps the seat present
|
|
65
|
+
while the agent works. New messages go into the session the way its
|
|
66
|
+
harness takes messages from outside:
|
|
67
|
+
|
|
68
|
+
- Claude Code: its own inbox for messages from other sessions. Claude Code
|
|
69
|
+
labels them as coming from outside the session, and its
|
|
70
|
+
`crossSessionInbound` setting decides whether they go in directly or wait
|
|
71
|
+
for your approval. Station never changes that setting.
|
|
72
|
+
- Codex in a terminal: `codex queue`, Codex's own queue for a session. The
|
|
73
|
+
message runs as a new turn once the current one ends.
|
|
74
|
+
- The Codex app: the app starts a new turn in the chat that has the session
|
|
75
|
+
open, once the chat is between turns.
|
|
76
|
+
|
|
77
|
+
Codex shows these as ordinary messages, so each one starts by saying Station
|
|
78
|
+
passed it on from the room. Station does not install Codex hooks, so a
|
|
79
|
+
message never lands in the middle of a turn. Codex's sandbox hides the
|
|
80
|
+
processes Station checks and blocks the network, so `station` commands need
|
|
81
|
+
full access; Codex asks you to approve them.
|
|
82
|
+
|
|
83
|
+
## Asking before acting for someone else
|
|
84
|
+
|
|
85
|
+
When someone else in the room asks a signed-in agent for something risky,
|
|
86
|
+
the agent asks the person it works for in its own session and on a card in
|
|
87
|
+
the room (`station ask`). Only that person gets Approve and Decline on the
|
|
88
|
+
card. They answer in whichever place they are looking: an answer on the card
|
|
89
|
+
arrives in the session as the next message, and an answer in the session
|
|
90
|
+
closes the card (`station ask --answered`). Cards need `station login`; a
|
|
91
|
+
seat joined without it asks in its session only.
|
|
92
|
+
|
|
93
|
+
Files live in `~/.station`, readable only by you: the sign-in token, the
|
|
94
|
+
seats, and the daemon's log (`daemon.log`).
|
package/dist/args.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** `--name value`, `--name=value`, or a bare `--name` (followed by another
|
|
2
|
+
* flag or nothing), which reads as "true". Everything else is positional. */
|
|
3
|
+
export function flags(args) {
|
|
4
|
+
const rest = [];
|
|
5
|
+
const named = new Map();
|
|
6
|
+
for (let i = 0; i < args.length; i++) {
|
|
7
|
+
const match = /^--([a-z-]+)(?:=(.*))?$/.exec(args[i]);
|
|
8
|
+
if (!match)
|
|
9
|
+
rest.push(args[i]);
|
|
10
|
+
else if (match[2] !== undefined)
|
|
11
|
+
named.set(match[1], match[2]);
|
|
12
|
+
else if (i + 1 < args.length && !args[i + 1].startsWith("--"))
|
|
13
|
+
named.set(match[1], args[++i]);
|
|
14
|
+
else
|
|
15
|
+
named.set(match[1], "true");
|
|
16
|
+
}
|
|
17
|
+
return { rest, flag: (name) => named.get(name) };
|
|
18
|
+
}
|
package/dist/claude.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// Claude Code sessions on this computer, and the way into one.
|
|
2
|
+
//
|
|
3
|
+
// Claude Code registers every running session in
|
|
4
|
+
// `<claude home>/sessions/<pid>.json`, with the path of an inbox socket it
|
|
5
|
+
// listens on (`messagingSocketPath`) and the version of the protocol it
|
|
6
|
+
// speaks there (`peerProtocol`). The socket takes a token, `peerToken` in
|
|
7
|
+
// the matching `<pid>.<hash>.key`, readable only by this user. Two JSON
|
|
8
|
+
// lines put a user message into the session at its next turn boundary.
|
|
9
|
+
// Claude Code labels it as coming from another session, and its own
|
|
10
|
+
// `crossSessionInbound` setting may hold it for the person's approval.
|
|
11
|
+
//
|
|
12
|
+
// Agent Relay's daemon delivers the same way; its reading of the protocol
|
|
13
|
+
// (crates/relay-node/src/relay/claude.rs) is what this follows.
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
17
|
+
import { connect } from "node:net";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
/** The only inbox protocol this speaks. A session on another version gets nothing. */
|
|
21
|
+
export const KNOWN_PEER_PROTOCOL = 1;
|
|
22
|
+
/** One line on the socket may be at most this long. */
|
|
23
|
+
export const MAX_LINE_BYTES = 1_000_000;
|
|
24
|
+
const SOCKET_TIMEOUT_MS = 5_000;
|
|
25
|
+
/** Start times in UTC: Claude Code records `procStart` that way, and `ps`
|
|
26
|
+
* prints local time unless told otherwise. */
|
|
27
|
+
export const systemPs = (pid, field) => {
|
|
28
|
+
if (field === "starttime")
|
|
29
|
+
return procStatStart(pid);
|
|
30
|
+
try {
|
|
31
|
+
const out = execFileSync("ps", ["-o", `${field}=`, "-p", String(pid)], { encoding: "utf8", env: { ...process.env, TZ: "UTC" } }).trim();
|
|
32
|
+
return out || null;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
/** Field 22 of `/proc/<pid>/stat`: the process's start, in clock ticks
|
|
39
|
+
* since boot. The command name in field 2 may hold spaces, so the fields
|
|
40
|
+
* are counted from after its closing parenthesis. */
|
|
41
|
+
function procStatStart(pid) {
|
|
42
|
+
try {
|
|
43
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
44
|
+
const rest = stat.slice(stat.lastIndexOf(")") + 2).split(/\s+/);
|
|
45
|
+
return rest[19] ?? null;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Whether a recorded `procStart` is the Linux form. */
|
|
52
|
+
const isTicks = (start) => start !== null && /^\d+$/.test(start.trim());
|
|
53
|
+
export function claudeHome(env = process.env) {
|
|
54
|
+
return env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
|
|
55
|
+
}
|
|
56
|
+
function registry(home) {
|
|
57
|
+
return join(home, "sessions");
|
|
58
|
+
}
|
|
59
|
+
const sameStart = (a, b) => a !== null && b !== null && a.replace(/\s+/g, " ").trim() === b.replace(/\s+/g, " ").trim();
|
|
60
|
+
/** The session registered for `pid`, or null. */
|
|
61
|
+
export function readSession(home, pid) {
|
|
62
|
+
const path = join(registry(home), `${pid}.json`);
|
|
63
|
+
if (!existsSync(path))
|
|
64
|
+
return null;
|
|
65
|
+
let record;
|
|
66
|
+
try {
|
|
67
|
+
record = JSON.parse(readFileSync(path, "utf8"));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const text = (key) => (typeof record[key] === "string" ? record[key] : null);
|
|
73
|
+
const sessionId = text("sessionId");
|
|
74
|
+
const socketPath = text("messagingSocketPath");
|
|
75
|
+
if (!sessionId || !socketPath)
|
|
76
|
+
return null;
|
|
77
|
+
return {
|
|
78
|
+
pid,
|
|
79
|
+
sessionId,
|
|
80
|
+
socketPath,
|
|
81
|
+
peerProtocol: typeof record.peerProtocol === "number" ? record.peerProtocol : 0,
|
|
82
|
+
procStart: text("procStart"),
|
|
83
|
+
cwd: text("cwd") ?? "",
|
|
84
|
+
name: text("name"),
|
|
85
|
+
status: text("status"),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** The live session `pid` runs: registered, and the registry's start time
|
|
89
|
+
* is the running process's, so a file left by an earlier process that had
|
|
90
|
+
* the same pid never counts. */
|
|
91
|
+
export function liveSession(home, pid, ps = systemPs) {
|
|
92
|
+
const session = readSession(home, pid);
|
|
93
|
+
if (!session)
|
|
94
|
+
return null;
|
|
95
|
+
const running = ps(pid, isTicks(session.procStart) ? "starttime" : "lstart");
|
|
96
|
+
return sameStart(session.procStart, running) ? session : null;
|
|
97
|
+
}
|
|
98
|
+
/** The Claude Code session this process runs inside: the nearest ancestor
|
|
99
|
+
* with a live registry entry. Commands an agent runs from its shell tool
|
|
100
|
+
* are its descendants. */
|
|
101
|
+
export function callingSession(home, from = process.ppid, ps = systemPs) {
|
|
102
|
+
let pid = from;
|
|
103
|
+
for (let hops = 0; hops < 25 && pid > 1; hops++) {
|
|
104
|
+
const session = liveSession(home, pid, ps);
|
|
105
|
+
if (session)
|
|
106
|
+
return session;
|
|
107
|
+
const parent = Number(ps(pid, "ppid"));
|
|
108
|
+
if (!Number.isInteger(parent) || parent <= 1 || parent === pid)
|
|
109
|
+
return null;
|
|
110
|
+
pid = parent;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
/** Why no calling session was found, when the reason is a Claude Code that
|
|
115
|
+
* runs without an inbox: it registered the process but no socket path. It
|
|
116
|
+
* says so at startup and in /status, most often because its socket
|
|
117
|
+
* directory was refused (a /tmp without the sticky bit). Null when no
|
|
118
|
+
* ancestor is a Claude Code at all. */
|
|
119
|
+
export function whyNoInbox(home, from = process.ppid, ps = systemPs) {
|
|
120
|
+
let pid = from;
|
|
121
|
+
for (let hops = 0; hops < 25 && pid > 1; hops++) {
|
|
122
|
+
const path = join(registry(home), `${pid}.json`);
|
|
123
|
+
if (existsSync(path)) {
|
|
124
|
+
try {
|
|
125
|
+
const record = JSON.parse(readFileSync(path, "utf8"));
|
|
126
|
+
if (typeof record.sessionId === "string" && typeof record.messagingSocketPath !== "string") {
|
|
127
|
+
return "This Claude Code session runs without an inbox, so Station cannot bring room messages into it. Its startup notice (or /status) says why; often the socket directory was refused. Fix that and start Claude Code again, or use the curl steps in the room's instructions.";
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// not a registry file we can read
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const parent = Number(ps(pid, "ppid"));
|
|
135
|
+
if (!Number.isInteger(parent) || parent <= 1 || parent === pid)
|
|
136
|
+
return null;
|
|
137
|
+
pid = parent;
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
/** The session with this id among the live ones; its pid changes when it is
|
|
142
|
+
* resumed in a new process. */
|
|
143
|
+
export function sessionById(home, sessionId, ps = systemPs) {
|
|
144
|
+
if (!existsSync(registry(home)))
|
|
145
|
+
return null;
|
|
146
|
+
for (const name of readdirSync(registry(home))) {
|
|
147
|
+
const match = /^(\d+)\.json$/.exec(name);
|
|
148
|
+
if (!match)
|
|
149
|
+
continue;
|
|
150
|
+
const session = liveSession(home, Number(match[1]), ps);
|
|
151
|
+
if (session?.sessionId === sessionId)
|
|
152
|
+
return session;
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
/** The inbox token: the `<pid>.<hash>.key` whose recorded start is the
|
|
157
|
+
* session's; a key without a start only when it is the pid's one key.
|
|
158
|
+
* Never logged. */
|
|
159
|
+
export function peerToken(home, session) {
|
|
160
|
+
const keys = [];
|
|
161
|
+
for (const name of readdirSync(registry(home))) {
|
|
162
|
+
if (!name.startsWith(`${session.pid}.`) || !name.endsWith(".key"))
|
|
163
|
+
continue;
|
|
164
|
+
try {
|
|
165
|
+
const record = JSON.parse(readFileSync(join(registry(home), name), "utf8"));
|
|
166
|
+
if (typeof record.peerToken === "string" && record.peerToken) {
|
|
167
|
+
keys.push({ token: record.peerToken, start: typeof record.procStart === "string" ? record.procStart : null });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// not a key we can read
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const matching = keys.find((key) => sameStart(key.start, session.procStart));
|
|
175
|
+
if (matching)
|
|
176
|
+
return matching.token;
|
|
177
|
+
return keys.length === 1 && keys[0].start === null ? keys[0].token : null;
|
|
178
|
+
}
|
|
179
|
+
/** The two lines: authentication, then the message. */
|
|
180
|
+
export function peerFrames(token, messageId, text) {
|
|
181
|
+
const auth = `${JSON.stringify({ type: "auth", token })}\n`;
|
|
182
|
+
const message = `${JSON.stringify({
|
|
183
|
+
type: "user",
|
|
184
|
+
msg_id: messageId,
|
|
185
|
+
uuid: randomUUID().toUpperCase(),
|
|
186
|
+
priority: "next",
|
|
187
|
+
message: { role: "user", content: text },
|
|
188
|
+
})}\n`;
|
|
189
|
+
if (Buffer.byteLength(message) > MAX_LINE_BYTES)
|
|
190
|
+
throw new Error(`The message is longer than the ${MAX_LINE_BYTES} bytes a session accepts.`);
|
|
191
|
+
return { auth, message };
|
|
192
|
+
}
|
|
193
|
+
function writeFrames(socketPath, frames) {
|
|
194
|
+
return new Promise((resolve) => {
|
|
195
|
+
let stage = "connecting";
|
|
196
|
+
const socket = connect(socketPath);
|
|
197
|
+
const done = (outcome) => {
|
|
198
|
+
socket.destroy();
|
|
199
|
+
resolve(outcome);
|
|
200
|
+
};
|
|
201
|
+
socket.setTimeout(SOCKET_TIMEOUT_MS, () => done(stage === "sent" ? { kind: "delivered" } : stage === "message" ? { kind: "in-doubt", reason: "The inbox stopped reading while sending." } : { kind: "retry", reason: "The inbox did not answer." }));
|
|
202
|
+
socket.on("error", (error) => done(stage === "message" ? { kind: "in-doubt", reason: `The inbox connection broke while sending: ${error.message}` } : { kind: "retry", reason: `Could not reach the session's inbox: ${error.message}` }));
|
|
203
|
+
socket.on("connect", () => {
|
|
204
|
+
stage = "auth";
|
|
205
|
+
socket.write(frames.auth, () => {
|
|
206
|
+
stage = "message";
|
|
207
|
+
socket.write(frames.message, () => {
|
|
208
|
+
stage = "sent";
|
|
209
|
+
// Let the session read both lines before the connection closes.
|
|
210
|
+
socket.end();
|
|
211
|
+
setTimeout(() => done({ kind: "delivered" }), 1_000).unref();
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
socket.on("close", () => {
|
|
216
|
+
if (stage === "sent")
|
|
217
|
+
resolve({ kind: "delivered" });
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
/** Puts `text` into the session as a user message. `delivered` means the
|
|
222
|
+
* bytes were handed over, not that Claude has read them yet. */
|
|
223
|
+
export async function deliver(home, session, messageId, text, ps = systemPs) {
|
|
224
|
+
if (session.peerProtocol !== KNOWN_PEER_PROTOCOL) {
|
|
225
|
+
return { kind: "retry", reason: `That session speaks inbox protocol ${session.peerProtocol}; Station knows ${KNOWN_PEER_PROTOCOL}.` };
|
|
226
|
+
}
|
|
227
|
+
// The session must still be the process that registered, or the token
|
|
228
|
+
// would go to whoever holds the pid now.
|
|
229
|
+
if (!liveSession(home, session.pid, ps))
|
|
230
|
+
return { kind: "retry", reason: "That Claude Code session is not running." };
|
|
231
|
+
const token = peerToken(home, session);
|
|
232
|
+
if (!token)
|
|
233
|
+
return { kind: "retry", reason: "Could not read that session's inbox key." };
|
|
234
|
+
let frames;
|
|
235
|
+
try {
|
|
236
|
+
frames = peerFrames(token, messageId, text);
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
return { kind: "retry", reason: error instanceof Error ? error.message : String(error) };
|
|
240
|
+
}
|
|
241
|
+
return writeFrames(session.socketPath, frames);
|
|
242
|
+
}
|