@kendoo.agentdesk/agentdesk 0.6.8 → 0.7.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.
package/README.md CHANGED
@@ -69,6 +69,7 @@ agentdesk logout Sign out and remove credentials
69
69
  agentdesk init Set up project and configure tracker
70
70
  agentdesk team <TASK-ID> Run a team session on an existing task
71
71
  agentdesk team -d "..." Describe what you want — task created automatically
72
+ agentdesk daemon Start daemon for remote sessions
72
73
  agentdesk update Update to the latest version
73
74
  ```
74
75
 
@@ -121,6 +122,33 @@ AgentDesk also auto-discovers agents from `.claude/agents/`, `.claude/commands/`
121
122
  5. For UI tasks, the team captures screenshots (desktop + mobile) and uploads them to your task tracker
122
123
  6. Token usage is tracked and displayed per session
123
124
 
125
+ ## Daemon (Remote Sessions)
126
+
127
+ The daemon lets you trigger team sessions from the web dashboard instead of the terminal.
128
+
129
+ ```bash
130
+ agentdesk daemon
131
+ ```
132
+
133
+ Once running, a "Start Session" button appears on [agentdesk.live](https://agentdesk.live). Select a project, optionally provide a prompt, and the daemon spawns a Claude session on your machine. Output streams back to the dashboard in real-time.
134
+
135
+ ### Security
136
+
137
+ - **Outbound only** — no ports opened on your machine
138
+ - **Project allowlist** — only runs on projects registered via `agentdesk init`
139
+ - **No arbitrary commands** — only spawns Claude with a fixed set of allowed tools
140
+ - **Metadata-only logs** — session logs in `~/.agentdesk/logs/` contain timestamps and file paths, never sensitive data
141
+ - **Fail closed** — unknown projects or exceeded session limits are rejected
142
+
143
+ ### How it works
144
+
145
+ 1. The daemon connects to the server via WebSocket
146
+ 2. You click "Start Session" in the dashboard
147
+ 3. The server relays the request to your daemon
148
+ 4. The daemon spawns `claude` in the project directory
149
+ 5. Output streams through the server to the dashboard
150
+ 6. If the connection drops, output is buffered locally and replayed on reconnect
151
+
124
152
  ## Dashboard Features
125
153
 
126
154
  - **Live sessions** — watch agents collaborate in real-time
package/bin/agentdesk.mjs CHANGED
@@ -59,6 +59,7 @@ if (!command || command === "help" || command === "--help") {
59
59
  agentdesk init Set up project and configure tracker
60
60
  agentdesk team <TASK-ID> Run a team session on an existing task
61
61
  agentdesk team -d "..." Create a task and run a session
62
+ agentdesk daemon Start daemon for remote sessions
62
63
  agentdesk update Update to the latest version
63
64
 
64
65
  Options:
@@ -126,6 +127,11 @@ else if (command === "team") {
126
127
  process.exit(code);
127
128
  }
128
129
 
130
+ else if (command === "daemon") {
131
+ const { runDaemon } = await import("../cli/daemon.mjs");
132
+ await runDaemon();
133
+ }
134
+
129
135
  else if (command === "update") {
130
136
  const { execSync } = await import("child_process");
131
137
  console.log(`\n Updating ${pkg.name}...\n`);
package/cli/daemon.mjs ADDED
@@ -0,0 +1,443 @@
1
+ // `agentdesk daemon` — local background daemon for UI-triggered sessions
2
+
3
+ import { spawn } from "child_process";
4
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
5
+ import { createInterface } from "readline";
6
+ import { join } from "path";
7
+ import { randomUUID } from "crypto";
8
+ import WebSocket from "ws";
9
+ import { detectProject } from "./detect.mjs";
10
+ import { loadConfig } from "./config.mjs";
11
+ import { getStoredApiKey } from "./login.mjs";
12
+ import { resolveTeam, generateTeamPrompt } from "./agents.mjs";
13
+ import { buildPrompt } from "./prompt.mjs";
14
+ import { createStreamParser } from "./stream-parser.mjs";
15
+ import { getRegisteredProjects } from "./projects.mjs";
16
+
17
+ const CONFIG_DIR = join(process.env.HOME || process.env.USERPROFILE, ".agentdesk");
18
+ const LOGS_DIR = join(CONFIG_DIR, "logs");
19
+
20
+ // --- Ring buffer for disconnect resilience ---
21
+
22
+ class RingBuffer {
23
+ constructor(maxBytes = 10 * 1024 * 1024) {
24
+ this.maxBytes = maxBytes;
25
+ this.items = [];
26
+ this.currentBytes = 0;
27
+ this.seq = 0;
28
+ }
29
+
30
+ push(data) {
31
+ const json = JSON.stringify(data);
32
+ const bytes = Buffer.byteLength(json, "utf-8");
33
+
34
+ // Drop oldest items until we have room
35
+ while (this.items.length > 0 && this.currentBytes + bytes > this.maxBytes) {
36
+ const removed = this.items.shift();
37
+ this.currentBytes -= removed.bytes;
38
+ }
39
+
40
+ this.seq++;
41
+ this.items.push({ seq: this.seq, data, json, bytes });
42
+ this.currentBytes += bytes;
43
+ return this.seq;
44
+ }
45
+
46
+ drain() {
47
+ const items = this.items.splice(0);
48
+ this.currentBytes = 0;
49
+ return items.map(i => ({ seq: i.seq, data: i.data }));
50
+ }
51
+
52
+ get size() { return this.currentBytes; }
53
+ get length() { return this.items.length; }
54
+ }
55
+
56
+ // --- Dot-env loader ---
57
+
58
+ function loadDotEnv(dir) {
59
+ const envPath = join(dir, ".env");
60
+ if (!existsSync(envPath)) return {};
61
+ const vars = {};
62
+ for (const line of readFileSync(envPath, "utf-8").split("\n")) {
63
+ const trimmed = line.trim();
64
+ if (!trimmed || trimmed.startsWith("#")) continue;
65
+ const eq = trimmed.indexOf("=");
66
+ if (eq === -1) continue;
67
+ vars[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
68
+ }
69
+ return vars;
70
+ }
71
+
72
+ // --- Metadata logger ---
73
+
74
+ function logSessionMetadata(sessionId, metadata) {
75
+ try {
76
+ if (!existsSync(LOGS_DIR)) {
77
+ mkdirSync(LOGS_DIR, { recursive: true, mode: 0o700 });
78
+ }
79
+ const logPath = join(LOGS_DIR, `session-${sessionId}.json`);
80
+ writeFileSync(logPath, JSON.stringify(metadata, null, 2) + "\n", { mode: 0o600 });
81
+ } catch {
82
+ // Non-fatal — don't crash daemon for logging failures
83
+ }
84
+ }
85
+
86
+ // --- Main daemon ---
87
+
88
+ export async function runDaemon() {
89
+ const dim = "\x1b[2m";
90
+ const green = "\x1b[32m";
91
+ const yellow = "\x1b[33m";
92
+ const red = "\x1b[31m";
93
+ const cyan = "\x1b[36m";
94
+ const reset = "\x1b[0m";
95
+
96
+ // 1. Load credentials
97
+ const apiKey = getStoredApiKey();
98
+ if (!apiKey) {
99
+ console.log(`\n ${red}Not authenticated.${reset} Run ${cyan}agentdesk login${reset} first.\n`);
100
+ process.exit(1);
101
+ }
102
+
103
+ // 2. Load registered projects
104
+ const allProjects = getRegisteredProjects();
105
+ const projects = allProjects.filter(p => {
106
+ if (!existsSync(p.path)) return false;
107
+ if (!existsSync(join(p.path, ".agentdesk.json"))) return false;
108
+ return true;
109
+ });
110
+
111
+ if (projects.length === 0) {
112
+ console.log(`\n ${red}No registered projects found.${reset} Run ${cyan}agentdesk init${reset} in a project directory.\n`);
113
+ process.exit(1);
114
+ }
115
+
116
+ console.log("");
117
+ console.log(` ${green}AgentDesk Daemon${reset}`);
118
+ console.log(" ━━━━━━━━━━━━━━━━━━━━━━━━");
119
+ console.log("");
120
+ console.log(` Projects:`);
121
+ for (const p of projects) {
122
+ console.log(` ${dim}•${reset} ${p.name} ${dim}(${p.path})${reset}`);
123
+ }
124
+ console.log("");
125
+
126
+ // 3. Connect WebSocket
127
+ const DAEMON_URL = process.env.AGENTDESK_URL
128
+ ? process.env.AGENTDESK_URL.replace("/ws/agent", "/ws/daemon")
129
+ : "wss://agentdesk.live/ws/daemon";
130
+ const agentdeskServer = process.env.AGENTDESK_SERVER || "https://agentdesk.live";
131
+
132
+ // Enforce TLS in production — API key must not travel in cleartext
133
+ if (!DAEMON_URL.startsWith("wss://") && !DAEMON_URL.startsWith("ws://localhost") && !DAEMON_URL.startsWith("ws://127.0.0.1")) {
134
+ console.log(`\n ${red}Refusing to connect:${reset} daemon URL must use wss:// (got ${DAEMON_URL})\n`);
135
+ process.exit(1);
136
+ }
137
+
138
+ let ws = null;
139
+ let connected = false;
140
+ let reconnectTimer = null;
141
+ let heartbeatTimer = null;
142
+ const buffer = new RingBuffer();
143
+
144
+ // Active session state
145
+ let activeSession = null; // { sessionId, projectId, child, startedAt, filePathsTouched }
146
+
147
+ function send(data) {
148
+ if (connected && ws?.readyState === WebSocket.OPEN) {
149
+ ws.send(JSON.stringify(data));
150
+ return true;
151
+ }
152
+ return false;
153
+ }
154
+
155
+ function sendBuffered(sessionId, event) {
156
+ const seq = buffer.push({ sessionId, event });
157
+ const msg = { type: "daemon:session-output", sessionId, event, seq };
158
+ if (!send(msg)) {
159
+ // Already buffered — will replay on reconnect
160
+ }
161
+ }
162
+
163
+ function connectWs() {
164
+ try {
165
+ ws = new WebSocket(DAEMON_URL);
166
+
167
+ ws.on("open", () => {
168
+ send({ type: "auth", apiKey });
169
+ });
170
+
171
+ ws.on("message", (raw) => {
172
+ let msg;
173
+ try { msg = JSON.parse(raw.toString()); } catch { return; }
174
+
175
+ if (msg.type === "auth:ok") {
176
+ connected = true;
177
+ console.log(` ${green}Connected${reset} to ${dim}${DAEMON_URL}${reset}`);
178
+
179
+ // Announce projects — do NOT send local paths to the server
180
+ send({
181
+ type: "daemon:connect",
182
+ projects: projects.map(p => ({ id: p.id, name: p.name })),
183
+ });
184
+
185
+ // Replay buffered items
186
+ const buffered = buffer.drain();
187
+ if (buffered.length > 0) {
188
+ console.log(` ${dim}Replaying ${buffered.length} buffered events...${reset}`);
189
+ for (const item of buffered) {
190
+ send({ type: "daemon:session-output", sessionId: item.data.sessionId, event: item.data.event, seq: item.seq });
191
+ }
192
+ }
193
+
194
+ // Start heartbeat
195
+ clearInterval(heartbeatTimer);
196
+ heartbeatTimer = setInterval(() => send({ type: "daemon:heartbeat" }), 30000);
197
+
198
+ console.log(` ${dim}Waiting for sessions...${reset}\n`);
199
+ }
200
+
201
+ if (msg.type === "auth:error") {
202
+ console.log(` ${red}Authentication failed.${reset} Run ${cyan}agentdesk login${reset} to re-authenticate.`);
203
+ connected = false;
204
+ ws.close();
205
+ process.exit(1);
206
+ }
207
+
208
+ if (msg.type === "daemon:start-session") {
209
+ handleStartSession(msg);
210
+ }
211
+
212
+ if (msg.type === "daemon:cancel-session") {
213
+ handleCancelSession(msg);
214
+ }
215
+ });
216
+
217
+ ws.on("error", () => { connected = false; });
218
+
219
+ ws.on("close", (code) => {
220
+ connected = false;
221
+ clearInterval(heartbeatTimer);
222
+
223
+ if (code === 4001) {
224
+ console.log(` ${red}Authentication required.${reset} Run ${cyan}agentdesk login${reset}.`);
225
+ process.exit(1);
226
+ }
227
+
228
+ console.log(` ${yellow}Disconnected.${reset} Reconnecting in 5s...`);
229
+ clearTimeout(reconnectTimer);
230
+ reconnectTimer = setTimeout(connectWs, 5000);
231
+ });
232
+ } catch {
233
+ clearTimeout(reconnectTimer);
234
+ reconnectTimer = setTimeout(connectWs, 10000);
235
+ }
236
+ }
237
+
238
+ // 4. Session handling
239
+
240
+ async function handleStartSession({ sessionId, projectId, prompt }) {
241
+ // Validate project against local allowlist
242
+ const project = projects.find(p => p.id === projectId);
243
+ if (!project) {
244
+ console.log(` ${red}Rejected:${reset} unknown project ${dim}${projectId}${reset}`);
245
+ send({ type: "daemon:error", sessionId, error: `Project "${projectId}" not registered with this daemon` });
246
+ return;
247
+ }
248
+
249
+ // Enforce max 1 concurrent session — set flag BEFORE any async work to prevent race
250
+ if (activeSession) {
251
+ console.log(` ${red}Rejected:${reset} session already running ${dim}(${activeSession.sessionId})${reset}`);
252
+ send({ type: "daemon:error", sessionId, error: "Max concurrent sessions reached (1). Try again after the current session completes." });
253
+ return;
254
+ }
255
+ // Claim the slot immediately (before any await) to prevent race conditions
256
+ activeSession = { sessionId, projectId, child: null, startedAt: Date.now(), filePathsTouched: new Set() };
257
+
258
+ console.log(` ${green}Starting session${reset} ${dim}${sessionId}${reset}`);
259
+ console.log(` Project: ${project.name} ${dim}(${project.path})${reset}`);
260
+
261
+ const startedAt = activeSession.startedAt;
262
+ const filePathsTouched = activeSession.filePathsTouched;
263
+
264
+ try {
265
+ // Load project config
266
+ const detected = detectProject(project.path);
267
+ const projectEnv = loadDotEnv(project.path);
268
+ const projectApiKey = projectEnv.AGENTDESK_API_KEY || process.env.AGENTDESK_API_KEY || apiKey;
269
+ const config = await loadConfig(project.path, { apiKey: projectApiKey, serverUrl: agentdeskServer, projectName: project.name });
270
+ const tracker = config.tracker || (detected.hasLinear ? "linear" : null);
271
+
272
+ // Generate task ID
273
+ const taskId = prompt
274
+ ? prompt.toLowerCase().replace(/[^a-z0-9\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 40) || `task-${Date.now().toString(36)}`
275
+ : `daemon-${Date.now().toString(36)}`;
276
+
277
+ // Build task link
278
+ let taskLink = null;
279
+
280
+ // Resolve team
281
+ const team = resolveTeam(config);
282
+ const teamSections = generateTeamPrompt(team);
283
+
284
+ const inboxUrl = `${agentdeskServer}/api/sessions/${sessionId}/inbox`;
285
+ const sessionUrl = `${agentdeskServer}/sessions/${sessionId}`;
286
+
287
+ // Build prompt
288
+ const fullPrompt = buildPrompt({
289
+ taskId, taskLink,
290
+ description: prompt || "",
291
+ createTask: false,
292
+ tracker, config,
293
+ project: detected,
294
+ teamSections, inboxUrl, sessionUrl,
295
+ });
296
+
297
+ // Send session:start
298
+ sendBuffered(sessionId, {
299
+ type: "session:start",
300
+ taskId, taskLink,
301
+ title: prompt || taskId,
302
+ project: project.name,
303
+ sessionNumber: 1,
304
+ agents: teamSections.names,
305
+ });
306
+
307
+ sendBuffered(sessionId, { type: "phase:change", phase: "INTAKE" });
308
+
309
+ // Spawn Claude — NEVER uses --dangerously-skip-permissions
310
+ const child = spawn(
311
+ "claude",
312
+ [
313
+ "-p", fullPrompt,
314
+ "--allowedTools", "Bash,Read,Edit,Write,Glob,Grep",
315
+ "--verbose",
316
+ "--output-format", "stream-json",
317
+ ],
318
+ {
319
+ stdio: ["pipe", "pipe", "inherit"],
320
+ shell: false,
321
+ env: { ...process.env, ...loadDotEnv(project.path) },
322
+ cwd: project.path,
323
+ }
324
+ );
325
+
326
+ // Close stdin so claude doesn't wait for input
327
+ child.stdin.end();
328
+
329
+ activeSession.child = child;
330
+
331
+ // Parse stream
332
+ const { parseLine } = createStreamParser({
333
+ teamNames: teamSections.names,
334
+ callbacks: {
335
+ onPhaseChange({ phase }) {
336
+ sendBuffered(sessionId, { type: "phase:change", phase });
337
+ },
338
+ onAgentMessage({ agent, tag, message }) {
339
+ sendBuffered(sessionId, { type: "agent:message", agent, tag, message });
340
+ },
341
+ onToolUse({ agent, tool, description }) {
342
+ sendBuffered(sessionId, { type: "tool:use", agent, tool, description });
343
+ // Track file paths for metadata logging
344
+ const pathMatch = description.match(/(?:Reading|Editing|Writing)\s+(.+)/);
345
+ if (pathMatch) filePathsTouched.add(pathMatch[1]);
346
+ },
347
+ onToolResult({ success, summary }) {
348
+ sendBuffered(sessionId, { type: "tool:result", success, summary });
349
+ },
350
+ onSessionUpdate({ taskId: newTaskId }) {
351
+ sendBuffered(sessionId, { type: "session:update", taskId: newTaskId });
352
+ },
353
+ onSessionEnd({ duration, steps, inputTokens, outputTokens }) {
354
+ sendBuffered(sessionId, { type: "session:end", duration, steps, inputTokens, outputTokens });
355
+ console.log(` ${green}Session complete${reset} ${dim}${sessionId}${reset} (${duration}, ${steps} steps)`);
356
+
357
+ // Log metadata
358
+ logSessionMetadata(sessionId, {
359
+ sessionId, projectId,
360
+ startedAt, endedAt: Date.now(),
361
+ duration, exitCode: 0, steps,
362
+ filePathsTouched: [...filePathsTouched],
363
+ });
364
+
365
+ activeSession = null;
366
+ },
367
+ },
368
+ });
369
+
370
+ const rl = createInterface({ input: child.stdout });
371
+ for await (const line of rl) {
372
+ parseLine(line);
373
+ }
374
+
375
+ // Handle unexpected exit (no "result" event)
376
+ child.on("close", (code) => {
377
+ if (activeSession?.sessionId === sessionId) {
378
+ const duration = `${((Date.now() - startedAt) / 1000).toFixed(1)}s`;
379
+ console.log(` ${code ? red : yellow}Session exited${reset} ${dim}${sessionId}${reset} (code ${code})`);
380
+
381
+ sendBuffered(sessionId, { type: "session:end", duration, steps: 0, inputTokens: 0, outputTokens: 0 });
382
+
383
+ logSessionMetadata(sessionId, {
384
+ sessionId, projectId,
385
+ startedAt, endedAt: Date.now(),
386
+ duration, exitCode: code || 0, steps: 0,
387
+ filePathsTouched: [...filePathsTouched],
388
+ });
389
+
390
+ activeSession = null;
391
+ }
392
+ });
393
+
394
+ } catch (err) {
395
+ console.log(` ${red}Failed to start session:${reset} ${err.message}`);
396
+ // Send generic error to server — don't leak internal details (paths, config, etc.)
397
+ send({ type: "daemon:error", sessionId, error: "Failed to start session on daemon" });
398
+ activeSession = null;
399
+ }
400
+ }
401
+
402
+ function killChild(child) {
403
+ if (!child) return;
404
+ try { child.kill("SIGTERM"); } catch {}
405
+ // Force kill if SIGTERM doesn't work within 5 seconds
406
+ setTimeout(() => {
407
+ try { if (!child.killed) child.kill("SIGKILL"); } catch {}
408
+ }, 5000);
409
+ }
410
+
411
+ function handleCancelSession({ sessionId }) {
412
+ if (activeSession?.sessionId === sessionId) {
413
+ console.log(` ${yellow}Cancelling session${reset} ${dim}${sessionId}${reset}`);
414
+ killChild(activeSession.child);
415
+ activeSession = null;
416
+ }
417
+ }
418
+
419
+ // 5. Graceful shutdown
420
+
421
+ function shutdown() {
422
+ console.log(`\n ${dim}Shutting down...${reset}`);
423
+ clearInterval(heartbeatTimer);
424
+ clearTimeout(reconnectTimer);
425
+
426
+ if (activeSession) {
427
+ killChild(activeSession.child);
428
+ }
429
+
430
+ send({ type: "daemon:disconnect" });
431
+ try { ws?.close(); } catch {}
432
+ process.exit(0);
433
+ }
434
+
435
+ process.on("SIGINT", shutdown);
436
+ process.on("SIGTERM", shutdown);
437
+
438
+ // 6. Start
439
+ connectWs();
440
+
441
+ // Keep process alive
442
+ await new Promise(() => {});
443
+ }
package/cli/init.mjs CHANGED
@@ -6,6 +6,7 @@ import { createInterface } from "readline";
6
6
  import { detectProject } from "./detect.mjs";
7
7
  import { loadConfig } from "./config.mjs";
8
8
  import { getStoredApiKey } from "./login.mjs";
9
+ import { registerLocalProject } from "./projects.mjs";
9
10
 
10
11
  const SERVER = process.env.AGENTDESK_SERVER || "https://agentdesk.live";
11
12
 
@@ -122,6 +123,9 @@ export async function runInit(cwd) {
122
123
  writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n");
123
124
  console.log(` Saved .agentdesk.json`);
124
125
 
126
+ // Register in local project index (for daemon discovery)
127
+ registerLocalProject(projectId, project.name || projectId, project.dir);
128
+
125
129
  // --- Register with server ---
126
130
  try {
127
131
  const res = await fetch(`${SERVER}/api/projects`, {
@@ -0,0 +1,34 @@
1
+ // Local project registry — tracks which projects have been initialized with `agentdesk init`
2
+
3
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
4
+ import { join } from "path";
5
+
6
+ const CONFIG_DIR = join(process.env.HOME || process.env.USERPROFILE, ".agentdesk");
7
+ const PROJECTS_PATH = join(CONFIG_DIR, "projects.json");
8
+
9
+ export function getRegisteredProjects() {
10
+ try {
11
+ if (!existsSync(PROJECTS_PATH)) return [];
12
+ const data = JSON.parse(readFileSync(PROJECTS_PATH, "utf-8"));
13
+ return Array.isArray(data.projects) ? data.projects : [];
14
+ } catch {
15
+ return [];
16
+ }
17
+ }
18
+
19
+ export function registerLocalProject(id, name, path) {
20
+ const projects = getRegisteredProjects();
21
+ const existing = projects.findIndex(p => p.id === id);
22
+ const entry = { id, name, path, registeredAt: Date.now() };
23
+
24
+ if (existing >= 0) {
25
+ projects[existing] = entry;
26
+ } else {
27
+ projects.push(entry);
28
+ }
29
+
30
+ if (!existsSync(CONFIG_DIR)) {
31
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
32
+ }
33
+ writeFileSync(PROJECTS_PATH, JSON.stringify({ projects }, null, 2) + "\n", { mode: 0o600 });
34
+ }
package/cli/prompt.mjs ADDED
@@ -0,0 +1,98 @@
1
+ // Shared prompt builder — used by both `agentdesk team` and `agentdesk daemon`
2
+
3
+ import { readFileSync } from "fs";
4
+ import { resolve, dirname } from "path";
5
+ import { fileURLToPath } from "url";
6
+ import { generateContext } from "./detect.mjs";
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const PROMPT_PATH = resolve(__dirname, "../prompts/team.md");
10
+
11
+ export function buildPrompt({ taskId, taskLink, description, createTask, tracker, config, project, teamSections, inboxUrl, sessionUrl }) {
12
+ let prompt = readFileSync(PROMPT_PATH, "utf-8");
13
+
14
+ // Team substitution
15
+ prompt = prompt.replace(/\{\{AGENT_COUNT\}\}/g, String(teamSections.count));
16
+ prompt = prompt.replace(/\{\{AGENT_LIST\}\}/g, teamSections.agentList);
17
+ prompt = prompt.replace(/\{\{SPEAKING_ORDER\}\}/g, teamSections.speakingOrder);
18
+ prompt = prompt.replace(/\{\{GROUND_RULES\}\}/g, teamSections.groundRules);
19
+ prompt = prompt.replace(/\{\{CODE_PRINCIPLES\}\}/g, teamSections.codePrinciples);
20
+ prompt = prompt.replace(/\{\{BRAINSTORM_ORDER\}\}/g, teamSections.brainstormOrder);
21
+ prompt = prompt.replace(/\{\{PLANNING_ORDER\}\}/g, teamSections.planningOrder);
22
+ prompt = prompt.replace(/\{\{EXECUTION_STEPS\}\}/g, teamSections.executionSteps);
23
+
24
+ // Template substitution
25
+ prompt = prompt.replace(/\{\{TASK_ID\}\}/g, taskId);
26
+ prompt = prompt.replace(/\{\{TASK_LINK\}\}/g, taskLink || "");
27
+
28
+ // Task description
29
+ if (description) {
30
+ prompt = prompt.replace(/\{\{#TASK_DESCRIPTION\}\}([\s\S]*?)\{\{\/TASK_DESCRIPTION\}\}/g, "$1");
31
+ prompt = prompt.replace(/\{\{TASK_DESCRIPTION\}\}/g, description);
32
+ } else {
33
+ prompt = prompt.replace(/\{\{#TASK_DESCRIPTION\}\}[\s\S]*?\{\{\/TASK_DESCRIPTION\}\}/g, "");
34
+ }
35
+
36
+ // Create task instruction
37
+ if (createTask && description) {
38
+ let createInstr = "\n\n## CREATE TASK\n\nNo task ID was provided. Before starting work, Jane MUST create a new task in the tracker:\n\n";
39
+ if (tracker === "linear") {
40
+ createInstr += `Create a Linear issue using the GraphQL API:\n- Endpoint: https://api.linear.app/graphql\n- Auth: Authorization: $LINEAR_API_KEY\n- Set the title based on the description below\n- After creation, use the returned identifier (e.g., KEN-530) as the task ID for the rest of the session\n`;
41
+ } else if (tracker === "jira") {
42
+ createInstr += `Create a Jira issue:\n- Endpoint: ${config.jira?.baseUrl || ""}/rest/api/3/issue\n- Auth: Basic auth with $JIRA_EMAIL and $JIRA_API_TOKEN\n- Set the summary based on the description below\n- After creation, use the returned key (e.g., PROJ-42) as the task ID for the rest of the session\n`;
43
+ } else if (tracker === "github") {
44
+ createInstr += `Create a GitHub issue:\n- Run: gh issue create --title "..." --body "..."\n- After creation, use the returned issue number as the task ID for the rest of the session\n`;
45
+ }
46
+ createInstr += `\nTask description: ${description}\n`;
47
+ createInstr += `\nIMPORTANT: After creating the task, Jane MUST immediately announce the new task ID on its own line in this exact format:\nTASK_ID: <identifier>\nExample: TASK_ID: KEN-530\nThis is required so the session can be linked to the correct task.\n`;
48
+ prompt += createInstr;
49
+ }
50
+
51
+ // Tracker integration — enable the matching section, strip the rest
52
+ const trackers = ["LINEAR", "JIRA", "GITHUB"];
53
+ for (const t of trackers) {
54
+ const enabled = tracker === t.toLowerCase();
55
+ if (enabled) {
56
+ prompt = prompt.replace(new RegExp(`\\{\\{#${t}\\}\\}([\\s\\S]*?)\\{\\{\\/${t}\\}\\}`, "g"), "$1");
57
+ } else {
58
+ prompt = prompt.replace(new RegExp(`\\{\\{#${t}\\}\\}[\\s\\S]*?\\{\\{\\/${t}\\}\\}`, "g"), "");
59
+ }
60
+ }
61
+
62
+ // NO_TRACKER
63
+ if (!tracker) {
64
+ prompt = prompt.replace(/\{\{#NO_TRACKER\}\}([\s\S]*?)\{\{\/NO_TRACKER\}\}/g, "$1");
65
+ } else {
66
+ prompt = prompt.replace(/\{\{#NO_TRACKER\}\}[\s\S]*?\{\{\/NO_TRACKER\}\}/g, "");
67
+ }
68
+
69
+ // Jira-specific variables
70
+ if (config.jira?.baseUrl) {
71
+ prompt = prompt.replace(/\{\{JIRA_BASE_URL\}\}/g, config.jira.baseUrl);
72
+ }
73
+
74
+ // Append custom instructions from config
75
+ if (config.instructions) {
76
+ prompt += `\n\n## ADDITIONAL INSTRUCTIONS\n\n${config.instructions}\n`;
77
+ }
78
+
79
+ // Inject URLs into prompt
80
+ prompt = prompt.replace(/\{\{AGENTDESK_INBOX_URL\}\}/g, inboxUrl);
81
+ prompt = prompt.replace(/\{\{SESSION_URL\}\}/g, sessionUrl);
82
+
83
+ // Merge declared agents from config into project for context generation
84
+ if (config.projectAgents?.length) {
85
+ project.configAgents = config.projectAgents.map(a => ({
86
+ ...a,
87
+ type: a.type || "declared",
88
+ source: ".agentdesk.json",
89
+ }));
90
+ }
91
+
92
+ // Append project context and current time
93
+ const context = generateContext(project);
94
+ const now = new Date();
95
+ const timeInfo = `Current date/time: ${now.toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" })} ${now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`;
96
+
97
+ return `${prompt}\n\n---\n\n## PROJECT CONTEXT\n\n${context}\n\n${timeInfo}`;
98
+ }
@@ -0,0 +1,174 @@
1
+ // Shared Claude stream-json parser — used by both `agentdesk team` and `agentdesk daemon`
2
+
3
+ const PHASE_NAMES = ["INTAKE", "BRAINSTORM", "PLANNING", "EXECUTION", "REVIEW"];
4
+
5
+ function escapeRegex(s) {
6
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7
+ }
8
+
9
+ export function createStreamParser({ teamNames, callbacks }) {
10
+ // callbacks: { onAgentMessage, onPhaseChange, onToolUse, onToolResult, onSessionEnd, onSessionUpdate, onTokenUsage }
11
+
12
+ const agentPattern = teamNames.map(escapeRegex).join("|");
13
+ const agentRegex = new RegExp(`^(${agentPattern})\\s*[●◆▲■◈☾✦*]*\\s*:?\\s*`, "i");
14
+
15
+ let lastAgent = teamNames[0] || "Jane";
16
+ let stepCount = 0;
17
+ const startTime = Date.now();
18
+ let totalInputTokens = 0;
19
+ let totalOutputTokens = 0;
20
+
21
+ function detectAgent(text) {
22
+ const stripped = text.replace(/^[●◆▲■◈☾✦*\-─—\s]+/, "");
23
+ const match = stripped.match(agentRegex);
24
+ if (match) {
25
+ const raw = match[1];
26
+ const name = teamNames.find(n => n.toLowerCase() === raw.toLowerCase()) || raw;
27
+ return { name, rest: stripped.slice(match[0].length) };
28
+ }
29
+ return null;
30
+ }
31
+
32
+ function detectTag(text) {
33
+ if (/\[ARGUE\]/i.test(text)) return "ARGUE";
34
+ if (/\[AGREE\]/i.test(text)) return "AGREE";
35
+ if (/\[THINK\]/i.test(text)) return "THINK";
36
+ if (/\[ACT\]/i.test(text)) return "ACT";
37
+ return "SAY";
38
+ }
39
+
40
+ function detectPhase(text) {
41
+ const upper = text.trim().toUpperCase();
42
+ if (upper.startsWith("PHASE") || upper.startsWith("---") || upper.startsWith("#")) {
43
+ for (const p of PHASE_NAMES) {
44
+ if (upper.includes(p)) return p;
45
+ }
46
+ }
47
+ return null;
48
+ }
49
+
50
+ function timestamp() {
51
+ const d = new Date();
52
+ return [d.getHours(), d.getMinutes(), d.getSeconds()]
53
+ .map(n => String(n).padStart(2, "0")).join(":");
54
+ }
55
+
56
+ function parseLine(line) {
57
+ try {
58
+ const event = JSON.parse(line);
59
+
60
+ // Track token usage
61
+ if (event.usage) {
62
+ if (event.usage.input_tokens) totalInputTokens = Math.max(totalInputTokens, event.usage.input_tokens);
63
+ if (event.usage.output_tokens) totalOutputTokens += (event.usage.output_tokens_delta || 0);
64
+ }
65
+ if (event.message?.usage) {
66
+ if (event.message.usage.input_tokens) totalInputTokens = Math.max(totalInputTokens, event.message.usage.input_tokens);
67
+ if (event.message.usage.output_tokens) totalOutputTokens = Math.max(totalOutputTokens, event.message.usage.output_tokens);
68
+ }
69
+
70
+ if (event.type === "assistant" && event.message?.content) {
71
+ for (const block of event.message.content) {
72
+ if (block.type === "text" && block.text.trim()) {
73
+ const text = block.text.trim().replace(/\*+/g, "");
74
+
75
+ const phase = detectPhase(text);
76
+ if (phase) {
77
+ callbacks.onPhaseChange?.({ phase, timestamp: timestamp() });
78
+ continue;
79
+ }
80
+
81
+ // Detect TASK_ID announcement
82
+ const taskIdMatch = text.match(/TASK_ID:\s*(\S+)/);
83
+ if (taskIdMatch) {
84
+ callbacks.onSessionUpdate?.({ taskId: taskIdMatch[1] });
85
+ }
86
+
87
+ const textLines = text.split("\n");
88
+ let i = 0;
89
+ while (i < textLines.length) {
90
+ const currentLine = textLines[i].trim();
91
+ if (!currentLine) { i++; continue; }
92
+
93
+ const detected = detectAgent(currentLine);
94
+ if (detected) {
95
+ let msg = detected.rest;
96
+ while (i + 1 < textLines.length && !detectAgent(textLines[i + 1].trim())) {
97
+ i++;
98
+ const next = textLines[i].trim();
99
+ if (next) msg += "\n" + next;
100
+ }
101
+ const tag = detectTag(msg);
102
+ const cleanMsg = msg.replace(/\[(SAY|ACT|THINK|AGREE|ARGUE)\]\s*/gi, "");
103
+ lastAgent = detected.name;
104
+ callbacks.onAgentMessage?.({ agent: detected.name, tag, message: cleanMsg, timestamp: timestamp() });
105
+ } else {
106
+ callbacks.onAgentMessage?.({ agent: lastAgent, tag: "SAY", message: currentLine, timestamp: timestamp() });
107
+ }
108
+ i++;
109
+ }
110
+ }
111
+ }
112
+ }
113
+
114
+ if (event.type === "tool_use") {
115
+ const name = event.name || event.tool_name;
116
+ stepCount++;
117
+
118
+ let description = "Running command...";
119
+ if (name === "Bash") {
120
+ const cmd = event.input?.command || "";
121
+ if (cmd.includes("curl") && cmd.includes("linear")) description = "Calling Linear API...";
122
+ else if (cmd.includes("curl")) description = "Making API request...";
123
+ else {
124
+ const shortCmd = cmd.length > 80 ? cmd.slice(0, 80) + "..." : cmd;
125
+ description = `$ ${shortCmd}`;
126
+ }
127
+ } else if (name === "Read") {
128
+ const shortPath = (event.input?.file_path || "").split("/").slice(-3).join("/");
129
+ description = `Reading ${shortPath}`;
130
+ } else if (name === "Edit" || name === "Write") {
131
+ const shortPath = (event.input?.file_path || "").split("/").slice(-3).join("/");
132
+ description = `${name === "Edit" ? "Editing" : "Writing"} ${shortPath}`;
133
+ } else if (name === "Glob" || name === "Grep") {
134
+ description = `Searching ${event.input?.pattern || ""}`;
135
+ }
136
+
137
+ callbacks.onToolUse?.({ agent: lastAgent, tool: name, description, timestamp: timestamp() });
138
+ }
139
+
140
+ if (event.type === "tool_result") {
141
+ const output = event.content || event.output;
142
+ let text = "";
143
+ if (typeof output === "string") text = output.trim();
144
+ else if (Array.isArray(output)) {
145
+ text = output.filter(b => b.type === "text").map(b => b.text.trim()).join("\n");
146
+ }
147
+
148
+ const hasError = text && (text.toLowerCase().includes("error") || text.toLowerCase().includes("failed"));
149
+ const summary = text?.length > 300 ? `Done (${text.length} chars)` : text || "Done";
150
+
151
+ callbacks.onToolResult?.({ success: !hasError, summary, timestamp: timestamp() });
152
+ }
153
+
154
+ if (event.type === "result") {
155
+ if (event.usage) {
156
+ if (event.usage.input_tokens) totalInputTokens = Math.max(totalInputTokens, event.usage.input_tokens);
157
+ if (event.usage.output_tokens) totalOutputTokens = Math.max(totalOutputTokens, event.usage.output_tokens);
158
+ }
159
+ const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);
160
+ callbacks.onSessionEnd?.({
161
+ duration: `${totalTime}s`,
162
+ steps: stepCount,
163
+ inputTokens: totalInputTokens,
164
+ outputTokens: totalOutputTokens,
165
+ timestamp: timestamp(),
166
+ });
167
+ }
168
+ } catch {
169
+ // skip non-JSON lines
170
+ }
171
+ }
172
+
173
+ return { parseLine, getLastAgent: () => lastAgent, getStepCount: () => stepCount };
174
+ }
package/cli/team.mjs CHANGED
@@ -3,14 +3,15 @@
3
3
  import { spawn } from "child_process";
4
4
  import { existsSync, readFileSync } from "fs";
5
5
  import { createInterface } from "readline";
6
- import { resolve, dirname, join } from "path";
7
- import { fileURLToPath } from "url";
6
+ import { join } from "path";
8
7
  import { randomUUID } from "crypto";
9
8
  import WebSocket from "ws";
10
- import { detectProject, generateContext } from "./detect.mjs";
9
+ import { detectProject } from "./detect.mjs";
11
10
  import { loadConfig } from "./config.mjs";
12
11
  import { getStoredApiKey } from "./login.mjs";
13
12
  import { resolveTeam, generateTeamPrompt } from "./agents.mjs";
13
+ import { buildPrompt } from "./prompt.mjs";
14
+ import { createStreamParser } from "./stream-parser.mjs";
14
15
 
15
16
  function loadDotEnv(dir) {
16
17
  const envPath = join(dir, ".env");
@@ -26,9 +27,6 @@ function loadDotEnv(dir) {
26
27
  return vars;
27
28
  }
28
29
 
29
- const __dirname = dirname(fileURLToPath(import.meta.url));
30
- const PROMPT_PATH = resolve(__dirname, "../prompts/team.md");
31
-
32
30
  export async function runTeam(taskId, opts = {}) {
33
31
  const cwd = opts.cwd || process.cwd();
34
32
  const description = opts.description || "";
@@ -86,98 +84,18 @@ export async function runTeam(taskId, opts = {}) {
86
84
  const team = resolveTeam(config);
87
85
  const teamSections = generateTeamPrompt(team);
88
86
 
89
- // Build prompt
90
- let prompt = readFileSync(PROMPT_PATH, "utf-8");
91
-
92
- // Team substitution
93
- prompt = prompt.replace(/\{\{AGENT_COUNT\}\}/g, String(teamSections.count));
94
- prompt = prompt.replace(/\{\{AGENT_LIST\}\}/g, teamSections.agentList);
95
- prompt = prompt.replace(/\{\{SPEAKING_ORDER\}\}/g, teamSections.speakingOrder);
96
- prompt = prompt.replace(/\{\{GROUND_RULES\}\}/g, teamSections.groundRules);
97
- prompt = prompt.replace(/\{\{CODE_PRINCIPLES\}\}/g, teamSections.codePrinciples);
98
- prompt = prompt.replace(/\{\{BRAINSTORM_ORDER\}\}/g, teamSections.brainstormOrder);
99
- prompt = prompt.replace(/\{\{PLANNING_ORDER\}\}/g, teamSections.planningOrder);
100
- prompt = prompt.replace(/\{\{EXECUTION_STEPS\}\}/g, teamSections.executionSteps);
101
-
102
- // Template substitution
103
- prompt = prompt.replace(/\{\{TASK_ID\}\}/g, taskId);
104
- prompt = prompt.replace(/\{\{TASK_LINK\}\}/g, taskLink || "");
105
-
106
- // Task description
107
- if (description) {
108
- prompt = prompt.replace(/\{\{#TASK_DESCRIPTION\}\}([\s\S]*?)\{\{\/TASK_DESCRIPTION\}\}/g, "$1");
109
- prompt = prompt.replace(/\{\{TASK_DESCRIPTION\}\}/g, description);
110
- } else {
111
- prompt = prompt.replace(/\{\{#TASK_DESCRIPTION\}\}[\s\S]*?\{\{\/TASK_DESCRIPTION\}\}/g, "");
112
- }
113
-
114
- // Create task instruction — when -d is used without a task ID and a tracker is configured
115
- if (createTask && description) {
116
- let createInstr = "\n\n## CREATE TASK\n\nNo task ID was provided. Before starting work, Jane MUST create a new task in the tracker:\n\n";
117
- if (tracker === "linear") {
118
- createInstr += `Create a Linear issue using the GraphQL API:\n- Endpoint: https://api.linear.app/graphql\n- Auth: Authorization: $LINEAR_API_KEY\n- Set the title based on the description below\n- After creation, use the returned identifier (e.g., KEN-530) as the task ID for the rest of the session\n`;
119
- } else if (tracker === "jira") {
120
- createInstr += `Create a Jira issue:\n- Endpoint: ${config.jira?.baseUrl || ""}/rest/api/3/issue\n- Auth: Basic auth with $JIRA_EMAIL and $JIRA_API_TOKEN\n- Set the summary based on the description below\n- After creation, use the returned key (e.g., PROJ-42) as the task ID for the rest of the session\n`;
121
- } else if (tracker === "github") {
122
- createInstr += `Create a GitHub issue:\n- Run: gh issue create --title "..." --body "..."\n- After creation, use the returned issue number as the task ID for the rest of the session\n`;
123
- }
124
- createInstr += `\nTask description: ${description}\n`;
125
- createInstr += `\nIMPORTANT: After creating the task, Jane MUST immediately announce the new task ID on its own line in this exact format:\nTASK_ID: <identifier>\nExample: TASK_ID: KEN-530\nThis is required so the session can be linked to the correct task.\n`;
126
- prompt += createInstr;
127
- }
128
-
129
- // Tracker integration — enable the matching section, strip the rest
130
- const trackers = ["LINEAR", "JIRA", "GITHUB"];
131
- for (const t of trackers) {
132
- const enabled = tracker === t.toLowerCase();
133
- if (enabled) {
134
- prompt = prompt.replace(new RegExp(`\\{\\{#${t}\\}\\}([\\s\\S]*?)\\{\\{\\/${t}\\}\\}`, "g"), "$1");
135
- } else {
136
- prompt = prompt.replace(new RegExp(`\\{\\{#${t}\\}\\}[\\s\\S]*?\\{\\{\\/${t}\\}\\}`, "g"), "");
137
- }
138
- }
139
-
140
- // NO_TRACKER — enable if no tracker configured
141
- if (!tracker) {
142
- prompt = prompt.replace(/\{\{#NO_TRACKER\}\}([\s\S]*?)\{\{\/NO_TRACKER\}\}/g, "$1");
143
- } else {
144
- prompt = prompt.replace(/\{\{#NO_TRACKER\}\}[\s\S]*?\{\{\/NO_TRACKER\}\}/g, "");
145
- }
146
-
147
- // Jira-specific variables
148
- if (config.jira?.baseUrl) {
149
- prompt = prompt.replace(/\{\{JIRA_BASE_URL\}\}/g, config.jira.baseUrl);
150
- }
151
-
152
- // Append custom instructions from config
153
- if (config.instructions) {
154
- prompt += `\n\n## ADDITIONAL INSTRUCTIONS\n\n${config.instructions}\n`;
155
- }
156
-
157
87
  // --- AgentDesk WebSocket config ---
158
88
  const AGENTDESK_URL = process.env.AGENTDESK_URL || "wss://agentdesk.live/ws/agent";
159
89
  const sessionId = `${taskId}-${randomUUID().slice(0, 8)}`;
160
90
  const inboxUrl = `${agentdeskServer}/api/sessions/${sessionId}/inbox`;
161
91
  const sessionUrl = `${agentdeskServer}/sessions/${sessionId}`;
162
92
 
163
- // Inject URLs into prompt
164
- prompt = prompt.replace(/\{\{AGENTDESK_INBOX_URL\}\}/g, inboxUrl);
165
- prompt = prompt.replace(/\{\{SESSION_URL\}\}/g, sessionUrl);
166
-
167
- // Merge declared agents from config into project for context generation
168
- if (config.projectAgents?.length) {
169
- project.configAgents = config.projectAgents.map(a => ({
170
- ...a,
171
- type: a.type || "declared",
172
- source: ".agentdesk.json",
173
- }));
174
- }
93
+ // Build prompt using shared builder
94
+ const fullPrompt = buildPrompt({
95
+ taskId, taskLink, description, createTask, tracker, config, project,
96
+ teamSections, inboxUrl, sessionUrl,
97
+ });
175
98
 
176
- // Append project context and current time
177
- const context = generateContext(project);
178
- const now = new Date();
179
- const timeInfo = `Current date/time: ${now.toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" })} ${now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`;
180
- const fullPrompt = `${prompt}\n\n---\n\n## PROJECT CONTEXT\n\n${context}\n\n${timeInfo}`;
181
99
  let vizWs = null;
182
100
  let vizConnected = false;
183
101
  const vizQueue = [];
@@ -275,48 +193,9 @@ export async function runTeam(taskId, opts = {}) {
275
193
  process.on("SIGINT", () => { try { child.kill(); } catch {} process.exit(1); });
276
194
  process.on("SIGTERM", () => { try { child.kill(); } catch {} process.exit(1); });
277
195
 
278
- // --- Stream parsing ---
279
- const PHASE_NAMES = ["INTAKE", "BRAINSTORM", "PLANNING", "EXECUTION", "REVIEW"];
280
-
281
- // Build agent name regex dynamically from resolved team
282
- const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
283
- const agentNames = teamSections.names.map(escapeRegex).join("|");
284
- const agentRegex = new RegExp(`^(${agentNames})\\s*[●◆▲■◈☾✦*]*\\s*:?\\s*`, "i");
285
-
286
- function detectAgent(text) {
287
- // Strip all badge decoration characters from the start
288
- const stripped = text.replace(/^[●◆▲■◈☾✦*\-─—\s]+/, '');
289
- const match = stripped.match(agentRegex);
290
- if (match) {
291
- const raw = match[1];
292
- // Preserve original casing from team config
293
- const name = teamSections.names.find(n => n.toLowerCase() === raw.toLowerCase()) || raw;
294
- return { name, rest: stripped.slice(match[0].length) };
295
- }
296
- return null;
297
- }
298
-
299
- function detectTag(text) {
300
- if (/\[ARGUE\]/i.test(text)) return "ARGUE";
301
- if (/\[AGREE\]/i.test(text)) return "AGREE";
302
- if (/\[THINK\]/i.test(text)) return "THINK";
303
- if (/\[ACT\]/i.test(text)) return "ACT";
304
- return "SAY";
305
- }
306
-
307
- function detectPhase(text) {
308
- const upper = text.trim().toUpperCase();
309
- if (upper.startsWith("PHASE") || upper.startsWith("---") || upper.startsWith("#")) {
310
- for (const p of PHASE_NAMES) {
311
- if (upper.includes(p)) return p;
312
- }
313
- }
314
- return null;
315
- }
316
-
317
- let stepCount = 0;
318
- const startTime = Date.now();
319
- let lastAgent = "Jane";
196
+ // --- Stream parsing via shared parser ---
197
+ console.log("\n━━━ INTAKE ━━━\n");
198
+ vizSend({ type: "phase:change", phase: "INTAKE" });
320
199
 
321
200
  function timestamp() {
322
201
  const d = new Date();
@@ -324,150 +203,58 @@ export async function runTeam(taskId, opts = {}) {
324
203
  .map(n => String(n).padStart(2, "0")).join(":");
325
204
  }
326
205
 
327
- console.log("\n━━━ INTAKE ━━━\n");
328
- vizSend({ type: "phase:change", phase: "INTAKE" });
329
-
330
- let totalInputTokens = 0;
331
- let totalOutputTokens = 0;
332
-
333
- const rl = createInterface({ input: child.stdout });
334
-
335
- for await (const line of rl) {
336
- try {
337
- const event = JSON.parse(line);
338
-
339
- // Track token usage from any event that reports it
340
- if (event.usage) {
341
- if (event.usage.input_tokens) totalInputTokens = Math.max(totalInputTokens, event.usage.input_tokens);
342
- if (event.usage.output_tokens) totalOutputTokens += (event.usage.output_tokens_delta || 0);
343
- }
344
- if (event.message?.usage) {
345
- if (event.message.usage.input_tokens) totalInputTokens = Math.max(totalInputTokens, event.message.usage.input_tokens);
346
- if (event.message.usage.output_tokens) totalOutputTokens = Math.max(totalOutputTokens, event.message.usage.output_tokens);
347
- }
348
-
349
- if (event.type === "assistant" && event.message?.content) {
350
- for (const block of event.message.content) {
351
- if (block.type === "text" && block.text.trim()) {
352
- const text = block.text.trim().replace(/\*+/g, "");
353
-
354
- const phase = detectPhase(text);
355
- if (phase) {
356
- console.log(`\n━━━ ${phase} ━━━\n`);
357
- vizSend({ type: "phase:change", phase });
358
- continue;
359
- }
360
-
361
- // Detect TASK_ID announcement from Jane
362
- const taskIdMatch = text.match(/TASK_ID:\s*(\S+)/);
363
- if (taskIdMatch && createTask) {
364
- const newTaskId = taskIdMatch[1];
365
- taskId = newTaskId;
366
- // Build task link for the real task
367
- if (tracker === "linear" && config.linear?.workspace) {
368
- taskLink = `https://linear.app/${config.linear.workspace}/issue/${newTaskId}`;
369
- } else if (tracker === "jira" && config.jira?.baseUrl) {
370
- taskLink = `${config.jira.baseUrl}/browse/${newTaskId}`;
371
- } else if (tracker === "github") {
372
- const repo = config.github?.repo || "";
373
- if (repo) taskLink = `https://github.com/${repo}/issues/${newTaskId}`;
374
- }
375
- console.log(`\n # Task: ${newTaskId}${taskLink ? ` (${taskLink})` : ""}\n`);
376
- vizSend({ type: "session:update", taskId: newTaskId, taskLink, title: description || newTaskId });
377
- }
378
-
379
- const textLines = text.split("\n");
380
- let i = 0;
381
- while (i < textLines.length) {
382
- const currentLine = textLines[i].trim();
383
- if (!currentLine) { i++; continue; }
384
-
385
- const detected = detectAgent(currentLine);
386
- if (detected) {
387
- let msg = detected.rest;
388
- while (i + 1 < textLines.length && !detectAgent(textLines[i + 1].trim())) {
389
- i++;
390
- const next = textLines[i].trim();
391
- if (next) msg += "\n" + next;
392
- }
393
- const tag = detectTag(msg);
394
- const cleanMsg = msg.replace(/\[(SAY|ACT|THINK|AGREE|ARGUE)\]\s*/gi, "");
395
- lastAgent = detected.name;
396
-
397
- console.log(`${timestamp()} ${detected.name} [${tag}] ${cleanMsg}\n`);
398
- vizSend({ type: "agent:message", agent: detected.name, tag, message: cleanMsg });
399
- } else {
400
- console.log(` ${currentLine}`);
401
- vizSend({ type: "agent:message", agent: lastAgent, tag: "SAY", message: currentLine });
402
- }
403
- i++;
404
- }
405
- }
406
- }
407
- }
408
-
409
- if (event.type === "tool_use") {
410
- const name = event.name || event.tool_name;
411
- stepCount++;
412
-
413
- let description = "Running command...";
414
- if (name === "Bash") {
415
- const cmd = event.input?.command || "";
416
- if (cmd.includes("curl") && cmd.includes("linear")) description = "Calling Linear API...";
417
- else if (cmd.includes("curl")) description = "Making API request...";
418
- const shortCmd = cmd.length > 80 ? cmd.slice(0, 80) + "..." : cmd;
419
- console.log(` ${lastAgent} [ACT] ${description}\n $ ${shortCmd}`);
420
- } else if (name === "Read") {
421
- const shortPath = (event.input?.file_path || "").split("/").slice(-3).join("/");
422
- description = `Reading ${shortPath}`;
423
- console.log(` ${lastAgent} [ACT] ${description}`);
424
- } else if (name === "Edit" || name === "Write") {
425
- const shortPath = (event.input?.file_path || "").split("/").slice(-3).join("/");
426
- description = `${name === "Edit" ? "Editing" : "Writing"} ${shortPath}`;
427
- console.log(` ${lastAgent} [ACT] ${description}`);
428
- } else if (name === "Glob" || name === "Grep") {
429
- description = `Searching ${event.input?.pattern || ""}`;
430
- console.log(` ${lastAgent} [ACT] ${description}`);
431
- }
432
-
433
- vizSend({ type: "tool:use", agent: lastAgent, tool: name, description });
434
- }
435
-
436
- if (event.type === "tool_result") {
437
- const output = event.content || event.output;
438
- let text = "";
439
- if (typeof output === "string") text = output.trim();
440
- else if (Array.isArray(output)) {
441
- text = output.filter(b => b.type === "text").map(b => b.text.trim()).join("\n");
442
- }
443
-
444
- const hasError = text && (text.toLowerCase().includes("error") || text.toLowerCase().includes("failed"));
445
- if (text && text.length <= 300) {
446
- console.log(` ${hasError ? "✗" : "✓"} ${text}`);
206
+ const { parseLine } = createStreamParser({
207
+ teamNames: teamSections.names,
208
+ callbacks: {
209
+ onPhaseChange({ phase }) {
210
+ console.log(`\n━━━ ${phase} ━━━\n`);
211
+ vizSend({ type: "phase:change", phase });
212
+ },
213
+ onAgentMessage({ agent, tag, message }) {
214
+ console.log(`${timestamp()} ${agent} [${tag}] ${message}\n`);
215
+ vizSend({ type: "agent:message", agent, tag, message });
216
+ },
217
+ onToolUse({ agent, tool, description }) {
218
+ if (tool === "Bash") {
219
+ console.log(` ${agent} [ACT] ${description}`);
447
220
  } else {
448
- console.log(` ${hasError ? "✗" : "✓"} Done${text ? ` (${text.length} chars)` : ""}`);
221
+ console.log(` ${agent} [ACT] ${description}`);
449
222
  }
450
-
451
- vizSend({ type: "tool:result", success: !hasError, summary: text?.length > 300 ? `Done (${text.length} chars)` : text || "Done" });
452
- }
453
-
454
- if (event.type === "result") {
455
- // Final usage from result event
456
- if (event.usage) {
457
- if (event.usage.input_tokens) totalInputTokens = Math.max(totalInputTokens, event.usage.input_tokens);
458
- if (event.usage.output_tokens) totalOutputTokens = Math.max(totalOutputTokens, event.usage.output_tokens);
223
+ vizSend({ type: "tool:use", agent, tool, description });
224
+ },
225
+ onToolResult({ success, summary }) {
226
+ console.log(` ${success ? "✓" : "✗"} ${summary}`);
227
+ vizSend({ type: "tool:result", success, summary });
228
+ },
229
+ onSessionUpdate({ taskId: newTaskId }) {
230
+ if (createTask) {
231
+ taskId = newTaskId;
232
+ if (tracker === "linear" && config.linear?.workspace) {
233
+ taskLink = `https://linear.app/${config.linear.workspace}/issue/${newTaskId}`;
234
+ } else if (tracker === "jira" && config.jira?.baseUrl) {
235
+ taskLink = `${config.jira.baseUrl}/browse/${newTaskId}`;
236
+ } else if (tracker === "github") {
237
+ const repo = config.github?.repo || "";
238
+ if (repo) taskLink = `https://github.com/${repo}/issues/${newTaskId}`;
239
+ }
240
+ console.log(`\n # Task: ${newTaskId}${taskLink ? ` (${taskLink})` : ""}\n`);
241
+ vizSend({ type: "session:update", taskId: newTaskId, taskLink, title: description || newTaskId });
459
242
  }
460
- const totalTokens = totalInputTokens + totalOutputTokens;
461
- const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);
243
+ },
244
+ onSessionEnd({ duration, steps, inputTokens, outputTokens }) {
245
+ const totalTokens = inputTokens + outputTokens;
462
246
  console.log(`\n━━━ DONE ━━━`);
463
- console.log(` ${totalTime}s | ${stepCount} steps${totalTokens ? ` | ${totalTokens.toLocaleString()} tokens` : ""}\n`);
464
- vizSend({ type: "session:end", duration: `${totalTime}s`, steps: stepCount, inputTokens: totalInputTokens, outputTokens: totalOutputTokens });
465
- clearInterval(inboxTimer);
247
+ console.log(` ${duration} | ${steps} steps${totalTokens ? ` | ${totalTokens.toLocaleString()} tokens` : ""}\n`);
248
+ vizSend({ type: "session:end", duration, steps, inputTokens, outputTokens });
466
249
  setTimeout(() => { try { vizWs?.close(); } catch {} }, 500);
467
- }
468
- } catch {
469
- // skip non-JSON lines
470
- }
250
+ },
251
+ },
252
+ });
253
+
254
+ const rl = createInterface({ input: child.stdout });
255
+
256
+ for await (const line of rl) {
257
+ parseLine(line);
471
258
  }
472
259
 
473
260
  return new Promise(resolve => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kendoo.agentdesk/agentdesk",
3
- "version": "0.6.8",
3
+ "version": "0.7.0",
4
4
  "description": "AI team orchestrator for Claude Code — run collaborative agent sessions from your terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -50,8 +50,5 @@
50
50
  "team"
51
51
  ],
52
52
  "license": "MIT",
53
- "repository": {
54
- "type": "git",
55
- "url": "https://github.com/kendoo-rd/agentdesk.git"
56
- }
53
+ "homepage": "https://agentdesk.live"
57
54
  }