arisa 5.2.17 → 5.2.20

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 (47) hide show
  1. package/LOW-MEMORY.md +37 -0
  2. package/README.md +6 -3
  3. package/package.json +3 -3
  4. package/pnpm-workspace.yaml +7 -5
  5. package/src/core/agent/agent-manager.js +13 -14
  6. package/src/core/agent/auth-flow.js +6 -6
  7. package/src/core/agent/model-selection.js +4 -3
  8. package/src/core/agent/model-speed.js +13 -3
  9. package/src/core/agent/pi-auth-login.js +28 -28
  10. package/src/core/agent/pi-capability-tools.js +1 -0
  11. package/src/core/agent/pi-runtime.js +14 -21
  12. package/src/core/artifacts/artifact-index.js +107 -0
  13. package/src/core/artifacts/artifact-store.js +15 -84
  14. package/src/core/artifacts/legacy-artifact-reader.js +46 -0
  15. package/src/core/capabilities/capability-service.js +25 -0
  16. package/src/core/tasks/task-store.js +2 -1
  17. package/src/index.js +10 -4
  18. package/src/official-tools.lock.json +25 -18
  19. package/src/platform/paths.js +5 -0
  20. package/src/runtime/bootstrap-cli.js +3 -3
  21. package/src/runtime/bootstrap-telegram.js +7 -7
  22. package/src/runtime/slave-cli.js +20 -10
  23. package/src/runtime/slave-service.js +299 -7
  24. package/src/runtime/tui.js +5 -6
  25. package/src/transport/telegram/bot.js +9 -5
  26. package/src/transport/telegram/model-callback.js +3 -2
  27. package/src/transport/telegram/model-controls.js +4 -4
  28. package/src/transport/telegram/model-picker.js +1 -1
  29. package/src/transport/telegram/task-dispatcher.js +50 -23
  30. package/src/transport/telegram/telegram-auth-controller.js +7 -7
  31. package/src/transport/telegram/telegram-session-bridge.js +2 -1
  32. package/test/agent-turn-coordinator.test.js +5 -2
  33. package/test/artifact-index-memory.test.js +46 -0
  34. package/test/artifact-index-migration.test.js +88 -0
  35. package/test/artifact-store.test.js +3 -3
  36. package/test/auth-flow.test.js +2 -2
  37. package/test/capabilities-security.test.js +36 -0
  38. package/test/cli-memory.test.js +22 -0
  39. package/test/model-selection.test.js +14 -4
  40. package/test/official-tool-installer.test.js +13 -0
  41. package/test/paths.test.js +2 -0
  42. package/test/pi-auth-login.test.js +78 -0
  43. package/test/pi-capability-tools.test.js +3 -0
  44. package/test/pi-speed-integration.test.js +177 -0
  45. package/test/slave-cli.test.js +221 -5
  46. package/test/task-store.test.js +3 -1
  47. package/test/telegram-task-dispatcher.test.js +57 -2
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { access, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
2
+ import { access, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import test from "node:test";
@@ -8,9 +8,15 @@ import { parseSlaveBootstrapUrl } from "../src/runtime/slave-bootstrap-url.js";
8
8
  import { withSecureRequestFile } from "../src/runtime/secure-request-file.js";
9
9
  import { ensureMasterSlaveTool, formatSlaveStatus, runSlaveBootstrap, runSlaveCli } from "../src/runtime/slave-cli.js";
10
10
  import {
11
+ buildSlaveLaunchdPlist,
11
12
  buildSlaveSystemdUnit,
13
+ buildSlaveWindowsLauncher,
14
+ buildSlaveWindowsTaskXml,
15
+ controlSlaveService,
12
16
  getSlavePaths,
17
+ installSlaveLaunchdService,
13
18
  installSlaveSystemdService,
19
+ installSlaveWindowsService,
14
20
  registerSlaveServiceProcess,
15
21
  selectSlaveServiceAccount
16
22
  } from "../src/runtime/slave-service.js";
@@ -75,6 +81,14 @@ test("selects the invoking service account without prompting", async () => {
75
81
  await selectSlaveServiceAccount({ euid: 0, currentUser: "root", environment: {} }),
76
82
  { scope: "system", user: "root", root: true, dedicated: false }
77
83
  );
84
+ assert.deepEqual(
85
+ await selectSlaveServiceAccount({
86
+ platform: "win32",
87
+ currentUser: "martin.clasen",
88
+ environment: { USERDOMAIN: "MCL4SEN", USERNAME: "martin.clasen" }
89
+ }),
90
+ { scope: "user", user: "MCL4SEN\\martin.clasen", root: false, dedicated: false }
91
+ );
78
92
  });
79
93
 
80
94
  test("builds a dedicated headless systemd service with isolated state", () => {
@@ -106,7 +120,7 @@ test("escapes systemd WorkingDirectory paths without quoting the entire value",
106
120
 
107
121
  test("reports Master connectivity separately from pairing and daemon readiness", () => {
108
122
  const text = formatSlaveStatus({
109
- systemd: { running: true, status: "active" },
123
+ service: { running: true, status: "active", serviceManager: "launchd" },
110
124
  diagnostic: {
111
125
  daemon: { state: "ready" },
112
126
  role: "slave",
@@ -118,6 +132,7 @@ test("reports Master connectivity separately from pairing and daemon readiness",
118
132
  pendingSecrets: 0
119
133
  }
120
134
  });
135
+ assert.match(text, /Service \(launchd\): active/);
121
136
  assert.match(text, /Daemon: ready/);
122
137
  assert.match(text, /Paired: yes/);
123
138
  assert.match(text, /Connected: no/);
@@ -163,6 +178,207 @@ test("installs and restarts the Linux systemd target after pairing", async (t) =
163
178
  ]);
164
179
  });
165
180
 
181
+ test("builds and installs a macOS launchd service with isolated state", async (t) => {
182
+ const root = await mkdtemp(path.join(os.tmpdir(), "arisa-slave-launchd-"));
183
+ t.after(() => rm(root, { recursive: true, force: true }));
184
+ const account = { scope: "user", user: "tester", root: false, dedicated: false };
185
+ const slaveHome = path.join(root, "home & state");
186
+ const entryFile = "/Applications/Arisa & Tools/index.js";
187
+ const plist = buildSlaveLaunchdPlist({
188
+ account,
189
+ slaveHome,
190
+ entryFile,
191
+ nodePath: "/opt/homebrew/bin/node",
192
+ environment: { PATH: "/opt/homebrew/bin:/usr/bin" }
193
+ });
194
+ assert.match(plist, /<string>com\.arisa\.slave<\/string>/);
195
+ assert.match(plist, /<string>\/Applications\/Arisa &amp; Tools\/index\.js<\/string>/);
196
+ assert.match(plist, /<string>\/opt\/homebrew\/bin:\/usr\/bin<\/string>/);
197
+ assert.match(plist, /<key>RunAtLoad<\/key>\n <true\/>/);
198
+ assert.match(plist, /<key>KeepAlive<\/key>\n <true\/>/);
199
+
200
+ const calls = [];
201
+ const unitDir = path.join(root, "LaunchAgents");
202
+ const result = await installSlaveLaunchdService({
203
+ account,
204
+ slaveHome,
205
+ entryFile,
206
+ execute: async (command, args) => {
207
+ calls.push([command, args]);
208
+ if (args[0] === "bootout") throw new Error("not loaded");
209
+ return { stdout: "", stderr: "" };
210
+ },
211
+ environment: { PATH: "/opt/homebrew/bin:/usr/bin" },
212
+ platform: "darwin",
213
+ uid: 501,
214
+ userUnitDir: unitDir
215
+ });
216
+ assert.equal(result.serviceManager, "launchd");
217
+ assert.equal(result.serviceTarget, "gui/501/com.arisa.slave");
218
+ assert.equal(await access(result.unitFile).then(() => true, () => false), true);
219
+ assert.deepEqual(calls.slice(1), [
220
+ ["launchctl", ["bootstrap", "gui/501", result.unitFile]],
221
+ ["launchctl", ["enable", "gui/501/com.arisa.slave"]],
222
+ ["launchctl", ["kickstart", "-k", "gui/501/com.arisa.slave"]]
223
+ ]);
224
+ });
225
+
226
+ test("controls a persisted macOS launchd service", async (t) => {
227
+ const root = await mkdtemp(path.join(os.tmpdir(), "arisa-slave-launchd-control-"));
228
+ t.after(() => rm(root, { recursive: true, force: true }));
229
+ const paths = getSlavePaths(root);
230
+ await mkdir(paths.state, { recursive: true });
231
+ await writeFile(paths.descriptorFile, `${JSON.stringify({
232
+ serviceManager: "launchd",
233
+ serviceTarget: "gui/501/com.arisa.slave",
234
+ unitFile: "/Users/tester/Library/LaunchAgents/com.arisa.slave.plist"
235
+ })}\n`);
236
+ const calls = [];
237
+ const execute = async (command, args) => {
238
+ calls.push([command, args]);
239
+ return { stdout: "", stderr: "" };
240
+ };
241
+ const status = await controlSlaveService(paths, "status", { execute });
242
+ assert.deepEqual(status, { running: true, status: "active", serviceManager: "launchd" });
243
+ await controlSlaveService(paths, "restart", { execute });
244
+ assert.deepEqual(calls, [
245
+ ["launchctl", ["print", "gui/501/com.arisa.slave"]],
246
+ ["launchctl", ["print", "gui/501/com.arisa.slave"]],
247
+ ["launchctl", ["enable", "gui/501/com.arisa.slave"]],
248
+ ["launchctl", ["kickstart", "-k", "gui/501/com.arisa.slave"]]
249
+ ]);
250
+ });
251
+
252
+ test("builds, installs, and controls a Windows scheduled Slave task", async (t) => {
253
+ const root = await mkdtemp(path.join(os.tmpdir(), "arisa-slave-windows-"));
254
+ t.after(() => rm(root, { recursive: true, force: true }));
255
+ const account = { scope: "user", user: "MCL4SEN\\martin.clasen", root: false, dedicated: false };
256
+ const entryFile = path.join(root, "Arisa & Tools", "index.js");
257
+ const launcher = buildSlaveWindowsLauncher({
258
+ slaveHome: path.join(root, "slave's home"),
259
+ entryFile,
260
+ nodePath: path.join(root, "Node", "node.exe")
261
+ });
262
+ assert.match(launcher, /process\.env\.ARISA_SLAVE_HOME/);
263
+ assert.match(launcher, /slave's home/);
264
+ assert.match(launcher, /"--service-runner"/);
265
+ assert.match(launcher, /windowsHide: true/);
266
+ assert.doesNotThrow(() => new Function(launcher));
267
+
268
+ const taskXml = buildSlaveWindowsTaskXml({
269
+ account,
270
+ launcherFile: path.join(root, "arisa-slave-launcher.cjs"),
271
+ nodePath: path.join(root, "Node", "node.exe")
272
+ });
273
+ assert.match(taskXml, /<UserId>MCL4SEN\\martin\.clasen<\/UserId>/);
274
+ assert.match(taskXml, /<RestartOnFailure>/);
275
+ assert.match(taskXml, /Node\/node\.exe/);
276
+ assert.match(taskXml, /arisa-slave-launcher\.cjs/);
277
+ assert.match(taskXml, /<WorkingDirectory>/);
278
+
279
+ const calls = [];
280
+ const result = await installSlaveWindowsService({
281
+ account,
282
+ slaveHome: path.join(root, "home"),
283
+ entryFile,
284
+ execute: async (command, args) => {
285
+ calls.push([command, args]);
286
+ if (args[0] === "/End") throw new Error("not running");
287
+ return { stdout: "", stderr: "" };
288
+ },
289
+ platform: "win32"
290
+ });
291
+ assert.equal(result.serviceManager, "windows-task");
292
+ assert.equal(result.serviceTarget, "Arisa Slave");
293
+ assert.equal(await access(result.launcherFile).then(() => true, () => false), true);
294
+ assert.equal(await access(result.unitFile).then(() => true, () => false), true);
295
+ const persistedLauncher = await readFile(result.launcherFile);
296
+ assert.deepEqual([...persistedLauncher.subarray(0, 3)], [0xef, 0xbb, 0xbf]);
297
+ const persistedTask = await readFile(result.unitFile);
298
+ assert.deepEqual([...persistedTask.subarray(0, 2)], [0xff, 0xfe]);
299
+ assert.match(persistedTask.toString("utf16le"), /<Task version="1\.4"/);
300
+ assert.deepEqual(calls.slice(1), [
301
+ ["schtasks.exe", ["/Create", "/TN", "Arisa Slave", "/XML", result.unitFile, "/F"]],
302
+ ["schtasks.exe", ["/Run", "/TN", "Arisa Slave"]]
303
+ ]);
304
+
305
+ const paths = result.paths;
306
+ await writeFile(paths.descriptorFile, `${JSON.stringify({
307
+ serviceManager: "windows-task",
308
+ serviceTarget: "Arisa Slave",
309
+ unitFile: result.unitFile,
310
+ launcherFile: result.launcherFile
311
+ })}\n`);
312
+ await writeFile(paths.pidFile, `${process.pid}\n`);
313
+ calls.length = 0;
314
+ const status = await controlSlaveService(paths, "status", { execute: async (command, args) => {
315
+ calls.push([command, args]);
316
+ return { stdout: "", stderr: "" };
317
+ } });
318
+ assert.deepEqual(status, { running: true, status: "active", serviceManager: "windows-task" });
319
+ assert.deepEqual(calls, [["schtasks.exe", ["/Query", "/TN", "Arisa Slave"]]]);
320
+ });
321
+
322
+ test("bootstraps macOS through launchd and persists its service identity", async (t) => {
323
+ const home = await mkdtemp(path.join(os.tmpdir(), "arisa-slave-macos-bootstrap-"));
324
+ t.after(() => rm(home, { recursive: true, force: true }));
325
+ const paths = getSlavePaths(home);
326
+ const account = { scope: "user", user: "tester", root: false, dedicated: false };
327
+ const unitFile = "/Users/tester/Library/LaunchAgents/com.arisa.slave.plist";
328
+ await runSlaveBootstrap(`tcp://198.51.100.12:4719/${secret}`, {
329
+ paths,
330
+ entryFile: "/opt/arisa/src/index.js",
331
+ platform: "darwin",
332
+ selectAccount: async () => account,
333
+ ensureTool: async () => {},
334
+ invokeTool: async () => ({ ok: true }),
335
+ installService: async (options) => {
336
+ assert.equal(options.platform, "darwin");
337
+ return {
338
+ serviceManager: "launchd",
339
+ serviceTarget: "gui/501/com.arisa.slave",
340
+ unitFile
341
+ };
342
+ },
343
+ output: { log: () => {} }
344
+ });
345
+ const descriptor = JSON.parse(await readFile(paths.descriptorFile, "utf8"));
346
+ assert.equal(descriptor.serviceManager, "launchd");
347
+ assert.equal(descriptor.serviceTarget, "gui/501/com.arisa.slave");
348
+ assert.equal(descriptor.unitFile, unitFile);
349
+ assert.deepEqual(descriptor.account, account);
350
+ });
351
+
352
+ test("bootstraps Windows through Task Scheduler and persists its service identity", async (t) => {
353
+ const home = await mkdtemp(path.join(os.tmpdir(), "arisa-slave-windows-bootstrap-"));
354
+ t.after(() => rm(home, { recursive: true, force: true }));
355
+ const paths = getSlavePaths(home);
356
+ const account = { scope: "user", user: "MCL4SEN\\martin.clasen", root: false, dedicated: false };
357
+ const launcherFile = path.join(home, "state", "arisa-slave-launcher.cjs");
358
+ await runSlaveBootstrap(`tcp://198.51.100.12:4719/${secret}`, {
359
+ paths,
360
+ entryFile: "/opt/arisa/src/index.js",
361
+ platform: "win32",
362
+ selectAccount: async () => account,
363
+ ensureTool: async () => {},
364
+ invokeTool: async () => ({ ok: true }),
365
+ installService: async (options) => {
366
+ assert.equal(options.platform, "win32");
367
+ return {
368
+ serviceManager: "windows-task",
369
+ serviceTarget: "Arisa Slave",
370
+ unitFile: path.join(home, "state", "arisa-slave-task.xml"),
371
+ launcherFile
372
+ };
373
+ },
374
+ output: { log: () => {} }
375
+ });
376
+ const descriptor = JSON.parse(await readFile(paths.descriptorFile, "utf8"));
377
+ assert.equal(descriptor.serviceManager, "windows-task");
378
+ assert.equal(descriptor.serviceTarget, "Arisa Slave");
379
+ assert.equal(descriptor.launcherFile, launcherFile);
380
+ });
381
+
166
382
  test("validates before effects and keeps the bootstrap secret out of service metadata", async (t) => {
167
383
  const home = await mkdtemp(path.join(os.tmpdir(), "arisa-slave-home-"));
168
384
  t.after(() => rm(home, { recursive: true, force: true }));
@@ -298,7 +514,7 @@ test("combines systemd and local tool diagnostics for Slave status", async (t) =
298
514
  paths: getSlavePaths(home),
299
515
  controlService: async (_paths, operation) => {
300
516
  assert.equal(operation, "status");
301
- return { running: true, status: "active" };
517
+ return { running: true, status: "active", serviceManager: "systemd" };
302
518
  },
303
519
  toolInstalled: async () => true,
304
520
  invokeTool: async (_paths, args) => {
@@ -321,8 +537,8 @@ test("combines systemd and local tool diagnostics for Slave status", async (t) =
321
537
  },
322
538
  output: { log: (line) => output.push(line) }
323
539
  });
324
- assert.equal(result.systemd.running, true);
325
- assert.match(output[0], /Systemd: active/);
540
+ assert.equal(result.service.running, true);
541
+ assert.match(output[0], /Service \(systemd\): active/);
326
542
  assert.match(output[0], /Daemon: ready/);
327
543
  assert.match(output[0], /Role: slave/);
328
544
  assert.match(output[0], /Endpoint: tcp:\/\/198\.51\.100\.12:4719/);
@@ -135,11 +135,13 @@ test("persists auth blocks, claims only due probes, and clears the block after s
135
135
  await store.claimDue();
136
136
  const blocked = await store.blockAuth("auth-poll", "authentication expired", {
137
137
  retryAfterSeconds: 3600,
138
- probeArgs: { action: "auth-status" }
138
+ probeArgs: { action: "auth-status" },
139
+ toolName: "checker"
139
140
  });
140
141
  assert.equal(blocked.status, "blocked_auth");
141
142
  assert.equal(blocked.authBlockedNew, true);
142
143
  assert.deepEqual(blocked.authBlock.probeArgs, { action: "auth-status" });
144
+ assert.equal(blocked.authBlock.toolName, "checker");
143
145
  assert.deepEqual(await store.claimDue(), []);
144
146
 
145
147
  store.tasks.find((task) => task.id === "auth-poll").runAt = new Date(Date.now() - 1000).toISOString();
@@ -9,7 +9,7 @@ function createHarness(overrides = {}) {
9
9
  async complete(...args) { calls.push(["complete", ...args]); return { status: "done" }; },
10
10
  async blockAuth(taskId, error, resolution) {
11
11
  calls.push(["blockAuth", taskId, error.message, resolution]);
12
- return { status: "blocked_auth", authBlockedNew: true, runAt: "2026-09-02T05:00:00.000Z" };
12
+ return { status: "blocked_auth", authBlockedNew: true, authBlock: resolution, runAt: "2026-09-02T05:00:00.000Z" };
13
13
  },
14
14
  async retryOrFail(taskId, error, options) {
15
15
  calls.push(["retryOrFail", taskId, error.message, options]);
@@ -140,7 +140,7 @@ test("pauses a poll once when its tool reports terminal authentication failure",
140
140
  payload: { chatId: 123, toolName: "checker", args: { action: "poll" } }
141
141
  });
142
142
 
143
- assert.deepEqual(calls.at(-2), ["blockAuth", "poll-auth", "authentication expired", resolution]);
143
+ assert.deepEqual(calls.at(-2), ["blockAuth", "poll-auth", "authentication expired", { ...resolution, toolName: "checker" }]);
144
144
  assert.deepEqual(calls.at(-1), [
145
145
  "send",
146
146
  123,
@@ -149,6 +149,61 @@ test("pauses a poll once when its tool reports terminal authentication failure",
149
149
  ]);
150
150
  });
151
151
 
152
+ test("pauses a recurring agent task after a nested tool reports blocked authentication", async () => {
153
+ const resolution = {
154
+ type: "reauthentication_required",
155
+ retryAfterSeconds: 3600,
156
+ probeArgs: { action: "status" }
157
+ };
158
+ const { calls, dispatcher } = createHarness({
159
+ dependencies: {
160
+ enqueueAsyncPrompt: async (input) => {
161
+ calls.push(["enqueue", input]);
162
+ input.agentTaskExecution.blockedAuth = {
163
+ toolName: "creator-scout",
164
+ error: "authentication expired",
165
+ resolution
166
+ };
167
+ }
168
+ }
169
+ });
170
+
171
+ await dispatcher.runClaimedTask({
172
+ id: "agent-auth",
173
+ kind: "agent_task",
174
+ payload: { chatId: 123, prompt: "run harvest" },
175
+ recurrence: { type: "interval", everySeconds: 14400 }
176
+ });
177
+
178
+ assert.deepEqual(calls.at(-2), ["blockAuth", "agent-auth", "authentication expired", { ...resolution, toolName: "creator-scout" }]);
179
+ assert.deepEqual(calls.at(-1), [
180
+ "send",
181
+ 123,
182
+ "⚠️ Arisa automation paused for authentication\nTool: creator-scout\nReason: authentication expired\nNext authentication check: 2026-09-02T05:00:00.000Z",
183
+ undefined
184
+ ]);
185
+ assert.equal(calls.some(([name]) => name === "complete"), false);
186
+ });
187
+
188
+ test("probes a blocked agent task before starting another reasoning turn", async () => {
189
+ const { calls, dispatcher } = createHarness();
190
+ await dispatcher.runClaimedTask({
191
+ id: "agent-resume",
192
+ kind: "agent_task",
193
+ authBlock: { toolName: "creator-scout", probeArgs: { action: "status" }, retryAfterSeconds: 3600 },
194
+ payload: { chatId: 123, prompt: "run harvest" }
195
+ });
196
+
197
+ assert.deepEqual(calls[0], ["runTurn", { priority: "background", label: "authentication probe creator-scout" }]);
198
+ assert.deepEqual(calls[1], ["runTool", {
199
+ name: "creator-scout",
200
+ request: { args: { action: "status" } },
201
+ chatId: 123
202
+ }]);
203
+ assert.equal(calls.filter(([name]) => name === "enqueue").length, 1);
204
+ assert.deepEqual(calls.at(-1), ["complete", "agent-resume"]);
205
+ });
206
+
152
207
  test("uses a lightweight auth probe before resuming blocked poll work", async () => {
153
208
  const { calls, dispatcher } = createHarness();
154
209
  await dispatcher.runClaimedTask({