@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.
- package/LICENSE +21 -0
- package/README.md +21 -218
- package/package.json +32 -35
- package/pave.js +3 -0
- package/sandbox/SandboxRunner.js +1 -0
- package/sandbox/pave-run.js +2 -0
- package/sandbox/permission.js +1 -0
- package/sandbox/utils/yaml.js +1 -0
- package/MARKETPLACE.md +0 -406
- package/build-binary.js +0 -591
- package/build-npm.js +0 -537
- package/build.js +0 -230
- package/check-binary.js +0 -26
- package/deploy.sh +0 -95
- package/index.js +0 -5776
- package/lib/agent-registry.js +0 -1037
- package/lib/args-parser.js +0 -837
- package/lib/blessed-widget-patched.js +0 -93
- package/lib/cli-markdown.js +0 -590
- package/lib/compaction.js +0 -153
- package/lib/duration.js +0 -94
- package/lib/hash.js +0 -22
- package/lib/marketplace.js +0 -866
- package/lib/memory-config.js +0 -166
- package/lib/skill-manager.js +0 -891
- package/lib/soul.js +0 -31
- package/lib/tool-output-formatter.js +0 -180
- package/start-pave.sh +0 -149
- package/status.js +0 -271
- package/test/abort-stream.test.js +0 -445
- package/test/agent-auto-compaction.test.js +0 -552
- package/test/agent-comm-abort.test.js +0 -95
- package/test/agent-comm.test.js +0 -598
- package/test/agent-inbox.test.js +0 -576
- package/test/agent-init.test.js +0 -264
- package/test/agent-interrupt.test.js +0 -314
- package/test/agent-lifecycle.test.js +0 -520
- package/test/agent-log-files.test.js +0 -349
- package/test/agent-mode.manual-test.js +0 -392
- package/test/agent-parsing.test.js +0 -228
- package/test/agent-post-stream-idle.test.js +0 -762
- package/test/agent-registry.test.js +0 -359
- package/test/agent-rm.test.js +0 -442
- package/test/agent-spawn.test.js +0 -933
- package/test/agent-status-api.test.js +0 -624
- package/test/agent-update.test.js +0 -435
- package/test/args-parser.test.js +0 -391
- package/test/auto-compaction-chat.manual-test.js +0 -227
- package/test/auto-compaction.test.js +0 -941
- package/test/build-config.test.js +0 -120
- package/test/build-npm.test.js +0 -388
- package/test/chat-command.test.js +0 -137
- package/test/chat-leading-lines.test.js +0 -159
- package/test/config-flag.test.js +0 -272
- package/test/cursor-drift.test.js +0 -135
- package/test/debug-require.js +0 -23
- package/test/dir-migration.test.js +0 -323
- package/test/duration.test.js +0 -229
- package/test/ghostty-term.test.js +0 -202
- package/test/http500-backoff.test.js +0 -854
- package/test/integration.test.js +0 -86
- package/test/memory-guard-env.test.js +0 -220
- package/test/pr233-fixes.test.js +0 -259
- package/test/run-agent-init.js +0 -297
- package/test/run-all.js +0 -64
- package/test/run-config-flag.js +0 -159
- package/test/run-cursor-drift.js +0 -82
- package/test/run-session-path.js +0 -154
- package/test/run-tests.js +0 -643
- package/test/sandbox-redirect.test.js +0 -202
- package/test/session-path.test.js +0 -132
- package/test/shebang-strip.test.js +0 -241
- package/test/soul-reinject.test.js +0 -1027
- package/test/soul-reread.test.js +0 -281
- package/test/tool-output-formatter.test.js +0 -486
- package/test/tool-output-gating.test.js +0 -143
- package/test/tool-states.test.js +0 -167
- package/test/tools-flag.test.js +0 -65
- package/test/tui-attach.test.js +0 -1255
- package/test/tui-compaction.test.js +0 -354
- package/test/tui-wrap.test.js +0 -568
- package/test-binary.js +0 -52
- package/test-binary2.js +0 -36
package/lib/agent-registry.js
DELETED
|
@@ -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
|
-
};
|