@ours.network/fleet 1.2.0-nightly.5 → 1.2.0-nightly.7

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
@@ -1710,3 +1710,43 @@ Explicit Cowork socket, config or state-directory overrides continue to select
1710
1710
  local Unix management. Profiles without `serverUrl` retain legacy behavior. The
1711
1711
  HTTP API grants operator room authority; use the installer's supported private
1712
1712
  or authenticated external gateway entry, and keep its backend ports private.
1713
+
1714
+ ### Agent ours tools through the supervisor
1715
+
1716
+ Operators can invoke the running agent's ordinary ours MCP tools without sending
1717
+ an LLM prompt or binding another session to its identity:
1718
+
1719
+ ```sh
1720
+ ours-fleet ours tools Critic1
1721
+ ours-fleet ours call Critic1 current_identity
1722
+ ours-fleet ours call Critic1 list_contacts
1723
+ ours-fleet ours call Critic1 generate_invite --args-file /private/invite-options.json
1724
+ ours-fleet ours call Critic1 add_contact --args-file /private/contact.json
1725
+ ```
1726
+
1727
+ `tools` returns the managed MCP tool names and argument schemas, plus the selected
1728
+ agent's identity name, CID, and supervisor generation. `call` returns the same
1729
+ identity metadata and the MCP result. The private JSON file contains the tool's
1730
+ arguments (for example `{"invite":"…"}` for `add_contact`); omitted arguments mean
1731
+ `{}`. Protect files containing invites and protect command output containing a
1732
+ new invite. Tool errors set a nonzero CLI exit status.
1733
+
1734
+ The authenticated REST equivalents are `GET /api/v1/roles/:id/ours/tools` and
1735
+ `POST /api/v1/roles/:id/ours/call`, with the normal Fleet session and CSRF token.
1736
+ The POST body is `{"tool":"list_contacts","arguments":{}}`. MCP tool errors
1737
+ remain in `result.isError`; transport failures use the normal Fleet error envelope.
1738
+ Request arguments and results are excluded from the Fleet audit log.
1739
+
1740
+ Both interfaces use the supervisor's existing fixed-identity MCP server and tool
1741
+ policy. Identity creation, removal, switching, and binding are not exposed. The
1742
+ supervisor must be running. Older descriptors are supported only when a unique
1743
+ existing runtime journal, instance record, and identity pin prove the selected
1744
+ agent and generation, and the same MCP connection confirms its identity and
1745
+ lifetime before the operation. This reads existing state without rewriting the
1746
+ descriptor or restarting the agent. Missing, ambiguous, stale, or mismatched
1747
+ proofs fail closed. Unknown identity descriptions also fail closed. Closing an operator call
1748
+ retains the supervisor's identity. An interrupted mutation can have an unknown
1749
+ outcome: inspect state before retrying, because there is no automatic retry or
1750
+ exactly-once guarantee. Contact acceptance alone does not prove peer verification
1751
+ or message delivery. File tools resolve paths in the invoking CLI process or
1752
+ Fleet web server's filesystem context, with that process's access permissions.
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { createHash } from 'node:crypto';
2
3
  import { connect } from 'node:net';
3
4
  import { access, mkdir, open, readFile } from 'node:fs/promises';
4
5
  import { constants } from 'node:fs';
@@ -12,6 +13,14 @@ export async function runBridge(descriptorPath) {
12
13
  typeof descriptor.capability !== 'string' ||
13
14
  !Number.isInteger(descriptor.generation))
14
15
  throw Error('INVALID_BRIDGE_DESCRIPTOR');
16
+ if (process.env.FLEET_OURS_BRIDGE_EXPECTED) {
17
+ const expected = JSON.parse(process.env.FLEET_OURS_BRIDGE_EXPECTED);
18
+ const digest = createHash('sha256').update(JSON.stringify([descriptor.socket, descriptor.capability, descriptor.generation])).digest('hex');
19
+ if (expected.transportDigest !== digest)
20
+ throw Error('SUPERVISOR_TRANSPORT_CHANGED');
21
+ if (['role', 'identity', 'cid', 'generation'].some(key => descriptor[key] !== expected[key]))
22
+ throw Error('SUPERVISOR_SELECTION_CHANGED');
23
+ }
15
24
  const wire = new Wire(connect(descriptor.socket));
16
25
  const handles = new Map();
17
26
  const cleanup = async () => {
@@ -11,19 +11,31 @@ import { atomicPrivateWrite, binderKey } from './state.js';
11
11
  import { AgentOursRuntime } from './runtime.js';
12
12
  import { startMcpEndpoint } from './mcp-endpoint.js';
13
13
  export const privateRuntimeRoot = () => join(stateRoot(), 'private-ours');
14
+ function roomSecretPath(role) {
15
+ const startup = role.roomMemberStartup;
16
+ return join(privateRuntimeRoot(), 'room-inputs', binderKey(startup.room_identity_cid, JSON.stringify([role.name, startup.invite_id])) + '.json');
17
+ }
18
+ function matchesRoomSecret(secret, role) {
19
+ const startup = role.roomMemberStartup;
20
+ return secret.room_id === startup.room_id && secret.room_identity_cid === startup.room_identity_cid
21
+ && secret.invite_id === startup.invite_id && secret.identity_name === role.identity
22
+ && secret.role === startup.role;
23
+ }
14
24
  /** Only trusted launch orchestration may write this descriptor, never the child. */
15
25
  export function storeRoomSecret(role) {
16
26
  const startup = role.roomMemberStartup;
17
27
  if (!startup?.invite)
18
28
  return;
29
+ if (startup.identity_name !== role.identity)
30
+ throw Error('ROOM_SECRET_MISMATCH');
19
31
  const root = join(privateRuntimeRoot(), 'room-inputs');
20
32
  mkdirSync(root, { recursive: true, mode: 0o700 });
21
- const path = join(root, binderKey(startup.room_identity_cid, role.name) + '.json');
33
+ // Failed attempts retain private evidence. A new invite must never overwrite
34
+ // or consume a previous attempt's descriptor.
35
+ const path = roomSecretPath(role);
22
36
  if (existsSync(path)) {
23
37
  const old = JSON.parse(readFileSync(path, 'utf8'));
24
- if (old.room_id !== startup.room_id ||
25
- old.invite_id !== startup.invite_id ||
26
- old.identity_name !== startup.identity_name)
38
+ if (!matchesRoomSecret(old, role) || old.invite !== startup.invite)
27
39
  throw Error('ROOM_SECRET_COLLISION');
28
40
  return;
29
41
  }
@@ -144,7 +156,9 @@ export async function prepareManagedAgent(role, stateDir, temporary) {
144
156
  let room;
145
157
  if (role.roomMemberStartup) {
146
158
  const startup = role.roomMemberStartup;
147
- const path = join(root, 'room-inputs', binderKey(startup.room_identity_cid, role.name) + '.json');
159
+ const legacyPath = join(root, 'room-inputs', binderKey(startup.room_identity_cid, role.name) + '.json');
160
+ const currentPath = roomSecretPath(role);
161
+ const path = existsSync(currentPath) ? currentPath : legacyPath;
148
162
  const cowork = createCoworkAdapter();
149
163
  room = {
150
164
  id: startup.room_id,
@@ -153,9 +167,7 @@ export async function prepareManagedAgent(role, stateDir, temporary) {
153
167
  action: startup.invite_id,
154
168
  redeem: async (attached) => {
155
169
  const secret = JSON.parse(readFileSync(path, 'utf8'));
156
- if (secret.room_id !== startup.room_id ||
157
- secret.invite_id !== startup.invite_id ||
158
- secret.identity_name !== role.identity)
170
+ if (!matchesRoomSecret(secret, role))
159
171
  throw Error('ROOM_SECRET_MISMATCH');
160
172
  return attached.addContact({ invite: secret.invite });
161
173
  },
@@ -207,7 +219,7 @@ export async function prepareManagedAgent(role, stateDir, temporary) {
207
219
  });
208
220
  partialEndpoint = endpoint;
209
221
  const descriptor = join(bridgeDir, 'descriptor.json');
210
- atomicPrivateWrite(descriptor, { socket, capability, generation });
222
+ atomicPrivateWrite(descriptor, { socket, capability, generation, role: role.name, identity: role.identity, cid: runtime.snapshot.cid });
211
223
  return {
212
224
  runtime,
213
225
  descriptor,
@@ -0,0 +1,7 @@
1
+ /** Read-only compatibility proof for supervisors predating descriptor identity metadata. */
2
+ export declare function legacySupervisorIdentity(role: string, name: string, temporary: boolean, generation: number): {
3
+ name: string;
4
+ cid: string;
5
+ generation: number;
6
+ proof: string;
7
+ };
@@ -0,0 +1,43 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync, readdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { stateRoot } from '../paths.js';
5
+ import { binderKey } from '../agent-ours/state.js';
6
+ import { FleetError } from './errors.js';
7
+ /** Read-only compatibility proof for supervisors predating descriptor identity metadata. */
8
+ export function legacySupervisorIdentity(role, name, temporary, generation) {
9
+ const root = join(stateRoot(), 'private-ours');
10
+ try {
11
+ const matches = [];
12
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
13
+ if (!entry.isDirectory() || !/^[a-f0-9]{64}$/.test(entry.name))
14
+ continue;
15
+ const dir = join(root, entry.name);
16
+ const instance = JSON.parse(readFileSync(join(dir, 'instance.json'), 'utf8'));
17
+ if (instance.role !== role)
18
+ continue;
19
+ const state = JSON.parse(readFileSync(join(dir, 'state.json'), 'utf8'));
20
+ const pin = JSON.parse(readFileSync(join(dir, 'identity-pin.json'), 'utf8'));
21
+ if (!Number.isSafeInteger(generation) || generation < 1
22
+ || typeof state.action !== 'string' || !state.action
23
+ || instance.temporary !== temporary || typeof instance.instance !== 'string' || !instance.instance
24
+ || state.version !== 1 || state.instance !== instance.instance
25
+ || state.name !== name || state.lifetime !== (temporary ? 'temporary' : 'permanent')
26
+ || state.generation !== generation || !['READY', 'SERVING'].includes(state.phase)
27
+ || typeof state.daemon !== 'string' || !state.daemon || binderKey(state.daemon, name) !== entry.name
28
+ || !/^[a-f0-9]{64}$/i.test(state.cid ?? '')
29
+ || pin.daemon !== state.daemon || pin.name !== name || pin.cid !== state.cid)
30
+ throw new Error('mismatched legacy ownership proof');
31
+ matches.push({ name, cid: state.cid, generation, proof: createHash('sha256').update(JSON.stringify([
32
+ instance.instance, state.action, state.daemon, name, state.cid, state.lifetime, generation,
33
+ ])).digest('hex') });
34
+ }
35
+ if (matches.length !== 1)
36
+ throw new Error('missing or ambiguous legacy ownership proof');
37
+ return matches[0];
38
+ }
39
+ catch {
40
+ // The private directory also contains ownership material: never echo parse errors.
41
+ throw new FleetError('capability_unavailable', 'legacy supervisor ownership proof is missing, ambiguous, or mismatched');
42
+ }
43
+ }
@@ -0,0 +1,159 @@
1
+ export interface SupervisorToolRequest {
2
+ tool: string;
3
+ arguments?: Record<string, unknown>;
4
+ }
5
+ /** CLI and REST use the same fixed-identity MCP server as the agent harness. */
6
+ export declare class SupervisorOursTools {
7
+ private withClient;
8
+ list(role: string): Promise<{
9
+ tools: {
10
+ inputSchema: {
11
+ [x: string]: unknown;
12
+ type: "object";
13
+ properties?: Record<string, object> | undefined;
14
+ required?: string[] | undefined;
15
+ };
16
+ name: string;
17
+ description?: string | undefined;
18
+ outputSchema?: {
19
+ [x: string]: unknown;
20
+ type: "object";
21
+ properties?: Record<string, object> | undefined;
22
+ required?: string[] | undefined;
23
+ } | undefined;
24
+ annotations?: {
25
+ title?: string | undefined;
26
+ readOnlyHint?: boolean | undefined;
27
+ destructiveHint?: boolean | undefined;
28
+ idempotentHint?: boolean | undefined;
29
+ openWorldHint?: boolean | undefined;
30
+ } | undefined;
31
+ execution?: {
32
+ taskSupport?: "optional" | "required" | "forbidden" | undefined;
33
+ } | undefined;
34
+ _meta?: Record<string, unknown> | undefined;
35
+ icons?: {
36
+ src: string;
37
+ mimeType?: string | undefined;
38
+ sizes?: string[] | undefined;
39
+ theme?: "light" | "dark" | undefined;
40
+ }[] | undefined;
41
+ title?: string | undefined;
42
+ }[];
43
+ _meta?: {
44
+ [x: string]: unknown;
45
+ progressToken?: string | number | undefined;
46
+ "io.modelcontextprotocol/related-task"?: {
47
+ taskId: string;
48
+ } | undefined;
49
+ } | undefined;
50
+ nextCursor?: string | undefined;
51
+ agent: string;
52
+ identity: {
53
+ name: string;
54
+ cid: string;
55
+ generation: number;
56
+ };
57
+ }>;
58
+ call(role: string, request: SupervisorToolRequest): Promise<{
59
+ agent: string;
60
+ identity: {
61
+ name: string;
62
+ cid: string;
63
+ generation: number;
64
+ };
65
+ result: {
66
+ [x: string]: unknown;
67
+ content: ({
68
+ type: "text";
69
+ text: string;
70
+ annotations?: {
71
+ audience?: ("user" | "assistant")[] | undefined;
72
+ priority?: number | undefined;
73
+ lastModified?: string | undefined;
74
+ } | undefined;
75
+ _meta?: Record<string, unknown> | undefined;
76
+ } | {
77
+ type: "image";
78
+ data: string;
79
+ mimeType: string;
80
+ annotations?: {
81
+ audience?: ("user" | "assistant")[] | undefined;
82
+ priority?: number | undefined;
83
+ lastModified?: string | undefined;
84
+ } | undefined;
85
+ _meta?: Record<string, unknown> | undefined;
86
+ } | {
87
+ type: "audio";
88
+ data: string;
89
+ mimeType: string;
90
+ annotations?: {
91
+ audience?: ("user" | "assistant")[] | undefined;
92
+ priority?: number | undefined;
93
+ lastModified?: string | undefined;
94
+ } | undefined;
95
+ _meta?: Record<string, unknown> | undefined;
96
+ } | {
97
+ type: "resource";
98
+ resource: {
99
+ uri: string;
100
+ text: string;
101
+ mimeType?: string | undefined;
102
+ _meta?: Record<string, unknown> | undefined;
103
+ } | {
104
+ uri: string;
105
+ blob: string;
106
+ mimeType?: string | undefined;
107
+ _meta?: Record<string, unknown> | undefined;
108
+ };
109
+ annotations?: {
110
+ audience?: ("user" | "assistant")[] | undefined;
111
+ priority?: number | undefined;
112
+ lastModified?: string | undefined;
113
+ } | undefined;
114
+ _meta?: Record<string, unknown> | undefined;
115
+ } | {
116
+ uri: string;
117
+ name: string;
118
+ type: "resource_link";
119
+ description?: string | undefined;
120
+ mimeType?: string | undefined;
121
+ size?: number | undefined;
122
+ annotations?: {
123
+ audience?: ("user" | "assistant")[] | undefined;
124
+ priority?: number | undefined;
125
+ lastModified?: string | undefined;
126
+ } | undefined;
127
+ _meta?: {
128
+ [x: string]: unknown;
129
+ } | undefined;
130
+ icons?: {
131
+ src: string;
132
+ mimeType?: string | undefined;
133
+ sizes?: string[] | undefined;
134
+ theme?: "light" | "dark" | undefined;
135
+ }[] | undefined;
136
+ title?: string | undefined;
137
+ })[];
138
+ _meta?: {
139
+ [x: string]: unknown;
140
+ progressToken?: string | number | undefined;
141
+ "io.modelcontextprotocol/related-task"?: {
142
+ taskId: string;
143
+ } | undefined;
144
+ } | undefined;
145
+ structuredContent?: Record<string, unknown> | undefined;
146
+ isError?: boolean | undefined;
147
+ } | {
148
+ [x: string]: unknown;
149
+ toolResult: unknown;
150
+ _meta?: {
151
+ [x: string]: unknown;
152
+ progressToken?: string | number | undefined;
153
+ "io.modelcontextprotocol/related-task"?: {
154
+ taskId: string;
155
+ } | undefined;
156
+ } | undefined;
157
+ };
158
+ }>;
159
+ }
@@ -0,0 +1,121 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
6
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
7
+ import { agentDir } from '../paths.js';
8
+ import { ROLE_NAME_RE } from '../config.js';
9
+ import { legacySupervisorIdentity } from './legacy-supervisor-identity.js';
10
+ import { FleetError } from './errors.js';
11
+ /** CLI and REST use the same fixed-identity MCP server as the agent harness. */
12
+ export class SupervisorOursTools {
13
+ async withClient(role, work) {
14
+ if (!ROLE_NAME_RE.test(role))
15
+ throw new FleetError('invalid_request', 'invalid agent name');
16
+ const findCandidates = () => [false, true].map(temporary => ({ temporary, dir: agentDir(role, temporary) }))
17
+ .map(row => ({ ...row, path: join(row.dir, '.ours-bridge', 'descriptor.json') }))
18
+ .filter(row => existsSync(row.path));
19
+ const candidates = findCandidates();
20
+ if (candidates.length !== 1)
21
+ throw new FleetError('capability_unavailable', 'agent supervisor endpoint is missing or ambiguous');
22
+ const selected = candidates[0];
23
+ let descriptor;
24
+ let identityName;
25
+ try {
26
+ descriptor = JSON.parse(readFileSync(selected.path, 'utf8'));
27
+ identityName = readFileSync(join(selected.dir, '.identity'), 'utf8').trim();
28
+ if (!descriptor || typeof descriptor !== 'object')
29
+ throw new Error();
30
+ }
31
+ catch {
32
+ throw new FleetError('capability_unavailable', 'supervisor identity metadata cannot be read');
33
+ }
34
+ const legacy = ['role', 'identity', 'cid'].every(key => descriptor[key] === undefined);
35
+ const identity = legacy
36
+ ? legacySupervisorIdentity(role, identityName, selected.temporary, descriptor.generation)
37
+ : { name: identityName, cid: descriptor.cid, generation: descriptor.generation };
38
+ if (legacy && descriptor.socket !== join(selected.dir, '.ours-bridge', `g${descriptor.generation}.sock`))
39
+ throw new FleetError('capability_unavailable', 'legacy supervisor socket does not match the selected agent');
40
+ if ((!legacy && (descriptor.role !== role || descriptor.identity !== identityName))
41
+ || !/^[a-f0-9]{64}$/i.test(identity.cid ?? '') || !Number.isSafeInteger(descriptor.generation) || descriptor.generation < 1)
42
+ throw new FleetError('capability_unavailable', 'supervisor identity metadata is unavailable or mismatched');
43
+ const verifySelection = () => {
44
+ try {
45
+ const current = findCandidates();
46
+ const freshDescriptor = JSON.parse(readFileSync(selected.path, 'utf8'));
47
+ if (current.length !== 1 || current[0].path !== selected.path
48
+ || readFileSync(join(selected.dir, '.identity'), 'utf8').trim() !== identityName
49
+ || ['role', 'identity', 'cid', 'socket', 'capability', 'generation']
50
+ .some(key => freshDescriptor[key] !== descriptor[key]))
51
+ throw new Error();
52
+ }
53
+ catch {
54
+ throw new FleetError('capability_unavailable', 'selected supervisor assignment changed');
55
+ }
56
+ };
57
+ const client = new Client({ name: 'ours-fleet-supervisor-tools', version: '1' });
58
+ const transport = new StdioClientTransport({
59
+ command: process.execPath,
60
+ args: [fileURLToPath(new URL('../agent-ours/bridge.js', import.meta.url))],
61
+ env: { ...Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] !== undefined)),
62
+ FLEET_OURS_BRIDGE_DESCRIPTOR: selected.path,
63
+ FLEET_OURS_BRIDGE_EXPECTED: JSON.stringify({ role: descriptor.role, identity: descriptor.identity, cid: descriptor.cid, generation: descriptor.generation,
64
+ transportDigest: createHash('sha256').update(JSON.stringify([descriptor.socket, descriptor.capability, descriptor.generation])).digest('hex') }) },
65
+ stderr: 'pipe',
66
+ });
67
+ try {
68
+ await client.connect(transport);
69
+ const verify = async () => {
70
+ if (!legacy) {
71
+ verifySelection();
72
+ return;
73
+ }
74
+ const actual = await client.callTool({ name: 'current_identity', arguments: {} });
75
+ const blocks = actual.content;
76
+ const lines = blocks?.length === 1 && blocks[0].type === 'text' ? blocks[0].text?.split('\n') : undefined;
77
+ const prefix = `Bound to "${identity.name}" (${identity.cid})`;
78
+ const suffix = lines?.[0].startsWith(prefix) ? lines[0].slice(prefix.length) : undefined;
79
+ const roleSuffix = typeof suffix === 'string' && /^ — role "[^"\r\n]+" under root "[^"\r\n]+"\.$/.test(suffix);
80
+ const temporaryLine = 'TEMPORARY identity owned by the Fleet supervisor for this logical agent instance. Bridge or harness disconnect retains it; terminal supervisor release deletes local state with best-effort peer notices.';
81
+ if (actual.isError || !(roleSuffix || (selected.temporary && suffix === '.'))
82
+ || (selected.temporary ? lines?.[1] !== temporaryLine : lines?.[1]?.startsWith('TEMPORARY')))
83
+ throw new FleetError('capability_unavailable', 'connected supervisor identity does not match legacy ownership proof');
84
+ const fresh = legacySupervisorIdentity(role, identityName, selected.temporary, descriptor.generation);
85
+ if (!('proof' in identity) || fresh.proof !== identity.proof)
86
+ throw new FleetError('capability_unavailable', 'legacy supervisor identity changed');
87
+ verifySelection();
88
+ };
89
+ await verify();
90
+ return await work(client, { name: identity.name, cid: identity.cid, generation: identity.generation }, verify);
91
+ }
92
+ catch (error) {
93
+ if (error instanceof FleetError)
94
+ throw error;
95
+ // Do not leak arguments, invites, capabilities, or raw transport errors.
96
+ throw new FleetError('control_unavailable', 'supervisor tool request failed; its outcome may be unknown, do not retry a mutation automatically', { retryable: false });
97
+ }
98
+ finally {
99
+ await client.close().catch(() => { });
100
+ await transport.close().catch(() => { });
101
+ }
102
+ }
103
+ list(role) {
104
+ return this.withClient(role, async (client, identity) => ({
105
+ agent: role, identity, ...(await client.listTools()),
106
+ }));
107
+ }
108
+ call(role, request) {
109
+ if (!request || typeof request.tool !== 'string'
110
+ || (request.arguments !== undefined && (!request.arguments || typeof request.arguments !== 'object' || Array.isArray(request.arguments))))
111
+ throw new FleetError('invalid_request', 'tool and object arguments are required');
112
+ return this.withClient(role, async (client, identity, verify) => {
113
+ const { tools } = await client.listTools();
114
+ if (!tools.some(tool => tool.name === request.tool))
115
+ throw new FleetError('forbidden', 'tool is not exposed by the fixed-identity supervisor');
116
+ await verify();
117
+ const result = await client.callTool({ name: request.tool, arguments: request.arguments ?? {} });
118
+ return { agent: role, identity, result };
119
+ });
120
+ }
121
+ }
@@ -136,7 +136,7 @@ export class TaskRoomApplicationService {
136
136
  }
137
137
  catch (error) {
138
138
  if (error instanceof CoworkUnavailableError)
139
- persistBlockTask(task.task_id, 'Cowork management socket is unavailable');
139
+ persistBlockTask(task.task_id, 'Cowork management is unavailable');
140
140
  // Once the durable Room exists, provisioning errors are resumable
141
141
  // saga state. Return that explicit state so the command can launch a
142
142
  // continuation and report the durable in-progress outcome.
@@ -234,17 +234,23 @@ export class TaskRoomApplicationService {
234
234
  const ready = task.state === 'active' && room?.state === 'active'
235
235
  && active === expected && launched === expected;
236
236
  const blocker = task.outcome?.summary ?? task.blocked?.reason ?? room?.saga.error;
237
- const nextAction = room?.provisioning_detail === 'waiting_owner_authorization'
238
- ? `Ensure ours-cowork 1.3.0 or newer is running and available, then run ours-fleet task start ${task.task_id}.`
239
- : room?.provisioning_detail === 'waiting_owner_invite'
240
- ? `Rotate rooms.owner.public_invite, then run ours-fleet task start ${task.task_id}.`
241
- : room?.provisioning_detail === 'owner_cid_mismatch'
242
- ? `Verify rooms.owner.expected_cid, rotate the Owner invite, then run ours-fleet task start ${task.task_id}.`
243
- : room?.provisioning_detail === 'waiting_cowork'
244
- ? `Restore ours-cowork, then run ours-fleet task start ${task.task_id}.`
245
- : failed
246
- ? `Correct the blocker, then run ours-fleet task start ${task.task_id}.`
247
- : undefined;
237
+ const nextAction = task.terminal_intent
238
+ ? `Complete the accepted ${task.terminal_intent.kind} operation; do not restart provisioning.`
239
+ : room?.state === 'closing' || room?.state === 'closed'
240
+ ? `Complete room cleanup; do not restart provisioning.`
241
+ : room?.provisioning_detail === 'member_failed'
242
+ ? `Inspect the failed launch, then run ours-fleet task start ${task.task_id}.`
243
+ : room?.provisioning_detail === 'waiting_owner_authorization'
244
+ ? `Ensure ours-cowork 1.3.0 or newer is running and available, then run ours-fleet task start ${task.task_id}.`
245
+ : room?.provisioning_detail === 'waiting_owner_invite'
246
+ ? `Rotate rooms.owner.public_invite, then run ours-fleet task start ${task.task_id}.`
247
+ : room?.provisioning_detail === 'owner_cid_mismatch'
248
+ ? `Verify rooms.owner.expected_cid, rotate the Owner invite, then run ours-fleet task start ${task.task_id}.`
249
+ : room?.provisioning_detail === 'waiting_cowork'
250
+ ? `Restore ours-cowork, then run ours-fleet task start ${task.task_id}.`
251
+ : failed
252
+ ? `Correct the blocker, then run ours-fleet task start ${task.task_id}.`
253
+ : undefined;
248
254
  return {
249
255
  kind: failed ? 'failed' : ready ? 'ready' : 'in_progress', task, room,
250
256
  launch: {
@@ -410,7 +416,9 @@ export class TaskRoomApplicationService {
410
416
  let task = readTask(input.taskId);
411
417
  let room = task.room_id ? getRoomRecord(task.room_id) : undefined;
412
418
  const issues = [];
413
- if (task.state !== 'provisioning')
419
+ if (task.state !== 'provisioning' || task.terminal_intent
420
+ || room?.state === 'closing' || room?.state === 'closed'
421
+ || room?.provisioning_detail === 'member_failed')
414
422
  return {
415
423
  kind: 'no_op', task, room, issues,
416
424
  };
@@ -784,7 +792,7 @@ export class TaskRoomApplicationService {
784
792
  }
785
793
  catch (error) {
786
794
  if (error instanceof CoworkUnavailableError)
787
- persistBlockTask(task.task_id, 'Cowork management socket is unavailable');
795
+ persistBlockTask(task.task_id, 'Cowork management is unavailable');
788
796
  const current = readTask(task.task_id);
789
797
  if (!current.room_id || !getRoomRecord(current.room_id))
790
798
  throw error;
@@ -802,7 +810,7 @@ export class TaskRoomApplicationService {
802
810
  }
803
811
  catch (error) {
804
812
  if (error instanceof CoworkUnavailableError)
805
- persistBlockTask(task.task_id, 'Cowork management socket is unavailable');
813
+ persistBlockTask(task.task_id, 'Cowork management is unavailable');
806
814
  task = readTask(task.task_id);
807
815
  return { task, status: 'in_progress' };
808
816
  }
@@ -820,7 +828,7 @@ export class TaskRoomApplicationService {
820
828
  }
821
829
  catch (error) {
822
830
  if (error instanceof CoworkUnavailableError)
823
- persistBlockTask(task.task_id, 'Cowork management socket is unavailable');
831
+ persistBlockTask(task.task_id, 'Cowork management is unavailable');
824
832
  task = readTask(task.task_id);
825
833
  }
826
834
  }
@@ -878,11 +886,17 @@ export class TaskRoomApplicationService {
878
886
  if (fresh.deletion?.status === 'pending')
879
887
  throw new TaskRoomApplicationError('task_deleting', `task ${task.task_id} is pending deletion`, { task: task.task_id });
880
888
  }
889
+ const requiredRoles = new Map();
890
+ if (attachOwner)
891
+ requiredRoles.set(rooms.owner.role, 1);
892
+ for (const member of launchTemplate?.members ?? [])
893
+ requiredRoles.set(member.role, (requiredRoles.get(member.role) ?? 0) + member.count);
881
894
  const created = await cowork.createRoom({
882
895
  room_name: roomName, goal: task.goal?.trim() || task.title,
883
896
  briefing: task.brief?.trim() || launchTemplate?.contract?.trim() || task.goal?.trim() || task.title,
884
897
  quiet_membership: launchTemplate?.room?.quiet_membership,
885
898
  anonymous: policy.anonymous,
899
+ activation_requirements: [...requiredRoles].map(([role, count]) => ({ role, count })),
886
900
  });
887
901
  const record = createRoomRecord({
888
902
  room_id: created.room_id, room_name: roomName, room_identity_cid: created.identity_cid,
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "1.2.0-nightly.5",
3
- "buildId": "4d7255bd137c",
4
- "commit": "273d3bec2505bd1a4244c6b00b836238faaac99e",
2
+ "version": "1.2.0-nightly.7",
3
+ "buildId": "f5cfd3c1f10c",
4
+ "commit": "a5d6f736733fa419e6acf7306affb4a662261bbf",
5
5
  "dirty": true,
6
- "builtAt": "2026-09-22T18:42:44.399Z",
6
+ "builtAt": "2026-09-23T20:14:37.547Z",
7
7
  "capabilities": [
8
8
  "cowork.http-management-v1",
9
9
  "monitor.interrupt.after_tool"
package/dist/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { SupervisorOursTools } from './application/supervisor-ours-tools.js';
2
3
  import { runTempSupervisor, TEMP_RECYCLE_EXIT } from './temp-supervisor-recovery.js';
3
4
  import { spawn as spawnChild } from 'node:child_process';
4
5
  import { randomUUID } from 'node:crypto';
@@ -496,6 +497,40 @@ program.command('peek <name> [lines]').description('pane snapshot without attach
496
497
  die(controlFailure(name, 'peek', e));
497
498
  }
498
499
  });
500
+ const oursToolsCommand = program.command('ours').description('invoke tools through a named agent supervisor and its fixed identity');
501
+ oursToolsCommand.command('tools <agent>').description('list the same tools and schemas exposed to the agent MCP')
502
+ .action(async (agent) => {
503
+ try {
504
+ console.log(JSON.stringify(await new SupervisorOursTools().list(agent), null, 2));
505
+ }
506
+ catch (error) {
507
+ die(error);
508
+ }
509
+ });
510
+ oursToolsCommand.command('call <agent> <tool>').description('call a supervisor MCP tool without prompting the agent')
511
+ .option('--args-file <path>', 'JSON object arguments from a private file; omitted means {}')
512
+ .action(async (agent, tool, options) => {
513
+ try {
514
+ let args = {};
515
+ if (options.argsFile) {
516
+ try {
517
+ args = JSON.parse(readFileSync(options.argsFile, 'utf8'));
518
+ }
519
+ catch {
520
+ throw new Error('cannot read tool arguments: expected a readable JSON object file');
521
+ }
522
+ }
523
+ const response = await new SupervisorOursTools().call(agent, { tool, arguments: args });
524
+ console.log(JSON.stringify(response, null, 2));
525
+ if ('isError' in response.result && response.result.isError)
526
+ throw new FleetCliExit(1, 'runtime', 'unknown');
527
+ }
528
+ catch (error) {
529
+ if (error instanceof FleetCliExit)
530
+ throw error;
531
+ die(error);
532
+ }
533
+ });
499
534
  program.command('send <name> [text...]').description("type into the agent's console")
500
535
  .action(async (name, text, opts) => {
501
536
  const stateDir = acpStateDir(name);
@@ -1612,6 +1647,8 @@ async function parseFleetCli() {
1612
1647
  await program.parseAsync(process.argv);
1613
1648
  }
1614
1649
  catch (error) {
1650
+ if (error instanceof FleetCliExit)
1651
+ throw error;
1615
1652
  const commander = error;
1616
1653
  if (commander.exitCode === 0)
1617
1654
  return;
package/dist/docs.d.ts CHANGED
@@ -17,8 +17,8 @@ export declare const AI_DOCS = "# ours-fleet reference\n\nours-fleet runs persis
17
17
  * \`forbidden\` is the more important half. The old skills prescribed
18
18
  * \`--approval ask --filesystem workspace --unattended deny\` as a blanket
19
19
  * default while also telling the agent to stop at a failed doctor check — and
20
- * that combination is exactly what \`doctor\` FAILS, because \`ask\` grants an
21
- * unattended role nothing but \`read-state\` and \`deny\` makes the shortfall
20
+ * that combination is exactly what \`doctor\` FAILS, because \`ask\` cannot
21
+ * guarantee unattended capabilities and \`deny\` makes the shortfall
22
22
  * fatal. Following the skill produced a role the CLI then refused.
23
23
  */
24
24
  export declare const SPAWN_SKILL_CONTRACT: {