arisa 5.1.12 → 5.1.24

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.
@@ -43,6 +43,13 @@ function normalizeLimit(limit) {
43
43
  return Math.min(value, 100);
44
44
  }
45
45
 
46
+ function normalizeAcknowledgement(value) {
47
+ if (value == null || value === "") return "";
48
+ const acknowledgement = requireString(value, "acknowledgement").trim();
49
+ if (acknowledgement.length > 500) throw new Error("acknowledgement must be at most 500 characters");
50
+ return acknowledgement;
51
+ }
52
+
46
53
  export function createArisaCapabilities({
47
54
  artifactStore,
48
55
  taskStore,
@@ -206,7 +213,11 @@ export function createArisaCapabilities({
206
213
  const resourceId = String(params.resourceId || "").trim();
207
214
  return taskStore.add({
208
215
  kind: "agent_event",
209
- payload: { prompt: requireString(params.prompt, "prompt"), resourceId }
216
+ payload: {
217
+ prompt: requireString(params.prompt, "prompt"),
218
+ resourceId,
219
+ acknowledgement: normalizeAcknowledgement(params.acknowledgement)
220
+ }
210
221
  }, {
211
222
  payload: { chatId: scopedChatId },
212
223
  source: { type: "tool", toolName: scopedToolName, chatId: scopedChatId, resourceId }
@@ -135,6 +135,7 @@ export async function createApp({ logger, runtimeOverrides, requestRestart } = {
135
135
  toolProcessSupervisor,
136
136
  daemonPolicy: config.daemons,
137
137
  doctorPolicy: config.doctor,
138
+ inspectToolDependencies: async () => toolRegistry.dependencyIssues(),
138
139
  inspectInfrastructure: async () => {
139
140
  const tool = toolRegistry.get("master-slave");
140
141
  if (!tool) return null;
@@ -87,16 +87,43 @@ function daemonLabel(record) {
87
87
  return `${record.toolName} (${record.instanceId || "global"})`;
88
88
  }
89
89
 
90
- function daemonResultSummary(results) {
91
- const states = new Map();
90
+ function daemonState(result) {
91
+ return result.diagnostic?.state || result.outcome;
92
+ }
93
+
94
+ function daemonStateLabel(state) {
95
+ return String(state || "unknown")
96
+ .split("-")
97
+ .map((part) => part ? `${part[0].toUpperCase()}${part.slice(1)}` : part)
98
+ .join(" ");
99
+ }
100
+
101
+ function daemonReportLines(results) {
102
+ const priority = ["ready", "starting", "degraded", "unhealthy", "restarting", "stopped", "failed"];
103
+ const groups = new Map();
92
104
  for (const result of results) {
93
- const state = result.diagnostic?.state || result.outcome;
94
- states.set(state, (states.get(state) || 0) + 1);
105
+ const state = daemonState(result);
106
+ if (!groups.has(state)) groups.set(state, []);
107
+ groups.get(state).push(result);
95
108
  }
96
- return [...states.entries()]
97
- .sort(([left], [right]) => left.localeCompare(right))
98
- .map(([state, count]) => `${count} ${state}`)
99
- .join(", ");
109
+ const states = [...groups.keys()].sort((left, right) => {
110
+ const leftIndex = priority.indexOf(left);
111
+ const rightIndex = priority.indexOf(right);
112
+ if (leftIndex === -1 && rightIndex === -1) return left.localeCompare(right);
113
+ if (leftIndex === -1) return 1;
114
+ if (rightIndex === -1) return -1;
115
+ return leftIndex - rightIndex;
116
+ });
117
+ const lines = [];
118
+ for (const state of states) {
119
+ const group = groups.get(state);
120
+ lines.push(` ${daemonStateLabel(state)} (${group.length})`);
121
+ for (const result of group.sort((left, right) => daemonLabel(left.record).localeCompare(daemonLabel(right.record)))) {
122
+ const scope = result.record.scope?.type || (result.record.instanceId === "global" ? "global" : "chat");
123
+ lines.push(...wrapReportText(`${result.record.toolName} [${scope}]`, { firstPrefix: " - ", nextPrefix: " " }));
124
+ }
125
+ }
126
+ return lines;
100
127
  }
101
128
 
102
129
  function formatTokenCount(tokens) {
@@ -123,6 +150,24 @@ function formatUptime(seconds) {
123
150
  return [days ? `${days}d` : "", hours ? `${hours}h` : "", `${minutes}m`].filter(Boolean).join(" ");
124
151
  }
125
152
 
153
+ function masterSlaveMode(infrastructure) {
154
+ const parts = [infrastructure.role || "unknown", infrastructure.daemon?.state || "unknown"];
155
+ if (infrastructure.paired != null) parts.push(infrastructure.paired ? "paired" : "unpaired");
156
+ return parts.join(" · ");
157
+ }
158
+
159
+ function masterSlaveActivity(jobs) {
160
+ const active = jobs?.active;
161
+ const queued = jobs?.queued;
162
+ const failed = jobs?.failed;
163
+ if ([active, queued, failed].every((value) => value === 0)) return "idle";
164
+ return `${active ?? "?"} active · ${queued ?? "?"} queued · ${failed ?? "?"} failed`;
165
+ }
166
+
167
+ function compactEndpoint(endpoint) {
168
+ return String(endpoint || "not configured").replace(/^tcp:\/\//, "");
169
+ }
170
+
126
171
  export async function inspectSystemResources({ diskPath = arisaHomeDir } = {}) {
127
172
  const [load1, load5, load15] = os.loadavg();
128
173
  const memoryTotal = os.totalmem();
@@ -221,22 +266,18 @@ export function formatDoctorReport(report) {
221
266
  lines.push(...reportRow("Large", large));
222
267
  lines.push(...reportRow("Ineff.", inefficient));
223
268
  if (measured.length) lines.push(...reportRow("Max", `${Math.max(...measured.map((context) => context.percent)).toFixed(1)}%`));
224
- lines.push("", "Daemons");
225
- lines.push(...reportRow("Checked", report.daemons.length));
226
- if (report.daemons.length) lines.push(...reportRow("Status", daemonResultSummary(report.daemons)));
269
+ lines.push("", `Daemons (${report.daemons.length})`);
270
+ if (report.daemons.length) lines.push(...daemonReportLines(report.daemons));
227
271
  if (report.infrastructure) {
228
272
  lines.push("", "Master/Slave");
229
273
  if (report.infrastructure.error) {
230
274
  lines.push(...reportRow("Status", `unavailable: ${report.infrastructure.error}`));
231
275
  } else {
232
- lines.push(...reportRow("Role", report.infrastructure.role || "unknown"));
233
- lines.push(...reportRow("Daemon", report.infrastructure.daemon?.state || "unknown"));
234
- lines.push(...reportRow("Endpoint", report.infrastructure.endpoint || "not configured"));
235
- lines.push(...reportRow("Identity", report.infrastructure.identityFingerprint || "not configured"));
236
- lines.push(...reportRow("Paired", report.infrastructure.paired == null ? "n/a" : report.infrastructure.paired ? "yes" : "no"));
276
+ lines.push(...reportRow("Mode", masterSlaveMode(report.infrastructure)));
277
+ lines.push(...reportRow("Endpoint", compactEndpoint(report.infrastructure.endpoint)));
278
+ lines.push(...reportRow("Activity", masterSlaveActivity(report.infrastructure.jobs)));
237
279
  lines.push(...reportRow("Tools", report.infrastructure.toolCount ?? "unknown"));
238
- lines.push(...reportRow("Jobs", `active=${report.infrastructure.jobs?.active ?? "unknown"}, queued=${report.infrastructure.jobs?.queued ?? "unknown"}, failed=${report.infrastructure.jobs?.failed ?? "unknown"}`));
239
- lines.push(...reportRow("Pending secrets", report.infrastructure.pendingSecrets ?? "unknown"));
280
+ lines.push(...reportRow("Secrets", `${report.infrastructure.pendingSecrets ?? "unknown"} pending`));
240
281
  }
241
282
  }
242
283
  if (report.system) {
@@ -273,7 +314,8 @@ export async function runDoctor({
273
314
  stopDaemon = stopManagedDaemon,
274
315
  unregisterDaemon = unregisterManagedDaemon,
275
316
  inspectResources = inspectSystemResources,
276
- inspectInfrastructure = null
317
+ inspectInfrastructure = null,
318
+ inspectToolDependencies = null
277
319
  }) {
278
320
  assertDoctorPolicy(doctorPolicy);
279
321
  const runtime = await agentManager.getRuntimeDiagnostic({
@@ -290,6 +332,16 @@ export async function runDoctor({
290
332
  infrastructure: null
291
333
  };
292
334
  addContextAttention(report);
335
+ if (inspectToolDependencies) {
336
+ try {
337
+ for (const issue of await inspectToolDependencies()) {
338
+ const installed = issue.installedVersion ? `; installed ${issue.installedVersion}` : "";
339
+ report.attention.push(`Tool dependency ${issue.type}: ${issue.tool} requires ${issue.dependency}@${issue.range || "valid"}${installed}.`);
340
+ }
341
+ } catch (error) {
342
+ report.attention.push(`Tool dependency inspection failed: ${error?.message || error}`);
343
+ }
344
+ }
293
345
  if (inspectInfrastructure) {
294
346
  try {
295
347
  report.infrastructure = await inspectInfrastructure();
@@ -1,18 +1,33 @@
1
1
  import { renderTextReport } from "./report-format.js";
2
2
 
3
- export function formatToolUsageReport(tools) {
4
- const lines = ["Arisa tools", "===========", "Usage count"];
5
- if (!tools.length) lines.push(" (none installed)");
6
-
7
- const sortedTools = [...tools].sort((left, right) =>
3
+ function sortedTools(tools) {
4
+ return [...tools].sort((left, right) =>
8
5
  Number(right.count) - Number(left.count) || String(left.name).localeCompare(String(right.name))
9
6
  );
10
- const nameWidth = Math.max(0, ...sortedTools.map((tool) => String(tool.name).length));
11
- const countWidth = Math.max(1, ...sortedTools.map((tool) => String(tool.count).length));
12
- for (const tool of sortedTools) {
7
+ }
8
+
9
+ function usageRows(tools, nameWidth, countWidth) {
10
+ if (!tools.length) return [" (none)"];
11
+ return sortedTools(tools).map((tool) => {
13
12
  const name = String(tool.name).padEnd(nameWidth);
14
13
  const count = String(tool.count).padStart(countWidth);
15
- lines.push(`- ${name} ${count}`);
16
- }
14
+ return `- ${name} ${count}`;
15
+ });
16
+ }
17
+
18
+ export function formatToolUsageReport(tools) {
19
+ const official = tools.filter((tool) => tool.official);
20
+ const local = tools.filter((tool) => !tool.official);
21
+ const nameWidth = Math.max(0, ...tools.map((tool) => String(tool.name).length));
22
+ const countWidth = Math.max(1, ...tools.map((tool) => String(tool.count).length));
23
+ const lines = [
24
+ "Arisa tools",
25
+ "===========",
26
+ "Official",
27
+ ...usageRows(official, nameWidth, countWidth),
28
+ "",
29
+ "Local",
30
+ ...usageRows(local, nameWidth, countWidth)
31
+ ];
17
32
  return renderTextReport(lines);
18
33
  }
@@ -354,21 +354,23 @@ function buildStartupMessage(chatMeta = {}) {
354
354
  }
355
355
 
356
356
  export async function collectText(session, prompt, { logger, chatId, onSlowPrompt } = {}) {
357
- let text = "";
357
+ const assistantMessages = [];
358
+ let assistantMessage = "";
358
359
  let assistantErrorMessage = "";
359
- let shouldSeparateAssistantMessage = false;
360
360
  let slowPromptTimer = null;
361
+ const finishAssistantMessage = () => {
362
+ if (assistantMessage && !isSilentReply(assistantMessage)) {
363
+ assistantMessages.push(assistantMessage);
364
+ }
365
+ assistantMessage = "";
366
+ };
361
367
  const unsubscribe = session.subscribe((event) => {
362
368
  if (event.arisaPromptScoped === false) return;
363
369
  if (event.type === "message_start" && event.message.role === "assistant") {
364
- shouldSeparateAssistantMessage = text.trim().length > 0;
370
+ finishAssistantMessage();
365
371
  }
366
372
  if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
367
- if (shouldSeparateAssistantMessage && event.assistantMessageEvent.delta) {
368
- text += "\n\n";
369
- shouldSeparateAssistantMessage = false;
370
- }
371
- text += event.assistantMessageEvent.delta;
373
+ assistantMessage += event.assistantMessageEvent.delta;
372
374
  }
373
375
  if (event.type === "message_end" && event.message?.role === "assistant") {
374
376
  if (event.message.stopReason === "error") {
@@ -377,6 +379,7 @@ export async function collectText(session, prompt, { logger, chatId, onSlowPromp
377
379
  // Auto-compaction and retry can emit a transient error before a successful continuation.
378
380
  assistantErrorMessage = "";
379
381
  }
382
+ finishAssistantMessage();
380
383
  }
381
384
  const logMessage = sessionEventLogMessage(event);
382
385
  if (logMessage) logger?.log("agent", `chat ${chatId} ${logMessage}`);
@@ -402,7 +405,8 @@ export async function collectText(session, prompt, { logger, chatId, onSlowPromp
402
405
  throw new Error(assistantErrorMessage);
403
406
  }
404
407
 
405
- return text.trim();
408
+ finishAssistantMessage();
409
+ return assistantMessages.join("\n\n").trim();
406
410
  }
407
411
 
408
412
  export function isSilentReply(text) {
@@ -431,16 +435,30 @@ function sanitizeSessionHandoff(text) {
431
435
  return `${sanitized.slice(0, 3997).trim()}...`;
432
436
  }
433
437
 
434
- async function withTyping(ctx, work) {
435
- await ctx.api.sendChatAction(ctx.chat.id, "typing");
438
+ export async function startTelegramTyping(ctx) {
439
+ await ctx.api.sendChatAction(ctx.chat.id, "typing").catch(() => {});
436
440
  const timer = setInterval(() => {
437
441
  ctx.api.sendChatAction(ctx.chat.id, "typing").catch(() => {});
438
442
  }, 4000);
443
+ return () => clearInterval(timer);
444
+ }
445
+
446
+ export async function ensureQueuedTelegramTyping(chatState, ctx) {
447
+ if (chatState.stopQueuedTyping) return;
448
+ chatState.stopQueuedTyping = await startTelegramTyping(ctx);
449
+ }
450
+
451
+ export function stopQueuedTelegramTyping(chatState) {
452
+ chatState.stopQueuedTyping?.();
453
+ chatState.stopQueuedTyping = null;
454
+ }
439
455
 
456
+ async function withTyping(ctx, work) {
457
+ const stopTyping = await startTelegramTyping(ctx);
440
458
  try {
441
459
  return await work();
442
460
  } finally {
443
- clearInterval(timer);
461
+ stopTyping();
444
462
  }
445
463
  }
446
464
 
@@ -456,7 +474,8 @@ export function createChatStateStore() {
456
474
  beforeNextPrompt: null,
457
475
  activeSession: null,
458
476
  activeSteers: [],
459
- assistantMessages: new Map()
477
+ assistantMessages: new Map(),
478
+ stopQueuedTyping: null
460
479
  };
461
480
  states.set(String(chatId), state);
462
481
  return state;
@@ -555,6 +574,7 @@ export async function drainChatPromptQueue({
555
574
  chatState.continueAfterClose = false;
556
575
  }
557
576
  } finally {
577
+ stopQueuedTelegramTyping(chatState);
558
578
  chatState.processing = false;
559
579
  chatState.activeSession = null;
560
580
  chatState.activeSteers = [];
@@ -1065,6 +1085,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1065
1085
  const chatState = getChatState(ctx.chat.id);
1066
1086
 
1067
1087
  if (chatState.processing) {
1088
+ await ensureQueuedTelegramTyping(chatState, ctx);
1068
1089
  const incomingPrompt = await buildIncomingPrompt(ctx);
1069
1090
  const busyMessageMode = typeof ctx.message?.text === "string"
1070
1091
  ? resolveTelegramBusyMessageMode(config, ctx.chat.id)
@@ -1111,6 +1132,13 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1111
1132
  timer.unref?.();
1112
1133
  }
1113
1134
 
1135
+ async function enqueueAsyncPrompt({ chatId, prompt, label }) {
1136
+ const ctx = { chat: { id: chatId }, api: bot.api };
1137
+ const chatState = getChatState(chatId);
1138
+ if (chatState.processing) await ensureQueuedTelegramTyping(chatState, ctx);
1139
+ return enqueuePrompt({ chatId, prompt, label, ctx });
1140
+ }
1141
+
1114
1142
  async function dispatchTask(task) {
1115
1143
  const chatId = task.payload?.chatId;
1116
1144
  if (!chatId) {
@@ -1124,7 +1152,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1124
1152
  return;
1125
1153
  }
1126
1154
  logger?.log("tasks", `running task ${task.id} for chat ${chatId}`);
1127
- await enqueuePrompt({
1155
+ await enqueueAsyncPrompt({
1128
1156
  chatId,
1129
1157
  prompt: await buildAsyncTaskPrompt({ task, artifactStore, toolRegistry, resourceNotes, logger }),
1130
1158
  label: `scheduled task ${task.id}`
@@ -1135,7 +1163,15 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1135
1163
 
1136
1164
  if (task.kind === "agent_event") {
1137
1165
  logger?.log("tasks", `agent event ${task.id} for chat ${chatId}`);
1138
- await enqueuePrompt({
1166
+ const acknowledgement = String(task.payload?.acknowledgement || "").trim();
1167
+ if (acknowledgement) {
1168
+ try {
1169
+ await bot.api.sendMessage(chatId, acknowledgement);
1170
+ } catch (error) {
1171
+ logger?.log("telegram", `agent event acknowledgement failed for chat ${chatId}: ${error instanceof Error ? error.message : String(error)}`);
1172
+ }
1173
+ }
1174
+ await enqueueAsyncPrompt({
1139
1175
  chatId,
1140
1176
  prompt: await buildAsyncEventPrompt(task, resourceNotes),
1141
1177
  label: `agent event ${task.id}`
@@ -142,6 +142,27 @@ test("requires chatId for chat-scoped IPC methods", async () => {
142
142
  }
143
143
  });
144
144
 
145
+ test("agent events preserve a bounded immediate acknowledgement", async () => {
146
+ const capabilities = createCapabilities();
147
+ const created = await capabilities.dispatch({
148
+ method: "agent.enqueueEvent",
149
+ toolName: "browser-session-bridge",
150
+ chatId: "chat-a",
151
+ params: {
152
+ prompt: "Continue the pending authorization flow",
153
+ acknowledgement: "Authorization received. Continuing now."
154
+ }
155
+ });
156
+
157
+ assert.equal(created.payload.acknowledgement, "Authorization received. Continuing now.");
158
+ await assert.rejects(() => capabilities.dispatch({
159
+ method: "agent.enqueueEvent",
160
+ toolName: "browser-session-bridge",
161
+ chatId: "chat-a",
162
+ params: { prompt: "Continue", acknowledgement: "x".repeat(501) }
163
+ }), /at most 500 characters/);
164
+ });
165
+
145
166
  test("delivers only artifacts resolved from the requesting chat", async () => {
146
167
  const artifact = { id: "artifact-1", chatId: "chat-a", path: "/safe/chat-a/file.txt" };
147
168
  const deliveries = [];
@@ -4,13 +4,38 @@ import {
4
4
  collectText,
5
5
  createChatStateStore,
6
6
  drainChatPromptQueue,
7
+ ensureQueuedTelegramTyping,
7
8
  isSilentReply,
8
9
  queueChatPrompt,
9
10
  resolveTelegramBusyMessageMode,
10
- routeBusyPrompt
11
+ routeBusyPrompt,
12
+ stopQueuedTelegramTyping
11
13
  } from "../src/transport/telegram/bot.js";
12
14
  import { selectScheduledTasks } from "../src/core/agent/agent-manager.js";
13
15
 
16
+ test("queued Telegram prompts start typing immediately and share one indicator", async () => {
17
+ let actions = 0;
18
+ const chatState = { stopQueuedTyping: null };
19
+ const ctx = {
20
+ chat: { id: 879964957 },
21
+ api: {
22
+ async sendChatAction(chatId, action) {
23
+ assert.equal(chatId, 879964957);
24
+ assert.equal(action, "typing");
25
+ actions += 1;
26
+ }
27
+ }
28
+ };
29
+
30
+ await ensureQueuedTelegramTyping(chatState, ctx);
31
+ await ensureQueuedTelegramTyping(chatState, ctx);
32
+ assert.equal(actions, 1);
33
+ assert.equal(typeof chatState.stopQueuedTyping, "function");
34
+
35
+ stopQueuedTelegramTyping(chatState);
36
+ assert.equal(chatState.stopQueuedTyping, null);
37
+ });
38
+
14
39
  function createSession(events) {
15
40
  const listeners = new Set();
16
41
  return {
@@ -59,6 +84,44 @@ test("collectText preserves the final assistant error", async () => {
59
84
  await assert.rejects(() => collectText(session, "hello"), /terminal failure/);
60
85
  });
61
86
 
87
+ test("collectText drops a standalone silent event reply before a steered response", async () => {
88
+ const session = createSession([
89
+ { type: "message_start", message: { role: "assistant" } },
90
+ {
91
+ type: "message_update",
92
+ message: { role: "assistant" },
93
+ assistantMessageEvent: { type: "text_delta", delta: "NO_REPLY" }
94
+ },
95
+ { type: "message_end", message: { role: "assistant", stopReason: "stop" } },
96
+ { type: "message_start", message: { role: "assistant" } },
97
+ {
98
+ type: "message_update",
99
+ message: { role: "assistant" },
100
+ assistantMessageEvent: { type: "text_delta", delta: "The useful Telegram response." }
101
+ },
102
+ { type: "message_end", message: { role: "assistant", stopReason: "stop" } }
103
+ ]);
104
+
105
+ assert.equal(await collectText(session, "event followed by a Telegram steer"), "The useful Telegram response.");
106
+ });
107
+
108
+ test("collectText preserves useful text in the same message as a silent marker", async () => {
109
+ const session = createSession([
110
+ { type: "message_start", message: { role: "assistant" } },
111
+ {
112
+ type: "message_update",
113
+ message: { role: "assistant" },
114
+ assistantMessageEvent: { type: "text_delta", delta: "NO_REPLY\n\nThis text belongs to the same response." }
115
+ },
116
+ { type: "message_end", message: { role: "assistant", stopReason: "stop" } }
117
+ ]);
118
+
119
+ assert.equal(
120
+ await collectText(session, "one response that mentions the marker"),
121
+ "NO_REPLY\n\nThis text belongs to the same response."
122
+ );
123
+ });
124
+
62
125
  test("recognizes standalone silent reply markers", () => {
63
126
  assert.equal(isSilentReply("NO_REPLY"), true);
64
127
  assert.equal(isSilentReply("No reply needed."), true);
@@ -96,7 +159,8 @@ test("chat state uses one queue for numeric and string chat IDs", () => {
96
159
  beforeNextPrompt: null,
97
160
  activeSession: null,
98
161
  activeSteers: [],
99
- assistantMessages: new Map()
162
+ assistantMessages: new Map(),
163
+ stopQueuedTyping: null
100
164
  });
101
165
  });
102
166
 
@@ -87,6 +87,61 @@ test("reports Pi context size and retained-content inefficiency", async () => {
87
87
  assert.ok(formatted.split("\n").every((line) => [...line].length <= 35));
88
88
  });
89
89
 
90
+ test("lists each checked daemon with its scope and state", async () => {
91
+ const { report } = await run({
92
+ repairs: [
93
+ {
94
+ record: { toolName: "master-slave", instanceId: "global", scope: { type: "global" } },
95
+ diagnostic: { state: "ready" },
96
+ outcome: "healthy"
97
+ },
98
+ {
99
+ record: { toolName: "context-vault", instanceId: "chat-1", scope: { type: "chat" } },
100
+ diagnostic: { state: "stopped" },
101
+ outcome: "stopped"
102
+ }
103
+ ]
104
+ });
105
+
106
+ report.infrastructure = {
107
+ role: "master",
108
+ daemon: { state: "ready" },
109
+ endpoint: "tcp://198.74.61.48:4719",
110
+ paired: null,
111
+ identityFingerprint: "unnecessarily-long-fingerprint",
112
+ toolCount: 0,
113
+ jobs: { active: 0, queued: 0, failed: 0 },
114
+ pendingSecrets: 0
115
+ };
116
+ const formatted = formatDoctorReport(report);
117
+ assert.match(formatted, /Daemons \(2\)\n Ready \(1\)\n - master-slave \[global\]/);
118
+ assert.match(formatted, / Stopped \(1\)\n - context-vault \[chat\]/);
119
+ assert.match(formatted, /Master\/Slave\n Mode master · ready/);
120
+ assert.match(formatted, /Endpoint 198\.74\.61\.48:4719/);
121
+ assert.match(formatted, /Activity idle/);
122
+ assert.doesNotMatch(formatted, /Identity|fingerprint|Paired/);
123
+ assert.ok(formatted.split("\n").every((line) => [...line].length <= 35));
124
+ });
125
+
126
+ test("reports missing tool dependencies as attention items", async () => {
127
+ const report = await runDoctor({
128
+ agentManager: { getRuntimeDiagnostic: async () => runtime() },
129
+ toolProcessSupervisor: { repair: async () => [] },
130
+ daemonPolicy,
131
+ doctorPolicy,
132
+ listProcesses: async () => [],
133
+ serviceStatus: async () => ({ running: false }),
134
+ inspectResources: async () => system,
135
+ inspectToolDependencies: async () => [{
136
+ tool: "magnific-mcp",
137
+ type: "missing",
138
+ dependency: "mcp-client",
139
+ range: "^0.1.0"
140
+ }]
141
+ });
142
+ assert.match(report.attention.join("\n"), /magnific-mcp requires mcp-client@\^0\.1\.0/);
143
+ });
144
+
90
145
  test("stops only a registered duplicate Arisa service with verified identity", async () => {
91
146
  const duplicatePid = 321;
92
147
  const { report, stopped } = await run({
@@ -0,0 +1,23 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFile } from "node:fs/promises";
3
+ import test from "node:test";
4
+
5
+ async function manifest(name) {
6
+ return JSON.parse(await readFile(new URL(`../../tools/${name}/tool.manifest.json`, import.meta.url), "utf8"));
7
+ }
8
+
9
+ test("official orchestrators declare their hard tool dependencies", async () => {
10
+ assert.deepEqual((await manifest("magnific-mcp")).toolDependencies, { "mcp-client": "^0.1.0" });
11
+ assert.deepEqual((await manifest("campaign-draft-runner")).toolDependencies, {
12
+ "pr-campaign": "^0.1.0",
13
+ "gmail-workspace": "^0.1.0"
14
+ });
15
+ assert.deepEqual((await manifest("x-campaign-runner")).toolDependencies, { "x-dm": "^0.2.0" });
16
+ assert.deepEqual((await manifest("official-tool-sync")).toolDependencies, { trash: "^1.0.0" });
17
+ });
18
+
19
+ test("optional tool integrations do not become hard dependencies", async () => {
20
+ assert.deepEqual((await manifest("whatsapp-web")).toolDependencies, undefined);
21
+ assert.deepEqual((await manifest("pr-campaign")).toolDependencies, undefined);
22
+ assert.deepEqual((await manifest("master-slave")).toolDependencies, undefined);
23
+ });
@@ -3,6 +3,7 @@ import crypto from "node:crypto";
3
3
  import { cp, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
6
7
  import test from "node:test";
7
8
  import {
8
9
  installBundledOfficialTool,
@@ -59,6 +60,18 @@ test("verifies the exact file set and digests", async (t) => {
59
60
  await assert.rejects(() => verifyOfficialToolTree(source, files), /unexpected=extra.js/);
60
61
  });
61
62
 
63
+ test("every bundled official tool lock matches the catalog source", async () => {
64
+ const lock = JSON.parse(await readFile(new URL("../src/official-tools.lock.json", import.meta.url), "utf8"));
65
+ for (const [name, entry] of Object.entries(lock.tools)) {
66
+ const source = fileURLToPath(new URL(`../../tools/${name}/`, import.meta.url));
67
+ assert.deepEqual(
68
+ await verifyOfficialToolTree(source, entry.files),
69
+ { files: Object.keys(entry.files).length },
70
+ name
71
+ );
72
+ }
73
+ });
74
+
62
75
  test("rejects symbolic links before deployment", async (t) => {
63
76
  const { source, files } = await fixture(t);
64
77
  await symlink(path.join(source, "index.js"), path.join(source, "link.js"));
@@ -72,15 +85,18 @@ test("installs a verified staged tree without overwriting an existing tool", asy
72
85
  await mkdir(path.join(checkoutDir, "tools"), { recursive: true });
73
86
  await cp(source, path.join(checkoutDir, "tools", "master-slave"), { recursive: true });
74
87
  };
88
+ const lifecycle = [];
75
89
  const result = await installLockedOfficialTool({
76
90
  toolName: "master-slave",
77
91
  lock: lock(files),
78
92
  destination,
79
93
  scratchRoot: root,
80
94
  checkout,
81
- validate: async () => {}
95
+ installDependencies: async () => { lifecycle.push("dependencies"); },
96
+ validate: async () => { lifecycle.push("validate"); }
82
97
  });
83
98
  assert.equal(result.commit, "a".repeat(40));
99
+ assert.deepEqual(lifecycle, ["dependencies", "validate"]);
84
100
  assert.equal(await readFile(path.join(destination, "index.js"), "utf8"), "process.stdout.write('ok');\n");
85
101
  await assert.rejects(
86
102
  () => installLockedOfficialTool({ toolName: "master-slave", lock: lock(files), destination, scratchRoot: root, checkout }),
@@ -100,8 +116,31 @@ test("loads the bundled lock before selecting the canonical tool destination", a
100
116
  return { installed: true };
101
117
  }
102
118
  });
103
- assert.deepEqual(result, { installed: true });
119
+ assert.deepEqual(result, { installed: true, dependencies: [] });
104
120
  assert.equal(calls[0].toolName, "master-slave");
105
121
  assert.deepEqual(calls[0].lock, lock(files));
106
122
  assert.match(calls[0].destination, /tools\/master-slave$/);
107
123
  });
124
+
125
+ test("installs locked tool dependencies before the requested tool", async (t) => {
126
+ const { root, files } = await fixture(t);
127
+ const dependencyLock = lock(files);
128
+ dependencyLock.tools = {
129
+ "mcp-client": { version: "0.1.0", toolDependencies: {}, files },
130
+ "magnific-mcp": { version: "0.1.0", toolDependencies: { "mcp-client": "^0.1.0" }, files }
131
+ };
132
+ const lockFile = path.join(root, "dependency-lock.json");
133
+ await writeFile(lockFile, `${JSON.stringify(dependencyLock)}\n`);
134
+ const calls = [];
135
+ const result = await installBundledOfficialTool("magnific-mcp", {
136
+ lockFile,
137
+ resolveInstalledVersion: async () => undefined,
138
+ install: async ({ toolName }) => {
139
+ calls.push(toolName);
140
+ return { toolName, installed: true };
141
+ }
142
+ });
143
+ assert.deepEqual(calls, ["mcp-client", "magnific-mcp"]);
144
+ assert.equal(result.toolName, "magnific-mcp");
145
+ assert.deepEqual(result.dependencies, [{ name: "mcp-client", version: "0.1.0", status: "installed" }]);
146
+ });