privateer-agent 0.4.1 → 0.5.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": "privateer-agent",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Privateer — a provider-agnostic, safe-by-default terminal coding agent with TEE/Tinfoil attestation, rebuilt on the Pi toolkit. Bring your own model across 20 providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,13 @@
1
+ // Harbor hosted mode.
2
+ //
3
+ // When true, this daemon is running inside Privateer's confidential-VM fleet
4
+ // (the host orchestrator sets HARBOR_HOSTED=1), not on a user's own machine.
5
+ // Hosted daemons run on-demand: they report their next routine fire time to the
6
+ // server and idle-suspend when there's no work, so the server can wake them
7
+ // again in time. A daemon on a user's laptop leaves this off and keeps running
8
+ // its own cron continuously.
9
+ //
10
+ // Read via process.env at call time, mirroring PRIVATEER_HOME / PRIVATEER_SERVER_URL.
11
+ export function isHosted(): boolean {
12
+ return process.env.HARBOR_HOSTED === "1";
13
+ }
@@ -50,6 +50,7 @@ import { deliver, type RelayPusher, type CloudPusher } from "../routines/deliver
50
50
  import { sealJson, decodeAccountPublicKey } from "../crypto/outboxSeal.ts";
51
51
  import { redactText, collectSecrets } from "../util/redact.ts";
52
52
  import { startIpcServer, type IpcRequest, type IpcResponse } from "./ipc.ts";
53
+ import { isHosted } from "../config/hosted.ts";
53
54
 
54
55
  // The safe, read-only toolset for unattended runs — Pi builtins with no
55
56
  // write/edit/bash, so a routine firing with nobody watching can't mutate the
@@ -59,6 +60,10 @@ import { startIpcServer, type IpcRequest, type IpcResponse } from "./ipc.ts";
59
60
  const SAFE_TOOLS = ["read", "grep", "find", "ls"];
60
61
 
61
62
  const TICK_MS = 60_000; // scan for due routines once a minute
63
+ // Harbor hosted mode (isHosted): suspend after this much idle time with no work,
64
+ // and stay up if a routine is due within the lead window (avoids suspend→wake churn).
65
+ const HOSTED_IDLE_MS = Number(process.env.HARBOR_IDLE_MS) || 5 * 60_000;
66
+ const HOSTED_SUSPEND_MIN_LEAD_MS = Number(process.env.HARBOR_SUSPEND_MIN_LEAD_MS) || 2 * 60_000;
62
67
  const MAX_CLOUD_PLAINTEXT = 45_000;
63
68
  // How long a workflow `human_gate` (or a script-approval prompt) waits for the app to
64
69
  // answer before it fail-closes to "no response" (the runner then defers the run). Bounds
@@ -158,6 +163,11 @@ export class Daemon {
158
163
  private relay?: RelayClient;
159
164
  private controllerAttached = false;
160
165
  private relayTerminated = false;
166
+ // Harbor hosted mode: last time a controller attached or a routine ran. Drives
167
+ // idle-suspend (no `controller_detached` frame exists, so we gate on inactivity
168
+ // + no live work rather than on controllerAttached, which never resets while the
169
+ // socket stays open).
170
+ private lastActivityAt = Date.now();
161
171
  // Live, app-drivable sessions spawned on demand (task_spawn). Each has its OWN relay
162
172
  // terminal (task-<uuid>); the daemon just keeps handles so it can reap them on shutdown.
163
173
  private readonly liveTasks = new Map<string, LiveTaskHandle>();
@@ -406,6 +416,8 @@ export class Daemon {
406
416
 
407
417
  private onControllerAttached(): void {
408
418
  this.controllerAttached = true;
419
+ this.markActivity(); // hosted: a driver is present — reset the idle timer
420
+
409
421
  this.relay?.sendSnapshot([{ kind: "notice", text: "Privateer routines — results will appear here as they run." }]);
410
422
  // Version + this terminal's identity public key (so the app can confirm this is
411
423
  // the terminal it PINNED at link time before sealing channel tokens to it). No
@@ -506,6 +518,74 @@ export class Daemon {
506
518
  await this.runRoutine(r);
507
519
  }
508
520
  }
521
+ await this.hostedTick();
522
+ }
523
+
524
+ // ── Harbor hosted mode ──────────────────────────────────────────────────────
525
+ // A hosted daemon runs on-demand: it reports its earliest upcoming fire time so
526
+ // the server can wake it while suspended, and idle-suspends when there's no work.
527
+ // No-op on a user's own machine (isHosted() === false).
528
+
529
+ private markActivity(): void { this.lastActivityAt = Date.now(); }
530
+
531
+ // Earliest fire time (ms) across enabled, valid routines — mirrors tick()'s fire
532
+ // filters so we never report/wait on a routine the scheduler won't actually fire.
533
+ private earliestFireMs(): number | null {
534
+ let earliest: number | null = null;
535
+ for (const r of loadRoutines()) {
536
+ if (!r.enabled || triggerError(r)) continue;
537
+ const nrStr = r.nextRun ?? computeNextRun(r)?.toISOString();
538
+ if (!nrStr) continue;
539
+ const t = Date.parse(nrStr);
540
+ if (Number.isNaN(t)) continue;
541
+ if (earliest === null || t < earliest) earliest = t;
542
+ }
543
+ return earliest;
544
+ }
545
+
546
+ // Report our next fire time to the server so its scheduler can wake us. When
547
+ // `suspending`, also flip the server-side status to suspended (we're about to
548
+ // exit) so the sweeper knows to wake us again. Best-effort: an offline instance
549
+ // retries next tick; a suspend report failing means we stay up this tick.
550
+ private async reportSchedule(suspending = false): Promise<boolean> {
551
+ if (!isHosted() || !hasCredentials()) return true;
552
+ const earliest = this.earliestFireMs();
553
+ try {
554
+ const res = await apiRequest("/api/harbor/agent/schedule", {
555
+ method: "POST",
556
+ headers: { "content-type": "application/json" },
557
+ body: JSON.stringify({
558
+ termId: routineRelayId(),
559
+ nextRoutineAt: earliest !== null ? new Date(earliest).toISOString() : null,
560
+ ...(suspending ? { suspended: true } : {}),
561
+ }),
562
+ });
563
+ return res.ok;
564
+ } catch {
565
+ return false;
566
+ }
567
+ }
568
+
569
+ private shouldSuspend(): boolean {
570
+ if (this.running.size > 0 || this.liveTasks.size > 0) return false;
571
+ if (Date.now() - this.lastActivityAt < HOSTED_IDLE_MS) return false;
572
+ const earliest = this.earliestFireMs();
573
+ if (earliest !== null && earliest - Date.now() < HOSTED_SUSPEND_MIN_LEAD_MS) return false;
574
+ return true;
575
+ }
576
+
577
+ private async hostedTick(): Promise<void> {
578
+ if (!isHosted()) return;
579
+ if (this.shouldSuspend()) {
580
+ // Tell the server we're suspending (+ our next fire) BEFORE exiting; only
581
+ // then tear down. If the report fails, stay up and try again next tick.
582
+ if (!(await this.reportSchedule(true))) return;
583
+ log("hosted: idle — suspending (server will wake for the next routine)");
584
+ this.stop();
585
+ void revokeLocalSessions().finally(() => process.exit(0));
586
+ return;
587
+ }
588
+ await this.reportSchedule();
509
589
  }
510
590
 
511
591
  // Execute a routine to completion and deliver the result. The one rewired seam:
@@ -514,6 +594,7 @@ export class Daemon {
514
594
  async runRoutine(routine: Routine): Promise<IpcResponse> {
515
595
  if (this.running.has(routine.id)) return { ok: false, message: "already running" };
516
596
  this.running.add(routine.id);
597
+ this.markActivity(); // hosted: work in progress — don't idle-suspend under it
517
598
  log(`running routine "${routine.name}"`);
518
599
 
519
600
  const config = loadDaemonConfig();