@ours.network/fleet 0.19.0-nightly.2 → 0.19.0-nightly.3

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.
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "0.19.0-nightly.2",
3
- "buildId": "13880d55bd06",
4
- "commit": "ccf83082de238e6dc8ddb1bc557d2b7ffc7e816f",
2
+ "version": "0.19.0-nightly.3",
3
+ "buildId": "c969df19c5f2",
4
+ "commit": "1cfdb94ec5bc493c83c72f62dd70c669c78b9e68",
5
5
  "dirty": true,
6
- "builtAt": "2026-08-23T08:33:08.792Z",
6
+ "builtAt": "2026-08-23T10:19:42.625Z",
7
7
  "capabilities": [
8
8
  "monitor.interrupt.after_tool"
9
9
  ]
package/dist/config.d.ts CHANGED
@@ -170,6 +170,12 @@ export interface FleetConfig {
170
170
  tasks?: import('./rooms-tasks/types.js').TasksConfig;
171
171
  /** SHA-256 fingerprint of the resolved owner invite (never the invite itself). */
172
172
  ownerInviteFingerprint?: string;
173
+ /**
174
+ * Resolved owner invite for immediate private-IPC use only. This property is
175
+ * deliberately non-enumerable so config dumps and JSON output cannot expose
176
+ * the bearer credential.
177
+ */
178
+ ownerInvite?: string;
173
179
  }
174
180
  export declare class ConfigError extends Error {
175
181
  }
package/dist/config.js CHANGED
@@ -276,6 +276,7 @@ export function loadConfig(configPath, options = {}) {
276
276
  // top-level (room_templates merge by name, last writer wins).
277
277
  let rooms;
278
278
  let ownerInviteFingerprint;
279
+ let ownerInvite;
279
280
  let roomTemplates;
280
281
  let tasks;
281
282
  for (const { file, doc } of docs) {
@@ -284,6 +285,7 @@ export function loadConfig(configPath, options = {}) {
284
285
  throw new ConfigError(`rooms: defined in multiple files; last: ${file}`);
285
286
  const validated = validateRoomsConfig(deepSub(doc.rooms, vars), vars, file);
286
287
  ownerInviteFingerprint = validated._invite?.fingerprint;
288
+ ownerInvite = validated._invite?.value;
287
289
  const { _invite: _, ...clean } = validated;
288
290
  rooms = clean;
289
291
  }
@@ -297,11 +299,20 @@ export function loadConfig(configPath, options = {}) {
297
299
  tasks = validateTasksConfig(deepSub(doc.tasks, vars), file);
298
300
  }
299
301
  }
300
- return {
302
+ const result = {
301
303
  roles, vars, defaults, files, startStaggerMs, diagnostics, watchdogs,
302
304
  loops: resolvedLoops.loops,
303
305
  rooms, roomTemplates, tasks, ownerInviteFingerprint,
304
306
  };
307
+ if (ownerInvite !== undefined) {
308
+ Object.defineProperty(result, 'ownerInvite', {
309
+ value: ownerInvite,
310
+ enumerable: false,
311
+ writable: false,
312
+ configurable: false,
313
+ });
314
+ }
315
+ return result;
305
316
  }
306
317
  /**
307
318
  * Canonical form of a 64-hex container ID for authorization decisions. Hex
@@ -1,8 +1,9 @@
1
1
  import { readFileSync } from 'node:fs';
2
- import { loadConfig } from '../config.js';
2
+ import { loadConfig, ConfigError } from '../config.js';
3
3
  import { resolveTemplate, listTemplates, snapshotTemplate, hashTemplate, } from './templates.js';
4
- import { createTask, getTask, listTasks, startTask, blockTask, unblockTask, reviewTask, completeTask, cancelTask, } from './task-state.js';
5
- import { createRoomRecord, getRoomRecord, listRoomRecords, closeRoom as closeRoomRecord, } from './room-state.js';
4
+ import { createTask, getTask, listTasks, startTask, blockTask, unblockTask, reviewTask, completeTask, cancelTask, updateTaskRoom, } from './task-state.js';
5
+ import { createRoomRecord, getRoomRecord, listRoomRecords, closeRoom as closeRoomRecord, advanceSaga, setOwnerSeat, setSagaError, } from './room-state.js';
6
+ import { createCoworkAdapter, CoworkProtocolError, CoworkUnavailableError } from './cowork-adapter.js';
6
7
  function die(e) {
7
8
  const msg = e instanceof Error ? e.message : String(e);
8
9
  process.stderr.write(`error: ${msg}\n`);
@@ -14,6 +15,64 @@ function loadCfg(opts) {
14
15
  function allTemplates(cfg) {
15
16
  return cfg.roomTemplates ?? {};
16
17
  }
18
+ function coworkFor(cfg) {
19
+ if (!cfg.rooms || cfg.rooms.provider !== 'cowork')
20
+ throw new ConfigError('rooms: configure provider: cowork before creating or querying rooms');
21
+ return createCoworkAdapter({ configPath: cfg.rooms.cowork?.config });
22
+ }
23
+ function resolveRoomTemplate(cfg, name) {
24
+ if (!name)
25
+ return undefined;
26
+ const template = resolveTemplate(name, allTemplates(cfg));
27
+ if (!template)
28
+ throw new Error(`template not found: ${name}`);
29
+ return snapshotTemplate(template);
30
+ }
31
+ async function provisionRoom(cfg, input) {
32
+ const rooms = cfg.rooms;
33
+ if (!rooms)
34
+ throw new ConfigError('rooms: configuration is required');
35
+ const attachOwner = rooms.defaults?.attach_owner !== false;
36
+ if (attachOwner && !cfg.ownerInvite)
37
+ throw new ConfigError('rooms.owner: public_invite or public_invite_file is required when attach_owner is enabled');
38
+ const cowork = coworkFor(cfg);
39
+ const goal = input.goal?.trim() || input.name;
40
+ const briefing = input.brief?.trim() || input.template?.contract?.trim() || goal;
41
+ const created = await cowork.createRoom({
42
+ room_name: input.name,
43
+ goal,
44
+ briefing,
45
+ quiet_membership: input.template?.room?.quiet_membership,
46
+ anonymous: input.template?.room?.anonymous,
47
+ });
48
+ let record = createRoomRecord({
49
+ room_id: created.room_id,
50
+ room_name: input.name,
51
+ room_identity_cid: created.identity_cid,
52
+ task_id: input.taskId,
53
+ template_snapshot: input.template,
54
+ });
55
+ input.onCreated?.(record);
56
+ record = advanceSaga(record.room_id, 'create_room', 1);
57
+ if (attachOwner) {
58
+ try {
59
+ record = advanceSaga(record.room_id, 'attach_owner', 2);
60
+ const accepted = await cowork.acceptInvite(record.room_id, cfg.ownerInvite, {
61
+ role: rooms.owner.role,
62
+ expected_cid: rooms.owner.expected_cid,
63
+ });
64
+ record = setOwnerSeat(record.room_id, accepted.seat_cid, cfg.ownerInviteFingerprint ?? '');
65
+ }
66
+ catch (error) {
67
+ const mismatch = error instanceof CoworkProtocolError && /CID|expected/i.test(error.message);
68
+ setSagaError(record.room_id, error instanceof Error ? error.message : String(error), mismatch
69
+ ? 'Verify rooms.owner.expected_cid and rotate the configured invite if necessary.'
70
+ : 'Rotate rooms.owner.public_invite or public_invite_file, then run room recover.', mismatch ? 'owner_cid_mismatch' : 'waiting_owner_invite');
71
+ throw error;
72
+ }
73
+ }
74
+ return advanceSaga(record.room_id, 'create_members', 3);
75
+ }
17
76
  export function registerTemplateCommands(parent, cOpt) {
18
77
  const templateCmd = parent.command('template').description('room template operations');
19
78
  cOpt(templateCmd.command('list'))
@@ -114,7 +173,7 @@ export function registerTaskCommands(parent, cOpt) {
114
173
  .option('--no-room', 'create task without a room')
115
174
  .option('--idempotency-key <key>', 'idempotency key')
116
175
  .option('--json', 'JSON output')
117
- .action((opts) => {
176
+ .action(async (opts) => {
118
177
  try {
119
178
  const cfg = loadCfg(opts);
120
179
  let templateRef;
@@ -138,7 +197,7 @@ export function registerTaskCommands(parent, cOpt) {
138
197
  brief = readFileSync(opts.briefFile, 'utf8');
139
198
  }
140
199
  const origin = { type: 'cli' };
141
- const record = createTask({
200
+ let record = createTask({
142
201
  title: opts.title,
143
202
  brief,
144
203
  brief_file: opts.briefFile,
@@ -147,6 +206,28 @@ export function registerTaskCommands(parent, cOpt) {
147
206
  idempotency_key: opts.idempotencyKey,
148
207
  start: !opts.backlog,
149
208
  });
209
+ if (!opts.backlog && opts.room !== false && templateRef && !record.room_id) {
210
+ const template = resolveRoomTemplate(cfg, templateRef.name);
211
+ try {
212
+ await provisionRoom(cfg, {
213
+ name: opts.title,
214
+ goal: opts.title,
215
+ brief,
216
+ template,
217
+ taskId: record.task_id,
218
+ onCreated: room => {
219
+ record = updateTaskRoom(record.task_id, room.room_id, room.room_identity_cid);
220
+ },
221
+ });
222
+ record = getTask(record.task_id);
223
+ }
224
+ catch (error) {
225
+ if (error instanceof CoworkUnavailableError) {
226
+ blockTask(record.task_id, 'Cowork management socket is unavailable');
227
+ }
228
+ throw error;
229
+ }
230
+ }
150
231
  if (opts.json) {
151
232
  console.log(JSON.stringify({ schema_version: 1, task: record }, null, 2));
152
233
  return;
@@ -224,12 +305,29 @@ export function registerTaskCommands(parent, cOpt) {
224
305
  die(e);
225
306
  }
226
307
  });
227
- taskCmd.command('start <id>')
308
+ cOpt(taskCmd.command('start <id>'))
228
309
  .description('start a backlog task')
229
310
  .option('--json', 'JSON output')
230
- .action((id, opts) => {
311
+ .action(async (id, opts) => {
231
312
  try {
232
- const t = startTask(id);
313
+ const cfg = loadCfg(opts);
314
+ let t = startTask(id);
315
+ if (t.template && !t.room_id) {
316
+ const template = resolveRoomTemplate(cfg, t.template.name);
317
+ if (!template || template.content_hash !== t.template.content_hash)
318
+ throw new Error(`task template snapshot no longer matches ${t.template.name}@${t.template.version}`);
319
+ await provisionRoom(cfg, {
320
+ name: t.title,
321
+ goal: t.title,
322
+ brief: t.brief,
323
+ template,
324
+ taskId: t.task_id,
325
+ onCreated: room => {
326
+ t = updateTaskRoom(t.task_id, room.room_id, room.room_identity_cid);
327
+ },
328
+ });
329
+ t = getTask(t.task_id);
330
+ }
233
331
  if (opts.json) {
234
332
  console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
235
333
  return;
@@ -382,13 +480,12 @@ export function registerRoomCommands(parent, cOpt) {
382
480
  .option('--brief <text>', 'room briefing')
383
481
  .option('--brief-file <path>', 'room briefing from file')
384
482
  .option('--json', 'JSON output')
385
- .action((opts) => {
483
+ .action(async (opts) => {
386
484
  try {
387
485
  const cfg = loadCfg(opts);
388
486
  let brief = opts.brief;
389
487
  if (opts.briefFile)
390
488
  brief = readFileSync(opts.briefFile, 'utf8');
391
- const roomId = `room-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
392
489
  let templateSnapshot;
393
490
  if (opts.template) {
394
491
  const t = resolveTemplate(opts.template, allTemplates(cfg));
@@ -396,10 +493,11 @@ export function registerRoomCommands(parent, cOpt) {
396
493
  die(new Error(`template not found: ${opts.template}`));
397
494
  templateSnapshot = snapshotTemplate(t);
398
495
  }
399
- const record = createRoomRecord({
400
- room_id: roomId,
401
- room_name: opts.name,
402
- template_snapshot: templateSnapshot,
496
+ const record = await provisionRoom(cfg, {
497
+ name: opts.name,
498
+ goal: opts.goal,
499
+ brief,
500
+ template: templateSnapshot,
403
501
  });
404
502
  if (opts.json) {
405
503
  console.log(JSON.stringify({ schema_version: 1, room: record }, null, 2));
@@ -413,17 +511,22 @@ export function registerRoomCommands(parent, cOpt) {
413
511
  die(e);
414
512
  }
415
513
  });
416
- roomCmd.command('list')
514
+ cOpt(roomCmd.command('list'))
417
515
  .description('list rooms')
418
516
  .option('--state <state>', 'filter by state (active|provisioning|closing|closed|all)')
419
517
  .option('--json', 'JSON output')
420
- .action((opts) => {
518
+ .action(async (opts) => {
421
519
  try {
520
+ const cfg = loadCfg(opts);
422
521
  let stateFilter;
423
522
  if (opts.state && opts.state !== 'all') {
424
523
  stateFilter = opts.state;
425
524
  }
426
- const rooms = listRoomRecords(stateFilter ? { state: stateFilter } : undefined);
525
+ const local = new Map(listRoomRecords().map(room => [room.room_id, room]));
526
+ const coworkRooms = await coworkFor(cfg).listRooms();
527
+ const rooms = coworkRooms
528
+ .filter(room => !stateFilter || room.state === stateFilter)
529
+ .map(room => ({ ...room, orchestration: local.get(room.room_id) ?? null }));
427
530
  if (opts.json) {
428
531
  console.log(JSON.stringify({ schema_version: 1, rooms }, null, 2));
429
532
  return;
@@ -433,122 +536,144 @@ export function registerRoomCommands(parent, cOpt) {
433
536
  return;
434
537
  }
435
538
  for (const r of rooms)
436
- console.log(`${r.room_id} ${r.state} ${r.room_name}${r.task_id ? ` (task: ${r.task_id})` : ''}`);
539
+ console.log(`${r.room_id} ${r.state} ${r.room_name}${r.orchestration?.task_id ? ` (task: ${r.orchestration.task_id})` : ''}`);
437
540
  }
438
541
  catch (e) {
439
542
  die(e);
440
543
  }
441
544
  });
442
- roomCmd.command('show <id>')
545
+ cOpt(roomCmd.command('show <id>'))
443
546
  .description('show room details')
444
547
  .option('--json', 'JSON output')
445
- .action((id, opts) => {
548
+ .action(async (id, opts) => {
446
549
  try {
447
- const r = getRoomRecord(id);
448
- if (!r)
550
+ const cfg = loadCfg(opts);
551
+ const cowork = await coworkFor(cfg).getRoom(id);
552
+ if (!cowork)
449
553
  die(new Error(`room not found: ${id}`));
554
+ const r = getRoomRecord(id);
450
555
  if (opts.json) {
451
- console.log(JSON.stringify({ schema_version: 1, room: r }, null, 2));
556
+ console.log(JSON.stringify({ schema_version: 1, room: cowork, orchestration: r ?? null }, null, 2));
452
557
  return;
453
558
  }
454
- console.log(`Room: ${r.room_id}`);
455
- console.log(`Name: ${r.room_name}`);
456
- console.log(`State: ${r.state}`);
457
- if (r.task_id)
559
+ console.log(`Room: ${cowork.room_id}`);
560
+ console.log(`Name: ${cowork.room_name}`);
561
+ console.log(`State: ${cowork.state}`);
562
+ console.log(`Identity CID: ${cowork.identity_cid}`);
563
+ if (r?.task_id)
458
564
  console.log(`Task: ${r.task_id}`);
459
- if (r.room_identity_cid)
460
- console.log(`Identity CID: ${r.room_identity_cid}`);
461
- console.log(`Saga: ${r.saga.phase} (step ${r.saga.step_index})`);
462
- if (r.provisioning_detail)
565
+ if (r)
566
+ console.log(`Saga: ${r.saga.phase} (step ${r.saga.step_index})`);
567
+ if (r?.provisioning_detail)
463
568
  console.log(`Detail: ${r.provisioning_detail}`);
464
- if (r.saga.error)
569
+ if (r?.saga.error)
465
570
  console.log(`Error: ${r.saga.error}`);
466
- if (r.member_seats.length) {
571
+ if (cowork.seats.length) {
467
572
  console.log('Members:');
468
- for (const s of r.member_seats)
469
- console.log(` ${s.role_name} (${s.cowork_role}) ${s.seat_state}`);
573
+ for (const s of cowork.seats)
574
+ console.log(` ${s.identity_cid} (${s.role}) ${s.seat_state}`);
470
575
  }
471
- console.log(`Created: ${r.created_at}`);
472
- if (r.activated_at)
473
- console.log(`Activated: ${r.activated_at}`);
474
- if (r.closed_at)
475
- console.log(`Closed: ${r.closed_at}`);
576
+ if (r)
577
+ console.log(`Tracked by Fleet since: ${r.created_at}`);
476
578
  }
477
579
  catch (e) {
478
580
  die(e);
479
581
  }
480
582
  });
481
- roomCmd.command('members <id>')
583
+ cOpt(roomCmd.command('members <id>'))
482
584
  .description('show room members')
483
585
  .option('--json', 'JSON output')
484
- .action((id, opts) => {
586
+ .action(async (id, opts) => {
485
587
  try {
588
+ const cfg = loadCfg(opts);
486
589
  const r = getRoomRecord(id);
487
- if (!r)
488
- die(new Error(`room not found: ${id}`));
590
+ const members = await coworkFor(cfg).getSeats(id);
489
591
  if (opts.json) {
490
592
  console.log(JSON.stringify({
491
593
  schema_version: 1,
492
- room_id: r.room_id,
493
- members: r.member_seats,
494
- owner_seat_cid: r.owner_seat_cid ?? null,
594
+ room_id: id,
595
+ members,
596
+ owner_seat_cid: r?.owner_seat_cid ?? null,
495
597
  }, null, 2));
496
598
  return;
497
599
  }
498
- console.log(`Room ${r.room_id} — ${r.room_name}`);
499
- if (r.owner_seat_cid)
600
+ console.log(`Room ${id}${r ? ` — ${r.room_name}` : ''}`);
601
+ if (r?.owner_seat_cid)
500
602
  console.log(`Owner: ${r.owner_seat_cid}`);
501
- if (!r.member_seats.length) {
603
+ if (!members.length) {
502
604
  console.log('No members.');
503
605
  return;
504
606
  }
505
- for (const s of r.member_seats)
506
- console.log(` ${s.role_name} (${s.cowork_role}) ${s.seat_state} ${s.identity_cid}`);
607
+ for (const s of members)
608
+ console.log(` ${s.identity_cid} (${s.role}) ${s.seat_state}`);
507
609
  }
508
610
  catch (e) {
509
611
  die(e);
510
612
  }
511
613
  });
512
- roomCmd.command('close <id> <confirm-id>')
614
+ cOpt(roomCmd.command('close <id> <confirm-id>'))
513
615
  .description('close a room (requires ID twice for confirmation)')
514
616
  .option('--json', 'JSON output')
515
- .action((id, confirmId, opts) => {
617
+ .action(async (id, confirmId, opts) => {
516
618
  try {
517
619
  if (id !== confirmId)
518
620
  die(new Error('confirmation ID must match room ID'));
519
- const r = closeRoomRecord(id);
621
+ const cfg = loadCfg(opts);
622
+ await coworkFor(cfg).closeRoom(id);
623
+ const existing = getRoomRecord(id);
624
+ const r = existing ? closeRoomRecord(id) : undefined;
520
625
  if (opts.json) {
521
- console.log(JSON.stringify({ schema_version: 1, room: r }, null, 2));
626
+ console.log(JSON.stringify({ schema_version: 1, room_id: id, state: 'closed', orchestration: r ?? null }, null, 2));
522
627
  return;
523
628
  }
524
- console.log(`Room ${r.room_id} · closed`);
629
+ console.log(`Room ${id} · closed`);
525
630
  }
526
631
  catch (e) {
527
632
  die(e);
528
633
  }
529
634
  });
530
- roomCmd.command('recover <id>')
635
+ cOpt(roomCmd.command('recover <id>'))
531
636
  .description('attempt to recover a stuck room')
532
637
  .option('--json', 'JSON output')
533
- .action((id, opts) => {
638
+ .action(async (id, opts) => {
534
639
  try {
535
- const r = getRoomRecord(id);
536
- if (!r)
537
- die(new Error(`room not found: ${id}`));
640
+ const cfg = loadCfg(opts);
641
+ const adapter = coworkFor(cfg);
642
+ const cowork = await adapter.recoverRoom(id);
643
+ let r = getRoomRecord(id);
644
+ if (r && !r.owner_seat_cid
645
+ && (r.provisioning_detail === 'waiting_owner_invite'
646
+ || r.provisioning_detail === 'owner_cid_mismatch')) {
647
+ const expected = cfg.rooms.owner.expected_cid.toLowerCase();
648
+ const existing = (await adapter.getSeats(id))
649
+ .find(seat => seat.identity_cid.toLowerCase() === expected && seat.seat_state !== 'removed');
650
+ if (!existing && !cfg.ownerInvite)
651
+ throw new ConfigError('rooms.owner: configure public_invite or public_invite_file before recovery');
652
+ let acceptedCid = existing?.identity_cid;
653
+ if (!acceptedCid) {
654
+ const accepted = await adapter.acceptInvite(id, cfg.ownerInvite, {
655
+ role: cfg.rooms.owner.role,
656
+ expected_cid: cfg.rooms.owner.expected_cid,
657
+ });
658
+ acceptedCid = accepted.seat_cid;
659
+ }
660
+ setOwnerSeat(id, acceptedCid, cfg.ownerInviteFingerprint ?? '');
661
+ r = advanceSaga(id, 'create_members', 3);
662
+ }
538
663
  const actions = [];
539
- if (r.saga.error)
664
+ if (r?.saga.error)
540
665
  actions.push(`Last error: ${r.saga.error}`);
541
- if (r.saga.recovery_hint)
666
+ if (r?.saga.recovery_hint)
542
667
  actions.push(r.saga.recovery_hint);
543
- if (r.provisioning_detail === 'waiting_cowork')
668
+ if (r?.provisioning_detail === 'waiting_cowork')
544
669
  actions.push('Check ours-cowork service status');
545
- if (r.provisioning_detail === 'waiting_owner_invite')
670
+ if (r?.provisioning_detail === 'waiting_owner_invite')
546
671
  actions.push('Rotate rooms.owner.public_invite in config, then re-run recover');
547
672
  if (opts.json) {
548
- console.log(JSON.stringify({ schema_version: 1, room: r, recovery_actions: actions }, null, 2));
673
+ console.log(JSON.stringify({ schema_version: 1, room: cowork, orchestration: r ?? null, recovery_actions: actions }, null, 2));
549
674
  return;
550
675
  }
551
- console.log(`Room ${r.room_id} · ${r.state} · saga: ${r.saga.phase}`);
676
+ console.log(`Room ${cowork.room_id} · ${cowork.state}${r ? ` · saga: ${r.saga.phase}` : ''}`);
552
677
  if (actions.length) {
553
678
  console.log('Recovery:');
554
679
  for (const a of actions)
@@ -1,17 +1,10 @@
1
1
  /**
2
- * Cowork private management-socket adapter.
2
+ * Typed client for the ours-cowork v1 private management-socket protocol.
3
3
  *
4
- * Fleet orchestrates tasks/rooms but Cowork owns room state. This adapter
5
- * communicates with Cowork via its Unix management socket using versioned
6
- * JSON RPC. All methods fail closed: if Cowork is unreachable or returns
7
- * an unexpected response, the operation fails rather than guessing.
8
- *
9
- * BLOCKED: The management socket protocol is defined in ours-cowork but
10
- * the exact prerelease wire format needs verification. This adapter
11
- * defines the interface Fleet needs and provides a fail-closed stub
12
- * that reports the blocker. Replace the stub with socket calls once
13
- * the Cowork management socket contract is verified.
4
+ * Cowork owns room state. Fleet only invokes the versioned JSON RPC exposed
5
+ * by Cowork's Unix socket and projects the response into orchestration data.
14
6
  */
7
+ import type { Socket } from 'node:net';
15
8
  export interface CoworkRoomCreateResult {
16
9
  room_id: string;
17
10
  identity_name: string;
@@ -44,8 +37,8 @@ export interface CoworkAdapter {
44
37
  available(): Promise<boolean>;
45
38
  createRoom(opts: {
46
39
  room_name: string;
47
- goal?: string;
48
- briefing?: string;
40
+ goal: string;
41
+ briefing: string;
49
42
  quiet_membership?: boolean;
50
43
  anonymous?: boolean;
51
44
  }): Promise<CoworkRoomCreateResult>;
@@ -57,24 +50,28 @@ export interface CoworkAdapter {
57
50
  role: string;
58
51
  min_accepts: number;
59
52
  }): Promise<CoworkInviteResult>;
60
- acceptAsIdentity(roomId: string, invite: string, identityCid: string, opts: {
61
- role: string;
62
- }): Promise<CoworkInviteAcceptResult>;
63
53
  getRoom(roomId: string): Promise<CoworkRoomInfo | undefined>;
64
54
  listRooms(): Promise<CoworkRoomInfo[]>;
65
55
  closeRoom(roomId: string): Promise<void>;
66
56
  getSeats(roomId: string): Promise<CoworkSeatInfo[]>;
57
+ recoverRoom(roomId: string): Promise<CoworkRoomInfo>;
67
58
  }
68
59
  export declare class CoworkUnavailableError extends Error {
69
- constructor(message?: string);
60
+ constructor(message?: string, options?: ErrorOptions);
70
61
  }
71
62
  export declare class CoworkProtocolError extends Error {
72
63
  readonly operation: string;
73
- constructor(operation: string, message: string);
64
+ readonly code: string;
65
+ constructor(operation: string, message: string, code?: string, options?: ErrorOptions);
74
66
  }
75
- /**
76
- * Fail-closed stub adapter. Every method throws CoworkUnavailableError.
77
- * This is the correct behavior until the Cowork management socket
78
- * integration is verified against the prerelease wire format.
79
- */
80
- export declare function createStubAdapter(): CoworkAdapter;
67
+ export interface CoworkAdapterOptions {
68
+ configPath?: string;
69
+ socketPath?: string;
70
+ timeoutMs?: number;
71
+ env?: NodeJS.ProcessEnv;
72
+ home?: string;
73
+ connect?: (path: string) => Socket;
74
+ }
75
+ /** Resolve the same config/state inputs as ours-cowork and return its Unix socket. */
76
+ export declare function resolveCoworkSocketPath(options?: CoworkAdapterOptions): string;
77
+ export declare function createCoworkAdapter(options?: CoworkAdapterOptions): CoworkAdapter;
@@ -1,48 +1,272 @@
1
1
  /**
2
- * Cowork private management-socket adapter.
2
+ * Typed client for the ours-cowork v1 private management-socket protocol.
3
3
  *
4
- * Fleet orchestrates tasks/rooms but Cowork owns room state. This adapter
5
- * communicates with Cowork via its Unix management socket using versioned
6
- * JSON RPC. All methods fail closed: if Cowork is unreachable or returns
7
- * an unexpected response, the operation fails rather than guessing.
8
- *
9
- * BLOCKED: The management socket protocol is defined in ours-cowork but
10
- * the exact prerelease wire format needs verification. This adapter
11
- * defines the interface Fleet needs and provides a fail-closed stub
12
- * that reports the blocker. Replace the stub with socket calls once
13
- * the Cowork management socket contract is verified.
4
+ * Cowork owns room state. Fleet only invokes the versioned JSON RPC exposed
5
+ * by Cowork's Unix socket and projects the response into orchestration data.
14
6
  */
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { join, resolve } from 'node:path';
10
+ import { createConnection } from 'node:net';
11
+ const RPC_VERSION = 1;
12
+ const DEFAULT_TIMEOUT_MS = 10_000;
13
+ const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
15
14
  export class CoworkUnavailableError extends Error {
16
- constructor(message = 'Cowork management socket is not reachable') {
17
- super(message);
15
+ constructor(message = 'Cowork management socket is not reachable', options) {
16
+ super(message, options);
17
+ this.name = 'CoworkUnavailableError';
18
18
  }
19
19
  }
20
20
  export class CoworkProtocolError extends Error {
21
21
  operation;
22
- constructor(operation, message) {
23
- super(`cowork ${operation}: ${message}`);
22
+ code;
23
+ constructor(operation, message, code = 'protocol_error', options) {
24
+ super(`cowork ${operation}: ${message}`, options);
24
25
  this.operation = operation;
26
+ this.code = code;
27
+ this.name = 'CoworkProtocolError';
25
28
  }
26
29
  }
27
- /**
28
- * Fail-closed stub adapter. Every method throws CoworkUnavailableError.
29
- * This is the correct behavior until the Cowork management socket
30
- * integration is verified against the prerelease wire format.
31
- */
32
- export function createStubAdapter() {
33
- const unavailable = (op) => {
34
- throw new CoworkUnavailableError(`Cowork adapter not yet connected: ${op}. ` +
35
- 'Blocker: Cowork management socket wire format must be verified against ours-cowork/prerelease.');
30
+ function object(value, operation, label) {
31
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
32
+ throw new CoworkProtocolError(operation, `${label} must be an object`);
33
+ return value;
34
+ }
35
+ function string(value, operation, label) {
36
+ if (typeof value !== 'string' || value.length === 0)
37
+ throw new CoworkProtocolError(operation, `${label} must be a non-empty string`);
38
+ return value;
39
+ }
40
+ function text(value, operation, label) {
41
+ if (typeof value !== 'string')
42
+ throw new CoworkProtocolError(operation, `${label} must be a string`);
43
+ return value;
44
+ }
45
+ function roomState(value, operation) {
46
+ if (value !== 'provisioning' && value !== 'active' && value !== 'closing' && value !== 'closed')
47
+ throw new CoworkProtocolError(operation, 'room state is invalid');
48
+ return value;
49
+ }
50
+ function seatState(value, operation) {
51
+ if (value !== 'pending' && value !== 'active' && value !== 'removed')
52
+ throw new CoworkProtocolError(operation, 'seat state is invalid');
53
+ return value;
54
+ }
55
+ function projectSeat(value, operation) {
56
+ const seat = object(value, operation, 'seat');
57
+ return {
58
+ identity_cid: string(seat.identity, operation, 'seat.identity'),
59
+ role: string(seat.role, operation, 'seat.role'),
60
+ seat_state: seatState(seat.state, operation),
36
61
  };
62
+ }
63
+ function projectRoom(value, operation) {
64
+ const room = object(value, operation, 'room');
65
+ if (!Array.isArray(room.seats))
66
+ throw new CoworkProtocolError(operation, 'room.seats must be an array');
67
+ const mission = room.mission === undefined ? undefined : object(room.mission, operation, 'room.mission');
68
+ return {
69
+ room_id: string(room.room_id, operation, 'room.room_id'),
70
+ identity_name: string(room.identity_name, operation, 'room.identity_name'),
71
+ // Cowork reserves an empty CID only for its durable packet_pending
72
+ // recovery sentinel; list/show must preserve that recoverable state.
73
+ identity_cid: text(room.identity_cid, operation, 'room.identity_cid'),
74
+ room_name: string(room.room_name, operation, 'room.room_name'),
75
+ state: roomState(room.state, operation),
76
+ seats: room.seats.map((seat) => projectSeat(seat, operation)),
77
+ ...(typeof mission?.goal === 'string' ? { goal: mission.goal } : {}),
78
+ ...(typeof mission?.briefing === 'string' ? { briefing: mission.briefing } : {}),
79
+ };
80
+ }
81
+ /** Resolve the same config/state inputs as ours-cowork and return its Unix socket. */
82
+ export function resolveCoworkSocketPath(options = {}) {
83
+ if (options.socketPath)
84
+ return resolve(options.socketPath);
85
+ const env = options.env ?? process.env;
86
+ const home = options.home ?? homedir();
87
+ const configuredPath = options.configPath ?? env.OURS_COWORK_CONFIG;
88
+ const configPath = resolve(configuredPath ?? join(home, '.ours-cowork', 'config.json'));
89
+ let stateDir = resolve(home, '.ours-cowork');
90
+ if (existsSync(configPath)) {
91
+ let parsed;
92
+ try {
93
+ parsed = JSON.parse(readFileSync(configPath, 'utf8'));
94
+ }
95
+ catch (error) {
96
+ throw new CoworkProtocolError('config', `malformed config at ${configPath}`, 'invalid_config', { cause: error });
97
+ }
98
+ const config = object(parsed, 'config', 'config');
99
+ if (config.version !== 1 || typeof config.stateDir !== 'string' || !config.stateDir)
100
+ throw new CoworkProtocolError('config', `invalid Cowork v1 config at ${configPath}`, 'invalid_config');
101
+ stateDir = resolve(config.stateDir);
102
+ }
103
+ else if (configuredPath !== undefined) {
104
+ throw new CoworkProtocolError('config', `configured Cowork config does not exist: ${configPath}`, 'invalid_config');
105
+ }
106
+ if (env.OURS_COWORK_STATE_DIR)
107
+ stateDir = resolve(env.OURS_COWORK_STATE_DIR);
108
+ return join(stateDir, 'management.sock');
109
+ }
110
+ function rpcCall(socketPath, method, params, timeoutMs, connect) {
111
+ const id = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
112
+ const request = `${JSON.stringify({ version: RPC_VERSION, id, method, params })}\n`;
113
+ return new Promise((resolveCall, rejectCall) => {
114
+ const socket = connect(socketPath);
115
+ let bytes = '';
116
+ let size = 0;
117
+ let settled = false;
118
+ let connected = false;
119
+ const finishError = (error) => {
120
+ if (settled)
121
+ return;
122
+ settled = true;
123
+ clearTimeout(timer);
124
+ socket.destroy();
125
+ rejectCall(error);
126
+ };
127
+ const timer = setTimeout(() => finishError(new CoworkUnavailableError(connected ? 'Cowork did not answer the management socket' : 'Cowork management socket is not reachable')), timeoutMs);
128
+ socket.setEncoding('utf8');
129
+ socket.once('connect', () => {
130
+ connected = true;
131
+ socket.write(request);
132
+ });
133
+ socket.on('data', (chunk) => {
134
+ if (settled)
135
+ return;
136
+ const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
137
+ size += Buffer.byteLength(text);
138
+ if (size > MAX_RESPONSE_BYTES) {
139
+ finishError(new CoworkProtocolError(method, 'response exceeded 4 MiB'));
140
+ return;
141
+ }
142
+ bytes += text;
143
+ const newline = bytes.indexOf('\n');
144
+ if (newline < 0)
145
+ return;
146
+ if (bytes.slice(newline + 1).trim() !== '') {
147
+ finishError(new CoworkProtocolError(method, 'daemon returned more than one response'));
148
+ return;
149
+ }
150
+ let response;
151
+ try {
152
+ response = object(JSON.parse(bytes.slice(0, newline)), method, 'RPC response');
153
+ }
154
+ catch (error) {
155
+ finishError(error instanceof CoworkProtocolError
156
+ ? error : new CoworkProtocolError(method, 'daemon returned malformed JSON', 'protocol_error', { cause: error }));
157
+ return;
158
+ }
159
+ if (response.version !== RPC_VERSION || response.id !== id
160
+ || (Object.hasOwn(response, 'error') === Object.hasOwn(response, 'result'))) {
161
+ finishError(new CoworkProtocolError(method, 'daemon returned an invalid RPC response'));
162
+ return;
163
+ }
164
+ settled = true;
165
+ clearTimeout(timer);
166
+ socket.destroy();
167
+ if (Object.hasOwn(response, 'error')) {
168
+ const error = object(response.error, method, 'RPC error');
169
+ rejectCall(new CoworkProtocolError(method, typeof error.message === 'string' ? error.message : 'unknown RPC error', typeof error.code === 'string' ? error.code : 'rpc_error'));
170
+ }
171
+ else {
172
+ resolveCall(response.result);
173
+ }
174
+ });
175
+ socket.once('end', () => {
176
+ if (!settled)
177
+ finishError(new CoworkProtocolError(method, 'daemon closed without a complete response'));
178
+ });
179
+ socket.once('error', (error) => {
180
+ if (!settled)
181
+ finishError(new CoworkUnavailableError(error.code === 'EACCES' || error.code === 'EPERM'
182
+ ? 'Cowork management socket access denied' : 'Cowork management socket is not reachable', { cause: error }));
183
+ });
184
+ });
185
+ }
186
+ export function createCoworkAdapter(options = {}) {
187
+ const socketPath = resolveCoworkSocketPath(options);
188
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
189
+ const connect = options.connect ?? ((path) => createConnection(path));
190
+ const call = (method, params) => rpcCall(socketPath, method, params, timeoutMs, connect);
37
191
  return {
38
- async available() { return false; },
39
- async createRoom() { return unavailable('createRoom'); },
40
- async acceptInvite() { return unavailable('acceptInvite'); },
41
- async issueInvite() { return unavailable('issueInvite'); },
42
- async acceptAsIdentity() { return unavailable('acceptAsIdentity'); },
43
- async getRoom() { return unavailable('getRoom'); },
44
- async listRooms() { return unavailable('listRooms'); },
45
- async closeRoom() { return unavailable('closeRoom'); },
46
- async getSeats() { return unavailable('getSeats'); },
192
+ async available() {
193
+ try {
194
+ await call('room.list', {});
195
+ return true;
196
+ }
197
+ catch {
198
+ return false;
199
+ }
200
+ },
201
+ async createRoom(opts) {
202
+ const result = projectRoom(await call('room.create', {
203
+ name: opts.room_name,
204
+ goal: opts.goal,
205
+ briefing: opts.briefing,
206
+ ...(opts.quiet_membership === undefined ? {} : { quiet_membership: opts.quiet_membership }),
207
+ ...(opts.anonymous === undefined ? {} : { anonymous: opts.anonymous }),
208
+ }), 'room.create');
209
+ if (!result.identity_cid)
210
+ throw new CoworkProtocolError('room.create', 'created room did not establish an identity CID');
211
+ return { room_id: result.room_id, identity_name: result.identity_name, identity_cid: result.identity_cid };
212
+ },
213
+ async acceptInvite(roomId, invite, opts) {
214
+ const receipt = object(await call('room.accept', {
215
+ room_id: roomId,
216
+ role: opts.role,
217
+ invite,
218
+ expected_cid: opts.expected_cid,
219
+ }), 'room.accept', 'receipt');
220
+ const state = seatState(receipt.state, 'room.accept');
221
+ if (state === 'removed')
222
+ throw new CoworkProtocolError('room.accept', 'accepted seat cannot be removed');
223
+ return {
224
+ seat_cid: string(receipt.identity, 'room.accept', 'receipt.identity'),
225
+ seat_state: state,
226
+ };
227
+ },
228
+ async issueInvite(roomId, opts) {
229
+ const receipt = object(await call('room.invite', {
230
+ room_id: roomId,
231
+ mode: 'public',
232
+ role: opts.role,
233
+ min_accepts: opts.min_accepts,
234
+ }), 'room.invite', 'receipt');
235
+ const invite = object(receipt.invite, 'room.invite', 'receipt.invite');
236
+ return {
237
+ invite: string(receipt.blob, 'room.invite', 'receipt.blob'),
238
+ min_accepts: typeof invite.min_accepts === 'number'
239
+ ? invite.min_accepts : opts.min_accepts,
240
+ };
241
+ },
242
+ async getRoom(roomId) {
243
+ try {
244
+ return projectRoom(await call('room.show', { room_id: roomId }), 'room.show');
245
+ }
246
+ catch (error) {
247
+ if (error instanceof CoworkProtocolError && error.code === 'not_found')
248
+ return undefined;
249
+ throw error;
250
+ }
251
+ },
252
+ async listRooms() {
253
+ const result = await call('room.list', {});
254
+ if (!Array.isArray(result))
255
+ throw new CoworkProtocolError('room.list', 'result must be an array');
256
+ return result.map((room) => projectRoom(room, 'room.list'));
257
+ },
258
+ async closeRoom(roomId) { await call('room.close', { room_id: roomId }); },
259
+ async getSeats(roomId) {
260
+ const result = await call('room.participants', { room_id: roomId });
261
+ if (!Array.isArray(result))
262
+ throw new CoworkProtocolError('room.participants', 'result must be an array');
263
+ return result.map((seat) => projectSeat(seat, 'room.participants'));
264
+ },
265
+ async recoverRoom(roomId) {
266
+ // Cowork performs packet/state reconciliation during daemon recovery.
267
+ // Its `room.recover` RPC is specifically invite-receipt recovery, so a
268
+ // Fleet reconciliation pass must read `room.show`, not mutate invites.
269
+ return projectRoom(await call('room.show', { room_id: roomId }), 'room.show');
270
+ },
47
271
  };
48
272
  }
@@ -5,6 +5,7 @@ export declare class RoomStateError extends Error {
5
5
  export interface CreateRoomInput {
6
6
  room_id: string;
7
7
  room_name: string;
8
+ room_identity_cid?: string;
8
9
  task_id?: string;
9
10
  template_snapshot?: import('./types.js').TemplateSnapshot;
10
11
  }
@@ -23,6 +23,7 @@ export function createRoomRecord(input) {
23
23
  const record = {
24
24
  room_id: input.room_id,
25
25
  room_name: input.room_name,
26
+ room_identity_cid: input.room_identity_cid,
26
27
  task_id: input.task_id,
27
28
  template_snapshot: input.template_snapshot,
28
29
  saga: { phase: 'persist_intent', step_index: 0 },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "0.19.0-nightly.2",
3
+ "version": "0.19.0-nightly.3",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux or ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",