@bulletproof-sh/ctrl-daemon 0.0.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.
Potentially problematic release.
This version of @bulletproof-sh/ctrl-daemon might be problematic. Click here for more details.
- package/bin/ctrl-daemon.js +27 -0
- package/dist/index.js +671 -0
- package/package.json +26 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const entry = join(__dirname, "..", "dist", "index.js");
|
|
8
|
+
|
|
9
|
+
// Check for bun in PATH
|
|
10
|
+
try {
|
|
11
|
+
execFileSync("bun", ["--version"], { stdio: "ignore" });
|
|
12
|
+
} catch {
|
|
13
|
+
console.error(
|
|
14
|
+
"ctrl-daemon requires Bun to run.\n\nInstall Bun: https://bun.sh\n curl -fsSL https://bun.sh/install | bash",
|
|
15
|
+
);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Forward all CLI args to the daemon entry point
|
|
20
|
+
const args = ["run", entry, ...process.argv.slice(2)];
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
execFileSync("bun", args, { stdio: "inherit" });
|
|
24
|
+
} catch (err) {
|
|
25
|
+
// execFileSync throws on non-zero exit — just propagate the code
|
|
26
|
+
process.exit(err.status ?? 1);
|
|
27
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/index.ts
|
|
3
|
+
import * as path3 from "path";
|
|
4
|
+
|
|
5
|
+
// src/projectScanner.ts
|
|
6
|
+
import * as fs2 from "fs";
|
|
7
|
+
import * as path2 from "path";
|
|
8
|
+
|
|
9
|
+
// ../shared/src/transcript/constants.ts
|
|
10
|
+
var FILE_WATCHER_POLL_INTERVAL_MS = 2000;
|
|
11
|
+
var PROJECT_SCAN_INTERVAL_MS = 1000;
|
|
12
|
+
var TOOL_DONE_DELAY_MS = 300;
|
|
13
|
+
var PERMISSION_TIMER_DELAY_MS = 7000;
|
|
14
|
+
var TEXT_IDLE_DELAY_MS = 5000;
|
|
15
|
+
var AGENT_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
16
|
+
var BASH_COMMAND_DISPLAY_MAX_LENGTH = 30;
|
|
17
|
+
var TASK_DESCRIPTION_DISPLAY_MAX_LENGTH = 40;
|
|
18
|
+
|
|
19
|
+
// src/sessionWatcher.ts
|
|
20
|
+
import * as fs from "fs";
|
|
21
|
+
// ../shared/src/transcript/timerManager.ts
|
|
22
|
+
function clearAgentActivity(agent, agentId, permissionTimers, broadcast) {
|
|
23
|
+
if (!agent)
|
|
24
|
+
return;
|
|
25
|
+
agent.activeToolIds.clear();
|
|
26
|
+
agent.activeToolStatuses.clear();
|
|
27
|
+
agent.activeToolNames.clear();
|
|
28
|
+
agent.activeSubagentToolIds.clear();
|
|
29
|
+
agent.activeSubagentToolNames.clear();
|
|
30
|
+
agent.isWaiting = false;
|
|
31
|
+
agent.permissionSent = false;
|
|
32
|
+
cancelPermissionTimer(agentId, permissionTimers);
|
|
33
|
+
broadcast({ type: "agentToolsClear", id: agentId });
|
|
34
|
+
broadcast({ type: "agentStatus", id: agentId, status: "active" });
|
|
35
|
+
}
|
|
36
|
+
function cancelWaitingTimer(agentId, waitingTimers) {
|
|
37
|
+
const timer = waitingTimers.get(agentId);
|
|
38
|
+
if (timer) {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
waitingTimers.delete(agentId);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function startWaitingTimer(agentId, delayMs, agents, waitingTimers, broadcast) {
|
|
44
|
+
cancelWaitingTimer(agentId, waitingTimers);
|
|
45
|
+
const timer = setTimeout(() => {
|
|
46
|
+
waitingTimers.delete(agentId);
|
|
47
|
+
const agent = agents.get(agentId);
|
|
48
|
+
if (agent) {
|
|
49
|
+
agent.isWaiting = true;
|
|
50
|
+
}
|
|
51
|
+
broadcast({
|
|
52
|
+
type: "agentStatus",
|
|
53
|
+
id: agentId,
|
|
54
|
+
status: "waiting"
|
|
55
|
+
});
|
|
56
|
+
}, delayMs);
|
|
57
|
+
waitingTimers.set(agentId, timer);
|
|
58
|
+
}
|
|
59
|
+
function cancelPermissionTimer(agentId, permissionTimers) {
|
|
60
|
+
const timer = permissionTimers.get(agentId);
|
|
61
|
+
if (timer) {
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
permissionTimers.delete(agentId);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function startPermissionTimer(agentId, agents, permissionTimers, permissionExemptTools, broadcast) {
|
|
67
|
+
cancelPermissionTimer(agentId, permissionTimers);
|
|
68
|
+
const timer = setTimeout(() => {
|
|
69
|
+
permissionTimers.delete(agentId);
|
|
70
|
+
const agent = agents.get(agentId);
|
|
71
|
+
if (!agent)
|
|
72
|
+
return;
|
|
73
|
+
let hasNonExempt = false;
|
|
74
|
+
for (const toolId of agent.activeToolIds) {
|
|
75
|
+
const toolName = agent.activeToolNames.get(toolId);
|
|
76
|
+
if (!permissionExemptTools.has(toolName || "")) {
|
|
77
|
+
hasNonExempt = true;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const stuckSubagentParentToolIds = [];
|
|
82
|
+
for (const [parentToolId, subToolNames] of agent.activeSubagentToolNames) {
|
|
83
|
+
for (const [, toolName] of subToolNames) {
|
|
84
|
+
if (!permissionExemptTools.has(toolName)) {
|
|
85
|
+
stuckSubagentParentToolIds.push(parentToolId);
|
|
86
|
+
hasNonExempt = true;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (hasNonExempt) {
|
|
92
|
+
agent.permissionSent = true;
|
|
93
|
+
console.log(`[ctrl] Agent ${agentId}: possible permission wait detected`);
|
|
94
|
+
broadcast({
|
|
95
|
+
type: "agentToolPermission",
|
|
96
|
+
id: agentId
|
|
97
|
+
});
|
|
98
|
+
for (const parentToolId of stuckSubagentParentToolIds) {
|
|
99
|
+
broadcast({
|
|
100
|
+
type: "subagentToolPermission",
|
|
101
|
+
id: agentId,
|
|
102
|
+
parentToolId
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}, PERMISSION_TIMER_DELAY_MS);
|
|
107
|
+
permissionTimers.set(agentId, timer);
|
|
108
|
+
}
|
|
109
|
+
// ../shared/src/transcript/transcriptParser.ts
|
|
110
|
+
import * as path from "path";
|
|
111
|
+
var PERMISSION_EXEMPT_TOOLS = new Set(["Task", "AskUserQuestion"]);
|
|
112
|
+
function formatToolStatus(toolName, input) {
|
|
113
|
+
const base = (p) => typeof p === "string" ? path.basename(p) : "";
|
|
114
|
+
switch (toolName) {
|
|
115
|
+
case "Read":
|
|
116
|
+
return `Reading ${base(input.file_path)}`;
|
|
117
|
+
case "Edit":
|
|
118
|
+
return `Editing ${base(input.file_path)}`;
|
|
119
|
+
case "Write":
|
|
120
|
+
return `Writing ${base(input.file_path)}`;
|
|
121
|
+
case "Bash": {
|
|
122
|
+
const cmd = input.command || "";
|
|
123
|
+
return `Running: ${cmd.length > BASH_COMMAND_DISPLAY_MAX_LENGTH ? cmd.slice(0, BASH_COMMAND_DISPLAY_MAX_LENGTH) + "\u2026" : cmd}`;
|
|
124
|
+
}
|
|
125
|
+
case "Glob":
|
|
126
|
+
return "Searching files";
|
|
127
|
+
case "Grep":
|
|
128
|
+
return "Searching code";
|
|
129
|
+
case "WebFetch":
|
|
130
|
+
return "Fetching web content";
|
|
131
|
+
case "WebSearch":
|
|
132
|
+
return "Searching the web";
|
|
133
|
+
case "Task": {
|
|
134
|
+
const desc = typeof input.description === "string" ? input.description : "";
|
|
135
|
+
return desc ? `Subtask: ${desc.length > TASK_DESCRIPTION_DISPLAY_MAX_LENGTH ? desc.slice(0, TASK_DESCRIPTION_DISPLAY_MAX_LENGTH) + "\u2026" : desc}` : "Running subtask";
|
|
136
|
+
}
|
|
137
|
+
case "AskUserQuestion":
|
|
138
|
+
return "Waiting for your answer";
|
|
139
|
+
case "EnterPlanMode":
|
|
140
|
+
return "Planning";
|
|
141
|
+
case "NotebookEdit":
|
|
142
|
+
return `Editing notebook`;
|
|
143
|
+
default:
|
|
144
|
+
return `Using ${toolName}`;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function processTranscriptLine(agentId, line, agents, waitingTimers, permissionTimers, broadcast) {
|
|
148
|
+
const agent = agents.get(agentId);
|
|
149
|
+
if (!agent)
|
|
150
|
+
return;
|
|
151
|
+
try {
|
|
152
|
+
const record = JSON.parse(line);
|
|
153
|
+
if (record.type === "assistant" && Array.isArray(record.message?.content)) {
|
|
154
|
+
const blocks = record.message.content;
|
|
155
|
+
const hasToolUse = blocks.some((b) => b.type === "tool_use");
|
|
156
|
+
if (hasToolUse) {
|
|
157
|
+
cancelWaitingTimer(agentId, waitingTimers);
|
|
158
|
+
agent.isWaiting = false;
|
|
159
|
+
agent.hadToolsInTurn = true;
|
|
160
|
+
broadcast({ type: "agentStatus", id: agentId, status: "active" });
|
|
161
|
+
let hasNonExemptTool = false;
|
|
162
|
+
for (const block of blocks) {
|
|
163
|
+
if (block.type === "tool_use" && block.id) {
|
|
164
|
+
const toolName = block.name || "";
|
|
165
|
+
const status = formatToolStatus(toolName, block.input || {});
|
|
166
|
+
console.log(`[ctrl] Agent ${agentId} tool start: ${block.id} ${status}`);
|
|
167
|
+
agent.activeToolIds.add(block.id);
|
|
168
|
+
agent.activeToolStatuses.set(block.id, status);
|
|
169
|
+
agent.activeToolNames.set(block.id, toolName);
|
|
170
|
+
if (!PERMISSION_EXEMPT_TOOLS.has(toolName)) {
|
|
171
|
+
hasNonExemptTool = true;
|
|
172
|
+
}
|
|
173
|
+
broadcast({
|
|
174
|
+
type: "agentToolStart",
|
|
175
|
+
id: agentId,
|
|
176
|
+
toolId: block.id,
|
|
177
|
+
status
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (hasNonExemptTool) {
|
|
182
|
+
startPermissionTimer(agentId, agents, permissionTimers, PERMISSION_EXEMPT_TOOLS, broadcast);
|
|
183
|
+
}
|
|
184
|
+
} else if (blocks.some((b) => b.type === "text") && !agent.hadToolsInTurn) {
|
|
185
|
+
startWaitingTimer(agentId, TEXT_IDLE_DELAY_MS, agents, waitingTimers, broadcast);
|
|
186
|
+
}
|
|
187
|
+
} else if (record.type === "progress") {
|
|
188
|
+
processProgressRecord(agentId, record, agents, waitingTimers, permissionTimers, broadcast);
|
|
189
|
+
} else if (record.type === "user") {
|
|
190
|
+
const content = record.message?.content;
|
|
191
|
+
if (Array.isArray(content)) {
|
|
192
|
+
const blocks = content;
|
|
193
|
+
const hasToolResult = blocks.some((b) => b.type === "tool_result");
|
|
194
|
+
if (hasToolResult) {
|
|
195
|
+
for (const block of blocks) {
|
|
196
|
+
if (block.type === "tool_result" && block.tool_use_id) {
|
|
197
|
+
console.log(`[ctrl] Agent ${agentId} tool done: ${block.tool_use_id}`);
|
|
198
|
+
const completedToolId = block.tool_use_id;
|
|
199
|
+
if (agent.activeToolNames.get(completedToolId) === "Task") {
|
|
200
|
+
agent.activeSubagentToolIds.delete(completedToolId);
|
|
201
|
+
agent.activeSubagentToolNames.delete(completedToolId);
|
|
202
|
+
broadcast({
|
|
203
|
+
type: "subagentClear",
|
|
204
|
+
id: agentId,
|
|
205
|
+
parentToolId: completedToolId
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
agent.activeToolIds.delete(completedToolId);
|
|
209
|
+
agent.activeToolStatuses.delete(completedToolId);
|
|
210
|
+
agent.activeToolNames.delete(completedToolId);
|
|
211
|
+
const toolId = completedToolId;
|
|
212
|
+
setTimeout(() => {
|
|
213
|
+
broadcast({
|
|
214
|
+
type: "agentToolDone",
|
|
215
|
+
id: agentId,
|
|
216
|
+
toolId
|
|
217
|
+
});
|
|
218
|
+
}, TOOL_DONE_DELAY_MS);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (agent.activeToolIds.size === 0) {
|
|
222
|
+
agent.hadToolsInTurn = false;
|
|
223
|
+
}
|
|
224
|
+
} else {
|
|
225
|
+
cancelWaitingTimer(agentId, waitingTimers);
|
|
226
|
+
clearAgentActivity(agent, agentId, permissionTimers, broadcast);
|
|
227
|
+
agent.hadToolsInTurn = false;
|
|
228
|
+
}
|
|
229
|
+
} else if (typeof content === "string" && content.trim()) {
|
|
230
|
+
cancelWaitingTimer(agentId, waitingTimers);
|
|
231
|
+
clearAgentActivity(agent, agentId, permissionTimers, broadcast);
|
|
232
|
+
agent.hadToolsInTurn = false;
|
|
233
|
+
}
|
|
234
|
+
} else if (record.type === "system" && record.subtype === "turn_duration") {
|
|
235
|
+
cancelWaitingTimer(agentId, waitingTimers);
|
|
236
|
+
cancelPermissionTimer(agentId, permissionTimers);
|
|
237
|
+
if (agent.activeToolIds.size > 0) {
|
|
238
|
+
agent.activeToolIds.clear();
|
|
239
|
+
agent.activeToolStatuses.clear();
|
|
240
|
+
agent.activeToolNames.clear();
|
|
241
|
+
agent.activeSubagentToolIds.clear();
|
|
242
|
+
agent.activeSubagentToolNames.clear();
|
|
243
|
+
broadcast({ type: "agentToolsClear", id: agentId });
|
|
244
|
+
}
|
|
245
|
+
agent.isWaiting = true;
|
|
246
|
+
agent.permissionSent = false;
|
|
247
|
+
agent.hadToolsInTurn = false;
|
|
248
|
+
broadcast({
|
|
249
|
+
type: "agentStatus",
|
|
250
|
+
id: agentId,
|
|
251
|
+
status: "waiting"
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
} catch {}
|
|
255
|
+
}
|
|
256
|
+
function processProgressRecord(agentId, record, agents, waitingTimers, permissionTimers, broadcast) {
|
|
257
|
+
const agent = agents.get(agentId);
|
|
258
|
+
if (!agent)
|
|
259
|
+
return;
|
|
260
|
+
const parentToolId = record.parentToolUseID;
|
|
261
|
+
if (!parentToolId)
|
|
262
|
+
return;
|
|
263
|
+
const data = record.data;
|
|
264
|
+
if (!data)
|
|
265
|
+
return;
|
|
266
|
+
const dataType = data.type;
|
|
267
|
+
if (dataType === "bash_progress" || dataType === "mcp_progress") {
|
|
268
|
+
if (agent.activeToolIds.has(parentToolId)) {
|
|
269
|
+
startPermissionTimer(agentId, agents, permissionTimers, PERMISSION_EXEMPT_TOOLS, broadcast);
|
|
270
|
+
}
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (agent.activeToolNames.get(parentToolId) !== "Task")
|
|
274
|
+
return;
|
|
275
|
+
const msg = data.message;
|
|
276
|
+
if (!msg)
|
|
277
|
+
return;
|
|
278
|
+
const msgType = msg.type;
|
|
279
|
+
const innerMsg = msg.message;
|
|
280
|
+
const content = innerMsg?.content;
|
|
281
|
+
if (!Array.isArray(content))
|
|
282
|
+
return;
|
|
283
|
+
if (msgType === "assistant") {
|
|
284
|
+
let hasNonExemptSubTool = false;
|
|
285
|
+
for (const block of content) {
|
|
286
|
+
if (block.type === "tool_use" && block.id) {
|
|
287
|
+
const toolName = block.name || "";
|
|
288
|
+
const status = formatToolStatus(toolName, block.input || {});
|
|
289
|
+
console.log(`[ctrl] Agent ${agentId} subagent tool start: ${block.id} ${status} (parent: ${parentToolId})`);
|
|
290
|
+
let subTools = agent.activeSubagentToolIds.get(parentToolId);
|
|
291
|
+
if (!subTools) {
|
|
292
|
+
subTools = new Set;
|
|
293
|
+
agent.activeSubagentToolIds.set(parentToolId, subTools);
|
|
294
|
+
}
|
|
295
|
+
subTools.add(block.id);
|
|
296
|
+
let subNames = agent.activeSubagentToolNames.get(parentToolId);
|
|
297
|
+
if (!subNames) {
|
|
298
|
+
subNames = new Map;
|
|
299
|
+
agent.activeSubagentToolNames.set(parentToolId, subNames);
|
|
300
|
+
}
|
|
301
|
+
subNames.set(block.id, toolName);
|
|
302
|
+
if (!PERMISSION_EXEMPT_TOOLS.has(toolName)) {
|
|
303
|
+
hasNonExemptSubTool = true;
|
|
304
|
+
}
|
|
305
|
+
broadcast({
|
|
306
|
+
type: "subagentToolStart",
|
|
307
|
+
id: agentId,
|
|
308
|
+
parentToolId,
|
|
309
|
+
toolId: block.id,
|
|
310
|
+
status
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (hasNonExemptSubTool) {
|
|
315
|
+
startPermissionTimer(agentId, agents, permissionTimers, PERMISSION_EXEMPT_TOOLS, broadcast);
|
|
316
|
+
}
|
|
317
|
+
} else if (msgType === "user") {
|
|
318
|
+
for (const block of content) {
|
|
319
|
+
if (block.type === "tool_result" && block.tool_use_id) {
|
|
320
|
+
console.log(`[ctrl] Agent ${agentId} subagent tool done: ${block.tool_use_id} (parent: ${parentToolId})`);
|
|
321
|
+
const subTools = agent.activeSubagentToolIds.get(parentToolId);
|
|
322
|
+
if (subTools) {
|
|
323
|
+
subTools.delete(block.tool_use_id);
|
|
324
|
+
}
|
|
325
|
+
const subNames = agent.activeSubagentToolNames.get(parentToolId);
|
|
326
|
+
if (subNames) {
|
|
327
|
+
subNames.delete(block.tool_use_id);
|
|
328
|
+
}
|
|
329
|
+
const toolId = block.tool_use_id;
|
|
330
|
+
setTimeout(() => {
|
|
331
|
+
broadcast({
|
|
332
|
+
type: "subagentToolDone",
|
|
333
|
+
id: agentId,
|
|
334
|
+
parentToolId,
|
|
335
|
+
toolId
|
|
336
|
+
});
|
|
337
|
+
}, 300);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
let stillHasNonExempt = false;
|
|
341
|
+
for (const [, subNames] of agent.activeSubagentToolNames) {
|
|
342
|
+
for (const [, toolName] of subNames) {
|
|
343
|
+
if (!PERMISSION_EXEMPT_TOOLS.has(toolName)) {
|
|
344
|
+
stillHasNonExempt = true;
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (stillHasNonExempt)
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
if (stillHasNonExempt) {
|
|
352
|
+
startPermissionTimer(agentId, agents, permissionTimers, PERMISSION_EXEMPT_TOOLS, broadcast);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// src/sessionWatcher.ts
|
|
357
|
+
function startSessionWatcher(agentId, filePath, agents, fileWatchers, pollingTimers, waitingTimers, permissionTimers, broadcast) {
|
|
358
|
+
try {
|
|
359
|
+
const watcher = fs.watch(filePath, () => {
|
|
360
|
+
readNewLines(agentId, agents, waitingTimers, permissionTimers, broadcast);
|
|
361
|
+
});
|
|
362
|
+
fileWatchers.set(agentId, watcher);
|
|
363
|
+
} catch (e) {
|
|
364
|
+
console.log(`[ctrl-daemon] fs.watch failed for agent ${agentId}: ${e}`);
|
|
365
|
+
}
|
|
366
|
+
const interval = setInterval(() => {
|
|
367
|
+
if (!agents.has(agentId)) {
|
|
368
|
+
clearInterval(interval);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
readNewLines(agentId, agents, waitingTimers, permissionTimers, broadcast);
|
|
372
|
+
}, FILE_WATCHER_POLL_INTERVAL_MS);
|
|
373
|
+
pollingTimers.set(agentId, interval);
|
|
374
|
+
}
|
|
375
|
+
function readNewLines(agentId, agents, waitingTimers, permissionTimers, broadcast) {
|
|
376
|
+
const agent = agents.get(agentId);
|
|
377
|
+
if (!agent)
|
|
378
|
+
return;
|
|
379
|
+
try {
|
|
380
|
+
const stat = fs.statSync(agent.jsonlFile);
|
|
381
|
+
if (stat.size <= agent.fileOffset)
|
|
382
|
+
return;
|
|
383
|
+
const buf = Buffer.alloc(stat.size - agent.fileOffset);
|
|
384
|
+
const fd = fs.openSync(agent.jsonlFile, "r");
|
|
385
|
+
fs.readSync(fd, buf, 0, buf.length, agent.fileOffset);
|
|
386
|
+
fs.closeSync(fd);
|
|
387
|
+
agent.fileOffset = stat.size;
|
|
388
|
+
const text = agent.lineBuffer + buf.toString("utf-8");
|
|
389
|
+
const lines = text.split(`
|
|
390
|
+
`);
|
|
391
|
+
agent.lineBuffer = lines.pop() || "";
|
|
392
|
+
const hasLines = lines.some((l) => l.trim());
|
|
393
|
+
if (hasLines) {
|
|
394
|
+
agent.lastActivityAt = Date.now();
|
|
395
|
+
cancelWaitingTimer(agentId, waitingTimers);
|
|
396
|
+
cancelPermissionTimer(agentId, permissionTimers);
|
|
397
|
+
if (agent.permissionSent) {
|
|
398
|
+
agent.permissionSent = false;
|
|
399
|
+
broadcast({ type: "agentToolPermissionClear", id: agentId });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
for (const line of lines) {
|
|
403
|
+
if (!line.trim())
|
|
404
|
+
continue;
|
|
405
|
+
processTranscriptLine(agentId, line, agents, waitingTimers, permissionTimers, broadcast);
|
|
406
|
+
}
|
|
407
|
+
} catch (e) {
|
|
408
|
+
console.log(`[ctrl-daemon] Read error for agent ${agentId}: ${e}`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
function stopSessionWatcher(agentId, fileWatchers, pollingTimers, waitingTimers, permissionTimers) {
|
|
412
|
+
fileWatchers.get(agentId)?.close();
|
|
413
|
+
fileWatchers.delete(agentId);
|
|
414
|
+
const pt = pollingTimers.get(agentId);
|
|
415
|
+
if (pt)
|
|
416
|
+
clearInterval(pt);
|
|
417
|
+
pollingTimers.delete(agentId);
|
|
418
|
+
cancelWaitingTimer(agentId, waitingTimers);
|
|
419
|
+
cancelPermissionTimer(agentId, permissionTimers);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// src/projectScanner.ts
|
|
423
|
+
function resolveProjectHash(projectDir) {
|
|
424
|
+
return projectDir.replace(/[:\\/.]/g, "-");
|
|
425
|
+
}
|
|
426
|
+
function resolveProjectDir(projectDir, claudeHome) {
|
|
427
|
+
const home = claudeHome || path2.join(process.env.HOME || "~", ".claude");
|
|
428
|
+
const projectsDir = path2.join(home, "projects");
|
|
429
|
+
const exactHash = resolveProjectHash(projectDir);
|
|
430
|
+
const exactPath = path2.join(projectsDir, exactHash);
|
|
431
|
+
if (fs2.existsSync(exactPath))
|
|
432
|
+
return exactPath;
|
|
433
|
+
const macHash = projectDir.replace(/[:\\/._ ]/g, "-");
|
|
434
|
+
const macPath = path2.join(projectsDir, macHash);
|
|
435
|
+
if (fs2.existsSync(macPath))
|
|
436
|
+
return macPath;
|
|
437
|
+
return exactPath;
|
|
438
|
+
}
|
|
439
|
+
function collectJsonlFiles(dir) {
|
|
440
|
+
try {
|
|
441
|
+
const results = [];
|
|
442
|
+
for (const f of fs2.readdirSync(dir)) {
|
|
443
|
+
if (!f.endsWith(".jsonl"))
|
|
444
|
+
continue;
|
|
445
|
+
const filePath = path2.join(dir, f);
|
|
446
|
+
try {
|
|
447
|
+
const stat = fs2.statSync(filePath);
|
|
448
|
+
results.push({ filePath, mtimeMs: stat.mtimeMs });
|
|
449
|
+
} catch {}
|
|
450
|
+
}
|
|
451
|
+
return results;
|
|
452
|
+
} catch {
|
|
453
|
+
return [];
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
function collectAllJsonlFiles(projectsRoot) {
|
|
457
|
+
const files = [];
|
|
458
|
+
let subdirs;
|
|
459
|
+
try {
|
|
460
|
+
subdirs = fs2.readdirSync(projectsRoot);
|
|
461
|
+
} catch {
|
|
462
|
+
return [];
|
|
463
|
+
}
|
|
464
|
+
for (const subdir of subdirs) {
|
|
465
|
+
const fullPath = path2.join(projectsRoot, subdir);
|
|
466
|
+
try {
|
|
467
|
+
const stat = fs2.statSync(fullPath);
|
|
468
|
+
if (stat.isDirectory()) {
|
|
469
|
+
files.push(...collectJsonlFiles(fullPath));
|
|
470
|
+
}
|
|
471
|
+
} catch {}
|
|
472
|
+
}
|
|
473
|
+
return files;
|
|
474
|
+
}
|
|
475
|
+
function startProjectScanner(rootDir, scanAll, agents, fileWatchers, pollingTimers, waitingTimers, permissionTimers, broadcast) {
|
|
476
|
+
const knownJsonlFiles = new Set;
|
|
477
|
+
let nextAgentId = 1;
|
|
478
|
+
function scan() {
|
|
479
|
+
const now = Date.now();
|
|
480
|
+
const fileInfos = scanAll ? collectAllJsonlFiles(rootDir) : collectJsonlFiles(rootDir);
|
|
481
|
+
for (const { filePath, mtimeMs } of fileInfos) {
|
|
482
|
+
if (knownJsonlFiles.has(filePath))
|
|
483
|
+
continue;
|
|
484
|
+
knownJsonlFiles.add(filePath);
|
|
485
|
+
if (now - mtimeMs > AGENT_IDLE_TIMEOUT_MS)
|
|
486
|
+
continue;
|
|
487
|
+
const id = nextAgentId++;
|
|
488
|
+
const agentProjectDir = path2.dirname(filePath);
|
|
489
|
+
const agent = {
|
|
490
|
+
id,
|
|
491
|
+
projectDir: agentProjectDir,
|
|
492
|
+
jsonlFile: filePath,
|
|
493
|
+
fileOffset: 0,
|
|
494
|
+
lineBuffer: "",
|
|
495
|
+
activeToolIds: new Set,
|
|
496
|
+
activeToolStatuses: new Map,
|
|
497
|
+
activeToolNames: new Map,
|
|
498
|
+
activeSubagentToolIds: new Map,
|
|
499
|
+
activeSubagentToolNames: new Map,
|
|
500
|
+
isWaiting: false,
|
|
501
|
+
permissionSent: false,
|
|
502
|
+
hadToolsInTurn: false,
|
|
503
|
+
lastActivityAt: mtimeMs
|
|
504
|
+
};
|
|
505
|
+
agents.set(id, agent);
|
|
506
|
+
console.log(`[ctrl-daemon] Agent ${id}: watching ${path2.basename(filePath)}`);
|
|
507
|
+
broadcast({ type: "agentCreated", id });
|
|
508
|
+
startSessionWatcher(id, filePath, agents, fileWatchers, pollingTimers, waitingTimers, permissionTimers, broadcast);
|
|
509
|
+
readNewLines(id, agents, waitingTimers, permissionTimers, broadcast);
|
|
510
|
+
}
|
|
511
|
+
for (const [agentId, agent] of agents) {
|
|
512
|
+
const lastActivity = agent.lastActivityAt || 0;
|
|
513
|
+
const idle = now - lastActivity > AGENT_IDLE_TIMEOUT_MS;
|
|
514
|
+
const removed = !fs2.existsSync(agent.jsonlFile);
|
|
515
|
+
if (removed || idle) {
|
|
516
|
+
const reason = removed ? "JSONL removed" : "idle timeout";
|
|
517
|
+
console.log(`[ctrl-daemon] Agent ${agentId}: ${reason}, closing`);
|
|
518
|
+
stopSessionWatcher(agentId, fileWatchers, pollingTimers, waitingTimers, permissionTimers);
|
|
519
|
+
agents.delete(agentId);
|
|
520
|
+
knownJsonlFiles.delete(agent.jsonlFile);
|
|
521
|
+
broadcast({ type: "agentClosed", id: agentId });
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
const timer = setInterval(scan, PROJECT_SCAN_INTERVAL_MS);
|
|
526
|
+
scan();
|
|
527
|
+
return {
|
|
528
|
+
stop: () => clearInterval(timer)
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// src/server.ts
|
|
533
|
+
function createServer({ port, host, agents }) {
|
|
534
|
+
const clients = new Set;
|
|
535
|
+
function broadcast(msg) {
|
|
536
|
+
const json = JSON.stringify(msg);
|
|
537
|
+
for (const ws of clients) {
|
|
538
|
+
try {
|
|
539
|
+
ws.send(json);
|
|
540
|
+
} catch {}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
const server = Bun.serve({
|
|
544
|
+
port,
|
|
545
|
+
hostname: host,
|
|
546
|
+
fetch(req, server2) {
|
|
547
|
+
const url = new URL(req.url);
|
|
548
|
+
if (url.pathname === "/ws") {
|
|
549
|
+
if (server2.upgrade(req))
|
|
550
|
+
return;
|
|
551
|
+
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
552
|
+
}
|
|
553
|
+
if (url.pathname === "/health") {
|
|
554
|
+
return Response.json({
|
|
555
|
+
status: "ok",
|
|
556
|
+
agents: agents.size
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
return new Response("Not found", { status: 404 });
|
|
560
|
+
},
|
|
561
|
+
websocket: {
|
|
562
|
+
open(ws) {
|
|
563
|
+
clients.add(ws);
|
|
564
|
+
console.log(`[ctrl-daemon] WebSocket client connected (${clients.size} total)`);
|
|
565
|
+
const existingAgents = [];
|
|
566
|
+
for (const [, agent] of agents) {
|
|
567
|
+
const activeTools = [];
|
|
568
|
+
for (const toolId of agent.activeToolIds) {
|
|
569
|
+
activeTools.push({
|
|
570
|
+
toolId,
|
|
571
|
+
status: agent.activeToolStatuses.get(toolId) || ""
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
existingAgents.push({
|
|
575
|
+
id: agent.id,
|
|
576
|
+
isWaiting: agent.isWaiting,
|
|
577
|
+
permissionSent: agent.permissionSent,
|
|
578
|
+
activeTools
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
ws.send(JSON.stringify({ type: "existingAgents", agents: existingAgents }));
|
|
582
|
+
},
|
|
583
|
+
message(_ws, _message) {},
|
|
584
|
+
close(ws) {
|
|
585
|
+
clients.delete(ws);
|
|
586
|
+
console.log(`[ctrl-daemon] WebSocket client disconnected (${clients.size} total)`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
console.log(`[ctrl-daemon] Listening on ws://${server.hostname}:${server.port}/ws`);
|
|
591
|
+
return {
|
|
592
|
+
broadcast,
|
|
593
|
+
stop: () => server.stop()
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// src/index.ts
|
|
598
|
+
function printUsage() {
|
|
599
|
+
console.log(`Usage: ctrl-daemon [options]
|
|
600
|
+
|
|
601
|
+
Options:
|
|
602
|
+
--port <number> Port to listen on (default: 3001)
|
|
603
|
+
--host <address> Host/address to bind to (default: 0.0.0.0)
|
|
604
|
+
--project-dir <path> Watch a single project directory
|
|
605
|
+
--help, -h Show this help message
|
|
606
|
+
|
|
607
|
+
Without --project-dir, watches ALL projects in ~/.claude/projects/.
|
|
608
|
+
With --project-dir, watches only that specific project.`);
|
|
609
|
+
}
|
|
610
|
+
function parseArgs() {
|
|
611
|
+
const args = process.argv.slice(2);
|
|
612
|
+
let projectDir;
|
|
613
|
+
let port = 3001;
|
|
614
|
+
let host = "0.0.0.0";
|
|
615
|
+
for (let i = 0;i < args.length; i++) {
|
|
616
|
+
if (args[i] === "--help" || args[i] === "-h") {
|
|
617
|
+
printUsage();
|
|
618
|
+
process.exit(0);
|
|
619
|
+
} else if (args[i] === "--project-dir" && args[i + 1]) {
|
|
620
|
+
projectDir = args[++i];
|
|
621
|
+
} else if (args[i] === "--port" && args[i + 1]) {
|
|
622
|
+
port = Number.parseInt(args[++i], 10);
|
|
623
|
+
} else if (args[i] === "--host" && args[i + 1]) {
|
|
624
|
+
host = args[++i];
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return { projectDir, port, host };
|
|
628
|
+
}
|
|
629
|
+
function resolveProjectsRoot(claudeHome) {
|
|
630
|
+
const home = claudeHome || path3.join(process.env.HOME || "~", ".claude");
|
|
631
|
+
return path3.join(home, "projects");
|
|
632
|
+
}
|
|
633
|
+
function main() {
|
|
634
|
+
const { projectDir, port, host } = parseArgs();
|
|
635
|
+
const claudeHome = process.env.CLAUDE_HOME;
|
|
636
|
+
let scanDirs;
|
|
637
|
+
if (projectDir) {
|
|
638
|
+
const dir = resolveProjectDir(projectDir, claudeHome);
|
|
639
|
+
scanDirs = [dir];
|
|
640
|
+
console.log(`[ctrl-daemon] Project dir: ${projectDir}`);
|
|
641
|
+
console.log(`[ctrl-daemon] Session dir: ${dir}`);
|
|
642
|
+
} else {
|
|
643
|
+
const projectsRoot = resolveProjectsRoot(claudeHome);
|
|
644
|
+
console.log(`[ctrl-daemon] Watching all projects in: ${projectsRoot}`);
|
|
645
|
+
scanDirs = [projectsRoot];
|
|
646
|
+
}
|
|
647
|
+
const agents = new Map;
|
|
648
|
+
const fileWatchers = new Map;
|
|
649
|
+
const pollingTimers = new Map;
|
|
650
|
+
const waitingTimers = new Map;
|
|
651
|
+
const permissionTimers = new Map;
|
|
652
|
+
const server = createServer({ port, host, agents });
|
|
653
|
+
const scanAll = !projectDir;
|
|
654
|
+
const scanner = startProjectScanner(scanDirs[0], scanAll, agents, fileWatchers, pollingTimers, waitingTimers, permissionTimers, server.broadcast);
|
|
655
|
+
process.on("SIGINT", () => {
|
|
656
|
+
console.log(`
|
|
657
|
+
[ctrl-daemon] Shutting down...`);
|
|
658
|
+
scanner.stop();
|
|
659
|
+
server.stop();
|
|
660
|
+
for (const watcher of fileWatchers.values())
|
|
661
|
+
watcher.close();
|
|
662
|
+
for (const timer of pollingTimers.values())
|
|
663
|
+
clearInterval(timer);
|
|
664
|
+
for (const timer of waitingTimers.values())
|
|
665
|
+
clearTimeout(timer);
|
|
666
|
+
for (const timer of permissionTimers.values())
|
|
667
|
+
clearTimeout(timer);
|
|
668
|
+
process.exit(0);
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bulletproof-sh/ctrl-daemon",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "WebSocket daemon for ctrl — watches Claude Code sessions and broadcasts agent state",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"ctrl-daemon": "bin/ctrl-daemon.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"dist/"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"dev": "bun --watch src/index.ts",
|
|
16
|
+
"start": "bun src/index.ts",
|
|
17
|
+
"build": "bun build src/index.ts --outdir dist --target bun",
|
|
18
|
+
"check": "biome check .",
|
|
19
|
+
"prepublishOnly": "bun run build"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@biomejs/biome": "^1.9.4",
|
|
23
|
+
"@types/bun": "latest",
|
|
24
|
+
"typescript": "~5.9.3"
|
|
25
|
+
}
|
|
26
|
+
}
|