@ours.network/fleet 1.2.0-nightly.63 → 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 (34) hide show
  1. package/README.md +3 -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/session-control.d.ts +31 -17
  6. package/dist/application/session-control.js +148 -33
  7. package/dist/build-info.json +4 -4
  8. package/dist/session/acp.d.ts +8 -1
  9. package/dist/session/acp.js +97 -43
  10. package/dist/session/arbiter.d.ts +4 -0
  11. package/dist/session/arbiter.js +18 -0
  12. package/dist/session/codex-app-server.d.ts +7 -1
  13. package/dist/session/codex-app-server.js +60 -9
  14. package/dist/session/control.d.ts +5 -2
  15. package/dist/session/control.js +145 -20
  16. package/dist/session/conversation-observation.d.ts +25 -0
  17. package/dist/session/conversation-observation.js +100 -0
  18. package/dist/session/conversation-replay.d.ts +50 -0
  19. package/dist/session/conversation-replay.js +203 -0
  20. package/dist/session/conversation-store.d.ts +14 -0
  21. package/dist/session/conversation-store.js +201 -17
  22. package/dist/session/conversation-types.d.ts +29 -1
  23. package/dist/session/types.d.ts +6 -0
  24. package/dist/web/auth.d.ts +2 -0
  25. package/dist/web/auth.js +10 -1
  26. package/dist/web/conversation-stream-codec.d.ts +23 -0
  27. package/dist/web/conversation-stream-codec.js +110 -0
  28. package/dist/web/conversation-stream.d.ts +25 -0
  29. package/dist/web/conversation-stream.js +231 -0
  30. package/dist/web/runtime.js +12 -2
  31. package/dist/web/server.d.ts +2 -0
  32. package/dist/web/server.js +2 -0
  33. package/docs/conversation-stream.md +176 -0
  34. package/package.json +2 -1
package/README.md CHANGED
@@ -181,6 +181,9 @@ credentials and rotating refresh credentials; see
181
181
  owner-private Unix socket; local processes running as the same OS user are
182
182
  therefore inside the trust boundary.
183
183
 
184
+ The read-only [conversation event stream](docs/conversation-stream.md) documents
185
+ authentication deadlines, replay coverage, bounded framing and filesystem support.
186
+
184
187
  On first setup, the CLI requires an explicit access choice: `--password-file`
185
188
  or `--pairing` for protected access, or `--no-password` for intentional
186
189
  unprotected access. `--password-file` stores only a salted scrypt verifier in
@@ -68,6 +68,7 @@ export async function conversationTailPage(stateDir, request, snapshot, resumedS
68
68
  const events = [];
69
69
  let newest;
70
70
  let hasOlder = false;
71
+ const barrier = snapshot.ledgerBarrier;
71
72
  const pending = new Set(snapshot.pendingPermissionIds);
72
73
  let capabilities;
73
74
  const context = [];
@@ -94,6 +95,8 @@ export async function conversationTailPage(stateDir, request, snapshot, resumedS
94
95
  const seq = Number(event.seq);
95
96
  if (!Number.isSafeInteger(seq))
96
97
  return true;
98
+ if (barrier !== undefined && seq > barrier)
99
+ return true;
97
100
  newest ??= seq;
98
101
  if (seq < before && conversationEventVisible(event, snapshot.sessionGeneration, resumedSessionId)) {
99
102
  const inPage = !hasOlder && events.length < limit;
@@ -120,7 +123,7 @@ export async function conversationTailPage(stateDir, request, snapshot, resumedS
120
123
  context.reverse();
121
124
  return { events, snapshot, hasOlder, context, hasMore: false,
122
125
  ...(events.length ? { olderCursor: String(events[0].seq) } : {}),
123
- ...(before === Infinity && newest !== undefined ? { nextCursor: String(newest) } : {}) };
126
+ ...(before === Infinity && (barrier !== undefined || newest !== undefined) ? { nextCursor: String(barrier ?? newest) } : {}) };
124
127
  }
125
128
  /** Read the existing ACP session ledger without opening its writer/recovery store. */
126
129
  export async function resumedConversationPage(stateDir, request, live, expectedSessionId) {
@@ -0,0 +1,62 @@
1
+ import { type FSWatcher } from 'node:fs';
2
+ import { type SessionBackendId } from '../config.js';
3
+ export interface ConversationRoleState {
4
+ roleId: string;
5
+ /** Connection-owned ordering; never a session stateRevision. */
6
+ roleRevision: number;
7
+ roleLifetime: string;
8
+ availability: 'available' | 'unavailable' | 'missing';
9
+ fallbackHarness?: string;
10
+ backend?: SessionBackendId;
11
+ stateDir?: string;
12
+ health: 'healthy' | 'unsupported' | 'failed';
13
+ failure?: 'watch-unsupported' | 'watch-error' | 'watch-closed' | 'role-read-failed' | 'observer-disposed';
14
+ }
15
+ export interface ConversationRoleObservation {
16
+ snapshot(): ConversationRoleState;
17
+ subscribe(listener: (state: ConversationRoleState) => void): {
18
+ snapshot: ConversationRoleState;
19
+ unsubscribe(): void;
20
+ };
21
+ dispose(): void;
22
+ }
23
+ export type ObserveConversationRole = (listener: (state: ConversationRoleState) => void) => ConversationRoleObservation;
24
+ export interface ConversationRoleObserverOptions {
25
+ roleId: string;
26
+ configPath?: string;
27
+ permanentRoot?: string;
28
+ temporaryRoot?: string;
29
+ watch?: (path: string, listener: (event: string, filename: string | null) => void) => Pick<FSWatcher, 'on' | 'close'>;
30
+ subscribeMutations?: (listener: () => void) => () => void;
31
+ }
32
+ /** Native directory notifications are assumed reliable on qualified host/mount setups.
33
+ * No periodic reads or claim of detecting every silently dropped filesystem event.
34
+ */
35
+ export declare class ConversationRoleObserver implements ConversationRoleObservation {
36
+ private readonly options;
37
+ private readonly listener;
38
+ private state;
39
+ private watchers;
40
+ private detach?;
41
+ private disposed;
42
+ private readonly listeners;
43
+ private queued;
44
+ private fingerprint?;
45
+ private readonly configPath;
46
+ private readonly permanent;
47
+ private readonly temporary;
48
+ constructor(options: ConversationRoleObserverOptions, listener: (state: ConversationRoleState) => void);
49
+ snapshot(): ConversationRoleState;
50
+ subscribe(listener: (state: ConversationRoleState) => void): {
51
+ snapshot: ConversationRoleState;
52
+ unsubscribe: () => void;
53
+ };
54
+ private publish;
55
+ dispose(): void;
56
+ private release;
57
+ private fail;
58
+ private schedule;
59
+ private refresh;
60
+ private install;
61
+ private watchFailure;
62
+ }
@@ -0,0 +1,206 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { lstatSync, readFileSync, realpathSync, watch } from 'node:fs';
3
+ import { basename, dirname, join, resolve } from 'node:path';
4
+ import { parse } from 'yaml';
5
+ import { loadConfig, ROLE_NAME_RE, splitRootFor } from '../config.js';
6
+ import { agentsRoot, defaultConfigPath, tmpRoot } from '../paths.js';
7
+ import { controlSocketPath } from '../session/control.js';
8
+ function identity(path) {
9
+ try {
10
+ const s = lstatSync(path);
11
+ return `${s.dev}:${s.ino}:${s.birthtimeMs}`;
12
+ }
13
+ catch (error) {
14
+ if (error.code === 'ENOENT')
15
+ return;
16
+ throw error;
17
+ }
18
+ }
19
+ /** Native directory notifications are assumed reliable on qualified host/mount setups.
20
+ * No periodic reads or claim of detecting every silently dropped filesystem event.
21
+ */
22
+ export class ConversationRoleObserver {
23
+ options;
24
+ listener;
25
+ state;
26
+ watchers = new Map();
27
+ detach;
28
+ disposed = false;
29
+ listeners = new Set();
30
+ queued = false;
31
+ fingerprint;
32
+ configPath;
33
+ permanent;
34
+ temporary;
35
+ constructor(options, listener) {
36
+ this.options = options;
37
+ this.listener = listener;
38
+ if (!ROLE_NAME_RE.test(options.roleId))
39
+ throw new Error('invalid role id');
40
+ this.configPath = resolve(options.configPath ?? defaultConfigPath());
41
+ this.permanent = resolve(options.permanentRoot ?? agentsRoot(), options.roleId);
42
+ this.temporary = resolve(options.temporaryRoot ?? tmpRoot(), options.roleId);
43
+ this.state = { roleId: options.roleId, roleRevision: 0, roleLifetime: randomUUID(), availability: 'missing', health: 'healthy' };
44
+ this.refresh();
45
+ if (this.state.health === 'healthy')
46
+ this.detach = options.subscribeMutations?.(() => this.schedule());
47
+ }
48
+ snapshot() {
49
+ return this.disposed ? { ...this.state, health: 'failed', failure: 'observer-disposed' } : { ...this.state };
50
+ }
51
+ subscribe(listener) {
52
+ if (!this.disposed)
53
+ this.listeners.add(listener);
54
+ return { snapshot: this.snapshot(), unsubscribe: () => { this.listeners.delete(listener); } };
55
+ }
56
+ publish() {
57
+ this.listener(this.snapshot());
58
+ for (const listener of this.listeners)
59
+ listener(this.snapshot());
60
+ }
61
+ dispose() {
62
+ if (this.disposed)
63
+ return;
64
+ this.disposed = true;
65
+ this.release();
66
+ this.listeners.clear();
67
+ }
68
+ release() {
69
+ const retired = [...this.watchers.values()];
70
+ // Retire before close: native close events may arrive synchronously or later.
71
+ this.watchers.clear();
72
+ for (const watcher of retired)
73
+ watcher.handle.close();
74
+ this.detach?.();
75
+ this.detach = undefined;
76
+ }
77
+ fail(failure) {
78
+ if (this.disposed || this.state.health !== 'healthy')
79
+ return;
80
+ this.state = { ...this.state, roleRevision: this.state.roleRevision + 1, health: failure === 'watch-unsupported' ? 'unsupported' : 'failed', failure };
81
+ this.release();
82
+ this.publish();
83
+ }
84
+ schedule() {
85
+ if (this.queued || this.disposed || this.state.health !== 'healthy')
86
+ return;
87
+ this.queued = true;
88
+ queueMicrotask(() => { this.queued = false; if (!this.disposed && this.state.health === 'healthy')
89
+ this.refresh(); });
90
+ }
91
+ refresh() {
92
+ try {
93
+ // Install parents before reading, including absent children, so atomic rename
94
+ // and recreation cannot strand us watching an unlinked inode.
95
+ this.install();
96
+ if (this.state.health !== 'healthy')
97
+ return;
98
+ let configured;
99
+ const split = splitRootFor(this.configPath);
100
+ if (identity(this.configPath) && identity(split) && identity(join(split, 'agents')))
101
+ configured = loadConfig(this.configPath).roles.find(role => role.name === this.options.roleId);
102
+ const permanentId = identity(this.permanent), temporaryId = identity(this.temporary);
103
+ const stateDir = temporaryId ? realpathSync(this.temporary) : permanentId ? realpathSync(this.permanent) : undefined;
104
+ let temporary;
105
+ if (!configured && temporaryId && identity(join(this.temporary, 'role.yaml'))) {
106
+ const raw = parse(readFileSync(join(this.temporary, 'role.yaml'), 'utf8'));
107
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)
108
+ || (raw.session !== undefined && raw.session !== 'acp' && raw.session !== 'codex-app-server'))
109
+ throw new Error('invalid temporary role snapshot');
110
+ temporary = { session: raw.session ?? 'acp', harness: typeof raw.harness === 'string' ? raw.harness : 'claude-code' };
111
+ }
112
+ const backend = configured?.session ?? temporary?.session;
113
+ const fallbackHarness = configured?.harness ?? temporary?.harness;
114
+ const socket = stateDir ? identity(controlSocketPath(stateDir)) : undefined;
115
+ const tokenPath = stateDir && join(stateDir, '.control-token');
116
+ const token = tokenPath && identity(tokenPath) ? createHash('sha256').update(readFileSync(tokenPath)).digest('hex') : undefined;
117
+ const exists = Boolean(configured || permanentId || temporaryId);
118
+ const availability = !exists ? 'missing' : stateDir && socket && token && backend ? 'available' : 'unavailable';
119
+ const fingerprint = JSON.stringify([Boolean(configured), permanentId, temporaryId, backend, socket, token]);
120
+ const lifetime = this.fingerprint === fingerprint ? this.state.roleLifetime : randomUUID();
121
+ this.fingerprint = fingerprint;
122
+ const next = { roleId: this.options.roleId, roleRevision: this.state.roleRevision, roleLifetime: lifetime,
123
+ availability, fallbackHarness, backend, stateDir, health: 'healthy' };
124
+ if (JSON.stringify(next) !== JSON.stringify(this.state)) {
125
+ this.state = { ...next, roleRevision: this.state.roleRevision + 1 };
126
+ this.publish();
127
+ }
128
+ }
129
+ catch {
130
+ this.fail('role-read-failed');
131
+ }
132
+ }
133
+ install() {
134
+ const wanted = new Map();
135
+ const add = (dir, child) => {
136
+ if (child === undefined)
137
+ wanted.set(dir, null);
138
+ else if (wanted.get(dir) !== null) {
139
+ const names = wanted.get(dir) ?? new Set();
140
+ names.add(child);
141
+ wanted.set(dir, names);
142
+ }
143
+ };
144
+ const parents = (path, boundary) => {
145
+ while (path !== boundary && dirname(path) !== path) {
146
+ add(dirname(path), basename(path));
147
+ path = dirname(path);
148
+ }
149
+ };
150
+ const configRoot = splitRootFor(this.configPath);
151
+ const configBoundary = dirname(dirname(this.configPath));
152
+ parents(this.configPath, configBoundary);
153
+ for (const dir of [configRoot, ...['agents', 'brains', 'roles', 'agent_templates'].map(kind => join(configRoot, kind))]) {
154
+ parents(dir, configBoundary);
155
+ add(dir);
156
+ }
157
+ for (const dir of [this.permanent, this.temporary]) {
158
+ parents(dir, dirname(dirname(dirname(dir))));
159
+ for (const name of ['role.yaml', '.control-token', '.control.sock'])
160
+ add(dir, name);
161
+ const socket = controlSocketPath(dir);
162
+ parents(socket, dirname(dirname(socket)));
163
+ }
164
+ for (const [dir, current] of this.watchers) {
165
+ if (!wanted.has(dir) || identity(dir) !== current.identity) {
166
+ this.watchers.delete(dir);
167
+ current.handle.close();
168
+ }
169
+ }
170
+ for (const [dir, names] of wanted) {
171
+ const id = identity(dir);
172
+ if (!id)
173
+ continue;
174
+ const existing = this.watchers.get(dir);
175
+ if (existing) {
176
+ existing.names = names;
177
+ continue;
178
+ }
179
+ try {
180
+ const handle = (this.options.watch ?? ((path, listener) => watch(path, { persistent: false }, listener)))(dir, (_event, filename) => {
181
+ const entry = this.watchers.get(dir);
182
+ if (filename === null || !entry?.names || entry.names.has(String(filename)) || String(filename) === basename(dir))
183
+ this.schedule();
184
+ });
185
+ this.watchers.set(dir, { identity: id, names, handle });
186
+ handle.on('error', (error) => {
187
+ if (this.watchers.get(dir)?.handle === handle)
188
+ this.watchFailure(error);
189
+ });
190
+ handle.on('close', () => {
191
+ if (this.watchers.get(dir)?.handle !== handle)
192
+ return;
193
+ this.watchers.delete(dir);
194
+ this.fail('watch-closed');
195
+ });
196
+ }
197
+ catch (error) {
198
+ this.watchFailure(error);
199
+ return;
200
+ }
201
+ }
202
+ }
203
+ watchFailure(error) {
204
+ this.fail(['ENOSYS', 'ENOTSUP', 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM'].includes(error.code ?? '') ? 'watch-unsupported' : 'watch-error');
205
+ }
206
+ }
@@ -1,8 +1,10 @@
1
+ import type { ConversationRoleObservation, ConversationRoleState, ObserveConversationRole } from './conversation-role-observer.js';
1
2
  import { type ConversationTailPage } from './conversation-history.js';
2
3
  import type { AgentHistoryQuery } from '../agent-ours/correspondence.js';
3
- import { controlRequest } from '../session/control.js';
4
- import type { ConversationEventV1, ConversationSnapshot, PromptReceipt } from '../session/conversation-types.js';
4
+ import { controlRequest, followConversation } from '../session/control.js';
5
+ import type { ConversationEventV1, ConversationSnapshot, ConversationObservationState, PromptReceipt } from '../session/conversation-types.js';
5
6
  import type { SessionSnapshot } from '../session/types.js';
7
+ import type { PassiveConversationFollowRequest, ConversationReplayPage, ConversationReplayReady } from '../session/conversation-replay.js';
6
8
  import type { OutputPage, SendReceipt, SessionDescriptor } from './types.js';
7
9
  export interface ConversationPageView {
8
10
  events: ConversationEventV1[];
@@ -14,6 +16,23 @@ export interface ConversationPageView {
14
16
  export interface ConversationFollowHandle {
15
17
  close(): void;
16
18
  }
19
+ export interface LegacyConversationFollowRequest {
20
+ after?: string;
21
+ onPage(page: ConversationPageView): void;
22
+ onEvent(event: ConversationEventV1): void;
23
+ onClose(reason?: string): void;
24
+ }
25
+ export interface PassiveConversationFollow extends PassiveConversationFollowRequest {
26
+ /** Reports role updates from the owned or borrowed observer; never attaches a controller. */
27
+ onRole?(state: ConversationRoleState): void;
28
+ /** Application-only borrowed owner; never sent over the private wire. */
29
+ roleObservation?: ConversationRoleObservation;
30
+ onReplay(page: ConversationReplayPage): void;
31
+ onReady(ready: ConversationReplayReady): void;
32
+ onEvent(event: ConversationEventV1): void;
33
+ onState(state: ConversationObservationState): void;
34
+ onClose(reason?: string): void;
35
+ }
17
36
  /**
18
37
  * An interrupt that reached the session. `state` says whether the turn stopped
19
38
  * cooperatively or through bounded forced recovery — both are accepted, so no
@@ -25,9 +44,11 @@ export interface InterruptReceipt {
25
44
  reasonCode?: string;
26
45
  }
27
46
  export interface RoleSessionControl {
47
+ /** Standalone owner for role-only observation; dispose with that connection. */
48
+ observeRole?: ObserveConversationRole;
28
49
  agentContacts?(): Promise<unknown>;
29
50
  agentHistory?(request: AgentHistoryQuery): Promise<unknown>;
30
- describe(): Promise<SessionDescriptor>;
51
+ describe(roleObservation?: ConversationRoleObservation): Promise<SessionDescriptor>;
31
52
  snapshot(): Promise<SessionSnapshot>;
32
53
  recentOutput(request?: {
33
54
  since?: number;
@@ -80,21 +101,19 @@ export interface RoleSessionControl {
80
101
  * replay page; `onEvent` every subsequent durable event; `onClose` fires
81
102
  * exactly once when the underlying control stream ends.
82
103
  */
83
- followConversation?(request: {
84
- after?: string;
85
- onPage(page: ConversationPageView): void;
86
- onEvent(event: ConversationEventV1): void;
87
- onClose(reason?: string): void;
88
- }): Promise<ConversationFollowHandle>;
104
+ followConversation?(request: LegacyConversationFollowRequest | PassiveConversationFollow): Promise<ConversationFollowHandle>;
89
105
  }
90
106
  export declare class RoleSessionControlAdapter implements RoleSessionControl {
91
107
  private readonly stateDir;
92
108
  private readonly request;
93
- constructor(stateDir: string, request?: typeof controlRequest);
109
+ private readonly openFollow;
110
+ readonly observeRole?: ObserveConversationRole | undefined;
111
+ private controlHealthy;
112
+ constructor(stateDir: string, request?: typeof controlRequest, openFollow?: typeof followConversation, observeRole?: ObserveConversationRole | undefined);
94
113
  agentContacts(): Promise<unknown>;
95
114
  agentHistory(request: AgentHistoryQuery): Promise<unknown>;
96
115
  private requireCorrespondence;
97
- describe(): Promise<SessionDescriptor>;
116
+ describe(roleObservation?: ConversationRoleObservation): Promise<SessionDescriptor>;
98
117
  snapshot(): Promise<SessionSnapshot>;
99
118
  recentOutput(request?: {
100
119
  since?: number;
@@ -135,12 +154,7 @@ export declare class RoleSessionControlAdapter implements RoleSessionControl {
135
154
  accepted: true;
136
155
  commandId: string;
137
156
  }>;
138
- followConversation(request: {
139
- after?: string;
140
- onPage(page: ConversationPageView): void;
141
- onEvent(event: ConversationEventV1): void;
142
- onClose(reason?: string): void;
143
- }): Promise<ConversationFollowHandle>;
157
+ followConversation(request: LegacyConversationFollowRequest | PassiveConversationFollow): Promise<ConversationFollowHandle>;
144
158
  respondPermission(request: {
145
159
  permissionId: string;
146
160
  optionId: string;
@@ -7,21 +7,36 @@ const visibleEvents = (events) => events.filter(event => event.kind !== 'thought
7
7
  export class RoleSessionControlAdapter {
8
8
  stateDir;
9
9
  request;
10
- constructor(stateDir, request = controlRequest) {
10
+ openFollow;
11
+ observeRole;
12
+ controlHealthy = true;
13
+ constructor(stateDir, request = controlRequest, openFollow = followConversation, observeRole) {
11
14
  this.stateDir = stateDir;
12
15
  this.request = request;
16
+ this.openFollow = openFollow;
17
+ this.observeRole = observeRole;
13
18
  }
14
19
  async agentContacts() { await this.requireCorrespondence(); return this.call('agent_contacts'); }
15
20
  async agentHistory(request) { await this.requireCorrespondence(); return this.call('agent_history', { agentHistory: request }); }
16
21
  async requireCorrespondence() { if (!(await this.describe()).features.includes('agent_correspondence'))
17
22
  throw new FleetError('capability_unavailable', 'This session needs an updated supervisor to show contacts and history.'); }
18
- async describe() {
19
- const response = await this.call('snapshot');
20
- const result = response;
21
- return {
22
- backend: result.backend, protocolVersion: result.protocolVersion ?? 1,
23
- features: result.features ?? [], snapshot: result,
24
- };
23
+ async describe(roleObservation) {
24
+ // A describe probe owns and always disposes its temporary watcher. Follow uses
25
+ // a separate connection-owned observer and publishes updates via onRole.
26
+ const role = roleObservation ?? this.observeRole?.(() => { });
27
+ try {
28
+ const response = await this.call('snapshot');
29
+ const result = response;
30
+ const features = (result.features ?? []).filter(feature => feature !== 'conversation_live_complete');
31
+ if (this.controlHealthy && role?.snapshot().health === 'healthy' && role.snapshot().availability === 'available'
32
+ && features.includes('conversation_observation') && features.includes('control_heartbeat'))
33
+ features.push('conversation_live_complete');
34
+ return { backend: result.backend, protocolVersion: result.protocolVersion ?? 1, features, snapshot: result };
35
+ }
36
+ finally {
37
+ if (!roleObservation)
38
+ role?.dispose();
39
+ }
25
40
  }
26
41
  async snapshot() {
27
42
  return await this.call('snapshot');
@@ -81,22 +96,30 @@ export class RoleSessionControlAdapter {
81
96
  }
82
97
  }
83
98
  async conversationTail(request = {}) {
84
- const live = await this.call('conversation_page', { limit: 1 });
85
- const snapshot = await this.snapshot();
86
- let resumed;
87
- if (snapshot.backend === 'acp' && snapshot.sessionId) {
99
+ // A replacement while reading disk invalidates both the tail and its snapshot.
100
+ for (let attempt = 0; attempt < 3; attempt++) {
101
+ const live = await this.call('conversation_page', { limit: 1 });
102
+ const snapshot = await this.snapshot();
103
+ let resumed;
104
+ if (snapshot.backend === 'acp' && snapshot.sessionId) {
105
+ try {
106
+ if ((await readFile(join(this.stateDir, '.acp-session-id'), 'utf8')).trim() === snapshot.sessionId)
107
+ resumed = snapshot.sessionId;
108
+ }
109
+ catch { /* no resumed history: live generation only */ }
110
+ }
111
+ let page;
88
112
  try {
89
- if ((await readFile(join(this.stateDir, '.acp-session-id'), 'utf8')).trim() === snapshot.sessionId)
90
- resumed = snapshot.sessionId;
113
+ page = await conversationTailPage(this.stateDir, request, live.snapshot, resumed);
91
114
  }
92
- catch { /* no resumed history: live generation only */ }
93
- }
94
- try {
95
- return await conversationTailPage(this.stateDir, request, live.snapshot, resumed);
96
- }
97
- catch (error) {
98
- throw new FleetError('invalid_request', error.message);
115
+ catch (error) {
116
+ throw new FleetError('invalid_request', error.message);
117
+ }
118
+ const check = await this.call('conversation_page', { limit: 1 });
119
+ if (check.snapshot.sessionGeneration === live.snapshot.sessionGeneration)
120
+ return page;
99
121
  }
122
+ throw new FleetError('stale_state', 'Session generation changed during conversation baseline; explicit recovery required.');
100
123
  }
101
124
  async submitPromptV2(request) {
102
125
  if (!request.text.trim())
@@ -170,32 +193,124 @@ export class RoleSessionControlAdapter {
170
193
  }
171
194
  }
172
195
  async followConversation(request) {
196
+ const passive = 'controller' in request ? request : undefined;
173
197
  let sawPage = false;
174
198
  let closed = false;
199
+ let role;
200
+ let unsubscribeRole;
201
+ let lifetime;
202
+ let roleRevision = -1;
203
+ let silence;
204
+ let lastControlHeartbeat = Date.now();
205
+ const armSilence = () => {
206
+ if (silence)
207
+ clearTimeout(silence);
208
+ silence = setTimeout(() => {
209
+ // Liveness only: no role/session snapshots, regardless of web traffic.
210
+ if (Date.now() - lastControlHeartbeat >= 6_000) {
211
+ this.controlHealthy = false;
212
+ finish('control-silent');
213
+ }
214
+ else
215
+ armSilence();
216
+ }, Math.max(1, 6_000 - (Date.now() - lastControlHeartbeat)));
217
+ silence.unref?.();
218
+ };
219
+ let follow;
175
220
  const finish = (reason) => {
176
221
  if (closed)
177
222
  return;
178
223
  closed = true;
224
+ passive?.signal?.removeEventListener('abort', aborted);
225
+ if (silence)
226
+ clearTimeout(silence);
227
+ unsubscribeRole?.();
228
+ if (!passive?.roleObservation)
229
+ role?.dispose();
230
+ follow?.close();
179
231
  request.onClose(reason);
180
232
  };
181
- const follow = await followConversation(this.stateDir, request.after, message => {
182
- if (message.conversationEvent) {
183
- request.onEvent(message.conversationEvent);
184
- return;
233
+ const aborted = () => finish('conversation follow aborted');
234
+ passive?.signal?.addEventListener('abort', aborted, { once: true });
235
+ if (passive?.signal?.aborted)
236
+ aborted();
237
+ if (closed)
238
+ return { close: () => { } };
239
+ try {
240
+ if (passive) {
241
+ const changed = (state) => {
242
+ if (closed || state.roleRevision <= roleRevision)
243
+ return;
244
+ roleRevision = state.roleRevision;
245
+ passive.onRole?.(state);
246
+ if (state.health !== 'healthy') {
247
+ this.controlHealthy = false;
248
+ finish(state.failure ?? 'role-observation-unavailable');
249
+ }
250
+ else if (lifetime !== undefined && (state.roleLifetime !== lifetime || state.availability !== 'available' || state.stateDir !== this.stateDir))
251
+ finish('role-replaced');
252
+ lifetime = state.roleLifetime;
253
+ };
254
+ role = passive.roleObservation ?? this.observeRole?.(() => { });
255
+ if (role) {
256
+ const registration = role.subscribe(changed);
257
+ unsubscribeRole = registration.unsubscribe;
258
+ const initial = registration.snapshot;
259
+ changed(initial);
260
+ if (initial.availability !== 'available' || initial.stateDir !== this.stateDir)
261
+ finish('role-unavailable');
262
+ if (closed)
263
+ return { close: () => { } };
264
+ }
265
+ armSilence();
185
266
  }
186
- if (!sawPage) {
187
- sawPage = true;
267
+ follow = await this.openFollow(this.stateDir, passive ? { controller: false, expectedGeneration: passive.expectedGeneration, after: passive.after, signal: passive.signal } : request.after, message => {
268
+ if (closed)
269
+ return;
270
+ if (passive && message.controlHeartbeat === true) {
271
+ lastControlHeartbeat = Date.now();
272
+ this.controlHealthy = true;
273
+ armSilence();
274
+ return;
275
+ }
188
276
  if (message.ok === false) {
189
277
  finish(String(message.error ?? 'conversation follow refused'));
190
- follow.close();
191
278
  return;
192
279
  }
193
- request.onPage(message.result);
280
+ if (message.conversationEvent) {
281
+ request.onEvent(message.conversationEvent);
282
+ return;
283
+ }
284
+ if (passive) {
285
+ if (message.conversationReplay)
286
+ passive.onReplay(message.conversationReplay);
287
+ else if (message.conversationReady)
288
+ passive.onReady(message.conversationReady);
289
+ else if (message.conversationState)
290
+ passive.onState(message.conversationState);
291
+ }
292
+ else if (!sawPage) {
293
+ sawPage = true;
294
+ request.onPage(message.result);
295
+ }
296
+ });
297
+ // Abort may win while the connector is still creating its handle.
298
+ if (closed || passive?.signal?.aborted) {
299
+ follow.close();
300
+ finish('conversation follow aborted');
194
301
  }
195
- });
196
- follow.socket.once('close', () => finish());
197
- follow.socket.once('error', error => finish(error.message));
198
- return { close: () => { follow.close(); finish(); } };
302
+ else {
303
+ follow.socket.once('close', () => finish());
304
+ follow.socket.once('error', error => finish(error.message));
305
+ if (follow.socket.destroyed)
306
+ finish();
307
+ }
308
+ return { close: () => finish() };
309
+ }
310
+ catch (error) {
311
+ finish(error.message);
312
+ throw error;
313
+ }
199
314
  }
200
315
  async respondPermission(request) {
201
316
  await this.call('respond_permission', request);
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "1.2.0-nightly.63",
3
- "buildId": "9a1df2251871",
4
- "commit": "ec4a9231a3a43772d7b7348e3fea407cf83e5bd1",
2
+ "version": "1.2.0-nightly.64",
3
+ "buildId": "2c1e4fbdd573",
4
+ "commit": "b239e42b7564298704a0651d766667ac43adcc9a",
5
5
  "dirty": true,
6
- "builtAt": "2026-10-14T20:26:32.887Z",
6
+ "builtAt": "2026-10-15T18:18:33.495Z",
7
7
  "capabilities": [
8
8
  "managed-cli.codex-reasoning-effort-v1",
9
9
  "managed-cli.setup-v1",