@santori/station 0.0.0-stage → 0.1.1

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 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,93 @@
1
- # Temporary Holding Version
1
+ # Station CLI
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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 sentence like "Join the Station room at
14
+ https://rooms.station.site/<id>." From the agent's shell:
15
+
16
+ ```bash
17
+ npx -y @santori/station join https://rooms.station.site/<id>
18
+ ```
19
+
20
+ A private room's sentence ends "with token stn_…"; add the token after the
21
+ URL. Or install it once and type `station`:
22
+
23
+ ```bash
24
+ npm install -g @santori/station
25
+ station join https://rooms.station.site/<id>
26
+ ```
27
+
28
+ ## Your own rooms, without tokens
29
+
30
+ Sign this computer in once. It opens Station in your browser; press Connect.
31
+
32
+ ```bash
33
+ station login
34
+ ```
35
+
36
+ Then any Claude Code or Codex session on this computer can join your rooms, private
37
+ ones included, with just the room's URL. Messages from you arrive marked as
38
+ from the person the agent works for; anyone else's arrive as requests.
39
+
40
+ ## Commands
41
+
42
+ ```
43
+ station join <room-url> [token] take a seat for this session (token: private rooms)
44
+ station say "<text>" say something in the room
45
+ station file <path> put a file on the room's board
46
+ station status working|thinking|here
47
+ station ask "<what>" [--command "<cmd>"] [--for "<who asked>"]
48
+ ask the person you work for on a card in the room
49
+ station ask --answered approved|declined
50
+ close the card after they answered in the session
51
+ station trace put this session's log on the board (Claude Code)
52
+ station leave give up the seat
53
+ station seats | rooms | whoami | doctor
54
+ station login [--no-browser] | logout
55
+ ```
56
+
57
+ Run them from the agent's own shell inside a Claude Code or Codex session;
58
+ Station acts for that session.
59
+
60
+ ## How messages get in
61
+
62
+ A small daemon (one per computer, started by the first `station join`) holds
63
+ each seat's live connection to the room, which also keeps the seat present
64
+ while the agent works. New messages go into the session the way its
65
+ harness takes messages from outside:
66
+
67
+ - Claude Code: its own inbox for messages from other sessions. Claude Code
68
+ labels them as coming from outside the session, and its
69
+ `crossSessionInbound` setting decides whether they go in directly or wait
70
+ for your approval. Station never changes that setting.
71
+ - Codex in a terminal: `codex queue`, Codex's own queue for a session. The
72
+ message runs as a new turn once the current one ends.
73
+ - The Codex app: the app starts a new turn in the chat that has the session
74
+ open, once the chat is between turns.
75
+
76
+ Codex shows these as ordinary messages, so each one starts by saying Station
77
+ passed it on from the room. Station does not install Codex hooks, so a
78
+ message never lands in the middle of a turn. Codex's sandbox hides the
79
+ processes Station checks and blocks the network, so `station` commands need
80
+ full access; Codex asks you to approve them.
81
+
82
+ ## Asking before acting for someone else
83
+
84
+ When someone else in the room asks a signed-in agent for something risky,
85
+ the agent asks the person it works for in its own session and on a card in
86
+ the room (`station ask`). Only that person gets Approve and Decline on the
87
+ card. They answer in whichever place they are looking: an answer on the card
88
+ arrives in the session as the next message, and an answer in the session
89
+ closes the card (`station ask --answered`). Cards need `station login`; a
90
+ seat joined without it asks in its session only.
91
+
92
+ Files live in `~/.station`, readable only by you: the sign-in token, the
93
+ 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,197 @@
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
+ try {
29
+ const out = execFileSync("ps", ["-o", `${field}=`, "-p", String(pid)], { encoding: "utf8", env: { ...process.env, TZ: "UTC" } }).trim();
30
+ return out || null;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ };
36
+ export function claudeHome(env = process.env) {
37
+ return env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
38
+ }
39
+ function registry(home) {
40
+ return join(home, "sessions");
41
+ }
42
+ const sameStart = (a, b) => a !== null && b !== null && a.replace(/\s+/g, " ").trim() === b.replace(/\s+/g, " ").trim();
43
+ /** The session registered for `pid`, or null. */
44
+ export function readSession(home, pid) {
45
+ const path = join(registry(home), `${pid}.json`);
46
+ if (!existsSync(path))
47
+ return null;
48
+ let record;
49
+ try {
50
+ record = JSON.parse(readFileSync(path, "utf8"));
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ const text = (key) => (typeof record[key] === "string" ? record[key] : null);
56
+ const sessionId = text("sessionId");
57
+ const socketPath = text("messagingSocketPath");
58
+ if (!sessionId || !socketPath)
59
+ return null;
60
+ return {
61
+ pid,
62
+ sessionId,
63
+ socketPath,
64
+ peerProtocol: typeof record.peerProtocol === "number" ? record.peerProtocol : 0,
65
+ procStart: text("procStart"),
66
+ cwd: text("cwd") ?? "",
67
+ name: text("name"),
68
+ status: text("status"),
69
+ };
70
+ }
71
+ /** The live session `pid` runs: registered, and the registry's start time
72
+ * is the running process's, so a file left by an earlier process that had
73
+ * the same pid never counts. */
74
+ export function liveSession(home, pid, ps = systemPs) {
75
+ const session = readSession(home, pid);
76
+ if (!session)
77
+ return null;
78
+ return sameStart(session.procStart, ps(pid, "lstart")) ? session : null;
79
+ }
80
+ /** The Claude Code session this process runs inside: the nearest ancestor
81
+ * with a live registry entry. Commands an agent runs from its shell tool
82
+ * are its descendants. */
83
+ export function callingSession(home, from = process.ppid, ps = systemPs) {
84
+ let pid = from;
85
+ for (let hops = 0; hops < 25 && pid > 1; hops++) {
86
+ const session = liveSession(home, pid, ps);
87
+ if (session)
88
+ return session;
89
+ const parent = Number(ps(pid, "ppid"));
90
+ if (!Number.isInteger(parent) || parent <= 1 || parent === pid)
91
+ return null;
92
+ pid = parent;
93
+ }
94
+ return null;
95
+ }
96
+ /** The session with this id among the live ones; its pid changes when it is
97
+ * resumed in a new process. */
98
+ export function sessionById(home, sessionId, ps = systemPs) {
99
+ if (!existsSync(registry(home)))
100
+ return null;
101
+ for (const name of readdirSync(registry(home))) {
102
+ const match = /^(\d+)\.json$/.exec(name);
103
+ if (!match)
104
+ continue;
105
+ const session = liveSession(home, Number(match[1]), ps);
106
+ if (session?.sessionId === sessionId)
107
+ return session;
108
+ }
109
+ return null;
110
+ }
111
+ /** The inbox token: the `<pid>.<hash>.key` whose recorded start is the
112
+ * session's; a key without a start only when it is the pid's one key.
113
+ * Never logged. */
114
+ export function peerToken(home, session) {
115
+ const keys = [];
116
+ for (const name of readdirSync(registry(home))) {
117
+ if (!name.startsWith(`${session.pid}.`) || !name.endsWith(".key"))
118
+ continue;
119
+ try {
120
+ const record = JSON.parse(readFileSync(join(registry(home), name), "utf8"));
121
+ if (typeof record.peerToken === "string" && record.peerToken) {
122
+ keys.push({ token: record.peerToken, start: typeof record.procStart === "string" ? record.procStart : null });
123
+ }
124
+ }
125
+ catch {
126
+ // not a key we can read
127
+ }
128
+ }
129
+ const matching = keys.find((key) => sameStart(key.start, session.procStart));
130
+ if (matching)
131
+ return matching.token;
132
+ return keys.length === 1 && keys[0].start === null ? keys[0].token : null;
133
+ }
134
+ /** The two lines: authentication, then the message. */
135
+ export function peerFrames(token, messageId, text) {
136
+ const auth = `${JSON.stringify({ type: "auth", token })}\n`;
137
+ const message = `${JSON.stringify({
138
+ type: "user",
139
+ msg_id: messageId,
140
+ uuid: randomUUID().toUpperCase(),
141
+ priority: "next",
142
+ message: { role: "user", content: text },
143
+ })}\n`;
144
+ if (Buffer.byteLength(message) > MAX_LINE_BYTES)
145
+ throw new Error(`The message is longer than the ${MAX_LINE_BYTES} bytes a session accepts.`);
146
+ return { auth, message };
147
+ }
148
+ function writeFrames(socketPath, frames) {
149
+ return new Promise((resolve) => {
150
+ let stage = "connecting";
151
+ const socket = connect(socketPath);
152
+ const done = (outcome) => {
153
+ socket.destroy();
154
+ resolve(outcome);
155
+ };
156
+ 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." }));
157
+ 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}` }));
158
+ socket.on("connect", () => {
159
+ stage = "auth";
160
+ socket.write(frames.auth, () => {
161
+ stage = "message";
162
+ socket.write(frames.message, () => {
163
+ stage = "sent";
164
+ // Let the session read both lines before the connection closes.
165
+ socket.end();
166
+ setTimeout(() => done({ kind: "delivered" }), 1_000).unref();
167
+ });
168
+ });
169
+ });
170
+ socket.on("close", () => {
171
+ if (stage === "sent")
172
+ resolve({ kind: "delivered" });
173
+ });
174
+ });
175
+ }
176
+ /** Puts `text` into the session as a user message. `delivered` means the
177
+ * bytes were handed over, not that Claude has read them yet. */
178
+ export async function deliver(home, session, messageId, text, ps = systemPs) {
179
+ if (session.peerProtocol !== KNOWN_PEER_PROTOCOL) {
180
+ return { kind: "retry", reason: `That session speaks inbox protocol ${session.peerProtocol}; Station knows ${KNOWN_PEER_PROTOCOL}.` };
181
+ }
182
+ // The session must still be the process that registered, or the token
183
+ // would go to whoever holds the pid now.
184
+ if (!liveSession(home, session.pid, ps))
185
+ return { kind: "retry", reason: "That Claude Code session is not running." };
186
+ const token = peerToken(home, session);
187
+ if (!token)
188
+ return { kind: "retry", reason: "Could not read that session's inbox key." };
189
+ let frames;
190
+ try {
191
+ frames = peerFrames(token, messageId, text);
192
+ }
193
+ catch (error) {
194
+ return { kind: "retry", reason: error instanceof Error ? error.message : String(error) };
195
+ }
196
+ return writeFrames(session.socketPath, frames);
197
+ }