@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,604 @@
1
+ import type {
2
+ ChatMessage,
3
+ ChatMessageBlock,
4
+ TextChatMessageBlock,
5
+ ToolResultChatMessageBlock,
6
+ ToolUseChatMessageBlock
7
+ } from '@lzdi/pty-remote-protocol/runtime-types.ts';
8
+
9
+ interface CodexTextContentBlock {
10
+ type?: string;
11
+ text?: string;
12
+ }
13
+
14
+ interface CodexResponseItemPayload {
15
+ type?: string;
16
+ role?: string;
17
+ phase?: string;
18
+ content?: string | CodexTextContentBlock[];
19
+ arguments?: string;
20
+ input?: unknown;
21
+ output?: unknown;
22
+ call_id?: string;
23
+ name?: string;
24
+ status?: string;
25
+ action?: {
26
+ type?: string;
27
+ query?: string;
28
+ queries?: string[];
29
+ url?: string;
30
+ pattern?: string;
31
+ } | Record<string, unknown>;
32
+ }
33
+
34
+ interface CodexEventMsgPayload {
35
+ type?: string;
36
+ message?: string;
37
+ text?: string;
38
+ phase?: string;
39
+ }
40
+
41
+ interface CodexJsonlRecord {
42
+ timestamp?: string;
43
+ type?: string;
44
+ payload?: CodexResponseItemPayload | CodexEventMsgPayload;
45
+ }
46
+
47
+ export type CodexJsonlRuntimePhase = 'idle' | 'running';
48
+
49
+ export interface CodexJsonlMessagesState {
50
+ orderedIds: string[];
51
+ messagesById: Map<string, ChatMessage>;
52
+ runtimePhase: CodexJsonlRuntimePhase;
53
+ activityRevision: number;
54
+ messageSequence: number;
55
+ seenAssistantTextKeys: Set<string>;
56
+ seenUserTextKeys: Set<string>;
57
+ }
58
+
59
+ export function createCodexJsonlMessagesState(): CodexJsonlMessagesState {
60
+ return {
61
+ orderedIds: [],
62
+ messagesById: new Map<string, ChatMessage>(),
63
+ runtimePhase: 'idle',
64
+ activityRevision: 0,
65
+ messageSequence: 0,
66
+ seenAssistantTextKeys: new Set<string>(),
67
+ seenUserTextKeys: new Set<string>()
68
+ };
69
+ }
70
+
71
+ function normalizeAssistantText(text: string): string {
72
+ return text.trim();
73
+ }
74
+
75
+ function isSyntheticEnvironmentContext(text: string): boolean {
76
+ const normalized = text.trim();
77
+ return /^<environment_context>\s*[\s\S]*<\/environment_context>$/.test(normalized);
78
+ }
79
+
80
+ function hashText(input: string): string {
81
+ let hash = 2166136261;
82
+ for (let index = 0; index < input.length; index += 1) {
83
+ hash ^= input.charCodeAt(index);
84
+ hash = Math.imul(hash, 16777619);
85
+ }
86
+ return (hash >>> 0).toString(36);
87
+ }
88
+
89
+ function createStableTextMessageId(kind: string, timestamp: string | undefined, text: string, sequence: number): string {
90
+ const normalized = text.trim();
91
+ const digest = hashText(normalized);
92
+ const normalizedTimestamp = timestamp?.trim();
93
+ if (normalizedTimestamp) {
94
+ return `${kind}:${normalizedTimestamp}:${digest}`;
95
+ }
96
+ return `${kind}:seq:${sequence}:${digest}`;
97
+ }
98
+
99
+ function rememberAssistantText(
100
+ state: CodexJsonlMessagesState,
101
+ timestamp: string | undefined,
102
+ text: string
103
+ ): boolean {
104
+ const normalizedText = normalizeAssistantText(text);
105
+ if (!normalizedText) {
106
+ return false;
107
+ }
108
+
109
+ const parsedTimestampMs = new Date(timestamp ?? '').getTime();
110
+ const timestampBucket = Number.isFinite(parsedTimestampMs)
111
+ ? String(Math.floor(parsedTimestampMs / 1_000))
112
+ : `seq:${Math.floor(state.messageSequence / 4)}`;
113
+ const dedupeKey = `${timestampBucket}\u0000${normalizedText}`;
114
+ if (state.seenAssistantTextKeys.has(dedupeKey)) {
115
+ return false;
116
+ }
117
+
118
+ state.seenAssistantTextKeys.add(dedupeKey);
119
+ return true;
120
+ }
121
+
122
+ function rememberUserText(
123
+ state: CodexJsonlMessagesState,
124
+ timestamp: string | undefined,
125
+ text: string
126
+ ): boolean {
127
+ const normalizedText = text.trim();
128
+ if (!normalizedText) {
129
+ return false;
130
+ }
131
+
132
+ const parsedTimestampMs = new Date(timestamp ?? '').getTime();
133
+ const timestampBucket = Number.isFinite(parsedTimestampMs)
134
+ ? String(Math.floor(parsedTimestampMs / 1_000))
135
+ : `seq:${Math.floor(state.messageSequence / 4)}`;
136
+ const dedupeKey = `${timestampBucket}\u0000${normalizedText}`;
137
+ if (state.seenUserTextKeys.has(dedupeKey)) {
138
+ return false;
139
+ }
140
+
141
+ state.seenUserTextKeys.add(dedupeKey);
142
+ return true;
143
+ }
144
+
145
+ function stringifyUnknown(value: unknown): string {
146
+ if (typeof value === 'string') {
147
+ return value.trim();
148
+ }
149
+
150
+ if (value === null || value === undefined) {
151
+ return '';
152
+ }
153
+
154
+ if (typeof value === 'object') {
155
+ return JSON.stringify(value, null, 2);
156
+ }
157
+
158
+ return String(value);
159
+ }
160
+
161
+ function normalizeCreatedAt(timestamp: string | undefined, sequence: number): string {
162
+ const parsed = new Date(timestamp ?? '').getTime();
163
+ if (Number.isFinite(parsed)) {
164
+ return new Date(parsed).toISOString();
165
+ }
166
+ return new Date(sequence * 1_000).toISOString();
167
+ }
168
+
169
+ function hasVisibleBlocks(blocks: ChatMessageBlock[]): boolean {
170
+ return blocks.some((block) => {
171
+ switch (block.type) {
172
+ case 'text':
173
+ return Boolean(block.text.trim());
174
+ case 'tool_use':
175
+ return Boolean(block.toolName || block.input);
176
+ case 'tool_result':
177
+ return Boolean(block.content.trim());
178
+ default:
179
+ return false;
180
+ }
181
+ });
182
+ }
183
+
184
+ function createTextBlock(baseId: string, text: string, index = 0): TextChatMessageBlock | null {
185
+ const normalized = text.trimEnd();
186
+ if (!normalized.trim()) {
187
+ return null;
188
+ }
189
+
190
+ return {
191
+ id: `${baseId}:text:${index}`,
192
+ type: 'text',
193
+ text: normalized
194
+ };
195
+ }
196
+
197
+ function createToolUseBlock(callId: string, toolName: string, input: string): ToolUseChatMessageBlock {
198
+ return {
199
+ id: `tool:${callId}:use`,
200
+ type: 'tool_use',
201
+ toolCallId: callId,
202
+ toolName: toolName.trim() || 'unknown',
203
+ input
204
+ };
205
+ }
206
+
207
+ function createToolResultBlock(callId: string, content: string, isError: boolean): ToolResultChatMessageBlock {
208
+ return {
209
+ id: `tool:${callId}:result`,
210
+ type: 'tool_result',
211
+ toolCallId: callId,
212
+ content,
213
+ isError
214
+ };
215
+ }
216
+
217
+ function getWebSearchQuery(action: CodexResponseItemPayload['action']): string {
218
+ if (!action || typeof action !== 'object') {
219
+ return '';
220
+ }
221
+
222
+ const normalizedAction = action as {
223
+ type?: string;
224
+ query?: string;
225
+ queries?: string[];
226
+ };
227
+ if (normalizedAction.type !== 'search') {
228
+ return '';
229
+ }
230
+
231
+ if (typeof normalizedAction.query === 'string' && normalizedAction.query.trim()) {
232
+ return normalizedAction.query.trim();
233
+ }
234
+
235
+ if (Array.isArray(normalizedAction.queries)) {
236
+ const firstQuery = normalizedAction.queries.find((query) => typeof query === 'string' && query.trim());
237
+ return firstQuery?.trim() ?? '';
238
+ }
239
+
240
+ return '';
241
+ }
242
+
243
+ function mergeMessageBlocks(existingBlocks: ChatMessageBlock[], nextBlocks: ChatMessageBlock[]): ChatMessageBlock[] {
244
+ if (existingBlocks.length === 0) {
245
+ return nextBlocks;
246
+ }
247
+
248
+ if (nextBlocks.length === 0) {
249
+ return existingBlocks;
250
+ }
251
+
252
+ const merged = existingBlocks.slice();
253
+ const blockIndexById = new Map(merged.map((block, index) => [block.id, index]));
254
+
255
+ for (const block of nextBlocks) {
256
+ const existingIndex = blockIndexById.get(block.id);
257
+ if (existingIndex === undefined) {
258
+ blockIndexById.set(block.id, merged.length);
259
+ merged.push(block);
260
+ continue;
261
+ }
262
+ merged[existingIndex] = block;
263
+ }
264
+
265
+ return merged;
266
+ }
267
+
268
+ function deriveMessageStatus(
269
+ blocks: ChatMessageBlock[],
270
+ runtimePhase: CodexJsonlRuntimePhase
271
+ ): ChatMessage['status'] {
272
+ if (blocks.some((block) => block.type === 'tool_result' && block.isError)) {
273
+ return 'error';
274
+ }
275
+
276
+ const hasToolUse = blocks.some((block) => block.type === 'tool_use');
277
+ const hasToolResult = blocks.some((block) => block.type === 'tool_result');
278
+ if (hasToolUse && !hasToolResult && runtimePhase === 'running') {
279
+ return 'streaming';
280
+ }
281
+
282
+ return 'complete';
283
+ }
284
+
285
+ export function refreshCodexJsonlMessageStatuses(state: CodexJsonlMessagesState): void {
286
+ for (const [messageId, message] of state.messagesById.entries()) {
287
+ const nextStatus = deriveMessageStatus(message.blocks, state.runtimePhase);
288
+ if (message.status === nextStatus) {
289
+ continue;
290
+ }
291
+ state.messagesById.set(messageId, {
292
+ ...message,
293
+ status: nextStatus
294
+ });
295
+ }
296
+ }
297
+
298
+ function upsertMessage(state: CodexJsonlMessagesState, nextMessage: ChatMessage): void {
299
+ const existing = state.messagesById.get(nextMessage.id);
300
+ const blocks = hasVisibleBlocks(nextMessage.blocks)
301
+ ? mergeMessageBlocks(existing?.blocks ?? [], nextMessage.blocks)
302
+ : existing?.blocks ?? [];
303
+
304
+ if (!hasVisibleBlocks(blocks)) {
305
+ return;
306
+ }
307
+
308
+ const mergedMessage: ChatMessage = {
309
+ id: nextMessage.id,
310
+ role: nextMessage.role,
311
+ blocks,
312
+ status: deriveMessageStatus(blocks, state.runtimePhase),
313
+ createdAt: existing?.createdAt ?? nextMessage.createdAt
314
+ };
315
+
316
+ if (!existing) {
317
+ state.orderedIds.push(nextMessage.id);
318
+ }
319
+
320
+ state.messagesById.set(nextMessage.id, mergedMessage);
321
+ state.activityRevision += 1;
322
+ }
323
+
324
+ function extractTextBlocks(content: string | CodexTextContentBlock[] | undefined, baseId: string): ChatMessageBlock[] {
325
+ if (typeof content === 'string') {
326
+ const textBlock = createTextBlock(baseId, content);
327
+ return textBlock ? [textBlock] : [];
328
+ }
329
+
330
+ if (!Array.isArray(content)) {
331
+ return [];
332
+ }
333
+
334
+ const blocks: ChatMessageBlock[] = [];
335
+ for (const [index, block] of content.entries()) {
336
+ if (!block || typeof block !== 'object') {
337
+ continue;
338
+ }
339
+
340
+ if ((block.type === 'input_text' || block.type === 'output_text') && typeof block.text === 'string') {
341
+ const textBlock = createTextBlock(baseId, block.text, index);
342
+ if (textBlock) {
343
+ blocks.push(textBlock);
344
+ }
345
+ }
346
+ }
347
+
348
+ return blocks;
349
+ }
350
+
351
+ function applyEventMsg(state: CodexJsonlMessagesState, payload: CodexEventMsgPayload | undefined, timestamp: string | undefined): void {
352
+ const payloadType = payload?.type;
353
+ if (payloadType === 'user_message') {
354
+ const sequence = state.messageSequence++;
355
+ const messageText = payload?.message ?? '';
356
+ if (isSyntheticEnvironmentContext(messageText)) {
357
+ return;
358
+ }
359
+ if (!rememberUserText(state, timestamp, messageText)) {
360
+ return;
361
+ }
362
+ const messageId = createStableTextMessageId('codex:user', timestamp, messageText, sequence);
363
+ const textBlock = createTextBlock(messageId, messageText);
364
+ if (!textBlock) {
365
+ return;
366
+ }
367
+
368
+ upsertMessage(state, {
369
+ id: messageId,
370
+ role: 'user',
371
+ blocks: [textBlock],
372
+ status: 'complete',
373
+ createdAt: normalizeCreatedAt(timestamp, sequence)
374
+ });
375
+ return;
376
+ }
377
+
378
+ if (payloadType === 'agent_reasoning') {
379
+ const reasoningText = typeof payload?.text === 'string' ? payload.text.trim() : '';
380
+ if (!reasoningText) {
381
+ return;
382
+ }
383
+ if (!rememberAssistantText(state, timestamp, reasoningText)) {
384
+ return;
385
+ }
386
+
387
+ const sequence = state.messageSequence++;
388
+ const messageId = createStableTextMessageId('codex:assistant_reasoning', timestamp, reasoningText, sequence);
389
+ const textBlock = createTextBlock(messageId, reasoningText);
390
+ if (!textBlock) {
391
+ return;
392
+ }
393
+
394
+ upsertMessage(state, {
395
+ id: messageId,
396
+ role: 'assistant',
397
+ blocks: [textBlock],
398
+ status: 'complete',
399
+ createdAt: normalizeCreatedAt(timestamp, sequence)
400
+ });
401
+ return;
402
+ }
403
+
404
+ if (payloadType === 'agent_message') {
405
+ const messageText = typeof payload?.message === 'string' ? payload.message.trim() : '';
406
+ if (!messageText) {
407
+ return;
408
+ }
409
+ if (!rememberAssistantText(state, timestamp, messageText)) {
410
+ return;
411
+ }
412
+
413
+ const sequence = state.messageSequence++;
414
+ const messageId = createStableTextMessageId('codex:assistant_text', timestamp, messageText, sequence);
415
+ const textBlock = createTextBlock(messageId, messageText);
416
+ if (!textBlock) {
417
+ return;
418
+ }
419
+
420
+ upsertMessage(state, {
421
+ id: messageId,
422
+ role: 'assistant',
423
+ blocks: [textBlock],
424
+ status: 'complete',
425
+ createdAt: normalizeCreatedAt(timestamp, sequence)
426
+ });
427
+ return;
428
+ }
429
+
430
+ let nextPhase: CodexJsonlRuntimePhase | null = null;
431
+
432
+ if (payloadType === 'task_started') {
433
+ nextPhase = 'running';
434
+ } else if (payloadType === 'task_complete' || payloadType === 'turn_aborted') {
435
+ nextPhase = 'idle';
436
+ }
437
+
438
+ if (!nextPhase || nextPhase === state.runtimePhase) {
439
+ return;
440
+ }
441
+
442
+ state.runtimePhase = nextPhase;
443
+ state.activityRevision += 1;
444
+ refreshCodexJsonlMessageStatuses(state);
445
+ }
446
+
447
+ function applyResponseItem(
448
+ state: CodexJsonlMessagesState,
449
+ payload: CodexResponseItemPayload | undefined,
450
+ timestamp: string | undefined
451
+ ): void {
452
+ const payloadType = payload?.type;
453
+ if (!payloadType) {
454
+ return;
455
+ }
456
+
457
+ if (payloadType === 'message') {
458
+ const role = payload?.role;
459
+ if (role !== 'assistant' && role !== 'user') {
460
+ return;
461
+ }
462
+
463
+ const sequence = state.messageSequence++;
464
+ const provisionalMessageId = `codex:assistant:${sequence}`;
465
+ const blocks = extractTextBlocks(payload.content, provisionalMessageId);
466
+ if (blocks.length === 0) {
467
+ return;
468
+ }
469
+ const messageText = blocks
470
+ .filter((block): block is TextChatMessageBlock => block.type === 'text')
471
+ .map((block) => block.text)
472
+ .join('\n')
473
+ .trim();
474
+ if (role === 'user' && isSyntheticEnvironmentContext(messageText)) {
475
+ return;
476
+ }
477
+ if (role === 'user' && !rememberUserText(state, timestamp, messageText)) {
478
+ return;
479
+ }
480
+ if (role === 'assistant' && !rememberAssistantText(state, timestamp, messageText)) {
481
+ return;
482
+ }
483
+
484
+ const stableMessageId = createStableTextMessageId(
485
+ role === 'assistant' ? 'codex:assistant_text' : 'codex:user',
486
+ timestamp,
487
+ messageText,
488
+ sequence
489
+ );
490
+ const stableBlocks = extractTextBlocks(payload.content, stableMessageId);
491
+ if (stableBlocks.length === 0) {
492
+ return;
493
+ }
494
+
495
+ upsertMessage(state, {
496
+ id: stableMessageId,
497
+ role,
498
+ blocks: stableBlocks,
499
+ status: 'complete',
500
+ createdAt: normalizeCreatedAt(timestamp, sequence)
501
+ });
502
+ return;
503
+ }
504
+
505
+ if (payloadType === 'function_call' || payloadType === 'custom_tool_call') {
506
+ const callId = payload.call_id?.trim();
507
+ if (!callId) {
508
+ return;
509
+ }
510
+
511
+ const rawInput = payloadType === 'custom_tool_call' ? payload.input : payload.arguments;
512
+ upsertMessage(state, {
513
+ id: `tool:${callId}`,
514
+ role: 'assistant',
515
+ blocks: [createToolUseBlock(callId, payload.name ?? 'unknown', stringifyUnknown(rawInput))],
516
+ status: 'streaming',
517
+ createdAt: normalizeCreatedAt(timestamp, state.messageSequence++)
518
+ });
519
+ return;
520
+ }
521
+
522
+ if (payloadType === 'web_search_call') {
523
+ const query = getWebSearchQuery(payload.action);
524
+ if (!query) {
525
+ return;
526
+ }
527
+
528
+ const sequence = state.messageSequence++;
529
+ const callId = `web_search_${sequence}`;
530
+ upsertMessage(state, {
531
+ id: `tool:${callId}`,
532
+ role: 'assistant',
533
+ blocks: [createToolUseBlock(callId, 'web_search', query)],
534
+ status: 'complete',
535
+ createdAt: normalizeCreatedAt(timestamp, sequence)
536
+ });
537
+ return;
538
+ }
539
+
540
+ if (payloadType === 'function_call_output' || payloadType === 'custom_tool_call_output') {
541
+ const callId = payload.call_id?.trim();
542
+ if (!callId) {
543
+ return;
544
+ }
545
+
546
+ const normalizedStatus = payload.status?.trim().toLowerCase();
547
+ const isError = normalizedStatus === 'error' || normalizedStatus === 'failed' || normalizedStatus === 'cancelled';
548
+ upsertMessage(state, {
549
+ id: `tool:${callId}`,
550
+ role: 'assistant',
551
+ blocks: [createToolResultBlock(callId, stringifyUnknown(payload.output), isError)],
552
+ status: isError ? 'error' : 'complete',
553
+ createdAt: normalizeCreatedAt(timestamp, state.messageSequence++)
554
+ });
555
+ }
556
+ }
557
+
558
+ export function applyCodexJsonlLine(state: CodexJsonlMessagesState, line: string): boolean {
559
+ const trimmed = line.trim();
560
+ if (!trimmed) {
561
+ return true;
562
+ }
563
+
564
+ let parsed: CodexJsonlRecord;
565
+ try {
566
+ parsed = JSON.parse(trimmed) as CodexJsonlRecord;
567
+ } catch {
568
+ return false;
569
+ }
570
+
571
+ if (parsed.type === 'event_msg') {
572
+ applyEventMsg(state, parsed.payload as CodexEventMsgPayload | undefined, parsed.timestamp);
573
+ return true;
574
+ }
575
+
576
+ if (parsed.type === 'response_item') {
577
+ applyResponseItem(state, parsed.payload as CodexResponseItemPayload | undefined, parsed.timestamp);
578
+ return true;
579
+ }
580
+
581
+ return true;
582
+ }
583
+
584
+ export function materializeCodexJsonlMessages(state: CodexJsonlMessagesState): ChatMessage[] {
585
+ return state.orderedIds
586
+ .map((messageId) => state.messagesById.get(messageId))
587
+ .filter((message): message is ChatMessage => Boolean(message));
588
+ }
589
+
590
+ export function parseCodexJsonlMessages(raw: string): {
591
+ isRunning: boolean;
592
+ messages: ChatMessage[];
593
+ } {
594
+ const state = createCodexJsonlMessagesState();
595
+
596
+ for (const line of raw.split('\n')) {
597
+ applyCodexJsonlLine(state, line);
598
+ }
599
+
600
+ return {
601
+ isRunning: state.runtimePhase === 'running',
602
+ messages: materializeCodexJsonlMessages(state)
603
+ };
604
+ }