arisa 5.2.17 → 5.2.19

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 (33) hide show
  1. package/README.md +4 -2
  2. package/package.json +10 -9
  3. package/pnpm-workspace.yaml +7 -5
  4. package/src/core/agent/agent-manager.js +13 -14
  5. package/src/core/agent/auth-flow.js +6 -6
  6. package/src/core/agent/model-speed.js +3 -1
  7. package/src/core/agent/pi-auth-login.js +28 -28
  8. package/src/core/agent/pi-capability-tools.js +1 -0
  9. package/src/core/agent/pi-runtime.js +14 -21
  10. package/src/core/capabilities/capability-service.js +25 -0
  11. package/src/core/tasks/task-store.js +2 -1
  12. package/src/official-tools.lock.json +25 -18
  13. package/src/runtime/bootstrap-cli.js +3 -3
  14. package/src/runtime/bootstrap-telegram.js +7 -7
  15. package/src/runtime/slave-cli.js +20 -10
  16. package/src/runtime/slave-service.js +299 -7
  17. package/src/runtime/tui.js +5 -6
  18. package/src/transport/telegram/bot.js +9 -5
  19. package/src/transport/telegram/model-controls.js +1 -1
  20. package/src/transport/telegram/task-dispatcher.js +50 -23
  21. package/src/transport/telegram/telegram-auth-controller.js +7 -7
  22. package/src/transport/telegram/telegram-session-bridge.js +2 -1
  23. package/test/agent-turn-coordinator.test.js +5 -2
  24. package/test/auth-flow.test.js +2 -2
  25. package/test/capabilities-security.test.js +36 -0
  26. package/test/model-selection.test.js +3 -2
  27. package/test/official-tool-installer.test.js +13 -0
  28. package/test/pi-auth-login.test.js +78 -0
  29. package/test/pi-capability-tools.test.js +3 -0
  30. package/test/pi-speed-integration.test.js +176 -0
  31. package/test/slave-cli.test.js +221 -5
  32. package/test/task-store.test.js +3 -1
  33. package/test/telegram-task-dispatcher.test.js +57 -2
@@ -23,7 +23,7 @@ import {
23
23
 
24
24
  export async function runTelegramBootstrap({ telegramApiKey, setupToken, botInfo }) {
25
25
  const bot = new Bot(telegramApiKey);
26
- const runtime = createPiRuntime();
26
+ const runtime = await createPiRuntime();
27
27
  const providers = sortBootstrapProviders(listPiProviders(runtime));
28
28
  let setupChatId = null;
29
29
  let chatMeta = {};
@@ -90,7 +90,7 @@ export async function runTelegramBootstrap({ telegramApiKey, setupToken, botInfo
90
90
 
91
91
  const askModel = async (ctx = null, page = 0) => {
92
92
  state = "model";
93
- const models = sortBootstrapModels(selectedProvider.provider, listProviderModels(selectedProvider.provider, createPiRuntime()));
93
+ const models = sortBootstrapModels(selectedProvider.provider, listProviderModels(selectedProvider.provider, await createPiRuntime()));
94
94
  const keyboard = buildPagedInlineKeyboard("model", models.map((model) => ({ text: formatPiModelOption(model) })), {
95
95
  page,
96
96
  pageSize: telegramConfigDefaults.modelPickerPageSize
@@ -119,7 +119,7 @@ export async function runTelegramBootstrap({ telegramApiKey, setupToken, botInfo
119
119
  };
120
120
 
121
121
  const askAuthMethod = async (ctx = null) => {
122
- const providerRuntime = createPiRuntime();
122
+ const providerRuntime = await createPiRuntime();
123
123
  const selectedAuthReady = hasProviderAuth(selectedProvider.provider, providerRuntime);
124
124
  const providerSupportsOAuth = supportsProviderOAuth(selectedProvider.provider, providerRuntime);
125
125
  const buttons = [];
@@ -142,7 +142,7 @@ export async function runTelegramBootstrap({ telegramApiKey, setupToken, botInfo
142
142
  try {
143
143
  await login.promise;
144
144
  activeLogin = null;
145
- if (hasProviderAuth(selectedProvider.provider, createPiRuntime())) {
145
+ if (hasProviderAuth(selectedProvider.provider, await createPiRuntime())) {
146
146
  await sendSetupMessage(`Detected Pi auth for ${selectedProvider.provider}.`);
147
147
  await askBackground();
148
148
  return;
@@ -157,7 +157,7 @@ export async function runTelegramBootstrap({ telegramApiKey, setupToken, botInfo
157
157
  };
158
158
 
159
159
  const startPiLogin = async () => {
160
- if (hasProviderAuth(selectedProvider.provider, createPiRuntime())) {
160
+ if (hasProviderAuth(selectedProvider.provider, await createPiRuntime())) {
161
161
  await sendSetupMessage(`Existing Pi auth for ${selectedProvider.provider} detected.`);
162
162
  await askBackground();
163
163
  return;
@@ -248,7 +248,7 @@ export async function runTelegramBootstrap({ telegramApiKey, setupToken, botInfo
248
248
  return;
249
249
  }
250
250
  if (action === "model" && state === "model") {
251
- const models = sortBootstrapModels(selectedProvider.provider, listProviderModels(selectedProvider.provider, createPiRuntime()));
251
+ const models = sortBootstrapModels(selectedProvider.provider, listProviderModels(selectedProvider.provider, await createPiRuntime()));
252
252
  selectedModel = models[Number(rawValue)];
253
253
  if (!selectedModel) return;
254
254
  await askAuthMethod(ctx);
@@ -256,7 +256,7 @@ export async function runTelegramBootstrap({ telegramApiKey, setupToken, botInfo
256
256
  }
257
257
  if (action === "auth" && state === "auth-method") {
258
258
  if (rawValue === "existing") {
259
- if (hasProviderAuth(selectedProvider.provider, createPiRuntime())) {
259
+ if (hasProviderAuth(selectedProvider.provider, await createPiRuntime())) {
260
260
  await askBackground(ctx);
261
261
  } else {
262
262
  await sendSetupMessage(`No existing Pi auth found for ${selectedProvider.provider}.`);
@@ -11,7 +11,7 @@ import { withSecureRequestFile } from "./secure-request-file.js";
11
11
  import {
12
12
  controlSlaveService,
13
13
  getSlavePaths,
14
- installSlaveSystemdService,
14
+ installSlaveService,
15
15
  isSlaveToolInstalled,
16
16
  readSlaveServiceDescriptor,
17
17
  registerSlaveServiceProcess,
@@ -138,11 +138,13 @@ function parseSlaveToolOutput(result) {
138
138
  return result?.output && typeof result.output === "object" ? result.output : result;
139
139
  }
140
140
 
141
- export function formatSlaveStatus({ systemd, diagnostic }) {
141
+ export function formatSlaveStatus({ service, systemd, diagnostic }) {
142
+ const serviceStatus = service || systemd;
143
+ const manager = serviceStatus?.serviceManager || (systemd ? "systemd" : "service");
142
144
  const jobs = diagnostic?.jobs && typeof diagnostic.jobs === "object" ? diagnostic.jobs : {};
143
145
  return [
144
146
  "Arisa Slave status",
145
- `Systemd: ${systemd.running ? "active" : systemd.status || "inactive"}`,
147
+ `Service (${manager}): ${serviceStatus?.running ? "active" : serviceStatus?.status || "inactive"}`,
146
148
  `Daemon: ${diagnostic?.daemon?.state || diagnostic?.daemonState || "unknown"}`,
147
149
  `Role: ${diagnostic?.role || "unknown"}`,
148
150
  `Endpoint: ${diagnostic?.endpoint || "not configured"}`,
@@ -167,14 +169,14 @@ export async function runSlaveBootstrap(url, {
167
169
  paths = getSlavePaths(resolveSlaveHome()),
168
170
  selectAccount = selectSlaveServiceAccount,
169
171
  ensureTool = ensureMasterSlaveTool,
170
- installService = installSlaveSystemdService,
172
+ installService = installSlaveService,
171
173
  invokeTool = invokeSlaveTool,
172
174
  entryFile,
173
175
  output = console,
174
176
  platform = process.platform
175
177
  } = {}) {
176
178
  parseSlaveBootstrapUrl(url);
177
- if (platform !== "linux") throw new Error("Arisa Slave service installation currently requires Linux with systemd");
179
+ if (!["linux", "darwin", "win32"].includes(platform)) throw new Error(`Arisa Slave service installation is not supported on ${platform}`);
178
180
  const account = await selectAccount();
179
181
  await ensureSlaveConfig(paths);
180
182
  await ensureTool(paths);
@@ -188,8 +190,16 @@ export async function runSlaveBootstrap(url, {
188
190
  } catch (error) {
189
191
  throw explainSlaveBootstrapError(error);
190
192
  }
191
- await installService({ account, slaveHome: paths.home, entryFile });
192
- await writeSlaveServiceDescriptor(paths, { version: 1, account, installedAt: new Date().toISOString() });
193
+ const installedService = await installService({ account, slaveHome: paths.home, entryFile, platform });
194
+ await writeSlaveServiceDescriptor(paths, {
195
+ version: 1,
196
+ account,
197
+ serviceManager: installedService?.serviceManager || ({ darwin: "launchd", win32: "windows-task" }[platform] || "systemd"),
198
+ serviceTarget: installedService?.serviceTarget || null,
199
+ unitFile: installedService?.unitFile || null,
200
+ launcherFile: installedService?.launcherFile || null,
201
+ installedAt: new Date().toISOString()
202
+ });
193
203
  output.log(`Arisa Slave paired and running as ${account.user}${account.root ? " (root)" : ""}.`);
194
204
  return result;
195
205
  }
@@ -240,12 +250,12 @@ export async function runSlaveCli({
240
250
  }
241
251
  if (action === "status") {
242
252
  if (positionals.length !== 1) throw new Error("arisa slave status does not accept additional arguments");
243
- const systemd = await controlService(paths, "status");
253
+ const service = await controlService(paths, "status");
244
254
  const diagnostic = await toolInstalled(paths, toolName)
245
255
  ? parseSlaveToolOutput(await invokeTool(paths, { action: "slave.status" }))
246
256
  : { daemonState: "not-installed", role: "slave", paired: false, toolCount: 0, pendingSecrets: 0 };
247
- output.log(formatSlaveStatus({ systemd, diagnostic }));
248
- return { systemd, diagnostic };
257
+ output.log(formatSlaveStatus({ service, diagnostic }));
258
+ return { service, diagnostic };
249
259
  }
250
260
  if (action === "log") {
251
261
  if (positionals.length !== 1) throw new Error("arisa slave log does not accept additional arguments");
@@ -4,6 +4,8 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
 
6
6
  export const slaveServiceName = "arisa-slave.service";
7
+ export const slaveLaunchdLabel = "com.arisa.slave";
8
+ export const slaveWindowsTaskName = "Arisa Slave";
7
9
 
8
10
  function exists(target) {
9
11
  return access(target).then(() => true, () => false);
@@ -15,6 +17,12 @@ function requireAccountName(value) {
15
17
  return name;
16
18
  }
17
19
 
20
+ function requireWindowsAccountName(value) {
21
+ const name = String(value || "").trim();
22
+ if (!name || /[\u0000-\u001f<>"|]/.test(name)) throw new Error(`Invalid Windows service account: ${name || "empty"}`);
23
+ return name;
24
+ }
25
+
18
26
  export function resolveSlaveHome({ environment = process.env, euid = process.geteuid?.(), homedir = os.homedir() } = {}) {
19
27
  if (environment.ARISA_SLAVE_HOME) return path.resolve(environment.ARISA_SLAVE_HOME);
20
28
  return euid === 0 ? "/var/lib/arisa-slave" : path.join(homedir, ".arisa-slave");
@@ -39,8 +47,15 @@ export function getSlavePaths(slaveHome) {
39
47
  export async function selectSlaveServiceAccount({
40
48
  euid = process.geteuid?.(),
41
49
  currentUser = os.userInfo().username,
42
- environment = process.env
50
+ environment = process.env,
51
+ platform = process.platform
43
52
  } = {}) {
53
+ if (platform === "win32") {
54
+ const qualifiedUser = environment.USERDOMAIN && environment.USERNAME
55
+ ? `${environment.USERDOMAIN}\\${environment.USERNAME}`
56
+ : currentUser;
57
+ return { scope: "user", user: requireWindowsAccountName(qualifiedUser), root: false, dedicated: false };
58
+ }
44
59
  if (euid !== 0) {
45
60
  return { scope: "user", user: requireAccountName(currentUser), root: false, dedicated: false };
46
61
  }
@@ -95,6 +110,154 @@ export function buildSlaveSystemdUnit({ account, slaveHome, entryFile, nodePath
95
110
  ].filter((line, index, lines) => line || lines[index - 1] !== "").join("\n");
96
111
  }
97
112
 
113
+ function escapeXml(value) {
114
+ return String(value)
115
+ .replaceAll("&", "&amp;")
116
+ .replaceAll("<", "&lt;")
117
+ .replaceAll(">", "&gt;")
118
+ .replaceAll('"', "&quot;")
119
+ .replaceAll("'", "&apos;");
120
+ }
121
+
122
+ function plistString(value) {
123
+ return ` <string>${escapeXml(value)}</string>`;
124
+ }
125
+
126
+ export function buildSlaveLaunchdPlist({ account, slaveHome, entryFile, nodePath = process.execPath, environment = process.env }) {
127
+ if (!account?.scope || !account?.user) throw new Error("Slave launchd service requires a service account");
128
+ if (typeof entryFile !== "string" || !path.isAbsolute(entryFile)) throw new Error("Slave launchd service requires an absolute Arisa entry file");
129
+ const paths = getSlavePaths(slaveHome);
130
+ const serviceUser = account.scope === "system" && account.user !== "root"
131
+ ? [" <key>UserName</key>", ` <string>${escapeXml(account.user)}</string>`]
132
+ : [];
133
+ const environmentEntries = {
134
+ ARISA_HOME: paths.home,
135
+ ARISA_SLAVE_HOME: paths.home,
136
+ ...(environment.HOME ? { HOME: environment.HOME } : {}),
137
+ ...(environment.PATH ? { PATH: environment.PATH } : {}),
138
+ ...(environment.TMPDIR ? { TMPDIR: environment.TMPDIR } : {})
139
+ };
140
+ return [
141
+ '<?xml version="1.0" encoding="UTF-8"?>',
142
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
143
+ '<plist version="1.0">',
144
+ '<dict>',
145
+ ' <key>Label</key>',
146
+ ` <string>${slaveLaunchdLabel}</string>`,
147
+ ' <key>ProgramArguments</key>',
148
+ ' <array>',
149
+ plistString(nodePath),
150
+ plistString(entryFile),
151
+ plistString("slave"),
152
+ plistString("--service-runner"),
153
+ ' </array>',
154
+ ' <key>EnvironmentVariables</key>',
155
+ ' <dict>',
156
+ ...Object.entries(environmentEntries).flatMap(([key, value]) => [
157
+ ` <key>${escapeXml(key)}</key>`,
158
+ ` <string>${escapeXml(value)}</string>`
159
+ ]),
160
+ ' </dict>',
161
+ ' <key>WorkingDirectory</key>',
162
+ ` <string>${escapeXml(paths.home)}</string>`,
163
+ ' <key>StandardOutPath</key>',
164
+ ` <string>${escapeXml(paths.logFile)}</string>`,
165
+ ' <key>StandardErrorPath</key>',
166
+ ` <string>${escapeXml(paths.logFile)}</string>`,
167
+ ' <key>RunAtLoad</key>',
168
+ ' <true/>',
169
+ ' <key>KeepAlive</key>',
170
+ ' <true/>',
171
+ ...serviceUser,
172
+ '</dict>',
173
+ '</plist>',
174
+ ''
175
+ ].join("\n");
176
+ }
177
+
178
+ export function buildSlaveWindowsLauncher({ slaveHome, entryFile, nodePath = process.execPath }) {
179
+ if (typeof entryFile !== "string" || !path.isAbsolute(entryFile)) throw new Error("Slave Windows launcher requires an absolute Arisa entry file");
180
+ const paths = getSlavePaths(slaveHome);
181
+ return [
182
+ 'const { closeSync, openSync } = require("node:fs");',
183
+ 'const { spawn } = require("node:child_process");',
184
+ `const home = ${JSON.stringify(paths.home)};`,
185
+ `const logFile = ${JSON.stringify(paths.logFile)};`,
186
+ `process.env.ARISA_HOME = ${JSON.stringify(paths.home)};`,
187
+ `process.env.ARISA_SLAVE_HOME = ${JSON.stringify(paths.home)};`,
188
+ 'process.chdir(home);',
189
+ 'const log = openSync(logFile, "a");',
190
+ `const child = spawn(${JSON.stringify(nodePath)}, [${JSON.stringify(entryFile)}, "slave", "--service-runner"], {`,
191
+ ' cwd: home,',
192
+ ' env: process.env,',
193
+ ' stdio: ["ignore", log, log],',
194
+ ' windowsHide: true',
195
+ '});',
196
+ 'for (const signal of ["SIGINT", "SIGTERM"]) {',
197
+ ' process.on(signal, () => child.kill(signal));',
198
+ '}',
199
+ 'let finished = false;',
200
+ 'function finish(code, error) {',
201
+ ' if (finished) return;',
202
+ ' finished = true;',
203
+ ' if (error) require("node:fs").writeSync(log, `${error.stack || error.message || error}\\n`);',
204
+ ' closeSync(log);',
205
+ ' process.exitCode = Number.isInteger(code) ? code : 1;',
206
+ '}',
207
+ 'child.once("error", (error) => finish(1, error));',
208
+ 'child.once("close", (code) => finish(code));',
209
+ ''
210
+ ].join("\r\n");
211
+ }
212
+
213
+ export function buildSlaveWindowsTaskXml({ account, launcherFile, nodePath = process.execPath }) {
214
+ if (!account?.user) throw new Error("Slave Windows task requires a service account");
215
+ if (typeof launcherFile !== "string" || !path.isAbsolute(launcherFile)) throw new Error("Slave Windows task requires an absolute launcher file");
216
+ const taskArguments = `"${launcherFile}"`;
217
+ return [
218
+ '<?xml version="1.0" encoding="UTF-16"?>',
219
+ '<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">',
220
+ ' <RegistrationInfo>',
221
+ ` <Author>${escapeXml(account.user)}</Author>`,
222
+ ' <Description>Arisa Slave headless host</Description>',
223
+ ' </RegistrationInfo>',
224
+ ' <Triggers>',
225
+ ' <LogonTrigger>',
226
+ ' <Enabled>true</Enabled>',
227
+ ` <UserId>${escapeXml(account.user)}</UserId>`,
228
+ ' </LogonTrigger>',
229
+ ' </Triggers>',
230
+ ' <Principals>',
231
+ ' <Principal id="Author">',
232
+ ` <UserId>${escapeXml(account.user)}</UserId>`,
233
+ ' <LogonType>InteractiveToken</LogonType>',
234
+ ' <RunLevel>LeastPrivilege</RunLevel>',
235
+ ' </Principal>',
236
+ ' </Principals>',
237
+ ' <Settings>',
238
+ ' <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>',
239
+ ' <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>',
240
+ ' <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>',
241
+ ' <StartWhenAvailable>true</StartWhenAvailable>',
242
+ ' <Enabled>true</Enabled>',
243
+ ' <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>',
244
+ ' <RestartOnFailure>',
245
+ ' <Interval>PT1M</Interval>',
246
+ ' <Count>999</Count>',
247
+ ' </RestartOnFailure>',
248
+ ' </Settings>',
249
+ ' <Actions Context="Author">',
250
+ ' <Exec>',
251
+ ` <Command>${escapeXml(nodePath)}</Command>`,
252
+ ` <Arguments>${escapeXml(taskArguments)}</Arguments>`,
253
+ ` <WorkingDirectory>${escapeXml(path.dirname(launcherFile))}</WorkingDirectory>`,
254
+ ' </Exec>',
255
+ ' </Actions>',
256
+ '</Task>',
257
+ ''
258
+ ].join("\r\n");
259
+ }
260
+
98
261
  export function runCommand(command, args, { cwd, env = process.env } = {}) {
99
262
  return new Promise((resolve, reject) => {
100
263
  const child = spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
@@ -155,6 +318,70 @@ export async function installSlaveSystemdService({
155
318
  return { unitFile, serviceName: slaveServiceName, account, paths };
156
319
  }
157
320
 
321
+ export async function installSlaveLaunchdService({
322
+ account,
323
+ slaveHome,
324
+ entryFile,
325
+ execute = runCommand,
326
+ environment = process.env,
327
+ platform = process.platform,
328
+ uid = process.getuid?.(),
329
+ systemUnitDir = "/Library/LaunchDaemons",
330
+ userUnitDir = path.join(os.homedir(), "Library", "LaunchAgents")
331
+ }) {
332
+ if (platform !== "darwin") throw new Error("Arisa Slave launchd installation requires macOS");
333
+ if (account.scope === "user" && !Number.isSafeInteger(uid)) throw new Error("Cannot determine the macOS user id for launchd");
334
+ const paths = getSlavePaths(slaveHome);
335
+ await mkdir(paths.state, { recursive: true, mode: 0o700 });
336
+ const unitDir = account.scope === "system" ? systemUnitDir : userUnitDir;
337
+ await mkdir(unitDir, { recursive: true });
338
+ const unitFile = path.join(unitDir, `${slaveLaunchdLabel}.plist`);
339
+ await writeFile(unitFile, buildSlaveLaunchdPlist({ account, slaveHome: paths.home, entryFile, environment }), { mode: 0o644 });
340
+ const domain = account.scope === "system" ? "system" : `gui/${uid}`;
341
+ const serviceTarget = `${domain}/${slaveLaunchdLabel}`;
342
+ await execute("launchctl", ["bootout", serviceTarget], { env: environment }).catch(() => {});
343
+ await execute("launchctl", ["bootstrap", domain, unitFile], { env: environment });
344
+ await execute("launchctl", ["enable", serviceTarget], { env: environment });
345
+ await execute("launchctl", ["kickstart", "-k", serviceTarget], { env: environment });
346
+ return { unitFile, serviceName: slaveLaunchdLabel, serviceManager: "launchd", serviceTarget, account, paths };
347
+ }
348
+
349
+ export async function installSlaveWindowsService({
350
+ account,
351
+ slaveHome,
352
+ entryFile,
353
+ execute = runCommand,
354
+ platform = process.platform
355
+ }) {
356
+ if (platform !== "win32") throw new Error("Arisa Slave Task Scheduler installation requires Windows");
357
+ const paths = getSlavePaths(slaveHome);
358
+ await mkdir(paths.state, { recursive: true, mode: 0o700 });
359
+ const launcherFile = path.join(paths.state, "arisa-slave-launcher.cjs");
360
+ const unitFile = path.join(paths.state, "arisa-slave-task.xml");
361
+ await writeFile(launcherFile, `\uFEFF${buildSlaveWindowsLauncher({ slaveHome: paths.home, entryFile })}`, { encoding: "utf8", mode: 0o600 });
362
+ await writeFile(unitFile, `\uFEFF${buildSlaveWindowsTaskXml({ account, launcherFile })}`, { encoding: "utf16le", mode: 0o600 });
363
+ await execute("schtasks.exe", ["/End", "/TN", slaveWindowsTaskName]).catch(() => {});
364
+ await execute("schtasks.exe", ["/Create", "/TN", slaveWindowsTaskName, "/XML", unitFile, "/F"]);
365
+ await execute("schtasks.exe", ["/Run", "/TN", slaveWindowsTaskName]);
366
+ return {
367
+ unitFile,
368
+ launcherFile,
369
+ serviceName: slaveWindowsTaskName,
370
+ serviceManager: "windows-task",
371
+ serviceTarget: slaveWindowsTaskName,
372
+ account,
373
+ paths
374
+ };
375
+ }
376
+
377
+ export async function installSlaveService(options = {}) {
378
+ const platform = options.platform || process.platform;
379
+ if (platform === "linux") return installSlaveSystemdService({ ...options, platform });
380
+ if (platform === "darwin") return installSlaveLaunchdService({ ...options, platform });
381
+ if (platform === "win32") return installSlaveWindowsService({ ...options, platform });
382
+ throw new Error(`Arisa Slave service installation is not supported on ${platform}`);
383
+ }
384
+
158
385
  export async function writeSlaveServiceDescriptor(paths, descriptor) {
159
386
  await mkdir(paths.state, { recursive: true, mode: 0o700 });
160
387
  await writeFile(paths.descriptorFile, `${JSON.stringify(descriptor, null, 2)}\n`, { mode: 0o600 });
@@ -169,20 +396,85 @@ export async function readSlaveServiceDescriptor(paths) {
169
396
  }
170
397
  }
171
398
 
172
- export async function controlSlaveService(paths, operation, { execute = runCommand } = {}) {
173
- const descriptor = await readSlaveServiceDescriptor(paths);
399
+ async function controlSystemdService(descriptor, operation, execute) {
174
400
  const prefix = descriptor.account?.scope === "user" ? ["--user"] : [];
175
401
  if (operation === "status") {
176
402
  try {
177
403
  const result = await execute("systemctl", [...prefix, "is-active", slaveServiceName]);
178
- return { running: result.stdout.trim() === "active", status: result.stdout.trim() };
404
+ return { running: result.stdout.trim() === "active", status: result.stdout.trim(), serviceManager: "systemd" };
179
405
  } catch {
180
- return { running: false, status: "inactive" };
406
+ return { running: false, status: "inactive", serviceManager: "systemd" };
181
407
  }
182
408
  }
183
- if (!["start", "stop", "restart"].includes(operation)) throw new Error(`Unsupported Slave service operation: ${operation}`);
184
409
  await execute("systemctl", [...prefix, operation, slaveServiceName]);
185
- return { ok: true, operation };
410
+ return { ok: true, operation, serviceManager: "systemd" };
411
+ }
412
+
413
+ async function launchdIsLoaded(serviceTarget, execute) {
414
+ try {
415
+ await execute("launchctl", ["print", serviceTarget]);
416
+ return true;
417
+ } catch {
418
+ return false;
419
+ }
420
+ }
421
+
422
+ async function controlLaunchdService(descriptor, operation, execute) {
423
+ const serviceTarget = descriptor.serviceTarget;
424
+ const unitFile = descriptor.unitFile;
425
+ const domain = serviceTarget?.slice(0, serviceTarget.lastIndexOf("/"));
426
+ if (!serviceTarget || !unitFile || !domain) throw new Error("Arisa Slave launchd descriptor is incomplete");
427
+ const loaded = await launchdIsLoaded(serviceTarget, execute);
428
+ if (operation === "status") {
429
+ return { running: loaded, status: loaded ? "active" : "inactive", serviceManager: "launchd" };
430
+ }
431
+ if (operation === "stop") {
432
+ if (loaded) await execute("launchctl", ["bootout", serviceTarget]);
433
+ return { ok: true, operation, serviceManager: "launchd" };
434
+ }
435
+ if (!loaded) await execute("launchctl", ["bootstrap", domain, unitFile]);
436
+ await execute("launchctl", ["enable", serviceTarget]);
437
+ await execute("launchctl", ["kickstart", ...(operation === "restart" ? ["-k"] : []), serviceTarget]);
438
+ return { ok: true, operation, serviceManager: "launchd" };
439
+ }
440
+
441
+ async function registeredSlavePidIsAlive(paths) {
442
+ try {
443
+ const pid = Number.parseInt((await readFile(paths.pidFile, "utf8")).trim(), 10);
444
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
445
+ process.kill(pid, 0);
446
+ return true;
447
+ } catch {
448
+ return false;
449
+ }
450
+ }
451
+
452
+ async function controlWindowsTask(paths, descriptor, operation, execute) {
453
+ const taskName = descriptor.serviceTarget || slaveWindowsTaskName;
454
+ if (operation === "status") {
455
+ try {
456
+ await execute("schtasks.exe", ["/Query", "/TN", taskName]);
457
+ const running = await registeredSlavePidIsAlive(paths);
458
+ return { running, status: running ? "active" : "inactive", serviceManager: "windows-task" };
459
+ } catch {
460
+ return { running: false, status: "not-installed", serviceManager: "windows-task" };
461
+ }
462
+ }
463
+ if (operation === "stop" || operation === "restart") {
464
+ await execute("schtasks.exe", ["/End", "/TN", taskName]).catch(() => {});
465
+ }
466
+ if (operation === "start" || operation === "restart") {
467
+ await execute("schtasks.exe", ["/Run", "/TN", taskName]);
468
+ }
469
+ return { ok: true, operation, serviceManager: "windows-task" };
470
+ }
471
+
472
+ export async function controlSlaveService(paths, operation, { execute = runCommand } = {}) {
473
+ if (!["start", "stop", "restart", "status"].includes(operation)) throw new Error(`Unsupported Slave service operation: ${operation}`);
474
+ const descriptor = await readSlaveServiceDescriptor(paths);
475
+ if (descriptor.serviceManager === "launchd") return controlLaunchdService(descriptor, operation, execute);
476
+ if (descriptor.serviceManager === "windows-task") return controlWindowsTask(paths, descriptor, operation, execute);
477
+ return controlSystemdService(descriptor, operation, execute);
186
478
  }
187
479
 
188
480
  export async function registerSlaveServiceProcess(paths) {
@@ -126,8 +126,8 @@ function requiresProviderAuth(model) {
126
126
  export async function createArisaTuiRuntime({ config, client, logger } = {}) {
127
127
  const chatId = resolveTuiChatId(config);
128
128
  const policy = buildPiToolPolicy({ config, customToolNames: tuiToolNames });
129
- const piRuntime = createPiRuntime({ provider: config.pi.provider, apiKey: config.pi.apiKey });
130
- const model = piRuntime.modelRegistry.find(config.pi.provider, config.pi.model);
129
+ const piRuntime = await createPiRuntime({ provider: config.pi.provider, apiKey: config.pi.apiKey });
130
+ const model = piRuntime.getModel(config.pi.provider, config.pi.model);
131
131
  if (!model) throw new Error(`Model not found: ${config.pi.provider}/${config.pi.model}`);
132
132
  if (requiresProviderAuth(model) && !config.pi.apiKey && !hasProviderAuth(config.pi.provider, piRuntime)) {
133
133
  throw new Error(`No auth found for ${config.pi.provider}. Complete Arisa bootstrap first.`);
@@ -141,8 +141,7 @@ export async function createArisaTuiRuntime({ config, client, logger } = {}) {
141
141
  const services = await createAgentSessionServices({
142
142
  cwd,
143
143
  agentDir: arisaHomeDir,
144
- authStorage: piRuntime.authStorage,
145
- modelRegistry: piRuntime.modelRegistry,
144
+ modelRuntime: piRuntime,
146
145
  settingsManager,
147
146
  resourceLoaderOptions: {
148
147
  agentsFilesOverride: (current) => appendArisaAgentsFile(current, arisaAgentsContent)
@@ -158,8 +157,8 @@ export async function createArisaTuiRuntime({ config, client, logger } = {}) {
158
157
  excludeTools: policy.excludeTools,
159
158
  customTools
160
159
  });
161
- const speedController = createModelSpeedController(created.session.agent.streamFn, config.pi.speed);
162
- created.session.agent.streamFn = speedController.streamFn;
160
+ const speedController = createModelSpeedController(created.session.agent.streamFunction, config.pi.speed);
161
+ created.session.agent.streamFunction = speedController.streamFn;
163
162
  logger?.log("tui", `opened ${created.session.sessionFile || "in-memory session"} for owner scope ${chatId}`);
164
163
  return { ...created, services, diagnostics: services.diagnostics };
165
164
  };
@@ -335,7 +335,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
335
335
  timer.unref?.();
336
336
  }
337
337
 
338
- async function enqueueAsyncPrompt({ chatId, prompt, label, route: taskRoute, timeoutMs, priority = "background", queueTtlMs = undefined }) {
338
+ async function enqueueAsyncPrompt({ chatId, prompt, label, route: taskRoute, timeoutMs, priority = "background", queueTtlMs = undefined, agentTaskExecution = null }) {
339
339
  let ctx = { chat: { id: chatId }, api: bot.api };
340
340
  const destination = taskRoute?.transport === "telegram" ? taskRoute.destination : null;
341
341
  if (destination?.chatId && destination?.threadId) {
@@ -349,7 +349,11 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
349
349
  if (!route.ok) throw new Error("Scheduled owner-workspace destination is unavailable.");
350
350
  registerRoute(ctx, route);
351
351
  }
352
- const route = contextRoute(ctx);
352
+ const route = {
353
+ ...contextRoute(ctx),
354
+ ...(agentTaskExecution ? { agentTaskExecution } : {})
355
+ };
356
+ registerRoute(ctx, route);
353
357
  const chatState = getChatState(route.sessionId);
354
358
  if (chatState.processing) await ensureQueuedTelegramTyping(chatState, ctx);
355
359
  let timer = null;
@@ -409,7 +413,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
409
413
  const auth = await authorizeContext(ctx);
410
414
  if (!auth.ok) return;
411
415
  if (authController.getIssue()) {
412
- await ctx.reply(authController.buildBlockedMessage(ctx.chat.id));
416
+ await ctx.reply(await authController.buildBlockedMessage(ctx.chat.id));
413
417
  return;
414
418
  }
415
419
  await handleNewCommand(ctx);
@@ -536,7 +540,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
536
540
  if (await authController.submitRenewalInput(ctx)) return;
537
541
 
538
542
  if (authController.getIssue()) {
539
- await ctx.reply(authController.buildBlockedMessage(ctx.chat.id));
543
+ await ctx.reply(await authController.buildBlockedMessage(ctx.chat.id));
540
544
  return;
541
545
  }
542
546
 
@@ -548,7 +552,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
548
552
  if (wasPromptErrorNotified(error)) return;
549
553
  const issue = getPiAuthIssue(error);
550
554
  await ctx.reply(issue
551
- ? buildPiAuthTelegramMessage({ config, chatId: ctx.chat.id, issue })
555
+ ? await buildPiAuthTelegramMessage({ config, chatId: ctx.chat.id, issue })
552
556
  : getErrorMessage(error));
553
557
  });
554
558
  });
@@ -40,7 +40,7 @@ function restorePreviousSelection(agentConfig, key, hadSelections, previousSelec
40
40
 
41
41
  export function createTelegramModelControls({ config, saveConfig, agentManager, contextRoute }) {
42
42
  async function getProviderModels() {
43
- const runtime = createPiRuntime({
43
+ const runtime = await createPiRuntime({
44
44
  provider: config.pi.provider,
45
45
  apiKey: config.pi.apiKey
46
46
  });