@hyperdrive.bot/fleet-server 0.3.159 → 0.3.160

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 (48) hide show
  1. package/dist/server/server/agent/agent-manager.d.ts +29 -0
  2. package/dist/server/server/agent/agent-manager.js +69 -5
  3. package/dist/server/server/agent/agent-timeline-store.d.ts +10 -0
  4. package/dist/server/server/agent/agent-timeline-store.js +54 -6
  5. package/dist/server/server/agent/blocker-log.d.ts +3 -1
  6. package/dist/server/server/agent/blocker-log.js +17 -5
  7. package/dist/server/server/agent/card-move-log.d.ts +3 -1
  8. package/dist/server/server/agent/card-move-log.js +21 -14
  9. package/dist/server/server/agent/import-sessions.js +1 -1
  10. package/dist/server/server/agent/jsonl-card-index.d.ts +53 -0
  11. package/dist/server/server/agent/jsonl-card-index.js +138 -0
  12. package/dist/server/server/agent/lifecycle-command.d.ts +18 -1
  13. package/dist/server/server/agent/lifecycle-command.js +21 -2
  14. package/dist/server/server/agent/mcp-shared.d.ts +1 -0
  15. package/dist/server/server/agent/provider-snapshot-manager.d.ts +9 -0
  16. package/dist/server/server/agent/provider-snapshot-manager.js +38 -1
  17. package/dist/server/server/agent/structured-generation-providers.d.ts +2 -1
  18. package/dist/server/server/agent/structured-generation-providers.js +21 -8
  19. package/dist/server/server/fleet/decision-service.d.ts +8 -0
  20. package/dist/server/server/fleet/decision-service.js +24 -1
  21. package/dist/server/server/fleet/fleet-controls.js +2 -1
  22. package/dist/server/server/fleet/fleet-reader.d.ts +12 -0
  23. package/dist/server/server/fleet/fleet-reader.js +48 -2
  24. package/dist/server/server/schedule/service.d.ts +4 -0
  25. package/dist/server/server/schedule/service.js +26 -5
  26. package/dist/server/server/schedule/store.d.ts +51 -0
  27. package/dist/server/server/schedule/store.js +184 -16
  28. package/dist/server/server/session/agent-updates/agent-updates-service.d.ts +16 -1
  29. package/dist/server/server/session/agent-updates/agent-updates-service.js +59 -2
  30. package/dist/server/server/session/provider/provider-catalog-session.d.ts +8 -0
  31. package/dist/server/server/session/provider/provider-catalog-session.js +21 -1
  32. package/dist/server/server/session.d.ts +16 -0
  33. package/dist/server/server/session.js +115 -19
  34. package/dist/server/server/websocket-server.d.ts +5 -0
  35. package/dist/server/server/websocket-server.js +10 -0
  36. package/dist/server/server/workspace-directory.d.ts +4 -1
  37. package/dist/server/server/workspace-directory.js +3 -2
  38. package/dist/server/web-ui/_expo/static/js/web/{index-1420ca2d21c343afea2c8e2da752cd54.js → index-29775d90476d32ca65921d262ae06baa.js} +6 -6
  39. package/dist/server/web-ui/_expo/static/js/web/index-29775d90476d32ca65921d262ae06baa.js.br +0 -0
  40. package/dist/server/web-ui/_expo/static/js/web/index-29775d90476d32ca65921d262ae06baa.js.gz +0 -0
  41. package/dist/server/web-ui/_expo/static/js/web/{index-1420ca2d21c343afea2c8e2da752cd54.js.map.br → index-29775d90476d32ca65921d262ae06baa.js.map.br} +0 -0
  42. package/dist/server/web-ui/_expo/static/js/web/{index-1420ca2d21c343afea2c8e2da752cd54.js.map.gz → index-29775d90476d32ca65921d262ae06baa.js.map.gz} +0 -0
  43. package/dist/server/web-ui/index.html +1 -1
  44. package/dist/server/web-ui/index.html.br +0 -0
  45. package/dist/server/web-ui/index.html.gz +0 -0
  46. package/package.json +6 -6
  47. package/dist/server/web-ui/_expo/static/js/web/index-1420ca2d21c343afea2c8e2da752cd54.js.br +0 -0
  48. package/dist/server/web-ui/_expo/static/js/web/index-1420ca2d21c343afea2c8e2da752cd54.js.gz +0 -0
@@ -1,13 +1,64 @@
1
1
  import { type StoredSchedule } from "@hyperdrive.bot/fleet-protocol/schedule/types";
2
+ /**
3
+ * How many runs a schedule file keeps. Every run start and finish rewrites the
4
+ * whole file, and the history grew unbounded (5,510 runs, 2.3 MB in one real
5
+ * file), so the oldest runs are dropped at write time. How many finished runs
6
+ * were dropped is kept in `droppedRunCount`, so `maxRuns` completion still
7
+ * counts every run that ever happened.
8
+ */
9
+ export declare const SCHEDULE_RUNS_RETAINED = 200;
10
+ export declare function capScheduleRuns(schedule: StoredSchedule): StoredSchedule;
11
+ /**
12
+ * Three-way merge of a daemon write over a file that changed on disk since the
13
+ * daemon last read it. `base` is what the daemon read, `mine` what it wants to
14
+ * write, `theirs` what is on disk now. A top-level field the daemon did not
15
+ * change keeps the disk value; a field it changed keeps the daemon's. Runs are
16
+ * unioned by id, the daemon's copy of a run winning.
17
+ */
18
+ export declare function mergeScheduleWrite(base: StoredSchedule, mine: StoredSchedule, theirs: StoredSchedule): StoredSchedule;
19
+ /**
20
+ * File-backed schedule store with an in-memory cache validated against disk.
21
+ *
22
+ * Every read stats the directory's `*.json` files (no read, no parse) and
23
+ * re-reads only files whose mtime or size changed, so a schedule written by
24
+ * hand or by another tool is still picked up by a running daemon, at a fraction
25
+ * of the cost of re-parsing 5 MB of JSON per call. Concurrent reads share one
26
+ * validation pass. A write whose file changed on disk since the daemon read it
27
+ * is merged over the disk copy (`mergeScheduleWrite`) rather than clobbering it.
28
+ *
29
+ * Returned objects are shared with the cache: treat them as read-only and write
30
+ * changes back through `put`.
31
+ */
2
32
  export declare class ScheduleStore {
3
33
  private readonly dir;
34
+ private readonly cache;
35
+ private readonly stamps;
36
+ private validating;
37
+ private revision;
4
38
  constructor(dir: string);
5
39
  private filePath;
6
40
  private ensureDir;
41
+ private readStamp;
42
+ private readScheduleFile;
43
+ /** Bring the cache in line with disk, re-reading only files that changed. */
44
+ private validate;
45
+ private validateNow;
7
46
  list(): Promise<StoredSchedule[]>;
8
47
  get(id: string): Promise<StoredSchedule | null>;
48
+ /**
49
+ * Delete temp files left by atomic writes that never renamed (a crash or kill
50
+ * mid-write). They are invisible to `list` but pile up on disk. Returns how
51
+ * many were removed.
52
+ */
53
+ sweepOrphanedTempFiles(now?: number): Promise<number>;
9
54
  create(schedule: Omit<StoredSchedule, "id">): Promise<StoredSchedule>;
10
55
  put(schedule: StoredSchedule): Promise<void>;
11
56
  delete(id: string): Promise<void>;
57
+ /**
58
+ * Bumps whenever the cached set changes (a disk change seen on validation, a
59
+ * put, a delete), so a caller can reuse anything it derived from `list()`
60
+ * until the store actually changes.
61
+ */
62
+ get version(): number;
12
63
  }
13
64
  //# sourceMappingURL=store.d.ts.map
@@ -1,14 +1,84 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { mkdir, readFile, readdir, rm } from "node:fs/promises";
2
+ import { mkdir, readFile, readdir, rm, stat } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { StoredScheduleSchema, } from "@hyperdrive.bot/fleet-protocol/schedule/types";
5
5
  import { writeJsonFileAtomic } from "../atomic-file.js";
6
+ /**
7
+ * How many runs a schedule file keeps. Every run start and finish rewrites the
8
+ * whole file, and the history grew unbounded (5,510 runs, 2.3 MB in one real
9
+ * file), so the oldest runs are dropped at write time. How many finished runs
10
+ * were dropped is kept in `droppedRunCount`, so `maxRuns` completion still
11
+ * counts every run that ever happened.
12
+ */
13
+ export const SCHEDULE_RUNS_RETAINED = 200;
14
+ /**
15
+ * A leftover `.<id>.json.<pid>.<ts>.<uuid>.tmp` from an interrupted atomic write
16
+ * is only swept when it is at least this old, so a write racing the sweep is
17
+ * never deleted from under itself.
18
+ */
19
+ const ORPHAN_TMP_MIN_AGE_MS = 60000;
20
+ export function capScheduleRuns(schedule) {
21
+ if (schedule.runs.length <= SCHEDULE_RUNS_RETAINED) {
22
+ return schedule;
23
+ }
24
+ const dropped = schedule.runs.slice(0, schedule.runs.length - SCHEDULE_RUNS_RETAINED);
25
+ const droppedFinished = dropped.filter((run) => run.status !== "running").length;
26
+ return {
27
+ ...schedule,
28
+ runs: schedule.runs.slice(-SCHEDULE_RUNS_RETAINED),
29
+ droppedRunCount: (schedule.droppedRunCount ?? 0) + droppedFinished,
30
+ };
31
+ }
32
+ function sameStamp(left, right) {
33
+ return (left !== undefined &&
34
+ right !== undefined &&
35
+ left.mtimeMs === right.mtimeMs &&
36
+ left.size === right.size);
37
+ }
38
+ /**
39
+ * Three-way merge of a daemon write over a file that changed on disk since the
40
+ * daemon last read it. `base` is what the daemon read, `mine` what it wants to
41
+ * write, `theirs` what is on disk now. A top-level field the daemon did not
42
+ * change keeps the disk value; a field it changed keeps the daemon's. Runs are
43
+ * unioned by id, the daemon's copy of a run winning.
44
+ */
45
+ export function mergeScheduleWrite(base, mine, theirs) {
46
+ const merged = { ...theirs };
47
+ for (const key of Object.keys(mine)) {
48
+ if (key === "runs")
49
+ continue;
50
+ if (JSON.stringify(mine[key]) !== JSON.stringify(base[key])) {
51
+ merged[key] = mine[key];
52
+ }
53
+ }
54
+ const mineRunIds = new Set(mine.runs.map((run) => run.id));
55
+ const theirsOnly = theirs.runs.filter((run) => !mineRunIds.has(run.id));
56
+ merged.runs = [...theirsOnly, ...mine.runs].sort((left, right) => left.startedAt.localeCompare(right.startedAt));
57
+ return merged;
58
+ }
6
59
  function generateScheduleId() {
7
60
  return randomBytes(4).toString("hex");
8
61
  }
62
+ /**
63
+ * File-backed schedule store with an in-memory cache validated against disk.
64
+ *
65
+ * Every read stats the directory's `*.json` files (no read, no parse) and
66
+ * re-reads only files whose mtime or size changed, so a schedule written by
67
+ * hand or by another tool is still picked up by a running daemon, at a fraction
68
+ * of the cost of re-parsing 5 MB of JSON per call. Concurrent reads share one
69
+ * validation pass. A write whose file changed on disk since the daemon read it
70
+ * is merged over the disk copy (`mergeScheduleWrite`) rather than clobbering it.
71
+ *
72
+ * Returned objects are shared with the cache: treat them as read-only and write
73
+ * changes back through `put`.
74
+ */
9
75
  export class ScheduleStore {
10
76
  constructor(dir) {
11
77
  this.dir = dir;
78
+ this.cache = new Map();
79
+ this.stamps = new Map();
80
+ this.validating = null;
81
+ this.revision = 0;
12
82
  }
13
83
  filePath(id) {
14
84
  return join(this.dir, `${id}.json`);
@@ -16,29 +86,99 @@ export class ScheduleStore {
16
86
  async ensureDir() {
17
87
  await mkdir(this.dir, { recursive: true });
18
88
  }
19
- async list() {
89
+ async readStamp(path) {
90
+ try {
91
+ const info = await stat(path);
92
+ return { mtimeMs: info.mtimeMs, size: info.size };
93
+ }
94
+ catch (error) {
95
+ if (error.code === "ENOENT")
96
+ return undefined;
97
+ throw error;
98
+ }
99
+ }
100
+ async readScheduleFile(path) {
101
+ const content = await readFile(path, "utf-8");
102
+ return StoredScheduleSchema.parse(JSON.parse(content));
103
+ }
104
+ /** Bring the cache in line with disk, re-reading only files that changed. */
105
+ validate() {
106
+ if (!this.validating) {
107
+ this.validating = this.validateNow().finally(() => {
108
+ this.validating = null;
109
+ });
110
+ }
111
+ return this.validating;
112
+ }
113
+ async validateNow() {
20
114
  await this.ensureDir();
21
115
  const entries = await readdir(this.dir, { withFileTypes: true });
22
- const schedules = await Promise.all(entries
116
+ const names = entries
23
117
  .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
24
- .map(async (entry) => {
25
- const content = await readFile(join(this.dir, entry.name), "utf-8");
26
- return StoredScheduleSchema.parse(JSON.parse(content));
118
+ .map((entry) => entry.name);
119
+ const seen = new Set();
120
+ let changed = false;
121
+ await Promise.all(names.map(async (name) => {
122
+ seen.add(name);
123
+ const path = join(this.dir, name);
124
+ const stamp = await this.readStamp(path);
125
+ if (!stamp)
126
+ return;
127
+ if (sameStamp(this.stamps.get(name), stamp))
128
+ return;
129
+ const schedule = await this.readScheduleFile(path);
130
+ this.cache.set(schedule.id, schedule);
131
+ this.stamps.set(name, stamp);
132
+ changed = true;
27
133
  }));
28
- return schedules.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
134
+ for (const name of this.stamps.keys()) {
135
+ if (seen.has(name))
136
+ continue;
137
+ this.stamps.delete(name);
138
+ this.cache.delete(name.slice(0, -".json".length));
139
+ changed = true;
140
+ }
141
+ if (changed) {
142
+ this.revision += 1;
143
+ }
144
+ }
145
+ async list() {
146
+ await this.validate();
147
+ return [...this.cache.values()].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
29
148
  }
30
149
  async get(id) {
150
+ await this.validate();
151
+ return this.cache.get(id) ?? null;
152
+ }
153
+ /**
154
+ * Delete temp files left by atomic writes that never renamed (a crash or kill
155
+ * mid-write). They are invisible to `list` but pile up on disk. Returns how
156
+ * many were removed.
157
+ */
158
+ async sweepOrphanedTempFiles(now = Date.now()) {
31
159
  await this.ensureDir();
32
- try {
33
- const content = await readFile(this.filePath(id), "utf-8");
34
- return StoredScheduleSchema.parse(JSON.parse(content));
35
- }
36
- catch (error) {
37
- if (error.code === "ENOENT") {
38
- return null;
160
+ const entries = await readdir(this.dir, { withFileTypes: true });
161
+ let removed = 0;
162
+ for (const entry of entries) {
163
+ if (!entry.isFile() || !entry.name.startsWith(".") || !entry.name.endsWith(".tmp")) {
164
+ continue;
165
+ }
166
+ const path = join(this.dir, entry.name);
167
+ try {
168
+ const info = await stat(path);
169
+ if (now - info.mtimeMs < ORPHAN_TMP_MIN_AGE_MS) {
170
+ continue;
171
+ }
172
+ await rm(path, { force: true });
173
+ removed += 1;
174
+ }
175
+ catch (error) {
176
+ if (error.code !== "ENOENT") {
177
+ throw error;
178
+ }
39
179
  }
40
- throw error;
41
180
  }
181
+ return removed;
42
182
  }
43
183
  async create(schedule) {
44
184
  const created = { ...schedule, id: generateScheduleId() };
@@ -47,11 +187,39 @@ export class ScheduleStore {
47
187
  }
48
188
  async put(schedule) {
49
189
  await this.ensureDir();
50
- await writeJsonFileAtomic(this.filePath(schedule.id), schedule);
190
+ const name = `${schedule.id}.json`;
191
+ const path = this.filePath(schedule.id);
192
+ let next = schedule;
193
+ // What the daemon last read for this id, and the file stamp it read it at.
194
+ const base = this.cache.get(schedule.id);
195
+ const known = this.stamps.get(name);
196
+ const onDisk = known ? await this.readStamp(path) : undefined;
197
+ if (base && known && onDisk && !sameStamp(known, onDisk)) {
198
+ // Edited behind the daemon's back since it was read: merge, never clobber.
199
+ next = mergeScheduleWrite(base, schedule, await this.readScheduleFile(path));
200
+ }
201
+ const capped = capScheduleRuns(next);
202
+ await writeJsonFileAtomic(path, capped);
203
+ const stamp = await this.readStamp(path);
204
+ if (stamp)
205
+ this.stamps.set(name, stamp);
206
+ this.cache.set(capped.id, capped);
207
+ this.revision += 1;
51
208
  }
52
209
  async delete(id) {
53
210
  await this.ensureDir();
54
211
  await rm(this.filePath(id), { force: true });
212
+ this.cache.delete(id);
213
+ this.stamps.delete(`${id}.json`);
214
+ this.revision += 1;
215
+ }
216
+ /**
217
+ * Bumps whenever the cached set changes (a disk change seen on validation, a
218
+ * put, a delete), so a caller can reuse anything it derived from `list()`
219
+ * until the store actually changes.
220
+ */
221
+ get version() {
222
+ return this.revision;
55
223
  }
56
224
  }
57
225
  //# sourceMappingURL=store.js.map
@@ -30,7 +30,14 @@ export interface AgentUpdatesService {
30
30
  }): void;
31
31
  clearSubscription(subscriptionId: string): void;
32
32
  hasSubscription(): boolean;
33
- forwardLiveAgent(agent: ManagedAgent): Promise<void>;
33
+ /**
34
+ * Emit the agent's update, then its workspace update. Workspace updates are
35
+ * coalesced (see WORKSPACE_UPDATE_COALESCE_MS) unless `immediate`, which a
36
+ * request handler that replies after this resolves should pass.
37
+ */
38
+ forwardLiveAgent(agent: ManagedAgent, options?: {
39
+ immediate?: boolean;
40
+ }): Promise<void>;
34
41
  emitStoredRecord(record: StoredAgentRecord): Promise<AgentSnapshotPayload>;
35
42
  removeAgent(agentId: string): void;
36
43
  dispose(): void;
@@ -43,7 +50,15 @@ export interface AgentUpdatesServiceDeps {
43
50
  buildProjectPlacementForWorkspaceId(workspaceId: string): Promise<ProjectPlacementPayload | null>;
44
51
  emitWorkspaceUpdateForWorkspaceId(workspaceId: string): Promise<void>;
45
52
  logger: pino.Logger;
53
+ /** Coalescing window for workspace updates; defaults to WORKSPACE_UPDATE_COALESCE_MS. */
54
+ workspaceUpdateCoalesceMs?: number;
46
55
  }
56
+ /**
57
+ * A streaming agent emits agent_state many times a second, and each one used to
58
+ * rebuild its workspace descriptor on the spot. Workspace updates are collected
59
+ * per workspace for this long and flushed once.
60
+ */
61
+ export declare const WORKSPACE_UPDATE_COALESCE_MS = 100;
47
62
  /**
48
63
  * Pure predicate shared by the live subscription stream and the snapshot listing
49
64
  * pager: does an agent (with its resolved project placement) satisfy a
@@ -1,4 +1,10 @@
1
1
  import { resolveEffectiveThinkingOptionId } from "../../agent/agent-projections.js";
2
+ /**
3
+ * A streaming agent emits agent_state many times a second, and each one used to
4
+ * rebuild its workspace descriptor on the spot. Workspace updates are collected
5
+ * per workspace for this long and flushed once.
6
+ */
7
+ export const WORKSPACE_UPDATE_COALESCE_MS = 100;
2
8
  function agentThinkingOptionMatchesFilter(agent, filter) {
3
9
  if (filter.thinkingOptionId === undefined) {
4
10
  return true;
@@ -64,6 +70,43 @@ function agentUpdateTargetId(update) {
64
70
  }
65
71
  export function createAgentUpdatesService(deps) {
66
72
  let subscription = null;
73
+ const coalesceMs = deps.workspaceUpdateCoalesceMs ?? WORKSPACE_UPDATE_COALESCE_MS;
74
+ let pendingWorkspaceIds = new Set();
75
+ let disposed = false;
76
+ let pendingWorkspaceFlush = null;
77
+ async function flushWorkspaceUpdates() {
78
+ const flush = pendingWorkspaceFlush;
79
+ const workspaceIds = pendingWorkspaceIds;
80
+ pendingWorkspaceFlush = null;
81
+ pendingWorkspaceIds = new Set();
82
+ for (const workspaceId of workspaceIds) {
83
+ if (disposed)
84
+ break;
85
+ try {
86
+ await deps.emitWorkspaceUpdateForWorkspaceId(workspaceId);
87
+ }
88
+ catch (error) {
89
+ deps.logger.error({ err: error, workspaceId }, "Failed to emit workspace update");
90
+ }
91
+ }
92
+ flush?.resolve();
93
+ }
94
+ /**
95
+ * Queue a workspace update and resolve once the batch holding it has flushed,
96
+ * so a caller that awaits still observes the emitted update.
97
+ */
98
+ function scheduleWorkspaceUpdate(workspaceId) {
99
+ pendingWorkspaceIds.add(workspaceId);
100
+ if (!pendingWorkspaceFlush) {
101
+ let resolve;
102
+ const done = new Promise((settle) => {
103
+ resolve = settle;
104
+ });
105
+ const timer = setTimeout(() => void flushWorkspaceUpdates(), coalesceMs);
106
+ pendingWorkspaceFlush = { timer, done, resolve };
107
+ }
108
+ return pendingWorkspaceFlush.done;
109
+ }
67
110
  function bufferOrEmit(sub, payload) {
68
111
  if (payload.kind === "upsert" && !deps.isProviderVisibleToClient(payload.agent.provider)) {
69
112
  return;
@@ -158,7 +201,7 @@ export function createAgentUpdatesService(deps) {
158
201
  });
159
202
  return payload;
160
203
  }
161
- async function forwardLiveAgent(agent) {
204
+ async function forwardLiveAgent(agent, options) {
162
205
  try {
163
206
  const sub = subscription;
164
207
  const payload = await deps.buildAgentPayload(agent);
@@ -196,7 +239,14 @@ export function createAgentUpdatesService(deps) {
196
239
  // A lifecycle change updates exactly the agent's owning workspace, never
197
240
  // every workspace sharing its cwd. Ownership is the agent's workspaceId.
198
241
  if (payload.workspaceId) {
199
- await deps.emitWorkspaceUpdateForWorkspaceId(payload.workspaceId);
242
+ if (options?.immediate) {
243
+ // This flush covers any queued update for the same workspace.
244
+ pendingWorkspaceIds.delete(payload.workspaceId);
245
+ await deps.emitWorkspaceUpdateForWorkspaceId(payload.workspaceId);
246
+ }
247
+ else {
248
+ await scheduleWorkspaceUpdate(payload.workspaceId);
249
+ }
200
250
  }
201
251
  }
202
252
  catch (error) {
@@ -205,6 +255,13 @@ export function createAgentUpdatesService(deps) {
205
255
  }
206
256
  function dispose() {
207
257
  subscription = null;
258
+ disposed = true;
259
+ if (pendingWorkspaceFlush) {
260
+ clearTimeout(pendingWorkspaceFlush.timer);
261
+ pendingWorkspaceFlush.resolve();
262
+ pendingWorkspaceFlush = null;
263
+ pendingWorkspaceIds = new Set();
264
+ }
208
265
  }
209
266
  return {
210
267
  beginSubscription,
@@ -66,8 +66,16 @@ export declare class ProviderCatalogSession {
66
66
  private readonly providerUsageService;
67
67
  private readonly logger;
68
68
  private unsubscribeSnapshotEvents;
69
+ /**
70
+ * Snapshot keys (resolved cwds) this client has asked about. Background work
71
+ * warms a snapshot per agent worktree, and pushing every one of those to every
72
+ * client was ~145 unsolicited updates per 30s. A client only gets pushes for the
73
+ * global snapshot and for cwds it requested.
74
+ */
75
+ private readonly requestedSnapshotCwds;
69
76
  constructor(options: ProviderCatalogSessionOptions);
70
77
  start(): void;
78
+ private noteRequestedCwd;
71
79
  dispose(): void;
72
80
  private downgradeModeIconsForClient;
73
81
  private downgradeEntryModesForClient;
@@ -1,5 +1,5 @@
1
1
  import { getErrorMessage } from "@hyperdrive.bot/fleet-protocol/error-utils";
2
- import { isGlobalProviderSnapshotKey, } from "../../agent/provider-snapshot-manager.js";
2
+ import { isGlobalProviderSnapshotKey, resolveSnapshotCwd, } from "../../agent/provider-snapshot-manager.js";
3
3
  import { expandTilde } from "../../../utils/path.js";
4
4
  // COMPAT(customModeIcons): the only mode icons known to clients before v0.1.84. Any
5
5
  // other icon name is downgraded to "ShieldCheck" for those clients.
@@ -19,6 +19,13 @@ const LEGACY_MODE_ICONS = new Set([
19
19
  export class ProviderCatalogSession {
20
20
  constructor(options) {
21
21
  this.unsubscribeSnapshotEvents = null;
22
+ /**
23
+ * Snapshot keys (resolved cwds) this client has asked about. Background work
24
+ * warms a snapshot per agent worktree, and pushing every one of those to every
25
+ * client was ~145 unsolicited updates per 30s. A client only gets pushes for the
26
+ * global snapshot and for cwds it requested.
27
+ */
28
+ this.requestedSnapshotCwds = new Set();
22
29
  this.host = options.host;
23
30
  this.providerSnapshotManager = options.providerSnapshotManager;
24
31
  this.providerUsageService = options.providerUsageService;
@@ -26,6 +33,9 @@ export class ProviderCatalogSession {
26
33
  }
27
34
  start() {
28
35
  const handleProviderSnapshotChange = (entries, cwd) => {
36
+ if (!isGlobalProviderSnapshotKey(cwd) && !this.requestedSnapshotCwds.has(cwd)) {
37
+ return;
38
+ }
29
39
  // COMPAT(providersSnapshot): keep provider visibility gating for older clients.
30
40
  const visibleEntries = entries.filter((entry) => this.host.isProviderVisibleToClient(entry.provider));
31
41
  const snapshotCwd = isGlobalProviderSnapshotKey(cwd) ? undefined : cwd;
@@ -43,6 +53,11 @@ export class ProviderCatalogSession {
43
53
  this.providerSnapshotManager.off("change", handleProviderSnapshotChange);
44
54
  };
45
55
  }
56
+ noteRequestedCwd(cwd) {
57
+ if (cwd?.trim()) {
58
+ this.requestedSnapshotCwds.add(resolveSnapshotCwd(expandTilde(cwd)));
59
+ }
60
+ }
46
61
  dispose() {
47
62
  if (this.unsubscribeSnapshotEvents) {
48
63
  this.unsubscribeSnapshotEvents();
@@ -78,6 +93,7 @@ export class ProviderCatalogSession {
78
93
  }
79
94
  async handleListProviderModelsRequest(msg) {
80
95
  const cwd = resolveCatalogRequestCwd(msg.cwd);
96
+ this.noteRequestedCwd(cwd);
81
97
  const fetchedAt = new Date().toISOString();
82
98
  const entry = await this.getProviderSnapshotEntryForRead(cwd, msg.provider);
83
99
  if (!entry) {
@@ -125,6 +141,7 @@ export class ProviderCatalogSession {
125
141
  async handleListProviderModesRequest(msg) {
126
142
  const fetchedAt = new Date().toISOString();
127
143
  const cwd = resolveCatalogRequestCwd(msg.cwd);
144
+ this.noteRequestedCwd(cwd);
128
145
  const entry = await this.getProviderSnapshotEntryForRead(cwd, msg.provider);
129
146
  if (!entry) {
130
147
  this.host.emit({
@@ -196,6 +213,7 @@ export class ProviderCatalogSession {
196
213
  async handleListProviderFeaturesRequest(msg) {
197
214
  const fetchedAt = new Date().toISOString();
198
215
  try {
216
+ this.noteRequestedCwd(msg.draftConfig.cwd);
199
217
  const sessionConfig = this.buildDraftAgentSessionConfig(msg.draftConfig);
200
218
  const features = await this.host.listDraftFeatures(sessionConfig);
201
219
  this.host.emit({
@@ -250,6 +268,7 @@ export class ProviderCatalogSession {
250
268
  }
251
269
  }
252
270
  async handleGetProvidersSnapshotRequest(msg) {
271
+ this.noteRequestedCwd(msg.cwd);
253
272
  // COMPAT(providersSnapshot): keep legacy provider-list RPCs alongside snapshot flow.
254
273
  const entries = this.providerSnapshotManager
255
274
  .getSnapshot(msg.cwd ? expandTilde(msg.cwd) : undefined)
@@ -286,6 +305,7 @@ export class ProviderCatalogSession {
286
305
  }
287
306
  async handleRefreshProvidersSnapshotRequest(msg) {
288
307
  if (msg.cwd) {
308
+ this.noteRequestedCwd(msg.cwd);
289
309
  await this.providerSnapshotManager.refreshSnapshotForCwd({
290
310
  cwd: expandTilde(msg.cwd),
291
311
  providers: msg.providers,
@@ -13,6 +13,7 @@ import type { WorkspaceGitService } from "./workspace-git-service.js";
13
13
  import { AgentManager } from "./agent/agent-manager.js";
14
14
  import type { WorkflowManager } from "./workflow/workflow-manager.js";
15
15
  import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
16
+ import type { StoredAgentRecord } from "./agent/agent-storage.js";
16
17
  import type { AgentStorage } from "./agent/agent-storage.js";
17
18
  import { type PersistedWorkspaceRecord, type ProjectRegistry, type WorkspaceRegistry } from "./workspace-registry.js";
18
19
  import type { CohortRecorder } from "./ingestion/apply.js";
@@ -167,6 +168,15 @@ export type SessionLifecycleIntent = {
167
168
  requestId: string;
168
169
  reason: string;
169
170
  };
171
+ /**
172
+ * The stored records a workspace descriptor for `workspaceIds` can depend on:
173
+ * records owned by one of those workspaces, running delegated records (they roll
174
+ * up to their delegation root, see WorkspaceDirectory), and the ancestor chain of
175
+ * every delegated record or live agent so the root can be resolved.
176
+ */
177
+ export declare function selectRecordsForWorkspaces(records: readonly StoredAgentRecord[], liveAgents: ReadonlyArray<{
178
+ labels?: Record<string, unknown> | null;
179
+ }>, workspaceIds: ReadonlySet<string>): StoredAgentRecord[];
170
180
  /**
171
181
  * Session represents a single connected client session.
172
182
  * It owns all state management, orchestration logic, and message processing.
@@ -505,6 +515,12 @@ export declare class Session {
505
515
  /** Read one background shell's captured output (tailed and redacted). */
506
516
  private handleBackgroundTaskOutputRequest;
507
517
  private handleDismissBackgroundTaskRequest;
518
+ /**
519
+ * Persist the archive and emit the archived agent, then hand back the rest
520
+ * (process teardown, then the workspace delta) as `background` so the caller
521
+ * can reply first. Stopping a busy agent and rebuilding its workspace used to
522
+ * sit in front of the reply.
523
+ */
508
524
  private archiveAgentForClose;
509
525
  private handleDetachAgentRequest;
510
526
  private handleCloseItemsRequest;