@ours.network/fleet 0.9.3 → 0.9.5

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.
@@ -0,0 +1,280 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { isAbsolute, join, relative, resolve } from 'node:path';
5
+ import { Readable, Writable } from 'node:stream';
6
+ import * as acp from '@agentclientprotocol/sdk';
7
+ import { SessionEvents } from './events.js';
8
+ /**
9
+ * Persistent ACP v1 client. It is the sole owner of the agent's stdio; all
10
+ * human/automation attachment happens through the fleet role-control protocol.
11
+ */
12
+ export class AcpSession {
13
+ options;
14
+ backend = 'acp';
15
+ pid;
16
+ child;
17
+ events;
18
+ sessionFile;
19
+ pendingPermissions = new Map();
20
+ connection;
21
+ sessionId;
22
+ readiness = 'starting';
23
+ lastError;
24
+ promptTail = Promise.resolve();
25
+ capabilities;
26
+ controllerCount = 0;
27
+ constructor(options, child, connection) {
28
+ this.options = options;
29
+ this.child = child;
30
+ this.connection = connection;
31
+ this.pid = child.pid ?? -1;
32
+ this.events = new SessionEvents(join(options.stateDir, '.session-events.jsonl'));
33
+ this.sessionFile = join(options.stateDir, '.acp-session-id');
34
+ child.stderr.on('data', chunk => options.log(`[${options.name}] acp: ${String(chunk).trimEnd()}`));
35
+ child.once('exit', (code, signal) => {
36
+ if (this.readiness !== 'failed') {
37
+ this.readiness = 'failed';
38
+ this.lastError = `ACP agent exited (${code ?? signal ?? 'unknown'})`;
39
+ }
40
+ this.events.emit('state', { status: 'failed', text: this.lastError });
41
+ });
42
+ }
43
+ static async start(options) {
44
+ if (!options.argv.length)
45
+ throw new Error('ACP agent command is empty');
46
+ const child = spawn(options.argv[0], options.argv.slice(1), {
47
+ cwd: options.cwd,
48
+ env: { ...process.env, ...options.env },
49
+ stdio: ['pipe', 'pipe', 'pipe'],
50
+ });
51
+ await new Promise((resolve, reject) => {
52
+ child.once('spawn', resolve);
53
+ child.once('error', reject);
54
+ });
55
+ let instance;
56
+ const app = acp.client({ name: 'ours-fleet' })
57
+ .onNotification(acp.methods.client.session.update, ({ params }) => {
58
+ instance?.recordUpdate(params.update);
59
+ })
60
+ .onRequest(acp.methods.client.session.requestPermission, ({ params }) => {
61
+ if (!instance)
62
+ return { outcome: { outcome: 'cancelled' } };
63
+ return instance.requestPermission(params);
64
+ });
65
+ const stream = acp.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
66
+ const connection = app.connect(stream);
67
+ instance = new AcpSession(options, child, connection);
68
+ try {
69
+ await instance.initialize();
70
+ return instance;
71
+ }
72
+ catch (error) {
73
+ instance.fail(error);
74
+ await instance.close();
75
+ throw error;
76
+ }
77
+ }
78
+ isAlive() {
79
+ return this.child.exitCode === null && !this.child.killed;
80
+ }
81
+ snapshot() {
82
+ return {
83
+ backend: 'acp',
84
+ alive: this.isAlive(),
85
+ readiness: this.readiness,
86
+ sessionId: this.sessionId,
87
+ lastError: this.lastError,
88
+ pendingPermissionId: this.pendingPermissions.keys().next().value,
89
+ };
90
+ }
91
+ submitPrompt(text) {
92
+ const operation = this.promptTail.then(() => this.runPrompt(text));
93
+ this.promptTail = operation.then(() => undefined, () => undefined);
94
+ return operation;
95
+ }
96
+ async interrupt() {
97
+ if (!this.sessionId)
98
+ return;
99
+ await this.connection.agent.notify(acp.methods.agent.session.cancel, { sessionId: this.sessionId });
100
+ }
101
+ respondPermission(permissionId, optionId) {
102
+ const pending = this.pendingPermissions.get(permissionId);
103
+ if (!pending || !pending.options.some(option => option.optionId === optionId))
104
+ return false;
105
+ this.pendingPermissions.delete(permissionId);
106
+ pending.resolve({ outcome: { outcome: 'selected', optionId } });
107
+ this.readiness = 'running';
108
+ return true;
109
+ }
110
+ eventsSince(seq) {
111
+ return this.events.since(seq);
112
+ }
113
+ subscribe(listener) {
114
+ return this.events.subscribe(listener);
115
+ }
116
+ setControllerAttached(attached) {
117
+ this.controllerCount = Math.max(0, this.controllerCount + (attached ? 1 : -1));
118
+ }
119
+ async close() {
120
+ for (const pending of this.pendingPermissions.values())
121
+ pending.resolve({ outcome: { outcome: 'cancelled' } });
122
+ this.pendingPermissions.clear();
123
+ if (this.sessionId && this.capabilities?.sessionCapabilities?.close != null) {
124
+ await this.connection.agent.request(acp.methods.agent.session.close, { sessionId: this.sessionId }).catch(() => undefined);
125
+ }
126
+ this.connection.close();
127
+ if (this.isAlive())
128
+ this.child.kill('SIGTERM');
129
+ }
130
+ async initialize() {
131
+ const initialized = await this.connection.agent.request(acp.methods.agent.initialize, {
132
+ protocolVersion: acp.PROTOCOL_VERSION,
133
+ clientCapabilities: {},
134
+ clientInfo: { name: 'ours-fleet', version: '1' },
135
+ });
136
+ if (initialized.protocolVersion !== acp.PROTOCOL_VERSION)
137
+ throw new Error(`ACP protocol mismatch: agent selected ${initialized.protocolVersion}, client supports ${acp.PROTOCOL_VERSION}`);
138
+ this.capabilities = initialized.agentCapabilities;
139
+ const persisted = this.options.mode === 'resume' && existsSync(this.sessionFile)
140
+ ? readFileSync(this.sessionFile, 'utf8').trim()
141
+ : '';
142
+ if (persisted && this.capabilities?.sessionCapabilities?.resume != null) {
143
+ await this.connection.agent.request(acp.methods.agent.session.resume, {
144
+ sessionId: persisted,
145
+ cwd: this.options.cwd,
146
+ mcpServers: [],
147
+ });
148
+ this.sessionId = persisted;
149
+ }
150
+ else if (persisted && this.capabilities?.loadSession) {
151
+ await this.connection.agent.request(acp.methods.agent.session.load, {
152
+ sessionId: persisted,
153
+ cwd: this.options.cwd,
154
+ mcpServers: [],
155
+ });
156
+ this.sessionId = persisted;
157
+ }
158
+ else {
159
+ const created = await this.connection.agent.request(acp.methods.agent.session.new, {
160
+ cwd: this.options.cwd,
161
+ mcpServers: [],
162
+ });
163
+ this.sessionId = created.sessionId;
164
+ }
165
+ writeFileSync(this.sessionFile, this.sessionId + '\n', { mode: 0o600 });
166
+ this.readiness = 'idle';
167
+ this.events.emit('state', { status: 'idle', text: `ACP session ${this.sessionId}` });
168
+ }
169
+ async runPrompt(text) {
170
+ if (!this.sessionId || !this.isAlive())
171
+ return { accepted: false, outcome: 'failed', detail: this.lastError ?? 'ACP session is offline' };
172
+ this.readiness = 'running';
173
+ const turnId = randomUUID();
174
+ this.events.emit('state', { turnId, status: 'running' });
175
+ try {
176
+ const response = await this.connection.agent.request(acp.methods.agent.session.prompt, {
177
+ sessionId: this.sessionId,
178
+ prompt: [{ type: 'text', text }],
179
+ });
180
+ this.readiness = 'idle';
181
+ this.events.emit('turn_stop', { turnId, stopReason: response.stopReason });
182
+ this.events.emit('state', { status: 'idle' });
183
+ const outcome = response.stopReason === 'cancelled'
184
+ ? 'cancelled'
185
+ : response.stopReason === 'refusal'
186
+ ? 'refused'
187
+ : 'completed';
188
+ return { accepted: true, outcome, detail: response.stopReason };
189
+ }
190
+ catch (error) {
191
+ this.lastError = error?.message ?? String(error);
192
+ this.readiness = this.isAlive() ? 'idle' : 'failed';
193
+ this.events.emit('error', { turnId, text: this.lastError });
194
+ if (this.isAlive())
195
+ this.events.emit('state', { status: 'idle' });
196
+ return { accepted: false, outcome: 'failed', detail: this.lastError };
197
+ }
198
+ }
199
+ requestPermission(params) {
200
+ const choose = (kinds) => params.options.find(option => kinds.includes(option.kind));
201
+ if (this.options.permissions.approval === 'allow' && this.withinAutomaticBoundary(params)) {
202
+ const option = choose(['allow_always', 'allow_once']);
203
+ return Promise.resolve(option
204
+ ? { outcome: { outcome: 'selected', optionId: option.optionId } }
205
+ : { outcome: { outcome: 'cancelled' } });
206
+ }
207
+ if (this.options.permissions.approval === 'deny'
208
+ || (this.controllerCount === 0 && this.options.permissions.unattended === 'deny')) {
209
+ const option = choose(['reject_always', 'reject_once']);
210
+ return Promise.resolve(option
211
+ ? { outcome: { outcome: 'selected', optionId: option.optionId } }
212
+ : { outcome: { outcome: 'cancelled' } });
213
+ }
214
+ const permissionId = randomUUID();
215
+ this.readiness = 'awaiting_permission';
216
+ this.events.emit('permission', {
217
+ permissionId,
218
+ toolCallId: params.toolCall.toolCallId,
219
+ title: params.toolCall.title ?? 'Permission requested',
220
+ status: 'pending',
221
+ options: params.options.map(option => ({
222
+ optionId: option.optionId, name: option.name, kind: option.kind,
223
+ })),
224
+ });
225
+ return new Promise(resolve => {
226
+ this.pendingPermissions.set(permissionId, { options: params.options, resolve });
227
+ });
228
+ }
229
+ withinAutomaticBoundary(params) {
230
+ const filesystem = this.options.permissions.filesystem;
231
+ if (filesystem === 'unrestricted')
232
+ return true;
233
+ if (filesystem === 'read-only' && params.toolCall.kind !== 'read')
234
+ return false;
235
+ const locations = params.toolCall.locations ?? [];
236
+ if (locations.length === 0)
237
+ return false;
238
+ const cwd = resolve(this.options.cwd);
239
+ return locations.every(location => {
240
+ const path = resolve(location.path);
241
+ const rel = relative(cwd, path);
242
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
243
+ });
244
+ }
245
+ recordUpdate(update) {
246
+ switch (update.sessionUpdate) {
247
+ case 'agent_message_chunk':
248
+ this.events.emit('agent_text', {
249
+ text: update.content.type === 'text' ? update.content.text : `[${update.content.type}]`,
250
+ });
251
+ break;
252
+ case 'agent_thought_chunk':
253
+ this.events.emit('thought', {
254
+ text: update.content.type === 'text' ? update.content.text : `[${update.content.type}]`,
255
+ });
256
+ break;
257
+ case 'tool_call':
258
+ this.events.emit('tool_call', {
259
+ toolCallId: update.toolCallId,
260
+ title: update.title,
261
+ status: update.status,
262
+ });
263
+ break;
264
+ case 'tool_call_update':
265
+ this.events.emit('tool_update', {
266
+ toolCallId: update.toolCallId,
267
+ title: update.title ?? undefined,
268
+ status: update.status ?? undefined,
269
+ });
270
+ break;
271
+ default:
272
+ break;
273
+ }
274
+ }
275
+ fail(error) {
276
+ this.lastError = error?.message ?? String(error);
277
+ this.readiness = 'failed';
278
+ this.events.emit('error', { text: this.lastError });
279
+ }
280
+ }
@@ -0,0 +1,41 @@
1
+ import { type Socket } from 'node:net';
2
+ import type { SessionHandle } from './types.js';
3
+ export interface ControlRequest {
4
+ version: 1;
5
+ id: string;
6
+ token: string;
7
+ command: 'status' | 'snapshot' | 'submit_prompt' | 'respond_permission' | 'interrupt' | 'follow';
8
+ text?: string;
9
+ permissionId?: string;
10
+ optionId?: string;
11
+ since?: number;
12
+ }
13
+ export interface ControlResponse {
14
+ version: 1;
15
+ id: string;
16
+ ok: boolean;
17
+ result?: unknown;
18
+ error?: string;
19
+ }
20
+ export declare const controlSocketPath: (stateDir: string) => string;
21
+ export declare const controlTokenPath: (stateDir: string) => string;
22
+ /** Private, versioned JSONL control plane for CLI and future console frontends. */
23
+ export declare class RoleControlServer {
24
+ private readonly session;
25
+ private readonly log;
26
+ private readonly server;
27
+ private readonly socketPath;
28
+ private readonly token;
29
+ private readonly sockets;
30
+ constructor(stateDir: string, session: SessionHandle, log: (line: string) => void);
31
+ start(): Promise<void>;
32
+ close(): Promise<void>;
33
+ private accept;
34
+ private handle;
35
+ private write;
36
+ }
37
+ export declare function controlRequest(stateDir: string, request: Omit<ControlRequest, 'version' | 'id' | 'token'>, timeoutMs?: number): Promise<ControlResponse>;
38
+ export declare function followControl(stateDir: string, onMessage: (message: Record<string, unknown>) => void): Promise<{
39
+ socket: Socket;
40
+ send(request: Omit<ControlRequest, 'version' | 'id' | 'token'>): void;
41
+ }>;
@@ -0,0 +1,218 @@
1
+ import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
2
+ import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { createConnection, createServer } from 'node:net';
4
+ import { join } from 'node:path';
5
+ const MAX_LINE_BYTES = 64 * 1024;
6
+ export const controlSocketPath = (stateDir) => join(stateDir, '.control.sock');
7
+ export const controlTokenPath = (stateDir) => join(stateDir, '.control-token');
8
+ function sameToken(actual, supplied) {
9
+ const a = Buffer.from(actual);
10
+ const b = Buffer.from(supplied);
11
+ return a.length === b.length && timingSafeEqual(a, b);
12
+ }
13
+ /** Private, versioned JSONL control plane for CLI and future console frontends. */
14
+ export class RoleControlServer {
15
+ session;
16
+ log;
17
+ server;
18
+ socketPath;
19
+ token;
20
+ sockets = new Set();
21
+ constructor(stateDir, session, log) {
22
+ this.session = session;
23
+ this.log = log;
24
+ this.socketPath = controlSocketPath(stateDir);
25
+ const tokenPath = controlTokenPath(stateDir);
26
+ this.token = existsSync(tokenPath)
27
+ ? readFileSync(tokenPath, 'utf8').trim()
28
+ : randomBytes(32).toString('hex');
29
+ writeFileSync(tokenPath, this.token + '\n', { mode: 0o600 });
30
+ chmodSync(tokenPath, 0o600);
31
+ rmSync(this.socketPath, { force: true });
32
+ this.server = createServer(socket => this.accept(socket));
33
+ }
34
+ async start() {
35
+ await new Promise((resolve, reject) => {
36
+ this.server.once('error', reject);
37
+ this.server.listen(this.socketPath, () => {
38
+ this.server.off('error', reject);
39
+ try {
40
+ chmodSync(this.socketPath, 0o600);
41
+ }
42
+ catch { /* platform dependent */ }
43
+ resolve();
44
+ });
45
+ });
46
+ }
47
+ async close() {
48
+ for (const socket of this.sockets)
49
+ socket.destroy();
50
+ await new Promise(resolve => this.server.close(() => resolve()));
51
+ rmSync(this.socketPath, { force: true });
52
+ }
53
+ accept(socket) {
54
+ this.sockets.add(socket);
55
+ socket.setEncoding('utf8');
56
+ let buffer = '';
57
+ let unsubscribe;
58
+ socket.on('data', chunk => {
59
+ buffer += chunk;
60
+ if (Buffer.byteLength(buffer) > MAX_LINE_BYTES) {
61
+ socket.destroy(new Error('control request too large'));
62
+ return;
63
+ }
64
+ for (;;) {
65
+ const newline = buffer.indexOf('\n');
66
+ if (newline < 0)
67
+ break;
68
+ const line = buffer.slice(0, newline);
69
+ buffer = buffer.slice(newline + 1);
70
+ if (!line.trim())
71
+ continue;
72
+ void this.handle(line, socket).then(stop => {
73
+ if (stop) {
74
+ if (unsubscribe) {
75
+ unsubscribe();
76
+ this.session.setControllerAttached(false);
77
+ }
78
+ unsubscribe = stop;
79
+ }
80
+ });
81
+ }
82
+ });
83
+ socket.on('close', () => {
84
+ this.sockets.delete(socket);
85
+ if (unsubscribe) {
86
+ unsubscribe();
87
+ this.session.setControllerAttached(false);
88
+ }
89
+ });
90
+ socket.on('error', error => this.log(`control socket: ${error.message}`));
91
+ }
92
+ async handle(line, socket) {
93
+ let request;
94
+ try {
95
+ request = JSON.parse(line);
96
+ }
97
+ catch {
98
+ this.write(socket, { version: 1, id: '?', ok: false, error: 'invalid JSON' });
99
+ return;
100
+ }
101
+ if (request.version !== 1 || typeof request.id !== 'string'
102
+ || !sameToken(this.token, request.token ?? '')) {
103
+ this.write(socket, { version: 1, id: request.id ?? '?', ok: false, error: 'unauthorized' });
104
+ return;
105
+ }
106
+ try {
107
+ switch (request.command) {
108
+ case 'status':
109
+ case 'snapshot':
110
+ this.write(socket, { version: 1, id: request.id, ok: true, result: this.session.snapshot() });
111
+ return;
112
+ case 'submit_prompt': {
113
+ if (!request.text?.trim())
114
+ throw new Error('text is required');
115
+ const result = await this.session.submitPrompt(request.text);
116
+ this.write(socket, { version: 1, id: request.id, ok: result.accepted, result,
117
+ error: result.accepted ? undefined : result.detail });
118
+ return;
119
+ }
120
+ case 'respond_permission': {
121
+ if (!request.permissionId || !request.optionId)
122
+ throw new Error('permissionId and optionId are required');
123
+ const accepted = this.session.respondPermission(request.permissionId, request.optionId);
124
+ this.write(socket, {
125
+ version: 1, id: request.id, ok: accepted,
126
+ result: { accepted }, error: accepted ? undefined : 'stale or invalid permission response',
127
+ });
128
+ return;
129
+ }
130
+ case 'interrupt':
131
+ await this.session.interrupt();
132
+ this.write(socket, { version: 1, id: request.id, ok: true });
133
+ return;
134
+ case 'follow': {
135
+ const since = Number.isFinite(request.since) ? Number(request.since) : 0;
136
+ this.write(socket, {
137
+ version: 1, id: request.id, ok: true,
138
+ result: { events: this.session.eventsSince(since), snapshot: this.session.snapshot() },
139
+ });
140
+ this.session.setControllerAttached(true);
141
+ return this.session.subscribe(event => {
142
+ if (!socket.destroyed)
143
+ socket.write(JSON.stringify({ version: 1, event }) + '\n');
144
+ });
145
+ }
146
+ }
147
+ }
148
+ catch (error) {
149
+ this.write(socket, {
150
+ version: 1,
151
+ id: request.id,
152
+ ok: false,
153
+ error: error?.message ?? String(error),
154
+ });
155
+ }
156
+ }
157
+ write(socket, response) {
158
+ if (!socket.destroyed)
159
+ socket.write(JSON.stringify(response) + '\n');
160
+ }
161
+ }
162
+ export async function controlRequest(stateDir, request, timeoutMs = 120_000) {
163
+ const token = readFileSync(controlTokenPath(stateDir), 'utf8').trim();
164
+ const id = randomUUID();
165
+ const socket = createConnection(controlSocketPath(stateDir));
166
+ socket.setEncoding('utf8');
167
+ const response = await new Promise((resolve, reject) => {
168
+ const timer = setTimeout(() => {
169
+ socket.destroy();
170
+ reject(new Error('role control request timed out'));
171
+ }, timeoutMs);
172
+ let buffer = '';
173
+ socket.once('error', error => { clearTimeout(timer); reject(error); });
174
+ socket.on('data', chunk => {
175
+ buffer += chunk;
176
+ const newline = buffer.indexOf('\n');
177
+ if (newline < 0)
178
+ return;
179
+ clearTimeout(timer);
180
+ try {
181
+ resolve(JSON.parse(buffer.slice(0, newline)));
182
+ }
183
+ catch (error) {
184
+ reject(error);
185
+ }
186
+ socket.end();
187
+ });
188
+ socket.once('connect', () => socket.write(JSON.stringify({
189
+ version: 1, id, token, ...request,
190
+ }) + '\n'));
191
+ });
192
+ return response;
193
+ }
194
+ export async function followControl(stateDir, onMessage) {
195
+ const token = readFileSync(controlTokenPath(stateDir), 'utf8').trim();
196
+ const socket = createConnection(controlSocketPath(stateDir));
197
+ socket.setEncoding('utf8');
198
+ let buffer = '';
199
+ socket.on('data', chunk => {
200
+ buffer += chunk;
201
+ for (;;) {
202
+ const newline = buffer.indexOf('\n');
203
+ if (newline < 0)
204
+ break;
205
+ const line = buffer.slice(0, newline);
206
+ buffer = buffer.slice(newline + 1);
207
+ if (line.trim())
208
+ onMessage(JSON.parse(line));
209
+ }
210
+ });
211
+ await new Promise((resolve, reject) => {
212
+ socket.once('connect', resolve);
213
+ socket.once('error', reject);
214
+ });
215
+ const send = (request) => socket.write(JSON.stringify({ version: 1, id: randomUUID(), token, ...request }) + '\n');
216
+ send({ command: 'follow', since: 0 });
217
+ return { socket, send };
218
+ }
@@ -0,0 +1,14 @@
1
+ import type { SessionEvent, SessionEventKind } from './types.js';
2
+ /** Bounded, typed event stream shared by CLI frontends and future ACP/Toad facades. */
3
+ export declare class SessionEvents {
4
+ private readonly path;
5
+ private seq;
6
+ private readonly events;
7
+ private readonly listeners;
8
+ constructor(path: string);
9
+ emit(kind: SessionEventKind, fields?: Omit<SessionEvent, 'version' | 'seq' | 'at' | 'kind'>): SessionEvent;
10
+ since(seq: number): SessionEvent[];
11
+ subscribe(listener: (event: SessionEvent) => void): () => void;
12
+ private restore;
13
+ private rotateIfNeeded;
14
+ }
@@ -0,0 +1,67 @@
1
+ import { appendFileSync, existsSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
2
+ const MAX_EVENTS = 1_000;
3
+ const MAX_EVENT_FILE_BYTES = 2 * 1024 * 1024;
4
+ /** Bounded, typed event stream shared by CLI frontends and future ACP/Toad facades. */
5
+ export class SessionEvents {
6
+ path;
7
+ seq = 0;
8
+ events = [];
9
+ listeners = new Set();
10
+ constructor(path) {
11
+ this.path = path;
12
+ this.restore();
13
+ }
14
+ emit(kind, fields = {}) {
15
+ const event = {
16
+ version: 1,
17
+ seq: ++this.seq,
18
+ at: new Date().toISOString(),
19
+ kind,
20
+ ...fields,
21
+ };
22
+ this.events.push(event);
23
+ if (this.events.length > MAX_EVENTS)
24
+ this.events.shift();
25
+ try {
26
+ this.rotateIfNeeded();
27
+ appendFileSync(this.path, JSON.stringify(event) + '\n', { mode: 0o600 });
28
+ }
29
+ catch { /* diagnostics must never terminate a role */ }
30
+ for (const listener of this.listeners)
31
+ listener(event);
32
+ return event;
33
+ }
34
+ since(seq) {
35
+ return this.events.filter(event => event.seq > seq);
36
+ }
37
+ subscribe(listener) {
38
+ this.listeners.add(listener);
39
+ return () => this.listeners.delete(listener);
40
+ }
41
+ restore() {
42
+ try {
43
+ if (!existsSync(this.path))
44
+ return;
45
+ const lines = readFileSync(this.path, 'utf8').trim().split('\n').slice(-MAX_EVENTS);
46
+ for (const line of lines) {
47
+ const event = JSON.parse(line);
48
+ if (event.version === 1 && typeof event.seq === 'number') {
49
+ this.events.push(event);
50
+ this.seq = Math.max(this.seq, event.seq);
51
+ }
52
+ }
53
+ }
54
+ catch { /* begin a fresh projection if the diagnostic file is corrupt */ }
55
+ }
56
+ rotateIfNeeded() {
57
+ if (!existsSync(this.path) || statSync(this.path).size < MAX_EVENT_FILE_BYTES)
58
+ return;
59
+ const rotated = this.path + '.1';
60
+ try {
61
+ renameSync(this.path, rotated);
62
+ }
63
+ catch {
64
+ writeFileSync(this.path, '', { mode: 0o600 });
65
+ }
66
+ }
67
+ }
@@ -0,0 +1,20 @@
1
+ import type { Tmux } from '../tmux.js';
2
+ import type { SessionEvent, SessionHandle, SessionSnapshot, TurnResult } from './types.js';
3
+ /** SessionHandle adapter for the existing tmux transport. */
4
+ export declare class TmuxSession implements SessionHandle {
5
+ private readonly name;
6
+ readonly pid: number;
7
+ private readonly tmux;
8
+ private readonly processAlive;
9
+ readonly backend: "tmux";
10
+ constructor(name: string, pid: number, tmux: Tmux, processAlive: (pid: number) => boolean);
11
+ isAlive(): boolean;
12
+ snapshot(): SessionSnapshot;
13
+ submitPrompt(text: string): Promise<TurnResult>;
14
+ interrupt(): Promise<void>;
15
+ respondPermission(): boolean;
16
+ eventsSince(): SessionEvent[];
17
+ subscribe(): () => void;
18
+ setControllerAttached(): void;
19
+ close(): Promise<void>;
20
+ }
@@ -0,0 +1,46 @@
1
+ /** SessionHandle adapter for the existing tmux transport. */
2
+ export class TmuxSession {
3
+ name;
4
+ pid;
5
+ tmux;
6
+ processAlive;
7
+ backend = 'tmux';
8
+ constructor(name, pid, tmux, processAlive) {
9
+ this.name = name;
10
+ this.pid = pid;
11
+ this.tmux = tmux;
12
+ this.processAlive = processAlive;
13
+ }
14
+ isAlive() {
15
+ return this.processAlive(this.pid);
16
+ }
17
+ snapshot() {
18
+ return {
19
+ backend: 'tmux',
20
+ alive: this.isAlive(),
21
+ readiness: this.isAlive() ? 'idle' : 'failed',
22
+ };
23
+ }
24
+ async submitPrompt(text) {
25
+ if (!this.isAlive())
26
+ return { accepted: false, outcome: 'failed', detail: 'tmux pane is offline' };
27
+ await this.tmux.sendText(this.name, text);
28
+ return { accepted: true, outcome: 'inconclusive' };
29
+ }
30
+ async interrupt() {
31
+ await this.tmux.sendKey(this.name, 'C-c');
32
+ }
33
+ respondPermission() {
34
+ return false;
35
+ }
36
+ eventsSince() {
37
+ return [];
38
+ }
39
+ subscribe() {
40
+ return () => { };
41
+ }
42
+ setControllerAttached() { }
43
+ async close() {
44
+ await this.tmux.kill(this.name);
45
+ }
46
+ }