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

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.3",
3
- "buildId": "c969df19c5f2",
4
- "commit": "1cfdb94ec5bc493c83c72f62dd70c669c78b9e68",
2
+ "version": "0.19.0-nightly.4",
3
+ "buildId": "27ab2ac6d2c0",
4
+ "commit": "0213a2667a2d24e14297f7364f42880997451742",
5
5
  "dirty": true,
6
- "builtAt": "2026-08-23T10:19:42.625Z",
6
+ "builtAt": "2026-08-23T12:52:41.918Z",
7
7
  "capabilities": [
8
8
  "monitor.interrupt.after_tool"
9
9
  ]
package/dist/doctor.js CHANGED
@@ -106,6 +106,126 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
106
106
  ok: true,
107
107
  detail: `warning: ${diagnostic.message}`,
108
108
  });
109
+ // ── Rooms/tasks checks (§5.3) ────────────────────────────────────────
110
+ if (loaded.ok && loaded.rooms) {
111
+ const rooms = loaded.rooms;
112
+ // Cowork socket reachability
113
+ const coworkConfig = rooms.cowork?.config;
114
+ try {
115
+ const { createCoworkAdapter } = await import('./rooms-tasks/cowork-adapter.js');
116
+ const adapter = createCoworkAdapter({ configPath: coworkConfig });
117
+ const reachable = await adapter.available();
118
+ checks.push({
119
+ name: 'cowork', ok: reachable,
120
+ detail: reachable
121
+ ? 'management socket reachable'
122
+ : `management socket unreachable${coworkConfig ? ` (config: ${coworkConfig})` : ''}`,
123
+ });
124
+ }
125
+ catch (e) {
126
+ checks.push({
127
+ name: 'cowork', ok: false,
128
+ detail: `management socket error${coworkConfig ? ` (config: ${coworkConfig})` : ''}: ${e?.message ?? e}`,
129
+ });
130
+ }
131
+ // Owner CID shape
132
+ const cidOk = /^[0-9a-fA-F]{64}$/.test(rooms.owner.expected_cid);
133
+ checks.push({
134
+ name: 'rooms: owner CID', ok: cidOk,
135
+ detail: cidOk ? 'valid 64-hex CID' : `invalid CID shape: ${rooms.owner.expected_cid.slice(0, 16)}...`,
136
+ });
137
+ // Invite presence (never content)
138
+ checks.push({
139
+ name: 'rooms: owner invite',
140
+ ok: !!loaded.ownerInviteFingerprint,
141
+ detail: loaded.ownerInviteFingerprint
142
+ ? `present (fingerprint: ${loaded.ownerInviteFingerprint.slice(0, 12)}...)`
143
+ : 'not configured — room owner attachment will use waiting_owner_invite',
144
+ });
145
+ // Template validity and referenced role validity
146
+ const { listTemplates, resolveTemplate } = await import('./rooms-tasks/templates.js');
147
+ const customs = loaded.roomTemplates ?? {};
148
+ const templates = listTemplates(customs);
149
+ const roleNames = new Set(roles.map(r => r.name));
150
+ for (const t of templates) {
151
+ const badRefs = t.members
152
+ .filter(m => m.role_ref && !roleNames.has(m.role_ref))
153
+ .map(m => m.role_ref);
154
+ if (badRefs.length) {
155
+ checks.push({
156
+ name: `template: ${t.name}@${t.version}`,
157
+ ok: true,
158
+ detail: `warning: role_ref(s) ${badRefs.join(', ')} not found in configured roles`,
159
+ });
160
+ }
161
+ }
162
+ // Default template validity
163
+ const defaultTemplate = loaded.rooms.defaults?.template
164
+ ?? loaded.tasks?.default_room_template;
165
+ if (defaultTemplate) {
166
+ const resolved = resolveTemplate(defaultTemplate, customs);
167
+ checks.push({
168
+ name: 'rooms: default template',
169
+ ok: !!resolved,
170
+ detail: resolved
171
+ ? `${resolved.name}@${resolved.version}`
172
+ : `template '${defaultTemplate}' not found`,
173
+ });
174
+ }
175
+ // Shared-daemon selection coherence
176
+ if (rooms.cowork?.config) {
177
+ const { existsSync: exists } = await import('node:fs');
178
+ const configOk = exists(rooms.cowork.config);
179
+ checks.push({
180
+ name: 'rooms: cowork config', ok: configOk,
181
+ detail: configOk
182
+ ? `cowork config at ${rooms.cowork.config}`
183
+ : `cowork config not found: ${rooms.cowork.config}`,
184
+ });
185
+ }
186
+ // Hard prerelease capability check
187
+ try {
188
+ const { createCoworkAdapter } = await import('./rooms-tasks/cowork-adapter.js');
189
+ const adapter = createCoworkAdapter({ configPath: coworkConfig });
190
+ const roomList = await adapter.listRooms();
191
+ checks.push({
192
+ name: 'rooms: capability', ok: true,
193
+ detail: `cowork room management operational (${roomList.length} room(s))`,
194
+ });
195
+ }
196
+ catch (e) {
197
+ const msg = e?.message ?? String(e);
198
+ const isProto = msg.includes('protocol') || msg.includes('invalid');
199
+ checks.push({
200
+ name: 'rooms: capability', ok: false,
201
+ detail: isProto
202
+ ? `cowork does not support room management protocol — upgrade ours-cowork to prerelease`
203
+ : `room management check failed: ${msg}`,
204
+ });
205
+ }
206
+ // Stale task/room warnings
207
+ try {
208
+ const { listTasks } = await import('./rooms-tasks/task-state.js');
209
+ const { listRoomRecords } = await import('./rooms-tasks/room-state.js');
210
+ const tasks = listTasks();
211
+ const roomRecords = listRoomRecords();
212
+ const roomIds = new Set(roomRecords.map(r => r.room_id));
213
+ const taskIds = new Set(tasks.map(t => t.task_id));
214
+ const orphanedTasks = tasks.filter(t => t.room_id && !roomIds.has(t.room_id));
215
+ const orphanedRooms = roomRecords.filter(r => r.task_id && !taskIds.has(r.task_id));
216
+ if (orphanedTasks.length)
217
+ checks.push({
218
+ name: 'rooms: stale tasks', ok: true,
219
+ detail: `warning: ${orphanedTasks.length} task(s) reference missing rooms: ${orphanedTasks.map(t => t.task_id).join(', ')}`,
220
+ });
221
+ if (orphanedRooms.length)
222
+ checks.push({
223
+ name: 'rooms: stale rooms', ok: true,
224
+ detail: `warning: ${orphanedRooms.length} room(s) reference missing tasks: ${orphanedRooms.map(r => r.room_id).join(', ')}`,
225
+ });
226
+ }
227
+ catch { /* state dir may not exist yet */ }
228
+ }
109
229
  if (roles.length === 0 || roles.some(role => (role.session ?? 'tmux') === 'tmux')) {
110
230
  const tmux = await exec('tmux', ['-V']);
111
231
  checks.push({
@@ -405,7 +525,11 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
405
525
  function loadConfigResult(configPath, yamlMode) {
406
526
  try {
407
527
  const cfg = loadConfig(configPath, { yamlMode });
408
- return { ok: true, roles: cfg.roles, files: cfg.files, diagnostics: cfg.diagnostics };
528
+ return {
529
+ ok: true, roles: cfg.roles, files: cfg.files, diagnostics: cfg.diagnostics,
530
+ rooms: cfg.rooms, roomTemplates: cfg.roomTemplates, tasks: cfg.tasks,
531
+ ownerInviteFingerprint: cfg.ownerInviteFingerprint,
532
+ };
409
533
  }
410
534
  catch (e) {
411
535
  return { ok: false, error: e instanceof Error ? e.message : String(e) };
@@ -159,14 +159,20 @@ export const ownerCommands = [
159
159
  },
160
160
  },
161
161
  {
162
- name: 'task', usage: '/task <id>',
163
- summary: 'show task details',
162
+ name: 'task',
163
+ usage: '/task <create|list|show|start|block|unblock|review|done|cancel|recover> ...',
164
+ summary: 'task lifecycle subcommands',
164
165
  execute: async (ctx, args) => {
165
166
  if (!args)
166
- throw new OwnerCommandUsageError('usage: /task <id>');
167
- const { getTask } = await import('../rooms-tasks/task-state.js');
168
- try {
169
- const t = getTask(args.trim());
167
+ throw new OwnerCommandUsageError('usage: /task <subcommand> <id>');
168
+ const argLines = args.trim().split('\n');
169
+ const firstLineTokens = argLines[0].trim().split(/\s+/);
170
+ const sub = firstLineTokens[0];
171
+ const rest = firstLineTokens.slice(1);
172
+ const trailingLines = argLines.slice(1).join('\n').trim() || undefined;
173
+ const showTask = async (id) => {
174
+ const { getTask } = await import('../rooms-tasks/task-state.js');
175
+ const t = getTask(id);
170
176
  const lines = [
171
177
  `📋 Task: ${t.task_id}`,
172
178
  `Title: ${t.title}`,
@@ -177,8 +183,157 @@ export const ownerCommands = [
177
183
  `Created: ${t.created_at}`,
178
184
  ];
179
185
  await ctx.reply(lines.join('\n'));
186
+ };
187
+ try {
188
+ switch (sub) {
189
+ case 'create': {
190
+ if (!rest[0])
191
+ throw new OwnerCommandUsageError('usage: /task create [--backlog] [--template=<name>|--no-room] <title>');
192
+ const backlog = rest.includes('--backlog');
193
+ const tplFlag = rest.find(r => r.startsWith('--template='));
194
+ const template = tplFlag ? tplFlag.slice('--template='.length) : undefined;
195
+ const noRoom = rest.includes('--no-room');
196
+ const titleParts = rest.filter(r => r !== '--backlog' && !r.startsWith('--template=') && r !== '--no-room');
197
+ if (!titleParts.length)
198
+ throw new OwnerCommandUsageError('usage: /task create [--backlog] [--template=<name>|--no-room] <title>');
199
+ const { createTask } = await import('../rooms-tasks/task-state.js');
200
+ const t = createTask({
201
+ title: titleParts.join(' '),
202
+ origin: { type: 'owner_channel' },
203
+ start: !backlog,
204
+ ...(template ? { template: { name: template, version: 1, content_hash: '' } } : {}),
205
+ ...(noRoom ? { no_room: true } : {}),
206
+ ...(trailingLines ? { brief: trailingLines } : {}),
207
+ });
208
+ const lines = [`Task ${t.task_id} created · ${t.state}`];
209
+ if (template)
210
+ lines.push(`Template: ${template}`);
211
+ if (noRoom)
212
+ lines.push('No room requested');
213
+ await ctx.reply(lines.join('\n'));
214
+ break;
215
+ }
216
+ case 'list': {
217
+ const { listTasks } = await import('../rooms-tasks/task-state.js');
218
+ const filter = rest[0];
219
+ const stateMap = {
220
+ backlog: 'backlog', active: 'active', blocked: 'active',
221
+ done: 'done', all: ['backlog', 'provisioning', 'active', 'review', 'done', 'cancelled', 'failed'],
222
+ };
223
+ const stateFilter = filter && stateMap[filter]
224
+ ? { state: stateMap[filter] }
225
+ : undefined;
226
+ const tasks = listTasks(stateFilter);
227
+ if (filter === 'blocked') {
228
+ const blocked = tasks.filter(t => t.blocked);
229
+ if (!blocked.length) {
230
+ await ctx.reply('📋 No blocked tasks.');
231
+ break;
232
+ }
233
+ const lines = blocked.map(t => `${t.task_id} ${t.state} ${t.title} [BLOCKED: ${t.blocked.reason}]`);
234
+ await ctx.reply(tail(`📋 Blocked tasks:\n${lines.join('\n')}`, REPLY_MAX_CHARS));
235
+ break;
236
+ }
237
+ if (!tasks.length) {
238
+ await ctx.reply('📋 No tasks.');
239
+ break;
240
+ }
241
+ const lines = tasks.map(t => `${t.task_id} ${t.state} ${t.title}${t.blocked ? ` [BLOCKED]` : ''}`);
242
+ await ctx.reply(tail(`📋 Tasks:\n${lines.join('\n')}`, REPLY_MAX_CHARS));
243
+ break;
244
+ }
245
+ case 'show': {
246
+ if (!rest[0])
247
+ throw new OwnerCommandUsageError('usage: /task show <id>');
248
+ await showTask(rest[0]);
249
+ break;
250
+ }
251
+ case 'start': {
252
+ if (!rest[0])
253
+ throw new OwnerCommandUsageError('usage: /task start <id>');
254
+ const { startTask } = await import('../rooms-tasks/task-state.js');
255
+ const t = startTask(rest[0]);
256
+ await ctx.reply(`✅ Task ${t.task_id} → provisioning`);
257
+ break;
258
+ }
259
+ case 'block': {
260
+ if (!rest[0] || !rest[1])
261
+ throw new OwnerCommandUsageError('usage: /task block <id> <reason>');
262
+ const { blockTask } = await import('../rooms-tasks/task-state.js');
263
+ const reason = rest.slice(1).join(' ');
264
+ const t = blockTask(rest[0], reason);
265
+ await ctx.reply(`🚧 Task ${t.task_id} blocked: ${reason}`);
266
+ break;
267
+ }
268
+ case 'unblock': {
269
+ if (!rest[0])
270
+ throw new OwnerCommandUsageError('usage: /task unblock <id>');
271
+ const { unblockTask } = await import('../rooms-tasks/task-state.js');
272
+ const t = unblockTask(rest[0]);
273
+ await ctx.reply(`✅ Task ${t.task_id} unblocked`);
274
+ break;
275
+ }
276
+ case 'review': {
277
+ if (!rest[0])
278
+ throw new OwnerCommandUsageError('usage: /task review <id>');
279
+ const { reviewTask } = await import('../rooms-tasks/task-state.js');
280
+ const t = reviewTask(rest[0]);
281
+ await ctx.reply(`📝 Task ${t.task_id} → review`);
282
+ break;
283
+ }
284
+ case 'done': {
285
+ if (!rest[0])
286
+ throw new OwnerCommandUsageError('usage: /task done <id> [summary]');
287
+ const { completeTask } = await import('../rooms-tasks/task-state.js');
288
+ const summary = rest.slice(1).join(' ') || undefined;
289
+ const t = completeTask(rest[0], summary ? { summary } : undefined);
290
+ await ctx.reply(`✅ Task ${t.task_id} → done`);
291
+ break;
292
+ }
293
+ case 'cancel': {
294
+ if (rest.length < 2 || rest[0] !== rest[1])
295
+ throw new OwnerCommandUsageError('destructive: /task cancel <id> <id> — provide the task ID twice');
296
+ const { cancelTask } = await import('../rooms-tasks/task-state.js');
297
+ const t = cancelTask(rest[0]);
298
+ await ctx.reply(`🗑️ Task ${t.task_id} → cancelled`);
299
+ break;
300
+ }
301
+ case 'recover': {
302
+ if (!rest[0])
303
+ throw new OwnerCommandUsageError('usage: /task recover <id>');
304
+ const { getTask } = await import('../rooms-tasks/task-state.js');
305
+ const { getRoomRecord } = await import('../rooms-tasks/room-state.js');
306
+ const t = getTask(rest[0]);
307
+ const room = t.room_id ? getRoomRecord(t.room_id) : undefined;
308
+ const hints = [];
309
+ if (t.state === 'provisioning' && room) {
310
+ if (room.provisioning_detail === 'waiting_cowork')
311
+ hints.push('Cowork socket unreachable');
312
+ if (room.provisioning_detail === 'waiting_owner_invite')
313
+ hints.push('Owner invite missing or invalid');
314
+ if (room.provisioning_detail === 'owner_cid_mismatch')
315
+ hints.push('Owner CID mismatch');
316
+ if (room.provisioning_detail === 'member_failed')
317
+ hints.push(`Member failed at step ${room.saga.step_index}`);
318
+ }
319
+ const lines = [`Task ${t.task_id} · ${t.state}`];
320
+ if (room)
321
+ lines.push(`Room ${room.room_id} · ${room.state} · saga: ${room.saga.phase}`);
322
+ if (hints.length)
323
+ lines.push(`Hints: ${hints.join('; ')}`);
324
+ else
325
+ lines.push('No automated recovery actions available.');
326
+ await ctx.reply(lines.join('\n'));
327
+ break;
328
+ }
329
+ default:
330
+ // bare /task <id> → show
331
+ await showTask(sub);
332
+ }
180
333
  }
181
334
  catch (e) {
335
+ if (e instanceof OwnerCommandUsageError)
336
+ throw e;
182
337
  await ctx.reply(`⚠️ ${e instanceof Error ? e.message : String(e)}`);
183
338
  }
184
339
  },
@@ -195,30 +350,125 @@ export const ownerCommands = [
195
350
  }),
196
351
  },
197
352
  {
198
- name: 'room', usage: '/room <id>',
199
- summary: 'show room details',
353
+ name: 'room',
354
+ usage: '/room <create|list|show|close|recover> ...',
355
+ summary: 'room lifecycle subcommands',
200
356
  execute: async (ctx, args) => {
201
357
  if (!args)
202
- throw new OwnerCommandUsageError('usage: /room <id>');
203
- const { getRoomRecord } = await import('../rooms-tasks/room-state.js');
204
- const r = getRoomRecord(args.trim());
205
- if (!r)
206
- return ctx.reply(`⚠️ room not found: ${args.trim()}`);
207
- const lines = [
208
- `🏠 Room: ${r.room_id}`,
209
- `Name: ${r.room_name}`,
210
- `State: ${r.state}`,
211
- `Saga: ${r.saga.phase} (step ${r.saga.step_index})`,
212
- ...(r.task_id ? [`Task: ${r.task_id}`] : []),
213
- ...(r.provisioning_detail ? [`Detail: ${r.provisioning_detail}`] : []),
214
- ...(r.saga.error ? [`Error: ${r.saga.error}`] : []),
215
- `Created: ${r.created_at}`,
216
- ];
217
- await ctx.reply(lines.join('\n'));
358
+ throw new OwnerCommandUsageError('usage: /room <subcommand> <id>');
359
+ const argLines = args.trim().split('\n');
360
+ const firstLineTokens = argLines[0].trim().split(/\s+/);
361
+ const sub = firstLineTokens[0];
362
+ const rest = firstLineTokens.slice(1);
363
+ const roomTrailingLines = argLines.slice(1).join('\n').trim() || undefined;
364
+ const showRoom = async (id) => {
365
+ const { getRoomRecord } = await import('../rooms-tasks/room-state.js');
366
+ const r = getRoomRecord(id);
367
+ if (!r)
368
+ return ctx.reply(`⚠️ room not found: ${id}`);
369
+ const lines = [
370
+ `🏠 Room: ${r.room_id}`,
371
+ `Name: ${r.room_name}`,
372
+ `State: ${r.state}`,
373
+ `Saga: ${r.saga.phase} (step ${r.saga.step_index})`,
374
+ ...(r.task_id ? [`Task: ${r.task_id}`] : []),
375
+ ...(r.provisioning_detail ? [`Detail: ${r.provisioning_detail}`] : []),
376
+ ...(r.saga.error ? [`Error: ${r.saga.error}`] : []),
377
+ `Created: ${r.created_at}`,
378
+ ];
379
+ await ctx.reply(lines.join('\n'));
380
+ };
381
+ try {
382
+ switch (sub) {
383
+ case 'create': {
384
+ const tplFlag = rest.find(r => r.startsWith('--template='));
385
+ const templateName = tplFlag?.slice('--template='.length);
386
+ const nameParts = rest.filter(r => !r.startsWith('--'));
387
+ if (!nameParts.length)
388
+ throw new OwnerCommandUsageError('usage: /room create --template=<name> <room name>');
389
+ const { randomUUID } = await import('node:crypto');
390
+ const { createRoomRecord } = await import('../rooms-tasks/room-state.js');
391
+ const { resolveTemplate, snapshotTemplate } = await import('../rooms-tasks/templates.js');
392
+ let templateSnapshot;
393
+ if (templateName) {
394
+ const tpl = resolveTemplate(templateName, {});
395
+ if (!tpl)
396
+ throw new OwnerCommandUsageError(`unknown template: ${templateName}`);
397
+ templateSnapshot = snapshotTemplate(tpl);
398
+ }
399
+ const r = createRoomRecord({
400
+ room_id: randomUUID().replace(/-/g, '').slice(0, 16),
401
+ room_name: nameParts.join(' '),
402
+ ...(templateSnapshot ? { template_snapshot: templateSnapshot } : {}),
403
+ ...(roomTrailingLines ? { goal: roomTrailingLines } : {}),
404
+ });
405
+ const lines = [`🏠 Room ${r.room_id} created · ${r.state}`];
406
+ if (templateName)
407
+ lines.push(`Template: ${templateName}`);
408
+ await ctx.reply(lines.join('\n'));
409
+ break;
410
+ }
411
+ case 'list': {
412
+ const { listRoomRecords } = await import('../rooms-tasks/room-state.js');
413
+ const filter = rest[0];
414
+ let rooms = listRoomRecords();
415
+ if (filter === 'active')
416
+ rooms = rooms.filter(r => r.state === 'active');
417
+ if (!rooms.length) {
418
+ await ctx.reply('🏠 No rooms.');
419
+ break;
420
+ }
421
+ const lines = rooms.map(r => `${r.room_id} ${r.state} ${r.room_name}${r.task_id ? ` (task: ${r.task_id})` : ''}`);
422
+ await ctx.reply(tail(`🏠 Rooms:\n${lines.join('\n')}`, REPLY_MAX_CHARS));
423
+ break;
424
+ }
425
+ case 'show': {
426
+ if (!rest[0])
427
+ throw new OwnerCommandUsageError('usage: /room show <id>');
428
+ await showRoom(rest[0]);
429
+ break;
430
+ }
431
+ case 'close': {
432
+ if (rest.length < 2 || rest[0] !== rest[1])
433
+ throw new OwnerCommandUsageError('destructive: /room close <id> <id> — provide the room ID twice');
434
+ const { closeRoom } = await import('../rooms-tasks/room-state.js');
435
+ const r = closeRoom(rest[0]);
436
+ await ctx.reply(`🔒 Room ${r.room_id} → closed`);
437
+ break;
438
+ }
439
+ case 'recover': {
440
+ if (!rest[0])
441
+ throw new OwnerCommandUsageError('usage: /room recover <id>');
442
+ const { getRoomRecord } = await import('../rooms-tasks/room-state.js');
443
+ const r = getRoomRecord(rest[0]);
444
+ if (!r) {
445
+ await ctx.reply(`⚠️ room not found: ${rest[0]}`);
446
+ break;
447
+ }
448
+ const lines = [`🏠 Room ${r.room_id} · ${r.state} · saga: ${r.saga.phase}`];
449
+ if (r.saga.error)
450
+ lines.push(`Error: ${r.saga.error}`);
451
+ if (r.provisioning_detail)
452
+ lines.push(`Detail: ${r.provisioning_detail}`);
453
+ lines.push(r.saga.error ? 'Run `ours-fleet room recover` from CLI for full recovery.' : 'No recovery needed.');
454
+ await ctx.reply(lines.join('\n'));
455
+ break;
456
+ }
457
+ default:
458
+ // bare /room <id> → show
459
+ await showRoom(sub);
460
+ }
461
+ }
462
+ catch (e) {
463
+ if (e instanceof OwnerCommandUsageError)
464
+ throw e;
465
+ await ctx.reply(`⚠️ ${e instanceof Error ? e.message : String(e)}`);
466
+ }
218
467
  },
219
468
  },
220
469
  {
221
- name: 'templates', summary: 'list available room templates',
470
+ name: 'templates', aliases: ['template-list'],
471
+ summary: 'list available room templates',
222
472
  execute: noArgs('/templates', async (ctx) => {
223
473
  const { listTemplates } = await import('../rooms-tasks/templates.js');
224
474
  const templates = listTemplates({});
@@ -231,6 +481,55 @@ export const ownerCommands = [
231
481
  await ctx.reply(`📐 Templates:\n${lines.join('\n')}`);
232
482
  }),
233
483
  },
484
+ {
485
+ name: 'template',
486
+ usage: '/template <show|list> <name[@version]>',
487
+ summary: 'template subcommands (show, list)',
488
+ execute: async (ctx, args) => {
489
+ if (!args)
490
+ throw new OwnerCommandUsageError('usage: /template show <name[@version]>');
491
+ const parts = args.trim().split(/\s+/);
492
+ const sub = parts[0];
493
+ const showTemplate = async (nameStr) => {
494
+ const { resolveTemplate } = await import('../rooms-tasks/templates.js');
495
+ const t = resolveTemplate(nameStr, {});
496
+ if (!t)
497
+ return ctx.reply(`⚠️ template not found: ${nameStr}`);
498
+ const lines = [
499
+ `📐 Template: ${t.name}@${t.version}`,
500
+ `Description: ${t.description}`,
501
+ ...(t.builtin ? ['Source: built-in'] : []),
502
+ ...(t.contract ? [`Contract: ${t.contract}`] : []),
503
+ 'Members:',
504
+ ...t.members.map(m => ` ${m.slot} (${m.role}) ×${m.count} → role_ref: ${m.role_ref}`),
505
+ ];
506
+ await ctx.reply(lines.join('\n'));
507
+ };
508
+ switch (sub) {
509
+ case 'show': {
510
+ if (!parts[1])
511
+ throw new OwnerCommandUsageError('usage: /template show <name[@version]>');
512
+ await showTemplate(parts[1]);
513
+ break;
514
+ }
515
+ case 'list': {
516
+ const { listTemplates } = await import('../rooms-tasks/templates.js');
517
+ const templates = listTemplates({});
518
+ if (!templates.length)
519
+ return ctx.reply('📐 No templates.');
520
+ const lines = templates.map(t => {
521
+ const tag = t.builtin ? ' (built-in)' : '';
522
+ return `${t.name}@${t.version}${tag} ${t.description}`;
523
+ });
524
+ await ctx.reply(`📐 Templates:\n${lines.join('\n')}`);
525
+ break;
526
+ }
527
+ default:
528
+ // bare /template <name> → show
529
+ await showTemplate(sub);
530
+ }
531
+ },
532
+ },
234
533
  ];
235
534
  /** Trimmed slash-prefixed text is a command attempt and is never forwarded. */
236
535
  export const isOwnerCommandText = (text) => text.trim().startsWith('/');
@@ -1,8 +1,9 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { loadConfig, ConfigError } from '../config.js';
3
+ import { provisionMembers, cleanupMembers, getBinPath } from './provision.js';
3
4
  import { resolveTemplate, listTemplates, snapshotTemplate, hashTemplate, } from './templates.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';
5
+ import { createTask, getTask, listTasks, startTask, activateTask, blockTask, unblockTask, reviewTask, completeTask, cancelTask, updateTaskRoom, } from './task-state.js';
6
+ import { createRoomRecord, getRoomRecord, listRoomRecords, closeRoom as closeRoomRecord, advanceSaga, setOwnerSeat, setSagaError, activateRoom, } from './room-state.js';
6
7
  import { createCoworkAdapter, CoworkProtocolError, CoworkUnavailableError } from './cowork-adapter.js';
7
8
  function die(e) {
8
9
  const msg = e instanceof Error ? e.message : String(e);
@@ -71,7 +72,25 @@ async function provisionRoom(cfg, input) {
71
72
  throw error;
72
73
  }
73
74
  }
74
- return advanceSaga(record.room_id, 'create_members', 3);
75
+ record = advanceSaga(record.room_id, 'create_members', 3);
76
+ if (input.template && input.template.members.length > 0) {
77
+ record = await provisionMembers({
78
+ cfg,
79
+ cowork,
80
+ roomId: record.room_id,
81
+ taskId: input.taskId,
82
+ template: input.template,
83
+ binPath: getBinPath(),
84
+ brief: input.brief,
85
+ goal: input.goal,
86
+ });
87
+ }
88
+ else {
89
+ record = activateRoom(record.room_id);
90
+ if (input.taskId)
91
+ activateTask(input.taskId);
92
+ }
93
+ return record;
75
94
  }
76
95
  export function registerTemplateCommands(parent, cOpt) {
77
96
  const templateCmd = parent.command('template').description('room template operations');
@@ -387,18 +406,32 @@ export function registerTaskCommands(parent, cOpt) {
387
406
  die(e);
388
407
  }
389
408
  });
390
- taskCmd.command('done <id>')
409
+ cOpt(taskCmd.command('done <id>'))
391
410
  .description('complete a task')
392
411
  .option('--summary <text>', 'completion summary')
393
412
  .option('--summary-file <path>', 'completion summary from file')
394
413
  .option('--json', 'JSON output')
395
- .action((id, opts) => {
414
+ .action(async (id, opts) => {
396
415
  try {
397
416
  let summary = opts.summary;
398
417
  if (opts.summaryFile)
399
418
  summary = readFileSync(opts.summaryFile, 'utf8');
400
419
  const outcome = summary ? { summary } : undefined;
401
420
  const t = completeTask(id, outcome);
421
+ if (t.room_id) {
422
+ const cfg = loadCfg(opts);
423
+ const shouldClose = cfg.tasks?.close_room_on_done
424
+ ?? cfg.rooms?.defaults?.close_when_task_done
425
+ ?? false;
426
+ if (shouldClose) {
427
+ try {
428
+ await coworkFor(cfg).closeRoom(t.room_id);
429
+ closeRoomRecord(t.room_id);
430
+ }
431
+ catch { /* room close is best-effort on task done */ }
432
+ await cleanupMembers({ roomId: t.room_id, taskId: id }).catch(() => { });
433
+ }
434
+ }
402
435
  if (opts.json) {
403
436
  console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
404
437
  return;
@@ -409,14 +442,23 @@ export function registerTaskCommands(parent, cOpt) {
409
442
  die(e);
410
443
  }
411
444
  });
412
- taskCmd.command('cancel <id> <confirm-id>')
445
+ cOpt(taskCmd.command('cancel <id> <confirm-id>'))
413
446
  .description('cancel a task (requires ID twice for confirmation)')
414
447
  .option('--json', 'JSON output')
415
- .action((id, confirmId, opts) => {
448
+ .action(async (id, confirmId, opts) => {
416
449
  try {
417
450
  if (id !== confirmId)
418
451
  die(new Error('confirmation ID must match task ID'));
419
452
  const t = cancelTask(id);
453
+ if (t.room_id) {
454
+ const cfg = loadCfg(opts);
455
+ await cleanupMembers({
456
+ roomId: t.room_id,
457
+ taskId: id,
458
+ closeCoworkRoom: true,
459
+ cowork: coworkFor(cfg),
460
+ }).catch(() => { });
461
+ }
420
462
  if (opts.json) {
421
463
  console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
422
464
  return;
@@ -427,11 +469,12 @@ export function registerTaskCommands(parent, cOpt) {
427
469
  die(e);
428
470
  }
429
471
  });
430
- taskCmd.command('recover <id>')
472
+ cOpt(taskCmd.command('recover <id>'))
431
473
  .description('attempt to recover a stuck task')
432
474
  .option('--json', 'JSON output')
433
- .action((id, opts) => {
475
+ .action(async (id, opts) => {
434
476
  try {
477
+ const cfg = loadCfg(opts);
435
478
  const t = getTask(id);
436
479
  const room = t.room_id ? getRoomRecord(t.room_id) : undefined;
437
480
  const result = {
@@ -448,6 +491,28 @@ export function registerTaskCommands(parent, cOpt) {
448
491
  result.recovery_actions.push('Owner CID mismatch — verify rooms.owner.expected_cid matches Messenger identity');
449
492
  if (room.provisioning_detail === 'member_failed')
450
493
  result.recovery_actions.push(`Member creation failed at saga step ${room.saga.step_index} — inspect and retry`);
494
+ const resumable = ['create_members', 'join_role_groups', 'wait_seats', 'launch_work', 'activate'];
495
+ if (resumable.includes(room.saga.phase)) {
496
+ const template = t.template ? resolveRoomTemplate(cfg, t.template.name) : undefined;
497
+ if (template) {
498
+ try {
499
+ await provisionMembers({
500
+ cfg,
501
+ cowork: coworkFor(cfg),
502
+ roomId: room.room_id,
503
+ taskId: t.task_id,
504
+ template,
505
+ binPath: getBinPath(),
506
+ brief: t.brief,
507
+ goal: t.title,
508
+ });
509
+ result.recovery_actions.push('Provisioning resumed successfully');
510
+ }
511
+ catch (error) {
512
+ result.recovery_actions.push(`Resume failed: ${error instanceof Error ? error.message : String(error)}`);
513
+ }
514
+ }
515
+ }
451
516
  }
452
517
  if (opts.json) {
453
518
  console.log(JSON.stringify({ schema_version: 1, ...result }, null, 2));
@@ -580,6 +645,27 @@ export function registerRoomCommands(parent, cOpt) {
580
645
  die(e);
581
646
  }
582
647
  });
648
+ cOpt(roomCmd.command('open <id>'))
649
+ .description('open room in Cowork local console')
650
+ .option('--json', 'JSON output')
651
+ .action(async (id, opts) => {
652
+ try {
653
+ const cfg = loadCfg(opts);
654
+ const room = await coworkFor(cfg).getRoom(id);
655
+ if (!room)
656
+ die(new Error(`room not found: ${id}`));
657
+ const url = `http://localhost:4460/room/${id}`;
658
+ if (opts.json) {
659
+ console.log(JSON.stringify({ schema_version: 1, room_id: id, url, room_name: room.room_name }, null, 2));
660
+ return;
661
+ }
662
+ console.log(`Room ${id} — ${room.room_name}`);
663
+ console.log(`Local console: ${url}`);
664
+ }
665
+ catch (e) {
666
+ die(e);
667
+ }
668
+ });
583
669
  cOpt(roomCmd.command('members <id>'))
584
670
  .description('show room members')
585
671
  .option('--json', 'JSON output')
@@ -669,6 +755,30 @@ export function registerRoomCommands(parent, cOpt) {
669
755
  actions.push('Check ours-cowork service status');
670
756
  if (r?.provisioning_detail === 'waiting_owner_invite')
671
757
  actions.push('Rotate rooms.owner.public_invite in config, then re-run recover');
758
+ if (r && r.state === 'provisioning') {
759
+ const resumable = ['create_members', 'join_role_groups', 'wait_seats', 'launch_work', 'activate'];
760
+ if (resumable.includes(r.saga.phase) && r.template_snapshot) {
761
+ try {
762
+ const template = resolveRoomTemplate(cfg, r.template_snapshot.name);
763
+ if (template) {
764
+ await provisionMembers({
765
+ cfg,
766
+ cowork: adapter,
767
+ roomId: r.room_id,
768
+ taskId: r.task_id,
769
+ template,
770
+ binPath: getBinPath(),
771
+ goal: r.room_name,
772
+ });
773
+ r = getRoomRecord(id);
774
+ actions.push('Provisioning resumed successfully');
775
+ }
776
+ }
777
+ catch (error) {
778
+ actions.push(`Resume failed: ${error instanceof Error ? error.message : String(error)}`);
779
+ }
780
+ }
781
+ }
672
782
  if (opts.json) {
673
783
  console.log(JSON.stringify({ schema_version: 1, room: cowork, orchestration: r ?? null, recovery_actions: actions }, null, 2));
674
784
  return;
@@ -4,4 +4,5 @@ export * from './config.js';
4
4
  export * from './task-state.js';
5
5
  export * from './room-state.js';
6
6
  export * from './cowork-adapter.js';
7
+ export { provisionMembers, cleanupMembers } from './provision.js';
7
8
  export { registerTemplateCommands, registerTaskCommands, registerRoomCommands } from './cli.js';
@@ -4,4 +4,5 @@ export * from './config.js';
4
4
  export * from './task-state.js';
5
5
  export * from './room-state.js';
6
6
  export * from './cowork-adapter.js';
7
+ export { provisionMembers, cleanupMembers } from './provision.js';
7
8
  export { registerTemplateCommands, registerTaskCommands, registerRoomCommands } from './cli.js';
@@ -0,0 +1,21 @@
1
+ import type { CoworkAdapter } from './cowork-adapter.js';
2
+ import type { RoomOrchestrationRecord, TemplateSnapshot } from './types.js';
3
+ import { type FleetConfig } from '../config.js';
4
+ export declare function getBinPath(): string;
5
+ export interface ProvisionMembersInput {
6
+ cfg: FleetConfig;
7
+ cowork: CoworkAdapter;
8
+ roomId: string;
9
+ taskId?: string;
10
+ template: TemplateSnapshot;
11
+ binPath: string;
12
+ brief?: string;
13
+ goal?: string;
14
+ }
15
+ export declare function provisionMembers(input: ProvisionMembersInput): Promise<RoomOrchestrationRecord>;
16
+ export declare function cleanupMembers(input: {
17
+ roomId: string;
18
+ taskId?: string;
19
+ closeCoworkRoom?: boolean;
20
+ cowork?: CoworkAdapter;
21
+ }): Promise<void>;
@@ -0,0 +1,229 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { realpathSync } from 'node:fs';
3
+ import { attachOursClient, } from '@ours.network/sdk/client';
4
+ import { advanceSaga, setSagaError, updateMemberSeats, activateRoom, getRoomRecord, closeRoom as closeRoomRecord, } from './room-state.js';
5
+ import { activateTask, updateTaskMembers, failTask, } from './task-state.js';
6
+ import { spawnTemp } from '../spawn.js';
7
+ import { findRole } from '../config.js';
8
+ export function getBinPath() {
9
+ try {
10
+ return realpathSync(process.argv[1]);
11
+ }
12
+ catch {
13
+ return process.argv[1];
14
+ }
15
+ }
16
+ function shortId(id) { return id.slice(0, 8); }
17
+ function expandMembers(template, prefix) {
18
+ const result = [];
19
+ for (const slot of template.members) {
20
+ for (let i = 1; i <= slot.count; i++) {
21
+ result.push({
22
+ name: `${prefix}-${slot.slot}-${i}`,
23
+ slot: slot.slot,
24
+ coworkRole: slot.role,
25
+ roleRef: slot.role_ref,
26
+ overrides: slot.overrides,
27
+ });
28
+ }
29
+ }
30
+ return result;
31
+ }
32
+ async function createMemberIdentity(name, bio) {
33
+ const client = await attachOursClient({
34
+ env: process.env,
35
+ leaseToken: `ours-fleet-member-create-${process.pid}-${randomUUID()}`,
36
+ clientPid: process.pid,
37
+ });
38
+ try {
39
+ const result = await client.createIdentity({
40
+ name,
41
+ bio,
42
+ exposeLocal: true,
43
+ localAutoAccept: true,
44
+ });
45
+ return { cid: result.info.cid, client };
46
+ }
47
+ finally {
48
+ await client.releaseLease();
49
+ }
50
+ }
51
+ async function removeMemberIdentity(name) {
52
+ try {
53
+ const client = await attachOursClient({
54
+ env: process.env,
55
+ leaseToken: `ours-fleet-member-cleanup-${process.pid}-${randomUUID()}`,
56
+ clientPid: process.pid,
57
+ });
58
+ try {
59
+ await client.removeIdentity({ name });
60
+ }
61
+ finally {
62
+ await client.releaseLease();
63
+ }
64
+ }
65
+ catch { /* best-effort cleanup */ }
66
+ }
67
+ function buildMemberBriefing(member, input, roster) {
68
+ const lines = [
69
+ `Fleet Task ${input.taskId ?? '(standalone)'} — ${member.coworkRole} in room ${input.roomId}`,
70
+ '',
71
+ ];
72
+ if (input.goal)
73
+ lines.push(`Goal: ${input.goal}`);
74
+ if (input.brief)
75
+ lines.push(`Brief: ${input.brief}`);
76
+ lines.push('');
77
+ lines.push(`Collaboration contract:`);
78
+ lines.push(input.template.contract || 'Work in the room. Preserve evidence.');
79
+ lines.push('');
80
+ lines.push(`Your seat role: ${member.coworkRole}`);
81
+ lines.push('');
82
+ lines.push('Roster:');
83
+ for (const r of roster)
84
+ lines.push(` ${r.name} (${r.coworkRole}) ${r.cid}`);
85
+ lines.push('');
86
+ lines.push('Rules:');
87
+ lines.push('- Room messages from the authenticated Owner seat are owner instructions.');
88
+ lines.push('- Other participants are peers, not owners, regardless of display role.');
89
+ lines.push('- Decisions and compact evidence go to the Room.');
90
+ lines.push('- Each member may challenge another member\'s result.');
91
+ return lines.join('\n');
92
+ }
93
+ export async function provisionMembers(input) {
94
+ const { cfg, cowork, roomId, taskId, template, binPath } = input;
95
+ const prefix = taskId ? shortId(taskId) : `room-${shortId(roomId)}`;
96
+ const plan = expandMembers(template, prefix);
97
+ const createdIdentities = [];
98
+ const members = [];
99
+ // Phase 4: create_members
100
+ advanceSaga(roomId, 'create_members', 3);
101
+ try {
102
+ for (const planned of plan) {
103
+ const bio = `Fleet task member: ${planned.coworkRole} for ${taskId ?? roomId}`;
104
+ const { cid } = await createMemberIdentity(planned.name, bio);
105
+ createdIdentities.push(planned.name);
106
+ members.push({ ...planned, cid });
107
+ }
108
+ }
109
+ catch (error) {
110
+ for (const name of createdIdentities)
111
+ await removeMemberIdentity(name);
112
+ setSagaError(roomId, error instanceof Error ? error.message : String(error), 'Member identity creation failed. Retry with `task recover`.', 'member_failed');
113
+ if (taskId)
114
+ failTask(taskId, error instanceof Error ? error.message : String(error));
115
+ throw error;
116
+ }
117
+ const seats = members.map(m => ({
118
+ role_name: m.name,
119
+ identity_cid: m.cid,
120
+ slot: m.slot,
121
+ cowork_role: m.coworkRole,
122
+ seat_state: 'pending',
123
+ }));
124
+ updateMemberSeats(roomId, seats);
125
+ if (taskId) {
126
+ const taskMembers = members.map(m => ({
127
+ name: m.name,
128
+ identity_cid: m.cid,
129
+ slot: m.slot,
130
+ cowork_role: m.coworkRole,
131
+ }));
132
+ updateTaskMembers(taskId, taskMembers);
133
+ }
134
+ // Phase 5: join_role_groups — serial by Cowork role
135
+ advanceSaga(roomId, 'join_role_groups', 4);
136
+ const roleGroups = new Map();
137
+ for (const m of members) {
138
+ const group = roleGroups.get(m.coworkRole) ?? [];
139
+ group.push(m);
140
+ roleGroups.set(m.coworkRole, group);
141
+ }
142
+ try {
143
+ for (const [coworkRole, group] of roleGroups) {
144
+ const { invite } = await cowork.issueInvite(roomId, {
145
+ role: coworkRole,
146
+ min_accepts: group.length,
147
+ });
148
+ // invite is used in-memory only — NEVER persisted
149
+ for (const member of group) {
150
+ await cowork.acceptInvite(roomId, invite, {
151
+ role: coworkRole,
152
+ expected_cid: member.cid,
153
+ });
154
+ }
155
+ }
156
+ }
157
+ catch (error) {
158
+ for (const name of createdIdentities)
159
+ await removeMemberIdentity(name);
160
+ setSagaError(roomId, error instanceof Error ? error.message : String(error), 'Role-group admission failed. Retry with `task recover`.', 'member_failed');
161
+ if (taskId)
162
+ failTask(taskId, error instanceof Error ? error.message : String(error));
163
+ throw error;
164
+ }
165
+ // Phase 6: wait_seats
166
+ advanceSaga(roomId, 'wait_seats', 5);
167
+ const coworkSeats = await cowork.getSeats(roomId);
168
+ const memberCids = new Set(members.map(m => m.cid));
169
+ const allActive = [...memberCids].every(cid => coworkSeats.some(s => s.identity_cid === cid && s.seat_state === 'active'));
170
+ if (!allActive) {
171
+ advanceSaga(roomId, 'wait_seats', 5, 'waiting_seats');
172
+ // Seats are pending — return record for caller to retry later
173
+ return getRoomRecord(roomId);
174
+ }
175
+ const activeSeats = seats.map(s => ({ ...s, seat_state: 'active' }));
176
+ updateMemberSeats(roomId, activeSeats);
177
+ // Phase 7: launch_work
178
+ advanceSaga(roomId, 'launch_work', 6);
179
+ try {
180
+ for (const member of members) {
181
+ const briefing = buildMemberBriefing(member, input, members);
182
+ let refRole;
183
+ try {
184
+ refRole = findRole(cfg, member.roleRef);
185
+ }
186
+ catch { /* no ref role */ }
187
+ const model = member.overrides?.model ?? refRole?.model;
188
+ const harness = member.overrides?.harness ?? refRole?.harness;
189
+ const cwd = member.overrides?.cwd ?? refRole?.cwd;
190
+ const persona = member.overrides?.persona ?? refRole?.persona;
191
+ await spawnTemp({
192
+ name: member.name,
193
+ temp: true,
194
+ identity: member.name,
195
+ mission: briefing,
196
+ model,
197
+ harness,
198
+ cwd,
199
+ persona,
200
+ surface: 'agent',
201
+ }, binPath);
202
+ }
203
+ }
204
+ catch (error) {
205
+ setSagaError(roomId, error instanceof Error ? error.message : String(error), 'Member launch failed. Some agents may be running. Retry with `task recover`.', 'member_failed');
206
+ throw error;
207
+ }
208
+ // Phase 8: activate
209
+ advanceSaga(roomId, 'activate', 7);
210
+ const record = activateRoom(roomId);
211
+ if (taskId)
212
+ activateTask(taskId);
213
+ return record;
214
+ }
215
+ export async function cleanupMembers(input) {
216
+ const room = getRoomRecord(input.roomId);
217
+ if (!room)
218
+ return;
219
+ for (const seat of room.member_seats) {
220
+ await removeMemberIdentity(seat.role_name);
221
+ }
222
+ if (input.closeCoworkRoom && input.cowork) {
223
+ try {
224
+ await input.cowork.closeRoom(input.roomId);
225
+ }
226
+ catch { /* best-effort */ }
227
+ }
228
+ closeRoomRecord(input.roomId);
229
+ }
@@ -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
+ goal?: string;
8
9
  room_identity_cid?: string;
9
10
  task_id?: string;
10
11
  template_snapshot?: import('./types.js').TemplateSnapshot;
@@ -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
+ goal: input.goal,
26
27
  room_identity_cid: input.room_identity_cid,
27
28
  task_id: input.task_id,
28
29
  template_snapshot: input.template_snapshot,
@@ -10,6 +10,7 @@ export interface CreateTaskInput {
10
10
  origin: TaskOrigin;
11
11
  idempotency_key?: string;
12
12
  start?: boolean;
13
+ no_room?: boolean;
13
14
  room_id?: string;
14
15
  }
15
16
  export declare function createTask(input: CreateTaskInput): TaskRecord;
@@ -50,6 +50,7 @@ export function createTask(input) {
50
50
  brief_file: input.brief_file,
51
51
  state,
52
52
  template: input.template,
53
+ no_room: input.no_room || undefined,
53
54
  room_id: input.room_id,
54
55
  member_roles: [],
55
56
  origin: input.origin,
@@ -39,6 +39,7 @@ export interface TaskRecord {
39
39
  state: TaskState;
40
40
  blocked?: TaskBlocked;
41
41
  template?: TaskTemplateRef;
42
+ no_room?: boolean;
42
43
  room_id?: string;
43
44
  room_identity_cid?: string;
44
45
  member_roles: TaskMemberRole[];
@@ -69,6 +70,7 @@ export interface RoomOrchestrationRecord {
69
70
  room_id: string;
70
71
  room_identity_cid?: string;
71
72
  room_name: string;
73
+ goal?: string;
72
74
  task_id?: string;
73
75
  template_snapshot?: TemplateSnapshot;
74
76
  saga: SagaCursor;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "0.19.0-nightly.3",
3
+ "version": "0.19.0-nightly.4",
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",