@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,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = path.dirname(__filename);
|
|
9
|
+
const ROOT_DIR = path.resolve(__dirname, '..');
|
|
10
|
+
const requireFromBin = createRequire(import.meta.url);
|
|
11
|
+
const TSX_IMPORT_PATH = requireFromBin.resolve('tsx', { paths: [ROOT_DIR] });
|
|
12
|
+
|
|
13
|
+
const child = spawn(
|
|
14
|
+
process.execPath,
|
|
15
|
+
['--disable-warning=ExperimentalWarning', '--import', TSX_IMPORT_PATH, path.join(ROOT_DIR, 'src/cli-main.ts'), ...process.argv.slice(2)],
|
|
16
|
+
{
|
|
17
|
+
stdio: 'inherit',
|
|
18
|
+
env: process.env
|
|
19
|
+
}
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
child.on('exit', (code) => {
|
|
23
|
+
process.exit(typeof code === 'number' ? code : 0);
|
|
24
|
+
});
|
package/cli.conf
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# CLI config for pty-remote
|
|
2
|
+
# Lines are KEY=VALUE. Empty values are ignored. Environment variables override this file.
|
|
3
|
+
|
|
4
|
+
# Socket
|
|
5
|
+
HOST=127.0.0.1
|
|
6
|
+
PORT=3001
|
|
7
|
+
# SOCKET_URL=
|
|
8
|
+
|
|
9
|
+
# Providers
|
|
10
|
+
PTY_REMOTE_PROVIDERS=claude,codex
|
|
11
|
+
|
|
12
|
+
# CLI identity
|
|
13
|
+
# PTY_REMOTE_CLI_ID=
|
|
14
|
+
|
|
15
|
+
# Limits
|
|
16
|
+
PTY_REMOTE_MAX_DETACHED_PTYS=5
|
|
17
|
+
|
|
18
|
+
# Terminal
|
|
19
|
+
TERMINAL_COLS=120
|
|
20
|
+
TERMINAL_ROWS=32
|
|
21
|
+
TERMINAL_FRAME_SCROLLBACK=500
|
|
22
|
+
RECENT_OUTPUT_MAX_CHARS=12000
|
|
23
|
+
|
|
24
|
+
# Timeouts & debounce
|
|
25
|
+
CLAUDE_READY_TIMEOUT_MS=20000
|
|
26
|
+
CODEX_READY_TIMEOUT_MS=20000
|
|
27
|
+
PROMPT_SUBMIT_DELAY_MS=120
|
|
28
|
+
JSONL_REFRESH_DEBOUNCE_MS=120
|
|
29
|
+
SNAPSHOT_EMIT_DEBOUNCE_MS=200
|
|
30
|
+
|
|
31
|
+
# Snapshot limits
|
|
32
|
+
SNAPSHOT_MESSAGES_MAX=40
|
|
33
|
+
OLDER_MESSAGES_PAGE_MAX=40
|
|
34
|
+
|
|
35
|
+
# GC / TTL
|
|
36
|
+
GC_INTERVAL_MS=300000
|
|
37
|
+
DETACHED_PTY_TTL_MS=43200000
|
|
38
|
+
DETACHED_DRAFT_TTL_MS=300000
|
|
39
|
+
DETACHED_JSONL_MISSING_TTL_MS=120000
|
|
40
|
+
|
|
41
|
+
# Provider bins
|
|
42
|
+
# CLAUDE_BIN=
|
|
43
|
+
# CODEX_BIN=
|
|
44
|
+
|
|
45
|
+
# Claude permission mode
|
|
46
|
+
CLAUDE_PERMISSION_MODE=bypassPermissions
|
|
47
|
+
|
|
48
|
+
# Codex history
|
|
49
|
+
# CODEX_HOME=
|
|
50
|
+
# CODEX_HISTORY_PATH=
|
|
51
|
+
# CODEX_SESSIONS_ROOT_PATH=
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"timestamp":"{{timestamp}}","type":"session_meta","payload":{"id":"{{session_id}}","timestamp":"{{timestamp}}","cwd":"{{cwd}}","originator":"codex_cli_rs","cli_version":"{{cli_version}}","source":"cli"}}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lzdi/pty-remote-cli",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/234687552/pty-remote"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=23.0.0"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "node scripts/ensure-node-pty-helper.js",
|
|
15
|
+
"dev": "tsx watch src/cli-main.ts",
|
|
16
|
+
"dev:claude": "PTY_REMOTE_PROVIDER=claude tsx watch src/cli-main.ts",
|
|
17
|
+
"dev:codex": "PTY_REMOTE_PROVIDER=codex tsx watch src/cli-main.ts",
|
|
18
|
+
"postinstall": "node scripts/ensure-node-pty-helper.js",
|
|
19
|
+
"start": "node bin/pty-remote-cli.js",
|
|
20
|
+
"start:claude": "PTY_REMOTE_PROVIDER=claude node bin/pty-remote-cli.js",
|
|
21
|
+
"start:codex": "PTY_REMOTE_PROVIDER=codex node bin/pty-remote-cli.js",
|
|
22
|
+
"threads": "node bin/pty-remote-cli.js threads"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@lzdi/pty-remote-protocol": "0.1.3",
|
|
26
|
+
"@xterm/headless": "^6.0.0",
|
|
27
|
+
"node-pty": "^1.1.0",
|
|
28
|
+
"socket.io-client": "^4.8.3",
|
|
29
|
+
"tsx": "^4.20.6"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^24.5.2",
|
|
33
|
+
"typescript": "^5.9.3"
|
|
34
|
+
},
|
|
35
|
+
"bin": {
|
|
36
|
+
"pty-remote-cli": "bin/pty-remote-cli.js"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"bin",
|
|
40
|
+
"cli.conf",
|
|
41
|
+
"codex_template.jsonl",
|
|
42
|
+
"scripts",
|
|
43
|
+
"src"
|
|
44
|
+
]
|
|
45
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { chmodSync, existsSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
if (process.platform !== 'darwin' || process.arch !== 'arm64') {
|
|
5
|
+
process.exit(0);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const helperCandidates = [
|
|
9
|
+
path.join(process.cwd(), 'node_modules', 'node-pty', 'prebuilds', 'darwin-arm64', 'spawn-helper'),
|
|
10
|
+
path.join(process.cwd(), 'node_modules', 'node-pty', 'build', 'Release', 'spawn-helper')
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
for (const helperPath of helperCandidates) {
|
|
14
|
+
if (!existsSync(helperPath)) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const mode = statSync(helperPath).mode & 0o111;
|
|
19
|
+
if (mode === 0o111) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
chmodSync(helperPath, 0o755);
|
|
24
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
import type { ProviderId } from '@lzdi/pty-remote-protocol/runtime-types.ts';
|
|
7
|
+
|
|
8
|
+
export interface UploadAttachmentInput {
|
|
9
|
+
contentBase64: string;
|
|
10
|
+
conversationKey: string | null;
|
|
11
|
+
cwd: string;
|
|
12
|
+
filename: string;
|
|
13
|
+
mimeType: string;
|
|
14
|
+
providerId: ProviderId;
|
|
15
|
+
sessionId: string | null;
|
|
16
|
+
size: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UploadedAttachmentRecord {
|
|
20
|
+
attachmentId: string;
|
|
21
|
+
conversationKey: string | null;
|
|
22
|
+
cwd: string;
|
|
23
|
+
filename: string;
|
|
24
|
+
mimeType: string;
|
|
25
|
+
path: string;
|
|
26
|
+
providerId: ProviderId;
|
|
27
|
+
sessionId: string | null;
|
|
28
|
+
size: number;
|
|
29
|
+
status: 'pending' | 'sent';
|
|
30
|
+
createdAt: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ATTACHMENT_MAX_BYTES = 4 * 1024 * 1024;
|
|
34
|
+
const ATTACHMENT_PENDING_TTL_MS = 30 * 60 * 1000;
|
|
35
|
+
const ATTACHMENT_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
|
36
|
+
|
|
37
|
+
function sanitizeSegment(value: string): string {
|
|
38
|
+
const trimmed = value.trim();
|
|
39
|
+
const normalized = trimmed.replace(/[/\\?%*:|"<>]/g, '-').replace(/\s+/g, '-');
|
|
40
|
+
return normalized.slice(0, 80) || 'attachment';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sanitizeFilename(filename: string): string {
|
|
44
|
+
return sanitizeSegment(filename).replace(/\.\.+/g, '.');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function estimateBase64Bytes(base64: string): number {
|
|
48
|
+
const len = base64.length;
|
|
49
|
+
if (len === 0) {
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
|
|
53
|
+
return Math.floor((len * 3) / 4) - padding;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function createConversationDirectoryName(input: {
|
|
57
|
+
conversationKey: string | null;
|
|
58
|
+
cwd: string;
|
|
59
|
+
sessionId: string | null;
|
|
60
|
+
}): string {
|
|
61
|
+
const basis = input.conversationKey ?? input.sessionId ?? input.cwd;
|
|
62
|
+
const hash = createHash('sha1').update(basis).digest('hex').slice(0, 12);
|
|
63
|
+
const label = sanitizeSegment(path.basename(input.cwd) || (input.conversationKey ?? input.sessionId ?? 'draft'));
|
|
64
|
+
return `${label}-${hash}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class AttachmentManager {
|
|
68
|
+
private readonly attachments = new Map<string, UploadedAttachmentRecord>();
|
|
69
|
+
|
|
70
|
+
private readonly rootDir = path.join(os.homedir(), '.pty-remote', 'uploads');
|
|
71
|
+
|
|
72
|
+
private cleanupTimer: NodeJS.Timeout | null = null;
|
|
73
|
+
|
|
74
|
+
start(): void {
|
|
75
|
+
if (this.cleanupTimer) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
this.cleanupTimer = setInterval(() => {
|
|
80
|
+
void this.cleanupExpiredPending();
|
|
81
|
+
}, ATTACHMENT_CLEANUP_INTERVAL_MS);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
stop(): void {
|
|
85
|
+
if (this.cleanupTimer) {
|
|
86
|
+
clearInterval(this.cleanupTimer);
|
|
87
|
+
this.cleanupTimer = null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async uploadAttachment(input: UploadAttachmentInput): Promise<UploadedAttachmentRecord> {
|
|
92
|
+
const estimatedBytes = estimateBase64Bytes(input.contentBase64);
|
|
93
|
+
if (estimatedBytes > ATTACHMENT_MAX_BYTES || input.size > ATTACHMENT_MAX_BYTES) {
|
|
94
|
+
throw new Error('图片不能超过 4MB');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const attachmentId = randomUUID();
|
|
98
|
+
const directory = path.join(
|
|
99
|
+
this.rootDir,
|
|
100
|
+
input.providerId,
|
|
101
|
+
createConversationDirectoryName({
|
|
102
|
+
conversationKey: input.conversationKey,
|
|
103
|
+
cwd: input.cwd,
|
|
104
|
+
sessionId: input.sessionId
|
|
105
|
+
})
|
|
106
|
+
);
|
|
107
|
+
const filename = `${Date.now()}-${attachmentId}-${sanitizeFilename(input.filename)}`;
|
|
108
|
+
const filePath = path.join(directory, filename);
|
|
109
|
+
const buffer = Buffer.from(input.contentBase64, 'base64');
|
|
110
|
+
|
|
111
|
+
if (buffer.length > ATTACHMENT_MAX_BYTES) {
|
|
112
|
+
throw new Error('图片不能超过 4MB');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
await mkdir(directory, { recursive: true });
|
|
116
|
+
await writeFile(filePath, buffer);
|
|
117
|
+
|
|
118
|
+
const record: UploadedAttachmentRecord = {
|
|
119
|
+
attachmentId,
|
|
120
|
+
conversationKey: input.conversationKey,
|
|
121
|
+
cwd: input.cwd,
|
|
122
|
+
createdAt: Date.now(),
|
|
123
|
+
filename: input.filename,
|
|
124
|
+
mimeType: input.mimeType,
|
|
125
|
+
path: filePath,
|
|
126
|
+
providerId: input.providerId,
|
|
127
|
+
sessionId: input.sessionId,
|
|
128
|
+
size: input.size,
|
|
129
|
+
status: 'pending'
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
this.attachments.set(attachmentId, record);
|
|
133
|
+
return record;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async deleteAttachment(attachmentId: string): Promise<void> {
|
|
137
|
+
const record = this.attachments.get(attachmentId);
|
|
138
|
+
if (!record) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
this.attachments.delete(attachmentId);
|
|
143
|
+
await rm(record.path, { force: true }).catch(() => undefined);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
markReferencedPathsAsSent(content: string): void {
|
|
147
|
+
for (const record of this.attachments.values()) {
|
|
148
|
+
if (record.status === 'sent') {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (content.includes(`@${record.path}`)) {
|
|
152
|
+
record.status = 'sent';
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async cleanupConversation(target: {
|
|
158
|
+
conversationKey: string | null;
|
|
159
|
+
cwd: string;
|
|
160
|
+
providerId: ProviderId;
|
|
161
|
+
sessionId: string | null;
|
|
162
|
+
}): Promise<void> {
|
|
163
|
+
const removals = [...this.attachments.values()].filter(
|
|
164
|
+
(record) =>
|
|
165
|
+
record.providerId === target.providerId &&
|
|
166
|
+
record.cwd === target.cwd &&
|
|
167
|
+
record.conversationKey === target.conversationKey &&
|
|
168
|
+
record.sessionId === target.sessionId
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
await Promise.all(removals.map((record) => this.deleteAttachment(record.attachmentId)));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async cleanupProject(target: { cwd: string; providerId: ProviderId }): Promise<void> {
|
|
175
|
+
const removals = [...this.attachments.values()].filter(
|
|
176
|
+
(record) => record.providerId === target.providerId && record.cwd === target.cwd
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
await Promise.all(removals.map((record) => this.deleteAttachment(record.attachmentId)));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async cleanupExpiredPending(): Promise<void> {
|
|
183
|
+
const cutoff = Date.now() - ATTACHMENT_PENDING_TTL_MS;
|
|
184
|
+
const removals = [...this.attachments.values()].filter(
|
|
185
|
+
(record) => record.status === 'pending' && record.createdAt < cutoff
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
await Promise.all(removals.map((record) => this.deleteAttachment(record.attachmentId)));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async shutdown(): Promise<void> {
|
|
192
|
+
this.stop();
|
|
193
|
+
const removals = [...this.attachments.values()].map((record) => this.deleteAttachment(record.attachmentId));
|
|
194
|
+
await Promise.all(removals);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = path.dirname(__filename);
|
|
8
|
+
const ROOT_DIR = path.resolve(__dirname, '../..');
|
|
9
|
+
const USER_CONFIG_DIR = path.join(os.homedir(), '.pty-remote');
|
|
10
|
+
const USER_CONFIG_PATH = path.join(USER_CONFIG_DIR, 'cli.conf');
|
|
11
|
+
const TEMPLATE_PATH = path.join(ROOT_DIR, 'cli.conf');
|
|
12
|
+
function ensureCliConfigFile(): string {
|
|
13
|
+
try {
|
|
14
|
+
if (!existsSync(USER_CONFIG_PATH)) {
|
|
15
|
+
if (existsSync(TEMPLATE_PATH)) {
|
|
16
|
+
mkdirSync(USER_CONFIG_DIR, { recursive: true });
|
|
17
|
+
copyFileSync(TEMPLATE_PATH, USER_CONFIG_PATH);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
} catch {
|
|
21
|
+
// Fall back to environment defaults if we cannot create/read the config file.
|
|
22
|
+
}
|
|
23
|
+
return USER_CONFIG_PATH;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseConfig(raw: string): Record<string, string> {
|
|
27
|
+
const entries: Record<string, string> = {};
|
|
28
|
+
const lines = raw.split(/\r?\n/);
|
|
29
|
+
for (const line of lines) {
|
|
30
|
+
const trimmed = line.trim();
|
|
31
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const [key, ...rest] = trimmed.split('=');
|
|
35
|
+
if (!key) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const value = rest.join('=').trim();
|
|
39
|
+
if (!value) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
entries[key.trim()] = value;
|
|
43
|
+
}
|
|
44
|
+
return entries;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function loadCliConfig(): Record<string, string> {
|
|
48
|
+
const configPath = ensureCliConfigFile();
|
|
49
|
+
if (!existsSync(configPath)) {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const raw = readFileSync(configPath, 'utf8');
|
|
54
|
+
return parseConfig(raw);
|
|
55
|
+
} catch {
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
}
|