gestalt-mobile 0.20.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/client/assets/index-Db9p-xm2.css +1 -0
  2. package/dist/client/assets/index-NHzJXg1w.js +29 -0
  3. package/dist/client/index.html +2 -2
  4. package/dist/server/server/app.js +2 -0
  5. package/dist/server/server/composition.contracts.js +142 -6
  6. package/dist/server/server/composition.js +39 -8
  7. package/dist/server/server/features/agent-activity/model.js +5 -0
  8. package/dist/server/server/features/agent-activity/registry.js +12 -1
  9. package/dist/server/server/features/autopilot/application/service.js +53 -1
  10. package/dist/server/server/features/files/application/ports.js +6 -0
  11. package/dist/server/server/features/files/copy-entry/endpoint.js +48 -0
  12. package/dist/server/server/features/files/delete-entry/endpoint.js +23 -0
  13. package/dist/server/server/features/files/domain/relative-directory.js +15 -0
  14. package/dist/server/server/features/files/domain/workspace-directory.js +6 -0
  15. package/dist/server/server/features/files/list-directory/endpoint.js +49 -0
  16. package/dist/server/server/features/files/move-entry/endpoint.js +4 -0
  17. package/dist/server/server/features/files/register-routes.js +19 -0
  18. package/dist/server/server/features/files/upload-file/endpoint.js +35 -0
  19. package/dist/server/server/features/sessions/list-sessions/endpoint.js +10 -6
  20. package/dist/server/server/features/sessions/model/relay-session.js +19 -0
  21. package/dist/server/server/features/sessions/promote-recent-thread/use-case.js +13 -3
  22. package/dist/server/server/features/sessions/register-routes.js +1 -0
  23. package/dist/server/server/features/sessions/start-session/use-case.js +3 -5
  24. package/dist/server/server/platform/auth/sqlite-authorization-store.js +98 -205
  25. package/dist/server/server/platform/auth/sqlite.js +54 -0
  26. package/dist/server/server/platform/codex/activity-facts.js +4 -0
  27. package/dist/server/server/platform/codex/recent-thread-lister.js +37 -11
  28. package/dist/server/server/platform/codex/rollout-execution-policy.js +68 -0
  29. package/dist/server/server/platform/codex/session-runtime.js +76 -17
  30. package/dist/server/server/platform/filesystem/filesystem-workspace-files.js +559 -0
  31. package/dist/server/server/platform/persistence/migrate.js +5 -1
  32. package/dist/server/server/platform/persistence/sqlite-session-repository.js +11 -3
  33. package/package.json +1 -1
  34. package/dist/client/assets/index-CnXbn39K.js +0 -29
  35. package/dist/client/assets/index-DYERmevO.css +0 -1
@@ -10,6 +10,7 @@ import { DatabaseSync } from 'node:sqlite';
10
10
  import { authorizedDeviceId, localOwnerId, passkeyCeremonyId, webAuthnCredentialId, } from '../../features/auth/domain/identifiers.js';
11
11
  import { deviceNickname } from '../../features/auth/domain/device-nickname.js';
12
12
  import { CeremonyCapacityError } from '../../features/auth/domain/errors.js';
13
+ import { AuthStatementCache, withImmediateTransaction } from './sqlite.js';
13
14
  const OWNER_ID = localOwnerId('local-owner');
14
15
  const SCHEMA_VERSION = 1;
15
16
  const SQLITE_BUSY_TIMEOUT_MS = 5_000;
@@ -24,6 +25,7 @@ export function authorizationDatabasePath(homeDirectory) {
24
25
  export class SqliteAuthorizationStore {
25
26
  path;
26
27
  db;
28
+ statements;
27
29
  relyingParty;
28
30
  constructor(homeDirectory, relyingParty) {
29
31
  this.path = authorizationDatabasePath(homeDirectory);
@@ -32,6 +34,7 @@ export class SqliteAuthorizationStore {
32
34
  chmodSync(parent, 0o700);
33
35
  this.relyingParty = relyingParty;
34
36
  this.db = new DatabaseSync(this.path);
37
+ this.statements = new AuthStatementCache(this.db);
35
38
  try {
36
39
  // journal_mode can take an exclusive lock. Install the bounded busy handler first so
37
40
  // simultaneous first opens wait for the winner instead of failing during startup.
@@ -62,8 +65,7 @@ export class SqliteAuthorizationStore {
62
65
  initializeSchemaAttempt() {
63
66
  this.db.exec('PRAGMA journal_mode = WAL;');
64
67
  this.db.exec('PRAGMA foreign_keys = ON;');
65
- this.db.exec('BEGIN IMMEDIATE');
66
- try {
68
+ withImmediateTransaction(this.db, () => {
67
69
  this.db
68
70
  .exec(`CREATE TABLE IF NOT EXISTS auth_settings (singleton INTEGER PRIMARY KEY CHECK (singleton = 1), schema_version INTEGER NOT NULL, rp_id TEXT NOT NULL, user_handle BLOB NOT NULL);
69
71
  CREATE TABLE IF NOT EXISTS auth_devices (id TEXT PRIMARY KEY, credential_id TEXT NOT NULL UNIQUE, public_key BLOB NOT NULL, counter INTEGER NOT NULL, transports_json TEXT NOT NULL, device_type TEXT NOT NULL, backed_up INTEGER NOT NULL CHECK(backed_up IN (0,1)), nickname TEXT NOT NULL, created_at TEXT NOT NULL, last_used_at TEXT, version INTEGER NOT NULL DEFAULT 0);
@@ -85,186 +87,129 @@ CREATE TABLE IF NOT EXISTS auth_sessions (token_hash TEXT PRIMARY KEY, device_id
85
87
  .get();
86
88
  if (settings && settings.rp_id !== this.relyingParty.rpId && this.deviceCount() > 0)
87
89
  throw new Error('WebAuthn RP ID hostname changed while credentials exist');
88
- this.db.exec('COMMIT');
89
- }
90
- catch (error) {
91
- try {
92
- this.db.exec('ROLLBACK');
93
- }
94
- catch { }
95
- throw error;
96
- }
90
+ });
97
91
  }
98
92
  initializeOwner(userHandle) {
99
93
  if (userHandle.length === 0)
100
94
  throw new Error('Local owner handle must not be empty');
101
- this.db.exec('BEGIN IMMEDIATE');
102
- try {
103
- const current = this.db
95
+ return withImmediateTransaction(this.db, () => {
96
+ const current = this.statements
104
97
  .prepare('SELECT user_handle, rp_id FROM auth_settings WHERE singleton = 1')
105
98
  .get();
106
99
  if (!current)
107
- this.db
100
+ this.statements
108
101
  .prepare('INSERT INTO auth_settings (singleton, schema_version, rp_id, user_handle) VALUES (1, ?, ?, ?)')
109
102
  .run(SCHEMA_VERSION, this.relyingParty.rpId, userHandle);
110
103
  else if (current.rp_id !== this.relyingParty.rpId && this.deviceCount() > 0)
111
104
  throw new Error('WebAuthn RP ID hostname changed while credentials exist');
112
105
  else if (current.rp_id !== this.relyingParty.rpId)
113
- this.db
106
+ this.statements
114
107
  .prepare('UPDATE auth_settings SET rp_id = ? WHERE singleton = 1')
115
108
  .run(this.relyingParty.rpId);
116
- this.db.exec('COMMIT');
117
109
  return { id: OWNER_ID, userHandle: new Uint8Array(current?.user_handle ?? userHandle) };
118
- }
119
- catch (error) {
120
- this.db.exec('ROLLBACK');
121
- throw error;
122
- }
110
+ });
123
111
  }
124
112
  readOwner() {
125
- const row = this.db
113
+ const row = this.statements
126
114
  .prepare('SELECT user_handle FROM auth_settings WHERE singleton = 1')
127
115
  .get();
128
116
  return row ? { id: OWNER_ID, userHandle: new Uint8Array(row.user_handle) } : null;
129
117
  }
130
118
  listAuthorizedDevices() {
131
- return this.db.prepare('SELECT * FROM auth_devices ORDER BY created_at, id').all().map(toDevice);
119
+ return this.statements
120
+ .prepare('SELECT * FROM auth_devices ORDER BY created_at, id')
121
+ .all().map(toDevice);
132
122
  }
133
123
  claimFirstDevice(owner, device) {
134
124
  if (owner.id !== OWNER_ID || !this.readOwner())
135
125
  throw new Error('Local owner is not initialized');
136
- this.db.exec('BEGIN IMMEDIATE');
137
- try {
138
- if (this.deviceCount()) {
139
- this.db.exec('COMMIT');
126
+ return withImmediateTransaction(this.db, () => {
127
+ if (this.deviceCount())
140
128
  return 'alreadyClaimed';
141
- }
142
129
  this.insertDevice(device);
143
- this.db.exec('COMMIT');
144
130
  return 'claimed';
145
- }
146
- catch (error) {
147
- this.db.exec('ROLLBACK');
148
- throw error;
149
- }
131
+ });
150
132
  }
151
133
  authorizeDevice(device) {
152
- this.db.exec('BEGIN IMMEDIATE');
153
134
  try {
154
- if (!this.readOwner() || !this.deviceCount()) {
155
- this.db.exec('COMMIT');
156
- return 'notAuthorized';
157
- }
158
- try {
135
+ return withImmediateTransaction(this.db, () => {
136
+ if (!this.readOwner() || !this.deviceCount())
137
+ return 'notAuthorized';
159
138
  this.insertDevice(device);
160
- }
161
- catch (error) {
162
- if (String(error).includes('UNIQUE constraint failed: auth_devices.credential_id')) {
163
- this.db.exec('ROLLBACK');
164
- return 'duplicateCredential';
165
- }
166
- throw error;
167
- }
168
- this.db.exec('COMMIT');
169
- return 'authorized';
139
+ return 'authorized';
140
+ });
170
141
  }
171
142
  catch (error) {
172
- try {
173
- this.db.exec('ROLLBACK');
174
- }
175
- catch { }
143
+ if (String(error).includes('UNIQUE constraint failed: auth_devices.credential_id'))
144
+ return 'duplicateCredential';
176
145
  throw error;
177
146
  }
178
147
  }
179
148
  findDeviceByCredentialId(id) {
180
- const row = this.db.prepare('SELECT * FROM auth_devices WHERE credential_id = ?').get(id);
149
+ const row = this.statements
150
+ .prepare('SELECT * FROM auth_devices WHERE credential_id = ?')
151
+ .get(id);
181
152
  return row ? toDevice(row) : null;
182
153
  }
183
154
  findDevice(id) {
184
- const row = this.db.prepare('SELECT * FROM auth_devices WHERE id = ?').get(id);
155
+ const row = this.statements.prepare('SELECT * FROM auth_devices WHERE id = ?').get(id);
185
156
  return row ? toDevice(row) : null;
186
157
  }
187
158
  renameDevice(id, expectedVersion, nickname) {
188
- this.db.exec('BEGIN IMMEDIATE');
189
- try {
190
- const result = this.db
159
+ return withImmediateTransaction(this.db, () => {
160
+ const result = this.statements
191
161
  .prepare('UPDATE auth_devices SET nickname = ?, version = version + 1 WHERE id = ? AND version = ?')
192
162
  .run(nickname, id, expectedVersion);
193
- if (result.changes) {
194
- this.db.exec('COMMIT');
163
+ if (result.changes)
195
164
  return 'renamed';
196
- }
197
165
  const outcome = this.findDevice(id) ? 'stale' : 'notFound';
198
- this.db.exec('COMMIT');
199
166
  return outcome;
200
- }
201
- catch (error) {
202
- this.db.exec('ROLLBACK');
203
- throw error;
204
- }
167
+ });
205
168
  }
206
169
  advanceCounter(id, expected, expectedVersion, next, usedAt) {
207
- const zeroSynced = this.db
170
+ const zeroSynced = this.statements
208
171
  .prepare("UPDATE auth_devices SET last_used_at = ?, version = version + 1 WHERE id = ? AND counter = ? AND version = ? AND ? = 0 AND device_type = 'multiDevice'")
209
172
  .run(usedAt, id, expected, expectedVersion, next).changes === 1;
210
173
  return (zeroSynced ||
211
- this.db
174
+ this.statements
212
175
  .prepare('UPDATE auth_devices SET counter = ?, last_used_at = ?, version = version + 1 WHERE id = ? AND counter = ? AND version = ? AND ? > counter')
213
176
  .run(next, usedAt, id, expected, expectedVersion, next).changes === 1);
214
177
  }
215
178
  revokeDevice(id, revokedAt) {
216
- this.db.exec('BEGIN IMMEDIATE');
217
- try {
218
- if (!this.findDevice(id)) {
219
- this.db.exec('COMMIT');
179
+ return withImmediateTransaction(this.db, () => {
180
+ if (!this.findDevice(id))
220
181
  return 'notFound';
221
- }
222
- if (this.deviceCount() < 2) {
223
- this.db.exec('COMMIT');
182
+ if (this.deviceCount() < 2)
224
183
  return 'finalDevice';
225
- }
226
- this.db
184
+ this.statements
227
185
  .prepare('UPDATE auth_sessions SET revoked_at = ? WHERE device_id = ? AND revoked_at IS NULL')
228
186
  .run(revokedAt, id);
229
- this.db.prepare('DELETE FROM auth_devices WHERE id = ?').run(id);
230
- this.db.exec('COMMIT');
187
+ this.statements.prepare('DELETE FROM auth_devices WHERE id = ?').run(id);
231
188
  return 'revoked';
232
- }
233
- catch (error) {
234
- this.db.exec('ROLLBACK');
235
- throw error;
236
- }
189
+ });
237
190
  }
238
191
  saveCeremony(token, ceremony, now) {
239
- this.db.exec('BEGIN IMMEDIATE');
240
- try {
192
+ withImmediateTransaction(this.db, () => {
241
193
  // Admission and cleanup share one immediate transaction, so separate
242
194
  // processes cannot both observe the same remaining slot.
243
- this.db.prepare('DELETE FROM auth_ceremonies WHERE consumed_at IS NOT NULL').run();
195
+ this.statements.prepare('DELETE FROM auth_ceremonies WHERE consumed_at IS NOT NULL').run();
244
196
  if (now)
245
- this.db.prepare('DELETE FROM auth_ceremonies WHERE expires_at <= ?').run(now);
246
- const active = this.db
197
+ this.statements.prepare('DELETE FROM auth_ceremonies WHERE expires_at <= ?').run(now);
198
+ const active = this.statements
247
199
  .prepare('SELECT count(*) AS count FROM auth_ceremonies WHERE consumed_at IS NULL')
248
200
  .get();
249
201
  if (active.count >= 64)
250
202
  throw new CeremonyCapacityError('Ceremony capacity reached');
251
- this.db
203
+ this.statements
252
204
  .prepare('INSERT INTO auth_ceremonies (token_hash, purpose, challenge, expected_origin, rp_id, expires_at, ticket_hash) VALUES (?, ?, ?, ?, ?, ?, ?)')
253
205
  .run(hash(token), ceremony.purpose, ceremony.challenge, ceremony.expectedOrigin, ceremony.rpId, ceremony.expiresAt, ceremony.enrollmentTicket ? hash(ceremony.enrollmentTicket) : null);
254
- this.db.exec('COMMIT');
255
- }
256
- catch (error) {
257
- this.db.exec('ROLLBACK');
258
- throw error;
259
- }
206
+ });
260
207
  }
261
208
  consumeCeremony(token, now) {
262
- this.db.exec('BEGIN IMMEDIATE');
263
- try {
264
- const row = this.db
209
+ return withImmediateTransaction(this.db, () => {
210
+ const row = this.statements
265
211
  .prepare('UPDATE auth_ceremonies SET consumed_at = ? WHERE token_hash = ? AND consumed_at IS NULL AND expires_at > ? RETURNING purpose, challenge, expected_origin, rp_id, expires_at')
266
212
  .get(now, hash(token), now);
267
- this.db.exec('COMMIT');
268
213
  return row
269
214
  ? {
270
215
  id: passkeyCeremonyId(token),
@@ -275,14 +220,10 @@ CREATE TABLE IF NOT EXISTS auth_sessions (token_hash TEXT PRIMARY KEY, device_id
275
220
  expiresAt: row.expires_at,
276
221
  }
277
222
  : null;
278
- }
279
- catch (error) {
280
- this.db.exec('ROLLBACK');
281
- throw error;
282
- }
223
+ });
283
224
  }
284
225
  readCeremony(token, now) {
285
- const row = this.db
226
+ const row = this.statements
286
227
  .prepare('SELECT purpose, challenge, expected_origin, rp_id, expires_at FROM auth_ceremonies WHERE token_hash = ? AND consumed_at IS NULL AND expires_at > ?')
287
228
  .get(hash(token), now);
288
229
  return row
@@ -297,29 +238,23 @@ CREATE TABLE IF NOT EXISTS auth_sessions (token_hash TEXT PRIMARY KEY, device_id
297
238
  : null;
298
239
  }
299
240
  saveTicket(token, ticket) {
300
- this.db
241
+ this.statements
301
242
  .prepare('INSERT INTO auth_tickets (token_hash, expires_at, consumed_at) VALUES (?, ?, NULL)')
302
243
  .run(hash(token), ticket.expiresAt);
303
244
  }
304
245
  issueEnrollmentTicket(token, creatorSession, expiresAt) {
305
- this.db.exec('BEGIN IMMEDIATE');
306
- try {
246
+ withImmediateTransaction(this.db, () => {
307
247
  const creator = hash(creatorSession);
308
- this.db
248
+ this.statements
309
249
  .prepare('DELETE FROM auth_tickets WHERE creator_session_hash = ? AND consumed_at IS NULL')
310
250
  .run(creator);
311
- this.db
251
+ this.statements
312
252
  .prepare('INSERT INTO auth_tickets (token_hash, expires_at, consumed_at, creator_session_hash) VALUES (?, ?, NULL, ?)')
313
253
  .run(hash(token), expiresAt, creator);
314
- this.db.exec('COMMIT');
315
- }
316
- catch (error) {
317
- this.db.exec('ROLLBACK');
318
- throw error;
319
- }
254
+ });
320
255
  }
321
256
  enrollmentTicketStatus(creatorSession, now) {
322
- const row = this.db
257
+ const row = this.statements
323
258
  .prepare('SELECT expires_at, consumed_at FROM auth_tickets WHERE creator_session_hash = ? ORDER BY rowid DESC LIMIT 1')
324
259
  .get(hash(creatorSession));
325
260
  if (!row)
@@ -329,126 +264,90 @@ CREATE TABLE IF NOT EXISTS auth_sessions (token_hash TEXT PRIMARY KEY, device_id
329
264
  return row.expires_at > now ? 'pending' : 'expired';
330
265
  }
331
266
  cancelEnrollmentTicket(creatorSession, now) {
332
- this.db.exec('BEGIN IMMEDIATE');
333
- try {
334
- const result = this.db
267
+ return withImmediateTransaction(this.db, () => {
268
+ const result = this.statements
335
269
  .prepare('DELETE FROM auth_tickets WHERE rowid = (SELECT rowid FROM auth_tickets WHERE creator_session_hash = ? AND consumed_at IS NULL AND expires_at > ? ORDER BY rowid DESC LIMIT 1)')
336
270
  .run(hash(creatorSession), now);
337
- this.db.exec('COMMIT');
338
271
  return result.changes === 1;
339
- }
340
- catch (error) {
341
- this.db.exec('ROLLBACK');
342
- throw error;
343
- }
272
+ });
344
273
  }
345
274
  consumeTicket(token, now) {
346
275
  return this.consume('auth_tickets', token, now);
347
276
  }
348
277
  ticketAvailable(token, now) {
349
- return Boolean(this.db
278
+ return Boolean(this.statements
350
279
  .prepare('SELECT 1 FROM auth_tickets t WHERE t.token_hash = ? AND t.consumed_at IS NULL AND t.expires_at > ? AND (t.creator_session_hash IS NULL OR EXISTS (SELECT 1 FROM auth_sessions s WHERE s.token_hash = t.creator_session_hash AND s.revoked_at IS NULL AND s.expires_at > ?))')
351
280
  .get(hash(token), now, now));
352
281
  }
353
282
  completeRegistration(input) {
354
283
  if (input.session.deviceId !== input.device.id)
355
284
  throw new Error('AUTHORIZATION_SESSION_DEVICE_MISMATCH');
356
- this.db.exec('BEGIN IMMEDIATE');
357
285
  try {
358
- const ceremony = this.db
359
- .prepare('SELECT ticket_hash FROM auth_ceremonies WHERE token_hash = ? AND purpose = ? AND consumed_at IS NULL AND expires_at > ?')
360
- .get(hash(input.ceremony), 'registration', input.now);
361
- if (!ceremony) {
362
- this.db.exec('COMMIT');
363
- return 'ceremonyUnavailable';
364
- }
365
- if (ceremony.ticket_hash) {
366
- const result = this.db
367
- .prepare('UPDATE auth_tickets SET consumed_at = ? WHERE token_hash = ? AND consumed_at IS NULL AND expires_at > ? AND (creator_session_hash IS NULL OR EXISTS (SELECT 1 FROM auth_sessions WHERE token_hash = auth_tickets.creator_session_hash AND revoked_at IS NULL AND expires_at > ?))')
368
- .run(input.now, ceremony.ticket_hash, input.now, input.now);
369
- if (result.changes !== 1) {
370
- this.db.exec('COMMIT');
371
- return 'ticketUnavailable';
286
+ return withImmediateTransaction(this.db, () => {
287
+ const ceremony = this.statements
288
+ .prepare('SELECT ticket_hash FROM auth_ceremonies WHERE token_hash = ? AND purpose = ? AND consumed_at IS NULL AND expires_at > ?')
289
+ .get(hash(input.ceremony), 'registration', input.now);
290
+ if (!ceremony)
291
+ return 'ceremonyUnavailable';
292
+ if (ceremony.ticket_hash) {
293
+ const result = this.statements
294
+ .prepare('UPDATE auth_tickets SET consumed_at = ? WHERE token_hash = ? AND consumed_at IS NULL AND expires_at > ? AND (creator_session_hash IS NULL OR EXISTS (SELECT 1 FROM auth_sessions WHERE token_hash = auth_tickets.creator_session_hash AND revoked_at IS NULL AND expires_at > ?))')
295
+ .run(input.now, ceremony.ticket_hash, input.now, input.now);
296
+ if (result.changes !== 1)
297
+ return 'ticketUnavailable';
372
298
  }
373
- }
374
- else if (this.deviceCount() !== 0) {
375
- this.db.exec('COMMIT');
376
- return 'bootstrapAlreadyClaimed';
377
- }
378
- try {
299
+ else if (this.deviceCount() !== 0)
300
+ return 'bootstrapAlreadyClaimed';
379
301
  this.insertDevice(input.device);
380
- }
381
- catch (error) {
382
- if (String(error).includes('UNIQUE constraint failed: auth_devices.credential_id')) {
383
- this.db.exec('ROLLBACK');
384
- return 'duplicateCredential';
385
- }
386
- throw error;
387
- }
388
- this.db
389
- .prepare('INSERT INTO auth_sessions (token_hash, device_id, expires_at) VALUES (?, ?, ?)')
390
- .run(hash(input.session.id), input.session.deviceId, input.session.expiresAt);
391
- this.db
392
- .prepare('UPDATE auth_ceremonies SET consumed_at = ? WHERE token_hash = ?')
393
- .run(input.now, hash(input.ceremony));
394
- this.db.exec('COMMIT');
395
- return 'registered';
302
+ this.statements
303
+ .prepare('INSERT INTO auth_sessions (token_hash, device_id, expires_at) VALUES (?, ?, ?)')
304
+ .run(hash(input.session.id), input.session.deviceId, input.session.expiresAt);
305
+ this.statements
306
+ .prepare('UPDATE auth_ceremonies SET consumed_at = ? WHERE token_hash = ?')
307
+ .run(input.now, hash(input.ceremony));
308
+ return 'registered';
309
+ });
396
310
  }
397
311
  catch (error) {
398
- try {
399
- this.db.exec('ROLLBACK');
400
- }
401
- catch { }
312
+ if (String(error).includes('UNIQUE constraint failed: auth_devices.credential_id'))
313
+ return 'duplicateCredential';
402
314
  throw error;
403
315
  }
404
316
  }
405
317
  completeAuthentication(input) {
406
318
  if (input.session.deviceId !== input.device.id)
407
319
  throw new Error('AUTHORIZATION_SESSION_DEVICE_MISMATCH');
408
- this.db.exec('BEGIN IMMEDIATE');
409
- try {
410
- const ceremony = this.db
320
+ return withImmediateTransaction(this.db, () => {
321
+ const ceremony = this.statements
411
322
  .prepare('SELECT 1 FROM auth_ceremonies WHERE token_hash = ? AND purpose = ? AND consumed_at IS NULL AND expires_at > ?')
412
323
  .get(hash(input.ceremony), 'authentication', input.now);
413
- if (!ceremony) {
414
- this.db.exec('COMMIT');
324
+ if (!ceremony)
415
325
  return false;
416
- }
417
326
  const updated = this.advanceCounter(input.device.id, input.device.counter, input.device.version ?? 0, input.nextCounter, input.now);
418
- if (!updated) {
419
- this.db.exec('COMMIT');
327
+ if (!updated)
420
328
  return false;
421
- }
422
- this.db
329
+ this.statements
423
330
  .prepare('INSERT INTO auth_sessions (token_hash, device_id, expires_at) VALUES (?, ?, ?)')
424
331
  .run(hash(input.session.id), input.session.deviceId, input.session.expiresAt);
425
- this.db
332
+ this.statements
426
333
  .prepare('UPDATE auth_ceremonies SET consumed_at = ? WHERE token_hash = ?')
427
334
  .run(input.now, hash(input.ceremony));
428
- this.db.exec('COMMIT');
429
335
  return true;
430
- }
431
- catch (error) {
432
- try {
433
- this.db.exec('ROLLBACK');
434
- }
435
- catch { }
436
- throw error;
437
- }
336
+ });
438
337
  }
439
338
  saveSession(token, session) {
440
- this.db
339
+ this.statements
441
340
  .prepare('INSERT INTO auth_sessions (token_hash, device_id, expires_at) VALUES (?, ?, ?)')
442
341
  .run(hash(token), session.deviceId, session.expiresAt);
443
342
  }
444
343
  sessionDevice(token, now) {
445
- const row = this.db
344
+ const row = this.statements
446
345
  .prepare('SELECT device_id FROM auth_sessions WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > ?')
447
346
  .get(hash(token), now);
448
347
  return row?.device_id ? authorizedDeviceId(row.device_id) : null;
449
348
  }
450
349
  revokeSession(token, now) {
451
- return (this.db
350
+ return (this.statements
452
351
  .prepare('UPDATE auth_sessions SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL')
453
352
  .run(now, hash(token)).changes === 1);
454
353
  }
@@ -456,26 +355,20 @@ CREATE TABLE IF NOT EXISTS auth_sessions (token_hash TEXT PRIMARY KEY, device_id
456
355
  this.db.close();
457
356
  }
458
357
  insertDevice(device) {
459
- this.db
358
+ this.statements
460
359
  .prepare('INSERT INTO auth_devices (id, credential_id, public_key, counter, transports_json, device_type, backed_up, nickname, created_at, last_used_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
461
360
  .run(device.id, device.credentialId, device.publicKey, device.counter, JSON.stringify(device.transports), device.deviceType, device.backedUp ? 1 : 0, device.nickname, device.createdAt, device.lastUsedAt ?? null, device.version ?? 0);
462
361
  }
463
362
  consume(table, token, now) {
464
- this.db.exec('BEGIN IMMEDIATE');
465
- try {
363
+ return withImmediateTransaction(this.db, () => {
466
364
  const changes = this.db
467
365
  .prepare(`UPDATE ${table} SET consumed_at = ? WHERE token_hash = ? AND consumed_at IS NULL AND expires_at > ?`)
468
366
  .run(now, hash(token), now).changes;
469
- this.db.exec('COMMIT');
470
367
  return changes === 1;
471
- }
472
- catch (error) {
473
- this.db.exec('ROLLBACK');
474
- throw error;
475
- }
368
+ });
476
369
  }
477
370
  deviceCount() {
478
- return this.db.prepare('SELECT COUNT(*) AS count FROM auth_devices').get().count;
371
+ return this.statements.prepare('SELECT COUNT(*) AS count FROM auth_devices').get().count;
479
372
  }
480
373
  }
481
374
  function toDevice(row) {
@@ -0,0 +1,54 @@
1
+ /*
2
+ * Copyright (C) 2026 Dyne.org foundation
3
+ * Designed by Denis Roio <jaromil@dyne.org>
4
+ * SPDX-License-Identifier: AGPL-3.0-or-later
5
+ */
6
+ export function withImmediateTransaction(db, action) {
7
+ db.exec('BEGIN IMMEDIATE');
8
+ try {
9
+ const result = action();
10
+ db.exec('COMMIT');
11
+ return result;
12
+ }
13
+ catch (error) {
14
+ if (db.isTransaction) {
15
+ try {
16
+ db.exec('ROLLBACK');
17
+ }
18
+ catch { }
19
+ }
20
+ throw error;
21
+ }
22
+ }
23
+ /**
24
+ * Auth has existing positional-parameter SQL, while SQLTagStore only accepts
25
+ * tagged-template parameters. This small LRU keeps that SQL unchanged and only
26
+ * receives repeated static statements from the authorization store.
27
+ */
28
+ export class AuthStatementCache {
29
+ db;
30
+ maxSize;
31
+ statements = new Map();
32
+ constructor(db, maxSize = 32) {
33
+ this.db = db;
34
+ this.maxSize = maxSize;
35
+ if (!Number.isInteger(maxSize) || maxSize < 1)
36
+ throw new RangeError('Statement cache size must be a positive integer');
37
+ }
38
+ prepare(sql) {
39
+ const cached = this.statements.get(sql);
40
+ if (cached) {
41
+ this.statements.delete(sql);
42
+ this.statements.set(sql, cached);
43
+ return cached;
44
+ }
45
+ const statement = this.db.prepare(sql);
46
+ if (this.statements.size === this.maxSize) {
47
+ const oldest = this.statements.keys().next().value;
48
+ if (oldest !== undefined)
49
+ this.statements.delete(oldest);
50
+ }
51
+ this.statements.set(sql, statement);
52
+ return statement;
53
+ }
54
+ }
@@ -95,6 +95,9 @@ export function decodeAgentActivityFacts(sessionId, occurredAt, input) {
95
95
  : typeof item.status === 'string' && item.status.length <= 64
96
96
  ? item.status
97
97
  : undefined;
98
+ const model = action === 'spawn_agent' && typeof item.model === 'string' && item.model.length <= 256
99
+ ? item.model
100
+ : undefined;
98
101
  return {
99
102
  sessionId,
100
103
  occurredAt,
@@ -105,6 +108,7 @@ export function decodeAgentActivityFacts(sessionId, occurredAt, input) {
105
108
  ? { childStatus: currentStatus ?? legacyStatus }
106
109
  : {}),
107
110
  ...(action ? { collaborationAction: action } : {}),
111
+ ...(model ? { childModel: model } : {}),
108
112
  };
109
113
  };
110
114
  return children.length > 0 ? children.map((child) => fact(child)) : [fact()];
@@ -3,18 +3,36 @@
3
3
  * Designed by Denis Roio <jaromil@dyne.org>
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
+ import { readRolloutExecutionPolicy } from './rollout-execution-policy.js';
6
7
  export function createRecentThreadLister(deps) {
8
+ const rolloutPaths = new Map();
9
+ const list = async () => {
10
+ const profiles = (await deps.profiles.list()).filter((profile) => profile.state === 'ok');
11
+ const listed = await Promise.all(profiles.map(async ({ name }) => listProfileThreads(deps.launch, name, deps.root)));
12
+ const byId = new Map();
13
+ for (const thread of listed.flat()) {
14
+ const known = byId.get(thread.id);
15
+ if (!known || (thread.recencyAt ?? 0) > (known.recencyAt ?? 0))
16
+ byId.set(thread.id, thread);
17
+ }
18
+ rolloutPaths.clear();
19
+ return [...byId.values()]
20
+ .sort((a, b) => (b.recencyAt ?? 0) - (a.recencyAt ?? 0))
21
+ .map(({ rolloutPath, ...thread }) => {
22
+ if (rolloutPath)
23
+ rolloutPaths.set(thread.id, rolloutPath);
24
+ return thread;
25
+ });
26
+ };
7
27
  return {
8
- async list() {
9
- const profiles = (await deps.profiles.list()).filter((profile) => profile.state === 'ok');
10
- const listed = await Promise.all(profiles.map(async ({ name }) => listProfileThreads(deps.launch, name, deps.root)));
11
- const byId = new Map();
12
- for (const thread of listed.flat()) {
13
- const known = byId.get(thread.id);
14
- if (!known || (thread.recencyAt ?? 0) > (known.recencyAt ?? 0))
15
- byId.set(thread.id, thread);
16
- }
17
- return [...byId.values()].sort((a, b) => (b.recencyAt ?? 0) - (a.recencyAt ?? 0));
28
+ list,
29
+ executionPolicy: async (threadId) => {
30
+ if (!rolloutPaths.has(threadId))
31
+ await list();
32
+ const rolloutPath = rolloutPaths.get(threadId);
33
+ return rolloutPath
34
+ ? (deps.readExecutionPolicy ?? readRolloutExecutionPolicy)(rolloutPath)
35
+ : undefined;
18
36
  },
19
37
  };
20
38
  }
@@ -34,7 +52,15 @@ async function listProfileThreads(launch, profile, cwd) {
34
52
  return (result.data ?? []).flatMap((thread) => {
35
53
  if (typeof thread.id !== 'string' || typeof thread.cwd !== 'string')
36
54
  return [];
37
- return [{ id: thread.id, cwd: thread.cwd, profile, recencyAt: toRecency(thread.recencyAt) }];
55
+ return [
56
+ {
57
+ id: thread.id,
58
+ cwd: thread.cwd,
59
+ profile,
60
+ recencyAt: toRecency(thread.recencyAt),
61
+ ...(typeof thread.path === 'string' ? { rolloutPath: thread.path } : {}),
62
+ },
63
+ ];
38
64
  });
39
65
  }
40
66
  catch {