@ours.network/fleet 1.1.5 → 1.2.0-nightly.10

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.
Files changed (96) hide show
  1. package/README.md +163 -21
  2. package/dist/agent-ours/bridge.d.ts +3 -0
  3. package/dist/agent-ours/bridge.js +178 -0
  4. package/dist/agent-ours/controller.d.ts +14 -0
  5. package/dist/agent-ours/controller.js +60 -0
  6. package/dist/agent-ours/file-rpc.d.ts +23 -0
  7. package/dist/agent-ours/file-rpc.js +123 -0
  8. package/dist/agent-ours/harness.d.ts +14 -0
  9. package/dist/agent-ours/harness.js +64 -0
  10. package/dist/agent-ours/mcp-endpoint.d.ts +15 -0
  11. package/dist/agent-ours/mcp-endpoint.js +111 -0
  12. package/dist/agent-ours/runtime.d.ts +53 -0
  13. package/dist/agent-ours/runtime.js +247 -0
  14. package/dist/agent-ours/service.d.ts +23 -0
  15. package/dist/agent-ours/service.js +364 -0
  16. package/dist/agent-ours/state.d.ts +45 -0
  17. package/dist/agent-ours/state.js +95 -0
  18. package/dist/agent-ours/wire.d.ts +14 -0
  19. package/dist/agent-ours/wire.js +55 -0
  20. package/dist/agent-recovery-gate.js +1 -1
  21. package/dist/application/legacy-supervisor-identity.d.ts +7 -0
  22. package/dist/application/legacy-supervisor-identity.js +43 -0
  23. package/dist/application/supervisor-ours-tools.d.ts +159 -0
  24. package/dist/application/supervisor-ours-tools.js +121 -0
  25. package/dist/application/task-room-service.js +55 -19
  26. package/dist/briefing.js +10 -59
  27. package/dist/build-info.json +6 -5
  28. package/dist/capabilities.d.ts +2 -1
  29. package/dist/capabilities.js +2 -0
  30. package/dist/cli.js +75 -8
  31. package/dist/client-profile.d.ts +18 -0
  32. package/dist/client-profile.js +130 -0
  33. package/dist/config.d.ts +1 -0
  34. package/dist/creation.d.ts +1 -1
  35. package/dist/creation.js +13 -21
  36. package/dist/daemon-recovery.d.ts +2 -0
  37. package/dist/daemon-recovery.js +53 -2
  38. package/dist/docs.d.ts +3 -3
  39. package/dist/docs.js +32 -23
  40. package/dist/doctor.d.ts +1 -1
  41. package/dist/doctor.js +70 -14
  42. package/dist/fleet-command-audit.js +3 -2
  43. package/dist/harness/agent-session.d.ts +2 -0
  44. package/dist/harness/claude-code-session.js +4 -2
  45. package/dist/harness/codex-session.js +9 -2
  46. package/dist/harness/codex.d.ts +3 -2
  47. package/dist/harness/codex.js +8 -3
  48. package/dist/harness/hermes-session.js +7 -1
  49. package/dist/init-wizard.d.ts +11 -6
  50. package/dist/init-wizard.js +33 -0
  51. package/dist/monitor.d.ts +34 -3
  52. package/dist/monitor.js +174 -74
  53. package/dist/owner-channel/channel.d.ts +3 -0
  54. package/dist/owner-channel/channel.js +28 -6
  55. package/dist/owner-channel/ours-client.d.ts +12 -5
  56. package/dist/owner-channel/ours-client.js +48 -12
  57. package/dist/rooms-tasks/archived-absence.d.ts +8 -0
  58. package/dist/rooms-tasks/archived-absence.js +48 -0
  59. package/dist/rooms-tasks/cli.js +1 -1
  60. package/dist/rooms-tasks/close.d.ts +3 -0
  61. package/dist/rooms-tasks/close.js +95 -17
  62. package/dist/rooms-tasks/cowork-adapter.d.ts +4 -0
  63. package/dist/rooms-tasks/cowork-adapter.js +10 -2
  64. package/dist/rooms-tasks/cowork-http.d.ts +3 -0
  65. package/dist/rooms-tasks/cowork-http.js +70 -0
  66. package/dist/rooms-tasks/deletion.js +40 -3
  67. package/dist/rooms-tasks/member-startup.d.ts +1 -0
  68. package/dist/rooms-tasks/member-startup.js +2 -0
  69. package/dist/rooms-tasks/provision.d.ts +11 -0
  70. package/dist/rooms-tasks/provision.js +138 -26
  71. package/dist/rooms-tasks/room-state.js +13 -0
  72. package/dist/rooms-tasks/task-state.d.ts +13 -3
  73. package/dist/rooms-tasks/task-state.js +42 -6
  74. package/dist/rooms-tasks/types.d.ts +12 -0
  75. package/dist/rooms-tasks/workspace-artifacts.d.ts +6 -0
  76. package/dist/rooms-tasks/workspace-artifacts.js +93 -0
  77. package/dist/rooms-tasks/workspace.d.ts +26 -0
  78. package/dist/rooms-tasks/workspace.js +195 -0
  79. package/dist/runner.d.ts +14 -0
  80. package/dist/runner.js +540 -494
  81. package/dist/session/codex-app-server-transport.js +1 -1
  82. package/dist/session/codex-app-server.js +31 -5
  83. package/dist/spawn.js +19 -16
  84. package/dist/startup-readiness.d.ts +13 -0
  85. package/dist/startup-readiness.js +38 -0
  86. package/dist/supervisor/launchd.js +8 -1
  87. package/dist/supervisor/systemd.js +5 -0
  88. package/dist/temp-supervisor-recovery.d.ts +3 -0
  89. package/dist/temp-supervisor-recovery.js +46 -0
  90. package/dist/watchdog/briefing.js +3 -16
  91. package/dist/watchdog/run.js +19 -5
  92. package/dist/web/server.d.ts +2 -0
  93. package/dist/web/server.js +18 -1
  94. package/docs/task-workspaces.md +98 -0
  95. package/package.json +8 -4
  96. package/presets/fleet/roles/Coordinator.yaml +22 -0
@@ -55,7 +55,7 @@ export class CodexAppServerTransport {
55
55
  throw new Error('Codex app-server command is empty');
56
56
  const child = spawn(options.argv[0], options.argv.slice(1), {
57
57
  cwd: options.cwd,
58
- env: { ...process.env, ...options.env },
58
+ env: { ...(options.env?.FLEET_OURS_MANAGED === '1' ? {} : process.env), ...options.env },
59
59
  stdio: ['pipe', 'pipe', 'pipe'],
60
60
  });
61
61
  // `pid` is assigned synchronously on a successful local spawn. Checking it
@@ -879,6 +879,21 @@ export class CodexAppServerSession {
879
879
  this.transport.respond(id, { currentTimeAt: Math.floor(Date.now() / 1_000) });
880
880
  return;
881
881
  }
882
+ if (method === 'mcpServer/elicitation/request') {
883
+ const schema = params.requestedSchema;
884
+ // The permission UI can answer a confirmation, but cannot collect form
885
+ // fields or perform URL-based verification on the user's behalf.
886
+ if (params.mode !== 'form' || !isObject(schema) || schema.type !== 'object'
887
+ || !isObject(schema.properties) || Object.keys(schema.properties).length
888
+ || (schema.required !== undefined
889
+ && (!Array.isArray(schema.required) || schema.required.length))) {
890
+ this.options.log(`[${this.options.name}] codex app-server: unsupported MCP elicitation form`);
891
+ this.transport.respond(id, { action: 'decline', content: null, _meta: null });
892
+ return;
893
+ }
894
+ this.requestPermission(method, id, params);
895
+ return;
896
+ }
882
897
  if (method === 'item/tool/requestUserInput') {
883
898
  this.options.log(`[${this.options.name}] codex app-server: requestUserInput has no interactive Fleet mapping; returning no answers`);
884
899
  this.transport.respond(id, { answers: {} });
@@ -896,9 +911,11 @@ export class CodexAppServerSession {
896
911
  { optionId: 'allow_session', name: 'Allow for session', kind: 'allow_always', native: 'acceptForSession' },
897
912
  { optionId: 'deny_once', name: 'Deny', kind: 'reject_once', native: 'decline' },
898
913
  { optionId: 'cancel', name: 'Cancel', kind: 'reject_once', native: 'cancel' },
899
- ].filter(option => method === 'item/fileChange/requestApproval'
900
- || method === 'item/permissions/requestApproval'
901
- || !available.length || available.includes(option.native))
914
+ ].filter(option => method === 'mcpServer/elicitation/request'
915
+ ? option.optionId !== 'allow_session'
916
+ : method === 'item/fileChange/requestApproval'
917
+ || method === 'item/permissions/requestApproval'
918
+ || !available.length || available.includes(option.native))
902
919
  .map(({ native: _native, ...option }) => option);
903
920
  const pending = {
904
921
  requestId, method, toolCallId, options,
@@ -909,7 +926,8 @@ export class CodexAppServerSession {
909
926
  // `approval=allow` covers native operations inside the declared boundary;
910
927
  // it must never silently widen filesystem or network authority.
911
928
  if (this.options.permissions.approval === 'allow'
912
- && method !== 'item/permissions/requestApproval') {
929
+ && method !== 'item/permissions/requestApproval'
930
+ && method !== 'mcpServer/elicitation/request') {
913
931
  this.transport.respond(requestId, this.permissionResponse(pending, 'accept'));
914
932
  this.resolvePermission(permissionId, pending, 'allowed', 'automatic', 'allow_once', 'permissions.approval=allow', 'the native request is inside the configured Codex boundary');
915
933
  return;
@@ -925,7 +943,9 @@ export class CodexAppServerSession {
925
943
  this.readiness = 'awaiting_permission';
926
944
  const timeoutMs = this.options.permissionTimeoutMs ?? PERMISSION_TIMEOUT_MS;
927
945
  const expiresAt = new Date(Date.now() + timeoutMs).toISOString();
928
- const title = string(params.command) ?? string(params.reason)
946
+ const title = (method === 'mcpServer/elicitation/request'
947
+ ? `${string(params.serverName) ?? 'MCP'}: ${string(params.message) ?? 'Confirmation requested'}`
948
+ : undefined) ?? string(params.command) ?? string(params.reason)
929
949
  ?? (method === 'item/fileChange/requestApproval' ? 'Apply file changes'
930
950
  : method === 'item/permissions/requestApproval' ? 'Grant additional permissions'
931
951
  : 'Run command');
@@ -993,6 +1013,12 @@ export class CodexAppServerSession {
993
1013
  return !turnId || !this.activeTurn.nativeTurnId || turnId === this.activeTurn.nativeTurnId;
994
1014
  }
995
1015
  permissionResponse(pending, decision) {
1016
+ if (pending.method === 'mcpServer/elicitation/request')
1017
+ return {
1018
+ action: decision === 'acceptForSession' ? 'accept' : decision,
1019
+ content: decision.startsWith('accept') ? {} : null,
1020
+ _meta: null,
1021
+ };
996
1022
  if (pending.method !== 'item/permissions/requestApproval')
997
1023
  return { decision };
998
1024
  const requested = pending.requestedPermissions ?? {};
package/dist/spawn.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { storeRoomSecret, storeTemporaryLaunch, preparePermanentAssignment } from './agent-ours/service.js';
3
+ import { ensureWorkspace } from './rooms-tasks/workspace.js';
1
4
  import { spawn as spawnChild } from 'node:child_process';
2
5
  import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
6
  import { join } from 'node:path';
@@ -7,7 +10,7 @@ import { validateIsolationConfig } from './isolation/policy.js';
7
10
  import { loadConfig, findRole, splitRootFor, validateMonitorConfig, } from './config.js';
8
11
  import { applyRole, up } from './ops.js';
9
12
  import { START_STAGGER_FILE } from './runner.js';
10
- import { buildProvenance, daemonIdentityProvisioner, ensureIdentity, provenanceOf, withCreationTransaction, writeProvenance, writeRoleFile, } from './creation.js';
13
+ import { buildProvenance, provenanceOf, withCreationTransaction, writeProvenance, writeRoleFile, } from './creation.js';
11
14
  import { VERSION } from './version.js';
12
15
  import { recordGeneratedAgentSource } from './generated-agent-source.js';
13
16
  import './harness/claude-code.js';
@@ -239,22 +242,12 @@ export async function spawnPermanent(o, deps, creation = {}) {
239
242
  return withCreationTransaction({ role: o.name, identity: effectiveIdentity(o) }, async (tx) => {
240
243
  assertNameFree(o);
241
244
  const cfg = loadConfig(o.configPath);
242
- // Establish the identity BEFORE the service is enabled, and record
243
- // what was actually guaranteed so the briefing can say something true.
245
+ // Pin an existing assignment without binding; the supervisor provisions
246
+ // a missing role identity before starting its harness.
244
247
  creation.onStage?.('checking_identity');
245
- const guarantee = await ensureIdentity(effectiveIdentity(o), { bio: prepared.role.bio, persona: prepared.role.persona }, creation.identityProvisioner ?? deps.identityProvisioner ?? daemonIdentityProvisioner(), deps.log);
246
- creation.onStage?.('checking_identity', {
247
- result: guarantee.evidence, guarantee: guarantee.state,
248
- });
249
- if (guarantee.state === 'created')
250
- // We minted it; a failed creation must not leave an orphan identity
251
- // behind. Only ever removes an identity THIS transaction created.
252
- tx.record({
253
- stage: `ours identity ${effectiveIdentity(o)}`,
254
- undo: async () => {
255
- await creation.identityProvisioner?.remove?.(effectiveIdentity(o));
256
- },
257
- });
248
+ const assignment = await preparePermanentAssignment(prepared.role);
249
+ const guarantee = { state: assignment, evidence: assignment === 'verified' ? 'verified' : 'missing' };
250
+ creation.onStage?.('checking_identity', { result: guarantee.evidence, guarantee: guarantee.state });
258
251
  const agentRoot = join(splitRootFor(o.configPath ?? defaultConfigPath()), 'agents');
259
252
  mkdirSync(agentRoot, { recursive: true });
260
253
  creation.onStage?.('writing_role');
@@ -329,6 +322,12 @@ export async function spawnTemp(o, binPath, launch = independentSupervisor, crea
329
322
  if (o.isolationFile)
330
323
  readIsolationFile(o.isolationFile); // fail before reserving
331
324
  const prepared = resolvedSpawn(o); // canonical validation before mutation
325
+ if (o.roomMemberStartup?.workspace) {
326
+ const workspace = o.roomMemberStartup.workspace;
327
+ if (prepared.role.cwd !== workspace.path)
328
+ throw new Error('room member cwd must equal its recorded owned workspace');
329
+ ensureWorkspace(workspace);
330
+ }
332
331
  // Retire only supervisors whose recorded owner is definitively stopped. This
333
332
  // bounded pass keeps the active roster clean without deleting old evidence.
334
333
  await reclaimStaleTempState();
@@ -354,6 +353,10 @@ async function spawnTempInner(o, preparedRole, binPath, launch, tx, onStage) {
354
353
  temporaryLoopSource: o.loopSource ?? (temporaryLoops?.length ? 'agent-template' : 'omitted'),
355
354
  roomMemberStartup: o.roomMemberStartup,
356
355
  };
356
+ storeTemporaryLaunch(role, o.creationActionId ?? randomUUID());
357
+ storeRoomSecret(role);
358
+ if (role.roomMemberStartup)
359
+ role.roomMemberStartup = { ...role.roomMemberStartup, invite: '' };
357
360
  onStage?.('writing_role');
358
361
  const dir = applyRole(role, { temp: true, identityGuarantee: 'unverified' });
359
362
  const provenance = buildProvenance({
@@ -0,0 +1,13 @@
1
+ import { type DaemonGenerationProbe } from './daemon-recovery.js';
2
+ export declare const STARTUP_WAIT_MS = 240000;
3
+ export declare const STARTUP_RETRY_MS = 5000;
4
+ interface ReadinessDeps {
5
+ probe(env: NodeJS.ProcessEnv): Promise<DaemonGenerationProbe>;
6
+ now(): number;
7
+ sleep(ms: number): Promise<void>;
8
+ log(message: string): void;
9
+ env: NodeJS.ProcessEnv;
10
+ }
11
+ /** Read-only preflight: never binds identities, starts a harness or edits its ledger. */
12
+ export declare function waitForRoleDaemon(name: string, configPath?: string, partial?: Partial<ReadinessDeps>): Promise<void>;
13
+ export {};
@@ -0,0 +1,38 @@
1
+ import { agentDir } from './paths.js';
2
+ import { findRole, loadConfig } from './config.js';
3
+ import { resolveConfigPath } from './runner.js';
4
+ import { probeDaemonGeneration } from './daemon-recovery.js';
5
+ export const STARTUP_WAIT_MS = 240_000;
6
+ export const STARTUP_RETRY_MS = 5_000;
7
+ /** Read-only preflight: never binds identities, starts a harness or edits its ledger. */
8
+ export async function waitForRoleDaemon(name, configPath, partial = {}) {
9
+ const deps = {
10
+ probe: env => probeDaemonGeneration(((url, init) => globalThis.fetch(url, {
11
+ ...init, signal: AbortSignal.timeout(10_000),
12
+ })), env),
13
+ now: Date.now,
14
+ sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
15
+ log: message => process.stderr.write(message + '\n'),
16
+ env: process.env,
17
+ ...partial,
18
+ };
19
+ const path = resolveConfigPath(agentDir(name), configPath);
20
+ const role = findRole(loadConfig(path), name);
21
+ const env = { ...deps.env, ...role.env };
22
+ const deadline = deps.now() + STARTUP_WAIT_MS;
23
+ let lastReason = '';
24
+ while (deps.now() < deadline) {
25
+ const result = await deps.probe(env);
26
+ if (result.state === 'ready') {
27
+ deps.log(`[${name}] daemon ready; starting supervised agent`);
28
+ return;
29
+ }
30
+ // Only structured diagnostic codes, never endpoint responses or credentials.
31
+ const reason = /^[A-Z][A-Z0-9_]*$/.test(result.reason) ? result.reason : 'DAEMON_UNAVAILABLE';
32
+ if (reason !== lastReason)
33
+ deps.log(`[${name}] waiting for configured daemon: ${reason}`);
34
+ lastReason = reason;
35
+ await deps.sleep(Math.min(STARTUP_RETRY_MS, Math.max(0, deadline - deps.now())));
36
+ }
37
+ throw new Error(`Daemon not ready (${lastReason}); service manager will retry startup`);
38
+ }
@@ -14,8 +14,15 @@ const LAST_EXIT_RE = /^\s*last exit (?:code|status|reason)\s*=\s*(.+?)\s*$/mi;
14
14
  const STATE_RE = /^\s*state\s*=\s*(.+?)\s*$/m;
15
15
  const agentsDir = () => join(home(), 'Library', 'LaunchAgents');
16
16
  const plistPath = (name) => join(agentsDir(), `${labelFor(name)}.plist`);
17
+ const xml = (value) => value
18
+ .replaceAll('&', '&amp;')
19
+ .replaceAll('<', '&lt;')
20
+ .replaceAll('>', '&gt;')
21
+ .replaceAll('"', '&quot;')
22
+ .replaceAll("'", '&apos;');
17
23
  function plist(name, binPath) {
18
24
  const log = join(logsRoot(), `${name}.log`);
25
+ const config = process.env.OURS_CONFIG;
19
26
  return `<?xml version="1.0" encoding="UTF-8"?>
20
27
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
21
28
  <plist version="1.0">
@@ -23,7 +30,7 @@ function plist(name, binPath) {
23
30
  <key>Label</key><string>${labelFor(name)}</string>
24
31
  <key>ProgramArguments</key>
25
32
  <array><string>${binPath}</string><string>_run</string><string>${name}</string></array>
26
- <!-- The runner owns the child-session restart loop. launchd must only
33
+ ${config ? ` <key>EnvironmentVariables</key><dict><key>OURS_CONFIG</key><string>${xml(config)}</string></dict>\n` : ''} <!-- The runner owns the child-session restart loop. launchd must only
27
34
  recover the runner PROCESS crashing: a bare KeepAlive would resume the
28
35
  uncounted relaunch loop and restart a deliberately held-down agent. -->
29
36
  <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
@@ -100,10 +100,15 @@ export function makeSystemdBackend(exec = realExec) {
100
100
  writeFileSync(join(unitDir, UNIT_TEMPLATE), `[Unit]
101
101
  Description=ours-fleet agent %i
102
102
  After=default.target
103
+ StartLimitIntervalSec=0
103
104
 
104
105
  [Service]
105
106
  Type=simple
106
107
  ${environmentLines}
108
+ # Wait outside the runner so boot-time daemon unavailability cannot consume
109
+ # the agent crash budget. Selection comes from the same role and profile.
110
+ ExecStartPre=${unitArg(process.execPath)} ${unitArg(binPath)} _wait-daemon %i
111
+ TimeoutStartSec=270
107
112
  ExecStart=${unitArg(process.execPath)} ${unitArg(binPath)} _run %i
108
113
  # The RUNNER owns the child-session restart loop, with a counted, backed-off
109
114
  # circuit breaker. systemd must only recover the runner PROCESS crashing —
@@ -0,0 +1,3 @@
1
+ export declare const TEMP_RECYCLE_EXIT = 75;
2
+ /** Keep the service's main process alive while replacing a PID-scoped worker. */
3
+ export declare function runTempSupervisor(name: string, entrypoint: string, initial?: () => Promise<void>): Promise<void>;
@@ -0,0 +1,46 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { runTemp, SupervisorRecycleRequiredError } from './runner.js';
3
+ export const TEMP_RECYCLE_EXIT = 75;
4
+ /** Keep the service's main process alive while replacing a PID-scoped worker. */
5
+ export async function runTempSupervisor(name, entrypoint, initial = () => runTemp(name)) {
6
+ try {
7
+ await initial();
8
+ return;
9
+ }
10
+ catch (error) {
11
+ if (!(error instanceof SupervisorRecycleRequiredError))
12
+ throw error;
13
+ }
14
+ for (let attempt = 0; attempt < 3; attempt++) {
15
+ const child = spawn(process.execPath, [entrypoint, '_run-temp-worker', name], {
16
+ stdio: 'inherit',
17
+ env: process.env,
18
+ });
19
+ let stopping = false;
20
+ const term = () => {
21
+ stopping = true;
22
+ child.kill('SIGTERM');
23
+ }, interrupt = () => {
24
+ stopping = true;
25
+ child.kill('SIGINT');
26
+ };
27
+ process.on('SIGTERM', term);
28
+ process.on('SIGINT', interrupt);
29
+ let code;
30
+ try {
31
+ code = await new Promise((resolve, reject) => {
32
+ child.once('error', reject);
33
+ child.once('exit', resolve);
34
+ });
35
+ }
36
+ finally {
37
+ process.off('SIGTERM', term);
38
+ process.off('SIGINT', interrupt);
39
+ }
40
+ if (code === 0 || stopping)
41
+ return;
42
+ if (code !== TEMP_RECYCLE_EXIT)
43
+ throw Error(`Temporary supervisor replacement exited with ${code}`);
44
+ }
45
+ throw Error('Temporary supervisor recycle limit reached; identity retained for explicit recovery');
46
+ }
@@ -5,22 +5,9 @@ import { formatDuration } from '../duration.js';
5
5
  * wasn't verified when configured, fall back to createTool on first run.
6
6
  */
7
7
  function bindSection(heading, wd, v, guarantee) {
8
- const L = ['', heading];
9
- L.push(`Call the **${v.bindTool}** tool with name "${wd.identity}" force=true (search the`);
10
- L.push('deferred tool registry first if needed).');
11
- if (guarantee === 'unverified') {
12
- L.push(`- This identity was NOT verified when this watchdog was configured, so it may not`);
13
- L.push(` exist yet. If binding reports no such identity, call **${v.createTool}** name`);
14
- L.push(` "${wd.identity}" once to mint it on this first run, then you are bound. Re-binding`);
15
- L.push(' your OWN identity is always allowed.');
16
- }
17
- else {
18
- L.push(`- It was ${guarantee === 'created' ? 'created' : 'verified to exist'} when this`);
19
- L.push(' watchdog was configured, so binding should succeed. If it unexpectedly reports no');
20
- L.push(` such identity, call **${v.createTool}** name "${wd.identity}" once and report the`);
21
- L.push(' discrepancy — something removed it since.');
22
- }
23
- return L;
8
+ return ['', '## Assigned identity',
9
+ `Your supervisor has bound your identity **${wd.identity}**. The ours tools are ready.`,
10
+ 'No identity setup or skill reading is required.'];
24
11
  }
25
12
  /**
26
13
  * Render a watchdog run's fixed contract (briefing.md-equivalent for a one-shot clean-context
@@ -1,3 +1,4 @@
1
+ import { preparePermanentAssignment, releaseManagedAgent } from '../agent-ours/service.js';
1
2
  import { closeSync, existsSync, openSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs';
2
3
  import { spawn as spawnChild } from 'node:child_process';
3
4
  import { join } from 'node:path';
@@ -7,8 +8,7 @@ import { acquireRunLock, formatRunId, pruneReports, releaseRunLock, writeReport
7
8
  import { generateNotifierBriefing, generateWatchdogBriefing } from './briefing.js';
8
9
  import { computeDigest, reconcileLedger, readLedger, writeLedger } from './alerts.js';
9
10
  import { applyRole } from '../ops.js';
10
- import { daemonIdentityInventoryProvisioner, ensureIdentity, } from '../creation.js';
11
- import { runOnce, START_STAGGER_FILE } from '../runner.js';
11
+ import { runOnce, loadTempRole, START_STAGGER_FILE } from '../runner.js';
12
12
  import { agentDir, tmpRoot } from '../paths.js';
13
13
  import { loadConfig, ROLE_NAME_RE, } from '../config.js';
14
14
  import { getAdapter } from '../harness/registry.js';
@@ -53,6 +53,7 @@ async function killIfNeeded(reason, child, roleName, sleep) {
53
53
  if (reason === 'stable')
54
54
  await sleep(HARVEST_GRACE_MS);
55
55
  child.kill();
56
+ await child.exited;
56
57
  }
57
58
  }
58
59
  function defaultLaunchChild(binPath, roleName, runDir) {
@@ -139,7 +140,7 @@ export async function executeWatchdogRun(wd, deps) {
139
140
  rmSync(runDir, { recursive: true, force: true });
140
141
  let report;
141
142
  try {
142
- const guarantee = await ensureIdentity(wd.identity, {}, deps.identityProvisioner ?? daemonIdentityInventoryProvisioner(), deps.log);
143
+ const guarantee = { state: 'unverified' }; // The child supervisor provisions before its harness starts.
143
144
  const cfg = deps.cfg ?? loadConfig();
144
145
  const discovered = wd.watchExplicit
145
146
  ? []
@@ -272,7 +273,7 @@ export async function executeNotifierRun(wd, text, deps) {
272
273
  // A crashed previous run can leave the temp dir behind; start clean.
273
274
  if (existsSync(runDir))
274
275
  rmSync(runDir, { recursive: true, force: true });
275
- const guarantee = await ensureIdentity(wd.identity, {}, deps.identityProvisioner ?? daemonIdentityInventoryProvisioner(), deps.log);
276
+ const guarantee = { state: 'unverified' }; // The child supervisor provisions before its harness starts.
276
277
  const cfg = deps.cfg ?? loadConfig();
277
278
  const role = buildWatchdogRole(wd, cfg);
278
279
  const dir = applyRole(role, { temp: true, identityGuarantee: guarantee.state });
@@ -317,5 +318,18 @@ export async function executeNotifierRun(wd, text, deps) {
317
318
  }
318
319
  /** What `_run-watchdog` calls: one supervised session, no cleanup — the parent harvests. */
319
320
  export async function runWatchdogAgent(name) {
320
- await runOnce(name, { temp: true });
321
+ const role = loadTempRole(name);
322
+ await preparePermanentAssignment(role);
323
+ let stopping = false;
324
+ const stop = () => { stopping = true; };
325
+ process.on('SIGTERM', stop);
326
+ process.on('SIGINT', stop);
327
+ try {
328
+ await runOnce(name, { temp: true, identityLifetime: 'permanent' }, { shouldStop: () => stopping });
329
+ }
330
+ finally {
331
+ process.off('SIGTERM', stop);
332
+ process.off('SIGINT', stop);
333
+ await releaseManagedAgent(role);
334
+ }
321
335
  }
@@ -1,3 +1,4 @@
1
+ import { SupervisorOursTools } from '../application/supervisor-ours-tools.js';
1
2
  import { type FastifyInstance } from 'fastify';
2
3
  import type { FleetQueryService } from '../application/fleet-query-service.js';
3
4
  import type { RoleRepository } from '../application/role-repository.js';
@@ -31,6 +32,7 @@ export interface WebServices {
31
32
  topologyPromote?: TopologyPromoteService;
32
33
  removal?: RoleRemovalService;
33
34
  taskRooms?: TaskRoomApplicationService;
35
+ oursTools?: Pick<SupervisorOursTools, 'list' | 'call'>;
34
36
  }
35
37
  export interface WebServer {
36
38
  app: FastifyInstance;
@@ -1,3 +1,4 @@
1
+ import { SupervisorOursTools } from '../application/supervisor-ours-tools.js';
1
2
  import { existsSync } from 'node:fs';
2
3
  import { dirname, join } from 'node:path';
3
4
  import { fileURLToPath } from 'node:url';
@@ -51,7 +52,11 @@ export async function buildWebServer(services, boundary, options = {}) {
51
52
  }
52
53
  });
53
54
  app.setErrorHandler(async (error, request, reply) => {
54
- const fleetError = normalizeError(error, request.id);
55
+ const privateToolParseError = request.routeOptions.url === '/api/v1/roles/:id/ours/call'
56
+ && error instanceof Error && 'code' in error
57
+ && typeof error.code === 'string' && error.code.startsWith('FST_ERR_CTP_');
58
+ const fleetError = normalizeError(privateToolParseError
59
+ ? new FleetError('invalid_request', 'expected a valid JSON tool request') : error, request.id);
55
60
  await audit.record({
56
61
  requestId: request.id, action: `${request.method} ${request.routeOptions.url ?? request.url}`,
57
62
  result: 'rejected', errorCode: fleetError.code,
@@ -311,6 +316,18 @@ export async function buildWebServer(services, boundary, options = {}) {
311
316
  });
312
317
  return result;
313
318
  });
319
+ const oursTools = services.oursTools ?? new SupervisorOursTools();
320
+ app.get('/api/v1/roles/:id/ours/tools', async (request) => {
321
+ auth.authenticate(request);
322
+ return oursTools.list(request.params.id);
323
+ });
324
+ app.post('/api/v1/roles/:id/ours/call', async (request) => {
325
+ const session = auth.authenticate(request, true);
326
+ const result = await oursTools.call(request.params.id, request.body);
327
+ await audit.record({ requestId: request.id, browser: session.id,
328
+ action: 'ours.call', result: result.result.isError === true ? 'tool_error' : 'succeeded' });
329
+ return result;
330
+ });
314
331
  app.get('/api/v1/roles/:id', async (request) => {
315
332
  auth.authenticate(request);
316
333
  return services.query.detail(request.params.id);
@@ -0,0 +1,98 @@
1
+ # Task workspaces and artifact retention
2
+
3
+ New Tasks allocate a private workspace at
4
+ `$OURS_FLEET_HOME/.ours-fleet/workspaces/tasks/<task-id>` (the user's home is the
5
+ default for `OURS_FLEET_HOME`). New standalone Rooms use
6
+ `.ours-fleet/workspaces/rooms/<room-id>`. A linked Room shares its Task's workspace.
7
+ The absolute path, owner kind/ID and random ownership token are stored in the
8
+ record before directory allocation; `.fleet-workspace.json` proves ownership.
9
+ The path is visible in Task/Room JSON from both CLI and API. A folder of the same
10
+ name without the matching marker is never adopted.
11
+
12
+ Every newly managed member starts at that exact workspace, including retries.
13
+ An explicit template/member `cwd` remains in the sealed input execution plan as
14
+ source context, but the owned workspace overrides the effective launch cwd.
15
+ Copy or clone required source into the workspace; Fleet does not move or delete
16
+ the configured source directory. Briefings explain this rule. Use separate
17
+ per-agent branches/directories inside the workspace for concurrent edits.
18
+
19
+ All execution artifacts, dependency checkouts and worktrees belong inside the
20
+ workspace. Clone each repository there first, then use `git worktree add` with
21
+ destinations inside the same workspace. Git's native worktree registry is the
22
+ registry of record. Fleet audits `.git`, `commondir` and reverse `gitdir` links
23
+ before deletion; both the common repository and all registered worktrees must
24
+ be inside the workspace. An external repository's worktree inside the workspace,
25
+ or an internal repository's worktree outside it, blocks deletion. No broad
26
+ `git worktree prune`, branch deletion, reset or modification of unrelated Git
27
+ metadata is performed. Removing the owned repositories also removes their
28
+ complete internal worktree registries, including dirty and stale owned entries.
29
+
30
+ | Operation | Workspace and execution artifacts | Member runtime evidence |
31
+ | --- | --- | --- |
32
+ | Complete, fail, cancel | Retained while Task is recorded | Retained in Fleet recovery archives |
33
+ | Linked Room retirement or deletion | Task workspace retained | Retained |
34
+ | Internal standalone Room retirement | Room workspace retained | Retained |
35
+ | Explicit Task deletion | Removed after member retirement | All proven owned retired launches removed |
36
+ | Explicit standalone Room deletion | Removed after member retirement | All proven owned retired launches removed |
37
+
38
+ Internal room close means retirement, not destructive Task deletion.
39
+ The existing CLI `room close ID ID` remains a deprecated alias for explicit
40
+ `room delete ID ID`: it deletes standalone room-owned artifacts, but always
41
+ retains a linked Task workspace. Its repeated-ID confirmation is unchanged. To remove a Task's
42
+ artifacts, explicitly delete the Task. Cancellation is not deletion. Automatic
43
+ compatibility cleanup of legacy closed Rooms skips records with owned workspaces.
44
+ These choices reconcile close/remove wording with the requirement that completed,
45
+ recorded Tasks retain all artifacts.
46
+
47
+ Fleet's active member state (briefings, logs, session state and WORKLOG) remains
48
+ under `.ours-fleet/tmp` while supervisors run, then in
49
+ `.ours-fleet/recovery/temporary`. The launch stores the workspace descriptor.
50
+ Explicit deletion first proves managed agents retired, then identifies all
51
+ matching archived launches, including failed and replaced attempts, using the
52
+ workspace descriptor plus role and termination/launch evidence. It preflights
53
+ these archives and atomically transfers them to `.fleet-retired-agents` in the
54
+ workspace before removal. Unrelated archives are not adopted by a name prefix.
55
+ Fleet audit receipts, creation/termination journals and shared infrastructure
56
+ logs are audit metadata and intentionally outlive Task artifact deletion.
57
+
58
+ Provisioning serializes Task operation then Room close locks, including bounded
59
+ seat waits, so concurrent retries cannot publish competing members and deletion
60
+ cannot race a member launch. Local published Rooms are reused if the Task link
61
+ write was interrupted. Workspace allocation publishes a marked staging directory;
62
+ restart reuses the durable token. Deletion renames the validated workspace to a
63
+ token-specific sibling tombstone before removal. A crash during removal resumes
64
+ that exact tombstone; the Task record is unlinked last. Before collecting archives,
65
+ Task deletion durably checkpoints verified retirement evidence. Retries verify
66
+ any remaining original archive and still refuse replacement live member state
67
+ or a recreated identity even after the archive has been consumed. Failed deletion
68
+ remains retryable with its recorded error.
69
+
70
+ Deletion rejects traversal, foreign recorded paths, changed markers and symlinked
71
+ workspace ancestors. Ordinary artifact symlinks are unlinked without following
72
+ their targets. Git control symlinks and foreign pointers are refused. As with
73
+ Fleet's other filesystem lifecycle operations, a hostile same-UID process that
74
+ mutates paths during validation requires OS isolation; application checks are
75
+ not a security boundary against that process. Fleet enforces launch cwd and
76
+ safe deletion; unrestricted agents can still write outside cwd, so the artifact
77
+ placement rule also requires agent compliance and/or filesystem isolation.
78
+
79
+ ## Compatibility and limits
80
+
81
+ Records from before this feature without `workspace` remain unmanaged. Existing
82
+ agents retain their cwd and exact briefing/fingerprint so restart matching works.
83
+ Fleet never claims an old cwd, an outcome artifact path, or a legacy recovery
84
+ archive as owned merely because it was mentioned by a Task. Create a new managed
85
+ Task and explicitly copy desired artifacts to migrate; deleting a legacy Task
86
+ continues its previous lifecycle and does not delete arbitrary old files.
87
+
88
+ Changing `OURS_FLEET_HOME` does not silently rebase recorded absolute ownership
89
+ paths. Restore the original state root or explicitly migrate records and files
90
+ with the supervisors stopped; mismatched paths fail closed. Archive transfer
91
+ uses same-filesystem rename and reports a recoverable failure across mounts.
92
+
93
+ Cowork's remote create operation has no idempotency key in this interface. A crash
94
+ after remote creation but before local Room publication is an existing uncertain
95
+ remote result; this change deduplicates locally recorded rooms and does not claim
96
+ to solve that remote transaction window. Inspect remote room ownership before
97
+ retrying ambiguous remote creation. No services or installed Fleet code are
98
+ changed by building or testing this feature.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.1.5",
3
+ "version": "1.2.0-nightly.10",
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",
@@ -18,6 +18,7 @@
18
18
  "examples",
19
19
  "presets",
20
20
  "README.md",
21
+ "docs/task-workspaces.md",
21
22
  "LICENSE"
22
23
  ],
23
24
  "engines": {
@@ -34,14 +35,16 @@
34
35
  "test:e2e": "npm run build && playwright test",
35
36
  "dev:web": "vite",
36
37
  "prepack": "npm run build",
37
- "prepublishOnly": "npm run build && npm test"
38
+ "prepublishOnly": "npm run build && npm test",
39
+ "test:v1-recovery": "node test/fleet-v1-recovery.integration.mjs retry && node test/fleet-v1-recovery.integration.mjs terminal-after-failure && node test/fleet-v1-recovery.integration.mjs terminal-release-failure"
38
40
  },
39
41
  "dependencies": {
40
42
  "@agentclientprotocol/sdk": "^1.3.0",
41
43
  "@fastify/static": "^10.1.2",
42
44
  "@fastify/websocket": "^11.2.0",
43
- "@ours.network/cli": "1.0.1",
44
- "@ours.network/sdk": "3.7.0",
45
+ "@ours.network/cli": "2.8.1-nightly.9",
46
+ "@ours.network/mcp": "1.2.0-nightly.7",
47
+ "@ours.network/sdk": "3.8.1-nightly.11",
45
48
  "commander": "^12.1.0",
46
49
  "fastify": "^5.4.0",
47
50
  "react": "^19.1.1",
@@ -55,6 +58,7 @@
55
58
  "@agentclientprotocol/codex-acp": "1.10.0"
56
59
  },
57
60
  "devDependencies": {
61
+ "@ours.network/daemon": "3.8.1-nightly.3",
58
62
  "@playwright/test": "^1.54.1",
59
63
  "@types/node": "^20.14.0",
60
64
  "@types/react": "^19.1.9",
@@ -12,6 +12,28 @@ persona: |
12
12
  Only execute task work yourself when the owner explicitly orders you to override this boundary
13
13
  for that specific task.
14
14
 
15
+ Agent identity and contact management requested by the owner is Fleet management work.
16
+ For requests such as "generate an invite for this agent", "accept this invite for that agent",
17
+ "list its contacts", or "send this message as that agent", select the named agent and call the
18
+ corresponding deterministic supervisor operation through the Fleet CLI or REST API. The
19
+ supervisor owns and binds that agent's identity; verify that the selected agent and returned
20
+ identity match the owner's request. Use the shared supervisor tool interface for these
21
+ operations, including generate_invite, add_contact, list_contacts, and send_message.
22
+ Discover schemas with `ours-fleet ours tools <agent>`; invoke with
23
+ `ours-fleet ours call <agent> <tool> --args-file <private-json-file>` (omit the file for {}).
24
+ REST equivalents are GET `/api/v1/roles/<agent>/ours/tools` and POST
25
+ `/api/v1/roles/<agent>/ours/call` with body {"tool":"<tool>","arguments":{...}}.
26
+ Use the normal authenticated Fleet session and CSRF token for REST calls.
27
+ Do not ask the agent in its conversation to perform these operations, send the invite to its
28
+ LLM session, or use session steering as an identity-management API. Do not create a separate
29
+ identity, choose or rebind an identity, force a binding, or restart a supervisor to perform a
30
+ contact operation. If the supported supervisor operation is unavailable or identity matching
31
+ fails, report that exact blocker and retain the request without attempting a substitute path.
32
+ Keep invite inputs in the supported private-file or request-body channel, out of logs and
33
+ unrelated rooms. Report the operation's actual result: accepting an invite may leave contact
34
+ verification pending, so do not claim that contact establishment or message delivery is complete
35
+ until the corresponding result confirms it.
36
+
15
37
  For every new task:
16
38
  1. Record it first with `ours-fleet task create --title "<title>" --brief "<brief>" --backlog --no-room`.
17
39
  2. Ask the owner which room template to use: `single`, `pair`, or `team`.