@flisk-sh/flisk 1.4.6 → 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 (66) hide show
  1. package/README.md +2 -2
  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 +12 -0
  5. package/cli/dist/commands/logs.js +71 -4
  6. package/cli/dist/commands/project.js +16 -0
  7. package/cli/dist/commands/resource.js +3 -1
  8. package/cli/dist/commands/status.js +43 -24
  9. package/cli/dist/commands/ui.js +41 -2
  10. package/cli/dist/commands/up.js +54 -11
  11. package/cli/dist/components/DetailPanel.js +23 -3
  12. package/cli/dist/mcp/tools.js +9 -6
  13. package/cli/dist/types/index.js +0 -2
  14. package/cli/dist/utils/agent-format.js +18 -0
  15. package/cli/dist/utils/agent-sessions.js +122 -0
  16. package/cli/dist/utils/agent-skills.js +64 -0
  17. package/cli/dist/utils/cold-preflight.js +3 -0
  18. package/cli/dist/utils/doctor-fix-first.js +93 -0
  19. package/cli/dist/utils/doctor-report.js +3 -0
  20. package/cli/dist/utils/native-runtime.js +3 -0
  21. package/cli/dist/utils/native-service.js +2 -2
  22. package/cli/dist/utils/service-manifest.js +8 -15
  23. package/cli/dist/utils/services.js +7 -10
  24. package/cli/dist/utils/startup-report.js +45 -3
  25. package/cli/dist/utils/status-groups.js +23 -0
  26. package/cli/dist/utils/up-hints.js +42 -0
  27. package/cli/dist/utils/up-progress.js +117 -0
  28. package/cli/dist/utils/up-readiness.js +5 -0
  29. package/cli/dist-bundle/{chunk-U5FCQF42.js → chunk-6OW2GVQV.js} +1 -1
  30. package/cli/dist-bundle/chunk-B2JOM3HN.js +35 -0
  31. package/cli/dist-bundle/{chunk-R7DWXISM.js → chunk-F2DLIM75.js} +1 -1
  32. package/cli/dist-bundle/{chunk-ETXZ5LUG.js → chunk-L6O7TT3K.js} +10 -10
  33. package/cli/dist-bundle/chunk-MFY7RZ76.js +130 -0
  34. package/cli/dist-bundle/{chunk-MUDE4DZD.js → chunk-MYAYJF4Q.js} +1 -1
  35. package/cli/dist-bundle/{chunk-DPQK5TIS.js → chunk-P7GZ6NGM.js} +1 -1
  36. package/cli/dist-bundle/{chunk-IDNES5PE.js → chunk-T44DEEL6.js} +1 -1
  37. package/cli/dist-bundle/{chunk-C4UFBXRD.js → chunk-TIPSERWO.js} +1 -1
  38. package/cli/dist-bundle/{chunk-BIVAAWBX.js → chunk-VYPB7MY5.js} +1 -1
  39. package/cli/dist-bundle/{chunk-WCGBVETR.js → chunk-XHENT4KO.js} +1 -1
  40. package/cli/dist-bundle/{chunk-CAEMWNPF.js → chunk-YTBTCAWY.js} +1 -1
  41. package/cli/dist-bundle/cli.js +462 -377
  42. package/cli/dist-bundle/{cold-preflight-KUWOL2Q2.js → cold-preflight-DQVJUGBB.js} +1 -1
  43. package/cli/dist-bundle/{demo-stage-QCC4IWXR.js → demo-stage-F4YA7UL2.js} +1 -1
  44. package/cli/dist-bundle/{open-AZNX4AC5.js → open-ZUSSVPD3.js} +1 -1
  45. package/cli/dist-bundle/project-JFCTPRP5.js +2 -0
  46. package/cli/dist-bundle/{services-EY5WKPHQ.js → services-DOHDHOCT.js} +1 -1
  47. package/cli/dist-bundle/{tilt-port-owner-UBU6JWOA.js → tilt-port-owner-XODQHXHX.js} +1 -1
  48. package/cli/package.json +1 -1
  49. package/cli/templates/agent-skills/flisk-agent-ops/SKILL.md +39 -0
  50. package/cli/templates/agent-skills/flisk-build/SKILL.md +85 -0
  51. package/cli/templates/agent-skills/flisk-doctor/SKILL.md +28 -0
  52. package/cli/templates/agent-skills/flisk-recipe-ai-agent/SKILL.md +45 -0
  53. package/cli/templates/agent-skills/flisk-recipe-blog/SKILL.md +36 -0
  54. package/cli/templates/agent-skills/flisk-recipe-saas/SKILL.md +39 -0
  55. package/cli/templates/agent-skills/flisk-recipe-shop/SKILL.md +41 -0
  56. package/cli/templates/agent-skills/flisk-recipe-starters/SKILL.md +46 -0
  57. package/cli/templates/agent-skills/flisk-resource/SKILL.md +27 -0
  58. package/cli/templates/agent-skills/flisk-status/SKILL.md +25 -0
  59. package/cli/templates/agent-skills/flisk-troubleshoot/SKILL.md +227 -0
  60. package/cli/templates/agent-skills/flisk-up/SKILL.md +37 -0
  61. package/package.json +1 -1
  62. package/runtime/engine/schemas/service-schema.json +3 -15
  63. package/runtime/engine/topologies/tilt/resources/orchestrator/apply_compose_resource_registration.star +7 -1
  64. package/cli/dist-bundle/chunk-CDKEHFW6.js +0 -35
  65. package/cli/dist-bundle/chunk-GT76VMUL.js +0 -130
  66. package/cli/dist-bundle/project-5SWIGCNP.js +0 -2
package/README.md CHANGED
@@ -356,11 +356,11 @@ Get a running local stack in **3 commands**. Works on macOS, Linux, and Windows
356
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
357
 
358
358
  ```json
359
- { "schemaVersion": 1, "name": "api", "command": "node server.js", "port": 4000, "healthCheckPath": "/health" }
359
+ { "schemaVersion": 1, "appName": "api", "appType": "backend", "command": "node server.js", "port": 4000, "healthCheckPath": "/health" }
360
360
  ```
361
361
 
362
362
  ```json
363
- { "schemaVersion": 1, "name": "web", "command": "node server.js", "port": 3000, "dependsOn": ["api"] }
363
+ { "schemaVersion": 1, "appName": "web", "appType": "frontend", "command": "node server.js", "port": 3000, "dependsOn": ["api"] }
364
364
  ```
365
365
 
366
366
  Then start everything from the project folder:
@@ -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 {
@@ -14,6 +14,7 @@ import { AUTOGENERATED_DIR, CONTAINER_RUNTIME_CHECK, MASTER_CONFIG_FILES, PACKAG
14
14
  import { isPathDiscovered, readDiscoveryPaths } from "../utils/discovery-paths.js";
15
15
  import { checkCrashLoopingContainers } from "../utils/doctor-crashloop.js";
16
16
  import { applyDoctorFixes, checkDuplicateFliskInstalls, hasFailedActions, planDoctorFixes, projectTiltPids, } from "../utils/doctor-fix.js";
17
+ import { formatFixFirst, rankDoctorFixes } from "../utils/doctor-fix-first.js";
17
18
  import { checkDiskSpace, dockerDesktopStuck } from "../utils/doctor-host.js";
18
19
  import { checkNativeCommands, checkNativeDependsOn, checkNativeRuntime, checkNativeServiceHealth, } from "../utils/doctor-native.js";
19
20
  import { collectDoctorChecks, createDoctorReport, getDoctorExitCode, } from "../utils/doctor-report.js";
@@ -1216,6 +1217,15 @@ function printDoctorResults(list) {
1216
1217
  }
1217
1218
  export function renderDoctorOutput(machineResults, projectResults, inProject, options = {}, allResults) {
1218
1219
  const results = allResults ?? [...machineResults, ...projectResults];
1220
+ const printFixFirst = () => {
1221
+ const lines = formatFixFirst(rankDoctorFixes(results));
1222
+ if (lines.length === 0)
1223
+ return;
1224
+ console.log(chalk.bold("Fix first"));
1225
+ for (const line of lines)
1226
+ console.log(line);
1227
+ console.log("");
1228
+ };
1219
1229
  if (options.all) {
1220
1230
  console.log(chalk.bold("Machine"));
1221
1231
  printDoctorResults(machineResults);
@@ -1230,10 +1240,12 @@ export function renderDoctorOutput(machineResults, projectResults, inProject, op
1230
1240
  }
1231
1241
  console.log("");
1232
1242
  console.log(formatDoctorSummary(results));
1243
+ printFixFirst();
1233
1244
  }
1234
1245
  else {
1235
1246
  printDoctorResults(results);
1236
1247
  console.log("");
1248
+ printFixFirst();
1237
1249
  const allPassed = results.every((check) => check.didPass || check.isSkipped || check.isWarning);
1238
1250
  if (allPassed && !inProject) {
1239
1251
  console.log(`${chalk.gray("○")} ${chalk.gray("Not in a Flisk project, so project checks were skipped")}`);