@adhdev/daemon-core 0.7.5 → 0.7.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +164 -38
- package/dist/index.d.ts +164 -38
- package/dist/index.js +4051 -2547
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +3696 -2192
- package/dist/index.mjs.map +1 -1
- package/dist/{normalize-tKg8IiDk.d.mts → normalize-auJAPmKy.d.mts} +669 -629
- package/dist/{normalize-tKg8IiDk.d.ts → normalize-auJAPmKy.d.ts} +669 -629
- package/dist/status/normalize.d.mts +1 -1
- package/dist/status/normalize.d.ts +1 -1
- package/package.json +5 -1
- package/src/agent-stream/forward.ts +6 -0
- package/src/boot/daemon-lifecycle.ts +7 -4
- package/src/cli-adapter-types.ts +2 -0
- package/src/cli-adapters/provider-cli-adapter.ts +148 -11
- package/src/cli-adapters/pty-transport.ts +100 -0
- package/src/cli-adapters/session-host-transport.ts +392 -0
- package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +126 -0
- package/src/cli-adapters/terminal-backends/types.ts +17 -0
- package/src/cli-adapters/terminal-backends/xterm-backend.ts +87 -0
- package/src/cli-adapters/terminal-screen.ts +40 -53
- package/src/commands/cli-manager.ts +184 -55
- package/src/config/config.d.ts +116 -0
- package/src/config/workspace-activity.d.ts +22 -0
- package/src/config/workspaces.d.ts +84 -0
- package/src/daemon/dev-auto-implement.ts +1087 -0
- package/src/daemon/dev-cdp-handlers.ts +1003 -0
- package/src/daemon/dev-cli-debug.ts +288 -0
- package/src/daemon/dev-server-types.ts +45 -0
- package/src/daemon/dev-server.ts +121 -1698
- package/src/index.ts +5 -1
- package/src/providers/cli-provider-instance.ts +13 -1
- package/src/providers/contracts.d.ts +408 -0
- package/src/providers/contracts.ts +9 -0
- package/src/providers/extension-provider-instance.ts +50 -10
- package/src/providers/provider-instance-manager.ts +48 -10
- package/src/providers/provider-instance.d.ts +142 -0
- package/src/providers/provider-instance.ts +23 -1
- package/src/shared-types.d.ts +157 -0
- package/src/shared-types.ts +14 -0
- package/src/status/builders.ts +6 -0
- package/src/status/normalize.d.ts +14 -0
- package/src/types.d.ts +127 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SessionHostClient,
|
|
3
|
+
type SessionHostEndpoint,
|
|
4
|
+
type SessionHostEvent,
|
|
5
|
+
type SessionHostCategory,
|
|
6
|
+
type SessionHostRecord,
|
|
7
|
+
} from '@adhdev/session-host-core';
|
|
8
|
+
import { LOG } from '../logging/logger.js';
|
|
9
|
+
import type { PtyRuntimeMetadata, PtyRuntimeTransport, PtySpawnOptions, PtyTransportFactory } from './pty-transport.js';
|
|
10
|
+
|
|
11
|
+
interface SessionHostPtyTransportFactoryOptions {
|
|
12
|
+
endpoint?: SessionHostEndpoint;
|
|
13
|
+
appName?: string;
|
|
14
|
+
clientId: string;
|
|
15
|
+
runtimeId: string;
|
|
16
|
+
providerType: string;
|
|
17
|
+
category?: SessionHostCategory;
|
|
18
|
+
workspace: string;
|
|
19
|
+
meta?: Record<string, unknown>;
|
|
20
|
+
attachExisting?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface SessionHostRuntimeOptions extends SessionHostPtyTransportFactoryOptions {
|
|
24
|
+
command: string;
|
|
25
|
+
args: string[];
|
|
26
|
+
spawnOptions: PtySpawnOptions;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class SessionHostRuntimeTransport implements PtyRuntimeTransport {
|
|
30
|
+
readonly ready: Promise<void>;
|
|
31
|
+
|
|
32
|
+
private readonly client: SessionHostClient;
|
|
33
|
+
private readonly dataCallbacks = new Set<(data: string) => void>();
|
|
34
|
+
private readonly exitCallbacks = new Set<(info: { exitCode: number }) => void>();
|
|
35
|
+
private readonly pendingOutput: string[] = [];
|
|
36
|
+
private operationChain = Promise.resolve();
|
|
37
|
+
private unsubscribe: (() => void) | null = null;
|
|
38
|
+
private currentPid = 0;
|
|
39
|
+
private closed = false;
|
|
40
|
+
private metadata: PtyRuntimeMetadata | null = null;
|
|
41
|
+
|
|
42
|
+
constructor(private readonly options: SessionHostRuntimeOptions) {
|
|
43
|
+
this.client = new SessionHostClient({
|
|
44
|
+
endpoint: options.endpoint,
|
|
45
|
+
appName: options.appName,
|
|
46
|
+
});
|
|
47
|
+
this.ready = this.boot();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get pid(): number {
|
|
51
|
+
return this.currentPid;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
getMetadata(): PtyRuntimeMetadata | null {
|
|
55
|
+
return this.metadata ? {
|
|
56
|
+
...this.metadata,
|
|
57
|
+
writeOwner: this.metadata.writeOwner ? { ...this.metadata.writeOwner } : null,
|
|
58
|
+
attachedClients: this.metadata.attachedClients?.map((client) => ({ ...client })) || [],
|
|
59
|
+
} : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
onData(callback: (data: string) => void): void {
|
|
63
|
+
this.dataCallbacks.add(callback);
|
|
64
|
+
if (this.pendingOutput.length > 0) {
|
|
65
|
+
for (const chunk of this.pendingOutput.splice(0)) {
|
|
66
|
+
callback(chunk);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
onExit(callback: (info: { exitCode: number }) => void): void {
|
|
72
|
+
this.exitCallbacks.add(callback);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
write(data: string): void {
|
|
76
|
+
this.enqueue(async () => {
|
|
77
|
+
let response = await this.client.request({
|
|
78
|
+
type: 'send_input',
|
|
79
|
+
payload: {
|
|
80
|
+
sessionId: this.options.runtimeId,
|
|
81
|
+
clientId: this.options.clientId,
|
|
82
|
+
data,
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
if (!response.success && response.error?.startsWith('Write owned by ')) {
|
|
86
|
+
const ownerResponse = await this.client.request<SessionHostRecord>({
|
|
87
|
+
type: 'acquire_write',
|
|
88
|
+
payload: {
|
|
89
|
+
sessionId: this.options.runtimeId,
|
|
90
|
+
clientId: this.options.clientId,
|
|
91
|
+
ownerType: 'user',
|
|
92
|
+
force: true,
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
if (ownerResponse.success && ownerResponse.result) {
|
|
96
|
+
this.updateMetadata(ownerResponse.result);
|
|
97
|
+
response = await this.client.request({
|
|
98
|
+
type: 'send_input',
|
|
99
|
+
payload: {
|
|
100
|
+
sessionId: this.options.runtimeId,
|
|
101
|
+
clientId: this.options.clientId,
|
|
102
|
+
data,
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (!response.success) {
|
|
108
|
+
throw new Error(response.error || `Failed to write to runtime ${this.options.runtimeId}`);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
resize(cols: number, rows: number): void {
|
|
114
|
+
this.enqueue(async () => {
|
|
115
|
+
await this.client.request({
|
|
116
|
+
type: 'resize_session',
|
|
117
|
+
payload: {
|
|
118
|
+
sessionId: this.options.runtimeId,
|
|
119
|
+
cols,
|
|
120
|
+
rows,
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
kill(): void {
|
|
127
|
+
this.enqueue(async () => {
|
|
128
|
+
await this.client.request({
|
|
129
|
+
type: 'stop_session',
|
|
130
|
+
payload: { sessionId: this.options.runtimeId },
|
|
131
|
+
});
|
|
132
|
+
await this.closeClient(false);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
clearBuffer(): void {
|
|
137
|
+
this.enqueue(async () => {
|
|
138
|
+
const response = await this.client.request<SessionHostRecord>({
|
|
139
|
+
type: 'clear_session_buffer',
|
|
140
|
+
payload: {
|
|
141
|
+
sessionId: this.options.runtimeId,
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
if (!response.success) {
|
|
145
|
+
throw new Error(response.error || `Failed to clear runtime buffer ${this.options.runtimeId}`);
|
|
146
|
+
}
|
|
147
|
+
if (response.result) {
|
|
148
|
+
this.updateMetadata(response.result);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
detach(): void {
|
|
154
|
+
this.enqueue(async () => {
|
|
155
|
+
await this.client.request({
|
|
156
|
+
type: 'release_write',
|
|
157
|
+
payload: {
|
|
158
|
+
sessionId: this.options.runtimeId,
|
|
159
|
+
clientId: this.options.clientId,
|
|
160
|
+
},
|
|
161
|
+
}).catch(() => ({ success: false }));
|
|
162
|
+
await this.client.request({
|
|
163
|
+
type: 'detach_session',
|
|
164
|
+
payload: {
|
|
165
|
+
sessionId: this.options.runtimeId,
|
|
166
|
+
clientId: this.options.clientId,
|
|
167
|
+
},
|
|
168
|
+
}).catch(() => ({ success: false }));
|
|
169
|
+
await this.closeClient(false);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private async boot(): Promise<void> {
|
|
174
|
+
await this.client.connect();
|
|
175
|
+
this.unsubscribe = this.client.onEvent((event: SessionHostEvent) => this.handleEvent(event));
|
|
176
|
+
|
|
177
|
+
let record: SessionHostRecord | null = null;
|
|
178
|
+
if (this.options.attachExisting) {
|
|
179
|
+
const existingRecords = await this.client.request<SessionHostRecord[]>({
|
|
180
|
+
type: 'list_sessions',
|
|
181
|
+
payload: {},
|
|
182
|
+
});
|
|
183
|
+
const existingRecord = existingRecords.success && existingRecords.result
|
|
184
|
+
? existingRecords.result.find((item) => item.sessionId === this.options.runtimeId) || null
|
|
185
|
+
: null;
|
|
186
|
+
if (existingRecord?.lifecycle === 'interrupted') {
|
|
187
|
+
const resumeResponse = await this.client.request<SessionHostRecord>({
|
|
188
|
+
type: 'resume_session',
|
|
189
|
+
payload: {
|
|
190
|
+
sessionId: this.options.runtimeId,
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
if (!resumeResponse.success) {
|
|
194
|
+
LOG.warn('CLI', `[session-host:${this.options.runtimeId}] resume failed: ${resumeResponse.error || 'unknown error'}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const attachResponse = await this.client.request<SessionHostRecord>({
|
|
198
|
+
type: 'attach_session',
|
|
199
|
+
payload: {
|
|
200
|
+
sessionId: this.options.runtimeId,
|
|
201
|
+
clientId: this.options.clientId,
|
|
202
|
+
clientType: 'daemon',
|
|
203
|
+
readOnly: false,
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
if (!attachResponse.success || !attachResponse.result) {
|
|
207
|
+
throw new Error(attachResponse.error || `Failed to attach runtime ${this.options.runtimeId}`);
|
|
208
|
+
}
|
|
209
|
+
record = attachResponse.result;
|
|
210
|
+
} else {
|
|
211
|
+
const createResponse = await this.client.request<SessionHostRecord>({
|
|
212
|
+
type: 'create_session',
|
|
213
|
+
payload: {
|
|
214
|
+
sessionId: this.options.runtimeId,
|
|
215
|
+
providerType: this.options.providerType,
|
|
216
|
+
category: this.options.category || 'cli',
|
|
217
|
+
workspace: this.options.workspace,
|
|
218
|
+
launchCommand: {
|
|
219
|
+
command: this.options.command,
|
|
220
|
+
args: this.options.args,
|
|
221
|
+
env: this.options.spawnOptions.env,
|
|
222
|
+
},
|
|
223
|
+
cols: this.options.spawnOptions.cols,
|
|
224
|
+
rows: this.options.spawnOptions.rows,
|
|
225
|
+
clientId: this.options.clientId,
|
|
226
|
+
clientType: 'daemon',
|
|
227
|
+
meta: this.options.meta,
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
if (!createResponse.success || !createResponse.result) {
|
|
231
|
+
throw new Error(createResponse.error || `Failed to create runtime ${this.options.runtimeId}`);
|
|
232
|
+
}
|
|
233
|
+
record = createResponse.result;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
this.currentPid = record.osPid || 0;
|
|
237
|
+
this.updateMetadata(record);
|
|
238
|
+
|
|
239
|
+
const ownerResponse = await this.client.request<SessionHostRecord>({
|
|
240
|
+
type: 'acquire_write',
|
|
241
|
+
payload: {
|
|
242
|
+
sessionId: this.options.runtimeId,
|
|
243
|
+
clientId: this.options.clientId,
|
|
244
|
+
ownerType: 'agent',
|
|
245
|
+
force: !this.options.attachExisting,
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
if (!ownerResponse.success) {
|
|
249
|
+
if (this.options.attachExisting && ownerResponse.error?.startsWith('Write owned by ')) {
|
|
250
|
+
LOG.info('CLI', `[session-host:${this.options.runtimeId}] attached without write ownership (${ownerResponse.error})`);
|
|
251
|
+
} else {
|
|
252
|
+
throw new Error(ownerResponse.error || `Failed to acquire write owner for ${this.options.runtimeId}`);
|
|
253
|
+
}
|
|
254
|
+
} else if (ownerResponse.result) {
|
|
255
|
+
this.updateMetadata(ownerResponse.result);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (this.options.attachExisting) {
|
|
259
|
+
const snapshotResponse = await this.client.request<{ seq: number; text: string; truncated: boolean }>({
|
|
260
|
+
type: 'get_snapshot',
|
|
261
|
+
payload: {
|
|
262
|
+
sessionId: this.options.runtimeId,
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
if (!snapshotResponse.success) {
|
|
266
|
+
throw new Error(snapshotResponse.error || `Failed to load snapshot for ${this.options.runtimeId}`);
|
|
267
|
+
}
|
|
268
|
+
const text = snapshotResponse.result?.text || '';
|
|
269
|
+
if (text) {
|
|
270
|
+
this.emitData(text);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private handleEvent(event: SessionHostEvent): void {
|
|
276
|
+
if (event.sessionId !== this.options.runtimeId) return;
|
|
277
|
+
if ((event.type === 'session_started' || event.type === 'session_resumed') && typeof event.pid === 'number') {
|
|
278
|
+
this.currentPid = event.pid;
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (event.type === 'write_owner_changed') {
|
|
282
|
+
this.metadata = {
|
|
283
|
+
...(this.metadata || { runtimeId: this.options.runtimeId }),
|
|
284
|
+
writeOwner: event.owner ? {
|
|
285
|
+
clientId: event.owner.clientId,
|
|
286
|
+
ownerType: event.owner.ownerType,
|
|
287
|
+
} : null,
|
|
288
|
+
};
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (event.type === 'client_attached') {
|
|
292
|
+
const nextClients = new Map(
|
|
293
|
+
(this.metadata?.attachedClients || []).map((client) => [client.clientId, client] as const),
|
|
294
|
+
);
|
|
295
|
+
nextClients.set(event.client.clientId, {
|
|
296
|
+
clientId: event.client.clientId,
|
|
297
|
+
type: event.client.type,
|
|
298
|
+
readOnly: event.client.readOnly,
|
|
299
|
+
});
|
|
300
|
+
this.metadata = {
|
|
301
|
+
...(this.metadata || { runtimeId: this.options.runtimeId }),
|
|
302
|
+
attachedClients: Array.from(nextClients.values()),
|
|
303
|
+
};
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (event.type === 'client_detached') {
|
|
307
|
+
this.metadata = {
|
|
308
|
+
...(this.metadata || { runtimeId: this.options.runtimeId }),
|
|
309
|
+
attachedClients: (this.metadata?.attachedClients || []).filter((client) => client.clientId !== event.clientId),
|
|
310
|
+
};
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (event.type === 'session_output') {
|
|
314
|
+
this.emitData(event.data);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (event.type === 'session_cleared') {
|
|
318
|
+
this.pendingOutput.length = 0;
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (event.type === 'session_exit') {
|
|
322
|
+
for (const callback of this.exitCallbacks) {
|
|
323
|
+
callback({ exitCode: event.exitCode ?? 0 });
|
|
324
|
+
}
|
|
325
|
+
void this.closeClient(false);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private emitData(data: string): void {
|
|
330
|
+
if (this.dataCallbacks.size === 0) {
|
|
331
|
+
this.pendingOutput.push(data);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
for (const callback of this.dataCallbacks) {
|
|
335
|
+
callback(data);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private updateMetadata(record: SessionHostRecord): void {
|
|
340
|
+
this.metadata = {
|
|
341
|
+
runtimeId: record.sessionId,
|
|
342
|
+
runtimeKey: record.runtimeKey,
|
|
343
|
+
displayName: record.displayName,
|
|
344
|
+
workspaceLabel: record.workspaceLabel,
|
|
345
|
+
writeOwner: record.writeOwner ? {
|
|
346
|
+
clientId: record.writeOwner.clientId,
|
|
347
|
+
ownerType: record.writeOwner.ownerType,
|
|
348
|
+
} : null,
|
|
349
|
+
attachedClients: record.attachedClients.map((client: SessionHostRecord['attachedClients'][number]) => ({
|
|
350
|
+
clientId: client.clientId,
|
|
351
|
+
type: client.type,
|
|
352
|
+
readOnly: client.readOnly,
|
|
353
|
+
})),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private enqueue(action: () => Promise<void>): void {
|
|
358
|
+
this.operationChain = this.operationChain
|
|
359
|
+
.then(() => this.ready)
|
|
360
|
+
.then(action)
|
|
361
|
+
.catch((error) => {
|
|
362
|
+
LOG.warn('CLI', `[session-host:${this.options.runtimeId}] ${error?.message || error}`);
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private async closeClient(destroy = false): Promise<void> {
|
|
367
|
+
if (this.closed) return;
|
|
368
|
+
this.closed = true;
|
|
369
|
+
try {
|
|
370
|
+
this.unsubscribe?.();
|
|
371
|
+
this.unsubscribe = null;
|
|
372
|
+
} catch { /* noop */ }
|
|
373
|
+
try {
|
|
374
|
+
await this.client.close();
|
|
375
|
+
} catch {
|
|
376
|
+
if (destroy) throw new Error(`Failed to close session host client: ${this.options.runtimeId}`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export class SessionHostPtyTransportFactory implements PtyTransportFactory {
|
|
382
|
+
constructor(private readonly options: SessionHostPtyTransportFactoryOptions) {}
|
|
383
|
+
|
|
384
|
+
spawn(command: string, args: string[], spawnOptions: PtySpawnOptions): PtyRuntimeTransport {
|
|
385
|
+
return new SessionHostRuntimeTransport({
|
|
386
|
+
...this.options,
|
|
387
|
+
command,
|
|
388
|
+
args,
|
|
389
|
+
spawnOptions,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
TerminalViewportBackend,
|
|
3
|
+
TerminalViewportBackendOptions,
|
|
4
|
+
TerminalViewportBackendPreference,
|
|
5
|
+
} from './types.js';
|
|
6
|
+
|
|
7
|
+
type GhosttyVtTerminal = {
|
|
8
|
+
write(data: string | Uint8Array): void;
|
|
9
|
+
resize(cols: number, rows: number): void;
|
|
10
|
+
formatPlainText(options?: { trim?: boolean }): string;
|
|
11
|
+
dispose(): void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type GhosttyVtBinding = {
|
|
15
|
+
createTerminal(options: { cols: number; rows: number; scrollback: number }): GhosttyVtTerminal;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const DEFAULT_BINDING_CANDIDATES = [
|
|
19
|
+
'@adhdev/ghostty-vt-node',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
let cachedBinding: GhosttyVtBinding | null | undefined;
|
|
23
|
+
let cachedBindingError: Error | null = null;
|
|
24
|
+
|
|
25
|
+
function isModuleNotFoundError(error: unknown, ref: string): boolean {
|
|
26
|
+
if (!(error instanceof Error)) return false;
|
|
27
|
+
const message = error.message || '';
|
|
28
|
+
const code = (error as any).code;
|
|
29
|
+
return code === 'MODULE_NOT_FOUND' && message.includes(ref);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeBinding(mod: any, ref: string): GhosttyVtBinding {
|
|
33
|
+
const binding = mod?.default?.createTerminal
|
|
34
|
+
? mod.default
|
|
35
|
+
: mod?.createTerminal
|
|
36
|
+
? mod
|
|
37
|
+
: null;
|
|
38
|
+
|
|
39
|
+
if (!binding) {
|
|
40
|
+
throw new Error(`Ghostty VT binding "${ref}" does not export createTerminal()`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return binding as GhosttyVtBinding;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getBindingCandidates(): string[] {
|
|
47
|
+
const explicit = process.env.ADHDEV_GHOSTTY_VT_BINDING?.trim();
|
|
48
|
+
return explicit ? [explicit] : DEFAULT_BINDING_CANDIDATES;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function loadGhosttyVtBinding(required: boolean): GhosttyVtBinding | null {
|
|
52
|
+
if (cachedBinding !== undefined) {
|
|
53
|
+
if (!cachedBinding && required && cachedBindingError) {
|
|
54
|
+
throw cachedBindingError;
|
|
55
|
+
}
|
|
56
|
+
return cachedBinding;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const errors: string[] = [];
|
|
60
|
+
|
|
61
|
+
for (const ref of getBindingCandidates()) {
|
|
62
|
+
try {
|
|
63
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
64
|
+
const mod = require(ref);
|
|
65
|
+
cachedBinding = normalizeBinding(mod, ref);
|
|
66
|
+
cachedBindingError = null;
|
|
67
|
+
return cachedBinding;
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (isModuleNotFoundError(error, ref)) {
|
|
70
|
+
errors.push(`${ref}: module not found`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
74
|
+
errors.push(`${ref}: ${message}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
cachedBinding = null;
|
|
79
|
+
cachedBindingError = new Error(
|
|
80
|
+
`ghostty-vt backend requested but no binding is available (${errors.join('; ') || 'no candidates tried'})`,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
if (required) throw cachedBindingError;
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function resolveTerminalBackendPreference(): TerminalViewportBackendPreference {
|
|
88
|
+
const raw = process.env.ADHDEV_TERMINAL_BACKEND?.trim().toLowerCase();
|
|
89
|
+
if (raw === 'ghostty-vt' || raw === 'xterm' || raw === 'auto') return raw;
|
|
90
|
+
return 'auto';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function isGhosttyVtBackendAvailable(): boolean {
|
|
94
|
+
return !!loadGhosttyVtBinding(false);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export class GhosttyVtTerminalBackend implements TerminalViewportBackend {
|
|
98
|
+
readonly kind = 'ghostty-vt' as const;
|
|
99
|
+
private terminal: GhosttyVtTerminal;
|
|
100
|
+
|
|
101
|
+
constructor(options: TerminalViewportBackendOptions) {
|
|
102
|
+
const binding = loadGhosttyVtBinding(true);
|
|
103
|
+
this.terminal = binding.createTerminal({
|
|
104
|
+
cols: Math.max(1, options.cols | 0),
|
|
105
|
+
rows: Math.max(1, options.rows | 0),
|
|
106
|
+
scrollback: Math.max(0, options.scrollback | 0),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
resize(rows: number, cols: number): void {
|
|
111
|
+
this.terminal.resize(Math.max(1, cols | 0), Math.max(1, rows | 0));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
write(data: string): void {
|
|
115
|
+
if (!data) return;
|
|
116
|
+
this.terminal.write(data);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
getText(): string {
|
|
120
|
+
return this.terminal.formatPlainText({ trim: true }) || '';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
dispose(): void {
|
|
124
|
+
this.terminal.dispose();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type TerminalViewportBackendKind = 'xterm' | 'ghostty-vt';
|
|
2
|
+
|
|
3
|
+
export type TerminalViewportBackendPreference = TerminalViewportBackendKind | 'auto';
|
|
4
|
+
|
|
5
|
+
export type TerminalViewportBackendOptions = {
|
|
6
|
+
rows: number;
|
|
7
|
+
cols: number;
|
|
8
|
+
scrollback: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export interface TerminalViewportBackend {
|
|
12
|
+
readonly kind: TerminalViewportBackendKind;
|
|
13
|
+
resize(rows: number, cols: number): void;
|
|
14
|
+
write(data: string): void;
|
|
15
|
+
getText(): string;
|
|
16
|
+
dispose(): void;
|
|
17
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { TerminalViewportBackend, TerminalViewportBackendOptions } from './types.js';
|
|
2
|
+
|
|
3
|
+
type XtermBufferLine = {
|
|
4
|
+
translateToString(trimRight?: boolean): string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
type XtermBuffer = {
|
|
8
|
+
length: number;
|
|
9
|
+
viewportY: number;
|
|
10
|
+
getLine(index: number): XtermBufferLine | undefined;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type XtermTerminal = {
|
|
14
|
+
buffer: { active: XtermBuffer };
|
|
15
|
+
write(data: string, callback?: () => void): void;
|
|
16
|
+
resize(cols: number, rows: number): void;
|
|
17
|
+
dispose(): void;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
let TerminalCtor: (new (options: { cols: number; rows: number; scrollback: number }) => XtermTerminal) | null = null;
|
|
21
|
+
|
|
22
|
+
function loadTerminalCtor(): new (options: { cols: number; rows: number; scrollback: number }) => XtermTerminal {
|
|
23
|
+
if (!TerminalCtor) {
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
25
|
+
const mod = require('@xterm/xterm');
|
|
26
|
+
TerminalCtor = mod.Terminal || mod.default?.Terminal || mod.default;
|
|
27
|
+
if (!TerminalCtor) {
|
|
28
|
+
throw new Error('@xterm/xterm Terminal export not found');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return TerminalCtor;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class XtermTerminalBackend implements TerminalViewportBackend {
|
|
35
|
+
readonly kind = 'xterm' as const;
|
|
36
|
+
private rows: number;
|
|
37
|
+
private cols: number;
|
|
38
|
+
private terminal: XtermTerminal;
|
|
39
|
+
|
|
40
|
+
constructor(options: TerminalViewportBackendOptions) {
|
|
41
|
+
this.rows = Math.max(1, options.rows | 0);
|
|
42
|
+
this.cols = Math.max(1, options.cols | 0);
|
|
43
|
+
this.terminal = this.createTerminal(options.scrollback);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
resize(rows: number, cols: number): void {
|
|
47
|
+
this.rows = Math.max(1, rows | 0);
|
|
48
|
+
this.cols = Math.max(1, cols | 0);
|
|
49
|
+
this.terminal.resize(this.cols, this.rows);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
write(data: string): void {
|
|
53
|
+
if (!data) return;
|
|
54
|
+
this.terminal.write(data);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
getText(): string {
|
|
58
|
+
const buffer = this.terminal.buffer.active;
|
|
59
|
+
const start = Math.max(0, buffer.viewportY || 0);
|
|
60
|
+
const end = Math.max(start, Math.min(buffer.length || 0, start + this.rows));
|
|
61
|
+
const lines: string[] = [];
|
|
62
|
+
|
|
63
|
+
for (let i = start; i < end; i++) {
|
|
64
|
+
const line = buffer.getLine(i);
|
|
65
|
+
lines.push(line ? line.translateToString(true) : '');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let first = 0;
|
|
69
|
+
let last = lines.length;
|
|
70
|
+
while (first < last && !lines[first]?.trim()) first++;
|
|
71
|
+
while (last > first && !lines[last - 1]?.trim()) last--;
|
|
72
|
+
return lines.slice(first, last).join('\n');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
dispose(): void {
|
|
76
|
+
this.terminal.dispose();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private createTerminal(scrollback: number): XtermTerminal {
|
|
80
|
+
const Terminal = loadTerminalCtor();
|
|
81
|
+
return new Terminal({
|
|
82
|
+
cols: this.cols,
|
|
83
|
+
rows: this.rows,
|
|
84
|
+
scrollback,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|