@forwardimpact/outpost 3.6.0 → 3.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/outpost",
3
- "version": "3.6.0",
3
+ "version": "3.8.0",
4
4
  "description": "Personal operations center — context from email, calendar, and knowledge assembled so preparation is continuous, not a morning scramble.",
5
5
  "homepage": "https://www.forwardimpact.team",
6
6
  "repository": {
package/src/outpost.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // fit-outpost daemon Run continuously (poll every 60s)
6
6
  // fit-outpost wake <agent> Wake a specific agent immediately
7
7
  // fit-outpost init <path> Initialize a new knowledge base
8
- // fit-outpost update [path] Update KB with latest CLAUDE.md, agents and skills
8
+ // fit-outpost update [path] Update KB with latest CLAUDE.md, agents and skills (defaults to current directory)
9
9
  // fit-outpost stop Gracefully stop daemon and all running agents
10
10
  // fit-outpost validate Validate agent definitions exist
11
11
  // fit-outpost status Show agent status
@@ -26,7 +26,7 @@ import { StateManager } from "./state-manager.js";
26
26
  import { AgentRunner } from "./agent-runner.js";
27
27
  import { Scheduler, formatLocalTime } from "./scheduler.js";
28
28
  import { KBManager } from "./kb-manager.js";
29
- import { SocketServer, requestShutdown } from "./socket-server.js";
29
+ import { SocketServer, requestShutdown, requestWake } from "./socket-server.js";
30
30
  import {
31
31
  readPosture,
32
32
  writePosture,
@@ -64,7 +64,8 @@ function buildDefinition(version) {
64
64
  {
65
65
  name: "update",
66
66
  args: "[path]",
67
- description: "Update KB with latest CLAUDE.md, agents and skills",
67
+ description:
68
+ "Update KB with latest CLAUDE.md, agents and skills (defaults to current directory)",
68
69
  },
69
70
  {
70
71
  name: "stop",
@@ -302,39 +303,11 @@ export async function run(runtime, version) {
302
303
  const tpl = await requireTemplateDir();
303
304
  if (tpl === null) return 1;
304
305
 
305
- if (args[0]) {
306
- const result = await kbManager.update(args[0], tpl);
307
- if (!result.ok) {
308
- proc.stderr.write(result.error + "\n");
309
- return result.code;
310
- }
311
- return 0;
312
- }
313
-
314
- const config = await loadConfig();
315
- const kbPaths = [
316
- ...new Set(
317
- Object.values(config.agents)
318
- .filter((a) => a.kb)
319
- .map((a) => expandPath(a.kb)),
320
- ),
321
- ];
322
-
323
- if (kbPaths.length === 0) {
324
- proc.stderr.write(
325
- "No knowledge bases configured and no path given.\n" +
326
- "Usage: fit-outpost update [path]\n",
327
- );
328
- return 1;
329
- }
330
-
331
- for (const kb of kbPaths) {
332
- logger.info(`\nUpdating ${kb}...`);
333
- const result = await kbManager.update(kb, tpl);
334
- if (!result.ok) {
335
- proc.stderr.write(result.error + "\n");
336
- return result.code;
337
- }
306
+ const target = args[0] ?? proc.cwd();
307
+ const result = await kbManager.update(target, tpl);
308
+ if (!result.ok) {
309
+ proc.stderr.write(result.error + "\n");
310
+ return result.code;
338
311
  }
339
312
  return 0;
340
313
  }
@@ -436,17 +409,28 @@ export async function run(runtime, version) {
436
409
  cli.usageError("missing required argument <agent>");
437
410
  return 2;
438
411
  }
439
- const config = await loadConfig();
440
- const state = await stateManager.load();
441
- const agent = config.agents[args[0]];
442
- if (!agent) {
412
+ // Always route the wake through the running daemon. The daemon is the
413
+ // only spawn site that descends from fit-outpost.app, so a `claude`
414
+ // spawned there inherits the app as its TCC responsible process and a
415
+ // single grant to the app covers it. Spawning from this CLI process
416
+ // would attribute the access to the terminal instead, breaking the
417
+ // single-grant model. If no daemon is running there is nowhere to wake
418
+ // with correct attribution, so this errors rather than spawning locally.
419
+ const result = await requestWake(SOCKET_PATH, args[0], runtime);
420
+ if (result.ok) {
421
+ log(`Wake dispatched to daemon for "${args[0]}".`);
422
+ return 0;
423
+ }
424
+ if (result.reason === "not-running") {
443
425
  cli.error(
444
- `agent "${args[0]}" not found. Available: ${Object.keys(config.agents).join(", ") || "(none)"}`,
426
+ "daemon not running. Start fit-outpost.app (or run `fit-outpost daemon`) before waking an agent.",
445
427
  );
446
- return 1;
428
+ } else if (result.reason === "timeout") {
429
+ cli.error("daemon did not respond to the wake request.");
430
+ } else {
431
+ cli.error(result.message);
447
432
  }
448
- await agentRunner.wake(args[0], agent, state, config.env);
449
- return 0;
433
+ return 1;
450
434
  },
451
435
  init: async () => {
452
436
  if (!args[0]) {
@@ -323,6 +323,71 @@ export class SocketServer {
323
323
  }
324
324
  }
325
325
 
326
+ /**
327
+ * Connect to the running daemon and ask it to wake an agent.
328
+ *
329
+ * The wake runs inside the daemon process, which is the only spawn site that
330
+ * descends from fit-outpost.app — so the spawned `claude` inherits the app as
331
+ * its TCC responsible process. Routing every wake through the daemon is what
332
+ * keeps the single-grant model intact; a wake spawned from this CLI process
333
+ * would be attributed to the terminal instead. The daemon acknowledges
334
+ * (`ack`) once it has accepted the request and then runs the wake
335
+ * asynchronously, so this resolves on the ack rather than on completion.
336
+ *
337
+ * @param {string} socketPath
338
+ * @param {string} agent - Agent name to wake.
339
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
340
+ * Injected runtime bag (uses `fsSync` and `clock`).
341
+ * @returns {Promise<{ ok: boolean, reason?: "not-running"|"timeout"|"error", message?: string }>}
342
+ */
343
+ export async function requestWake(socketPath, agent, runtime) {
344
+ if (!runtime?.fsSync) throw new Error("runtime.fsSync is required");
345
+ if (!runtime?.clock) throw new Error("runtime.clock is required");
346
+ if (!runtime.fsSync.existsSync(socketPath)) {
347
+ return { ok: false, reason: "not-running" };
348
+ }
349
+
350
+ return new Promise((resolve) => {
351
+ const timeout = runtime.clock.setTimeout(() => {
352
+ socket.destroy();
353
+ resolve({ ok: false, reason: "timeout" });
354
+ }, 5000);
355
+
356
+ const socket = createConnection(socketPath, () => {
357
+ socket.write(JSON.stringify({ type: "wake", agent }) + "\n");
358
+ });
359
+
360
+ let buffer = "";
361
+ socket.on("data", (data) => {
362
+ buffer += data.toString();
363
+ const idx = buffer.indexOf("\n");
364
+ if (idx === -1) return;
365
+ runtime.clock.clearTimeout(timeout);
366
+ let msg = null;
367
+ try {
368
+ msg = JSON.parse(buffer.slice(0, idx));
369
+ } catch {}
370
+ socket.destroy();
371
+ if (msg && msg.type === "ack" && msg.command === "wake") {
372
+ resolve({ ok: true });
373
+ } else {
374
+ resolve({
375
+ ok: false,
376
+ reason: "error",
377
+ message: msg?.message || "daemon rejected wake request",
378
+ });
379
+ }
380
+ });
381
+
382
+ // A stale socket file (daemon crashed) refuses the connection; treat it
383
+ // the same as a missing daemon.
384
+ socket.on("error", () => {
385
+ runtime.clock.clearTimeout(timeout);
386
+ resolve({ ok: false, reason: "not-running" });
387
+ });
388
+ });
389
+ }
390
+
326
391
  /**
327
392
  * Connect to the daemon socket and request graceful shutdown.
328
393
  * @param {string} socketPath