@masslessai/push-todo 4.1.3 → 4.1.5
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/agent-versions.js +204 -0
- package/lib/cli.js +7 -0
- package/lib/daemon.js +245 -3
- package/lib/fetch.js +21 -0
- package/lib/project-freshness.js +270 -0
- package/lib/update.js +150 -0
- package/package.json +1 -1
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent version detection and tracking for Push daemon.
|
|
3
|
+
*
|
|
4
|
+
* Detects installed versions of Claude Code, OpenAI Codex, and OpenClaw CLIs.
|
|
5
|
+
* Reports version parity with the push-todo CLI and flags outdated agents.
|
|
6
|
+
*
|
|
7
|
+
* Pattern: follows heartbeat.js — pure functions, internally throttled, non-fatal.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execFileSync } from 'child_process';
|
|
11
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
12
|
+
import { homedir } from 'os';
|
|
13
|
+
import { join } from 'path';
|
|
14
|
+
|
|
15
|
+
const PUSH_DIR = join(homedir(), '.push');
|
|
16
|
+
const VERSIONS_CACHE_FILE = join(PUSH_DIR, 'agent_versions.json');
|
|
17
|
+
const CHECK_INTERVAL = 3600000; // 1 hour
|
|
18
|
+
|
|
19
|
+
// ==================== Agent Definitions ====================
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Agent CLI definitions: command name, version flag, and how to parse output.
|
|
23
|
+
*
|
|
24
|
+
* Each agent has:
|
|
25
|
+
* - cmd: the CLI binary name
|
|
26
|
+
* - versionArgs: args to get version string
|
|
27
|
+
* - parseVersion: extracts semver from command output
|
|
28
|
+
*/
|
|
29
|
+
const AGENTS = {
|
|
30
|
+
'claude-code': {
|
|
31
|
+
cmd: 'claude',
|
|
32
|
+
versionArgs: ['--version'],
|
|
33
|
+
parseVersion(output) {
|
|
34
|
+
// "claude v2.1.41" or just "2.1.41"
|
|
35
|
+
const match = output.match(/(\d+\.\d+\.\d+)/);
|
|
36
|
+
return match ? match[1] : null;
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
'openai-codex': {
|
|
40
|
+
cmd: 'codex',
|
|
41
|
+
versionArgs: ['--version'],
|
|
42
|
+
parseVersion(output) {
|
|
43
|
+
const match = output.match(/(\d+\.\d+\.\d+)/);
|
|
44
|
+
return match ? match[1] : null;
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
'openclaw': {
|
|
48
|
+
cmd: 'openclaw',
|
|
49
|
+
versionArgs: ['--version'],
|
|
50
|
+
parseVersion(output) {
|
|
51
|
+
const match = output.match(/(\d+\.\d+\.\d+)/);
|
|
52
|
+
return match ? match[1] : null;
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// ==================== Version Detection ====================
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Detect the installed version of a single agent CLI.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} agentType - One of 'claude-code', 'openai-codex', 'openclaw'
|
|
63
|
+
* @returns {{ installed: boolean, version: string|null, error: string|null }}
|
|
64
|
+
*/
|
|
65
|
+
export function detectAgentVersion(agentType) {
|
|
66
|
+
const agent = AGENTS[agentType];
|
|
67
|
+
if (!agent) {
|
|
68
|
+
return { installed: false, version: null, error: `Unknown agent type: ${agentType}` };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const output = execFileSync(agent.cmd, agent.versionArgs, {
|
|
73
|
+
timeout: 10000,
|
|
74
|
+
encoding: 'utf8',
|
|
75
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
76
|
+
}).trim();
|
|
77
|
+
|
|
78
|
+
const version = agent.parseVersion(output);
|
|
79
|
+
if (version) {
|
|
80
|
+
return { installed: true, version, error: null };
|
|
81
|
+
}
|
|
82
|
+
return { installed: true, version: null, error: `Could not parse version from: ${output}` };
|
|
83
|
+
} catch (err) {
|
|
84
|
+
// ENOENT = not installed; other errors = installed but broken
|
|
85
|
+
if (err.code === 'ENOENT') {
|
|
86
|
+
return { installed: false, version: null, error: null };
|
|
87
|
+
}
|
|
88
|
+
return { installed: false, version: null, error: err.message };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Detect versions of all known agent CLIs.
|
|
94
|
+
*
|
|
95
|
+
* @returns {Object.<string, { installed: boolean, version: string|null, error: string|null }>}
|
|
96
|
+
*/
|
|
97
|
+
export function detectAllAgentVersions() {
|
|
98
|
+
const results = {};
|
|
99
|
+
for (const agentType of Object.keys(AGENTS)) {
|
|
100
|
+
results[agentType] = detectAgentVersion(agentType);
|
|
101
|
+
}
|
|
102
|
+
return results;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ==================== Cache ====================
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Load cached agent version data.
|
|
109
|
+
*
|
|
110
|
+
* @returns {{ versions: Object, checkedAt: string|null }|null}
|
|
111
|
+
*/
|
|
112
|
+
function loadCache() {
|
|
113
|
+
try {
|
|
114
|
+
if (existsSync(VERSIONS_CACHE_FILE)) {
|
|
115
|
+
return JSON.parse(readFileSync(VERSIONS_CACHE_FILE, 'utf8'));
|
|
116
|
+
}
|
|
117
|
+
} catch {}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Save agent version data to cache.
|
|
123
|
+
*
|
|
124
|
+
* @param {Object} versions - Agent version results
|
|
125
|
+
*/
|
|
126
|
+
function saveCache(versions) {
|
|
127
|
+
try {
|
|
128
|
+
mkdirSync(PUSH_DIR, { recursive: true });
|
|
129
|
+
writeFileSync(VERSIONS_CACHE_FILE, JSON.stringify({
|
|
130
|
+
versions,
|
|
131
|
+
checkedAt: new Date().toISOString(),
|
|
132
|
+
}, null, 2));
|
|
133
|
+
} catch {}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ==================== Throttled Check ====================
|
|
137
|
+
|
|
138
|
+
let lastCheckTime = 0;
|
|
139
|
+
let cachedResults = null;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Get agent versions (throttled to once per hour).
|
|
143
|
+
* Returns cached results if within the check interval.
|
|
144
|
+
*
|
|
145
|
+
* @param {{ force?: boolean }} options
|
|
146
|
+
* @returns {Object.<string, { installed: boolean, version: string|null, error: string|null }>}
|
|
147
|
+
*/
|
|
148
|
+
export function getAgentVersions({ force = false } = {}) {
|
|
149
|
+
const now = Date.now();
|
|
150
|
+
|
|
151
|
+
// Return in-memory cache if fresh
|
|
152
|
+
if (!force && cachedResults && (now - lastCheckTime < CHECK_INTERVAL)) {
|
|
153
|
+
return cachedResults;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Try disk cache if no in-memory cache
|
|
157
|
+
if (!force && !cachedResults) {
|
|
158
|
+
const diskCache = loadCache();
|
|
159
|
+
if (diskCache?.checkedAt) {
|
|
160
|
+
const cacheAge = now - new Date(diskCache.checkedAt).getTime();
|
|
161
|
+
if (cacheAge < CHECK_INTERVAL) {
|
|
162
|
+
cachedResults = diskCache.versions;
|
|
163
|
+
lastCheckTime = now - (CHECK_INTERVAL - cacheAge); // preserve remaining TTL
|
|
164
|
+
return cachedResults;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Fresh detection
|
|
170
|
+
cachedResults = detectAllAgentVersions();
|
|
171
|
+
lastCheckTime = now;
|
|
172
|
+
saveCache(cachedResults);
|
|
173
|
+
return cachedResults;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Get a human-readable summary of agent versions for logging.
|
|
178
|
+
*
|
|
179
|
+
* @param {Object} versions - From getAgentVersions()
|
|
180
|
+
* @returns {string}
|
|
181
|
+
*/
|
|
182
|
+
export function formatAgentVersionSummary(versions) {
|
|
183
|
+
const parts = [];
|
|
184
|
+
for (const [type, info] of Object.entries(versions)) {
|
|
185
|
+
const label = type.replace(/-/g, ' ');
|
|
186
|
+
if (info.installed && info.version) {
|
|
187
|
+
parts.push(`${label}=v${info.version}`);
|
|
188
|
+
} else if (info.installed) {
|
|
189
|
+
parts.push(`${label}=installed (unknown version)`);
|
|
190
|
+
} else {
|
|
191
|
+
parts.push(`${label}=not found`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return parts.join(', ');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Get the list of known agent types.
|
|
199
|
+
*
|
|
200
|
+
* @returns {string[]}
|
|
201
|
+
*/
|
|
202
|
+
export function getKnownAgentTypes() {
|
|
203
|
+
return Object.keys(AGENTS);
|
|
204
|
+
}
|
package/lib/cli.js
CHANGED
|
@@ -47,6 +47,7 @@ ${bold('USAGE:')}
|
|
|
47
47
|
push-todo connect Run connection doctor
|
|
48
48
|
push-todo search <query> Search tasks
|
|
49
49
|
push-todo review Review completed tasks
|
|
50
|
+
push-todo update Update CLI, check agents, refresh projects
|
|
50
51
|
|
|
51
52
|
${bold('OPTIONS:')}
|
|
52
53
|
--all-projects, -a List tasks from all projects
|
|
@@ -655,6 +656,12 @@ export async function run(argv) {
|
|
|
655
656
|
return runConnect(values);
|
|
656
657
|
}
|
|
657
658
|
|
|
659
|
+
// Update command - manual update of CLI, agents, and project freshness
|
|
660
|
+
if (command === 'update') {
|
|
661
|
+
const { runManualUpdate } = await import('./update.js');
|
|
662
|
+
return runManualUpdate(values);
|
|
663
|
+
}
|
|
664
|
+
|
|
658
665
|
// Review command
|
|
659
666
|
if (command === 'review') {
|
|
660
667
|
return fetch.runReview(values);
|
package/lib/daemon.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { randomUUID } from 'crypto';
|
|
17
17
|
import { spawn, execSync, execFileSync } from 'child_process';
|
|
18
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, unlinkSync, statSync, renameSync } from 'fs';
|
|
18
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, unlinkSync, statSync, renameSync, readdirSync } from 'fs';
|
|
19
19
|
import { homedir, hostname, platform } from 'os';
|
|
20
20
|
import { join, dirname } from 'path';
|
|
21
21
|
import { fileURLToPath } from 'url';
|
|
@@ -25,6 +25,8 @@ import { getProjectContext, buildSmartPrompt, invalidateCache } from './context-
|
|
|
25
25
|
import { sendMacNotification } from './utils/notify.js';
|
|
26
26
|
import { checkAndRunDueJobs } from './cron.js';
|
|
27
27
|
import { runHeartbeatChecks } from './heartbeat.js';
|
|
28
|
+
import { getAgentVersions, formatAgentVersionSummary } from './agent-versions.js';
|
|
29
|
+
import { checkAllProjectsFreshness } from './project-freshness.js';
|
|
28
30
|
|
|
29
31
|
const __filename = fileURLToPath(import.meta.url);
|
|
30
32
|
const __dirname = dirname(__filename);
|
|
@@ -46,6 +48,10 @@ const RETRY_BACKOFF_FACTOR = 2;
|
|
|
46
48
|
const CERTAINTY_HIGH_THRESHOLD = 0.7;
|
|
47
49
|
const CERTAINTY_LOW_THRESHOLD = 0.4;
|
|
48
50
|
|
|
51
|
+
// Idle auto-recovery (Level B)
|
|
52
|
+
const IDLE_TIMEOUT_MS = 900000; // 15 min no stdout → kill (smarter than absolute timeout)
|
|
53
|
+
const HEARTBEAT_INTERVAL_MS = 300000; // 5 min progress heartbeat to Supabase (Level A)
|
|
54
|
+
|
|
49
55
|
// Stuck detection
|
|
50
56
|
const STUCK_IDLE_THRESHOLD = 600000; // 10 min
|
|
51
57
|
const STUCK_WARNING_THRESHOLD = 300000; // 5 min
|
|
@@ -108,6 +114,7 @@ const taskLastOutput = new Map(); // displayNumber -> timestamp
|
|
|
108
114
|
const taskStdoutBuffer = new Map(); // displayNumber -> lines[]
|
|
109
115
|
const taskStderrBuffer = new Map(); // displayNumber -> lines[]
|
|
110
116
|
const taskProjectPaths = new Map(); // displayNumber -> projectPath
|
|
117
|
+
const taskLastHeartbeat = new Map(); // displayNumber -> timestamp of last progress heartbeat
|
|
111
118
|
let daemonStartTime = null;
|
|
112
119
|
|
|
113
120
|
// ==================== Utilities ====================
|
|
@@ -281,6 +288,59 @@ let cachedCapabilities = null;
|
|
|
281
288
|
let lastCapabilityCheck = 0;
|
|
282
289
|
const CAPABILITY_CHECK_INTERVAL = 3600000; // 1 hour
|
|
283
290
|
|
|
291
|
+
/**
|
|
292
|
+
* Discover skills for all registered projects.
|
|
293
|
+
* Scans ~/.claude/skills/ (global) and <projectPath>/.claude/skills/ (per-project).
|
|
294
|
+
* Returns: { "github.com/user/repo": ["skill1", "skill2"], ... }
|
|
295
|
+
*/
|
|
296
|
+
function discoverProjectSkills() {
|
|
297
|
+
const globalSkillsDir = join(homedir(), '.claude', 'skills');
|
|
298
|
+
const globalSkills = [];
|
|
299
|
+
|
|
300
|
+
// Enumerate global skills
|
|
301
|
+
if (existsSync(globalSkillsDir)) {
|
|
302
|
+
try {
|
|
303
|
+
for (const entry of readdirSync(globalSkillsDir, { withFileTypes: true })) {
|
|
304
|
+
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
|
305
|
+
globalSkills.push(entry.name);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
} catch { /* ignore */ }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// For each registered project, enumerate project-local skills and merge with global
|
|
312
|
+
const result = {};
|
|
313
|
+
if (!existsSync(REGISTRY_FILE)) return result;
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
const data = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
|
|
317
|
+
for (const [, info] of Object.entries(data.projects || {})) {
|
|
318
|
+
const remote = info.gitRemote;
|
|
319
|
+
const localPath = info.localPath || info.local_path;
|
|
320
|
+
if (!remote || !localPath) continue;
|
|
321
|
+
|
|
322
|
+
const projectSkillsDir = join(localPath, '.claude', 'skills');
|
|
323
|
+
const projectSkills = new Set(globalSkills);
|
|
324
|
+
|
|
325
|
+
if (existsSync(projectSkillsDir)) {
|
|
326
|
+
try {
|
|
327
|
+
for (const entry of readdirSync(projectSkillsDir, { withFileTypes: true })) {
|
|
328
|
+
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
|
329
|
+
projectSkills.add(entry.name);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
} catch { /* ignore */ }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (projectSkills.size > 0) {
|
|
336
|
+
result[remote] = [...projectSkills].sort();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
} catch { /* ignore */ }
|
|
340
|
+
|
|
341
|
+
return result;
|
|
342
|
+
}
|
|
343
|
+
|
|
284
344
|
function detectCapabilities() {
|
|
285
345
|
const caps = {
|
|
286
346
|
auto_merge: getAutoMergeEnabled(),
|
|
@@ -300,6 +360,8 @@ function detectCapabilities() {
|
|
|
300
360
|
caps.gh_cli = 'not_installed';
|
|
301
361
|
}
|
|
302
362
|
|
|
363
|
+
caps.project_skills = discoverProjectSkills();
|
|
364
|
+
|
|
303
365
|
return caps;
|
|
304
366
|
}
|
|
305
367
|
|
|
@@ -1338,6 +1400,151 @@ function checkTaskIdle(displayNumber) {
|
|
|
1338
1400
|
return false;
|
|
1339
1401
|
}
|
|
1340
1402
|
|
|
1403
|
+
// ==================== Progress Heartbeat (Level A) ====================
|
|
1404
|
+
|
|
1405
|
+
async function sendProgressHeartbeats() {
|
|
1406
|
+
const now = Date.now();
|
|
1407
|
+
|
|
1408
|
+
for (const [displayNumber, taskInfo] of runningTasks) {
|
|
1409
|
+
const info = taskDetails.get(displayNumber) || {};
|
|
1410
|
+
|
|
1411
|
+
// Skip tasks awaiting user confirmation (not truly hanging)
|
|
1412
|
+
if (info.phase === 'awaiting_confirmation') continue;
|
|
1413
|
+
|
|
1414
|
+
// Throttle: only send every HEARTBEAT_INTERVAL_MS
|
|
1415
|
+
const lastHeartbeat = taskLastHeartbeat.get(displayNumber) || taskInfo.startTime;
|
|
1416
|
+
if (now - lastHeartbeat < HEARTBEAT_INTERVAL_MS) continue;
|
|
1417
|
+
|
|
1418
|
+
// Compute metrics
|
|
1419
|
+
const elapsedSec = Math.floor((now - taskInfo.startTime) / 1000);
|
|
1420
|
+
const elapsedMin = Math.floor(elapsedSec / 60);
|
|
1421
|
+
const lastOutputTs = taskLastOutput.get(displayNumber);
|
|
1422
|
+
const idleSec = lastOutputTs ? Math.floor((now - lastOutputTs) / 1000) : elapsedSec;
|
|
1423
|
+
const idleMin = Math.floor(idleSec / 60);
|
|
1424
|
+
|
|
1425
|
+
const activityDesc = idleSec < 60 ? 'active' : `idle ${idleMin}m`;
|
|
1426
|
+
const phase = info.phase || 'executing';
|
|
1427
|
+
const eventSummary = `Running for ${elapsedMin}m. Last activity: ${activityDesc}. Phase: ${phase}.`;
|
|
1428
|
+
|
|
1429
|
+
log(`Task #${displayNumber}: sending progress heartbeat (${eventSummary})`);
|
|
1430
|
+
|
|
1431
|
+
// Update throttle timestamp BEFORE the async call to prevent concurrent sends
|
|
1432
|
+
taskLastHeartbeat.set(displayNumber, now);
|
|
1433
|
+
|
|
1434
|
+
// Send event-only update (no status field) — non-fatal if it fails
|
|
1435
|
+
const taskId = info.taskId || null;
|
|
1436
|
+
apiRequest('update-task-execution', {
|
|
1437
|
+
method: 'PATCH',
|
|
1438
|
+
body: JSON.stringify({
|
|
1439
|
+
todoId: taskId,
|
|
1440
|
+
displayNumber,
|
|
1441
|
+
event: {
|
|
1442
|
+
type: 'progress',
|
|
1443
|
+
timestamp: new Date().toISOString(),
|
|
1444
|
+
machineName: getMachineName() || undefined,
|
|
1445
|
+
summary: eventSummary
|
|
1446
|
+
}
|
|
1447
|
+
// No status field — event-only update, won't change execution_status
|
|
1448
|
+
})
|
|
1449
|
+
}).catch(err => {
|
|
1450
|
+
log(`Task #${displayNumber}: heartbeat failed (non-fatal): ${err.message}`);
|
|
1451
|
+
});
|
|
1452
|
+
// NOTE: intentionally not awaited — heartbeats are fire-and-forget
|
|
1453
|
+
// to avoid blocking the poll loop when Supabase is slow
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// ==================== Idle Auto-Recovery (Level B) ====================
|
|
1458
|
+
|
|
1459
|
+
async function killIdleTasks() {
|
|
1460
|
+
const now = Date.now();
|
|
1461
|
+
const idleTimedOut = [];
|
|
1462
|
+
|
|
1463
|
+
for (const [displayNumber, taskInfo] of runningTasks) {
|
|
1464
|
+
const info = taskDetails.get(displayNumber) || {};
|
|
1465
|
+
|
|
1466
|
+
// Exempt: tasks awaiting user confirmation
|
|
1467
|
+
if (info.phase === 'awaiting_confirmation') continue;
|
|
1468
|
+
|
|
1469
|
+
const lastOutput = taskLastOutput.get(displayNumber);
|
|
1470
|
+
if (!lastOutput) continue; // No output tracking yet — not idle, just starting
|
|
1471
|
+
|
|
1472
|
+
const idleMs = now - lastOutput;
|
|
1473
|
+
if (idleMs > IDLE_TIMEOUT_MS) {
|
|
1474
|
+
log(`Task #${displayNumber} IDLE TIMEOUT: ${Math.floor(idleMs / 1000)}s since last output`);
|
|
1475
|
+
idleTimedOut.push(displayNumber);
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
for (const displayNumber of idleTimedOut) {
|
|
1480
|
+
const taskInfo = runningTasks.get(displayNumber);
|
|
1481
|
+
if (!taskInfo) continue;
|
|
1482
|
+
|
|
1483
|
+
const info = taskDetails.get(displayNumber) || {};
|
|
1484
|
+
const projectPath = taskProjectPaths.get(displayNumber);
|
|
1485
|
+
const elapsedSec = Math.floor((now - taskInfo.startTime) / 1000);
|
|
1486
|
+
const idleSec = Math.floor((now - (taskLastOutput.get(displayNumber) || taskInfo.startTime)) / 1000);
|
|
1487
|
+
const durationStr = elapsedSec < 60 ? `${elapsedSec}s` : `${Math.floor(elapsedSec / 60)}m ${elapsedSec % 60}s`;
|
|
1488
|
+
const machineName = getMachineName() || 'Mac';
|
|
1489
|
+
|
|
1490
|
+
// Extract semantic summary WHILE session is still alive
|
|
1491
|
+
// (a live session produces a better "what have you done so far" answer)
|
|
1492
|
+
const sessionId = taskInfo.sessionId;
|
|
1493
|
+
const worktreePath = getWorktreePath(displayNumber, projectPath);
|
|
1494
|
+
const summaryPath = existsSync(worktreePath) ? worktreePath : (projectPath || process.cwd());
|
|
1495
|
+
log(`Task #${displayNumber}: extracting pre-kill summary...`);
|
|
1496
|
+
const idleSummary = extractSemanticSummary(summaryPath, sessionId);
|
|
1497
|
+
|
|
1498
|
+
// Now kill the process
|
|
1499
|
+
log(`Task #${displayNumber}: killing idle process (PID: ${taskInfo.process.pid})`);
|
|
1500
|
+
try {
|
|
1501
|
+
taskInfo.process.kill('SIGTERM');
|
|
1502
|
+
await new Promise(r => setTimeout(r, 5000));
|
|
1503
|
+
taskInfo.process.kill('SIGKILL');
|
|
1504
|
+
} catch {}
|
|
1505
|
+
|
|
1506
|
+
runningTasks.delete(displayNumber);
|
|
1507
|
+
cleanupWorktree(displayNumber, projectPath);
|
|
1508
|
+
|
|
1509
|
+
const idleError = idleSummary
|
|
1510
|
+
? `${idleSummary}\nSession went idle for ${Math.floor(idleSec / 60)}m with no output. Killed after ${durationStr} on ${machineName}.`
|
|
1511
|
+
: `Session went idle for ${Math.floor(idleSec / 60)}m with no output (limit: ${IDLE_TIMEOUT_MS / 60000}m). Killed after ${durationStr} on ${machineName}.`;
|
|
1512
|
+
|
|
1513
|
+
await updateTaskStatus(displayNumber, 'failed', {
|
|
1514
|
+
error: idleError,
|
|
1515
|
+
sessionId
|
|
1516
|
+
}, info.taskId);
|
|
1517
|
+
|
|
1518
|
+
if (NOTIFY_ON_FAILURE) {
|
|
1519
|
+
sendMacNotification(
|
|
1520
|
+
`Task #${displayNumber} idle timeout`,
|
|
1521
|
+
`${(info.summary || 'Unknown').slice(0, 40)}... idle ${Math.floor(idleSec / 60)}m`,
|
|
1522
|
+
'Basso'
|
|
1523
|
+
);
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
trackCompleted({
|
|
1527
|
+
displayNumber,
|
|
1528
|
+
summary: info.summary || 'Unknown task',
|
|
1529
|
+
completedAt: new Date().toISOString(),
|
|
1530
|
+
duration: elapsedSec,
|
|
1531
|
+
status: 'idle_timeout'
|
|
1532
|
+
});
|
|
1533
|
+
|
|
1534
|
+
// Full cleanup of all tracking Maps
|
|
1535
|
+
taskDetails.delete(displayNumber);
|
|
1536
|
+
taskLastOutput.delete(displayNumber);
|
|
1537
|
+
taskStdoutBuffer.delete(displayNumber);
|
|
1538
|
+
taskStderrBuffer.delete(displayNumber);
|
|
1539
|
+
taskProjectPaths.delete(displayNumber);
|
|
1540
|
+
taskLastHeartbeat.delete(displayNumber);
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
if (idleTimedOut.length > 0) {
|
|
1544
|
+
updateStatusFile();
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1341
1548
|
// ==================== Session ID Extraction ====================
|
|
1342
1549
|
|
|
1343
1550
|
function extractSessionIdFromStdout(proc, buffer) {
|
|
@@ -1614,7 +1821,6 @@ async function executeTask(task) {
|
|
|
1614
1821
|
? [
|
|
1615
1822
|
'--continue', previousSessionId,
|
|
1616
1823
|
'-p', prompt,
|
|
1617
|
-
'--worktree', worktreeName,
|
|
1618
1824
|
'--allowedTools', allowedTools,
|
|
1619
1825
|
'--output-format', 'json',
|
|
1620
1826
|
'--permission-mode', 'bypassPermissions',
|
|
@@ -1622,7 +1828,6 @@ async function executeTask(task) {
|
|
|
1622
1828
|
]
|
|
1623
1829
|
: [
|
|
1624
1830
|
'-p', prompt,
|
|
1625
|
-
'--worktree', worktreeName,
|
|
1626
1831
|
'--allowedTools', allowedTools,
|
|
1627
1832
|
'--output-format', 'json',
|
|
1628
1833
|
'--permission-mode', 'bypassPermissions',
|
|
@@ -1658,6 +1863,7 @@ async function executeTask(task) {
|
|
|
1658
1863
|
taskLastOutput.set(displayNumber, Date.now());
|
|
1659
1864
|
taskStdoutBuffer.set(displayNumber, []);
|
|
1660
1865
|
taskStderrBuffer.set(displayNumber, []);
|
|
1866
|
+
taskLastHeartbeat.set(displayNumber, Date.now());
|
|
1661
1867
|
|
|
1662
1868
|
// Monitor stderr (critical for diagnosing fast exits)
|
|
1663
1869
|
child.stderr.on('data', (data) => {
|
|
@@ -1713,6 +1919,7 @@ async function executeTask(task) {
|
|
|
1713
1919
|
runningTasks.delete(displayNumber);
|
|
1714
1920
|
await updateTaskStatus(displayNumber, 'failed', { error: error.message }, taskId);
|
|
1715
1921
|
taskDetails.delete(displayNumber);
|
|
1922
|
+
taskLastHeartbeat.delete(displayNumber);
|
|
1716
1923
|
updateStatusFile();
|
|
1717
1924
|
});
|
|
1718
1925
|
|
|
@@ -1936,6 +2143,7 @@ async function handleTaskCompletion(displayNumber, exitCode) {
|
|
|
1936
2143
|
taskStdoutBuffer.delete(displayNumber);
|
|
1937
2144
|
taskStderrBuffer.delete(displayNumber);
|
|
1938
2145
|
taskProjectPaths.delete(displayNumber);
|
|
2146
|
+
taskLastHeartbeat.delete(displayNumber);
|
|
1939
2147
|
updateStatusFile();
|
|
1940
2148
|
}
|
|
1941
2149
|
|
|
@@ -2014,6 +2222,13 @@ function updateStatusFile() {
|
|
|
2014
2222
|
// ==================== Task Checking ====================
|
|
2015
2223
|
|
|
2016
2224
|
async function checkTimeouts() {
|
|
2225
|
+
// Level A: Send progress heartbeats for long-running tasks
|
|
2226
|
+
await sendProgressHeartbeats();
|
|
2227
|
+
|
|
2228
|
+
// Level B: Kill tasks that have been idle too long (fires at 15 min, before 60 min absolute)
|
|
2229
|
+
await killIdleTasks();
|
|
2230
|
+
|
|
2231
|
+
// Absolute timeout (safety net — 60 min wall clock)
|
|
2017
2232
|
const now = Date.now();
|
|
2018
2233
|
const timedOut = [];
|
|
2019
2234
|
|
|
@@ -2080,6 +2295,7 @@ async function checkTimeouts() {
|
|
|
2080
2295
|
taskStdoutBuffer.delete(displayNumber);
|
|
2081
2296
|
taskStderrBuffer.delete(displayNumber);
|
|
2082
2297
|
taskProjectPaths.delete(displayNumber);
|
|
2298
|
+
taskLastHeartbeat.delete(displayNumber);
|
|
2083
2299
|
cleanupWorktree(displayNumber, projectPath);
|
|
2084
2300
|
}
|
|
2085
2301
|
|
|
@@ -2243,6 +2459,8 @@ async function mainLoop() {
|
|
|
2243
2459
|
log(`Auto-update: ${getAutoUpdateEnabled() ? 'Enabled' : 'Disabled'}`);
|
|
2244
2460
|
const caps = getCapabilities();
|
|
2245
2461
|
log(`Capabilities: gh=${caps.gh_cli}, auto-merge=${caps.auto_merge}, auto-complete=${caps.auto_complete}`);
|
|
2462
|
+
const agentVersions = getAgentVersions({ force: true });
|
|
2463
|
+
log(`Agent versions: ${formatAgentVersionSummary(agentVersions)}`);
|
|
2246
2464
|
log(`Log file: ${LOG_FILE}`);
|
|
2247
2465
|
|
|
2248
2466
|
// Show registered projects
|
|
@@ -2313,6 +2531,30 @@ async function mainLoop() {
|
|
|
2313
2531
|
logError(`Heartbeat error: ${error.message}`);
|
|
2314
2532
|
}
|
|
2315
2533
|
|
|
2534
|
+
// Project freshness check (internally throttled to once per hour)
|
|
2535
|
+
try {
|
|
2536
|
+
const projects = getListedProjects();
|
|
2537
|
+
const busyPaths = new Set();
|
|
2538
|
+
for (const [, info] of runningTasks) {
|
|
2539
|
+
if (info.projectPath) busyPaths.add(info.projectPath);
|
|
2540
|
+
}
|
|
2541
|
+
checkAllProjectsFreshness({
|
|
2542
|
+
projects,
|
|
2543
|
+
autoRebase: getAutoUpdateEnabled(),
|
|
2544
|
+
busyPaths,
|
|
2545
|
+
log,
|
|
2546
|
+
});
|
|
2547
|
+
} catch (error) {
|
|
2548
|
+
logError(`Freshness check error: ${error.message}`);
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
// Agent version check (internally throttled to once per hour)
|
|
2552
|
+
try {
|
|
2553
|
+
getAgentVersions();
|
|
2554
|
+
} catch (error) {
|
|
2555
|
+
logError(`Agent version check error: ${error.message}`);
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2316
2558
|
// Self-update check (throttled to once per hour, only applies when idle)
|
|
2317
2559
|
if (getAutoUpdateEnabled()) {
|
|
2318
2560
|
checkAndApplyUpdate();
|
package/lib/fetch.js
CHANGED
|
@@ -12,6 +12,7 @@ import { formatTaskForDisplay, formatSearchResult } from './utils/format.js';
|
|
|
12
12
|
import { bold, green, yellow, red, cyan, dim, muted } from './utils/colors.js';
|
|
13
13
|
import { decryptTodoField, isE2EEAvailable } from './encryption.js';
|
|
14
14
|
import { getAutoCommitEnabled, getAutoMergeEnabled, getAutoCompleteEnabled, getAutoUpdateEnabled, getMaxBatchSize } from './config.js';
|
|
15
|
+
import { getAgentVersions } from './agent-versions.js';
|
|
15
16
|
import { writeFileSync, mkdirSync } from 'fs';
|
|
16
17
|
import { homedir } from 'os';
|
|
17
18
|
import { join } from 'path';
|
|
@@ -445,6 +446,26 @@ export async function showStatus(options = {}) {
|
|
|
445
446
|
console.log(`${bold('Auto-update:')} ${autoUpdate ? 'Enabled' : 'Disabled'}`);
|
|
446
447
|
console.log(`${bold('Max batch size:')} ${maxBatch}`);
|
|
447
448
|
console.log(`${bold('Registered projects:')} ${status.registeredProjects}`);
|
|
449
|
+
console.log('');
|
|
450
|
+
|
|
451
|
+
// Agent versions
|
|
452
|
+
const agentVersions = getAgentVersions();
|
|
453
|
+
console.log(bold('Agent Versions:'));
|
|
454
|
+
const agentLabels = {
|
|
455
|
+
'claude-code': 'Claude Code',
|
|
456
|
+
'openai-codex': 'Codex',
|
|
457
|
+
'openclaw': 'OpenClaw',
|
|
458
|
+
};
|
|
459
|
+
for (const [type, info] of Object.entries(agentVersions)) {
|
|
460
|
+
const label = agentLabels[type] || type;
|
|
461
|
+
if (info.installed && info.version) {
|
|
462
|
+
console.log(` ${bold(label + ':')} ${green('v' + info.version)}`);
|
|
463
|
+
} else if (info.installed) {
|
|
464
|
+
console.log(` ${bold(label + ':')} ${yellow('installed (unknown version)')}`);
|
|
465
|
+
} else {
|
|
466
|
+
console.log(` ${bold(label + ':')} ${dim('not installed')}`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
448
469
|
}
|
|
449
470
|
|
|
450
471
|
/**
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project freshness checker for Push daemon.
|
|
3
|
+
*
|
|
4
|
+
* Checks if registered projects are behind their remote and optionally
|
|
5
|
+
* pulls updates via rebase. Only updates when the working tree is clean
|
|
6
|
+
* and no daemon tasks are running for that project.
|
|
7
|
+
*
|
|
8
|
+
* Pattern: follows heartbeat.js — pure functions, internally throttled, non-fatal.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execFileSync } from 'child_process';
|
|
12
|
+
import { existsSync } from 'fs';
|
|
13
|
+
import { join } from 'path';
|
|
14
|
+
|
|
15
|
+
// ==================== Configuration ====================
|
|
16
|
+
|
|
17
|
+
const FRESHNESS_CHECK_INTERVAL = 3600000; // 1 hour
|
|
18
|
+
|
|
19
|
+
// ==================== Internal State ====================
|
|
20
|
+
|
|
21
|
+
let lastFreshnessCheck = 0;
|
|
22
|
+
|
|
23
|
+
// ==================== Git Helpers ====================
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get the default branch for a repository (main or master).
|
|
27
|
+
*
|
|
28
|
+
* @param {string} projectPath
|
|
29
|
+
* @returns {string|null}
|
|
30
|
+
*/
|
|
31
|
+
function getDefaultBranch(projectPath) {
|
|
32
|
+
try {
|
|
33
|
+
// Try symbolic-ref to origin/HEAD first
|
|
34
|
+
const ref = execFileSync('git', ['symbolic-ref', 'refs/remotes/origin/HEAD'], {
|
|
35
|
+
cwd: projectPath,
|
|
36
|
+
timeout: 5000,
|
|
37
|
+
encoding: 'utf8',
|
|
38
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
39
|
+
}).trim();
|
|
40
|
+
// "refs/remotes/origin/main" → "main"
|
|
41
|
+
return ref.split('/').pop();
|
|
42
|
+
} catch {
|
|
43
|
+
// Fallback: check if main or master exists
|
|
44
|
+
for (const branch of ['main', 'master']) {
|
|
45
|
+
try {
|
|
46
|
+
execFileSync('git', ['rev-parse', '--verify', `refs/remotes/origin/${branch}`], {
|
|
47
|
+
cwd: projectPath,
|
|
48
|
+
timeout: 5000,
|
|
49
|
+
encoding: 'utf8',
|
|
50
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
51
|
+
});
|
|
52
|
+
return branch;
|
|
53
|
+
} catch {}
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get the currently checked-out branch.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} projectPath
|
|
63
|
+
* @returns {string|null}
|
|
64
|
+
*/
|
|
65
|
+
function getCurrentBranch(projectPath) {
|
|
66
|
+
try {
|
|
67
|
+
return execFileSync('git', ['branch', '--show-current'], {
|
|
68
|
+
cwd: projectPath,
|
|
69
|
+
timeout: 5000,
|
|
70
|
+
encoding: 'utf8',
|
|
71
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
72
|
+
}).trim() || null;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Check if the working tree is clean (no uncommitted changes).
|
|
80
|
+
*
|
|
81
|
+
* @param {string} projectPath
|
|
82
|
+
* @returns {boolean}
|
|
83
|
+
*/
|
|
84
|
+
function isWorkingTreeClean(projectPath) {
|
|
85
|
+
try {
|
|
86
|
+
const status = execFileSync('git', ['status', '--porcelain'], {
|
|
87
|
+
cwd: projectPath,
|
|
88
|
+
timeout: 10000,
|
|
89
|
+
encoding: 'utf8',
|
|
90
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
91
|
+
}).trim();
|
|
92
|
+
return status === '';
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Fetch from remote (quiet, non-interactive).
|
|
100
|
+
*
|
|
101
|
+
* @param {string} projectPath
|
|
102
|
+
* @returns {boolean} true if fetch succeeded
|
|
103
|
+
*/
|
|
104
|
+
function gitFetch(projectPath) {
|
|
105
|
+
try {
|
|
106
|
+
execFileSync('git', ['fetch', '--quiet'], {
|
|
107
|
+
cwd: projectPath,
|
|
108
|
+
timeout: 30000,
|
|
109
|
+
encoding: 'utf8',
|
|
110
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
111
|
+
});
|
|
112
|
+
return true;
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Count how many commits the local branch is behind the remote.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} projectPath
|
|
122
|
+
* @param {string} branch
|
|
123
|
+
* @returns {number} commits behind, or -1 on error
|
|
124
|
+
*/
|
|
125
|
+
function commitsBehind(projectPath, branch) {
|
|
126
|
+
try {
|
|
127
|
+
const output = execFileSync('git', [
|
|
128
|
+
'rev-list', '--count', `${branch}..origin/${branch}`
|
|
129
|
+
], {
|
|
130
|
+
cwd: projectPath,
|
|
131
|
+
timeout: 5000,
|
|
132
|
+
encoding: 'utf8',
|
|
133
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
134
|
+
}).trim();
|
|
135
|
+
return parseInt(output, 10) || 0;
|
|
136
|
+
} catch {
|
|
137
|
+
return -1;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ==================== Freshness Check ====================
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Check and optionally update a single project.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} projectPath - Absolute path to the project
|
|
147
|
+
* @param {Object} options
|
|
148
|
+
* @param {boolean} options.autoRebase - If true, pull --rebase when behind
|
|
149
|
+
* @param {Set<string>} options.busyPaths - Paths with running tasks (skip these)
|
|
150
|
+
* @param {Function} options.log - Logging function
|
|
151
|
+
* @returns {{ status: string, behind?: number, updated?: boolean, error?: string }}
|
|
152
|
+
*/
|
|
153
|
+
export function checkProjectFreshness(projectPath, { autoRebase = false, busyPaths = new Set(), log = () => {} } = {}) {
|
|
154
|
+
if (!existsSync(join(projectPath, '.git'))) {
|
|
155
|
+
return { status: 'not_a_repo' };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const defaultBranch = getDefaultBranch(projectPath);
|
|
159
|
+
if (!defaultBranch) {
|
|
160
|
+
return { status: 'no_default_branch' };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const currentBranch = getCurrentBranch(projectPath);
|
|
164
|
+
|
|
165
|
+
// Fetch latest from remote
|
|
166
|
+
if (!gitFetch(projectPath)) {
|
|
167
|
+
return { status: 'fetch_failed' };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Check how far behind the default branch is
|
|
171
|
+
const behind = commitsBehind(projectPath, defaultBranch);
|
|
172
|
+
if (behind < 0) {
|
|
173
|
+
return { status: 'compare_failed' };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (behind === 0) {
|
|
177
|
+
return { status: 'up_to_date', behind: 0 };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Project is behind — decide whether to auto-update
|
|
181
|
+
if (!autoRebase) {
|
|
182
|
+
return { status: 'behind', behind };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Skip if tasks are running in this project
|
|
186
|
+
if (busyPaths.has(projectPath)) {
|
|
187
|
+
log(`Freshness: ${projectPath} is ${behind} commit(s) behind but has running tasks — skipping rebase`);
|
|
188
|
+
return { status: 'behind_busy', behind };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Skip if not on default branch (user is on a feature branch)
|
|
192
|
+
if (currentBranch !== defaultBranch) {
|
|
193
|
+
log(`Freshness: ${projectPath} is ${behind} commit(s) behind but on branch '${currentBranch}' — skipping rebase`);
|
|
194
|
+
return { status: 'behind_wrong_branch', behind, currentBranch };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Skip if working tree is dirty
|
|
198
|
+
if (!isWorkingTreeClean(projectPath)) {
|
|
199
|
+
log(`Freshness: ${projectPath} is ${behind} commit(s) behind but working tree is dirty — skipping rebase`);
|
|
200
|
+
return { status: 'behind_dirty', behind };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Safe to rebase
|
|
204
|
+
try {
|
|
205
|
+
execFileSync('git', ['pull', '--rebase', '--quiet'], {
|
|
206
|
+
cwd: projectPath,
|
|
207
|
+
timeout: 60000,
|
|
208
|
+
encoding: 'utf8',
|
|
209
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
210
|
+
});
|
|
211
|
+
log(`Freshness: ${projectPath} updated (${behind} commit(s) rebased)`);
|
|
212
|
+
return { status: 'updated', behind, updated: true };
|
|
213
|
+
} catch (err) {
|
|
214
|
+
// Abort failed rebase to leave tree clean
|
|
215
|
+
try {
|
|
216
|
+
execFileSync('git', ['rebase', '--abort'], {
|
|
217
|
+
cwd: projectPath,
|
|
218
|
+
timeout: 5000,
|
|
219
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
220
|
+
});
|
|
221
|
+
} catch {}
|
|
222
|
+
log(`Freshness: ${projectPath} rebase failed — aborted. Error: ${err.message}`);
|
|
223
|
+
return { status: 'rebase_failed', behind, error: err.message };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Check freshness of all registered projects (internally throttled).
|
|
229
|
+
*
|
|
230
|
+
* @param {Object} config
|
|
231
|
+
* @param {Object.<string, string>} config.projects - gitRemote → localPath mapping
|
|
232
|
+
* @param {boolean} config.autoRebase - Whether to auto-pull when behind
|
|
233
|
+
* @param {Set<string>} config.busyPaths - Paths with running tasks
|
|
234
|
+
* @param {Function} config.log - Logging function
|
|
235
|
+
* @param {boolean} [config.force] - Skip throttle
|
|
236
|
+
* @returns {Object.<string, Object>|null} Results per project path, or null if throttled
|
|
237
|
+
*/
|
|
238
|
+
export function checkAllProjectsFreshness(config) {
|
|
239
|
+
const { projects, autoRebase = false, busyPaths = new Set(), log = () => {}, force = false } = config;
|
|
240
|
+
const now = Date.now();
|
|
241
|
+
|
|
242
|
+
if (!force && (now - lastFreshnessCheck < FRESHNESS_CHECK_INTERVAL)) {
|
|
243
|
+
return null; // throttled
|
|
244
|
+
}
|
|
245
|
+
lastFreshnessCheck = now;
|
|
246
|
+
|
|
247
|
+
const results = {};
|
|
248
|
+
const paths = Object.values(projects);
|
|
249
|
+
|
|
250
|
+
for (const projectPath of paths) {
|
|
251
|
+
results[projectPath] = checkProjectFreshness(projectPath, {
|
|
252
|
+
autoRebase,
|
|
253
|
+
busyPaths,
|
|
254
|
+
log,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Log summary
|
|
259
|
+
const behindCount = Object.values(results).filter(r =>
|
|
260
|
+
r.status === 'behind' || r.status === 'behind_busy' ||
|
|
261
|
+
r.status === 'behind_dirty' || r.status === 'behind_wrong_branch'
|
|
262
|
+
).length;
|
|
263
|
+
const updatedCount = Object.values(results).filter(r => r.status === 'updated').length;
|
|
264
|
+
|
|
265
|
+
if (behindCount > 0 || updatedCount > 0) {
|
|
266
|
+
log(`Freshness: ${updatedCount} project(s) updated, ${behindCount} behind`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return results;
|
|
270
|
+
}
|
package/lib/update.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manual update orchestrator for Push CLI.
|
|
3
|
+
*
|
|
4
|
+
* `push-todo update` performs three actions:
|
|
5
|
+
* 1. Self-update: check and install latest push-todo from npm
|
|
6
|
+
* 2. Agent versions: detect and display installed agent CLI versions
|
|
7
|
+
* 3. Project freshness: fetch and rebase registered projects that are behind
|
|
8
|
+
*
|
|
9
|
+
* Separation of concerns:
|
|
10
|
+
* - Daemon: runs all three checks periodically (hourly, throttled, non-interactive)
|
|
11
|
+
* - This module: runs on explicit user request (immediate, verbose, interactive)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync } from 'fs';
|
|
15
|
+
import { join, dirname } from 'path';
|
|
16
|
+
import { fileURLToPath } from 'url';
|
|
17
|
+
|
|
18
|
+
import { checkForUpdate, performUpdate, compareSemver } from './self-update.js';
|
|
19
|
+
import { getAgentVersions, getKnownAgentTypes } from './agent-versions.js';
|
|
20
|
+
import { checkProjectFreshness } from './project-freshness.js';
|
|
21
|
+
import { getRegistry } from './project-registry.js';
|
|
22
|
+
import { bold, green, yellow, red, cyan, dim } from './utils/colors.js';
|
|
23
|
+
|
|
24
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
25
|
+
const __dirname = dirname(__filename);
|
|
26
|
+
|
|
27
|
+
function getVersion() {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')).version;
|
|
30
|
+
} catch {
|
|
31
|
+
return 'unknown';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const AGENT_LABELS = {
|
|
36
|
+
'claude-code': 'Claude Code',
|
|
37
|
+
'openai-codex': 'Codex',
|
|
38
|
+
'openclaw': 'OpenClaw',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Run the manual update flow.
|
|
43
|
+
*
|
|
44
|
+
* @param {Object} values - Parsed CLI flags
|
|
45
|
+
*/
|
|
46
|
+
export async function runManualUpdate(values) {
|
|
47
|
+
const currentVersion = getVersion();
|
|
48
|
+
|
|
49
|
+
console.log();
|
|
50
|
+
console.log(bold(' Push Update'));
|
|
51
|
+
console.log(' ' + '='.repeat(40));
|
|
52
|
+
console.log();
|
|
53
|
+
|
|
54
|
+
// ── 1. Self-update ──────────────────────────────────────
|
|
55
|
+
console.log(bold(' Push CLI'));
|
|
56
|
+
console.log(` Current version: v${currentVersion}`);
|
|
57
|
+
|
|
58
|
+
// Force bypass throttle for manual check
|
|
59
|
+
const updateResult = checkForUpdate(currentVersion);
|
|
60
|
+
|
|
61
|
+
if (updateResult.available) {
|
|
62
|
+
console.log(` Latest version: ${green('v' + updateResult.version)}`);
|
|
63
|
+
console.log();
|
|
64
|
+
console.log(` Updating to v${updateResult.version}...`);
|
|
65
|
+
const success = performUpdate(updateResult.version);
|
|
66
|
+
if (success) {
|
|
67
|
+
console.log(` ${green('Updated successfully')}`);
|
|
68
|
+
} else {
|
|
69
|
+
console.log(` ${red('Update failed')} — try: npm install -g @masslessai/push-todo`);
|
|
70
|
+
}
|
|
71
|
+
} else if (updateResult.reason === 'too_recent') {
|
|
72
|
+
console.log(` Latest version: v${updateResult.version} ${dim('(published <1hr ago, waiting)')}`);
|
|
73
|
+
console.log(` ${green('Up to date')}`);
|
|
74
|
+
} else {
|
|
75
|
+
console.log(` ${green('Up to date')}`);
|
|
76
|
+
}
|
|
77
|
+
console.log();
|
|
78
|
+
|
|
79
|
+
// ── 2. Agent versions ──────────────────────────────────
|
|
80
|
+
console.log(bold(' Agent CLIs'));
|
|
81
|
+
|
|
82
|
+
const agentVersions = getAgentVersions({ force: true });
|
|
83
|
+
for (const type of getKnownAgentTypes()) {
|
|
84
|
+
const info = agentVersions[type];
|
|
85
|
+
const label = AGENT_LABELS[type] || type;
|
|
86
|
+
|
|
87
|
+
if (info.installed && info.version) {
|
|
88
|
+
console.log(` ${label}: ${green('v' + info.version)}`);
|
|
89
|
+
} else if (info.installed) {
|
|
90
|
+
console.log(` ${label}: ${yellow('installed')} ${dim('(version unknown)')}`);
|
|
91
|
+
} else {
|
|
92
|
+
console.log(` ${label}: ${dim('not installed')}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
console.log();
|
|
96
|
+
|
|
97
|
+
// ── 3. Project freshness ───────────────────────────────
|
|
98
|
+
const registry = getRegistry();
|
|
99
|
+
const projects = registry.listProjects();
|
|
100
|
+
const projectPaths = Object.entries(projects);
|
|
101
|
+
|
|
102
|
+
if (projectPaths.length === 0) {
|
|
103
|
+
console.log(bold(' Projects'));
|
|
104
|
+
console.log(` ${dim('No projects registered')}`);
|
|
105
|
+
console.log();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
console.log(bold(' Project Freshness'));
|
|
110
|
+
|
|
111
|
+
for (const [remote, localPath] of projectPaths) {
|
|
112
|
+
const result = checkProjectFreshness(localPath, {
|
|
113
|
+
autoRebase: true,
|
|
114
|
+
busyPaths: new Set(), // Manual update has no running tasks
|
|
115
|
+
log: (msg) => console.log(` ${dim(msg)}`),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const shortRemote = remote.length > 40
|
|
119
|
+
? '...' + remote.slice(-37)
|
|
120
|
+
: remote;
|
|
121
|
+
|
|
122
|
+
switch (result.status) {
|
|
123
|
+
case 'up_to_date':
|
|
124
|
+
console.log(` ${shortRemote}: ${green('up to date')}`);
|
|
125
|
+
break;
|
|
126
|
+
case 'updated':
|
|
127
|
+
console.log(` ${shortRemote}: ${green('updated')} ${dim(`(${result.behind} commit(s) rebased)`)}`);
|
|
128
|
+
break;
|
|
129
|
+
case 'behind_wrong_branch':
|
|
130
|
+
console.log(` ${shortRemote}: ${yellow(`${result.behind} behind`)} ${dim(`(on branch '${result.currentBranch}')`)}`);
|
|
131
|
+
break;
|
|
132
|
+
case 'behind_dirty':
|
|
133
|
+
console.log(` ${shortRemote}: ${yellow(`${result.behind} behind`)} ${dim('(dirty working tree)')}`);
|
|
134
|
+
break;
|
|
135
|
+
case 'rebase_failed':
|
|
136
|
+
console.log(` ${shortRemote}: ${red('rebase failed')} ${dim(`(${result.behind} behind)`)}`);
|
|
137
|
+
break;
|
|
138
|
+
case 'fetch_failed':
|
|
139
|
+
console.log(` ${shortRemote}: ${dim('fetch failed (offline?)')}`);
|
|
140
|
+
break;
|
|
141
|
+
case 'not_a_repo':
|
|
142
|
+
console.log(` ${shortRemote}: ${dim('path is not a git repo')}`);
|
|
143
|
+
break;
|
|
144
|
+
default:
|
|
145
|
+
console.log(` ${shortRemote}: ${dim(result.status)}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
console.log();
|
|
150
|
+
}
|