@ours.network/fleet 1.2.0-nightly.23 → 1.2.0-nightly.25

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.
package/README.md CHANGED
@@ -1820,3 +1820,8 @@ See [predefined Brain models and runtime requirements](presets/README.md) for th
1820
1820
 
1821
1821
  Room layouts describe room composition with shared or fresh participant sessions and independent room closure.
1822
1822
  See [Room layouts](docs/room-layouts.md) for YAML examples and CLI commands.
1823
+
1824
+
1825
+ ### Dedicated notification service
1826
+
1827
+ Configure the web backend with `OURS_NOTIFICATIONS_ORIGIN` and a Fleet-scoped `OURS_NOTIFICATIONS_PRODUCER_TOKEN`. It reads each local supervisor ledger incrementally with persistent checkpoints under web state, correlating owner browser/channel prompts with terminal completion. Monitor/startup turns do not notify. First installation skips existing history; no agent restart or conversation controller is needed. Persist web state across deployment/restarts. Reads are bounded to 256 KiB per role per tick, and oversized model/media records are skipped across chunks. Expose `/notifications` through the generic authenticated prefix gateway with a server-side user token and `stripBrowserContext: true`; producer tokens stay server-side. All browser sessions of this single-owner console map to one stable notification account. See [ours-notifications](https://github.com/adapt-toolkit/ours-notifications) for configuration/API/release details.
@@ -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.23",
3
- "buildId": "84a9c6c0a54c",
4
- "commit": "75b0bebdb33a4ace4713015f7a7f49adc330f6e4",
2
+ "version": "1.2.0-nightly.25",
3
+ "buildId": "a567036edf8d",
4
+ "commit": "59ceeac2bb5c52f0861fe19d3c20e5e884d37c6c",
5
5
  "dirty": true,
6
- "builtAt": "2026-09-30T10:20:51.847Z",
6
+ "builtAt": "2026-09-30T20:02:41.728Z",
7
7
  "capabilities": [
8
8
  "cowork.http-management-v1",
9
9
  "monitor.interrupt.after_tool"
@@ -0,0 +1,18 @@
1
+ import { type ProducerConfig } from './outbox.js';
2
+ /** Read durable supervisor ledgers without restarting agents or attaching a
3
+ * conversation controller. Each tick reads at most 256 KiB per role, from its
4
+ * checkpoint; it never loads a complete transcript or requests model history. */
5
+ export declare class FleetNotificationProducer {
6
+ private readonly roots;
7
+ private readonly stateDir;
8
+ private readonly config;
9
+ private readonly warn;
10
+ private readonly queues;
11
+ private readonly timer;
12
+ private readonly existing;
13
+ constructor(roots: string[], stateDir: string, config: ProducerConfig, warn?: (line: string) => void);
14
+ private names;
15
+ poll(): void;
16
+ drain(): Promise<void>;
17
+ close(): Promise<void>;
18
+ }
@@ -0,0 +1,129 @@
1
+ import { closeSync, existsSync, lstatSync, openSync, readSync, readdirSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { NotificationOutbox } from './outbox.js';
5
+ /** Read durable supervisor ledgers without restarting agents or attaching a
6
+ * conversation controller. Each tick reads at most 256 KiB per role, from its
7
+ * checkpoint; it never loads a complete transcript or requests model history. */
8
+ export class FleetNotificationProducer {
9
+ roots;
10
+ stateDir;
11
+ config;
12
+ warn;
13
+ queues = new Map();
14
+ timer;
15
+ existing = new Set();
16
+ constructor(roots, stateDir, config, warn = () => { }) {
17
+ this.roots = roots;
18
+ this.stateDir = stateDir;
19
+ this.config = config;
20
+ this.warn = warn;
21
+ for (const root of roots)
22
+ for (const name of this.names(root))
23
+ this.existing.add(join(root, name));
24
+ this.poll();
25
+ this.timer = setInterval(() => this.poll(), 1000);
26
+ this.timer.unref();
27
+ }
28
+ names(root) {
29
+ try {
30
+ return readdirSync(root, { withFileTypes: true }).filter(d => d.isDirectory() && /^[a-zA-Z0-9_-]+$/.test(d.name)).map(d => d.name);
31
+ }
32
+ catch {
33
+ return [];
34
+ }
35
+ }
36
+ poll() {
37
+ for (const root of this.roots)
38
+ for (const roleId of this.names(root)) {
39
+ const roleDir = join(root, roleId), dir = join(roleDir, '.conversation');
40
+ try {
41
+ if (!existsSync(dir) || lstatSync(dir).isSymbolicLink())
42
+ continue;
43
+ const segments = readdirSync(dir).filter(n => /^events-\d{6}\.jsonl$/.test(n) && !lstatSync(join(dir, n)).isSymbolicLink()).sort();
44
+ if (!segments.length)
45
+ continue;
46
+ let outbox = this.queues.get(roleDir);
47
+ if (!outbox) {
48
+ const key = createHash('sha256').update(roleDir).digest('hex');
49
+ outbox = new NotificationOutbox(join(this.stateDir, key + '.json'), this.config, async (value, eventId) => ({ eventId, title: `${roleId}: agent completed`, body: 'Your agent finished responding. Open the chat to view the result.',
50
+ url: `/fleet/chats?chat=${encodeURIComponent(roleId)}&detail=1`, ...value }), this.warn);
51
+ if (!outbox.checkpoint) {
52
+ const segment = this.existing.has(roleDir) ? segments.at(-1) : segments[0];
53
+ outbox.saveCheckpoint({ segment, offset: this.existing.has(roleDir) ? statSync(join(dir, segment)).size : 0, prompts: [] });
54
+ }
55
+ this.queues.set(roleDir, outbox);
56
+ }
57
+ const saved = outbox.checkpoint;
58
+ const current = { ...saved, prompts: [...saved.prompts], messages: { ...saved.messages } };
59
+ let index = segments.indexOf(current.segment);
60
+ if (index < 0) {
61
+ this.warn('Notification ledger segment unavailable; checkpoint retained');
62
+ continue;
63
+ }
64
+ const file = join(dir, current.segment), size = statSync(file).size;
65
+ if (size < current.offset) {
66
+ this.warn('Notification ledger truncated; checkpoint retained');
67
+ continue;
68
+ }
69
+ if (size === current.offset && index < segments.length - 1) {
70
+ current.segment = segments[++index];
71
+ current.offset = 0;
72
+ }
73
+ const fd = openSync(join(dir, current.segment), 'r'), buffer = Buffer.alloc(256 * 1024);
74
+ let count;
75
+ try {
76
+ count = readSync(fd, buffer, 0, buffer.length, current.offset);
77
+ }
78
+ finally {
79
+ closeSync(fd);
80
+ }
81
+ let position = 0;
82
+ while (position < count) {
83
+ const end = buffer.indexOf(10, position);
84
+ if (end < 0 || end >= count) {
85
+ // Oversized model/media events are irrelevant to prompt/completion
86
+ // correlation. Skip in bounded chunks; retain partial small lines.
87
+ if (current.discarding || (position === 0 && count === buffer.length)) {
88
+ current.offset += count - position;
89
+ current.discarding = true;
90
+ this.warn('Notification observer skipping oversized ledger record');
91
+ }
92
+ break;
93
+ }
94
+ if (current.discarding) {
95
+ current.offset += end - position + 1;
96
+ position = end + 1;
97
+ current.discarding = false;
98
+ continue;
99
+ }
100
+ const event = JSON.parse(buffer.subarray(position, end).toString('utf8'));
101
+ if (event.schemaVersion !== 1 || event.roleId !== roleId)
102
+ throw new Error('invalid ledger event');
103
+ const prompt = `${event.sessionGeneration}:${event.promptId}`;
104
+ if (event.kind === 'prompt.admitted' && event.promptId && ['browser', 'owner_admin_console', 'owner_channel'].includes(event.source ?? '')) {
105
+ if (!current.prompts.includes(prompt))
106
+ current.prompts.push(prompt);
107
+ }
108
+ if (['message.chunk', 'message.replace'].includes(event.kind) && event.promptId && event.messageId && current.prompts.includes(prompt))
109
+ current.messages[prompt] = event.messageId;
110
+ if (event.kind === 'turn.completed' && event.promptId && current.prompts.includes(prompt)) {
111
+ if (!outbox.enqueue(`${roleId}:${event.sessionGeneration}:${event.seq}`, { url: `/fleet/chats?chat=${encodeURIComponent(roleId)}&detail=1#fleet-message-${encodeURIComponent(current.messages[prompt] ?? event.promptId)}` }))
112
+ break;
113
+ current.prompts = current.prompts.filter(p => p !== prompt);
114
+ delete current.messages[prompt];
115
+ }
116
+ current.offset += end - position + 1;
117
+ position = end + 1;
118
+ }
119
+ if (current.segment !== saved.segment || current.offset !== saved.offset)
120
+ outbox.saveCheckpoint(current);
121
+ }
122
+ catch {
123
+ this.warn('Notification ledger admission pending; checkpoint will replay');
124
+ }
125
+ }
126
+ }
127
+ async drain() { await Promise.all([...this.queues.values()].map(q => q.drain())); }
128
+ async close() { clearInterval(this.timer); await Promise.all([...this.queues.values()].map(q => q.close())); }
129
+ }
@@ -0,0 +1,31 @@
1
+ export interface NotificationInput {
2
+ eventId: string;
3
+ title: string;
4
+ body: string;
5
+ url: string;
6
+ }
7
+ export interface ProducerConfig {
8
+ origin: string;
9
+ token: string;
10
+ }
11
+ export declare function producerConfig(env?: NodeJS.ProcessEnv): ProducerConfig | undefined;
12
+ /** Durable producer queue. Service dedupe makes crash-after-acceptance replay safe. */
13
+ export declare class NotificationOutbox {
14
+ private readonly file;
15
+ private readonly config;
16
+ private readonly project;
17
+ private readonly warn;
18
+ private entries;
19
+ cursor?: number;
20
+ checkpoint?: unknown;
21
+ private running?;
22
+ private timer;
23
+ constructor(file: string, config: ProducerConfig, project: (value: unknown, id: string) => Promise<NotificationInput>, warn?: (message: string) => void);
24
+ private persist;
25
+ advance(cursor: number): void;
26
+ saveCheckpoint(checkpoint: unknown): void;
27
+ enqueue(id: string, value: unknown, cursor?: number | undefined): boolean;
28
+ drain(): Promise<void>;
29
+ private drainOnce;
30
+ close(): Promise<void>;
31
+ }
@@ -0,0 +1,104 @@
1
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ export function producerConfig(env = process.env) {
4
+ const origin = env.OURS_NOTIFICATIONS_ORIGIN, token = env.OURS_NOTIFICATIONS_PRODUCER_TOKEN;
5
+ if (!origin && !token)
6
+ return;
7
+ if (!origin || !token || token.length < 32)
8
+ throw new Error('notifications producer requires origin and scoped token');
9
+ const url = new URL(origin);
10
+ if (!['http:', 'https:'].includes(url.protocol) || url.origin !== origin || url.username || url.password)
11
+ throw new Error('invalid notification service origin');
12
+ if (url.protocol === 'http:' && !['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname))
13
+ throw new Error('notification credentials require HTTPS or loopback');
14
+ return { origin, token };
15
+ }
16
+ /** Durable producer queue. Service dedupe makes crash-after-acceptance replay safe. */
17
+ export class NotificationOutbox {
18
+ file;
19
+ config;
20
+ project;
21
+ warn;
22
+ entries;
23
+ cursor;
24
+ checkpoint;
25
+ running;
26
+ timer;
27
+ constructor(file, config, project, warn = () => { }) {
28
+ this.file = file;
29
+ this.config = config;
30
+ this.project = project;
31
+ this.warn = warn;
32
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
33
+ const saved = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : { entries: [] };
34
+ this.entries = Array.isArray(saved) ? saved : saved.entries;
35
+ this.cursor = saved.cursor;
36
+ this.checkpoint = saved.checkpoint;
37
+ if (!Array.isArray(this.entries))
38
+ throw new Error('invalid notification outbox');
39
+ this.timer = setInterval(() => { void this.drain(); }, 3000);
40
+ this.timer.unref();
41
+ }
42
+ persist(entries, cursor = this.cursor, checkpoint = this.checkpoint) {
43
+ const tmp = this.file + '.tmp', fd = openSync(tmp, 'w', 0o600);
44
+ try {
45
+ writeFileSync(fd, JSON.stringify({ entries, cursor, checkpoint }));
46
+ fsyncSync(fd);
47
+ }
48
+ finally {
49
+ closeSync(fd);
50
+ }
51
+ renameSync(tmp, this.file);
52
+ const dir = openSync(dirname(this.file), 'r');
53
+ try {
54
+ fsyncSync(dir);
55
+ }
56
+ finally {
57
+ closeSync(dir);
58
+ }
59
+ this.entries = entries;
60
+ this.cursor = cursor;
61
+ this.checkpoint = checkpoint;
62
+ }
63
+ advance(cursor) { this.persist(this.entries, cursor); }
64
+ saveCheckpoint(checkpoint) { this.persist(this.entries, this.cursor, checkpoint); }
65
+ enqueue(id, value, cursor = this.cursor) {
66
+ if (this.entries.some(e => e.id === id)) {
67
+ if (cursor !== this.cursor)
68
+ this.advance(cursor);
69
+ return true;
70
+ }
71
+ if (this.entries.length >= 2000)
72
+ return false;
73
+ this.persist([...this.entries, { id, value }], cursor);
74
+ return true;
75
+ }
76
+ drain() {
77
+ return this.running ??= this.drainOnce().finally(() => { this.running = undefined; });
78
+ }
79
+ async drainOnce() {
80
+ for (const entry of [...this.entries].slice(0, 32)) {
81
+ try {
82
+ const value = entry.payload ?? await this.project(entry.value, entry.id);
83
+ // Freeze the canonical projection before any network attempt. A crash
84
+ // after service acceptance must replay the identical dedupe payload.
85
+ if (!entry.payload)
86
+ this.persist(this.entries.map(e => e.id === entry.id ? { ...e, payload: value } : e));
87
+ const response = await fetch(this.config.origin + '/api/v1/send', {
88
+ method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10_000),
89
+ headers: { Authorization: `Bearer ${this.config.token}`, 'Content-Type': 'application/json' },
90
+ body: JSON.stringify(value),
91
+ });
92
+ await response.body?.cancel();
93
+ if (!response.ok)
94
+ throw new Error('notification service rejected event');
95
+ this.persist(this.entries.filter(e => e.id !== entry.id));
96
+ }
97
+ catch {
98
+ this.warn('Notification producer delivery pending; retrying from durable outbox');
99
+ break;
100
+ }
101
+ }
102
+ }
103
+ async close() { clearInterval(this.timer); await this.running; }
104
+ }
@@ -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) {
@@ -6,6 +6,8 @@ export interface ServiceTarget {
6
6
  origin: string;
7
7
  /** Server-side service credentials; never taken from browser headers. */
8
8
  headers?: Record<string, string>;
9
+ /** Dedicated credential-authenticated services reject direct browser context. */
10
+ stripBrowserContext?: boolean;
9
11
  }
10
12
  export interface GatewayOptions {
11
13
  /** Backend mode is for a separate loopback machine-client listener; browsers are rejected. */
@@ -45,6 +45,10 @@ export function createPrefixGateway(options) {
45
45
  for (const name of ['cookie', 'authorization', 'x-csrf-token', 'x-ours-api-token'])
46
46
  delete headers[name];
47
47
  Object.assign(headers, route.service.headers);
48
+ if (route.service.stripBrowserContext) {
49
+ for (const name of ['origin', 'sec-fetch-site', 'sec-fetch-mode', 'sec-fetch-dest', 'sec-fetch-user'])
50
+ delete headers[name];
51
+ }
48
52
  headers.host = new URL(route.origin).host;
49
53
  }
50
54
  if (upgrade) {
@@ -1,4 +1,8 @@
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';
4
+ import { FleetNotificationProducer } from '../notifications/fleet-producer.js';
5
+ import { producerConfig } from '../notifications/outbox.js';
2
6
  import { SubscriptionService } from '../subscriptions/service.js';
3
7
  import { existsSync, realpathSync } from 'node:fs';
4
8
  import { resolve } from 'node:path';
@@ -14,7 +18,7 @@ import { FleetError } from '../application/errors.js';
14
18
  import { controlRequest, controlSocketPath } from '../session/control.js';
15
19
  import { pickBackend } from '../supervisor/index.js';
16
20
  import { realExec } from '../exec.js';
17
- import { home, stateRoot } from '../paths.js';
21
+ import { home, stateRoot, agentsRoot, tmpRoot } from '../paths.js';
18
22
  import { AuditSink } from './audit.js';
19
23
  import { FleetEventBus } from './events.js';
20
24
  import { buildWebServer } from './server.js';
@@ -50,6 +54,7 @@ function cachedConfigProvider(configPath) {
50
54
  };
51
55
  }
52
56
  export async function startWebConsole(options) {
57
+ const notificationConfig = producerConfig();
53
58
  const requestedPort = options.port ?? 49_271;
54
59
  if (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65_535)
55
60
  throw new FleetError('invalid_request', 'port must be between 0 and 65535');
@@ -164,6 +169,7 @@ export async function startWebConsole(options) {
164
169
  query, repository, logs, commands, creation, removal, audit, events, watchdogs, configuration,
165
170
  taskRooms: new TaskRoomApplicationService(options.configPath, { binPath: () => options.binPath }),
166
171
  roomLayouts: new RoomLayoutDefinitions(options.configPath),
172
+ layoutBindings: new LayoutBindingGrants(new RoomLayoutService(options.configPath).supervisor()),
167
173
  presetProvenance: new PresetProvenance(options.configPath),
168
174
  topology: readTopology, topologyDrafts, topologyPromote,
169
175
  subscriptions: new SubscriptionService({
@@ -233,10 +239,12 @@ export async function startWebConsole(options) {
233
239
  if (options.open !== false)
234
240
  openBrowser(access.mode === 'pairing'
235
241
  ? `${browserOrigin}/#bootstrap=${server.auth.bootstrapSecret}` : `${browserOrigin}/`);
242
+ const notifications = notificationConfig ? new FleetNotificationProducer([agentsRoot(), tmpRoot()], resolve(webDir, 'notification-outboxes'), notificationConfig, options.log) : undefined;
236
243
  return {
237
244
  ...server, address: browserOrigin,
238
245
  async close() {
239
246
  try {
247
+ await notifications?.close();
240
248
  await control?.close();
241
249
  await server.close();
242
250
  }
@@ -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
@@ -925,7 +937,11 @@ export async function buildWebServer(services, boundary, options = {}) {
925
937
  app.setNotFoundHandler((request, reply) => {
926
938
  if (request.url.startsWith('/api/'))
927
939
  return reply.code(404).send({ error: 'not found' });
928
- return reply.sendFile('index.html');
940
+ const path = request.url.split('?')[0];
941
+ if ((path === '/chats' || path.startsWith('/chats/')) && existsSync(join(staticRoot, 'fleet-index.html')))
942
+ return reply.redirect((request.url.includes('?') ? '/fleet/chats' : '/fleet') + (request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : ''));
943
+ const fleetEntry = (path === '/fleet' || path.startsWith('/fleet/')) && existsSync(join(staticRoot, 'fleet-index.html'));
944
+ return reply.sendFile(fleetEntry ? 'fleet-index.html' : 'index.html');
929
945
  });
930
946
  }
931
947
  return {
@@ -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.23",
3
+ "version": "1.2.0-nightly.25",
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",