teleportation-cli 1.2.2 → 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.
@@ -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 };
@@ -287,11 +287,11 @@ export async function installGeminiHooks(sourceGeminiHooksDir) {
287
287
  settings.hooks = settings.hooks || {};
288
288
 
289
289
  const hooksConfig = {
290
- BeforeTool: [{ command: `node ${join(destHooksDir, 'before_tool.mjs')}`, timeout: 65000 }],
291
- AfterTool: [{ command: `node ${join(destHooksDir, 'after_tool.mjs')}`, timeout: 10000 }],
292
- SessionStart: [{ command: `node ${join(destHooksDir, 'session_start.mjs')}`, timeout: 15000 }],
293
- AfterAgent: [{ command: `node ${join(destHooksDir, 'after_agent.mjs')}`, timeout: 15000 }],
294
- SessionEnd: [{ command: `node ${join(destHooksDir, 'session_end.mjs')}`, timeout: 10000 }]
290
+ BeforeTool: [{ command: `bun ${join(destHooksDir, 'before_tool.mjs')}`, timeout: 65000 }],
291
+ AfterTool: [{ command: `bun ${join(destHooksDir, 'after_tool.mjs')}`, timeout: 10000 }],
292
+ SessionStart: [{ command: `bun ${join(destHooksDir, 'session_start.mjs')}`, timeout: 15000 }],
293
+ AfterAgent: [{ command: `bun ${join(destHooksDir, 'after_agent.mjs')}`, timeout: 15000 }],
294
+ SessionEnd: [{ command: `bun ${join(destHooksDir, 'session_end.mjs')}`, timeout: 10000 }]
295
295
  };
296
296
 
297
297
  // Standardize: Ensure BeforeAgent is also set if needed (parity with Claude's UserPromptSubmit)
@@ -316,7 +316,10 @@ export async function installDaemon() {
316
316
 
317
317
  const daemonFiles = [
318
318
  'teleportation-daemon.js',
319
- 'lifecycle.js'
319
+ 'lifecycle.js',
320
+ 'task-executor-v2.js',
321
+ 'transcript-ingestion.js',
322
+ 'timeline-analyzer.js',
320
323
  ];
321
324
 
322
325
  const installed = [];
@@ -352,7 +355,7 @@ export async function installDaemon() {
352
355
 
353
356
  /**
354
357
  * Copy daemon dependency modules to ~/.teleportation/
355
- * This includes machine-coders and router
358
+ * This includes machine-coders, router, utils, and auth
356
359
  */
357
360
  export async function installDaemonModules() {
358
361
  const sourceLibDir = join(getTeleportationDir(), 'lib');
@@ -377,6 +380,19 @@ export async function installDaemonModules() {
377
380
  'models.js',
378
381
  'mech-llms-client.js'
379
382
  ]
383
+ },
384
+ {
385
+ name: 'utils',
386
+ files: [
387
+ 'logger.js',
388
+ 'log-sanitizer.js'
389
+ ]
390
+ },
391
+ {
392
+ name: 'auth',
393
+ files: [
394
+ 'credentials.js'
395
+ ]
380
396
  }
381
397
  ];
382
398
 
@@ -513,42 +529,56 @@ export async function createSettings() {
513
529
  matcher: ".*",
514
530
  hooks: [{
515
531
  type: "command",
516
- command: `node ${join(projectHooksDir, 'pre_tool_use.mjs')}`
532
+ command: `bun ${join(projectHooksDir, 'pre_tool_use.mjs')}`
517
533
  }]
518
534
  }],
519
535
  Stop: [{
520
536
  matcher: ".*",
521
537
  hooks: [{
522
538
  type: "command",
523
- command: `node ${join(projectHooksDir, 'stop.mjs')}`
539
+ command: `bun ${join(projectHooksDir, 'stop.mjs')}`
524
540
  }]
525
541
  }],
526
542
  SessionStart: [{
527
543
  matcher: ".*",
528
544
  hooks: [{
529
545
  type: "command",
530
- command: `node ${join(projectHooksDir, 'session_start.mjs')}`
546
+ command: `bun ${join(projectHooksDir, 'session_start.mjs')}`
531
547
  }]
532
548
  }],
533
549
  SessionEnd: [{
534
550
  matcher: ".*",
535
551
  hooks: [{
536
552
  type: "command",
537
- command: `node ${join(projectHooksDir, 'session_end.mjs')}`
553
+ command: `bun ${join(projectHooksDir, 'session_end.mjs')}`
538
554
  }]
539
555
  }],
540
556
  Notification: [{
541
557
  matcher: ".*",
542
558
  hooks: [{
543
559
  type: "command",
544
- command: `node ${join(projectHooksDir, 'notification.mjs')}`
560
+ command: `bun ${join(projectHooksDir, 'notification.mjs')}`
545
561
  }]
546
562
  }],
547
563
  UserPromptSubmit: [{
548
564
  matcher: ".*",
549
565
  hooks: [{
550
566
  type: "command",
551
- command: `node ${join(projectHooksDir, 'user_prompt_submit.mjs')}`
567
+ command: `bun ${join(projectHooksDir, 'user_prompt_submit.mjs')}`
568
+ }]
569
+ }],
570
+ PermissionRequest: [{
571
+ matcher: ".*",
572
+ hooks: [{
573
+ type: "command",
574
+ command: `bun ${join(projectHooksDir, 'permission_request.mjs')}`
575
+ }]
576
+ }],
577
+ PostToolUse: [{
578
+ matcher: ".*",
579
+ hooks: [{
580
+ type: "command",
581
+ command: `bun ${join(projectHooksDir, 'post_tool_use.mjs')}`
552
582
  }]
553
583
  }]
554
584
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teleportation-cli",
3
- "version": "1.2.2",
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",
@@ -1295,7 +1295,63 @@ async function commandTest() {
1295
1295
  console.log(c.red(' ❌ FAIL - Hook execution error\n'));
1296
1296
  failed++;
1297
1297
  }
1298
-
1298
+
1299
+ // Test 6: Approval Round-Trip
1300
+ console.log(c.yellow('Test 6: Approval Round-Trip'));
1301
+ if (creds.RELAY_API_KEY && relayUrl) {
1302
+ try {
1303
+ // Create a test approval
1304
+ const createRes = await fetch(`${relayUrl}/api/approvals`, {
1305
+ method: 'POST',
1306
+ headers: {
1307
+ 'Content-Type': 'application/json',
1308
+ 'Authorization': `Bearer ${creds.RELAY_API_KEY}`
1309
+ },
1310
+ body: JSON.stringify({
1311
+ session_id: 'test-smoke-' + Date.now(),
1312
+ tool_name: 'Read',
1313
+ tool_input: { file_path: '/tmp/smoke-test.txt' }
1314
+ }),
1315
+ signal: AbortSignal.timeout(10000)
1316
+ });
1317
+
1318
+ if (!createRes.ok) throw new Error(`Create failed: ${createRes.status}`);
1319
+ const { id: approvalId } = await createRes.json();
1320
+
1321
+ // Approve it
1322
+ const decideRes = await fetch(`${relayUrl}/api/approvals/${approvalId}/decision`, {
1323
+ method: 'POST',
1324
+ headers: {
1325
+ 'Content-Type': 'application/json',
1326
+ 'Authorization': `Bearer ${creds.RELAY_API_KEY}`
1327
+ },
1328
+ body: JSON.stringify({ decision: 'allow', reason: 'smoke test' }),
1329
+ signal: AbortSignal.timeout(10000)
1330
+ });
1331
+
1332
+ if (!decideRes.ok) throw new Error(`Decide failed: ${decideRes.status}`);
1333
+
1334
+ // Verify
1335
+ const checkRes = await fetch(`${relayUrl}/api/approvals/${approvalId}`, {
1336
+ headers: { 'Authorization': `Bearer ${creds.RELAY_API_KEY}` },
1337
+ signal: AbortSignal.timeout(10000)
1338
+ });
1339
+ const approval = await checkRes.json();
1340
+
1341
+ if (approval.status === 'allowed') {
1342
+ console.log(c.green(' ✅ PASS - Approval round-trip works\n'));
1343
+ passed++;
1344
+ } else {
1345
+ throw new Error(`Expected status=allowed, got ${approval.status}`);
1346
+ }
1347
+ } catch (e) {
1348
+ console.log(c.red(` ❌ FAIL - Approval round-trip: ${e.message}\n`));
1349
+ failed++;
1350
+ }
1351
+ } else {
1352
+ console.log(c.yellow(' ⏭️ SKIP - No credentials for round-trip test\n'));
1353
+ }
1354
+
1299
1355
  // Summary
1300
1356
  console.log(c.purple('Test Summary:'));
1301
1357
  console.log(` Passed: ${c.green(passed)}`);