@ours.network/fleet 0.15.3 → 0.15.5

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 (64) hide show
  1. package/README.md +54 -19
  2. package/dist/application/fleet-query-service.js +3 -0
  3. package/dist/application/model-catalog.d.ts +20 -0
  4. package/dist/application/model-catalog.js +57 -0
  5. package/dist/application/role-creation-service.d.ts +7 -0
  6. package/dist/application/role-creation-service.js +21 -4
  7. package/dist/application/role-removal-service.d.ts +32 -0
  8. package/dist/application/role-removal-service.js +87 -0
  9. package/dist/application/role-repository.js +13 -1
  10. package/dist/application/session-control.d.ts +74 -0
  11. package/dist/application/session-control.js +66 -1
  12. package/dist/application/types.d.ts +18 -0
  13. package/dist/briefing.js +10 -1
  14. package/dist/cli.js +1 -1
  15. package/dist/config.d.ts +4 -1
  16. package/dist/config.js +3 -2
  17. package/dist/docs.d.ts +1 -1
  18. package/dist/docs.js +14 -5
  19. package/dist/fleet-proxy.js +3 -1
  20. package/dist/harness/claude-code.js +20 -3
  21. package/dist/harness/codex.js +14 -2
  22. package/dist/harness/types.d.ts +6 -1
  23. package/dist/index.d.ts +2 -1
  24. package/dist/index.js +1 -0
  25. package/dist/owner-channel/attachments.d.ts +11 -1
  26. package/dist/owner-channel/attachments.js +24 -3
  27. package/dist/owner-channel/channel.d.ts +2 -0
  28. package/dist/owner-channel/channel.js +134 -12
  29. package/dist/owner-channel/state.d.ts +1 -0
  30. package/dist/owner-channel/state.js +6 -3
  31. package/dist/permissions.d.ts +5 -0
  32. package/dist/permissions.js +7 -0
  33. package/dist/runner.js +2 -0
  34. package/dist/session/acp.d.ts +66 -1
  35. package/dist/session/acp.js +427 -22
  36. package/dist/session/arbiter.d.ts +10 -1
  37. package/dist/session/arbiter.js +24 -0
  38. package/dist/session/control.d.ts +28 -2
  39. package/dist/session/control.js +145 -5
  40. package/dist/session/conversation-normalizer.d.ts +34 -0
  41. package/dist/session/conversation-normalizer.js +356 -0
  42. package/dist/session/conversation-store.d.ts +88 -0
  43. package/dist/session/conversation-store.js +347 -0
  44. package/dist/session/conversation-types.d.ts +274 -0
  45. package/dist/session/conversation-types.js +1 -0
  46. package/dist/session/events.js +6 -1
  47. package/dist/session/types.d.ts +49 -0
  48. package/dist/spawn.d.ts +1 -0
  49. package/dist/spawn.js +9 -4
  50. package/dist/web/auth.d.ts +1 -1
  51. package/dist/web/fleet-config-service.d.ts +47 -0
  52. package/dist/web/fleet-config-service.js +204 -0
  53. package/dist/web/runtime.js +14 -1
  54. package/dist/web/server.d.ts +6 -0
  55. package/dist/web/server.js +181 -9
  56. package/dist/web/topology.d.ts +31 -0
  57. package/dist/web/topology.js +61 -0
  58. package/dist/web-app/assets/{TerminalView-DMoT8udI.js → TerminalView-hZpyUFY_.js} +1 -1
  59. package/dist/web-app/assets/index-COg4Azq1.css +1 -0
  60. package/dist/web-app/assets/index-Cde9auW0.js +10 -0
  61. package/dist/web-app/index.html +2 -2
  62. package/package.json +1 -1
  63. package/dist/web-app/assets/index-B-jtLAkp.css +0 -1
  64. package/dist/web-app/assets/index-B6T8JLSd.js +0 -9
package/README.md CHANGED
@@ -300,7 +300,7 @@ defaults:
300
300
  harness: claude-code # for roles that don't set one
301
301
  session: tmux # tmux (default) | acp
302
302
  permissions: # common intent, translated by each harness/backend
303
- approval: ask # ask | allow | deny
303
+ approval: ask # ask | auto | allow (`deny` is a deprecated alias)
304
304
  filesystem: workspace # read-only | workspace | unrestricted
305
305
  unattended: deny # deny | wait
306
306
  model: claude-fable-5 # default model for roles that don't set one (per-role model / --model wins)
@@ -328,7 +328,7 @@ roles:
328
328
  owner_channel: # optional trusted owner ingress; requires session: acp
329
329
  identity: "Name Owner Channel" # existing, dedicated ours identity bound only by fleet
330
330
  owners: [owner-contact-cid] # authenticated ours contact IDs, never display names
331
- agent: managed-agent-cid # exact role identity CID allowed to relay messages outward
331
+ agent: managed-agent-cid # exact role CID allowed to relay messages/files outward
332
332
  interrupt: false # false queues; true cancels current work first
333
333
  progress_interval_ms: 30000 # fleet-generated progress notices; 0 disables
334
334
  attachments: # secure inbound documents, images, and voice
@@ -467,15 +467,22 @@ native settings actually grant, against a fixed floor:
467
467
  those requests with nobody to see it. With `unattended: wait` it **warns**,
468
468
  since a human can still attach a console and answer.
469
469
 
470
- **Security meaning.** `approval: allow` maps to Claude's `bypassPermissions`,
471
- the mode that actually permits the actions the role was authorized to take.
470
+ **Security meaning.** `ask` maps to Codex `untrusted` / Claude `default`,
471
+ `auto` to Codex `on-request` / Claude `acceptEdits`, and `allow` to Codex
472
+ `never` / Claude `bypassPermissions`, the non-interactive modes that actually
473
+ permit the actions the role was authorized to take.
472
474
  `dontAsk` suppresses only the *prompt*, not the denial, which is why an
473
- `allow` role previously ran unable to do its job. Nothing but an explicit
474
- `allow` is elevated: `ask` keeps Claude's default mode and `deny` maps to
475
- `plan`. `allow` is a real grant — give it deliberately, and keep per-role
475
+ `allow` role previously ran unable to do its job. Legacy `deny` is accepted
476
+ only for compatibility and retains its conservative Codex `on-request` /
477
+ Claude `plan` translation. `allow` is a real grant — give it deliberately, and keep per-role
476
478
  [`isolation:`](#agent-isolation) as the outer boundary, which no permission
477
479
  mode can cross.
478
480
 
481
+ ACP exposes agent-specific session mode IDs and `session/set_mode`, but no
482
+ portable permission-policy capability. Fleet uses that primitive where an
483
+ adapter has a corresponding mode and otherwise translates at the adapter;
484
+ live session metadata reports both normalized and harness-native modes.
485
+
479
486
  ### Isolation at creation time
480
487
 
481
488
  ```sh
@@ -644,15 +651,16 @@ These commands require a running ACP role with `owner_channel` enabled. Missing,
644
651
  stopped, tmux, disabled, draining, and unavailable-MCP targets fail without
645
652
  starting a second client, binding an identity, or opening a network listener.
646
653
 
647
- The managed agent has one outbound rule: use its ordinary ours `send_message`
648
- tool to message the channel identity. Fleet checks that the authenticated sender
649
- CID exactly equals `agent`, then forwards the body as a new message to the owner
650
- CID that most recently sent inbound channel mail. This applies equally to progress,
651
- blockers, suggestions, and proactive notes: there is no task/request/update type.
652
- The agent never chooses an owner recipient. A single configured owner is the safe
653
- fallback; multiple owners without route history fail closed instead of guessing or
654
- broadcasting. Different devices sharing one ours identity share one CID, while
655
- separate authorized identities naturally hand off the route when either sends.
654
+ The managed agent can use its ordinary ours `send_message` or `send_file` tool to
655
+ message the channel identity. Fleet checks that the authenticated sender CID
656
+ exactly equals `agent`, then forwards the body/file to a resolved authenticated
657
+ owner route. This applies equally to blockers, suggestions, proactive notes, and
658
+ ordinary files: there is no model-selected owner recipient. An explicit reply wire
659
+ pins a file to that wire's owner; otherwise the latest authenticated owner is used.
660
+ A single configured owner is the safe fallback; multiple owners without route
661
+ history fail closed instead of guessing or broadcasting. Different devices sharing
662
+ one ours identity share one CID, while separate authorized identities naturally
663
+ hand off the unscoped route when either sends.
656
664
 
657
665
  Messages from CIDs which are neither an owner nor the configured agent are never
658
666
  injected or relayed. Fleet consumes the attempt, does not answer its sender, and
@@ -664,18 +672,32 @@ An owner request follows one ordered lifecycle on its authenticated source wire:
664
672
 
665
673
  1. Fleet sends an immediate receipt describing started, queued, or interrupting state.
666
674
  2. Periodic fleet-generated summaries may report allowlisted ACP activity shapes.
667
- 3. The agent may send any non-final message to the channel identity through its
675
+ 3. On maintained Codex ACP adapters, assistant chunks carrying the exact
676
+ `_meta.codex.phase = "commentary"` marker are automatically batched and forwarded
677
+ on this request's fixed owner CID/source wire. Unknown or absent phases are never
678
+ inferred as commentary; thoughts, tools, permissions, prompts, and raw events are
679
+ excluded by event kind. Older adapters therefore retain final-only behavior.
680
+ 4. The agent may also send any non-final or out-of-turn message through its
668
681
  ordinary ours MCP tool. CID authentication is the message gate; no task ID,
669
682
  request ID, phase, reply reference, or routing command is used.
670
- 4. The agent may send a caption and one or more files to the channel identity.
683
+ 4. The agent may send a caption and one or more files to the channel identity.
671
684
  A reply reference selects the authenticated owner of that exact source wire;
672
685
  an uncorrelated group uses the latest authenticated owner (or the sole-owner
673
686
  fallback). Fleet resolves that route once, admits every file before emitting
674
687
  any part, then sends the caption and files to the same owner on the same route.
675
- 5. Fleet independently emits exactly one final ACP response (or a sanitized
688
+ 5. Fleet independently emits exactly one final ACP response (or a sanitized
676
689
  terminal outcome). Successful owner-request turns send regular files from the
677
690
  request outbox afterward, correlated to the same source wire.
678
691
 
692
+ Commentary uses a 750 ms latency flush plus paragraph and 1,600-character/6,400-byte
693
+ boundaries, with at most 32 updates and 512 in-memory fragment dedupe keys per turn.
694
+ The terminal boundary flushes commentary before the final. Replay-marked fragments,
695
+ wrong turns, wrong typed origins, missing message IDs, and unsafe/ambiguous content
696
+ fail closed. Commentary plaintext buffers and fragment keys are memory-only; bounded
697
+ wire-and-batch digests plus sending/delivered/uncertain status are persisted before
698
+ transport. Thus reconnect replay and crash recovery never blindly resend the same
699
+ batch, while an uncertain in-flight update may be omitted rather than duplicated.
700
+
679
701
  Fleet chooses the stored latest authenticated owner for every managed-agent
680
702
  message; the model supplies only text and the channel contact. Relay audit logs
681
703
  contain hashed wire prefixes and sizes, never bodies. A durable pre-send marker
@@ -806,6 +828,19 @@ admission. The host must run an ours-mcp version whose
806
828
  these guarantees; `ours-mcp voice-status --json` reports whether transcription
807
829
  is currently configured.
808
830
 
831
+ Managed-agent file egress is a separate trust direction. It retains the same
832
+ metadata provenance checks, count/per-file/request byte caps, regular-file and
833
+ no-symlink reads, actual byte-count/SHA-256 verification, filename sanitization,
834
+ private staging, fixed owner routing, and bounded body-free recovery metadata.
835
+ It deliberately does not consult `attachments.enabled` or `allowed_mime`; MIME is
836
+ still detected during byte validation, but a declared-versus-detected mismatch is
837
+ not an egress deny gate. Markdown, HTML, octet-stream, unknown extensions, and normal
838
+ binaries are therefore routable only from the exact configured `agent` CID.
839
+ Owner-to-agent admission above remains byte-for-byte strict. Each outbound file
840
+ gets a durable pre-send marker: a failed/ambiguous send becomes `uncertain` and is
841
+ not blindly retried, while files not yet attempted remain recoverable after restart.
842
+ Logs contain counts and byte totals, never filenames or raw bytes.
843
+
809
844
  Owner channels currently require `session: acp`. Fleet needs structured,
810
845
  turn-correlated assistant output for automatic replies; scraping a tmux pane
811
846
  cannot reliably distinguish the final answer from thoughts, tool output, or
@@ -150,6 +150,9 @@ export class FleetQueryService {
150
150
  readiness: snapshot.readiness, evidence: 'authoritative',
151
151
  sessionId: snapshot.sessionId, lastError: snapshot.lastError,
152
152
  pendingPermissionId: snapshot.pendingPermissionId,
153
+ protocolVersion: snapshot.protocolVersion, features: snapshot.features,
154
+ runtimeModel: snapshot.runtimeModel, reasoningEffort: snapshot.reasoningEffort,
155
+ permissionMode: snapshot.permissionMode,
153
156
  };
154
157
  }
155
158
  catch (error) {
@@ -0,0 +1,20 @@
1
+ export type ModelCatalogSource = 'codex-runtime-catalog' | 'claude-adapter-2.1' | 'fleet-config';
2
+ export interface HarnessModelOption {
3
+ id: string;
4
+ label: string;
5
+ reasoningEfforts: string[];
6
+ defaultReasoningEffort?: string;
7
+ source: ModelCatalogSource;
8
+ }
9
+ export interface HarnessModelCatalog {
10
+ models: HarnessModelOption[];
11
+ warnings: string[];
12
+ }
13
+ /** Read Codex's locally materialized `model/list` result; never invent fallback aliases. */
14
+ export declare function codexModelCatalog(path?: string): HarnessModelCatalog;
15
+ /**
16
+ * Claude Code 2.1 has no typed model-list endpoint. These exact IDs are the
17
+ * versioned adapter contract shipped by the installed 2.1 CLI, not aliases and
18
+ * not a claim about account entitlement (which Claude validates at launch).
19
+ */
20
+ export declare function claudeModelCatalog(): HarnessModelCatalog;
@@ -0,0 +1,57 @@
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ const validId = (value) => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._[\]-]{0,127}$/.test(value);
5
+ /** Read Codex's locally materialized `model/list` result; never invent fallback aliases. */
6
+ export function codexModelCatalog(path = join(homedir(), '.codex', 'models_cache.json')) {
7
+ try {
8
+ if (statSync(path).size > 2_000_000)
9
+ throw new Error('catalog exceeds the 2 MB safety limit');
10
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
11
+ const models = (parsed.models ?? []).flatMap(raw => {
12
+ if (!raw || typeof raw !== 'object')
13
+ return [];
14
+ const model = raw;
15
+ if (model.visibility !== 'list' || !validId(model.slug))
16
+ return [];
17
+ const levels = Array.isArray(model.supported_reasoning_levels)
18
+ ? model.supported_reasoning_levels.flatMap(level => {
19
+ const effort = level?.effort;
20
+ return validId(effort) ? [effort] : [];
21
+ }) : [];
22
+ return [{
23
+ id: model.slug,
24
+ label: typeof model.display_name === 'string' ? model.display_name : model.slug,
25
+ reasoningEfforts: [...new Set(levels)],
26
+ ...(validId(model.default_reasoning_level)
27
+ ? { defaultReasoningEffort: model.default_reasoning_level } : {}),
28
+ source: 'codex-runtime-catalog',
29
+ }];
30
+ });
31
+ if (!models.length)
32
+ throw new Error('catalog has no picker-visible models');
33
+ return { models, warnings: [] };
34
+ }
35
+ catch (error) {
36
+ return {
37
+ models: [],
38
+ warnings: [`Codex runtime model catalog unavailable: ${error.message}. Use Advanced to enter an exact ID.`],
39
+ };
40
+ }
41
+ }
42
+ /**
43
+ * Claude Code 2.1 has no typed model-list endpoint. These exact IDs are the
44
+ * versioned adapter contract shipped by the installed 2.1 CLI, not aliases and
45
+ * not a claim about account entitlement (which Claude validates at launch).
46
+ */
47
+ export function claudeModelCatalog() {
48
+ const reasoningEfforts = ['low', 'medium', 'high', 'xhigh', 'max'];
49
+ return {
50
+ models: [
51
+ { id: 'claude-fable-5', label: 'Claude Fable 5', reasoningEfforts, source: 'claude-adapter-2.1' },
52
+ { id: 'claude-opus-5', label: 'Claude Opus 5', reasoningEfforts, source: 'claude-adapter-2.1' },
53
+ { id: 'claude-sonnet-5', label: 'Claude Sonnet 5', reasoningEfforts, source: 'claude-adapter-2.1' },
54
+ ],
55
+ warnings: ['Claude Code exposes no typed model-list endpoint; choices are exact 2.1 adapter IDs and entitlement is validated at launch.'],
56
+ };
57
+ }
@@ -3,11 +3,13 @@ import { type IdentityProvisioner } from '../creation.js';
3
3
  import { type SupervisorLauncher } from '../spawn.js';
4
4
  import type { OpsDeps } from '../ops.js';
5
5
  import { FleetError } from './errors.js';
6
+ import { type HarnessModelCatalog, type HarnessModelOption } from './model-catalog.js';
6
7
  export interface CreateRoleSessionRequest {
7
8
  name: string;
8
9
  harness: 'codex' | 'claude-code';
9
10
  /** null means the selected harness's own default; web blank fields send null. */
10
11
  model?: string | null;
12
+ reasoningEffort?: string | null;
11
13
  session: 'acp' | 'tmux';
12
14
  cwd?: string;
13
15
  lifetime: 'permanent' | 'temporary';
@@ -40,6 +42,9 @@ export interface CreationCapabilities {
40
42
  sessions: Array<'acp' | 'tmux'>;
41
43
  defaultModel?: string;
42
44
  models: string[];
45
+ modelOptions: HarnessModelOption[];
46
+ catalogSource: string;
47
+ customModelAllowed: true;
43
48
  warnings: string[];
44
49
  }>;
45
50
  lifetimes: Array<'permanent' | 'temporary'>;
@@ -66,6 +71,7 @@ export interface CreationPreview {
66
71
  harness: string;
67
72
  session: 'acp' | 'tmux';
68
73
  model?: string;
74
+ reasoningEffort?: string;
69
75
  cwd?: string;
70
76
  lifetime: 'permanent' | 'temporary';
71
77
  permissions: CommonPermissions;
@@ -115,6 +121,7 @@ export interface RoleCreationServiceOptions {
115
121
  journalDir?: string;
116
122
  probeReady?: (name: string, session: 'acp' | 'tmux') => Promise<'ready' | 'attention' | 'unknown'>;
117
123
  onProgress?: (action: CreationAction) => void;
124
+ modelCatalogs?: Partial<Record<'codex' | 'claude-code', () => HarnessModelCatalog>>;
118
125
  }
119
126
  export declare class RoleCreationService {
120
127
  private readonly options;
@@ -7,6 +7,7 @@ import { replaceFileAtomically } from '../atomic-file.js';
7
7
  import { stateRoot } from '../paths.js';
8
8
  import { buildRoleConfig, spawnPermanent, spawnTemp, validateSpawnOpts, } from '../spawn.js';
9
9
  import { FleetError, normalizeError } from './errors.js';
10
+ import { claudeModelCatalog, codexModelCatalog, } from './model-catalog.js';
10
11
  const canonical = (value) => {
11
12
  if (Array.isArray(value))
12
13
  return `[${value.map(canonical).join(',')}]`;
@@ -48,13 +49,21 @@ export class RoleCreationService {
48
49
  catch (error) {
49
50
  reasons.push(`configuration is invalid: ${error.message}`);
50
51
  }
51
- const modelsFor = (harness, suggested) => {
52
+ const modelsFor = (harness, catalog) => {
52
53
  const configured = roles.filter(role => role.harness === harness)
53
54
  .flatMap(role => [role.model, ...(role.model_chain ?? [])])
54
55
  .filter((model) => Boolean(model));
55
56
  const inherited = resolveRoleModel(undefined, harness, defaults);
56
- return [...new Set([...(inherited ? [inherited] : []), ...configured, ...suggested])];
57
+ const configuredOptions = [...new Set([...(inherited ? [inherited] : []), ...configured])]
58
+ .filter(id => !catalog.models.some(model => model.id === id))
59
+ .map(id => ({ id, label: `${id} (configured)`, reasoningEfforts: [], source: 'fleet-config' }));
60
+ const modelOptions = [...configuredOptions, ...catalog.models];
61
+ return { modelOptions, models: modelOptions.map(model => model.id) };
57
62
  };
63
+ const codexCatalog = this.options.modelCatalogs?.codex?.() ?? codexModelCatalog();
64
+ const claudeCatalog = this.options.modelCatalogs?.['claude-code']?.() ?? claudeModelCatalog();
65
+ const codexModels = modelsFor('codex', codexCatalog);
66
+ const claudeModels = modelsFor('claude-code', claudeCatalog);
58
67
  return {
59
68
  available: reasons.length === 0,
60
69
  reasons,
@@ -62,12 +71,14 @@ export class RoleCreationService {
62
71
  {
63
72
  id: 'codex', available: true, sessions: ['acp', 'tmux'],
64
73
  defaultModel: resolveRoleModel(undefined, 'codex', defaults),
65
- models: modelsFor('codex', ['gpt-5.6', 'gpt-5.4']), warnings: [],
74
+ ...codexModels, catalogSource: 'codex-runtime-catalog', customModelAllowed: true,
75
+ warnings: codexCatalog.warnings,
66
76
  },
67
77
  {
68
78
  id: 'claude-code', available: true, sessions: ['acp', 'tmux'],
69
79
  defaultModel: resolveRoleModel(undefined, 'claude-code', defaults),
70
- models: modelsFor('claude-code', ['sonnet', 'opus', 'haiku']), warnings: [],
80
+ ...claudeModels, catalogSource: 'claude-adapter-2.1', customModelAllowed: true,
81
+ warnings: claudeCatalog.warnings,
71
82
  },
72
83
  ],
73
84
  lifetimes: ['permanent', 'temporary'],
@@ -100,6 +111,7 @@ export class RoleCreationService {
100
111
  harness: request.harness ?? defaults.harness ?? 'claude-code',
101
112
  session: request.session ?? defaults.session ?? 'tmux',
102
113
  model: resolveRoleModel(request.model, request.harness, defaults),
114
+ reasoningEffort: request.reasoningEffort ?? undefined,
103
115
  cwd, lifetime: request.lifetime,
104
116
  permissions: resolvePermissions(defaults.permissions, request.permissions),
105
117
  monitor: resolveMonitorConfig(defaults.monitor, request.monitor),
@@ -252,6 +264,9 @@ export class RoleCreationService {
252
264
  if (!['permanent', 'temporary'].includes(input.lifetime))
253
265
  throw new FleetError('invalid_request', 'unsupported lifetime');
254
266
  bounded(input.model ?? undefined, 'model', 128);
267
+ bounded(input.reasoningEffort ?? undefined, 'reasoning effort', 16);
268
+ if (input.reasoningEffort != null && !['low', 'medium', 'high', 'xhigh', 'max', 'ultra'].includes(input.reasoningEffort))
269
+ throw new FleetError('invalid_request', 'unsupported reasoning effort');
255
270
  bounded(input.mission, 'mission', 4_096);
256
271
  bounded(input.coordinator, 'coordinator', 128);
257
272
  bounded(input.bio, 'bio', 8_192);
@@ -266,6 +281,7 @@ export class RoleCreationService {
266
281
  }
267
282
  return {
268
283
  ...input, model: input.model === null ? null : input.model?.trim() || undefined,
284
+ reasoningEffort: input.reasoningEffort === null ? null : input.reasoningEffort?.trim() || undefined,
269
285
  mission: input.mission?.trim() || undefined, coordinator: input.coordinator?.trim() || undefined,
270
286
  bio: input.bio?.trim() || undefined, persona: input.persona?.trim() || undefined,
271
287
  monitor: input.monitor ? structuredClone(input.monitor) : undefined,
@@ -295,6 +311,7 @@ export class RoleCreationService {
295
311
  return {
296
312
  name: request.name, identity: request.name, harness: request.harness,
297
313
  model: request.model, session: request.session, cwd: request.cwd,
314
+ reasoningEffort: request.reasoningEffort,
298
315
  mission: request.mission, coordinator: request.coordinator,
299
316
  approval: request.permissions.approval, filesystem: request.permissions.filesystem,
300
317
  unattended: request.permissions.unattended, bio: request.bio, persona: request.persona,
@@ -0,0 +1,32 @@
1
+ import { type OpsDeps } from '../ops.js';
2
+ export interface RoleRemovalPreview {
3
+ role: string;
4
+ configured: boolean;
5
+ lifetime: 'permanent' | 'temporary' | 'orphan';
6
+ effects: string[];
7
+ confirmation: 'typed-role-name' | 'confirm';
8
+ coordinatorProtection: boolean;
9
+ selfProtected: boolean;
10
+ recovery: {
11
+ available: boolean;
12
+ detail: string;
13
+ };
14
+ }
15
+ export declare class RoleRemovalService {
16
+ private readonly options;
17
+ constructor(options: {
18
+ configPath?: string;
19
+ ops: OpsDeps;
20
+ currentControlRole?: string;
21
+ });
22
+ preview(requested: string): RoleRemovalPreview;
23
+ remove(input: {
24
+ role: string;
25
+ confirmation?: string;
26
+ confirmed?: boolean;
27
+ coordinatorAcknowledged?: boolean;
28
+ }): Promise<RoleRemovalPreview & {
29
+ removed: true;
30
+ recoveryPath: string;
31
+ }>;
32
+ }
@@ -0,0 +1,87 @@
1
+ import { cpSync, existsSync, mkdirSync, readdirSync, renameSync } from 'node:fs';
2
+ import { basename, dirname, join } from 'node:path';
3
+ import { loadConfig, ROLE_NAME_RE } from '../config.js';
4
+ import { agentDir, stateRoot } from '../paths.js';
5
+ import { rmRole } from '../ops.js';
6
+ import { FleetError } from './errors.js';
7
+ export class RoleRemovalService {
8
+ options;
9
+ constructor(options) {
10
+ this.options = options;
11
+ }
12
+ preview(requested) {
13
+ if (!ROLE_NAME_RE.test(requested))
14
+ throw new FleetError('invalid_request', 'invalid role name');
15
+ const cfg = loadConfig(this.options.configPath);
16
+ const names = new Set(cfg.roles.map(role => role.name));
17
+ for (const root of [dirname(agentDir('_')), dirname(agentDir('_', true))]) {
18
+ try {
19
+ for (const entry of readdirSync(root, { withFileTypes: true }))
20
+ if (entry.isDirectory())
21
+ names.add(entry.name);
22
+ }
23
+ catch { /* missing state root */ }
24
+ }
25
+ const folded = [...names].filter(name => name.toLocaleLowerCase('en-US') === requested.toLocaleLowerCase('en-US'));
26
+ if (folded.length > 1)
27
+ throw new FleetError('conflict', `case-fold collision: ${folded.sort().join(', ')}; use the exact role name`);
28
+ if (!names.has(requested)) {
29
+ if (folded.length)
30
+ throw new FleetError('conflict', `role names are case-sensitive; use '${folded[0]}' exactly`);
31
+ throw new FleetError('role_not_found', `no such configured or state-backed role '${requested}'`);
32
+ }
33
+ const role = cfg.roles.find(item => item.name === requested);
34
+ const permanentState = agentDir(requested);
35
+ const temporaryState = agentDir(requested, true);
36
+ const lifetime = existsSync(temporaryState) && !existsSync(permanentState)
37
+ ? 'temporary' : role ? 'permanent' : 'orphan';
38
+ const coordinatorProtection = cfg.roles.some(item => item.name !== requested && item.coordinator === requested);
39
+ const selfProtected = this.options.currentControlRole === requested;
40
+ const effects = [
41
+ `Stop and uninstall the exact backend registration for '${requested}'.`,
42
+ `Archive then remove ${lifetime === 'temporary' ? temporaryState : permanentState}.`,
43
+ ];
44
+ if (role?.sourceFile.includes('/fleet.d/'))
45
+ effects.push(`Remove spawned configuration ${role.sourceFile}.`);
46
+ else if (role)
47
+ effects.push(`Keep hand-written configuration ${role.sourceFile}; the role can be recreated from it.`);
48
+ else
49
+ effects.push('Clean dangling unit/state left before configuration registration completed.');
50
+ return {
51
+ role: requested, configured: Boolean(role), lifetime, effects,
52
+ confirmation: lifetime === 'temporary' ? 'confirm' : 'typed-role-name',
53
+ coordinatorProtection, selfProtected,
54
+ recovery: { available: true, detail: 'State and spawned configuration are copied to the local removal archive before deletion.' },
55
+ };
56
+ }
57
+ async remove(input) {
58
+ const preview = this.preview(input.role);
59
+ if (preview.selfProtected)
60
+ throw new FleetError('forbidden', 'the current control role cannot remove itself from its own web session');
61
+ if (preview.coordinatorProtection && !input.coordinatorAcknowledged)
62
+ throw new FleetError('prerequisite_unavailable', 'this role coordinates other configured roles; acknowledge the impact explicitly');
63
+ if (preview.confirmation === 'typed-role-name' && input.confirmation !== preview.role)
64
+ throw new FleetError('invalid_request', `type '${preview.role}' exactly to confirm removal`);
65
+ if (preview.confirmation === 'confirm' && input.confirmed !== true)
66
+ throw new FleetError('invalid_request', 'explicit confirmation is required');
67
+ const cfg = loadConfig(this.options.configPath);
68
+ const role = cfg.roles.find(item => item.name === preview.role);
69
+ const sourceState = preview.lifetime === 'temporary' ? agentDir(preview.role, true) : agentDir(preview.role);
70
+ const stamp = new Date().toISOString().replaceAll(/[:.]/g, '-');
71
+ const recoveryPath = join(stateRoot(), 'recovery', 'removed', `${stamp}-${preview.role}`);
72
+ mkdirSync(recoveryPath, { recursive: true, mode: 0o700 });
73
+ if (existsSync(sourceState))
74
+ cpSync(sourceState, join(recoveryPath, 'state'), { recursive: true });
75
+ if (role?.sourceFile.includes('/fleet.d/') && existsSync(role.sourceFile))
76
+ cpSync(role.sourceFile, join(recoveryPath, basename(role.sourceFile)));
77
+ if (role)
78
+ await rmRole(cfg, preview.role, this.options.ops);
79
+ else {
80
+ await this.options.ops.backend.uninstall(preview.role);
81
+ if (existsSync(sourceState))
82
+ renameSync(sourceState, join(recoveryPath, 'orphan-state'));
83
+ this.options.ops.log(`removed orphaned backend/state '${preview.role}'`);
84
+ }
85
+ return { ...preview, removed: true, recoveryPath };
86
+ }
87
+ }
@@ -3,6 +3,7 @@ import { join, relative } from 'node:path';
3
3
  import { parse } from 'yaml';
4
4
  import { loadConfig, resolveMonitorConfig, resolvePermissions, ROLE_NAME_RE, } from '../config.js';
5
5
  import { agentsRoot, tmpRoot } from '../paths.js';
6
+ import { readSpawnLineage } from '../web/topology.js';
6
7
  const MAX_SNAPSHOT_BYTES = 256 * 1024;
7
8
  function safeDirs(root) {
8
9
  try {
@@ -15,11 +16,18 @@ function safeDirs(root) {
15
16
  }
16
17
  }
17
18
  function view(role) {
19
+ const options = role.harness_options;
18
20
  return {
19
21
  name: role.name, harness: role.harness, session: role.session, identity: role.identity,
20
22
  mission: role.mission, coordinator: role.coordinator, model: role.model,
21
23
  cwd: role.cwd ? redactHome(role.cwd) : undefined,
22
24
  permissions: role.permissions,
25
+ nativeRuntime: {
26
+ ...(typeof options?.approval === 'string' ? { approval: options.approval } : {}),
27
+ ...(typeof options?.permission_mode === 'string' ? { permissionMode: options.permission_mode } : {}),
28
+ ...(typeof options?.sandbox === 'string' ? { sandbox: options.sandbox } : {}),
29
+ },
30
+ oversee: role.oversee,
23
31
  };
24
32
  }
25
33
  function redactHome(path) {
@@ -112,17 +120,21 @@ export class RoleRepository {
112
120
  code: 'backend_mismatch', severity: 'warning',
113
121
  detail: `configured ${intended}, detected ${detected}`,
114
122
  });
123
+ const stateRef = inTemporary ? { lifetime: 'temporary' }
124
+ : inPermanent ? { lifetime: 'permanent' } : undefined;
125
+ const tempStateDir = inTemporary ? join(temporaryRoot, name) : undefined;
115
126
  return {
116
127
  id: name,
117
128
  lifetime: config ? 'permanent' : inTemporary ? 'temporary' : 'orphan',
118
129
  configured: Boolean(config),
119
130
  config: config ? view(config) : tempRole ? view(tempRole) : undefined,
120
- stateRef: inTemporary ? { lifetime: 'temporary' } : inPermanent ? { lifetime: 'permanent' } : undefined,
131
+ stateRef,
121
132
  stateHealth,
122
133
  configuredBackend: intended,
123
134
  detectedBackend: detected,
124
135
  compatibility: { compatible: true },
125
136
  problems,
137
+ lineage: tempStateDir ? readSpawnLineage(tempStateDir) : undefined,
126
138
  };
127
139
  });
128
140
  }
@@ -1,7 +1,18 @@
1
1
  import { controlRequest } from '../session/control.js';
2
+ import type { ConversationEventV1, ConversationSnapshot, PromptReceipt } from '../session/conversation-types.js';
2
3
  import type { SessionSnapshot } from '../session/types.js';
3
4
  import { Tmux } from '../tmux.js';
4
5
  import type { OutputPage, SendReceipt, SessionDescriptor } from './types.js';
6
+ export interface ConversationPageView {
7
+ events: ConversationEventV1[];
8
+ firstAvailableCursor?: string;
9
+ nextCursor?: string;
10
+ hasMore: boolean;
11
+ snapshot: ConversationSnapshot;
12
+ }
13
+ export interface ConversationFollowHandle {
14
+ close(): void;
15
+ }
5
16
  export interface RoleSessionControl {
6
17
  describe(): Promise<SessionDescriptor>;
7
18
  snapshot(): Promise<SessionSnapshot>;
@@ -19,6 +30,40 @@ export interface RoleSessionControl {
19
30
  }): Promise<{
20
31
  accepted: true;
21
32
  }>;
33
+ conversationPage?(request: {
34
+ after?: string;
35
+ limit?: number;
36
+ }): Promise<ConversationPageView>;
37
+ submitPromptV2?(request: {
38
+ commandId: string;
39
+ text: string;
40
+ actorBrowserSession: string;
41
+ source: 'owner_admin_console';
42
+ }): Promise<PromptReceipt>;
43
+ interruptV2?(commandId: string): Promise<{
44
+ accepted: true;
45
+ commandId: string;
46
+ }>;
47
+ respondPermissionV2?(request: {
48
+ commandId: string;
49
+ permissionId: string;
50
+ optionId: string;
51
+ sessionGeneration: string;
52
+ }): Promise<{
53
+ accepted: true;
54
+ commandId: string;
55
+ }>;
56
+ /**
57
+ * Open a live follow on the role's ledger. `onPage` receives the initial
58
+ * replay page; `onEvent` every subsequent durable event; `onClose` fires
59
+ * exactly once when the underlying control stream ends.
60
+ */
61
+ followConversation?(request: {
62
+ after?: string;
63
+ onPage(page: ConversationPageView): void;
64
+ onEvent(event: ConversationEventV1): void;
65
+ onClose(reason?: string): void;
66
+ }): Promise<ConversationFollowHandle>;
22
67
  }
23
68
  export declare class AcpRoleSessionAdapter implements RoleSessionControl {
24
69
  private readonly stateDir;
@@ -34,6 +79,35 @@ export declare class AcpRoleSessionAdapter implements RoleSessionControl {
34
79
  interrupt(): Promise<{
35
80
  accepted: true;
36
81
  }>;
82
+ conversationPage(request?: {
83
+ after?: string;
84
+ limit?: number;
85
+ }): Promise<ConversationPageView>;
86
+ submitPromptV2(request: {
87
+ commandId: string;
88
+ text: string;
89
+ actorBrowserSession: string;
90
+ source: 'owner_admin_console';
91
+ }): Promise<PromptReceipt>;
92
+ interruptV2(commandId: string): Promise<{
93
+ accepted: true;
94
+ commandId: string;
95
+ }>;
96
+ respondPermissionV2(request: {
97
+ commandId: string;
98
+ permissionId: string;
99
+ optionId: string;
100
+ sessionGeneration: string;
101
+ }): Promise<{
102
+ accepted: true;
103
+ commandId: string;
104
+ }>;
105
+ followConversation(request: {
106
+ after?: string;
107
+ onPage(page: ConversationPageView): void;
108
+ onEvent(event: ConversationEventV1): void;
109
+ onClose(reason?: string): void;
110
+ }): Promise<ConversationFollowHandle>;
37
111
  respondPermission(request: {
38
112
  permissionId: string;
39
113
  optionId: string;