@ours.network/fleet 1.2.0-nightly.36 → 1.2.0-nightly.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "1.2.0-nightly.36",
3
- "buildId": "63df63bcda45",
4
- "commit": "b4575c95acd1ae2fee42f8f8871bfe9d816a1b1a",
2
+ "version": "1.2.0-nightly.38",
3
+ "buildId": "2eaafbd42c9d",
4
+ "commit": "86c7af960f9b2cfdbf4dcddee25bc7303e78780c",
5
5
  "dirty": true,
6
- "builtAt": "2026-10-02T18:04:01.351Z",
6
+ "builtAt": "2026-10-02T21:39:35.791Z",
7
7
  "capabilities": [
8
8
  "workspace.enroll.preserve-profile-v1",
9
9
  "cowork.http-management-v1",
@@ -6,7 +6,8 @@ import { realExec } from '../exec.js';
6
6
  import { registerAdapter } from './registry.js';
7
7
  import { replaceFileAtomically, withFileLock } from '../atomic-file.js';
8
8
  import { harnessRuntimeDir } from '../isolation/policy.js';
9
- import { bundledAcpAgent } from './acp-agent.js';
9
+ import { bundledAcpAgent, resolveBundledAcpAgent } from './acp-agent.js';
10
+ import { hostClaude } from './claude-runtime.js';
10
11
  import { ClaudeCodeAgentSessionAdapter } from './claude-code-session.js';
11
12
  const OPTION_KEYS = [
12
13
  'plugins', 'mem_palace', 'mem_palace_midsession_autosave', 'permission_mode', 'effort',
@@ -283,6 +284,13 @@ export function makeClaudeCodeAdapter(exec = realExec, transport) {
283
284
  };
284
285
  if (!memPalace)
285
286
  env.MEMPALACE_DISABLED = 'true';
287
+ // The bundled adapter runs the Claude Code shipped with its SDK unless told otherwise. A host
288
+ // Claude Code at least as new as that copy is the one the person updates, so sessions run it.
289
+ if (!customAcpCommand(role)) {
290
+ const host = hostClaude(resolveBundledAcpAgent('@agentclientprotocol/claude-agent-acp', 'claude-agent-acp', 'claude-agent-acp').manifestPath, { ...process.env, ...role.env }, dirs.runCwd);
291
+ if (host)
292
+ env.CLAUDE_CODE_EXECUTABLE = host.path;
293
+ }
286
294
  // Per-role harness runtime home. Created before sandbox entry so the
287
295
  // bind has something to mount; harmless for un-isolated roles.
288
296
  // Only a role that declares `isolation:` gets a sandbox, and only a
@@ -0,0 +1,12 @@
1
+ /** The Claude Code version shipped inside the adapter's own SDK dependency. */
2
+ export declare function bundledClaudeVersion(adapterManifest: string): string | undefined;
3
+ /**
4
+ * The host's own Claude Code, when ACP sessions should run it instead of the copy bundled with the adapter.
5
+ * The person signs in to, updates and checks the host Claude Code, and a newer one knows models the bundled
6
+ * one does not. It is used only when it is at least as new as the bundled copy; an explicit
7
+ * CLAUDE_CODE_EXECUTABLE, in the role or the service environment, always decides instead.
8
+ */
9
+ export declare function hostClaude(adapterManifest: string | undefined, env: NodeJS.ProcessEnv, cwd?: string, version?: (path: string, env: NodeJS.ProcessEnv) => string | undefined): {
10
+ path: string;
11
+ version: string;
12
+ } | undefined;
@@ -0,0 +1,43 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname, join } from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { codexVersionAtLeast, executableOnPath } from './codex-runtime.js';
6
+ const versionOf = (text) => text.match(/\b(\d+\.\d+\.\d+)\b/)?.[1];
7
+ function installedClaudeVersion(path, env) {
8
+ const result = spawnSync(path, ['--version'], { env, encoding: 'utf8', timeout: 5_000, stdio: ['ignore', 'pipe', 'ignore'] });
9
+ return result.status === 0 ? versionOf(result.stdout ?? '') : undefined;
10
+ }
11
+ /** The Claude Code version shipped inside the adapter's own SDK dependency. */
12
+ export function bundledClaudeVersion(adapterManifest) {
13
+ try {
14
+ const sdk = dirname(createRequire(adapterManifest).resolve('@anthropic-ai/claude-agent-sdk'));
15
+ const version = JSON.parse(readFileSync(join(sdk, 'package.json'), 'utf8')).claudeCodeVersion;
16
+ return typeof version === 'string' ? version : undefined;
17
+ }
18
+ catch {
19
+ return undefined;
20
+ }
21
+ }
22
+ /**
23
+ * The host's own Claude Code, when ACP sessions should run it instead of the copy bundled with the adapter.
24
+ * The person signs in to, updates and checks the host Claude Code, and a newer one knows models the bundled
25
+ * one does not. It is used only when it is at least as new as the bundled copy; an explicit
26
+ * CLAUDE_CODE_EXECUTABLE, in the role or the service environment, always decides instead.
27
+ */
28
+ export function hostClaude(adapterManifest, env, cwd = process.cwd(), version = installedClaudeVersion) {
29
+ if (env.CLAUDE_CODE_EXECUTABLE !== undefined || !adapterManifest)
30
+ return undefined;
31
+ const bundled = bundledClaudeVersion(adapterManifest);
32
+ if (!bundled)
33
+ return undefined;
34
+ let path;
35
+ try {
36
+ path = executableOnPath('claude', env, cwd);
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ const found = version(path, env);
42
+ return found && codexVersionAtLeast(found, bundled) ? { path, version: found } : undefined;
43
+ }
@@ -53,6 +53,8 @@ export declare function validateCatalog(value: BrainCatalog, path?: string): Bra
53
53
  export declare function readInitSettings(path: string): InitAnswers;
54
54
  export declare function askInitQuestions(prompter: InitPrompter, configuration: string): Promise<InitAnswers | undefined>;
55
55
  export declare function formatSetupSummary(answers: InitAnswers, configuration: string): string;
56
+ /** The packaged Brain preset for a catalog model at one effort; the same naming the presets are generated with. */
57
+ export declare function brainPresetId(model: Pick<CatalogModel, 'harness' | 'model'>, effort: string): string;
56
58
  /** Build the exact, deterministic default experience without writing to disk. */
57
59
  export declare function generateSetup(answers: InitAnswers): GeneratedSetup;
58
60
  /** Run the mutation boundary only after the complete questionnaire is accepted. */
@@ -173,6 +173,7 @@ export function formatSetupSummary(answers, configuration) {
173
173
  ' Team: LocalCoordinator + Developer + Critic',
174
174
  ' Gated engineering: Engineer (gate pipeline) + independent Critic',
175
175
  ' FleetCoordinator: coordination model',
176
+ ' Brains: the packaged presets; each prepared agent references the preset of its chosen model',
176
177
  '',
177
178
  'After the final Yes, Fleet performs host service setup before publishing the complete configuration.',
178
179
  'A hard termination after that Yes can leave host integration or private stage/recovery evidence to inspect.',
@@ -181,6 +182,11 @@ export function formatSetupSummary(answers, configuration) {
181
182
  function preset(relative) {
182
183
  return readFileSync(join(packagedPresetRoot(), 'fleet', relative), 'utf8');
183
184
  }
185
+ /** The packaged Brain preset for a catalog model at one effort; the same naming the presets are generated with. */
186
+ export function brainPresetId(model, effort) {
187
+ return `${model.harness === 'claude-code' ? 'claude' : 'codex'}-${model.model
188
+ .replace(/^claude-/, '').replace(/\./g, '-').toLowerCase()}-${effort}`;
189
+ }
184
190
  /** Build the exact, deterministic default experience without writing to disk. */
185
191
  export function generateSetup(answers) {
186
192
  const effort = REASONING_EFFORT[answers.reasoning];
@@ -201,6 +207,7 @@ export function generateSetup(answers) {
201
207
  ' permissions: { approval: ask, filesystem: workspace, unattended: deny }',
202
208
  '',
203
209
  ].join('\n'));
210
+ const assigned = {};
204
211
  for (const work of WORK_KINDS) {
205
212
  const proposed = answers.models[work];
206
213
  if (!proposed || !answers.subscriptions.includes(subscriptionFor(proposed)))
@@ -214,14 +221,14 @@ export function generateSetup(answers) {
214
221
  throw new Error(`${work} contains tampered model capabilities`);
215
222
  if (!model.efforts.includes(effort))
216
223
  throw new Error(`${MODEL_LABELS[model.model]} does not support ${answers.reasoning} reasoning`);
217
- files.set(`brains/${work}.yaml`, [
218
- `harness: ${model.harness}`,
219
- `session: ${model.session}`,
220
- `model: ${model.model}`,
221
- `effort: ${effort}`,
222
- '',
223
- ].join('\n'));
224
+ // The chosen model is assigned by reference to its packaged Brain preset; setup defines no Brain of its own.
225
+ const id = brainPresetId(model, effort);
226
+ if (!existsSync(join(packagedPresetRoot(), 'fleet', 'brains', `${id}.yaml`)))
227
+ throw new Error(`no packaged Brain preset for ${MODEL_LABELS[model.model]} at ${effort} reasoning`);
228
+ assigned[work] = id;
224
229
  }
230
+ for (const name of readdirSync(join(packagedPresetRoot(), 'fleet', 'brains')).filter(name => name.endsWith('.yaml')).sort())
231
+ files.set(`brains/${name}`, preset(`brains/${name}`));
225
232
  if (answers.assignmentStrategy === 'one-model') {
226
233
  const tuples = new Set(WORK_KINDS.map(work => {
227
234
  const model = answers.models[work];
@@ -236,7 +243,7 @@ export function generateSetup(answers) {
236
243
  continue;
237
244
  files.set(`agent_templates/${role}.yaml`, [
238
245
  `role: { ref: ${role} }`,
239
- `brain: { ref: ${ROLE_WORK[role]} }`,
246
+ `brain: { ref: ${assigned[ROLE_WORK[role]]} }`,
240
247
  'coordinator: FleetCoordinator',
241
248
  // Temporary default while the product is being tested: prepared agents work without asking.
242
249
  'permissions: { approval: allow, filesystem: unrestricted, unattended: deny }',
@@ -245,7 +252,7 @@ export function generateSetup(answers) {
245
252
  ].join('\n'));
246
253
  }
247
254
  files.set('agents/FleetCoordinator.yaml', preset('agents/FleetCoordinator.yaml')
248
- .replace('brain: { ref: claude-default }', 'brain: { ref: coordination }'));
255
+ .replace('brain: { ref: claude-default }', `brain: { ref: ${assigned.coordination} }`));
249
256
  for (const name of ['single', 'pair', 'team', 'engineering']) {
250
257
  files.set(`room_templates/${name}.yaml`, preset(`room_templates/${name}.yaml`));
251
258
  files.set(`room_layouts/${name}.yaml`, preset(`room_layouts/${name}.yaml`));
@@ -67,6 +67,14 @@ const V5_GENERATED_ROLE_DEFAULT_FINGERPRINTS = Object.freeze({
67
67
  'agent_templates/Developer.yaml': 'fdd953aab72d042e62780dd273598745aae59521b39edf4876d7676bb7189c83',
68
68
  'agent_templates/Critic.yaml': '3e98a28f17876061cfadf984351aa59fdbe19629e514eff52c74546545c64a91',
69
69
  });
70
+ // The packaged Coordinator role of revisions 8 and 9 addressed a named owner channel that a
71
+ // fresh installation does not have. Untouched copies adopt the current role; edited ones stay.
72
+ const OWNER_CHANNEL_COORDINATOR_FINGERPRINTS = Object.freeze({
73
+ 'roles/Coordinator.yaml': [
74
+ '8c8ad28d9bb9ddf9dba8c5c9c7859f25edd8fa10ebf833e2340124a6e49583ec',
75
+ 'f0d8b169ad0a18051b57e5280b93cf01e05faa5c12f4e12bdf89973edf2ef58a',
76
+ ],
77
+ });
70
78
  const CURRENT_ROLE_DEFAULTS = new Set([
71
79
  'roles/Coordinator.yaml', 'roles/LocalCoordinator.yaml', 'roles/Developer.yaml', 'roles/Critic.yaml',
72
80
  'roles/Engineer.yaml',
@@ -196,6 +204,7 @@ export function migratePackagedRoleDefaults(configuration, options = {}, hooks =
196
204
  ...Object.keys(V3_ROLE_DEFAULT_FINGERPRINTS),
197
205
  ...Object.keys(V4_ROLE_DEFAULT_FINGERPRINTS),
198
206
  ...Object.keys(V5_ROLE_DEFAULT_FINGERPRINTS),
207
+ ...Object.keys(OWNER_CHANNEL_COORDINATOR_FINGERPRINTS),
199
208
  ]);
200
209
  for (const relative of known) {
201
210
  const path = join(root, relative);
@@ -209,6 +218,7 @@ export function migratePackagedRoleDefaults(configuration, options = {}, hooks =
209
218
  V4_GENERATED_ROLE_DEFAULT_FINGERPRINTS[relative],
210
219
  V5_ROLE_DEFAULT_FINGERPRINTS[relative],
211
220
  V5_GENERATED_ROLE_DEFAULT_FINGERPRINTS[relative],
221
+ ...(OWNER_CHANNEL_COORDINATOR_FINGERPRINTS[relative] ?? []),
212
222
  ].some(expected => expected !== undefined && actual === expected);
213
223
  if (recognized)
214
224
  (CURRENT_ROLE_DEFAULTS.has(relative) ? replacements : removals).push(path);
@@ -339,7 +339,10 @@ export declare class AcpSession implements AgentSession {
339
339
  * signal. Never infer commentary from text, message order, or unknown meta.
340
340
  */
341
341
  private codexMessagePhase;
342
- /** Normalize every ACP update losslessly into the durable ledger. */
342
+ /**
343
+ * Normalize every ACP update losslessly into the durable ledger. The ledger is the conversation the
344
+ * owner reads, so the agent's commentary between its tools is kept as written, like its final answer.
345
+ */
343
346
  private recordConversationUpdate;
344
347
  taskNoticeBinding(): TaskNoticeBinding | undefined;
345
348
  recordTaskCreated(binding: TaskNoticeBinding, notice: TaskCreatedNotice): void;
@@ -38,7 +38,6 @@ const TERMINAL_TOOL_STATUSES = new Set(['completed', 'failed']);
38
38
  /** Consumed only by Fleet's authenticated bundled-Codex app-server proxy. */
39
39
  export const CODEX_DISABLE_INHERITED_MCP_ENV = 'OURS_FLEET_CODEX_DISABLE_INHERITED_MCP';
40
40
  const SCHEDULED_LOOP_REDACTION = '[scheduled-loop content redacted]';
41
- const OWNER_COMMENTARY_REDACTION = '[assistant commentary redacted]';
42
41
  const MAX_CANONICAL_SYMLINK_DEPTH = 40;
43
42
  const scheduledTurn = (turn) => turn?.origin?.kind === 'scheduled-loop';
44
43
  /**
@@ -1711,7 +1710,7 @@ export class AcpSession {
1711
1710
  const scheduled = this.activeTurn?.origin?.kind === 'scheduled-loop';
1712
1711
  const messagePhase = update.sessionUpdate === 'agent_message_chunk'
1713
1712
  ? this.codexMessagePhase(update) : undefined;
1714
- this.recordConversationUpdate(update, scheduled, messagePhase === 'commentary');
1713
+ this.recordConversationUpdate(update, scheduled);
1715
1714
  if (update.sessionUpdate === 'config_option_update')
1716
1715
  this.captureRuntimeMetadata(update.configOptions);
1717
1716
  if (update.sessionUpdate === 'usage_update') {
@@ -1795,13 +1794,15 @@ export class AcpSession {
1795
1794
  return undefined;
1796
1795
  return phase === 'commentary' || phase === 'final_answer' ? phase : 'ambiguous';
1797
1796
  }
1798
- /** Normalize every ACP update losslessly into the durable ledger. */
1799
- recordConversationUpdate(update, scheduled, commentary = false) {
1797
+ /**
1798
+ * Normalize every ACP update losslessly into the durable ledger. The ledger is the conversation the
1799
+ * owner reads, so the agent's commentary between its tools is kept as written, like its final answer.
1800
+ */
1801
+ recordConversationUpdate(update, scheduled) {
1800
1802
  const normalized = normalizeSessionUpdate(update, scheduled ? {
1801
1803
  redactText: SCHEDULED_LOOP_REDACTION,
1802
1804
  redactToolCallId: 'scheduled-loop-tool',
1803
- }
1804
- : commentary ? { redactText: OWNER_COMMENTARY_REDACTION } : {});
1805
+ } : {});
1805
1806
  this.conversation.appendSafe({
1806
1807
  kind: normalized.kind,
1807
1808
  sessionGeneration: this.sessionGeneration,
@@ -14,7 +14,6 @@ const INTERRUPT_TERMINATE_GRACE_MS = 5_000;
14
14
  const TERMINAL_RECONCILE_QUIET_MS = 2_000;
15
15
  const TERMINAL_RECONCILE_REQUEST_TIMEOUT_MS = 5_000;
16
16
  const SCHEDULED_REDACTION = '[scheduled-loop content redacted]';
17
- const COMMENTARY_REDACTION = '[assistant commentary redacted]';
18
17
  const OWNER_ADMIN_CONTEXT_KEY = 'ours-fleet://prompt-provenance?source=owner_admin_console';
19
18
  const OWNER_CHANNEL_CONTEXT_KEY = 'ours-fleet://prompt-provenance?source=owner_channel';
20
19
  const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -719,7 +718,7 @@ export class CodexAppServerSession {
719
718
  promptId: this.activeTurn?.promptId, turnId: this.activeTurn?.promptId,
720
719
  messageId: id, source: 'agent',
721
720
  payload: {
722
- role: 'assistant', content: textBlock(delta, scheduled ? SCHEDULED_REDACTION : phase === 'commentary' ? COMMENTARY_REDACTION : undefined),
721
+ role: 'assistant', content: textBlock(delta, scheduled ? SCHEDULED_REDACTION : undefined),
723
722
  },
724
723
  });
725
724
  }
@@ -202,9 +202,13 @@ export async function buildWebServer(services, boundary, options = {}) {
202
202
  throw new FleetError('backend_failure', `Fleet setup did not complete: ${error.message}`);
203
203
  }
204
204
  // Setup preserves files it finds, so confirm the published models are the ones this request chose.
205
- const brains = configuration.read(true).model.brains ?? {};
205
+ // The prepared agents reference packaged Brain presets, so resolve what each of them now points at:
206
+ // a preserved file of a packaged name may hold another model, session or reasoning than its name says.
207
+ const published = configuration.read(true).model, brains = published.brains ?? {};
208
+ const brainOf = (holder) => brains[String(holder?.brain?.ref ?? '')];
209
+ const assigned = { coordination: brainOf(published.agents.FleetCoordinator), development: brainOf(published.agent_templates?.Developer), review: brainOf(published.agent_templates?.Critic) };
206
210
  for (const work of ONBOARDING_WORK)
207
- if (brains[work]?.harness !== models[work].harness || brains[work]?.model !== models[work].model)
211
+ if (assigned[work]?.harness !== models[work].harness || assigned[work]?.model !== models[work].model || assigned[work]?.session !== models[work].session || assigned[work]?.effort !== 'medium')
208
212
  throw new FleetError('conflict', 'Fleet was set up with different models; review its configuration');
209
213
  return { configured: true };
210
214
  });
@@ -1,3 +1,4 @@
1
+ import { type AttachOursClientOptions, type OursClient } from '@ours.network/sdk/client';
1
2
  export interface WorkspacePayload {
2
3
  version: 1;
3
4
  appOrigin: string;
@@ -16,18 +17,24 @@ export interface WorkspacePayload {
16
17
  };
17
18
  }
18
19
  export declare function readWorkspacePayload(file: string): WorkspacePayload;
20
+ type DaemonClient = Pick<OursClient, 'listIdentities' | 'chooseIdentity' | 'addContact' | 'listContacts' | 'sendCommand' | 'removeContact' | 'releaseLease' | 'close'>;
21
+ /** How tunnel setup reaches this host's daemon; replaced only by tests. */
22
+ export type AttachDaemonClient = (options: AttachOursClientOptions) => Promise<DaemonClient>;
19
23
  /**
20
24
  * Check the Fleet configuration tunnel setup will use, before the one-time command is used.
21
25
  * Reads only; an existing Owner is compared with this host's own Messenger identity.
22
26
  */
23
- export declare function checkWorkspaceConfiguration(configuration?: string): Promise<void>;
27
+ export declare function checkWorkspaceConfiguration(configuration?: string, attach?: AttachDaemonClient): Promise<void>;
24
28
  export declare function enrollWorkspace(payload: WorkspacePayload, configuration?: string, options?: {
25
29
  migrateAppOrigin?: boolean;
26
30
  preserveProfile?: boolean;
31
+ attach?: AttachDaemonClient;
32
+ contactWaitMs?: number;
33
+ afterInvitation?: () => void;
27
34
  }): Promise<{
28
35
  origin: string;
29
36
  hostWorkspaceId: string;
30
- rootCid: any;
37
+ rootCid: string;
31
38
  }>;
32
39
  /** Only the existing signed-root receipt authorizes the scoped tunnel target. */
33
40
  /**
@@ -35,7 +42,7 @@ export declare function enrollWorkspace(payload: WorkspacePayload, configuration
35
42
  * that binding, remove exactly that contact from this host's Messenger, so it does not stay in the
36
43
  * person's chats. The server identity is the one recorded with the binding; nothing else is touched.
37
44
  */
38
- export declare function removeEnrollmentContact(): Promise<boolean>;
45
+ export declare function removeEnrollmentContact(attach?: AttachDaemonClient): Promise<boolean>;
39
46
  export declare function configureWorkspacePort(payload: Pick<WorkspacePayload, 'appOrigin' | 'challenge'>, hostWorkspaceId: string, rootCid: string, port: number): Promise<void>;
40
47
  /**
41
48
  * Point the tunnel at this host's port. The account answers only for a confirmed signed binding, and
@@ -43,3 +50,4 @@ export declare function configureWorkspacePort(payload: Pick<WorkspacePayload, '
43
50
  * so the setup can be finished later. A removal that fails is reported and does not undo the setup.
44
51
  */
45
52
  export declare function confirmWorkspaceTarget(payload: Pick<WorkspacePayload, 'appOrigin' | 'challenge'>, hostWorkspaceId: string, rootCid: string, port: number, cleanupFailed?: (error: unknown) => void): Promise<void>;
53
+ export {};
@@ -5,7 +5,9 @@ import { readClientProfile } from './client-profile.js';
5
5
  import { stateRoot, defaultConfigPath, workspaceOwnerInvite } from './paths.js';
6
6
  import { replaceFileAtomically } from './atomic-file.js';
7
7
  import { loadConfig, splitRootFor } from './config.js';
8
- import { parse, stringify } from 'yaml';
8
+ import { parse, parseDocument, stringify } from 'yaml';
9
+ import { randomUUID } from 'node:crypto';
10
+ import { attachOursClient } from '@ours.network/sdk/client';
9
11
  import { preflightInitPaths } from './init-wizard.js';
10
12
  import { WebAccessStore } from './web/access.js';
11
13
  import { WorkspaceDeviceStore } from './web/workspace-devices.js';
@@ -71,14 +73,105 @@ function hostMessenger() {
71
73
  const secret = readFileSync(profile.credentialPath, 'utf8').trim();
72
74
  return async (path, value) => { const response = await fetch(new URL('api/' + path, base), { method: value === undefined ? 'GET' : 'POST', headers: { 'X-Ours-Api-Token': secret, ...(value === undefined ? {} : { 'Content-Type': 'application/json', 'Origin': base.origin, 'X-Ours-Messenger-CSRF': '1' }) }, ...(value === undefined ? {} : { body: JSON.stringify(value) }), signal: AbortSignal.timeout(value === undefined ? 15000 : 60000), redirect: 'error' }); if (!response.ok) {
73
75
  await response.body?.cancel();
74
- throw Error('Workspace Messenger enrollment failed (HTTP ' + response.status + '); request a fresh setup payload before retrying');
76
+ throw Object.assign(Error('Workspace Messenger enrollment failed (HTTP ' + response.status + '); request a fresh setup payload before retrying'), { status: response.status });
75
77
  } return response.json(); };
76
78
  }
79
+ const daemon = (attach, purpose) => { const profile = readClientProfile(); return attach({ endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId, credentialPath: profile.credentialPath, sessionMode: 'external', leaseToken: `ours-fleet-workspace-${purpose}-${process.pid}-${randomUUID()}`, env: {} }); };
80
+ const CID = /^[a-f0-9]{64}$/i, same = (a, b) => a.toLowerCase() === b.toLowerCase();
81
+ /**
82
+ * Who this host's Messenger is. Messenger either runs as the Human root itself, or as the person's own
83
+ * identity and then names the root the daemon describes for it. That is accepted only when this host's
84
+ * daemon, asked by Fleet itself, lists exactly one Human root, that root is the named one, and the
85
+ * Messenger identity is listed as a permanent identity under it.
86
+ */
87
+ async function messengerIdentity(request, attach) {
88
+ const bound = await request('workspace/enrollment-identity');
89
+ if (bound.rootCid === undefined)
90
+ return { cid: bound.cid, preserveProfile: bound.preserveProfile === true };
91
+ if (typeof bound.cid !== 'string' || !CID.test(bound.cid) || typeof bound.rootCid !== 'string' || !CID.test(bound.rootCid) || same(bound.cid, bound.rootCid))
92
+ throw Error('Bound Messenger identity unavailable');
93
+ const root = await humanRoot(attach, bound.rootCid, bound.cid);
94
+ return { cid: bound.cid, preserveProfile: bound.preserveProfile === true, root };
95
+ }
96
+ /** This host's only Human root, as its daemon lists it; optionally with a permanent identity that must be listed under it. */
97
+ async function humanRoot(attach, rootCid, under) {
98
+ const client = await daemon(attach, 'identities');
99
+ const rows = await client.listIdentities().finally(() => client.close());
100
+ const roots = rows.filter(row => 'kind' in row && row.kind === 'root'), root = roots[0], row = under === undefined ? undefined : rows.find(row => 'cid' in row && same(row.cid, under));
101
+ if (roots.length !== 1 || !('kind' in root) || !same(root.cid, rootCid) || (under !== undefined && (!row || !('kind' in row) || row.kind !== 'role' || row.temp !== null)))
102
+ throw Error("This host's Messenger identity is not a permanent identity under this host's Human root. Tunnel setup changed nothing.");
103
+ return { name: root.name, cid: root.cid };
104
+ }
105
+ /** Run one operation as the Human root and always give the root back, whatever happens. */
106
+ async function asHumanRoot(attach, root, purpose, use) {
107
+ const client = await daemon(attach, purpose);
108
+ let done = false;
109
+ try {
110
+ const bound = await client.chooseIdentity({ name: root.name, force: false }).catch((error) => {
111
+ throw Error(`This host's Human root cannot be used right now (${error.code ?? error.message}). Close the session that holds it, then run setup again. Nothing was changed.`);
112
+ });
113
+ if (!same(bound.cid, root.cid))
114
+ throw Error("This host's Human root changed while setup was running. Nothing was changed.");
115
+ const result = await use(client);
116
+ // The operation is not reported as done while the root may still be held. The release is attempted exactly once.
117
+ done = true;
118
+ const released = await client.releaseLease();
119
+ if (released.failed > 0)
120
+ throw Error("This host's Human root was not given back completely. Run setup again.");
121
+ return result;
122
+ }
123
+ finally {
124
+ // After a failure the root is released once as well; what failed first is what is reported.
125
+ if (!done)
126
+ await client.releaseLease().catch(() => { });
127
+ await client.close().catch(() => { });
128
+ }
129
+ }
130
+ /** The signed workspace binding, sent by the Human root itself: the same command Messenger sends when it runs as the root. */
131
+ async function proveWithHumanRoot(attach, root, payload, hostWorkspaceId, contactWaitMs) {
132
+ const serverCid = payload.serverCid.toUpperCase();
133
+ await asHumanRoot(attach, root, 'proof', async (client) => {
134
+ const peer = await client.addContact({ invite: payload.invitation });
135
+ if (peer.cid.toUpperCase() !== serverCid)
136
+ throw Error('Enrollment server identity mismatch');
137
+ const deadline = Date.now() + contactWaitMs;
138
+ while (!(await client.listContacts()).contacts.some(contact => contact.container_id.toUpperCase() === serverCid)) {
139
+ if (Date.now() >= deadline)
140
+ throw Error('Enrollment contact is not ready; obtain a fresh setup payload before retrying');
141
+ await new Promise(resolve => setTimeout(resolve, 100));
142
+ }
143
+ const outcome = await client.sendCommand({ contact: serverCid, command: 'bind-workspace', arguments: { type: 'ours.app.bind-workspace.v1', accountId: payload.challenge.accountId, workspaceId: payload.challenge.workspaceId, nonce: payload.challenge.nonce, hostWorkspaceId } });
144
+ if (!('sent' in outcome) || !outcome.sent)
145
+ throw Error('Workspace proof was not sent');
146
+ });
147
+ }
148
+ /**
149
+ * True when the Owner entry is the one tunnel setup manages and it still names the Human root of an
150
+ * installation whose Messenger now runs as the person's own identity under that root. Only then is the
151
+ * entry moved to that identity; an Owner entry anyone else wrote is never touched.
152
+ */
153
+ function managedRootOwner(configuration, rooms, root) {
154
+ if (!same(rooms.owner.expected_cid, root.cid) || rooms.owner.public_invite_file !== workspaceOwnerInvite() || rooms.defaults?.attach_owner === false)
155
+ return false;
156
+ if (!existsSync(join(stateRoot(), 'workspace', 'binding.json')))
157
+ return false;
158
+ let written;
159
+ try {
160
+ written = parseDocument(readFileSync(configuration, 'utf8')).toJS()?.rooms;
161
+ }
162
+ catch {
163
+ return false;
164
+ }
165
+ const entry = written?.owner, defaults = written?.defaults, keys = (value) => Object.keys(value).sort().join();
166
+ // Exactly the Owner entry and defaults tunnel setup writes; anything added or changed beside them is someone's own configuration.
167
+ return !!written && keys(written) === 'defaults,owner' && !!entry && keys(entry) === 'expected_cid,provider,public_invite_file,role' && entry.provider === 'messenger-server' && entry.role === 'Owner' && typeof entry.expected_cid === 'string' && same(entry.expected_cid, root.cid)
168
+ && !!defaults && keys(defaults) === 'attach_owner,close_when_task_done' && defaults.attach_owner === true && defaults.close_when_task_done === true;
169
+ }
77
170
  /**
78
171
  * Check the Fleet configuration tunnel setup will use, before the one-time command is used.
79
172
  * Reads only; an existing Owner is compared with this host's own Messenger identity.
80
173
  */
81
- export async function checkWorkspaceConfiguration(configuration = defaultConfigPath()) {
174
+ export async function checkWorkspaceConfiguration(configuration = defaultConfigPath(), attach = attachOursClient) {
82
175
  const paths = preflightInitPaths(configuration);
83
176
  if (!paths.manifestExisted) {
84
177
  if (paths.rootExisted)
@@ -92,19 +185,20 @@ export async function checkWorkspaceConfiguration(configuration = defaultConfigP
92
185
  throw Error(`${paths.configPath}: rooms.defaults.attach_owner is disabled; enable it before tunnel setup.`);
93
186
  if (!loaded.rooms.owner.public_invite_file)
94
187
  throw Error(`${paths.configPath}: rooms.owner must use public_invite_file before tunnel setup.`);
95
- let cid;
188
+ let cid, root;
96
189
  try {
97
- cid = (await hostMessenger()('workspace/enrollment-identity')).cid;
190
+ ({ cid, root } = await messengerIdentity(hostMessenger(), attach));
98
191
  }
99
192
  catch {
100
193
  cid = undefined;
101
194
  }
102
195
  if (typeof cid !== 'string' || !/^[a-f0-9]{64}$/i.test(cid))
103
196
  throw Error(`${paths.configPath} names a room Owner, and this host's Messenger identity cannot be read to compare with it. Start Messenger on this host first.`);
104
- if (loaded.rooms.owner.expected_cid.toLowerCase() !== cid.toLowerCase() && !recordsLostEnrollment(paths.configPath))
197
+ if (loaded.rooms.owner.expected_cid.toLowerCase() !== cid.toLowerCase() && !recordsLostEnrollment(paths.configPath) && !(root && managedRootOwner(paths.configPath, loaded.rooms, root)))
105
198
  throw Error(`${paths.configPath}: rooms.owner.expected_cid names a different Owner than this host's Messenger identity. Tunnel setup does not replace an existing Owner.`);
106
199
  }
107
200
  export async function enrollWorkspace(payload, configuration = defaultConfigPath(), options = {}) {
201
+ const attach = options.attach ?? attachOursClient;
108
202
  const request = hostMessenger();
109
203
  if (new WebAccessStore().read().mode === 'none')
110
204
  throw Error('Workspace enrollment requires protected web access; enable pairing or password before setup');
@@ -131,12 +225,15 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
131
225
  throw Error('Existing rooms.defaults.attach_owner is disabled; enable automatic Human Owner admission before workspace setup');
132
226
  if (loaded.rooms && !loaded.rooms.owner.public_invite_file)
133
227
  throw Error('Existing room owner configuration must be reviewed before workspace enrollment');
134
- const identity = await request('workspace/enrollment-identity');
228
+ const identity = await messengerIdentity(request, attach);
135
229
  if (!/^[a-f0-9]{64}$/i.test(identity.cid))
136
230
  throw Error('Bound Messenger identity unavailable');
231
+ // An installation that ran Messenger as the Human root and now runs it as the person's own identity keeps its managed Owner entry, moved to that identity.
232
+ const moved = Boolean(loaded.rooms) && !lost && !!identity.root && !same(loaded.rooms.owner.expected_cid, identity.cid) && managedRootOwner(resolve(configuration), loaded.rooms, identity.root)
233
+ && JSON.parse(readFileSync(previousFile, 'utf8')).serverCid?.toUpperCase() === payload.serverCid.toUpperCase();
137
234
  if (options.preserveProfile && identity.preserveProfile !== true)
138
235
  throw Error('Existing-host setup requires Messenger profile-preservation support; update the host Messenger before retrying');
139
- if (loaded.rooms && !lost && loaded.rooms.owner.expected_cid.toLowerCase() !== identity.cid.toLowerCase())
236
+ if (loaded.rooms && !lost && !moved && loaded.rooms.owner.expected_cid.toLowerCase() !== identity.cid.toLowerCase())
140
237
  throw Error('Retained room owner CID conflicts with the Human root');
141
238
  const inviteFile = loaded.rooms?.owner.public_invite_file || join(dir, 'owner.invite');
142
239
  if (existsSync(inviteFile)) {
@@ -148,14 +245,30 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
148
245
  const hostWorkspaceId = devices.workspaceId;
149
246
  devices.close();
150
247
  const { name, surname, ...proofPayload } = payload;
151
- const result = await request('workspace/enroll', options.preserveProfile ? { ...proofPayload, hostWorkspaceId, preserveProfile: true } : { ...payload, hostWorkspaceId });
152
- if (!result.submitted || !/^[a-f0-9]{64}$/i.test(result.rootCid) || typeof result.ownerInvite !== 'string')
153
- throw Error('Malformed workspace enrollment response');
154
- // Whatever the configuration said before, what is recorded from here on is this host's own root.
155
- if (result.rootCid.toLowerCase() !== identity.cid.toLowerCase())
156
- throw Error('Workspace enrollment answered for a different root than this host identity; local configuration is unchanged');
157
- if (loaded.rooms && !lost && loaded.rooms.owner.expected_cid.toLowerCase() !== result.rootCid.toLowerCase())
158
- throw Error('Retained room owner CID conflicts with the Human root');
248
+ let result, ownerCid;
249
+ if (identity.root) {
250
+ // The Human root signs the binding; the person's own Messenger identity is the room Owner and issues the Owner invitation.
251
+ await proveWithHumanRoot(attach, identity.root, payload, hostWorkspaceId, options.contactWaitMs ?? 10000);
252
+ const invite = await request('invites', { mode: 'public' });
253
+ if (typeof invite.blob !== 'string' || !invite.blob)
254
+ throw Error('Malformed workspace enrollment response');
255
+ const after = await request('workspace/enrollment-identity');
256
+ if (typeof after.cid !== 'string' || !same(after.cid, identity.cid) || typeof after.rootCid !== 'string' || !same(after.rootCid, identity.root.cid))
257
+ throw Error("This host's Messenger identity changed while setup was running; local configuration is unchanged");
258
+ result = { submitted: true, rootCid: identity.root.cid, ownerInvite: invite.blob };
259
+ ownerCid = identity.cid;
260
+ }
261
+ else {
262
+ result = await request('workspace/enroll', options.preserveProfile ? { ...proofPayload, hostWorkspaceId, preserveProfile: true } : { ...payload, hostWorkspaceId });
263
+ if (!result.submitted || !/^[a-f0-9]{64}$/i.test(result.rootCid) || typeof result.ownerInvite !== 'string')
264
+ throw Error('Malformed workspace enrollment response');
265
+ // Whatever the configuration said before, what is recorded from here on is this host's own root.
266
+ if (result.rootCid.toLowerCase() !== identity.cid.toLowerCase())
267
+ throw Error('Workspace enrollment answered for a different root than this host identity; local configuration is unchanged');
268
+ if (loaded.rooms && !lost && loaded.rooms.owner.expected_cid.toLowerCase() !== result.rootCid.toLowerCase())
269
+ throw Error('Retained room owner CID conflicts with the Human root');
270
+ ownerCid = result.rootCid;
271
+ }
159
272
  if (migrating) {
160
273
  const deadline = Date.now() + 15000;
161
274
  while (true) {
@@ -173,16 +286,24 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
173
286
  }
174
287
  }
175
288
  replaceFileAtomically(inviteFile, result.ownerInvite + '\n', 0o600);
289
+ options.afterInvitation?.();
176
290
  const split = splitRootFor(configuration);
177
291
  mkdirSync(split, { recursive: true, mode: 0o700 });
178
292
  if (!loaded.rooms)
179
- replaceFileAtomically(configuration, readFileSync(configuration, 'utf8') + '\n' + stringify(generatedRooms(result.rootCid, inviteFile)), 0o600);
180
- else if (lost && loaded.rooms.owner.expected_cid.toLowerCase() !== result.rootCid.toLowerCase())
181
- replaceFileAtomically(configuration, `api_version: ${GENERATED_VERSION}\n\n` + stringify(generatedRooms(result.rootCid, inviteFile)), 0o600);
293
+ replaceFileAtomically(configuration, readFileSync(configuration, 'utf8') + '\n' + stringify(generatedRooms(ownerCid, inviteFile)), 0o600);
294
+ else if (lost && loaded.rooms.owner.expected_cid.toLowerCase() !== ownerCid.toLowerCase())
295
+ replaceFileAtomically(configuration, `api_version: ${GENERATED_VERSION}\n\n` + stringify(generatedRooms(ownerCid, inviteFile)), 0o600);
296
+ else if (moved) {
297
+ // The invitation above and this entry are two files. A setup interrupted between them leaves the entry naming the root,
298
+ // which is this same case again: running setup once more writes both.
299
+ const document = parseDocument(readFileSync(configuration, 'utf8'));
300
+ document.setIn(['rooms', 'owner', 'expected_cid'], ownerCid);
301
+ replaceFileAtomically(configuration, document.toString(), 0o600);
302
+ }
182
303
  replaceFileAtomically(join(dir, 'connector'), payload.connectorToken + '\n', 0o600);
183
304
  const origin = `https://${payload.hostname}`;
184
305
  replaceFileAtomically(join(dir, 'tunnel.json'), JSON.stringify({ origin, tokenFile: join(dir, 'connector') }) + '\n', 0o600);
185
- replaceFileAtomically(previousFile, JSON.stringify({ workspaceId: payload.challenge.workspaceId, hostWorkspaceId, appOrigin, serverCid: payload.serverCid.toUpperCase() }) + '\n', 0o600);
306
+ replaceFileAtomically(previousFile, JSON.stringify({ workspaceId: payload.challenge.workspaceId, hostWorkspaceId, appOrigin, serverCid: payload.serverCid.toUpperCase(), ...(identity.root ? { proofRootCid: identity.root.cid } : {}) }) + '\n', 0o600);
186
307
  loadConfig(configuration);
187
308
  return { origin, hostWorkspaceId, rootCid: result.rootCid };
188
309
  }
@@ -192,18 +313,30 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
192
313
  * that binding, remove exactly that contact from this host's Messenger, so it does not stay in the
193
314
  * person's chats. The server identity is the one recorded with the binding; nothing else is touched.
194
315
  */
195
- export async function removeEnrollmentContact() {
196
- let serverCid;
316
+ export async function removeEnrollmentContact(attach = attachOursClient) {
317
+ let serverCid, proofRootCid;
197
318
  try {
198
- serverCid = JSON.parse(readFileSync(join(stateRoot(), 'workspace', 'binding.json'), 'utf8')).serverCid;
319
+ ({ serverCid, proofRootCid } = JSON.parse(readFileSync(join(stateRoot(), 'workspace', 'binding.json'), 'utf8')));
199
320
  }
200
321
  catch {
201
322
  return false;
202
323
  }
203
324
  if (typeof serverCid !== 'string' || !/^[A-F0-9]{64}$/i.test(serverCid))
204
325
  return false;
326
+ const contact = serverCid.toUpperCase();
327
+ // The contact belongs to whoever sent the binding. When that was the Human root itself, the binding records it.
328
+ if (typeof proofRootCid === 'string' && CID.test(proofRootCid)) {
329
+ try {
330
+ const root = await humanRoot(attach, proofRootCid);
331
+ await asHumanRoot(attach, root, 'cleanup', client => client.removeContact({ contact }));
332
+ }
333
+ catch {
334
+ throw Error('the Human root did not remove it');
335
+ }
336
+ return true;
337
+ }
205
338
  try {
206
- await hostMessenger()('contacts/remove', { contact: serverCid.toUpperCase() });
339
+ await hostMessenger()('contacts/remove', { contact });
207
340
  }
208
341
  catch {
209
342
  throw Error('Messenger did not remove it');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.2.0-nightly.36",
3
+ "version": "1.2.0-nightly.38",
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",
@@ -2,16 +2,12 @@ mission: Keep fleet development moving by managing the task backlog, rooms, and
2
2
  persona: |
3
3
  You are the fleet Coordinator. Own progress and workflow, not execution quality or results.
4
4
 
5
- Each time your session starts, before anything else, use ours `send_message` to tell
6
- `Coordinator Owner Channel` in two or three plain sentences that you are the Fleet Coordinator,
7
- that you have started and are ready, and that the owner can ask you anything about the fleet:
8
- how the app works, and how to set up new agents, tasks and rooms. Send this introduction once
9
- per start; it is not a permission request and needs no reply.
10
-
11
- Immediately before each meaningful action, proactively use ours `send_message` to contact
12
- `Coordinator Owner Channel` with a short, plain one-sentence statement of what you are about to do.
13
- This is an informational intent notice, never a permission request; proceed without waiting unless
14
- a separate safety or authority rule genuinely requires approval.
5
+ Each time your session starts, before anything else, introduce yourself in your first reply in
6
+ this conversation, in two or three plain sentences: you are the Fleet Coordinator, you have
7
+ started and are ready, and the owner can ask you anything about the fleet: how the app works, and
8
+ how to set up new agents, tasks and rooms. Do this once per start; it is not a permission request
9
+ and needs no reply. The owner reads this conversation: do not send the introduction, or any other
10
+ notice, to a separate contact or channel, and never report a missing contact or channel as a blocker.
15
11
 
16
12
  Out of scope: do not personally implement, inspect, analyze, research, review, test, validate,
17
13
  or otherwise execute task work. Delegate all execution to temporary agents in managed rooms.
@@ -43,13 +39,12 @@ persona: |
43
39
  For every new task:
44
40
  1. Record it first with `ours-fleet task create --title "<title>" --brief "<brief>" --backlog --no-room`.
45
41
  2. Ask the owner which room template to use: `single`, `pair`, or `team`.
46
- 3. Resolve the exact launch plan without creating the room. Immediately before room creation, send
47
- one proactive message to `Coordinator Owner Channel` stating that you are about to create the
48
- room and listing the task, selected template, every member role and count, and each member's
49
- Agent Template, Brain, harness, session, model, reasoning effort, permissions, and working
50
- directory. Wait for `send_message` to confirm delivery. This detailed message replaces the
51
- generic meaningful-action notice for room creation. If delivery fails, do not create the room.
52
- 4. Only after confirmed delivery, start it with
42
+ 3. Resolve the exact launch plan without creating the room. Immediately before room creation,
43
+ state in this conversation that you are about to create the room, listing the task, selected
44
+ template, every member role and count, and each member's Agent Template,
45
+ Brain, harness, session, model, reasoning effort, permissions, and working directory.
46
+ This is a notice, not a permission request; do not wait for a reply.
47
+ 4. Then start it with
53
48
  `ours-fleet task start <task-id> --template <template>`.
54
49
 
55
50
  Keep the backlog current with `ours-fleet task list --state all` and use `task show`, `task block`,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "preset_revision": 9,
3
+ "preset_revision": 10,
4
4
  "brain_catalog_revision": 4,
5
5
  "description": "ours-fleet standard configuration presets"
6
6
  }