@masslessai/push-todo 3.9.0 → 3.10.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.
package/lib/cli.js CHANGED
@@ -93,6 +93,14 @@ ${bold('CONNECT OPTIONS:')}
93
93
  --store-e2ee-key <key> Import E2EE encryption key
94
94
  --description <text> Project description (with connect)
95
95
 
96
+ ${bold('CONFIRM (for daemon skills):')}
97
+ push-todo confirm --type "social_post" --title "Post tweet" --content "..."
98
+ --type <type> Confirmation category (social_post, email, etc.)
99
+ --title <text> Short description of the action
100
+ --content <text> Content to be confirmed
101
+ --metadata <json> Optional JSON metadata for rich rendering
102
+ --task <number> Display number (auto-detected in daemon)
103
+
96
104
  ${bold('SETTINGS:')}
97
105
  push-todo setting Show all settings
98
106
  push-todo setting auto-commit Toggle auto-commit
@@ -135,7 +143,13 @@ const options = {
135
143
  'validate-key': { type: 'boolean' },
136
144
  'validate-project': { type: 'boolean' },
137
145
  'store-e2ee-key': { type: 'string' },
138
- 'description': { type: 'string' }
146
+ 'description': { type: 'string' },
147
+ // Confirm command options
148
+ 'type': { type: 'string' },
149
+ 'title': { type: 'string' },
150
+ 'content': { type: 'string' },
151
+ 'metadata': { type: 'string' },
152
+ 'task': { type: 'string' },
139
153
  };
140
154
 
141
155
  /**
@@ -465,6 +479,12 @@ export async function run(argv) {
465
479
  // Get the command (first positional)
466
480
  const command = positionals[0];
467
481
 
482
+ // Confirm command - request user confirmation for irreversible actions
483
+ if (command === 'confirm') {
484
+ const { requestConfirmation } = await import('./confirm.js');
485
+ return requestConfirmation(values, positionals);
486
+ }
487
+
468
488
  // Connect command
469
489
  if (command === 'connect') {
470
490
  return runConnect(values);
package/lib/confirm.js ADDED
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Remote confirmation command for Push CLI.
3
+ *
4
+ * Requests user confirmation before irreversible external actions.
5
+ * Claude calls `push-todo confirm --type X --title Y --content Z`
6
+ * which blocks until the user approves or rejects from the iOS app.
7
+ *
8
+ * See: docs/20260212_remote_confirmation_protocol_architecture.md
9
+ */
10
+
11
+ import { getApiKey } from './config.js';
12
+ import { getMachineName } from './machine-id.js';
13
+ import { bold, red, cyan, dim, green, yellow } from './utils/colors.js';
14
+
15
+ const API_BASE = 'https://jxuzqcbqhiaxmfitzxlo.supabase.co/functions/v1';
16
+
17
+ const POLL_INTERVAL_MS = 3000;
18
+
19
+ /**
20
+ * Make an authenticated API request to the Push backend.
21
+ */
22
+ async function apiRequest(endpoint, options = {}) {
23
+ const apiKey = getApiKey();
24
+ if (!apiKey) {
25
+ throw new Error('No API key configured. Run "push-todo connect" first.');
26
+ }
27
+
28
+ const url = `${API_BASE}/${endpoint}`;
29
+ return fetch(url, {
30
+ ...options,
31
+ headers: {
32
+ 'Authorization': `Bearer ${apiKey}`,
33
+ 'Content-Type': 'application/json',
34
+ ...options.headers,
35
+ },
36
+ });
37
+ }
38
+
39
+ /**
40
+ * Request confirmation and poll until user responds.
41
+ *
42
+ * @param {Object} values - Parsed CLI values
43
+ * @param {string[]} positionals - Positional arguments
44
+ */
45
+ export async function requestConfirmation(values, positionals) {
46
+ const type = values.type;
47
+ const title = values.title;
48
+ const content = values.content;
49
+ const metadataStr = values.metadata;
50
+ const taskArg = values.task;
51
+
52
+ // Validate required fields
53
+ if (!type || !title || !content) {
54
+ console.error(red('Error: --type, --title, and --content are required.'));
55
+ console.error('');
56
+ console.error('Usage:');
57
+ console.error(' push-todo confirm --type "social_post" --title "Reply to @user" --content "Your reply text..."');
58
+ console.error('');
59
+ console.error('Options:');
60
+ console.error(' --type Confirmation category (social_post, email, publish, etc.)');
61
+ console.error(' --title Short description of the action');
62
+ console.error(' --content The actual content to be confirmed');
63
+ console.error(' --metadata Optional JSON metadata for rich rendering');
64
+ console.error(' --task Display number (auto-detected from PUSH_DISPLAY_NUMBER)');
65
+ process.exit(1);
66
+ }
67
+
68
+ // Resolve display number: explicit --task > PUSH_DISPLAY_NUMBER env
69
+ let displayNumber = taskArg ? parseInt(taskArg, 10) : null;
70
+ let todoId = null;
71
+
72
+ if (!displayNumber) {
73
+ const envDisplayNumber = process.env.PUSH_DISPLAY_NUMBER;
74
+ if (envDisplayNumber) {
75
+ displayNumber = parseInt(envDisplayNumber, 10);
76
+ }
77
+ }
78
+
79
+ if (!displayNumber) {
80
+ todoId = process.env.PUSH_TASK_ID || null;
81
+ }
82
+
83
+ if (!displayNumber && !todoId) {
84
+ console.error(red('Error: Cannot determine task. Use --task <number> or set PUSH_DISPLAY_NUMBER env.'));
85
+ process.exit(1);
86
+ }
87
+
88
+ // Parse metadata if provided
89
+ let metadata = null;
90
+ if (metadataStr) {
91
+ try {
92
+ metadata = JSON.parse(metadataStr);
93
+ } catch {
94
+ console.error(red('Error: --metadata must be valid JSON.'));
95
+ process.exit(1);
96
+ }
97
+ }
98
+
99
+ const machineName = getMachineName() || null;
100
+
101
+ // Build request payload
102
+ const payload = {
103
+ type,
104
+ title,
105
+ body: content,
106
+ };
107
+ if (displayNumber) payload.displayNumber = displayNumber;
108
+ if (todoId) payload.todoId = todoId;
109
+ if (metadata) payload.metadata = metadata;
110
+ if (machineName) payload.machineName = machineName;
111
+
112
+ // Send confirmation request
113
+ console.error(`[push-confirm] Requesting confirmation for task #${displayNumber || '?'}...`);
114
+ console.error(`[push-confirm] Type: ${type}`);
115
+ console.error(`[push-confirm] Title: ${title}`);
116
+ console.error(`[push-confirm] Content: ${content.slice(0, 100)}${content.length > 100 ? '...' : ''}`);
117
+
118
+ let confirmationId;
119
+ try {
120
+ const response = await apiRequest('request-confirmation', {
121
+ method: 'POST',
122
+ body: JSON.stringify(payload),
123
+ });
124
+
125
+ if (!response.ok) {
126
+ const text = await response.text();
127
+ console.error(red(`[push-confirm] Failed to request confirmation: ${text}`));
128
+ // Output JSON for Claude to parse
129
+ console.log(JSON.stringify({ approved: false, error: `Request failed: ${text}` }));
130
+ process.exit(1);
131
+ }
132
+
133
+ const data = await response.json();
134
+ confirmationId = data.confirmationId;
135
+ console.error(`[push-confirm] Confirmation ID: ${confirmationId}`);
136
+ console.error(`[push-confirm] Waiting for user response on iPhone...`);
137
+ } catch (err) {
138
+ console.error(red(`[push-confirm] Error: ${err.message}`));
139
+ console.log(JSON.stringify({ approved: false, error: err.message }));
140
+ process.exit(1);
141
+ }
142
+
143
+ // Poll for response
144
+ const taskFilter = displayNumber
145
+ ? `display_number=${displayNumber}`
146
+ : `todo_id=${todoId}`;
147
+
148
+ while (true) {
149
+ try {
150
+ const response = await apiRequest(`synced-todos?${taskFilter}`);
151
+
152
+ if (!response.ok) {
153
+ console.error(dim(`[push-confirm] Poll error (${response.status}), retrying...`));
154
+ await sleep(POLL_INTERVAL_MS);
155
+ continue;
156
+ }
157
+
158
+ const data = await response.json();
159
+ const todos = data.todos || [];
160
+
161
+ if (todos.length === 0) {
162
+ console.error(dim(`[push-confirm] Task not found, retrying...`));
163
+ await sleep(POLL_INTERVAL_MS);
164
+ continue;
165
+ }
166
+
167
+ const todo = todos[0];
168
+
169
+ // Check if confirmation was responded to
170
+ if (todo.executionStatus !== 'awaiting_confirmation') {
171
+ // Confirmation was responded to — check events for the result
172
+ const approved = wasApproved(todo, confirmationId);
173
+
174
+ if (approved) {
175
+ console.error(green(`[push-confirm] Approved by user.`));
176
+ console.log(JSON.stringify({ approved: true, message: 'Confirmed by user' }));
177
+ process.exit(0);
178
+ } else {
179
+ console.error(yellow(`[push-confirm] Rejected by user.`));
180
+ console.log(JSON.stringify({ approved: false, message: 'Rejected by user' }));
181
+ process.exit(1);
182
+ }
183
+ }
184
+
185
+ // Still waiting
186
+ await sleep(POLL_INTERVAL_MS);
187
+ } catch (err) {
188
+ console.error(dim(`[push-confirm] Poll error: ${err.message}, retrying...`));
189
+ await sleep(POLL_INTERVAL_MS);
190
+ }
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Check execution events to determine if confirmation was approved.
196
+ */
197
+ function wasApproved(todo, confirmationId) {
198
+ try {
199
+ const events = todo.executionEventsJson
200
+ ? (typeof todo.executionEventsJson === 'string'
201
+ ? JSON.parse(todo.executionEventsJson)
202
+ : todo.executionEventsJson)
203
+ : [];
204
+
205
+ // Find the most recent confirmation response event
206
+ for (let i = events.length - 1; i >= 0; i--) {
207
+ const event = events[i];
208
+ if (event.type === 'confirmation_approved') {
209
+ return true;
210
+ }
211
+ if (event.type === 'confirmation_rejected') {
212
+ return false;
213
+ }
214
+ }
215
+ } catch {
216
+ // Parse error — fall through
217
+ }
218
+
219
+ // If no event found but status changed from awaiting_confirmation,
220
+ // check if pendingConfirmation was cleared (approved is more likely)
221
+ return !todo.pendingConfirmation;
222
+ }
223
+
224
+ function sleep(ms) {
225
+ return new Promise(resolve => setTimeout(resolve, ms));
226
+ }
package/lib/connect.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * Ported from: plugins/push-todo/scripts/connect.py (1866 lines)
13
13
  */
14
14
 
15
- import { execSync, spawnSync, spawn } from 'child_process';
15
+ import { execSync, execFileSync, spawnSync, spawn } from 'child_process';
16
16
  import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, statSync } from 'fs';
17
17
  import { setTimeout as sleep } from 'timers/promises';
18
18
  import { homedir } from 'os';
@@ -957,6 +957,94 @@ const CLIENT_TO_ACTION_TYPE = {
957
957
  'clawdbot': 'clawdbot',
958
958
  };
959
959
 
960
+ // ============================================================================
961
+ // AGENT CALLER DETECTION
962
+ // ============================================================================
963
+ // See: /docs/20260212_agent_caller_identity_detection_investigation_and_implementation_plan.md
964
+
965
+ // Known agent process names -> client_type mapping
966
+ const PROCESS_NAME_TO_CLIENT = {
967
+ 'claude': 'claude-code',
968
+ 'openclaw': 'openclaw',
969
+ 'openclaw-tui': 'openclaw',
970
+ 'codex': 'openai-codex',
971
+ };
972
+
973
+ /**
974
+ * Walk up the process tree and collect ancestor process names.
975
+ * Uses execFileSync (no shell) for safety. Works on macOS and Linux.
976
+ */
977
+ function getAncestorProcessNames(maxDepth = 8) {
978
+ const names = [];
979
+ let pid = process.ppid;
980
+
981
+ for (let i = 0; i < maxDepth && pid > 1; i++) {
982
+ try {
983
+ const info = execFileSync('ps', ['-o', 'ppid=,comm=', '-p', String(pid)], {
984
+ encoding: 'utf8',
985
+ timeout: 1000,
986
+ stdio: ['pipe', 'pipe', 'pipe'],
987
+ }).trim();
988
+
989
+ const match = info.match(/^\s*(\d+)\s+(.+)$/);
990
+ if (!match) break;
991
+
992
+ const comm = match[2].trim();
993
+ const name = comm.split('/').pop();
994
+ names.push(name);
995
+ pid = parseInt(match[1]);
996
+ } catch {
997
+ break;
998
+ }
999
+ }
1000
+
1001
+ return names;
1002
+ }
1003
+
1004
+ /**
1005
+ * Detect which AI agent is calling us.
1006
+ *
1007
+ * Detection layers (in order of reliability):
1008
+ * 1. Explicit --client flag (manual override, always wins)
1009
+ * 2. Environment variables (agent explicitly identifies itself)
1010
+ * 3. Process tree inspection (universal, no agent cooperation needed)
1011
+ * 4. Default to 'claude-code' (backward compatibility)
1012
+ *
1013
+ * @param {string|undefined} explicitClient - Value from --client flag
1014
+ * @returns {{ clientType: string, method: string }}
1015
+ */
1016
+ function detectCallerAgent(explicitClient) {
1017
+ // Layer 1: Explicit --client flag always wins
1018
+ if (explicitClient) {
1019
+ return { clientType: explicitClient, method: 'flag' };
1020
+ }
1021
+
1022
+ // Layer 2: Known environment variables
1023
+ if (process.env.CLAUDECODE === '1') {
1024
+ return { clientType: 'claude-code', method: 'env' };
1025
+ }
1026
+
1027
+ // Layer 3: Process tree inspection
1028
+ try {
1029
+ const ancestors = getAncestorProcessNames();
1030
+ for (const name of ancestors) {
1031
+ if (PROCESS_NAME_TO_CLIENT[name]) {
1032
+ return { clientType: PROCESS_NAME_TO_CLIENT[name], method: 'process-tree' };
1033
+ }
1034
+ for (const [processName, clientType] of Object.entries(PROCESS_NAME_TO_CLIENT)) {
1035
+ if (name.startsWith(processName)) {
1036
+ return { clientType, method: 'process-tree' };
1037
+ }
1038
+ }
1039
+ }
1040
+ } catch {
1041
+ // Process tree inspection failed — continue to default
1042
+ }
1043
+
1044
+ // Layer 4: Default (backward compatibility)
1045
+ return { clientType: 'claude-code', method: 'default' };
1046
+ }
1047
+
960
1048
  /**
961
1049
  * Register project in local registry for daemon routing.
962
1050
  *
@@ -1053,8 +1141,9 @@ export async function runConnect(options = {}) {
1053
1141
  // Self-healing: ensure daemon is running
1054
1142
  ensureDaemonRunning();
1055
1143
 
1056
- // Auto-detect client type from installation method
1057
- let clientType = options.client || 'claude-code';
1144
+ // Auto-detect calling agent (Claude Code, OpenClaw, Codex, etc.)
1145
+ const detection = detectCallerAgent(options.client);
1146
+ let clientType = detection.clientType;
1058
1147
  const clientName = CLIENT_NAMES[clientType] || 'Claude Code';
1059
1148
 
1060
1149
  // Handle --check-version (JSON output)
@@ -1112,6 +1201,9 @@ export async function runConnect(options = {}) {
1112
1201
  console.log('');
1113
1202
  console.log(` Push Voice Tasks Connect`);
1114
1203
  console.log(' ' + '='.repeat(40));
1204
+ if (detection.method !== 'default') {
1205
+ console.log(` Agent: ${clientName} (detected via ${detection.method})`);
1206
+ }
1115
1207
  console.log('');
1116
1208
 
1117
1209
  // Step 1: Check for updates
package/lib/daemon.js CHANGED
@@ -1152,6 +1152,10 @@ function monitorTaskStdout(displayNumber, proc) {
1152
1152
  }
1153
1153
 
1154
1154
  function checkTaskIdle(displayNumber) {
1155
+ // Skip idle check for tasks awaiting user confirmation
1156
+ const info = taskDetails.get(displayNumber) || {};
1157
+ if (info.phase === 'awaiting_confirmation') return false;
1158
+
1155
1159
  const lastOutput = taskLastOutput.get(displayNumber);
1156
1160
  if (!lastOutput) return false;
1157
1161
 
@@ -1393,6 +1397,14 @@ IMPORTANT:
1393
1397
  if (buffer.length > 20) buffer.shift();
1394
1398
  taskStdoutBuffer.set(displayNumber, buffer);
1395
1399
 
1400
+ // Detect confirmation waiting (exempt from timeouts)
1401
+ if (line.includes('[push-confirm] Waiting for')) {
1402
+ updateTaskDetail(displayNumber, {
1403
+ phase: 'awaiting_confirmation',
1404
+ detail: 'Waiting for user confirmation on iPhone'
1405
+ });
1406
+ }
1407
+
1396
1408
  const stuckReason = checkStuckPatterns(displayNumber, line);
1397
1409
  if (stuckReason) {
1398
1410
  log(`Task #${displayNumber} may be stuck: ${stuckReason}`);
@@ -1675,6 +1687,13 @@ async function checkTimeouts() {
1675
1687
  const timedOut = [];
1676
1688
 
1677
1689
  for (const [displayNumber, taskInfo] of runningTasks) {
1690
+ const info = taskDetails.get(displayNumber) || {};
1691
+
1692
+ // Skip timeout checks for tasks awaiting user confirmation on iPhone
1693
+ if (info.phase === 'awaiting_confirmation') {
1694
+ continue;
1695
+ }
1696
+
1678
1697
  const elapsed = now - taskInfo.startTime;
1679
1698
 
1680
1699
  if (elapsed > TASK_TIMEOUT_MS) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@masslessai/push-todo",
3
- "version": "3.9.0",
3
+ "version": "3.10.0",
4
4
  "description": "Voice tasks from Push iOS app for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {