teleportation-cli 1.0.1 → 1.1.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.
File without changes
@@ -101,15 +101,15 @@ const fetchJson = async (url, opts) => {
101
101
 
102
102
  const raw = await readStdin();
103
103
  let input;
104
- try {
105
- input = JSON.parse(raw || '{}');
104
+ try {
105
+ input = JSON.parse(raw || '{}');
106
106
  } catch (e) {
107
107
  log(`ERROR: Invalid JSON: ${e.message}`);
108
108
  return exit(0);
109
109
  }
110
110
 
111
- const { session_id, tool_name, tool_input, cwd } = input || {};
112
- log(`Session: ${session_id}, Tool: ${tool_name}, CWD: ${cwd}`);
111
+ const { session_id, tool_name, tool_input, cwd, tool_use_id, transcript_path } = input || {};
112
+ log(`Session: ${session_id}, Tool: ${tool_name}, CWD: ${cwd}, tool_use_id: ${tool_use_id || 'none'}`);
113
113
 
114
114
  // Validate session_id
115
115
  if (!isValidSessionId(session_id)) {
@@ -157,15 +157,10 @@ const fetchJson = async (url, opts) => {
157
157
  log(`Could not check away status: ${e.message} - using fail-safe: ${failSafe}`);
158
158
  }
159
159
 
160
- // If user is NOT away, don't create an approval - let Claude Code handle it locally
161
- // This prevents stale approvals from appearing in the mobile UI
162
- if (!isAway) {
163
- log('User is present - letting Claude Code show permission dialog (no remote approval created)');
164
- return exit(0);
165
- }
166
-
167
- // User is AWAY - create remote approval and poll for decision
168
- log(`Creating remote approval for ${tool_name}...`);
160
+ // ALWAYS create remote approval so it's visible in mobile UI
161
+ // If user is present, we'll also let Claude Code show its native prompt
162
+ // The PostToolUse hook will invalidate the approval if user approves locally
163
+ log(`Creating remote approval for ${tool_name} (away=${isAway})...`);
169
164
 
170
165
  // Extract session metadata (project name, hostname, branch, etc.)
171
166
  let meta = {};
@@ -207,7 +202,48 @@ const fetchJson = async (url, opts) => {
207
202
  log(`Warning: Failed to invalidate old approvals: ${e.message}`);
208
203
  }
209
204
 
210
- // Create approval request with metadata
205
+ // Build conversation context (PRD-0013 Phase 1)
206
+ let conversation_context = null;
207
+ if (transcript_path) {
208
+ try {
209
+ const transcriptData = await readFile(transcript_path, 'utf-8');
210
+ const transcript = JSON.parse(transcriptData);
211
+
212
+ // Find last user message
213
+ let lastUserMessage = null;
214
+ for (let i = transcript.length - 1; i >= 0; i--) {
215
+ if (transcript[i]?.role === 'user' && transcript[i]?.content) {
216
+ // Extract text from content array
217
+ const textContent = Array.isArray(transcript[i].content)
218
+ ? transcript[i].content.find(c => c?.type === 'text')?.text
219
+ : transcript[i].content;
220
+ if (textContent) {
221
+ lastUserMessage = textContent;
222
+ break;
223
+ }
224
+ }
225
+ }
226
+
227
+ if (lastUserMessage) {
228
+ conversation_context = {
229
+ user_last_message: lastUserMessage.slice(0, 500), // Truncate to 500 chars
230
+ claude_reasoning: null, // Phase 2: extract Claude's reasoning
231
+ timestamp: Date.now()
232
+ };
233
+ log(`Extracted conversation context: user_message="${lastUserMessage.slice(0, 50)}..."`);
234
+ }
235
+ } catch (e) {
236
+ log(`Warning: Failed to extract conversation context: ${e.message}`);
237
+ }
238
+ }
239
+
240
+ // Fallback: Generate tool_use_id if not provided (defensive programming)
241
+ const effective_tool_use_id = tool_use_id || `tool_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
242
+ if (!tool_use_id) {
243
+ log(`Warning: tool_use_id not provided, generated fallback: ${effective_tool_use_id}`);
244
+ }
245
+
246
+ // Create approval request with metadata and context (PRD-0013)
211
247
  let approvalId;
212
248
  try {
213
249
  const created = await fetchJson(`${RELAY_API_URL}/api/approvals`, {
@@ -216,17 +252,35 @@ const fetchJson = async (url, opts) => {
216
252
  'Content-Type': 'application/json',
217
253
  'Authorization': `Bearer ${RELAY_API_KEY}`
218
254
  },
219
- body: JSON.stringify({ session_id, tool_name, tool_input, meta })
255
+ body: JSON.stringify({
256
+ session_id,
257
+ tool_name,
258
+ tool_input,
259
+ meta,
260
+ tool_use_id: effective_tool_use_id,
261
+ conversation_context,
262
+ task_description: null, // Phase 2: infer multi-step tasks
263
+ transcript_excerpt: null // Phase 2: include recent messages
264
+ })
220
265
  });
221
266
  approvalId = created.id;
222
- log(`Approval created: ${approvalId}`);
267
+ log(`Approval created: ${approvalId} (tool_use_id: ${effective_tool_use_id})`);
223
268
  } catch (e) {
224
269
  log(`ERROR creating approval: ${e.message}`);
225
270
  return exit(0); // Let Claude Code handle it
226
271
  }
227
272
 
228
- // Poll for remote approval decision
229
- log('Polling for remote approval decision...');
273
+ // If user is PRESENT: return immediately and let Claude Code show its native prompt
274
+ // The approval is now visible in mobile UI. If user approves locally:
275
+ // - PostToolUse will invalidate the approval
276
+ // - Mobile UI will filter it out (any timeline event after approval = stale)
277
+ if (!isAway) {
278
+ log(`User is present - approval ${approvalId} created for mobile visibility, letting Claude Code handle locally`);
279
+ return exit(0);
280
+ }
281
+
282
+ // User is AWAY - poll for remote approval decision
283
+ log(`User is away - polling for remote approval decision (timeout=${FAST_APPROVAL_TIMEOUT_MS}ms)...`);
230
284
  let consecutiveFailures = 0;
231
285
  const MAX_CONSECUTIVE_FAILURES = 5;
232
286
 
@@ -241,7 +295,6 @@ const fetchJson = async (url, opts) => {
241
295
  if (status.status === 'allowed') {
242
296
  log('Remote approval: ALLOWED');
243
297
  // Acknowledge on the fast-path to prevent duplicate daemon execution.
244
- // CRITICAL: If ACK fails, we must NOT proceed with local execution to avoid duplicates.
245
298
  try {
246
299
  const ackRes = await fetch(`${RELAY_API_URL}/api/approvals/${approvalId}/ack`, {
247
300
  method: 'POST',
@@ -252,7 +305,6 @@ const fetchJson = async (url, opts) => {
252
305
  }
253
306
  } catch (e) {
254
307
  log(`ERROR: Failed to ack approval ${approvalId}: ${e.message} - aborting to prevent duplicate execution`);
255
- // Return early without allowing - let daemon handle it instead
256
308
  const out = {
257
309
  hookSpecificOutput: {
258
310
  hookEventName: 'PermissionRequest',
@@ -306,8 +358,7 @@ const fetchJson = async (url, opts) => {
306
358
  await sleep(POLLING_INTERVAL_MS);
307
359
  }
308
360
 
309
- // Fast-path timeout: do NOT fall back to local permission prompts while user is away.
310
- // Instead, hand off to daemon (background) and inform Claude.
361
+ // Timeout while user is away - hand off to daemon
311
362
  log('Fast-path approval timeout - handing off to daemon');
312
363
 
313
364
  try {
@@ -327,7 +378,7 @@ const fetchJson = async (url, opts) => {
327
378
  hookSpecificOutput: {
328
379
  hookEventName: 'PermissionRequest',
329
380
  permissionDecision: 'deny',
330
- permissionDecisionReason: '⏳ Teleportation: waiting for mobile approval timed out. This request was handed off to the daemon and will run in the background once approved. You will see a Daemon Work Update here when it completes.'
381
+ permissionDecisionReason: '⏳ Teleportation: waiting for mobile approval timed out. This request was handed off to the daemon and will run in the background once approved.'
331
382
  },
332
383
  suppressOutput: true
333
384
  };
@@ -81,7 +81,9 @@ const fetchJson = async (url, opts) => {
81
81
  }
82
82
 
83
83
  // Clear any pending approvals for this session since the tool executed successfully
84
- // This handles the case where Claude Code auto-approved the tool
84
+ // This handles the case where Claude Code auto-approved the tool locally
85
+ // We set handled_location: 'local' to explicitly mark these as locally-handled
86
+ // (not approved via mobile UI) - this prevents race conditions in the frontend
85
87
  try {
86
88
  await fetchJson(`${RELAY_API_URL}/api/approvals/invalidate`, {
87
89
  method: 'POST',
@@ -91,7 +93,8 @@ const fetchJson = async (url, opts) => {
91
93
  },
92
94
  body: JSON.stringify({
93
95
  session_id,
94
- reason: `Tool ${tool_name} executed (auto-approved by Claude Code)`
96
+ reason: `Tool ${tool_name} executed (auto-approved by Claude Code)`,
97
+ handled_location: 'local'
95
98
  })
96
99
  });
97
100
  log(`Cleared pending approvals after tool execution: ${tool_name}`);
@@ -144,6 +144,7 @@ const fetchJson = async (url, opts) => {
144
144
  // can approve/deny from their mobile device. This enables true remote control.
145
145
 
146
146
  // Helper: Format daemon work results into a human-readable message
147
+ // Enhanced for PRD-0013 Phase 1: Context Preservation
147
148
  const formatDaemonUpdate = (results) => {
148
149
  if (!results || results.length === 0) return '';
149
150
 
@@ -151,12 +152,53 @@ const fetchJson = async (url, opts) => {
151
152
  const hasBrowserTasks = results.some(r => {
152
153
  const toolName = (r.tool_name || '').toLowerCase();
153
154
  const command = (r.command || '').toLowerCase();
154
- return toolName.includes('browser') || toolName.includes('mcp') ||
155
+ return toolName.includes('browser') || toolName.includes('mcp') ||
155
156
  command.includes('browser') || command.includes('mcp');
156
157
  });
157
-
158
+
158
159
  const taskType = hasBrowserTasks ? 'browser/interactive task' : 'task';
159
- const header = `🤖 **Daemon Work Update** (${results.length} ${taskType}${results.length > 1 ? 's' : ''} completed while you were away)\n\n`;
160
+ let header = `🤖 **Daemon Work Update** (${results.length} ${taskType}${results.length > 1 ? 's' : ''} completed while you were away)\n\n`;
161
+
162
+ // PRD-0013 Phase 1: Show original context if available
163
+ // This helps Claude remember what the user originally requested
164
+ const firstResult = results[0];
165
+ if (firstResult?.original_context?.user_last_message) {
166
+ const context = firstResult.original_context;
167
+ const timeElapsed = context.timestamp
168
+ ? Date.now() - context.timestamp
169
+ : null;
170
+
171
+ // Format time elapsed
172
+ let timeAgo = '';
173
+ if (timeElapsed) {
174
+ const seconds = Math.floor(timeElapsed / 1000);
175
+ const minutes = Math.floor(seconds / 60);
176
+ const hours = Math.floor(minutes / 60);
177
+
178
+ if (hours > 0) {
179
+ timeAgo = `${hours} hour${hours > 1 ? 's' : ''} ago`;
180
+ } else if (minutes > 0) {
181
+ timeAgo = `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
182
+ } else {
183
+ timeAgo = `${seconds} second${seconds > 1 ? 's' : ''} ago`;
184
+ }
185
+ }
186
+
187
+ header += `**Original Request**${timeAgo ? ` (${timeAgo})` : ''}:\n`;
188
+ header += `> ${context.user_last_message}\n\n`;
189
+
190
+ // Show original tool_use_id if available (helps with debugging/tracing)
191
+ if (firstResult.original_tool_use_id) {
192
+ header += `_Results for tool ID: ${firstResult.original_tool_use_id}_\n\n`;
193
+ }
194
+
195
+ // PRD-0013 Phase 2: Show task description and next step suggestion if available
196
+ // For Phase 1, task_description will be null, so this won't appear yet
197
+ if (context.task_description) {
198
+ header += `**Next Step:**\n`;
199
+ header += `${context.task_description}\n\n`;
200
+ }
201
+ }
160
202
 
161
203
  const formatOutput = (output, toolName) => {
162
204
  if (!output || output.trim() === '') return '(No output)';