@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 +3 -0
- package/lib/cli.js +21 -1
- package/lib/confirm.js +226 -0
- package/lib/connect.js +124 -7
- package/lib/daemon.js +79 -7
- package/lib/project-registry.js +180 -34
- package/package.json +1 -1
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
|
|
1041
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 [
|
|
528
|
-
|
|
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) {
|
package/lib/project-registry.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
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[
|
|
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[
|
|
89
|
-
this._data.projects[
|
|
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 =
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
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 [
|
|
139
|
-
|
|
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
|
|
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}
|
|
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(
|
|
160
|
-
|
|
161
|
-
delete this._data.projects[gitRemote];
|
|
276
|
+
unregister(keyOrRemote) {
|
|
277
|
+
let found = false;
|
|
162
278
|
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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}
|
|
318
|
+
* @param {string} keyOrRemote
|
|
188
319
|
* @returns {boolean} True if successful
|
|
189
320
|
*/
|
|
190
|
-
setDefaultProject(
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
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 [
|
|
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'
|