@lazyingart/agintiflow 0.20.127 → 0.20.128

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/README.md CHANGED
@@ -103,13 +103,15 @@ Provider signup and key pages:
103
103
  | Qwen / DashScope | [https://bailian.console.aliyun.com/](https://bailian.console.aliyun.com/) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` |
104
104
  | GRS AI image tools | [https://grsai.ai/dashboard/api-keys](https://grsai.ai/dashboard/api-keys) | Configure with `/auxiliary grsai` or `aginti login grsai` |
105
105
 
106
- The CLI quietly auto-starts or reuses the local web UI from the same project. It tries `http://127.0.0.1:3210` first, then `3211`, `3212`, and so on if the port is already occupied. The active URL is shown in the CLI launch header. If startup is blocked or unavailable, the same header row shows the recovery hint; run `/webapp [port]` inside the CLI to retry.
106
+ The CLI quietly auto-starts or reuses the local web UI from the same project. It tries `http://127.0.0.1:3210` first, then `3211`, `3212`, and so on if the port is already occupied by another project. The active URL is shown in the CLI launch header. If startup is blocked, stale, or unavailable, the same header row shows the recovery hint; run `/webapp [port]` inside the CLI to retry, or `/webapp restart [port]` to stop and relaunch the local webapp with the current project and canonical `~/.agintiflow` session home.
107
107
 
108
108
  Package installation also makes a best-effort, non-blocking webapp initialization. Install never fails because the optional local webapp could not start.
109
109
 
110
110
  Launch the web UI explicitly when you want a foreground web server:
111
111
 
112
112
  ```bash
113
+ aginti webapp
114
+ aginti webapp restart
113
115
  aginti web --port 3210
114
116
  # opens http://127.0.0.1:3210, or the next available port
115
117
  ```
@@ -143,7 +145,7 @@ aginti --language de
143
145
  | Goal | Command |
144
146
  | --- | --- |
145
147
  | Start interactive chat | `aginti` or `aginti chat` |
146
- | Start local web app | Auto-starts with `aginti`; foreground mode is `aginti web --port 3210` |
148
+ | Start local web app | Auto-starts with `aginti`; detached command is `aginti webapp`; restart with `aginti webapp restart`; foreground mode is `aginti web --port 3210` |
147
149
  | Save provider keys | `aginti auth`, `/auth`, `/login` |
148
150
  | Review current repo | `/review [focus]` |
149
151
  | Toggle SCS quality gate | `/scs` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyingart/agintiflow",
3
- "version": "0.20.127",
3
+ "version": "0.20.128",
4
4
  "type": "module",
5
5
  "description": "AgInTiFlow is a project-aware agent workspace for hybrid wet-dry R&D, hardware-aware intelligence, software automation, and industrial workflows.",
6
6
  "license": "Apache-2.0",
package/public/app.js CHANGED
@@ -2083,7 +2083,7 @@ function renderArtifactContent(content) {
2083
2083
  artifactViewerKindEl.textContent = content.kind || item?.kind || "";
2084
2084
  const streamedUrl = content.url || (content.id ? artifactRawUrl(content.id) : "");
2085
2085
  const downloadUrl = content.downloadUrl || (content.id ? artifactRawUrl(content.id, { download: true }) : "");
2086
- const renderUrl = content.dataUrl || streamedUrl;
2086
+ const renderUrl = streamedUrl || content.dataUrl;
2087
2087
 
2088
2088
  if (renderUrl && (content.kind === "pdf" || content.mime === "application/pdf")) {
2089
2089
  artifactViewerBodyEl.innerHTML = `
@@ -2133,15 +2133,19 @@ function artifactDownloadName(item, content) {
2133
2133
  ? ".pdf"
2134
2134
  : content.mime?.startsWith("image/png")
2135
2135
  ? ".png"
2136
- : content.mime?.startsWith("image/svg")
2137
- ? ".svg"
2138
- : content.kind === "json"
2139
- ? ".json"
2140
- : content.kind === "diff"
2141
- ? ".diff"
2142
- : content.kind === "markdown"
2143
- ? ".md"
2144
- : ".txt";
2136
+ : content.mime?.startsWith("image/jpeg")
2137
+ ? ".jpg"
2138
+ : content.mime?.startsWith("image/webp")
2139
+ ? ".webp"
2140
+ : content.mime?.startsWith("image/svg")
2141
+ ? ".svg"
2142
+ : content.kind === "json"
2143
+ ? ".json"
2144
+ : content.kind === "diff"
2145
+ ? ".diff"
2146
+ : content.kind === "markdown"
2147
+ ? ".md"
2148
+ : ".txt";
2145
2149
  const base = sourceName
2146
2150
  .split("/")
2147
2151
  .filter(Boolean)
@@ -14,7 +14,7 @@ AgInTiFlow is a project-aware agent workspace for hybrid wet-dry R&D, hardware-a
14
14
 
15
15
  Project-oriented programming for agentic workflows.
16
16
 
17
- AAPS is a prompt-native programming language and visual studio for turning prompts into structured, verifiable pipelines. It connects wet and dry experiments, hardware and software, and human intent with executable agent work through tasks, typed inputs, declared outputs, validation gates, recovery steps, and durable artifacts.
17
+ AAPS is a project oriented and prompt-native programming language and visual studio for turning prompts into structured, verifiable pipelines. It connects wet and dry experiments, hardware and software, and human intent with executable agent work through tasks, typed inputs, declared outputs, validation gates, recovery steps, and durable artifacts.
18
18
 
19
19
  ## Shorter Version
20
20
 
@@ -99,6 +99,42 @@ async function main() {
99
99
  throw new Error("large artifact file resolver returned invalid metadata");
100
100
  }
101
101
 
102
+ const largePdfPath = path.join(workspace, "compiled-paper.pdf");
103
+ await fs.writeFile(largePdfPath, Buffer.concat([Buffer.from("%PDF-1.7\n"), Buffer.alloc(4_200_000)]));
104
+ const pdfNormalized = normalizeCanvasPayload(
105
+ {
106
+ title: "Compiled paper",
107
+ kind: "pdf",
108
+ path: "compiled-paper.pdf",
109
+ selected: true,
110
+ },
111
+ config
112
+ );
113
+ if (!pdfNormalized.ok) throw new Error(pdfNormalized.reason || "large PDF canvas payload normalization failed");
114
+ const pdfPersisted = await persistCanvasPayloadFile(pdfNormalized.payload, { config, store });
115
+ if (!pdfPersisted.ok) throw new Error(pdfPersisted.reason || "large PDF canvas artifact persistence failed");
116
+ const { items: pdfItems } = buildArtifacts({
117
+ sessionId: store.sessionId,
118
+ events: [
119
+ {
120
+ timestamp: new Date().toISOString(),
121
+ type: "canvas.item",
122
+ data: {
123
+ ...pdfPersisted.payload,
124
+ commandCwd: workspace,
125
+ },
126
+ },
127
+ ],
128
+ store,
129
+ });
130
+ const pdfContent = await readArtifactContent(pdfItems[0], { store, config });
131
+ if (!pdfContent.ok || pdfContent.kind !== "pdf" || pdfContent.mime !== "application/pdf") {
132
+ throw new Error(`large PDF artifact did not expose PDF metadata: ${JSON.stringify(pdfContent)}`);
133
+ }
134
+ if (!pdfContent.tooLargeForInline || !pdfContent.url || !pdfContent.downloadUrl || pdfContent.dataUrl) {
135
+ throw new Error("large PDF artifact should stream through preview/download URLs instead of inline data");
136
+ }
137
+
102
138
  const missing = await persistCanvasPayloadFile({ ...normalized.payload, path: "missing.png" }, { config, store });
103
139
  if (missing.ok) throw new Error("missing canvas path should fail persistence");
104
140
 
@@ -26,6 +26,7 @@ async function runCli(args, envOverrides = {}) {
26
26
  ...process.env,
27
27
  AGINTIFLOW_RUNTIME_DIR: "",
28
28
  AGINTIFLOW_HOME: agintiflowHome,
29
+ AGINTIFLOW_NO_WEB_AUTO_START: "1",
29
30
  ...envOverrides,
30
31
  },
31
32
  });
@@ -41,6 +42,7 @@ async function runCliIn(cwd, args, envOverrides = {}) {
41
42
  ...process.env,
42
43
  AGINTIFLOW_RUNTIME_DIR: "",
43
44
  AGINTIFLOW_HOME: agintiflowHome,
45
+ AGINTIFLOW_NO_WEB_AUTO_START: "1",
44
46
  ...envOverrides,
45
47
  },
46
48
  });
@@ -78,6 +78,7 @@ function runCli(args, inputText) {
78
78
  ...process.env,
79
79
  AGINTIFLOW_RUNTIME_DIR: "",
80
80
  AGINTIFLOW_HOME: agintiflowHome,
81
+ AGINTIFLOW_NO_WEB_AUTO_START: "1",
81
82
  AGINTIFLOW_PREVIEW_TTL_MS: "1000",
82
83
  AGINTI_LANGUAGE: "en",
83
84
  },
@@ -146,6 +147,7 @@ async function runTmuxInterruptSmoke({ key, expected }) {
146
147
  "env",
147
148
  `AGINTIFLOW_HOME=${shellQuote(agintiflowHome)}`,
148
149
  "AGINTIFLOW_RUNTIME_DIR=",
150
+ "AGINTIFLOW_NO_WEB_AUTO_START=1",
149
151
  "AGINTI_LANGUAGE=en",
150
152
  "AGINTI_INTERRUPT_FORCE_EXIT_MS=2500",
151
153
  shellQuote(process.execPath),
@@ -7,13 +7,18 @@ import { ensureAgintiWebApp } from "../src/web-autostart.js";
7
7
 
8
8
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
9
9
  const runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), "agintiflow-web-autostart-"));
10
+ const homeDir = path.join(runtimeDir, "stable-web-home");
11
+ const inheritedHome = path.join(runtimeDir, "leaked-cli-home");
10
12
  const preferredPort = 44500 + Math.floor(Math.random() * 1000);
11
13
  let childPid = 0;
14
+ const originalHome = process.env.AGINTIFLOW_HOME;
12
15
 
13
16
  try {
17
+ process.env.AGINTIFLOW_HOME = inheritedHome;
14
18
  const first = await ensureAgintiWebApp({
15
19
  packageDir: repoRoot,
16
20
  cwd: runtimeDir,
21
+ home: homeDir,
17
22
  preferredPort,
18
23
  host: "127.0.0.1",
19
24
  });
@@ -25,17 +30,35 @@ try {
25
30
  if (!health.ok || Number(health.port) !== preferredPort) {
26
31
  throw new Error(`auto-started web health was invalid: ${JSON.stringify(health)}`);
27
32
  }
33
+ if (path.resolve(health.agintiflowHome) !== path.resolve(homeDir) || path.resolve(health.runtimeDir) !== path.resolve(runtimeDir)) {
34
+ throw new Error(`auto-started webapp inherited the wrong context: ${JSON.stringify(health)}`);
35
+ }
28
36
  const second = await ensureAgintiWebApp({
29
37
  packageDir: repoRoot,
30
38
  cwd: runtimeDir,
39
+ home: homeDir,
31
40
  preferredPort,
32
41
  host: "127.0.0.1",
33
42
  });
34
43
  if (!second.ok || !second.reused || second.url !== first.url) {
35
44
  throw new Error(`expected second auto-start call to reuse existing webapp, got ${JSON.stringify(second)}`);
36
45
  }
46
+ const restarted = await ensureAgintiWebApp({
47
+ packageDir: repoRoot,
48
+ cwd: runtimeDir,
49
+ home: homeDir,
50
+ preferredPort,
51
+ host: "127.0.0.1",
52
+ restart: true,
53
+ });
54
+ if (!restarted.ok || !restarted.restarted || restarted.url !== first.url || Number(restarted.pid) === childPid) {
55
+ throw new Error(`expected restart on same URL with a new pid, got ${JSON.stringify(restarted)}`);
56
+ }
57
+ childPid = Number(restarted.pid) || childPid;
37
58
  console.log(`web auto-start smoke passed: ${first.url}`);
38
59
  } finally {
60
+ if (originalHome === undefined) delete process.env.AGINTIFLOW_HOME;
61
+ else process.env.AGINTIFLOW_HOME = originalHome;
39
62
  if (childPid) {
40
63
  try {
41
64
  process.kill(childPid, "SIGTERM");
@@ -47,7 +47,8 @@ async function runCase({ port, env = {}, expectHeader, label }) {
47
47
  env: {
48
48
  ...process.env,
49
49
  AGINTIFLOW_NO_ANIMATION: "1",
50
- AGINTIFLOW_HOME: path.join(runtimeDir, `.agintiflow-home-${label}`),
50
+ AGINTIFLOW_HOME: path.join(runtimeDir, `.ignored-cli-home-${label}`),
51
+ AGINTIFLOW_WEB_HOME: path.join(runtimeDir, `.agintiflow-web-home-${label}`),
51
52
  ...env,
52
53
  },
53
54
  stdio: ["pipe", "pipe", "pipe"],
@@ -64,10 +65,15 @@ async function runCase({ port, env = {}, expectHeader, label }) {
64
65
  await waitFor(() => output.stdout.includes(expectHeader), child, `${label} launch header`, output);
65
66
  child.stdin.write(`/webapp ${port}\n`);
66
67
  await waitFor(() => output.stdout.includes(`webapp=http://127.0.0.1:${port}`), child, `${label} /webapp command`, output);
68
+ child.stdin.write(`/webapp restart ${port}\n`);
69
+ await waitFor(() => output.stdout.includes(`webapp=http://127.0.0.1:${port} restarted`), child, `${label} /webapp restart command`, output);
67
70
  const health = await fetch(`http://127.0.0.1:${port}/health`).then((response) => response.json());
68
71
  if (!health.ok || health.app !== "agintiflow" || Number(health.port) !== port) {
69
72
  throw new Error(`invalid /webapp health response for ${label}: ${JSON.stringify(health)}`);
70
73
  }
74
+ if (path.resolve(health.agintiflowHome) !== path.resolve(path.join(runtimeDir, `.agintiflow-web-home-${label}`))) {
75
+ throw new Error(`webapp command inherited the wrong home for ${label}: ${JSON.stringify(health)}`);
76
+ }
71
77
  } finally {
72
78
  child.kill("SIGTERM");
73
79
  await killPort(port);
package/src/cli.js CHANGED
@@ -95,11 +95,12 @@ function printUnknownCliOptions(options = []) {
95
95
  }
96
96
 
97
97
  async function maybeEnsureDefaultWebApp(args = {}, { commandCwd = process.cwd() } = {}) {
98
- if (args.web) return { ok: false, url: "" };
98
+ if (args.web || args.webapp) return { ok: false, url: "" };
99
99
  try {
100
100
  return await ensureAgintiWebApp({
101
101
  packageDir,
102
102
  cwd: args.commandCwd || commandCwd || process.cwd(),
103
+ home: args.webHome || "",
103
104
  host: args.host || process.env.AGINTI_WEB_HOST || "127.0.0.1",
104
105
  preferredPort: args.port || process.env.AGINTI_WEB_PORT || 3210,
105
106
  language: args.language ? resolveLanguage(args.language) : "",
@@ -379,6 +380,9 @@ export function parseArgs(argv) {
379
380
  sandboxStatus: false,
380
381
  sandboxPreflight: false,
381
382
  web: false,
383
+ webapp: false,
384
+ webAction: "",
385
+ webHome: "",
382
386
  interactive: false,
383
387
  port: "",
384
388
  host: "",
@@ -399,6 +403,21 @@ export function parseArgs(argv) {
399
403
  parts.push(...argv.slice(i + 1));
400
404
  break;
401
405
  }
406
+ if ((arg === "web" || arg === "--web") && String(argv[i + 1] || "").toLowerCase() === "restart") {
407
+ result.webapp = true;
408
+ result.webAction = "restart";
409
+ i += 1;
410
+ continue;
411
+ }
412
+ if (arg === "webapp" || arg === "--webapp" || arg === "web-ui" || arg === "--web-ui") {
413
+ result.webapp = true;
414
+ const next = String(argv[i + 1] || "");
415
+ if (next && !next.startsWith("--")) {
416
+ result.webAction = next.toLowerCase();
417
+ i += 1;
418
+ }
419
+ continue;
420
+ }
402
421
  if (arg === "web" || arg === "--web") {
403
422
  result.web = true;
404
423
  continue;
@@ -417,6 +436,11 @@ export function parseArgs(argv) {
417
436
  i += 1;
418
437
  continue;
419
438
  }
439
+ if (arg === "--web-home") {
440
+ result.webHome = readOption(argv, i);
441
+ i += 1;
442
+ continue;
443
+ }
420
444
  if (arg === "--language" || arg === "--lang" || arg === "-L") {
421
445
  const first = readOption(argv, i);
422
446
  const second = argv[i + 2] && !String(argv[i + 2]).startsWith("--") ? argv[i + 2] : "";
@@ -1880,6 +1904,33 @@ export async function main(argv = process.argv.slice(2)) {
1880
1904
  const args = { ...parsedArgs, commandCwd: parsedArgs.commandCwd || commandCwd };
1881
1905
  exitOnUnknownOptions(args);
1882
1906
 
1907
+ if (args.webapp) {
1908
+ const action = String(args.webAction || "start").toLowerCase();
1909
+ if (!["start", "restart", "reuse"].includes(action)) {
1910
+ console.error("Usage: aginti webapp [start|restart] [--port 3210] [--host 127.0.0.1]");
1911
+ process.exit(1);
1912
+ }
1913
+ const result = await ensureAgintiWebApp({
1914
+ packageDir,
1915
+ cwd: args.commandCwd || commandCwd,
1916
+ home: args.webHome || "",
1917
+ host: args.host || process.env.AGINTI_WEB_HOST || "127.0.0.1",
1918
+ preferredPort: args.port || process.env.AGINTI_WEB_PORT || 3210,
1919
+ language: args.language ? resolveLanguage(args.language) : "",
1920
+ restart: action === "restart",
1921
+ respectAutoStartDisable: false,
1922
+ }).catch((error) => ({ ok: false, error: error instanceof Error ? error.message : String(error), url: "" }));
1923
+ if (!result.ok) {
1924
+ console.error(`webapp unavailable: ${result.error || "unknown"}`);
1925
+ process.exit(1);
1926
+ }
1927
+ const state = result.restarted ? "restarted" : result.reused ? "reused" : "started";
1928
+ console.log(`webapp: ${result.url} ${state}`);
1929
+ console.log(`project: ${result.runtimeDir || path.resolve(args.commandCwd || commandCwd)}`);
1930
+ console.log(`home: ${result.agintiflowHome || ""}`);
1931
+ return;
1932
+ }
1933
+
1883
1934
  if (args.web) {
1884
1935
  if (args.port) process.env.PORT = String(args.port);
1885
1936
  if (args.host) process.env.HOST = String(args.host);
package/src/i18n.js CHANGED
@@ -139,7 +139,7 @@ const TRANSLATIONS = {
139
139
  helpSkills: "List Markdown skills selected for a topic.",
140
140
  helpSkillMesh: "Manage strict reviewed skill sharing.",
141
141
  helpProfile: "Set task profile, e.g. code, website, latex, maintenance.",
142
- helpWebapp: "Start or reuse the local webapp and print its URL.",
142
+ helpWebapp: "Start, reuse, or restart the local webapp and print its URL.",
143
143
  helpWebSearch: "Enable or disable the web_search tool.",
144
144
  helpEnableScs: "Toggle Student-Committee-Supervisor gated execution.",
145
145
  helpScouts: "Enable parallel DeepSeek scouts and set scout count.",
@@ -881,7 +881,7 @@ function printHelp() {
881
881
  ` ${command("/skills [query]", "List Markdown skills selected for a topic.", "helpSkills")}`,
882
882
  ` ${command("/skillmesh [status|off|record|share|sync]", "Manage strict reviewed skill sharing.", "helpSkillMesh")}`,
883
883
  ` ${command("/profile <name>", "Set task profile, e.g. code, website, latex, maintenance.", "helpProfile")}`,
884
- ` ${command("/webapp [port]", "Start or reuse the local webapp and print its URL.", "helpWebapp")}`,
884
+ ` ${command("/webapp [port|restart]", "Start, reuse, or restart the local webapp and print its URL.", "helpWebapp")}`,
885
885
  ` ${command("/web-search on|off", "Enable or disable the web_search tool.", "helpWebSearch")}`,
886
886
  ` ${command("/web-research <query>", "Run a sourced web_research turn with persisted evidence.", "helpWebSearch")}`,
887
887
  ` ${command("/image-read <path> [question]", "Run read_image on a workspace screenshot/image.", "helpWebSearch")}`,
@@ -3010,20 +3010,24 @@ async function handleCommand(line, state, packageDir) {
3010
3010
  return true;
3011
3011
  }
3012
3012
  if (command === "webapp" || command === "web") {
3013
- const port = Number(value) || Number(process.env.AGINTI_WEB_PORT || process.env.PORT || 3210);
3014
- printSystemLine("webapp=starting");
3013
+ const words = value.split(/\s+/).filter(Boolean);
3014
+ const restart = words.some((word) => word.toLowerCase() === "restart");
3015
+ const portValue = words.find((word) => /^\d+$/.test(word));
3016
+ const port = Number(portValue) || Number(process.env.AGINTI_WEB_PORT || process.env.PORT || 3210);
3017
+ printSystemLine(restart ? "webapp=restarting" : "webapp=starting");
3015
3018
  const result = await ensureAgintiWebApp({
3016
3019
  packageDir,
3017
3020
  cwd: state.commandCwd || process.cwd(),
3018
3021
  host: process.env.AGINTI_WEB_HOST || process.env.HOST || "127.0.0.1",
3019
3022
  preferredPort: port,
3020
3023
  language: state.language,
3024
+ restart,
3021
3025
  respectAutoStartDisable: false,
3022
3026
  }).catch((error) => ({ ok: false, error: error instanceof Error ? error.message : String(error), url: "" }));
3023
3027
  if (result.ok) {
3024
3028
  state.webAppUrl = result.url;
3025
3029
  state.webAppNotice = "";
3026
- printSystemLine(`webapp=${result.url} ${result.reused ? "reused" : "started"}`);
3030
+ printSystemLine(`webapp=${result.url} ${result.restarted ? "restarted" : result.reused ? "reused" : "started"}`);
3027
3031
  } else {
3028
3032
  state.webAppUrl = "";
3029
3033
  state.webAppNotice = `webapp unavailable - use /webapp to retry; error: ${compactLine(result.error || "unknown", 72)}`;
@@ -1,6 +1,7 @@
1
- import { spawn } from "node:child_process";
1
+ import { execFile, spawn } from "node:child_process";
2
2
  import http from "node:http";
3
3
  import net from "node:net";
4
+ import os from "node:os";
4
5
  import path from "node:path";
5
6
 
6
7
  const DEFAULT_HOST = "127.0.0.1";
@@ -20,7 +21,40 @@ function webUrl(host, port) {
20
21
  return `http://${host}:${port}`;
21
22
  }
22
23
 
23
- function fetchHealth(host, port, timeoutMs = 450) {
24
+ function isInside(root, target) {
25
+ const relative = path.relative(root, target);
26
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
27
+ }
28
+
29
+ function samePath(left = "", right = "") {
30
+ if (!left || !right) return false;
31
+ return path.resolve(left) === path.resolve(right);
32
+ }
33
+
34
+ function defaultAgintiflowHome() {
35
+ return path.join(os.homedir(), ".agintiflow");
36
+ }
37
+
38
+ function isLikelyTransientPath(value = "") {
39
+ if (!value) return false;
40
+ const resolved = path.resolve(value);
41
+ const tmp = path.resolve(os.tmpdir());
42
+ return isInside(tmp, resolved) || /agintiflow-(cli-chat|webapp-command|web-autostart|web-port|smoke|test)-/i.test(resolved);
43
+ }
44
+
45
+ function resolveWebHome(home = "") {
46
+ const explicit = home || process.env.AGINTIFLOW_WEB_HOME || process.env.AGINTI_WEB_HOME || "";
47
+ if (explicit) return path.resolve(explicit);
48
+ const inherited = process.env.AGINTIFLOW_HOME || "";
49
+ if (inherited && !isLikelyTransientPath(inherited)) return path.resolve(inherited);
50
+ return defaultAgintiflowHome();
51
+ }
52
+
53
+ function resolveRuntimeDir(cwd = "") {
54
+ return path.resolve(process.env.AGINTIFLOW_WEB_RUNTIME_DIR || cwd || process.cwd());
55
+ }
56
+
57
+ function fetchHealthDetails(host, port, timeoutMs = 450) {
24
58
  return new Promise((resolve) => {
25
59
  const req = http.get(`${webUrl(host, port)}/health`, { timeout: timeoutMs }, (res) => {
26
60
  let body = "";
@@ -31,20 +65,28 @@ function fetchHealth(host, port, timeoutMs = 450) {
31
65
  res.on("end", () => {
32
66
  try {
33
67
  const json = JSON.parse(body || "{}");
34
- resolve(Boolean(res.statusCode === 200 && json.ok && (json.app === "agintiflow" || Number(json.port) === port)));
68
+ resolve({
69
+ ok: Boolean(res.statusCode === 200 && json.ok && (json.app === "agintiflow" || Number(json.port) === port)),
70
+ statusCode: res.statusCode,
71
+ ...json,
72
+ });
35
73
  } catch {
36
- resolve(false);
74
+ resolve({ ok: false });
37
75
  }
38
76
  });
39
77
  });
40
78
  req.on("timeout", () => {
41
79
  req.destroy();
42
- resolve(false);
80
+ resolve({ ok: false });
43
81
  });
44
- req.on("error", () => resolve(false));
82
+ req.on("error", () => resolve({ ok: false }));
45
83
  });
46
84
  }
47
85
 
86
+ async function fetchHealth(host, port, timeoutMs = 450) {
87
+ return (await fetchHealthDetails(host, port, timeoutMs)).ok;
88
+ }
89
+
48
90
  function canListen(host, port) {
49
91
  return new Promise((resolve) => {
50
92
  const server = net.createServer();
@@ -64,14 +106,104 @@ async function waitForHealth(host, port, timeoutMs = 7000) {
64
106
  return false;
65
107
  }
66
108
 
67
- export async function findReusableOrFreeWebPort({ host = DEFAULT_HOST, preferredPort = DEFAULT_PORT, attempts = MAX_PORT_ATTEMPTS } = {}) {
109
+ function compatibleHealth(health = {}, { cwd = "", home = "", packageDir = "" } = {}) {
110
+ if (!health.ok || health.app !== "agintiflow") return false;
111
+ if (!health.runtimeDir || !health.agintiflowHome) return false;
112
+ if (!samePath(health.runtimeDir, cwd)) return false;
113
+ if (!samePath(health.agintiflowHome, home)) return false;
114
+ if (health.packageDir && packageDir && !samePath(health.packageDir, packageDir)) return false;
115
+ return true;
116
+ }
117
+
118
+ function listenerPids(port) {
119
+ return new Promise((resolve) => {
120
+ execFile("lsof", [`-tiTCP:${port}`, "-sTCP:LISTEN"], { encoding: "utf8" }, (error, stdout) => {
121
+ if (error) {
122
+ resolve([]);
123
+ return;
124
+ }
125
+ resolve(
126
+ String(stdout || "")
127
+ .split(/\s+/)
128
+ .map((value) => Number(value))
129
+ .filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid)
130
+ );
131
+ });
132
+ });
133
+ }
134
+
135
+ async function waitForPortRelease(host, port, timeoutMs = 5000) {
136
+ const deadline = Date.now() + timeoutMs;
137
+ while (Date.now() < deadline) {
138
+ if (!(await fetchHealth(host, port, 220)) && (await canListen(host, port))) return true;
139
+ await new Promise((resolve) => setTimeout(resolve, 150));
140
+ }
141
+ return false;
142
+ }
143
+
144
+ async function stopWebAppOnPort({ host, port, health = {} } = {}) {
145
+ const pids = new Set();
146
+ if (Number.isInteger(Number(health.pid)) && Number(health.pid) > 0) pids.add(Number(health.pid));
147
+ for (const pid of await listenerPids(port)) pids.add(pid);
148
+ if (pids.size === 0) return { ok: false, error: `Could not identify AgInTiFlow webapp process on ${host}:${port}.` };
149
+
150
+ for (const pid of pids) {
151
+ try {
152
+ process.kill(pid, "SIGTERM");
153
+ } catch {
154
+ // Already stopped or not owned by this user.
155
+ }
156
+ }
157
+ if (await waitForPortRelease(host, port, 3500)) return { ok: true, pids: [...pids], forced: false };
158
+
159
+ for (const pid of pids) {
160
+ try {
161
+ process.kill(pid, "SIGKILL");
162
+ } catch {
163
+ // Ignore.
164
+ }
165
+ }
166
+ const released = await waitForPortRelease(host, port, 2000);
167
+ return released
168
+ ? { ok: true, pids: [...pids], forced: true }
169
+ : { ok: false, pids: [...pids], error: `AgInTiFlow webapp on ${host}:${port} did not stop.` };
170
+ }
171
+
172
+ export async function findReusableOrFreeWebPort({
173
+ host = DEFAULT_HOST,
174
+ preferredPort = DEFAULT_PORT,
175
+ attempts = MAX_PORT_ATTEMPTS,
176
+ cwd = process.cwd(),
177
+ home = "",
178
+ packageDir = process.cwd(),
179
+ restart = false,
180
+ } = {}) {
68
181
  const startPort = normalizePort(preferredPort);
69
182
  const normalizedHost = normalizeHost(host);
183
+ const runtimeDir = resolveRuntimeDir(cwd);
184
+ const homeDir = resolveWebHome(home);
70
185
  for (let offset = 0; offset < attempts; offset += 1) {
71
186
  const port = startPort + offset;
72
187
  if (port >= 65536) break;
73
- if (await fetchHealth(normalizedHost, port)) {
74
- return { port, host: normalizedHost, url: webUrl(normalizedHost, port), reused: true, available: false };
188
+ const health = await fetchHealthDetails(normalizedHost, port);
189
+ if (health.ok) {
190
+ if (restart && Number(port) === startPort) {
191
+ const stopped = await stopWebAppOnPort({ host: normalizedHost, port, health });
192
+ if (!stopped.ok) return { port, host: normalizedHost, url: "", reused: false, available: false, stopped, error: stopped.error };
193
+ return {
194
+ port,
195
+ host: normalizedHost,
196
+ url: webUrl(normalizedHost, port),
197
+ reused: false,
198
+ available: true,
199
+ restarted: true,
200
+ stopped,
201
+ };
202
+ }
203
+ if (compatibleHealth(health, { cwd: runtimeDir, home: homeDir, packageDir })) {
204
+ return { port, host: normalizedHost, url: webUrl(normalizedHost, port), reused: true, available: false, health };
205
+ }
206
+ continue;
75
207
  }
76
208
  if (await canListen(normalizedHost, port)) {
77
209
  return { port, host: normalizedHost, url: webUrl(normalizedHost, port), reused: false, available: true };
@@ -83,32 +215,40 @@ export async function findReusableOrFreeWebPort({ host = DEFAULT_HOST, preferred
83
215
  export async function ensureAgintiWebApp({
84
216
  packageDir = process.cwd(),
85
217
  cwd = process.cwd(),
218
+ home = "",
86
219
  host = DEFAULT_HOST,
87
220
  preferredPort = DEFAULT_PORT,
88
221
  language = "",
222
+ restart = false,
89
223
  respectAutoStartDisable = true,
90
224
  } = {}) {
91
225
  if (respectAutoStartDisable && (process.env.AGINTI_NO_WEB_AUTO_START === "1" || process.env.AGINTIFLOW_NO_WEB_AUTO_START === "1")) {
92
226
  return { ok: false, disabled: true, url: "" };
93
227
  }
94
228
 
95
- const candidate = await findReusableOrFreeWebPort({ host, preferredPort });
229
+ const runtimeDir = resolveRuntimeDir(cwd);
230
+ const homeDir = resolveWebHome(home);
231
+ const candidate = await findReusableOrFreeWebPort({ host, preferredPort, cwd: runtimeDir, home: homeDir, packageDir, restart });
96
232
  if (!candidate.port) {
97
- return { ok: false, error: `No available AgInTiFlow web port from ${normalizePort(preferredPort)}.`, url: "" };
233
+ return { ok: false, error: candidate.error || `No available AgInTiFlow web port from ${normalizePort(preferredPort)}.`, url: "" };
234
+ }
235
+ if (candidate.error || (!candidate.available && !candidate.reused)) {
236
+ return { ok: false, error: candidate.error || `No reusable or free AgInTiFlow web port from ${normalizePort(preferredPort)}.`, url: "" };
98
237
  }
99
238
  if (candidate.reused) {
100
- return { ok: true, reused: true, started: false, ...candidate };
239
+ return { ok: true, reused: true, started: false, runtimeDir, agintiflowHome: homeDir, ...candidate };
101
240
  }
102
241
 
103
242
  const child = spawn(process.execPath, [path.join(packageDir, "web.js")], {
104
- cwd,
243
+ cwd: runtimeDir,
105
244
  detached: true,
106
245
  stdio: "ignore",
107
246
  env: {
108
247
  ...process.env,
109
248
  HOST: candidate.host,
110
249
  PORT: String(candidate.port),
111
- AGINTIFLOW_RUNTIME_DIR: cwd,
250
+ AGINTIFLOW_RUNTIME_DIR: runtimeDir,
251
+ AGINTIFLOW_HOME: homeDir,
112
252
  AGINTIFLOW_PACKAGE_DIR: packageDir,
113
253
  ...(language ? { AGINTI_LANGUAGE: language } : {}),
114
254
  },
@@ -117,6 +257,16 @@ export async function ensureAgintiWebApp({
117
257
 
118
258
  const healthy = await waitForHealth(candidate.host, candidate.port);
119
259
  return healthy
120
- ? { ok: true, reused: false, started: true, pid: child.pid, ...candidate }
260
+ ? {
261
+ ok: true,
262
+ reused: false,
263
+ started: true,
264
+ restarted: Boolean(candidate.restarted),
265
+ stopped: candidate.stopped,
266
+ pid: child.pid,
267
+ runtimeDir,
268
+ agintiflowHome: homeDir,
269
+ ...candidate,
270
+ }
121
271
  : { ok: false, error: `Started web process ${child.pid}, but ${candidate.url}/health did not become ready.`, url: "" };
122
272
  }
package/web.js CHANGED
@@ -1383,7 +1383,21 @@ app.post("/api/runs/:sessionId/stop", async (req, res) => {
1383
1383
  });
1384
1384
 
1385
1385
  app.get("/health", (_req, res) => {
1386
- res.json({ ok: true, app: "agintiflow", port, url: `http://${host}:${port}` });
1386
+ res.json({
1387
+ ok: true,
1388
+ app: "agintiflow",
1389
+ version: packageJson.version,
1390
+ pid: process.pid,
1391
+ host,
1392
+ port,
1393
+ url: `http://${host}:${port}`,
1394
+ runtimeDir: baseDir,
1395
+ projectRoot: baseDir,
1396
+ agintiflowHome: storagePaths.agintiflowHome,
1397
+ sessionsDir,
1398
+ projectSessionsDir,
1399
+ packageDir,
1400
+ });
1387
1401
  });
1388
1402
 
1389
1403
  await fs.mkdir(sessionsDir, { recursive: true });