@saadnvd1/agent-os 0.3.654 → 0.3.657

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/app/api/health/env-failures/route.test.ts +60 -0
  2. package/app/api/health/env-failures/route.ts +51 -0
  3. package/app/api/shares/pdf/route.test.ts +122 -0
  4. package/app/api/shares/pdf/route.ts +31 -0
  5. package/app/api/shares/preview/route.ts +5 -0
  6. package/components/Health/EnvFailures/ClassRow.tsx +104 -0
  7. package/components/Health/EnvFailures/SessionList.tsx +49 -0
  8. package/components/Health/EnvFailures/index.tsx +46 -0
  9. package/components/Health/HealthPanel.tsx +11 -1
  10. package/components/Share/DownloadMenu.tsx +112 -0
  11. package/components/Share/ShareDialog.tsx +31 -47
  12. package/data/health/index.ts +31 -0
  13. package/data/shares/queries.ts +11 -0
  14. package/lib/agents/brief.ts +1 -1
  15. package/lib/agents/self-env.ts +1 -0
  16. package/lib/chat/runner.ts +3 -0
  17. package/lib/db/migrations/20261016T1800_env_failures.ts +33 -0
  18. package/lib/dev-instance/index.ts +6 -0
  19. package/lib/dev-instance/liveServer.ts +46 -0
  20. package/lib/dev-instance/pickPort.ts +66 -0
  21. package/lib/dev-instance/processInfo.ts +68 -0
  22. package/lib/dev-instance/spawnServer.ts +36 -0
  23. package/lib/dev-instance/startDevServer.ts +80 -0
  24. package/lib/dev-instance/stopDevServer.ts +98 -0
  25. package/lib/dev-instance/types.ts +13 -0
  26. package/lib/health/config.ts +8 -0
  27. package/lib/health/env/classes.ts +121 -0
  28. package/lib/health/env/classify.ts +67 -0
  29. package/lib/health/env/envTick.ts +46 -0
  30. package/lib/health/env/fixText.ts +24 -0
  31. package/lib/health/env/helpers.test-util.ts +21 -0
  32. package/lib/health/env/index.ts +6 -0
  33. package/lib/health/env/raiseAlerts.ts +84 -0
  34. package/lib/health/env/recordHit.ts +62 -0
  35. package/lib/health/env/sendFixNotes.ts +80 -0
  36. package/lib/health/env/summarize.ts +94 -0
  37. package/lib/health/env/types.ts +73 -0
  38. package/lib/health/tick.ts +6 -0
  39. package/lib/security/demo.ts +1 -0
  40. package/lib/share/pdf.ts +116 -0
  41. package/lib/share/view.ts +9 -1
  42. package/package.json +5 -3
  43. package/scripts/dev-stop.ts +7 -0
  44. package/scripts/dev-task.ts +16 -0
  45. package/scripts/screenshots/demo.ts +4 -8
  46. package/scripts/screenshots/run.ts +5 -8
  47. package/server.ts +25 -0
@@ -0,0 +1,73 @@
1
+ // What the environment-failure detector knows and shows. Plain data, no I/O.
2
+
3
+ export interface EnvClass {
4
+ id: string;
5
+ label: string;
6
+ // shell: only shell tools' output; guard: any tool's refused call.
7
+ scope: "shell" | "guard";
8
+ // Any of these, tested per line.
9
+ match: RegExp[];
10
+ // Also has to hold, tested on the whole output.
11
+ requires?: RegExp;
12
+ // Vetoes: a command or output that shows it isn't this machine's fault.
13
+ except?: { command?: RegExp; output?: RegExp };
14
+ // Sent once to a session that hit it; null means record and escalate only.
15
+ fix: string | null;
16
+ }
17
+
18
+ export interface EnvHit {
19
+ class: string;
20
+ line: string;
21
+ command: string;
22
+ }
23
+
24
+ export interface EnvFailureRow {
25
+ id: number;
26
+ session_id: string;
27
+ class: string;
28
+ project_id: string | null;
29
+ workspace_id: string | null;
30
+ command: string | null;
31
+ line: string | null;
32
+ last_item_id: string | null;
33
+ hits: number;
34
+ first_at: number;
35
+ last_at: number;
36
+ nudged_at: number | null;
37
+ nudge_skipped: string | null;
38
+ alerted: string | null;
39
+ alerted_at: number | null;
40
+ }
41
+
42
+ export interface EnvClassView {
43
+ id: string;
44
+ label: string;
45
+ fix: string | null;
46
+ fixOverridden: boolean;
47
+ sessions7: number;
48
+ sessions30: number;
49
+ projects: {
50
+ id: string | null;
51
+ name: string;
52
+ sessions7: number;
53
+ sessions30: number;
54
+ }[];
55
+ }
56
+
57
+ export interface EnvSummary {
58
+ now: number;
59
+ classes: EnvClassView[];
60
+ }
61
+
62
+ export interface EnvSessionView {
63
+ sessionId: string;
64
+ name: string;
65
+ project: string | null;
66
+ command: string | null;
67
+ line: string | null;
68
+ hits: number;
69
+ firstAt: number;
70
+ lastAt: number;
71
+ // "sent", or why not.
72
+ fix: string;
73
+ }
@@ -10,6 +10,7 @@ import { leaveHeartbeat } from "./boots";
10
10
  import { healthConfig, type HealthConfig } from "./config";
11
11
  import { runDetectors } from "./detectors";
12
12
  import { gatherFacts, liveBackgroundJobs } from "./facts";
13
+ import { envTick, realEnv, type EnvDeps } from "./env/envTick";
13
14
  import { addNote } from "./notes";
14
15
  import { closeResolvedPieces } from "./pieces";
15
16
  import { deliver, deliverClosed, type RouteDeps, realRoute } from "./route";
@@ -52,6 +53,7 @@ export interface TickDeps {
52
53
  facts: (now: number, c: HealthConfig) => HealthFacts;
53
54
  watch: WatchDeps;
54
55
  route: RouteDeps;
56
+ env: EnvDeps;
55
57
  wake: typeof wake;
56
58
  }
57
59
 
@@ -60,6 +62,7 @@ export const realTick: TickDeps = {
60
62
  facts: gatherFacts,
61
63
  watch: { run: runClaude, usagePct: currentUsagePct },
62
64
  route: realRoute,
65
+ env: realEnv,
63
66
  wake,
64
67
  };
65
68
 
@@ -137,6 +140,9 @@ export function healthTick(
137
140
  closeResolvedPieces(now);
138
141
  leaveHeartbeat(now, liveBackgroundJobs());
139
142
  giveUpStale(now, deps);
143
+ void envTick(now, c, deps.env).catch((error) =>
144
+ console.error("[health] environment failures:", error)
145
+ );
140
146
 
141
147
  const running: Promise<void>[] = [];
142
148
  for (const row of openAnomalies()) {
@@ -66,6 +66,7 @@ export const STATIC_SIBLINGS = [
66
66
  "me",
67
67
  "items",
68
68
  "network",
69
+ "pdf",
69
70
  "preview",
70
71
  "queue",
71
72
  "status",
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The share page as a PDF, printed by headless Chromium from the exact HTML
3
+ * the preview shows. One browser is launched lazily, reused, and closed after
4
+ * a quiet spell. Nothing on the page can reach the network: only the inlined
5
+ * markup is rendered.
6
+ */
7
+
8
+ import fs from "fs";
9
+ import type { Browser, LaunchOptions } from "playwright";
10
+
11
+ const IDLE_MS = 60_000;
12
+ const RENDER_MS = 30_000;
13
+
14
+ const CHROME_PATHS = [
15
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
16
+ "/usr/bin/google-chrome",
17
+ "/usr/bin/google-chrome-stable",
18
+ "/usr/bin/chromium",
19
+ "/usr/bin/chromium-browser",
20
+ ];
21
+
22
+ /** Print-only rules, added here so the HTML download stays as it was. */
23
+ const PRINT_CSS = `
24
+ @page{margin:16mm 14mm}
25
+ html,body{background:#fff!important}
26
+ .wrap{max-width:none;padding:0}
27
+ *{-webkit-print-color-adjust:exact;print-color-adjust:exact}
28
+ pre code,pre,details.tool pre{white-space:pre-wrap!important;word-break:break-word!important;overflow-wrap:anywhere;overflow:visible;max-height:none}
29
+ table{display:table;width:100%;table-layout:auto;overflow-wrap:anywhere}
30
+ pre,table,tr,.plan,.error,details.tool{break-inside:avoid-page}
31
+ h1,h2,h3,h4{break-after:avoid}
32
+ img{max-width:100%;break-inside:avoid}
33
+ `;
34
+
35
+ let found: LaunchOptions | null | undefined;
36
+
37
+ /** How to launch a browser on this machine, or null when there is none. */
38
+ export async function findBrowser(): Promise<LaunchOptions | null> {
39
+ if (found !== undefined) return found;
40
+ const { chromium } = await import("playwright");
41
+ const candidates = [chromium.executablePath(), ...CHROME_PATHS];
42
+ const path = candidates.find((p) => p && fs.existsSync(p));
43
+ found = path ? { executablePath: path, headless: true } : null;
44
+ return found;
45
+ }
46
+
47
+ export const pdfAvailable = async () => (await findBrowser()) !== null;
48
+
49
+ /** Test hook: forget the cached lookup and any open browser. */
50
+ export async function resetPdfBrowser() {
51
+ found = undefined;
52
+ await closeBrowser();
53
+ }
54
+
55
+ let browser: Promise<Browser> | null = null;
56
+ let idle: NodeJS.Timeout | null = null;
57
+
58
+ async function closeBrowser() {
59
+ if (idle) clearTimeout(idle);
60
+ idle = null;
61
+ const b = browser;
62
+ browser = null;
63
+ if (b) await (await b).close().catch(() => {});
64
+ }
65
+
66
+ async function getBrowser(): Promise<Browser> {
67
+ const opts = await findBrowser();
68
+ if (!opts) throw new Error("No browser to print with on this machine.");
69
+ if (!browser) {
70
+ const { chromium } = await import("playwright");
71
+ const launched = chromium.launch(opts);
72
+ browser = launched;
73
+ launched.then(
74
+ (b) =>
75
+ b.on("disconnected", () => browser === launched && (browser = null)),
76
+ () => {
77
+ if (browser === launched) browser = null;
78
+ }
79
+ );
80
+ }
81
+ return browser;
82
+ }
83
+
84
+ const withPrint = (html: string) =>
85
+ html.includes("</head>")
86
+ ? html.replace("</head>", () => `<style>${PRINT_CSS}</style></head>`)
87
+ : `<style>${PRINT_CSS}</style>${html}`;
88
+
89
+ /** The page printed to an A4 PDF. */
90
+ export async function renderPdf(html: string): Promise<Buffer> {
91
+ const b = await getBrowser();
92
+ if (idle) clearTimeout(idle);
93
+ const ctx = await b.newContext({
94
+ javaScriptEnabled: false,
95
+ colorScheme: "light",
96
+ });
97
+ try {
98
+ await ctx.setOffline(true);
99
+ const page = await ctx.newPage();
100
+ page.setDefaultTimeout(RENDER_MS);
101
+ await page.emulateMedia({ media: "print", colorScheme: "light" });
102
+ // "load" waits for images and fonts; scripts are off, so nothing runs after.
103
+ await page.setContent(withPrint(html), { waitUntil: "load" });
104
+ return Buffer.from(
105
+ await page.pdf({
106
+ format: "A4",
107
+ printBackground: true,
108
+ preferCSSPageSize: true,
109
+ })
110
+ );
111
+ } finally {
112
+ await ctx.close().catch(() => {});
113
+ idle = setTimeout(() => void closeBrowser(), IDLE_MS);
114
+ idle.unref();
115
+ }
116
+ }
package/lib/share/view.ts CHANGED
@@ -22,6 +22,9 @@ export interface PreviewItem {
22
22
  excerpt: string;
23
23
  }
24
24
 
25
+ /** Download formats a server can make; the client lists what it is told. */
26
+ export type ShareFormat = "pdf" | "html" | "md";
27
+
25
28
  export interface SharePreview {
26
29
  html: string;
27
30
  markdown: string;
@@ -33,6 +36,8 @@ export interface SharePreview {
33
36
  hits: Record<"secret" | "path" | "ip" | "host" | "email", number>;
34
37
  tooBig: boolean;
35
38
  connectLinked: boolean;
39
+ /** Best first. pdf only when this machine has a browser to print with. */
40
+ formats: ShareFormat[];
36
41
  }
37
42
 
38
43
  export interface SharedLinkRow {
@@ -103,7 +108,10 @@ export function formatBytes(n: number): string {
103
108
  }
104
109
 
105
110
  /** A file name for a download: the title, plain, with .html (or `ext`). */
106
- export function fileName(title: string, ext: "html" | "md" = "html"): string {
111
+ export function fileName(
112
+ title: string,
113
+ ext: "html" | "md" | "pdf" = "html"
114
+ ): string {
107
115
  const slug = title
108
116
  .toLowerCase()
109
117
  .replace(/[^a-z0-9]+/g, "-")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.654",
3
+ "version": "0.3.657",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -31,10 +31,12 @@
31
31
  "scripts": {
32
32
  "predev": "node scripts/ensure-native.mjs && node scripts/gen-migrations-index.mjs",
33
33
  "dev": "AGENTOS_DEV=1 tsx server.ts",
34
+ "dev:task": "tsx scripts/dev-task.ts",
35
+ "dev:stop": "tsx scripts/dev-stop.ts",
34
36
  "prebuild": "node scripts/gen-migrations-index.mjs",
35
37
  "build": "next build",
36
38
  "prestart": "node scripts/ensure-native.mjs && node scripts/gen-migrations-index.mjs",
37
- "start": "NODE_ENV=production tsx server.ts",
39
+ "start": "NODE_ENV=production AGENTOS_SERVER=live node --import tsx server.ts",
38
40
  "pretypecheck": "node scripts/gen-migrations-index.mjs",
39
41
  "typecheck": "tsc --noEmit",
40
42
  "setup": "bash scripts/setup.sh",
@@ -141,6 +143,7 @@
141
143
  "next": "^16.3.8",
142
144
  "next-themes": "^0.4.6",
143
145
  "node-pty": "^1.2.0-beta.6",
146
+ "playwright": "^1.63.0",
144
147
  "qrcode": "^1.5.4",
145
148
  "react": "^19.2.1",
146
149
  "react-dom": "^19.2.1",
@@ -171,7 +174,6 @@
171
174
  "eslint-config-next": "^16.3.8",
172
175
  "husky": "^9.1.7",
173
176
  "jsdom": "^30.1.2",
174
- "playwright": "^1.63.0",
175
177
  "prettier": "^3.7.4",
176
178
  "prettier-plugin-tailwindcss": "^0.7.2",
177
179
  "sharp": "^0.35.5",
@@ -0,0 +1,7 @@
1
+ import fs from "fs";
2
+ import { stopDevServer } from "@/lib/dev-instance";
3
+
4
+ stopDevServer(fs.realpathSync(process.cwd())).then((r) => {
5
+ console.log(r.message);
6
+ process.exit(r.ok ? 0 : 1);
7
+ });
@@ -0,0 +1,16 @@
1
+ import fs from "fs";
2
+ import { startDevServer } from "@/lib/dev-instance";
3
+
4
+ const i = process.argv.indexOf("--port");
5
+ const requested = i === -1 ? undefined : Number(process.argv[i + 1]);
6
+
7
+ startDevServer({ worktree: fs.realpathSync(process.cwd()), requested })
8
+ .then((r) =>
9
+ console.log(
10
+ `Dev server ${r.reused ? "already running" : "started"}: pid ${r.pid} on http://localhost:${r.port} (log ${r.log}). Stop it with npm run dev:stop.`
11
+ )
12
+ )
13
+ .catch((e: Error) => {
14
+ console.error(e.message);
15
+ process.exit(1);
16
+ });
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import fs from "fs";
13
13
  import path from "path";
14
- import { execFileSync, spawn } from "child_process";
14
+ import { spawnServer } from "@/lib/dev-instance";
15
15
  import { PORT, REPO, demoEnv } from "./config";
16
16
  import { seed, teardownTmux } from "./seed";
17
17
  import { startPeer } from "./peer";
@@ -35,13 +35,9 @@ async function main() {
35
35
  process.env[k] ? [[k, process.env[k]!]] : []
36
36
  )
37
37
  );
38
- // server.ts imports the generated migrations index.
39
- execFileSync(process.execPath, ["scripts/gen-migrations-index.mjs"], {
40
- cwd: REPO,
41
- });
42
- const tsx = path.join(REPO, "node_modules", "tsx", "dist", "cli.mjs");
43
- const server = spawn(process.execPath, [tsx, "server.ts"], {
44
- cwd: REPO,
38
+ const server = spawnServer({
39
+ repo: REPO,
40
+ port: PORT,
45
41
  env: demoEnv({
46
42
  AGENTOS_BIND: "127.0.0.1",
47
43
  ...passed,
@@ -8,7 +8,8 @@
8
8
  */
9
9
  import fs from "fs";
10
10
  import path from "path";
11
- import { execFileSync, spawn, type ChildProcess } from "child_process";
11
+ import { type ChildProcess } from "child_process";
12
+ import { spawnServer } from "@/lib/dev-instance";
12
13
  import { BASE_URL, PORT, REPO, ROOT, demoEnv } from "./config";
13
14
  import { seed, teardownTmux } from "./seed";
14
15
  import { shoot } from "./shoot";
@@ -38,14 +39,10 @@ async function waitForServer(server: ChildProcess): Promise<void> {
38
39
 
39
40
  function startServer(): ChildProcess {
40
41
  const log = fs.openSync(path.join(ROOT, "server.log"), "w");
41
- // server.ts imports the generated migrations index.
42
- execFileSync(process.execPath, ["scripts/gen-migrations-index.mjs"], {
43
- cwd: REPO,
44
- });
45
- const tsx = path.join(REPO, "node_modules", "tsx", "dist", "cli.mjs");
46
42
  // Loopback only: the demo never listens on another network.
47
- return spawn(process.execPath, [tsx, "server.ts"], {
48
- cwd: REPO,
43
+ return spawnServer({
44
+ repo: REPO,
45
+ port: PORT,
49
46
  // No watchers: the seeded orchestrator and tasks must sit still, never
50
47
  // start an agent.
51
48
  env: demoEnv({
package/server.ts CHANGED
@@ -78,6 +78,11 @@ import { failInterruptedSetups } from "./lib/sessions/worktree-setup";
78
78
  import { sweepOrphanTmux } from "./lib/tmux/orphans";
79
79
  import { releaseInterruptedClaims } from "./lib/orchestrator/ask-approvals";
80
80
  import { resumeReviews } from "./lib/orchestrator/review";
81
+ import {
82
+ isLiveServer,
83
+ readLiveRecord,
84
+ writeLiveRecord,
85
+ } from "./lib/dev-instance";
81
86
  import { claimAgentosEnv } from "./lib/agents/self-env";
82
87
  import { wslNotes, wslVersion } from "./lib/wsl";
83
88
  import { getAllProjects } from "./lib/projects";
@@ -89,6 +94,19 @@ const hostname = "127.0.0.1";
89
94
 
90
95
  // Support: npm run dev -- -p 3012
91
96
  const port = serverPort();
97
+ const live = isLiveServer();
98
+ // A distinct title keeps `pkill -f server.ts` and `pkill tsx` off the
99
+ // production server; an AgentOS in a worktree can't take its port.
100
+ process.title = live ? "agentos-server" : "agentos-dev";
101
+ if (!live) {
102
+ const rec = readLiveRecord();
103
+ if (rec && rec.port === port && rec.pid !== process.pid) {
104
+ console.error(
105
+ `Refusing to start: port ${port} is the live AgentOS server (pid ${rec.pid}). Use npm run dev:task.`
106
+ );
107
+ process.exit(1);
108
+ }
109
+ }
92
110
  claimAgentosEnv(port);
93
111
  // Demo mode: seeded data, nothing that runs code (lib/security/demo).
94
112
  const demo = demoMode();
@@ -302,6 +320,13 @@ app.prepare().then(async () => {
302
320
  });
303
321
  server.listen(port, address, () => {
304
322
  console.log(`> Agent-OS ready on http://${address}:${port}`);
323
+ if (live)
324
+ writeLiveRecord({
325
+ pid: process.pid,
326
+ port,
327
+ cwd: process.cwd(),
328
+ startedAt: Date.now(),
329
+ });
305
330
  });
306
331
  };
307
332
  const stopListening = (address: string) => {