@ours.network/fleet 1.2.0-nightly.46 → 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.
@@ -55,8 +55,14 @@ export interface RoleSessionControl {
55
55
  text: string;
56
56
  actorBrowserSession: string;
57
57
  expectedSessionGeneration?: string;
58
+ requireIdle?: boolean;
58
59
  source: 'owner_admin_console';
59
60
  }): Promise<PromptReceipt>;
61
+ interruptPromptV2?(request: {
62
+ commandId: string;
63
+ expectedSessionGeneration: string;
64
+ promptId: string;
65
+ }): Promise<InterruptReceipt>;
60
66
  interruptV2?(commandId: string): Promise<InterruptReceipt & {
61
67
  commandId: string;
62
68
  }>;
@@ -109,8 +115,14 @@ export declare class RoleSessionControlAdapter implements RoleSessionControl {
109
115
  text: string;
110
116
  actorBrowserSession: string;
111
117
  expectedSessionGeneration?: string;
118
+ requireIdle?: boolean;
112
119
  source: 'owner_admin_console';
113
120
  }): Promise<PromptReceipt>;
121
+ interruptPromptV2(request: {
122
+ commandId: string;
123
+ expectedSessionGeneration: string;
124
+ promptId: string;
125
+ }): Promise<InterruptReceipt>;
114
126
  interruptV2(commandId: string): Promise<InterruptReceipt & {
115
127
  commandId: string;
116
128
  }>;
@@ -106,6 +106,9 @@ export class RoleSessionControlAdapter {
106
106
  // A prompt bound to a session generation (voice or attachments) is checked atomically by supervisors that
107
107
  // support it. Older supervisors do not know that command and never answer, so check the generation here
108
108
  // and submit an ordinary prompt; its command id keeps retries idempotent either way.
109
+ const features = request.requireIdle ? (await this.describe()).features : undefined;
110
+ if (request.requireIdle && (!request.expectedSessionGeneration || !features?.includes('idle_bound_prompts')))
111
+ throw new FleetError('capability_unavailable', 'Live voice requires an updated supervisor.');
109
112
  let command = 'submit_prompt_v2';
110
113
  if (request.expectedSessionGeneration) {
111
114
  if ((await this.describe()).features.includes('generation_bound_prompts'))
@@ -119,18 +122,33 @@ export class RoleSessionControlAdapter {
119
122
  try {
120
123
  return await this.call(command, {
121
124
  commandId: request.commandId, text: request.text, actor: request.actorBrowserSession,
122
- source: request.source, expectedSessionGeneration: request.expectedSessionGeneration,
125
+ source: request.source, expectedSessionGeneration: request.expectedSessionGeneration, requireIdle: request.requireIdle,
123
126
  });
124
127
  }
125
128
  catch (error) {
126
129
  const fleetError = normalizeError(error);
127
130
  if (fleetError.message.includes('session_changed:'))
128
131
  throw new FleetError('stale_state', 'Agent session changed. Record a new voice message.');
132
+ if (fleetError.message.includes('voice_busy:'))
133
+ throw new FleetError('conflict', 'Agent is already working. Wait for the current turn.');
129
134
  if (fleetError.message.includes('idempotency_conflict'))
130
135
  throw new FleetError('idempotency_conflict', 'this command id was already used with a different prompt body');
131
136
  throw fleetError;
132
137
  }
133
138
  }
139
+ async interruptPromptV2(request) {
140
+ if (!(await this.describe()).features.includes('targeted_voice_interrupt'))
141
+ throw new FleetError('capability_unavailable', 'Live voice requires an updated supervisor.');
142
+ try {
143
+ return await this.call('interrupt_voice_v2', request);
144
+ }
145
+ catch (error) {
146
+ const failure = normalizeError(error);
147
+ if (failure.message.includes('session_changed:'))
148
+ throw new FleetError('stale_state', 'Agent session changed.');
149
+ throw failure;
150
+ }
151
+ }
134
152
  async interruptV2(commandId) {
135
153
  const receipt = await this.call('interrupt_v2', { commandId });
136
154
  return {
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "1.2.0-nightly.46",
3
- "buildId": "a75b12e57308",
4
- "commit": "f608ced10209f4b9ecf9dc9b573d8cab46136a68",
2
+ "version": "1.2.0-nightly.47",
3
+ "buildId": "bbc0b3f7e8a7",
4
+ "commit": "5a8f562be01ca289722c0504f70281f2ce6d64e1",
5
5
  "dirty": true,
6
- "builtAt": "2026-10-04T23:11:11.430Z",
6
+ "builtAt": "2026-10-05T15:15:27.375Z",
7
7
  "capabilities": [
8
8
  "managed-cli.setup-v1",
9
9
  "workspace.enroll.preserve-profile-v1",
@@ -272,6 +272,8 @@ export declare class AcpSession implements AgentSession {
272
272
  * (`queuePrompt({ interrupt: true })`) needs the typed error, because only it
273
273
  * still owes an undelivered message a replay.
274
274
  */
275
+ readonly liveVoiceSupported = true;
276
+ interruptPrompt(generation: string, promptId: string): Promise<InterruptOutcome>;
275
277
  interrupt(source?: TurnCancellationSource): Promise<InterruptOutcome>;
276
278
  private cancelActive;
277
279
  /**
@@ -763,6 +763,8 @@ export class AcpSession {
763
763
  return existing;
764
764
  if (command.expectedSessionGeneration !== undefined && command.expectedSessionGeneration !== this.conversationSnapshot().sessionGeneration)
765
765
  throw new Error('session_changed: Agent session changed. Record a new voice message.');
766
+ if (command.requireIdle && (this.queueDepth > 0 || this.conversationSnapshot().readiness !== 'idle'))
767
+ throw new Error('voice_busy: Agent is already working. Wait for the current turn.');
766
768
  const queued = await this.queuePrompt(command.text, {
767
769
  origin: { kind: 'owner-admin-console', commandId: command.commandId },
768
770
  actor: { browserSession: command.actorBrowserSession },
@@ -795,6 +797,14 @@ export class AcpSession {
795
797
  * (`queuePrompt({ interrupt: true })`) needs the typed error, because only it
796
798
  * still owes an undelivered message a replay.
797
799
  */
800
+ liveVoiceSupported = true;
801
+ async interruptPrompt(generation, promptId) {
802
+ if (generation !== this.sessionGeneration)
803
+ throw new Error('session_changed: Agent session changed.');
804
+ if (this.activeTurn?.id !== promptId)
805
+ return { state: 'settled' };
806
+ return this.interrupt('local-console');
807
+ }
798
808
  async interrupt(source = 'local-console') {
799
809
  try {
800
810
  await this.cancelActive(source);
@@ -75,6 +75,8 @@ export declare class RoleTurnArbiter implements AgentSession {
75
75
  conversationSnapshot(): ConversationSnapshot;
76
76
  subscribeConversation(listener: (event: ConversationEventV1) => void): () => void;
77
77
  submitPromptBrowser(command: SubmitPromptCommand): Promise<PromptReceipt>;
78
+ get liveVoiceSupported(): boolean;
79
+ interruptPrompt(generation: string, promptId: string): Promise<InterruptOutcome>;
78
80
  interrupt(source?: TurnCancellationSource): Promise<InterruptOutcome>;
79
81
  respondPermission(permissionId: string, optionId: string): boolean;
80
82
  respondPermissionV2(permissionId: string, optionId: string, sessionGeneration: string): 'accepted' | 'stale';
@@ -177,6 +177,14 @@ export class RoleTurnArbiter {
177
177
  return this.exclusive(() => { if (this.retiring)
178
178
  throw new Error('Chat is closing'); return this.session.submitPromptBrowser(command); });
179
179
  }
180
+ get liveVoiceSupported() { return this.session.liveVoiceSupported === true; }
181
+ interruptPrompt(generation, promptId) {
182
+ return this.exclusive(async () => {
183
+ if (!this.session.interruptPrompt)
184
+ throw new Error('targeted voice interrupt is unavailable');
185
+ return interruptOutcome(await this.session.interruptPrompt(generation, promptId));
186
+ });
187
+ }
180
188
  interrupt(source = 'local-console') {
181
189
  return this.exclusive(async () => interruptOutcome(await this.session.interrupt(source)));
182
190
  }
@@ -69,6 +69,8 @@ export declare class CodexAppServerSession implements AgentSession {
69
69
  snapshot(): SessionSnapshot;
70
70
  queuePrompt(text: string, options?: SubmitPromptOptions): Promise<QueuedPrompt>;
71
71
  submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
72
+ readonly liveVoiceSupported = true;
73
+ interruptPrompt(generation: string, promptId: string): Promise<InterruptOutcome>;
72
74
  interrupt(source?: TurnCancellationSource): Promise<InterruptOutcome>;
73
75
  respondPermission(permissionId: string, optionId: string): boolean;
74
76
  respondPermissionV2(permissionId: string, optionId: string, sessionGeneration: string): 'accepted' | 'stale';
@@ -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';
@@ -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))
@@ -0,0 +1,13 @@
1
+ # Live voice managed-session boundary
2
+
3
+ App Live voice sends transcripts through existing ACP managed sessions and reads the actual agent conversation. Updated ACP and native Codex supervisors advertise `generation_bound_prompts`, `idle_bound_prompts` and `targeted_voice_interrupt`; `/api/v1/roles/:id/voice-capabilities` authenticates the browser and reports whether all are present.
4
+
5
+ `submit_voice_prompt` accepts `requireIdle: true`. Receipt replay precedes the idle check so retrying a lost acknowledgment does not create a second prompt or fail just because the first is running. New admission checks generation and idle/queue/tool/permission state inside the existing turn arbiter before queuing. Unrelated work is never interrupted to make room.
6
+
7
+ `interrupt_voice_v2` requires a command ID, expected session generation and exact prompt ID. A matching active prompt uses normal managed-session cancellation; a settled or different prompt is a harmless no-op. A stale generation is rejected. Receipts are idempotent and conflicting reuse of an interrupt command is rejected. The browser's `/interrupt` route selects this path only when the complete target is present, with no legacy unbound fallback.
8
+
9
+ Authenticated CSRF-protected `POST /api/v1/roles/:id/live-presence` takes `{ expectedSessionGeneration }`, validates the current generation before attaching `followConversation`, and streams NDJSON readiness/heartbeats. The HTTP connection owns one attended controller until disconnect. Close, generation change, auth expiry, slow consumer or attach failure detach it. The browser's ordinary workspace fetch supports this stream without provider credentials in a URL. Existing ACP permission expiry and controller detach grace remain in force.
10
+
11
+ The daemon/App changes and configuration instructions live in the corresponding `ours-app/docs/live-voice.md` and `ours-sdk` Live voice documentation. The daemon owns the OpenAI key and 60-second Realtime credential minting; Fleet never receives the provider key.
12
+
13
+ Coverage includes actual ACP/control-socket busy/retry/targeted-cancel tests, native Codex transport tests, authenticated presence-boundary tests and the App native browser/audio integration with separate Codex/Claude ACP protocol fixtures. Provider inference and physical-device acceptance are separate operator checks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.2.0-nightly.46",
3
+ "version": "1.2.0-nightly.47",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, managed native/ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",
@@ -19,6 +19,7 @@
19
19
  "presets",
20
20
  "README.md",
21
21
  "docs/task-workspaces.md",
22
+ "docs/live-voice.md",
22
23
  "docs/room-layouts.md",
23
24
  "docs/validation/managed-cli-permissions.md",
24
25
  "LICENSE"
@@ -41,9 +42,9 @@
41
42
  "dependencies": {
42
43
  "@agentclientprotocol/sdk": "^1.3.0",
43
44
  "@fastify/websocket": "^11.2.0",
44
- "@ours.network/cli": "2.8.1-nightly.13",
45
- "@ours.network/mcp": "1.2.0-nightly.10",
46
- "@ours.network/sdk": "3.8.1-nightly.15",
45
+ "@ours.network/cli": "2.8.1-nightly.15",
46
+ "@ours.network/mcp": "1.2.0-nightly.12",
47
+ "@ours.network/sdk": "3.8.1-nightly.17",
47
48
  "commander": "^12.1.0",
48
49
  "fastify": "^5.4.0",
49
50
  "qrcode": "1.5.4",
@@ -56,7 +57,7 @@
56
57
  "@agentclientprotocol/codex-acp": "1.10.0"
57
58
  },
58
59
  "devDependencies": {
59
- "@ours.network/daemon": "3.8.1-nightly.7",
60
+ "@ours.network/daemon": "3.8.1-nightly.9",
60
61
  "@types/node": "^22.20.4",
61
62
  "@types/qrcode": "^1.5.6",
62
63
  "@types/ws": "^8.18.1",