@ours.network/fleet 1.2.0-nightly.19 → 1.2.0-nightly.20

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 (99) hide show
  1. package/README.md +10 -1
  2. package/dist/agent-ours/correspondence.d.ts +10 -0
  3. package/dist/agent-ours/correspondence.js +1 -0
  4. package/dist/agent-ours/runtime.d.ts +6 -0
  5. package/dist/agent-ours/runtime.js +36 -0
  6. package/dist/agent-ours/service.js +2 -1
  7. package/dist/application/conversation-history.d.ts +6 -0
  8. package/dist/application/conversation-history.js +65 -0
  9. package/dist/application/fleet-query-service.d.ts +1 -1
  10. package/dist/application/fleet-query-service.js +2 -2
  11. package/dist/application/preset-provenance.d.ts +19 -0
  12. package/dist/application/preset-provenance.js +59 -0
  13. package/dist/application/room-layout-definitions.d.ts +45 -0
  14. package/dist/application/room-layout-definitions.js +130 -0
  15. package/dist/application/session-control.d.ts +8 -0
  16. package/dist/application/session-control.js +21 -3
  17. package/dist/application/task-layouts.d.ts +89 -0
  18. package/dist/application/task-layouts.js +248 -0
  19. package/dist/application/task-room-service.d.ts +82 -0
  20. package/dist/application/task-room-service.js +197 -9
  21. package/dist/build-info.json +4 -4
  22. package/dist/cli.js +2 -0
  23. package/dist/config.d.ts +3 -0
  24. package/dist/config.js +7 -0
  25. package/dist/init-wizard.js +4 -2
  26. package/dist/rooms-tasks/add-member.d.ts +48 -0
  27. package/dist/rooms-tasks/add-member.js +83 -0
  28. package/dist/rooms-tasks/cli.js +20 -0
  29. package/dist/rooms-tasks/external-worker.d.ts +1 -1
  30. package/dist/rooms-tasks/external-worker.js +3 -3
  31. package/dist/rooms-tasks/layout-cli.d.ts +2 -0
  32. package/dist/rooms-tasks/layout-cli.js +81 -0
  33. package/dist/rooms-tasks/layout-config.d.ts +11 -0
  34. package/dist/rooms-tasks/layout-config.js +89 -0
  35. package/dist/rooms-tasks/layout-control.d.ts +21 -0
  36. package/dist/rooms-tasks/layout-control.js +52 -0
  37. package/dist/rooms-tasks/layout-service.d.ts +39 -0
  38. package/dist/rooms-tasks/layout-service.js +173 -0
  39. package/dist/rooms-tasks/layout.d.ts +102 -0
  40. package/dist/rooms-tasks/layout.js +227 -0
  41. package/dist/rooms-tasks/live-readiness.js +2 -1
  42. package/dist/rooms-tasks/provision.d.ts +3 -0
  43. package/dist/rooms-tasks/provision.js +44 -22
  44. package/dist/rooms-tasks/room-state.d.ts +1 -1
  45. package/dist/rooms-tasks/room-state.js +3 -1
  46. package/dist/rooms-tasks/task-state.d.ts +19 -0
  47. package/dist/rooms-tasks/task-state.js +58 -6
  48. package/dist/rooms-tasks/types.d.ts +14 -0
  49. package/dist/runner.d.ts +7 -2
  50. package/dist/runner.js +52 -4
  51. package/dist/session/acp.d.ts +3 -0
  52. package/dist/session/acp.js +7 -0
  53. package/dist/session/arbiter.d.ts +3 -0
  54. package/dist/session/arbiter.js +20 -2
  55. package/dist/session/codex-app-server.d.ts +3 -0
  56. package/dist/session/codex-app-server.js +25 -1
  57. package/dist/session/control.d.ts +10 -1
  58. package/dist/session/control.js +36 -2
  59. package/dist/session/conversation-normalizer.d.ts +2 -1
  60. package/dist/session/conversation-normalizer.js +1 -1
  61. package/dist/session/conversation-types.d.ts +5 -3
  62. package/dist/session/task-notice.d.ts +14 -0
  63. package/dist/session/task-notice.js +13 -0
  64. package/dist/session/types.d.ts +3 -0
  65. package/dist/socket-path.d.ts +2 -0
  66. package/dist/socket-path.js +22 -0
  67. package/dist/temp-idle.d.ts +17 -0
  68. package/dist/temp-idle.js +42 -0
  69. package/dist/temp-lifecycle.d.ts +1 -1
  70. package/dist/temp-lifecycle.js +1 -1
  71. package/dist/web/agent-attachments.d.ts +29 -0
  72. package/dist/web/agent-attachments.js +162 -0
  73. package/dist/web/auth.d.ts +4 -1
  74. package/dist/web/auth.js +23 -2
  75. package/dist/web/control.js +2 -1
  76. package/dist/web/fleet-config-service.d.ts +4 -1
  77. package/dist/web/fleet-config-service.js +46 -15
  78. package/dist/web/prefix-gateway.d.ts +20 -0
  79. package/dist/web/prefix-gateway.js +150 -0
  80. package/dist/web/runtime.d.ts +4 -0
  81. package/dist/web/runtime.js +19 -14
  82. package/dist/web/server.d.ts +5 -0
  83. package/dist/web/server.js +275 -24
  84. package/docs/room-layouts.md +92 -0
  85. package/examples/room-layouts/fresh.yaml +27 -0
  86. package/examples/room-layouts/shared.yaml +19 -0
  87. package/package.json +14 -13
  88. package/presets/fleet/room_layouts/engineering.yaml +25 -0
  89. package/presets/fleet/room_layouts/pair.yaml +24 -0
  90. package/presets/fleet/room_layouts/single.yaml +17 -0
  91. package/presets/fleet/room_layouts/team.yaml +30 -0
  92. package/dist/web-app/assets/index-59GF-gsZ.css +0 -1
  93. package/dist/web-app/assets/index-C0h3ALvs.js +0 -10
  94. package/dist/web-app/icons/ours-fleet-maskable.svg +0 -4
  95. package/dist/web-app/icons/ours-fleet.svg +0 -4
  96. package/dist/web-app/index.html +0 -17
  97. package/dist/web-app/manifest.webmanifest +0 -15
  98. package/dist/web-app/offline.html +0 -18
  99. package/dist/web-app/sw.js +0 -51
@@ -1,3 +1,6 @@
1
+ import { storeAgentAttachment, attachmentPrompt, MAX_ATTACHMENT_BODY, readAgentAttachment, prepareAttachmentPresentation, presentAttachmentEvent } from './agent-attachments.js';
2
+ import { readChatIdle } from '../temp-idle.js';
3
+ import { agentDir } from '../paths.js';
1
4
  import { SupervisorOursTools } from '../application/supervisor-ours-tools.js';
2
5
  import { existsSync } from 'node:fs';
3
6
  import { dirname, join } from 'node:path';
@@ -34,15 +37,15 @@ export async function buildWebServer(services, boundary, options = {}) {
34
37
  app.addHook('onRequest', async (request, reply) => {
35
38
  reply.header('X-Content-Type-Options', 'nosniff');
36
39
  reply.header('Referrer-Policy', 'no-referrer');
37
- reply.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()');
40
+ reply.header('Permissions-Policy', 'camera=(), microphone=(self), geolocation=(), payment=()');
38
41
  reply.header('X-Frame-Options', 'DENY');
39
- reply.header('Content-Security-Policy', `default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; ` +
42
+ reply.header('Content-Security-Policy', `default-src 'self'; script-src 'self'; style-src 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; ` +
40
43
  `connect-src 'self' ${auth.secureCookies ? 'wss' : 'ws'}://${auth.host}; object-src 'none'; base-uri 'none'; ` +
41
44
  `frame-ancestors 'none'; form-action 'self'; manifest-src 'self'; worker-src 'self'`);
42
45
  if (request.url.startsWith('/api/'))
43
46
  reply.header('Cache-Control', 'no-store');
44
47
  try {
45
- auth.validateBoundary(request, false);
48
+ auth.validateBoundary(request, false, !request.url.startsWith('/api/'));
46
49
  }
47
50
  catch (error) {
48
51
  if (request.url.startsWith('/api/'))
@@ -133,7 +136,8 @@ export async function buildWebServer(services, boundary, options = {}) {
133
136
  });
134
137
  app.get('/api/v1/roles', async (request) => {
135
138
  auth.authenticate(request);
136
- return { roles: await services.query.list() };
139
+ const query = request.query;
140
+ return { roles: await services.query.list(query.includeTemporary === 'true') };
137
141
  });
138
142
  const taskApi = async (fn) => {
139
143
  try {
@@ -146,6 +150,8 @@ export async function buildWebServer(services, boundary, options = {}) {
146
150
  ? 'conflict' : 'invalid_request';
147
151
  throw new FleetError(code, error.message);
148
152
  }
153
+ if (error instanceof TaskStateError && 'accepted' in error && error.accepted === false)
154
+ return Promise.reject(Object.assign(new FleetError('invalid_request', error.message), { memberNotAccepted: true }));
149
155
  if (error instanceof TaskStateError) {
150
156
  throw new FleetError(error.message.startsWith('task not found:') ? 'resource_not_found' : 'conflict', error.message);
151
157
  }
@@ -193,9 +199,10 @@ export async function buildWebServer(services, boundary, options = {}) {
193
199
  const state = query.state && query.state !== 'all' ? query.state : undefined;
194
200
  const filter = { ...(state ? { state } : {}), ...(query.list ? { list: query.list } : {}),
195
201
  ...(query.includeDeleting === 'true' ? { includeDeleting: true } : {}) };
202
+ const api = requireTaskRooms();
196
203
  return taskApi(() => query.groupByList === 'true'
197
- ? { groups: requireTaskRooms().groupedTasks(filter) }
198
- : { tasks: requireTaskRooms().listTasks(filter) });
204
+ ? { groups: api.groupedTasks(filter).map(group => ({ ...group, tasks: group.tasks.map(task => api.withLayoutRooms(task)) })) }
205
+ : { tasks: api.listTasks(filter).map(task => api.withLayoutRooms(task)) });
199
206
  });
200
207
  app.delete('/api/v1/tasks/:id', async (request, reply) => {
201
208
  auth.authenticate(request, true);
@@ -226,12 +233,17 @@ export async function buildWebServer(services, boundary, options = {}) {
226
233
  const body = request.body;
227
234
  if (typeof body?.title !== 'string' || !body.title)
228
235
  throw new FleetError('invalid_request', 'title is required');
236
+ // Every task runs a saved Room Layout; rooms are never defined inline or from room templates here.
237
+ if (typeof body.layout !== 'string' || !body.layout)
238
+ throw new FleetError('invalid_request', 'layout is required: choose a room layout from Settings › Room layouts');
239
+ if (body.template !== undefined || body.noRoom !== undefined)
240
+ throw new FleetError('invalid_request', 'tasks use room layouts; template and noRoom are not accepted');
229
241
  const task = await taskApi(() => requireTaskRooms().createTask({
230
242
  actor: { kind: 'local_control', surface: 'web' }, title: body.title,
231
243
  brief: typeof body.brief === 'string' ? body.brief : undefined,
232
- template: typeof body.template === 'string' ? body.template : undefined,
233
- backlog: body.backlog === true, noRoom: body.noRoom === true,
244
+ backlog: body.backlog === true,
234
245
  list: typeof body.list === 'string' ? body.list : undefined,
246
+ layout: body.layout,
235
247
  idempotencyKey: typeof request.headers['idempotency-key'] === 'string'
236
248
  ? request.headers['idempotency-key'] : undefined,
237
249
  origin: { type: 'web' },
@@ -245,11 +257,162 @@ export async function buildWebServer(services, boundary, options = {}) {
245
257
  const list = String(request.body?.list ?? '');
246
258
  return { task: await taskApi(() => requireTaskRooms().moveTask({ actor: { kind: 'local_control', surface: 'web' }, taskId, list })) };
247
259
  });
260
+ app.patch('/api/v1/tasks/:id/description', async (request) => {
261
+ auth.authenticate(request, true);
262
+ const body = request.body;
263
+ if (typeof body?.brief !== 'string' || typeof body.expectedBrief !== 'string' || body.brief.length > 100_000)
264
+ throw new FleetError('invalid_request', 'brief and expectedBrief must be strings; brief maximum is 100000 characters');
265
+ return { task: await taskApi(() => requireTaskRooms().editTaskDescription({
266
+ actor: { kind: 'local_control', surface: 'web' }, taskId: request.params.id,
267
+ brief: body.brief, expectedBrief: body.expectedBrief,
268
+ })) };
269
+ });
270
+ app.get('/api/v1/tasks/:id', async (request) => {
271
+ auth.authenticate(request);
272
+ return taskApi(() => requireTaskRooms().getTask(request.params.id));
273
+ });
274
+ app.post('/api/v1/tasks/:id/members', async (request, reply) => {
275
+ auth.authenticate(request, true);
276
+ const body = request.body;
277
+ try {
278
+ const result = await taskApi(() => requireTaskRooms().addMember(request.params.id, body));
279
+ reply.code(result.state === 'running' ? 202 : 200);
280
+ return result;
281
+ }
282
+ catch (error) {
283
+ if (error instanceof Error && 'memberNotAccepted' in error)
284
+ return reply.code(409).send({ error: { code: 'member_not_accepted', message: error.message }, accepted: false });
285
+ throw error;
286
+ }
287
+ });
288
+ app.get('/api/v1/tasks/:id/member-additions/:requestId', async (request) => {
289
+ auth.authenticate(request);
290
+ const { id, requestId } = request.params;
291
+ return taskApi(() => requireTaskRooms().memberAddition(id, requestId));
292
+ });
293
+ app.patch('/api/v1/tasks/:id/layout', async (request) => {
294
+ const session = auth.authenticate(request, true);
295
+ const body = request.body;
296
+ const name = (v) => typeof v === 'string' && v.length > 0;
297
+ if (!name(body?.layout) || !(body.expectedLayout === null || name(body.expectedLayout)))
298
+ throw new FleetError('invalid_request', 'layout must be a layout name; expectedLayout a layout name or null');
299
+ const task = await taskApi(() => requireTaskRooms().setTaskLayout({ actor: { kind: 'local_control', surface: 'web' },
300
+ taskId: request.params.id, layout: body.layout, expectedLayout: body.expectedLayout }));
301
+ await audit.record({ requestId: request.id, browser: session.id, action: 'task.layout.set', result: 'succeeded' });
302
+ return { task: requireTaskRooms().withLayoutRooms(task) };
303
+ });
304
+ app.get('/api/v1/tasks/:id/layout', async (request) => {
305
+ auth.authenticate(request);
306
+ return { layout: await taskApi(async () => requireTaskRooms().taskLayout(request.params.id)) };
307
+ });
308
+ for (const operation of ['open', 'close']) {
309
+ app.post(`/api/v1/tasks/:id/layout/rooms/:room/${operation}`, async (request, reply) => {
310
+ const session = auth.authenticate(request, true);
311
+ const { id, room } = request.params;
312
+ const accepted = await taskApi(() => requireTaskRooms().launchTaskLayoutOperation({
313
+ actor: { kind: 'local_control', surface: 'web' }, taskId: id, room,
314
+ operation: operation === 'open' ? 'open' : 'close-room',
315
+ }));
316
+ await audit.record({ requestId: request.id, browser: session.id, action: `task.layout.${operation}`, result: 'succeeded' });
317
+ reply.code(202);
318
+ return { operation: accepted };
319
+ });
320
+ }
321
+ app.post('/api/v1/tasks/:id/layout/cleanup', async (request, reply) => {
322
+ auth.authenticate(request, true);
323
+ const accepted = await taskApi(() => requireTaskRooms().retryTaskLayoutCleanup({
324
+ actor: { kind: 'local_control', surface: 'web' }, taskId: request.params.id,
325
+ }));
326
+ reply.code(202);
327
+ return { operation: accepted };
328
+ });
329
+ app.get('/api/v1/task-templates', async (request) => {
330
+ auth.authenticate(request);
331
+ return { templates: requireTaskRooms().listTemplates() };
332
+ });
333
+ const layouts = () => {
334
+ if (!services.roomLayouts)
335
+ throw new FleetError('capability_unavailable', 'room layout editing is unavailable');
336
+ return services.roomLayouts;
337
+ };
338
+ app.get('/api/v1/room-layouts', async (request) => {
339
+ auth.authenticate(request);
340
+ const list = layouts().list(), defaultLayout = layouts().defaultLayout(list);
341
+ return { layouts: list, ...(defaultLayout ? { default_layout: defaultLayout } : {}) };
342
+ });
343
+ app.post('/api/v1/room-layouts/validate', async (request) => {
344
+ auth.authenticate(request, true);
345
+ return layouts().validate(request.body?.definition);
346
+ });
347
+ app.put('/api/v1/room-layouts/:name', async (request) => {
348
+ const session = auth.authenticate(request, true);
349
+ const body = request.body;
350
+ if (typeof body?.revision !== 'string')
351
+ throw new FleetError('invalid_request', 'revision is required');
352
+ const result = await layouts().save(request.params.name, body.revision, body.definition);
353
+ events.publish('room_layouts.changed', { name: result.name, revision: result.revision });
354
+ await audit.record({ requestId: request.id, browser: session.id, action: 'room_layout.save', result: 'succeeded' });
355
+ return result;
356
+ });
357
+ app.delete('/api/v1/room-layouts/:name', async (request) => {
358
+ const session = auth.authenticate(request, true);
359
+ const revision = request.query.revision;
360
+ if (typeof revision !== 'string' || !revision)
361
+ throw new FleetError('invalid_request', 'revision is required');
362
+ const result = await layouts().remove(request.params.name, revision);
363
+ events.publish('room_layouts.changed', { name: result.name });
364
+ await audit.record({ requestId: request.id, browser: session.id, action: 'room_layout.delete', result: 'succeeded' });
365
+ return result;
366
+ });
367
+ for (const action of ['start', 'block', 'unblock', 'review', 'finish', 'cancel']) {
368
+ app.post(`/api/v1/tasks/:id/${action}`, async (request, reply) => {
369
+ auth.authenticate(request, true);
370
+ const taskId = request.params.id;
371
+ const body = (request.body ?? {});
372
+ const input = { actor: { kind: 'local_control', surface: 'web' }, taskId };
373
+ const api = requireTaskRooms();
374
+ return taskApi(async () => {
375
+ switch (action) {
376
+ case 'start': {
377
+ // A task's layout can be chosen or changed but never removed, so this check cannot go stale.
378
+ if (!api.getTask(taskId).task.layout)
379
+ throw new FleetError('conflict', 'choose a room layout for this task before starting it');
380
+ const task = await api.startTask(input);
381
+ const provisioning = await api.launchTaskProvisioning(taskId);
382
+ if (provisioning.kind !== 'ready')
383
+ reply.code(202);
384
+ return { task, provisioning };
385
+ }
386
+ case 'block':
387
+ if (typeof body.reason !== 'string' || !body.reason.trim())
388
+ throw new FleetError('invalid_request', 'reason is required');
389
+ return { task: api.blockTask({ ...input, reason: body.reason }) };
390
+ case 'unblock': return { task: api.unblockTask(input) };
391
+ case 'review': return { task: api.reviewTask(input) };
392
+ case 'finish':
393
+ case 'cancel': {
394
+ const plan = action === 'finish' ? await api.finishTask(input) : await api.cancelTask(input);
395
+ const task = plan.settlementRequired ? await api.launchTaskSettlement(taskId) : plan.task;
396
+ const pending = task.terminal_intent?.status === 'pending';
397
+ if (pending)
398
+ reply.code(202);
399
+ return { task, pending };
400
+ }
401
+ }
402
+ });
403
+ });
404
+ }
405
+ app.get('/api/v1/configuration/provenance', async (request) => {
406
+ auth.authenticate(request);
407
+ if (!services.presetProvenance)
408
+ throw new FleetError('capability_unavailable', 'preset provenance is unavailable');
409
+ return { provenance: services.presetProvenance.read() };
410
+ });
248
411
  app.get('/api/v1/configuration', async (request) => {
249
412
  auth.authenticate(request);
250
413
  if (!services.configuration)
251
414
  throw new FleetError('capability_unavailable', 'fleet configuration editing is unavailable');
252
- return services.configuration.read();
415
+ return services.configuration.read(request.query.includeDefinitions === 'true');
253
416
  });
254
417
  app.post('/api/v1/configuration/preview', async (request) => {
255
418
  auth.authenticate(request, true);
@@ -317,6 +480,20 @@ export async function buildWebServer(services, boundary, options = {}) {
317
480
  return result;
318
481
  });
319
482
  const oursTools = services.oursTools ?? new SupervisorOursTools();
483
+ app.get('/api/v1/roles/:id/contacts', async (request) => {
484
+ auth.authenticate(request);
485
+ const control = await services.session(request.params.id);
486
+ if (!control.agentContacts)
487
+ throw new FleetError('capability_unavailable', 'Supervisor contacts API is unavailable');
488
+ return control.agentContacts();
489
+ });
490
+ app.get('/api/v1/roles/:id/messages', async (request) => {
491
+ auth.authenticate(request);
492
+ const control = await services.session(request.params.id);
493
+ if (!control.agentHistory)
494
+ throw new FleetError('capability_unavailable', 'Supervisor history API is unavailable');
495
+ return control.agentHistory({ peer_cid: request.query.peer_cid, ...(request.query.limit !== undefined ? { limit: Number(request.query.limit) } : {}), ...(request.query.before_seq !== undefined ? { before_seq: Number(request.query.before_seq) } : {}) });
496
+ });
320
497
  app.get('/api/v1/roles/:id/ours/tools', async (request) => {
321
498
  auth.authenticate(request);
322
499
  return oursTools.list(request.params.id);
@@ -330,7 +507,8 @@ export async function buildWebServer(services, boundary, options = {}) {
330
507
  });
331
508
  app.get('/api/v1/roles/:id', async (request) => {
332
509
  auth.authenticate(request);
333
- return services.query.detail(request.params.id);
510
+ const detail = await services.query.detail(request.params.id);
511
+ return { ...detail, chatIdle: detail.role.lifetime === 'temporary' ? readChatIdle(agentDir(request.params.id, true)) ?? null : null };
334
512
  });
335
513
  app.get('/api/v1/roles/:id/removal-preview', async (request) => {
336
514
  auth.authenticate(request);
@@ -365,15 +543,70 @@ export async function buildWebServer(services, boundary, options = {}) {
365
543
  const control = await services.session(request.params.id);
366
544
  if (!control.conversationPage)
367
545
  throw new FleetError('capability_unavailable', 'this role has no conversation ledger');
368
- return control.conversationPage({
546
+ const page = await control.conversationPage({
369
547
  after: request.query.after,
370
548
  limit: request.query.limit ? Number(request.query.limit) : undefined,
371
549
  });
550
+ const role = await services.repository.get(request.params.id);
551
+ const stateDir = role ? services.repository.stateDir(role) : undefined;
552
+ return { ...page, events: page.events.map(event => presentAttachmentEvent(stateDir, request.params.id, event)) };
553
+ });
554
+ app.get('/api/v1/roles/:id/attachments/:attachmentId', async (request, reply) => {
555
+ auth.authenticate(request);
556
+ const role = await services.repository.get(request.params.id);
557
+ if (!role)
558
+ throw new FleetError('role_not_found', 'Agent not found');
559
+ const stateDir = services.repository.stateDir(role);
560
+ if (!stateDir)
561
+ throw new FleetError('capability_unavailable', 'Agent file storage is unavailable');
562
+ const { record, bytes } = readAgentAttachment(stateDir, request.params.attachmentId);
563
+ const image = /^image\/(png|jpeg|gif|webp|avif)$/.test(record.mimeType);
564
+ reply.header('Cache-Control', 'private, no-store');
565
+ reply.header('X-Content-Type-Options', 'nosniff');
566
+ reply.header('Content-Security-Policy', "default-src 'none'; sandbox");
567
+ reply.header('Content-Disposition', `${image ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(record.name).replace(/'/g, '%27')}`);
568
+ return reply.type(image ? record.mimeType : 'application/octet-stream').send(bytes);
569
+ });
570
+ app.post('/api/v1/roles/:id/attachments', { bodyLimit: MAX_ATTACHMENT_BODY }, async (request) => {
571
+ auth.authenticate(request, true);
572
+ const role = await services.repository.get(request.params.id);
573
+ if (!role)
574
+ throw new FleetError('role_not_found', 'Agent not found');
575
+ const stateDir = services.repository.stateDir(role);
576
+ if (!stateDir)
577
+ throw new FleetError('capability_unavailable', 'Agent file storage is unavailable');
578
+ const body = request.body;
579
+ if (typeof body?.expectedSessionGeneration !== 'string' || !body.expectedSessionGeneration.trim())
580
+ throw new FleetError('invalid_request', 'expectedSessionGeneration is required for attachments');
581
+ const control = await services.session(request.params.id);
582
+ if (!control.conversationPage || !control.submitPromptV2)
583
+ throw new FleetError('capability_unavailable', 'Session-bound attachments are unavailable');
584
+ const page = await control.conversationPage({ limit: 1 });
585
+ if (page.snapshot.sessionGeneration !== body.expectedSessionGeneration)
586
+ throw new FleetError('stale_state', 'Agent session changed. Select the files again.');
587
+ return storeAgentAttachment(stateDir, body.expectedSessionGeneration, request.body);
372
588
  });
373
589
  app.post('/api/v1/roles/:id/input', async (request, reply) => {
374
590
  const session = auth.authenticate(request, true);
375
591
  const body = request.body;
376
- const text = String(body?.text ?? '');
592
+ if (body?.expectedSessionGeneration !== undefined && (typeof body.expectedSessionGeneration !== 'string' || !body.expectedSessionGeneration.trim()))
593
+ throw new FleetError('invalid_request', 'expectedSessionGeneration must be a nonempty string');
594
+ let text = String(body?.text ?? '');
595
+ if (body?.attachments !== undefined) {
596
+ if (typeof body.commandId !== 'string' || !body.commandId.trim())
597
+ throw new FleetError('invalid_request', 'commandId is required for attachments');
598
+ if (typeof body.expectedSessionGeneration !== 'string' || !body.expectedSessionGeneration.trim())
599
+ throw new FleetError('invalid_request', 'expectedSessionGeneration is required for attachments');
600
+ const role = await services.repository.get(request.params.id);
601
+ if (!role)
602
+ throw new FleetError('role_not_found', 'Agent not found');
603
+ const stateDir = services.repository.stateDir(role);
604
+ if (!stateDir)
605
+ throw new FleetError('capability_unavailable', 'Agent file storage is unavailable');
606
+ const originalText = text;
607
+ text = attachmentPrompt(stateDir, body.expectedSessionGeneration, body.attachments, text);
608
+ prepareAttachmentPresentation(stateDir, body.expectedSessionGeneration, body.commandId, body.attachments, originalText, text);
609
+ }
377
610
  const commandId = typeof body?.commandId === 'string' && body.commandId.trim()
378
611
  ? body.commandId : undefined;
379
612
  const control = await services.session(request.params.id);
@@ -384,6 +617,7 @@ export async function buildWebServer(services, boundary, options = {}) {
384
617
  const admittedCommandId = commandId ?? randomBytes(16).toString('hex');
385
618
  const receipt = await control.submitPromptV2({
386
619
  commandId: admittedCommandId, text, source: 'owner_admin_console',
620
+ expectedSessionGeneration: body.expectedSessionGeneration,
387
621
  actorBrowserSession: createHmac('sha256', digestKey).update(session.id).digest('hex').slice(0, 24),
388
622
  });
389
623
  await audit.record({
@@ -395,6 +629,8 @@ export async function buildWebServer(services, boundary, options = {}) {
395
629
  reply.code(202);
396
630
  return receipt;
397
631
  }
632
+ if (body.expectedSessionGeneration !== undefined)
633
+ throw new FleetError('capability_unavailable', 'session-bound voice input is unavailable for this role');
398
634
  const receipt = await control.sendText(text);
399
635
  await audit.record({
400
636
  requestId: request.id, browser: session.id, roleId: request.params.id,
@@ -460,10 +696,22 @@ export async function buildWebServer(services, boundary, options = {}) {
460
696
  const body = request.body;
461
697
  if (!body.action || !['start', 'stop', 'restart_resume', 'restart_fresh'].includes(body.action))
462
698
  throw new FleetError('invalid_request', 'invalid lifecycle action');
463
- const receipt = await services.commands.execute({
464
- roleId: request.params.id, action: body.action,
465
- actionId: body.actionId, confirmation: body.confirmation,
466
- });
699
+ let receipt;
700
+ try {
701
+ receipt = await services.commands.execute({
702
+ roleId: request.params.id, action: body.action,
703
+ actionId: body.actionId, confirmation: body.confirmation,
704
+ });
705
+ }
706
+ catch (error) {
707
+ // An existing receipt always wins: a conflicting/repeated action is not a
708
+ // proof that the original operation was never admitted.
709
+ if (typeof body.actionId === 'string' && !services.commands.get(body.actionId)) {
710
+ const normalized = normalizeError(error);
711
+ return reply.code(409).send({ error: { code: 'action_not_accepted', message: normalized.message }, accepted: false });
712
+ }
713
+ throw error;
714
+ }
467
715
  events.publish('action.changed', receipt, request.params.id);
468
716
  reply.code(202);
469
717
  return receipt;
@@ -551,6 +799,9 @@ export async function buildWebServer(services, boundary, options = {}) {
551
799
  }
552
800
  socket.send(JSON.stringify(payload));
553
801
  };
802
+ const role = await services.repository.get(request.params.id);
803
+ const stateDir = role ? services.repository.stateDir(role) : undefined;
804
+ const present = (event) => presentAttachmentEvent(stateDir, request.params.id, event);
554
805
  const follow = await control.followConversation({
555
806
  after,
556
807
  onPage: page => {
@@ -561,11 +812,11 @@ export async function buildWebServer(services, boundary, options = {}) {
561
812
  lastCursor: page.nextCursor ?? after,
562
813
  });
563
814
  if (page.events.length)
564
- guardedSend({ type: 'events', events: page.events });
815
+ guardedSend({ type: 'events', events: page.events.map(present) });
565
816
  if (page.hasMore)
566
817
  guardedSend({ type: 'resync.required' });
567
818
  },
568
- onEvent: event => guardedSend({ type: 'events', events: [event] }),
819
+ onEvent: event => guardedSend({ type: 'events', events: [present(event)] }),
569
820
  onClose: reason => {
570
821
  if (socket.readyState === socket.OPEN)
571
822
  socket.close(4409, (reason ?? 'conversation stream ended').slice(0, 120));
@@ -578,7 +829,7 @@ export async function buildWebServer(services, boundary, options = {}) {
578
829
  socket.on('close', () => { clearInterval(heartbeat); follow.close(); });
579
830
  });
580
831
  });
581
- const staticRoot = join(dirname(fileURLToPath(import.meta.url)), '..', 'web-app');
832
+ const staticRoot = options.staticRoot ?? join(dirname(fileURLToPath(import.meta.url)), '..', 'web-app');
582
833
  if (existsSync(staticRoot)) {
583
834
  await app.register(fastifyStatic, { root: staticRoot, prefix: '/' });
584
835
  app.setNotFoundHandler((request, reply) => {
@@ -599,17 +850,17 @@ export async function buildWebServer(services, boundary, options = {}) {
599
850
  function setAuthCookies(reply, session, device, secure = false) {
600
851
  const suffix = secure ? '; Secure' : '';
601
852
  reply.header('Set-Cookie', [
602
- `ofs_session=${encodeURIComponent(session)}; HttpOnly; SameSite=Strict; Path=/api; Max-Age=28800${suffix}`,
603
- `ofs_device=${encodeURIComponent(device)}; HttpOnly; SameSite=Strict; Path=/api; Max-Age=2592000${suffix}`,
853
+ `ofs_session=${encodeURIComponent(session)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=28800${suffix}`,
854
+ `ofs_device=${encodeURIComponent(device)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=2592000${suffix}`,
604
855
  ]);
605
856
  }
606
857
  function setSessionCookie(reply, session, secure = false) {
607
- reply.header('Set-Cookie', `ofs_session=${encodeURIComponent(session)}; HttpOnly; SameSite=Strict; Path=/api; Max-Age=28800${secure ? '; Secure' : ''}`);
858
+ reply.header('Set-Cookie', `ofs_session=${encodeURIComponent(session)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=28800${secure ? '; Secure' : ''}`);
608
859
  }
609
860
  function clearAuthCookies(reply) {
610
861
  reply.header('Set-Cookie', [
611
- 'ofs_session=; HttpOnly; SameSite=Strict; Path=/api; Max-Age=0',
612
- 'ofs_device=; HttpOnly; SameSite=Strict; Path=/api; Max-Age=0',
862
+ 'ofs_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0',
863
+ 'ofs_device=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0',
613
864
  ]);
614
865
  }
615
866
  function promoteRequest(body) {
@@ -0,0 +1,92 @@
1
+ # YAML Room Layouts
2
+
3
+ Room Layout describes room goals, participants and their shared or independent
4
+ sessions. Rooms and agents have independent lifecycles.
5
+
6
+ Place a layout at `fleet/room_layouts/<name>.yaml` beside the split configuration
7
+ for `fleet.yaml`. Agent Templates remain in `fleet/agent_templates/`.
8
+
9
+ ```yaml
10
+ version: 1
11
+ participants:
12
+ product: {agent_template: Product}
13
+ developer: {agent_template: Developer}
14
+ qa: {agent_template: QA}
15
+ rooms:
16
+ discovery:
17
+ goal: Clarify the problem and acceptance criteria
18
+ members: [product, developer, qa]
19
+ design:
20
+ goal: Review implementation choices
21
+ members: [developer, qa]
22
+ amigos:
23
+ goal: Resolve a concrete acceptance question
24
+ members: [product, developer, qa]
25
+ roles: {product: product, developer: developer, qa: qa}
26
+ ```
27
+
28
+ Repeating a participant key reuses its exact running instance and session.
29
+ Different keys using the same Agent Template create independent instances.
30
+ See the [shared](../examples/room-layouts/shared.yaml) and
31
+ [fresh](../examples/room-layouts/fresh.yaml) examples. Both choices can coexist.
32
+ Fresh agents receive their room context. Supply relevant evidence through ordinary
33
+ room messages or existing project documents when needed.
34
+
35
+ A participant without `agent_template` requires a binding. `layout instance`
36
+ returns a local exact reference (supervisor, agent, launch, CID and session).
37
+ Pass participant-to-reference YAML to `create --bindings`. A stopped, replaced
38
+ or remote instance is rejected.
39
+
40
+ ```sh
41
+ ours-fleet layout validate work
42
+ ours-fleet layout create work --id example
43
+ ours-fleet layout open example design
44
+ ours-fleet layout open example discovery
45
+ ours-fleet layout open example amigos
46
+ ours-fleet layout close-room example amigos
47
+ ours-fleet layout status example
48
+ ours-fleet layout close example
49
+ ```
50
+
51
+ Any declared room may open first. `create` only saves the definition and resolved
52
+ Agent Templates; `open` lazily starts missing participants and admits all members
53
+ before sending their room context. The membership notification is informational. Existing room rules govern when
54
+ an agent contributes. Each room opens through an explicit command.
55
+
56
+ `close-room` archives a room and keeps agents available to other rooms. `close`
57
+ archives opened rooms and stops only temporary participants created by this layout
58
+ instance. Explicitly bound participants survive. Cleanup processes existing resources. A 3-Amigos room is an ordinary room with
59
+ complementary members.
60
+
61
+ Supported room fields: `goal`, `members`, optional `roles`, `contract`,
62
+ `quiet_membership`, `anonymous`. `contract` is briefing text.
63
+ Top-level fields: `version: 1`, optional `description`, `participants`, `rooms`.
64
+ Unknown fields, ambiguous YAML and invalid member references are rejected.
65
+ Existing templates and tasks keep their behavior.
66
+
67
+ Only local Fleet control is supported. The existing authenticated control socket,
68
+ agent runtime, `spawnTemp` and session queue perform operations. Private state
69
+ snapshots template definitions; public status hides their environment values.
70
+ Existing owner settings apply; owner CID/invite fingerprint are pinned. Unknown
71
+ mutation outcomes stop new work for inspection rather than blindly replaying a
72
+ spawn or room creation. `close` and `close-room` can still clean known resources.
73
+ Cleanup continues after individual errors and does not stop replacement or borrowed
74
+ instances. Confirmed stopped workers need no live control socket. An unknown
75
+ original outcome remains recorded, and cleanup reports incomplete until inspected;
76
+ it never claims that unknown resources were found or removed. Retrying cleanup
77
+ checks known resources and skips completed cleanup. Reopening an active layout room
78
+ checks its native state, CID and required seats and reports drift without repairing it.
79
+
80
+
81
+ Shared sessions retain available conversation context, not guaranteed perfect
82
+ recall or isolation between rooms. Compaction and model judgment still matter.
83
+ Rooms keep their existing turn scheduling.
84
+
85
+ Each layout instance creates distinct native room names, so several instances can
86
+ open the same declared room key. Participant roles must differ from the attached
87
+ owner role; a collision is rejected before creating resources.
88
+
89
+ Definitions are loaded for authoring commands. Retained instances use their saved
90
+ layout and Agent Templates. Status and cleanup operate on saved resources even
91
+ when the source YAML is broken or removed. New agent launches continue to use
92
+ Fleet's normal configuration and launch checks.
@@ -0,0 +1,27 @@
1
+ version: 1
2
+ description: Separate participant keys create independent agent sessions for each room
3
+ participants:
4
+ productArchitect:
5
+ agent_template: Architect
6
+ productReviewer:
7
+ agent_template: Reviewer
8
+ designArchitect:
9
+ agent_template: Architect
10
+ designDeveloper:
11
+ agent_template: Developer
12
+ designReviewer:
13
+ agent_template: Reviewer
14
+ deliveryDeveloper:
15
+ agent_template: Developer
16
+ deliveryReviewer:
17
+ agent_template: Reviewer
18
+ rooms:
19
+ product:
20
+ goal: Agree on the problem and acceptance criteria
21
+ members: [productArchitect, productReviewer]
22
+ design:
23
+ goal: Design the accepted solution
24
+ members: [designArchitect, designDeveloper, designReviewer]
25
+ delivery:
26
+ goal: Implement and verify the accepted design
27
+ members: [deliveryDeveloper, deliveryReviewer]
@@ -0,0 +1,19 @@
1
+ version: 1
2
+ description: Same agents retain their sessions across rooms
3
+ participants:
4
+ architect:
5
+ agent_template: Architect
6
+ developer:
7
+ agent_template: Developer
8
+ reviewer:
9
+ agent_template: Reviewer
10
+ rooms:
11
+ product:
12
+ goal: Agree on the problem and acceptance criteria
13
+ members: [architect, reviewer]
14
+ design:
15
+ goal: Design the accepted solution
16
+ members: [architect, developer, reviewer]
17
+ delivery:
18
+ goal: Implement and verify the accepted design
19
+ members: [developer, reviewer]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.2.0-nightly.19",
3
+ "version": "1.2.0-nightly.20",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, managed native/ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",
@@ -19,6 +19,7 @@
19
19
  "presets",
20
20
  "README.md",
21
21
  "docs/task-workspaces.md",
22
+ "docs/room-layouts.md",
22
23
  "LICENSE"
23
24
  ],
24
25
  "engines": {
@@ -26,14 +27,12 @@
26
27
  },
27
28
  "scripts": {
28
29
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
29
- "build": "npm run clean && tsc -p tsconfig.json && vite build && node scripts/build-info.mjs",
30
- "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p web/tsconfig.json --noEmit",
30
+ "build": "npm run clean && tsc -p tsconfig.json && node scripts/build-info.mjs",
31
+ "typecheck": "tsc -p tsconfig.json --noEmit",
31
32
  "lint": "npm run typecheck",
32
33
  "test": "vitest run",
33
34
  "test:web": "vitest run test/web",
34
35
  "test:pack": "vitest run --config vitest.integration.config.ts",
35
- "test:e2e": "npm run build && playwright test",
36
- "dev:web": "vite",
37
36
  "prepack": "npm run build",
38
37
  "prepublishOnly": "npm run build && npm test",
39
38
  "test:v1-recovery": "node test/fleet-v1-recovery.integration.mjs retry && node test/fleet-v1-recovery.integration.mjs terminal-after-failure && node test/fleet-v1-recovery.integration.mjs terminal-release-failure"
@@ -44,11 +43,9 @@
44
43
  "@fastify/websocket": "^11.2.0",
45
44
  "@ours.network/cli": "2.8.1-nightly.12",
46
45
  "@ours.network/mcp": "1.2.0-nightly.9",
47
- "@ours.network/sdk": "3.8.1-nightly.14",
46
+ "@ours.network/sdk": "3.8.1-nightly.15",
48
47
  "commander": "^12.1.0",
49
48
  "fastify": "^5.4.0",
50
- "react": "^19.1.1",
51
- "react-dom": "^19.1.1",
52
49
  "secure-json-parse": "^4.1.0",
53
50
  "ws": "^8.18.3",
54
51
  "yaml": "^2.5.0"
@@ -58,14 +55,18 @@
58
55
  "@agentclientprotocol/codex-acp": "1.10.0"
59
56
  },
60
57
  "devDependencies": {
61
- "@ours.network/daemon": "3.8.1-nightly.6",
62
- "@playwright/test": "^1.54.1",
58
+ "@ours.network/daemon": "3.8.1-nightly.7",
63
59
  "@types/node": "^20.14.0",
64
- "@types/react": "^19.1.9",
65
- "@types/react-dom": "^19.1.7",
66
60
  "@types/ws": "^8.18.1",
67
61
  "typescript": "^5.5.0",
68
- "vite": "^7.0.6",
69
62
  "vitest": "^2.0.0"
63
+ },
64
+ "overrides": {
65
+ "@ours.network/mcp": {
66
+ "@ours.network/sdk": "$@ours.network/sdk"
67
+ },
68
+ "@ours.network/cli": {
69
+ "@ours.network/sdk": "$@ours.network/sdk"
70
+ }
70
71
  }
71
72
  }