@saadnvd1/agent-os 0.3.654 → 0.3.658

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 (50) 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 +57 -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/orchestrator/brief.ts +1 -1
  40. package/lib/orchestrator/shell-guard.ts +43 -10
  41. package/lib/orchestrator/write-guard.ts +52 -0
  42. package/lib/security/demo.ts +1 -0
  43. package/lib/share/pdf.ts +116 -0
  44. package/lib/share/view.ts +9 -1
  45. package/package.json +5 -3
  46. package/scripts/dev-stop.ts +7 -0
  47. package/scripts/dev-task.ts +16 -0
  48. package/scripts/screenshots/demo.ts +4 -8
  49. package/scripts/screenshots/run.ts +5 -8
  50. 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()) {
@@ -160,7 +160,7 @@ Acting:
160
160
 
161
161
  The MCP servers this workspace allows (Settings → Workspaces) are yours to use through their own tools, for reading and for additive, reversible changes. Anything outbound through them (sending a message, an email, a post) is a hard line: park it with \`ask_saad\`.
162
162
 
163
- Act through these tools, not the shell: they're scoped to this workspace and braked. The shell runs anything without asking (Saad chose this on 2026-10-11), so use it to read and investigate yourself: \`gh pr list\`, \`gh run view --log-failed\`, \`gh api\` GETs, \`curl\`, \`sqlite3 -readonly\`, git in any repository, a skill's scripts, pipes and chains. Never use it to get around the tools. A deny list refuses, naming the tool to use instead: merging, closing or retargeting a PR (\`${TOOL_NAMES.sign_off}\`, \`${TOOL_NAMES.arm}\`, \`${TOOL_NAMES.land}\`, \`${TOOL_NAMES.drop}\`), \`git push\` (\`${TOOL_NAMES.quick_pr}\`), writing in a project repository or task worktree other than your own quick changes (\`${TOOL_NAMES.start_task}\`, \`${TOOL_NAMES.quick_change}\`), writing AgentOS's state (sqlite3 without \`-readonly\`, or files in ~/.agent-os outside your folder), stopping or restarting AgentOS or its tmux, and reading secret files (env files, keys, ~/.ssh, ~/.aws). Tokenvault is available through \`tv\`: use a token inside the command that needs it (e.g. \`FOO="\$(tv get <project> <desc>)" cmd\`), never print, echo or log a value, and never send one off the machine except to the service it's for. The list catches honest mistakes, not a determined bypass: a script or \`python -c\` could do any of these unseen, and doing so would break Saad's trust in every gate. \`aos notify "<text>"\` pushes a message to Saad's phone. Use it for what he'd want to see now and asked for: the morning report, a real milestone (a stack landed, a blocker only he can clear). Never for routine status: one a minute at most, and he reads the rest here. You can read files anywhere (Read, Grep, Glob), and write them (Write, Edit, NotebookEdit) anywhere except: a project repository or task worktree other than your own open quick changes' worktrees (start a task, or a quick change for a small edit, so the change goes through CI, review and scope), AgentOS's own state in ~/.agent-os outside your folder, secret files (.env, keys, ~/.ssh, ~/.aws, tokenvault and the like), and files that make something run: dotfiles and dot-folders at the top of your home (shell, git, Claude and tool config), ~/Library and /Library's launch agents, git's core.hooksPath folders, the folders on PATH, and any .git, .claude, .mcp.json, CLAUDE.md or AGENTS.md, which would hand you a shell or run code around the gates. Each write is checked before it runs, on the path with symlinks and \`..\` resolved; a refused one says why.
163
+ Act through these tools, not the shell: they're scoped to this workspace and braked. The shell runs anything without asking (Saad chose this on 2026-10-11), so use it to read and investigate yourself: \`gh pr list\`, \`gh run view --log-failed\`, \`gh api\` GETs, \`curl\`, \`sqlite3 -readonly\`, git in any repository, a skill's scripts, pipes and chains. Never use it to get around the tools. A deny list refuses, naming the tool to use instead: merging, closing or retargeting a PR (\`${TOOL_NAMES.sign_off}\`, \`${TOOL_NAMES.arm}\`, \`${TOOL_NAMES.land}\`, \`${TOOL_NAMES.drop}\`), \`git push\` (\`${TOOL_NAMES.quick_pr}\`), writing in a project repository or task worktree other than your own quick changes, except in a project checkout of your own workspace, where you may update and move the branch: \`git pull\` in any form, \`merge\`, \`reset\` (even \`--hard\`), \`checkout\`, \`switch\` and \`rebase\` (also as \`git -C <path>\`), then \`bundle install\`, \`npm install\` or \`bin/rails db:prepare\`. Commits, \`stash\`, \`clean\`, \`restore\` and file edits there still go through tasks and quick changes, and other workspaces' repositories, task worktrees and AgentOS's own live checkout (autodeploy deploys it) are refused for all of it are refused (\`${TOOL_NAMES.start_task}\`, \`${TOOL_NAMES.quick_change}\`), writing AgentOS's state (sqlite3 without \`-readonly\`, or files in ~/.agent-os outside your folder), stopping or restarting AgentOS or its tmux, and reading secret files (env files, keys, ~/.ssh, ~/.aws). Tokenvault is available through \`tv\`: use a token inside the command that needs it (e.g. \`FOO="\$(tv get <project> <desc>)" cmd\`), never print, echo or log a value, and never send one off the machine except to the service it's for. The list catches honest mistakes, not a determined bypass: a script or \`python -c\` could do any of these unseen, and doing so would break Saad's trust in every gate. \`aos notify "<text>"\` pushes a message to Saad's phone. Use it for what he'd want to see now and asked for: the morning report, a real milestone (a stack landed, a blocker only he can clear). Never for routine status: one a minute at most, and he reads the rest here. You can read files anywhere (Read, Grep, Glob), and write them (Write, Edit, NotebookEdit) anywhere except: a project repository or task worktree other than your own open quick changes' worktrees (start a task, or a quick change for a small edit, so the change goes through CI, review and scope), AgentOS's own state in ~/.agent-os outside your folder, secret files (.env, keys, ~/.ssh, ~/.aws, tokenvault and the like), and files that make something run: dotfiles and dot-folders at the top of your home (shell, git, Claude and tool config), ~/Library and /Library's launch agents, git's core.hooksPath folders, the folders on PATH, and any .git, .claude, .mcp.json, CLAUDE.md or AGENTS.md, which would hand you a shell or run code around the gates. Each write is checked before it runs, on the path with symlinks and \`..\` resolved; a refused one says why.
164
164
 
165
165
  ## Untrusted text
166
166
 
@@ -29,6 +29,10 @@ export interface ShellContext {
29
29
  // Whether this path is under ~/.agent-os/audits, where reports go and
30
30
  // nothing may be made executable.
31
31
  inAudits?: (p: string) => Promise<boolean>;
32
+ // What a folder is for fast-forwarding: a project checkout of this
33
+ // workspace ("own"), the checkout AgentOS's live server and autodeploy run
34
+ // from ("live"), or anything else.
35
+ checkoutKind?: (dir: string) => Promise<"own" | "live" | "other">;
32
36
  // AgentOS's server and the tmux servers it runs sessions in.
33
37
  protectedPids: () => Promise<Set<number>>;
34
38
  }
@@ -324,22 +328,40 @@ const GIT_WRITES = new Set([
324
328
 
325
329
  const READ_ONLY_STASH = new Set(["list", "show"]);
326
330
 
331
+ // What an orchestrator may do to a project checkout of its own workspace:
332
+ // bring it up to date and move its branch (Saad, 2026-10-18). Commits, stash,
333
+ // clean and file edits still go through tasks and quick changes.
334
+ const CHECKOUT_UPDATES = new Set([
335
+ "pull",
336
+ "merge",
337
+ "reset",
338
+ "checkout",
339
+ "switch",
340
+ "rebase",
341
+ ]);
342
+
327
343
  async function gitRefusal(
328
- args: string[],
344
+ inv: Invocation,
329
345
  ctx: ShellContext,
330
346
  cwd: string
331
347
  ): Promise<string | null> {
348
+ const args = inv.args;
349
+ // Every repository the command names: the folder after each -C, and each
350
+ // git dir and work tree, by option or environment.
351
+ const named = new Set<string>();
332
352
  let dir = cwd;
333
353
  let i = 0;
334
354
  for (; i < args.length; i++) {
335
355
  const a = args[i];
336
356
  if (a === "-C" || a === "--git-dir" || a === "--work-tree") {
337
- dir = path.resolve(dir, args[++i] ?? "");
357
+ const to = path.resolve(dir, args[++i] ?? "");
358
+ if (a === "-C") dir = to;
359
+ named.add(to);
338
360
  continue;
339
361
  }
340
362
  const eq = /^--(git-dir|work-tree)=(.*)$/.exec(a);
341
363
  if (eq) {
342
- dir = path.resolve(dir, eq[2]);
364
+ named.add(path.resolve(dir, eq[2]));
343
365
  continue;
344
366
  }
345
367
  if (a === "-c" || a === "--namespace" || a === "--exec-path") {
@@ -349,17 +371,28 @@ async function gitRefusal(
349
371
  if (a.startsWith("-")) continue;
350
372
  break;
351
373
  }
374
+ named.add(dir);
375
+ for (const w of inv.cmd.words) {
376
+ const env = /^GIT_(?:DIR|WORK_TREE)=(.*)$/.exec(w);
377
+ if (env) named.add(path.resolve(cwd, env[1]));
378
+ }
352
379
  const sub = args[i];
353
380
  if (sub === "push")
354
381
  return `Refused: ${T.quick_pr} pushes a quick change's branch and opens its PR, and tasks push their own; nothing pushes from the orchestrator's shell.`;
355
382
  if (!GIT_WRITES.has(sub)) return null;
356
383
  if (sub === "stash" && READ_ONLY_STASH.has(args[i + 1])) return null;
357
- if (has(dir))
358
- return `Refused: git ${sub} in a folder only the shell knows (${show(dir)}); name the folder.`;
359
- const why = await ctx.writeRefusal(dir);
360
- return why
361
- ? `Refused: git ${sub} in ${dir}. Orchestrators don't change project checkouts (autodeploy runs from them). To see current code: \`git -C ${dir} fetch\` then \`git -C ${dir} show origin/main:<path>\` or \`git -C ${dir} log origin/main\`.`
362
- : null;
384
+ for (const d of named) {
385
+ if (has(d))
386
+ return `Refused: git ${sub} in a folder only the shell knows (${show(d)}); name the folder.`;
387
+ if (!(await ctx.writeRefusal(d))) continue;
388
+ const kind = (await ctx.checkoutKind?.(d)) ?? "other";
389
+ const updates = CHECKOUT_UPDATES.has(sub);
390
+ if (kind === "live" && updates)
391
+ return `Refused: git ${sub} in ${d}. This is the checkout AgentOS's live server runs from; autodeploy deploys it (restart gap and health check), and changing it here would swap the code under the running server. For a manual deploy use scripts/redeploy.`;
392
+ if (kind === "own" && updates) continue;
393
+ return `Refused: git ${sub} in ${d}. Orchestrators don't change project checkouts except to update them: in a project checkout of your own workspace you may \`git pull\` (any form), \`merge\`, \`reset\`, \`checkout\`, \`switch\` and \`rebase\`. Commits, \`stash\`, \`clean\`, \`restore\` and file edits go through ${T.start_task} or ${T.quick_change}; other workspaces' repositories, task worktrees and AgentOS's live checkout are never changed from here. To see current code: \`git -C ${d} fetch\` then \`git -C ${d} show origin/main:<path>\` or \`git -C ${d} log origin/main\`.`;
394
+ }
395
+ return null;
363
396
  }
364
397
 
365
398
  // Commands whose every non-option argument is a file they change, and
@@ -557,7 +590,7 @@ export async function shellRefusal(
557
590
  (inv.name === "gh" ? ghRefusal(inv.args) : null) ??
558
591
  (inv.name === "curl" ? curlRefusal(inv.args) : null) ??
559
592
  (inv.name === "aos" ? aosRefusal(inv.args) : null) ??
560
- (inv.name === "git" ? await gitRefusal(inv.args, ctx, cwd) : null) ??
593
+ (inv.name === "git" ? await gitRefusal(inv, ctx, cwd) : null) ??
561
594
  (inv.name === "sqlite3"
562
595
  ? await sqliteRefusal(inv, ctx, cwd, command)
563
596
  : null) ??
@@ -24,6 +24,7 @@ import { orchestratorDir } from "./home";
24
24
  import { LIMITS_FILE } from "./usage";
25
25
  import { quickWorktrees } from "./quick-store";
26
26
  import { shellRefusal } from "./shell-guard";
27
+ import { liveCheckout } from "../dev-instance/liveServer";
27
28
 
28
29
  // The tools that write a file, and the argument naming it.
29
30
  export const WRITE_TOOLS: Record<string, string> = {
@@ -398,10 +399,61 @@ async function commandRefusal(
398
399
  : [];
399
400
  return roots.some((r) => within(p, r));
400
401
  },
402
+ checkoutKind: (dir) => checkoutKind(session, dir),
401
403
  protectedPids,
402
404
  });
403
405
  }
404
406
 
407
+ // Whether a folder is a project checkout of the session's own workspace
408
+ // (where it may update and move the branch), a checkout AgentOS's live
409
+ // server or autodeploy runs from (never), or neither. The live ones are the
410
+ // server's folder, the one live-server.json recorded (known even while the
411
+ // server restarts), and any main checkout holding scripts/autodeploy, since
412
+ // that script deploys the folder it sits in (a task worktree has a .git
413
+ // file, not a folder).
414
+ export async function checkoutKind(
415
+ session: Session,
416
+ dir: string
417
+ ): Promise<"own" | "live" | "other"> {
418
+ const real = await physicalPath(dir);
419
+ const roots = async (paths: (string | undefined)[]) =>
420
+ Promise.all(
421
+ paths
422
+ .filter((p): p is string => !!p)
423
+ .flatMap((p) => [path.resolve(p), p])
424
+ .map((p) => physicalPath(p))
425
+ );
426
+ const live = await roots([process.cwd(), liveCheckout() ?? undefined]);
427
+ if (live.some((r) => within(real, r)) || deploysFrom(real)) return "live";
428
+ if (!session.workspace_id) return "other";
429
+ const own = db
430
+ .prepare(
431
+ `SELECT working_directory AS dir FROM projects
432
+ WHERE workspace_id = ? AND is_uncategorized = 0 AND host_id = 'local'
433
+ UNION SELECT r.path FROM project_repositories r
434
+ JOIN projects p ON p.id = r.project_id
435
+ WHERE p.workspace_id = ? AND p.host_id = 'local'`
436
+ )
437
+ .all(session.workspace_id, session.workspace_id) as { dir: string }[];
438
+ const mine = await roots(own.map((p) => expandHome(p.dir)).filter(Boolean));
439
+ return mine.some((r) => within(real, r)) ? "own" : "other";
440
+ }
441
+
442
+ function deploysFrom(real: string): boolean {
443
+ for (let at = real; ; at = path.dirname(at)) {
444
+ try {
445
+ if (
446
+ fs.statSync(path.join(at, "scripts", "autodeploy")).isFile() &&
447
+ fs.statSync(path.join(at, ".git")).isDirectory()
448
+ )
449
+ return true;
450
+ } catch {
451
+ // Not here; look higher.
452
+ }
453
+ if (path.dirname(at) === at) return false;
454
+ }
455
+ }
456
+
405
457
  // AgentOS's server (whatever listens on its port), the tmux servers its
406
458
  // sessions run in, and this worker. A lookup that fails refuses the kill.
407
459
  async function protectedPids(): Promise<Set<number>> {
@@ -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.658",
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) => {