@forwardimpact/basecamp 2.4.2 → 2.6.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/config/scheduler.json +10 -5
- package/package.json +1 -1
- package/src/basecamp.js +101 -729
- package/template/.claude/agents/chief-of-staff.md +14 -3
- package/template/.claude/agents/head-hunter.md +436 -0
- package/template/.claude/agents/librarian.md +1 -1
- package/template/.claude/settings.json +4 -1
- package/template/.claude/skills/analyze-cv/SKILL.md +39 -7
- package/template/.claude/skills/draft-emails/SKILL.md +29 -9
- package/template/.claude/skills/draft-emails/scripts/scan-emails.mjs +4 -4
- package/template/.claude/skills/draft-emails/scripts/send-email.mjs +41 -6
- package/template/.claude/skills/meeting-prep/SKILL.md +7 -4
- package/template/.claude/skills/process-hyprnote/SKILL.md +17 -8
- package/template/.claude/skills/process-hyprnote/scripts/scan.mjs +246 -0
- package/template/.claude/skills/scan-open-candidates/SKILL.md +476 -0
- package/template/.claude/skills/scan-open-candidates/scripts/state.mjs +396 -0
- package/template/.claude/skills/sync-apple-calendar/SKILL.md +41 -0
- package/template/.claude/skills/sync-apple-calendar/scripts/query.mjs +301 -0
- package/template/.claude/skills/synthesize-deck/SKILL.md +296 -0
- package/template/.claude/skills/synthesize-deck/scripts/extract-pptx.mjs +210 -0
- package/template/.claude/skills/track-candidates/SKILL.md +45 -0
- package/template/.claude/skills/workday-requisition/SKILL.md +86 -53
- package/template/.claude/skills/workday-requisition/scripts/parse-workday.mjs +103 -37
- package/template/CLAUDE.md +13 -3
package/src/basecamp.js
CHANGED
|
@@ -18,17 +18,23 @@ import {
|
|
|
18
18
|
writeFileSync,
|
|
19
19
|
existsSync,
|
|
20
20
|
mkdirSync,
|
|
21
|
-
unlinkSync,
|
|
22
|
-
chmodSync,
|
|
23
21
|
readdirSync,
|
|
24
|
-
statSync,
|
|
25
22
|
cpSync,
|
|
26
23
|
copyFileSync,
|
|
24
|
+
appendFileSync,
|
|
27
25
|
} from "node:fs";
|
|
28
26
|
import { join, dirname, resolve } from "node:path";
|
|
29
27
|
import { homedir } from "node:os";
|
|
30
28
|
import { fileURLToPath } from "node:url";
|
|
31
|
-
|
|
29
|
+
|
|
30
|
+
import * as posixSpawn from "./posix-spawn.js";
|
|
31
|
+
import { StateManager } from "./state-manager.js";
|
|
32
|
+
import { AgentRunner } from "./agent-runner.js";
|
|
33
|
+
import { Scheduler } from "./scheduler.js";
|
|
34
|
+
import { KBManager } from "./kb-manager.js";
|
|
35
|
+
import { SocketServer, requestShutdown } from "./socket-server.js";
|
|
36
|
+
|
|
37
|
+
// --- Paths -------------------------------------------------------------------
|
|
32
38
|
|
|
33
39
|
const HOME = homedir();
|
|
34
40
|
const BASECAMP_HOME = join(HOME, ".fit", "basecamp");
|
|
@@ -41,72 +47,62 @@ const __dirname =
|
|
|
41
47
|
const SHARE_DIR = "/usr/local/share/fit-basecamp";
|
|
42
48
|
const SOCKET_PATH = join(BASECAMP_HOME, "basecamp.sock");
|
|
43
49
|
|
|
44
|
-
// ---
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
50
|
+
// --- Logging -----------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
function createLogger(logDir, fs) {
|
|
53
|
+
if (!logDir) throw new Error("logDir is required");
|
|
54
|
+
if (!fs) throw new Error("fs is required");
|
|
55
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
56
|
+
return function log(msg) {
|
|
57
|
+
const ts = new Date().toISOString();
|
|
58
|
+
const line = `[${ts}] ${msg}`;
|
|
59
|
+
console.log(line);
|
|
60
|
+
fs.appendFileSync(
|
|
61
|
+
join(logDir, `scheduler-${ts.slice(0, 10)}.log`),
|
|
62
|
+
line + "\n",
|
|
63
|
+
);
|
|
64
|
+
};
|
|
65
|
+
}
|
|
56
66
|
|
|
57
|
-
|
|
67
|
+
const log = createLogger(LOG_DIR, { mkdirSync, appendFileSync });
|
|
58
68
|
|
|
59
|
-
|
|
60
|
-
mkdirSync(dir, { recursive: true });
|
|
61
|
-
}
|
|
69
|
+
// --- Config ------------------------------------------------------------------
|
|
62
70
|
|
|
63
|
-
function
|
|
71
|
+
function loadConfig() {
|
|
64
72
|
try {
|
|
65
|
-
return JSON.parse(readFileSync(
|
|
73
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
66
74
|
} catch {
|
|
67
|
-
return
|
|
75
|
+
return { agents: {} };
|
|
68
76
|
}
|
|
69
77
|
}
|
|
70
78
|
|
|
71
|
-
function writeJSON(path, data) {
|
|
72
|
-
ensureDir(dirname(path));
|
|
73
|
-
writeFileSync(path, JSON.stringify(data, null, 2) + "\n");
|
|
74
|
-
}
|
|
75
|
-
|
|
76
79
|
function expandPath(p) {
|
|
77
80
|
return p.startsWith("~/") ? join(HOME, p.slice(2)) : resolve(p);
|
|
78
81
|
}
|
|
79
82
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
83
|
+
// --- Wire dependencies -------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
const fsOps = { readFileSync, writeFileSync, mkdirSync };
|
|
86
|
+
const stateManager = new StateManager(STATE_PATH, fsOps);
|
|
87
|
+
const agentRunner = new AgentRunner(posixSpawn, stateManager, log, CACHE_DIR);
|
|
88
|
+
const scheduler = new Scheduler(loadConfig, stateManager, agentRunner, log);
|
|
89
|
+
const kbManager = new KBManager(
|
|
90
|
+
{
|
|
91
|
+
existsSync,
|
|
92
|
+
mkdirSync,
|
|
93
|
+
cpSync,
|
|
94
|
+
copyFileSync,
|
|
95
|
+
readFileSync,
|
|
96
|
+
writeFileSync,
|
|
97
|
+
readdirSync,
|
|
98
|
+
},
|
|
99
|
+
log,
|
|
100
|
+
);
|
|
95
101
|
|
|
96
|
-
|
|
97
|
-
const paths = [
|
|
98
|
-
"/usr/local/bin/claude",
|
|
99
|
-
join(HOME, ".claude", "bin", "claude"),
|
|
100
|
-
join(HOME, ".local", "bin", "claude"),
|
|
101
|
-
"/opt/homebrew/bin/claude",
|
|
102
|
-
];
|
|
103
|
-
for (const p of paths) if (existsSync(p)) return p;
|
|
104
|
-
return "claude";
|
|
105
|
-
}
|
|
102
|
+
// --- Template dir resolution -------------------------------------------------
|
|
106
103
|
|
|
107
104
|
/**
|
|
108
105
|
* Detect if running from inside a macOS .app bundle.
|
|
109
|
-
* The binary is at Basecamp.app/Contents/MacOS/fit-basecamp.
|
|
110
106
|
* @returns {{ bundle: string, resources: string } | null}
|
|
111
107
|
*/
|
|
112
108
|
function getBundlePath() {
|
|
@@ -124,533 +120,6 @@ function getBundlePath() {
|
|
|
124
120
|
return null;
|
|
125
121
|
}
|
|
126
122
|
|
|
127
|
-
function loadConfig() {
|
|
128
|
-
return readJSON(CONFIG_PATH, { agents: {} });
|
|
129
|
-
}
|
|
130
|
-
function loadState() {
|
|
131
|
-
const raw = readJSON(STATE_PATH, null);
|
|
132
|
-
if (!raw || typeof raw !== "object" || !raw.agents) {
|
|
133
|
-
const state = { agents: {} };
|
|
134
|
-
saveState(state);
|
|
135
|
-
return state;
|
|
136
|
-
}
|
|
137
|
-
return raw;
|
|
138
|
-
}
|
|
139
|
-
function saveState(state) {
|
|
140
|
-
writeJSON(STATE_PATH, state);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// --- Cron matching ----------------------------------------------------------
|
|
144
|
-
|
|
145
|
-
function matchField(field, value) {
|
|
146
|
-
if (field === "*") return true;
|
|
147
|
-
if (field.startsWith("*/")) return value % parseInt(field.slice(2)) === 0;
|
|
148
|
-
return field.split(",").some((part) => {
|
|
149
|
-
if (part.includes("-")) {
|
|
150
|
-
const [lo, hi] = part.split("-").map(Number);
|
|
151
|
-
return value >= lo && value <= hi;
|
|
152
|
-
}
|
|
153
|
-
return parseInt(part) === value;
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function cronMatches(expr, d) {
|
|
158
|
-
const [min, hour, dom, month, dow] = expr.trim().split(/\s+/);
|
|
159
|
-
return (
|
|
160
|
-
matchField(min, d.getMinutes()) &&
|
|
161
|
-
matchField(hour, d.getHours()) &&
|
|
162
|
-
matchField(dom, d.getDate()) &&
|
|
163
|
-
matchField(month, d.getMonth() + 1) &&
|
|
164
|
-
matchField(dow, d.getDay())
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// --- Scheduling logic -------------------------------------------------------
|
|
169
|
-
|
|
170
|
-
function floorToMinute(d) {
|
|
171
|
-
const t = d.getTime();
|
|
172
|
-
return t - (t % 60_000);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function shouldWake(agent, agentState, now) {
|
|
176
|
-
if (agent.enabled === false) return false;
|
|
177
|
-
if (agentState.status === "active") return false;
|
|
178
|
-
const { schedule } = agent;
|
|
179
|
-
if (!schedule) return false;
|
|
180
|
-
const lastWoke = agentState.lastWokeAt
|
|
181
|
-
? new Date(agentState.lastWokeAt)
|
|
182
|
-
: null;
|
|
183
|
-
|
|
184
|
-
if (schedule.type === "cron") {
|
|
185
|
-
if (lastWoke && floorToMinute(lastWoke) === floorToMinute(now))
|
|
186
|
-
return false;
|
|
187
|
-
return cronMatches(schedule.expression, now);
|
|
188
|
-
}
|
|
189
|
-
if (schedule.type === "interval") {
|
|
190
|
-
const ms = (schedule.minutes || 5) * 60_000;
|
|
191
|
-
return !lastWoke || now.getTime() - lastWoke.getTime() >= ms;
|
|
192
|
-
}
|
|
193
|
-
if (schedule.type === "once") {
|
|
194
|
-
return !agentState.lastWokeAt && now >= new Date(schedule.runAt);
|
|
195
|
-
}
|
|
196
|
-
return false;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// --- Agent execution --------------------------------------------------------
|
|
200
|
-
|
|
201
|
-
function failAgent(agentState, error) {
|
|
202
|
-
Object.assign(agentState, {
|
|
203
|
-
status: "failed",
|
|
204
|
-
startedAt: null,
|
|
205
|
-
lastWokeAt: new Date().toISOString(),
|
|
206
|
-
lastError: String(error).slice(0, 500),
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
async function wakeAgent(agentName, agent, state) {
|
|
211
|
-
if (!agent.kb) {
|
|
212
|
-
log(`Agent ${agentName}: no "kb" specified, skipping.`);
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
const kbPath = expandPath(agent.kb);
|
|
216
|
-
if (!existsSync(kbPath)) {
|
|
217
|
-
log(`Agent ${agentName}: path "${kbPath}" does not exist, skipping.`);
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const claude = findClaude();
|
|
222
|
-
|
|
223
|
-
log(`Waking agent: ${agentName} (kb: ${agent.kb})`);
|
|
224
|
-
|
|
225
|
-
const as = (state.agents[agentName] ||= {});
|
|
226
|
-
as.status = "active";
|
|
227
|
-
as.startedAt = new Date().toISOString();
|
|
228
|
-
saveState(state);
|
|
229
|
-
|
|
230
|
-
const spawnArgs = ["--agent", agentName, "--print", "-p", "Observe and act."];
|
|
231
|
-
|
|
232
|
-
try {
|
|
233
|
-
const { pid, stdoutFd, stderrFd } = posixSpawn.spawn(
|
|
234
|
-
claude,
|
|
235
|
-
spawnArgs,
|
|
236
|
-
undefined,
|
|
237
|
-
kbPath,
|
|
238
|
-
);
|
|
239
|
-
activeChildren.add(pid);
|
|
240
|
-
|
|
241
|
-
// Read stdout and stderr concurrently to avoid pipe deadlocks,
|
|
242
|
-
// then wait for the child to exit.
|
|
243
|
-
const [stdout, stderr] = await Promise.all([
|
|
244
|
-
posixSpawn.readAll(stdoutFd),
|
|
245
|
-
posixSpawn.readAll(stderrFd),
|
|
246
|
-
]);
|
|
247
|
-
const exitCode = await posixSpawn.waitForExit(pid);
|
|
248
|
-
activeChildren.delete(pid);
|
|
249
|
-
|
|
250
|
-
if (exitCode === 0) {
|
|
251
|
-
log(`Agent ${agentName} completed. Output: ${stdout.slice(0, 200)}...`);
|
|
252
|
-
updateAgentState(as, stdout, agentName);
|
|
253
|
-
} else {
|
|
254
|
-
const errMsg = stderr || stdout || `Exit code ${exitCode}`;
|
|
255
|
-
log(`Agent ${agentName} failed: ${errMsg.slice(0, 300)}`);
|
|
256
|
-
failAgent(as, errMsg);
|
|
257
|
-
}
|
|
258
|
-
} catch (err) {
|
|
259
|
-
log(`Agent ${agentName} failed: ${err.message}`);
|
|
260
|
-
failAgent(as, err.message);
|
|
261
|
-
}
|
|
262
|
-
saveState(state);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* Parse Decision:/Action: lines from agent output and update state.
|
|
267
|
-
* Also saves stdout to the state directory as a briefing fallback.
|
|
268
|
-
* @param {object} agentState
|
|
269
|
-
* @param {string} stdout
|
|
270
|
-
* @param {string} agentName
|
|
271
|
-
*/
|
|
272
|
-
function updateAgentState(agentState, stdout, agentName) {
|
|
273
|
-
const lines = stdout.split("\n");
|
|
274
|
-
const decisionLine = lines.find((l) => l.startsWith("Decision:"));
|
|
275
|
-
const actionLine = lines.find((l) => l.startsWith("Action:"));
|
|
276
|
-
|
|
277
|
-
Object.assign(agentState, {
|
|
278
|
-
status: "idle",
|
|
279
|
-
startedAt: null,
|
|
280
|
-
lastWokeAt: new Date().toISOString(),
|
|
281
|
-
lastDecision: decisionLine
|
|
282
|
-
? decisionLine.slice(10).trim()
|
|
283
|
-
: stdout.slice(0, 200),
|
|
284
|
-
lastAction: actionLine ? actionLine.slice(8).trim() : null,
|
|
285
|
-
lastError: null,
|
|
286
|
-
wakeCount: (agentState.wakeCount || 0) + 1,
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
// Save output as briefing fallback so View Briefing always has content
|
|
290
|
-
const stateDir = join(CACHE_DIR, "state");
|
|
291
|
-
ensureDir(stateDir);
|
|
292
|
-
const prefix = agentName.replace(/-/g, "_");
|
|
293
|
-
writeFileSync(join(stateDir, `${prefix}_last_output.md`), stdout);
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
/**
|
|
297
|
-
* Reset agents stuck in "active" state. This happens when the daemon
|
|
298
|
-
* restarts while agents were running, or when a child process exits
|
|
299
|
-
* without triggering cleanup (e.g. pipe error, signal).
|
|
300
|
-
*
|
|
301
|
-
* @param {object} state
|
|
302
|
-
* @param {{ reason: string, maxAge?: number }} opts
|
|
303
|
-
* reason — logged and stored in lastError
|
|
304
|
-
* maxAge — if set, only reset agents active longer than this (ms)
|
|
305
|
-
*/
|
|
306
|
-
function resetStaleAgents(state, { reason, maxAge }) {
|
|
307
|
-
let resetCount = 0;
|
|
308
|
-
for (const [name, as] of Object.entries(state.agents)) {
|
|
309
|
-
if (as.status !== "active") continue;
|
|
310
|
-
if (maxAge && as.startedAt) {
|
|
311
|
-
const elapsed = Date.now() - new Date(as.startedAt).getTime();
|
|
312
|
-
if (elapsed < maxAge) continue;
|
|
313
|
-
}
|
|
314
|
-
log(`Resetting stale agent: ${name} (${reason})`);
|
|
315
|
-
Object.assign(as, {
|
|
316
|
-
status: "interrupted",
|
|
317
|
-
startedAt: null,
|
|
318
|
-
lastError: reason,
|
|
319
|
-
});
|
|
320
|
-
resetCount++;
|
|
321
|
-
}
|
|
322
|
-
if (resetCount > 0) saveState(state);
|
|
323
|
-
return resetCount;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
async function wakeDueAgents() {
|
|
327
|
-
const config = loadConfig(),
|
|
328
|
-
state = loadState(),
|
|
329
|
-
now = new Date();
|
|
330
|
-
|
|
331
|
-
// Reset agents that have been active longer than the maximum runtime.
|
|
332
|
-
resetStaleAgents(state, {
|
|
333
|
-
reason: "Exceeded maximum runtime",
|
|
334
|
-
maxAge: MAX_AGENT_RUNTIME_MS,
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
let wokeAny = false;
|
|
338
|
-
for (const [name, agent] of Object.entries(config.agents)) {
|
|
339
|
-
if (shouldWake(agent, state.agents[name] || {}, now)) {
|
|
340
|
-
await wakeAgent(name, agent, state);
|
|
341
|
-
wokeAny = true;
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
if (!wokeAny) log("No agents due.");
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// --- Next-wake computation --------------------------------------------------
|
|
348
|
-
|
|
349
|
-
/** @param {object} agent @param {object} agentState @param {Date} now */
|
|
350
|
-
function computeNextWakeAt(agent, agentState, now) {
|
|
351
|
-
if (agent.enabled === false) return null;
|
|
352
|
-
const { schedule } = agent;
|
|
353
|
-
if (!schedule) return null;
|
|
354
|
-
|
|
355
|
-
if (schedule.type === "interval") {
|
|
356
|
-
const ms = (schedule.minutes || 5) * 60_000;
|
|
357
|
-
const lastWoke = agentState.lastWokeAt
|
|
358
|
-
? new Date(agentState.lastWokeAt)
|
|
359
|
-
: null;
|
|
360
|
-
if (!lastWoke) return now.toISOString();
|
|
361
|
-
return new Date(lastWoke.getTime() + ms).toISOString();
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
if (schedule.type === "cron") {
|
|
365
|
-
const limit = 24 * 60;
|
|
366
|
-
const start = new Date(floorToMinute(now) + 60_000);
|
|
367
|
-
for (let i = 0; i < limit; i++) {
|
|
368
|
-
const candidate = new Date(start.getTime() + i * 60_000);
|
|
369
|
-
if (cronMatches(schedule.expression, candidate)) {
|
|
370
|
-
return candidate.toISOString();
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
return null;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
if (schedule.type === "once") {
|
|
377
|
-
if (agentState.lastWokeAt) return null;
|
|
378
|
-
return schedule.runAt;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
return null;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
// --- Briefing file resolution -----------------------------------------------
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* Resolve the briefing file for an agent by convention:
|
|
388
|
-
* 1. Scan ~/.cache/fit/basecamp/state/ for files matching {agent_name}_*.md
|
|
389
|
-
* 2. Fall back to the KB's knowledge/Briefings/ directory (latest .md file)
|
|
390
|
-
*
|
|
391
|
-
* @param {string} agentName
|
|
392
|
-
* @param {object} agentConfig
|
|
393
|
-
* @returns {string|null}
|
|
394
|
-
*/
|
|
395
|
-
function resolveBriefingFile(agentName, agentConfig) {
|
|
396
|
-
// 1. Scan state directory for agent-specific files (latest by mtime)
|
|
397
|
-
const stateDir = join(CACHE_DIR, "state");
|
|
398
|
-
if (existsSync(stateDir)) {
|
|
399
|
-
const prefix = agentName.replace(/-/g, "_") + "_";
|
|
400
|
-
const matches = readdirSync(stateDir).filter(
|
|
401
|
-
(f) => f.startsWith(prefix) && f.endsWith(".md"),
|
|
402
|
-
);
|
|
403
|
-
if (matches.length > 0) {
|
|
404
|
-
let latest = join(stateDir, matches[0]);
|
|
405
|
-
let latestMtime = statSync(latest).mtimeMs;
|
|
406
|
-
for (let i = 1; i < matches.length; i++) {
|
|
407
|
-
const p = join(stateDir, matches[i]);
|
|
408
|
-
const mt = statSync(p).mtimeMs;
|
|
409
|
-
if (mt > latestMtime) {
|
|
410
|
-
latest = p;
|
|
411
|
-
latestMtime = mt;
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
return latest;
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
// 2. Fall back to KB briefings directory (latest by name)
|
|
419
|
-
if (agentConfig.kb) {
|
|
420
|
-
const dir = join(expandPath(agentConfig.kb), "knowledge", "Briefings");
|
|
421
|
-
if (existsSync(dir)) {
|
|
422
|
-
const files = readdirSync(dir)
|
|
423
|
-
.filter((f) => f.endsWith(".md"))
|
|
424
|
-
.sort()
|
|
425
|
-
.reverse();
|
|
426
|
-
if (files.length > 0) return join(dir, files[0]);
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
return null;
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// --- Socket server ----------------------------------------------------------
|
|
434
|
-
|
|
435
|
-
/** @param {import('node:net').Socket} socket @param {object} data */
|
|
436
|
-
function send(socket, data) {
|
|
437
|
-
try {
|
|
438
|
-
socket.write(JSON.stringify(data) + "\n");
|
|
439
|
-
} catch {}
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
function handleStatusRequest(socket) {
|
|
443
|
-
const config = loadConfig();
|
|
444
|
-
const state = loadState();
|
|
445
|
-
const now = new Date();
|
|
446
|
-
const agents = {};
|
|
447
|
-
|
|
448
|
-
for (const [name, agent] of Object.entries(config.agents)) {
|
|
449
|
-
const as = state.agents[name] || {};
|
|
450
|
-
agents[name] = {
|
|
451
|
-
enabled: agent.enabled !== false,
|
|
452
|
-
status: as.status || "never-woken",
|
|
453
|
-
lastWokeAt: as.lastWokeAt || null,
|
|
454
|
-
nextWakeAt: computeNextWakeAt(agent, as, now),
|
|
455
|
-
lastAction: as.lastAction || null,
|
|
456
|
-
lastDecision: as.lastDecision || null,
|
|
457
|
-
wakeCount: as.wakeCount || 0,
|
|
458
|
-
lastError: as.lastError || null,
|
|
459
|
-
kbPath: agent.kb ? expandPath(agent.kb) : null,
|
|
460
|
-
briefingFile: resolveBriefingFile(name, agent),
|
|
461
|
-
};
|
|
462
|
-
if (as.startedAt) agents[name].startedAt = as.startedAt;
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
send(socket, {
|
|
466
|
-
type: "status",
|
|
467
|
-
uptime: daemonStartedAt
|
|
468
|
-
? Math.floor((Date.now() - daemonStartedAt) / 1000)
|
|
469
|
-
: 0,
|
|
470
|
-
agents,
|
|
471
|
-
});
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
function handleMessage(socket, line) {
|
|
475
|
-
let request;
|
|
476
|
-
try {
|
|
477
|
-
request = JSON.parse(line);
|
|
478
|
-
} catch {
|
|
479
|
-
send(socket, { type: "error", message: "Invalid JSON" });
|
|
480
|
-
return;
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
if (request.type === "status") return handleStatusRequest(socket);
|
|
484
|
-
|
|
485
|
-
if (request.type === "shutdown") {
|
|
486
|
-
log("Shutdown requested via socket.");
|
|
487
|
-
send(socket, { type: "ack", command: "shutdown" });
|
|
488
|
-
socket.end();
|
|
489
|
-
killActiveChildren();
|
|
490
|
-
process.exit(0);
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
if (request.type === "wake") {
|
|
494
|
-
if (!request.agent) {
|
|
495
|
-
send(socket, { type: "error", message: "Missing agent name" });
|
|
496
|
-
return;
|
|
497
|
-
}
|
|
498
|
-
const config = loadConfig();
|
|
499
|
-
const agent = config.agents[request.agent];
|
|
500
|
-
if (!agent) {
|
|
501
|
-
send(socket, {
|
|
502
|
-
type: "error",
|
|
503
|
-
message: `Agent not found: ${request.agent}`,
|
|
504
|
-
});
|
|
505
|
-
return;
|
|
506
|
-
}
|
|
507
|
-
send(socket, { type: "ack", command: "wake", agent: request.agent });
|
|
508
|
-
const state = loadState();
|
|
509
|
-
wakeAgent(request.agent, agent, state).catch(() => {});
|
|
510
|
-
return;
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
send(socket, {
|
|
514
|
-
type: "error",
|
|
515
|
-
message: `Unknown request type: ${request.type}`,
|
|
516
|
-
});
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
function startSocketServer() {
|
|
520
|
-
try {
|
|
521
|
-
unlinkSync(SOCKET_PATH);
|
|
522
|
-
} catch {}
|
|
523
|
-
|
|
524
|
-
const server = createServer((socket) => {
|
|
525
|
-
let buffer = "";
|
|
526
|
-
socket.on("data", (data) => {
|
|
527
|
-
buffer += data.toString();
|
|
528
|
-
let idx;
|
|
529
|
-
while ((idx = buffer.indexOf("\n")) !== -1) {
|
|
530
|
-
const line = buffer.slice(0, idx).trim();
|
|
531
|
-
buffer = buffer.slice(idx + 1);
|
|
532
|
-
if (line) handleMessage(socket, line);
|
|
533
|
-
}
|
|
534
|
-
});
|
|
535
|
-
socket.on("error", () => {});
|
|
536
|
-
});
|
|
537
|
-
|
|
538
|
-
server.listen(SOCKET_PATH, () => {
|
|
539
|
-
chmodSync(SOCKET_PATH, 0o600);
|
|
540
|
-
log(`Socket server listening on ${SOCKET_PATH}`);
|
|
541
|
-
});
|
|
542
|
-
|
|
543
|
-
server.on("error", (err) => {
|
|
544
|
-
log(`Socket server error: ${err.message}`);
|
|
545
|
-
});
|
|
546
|
-
|
|
547
|
-
const cleanup = () => {
|
|
548
|
-
killActiveChildren();
|
|
549
|
-
server.close();
|
|
550
|
-
try {
|
|
551
|
-
unlinkSync(SOCKET_PATH);
|
|
552
|
-
} catch {}
|
|
553
|
-
process.exit(0);
|
|
554
|
-
};
|
|
555
|
-
process.on("SIGTERM", cleanup);
|
|
556
|
-
process.on("SIGINT", cleanup);
|
|
557
|
-
|
|
558
|
-
return server;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
// --- Graceful shutdown -------------------------------------------------------
|
|
562
|
-
|
|
563
|
-
/**
|
|
564
|
-
* Send SIGTERM to all tracked child processes (running claude sessions).
|
|
565
|
-
* Called on daemon shutdown to prevent orphaned processes.
|
|
566
|
-
*/
|
|
567
|
-
function killActiveChildren() {
|
|
568
|
-
for (const pid of activeChildren) {
|
|
569
|
-
try {
|
|
570
|
-
process.kill(pid, "SIGTERM");
|
|
571
|
-
log(`Sent SIGTERM to child PID ${pid}`);
|
|
572
|
-
} catch {
|
|
573
|
-
// Already exited
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
activeChildren.clear();
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
/**
|
|
580
|
-
* Connect to the daemon socket and request graceful shutdown.
|
|
581
|
-
* Waits up to 5 seconds for the daemon to exit.
|
|
582
|
-
* @returns {Promise<boolean>} true if shutdown succeeded
|
|
583
|
-
*/
|
|
584
|
-
async function requestShutdown() {
|
|
585
|
-
if (!existsSync(SOCKET_PATH)) {
|
|
586
|
-
console.log("Daemon not running (no socket).");
|
|
587
|
-
return false;
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
const { createConnection } = await import("node:net");
|
|
591
|
-
return new Promise((resolve) => {
|
|
592
|
-
const timeout = setTimeout(() => {
|
|
593
|
-
console.log("Shutdown timed out.");
|
|
594
|
-
socket.destroy();
|
|
595
|
-
resolve(false);
|
|
596
|
-
}, 5000);
|
|
597
|
-
|
|
598
|
-
const socket = createConnection(SOCKET_PATH, () => {
|
|
599
|
-
socket.write(JSON.stringify({ type: "shutdown" }) + "\n");
|
|
600
|
-
});
|
|
601
|
-
|
|
602
|
-
let buffer = "";
|
|
603
|
-
socket.on("data", (data) => {
|
|
604
|
-
buffer += data.toString();
|
|
605
|
-
if (buffer.includes("\n")) {
|
|
606
|
-
clearTimeout(timeout);
|
|
607
|
-
console.log("Daemon stopped.");
|
|
608
|
-
socket.destroy();
|
|
609
|
-
resolve(true);
|
|
610
|
-
}
|
|
611
|
-
});
|
|
612
|
-
|
|
613
|
-
socket.on("error", () => {
|
|
614
|
-
clearTimeout(timeout);
|
|
615
|
-
console.log("Daemon not running (connection refused).");
|
|
616
|
-
resolve(false);
|
|
617
|
-
});
|
|
618
|
-
|
|
619
|
-
socket.on("close", () => {
|
|
620
|
-
clearTimeout(timeout);
|
|
621
|
-
resolve(true);
|
|
622
|
-
});
|
|
623
|
-
});
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
// --- Daemon -----------------------------------------------------------------
|
|
627
|
-
|
|
628
|
-
function daemon() {
|
|
629
|
-
daemonStartedAt = Date.now();
|
|
630
|
-
log("Scheduler daemon started. Polling every 60 seconds.");
|
|
631
|
-
log(`Config: ${CONFIG_PATH} State: ${STATE_PATH}`);
|
|
632
|
-
|
|
633
|
-
// Reset any agents left "active" from a previous daemon session.
|
|
634
|
-
const state = loadState();
|
|
635
|
-
resetStaleAgents(state, { reason: "Daemon restarted" });
|
|
636
|
-
|
|
637
|
-
startSocketServer();
|
|
638
|
-
wakeDueAgents().catch((err) => log(`Error: ${err.message}`));
|
|
639
|
-
setInterval(async () => {
|
|
640
|
-
try {
|
|
641
|
-
await wakeDueAgents();
|
|
642
|
-
} catch (err) {
|
|
643
|
-
log(`Error: ${err.message}`);
|
|
644
|
-
}
|
|
645
|
-
}, 60_000);
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
// --- Init knowledge base ----------------------------------------------------
|
|
649
|
-
|
|
650
|
-
/**
|
|
651
|
-
* Resolve the template directory or exit with an error.
|
|
652
|
-
* @returns {string}
|
|
653
|
-
*/
|
|
654
123
|
function requireTemplateDir() {
|
|
655
124
|
const bundle = getBundlePath();
|
|
656
125
|
if (bundle) {
|
|
@@ -666,148 +135,47 @@ function requireTemplateDir() {
|
|
|
666
135
|
process.exit(1);
|
|
667
136
|
}
|
|
668
137
|
|
|
669
|
-
|
|
670
|
-
* Copy bundled files (CLAUDE.md, skills, agents) from template to a KB.
|
|
671
|
-
* Shared by --init and --update.
|
|
672
|
-
* @param {string} tpl Path to the template directory
|
|
673
|
-
* @param {string} dest Path to the target knowledge base
|
|
674
|
-
*/
|
|
675
|
-
function copyBundledFiles(tpl, dest) {
|
|
676
|
-
// CLAUDE.md
|
|
677
|
-
copyFileSync(join(tpl, "CLAUDE.md"), join(dest, "CLAUDE.md"));
|
|
678
|
-
console.log(` Updated CLAUDE.md`);
|
|
679
|
-
|
|
680
|
-
// Settings — merge template permissions into existing settings
|
|
681
|
-
mergeSettings(tpl, dest);
|
|
682
|
-
|
|
683
|
-
// Skills and agents
|
|
684
|
-
for (const sub of ["skills", "agents"]) {
|
|
685
|
-
const src = join(tpl, ".claude", sub);
|
|
686
|
-
if (!existsSync(src)) continue;
|
|
687
|
-
cpSync(src, join(dest, ".claude", sub), { recursive: true });
|
|
688
|
-
const entries = readdirSync(src, { withFileTypes: true }).filter((d) =>
|
|
689
|
-
sub === "skills" ? d.isDirectory() : d.name.endsWith(".md"),
|
|
690
|
-
);
|
|
691
|
-
const names = entries.map((d) =>
|
|
692
|
-
sub === "agents" ? d.name.replace(".md", "") : d.name,
|
|
693
|
-
);
|
|
694
|
-
console.log(` Updated ${names.length} ${sub}: ${names.join(", ")}`);
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
/**
|
|
699
|
-
* Merge template settings.json into the destination's settings.json.
|
|
700
|
-
* Adds any missing entries from allow, deny, and additionalDirectories
|
|
701
|
-
* without removing user customizations.
|
|
702
|
-
* @param {string} tpl Template directory
|
|
703
|
-
* @param {string} dest Knowledge base directory
|
|
704
|
-
*/
|
|
705
|
-
function mergeSettings(tpl, dest) {
|
|
706
|
-
const src = join(tpl, ".claude", "settings.json");
|
|
707
|
-
if (!existsSync(src)) return;
|
|
138
|
+
// --- Daemon ------------------------------------------------------------------
|
|
708
139
|
|
|
709
|
-
|
|
140
|
+
function daemon() {
|
|
141
|
+
const daemonStartedAt = Date.now();
|
|
142
|
+
log("Scheduler daemon started. Polling every 60 seconds.");
|
|
143
|
+
log(`Config: ${CONFIG_PATH} State: ${STATE_PATH}`);
|
|
710
144
|
|
|
711
|
-
//
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
145
|
+
// Reset any agents left "active" from a previous daemon session.
|
|
146
|
+
const state = stateManager.load();
|
|
147
|
+
stateManager.resetStaleAgents(state, { reason: "Daemon restarted" }, log);
|
|
148
|
+
|
|
149
|
+
const socketServer = new SocketServer(
|
|
150
|
+
SOCKET_PATH,
|
|
151
|
+
scheduler,
|
|
152
|
+
agentRunner,
|
|
153
|
+
stateManager,
|
|
154
|
+
loadConfig,
|
|
155
|
+
log,
|
|
156
|
+
CACHE_DIR,
|
|
157
|
+
daemonStartedAt,
|
|
158
|
+
);
|
|
159
|
+
socketServer.start();
|
|
718
160
|
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
// Merge array fields
|
|
726
|
-
for (const key of ["allow", "deny", "additionalDirectories"]) {
|
|
727
|
-
if (!tp[key]?.length) continue;
|
|
728
|
-
const set = new Set((ep[key] ||= []));
|
|
729
|
-
for (const entry of tp[key]) {
|
|
730
|
-
if (!set.has(entry)) {
|
|
731
|
-
ep[key].push(entry);
|
|
732
|
-
set.add(entry);
|
|
733
|
-
added++;
|
|
734
|
-
}
|
|
161
|
+
scheduler.wakeDueAgents().catch((err) => log(`Error: ${err.message}`));
|
|
162
|
+
setInterval(async () => {
|
|
163
|
+
try {
|
|
164
|
+
await scheduler.wakeDueAgents();
|
|
165
|
+
} catch (err) {
|
|
166
|
+
log(`Error: ${err.message}`);
|
|
735
167
|
}
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
// Merge scalar fields
|
|
739
|
-
if (tp.defaultMode && !ep.defaultMode) {
|
|
740
|
-
ep.defaultMode = tp.defaultMode;
|
|
741
|
-
added++;
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
if (added > 0) {
|
|
745
|
-
writeJSON(destPath, existing);
|
|
746
|
-
console.log(` Updated settings.json (${added} new entries)`);
|
|
747
|
-
} else {
|
|
748
|
-
console.log(` Settings up to date`);
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
function initKB(targetPath) {
|
|
753
|
-
const dest = expandPath(targetPath);
|
|
754
|
-
if (existsSync(join(dest, "CLAUDE.md"))) {
|
|
755
|
-
console.error(`Knowledge base already exists at ${dest}`);
|
|
756
|
-
process.exit(1);
|
|
757
|
-
}
|
|
758
|
-
const tpl = requireTemplateDir();
|
|
759
|
-
|
|
760
|
-
ensureDir(dest);
|
|
761
|
-
for (const d of [
|
|
762
|
-
"knowledge/People",
|
|
763
|
-
"knowledge/Organizations",
|
|
764
|
-
"knowledge/Projects",
|
|
765
|
-
"knowledge/Topics",
|
|
766
|
-
"knowledge/Briefings",
|
|
767
|
-
])
|
|
768
|
-
ensureDir(join(dest, d));
|
|
769
|
-
|
|
770
|
-
// User-specific files (not overwritten by --update)
|
|
771
|
-
copyFileSync(join(tpl, "USER.md"), join(dest, "USER.md"));
|
|
772
|
-
|
|
773
|
-
// Bundled files (shared with --update)
|
|
774
|
-
copyBundledFiles(tpl, dest);
|
|
775
|
-
|
|
776
|
-
console.log(
|
|
777
|
-
`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`,
|
|
778
|
-
);
|
|
168
|
+
}, 60_000);
|
|
779
169
|
}
|
|
780
170
|
|
|
781
|
-
// --- Update
|
|
171
|
+
// --- Update ------------------------------------------------------------------
|
|
782
172
|
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
* Settings.json is merged — new template entries are added without
|
|
787
|
-
* removing user customizations.
|
|
788
|
-
* @param {string} targetPath
|
|
789
|
-
*/
|
|
790
|
-
function updateKB(targetPath) {
|
|
791
|
-
const dest = expandPath(targetPath);
|
|
792
|
-
if (!existsSync(join(dest, "CLAUDE.md"))) {
|
|
793
|
-
console.error(`No knowledge base found at ${dest}`);
|
|
794
|
-
process.exit(1);
|
|
795
|
-
}
|
|
796
|
-
const tpl = requireTemplateDir();
|
|
797
|
-
copyBundledFiles(tpl, dest);
|
|
798
|
-
console.log(`\nKnowledge base updated: ${dest}`);
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
/**
|
|
802
|
-
* Run --update for an explicit path or every unique KB in the scheduler config.
|
|
803
|
-
*/
|
|
804
|
-
function runUpdate() {
|
|
805
|
-
if (args[1]) {
|
|
806
|
-
updateKB(args[1]);
|
|
173
|
+
function runUpdate(cliArgs) {
|
|
174
|
+
if (cliArgs[1]) {
|
|
175
|
+
kbManager.update(cliArgs[1], requireTemplateDir());
|
|
807
176
|
return;
|
|
808
177
|
}
|
|
809
178
|
|
|
810
|
-
// Discover unique KB paths from config
|
|
811
179
|
const config = loadConfig();
|
|
812
180
|
const kbPaths = [
|
|
813
181
|
...new Set(
|
|
@@ -827,15 +195,15 @@ function runUpdate() {
|
|
|
827
195
|
|
|
828
196
|
for (const kb of kbPaths) {
|
|
829
197
|
console.log(`\nUpdating ${kb}...`);
|
|
830
|
-
|
|
198
|
+
kbManager.update(kb, requireTemplateDir());
|
|
831
199
|
}
|
|
832
200
|
}
|
|
833
201
|
|
|
834
|
-
// --- Status
|
|
202
|
+
// --- Status ------------------------------------------------------------------
|
|
835
203
|
|
|
836
204
|
function showStatus() {
|
|
837
|
-
const config = loadConfig()
|
|
838
|
-
|
|
205
|
+
const config = loadConfig();
|
|
206
|
+
const state = stateManager.load();
|
|
839
207
|
console.log("\nBasecamp Scheduler\n==================\n");
|
|
840
208
|
|
|
841
209
|
const agents = Object.entries(config.agents || {});
|
|
@@ -860,7 +228,7 @@ function showStatus() {
|
|
|
860
228
|
}
|
|
861
229
|
}
|
|
862
230
|
|
|
863
|
-
// --- Validate
|
|
231
|
+
// --- Validate ----------------------------------------------------------------
|
|
864
232
|
|
|
865
233
|
function findInLocalOrGlobal(kbPath, subPath) {
|
|
866
234
|
const local = join(kbPath, ".claude", subPath);
|
|
@@ -906,7 +274,7 @@ function validate() {
|
|
|
906
274
|
if (errors > 0) process.exit(1);
|
|
907
275
|
}
|
|
908
276
|
|
|
909
|
-
// --- Help
|
|
277
|
+
// --- Help --------------------------------------------------------------------
|
|
910
278
|
|
|
911
279
|
function showHelp() {
|
|
912
280
|
const bin = "fit-basecamp";
|
|
@@ -929,11 +297,11 @@ Logs: ~/.fit/basecamp/logs/
|
|
|
929
297
|
`);
|
|
930
298
|
}
|
|
931
299
|
|
|
932
|
-
// --- CLI entry point
|
|
300
|
+
// --- CLI entry point ---------------------------------------------------------
|
|
933
301
|
|
|
934
302
|
const args = process.argv.slice(2);
|
|
935
303
|
const command = args[0];
|
|
936
|
-
|
|
304
|
+
mkdirSync(BASECAMP_HOME, { recursive: true });
|
|
937
305
|
|
|
938
306
|
function requireArg(usage) {
|
|
939
307
|
if (!args[1]) {
|
|
@@ -949,25 +317,29 @@ const commands = {
|
|
|
949
317
|
"--daemon": daemon,
|
|
950
318
|
"--validate": validate,
|
|
951
319
|
"--stop": async () => {
|
|
952
|
-
const stopped = await requestShutdown();
|
|
320
|
+
const stopped = await requestShutdown(SOCKET_PATH);
|
|
953
321
|
if (!stopped) process.exit(1);
|
|
954
322
|
},
|
|
955
323
|
"--status": showStatus,
|
|
956
|
-
"--init": () =>
|
|
957
|
-
|
|
324
|
+
"--init": () =>
|
|
325
|
+
kbManager.init(
|
|
326
|
+
requireArg("Usage: fit-basecamp --init <path>"),
|
|
327
|
+
requireTemplateDir(),
|
|
328
|
+
),
|
|
329
|
+
"--update": () => runUpdate(args),
|
|
958
330
|
"--wake": async () => {
|
|
959
331
|
const name = requireArg("Usage: fit-basecamp --wake <agent-name>");
|
|
960
|
-
const config = loadConfig()
|
|
961
|
-
|
|
962
|
-
|
|
332
|
+
const config = loadConfig();
|
|
333
|
+
const state = stateManager.load();
|
|
334
|
+
const agent = config.agents[name];
|
|
963
335
|
if (!agent) {
|
|
964
336
|
console.error(
|
|
965
337
|
`Agent "${name}" not found. Available: ${Object.keys(config.agents).join(", ") || "(none)"}`,
|
|
966
338
|
);
|
|
967
339
|
process.exit(1);
|
|
968
340
|
}
|
|
969
|
-
await
|
|
341
|
+
await agentRunner.wake(name, agent, state);
|
|
970
342
|
},
|
|
971
343
|
};
|
|
972
344
|
|
|
973
|
-
await (commands[command] || wakeDueAgents)();
|
|
345
|
+
await (commands[command] || (() => scheduler.wakeDueAgents()))();
|