@ours.network/fleet 2.0.3 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,7 +17,6 @@ export interface OwnerCommentsState {
17
17
  /** False when this session's backend never emits ACP commentary at all. */
18
18
  supported: boolean;
19
19
  }
20
- export type OwnerProgressPhase = 'starting request' | 'waiting behind earlier requests' | 'working on request' | 'planning next step' | 'using tools' | 'reviewing tool results' | 'drafting response' | 'waiting for approval' | 'resuming after permission decision' | 'recovering from session error';
21
20
  export declare const ownerNotices: {
22
21
  receivedStarted: () => string;
23
22
  receivedQueued: (queuedBehind: number) => string;
@@ -43,7 +42,6 @@ export declare const ownerNotices: {
43
42
  attachmentRejected: (reason: string) => string;
44
43
  attachmentFailed: () => string;
45
44
  deliveryFailed: (role: string) => string;
46
- progress: (elapsedMs: number, phase: OwnerProgressPhase, started: number, completed: number, activityUpdates?: number) => string;
47
45
  /** The labeled form of one relayed ACP live comment. */
48
46
  comment: (message: string) => string;
49
47
  comments: (state: OwnerCommentsState) => string;
@@ -5,18 +5,6 @@
5
5
  * never be spoofed away by model content.
6
6
  */
7
7
  export const OWNER_COMMENT_LABEL = '🟡 Live update:';
8
- const duration = (elapsedMs) => {
9
- const seconds = Math.max(0, Math.floor(elapsedMs / 1_000));
10
- const hours = Math.floor(seconds / 3_600);
11
- const minutes = Math.floor((seconds % 3_600) / 60);
12
- const remainder = seconds % 60;
13
- return [hours ? `${hours}h` : '', minutes ? `${minutes}m` : '', remainder ? `${remainder}s` : '']
14
- .filter(Boolean).join(' ') || '0s';
15
- };
16
- const count = (value, singular, plural = `${singular}s`) => `${value} ${value === 1 ? singular : plural}`;
17
- const joinCounts = (parts) => parts.length < 2
18
- ? parts[0] ?? ''
19
- : `${parts.slice(0, -1).join(', ')} and ${parts.at(-1)}`;
20
8
  export const ownerNotices = {
21
9
  receivedStarted: () => 'ℹ️ Message received. The agent has started working on this request now. '
22
10
  + 'The response will arrive in this channel when ready.',
@@ -64,19 +52,6 @@ export const ownerNotices = {
64
52
  attachmentRejected: (reason) => `⚠️ Attachment rejected: ${reason}.`,
65
53
  attachmentFailed: () => '⚠️ Could not securely retrieve or admit this attachment request.',
66
54
  deliveryFailed: (role) => `⚠️ Could not deliver this request to ${role}.`,
67
- progress: (elapsedMs, phase, started, completed, activityUpdates = 0) => {
68
- const counts = [];
69
- if (started)
70
- counts.push(`${count(started, 'tool action')} started`);
71
- if (completed)
72
- counts.push(`${count(completed, 'tool action')} completed`);
73
- if (activityUpdates)
74
- counts.push(`${count(activityUpdates, counts.length ? 'additional activity update' : 'activity update')} observed`);
75
- const activity = counts.length
76
- ? `${joinCounts(counts)} since the last update.`
77
- : 'no new reportable activity since the last update.';
78
- return `⏳ Working for ${duration(elapsedMs)} · ${phase} · ${activity}`;
79
- },
80
55
  /** The labeled form of one relayed ACP live comment. */
81
56
  comment: (message) => `${OWNER_COMMENT_LABEL} ${message}`,
82
57
  comments: (state) => `🟡 Live updates are ${state.enabled ? 'ON' : 'OFF'} for this session `
@@ -57,6 +57,8 @@ export interface OursOps {
57
57
  start(): Promise<void>;
58
58
  /** Bind this session's identity. Throws `BOUND_ELSEWHERE` when it is held live. */
59
59
  bindIdentity(name: string): Promise<void>;
60
+ /** CID returned by the current successful mailbox binding; absent while unbound. */
61
+ boundIdentityCid?(): string | undefined;
60
62
  /** Replace the bound identity's advertised typed-command catalog and handlers. */
61
63
  registerCommands(commands: OursRegisteredCommand[]): Promise<void>;
62
64
  listContacts(): Promise<OursContactsView>;
@@ -132,11 +134,13 @@ export declare class OursSdkClient implements OursOps {
132
134
  private readonly log;
133
135
  private readonly deps;
134
136
  private client?;
137
+ private boundCid?;
135
138
  private terminalReleasePending;
136
139
  private readonly leaseToken;
137
140
  constructor(env?: Record<string, string>, log?: (line: string) => void, deps?: OursSdkClientDeps);
138
141
  start(): Promise<void>;
139
142
  bindIdentity(name: string): Promise<void>;
143
+ boundIdentityCid(): string | undefined;
140
144
  registerCommands(commands: OursRegisteredCommand[]): Promise<void>;
141
145
  listContacts(): Promise<OursContactsView>;
142
146
  generateInvite(name?: string): Promise<OursInviteResult>;
@@ -117,6 +117,7 @@ export class OursSdkClient {
117
117
  log;
118
118
  deps;
119
119
  client;
120
+ boundCid;
120
121
  // Disposing a transport does not discharge this owner's terminal cleanup.
121
122
  terminalReleasePending = false;
122
123
  leaseToken = `ours-fleet-owner-${process.pid}-${randomUUID()}`;
@@ -141,8 +142,12 @@ export class OursSdkClient {
141
142
  async bindIdentity(name) {
142
143
  // force is pinned off: the owner channel never evicts another live session
143
144
  // from an identity, it waits for the bounded handoff window and then fails.
144
- await this.ops().chooseIdentity({ name, force: false });
145
+ this.boundCid = undefined;
146
+ const result = await this.ops().chooseIdentity({ name, force: false });
147
+ if (typeof result.cid === 'string' && /^[a-f0-9]{64}$/i.test(result.cid))
148
+ this.boundCid = result.cid.toLowerCase();
145
149
  }
150
+ boundIdentityCid() { return this.boundCid; }
146
151
  async registerCommands(commands) {
147
152
  await this.ops().registerCommands(commands);
148
153
  }
@@ -218,6 +223,7 @@ export class OursSdkClient {
218
223
  + 'session must be re-established after an upgrade; files are not queued');
219
224
  }
220
225
  async close(options = {}) {
226
+ this.boundCid = undefined;
221
227
  const terminal = options.releaseLease !== false;
222
228
  // A failed recovery may have disposed its client before normal shutdown.
223
229
  // Reattach the same owner through ordinary verified selection; no rebind is
@@ -2,6 +2,7 @@ import type { FleetConfig } from '../config.js';
2
2
  import { TaskStateError } from './task-state.js';
3
3
  import { provisionMembers } from './provision.js';
4
4
  import type { CoworkAdapter } from './cowork-adapter.js';
5
+ import type { TaskLayouts } from '../application/task-layouts.js';
5
6
  export declare class MemberAdditionRejected extends TaskStateError {
6
7
  readonly accepted = false;
7
8
  }
@@ -29,10 +30,11 @@ export declare function requestMemberAddition(input: {
29
30
  taskId: string;
30
31
  request: AddMemberRequest;
31
32
  cfg: FleetConfig;
32
- cowork: CoworkAdapter;
33
+ cowork?: CoworkAdapter;
33
34
  configPath?: string;
34
35
  binPath: string;
35
36
  provision?: typeof provisionMembers;
37
+ layouts?: TaskLayouts;
36
38
  }): Promise<{
37
39
  requestId: string;
38
40
  taskId: string;
@@ -10,6 +10,9 @@ import { getRoomRecord } from './room-state.js';
10
10
  import { prepareExecutionPlan } from './member-overrides.js';
11
11
  import { sealTemplateSnapshot } from './templates.js';
12
12
  import { provisionMembers } from './provision.js';
13
+ import { EMPTY_TASK_LAYOUT, EMPTY_TASK_ROOM } from './empty-layout.js';
14
+ import { layoutOwnedMemberName } from './layout-member-retirement.js';
15
+ import { validLayoutKey } from './layout-config.js';
13
16
  export class MemberAdditionRejected extends TaskStateError {
14
17
  accepted = false;
15
18
  }
@@ -46,6 +49,43 @@ export async function requestMemberAddition(input) {
46
49
  }
47
50
  const task = getTask(taskId);
48
51
  const room = task.room_id && getRoomRecord(task.room_id);
52
+ if (task.layout?.name === EMPTY_TASK_LAYOUT && input.layouts) {
53
+ if (!validLayoutKey(r.slot))
54
+ throw new MemberAdditionRejected('Invalid member name');
55
+ const view = input.layouts.view(task);
56
+ if (!['active', 'review'].includes(task.state) || task.terminal_intent || task.deletion?.status === 'pending'
57
+ || view?.rooms.find(room => room.key === EMPTY_TASK_ROOM)?.state !== 'active' || view.uncertain || view.closing)
58
+ throw new MemberAdditionRejected('Adding an agent requires an active task room');
59
+ if (view.participants.some(p => p.key === r.slot))
60
+ throw new MemberAdditionRejected('This member slot already exists');
61
+ if (r.slot === (input.cfg.rooms?.owner.role ?? 'Owner'))
62
+ throw new MemberAdditionRejected('A member cannot use the Owner role');
63
+ const definition = (() => {
64
+ try {
65
+ const plan = prepareExecutionPlan({ name: 'additional-member', version: 1, description: 'Additional task participant',
66
+ members: [{ slot: r.slot, role: r.slot, count: 1, agent_template: r.agentTemplate }] }, input.cfg, { [r.slot]: { role: r.role, brain: r.brain, ...(task.workspace ? { cwd: task.workspace.path } : {}) } });
67
+ return Object.values(plan.launchDefinitions)[0];
68
+ }
69
+ catch (error) {
70
+ throw new MemberAdditionRejected(error instanceof Error ? error.message : 'Invalid member configuration');
71
+ }
72
+ })();
73
+ const receipt = { taskId, requestId: r.requestId,
74
+ roleId: layoutOwnedMemberName(task.layout.run_id, r.slot), hash, state: 'running' };
75
+ const save = () => { replaceFileAtomically(path, JSON.stringify(receipt) + '\n', 0o600); publishFleetResourceCommit(path); };
76
+ save();
77
+ running.add(path);
78
+ void input.layouts.addMember(task, { key: r.slot, role: r.slot, requestId: r.requestId, hash, definition }).then(() => {
79
+ receipt.state = 'succeeded';
80
+ }).catch(() => { receipt.state = 'attention'; receipt.error = 'Member launch is not confirmed. Inspect the task layout and agent before recovery.'; })
81
+ .finally(() => { try {
82
+ save();
83
+ }
84
+ finally {
85
+ running.delete(path);
86
+ } }).catch(() => { running.delete(path); });
87
+ return memberAddition(taskId, r.requestId);
88
+ }
49
89
  if (!room || !['active', 'review'].includes(task.state) || task.terminal_intent || room.state !== 'active'
50
90
  || room.task_id !== taskId || room.room_identity_cid !== task.room_identity_cid)
51
91
  throw new MemberAdditionRejected('Adding an agent requires an active task room');
@@ -1334,12 +1334,12 @@ export function registerTaskCommands(parent, cOpt) {
1334
1334
  }
1335
1335
  });
1336
1336
  cOpt(taskCmd.command('_layout <id> <operation> [room]', { hidden: true }))
1337
- .description('internal: run an accepted layout room operation (open, close-room, close)')
1337
+ .description('internal: run an accepted layout operation (start, open, close-room, close)')
1338
1338
  .option('--json', 'JSON output')
1339
1339
  .option('--operation-id <id>', 'accepted operation ID; status updates apply only while it is current')
1340
1340
  .action(async (id, operation, room, opts) => {
1341
1341
  try {
1342
- if (!['open', 'close-room', 'close'].includes(operation))
1342
+ if (!['start', 'open', 'close-room', 'close'].includes(operation))
1343
1343
  throw new Error(`unknown layout operation: ${operation}`);
1344
1344
  const view = await taskRoomService(opts.configuration).runTaskLayoutOperation({
1345
1345
  actor: { kind: 'internal_worker', surface: 'cli' }, taskId: id,
@@ -0,0 +1,5 @@
1
+ import type { RoomLayoutDefinition } from './layout-config.js';
2
+ /** Reserved built-in source; task runs snapshot it just like a saved layout. */
3
+ export declare const EMPTY_TASK_LAYOUT = "builtin-empty";
4
+ export declare const EMPTY_TASK_ROOM = "mission";
5
+ export declare function emptyTaskLayout(): RoomLayoutDefinition;
@@ -0,0 +1,8 @@
1
+ /** Reserved built-in source; task runs snapshot it just like a saved layout. */
2
+ export const EMPTY_TASK_LAYOUT = 'builtin-empty';
3
+ export const EMPTY_TASK_ROOM = 'mission';
4
+ export function emptyTaskLayout() {
5
+ return { version: 1, participants: {}, rooms: {
6
+ [EMPTY_TASK_ROOM]: { goal: 'Work on the mission', members: [] },
7
+ } };
8
+ }
@@ -24,8 +24,8 @@ export function validateRoomLayout(raw, label = 'room layout') {
24
24
  text(doc.description, `${label}.description`);
25
25
  for (const section of ['participants', 'rooms']) {
26
26
  const value = doc[section];
27
- if (!value || typeof value !== 'object' || Array.isArray(value) || !Object.keys(value).length)
28
- throw Error(`${label}.${section}: non-empty mapping required`);
27
+ if (!value || typeof value !== 'object' || Array.isArray(value) || (section === 'rooms' && !Object.keys(value).length))
28
+ throw Error(`${label}.${section}: ${section === 'rooms' ? 'non-empty ' : ''}mapping required`);
29
29
  for (const key of Object.keys(value))
30
30
  if (!validLayoutKey(key))
31
31
  throw Error(`${label}: invalid key ${key}`);
@@ -12,7 +12,7 @@ export declare class NativeLayoutSupervisor implements LayoutSupervisor {
12
12
  inspect(agent: string, temporary: boolean): Promise<LayoutInstance>;
13
13
  private call;
14
14
  verify(i: LayoutInstance): Promise<void>;
15
- spawn(key: string, template: string): Promise<LayoutInstance>;
15
+ spawn(key: string, template: string, snapshot?: AgentTemplateDefinition): Promise<LayoutInstance>;
16
16
  join(i: LayoutInstance, invite: string, roomCid: string, membership?: {
17
17
  roomId: string;
18
18
  role: string;
@@ -14,6 +14,7 @@ import { callRemoteLayoutBinding } from './layout-binding-client.js';
14
14
  import { readClientProfile } from '../client-profile.js';
15
15
  import { tempSupervisorLiveness } from '../temp-lifecycle.js';
16
16
  import { layoutOwnedMemberName, retireOwnedLayoutMember } from './layout-member-retirement.js';
17
+ import { EMPTY_TASK_LAYOUT, emptyTaskLayout } from './empty-layout.js';
17
18
  export class NativeLayoutSupervisor {
18
19
  configPath;
19
20
  runId;
@@ -47,8 +48,8 @@ export class NativeLayoutSupervisor {
47
48
  return this.request(i.agent, i.temporary, { ...request, instance: i });
48
49
  }
49
50
  async verify(i) { await this.call(i, { action: 'verify' }); }
50
- async spawn(key, template) {
51
- const definition = this.templates[template];
51
+ async spawn(key, template, snapshot) {
52
+ const definition = snapshot ?? this.templates[template];
52
53
  if (!definition)
53
54
  throw Error(`agent template not found in run snapshot: ${template}`);
54
55
  const name = layoutOwnedMemberName(this.runId, key);
@@ -145,6 +146,8 @@ export class RoomLayoutService {
145
146
  }
146
147
  /** Reads only the named file, so an unrelated broken layout cannot block this one. */
147
148
  definition(name) {
149
+ if (name === EMPTY_TASK_LAYOUT)
150
+ return emptyTaskLayout();
148
151
  if (!validLayoutKey(name))
149
152
  throw Error(`room layout not found: ${name}`);
150
153
  const root = join(splitRootFor(this.configPath ?? defaultConfigPath()), 'room_layouts');
@@ -53,7 +53,7 @@ export interface LayoutSupervisor {
53
53
  id: string;
54
54
  /** Must verify current launch, CID, live session and standalone ownership. */
55
55
  verify(instance: LayoutInstance): Promise<void>;
56
- spawn(key: string, template: string): Promise<LayoutInstance>;
56
+ spawn(key: string, template: string, definition?: import('../config.js').AgentTemplateDefinition): Promise<LayoutInstance>;
57
57
  join(instance: LayoutInstance, invite: string, roomCid: string, membership?: {
58
58
  roomId: string;
59
59
  role: string;
@@ -86,6 +86,14 @@ export interface RoomLayoutState {
86
86
  controller: string;
87
87
  participants: Record<string, ParticipantState>;
88
88
  rooms: Record<string, RoomState>;
89
+ /** Runtime additions leave the pinned source definition unchanged. */
90
+ additional_members?: Record<string, {
91
+ room: string;
92
+ role: string;
93
+ template: string;
94
+ request_id: string;
95
+ hash: string;
96
+ }>;
89
97
  order: string[];
90
98
  closed: boolean;
91
99
  closing?: boolean;
@@ -109,6 +117,15 @@ export declare class RoomLayout {
109
117
  private mutation;
110
118
  private activateRoom;
111
119
  activate(actor: string, key: string): Promise<void>;
120
+ /** Append an explicitly requested agent to an active owner-only room, with durable retry identity. */
121
+ addMember(actor: string, input: {
122
+ key: string;
123
+ room: string;
124
+ role: string;
125
+ requestId: string;
126
+ hash: string;
127
+ definition: import('../config.js').AgentTemplateDefinition;
128
+ }): Promise<void>;
112
129
  private assertRoomActive;
113
130
  private archiveRoom;
114
131
  closeRoom(actor: string, key: string): Promise<void>;
@@ -14,17 +14,20 @@ function need(ok, message) { if (!ok)
14
14
  throw Error(message); }
15
15
  /** Room/participant keys cannot contain ':', so scoped instance keys cannot collide. */
16
16
  function instanceKey(definition, participant, room) {
17
- return definition.participants[participant].instance_scope === 'room' ? `${room}:${participant}` : participant;
17
+ return definition.participants[participant]?.instance_scope === 'room' ? `${room}:${participant}` : participant;
18
18
  }
19
19
  export function validateLayout(def) {
20
- need(Object.keys(def.rooms).length && Object.keys(def.participants).length, 'rooms and participants required');
20
+ need(Object.keys(def.rooms).length, 'rooms required');
21
+ const empty = Object.keys(def.participants).length === 0;
22
+ if (empty)
23
+ need(Object.keys(def.rooms).length === 1, 'an empty layout requires exactly one room');
21
24
  for (const [key, participant] of Object.entries(def.participants)) {
22
25
  need(participant.instance_scope === undefined || ['layout', 'room'].includes(participant.instance_scope), `invalid instance_scope for ${key}`);
23
26
  if (participant.instance_scope === 'room')
24
27
  need(typeof participant.agent_template === 'string' && participant.agent_template.trim(), `room-scoped participant ${key} requires agent_template`);
25
28
  }
26
29
  for (const [key, room] of Object.entries(def.rooms)) {
27
- need(key.length && room.goal.trim() && room.members.length, 'room goal and members required');
30
+ need(key.length && room.goal.trim() && (empty || room.members.length), 'room goal and members required');
28
31
  need(new Set(room.members).size === room.members.length, 'duplicate participant in room');
29
32
  for (const member of room.members)
30
33
  need(own(def.participants, member), `unknown participant ${member}`);
@@ -87,6 +90,8 @@ export class RoomLayout {
87
90
  });
88
91
  }
89
92
  checkOwnerRoles(definition) {
93
+ if (!Object.keys(definition.participants).length)
94
+ need(this.owner, 'an empty layout requires an attached room owner');
90
95
  if (!this.owner)
91
96
  return;
92
97
  for (const room of Object.values(definition.rooms))
@@ -113,6 +118,9 @@ export class RoomLayout {
113
118
  this.save(s);
114
119
  }
115
120
  async activateRoom(s, key, spec) {
121
+ const additions = Object.entries(s.additional_members ?? {}).filter(([, member]) => member.room === key);
122
+ spec = { ...spec, members: [...spec.members, ...additions.map(([name]) => name)],
123
+ roles: { ...spec.roles, ...Object.fromEntries(additions.map(([name, member]) => [name, member.role])) } };
116
124
  if (s.owner)
117
125
  need(this.owner && canonicalJson(s.owner) === canonicalJson(ownerReference(this.owner)), 'room owner configuration changed');
118
126
  this.checkOwnerRoles(s.definition);
@@ -129,15 +137,18 @@ export class RoomLayout {
129
137
  return;
130
138
  }
131
139
  const room = s.rooms[key] ??= { spec, state: 'provisioning', ready: [] };
140
+ room.spec = spec;
132
141
  this.save(s);
133
142
  for (const member of spec.members) {
134
143
  const resolvedKey = instanceKey(s.definition, member, key), p = s.participants[resolvedKey];
135
144
  if (!p.instance) {
136
- const template = s.definition.participants[member]?.agent_template;
145
+ const template = s.definition.participants[member]?.agent_template ?? s.additional_members?.[member]?.template;
137
146
  need(template, `no factory for ${member}`);
138
147
  if (this.supervisor.taskSupervised)
139
148
  p.task_supervised = true;
140
- await this.mutation(s, `spawn:${resolvedKey}`, () => this.supervisor.spawn(resolvedKey, template), i => { p.instance = i; });
149
+ await this.mutation(s, `spawn:${resolvedKey}`, () => s.additional_members?.[member]
150
+ ? this.supervisor.spawn(resolvedKey, template, s.agent_templates?.[template])
151
+ : this.supervisor.spawn(resolvedKey, template), i => { p.instance = i; });
141
152
  await this.verify(p.instance);
142
153
  }
143
154
  }
@@ -147,7 +158,8 @@ export class RoomLayout {
147
158
  room_name: `${key.slice(0, 24)}-${createHash('sha256').update(this.file + '\0' + key).digest('hex').slice(0, 24)}`,
148
159
  goal: spec.goal, briefing: JSON.stringify({ goal: spec.goal, contract: spec.contract }),
149
160
  quiet_membership: spec.quiet_membership ?? true, anonymous: spec.anonymous ?? false,
150
- activation_requirements: [...new Set(spec.members.map(roleFor))].map(role => ({ role, count: spec.members.filter(m => roleFor(m) === role).length })),
161
+ activation_requirements: !spec.members.length && s.owner ? [{ role: s.owner.role, count: 1 }]
162
+ : [...new Set(spec.members.map(roleFor))].map(role => ({ role, count: spec.members.filter(m => roleFor(m) === role).length })),
151
163
  }), native => { room.native = native; s.order.push(key); });
152
164
  if (s.owner && !room.owner_ready)
153
165
  await this.mutation(s, `owner:${key}`, async () => {
@@ -188,6 +200,28 @@ export class RoomLayout {
188
200
  await this.activateRoom(s, key, s.definition.rooms[key]);
189
201
  });
190
202
  }
203
+ /** Append an explicitly requested agent to an active owner-only room, with durable retry identity. */
204
+ addMember(actor, input) {
205
+ return this.operation(actor, async (s) => {
206
+ need(!Object.keys(s.definition.participants).length, 'manual layout additions require an empty source layout');
207
+ need(/^[A-Za-z][A-Za-z0-9_-]{0,31}$/.test(input.key) && !['constructor', 'prototype', '__proto__'].includes(input.key), 'invalid member key');
208
+ need(s.owner && input.role !== s.owner.role, 'participant cannot use the owner role');
209
+ const existing = s.additional_members?.[input.key];
210
+ if (existing)
211
+ need(existing.request_id === input.requestId && existing.hash === input.hash, 'this member slot already exists');
212
+ else {
213
+ need(s.rooms[input.room]?.state === 'active', 'adding an agent requires an active layout room');
214
+ await this.assertRoomActive(s, input.room);
215
+ const template = `added-${input.key}`;
216
+ (s.additional_members ??= {})[input.key] = { room: input.room, role: input.role, template, request_id: input.requestId, hash: input.hash };
217
+ (s.agent_templates ??= {})[template] = structuredClone(input.definition);
218
+ s.participants[input.key] = { owned: true };
219
+ s.rooms[input.room].state = 'provisioning';
220
+ this.save(s);
221
+ }
222
+ await this.activateRoom(s, input.room, s.definition.rooms[input.room]);
223
+ });
224
+ }
191
225
  async assertRoomActive(s, key) {
192
226
  const room = s.rooms[key], native = await this.cowork.getRoom(room.native.room_id);
193
227
  need(native?.identity_cid === room.native.identity_cid && native.state === 'active', 'native room missing, changed or inactive');
@@ -246,6 +246,7 @@ export class RoleControlServer {
246
246
  version: 1, id: request.id, ok: true,
247
247
  result: {
248
248
  ...this.session.snapshot(),
249
+ ownerChannelCid: this.ownerChannel?.ownerChannelCid?.(),
249
250
  protocolVersion: this.session.conversationPage ? 3 : 2,
250
251
  features: [
251
252
  'events_since', 'observer_follow', 'retained_range',
@@ -1,6 +1,7 @@
1
1
  import { FleetResourceViews, ResourceBindingError, canonicalResourceDescriptor } from './resource-views.js';
2
2
  import { publishFleetResourceCommit } from '../application/resource-observer.js';
3
3
  import { stateRoot, defaultConfigPath } from '../paths.js';
4
+ import { EMPTY_TASK_LAYOUT } from '../rooms-tasks/empty-layout.js';
4
5
  import { conversationPreview } from '../application/conversation-history.js';
5
6
  import { registerConversationStream } from './conversation-stream.js';
6
7
  import { conversationHasAttachment } from '../application/conversation-history.js';
@@ -561,9 +562,9 @@ export async function buildWebServer(services, boundary, options = {}) {
561
562
  const body = request.body;
562
563
  if (typeof body?.title !== 'string' || !body.title)
563
564
  throw new FleetError('invalid_request', 'title is required');
564
- // Every task runs a saved Room Layout; rooms are never defined inline or from room templates here.
565
- if (typeof body.layout !== 'string' || !body.layout)
566
- throw new FleetError('invalid_request', 'layout is required: choose a room layout from Settings › Room layouts');
565
+ // Omission selects a real built-in layout, never a legacy no-room task.
566
+ if (body.layout !== undefined && (typeof body.layout !== 'string' || !body.layout))
567
+ throw new FleetError('invalid_request', 'layout must be a non-empty room layout name, or omitted for the empty layout');
567
568
  if (body.template !== undefined || body.noRoom !== undefined)
568
569
  throw new FleetError('invalid_request', 'tasks use room layouts; template and noRoom are not accepted');
569
570
  const task = await taskApi(() => requireTaskRooms().createTask({
@@ -571,7 +572,8 @@ export async function buildWebServer(services, boundary, options = {}) {
571
572
  brief: typeof body.brief === 'string' ? body.brief : undefined,
572
573
  backlog: body.backlog === true,
573
574
  list: typeof body.list === 'string' ? body.list : undefined,
574
- layout: body.layout,
575
+ layout: body.layout === undefined ? EMPTY_TASK_LAYOUT : body.layout,
576
+ deferLayoutStart: true,
575
577
  idempotencyKey: typeof request.headers['idempotency-key'] === 'string'
576
578
  ? request.headers['idempotency-key'] : undefined,
577
579
  origin: { type: 'web' },
@@ -748,7 +750,7 @@ export async function buildWebServer(services, boundary, options = {}) {
748
750
  // A task's layout can be chosen or changed but never removed, so this check cannot go stale.
749
751
  if (!api.getTask(taskId).task.layout)
750
752
  throw new FleetError('conflict', 'choose a room layout for this task before starting it');
751
- const task = await api.startTask(input);
753
+ const task = await api.startTask({ ...input, deferLayoutStart: true });
752
754
  const provisioning = await api.launchTaskProvisioning(taskId);
753
755
  if (provisioning.kind !== 'ready')
754
756
  reply.code(202);
@@ -3,6 +3,40 @@
3
3
  Room Layout describes room goals, participants and their shared or independent
4
4
  sessions. Rooms and agents have independent lifecycles.
5
5
 
6
+ ## Empty mission layout
7
+
8
+ `POST /api/v1/tasks` may omit `layout`. Fleet then assigns the reserved built-in
9
+ `builtin-empty` layout: one `mission` room and no agent participants. It is a real
10
+ task layout with the usual pinned definition, private run snapshot, room ownership,
11
+ and finish/cancel/delete cleanup. The built-in source cannot be edited or deleted;
12
+ explicit saved-layout names select their configured rooms and participants.
13
+
14
+ Starting this default mission creates its native room and admits the configured
15
+ Owner CID before reporting Active. Starting a saved layout likewise initializes all
16
+ its rooms and required agents automatically, in definition order. Shared
17
+ participants are reused across their rooms. The HTTP request accepts startup
18
+ promptly and a detached worker completes it; the task remains Provisioning until
19
+ all rooms are ready. No agents are spawned. Owner attachment must be
20
+ enabled and an Owner invite configured; an empty ownerless room is rejected.
21
+ Saving with `backlog: true` creates no native room or agents until Start. Unknown
22
+ native mutation outcomes retain the existing reconciliation marker and are not
23
+ blindly replayed. Repeated requests with the same idempotency key reuse the task.
24
+
25
+ An active built-in empty mission accepts agents later through
26
+ `POST /api/v1/tasks/:id/members` and the existing durable member-addition receipt
27
+ API. These explicit additions retain their own launch snapshots alongside the
28
+ original empty definition. They use the mission workspace and existing agent
29
+ ownership, admission, assignment and retirement checks. A repeated request ID
30
+ observes the original receipt; an uncertain launch requires inspection, not a
31
+ second automatic launch. The retained room-opening endpoint remains available for recovery of older
32
+ active tasks whose rooms were declared but never initialized. New missions do not
33
+ require a separate room-opening action.
34
+
35
+ Layout YAML may also describe a single Owner room using `participants: {}` and
36
+ `members: []`. Other layouts continue to require valid participant references.
37
+
38
+ ## Saved layouts
39
+
6
40
  Place a layout at `fleet/room_layouts/<name>.yaml` beside the split configuration
7
41
  for `fleet.yaml`. Agent Templates remain in `fleet/agent_templates/`.
8
42
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "2.0.3",
3
+ "version": "2.1.0",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, managed native/ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",