@masslessai/push-todo 3.8.3 → 3.9.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/connect.js CHANGED
@@ -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,31 @@ 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
+
949
960
  /**
950
961
  * Register project in local registry for daemon routing.
962
+ *
963
+ * @param {string} gitRemoteRaw - Raw git remote URL
964
+ * @param {string} localPath - Absolute local path
965
+ * @param {Object} [actionMeta] - Action metadata from register-project response
966
+ * @returns {boolean}
951
967
  */
952
- function registerProjectLocally(gitRemoteRaw, localPath) {
968
+ function registerProjectLocally(gitRemoteRaw, localPath, actionMeta = {}) {
953
969
  if (!gitRemoteRaw) return false;
954
970
 
955
971
  const gitRemote = normalizeGitRemote(gitRemoteRaw);
956
972
  if (!gitRemote) return false;
957
973
 
958
974
  const registry = getRegistry();
959
- return registry.register(gitRemote, localPath);
975
+ return registry.register(gitRemote, localPath, actionMeta);
960
976
  }
961
977
 
962
978
  /**
@@ -1128,7 +1144,12 @@ export async function runConnect(options = {}) {
1128
1144
  // Register in local project registry for global daemon routing
1129
1145
  const gitRemoteRaw = getGitRemote();
1130
1146
  const localPath = process.cwd();
1131
- const isNewLocal = registerProjectLocally(gitRemoteRaw, localPath);
1147
+ const actionType = result.action_type || CLIENT_TO_ACTION_TYPE[clientType] || clientType;
1148
+ const isNewLocal = registerProjectLocally(gitRemoteRaw, localPath, {
1149
+ actionType,
1150
+ actionId: result.action_id,
1151
+ actionName: result.action_name,
1152
+ });
1132
1153
 
1133
1154
  console.log('');
1134
1155
  console.log(' ' + '='.repeat(40));
@@ -1204,7 +1225,11 @@ export async function runConnect(options = {}) {
1204
1225
  // Register in local project registry for global daemon routing
1205
1226
  const gitRemoteRaw = getGitRemote();
1206
1227
  const localPath = process.cwd();
1207
- const isNewLocal = registerProjectLocally(gitRemoteRaw, localPath);
1228
+ const actionType = CLIENT_TO_ACTION_TYPE[clientType] || clientType;
1229
+ const isNewLocal = registerProjectLocally(gitRemoteRaw, localPath, {
1230
+ actionType,
1231
+ actionName: authResult.action_name,
1232
+ });
1208
1233
 
1209
1234
  // Show success
1210
1235
  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() {
@@ -1200,6 +1252,7 @@ async function executeTask(task) {
1200
1252
 
1201
1253
  const displayNumber = task.displayNumber || task.display_number;
1202
1254
  const gitRemote = task.gitRemote || task.git_remote;
1255
+ const taskActionType = task.actionType || task.action_type || null;
1203
1256
  const summary = task.summary || 'No summary';
1204
1257
  const content = task.normalizedContent || task.normalized_content ||
1205
1258
  task.content || task.summary || 'Work on this task';
@@ -1219,10 +1272,10 @@ async function executeTask(task) {
1219
1272
  return null;
1220
1273
  }
1221
1274
 
1222
- // Get project path
1275
+ // Get project path (use action_type for multi-agent routing)
1223
1276
  let projectPath = null;
1224
1277
  if (gitRemote) {
1225
- projectPath = getProjectPath(gitRemote);
1278
+ projectPath = getProjectPath(gitRemote, taskActionType);
1226
1279
  if (!projectPath) {
1227
1280
  log(`Task #${displayNumber}: Project not registered: ${gitRemote}`);
1228
1281
  log("Run '/push-todo connect' in the project directory to register");
@@ -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.9.0",
4
4
  "description": "Voice tasks from Push iOS app for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {