@lazyingart/agintiflow 0.4.0 → 0.5.0

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
@@ -66,6 +66,8 @@ Useful project commands:
66
66
  ```bash
67
67
  aginti keys status
68
68
  printf '%s' "$DEEPSEEK_API_KEY" | aginti keys set deepseek --stdin
69
+ aginti capabilities
70
+ aginti doctor --capabilities
69
71
  aginti sessions list
70
72
  aginti sessions show <session-id>
71
73
  aginti resume <session-id> "continue with a short follow-up"
@@ -113,6 +115,29 @@ npm start -- --resume your-session-id
113
115
 
114
116
  The package exposes both `aginti` and `aginti-cli`; they run the same CLI entrypoint.
115
117
 
118
+ ## Capability Checks
119
+
120
+ Use the capability report to verify a project folder before running real agent work:
121
+
122
+ ```bash
123
+ aginti capabilities
124
+ aginti capabilities --json
125
+ aginti doctor --capabilities
126
+ ```
127
+
128
+ The report checks the project root, command cwd, shared `.sessions/`, provider-key presence, DeepSeek routes, guarded file and shell tools, Docker status, wrappers, task profiles, TeX, Node/npm, Python, R, conda, and maintenance command policy. It never prints API key or token values.
129
+
130
+ Live DeepSeek verification is opt-in because it spends provider credits:
131
+
132
+ ```bash
133
+ AGINTIFLOW_REAL_DEEPSEEK=1 \
134
+ AGINTIFLOW_REAL_WORKSPACE=/home/lachlan/ProjectsLFS/aginti-test \
135
+ AGINTIFLOW_REAL_WEB_BASE_URL=http://127.0.0.1:3220 \
136
+ npm run real:deepseek
137
+ ```
138
+
139
+ The live suite asks DeepSeek v4 flash/pro to create and improve a Node app, generate LaTeX/PDF artifacts when TeX exists, create a website-test sample, write Docker-safe maintenance plans, create an AAPS sample, and verify CLI/web session sharing. See [docs/real-deepseek-capabilities.md](docs/real-deepseek-capabilities.md).
140
+
116
141
  ## Web UI
117
142
 
118
143
  The web app includes:
@@ -0,0 +1,51 @@
1
+ # Real DeepSeek Capability Suite
2
+
3
+ AgInTiFlow keeps deterministic mock smoke tests for CI, but Round 9 added an opt-in live suite for validating DeepSeek v4 flash/pro behavior against a real project folder.
4
+
5
+ ## Prerequisites
6
+
7
+ - Node.js 22+
8
+ - A project folder initialized with `aginti init`
9
+ - `DEEPSEEK_API_KEY` in the environment or project-local `.aginti/.env`
10
+ - Optional web UI running from the same project folder
11
+
12
+ Never print or commit API keys. The suite reports only provider availability.
13
+
14
+ ## Static Capability Report
15
+
16
+ ```bash
17
+ aginti capabilities
18
+ aginti capabilities --json
19
+ aginti doctor --capabilities
20
+ ```
21
+
22
+ The report checks project root, command cwd, shared `.sessions/`, provider-key presence, DeepSeek routes, file/shell tools, Docker status, wrappers, task profiles, TeX, Node/npm, Python, R, conda, and maintenance command guardrails.
23
+
24
+ ## Live DeepSeek Suite
25
+
26
+ Run from the source repository:
27
+
28
+ ```bash
29
+ AGINTIFLOW_REAL_DEEPSEEK=1 \
30
+ AGINTIFLOW_REAL_WORKSPACE=/home/lachlan/ProjectsLFS/aginti-test \
31
+ AGINTIFLOW_REAL_WEB_BASE_URL=http://127.0.0.1:3220 \
32
+ npm run real:deepseek
33
+ ```
34
+
35
+ Optional case filter:
36
+
37
+ ```bash
38
+ AGINTIFLOW_REAL_DEEPSEEK=1 AGINTIFLOW_REAL_CASES=flash,pro npm run real:deepseek
39
+ ```
40
+
41
+ ## What It Validates
42
+
43
+ - `flash`: DeepSeek v4 flash creates a dependency-free Node/HTML app with `node:test` tests and runs `npm --prefix <app> test` when safe.
44
+ - `pro`: DeepSeek v4 pro resumes/improves the app and expands tests.
45
+ - `latex`: creates `.tex` source and compiles only if a TeX toolchain is available; otherwise writes an honest setup artifact.
46
+ - `website`: creates a dependency-free website test sample and runs safe tests when dependencies are present.
47
+ - `maintenance`: creates dry-run maintenance scripts/plans under `maintenance/` and validates script syntax; no global installs.
48
+ - `aaps`: creates a project-local `.aaps` sample and notes for `@lazyingart/aaps` workflows without publishing.
49
+ - `web`: when `AGINTIFLOW_REAL_WEB_BASE_URL` is set, starts a web run and verifies CLI/web session sharing.
50
+
51
+ The suite writes a JSON summary with session IDs, generated files, routes, and failures. It is intentionally opt-in because it spends live model credits.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyingart/agintiflow",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "AgInTiFlow is a resumable Playwright website-control agent with OpenAI-compatible tool calling.",
6
6
  "license": "Apache-2.0",
@@ -40,7 +40,9 @@
40
40
  "public/",
41
41
  "scripts/install-docker-ubuntu.sh",
42
42
  "scripts/setup-agent-toolchain-docker.sh",
43
+ "scripts/real-deepseek-capabilities.js",
43
44
  "scripts/smoke-coding-tools.js",
45
+ "scripts/smoke-capabilities.js",
44
46
  "scripts/smoke-toolchain-docker.js",
45
47
  "scripts/smoke-web-api.js",
46
48
  "src/",
@@ -59,8 +61,10 @@
59
61
  "smoke:coding-tools": "node scripts/smoke-coding-tools.js",
60
62
  "smoke:toolchain-docker": "node scripts/smoke-toolchain-docker.js",
61
63
  "smoke:web-api": "node scripts/smoke-web-api.js",
62
- "test": "npm run check && npm run smoke:web-api && npm run smoke:coding-tools",
63
- "pack:dry-run": "npm pack --dry-run"
64
+ "real:deepseek": "node scripts/real-deepseek-capabilities.js",
65
+ "test": "npm run check && npm run smoke:web-api && npm run smoke:coding-tools && npm run smoke:capabilities",
66
+ "pack:dry-run": "npm pack --dry-run",
67
+ "smoke:capabilities": "node scripts/smoke-capabilities.js"
64
68
  },
65
69
  "dependencies": {
66
70
  "express": "^5.1.0",
@@ -0,0 +1,389 @@
1
+ #!/usr/bin/env node
2
+ import { execFile } from "node:child_process";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { fileURLToPath } from "node:url";
7
+ import { runAgent } from "../src/agent-runner.js";
8
+ import { buildCapabilityReport } from "../src/capabilities.js";
9
+ import { resolveRuntimeConfig } from "../src/config.js";
10
+ import { initProject, listProjectSessions, providerKeyStatus, showProjectSession } from "../src/project.js";
11
+
12
+ const execFileAsync = promisify(execFile);
13
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
14
+ const packageJson = JSON.parse(await fs.readFile(path.join(repoRoot, "package.json"), "utf8"));
15
+ const projectRoot = path.resolve(process.env.AGINTIFLOW_REAL_WORKSPACE || "/home/lachlan/ProjectsLFS/aginti-test");
16
+ const webBaseUrl = String(process.env.AGINTIFLOW_REAL_WEB_BASE_URL || "").replace(/\/$/, "");
17
+ const enabled = process.env.AGINTIFLOW_REAL_DEEPSEEK === "1";
18
+ const stamp = new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
19
+ function isJavaScriptTest(file) {
20
+ const base = path.basename(file);
21
+ return /^(test|.+\.(test|spec))\.[mc]?js$/.test(base) || (file.includes("/test/") && /\.[mc]?js$/.test(base));
22
+ }
23
+
24
+ function selectedCases() {
25
+ const raw = process.env.AGINTIFLOW_REAL_CASES || "flash,pro,latex,website,maintenance,aaps";
26
+ return new Set(raw.split(",").map((item) => item.trim()).filter(Boolean));
27
+ }
28
+
29
+ function baseConfig(goal, overrides = {}) {
30
+ return resolveRuntimeConfig(
31
+ {
32
+ provider: "deepseek",
33
+ routingMode: overrides.routingMode || "smart",
34
+ model: overrides.model || "",
35
+ goal,
36
+ commandCwd: projectRoot,
37
+ maxSteps: overrides.maxSteps || 12,
38
+ sandboxMode: overrides.sandboxMode || "host",
39
+ packageInstallPolicy: overrides.packageInstallPolicy || "block",
40
+ allowShellTool: overrides.allowShellTool ?? true,
41
+ allowFileTools: true,
42
+ taskProfile: overrides.taskProfile || "auto",
43
+ resume: overrides.resume || "",
44
+ sessionId: overrides.sessionId || "",
45
+ },
46
+ {
47
+ baseDir: projectRoot,
48
+ packageDir: repoRoot,
49
+ provider: "deepseek",
50
+ routingMode: overrides.routingMode || "smart",
51
+ model: overrides.model || "",
52
+ commandCwd: projectRoot,
53
+ maxSteps: overrides.maxSteps || 12,
54
+ sandboxMode: overrides.sandboxMode || "host",
55
+ packageInstallPolicy: overrides.packageInstallPolicy || "block",
56
+ allowShellTool: overrides.allowShellTool ?? true,
57
+ allowFileTools: true,
58
+ taskProfile: overrides.taskProfile || "auto",
59
+ resume: overrides.resume || "",
60
+ sessionId: overrides.resume ? "" : overrides.sessionId,
61
+ }
62
+ );
63
+ }
64
+
65
+ async function runCliCase(name, goal, overrides = {}) {
66
+ const config = baseConfig(goal, {
67
+ ...overrides,
68
+ sessionId: overrides.sessionId || `round9-${name}-${stamp}`,
69
+ });
70
+ const startedAt = new Date().toISOString();
71
+ const result = await runAgent(config);
72
+ const sessionId = result.sessionId || config.sessionId || config.resume;
73
+ const session = await showProjectSession(projectRoot, sessionId).catch(() => null);
74
+ return {
75
+ name,
76
+ channel: "cli",
77
+ sessionId,
78
+ provider: config.provider,
79
+ model: config.model,
80
+ routingMode: config.routingMode,
81
+ taskProfile: config.taskProfile,
82
+ startedAt,
83
+ result: result.result || "",
84
+ stopped: Boolean(result.stopped),
85
+ events: session?.events?.length || 0,
86
+ };
87
+ }
88
+
89
+ async function fetchJson(url, options = {}) {
90
+ const response = await fetch(url, options);
91
+ const data = await response.json().catch(() => ({}));
92
+ if (!response.ok) throw new Error(`${url} failed ${response.status}: ${data.error || response.statusText}`);
93
+ return data;
94
+ }
95
+
96
+ async function waitForWebRun(sessionId) {
97
+ const deadline = Date.now() + 180000;
98
+ while (Date.now() < deadline) {
99
+ const run = await fetchJson(`${webBaseUrl}/api/runs/${encodeURIComponent(sessionId)}`);
100
+ if (run.status === "finished" || run.status === "failed") return run;
101
+ await new Promise((resolve) => setTimeout(resolve, 1500));
102
+ }
103
+ throw new Error(`Web run timed out: ${sessionId}`);
104
+ }
105
+
106
+ async function runWebCase(name, goal, overrides = {}) {
107
+ if (!webBaseUrl) return null;
108
+ const started = await fetchJson(`${webBaseUrl}/api/runs`, {
109
+ method: "POST",
110
+ headers: { "Content-Type": "application/json" },
111
+ body: JSON.stringify({
112
+ provider: "deepseek",
113
+ routingMode: overrides.routingMode || "smart",
114
+ model: overrides.model || "",
115
+ goal,
116
+ commandCwd: projectRoot,
117
+ sandboxMode: overrides.sandboxMode || "host",
118
+ packageInstallPolicy: overrides.packageInstallPolicy || "block",
119
+ allowShellTool: overrides.allowShellTool ?? true,
120
+ allowFileTools: true,
121
+ maxSteps: overrides.maxSteps || 12,
122
+ taskProfile: overrides.taskProfile || "auto",
123
+ headless: true,
124
+ }),
125
+ });
126
+ const run = await waitForWebRun(started.sessionId);
127
+ return {
128
+ name,
129
+ channel: "web",
130
+ sessionId: started.sessionId,
131
+ provider: run.provider,
132
+ model: run.model,
133
+ status: run.status,
134
+ result: run.result || "",
135
+ error: run.error || "",
136
+ };
137
+ }
138
+
139
+ async function assertFile(relativePath) {
140
+ const absolutePath = path.join(projectRoot, relativePath);
141
+ const stat = await fs.stat(absolutePath).catch(() => null);
142
+ return {
143
+ path: relativePath,
144
+ exists: Boolean(stat),
145
+ size: stat?.size || 0,
146
+ };
147
+ }
148
+
149
+ async function listFilesRecursive(relativeDir) {
150
+ const root = path.join(projectRoot, relativeDir);
151
+ const entries = [];
152
+ async function visit(currentAbs, currentRel) {
153
+ const children = await fs.readdir(currentAbs, { withFileTypes: true }).catch(() => []);
154
+ for (const child of children) {
155
+ const childAbs = path.join(currentAbs, child.name);
156
+ const childRel = path.posix.join(currentRel.split(path.sep).join(path.posix.sep), child.name);
157
+ if (child.isDirectory()) await visit(childAbs, childRel);
158
+ else if (child.isFile()) entries.push(childRel);
159
+ }
160
+ }
161
+ await visit(root, relativeDir);
162
+ return entries;
163
+ }
164
+
165
+ async function runLocalCheck(command, args, cwdRel = ".") {
166
+ try {
167
+ const result = await execFileAsync(command, args, {
168
+ cwd: path.join(projectRoot, cwdRel),
169
+ timeout: 90000,
170
+ maxBuffer: 2 * 1024 * 1024,
171
+ env: {
172
+ ...process.env,
173
+ npm_config_loglevel: "warn",
174
+ },
175
+ });
176
+ return {
177
+ ok: true,
178
+ command: [command, ...args].join(" "),
179
+ stdout: String(result.stdout || "").slice(0, 1600),
180
+ stderr: String(result.stderr || "").slice(0, 1600),
181
+ };
182
+ } catch (error) {
183
+ return {
184
+ ok: false,
185
+ command: [command, ...args].join(" "),
186
+ stdout: String(error.stdout || "").slice(0, 1600),
187
+ stderr: String(error.stderr || error.message || "").slice(0, 1600),
188
+ };
189
+ }
190
+ }
191
+
192
+ function addCheck(report, name, ok, details = {}) {
193
+ report.checks.push({ name, ok: Boolean(ok), ...details });
194
+ if (!ok) report.ok = false;
195
+ }
196
+
197
+ function addStoppedWarning(report, run) {
198
+ if (run?.stopped) {
199
+ report.warnings.push(`${run.name} reached max steps before finish(); artifacts were validated separately.`);
200
+ }
201
+ }
202
+
203
+ if (!enabled) {
204
+ console.log(
205
+ JSON.stringify(
206
+ {
207
+ ok: true,
208
+ skipped: true,
209
+ reason: "Set AGINTIFLOW_REAL_DEEPSEEK=1 to run live DeepSeek capability checks.",
210
+ },
211
+ null,
212
+ 2
213
+ )
214
+ );
215
+ process.exit(0);
216
+ }
217
+
218
+ await initProject(projectRoot);
219
+ const keyStatus = providerKeyStatus(projectRoot);
220
+ if (!keyStatus.deepseek) {
221
+ console.log(
222
+ JSON.stringify(
223
+ {
224
+ ok: false,
225
+ skipped: true,
226
+ reason: "DeepSeek key is not available by env or project-local .aginti/.env.",
227
+ },
228
+ null,
229
+ 2
230
+ )
231
+ );
232
+ process.exit(0);
233
+ }
234
+
235
+ const cases = selectedCases();
236
+ const appDir = `round9/deepseek-app-${stamp}`;
237
+ const report = {
238
+ ok: true,
239
+ projectRoot,
240
+ webBaseUrl: webBaseUrl || "",
241
+ stamp,
242
+ capabilities: await buildCapabilityReport(
243
+ projectRoot,
244
+ packageJson.version,
245
+ baseConfig("capability report", { allowShellTool: true, allowFileTools: true })
246
+ ),
247
+ runs: [],
248
+ files: [],
249
+ checks: [],
250
+ warnings: [],
251
+ };
252
+
253
+ if (cases.has("flash")) {
254
+ const run = await runCliCase(
255
+ "flash-node-app",
256
+ `Create a small dependency-free Node and HTML app with tests under ${appDir}. Run safe checks if you can.`,
257
+ { routingMode: "fast", taskProfile: "node", maxSteps: 24 }
258
+ );
259
+ report.runs.push(run);
260
+ addStoppedWarning(report, run);
261
+ const files = await listFilesRecursive(appDir);
262
+ const testFile = files.find(isJavaScriptTest);
263
+ const appHasCode = files.some((file) => /\.(js|mjs)$/.test(file));
264
+ const appHasHtml = files.some((file) => /\.html$/.test(file));
265
+ const testResult = testFile ? await runLocalCheck("node", ["--test", testFile]) : { ok: false, command: "node --test <missing>" };
266
+ report.files.push(await assertFile(testFile || `${appDir}/test/app.test.js`));
267
+ addCheck(report, "flash-node-app-files", appHasCode && appHasHtml && Boolean(testFile), { files });
268
+ addCheck(report, "flash-node-app-tests", testResult.ok, testResult);
269
+ }
270
+
271
+ if (cases.has("pro")) {
272
+ const previous = report.runs.find((run) => run.name === "flash-node-app");
273
+ const run = await runCliCase(
274
+ "pro-improve-app",
275
+ `Improve the app in ${appDir} with a useful new feature and matching tests. Run safe checks if you can.`,
276
+ {
277
+ routingMode: "complex",
278
+ taskProfile: "code",
279
+ maxSteps: 24,
280
+ resume: previous?.sessionId || "",
281
+ sessionId: previous ? "" : `round9-pro-improve-${stamp}`,
282
+ }
283
+ );
284
+ report.runs.push(run);
285
+ addStoppedWarning(report, run);
286
+ const files = await listFilesRecursive(appDir);
287
+ const testFile = files.find(isJavaScriptTest);
288
+ const testResult = testFile ? await runLocalCheck("node", ["--test", testFile]) : { ok: false, command: "node --test <missing>" };
289
+ addCheck(report, "pro-improve-app-tests", testResult.ok, testResult);
290
+ }
291
+
292
+ if (cases.has("latex")) {
293
+ const texDir = `round9/latex-${stamp}`;
294
+ const run = await runCliCase(
295
+ "latex-report",
296
+ `Write a small LaTeX note under ${texDir} about AgInTiFlow capability testing. Compile it if TeX is available; otherwise create an honest setup note.`,
297
+ { routingMode: "complex", taskProfile: "latex", maxSteps: 16 }
298
+ );
299
+ report.runs.push(run);
300
+ addStoppedWarning(report, run);
301
+ const files = await listFilesRecursive(texDir);
302
+ report.files.push(await assertFile(files.find((file) => file.endsWith(".tex")) || `${texDir}/note.tex`));
303
+ addCheck(report, "latex-source", files.some((file) => file.endsWith(".tex")), { files });
304
+ addCheck(report, "latex-pdf-or-setup", files.some((file) => file.endsWith(".pdf") || /setup|readme/i.test(file)), { files });
305
+ }
306
+
307
+ if (cases.has("website")) {
308
+ const websiteDir = `round9/website-test-${stamp}`;
309
+ const run = await runCliCase(
310
+ "website-test",
311
+ `Create a small website under ${websiteDir} with a simple local test or check file. Run safe checks if you can without installing packages.`,
312
+ { routingMode: "fast", taskProfile: "website", maxSteps: 24 }
313
+ );
314
+ report.runs.push(run);
315
+ addStoppedWarning(report, run);
316
+ const files = await listFilesRecursive(websiteDir);
317
+ const nodeTest = files.find(isJavaScriptTest);
318
+ const pythonTest = files.find((file) => /(^|\/)(test_.*|.*_test)\.py$/.test(path.basename(file)));
319
+ const shellTest = files.find((file) => /^test[-_\w]*\.sh$/.test(path.basename(file)));
320
+ const testResult = nodeTest
321
+ ? await runLocalCheck("node", ["--test", nodeTest])
322
+ : pythonTest
323
+ ? await runLocalCheck("python3", [pythonTest])
324
+ : shellTest
325
+ ? await runLocalCheck("bash", ["-n", shellTest])
326
+ : { ok: false, command: "<missing website test>" };
327
+ report.files.push(await assertFile(nodeTest || pythonTest || shellTest || `${websiteDir}/test/website.test.js`));
328
+ addCheck(report, "website-test-files", files.some((file) => file.endsWith(".html")) && Boolean(nodeTest || pythonTest || shellTest), { files });
329
+ addCheck(report, "website-test-runs", testResult.ok, testResult);
330
+ }
331
+
332
+ if (cases.has("maintenance")) {
333
+ const maintenanceDir = `maintenance/round9-${stamp}`;
334
+ const run = await runCliCase(
335
+ "maintenance-plan",
336
+ `Create project-local dry-run maintenance plans and scripts under ${maintenanceDir} for Miniforge/conda, R, Python tooling, CmdStan/CmdStanR, and PyStan. Validate scripts safely if you can.`,
337
+ {
338
+ routingMode: "complex",
339
+ taskProfile: "maintenance",
340
+ sandboxMode: "docker-workspace",
341
+ packageInstallPolicy: "block",
342
+ maxSteps: 24,
343
+ }
344
+ );
345
+ report.runs.push(run);
346
+ addStoppedWarning(report, run);
347
+ const files = await listFilesRecursive(maintenanceDir);
348
+ const shellScripts = files.filter((file) => file.endsWith(".sh"));
349
+ const syntaxResults = [];
350
+ for (const script of shellScripts) syntaxResults.push(await runLocalCheck("bash", ["-n", script]));
351
+ report.files.push(await assertFile(files.find((file) => /\.md$/i.test(file)) || `${maintenanceDir}/README.md`));
352
+ addCheck(report, "maintenance-plan-files", files.some((file) => /\.md$/i.test(file)) && shellScripts.length > 0, { files });
353
+ addCheck(report, "maintenance-shell-syntax", syntaxResults.every((item) => item.ok), { syntaxResults });
354
+ }
355
+
356
+ if (cases.has("aaps")) {
357
+ const run = await runCliCase(
358
+ "aaps-sample",
359
+ "Create a small project-local AAPS sample and notes for @lazyingart/aaps workflows.",
360
+ { routingMode: "fast", taskProfile: "aaps", maxSteps: 14 }
361
+ );
362
+ report.runs.push(run);
363
+ addStoppedWarning(report, run);
364
+ const files = [
365
+ ...(await listFilesRecursive(".aaps")),
366
+ ...(await listFilesRecursive("aaps-sample")),
367
+ ];
368
+ const aapsFile = files.find((file) => file.includes(".aaps/") || file.endsWith(".aaps") || file.endsWith(".json"));
369
+ report.files.push(await assertFile(aapsFile || ".aaps/round9-sample.json"));
370
+ addCheck(report, "aaps-sample-files", Boolean(aapsFile), { files });
371
+ }
372
+
373
+ if (webBaseUrl) {
374
+ const run = await runWebCase(
375
+ "web-sync-real",
376
+ `Create notes/web-real-${stamp}.md with a short note that this web run shares the project session folder.`,
377
+ { routingMode: "fast", taskProfile: "code", maxSteps: 10 }
378
+ );
379
+ report.runs.push(run);
380
+ report.files.push(await assertFile(`notes/web-real-${stamp}.md`));
381
+ addCheck(report, "web-sync-file", (await assertFile(`notes/web-real-${stamp}.md`)).exists, {
382
+ sessionId: run?.sessionId,
383
+ status: run?.status,
384
+ });
385
+ }
386
+
387
+ report.sessions = await listProjectSessions(projectRoot, 20);
388
+ console.log(JSON.stringify(report, null, 2));
389
+ if (!report.ok) process.exitCode = 1;
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ import { execFile } from "node:child_process";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { promisify } from "node:util";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const execFileAsync = promisify(execFile);
10
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
11
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "agintiflow-capabilities-"));
12
+
13
+ function assert(condition, message) {
14
+ if (!condition) throw new Error(message);
15
+ }
16
+
17
+ async function runCli(args) {
18
+ const result = await execFileAsync(process.execPath, [path.join(repoRoot, "bin/aginti-cli.js"), ...args], {
19
+ cwd: tempRoot,
20
+ timeout: 20000,
21
+ maxBuffer: 2 * 1024 * 1024,
22
+ env: {
23
+ ...process.env,
24
+ AGINTIFLOW_RUNTIME_DIR: "",
25
+ },
26
+ });
27
+ return result.stdout;
28
+ }
29
+
30
+ try {
31
+ await runCli(["init"]);
32
+ const capabilities = JSON.parse(await runCli(["capabilities", "--json"]));
33
+ assert(capabilities.project.root === tempRoot, "capabilities did not use cwd as project root");
34
+ assert(capabilities.project.commandCwd === tempRoot, "capabilities did not default commandCwd to project root");
35
+ assert(capabilities.project.sharedSessionFolder, "capabilities did not report shared session folder");
36
+ assert(capabilities.keys?.mock === true, "capabilities did not report mock availability");
37
+ assert(
38
+ capabilities.checks.some((check) => check.name === "npm-prefix-test-policy" && check.ok),
39
+ "npm --prefix test policy is not allowed"
40
+ );
41
+ assert(
42
+ capabilities.checks.some((check) => check.name === "cd-npm-test-policy" && check.ok),
43
+ "cd <dir> && npm test policy is not allowed"
44
+ );
45
+ assert(
46
+ capabilities.checks.some((check) => check.name === "mkdir-policy" && check.ok),
47
+ "safe mkdir -p policy is not allowed"
48
+ );
49
+ assert(
50
+ capabilities.checks.some((check) => check.name === "bash-syntax-policy" && check.ok),
51
+ "bash -n maintenance script policy is not allowed"
52
+ );
53
+ assert(
54
+ capabilities.maintenancePolicy.some((check) => check.command.startsWith("sudo") && !check.allowed),
55
+ "sudo maintenance command was not blocked"
56
+ );
57
+
58
+ const doctor = JSON.parse(await runCli(["doctor", "--capabilities", "--json"]));
59
+ assert(doctor.project.root === tempRoot, "doctor --capabilities used the wrong project root");
60
+
61
+ console.log(
62
+ JSON.stringify(
63
+ {
64
+ ok: true,
65
+ projectRoot: tempRoot,
66
+ checks: ["capabilities-cli", "doctor-capabilities", "maintenance-policy"],
67
+ },
68
+ null,
69
+ 2
70
+ )
71
+ );
72
+ } finally {
73
+ await fs.rm(tempRoot, { recursive: true, force: true });
74
+ }
@@ -83,6 +83,13 @@ try {
83
83
  const keyStatus = await fetchJson("/api/keys/status");
84
84
  if (typeof keyStatus.keyStatus?.deepseek !== "boolean") throw new Error("key status endpoint is invalid");
85
85
  if ("localEnvPath" in keyStatus.keyStatus) throw new Error("key status leaked a local env path");
86
+ const capabilities = await fetchJson("/api/capabilities");
87
+ if (capabilities.project?.root !== runtimeDir || !Array.isArray(capabilities.checks)) {
88
+ throw new Error("capability endpoint returned an invalid project report");
89
+ }
90
+ if (!capabilities.checks.some((check) => check.name === "npm-prefix-test-policy")) {
91
+ throw new Error("capability endpoint did not include command policy checks");
92
+ }
86
93
  const savedKey = await fetchJson("/api/keys/deepseek", {
87
94
  method: "POST",
88
95
  headers: { "Content-Type": "application/json" },
@@ -231,6 +238,7 @@ try {
231
238
  endpoints: [
232
239
  "/api/config",
233
240
  "/api/keys/status",
241
+ "/api/capabilities",
234
242
  "POST /api/keys/:provider",
235
243
  "/api/sandbox/status",
236
244
  "/api/sandbox/preflight",
@@ -156,7 +156,10 @@ function createInitialState(config, sessionId) {
156
156
  `Task profile: ${taskProfile.label}. ${taskProfile.prompt}`,
157
157
  "A frontend canvas/artifacts tunnel exists. Use send_to_canvas when important markdown, diffs, screenshots, images, or workspace files should be highlighted in the UI. It is optional and ordinary final text can still go directly to finish.",
158
158
  "For visual-output requests such as draw, plot, graph, chart, diagram, figure, image, or visualization, proactively publish a canvas artifact even when the user does not mention canvas. If workspace file tools are enabled, prefer creating a small SVG or markdown artifact and call send_to_canvas with selected=true.",
159
- "For LaTeX/PDF requests, create the needed source/assets, compile with the available allowlisted TeX toolchain, and publish the resulting PDF through send_to_canvas. For subfolder documents, keep outputs beside the source. Use pdflatex-compatible figure formats such as PDF or PNG.",
159
+ "Work like a practical coding agent: inspect when useful, edit with file tools, run safe checks when they add confidence, and keep outputs inside the workspace.",
160
+ "Use the canvas tunnel for outputs the user would likely want to inspect visually, such as figures, PDFs, screenshots, images, important markdown, or generated files.",
161
+ "For environment or system-maintenance work, prefer project-local dry-run plans/scripts unless the configured policy explicitly allows stronger actions.",
162
+ "When the requested outcome is complete and a useful check has passed or been honestly skipped, stop and call finish.",
160
163
  "When done, call finish with a concise result.",
161
164
  ].join(" "),
162
165
  },
@@ -178,7 +181,8 @@ function createInitialState(config, sessionId) {
178
181
  `Task profile: ${taskProfile.label}. ${taskProfile.prompt}`,
179
182
  "Canvas/artifacts tunnel: available through send_to_canvas for optional frontend rendering.",
180
183
  "Visual-output requests should produce a canvas artifact without requiring the user to ask for canvas explicitly.",
181
- "LaTeX/PDF requests should produce source artifacts and, when possible, a compiled PDF artifact. For subfolder documents, keep outputs beside the source. For figure-in-document tasks, create PDF/PNG figures that pdflatex can include.",
184
+ "Use file, shell, browser, canvas, and wrapper tools when they are useful; choose the workflow from the user's request.",
185
+ "Keep environment or system-maintenance actions project-local and reversible unless policy explicitly permits stronger actions.",
182
186
  ]
183
187
  .filter(Boolean)
184
188
  .join("\n"),
@@ -369,22 +373,31 @@ function sanitizeToolResult(result) {
369
373
  }
370
374
 
371
375
  async function runShellCommand(command, config, policy = evaluateCommandPolicy(command, config)) {
372
- if (config.useDockerSandbox) {
373
- return runDockerSandboxCommand(command, config, policy);
374
- }
375
-
376
- const result = await exec(command, {
377
- cwd: config.commandCwd,
378
- timeout: 5000,
379
- maxBuffer: 200 * 1024,
380
- shell: "/bin/bash",
381
- env: safeExecutionEnv(),
382
- });
376
+ try {
377
+ const result = config.useDockerSandbox
378
+ ? await runDockerSandboxCommand(command, config, policy)
379
+ : await exec(command, {
380
+ cwd: config.commandCwd,
381
+ timeout: 30000,
382
+ maxBuffer: 200 * 1024,
383
+ shell: "/bin/bash",
384
+ env: safeExecutionEnv(),
385
+ });
383
386
 
384
- return {
385
- stdout: redactSensitiveText(result.stdout).trim().slice(0, 8000),
386
- stderr: redactSensitiveText(result.stderr).trim().slice(0, 4000),
387
- };
387
+ return {
388
+ ok: true,
389
+ exitCode: 0,
390
+ stdout: redactSensitiveText(result.stdout).trim().slice(0, 8000),
391
+ stderr: redactSensitiveText(result.stderr).trim().slice(0, 4000),
392
+ };
393
+ } catch (error) {
394
+ return {
395
+ ok: false,
396
+ exitCode: Number.isInteger(error?.code) ? error.code : 1,
397
+ stdout: redactSensitiveText(String(error?.stdout || "")).trim().slice(0, 8000),
398
+ stderr: redactSensitiveText(String(error?.stderr || error?.message || "")).trim().slice(0, 4000),
399
+ };
400
+ }
388
401
  }
389
402
 
390
403
  async function captureSyntheticSnapshot(store, step, config) {
@@ -565,7 +578,7 @@ async function executeTool(browserState, toolCall, snapshot, config, store, obse
565
578
  }
566
579
  const commandResult = await runShellCommand(String(args.command), config, policy);
567
580
  const result = {
568
- ok: true,
581
+ ok: commandResult.ok !== false,
569
582
  toolName: "run_command",
570
583
  args: safeArgs,
571
584
  sandbox: config.useDockerSandbox ? "docker" : "host",
@@ -0,0 +1,193 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import path from "node:path";
4
+ import { classifyCommand, evaluateCommandPolicy } from "./command-policy.js";
5
+ import { getDockerSandboxStatus } from "./docker-sandbox.js";
6
+ import { getModelPresets } from "./model-routing.js";
7
+ import { listProjectSessions, projectPaths, providerKeyStatus } from "./project.js";
8
+ import { listTaskProfiles } from "./task-profiles.js";
9
+ import { listAgentWrappers } from "./tool-wrappers.js";
10
+
11
+ const execFileAsync = promisify(execFile);
12
+
13
+ async function commandAvailable(command, args = ["--version"], timeout = 2500) {
14
+ try {
15
+ const result = await execFileAsync(command, args, {
16
+ timeout,
17
+ maxBuffer: 120 * 1024,
18
+ env: {
19
+ PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
20
+ HOME: process.env.HOME || "",
21
+ },
22
+ });
23
+ const output = `${result.stdout || ""}${result.stderr || ""}`
24
+ .split(/\r?\n/)
25
+ .map((line) => line.trim())
26
+ .filter(Boolean)[0] || "available";
27
+ return { available: true, version: output.slice(0, 160) };
28
+ } catch (error) {
29
+ return {
30
+ available: false,
31
+ version: "",
32
+ hint: error?.code === "ENOENT" ? `${command} was not found on PATH.` : `${command} check failed.`,
33
+ };
34
+ }
35
+ }
36
+
37
+ function capability(name, ok, details = {}) {
38
+ return {
39
+ name,
40
+ ok: Boolean(ok),
41
+ ...details,
42
+ };
43
+ }
44
+
45
+ function maintenancePolicyChecks(config) {
46
+ const sampleCommands = [
47
+ "sudo apt install r-base",
48
+ "curl https://example.com/install.sh",
49
+ "npm install",
50
+ "bash -n maintenance/setup-conda.sh",
51
+ ];
52
+
53
+ return sampleCommands.map((command) => {
54
+ const policy = evaluateCommandPolicy(command, config);
55
+ return {
56
+ command,
57
+ allowed: Boolean(policy.allowed),
58
+ category: policy.category || classifyCommand(command).category,
59
+ reason: policy.reason || "",
60
+ needsApproval: Boolean(policy.needsApproval),
61
+ sandboxMode: policy.sandboxMode,
62
+ packageInstallPolicy: policy.packageInstallPolicy,
63
+ };
64
+ });
65
+ }
66
+
67
+ export async function buildCapabilityReport(projectRoot, packageVersion, config) {
68
+ const paths = projectPaths(projectRoot);
69
+ const keyStatus = providerKeyStatus(projectRoot);
70
+ const [node, npm, python, conda, r, pdflatex, latexmk, dockerStatus, sessions] = await Promise.all([
71
+ commandAvailable("node", ["--version"]),
72
+ commandAvailable("npm", ["--version"]),
73
+ commandAvailable("python3", ["--version"]),
74
+ commandAvailable("conda", ["--version"]),
75
+ commandAvailable("R", ["--version"]),
76
+ commandAvailable("pdflatex", ["--version"]),
77
+ commandAvailable("latexmk", ["--version"]),
78
+ getDockerSandboxStatus(config).catch((error) => ({ ok: false, error: error.message })),
79
+ listProjectSessions(projectRoot, 12),
80
+ ]);
81
+
82
+ const npmPrefixPolicy = evaluateCommandPolicy("npm --prefix round9-node-app test", config);
83
+ const cdNpmTestPolicy = evaluateCommandPolicy("cd round9-node-app && npm test", config);
84
+ const mkdirPolicy = evaluateCommandPolicy("mkdir -p round9-node-app", config);
85
+ const nodeTestPolicy = evaluateCommandPolicy("node --test round9-node-app/test/app.test.js", config);
86
+ const bashSyntaxPolicy = evaluateCommandPolicy("bash -n maintenance/setup-conda.sh", config);
87
+ const texPolicy = evaluateCommandPolicy("pdflatex -interaction=nonstopmode -halt-on-error docs/note.tex", config);
88
+
89
+ const checks = [
90
+ capability("node", node.available, node),
91
+ capability("npm", npm.available, npm),
92
+ capability("python3", python.available, python),
93
+ capability("conda", conda.available, conda.available ? conda : { ...conda, setup: "Optional. Generate a dry-run Miniforge setup plan under maintenance/ before installing." }),
94
+ capability("R", r.available, r.available ? r : { ...r, setup: "Optional. Generate a project-local R setup plan; do not install globally from the agent." }),
95
+ capability("pdflatex", pdflatex.available, pdflatex.available ? pdflatex : { ...pdflatex, setup: "LaTeX tasks should create .tex source and an honest setup report when TeX is unavailable." }),
96
+ capability("latexmk", latexmk.available, latexmk.available ? latexmk : { ...latexmk, setup: "latexmk is optional if pdflatex is available." }),
97
+ capability("docker", Boolean(dockerStatus?.dockerAvailable), dockerStatus || {}),
98
+ capability("deepseek-key", keyStatus.deepseek, { envVars: keyStatus.envVars.deepseek }),
99
+ capability("openai-key", keyStatus.openai, { envVars: keyStatus.envVars.openai }),
100
+ capability("file-tools", Boolean(config.allowFileTools), { workspace: config.commandCwd }),
101
+ capability("shell-tool", Boolean(config.allowShellTool), {
102
+ sandboxMode: config.sandboxMode,
103
+ packageInstallPolicy: config.packageInstallPolicy,
104
+ }),
105
+ capability("npm-prefix-test-policy", Boolean(npmPrefixPolicy.allowed), npmPrefixPolicy),
106
+ capability("cd-npm-test-policy", Boolean(cdNpmTestPolicy.allowed), cdNpmTestPolicy),
107
+ capability("mkdir-policy", Boolean(mkdirPolicy.allowed), mkdirPolicy),
108
+ capability("node-test-policy", Boolean(nodeTestPolicy.allowed), nodeTestPolicy),
109
+ capability("bash-syntax-policy", Boolean(bashSyntaxPolicy.allowed), bashSyntaxPolicy),
110
+ capability("tex-policy", Boolean(texPolicy.allowed), texPolicy),
111
+ ];
112
+
113
+ return {
114
+ ok: true,
115
+ generatedAt: new Date().toISOString(),
116
+ package: {
117
+ name: "@lazyingart/agintiflow",
118
+ version: packageVersion,
119
+ },
120
+ project: {
121
+ root: paths.root,
122
+ commandCwd: path.resolve(config.commandCwd),
123
+ sessionsDir: paths.sessionsDir,
124
+ sessionDbPath: paths.sessionDbPath,
125
+ sharedSessionFolder: path.resolve(config.sessionsDir) === path.resolve(paths.sessionsDir),
126
+ },
127
+ routing: {
128
+ active: {
129
+ provider: config.provider,
130
+ model: config.model,
131
+ routingMode: config.routingMode,
132
+ routeReason: config.routeReason,
133
+ },
134
+ presets: getModelPresets(),
135
+ },
136
+ keys: {
137
+ deepseek: keyStatus.deepseek,
138
+ openai: keyStatus.openai,
139
+ mock: true,
140
+ localEnv: keyStatus.localEnv,
141
+ envVars: keyStatus.envVars,
142
+ },
143
+ tools: {
144
+ wrappers: listAgentWrappers().map((wrapper) => ({
145
+ name: wrapper.name,
146
+ label: wrapper.label,
147
+ available: wrapper.available,
148
+ role: wrapper.role,
149
+ })),
150
+ taskProfiles: listTaskProfiles().map((profile) => ({
151
+ id: profile.id,
152
+ label: profile.label,
153
+ tools: profile.tools,
154
+ })),
155
+ },
156
+ checks,
157
+ maintenancePolicy: maintenancePolicyChecks(config),
158
+ sessions,
159
+ actionableSetup: checks
160
+ .filter((check) => !check.ok && check.setup)
161
+ .map((check) => ({
162
+ capability: check.name,
163
+ setup: check.setup,
164
+ })),
165
+ };
166
+ }
167
+
168
+ export function printCapabilityReport(report) {
169
+ console.log(`AgInTiFlow capabilities ${report.package.version}`);
170
+ console.log(`project=${report.project.root}`);
171
+ console.log(`cwd=${report.project.commandCwd}`);
172
+ console.log(`sessions=${report.project.sessionsDir}`);
173
+ console.log(`sessionDb=${report.project.sessionDbPath}`);
174
+ console.log(`sharedSessions=${report.project.sharedSessionFolder}`);
175
+ console.log(
176
+ `route=${report.routing.active.routingMode} ${report.routing.active.provider}/${report.routing.active.model}`
177
+ );
178
+ console.log(
179
+ `keys: deepseek=${report.keys.deepseek ? "available" : "missing"} openai=${
180
+ report.keys.openai ? "available" : "missing"
181
+ } mock=available localEnv=${report.keys.localEnv}`
182
+ );
183
+ for (const check of report.checks) {
184
+ const suffix = check.version ? ` ${check.version}` : check.reason ? ` ${check.reason}` : check.hint ? ` ${check.hint}` : "";
185
+ console.log(`${check.ok ? "OK" : "MISS"} ${check.name}${suffix}`);
186
+ }
187
+ if (report.actionableSetup.length > 0) {
188
+ console.log("setup:");
189
+ for (const item of report.actionableSetup) {
190
+ console.log(`- ${item.capability}: ${item.setup}`);
191
+ }
192
+ }
193
+ }
package/src/cli.js CHANGED
@@ -3,6 +3,7 @@ import { loadConfig } from "./config.js";
3
3
  import { listAgentWrappers } from "./tool-wrappers.js";
4
4
  import { getModelPresets } from "./model-routing.js";
5
5
  import { getDockerSandboxStatus, runDockerPreflight } from "./docker-sandbox.js";
6
+ import { buildCapabilityReport, printCapabilityReport } from "./capabilities.js";
6
7
  import {
7
8
  doctorReport,
8
9
  initProject,
@@ -306,19 +307,53 @@ async function handleSessionsCommand(argv) {
306
307
  }
307
308
 
308
309
  export async function main(argv = process.argv.slice(2)) {
310
+ if (argv[0] === "--version" || argv[0] === "version" || argv[0] === "-v") {
311
+ console.log(packageJson.version);
312
+ return;
313
+ }
314
+
309
315
  if (argv[0] === "init") {
310
316
  printInitResult(await initProject(process.cwd()));
311
317
  return;
312
318
  }
313
319
 
314
320
  if (argv[0] === "doctor") {
315
- const config = loadConfig({ goal: "doctor" }, { packageDir, baseDir: process.cwd() });
316
- const report = await doctorReport(process.cwd(), packageJson.version, config);
321
+ const parsed = parseArgs(argv.slice(1).filter((arg) => arg !== "--json" && arg !== "--capabilities"));
322
+ const config = loadConfig(
323
+ {
324
+ ...parsed,
325
+ goal: "doctor",
326
+ allowShellTool: parsed.allowShellTool ?? true,
327
+ allowFileTools: parsed.allowFileTools ?? true,
328
+ },
329
+ { packageDir, baseDir: process.cwd() }
330
+ );
331
+ const report = argv.includes("--capabilities")
332
+ ? await buildCapabilityReport(process.cwd(), packageJson.version, config)
333
+ : await doctorReport(process.cwd(), packageJson.version, config);
317
334
  if (argv.includes("--json")) console.log(JSON.stringify(report, null, 2));
335
+ else if (argv.includes("--capabilities")) printCapabilityReport(report);
318
336
  else printDoctorReport(report);
319
337
  return;
320
338
  }
321
339
 
340
+ if (argv[0] === "capabilities") {
341
+ const parsed = parseArgs(argv.slice(1).filter((arg) => arg !== "--json"));
342
+ const config = loadConfig(
343
+ {
344
+ ...parsed,
345
+ goal: "capabilities",
346
+ allowShellTool: parsed.allowShellTool ?? true,
347
+ allowFileTools: parsed.allowFileTools ?? true,
348
+ },
349
+ { packageDir, baseDir: process.cwd() }
350
+ );
351
+ const report = await buildCapabilityReport(process.cwd(), packageJson.version, config);
352
+ if (argv.includes("--json")) console.log(JSON.stringify(report, null, 2));
353
+ else printCapabilityReport(report);
354
+ return;
355
+ }
356
+
322
357
  if (argv[0] === "keys/status") {
323
358
  await handleKeyCommand(["status"]);
324
359
  return;
@@ -27,12 +27,19 @@ const READ_ONLY_PATTERNS = [
27
27
 
28
28
  const TEST_PATTERNS = [
29
29
  /^npm\s+(run\s+)?(check|test|build|lint)(?:\s+--\s+[-\w./:=]+)*$/,
30
+ /^npm\s+--prefix\s+[-\w./]+\s+(run\s+)?(check|test|build|lint)(?:\s+--\s+[-\w./:=]+)*$/,
30
31
  /^npm\s+test$/,
31
32
  /^node\s+--check\s+[-\w./]+$/,
33
+ /^node\s+--test(?:\s+[-\w./]+)*$/,
34
+ /^bash\s+-n\s+[-\w./]+\.sh$/,
35
+ /^sh\s+-n\s+[-\w./]+\.sh$/,
36
+ /^python(?:3)?\s+-m\s+py_compile\s+[-\w./]+\.py$/,
32
37
  /^python(?:3)?\s+-m\s+pytest(?:\s+[-\w./:=]+)*$/,
33
38
  /^pytest(?:\s+[-\w./:=]+)*$/,
34
39
  ];
35
40
 
41
+ const SAFE_WORKSPACE_WRITE_PATTERNS = [/^mkdir\s+-p\s+[-\w./]+$/];
42
+
36
43
  const TOOLCHAIN_PATTERNS = [
37
44
  /^python(?:3)?\s+[-\w./]+\.py(?:\s+[-\w./:=]+)*$/,
38
45
  /^latexmk\s+(?=[-\w./=\s]*-pdf\b)(?:(?:-cd|-pdf|-interaction=nonstopmode|-halt-on-error|-output-directory=[-\w./]+)\s+)+[-\w./]+\.tex$/,
@@ -107,10 +114,19 @@ function matchAny(patterns, command) {
107
114
  return patterns.some((pattern) => pattern.test(command));
108
115
  }
109
116
 
110
- export function classifyCommand(command) {
111
- const normalized = String(command || "").trim();
112
- if (!normalized) return { category: "blocked", reason: "Command is empty." };
117
+ function isSafeRelativeDir(value) {
118
+ const normalized = String(value || "").trim();
119
+ if (!normalized || normalized.startsWith("/") || normalized.startsWith("~")) return false;
120
+ return normalized.split("/").every((part) => part && part !== "." && part !== "..");
121
+ }
122
+
123
+ function isSafeVirtualWorkspaceDir(value) {
124
+ const normalized = String(value || "").trim();
125
+ if (!normalized.startsWith("/workspace/")) return false;
126
+ return isSafeRelativeDir(normalized.replace(/^\/workspace\//, ""));
127
+ }
113
128
 
129
+ function classifySimpleCommand(normalized) {
114
130
  if (ALWAYS_BLOCKED_PATTERNS.some((pattern) => pattern.test(normalized))) {
115
131
  return { category: "blocked", reason: "Command is blocked because it may expose secrets or publish packages." };
116
132
  }
@@ -119,6 +135,14 @@ export function classifyCommand(command) {
119
135
  if (BLOCKED_SHELL_TOKENS.some((part) => normalized.includes(part))) {
120
136
  return { category: "blocked", reason: `Command contains blocked shell syntax: ${normalized}` };
121
137
  }
138
+ if (matchAny(SAFE_WORKSPACE_WRITE_PATTERNS, normalized)) {
139
+ const target = normalized.replace(/^mkdir\s+-p\s+/, "");
140
+ const virtualWorkspacePath = isSafeVirtualWorkspaceDir(target);
141
+ if (!isSafeRelativeDir(target) && !virtualWorkspacePath) {
142
+ return { category: "blocked", reason: `mkdir target must be a safe workspace-relative directory: ${target}` };
143
+ }
144
+ return { category: "workspace-write", needsNetwork: false, writesWorkspace: true, virtualWorkspacePath };
145
+ }
122
146
  if (BLOCKED_WRITE_TOKENS.some((part) => lowered.includes(part))) {
123
147
  return { category: "blocked", reason: `Command contains a write-capable or network token: ${normalized}` };
124
148
  }
@@ -142,6 +166,26 @@ export function classifyCommand(command) {
142
166
  return { category: "blocked", reason: `Command is outside the execution allowlist: ${normalized}` };
143
167
  }
144
168
 
169
+ function classifyCdCommand(normalized) {
170
+ const match = normalized.match(/^cd\s+([-\w./]+)\s+&&\s+(.+)$/);
171
+ if (!match) return null;
172
+ const [, dir, inner] = match;
173
+ const virtualWorkspacePath = isSafeVirtualWorkspaceDir(dir);
174
+ if (!isSafeRelativeDir(dir) && !virtualWorkspacePath) {
175
+ return { category: "blocked", reason: `cd target must be a safe workspace-relative directory: ${dir}` };
176
+ }
177
+ const innerClassification = classifySimpleCommand(inner.trim());
178
+ if (innerClassification.category === "blocked") return innerClassification;
179
+ return { ...innerClassification, cdDir: dir, virtualWorkspacePath };
180
+ }
181
+
182
+ export function classifyCommand(command) {
183
+ const normalized = String(command || "").trim();
184
+ if (!normalized) return { category: "blocked", reason: "Command is empty." };
185
+
186
+ return classifyCdCommand(normalized) || classifySimpleCommand(normalized);
187
+ }
188
+
145
189
  export function evaluateCommandPolicy(command, config) {
146
190
  const classification = classifyCommand(command);
147
191
  const sandboxMode = normalizeSandboxMode(config.sandboxMode);
@@ -151,6 +195,16 @@ export function evaluateCommandPolicy(command, config) {
151
195
  return { allowed: false, ...classification, sandboxMode, packageInstallPolicy };
152
196
  }
153
197
 
198
+ if (classification.virtualWorkspacePath && !config.useDockerSandbox) {
199
+ return {
200
+ allowed: false,
201
+ ...classification,
202
+ reason: "Virtual /workspace shell paths are allowed only inside Docker sandbox mode.",
203
+ sandboxMode,
204
+ packageInstallPolicy,
205
+ };
206
+ }
207
+
154
208
  if (!config.allowShellTool) {
155
209
  return {
156
210
  allowed: false,
@@ -156,8 +156,10 @@ export async function createPlan(client, config, state) {
156
156
  : "",
157
157
  `Task profile: ${taskProfile.label}. ${taskProfile.prompt}`,
158
158
  "A canvas/artifacts tunnel is available through send_to_canvas. Use it when an output should be highlighted visually, such as screenshots, image files, important markdown, diffs, or generated artifact paths. It is optional for ordinary text answers.",
159
- "When the user asks to draw, plot, graph, chart, diagram, create a figure, or visualize something, include a canvas artifact even if the user does not mention canvas. Prefer a small SVG file or concise markdown figure when file tools are available.",
160
- "When the user asks for LaTeX, TeX, a paper, manuscript, report, or PDF, plan to create the needed source/assets, compile with the available allowlisted TeX toolchain, and publish the PDF through the canvas tunnel. For subfolder documents, keep outputs beside the source. For generated figures, use pdflatex-compatible formats such as PDF or PNG.",
159
+ "Work like a practical coding agent: inspect when useful, edit with file tools, run safe checks when they add confidence, and keep outputs inside the workspace.",
160
+ "Use the canvas tunnel for outputs the user would likely want to inspect visually, such as figures, PDFs, screenshots, images, important markdown, or generated files.",
161
+ "For environment or system-maintenance work, prefer project-local dry-run plans/scripts unless the configured policy explicitly allows stronger actions.",
162
+ "Plan for a complete result, not endless exploration; finish once the request is satisfied and checks have passed or been honestly skipped.",
161
163
  "Return a numbered plan only.",
162
164
  ]
163
165
  .filter(Boolean)
@@ -10,7 +10,7 @@ export const TASK_PROFILES = {
10
10
  id: "code",
11
11
  label: "Code writing",
12
12
  prompt:
13
- "Act like a coding agent: inspect files first, make targeted workspace-local edits, run relevant checks, and report changed files and residual risks.",
13
+ "Act like a coding agent: understand the request, edit workspace files, run useful safe checks, and report changed files and residual risks.",
14
14
  tools: ["files", "shell", "sandbox"],
15
15
  },
16
16
  writing: {
@@ -45,14 +45,21 @@ export const TASK_PROFILES = {
45
45
  id: "node",
46
46
  label: "Node",
47
47
  prompt:
48
- "For Node.js tasks, inspect package scripts, use npm checks/tests when safe, and keep generated files inside the project workspace.",
48
+ "For Node.js tasks, use the local project structure, add tests when useful, and run safe npm/node checks when available.",
49
49
  tools: ["files", "shell", "sandbox"],
50
50
  },
51
+ website: {
52
+ id: "website",
53
+ label: "Website testing",
54
+ prompt:
55
+ "For website-testing tasks, create or inspect the site, add a simple local test/check file when useful, and run safe checks without installing packages unless approved.",
56
+ tools: ["files", "shell", "canvas", "sandbox"],
57
+ },
51
58
  aaps: {
52
59
  id: "aaps",
53
60
  label: "AAPS",
54
61
  prompt:
55
- "For AAPS tasks, recognize .aaps folders and @lazyingart/aaps package workflows. Prefer project-local config, safe publish preparation, and explicit secret handling.",
62
+ "For AAPS tasks, recognize .aaps folders and @lazyingart/aaps workflows, keep work project-local, and avoid secrets or publishing.",
56
63
  tools: ["files", "shell", "sandbox"],
57
64
  },
58
65
  latex: {
@@ -66,7 +73,7 @@ export const TASK_PROFILES = {
66
73
  id: "maintenance",
67
74
  label: "System maintenance",
68
75
  prompt:
69
- "For system maintenance, diagnose first, prefer idempotent scripts, ask for approval before privileged or destructive operations, and avoid leaking credentials.",
76
+ "For system maintenance, diagnose first, prefer reversible project-local plans or dry-run scripts, and avoid privileged/global/destructive actions unless explicitly approved.",
70
77
  tools: ["shell", "sandbox", "files"],
71
78
  },
72
79
  };
package/web.js CHANGED
@@ -13,6 +13,7 @@ import { normalizePackageInstallPolicy, normalizeSandboxMode } from "./src/comma
13
13
  import { summarizeWorkspaceTools, WORKSPACE_TOOL_NAMES } from "./src/workspace-tools.js";
14
14
  import { listTaskProfiles, normalizeTaskProfile } from "./src/task-profiles.js";
15
15
  import { loadProjectEnv, projectPaths, providerKeyStatus, setProviderKey } from "./src/project.js";
16
+ import { buildCapabilityReport } from "./src/capabilities.js";
16
17
  import {
17
18
  buildArtifacts,
18
19
  countUnreadArtifacts,
@@ -24,6 +25,7 @@ import {
24
25
  const __filename = fileURLToPath(import.meta.url);
25
26
  const __dirname = path.dirname(__filename);
26
27
  const packageDir = __dirname;
28
+ const packageJson = JSON.parse(await fs.readFile(path.join(packageDir, "package.json"), "utf8"));
27
29
  const baseDir = path.resolve(process.env.AGINTIFLOW_RUNTIME_DIR || process.cwd());
28
30
  const sessionsDir = path.join(baseDir, ".sessions");
29
31
  loadProjectEnv(baseDir);
@@ -539,6 +541,18 @@ app.get("/api/keys/status", (_req, res) => {
539
541
  res.json({ ok: true, keyStatus: publicKeyStatus(baseDir) });
540
542
  });
541
543
 
544
+ app.get("/api/capabilities", async (_req, res) => {
545
+ const preferences = normalizePreferencePayload({}, db.getPreferences());
546
+ const config = buildRunConfig({
547
+ ...preferences,
548
+ goal: "capabilities",
549
+ allowShellTool: true,
550
+ allowFileTools: true,
551
+ });
552
+ const report = await buildCapabilityReport(baseDir, packageJson.version, config);
553
+ res.json(report);
554
+ });
555
+
542
556
  app.post("/api/keys/:provider", async (req, res) => {
543
557
  try {
544
558
  const result = await setProviderKey(baseDir, req.params.provider, req.body?.apiKey || req.body?.key || "");