@ours.network/fleet 1.2.0-nightly.45 → 1.2.0-nightly.47

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.
@@ -227,6 +227,14 @@ export class CodexAppServerSession {
227
227
  throw error;
228
228
  }
229
229
  }
230
+ liveVoiceSupported = true;
231
+ async interruptPrompt(generation, promptId) {
232
+ if (generation !== this.sessionGeneration)
233
+ throw new Error('session_changed: Agent session changed.');
234
+ if (this.activeTurn?.promptId !== promptId)
235
+ return { state: 'settled' };
236
+ return this.interrupt('local-console');
237
+ }
230
238
  async interrupt(source = 'local-console') {
231
239
  const active = this.activeTurn;
232
240
  if (!active || !this.threadId)
@@ -376,6 +384,8 @@ export class CodexAppServerSession {
376
384
  return existing;
377
385
  if (command.expectedSessionGeneration !== undefined && command.expectedSessionGeneration !== this.conversationSnapshot().sessionGeneration)
378
386
  throw new Error('session_changed: Agent session changed. Record a new voice message.');
387
+ if (command.requireIdle && (this.queueDepth > 0 || this.conversationSnapshot().readiness !== 'idle'))
388
+ throw new Error('voice_busy: Agent is already working. Wait for the current turn.');
379
389
  const queued = await this.queuePrompt(command.text, {
380
390
  origin: { kind: 'owner-admin-console', commandId: command.commandId },
381
391
  actor: { browserSession: command.actorBrowserSession },
@@ -11,11 +11,13 @@ export interface ControlRequest {
11
11
  version: 1 | 2 | 3;
12
12
  id: string;
13
13
  token: string;
14
- 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' | 'submit_voice_prompt' | 'interrupt_v2' | 'layout_control' | 'agent_contacts' | 'agent_history' | 'respond_permission_v2' | 'fleet_spawn' | 'fleet_audit_begin' | 'fleet_audit_present' | 'fleet_audit_finish';
14
+ 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' | 'submit_voice_prompt' | 'interrupt_v2' | 'interrupt_voice_v2' | 'layout_control' | 'agent_contacts' | 'agent_history' | 'respond_permission_v2' | 'fleet_spawn' | 'fleet_audit_begin' | 'fleet_audit_present' | 'fleet_audit_finish';
15
15
  layout?: LayoutControlRequest;
16
16
  agentHistory?: AgentHistoryQuery;
17
17
  text?: string;
18
18
  expectedSessionGeneration?: string;
19
+ requireIdle?: boolean;
20
+ promptId?: string;
19
21
  permissionId?: string;
20
22
  optionId?: string;
21
23
  since?: number;
@@ -9,7 +9,7 @@ import { interruptSession, queueSessionPrompt, respondSessionPermission, respond
9
9
  const MAX_LINE_BYTES = 64 * 1024;
10
10
  /** Commands that require protocol version 3. */
11
11
  const V3_COMMANDS = new Set([
12
- 'conversation_page', 'conversation_follow', 'submit_prompt_v2', 'submit_voice_prompt', 'interrupt_v2',
12
+ 'conversation_page', 'conversation_follow', 'submit_prompt_v2', 'submit_voice_prompt', 'interrupt_v2', 'interrupt_voice_v2',
13
13
  'respond_permission_v2',
14
14
  ]);
15
15
  /** The one retained-range projection shared by polling and live-follow admission. */
@@ -243,6 +243,7 @@ export class RoleControlServer {
243
243
  ...(this.correspondence ? ['agent_correspondence'] : []),
244
244
  ...(this.session.conversationPage ? ['conversation_v3'] : []),
245
245
  ...(this.session.submitPromptBrowser ? ['generation_bound_prompts'] : []),
246
+ ...(this.session.liveVoiceSupported ? ['idle_bound_prompts', 'targeted_voice_interrupt'] : []),
246
247
  ...(this.session.capabilities?.steering ? ['steering'] : []),
247
248
  ...(this.session.capabilities?.permissions ? ['permissions'] : []),
248
249
  ...(this.session.capabilities?.messagePhases ? ['message_phases'] : []),
@@ -466,7 +467,7 @@ export class RoleControlServer {
466
467
  const receipt = await this.session.submitPromptBrowser({
467
468
  commandId: request.commandId, text: request.text,
468
469
  source: 'owner_admin_console', actorBrowserSession: request.actor,
469
- expectedSessionGeneration: request.expectedSessionGeneration,
470
+ expectedSessionGeneration: request.expectedSessionGeneration, requireIdle: request.requireIdle === true,
470
471
  });
471
472
  this.write(socket, { version: 1, id: request.id, ok: true, result: receipt });
472
473
  }
@@ -477,6 +478,29 @@ export class RoleControlServer {
477
478
  }
478
479
  return;
479
480
  }
481
+ case 'interrupt_voice_v2': {
482
+ this.requireConversation(request);
483
+ if (!request.commandId?.trim() || !request.expectedSessionGeneration?.trim() || !request.promptId?.trim())
484
+ throw new SessionControlError('rejected', 'commandId, expectedSessionGeneration and promptId are required');
485
+ if (!this.session.interruptPrompt)
486
+ throw new SessionControlError('rejected', 'targeted voice interrupt unavailable');
487
+ const key = 'voice:' + request.commandId;
488
+ const signature = JSON.stringify([request.expectedSessionGeneration, request.promptId]);
489
+ const existing = this.interruptCommands.get(key);
490
+ if (existing) {
491
+ if (existing.signature !== signature)
492
+ throw new SessionControlError('rejected', 'idempotency_conflict: interrupt target changed');
493
+ this.write(socket, { version: 1, id: request.id, ok: true, result: existing.receipt });
494
+ return;
495
+ }
496
+ const outcome = await this.session.interruptPrompt(request.expectedSessionGeneration, request.promptId);
497
+ const receipt = { accepted: true, commandId: request.commandId, ...outcome };
498
+ this.interruptCommands.set(key, { signature, receipt });
499
+ if (this.interruptCommands.size > 200)
500
+ this.interruptCommands.delete(this.interruptCommands.keys().next().value);
501
+ this.write(socket, { version: 1, id: request.id, ok: true, result: receipt });
502
+ return;
503
+ }
480
504
  case 'interrupt_v2': {
481
505
  this.requireConversation(request);
482
506
  if (!request.commandId?.trim())
@@ -264,6 +264,8 @@ export interface ConversationEventV1 {
264
264
  }
265
265
  export interface SubmitPromptCommand {
266
266
  expectedSessionGeneration?: string;
267
+ /** Live speech must not silently queue behind an unrelated turn. */
268
+ requireIdle?: boolean;
267
269
  /** Idempotency-Key / clientRequestId. Reuse with a different body is a conflict. */
268
270
  commandId: string;
269
271
  text: string;
@@ -319,6 +319,9 @@ export interface AgentSession {
319
319
  * keep compiling. Read it through `interruptOutcome`, never directly.
320
320
  */
321
321
  interrupt(source?: TurnCancellationSource): Promise<InterruptResult>;
322
+ /** Atomically bind cancellation to one voice-owned turn. */
323
+ readonly liveVoiceSupported?: boolean;
324
+ interruptPrompt?(generation: string, promptId: string): Promise<InterruptResult>;
322
325
  respondPermission(permissionId: string, optionId: string): boolean;
323
326
  /** Generation-bound browser decision; stale/settled/invalid all fail closed. */
324
327
  respondPermissionV2?(permissionId: string, optionId: string, sessionGeneration: string): 'accepted' | 'stale';
@@ -6,3 +6,14 @@ export declare function readTunnelSetupStdin(input?: AsyncIterable<Buffer | stri
6
6
  export declare function readTunnelSetupArgument(input: string): string;
7
7
  /** Redeem the grant by HTTPS POST; long-lived connector credentials remain off argv and URLs. */
8
8
  export declare function redeemTunnelSetup(input: string, request?: typeof fetch): Promise<WorkspacePayload>;
9
+ /** Inspect only public destination metadata; this never consumes a one-use grant. */
10
+ export declare function inspectTunnelSetup(input: string, request?: typeof fetch): Promise<{
11
+ workspaceId: string;
12
+ serverCid: string;
13
+ appOrigin: string;
14
+ } | undefined>;
15
+ export declare function verifyTunnelSetupTarget(target: {
16
+ workspaceId: string;
17
+ serverCid: string;
18
+ appOrigin: string;
19
+ } | undefined, payload: WorkspacePayload): void;
@@ -80,3 +80,45 @@ export async function redeemTunnelSetup(input, request = fetch) {
80
80
  throw Error('Tunnel setup response origin mismatch');
81
81
  return payload;
82
82
  }
83
+ /** Inspect only public destination metadata; this never consumes a one-use grant. */
84
+ export async function inspectTunnelSetup(input, request = fetch) {
85
+ let version;
86
+ try {
87
+ version = JSON.parse(Buffer.from(input.trim(), 'base64url').toString()).version;
88
+ }
89
+ catch { }
90
+ if (version === 1) {
91
+ const p = decodeWorkspacePayload(input);
92
+ return { workspaceId: p.challenge.workspaceId, serverCid: p.serverCid, appOrigin: p.appOrigin };
93
+ }
94
+ const grant = decodeTunnelSetupGrant(input);
95
+ let response;
96
+ try {
97
+ response = await request(grant.appOrigin + '/account-api/workspace-install-inspect', { method: 'POST', redirect: 'error', credentials: 'omit', signal: AbortSignal.timeout(15000), headers: { Origin: grant.appOrigin, 'Content-Type': 'application/json' }, body: JSON.stringify({ code: grant.code }) });
98
+ }
99
+ catch {
100
+ throw Error('Could not inspect the account setup command; existing setup and grant preserved');
101
+ }
102
+ if (response.status === 404 || response.status === 405) {
103
+ await response.body?.cancel();
104
+ return;
105
+ }
106
+ if (!response.ok) {
107
+ await response.body?.cancel();
108
+ throw Error('Setup command inspection failed; existing setup and grant preserved');
109
+ }
110
+ let target;
111
+ try {
112
+ target = await response.json();
113
+ }
114
+ catch {
115
+ throw Error('Invalid setup command inspection response');
116
+ }
117
+ if (!target || Object.keys(target).sort().join(',') !== 'serverCid,workspaceId' || typeof target.workspaceId !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(target.workspaceId) || typeof target.serverCid !== 'string' || !/^[a-f0-9]{64}$/i.test(target.serverCid))
118
+ throw Error('Invalid setup command inspection response');
119
+ return { ...target, appOrigin: grant.appOrigin };
120
+ }
121
+ export function verifyTunnelSetupTarget(target, payload) {
122
+ if (target && (target.workspaceId !== payload.challenge.workspaceId || target.serverCid.toUpperCase() !== payload.serverCid.toUpperCase() || target.appOrigin !== payload.appOrigin))
123
+ throw Error('Redeemed setup command differs from inspected destination; existing setup preserved');
124
+ }
@@ -691,6 +691,11 @@ export async function buildWebServer(services, boundary, options = {}) {
691
691
  const detail = await services.query.detail(request.params.id);
692
692
  return { ...detail, chatIdle: detail.role.lifetime === 'temporary' ? readChatIdle(agentDir(request.params.id, true)) ?? null : null };
693
693
  });
694
+ app.get('/api/v1/roles/:id/voice-capabilities', async (request) => {
695
+ auth.authenticate(request);
696
+ const descriptor = await (await services.session(request.params.id)).describe();
697
+ return { supported: ['generation_bound_prompts', 'idle_bound_prompts', 'targeted_voice_interrupt'].every(f => descriptor.features.includes(f)) };
698
+ });
694
699
  app.get('/api/v1/roles/:id/removal-preview', async (request) => {
695
700
  auth.authenticate(request);
696
701
  if (!ROLE_NAME_RE.test(request.params.id))
@@ -781,6 +786,8 @@ export async function buildWebServer(services, boundary, options = {}) {
781
786
  const body = request.body;
782
787
  if (body?.expectedSessionGeneration !== undefined && (typeof body.expectedSessionGeneration !== 'string' || !body.expectedSessionGeneration.trim()))
783
788
  throw new FleetError('invalid_request', 'expectedSessionGeneration must be a nonempty string');
789
+ if (body?.requireIdle !== undefined && typeof body.requireIdle !== 'boolean')
790
+ throw new FleetError('invalid_request', 'requireIdle must be boolean');
784
791
  let text = String(body?.text ?? '');
785
792
  if (body?.attachments !== undefined) {
786
793
  if (typeof body.commandId !== 'string' || !body.commandId.trim())
@@ -807,7 +814,7 @@ export async function buildWebServer(services, boundary, options = {}) {
807
814
  const admittedCommandId = commandId ?? randomBytes(16).toString('hex');
808
815
  const receipt = await control.submitPromptV2({
809
816
  commandId: admittedCommandId, text, source: 'owner_admin_console',
810
- expectedSessionGeneration: body.expectedSessionGeneration,
817
+ expectedSessionGeneration: body.expectedSessionGeneration, requireIdle: body.requireIdle === true,
811
818
  actorBrowserSession: createHmac('sha256', digestKey).update(session.id).digest('hex').slice(0, 24),
812
819
  });
813
820
  await audit.record({
@@ -819,7 +826,7 @@ export async function buildWebServer(services, boundary, options = {}) {
819
826
  reply.code(202);
820
827
  return receipt;
821
828
  }
822
- if (body.expectedSessionGeneration !== undefined)
829
+ if (body.expectedSessionGeneration !== undefined || body.requireIdle)
823
830
  throw new FleetError('capability_unavailable', 'session-bound voice input is unavailable for this role');
824
831
  const receipt = await control.sendText(text);
825
832
  await audit.record({
@@ -836,6 +843,16 @@ export async function buildWebServer(services, boundary, options = {}) {
836
843
  const commandId = typeof body?.commandId === 'string' && body.commandId.trim()
837
844
  ? body.commandId : undefined;
838
845
  const control = await services.session(request.params.id);
846
+ if (body?.expectedSessionGeneration !== undefined || body?.promptId !== undefined) {
847
+ if (!commandId || typeof body.expectedSessionGeneration !== 'string' || !body.expectedSessionGeneration.trim() || typeof body.promptId !== 'string' || !body.promptId.trim())
848
+ throw new FleetError('invalid_request', 'Targeted interrupt requires commandId, expectedSessionGeneration and promptId');
849
+ if (!control.interruptPromptV2)
850
+ throw new FleetError('capability_unavailable', 'Targeted voice interrupt is unavailable');
851
+ const receipt = await control.interruptPromptV2({ commandId, expectedSessionGeneration: body.expectedSessionGeneration, promptId: body.promptId });
852
+ await audit.record({ requestId: request.id, browser: session.id, roleId: request.params.id, action: 'session.interrupt_voice', result: 'accepted' });
853
+ reply.code(202);
854
+ return receipt;
855
+ }
839
856
  if (commandId && control.interruptV2) {
840
857
  const receipt = await control.interruptV2(commandId);
841
858
  await audit.record({
@@ -920,6 +937,70 @@ export async function buildWebServer(services, boundary, options = {}) {
920
937
  throw new FleetError('role_not_found', 'creation action not found');
921
938
  return action;
922
939
  });
940
+ // A fetch stream works through the same authenticated workspace transport as
941
+ // ordinary requests, and owns one ACP controller until its browser disconnects.
942
+ app.post('/api/v1/roles/:id/live-presence', async (request, reply) => {
943
+ const browser = auth.authenticate(request, true);
944
+ const generation = request.body?.expectedSessionGeneration;
945
+ if (typeof generation !== 'string' || !generation.trim())
946
+ throw new FleetError('invalid_request', 'expectedSessionGeneration is required');
947
+ const control = await services.session(request.params.id);
948
+ if (!control.followConversation || !control.conversationPage)
949
+ throw new FleetError('capability_unavailable', 'Live voice requires a conversation controller');
950
+ const page = await control.conversationPage({ limit: 1 });
951
+ if (page.snapshot.sessionGeneration !== generation)
952
+ throw new FleetError('stale_state', 'Agent session changed. Start live voice again.');
953
+ // Session/page discovery can await I/O; recheck revocation before attaching.
954
+ auth.authenticate(request, true);
955
+ if (reply.raw.destroyed)
956
+ return;
957
+ reply.hijack();
958
+ for (const [name, value] of Object.entries(reply.getHeaders()))
959
+ if (value !== undefined)
960
+ reply.raw.setHeader(name, Array.isArray(value) ? value : String(value));
961
+ reply.raw.writeHead(200, { 'Content-Type': 'application/x-ndjson', 'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no' });
962
+ let ended = false;
963
+ let follow;
964
+ let heartbeat;
965
+ let unbind;
966
+ const close = () => { if (ended)
967
+ return; ended = true; unbind?.(); if (heartbeat)
968
+ clearInterval(heartbeat); follow?.close(); reply.raw.end(); };
969
+ const write = (value) => { if (!ended && !reply.raw.write(JSON.stringify(value) + '\n'))
970
+ close(); };
971
+ reply.raw.once('close', close);
972
+ unbind = auth.bindTransport(browser.id, close);
973
+ try {
974
+ follow = await control.followConversation({
975
+ after: page.nextCursor,
976
+ onPage: current => { if (current.snapshot.sessionGeneration !== generation) {
977
+ write({ error: 'Agent session changed. Start live voice again.' });
978
+ close();
979
+ }
980
+ else
981
+ write({ ready: true }); },
982
+ onEvent: event => { if (event.sessionGeneration !== generation) {
983
+ write({ error: 'Agent session changed. Start live voice again.' });
984
+ close();
985
+ } },
986
+ onClose: () => close(),
987
+ });
988
+ if (ended)
989
+ follow.close();
990
+ else
991
+ heartbeat = setInterval(() => { try {
992
+ auth.authenticate(request);
993
+ write({ heartbeat: true });
994
+ }
995
+ catch {
996
+ close();
997
+ } }, 10_000);
998
+ }
999
+ catch {
1000
+ write({ error: 'Agent controller connection failed.' });
1001
+ close();
1002
+ }
1003
+ });
923
1004
  app.post('/api/v1/ws-tickets', async (request) => {
924
1005
  const body = request.body;
925
1006
  if (!body?.purpose || !['events', 'conversation'].includes(body.purpose))
@@ -25,5 +25,7 @@ export declare class WorkspaceDeviceStore {
25
25
  valid(id: string): boolean;
26
26
  list(): WorkspaceDevice[];
27
27
  revoke(id: string): void;
28
+ /** Retire account links without changing the installation's stable host ID or other data. */
29
+ revokeAll(): void;
28
30
  close(): void;
29
31
  }
@@ -80,5 +80,18 @@ export class WorkspaceDeviceStore {
80
80
  valid(id) { return Boolean(this.db.prepare('SELECT id FROM devices WHERE id=? AND revokedAt IS NULL AND expiresAt>?').get(id, this.now())); }
81
81
  list() { return this.db.prepare('SELECT id,label,createdAt,lastUsedAt,expiresAt,revokedAt FROM devices ORDER BY createdAt DESC LIMIT 256').all().map(row => ({ id: String(row.id), label: String(row.label), createdAt: Number(row.createdAt), lastUsedAt: Number(row.lastUsedAt), expiresAt: Number(row.expiresAt), revokedAt: row.revokedAt === null ? null : Number(row.revokedAt) })); }
82
82
  revoke(id) { this.db.prepare('UPDATE devices SET revokedAt=? WHERE id=? AND revokedAt IS NULL').run(this.now(), id); this.db.prepare('DELETE FROM enrollments WHERE issuer=?').run(id); }
83
+ /** Retire account links without changing the installation's stable host ID or other data. */
84
+ revokeAll() {
85
+ this.db.exec('BEGIN IMMEDIATE');
86
+ try {
87
+ this.db.prepare('UPDATE devices SET revokedAt=? WHERE revokedAt IS NULL').run(this.now());
88
+ this.db.exec('DELETE FROM enrollments');
89
+ this.db.exec('COMMIT');
90
+ }
91
+ catch (error) {
92
+ this.db.exec('ROLLBACK');
93
+ throw error;
94
+ }
95
+ }
83
96
  close() { this.db.close(); }
84
97
  }
@@ -6,7 +6,7 @@ export interface WorkspacePayload {
6
6
  rootName: string;
7
7
  name: string;
8
8
  surname: string;
9
- connectorToken: string;
9
+ connectorToken?: string;
10
10
  invitation: string;
11
11
  serverCid: string;
12
12
  challenge: {
@@ -17,7 +17,11 @@ export interface WorkspacePayload {
17
17
  };
18
18
  }
19
19
  export declare function readWorkspacePayload(file: string): WorkspacePayload;
20
- export declare function decodeWorkspacePayload(input: string): WorkspacePayload;
20
+ export declare function decodeWorkspacePayload(input: string, options?: {
21
+ allowExpired?: boolean;
22
+ }): WorkspacePayload;
23
+ /** Credential-free renewal is allowed only on the host already holding this private connector. */
24
+ export declare function checkWorkspaceConnector(payload: WorkspacePayload): void;
21
25
  type DaemonClient = Pick<OursClient, 'listIdentities' | 'chooseIdentity' | 'addContact' | 'listContacts' | 'sendCommand' | 'removeContact' | 'releaseLease' | 'close'>;
22
26
  /** How tunnel setup reaches this host's daemon; replaced only by tests. */
23
27
  export type AttachDaemonClient = (options: AttachOursClientOptions) => Promise<DaemonClient>;
@@ -29,6 +33,7 @@ export declare function checkWorkspaceConfiguration(configuration?: string, atta
29
33
  export declare function enrollWorkspace(payload: WorkspacePayload, configuration?: string, options?: {
30
34
  migrateAppOrigin?: boolean;
31
35
  preserveProfile?: boolean;
36
+ replacingWorkspace?: string;
32
37
  attach?: AttachDaemonClient;
33
38
  contactWaitMs?: number;
34
39
  afterInvitation?: () => void;
@@ -51,4 +56,27 @@ export declare function configureWorkspacePort(payload: Pick<WorkspacePayload, '
51
56
  * so the setup can be finished later. A removal that fails is reported and does not undo the setup.
52
57
  */
53
58
  export declare function confirmWorkspaceTarget(payload: Pick<WorkspacePayload, 'appOrigin' | 'challenge'>, hostWorkspaceId: string, rootCid: string, port: number, cleanupFailed?: (error: unknown) => void): Promise<void>;
59
+ /** Retire exactly the original registration using the same signed Human root that bound it. */
60
+ export declare function unregisterWorkspace(previous: {
61
+ workspaceId: string;
62
+ hostWorkspaceId: string;
63
+ appOrigin: string;
64
+ serverCid: string;
65
+ proofRootCid?: string;
66
+ }, payload: WorkspacePayload, operationNonce: string, attach?: AttachDaemonClient, options?: {
67
+ request?: typeof fetch;
68
+ waitMs?: number;
69
+ checkOnly?: boolean;
70
+ beforeSend?: () => void;
71
+ operationExpiresAt?: number;
72
+ onReceipt?: (status: {
73
+ deleted?: boolean;
74
+ retired?: boolean;
75
+ absent?: boolean;
76
+ rejected?: boolean;
77
+ expired?: boolean;
78
+ deadlineEnforced?: boolean;
79
+ oldBindingPresent?: boolean;
80
+ }) => void;
81
+ }): Promise<string>;
54
82
  export {};