@kendoo.agentdesk/agentdesk 0.6.9 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/bin/agentdesk.mjs +6 -0
- package/cli/daemon.mjs +468 -0
- package/cli/init.mjs +4 -0
- package/cli/projects.mjs +34 -0
- package/cli/prompt.mjs +98 -0
- package/cli/stream-parser.mjs +174 -0
- package/cli/team.mjs +59 -272
- package/package.json +1 -1
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,468 @@
|
|
|
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, registerLocalProject } 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 — local registry first, then server fallback
|
|
104
|
+
const agentdeskServer = process.env.AGENTDESK_SERVER || "https://agentdesk.live";
|
|
105
|
+
let allProjects = getRegisteredProjects();
|
|
106
|
+
|
|
107
|
+
// Fallback: fetch from server if local registry is empty (pre-0.7.0 projects)
|
|
108
|
+
if (allProjects.length === 0) {
|
|
109
|
+
try {
|
|
110
|
+
const res = await fetch(`${agentdeskServer}/api/projects`, {
|
|
111
|
+
headers: { "x-api-key": apiKey },
|
|
112
|
+
signal: AbortSignal.timeout(5000),
|
|
113
|
+
});
|
|
114
|
+
if (res.ok) {
|
|
115
|
+
const serverProjects = await res.json();
|
|
116
|
+
if (Array.isArray(serverProjects) && serverProjects.length > 0) {
|
|
117
|
+
console.log(` ${dim}Syncing ${serverProjects.length} project(s) from server...${reset}`);
|
|
118
|
+
for (const sp of serverProjects) {
|
|
119
|
+
if (sp.path && existsSync(sp.path)) {
|
|
120
|
+
registerLocalProject(sp.id || sp.name, sp.name, sp.path);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
allProjects = getRegisteredProjects();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
// Server not reachable — continue with local only
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const projects = allProjects.filter(p => {
|
|
132
|
+
if (!existsSync(p.path)) return false;
|
|
133
|
+
if (!existsSync(join(p.path, ".agentdesk.json"))) return false;
|
|
134
|
+
return true;
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
if (projects.length === 0) {
|
|
138
|
+
console.log(`\n ${red}No registered projects found.${reset} Run ${cyan}agentdesk init${reset} in a project directory.\n`);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
console.log("");
|
|
143
|
+
console.log(` ${green}AgentDesk Daemon${reset}`);
|
|
144
|
+
console.log(" ━━━━━━━━━━━━━━━━━━━━━━━━");
|
|
145
|
+
console.log("");
|
|
146
|
+
console.log(` Projects:`);
|
|
147
|
+
for (const p of projects) {
|
|
148
|
+
console.log(` ${dim}•${reset} ${p.name} ${dim}(${p.path})${reset}`);
|
|
149
|
+
}
|
|
150
|
+
console.log("");
|
|
151
|
+
|
|
152
|
+
// 3. Connect WebSocket
|
|
153
|
+
const DAEMON_URL = process.env.AGENTDESK_URL
|
|
154
|
+
? process.env.AGENTDESK_URL.replace("/ws/agent", "/ws/daemon")
|
|
155
|
+
: "wss://agentdesk.live/ws/daemon";
|
|
156
|
+
|
|
157
|
+
// Enforce TLS in production — API key must not travel in cleartext
|
|
158
|
+
if (!DAEMON_URL.startsWith("wss://") && !DAEMON_URL.startsWith("ws://localhost") && !DAEMON_URL.startsWith("ws://127.0.0.1")) {
|
|
159
|
+
console.log(`\n ${red}Refusing to connect:${reset} daemon URL must use wss:// (got ${DAEMON_URL})\n`);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let ws = null;
|
|
164
|
+
let connected = false;
|
|
165
|
+
let reconnectTimer = null;
|
|
166
|
+
let heartbeatTimer = null;
|
|
167
|
+
const buffer = new RingBuffer();
|
|
168
|
+
|
|
169
|
+
// Active session state
|
|
170
|
+
let activeSession = null; // { sessionId, projectId, child, startedAt, filePathsTouched }
|
|
171
|
+
|
|
172
|
+
function send(data) {
|
|
173
|
+
if (connected && ws?.readyState === WebSocket.OPEN) {
|
|
174
|
+
ws.send(JSON.stringify(data));
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function sendBuffered(sessionId, event) {
|
|
181
|
+
const seq = buffer.push({ sessionId, event });
|
|
182
|
+
const msg = { type: "daemon:session-output", sessionId, event, seq };
|
|
183
|
+
if (!send(msg)) {
|
|
184
|
+
// Already buffered — will replay on reconnect
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function connectWs() {
|
|
189
|
+
try {
|
|
190
|
+
ws = new WebSocket(DAEMON_URL);
|
|
191
|
+
|
|
192
|
+
ws.on("open", () => {
|
|
193
|
+
send({ type: "auth", apiKey });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
ws.on("message", (raw) => {
|
|
197
|
+
let msg;
|
|
198
|
+
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
|
199
|
+
|
|
200
|
+
if (msg.type === "auth:ok") {
|
|
201
|
+
connected = true;
|
|
202
|
+
console.log(` ${green}Connected${reset} to ${dim}${DAEMON_URL}${reset}`);
|
|
203
|
+
|
|
204
|
+
// Announce projects — do NOT send local paths to the server
|
|
205
|
+
send({
|
|
206
|
+
type: "daemon:connect",
|
|
207
|
+
projects: projects.map(p => ({ id: p.id, name: p.name })),
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Replay buffered items
|
|
211
|
+
const buffered = buffer.drain();
|
|
212
|
+
if (buffered.length > 0) {
|
|
213
|
+
console.log(` ${dim}Replaying ${buffered.length} buffered events...${reset}`);
|
|
214
|
+
for (const item of buffered) {
|
|
215
|
+
send({ type: "daemon:session-output", sessionId: item.data.sessionId, event: item.data.event, seq: item.seq });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Start heartbeat
|
|
220
|
+
clearInterval(heartbeatTimer);
|
|
221
|
+
heartbeatTimer = setInterval(() => send({ type: "daemon:heartbeat" }), 30000);
|
|
222
|
+
|
|
223
|
+
console.log(` ${dim}Waiting for sessions...${reset}\n`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (msg.type === "auth:error") {
|
|
227
|
+
console.log(` ${red}Authentication failed.${reset} Run ${cyan}agentdesk login${reset} to re-authenticate.`);
|
|
228
|
+
connected = false;
|
|
229
|
+
ws.close();
|
|
230
|
+
process.exit(1);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (msg.type === "daemon:start-session") {
|
|
234
|
+
handleStartSession(msg);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (msg.type === "daemon:cancel-session") {
|
|
238
|
+
handleCancelSession(msg);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
ws.on("error", () => { connected = false; });
|
|
243
|
+
|
|
244
|
+
ws.on("close", (code) => {
|
|
245
|
+
connected = false;
|
|
246
|
+
clearInterval(heartbeatTimer);
|
|
247
|
+
|
|
248
|
+
if (code === 4001) {
|
|
249
|
+
console.log(` ${red}Authentication required.${reset} Run ${cyan}agentdesk login${reset}.`);
|
|
250
|
+
process.exit(1);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
console.log(` ${yellow}Disconnected.${reset} Reconnecting in 5s...`);
|
|
254
|
+
clearTimeout(reconnectTimer);
|
|
255
|
+
reconnectTimer = setTimeout(connectWs, 5000);
|
|
256
|
+
});
|
|
257
|
+
} catch {
|
|
258
|
+
clearTimeout(reconnectTimer);
|
|
259
|
+
reconnectTimer = setTimeout(connectWs, 10000);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 4. Session handling
|
|
264
|
+
|
|
265
|
+
async function handleStartSession({ sessionId, projectId, prompt }) {
|
|
266
|
+
// Validate project against local allowlist
|
|
267
|
+
const project = projects.find(p => p.id === projectId);
|
|
268
|
+
if (!project) {
|
|
269
|
+
console.log(` ${red}Rejected:${reset} unknown project ${dim}${projectId}${reset}`);
|
|
270
|
+
send({ type: "daemon:error", sessionId, error: `Project "${projectId}" not registered with this daemon` });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Enforce max 1 concurrent session — set flag BEFORE any async work to prevent race
|
|
275
|
+
if (activeSession) {
|
|
276
|
+
console.log(` ${red}Rejected:${reset} session already running ${dim}(${activeSession.sessionId})${reset}`);
|
|
277
|
+
send({ type: "daemon:error", sessionId, error: "Max concurrent sessions reached (1). Try again after the current session completes." });
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
// Claim the slot immediately (before any await) to prevent race conditions
|
|
281
|
+
activeSession = { sessionId, projectId, child: null, startedAt: Date.now(), filePathsTouched: new Set() };
|
|
282
|
+
|
|
283
|
+
console.log(` ${green}Starting session${reset} ${dim}${sessionId}${reset}`);
|
|
284
|
+
console.log(` Project: ${project.name} ${dim}(${project.path})${reset}`);
|
|
285
|
+
|
|
286
|
+
const startedAt = activeSession.startedAt;
|
|
287
|
+
const filePathsTouched = activeSession.filePathsTouched;
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
// Load project config
|
|
291
|
+
const detected = detectProject(project.path);
|
|
292
|
+
const projectEnv = loadDotEnv(project.path);
|
|
293
|
+
const projectApiKey = projectEnv.AGENTDESK_API_KEY || process.env.AGENTDESK_API_KEY || apiKey;
|
|
294
|
+
const config = await loadConfig(project.path, { apiKey: projectApiKey, serverUrl: agentdeskServer, projectName: project.name });
|
|
295
|
+
const tracker = config.tracker || (detected.hasLinear ? "linear" : null);
|
|
296
|
+
|
|
297
|
+
// Generate task ID
|
|
298
|
+
const taskId = prompt
|
|
299
|
+
? prompt.toLowerCase().replace(/[^a-z0-9\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 40) || `task-${Date.now().toString(36)}`
|
|
300
|
+
: `daemon-${Date.now().toString(36)}`;
|
|
301
|
+
|
|
302
|
+
// Build task link
|
|
303
|
+
let taskLink = null;
|
|
304
|
+
|
|
305
|
+
// Resolve team
|
|
306
|
+
const team = resolveTeam(config);
|
|
307
|
+
const teamSections = generateTeamPrompt(team);
|
|
308
|
+
|
|
309
|
+
const inboxUrl = `${agentdeskServer}/api/sessions/${sessionId}/inbox`;
|
|
310
|
+
const sessionUrl = `${agentdeskServer}/sessions/${sessionId}`;
|
|
311
|
+
|
|
312
|
+
// Build prompt
|
|
313
|
+
const fullPrompt = buildPrompt({
|
|
314
|
+
taskId, taskLink,
|
|
315
|
+
description: prompt || "",
|
|
316
|
+
createTask: false,
|
|
317
|
+
tracker, config,
|
|
318
|
+
project: detected,
|
|
319
|
+
teamSections, inboxUrl, sessionUrl,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// Send session:start
|
|
323
|
+
sendBuffered(sessionId, {
|
|
324
|
+
type: "session:start",
|
|
325
|
+
taskId, taskLink,
|
|
326
|
+
title: prompt || taskId,
|
|
327
|
+
project: project.name,
|
|
328
|
+
sessionNumber: 1,
|
|
329
|
+
agents: teamSections.names,
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
sendBuffered(sessionId, { type: "phase:change", phase: "INTAKE" });
|
|
333
|
+
|
|
334
|
+
// Spawn Claude — NEVER uses --dangerously-skip-permissions
|
|
335
|
+
const child = spawn(
|
|
336
|
+
"claude",
|
|
337
|
+
[
|
|
338
|
+
"-p", fullPrompt,
|
|
339
|
+
"--allowedTools", "Bash,Read,Edit,Write,Glob,Grep",
|
|
340
|
+
"--verbose",
|
|
341
|
+
"--output-format", "stream-json",
|
|
342
|
+
],
|
|
343
|
+
{
|
|
344
|
+
stdio: ["pipe", "pipe", "inherit"],
|
|
345
|
+
shell: false,
|
|
346
|
+
env: { ...process.env, ...loadDotEnv(project.path) },
|
|
347
|
+
cwd: project.path,
|
|
348
|
+
}
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
// Close stdin so claude doesn't wait for input
|
|
352
|
+
child.stdin.end();
|
|
353
|
+
|
|
354
|
+
activeSession.child = child;
|
|
355
|
+
|
|
356
|
+
// Parse stream
|
|
357
|
+
const { parseLine } = createStreamParser({
|
|
358
|
+
teamNames: teamSections.names,
|
|
359
|
+
callbacks: {
|
|
360
|
+
onPhaseChange({ phase }) {
|
|
361
|
+
sendBuffered(sessionId, { type: "phase:change", phase });
|
|
362
|
+
},
|
|
363
|
+
onAgentMessage({ agent, tag, message }) {
|
|
364
|
+
sendBuffered(sessionId, { type: "agent:message", agent, tag, message });
|
|
365
|
+
},
|
|
366
|
+
onToolUse({ agent, tool, description }) {
|
|
367
|
+
sendBuffered(sessionId, { type: "tool:use", agent, tool, description });
|
|
368
|
+
// Track file paths for metadata logging
|
|
369
|
+
const pathMatch = description.match(/(?:Reading|Editing|Writing)\s+(.+)/);
|
|
370
|
+
if (pathMatch) filePathsTouched.add(pathMatch[1]);
|
|
371
|
+
},
|
|
372
|
+
onToolResult({ success, summary }) {
|
|
373
|
+
sendBuffered(sessionId, { type: "tool:result", success, summary });
|
|
374
|
+
},
|
|
375
|
+
onSessionUpdate({ taskId: newTaskId }) {
|
|
376
|
+
sendBuffered(sessionId, { type: "session:update", taskId: newTaskId });
|
|
377
|
+
},
|
|
378
|
+
onSessionEnd({ duration, steps, inputTokens, outputTokens }) {
|
|
379
|
+
sendBuffered(sessionId, { type: "session:end", duration, steps, inputTokens, outputTokens });
|
|
380
|
+
console.log(` ${green}Session complete${reset} ${dim}${sessionId}${reset} (${duration}, ${steps} steps)`);
|
|
381
|
+
|
|
382
|
+
// Log metadata
|
|
383
|
+
logSessionMetadata(sessionId, {
|
|
384
|
+
sessionId, projectId,
|
|
385
|
+
startedAt, endedAt: Date.now(),
|
|
386
|
+
duration, exitCode: 0, steps,
|
|
387
|
+
filePathsTouched: [...filePathsTouched],
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
activeSession = null;
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
const rl = createInterface({ input: child.stdout });
|
|
396
|
+
for await (const line of rl) {
|
|
397
|
+
parseLine(line);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Handle unexpected exit (no "result" event)
|
|
401
|
+
child.on("close", (code) => {
|
|
402
|
+
if (activeSession?.sessionId === sessionId) {
|
|
403
|
+
const duration = `${((Date.now() - startedAt) / 1000).toFixed(1)}s`;
|
|
404
|
+
console.log(` ${code ? red : yellow}Session exited${reset} ${dim}${sessionId}${reset} (code ${code})`);
|
|
405
|
+
|
|
406
|
+
sendBuffered(sessionId, { type: "session:end", duration, steps: 0, inputTokens: 0, outputTokens: 0 });
|
|
407
|
+
|
|
408
|
+
logSessionMetadata(sessionId, {
|
|
409
|
+
sessionId, projectId,
|
|
410
|
+
startedAt, endedAt: Date.now(),
|
|
411
|
+
duration, exitCode: code || 0, steps: 0,
|
|
412
|
+
filePathsTouched: [...filePathsTouched],
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
activeSession = null;
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
} catch (err) {
|
|
420
|
+
console.log(` ${red}Failed to start session:${reset} ${err.message}`);
|
|
421
|
+
// Send generic error to server — don't leak internal details (paths, config, etc.)
|
|
422
|
+
send({ type: "daemon:error", sessionId, error: "Failed to start session on daemon" });
|
|
423
|
+
activeSession = null;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function killChild(child) {
|
|
428
|
+
if (!child) return;
|
|
429
|
+
try { child.kill("SIGTERM"); } catch {}
|
|
430
|
+
// Force kill if SIGTERM doesn't work within 5 seconds
|
|
431
|
+
setTimeout(() => {
|
|
432
|
+
try { if (!child.killed) child.kill("SIGKILL"); } catch {}
|
|
433
|
+
}, 5000);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function handleCancelSession({ sessionId }) {
|
|
437
|
+
if (activeSession?.sessionId === sessionId) {
|
|
438
|
+
console.log(` ${yellow}Cancelling session${reset} ${dim}${sessionId}${reset}`);
|
|
439
|
+
killChild(activeSession.child);
|
|
440
|
+
activeSession = null;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// 5. Graceful shutdown
|
|
445
|
+
|
|
446
|
+
function shutdown() {
|
|
447
|
+
console.log(`\n ${dim}Shutting down...${reset}`);
|
|
448
|
+
clearInterval(heartbeatTimer);
|
|
449
|
+
clearTimeout(reconnectTimer);
|
|
450
|
+
|
|
451
|
+
if (activeSession) {
|
|
452
|
+
killChild(activeSession.child);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
send({ type: "daemon:disconnect" });
|
|
456
|
+
try { ws?.close(); } catch {}
|
|
457
|
+
process.exit(0);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
process.on("SIGINT", shutdown);
|
|
461
|
+
process.on("SIGTERM", shutdown);
|
|
462
|
+
|
|
463
|
+
// 6. Start
|
|
464
|
+
connectWs();
|
|
465
|
+
|
|
466
|
+
// Keep process alive
|
|
467
|
+
await new Promise(() => {});
|
|
468
|
+
}
|
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`, {
|
package/cli/projects.mjs
ADDED
|
@@ -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 {
|
|
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
|
|
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
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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(` ${
|
|
221
|
+
console.log(` ${agent} [ACT] ${description}`);
|
|
449
222
|
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
461
|
-
|
|
243
|
+
},
|
|
244
|
+
onSessionEnd({ duration, steps, inputTokens, outputTokens }) {
|
|
245
|
+
const totalTokens = inputTokens + outputTokens;
|
|
462
246
|
console.log(`\n━━━ DONE ━━━`);
|
|
463
|
-
console.log(` ${
|
|
464
|
-
vizSend({ type: "session:end", duration
|
|
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
|
-
}
|
|
469
|
-
|
|
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 => {
|