@codegrammer/co-od 0.1.1 → 0.1.3

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.
@@ -7,6 +7,11 @@ export interface ExecuteOptions {
7
7
  workDir: string;
8
8
  onOutput?: (data: string) => void;
9
9
  signal?: AbortSignal;
10
+ /** Room context — enables hooks that report activity to the room */
11
+ roomId?: string;
12
+ runId?: string;
13
+ serverUrl?: string;
14
+ sessionToken?: string;
10
15
  }
11
16
  export interface ExecuteResult {
12
17
  exitCode: number;
@@ -1,5 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
+ import { generateHooksDir, cleanupHooksDir } from "../hooks-generator.js";
3
4
  function which(cmd) {
4
5
  const paths = (process.env.PATH || "").split(":");
5
6
  return paths.some((dir) => existsSync(`${dir}/${cmd}`));
@@ -11,10 +12,34 @@ export class ClaudeAdapter {
11
12
  }
12
13
  async execute(goal, options) {
13
14
  const { workDir, onOutput, signal } = options;
15
+ // Generate hooks config if room context is available
16
+ let hooksDir = null;
17
+ const args = ["-p", "--dangerously-skip-permissions"];
18
+ if (options.roomId && options.runId && options.serverUrl && options.sessionToken) {
19
+ try {
20
+ hooksDir = generateHooksDir({
21
+ roomId: options.roomId,
22
+ runId: options.runId,
23
+ serverUrl: options.serverUrl,
24
+ sessionToken: options.sessionToken,
25
+ });
26
+ // Add the hooks dir so Claude Code picks up the settings.local.json
27
+ args.push("--add-dir", hooksDir);
28
+ onOutput?.(`[co-od] Live activity reporting enabled for this run\n`);
29
+ }
30
+ catch {
31
+ // Non-fatal — run without hooks
32
+ }
33
+ }
34
+ args.push("--", goal);
14
35
  return new Promise((resolve, reject) => {
15
- const child = spawn("claude", ["-p", "--dangerously-skip-permissions", "--", goal], {
36
+ const child = spawn("claude", args, {
16
37
  cwd: workDir,
17
- env: { ...process.env },
38
+ env: {
39
+ ...process.env,
40
+ CO_OD_ROOM_ID: options.roomId || "",
41
+ CO_OD_RUN_ID: options.runId || "",
42
+ },
18
43
  stdio: ["pipe", "pipe", "pipe"],
19
44
  });
20
45
  let stdout = "";
@@ -30,9 +55,13 @@ export class ClaudeAdapter {
30
55
  onOutput?.(chunk);
31
56
  });
32
57
  child.on("exit", (code) => {
58
+ if (hooksDir)
59
+ cleanupHooksDir(hooksDir);
33
60
  resolve({ exitCode: code ?? 1, stdout, stderr });
34
61
  });
35
62
  child.on("error", (err) => {
63
+ if (hooksDir)
64
+ cleanupHooksDir(hooksDir);
36
65
  reject(err);
37
66
  });
38
67
  if (signal) {
@@ -36,6 +36,9 @@ function parseArgs(args) {
36
36
  else if (args[i] === "--server" && args[i + 1]) {
37
37
  parsed.server = args[++i];
38
38
  }
39
+ else if (args[i] === "--handoff-to" && args[i + 1]) {
40
+ parsed.handoffTo = args[++i];
41
+ }
39
42
  else if (!args[i].startsWith("--")) {
40
43
  parsed.roomId = args[i];
41
44
  }
@@ -128,41 +131,66 @@ export async function run(args) {
128
131
  activeRuns++;
129
132
  console.error(`[${timestamp()}] Dispatching: ${goal.slice(0, 80)}`);
130
133
  // Create a run on the server
134
+ const sessionToken = api.getSessionToken();
135
+ const baseUrl = api.getBaseUrl();
131
136
  api
132
137
  .post(`/api/rooms/${parsed.roomId}/agent-runs`, { goal, provider: parsed.provider, agentId, eventId: event._id })
133
138
  .then(async ({ runId }) => {
134
139
  const result = await adapter.execute(goal, {
135
140
  workDir: parsed.dir,
136
141
  onOutput: (data) => process.stderr.write(data),
142
+ // Room context enables Claude Code hooks for live reporting
143
+ roomId: parsed.roomId,
144
+ runId,
145
+ serverUrl: baseUrl,
146
+ sessionToken: sessionToken || undefined,
137
147
  });
138
- console.error(`\n[${timestamp()}] Run ${runId} ${result.exitCode === 0 ? "succeeded" : "failed"}`);
148
+ const succeeded = result.exitCode === 0;
149
+ console.error(`\n[${timestamp()}] Run ${runId} ${succeeded ? "succeeded" : "failed"}`);
139
150
  // Report completion
140
151
  try {
141
- await api.post(`/api/rooms/${parsed.roomId}/agent-runs/${runId}/handoff`, {
142
- ok: result.exitCode === 0,
143
- run: {
144
- status: result.exitCode === 0 ? "succeeded" : "failed",
145
- steps: [
146
- {
147
- kind: result.exitCode === 0 ? "observation" : "error",
148
- input: { description: goal },
149
- output: {
150
- success: result.exitCode === 0,
151
- payload: {
152
- stdout: result.stdout.slice(-4096),
153
- stderr: result.stderr.slice(-2048),
154
- exitCode: result.exitCode,
155
- },
156
- },
157
- created_at: Date.now(),
158
- },
159
- ],
152
+ await api.post(`/api/rooms/${parsed.roomId}/agent-runs/${runId}/steps`, {
153
+ kind: succeeded ? "observation" : "error",
154
+ input: { description: succeeded ? "Task completed" : "Task failed" },
155
+ output: {
156
+ success: succeeded,
157
+ payload: {
158
+ stdout: result.stdout.slice(-4096),
159
+ stderr: result.stderr.slice(-2048),
160
+ exitCode: result.exitCode,
161
+ },
160
162
  },
161
163
  });
162
164
  }
163
165
  catch {
164
166
  console.error(`[${timestamp()}] Warning: failed to report completion`);
165
167
  }
168
+ // Agent-to-agent handoff
169
+ const handoffTarget = parsed.handoffTo ||
170
+ event.payload?.handoffTo ||
171
+ null;
172
+ if (succeeded && handoffTarget && parsed.roomId) {
173
+ console.error(`[${timestamp()}] Handing off to "${handoffTarget}"...`);
174
+ try {
175
+ // Find target agent by name
176
+ const agents = await api.get(`/api/rooms/${parsed.roomId}/agents`);
177
+ const target = agents.agents?.find((a) => a.name.toLowerCase() === handoffTarget.toLowerCase() || a._id === handoffTarget);
178
+ if (target) {
179
+ await api.post(`/api/rooms/${parsed.roomId}/agent-runs/${runId}/handoff`, {
180
+ targetAgentId: target._id,
181
+ context: `Completed: ${goal}. Output: ${result.stdout.slice(-500)}`,
182
+ goal: `Continue from "${agentName}": ${goal}`,
183
+ });
184
+ console.error(`[${timestamp()}] Handed off to "${target.name}" (${target._id})`);
185
+ }
186
+ else {
187
+ console.error(`[${timestamp()}] Handoff target "${handoffTarget}" not found in room`);
188
+ }
189
+ }
190
+ catch (err) {
191
+ console.error(`[${timestamp()}] Handoff failed: ${err}`);
192
+ }
193
+ }
166
194
  })
167
195
  .catch((err) => {
168
196
  console.error(`[${timestamp()}] Run failed: ${err}`);
@@ -57,12 +57,16 @@ export async function run(args) {
57
57
  if (!parsed.json) {
58
58
  console.error(`[co-od] Run ${runId} created. Executing with ${adapter.name}...`);
59
59
  }
60
- // Execute locally
60
+ // Execute locally — with room context for live hooks reporting
61
61
  const result = await adapter.execute(parsed.goal, {
62
62
  workDir: parsed.dir,
63
63
  onOutput: parsed.json
64
64
  ? undefined
65
65
  : (data) => process.stderr.write(data),
66
+ roomId: parsed.roomId,
67
+ runId,
68
+ serverUrl: api.getBaseUrl(),
69
+ sessionToken: api.getSessionToken() || undefined,
66
70
  });
67
71
  // Report completion
68
72
  if (!parsed.json) {
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Generates Claude Code hooks configuration that reports agent activity
3
+ * back to a co-ode room in real time.
4
+ *
5
+ * When `co-od daemon` or `co-od run` spawns Claude Code, it generates
6
+ * a temporary hooks config so every tool call, notification, and completion
7
+ * gets reported to the room's activity stream.
8
+ */
9
+ export interface HooksConfig {
10
+ roomId: string;
11
+ runId: string;
12
+ serverUrl: string;
13
+ sessionToken: string;
14
+ }
15
+ /**
16
+ * Generate a .claude/settings.local.json with hooks that report to co-ode.
17
+ * Returns the path to the generated directory (use as --add-dir or cwd).
18
+ */
19
+ export declare function generateHooksDir(config: HooksConfig): string;
20
+ /**
21
+ * Clean up hooks dir after run completes.
22
+ */
23
+ export declare function cleanupHooksDir(dir: string): void;
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Generates Claude Code hooks configuration that reports agent activity
3
+ * back to a co-ode room in real time.
4
+ *
5
+ * When `co-od daemon` or `co-od run` spawns Claude Code, it generates
6
+ * a temporary hooks config so every tool call, notification, and completion
7
+ * gets reported to the room's activity stream.
8
+ */
9
+ import { writeFileSync, mkdirSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { tmpdir } from "node:os";
12
+ /**
13
+ * Generate a .claude/settings.local.json with hooks that report to co-ode.
14
+ * Returns the path to the generated directory (use as --add-dir or cwd).
15
+ */
16
+ export function generateHooksDir(config) {
17
+ const dir = join(tmpdir(), `co-od-hooks-${config.runId}`);
18
+ const claudeDir = join(dir, ".claude");
19
+ mkdirSync(claudeDir, { recursive: true });
20
+ const reportScript = generateReportScript(config);
21
+ const reportScriptPath = join(dir, "co-od-report.mjs");
22
+ writeFileSync(reportScriptPath, reportScript, "utf-8");
23
+ // Claude Code hooks configuration
24
+ const hooks = {
25
+ hooks: {
26
+ PostToolUse: [
27
+ {
28
+ matcher: "*",
29
+ hooks: [
30
+ {
31
+ type: "command",
32
+ command: `node "${reportScriptPath}" post-tool-use "$TOOL_NAME" "$TOOL_INPUT" "$TOOL_OUTPUT"`,
33
+ },
34
+ ],
35
+ },
36
+ ],
37
+ Notification: [
38
+ {
39
+ matcher: "*",
40
+ hooks: [
41
+ {
42
+ type: "command",
43
+ command: `node "${reportScriptPath}" notification "$NOTIFICATION_TYPE" "$NOTIFICATION_MESSAGE"`,
44
+ },
45
+ ],
46
+ },
47
+ ],
48
+ Stop: [
49
+ {
50
+ matcher: "*",
51
+ hooks: [
52
+ {
53
+ type: "command",
54
+ command: `node "${reportScriptPath}" stop "$STOP_REASON"`,
55
+ },
56
+ ],
57
+ },
58
+ ],
59
+ },
60
+ };
61
+ writeFileSync(join(claudeDir, "settings.local.json"), JSON.stringify(hooks, null, 2), "utf-8");
62
+ return dir;
63
+ }
64
+ /**
65
+ * Generate the report script that hooks call.
66
+ * This is a standalone Node.js script — no imports, no deps.
67
+ */
68
+ function generateReportScript(config) {
69
+ return `#!/usr/bin/env node
70
+ // Auto-generated by co-od CLI — reports Claude Code activity to a co-ode room
71
+ const ROOM_ID = ${JSON.stringify(config.roomId)};
72
+ const RUN_ID = ${JSON.stringify(config.runId)};
73
+ const SERVER = ${JSON.stringify(config.serverUrl)};
74
+ const TOKEN = ${JSON.stringify(config.sessionToken)};
75
+
76
+ const [,, event, ...args] = process.argv;
77
+
78
+ async function report() {
79
+ const payload = { roomId: ROOM_ID, runId: RUN_ID, event, args, ts: Date.now() };
80
+
81
+ // Map hook events to agent step updates
82
+ let stepKind = "observation";
83
+ let description = "";
84
+
85
+ if (event === "post-tool-use") {
86
+ const [toolName, toolInput, toolOutput] = args;
87
+ stepKind = "tool_call";
88
+ description = toolName || "tool_call";
89
+
90
+ // Report as a step on the run
91
+ try {
92
+ await fetch(SERVER + "/api/rooms/" + ROOM_ID + "/agent-runs/" + RUN_ID + "/steps", {
93
+ method: "POST",
94
+ headers: {
95
+ "content-type": "application/json",
96
+ "authorization": "Bearer " + TOKEN,
97
+ },
98
+ body: JSON.stringify({
99
+ kind: stepKind,
100
+ input: {
101
+ description: description,
102
+ payload: { toolInput: (toolInput || "").slice(0, 2000) },
103
+ },
104
+ output: {
105
+ success: true,
106
+ payload: { toolOutput: (toolOutput || "").slice(0, 4000) },
107
+ },
108
+ }),
109
+ });
110
+ } catch {
111
+ // Non-fatal — don't break Claude Code execution
112
+ }
113
+ }
114
+
115
+ if (event === "notification") {
116
+ const [type, message] = args;
117
+ try {
118
+ await fetch(SERVER + "/api/rooms/" + ROOM_ID + "/messages", {
119
+ method: "POST",
120
+ headers: {
121
+ "content-type": "application/json",
122
+ "authorization": "Bearer " + TOKEN,
123
+ },
124
+ body: JSON.stringify({
125
+ body_md: "[agent] " + (message || type || "notification"),
126
+ author_type: "agent",
127
+ }),
128
+ });
129
+ } catch {
130
+ // Non-fatal
131
+ }
132
+ }
133
+
134
+ if (event === "stop") {
135
+ const [reason] = args;
136
+ try {
137
+ await fetch(SERVER + "/api/rooms/" + ROOM_ID + "/agent-runs/" + RUN_ID + "/steps", {
138
+ method: "POST",
139
+ headers: {
140
+ "content-type": "application/json",
141
+ "authorization": "Bearer " + TOKEN,
142
+ },
143
+ body: JSON.stringify({
144
+ kind: "observation",
145
+ input: { description: "Agent stopped: " + (reason || "complete") },
146
+ output: { success: true, payload: { reason: reason || "complete" } },
147
+ }),
148
+ });
149
+ } catch {
150
+ // Non-fatal
151
+ }
152
+ }
153
+ }
154
+
155
+ report().catch(() => process.exit(0));
156
+ `;
157
+ }
158
+ /**
159
+ * Clean up hooks dir after run completes.
160
+ */
161
+ export function cleanupHooksDir(dir) {
162
+ try {
163
+ const { rmSync } = require("node:fs");
164
+ rmSync(dir, { recursive: true, force: true });
165
+ }
166
+ catch {
167
+ // Non-fatal
168
+ }
169
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@codegrammer/co-od",
3
- "version": "0.1.1",
4
- "description": "CLI for co-ode run AI agents in shared rooms from the command line",
3
+ "version": "0.1.3",
4
+ "description": "CLI for co-ode \u2014 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
+ }