@cnrai/pave 0.3.35 → 0.3.51

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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +21 -218
  3. package/package.json +32 -35
  4. package/pave.js +3 -0
  5. package/sandbox/SandboxRunner.js +1 -0
  6. package/sandbox/pave-run.js +2 -0
  7. package/sandbox/permission.js +1 -0
  8. package/sandbox/utils/yaml.js +1 -0
  9. package/MARKETPLACE.md +0 -406
  10. package/build-binary.js +0 -591
  11. package/build-npm.js +0 -537
  12. package/build.js +0 -230
  13. package/check-binary.js +0 -26
  14. package/deploy.sh +0 -95
  15. package/index.js +0 -5776
  16. package/lib/agent-registry.js +0 -1037
  17. package/lib/args-parser.js +0 -837
  18. package/lib/blessed-widget-patched.js +0 -93
  19. package/lib/cli-markdown.js +0 -590
  20. package/lib/compaction.js +0 -153
  21. package/lib/duration.js +0 -94
  22. package/lib/hash.js +0 -22
  23. package/lib/marketplace.js +0 -866
  24. package/lib/memory-config.js +0 -166
  25. package/lib/skill-manager.js +0 -891
  26. package/lib/soul.js +0 -31
  27. package/lib/tool-output-formatter.js +0 -180
  28. package/start-pave.sh +0 -149
  29. package/status.js +0 -271
  30. package/test/abort-stream.test.js +0 -445
  31. package/test/agent-auto-compaction.test.js +0 -552
  32. package/test/agent-comm-abort.test.js +0 -95
  33. package/test/agent-comm.test.js +0 -598
  34. package/test/agent-inbox.test.js +0 -576
  35. package/test/agent-init.test.js +0 -264
  36. package/test/agent-interrupt.test.js +0 -314
  37. package/test/agent-lifecycle.test.js +0 -520
  38. package/test/agent-log-files.test.js +0 -349
  39. package/test/agent-mode.manual-test.js +0 -392
  40. package/test/agent-parsing.test.js +0 -228
  41. package/test/agent-post-stream-idle.test.js +0 -762
  42. package/test/agent-registry.test.js +0 -359
  43. package/test/agent-rm.test.js +0 -442
  44. package/test/agent-spawn.test.js +0 -933
  45. package/test/agent-status-api.test.js +0 -624
  46. package/test/agent-update.test.js +0 -435
  47. package/test/args-parser.test.js +0 -391
  48. package/test/auto-compaction-chat.manual-test.js +0 -227
  49. package/test/auto-compaction.test.js +0 -941
  50. package/test/build-config.test.js +0 -120
  51. package/test/build-npm.test.js +0 -388
  52. package/test/chat-command.test.js +0 -137
  53. package/test/chat-leading-lines.test.js +0 -159
  54. package/test/config-flag.test.js +0 -272
  55. package/test/cursor-drift.test.js +0 -135
  56. package/test/debug-require.js +0 -23
  57. package/test/dir-migration.test.js +0 -323
  58. package/test/duration.test.js +0 -229
  59. package/test/ghostty-term.test.js +0 -202
  60. package/test/http500-backoff.test.js +0 -854
  61. package/test/integration.test.js +0 -86
  62. package/test/memory-guard-env.test.js +0 -220
  63. package/test/pr233-fixes.test.js +0 -259
  64. package/test/run-agent-init.js +0 -297
  65. package/test/run-all.js +0 -64
  66. package/test/run-config-flag.js +0 -159
  67. package/test/run-cursor-drift.js +0 -82
  68. package/test/run-session-path.js +0 -154
  69. package/test/run-tests.js +0 -643
  70. package/test/sandbox-redirect.test.js +0 -202
  71. package/test/session-path.test.js +0 -132
  72. package/test/shebang-strip.test.js +0 -241
  73. package/test/soul-reinject.test.js +0 -1027
  74. package/test/soul-reread.test.js +0 -281
  75. package/test/tool-output-formatter.test.js +0 -486
  76. package/test/tool-output-gating.test.js +0 -143
  77. package/test/tool-states.test.js +0 -167
  78. package/test/tools-flag.test.js +0 -65
  79. package/test/tui-attach.test.js +0 -1255
  80. package/test/tui-compaction.test.js +0 -354
  81. package/test/tui-wrap.test.js +0 -568
  82. package/test-binary.js +0 -52
  83. package/test-binary2.js +0 -36
@@ -1,1037 +0,0 @@
1
- /**
2
- * Agent Registry - manages agent status files in ~/.pave/agents/
3
- *
4
- * Each running pave agent writes its state to ~/.pave/agents/<name>/status.json.
5
- * The `pave agents` command reads these to list all known agents.
6
- */
7
-
8
- const fs = require('fs');
9
- const path = require('path');
10
- const os = require('os');
11
-
12
- /** Directory where agent status files are stored (overridable for testing) */
13
- let _agentsDir = path.join(os.homedir(), '.pave', 'agents');
14
-
15
- /** Get the current agents directory */
16
- function getAgentsDir() {
17
- return _agentsDir;
18
- }
19
-
20
- /** Override agents directory (for testing) */
21
- function setAgentsDir(dir) {
22
- _agentsDir = dir;
23
- }
24
-
25
- /** Reset agents directory to default */
26
- function resetAgentsDir() {
27
- _agentsDir = path.join(os.homedir(), '.pave', 'agents');
28
- }
29
-
30
- /** Valid agent states */
31
- const STATES = {
32
- STARTING: 'starting',
33
- WORKING: 'working',
34
- SLEEPING: 'sleeping',
35
- STOPPED: 'stopped',
36
- CRASHED: 'crashed',
37
- ERROR: 'error',
38
- };
39
-
40
- /**
41
- * Sanitize agent name for filesystem safety.
42
- * Lowercase, replace spaces with hyphens, remove special chars.
43
- * @param {string} name - Raw agent name
44
- * @returns {string} Sanitized name
45
- */
46
- function sanitizeName(name) {
47
- if (!name) return 'default';
48
- return name
49
- .toLowerCase()
50
- .replace(/\s+/g, '-')
51
- .replace(/[^a-z0-9_-]/g, '')
52
- .replace(/-+/g, '-')
53
- .replace(/^-|-$/g, '')
54
- || 'default';
55
- }
56
-
57
- /**
58
- * Get the directory for a specific agent.
59
- * @param {string} name - Agent name (will be sanitized)
60
- * @returns {string} Path to agent directory
61
- */
62
- function getAgentDir(name) {
63
- return path.join(getAgentsDir(), sanitizeName(name));
64
- }
65
-
66
- /**
67
- * Get the status file path for a specific agent.
68
- * @param {string} name - Agent name (will be sanitized)
69
- * @returns {string} Path to status.json
70
- */
71
- function getStatusPath(name) {
72
- return path.join(getAgentDir(name), 'status.json');
73
- }
74
-
75
- /**
76
- * Check if a process with the given PID is alive.
77
- * @param {number} pid - Process ID
78
- * @returns {boolean} true if the process is running
79
- */
80
- function isProcessAlive(pid) {
81
- // Guard: pid must be a positive finite integer (0 and negatives have special POSIX semantics)
82
- if (!Number.isFinite(pid) || pid <= 0) return false;
83
- try {
84
- process.kill(pid, 0);
85
- return true;
86
- } catch (e) {
87
- // EPERM means the process exists but we don't have permission to signal it
88
- if (e.code === 'EPERM') return true;
89
- // ESRCH means no such process
90
- return false;
91
- }
92
- }
93
-
94
- /**
95
- * Write agent status to disk.
96
- * Uses write-to-temp + rename for atomicity.
97
- * Creates directories with 0o700 and files with 0o600 to restrict access.
98
- * @param {string} name - Agent name
99
- * @param {object} status - Status object to write. All timestamps
100
- * (startedAt, updatedAt) are in milliseconds since epoch (Date.now()).
101
- * Note: the issue #198 schema example shows 10-digit Unix seconds, but
102
- * this implementation uses 13-digit ms to match standard JS conventions.
103
- */
104
- function writeStatus(name, status) {
105
- if (!status || typeof status !== 'object') {
106
- console.error('[agent-registry] Invalid status object; skipping write');
107
- return;
108
- }
109
-
110
- const dir = getAgentDir(name);
111
- const statusPath = path.join(dir, 'status.json');
112
- const tmpPath = statusPath + '.tmp';
113
-
114
- try {
115
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
116
- } catch (e) {
117
- // Directory may already exist
118
- }
119
-
120
- status.updatedAt = Date.now();
121
- const content = JSON.stringify(status, null, 2);
122
-
123
- try {
124
- fs.writeFileSync(tmpPath, content, { mode: 0o600 });
125
- try {
126
- fs.renameSync(tmpPath, statusPath);
127
- } catch (renameErr) {
128
- // On Windows, rename can fail with EEXIST/EPERM when destination exists.
129
- // Only attempt the unlink+retry for those specific error codes.
130
- if (renameErr.code === 'EEXIST' || renameErr.code === 'EPERM') {
131
- try { fs.unlinkSync(statusPath); } catch (e3) {}
132
- fs.renameSync(tmpPath, statusPath);
133
- } else {
134
- throw renameErr;
135
- }
136
- }
137
- } catch (e) {
138
- // Best-effort cleanup
139
- try { fs.unlinkSync(tmpPath); } catch (e2) {}
140
- // Don't throw - status write failure should not crash the agent
141
- console.error('[agent-registry] Failed to write status: ' + e.message);
142
- }
143
- }
144
-
145
- /**
146
- * Read agent status from disk.
147
- * @param {string} name - Agent name
148
- * @returns {object|null} Status object or null if not found
149
- */
150
- function readStatus(name) {
151
- const statusPath = getStatusPath(name);
152
- try {
153
- const content = fs.readFileSync(statusPath, 'utf8');
154
- return JSON.parse(content);
155
- } catch (e) {
156
- return null;
157
- }
158
- }
159
-
160
- /**
161
- * Atomically claim an agent name. Creates a lock file with 'wx' (exclusive
162
- * create) so two concurrent starts with the same name will race on the
163
- * kernel's O_EXCL, and only one will succeed.
164
- *
165
- * The lock file contains the PID of the claiming process.
166
- *
167
- * @param {string} name - Agent name
168
- * @returns {{claimed: boolean, existingPid: number|null}} Result
169
- */
170
- function claimAgent(name) {
171
- const dir = getAgentDir(name);
172
- const lockPath = path.join(dir, 'lock');
173
-
174
- try {
175
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
176
- } catch (e) {
177
- // Directory may already exist
178
- }
179
-
180
- // Try to read existing lock to check for stale/alive process
181
- try {
182
- const existingLock = fs.readFileSync(lockPath, 'utf8');
183
- const existingPid = parseInt(existingLock.trim(), 10);
184
- if (existingPid && isProcessAlive(existingPid)) {
185
- return { claimed: false, existingPid };
186
- }
187
- // Stale lock (process dead) - remove and retry
188
- try { fs.unlinkSync(lockPath); } catch (e) {}
189
- } catch (e) {
190
- // No lock file exists - proceed to claim
191
- }
192
-
193
- try {
194
- fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx', mode: 0o600 });
195
- return { claimed: true, existingPid: null };
196
- } catch (e) {
197
- if (e.code === 'EEXIST') {
198
- // Another process claimed between our read and write - re-read to get PID
199
- try {
200
- const pid = parseInt(fs.readFileSync(lockPath, 'utf8').trim(), 10);
201
- return { claimed: false, existingPid: pid || null };
202
- } catch (e2) {
203
- return { claimed: false, existingPid: null };
204
- }
205
- }
206
- console.error('[agent-registry] Failed to claim agent: ' + e.message);
207
- return { claimed: false, existingPid: null };
208
- }
209
- }
210
-
211
- /**
212
- * Release an agent lock (remove the lock file).
213
- * @param {string} name - Agent name
214
- */
215
- function releaseAgent(name) {
216
- const lockPath = path.join(getAgentDir(name), 'lock');
217
- try { fs.unlinkSync(lockPath); } catch (e) {}
218
- }
219
-
220
- /**
221
- * Check if an agent with the given name is already running.
222
- * @param {string} name - Agent name
223
- * @returns {{alive: boolean, status: object|null}} Result with liveness and status
224
- */
225
- function isAgentAlive(name) {
226
- const status = readStatus(name);
227
- if (!status) return { alive: false, status: null };
228
- if (status.state === STATES.STOPPED) return { alive: false, status };
229
- if (!status.pid) return { alive: false, status };
230
- return { alive: isProcessAlive(status.pid), status };
231
- }
232
-
233
- /**
234
- * Get the server URL for a running agent.
235
- * Reads from status.json's serverUrl field. Returns null if agent is
236
- * not running or serverUrl is not available.
237
- * @param {string} name - Agent name
238
- * @returns {string|null} Server URL (e.g., 'http://localhost:1234') or null
239
- */
240
- function getAgentServerUrl(name) {
241
- const { alive, status } = isAgentAlive(name);
242
- if (!alive || !status || !status.serverUrl) return null;
243
- return status.serverUrl;
244
- }
245
-
246
- /**
247
- * List all registered agents with their status and liveness.
248
- * @returns {Array<{name: string, status: object, alive: boolean}>}
249
- */
250
- function listAgents() {
251
- const results = [];
252
- let entries;
253
-
254
- try {
255
- entries = fs.readdirSync(getAgentsDir(), { withFileTypes: true });
256
- } catch (e) {
257
- // No agents directory yet
258
- return results;
259
- }
260
-
261
- /** Strict safe pattern for agent directory names */
262
- const SAFE_NAME_RE = /^[a-z0-9_-]+$/;
263
-
264
- for (let i = 0; i < entries.length; i++) {
265
- if (!entries[i].isDirectory()) continue;
266
-
267
- const name = entries[i].name;
268
- // Skip directories that don't match sanitized name pattern
269
- // to prevent terminal escape injection from crafted directory names
270
- if (!SAFE_NAME_RE.test(name)) continue;
271
-
272
- const status = readStatus(name);
273
- if (!status) continue;
274
-
275
- let alive = false;
276
- if (status.state !== STATES.STOPPED && status.pid) {
277
- alive = isProcessAlive(status.pid);
278
- }
279
-
280
- // Detect crashed agents (skip STOPPED and ERROR since those are terminal states)
281
- let displayState = status.state;
282
- if (!alive && status.state !== STATES.STOPPED && status.state !== STATES.ERROR) {
283
- displayState = STATES.CRASHED;
284
- }
285
-
286
- results.push({
287
- name,
288
- status: { ...status, state: displayState },
289
- alive,
290
- });
291
- }
292
-
293
- // Sort by most recently updated
294
- results.sort((a, b) => {
295
- return (b.status.updatedAt || 0) - (a.status.updatedAt || 0);
296
- });
297
-
298
- return results;
299
- }
300
-
301
- /**
302
- * Format uptime from a start timestamp.
303
- * @param {number} startedAt - Start timestamp in ms
304
- * @returns {string} Human-readable uptime (e.g., "2h 15m")
305
- */
306
- function formatUptime(startedAt) {
307
- if (!startedAt) return '—';
308
- const elapsed = Date.now() - startedAt;
309
- if (elapsed < 0) return '—';
310
-
311
- const seconds = Math.floor(elapsed / 1000);
312
- const minutes = Math.floor(seconds / 60);
313
- const hours = Math.floor(minutes / 60);
314
- const days = Math.floor(hours / 24);
315
-
316
- if (days > 0) return days + 'd ' + (hours % 24) + 'h';
317
- if (hours > 0) return hours + 'h ' + (minutes % 60) + 'm';
318
- if (minutes > 0) return minutes + 'm';
319
- return seconds + 's';
320
- }
321
-
322
- /**
323
- * Resolve an agent name by prefix matching.
324
- * If the exact name matches, return it. Otherwise, try prefix matching
325
- * against all registered agents. Returns the match only if exactly one
326
- * agent matches the prefix.
327
- *
328
- * @param {string} nameOrPrefix - Agent name or prefix
329
- * @returns {{name: string|null, error: string|null, candidates: string[]}}
330
- */
331
- function resolveAgentName(nameOrPrefix) {
332
- if (!nameOrPrefix) {
333
- return { name: null, error: 'No agent name provided', candidates: [] };
334
- }
335
-
336
- const sanitized = sanitizeName(nameOrPrefix);
337
-
338
- // Prevent garbage input (e.g. only spaces or punctuation) from silently
339
- // resolving to the "default" agent via sanitization. Only allow "default"
340
- // when the user explicitly requested it.
341
- if (
342
- sanitized === 'default' &&
343
- typeof nameOrPrefix === 'string' &&
344
- nameOrPrefix.trim().toLowerCase() !== 'default'
345
- ) {
346
- return { name: null, error: 'Invalid agent name provided', candidates: [] };
347
- }
348
-
349
- /** Strict safe pattern (same as listAgents) to prevent terminal injection */
350
- const SAFE_NAME_RE = /^[a-z0-9_-]+$/;
351
-
352
- // Try exact match first
353
- const status = readStatus(sanitized);
354
- if (status) {
355
- return { name: sanitized, error: null, candidates: [sanitized] };
356
- }
357
-
358
- // Try prefix matching
359
- let entries;
360
- try {
361
- entries = fs.readdirSync(getAgentsDir(), { withFileTypes: true });
362
- } catch (e) {
363
- // Distinguish "no agents directory" from other I/O/permission errors
364
- if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')) {
365
- return { name: null, error: 'No agents registered', candidates: [] };
366
- }
367
- const codeInfo = e && e.code ? ' (code: ' + e.code + ')' : '';
368
- const msgInfo = e && e.message ? ': ' + e.message : '';
369
- return { name: null, error: 'Failed to read agents directory' + codeInfo + msgInfo, candidates: [] };
370
- }
371
-
372
- const matches = [];
373
- for (let i = 0; i < entries.length; i++) {
374
- if (!entries[i].isDirectory()) continue;
375
- const dirName = entries[i].name;
376
- // Skip directories that don't match safe name pattern (terminal injection prevention)
377
- if (!SAFE_NAME_RE.test(dirName)) continue;
378
- if (dirName.indexOf(sanitized) === 0) {
379
- // Only include if it has a status file
380
- if (readStatus(dirName)) {
381
- matches.push(dirName);
382
- }
383
- }
384
- }
385
-
386
- if (matches.length === 1) {
387
- return { name: matches[0], error: null, candidates: matches };
388
- } if (matches.length === 0) {
389
- return { name: null, error: 'No agent found matching "' + sanitized + '"', candidates: [] };
390
- }
391
- return {
392
- name: null,
393
- error: 'Ambiguous agent name "' + sanitized + '". Matches: ' + matches.join(', '),
394
- candidates: matches,
395
- };
396
- }
397
-
398
- /**
399
- * Stop a running agent by sending SIGTERM, with SIGKILL fallback.
400
- *
401
- * @param {string} name - Resolved agent name (already sanitized)
402
- * @param {object} [opts] - Options
403
- * @param {number} [opts.timeout] - Milliseconds to wait before SIGKILL (default: 5000)
404
- * @returns {Promise<{success: boolean, error: string|null, killed: boolean}>}
405
- */
406
- function stopAgent(name, opts) {
407
- const timeout = (opts && opts.timeout) || 5000;
408
- const status = readStatus(name);
409
-
410
- if (!status) {
411
- return Promise.resolve({ success: false, error: 'Agent "' + name + '" not found', killed: false });
412
- }
413
-
414
- if (status.state === STATES.STOPPED) {
415
- return Promise.resolve({ success: false, error: 'Agent "' + name + '" is already stopped', killed: false });
416
- }
417
-
418
- if (!status.pid) {
419
- return Promise.resolve({ success: false, error: 'Agent "' + name + '" has no PID recorded', killed: false });
420
- }
421
-
422
- if (!isProcessAlive(status.pid)) {
423
- // Process is dead but status wasn't updated.
424
- // Re-read to guard against a restart race: if a new agent has already
425
- // claimed the name with a different PID, leave its status alone.
426
- const latestStatus = readStatus(name) || status;
427
- const pidStillOurs = !latestStatus.pid || latestStatus.pid === status.pid;
428
- if (pidStillOurs) {
429
- writeStatus(name, { ...latestStatus, state: STATES.STOPPED, currentTask: 'stopped (stale)' });
430
- // Don't call releaseAgent() here - a new agent may have already claimed
431
- // the lock file (with its own PID) before writing status.json.
432
- // claimAgent() handles stale locks on its own, so this is safe.
433
- }
434
- return Promise.resolve({ success: true, error: null, killed: false });
435
- }
436
-
437
- // Send SIGTERM
438
- try {
439
- process.kill(status.pid, 'SIGTERM');
440
- } catch (e) {
441
- // If the process exited between the liveness check and this kill call,
442
- // Node will throw ESRCH. Treat as successful stop.
443
- if (e && e.code === 'ESRCH') {
444
- // Re-read status to guard against a restart race (same as stale path).
445
- const latestStatus = readStatus(name) || status;
446
- const pidStillOurs = !latestStatus.pid || latestStatus.pid === status.pid;
447
- if (pidStillOurs) {
448
- writeStatus(name, { ...latestStatus, state: STATES.STOPPED, currentTask: 'stopped (exited before SIGTERM)' });
449
- // Don't releaseAgent() - a new agent may own the lock file already.
450
- // claimAgent() handles stale locks on its own.
451
- }
452
- return Promise.resolve({ success: true, error: null, killed: false });
453
- }
454
- return Promise.resolve({ success: false, error: 'Failed to send SIGTERM: ' + e.message, killed: false });
455
- }
456
-
457
- // Wait for clean shutdown, then SIGKILL if needed
458
- return new Promise((resolve) => {
459
- let elapsed = 0;
460
- const interval = 500;
461
-
462
- const timer = setInterval(() => {
463
- elapsed += interval;
464
-
465
- // Check if the process has stopped
466
- if (!isProcessAlive(status.pid)) {
467
- clearInterval(timer);
468
- // Re-read status to avoid overwriting state owned by a newly restarted agent.
469
- // If a new agent claimed the name (different PID), leave its status alone.
470
- const latestStatus = readStatus(name) || status;
471
- const pidStillOurs = !latestStatus.pid || latestStatus.pid === status.pid;
472
- if (pidStillOurs && (!latestStatus.state || latestStatus.state !== STATES.STOPPED)) {
473
- writeStatus(name, { ...latestStatus, state: STATES.STOPPED, currentTask: 'stopped (SIGTERM)' });
474
- // Don't releaseAgent() - claimAgent() handles stale locks on its own,
475
- // and a new agent may already own the lock file.
476
- }
477
- resolve({ success: true, error: null, killed: false });
478
- return;
479
- }
480
-
481
- // Timeout - send SIGKILL
482
- if (elapsed >= timeout) {
483
- clearInterval(timer);
484
- let killedBySigkill = false;
485
- try {
486
- process.kill(status.pid, 'SIGKILL');
487
- killedBySigkill = true;
488
- } catch (e) {
489
- if (e && e.code === 'ESRCH') {
490
- // Process already exited between the last liveness check and
491
- // the SIGKILL - it shut down cleanly at the timeout boundary.
492
- killedBySigkill = false;
493
- } else if (isProcessAlive(status.pid)) {
494
- // Signal failed for another reason (EPERM) and process is alive
495
- resolve({
496
- success: false,
497
- error: 'Failed to SIGKILL agent "' + name + '" (pid ' + status.pid + '): ' + (e && e.message ? e.message : 'unknown error'),
498
- killed: false,
499
- });
500
- return;
501
- }
502
- // else: signal failed but process is dead - treat as clean stop
503
- }
504
-
505
- // Helper to finalize a successful stop/kill: re-read status to avoid
506
- // overwriting state owned by a newly restarted agent, and only update
507
- // status if the PID still matches this agent. Don't release the lock -
508
- // claimAgent() handles stale locks on its own, and a new agent may
509
- // have already claimed the lock file.
510
- function finalizeStop(finalKilledBySigkill) {
511
- const latestStatus = readStatus(name) || status;
512
- const pidStillOurs = !latestStatus.pid || latestStatus.pid === status.pid;
513
- if (pidStillOurs) {
514
- const task = finalKilledBySigkill ? 'killed (SIGKILL)' : 'stopped (SIGTERM)';
515
- writeStatus(name, { ...latestStatus, state: STATES.STOPPED, currentTask: task });
516
- }
517
- resolve({ success: true, error: null, killed: finalKilledBySigkill });
518
- }
519
-
520
- if (!killedBySigkill) {
521
- // Either the process was already dead (ESRCH) or we otherwise treat it
522
- // as cleanly stopped at the timeout boundary.
523
- finalizeStop(false);
524
- return;
525
- }
526
-
527
- // We sent SIGKILL, but the process may still be terminating. Poll for a
528
- // short period to confirm exit before marking the agent as stopped and
529
- // releasing the lock.
530
- const sigkillWaitInterval = 100;
531
- const sigkillMaxWait = 2000;
532
- let sigkillWaited = 0;
533
- const sigkillTimer = setInterval(() => {
534
- if (!isProcessAlive(status.pid)) {
535
- clearInterval(sigkillTimer);
536
- finalizeStop(true);
537
- return;
538
- }
539
- sigkillWaited += sigkillWaitInterval;
540
- if (sigkillWaited >= sigkillMaxWait) {
541
- clearInterval(sigkillTimer);
542
- // Process did not exit within the grace period after SIGKILL.
543
- // Do not mark it as stopped or release the lock; report failure.
544
- resolve({
545
- success: false,
546
- error: 'Agent "' + name + '" (pid ' + status.pid + ') did not exit after SIGKILL',
547
- killed: true,
548
- });
549
- }
550
- }, sigkillWaitInterval);
551
- }
552
- }, interval);
553
- });
554
- }
555
-
556
- /**
557
- * Get the log file path for an agent.
558
- * Resolves from the agent's status.json (cwd + config).
559
- *
560
- * @param {string} name - Resolved agent name
561
- * @returns {{path: string|null, error: string|null}}
562
- */
563
- function getAgentLogFile(name) {
564
- const status = readStatus(name);
565
- if (!status) {
566
- return { path: null, error: 'Agent "' + name + '" not found' };
567
- }
568
-
569
- const cwd = status.cwd;
570
- const config = status.config || '.pave';
571
-
572
- if (!cwd) {
573
- return { path: null, error: 'Agent "' + name + '" has no working directory recorded' };
574
- }
575
-
576
- const logFile = path.join(cwd, config, 'pave.log');
577
- return { path: logFile, error: null };
578
- }
579
-
580
- // ─── Inbox Protocol (Phase 3) ──────────────────────────────────────────────
581
-
582
- /**
583
- * Get the inbox directory path for a specific agent.
584
- * @param {string} name - Agent name (will be sanitized)
585
- * @returns {string} Path to inbox directory
586
- */
587
- function getInboxDir(name) {
588
- return path.join(getAgentDir(name), 'inbox');
589
- }
590
-
591
- /**
592
- * Determine the next sequence number for an inbox message.
593
- * Scans existing inbox files and returns max + 1.
594
- * @param {string} inboxDir - Path to inbox directory
595
- * @returns {number} Next sequence number (starts at 1)
596
- */
597
- function _nextSequence(inboxDir) {
598
- let maxSeq = 0;
599
- try {
600
- const files = fs.readdirSync(inboxDir);
601
- for (let i = 0; i < files.length; i++) {
602
- const match = files[i].match(/^(\d+)-/);
603
- if (match) {
604
- const seq = parseInt(match[1], 10);
605
- if (seq > maxSeq) maxSeq = seq;
606
- }
607
- }
608
- } catch (e) {
609
- // Directory may not exist yet
610
- }
611
- return maxSeq + 1;
612
- }
613
-
614
- /**
615
- * Write a message to an agent's inbox.
616
- * Uses write-to-temp + fs.renameSync for atomicity.
617
- * Filenames include PID + random suffix for concurrent-writer safety.
618
- * Timestamps are in milliseconds (Date.now()), consistent with writeStatus().
619
- *
620
- * @param {string} name - Agent name (will be sanitized)
621
- * @param {string} messageText - Message content
622
- * @param {object} [opts] - Options
623
- * @param {string} [opts.from] - Sender identifier (default: 'tui')
624
- * @param {string} [opts.priority] - 'normal' or 'high' (default: 'normal')
625
- * @returns {{success: boolean, error: string|null, file: string|null}}
626
- */
627
- function writeInboxMessage(name, messageText, opts) {
628
- if (!messageText || typeof messageText !== 'string') {
629
- return { success: false, error: 'Message must be a non-empty string', file: null };
630
- }
631
-
632
- const sanitized = sanitizeName(name);
633
- const inboxDir = getInboxDir(sanitized);
634
- const from = (opts && opts.from) || 'tui';
635
- const priority = (opts && opts.priority) || 'normal';
636
-
637
- // Validate priority
638
- if (priority !== 'normal' && priority !== 'high') {
639
- return { success: false, error: 'Invalid priority: must be "normal" or "high"', file: null };
640
- }
641
-
642
- // Ensure inbox directory exists
643
- try {
644
- fs.mkdirSync(inboxDir, { recursive: true, mode: 0o700 });
645
- } catch (e) {
646
- if (e && e.code !== 'EEXIST') {
647
- return { success: false, error: 'Failed to create inbox directory: ' + e.message, file: null };
648
- }
649
- }
650
-
651
- const seq = _nextSequence(inboxDir);
652
- const timestamp = Date.now();
653
- // Zero-pad sequence to 4 digits for proper FIFO ordering
654
- const seqStr = String(seq).padStart(4, '0');
655
- // Add PID + random suffix to avoid filename collisions across concurrent writers
656
- const uniqueSuffix = process.pid + '-' + Math.random().toString(16).slice(2, 10);
657
- const filename = seqStr + '-' + timestamp + '-' + uniqueSuffix + '.json';
658
- const filePath = path.join(inboxDir, filename);
659
- // Use unique temp path to avoid collisions between concurrent writers
660
- const tmpPath = filePath + '.' + process.pid + '-' + Math.random().toString(16).slice(2, 8) + '.tmp';
661
-
662
- const envelope = {
663
- message: messageText,
664
- from,
665
- priority,
666
- timestamp, // Milliseconds (consistent with writeStatus)
667
- };
668
-
669
- try {
670
- fs.writeFileSync(tmpPath, JSON.stringify(envelope, null, 2), { mode: 0o600 });
671
- try {
672
- fs.renameSync(tmpPath, filePath);
673
- } catch (renameErr) {
674
- // On destination collision (extremely unlikely with unique suffix), retry with new name
675
- if (renameErr.code === 'EEXIST' || renameErr.code === 'EPERM') {
676
- const retrySuffix = process.pid + '-' + Math.random().toString(16).slice(2, 10);
677
- const retryFilename = seqStr + '-' + timestamp + '-' + retrySuffix + '.json';
678
- const retryPath = path.join(inboxDir, retryFilename);
679
- fs.renameSync(tmpPath, retryPath);
680
- return { success: true, error: null, file: retryFilename };
681
- }
682
- throw renameErr;
683
- }
684
- return { success: true, error: null, file: filename };
685
- } catch (e) {
686
- try { fs.unlinkSync(tmpPath); } catch (e2) {}
687
- return { success: false, error: 'Failed to write inbox message: ' + e.message, file: null };
688
- }
689
- }
690
-
691
- /**
692
- * Check if an agent's inbox has pending messages.
693
- * @param {string} name - Agent name (will be sanitized)
694
- * @returns {boolean} true if there are messages in the inbox
695
- */
696
- function inboxHasMessages(name) {
697
- const inboxDir = getInboxDir(sanitizeName(name));
698
- try {
699
- const files = fs.readdirSync(inboxDir);
700
- for (let i = 0; i < files.length; i++) {
701
- if (files[i].endsWith('.json') && !files[i].endsWith('.tmp')) {
702
- return true;
703
- }
704
- }
705
- } catch (e) {
706
- // No inbox directory
707
- }
708
- return false;
709
- }
710
-
711
- /**
712
- * Drain messages from an agent's inbox.
713
- * Reads up to `limit` messages in FIFO order (sorted by filename),
714
- * with high-priority messages sorted before normal within the batch.
715
- *
716
- * By default, inbox files are deleted after reading. When `opts.deleteFiles`
717
- * is false, files are left on disk and the caller must call
718
- * `deleteInboxFiles(result.files)` after confirming delivery. This enables
719
- * at-least-once delivery semantics: if the downstream send fails, messages
720
- * remain on disk and are retried on the next iteration. (#231)
721
- *
722
- * Malformed/unreadable files are always deleted immediately regardless of
723
- * the `deleteFiles` option, since they can never be delivered.
724
- *
725
- * @param {string} name - Agent name (will be sanitized)
726
- * @param {object} [opts] - Options
727
- * @param {number} [opts.limit] - Maximum messages to drain (default: 5)
728
- * @param {boolean} [opts.deleteFiles] - Delete files after read (default: true)
729
- * @returns {{messages: Array<object>, remaining: number, files: string[]}}
730
- * - messages: parsed message envelopes (sorted by priority then FIFO)
731
- * - remaining: count of unread messages still in inbox
732
- * - files: absolute paths of successfully read files (for deferred deletion)
733
- */
734
- function drainInbox(name, opts) {
735
- const limit = (opts && typeof opts.limit === 'number' && opts.limit > 0) ? opts.limit : 5;
736
- const inboxDir = getInboxDir(sanitizeName(name));
737
-
738
- let files;
739
- try {
740
- files = fs.readdirSync(inboxDir);
741
- } catch (e) {
742
- return { messages: [], remaining: 0, files: [] };
743
- }
744
-
745
- // Filter to valid message files and sort by parsed sequence number (then timestamp as tiebreaker)
746
- // Numeric sort avoids issues with lexical sort when sequence exceeds zero-pad width
747
- const messageFiles = files
748
- .filter((f) => { return f.endsWith('.json') && !f.endsWith('.tmp'); })
749
- .sort((a, b) => {
750
- const seqA = parseInt(a.split('-')[0], 10) || 0;
751
- const seqB = parseInt(b.split('-')[0], 10) || 0;
752
- if (seqA !== seqB) return seqA - seqB;
753
- // Tiebreaker: parse timestamp (second segment)
754
- const tsA = parseInt(a.split('-')[1], 10) || 0;
755
- const tsB = parseInt(b.split('-')[1], 10) || 0;
756
- return tsA - tsB;
757
- });
758
-
759
- const messages = [];
760
- const successFiles = []; // Files with valid messages (for deferred deletion)
761
- const malformedFiles = []; // Malformed/unreadable files (always deleted immediately)
762
-
763
- // Read up to limit messages
764
- const readCount = Math.min(messageFiles.length, limit);
765
- for (let i = 0; i < readCount; i++) {
766
- const filePath = path.join(inboxDir, messageFiles[i]);
767
- try {
768
- const content = fs.readFileSync(filePath, 'utf8');
769
- const envelope = JSON.parse(content);
770
- // Validate required fields
771
- if (envelope && typeof envelope.message === 'string') {
772
- messages.push({
773
- message: envelope.message,
774
- from: envelope.from || 'unknown',
775
- priority: envelope.priority || 'normal',
776
- timestamp: envelope.timestamp || 0,
777
- file: messageFiles[i],
778
- });
779
- successFiles.push(filePath);
780
- } else {
781
- // Malformed file - always delete immediately (can never be delivered)
782
- console.error('[agent-registry] Malformed inbox file, deleting: ' + messageFiles[i]);
783
- malformedFiles.push(filePath);
784
- }
785
- } catch (e) {
786
- // Malformed/unreadable file - always delete immediately
787
- console.error('[agent-registry] Error reading inbox file ' + messageFiles[i] + ': ' + e.message);
788
- malformedFiles.push(filePath);
789
- }
790
- }
791
-
792
- // Always delete malformed files immediately (they can never be delivered)
793
- for (let i = 0; i < malformedFiles.length; i++) {
794
- try { fs.unlinkSync(malformedFiles[i]); } catch (e) {}
795
- }
796
-
797
- // Sort within batch: high-priority first, then by original FIFO order (numeric)
798
- messages.sort((a, b) => {
799
- if (a.priority === 'high' && b.priority !== 'high') return -1;
800
- if (a.priority !== 'high' && b.priority === 'high') return 1;
801
- // Preserve FIFO order within same priority using numeric sequence + timestamp
802
- const seqA = parseInt(a.file.split('-')[0], 10) || 0;
803
- const seqB = parseInt(b.file.split('-')[0], 10) || 0;
804
- if (seqA !== seqB) return seqA - seqB;
805
- const tsA = parseInt(a.file.split('-')[1], 10) || 0;
806
- const tsB = parseInt(b.file.split('-')[1], 10) || 0;
807
- return tsA - tsB;
808
- });
809
-
810
- // Delete successfully-read files only if deleteFiles option is true (default: true for backward compat)
811
- // When deleteFiles is false, caller is responsible for calling deleteInboxFiles() after
812
- // confirming delivery. This prevents message loss if sendMessageAndStream fails. (#231)
813
- const shouldDelete = !(opts && opts.deleteFiles === false);
814
- if (shouldDelete) {
815
- for (let i = 0; i < successFiles.length; i++) {
816
- try { fs.unlinkSync(successFiles[i]); } catch (e) {}
817
- }
818
- }
819
-
820
- const remaining = messageFiles.length - readCount;
821
- return { messages, remaining, files: successFiles };
822
- }
823
-
824
- /**
825
- * Delete inbox files after confirming delivery.
826
- * Used with drainInbox({ deleteFiles: false }) for at-least-once delivery semantics. (#231)
827
- * @param {string[]} filePaths - Array of absolute file paths to delete
828
- */
829
- function deleteInboxFiles(filePaths) {
830
- if (!Array.isArray(filePaths)) return;
831
- for (let i = 0; i < filePaths.length; i++) {
832
- try { fs.unlinkSync(filePaths[i]); } catch (e) {}
833
- }
834
- }
835
-
836
- /**
837
- * Format inbox messages for inclusion in an LLM prompt.
838
- * @param {Array<object>} messages - Array of message objects from drainInbox
839
- * @param {number} [remaining] - Number of remaining messages (optional)
840
- * @returns {string} Formatted prompt string
841
- */
842
- function formatInboxMessages(messages, remaining) {
843
- if (!messages || messages.length === 0) return '';
844
-
845
- let result = 'You have received messages from users. Process them, then continue your normal work:\n';
846
-
847
- for (let i = 0; i < messages.length; i++) {
848
- const msg = messages[i];
849
- const priorityTag = msg.priority === 'high' ? ', priority: high' : '';
850
- result += '\n[' + (i + 1) + '] (from: ' + msg.from + priorityTag + ')\n';
851
- result += msg.message + '\n';
852
- }
853
-
854
- if (remaining && remaining > 0) {
855
- result += '\n(' + remaining + ' more messages remaining in inbox, will be delivered next iteration)\n';
856
- }
857
-
858
- return result;
859
- }
860
-
861
- // ============================================================
862
- // Urgent Interrupt Protocol (Phase 4, Issue #201)
863
- // ============================================================
864
-
865
- /**
866
- * Get the path to an agent's interrupt file.
867
- * @param {string} name - Agent name (will be sanitized)
868
- * @returns {string} Path to interrupt.json
869
- */
870
- function getInterruptPath(name) {
871
- return path.join(getAgentDir(name), 'interrupt.json');
872
- }
873
-
874
- /**
875
- * Write an urgent interrupt to an agent.
876
- * Latest write wins — no queue. Urgency means "drop everything."
877
- * Uses atomic write (tmp + rename) for safety.
878
- *
879
- * @param {string} name - Agent name (will be sanitized)
880
- * @param {string} messageText - Interrupt message
881
- * @param {object} [opts] - Options
882
- * @param {string} [opts.from] - Sender identifier (default: 'tui')
883
- * @param {string} [opts.action] - 'abort' (default). 'divert' reserved for future use.
884
- * @returns {{success: boolean, error: string|null}}
885
- */
886
- function writeInterrupt(name, messageText, opts) {
887
- const from = (opts && opts.from) || 'tui';
888
- const action = (opts && opts.action) || 'abort';
889
-
890
- if (!messageText || typeof messageText !== 'string') {
891
- return { success: false, error: 'Interrupt message must be a non-empty string' };
892
- }
893
-
894
- const dir = getAgentDir(name);
895
- const interruptPath = path.join(dir, 'interrupt.json');
896
- const tmpPath = interruptPath + '.' + process.pid + '-' + Math.random().toString(16).slice(2, 8) + '.tmp';
897
-
898
- const envelope = {
899
- action,
900
- message: messageText,
901
- from,
902
- timestamp: Date.now(),
903
- };
904
-
905
- try {
906
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
907
- } catch (e) {
908
- return { success: false, error: 'Failed to create agent directory: ' + e.message };
909
- }
910
-
911
- try {
912
- fs.writeFileSync(tmpPath, JSON.stringify(envelope, null, 2), { mode: 0o600 });
913
- try {
914
- fs.renameSync(tmpPath, interruptPath);
915
- } catch (renameErr) {
916
- if (renameErr.code === 'EEXIST' || renameErr.code === 'EPERM') {
917
- try { fs.unlinkSync(interruptPath); } catch (e2) {}
918
- fs.renameSync(tmpPath, interruptPath);
919
- } else {
920
- throw renameErr;
921
- }
922
- }
923
- return { success: true, error: null };
924
- } catch (e) {
925
- try { fs.unlinkSync(tmpPath); } catch (e2) {}
926
- return { success: false, error: 'Failed to write interrupt: ' + e.message };
927
- }
928
- }
929
-
930
- /**
931
- * Check if an agent has a pending interrupt, without reading/consuming it.
932
- * @param {string} name - Agent name
933
- * @returns {boolean}
934
- */
935
- function hasInterrupt(name) {
936
- try {
937
- fs.accessSync(getInterruptPath(name), fs.constants.F_OK);
938
- return true;
939
- } catch (e) {
940
- return false;
941
- }
942
- }
943
-
944
- /**
945
- * Read an agent's interrupt file. Does NOT delete it (caller must clearInterrupt).
946
- * @param {string} name - Agent name
947
- * @returns {object|null} Interrupt envelope or null if none/unreadable
948
- */
949
- function readInterrupt(name) {
950
- const interruptPath = getInterruptPath(name);
951
- try {
952
- const data = fs.readFileSync(interruptPath, 'utf8');
953
- const envelope = JSON.parse(data);
954
- if (!envelope.message || typeof envelope.message !== 'string') {
955
- return null;
956
- }
957
- return envelope;
958
- } catch (e) {
959
- return null;
960
- }
961
- }
962
-
963
- /**
964
- * Delete an agent's interrupt file after it has been processed.
965
- * @param {string} name - Agent name
966
- */
967
- function clearInterrupt(name) {
968
- try {
969
- fs.unlinkSync(getInterruptPath(name));
970
- } catch (e) {
971
- // File may already be gone
972
- }
973
- }
974
-
975
- /**
976
- * Remove an agent's registry entry and all associated files.
977
- * Removes the entire agent directory (status.json, lock, inbox/, interrupt.json).
978
- * Does NOT verify if the agent is running - caller should check.
979
- * @param {string} name - Agent name
980
- * @returns {{ removed: boolean, error: null }} Result object (error is always null on return)
981
- * @throws {Error} On filesystem errors other than ENOENT (e.g., EPERM, EBUSY)
982
- */
983
- function removeAgent(name) {
984
- const sanitized = sanitizeName(name);
985
- const agentDir = getAgentDir(sanitized);
986
-
987
- // Remove the entire agent directory recursively (Node 14.14+).
988
- // This cleans up status.json, lock, inbox/, interrupt.json, and any
989
- // future artifacts under the agent directory.
990
- // Using force:false so rmSync throws on real errors (EPERM, EBUSY).
991
- // ENOENT is caught explicitly to handle races (dir removed externally).
992
- try {
993
- fs.rmSync(agentDir, { recursive: true });
994
- return { removed: true, error: null };
995
- } catch (e) {
996
- // ENOENT means the directory was already gone — not an error
997
- if (e && e.code === 'ENOENT') {
998
- return { removed: false, error: null };
999
- }
1000
- // Surface real errors (EPERM, EBUSY, etc.) to the caller
1001
- throw e;
1002
- }
1003
- }
1004
-
1005
- module.exports = {
1006
- getAgentsDir,
1007
- setAgentsDir,
1008
- resetAgentsDir,
1009
- STATES,
1010
- sanitizeName,
1011
- getAgentDir,
1012
- getStatusPath,
1013
- isProcessAlive,
1014
- claimAgent,
1015
- releaseAgent,
1016
- writeStatus,
1017
- readStatus,
1018
- isAgentAlive,
1019
- getAgentServerUrl,
1020
- listAgents,
1021
- formatUptime,
1022
- resolveAgentName,
1023
- stopAgent,
1024
- removeAgent,
1025
- getAgentLogFile,
1026
- getInboxDir,
1027
- writeInboxMessage,
1028
- inboxHasMessages,
1029
- drainInbox,
1030
- deleteInboxFiles,
1031
- formatInboxMessages,
1032
- getInterruptPath,
1033
- writeInterrupt,
1034
- hasInterrupt,
1035
- readInterrupt,
1036
- clearInterrupt,
1037
- };