arisa 5.1.2 → 5.1.8

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 (39) hide show
  1. package/AGENTS.md +12 -4
  2. package/ARISA-MASTER-SLAVE-SPEC.md +844 -0
  3. package/README.md +25 -0
  4. package/package.json +1 -1
  5. package/src/core/agent/agent-manager.js +55 -12
  6. package/src/core/config/config-defaults.js +3 -1
  7. package/src/core/tools/daemon-processes.js +48 -6
  8. package/src/core/tools/daemon-runtime.js +370 -138
  9. package/src/core/tools/ipc-client.js +3 -0
  10. package/src/core/tools/official-tool-catalog.js +32 -0
  11. package/src/core/tools/official-tool-installer.js +183 -0
  12. package/src/core/tools/tool-registry.js +203 -18
  13. package/src/core/tools/tool-resource-note-store.js +78 -0
  14. package/src/index.js +25 -2
  15. package/src/official-tools.lock.json +40 -0
  16. package/src/runtime/arisa-capabilities.js +43 -3
  17. package/src/runtime/create-app.js +9 -0
  18. package/src/runtime/create-headless-app.js +77 -0
  19. package/src/runtime/doctor.js +27 -2
  20. package/src/runtime/headless-tool-executor.js +45 -0
  21. package/src/runtime/paths.js +16 -4
  22. package/src/runtime/secure-request-file.js +21 -0
  23. package/src/runtime/slave-bootstrap-url.js +51 -0
  24. package/src/runtime/slave-cli.js +267 -0
  25. package/src/runtime/slave-service.js +225 -0
  26. package/src/runtime/tool-usage-report.js +11 -3
  27. package/src/transport/telegram/bot.js +37 -7
  28. package/test/capabilities-security.test.js +29 -0
  29. package/test/daemon-catalog-conformance.test.js +3 -1
  30. package/test/daemon-runtime.test.js +58 -2
  31. package/test/official-tool-installer.test.js +107 -0
  32. package/test/paths.test.js +6 -12
  33. package/test/slave-cli.test.js +282 -0
  34. package/test/telegram-text-artifact.test.js +24 -1
  35. package/test/tool-capability-search.test.js +55 -0
  36. package/test/tool-registry-run.test.js +70 -1
  37. package/test/tool-resource-note.test.js +50 -0
  38. package/test/tool-usage.test.js +10 -5
  39. package/test-fixtures/fake-daemon.js +12 -1
@@ -1,8 +1,10 @@
1
1
  import crypto from "node:crypto";
2
- import { mkdir, readdir, rename, unlink } from "node:fs/promises";
2
+ import net from "node:net";
3
+ import { chmod, mkdir, readFile, readdir, rename, rm, unlink } from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  import {
5
6
  daemonPaths,
7
+ ensureDaemonCapability,
6
8
  isProcessAlive,
7
9
  readJson,
8
10
  startManagedDaemon,
@@ -13,6 +15,9 @@ import {
13
15
  import { loadDaemonPolicy } from "./daemon-policy.js";
14
16
 
15
17
  const CONTROL_FIELD = "__daemon";
18
+ export const DAEMON_PROTOCOL_VERSION = 1;
19
+ export const DAEMON_EVENT_TYPES = Object.freeze(["accepted", "progress", "chunk", "completed", "failed"]);
20
+ const TERMINAL_EVENT_TYPES = new Set(["completed", "failed"]);
16
21
 
17
22
  function sleep(ms) {
18
23
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -44,43 +49,193 @@ function jobPaths(paths, id) {
44
49
  };
45
50
  }
46
51
 
47
- async function waitForResult(paths, id, { timeoutMs, intervalMs }) {
48
- const files = jobPaths(paths, id);
52
+ function daemonFrame(jobId, type, sequence, payload = {}) {
53
+ return { version: DAEMON_PROTOCOL_VERSION, jobId, type, sequence, payload };
54
+ }
55
+
56
+ function terminalResult(frame) {
57
+ if (frame.type === "failed") {
58
+ const error = new Error(frame.payload?.error || "Daemon job failed");
59
+ if (frame.payload?.code) error.code = frame.payload.code;
60
+ throw error;
61
+ }
62
+ return frame.payload?.output || {};
63
+ }
64
+
65
+ async function readCapability(paths) {
66
+ const token = (await readFile(paths.capabilityFile, "utf8")).trim();
67
+ if (!token) throw new Error(`Invalid daemon capability for ${paths.toolName}`);
68
+ return token;
69
+ }
70
+
71
+ async function writeSocketFrame(socket, frame, { maxFrameBytes = 1_048_576, streamBufferBytes = 1_048_576 } = {}) {
72
+ if (socket.destroyed || !socket.writable) return false;
73
+ const encoded = `${JSON.stringify(frame)}\n`;
74
+ if (Buffer.byteLength(encoded, "utf8") > maxFrameBytes) {
75
+ throw new Error(`Daemon IPC frame exceeds ${maxFrameBytes} bytes`);
76
+ }
77
+ if (socket.writableLength >= streamBufferBytes) {
78
+ await new Promise((resolve) => {
79
+ const finish = () => {
80
+ socket.off("drain", finish);
81
+ socket.off("close", finish);
82
+ socket.off("error", finish);
83
+ resolve();
84
+ };
85
+ socket.once("drain", finish);
86
+ socket.once("close", finish);
87
+ socket.once("error", finish);
88
+ });
89
+ if (socket.destroyed || !socket.writable) return false;
90
+ }
91
+ if (socket.write(encoded)) return true;
92
+ await new Promise((resolve) => {
93
+ const finish = () => {
94
+ socket.off("drain", finish);
95
+ socket.off("close", finish);
96
+ socket.off("error", finish);
97
+ resolve();
98
+ };
99
+ socket.once("drain", finish);
100
+ socket.once("close", finish);
101
+ socket.once("error", finish);
102
+ });
103
+ return !socket.destroyed;
104
+ }
105
+
106
+ function validateDaemonEvent(frame, { jobId, previousSequence = 0, terminalSeen = false } = {}) {
107
+ if (!frame || frame.version !== DAEMON_PROTOCOL_VERSION || frame.jobId !== jobId) {
108
+ throw new Error("Invalid daemon event identity or protocol version");
109
+ }
110
+ if (!DAEMON_EVENT_TYPES.includes(frame.type)) throw new Error(`Invalid daemon event type: ${frame.type}`);
111
+ if (!Number.isSafeInteger(frame.sequence) || frame.sequence <= previousSequence) {
112
+ throw new Error(`Invalid daemon event sequence for ${jobId}: ${frame.sequence}`);
113
+ }
114
+ if (terminalSeen) throw new Error(`Daemon job ${jobId} emitted more than one terminal event`);
115
+ return { sequence: frame.sequence, terminal: TERMINAL_EVENT_TYPES.has(frame.type) };
116
+ }
117
+
118
+ async function connectDaemon(paths, request, { timeoutMs, onEvent, maxFrameBytes }) {
49
119
  const startedAt = Date.now();
120
+ const token = await readCapability(paths);
121
+ let lastError;
50
122
  while (Date.now() - startedAt < timeoutMs) {
51
- const result = await readJson(files.result, null);
52
- if (result) {
53
- await unlink(files.result).catch(() => {});
54
- if (!result.ok) {
55
- const error = new Error(result.error || `${paths.toolName} daemon job failed`);
56
- if (result.code) error.code = result.code;
57
- throw error;
58
- }
59
- return result.output || {};
123
+ try {
124
+ return await new Promise((resolve, reject) => {
125
+ const socket = net.createConnection(paths.socketFile);
126
+ let buffer = "";
127
+ let sequence = 0;
128
+ let terminalSeen = false;
129
+ let settled = false;
130
+ let observerChain = Promise.resolve();
131
+ const remainingMs = Math.max(1, timeoutMs - (Date.now() - startedAt));
132
+ const timer = setTimeout(() => finish(reject, Object.assign(
133
+ new Error(`${paths.toolName} daemon job timed out after ${timeoutMs}ms`),
134
+ { code: "DAEMON_JOB_TIMEOUT" }
135
+ )), remainingMs);
136
+ timer.unref?.();
137
+
138
+ function finish(fn, value) {
139
+ if (settled) return;
140
+ settled = true;
141
+ clearTimeout(timer);
142
+ socket.destroy();
143
+ fn(value);
144
+ }
145
+
146
+ socket.setEncoding("utf8");
147
+ socket.once("connect", () => {
148
+ socket.write(`${JSON.stringify({
149
+ version: DAEMON_PROTOCOL_VERSION,
150
+ type: "submit",
151
+ jobId: request.jobId,
152
+ capabilityToken: token
153
+ })}\n`);
154
+ });
155
+ socket.on("data", (chunk) => {
156
+ buffer += chunk;
157
+ if (Buffer.byteLength(buffer, "utf8") > maxFrameBytes && !buffer.includes("\n")) {
158
+ finish(reject, new Error(`Daemon IPC frame exceeds ${maxFrameBytes} bytes`));
159
+ return;
160
+ }
161
+ let newlineIndex = buffer.indexOf("\n");
162
+ while (newlineIndex !== -1) {
163
+ const line = buffer.slice(0, newlineIndex).trim();
164
+ buffer = buffer.slice(newlineIndex + 1);
165
+ newlineIndex = buffer.indexOf("\n");
166
+ if (!line) continue;
167
+ let frame;
168
+ try {
169
+ frame = JSON.parse(line);
170
+ const validated = validateDaemonEvent(frame, {
171
+ jobId: request.jobId,
172
+ previousSequence: sequence,
173
+ terminalSeen
174
+ });
175
+ sequence = validated.sequence;
176
+ terminalSeen = validated.terminal;
177
+ } catch (error) {
178
+ finish(reject, error);
179
+ return;
180
+ }
181
+ observerChain = observerChain.then(() => onEvent?.(frame));
182
+ if (terminalSeen) {
183
+ observerChain.then(() => {
184
+ try {
185
+ finish(resolve, terminalResult(frame));
186
+ } catch (error) {
187
+ finish(reject, error);
188
+ }
189
+ }, (error) => finish(reject, error));
190
+ }
191
+ }
192
+ });
193
+ socket.once("error", (error) => finish(reject, error));
194
+ socket.once("close", () => {
195
+ if (!settled) finish(reject, new Error("Daemon IPC connection closed before terminal result"));
196
+ });
197
+ });
198
+ } catch (error) {
199
+ lastError = error;
200
+ if (!["ENOENT", "ECONNREFUSED"].includes(error?.code)) throw error;
201
+ await sleep(Math.min(25, Math.max(1, timeoutMs - (Date.now() - startedAt))));
60
202
  }
61
- await sleep(intervalMs);
62
203
  }
63
- const error = new Error(`${paths.toolName} daemon job timed out after ${timeoutMs}ms`);
64
- error.code = "DAEMON_JOB_TIMEOUT";
204
+ const error = new Error(`${paths.toolName} daemon IPC was unavailable after ${timeoutMs}ms`);
205
+ error.code = lastError?.code || "DAEMON_IPC_UNAVAILABLE";
65
206
  throw error;
66
207
  }
67
208
 
68
- async function enqueue(paths, payload, { control = false, timeoutMs, intervalMs }) {
209
+ async function enqueue(paths, payload, { control = false, timeoutMs, onEvent, jobId, maxFrameBytes } = {}) {
69
210
  await mkdir(paths.commandsDir, { recursive: true });
70
- const id = `${control ? "control" : "job"}-${crypto.randomUUID()}`;
71
- await writeJson(jobPaths(paths, id).request, { id, ...payload });
72
- return waitForResult(paths, id, { timeoutMs, intervalMs });
211
+ const id = jobId || `${control ? "control" : "job"}-${crypto.randomUUID()}`;
212
+ const files = jobPaths(paths, id);
213
+ const existingResult = await readJson(files.result, null);
214
+ if (existingResult?.terminal) {
215
+ await onEvent?.(existingResult.terminal);
216
+ return terminalResult(existingResult.terminal);
217
+ }
218
+ const existingRequest = await readJson(files.request, null);
219
+ const existingAccepted = await readJson(files.processing, null);
220
+ if (!existingRequest && !existingAccepted) {
221
+ await writeJson(files.request, {
222
+ id,
223
+ status: "queued",
224
+ queuedAt: new Date().toISOString(),
225
+ payload
226
+ });
227
+ }
228
+ return connectDaemon(paths, { jobId: id }, { timeoutMs, onEvent, maxFrameBytes });
73
229
  }
74
230
 
75
- export async function submitDaemonControl(record, operation, { timeoutMs } = {}) {
231
+ export async function submitDaemonControl(record, operation, { timeoutMs, onEvent } = {}) {
76
232
  const paths = daemonPaths({ toolName: record.toolName, scope: record.scope });
77
233
  const policy = await loadDaemonPolicy();
78
- return enqueue(paths, {
79
- [CONTROL_FIELD]: { operation }
80
- }, {
234
+ return enqueue(paths, { [CONTROL_FIELD]: { operation } }, {
81
235
  control: true,
82
236
  timeoutMs: timeoutMs ?? policy.healthTimeoutMs,
83
- intervalMs: policy.queuePollIntervalMs
237
+ onEvent,
238
+ maxFrameBytes: policy.ipcFrameBytes || 1_048_576
84
239
  });
85
240
  }
86
241
 
@@ -119,10 +274,7 @@ export function createDaemonRuntime({
119
274
  }
120
275
 
121
276
  async function start() {
122
- return startManagedDaemon({
123
- ...registration,
124
- beforeStart
125
- });
277
+ return startManagedDaemon({ ...registration, beforeStart });
126
278
  }
127
279
 
128
280
  async function stop() {
@@ -148,60 +300,61 @@ export function createDaemonRuntime({
148
300
  const status = await readJson(paths.statusFile, {});
149
301
  const pid = await getPid();
150
302
  if (isDaemonReady(status, pid, policy)) return status;
151
- await submitDaemonControl(registration, "health", {
152
- timeoutMs: timeoutMs ?? policy.healthTimeoutMs
153
- });
303
+ await submitDaemonControl(registration, "health", { timeoutMs: timeoutMs ?? policy.healthTimeoutMs });
154
304
  return waitReady({ timeoutMs: timeoutMs ?? policy.startupTimeoutMs });
155
305
  }
156
306
 
157
307
  async function submit(payload, {
158
308
  timeoutMs,
159
309
  readyTimeoutMs,
160
- requireReady = true
310
+ requireReady = true,
311
+ onEvent,
312
+ jobId
161
313
  } = {}) {
162
314
  const policy = await loadDaemonPolicy();
163
315
  await start();
164
316
  if (requireReady) await ensureReady({ timeoutMs: readyTimeoutMs });
165
317
  return enqueue(paths, payload, {
166
318
  timeoutMs: timeoutMs ?? policy.startupTimeoutMs,
167
- intervalMs: policy.queuePollIntervalMs
319
+ onEvent,
320
+ jobId,
321
+ maxFrameBytes: policy.ipcFrameBytes || 1_048_576
168
322
  });
169
323
  }
170
324
 
171
325
  async function claimNext() {
172
326
  await ensure();
173
- const files = (await readdir(paths.commandsDir))
174
- .filter((file) => file.endsWith(".request.json"))
327
+ const files = await readdir(paths.commandsDir);
328
+ const pending = files
329
+ .filter((file) => file.endsWith(".request.json") || file.endsWith(".processing.json"))
175
330
  .sort((a, b) => {
176
331
  const aControl = a.startsWith("control-") ? 0 : 1;
177
332
  const bControl = b.startsWith("control-") ? 0 : 1;
178
333
  return aControl - bControl || a.localeCompare(b);
179
334
  });
180
- for (const file of files) {
181
- const id = file.replace(/\.request\.json$/, "");
335
+ for (const file of pending) {
336
+ const id = file.replace(/\.(?:request|processing)\.json$/, "");
182
337
  const item = jobPaths(paths, id);
338
+ if (await readJson(item.result, null)) {
339
+ await Promise.all([unlink(item.request).catch(() => {}), unlink(item.processing).catch(() => {})]);
340
+ continue;
341
+ }
183
342
  try {
184
- await rename(item.request, item.processing);
185
- return { id, ...item, payload: await readJson(item.processing, null) };
343
+ if (file.endsWith(".request.json")) await rename(item.request, item.processing);
344
+ const record = await readJson(item.processing, null);
345
+ if (!record) continue;
346
+ const accepted = {
347
+ ...record,
348
+ status: "accepted",
349
+ acceptedAt: record.acceptedAt || new Date().toISOString()
350
+ };
351
+ await writeJson(item.processing, accepted);
352
+ return { id, ...item, payload: accepted.payload };
186
353
  } catch {}
187
354
  }
188
355
  return null;
189
356
  }
190
357
 
191
- async function complete(job, output) {
192
- await writeJson(job.result, { ok: true, output });
193
- await unlink(job.processing).catch(() => {});
194
- }
195
-
196
- async function fail(job, error) {
197
- await writeJson(job.result, {
198
- ok: false,
199
- error: error?.message || String(error),
200
- code: error?.code || null
201
- });
202
- await unlink(job.processing).catch(() => {});
203
- }
204
-
205
358
  async function workLoop({
206
359
  processJob,
207
360
  healthCheck,
@@ -209,17 +362,21 @@ export function createDaemonRuntime({
209
362
  beforeExit = null,
210
363
  idleTimeoutMs = 0
211
364
  }) {
212
- if (typeof healthCheck !== "function") {
213
- throw new Error(`${toolName} daemon must declare healthCheck`);
214
- }
365
+ if (typeof healthCheck !== "function") throw new Error(`${toolName} daemon must declare healthCheck`);
215
366
  const policy = await loadDaemonPolicy();
216
- const intervalMs = policy.queuePollIntervalMs;
367
+ const ipcLimits = {
368
+ maxFrameBytes: policy.ipcFrameBytes || 1_048_576,
369
+ streamBufferBytes: policy.streamBufferBytes || 1_048_576
370
+ };
371
+ const subscribers = new Map();
217
372
  let lastActivity = Date.now();
218
373
  let processing = false;
219
374
  let exiting = false;
220
375
  let acceptingWork = true;
376
+ let processRequested = false;
221
377
 
222
378
  await ensure();
379
+ const capabilityToken = process.env.ARISA_DAEMON_CAPABILITY || await ensureDaemonCapability(paths);
223
380
  await writeStatus({
224
381
  state: "starting",
225
382
  pid: process.pid,
@@ -228,107 +385,182 @@ export function createDaemonRuntime({
228
385
  message: "Daemon work loop started; waiting for health check"
229
386
  });
230
387
 
231
- const heartbeatTimer = setInterval(() => {
232
- writeStatus({ heartbeatAt: new Date().toISOString() }).catch(() => {});
233
- }, policy.heartbeatIntervalMs);
388
+ async function publish(frame) {
389
+ const sockets = [...(subscribers.get(frame.jobId) || [])];
390
+ for (const socket of sockets) {
391
+ if (!(await writeSocketFrame(socket, frame, ipcLimits))) subscribers.get(frame.jobId)?.delete(socket);
392
+ }
393
+ }
234
394
 
235
- const workTimer = setInterval(async () => {
236
- if (processing || exiting || !acceptingWork) return;
237
- processing = true;
395
+ async function persistTerminal(job, frame) {
396
+ await writeJson(job.result, {
397
+ id: job.id,
398
+ status: frame.type,
399
+ completedAt: new Date().toISOString(),
400
+ terminal: frame
401
+ });
402
+ await unlink(job.processing).catch(() => {});
403
+ await publish(frame);
404
+ }
405
+
406
+ async function execute(job) {
407
+ let sequence = 1;
408
+ await publish(daemonFrame(job.id, "accepted", sequence, {}));
409
+ const emit = async (type, payload = {}) => {
410
+ if (!['progress', 'chunk'].includes(type)) throw new Error(`Invalid non-terminal daemon event type: ${type}`);
411
+ sequence += 1;
412
+ await publish(daemonFrame(job.id, type, sequence, payload));
413
+ };
414
+ const operation = job.payload?.[CONTROL_FIELD]?.operation;
238
415
  try {
239
- const job = await claimNext();
240
- if (job) {
241
- const operation = job.payload?.[CONTROL_FIELD]?.operation;
242
- try {
243
- if (operation === "health") {
244
- const checkedAt = new Date().toISOString();
245
- await writeStatus({ lastHealthCheckAt: checkedAt });
246
- const output = await withTimeout(
247
- healthCheck,
248
- policy.healthTimeoutMs,
249
- `${toolName} health check timed out after ${policy.healthTimeoutMs}ms`
250
- );
251
- await writeStatus({
252
- state: "ready",
253
- lastHealthSuccessAt: new Date().toISOString(),
254
- consecutiveHealthFailures: 0,
255
- restartAttempts: 0,
256
- restartRequested: false,
257
- nextRestartAt: null,
258
- message: output?.message || "Daemon health check passed"
259
- });
260
- await complete(job, output || { ok: true });
261
- } else if (operation === "recover") {
262
- const recovered = typeof recover === "function"
263
- ? await withTimeout(
264
- recover,
265
- policy.healthTimeoutMs,
266
- `${toolName} recovery timed out after ${policy.healthTimeoutMs}ms`
267
- )
268
- : false;
269
- await complete(job, { recovered: recovered !== false });
270
- } else {
271
- lastActivity = Date.now();
272
- const output = await processJob(job.payload);
273
- await writeStatus({ lastSuccessfulJobAt: new Date().toISOString() });
274
- await complete(job, output);
275
- lastActivity = Date.now();
276
- }
277
- } catch (error) {
278
- if (error?.code === "DAEMON_OPERATION_TIMEOUT") {
279
- acceptingWork = false;
280
- }
281
- const current = await readJson(paths.statusFile, {});
282
- await writeStatus({
283
- ...(operation === "health"
284
- ? {
285
- state: error?.code === "DAEMON_OPERATION_TIMEOUT" ? "unhealthy" : "degraded",
286
- consecutiveHealthFailures: Number(current.consecutiveHealthFailures || 0) + 1
287
- }
288
- : {}),
289
- lastError: {
290
- at: new Date().toISOString(),
291
- phase: operation || "job",
292
- message: error?.message || String(error),
293
- ...(error?.code ? { code: error.code } : {})
294
- },
295
- message: error?.message || String(error)
296
- });
297
- await fail(job, error);
298
- }
299
- }
300
- if (idleTimeoutMs > 0 && Date.now() - lastActivity > idleTimeoutMs) {
301
- exiting = true;
302
- clearInterval(heartbeatTimer);
303
- clearInterval(workTimer);
304
- if (beforeExit) await beforeExit();
416
+ let output;
417
+ if (operation === "health") {
418
+ const checkedAt = new Date().toISOString();
419
+ await writeStatus({ lastHealthCheckAt: checkedAt });
420
+ output = await withTimeout(
421
+ healthCheck,
422
+ policy.healthTimeoutMs,
423
+ `${toolName} health check timed out after ${policy.healthTimeoutMs}ms`
424
+ );
305
425
  await writeStatus({
306
- state: "stopped",
426
+ state: "ready",
427
+ lastHealthSuccessAt: new Date().toISOString(),
428
+ consecutiveHealthFailures: 0,
429
+ restartAttempts: 0,
307
430
  restartRequested: false,
308
431
  nextRestartAt: null,
309
- message: "Idle timeout reached"
432
+ message: output?.message || "Daemon health check passed"
310
433
  });
311
- process.exit(0);
434
+ output ||= { ok: true };
435
+ } else if (operation === "recover") {
436
+ const recovered = typeof recover === "function"
437
+ ? await withTimeout(recover, policy.healthTimeoutMs, `${toolName} recovery timed out after ${policy.healthTimeoutMs}ms`)
438
+ : false;
439
+ output = { recovered: recovered !== false };
440
+ } else {
441
+ lastActivity = Date.now();
442
+ output = await processJob(job.payload, { emit, jobId: job.id });
443
+ await writeStatus({ lastSuccessfulJobAt: new Date().toISOString() });
444
+ lastActivity = Date.now();
312
445
  }
446
+ sequence += 1;
447
+ await persistTerminal(job, daemonFrame(job.id, "completed", sequence, { output }));
313
448
  } catch (error) {
449
+ if (error?.code === "DAEMON_OPERATION_TIMEOUT") acceptingWork = false;
450
+ const current = await readJson(paths.statusFile, {});
314
451
  await writeStatus({
315
- state: "degraded",
452
+ ...(operation === "health" ? {
453
+ state: error?.code === "DAEMON_OPERATION_TIMEOUT" ? "unhealthy" : "degraded",
454
+ consecutiveHealthFailures: Number(current.consecutiveHealthFailures || 0) + 1
455
+ } : {}),
316
456
  lastError: {
317
457
  at: new Date().toISOString(),
318
- phase: "work-loop",
458
+ phase: operation || "job",
319
459
  message: error?.message || String(error),
320
460
  ...(error?.code ? { code: error.code } : {})
321
461
  },
322
462
  message: error?.message || String(error)
323
463
  });
464
+ sequence += 1;
465
+ await persistTerminal(job, daemonFrame(job.id, "failed", sequence, {
466
+ error: error?.message || String(error),
467
+ code: error?.code || null
468
+ }));
469
+ }
470
+ }
471
+
472
+ async function processQueue() {
473
+ if (processing || exiting || !acceptingWork) {
474
+ processRequested = true;
475
+ return;
476
+ }
477
+ processing = true;
478
+ try {
479
+ do {
480
+ processRequested = false;
481
+ const job = await claimNext();
482
+ if (!job) break;
483
+ await execute(job);
484
+ } while (!exiting && acceptingWork);
485
+ } catch (error) {
486
+ await writeStatus({
487
+ state: "degraded",
488
+ lastError: { at: new Date().toISOString(), phase: "work-loop", message: error?.message || String(error), ...(error?.code ? { code: error.code } : {}) },
489
+ message: error?.message || String(error)
490
+ });
324
491
  } finally {
325
492
  processing = false;
493
+ if (processRequested && !exiting && acceptingWork) queueMicrotask(() => processQueue().catch(() => {}));
326
494
  }
327
- }, intervalMs);
495
+ }
496
+
497
+ if (process.platform !== "win32") await rm(paths.socketFile, { force: true });
498
+ const server = net.createServer((socket) => {
499
+ socket.setEncoding("utf8");
500
+ let buffer = "";
501
+ const subscribedJobs = new Set();
502
+ const removeSocket = () => {
503
+ for (const jobId of subscribedJobs) subscribers.get(jobId)?.delete(socket);
504
+ };
505
+ socket.on("data", (chunk) => {
506
+ buffer += chunk;
507
+ if (Buffer.byteLength(buffer, "utf8") > ipcLimits.maxFrameBytes && !buffer.includes("\n")) {
508
+ socket.destroy();
509
+ return;
510
+ }
511
+ let newlineIndex = buffer.indexOf("\n");
512
+ while (newlineIndex !== -1) {
513
+ const line = buffer.slice(0, newlineIndex).trim();
514
+ buffer = buffer.slice(newlineIndex + 1);
515
+ newlineIndex = buffer.indexOf("\n");
516
+ if (!line) continue;
517
+ let notification;
518
+ try {
519
+ notification = JSON.parse(line);
520
+ } catch {
521
+ socket.destroy();
522
+ return;
523
+ }
524
+ if (notification.version !== DAEMON_PROTOCOL_VERSION
525
+ || notification.type !== "submit"
526
+ || typeof notification.jobId !== "string"
527
+ || notification.capabilityToken !== capabilityToken) {
528
+ socket.destroy();
529
+ return;
530
+ }
531
+ const { jobId } = notification;
532
+ if (!subscribers.has(jobId)) subscribers.set(jobId, new Set());
533
+ subscribers.get(jobId).add(socket);
534
+ subscribedJobs.add(jobId);
535
+ readJson(jobPaths(paths, jobId).result, null).then((result) => {
536
+ if (result?.terminal) return writeSocketFrame(socket, result.terminal, ipcLimits);
537
+ return processQueue();
538
+ }).catch(() => socket.destroy());
539
+ }
540
+ });
541
+ socket.once("close", removeSocket);
542
+ socket.once("error", removeSocket);
543
+ });
544
+ await new Promise((resolve, reject) => {
545
+ server.once("error", reject);
546
+ server.listen(paths.socketFile, resolve);
547
+ });
548
+ if (process.platform !== "win32") await chmod(paths.socketFile, 0o600);
549
+
550
+ const heartbeatTimer = setInterval(() => {
551
+ writeStatus({ heartbeatAt: new Date().toISOString() }).catch(() => {});
552
+ }, policy.heartbeatIntervalMs);
553
+ const idleTimer = idleTimeoutMs > 0 ? setInterval(async () => {
554
+ if (processing || exiting || Date.now() - lastActivity <= idleTimeoutMs) return;
555
+ exiting = true;
556
+ clearInterval(heartbeatTimer);
557
+ clearInterval(idleTimer);
558
+ await beforeExit?.();
559
+ await writeStatus({ state: "stopped", restartRequested: false, nextRestartAt: null, message: "Idle timeout reached" });
560
+ server.close(() => process.exit(0));
561
+ }, Math.min(idleTimeoutMs, 1_000)) : null;
328
562
 
329
- submitDaemonControl(registration, "health", {
330
- timeoutMs: policy.healthTimeoutMs
331
- }).catch(() => {});
563
+ processQueue().catch(() => {});
332
564
  }
333
565
 
334
566
  return {
@@ -97,6 +97,9 @@ export function createArisaClient({
97
97
  help: (params) => call("tools.help", params),
98
98
  skills: (params) => call("tools.skills", params),
99
99
  setConfig: (params) => call("tools.setConfig", params),
100
+ setResourceNote: (params) => call("tools.setResourceNote", params),
101
+ getResourceNote: (params) => call("tools.getResourceNote", params),
102
+ installOfficial: (params, options) => call("tools.installOfficial", params, options),
100
103
  run: (params, options) => call("tools.run", params, options)
101
104
  },
102
105
  paths: {
@@ -0,0 +1,32 @@
1
+ import { rankToolMatches } from "./tool-registry.js";
2
+
3
+ const catalogApi = "https://api.github.com/repos/clasen/Arisa/contents/tools";
4
+ const rawBase = "https://raw.githubusercontent.com/clasen/Arisa/main/tools";
5
+
6
+ async function fetchJson(fetchImpl, url) {
7
+ const response = await fetchImpl(url, { headers: { Accept: "application/vnd.github+json" } });
8
+ if (!response.ok) throw new Error(`Official catalog request failed (${response.status})`);
9
+ return response.json();
10
+ }
11
+
12
+ export async function searchOfficialToolCatalog(query, { fetchImpl = fetch } = {}) {
13
+ const entries = await fetchJson(fetchImpl, catalogApi);
14
+ const directories = entries.filter((entry) => entry.type === "dir" && entry.name);
15
+ const manifests = (await Promise.all(directories.map(async ({ name }) => {
16
+ try {
17
+ return await fetchJson(fetchImpl, `${rawBase}/${encodeURIComponent(name)}/tool.manifest.json`);
18
+ } catch {
19
+ return null;
20
+ }
21
+ }))).filter(Boolean);
22
+ return rankToolMatches(manifests, query).map(({ tool, score }) => ({
23
+ name: tool.name,
24
+ description: tool.description,
25
+ input: tool.input,
26
+ output: tool.output,
27
+ category: tool.category || null,
28
+ keywords: tool.keywords || [],
29
+ score,
30
+ source: "official-catalog"
31
+ }));
32
+ }