@ours.network/fleet 1.2.0-nightly.22 → 1.2.0-nightly.24

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,4 +1,23 @@
1
+ import type { ConversationEventV1 } from '../session/conversation-types.js';
1
2
  import type { ConversationPageView } from './session-control.js';
3
+ /** Whether a ledger event belongs in the conversation: the live generation, or history of the resumed ACP session. */
4
+ export declare function conversationEventVisible(event: ConversationEventV1, sessionGeneration: string, resumedSessionId?: string): boolean;
5
+ export interface ConversationTailPage extends ConversationPageView {
6
+ /** Pass as `before` to read the next older page. */
7
+ olderCursor?: string;
8
+ hasOlder: boolean;
9
+ /** Latest page only: older events the view still needs, the current session's latest capabilities and still-pending permission requests. */
10
+ context: ConversationEventV1[];
11
+ }
12
+ /**
13
+ * The newest `limit` visible events before `before` (or the newest overall),
14
+ * read backwards from the ledger so opening a long session costs one page.
15
+ * `nextCursor` is the newest ledger position seen, for forward `after` paging.
16
+ */
17
+ export declare function conversationTailPage(stateDir: string, request: {
18
+ before?: string;
19
+ limit?: number;
20
+ }, snapshot: ConversationPageView['snapshot'], resumedSessionId?: string): Promise<ConversationTailPage>;
2
21
  /** Read the existing ACP session ledger without opening its writer/recovery store. */
3
22
  export declare function resumedConversationPage(stateDir: string, request: {
4
23
  after?: string;
@@ -1,7 +1,127 @@
1
1
  import { createReadStream } from 'node:fs';
2
- import { readFile, readdir } from 'node:fs/promises';
2
+ import { open, readFile, readdir } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { createInterface } from 'node:readline';
5
+ const HISTORICAL_KINDS = ['fleet.task_created', 'prompt.admitted', 'message.chunk', 'message.replace', 'tool.upsert', 'tool.content_chunk', 'turn.completed'];
6
+ /** Whether a ledger event belongs in the conversation: the live generation, or history of the resumed ACP session. */
7
+ export function conversationEventVisible(event, sessionGeneration, resumedSessionId) {
8
+ return event.source !== 'agent_replay' && (event.sessionGeneration === sessionGeneration || (!!resumedSessionId && HISTORICAL_KINDS.includes(event.kind) && event.acpSessionId === resumedSessionId));
9
+ }
10
+ /** Newest-first lines of a file, read in chunks from its end; `visit` returns false to stop. */
11
+ async function readLinesBackward(path, visit) {
12
+ const file = await open(path, 'r');
13
+ try {
14
+ let position = (await file.stat()).size;
15
+ let carry = Buffer.alloc(0);
16
+ while (position > 0) {
17
+ const size = Math.min(256 * 1024, position);
18
+ position -= size;
19
+ const chunk = Buffer.alloc(size);
20
+ await file.read(chunk, 0, size, position);
21
+ let buffer = Buffer.concat([chunk, carry]);
22
+ let end = buffer.length;
23
+ for (let i = buffer.length - 1; i >= 0; i--) {
24
+ if (buffer[i] !== 10)
25
+ continue;
26
+ if (end > i + 1 && !visit(buffer.toString('utf8', i + 1, end)))
27
+ return false;
28
+ end = i;
29
+ }
30
+ carry = buffer.subarray(0, end);
31
+ }
32
+ if (carry.length && !visit(carry.toString('utf8')))
33
+ return false;
34
+ return true;
35
+ }
36
+ finally {
37
+ await file.close();
38
+ }
39
+ }
40
+ /** The last event cursor in a segment, read from its tail; undefined when none is readable. */
41
+ async function lastSeq(path) {
42
+ let seq;
43
+ await readLinesBackward(path, line => { try {
44
+ const value = Number(JSON.parse(line).seq);
45
+ if (Number.isSafeInteger(value)) {
46
+ seq = value;
47
+ return false;
48
+ }
49
+ }
50
+ catch { } return true; });
51
+ return seq;
52
+ }
53
+ /**
54
+ * The newest `limit` visible events before `before` (or the newest overall),
55
+ * read backwards from the ledger so opening a long session costs one page.
56
+ * `nextCursor` is the newest ledger position seen, for forward `after` paging.
57
+ */
58
+ export async function conversationTailPage(stateDir, request, snapshot, resumedSessionId) {
59
+ const before = request.before === undefined ? Infinity : Number(request.before);
60
+ if (before !== Infinity && (!Number.isSafeInteger(before) || before < 0))
61
+ throw new Error('before must be a ledger cursor');
62
+ const limit = Math.min(1000, Math.max(1, Number.isFinite(request.limit) ? Math.floor(request.limit) : 200));
63
+ let files = [];
64
+ try {
65
+ files = (await readdir(join(stateDir, '.conversation'))).filter(n => /^events-\d{6}\.jsonl$/.test(n)).sort().reverse();
66
+ }
67
+ catch { }
68
+ const events = [];
69
+ let newest;
70
+ let hasOlder = false;
71
+ const pending = new Set(snapshot.pendingPermissionIds);
72
+ let capabilities;
73
+ const context = [];
74
+ // Context only accompanies the newest page; older pages stop as soon as they are full.
75
+ const wantsContext = () => before === Infinity && (!capabilities || pending.size > 0);
76
+ const generation = `"sessionGeneration":${JSON.stringify(snapshot.sessionGeneration)}`;
77
+ for (const file of files) {
78
+ const more = await readLinesBackward(join(stateDir, '.conversation', file), line => {
79
+ if (hasOlder) {
80
+ // The page is full; only the current generation's context kinds matter. Generations are
81
+ // appended in order, so the first line of an older one ends the search.
82
+ if (!line.includes(generation))
83
+ return !line.includes('"sessionGeneration":') && wantsContext();
84
+ if (!line.includes('"capabilities.updated"') && !line.includes('"permission.requested"'))
85
+ return wantsContext();
86
+ }
87
+ let event;
88
+ try {
89
+ event = JSON.parse(line);
90
+ }
91
+ catch {
92
+ return true;
93
+ }
94
+ const seq = Number(event.seq);
95
+ if (!Number.isSafeInteger(seq))
96
+ return true;
97
+ newest ??= seq;
98
+ if (seq < before && conversationEventVisible(event, snapshot.sessionGeneration, resumedSessionId)) {
99
+ const inPage = !hasOlder && events.length < limit;
100
+ if (event.sessionGeneration === snapshot.sessionGeneration) {
101
+ if (event.kind === 'capabilities.updated' && !capabilities) {
102
+ capabilities = event;
103
+ if (!inPage)
104
+ context.push(event);
105
+ }
106
+ if (event.kind === 'permission.requested' && event.permissionId && pending.delete(event.permissionId) && !inPage)
107
+ context.push(event);
108
+ }
109
+ if (inPage)
110
+ events.push(event);
111
+ else
112
+ hasOlder = true;
113
+ }
114
+ return !hasOlder || wantsContext();
115
+ });
116
+ if (!more)
117
+ break;
118
+ }
119
+ events.reverse();
120
+ context.reverse();
121
+ return { events, snapshot, hasOlder, context, hasMore: false,
122
+ ...(events.length ? { olderCursor: String(events[0].seq) } : {}),
123
+ ...(before === Infinity && newest !== undefined ? { nextCursor: String(newest) } : {}) };
124
+ }
5
125
  /** Read the existing ACP session ledger without opening its writer/recovery store. */
6
126
  export async function resumedConversationPage(stateDir, request, live, expectedSessionId) {
7
127
  let sessionId;
@@ -24,6 +144,10 @@ export async function resumedConversationPage(stateDir, request, live, expectedS
24
144
  let first;
25
145
  let hasMore = false;
26
146
  scan: for (const file of files) {
147
+ // Forward polling asks for the newest few events: skip whole segments that end at or before the cursor.
148
+ const end = await lastSeq(join(stateDir, '.conversation', file));
149
+ if (end !== undefined && end <= after)
150
+ continue;
27
151
  const input = createReadStream(join(stateDir, '.conversation', file), { encoding: 'utf8' });
28
152
  const lines = createInterface({ input, crlfDelay: Infinity });
29
153
  try {
@@ -1,3 +1,4 @@
1
+ import { type ConversationTailPage } from './conversation-history.js';
1
2
  import type { AgentHistoryQuery } from '../agent-ours/correspondence.js';
2
3
  import { controlRequest } from '../session/control.js';
3
4
  import type { ConversationEventV1, ConversationSnapshot, PromptReceipt } from '../session/conversation-types.js';
@@ -44,6 +45,11 @@ export interface RoleSessionControl {
44
45
  after?: string;
45
46
  limit?: number;
46
47
  }): Promise<ConversationPageView>;
48
+ /** The newest visible events (or those before `before`), read backwards from the ledger. */
49
+ conversationTail?(request: {
50
+ before?: string;
51
+ limit?: number;
52
+ }): Promise<ConversationTailPage>;
47
53
  submitPromptV2?(request: {
48
54
  commandId: string;
49
55
  text: string;
@@ -94,6 +100,10 @@ export declare class RoleSessionControlAdapter implements RoleSessionControl {
94
100
  after?: string;
95
101
  limit?: number;
96
102
  }): Promise<ConversationPageView>;
103
+ conversationTail(request?: {
104
+ before?: string;
105
+ limit?: number;
106
+ }): Promise<ConversationTailPage>;
97
107
  submitPromptV2(request: {
98
108
  commandId: string;
99
109
  text: string;
@@ -1,4 +1,6 @@
1
- import { resumedConversationPage } from './conversation-history.js';
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { conversationTailPage, resumedConversationPage } from './conversation-history.js';
2
4
  import { controlRequest, followConversation } from '../session/control.js';
3
5
  import { FleetError, normalizeError } from './errors.js';
4
6
  const visibleEvents = (events) => events.filter(event => event.kind !== 'thought');
@@ -78,13 +80,44 @@ export class RoleSessionControlAdapter {
78
80
  return live;
79
81
  }
80
82
  }
83
+ async conversationTail(request = {}) {
84
+ const live = await this.call('conversation_page', { limit: 1 });
85
+ const snapshot = await this.snapshot();
86
+ let resumed;
87
+ if (snapshot.backend === 'acp' && snapshot.sessionId) {
88
+ try {
89
+ if ((await readFile(join(this.stateDir, '.acp-session-id'), 'utf8')).trim() === snapshot.sessionId)
90
+ resumed = snapshot.sessionId;
91
+ }
92
+ catch { /* no resumed history: live generation only */ }
93
+ }
94
+ try {
95
+ return await conversationTailPage(this.stateDir, request, live.snapshot, resumed);
96
+ }
97
+ catch (error) {
98
+ throw new FleetError('invalid_request', error.message);
99
+ }
100
+ }
81
101
  async submitPromptV2(request) {
82
102
  if (!request.text.trim())
83
103
  throw new FleetError('invalid_request', 'text is required');
84
104
  if (Buffer.byteLength(request.text) > 32 * 1024)
85
105
  throw new FleetError('invalid_request', 'text exceeds 32 KiB');
106
+ // A prompt bound to a session generation (voice or attachments) is checked atomically by supervisors that
107
+ // support it. Older supervisors do not know that command and never answer, so check the generation here
108
+ // and submit an ordinary prompt; its command id keeps retries idempotent either way.
109
+ let command = 'submit_prompt_v2';
110
+ if (request.expectedSessionGeneration) {
111
+ if ((await this.describe()).features.includes('generation_bound_prompts'))
112
+ command = 'submit_voice_prompt';
113
+ else {
114
+ const live = await this.call('conversation_page', { limit: 1 });
115
+ if (live.snapshot?.sessionGeneration !== request.expectedSessionGeneration)
116
+ throw new FleetError('stale_state', 'Agent session changed. Record a new voice message.');
117
+ }
118
+ }
86
119
  try {
87
- return await this.call(request.expectedSessionGeneration ? 'submit_voice_prompt' : 'submit_prompt_v2', {
120
+ return await this.call(command, {
88
121
  commandId: request.commandId, text: request.text, actor: request.actorBrowserSession,
89
122
  source: request.source, expectedSessionGeneration: request.expectedSessionGeneration,
90
123
  });
@@ -31,6 +31,8 @@ export interface TaskLayoutView {
31
31
  rooms: TaskLayoutRoomView[];
32
32
  participants: Array<{
33
33
  key: string;
34
+ participant?: string;
35
+ room?: string;
34
36
  agent_template?: string;
35
37
  owned: boolean;
36
38
  retired: boolean;
@@ -153,7 +153,8 @@ export class TaskLayouts {
153
153
  };
154
154
  }),
155
155
  participants: Object.entries(state.participants).map(([key, p]) => ({
156
- key, agent_template: state.definition.participants[key]?.agent_template, owned: p.owned, retired: Boolean(p.retired),
156
+ key, ...(p.participant ? { participant: p.participant, room: p.room } : {}),
157
+ agent_template: state.definition.participants[p.participant ?? key]?.agent_template, owned: p.owned, retired: Boolean(p.retired),
157
158
  ...(p.instance?.agent ? { agent: p.instance.agent } : {}), ...(p.instance?.cid ? { cid: p.instance.cid } : {}),
158
159
  })),
159
160
  };
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "1.2.0-nightly.22",
3
- "buildId": "f5776b7613c5",
4
- "commit": "769fba35bc2918e792c353d2234d0fe5e2a48f57",
2
+ "version": "1.2.0-nightly.24",
3
+ "buildId": "80f2457d96f4",
4
+ "commit": "b9009b9a68c0c11536c33e239eae38144a491233",
5
5
  "dirty": true,
6
- "builtAt": "2026-09-30T07:56:23.700Z",
6
+ "builtAt": "2026-09-30T12:55:21.818Z",
7
7
  "capabilities": [
8
8
  "cowork.http-management-v1",
9
9
  "monitor.interrupt.after_tool"
@@ -0,0 +1,11 @@
1
+ import type { LayoutInstance } from './layout.js';
2
+ import type { LayoutControlRequest } from './layout-control.js';
3
+ export type LayoutBindingRequest = Omit<LayoutControlRequest, 'instance'> & {
4
+ roomId?: string;
5
+ roomRole?: string;
6
+ };
7
+ export declare const LAYOUT_BINDING_ACTIONS: readonly ["verify", "join", "assign"];
8
+ export declare const localLayoutReference: ({ remote: _remote, ...instance }: LayoutInstance) => LayoutInstance;
9
+ /** Initial support is the same-host, separate-OS-user use case from #194. */
10
+ export declare function layoutBindingOrigin(raw: string): string;
11
+ export declare function callRemoteLayoutBinding(instance: LayoutInstance, request: LayoutBindingRequest, daemonInstanceId: string, fetchImpl?: typeof fetch): Promise<unknown>;
@@ -0,0 +1,56 @@
1
+ import { lstatSync, readFileSync } from 'node:fs';
2
+ import { isAbsolute } from 'node:path';
3
+ import { canonicalJson } from '../canonical-json.js';
4
+ export const LAYOUT_BINDING_ACTIONS = ['verify', 'join', 'assign'];
5
+ export const localLayoutReference = ({ remote: _remote, ...instance }) => instance;
6
+ /** Initial support is the same-host, separate-OS-user use case from #194. */
7
+ export function layoutBindingOrigin(raw) {
8
+ const url = new URL(raw);
9
+ if (url.protocol !== 'http:' || !['127.0.0.1'].includes(url.hostname)
10
+ || url.username || url.password || url.pathname !== '/' || url.search || url.hash)
11
+ throw Error('cross-Fleet bindings require an explicit loopback HTTP origin');
12
+ return url.origin;
13
+ }
14
+ export async function callRemoteLayoutBinding(instance, request, daemonInstanceId, fetchImpl = fetch) {
15
+ const remote = instance.remote;
16
+ if (!LAYOUT_BINDING_ACTIONS.includes(request.action))
17
+ throw Error('cross-Fleet bindings allow only verify, join and assign');
18
+ if (remote.daemon_instance_id !== daemonInstanceId)
19
+ throw Error('cross-Fleet binding requires the same daemon instance');
20
+ const origin = layoutBindingOrigin(remote.url);
21
+ if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(remote.grant_id) || !isAbsolute(remote.credential_file))
22
+ throw Error('invalid cross-Fleet binding');
23
+ const stat = lstatSync(remote.credential_file);
24
+ if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077)
25
+ || (process.getuid && stat.uid !== process.getuid()) || stat.size > 256)
26
+ throw Error('untrusted cross-Fleet credential file');
27
+ const token = readFileSync(remote.credential_file, 'utf8').trim();
28
+ if (!/^[A-Za-z0-9_-]{43}$/.test(token))
29
+ throw Error('invalid cross-Fleet credential');
30
+ let response;
31
+ try {
32
+ response = await fetchImpl(`${origin}/api/v1/layout-bindings/${remote.grant_id}/control`, {
33
+ method: 'POST', redirect: 'error', signal: AbortSignal.timeout(60_000),
34
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
35
+ body: JSON.stringify({ ...request, instance: localLayoutReference(instance), daemonInstanceId }),
36
+ });
37
+ }
38
+ catch {
39
+ throw Error('cross-Fleet control unavailable; operation outcome may be unknown');
40
+ }
41
+ // Remote errors are not trusted presentation data and may contain private request bytes.
42
+ if (!response.ok)
43
+ throw Error(`cross-Fleet binding rejected (${response.status})`);
44
+ let result;
45
+ try {
46
+ result = await response.json();
47
+ }
48
+ catch {
49
+ throw Error('invalid cross-Fleet control response');
50
+ }
51
+ if (!result || typeof result !== 'object')
52
+ throw Error('invalid cross-Fleet control response');
53
+ if (canonicalJson(result.instance) !== canonicalJson(localLayoutReference(instance)))
54
+ throw Error('cross-Fleet instance proof changed');
55
+ return result.result;
56
+ }
@@ -0,0 +1,21 @@
1
+ import type { LayoutInstance, LayoutSupervisor } from './layout.js';
2
+ import { type CoworkAdapter } from './cowork-adapter.js';
3
+ /** Private authorization records in the existing Fleet state; no foreign lifecycle port. */
4
+ export declare class LayoutBindingGrants {
5
+ private supervisor;
6
+ private options;
7
+ constructor(supervisor: LayoutSupervisor, options?: {
8
+ root?: string;
9
+ daemonInstanceId?: () => string;
10
+ cowork?: () => CoworkAdapter;
11
+ });
12
+ private get root();
13
+ private daemonInstanceId;
14
+ private path;
15
+ share(instance: LayoutInstance, origin: string, output: string, participant: string): Promise<{
16
+ grant_id: string;
17
+ file: string;
18
+ }>;
19
+ revoke(id: string): Promise<void>;
20
+ control(id: string, authorization: string | undefined, raw: unknown): Promise<unknown>;
21
+ }
@@ -0,0 +1,128 @@
1
+ import { randomBytes, randomUUID, createHash, timingSafeEqual } from 'node:crypto';
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { dirname, basename, join } from 'node:path';
4
+ import { stringify } from 'yaml';
5
+ import { stateRoot } from '../paths.js';
6
+ import { withFileLock } from '../atomic-file.js';
7
+ import { readClientProfile } from '../client-profile.js';
8
+ import { FleetError } from '../application/errors.js';
9
+ import { assertLayoutFile, validLayoutKey } from './layout-config.js';
10
+ import { canonicalJson } from '../canonical-json.js';
11
+ import { LAYOUT_BINDING_ACTIONS, layoutBindingOrigin } from './layout-binding-client.js';
12
+ import { createCoworkAdapter } from './cowork-adapter.js';
13
+ const digest = (token) => createHash('sha256').update(token).digest('hex');
14
+ function privatePath(path, directory = false) {
15
+ assertLayoutFile(path, directory);
16
+ if (lstatSync(path).mode & 0o077)
17
+ throw new FleetError('forbidden', 'layout grants require owner-only permissions');
18
+ }
19
+ const allowedId = (id) => /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(id);
20
+ /** Private authorization records in the existing Fleet state; no foreign lifecycle port. */
21
+ export class LayoutBindingGrants {
22
+ supervisor;
23
+ options;
24
+ constructor(supervisor, options = {}) {
25
+ this.supervisor = supervisor;
26
+ this.options = options;
27
+ }
28
+ get root() { return this.options.root ?? join(stateRoot(), 'layout-bindings'); }
29
+ daemonInstanceId() { return this.options.daemonInstanceId?.() ?? readClientProfile().expectedInstanceId; }
30
+ path(id) {
31
+ if (!allowedId(id))
32
+ throw new FleetError('invalid_request', 'invalid layout binding grant');
33
+ return join(this.root, `${id}.json`);
34
+ }
35
+ async share(instance, origin, output, participant) {
36
+ layoutBindingOrigin(origin);
37
+ if (!validLayoutKey(participant) || instance.remote || instance.supervisor !== this.supervisor.id)
38
+ throw new FleetError('invalid_request', 'only an exact local standalone instance can be shared');
39
+ await this.supervisor.verify(instance);
40
+ const daemonInstanceId = this.daemonInstanceId();
41
+ const token = randomBytes(32).toString('base64url'), id = randomUUID();
42
+ const credential = `${output}.token`;
43
+ // Create-only publication: never replace another binding or credential.
44
+ if (existsSync(output) || existsSync(credential))
45
+ throw new FleetError('conflict', 'binding output already exists');
46
+ mkdirSync(this.root, { recursive: true, mode: 0o700 });
47
+ privatePath(this.root, true);
48
+ mkdirSync(dirname(output), { recursive: true, mode: 0o700 });
49
+ assertLayoutFile(dirname(output), true);
50
+ const grant = { version: 1, digest: digest(token), instance, daemonInstanceId };
51
+ writeFileSync(this.path(id), JSON.stringify(grant), { mode: 0o600, flag: 'wx' });
52
+ let tokenWritten = false;
53
+ try {
54
+ writeFileSync(credential, `${token}\n`, { mode: 0o600, flag: 'wx' });
55
+ tokenWritten = true;
56
+ writeFileSync(output, stringify({ [participant]: { ...instance, remote: {
57
+ url: layoutBindingOrigin(origin), grant_id: id, credential_file: basename(credential), daemon_instance_id: daemonInstanceId,
58
+ } } }), { mode: 0o600, flag: 'wx' });
59
+ }
60
+ catch (error) {
61
+ rmSync(this.path(id), { force: true });
62
+ if (tokenWritten)
63
+ rmSync(credential, { force: true });
64
+ throw error;
65
+ }
66
+ return { grant_id: id, file: output };
67
+ }
68
+ async revoke(id) {
69
+ const path = this.path(id);
70
+ if (!existsSync(this.root))
71
+ return;
72
+ privatePath(this.root, true);
73
+ await withFileLock(path + '.lock', () => { rmSync(path, { force: true }); });
74
+ }
75
+ async control(id, authorization, raw) {
76
+ const path = this.path(id);
77
+ if (!existsSync(path))
78
+ throw new FleetError('unauthorized', 'layout binding is unavailable');
79
+ privatePath(this.root, true);
80
+ return withFileLock(path + '.lock', async () => {
81
+ if (!existsSync(path))
82
+ throw new FleetError('unauthorized', 'layout binding is unavailable');
83
+ privatePath(path);
84
+ const grant = JSON.parse(readFileSync(path, 'utf8'));
85
+ const token = authorization?.match(/^Bearer ([A-Za-z0-9_-]{43})$/)?.[1];
86
+ if (!token || grant.version !== 1 || typeof grant.digest !== 'string' || !/^[a-f0-9]{64}$/.test(grant.digest)
87
+ || !timingSafeEqual(Buffer.from(digest(token)), Buffer.from(grant.digest)))
88
+ throw new FleetError('unauthorized', 'layout binding is unavailable');
89
+ const request = raw;
90
+ if (!request || !LAYOUT_BINDING_ACTIONS.includes(request.action)
91
+ || Object.keys(request).some(k => !['action', 'instance', 'roomId', 'roomRole', 'roomCid', 'assignment', 'daemonInstanceId'].includes(k)))
92
+ throw new FleetError('invalid_request', 'unsupported layout binding operation');
93
+ if (request.daemonInstanceId !== grant.daemonInstanceId || grant.daemonInstanceId !== this.daemonInstanceId()
94
+ || canonicalJson(request.instance) !== canonicalJson(grant.instance))
95
+ throw new FleetError('forbidden', 'layout binding does not match the granted instance');
96
+ try {
97
+ await this.supervisor.verify(grant.instance);
98
+ let result;
99
+ if (request.action === 'join') {
100
+ if (!request.roomId || !request.roomCid || !request.roomRole)
101
+ throw Error('room ID, CID and role required');
102
+ const cowork = this.options.cowork?.() ?? createCoworkAdapter();
103
+ const room = await cowork.getRoom(request.roomId);
104
+ if (!room || !['provisioning', 'active'].includes(room.state) || room.identity_cid !== request.roomCid)
105
+ throw Error('known mutable Cowork room required');
106
+ // Never redeem a caller-supplied contact invitation. Cowork owns this receipt.
107
+ const invitation = await cowork.issueInvite(request.roomId, { mode: 'one_time', role: request.roomRole, min_accepts: 1 });
108
+ await this.supervisor.join(grant.instance, invitation.invite, request.roomCid);
109
+ }
110
+ else if (request.action === 'assign') {
111
+ const a = request.assignment;
112
+ if (!a?.room_id || !a.room_cid || !a.room_role)
113
+ throw Error('assignment required');
114
+ const room = await (this.options.cowork?.() ?? createCoworkAdapter()).getRoom(a.room_id);
115
+ if (room?.state !== 'active' || room.identity_cid !== a.room_cid
116
+ || !room.seats.some(s => s.identity_cid === grant.instance.cid && s.role === a.room_role && s.seat_state === 'active'))
117
+ throw Error('active granted membership required');
118
+ result = await this.supervisor.assign(grant.instance, a);
119
+ }
120
+ await this.supervisor.verify(grant.instance);
121
+ return { instance: grant.instance, result };
122
+ }
123
+ catch {
124
+ throw new FleetError('rejected', 'layout binding operation rejected or unconfirmed; inspect before retry');
125
+ }
126
+ });
127
+ }
128
+ }
@@ -1,2 +1,4 @@
1
1
  import { Command } from 'commander';
2
+ import type { LayoutInstance } from './layout.js';
3
+ export declare function readLayoutBindings(file: string): Record<string, LayoutInstance>;
2
4
  export declare function registerLayoutCommands(parent: Command, cOpt: (cmd: Command) => Command): void;
@@ -1,4 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { LayoutBindingGrants } from './layout-binding-grants.js';
2
4
  import { stringify } from 'yaml';
3
5
  import { RoomLayoutService } from './layout-service.js';
4
6
  import { layoutSupervisorId } from './layout-control.js';
@@ -16,6 +18,15 @@ function document(file) {
16
18
  assertLayoutFile(file);
17
19
  return parseFleetDocument(file, readFileSync(file, 'utf8'), 'strict').value;
18
20
  }
21
+ export function readLayoutBindings(file) {
22
+ const absolute = resolve(file);
23
+ const bindings = document(absolute);
24
+ for (const instance of Object.values(bindings)) {
25
+ if (instance.remote)
26
+ instance.remote.credential_file = resolve(dirname(absolute), instance.remote.credential_file);
27
+ }
28
+ return bindings;
29
+ }
19
30
  export function registerLayoutCommands(parent, cOpt) {
20
31
  const layout = parent.command('layout').description('YAML room composition and membership');
21
32
  const command = (signature, description) => cOpt(layout.command(signature).description(description)).option('--json', 'JSON output');
@@ -52,11 +63,26 @@ export function registerLayoutCommands(parent, cOpt) {
52
63
  command('instance <agent>', 'inspect an exact live instance for a bindings YAML file')
53
64
  .option('--temporary', 'select a temporary agent')
54
65
  .action(action(async (agent, opts) => emit({ instance: await new RoomLayoutService(opts.configuration).supervisor().inspect(agent, Boolean(opts.temporary)) })));
66
+ command('share <agent>', 'grant room membership access to one exact local standalone instance')
67
+ .option('--temporary', 'select a temporary agent')
68
+ .requiredOption('--server-url <origin>', 'existing owner Fleet loopback HTTP origin')
69
+ .requiredOption('--output <file>', 'create binding YAML and adjacent private credential file')
70
+ .option('--participant <key>', 'logical layout participant key (default: agent name)')
71
+ .action(action(async (agent, opts) => {
72
+ const supervisor = new RoomLayoutService(opts.configuration).supervisor();
73
+ const instance = await supervisor.inspect(agent, Boolean(opts.temporary));
74
+ emit(await new LayoutBindingGrants(supervisor).share(instance, opts.serverUrl, resolve(opts.output), opts.participant ?? agent));
75
+ }));
76
+ command('revoke-binding <grant-id>', 'revoke a room membership grant without stopping the agent')
77
+ .action(action(async (id, opts) => {
78
+ await new LayoutBindingGrants(new RoomLayoutService(opts.configuration).supervisor()).revoke(id);
79
+ emit({ grant_id: id, revoked: true });
80
+ }));
55
81
  command('create <name>', 'snapshot a layout without creating rooms or agents')
56
82
  .option('--id <id>', 'unique layout instance ID').option('--bindings <file>', 'YAML mapping participant keys to exact instance references')
57
83
  .action(action(async (name, opts) => {
58
84
  const service = new RoomLayoutService(opts.configuration);
59
- const created = await service.create(name, opts.bindings ? document(opts.bindings) : {}, opts.id);
85
+ const created = await service.create(name, opts.bindings ? readLayoutBindings(opts.bindings) : {}, opts.id);
60
86
  emit({ id: created.id, state: created.state });
61
87
  }));
62
88
  command('status <id>', 'inspect room membership and agent instances').action(action((id, opts) => emit({ id, state: new RoomLayoutService(opts.configuration).open(id, true).snapshot() })));
@@ -31,7 +31,7 @@ export function validateRoomLayout(raw, label = 'room layout') {
31
31
  throw Error(`${label}: invalid key ${key}`);
32
32
  }
33
33
  for (const [key, rawParticipant] of Object.entries(doc.participants)) {
34
- const participant = mapping(rawParticipant, ['agent_template'], `${label}.participants.${key}`);
34
+ const participant = mapping(rawParticipant, ['agent_template', 'instance_scope'], `${label}.participants.${key}`);
35
35
  if (participant.agent_template !== undefined)
36
36
  text(participant.agent_template, 'agent_template');
37
37
  }
@@ -12,7 +12,10 @@ export declare class NativeLayoutSupervisor implements LayoutSupervisor {
12
12
  private call;
13
13
  verify(i: LayoutInstance): Promise<void>;
14
14
  spawn(key: string, template: string): Promise<LayoutInstance>;
15
- join(i: LayoutInstance, invite: string, roomCid: string): Promise<void>;
15
+ join(i: LayoutInstance, invite: string, roomCid: string, membership?: {
16
+ roomId: string;
17
+ role: string;
18
+ }): Promise<void>;
16
19
  assign(i: LayoutInstance, assignment: import('./layout.js').LayoutAssignment): Promise<void>;
17
20
  retire(i: LayoutInstance): Promise<void>;
18
21
  }
@@ -11,6 +11,8 @@ import { layoutSupervisorId } from './layout-control.js';
11
11
  import { RoomLayout } from './layout.js';
12
12
  import { assertLayoutFile, validLayoutKey, readRoomLayout, readRoomLayouts } from './layout-config.js';
13
13
  import { createCoworkAdapter } from './cowork-adapter.js';
14
+ import { callRemoteLayoutBinding } from './layout-binding-client.js';
15
+ import { readClientProfile } from '../client-profile.js';
14
16
  export class NativeLayoutSupervisor {
15
17
  configPath;
16
18
  runId;
@@ -35,6 +37,8 @@ export class NativeLayoutSupervisor {
35
37
  return await this.request(agent, temporary, { action: 'inspect' });
36
38
  }
37
39
  call(i, request) {
40
+ if (i.remote)
41
+ return callRemoteLayoutBinding(i, request, readClientProfile().expectedInstanceId);
38
42
  if (i.supervisor !== this.id || !i.agent || typeof i.temporary !== 'boolean')
39
43
  throw Error('invalid local layout binding');
40
44
  return this.request(i.agent, i.temporary, { ...request, instance: i });
@@ -60,7 +64,13 @@ export class NativeLayoutSupervisor {
60
64
  }
61
65
  throw Error(`layout agent startup not confirmed: ${name}: ${String(last)}`);
62
66
  }
63
- async join(i, invite, roomCid) {
67
+ async join(i, invite, roomCid, membership) {
68
+ if (i.remote) {
69
+ if (!membership)
70
+ throw Error('cross-Fleet join requires room ID and role');
71
+ await this.call(i, { action: 'join', roomCid, roomId: membership.roomId, roomRole: membership.role });
72
+ return;
73
+ }
64
74
  await this.call(i, { action: 'join', invite, roomCid });
65
75
  const cowork = createCoworkAdapter();
66
76
  const deadline = Date.now() + 30_000;
@@ -76,6 +86,8 @@ export class NativeLayoutSupervisor {
76
86
  await this.call(i, { action: 'assign', assignment });
77
87
  }
78
88
  async retire(i) {
89
+ if (i.remote)
90
+ throw Error('cannot retire a borrowed cross-Fleet instance');
79
91
  if (i.supervisor !== this.id || !i.agent || !/^[A-Za-z0-9_-]+$/.test(i.agent) || i.temporary !== true)
80
92
  throw Error('invalid local temporary layout instance');
81
93
  const dir = agentDir(i.agent, true);
@@ -16,9 +16,16 @@ export interface LayoutInstance {
16
16
  session: string;
17
17
  agent?: string;
18
18
  temporary?: boolean;
19
+ remote?: {
20
+ url: string;
21
+ grant_id: string;
22
+ credential_file: string;
23
+ daemon_instance_id: string;
24
+ };
19
25
  }
20
26
  export interface LayoutParticipant {
21
27
  agent_template?: string;
28
+ instance_scope?: 'layout' | 'room';
22
29
  }
23
30
  export interface LayoutRoom {
24
31
  goal: string;
@@ -46,7 +53,10 @@ export interface LayoutSupervisor {
46
53
  /** Must verify current launch, CID, live session and standalone ownership. */
47
54
  verify(instance: LayoutInstance): Promise<void>;
48
55
  spawn(key: string, template: string): Promise<LayoutInstance>;
49
- join(instance: LayoutInstance, invite: string, roomCid: string): Promise<void>;
56
+ join(instance: LayoutInstance, invite: string, roomCid: string, membership?: {
57
+ roomId: string;
58
+ role: string;
59
+ }): Promise<void>;
50
60
  assign(instance: LayoutInstance, assignment: LayoutAssignment): Promise<void>;
51
61
  /** Retire the exact instance, or confirm it is already stopped; never stop a replacement. */
52
62
  retire(instance: LayoutInstance): Promise<void>;
@@ -55,6 +65,8 @@ type ParticipantState = {
55
65
  instance?: LayoutInstance;
56
66
  owned: boolean;
57
67
  retired?: boolean;
68
+ participant?: string;
69
+ room?: string;
58
70
  };
59
71
  type RoomState = {
60
72
  spec: LayoutRoom;
@@ -10,8 +10,17 @@ const ownerReference = (owner) => ({ cid: owner.cid, role: owner.role, fingerpri
10
10
  const own = (o, key) => Object.hasOwn(o, key);
11
11
  function need(ok, message) { if (!ok)
12
12
  throw Error(message); }
13
+ /** Room/participant keys cannot contain ':', so scoped instance keys cannot collide. */
14
+ function instanceKey(definition, participant, room) {
15
+ return definition.participants[participant].instance_scope === 'room' ? `${room}:${participant}` : participant;
16
+ }
13
17
  export function validateLayout(def) {
14
18
  need(Object.keys(def.rooms).length && Object.keys(def.participants).length, 'rooms and participants required');
19
+ for (const [key, participant] of Object.entries(def.participants)) {
20
+ need(participant.instance_scope === undefined || ['layout', 'room'].includes(participant.instance_scope), `invalid instance_scope for ${key}`);
21
+ if (participant.instance_scope === 'room')
22
+ need(typeof participant.agent_template === 'string' && participant.agent_template.trim(), `room-scoped participant ${key} requires agent_template`);
23
+ }
15
24
  for (const [key, room] of Object.entries(def.rooms)) {
16
25
  need(key.length && room.goal.trim() && room.members.length, 'room goal and members required');
17
26
  need(new Set(room.members).size === room.members.length, 'duplicate participant in room');
@@ -37,7 +46,7 @@ export class RoomLayout {
37
46
  snapshot() { return JSON.parse(readFileSync(this.file, 'utf8')); }
38
47
  save(s) { replaceFileAtomically(this.file, JSON.stringify(s, null, 2)); }
39
48
  async verify(i) {
40
- need(i.supervisor === this.supervisor.id, 'remote binding unsupported');
49
+ need(i.supervisor === this.supervisor.id || i.remote, 'remote binding unsupported');
41
50
  need(i.launch && i.cid && i.session, 'incomplete instance reference');
42
51
  await this.supervisor.verify(i);
43
52
  }
@@ -47,8 +56,10 @@ export class RoomLayout {
47
56
  this.checkOwnerRoles(definition);
48
57
  await withFileLock(this.file + '.lock', async () => {
49
58
  need(!existsSync(this.file), 'layout instance already exists');
50
- for (const key of Object.keys(bindings))
59
+ for (const key of Object.keys(bindings)) {
51
60
  need(own(definition.participants, key), 'unknown binding');
61
+ need(definition.participants[key].instance_scope !== 'room', `binding unsupported for room-scoped participant ${key}`);
62
+ }
52
63
  // Validate every supplied binding before any external mutation.
53
64
  for (const [key, p] of Object.entries(definition.participants)) {
54
65
  need(bindings[key] || p.agent_template, `binding required for ${key}`);
@@ -59,7 +70,18 @@ export class RoomLayout {
59
70
  const cids = room.members.flatMap(key => bindings[key] ? [bindings[key].cid] : []);
60
71
  need(new Set(cids).size === cids.length, 'same instance cannot occupy two seats in one room');
61
72
  }
62
- this.save({ version: 1, definition, controller, ...(this.owner ? { owner: ownerReference(this.owner) } : {}), agent_templates: agentTemplates, participants: Object.fromEntries(Object.keys(definition.participants).map(key => [key, { owned: !bindings[key], ...(bindings[key] ? { instance: bindings[key] } : {}) }])), rooms: {}, order: [], closed: false });
73
+ const participants = {};
74
+ for (const [key, participant] of Object.entries(definition.participants)) {
75
+ if (participant.instance_scope === 'room') {
76
+ for (const [room, spec] of Object.entries(definition.rooms))
77
+ if (spec.members.includes(key))
78
+ participants[instanceKey(definition, key, room)] = { owned: true, participant: key, room };
79
+ }
80
+ else
81
+ participants[key] = { owned: !bindings[key], ...(bindings[key] ? { instance: bindings[key] } : {}) };
82
+ }
83
+ this.save({ version: 1, definition, controller, ...(this.owner ? { owner: ownerReference(this.owner) } : {}),
84
+ agent_templates: agentTemplates, participants, rooms: {}, order: [], closed: false });
63
85
  });
64
86
  }
65
87
  checkOwnerRoles(definition) {
@@ -94,10 +116,11 @@ export class RoomLayout {
94
116
  this.checkOwnerRoles(s.definition);
95
117
  need(s.rooms[key]?.state !== 'closed', 'room closed');
96
118
  for (const member of spec.members) {
97
- const i = s.participants[member]?.instance;
119
+ const p = s.participants[instanceKey(s.definition, member, key)];
120
+ const i = p?.instance;
98
121
  if (i)
99
122
  await this.verify(i);
100
- need(!s.participants[member]?.retired, 'participant retired');
123
+ need(!p?.retired, 'participant retired');
101
124
  }
102
125
  if (s.rooms[key]?.state === 'active') {
103
126
  await this.assertRoomActive(s, key);
@@ -106,11 +129,11 @@ export class RoomLayout {
106
129
  const room = s.rooms[key] ??= { spec, state: 'provisioning', ready: [] };
107
130
  this.save(s);
108
131
  for (const member of spec.members) {
109
- const p = s.participants[member];
132
+ const resolvedKey = instanceKey(s.definition, member, key), p = s.participants[resolvedKey];
110
133
  if (!p.instance) {
111
134
  const template = s.definition.participants[member]?.agent_template;
112
135
  need(template, `no factory for ${member}`);
113
- await this.mutation(s, `spawn:${member}`, () => this.supervisor.spawn(member, template), i => { p.instance = i; });
136
+ await this.mutation(s, `spawn:${resolvedKey}`, () => this.supervisor.spawn(resolvedKey, template), i => { p.instance = i; });
114
137
  await this.verify(p.instance);
115
138
  }
116
139
  }
@@ -130,13 +153,13 @@ export class RoomLayout {
130
153
  for (const member of spec.members) {
131
154
  if (room.ready.includes(member))
132
155
  continue;
133
- const i = s.participants[member].instance;
156
+ const i = s.participants[instanceKey(s.definition, member, key)].instance;
134
157
  await this.verify(i);
135
158
  const seats = await this.cowork.getSeats(room.native.room_id);
136
159
  if (!seats.some(seat => seat.identity_cid === i.cid && seat.role === roleFor(member) && seat.seat_state !== 'removed')) {
137
160
  await this.mutation(s, `admit:${key}:${member}`, async () => {
138
- const invitation = await this.cowork.issueInvite(room.native.room_id, { mode: 'one_time', role: roleFor(member), min_accepts: 1 });
139
- await this.supervisor.join(i, invitation.invite, room.native.identity_cid);
161
+ const invite = i.remote ? '' : (await this.cowork.issueInvite(room.native.room_id, { mode: 'one_time', role: roleFor(member), min_accepts: 1 })).invite;
162
+ await this.supervisor.join(i, invite, room.native.identity_cid, { roomId: room.native.room_id, role: roleFor(member) });
140
163
  }, () => { });
141
164
  }
142
165
  }
@@ -145,7 +168,7 @@ export class RoomLayout {
145
168
  for (const member of spec.members) {
146
169
  if (room.ready.includes(member))
147
170
  continue;
148
- const i = s.participants[member].instance;
171
+ const i = s.participants[instanceKey(s.definition, member, key)].instance;
149
172
  await this.verify(i);
150
173
  await this.mutation(s, `assignment:${key}:${member}`, () => this.supervisor.assign(i, {
151
174
  id: `${key}:${member}`, room_id: room.native.room_id, room_cid: room.native.identity_cid,
@@ -165,9 +188,9 @@ export class RoomLayout {
165
188
  const room = s.rooms[key], native = await this.cowork.getRoom(room.native.room_id);
166
189
  need(native?.identity_cid === room.native.identity_cid && native.state === 'active', 'native room missing, changed or inactive');
167
190
  if (s.owner)
168
- need(native.seats.some(seat => seat.identity_cid === s.owner.cid
191
+ need(native.seats.some(seat => seat.identity_cid.toLowerCase() === s.owner.cid.toLowerCase()
169
192
  && seat.role === s.owner.role && seat.seat_state === 'active'), 'owner membership not active');
170
- need(room.spec.members.every(member => native.seats.some(seat => seat.identity_cid === s.participants[member].instance.cid
193
+ need(room.spec.members.every(member => native.seats.some(seat => seat.identity_cid === s.participants[instanceKey(s.definition, member, key)].instance.cid
171
194
  && seat.role === (room.spec.roles?.[member] ?? member) && seat.seat_state === 'active')), 'membership not active');
172
195
  }
173
196
  async archiveRoom(s, key) {
@@ -242,6 +242,7 @@ export class RoleControlServer {
242
242
  'events_since', 'observer_follow', 'retained_range',
243
243
  ...(this.correspondence ? ['agent_correspondence'] : []),
244
244
  ...(this.session.conversationPage ? ['conversation_v3'] : []),
245
+ ...(this.session.submitPromptBrowser ? ['generation_bound_prompts'] : []),
245
246
  ...(this.session.capabilities?.steering ? ['steering'] : []),
246
247
  ...(this.session.capabilities?.permissions ? ['permissions'] : []),
247
248
  ...(this.session.capabilities?.messagePhases ? ['message_phases'] : []),
@@ -1,3 +1,5 @@
1
+ import { LayoutBindingGrants } from '../rooms-tasks/layout-binding-grants.js';
2
+ import { RoomLayoutService } from '../rooms-tasks/layout-service.js';
1
3
  import { spawn } from 'node:child_process';
2
4
  import { SubscriptionService } from '../subscriptions/service.js';
3
5
  import { existsSync, realpathSync } from 'node:fs';
@@ -164,6 +166,7 @@ export async function startWebConsole(options) {
164
166
  query, repository, logs, commands, creation, removal, audit, events, watchdogs, configuration,
165
167
  taskRooms: new TaskRoomApplicationService(options.configPath, { binPath: () => options.binPath }),
166
168
  roomLayouts: new RoomLayoutDefinitions(options.configPath),
169
+ layoutBindings: new LayoutBindingGrants(new RoomLayoutService(options.configPath).supervisor()),
167
170
  presetProvenance: new PresetProvenance(options.configPath),
168
171
  topology: readTopology, topologyDrafts, topologyPromote,
169
172
  subscriptions: new SubscriptionService({
@@ -19,7 +19,9 @@ import type { TaskRoomApplicationService } from '../application/task-room-servic
19
19
  import type { RoomLayoutDefinitions } from '../application/room-layout-definitions.js';
20
20
  import type { PresetProvenance } from '../application/preset-provenance.js';
21
21
  import type { SubscriptionService } from '../subscriptions/service.js';
22
+ import type { LayoutBindingGrants } from '../rooms-tasks/layout-binding-grants.js';
22
23
  export interface WebServices {
24
+ layoutBindings?: Pick<LayoutBindingGrants, 'control'>;
23
25
  query: FleetQueryService;
24
26
  repository: RoleRepository;
25
27
  session(roleId: string): Promise<RoleSessionControl>;
@@ -55,8 +55,20 @@ export async function buildWebServer(services, boundary, options = {}) {
55
55
  return reply.code(421).type('text/html').send(`<!doctype html><html><head><title>Fleet console address</title></head><body><main><h1>This fleet-console address is not configured</h1><p>${escapeHtml(message)}</p><p>For nginx or VPS access, configure an explicit public origin. The console does not guess proxy hosts.</p></main></body></html>`);
56
56
  }
57
57
  });
58
+ app.post('/api/v1/layout-bindings/:id/control', async (request) => {
59
+ // Initial support is direct same-host Fleet-to-Fleet transport.
60
+ const forwarded = Object.keys(request.headers).some(name => name === 'forwarded' || name.startsWith('x-forwarded-') || name === 'x-real-ip' || name === 'via');
61
+ if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(request.ip) || forwarded)
62
+ throw new FleetError('forbidden', 'layout bindings require direct loopback access');
63
+ // Machine credentials cannot authenticate browser or lifecycle routes.
64
+ if (request.headers.origin)
65
+ auth.validateBoundary(request, true);
66
+ if (!services.layoutBindings)
67
+ throw new FleetError('capability_unavailable', 'layout bindings unavailable');
68
+ return services.layoutBindings.control(request.params.id, request.headers.authorization, request.body);
69
+ });
58
70
  app.setErrorHandler(async (error, request, reply) => {
59
- const privateToolParseError = request.routeOptions.url === '/api/v1/roles/:id/ours/call'
71
+ const privateToolParseError = ['/api/v1/roles/:id/ours/call', '/api/v1/layout-bindings/:id/control'].includes(request.routeOptions.url ?? '')
60
72
  && error instanceof Error && 'code' in error
61
73
  && typeof error.code === 'string' && error.code.startsWith('FST_ERR_CTP_');
62
74
  const fleetError = normalizeError(privateToolParseError
@@ -624,13 +636,22 @@ export async function buildWebServer(services, boundary, options = {}) {
624
636
  const control = await services.session(request.params.id);
625
637
  if (!control.conversationPage)
626
638
  throw new FleetError('capability_unavailable', 'this role has no conversation ledger');
627
- const page = await control.conversationPage({
628
- after: request.query.after,
629
- limit: request.query.limit ? Number(request.query.limit) : undefined,
630
- });
639
+ const limit = request.query.limit ? Number(request.query.limit) : undefined;
640
+ // `latest` / `before` page backwards from the newest event; `after` pages forward for live updates.
641
+ const backward = request.query.latest === '1' || request.query.before !== undefined;
642
+ if (backward && request.query.after !== undefined)
643
+ throw new FleetError('invalid_request', 'after cannot be combined with latest or before');
644
+ if (backward && !control.conversationTail)
645
+ throw new FleetError('capability_unavailable', 'this role cannot page its conversation backwards');
631
646
  const role = await services.repository.get(request.params.id);
632
647
  const stateDir = role ? services.repository.stateDir(role) : undefined;
633
- return { ...page, events: page.events.map(event => presentAttachmentEvent(stateDir, request.params.id, event)) };
648
+ const present = (event) => presentAttachmentEvent(stateDir, request.params.id, event);
649
+ if (backward) {
650
+ const page = await control.conversationTail({ before: request.query.before, limit });
651
+ return { ...page, events: page.events.map(present), context: page.context.map(present) };
652
+ }
653
+ const page = await control.conversationPage({ after: request.query.after, limit });
654
+ return { ...page, events: page.events.map(present) };
634
655
  });
635
656
  app.get('/api/v1/roles/:id/attachments/:attachmentId', async (request, reply) => {
636
657
  auth.authenticate(request);
@@ -25,17 +25,43 @@ rooms:
25
25
  roles: {product: product, developer: developer, qa: qa}
26
26
  ```
27
27
 
28
- Repeating a participant key reuses its exact running instance and session.
29
- Different keys using the same Agent Template create independent instances.
28
+ Participant `instance_scope` controls instance reuse:
29
+
30
+ - `layout` (default): repeating a participant key reuses its exact running instance
31
+ and session within this layout run.
32
+ - `room`: each room using the key gets its own identity and session, lazily created
33
+ when that room opens. Repeated opens of the same active room reuse that instance.
34
+
35
+ For example, one shared process doctor and one independent critic per room:
36
+
37
+ ```yaml
38
+ version: 1
39
+ participants:
40
+ doctor: {agent_template: ProcessDoctor, instance_scope: layout}
41
+ critic: {agent_template: Critic, instance_scope: room}
42
+ rooms:
43
+ discovery:
44
+ goal: Agree on the problem
45
+ members: [doctor, critic]
46
+ design:
47
+ goal: Review the design
48
+ members: [doctor, critic]
49
+ ```
50
+
51
+ Different keys using the same Agent Template still create independent instances.
30
52
  See the [shared](../examples/room-layouts/shared.yaml) and
31
- [fresh](../examples/room-layouts/fresh.yaml) examples. Both choices can coexist.
53
+ [fresh](../examples/room-layouts/fresh.yaml) examples, and the mixed
54
+ [scoped](../examples/room-layouts/scoped.yaml) example. All choices can coexist.
32
55
  Fresh agents receive their room context. Supply relevant evidence through ordinary
33
56
  room messages or existing project documents when needed.
34
57
 
35
58
  A participant without `agent_template` requires a binding. `layout instance`
36
59
  returns a local exact reference (supervisor, agent, launch, CID and session).
37
- Pass participant-to-reference YAML to `create --bindings`. A stopped, replaced
38
- or remote instance is rejected.
60
+ Pass participant-to-reference YAML to `create --bindings`. A stopped or replaced instance is rejected. Cross-Fleet bindings require
61
+ an explicit owner grant, as described below.
62
+ Room-scoped participants require `agent_template`; an explicit binding for them
63
+ is rejected before creating resources. Bindings for layout-scoped participants
64
+ keep their existing behavior.
39
65
 
40
66
  ```sh
41
67
  ours-fleet layout validate work
@@ -57,15 +83,28 @@ an agent contributes. Each room opens through an explicit command.
57
83
  archives opened rooms and stops only temporary participants created by this layout
58
84
  instance. Explicitly bound participants survive. Cleanup processes existing resources. A 3-Amigos room is an ordinary room with
59
85
  complementary members.
86
+ Room-scoped instances follow the same cleanup rules: closing a room does not
87
+ retire them, and a closed room cannot reopen. A new layout run creates new instances.
88
+
89
+ Private state keeps the authorable definition unchanged. Its `participants` map
90
+ uses the original key for shared instances and `<room>:<participant>` for
91
+ room-scoped instances, with `participant` and `room` metadata. Declared but unopened
92
+ rooms have no running instance. Status and task read models distinguish these
93
+ entries and resolve their original Agent Template. Existing snapshots without
94
+ `instance_scope` retain their original keys and require no migration. Snapshots
95
+ using `room` require a Fleet version that supports this field; they are not a
96
+ downgrade format for older Fleet versions.
60
97
 
61
98
  Supported room fields: `goal`, `members`, optional `roles`, `contract`,
62
99
  `quiet_membership`, `anonymous`. `contract` is briefing text.
63
100
  Top-level fields: `version: 1`, optional `description`, `participants`, `rooms`.
101
+ Participant fields: optional `agent_template`, `instance_scope: layout|room`.
64
102
  Unknown fields, ambiguous YAML and invalid member references are rejected.
65
103
  Existing templates and tasks keep their behavior.
66
104
 
67
- Only local Fleet control is supported. The existing authenticated control socket,
68
- agent runtime, `spawnTemp` and session queue perform operations. Private state
105
+ Local Fleet control uses the existing authenticated control socket,
106
+ agent runtime, `spawnTemp` and session queue. Cross-Fleet bindings delegate
107
+ membership operations to their owning Fleet as described below. Private state
69
108
  snapshots template definitions; public status hides their environment values.
70
109
  Existing owner settings apply; owner CID/invite fingerprint are pinned. Unknown
71
110
  mutation outcomes stop new work for inspection rather than blindly replaying a
@@ -90,3 +129,99 @@ Definitions are loaded for authoring commands. Retained instances use their save
90
129
  layout and Agent Templates. Status and cleanup operate on saved resources even
91
130
  when the source YAML is broken or removed. New agent launches continue to use
92
131
  Fleet's normal configuration and launch checks.
132
+
133
+ ## Borrowing an instance from another Fleet on the same host
134
+
135
+ Fleet A can open rooms using standalone temporary or persistent agents supervised
136
+ by Fleet B under another OS user. Both must select the same daemon instance and
137
+ Cowork gateway. An agent's original Fleet keeps its session, identity and lifecycle
138
+ control. This extends layout bindings; it does not add bindings to legacy
139
+ `room create` / RoomTemplate provisioning.
140
+
141
+ On the agent owner's Fleet B, use its existing web server and grant access to an
142
+ exact live standalone instance:
143
+
144
+ ```sh
145
+ ours-fleet web serve --port 49272 --no-open
146
+ # In another terminal belonging to Fleet B's owner:
147
+ ours-fleet layout share Architect --temporary --participant architect \
148
+ --server-url http://127.0.0.1:49272 --output ./architect.binding.yaml
149
+ ```
150
+
151
+ The command prints the grant ID and output path. It creates a binding YAML and
152
+ an adjacent `.token` file, both owner-only. The private owner record stores a
153
+ SHA-256 token digest, not the token. `share` never overwrites an existing export.
154
+ Transfer both files explicitly to Fleet A's user through your existing secure
155
+ file-transfer method, keeping the receiving credential file owned by that user
156
+ with mode `0600`. Do not share Fleet B's console session, client profile, daemon
157
+ credential, private state directory or supervisor socket. This feature creates
158
+ no additional server; Fleet B's existing web server must remain available.
159
+
160
+ Export each specialist under its logical layout participant key, then merge the
161
+ YAML mappings into one bindings document. Keep every adjacent token filename
162
+ unique. Relative credential paths resolve against the bindings file's directory.
163
+ The reusable layout contains participant factories or empty participant mappings,
164
+ never concrete credentials or running-instance references:
165
+
166
+ ```yaml
167
+ version: 1
168
+ participants:
169
+ architect: {}
170
+ developer: {}
171
+ doctor: {}
172
+ rooms:
173
+ product: {goal: Scope, members: [architect, doctor]}
174
+ design: {goal: Design, members: [architect, developer, doctor]}
175
+ delivery: {goal: Delivery, members: [developer, doctor]}
176
+ ```
177
+
178
+ On Fleet A:
179
+
180
+ ```sh
181
+ ours-fleet layout create project --id project-run --bindings ./bindings.yaml
182
+ ours-fleet layout open project-run product
183
+ ours-fleet layout open project-run design
184
+ ours-fleet layout open project-run delivery
185
+ ours-fleet layout close-room project-run product
186
+ ours-fleet layout close project-run
187
+ ```
188
+
189
+ The exact CID, launch and harness session are reused. Reopening an active room
190
+ checks existing membership without delivering duplicate context. Closing rooms
191
+ archives Cowork history and never stops borrowed instances. After acceptance,
192
+ the original Fleet B owner explicitly retires its standalone temporary specialists
193
+ using the existing lifecycle commands. Persistent specialists keep their ordinary
194
+ lifecycle. Mixed local-created and borrowed participants are supported.
195
+
196
+ An exported reference adds `remote` to the existing instance reference:
197
+
198
+ ```yaml
199
+ remote:
200
+ url: http://127.0.0.1:49272
201
+ grant_id: 11111111-1111-4111-8111-111111111111
202
+ credential_file: architect.binding.yaml.token
203
+ daemon_instance_id: 22222222-2222-4222-8222-222222222222
204
+ ```
205
+
206
+ The grant authorizes only `verify`, `join`, and `assign` for that exact instance.
207
+ Room admission validates the requested room ID and CID against shared Cowork and
208
+ uses an invite issued by Cowork on the owner side; caller-supplied contact invites
209
+ are rejected before contact mutation. Context delivery also requires its active seat, expected room CID and role in
210
+ Cowork. It cannot inspect inventory, spawn, stop, reconfigure, export identities,
211
+ or authenticate browser console routes. It grants the holder room admission and
212
+ room-context delivery, so transfer it only to the intended room creator.
213
+
214
+ Owner-side revocation does not stop the agent or remove existing memberships:
215
+
216
+ ```sh
217
+ ours-fleet layout revoke-binding <grant-id>
218
+ ```
219
+
220
+ Stopped/replaced sessions, a different daemon, revoked grants or unavailable
221
+ owner transport produce errors; no replacement is spawned. A lost response to a
222
+ membership/context mutation retains the existing layout `uncertain` marker.
223
+ Inspect the native room and owning Fleet before retrying; no mutation is blindly
224
+ replayed. Only direct loopback HTTP origins (`127.0.0.1`) are supported;
225
+ redirects, external hosts and forwarded requests are rejected. No cross-host
226
+ support, discovery protocol, mission service, or room conversation limits are
227
+ introduced.
@@ -0,0 +1,21 @@
1
+ version: 1
2
+ description: Shared specialists and one independent reviewer per room
3
+ participants:
4
+ architect:
5
+ agent_template: Architect
6
+ instance_scope: layout
7
+ developer:
8
+ agent_template: Developer
9
+ reviewer:
10
+ agent_template: Reviewer
11
+ instance_scope: room
12
+ rooms:
13
+ product:
14
+ goal: Agree on the problem and acceptance criteria
15
+ members: [architect, reviewer]
16
+ design:
17
+ goal: Design the accepted solution
18
+ members: [architect, developer, reviewer]
19
+ delivery:
20
+ goal: Implement and verify the accepted design
21
+ members: [developer, reviewer]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.2.0-nightly.22",
3
+ "version": "1.2.0-nightly.24",
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",