@codegrammer/co-od 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.
@@ -0,0 +1,38 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { getSession, getBaseUrl } from "../api-client.js";
4
+ const CONFIG_DIR = join(homedir(), ".co-ode");
5
+ const SESSION_FILE = join(CONFIG_DIR, "session.json");
6
+ function parseArgs(args) {
7
+ return { json: args.includes("--json") };
8
+ }
9
+ function getStatusInfo() {
10
+ const session = getSession();
11
+ const server = getBaseUrl();
12
+ return {
13
+ loggedIn: session !== null,
14
+ user: session?.user || null,
15
+ sessionFile: SESSION_FILE,
16
+ sessionExpires: session
17
+ ? new Date(session.expiresAt).toLocaleString()
18
+ : null,
19
+ server,
20
+ };
21
+ }
22
+ export async function run(args) {
23
+ const parsed = parseArgs(args);
24
+ const info = getStatusInfo();
25
+ if (parsed.json) {
26
+ console.log(JSON.stringify(info, null, 2));
27
+ return;
28
+ }
29
+ console.log(`co-od status`);
30
+ console.log(`${"=".repeat(40)}`);
31
+ console.log(`Logged in: ${info.loggedIn ? "yes" : "no"}`);
32
+ if (info.loggedIn) {
33
+ console.log(`User: ${info.user || "(unknown)"}`);
34
+ console.log(`Session expires: ${info.sessionExpires}`);
35
+ }
36
+ console.log(`Session file: ${info.sessionFile}`);
37
+ console.log(`Server: ${info.server}`);
38
+ }
@@ -0,0 +1,10 @@
1
+ interface ConnectOptions {
2
+ terminalWsUrl: string;
3
+ realtimeToken: string;
4
+ roomId: string;
5
+ workDir: string;
6
+ serverUrl: string;
7
+ sessionToken: string;
8
+ }
9
+ export declare function connectToRoom(options: ConnectOptions): Promise<void>;
10
+ export {};
@@ -0,0 +1,145 @@
1
+ import WebSocket from "ws";
2
+ import { createLocalPty } from "./pty.js";
3
+ import { runPrompt, isRunning } from "./promptRunner.js";
4
+ export async function connectToRoom(options) {
5
+ const { terminalWsUrl, realtimeToken, roomId, workDir, serverUrl, sessionToken } = options;
6
+ // Build the PTY provider WebSocket URL
7
+ // terminalWsUrl is like ws://host:port — append the pty-provider path
8
+ const baseUrl = terminalWsUrl.replace(/\/$/, "");
9
+ const wsUrl = `${baseUrl}/pty-provider?roomId=${encodeURIComponent(roomId)}&token=${encodeURIComponent(realtimeToken)}`;
10
+ console.error(`[co-ode] Connecting to terminal gateway...`);
11
+ const ws = new WebSocket(wsUrl);
12
+ const pty = createLocalPty(workDir);
13
+ let reconnecting = false;
14
+ let tokenRefreshTimer = null;
15
+ // Wire PTY output → WebSocket
16
+ pty.onData((data) => {
17
+ if (ws.readyState === WebSocket.OPEN) {
18
+ ws.send(JSON.stringify({ type: "output", data }));
19
+ }
20
+ });
21
+ ws.on("open", () => {
22
+ console.error(`[co-ode] Connected! Terminal is now shared with the room.`);
23
+ console.error(`[co-ode] Room members can see your terminal and send Claude Code prompts.`);
24
+ console.error(`[co-ode] Press Ctrl+C to disconnect.\n`);
25
+ // Send initial connection info
26
+ ws.send(JSON.stringify({
27
+ type: "connected",
28
+ cwd: workDir,
29
+ data: `[co-ode] Remote CLI connected from ${workDir}\r\n\r\n`,
30
+ }));
31
+ // Schedule token refresh (45 minutes — tokens last 1 hour)
32
+ tokenRefreshTimer = setTimeout(() => refreshToken(options), 45 * 60 * 1000);
33
+ });
34
+ ws.on("message", (raw) => {
35
+ try {
36
+ const msg = JSON.parse(raw.toString());
37
+ switch (msg.type) {
38
+ case "input":
39
+ // Browser client typed something → forward to local PTY
40
+ if (msg.data) {
41
+ pty.write(msg.data);
42
+ }
43
+ break;
44
+ case "resize":
45
+ if (msg.cols && msg.rows) {
46
+ pty.resize(msg.cols, msg.rows);
47
+ }
48
+ break;
49
+ case "ping":
50
+ ws.send(JSON.stringify({ type: "pong" }));
51
+ break;
52
+ case "prompt.dispatch":
53
+ // Room member sent a Claude Code prompt
54
+ if (isRunning()) {
55
+ ws.send(JSON.stringify({ type: "prompt.busy", runId: msg.runId }));
56
+ break;
57
+ }
58
+ ws.send(JSON.stringify({ type: "prompt.ack", runId: msg.runId }));
59
+ void runPrompt({
60
+ runId: msg.runId,
61
+ goal: msg.goal,
62
+ token: msg.token,
63
+ completeUrl: msg.completeUrl,
64
+ heartbeatUrl: msg.heartbeatUrl,
65
+ heartbeatIntervalMs: msg.heartbeatIntervalMs || 10000,
66
+ }, {
67
+ workDir,
68
+ roomId,
69
+ serverUrl,
70
+ onOutput: (data) => {
71
+ if (ws.readyState === WebSocket.OPEN) {
72
+ ws.send(JSON.stringify({ type: "output", data }));
73
+ }
74
+ },
75
+ }).then(() => {
76
+ ws.send(JSON.stringify({
77
+ type: "prompt.complete",
78
+ runId: msg.runId,
79
+ status: "done",
80
+ }));
81
+ });
82
+ break;
83
+ }
84
+ }
85
+ catch {
86
+ // Ignore malformed messages
87
+ }
88
+ });
89
+ ws.on("close", () => {
90
+ if (!reconnecting) {
91
+ console.error(`\n[co-ode] Disconnected from room.`);
92
+ cleanup();
93
+ process.exit(0);
94
+ }
95
+ });
96
+ ws.on("error", (err) => {
97
+ console.error(`[co-ode] WebSocket error: ${err.message}`);
98
+ });
99
+ // Handle Ctrl+C gracefully
100
+ const cleanup = () => {
101
+ if (tokenRefreshTimer)
102
+ clearTimeout(tokenRefreshTimer);
103
+ pty.kill();
104
+ if (ws.readyState === WebSocket.OPEN) {
105
+ ws.close();
106
+ }
107
+ };
108
+ process.on("SIGINT", () => {
109
+ console.error(`\n[co-ode] Disconnecting...`);
110
+ cleanup();
111
+ process.exit(0);
112
+ });
113
+ process.on("SIGTERM", () => {
114
+ cleanup();
115
+ process.exit(0);
116
+ });
117
+ // Keep the process alive
118
+ await new Promise(() => {
119
+ // Never resolves — process stays alive until SIGINT/SIGTERM or ws close
120
+ });
121
+ }
122
+ async function refreshToken(options) {
123
+ try {
124
+ console.error(`[co-ode] Refreshing realtime token...`);
125
+ const res = await fetch(`${options.serverUrl}/api/rooms/${options.roomId}/realtime/session`, {
126
+ method: "POST",
127
+ headers: {
128
+ "content-type": "application/json",
129
+ authorization: `Bearer ${options.sessionToken}`,
130
+ },
131
+ body: JSON.stringify({ ttlSeconds: 3600 }),
132
+ });
133
+ if (res.ok) {
134
+ const data = (await res.json());
135
+ options.realtimeToken = data.token;
136
+ console.error(`[co-ode] Token refreshed`);
137
+ }
138
+ else {
139
+ console.error(`[co-ode] Warning: token refresh failed (${res.status})`);
140
+ }
141
+ }
142
+ catch (err) {
143
+ console.error(`[co-ode] Warning: token refresh error: ${err}`);
144
+ }
145
+ }
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ declare const VERSION = "0.1.0";
3
+ declare const COMMANDS: Record<string, {
4
+ desc: string;
5
+ usage: string;
6
+ }>;
7
+ declare function printHelp(): void;
8
+ declare function getArg(args: string[], flag: string): string | undefined;
9
+ declare function main(): Promise<void>;
package/dist/index.js ADDED
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ const VERSION = "0.1.0";
4
+ const COMMANDS = {
5
+ login: { desc: "Authenticate with co-ode", usage: "co-od login [--token <t>]" },
6
+ rooms: { desc: "List your rooms", usage: "co-od rooms [--json]" },
7
+ run: { desc: "Execute a single task in a room", usage: "co-od run <room> <goal> [--provider claude|codex] [--dir <path>] [--json]" },
8
+ join: { desc: "Join a room as an interactive agent", usage: "co-od join <room> [--invite-token <tok>] [--dir <path>]" },
9
+ daemon: { desc: "Autonomous watch mode for a room", usage: "co-od daemon <room> [--as <name>] [--auto-execute] [--watch <events>]" },
10
+ share: { desc: "Generate a relay code for teammates", usage: "co-od share [--provider claude|codex]" },
11
+ connect: { desc: "Connect to a relay via code", usage: "co-od connect <code>" },
12
+ status: { desc: "Show current login and running state", usage: "co-od status [--json]" },
13
+ };
14
+ function printHelp() {
15
+ console.error(`co-od v${VERSION} — CLI for co-ode rooms\n`);
16
+ console.error("Usage: co-od <command> [options]\n");
17
+ console.error("Commands:");
18
+ const maxLen = Math.max(...Object.keys(COMMANDS).map((k) => k.length));
19
+ for (const [name, { desc }] of Object.entries(COMMANDS)) {
20
+ console.error(` ${name.padEnd(maxLen + 2)}${desc}`);
21
+ }
22
+ console.error(`\nRun 'co-od <command> --help' for command-specific usage.`);
23
+ console.error(`\nOptions:`);
24
+ console.error(` --help, -h Show this help message`);
25
+ console.error(` --version, -v Show version`);
26
+ }
27
+ function getArg(args, flag) {
28
+ const idx = args.indexOf(flag);
29
+ if (idx === -1 || idx + 1 >= args.length)
30
+ return undefined;
31
+ return args[idx + 1];
32
+ }
33
+ async function main() {
34
+ const args = process.argv.slice(2);
35
+ // Global flags
36
+ if (args.includes("--version") || args.includes("-v")) {
37
+ console.log(VERSION);
38
+ process.exit(0);
39
+ }
40
+ if (args.includes("--help") || args.includes("-h") || args.length === 0) {
41
+ printHelp();
42
+ process.exit(0);
43
+ }
44
+ const command = args[0];
45
+ const commandArgs = args.slice(1);
46
+ // Show command-specific help
47
+ if (commandArgs.includes("--help") || commandArgs.includes("-h")) {
48
+ const cmd = COMMANDS[command];
49
+ if (cmd) {
50
+ console.error(`${cmd.desc}\n`);
51
+ console.error(`Usage: ${cmd.usage}`);
52
+ }
53
+ else {
54
+ console.error(`Unknown command: ${command}`);
55
+ printHelp();
56
+ }
57
+ process.exit(0);
58
+ }
59
+ // Route to command
60
+ switch (command) {
61
+ case "login": {
62
+ const { run } = await import("./commands/login.js");
63
+ await run(commandArgs);
64
+ break;
65
+ }
66
+ case "rooms": {
67
+ const { run } = await import("./commands/rooms.js");
68
+ await run(commandArgs);
69
+ break;
70
+ }
71
+ case "run": {
72
+ const { run } = await import("./commands/run.js");
73
+ await run(commandArgs);
74
+ break;
75
+ }
76
+ case "join": {
77
+ const { run } = await import("./commands/join.js");
78
+ await run(commandArgs);
79
+ break;
80
+ }
81
+ case "daemon": {
82
+ const { run } = await import("./commands/daemon.js");
83
+ await run(commandArgs);
84
+ break;
85
+ }
86
+ case "share": {
87
+ const { run } = await import("./commands/share.js");
88
+ await run(commandArgs);
89
+ break;
90
+ }
91
+ case "connect": {
92
+ const { run } = await import("./commands/connect.js");
93
+ await run(commandArgs);
94
+ break;
95
+ }
96
+ case "status": {
97
+ const { run } = await import("./commands/status.js");
98
+ await run(commandArgs);
99
+ break;
100
+ }
101
+ default:
102
+ console.error(`Unknown command: ${command}\n`);
103
+ printHelp();
104
+ process.exit(1);
105
+ }
106
+ }
107
+ main().catch((err) => {
108
+ console.error(`[co-od] Fatal: ${err.message || err}`);
109
+ process.exit(1);
110
+ });
@@ -0,0 +1,17 @@
1
+ interface PromptDispatch {
2
+ runId: string;
3
+ goal: string;
4
+ token: string;
5
+ completeUrl: string;
6
+ heartbeatUrl: string;
7
+ heartbeatIntervalMs: number;
8
+ }
9
+ interface PromptRunnerOptions {
10
+ workDir: string;
11
+ onOutput: (data: string) => void;
12
+ roomId?: string;
13
+ serverUrl?: string;
14
+ }
15
+ export declare function isRunning(): boolean;
16
+ export declare function runPrompt(dispatch: PromptDispatch, options: PromptRunnerOptions): Promise<void>;
17
+ export {};
@@ -0,0 +1,172 @@
1
+ import { spawn } from "node:child_process";
2
+ import { writeFileSync, mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ let currentRun = null;
6
+ export function isRunning() {
7
+ return currentRun !== null;
8
+ }
9
+ export async function runPrompt(dispatch, options) {
10
+ if (currentRun) {
11
+ throw new Error("A prompt is already running");
12
+ }
13
+ const { runId, goal, token, completeUrl, heartbeatUrl, heartbeatIntervalMs } = dispatch;
14
+ const { workDir, onOutput } = options;
15
+ onOutput(`\r\n[co-ode] Received prompt: ${goal}\r\n`);
16
+ onOutput(`[co-ode] Running Claude Code...\r\n\r\n`);
17
+ // Start heartbeat loop
18
+ const heartbeatTimer = setInterval(async () => {
19
+ try {
20
+ const res = await fetch(heartbeatUrl, {
21
+ method: "POST",
22
+ headers: {
23
+ "content-type": "application/json",
24
+ authorization: `Bearer ${token}`,
25
+ "x-local-bridge-token": token,
26
+ },
27
+ body: JSON.stringify({ runId }),
28
+ });
29
+ if (res.ok) {
30
+ const body = (await res.json());
31
+ if (body.continue === false) {
32
+ onOutput(`\r\n[co-ode] Server requested cancellation\r\n`);
33
+ cancelCurrentRun();
34
+ }
35
+ }
36
+ }
37
+ catch {
38
+ // Heartbeat failure is non-fatal
39
+ }
40
+ }, heartbeatIntervalMs || 10000);
41
+ // Write MCP config so Claude Code discovers co-od tools natively
42
+ const apiOrigin = completeUrl ? new URL(completeUrl).origin : (options.serverUrl || "");
43
+ const mcpConfigDir = join(tmpdir(), `co-ode-mcp-${runId}`);
44
+ const mcpConfigPath = join(mcpConfigDir, "mcp.json");
45
+ try {
46
+ mkdirSync(mcpConfigDir, { recursive: true });
47
+ writeFileSync(mcpConfigPath, JSON.stringify({
48
+ mcpServers: {
49
+ "co-ode": {
50
+ command: "npx",
51
+ args: ["@co-ode/mcp-server"],
52
+ env: {
53
+ CO_ODE_API_URL: apiOrigin,
54
+ CO_ODE_ROOM_ID: options.roomId || "",
55
+ CO_ODE_TOKEN: token,
56
+ },
57
+ },
58
+ },
59
+ }, null, 2));
60
+ }
61
+ catch {
62
+ // Non-fatal — Claude Code will work without MCP tools
63
+ }
64
+ // Spawn claude -p with MCP config
65
+ const child = spawn("claude", ["-p", goal, "--add-dir", workDir, "--mcp-config", mcpConfigPath], {
66
+ cwd: workDir,
67
+ env: {
68
+ ...process.env,
69
+ CO_ODE_API_URL: apiOrigin,
70
+ CO_ODE_ROOM_ID: options.roomId || "",
71
+ CO_ODE_TOKEN: token,
72
+ CO_ODE_RUN_ID: runId,
73
+ },
74
+ stdio: ["pipe", "pipe", "pipe"],
75
+ });
76
+ currentRun = { runId, process: child, heartbeatTimer };
77
+ let stdout = "";
78
+ let stderr = "";
79
+ child.stdout?.setEncoding("utf-8");
80
+ child.stderr?.setEncoding("utf-8");
81
+ child.stdout?.on("data", (chunk) => {
82
+ stdout += chunk;
83
+ onOutput(chunk);
84
+ });
85
+ child.stderr?.on("data", (chunk) => {
86
+ stderr += chunk;
87
+ onOutput(chunk);
88
+ });
89
+ return new Promise((resolve) => {
90
+ child.on("exit", async (code) => {
91
+ clearInterval(heartbeatTimer);
92
+ const succeeded = code === 0;
93
+ onOutput(`\r\n[co-ode] Claude Code ${succeeded ? "completed" : `exited with code ${code}`}\r\n`);
94
+ // Post completion
95
+ try {
96
+ await fetch(completeUrl, {
97
+ method: "POST",
98
+ headers: {
99
+ "content-type": "application/json",
100
+ authorization: `Bearer ${token}`,
101
+ "x-local-bridge-token": token,
102
+ },
103
+ body: JSON.stringify({
104
+ ok: succeeded,
105
+ run: {
106
+ status: succeeded ? "succeeded" : "failed",
107
+ steps: [
108
+ {
109
+ kind: succeeded ? "observation" : "error",
110
+ input: { description: goal },
111
+ output: {
112
+ success: succeeded,
113
+ payload: {
114
+ stdout: stdout.slice(-4096),
115
+ stderr: stderr.slice(-2048),
116
+ exitCode: code,
117
+ },
118
+ },
119
+ created_at: Date.now(),
120
+ },
121
+ ],
122
+ },
123
+ }),
124
+ });
125
+ }
126
+ catch (err) {
127
+ onOutput(`[co-ode] Warning: failed to post completion: ${err}\r\n`);
128
+ }
129
+ currentRun = null;
130
+ resolve();
131
+ });
132
+ child.on("error", async (err) => {
133
+ clearInterval(heartbeatTimer);
134
+ onOutput(`\r\n[co-ode] Failed to spawn Claude Code: ${err.message}\r\n`);
135
+ onOutput(`[co-ode] Make sure 'claude' CLI is installed and in PATH\r\n`);
136
+ try {
137
+ await fetch(completeUrl, {
138
+ method: "POST",
139
+ headers: {
140
+ "content-type": "application/json",
141
+ authorization: `Bearer ${token}`,
142
+ "x-local-bridge-token": token,
143
+ },
144
+ body: JSON.stringify({
145
+ ok: false,
146
+ run: {
147
+ status: "failed",
148
+ steps: [
149
+ {
150
+ kind: "error",
151
+ input: { description: "Failed to spawn Claude Code" },
152
+ output: { success: false, payload: { message: err.message } },
153
+ created_at: Date.now(),
154
+ },
155
+ ],
156
+ },
157
+ }),
158
+ });
159
+ }
160
+ catch {
161
+ // Non-fatal
162
+ }
163
+ currentRun = null;
164
+ resolve();
165
+ });
166
+ });
167
+ }
168
+ function cancelCurrentRun() {
169
+ if (currentRun) {
170
+ currentRun.process.kill("SIGTERM");
171
+ }
172
+ }
package/dist/pty.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ export interface PtyHandle {
2
+ onData: (cb: (data: string) => void) => void;
3
+ write: (data: string) => void;
4
+ resize: (cols: number, rows: number) => void;
5
+ kill: () => void;
6
+ }
7
+ /**
8
+ * Creates a local PTY-like shell process.
9
+ * Uses child_process.spawn with a pseudo-TTY via stdio pipes.
10
+ * For a production version, node-pty would give true PTY support,
11
+ * but this works without native compilation.
12
+ */
13
+ export declare function createLocalPty(cwd: string): PtyHandle;
package/dist/pty.js ADDED
@@ -0,0 +1,52 @@
1
+ import { spawn } from "node:child_process";
2
+ /**
3
+ * Creates a local PTY-like shell process.
4
+ * Uses child_process.spawn with a pseudo-TTY via stdio pipes.
5
+ * For a production version, node-pty would give true PTY support,
6
+ * but this works without native compilation.
7
+ */
8
+ export function createLocalPty(cwd) {
9
+ const shell = process.env.SHELL || "/bin/bash";
10
+ const cols = process.stdout.columns || 80;
11
+ const rows = process.stdout.rows || 24;
12
+ const child = spawn(shell, ["-i"], {
13
+ cwd,
14
+ env: {
15
+ ...process.env,
16
+ TERM: "xterm-256color",
17
+ COLUMNS: String(cols),
18
+ LINES: String(rows),
19
+ },
20
+ stdio: ["pipe", "pipe", "pipe"],
21
+ });
22
+ const dataCallbacks = [];
23
+ child.stdout?.setEncoding("utf-8");
24
+ child.stderr?.setEncoding("utf-8");
25
+ child.stdout?.on("data", (chunk) => {
26
+ for (const cb of dataCallbacks)
27
+ cb(chunk);
28
+ });
29
+ child.stderr?.on("data", (chunk) => {
30
+ for (const cb of dataCallbacks)
31
+ cb(chunk);
32
+ });
33
+ child.on("exit", () => {
34
+ for (const cb of dataCallbacks)
35
+ cb("\r\n[co-ode] Shell process exited\r\n");
36
+ });
37
+ return {
38
+ onData(cb) {
39
+ dataCallbacks.push(cb);
40
+ },
41
+ write(data) {
42
+ child.stdin?.write(data);
43
+ },
44
+ resize(_cols, _rows) {
45
+ // With child_process we can't truly resize without node-pty
46
+ // But we update env for new subprocesses
47
+ },
48
+ kill() {
49
+ child.kill("SIGTERM");
50
+ },
51
+ };
52
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@codegrammer/co-od",
3
+ "version": "0.1.0",
4
+ "description": "CLI for co-ode — run AI agents in shared rooms from the command line",
5
+ "type": "module",
6
+ "bin": {
7
+ "co-ode": "./dist/index.js",
8
+ "co-od": "./dist/index.js"
9
+ },
10
+ "main": "dist/index.js",
11
+ "files": [
12
+ "dist",
13
+ "SKILL.md"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc -p tsconfig.json",
17
+ "dev": "tsc -w -p tsconfig.json",
18
+ "prepublishOnly": "npm run build",
19
+ "lint": "echo noop"
20
+ },
21
+ "keywords": [
22
+ "ai",
23
+ "agents",
24
+ "cli",
25
+ "claude",
26
+ "codex",
27
+ "multiplayer",
28
+ "coding",
29
+ "collaboration",
30
+ "rooms"
31
+ ],
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/hacksurvivor/co-ode.git",
35
+ "directory": "packages/cli"
36
+ },
37
+ "homepage": "https://co-od.dev",
38
+ "license": "MIT",
39
+ "author": "co-ode contributors",
40
+ "engines": {
41
+ "node": ">=18"
42
+ },
43
+ "dependencies": {
44
+ "ws": "^8.18.0",
45
+ "open": "^10.1.0"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^22.10.2",
49
+ "@types/ws": "^8.5.13",
50
+ "typescript": "^5.6.2"
51
+ }
52
+ }