@forwardimpact/outpost 3.6.0 → 3.7.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.7.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
@@ -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,
@@ -436,17 +436,28 @@ export async function run(runtime, version) {
436
436
  cli.usageError("missing required argument <agent>");
437
437
  return 2;
438
438
  }
439
- const config = await loadConfig();
440
- const state = await stateManager.load();
441
- const agent = config.agents[args[0]];
442
- if (!agent) {
439
+ // Always route the wake through the running daemon. The daemon is the
440
+ // only spawn site that descends from fit-outpost.app, so a `claude`
441
+ // spawned there inherits the app as its TCC responsible process and a
442
+ // single grant to the app covers it. Spawning from this CLI process
443
+ // would attribute the access to the terminal instead, breaking the
444
+ // single-grant model. If no daemon is running there is nowhere to wake
445
+ // with correct attribution, so this errors rather than spawning locally.
446
+ const result = await requestWake(SOCKET_PATH, args[0], runtime);
447
+ if (result.ok) {
448
+ log(`Wake dispatched to daemon for "${args[0]}".`);
449
+ return 0;
450
+ }
451
+ if (result.reason === "not-running") {
443
452
  cli.error(
444
- `agent "${args[0]}" not found. Available: ${Object.keys(config.agents).join(", ") || "(none)"}`,
453
+ "daemon not running. Start fit-outpost.app (or run `fit-outpost daemon`) before waking an agent.",
445
454
  );
446
- return 1;
455
+ } else if (result.reason === "timeout") {
456
+ cli.error("daemon did not respond to the wake request.");
457
+ } else {
458
+ cli.error(result.message);
447
459
  }
448
- await agentRunner.wake(args[0], agent, state, config.env);
449
- return 0;
460
+ return 1;
450
461
  },
451
462
  init: async () => {
452
463
  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