@ours.network/fleet 0.15.1 → 0.15.4

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 (65) hide show
  1. package/README.md +49 -13
  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 +21 -1
  14. package/dist/cli.js +39 -7
  15. package/dist/config.d.ts +4 -1
  16. package/dist/config.js +3 -2
  17. package/dist/creation.d.ts +6 -3
  18. package/dist/creation.js +5 -1
  19. package/dist/docs.d.ts +1 -1
  20. package/dist/docs.js +47 -11
  21. package/dist/fleet-proxy.d.ts +25 -0
  22. package/dist/fleet-proxy.js +38 -0
  23. package/dist/harness/claude-code.js +20 -3
  24. package/dist/harness/codex.js +14 -2
  25. package/dist/harness/types.d.ts +6 -1
  26. package/dist/index.d.ts +2 -1
  27. package/dist/index.js +1 -0
  28. package/dist/owner-channel/channel.d.ts +13 -0
  29. package/dist/owner-channel/channel.js +191 -9
  30. package/dist/owner-channel/state.d.ts +7 -1
  31. package/dist/owner-channel/state.js +41 -4
  32. package/dist/permissions.d.ts +5 -0
  33. package/dist/permissions.js +7 -0
  34. package/dist/runner.d.ts +2 -0
  35. package/dist/runner.js +86 -2
  36. package/dist/session/acp.d.ts +61 -1
  37. package/dist/session/acp.js +398 -20
  38. package/dist/session/arbiter.d.ts +10 -1
  39. package/dist/session/arbiter.js +24 -0
  40. package/dist/session/control.d.ts +33 -2
  41. package/dist/session/control.js +158 -5
  42. package/dist/session/conversation-normalizer.d.ts +34 -0
  43. package/dist/session/conversation-normalizer.js +356 -0
  44. package/dist/session/conversation-store.d.ts +88 -0
  45. package/dist/session/conversation-store.js +347 -0
  46. package/dist/session/conversation-types.d.ts +274 -0
  47. package/dist/session/conversation-types.js +1 -0
  48. package/dist/session/types.d.ts +40 -0
  49. package/dist/spawn.d.ts +6 -1
  50. package/dist/spawn.js +23 -16
  51. package/dist/web/auth.d.ts +1 -1
  52. package/dist/web/fleet-config-service.d.ts +47 -0
  53. package/dist/web/fleet-config-service.js +204 -0
  54. package/dist/web/runtime.js +14 -1
  55. package/dist/web/server.d.ts +6 -0
  56. package/dist/web/server.js +181 -9
  57. package/dist/web/topology.d.ts +31 -0
  58. package/dist/web/topology.js +61 -0
  59. package/dist/web-app/assets/{TerminalView-DMoT8udI.js → TerminalView-hZpyUFY_.js} +1 -1
  60. package/dist/web-app/assets/index-COg4Azq1.css +1 -0
  61. package/dist/web-app/assets/index-Cde9auW0.js +10 -0
  62. package/dist/web-app/index.html +2 -2
  63. package/package.json +1 -1
  64. package/dist/web-app/assets/index-B-jtLAkp.css +0 -1
  65. package/dist/web-app/assets/index-B6T8JLSd.js +0 -9
@@ -58,12 +58,36 @@ export class RoleTurnArbiter {
58
58
  stopScheduledAdmission() { this.stopping = true; }
59
59
  isAlive() { return this.session.isAlive(); }
60
60
  snapshot() { return this.session.snapshot(); }
61
+ conversationPage(request = {}) {
62
+ if (!this.session.conversationPage)
63
+ throw new Error('conversation ledger is unavailable');
64
+ return this.session.conversationPage(request);
65
+ }
66
+ conversationSnapshot() {
67
+ if (!this.session.conversationSnapshot)
68
+ throw new Error('conversation snapshot is unavailable');
69
+ return this.session.conversationSnapshot();
70
+ }
71
+ subscribeConversation(listener) {
72
+ if (!this.session.subscribeConversation)
73
+ throw new Error('conversation subscription is unavailable');
74
+ return this.session.subscribeConversation(listener);
75
+ }
76
+ submitPromptBrowser(command) {
77
+ if (!this.session.submitPromptBrowser)
78
+ return Promise.reject(new Error('browser prompt admission is unavailable'));
79
+ return this.exclusive(() => this.session.submitPromptBrowser(command));
80
+ }
61
81
  interrupt(source = 'local-console') {
62
82
  return this.exclusive(() => this.session.interrupt(source));
63
83
  }
64
84
  respondPermission(permissionId, optionId) {
65
85
  return this.session.respondPermission(permissionId, optionId);
66
86
  }
87
+ respondPermissionV2(permissionId, optionId, sessionGeneration) {
88
+ return this.session.respondPermissionV2?.(permissionId, optionId, sessionGeneration)
89
+ ?? 'stale';
90
+ }
67
91
  eventsSince(seq) { return this.session.eventsSince(seq); }
68
92
  subscribe(listener) { return this.session.subscribe(listener); }
69
93
  setControllerAttached(attached) { this.session.setControllerAttached(attached); }
@@ -2,11 +2,13 @@ import { type Socket } from 'node:net';
2
2
  import type { ControlFailureKind, SessionHandle } from './types.js';
3
3
  import type { OwnerChannelHandle, OwnerChannelManagementRequest } from '../owner-channel/channel.js';
4
4
  import type { ScheduledLoopManagerHandle } from '../loops/manager.js';
5
+ import type { SpawnOpts } from '../spawn.js';
6
+ import type { ManagedFleetSpawnResult } from '../fleet-proxy.js';
5
7
  export interface ControlRequest {
6
- version: 1 | 2;
8
+ version: 1 | 2 | 3;
7
9
  id: string;
8
10
  token: string;
9
- command: 'status' | 'snapshot' | 'submit_prompt' | 'respond_permission' | 'interrupt' | 'follow' | 'events_since' | 'owner_channel_manage' | 'loop_status' | 'loop_run_now' | 'loop_disable' | 'loop_enable' | 'reload_config';
11
+ command: 'status' | 'snapshot' | 'submit_prompt' | 'respond_permission' | 'interrupt' | 'follow' | 'events_since' | 'owner_channel_manage' | 'loop_status' | 'loop_run_now' | 'loop_disable' | 'loop_enable' | 'reload_config' | 'conversation_page' | 'conversation_follow' | 'submit_prompt_v2' | 'interrupt_v2' | 'respond_permission_v2' | 'fleet_spawn';
10
12
  text?: string;
11
13
  permissionId?: string;
12
14
  optionId?: string;
@@ -15,6 +17,19 @@ export interface ControlRequest {
15
17
  controller?: boolean;
16
18
  ownerChannel?: OwnerChannelManagementRequest;
17
19
  loop?: string;
20
+ /** Conversation cursor: replay strictly after this durable seq. */
21
+ after?: string;
22
+ limit?: number;
23
+ /** Idempotency key for v3 mutations. */
24
+ commandId?: string;
25
+ /** Browser-session digest recorded as the acting principal. */
26
+ actor?: string;
27
+ /** Server-stamped provenance. User-controlled text cannot set this field. */
28
+ source?: 'owner_admin_console';
29
+ /** Runner generation shown with a permission card. */
30
+ sessionGeneration?: string;
31
+ /** Typed managed-spawn request accepted only over the authenticated role control plane. */
32
+ spawn?: SpawnOpts;
18
33
  }
19
34
  export interface ControlResponse {
20
35
  version: 1;
@@ -75,9 +90,12 @@ export declare class RoleControlServer {
75
90
  private readonly socketPath;
76
91
  private readonly token;
77
92
  private readonly sockets;
93
+ /** Interrupt idempotency: same command id returns the first receipt. */
94
+ private readonly interruptCommands;
78
95
  private ownerChannel?;
79
96
  private loopManager?;
80
97
  private reloadConfig?;
98
+ private fleetSpawner?;
81
99
  constructor(stateDir: string, session: SessionHandle, log: (line: string) => void);
82
100
  start(): Promise<void>;
83
101
  close(): Promise<void>;
@@ -85,8 +103,11 @@ export declare class RoleControlServer {
85
103
  setOwnerChannel(ownerChannel: OwnerChannelHandle | undefined): void;
86
104
  setLoopManager(loopManager: ScheduledLoopManagerHandle | undefined): void;
87
105
  setConfigReloader(reloadConfig: (() => Promise<unknown>) | undefined): void;
106
+ setFleetSpawner(fleetSpawner: ((options: SpawnOpts) => Promise<ManagedFleetSpawnResult>) | undefined): void;
88
107
  private accept;
89
108
  private handle;
109
+ /** Version and capability gate shared by every conversation v3 command. */
110
+ private requireConversation;
90
111
  private write;
91
112
  }
92
113
  /**
@@ -100,3 +121,13 @@ export declare function followControl(stateDir: string, onMessage: (message: Rec
100
121
  socket: Socket;
101
122
  send(request: Omit<ControlRequest, 'version' | 'id' | 'token'>): void;
102
123
  }>;
124
+ /**
125
+ * Open a live conversation follow on the role's private control socket. The
126
+ * first message is the initial page + snapshot; every subsequent message is
127
+ * `{ version, conversationEvent }`. Closing the socket detaches the
128
+ * controller-presence this connection contributed.
129
+ */
130
+ export declare function followConversation(stateDir: string, after: string | undefined, onMessage: (message: Record<string, unknown>) => void): Promise<{
131
+ socket: Socket;
132
+ close(): void;
133
+ }>;
@@ -4,6 +4,11 @@ import { createConnection, createServer } from 'node:net';
4
4
  import { join } from 'node:path';
5
5
  import { SessionControlError } from './types.js';
6
6
  const MAX_LINE_BYTES = 64 * 1024;
7
+ /** Commands that require protocol version 3. */
8
+ const V3_COMMANDS = new Set([
9
+ 'conversation_page', 'conversation_follow', 'submit_prompt_v2', 'interrupt_v2',
10
+ 'respond_permission_v2',
11
+ ]);
7
12
  /**
8
13
  * One line saying what a control failure does — and does not — prove about the
9
14
  * agent. Only `offline` is evidence that it is gone; every other kind used to
@@ -95,9 +100,12 @@ export class RoleControlServer {
95
100
  socketPath;
96
101
  token;
97
102
  sockets = new Set();
103
+ /** Interrupt idempotency: same command id returns the first receipt. */
104
+ interruptCommands = new Map();
98
105
  ownerChannel;
99
106
  loopManager;
100
107
  reloadConfig;
108
+ fleetSpawner;
101
109
  constructor(stateDir, session, log) {
102
110
  this.session = session;
103
111
  this.log = log;
@@ -140,6 +148,9 @@ export class RoleControlServer {
140
148
  setConfigReloader(reloadConfig) {
141
149
  this.reloadConfig = reloadConfig;
142
150
  }
151
+ setFleetSpawner(fleetSpawner) {
152
+ this.fleetSpawner = fleetSpawner;
153
+ }
143
154
  accept(socket) {
144
155
  this.sockets.add(socket);
145
156
  socket.setEncoding('utf8');
@@ -192,7 +203,7 @@ export class RoleControlServer {
192
203
  this.write(socket, { version: 1, id: '?', ok: false, error: 'invalid JSON' });
193
204
  return;
194
205
  }
195
- if ((request.version !== 1 && request.version !== 2) || typeof request.id !== 'string'
206
+ if (![1, 2, 3].includes(request.version) || typeof request.id !== 'string'
196
207
  || !sameToken(this.token, request.token ?? '')) {
197
208
  this.write(socket, { version: 1, id: request.id ?? '?', ok: false, error: 'unauthorized' });
198
209
  return;
@@ -204,8 +215,12 @@ export class RoleControlServer {
204
215
  this.write(socket, {
205
216
  version: 1, id: request.id, ok: true,
206
217
  result: {
207
- ...this.session.snapshot(), protocolVersion: 2,
208
- features: ['events_since', 'observer_follow', 'retained_range'],
218
+ ...this.session.snapshot(),
219
+ protocolVersion: this.session.conversationPage ? 3 : 2,
220
+ features: [
221
+ 'events_since', 'observer_follow', 'retained_range',
222
+ ...(this.session.conversationPage ? ['conversation_v3'] : []),
223
+ ],
209
224
  },
210
225
  });
211
226
  return;
@@ -271,6 +286,15 @@ export class RoleControlServer {
271
286
  });
272
287
  return;
273
288
  }
289
+ case 'fleet_spawn': {
290
+ if (request.version !== 2 || !request.spawn || typeof request.spawn.name !== 'string')
291
+ throw new SessionControlError('rejected', 'version 2 and typed spawn options are required');
292
+ if (!this.fleetSpawner)
293
+ throw new SessionControlError('rejected', 'managed fleet spawning is unavailable for this role');
294
+ const result = await this.fleetSpawner(request.spawn);
295
+ this.write(socket, { version: 1, id: request.id, ok: true, result });
296
+ return;
297
+ }
274
298
  case 'owner_channel_manage': {
275
299
  if (!request.ownerChannel || typeof request.ownerChannel.action !== 'string')
276
300
  throw new SessionControlError('rejected', 'owner-channel management action is required');
@@ -314,6 +338,90 @@ export class RoleControlServer {
314
338
  socket.write(JSON.stringify({ version: 1, event }) + '\n');
315
339
  }) };
316
340
  }
341
+ case 'conversation_page': {
342
+ this.requireConversation(request);
343
+ this.write(socket, {
344
+ version: 1, id: request.id, ok: true,
345
+ result: this.session.conversationPage({
346
+ after: request.after, limit: request.limit,
347
+ }),
348
+ });
349
+ return;
350
+ }
351
+ case 'conversation_follow': {
352
+ this.requireConversation(request);
353
+ this.write(socket, {
354
+ version: 1, id: request.id, ok: true,
355
+ result: this.session.conversationPage({
356
+ after: request.after, limit: request.limit,
357
+ }),
358
+ });
359
+ // A live conversation view is an attached controller: permission
360
+ // requests must reach it instead of the unattended policy.
361
+ const controller = request.controller !== false;
362
+ if (controller)
363
+ this.session.setControllerAttached(true);
364
+ return { controller, stop: this.session.subscribeConversation(event => {
365
+ if (!socket.destroyed)
366
+ socket.write(JSON.stringify({ version: 1, conversationEvent: event }) + '\n');
367
+ }) };
368
+ }
369
+ case 'submit_prompt_v2': {
370
+ this.requireConversation(request);
371
+ if (!request.commandId?.trim() || !request.text?.trim() || !request.actor?.trim()
372
+ || request.source !== 'owner_admin_console')
373
+ throw new SessionControlError('rejected', 'commandId, text, actor and authenticated source are required');
374
+ if (!this.session.submitPromptBrowser)
375
+ throw new SessionControlError('rejected', 'browser prompt admission is unavailable for this role');
376
+ try {
377
+ const receipt = await this.session.submitPromptBrowser({
378
+ commandId: request.commandId, text: request.text,
379
+ source: 'owner_admin_console', actorBrowserSession: request.actor,
380
+ });
381
+ this.write(socket, { version: 1, id: request.id, ok: true, result: receipt });
382
+ }
383
+ catch (error) {
384
+ if (error.name === 'IdempotencyConflictError')
385
+ throw new SessionControlError('rejected', 'idempotency_conflict: this command id was used with a different prompt body');
386
+ throw error;
387
+ }
388
+ return;
389
+ }
390
+ case 'interrupt_v2': {
391
+ this.requireConversation(request);
392
+ if (!request.commandId?.trim())
393
+ throw new SessionControlError('rejected', 'commandId is required');
394
+ const existing = this.interruptCommands.get(request.commandId);
395
+ if (existing) {
396
+ this.write(socket, { version: 1, id: request.id, ok: true, result: existing });
397
+ return;
398
+ }
399
+ await this.session.interrupt('local-console');
400
+ const receipt = { accepted: true, commandId: request.commandId, at: new Date().toISOString() };
401
+ this.interruptCommands.set(request.commandId, receipt);
402
+ if (this.interruptCommands.size > 200) {
403
+ const oldest = this.interruptCommands.keys().next().value;
404
+ this.interruptCommands.delete(oldest);
405
+ }
406
+ this.write(socket, { version: 1, id: request.id, ok: true, result: receipt });
407
+ return;
408
+ }
409
+ case 'respond_permission_v2': {
410
+ this.requireConversation(request);
411
+ if (!request.commandId?.trim() || !request.permissionId?.trim()
412
+ || !request.optionId?.trim() || !request.sessionGeneration?.trim())
413
+ throw new SessionControlError('rejected', 'commandId, permissionId, optionId and sessionGeneration are required');
414
+ if (!this.session.respondPermissionV2)
415
+ throw new SessionControlError('rejected', 'generation-bound permission responses are unavailable for this role');
416
+ const result = this.session.respondPermissionV2(request.permissionId, request.optionId, request.sessionGeneration);
417
+ if (result === 'stale')
418
+ throw new SessionControlError('rejected', 'stale_state: permission is settled, expired, invalid, or belongs to another session generation');
419
+ this.write(socket, {
420
+ version: 1, id: request.id, ok: true,
421
+ result: { accepted: true, commandId: request.commandId },
422
+ });
423
+ return;
424
+ }
317
425
  }
318
426
  }
319
427
  catch (error) {
@@ -328,6 +436,13 @@ export class RoleControlServer {
328
436
  });
329
437
  }
330
438
  }
439
+ /** Version and capability gate shared by every conversation v3 command. */
440
+ requireConversation(request) {
441
+ if (request.version !== 3)
442
+ throw new SessionControlError('rejected', 'protocol version 3 is required for conversation commands');
443
+ if (!this.session.conversationPage || !this.session.subscribeConversation)
444
+ throw new SessionControlError('rejected', 'this role has no conversation ledger');
445
+ }
331
446
  write(socket, response) {
332
447
  if (!socket.destroyed)
333
448
  socket.write(JSON.stringify(response) + '\n');
@@ -376,7 +491,7 @@ export async function controlRequest(stateDir, request, timeoutMs = 120_000) {
376
491
  socket.end();
377
492
  });
378
493
  socket.once('connect', () => socket.write(JSON.stringify({
379
- version: 2, id, token, ...request,
494
+ version: V3_COMMANDS.has(request.command) ? 3 : 2, id, token, ...request,
380
495
  }) + '\n'));
381
496
  });
382
497
  }
@@ -401,7 +516,45 @@ export async function followControl(stateDir, onMessage) {
401
516
  socket.once('connect', resolve);
402
517
  socket.once('error', reject);
403
518
  });
404
- const send = (request) => socket.write(JSON.stringify({ version: 1, id: randomUUID(), token, ...request }) + '\n');
519
+ const send = (request) => socket.write(JSON.stringify({
520
+ version: V3_COMMANDS.has(request.command) ? 3 : 1, id: randomUUID(), token, ...request,
521
+ }) + '\n');
405
522
  send({ command: 'follow', since: 0 });
406
523
  return { socket, send };
407
524
  }
525
+ /**
526
+ * Open a live conversation follow on the role's private control socket. The
527
+ * first message is the initial page + snapshot; every subsequent message is
528
+ * `{ version, conversationEvent }`. Closing the socket detaches the
529
+ * controller-presence this connection contributed.
530
+ */
531
+ export async function followConversation(stateDir, after, onMessage) {
532
+ const token = readFileSync(controlTokenPath(stateDir), 'utf8').trim();
533
+ const socket = createConnection(controlSocketPath(stateDir));
534
+ socket.setEncoding('utf8');
535
+ let buffer = '';
536
+ socket.on('data', chunk => {
537
+ buffer += chunk;
538
+ for (;;) {
539
+ const newline = buffer.indexOf('\n');
540
+ if (newline < 0)
541
+ break;
542
+ const line = buffer.slice(0, newline);
543
+ buffer = buffer.slice(newline + 1);
544
+ if (!line.trim())
545
+ continue;
546
+ try {
547
+ onMessage(JSON.parse(line));
548
+ }
549
+ catch { /* a torn frame ends this stream; the client resyncs over HTTP */ }
550
+ }
551
+ });
552
+ await new Promise((resolve, reject) => {
553
+ socket.once('connect', resolve);
554
+ socket.once('error', reject);
555
+ });
556
+ socket.write(JSON.stringify({
557
+ version: 3, id: randomUUID(), token, command: 'conversation_follow', after,
558
+ }) + '\n');
559
+ return { socket, close: () => socket.destroy() };
560
+ }
@@ -0,0 +1,34 @@
1
+ import type * as acp from '@agentclientprotocol/sdk';
2
+ import type { AdapterMeta, ConversationEventKind, ConversationPayload } from './conversation-types.js';
3
+ /**
4
+ * Reduce one ACP v1 `session/update` into one conversation-domain event draft.
5
+ *
6
+ * This is the protocol seam: v1 create/update pairs (and a future v2 upsert
7
+ * stream) both land in the same domain shapes. The function is pure and total —
8
+ * it never throws, never passes raw wire objects through, caps every payload,
9
+ * and quarantines namespaced `_meta` so unknown extensions cannot leak into
10
+ * generic rendering paths.
11
+ */
12
+ /** Cap for any single normalized text payload (spec §5.3). */
13
+ export declare const MAX_TEXT_BYTES: number;
14
+ /** Cap for one adapter `_meta` namespace value. */
15
+ export declare const MAX_META_BYTES: number;
16
+ /** Cap for serialized raw tool input/output retained as structured JSON. */
17
+ export declare const MAX_RAW_JSON_BYTES: number;
18
+ /** Cap for the sanitized preview of an unsupported update. */
19
+ export declare const MAX_UNSUPPORTED_PREVIEW_CHARS = 2048;
20
+ export interface NormalizeOptions {
21
+ /**
22
+ * Replace every text payload with this placeholder, keeping byte count and
23
+ * digest. Used for scheduled-loop turns whose output must not be retained.
24
+ */
25
+ redactText?: string;
26
+ }
27
+ export interface NormalizedUpdate {
28
+ kind: ConversationEventKind;
29
+ payload: ConversationPayload;
30
+ messageId?: string;
31
+ toolCallId?: string;
32
+ adapterMeta?: AdapterMeta[];
33
+ }
34
+ export declare function normalizeSessionUpdate(update: acp.SessionUpdate, options?: NormalizeOptions): NormalizedUpdate;