@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.
- package/README.md +6 -0
- package/dist/application/conversation-history.js +4 -1
- package/dist/application/conversation-role-observer.d.ts +62 -0
- package/dist/application/conversation-role-observer.js +206 -0
- package/dist/application/role-removal-service.js +1 -1
- package/dist/application/session-control.d.ts +31 -17
- package/dist/application/session-control.js +148 -33
- package/dist/build-info.json +4 -4
- package/dist/notifications/fleet-producer.js +4 -4
- package/dist/notifications/target-cleanup.d.ts +2 -0
- package/dist/notifications/target-cleanup.js +24 -2
- package/dist/ops.js +1 -1
- package/dist/session/acp.d.ts +8 -1
- package/dist/session/acp.js +97 -43
- package/dist/session/arbiter.d.ts +4 -0
- package/dist/session/arbiter.js +18 -0
- package/dist/session/codex-app-server.d.ts +7 -1
- package/dist/session/codex-app-server.js +60 -9
- package/dist/session/control.d.ts +5 -2
- package/dist/session/control.js +145 -20
- package/dist/session/conversation-observation.d.ts +25 -0
- package/dist/session/conversation-observation.js +100 -0
- package/dist/session/conversation-replay.d.ts +50 -0
- package/dist/session/conversation-replay.js +203 -0
- package/dist/session/conversation-store.d.ts +14 -0
- package/dist/session/conversation-store.js +201 -17
- package/dist/session/conversation-types.d.ts +29 -1
- package/dist/session/types.d.ts +6 -0
- package/dist/subscriptions/login.d.ts +1 -0
- package/dist/subscriptions/login.js +30 -5
- package/dist/web/auth.d.ts +2 -0
- package/dist/web/auth.js +10 -1
- package/dist/web/conversation-stream-codec.d.ts +23 -0
- package/dist/web/conversation-stream-codec.js +110 -0
- package/dist/web/conversation-stream.d.ts +25 -0
- package/dist/web/conversation-stream.js +231 -0
- package/dist/web/runtime.js +12 -2
- package/dist/web/server.d.ts +2 -0
- package/dist/web/server.js +2 -0
- package/docs/conversation-stream.md +176 -0
- 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
|
|
@@ -1838,6 +1841,9 @@ See [Room layouts](docs/room-layouts.md) for YAML examples and CLI commands.
|
|
|
1838
1841
|
|
|
1839
1842
|
Configure the web backend with `OURS_NOTIFICATIONS_ORIGIN` and a Fleet-scoped `OURS_NOTIFICATIONS_PRODUCER_TOKEN`. It reads each local supervisor ledger incrementally with persistent checkpoints under web state, correlating owner browser/channel prompts with terminal completion. Monitor/startup turns do not notify. First installation skips existing history; no agent restart or conversation controller is needed. Persist web state across deployment/restarts. Reads are bounded to 256 KiB per role per tick, and oversized model/media records are skipped across chunks. Expose `/notifications` through the generic authenticated prefix gateway with a server-side user token and `stripBrowserContext: true`; producer tokens stay server-side. All browser sessions of this single-owner console map to one stable notification account. See [ours-notifications](https://github.com/adapt-toolkit/ours-notifications) for configuration/API/release details.
|
|
1840
1843
|
|
|
1844
|
+
New agent notifications and confirmed role retirement use `/fleet/chats/agents/:roleId`, with each ID escaped once and matching message anchors retained. Deploy the app, notification service, and producers together: old query-based conversation links are intentionally unsupported. Existing pending payload URLs are replayed unchanged for idempotency; obsolete cleanup targets remain stored without being sent or treated as proof of absence. Task management notification paths are unchanged.
|
|
1845
|
+
|
|
1846
|
+
|
|
1841
1847
|
Existing-host enrollment: `ours-fleet workspace-enroll --file <private-file> --preserve-profile` requires Messenger to advertise preservation support before any enrollment POST. It omits Name/Surname and leaves existing/absent profile untouched. Existing Fleet models and agents are retained; the installer must skip init when its configuration exists. Signed root proof, tunnel setup and device QR issuance remain the normal enrollment flow. Older Messenger fails closed and requires its preservation-capable release.
|
|
1842
1848
|
|
|
1843
1849
|
### Connect an installed host to the App
|
|
@@ -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
|
+
}
|
|
@@ -94,7 +94,7 @@ export class RoleRemovalService {
|
|
|
94
94
|
await this.options.ops.backend.uninstall(preview.role);
|
|
95
95
|
if (existsSync(sourceState))
|
|
96
96
|
renameSync(sourceState, join(recoveryPath, 'orphan-state'));
|
|
97
|
-
await retireNotificationTarget(`/fleet/chats
|
|
97
|
+
await retireNotificationTarget(`/fleet/chats/agents/${encodeURIComponent(preview.role)}`).catch(() => { });
|
|
98
98
|
this.options.ops.log(`removed orphaned backend/state '${preview.role}'`);
|
|
99
99
|
}
|
|
100
100
|
return { ...preview, removed: true, recoveryPath };
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
resumed = snapshot.sessionId;
|
|
113
|
+
page = await conversationTailPage(this.stateDir, request, live.snapshot, resumed);
|
|
91
114
|
}
|
|
92
|
-
catch {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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);
|
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.64",
|
|
3
|
+
"buildId": "2c1e4fbdd573",
|
|
4
|
+
"commit": "b239e42b7564298704a0651d766667ac43adcc9a",
|
|
5
5
|
"dirty": true,
|
|
6
|
-
"builtAt": "2026-10-
|
|
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",
|