@flisk-sh/flisk 1.4.5 → 1.4.7

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 (117) hide show
  1. package/README.md +37 -14
  2. package/cli/dist/agent-runtimes/custom.js +94 -13
  3. package/cli/dist/commands/agent.js +144 -123
  4. package/cli/dist/commands/doctor.js +62 -12
  5. package/cli/dist/commands/down.js +33 -1
  6. package/cli/dist/commands/logs.js +107 -3
  7. package/cli/dist/commands/project.js +27 -0
  8. package/cli/dist/commands/resource.js +3 -1
  9. package/cli/dist/commands/status.js +82 -34
  10. package/cli/dist/commands/ui.js +41 -2
  11. package/cli/dist/commands/up.js +173 -12
  12. package/cli/dist/commands/upgrade.js +2 -2
  13. package/cli/dist/components/DetailPanel.js +23 -3
  14. package/cli/dist/mcp/tools.js +9 -6
  15. package/cli/dist/types/index.js +0 -2
  16. package/cli/dist/utils/agent-format.js +18 -0
  17. package/cli/dist/utils/agent-sessions.js +122 -0
  18. package/cli/dist/utils/agent-skills.js +64 -0
  19. package/cli/dist/utils/bun-requirement.js +7 -4
  20. package/cli/dist/utils/cold-preflight.js +55 -32
  21. package/cli/dist/utils/doctor-fix-first.js +93 -0
  22. package/cli/dist/utils/doctor-native.js +96 -0
  23. package/cli/dist/utils/doctor-report.js +3 -0
  24. package/cli/dist/utils/native-project.js +41 -0
  25. package/cli/dist/utils/native-runtime.js +516 -0
  26. package/cli/dist/utils/native-service.js +95 -0
  27. package/cli/dist/utils/native-up.js +156 -0
  28. package/cli/dist/utils/platform.js +1 -1
  29. package/cli/dist/utils/project-templates.js +1 -1
  30. package/cli/dist/utils/service-manifest.js +10 -1
  31. package/cli/dist/utils/services.js +11 -1
  32. package/cli/dist/utils/startup-report.js +45 -3
  33. package/cli/dist/utils/status-groups.js +23 -0
  34. package/cli/dist/utils/tilt-logs.js +8 -0
  35. package/cli/dist/utils/up-hints.js +42 -0
  36. package/cli/dist/utils/up-progress.js +117 -0
  37. package/cli/dist/utils/up-readiness.js +5 -0
  38. package/cli/dist-bundle/{chunk-Z5QEE4SW.js → chunk-6OW2GVQV.js} +1 -1
  39. package/cli/dist-bundle/chunk-B2JOM3HN.js +35 -0
  40. package/cli/dist-bundle/{chunk-VMVTECCK.js → chunk-F2DLIM75.js} +1 -1
  41. package/cli/dist-bundle/chunk-L6O7TT3K.js +18 -0
  42. package/cli/dist-bundle/chunk-MFY7RZ76.js +130 -0
  43. package/cli/dist-bundle/{chunk-SFOFTVYM.js → chunk-MYAYJF4Q.js} +1 -1
  44. package/cli/dist-bundle/{chunk-LUUJ4JB7.js → chunk-P7GZ6NGM.js} +1 -1
  45. package/cli/dist-bundle/{chunk-BP2M4OJ4.js → chunk-QEBN6ZVQ.js} +1 -1
  46. package/cli/dist-bundle/{chunk-4J2FLFUA.js → chunk-T44DEEL6.js} +1 -1
  47. package/cli/dist-bundle/{chunk-U42XZWXB.js → chunk-TIPSERWO.js} +1 -1
  48. package/cli/dist-bundle/{chunk-V36PT2HF.js → chunk-VYPB7MY5.js} +1 -1
  49. package/cli/dist-bundle/{chunk-TIYTGN5R.js → chunk-XHENT4KO.js} +1 -1
  50. package/cli/dist-bundle/{chunk-CGTWXWBU.js → chunk-YTBTCAWY.js} +1 -1
  51. package/cli/dist-bundle/cli.js +486 -396
  52. package/cli/dist-bundle/cold-preflight-DQVJUGBB.js +2 -0
  53. package/cli/dist-bundle/{demo-stage-G4W23BXW.js → demo-stage-F4YA7UL2.js} +1 -1
  54. package/cli/dist-bundle/{open-HQFT6WDC.js → open-ZUSSVPD3.js} +1 -1
  55. package/cli/dist-bundle/project-JFCTPRP5.js +2 -0
  56. package/cli/dist-bundle/{project-templates-IMJ3OX5I.js → project-templates-2VQPUBSQ.js} +1 -1
  57. package/cli/dist-bundle/{services-GLFKZBBZ.js → services-DOHDHOCT.js} +1 -1
  58. package/cli/dist-bundle/{tilt-port-owner-PJN4MPYE.js → tilt-port-owner-XODQHXHX.js} +1 -1
  59. package/cli/package.json +1 -1
  60. package/cli/templates/agent-skills/flisk-agent-ops/SKILL.md +39 -0
  61. package/cli/templates/agent-skills/flisk-build/SKILL.md +85 -0
  62. package/cli/templates/agent-skills/flisk-doctor/SKILL.md +28 -0
  63. package/cli/templates/agent-skills/flisk-recipe-ai-agent/SKILL.md +45 -0
  64. package/cli/templates/agent-skills/flisk-recipe-blog/SKILL.md +36 -0
  65. package/cli/templates/agent-skills/flisk-recipe-saas/SKILL.md +39 -0
  66. package/cli/templates/agent-skills/flisk-recipe-shop/SKILL.md +41 -0
  67. package/cli/templates/agent-skills/flisk-recipe-starters/SKILL.md +46 -0
  68. package/cli/templates/agent-skills/flisk-resource/SKILL.md +27 -0
  69. package/cli/templates/agent-skills/flisk-status/SKILL.md +25 -0
  70. package/cli/templates/agent-skills/flisk-troubleshoot/SKILL.md +227 -0
  71. package/cli/templates/agent-skills/flisk-up/SKILL.md +37 -0
  72. package/examples/{tdk-example → flisk-example}/README.md +2 -2
  73. package/package.json +2 -2
  74. package/runtime/discovery/AGENTS.md +1 -1
  75. package/runtime/discovery/TILT_DISCOVERY.star +1 -1
  76. package/runtime/discovery/deterministic.star +1 -1
  77. package/runtime/engine/AGENTS.md +3 -3
  78. package/runtime/engine/README.md +1 -1
  79. package/runtime/engine/REFACTORING_MANIFEST_SYSTEM.md +1 -1
  80. package/runtime/engine/assets/docker/wake-gateway/decide.ts +1 -1
  81. package/runtime/engine/assets/docker/wake-gateway/server.ts +2 -2
  82. package/runtime/engine/assets/scripts/mac-m1-resource-monitor.sh +6 -6
  83. package/runtime/engine/schemas/service-schema.json +87 -21
  84. package/runtime/engine/scripts/debug-container.sh +1 -1
  85. package/runtime/engine/scripts/generate-manifest.sh +2 -2
  86. package/runtime/engine/topologies/platform/docker/compose/traefik_standalone.star +19 -4
  87. package/runtime/engine/topologies/platform/docker/constants.star +2 -2
  88. package/runtime/engine/topologies/platform/docker/networking/traefik_static_routes.star +2 -2
  89. package/runtime/engine/topologies/tilt/manifest/mapper.star +1 -1
  90. package/runtime/engine/topologies/tilt/resources/orchestrator/apply_compose_resource_registration.star +8 -2
  91. package/runtime/ext/ide-components/README.md +1 -1
  92. package/runtime/ext/ui-enhancements/extension.yaml +2 -2
  93. package/runtime/specs/specs/TILT_SERVICE_DEFAULTS.star +1 -1
  94. package/runtime/specs/specs/TILT_TECH_STACK.star +1 -1
  95. package/cli/dist-bundle/chunk-BMHPPX2Q.js +0 -35
  96. package/cli/dist-bundle/chunk-PEZQEZVV.js +0 -17
  97. package/cli/dist-bundle/chunk-ULMQVD7J.js +0 -122
  98. package/cli/dist-bundle/cold-preflight-3BNDOLGS.js +0 -2
  99. package/cli/dist-bundle/project-JZO2CPZZ.js +0 -2
  100. /package/examples/{tdk-example → flisk-example}/LICENSE +0 -0
  101. /package/examples/{tdk-example → flisk-example}/TILT_SERVICE_DEFAULTS.star +0 -0
  102. /package/examples/{tdk-example → flisk-example}/TILT_TECH_STACK.star +0 -0
  103. /package/examples/{tdk-example → flisk-example}/Tiltfile +0 -0
  104. /package/examples/{tdk-example → flisk-example}/services/platform/messaging/docker-compose.yml +0 -0
  105. /package/examples/{tdk-example → flisk-example}/services/shop/orders-api/package.json +0 -0
  106. /package/examples/{tdk-example → flisk-example}/services/shop/orders-api/service.json +0 -0
  107. /package/examples/{tdk-example → flisk-example}/services/shop/orders-api/src/index.ts +0 -0
  108. /package/examples/{tdk-example → flisk-example}/services/shop/orders-app/index.html +0 -0
  109. /package/examples/{tdk-example → flisk-example}/services/shop/orders-app/package.json +0 -0
  110. /package/examples/{tdk-example → flisk-example}/services/shop/orders-app/public/.gitkeep +0 -0
  111. /package/examples/{tdk-example → flisk-example}/services/shop/orders-app/service.json +0 -0
  112. /package/examples/{tdk-example → flisk-example}/services/shop/orders-app/src/main.tsx +0 -0
  113. /package/examples/{tdk-example → flisk-example}/services/shop/orders-app/src/orders.tsx +0 -0
  114. /package/examples/{tdk-example → flisk-example}/services/shop/orders-app/src/style.css +0 -0
  115. /package/examples/{tdk-example → flisk-example}/services/shop/orders-worker/package.json +0 -0
  116. /package/examples/{tdk-example → flisk-example}/services/shop/orders-worker/service.json +0 -0
  117. /package/examples/{tdk-example → flisk-example}/services/shop/orders-worker/src/index.ts +0 -0
package/README.md CHANGED
@@ -114,13 +114,13 @@ flisk resource shop-web --type frontend --stack shop --yes
114
114
  flisk up shop
115
115
  ```
116
116
 
117
- You get an orders API and a storefront behind one local gateway, with Postgres and live reload. You need Docker, [Tilt](https://docs.tilt.dev/install.html), Node.js 22.12+, and [Bun](https://bun.sh/) 1.2+; `flisk doctor` checks your machine. Prefer to answer questions instead? `npm init create-microservices my-project` asks what to create. See the [quick start](#quick-start) for every option.
117
+ You get an orders API and a storefront behind one local gateway, with Postgres and live reload. You need Docker, [Tilt](https://docs.tilt.dev/install.html), Node.js 22.12+, and [Bun](https://bun.sh/) 1.2+; `flisk doctor` checks your machine. A simpler app needs none of those: give each service a `command` and [run it as local processes](docs/run-without-docker.md). Prefer to answer questions instead? `npm init create-microservices my-project` asks what to create. See the [quick start](#quick-start) for every option.
118
118
 
119
119
  <div align="center">
120
120
 
121
121
  <p><strong><a href="#quick-start">Quick start</a> · <a href="#why-flisk-cli">Why Flisk</a> · <a href="#examples">Examples</a> · <a href="#installation">Installation</a> · <a href="#faq">FAQ</a> · <a href="#docs">Documentation</a></strong></p>
122
122
 
123
- [Website](https://flisk.sh/) · [Quickstart](https://flisk.sh/docs/quickstart/) · [Examples](https://flisk.sh/docs/examples/) · [Awesome Flisk](https://github.com/flisk-sh/awesome-tdk-framework) · [Demo](https://flisk-sh.github.io/flisk-demo-animation/) · [Report a bug](https://github.com/flisk-sh/flisk/issues)
123
+ [Website](https://flisk.sh/) · [Quickstart](https://flisk.sh/docs/quickstart/) · [Examples](https://flisk.sh/docs/examples/) · [Awesome Flisk](https://github.com/flisk-sh/awesome-flisk-framework) · [Demo](https://flisk-sh.github.io/flisk-demo-animation/) · [Report a bug](https://github.com/flisk-sh/flisk/issues)
124
124
 
125
125
  <a id="readme-languages"></a>
126
126
 
@@ -245,7 +245,7 @@ flisk up shop
245
245
  Agents get durable checkpoint volumes that survive `flisk down`, sticky session routing via `x-session-id`, graceful SIGTERM drain during hot reloads, a Docker health check on `/health`, and application-level readiness checks on `/ready`. See the [AI agents guide](docs/agents/ai-agents.md) and [honest comparison with agent frameworks](docs/compare-honest.md#ai-agent-frameworks-and-workflow-engines-libfx-langgraph-mastra-vercel-workflows-temporal).
246
246
 
247
247
 
248
- **What Flisk is:** a local development runtime. Define each service in `service.json` and run `flisk up`. Docker runs the containers, Tilt live-updates them while you code, and Flisk writes the configuration both use. No local Kubernetes is needed.
248
+ **What Flisk is:** a local development runtime. Define each service in `service.json` and run `flisk up`. A service with a `command` runs as a local process, with nothing else installed. When you need a database, one gateway URL, or the container you deploy, Docker runs the containers, Tilt live-updates them while you code, and Flisk writes the configuration both use. No local Kubernetes is needed.
249
249
 
250
250
  **What Flisk is not:** a deploy tool or a Compose file. Production deployment stays with Helm, Argo CD, or Kustomize.
251
251
 
@@ -337,19 +337,42 @@ Get a running local stack in **3 commands**. Works on macOS, Linux, and Windows
337
337
 
338
338
  ### Prerequisites (install once)
339
339
 
340
- | Tool | Version | Install | Install path |
341
- |------|---------|---------|--------------|
342
- | **Docker** | Engine 25+, Compose 2.20.2+ | [docker.com](https://www.docker.com/products/docker-desktop/) | All paths |
343
- | **Tilt** | 0.25+ | `curl -fsSL https://raw.githubusercontent.com/tilt-dev/tilt/master/scripts/install.sh \| bash` | All paths |
344
- | **Node.js** | 22.12+ | [nodejs.org](https://nodejs.org/) | npm path (`npm install -g @flisk-sh/flisk`) |
345
- | **Bun** | 1.2+ | `curl -fsSL https://bun.sh/install \| bash` | Bun path (`bun add -g @flisk-sh/flisk`) |
340
+ | Tool | Version | Install | Needed for |
341
+ |------|---------|---------|------------|
342
+ | **Your app's runtime** | whatever your app uses | Node.js, Python, Go, Bun, ... | Every path: it runs your code |
343
+ | **Node.js** | 22.12+ | [nodejs.org](https://nodejs.org/) | npm install (`npm install -g @flisk-sh/flisk`) |
344
+ | **Docker** | Engine 25+, Compose 2.20.2+ | [docker.com](https://www.docker.com/products/docker-desktop/) | Docker + Tilt path only |
345
+ | **Tilt** | 0.25+ | `curl -fsSL https://raw.githubusercontent.com/tilt-dev/tilt/master/scripts/install.sh \| bash` | Docker + Tilt path only |
346
+ | **Bun** | 1.2+ | `curl -fsSL https://bun.sh/install \| bash` | Bun install (`bun add -g @flisk-sh/flisk`), or generated JavaScript services |
346
347
 
347
- > **Conditional prerequisites**: Node.js is only required when you install Flisk via npm (`npm install -g @flisk-sh/flisk`). Bun is only required when you install via Bun or your project has generated JavaScript services. Docker and Tilt are required for every path.
348
+ > **Two paths.** A project whose services each declare a `command` runs them as local processes: you need only Flisk and your app's runtime ([simplest path](#simplest-path-no-docker)). Docker and Tilt are needed for the Docker + Tilt path: databases such as Postgres, one gateway URL for every service, and running the same container you deploy. `flisk doctor` checks only what your project uses.
348
349
 
349
350
  > **Windows?** Use WSL2 Ubuntu. See the [Windows & WSL2 guide](docs/wsl2.md) for the recommended setup.
350
351
 
351
352
  ---
352
353
 
354
+ ### Simplest path: no Docker
355
+
356
+ For an app made of services you can start with one command each (an API, a website, a worker), put a `service.json` next to each one:
357
+
358
+ ```json
359
+ { "schemaVersion": 1, "appName": "api", "appType": "backend", "command": "node server.js", "port": 4000, "healthCheckPath": "/health" }
360
+ ```
361
+
362
+ ```json
363
+ { "schemaVersion": 1, "appName": "web", "appType": "frontend", "command": "node server.js", "port": 3000, "dependsOn": ["api"] }
364
+ ```
365
+
366
+ Then start everything from the project folder:
367
+
368
+ ```bash
369
+ flisk up
370
+ ```
371
+
372
+ Flisk starts the API, waits until it answers, starts the website, prints both URLs, prefixes each line of output with the service name, and restarts a service that crashes. Ctrl+C or `flisk down` stops them. No Docker, no Tilt, no generated files. See [Run your app without Docker](docs/run-without-docker.md) for every field, and for when to switch to the Docker + Tilt path.
373
+
374
+ ---
375
+
353
376
  ### Fastest path: one-command demo (no manual steps)
354
377
 
355
378
  ```bash
@@ -596,11 +619,11 @@ Runnable examples show Flisk on real stacks. Start with the smallest one:
596
619
  | [one-backend-helm](examples/one-backend-helm/README.md) | Working alongside Helm |
597
620
  | [byo](examples/byo/README.md) | Bringing your own existing resources |
598
621
  | [byo-jobs](examples/byo-jobs/README.md) | Bring-your-own jobs |
599
- | [tdk-example](examples/tdk-example/README.md) | A full multi-service stack |
622
+ | [flisk-example](examples/flisk-example/README.md) | A full multi-service stack |
600
623
 
601
624
  ## Use Flisk with your AI agent
602
625
 
603
- The repository ships five agent skills for common Flisk work under [`.agents/skills`](.agents/skills): `flisk-doctor` (environment checks), `flisk-up` (preview and start a stack), `flisk-resource` (create a service), `flisk-status` (health and URLs), and `flisk-troubleshoot` (fixing `flisk` and Tilt errors). Agents working in this repo also follow [AGENTS.md](AGENTS.md). Use these skills to let your agent check your setup and explain failures instead of guessing at Docker or Tilt configuration.
626
+ The repository ships agent skills for common Flisk work under [`.agents/skills`](.agents/skills): `flisk-build` (turn "build me a shop" into Flisk steps, check every URL, and report them), `flisk-recipe-starters` (start from a working restaurant, SaaS, store, AI agent or user-management app), recipes for a shop, a SaaS with login and billing, an AI agent with MCP tools and a database, and a blog (`flisk-recipe-shop`, `flisk-recipe-saas`, `flisk-recipe-ai-agent`, `flisk-recipe-blog`), `flisk-doctor` (environment checks), `flisk-up` (preview and start a stack), `flisk-resource` (create a service), `flisk-status` (health and URLs), and `flisk-troubleshoot` (fixing `flisk` and Tilt errors). More prompts to copy, by category (spreadsheets, recruiting, product management, shops, AI agents), are in the [prompt gallery](docs/recipes/build-with-your-agent.md). Agents working in this repo also follow [AGENTS.md](AGENTS.md). Use these skills to let your agent check your setup and explain failures instead of guessing at Docker or Tilt configuration.
604
627
 
605
628
  ## Installation
606
629
 
@@ -648,7 +671,7 @@ Set `FLISK_DEBUG=1` before running `flisk up --verbose` to include Starlark debu
648
671
  - [Generated service files and drift checks](docs/generated-files.md)
649
672
  - [Runnable one-backend example](examples/one-backend/README.md)
650
673
  - [Runnable Python backend example](examples/one-backend-python/README.md)
651
- - [Full multi-service example](examples/tdk-example/README.md)
674
+ - [Full multi-service example](examples/flisk-example/README.md)
652
675
  - [Features and license limits](docs/FEATURES.md)
653
676
  - [Tool comparison: docker-compose, Tilt, Skaffold, DevSpace, kind](docs/comparison.md)
654
677
  - [Honest comparison and known limits](docs/compare-honest.md)
@@ -686,7 +709,7 @@ On native Windows, `flisk --version`, `flisk doctor`, and `flisk up --dry-run` a
686
709
 
687
710
  Core stays free. Premium is a separate key for the extras above; no key required to run `flisk up`.
688
711
 
689
- **Ecosystem map:** examples, articles, related tools, and notes live in [awesome-tdk-framework](https://github.com/flisk-sh/awesome-tdk-framework). Star this repo (`flisk`) if the CLI is what you run; use the awesome list to browse the rest.
712
+ **Ecosystem map:** examples, articles, related tools, and notes live in [awesome-flisk-framework](https://github.com/flisk-sh/awesome-flisk-framework). Star this repo (`flisk`) if the CLI is what you run; use the awesome list to browse the rest.
690
713
 
691
714
  <p align="center">
692
715
 
@@ -24,6 +24,13 @@ interface Checkpoint {
24
24
  messages: Array<{ role: 'user' | 'agent'; content: string; at: string }>;
25
25
  }
26
26
 
27
+ // One row of GET /sessions: what is stored, not what is in it, so listing never reads a whole conversation.
28
+ interface SessionSummary {
29
+ sessionId: string;
30
+ lastActivity: string;
31
+ checkpointBytes: number;
32
+ }
33
+
27
34
  let client: Client | null = null;
28
35
  let initialized = false;
29
36
 
@@ -69,6 +76,17 @@ const store = {
69
76
  return { sessionId, messages: [] };
70
77
  }
71
78
  },
79
+ async list(): Promise<SessionSummary[]> {
80
+ const c = await getClient();
81
+ const res = await c.query(
82
+ 'SELECT session_id, updated_at, octet_length(messages::text) AS bytes FROM flisk_agent_sessions',
83
+ );
84
+ return res.rows.map((row: { session_id: string; updated_at: Date; bytes: number }) => ({
85
+ sessionId: row.session_id,
86
+ lastActivity: new Date(row.updated_at).toISOString(),
87
+ checkpointBytes: Number(row.bytes),
88
+ }));
89
+ },
72
90
  async save(checkpoint: Checkpoint): Promise<void> {
73
91
  const c = await getClient();
74
92
  await c.query(
@@ -100,6 +118,9 @@ async function runTurn(checkpoint: Checkpoint, input: string): Promise<string> {
100
118
 
101
119
  let draining = false;
102
120
  let inFlight = 0;
121
+ // Turns in flight per session, so GET /sessions can tell an active session from an idle one.
122
+ const activeTurns = new Map<string, number>();
123
+ const SESSION_LIST_LIMIT = 500;
103
124
 
104
125
  function sendJson(res: ServerResponse, status: number, body: unknown) {
105
126
  res.statusCode = status;
@@ -139,12 +160,19 @@ const server = createServer(async (req, res) => {
139
160
  }
140
161
  const body = await readJson(req);
141
162
  const input = String(body.input ?? '');
142
- const checkpoint = await store.load(sessionId);
143
- checkpoint.messages.push({ role: 'user', content: input, at: new Date().toISOString() });
144
- const reply = await runTurn(checkpoint, input);
145
- checkpoint.messages.push({ role: 'agent', content: reply, at: new Date().toISOString() });
146
- await store.save(checkpoint);
147
- return sendJson(res, 200, { sessionId, reply, turns: checkpoint.messages.length / 2 });
163
+ activeTurns.set(sessionId, (activeTurns.get(sessionId) ?? 0) + 1);
164
+ try {
165
+ const checkpoint = await store.load(sessionId);
166
+ checkpoint.messages.push({ role: 'user', content: input, at: new Date().toISOString() });
167
+ const reply = await runTurn(checkpoint, input);
168
+ checkpoint.messages.push({ role: 'agent', content: reply, at: new Date().toISOString() });
169
+ await store.save(checkpoint);
170
+ return sendJson(res, 200, { sessionId, reply, turns: checkpoint.messages.length / 2 });
171
+ } finally {
172
+ const left = (activeTurns.get(sessionId) ?? 1) - 1;
173
+ if (left > 0) activeTurns.set(sessionId, left);
174
+ else activeTurns.delete(sessionId);
175
+ }
148
176
  }
149
177
 
150
178
  if (url.pathname === '/session' && req.method === 'GET') {
@@ -154,6 +182,17 @@ const server = createServer(async (req, res) => {
154
182
  return sendJson(res, 200, { sessionId, turns: checkpoint.messages.length / 2, messages: checkpoint.messages });
155
183
  }
156
184
 
185
+ // Read-only list of the stored sessions, most recent first. status is 'active' while this instance runs a turn
186
+ // for the session, otherwise 'idle'. total counts every stored session, also those past the limit.
187
+ if (url.pathname === '/sessions' && req.method === 'GET') {
188
+ const all = (await store.list()).sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
189
+ const sessions = all.slice(0, SESSION_LIST_LIMIT).map((s) => ({
190
+ ...s,
191
+ status: activeTurns.has(s.sessionId) ? 'active' : 'idle',
192
+ }));
193
+ return sendJson(res, 200, { sessions, total: all.length });
194
+ }
195
+
157
196
  return sendJson(res, 404, { error: 'not_found' });
158
197
  } catch (error) {
159
198
  console.error('[agent] request failed:', error);
@@ -189,7 +228,7 @@ process.on('SIGINT', () => shutdown('SIGINT'));
189
228
  `;
190
229
  }
191
230
  return `import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
192
- import { mkdirSync, readFileSync, renameSync, writeFileSync, existsSync } from 'node:fs';
231
+ import { mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync, existsSync } from 'node:fs';
193
232
  import { join } from 'node:path';
194
233
 
195
234
  // Contract with Flisk (see docs/agents/ai-agents.md):
@@ -208,6 +247,13 @@ interface Checkpoint {
208
247
  messages: Array<{ role: 'user' | 'agent'; content: string; at: string }>;
209
248
  }
210
249
 
250
+ // One row of GET /sessions: what is stored, not what is in it, so listing never reads a whole conversation.
251
+ interface SessionSummary {
252
+ sessionId: string;
253
+ lastActivity: string;
254
+ checkpointBytes: number;
255
+ }
256
+
211
257
  // Session store. files: one JSON checkpoint per session, written atomically (temp file, then rename), so a kill
212
258
  // mid-write leaves the previous checkpoint intact.
213
259
  const store = {
@@ -223,6 +269,20 @@ const store = {
223
269
  const file = join(STATE_DIR, sessionId + '.json');
224
270
  return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : { sessionId, messages: [] };
225
271
  },
272
+ list(): SessionSummary[] {
273
+ if (!existsSync(STATE_DIR)) return [];
274
+ // Only finished checkpoints: an in-progress write is still a .tmp file.
275
+ return readdirSync(STATE_DIR)
276
+ .filter((file) => file.endsWith('.json'))
277
+ .flatMap((file) => {
278
+ try {
279
+ const stat = statSync(join(STATE_DIR, file));
280
+ return [{ sessionId: file.slice(0, -'.json'.length), lastActivity: stat.mtime.toISOString(), checkpointBytes: stat.size }];
281
+ } catch {
282
+ return []; // removed between the directory read and the stat
283
+ }
284
+ });
285
+ },
226
286
  save(checkpoint: Checkpoint): void {
227
287
  mkdirSync(STATE_DIR, { recursive: true });
228
288
  const file = join(STATE_DIR, checkpoint.sessionId + '.json');
@@ -241,6 +301,9 @@ async function runTurn(checkpoint: Checkpoint, input: string): Promise<string> {
241
301
 
242
302
  let draining = false;
243
303
  let inFlight = 0;
304
+ // Turns in flight per session, so GET /sessions can tell an active session from an idle one.
305
+ const activeTurns = new Map<string, number>();
306
+ const SESSION_LIST_LIMIT = 500;
244
307
 
245
308
  function sendJson(res: ServerResponse, status: number, body: unknown) {
246
309
  res.statusCode = status;
@@ -280,12 +343,19 @@ const server = createServer(async (req, res) => {
280
343
  }
281
344
  const body = await readJson(req);
282
345
  const input = String(body.input ?? '');
283
- const checkpoint = await store.load(sessionId);
284
- checkpoint.messages.push({ role: 'user', content: input, at: new Date().toISOString() });
285
- const reply = await runTurn(checkpoint, input);
286
- checkpoint.messages.push({ role: 'agent', content: reply, at: new Date().toISOString() });
287
- await store.save(checkpoint);
288
- return sendJson(res, 200, { sessionId, reply, turns: checkpoint.messages.length / 2 });
346
+ activeTurns.set(sessionId, (activeTurns.get(sessionId) ?? 0) + 1);
347
+ try {
348
+ const checkpoint = await store.load(sessionId);
349
+ checkpoint.messages.push({ role: 'user', content: input, at: new Date().toISOString() });
350
+ const reply = await runTurn(checkpoint, input);
351
+ checkpoint.messages.push({ role: 'agent', content: reply, at: new Date().toISOString() });
352
+ await store.save(checkpoint);
353
+ return sendJson(res, 200, { sessionId, reply, turns: checkpoint.messages.length / 2 });
354
+ } finally {
355
+ const left = (activeTurns.get(sessionId) ?? 1) - 1;
356
+ if (left > 0) activeTurns.set(sessionId, left);
357
+ else activeTurns.delete(sessionId);
358
+ }
289
359
  }
290
360
 
291
361
  if (url.pathname === '/session' && req.method === 'GET') {
@@ -295,6 +365,17 @@ const server = createServer(async (req, res) => {
295
365
  return sendJson(res, 200, { sessionId, turns: checkpoint.messages.length / 2, messages: checkpoint.messages });
296
366
  }
297
367
 
368
+ // Read-only list of the stored sessions, most recent first. status is 'active' while this instance runs a turn
369
+ // for the session, otherwise 'idle'. total counts every stored session, also those past the limit.
370
+ if (url.pathname === '/sessions' && req.method === 'GET') {
371
+ const all = (await store.list()).sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
372
+ const sessions = all.slice(0, SESSION_LIST_LIMIT).map((s) => ({
373
+ ...s,
374
+ status: activeTurns.has(s.sessionId) ? 'active' : 'idle',
375
+ }));
376
+ return sendJson(res, 200, { sessions, total: all.length });
377
+ }
378
+
298
379
  return sendJson(res, 404, { error: 'not_found' });
299
380
  } catch (error) {
300
381
  console.error('[agent] request failed:', error);
@@ -1,57 +1,64 @@
1
1
  // Copyright (c) 2026 Flisk Landscape contributors
2
2
  // SPDX-License-Identifier: MIT
3
- import * as http from "node:http";
4
3
  import chalk from "chalk";
5
4
  import { Command } from "commander";
5
+ import { formatAge } from "../utils/agent-format.js";
6
+ import { agentSessionListRequest, agentSessionRequest, fetchAgent, parseAgentSessionList, } from "../utils/agent-sessions.js";
6
7
  import { createDiscoveryContext } from "../utils/discovery-context.js";
7
8
  import { errorFactories, FliskError, runCommand } from "../utils/errors.js";
9
+ import { formatBytes } from "../utils/formatting.js";
8
10
  import { getHostPortPlan } from "../utils/host-port-config.js";
9
11
  import { createMachineEnvelope, writeMachineError } from "../utils/machine-output.js";
10
12
  import { findProjectRoot } from "../utils/paths.js";
11
13
  import { buildServicePorts } from "../utils/status-ports.js";
12
- const SESSION_REQUEST_TIMEOUT_MS = 10_000;
13
- /** GET with node:http because fetch silently drops a custom Host header, which is what Traefik routes on. */
14
- export function httpGet(url, headers, timeoutMs) {
15
- return new Promise((resolve, reject) => {
16
- // One signal covers the connection and the body read, so a hung agent cannot block the CLI.
17
- const req = http.get(url, { headers, signal: AbortSignal.timeout(timeoutMs) }, (res) => {
18
- const chunks = [];
19
- res.on("data", (chunk) => chunks.push(chunk));
20
- res.on("end", () => {
21
- const status = res.statusCode ?? 0;
22
- resolve({
23
- ok: status >= 200 && status < 300,
24
- status,
25
- text: Buffer.concat(chunks).toString("utf-8"),
26
- });
27
- });
28
- res.on("error", reject);
29
- });
30
- req.on("error", reject);
31
- });
32
- }
33
- /**
34
- * Agent containers publish no host port; the only way in is the Traefik route that `flisk status` prints as `url`
35
- * (for example http://api.<project>.localhost:8080/api/bot). `*.localhost` does not resolve everywhere (glibc without
36
- * systemd-resolved), so connect to loopback and keep the route's host in the Host header, which is all Traefik matches on.
37
- */
38
- export function agentSessionRequest(routeUrl, sessionId) {
39
- const route = new URL(routeUrl);
40
- const headers = { "x-session-id": sessionId };
41
- if (route.hostname.endsWith(".localhost")) {
42
- headers.host = route.host;
43
- route.hostname = "127.0.0.1";
14
+ export { formatAge } from "../utils/agent-format.js";
15
+ export { agentSessionRequest, httpGet } from "../utils/agent-sessions.js";
16
+ const WATCH_INTERVAL_MS = 2_000;
17
+ /** The plain-text table `flisk agent sessions <resource>` prints, one line per session. */
18
+ export function formatSessionTable(list, now = Date.now()) {
19
+ if (list.sessions.length === 0)
20
+ return ["No sessions yet."];
21
+ const rows = list.sessions.map((s) => [
22
+ s.sessionId,
23
+ formatAge(s.lastActivity, now),
24
+ s.status,
25
+ s.checkpointBytes === null ? "-" : formatBytes(s.checkpointBytes),
26
+ ]);
27
+ const header = ["SESSION", "LAST ACTIVITY", "STATUS", "CHECKPOINT"];
28
+ const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
29
+ const line = (cells) => cells
30
+ .map((c, i) => (i === cells.length - 1 ? c : c.padEnd(widths[i])))
31
+ .join(" ")
32
+ .trimEnd();
33
+ const lines = [line(header), ...rows.map(line)];
34
+ if (list.total > list.sessions.length) {
35
+ lines.push(`... ${list.total - list.sessions.length} more not shown`);
44
36
  }
45
- return { url: `${route.origin}${route.pathname.replace(/\/$/, "")}/session`, headers };
37
+ return lines;
46
38
  }
47
39
  export const agentCommand = new Command("agent").description("Inspect and operate AI agent resources");
48
40
  agentCommand
49
41
  .command("sessions <resource>")
50
- .description("Inspect session checkpoints from a running AI agent resource")
51
- .option("--session-id <id>", "Session ID to fetch (required)")
42
+ .description("List the sessions of a running AI agent resource, or inspect one session's checkpoint")
43
+ .option("--session-id <id>", "Session ID to fetch; without it, list all sessions")
44
+ .option("--watch", "Refresh the output every 2 seconds until interrupted", false)
52
45
  .option("--json", "Output one JSON object and exit", false)
53
46
  .action(async (resourceName, options) => {
47
+ // JSON mode prints its envelope and exits 1; human mode throws, and runCommand prints the message and hints.
48
+ const fail = ({ code, message, hints }) => {
49
+ if (options.json) {
50
+ console.log(JSON.stringify(createMachineEnvelope(null, [{ code, message }])));
51
+ process.exit(1);
52
+ }
53
+ throw new FliskError(message, hints);
54
+ };
54
55
  const action = async () => {
56
+ if (options.watch && options.json) {
57
+ fail({
58
+ code: "USAGE",
59
+ message: "--watch refreshes a table on the terminal and cannot be combined with --json.",
60
+ });
61
+ }
55
62
  const root = findProjectRoot();
56
63
  if (!root) {
57
64
  const error = errorFactories.notInProject();
@@ -62,112 +69,126 @@ agentCommand
62
69
  const discovery = createDiscoveryContext();
63
70
  const resource = discovery.resources.find((r) => r.name === resourceName);
64
71
  if (!resource) {
65
- const msg = `Resource "${resourceName}" not found in project.`;
66
- if (options.json) {
67
- console.log(JSON.stringify(createMachineEnvelope(null, [{ code: "UNKNOWN_RESOURCE", message: msg }])));
68
- process.exit(1);
69
- }
70
- throw new FliskError(msg);
72
+ fail({
73
+ code: "UNKNOWN_RESOURCE",
74
+ message: `Resource "${resourceName}" not found in project.`,
75
+ });
76
+ return;
71
77
  }
72
78
  const appType = resource.config?.appType ?? resource.type;
73
79
  if (appType !== "agent") {
74
- const msg = `Resource "${resourceName}" is of type "${appType}", not "agent".`;
75
- if (options.json) {
76
- console.log(JSON.stringify(createMachineEnvelope(null, [{ code: "NOT_AN_AGENT", message: msg }])));
77
- process.exit(1);
78
- }
79
- throw new FliskError(msg);
80
+ fail({
81
+ code: "NOT_AN_AGENT",
82
+ message: `Resource "${resourceName}" is of type "${appType}", not "agent".`,
83
+ });
80
84
  }
81
85
  const port = resource.port;
82
86
  if (!port) {
83
- const msg = `Agent "${resourceName}" has no assigned port.`;
84
- if (options.json) {
85
- console.log(JSON.stringify(createMachineEnvelope(null, [{ code: "NO_PORT", message: msg }])));
86
- process.exit(1);
87
- }
88
- throw new FliskError(msg);
89
- }
90
- // The agent runtime keys GET /session by x-session-id and has no list endpoint, so a missing id
91
- // would only come back as a confusing 400. Fail before any request. Checked after resource
92
- // validation so UNKNOWN_RESOURCE, NOT_AN_AGENT and NO_PORT keep their precedence.
93
- const sessionId = typeof options.sessionId === "string" ? options.sessionId.trim() : "";
94
- if (!sessionId) {
95
- const msg = `--session-id is required: the agent "${resourceName}" returns the checkpoint of one session.`;
96
- if (options.json) {
97
- console.log(JSON.stringify(createMachineEnvelope(null, [{ code: "SESSION_ID_REQUIRED", message: msg }])));
98
- process.exit(1);
99
- }
100
- throw new FliskError(msg, [`Run: flisk agent sessions ${resourceName} --session-id <id>`]);
87
+ fail({ code: "NO_PORT", message: `Agent "${resourceName}" has no assigned port.` });
101
88
  }
102
89
  const portPlan = root
103
90
  ? await getHostPortPlan(root, { inspectDocker: false }).catch(() => null)
104
91
  : null;
105
92
  const routeUrl = buildServicePorts(resource, portPlan?.ingressHttp).url;
106
93
  if (!routeUrl) {
107
- const msg = `Agent "${resourceName}" has no ingress route to reach it through.`;
108
- if (options.json) {
109
- console.log(JSON.stringify(createMachineEnvelope(null, [{ code: "NO_ROUTE", message: msg }])));
110
- process.exit(1);
111
- }
112
- throw new FliskError(msg);
94
+ fail({
95
+ code: "NO_ROUTE",
96
+ message: `Agent "${resourceName}" has no ingress route to reach it through.`,
97
+ });
98
+ return;
113
99
  }
114
- const request = agentSessionRequest(routeUrl, sessionId);
115
- // Nothing answered, or Traefik answered for a missing backend: either way the agent is not up.
116
- const notRunning = () => {
117
- const msg = `Agent "${resourceName}" is not responding at ${routeUrl}. Is the stack running?`;
118
- if (options.json) {
119
- console.log(JSON.stringify(createMachineEnvelope(null, [{ code: "AGENT_NOT_RUNNING", message: msg }])));
120
- process.exit(1);
100
+ // With an id: the one checkpoint that GET /session returns. Without: the list from GET /sessions.
101
+ const sessionId = typeof options.sessionId === "string" ? options.sessionId.trim() : "";
102
+ const notRunning = {
103
+ code: "AGENT_NOT_RUNNING",
104
+ message: `Agent "${resourceName}" is not responding at ${routeUrl}. Is the stack running?`,
105
+ hints: ["Start the stack with: flisk up"],
106
+ };
107
+ const requestFailed = (reply) => ({
108
+ code: "AGENT_REQUEST_FAILED",
109
+ message: `Agent returned ${reply.status}: ${reply.detail}`,
110
+ });
111
+ /** One request and its rendering: the data for --json, or the lines a human sees. */
112
+ const load = async () => {
113
+ if (sessionId) {
114
+ const reply = await fetchAgent(agentSessionRequest(routeUrl, sessionId));
115
+ if (reply.kind === "not-running")
116
+ return { ok: false, failure: notRunning };
117
+ if (reply.kind === "not-found") {
118
+ const detail = reply.body?.error;
119
+ return {
120
+ ok: false,
121
+ failure: requestFailed({
122
+ kind: "error",
123
+ status: 404,
124
+ detail: typeof detail === "string" ? detail : JSON.stringify(reply.body),
125
+ }),
126
+ };
127
+ }
128
+ if (reply.kind === "error")
129
+ return { ok: false, failure: requestFailed(reply) };
130
+ return {
131
+ ok: true,
132
+ data: { agent: resourceName, port, session: reply.body },
133
+ lines: [
134
+ chalk.bold(`Sessions for ${resourceName}:`),
135
+ JSON.stringify(reply.body, null, 2),
136
+ ],
137
+ };
138
+ }
139
+ const reply = await fetchAgent(agentSessionListRequest(routeUrl));
140
+ if (reply.kind === "not-running")
141
+ return { ok: false, failure: notRunning };
142
+ // The agent answered, but not for /sessions: it was scaffolded before the list endpoint existed.
143
+ const list = reply.kind === "ok" ? parseAgentSessionList(reply.body) : null;
144
+ if (reply.kind === "not-found" || (reply.kind === "ok" && !list)) {
145
+ return {
146
+ ok: false,
147
+ failure: {
148
+ code: "SESSION_LIST_UNSUPPORTED",
149
+ message: `Agent "${resourceName}" does not serve GET /sessions; it was created before Flisk could list agent sessions.`,
150
+ hints: [
151
+ `Read one session with: flisk agent sessions ${resourceName} --session-id <id>`,
152
+ "Or copy the /sessions handler from a new scaffold (flisk resource <name> --type agent) into the agent's src/index.ts",
153
+ ],
154
+ },
155
+ };
121
156
  }
122
- throw new FliskError(msg, ["Start the stack with: flisk up"]);
157
+ if (reply.kind === "error")
158
+ return { ok: false, failure: requestFailed(reply) };
159
+ const sessions = list;
160
+ return {
161
+ ok: true,
162
+ data: { agent: resourceName, port, sessions: sessions.sessions, total: sessions.total },
163
+ lines: [chalk.bold(`Sessions for ${resourceName}:`), ...formatSessionTable(sessions)],
164
+ };
123
165
  };
124
- let res;
125
- let bodyText;
126
- try {
127
- const reply = await httpGet(request.url, request.headers, SESSION_REQUEST_TIMEOUT_MS);
128
- res = reply;
129
- bodyText = reply.text;
130
- }
131
- catch {
132
- return notRunning();
133
- }
134
- let body = {};
135
- try {
136
- body = bodyText ? JSON.parse(bodyText) : {};
137
- }
138
- catch {
139
- body = { raw: bodyText };
140
- }
141
- // Traefik answers 502/503/504 when the route has no live container and a plain-text 404 when it has no route at
142
- // all. An agent's own errors are JSON, so a non-JSON 404 is the gateway, not the agent.
143
- const gatewayText = typeof body === "object" && body !== null && "raw" in body;
144
- if (!res.ok &&
145
- ([502, 503, 504].includes(res.status) || (res.status === 404 && gatewayText))) {
146
- return notRunning();
147
- }
148
- if (!res.ok) {
149
- const detail = body?.error ?? bodyText;
166
+ if (!options.watch) {
167
+ const result = await load();
168
+ if (!result.ok) {
169
+ fail(result.failure);
170
+ return;
171
+ }
150
172
  if (options.json) {
151
- console.log(JSON.stringify(createMachineEnvelope(null, [
152
- {
153
- code: "AGENT_REQUEST_FAILED",
154
- message: `Agent returned ${res.status}: ${detail}`,
155
- },
156
- ])));
157
- process.exit(1);
173
+ console.log(JSON.stringify(createMachineEnvelope(result.data)));
174
+ return;
158
175
  }
159
- throw new FliskError(`Agent error (${res.status}): ${detail}`);
160
- }
161
- if (options.json) {
162
- console.log(JSON.stringify(createMachineEnvelope({
163
- agent: resourceName,
164
- port,
165
- session: body,
166
- })));
176
+ for (const line of result.lines)
177
+ console.log(line);
167
178
  return;
168
179
  }
169
- console.log(chalk.bold(`Sessions for ${resourceName}:`));
170
- console.log(JSON.stringify(body, null, 2));
180
+ // --watch: redraw on a timer. A failed refresh (the agent restarting, say) is shown and retried, not fatal.
181
+ for (;;) {
182
+ const result = await load();
183
+ const lines = result.ok
184
+ ? result.lines
185
+ : [chalk.red(result.failure.message), ...(result.failure.hints ?? [])];
186
+ process.stdout.write("\x1b[2J\x1b[H");
187
+ for (const line of lines)
188
+ console.log(line);
189
+ console.log(chalk.dim(`\nRefreshing every ${WATCH_INTERVAL_MS / 1000}s. Ctrl+C to stop.`));
190
+ await new Promise((resolve) => setTimeout(resolve, WATCH_INTERVAL_MS));
191
+ }
171
192
  };
172
193
  if (options.json) {
173
194
  try {