@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.
- package/bin/pty-remote-cli.js +24 -0
- package/cli.conf +51 -0
- package/codex_template.jsonl +1 -0
- package/package.json +45 -0
- package/scripts/ensure-node-pty-helper.js +24 -0
- package/src/attachments/manager.ts +196 -0
- package/src/cli/cli-config.ts +58 -0
- package/src/cli/client.ts +674 -0
- package/src/cli/jsonl.ts +483 -0
- package/src/cli/pty-manager.ts +1509 -0
- package/src/cli/pty.ts +162 -0
- package/src/cli-main.ts +18 -0
- package/src/project-history.ts +175 -0
- package/src/providers/claude-history.ts +124 -0
- package/src/providers/claude.ts +66 -0
- package/src/providers/codex-history.ts +390 -0
- package/src/providers/codex-jsonl.ts +604 -0
- package/src/providers/codex-manager.ts +1662 -0
- package/src/providers/codex-pty.ts +144 -0
- package/src/providers/codex-resume-session.ts +253 -0
- package/src/providers/codex.ts +67 -0
- package/src/providers/provider-runtime.ts +58 -0
- package/src/providers/slash-commands.ts +115 -0
- package/src/terminal/frame-state.ts +457 -0
- package/src/threads-cli.ts +164 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { spawn as spawnPty, type IPty } from 'node-pty';
|
|
2
|
+
|
|
3
|
+
export interface CodexPtySession {
|
|
4
|
+
pty: IPty;
|
|
5
|
+
recentOutput: string;
|
|
6
|
+
startupUpdatePromptHandled: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type CodexPtyLifecycle = 'not_ready' | 'idle' | 'running';
|
|
10
|
+
|
|
11
|
+
interface StartCodexPtySessionOptions {
|
|
12
|
+
codexBin: string;
|
|
13
|
+
cols: number;
|
|
14
|
+
cwd: string;
|
|
15
|
+
env: NodeJS.ProcessEnv;
|
|
16
|
+
resumeSessionId?: string | null;
|
|
17
|
+
rows: number;
|
|
18
|
+
onData: (chunk: string) => void;
|
|
19
|
+
onExit: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createCodexLaunchConfig(
|
|
23
|
+
codexBin: string,
|
|
24
|
+
cwd: string,
|
|
25
|
+
resumeSessionId?: string | null
|
|
26
|
+
): {
|
|
27
|
+
args: string[];
|
|
28
|
+
command: string;
|
|
29
|
+
} {
|
|
30
|
+
const args = ['--no-alt-screen', '-C', cwd];
|
|
31
|
+
if (resumeSessionId) {
|
|
32
|
+
args.push('resume', resumeSessionId);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
command: codexBin,
|
|
37
|
+
args
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function startCodexPtySession(options: StartCodexPtySessionOptions): CodexPtySession {
|
|
42
|
+
const launch = createCodexLaunchConfig(options.codexBin, options.cwd, options.resumeSessionId);
|
|
43
|
+
const pty = spawnPty(launch.command, launch.args, {
|
|
44
|
+
cols: options.cols,
|
|
45
|
+
rows: options.rows,
|
|
46
|
+
cwd: options.cwd,
|
|
47
|
+
env: options.env,
|
|
48
|
+
name: 'xterm-256color'
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const session: CodexPtySession = {
|
|
52
|
+
pty,
|
|
53
|
+
recentOutput: '',
|
|
54
|
+
startupUpdatePromptHandled: false
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
pty.onData((chunk) => {
|
|
58
|
+
options.onData(chunk);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
pty.onExit(() => {
|
|
62
|
+
options.onExit();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return session;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function stopCodexPtySession(session: CodexPtySession | null): void {
|
|
69
|
+
if (!session) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
session.pty.kill();
|
|
75
|
+
} catch {
|
|
76
|
+
// ignore
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function resizeCodexPtySession(session: CodexPtySession | null, cols: number, rows: number): void {
|
|
81
|
+
if (!session) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
session.pty.resize(cols, rows);
|
|
87
|
+
} catch {
|
|
88
|
+
// ignore resize failures during session transitions
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function appendRecentOutput(session: CodexPtySession, chunk: string, maxChars: number): string {
|
|
93
|
+
session.recentOutput = `${session.recentOutput}${chunk}`.slice(-maxChars);
|
|
94
|
+
return session.recentOutput;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalizeOutput(text: string): string {
|
|
98
|
+
return text.replace(/\r/g, '\n');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function tailOutput(text: string, maxChars = 8_000): string {
|
|
102
|
+
return normalizeOutput(text).slice(-maxChars);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const RUNNING_LINE_PATTERN = /(^|\n)\s*[•◦]\s+[^\n]*esc to interrupt[^\n]*$/gimu;
|
|
106
|
+
const PROMPT_LINE_PATTERN = /(^|\n)\s*[›>]\s*[^\n]*$/gimu;
|
|
107
|
+
const DIRECTORY_TRUST_PROMPT_PATTERN = /Do you trust the contents of this directory\?/i;
|
|
108
|
+
const UPDATE_AVAILABLE_PROMPT_PATTERN = /Update\s+available!?/i;
|
|
109
|
+
const UPDATE_SKIP_OPTION_PATTERN = /(^|\n)\s*2\.\s*Skip(?:\s|$)/im;
|
|
110
|
+
const STARTER_PROMPT_PATTERN =
|
|
111
|
+
/Use \/skills to list available skills|Improve documentation in @filename|To get started, describe a task|Implement\s+\{feature\}|Implement\s+<feature>/i;
|
|
112
|
+
|
|
113
|
+
export function getCodexPtyLifecycle(output: string): CodexPtyLifecycle {
|
|
114
|
+
const tail = tailOutput(output);
|
|
115
|
+
const hasRunningLine = RUNNING_LINE_PATTERN.test(tail);
|
|
116
|
+
const hasPromptLine = PROMPT_LINE_PATTERN.test(tail);
|
|
117
|
+
|
|
118
|
+
if (hasRunningLine) {
|
|
119
|
+
return 'running';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (hasPromptLine) {
|
|
123
|
+
return 'idle';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return 'not_ready';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function looksReadyForInput(output: string): boolean {
|
|
130
|
+
return getCodexPtyLifecycle(output) === 'idle';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function looksLikeDirectoryTrustPrompt(output: string): boolean {
|
|
134
|
+
return DIRECTORY_TRUST_PROMPT_PATTERN.test(tailOutput(output));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function looksLikeUpdatePrompt(output: string): boolean {
|
|
138
|
+
const tail = tailOutput(output);
|
|
139
|
+
return UPDATE_AVAILABLE_PROMPT_PATTERN.test(tail) && UPDATE_SKIP_OPTION_PATTERN.test(tail);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function showsStarterPrompt(output: string): boolean {
|
|
143
|
+
return STARTER_PROMPT_PATTERN.test(tailOutput(output));
|
|
144
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
|
|
8
|
+
import { resolveCodexHistoryPaths, type CodexHistoryOptions } from './codex-history.ts';
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
const TEMPLATE_FILE_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../codex_template.jsonl');
|
|
12
|
+
const PLACEHOLDER_PATTERN = /^\{\{([a-z0-9_.]+)\}\}$/i;
|
|
13
|
+
|
|
14
|
+
interface CodexResumeSessionOptions extends CodexHistoryOptions {
|
|
15
|
+
codexBin?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface SessionMetaTemplateRecord {
|
|
19
|
+
timestamp?: string;
|
|
20
|
+
type: 'session_meta';
|
|
21
|
+
payload: Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface GitMetadataPayload {
|
|
25
|
+
commit_hash?: string;
|
|
26
|
+
branch?: string;
|
|
27
|
+
repository_url?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PreparedCodexResumeSession {
|
|
31
|
+
filePath: string;
|
|
32
|
+
sessionId: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
36
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function pad(value: number): string {
|
|
40
|
+
return String(value).padStart(2, '0');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function formatRolloutFileTimestamp(date: Date): string {
|
|
44
|
+
return [
|
|
45
|
+
date.getFullYear(),
|
|
46
|
+
pad(date.getMonth() + 1),
|
|
47
|
+
pad(date.getDate())
|
|
48
|
+
].join('-') + `T${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function createRolloutDirectory(rootPath: string, date: Date): string {
|
|
52
|
+
return path.join(
|
|
53
|
+
rootPath,
|
|
54
|
+
String(date.getFullYear()),
|
|
55
|
+
pad(date.getMonth() + 1),
|
|
56
|
+
pad(date.getDate())
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function readTemplateRecord(): Promise<SessionMetaTemplateRecord> {
|
|
61
|
+
const raw = await fs.readFile(TEMPLATE_FILE_PATH, 'utf8');
|
|
62
|
+
const firstLine = raw.split('\n', 1)[0]?.trim();
|
|
63
|
+
if (!firstLine) {
|
|
64
|
+
throw new Error(`Codex template file is empty: ${TEMPLATE_FILE_PATH}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const parsed = JSON.parse(firstLine) as unknown;
|
|
68
|
+
if (!isRecord(parsed) || parsed.type !== 'session_meta' || !isRecord(parsed.payload)) {
|
|
69
|
+
throw new Error(`Codex template file must start with session_meta: ${TEMPLATE_FILE_PATH}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
...parsed,
|
|
74
|
+
type: 'session_meta',
|
|
75
|
+
payload: parsed.payload
|
|
76
|
+
} as SessionMetaTemplateRecord;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function replaceTemplatePlaceholders(value: unknown, variables: Readonly<Record<string, string>>): unknown {
|
|
80
|
+
if (Array.isArray(value)) {
|
|
81
|
+
return value.map((item) => replaceTemplatePlaceholders(item, variables));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (isRecord(value)) {
|
|
85
|
+
return Object.fromEntries(
|
|
86
|
+
Object.entries(value).map(([key, entryValue]) => [key, replaceTemplatePlaceholders(entryValue, variables)])
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (typeof value !== 'string') {
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const match = value.match(PLACEHOLDER_PATTERN);
|
|
95
|
+
if (!match) {
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const variableName = match[1];
|
|
100
|
+
if (!(variableName in variables)) {
|
|
101
|
+
throw new Error(`Unknown Codex template placeholder: ${variableName}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return variables[variableName];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function findUnresolvedPlaceholder(value: unknown): string | null {
|
|
108
|
+
if (Array.isArray(value)) {
|
|
109
|
+
for (const item of value) {
|
|
110
|
+
const unresolved = findUnresolvedPlaceholder(item);
|
|
111
|
+
if (unresolved) {
|
|
112
|
+
return unresolved;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (isRecord(value)) {
|
|
119
|
+
for (const entryValue of Object.values(value)) {
|
|
120
|
+
const unresolved = findUnresolvedPlaceholder(entryValue);
|
|
121
|
+
if (unresolved) {
|
|
122
|
+
return unresolved;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (typeof value !== 'string') {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const match = value.match(PLACEHOLDER_PATTERN);
|
|
133
|
+
return match ? match[0] : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function readGitMetadata(cwd: string): Promise<GitMetadataPayload | null> {
|
|
137
|
+
try {
|
|
138
|
+
const insideWorkTree = await execFileAsync('git', ['-C', cwd, 'rev-parse', '--is-inside-work-tree'], {
|
|
139
|
+
encoding: 'utf8'
|
|
140
|
+
});
|
|
141
|
+
if (insideWorkTree.stdout.trim() !== 'true') {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const metadata: GitMetadataPayload = {};
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const result = await execFileAsync('git', ['-C', cwd, 'rev-parse', 'HEAD'], { encoding: 'utf8' });
|
|
152
|
+
const commitHash = result.stdout.trim();
|
|
153
|
+
if (commitHash) {
|
|
154
|
+
metadata.commit_hash = commitHash;
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
// ignore
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const result = await execFileAsync('git', ['-C', cwd, 'branch', '--show-current'], { encoding: 'utf8' });
|
|
162
|
+
const branch = result.stdout.trim();
|
|
163
|
+
if (branch) {
|
|
164
|
+
metadata.branch = branch;
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
// ignore
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const result = await execFileAsync('git', ['-C', cwd, 'config', '--get', 'remote.origin.url'], { encoding: 'utf8' });
|
|
172
|
+
const repositoryUrl = result.stdout.trim();
|
|
173
|
+
if (repositoryUrl) {
|
|
174
|
+
metadata.repository_url = repositoryUrl;
|
|
175
|
+
}
|
|
176
|
+
} catch {
|
|
177
|
+
// ignore
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return Object.keys(metadata).length > 0 ? metadata : null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function readCodexCliVersion(codexBin = 'codex'): Promise<string | null> {
|
|
184
|
+
try {
|
|
185
|
+
const result = await execFileAsync(codexBin, ['--version'], { encoding: 'utf8' });
|
|
186
|
+
const version = result.stdout.trim();
|
|
187
|
+
return version || null;
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function buildSessionMetaRecord(
|
|
194
|
+
template: SessionMetaTemplateRecord,
|
|
195
|
+
templateVariables: Readonly<Record<string, string>>,
|
|
196
|
+
gitMetadata: GitMetadataPayload | null
|
|
197
|
+
): SessionMetaTemplateRecord {
|
|
198
|
+
const rendered = replaceTemplatePlaceholders(template, templateVariables) as SessionMetaTemplateRecord;
|
|
199
|
+
const unresolved = findUnresolvedPlaceholder(rendered);
|
|
200
|
+
if (unresolved) {
|
|
201
|
+
throw new Error(`Unresolved Codex template placeholder: ${unresolved}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const payload = structuredClone(rendered.payload);
|
|
205
|
+
payload.source ??= 'cli';
|
|
206
|
+
payload.originator ??= 'codex_cli_rs';
|
|
207
|
+
|
|
208
|
+
if (gitMetadata) {
|
|
209
|
+
payload.git = gitMetadata;
|
|
210
|
+
} else {
|
|
211
|
+
delete payload.git;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
...rendered,
|
|
216
|
+
type: 'session_meta',
|
|
217
|
+
payload
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function prepareCodexResumeSession(
|
|
222
|
+
cwd: string,
|
|
223
|
+
options: CodexResumeSessionOptions = {}
|
|
224
|
+
): Promise<PreparedCodexResumeSession> {
|
|
225
|
+
const resolvedCwd = path.resolve(cwd);
|
|
226
|
+
const { sessionsRootPath } = resolveCodexHistoryPaths(options);
|
|
227
|
+
const template = await readTemplateRecord();
|
|
228
|
+
const sessionId = randomUUID();
|
|
229
|
+
const now = new Date();
|
|
230
|
+
const timestamp = now.toISOString();
|
|
231
|
+
const cliVersion = (await readCodexCliVersion(options.codexBin)) ?? 'unknown';
|
|
232
|
+
const gitMetadata = await readGitMetadata(resolvedCwd);
|
|
233
|
+
const nextRecord = buildSessionMetaRecord(
|
|
234
|
+
template,
|
|
235
|
+
{
|
|
236
|
+
cli_version: cliVersion,
|
|
237
|
+
cwd: resolvedCwd,
|
|
238
|
+
session_id: sessionId,
|
|
239
|
+
timestamp
|
|
240
|
+
},
|
|
241
|
+
gitMetadata
|
|
242
|
+
);
|
|
243
|
+
const rolloutDir = createRolloutDirectory(sessionsRootPath, now);
|
|
244
|
+
const filePath = path.join(rolloutDir, `rollout-${formatRolloutFileTimestamp(now)}-${sessionId}.jsonl`);
|
|
245
|
+
|
|
246
|
+
await fs.mkdir(rolloutDir, { recursive: true });
|
|
247
|
+
await fs.copyFile(TEMPLATE_FILE_PATH, filePath);
|
|
248
|
+
await fs.writeFile(filePath, `${JSON.stringify(nextRecord)}\n`, 'utf8');
|
|
249
|
+
return {
|
|
250
|
+
filePath,
|
|
251
|
+
sessionId
|
|
252
|
+
};
|
|
253
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ProjectSessionSummary } from '@lzdi/pty-remote-protocol/protocol.ts';
|
|
2
|
+
|
|
3
|
+
import { listCodexRecentSessions } from './codex-history.ts';
|
|
4
|
+
import { CodexManager, type CodexManagerOptions } from './codex-manager.ts';
|
|
5
|
+
import type { ProviderRuntime, ProviderRuntimeCallbacks, ProviderRuntimeSelection } from './provider-runtime.ts';
|
|
6
|
+
import { listProviderSlashCommands } from './slash-commands.ts';
|
|
7
|
+
|
|
8
|
+
export type CodexProviderRuntimeOptions = CodexManagerOptions;
|
|
9
|
+
|
|
10
|
+
export function createCodexProviderRuntime(
|
|
11
|
+
options: CodexProviderRuntimeOptions,
|
|
12
|
+
callbacks: ProviderRuntimeCallbacks
|
|
13
|
+
): ProviderRuntime {
|
|
14
|
+
const manager = new CodexManager(options, callbacks);
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
providerId: 'codex',
|
|
18
|
+
activateConversation(selection: ProviderRuntimeSelection) {
|
|
19
|
+
return manager.activateConversation(selection);
|
|
20
|
+
},
|
|
21
|
+
cleanupConversation(target) {
|
|
22
|
+
return manager.cleanupConversation(target);
|
|
23
|
+
},
|
|
24
|
+
cleanupProject(cwd: string) {
|
|
25
|
+
return manager.cleanupProject(cwd);
|
|
26
|
+
},
|
|
27
|
+
dispatchMessage(content: string) {
|
|
28
|
+
return manager.dispatchMessage(content);
|
|
29
|
+
},
|
|
30
|
+
getOlderMessages(beforeMessageId?: string, maxMessages?: number) {
|
|
31
|
+
return manager.getOlderMessages(beforeMessageId, maxMessages);
|
|
32
|
+
},
|
|
33
|
+
getRegistrationPayload() {
|
|
34
|
+
return manager.getRegistrationPayload();
|
|
35
|
+
},
|
|
36
|
+
getSnapshot() {
|
|
37
|
+
return manager.getSnapshot();
|
|
38
|
+
},
|
|
39
|
+
listSlashCommands() {
|
|
40
|
+
return listProviderSlashCommands('codex');
|
|
41
|
+
},
|
|
42
|
+
listProjectConversations(_projectRoot: string, maxSessions?: number): Promise<ProjectSessionSummary[]> {
|
|
43
|
+
return listCodexRecentSessions(maxSessions, options);
|
|
44
|
+
},
|
|
45
|
+
listManagedPtyHandles() {
|
|
46
|
+
return Promise.resolve(manager.listManagedPtyHandles());
|
|
47
|
+
},
|
|
48
|
+
primeActiveTerminalFrame() {
|
|
49
|
+
return manager.primeActiveTerminalFrame();
|
|
50
|
+
},
|
|
51
|
+
refreshActiveState() {
|
|
52
|
+
return manager.refreshActiveState();
|
|
53
|
+
},
|
|
54
|
+
resetActiveConversation() {
|
|
55
|
+
return manager.resetActiveThread();
|
|
56
|
+
},
|
|
57
|
+
shutdown() {
|
|
58
|
+
return manager.shutdown();
|
|
59
|
+
},
|
|
60
|
+
stopActiveRun() {
|
|
61
|
+
return manager.stopActiveRun();
|
|
62
|
+
},
|
|
63
|
+
updateTerminalSize(cols: number, rows: number) {
|
|
64
|
+
manager.updateTerminalSize(cols, rows);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GetOlderMessagesResultPayload,
|
|
3
|
+
ManagedPtyHandleSummary,
|
|
4
|
+
ProviderRuntimeRegistration,
|
|
5
|
+
ProjectSessionSummary,
|
|
6
|
+
SelectConversationResultPayload,
|
|
7
|
+
TerminalFramePatchPayload
|
|
8
|
+
} from '@lzdi/pty-remote-protocol/protocol.ts';
|
|
9
|
+
import type { ProviderId, RuntimeSnapshot } from '@lzdi/pty-remote-protocol/runtime-types.ts';
|
|
10
|
+
|
|
11
|
+
export interface ProviderRuntimeSelection {
|
|
12
|
+
cwd: string;
|
|
13
|
+
label: string;
|
|
14
|
+
sessionId: string | null;
|
|
15
|
+
conversationKey: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ProviderRuntimeCallbacks {
|
|
19
|
+
emitMessagesUpsert(payload: {
|
|
20
|
+
providerId: ProviderId | null;
|
|
21
|
+
conversationKey: string | null;
|
|
22
|
+
sessionId: string | null;
|
|
23
|
+
upserts: RuntimeSnapshot['messages'];
|
|
24
|
+
recentMessageIds: string[];
|
|
25
|
+
hasOlderMessages: boolean;
|
|
26
|
+
}): void;
|
|
27
|
+
emitSnapshot(snapshot: RuntimeSnapshot): void;
|
|
28
|
+
emitTerminalFramePatch(payload: Omit<TerminalFramePatchPayload, 'cliId' | 'providerId'>): void;
|
|
29
|
+
emitTerminalSessionEvicted(payload: {
|
|
30
|
+
conversationKey: string | null;
|
|
31
|
+
reason: string;
|
|
32
|
+
sessionId: string;
|
|
33
|
+
}): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ProviderRuntime {
|
|
37
|
+
readonly providerId: ProviderId;
|
|
38
|
+
activateConversation(selection: ProviderRuntimeSelection): Promise<SelectConversationResultPayload>;
|
|
39
|
+
cleanupConversation(target: {
|
|
40
|
+
cwd: string;
|
|
41
|
+
conversationKey: string;
|
|
42
|
+
sessionId: string | null;
|
|
43
|
+
}): Promise<void>;
|
|
44
|
+
cleanupProject(cwd: string): Promise<void>;
|
|
45
|
+
dispatchMessage(content: string): Promise<void>;
|
|
46
|
+
getOlderMessages(beforeMessageId?: string, maxMessages?: number): Promise<GetOlderMessagesResultPayload>;
|
|
47
|
+
getRegistrationPayload(): ProviderRuntimeRegistration;
|
|
48
|
+
getSnapshot(): RuntimeSnapshot;
|
|
49
|
+
listSlashCommands(): Promise<string[]>;
|
|
50
|
+
listProjectConversations(projectRoot: string, maxSessions?: number): Promise<ProjectSessionSummary[]>;
|
|
51
|
+
listManagedPtyHandles(): Promise<ManagedPtyHandleSummary[]>;
|
|
52
|
+
primeActiveTerminalFrame(): Promise<void>;
|
|
53
|
+
refreshActiveState(): Promise<void>;
|
|
54
|
+
resetActiveConversation(): Promise<void>;
|
|
55
|
+
shutdown(): Promise<void>;
|
|
56
|
+
stopActiveRun(): Promise<void>;
|
|
57
|
+
updateTerminalSize(cols: number, rows: number): void;
|
|
58
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { BUILTIN_SLASH_COMMANDS, type ProviderId } from '@lzdi/pty-remote-protocol/runtime-types.ts';
|
|
6
|
+
|
|
7
|
+
interface InstalledPluginsFile {
|
|
8
|
+
version: number;
|
|
9
|
+
plugins: Record<string, Array<{
|
|
10
|
+
installPath: string;
|
|
11
|
+
lastUpdated: string;
|
|
12
|
+
}>>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getClaudeCommandsDir(): string {
|
|
16
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR?.trim() || path.join(homedir(), '.claude');
|
|
17
|
+
return path.join(configDir, 'commands');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getCodexPromptsDir(): string {
|
|
21
|
+
const codexHome = process.env.CODEX_HOME?.trim() || path.join(homedir(), '.codex');
|
|
22
|
+
return path.join(codexHome, 'prompts');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function scanCommandNames(dir: string, segments: string[] = []): Promise<string[]> {
|
|
26
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
27
|
+
const discovered: string[] = [];
|
|
28
|
+
|
|
29
|
+
for (const entry of entries) {
|
|
30
|
+
if (entry.name.startsWith('.') || entry.isSymbolicLink()) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (entry.isDirectory()) {
|
|
35
|
+
if (entry.name.includes(':')) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
discovered.push(...(await scanCommandNames(path.join(dir, entry.name), [...segments, entry.name])));
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const baseName = entry.name.slice(0, -3);
|
|
47
|
+
if (!baseName || baseName.includes(':')) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
discovered.push([...segments, baseName].join(':'));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return discovered;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function scanClaudePluginCommandNames(): Promise<string[]> {
|
|
58
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR?.trim() || path.join(homedir(), '.claude');
|
|
59
|
+
const installedPluginsPath = path.join(configDir, 'plugins', 'installed_plugins.json');
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const content = await fs.readFile(installedPluginsPath, 'utf8');
|
|
63
|
+
const installedPlugins = JSON.parse(content) as InstalledPluginsFile;
|
|
64
|
+
const discovered: string[] = [];
|
|
65
|
+
|
|
66
|
+
for (const [pluginKey, installations] of Object.entries(installedPlugins.plugins ?? {})) {
|
|
67
|
+
if (installations.length === 0) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const latestInstallation = [...installations].sort((left, right) => {
|
|
72
|
+
return new Date(right.lastUpdated).getTime() - new Date(left.lastUpdated).getTime();
|
|
73
|
+
})[0];
|
|
74
|
+
if (!latestInstallation?.installPath) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const lastAtIndex = pluginKey.lastIndexOf('@');
|
|
79
|
+
const pluginName = lastAtIndex > 0 ? pluginKey.slice(0, lastAtIndex) : pluginKey;
|
|
80
|
+
const names = await scanCommandNames(path.join(latestInstallation.installPath, 'commands'));
|
|
81
|
+
for (const name of names) {
|
|
82
|
+
discovered.push(`${pluginName}:${name}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return discovered;
|
|
87
|
+
} catch {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function dedupeCommands(commandGroups: string[][]): string[] {
|
|
93
|
+
return [...new Set(commandGroups.flat().map((command) => command.trim()).filter(Boolean))].sort((left, right) =>
|
|
94
|
+
left.localeCompare(right)
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function listProviderSlashCommands(providerId: ProviderId): Promise<string[]> {
|
|
99
|
+
const builtin = BUILTIN_SLASH_COMMANDS[providerId] ?? [];
|
|
100
|
+
|
|
101
|
+
if (providerId === 'claude') {
|
|
102
|
+
const [userCommands, pluginCommands] = await Promise.all([
|
|
103
|
+
scanCommandNames(getClaudeCommandsDir()),
|
|
104
|
+
scanClaudePluginCommandNames()
|
|
105
|
+
]);
|
|
106
|
+
return dedupeCommands([builtin, pluginCommands, userCommands]);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (providerId === 'codex') {
|
|
110
|
+
const userCommands = await scanCommandNames(getCodexPromptsDir());
|
|
111
|
+
return dedupeCommands([builtin, userCommands]);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return dedupeCommands([builtin]);
|
|
115
|
+
}
|