@lzdi/pty-remote-cli 0.1.3

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,674 @@
1
+ import { execFile, execFileSync } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { mkdirSync, promises as fs, readFileSync, writeFileSync } from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { promisify } from 'node:util';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ import { io, type Socket } from 'socket.io-client';
10
+ import type {
11
+ CliCommandEnvelope,
12
+ CliCommandResult,
13
+ CliRegisterPayload,
14
+ CliRegisterResult,
15
+ GetRuntimeSnapshotResultPayload,
16
+ ListSlashCommandsResultPayload,
17
+ ListManagedPtyHandlesResultPayload,
18
+ ListProjectSessionsResultPayload,
19
+ PickProjectDirectoryResultPayload,
20
+ UploadAttachmentResultPayload,
21
+ RuntimeSnapshotPayload,
22
+ TerminalFramePatchPayload,
23
+ TerminalSessionEvictedPayload
24
+ } from '@lzdi/pty-remote-protocol/protocol.ts';
25
+ import type { ProviderId } from '@lzdi/pty-remote-protocol/runtime-types.ts';
26
+ import { AttachmentManager } from '../attachments/manager.ts';
27
+ import { createClaudeProviderRuntime } from '../providers/claude.ts';
28
+ import { createCodexProviderRuntime, type CodexProviderRuntimeOptions } from '../providers/codex.ts';
29
+ import type { ProviderRuntime } from '../providers/provider-runtime.ts';
30
+ import { loadCliConfig } from './cli-config.ts';
31
+ import type { PtyManagerOptions } from './pty-manager.ts';
32
+
33
+ const __filename = fileURLToPath(import.meta.url);
34
+ const __dirname = path.dirname(__filename);
35
+ const DEFAULT_ROOT_DIR = path.resolve(process.cwd());
36
+ const CONFIG_DIR = path.join(os.homedir(), '.pty-remote');
37
+ const CLI_ID_FILE = path.join(CONFIG_DIR, 'cli-id');
38
+ const ALL_PROVIDERS: ProviderId[] = ['claude', 'codex'];
39
+
40
+ const cliConfig = loadCliConfig();
41
+
42
+ function getConfigValue(key: string): string | undefined {
43
+ const envValue = process.env[key];
44
+ if (typeof envValue === 'string' && envValue.trim()) {
45
+ return envValue.trim();
46
+ }
47
+ const configValue = cliConfig[key];
48
+ if (typeof configValue === 'string' && configValue.trim()) {
49
+ return configValue.trim();
50
+ }
51
+ return undefined;
52
+ }
53
+
54
+ function getConfigInt(key: string, fallback: number, min = 0): number {
55
+ const raw = getConfigValue(key);
56
+ if (!raw) {
57
+ return fallback;
58
+ }
59
+ const parsed = Number.parseInt(raw, 10);
60
+ if (!Number.isFinite(parsed) || parsed < min) {
61
+ return fallback;
62
+ }
63
+ return parsed;
64
+ }
65
+
66
+ const configCodexHome = getConfigValue('CODEX_HOME');
67
+ if (configCodexHome && !process.env.CODEX_HOME) {
68
+ process.env.CODEX_HOME = configCodexHome;
69
+ }
70
+
71
+ const SOCKET_URL =
72
+ getConfigValue('SOCKET_URL') ?? `http://${getConfigValue('HOST') ?? '127.0.0.1'}:${getConfigValue('PORT') ?? '3001'}`;
73
+ const execFileAsync = promisify(execFile);
74
+ const SUPPORTED_PROVIDERS = resolveSupportedProviders(resolveProvidersConfigValue());
75
+ const CLI_ID = resolveCliId();
76
+ const CLI_LABEL = resolveCliLabel();
77
+ const PTY_BACKEND_NAME = 'node-pty';
78
+ const TERMINAL_FRAME_SCROLLBACK = getConfigInt('TERMINAL_FRAME_SCROLLBACK', 500, 50);
79
+ const RECENT_OUTPUT_MAX_CHARS = getConfigInt('RECENT_OUTPUT_MAX_CHARS', 12_000, 1);
80
+ const CLAUDE_READY_TIMEOUT_MS = getConfigInt('CLAUDE_READY_TIMEOUT_MS', 20_000, 0);
81
+ const CODEX_READY_TIMEOUT_MS = getConfigInt('CODEX_READY_TIMEOUT_MS', 20_000, 0);
82
+ const PROMPT_SUBMIT_DELAY_MS = getConfigInt('PROMPT_SUBMIT_DELAY_MS', 120, 0);
83
+ const JSONL_REFRESH_DEBOUNCE_MS = getConfigInt('JSONL_REFRESH_DEBOUNCE_MS', 120, 0);
84
+ const SNAPSHOT_EMIT_DEBOUNCE_MS = getConfigInt('SNAPSHOT_EMIT_DEBOUNCE_MS', 200, 0);
85
+ const SNAPSHOT_MESSAGES_MAX = getConfigInt('SNAPSHOT_MESSAGES_MAX', 40, 1);
86
+ const OLDER_MESSAGES_PAGE_MAX = getConfigInt('OLDER_MESSAGES_PAGE_MAX', 40, 1);
87
+ const TERMINAL_COLS = getConfigInt('TERMINAL_COLS', 120, 1);
88
+ const TERMINAL_ROWS = getConfigInt('TERMINAL_ROWS', 32, 1);
89
+ const DETACHED_PTY_TTL_MS = getConfigInt('DETACHED_PTY_TTL_MS', 12 * 60 * 60 * 1000, 0);
90
+ const DETACHED_DRAFT_TTL_MS = getConfigInt('DETACHED_DRAFT_TTL_MS', 5 * 60 * 1000, 0);
91
+ const DETACHED_JSONL_MISSING_TTL_MS = getConfigInt('DETACHED_JSONL_MISSING_TTL_MS', 2 * 60 * 1000, 0);
92
+ const GC_INTERVAL_MS = getConfigInt('GC_INTERVAL_MS', 5 * 60 * 1000, 0);
93
+ const MAX_DETACHED_PTYS = getConfigInt('PTY_REMOTE_MAX_DETACHED_PTYS', 5, 1);
94
+ const CLAUDE_PERMISSION_MODE = sanitizePermissionMode(getConfigValue('CLAUDE_PERMISSION_MODE'));
95
+
96
+ let socketClient: Socket | null = null;
97
+ let shuttingDown = false;
98
+ let shutdownPromise: Promise<void> | null = null;
99
+ const attachmentManager = new AttachmentManager();
100
+
101
+ const runtimeOptions: PtyManagerOptions = {
102
+ claudeBin: getConfigValue('CLAUDE_BIN') ?? (process.platform === 'darwin' ? '/opt/homebrew/bin/claude' : 'claude'),
103
+ permissionMode: CLAUDE_PERMISSION_MODE,
104
+ defaultCwd: DEFAULT_ROOT_DIR,
105
+ terminalCols: TERMINAL_COLS,
106
+ terminalRows: TERMINAL_ROWS,
107
+ terminalFrameScrollback: TERMINAL_FRAME_SCROLLBACK,
108
+ recentOutputMaxChars: RECENT_OUTPUT_MAX_CHARS,
109
+ claudeReadyTimeoutMs: CLAUDE_READY_TIMEOUT_MS,
110
+ promptSubmitDelayMs: PROMPT_SUBMIT_DELAY_MS,
111
+ jsonlRefreshDebounceMs: JSONL_REFRESH_DEBOUNCE_MS,
112
+ snapshotEmitDebounceMs: SNAPSHOT_EMIT_DEBOUNCE_MS,
113
+ snapshotMessagesMax: SNAPSHOT_MESSAGES_MAX,
114
+ olderMessagesPageMax: OLDER_MESSAGES_PAGE_MAX,
115
+ gcIntervalMs: GC_INTERVAL_MS,
116
+ detachedDraftTtlMs: DETACHED_DRAFT_TTL_MS,
117
+ detachedJsonlMissingTtlMs: DETACHED_JSONL_MISSING_TTL_MS,
118
+ detachedPtyTtlMs: DETACHED_PTY_TTL_MS,
119
+ maxDetachedPtys: MAX_DETACHED_PTYS
120
+ };
121
+
122
+ const codexRuntimeOptions: CodexProviderRuntimeOptions = {
123
+ codexBin: getConfigValue('CODEX_BIN') ?? (process.platform === 'darwin' ? '/opt/homebrew/bin/codex' : 'codex'),
124
+ defaultCwd: DEFAULT_ROOT_DIR,
125
+ terminalCols: TERMINAL_COLS,
126
+ terminalRows: TERMINAL_ROWS,
127
+ terminalFrameScrollback: TERMINAL_FRAME_SCROLLBACK,
128
+ recentOutputMaxChars: RECENT_OUTPUT_MAX_CHARS,
129
+ codexReadyTimeoutMs: CODEX_READY_TIMEOUT_MS,
130
+ promptSubmitDelayMs: PROMPT_SUBMIT_DELAY_MS,
131
+ jsonlRefreshDebounceMs: JSONL_REFRESH_DEBOUNCE_MS,
132
+ snapshotEmitDebounceMs: SNAPSHOT_EMIT_DEBOUNCE_MS,
133
+ snapshotMessagesMax: SNAPSHOT_MESSAGES_MAX,
134
+ olderMessagesPageMax: OLDER_MESSAGES_PAGE_MAX,
135
+ gcIntervalMs: GC_INTERVAL_MS,
136
+ detachedDraftTtlMs: DETACHED_DRAFT_TTL_MS,
137
+ detachedJsonlMissingTtlMs: DETACHED_JSONL_MISSING_TTL_MS,
138
+ detachedPtyTtlMs: DETACHED_PTY_TTL_MS,
139
+ maxDetachedPtys: MAX_DETACHED_PTYS,
140
+ historyPath: getConfigValue('CODEX_HISTORY_PATH')?.trim() || undefined,
141
+ sessionsRootPath: getConfigValue('CODEX_SESSIONS_ROOT_PATH')?.trim() || undefined
142
+ };
143
+
144
+ function cliErrorMessage(error: unknown, fallback: string): string {
145
+ return error instanceof Error ? error.message : fallback;
146
+ }
147
+
148
+ function logCli(level: 'info' | 'warn' | 'error', message: string, details?: Record<string, unknown>): void {
149
+ const logger = level === 'info' ? console.log : level === 'warn' ? console.warn : console.error;
150
+ if (details) {
151
+ logger(`[pty-remote][cli] ${message}`, details);
152
+ return;
153
+ }
154
+ logger(`[pty-remote][cli] ${message}`);
155
+ }
156
+
157
+ function createRuntime(providerId: ProviderId): ProviderRuntime {
158
+ const callbacks = {
159
+ emitMessagesUpsert(payload: {
160
+ providerId: ProviderId | null;
161
+ conversationKey: string | null;
162
+ sessionId: string | null;
163
+ upserts: ReturnType<ProviderRuntime['getSnapshot']>['messages'];
164
+ recentMessageIds: string[];
165
+ hasOlderMessages: boolean;
166
+ }) {
167
+ if (!socketClient?.connected) {
168
+ return;
169
+ }
170
+ socketClient.emit('cli:messages-upsert', {
171
+ ...payload,
172
+ cliId: CLI_ID
173
+ });
174
+ },
175
+ emitSnapshot(snapshot: ReturnType<ProviderRuntime['getSnapshot']>) {
176
+ if (!socketClient?.connected) {
177
+ return;
178
+ }
179
+ socketClient.emit('cli:snapshot', {
180
+ cliId: CLI_ID,
181
+ providerId,
182
+ snapshot
183
+ } satisfies RuntimeSnapshotPayload);
184
+ },
185
+ emitTerminalFramePatch(payload: { conversationKey: string | null; patch: TerminalFramePatchPayload['patch'] }) {
186
+ if (!socketClient?.connected) {
187
+ return;
188
+ }
189
+ socketClient.emit('cli:terminal-frame-patch', {
190
+ ...payload,
191
+ cliId: CLI_ID,
192
+ providerId
193
+ } satisfies TerminalFramePatchPayload);
194
+ },
195
+ emitTerminalSessionEvicted(payload: { conversationKey: string | null; reason: string; sessionId: string }) {
196
+ if (!socketClient?.connected) {
197
+ return;
198
+ }
199
+ socketClient.emit('cli:terminal-session-evicted', {
200
+ ...payload,
201
+ cliId: CLI_ID,
202
+ providerId
203
+ } satisfies TerminalSessionEvictedPayload);
204
+ }
205
+ };
206
+
207
+ if (providerId === 'codex') {
208
+ return createCodexProviderRuntime(codexRuntimeOptions, callbacks);
209
+ }
210
+
211
+ return createClaudeProviderRuntime(runtimeOptions, callbacks);
212
+ }
213
+
214
+ const runtimes = Object.fromEntries(
215
+ SUPPORTED_PROVIDERS.map((providerId) => [providerId, createRuntime(providerId)])
216
+ ) as Record<ProviderId, ProviderRuntime>;
217
+
218
+ function sanitizeIdentifier(value: string): string {
219
+ return value.trim().replace(/[^a-zA-Z0-9._-]+/g, '-');
220
+ }
221
+
222
+ function resolveCliId(): string {
223
+ const explicit = getConfigValue('PTY_REMOTE_CLI_ID');
224
+ if (explicit) {
225
+ return sanitizeIdentifier(explicit);
226
+ }
227
+
228
+ try {
229
+ const persisted = readFileSync(CLI_ID_FILE, 'utf8').trim();
230
+ if (persisted) {
231
+ return sanitizeIdentifier(persisted);
232
+ }
233
+ } catch {
234
+ // Fall back to generating a local persistent id.
235
+ }
236
+
237
+ const generated = sanitizeIdentifier(`cli-${randomUUID()}`);
238
+ mkdirSync(CONFIG_DIR, { recursive: true });
239
+ writeFileSync(CLI_ID_FILE, `${generated}\n`, 'utf8');
240
+ return generated;
241
+ }
242
+
243
+ function resolveCliLabel(): string {
244
+ const envMachineName = process.env.COMPUTERNAME?.trim() || process.env.HOSTNAME?.trim();
245
+ if (envMachineName) {
246
+ return envMachineName;
247
+ }
248
+
249
+ if (process.platform === 'darwin') {
250
+ try {
251
+ const computerName = execFileSyncSafe('scutil', ['--get', 'ComputerName']);
252
+ if (computerName) {
253
+ return computerName;
254
+ }
255
+ } catch {
256
+ // Fall back to hostname.
257
+ }
258
+ }
259
+
260
+ return os.hostname() || path.basename(DEFAULT_ROOT_DIR) || 'pty-remote-cli';
261
+ }
262
+
263
+ function execFileSyncSafe(command: string, args: string[]): string {
264
+ return `${execFileSync(command, args, { encoding: 'utf8' })}`.trim();
265
+ }
266
+
267
+ async function resolveProjectCwd(rawCwd: string): Promise<string> {
268
+ const resolvedCwd = path.resolve(rawCwd);
269
+ return fs.realpath(resolvedCwd).catch(() => resolvedCwd);
270
+ }
271
+
272
+ function sanitizePermissionMode(value: string | undefined): string {
273
+ const allowed = new Set(['default', 'acceptEdits', 'dontAsk', 'plan', 'bypassPermissions']);
274
+ if (value && allowed.has(value)) {
275
+ return value;
276
+ }
277
+ return 'bypassPermissions';
278
+ }
279
+
280
+ function resolveProvidersConfigValue(): string | undefined {
281
+ const envProviders = process.env.PTY_REMOTE_PROVIDERS?.trim();
282
+ if (envProviders) {
283
+ return envProviders;
284
+ }
285
+
286
+ const envProvider = process.env.PTY_REMOTE_PROVIDER?.trim();
287
+ if (envProvider) {
288
+ return envProvider;
289
+ }
290
+
291
+ const configProviders = cliConfig.PTY_REMOTE_PROVIDERS?.trim();
292
+ if (configProviders) {
293
+ return configProviders;
294
+ }
295
+
296
+ const configProvider = cliConfig.PTY_REMOTE_PROVIDER?.trim();
297
+ if (configProvider) {
298
+ return configProvider;
299
+ }
300
+
301
+ return undefined;
302
+ }
303
+
304
+ function resolveSupportedProviders(value: string | undefined): ProviderId[] {
305
+ const normalizedProviders = (value ?? '')
306
+ .split(',')
307
+ .map((entry) => entry.trim().toLowerCase())
308
+ .filter(Boolean);
309
+ const providers = ALL_PROVIDERS.filter((providerId) => normalizedProviders.includes(providerId));
310
+ return providers.length > 0 ? providers : [...ALL_PROVIDERS];
311
+ }
312
+
313
+ function getRuntime(providerId: ProviderId): ProviderRuntime {
314
+ const runtime = runtimes[providerId];
315
+ if (!runtime) {
316
+ throw new Error(`Provider ${providerId} is not enabled on this CLI`);
317
+ }
318
+ return runtime;
319
+ }
320
+
321
+ function requireTargetProviderId(envelope: CliCommandEnvelope): ProviderId {
322
+ const providerId = envelope.targetProviderId ?? null;
323
+ if (!providerId) {
324
+ throw new Error('Provider is not selected');
325
+ }
326
+ return providerId;
327
+ }
328
+
329
+ async function handleSocketCommand(envelope: CliCommandEnvelope): Promise<CliCommandResult> {
330
+ try {
331
+ if (envelope.name === 'send-message') {
332
+ const content = (envelope.payload as { content: string }).content;
333
+ await getRuntime(requireTargetProviderId(envelope)).dispatchMessage(content);
334
+ attachmentManager.markReferencedPathsAsSent(content);
335
+ return { ok: true, payload: null };
336
+ }
337
+
338
+ if (envelope.name === 'list-slash-commands') {
339
+ const runtime = getRuntime(requireTargetProviderId(envelope));
340
+ return {
341
+ ok: true,
342
+ payload: {
343
+ providerId: runtime.providerId,
344
+ commands: await runtime.listSlashCommands()
345
+ } satisfies ListSlashCommandsResultPayload
346
+ };
347
+ }
348
+
349
+ if (envelope.name === 'upload-attachment') {
350
+ const providerId = requireTargetProviderId(envelope);
351
+ const payload = envelope.payload as {
352
+ contentBase64: string;
353
+ conversationKey: string | null;
354
+ cwd: string;
355
+ filename: string;
356
+ mimeType: string;
357
+ sessionId: string | null;
358
+ size: number;
359
+ };
360
+ const attachment = await attachmentManager.uploadAttachment({
361
+ contentBase64: payload.contentBase64,
362
+ conversationKey: payload.conversationKey,
363
+ cwd: payload.cwd,
364
+ filename: payload.filename,
365
+ mimeType: payload.mimeType,
366
+ providerId,
367
+ sessionId: payload.sessionId,
368
+ size: payload.size
369
+ });
370
+
371
+ return {
372
+ ok: true,
373
+ payload: {
374
+ attachmentId: attachment.attachmentId,
375
+ filename: attachment.filename,
376
+ mimeType: attachment.mimeType,
377
+ path: attachment.path,
378
+ size: attachment.size
379
+ } satisfies UploadAttachmentResultPayload
380
+ };
381
+ }
382
+
383
+ if (envelope.name === 'delete-attachment') {
384
+ const payload = envelope.payload as { attachmentId: string };
385
+ await attachmentManager.deleteAttachment(payload.attachmentId);
386
+ return { ok: true, payload: null };
387
+ }
388
+
389
+ if (envelope.name === 'stop-message') {
390
+ await getRuntime(requireTargetProviderId(envelope)).stopActiveRun();
391
+ return { ok: true, payload: null };
392
+ }
393
+
394
+ if (envelope.name === 'reset-session') {
395
+ await getRuntime(requireTargetProviderId(envelope)).resetActiveConversation();
396
+ return { ok: true, payload: null };
397
+ }
398
+
399
+ if (envelope.name === 'get-runtime-snapshot') {
400
+ const runtime = getRuntime(requireTargetProviderId(envelope));
401
+ await runtime.refreshActiveState();
402
+ return {
403
+ ok: true,
404
+ payload: {
405
+ snapshot: runtime.getSnapshot()
406
+ } satisfies GetRuntimeSnapshotResultPayload
407
+ };
408
+ }
409
+
410
+ if (envelope.name === 'get-older-messages') {
411
+ const payload = envelope.payload as { beforeMessageId?: string; maxMessages?: number };
412
+ const runtime = getRuntime(requireTargetProviderId(envelope));
413
+ return {
414
+ ok: true,
415
+ payload: await runtime.getOlderMessages(payload.beforeMessageId, payload.maxMessages)
416
+ };
417
+ }
418
+
419
+ if (envelope.name === 'select-conversation') {
420
+ const payload = envelope.payload as {
421
+ cwd: string;
422
+ label?: string;
423
+ sessionId: string | null;
424
+ conversationKey: string;
425
+ clientRequestId?: string | null;
426
+ };
427
+ const runtime = getRuntime(requireTargetProviderId(envelope));
428
+ return {
429
+ ok: true,
430
+ payload: {
431
+ ...(await runtime.activateConversation({
432
+ cwd: payload.cwd,
433
+ label: payload.label?.trim() || path.basename(payload.cwd) || payload.cwd,
434
+ sessionId: payload.sessionId ?? null,
435
+ conversationKey: payload.conversationKey
436
+ })),
437
+ clientRequestId: payload.clientRequestId ?? null
438
+ }
439
+ };
440
+ }
441
+
442
+ if (envelope.name === 'cleanup-project') {
443
+ const payload = envelope.payload as { cwd: string };
444
+ const providerId = requireTargetProviderId(envelope);
445
+ const runtime = getRuntime(providerId);
446
+ await runtime.cleanupProject(payload.cwd);
447
+ await attachmentManager.cleanupProject({
448
+ cwd: payload.cwd,
449
+ providerId
450
+ });
451
+ return { ok: true, payload: null };
452
+ }
453
+
454
+ if (envelope.name === 'cleanup-conversation') {
455
+ const payload = envelope.payload as {
456
+ cwd: string;
457
+ conversationKey: string;
458
+ sessionId: string | null;
459
+ };
460
+ const providerId = requireTargetProviderId(envelope);
461
+ const runtime = getRuntime(providerId);
462
+ await runtime.cleanupConversation({
463
+ cwd: payload.cwd,
464
+ conversationKey: payload.conversationKey,
465
+ sessionId: payload.sessionId ?? null
466
+ });
467
+ await attachmentManager.cleanupConversation({
468
+ conversationKey: payload.conversationKey,
469
+ cwd: payload.cwd,
470
+ providerId,
471
+ sessionId: payload.sessionId ?? null
472
+ });
473
+ return { ok: true, payload: null };
474
+ }
475
+
476
+ if (envelope.name === 'list-project-conversations') {
477
+ const payload = envelope.payload as { cwd: string; maxSessions?: number };
478
+ const runtime = getRuntime(requireTargetProviderId(envelope));
479
+ const cwd = await resolveProjectCwd(payload.cwd);
480
+ return {
481
+ ok: true,
482
+ payload: {
483
+ providerId: runtime.providerId,
484
+ cwd,
485
+ label: path.basename(cwd) || cwd,
486
+ sessions: await runtime.listProjectConversations(cwd, payload.maxSessions)
487
+ } satisfies ListProjectSessionsResultPayload
488
+ };
489
+ }
490
+
491
+ if (envelope.name === 'list-managed-pty-handles') {
492
+ const runtime = getRuntime(requireTargetProviderId(envelope));
493
+ return {
494
+ ok: true,
495
+ payload: {
496
+ providerId: runtime.providerId,
497
+ handles: await runtime.listManagedPtyHandles()
498
+ } satisfies ListManagedPtyHandlesResultPayload
499
+ };
500
+ }
501
+
502
+ if (envelope.name === 'pick-project-directory') {
503
+ return {
504
+ ok: true,
505
+ payload: await pickProjectDirectory()
506
+ };
507
+ }
508
+
509
+ return { ok: false, error: `Unsupported command: ${envelope.name}` };
510
+ } catch (error) {
511
+ logCli('error', 'socket command failed', {
512
+ command: envelope.name,
513
+ error: cliErrorMessage(error, 'CLI command failed'),
514
+ requestId: envelope.requestId,
515
+ targetProviderId: envelope.targetProviderId ?? null
516
+ });
517
+ return {
518
+ ok: false,
519
+ error: error instanceof Error ? error.message : 'CLI command failed'
520
+ };
521
+ }
522
+ }
523
+
524
+ async function pickProjectDirectory(): Promise<PickProjectDirectoryResultPayload> {
525
+ if (process.platform === 'darwin') {
526
+ let stdout = '';
527
+ try {
528
+ ({ stdout } = await execFileAsync('osascript', [
529
+ '-e',
530
+ 'POSIX path of (choose folder with prompt "Select a project directory")'
531
+ ]));
532
+ } catch (error) {
533
+ const commandError = error as NodeJS.ErrnoException & { stderr?: string };
534
+ const stderr = `${commandError.stderr ?? ''}`.trim();
535
+ if (stderr.includes('用户已取消') || stderr.includes('User canceled') || stderr.includes('(-128)')) {
536
+ throw new Error('已取消选择目录');
537
+ }
538
+ throw error;
539
+ }
540
+
541
+ const cwd = await resolveProjectCwd(stdout.trim().replace(/\/+$/, '') || '/');
542
+ return {
543
+ cwd,
544
+ label: path.basename(cwd) || cwd
545
+ };
546
+ }
547
+
548
+ throw new Error(`Directory picker is not implemented for ${process.platform}`);
549
+ }
550
+
551
+ function connectSocketClient(): void {
552
+ const socket = io(`${SOCKET_URL}/cli`, {
553
+ path: '/socket.io/',
554
+ transports: ['websocket'],
555
+ reconnection: true,
556
+ reconnectionDelay: 1000,
557
+ reconnectionDelayMax: 5000
558
+ });
559
+
560
+ socketClient = socket;
561
+
562
+ socket.on('connect', () => {
563
+ const registrations = Object.fromEntries(
564
+ SUPPORTED_PROVIDERS.map((providerId) => [providerId, getRuntime(providerId).getRegistrationPayload()])
565
+ ) as CliRegisterPayload['runtimes'];
566
+ const primaryRegistration = registrations[SUPPORTED_PROVIDERS[0]];
567
+ socket.emit(
568
+ 'cli:register',
569
+ {
570
+ cliId: CLI_ID,
571
+ label: CLI_LABEL,
572
+ cwd: primaryRegistration?.cwd ?? DEFAULT_ROOT_DIR,
573
+ supportedProviders: SUPPORTED_PROVIDERS,
574
+ runtimes: registrations,
575
+ runtimeBackend: PTY_BACKEND_NAME
576
+ } satisfies CliRegisterPayload,
577
+ (result: CliRegisterResult) => {
578
+ if (!result.ok) {
579
+ if (result.errorCode === 'conflict') {
580
+ socket.io.opts.reconnection = false;
581
+ }
582
+ const message = result.error || `CLI ${CLI_ID} registration was rejected`;
583
+ console.error(message);
584
+ void shutdownCliClient(message, 1);
585
+ return;
586
+ }
587
+
588
+ console.log(`cli registered as ${result.cliId}`);
589
+ }
590
+ );
591
+ });
592
+
593
+ socket.on('cli:command', async (envelope: CliCommandEnvelope, callback?: (result: CliCommandResult) => void) => {
594
+ callback?.(await handleSocketCommand(envelope));
595
+ });
596
+
597
+ socket.on('cli:terminal-resize', (payload: { cols: number; rows: number; targetProviderId?: ProviderId | null }) => {
598
+ const providerId = payload.targetProviderId ?? null;
599
+ if (!providerId) {
600
+ return;
601
+ }
602
+ getRuntime(providerId).updateTerminalSize(payload.cols, payload.rows);
603
+ });
604
+
605
+ socket.on(
606
+ 'cli:terminal-frame-prime',
607
+ async (payload: { targetProviderId?: ProviderId | null }, callback?: (result: { ok: boolean; error?: string }) => void) => {
608
+ try {
609
+ const providerId = payload.targetProviderId ?? null;
610
+ if (!providerId) {
611
+ throw new Error('Provider is not selected');
612
+ }
613
+ await getRuntime(providerId).primeActiveTerminalFrame();
614
+ callback?.({ ok: true });
615
+ } catch (error) {
616
+ callback?.({
617
+ ok: false,
618
+ error: error instanceof Error ? error.message : 'Failed to prime terminal frame'
619
+ });
620
+ }
621
+ }
622
+ );
623
+
624
+ socket.on('disconnect', (reason) => {
625
+ console.log(`socket disconnected: ${reason}`);
626
+ });
627
+
628
+ socket.on('connect_error', (error) => {
629
+ console.error(`socket connect error: ${error.message}`);
630
+ });
631
+ }
632
+
633
+ async function shutdownCliClient(reason: string, exitCode = 0): Promise<void> {
634
+ if (shuttingDown) {
635
+ if (shutdownPromise) {
636
+ await shutdownPromise;
637
+ }
638
+ return;
639
+ }
640
+
641
+ shuttingDown = true;
642
+ shutdownPromise = (async () => {
643
+ console.log(`shutting down cli client (${reason})`);
644
+ await Promise.all(SUPPORTED_PROVIDERS.map((providerId) => getRuntime(providerId).shutdown()));
645
+ await attachmentManager.shutdown();
646
+ socketClient?.removeAllListeners();
647
+ socketClient?.disconnect();
648
+ socketClient = null;
649
+ })();
650
+
651
+ try {
652
+ await shutdownPromise;
653
+ } finally {
654
+ process.exit(exitCode);
655
+ }
656
+ }
657
+
658
+ process.once('SIGINT', () => {
659
+ void shutdownCliClient('SIGINT', 0);
660
+ });
661
+
662
+ process.once('SIGTERM', () => {
663
+ void shutdownCliClient('SIGTERM', 0);
664
+ });
665
+
666
+ process.once('SIGHUP', () => {
667
+ void shutdownCliClient('SIGHUP', 0);
668
+ });
669
+
670
+ export async function startCliClient(): Promise<void> {
671
+ attachmentManager.start();
672
+ connectSocketClient();
673
+ console.log(`cli client connecting to ${SOCKET_URL} as ${CLI_ID}`);
674
+ }