@phnx-labs/agents-cli 1.22.74 → 1.22.76

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.
Files changed (148) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/README.md +21 -9
  3. package/dist/bootstrap.js +7 -7
  4. package/dist/cli/command-registry.js +5 -0
  5. package/dist/commands/artifacts-setup.js +1 -1
  6. package/dist/commands/artifacts.js +1 -1
  7. package/dist/commands/auth.js +7 -1
  8. package/dist/commands/browser.js +104 -10
  9. package/dist/commands/commands.js +7 -6
  10. package/dist/commands/computer.d.ts +1 -0
  11. package/dist/commands/computer.js +26 -7
  12. package/dist/commands/config.js +27 -4
  13. package/dist/commands/cost.js +6 -4
  14. package/dist/commands/doctor.d.ts +6 -5
  15. package/dist/commands/doctor.js +32 -274
  16. package/dist/commands/exec.d.ts +2 -0
  17. package/dist/commands/exec.js +9 -2
  18. package/dist/commands/harness.d.ts +1 -0
  19. package/dist/commands/harness.js +11 -3
  20. package/dist/commands/hooks.js +7 -6
  21. package/dist/commands/mcp.js +7 -6
  22. package/dist/commands/memory.js +7 -7
  23. package/dist/commands/monitors.js +3 -2
  24. package/dist/commands/open.d.ts +25 -12
  25. package/dist/commands/open.js +24 -10
  26. package/dist/commands/permissions.js +7 -6
  27. package/dist/commands/plugins.js +21 -17
  28. package/dist/commands/route.js +33 -16
  29. package/dist/commands/rules.js +7 -12
  30. package/dist/commands/sessions-share.js +1 -1
  31. package/dist/commands/setup-watchdog.js +2 -2
  32. package/dist/commands/setup.js +22 -1
  33. package/dist/commands/share.js +26 -10
  34. package/dist/commands/skills.js +7 -6
  35. package/dist/commands/subagents.js +7 -6
  36. package/dist/commands/sync.js +81 -10
  37. package/dist/commands/view.js +4 -1
  38. package/dist/commands/watchdog.d.ts +1 -1
  39. package/dist/commands/watchdog.js +10 -10
  40. package/dist/commands/webhook.d.ts +4 -0
  41. package/dist/commands/webhook.js +22 -4
  42. package/dist/commands/workflows.js +7 -6
  43. package/dist/lib/account-registry.js +27 -6
  44. package/dist/lib/accounting/rotate.d.ts +3 -1
  45. package/dist/lib/accounting/rotate.js +8 -4
  46. package/dist/lib/auth-health.d.ts +2 -0
  47. package/dist/lib/auth-health.js +2 -0
  48. package/dist/lib/browser/chrome.d.ts +21 -0
  49. package/dist/lib/browser/chrome.js +60 -3
  50. package/dist/lib/browser/drivers/local.d.ts +21 -0
  51. package/dist/lib/browser/drivers/local.js +102 -9
  52. package/dist/lib/browser/profiles.d.ts +29 -1
  53. package/dist/lib/browser/profiles.js +50 -1
  54. package/dist/lib/browser/types.d.ts +18 -0
  55. package/dist/lib/computer/computer-rpc.d.ts +6 -1
  56. package/dist/lib/computer/computer-rpc.js +23 -3
  57. package/dist/lib/computer/des.d.ts +1 -0
  58. package/dist/lib/computer/des.js +114 -0
  59. package/dist/lib/computer/rfb-client.d.ts +53 -0
  60. package/dist/lib/computer/rfb-client.js +562 -0
  61. package/dist/lib/config-keys.d.ts +7 -2
  62. package/dist/lib/config-keys.js +17 -2
  63. package/dist/lib/daemon/auth-sync-service.js +3 -0
  64. package/dist/lib/daemon/daemon.js +17 -10
  65. package/dist/lib/daemon/session-summarizer-service.d.ts +24 -0
  66. package/dist/lib/daemon/session-summarizer-service.js +39 -0
  67. package/dist/lib/daemon/usage-sync-service.js +3 -0
  68. package/dist/lib/daemon-services.d.ts +1 -1
  69. package/dist/lib/daemon-services.js +5 -0
  70. package/dist/lib/daemon-ticks.d.ts +2 -2
  71. package/dist/lib/daemon-ticks.js +2 -1
  72. package/dist/lib/daemon-webhooks.js +15 -2
  73. package/dist/lib/deeplink/register.js +10 -9
  74. package/dist/lib/deeplink/url.d.ts +4 -4
  75. package/dist/lib/deeplink/url.js +4 -4
  76. package/dist/lib/device-config.js +25 -0
  77. package/dist/lib/devices/doctor-findings.d.ts +4 -4
  78. package/dist/lib/devices/doctor-findings.js +14 -8
  79. package/dist/lib/devices/registry.js +2 -0
  80. package/dist/lib/devices/stats-cache.d.ts +4 -0
  81. package/dist/lib/devices/stats-cache.js +19 -0
  82. package/dist/lib/drift-sync.d.ts +3 -1
  83. package/dist/lib/drift-sync.js +16 -5
  84. package/dist/lib/exec.d.ts +2 -0
  85. package/dist/lib/exec.js +16 -1
  86. package/dist/lib/fleet-shared-repo-sync.d.ts +12 -0
  87. package/dist/lib/fleet-shared-repo-sync.js +101 -4
  88. package/dist/lib/fleet-shared-state.d.ts +8 -0
  89. package/dist/lib/heal.d.ts +4 -3
  90. package/dist/lib/heal.js +5 -4
  91. package/dist/lib/hosts/ready.d.ts +1 -1
  92. package/dist/lib/hosts/ready.js +16 -4
  93. package/dist/lib/hosts/reconnect.js +4 -2
  94. package/dist/lib/identity/client.d.ts +6 -0
  95. package/dist/lib/identity/index.d.ts +16 -0
  96. package/dist/lib/identity/index.js +25 -1
  97. package/dist/lib/profiles.d.ts +2 -0
  98. package/dist/lib/profiles.js +28 -9
  99. package/dist/lib/reconcile-and-repair.d.ts +109 -0
  100. package/dist/lib/reconcile-and-repair.js +267 -0
  101. package/dist/lib/routers.d.ts +12 -1
  102. package/dist/lib/routers.js +30 -1
  103. package/dist/lib/scheduling/routines.js +8 -2
  104. package/dist/lib/session/active.d.ts +13 -0
  105. package/dist/lib/session/db.d.ts +47 -7
  106. package/dist/lib/session/db.js +114 -12
  107. package/dist/lib/session/mirror.js +58 -0
  108. package/dist/lib/session/remote/remote-list.d.ts +2 -0
  109. package/dist/lib/session/remote/remote-list.js +4 -0
  110. package/dist/lib/session/remote/watch.js +22 -2
  111. package/dist/lib/session/session-cache.d.ts +19 -0
  112. package/dist/lib/session/session-cache.js +46 -0
  113. package/dist/lib/session/types.d.ts +34 -0
  114. package/dist/lib/share/backend.d.ts +6 -4
  115. package/dist/lib/share/backend.js +10 -8
  116. package/dist/lib/share/config.d.ts +4 -3
  117. package/dist/lib/share/config.js +10 -1
  118. package/dist/lib/share/delete.d.ts +1 -1
  119. package/dist/lib/share/delete.js +1 -1
  120. package/dist/lib/share/html.d.ts +1 -1
  121. package/dist/lib/share/html.js +1 -1
  122. package/dist/lib/share/provision.d.ts +1 -1
  123. package/dist/lib/share/provision.js +2 -2
  124. package/dist/lib/share/publish.d.ts +23 -7
  125. package/dist/lib/share/publish.js +58 -12
  126. package/dist/lib/share/worker-template.js +221 -60
  127. package/dist/lib/startup/command-registry.js +2 -2
  128. package/dist/lib/state.d.ts +15 -0
  129. package/dist/lib/state.js +29 -7
  130. package/dist/lib/summarizer/config.d.ts +46 -0
  131. package/dist/lib/summarizer/config.js +83 -0
  132. package/dist/lib/summarizer/pass.d.ts +45 -0
  133. package/dist/lib/summarizer/pass.js +112 -0
  134. package/dist/lib/summarizer/summarize.d.ts +68 -0
  135. package/dist/lib/summarizer/summarize.js +120 -0
  136. package/dist/lib/teams/agents.d.ts +4 -3
  137. package/dist/lib/teams/agents.js +12 -4
  138. package/dist/lib/teams/scheduler.d.ts +4 -2
  139. package/dist/lib/teams/scheduler.js +6 -6
  140. package/dist/lib/tmux/session.d.ts +2 -0
  141. package/dist/lib/tmux/session.js +7 -1
  142. package/dist/lib/types.d.ts +20 -0
  143. package/dist/lib/verbs.d.ts +23 -0
  144. package/dist/lib/verbs.js +24 -0
  145. package/dist/lib/view-types.d.ts +4 -0
  146. package/dist/lib/watchdog/rotate.d.ts +1 -1
  147. package/dist/lib/watchdog/rotate.js +1 -1
  148. package/package.json +1 -1
@@ -319,6 +319,21 @@ export declare function getRuntimeStateDir(): string;
319
319
  export declare function getCompanionDir(): string;
320
320
  /** Path to browser runtime data — chrome-data, pids (~/.agents/.cache/browser/). */
321
321
  export declare function getBrowserRuntimeDir(): string;
322
+ /**
323
+ * Path to DURABLE browser-profile data (~/.agents/.history/browser-profiles/).
324
+ *
325
+ * This is the persistent home for an attach-only profile's `--user-data-dir` —
326
+ * where a one-time browser sign-in lives. It sits under `.history` (durable),
327
+ * NOT `.cache` (regenerable), for two reasons the ticket (PHNX-3967) named:
328
+ * - `agents browser profiles remove` sweeps `~/.agents/.cache/browser/<name>*`;
329
+ * a durable dir here survives that so logins are not wiped by a routine cleanup.
330
+ * - A cache wipe or the daemon reaper never touches it, so a signed-in Comet
331
+ * survives quit+relaunch.
332
+ * The user's canonical Comet is launched with this as `--user-data-dir`, and the
333
+ * ownership guard in the local driver compares the running instance's
334
+ * `--user-data-dir` against it to reject a foreign port-squatter.
335
+ */
336
+ export declare function getBrowserDurableDir(): string;
322
337
  /** Path to helper subprocess scratch (~/.agents/.cache/helpers/). */
323
338
  export declare function getHelpersDir(): string;
324
339
  /**
package/dist/lib/state.js CHANGED
@@ -642,6 +642,21 @@ export function getRuntimeStateDir() { return process.env.AGENTS_STATE_DIR ?? RU
642
642
  export function getCompanionDir() { return COMPANION_CACHE_DIR; }
643
643
  /** Path to browser runtime data — chrome-data, pids (~/.agents/.cache/browser/). */
644
644
  export function getBrowserRuntimeDir() { return BROWSER_RUNTIME_DIR; }
645
+ /**
646
+ * Path to DURABLE browser-profile data (~/.agents/.history/browser-profiles/).
647
+ *
648
+ * This is the persistent home for an attach-only profile's `--user-data-dir` —
649
+ * where a one-time browser sign-in lives. It sits under `.history` (durable),
650
+ * NOT `.cache` (regenerable), for two reasons the ticket (PHNX-3967) named:
651
+ * - `agents browser profiles remove` sweeps `~/.agents/.cache/browser/<name>*`;
652
+ * a durable dir here survives that so logins are not wiped by a routine cleanup.
653
+ * - A cache wipe or the daemon reaper never touches it, so a signed-in Comet
654
+ * survives quit+relaunch.
655
+ * The user's canonical Comet is launched with this as `--user-data-dir`, and the
656
+ * ownership guard in the local driver compares the running instance's
657
+ * `--user-data-dir` against it to reject a foreign port-squatter.
658
+ */
659
+ export function getBrowserDurableDir() { return path.join(HISTORY_DIR, 'browser-profiles'); }
645
660
  /** Path to helper subprocess scratch (~/.agents/.cache/helpers/). */
646
661
  export function getHelpersDir() { return HELPERS_DIR; }
647
662
  /**
@@ -1136,6 +1151,13 @@ function serializeCentral(central) {
1136
1151
  * read-snapshot-then-separately-lock race that {@link updateMeta} would impose.
1137
1152
  */
1138
1153
  export function writeMetaUnlocked(meta) {
1154
+ const writesDeviceRoutines = Object.prototype.hasOwnProperty.call(meta, 'deviceRoutines');
1155
+ const writesDeviceConfig = Object.prototype.hasOwnProperty.call(meta, 'deviceConfig');
1156
+ const writesDeviceBrowser = Object.prototype.hasOwnProperty.call(meta, 'deviceBrowser');
1157
+ const writesDeviceFleet = Object.prototype.hasOwnProperty.call(meta, 'deviceFleet');
1158
+ const writesDeviceHosts = Object.prototype.hasOwnProperty.call(meta, 'deviceHosts');
1159
+ const writesDeviceAccounts = Object.prototype.hasOwnProperty.call(meta, 'deviceAccounts');
1160
+ const writesProjectRoot = Object.prototype.hasOwnProperty.call(meta, 'projectRoot');
1139
1161
  // INVARIANT: every key destructured here must also be in BESPOKE_DEVICE_KEYS (and
1140
1162
  // vice versa) — a bespoke device key that is classified but NOT pulled out here
1141
1163
  // would fall into `central`, and the generic router skips it (BESPOKE_DEVICE_KEY_SET),
@@ -1191,17 +1213,17 @@ export function writeMetaUnlocked(meta) {
1191
1213
  delete doc.isolatedAgents;
1192
1214
  if (Array.isArray(deviceRoutines))
1193
1215
  doc.routines = deviceRoutines;
1194
- else
1216
+ else if (writesDeviceRoutines)
1195
1217
  delete doc.routines;
1196
1218
  const hasDeviceConfig = !!deviceConfig && Object.keys(deviceConfig).length > 0;
1197
1219
  if (hasDeviceConfig)
1198
1220
  doc.config = deviceConfig;
1199
- else
1221
+ else if (writesDeviceConfig)
1200
1222
  delete doc.config;
1201
1223
  const hasDeviceBrowser = !!deviceBrowser && Object.keys(deviceBrowser).length > 0;
1202
1224
  if (hasDeviceBrowser)
1203
1225
  doc.browser = deviceBrowser;
1204
- else
1226
+ else if (writesDeviceBrowser)
1205
1227
  delete doc.browser;
1206
1228
  // PHNX-3315 device-scoped fleet/hosts/accounts blocks. Each is this box's OWN
1207
1229
  // slice; the effective fleet view is unioned across every device doc at read
@@ -1219,12 +1241,12 @@ export function writeMetaUnlocked(meta) {
1219
1241
  df.ignored = fleetIgnored;
1220
1242
  doc.fleet = df;
1221
1243
  }
1222
- else
1244
+ else if (writesDeviceFleet)
1223
1245
  delete doc.fleet;
1224
1246
  const hasDeviceHosts = !!deviceHosts && Object.keys(deviceHosts).length > 0;
1225
1247
  if (hasDeviceHosts)
1226
1248
  doc.hosts = deviceHosts;
1227
- else
1249
+ else if (writesDeviceHosts)
1228
1250
  delete doc.hosts;
1229
1251
  const accountsNative = deviceAccounts?.native && Object.keys(deviceAccounts.native).length > 0
1230
1252
  ? deviceAccounts.native : undefined;
@@ -1238,12 +1260,12 @@ export function writeMetaUnlocked(meta) {
1238
1260
  da.bindings = accountsBindings;
1239
1261
  doc.accounts = da;
1240
1262
  }
1241
- else
1263
+ else if (writesDeviceAccounts)
1242
1264
  delete doc.accounts;
1243
1265
  const hasProjectRoot = typeof projectRoot === 'string' && projectRoot.length > 0;
1244
1266
  if (hasProjectRoot)
1245
1267
  doc.projectRoot = projectRoot;
1246
- else
1268
+ else if (writesProjectRoot)
1247
1269
  delete doc.projectRoot;
1248
1270
  // Generic device-scoped keys (PHNX-3315): any key left in `central` that this
1249
1271
  // version classifies as device but does NOT bespoke-route round-trips through
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Session-summarizer configuration (PHNX-3939).
3
+ *
4
+ * Resolves the three knobs behind the daemon summarizer — enabled / base URL /
5
+ * model — from `agents config` (`summarizer.*`, user-scope in the central
6
+ * agents.yaml) with a per-process env override
7
+ * (`AGENTS_SUMMARIZER_ENABLED` / `AGENTS_SUMMARIZER_BASEURL` /
8
+ * `AGENTS_SUMMARIZER_MODEL`). Off by default: with no config and no env, the
9
+ * summarizer is disabled and makes zero model calls.
10
+ *
11
+ * `isSummarizerReady()` is memoized on a short TTL because the display merge
12
+ * (the watch-stream projections) calls it once per session row — a fresh
13
+ * agents.yaml read per row would defeat the "blazing fast" requirement.
14
+ */
15
+ export interface SummarizerConfig {
16
+ enabled: boolean;
17
+ /** Anthropic-wire base URL (Ollama/vLLM/LiteLLM), or undefined when unconfigured. */
18
+ baseUrl?: string;
19
+ /** Model id to request, or undefined when unconfigured. */
20
+ model?: string;
21
+ }
22
+ /**
23
+ * Resolve the full summarizer config. Env overrides the stored config key by key;
24
+ * an unset env var falls through to `agents config`, then to the built-in
25
+ * default (disabled). Never throws — a missing/corrupt config reads as unset.
26
+ */
27
+ export declare function resolveSummarizerConfig(env?: NodeJS.ProcessEnv): SummarizerConfig;
28
+ /**
29
+ * True only when the summarizer is enabled AND has a base URL + model to call.
30
+ * A configuration that is `enabled` but missing an endpoint cannot produce a
31
+ * summary, so it is treated as unconfigured (the service no-ops, the merge marks
32
+ * `skipped`) rather than erroring on every tick.
33
+ */
34
+ export declare function isSummarizerRunnable(config: SummarizerConfig): boolean;
35
+ /**
36
+ * Memoized "will a summary actually be produced?" check for the hot merge path.
37
+ * Reflects {@link isSummarizerRunnable} — enabled AND a base URL AND a model —
38
+ * NOT just `enabled`, because an enabled-but-unconfigured summarizer computes
39
+ * nothing, so a row with no cached summary must read `skipped`, not a `pending`
40
+ * that never resolves (the exact case: `summarizer.enabled on` set before the
41
+ * endpoint). TTL keeps a config change visible within a few seconds without a
42
+ * per-row agents.yaml read.
43
+ */
44
+ export declare function isSummarizerReady(nowMs?: number): boolean;
45
+ /** Test seam: drop the memoized ready flag so the next read re-resolves. */
46
+ export declare function resetSummarizerReadyCacheForTest(): void;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Session-summarizer configuration (PHNX-3939).
3
+ *
4
+ * Resolves the three knobs behind the daemon summarizer — enabled / base URL /
5
+ * model — from `agents config` (`summarizer.*`, user-scope in the central
6
+ * agents.yaml) with a per-process env override
7
+ * (`AGENTS_SUMMARIZER_ENABLED` / `AGENTS_SUMMARIZER_BASEURL` /
8
+ * `AGENTS_SUMMARIZER_MODEL`). Off by default: with no config and no env, the
9
+ * summarizer is disabled and makes zero model calls.
10
+ *
11
+ * `isSummarizerReady()` is memoized on a short TTL because the display merge
12
+ * (the watch-stream projections) calls it once per session row — a fresh
13
+ * agents.yaml read per row would defeat the "blazing fast" requirement.
14
+ */
15
+ import { getConfigValue } from '../device-config.js';
16
+ /** Parse a boolean-ish env value; undefined when the var is unset. */
17
+ function envBool(raw) {
18
+ if (raw === undefined)
19
+ return undefined;
20
+ const v = raw.trim().toLowerCase();
21
+ if (v === '1' || v === 'true' || v === 'on' || v === 'yes')
22
+ return true;
23
+ if (v === '0' || v === 'false' || v === 'off' || v === 'no' || v === '')
24
+ return false;
25
+ return undefined;
26
+ }
27
+ function envString(raw) {
28
+ const v = raw?.trim();
29
+ return v ? v : undefined;
30
+ }
31
+ /**
32
+ * Resolve the full summarizer config. Env overrides the stored config key by key;
33
+ * an unset env var falls through to `agents config`, then to the built-in
34
+ * default (disabled). Never throws — a missing/corrupt config reads as unset.
35
+ */
36
+ export function resolveSummarizerConfig(env = process.env) {
37
+ let storedEnabled;
38
+ let storedBaseUrl;
39
+ let storedModel;
40
+ try {
41
+ storedEnabled = getConfigValue('summarizer.enabled').value;
42
+ storedBaseUrl = getConfigValue('summarizer.baseUrl').value;
43
+ storedModel = getConfigValue('summarizer.model').value;
44
+ }
45
+ catch {
46
+ // A missing/corrupt config store must not break the read path — treat as unset.
47
+ }
48
+ const enabled = envBool(env.AGENTS_SUMMARIZER_ENABLED) ?? storedEnabled ?? false;
49
+ const baseUrl = envString(env.AGENTS_SUMMARIZER_BASEURL) ?? envString(storedBaseUrl);
50
+ const model = envString(env.AGENTS_SUMMARIZER_MODEL) ?? envString(storedModel);
51
+ return { enabled, baseUrl, model };
52
+ }
53
+ /**
54
+ * True only when the summarizer is enabled AND has a base URL + model to call.
55
+ * A configuration that is `enabled` but missing an endpoint cannot produce a
56
+ * summary, so it is treated as unconfigured (the service no-ops, the merge marks
57
+ * `skipped`) rather than erroring on every tick.
58
+ */
59
+ export function isSummarizerRunnable(config) {
60
+ return config.enabled && Boolean(config.baseUrl) && Boolean(config.model);
61
+ }
62
+ let cachedReady = null;
63
+ const READY_TTL_MS = 3_000;
64
+ /**
65
+ * Memoized "will a summary actually be produced?" check for the hot merge path.
66
+ * Reflects {@link isSummarizerRunnable} — enabled AND a base URL AND a model —
67
+ * NOT just `enabled`, because an enabled-but-unconfigured summarizer computes
68
+ * nothing, so a row with no cached summary must read `skipped`, not a `pending`
69
+ * that never resolves (the exact case: `summarizer.enabled on` set before the
70
+ * endpoint). TTL keeps a config change visible within a few seconds without a
71
+ * per-row agents.yaml read.
72
+ */
73
+ export function isSummarizerReady(nowMs = Date.now()) {
74
+ if (cachedReady && nowMs - cachedReady.at < READY_TTL_MS)
75
+ return cachedReady.value;
76
+ const value = isSummarizerRunnable(resolveSummarizerConfig());
77
+ cachedReady = { at: nowMs, value };
78
+ return value;
79
+ }
80
+ /** Test seam: drop the memoized ready flag so the next read re-resolves. */
81
+ export function resetSummarizerReadyCacheForTest() {
82
+ cachedReady = null;
83
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * One session-summarizer pass (PHNX-3939) — the work the background
3
+ * SessionSummarizerService does per tick, factored out so it is testable with an
4
+ * injected model stub and no daemon scaffolding.
5
+ *
6
+ * Off the request path by construction: it reads THIS box's already-published
7
+ * live sessions (the warm cache the session-state service writes — never a fresh
8
+ * gather) and, per session, reuses the transcript-keyed `session_summaries` row
9
+ * until the transcript bytes change, so an unchanged session costs one indexed
10
+ * read and zero model calls. When the summarizer is disabled or has no endpoint,
11
+ * the pass does nothing at all.
12
+ */
13
+ import type { ActiveSession } from '../session/active.js';
14
+ import { type SummarizerConfig } from './config.js';
15
+ import { summarize as defaultSummarize } from './summarize.js';
16
+ /** Max sessions summarized per tick — a debounce ceiling on local-model calls. */
17
+ export declare const SUMMARIZER_MAX_PER_TICK = 8;
18
+ export interface SummarizerPassOptions {
19
+ now?: number;
20
+ config?: SummarizerConfig;
21
+ /** Live sessions to consider; default = this box's warm local-session cache. */
22
+ sessions?: ActiveSession[];
23
+ /** Injectable model call (tests stub the endpoint here). */
24
+ summarizeImpl?: typeof defaultSummarize;
25
+ signal?: AbortSignal;
26
+ maxPerTick?: number;
27
+ /** Injectable stat for tests; defaults to fs.statSync. */
28
+ statFile?: (p: string) => {
29
+ mtimeMs: number;
30
+ size: number;
31
+ };
32
+ /** Skip the reader-recency gate (tests pass sessions directly). */
33
+ requireReader?: boolean;
34
+ }
35
+ export interface SummarizerPassResult {
36
+ disabled: boolean;
37
+ computed: number;
38
+ reused: number;
39
+ skipped: number;
40
+ }
41
+ /**
42
+ * Run one pass. Returns per-outcome counts. Never throws — a per-session failure
43
+ * is isolated so one bad transcript can't stall the whole tick.
44
+ */
45
+ export declare function runSummarizerPass(opts?: SummarizerPassOptions): Promise<SummarizerPassResult>;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * One session-summarizer pass (PHNX-3939) — the work the background
3
+ * SessionSummarizerService does per tick, factored out so it is testable with an
4
+ * injected model stub and no daemon scaffolding.
5
+ *
6
+ * Off the request path by construction: it reads THIS box's already-published
7
+ * live sessions (the warm cache the session-state service writes — never a fresh
8
+ * gather) and, per session, reuses the transcript-keyed `session_summaries` row
9
+ * until the transcript bytes change, so an unchanged session costs one indexed
10
+ * read and zero model calls. When the summarizer is disabled or has no endpoint,
11
+ * the pass does nothing at all.
12
+ */
13
+ import * as fs from 'node:fs';
14
+ import { readSessionSummary, readSessionSummaryAny, writeSessionSummary, } from '../session/db.js';
15
+ import { isActiveSessionsJournalReaderRecent, readActiveSessionsCache, } from '../session/session-cache.js';
16
+ import { resolveSummarizerConfig, isSummarizerRunnable } from './config.js';
17
+ import { summarize as defaultSummarize } from './summarize.js';
18
+ /** Max sessions summarized per tick — a debounce ceiling on local-model calls. */
19
+ export const SUMMARIZER_MAX_PER_TICK = 8;
20
+ /**
21
+ * Merge model checkpoint TEXTS with any prior stored checkpoints so a line that
22
+ * already existed keeps its original `at`, and only genuinely new lines get
23
+ * stamped `now`. Order follows the model output (newest last).
24
+ */
25
+ function stampCheckpoints(texts, prior, nowIso) {
26
+ const priorAt = new Map((prior ?? []).map((c) => [c.text, c.at]));
27
+ return texts.map((text) => ({ text, at: priorAt.get(text) ?? nowIso }));
28
+ }
29
+ /**
30
+ * Run one pass. Returns per-outcome counts. Never throws — a per-session failure
31
+ * is isolated so one bad transcript can't stall the whole tick.
32
+ */
33
+ export async function runSummarizerPass(opts = {}) {
34
+ const now = opts.now ?? Date.now();
35
+ const config = opts.config ?? resolveSummarizerConfig();
36
+ const result = { disabled: false, computed: 0, reused: 0, skipped: 0 };
37
+ if (!isSummarizerRunnable(config)) {
38
+ result.disabled = true;
39
+ return result;
40
+ }
41
+ let sessions = opts.sessions;
42
+ if (!sessions) {
43
+ // Reader-gated like the active-sessions warm tick: no watcher, no work.
44
+ if (opts.requireReader !== false && !isActiveSessionsJournalReaderRecent(now))
45
+ return result;
46
+ sessions = readActiveSessionsCache('local')?.sessions ?? [];
47
+ }
48
+ const stat = opts.statFile ?? ((p) => {
49
+ const s = fs.statSync(p);
50
+ return { mtimeMs: s.mtimeMs, size: s.size };
51
+ });
52
+ const runSummarize = opts.summarizeImpl ?? defaultSummarize;
53
+ const maxPerTick = opts.maxPerTick ?? SUMMARIZER_MAX_PER_TICK;
54
+ const nowIso = new Date(now).toISOString();
55
+ let budget = maxPerTick;
56
+ for (const s of sessions) {
57
+ if (opts.signal?.aborted)
58
+ break;
59
+ if (budget <= 0)
60
+ break;
61
+ const id = s.sessionId;
62
+ const file = s.sessionFile;
63
+ if (!id || !file)
64
+ continue;
65
+ let stamp;
66
+ try {
67
+ const st = stat(file);
68
+ stamp = { fileMtimeMs: Math.round(st.mtimeMs), fileSize: st.size };
69
+ }
70
+ catch {
71
+ continue; // transcript unreadable this tick — try again next time
72
+ }
73
+ // Cached for these exact bytes → never recompute (the core "blazing fast" rule).
74
+ if (readSessionSummary(id, stamp)) {
75
+ result.reused++;
76
+ continue;
77
+ }
78
+ budget--;
79
+ const prompt = (s.firstUserMessage ?? s.topic ?? '').trim();
80
+ const prior = readSessionSummaryAny(id);
81
+ if (!prompt) {
82
+ // No extractable intent yet — cache a skip against these bytes so we don't
83
+ // re-attempt every tick; a later transcript write (new bytes) retries.
84
+ writeSessionSummary({ id, ...stamp, summary: { summaryState: 'skipped' } });
85
+ result.skipped++;
86
+ continue;
87
+ }
88
+ let computed;
89
+ try {
90
+ computed = await runSummarize(prompt, { todos: s.todos, plan: s.plan, phase: s.phase }, { baseUrl: config.baseUrl, model: config.model, ...(opts.signal ? { signal: opts.signal } : {}) });
91
+ }
92
+ catch {
93
+ computed = undefined;
94
+ }
95
+ if (!computed) {
96
+ writeSessionSummary({ id, ...stamp, summary: { summaryState: 'skipped' } });
97
+ result.skipped++;
98
+ continue;
99
+ }
100
+ const entry = {
101
+ // Goal is computed ONCE at first sight — a prior goal is kept across
102
+ // progress deltas so it stays stable while checkpoints/checklist refresh.
103
+ goal: prior?.goal ?? computed.goal,
104
+ checkpoints: stampCheckpoints(computed.checkpoints, prior?.checkpoints, nowIso),
105
+ summaryChecklist: computed.checklist,
106
+ summaryState: 'ready',
107
+ };
108
+ writeSessionSummary({ id, ...stamp, summary: entry });
109
+ result.computed++;
110
+ }
111
+ return result;
112
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The one model call behind the session summarizer (PHNX-3939).
3
+ *
4
+ * A pure boundary: given the session's first user turn (the goal source) and its
5
+ * live progress (todos / plan / phase off the state engine), it asks a local
6
+ * Anthropic-wire endpoint (Ollama / vLLM / LiteLLM — the same configurable base
7
+ * URL `computer/model.ts` speaks) for a strict JSON `{goal, checkpoints, checklist}`
8
+ * and validates the shape. On ANY failure — network, non-2xx, non-JSON, wrong
9
+ * shape — it returns `undefined`, and the caller records `summaryState: 'skipped'`.
10
+ *
11
+ * NEVER called on the request path: only the background SessionSummarizerService
12
+ * invokes it, debounced and reader-gated.
13
+ */
14
+ import type { TodoProgress } from '../session/types.js';
15
+ /** Live progress fed to the model alongside the goal-bearing prompt. */
16
+ export interface SummarizeProgress {
17
+ /** Latest checklist write (TodoWrite / update_plan), when the session has one. */
18
+ todos?: TodoProgress;
19
+ /** Plan markdown from the last ExitPlanMode, when present. */
20
+ plan?: string;
21
+ /** Coarse lifecycle phase (running / waiting / idle / …), when known. */
22
+ phase?: string;
23
+ }
24
+ /** The validated model output. `at` timestamps are stamped by the caller. */
25
+ export interface SummarizeResult {
26
+ goal: string;
27
+ /** Progress checkpoints, newest last (short lines). */
28
+ checkpoints: string[];
29
+ /** Detailed checklist. */
30
+ checklist: {
31
+ text: string;
32
+ done: boolean;
33
+ }[];
34
+ }
35
+ export interface SummarizeOptions {
36
+ baseUrl: string;
37
+ model: string;
38
+ maxTokens?: number;
39
+ /**
40
+ * API key for the endpoint. Deliberately NOT resolved from `ANTHROPIC_API_KEY`:
41
+ * the summarizer targets an operator-configured local/remote endpoint
42
+ * (Ollama/vLLM/LiteLLM) that typically ignores the key, so forwarding the real
43
+ * Anthropic credential there would leak it. Only an explicit
44
+ * `AGENTS_SUMMARIZER_API_KEY` (or this option) is ever sent; otherwise the
45
+ * header is empty.
46
+ */
47
+ apiKey?: string;
48
+ /** Injectable for tests; defaults to the global fetch. */
49
+ fetchImpl?: typeof fetch;
50
+ /** Abort the request when the daemon tick's deadline elapses. */
51
+ signal?: AbortSignal;
52
+ }
53
+ /** Compose the user message from the goal-bearing prompt and the live progress. */
54
+ export declare function buildSummarizeUserMessage(prompt: string, progress: SummarizeProgress): string;
55
+ /**
56
+ * Coerce an untrusted parsed object into a {@link SummarizeResult}, or undefined
57
+ * when the shape is wrong. Extra/missing optional arrays degrade to `[]` rather
58
+ * than failing, but a non-string goal is a hard reject — a summary with no goal
59
+ * is not a summary.
60
+ */
61
+ export declare function validateSummarizeResult(parsed: unknown): SummarizeResult | undefined;
62
+ /** Strip ``` fences and pull the first {...} block so a chatty model still parses. */
63
+ export declare function extractJsonObject(text: string): string | undefined;
64
+ /**
65
+ * Run one summarization. Returns the validated result, or `undefined` on any
66
+ * failure (the caller then marks the session `summaryState: 'skipped'`).
67
+ */
68
+ export declare function summarize(prompt: string, progress: SummarizeProgress, opts: SummarizeOptions): Promise<SummarizeResult | undefined>;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The one model call behind the session summarizer (PHNX-3939).
3
+ *
4
+ * A pure boundary: given the session's first user turn (the goal source) and its
5
+ * live progress (todos / plan / phase off the state engine), it asks a local
6
+ * Anthropic-wire endpoint (Ollama / vLLM / LiteLLM — the same configurable base
7
+ * URL `computer/model.ts` speaks) for a strict JSON `{goal, checkpoints, checklist}`
8
+ * and validates the shape. On ANY failure — network, non-2xx, non-JSON, wrong
9
+ * shape — it returns `undefined`, and the caller records `summaryState: 'skipped'`.
10
+ *
11
+ * NEVER called on the request path: only the background SessionSummarizerService
12
+ * invokes it, debounced and reader-gated.
13
+ */
14
+ import { ANTHROPIC_VERSION } from '../computer/model.js';
15
+ const SYSTEM_PROMPT = [
16
+ 'You summarize a coding-agent session for an operator dashboard.',
17
+ 'Reply with ONLY a single JSON object, no prose and no code fences, of exactly this shape:',
18
+ '{"goal": string, "checkpoints": string[], "checklist": [{"text": string, "done": boolean}]}',
19
+ '- goal: 1-2 lines capturing what the user actually asked for, in plain language.',
20
+ '- checkpoints: short lines of concrete progress so far, newest last; [] if none yet.',
21
+ '- checklist: the detailed steps to finish the goal, each with a done flag; [] if unknown.',
22
+ 'Do not invent progress that is not evidenced by the provided context.',
23
+ ].join('\n');
24
+ /** Compose the user message from the goal-bearing prompt and the live progress. */
25
+ export function buildSummarizeUserMessage(prompt, progress) {
26
+ const parts = [`USER REQUEST:\n${prompt.trim()}`];
27
+ if (progress.phase)
28
+ parts.push(`PHASE: ${progress.phase}`);
29
+ if (progress.todos && progress.todos.items.length > 0) {
30
+ const lines = progress.todos.items.map((t) => `- [${t.status === 'completed' ? 'x' : ' '}] ${t.content}`);
31
+ parts.push(`CURRENT CHECKLIST (${progress.todos.done}/${progress.todos.total} done):\n${lines.join('\n')}`);
32
+ }
33
+ if (progress.plan)
34
+ parts.push(`PLAN:\n${progress.plan.trim().slice(0, 4000)}`);
35
+ return parts.join('\n\n');
36
+ }
37
+ /**
38
+ * Coerce an untrusted parsed object into a {@link SummarizeResult}, or undefined
39
+ * when the shape is wrong. Extra/missing optional arrays degrade to `[]` rather
40
+ * than failing, but a non-string goal is a hard reject — a summary with no goal
41
+ * is not a summary.
42
+ */
43
+ export function validateSummarizeResult(parsed) {
44
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
45
+ return undefined;
46
+ const obj = parsed;
47
+ if (typeof obj.goal !== 'string' || obj.goal.trim().length === 0)
48
+ return undefined;
49
+ const checkpoints = Array.isArray(obj.checkpoints)
50
+ ? obj.checkpoints.filter((c) => typeof c === 'string' && c.trim().length > 0).map((c) => c.trim())
51
+ : [];
52
+ const checklist = Array.isArray(obj.checklist)
53
+ ? obj.checklist
54
+ .filter((c) => Boolean(c) && typeof c === 'object' && !Array.isArray(c))
55
+ .map((c) => ({ text: typeof c.text === 'string' ? c.text.trim() : '', done: Boolean(c.done) }))
56
+ .filter((c) => c.text.length > 0)
57
+ : [];
58
+ return { goal: obj.goal.trim(), checkpoints, checklist };
59
+ }
60
+ /** Extract the assistant text from an Anthropic Messages response body. */
61
+ function textFromBody(body) {
62
+ const content = body.content;
63
+ if (!Array.isArray(content))
64
+ return '';
65
+ return content
66
+ .filter((b) => Boolean(b) && typeof b === 'object')
67
+ .filter((b) => b.type === 'text' && typeof b.text === 'string')
68
+ .map((b) => b.text)
69
+ .join('');
70
+ }
71
+ /** Strip ``` fences and pull the first {...} block so a chatty model still parses. */
72
+ export function extractJsonObject(text) {
73
+ const unfenced = text.replace(/```(?:json)?/gi, '').trim();
74
+ const start = unfenced.indexOf('{');
75
+ const end = unfenced.lastIndexOf('}');
76
+ if (start === -1 || end === -1 || end < start)
77
+ return undefined;
78
+ return unfenced.slice(start, end + 1);
79
+ }
80
+ /**
81
+ * Run one summarization. Returns the validated result, or `undefined` on any
82
+ * failure (the caller then marks the session `summaryState: 'skipped'`).
83
+ */
84
+ export async function summarize(prompt, progress, opts) {
85
+ if (!prompt.trim())
86
+ return undefined;
87
+ const baseUrl = opts.baseUrl.replace(/\/+$/, '');
88
+ const doFetch = opts.fetchImpl ?? fetch;
89
+ // Only an explicit summarizer key is ever forwarded — never the ambient
90
+ // ANTHROPIC_API_KEY, which would leak to the operator's local endpoint.
91
+ const apiKey = opts.apiKey ?? process.env.AGENTS_SUMMARIZER_API_KEY ?? '';
92
+ try {
93
+ const res = await doFetch(`${baseUrl}/v1/messages`, {
94
+ method: 'POST',
95
+ headers: {
96
+ 'content-type': 'application/json',
97
+ 'x-api-key': apiKey,
98
+ 'anthropic-version': ANTHROPIC_VERSION,
99
+ },
100
+ body: JSON.stringify({
101
+ model: opts.model,
102
+ max_tokens: opts.maxTokens ?? 512,
103
+ system: SYSTEM_PROMPT,
104
+ messages: [{ role: 'user', content: buildSummarizeUserMessage(prompt, progress) }],
105
+ }),
106
+ ...(opts.signal ? { signal: opts.signal } : {}),
107
+ });
108
+ if (!res.ok)
109
+ return undefined;
110
+ const body = await res.json();
111
+ const text = textFromBody(body);
112
+ const json = extractJsonObject(text);
113
+ if (!json)
114
+ return undefined;
115
+ return validateSummarizeResult(JSON.parse(json));
116
+ }
117
+ catch {
118
+ return undefined;
119
+ }
120
+ }
@@ -569,9 +569,10 @@ export declare class AgentManager {
569
569
  private resolveScheduledPlacement;
570
570
  /**
571
571
  * Place an UNPINNED, non-cloud teammate onto the team pool via the cascade
572
- * (least-loaded), if the team declares one. A no-op for a pinned teammate
573
- * (hostName already set from `--device`), a cloud teammate, or a poolless team —
574
- * leaving hostName null so the local spawn runs unchanged. Shared by spawn()
572
+ * (least-loaded). A poolless team consumes the active worker allowlist and
573
+ * fails loud when none exists; it never silently lands on the orchestrator.
574
+ * A no-op only for a pinned teammate (hostName already set from `--device`) or
575
+ * a cloud teammate. Shared by spawn()
575
576
  * (immediate add-launch) and startReady() (staged launch) so an unpinned pool
576
577
  * teammate schedules identically no matter how it was fired.
577
578
  */
@@ -38,6 +38,7 @@ import { atomicWriteJsonSync } from '../fs-atomic.js';
38
38
  import { resolvePlacement, classifyExclusions, isTransientPlacementBlock, NoViableDeviceError, } from './scheduler.js';
39
39
  import { probePoolSignals } from './placement-probe.js';
40
40
  import { readMaxConcurrentCaps } from '../device-config.js';
41
+ import { filterAutoPool, listWorkerDevices } from '../devices/pool.js';
41
42
  import { redactSecrets, sanitizeForTerminal } from '../redact.js';
42
43
  import chalk from 'chalk';
43
44
  let lastMemoryWarnAt = 0;
@@ -2328,9 +2329,10 @@ export class AgentManager {
2328
2329
  }
2329
2330
  /**
2330
2331
  * Place an UNPINNED, non-cloud teammate onto the team pool via the cascade
2331
- * (least-loaded), if the team declares one. A no-op for a pinned teammate
2332
- * (hostName already set from `--device`), a cloud teammate, or a poolless team —
2333
- * leaving hostName null so the local spawn runs unchanged. Shared by spawn()
2332
+ * (least-loaded). A poolless team consumes the active worker allowlist and
2333
+ * fails loud when none exists; it never silently lands on the orchestrator.
2334
+ * A no-op only for a pinned teammate (hostName already set from `--device`) or
2335
+ * a cloud teammate. Shared by spawn()
2334
2336
  * (immediate add-launch) and startReady() (staged launch) so an unpinned pool
2335
2337
  * teammate schedules identically no matter how it was fired.
2336
2338
  */
@@ -2341,7 +2343,12 @@ export class AgentManager {
2341
2343
  if (!teamMeta)
2342
2344
  return;
2343
2345
  const roster = await this.listByTask(taskName);
2344
- const pool = teamMeta.devices ?? [];
2346
+ // A poolless team is still an automatic placement request: use the same
2347
+ // explicit worker allowlist as `--device auto`. Never silently run it on a
2348
+ // personal/desktop orchestrator merely because `devices` was omitted.
2349
+ const pool = teamMeta.devices?.length
2350
+ ? teamMeta.devices
2351
+ : filterAutoPool(listWorkerDevices());
2345
2352
  const maxConcurrent = pool.length > 1 ? readMaxConcurrentCaps(pool) : undefined;
2346
2353
  // On the start path (opts.probe), gather live signals so the pick is health-,
2347
2354
  // harness-, and load-aware (RUSH-2002); the add path stays the cap-only
@@ -2353,6 +2360,7 @@ export class AgentManager {
2353
2360
  const placeOpts = {
2354
2361
  maxConcurrent,
2355
2362
  signals,
2363
+ defaultDevices: pool,
2356
2364
  agentLabel: this.placementAgentLabel(agent),
2357
2365
  };
2358
2366
  if (signals) {