@ours.network/fleet 0.15.3 → 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 (58) hide show
  1. package/README.md +13 -6
  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/channel.js +6 -5
  26. package/dist/permissions.d.ts +5 -0
  27. package/dist/permissions.js +7 -0
  28. package/dist/runner.js +2 -0
  29. package/dist/session/acp.d.ts +61 -1
  30. package/dist/session/acp.js +398 -20
  31. package/dist/session/arbiter.d.ts +10 -1
  32. package/dist/session/arbiter.js +24 -0
  33. package/dist/session/control.d.ts +28 -2
  34. package/dist/session/control.js +145 -5
  35. package/dist/session/conversation-normalizer.d.ts +34 -0
  36. package/dist/session/conversation-normalizer.js +356 -0
  37. package/dist/session/conversation-store.d.ts +88 -0
  38. package/dist/session/conversation-store.js +347 -0
  39. package/dist/session/conversation-types.d.ts +274 -0
  40. package/dist/session/conversation-types.js +1 -0
  41. package/dist/session/types.d.ts +40 -0
  42. package/dist/spawn.d.ts +1 -0
  43. package/dist/spawn.js +9 -4
  44. package/dist/web/auth.d.ts +1 -1
  45. package/dist/web/fleet-config-service.d.ts +47 -0
  46. package/dist/web/fleet-config-service.js +204 -0
  47. package/dist/web/runtime.js +14 -1
  48. package/dist/web/server.d.ts +6 -0
  49. package/dist/web/server.js +181 -9
  50. package/dist/web/topology.d.ts +31 -0
  51. package/dist/web/topology.js +61 -0
  52. package/dist/web-app/assets/{TerminalView-DMoT8udI.js → TerminalView-hZpyUFY_.js} +1 -1
  53. package/dist/web-app/assets/index-COg4Azq1.css +1 -0
  54. package/dist/web-app/assets/index-Cde9auW0.js +10 -0
  55. package/dist/web-app/index.html +2 -2
  56. package/package.json +1 -1
  57. package/dist/web-app/assets/index-B-jtLAkp.css +0 -1
  58. 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); }
@@ -5,10 +5,10 @@ import type { ScheduledLoopManagerHandle } from '../loops/manager.js';
5
5
  import type { SpawnOpts } from '../spawn.js';
6
6
  import type { ManagedFleetSpawnResult } from '../fleet-proxy.js';
7
7
  export interface ControlRequest {
8
- version: 1 | 2;
8
+ version: 1 | 2 | 3;
9
9
  id: string;
10
10
  token: string;
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' | 'fleet_spawn';
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';
12
12
  text?: string;
13
13
  permissionId?: string;
14
14
  optionId?: string;
@@ -17,6 +17,18 @@ export interface ControlRequest {
17
17
  controller?: boolean;
18
18
  ownerChannel?: OwnerChannelManagementRequest;
19
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. */
20
32
  spawn?: SpawnOpts;
21
33
  }
22
34
  export interface ControlResponse {
@@ -78,6 +90,8 @@ export declare class RoleControlServer {
78
90
  private readonly socketPath;
79
91
  private readonly token;
80
92
  private readonly sockets;
93
+ /** Interrupt idempotency: same command id returns the first receipt. */
94
+ private readonly interruptCommands;
81
95
  private ownerChannel?;
82
96
  private loopManager?;
83
97
  private reloadConfig?;
@@ -92,6 +106,8 @@ export declare class RoleControlServer {
92
106
  setFleetSpawner(fleetSpawner: ((options: SpawnOpts) => Promise<ManagedFleetSpawnResult>) | undefined): void;
93
107
  private accept;
94
108
  private handle;
109
+ /** Version and capability gate shared by every conversation v3 command. */
110
+ private requireConversation;
95
111
  private write;
96
112
  }
97
113
  /**
@@ -105,3 +121,13 @@ export declare function followControl(stateDir: string, onMessage: (message: Rec
105
121
  socket: Socket;
106
122
  send(request: Omit<ControlRequest, 'version' | 'id' | 'token'>): void;
107
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,6 +100,8 @@ 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;
@@ -196,7 +203,7 @@ export class RoleControlServer {
196
203
  this.write(socket, { version: 1, id: '?', ok: false, error: 'invalid JSON' });
197
204
  return;
198
205
  }
199
- if ((request.version !== 1 && request.version !== 2) || typeof request.id !== 'string'
206
+ if (![1, 2, 3].includes(request.version) || typeof request.id !== 'string'
200
207
  || !sameToken(this.token, request.token ?? '')) {
201
208
  this.write(socket, { version: 1, id: request.id ?? '?', ok: false, error: 'unauthorized' });
202
209
  return;
@@ -208,8 +215,12 @@ export class RoleControlServer {
208
215
  this.write(socket, {
209
216
  version: 1, id: request.id, ok: true,
210
217
  result: {
211
- ...this.session.snapshot(), protocolVersion: 2,
212
- 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
+ ],
213
224
  },
214
225
  });
215
226
  return;
@@ -327,6 +338,90 @@ export class RoleControlServer {
327
338
  socket.write(JSON.stringify({ version: 1, event }) + '\n');
328
339
  }) };
329
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
+ }
330
425
  }
331
426
  }
332
427
  catch (error) {
@@ -341,6 +436,13 @@ export class RoleControlServer {
341
436
  });
342
437
  }
343
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
+ }
344
446
  write(socket, response) {
345
447
  if (!socket.destroyed)
346
448
  socket.write(JSON.stringify(response) + '\n');
@@ -389,7 +491,7 @@ export async function controlRequest(stateDir, request, timeoutMs = 120_000) {
389
491
  socket.end();
390
492
  });
391
493
  socket.once('connect', () => socket.write(JSON.stringify({
392
- version: 2, id, token, ...request,
494
+ version: V3_COMMANDS.has(request.command) ? 3 : 2, id, token, ...request,
393
495
  }) + '\n'));
394
496
  });
395
497
  }
@@ -414,7 +516,45 @@ export async function followControl(stateDir, onMessage) {
414
516
  socket.once('connect', resolve);
415
517
  socket.once('error', reject);
416
518
  });
417
- 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');
418
522
  send({ command: 'follow', since: 0 });
419
523
  return { socket, send };
420
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;