@masslessai/push-todo 3.8.3 → 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/api.js CHANGED
@@ -52,6 +52,9 @@ export async function fetchTasks(gitRemote, options = {}) {
52
52
  if (gitRemote) {
53
53
  params.set('git_remote', gitRemote);
54
54
  }
55
+ if (options.actionType) {
56
+ params.set('action_type', options.actionType);
57
+ }
55
58
  if (options.backlogOnly) {
56
59
  params.set('later_only', 'true');
57
60
  }
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';
@@ -937,6 +937,8 @@ async function registerProjectWithBackend(apiKey, clientType = 'claude-code', ke
937
937
  if (data.success) {
938
938
  return {
939
939
  status: 'success',
940
+ action_id: data.action_id || null,
941
+ action_type: data.action_type || null,
940
942
  action_name: data.normalized_name || data.action_name || 'Unknown',
941
943
  created: data.created !== false,
942
944
  message: data.message || ''
@@ -946,17 +948,119 @@ async function registerProjectWithBackend(apiKey, clientType = 'claude-code', ke
946
948
  return { status: 'error', message: 'Unknown error' };
947
949
  }
948
950
 
951
+ // Mapping from CLI client_type to canonical DB action_type
952
+ // Must match CLIENT_TO_ACTION_TYPE in register-project edge function
953
+ const CLIENT_TO_ACTION_TYPE = {
954
+ 'claude-code': 'claude-code',
955
+ 'openai-codex': 'openai-codex',
956
+ 'openclaw': 'clawdbot',
957
+ 'clawdbot': 'clawdbot',
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',
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
+
949
1048
  /**
950
1049
  * Register project in local registry for daemon routing.
1050
+ *
1051
+ * @param {string} gitRemoteRaw - Raw git remote URL
1052
+ * @param {string} localPath - Absolute local path
1053
+ * @param {Object} [actionMeta] - Action metadata from register-project response
1054
+ * @returns {boolean}
951
1055
  */
952
- function registerProjectLocally(gitRemoteRaw, localPath) {
1056
+ function registerProjectLocally(gitRemoteRaw, localPath, actionMeta = {}) {
953
1057
  if (!gitRemoteRaw) return false;
954
1058
 
955
1059
  const gitRemote = normalizeGitRemote(gitRemoteRaw);
956
1060
  if (!gitRemote) return false;
957
1061
 
958
1062
  const registry = getRegistry();
959
- return registry.register(gitRemote, localPath);
1063
+ return registry.register(gitRemote, localPath, actionMeta);
960
1064
  }
961
1065
 
962
1066
  /**
@@ -1037,8 +1141,9 @@ export async function runConnect(options = {}) {
1037
1141
  // Self-healing: ensure daemon is running
1038
1142
  ensureDaemonRunning();
1039
1143
 
1040
- // Auto-detect client type from installation method
1041
- 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;
1042
1147
  const clientName = CLIENT_NAMES[clientType] || 'Claude Code';
1043
1148
 
1044
1149
  // Handle --check-version (JSON output)
@@ -1096,6 +1201,9 @@ export async function runConnect(options = {}) {
1096
1201
  console.log('');
1097
1202
  console.log(` Push Voice Tasks Connect`);
1098
1203
  console.log(' ' + '='.repeat(40));
1204
+ if (detection.method !== 'default') {
1205
+ console.log(` Agent: ${clientName} (detected via ${detection.method})`);
1206
+ }
1099
1207
  console.log('');
1100
1208
 
1101
1209
  // Step 1: Check for updates
@@ -1128,7 +1236,12 @@ export async function runConnect(options = {}) {
1128
1236
  // Register in local project registry for global daemon routing
1129
1237
  const gitRemoteRaw = getGitRemote();
1130
1238
  const localPath = process.cwd();
1131
- const isNewLocal = registerProjectLocally(gitRemoteRaw, localPath);
1239
+ const actionType = result.action_type || CLIENT_TO_ACTION_TYPE[clientType] || clientType;
1240
+ const isNewLocal = registerProjectLocally(gitRemoteRaw, localPath, {
1241
+ actionType,
1242
+ actionId: result.action_id,
1243
+ actionName: result.action_name,
1244
+ });
1132
1245
 
1133
1246
  console.log('');
1134
1247
  console.log(' ' + '='.repeat(40));
@@ -1204,7 +1317,11 @@ export async function runConnect(options = {}) {
1204
1317
  // Register in local project registry for global daemon routing
1205
1318
  const gitRemoteRaw = getGitRemote();
1206
1319
  const localPath = process.cwd();
1207
- const isNewLocal = registerProjectLocally(gitRemoteRaw, localPath);
1320
+ const actionType = CLIENT_TO_ACTION_TYPE[clientType] || clientType;
1321
+ const isNewLocal = registerProjectLocally(gitRemoteRaw, localPath, {
1322
+ actionType,
1323
+ actionName: authResult.action_name,
1324
+ });
1208
1325
 
1209
1326
  // Show success
1210
1327
  console.log('');
package/lib/daemon.js CHANGED
@@ -372,13 +372,19 @@ async function fetchQueuedTasks() {
372
372
  const projects = getListedProjects();
373
373
  const gitRemotes = Object.keys(projects);
374
374
 
375
+ // Get projects with action type for structured heartbeat (multi-agent support)
376
+ const projectsWithType = getListedProjectsWithActionType();
377
+
375
378
  // Add machine registry headers for daemon status tracking
376
379
  // See: /docs/20260204_daemon_heartbeat_status_indicator_implementation_plan.md (machine_registry table)
377
380
  const heartbeatHeaders = {};
378
381
  if (machineId && gitRemotes.length > 0) {
379
382
  heartbeatHeaders['X-Machine-Id'] = machineId;
380
383
  heartbeatHeaders['X-Machine-Name'] = machineName || 'Unknown Mac';
381
- heartbeatHeaders['X-Git-Remotes'] = gitRemotes.join(',');
384
+ // Structured format: "remote::type,remote::type" (backward compat: old parsers split on , and ignore ::)
385
+ heartbeatHeaders['X-Git-Remotes'] = projectsWithType
386
+ .map(p => `${p.gitRemote}::${p.actionType}`)
387
+ .join(',');
382
388
  heartbeatHeaders['X-Daemon-Version'] = getVersion();
383
389
  heartbeatHeaders['X-Capabilities'] = JSON.stringify(getCapabilities());
384
390
  }
@@ -503,19 +509,38 @@ async function claimTask(displayNumber) {
503
509
 
504
510
  // ==================== Project Registry ====================
505
511
 
506
- function getProjectPath(gitRemote) {
512
+ function getProjectPath(gitRemote, actionType) {
507
513
  if (!existsSync(REGISTRY_FILE)) {
508
514
  return null;
509
515
  }
510
516
 
511
517
  try {
512
518
  const data = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
513
- return data.projects?.[gitRemote]?.localPath || data.projects?.[gitRemote]?.local_path || null;
519
+ const projects = data.projects || {};
520
+
521
+ // Try exact composite key first (V2 format)
522
+ if (actionType) {
523
+ const key = `${gitRemote}::${actionType}`;
524
+ if (projects[key]) {
525
+ return projects[key].localPath || projects[key].local_path || null;
526
+ }
527
+ }
528
+
529
+ // Fall back to scanning for matching gitRemote (V2 or V1 format)
530
+ for (const [key, info] of Object.entries(projects)) {
531
+ if ((info.gitRemote || key) === gitRemote) {
532
+ return info.localPath || info.local_path || null;
533
+ }
534
+ }
535
+ return null;
514
536
  } catch {
515
537
  return null;
516
538
  }
517
539
  }
518
540
 
541
+ /**
542
+ * List all registered projects (backward-compatible: gitRemote -> localPath).
543
+ */
519
544
  function getListedProjects() {
520
545
  if (!existsSync(REGISTRY_FILE)) {
521
546
  return {};
@@ -524,8 +549,11 @@ function getListedProjects() {
524
549
  try {
525
550
  const data = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
526
551
  const result = {};
527
- for (const [remote, info] of Object.entries(data.projects || {})) {
528
- result[remote] = info.localPath || info.local_path;
552
+ for (const [key, info] of Object.entries(data.projects || {})) {
553
+ const remote = info.gitRemote || key;
554
+ if (!(remote in result)) {
555
+ result[remote] = info.localPath || info.local_path;
556
+ }
529
557
  }
530
558
  return result;
531
559
  } catch {
@@ -533,6 +561,30 @@ function getListedProjects() {
533
561
  }
534
562
  }
535
563
 
564
+ /**
565
+ * List all registered projects with action type info.
566
+ * Returns array of {gitRemote, actionType} for structured heartbeat.
567
+ */
568
+ function getListedProjectsWithActionType() {
569
+ if (!existsSync(REGISTRY_FILE)) {
570
+ return [];
571
+ }
572
+
573
+ try {
574
+ const data = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
575
+ const result = [];
576
+ for (const [key, info] of Object.entries(data.projects || {})) {
577
+ result.push({
578
+ gitRemote: info.gitRemote || key,
579
+ actionType: info.actionType || 'claude-code',
580
+ });
581
+ }
582
+ return result;
583
+ } catch {
584
+ return [];
585
+ }
586
+ }
587
+
536
588
  // ==================== Git Worktree Management ====================
537
589
 
538
590
  function getWorktreeSuffix() {
@@ -1100,6 +1152,10 @@ function monitorTaskStdout(displayNumber, proc) {
1100
1152
  }
1101
1153
 
1102
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
+
1103
1159
  const lastOutput = taskLastOutput.get(displayNumber);
1104
1160
  if (!lastOutput) return false;
1105
1161
 
@@ -1200,6 +1256,7 @@ async function executeTask(task) {
1200
1256
 
1201
1257
  const displayNumber = task.displayNumber || task.display_number;
1202
1258
  const gitRemote = task.gitRemote || task.git_remote;
1259
+ const taskActionType = task.actionType || task.action_type || null;
1203
1260
  const summary = task.summary || 'No summary';
1204
1261
  const content = task.normalizedContent || task.normalized_content ||
1205
1262
  task.content || task.summary || 'Work on this task';
@@ -1219,10 +1276,10 @@ async function executeTask(task) {
1219
1276
  return null;
1220
1277
  }
1221
1278
 
1222
- // Get project path
1279
+ // Get project path (use action_type for multi-agent routing)
1223
1280
  let projectPath = null;
1224
1281
  if (gitRemote) {
1225
- projectPath = getProjectPath(gitRemote);
1282
+ projectPath = getProjectPath(gitRemote, taskActionType);
1226
1283
  if (!projectPath) {
1227
1284
  log(`Task #${displayNumber}: Project not registered: ${gitRemote}`);
1228
1285
  log("Run '/push-todo connect' in the project directory to register");
@@ -1340,6 +1397,14 @@ IMPORTANT:
1340
1397
  if (buffer.length > 20) buffer.shift();
1341
1398
  taskStdoutBuffer.set(displayNumber, buffer);
1342
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
+
1343
1408
  const stuckReason = checkStuckPatterns(displayNumber, line);
1344
1409
  if (stuckReason) {
1345
1410
  log(`Task #${displayNumber} may be stuck: ${stuckReason}`);
@@ -1622,6 +1687,13 @@ async function checkTimeouts() {
1622
1687
  const timedOut = [];
1623
1688
 
1624
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
+
1625
1697
  const elapsed = now - taskInfo.startTime;
1626
1698
 
1627
1699
  if (elapsed > TASK_TIMEOUT_MS) {
@@ -12,7 +12,7 @@ import { homedir } from 'os';
12
12
  import { join } from 'path';
13
13
 
14
14
  const REGISTRY_FILE = join(homedir(), '.config', 'push', 'projects.json');
15
- const REGISTRY_VERSION = 1;
15
+ const REGISTRY_VERSION = 2;
16
16
 
17
17
  /**
18
18
  * Project Registry class.
@@ -62,36 +62,91 @@ class ProjectRegistry {
62
62
  }
63
63
 
64
64
  _migrate(data) {
65
- // Future: handle migrations as needed
65
+ const oldVersion = data.version || 0;
66
+
67
+ // V1 → V2: Add actionType to project keys
68
+ // V1 keys: "github.com/user/repo" → V2 keys: "github.com/user/repo::claude-code"
69
+ // All V1 projects were Claude Code (the only agent type that existed)
70
+ if (oldVersion < 2 && data.projects) {
71
+ const newProjects = {};
72
+ for (const [key, info] of Object.entries(data.projects)) {
73
+ // Skip if already in V2 format (contains ::)
74
+ if (key.includes('::')) {
75
+ newProjects[key] = { ...info, gitRemote: info.gitRemote || key.split('::')[0] };
76
+ continue;
77
+ }
78
+ const v2Key = `${key}::claude-code`;
79
+ newProjects[v2Key] = {
80
+ ...info,
81
+ gitRemote: key,
82
+ actionType: 'claude-code',
83
+ actionId: null,
84
+ actionName: null,
85
+ };
86
+ }
87
+ data.projects = newProjects;
88
+
89
+ // Migrate defaultProject key too
90
+ if (data.defaultProject && !data.defaultProject.includes('::')) {
91
+ data.defaultProject = `${data.defaultProject}::claude-code`;
92
+ }
93
+ }
94
+
66
95
  data.version = REGISTRY_VERSION;
96
+ // Persist the migrated data immediately
97
+ writeFileSync(REGISTRY_FILE, JSON.stringify(data, null, 2));
67
98
  return data;
68
99
  }
69
100
 
101
+ /**
102
+ * Build the composite key for a project entry.
103
+ * Format: "gitRemote::actionType" (e.g., "github.com/user/repo::claude-code")
104
+ *
105
+ * @param {string} gitRemote - Normalized git remote
106
+ * @param {string} actionType - Action type (e.g., "claude-code", "clawdbot")
107
+ * @returns {string}
108
+ */
109
+ _makeKey(gitRemote, actionType) {
110
+ return `${gitRemote}::${actionType || 'claude-code'}`;
111
+ }
112
+
70
113
  /**
71
114
  * Register a project.
72
115
  *
73
116
  * @param {string} gitRemote - Normalized git remote (e.g., "github.com/user/repo")
74
117
  * @param {string} localPath - Absolute local path
118
+ * @param {Object} [actionMeta] - Action metadata from register-project response
119
+ * @param {string} [actionMeta.actionType] - Action type (e.g., "claude-code", "clawdbot")
120
+ * @param {string} [actionMeta.actionId] - Action UUID
121
+ * @param {string} [actionMeta.actionName] - Action display name
75
122
  * @returns {boolean} True if newly registered, false if updated existing
76
123
  */
77
- register(gitRemote, localPath) {
78
- const isNew = !(gitRemote in this._data.projects);
124
+ register(gitRemote, localPath, actionMeta = {}) {
125
+ const actionType = actionMeta.actionType || 'claude-code';
126
+ const key = this._makeKey(gitRemote, actionType);
127
+ const isNew = !(key in this._data.projects);
79
128
  const now = new Date().toISOString();
80
129
 
81
130
  if (isNew) {
82
- this._data.projects[gitRemote] = {
131
+ this._data.projects[key] = {
132
+ gitRemote,
83
133
  localPath,
134
+ actionType,
135
+ actionId: actionMeta.actionId || null,
136
+ actionName: actionMeta.actionName || null,
84
137
  registeredAt: now,
85
138
  lastUsed: now
86
139
  };
87
140
  } else {
88
- this._data.projects[gitRemote].localPath = localPath;
89
- this._data.projects[gitRemote].lastUsed = now;
141
+ this._data.projects[key].localPath = localPath;
142
+ this._data.projects[key].lastUsed = now;
143
+ if (actionMeta.actionId) this._data.projects[key].actionId = actionMeta.actionId;
144
+ if (actionMeta.actionName) this._data.projects[key].actionName = actionMeta.actionName;
90
145
  }
91
146
 
92
147
  // Set as default if first project
93
148
  if (this._data.defaultProject === null) {
94
- this._data.defaultProject = gitRemote;
149
+ this._data.defaultProject = key;
95
150
  }
96
151
 
97
152
  this._save();
@@ -99,16 +154,20 @@ class ProjectRegistry {
99
154
  }
100
155
 
101
156
  /**
102
- * Get local path for a git remote.
157
+ * Get local path for a git remote (and optional action type).
103
158
  * Updates lastUsed timestamp.
104
159
  *
160
+ * Supports both:
161
+ * - getPath("github.com/user/repo", "claude-code") → exact match
162
+ * - getPath("github.com/user/repo") → first match for that repo (backward compat)
163
+ *
105
164
  * @param {string} gitRemote - Normalized git remote
165
+ * @param {string} [actionType] - Optional action type for exact match
106
166
  * @returns {string|null} Local path or null if not registered
107
167
  */
108
- getPath(gitRemote) {
109
- const project = this._data.projects[gitRemote];
168
+ getPath(gitRemote, actionType) {
169
+ const project = this._findProject(gitRemote, actionType);
110
170
  if (project) {
111
- // Update last_used
112
171
  project.lastUsed = new Date().toISOString();
113
172
  this._save();
114
173
  return project.localPath;
@@ -121,55 +180,126 @@ class ProjectRegistry {
121
180
  * Useful for status checks and listing operations.
122
181
  *
123
182
  * @param {string} gitRemote - Normalized git remote
183
+ * @param {string} [actionType] - Optional action type
124
184
  * @returns {string|null} Local path or null if not registered
125
185
  */
126
- getPathWithoutUpdate(gitRemote) {
127
- const project = this._data.projects[gitRemote];
186
+ getPathWithoutUpdate(gitRemote, actionType) {
187
+ const project = this._findProject(gitRemote, actionType);
128
188
  return project ? project.localPath : null;
129
189
  }
130
190
 
131
191
  /**
132
- * List all registered projects.
192
+ * Find a project entry by gitRemote and optional actionType.
193
+ * If actionType provided, tries exact composite key first.
194
+ * Falls back to scanning all entries for matching gitRemote.
195
+ *
196
+ * @param {string} gitRemote
197
+ * @param {string} [actionType]
198
+ * @returns {Object|null}
199
+ */
200
+ _findProject(gitRemote, actionType) {
201
+ // Try exact composite key first
202
+ if (actionType) {
203
+ const key = this._makeKey(gitRemote, actionType);
204
+ if (key in this._data.projects) {
205
+ return this._data.projects[key];
206
+ }
207
+ }
208
+
209
+ // Fall back to scanning for matching gitRemote (backward compat + no actionType case)
210
+ for (const [key, info] of Object.entries(this._data.projects)) {
211
+ // V2 format: check the stored gitRemote field
212
+ if (info.gitRemote === gitRemote) {
213
+ return info;
214
+ }
215
+ // V1 format fallback: key IS the gitRemote (pre-migration)
216
+ if (key === gitRemote) {
217
+ return info;
218
+ }
219
+ }
220
+ return null;
221
+ }
222
+
223
+ /**
224
+ * List all registered projects (backward-compatible format).
225
+ * Returns unique gitRemote -> localPath mapping (first entry per remote wins).
133
226
  *
134
227
  * @returns {Object} Dict of gitRemote -> localPath
135
228
  */
136
229
  listProjects() {
137
230
  const result = {};
138
- for (const [remote, info] of Object.entries(this._data.projects)) {
139
- result[remote] = info.localPath;
231
+ for (const [_key, info] of Object.entries(this._data.projects)) {
232
+ const remote = info.gitRemote || _key;
233
+ // First entry per gitRemote wins (backward compat)
234
+ if (!(remote in result)) {
235
+ result[remote] = info.localPath;
236
+ }
140
237
  }
141
238
  return result;
142
239
  }
143
240
 
144
241
  /**
145
242
  * List all registered projects with full metadata.
243
+ * V2 format: includes actionType, actionId, gitRemote per entry.
146
244
  *
147
- * @returns {Object} Dict of gitRemote -> {localPath, registeredAt, lastUsed}
245
+ * @returns {Object} Dict of compositeKey -> {gitRemote, localPath, actionType, ...}
148
246
  */
149
247
  listProjectsWithMetadata() {
150
248
  return { ...this._data.projects };
151
249
  }
152
250
 
251
+ /**
252
+ * List all registered projects with action type info.
253
+ * Returns array of {gitRemote, localPath, actionType} for daemon routing.
254
+ *
255
+ * @returns {Array<{gitRemote: string, localPath: string, actionType: string}>}
256
+ */
257
+ listProjectsWithActionType() {
258
+ const result = [];
259
+ for (const [_key, info] of Object.entries(this._data.projects)) {
260
+ result.push({
261
+ gitRemote: info.gitRemote || _key,
262
+ localPath: info.localPath,
263
+ actionType: info.actionType || 'claude-code',
264
+ });
265
+ }
266
+ return result;
267
+ }
268
+
153
269
  /**
154
270
  * Unregister a project.
271
+ * Accepts either composite key ("remote::type") or plain gitRemote (removes all entries for that remote).
155
272
  *
156
- * @param {string} gitRemote - Normalized git remote
273
+ * @param {string} keyOrRemote - Composite key or plain git remote
157
274
  * @returns {boolean} True if was registered, false if not found
158
275
  */
159
- unregister(gitRemote) {
160
- if (gitRemote in this._data.projects) {
161
- delete this._data.projects[gitRemote];
276
+ unregister(keyOrRemote) {
277
+ let found = false;
162
278
 
163
- if (this._data.defaultProject === gitRemote) {
164
- // Set new default
279
+ // Try as exact key first
280
+ if (keyOrRemote in this._data.projects) {
281
+ delete this._data.projects[keyOrRemote];
282
+ found = true;
283
+ } else {
284
+ // Remove all entries matching this gitRemote
285
+ for (const [key, info] of Object.entries(this._data.projects)) {
286
+ if ((info.gitRemote || key) === keyOrRemote) {
287
+ delete this._data.projects[key];
288
+ found = true;
289
+ }
290
+ }
291
+ }
292
+
293
+ if (found) {
294
+ // Fix default if needed
295
+ if (this._data.defaultProject && !(this._data.defaultProject in this._data.projects)) {
165
296
  const remaining = Object.keys(this._data.projects);
166
297
  this._data.defaultProject = remaining.length > 0 ? remaining[0] : null;
167
298
  }
168
-
169
299
  this._save();
170
- return true;
171
300
  }
172
- return false;
301
+
302
+ return found;
173
303
  }
174
304
 
175
305
  /**
@@ -183,16 +313,26 @@ class ProjectRegistry {
183
313
 
184
314
  /**
185
315
  * Set a project as the default.
316
+ * Accepts composite key or plain gitRemote (finds first match).
186
317
  *
187
- * @param {string} gitRemote
318
+ * @param {string} keyOrRemote
188
319
  * @returns {boolean} True if successful
189
320
  */
190
- setDefaultProject(gitRemote) {
191
- if (gitRemote in this._data.projects) {
192
- this._data.defaultProject = gitRemote;
321
+ setDefaultProject(keyOrRemote) {
322
+ // Exact key match
323
+ if (keyOrRemote in this._data.projects) {
324
+ this._data.defaultProject = keyOrRemote;
193
325
  this._save();
194
326
  return true;
195
327
  }
328
+ // Find by gitRemote
329
+ for (const [key, info] of Object.entries(this._data.projects)) {
330
+ if ((info.gitRemote || key) === keyOrRemote) {
331
+ this._data.defaultProject = key;
332
+ this._save();
333
+ return true;
334
+ }
335
+ }
196
336
  return false;
197
337
  }
198
338
 
@@ -207,12 +347,14 @@ class ProjectRegistry {
207
347
 
208
348
  /**
209
349
  * Check if a project is registered.
350
+ * Checks both composite keys and plain gitRemote.
210
351
  *
211
352
  * @param {string} gitRemote
353
+ * @param {string} [actionType] - Optional action type for exact match
212
354
  * @returns {boolean}
213
355
  */
214
- isRegistered(gitRemote) {
215
- return gitRemote in this._data.projects;
356
+ isRegistered(gitRemote, actionType) {
357
+ return this._findProject(gitRemote, actionType) !== null;
216
358
  }
217
359
 
218
360
  /**
@@ -223,19 +365,22 @@ class ProjectRegistry {
223
365
  validatePaths() {
224
366
  const invalid = [];
225
367
 
226
- for (const [gitRemote, info] of Object.entries(this._data.projects)) {
368
+ for (const [key, info] of Object.entries(this._data.projects)) {
227
369
  const path = info.localPath;
370
+ const gitRemote = info.gitRemote || key;
228
371
 
229
372
  try {
230
373
  const stats = statSync(path);
231
374
  if (!stats.isDirectory()) {
232
375
  invalid.push({
376
+ key,
233
377
  gitRemote,
234
378
  localPath: path,
235
379
  reason: 'not_a_directory'
236
380
  });
237
381
  } else if (!existsSync(join(path, '.git'))) {
238
382
  invalid.push({
383
+ key,
239
384
  gitRemote,
240
385
  localPath: path,
241
386
  reason: 'not_a_git_repo'
@@ -243,6 +388,7 @@ class ProjectRegistry {
243
388
  }
244
389
  } catch {
245
390
  invalid.push({
391
+ key,
246
392
  gitRemote,
247
393
  localPath: path,
248
394
  reason: 'path_not_found'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@masslessai/push-todo",
3
- "version": "3.8.3",
3
+ "version": "3.10.0",
4
4
  "description": "Voice tasks from Push iOS app for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {