@ours.network/fleet 1.2.0-nightly.62 → 1.2.0-nightly.64

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 (41) hide show
  1. package/README.md +6 -0
  2. package/dist/application/conversation-history.js +4 -1
  3. package/dist/application/conversation-role-observer.d.ts +62 -0
  4. package/dist/application/conversation-role-observer.js +206 -0
  5. package/dist/application/role-removal-service.js +1 -1
  6. package/dist/application/session-control.d.ts +31 -17
  7. package/dist/application/session-control.js +148 -33
  8. package/dist/build-info.json +4 -4
  9. package/dist/notifications/fleet-producer.js +4 -4
  10. package/dist/notifications/target-cleanup.d.ts +2 -0
  11. package/dist/notifications/target-cleanup.js +24 -2
  12. package/dist/ops.js +1 -1
  13. package/dist/session/acp.d.ts +8 -1
  14. package/dist/session/acp.js +97 -43
  15. package/dist/session/arbiter.d.ts +4 -0
  16. package/dist/session/arbiter.js +18 -0
  17. package/dist/session/codex-app-server.d.ts +7 -1
  18. package/dist/session/codex-app-server.js +60 -9
  19. package/dist/session/control.d.ts +5 -2
  20. package/dist/session/control.js +145 -20
  21. package/dist/session/conversation-observation.d.ts +25 -0
  22. package/dist/session/conversation-observation.js +100 -0
  23. package/dist/session/conversation-replay.d.ts +50 -0
  24. package/dist/session/conversation-replay.js +203 -0
  25. package/dist/session/conversation-store.d.ts +14 -0
  26. package/dist/session/conversation-store.js +201 -17
  27. package/dist/session/conversation-types.d.ts +29 -1
  28. package/dist/session/types.d.ts +6 -0
  29. package/dist/subscriptions/login.d.ts +1 -0
  30. package/dist/subscriptions/login.js +30 -5
  31. package/dist/web/auth.d.ts +2 -0
  32. package/dist/web/auth.js +10 -1
  33. package/dist/web/conversation-stream-codec.d.ts +23 -0
  34. package/dist/web/conversation-stream-codec.js +110 -0
  35. package/dist/web/conversation-stream.d.ts +25 -0
  36. package/dist/web/conversation-stream.js +231 -0
  37. package/dist/web/runtime.js +12 -2
  38. package/dist/web/server.d.ts +2 -0
  39. package/dist/web/server.js +2 -0
  40. package/docs/conversation-stream.md +176 -0
  41. package/package.json +2 -1
@@ -73,13 +73,27 @@ export class ClaudeLogin extends BaseLogin {
73
73
  child;
74
74
  output = '';
75
75
  submitted = false;
76
+ continued = false;
76
77
  /** Resolves with the CLI exit status once it exits. */
77
78
  exited;
78
79
  constructor(owner, bins, home, profileId) {
79
80
  super('claude', owner, 'paste_code');
80
- const child = spawn(bins.script, ['-qefc', `${shellQuote(bins.claude)} auth login`, '/dev/null'], {
81
- env: profileEnv('CLAUDE_CONFIG_DIR', profileId, home), stdio: ['pipe', 'pipe', 'pipe'],
81
+ const darwin = process.platform === 'darwin';
82
+ const args = darwin
83
+ ? ['-q', '/dev/null', bins.claude, 'auth', 'login']
84
+ : ['-qefc', `${shellQuote(bins.claude)} auth login`, '/dev/null'];
85
+ // BSD script accepts a real pipe, but rejects Node's socket-backed stdin.
86
+ // Own the pipeline as one process group so cancellation also stops cat.
87
+ const child = spawn(darwin ? '/bin/sh' : bins.script, darwin
88
+ ? ['-c', '/bin/cat 3>&- | { "$@" 3>&-; result=$?; printf "%s\\n" "$result" >&3; exit "$result"; }', 'fleet-claude-login', bins.script, ...args] : args, {
89
+ detached: darwin,
90
+ env: profileEnv('CLAUDE_CONFIG_DIR', profileId, home),
91
+ stdio: darwin ? ['pipe', 'pipe', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'],
82
92
  });
93
+ // The terminal has exited; release cat's input so the shell can finish.
94
+ // Only the shell owns fd 3. Final close remains the authoritative status.
95
+ if (darwin)
96
+ child.stdio[3]?.once('data', () => child.stdin.end());
83
97
  this.child = child;
84
98
  child.stdin.on('error', () => { });
85
99
  const onData = (chunk) => {
@@ -98,14 +112,16 @@ export class ClaudeLogin extends BaseLogin {
98
112
  }
99
113
  }
100
114
  // Claude asks for Enter after success; the answer carries no secret.
101
- if (this.submitted && /press enter/i.test(this.output))
115
+ if (this.submitted && !this.continued && /press enter/i.test(this.output)) {
116
+ this.continued = true;
102
117
  child.stdin.write('\r');
118
+ }
103
119
  };
104
120
  child.stdout.on('data', onData);
105
121
  child.stderr.on('data', onData);
106
122
  this.exited = new Promise(resolve => {
107
123
  child.once('error', () => { this.end('failed', 'could not start the sign-in'); resolve(null); });
108
- child.once('close', code => resolve(code));
124
+ child.once('close', code => { this.child = undefined; resolve(code); });
109
125
  });
110
126
  }
111
127
  submitCode(code) {
@@ -118,7 +134,16 @@ export class ClaudeLogin extends BaseLogin {
118
134
  this.output = '';
119
135
  this.child?.stdin.write(`${code}\r`);
120
136
  }
121
- stop() { this.child?.kill('SIGTERM'); }
137
+ stop() {
138
+ if (process.platform === 'darwin' && this.child?.pid) {
139
+ try {
140
+ process.kill(-this.child.pid, 'SIGTERM');
141
+ }
142
+ catch { /* already exited */ }
143
+ }
144
+ else
145
+ this.child?.kill('SIGTERM');
146
+ }
122
147
  }
123
148
  /** Codex device-code login through the app-server's supported account API. */
124
149
  export class CodexDeviceLogin extends BaseLogin {
@@ -65,6 +65,8 @@ export declare class WebAuth {
65
65
  anonymous(request: FastifyRequest): BrowserSession;
66
66
  resume(request: FastifyRequest): AuthResult;
67
67
  authenticate(request: FastifyRequest, mutation?: boolean): BrowserSession;
68
+ /** Check a transport's opening lease without refreshing cookie idle activity. */
69
+ sessionValid(session: Pick<BrowserSession, 'id' | 'absoluteExpiresAt'>): boolean;
68
70
  logout(request: FastifyRequest): void;
69
71
  mintTicket(request: FastifyRequest, purpose: Ticket['purpose'], roleId?: string): {
70
72
  ticket: string;
package/dist/web/auth.js CHANGED
@@ -136,7 +136,7 @@ export class WebAuth {
136
136
  const id = parseCookies(request.headers.cookie ?? '').ofs_session;
137
137
  const session = id ? this.sessions.get(id) : undefined;
138
138
  const now = this.now();
139
- if (!session || now > session.absoluteExpiresAt || now - session.lastSeenAt > 30 * 60_000) {
139
+ if (!session || !this.sessionValid(session)) {
140
140
  if (id)
141
141
  this.removeSession(id);
142
142
  throw new FleetError('unauthorized', 'browser session is missing or expired');
@@ -150,6 +150,15 @@ export class WebAuth {
150
150
  session.lastSeenAt = now;
151
151
  return session;
152
152
  }
153
+ /** Check a transport's opening lease without refreshing cookie idle activity. */
154
+ sessionValid(session) {
155
+ const current = this.sessions.get(session.id), now = this.now();
156
+ if (!current || now >= session.absoluteExpiresAt)
157
+ return false;
158
+ if (session.id.startsWith('workspace:'))
159
+ return Boolean(this.workspaceDevices?.valid(session.id.slice(10)));
160
+ return now < current.absoluteExpiresAt && now - current.lastSeenAt < 30 * 60_000;
161
+ }
153
162
  logout(request) {
154
163
  const session = this.authenticate(request, true);
155
164
  const deviceId = this.sessionDevices.get(session.id);
@@ -0,0 +1,23 @@
1
+ /** Public v1 wire contract. Kept free of Node APIs so browser consumers can share it. */
2
+ export declare const MAX_FRAME_BYTES: number;
3
+ export declare const MAX_ASSEMBLY_BYTES: number;
4
+ export declare const MAX_PENDING_BYTES: number;
5
+ export type ConversationRecordType = 'ready' | 'ledger' | 'state' | 'role' | 'resync' | 'keepalive';
6
+ export interface ConversationStreamRecord {
7
+ version: 1;
8
+ type: ConversationRecordType;
9
+ payload: unknown;
10
+ }
11
+ /** A payload is the logical record; SSE/JSON envelopes do not consume its assembly budget. */
12
+ export declare function encodeConversationRecord(type: ConversationRecordType, payload: unknown, id: string): Generator<string>;
13
+ /** Strict incremental decoder: one assembly, no partial publication, UTF-8 split safe. */
14
+ export declare class ConversationStreamDecoder {
15
+ private readonly publish;
16
+ private decoder;
17
+ private pending;
18
+ private assembly?;
19
+ constructor(publish: (record: ConversationStreamRecord) => void);
20
+ push(bytes: Uint8Array): void;
21
+ finish(): void;
22
+ private accept;
23
+ }
@@ -0,0 +1,110 @@
1
+ /** Public v1 wire contract. Kept free of Node APIs so browser consumers can share it. */
2
+ export const MAX_FRAME_BYTES = 512 * 1024;
3
+ export const MAX_ASSEMBLY_BYTES = 16 * 1024 * 1024;
4
+ export const MAX_PENDING_BYTES = 8 * 1024 * 1024;
5
+ const utf8 = new TextEncoder();
6
+ const types = new Set(['ready', 'ledger', 'state', 'role', 'resync', 'keepalive']);
7
+ const frame = (value) => `event: conversation\ndata: ${JSON.stringify(value)}\n\n`;
8
+ const identity = (payload) => ({ generation: payload?.sessionGeneration ?? payload?.snapshot?.sessionGeneration,
9
+ revision: payload?.stateRevision ?? payload?.snapshot?.stateRevision ?? payload?.roleRevision });
10
+ /** A payload is the logical record; SSE/JSON envelopes do not consume its assembly budget. */
11
+ export function* encodeConversationRecord(type, payload, id) {
12
+ const json = JSON.stringify(payload), totalBytes = utf8.encode(json).byteLength;
13
+ if (totalBytes > MAX_ASSEMBLY_BYTES)
14
+ throw new Error('conversation record exceeds assembly limit');
15
+ const direct = frame({ version: 1, type, payload });
16
+ if (utf8.encode(direct).byteLength <= MAX_FRAME_BYTES) {
17
+ yield direct;
18
+ return;
19
+ }
20
+ const position = identity(payload);
21
+ yield frame({ version: 1, type: 'start', id, recordType: type, totalBytes, ...position });
22
+ // At most 48K UTF-16 units: even worst-case JSON escapes fit a 512 KiB frame.
23
+ let index = 0;
24
+ for (let offset = 0; offset < json.length;) {
25
+ let end = Math.min(json.length, offset + 48 * 1024);
26
+ if (end < json.length && /[\uD800-\uDBFF]/.test(json[end - 1]))
27
+ end--;
28
+ yield frame({ version: 1, type: 'chunk', id, index: index++, data: json.slice(offset, end) });
29
+ offset = end;
30
+ }
31
+ yield frame({ version: 1, type: 'commit', id, totalBytes, ...position });
32
+ }
33
+ /** Strict incremental decoder: one assembly, no partial publication, UTF-8 split safe. */
34
+ export class ConversationStreamDecoder {
35
+ publish;
36
+ decoder = new TextDecoder('utf-8', { fatal: true });
37
+ pending = '';
38
+ assembly;
39
+ constructor(publish) {
40
+ this.publish = publish;
41
+ }
42
+ push(bytes) {
43
+ try {
44
+ this.pending += this.decoder.decode(bytes, { stream: true });
45
+ for (;;) {
46
+ const end = this.pending.indexOf('\n\n');
47
+ if (end < 0)
48
+ break;
49
+ const text = this.pending.slice(0, end + 2);
50
+ this.pending = this.pending.slice(end + 2);
51
+ if (utf8.encode(text).byteLength > MAX_FRAME_BYTES)
52
+ throw new Error('frame limit exceeded');
53
+ if (!text.startsWith('event: conversation\ndata: '))
54
+ throw new Error('invalid conversation frame');
55
+ this.accept(JSON.parse(text.slice(26, -2)));
56
+ }
57
+ if (utf8.encode(this.pending).byteLength > MAX_FRAME_BYTES)
58
+ throw new Error('frame limit exceeded');
59
+ }
60
+ catch (error) {
61
+ this.pending = '';
62
+ this.assembly = undefined;
63
+ throw error;
64
+ }
65
+ }
66
+ finish() {
67
+ const rest = this.decoder.decode();
68
+ if (rest || this.pending || this.assembly)
69
+ throw new Error('incomplete conversation stream');
70
+ }
71
+ accept(value) {
72
+ if (value?.version !== 1)
73
+ throw new Error('unsupported conversation version');
74
+ if (types.has(value.type)) {
75
+ if (this.assembly)
76
+ throw new Error('overlapping assembly');
77
+ this.publish(value);
78
+ return;
79
+ }
80
+ if (value.type === 'start') {
81
+ if (this.assembly)
82
+ throw new Error('overlapping assembly');
83
+ if (typeof value.id !== 'string' || !types.has(value.recordType) || !Number.isSafeInteger(value.totalBytes) || value.totalBytes < 1 || value.totalBytes > MAX_ASSEMBLY_BYTES)
84
+ throw new Error('invalid assembly length/type');
85
+ this.assembly = { id: value.id, type: value.recordType, total: value.totalBytes, bytes: 0, index: 0, parts: [], generation: value.generation, revision: value.revision };
86
+ return;
87
+ }
88
+ const assembly = this.assembly;
89
+ if (!assembly || value.id !== assembly.id)
90
+ throw new Error('assembly identity mismatch');
91
+ if (value.type === 'chunk') {
92
+ if (value.index !== assembly.index++ || typeof value.data !== 'string')
93
+ throw new Error('assembly chunk order');
94
+ if (!value.data.length)
95
+ throw new Error('assembly empty chunk');
96
+ assembly.bytes += utf8.encode(value.data).byteLength;
97
+ if (assembly.bytes > assembly.total)
98
+ throw new Error('assembly length exceeded');
99
+ assembly.parts.push(value.data);
100
+ return;
101
+ }
102
+ if (value.type !== 'commit' || value.totalBytes !== assembly.total || assembly.bytes !== assembly.total)
103
+ throw new Error('assembly length mismatch');
104
+ const payload = JSON.parse(assembly.parts.join('')), position = identity(payload);
105
+ if (value.generation !== assembly.generation || value.revision !== assembly.revision || position.generation !== assembly.generation || position.revision !== assembly.revision)
106
+ throw new Error('assembly revision mismatch');
107
+ this.assembly = undefined;
108
+ this.publish({ version: 1, type: assembly.type, payload });
109
+ }
110
+ }
@@ -0,0 +1,25 @@
1
+ import type { Writable } from 'node:stream';
2
+ import { type ConversationRecordType } from './conversation-stream-codec.js';
3
+ /** One active logical record; only waiting encoded output consumes the pending budget. */
4
+ export declare class ConversationStreamWriter {
5
+ private readonly output;
6
+ private readonly failed;
7
+ private queue;
8
+ private active?;
9
+ private waiting;
10
+ private ended;
11
+ private sequence;
12
+ pendingBytes: number;
13
+ get busy(): boolean;
14
+ constructor(output: Writable, failed: (reason: string) => void);
15
+ private error;
16
+ private drain;
17
+ send(type: ConversationRecordType, payload: unknown): boolean;
18
+ private flush;
19
+ private fail;
20
+ close(): void;
21
+ }
22
+ import type { FastifyInstance } from 'fastify';
23
+ import type { WebServices } from './server.js';
24
+ import type { WebAuth } from './auth.js';
25
+ export declare function registerConversationStream(app: FastifyInstance, services: WebServices, auth: WebAuth): void;
@@ -0,0 +1,231 @@
1
+ import { encodeConversationRecord, MAX_PENDING_BYTES } from './conversation-stream-codec.js';
2
+ /** One active logical record; only waiting encoded output consumes the pending budget. */
3
+ export class ConversationStreamWriter {
4
+ output;
5
+ failed;
6
+ queue = [];
7
+ active;
8
+ waiting = false;
9
+ ended = false;
10
+ sequence = 0;
11
+ pendingBytes = 0;
12
+ get busy() { return Boolean(this.active); }
13
+ constructor(output, failed) {
14
+ this.output = output;
15
+ this.failed = failed;
16
+ output.on('drain', this.drain);
17
+ output.once('error', this.error);
18
+ }
19
+ error = () => this.fail('transport-error');
20
+ drain = () => { this.waiting = false; this.flush(); };
21
+ send(type, payload) {
22
+ if (this.ended)
23
+ return false;
24
+ const id = String(++this.sequence);
25
+ try {
26
+ if (!this.active) {
27
+ this.active = encodeConversationRecord(type, payload, id);
28
+ this.flush();
29
+ }
30
+ else {
31
+ let bytes = 0;
32
+ for (const frame of encodeConversationRecord(type, payload, id))
33
+ bytes += Buffer.byteLength(frame);
34
+ if (this.pendingBytes + bytes > MAX_PENDING_BYTES) {
35
+ this.fail('output-overflow');
36
+ return false;
37
+ }
38
+ this.queue.push({ type, payload, id, bytes });
39
+ this.pendingBytes += bytes;
40
+ }
41
+ return !this.ended;
42
+ }
43
+ catch {
44
+ this.fail('record-too-large');
45
+ return false;
46
+ }
47
+ }
48
+ flush() {
49
+ if (this.ended || this.waiting)
50
+ return;
51
+ try {
52
+ while (this.active) {
53
+ const next = this.active.next();
54
+ if (!next.done) {
55
+ if (!this.output.write(next.value)) {
56
+ this.waiting = true;
57
+ return;
58
+ }
59
+ }
60
+ else {
61
+ const queued = this.queue.shift();
62
+ this.active = queued ? encodeConversationRecord(queued.type, queued.payload, queued.id) : undefined;
63
+ if (queued)
64
+ this.pendingBytes -= queued.bytes;
65
+ }
66
+ }
67
+ }
68
+ catch {
69
+ this.fail('record-too-large');
70
+ }
71
+ }
72
+ fail(reason) {
73
+ if (this.ended)
74
+ return;
75
+ // A resync is only useful at a frame boundary with writable capacity.
76
+ if (!this.waiting && !this.output.destroyed) {
77
+ const frame = encodeConversationRecord('resync', { reason, capability: 'degraded', retry: reason !== 'record-too-large' }, 'terminal').next().value;
78
+ if (frame)
79
+ this.output.write(frame);
80
+ }
81
+ const blocked = this.waiting;
82
+ this.close();
83
+ this.failed(reason);
84
+ if (blocked)
85
+ this.output.destroy();
86
+ }
87
+ close() {
88
+ if (this.ended)
89
+ return;
90
+ this.ended = true;
91
+ this.active = undefined;
92
+ this.queue = [];
93
+ this.pendingBytes = 0;
94
+ this.output.off('drain', this.drain);
95
+ this.output.off('error', this.error);
96
+ }
97
+ }
98
+ import { FleetError } from '../application/errors.js';
99
+ export function registerConversationStream(app, services, auth) {
100
+ const connections = new Set();
101
+ app.addHook('preClose', async () => { for (const close of connections)
102
+ close(); });
103
+ app.get('/api/v1/roles/:id/conversation/events', async (request, reply) => {
104
+ const browser = auth.authenticate(request);
105
+ const generation = request.query.expectedGeneration, rawAfter = request.query.after ?? '0', after = Number(rawAfter);
106
+ if (typeof generation !== 'string' || !generation.trim() || generation.length > 512 || !/^\d+$/.test(rawAfter) || !Number.isSafeInteger(after))
107
+ throw new FleetError('invalid_request', 'expectedGeneration and a nonnegative integer after cursor are required');
108
+ if (!services.observeRole)
109
+ throw new FleetError('capability_unavailable', 'Complete conversation observation is unavailable');
110
+ let ended = false, owner, follow;
111
+ let binding, timer;
112
+ let epoch = 0, revision = -1, lifetime, available = false;
113
+ let abort = new AbortController();
114
+ const close = () => {
115
+ if (ended)
116
+ return;
117
+ ended = true;
118
+ epoch++;
119
+ abort.abort();
120
+ if (timer)
121
+ clearInterval(timer);
122
+ binding?.();
123
+ writer.close();
124
+ follow?.close();
125
+ follow = undefined;
126
+ owner?.dispose();
127
+ connections.delete(close);
128
+ reply.raw.end();
129
+ };
130
+ const writer = new ConversationStreamWriter(reply.raw, close);
131
+ const resync = (reason, retry = true) => writer.send('resync', { reason, retry, capability: 'degraded' });
132
+ const valid = () => { if (auth.sessionValid(browser))
133
+ return true; writer.send('resync', { reason: 'auth-expired', retry: browser.rotating ? 'authenticate' : 'reauthenticate', capability: 'auth-expired' }); close(); return false; };
134
+ const stopFollow = () => { epoch++; abort.abort(); abort = new AbortController(); follow?.close(); follow = undefined; };
135
+ const open = async () => {
136
+ const current = ++epoch, signal = abort.signal;
137
+ const active = () => !ended && current === epoch && !signal.aborted;
138
+ try {
139
+ const control = await services.session(request.params.id);
140
+ if (!active() || !valid())
141
+ return;
142
+ const descriptor = await control.describe(owner);
143
+ if (!active() || !valid())
144
+ return;
145
+ if (!control.followConversation || !descriptor.features.includes('conversation_live_complete')) {
146
+ resync('capability-unavailable', false);
147
+ return;
148
+ }
149
+ const handle = await control.followConversation({ controller: false, expectedGeneration: generation, after, signal, roleObservation: owner,
150
+ onReplay: page => { if (active() && valid()) {
151
+ for (const event of page.events)
152
+ writer.send('ledger', event);
153
+ writer.send('ledger', { coveredThrough: page.coveredThrough });
154
+ } },
155
+ onReady: ready => {
156
+ if (active() && valid()) {
157
+ writer.send('state', ready.snapshot);
158
+ writer.send('ready', { sessionGeneration: ready.snapshot.sessionGeneration, stateRevision: ready.snapshot.stateRevision,
159
+ ledgerBarrier: ready.snapshot.ledgerBarrier, coveredThrough: ready.coveredThrough, capability: 'complete', authorizedUntil: browser.absoluteExpiresAt });
160
+ }
161
+ },
162
+ onEvent: event => { if (active() && valid()) {
163
+ writer.send('ledger', event);
164
+ writer.send('ledger', { coveredThrough: event.seq });
165
+ } },
166
+ onState: state => { if (active() && valid())
167
+ writer.send('state', state); },
168
+ onClose: reason => { if (active()) {
169
+ epoch++;
170
+ abort.abort();
171
+ follow = undefined;
172
+ resync(reason ?? 'control-closed', !reason?.includes('assembly limit'));
173
+ } },
174
+ });
175
+ if (!active())
176
+ handle.close();
177
+ else
178
+ follow = handle;
179
+ }
180
+ catch {
181
+ if (active())
182
+ resync('observation-unavailable');
183
+ }
184
+ };
185
+ const changed = (state) => {
186
+ if (ended || state.roleRevision <= revision)
187
+ return;
188
+ revision = state.roleRevision;
189
+ const { stateDir: _private, ...publicState } = state;
190
+ writer.send('role', publicState);
191
+ const replaced = lifetime !== undefined && lifetime !== state.roleLifetime;
192
+ const nextAvailable = state.availability === 'available' && state.health === 'healthy';
193
+ if (replaced || !nextAvailable)
194
+ stopFollow();
195
+ if (state.health !== 'healthy')
196
+ resync(state.failure ?? 'role-observation-unavailable', false);
197
+ else if (replaced)
198
+ resync('role-replaced');
199
+ const shouldOpen = nextAvailable && (!available || replaced);
200
+ lifetime = state.roleLifetime;
201
+ available = nextAvailable;
202
+ if (shouldOpen)
203
+ void open();
204
+ };
205
+ reply.hijack();
206
+ for (const [name, value] of Object.entries(reply.getHeaders()))
207
+ if (value !== undefined)
208
+ reply.raw.setHeader(name, Array.isArray(value) ? value : String(value));
209
+ reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no' });
210
+ reply.raw.flushHeaders();
211
+ reply.raw.once('close', close);
212
+ connections.add(close);
213
+ binding = auth.bindTransport(browser, () => { writer.send('resync', { reason: 'auth-expired', capability: 'auth-expired', retry: browser.rotating ? 'authenticate' : 'reauthenticate' }); close(); });
214
+ try {
215
+ // Factory callbacks can be synchronous; defer handling until the owner can be borrowed.
216
+ owner = services.observeRole(request.params.id, state => queueMicrotask(() => changed(state)));
217
+ if (ended) {
218
+ owner.dispose();
219
+ return;
220
+ }
221
+ changed(owner.snapshot());
222
+ timer = setInterval(() => { if (valid())
223
+ writer.send('keepalive', { at: Date.now() }); }, 1_000);
224
+ timer.unref();
225
+ }
226
+ catch {
227
+ resync('role-observation-unavailable', false);
228
+ close();
229
+ }
230
+ });
231
+ }
@@ -1,3 +1,4 @@
1
+ import { ConversationRoleObserver } from '../application/conversation-role-observer.js';
1
2
  import { replaceFileAtomically } from '../atomic-file.js';
2
3
  import { validateAccountOrigin } from '../account-origin.js';
3
4
  import { startWorkspaceTunnel } from './workspace-tunnel.js';
@@ -110,6 +111,13 @@ export async function startWebConsole(options) {
110
111
  },
111
112
  });
112
113
  const events = new FleetEventBus();
114
+ const roleMutations = new Set();
115
+ const signalRoleMutation = () => { for (const listener of roleMutations)
116
+ listener(); };
117
+ const observeRole = (roleId, listener) => new ConversationRoleObserver({
118
+ roleId, configPath: options.configPath,
119
+ subscribeMutations: changed => { roleMutations.add(changed); return () => { roleMutations.delete(changed); }; },
120
+ }, listener);
113
121
  const audit = new AuditSink();
114
122
  const watchdogConfigProvider = cachedConfigProvider(options.configPath);
115
123
  const log = options.log ?? (() => { });
@@ -149,6 +157,7 @@ export async function startWebConsole(options) {
149
157
  ? 'ready' : detail?.status.overall === 'attention' ? 'attention' : 'unknown';
150
158
  },
151
159
  onProgress: action => {
160
+ signalRoleMutation();
152
161
  events.publish('creation.changed', action, action.roleId);
153
162
  void audit.record({
154
163
  roleId: action.roleId, action: `creation.${action.state}`,
@@ -164,6 +173,7 @@ export async function startWebConsole(options) {
164
173
  repository, ops, configPath: options.configPath,
165
174
  status: async (roleId) => (await query.detail(roleId)).status,
166
175
  onProgress: receipt => {
176
+ signalRoleMutation();
167
177
  events.publish('action.changed', receipt, receipt.roleId);
168
178
  void audit.record({
169
179
  roleId: receipt.roleId, action: `lifecycle.${receipt.action}`,
@@ -185,7 +195,7 @@ export async function startWebConsole(options) {
185
195
  let server;
186
196
  try {
187
197
  server = await buildWebServer({
188
- query, repository, logs, commands, creation, removal, audit, events, watchdogs, configuration,
198
+ query, repository, logs, commands, creation, removal, audit, events, watchdogs, configuration, observeRole,
189
199
  taskRooms: new TaskRoomApplicationService(options.configPath, { binPath: () => options.binPath }),
190
200
  roomLayouts: new RoomLayoutDefinitions(options.configPath),
191
201
  layoutBindings: new LayoutBindingGrants(new RoomLayoutService(options.configPath).supervisor()),
@@ -218,7 +228,7 @@ export async function startWebConsole(options) {
218
228
  const dir = repository.stateDir(role);
219
229
  if (!dir)
220
230
  throw new FleetError('control_unavailable', 'role state directory is unavailable');
221
- return new RoleSessionControlAdapter(dir);
231
+ return new RoleSessionControlAdapter(dir, undefined, undefined, listener => observeRole(roleId, listener));
222
232
  }
223
233
  throw new FleetError('capability_unavailable', 'role session backend is unavailable');
224
234
  },
@@ -1,3 +1,4 @@
1
+ import type { ConversationRoleObservation, ConversationRoleState } from '../application/conversation-role-observer.js';
1
2
  import { type InitAnswers } from '../init-wizard.js';
2
3
  import { SupervisorOursTools } from '../application/supervisor-ours-tools.js';
3
4
  import { type FastifyInstance } from 'fastify';
@@ -23,6 +24,7 @@ import type { OfferedModels } from '../subscriptions/cli.js';
23
24
  import type { SubscriptionService } from '../subscriptions/service.js';
24
25
  import type { LayoutBindingGrants } from '../rooms-tasks/layout-binding-grants.js';
25
26
  export interface WebServices {
27
+ observeRole?(roleId: string, listener: (state: ConversationRoleState) => void): ConversationRoleObservation;
26
28
  layoutBindings?: Pick<LayoutBindingGrants, 'control'>;
27
29
  query: FleetQueryService;
28
30
  repository: RoleRepository;
@@ -1,3 +1,4 @@
1
+ import { registerConversationStream } from './conversation-stream.js';
1
2
  import { conversationHasAttachment } from '../application/conversation-history.js';
2
3
  import { readDeliveredFile } from '../file-delivery/store.js';
3
4
  import { WorkspaceDeviceAuthError } from './workspace-devices.js';
@@ -88,6 +89,7 @@ export async function buildWebServer(services, boundary, options = {}) {
88
89
  reply.header('X-Ours-Request-Id', request.id);
89
90
  reply.code(statusFor(fleetError.code)).send({ error: fleetError.toJSON() });
90
91
  });
92
+ registerConversationStream(app, services, auth);
91
93
  app.post('/api/v1/auth/exchange', async (request, reply) => {
92
94
  const { session, device } = auth.exchange(request);
93
95
  setAuthCookies(reply, session.id, device.token, auth.secureCookies);