teleportation-cli 1.3.0 → 1.4.0

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.
@@ -16,7 +16,7 @@
16
16
  export async function fetchTimeline(session_id, config) {
17
17
  const { relayApiUrl, apiKey } = config;
18
18
 
19
- const response = await fetch(`${relayApiUrl}/api/timeline/${session_id}`, {
19
+ const response = await fetch(`${relayApiUrl}/api/sessions/${session_id}/timeline`, {
20
20
  headers: { 'Authorization': `Bearer ${apiKey}` }
21
21
  });
22
22
 
@@ -36,7 +36,7 @@ export async function fetchTimeline(session_id, config) {
36
36
  */
37
37
  export function analyzeTaskState(events, task_id) {
38
38
  // Filter events for this specific task
39
- const taskEvents = events.filter(e => e.meta?.task_id === task_id);
39
+ const taskEvents = events.filter(e => e.data?.task_id === task_id);
40
40
 
41
41
  if (taskEvents.length === 0) {
42
42
  return {
@@ -53,16 +53,17 @@ export function analyzeTaskState(events, task_id) {
53
53
 
54
54
  const lastEvent = taskEvents[taskEvents.length - 1];
55
55
 
56
- // Count turns (each assistant_response indicates a completed turn)
56
+ // Count turns (each assistant_response with matching task_id indicates a completed turn)
57
+ // Note: relay stores source inside data object, not at event top level
57
58
  const turn_count = taskEvents.filter(e =>
58
- e.type === 'assistant_response' && e.source === 'autonomous_task'
59
+ e.type === 'assistant_response'
59
60
  ).length;
60
61
 
61
62
  // Find the latest claude_session_id from task execution
62
63
  let claude_session_id = null;
63
64
  for (let i = taskEvents.length - 1; i >= 0; i--) {
64
- if (taskEvents[i].meta?.claude_session_id) {
65
- claude_session_id = taskEvents[i].meta.claude_session_id;
65
+ if (taskEvents[i].data?.claude_session_id) {
66
+ claude_session_id = taskEvents[i].data.claude_session_id;
66
67
  break;
67
68
  }
68
69
  }
@@ -72,7 +73,7 @@ export function analyzeTaskState(events, task_id) {
72
73
  e.type === 'approval_requested' &&
73
74
  !taskEvents.some(later =>
74
75
  later.type === 'approval_decided' &&
75
- later.meta?.approval_id === e.meta?.approval_id
76
+ later.data?.approval_id === e.data?.approval_id
76
77
  )
77
78
  );
78
79
 
@@ -93,7 +94,7 @@ export function analyzeTaskState(events, task_id) {
93
94
  state: 'paused',
94
95
  turn_count,
95
96
  ready_for_execution: false,
96
- reason: lastEvent.meta?.reason || 'Task paused',
97
+ reason: lastEvent.data?.reason || 'Task paused',
97
98
  claude_session_id,
98
99
  waiting_for_user_message: true,
99
100
  };
@@ -105,7 +106,7 @@ export function analyzeTaskState(events, task_id) {
105
106
  state: 'stopped',
106
107
  turn_count,
107
108
  ready_for_execution: false,
108
- reason: lastEvent.meta?.reason || 'Task stopped',
109
+ reason: lastEvent.data?.reason || 'Task stopped',
109
110
  claude_session_id,
110
111
  };
111
112
  }
@@ -134,7 +135,7 @@ export function analyzeTaskState(events, task_id) {
134
135
  // If last event is assistant_response, ready for next turn
135
136
  // Use stop_reason to determine if Claude is done (works like CLI)
136
137
  if (lastEvent.type === 'assistant_response') {
137
- const stopReason = lastEvent.meta?.stop_reason;
138
+ const stopReason = lastEvent.data?.stop_reason;
138
139
 
139
140
  // Claude uses "end_turn" when it's done with the current turn and waiting for input
140
141
  // This is the natural stopping point, just like in the CLI
@@ -183,9 +184,14 @@ export function getNextPrompt(state, task) {
183
184
  return task.task;
184
185
  }
185
186
 
186
- // If paused and resumed with user message, use that message
187
- if (task.pending_question) {
188
- return task.pending_question;
187
+ // If resumed with a user answer/message, use that as the prompt
188
+ if (task.pending_answer) {
189
+ return task.pending_answer;
190
+ }
191
+
192
+ // If redirected with new instructions, use those
193
+ if (task.pending_redirect) {
194
+ return task.pending_redirect;
189
195
  }
190
196
 
191
197
  // Default continuation prompt
@@ -7,7 +7,7 @@
7
7
  * @module lib/daemon/transcript-ingestion
8
8
  */
9
9
 
10
- import { readFile, readdir } from 'fs/promises';
10
+ import { readFile, readdir, writeFile, mkdir } from 'fs/promises';
11
11
  import { homedir, tmpdir } from 'os';
12
12
  import { join } from 'path';
13
13
 
@@ -132,6 +132,49 @@ const RETRY_BASE_DELAY_MS = 100;
132
132
  */
133
133
  const DEBUG = process.env.TELEPORTATION_DEBUG === 'true';
134
134
 
135
+ /**
136
+ * Local cursor directory for tracking last-processed transcript index.
137
+ * Persists across daemon restarts so we don't reprocess the entire transcript.
138
+ */
139
+ const CURSOR_DIR = join(tmpdir(), 'teleportation-cursors');
140
+
141
+ /**
142
+ * Read the local ingestion cursor for a session.
143
+ * Returns the last successfully processed transcript message count.
144
+ * @param {string} sessionId - Session ID
145
+ * @returns {Promise<number>} Last processed message count (0 if no cursor)
146
+ */
147
+ async function readCursor(sessionId) {
148
+ try {
149
+ const cursorPath = join(CURSOR_DIR, `${sessionId}.json`);
150
+ const data = JSON.parse(await readFile(cursorPath, 'utf8'));
151
+ return data.lastMessageCount || 0;
152
+ } catch {
153
+ return 0;
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Write the local ingestion cursor after successful push.
159
+ * @param {string} sessionId - Session ID
160
+ * @param {number} messageCount - Total transcript messages processed
161
+ * @param {number} eventsPushed - Events successfully pushed this cycle
162
+ */
163
+ async function writeCursor(sessionId, messageCount, eventsPushed) {
164
+ try {
165
+ await mkdir(CURSOR_DIR, { recursive: true });
166
+ const cursorPath = join(CURSOR_DIR, `${sessionId}.json`);
167
+ await writeFile(cursorPath, JSON.stringify({
168
+ lastMessageCount: messageCount,
169
+ lastEventsPushed: eventsPushed,
170
+ updatedAt: new Date().toISOString()
171
+ }));
172
+ } catch (e) {
173
+ // Non-fatal — next cycle will just reprocess some events
174
+ console.warn(`[transcript] Failed to write cursor for ${sessionId}: ${e.message}`);
175
+ }
176
+ }
177
+
135
178
  /**
136
179
  * Sleep helper for retry backoff
137
180
  * @param {number} ms - Milliseconds to sleep
@@ -328,7 +371,31 @@ function parseTimestamp(entry) {
328
371
  return Date.now();
329
372
  }
330
373
 
331
- const parsed = new Date(entry.timestamp).getTime();
374
+ const ts = entry.timestamp;
375
+
376
+ // Handle numeric string timestamps (epoch ms as string, e.g. "1771211826406")
377
+ // new Date("1771211826406") returns Invalid Date, so detect and convert first
378
+ if (typeof ts === 'string' && /^\d+$/.test(ts)) {
379
+ const numeric = Number(ts);
380
+ const now = Date.now();
381
+ if (numeric > now - TIMESTAMP_MAX_AGE_MS && numeric < now + TIMESTAMP_MAX_AGE_MS) {
382
+ return numeric;
383
+ }
384
+ console.warn(`[transcript] Numeric string timestamp out of range: ${ts}, using current time`);
385
+ return Date.now();
386
+ }
387
+
388
+ // Handle numeric timestamps directly
389
+ if (typeof ts === 'number') {
390
+ const now = Date.now();
391
+ if (ts > now - TIMESTAMP_MAX_AGE_MS && ts < now + TIMESTAMP_MAX_AGE_MS) {
392
+ return ts;
393
+ }
394
+ console.warn(`[transcript] Numeric timestamp out of range: ${ts}, using current time`);
395
+ return Date.now();
396
+ }
397
+
398
+ const parsed = new Date(ts).getTime();
332
399
 
333
400
  // Validate timestamp is reasonable (not NaN, not in distant past/future)
334
401
  if (isNaN(parsed)) {
@@ -396,7 +463,7 @@ function extractTimelineEvents(transcript, fromIndex = 0) {
396
463
  if (trimmedContent) {
397
464
  events.push({
398
465
  type: 'assistant_response',
399
- source: entry.type === 'autonomous_task' ? 'autonomous_task' : 'cli_interactive',
466
+ source: 'cli_interactive',
400
467
  timestamp,
401
468
  meta: {
402
469
  message: trimmedContent.slice(0, MAX_ASSISTANT_RESPONSE_LENGTH),
@@ -418,7 +485,7 @@ function extractTimelineEvents(transcript, fromIndex = 0) {
418
485
  for (const toolUse of toolUses) {
419
486
  events.push({
420
487
  type: 'tool_use',
421
- source: entry.type === 'autonomous_task' ? 'autonomous_task' : 'cli_interactive',
488
+ source: 'cli_interactive',
422
489
  timestamp,
423
490
  meta: {
424
491
  tool_name: toolUse.name,
@@ -448,7 +515,7 @@ function extractTimelineEvents(transcript, fromIndex = 0) {
448
515
 
449
516
  events.push({
450
517
  type: isError ? 'tool_failed' : 'tool_completed',
451
- source: entry.type === 'autonomous_task' ? 'autonomous_task' : 'cli_interactive',
518
+ source: 'cli_interactive',
452
519
  timestamp,
453
520
  meta: {
454
521
  tool_use_id: toolResult.tool_use_id,
@@ -573,14 +640,22 @@ export async function ingestTranscriptToTimeline(options) {
573
640
  let fromIndex = 0;
574
641
 
575
642
  if (task_id) {
576
- // For tasks: Use task's last ingested index
643
+ // For tasks: Use task's last ingested index, fall back to local cursor if fetchTask fails
577
644
  const task = await fetchTask(task_id, parent_session_id, config);
578
- fromIndex = task?.last_transcript_index || 0;
579
- console.log(`[transcript] Using task's last ingested index: ${fromIndex}`);
645
+ if (task?.last_transcript_index != null) {
646
+ fromIndex = task.last_transcript_index;
647
+ console.log(`[transcript] Using task's last ingested index: ${fromIndex}`);
648
+ } else {
649
+ // fetchTask returned null (network error) or task has no index — use local cursor
650
+ // Use claude_session_id as cursor key for tasks (each task has unique child session)
651
+ fromIndex = await readCursor(claude_session_id);
652
+ console.log(`[transcript] Task fetch failed or no index — using local cursor: ${fromIndex}`);
653
+ }
580
654
  } else {
581
- // For regular sessions: Query timeline to find recent events for deduplication
582
- // Queries last 50 events (not just 1) to catch concurrent hook/daemon ingestion
583
- // This prevents duplicates when hook and daemon run simultaneously
655
+ // For regular sessions: Use local cursor as primary deduplication,
656
+ // with timeline query as validation
657
+ const cursorMessageCount = await readCursor(parent_session_id);
658
+ console.log(`[transcript] Local cursor: ${cursorMessageCount} messages previously processed`);
584
659
  console.log(`[transcript] Querying timeline for session ${parent_session_id} to find recent events...`);
585
660
  try {
586
661
  const timelineResponse = await fetch(
@@ -599,46 +674,69 @@ export async function ingestTranscriptToTimeline(options) {
599
674
  console.log(`[transcript] Timeline returned ${timelineEvents.length} events`);
600
675
 
601
676
  if (timelineEvents.length > 0 && timelineEvents[0].timestamp) {
602
- const lastTimestamp = timelineEvents[0].timestamp;
603
- console.log(`[transcript] Last timeline event timestamp: ${lastTimestamp} (${new Date(lastTimestamp).toISOString()})`);
604
-
605
- // Sample first and last transcript timestamps
606
- if (transcript.length > 0) {
607
- const firstMsg = transcript[0];
608
- const lastMsg = transcript[transcript.length - 1];
609
- console.log(`[transcript] First transcript msg: ${firstMsg.timestamp}`);
610
- console.log(`[transcript] Last transcript msg: ${lastMsg.timestamp}`);
611
- }
612
-
613
- // Find the index of the first message after this timestamp
614
- fromIndex = transcript.findIndex(msg => {
615
- const msgTimestamp = new Date(msg.timestamp || 0).getTime();
616
- return msgTimestamp > lastTimestamp;
617
- });
618
-
619
- // If not found, start from end of transcript (all messages are older)
620
- if (fromIndex === -1) {
621
- log(`All messages older than last timeline event (${new Date(lastTimestamp).toISOString()}) - no new events`);
622
- console.log(`[transcript] All ${transcript.length} messages are older than last timeline event - no new events to ingest`);
623
- fromIndex = transcript.length;
677
+ // Timeline API returns timestamps as string epoch ms (e.g. "1771211826406")
678
+ // Must parse as number new Date("1771211826406") returns Invalid Date
679
+ const rawTimestamp = timelineEvents[0].timestamp;
680
+ const lastTimestamp = typeof rawTimestamp === 'string' && /^\d+$/.test(rawTimestamp)
681
+ ? Number(rawTimestamp)
682
+ : typeof rawTimestamp === 'number'
683
+ ? rawTimestamp
684
+ : new Date(rawTimestamp).getTime();
685
+
686
+ if (isNaN(lastTimestamp)) {
687
+ console.log(`[transcript] Could not parse timeline timestamp: ${rawTimestamp} - using local cursor`);
688
+ fromIndex = cursorMessageCount;
624
689
  } else {
625
- log(`Found ${transcript.length - fromIndex} new messages starting from index ${fromIndex}/${transcript.length}`);
626
- console.log(`[transcript] Found ${transcript.length - fromIndex} new messages starting from index ${fromIndex}/${transcript.length}`);
690
+ console.log(`[transcript] Last timeline event timestamp: ${lastTimestamp} (${new Date(lastTimestamp).toISOString()})`);
691
+
692
+ // Find the index of the first message after this timestamp
693
+ fromIndex = transcript.findIndex(msg => {
694
+ const msgTs = msg.timestamp;
695
+ // Transcript timestamps are ISO strings like "2026-02-15T16:10:42.035Z"
696
+ // or may be missing (undefined)
697
+ const msgTimestamp = !msgTs ? 0
698
+ : typeof msgTs === 'number' ? msgTs
699
+ : typeof msgTs === 'string' && /^\d+$/.test(msgTs) ? Number(msgTs)
700
+ : new Date(msgTs).getTime() || 0;
701
+ return msgTimestamp > lastTimestamp;
702
+ });
703
+
704
+ // If not found, start from end of transcript (all messages are older)
705
+ if (fromIndex === -1) {
706
+ log(`All messages older than last timeline event (${new Date(lastTimestamp).toISOString()}) - no new events`);
707
+ console.log(`[transcript] All ${transcript.length} messages are older than last timeline event - no new events to ingest`);
708
+ fromIndex = transcript.length;
709
+ } else {
710
+ log(`Found ${transcript.length - fromIndex} new messages starting from index ${fromIndex}/${transcript.length}`);
711
+ console.log(`[transcript] Found ${transcript.length - fromIndex} new messages starting from index ${fromIndex}/${transcript.length}`);
712
+ }
627
713
  }
628
714
  } else {
629
- console.log(`[transcript] No timeline events found - will process all transcript messages`);
630
- fromIndex = 0;
715
+ // No timeline events use local cursor to avoid reprocessing
716
+ fromIndex = cursorMessageCount;
717
+ if (cursorMessageCount > 0) {
718
+ console.log(`[transcript] No timeline events found - using local cursor (${cursorMessageCount})`);
719
+ } else {
720
+ console.log(`[transcript] No timeline events and no cursor - processing all transcript messages`);
721
+ }
631
722
  }
632
723
  } else {
633
- console.log(`[transcript] Timeline query failed with status ${timelineResponse.status} - processing all messages`);
634
- fromIndex = 0;
724
+ fromIndex = cursorMessageCount;
725
+ console.log(`[transcript] Timeline query failed (${timelineResponse.status}) - using local cursor (${cursorMessageCount})`);
635
726
  }
636
727
  } catch (error) {
637
- console.log(`[transcript] Failed to query timeline: ${error.message}, processing all messages`);
638
- fromIndex = 0;
728
+ fromIndex = cursorMessageCount;
729
+ console.log(`[transcript] Failed to query timeline: ${error.message} - using local cursor (${cursorMessageCount})`);
639
730
  }
640
731
  }
641
732
 
733
+ // Clamp fromIndex to transcript length to prevent stale cursors from
734
+ // skipping past the end of a shorter transcript (e.g., cursor=500, transcript has 100 messages)
735
+ if (fromIndex > transcript.length) {
736
+ console.warn(`[transcript] Clamping fromIndex from ${fromIndex} to transcript length ${transcript.length} — cursor may be stale or transcript truncated`);
737
+ fromIndex = transcript.length;
738
+ }
739
+
642
740
  // 3. Extract only NEW events from determined index
643
741
  const allEvents = extractTimelineEvents(transcript, fromIndex);
644
742
 
@@ -677,9 +775,16 @@ export async function ingestTranscriptToTimeline(options) {
677
775
  log(`===== INGESTION COMPLETE: ${successCount}/${events.length} events pushed (${failCount} failed) =====`);
678
776
  console.log(`[transcript] Pushed ${successCount}/${events.length} events (${failCount} failed)`);
679
777
 
680
- // 6. Update last ingested index to prevent duplicates on next call (tasks only)
681
- if (task_id && successCount > 0) {
682
- await updateLastIngestedIndex(task_id, parent_session_id, transcript.length, config);
778
+ // 6. Update cursors to prevent duplicates on next call
779
+ if (successCount > 0) {
780
+ // Local cursor — use claude_session_id for tasks (unique per child session),
781
+ // parent_session_id for regular sessions (maps to transcript file)
782
+ const cursorKey = task_id ? claude_session_id : parent_session_id;
783
+ await writeCursor(cursorKey, transcript.length, successCount);
784
+ // Remote cursor (tasks only) — persists in relay
785
+ if (task_id) {
786
+ await updateLastIngestedIndex(task_id, parent_session_id, transcript.length, config);
787
+ }
683
788
  }
684
789
 
685
790
  return { events_pushed: successCount, events_failed: failCount };
@@ -694,3 +799,6 @@ export async function getTranscriptLength(claude_session_id) {
694
799
  const transcript = await readTranscript(claude_session_id);
695
800
  return transcript.length;
696
801
  }
802
+
803
+ // Export internals for unit testing
804
+ export { parseTimestamp, extractTimelineEvents, readCursor, writeCursor };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teleportation-cli",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Remote approval system for Claude Code - approve AI coding changes from your phone",
5
5
  "type": "module",
6
6
  "main": "teleportation-cli.cjs",