@ours.network/fleet 1.2.0-nightly.46 → 1.2.0-nightly.48
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.
- package/dist/application/session-control.d.ts +12 -0
- package/dist/application/session-control.js +19 -1
- package/dist/build-info.json +4 -4
- package/dist/session/acp.d.ts +2 -0
- package/dist/session/acp.js +10 -0
- package/dist/session/arbiter.d.ts +2 -0
- package/dist/session/arbiter.js +8 -0
- package/dist/session/codex-app-server.d.ts +2 -0
- package/dist/session/codex-app-server.js +10 -0
- package/dist/session/control.d.ts +3 -1
- package/dist/session/control.js +26 -2
- package/dist/session/conversation-store.d.ts +1 -1
- package/dist/session/conversation-store.js +7 -11
- package/dist/session/conversation-types.d.ts +2 -0
- package/dist/session/types.d.ts +3 -0
- package/dist/web/server.js +83 -2
- package/docs/live-voice.md +13 -0
- package/package.json +6 -5
|
@@ -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 {
|
package/dist/build-info.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.2.0-nightly.
|
|
3
|
-
"buildId": "
|
|
4
|
-
"commit": "
|
|
2
|
+
"version": "1.2.0-nightly.48",
|
|
3
|
+
"buildId": "3f9ee2279814",
|
|
4
|
+
"commit": "c319f766cc541c22bd6327fe6cf7b9e3fdfc1945",
|
|
5
5
|
"dirty": true,
|
|
6
|
-
"builtAt": "2026-10-
|
|
6
|
+
"builtAt": "2026-10-06T07:13:59.670Z",
|
|
7
7
|
"capabilities": [
|
|
8
8
|
"managed-cli.setup-v1",
|
|
9
9
|
"workspace.enroll.preserve-profile-v1",
|
package/dist/session/acp.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/session/acp.js
CHANGED
|
@@ -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';
|
package/dist/session/arbiter.js
CHANGED
|
@@ -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;
|
package/dist/session/control.js
CHANGED
|
@@ -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())
|
|
@@ -29,6 +29,7 @@ type EventDraft = Omit<ConversationEventV1, 'schemaVersion' | 'roleId' | 'eventI
|
|
|
29
29
|
export declare class ConversationEventStore {
|
|
30
30
|
private readonly dir;
|
|
31
31
|
private nextSeq;
|
|
32
|
+
private firstAvailableSeq?;
|
|
32
33
|
private segments;
|
|
33
34
|
private tail;
|
|
34
35
|
private readonly listeners;
|
|
@@ -81,7 +82,6 @@ export declare class ConversationEventStore {
|
|
|
81
82
|
private trackPromptState;
|
|
82
83
|
private segmentFd;
|
|
83
84
|
private writeManifest;
|
|
84
|
-
private firstStoredSeq;
|
|
85
85
|
private eventsAfter;
|
|
86
86
|
private markDegraded;
|
|
87
87
|
}
|
|
@@ -24,6 +24,7 @@ export class IdempotencyConflictError extends Error {
|
|
|
24
24
|
export class ConversationEventStore {
|
|
25
25
|
dir;
|
|
26
26
|
nextSeq = 1;
|
|
27
|
+
firstAvailableSeq;
|
|
27
28
|
segments = [];
|
|
28
29
|
tail = [];
|
|
29
30
|
listeners = new Set();
|
|
@@ -72,6 +73,7 @@ export class ConversationEventStore {
|
|
|
72
73
|
const fd = this.segmentFd(Buffer.byteLength(line));
|
|
73
74
|
writeSync(fd, line);
|
|
74
75
|
fsyncSync(fd);
|
|
76
|
+
this.firstAvailableSeq ??= event.seq;
|
|
75
77
|
this.nextSeq++;
|
|
76
78
|
this.activeBytes += Buffer.byteLength(line);
|
|
77
79
|
this.tail.push(event);
|
|
@@ -101,7 +103,10 @@ export class ConversationEventStore {
|
|
|
101
103
|
const events = this.eventsAfter(after, limit + 1);
|
|
102
104
|
const hasMore = events.length > limit;
|
|
103
105
|
const pageEvents = hasMore ? events.slice(0, limit) : events;
|
|
104
|
-
|
|
106
|
+
// This single-writer store never removes retained segments. Recovery and
|
|
107
|
+
// append already know the first readable sequence; paging the memory tail
|
|
108
|
+
// must not parse the first segment again, especially while skipping replay.
|
|
109
|
+
const firstStored = this.firstAvailableSeq;
|
|
105
110
|
return {
|
|
106
111
|
events: pageEvents,
|
|
107
112
|
...(firstStored !== undefined ? { firstAvailableCursor: String(firstStored) } : {}),
|
|
@@ -159,6 +164,7 @@ export class ConversationEventStore {
|
|
|
159
164
|
let maxSeq = 0;
|
|
160
165
|
for (const segment of this.segments) {
|
|
161
166
|
for (const event of this.readSegment(segment)) {
|
|
167
|
+
this.firstAvailableSeq ??= event.seq;
|
|
162
168
|
maxSeq = Math.max(maxSeq, event.seq);
|
|
163
169
|
this.tail.push(event);
|
|
164
170
|
if (this.tail.length > TAIL_EVENTS)
|
|
@@ -308,16 +314,6 @@ export class ConversationEventStore {
|
|
|
308
314
|
}
|
|
309
315
|
}
|
|
310
316
|
// ── reads ──────────────────────────────────────────────────────────────────
|
|
311
|
-
firstStoredSeq() {
|
|
312
|
-
if (this.tail.length && this.tail[0].seq === 1)
|
|
313
|
-
return 1;
|
|
314
|
-
for (const segment of this.segments) {
|
|
315
|
-
const events = this.readSegment(segment);
|
|
316
|
-
if (events.length)
|
|
317
|
-
return events[0].seq;
|
|
318
|
-
}
|
|
319
|
-
return this.tail[0]?.seq;
|
|
320
|
-
}
|
|
321
317
|
eventsAfter(after, limit) {
|
|
322
318
|
// Serve from the in-memory tail whenever the range allows it.
|
|
323
319
|
if (this.tail.length && after >= this.tail[0].seq - 1)
|
|
@@ -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;
|
package/dist/session/types.d.ts
CHANGED
|
@@ -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';
|
package/dist/web/server.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "1.2.0-nightly.48",
|
|
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.
|
|
45
|
-
"@ours.network/mcp": "1.2.0-nightly.
|
|
46
|
-
"@ours.network/sdk": "3.8.1-nightly.
|
|
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.
|
|
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",
|