@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 +21 -1
- package/lib/confirm.js +226 -0
- package/lib/connect.js +95 -3
- package/lib/daemon.js +19 -0
- package/package.json +1 -1
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
|
|
1057
|
-
|
|
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) {
|