@masslessai/push-todo 3.9.0 → 3.10.1

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';
@@ -953,10 +953,98 @@ async function registerProjectWithBackend(apiKey, clientType = 'claude-code', ke
953
953
  const CLIENT_TO_ACTION_TYPE = {
954
954
  'claude-code': 'claude-code',
955
955
  'openai-codex': 'openai-codex',
956
- 'openclaw': 'clawdbot',
957
- 'clawdbot': 'clawdbot',
956
+ 'openclaw': 'openclaw',
957
+ 'clawdbot': 'openclaw', // legacy alias
958
+ };
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',
958
971
  };
959
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/lib/fetch.js CHANGED
@@ -58,13 +58,22 @@ function decryptTaskFields(task) {
58
58
  * @returns {Promise<void>}
59
59
  */
60
60
  export async function listTasks(options = {}) {
61
- // Determine git remote
61
+ // Determine git remote and action type for scoping
62
62
  let gitRemote = null;
63
+ let actionType = null;
63
64
  if (!options.allProjects) {
64
65
  gitRemote = getGitRemote();
65
66
  if (!gitRemote && isGitRepo()) {
66
67
  console.error(yellow('Warning: In a git repo but no remote configured.'));
67
68
  }
69
+ // Look up action type from project registry for proper multi-agent scoping
70
+ if (gitRemote) {
71
+ const registry = getRegistry();
72
+ const project = registry.findProject(gitRemote);
73
+ if (project) {
74
+ actionType = project.actionType;
75
+ }
76
+ }
68
77
  }
69
78
 
70
79
  // Fetch tasks
@@ -72,7 +81,8 @@ export async function listTasks(options = {}) {
72
81
  backlogOnly: options.backlog,
73
82
  includeBacklog: options.includeBacklog,
74
83
  completedOnly: options.completed,
75
- includeCompleted: options.includeCompleted
84
+ includeCompleted: options.includeCompleted,
85
+ actionType,
76
86
  });
77
87
 
78
88
  // Decrypt if E2EE is available
@@ -103,7 +103,7 @@ class ProjectRegistry {
103
103
  * Format: "gitRemote::actionType" (e.g., "github.com/user/repo::claude-code")
104
104
  *
105
105
  * @param {string} gitRemote - Normalized git remote
106
- * @param {string} actionType - Action type (e.g., "claude-code", "clawdbot")
106
+ * @param {string} actionType - Action type (e.g., "claude-code", "openclaw")
107
107
  * @returns {string}
108
108
  */
109
109
  _makeKey(gitRemote, actionType) {
@@ -116,7 +116,7 @@ class ProjectRegistry {
116
116
  * @param {string} gitRemote - Normalized git remote (e.g., "github.com/user/repo")
117
117
  * @param {string} localPath - Absolute local path
118
118
  * @param {Object} [actionMeta] - Action metadata from register-project response
119
- * @param {string} [actionMeta.actionType] - Action type (e.g., "claude-code", "clawdbot")
119
+ * @param {string} [actionMeta.actionType] - Action type (e.g., "claude-code", "openclaw")
120
120
  * @param {string} [actionMeta.actionId] - Action UUID
121
121
  * @param {string} [actionMeta.actionName] - Action display name
122
122
  * @returns {boolean} True if newly registered, false if updated existing
@@ -345,6 +345,18 @@ class ProjectRegistry {
345
345
  return Object.keys(this._data.projects).length;
346
346
  }
347
347
 
348
+ /**
349
+ * Find a project entry by gitRemote and optional actionType.
350
+ * Public wrapper for _findProject.
351
+ *
352
+ * @param {string} gitRemote
353
+ * @param {string} [actionType]
354
+ * @returns {Object|null} Project entry with {gitRemote, localPath, actionType, ...}
355
+ */
356
+ findProject(gitRemote, actionType) {
357
+ return this._findProject(gitRemote, actionType);
358
+ }
359
+
348
360
  /**
349
361
  * Check if a project is registered.
350
362
  * Checks both composite keys and plain gitRemote.
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.1",
4
4
  "description": "Voice tasks from Push iOS app for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {