@codegrammer/co-od 0.1.5 → 0.1.7

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.
@@ -1,9 +1,73 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { createInterface } from "node:readline";
2
3
  import { existsSync } from "node:fs";
3
4
  function which(cmd) {
4
5
  const paths = (process.env.PATH || "").split(":");
5
6
  return paths.some((dir) => existsSync(`${dir}/${cmd}`));
6
7
  }
8
+ /**
9
+ * Parse a Codex JSONL event and report it as an agent step to co-ode.
10
+ * Codex events: thread.started, turn.started, turn.completed,
11
+ * item.started, item.completed (with item types: command_execution,
12
+ * file_change, mcp_tool_call, agent_message, reasoning, plan_update)
13
+ */
14
+ async function reportCodexEvent(event, config) {
15
+ const type = event.type;
16
+ const item = event.item;
17
+ // Only report completed items as steps
18
+ if (type !== "item.completed" || !item)
19
+ return;
20
+ const itemType = item.type;
21
+ let stepKind;
22
+ let description;
23
+ let payload = {};
24
+ switch (itemType) {
25
+ case "command_execution":
26
+ stepKind = "tool_call";
27
+ description = item.command || "exec";
28
+ payload = { command: item.command, exitCode: item.exit_code };
29
+ break;
30
+ case "file_change":
31
+ stepKind = "tool_call";
32
+ description = `write ${item.path || "file"}`;
33
+ payload = { path: item.path };
34
+ break;
35
+ case "mcp_tool_call":
36
+ stepKind = "tool_call";
37
+ description = item.tool_name || "mcp_tool";
38
+ payload = { tool: item.tool_name };
39
+ break;
40
+ case "agent_message":
41
+ stepKind = "observation";
42
+ description = (item.text || "").slice(0, 200);
43
+ payload = { text: (item.text || "").slice(0, 4000) };
44
+ break;
45
+ case "plan_update":
46
+ stepKind = "plan";
47
+ description = "plan updated";
48
+ payload = { plan: item.plan };
49
+ break;
50
+ default:
51
+ return; // Skip reasoning, web_search, etc.
52
+ }
53
+ try {
54
+ await fetch(`${config.serverUrl}/api/rooms/${config.roomId}/agent-runs/${config.runId}/steps`, {
55
+ method: "POST",
56
+ headers: {
57
+ "content-type": "application/json",
58
+ authorization: `Bearer ${config.sessionToken}`,
59
+ },
60
+ body: JSON.stringify({
61
+ kind: stepKind,
62
+ input: { description, payload },
63
+ output: { success: true, payload },
64
+ }),
65
+ });
66
+ }
67
+ catch {
68
+ // Non-fatal — don't break Codex execution
69
+ }
70
+ }
7
71
  export class CodexAdapter {
8
72
  name = "codex";
9
73
  async available() {
@@ -11,25 +75,82 @@ export class CodexAdapter {
11
75
  }
12
76
  async execute(goal, options) {
13
77
  const { workDir, onOutput, signal } = options;
78
+ const hasRoomContext = Boolean(options.roomId && options.runId && options.serverUrl && options.sessionToken);
79
+ // Use --json for JSONL event streaming when we have room context
80
+ const args = ["exec", "--full-auto"];
81
+ if (hasRoomContext)
82
+ args.push("--json");
83
+ args.push("--", goal);
14
84
  return new Promise((resolve, reject) => {
15
- const child = spawn("codex", ["exec", "--full-auto", "--", goal], {
85
+ const child = spawn("codex", args, {
16
86
  cwd: workDir,
17
- env: { ...process.env },
87
+ env: {
88
+ ...process.env,
89
+ CO_OD_ROOM_ID: options.roomId || "",
90
+ CO_OD_RUN_ID: options.runId || "",
91
+ },
18
92
  stdio: ["pipe", "pipe", "pipe"],
19
93
  });
20
94
  let stdout = "";
21
95
  let stderr = "";
96
+ let lastAgentMessage = "";
22
97
  child.stdout?.setEncoding("utf-8");
23
98
  child.stderr?.setEncoding("utf-8");
24
- child.stdout?.on("data", (chunk) => {
25
- stdout += chunk;
26
- onOutput?.(chunk);
27
- });
99
+ if (hasRoomContext) {
100
+ // Parse JSONL events from stdout for real-time reporting
101
+ const rl = createInterface({ input: child.stdout, crlfDelay: Infinity });
102
+ rl.on("line", (line) => {
103
+ stdout += line + "\n";
104
+ try {
105
+ const event = JSON.parse(line);
106
+ // Extract final agent message for display
107
+ if (event.type === "item.completed" &&
108
+ event.item?.type === "agent_message") {
109
+ lastAgentMessage =
110
+ event.item.text || "";
111
+ }
112
+ // Report progress to co-ode
113
+ const config = {
114
+ roomId: options.roomId,
115
+ runId: options.runId,
116
+ serverUrl: options.serverUrl,
117
+ sessionToken: options.sessionToken,
118
+ };
119
+ void reportCodexEvent(event, config);
120
+ // Feed human-readable output to onOutput
121
+ const itemType = event.item?.type;
122
+ if (event.type === "item.completed" && itemType === "command_execution") {
123
+ onOutput?.(`$ ${event.item.command}\n`);
124
+ }
125
+ else if (event.type === "item.completed" && itemType === "file_change") {
126
+ onOutput?.(`[write] ${event.item.path}\n`);
127
+ }
128
+ else if (event.type === "item.completed" && itemType === "agent_message") {
129
+ onOutput?.(`${(event.item.text || "").slice(0, 500)}\n`);
130
+ }
131
+ }
132
+ catch {
133
+ // Not valid JSON — pass through as raw output
134
+ onOutput?.(line + "\n");
135
+ }
136
+ });
137
+ }
138
+ else {
139
+ // No room context — raw output mode (original behavior)
140
+ child.stdout?.on("data", (chunk) => {
141
+ stdout += chunk;
142
+ onOutput?.(chunk);
143
+ });
144
+ }
28
145
  child.stderr?.on("data", (chunk) => {
29
146
  stderr += chunk;
30
147
  onOutput?.(chunk);
31
148
  });
32
149
  child.on("exit", (code) => {
150
+ // If we parsed JSONL, include the last agent message in stdout for the caller
151
+ if (hasRoomContext && lastAgentMessage) {
152
+ stdout += `\n--- Agent Response ---\n${lastAgentMessage}\n`;
153
+ }
33
154
  resolve({ exitCode: code ?? 1, stdout, stderr });
34
155
  });
35
156
  child.on("error", (err) => {
@@ -1,3 +1,3 @@
1
1
  import type { AgentAdapter } from "./base.js";
2
2
  export declare function getAdapter(provider: string): AgentAdapter;
3
- export declare const PROVIDER_LIST = "claude|codex|openclaw";
3
+ export declare const PROVIDER_LIST = "claude|codex|openclaw|zeroclaw";
@@ -14,4 +14,4 @@ export function getAdapter(provider) {
14
14
  // Default to claude
15
15
  return new ClaudeAdapter();
16
16
  }
17
- export const PROVIDER_LIST = "claude|codex|openclaw";
17
+ export const PROVIDER_LIST = "claude|codex|openclaw|zeroclaw";
@@ -4,7 +4,7 @@ import { spawn } from "node:child_process";
4
4
  *
5
5
  * Supports both the legacy `openclaw` CLI and the newer `zeroclaw` CLI.
6
6
  * Sends tasks via `zeroclaw agent -m <goal>` (non-interactive single-shot mode).
7
- * Falls back to the HTTP gateway if running as a daemon.
7
+ * Reports tool calls and file changes as agent steps when room context is available.
8
8
  */
9
9
  const ZEROCLAW_BIN = process.env.ZEROCLAW_BIN || "zeroclaw";
10
10
  const OPENCLAW_BIN = process.env.OPENCLAW_BIN || "openclaw";
@@ -28,6 +28,62 @@ async function findBinary() {
28
28
  }
29
29
  return null;
30
30
  }
31
+ /**
32
+ * Report a step to the co-ode room.
33
+ */
34
+ async function reportStep(config, kind, description, payload = {}) {
35
+ try {
36
+ await fetch(`${config.serverUrl}/api/rooms/${config.roomId}/agent-runs/${config.runId}/steps`, {
37
+ method: "POST",
38
+ headers: {
39
+ "content-type": "application/json",
40
+ authorization: `Bearer ${config.sessionToken}`,
41
+ },
42
+ body: JSON.stringify({
43
+ kind,
44
+ input: { description, payload },
45
+ output: { success: true, payload },
46
+ }),
47
+ });
48
+ }
49
+ catch {
50
+ // Non-fatal
51
+ }
52
+ }
53
+ /**
54
+ * Parse openclaw/zeroclaw output lines for tool calls and file changes.
55
+ * ZeroClaw outputs structured markers like:
56
+ * [TOOL] tool_name: description
57
+ * [FILE] path/to/file
58
+ * [EXEC] command
59
+ * [DONE] summary
60
+ */
61
+ function parseOutputLine(line) {
62
+ const trimmed = line.trim();
63
+ if (trimmed.startsWith("[TOOL]")) {
64
+ return { kind: "tool_call", description: trimmed.slice(6).trim(), payload: {} };
65
+ }
66
+ if (trimmed.startsWith("[FILE]")) {
67
+ const path = trimmed.slice(6).trim();
68
+ return { kind: "tool_call", description: `write ${path}`, payload: { path } };
69
+ }
70
+ if (trimmed.startsWith("[EXEC]")) {
71
+ const cmd = trimmed.slice(6).trim();
72
+ return { kind: "tool_call", description: cmd, payload: { command: cmd } };
73
+ }
74
+ if (trimmed.startsWith("[DONE]")) {
75
+ return { kind: "observation", description: trimmed.slice(6).trim(), payload: {} };
76
+ }
77
+ // Also detect common patterns from unstructured output
78
+ if (trimmed.match(/^(reading|writing|creating|editing|modifying)\s/i)) {
79
+ return { kind: "tool_call", description: trimmed.slice(0, 100), payload: {} };
80
+ }
81
+ if (trimmed.match(/^\$\s+/)) {
82
+ const cmd = trimmed.slice(2).trim();
83
+ return { kind: "tool_call", description: cmd, payload: { command: cmd } };
84
+ }
85
+ return null;
86
+ }
31
87
  export class OpenClawAdapter {
32
88
  name = "openclaw";
33
89
  async available() {
@@ -44,26 +100,52 @@ export class OpenClawAdapter {
44
100
  };
45
101
  }
46
102
  const { bin } = found;
103
+ const hasRoomContext = Boolean(options.roomId && options.runId && options.serverUrl && options.sessionToken);
104
+ const reportConfig = hasRoomContext
105
+ ? {
106
+ roomId: options.roomId,
107
+ runId: options.runId,
108
+ serverUrl: options.serverUrl,
109
+ sessionToken: options.sessionToken,
110
+ }
111
+ : null;
47
112
  // zeroclaw agent -m "goal" — single-shot non-interactive mode
48
113
  const args = ["agent", "-m", goal];
49
114
  return new Promise((resolve) => {
50
115
  const child = spawn(bin, args, {
51
116
  cwd: options.workDir,
52
- env: { ...process.env },
117
+ env: {
118
+ ...process.env,
119
+ CO_OD_ROOM_ID: options.roomId || "",
120
+ CO_OD_RUN_ID: options.runId || "",
121
+ },
53
122
  stdio: ["pipe", "pipe", "pipe"],
54
123
  });
55
124
  if (options.signal) {
56
125
  options.signal.addEventListener("abort", () => {
57
126
  child.kill("SIGTERM");
58
- });
127
+ }, { once: true });
59
128
  }
60
129
  let stdout = "";
61
130
  let stderr = "";
131
+ let lineBuffer = "";
62
132
  child.stdout?.setEncoding("utf-8");
63
133
  child.stderr?.setEncoding("utf-8");
64
134
  child.stdout?.on("data", (chunk) => {
65
135
  stdout += chunk;
66
136
  options.onOutput?.(chunk);
137
+ // Parse lines for step reporting
138
+ if (reportConfig) {
139
+ lineBuffer += chunk;
140
+ const lines = lineBuffer.split("\n");
141
+ lineBuffer = lines.pop() || "";
142
+ for (const line of lines) {
143
+ const parsed = parseOutputLine(line);
144
+ if (parsed) {
145
+ void reportStep(reportConfig, parsed.kind, parsed.description, parsed.payload);
146
+ }
147
+ }
148
+ }
67
149
  });
68
150
  child.stderr?.on("data", (chunk) => {
69
151
  stderr += chunk;
@@ -77,6 +159,13 @@ export class OpenClawAdapter {
77
159
  });
78
160
  });
79
161
  child.on("close", (code) => {
162
+ // Flush remaining line buffer
163
+ if (reportConfig && lineBuffer.trim()) {
164
+ const parsed = parseOutputLine(lineBuffer);
165
+ if (parsed) {
166
+ void reportStep(reportConfig, parsed.kind, parsed.description, parsed.payload);
167
+ }
168
+ }
80
169
  resolve({
81
170
  exitCode: code ?? 1,
82
171
  stdout,
@@ -4,7 +4,7 @@ import { join } from "node:path";
4
4
  const CONFIG_DIR = join(homedir(), ".co-ode");
5
5
  const SESSION_FILE = join(CONFIG_DIR, "session.json");
6
6
  function getBaseUrl() {
7
- return (process.env.CO_ODE_SERVER || "https://co-ode.vercel.app");
7
+ return (process.env.CO_ODE_SERVER || "https://co-od.dev");
8
8
  }
9
9
  function getSessionToken() {
10
10
  try {
@@ -26,7 +26,7 @@ export async function run(args) {
26
26
  }
27
27
  const serverUrl = parsed.server ||
28
28
  process.env.CO_ODE_SERVER ||
29
- "https://co-ode.vercel.app";
29
+ "https://co-od.dev";
30
30
  console.error(`[co-od] Joining room ${parsed.roomId}...`);
31
31
  console.error(`[co-od] Server: ${serverUrl}`);
32
32
  console.error(`[co-od] Working directory: ${parsed.dir}`);
@@ -76,7 +76,7 @@ export async function run(args) {
76
76
  const parsed = parseArgs(args);
77
77
  const serverUrl = parsed.server ||
78
78
  process.env.CO_ODE_SERVER ||
79
- "https://co-ode.vercel.app";
79
+ "https://co-od.dev";
80
80
  // --token flag: headless/CI mode
81
81
  if (parsed.token) {
82
82
  saveSession(parsed.token);
@@ -8,4 +8,4 @@
8
8
  * 5. Open browser to room
9
9
  * 6. Done — user types a task in the browser
10
10
  */
11
- export declare function run(_args: string[]): Promise<void>;
11
+ export declare function run(args: string[]): Promise<void>;
@@ -129,9 +129,37 @@ async function openBrowser(url) {
129
129
  log(`Open in browser: ${url}`);
130
130
  }
131
131
  }
132
- export async function run(_args) {
132
+ async function isBridgeRunning() {
133
+ try {
134
+ const res = await fetch("http://127.0.0.1:4786/health", { signal: AbortSignal.timeout(1500) });
135
+ return res.ok;
136
+ }
137
+ catch {
138
+ return false;
139
+ }
140
+ }
141
+ export async function run(args) {
133
142
  const dir = process.cwd();
134
143
  const projectName = basename(dir);
144
+ const existing = findExistingConfig(dir);
145
+ const bridgeUp = await isBridgeRunning();
146
+ const hasToken = !!api.getSessionToken();
147
+ // ── FAST PATH: everything's already set up ──
148
+ // If config exists + bridge running + logged in → just show status, don't open browser
149
+ if (existing && bridgeUp && hasToken && !args.includes("--open")) {
150
+ const url = `${api.getBaseUrl()}/rooms/${existing.roomId}`;
151
+ console.error(` co-od · ${existing.roomName}\n`);
152
+ console.error(` room: ${url}`);
153
+ console.error(` bridge: running (port 4786)`);
154
+ console.error(` project: ${dir}\n`);
155
+ console.error(` Everything is running. Commands:\n`);
156
+ console.error(` co-od run ${existing.roomId.slice(0, 12)} "your task" # single task`);
157
+ console.error(` co-od daemon ${existing.roomId.slice(0, 12)} --auto-execute # autonomous`);
158
+ console.error(` co-od share # invite teammate`);
159
+ console.error(` co-od --open # open browser\n`);
160
+ return;
161
+ }
162
+ // ── SETUP PATH: first time or something needs fixing ──
135
163
  console.error(`
136
164
  ┌─────────────────────────────┐
137
165
  │ co-od │
@@ -140,13 +168,18 @@ export async function run(_args) {
140
168
  └─────────────────────────────┘
141
169
  `);
142
170
  // Step 1: Login
143
- log("[1/5] Checking authentication...");
144
- const loggedIn = await ensureLoggedIn();
145
- if (!loggedIn) {
146
- log("✗ Login required. Run: co-od login");
147
- process.exit(1);
171
+ if (!hasToken) {
172
+ log("[1/5] Checking authentication...");
173
+ const loggedIn = await ensureLoggedIn();
174
+ if (!loggedIn) {
175
+ log("✗ Login required. Run: co-od login");
176
+ process.exit(1);
177
+ }
178
+ log("✓ logged in\n");
179
+ }
180
+ else {
181
+ log("[1/5] ✓ logged in");
148
182
  }
149
- log("✓ logged in\n");
150
183
  // Step 2: Detect environment
151
184
  log("[2/5] Detecting environment...");
152
185
  const provider = detectProvider();
@@ -162,7 +195,6 @@ export async function run(_args) {
162
195
  log(`✓ project: ${dir}\n`);
163
196
  // Step 3: Find or create room
164
197
  log("[3/5] Setting up room...");
165
- const existing = findExistingConfig(dir);
166
198
  let roomId;
167
199
  let roomName;
168
200
  if (existing) {
@@ -171,7 +203,6 @@ export async function run(_args) {
171
203
  log(`✓ found existing room: ${roomName}`);
172
204
  }
173
205
  else {
174
- // Create new room
175
206
  roomName = projectName;
176
207
  try {
177
208
  const res = await api.post("/api/rooms", { name: roomName });
@@ -222,9 +253,14 @@ export async function run(_args) {
222
253
  }
223
254
  }
224
255
  console.error("");
225
- // Step 4: Start bridge
226
- log("[4/5] Starting local bridge...");
227
- startBridgeBackground();
256
+ // Step 4: Start bridge (only if not already running)
257
+ if (bridgeUp) {
258
+ log("[4/5] ✓ local bridge already running");
259
+ }
260
+ else {
261
+ log("[4/5] Starting local bridge...");
262
+ startBridgeBackground();
263
+ }
228
264
  console.error("");
229
265
  // Step 5: Open browser
230
266
  log("[5/5] Opening workspace...\n");
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- declare const VERSION = "0.1.0";
2
+ declare const VERSION = "0.1.6";
3
3
  declare const COMMANDS: Record<string, {
4
4
  desc: string;
5
5
  usage: string;
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
- const VERSION = "0.1.0";
3
+ const VERSION = "0.1.6";
4
4
  const COMMANDS = {
5
5
  init: { desc: "Set up a new co-ode project", usage: "co-od init [name] [--dir <path>] [--provider claude|codex|openclaw]" },
6
6
  login: { desc: "Authenticate with co-ode", usage: "co-od login [--token <t>]" },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@codegrammer/co-od",
3
- "version": "0.1.5",
4
- "description": "CLI for co-ode \u2014 run AI agents in shared rooms from the command line",
3
+ "version": "0.1.7",
4
+ "description": "CLI for co-ode run AI agents in shared rooms from the command line",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "co-ode": "./dist/index.js",
@@ -49,4 +49,4 @@
49
49
  "@types/ws": "^8.5.13",
50
50
  "typescript": "^5.6.2"
51
51
  }
52
- }
52
+ }