@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,483 @@
1
+ import path from 'node:path';
2
+
3
+ import type {
4
+ ChatMessage,
5
+ ChatMessageBlock,
6
+ TextChatMessageBlock,
7
+ ToolResultChatMessageBlock,
8
+ ToolUseChatMessageBlock
9
+ } from '@lzdi/pty-remote-protocol/runtime-types.ts';
10
+
11
+ interface ClaudeTextContentBlock {
12
+ type?: string;
13
+ text?: string;
14
+ }
15
+
16
+ interface ClaudeToolUseContentBlock {
17
+ type?: string;
18
+ id?: string;
19
+ name?: string;
20
+ input?: unknown;
21
+ }
22
+
23
+ interface ClaudeToolResultContentBlock {
24
+ type?: string;
25
+ tool_use_id?: string;
26
+ content?: unknown;
27
+ is_error?: boolean;
28
+ }
29
+
30
+ type ClaudeContentBlock = ClaudeTextContentBlock | ClaudeToolUseContentBlock | ClaudeToolResultContentBlock;
31
+
32
+ interface ClaudeJsonlRecord {
33
+ type?: string;
34
+ subtype?: string;
35
+ operation?: string;
36
+ uuid?: string;
37
+ timestamp?: string;
38
+ sessionId?: string;
39
+ isApiErrorMessage?: boolean;
40
+ retryInMs?: number;
41
+ retryAttempt?: number;
42
+ maxRetries?: number;
43
+ error?: {
44
+ status?: number;
45
+ };
46
+ message?: {
47
+ id?: string;
48
+ role?: string;
49
+ content?: string | ClaudeContentBlock[];
50
+ stop_reason?: string | null;
51
+ };
52
+ }
53
+
54
+ export type ClaudeJsonlRuntimePhase = 'idle' | 'running';
55
+
56
+ export interface ClaudeJsonlMessagesState {
57
+ orderedIds: string[];
58
+ messagesById: Map<string, ChatMessage>;
59
+ runtimePhase: ClaudeJsonlRuntimePhase;
60
+ activityRevision: number;
61
+ activeApiErrorMessageId: string | null;
62
+ nextSyntheticMessageSequence: number;
63
+ }
64
+
65
+ export function resolveClaudeProjectFilesPath(projectRoot: string, homeDir: string): string {
66
+ const projectSlug = projectRoot.replace(/[\\/]/g, '-');
67
+ return path.join(homeDir, '.claude', 'projects', projectSlug);
68
+ }
69
+
70
+ export function resolveClaudeJsonlFilePath(projectRoot: string, sessionId: string, homeDir: string): string {
71
+ return path.join(resolveClaudeProjectFilesPath(projectRoot, homeDir), `${sessionId}.jsonl`);
72
+ }
73
+
74
+ export function createClaudeJsonlMessagesState(): ClaudeJsonlMessagesState {
75
+ return {
76
+ orderedIds: [],
77
+ messagesById: new Map<string, ChatMessage>(),
78
+ runtimePhase: 'idle',
79
+ activityRevision: 0,
80
+ activeApiErrorMessageId: null,
81
+ nextSyntheticMessageSequence: 1
82
+ };
83
+ }
84
+
85
+ function isTextContentBlock(block: ClaudeContentBlock): block is ClaudeTextContentBlock {
86
+ return block.type === 'text' && 'text' in block && typeof block.text === 'string';
87
+ }
88
+
89
+ function isToolUseContentBlock(block: ClaudeContentBlock): block is ClaudeToolUseContentBlock {
90
+ return block.type === 'tool_use';
91
+ }
92
+
93
+ function isToolResultContentBlock(block: ClaudeContentBlock): block is ClaudeToolResultContentBlock {
94
+ return block.type === 'tool_result';
95
+ }
96
+
97
+ function hasVisibleBlocks(blocks: ChatMessageBlock[]): boolean {
98
+ return blocks.some((block) => {
99
+ switch (block.type) {
100
+ case 'text':
101
+ return Boolean(block.text.trim());
102
+ case 'tool_use':
103
+ return Boolean(block.toolName || block.input);
104
+ case 'tool_result':
105
+ return Boolean(block.content.trim());
106
+ default:
107
+ return false;
108
+ }
109
+ });
110
+ }
111
+
112
+ function blocksEqual(left: ChatMessageBlock[], right: ChatMessageBlock[]): boolean {
113
+ if (left.length !== right.length) {
114
+ return false;
115
+ }
116
+
117
+ return left.every((block, index) => {
118
+ const next = right[index];
119
+ if (!next || block.type !== next.type || block.id !== next.id) {
120
+ return false;
121
+ }
122
+
123
+ switch (block.type) {
124
+ case 'text':
125
+ return next.type === 'text' && block.text === next.text;
126
+ case 'tool_use':
127
+ return (
128
+ next.type === 'tool_use' &&
129
+ block.toolCallId === next.toolCallId &&
130
+ block.toolName === next.toolName &&
131
+ block.input === next.input
132
+ );
133
+ case 'tool_result':
134
+ return (
135
+ next.type === 'tool_result' &&
136
+ block.toolCallId === next.toolCallId &&
137
+ block.content === next.content &&
138
+ block.isError === next.isError
139
+ );
140
+ default:
141
+ return false;
142
+ }
143
+ });
144
+ }
145
+
146
+ function compactMessages(messages: ChatMessage[]): ChatMessage[] {
147
+ const compacted: ChatMessage[] = [];
148
+
149
+ for (const message of messages) {
150
+ if (!hasVisibleBlocks(message.blocks)) {
151
+ continue;
152
+ }
153
+
154
+ const previous = compacted.at(-1);
155
+ if (previous && previous.role === message.role && blocksEqual(previous.blocks, message.blocks)) {
156
+ compacted[compacted.length - 1] = message;
157
+ continue;
158
+ }
159
+
160
+ compacted.push(message);
161
+ }
162
+
163
+ return compacted;
164
+ }
165
+
166
+ function stringifyUnknown(value: unknown): string {
167
+ if (typeof value === 'string') {
168
+ return value.trim();
169
+ }
170
+
171
+ if (value === null || value === undefined) {
172
+ return '';
173
+ }
174
+
175
+ if (typeof value === 'object') {
176
+ return JSON.stringify(value, null, 2);
177
+ }
178
+
179
+ return String(value);
180
+ }
181
+
182
+ function resolveRecordMessageId(record: ClaudeJsonlRecord, role: 'user' | 'assistant'): string | null {
183
+ if (role === 'assistant') {
184
+ return record.message?.id ?? record.uuid ?? null;
185
+ }
186
+
187
+ return record.uuid ?? null;
188
+ }
189
+
190
+ function createTextBlock(baseId: string, text: string, index = 0): TextChatMessageBlock | null {
191
+ const normalizedText = text.trimEnd();
192
+ if (!normalizedText.trim()) {
193
+ return null;
194
+ }
195
+
196
+ return {
197
+ id: `${baseId}:text:${index}`,
198
+ type: 'text',
199
+ text: normalizedText
200
+ };
201
+ }
202
+
203
+ function createToolUseBlock(baseId: string, block: ClaudeToolUseContentBlock, index: number): ToolUseChatMessageBlock {
204
+ return {
205
+ id: block.id ? `tool:${block.id}:use` : `${baseId}:tool_use:${index}`,
206
+ type: 'tool_use',
207
+ toolCallId: block.id,
208
+ toolName: block.name?.trim() || 'unknown',
209
+ input: stringifyUnknown(block.input)
210
+ };
211
+ }
212
+
213
+ function createToolResultBlock(baseId: string, block: ClaudeToolResultContentBlock, index: number): ToolResultChatMessageBlock {
214
+ return {
215
+ id: block.tool_use_id ? `tool:${block.tool_use_id}:result:${index}` : `${baseId}:tool_result:${index}`,
216
+ type: 'tool_result',
217
+ toolCallId: block.tool_use_id,
218
+ content: stringifyUnknown(block.content),
219
+ isError: Boolean(block.is_error)
220
+ };
221
+ }
222
+
223
+ function extractMessageBlocks(record: ClaudeJsonlRecord, baseId: string): ChatMessageBlock[] {
224
+ const rawContent = record.message?.content;
225
+
226
+ if (typeof rawContent === 'string') {
227
+ const textBlock = createTextBlock(baseId, rawContent);
228
+ return textBlock ? [textBlock] : [];
229
+ }
230
+
231
+ if (!Array.isArray(rawContent)) {
232
+ return [];
233
+ }
234
+
235
+ const blocks: ChatMessageBlock[] = [];
236
+
237
+ for (const [index, block] of rawContent.entries()) {
238
+ if (!block || typeof block !== 'object') {
239
+ continue;
240
+ }
241
+
242
+ if (isTextContentBlock(block)) {
243
+ const textBlock = createTextBlock(baseId, block.text ?? '', index);
244
+ if (textBlock) {
245
+ blocks.push(textBlock);
246
+ }
247
+ continue;
248
+ }
249
+
250
+ if (isToolUseContentBlock(block)) {
251
+ blocks.push(createToolUseBlock(baseId, block, index));
252
+ continue;
253
+ }
254
+
255
+ if (isToolResultContentBlock(block)) {
256
+ blocks.push(createToolResultBlock(baseId, block, index));
257
+ }
258
+ }
259
+
260
+ return blocks;
261
+ }
262
+
263
+ function deriveMessageStatus(blocks: ChatMessageBlock[]): ChatMessage['status'] {
264
+ if (blocks.some((block) => block.type === 'tool_result' && block.isError)) {
265
+ return 'error';
266
+ }
267
+
268
+ return 'complete';
269
+ }
270
+
271
+ function mergeMessageBlocks(existingBlocks: ChatMessageBlock[], nextBlocks: ChatMessageBlock[]): ChatMessageBlock[] {
272
+ if (existingBlocks.length === 0) {
273
+ return nextBlocks;
274
+ }
275
+
276
+ if (nextBlocks.length === 0) {
277
+ return existingBlocks;
278
+ }
279
+
280
+ const mergedBlocks = existingBlocks.slice();
281
+ const blockIndexById = new Map(mergedBlocks.map((block, index) => [block.id, index]));
282
+
283
+ for (const block of nextBlocks) {
284
+ const existingIndex = blockIndexById.get(block.id);
285
+ if (existingIndex === undefined) {
286
+ blockIndexById.set(block.id, mergedBlocks.length);
287
+ mergedBlocks.push(block);
288
+ continue;
289
+ }
290
+
291
+ mergedBlocks[existingIndex] = block;
292
+ }
293
+
294
+ return mergedBlocks;
295
+ }
296
+
297
+ function upsertMessage(orderedIds: string[], messagesById: Map<string, ChatMessage>, nextMessage: ChatMessage): void {
298
+ const existing = messagesById.get(nextMessage.id);
299
+ const blocks = hasVisibleBlocks(nextMessage.blocks)
300
+ ? mergeMessageBlocks(existing?.blocks ?? [], nextMessage.blocks)
301
+ : existing?.blocks ?? [];
302
+
303
+ if (!hasVisibleBlocks(blocks)) {
304
+ return;
305
+ }
306
+
307
+ const mergedMessage: ChatMessage = {
308
+ id: nextMessage.id,
309
+ role: nextMessage.role,
310
+ blocks,
311
+ status: nextMessage.status === 'error' ? 'error' : deriveMessageStatus(blocks),
312
+ createdAt: existing?.createdAt || nextMessage.createdAt
313
+ };
314
+
315
+ if (!existing) {
316
+ orderedIds.push(nextMessage.id);
317
+ }
318
+
319
+ messagesById.set(nextMessage.id, mergedMessage);
320
+ }
321
+
322
+ function removeMessage(orderedIds: string[], messagesById: Map<string, ChatMessage>, messageId: string): void {
323
+ if (!messagesById.has(messageId)) {
324
+ return;
325
+ }
326
+
327
+ messagesById.delete(messageId);
328
+ const index = orderedIds.indexOf(messageId);
329
+ if (index >= 0) {
330
+ orderedIds.splice(index, 1);
331
+ }
332
+ }
333
+
334
+ function clearActiveApiErrorMessage(state: ClaudeJsonlMessagesState): void {
335
+ if (!state.activeApiErrorMessageId) {
336
+ return;
337
+ }
338
+
339
+ removeMessage(state.orderedIds, state.messagesById, state.activeApiErrorMessageId);
340
+ state.activeApiErrorMessageId = null;
341
+ }
342
+
343
+ function isApiErrorRecord(record: ClaudeJsonlRecord): boolean {
344
+ return record.type === 'system' && record.subtype === 'api_error';
345
+ }
346
+
347
+ function formatApiErrorText(record: ClaudeJsonlRecord): string {
348
+ const status = typeof record.error?.status === 'number' ? String(record.error.status) : 'unknown';
349
+ const lines = [`API Error: ${status} 请求错误(状态码: ${status})`];
350
+
351
+ if (typeof record.retryAttempt === 'number' && typeof record.maxRetries === 'number') {
352
+ const retryInSeconds = Math.max(0, Math.round((record.retryInMs ?? 0) / 1000));
353
+ lines.push(`Retrying in ${retryInSeconds} seconds... (attempt ${record.retryAttempt}/${record.maxRetries})`);
354
+ }
355
+
356
+ return lines.join('\n');
357
+ }
358
+
359
+ function updateRuntimePhase(
360
+ state: ClaudeJsonlMessagesState,
361
+ record: ClaudeJsonlRecord,
362
+ role: 'user' | 'assistant' | null,
363
+ blocks: ChatMessageBlock[]
364
+ ): void {
365
+ const markActivity = (nextPhase: ClaudeJsonlRuntimePhase): void => {
366
+ state.runtimePhase = nextPhase;
367
+ state.activityRevision += 1;
368
+ };
369
+
370
+ switch (record.type) {
371
+ case 'progress':
372
+ markActivity('running');
373
+ return;
374
+ case 'queue-operation':
375
+ markActivity('running');
376
+ return;
377
+ case 'system':
378
+ if (record.subtype === 'stop_hook_summary') {
379
+ markActivity('idle');
380
+ return;
381
+ }
382
+ if (record.subtype === 'api_error' || record.subtype === 'local_command') {
383
+ markActivity('running');
384
+ }
385
+ return;
386
+ default:
387
+ break;
388
+ }
389
+
390
+ if (role === 'user') {
391
+ markActivity('running');
392
+ return;
393
+ }
394
+
395
+ if (role !== 'assistant') {
396
+ return;
397
+ }
398
+
399
+ const stopReason = record.message?.stop_reason ?? null;
400
+ if (stopReason === 'end_turn') {
401
+ markActivity('idle');
402
+ return;
403
+ }
404
+
405
+ if (record.isApiErrorMessage && stopReason === 'stop_sequence') {
406
+ markActivity('idle');
407
+ return;
408
+ }
409
+
410
+ if (stopReason === 'tool_use' || blocks.some((block) => block.type === 'tool_use')) {
411
+ markActivity('running');
412
+ }
413
+ }
414
+
415
+ export function applyClaudeJsonlLine(state: ClaudeJsonlMessagesState, line: string): boolean {
416
+ const trimmed = line.trim();
417
+ if (!trimmed) {
418
+ return false;
419
+ }
420
+
421
+ let record: ClaudeJsonlRecord;
422
+ try {
423
+ record = JSON.parse(trimmed) as ClaudeJsonlRecord;
424
+ } catch {
425
+ return false;
426
+ }
427
+
428
+ let blocks: ChatMessageBlock[] = [];
429
+
430
+ if (isApiErrorRecord(record)) {
431
+ const syntheticMessageId = state.activeApiErrorMessageId ?? `claude:api_error:${state.nextSyntheticMessageSequence++}`;
432
+ state.activeApiErrorMessageId = syntheticMessageId;
433
+ const block = createTextBlock(syntheticMessageId, formatApiErrorText(record));
434
+ if (block) {
435
+ upsertMessage(state.orderedIds, state.messagesById, {
436
+ id: syntheticMessageId,
437
+ role: 'assistant',
438
+ blocks: [block],
439
+ status: 'error',
440
+ createdAt: record.timestamp ?? new Date().toISOString()
441
+ });
442
+ blocks = [block];
443
+ }
444
+ } else if (record.type !== 'summary') {
445
+ clearActiveApiErrorMessage(state);
446
+ }
447
+
448
+ const role = record.message?.role === 'user' || record.message?.role === 'assistant' ? record.message.role : null;
449
+
450
+ if (role) {
451
+ const messageId = resolveRecordMessageId(record, role);
452
+ if (messageId) {
453
+ blocks = extractMessageBlocks(record, messageId);
454
+ upsertMessage(state.orderedIds, state.messagesById, {
455
+ id: messageId,
456
+ role,
457
+ blocks,
458
+ status: record.isApiErrorMessage ? 'error' : deriveMessageStatus(blocks),
459
+ createdAt: record.timestamp ?? new Date().toISOString()
460
+ });
461
+ }
462
+ }
463
+
464
+ updateRuntimePhase(state, record, role, blocks);
465
+
466
+ return true;
467
+ }
468
+
469
+ export function materializeClaudeJsonlMessages(state: ClaudeJsonlMessagesState): ChatMessage[] {
470
+ return compactMessages(
471
+ state.orderedIds.map((messageId) => state.messagesById.get(messageId)).filter(Boolean) as ChatMessage[]
472
+ );
473
+ }
474
+
475
+ export function parseClaudeJsonlMessages(rawText: string): ChatMessage[] {
476
+ const state = createClaudeJsonlMessagesState();
477
+
478
+ for (const line of rawText.split('\n')) {
479
+ applyClaudeJsonlLine(state, line);
480
+ }
481
+
482
+ return materializeClaudeJsonlMessages(state);
483
+ }