@forwardimpact/basecamp 0.3.0 → 2.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.
- package/README.md +70 -85
- package/config/scheduler.json +13 -17
- package/package.json +11 -13
- package/src/basecamp.js +742 -0
- package/template/.claude/agents/chief-of-staff.md +99 -0
- package/template/.claude/agents/concierge.md +76 -0
- package/template/.claude/agents/librarian.md +61 -0
- package/template/.claude/agents/postman.md +73 -0
- package/template/.claude/settings.json +49 -0
- package/template/.claude/skills/draft-emails/SKILL.md +32 -3
- package/template/.claude/skills/draft-emails/scripts/scan-emails.sh +3 -2
- package/template/.claude/skills/extract-entities/SKILL.md +0 -1
- package/template/.claude/skills/extract-entities/references/TEMPLATES.md +1 -0
- package/template/.claude/skills/process-hyprnote/SKILL.md +335 -0
- package/template/.claude/skills/sync-apple-calendar/SKILL.md +6 -3
- package/template/.claude/skills/sync-apple-calendar/scripts/sync.py +13 -4
- package/template/.claude/skills/sync-apple-mail/SKILL.md +17 -5
- package/template/.claude/skills/sync-apple-mail/references/SCHEMA.md +32 -5
- package/template/.claude/skills/sync-apple-mail/scripts/sync.py +134 -27
- package/template/CLAUDE.md +81 -14
- package/template/knowledge/Briefings/.gitkeep +0 -0
- package/basecamp.js +0 -660
- package/build.js +0 -122
package/src/basecamp.js
ADDED
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Basecamp — CLI and scheduler for autonomous agent teams.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// node basecamp.js Wake due agents once and exit
|
|
7
|
+
// node basecamp.js --daemon Run continuously (poll every 60s)
|
|
8
|
+
// node basecamp.js --wake <agent> Wake a specific agent immediately
|
|
9
|
+
// node basecamp.js --init <path> Initialize a new knowledge base
|
|
10
|
+
// node basecamp.js --validate Validate agent definitions exist
|
|
11
|
+
// node basecamp.js --status Show agent status
|
|
12
|
+
// node basecamp.js --help Show this help
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
readFileSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
existsSync,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
unlinkSync,
|
|
20
|
+
chmodSync,
|
|
21
|
+
readdirSync,
|
|
22
|
+
statSync,
|
|
23
|
+
} from "node:fs";
|
|
24
|
+
import { execSync } from "node:child_process";
|
|
25
|
+
import { join, dirname, resolve } from "node:path";
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
import { createServer } from "node:net";
|
|
29
|
+
|
|
30
|
+
const HOME = homedir();
|
|
31
|
+
const BASECAMP_HOME = join(HOME, ".fit", "basecamp");
|
|
32
|
+
const CONFIG_PATH = join(BASECAMP_HOME, "scheduler.json");
|
|
33
|
+
const STATE_PATH = join(BASECAMP_HOME, "state.json");
|
|
34
|
+
const LOG_DIR = join(BASECAMP_HOME, "logs");
|
|
35
|
+
const CACHE_DIR = join(HOME, ".cache", "fit", "basecamp");
|
|
36
|
+
const __dirname =
|
|
37
|
+
import.meta.dirname || dirname(fileURLToPath(import.meta.url));
|
|
38
|
+
const SHARE_DIR = "/usr/local/share/fit-basecamp";
|
|
39
|
+
const SOCKET_PATH = join(BASECAMP_HOME, "basecamp.sock");
|
|
40
|
+
|
|
41
|
+
// --- posix_spawn (TCC-compliant process spawning) ---------------------------
|
|
42
|
+
|
|
43
|
+
import * as posixSpawn from "./posix-spawn.js";
|
|
44
|
+
|
|
45
|
+
let daemonStartedAt = null;
|
|
46
|
+
|
|
47
|
+
// Maximum time an agent can be "active" before being considered stale (35 min).
|
|
48
|
+
// Matches the 30-minute child_process timeout plus a buffer.
|
|
49
|
+
const MAX_AGENT_RUNTIME_MS = 35 * 60_000;
|
|
50
|
+
|
|
51
|
+
// --- Helpers ----------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
function ensureDir(dir) {
|
|
54
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readJSON(path, fallback) {
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
60
|
+
} catch {
|
|
61
|
+
return fallback;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function writeJSON(path, data) {
|
|
66
|
+
ensureDir(dirname(path));
|
|
67
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + "\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function expandPath(p) {
|
|
71
|
+
return p.startsWith("~/") ? join(HOME, p.slice(2)) : resolve(p);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function log(msg) {
|
|
75
|
+
const ts = new Date().toISOString();
|
|
76
|
+
const line = `[${ts}] ${msg}`;
|
|
77
|
+
console.log(line);
|
|
78
|
+
try {
|
|
79
|
+
ensureDir(LOG_DIR);
|
|
80
|
+
writeFileSync(
|
|
81
|
+
join(LOG_DIR, `scheduler-${ts.slice(0, 10)}.log`),
|
|
82
|
+
line + "\n",
|
|
83
|
+
{ flag: "a" },
|
|
84
|
+
);
|
|
85
|
+
} catch {
|
|
86
|
+
/* best effort */
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function findClaude() {
|
|
91
|
+
const paths = [
|
|
92
|
+
"/usr/local/bin/claude",
|
|
93
|
+
join(HOME, ".claude", "bin", "claude"),
|
|
94
|
+
join(HOME, ".local", "bin", "claude"),
|
|
95
|
+
"/opt/homebrew/bin/claude",
|
|
96
|
+
];
|
|
97
|
+
for (const p of paths) if (existsSync(p)) return p;
|
|
98
|
+
return "claude";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Detect if running from inside a macOS .app bundle.
|
|
103
|
+
* The binary is at Basecamp.app/Contents/MacOS/fit-basecamp.
|
|
104
|
+
* @returns {{ bundle: string, resources: string } | null}
|
|
105
|
+
*/
|
|
106
|
+
function getBundlePath() {
|
|
107
|
+
try {
|
|
108
|
+
const exe = process.execPath || "";
|
|
109
|
+
const macosDir = dirname(exe);
|
|
110
|
+
const contentsDir = dirname(macosDir);
|
|
111
|
+
const resourcesDir = join(contentsDir, "Resources");
|
|
112
|
+
if (existsSync(join(resourcesDir, "config"))) {
|
|
113
|
+
return { bundle: dirname(contentsDir), resources: resourcesDir };
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
/* not in bundle */
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function loadConfig() {
|
|
122
|
+
return readJSON(CONFIG_PATH, { agents: {} });
|
|
123
|
+
}
|
|
124
|
+
function loadState() {
|
|
125
|
+
const raw = readJSON(STATE_PATH, null);
|
|
126
|
+
if (!raw || typeof raw !== "object" || !raw.agents) {
|
|
127
|
+
const state = { agents: {} };
|
|
128
|
+
saveState(state);
|
|
129
|
+
return state;
|
|
130
|
+
}
|
|
131
|
+
return raw;
|
|
132
|
+
}
|
|
133
|
+
function saveState(state) {
|
|
134
|
+
writeJSON(STATE_PATH, state);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// --- Cron matching ----------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
function matchField(field, value) {
|
|
140
|
+
if (field === "*") return true;
|
|
141
|
+
if (field.startsWith("*/")) return value % parseInt(field.slice(2)) === 0;
|
|
142
|
+
return field.split(",").some((part) => {
|
|
143
|
+
if (part.includes("-")) {
|
|
144
|
+
const [lo, hi] = part.split("-").map(Number);
|
|
145
|
+
return value >= lo && value <= hi;
|
|
146
|
+
}
|
|
147
|
+
return parseInt(part) === value;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function cronMatches(expr, d) {
|
|
152
|
+
const [min, hour, dom, month, dow] = expr.trim().split(/\s+/);
|
|
153
|
+
return (
|
|
154
|
+
matchField(min, d.getMinutes()) &&
|
|
155
|
+
matchField(hour, d.getHours()) &&
|
|
156
|
+
matchField(dom, d.getDate()) &&
|
|
157
|
+
matchField(month, d.getMonth() + 1) &&
|
|
158
|
+
matchField(dow, d.getDay())
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// --- Scheduling logic -------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
function floorToMinute(d) {
|
|
165
|
+
return new Date(
|
|
166
|
+
d.getFullYear(),
|
|
167
|
+
d.getMonth(),
|
|
168
|
+
d.getDate(),
|
|
169
|
+
d.getHours(),
|
|
170
|
+
d.getMinutes(),
|
|
171
|
+
).getTime();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function shouldWake(agent, agentState, now) {
|
|
175
|
+
if (agent.enabled === false) return false;
|
|
176
|
+
if (agentState.status === "active") return false;
|
|
177
|
+
const { schedule } = agent;
|
|
178
|
+
if (!schedule) return false;
|
|
179
|
+
const lastWoke = agentState.lastWokeAt
|
|
180
|
+
? new Date(agentState.lastWokeAt)
|
|
181
|
+
: null;
|
|
182
|
+
|
|
183
|
+
if (schedule.type === "cron") {
|
|
184
|
+
if (lastWoke && floorToMinute(lastWoke) === floorToMinute(now))
|
|
185
|
+
return false;
|
|
186
|
+
return cronMatches(schedule.expression, now);
|
|
187
|
+
}
|
|
188
|
+
if (schedule.type === "interval") {
|
|
189
|
+
const ms = (schedule.minutes || 5) * 60_000;
|
|
190
|
+
return !lastWoke || now.getTime() - lastWoke.getTime() >= ms;
|
|
191
|
+
}
|
|
192
|
+
if (schedule.type === "once") {
|
|
193
|
+
return !agentState.lastWokeAt && now >= new Date(schedule.runAt);
|
|
194
|
+
}
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// --- Agent execution --------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
async function wakeAgent(agentName, agent, _config, state) {
|
|
201
|
+
if (!agent.kb) {
|
|
202
|
+
log(`Agent ${agentName}: no "kb" specified, skipping.`);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const kbPath = expandPath(agent.kb);
|
|
206
|
+
if (!existsSync(kbPath)) {
|
|
207
|
+
log(`Agent ${agentName}: path "${kbPath}" does not exist, skipping.`);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const claude = findClaude();
|
|
212
|
+
|
|
213
|
+
log(`Waking agent: ${agentName} (kb: ${agent.kb})`);
|
|
214
|
+
|
|
215
|
+
const as = (state.agents[agentName] ||= {});
|
|
216
|
+
as.status = "active";
|
|
217
|
+
as.startedAt = new Date().toISOString();
|
|
218
|
+
saveState(state);
|
|
219
|
+
|
|
220
|
+
const spawnArgs = ["--agent", agentName, "--print", "-p", "Observe and act."];
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
const { pid, stdoutFd, stderrFd } = posixSpawn.spawn(
|
|
224
|
+
claude,
|
|
225
|
+
spawnArgs,
|
|
226
|
+
undefined,
|
|
227
|
+
kbPath,
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// Read stdout and stderr concurrently to avoid pipe deadlocks,
|
|
231
|
+
// then wait for the child to exit.
|
|
232
|
+
const [stdout, stderr] = await Promise.all([
|
|
233
|
+
posixSpawn.readAll(stdoutFd),
|
|
234
|
+
posixSpawn.readAll(stderrFd),
|
|
235
|
+
]);
|
|
236
|
+
const exitCode = await posixSpawn.waitForExit(pid);
|
|
237
|
+
|
|
238
|
+
if (exitCode === 0) {
|
|
239
|
+
log(`Agent ${agentName} completed. Output: ${stdout.slice(0, 200)}...`);
|
|
240
|
+
updateAgentState(as, stdout, agentName);
|
|
241
|
+
} else {
|
|
242
|
+
const errMsg = stderr || stdout || `Exit code ${exitCode}`;
|
|
243
|
+
log(`Agent ${agentName} failed: ${errMsg.slice(0, 300)}`);
|
|
244
|
+
Object.assign(as, {
|
|
245
|
+
status: "failed",
|
|
246
|
+
startedAt: null,
|
|
247
|
+
lastWokeAt: new Date().toISOString(),
|
|
248
|
+
lastError: errMsg.slice(0, 500),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
saveState(state);
|
|
252
|
+
} catch (err) {
|
|
253
|
+
log(`Agent ${agentName} failed: ${err.message}`);
|
|
254
|
+
Object.assign(as, {
|
|
255
|
+
status: "failed",
|
|
256
|
+
startedAt: null,
|
|
257
|
+
lastWokeAt: new Date().toISOString(),
|
|
258
|
+
lastError: err.message.slice(0, 500),
|
|
259
|
+
});
|
|
260
|
+
saveState(state);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Parse Decision:/Action: lines from agent output and update state.
|
|
266
|
+
* Also saves stdout to the state directory as a briefing fallback.
|
|
267
|
+
* @param {object} agentState
|
|
268
|
+
* @param {string} stdout
|
|
269
|
+
* @param {string} agentName
|
|
270
|
+
*/
|
|
271
|
+
function updateAgentState(agentState, stdout, agentName) {
|
|
272
|
+
const lines = stdout.split("\n");
|
|
273
|
+
const decisionLine = lines.find((l) => l.startsWith("Decision:"));
|
|
274
|
+
const actionLine = lines.find((l) => l.startsWith("Action:"));
|
|
275
|
+
|
|
276
|
+
Object.assign(agentState, {
|
|
277
|
+
status: "idle",
|
|
278
|
+
startedAt: null,
|
|
279
|
+
lastWokeAt: new Date().toISOString(),
|
|
280
|
+
lastDecision: decisionLine
|
|
281
|
+
? decisionLine.slice(10).trim()
|
|
282
|
+
: stdout.slice(0, 200),
|
|
283
|
+
lastAction: actionLine ? actionLine.slice(8).trim() : null,
|
|
284
|
+
lastError: null,
|
|
285
|
+
wakeCount: (agentState.wakeCount || 0) + 1,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// Save output as briefing fallback so View Briefing always has content
|
|
289
|
+
const stateDir = join(CACHE_DIR, "state");
|
|
290
|
+
ensureDir(stateDir);
|
|
291
|
+
const prefix = agentName.replace(/-/g, "_");
|
|
292
|
+
writeFileSync(join(stateDir, `${prefix}_last_output.md`), stdout);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Reset agents stuck in "active" state. This happens when the daemon
|
|
297
|
+
* restarts while agents were running, or when a child process exits
|
|
298
|
+
* without triggering cleanup (e.g. pipe error, signal).
|
|
299
|
+
*
|
|
300
|
+
* @param {object} state
|
|
301
|
+
* @param {{ reason: string, maxAge?: number }} opts
|
|
302
|
+
* reason — logged and stored in lastError
|
|
303
|
+
* maxAge — if set, only reset agents active longer than this (ms)
|
|
304
|
+
*/
|
|
305
|
+
function resetStaleAgents(state, { reason, maxAge }) {
|
|
306
|
+
let resetCount = 0;
|
|
307
|
+
for (const [name, as] of Object.entries(state.agents)) {
|
|
308
|
+
if (as.status !== "active") continue;
|
|
309
|
+
if (maxAge && as.startedAt) {
|
|
310
|
+
const elapsed = Date.now() - new Date(as.startedAt).getTime();
|
|
311
|
+
if (elapsed < maxAge) continue;
|
|
312
|
+
}
|
|
313
|
+
log(`Resetting stale agent: ${name} (${reason})`);
|
|
314
|
+
Object.assign(as, {
|
|
315
|
+
status: "interrupted",
|
|
316
|
+
startedAt: null,
|
|
317
|
+
lastError: reason,
|
|
318
|
+
});
|
|
319
|
+
resetCount++;
|
|
320
|
+
}
|
|
321
|
+
if (resetCount > 0) saveState(state);
|
|
322
|
+
return resetCount;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function wakeDueAgents() {
|
|
326
|
+
const config = loadConfig(),
|
|
327
|
+
state = loadState(),
|
|
328
|
+
now = new Date();
|
|
329
|
+
|
|
330
|
+
// Reset agents that have been active longer than the maximum runtime.
|
|
331
|
+
resetStaleAgents(state, {
|
|
332
|
+
reason: "Exceeded maximum runtime",
|
|
333
|
+
maxAge: MAX_AGENT_RUNTIME_MS,
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
let wokeAny = false;
|
|
337
|
+
for (const [name, agent] of Object.entries(config.agents)) {
|
|
338
|
+
if (shouldWake(agent, state.agents[name] || {}, now)) {
|
|
339
|
+
await wakeAgent(name, agent, config, state);
|
|
340
|
+
wokeAny = true;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (!wokeAny) log("No agents due.");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// --- Next-wake computation --------------------------------------------------
|
|
347
|
+
|
|
348
|
+
/** @param {object} agent @param {object} agentState @param {Date} now */
|
|
349
|
+
function computeNextWakeAt(agent, agentState, now) {
|
|
350
|
+
if (agent.enabled === false) return null;
|
|
351
|
+
const { schedule } = agent;
|
|
352
|
+
if (!schedule) return null;
|
|
353
|
+
|
|
354
|
+
if (schedule.type === "interval") {
|
|
355
|
+
const ms = (schedule.minutes || 5) * 60_000;
|
|
356
|
+
const lastWoke = agentState.lastWokeAt
|
|
357
|
+
? new Date(agentState.lastWokeAt)
|
|
358
|
+
: null;
|
|
359
|
+
if (!lastWoke) return now.toISOString();
|
|
360
|
+
return new Date(lastWoke.getTime() + ms).toISOString();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (schedule.type === "cron") {
|
|
364
|
+
const limit = 24 * 60;
|
|
365
|
+
const start = new Date(floorToMinute(now) + 60_000);
|
|
366
|
+
for (let i = 0; i < limit; i++) {
|
|
367
|
+
const candidate = new Date(start.getTime() + i * 60_000);
|
|
368
|
+
if (cronMatches(schedule.expression, candidate)) {
|
|
369
|
+
return candidate.toISOString();
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (schedule.type === "once") {
|
|
376
|
+
if (agentState.lastWokeAt) return null;
|
|
377
|
+
return schedule.runAt;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// --- Briefing file resolution -----------------------------------------------
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Resolve the briefing file for an agent by convention:
|
|
387
|
+
* 1. Scan ~/.cache/fit/basecamp/state/ for files matching {agent_name}_*.md
|
|
388
|
+
* 2. Fall back to the KB's knowledge/Briefings/ directory (latest .md file)
|
|
389
|
+
*
|
|
390
|
+
* @param {string} agentName
|
|
391
|
+
* @param {object} agentConfig
|
|
392
|
+
* @returns {string|null}
|
|
393
|
+
*/
|
|
394
|
+
function resolveBriefingFile(agentName, agentConfig) {
|
|
395
|
+
// 1. Scan state directory for agent-specific files
|
|
396
|
+
const stateDir = join(CACHE_DIR, "state");
|
|
397
|
+
if (existsSync(stateDir)) {
|
|
398
|
+
const prefix = agentName.replace(/-/g, "_") + "_";
|
|
399
|
+
const matches = readdirSync(stateDir).filter(
|
|
400
|
+
(f) => f.startsWith(prefix) && f.endsWith(".md"),
|
|
401
|
+
);
|
|
402
|
+
if (matches.length === 1) return join(stateDir, matches[0]);
|
|
403
|
+
if (matches.length > 1) {
|
|
404
|
+
return matches
|
|
405
|
+
.map((f) => join(stateDir, f))
|
|
406
|
+
.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0];
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// 2. Fall back to KB briefings directory
|
|
411
|
+
if (agentConfig.kb) {
|
|
412
|
+
const kbPath = expandPath(agentConfig.kb);
|
|
413
|
+
const dir = join(kbPath, "knowledge", "Briefings");
|
|
414
|
+
if (existsSync(dir)) {
|
|
415
|
+
const files = readdirSync(dir)
|
|
416
|
+
.filter((f) => f.endsWith(".md"))
|
|
417
|
+
.sort()
|
|
418
|
+
.reverse();
|
|
419
|
+
if (files.length > 0) return join(dir, files[0]);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// --- Socket server ----------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
/** @param {import('node:net').Socket} socket @param {object} data */
|
|
429
|
+
function send(socket, data) {
|
|
430
|
+
try {
|
|
431
|
+
socket.write(JSON.stringify(data) + "\n");
|
|
432
|
+
} catch {}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function handleStatusRequest(socket) {
|
|
436
|
+
const config = loadConfig();
|
|
437
|
+
const state = loadState();
|
|
438
|
+
const now = new Date();
|
|
439
|
+
const agents = {};
|
|
440
|
+
|
|
441
|
+
for (const [name, agent] of Object.entries(config.agents)) {
|
|
442
|
+
const as = state.agents[name] || {};
|
|
443
|
+
agents[name] = {
|
|
444
|
+
enabled: agent.enabled !== false,
|
|
445
|
+
status: as.status || "never-woken",
|
|
446
|
+
lastWokeAt: as.lastWokeAt || null,
|
|
447
|
+
nextWakeAt: computeNextWakeAt(agent, as, now),
|
|
448
|
+
lastAction: as.lastAction || null,
|
|
449
|
+
lastDecision: as.lastDecision || null,
|
|
450
|
+
wakeCount: as.wakeCount || 0,
|
|
451
|
+
lastError: as.lastError || null,
|
|
452
|
+
kbPath: agent.kb ? expandPath(agent.kb) : null,
|
|
453
|
+
briefingFile: resolveBriefingFile(name, agent),
|
|
454
|
+
};
|
|
455
|
+
if (as.startedAt) agents[name].startedAt = as.startedAt;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
send(socket, {
|
|
459
|
+
type: "status",
|
|
460
|
+
uptime: daemonStartedAt
|
|
461
|
+
? Math.floor((Date.now() - daemonStartedAt) / 1000)
|
|
462
|
+
: 0,
|
|
463
|
+
agents,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function handleMessage(socket, line) {
|
|
468
|
+
let request;
|
|
469
|
+
try {
|
|
470
|
+
request = JSON.parse(line);
|
|
471
|
+
} catch {
|
|
472
|
+
send(socket, { type: "error", message: "Invalid JSON" });
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (request.type === "status") return handleStatusRequest(socket);
|
|
477
|
+
|
|
478
|
+
if (request.type === "wake") {
|
|
479
|
+
if (!request.agent) {
|
|
480
|
+
send(socket, { type: "error", message: "Missing agent name" });
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const config = loadConfig();
|
|
484
|
+
const agent = config.agents[request.agent];
|
|
485
|
+
if (!agent) {
|
|
486
|
+
send(socket, {
|
|
487
|
+
type: "error",
|
|
488
|
+
message: `Agent not found: ${request.agent}`,
|
|
489
|
+
});
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
send(socket, { type: "ack", command: "wake", agent: request.agent });
|
|
493
|
+
const state = loadState();
|
|
494
|
+
wakeAgent(request.agent, agent, config, state).catch(() => {});
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
send(socket, {
|
|
499
|
+
type: "error",
|
|
500
|
+
message: `Unknown request type: ${request.type}`,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function startSocketServer() {
|
|
505
|
+
try {
|
|
506
|
+
unlinkSync(SOCKET_PATH);
|
|
507
|
+
} catch {}
|
|
508
|
+
|
|
509
|
+
const server = createServer((socket) => {
|
|
510
|
+
let buffer = "";
|
|
511
|
+
socket.on("data", (data) => {
|
|
512
|
+
buffer += data.toString();
|
|
513
|
+
let idx;
|
|
514
|
+
while ((idx = buffer.indexOf("\n")) !== -1) {
|
|
515
|
+
const line = buffer.slice(0, idx).trim();
|
|
516
|
+
buffer = buffer.slice(idx + 1);
|
|
517
|
+
if (line) handleMessage(socket, line);
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
socket.on("error", () => {});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
server.listen(SOCKET_PATH, () => {
|
|
524
|
+
chmodSync(SOCKET_PATH, 0o600);
|
|
525
|
+
log(`Socket server listening on ${SOCKET_PATH}`);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
server.on("error", (err) => {
|
|
529
|
+
log(`Socket server error: ${err.message}`);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
const cleanup = () => {
|
|
533
|
+
server.close();
|
|
534
|
+
process.exit(0);
|
|
535
|
+
};
|
|
536
|
+
process.on("SIGTERM", cleanup);
|
|
537
|
+
process.on("SIGINT", cleanup);
|
|
538
|
+
|
|
539
|
+
return server;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// --- Daemon -----------------------------------------------------------------
|
|
543
|
+
|
|
544
|
+
function daemon() {
|
|
545
|
+
daemonStartedAt = Date.now();
|
|
546
|
+
log("Scheduler daemon started. Polling every 60 seconds.");
|
|
547
|
+
log(`Config: ${CONFIG_PATH} State: ${STATE_PATH}`);
|
|
548
|
+
|
|
549
|
+
// Reset any agents left "active" from a previous daemon session.
|
|
550
|
+
const state = loadState();
|
|
551
|
+
resetStaleAgents(state, { reason: "Daemon restarted" });
|
|
552
|
+
|
|
553
|
+
startSocketServer();
|
|
554
|
+
wakeDueAgents().catch((err) => log(`Error: ${err.message}`));
|
|
555
|
+
setInterval(async () => {
|
|
556
|
+
try {
|
|
557
|
+
await wakeDueAgents();
|
|
558
|
+
} catch (err) {
|
|
559
|
+
log(`Error: ${err.message}`);
|
|
560
|
+
}
|
|
561
|
+
}, 60_000);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// --- Init knowledge base ----------------------------------------------------
|
|
565
|
+
|
|
566
|
+
function findTemplateDir() {
|
|
567
|
+
const bundle = getBundlePath();
|
|
568
|
+
if (bundle) {
|
|
569
|
+
const tpl = join(bundle.resources, "template");
|
|
570
|
+
if (existsSync(tpl)) return tpl;
|
|
571
|
+
}
|
|
572
|
+
for (const d of [
|
|
573
|
+
join(SHARE_DIR, "template"),
|
|
574
|
+
join(__dirname, "..", "template"),
|
|
575
|
+
])
|
|
576
|
+
if (existsSync(d)) return d;
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function initKB(targetPath) {
|
|
581
|
+
const dest = expandPath(targetPath);
|
|
582
|
+
if (existsSync(join(dest, "CLAUDE.md"))) {
|
|
583
|
+
console.error(`Knowledge base already exists at ${dest}`);
|
|
584
|
+
process.exit(1);
|
|
585
|
+
}
|
|
586
|
+
const tpl = findTemplateDir();
|
|
587
|
+
if (!tpl) {
|
|
588
|
+
console.error("Template not found. Reinstall fit-basecamp.");
|
|
589
|
+
process.exit(1);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
ensureDir(dest);
|
|
593
|
+
for (const d of [
|
|
594
|
+
"knowledge/People",
|
|
595
|
+
"knowledge/Organizations",
|
|
596
|
+
"knowledge/Projects",
|
|
597
|
+
"knowledge/Topics",
|
|
598
|
+
"knowledge/Briefings",
|
|
599
|
+
])
|
|
600
|
+
ensureDir(join(dest, d));
|
|
601
|
+
|
|
602
|
+
execSync(`cp -R "${tpl}/." "${dest}/"`);
|
|
603
|
+
|
|
604
|
+
console.log(
|
|
605
|
+
`Knowledge base initialized at ${dest}\n\nNext steps:\n 1. Edit ${dest}/USER.md with your name, email, and domain\n 2. cd ${dest} && claude`,
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// --- Status -----------------------------------------------------------------
|
|
610
|
+
|
|
611
|
+
function showStatus() {
|
|
612
|
+
const config = loadConfig(),
|
|
613
|
+
state = loadState();
|
|
614
|
+
console.log("\nBasecamp Scheduler\n==================\n");
|
|
615
|
+
|
|
616
|
+
const agents = Object.entries(config.agents || {});
|
|
617
|
+
if (agents.length === 0) {
|
|
618
|
+
console.log(`No agents configured.\n\nEdit ${CONFIG_PATH} to add agents.`);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
console.log("Agents:");
|
|
623
|
+
for (const [name, agent] of agents) {
|
|
624
|
+
const s = state.agents[name] || {};
|
|
625
|
+
const kbStatus =
|
|
626
|
+
agent.kb && !existsSync(expandPath(agent.kb)) ? " (not found)" : "";
|
|
627
|
+
console.log(
|
|
628
|
+
` ${agent.enabled !== false ? "+" : "-"} ${name}\n` +
|
|
629
|
+
` KB: ${agent.kb || "(none)"}${kbStatus} Schedule: ${JSON.stringify(agent.schedule)}\n` +
|
|
630
|
+
` Status: ${s.status || "never-woken"} Last wake: ${s.lastWokeAt ? new Date(s.lastWokeAt).toLocaleString() : "never"} Wakes: ${s.wakeCount || 0}` +
|
|
631
|
+
(s.lastAction ? `\n Last action: ${s.lastAction}` : "") +
|
|
632
|
+
(s.lastDecision ? `\n Last decision: ${s.lastDecision}` : "") +
|
|
633
|
+
(s.lastError ? `\n Error: ${s.lastError.slice(0, 80)}` : ""),
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// --- Validate ---------------------------------------------------------------
|
|
639
|
+
|
|
640
|
+
function findInLocalOrGlobal(kbPath, subPath) {
|
|
641
|
+
const local = join(kbPath, ".claude", subPath);
|
|
642
|
+
const global = join(HOME, ".claude", subPath);
|
|
643
|
+
if (existsSync(local)) return local;
|
|
644
|
+
if (existsSync(global)) return global;
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function validate() {
|
|
649
|
+
const config = loadConfig();
|
|
650
|
+
const agents = Object.entries(config.agents || {});
|
|
651
|
+
if (agents.length === 0) {
|
|
652
|
+
console.log("No agents configured. Nothing to validate.");
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
console.log("\nValidating agents...\n");
|
|
657
|
+
let errors = 0;
|
|
658
|
+
|
|
659
|
+
for (const [name, agent] of agents) {
|
|
660
|
+
if (!agent.kb) {
|
|
661
|
+
console.log(` [FAIL] ${name}: no "kb" path specified`);
|
|
662
|
+
errors++;
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
const kbPath = expandPath(agent.kb);
|
|
666
|
+
if (!existsSync(kbPath)) {
|
|
667
|
+
console.log(` [FAIL] ${name}: path not found: ${kbPath}`);
|
|
668
|
+
errors++;
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const agentFile = join("agents", name + ".md");
|
|
673
|
+
const found = findInLocalOrGlobal(kbPath, agentFile);
|
|
674
|
+
console.log(
|
|
675
|
+
` [${found ? "OK" : "FAIL"}] ${name}: agent definition${found ? "" : " not found"}`,
|
|
676
|
+
);
|
|
677
|
+
if (!found) errors++;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
console.log(errors > 0 ? `\n${errors} error(s).` : "\nAll OK.");
|
|
681
|
+
if (errors > 0) process.exit(1);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// --- Help -------------------------------------------------------------------
|
|
685
|
+
|
|
686
|
+
function showHelp() {
|
|
687
|
+
const bin = "fit-basecamp";
|
|
688
|
+
console.log(`
|
|
689
|
+
Basecamp — Schedule autonomous agents across knowledge bases.
|
|
690
|
+
|
|
691
|
+
Usage:
|
|
692
|
+
${bin} Wake due agents once and exit
|
|
693
|
+
${bin} --daemon Run continuously (poll every 60s)
|
|
694
|
+
${bin} --wake <agent> Wake a specific agent immediately
|
|
695
|
+
${bin} --init <path> Initialize a new knowledge base
|
|
696
|
+
${bin} --validate Validate agent definitions exist
|
|
697
|
+
${bin} --status Show agent status
|
|
698
|
+
|
|
699
|
+
Config: ~/.fit/basecamp/scheduler.json
|
|
700
|
+
State: ~/.fit/basecamp/state.json
|
|
701
|
+
Logs: ~/.fit/basecamp/logs/
|
|
702
|
+
`);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// --- CLI entry point --------------------------------------------------------
|
|
706
|
+
|
|
707
|
+
const args = process.argv.slice(2);
|
|
708
|
+
const command = args[0];
|
|
709
|
+
ensureDir(BASECAMP_HOME);
|
|
710
|
+
|
|
711
|
+
const commands = {
|
|
712
|
+
"--help": showHelp,
|
|
713
|
+
"-h": showHelp,
|
|
714
|
+
"--daemon": daemon,
|
|
715
|
+
"--validate": validate,
|
|
716
|
+
"--status": showStatus,
|
|
717
|
+
"--init": () => {
|
|
718
|
+
if (!args[1]) {
|
|
719
|
+
console.error("Usage: node basecamp.js --init <path>");
|
|
720
|
+
process.exit(1);
|
|
721
|
+
}
|
|
722
|
+
initKB(args[1]);
|
|
723
|
+
},
|
|
724
|
+
"--wake": async () => {
|
|
725
|
+
if (!args[1]) {
|
|
726
|
+
console.error("Usage: node basecamp.js --wake <agent-name>");
|
|
727
|
+
process.exit(1);
|
|
728
|
+
}
|
|
729
|
+
const config = loadConfig(),
|
|
730
|
+
state = loadState(),
|
|
731
|
+
agent = config.agents[args[1]];
|
|
732
|
+
if (!agent) {
|
|
733
|
+
console.error(
|
|
734
|
+
`Agent "${args[1]}" not found. Available: ${Object.keys(config.agents).join(", ") || "(none)"}`,
|
|
735
|
+
);
|
|
736
|
+
process.exit(1);
|
|
737
|
+
}
|
|
738
|
+
await wakeAgent(args[1], agent, config, state);
|
|
739
|
+
},
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
await (commands[command] || wakeDueAgents)();
|