@ours.network/fleet 0.18.1 → 0.19.0-nightly.10

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 (90) hide show
  1. package/README.md +86 -90
  2. package/dist/application/fleet-query-service.js +0 -12
  3. package/dist/application/role-creation-service.js +10 -7
  4. package/dist/application/types.d.ts +0 -11
  5. package/dist/briefing.js +6 -15
  6. package/dist/build-info.json +5 -5
  7. package/dist/cli.js +12 -37
  8. package/dist/config.d.ts +14 -6
  9. package/dist/config.js +58 -31
  10. package/dist/creation.d.ts +38 -22
  11. package/dist/creation.js +111 -24
  12. package/dist/docs.d.ts +1 -1
  13. package/dist/docs.js +71 -94
  14. package/dist/doctor.d.ts +5 -1
  15. package/dist/doctor.js +143 -12
  16. package/dist/fleet-proxy.d.ts +0 -5
  17. package/dist/harness/acp-agent.js +6 -11
  18. package/dist/harness/claude-code.js +11 -200
  19. package/dist/harness/codex.d.ts +1 -4
  20. package/dist/harness/codex.js +12 -70
  21. package/dist/harness/types.d.ts +4 -54
  22. package/dist/loops/manager.d.ts +1 -30
  23. package/dist/loops/manager.js +6 -69
  24. package/dist/loops/state.d.ts +0 -18
  25. package/dist/loops/state.js +0 -4
  26. package/dist/monitor.js +1 -1
  27. package/dist/ops.d.ts +3 -0
  28. package/dist/ops.js +8 -3
  29. package/dist/owner-channel/attachments.d.ts +25 -2
  30. package/dist/owner-channel/attachments.js +61 -5
  31. package/dist/owner-channel/channel.d.ts +36 -30
  32. package/dist/owner-channel/channel.js +338 -291
  33. package/dist/owner-channel/commands.d.ts +9 -0
  34. package/dist/owner-channel/commands.js +393 -0
  35. package/dist/owner-channel/message-recovery.d.ts +25 -0
  36. package/dist/owner-channel/message-recovery.js +114 -0
  37. package/dist/owner-channel/notices.d.ts +0 -7
  38. package/dist/owner-channel/notices.js +0 -9
  39. package/dist/owner-channel/ours-client.d.ts +148 -0
  40. package/dist/owner-channel/ours-client.js +231 -0
  41. package/dist/rooms-tasks/cli.d.ts +4 -0
  42. package/dist/rooms-tasks/cli.js +1102 -0
  43. package/dist/rooms-tasks/close.d.ts +21 -0
  44. package/dist/rooms-tasks/close.js +159 -0
  45. package/dist/rooms-tasks/config.d.ts +15 -0
  46. package/dist/rooms-tasks/config.js +174 -0
  47. package/dist/rooms-tasks/cowork-adapter.d.ts +77 -0
  48. package/dist/rooms-tasks/cowork-adapter.js +272 -0
  49. package/dist/rooms-tasks/external-worker.d.ts +2 -0
  50. package/dist/rooms-tasks/external-worker.js +40 -0
  51. package/dist/rooms-tasks/index.d.ts +10 -0
  52. package/dist/rooms-tasks/index.js +10 -0
  53. package/dist/rooms-tasks/provision.d.ts +21 -0
  54. package/dist/rooms-tasks/provision.js +243 -0
  55. package/dist/rooms-tasks/room-state.d.ts +28 -0
  56. package/dist/rooms-tasks/room-state.js +221 -0
  57. package/dist/rooms-tasks/task-state.d.ts +43 -0
  58. package/dist/rooms-tasks/task-state.js +277 -0
  59. package/dist/rooms-tasks/templates.d.ts +6 -0
  60. package/dist/rooms-tasks/templates.js +94 -0
  61. package/dist/rooms-tasks/terminal.d.ts +23 -0
  62. package/dist/rooms-tasks/terminal.js +53 -0
  63. package/dist/rooms-tasks/types.d.ts +187 -0
  64. package/dist/rooms-tasks/types.js +20 -0
  65. package/dist/runner.d.ts +0 -48
  66. package/dist/runner.js +94 -236
  67. package/dist/session/acp.d.ts +0 -104
  68. package/dist/session/acp.js +10 -213
  69. package/dist/session/conversation-normalizer.d.ts +0 -6
  70. package/dist/session/conversation-normalizer.js +10 -153
  71. package/dist/session/conversation-types.d.ts +4 -23
  72. package/dist/session/types.d.ts +0 -35
  73. package/dist/spawn.js +26 -33
  74. package/dist/supervisor/systemd.js +29 -2
  75. package/dist/temp-lifecycle.d.ts +9 -2
  76. package/dist/temp-lifecycle.js +61 -0
  77. package/dist/watchdog/briefing.js +0 -7
  78. package/dist/watchdog/run.js +3 -3
  79. package/dist/web-app/assets/{TerminalView-C_G1ID2P.js → TerminalView-BAVk1Bot.js} +1 -1
  80. package/dist/web-app/assets/{index-BCBK78hw.js → index-C3S-xFRU.js} +5 -5
  81. package/dist/web-app/index.html +1 -1
  82. package/dist/worklog.d.ts +1 -7
  83. package/dist/worklog.js +39 -191
  84. package/package.json +3 -1
  85. package/dist/model-env.d.ts +0 -71
  86. package/dist/model-env.js +0 -106
  87. package/dist/owner-channel/mcp.d.ts +0 -24
  88. package/dist/owner-channel/mcp.js +0 -145
  89. package/dist/session/activity.d.ts +0 -31
  90. package/dist/session/activity.js +0 -48
@@ -0,0 +1,1102 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { loadConfig, ConfigError } from '../config.js';
3
+ import { provisionMembers, getBinPath } from './provision.js';
4
+ import { acceptManagedRoomClose, closeManagedRoom, recordManagedRoomCloseError, } from './close.js';
5
+ import { acceptTaskTerminalIntent, recordTaskTerminalIntentError, settleTaskTerminalIntent, } from './terminal.js';
6
+ import { launchFleetWorker } from './external-worker.js';
7
+ import { resolveTemplate, listTemplates, snapshotTemplate, hashTemplate, } from './templates.js';
8
+ import { createTask, getTask, listTasks, startTask, activateTask, blockTask, unblockTask, reviewTask, updateTaskRoom, updateTaskTemplate, TaskStateError, } from './task-state.js';
9
+ import { createRoomRecord, getRoomRecord, listRoomRecords, advanceSaga, setOwnerSeat, setSagaError, activateRoom, RoomStateError, } from './room-state.js';
10
+ import { createCoworkAdapter, CoworkProtocolError, CoworkUnavailableError } from './cowork-adapter.js';
11
+ import { TASK_CANCELLABLE_STATES, TASK_TERMINAL_STATES } from './types.js';
12
+ function die(e) {
13
+ const msg = e instanceof Error ? e.message : String(e);
14
+ process.stderr.write(`error: ${msg}\n`);
15
+ process.exit(1);
16
+ }
17
+ const PUBLIC_SETTLE_WAIT_MS = 60_000;
18
+ const PUBLIC_SETTLE_POLL_MS = 100;
19
+ const sleep = (ms) => new Promise(resolve => { setTimeout(resolve, ms); });
20
+ function errorText(error) {
21
+ return error instanceof Error ? error.message : String(error);
22
+ }
23
+ async function launchTaskSettleWorker(taskId, configPath) {
24
+ const previousErrorAt = getTask(taskId).terminal_intent?.error_at;
25
+ try {
26
+ await launchFleetWorker(['task', '_settle', taskId], `task-settle-${taskId}`, configPath);
27
+ }
28
+ catch (error) {
29
+ await recordTaskTerminalIntentError(taskId, errorText(error), `External settle worker failed to start. Retry task recover ${taskId}.`);
30
+ throw error;
31
+ }
32
+ const deadline = Date.now() + PUBLIC_SETTLE_WAIT_MS;
33
+ while (Date.now() < deadline) {
34
+ const task = getTask(taskId);
35
+ if (task.terminal_intent?.status === 'settled')
36
+ return { task, timedOut: false };
37
+ if (task.terminal_intent?.error_at !== previousErrorAt && task.terminal_intent?.error) {
38
+ throw new TaskStateError(task.terminal_intent.error);
39
+ }
40
+ await sleep(PUBLIC_SETTLE_POLL_MS);
41
+ }
42
+ return { task: getTask(taskId), timedOut: true };
43
+ }
44
+ async function launchRoomCloseWorker(roomId, configPath) {
45
+ const previousErrorAt = getRoomRecord(roomId)?.close?.error_at;
46
+ try {
47
+ await launchFleetWorker(['room', '_close', roomId], `room-close-${roomId}`, configPath);
48
+ }
49
+ catch (error) {
50
+ await recordManagedRoomCloseError(roomId, errorText(error), `External close worker failed to start. Retry room recover ${roomId}.`);
51
+ throw error;
52
+ }
53
+ const deadline = Date.now() + PUBLIC_SETTLE_WAIT_MS;
54
+ while (Date.now() < deadline) {
55
+ const room = getRoomRecord(roomId);
56
+ if (room.state === 'closed')
57
+ return { room, timedOut: false };
58
+ if (room.close?.error_at !== previousErrorAt && room.close?.error) {
59
+ throw new RoomStateError(room.close.error);
60
+ }
61
+ await sleep(PUBLIC_SETTLE_POLL_MS);
62
+ }
63
+ return { room: getRoomRecord(roomId), timedOut: true };
64
+ }
65
+ function loadCfg(opts) {
66
+ return loadConfig(opts.configuration);
67
+ }
68
+ function allTemplates(cfg) {
69
+ return cfg.roomTemplates ?? {};
70
+ }
71
+ function coworkFor(cfg) {
72
+ if (!cfg.rooms)
73
+ throw new ConfigError('rooms: configuration is required before creating or querying rooms');
74
+ return createCoworkAdapter({ configPath: cfg.rooms.cowork?.config });
75
+ }
76
+ function resolveRoomTemplate(cfg, name) {
77
+ if (!name)
78
+ return undefined;
79
+ const template = resolveTemplate(name, allTemplates(cfg));
80
+ if (!template)
81
+ throw new Error(`template not found: ${name}`);
82
+ return snapshotTemplate(template);
83
+ }
84
+ async function provisionRoom(cfg, input) {
85
+ const rooms = cfg.rooms;
86
+ if (!rooms)
87
+ throw new ConfigError('rooms: configuration is required');
88
+ const attachOwner = rooms.defaults?.attach_owner !== false;
89
+ if (attachOwner && !cfg.ownerInvite)
90
+ throw new ConfigError('rooms.owner: public_invite or public_invite_file is required when attach_owner is enabled');
91
+ const cowork = coworkFor(cfg);
92
+ const goal = input.goal?.trim() || input.name;
93
+ const briefing = input.brief?.trim() || input.template?.contract?.trim() || goal;
94
+ const created = await cowork.createRoom({
95
+ room_name: input.name,
96
+ goal,
97
+ briefing,
98
+ quiet_membership: input.template?.room?.quiet_membership,
99
+ anonymous: input.template?.room?.anonymous,
100
+ });
101
+ let record = createRoomRecord({
102
+ room_id: created.room_id,
103
+ room_name: input.name,
104
+ room_identity_cid: created.identity_cid,
105
+ task_id: input.taskId,
106
+ template_snapshot: input.template,
107
+ });
108
+ input.onCreated?.(record);
109
+ record = advanceSaga(record.room_id, 'create_room', 1);
110
+ if (attachOwner) {
111
+ try {
112
+ record = advanceSaga(record.room_id, 'attach_owner', 2);
113
+ const accepted = await cowork.acceptInvite(record.room_id, cfg.ownerInvite, {
114
+ role: rooms.owner.role,
115
+ expected_cid: rooms.owner.expected_cid,
116
+ });
117
+ record = setOwnerSeat(record.room_id, accepted.seat_cid, cfg.ownerInviteFingerprint ?? '');
118
+ }
119
+ catch (error) {
120
+ const mismatch = error instanceof CoworkProtocolError && /CID|expected/i.test(error.message);
121
+ setSagaError(record.room_id, error instanceof Error ? error.message : String(error), mismatch
122
+ ? 'Verify rooms.owner.expected_cid and rotate the configured invite if necessary.'
123
+ : 'Rotate rooms.owner.public_invite or public_invite_file, then run room recover.', mismatch ? 'owner_cid_mismatch' : 'waiting_owner_invite');
124
+ throw error;
125
+ }
126
+ }
127
+ record = advanceSaga(record.room_id, 'create_members', 3);
128
+ if (input.template && input.template.members.length > 0) {
129
+ record = await provisionMembers({
130
+ cfg,
131
+ cowork,
132
+ roomId: record.room_id,
133
+ taskId: input.taskId,
134
+ template: input.template,
135
+ binPath: getBinPath(),
136
+ brief: input.brief,
137
+ goal: input.goal,
138
+ });
139
+ }
140
+ else {
141
+ record = activateRoom(record.room_id);
142
+ if (input.taskId)
143
+ activateTask(input.taskId);
144
+ }
145
+ return record;
146
+ }
147
+ export function registerTemplateCommands(parent, cOpt) {
148
+ const templateCmd = parent.command('template').description('room template operations');
149
+ cOpt(templateCmd.command('list'))
150
+ .description('list available room templates')
151
+ .option('--json', 'JSON output')
152
+ .action((opts) => {
153
+ try {
154
+ const cfg = loadCfg(opts);
155
+ const templates = listTemplates(allTemplates(cfg));
156
+ if (opts.json) {
157
+ console.log(JSON.stringify({ schema_version: 1, templates }, null, 2));
158
+ return;
159
+ }
160
+ if (!templates.length) {
161
+ console.log('No templates configured.');
162
+ return;
163
+ }
164
+ for (const t of templates) {
165
+ const tag = t.builtin ? ' (built-in)' : '';
166
+ console.log(`${t.name}@${t.version}${tag} ${t.description}`);
167
+ }
168
+ }
169
+ catch (e) {
170
+ die(e);
171
+ }
172
+ });
173
+ cOpt(templateCmd.command('show <name>'))
174
+ .description('show template details')
175
+ .option('--json', 'JSON output')
176
+ .action((name, opts) => {
177
+ try {
178
+ const cfg = loadCfg(opts);
179
+ const t = resolveTemplate(name, allTemplates(cfg));
180
+ if (!t)
181
+ die(new Error(`template not found: ${name}`));
182
+ if (opts.json) {
183
+ console.log(JSON.stringify({ schema_version: 1, template: { ...t, content_hash: hashTemplate(t) } }, null, 2));
184
+ return;
185
+ }
186
+ console.log(`${t.name}@${t.version} ${t.description}`);
187
+ if (t.contract)
188
+ console.log(`\nContract:\n${t.contract}`);
189
+ console.log(`\nMembers:`);
190
+ for (const m of t.members)
191
+ console.log(` ${m.slot}: ${m.count}× ${m.role} (ref: ${m.role_ref})`);
192
+ console.log(`\nContent hash: ${hashTemplate(t)}`);
193
+ }
194
+ catch (e) {
195
+ die(e);
196
+ }
197
+ });
198
+ cOpt(templateCmd.command('validate'))
199
+ .description('validate all room templates')
200
+ .option('--json', 'JSON output')
201
+ .action((opts) => {
202
+ try {
203
+ const cfg = loadCfg(opts);
204
+ const templates = listTemplates(allTemplates(cfg));
205
+ const problems = [];
206
+ for (const t of templates) {
207
+ const issues = [];
208
+ for (const m of t.members) {
209
+ if (!cfg.roles.some(r => r.name === m.role_ref))
210
+ issues.push(`member ${m.slot}: role_ref '${m.role_ref}' not found in fleet roles`);
211
+ }
212
+ if (issues.length)
213
+ problems.push({ template: `${t.name}@${t.version}`, issues });
214
+ }
215
+ if (opts.json) {
216
+ console.log(JSON.stringify({ schema_version: 1, valid: !problems.length, problems }, null, 2));
217
+ return;
218
+ }
219
+ if (!problems.length) {
220
+ console.log('All templates valid.');
221
+ return;
222
+ }
223
+ for (const p of problems) {
224
+ console.log(`${p.template}:`);
225
+ for (const i of p.issues)
226
+ console.log(` - ${i}`);
227
+ }
228
+ process.exit(1);
229
+ }
230
+ catch (e) {
231
+ die(e);
232
+ }
233
+ });
234
+ }
235
+ export function registerTaskCommands(parent, cOpt) {
236
+ const taskCmd = parent.command('task').description('task lifecycle operations');
237
+ cOpt(taskCmd.command('create'))
238
+ .description('create a new task')
239
+ .requiredOption('--title <title>', 'task title')
240
+ .option('--template <name>', 'room template')
241
+ .option('--brief <text>', 'task brief')
242
+ .option('--brief-file <path>', 'task brief from file')
243
+ .option('--backlog', 'create in backlog (do not start immediately)')
244
+ .option('--no-room', 'create task without a room')
245
+ .option('--idempotency-key <key>', 'idempotency key')
246
+ .option('--json', 'JSON output')
247
+ .action(async (opts) => {
248
+ try {
249
+ const cfg = loadCfg(opts);
250
+ let templateRef;
251
+ if (opts.template) {
252
+ const t = resolveTemplate(opts.template, allTemplates(cfg));
253
+ if (!t)
254
+ die(new Error(`template not found: ${opts.template}`));
255
+ const snap = snapshotTemplate(t);
256
+ templateRef = { name: snap.name, version: snap.version, content_hash: snap.content_hash };
257
+ }
258
+ else if (opts.room !== false) {
259
+ const defaultTpl = cfg.tasks?.default_room_template ?? cfg.rooms?.defaults?.template ?? 'team';
260
+ const t = resolveTemplate(defaultTpl, allTemplates(cfg));
261
+ if (t) {
262
+ const snap = snapshotTemplate(t);
263
+ templateRef = { name: snap.name, version: snap.version, content_hash: snap.content_hash };
264
+ }
265
+ }
266
+ let brief = opts.brief;
267
+ if (opts.briefFile) {
268
+ brief = readFileSync(opts.briefFile, 'utf8');
269
+ }
270
+ const origin = { type: 'cli' };
271
+ let record = createTask({
272
+ title: opts.title,
273
+ brief,
274
+ brief_file: opts.briefFile,
275
+ template: templateRef,
276
+ origin,
277
+ idempotency_key: opts.idempotencyKey,
278
+ start: !opts.backlog,
279
+ });
280
+ if (!opts.backlog && opts.room !== false && templateRef && !record.room_id) {
281
+ const template = resolveRoomTemplate(cfg, templateRef.name);
282
+ try {
283
+ await provisionRoom(cfg, {
284
+ name: opts.title,
285
+ goal: opts.title,
286
+ brief,
287
+ template,
288
+ taskId: record.task_id,
289
+ onCreated: room => {
290
+ record = updateTaskRoom(record.task_id, room.room_id, room.room_identity_cid);
291
+ },
292
+ });
293
+ record = getTask(record.task_id);
294
+ }
295
+ catch (error) {
296
+ if (error instanceof CoworkUnavailableError) {
297
+ blockTask(record.task_id, 'Cowork management socket is unavailable');
298
+ }
299
+ throw error;
300
+ }
301
+ }
302
+ if (opts.json) {
303
+ console.log(JSON.stringify({ schema_version: 1, task: record }, null, 2));
304
+ return;
305
+ }
306
+ console.log(`Task ${record.task_id} created · ${record.state}`);
307
+ if (templateRef)
308
+ console.log(`Template: ${templateRef.name}@${templateRef.version}`);
309
+ }
310
+ catch (e) {
311
+ die(e);
312
+ }
313
+ });
314
+ cOpt(taskCmd.command('list'))
315
+ .description('list tasks')
316
+ .option('--state <state>', 'filter by state (backlog|active|provisioning|review|done|cancelled|failed|all)')
317
+ .option('--json', 'JSON output')
318
+ .action((opts) => {
319
+ try {
320
+ let stateFilter;
321
+ if (opts.state && opts.state !== 'all') {
322
+ stateFilter = opts.state;
323
+ }
324
+ const tasks = listTasks(stateFilter ? { state: stateFilter } : undefined);
325
+ if (opts.json) {
326
+ console.log(JSON.stringify({ schema_version: 1, tasks }, null, 2));
327
+ return;
328
+ }
329
+ if (!tasks.length) {
330
+ console.log('No tasks.');
331
+ return;
332
+ }
333
+ for (const t of tasks) {
334
+ const blocked = t.blocked ? ` [BLOCKED: ${t.blocked.reason}]` : '';
335
+ console.log(`${t.task_id} ${t.state}${blocked} ${t.title}`);
336
+ }
337
+ }
338
+ catch (e) {
339
+ die(e);
340
+ }
341
+ });
342
+ taskCmd.command('show <id>')
343
+ .description('show task details')
344
+ .option('--json', 'JSON output')
345
+ .action((id, opts) => {
346
+ try {
347
+ const t = getTask(id);
348
+ if (opts.json) {
349
+ console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
350
+ return;
351
+ }
352
+ console.log(`Task: ${t.task_id}`);
353
+ console.log(`Title: ${t.title}`);
354
+ console.log(`State: ${t.state}${t.blocked ? ` [BLOCKED: ${t.blocked.reason}]` : ''}`);
355
+ if (t.template)
356
+ console.log(`Template: ${t.template.name}@${t.template.version}`);
357
+ if (t.room_id)
358
+ console.log(`Room: ${t.room_id}`);
359
+ if (t.room_identity_cid)
360
+ console.log(`Room CID: ${t.room_identity_cid}`);
361
+ if (t.member_roles.length) {
362
+ console.log('Members:');
363
+ for (const m of t.member_roles)
364
+ console.log(` ${m.name} (${m.cowork_role}) ${m.identity_cid}`);
365
+ }
366
+ console.log(`Origin: ${t.origin.type}`);
367
+ console.log(`Created: ${t.created_at}`);
368
+ if (t.started_at)
369
+ console.log(`Started: ${t.started_at}`);
370
+ if (t.ended_at)
371
+ console.log(`Ended: ${t.ended_at}`);
372
+ if (t.outcome)
373
+ console.log(`Outcome: ${t.outcome.summary}`);
374
+ }
375
+ catch (e) {
376
+ die(e);
377
+ }
378
+ });
379
+ cOpt(taskCmd.command('start <id>'))
380
+ .description('start a backlog task')
381
+ .option('--json', 'JSON output')
382
+ .action(async (id, opts) => {
383
+ try {
384
+ const cfg = loadCfg(opts);
385
+ let t = startTask(id);
386
+ if (t.template && !t.room_id) {
387
+ const template = resolveRoomTemplate(cfg, t.template.name);
388
+ if (!template || template.content_hash !== t.template.content_hash)
389
+ throw new Error(`task template snapshot no longer matches ${t.template.name}@${t.template.version}`);
390
+ await provisionRoom(cfg, {
391
+ name: t.title,
392
+ goal: t.title,
393
+ brief: t.brief,
394
+ template,
395
+ taskId: t.task_id,
396
+ onCreated: room => {
397
+ t = updateTaskRoom(t.task_id, room.room_id, room.room_identity_cid);
398
+ },
399
+ });
400
+ t = getTask(t.task_id);
401
+ }
402
+ if (opts.json) {
403
+ console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
404
+ return;
405
+ }
406
+ console.log(`Task ${t.task_id} · provisioning`);
407
+ }
408
+ catch (e) {
409
+ die(e);
410
+ }
411
+ });
412
+ taskCmd.command('block <id>')
413
+ .description('mark a task as blocked')
414
+ .requiredOption('--reason <reason>', 'blocking reason')
415
+ .option('--json', 'JSON output')
416
+ .action((id, opts) => {
417
+ try {
418
+ const t = blockTask(id, opts.reason);
419
+ if (opts.json) {
420
+ console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
421
+ return;
422
+ }
423
+ console.log(`Task ${t.task_id} · blocked: ${opts.reason}`);
424
+ }
425
+ catch (e) {
426
+ die(e);
427
+ }
428
+ });
429
+ taskCmd.command('unblock <id>')
430
+ .description('unblock a task')
431
+ .option('--json', 'JSON output')
432
+ .action((id, opts) => {
433
+ try {
434
+ const t = unblockTask(id);
435
+ if (opts.json) {
436
+ console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
437
+ return;
438
+ }
439
+ console.log(`Task ${t.task_id} · unblocked`);
440
+ }
441
+ catch (e) {
442
+ die(e);
443
+ }
444
+ });
445
+ taskCmd.command('review <id>')
446
+ .description('move a task to review')
447
+ .option('--json', 'JSON output')
448
+ .action((id, opts) => {
449
+ try {
450
+ const t = reviewTask(id);
451
+ if (opts.json) {
452
+ console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
453
+ return;
454
+ }
455
+ console.log(`Task ${t.task_id} · review`);
456
+ }
457
+ catch (e) {
458
+ die(e);
459
+ }
460
+ });
461
+ cOpt(taskCmd.command('done <id>'))
462
+ .description('complete a task')
463
+ .option('--summary <text>', 'completion summary')
464
+ .option('--summary-file <path>', 'completion summary from file')
465
+ .option('--json', 'JSON output')
466
+ .action(async (id, opts) => {
467
+ try {
468
+ let summary = opts.summary;
469
+ if (opts.summaryFile)
470
+ summary = readFileSync(opts.summaryFile, 'utf8');
471
+ const outcome = summary ? { summary } : undefined;
472
+ const current = getTask(id);
473
+ if (TASK_TERMINAL_STATES.includes(current.state))
474
+ throw new TaskStateError(`task ${id} is already in terminal state '${current.state}'`);
475
+ if (current.state !== 'review')
476
+ throw new TaskStateError(`cannot transition from '${current.state}' to 'done'`);
477
+ let roomId;
478
+ let cfg;
479
+ if (current.room_id) {
480
+ cfg = loadCfg(opts);
481
+ const shouldClose = cfg.tasks?.close_room_on_done
482
+ ?? cfg.rooms?.defaults?.close_when_task_done
483
+ ?? false;
484
+ if (shouldClose)
485
+ roomId = current.room_id;
486
+ }
487
+ await acceptTaskTerminalIntent({ taskId: id, kind: 'done', roomId, outcome });
488
+ const settled = roomId
489
+ ? await launchTaskSettleWorker(id, opts.configuration)
490
+ : { task: getTask(id), timedOut: false };
491
+ const t = settled.task;
492
+ if (opts.json) {
493
+ console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
494
+ return;
495
+ }
496
+ if (settled.timedOut) {
497
+ console.log(`Task ${t.task_id} · ${t.state} (terminal intent accepted/pending; run task recover ${id})`);
498
+ return;
499
+ }
500
+ console.log(`Task ${t.task_id} · done`);
501
+ }
502
+ catch (e) {
503
+ die(e);
504
+ }
505
+ });
506
+ cOpt(taskCmd.command('cancel <id> <confirm-id>'))
507
+ .description('cancel a task (requires ID twice for confirmation)')
508
+ .option('--json', 'JSON output')
509
+ .action(async (id, confirmId, opts) => {
510
+ try {
511
+ if (id !== confirmId)
512
+ die(new Error('confirmation ID must match task ID'));
513
+ const current = getTask(id);
514
+ if (!TASK_CANCELLABLE_STATES.includes(current.state))
515
+ throw new TaskStateError(`cannot cancel a '${current.state}' task`);
516
+ if (current.room_id)
517
+ loadCfg(opts);
518
+ await acceptTaskTerminalIntent({
519
+ taskId: id, kind: 'cancelled', roomId: current.room_id,
520
+ });
521
+ const settled = current.room_id
522
+ ? await launchTaskSettleWorker(id, opts.configuration)
523
+ : { task: getTask(id), timedOut: false };
524
+ const t = settled.task;
525
+ if (opts.json) {
526
+ console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
527
+ return;
528
+ }
529
+ if (settled.timedOut) {
530
+ console.log(`Task ${t.task_id} · ${t.state} (terminal intent accepted/pending; run task recover ${id})`);
531
+ return;
532
+ }
533
+ console.log(`Task ${t.task_id} · cancelled`);
534
+ }
535
+ catch (e) {
536
+ die(e);
537
+ }
538
+ });
539
+ cOpt(taskCmd.command('recover <id>'))
540
+ .description('attempt to recover a stuck task')
541
+ .option('--json', 'JSON output')
542
+ .action(async (id, opts) => {
543
+ try {
544
+ const cfg = loadCfg(opts);
545
+ let t = getTask(id);
546
+ let terminalTimedOut = false;
547
+ if (t.terminal_intent?.status === 'pending') {
548
+ const settled = await launchTaskSettleWorker(id, opts.configuration);
549
+ t = settled.task;
550
+ terminalTimedOut = settled.timedOut;
551
+ }
552
+ const room = t.room_id ? getRoomRecord(t.room_id) : undefined;
553
+ const result = {
554
+ task: t,
555
+ room: room ?? null,
556
+ recovery_actions: [],
557
+ };
558
+ if (terminalTimedOut) {
559
+ result.recovery_actions.push(`Terminal intent remains pending — retry task recover ${id}`);
560
+ }
561
+ if (t.state === 'provisioning' && room) {
562
+ if (room.provisioning_detail === 'waiting_cowork')
563
+ result.recovery_actions.push('Cowork management socket unreachable — check ours-cowork service');
564
+ if (room.provisioning_detail === 'waiting_owner_invite')
565
+ result.recovery_actions.push('Owner invite invalid or expired — rotate rooms.owner.public_invite in config');
566
+ if (room.provisioning_detail === 'owner_cid_mismatch')
567
+ result.recovery_actions.push('Owner CID mismatch — verify rooms.owner.expected_cid matches Messenger identity');
568
+ if (room.provisioning_detail === 'member_failed')
569
+ result.recovery_actions.push(`Member creation failed at saga step ${room.saga.step_index} — inspect and retry`);
570
+ const resumable = ['create_members', 'join_role_groups', 'wait_seats', 'launch_work', 'activate'];
571
+ if (resumable.includes(room.saga.phase)) {
572
+ const template = t.template ? resolveRoomTemplate(cfg, t.template.name) : undefined;
573
+ if (template) {
574
+ try {
575
+ await provisionMembers({
576
+ cfg,
577
+ cowork: coworkFor(cfg),
578
+ roomId: room.room_id,
579
+ taskId: t.task_id,
580
+ template,
581
+ binPath: getBinPath(),
582
+ brief: t.brief,
583
+ goal: t.title,
584
+ });
585
+ result.recovery_actions.push('Provisioning resumed successfully');
586
+ }
587
+ catch (error) {
588
+ result.recovery_actions.push(`Resume failed: ${error instanceof Error ? error.message : String(error)}`);
589
+ }
590
+ }
591
+ }
592
+ }
593
+ if (opts.json) {
594
+ console.log(JSON.stringify({ schema_version: 1, ...result }, null, 2));
595
+ return;
596
+ }
597
+ console.log(`Task ${t.task_id} · ${t.state}`);
598
+ if (room)
599
+ console.log(`Room ${room.room_id} · ${room.state} · saga: ${room.saga.phase}`);
600
+ if (result.recovery_actions.length) {
601
+ console.log('Recovery actions:');
602
+ for (const a of result.recovery_actions)
603
+ console.log(` - ${a}`);
604
+ }
605
+ else {
606
+ console.log('No automated recovery actions available.');
607
+ }
608
+ }
609
+ catch (e) {
610
+ die(e);
611
+ }
612
+ });
613
+ cOpt(taskCmd.command('_settle <id>', { hidden: true }))
614
+ .description('internal: settle a previously accepted task terminal intent')
615
+ .option('--json', 'JSON output')
616
+ .action(async (id, opts) => {
617
+ try {
618
+ const cfg = loadCfg(opts);
619
+ const t = await settleTaskTerminalIntent({ taskId: id, cowork: coworkFor(cfg) });
620
+ if (opts.json) {
621
+ console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
622
+ return;
623
+ }
624
+ console.log(`Task ${t.task_id} · ${t.state}`);
625
+ }
626
+ catch (e) {
627
+ await recordTaskTerminalIntentError(id, errorText(e), `External settle worker failed. Retry task recover ${id}.`).catch(() => { });
628
+ die(e);
629
+ }
630
+ });
631
+ cOpt(taskCmd.command('work <id>'))
632
+ .description('ensure a task has a room and agents running')
633
+ .option('--template <name>', 'room template')
634
+ .option('--json', 'JSON output')
635
+ .action(async (id, opts) => {
636
+ try {
637
+ const cfg = loadCfg(opts);
638
+ let t = getTask(id);
639
+ if (TASK_TERMINAL_STATES.includes(t.state))
640
+ die(new Error(`task ${id} is in terminal state '${t.state}'`));
641
+ if (t.state === 'active' && t.room_id) {
642
+ // An explicit --template must agree with the room the task already
643
+ // runs in; a conflicting override is an error, not a silent no-op.
644
+ if (opts.template) {
645
+ const override = resolveTemplate(opts.template, allTemplates(cfg));
646
+ if (!override)
647
+ die(new Error(`template not found: ${opts.template}`));
648
+ const overrideSnap = snapshotTemplate(override);
649
+ const roomSnap = getRoomRecord(t.room_id)?.template_snapshot ?? t.template;
650
+ if (roomSnap && (roomSnap.name !== overrideSnap.name || roomSnap.content_hash !== overrideSnap.content_hash))
651
+ die(new Error(`template ${overrideSnap.name}@${overrideSnap.version} does not match room ${t.room_id}'s provisioned template ${roomSnap.name}@${roomSnap.version}`));
652
+ }
653
+ if (opts.json) {
654
+ console.log(JSON.stringify({ schema_version: 1, task: t, status: 'already_active' }, null, 2));
655
+ return;
656
+ }
657
+ console.log('Task already has an active room');
658
+ return;
659
+ }
660
+ // Select and validate the template snapshot before any state change or
661
+ // provisioning. An explicit --template re-pins the task; a stored ref
662
+ // must still match its snapshot (same contract as `task start`).
663
+ const templateName = opts.template
664
+ ?? t.template?.name
665
+ ?? cfg.tasks?.default_room_template
666
+ ?? cfg.rooms?.defaults?.template
667
+ ?? 'single';
668
+ const resolved = resolveTemplate(templateName, allTemplates(cfg));
669
+ if (!resolved)
670
+ die(new Error(`template not found: ${templateName}`));
671
+ const snap = snapshotTemplate(resolved);
672
+ if (!opts.template && t.template && snap.content_hash !== t.template.content_hash)
673
+ die(new Error(`task template snapshot no longer matches ${t.template.name}@${t.template.version}`));
674
+ // A provisioned room is pinned to its snapshot: never resume or re-pin
675
+ // an existing room under a different template.
676
+ if (t.room_id) {
677
+ const roomSnap = getRoomRecord(t.room_id)?.template_snapshot ?? t.template;
678
+ if (roomSnap && (roomSnap.name !== snap.name || roomSnap.content_hash !== snap.content_hash))
679
+ die(new Error(`template ${snap.name}@${snap.version} does not match room ${t.room_id}'s provisioned template ${roomSnap.name}@${roomSnap.version}`));
680
+ }
681
+ let templateRef = t.template;
682
+ if (!templateRef || templateRef.name !== snap.name || templateRef.content_hash !== snap.content_hash) {
683
+ templateRef = { name: snap.name, version: snap.version, content_hash: snap.content_hash };
684
+ t = updateTaskTemplate(t.task_id, templateRef);
685
+ }
686
+ if (t.state === 'backlog') {
687
+ t = startTask(id);
688
+ }
689
+ if (!t.room_id) {
690
+ try {
691
+ await provisionRoom(cfg, {
692
+ name: t.title,
693
+ goal: t.title,
694
+ brief: t.brief,
695
+ template: snap,
696
+ taskId: t.task_id,
697
+ onCreated: room => {
698
+ t = updateTaskRoom(t.task_id, room.room_id, room.room_identity_cid);
699
+ },
700
+ });
701
+ t = getTask(t.task_id);
702
+ }
703
+ catch (error) {
704
+ if (error instanceof CoworkUnavailableError) {
705
+ blockTask(t.task_id, 'Cowork management socket is unavailable');
706
+ }
707
+ throw error;
708
+ }
709
+ }
710
+ else if (t.state === 'provisioning') {
711
+ // The task already has a room mid-provisioning (e.g. seats were still
712
+ // pending on the last run). Resume it exactly as `task recover` does.
713
+ const room = getRoomRecord(t.room_id);
714
+ const resumable = ['create_members', 'join_role_groups', 'wait_seats', 'launch_work', 'activate'];
715
+ if (room && resumable.includes(room.saga.phase)) {
716
+ try {
717
+ await provisionMembers({
718
+ cfg,
719
+ cowork: coworkFor(cfg),
720
+ roomId: room.room_id,
721
+ taskId: t.task_id,
722
+ template: snap,
723
+ binPath: getBinPath(),
724
+ brief: t.brief,
725
+ goal: t.title,
726
+ });
727
+ }
728
+ catch (error) {
729
+ if (error instanceof CoworkUnavailableError) {
730
+ blockTask(t.task_id, 'Cowork management socket is unavailable');
731
+ }
732
+ throw error;
733
+ }
734
+ t = getTask(t.task_id);
735
+ }
736
+ else {
737
+ die(new Error(`task ${id} has room ${t.room_id} in a non-resumable state — run 'task recover ${id}'`));
738
+ }
739
+ }
740
+ if (opts.json) {
741
+ console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
742
+ return;
743
+ }
744
+ console.log(`Task ${t.task_id} · ${t.state}`);
745
+ if (templateRef)
746
+ console.log(`Template: ${templateRef.name}@${templateRef.version}`);
747
+ if (t.room_id)
748
+ console.log(`Room: ${t.room_id}`);
749
+ if (t.member_roles.length) {
750
+ console.log('Agents:');
751
+ for (const m of t.member_roles)
752
+ console.log(` ${m.name} (${m.cowork_role})`);
753
+ }
754
+ }
755
+ catch (e) {
756
+ die(e);
757
+ }
758
+ });
759
+ cOpt(taskCmd.command('finish <id>'))
760
+ .description('finish a task: transition to done and close its room')
761
+ .option('--summary <text>', 'completion summary')
762
+ .option('--summary-file <path>', 'completion summary from file')
763
+ .option('--json', 'JSON output')
764
+ .action(async (id, opts) => {
765
+ try {
766
+ let summary = opts.summary;
767
+ if (opts.summaryFile)
768
+ summary = readFileSync(opts.summaryFile, 'utf8');
769
+ const outcome = summary ? { summary } : undefined;
770
+ let t = getTask(id);
771
+ if (TASK_TERMINAL_STATES.includes(t.state))
772
+ die(new Error(`task ${id} is already in terminal state '${t.state}'`));
773
+ if (t.state === 'active') {
774
+ reviewTask(id);
775
+ }
776
+ if (t.room_id)
777
+ loadCfg(opts); // validate configuration before durable acceptance
778
+ await acceptTaskTerminalIntent({ taskId: id, kind: 'done', roomId: t.room_id, outcome });
779
+ const settled = t.room_id
780
+ ? await launchTaskSettleWorker(id, opts.configuration)
781
+ : { task: getTask(id), timedOut: false };
782
+ t = settled.task;
783
+ if (opts.json) {
784
+ console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
785
+ return;
786
+ }
787
+ if (settled.timedOut) {
788
+ console.log(`Task ${t.task_id} · ${t.state} (terminal intent accepted/pending; run task recover ${id})`);
789
+ return;
790
+ }
791
+ console.log(`Task ${t.task_id} · done`);
792
+ }
793
+ catch (e) {
794
+ die(e);
795
+ }
796
+ });
797
+ }
798
+ export function registerRoomCommands(parent, cOpt) {
799
+ const roomCmd = parent.command('room').description('room orchestration operations');
800
+ cOpt(roomCmd.command('create'))
801
+ .description('create a standalone room')
802
+ .requiredOption('--name <name>', 'room name')
803
+ .option('--template <name>', 'room template')
804
+ .option('--goal <text>', 'room goal')
805
+ .option('--brief <text>', 'room briefing')
806
+ .option('--brief-file <path>', 'room briefing from file')
807
+ .option('--json', 'JSON output')
808
+ .action(async (opts) => {
809
+ try {
810
+ const cfg = loadCfg(opts);
811
+ let brief = opts.brief;
812
+ if (opts.briefFile)
813
+ brief = readFileSync(opts.briefFile, 'utf8');
814
+ let templateSnapshot;
815
+ if (opts.template) {
816
+ const t = resolveTemplate(opts.template, allTemplates(cfg));
817
+ if (!t)
818
+ die(new Error(`template not found: ${opts.template}`));
819
+ templateSnapshot = snapshotTemplate(t);
820
+ }
821
+ const record = await provisionRoom(cfg, {
822
+ name: opts.name,
823
+ goal: opts.goal,
824
+ brief,
825
+ template: templateSnapshot,
826
+ });
827
+ if (opts.json) {
828
+ console.log(JSON.stringify({ schema_version: 1, room: record }, null, 2));
829
+ return;
830
+ }
831
+ console.log(`Room ${record.room_id} created · ${record.state}`);
832
+ if (templateSnapshot)
833
+ console.log(`Template: ${templateSnapshot.name}@${templateSnapshot.version}`);
834
+ }
835
+ catch (e) {
836
+ die(e);
837
+ }
838
+ });
839
+ cOpt(roomCmd.command('list'))
840
+ .description('list rooms')
841
+ .option('--state <state>', 'filter by state (active|provisioning|closing|closed|all)')
842
+ .option('--json', 'JSON output')
843
+ .action(async (opts) => {
844
+ try {
845
+ const cfg = loadCfg(opts);
846
+ let stateFilter;
847
+ if (opts.state && opts.state !== 'all') {
848
+ stateFilter = opts.state;
849
+ }
850
+ const local = new Map(listRoomRecords().map(room => [room.room_id, room]));
851
+ const coworkRooms = await coworkFor(cfg).listRooms();
852
+ const rooms = coworkRooms
853
+ .filter(room => !stateFilter || room.state === stateFilter)
854
+ .map(room => ({ ...room, orchestration: local.get(room.room_id) ?? null }));
855
+ if (opts.json) {
856
+ console.log(JSON.stringify({ schema_version: 1, rooms }, null, 2));
857
+ return;
858
+ }
859
+ if (!rooms.length) {
860
+ console.log('No rooms.');
861
+ return;
862
+ }
863
+ for (const r of rooms)
864
+ console.log(`${r.room_id} ${r.state} ${r.room_name}${r.orchestration?.task_id ? ` (task: ${r.orchestration.task_id})` : ''}`);
865
+ }
866
+ catch (e) {
867
+ die(e);
868
+ }
869
+ });
870
+ cOpt(roomCmd.command('show <id>'))
871
+ .description('show room details')
872
+ .option('--json', 'JSON output')
873
+ .action(async (id, opts) => {
874
+ try {
875
+ const cfg = loadCfg(opts);
876
+ const cowork = await coworkFor(cfg).getRoom(id);
877
+ if (!cowork)
878
+ die(new Error(`room not found: ${id}`));
879
+ const r = getRoomRecord(id);
880
+ if (opts.json) {
881
+ console.log(JSON.stringify({ schema_version: 1, room: cowork, orchestration: r ?? null }, null, 2));
882
+ return;
883
+ }
884
+ console.log(`Room: ${cowork.room_id}`);
885
+ console.log(`Name: ${cowork.room_name}`);
886
+ console.log(`State: ${cowork.state}`);
887
+ console.log(`Identity CID: ${cowork.identity_cid}`);
888
+ if (r?.task_id)
889
+ console.log(`Task: ${r.task_id}`);
890
+ if (r)
891
+ console.log(`Saga: ${r.saga.phase} (step ${r.saga.step_index})`);
892
+ if (r?.provisioning_detail)
893
+ console.log(`Detail: ${r.provisioning_detail}`);
894
+ if (r?.saga.error)
895
+ console.log(`Error: ${r.saga.error}`);
896
+ if (cowork.seats.length) {
897
+ console.log('Members:');
898
+ for (const s of cowork.seats)
899
+ console.log(` ${s.identity_cid} (${s.role}) ${s.seat_state}`);
900
+ }
901
+ if (r)
902
+ console.log(`Tracked by Fleet since: ${r.created_at}`);
903
+ }
904
+ catch (e) {
905
+ die(e);
906
+ }
907
+ });
908
+ cOpt(roomCmd.command('open <id>'))
909
+ .description('open room in Cowork local console')
910
+ .option('--json', 'JSON output')
911
+ .action(async (id, opts) => {
912
+ try {
913
+ const cfg = loadCfg(opts);
914
+ const room = await coworkFor(cfg).getRoom(id);
915
+ if (!room)
916
+ die(new Error(`room not found: ${id}`));
917
+ const url = `http://localhost:4460/room/${id}`;
918
+ if (opts.json) {
919
+ console.log(JSON.stringify({ schema_version: 1, room_id: id, url, room_name: room.room_name }, null, 2));
920
+ return;
921
+ }
922
+ console.log(`Room ${id} — ${room.room_name}`);
923
+ console.log(`Local console: ${url}`);
924
+ }
925
+ catch (e) {
926
+ die(e);
927
+ }
928
+ });
929
+ cOpt(roomCmd.command('members <id>'))
930
+ .description('show room members')
931
+ .option('--json', 'JSON output')
932
+ .action(async (id, opts) => {
933
+ try {
934
+ const cfg = loadCfg(opts);
935
+ const r = getRoomRecord(id);
936
+ const members = await coworkFor(cfg).getSeats(id);
937
+ if (opts.json) {
938
+ console.log(JSON.stringify({
939
+ schema_version: 1,
940
+ room_id: id,
941
+ members,
942
+ owner_seat_cid: r?.owner_seat_cid ?? null,
943
+ }, null, 2));
944
+ return;
945
+ }
946
+ console.log(`Room ${id}${r ? ` — ${r.room_name}` : ''}`);
947
+ if (r?.owner_seat_cid)
948
+ console.log(`Owner: ${r.owner_seat_cid}`);
949
+ if (!members.length) {
950
+ console.log('No members.');
951
+ return;
952
+ }
953
+ for (const s of members)
954
+ console.log(` ${s.identity_cid} (${s.role}) ${s.seat_state}`);
955
+ }
956
+ catch (e) {
957
+ die(e);
958
+ }
959
+ });
960
+ cOpt(roomCmd.command('close <id> <confirm-id>'))
961
+ .description('close a room (requires ID twice for confirmation)')
962
+ .option('--json', 'JSON output')
963
+ .action(async (id, confirmId, opts) => {
964
+ try {
965
+ if (id !== confirmId)
966
+ die(new Error('confirmation ID must match room ID'));
967
+ coworkFor(loadCfg(opts));
968
+ const existing = getRoomRecord(id);
969
+ if (!existing)
970
+ throw new Error(`room not found in Fleet orchestration: ${id}`);
971
+ await acceptManagedRoomClose(id);
972
+ const settled = await launchRoomCloseWorker(id, opts.configuration);
973
+ const r = settled.room;
974
+ if (opts.json) {
975
+ console.log(JSON.stringify({ schema_version: 1, room_id: id, state: r.state, orchestration: r }, null, 2));
976
+ return;
977
+ }
978
+ if (settled.timedOut) {
979
+ console.log(`Room ${id} · closing (accepted/pending; run room recover ${id})`);
980
+ return;
981
+ }
982
+ console.log(`Room ${id} · closed`);
983
+ }
984
+ catch (e) {
985
+ die(e);
986
+ }
987
+ });
988
+ cOpt(roomCmd.command('recover <id>'))
989
+ .description('attempt to recover a stuck room')
990
+ .option('--json', 'JSON output')
991
+ .action(async (id, opts) => {
992
+ try {
993
+ const cfg = loadCfg(opts);
994
+ const adapter = coworkFor(cfg);
995
+ let r = getRoomRecord(id);
996
+ if (r?.state === 'closing') {
997
+ const settled = await launchRoomCloseWorker(id, opts.configuration);
998
+ r = settled.room;
999
+ const cowork = r.state === 'closed' ? await adapter.getRoom(id) : undefined;
1000
+ if (opts.json) {
1001
+ console.log(JSON.stringify({
1002
+ schema_version: 1, room: cowork ?? null, orchestration: r,
1003
+ recovery_actions: [settled.timedOut
1004
+ ? `Closing remains pending — retry room recover ${id}`
1005
+ : 'Closing saga resumed successfully'],
1006
+ }, null, 2));
1007
+ return;
1008
+ }
1009
+ console.log(`Room ${id} · ${r.state} · close: ${r.close?.phase ?? 'unknown'}`);
1010
+ return;
1011
+ }
1012
+ const cowork = await adapter.recoverRoom(id);
1013
+ r = getRoomRecord(id);
1014
+ if (r && !r.owner_seat_cid
1015
+ && (r.provisioning_detail === 'waiting_owner_invite'
1016
+ || r.provisioning_detail === 'owner_cid_mismatch')) {
1017
+ const expected = cfg.rooms.owner.expected_cid.toLowerCase();
1018
+ const existing = (await adapter.getSeats(id))
1019
+ .find(seat => seat.identity_cid.toLowerCase() === expected && seat.seat_state !== 'removed');
1020
+ if (!existing && !cfg.ownerInvite)
1021
+ throw new ConfigError('rooms.owner: configure public_invite or public_invite_file before recovery');
1022
+ let acceptedCid = existing?.identity_cid;
1023
+ if (!acceptedCid) {
1024
+ const accepted = await adapter.acceptInvite(id, cfg.ownerInvite, {
1025
+ role: cfg.rooms.owner.role,
1026
+ expected_cid: cfg.rooms.owner.expected_cid,
1027
+ });
1028
+ acceptedCid = accepted.seat_cid;
1029
+ }
1030
+ setOwnerSeat(id, acceptedCid, cfg.ownerInviteFingerprint ?? '');
1031
+ r = advanceSaga(id, 'create_members', 3);
1032
+ }
1033
+ const actions = [];
1034
+ if (r?.saga.error)
1035
+ actions.push(`Last error: ${r.saga.error}`);
1036
+ if (r?.saga.recovery_hint)
1037
+ actions.push(r.saga.recovery_hint);
1038
+ if (r?.provisioning_detail === 'waiting_cowork')
1039
+ actions.push('Check ours-cowork service status');
1040
+ if (r?.provisioning_detail === 'waiting_owner_invite')
1041
+ actions.push('Rotate rooms.owner.public_invite in config, then re-run recover');
1042
+ if (r && r.state === 'provisioning') {
1043
+ const resumable = ['create_members', 'join_role_groups', 'wait_seats', 'launch_work', 'activate'];
1044
+ if (resumable.includes(r.saga.phase) && r.template_snapshot) {
1045
+ try {
1046
+ const template = resolveRoomTemplate(cfg, r.template_snapshot.name);
1047
+ if (template) {
1048
+ await provisionMembers({
1049
+ cfg,
1050
+ cowork: adapter,
1051
+ roomId: r.room_id,
1052
+ taskId: r.task_id,
1053
+ template,
1054
+ binPath: getBinPath(),
1055
+ goal: r.room_name,
1056
+ });
1057
+ r = getRoomRecord(id);
1058
+ actions.push('Provisioning resumed successfully');
1059
+ }
1060
+ }
1061
+ catch (error) {
1062
+ actions.push(`Resume failed: ${error instanceof Error ? error.message : String(error)}`);
1063
+ }
1064
+ }
1065
+ }
1066
+ if (opts.json) {
1067
+ console.log(JSON.stringify({ schema_version: 1, room: cowork, orchestration: r ?? null, recovery_actions: actions }, null, 2));
1068
+ return;
1069
+ }
1070
+ console.log(`Room ${cowork.room_id} · ${cowork.state}${r ? ` · saga: ${r.saga.phase}` : ''}`);
1071
+ if (actions.length) {
1072
+ console.log('Recovery:');
1073
+ for (const a of actions)
1074
+ console.log(` - ${a}`);
1075
+ }
1076
+ else {
1077
+ console.log('No recovery actions needed.');
1078
+ }
1079
+ }
1080
+ catch (e) {
1081
+ die(e);
1082
+ }
1083
+ });
1084
+ cOpt(roomCmd.command('_close <id>', { hidden: true }))
1085
+ .description('internal: settle an accepted room close')
1086
+ .option('--json', 'JSON output')
1087
+ .action(async (id, opts) => {
1088
+ try {
1089
+ const cfg = loadCfg(opts);
1090
+ const r = await closeManagedRoom({ roomId: id, cowork: coworkFor(cfg) });
1091
+ if (opts.json) {
1092
+ console.log(JSON.stringify({ schema_version: 1, room_id: id, state: r.state, orchestration: r }, null, 2));
1093
+ return;
1094
+ }
1095
+ console.log(`Room ${id} · ${r.state}`);
1096
+ }
1097
+ catch (e) {
1098
+ await recordManagedRoomCloseError(id, errorText(e), `External close worker failed. Retry room recover ${id}.`).catch(() => { });
1099
+ die(e);
1100
+ }
1101
+ });
1102
+ }