@lazyingart/agintiflow 0.20.48 → 0.20.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/skillmesh.md CHANGED
@@ -86,6 +86,49 @@ Endpoints:
86
86
 
87
87
  The relay stores only validated skill packs and metadata in its data directory. It rejects raw sessions, unsafe paths, secret-like text, unsupported schemas, overlarge packs, unsigned packs, and packs that do not declare the strict privacy contract.
88
88
 
89
+ ## Service Mode
90
+
91
+ A relay started in `tmux` or a normal shell will stop after reboot. For a node that should keep listening after reboot, install it as a service.
92
+
93
+ System service, recommended for public relay nodes:
94
+
95
+ ```bash
96
+ aginti skillmesh service install \
97
+ --host 0.0.0.0 \
98
+ --port 7377 \
99
+ --data ~/.aginti-skill-relay \
100
+ --public-url http://YOUR_PUBLIC_HOST:7377
101
+ ```
102
+
103
+ This uses `sudo -n` to create and enable `/etc/systemd/system/aginti-skill-relay.service`, so it requires sudo permission. It runs the relay as the current user and restarts on failure and after reboot.
104
+
105
+ User service, useful when sudo is not available:
106
+
107
+ ```bash
108
+ aginti skillmesh service install --user --linger \
109
+ --host 0.0.0.0 \
110
+ --port 7377 \
111
+ --data ~/.aginti-skill-relay \
112
+ --public-url http://YOUR_PUBLIC_HOST:7377
113
+ ```
114
+
115
+ A user service can run without sudo, but boot persistence requires lingering:
116
+
117
+ ```bash
118
+ sudo loginctl enable-linger $(whoami)
119
+ ```
120
+
121
+ Service commands:
122
+
123
+ ```bash
124
+ aginti skillmesh service status
125
+ aginti skillmesh service restart
126
+ aginti skillmesh service stop
127
+ aginti skillmesh service uninstall
128
+ ```
129
+
130
+ Use `--user` with those commands for the user-service scope.
131
+
89
132
  ## Storage
90
133
 
91
134
  Local client storage:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyingart/agintiflow",
3
- "version": "0.20.48",
3
+ "version": "0.20.49",
4
4
  "type": "module",
5
5
  "description": "AgInTiFlow is a web-first coding agent and CLI with DeepSeek routing, sandboxed tools, model providers, canvas artifacts, and optional wrappers.",
6
6
  "license": "Apache-2.0",
@@ -11,6 +11,7 @@ Implementation note as of v0.20.48:
11
11
  - `aginti skillmesh` and `/skillmesh` exist as the first conservative MVP.
12
12
  - The shipped implementation uses signed JSON skill packs first, not tarballs, to avoid archive traversal and executable-file risk.
13
13
  - Relay nodes can run without model/API keys via `aginti skillmesh serve`.
14
+ - Relay nodes can persist after reboot with `aginti skillmesh service install`; system services require sudo, while user services need systemd lingering for boot persistence.
14
15
  - Community imports install disabled by default and cannot override built-in skills.
15
16
  - Sync is explicit and metadata-first; no continuous background polling is enabled yet.
16
17
 
@@ -4,6 +4,7 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import {
6
6
  buildSkillPackFromMarkdown,
7
+ buildSkillMeshServiceUnit,
7
8
  enableSkillMeshSkill,
8
9
  installSkillPack,
9
10
  listInstalledSkillMeshSkills,
@@ -63,6 +64,15 @@ assert(!listSkills({ includeBody: false }).some((skill) => skill.id === "mesh-an
63
64
  await setSkillMeshMode("share");
64
65
  const config = await loadSkillMeshConfig();
65
66
  assert(config.mode === "share", "share mode should persist");
67
+ const serviceUnit = buildSkillMeshServiceUnit({
68
+ dataDir: path.join(tempRoot, "relay-service"),
69
+ runScript: path.join(tempRoot, "relay-service", "aginti-skill-relay.run.sh"),
70
+ scope: "system",
71
+ user: "aginti-test",
72
+ });
73
+ assert(serviceUnit.includes("Restart=on-failure"), "systemd unit should restart relay");
74
+ assert(serviceUnit.includes("WantedBy=multi-user.target"), "systemd unit should start at boot in system scope");
75
+ assert(serviceUnit.includes("ExecStart="), "systemd unit should include ExecStart");
66
76
 
67
77
  const secretMarkdown = skillMarkdown.replace("mesh-android-screenshot", "mesh-secret-test") + "\nOPENAI_API_KEY=sk-thisShouldBeRejected1234567890\n";
68
78
  let rejected = false;
@@ -109,6 +119,7 @@ console.log(
109
119
  "relay-node-list",
110
120
  "metadata-sync",
111
121
  "soft-unreachable-node",
122
+ "systemd-service-unit",
112
123
  ],
113
124
  },
114
125
  null,
package/src/cli.js CHANGED
@@ -383,7 +383,7 @@ export function parseArgs(argv) {
383
383
 
384
384
  function printUsage() {
385
385
  console.log(
386
- 'Usage: aginti [chat] OR aginti web [--port 3210] OR aginti update OR aginti models OR aginti skills [query] OR aginti skillmesh [status|off|record|share|sync|serve] OR aginti housekeeping [--json] OR aginti auth [deepseek|openai|qwen|venice|grsai] OR aginti resume [--all-sessions] [latest|<session-id>] ["prompt"] OR aginti --remove-empty-sessions OR aginti --remove-sessions OR aginti queue <session-id> "message" OR aginti [--no-auto-update] [--language en|ja|zh-Hans|zh-Hant|ko|fr|es|ar|vi|de|ru] [--image] [--latex] [--routing smart|fast|complex|manual] [--provider deepseek|openai|qwen|venice|mock] [--model MODEL] [--route-model MODEL] [--main-model MODEL] [--spare-model MODEL --spare-reasoning medium] [--aux-provider grsai|venice --aux-model MODEL] [--sandbox-mode host|docker-readonly|docker-workspace] [--package-install-policy block|prompt|allow] [--approve-package-installs] [--allow-shell|--no-shell] [--allow-file-tools|--no-file-tools] [--web-search|--no-web-search] [--parallel-scouts|--no-parallel-scouts --scout-count 1..10] [--allow-auxiliary-tools|--no-auxiliary-tools] [--allow-wrappers --wrapper codex --wrapper-model gpt-5.5] [--list-models|--list-routes] "your task"'
386
+ 'Usage: aginti [chat] OR aginti web [--port 3210] OR aginti update OR aginti models OR aginti skills [query] OR aginti skillmesh [status|off|record|share|sync|serve|service] OR aginti housekeeping [--json] OR aginti auth [deepseek|openai|qwen|venice|grsai] OR aginti resume [--all-sessions] [latest|<session-id>] ["prompt"] OR aginti --remove-empty-sessions OR aginti --remove-sessions OR aginti queue <session-id> "message" OR aginti [--no-auto-update] [--language en|ja|zh-Hans|zh-Hant|ko|fr|es|ar|vi|de|ru] [--image] [--latex] [--routing smart|fast|complex|manual] [--provider deepseek|openai|qwen|venice|mock] [--model MODEL] [--route-model MODEL] [--main-model MODEL] [--spare-model MODEL --spare-reasoning medium] [--aux-provider grsai|venice --aux-model MODEL] [--sandbox-mode host|docker-readonly|docker-workspace] [--package-install-policy block|prompt|allow] [--approve-package-installs] [--allow-shell|--no-shell] [--allow-file-tools|--no-file-tools] [--web-search|--no-web-search] [--parallel-scouts|--no-parallel-scouts --scout-count 1..10] [--allow-auxiliary-tools|--no-auxiliary-tools] [--allow-wrappers --wrapper codex --wrapper-model gpt-5.5] [--list-models|--list-routes] "your task"'
387
387
  );
388
388
  console.log(`Languages: ${["en", "ja", "zh-Hans", "zh-Hant", "ko", "fr", "es", "ar", "vi", "de", "ru"].map((code) => `${code}=${languageLabel(code)}`).join(", ")}`);
389
389
  }
package/src/skillmesh.js CHANGED
@@ -2,8 +2,11 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import fsSync from "node:fs";
4
4
  import http from "node:http";
5
+ import os from "node:os";
5
6
  import path from "node:path";
6
7
  import process from "node:process";
8
+ import { execFileSync } from "node:child_process";
9
+ import { fileURLToPath } from "node:url";
7
10
  import express from "express";
8
11
  import { agintiflowHome } from "./session-index.js";
9
12
  import { loadDatabaseSync } from "./sqlite.js";
@@ -16,6 +19,8 @@ const MAX_SKILLS_PER_PACK = 5;
16
19
  const MAX_SKILL_BYTES = 80 * 1024;
17
20
  const MAX_FEED_PACKS = 200;
18
21
  const DEFAULT_NODE_URL = "https://skills.flow.lazying.art";
22
+ const DEFAULT_SERVICE_NAME = "aginti-skill-relay";
23
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
19
24
  const SAFE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{1,80}$/;
20
25
  const FORBIDDEN_PATH_PARTS = [
21
26
  ".env",
@@ -921,6 +926,195 @@ export async function startSkillMeshRelay({
921
926
  };
922
927
  }
923
928
 
929
+ function expandHome(value = "") {
930
+ const text = String(value || "");
931
+ if (text === "~") return os.homedir();
932
+ if (text.startsWith("~/")) return path.join(os.homedir(), text.slice(2));
933
+ return text;
934
+ }
935
+
936
+ function shellQuote(value = "") {
937
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
938
+ }
939
+
940
+ function currentUserName() {
941
+ return process.env.SUDO_USER || process.env.USER || os.userInfo().username || "aginti-relay";
942
+ }
943
+
944
+ function servicePaths({ dataDir = "", name = DEFAULT_SERVICE_NAME } = {}) {
945
+ const root = path.resolve(expandHome(dataDir || "~/.aginti-skill-relay"));
946
+ return {
947
+ dataDir: root,
948
+ runScript: path.join(root, `${name}.run.sh`),
949
+ logDir: path.join(root, "logs"),
950
+ };
951
+ }
952
+
953
+ function relayServeArgs({ host = "127.0.0.1", port = 7377, dataDir = "", publicUrl = "", role = "major", noUploads = false } = {}) {
954
+ const args = [
955
+ process.execPath,
956
+ path.join(PACKAGE_ROOT, "bin", "aginti-cli.js"),
957
+ "skillmesh",
958
+ "serve",
959
+ "--role",
960
+ role,
961
+ "--host",
962
+ host,
963
+ "--port",
964
+ String(port),
965
+ "--data",
966
+ path.resolve(expandHome(dataDir || "~/.aginti-skill-relay")),
967
+ ];
968
+ if (publicUrl) args.push("--public-url", publicUrl);
969
+ if (noUploads) args.push("--no-uploads");
970
+ return args;
971
+ }
972
+
973
+ export function buildSkillMeshServiceUnit({
974
+ name = DEFAULT_SERVICE_NAME,
975
+ user = currentUserName(),
976
+ dataDir = "",
977
+ runScript = "",
978
+ scope = "system",
979
+ } = {}) {
980
+ const paths = servicePaths({ dataDir, name });
981
+ const script = runScript || paths.runScript;
982
+ const lines = [
983
+ "[Unit]",
984
+ "Description=AgInTi Skill Mesh Relay",
985
+ "After=network-online.target",
986
+ "Wants=network-online.target",
987
+ "",
988
+ "[Service]",
989
+ "Type=simple",
990
+ ];
991
+ if (scope === "system") {
992
+ lines.push(`User=${user}`);
993
+ }
994
+ lines.push(
995
+ `WorkingDirectory=${paths.dataDir}`,
996
+ "Environment=NODE_ENV=production",
997
+ `ExecStart=${script}`,
998
+ "Restart=on-failure",
999
+ "RestartSec=5",
1000
+ "NoNewPrivileges=true",
1001
+ "PrivateTmp=true",
1002
+ "ProtectSystem=full",
1003
+ `ReadWritePaths=${paths.dataDir}`,
1004
+ "",
1005
+ "[Install]",
1006
+ scope === "system" ? "WantedBy=multi-user.target" : "WantedBy=default.target",
1007
+ ""
1008
+ );
1009
+ return lines.join("\n");
1010
+ }
1011
+
1012
+ async function writeRelayRunScript(options = {}) {
1013
+ const name = options.name || DEFAULT_SERVICE_NAME;
1014
+ const paths = servicePaths({ dataDir: options.dataDir, name });
1015
+ await fs.mkdir(paths.logDir, { recursive: true });
1016
+ const args = relayServeArgs({ ...options, dataDir: paths.dataDir });
1017
+ const logPath = path.join(paths.logDir, "relay.log");
1018
+ const script = [
1019
+ "#!/usr/bin/env bash",
1020
+ "set -euo pipefail",
1021
+ `mkdir -p ${shellQuote(paths.logDir)}`,
1022
+ `exec ${args.map(shellQuote).join(" ")} 2>&1 | tee -a ${shellQuote(logPath)}`,
1023
+ "",
1024
+ ].join("\n");
1025
+ await fs.writeFile(paths.runScript, script, "utf8");
1026
+ await fs.chmod(paths.runScript, 0o755);
1027
+ return { ...paths, runScript: paths.runScript, logPath };
1028
+ }
1029
+
1030
+ function runCommand(command, args = [], { input = undefined, stdio = "pipe" } = {}) {
1031
+ return execFileSync(command, args, {
1032
+ input,
1033
+ encoding: "utf8",
1034
+ stdio: input === undefined ? stdio : ["pipe", "pipe", "pipe"],
1035
+ });
1036
+ }
1037
+
1038
+ function runSudo(args = [], { input = undefined } = {}) {
1039
+ return runCommand("sudo", ["-n", ...args], { input });
1040
+ }
1041
+
1042
+ function systemctl(scope = "system", args = []) {
1043
+ return scope === "user" ? runCommand("systemctl", ["--user", ...args]) : runSudo(["systemctl", ...args]);
1044
+ }
1045
+
1046
+ function serviceNameFromOptions(options = {}) {
1047
+ const name = String(options.name || DEFAULT_SERVICE_NAME).trim();
1048
+ if (!/^[A-Za-z0-9_.@-]+$/.test(name)) throw new Error(`Invalid service name: ${name}`);
1049
+ return name.endsWith(".service") ? name.slice(0, -8) : name;
1050
+ }
1051
+
1052
+ export async function installSkillMeshService(options = {}) {
1053
+ if (process.platform !== "linux") {
1054
+ throw new Error("Skill Mesh service install currently supports Linux systemd. Use a process manager on this OS.");
1055
+ }
1056
+ const scope = options.user ? "user" : "system";
1057
+ const name = serviceNameFromOptions(options);
1058
+ const serviceFile = `${name}.service`;
1059
+ const paths = await writeRelayRunScript({ ...options, name });
1060
+ const unit = buildSkillMeshServiceUnit({
1061
+ name,
1062
+ user: options.serviceUser || currentUserName(),
1063
+ dataDir: paths.dataDir,
1064
+ runScript: paths.runScript,
1065
+ scope,
1066
+ });
1067
+ if (scope === "user") {
1068
+ const unitDir = path.join(os.homedir(), ".config", "systemd", "user");
1069
+ const unitPath = path.join(unitDir, serviceFile);
1070
+ await fs.mkdir(unitDir, { recursive: true });
1071
+ await fs.writeFile(unitPath, unit, "utf8");
1072
+ runCommand("systemctl", ["--user", "daemon-reload"]);
1073
+ runCommand("systemctl", ["--user", "enable", "--now", serviceFile]);
1074
+ if (options.linger) {
1075
+ try {
1076
+ runSudo(["loginctl", "enable-linger", currentUserName()]);
1077
+ } catch {
1078
+ // Linger is optional; status output tells the user how to enable boot persistence.
1079
+ }
1080
+ }
1081
+ return { scope, serviceFile, unitPath, ...paths };
1082
+ }
1083
+ const unitPath = `/etc/systemd/system/${serviceFile}`;
1084
+ runSudo(["tee", unitPath], { input: unit });
1085
+ runSudo(["systemctl", "daemon-reload"]);
1086
+ runSudo(["systemctl", "enable", "--now", serviceFile]);
1087
+ return { scope, serviceFile, unitPath, ...paths };
1088
+ }
1089
+
1090
+ export async function manageSkillMeshService(action = "status", options = {}) {
1091
+ const scope = options.user ? "user" : "system";
1092
+ const name = serviceNameFromOptions(options);
1093
+ const serviceFile = `${name}.service`;
1094
+ if (action === "install") return installSkillMeshService(options);
1095
+ if (action === "uninstall" || action === "remove") {
1096
+ try {
1097
+ systemctl(scope, ["disable", "--now", serviceFile]);
1098
+ } catch {
1099
+ // Continue removing unit files even if the service is already absent.
1100
+ }
1101
+ if (scope === "user") {
1102
+ await fs.rm(path.join(os.homedir(), ".config", "systemd", "user", serviceFile), { force: true });
1103
+ runCommand("systemctl", ["--user", "daemon-reload"]);
1104
+ } else {
1105
+ runSudo(["rm", "-f", `/etc/systemd/system/${serviceFile}`]);
1106
+ runSudo(["systemctl", "daemon-reload"]);
1107
+ }
1108
+ return { scope, serviceFile, removed: true };
1109
+ }
1110
+ if (["start", "stop", "restart", "enable", "disable"].includes(action)) {
1111
+ systemctl(scope, [action, action === "enable" || action === "disable" ? "--now" : "", serviceFile].filter(Boolean));
1112
+ return { scope, serviceFile, action };
1113
+ }
1114
+ const output = systemctl(scope, ["status", "--no-pager", serviceFile]);
1115
+ return { scope, serviceFile, status: output };
1116
+ }
1117
+
924
1118
  function parseCliOptions(argv = []) {
925
1119
  const options = { _: [] };
926
1120
  for (let i = 0; i < argv.length; i += 1) {
@@ -993,6 +1187,38 @@ export async function handleSkillMeshCommand(argv = []) {
993
1187
  }
994
1188
  throw new Error("Usage: aginti skillmesh node add <name> <url> OR aginti skillmesh node remove <name-or-url>");
995
1189
  }
1190
+ if (command === "service") {
1191
+ const action = String(options._[0] || "status").toLowerCase();
1192
+ const serviceOptions = {
1193
+ name: options.name || DEFAULT_SERVICE_NAME,
1194
+ user: Boolean(options.user),
1195
+ linger: Boolean(options.linger),
1196
+ serviceUser: options["service-user"] || currentUserName(),
1197
+ host: options.host || "127.0.0.1",
1198
+ port: Number(options.port || 7377),
1199
+ dataDir: options.data || "~/.aginti-skill-relay",
1200
+ publicUrl: options["public-url"] || "",
1201
+ role: options.role || "major",
1202
+ noUploads: Boolean(options["no-uploads"]),
1203
+ };
1204
+ const result = await manageSkillMeshService(action, serviceOptions);
1205
+ if (action === "status") {
1206
+ console.log(result.status || `${result.serviceFile} status unavailable`);
1207
+ return;
1208
+ }
1209
+ if (action === "install") {
1210
+ console.log(`installed ${result.scope} service ${result.serviceFile}`);
1211
+ console.log(`unit=${result.unitPath}`);
1212
+ console.log(`run=${result.runScript}`);
1213
+ console.log(`data=${result.dataDir}`);
1214
+ if (result.scope === "user") {
1215
+ console.log("For reboot persistence of a user service, enable linger: sudo loginctl enable-linger $(whoami)");
1216
+ }
1217
+ return;
1218
+ }
1219
+ console.log(`${action} ${result.scope} service ${result.serviceFile}`);
1220
+ return;
1221
+ }
996
1222
  if (command === "export") {
997
1223
  const skillId = options._[0] || "";
998
1224
  if (!skillId) throw new Error("Usage: aginti skillmesh export <skill-id> [--out file.skillpack.json]");
@@ -1053,6 +1279,6 @@ export async function handleSkillMeshCommand(argv = []) {
1053
1279
  return;
1054
1280
  }
1055
1281
  throw new Error(
1056
- "Usage: aginti skillmesh [status|off|record|share|nodes|export|import|enable|disable-skill|sync|submit|serve]"
1282
+ "Usage: aginti skillmesh [status|off|record|share|nodes|node|service|export|import|enable|disable-skill|sync|submit|serve]"
1057
1283
  );
1058
1284
  }