@hyperdrive.bot/fleet-server 0.3.164 → 0.3.165

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 (55) hide show
  1. package/dist/server/extensions/daemon-backend.js +25 -0
  2. package/dist/server/server/agent/agent-storage.d.ts +67 -12
  3. package/dist/server/server/agent/agent-storage.js +208 -132
  4. package/dist/server/server/agent/providers/claude/transport/pty-query.js +10 -0
  5. package/dist/server/server/bootstrap.d.ts +5 -0
  6. package/dist/server/server/bootstrap.js +26 -3
  7. package/dist/server/server/chat/chat-service.d.ts +22 -0
  8. package/dist/server/server/chat/chat-service.js +46 -3
  9. package/dist/server/server/daemon-worker.js +1 -0
  10. package/dist/server/server/fleet/decision-service.d.ts +5 -0
  11. package/dist/server/server/fleet/decision-service.js +30 -0
  12. package/dist/server/server/loop-service.d.ts +95 -0
  13. package/dist/server/server/loop-service.js +64 -3
  14. package/dist/server/server/migrations/backfill-workspace-id.migration.js +3 -2
  15. package/dist/server/server/push/token-store.d.ts +5 -1
  16. package/dist/server/server/push/token-store.js +26 -5
  17. package/dist/server/server/schedule/service.js +4 -1
  18. package/dist/server/server/schedule/store.d.ts +21 -1
  19. package/dist/server/server/schedule/store.js +283 -2
  20. package/dist/server/server/search/indexer.d.ts +10 -0
  21. package/dist/server/server/search/indexer.js +51 -2
  22. package/dist/server/server/session.js +12 -13
  23. package/dist/server/server/state/agent-state.d.ts +39 -0
  24. package/dist/server/server/state/agent-state.js +94 -0
  25. package/dist/server/server/state/daemon-state.d.ts +30 -0
  26. package/dist/server/server/state/daemon-state.js +99 -0
  27. package/dist/server/server/state/legacy-import.d.ts +124 -0
  28. package/dist/server/server/state/legacy-import.js +682 -0
  29. package/dist/server/server/state/legacy-manifest.d.ts +73 -0
  30. package/dist/server/server/state/legacy-manifest.js +112 -0
  31. package/dist/server/server/state/legacy-mirror.d.ts +57 -0
  32. package/dist/server/server/state/legacy-mirror.js +114 -0
  33. package/dist/server/server/state/legacy-sources.d.ts +9 -0
  34. package/dist/server/server/state/legacy-sources.js +163 -0
  35. package/dist/server/server/state/state-db.d.ts +263 -0
  36. package/dist/server/server/state/state-db.js +772 -0
  37. package/dist/server/server/state/state-schema.d.ts +39 -0
  38. package/dist/server/server/state/state-schema.js +125 -0
  39. package/dist/server/server/state/state-worker.d.ts +48 -0
  40. package/dist/server/server/state/state-worker.js +165 -0
  41. package/dist/server/server/websocket-server.js +2 -1
  42. package/dist/server/server/workspace/session-openable.js +7 -0
  43. package/dist/server/server/workspace-reconciliation-service.js +14 -3
  44. package/dist/server/server/workspace-registry.d.ts +45 -2
  45. package/dist/server/server/workspace-registry.js +75 -4
  46. package/dist/server/web-ui/_expo/static/js/web/{index-2ac1a7249c20c322a1f9a54563cfee62.js → index-38580ed8926a5ddc569d744b8299d339.js} +4 -4
  47. package/dist/server/web-ui/_expo/static/js/web/index-38580ed8926a5ddc569d744b8299d339.js.br +0 -0
  48. package/dist/server/web-ui/_expo/static/js/web/{index-2ac1a7249c20c322a1f9a54563cfee62.js.gz → index-38580ed8926a5ddc569d744b8299d339.js.gz} +0 -0
  49. package/dist/server/web-ui/_expo/static/js/web/{index-2ac1a7249c20c322a1f9a54563cfee62.js.map.br → index-38580ed8926a5ddc569d744b8299d339.js.map.br} +0 -0
  50. package/dist/server/web-ui/_expo/static/js/web/{index-2ac1a7249c20c322a1f9a54563cfee62.js.map.gz → index-38580ed8926a5ddc569d744b8299d339.js.map.gz} +0 -0
  51. package/dist/server/web-ui/index.html +1 -1
  52. package/dist/server/web-ui/index.html.br +0 -0
  53. package/dist/server/web-ui/index.html.gz +0 -0
  54. package/package.json +6 -6
  55. package/dist/server/web-ui/_expo/static/js/web/index-2ac1a7249c20c322a1f9a54563cfee62.js.br +0 -0
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { mkdir, readFile, writeFile } from "node:fs/promises";
10
10
  import path from "node:path";
11
+ import { stateDbFor } from "../server/state/state-db.js";
11
12
  const STATUS_POLL_INTERVAL_MS = 2000;
12
13
  function mapStatus(agent) {
13
14
  if (agent.pendingPermissions.size > 0)
@@ -57,7 +58,19 @@ export function createDaemonExtensionBackend(deps) {
57
58
  .listAgents()
58
59
  .filter((agent) => !agent.internal)
59
60
  .map((agent) => toSummary(agent, serverId));
61
+ // Extension storage lives in state.sqlite's `extension_kv` (one row per
62
+ // extension and key) when the daemon has it; the per-extension JSON file is
63
+ // mirrored. Without it, the file is the store, as before.
64
+ const stateDb = stateDbFor(paseoHome);
65
+ const kv = stateDb?.doc("extension_kv") ?? null;
60
66
  async function readStore(extensionId) {
67
+ if (kv) {
68
+ const store = {};
69
+ for (const row of kv.loadAll(extensionId)) {
70
+ store[row.id] = JSON.parse(row.body);
71
+ }
72
+ return store;
73
+ }
61
74
  try {
62
75
  const raw = await readFile(storePath(paseoHome, extensionId), "utf8");
63
76
  return JSON.parse(raw);
@@ -120,6 +133,18 @@ export function createDaemonExtensionBackend(deps) {
120
133
  return store[key];
121
134
  },
122
135
  set: async (extensionId, key, value) => {
136
+ if (kv && stateDb) {
137
+ await kv.put(extensionId, key, value ?? null);
138
+ const file = storePath(paseoHome, extensionId);
139
+ stateDb.mirror.write(`extension-kv:${file}`, async () => {
140
+ const rev = stateDb.mirrorRev();
141
+ const store = await readStore(extensionId);
142
+ await mkdir(path.dirname(file), { recursive: true });
143
+ await writeFile(file, JSON.stringify(store, null, 2));
144
+ await stateDb.manifest.record("extension_kv", file, rev);
145
+ });
146
+ return;
147
+ }
123
148
  const store = await readStore(extensionId);
124
149
  store[key] = value;
125
150
  const file = storePath(paseoHome, extensionId);
@@ -3,6 +3,7 @@ import type { Logger } from "pino";
3
3
  import { type SessionDigest } from "../messages.js";
4
4
  import type { ManagedAgent } from "./agent-manager.js";
5
5
  import type { AgentSessionConfig } from "./agent-sdk-types.js";
6
+ import type { ChangedRow } from "../state/state-db.js";
6
7
  declare const STORED_AGENT_SCHEMA: z.ZodObject<{
7
8
  id: z.ZodString;
8
9
  provider: z.ZodString;
@@ -172,18 +173,30 @@ export declare const SERIALIZABLE_CONFIG_KEYS: ReadonlyArray<keyof SerializableA
172
173
  export type SerializableAgentConfig = Pick<AgentSessionConfig, "modeId" | "model" | "thinkingOptionId" | "featureValues" | "extra" | "systemPrompt" | "outputLanguage" | "goal" | "jobToBeDone" | "mcpServers" | "allowedTools">;
173
174
  export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>;
174
175
  export declare function parseStoredAgentRecord(value: unknown): StoredAgentRecord;
176
+ /**
177
+ * Where agent records are durably kept. Two implementations: the legacy
178
+ * per-agent JSON files (`FileAgentRecordBackend`) and `state.sqlite`
179
+ * (`state/agent-state.ts`). `AgentStorage` keeps the in-memory Map as the only
180
+ * read path either way; a backend is only loaded once and then written to.
181
+ */
182
+ export interface AgentRecordBackend {
183
+ loadAll(): Promise<StoredAgentRecord[]>;
184
+ /** `previous` is the record this one replaces, when there was one. */
185
+ write(record: StoredAgentRecord, previous: StoredAgentRecord | null): Promise<void>;
186
+ remove(agentId: string, previous: StoredAgentRecord | null): Promise<void>;
187
+ /** Changes since a daemon-wide revision, or null when the backend has no revisions. */
188
+ listChangedSince?(rev: number, limit?: number): ChangedRow[] | null;
189
+ }
175
190
  export declare class AgentStorage {
176
191
  private cache;
177
- private pathById;
178
- private pathsById;
179
192
  private pendingWrites;
180
193
  private deleting;
181
194
  private loaded;
182
- private baseDir;
183
195
  private loadPromise;
184
196
  private logger;
197
+ private readonly backend;
185
198
  private readonly changeListeners;
186
- constructor(baseDir: string, logger: Logger);
199
+ constructor(baseDir: string, logger: Logger, backend?: AgentRecordBackend);
187
200
  /**
188
201
  * Called after a record lands in (or leaves) the cache, so a listener that
189
202
  * reads the record back sees the new state. Feeds the agent directory
@@ -194,6 +207,11 @@ export declare class AgentStorage {
194
207
  initialize(): Promise<void>;
195
208
  list(): Promise<StoredAgentRecord[]>;
196
209
  get(agentId: string): Promise<StoredAgentRecord | null>;
210
+ /**
211
+ * Agent rows changed after `rev` (tombstones included), oldest first. Null on
212
+ * the file backend, which has no revisions. Internal until the delta protocol.
213
+ */
214
+ listChangedSince(rev: number, limit?: number): ChangedRow[] | null;
197
215
  upsert(record: StoredAgentRecord): Promise<void>;
198
216
  private queueRecordWrite;
199
217
  private writeRecord;
@@ -203,24 +221,61 @@ export declare class AgentStorage {
203
221
  title?: string | null;
204
222
  internal?: boolean;
205
223
  }): Promise<void>;
224
+ /**
225
+ * Read-modify-write of one stored agent inside its write queue. `build` gets
226
+ * the latest record (after every earlier write for the agent landed) and
227
+ * returns the next one, or null to leave it untouched. Resolves with what was
228
+ * written, or null.
229
+ */
230
+ update(agentId: string, build: (record: StoredAgentRecord) => StoredAgentRecord | null): Promise<StoredAgentRecord | null>;
206
231
  setTitle(agentId: string, title: string): Promise<void>;
207
232
  setGeneratedTitle(agentId: string, title: string): Promise<StoredAgentRecord>;
208
233
  /**
209
- * Targeted digest-only update. Drains pending writes, reads the latest record,
210
- * and updates ONLY the digest field — so an async digest refresh can never
211
- * clobber a concurrent title/label write (which a full-record persist would).
212
- * Mirrors setGeneratedTitle. See docs/session-digest.md §5.
234
+ * Targeted digest-only update. Reads the latest record inside the per-agent
235
+ * write queue and updates ONLY the digest field, so an async digest refresh
236
+ * can never clobber a concurrent title/label write (which a full-record
237
+ * persist would). See docs/session-digest.md §5.
213
238
  */
214
239
  setDigest(agentId: string, digest: SessionDigest): Promise<StoredAgentRecord>;
240
+ /**
241
+ * Read-modify-write inside the per-agent write queue. The read happens when
242
+ * the previous write for this agent has landed, so two updates racing (an
243
+ * archive teardown persisting its final snapshot while the user renames the
244
+ * agent) can no longer both start from the same old record and have the later
245
+ * one silently drop the earlier one's change.
246
+ */
247
+ private queueRecordUpdate;
215
248
  flush(): Promise<void>;
216
249
  private load;
217
250
  private doLoad;
218
- private scanDisk;
219
- private readRecordFile;
220
- private buildRecordPath;
251
+ }
252
+ /**
253
+ * The legacy layout: one `<baseDir>/<project-dir>/<agentId>.json` per agent.
254
+ * Also what the sqlite backend mirrors to, so it must work without a prior
255
+ * `loadAll` (the mirror never scans): a path it has not indexed is derived from
256
+ * the previous record's cwd.
257
+ */
258
+ export declare class FileAgentRecordBackend implements AgentRecordBackend {
259
+ private readonly baseDir;
260
+ private readonly logger;
261
+ private readonly observer?;
262
+ private pathById;
263
+ private pathsById;
264
+ constructor(baseDir: string, logger: Logger, observer?: {
265
+ written(filePath: string): Promise<void>;
266
+ removed(filePath: string): Promise<void>;
267
+ } | undefined);
268
+ /** Tell the backend a file for `agentId` exists (the sqlite mirror seeds this from its manifest). */
269
+ indexPath(agentId: string, filePath: string): void;
270
+ loadAll(): Promise<StoredAgentRecord[]>;
271
+ write(record: StoredAgentRecord, previous: StoredAgentRecord | null): Promise<void>;
272
+ remove(agentId: string, previous: StoredAgentRecord | null): Promise<void>;
221
273
  private addIndexedPath;
222
274
  private removeIndexedPath;
223
- private waitForPendingWrite;
224
275
  }
276
+ /** Every `*.json` directly under `baseDir` or one project directory below it. */
277
+ export declare function listAgentRecordFiles(baseDir: string): Promise<string[]>;
278
+ export declare function readAgentRecordFile(filePath: string, logger: Logger): Promise<StoredAgentRecord | null>;
279
+ export declare function agentRecordPath(baseDir: string, record: StoredAgentRecord): string;
225
280
  export {};
226
281
  //# sourceMappingURL=agent-storage.d.ts.map
@@ -90,17 +90,15 @@ export function parseStoredAgentRecord(value) {
90
90
  return STORED_AGENT_SCHEMA.parse(value);
91
91
  }
92
92
  export class AgentStorage {
93
- constructor(baseDir, logger) {
93
+ constructor(baseDir, logger, backend) {
94
94
  this.cache = new Map();
95
- this.pathById = new Map();
96
- this.pathsById = new Map();
97
95
  this.pendingWrites = new Map();
98
96
  this.deleting = new Set();
99
97
  this.loaded = false;
100
98
  this.loadPromise = null;
101
99
  this.changeListeners = new Set();
102
- this.baseDir = baseDir;
103
100
  this.logger = logger.child({ module: "agent", component: "agent-storage" });
101
+ this.backend = backend ?? new FileAgentRecordBackend(baseDir, this.logger);
104
102
  }
105
103
  /**
106
104
  * Called after a record lands in (or leaves) the cache, so a listener that
@@ -134,6 +132,13 @@ export class AgentStorage {
134
132
  await this.load();
135
133
  return this.cache.get(agentId) ?? null;
136
134
  }
135
+ /**
136
+ * Agent rows changed after `rev` (tombstones included), oldest first. Null on
137
+ * the file backend, which has no revisions. Internal until the delta protocol.
138
+ */
139
+ listChangedSince(rev, limit) {
140
+ return this.backend.listChangedSince?.(rev, limit) ?? null;
141
+ }
137
142
  async upsert(record) {
138
143
  await this.load();
139
144
  await this.queueRecordWrite(record);
@@ -157,23 +162,9 @@ export class AgentStorage {
157
162
  return tracked;
158
163
  }
159
164
  async writeRecord(record) {
160
- const agentId = record.id;
161
- const nextPath = this.buildRecordPath(record);
162
- const previousPath = this.pathById.get(agentId);
163
- await writeJsonFileAtomic(nextPath, record);
164
- this.addIndexedPath(agentId, nextPath);
165
- if (previousPath && previousPath !== nextPath) {
166
- try {
167
- await fs.unlink(previousPath);
168
- }
169
- catch {
170
- // ignore cleanup errors
171
- }
172
- this.removeIndexedPath(agentId, previousPath);
173
- }
174
- this.cache.set(agentId, record);
175
- this.pathById.set(agentId, nextPath);
176
- this.notifyChanged(agentId, "upsert");
165
+ await this.backend.write(record, this.cache.get(record.id) ?? null);
166
+ this.cache.set(record.id, record);
167
+ this.notifyChanged(record.id, "upsert");
177
168
  }
178
169
  beginDelete(agentId) {
179
170
  this.deleting.add(agentId);
@@ -182,88 +173,109 @@ export class AgentStorage {
182
173
  await this.load();
183
174
  this.beginDelete(agentId);
184
175
  await (this.pendingWrites.get(agentId) ?? Promise.resolve());
185
- const paths = Array.from(this.pathsById.get(agentId) ?? []);
186
- await Promise.all(paths.map(async (filePath) => {
187
- try {
188
- await fs.unlink(filePath);
189
- }
190
- catch (error) {
191
- const code = error.code;
192
- if (code && code !== "ENOENT") {
193
- this.logger.warn({ err: error, agentId, filePath }, "Failed to remove agent record file");
194
- }
195
- }
196
- }));
176
+ await this.backend.remove(agentId, this.cache.get(agentId) ?? null);
197
177
  const existed = this.cache.delete(agentId);
198
- this.pathById.delete(agentId);
199
- this.pathsById.delete(agentId);
200
178
  if (existed) {
201
179
  this.notifyChanged(agentId, "remove");
202
180
  }
203
181
  }
204
182
  async applySnapshot(agent, options) {
205
183
  await this.load();
206
- await this.waitForPendingWrite(agent.id);
207
- const existing = (await this.get(agent.id)) ?? null;
208
184
  const hasTitleOverride = options !== undefined && Object.prototype.hasOwnProperty.call(options, "title");
209
185
  const hasInternalOverride = options !== undefined && Object.prototype.hasOwnProperty.call(options, "internal");
210
- const record = toStoredAgentRecord(agent, {
211
- title: hasTitleOverride ? (options?.title ?? null) : (existing?.title ?? null),
212
- createdAt: existing?.createdAt,
213
- internal: hasInternalOverride ? options?.internal : (agent.internal ?? existing?.internal),
186
+ await this.queueRecordUpdate(agent.id, (existing) => {
187
+ const record = toStoredAgentRecord(agent, {
188
+ title: hasTitleOverride ? (options?.title ?? null) : (existing?.title ?? null),
189
+ createdAt: existing?.createdAt,
190
+ internal: hasInternalOverride ? options?.internal : (agent.internal ?? existing?.internal),
191
+ });
192
+ // Preserve soft-delete/archive status across snapshot flushes.
193
+ // `archivedAt` is not part of the ManagedAgent snapshot, so a naive projection
194
+ // would wipe it during normal persistence (including on daemon restart).
195
+ if (existing && existing.archivedAt !== undefined) {
196
+ record.archivedAt = existing.archivedAt;
197
+ }
198
+ return record;
214
199
  });
215
- // Preserve soft-delete/archive status across snapshot flushes.
216
- // `archivedAt` is not part of the ManagedAgent snapshot, so a naive projection
217
- // would wipe it during normal persistence (including on daemon restart).
218
- if (existing && existing.archivedAt !== undefined) {
219
- record.archivedAt = existing.archivedAt;
220
- }
221
- await this.upsert(record);
200
+ }
201
+ /**
202
+ * Read-modify-write of one stored agent inside its write queue. `build` gets
203
+ * the latest record (after every earlier write for the agent landed) and
204
+ * returns the next one, or null to leave it untouched. Resolves with what was
205
+ * written, or null.
206
+ */
207
+ async update(agentId, build) {
208
+ await this.load();
209
+ let skipped = false;
210
+ const written = await this.queueRecordUpdate(agentId, (record) => {
211
+ const next = record ? build(record) : null;
212
+ if (!next) {
213
+ skipped = true;
214
+ // Nothing to write: hand the queue the current record untouched.
215
+ return record;
216
+ }
217
+ return next;
218
+ }, () => skipped);
219
+ return skipped ? null : written;
222
220
  }
223
221
  async setTitle(agentId, title) {
224
222
  await this.load();
225
- await this.waitForPendingWrite(agentId);
226
- const record = await this.get(agentId);
227
- if (!record) {
228
- throw new Error(`Agent ${agentId} not found`);
229
- }
230
- await this.upsert({ ...record, title });
223
+ await this.queueRecordUpdate(agentId, (record) => {
224
+ if (!record) {
225
+ throw new Error(`Agent ${agentId} not found`);
226
+ }
227
+ return { ...record, title };
228
+ });
231
229
  }
232
230
  async setGeneratedTitle(agentId, title) {
233
231
  await this.load();
234
- await this.waitForPendingWrite(agentId);
235
- const record = this.cache.get(agentId) ?? null;
236
- if (!record) {
237
- throw new Error(`Agent ${agentId} not found`);
238
- }
239
- const nextRecord = {
240
- ...record,
241
- title,
242
- updatedAt: new Date().toISOString(),
243
- };
244
- await this.queueRecordWrite(nextRecord);
245
- return nextRecord;
232
+ return this.queueRecordUpdate(agentId, (record) => {
233
+ if (!record) {
234
+ throw new Error(`Agent ${agentId} not found`);
235
+ }
236
+ return { ...record, title, updatedAt: new Date().toISOString() };
237
+ });
246
238
  }
247
239
  /**
248
- * Targeted digest-only update. Drains pending writes, reads the latest record,
249
- * and updates ONLY the digest field — so an async digest refresh can never
250
- * clobber a concurrent title/label write (which a full-record persist would).
251
- * Mirrors setGeneratedTitle. See docs/session-digest.md §5.
240
+ * Targeted digest-only update. Reads the latest record inside the per-agent
241
+ * write queue and updates ONLY the digest field, so an async digest refresh
242
+ * can never clobber a concurrent title/label write (which a full-record
243
+ * persist would). See docs/session-digest.md §5.
252
244
  */
253
245
  async setDigest(agentId, digest) {
254
246
  await this.load();
255
- await this.waitForPendingWrite(agentId);
256
- const record = this.cache.get(agentId) ?? null;
257
- if (!record) {
258
- throw new Error(`Agent ${agentId} not found`);
259
- }
260
- const nextRecord = {
261
- ...record,
262
- digest,
263
- updatedAt: new Date().toISOString(),
264
- };
265
- await this.queueRecordWrite(nextRecord);
266
- return nextRecord;
247
+ return this.queueRecordUpdate(agentId, (record) => {
248
+ if (!record) {
249
+ throw new Error(`Agent ${agentId} not found`);
250
+ }
251
+ return { ...record, digest, updatedAt: new Date().toISOString() };
252
+ });
253
+ }
254
+ /**
255
+ * Read-modify-write inside the per-agent write queue. The read happens when
256
+ * the previous write for this agent has landed, so two updates racing (an
257
+ * archive teardown persisting its final snapshot while the user renames the
258
+ * agent) can no longer both start from the same old record and have the later
259
+ * one silently drop the earlier one's change.
260
+ */
261
+ queueRecordUpdate(agentId, build, skip = () => false) {
262
+ let built = null;
263
+ const prev = (this.pendingWrites.get(agentId) ?? Promise.resolve()).catch(() => undefined);
264
+ const next = prev.then(async () => {
265
+ built = build(this.cache.get(agentId) ?? null);
266
+ if (skip() || this.deleting.has(agentId)) {
267
+ return undefined;
268
+ }
269
+ await this.writeRecord(built);
270
+ return undefined;
271
+ });
272
+ const tracked = next.finally(() => {
273
+ if (this.pendingWrites.get(agentId) === tracked) {
274
+ this.pendingWrites.delete(agentId);
275
+ }
276
+ });
277
+ this.pendingWrites.set(agentId, tracked);
278
+ return tracked.then(() => built);
267
279
  }
268
280
  async flush() {
269
281
  await this.load().catch(() => undefined);
@@ -281,10 +293,11 @@ export class AgentStorage {
281
293
  }
282
294
  async doLoad() {
283
295
  this.cache.clear();
284
- this.pathById.clear();
285
- this.pathsById.clear();
286
296
  try {
287
- const records = await this.scanDisk();
297
+ const records = await this.backend.loadAll();
298
+ for (const record of records) {
299
+ this.cache.set(record.id, record);
300
+ }
288
301
  this.loaded = true;
289
302
  return records;
290
303
  }
@@ -298,65 +311,85 @@ export class AgentStorage {
298
311
  return [];
299
312
  }
300
313
  }
301
- async scanDisk() {
302
- const records = [];
303
- let entries = [];
304
- try {
305
- entries = await fs.readdir(this.baseDir, { withFileTypes: true });
306
- }
307
- catch (error) {
308
- if (error.code === "ENOENT") {
309
- return [];
310
- }
311
- throw error;
312
- }
313
- const rootRecordPaths = entries
314
- .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
315
- .map((entry) => path.join(this.baseDir, entry.name));
316
- const projectDirs = entries
317
- .filter((entry) => entry.isDirectory())
318
- .map((entry) => path.join(this.baseDir, entry.name));
319
- const projectFileLists = await Promise.all(projectDirs.map(async (projectDir) => {
320
- try {
321
- const files = await fs.readdir(projectDir, { withFileTypes: true });
322
- return files
323
- .filter((file) => file.isFile() && file.name.endsWith(".json"))
324
- .map((file) => path.join(projectDir, file.name));
325
- }
326
- catch {
327
- return [];
328
- }
329
- }));
330
- const allFilePaths = [...rootRecordPaths, ...projectFileLists.flat()];
331
- const loaded = await Promise.all(allFilePaths.map(async (filePath) => {
332
- const record = await this.readRecordFile(filePath);
314
+ }
315
+ /**
316
+ * The legacy layout: one `<baseDir>/<project-dir>/<agentId>.json` per agent.
317
+ * Also what the sqlite backend mirrors to, so it must work without a prior
318
+ * `loadAll` (the mirror never scans): a path it has not indexed is derived from
319
+ * the previous record's cwd.
320
+ */
321
+ export class FileAgentRecordBackend {
322
+ constructor(baseDir, logger, observer) {
323
+ this.baseDir = baseDir;
324
+ this.logger = logger;
325
+ this.observer = observer;
326
+ this.pathById = new Map();
327
+ this.pathsById = new Map();
328
+ }
329
+ /** Tell the backend a file for `agentId` exists (the sqlite mirror seeds this from its manifest). */
330
+ indexPath(agentId, filePath) {
331
+ this.addIndexedPath(agentId, filePath);
332
+ if (!this.pathById.has(agentId))
333
+ this.pathById.set(agentId, filePath);
334
+ }
335
+ async loadAll() {
336
+ this.pathById.clear();
337
+ this.pathsById.clear();
338
+ const files = await listAgentRecordFiles(this.baseDir);
339
+ const loaded = await Promise.all(files.map(async (filePath) => {
340
+ const record = await readAgentRecordFile(filePath, this.logger);
333
341
  return record ? { record, filePath } : null;
334
342
  }));
343
+ const records = [];
335
344
  for (const item of loaded) {
336
345
  if (!item)
337
346
  continue;
338
347
  const { record, filePath } = item;
339
348
  records.push(record);
340
- this.cache.set(record.id, record);
341
349
  this.pathById.set(record.id, filePath);
342
350
  this.addIndexedPath(record.id, filePath);
343
351
  }
344
352
  return records;
345
353
  }
346
- async readRecordFile(filePath) {
347
- try {
348
- const content = await fs.readFile(filePath, "utf8");
349
- const parsed = JSON.parse(content);
350
- return parseStoredAgentRecord(parsed);
351
- }
352
- catch (error) {
353
- this.logger.error({ err: error, filePath }, "Skipping invalid agent record");
354
- return null;
354
+ async write(record, previous) {
355
+ const agentId = record.id;
356
+ const nextPath = agentRecordPath(this.baseDir, record);
357
+ const previousPath = this.pathById.get(agentId) ??
358
+ (previous ? agentRecordPath(this.baseDir, previous) : undefined);
359
+ await writeJsonFileAtomic(nextPath, record);
360
+ this.addIndexedPath(agentId, nextPath);
361
+ await this.observer?.written(nextPath);
362
+ if (previousPath && previousPath !== nextPath) {
363
+ try {
364
+ await fs.unlink(previousPath);
365
+ }
366
+ catch {
367
+ // ignore cleanup errors
368
+ }
369
+ this.removeIndexedPath(agentId, previousPath);
370
+ await this.observer?.removed(previousPath);
355
371
  }
372
+ this.pathById.set(agentId, nextPath);
356
373
  }
357
- buildRecordPath(record) {
358
- const projectDir = projectDirNameFromCwd(record.cwd);
359
- return path.join(this.baseDir, projectDir, `${record.id}.json`);
374
+ async remove(agentId, previous) {
375
+ const paths = new Set(this.pathsById.get(agentId) ?? []);
376
+ if (previous) {
377
+ paths.add(agentRecordPath(this.baseDir, previous));
378
+ }
379
+ await Promise.all(Array.from(paths).map(async (filePath) => {
380
+ try {
381
+ await fs.unlink(filePath);
382
+ }
383
+ catch (error) {
384
+ const code = error.code;
385
+ if (code && code !== "ENOENT") {
386
+ this.logger.warn({ err: error, agentId, filePath }, "Failed to remove agent record file");
387
+ }
388
+ }
389
+ await this.observer?.removed(filePath);
390
+ }));
391
+ this.pathById.delete(agentId);
392
+ this.pathsById.delete(agentId);
360
393
  }
361
394
  addIndexedPath(agentId, filePath) {
362
395
  const paths = this.pathsById.get(agentId) ?? new Set();
@@ -373,10 +406,53 @@ export class AgentStorage {
373
406
  this.pathsById.delete(agentId);
374
407
  }
375
408
  }
376
- async waitForPendingWrite(agentId) {
377
- await (this.pendingWrites.get(agentId) ?? Promise.resolve()).catch(() => undefined);
409
+ }
410
+ /** Every `*.json` directly under `baseDir` or one project directory below it. */
411
+ export async function listAgentRecordFiles(baseDir) {
412
+ let entries = [];
413
+ try {
414
+ entries = await fs.readdir(baseDir, { withFileTypes: true });
415
+ }
416
+ catch (error) {
417
+ if (error.code === "ENOENT") {
418
+ return [];
419
+ }
420
+ throw error;
421
+ }
422
+ const rootRecordPaths = entries
423
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
424
+ .map((entry) => path.join(baseDir, entry.name));
425
+ const projectDirs = entries
426
+ .filter((entry) => entry.isDirectory())
427
+ .map((entry) => path.join(baseDir, entry.name));
428
+ const projectFileLists = await Promise.all(projectDirs.map(async (projectDir) => {
429
+ try {
430
+ const files = await fs.readdir(projectDir, { withFileTypes: true });
431
+ return files
432
+ .filter((file) => file.isFile() && file.name.endsWith(".json"))
433
+ .map((file) => path.join(projectDir, file.name));
434
+ }
435
+ catch {
436
+ return [];
437
+ }
438
+ }));
439
+ return [...rootRecordPaths, ...projectFileLists.flat()];
440
+ }
441
+ export async function readAgentRecordFile(filePath, logger) {
442
+ try {
443
+ const content = await fs.readFile(filePath, "utf8");
444
+ const parsed = JSON.parse(content);
445
+ return parseStoredAgentRecord(parsed);
446
+ }
447
+ catch (error) {
448
+ logger.error({ err: error, filePath }, "Skipping invalid agent record");
449
+ return null;
378
450
  }
379
451
  }
452
+ export function agentRecordPath(baseDir, record) {
453
+ const projectDir = projectDirNameFromCwd(record.cwd);
454
+ return path.join(baseDir, projectDir, `${record.id}.json`);
455
+ }
380
456
  function projectDirNameFromCwd(cwd) {
381
457
  // path.win32.parse handles drive letters, UNC roots, and Unix roots on all platforms
382
458
  const { root } = path.win32.parse(cwd);
@@ -166,6 +166,14 @@ const INVISIBLE_STRIP_GATE_RE = /removed\d+invisiblecharacters?·(?:(reviewandpr
166
166
  const INVISIBLE_STRIP_SCAN_CHARS = 400;
167
167
  /** How long after an Enter to keep looking for the gate before trusting the reaction. */
168
168
  const INVISIBLE_STRIP_GATE_WAIT_MS = 1000;
169
+ /**
170
+ * Pause between seeing the gate and pressing Enter again. The CLI ignores an Enter that
171
+ * lands right after it paints the gate: live on 0.3.163 paseo pressed ~30ms later and
172
+ * nothing was sent for six attempts (session 977f4263, 2026-09-26). Measured against
173
+ * claude 2.1.283 in tmux: a second Enter 30ms after the gate submitted nothing; 100ms,
174
+ * 200ms, 300ms and 1.5s each submitted. 500ms is five times the smallest delay that worked.
175
+ */
176
+ const INVISIBLE_STRIP_SETTLE_MS = 500;
169
177
  /**
170
178
  * Code points the CLI deletes before it records a prompt, so the transcript `user` record
171
179
  * no longer contains them. Measured against claude 2.1.283 by pasting each candidate into a
@@ -948,6 +956,7 @@ export class PtyQuery {
948
956
  await pollUntil(() => this.invisibleStripGateAt > enterAt, INVISIBLE_STRIP_GATE_WAIT_MS, 100);
949
957
  if (this.invisibleStripGateAt > enterAt && !this.invisibleStripEmpty) {
950
958
  this.recordLine("--- CLI stripped invisible characters from the answer; pressing Enter again ---");
959
+ await delay(INVISIBLE_STRIP_SETTLE_MS);
951
960
  await this.transport.writeRaw?.("\r");
952
961
  }
953
962
  // Same reason as sendDialogAnswer: with more than one question the picker advances
@@ -1516,6 +1525,7 @@ export class PtyQuery {
1516
1525
  }
1517
1526
  this.recordLine("--- CLI stripped invisible characters and is waiting; pressing Enter again ---");
1518
1527
  this.logger.info({ sessionId: this.sessionId }, "PtyQuery: CLI held the prompt after stripping invisible characters; confirming");
1528
+ await delay(INVISIBLE_STRIP_SETTLE_MS);
1519
1529
  await this.transport.writeRaw?.("\r");
1520
1530
  return "pressed";
1521
1531
  }
@@ -148,6 +148,11 @@ export interface PaseoDaemon {
148
148
  browserToolsBroker: BrowserToolsBroker;
149
149
  start(): Promise<void>;
150
150
  stop(): Promise<void>;
151
+ /**
152
+ * First thing on SIGTERM/SIGINT: commit queued state writes and start every
153
+ * pending legacy file write, before the slower service teardown.
154
+ */
155
+ beginStateShutdown(): void;
151
156
  getListenTarget(): ListenTarget | null;
152
157
  }
153
158
  /**