maestro-agent 0.0.1 → 0.0.2

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 (94) hide show
  1. package/README.md +316 -2
  2. package/bin/maestro.ts +5 -0
  3. package/dist/maestro +0 -0
  4. package/dist/web/assets/Connections-DV2Kql1Z.js +1 -0
  5. package/dist/web/assets/GanttView-CCT_rFpY.js +39 -0
  6. package/dist/web/assets/Home-BFbUIh2z.js +1 -0
  7. package/dist/web/assets/HooksCrons-ASM5-jDm.js +1 -0
  8. package/dist/web/assets/ProjectDetail-KZZi6IAd.js +1 -0
  9. package/dist/web/assets/Roles-KQ94PG3H.js +4 -0
  10. package/dist/web/assets/ScheduledTasks-CdJHJpEV.js +1 -0
  11. package/dist/web/assets/Settings-CTflMta-.js +1 -0
  12. package/dist/web/assets/Skills-D09W1mwX.js +2 -0
  13. package/dist/web/assets/Wizard-CW6B0wc3.js +1 -0
  14. package/dist/web/assets/WorkspaceChat-CthETL_A.js +1 -0
  15. package/dist/web/assets/WorkspaceDashboard-DTAesQuT.js +1 -0
  16. package/dist/web/assets/WorkspaceNew-Em4msIKn.js +1 -0
  17. package/dist/web/assets/WorkspaceProjects-Dxg2BpQy.js +1 -0
  18. package/dist/web/assets/WorkspaceTasks-C20mnnkP.js +1 -0
  19. package/dist/web/assets/index-B1k33vcR.js +11 -0
  20. package/dist/web/assets/index-Bk2hHz7P.css +1 -0
  21. package/dist/web/assets/index-Ddy5AJwx.js +61 -0
  22. package/dist/web/assets/useEventStream-DTID465I.js +1 -0
  23. package/dist/web/index.html +13 -0
  24. package/package.json +49 -6
  25. package/src/api/agents.ts +76 -0
  26. package/src/api/audit.ts +19 -0
  27. package/src/api/autopilot.ts +73 -0
  28. package/src/api/chat.ts +801 -0
  29. package/src/api/chief.ts +84 -0
  30. package/src/api/config.ts +39 -0
  31. package/src/api/gantt.ts +72 -0
  32. package/src/api/hooks.ts +54 -0
  33. package/src/api/inbox.ts +125 -0
  34. package/src/api/lark.ts +32 -0
  35. package/src/api/memory.ts +37 -0
  36. package/src/api/ops.ts +89 -0
  37. package/src/api/projects.ts +105 -0
  38. package/src/api/roles.ts +123 -0
  39. package/src/api/runtimes.ts +62 -0
  40. package/src/api/scheduled-tasks.ts +203 -0
  41. package/src/api/sessions.ts +479 -0
  42. package/src/api/skills.ts +386 -0
  43. package/src/api/tasks.ts +457 -0
  44. package/src/api/telegram.ts +94 -0
  45. package/src/api/templates.ts +36 -0
  46. package/src/api/webhooks.ts +20 -0
  47. package/src/api/workspaces.ts +150 -0
  48. package/src/bridges/lark/index.ts +213 -0
  49. package/src/bridges/telegram/index.ts +273 -0
  50. package/src/bridges/telegram/polling.ts +185 -0
  51. package/src/chat/index.ts +86 -0
  52. package/src/chief/index.ts +461 -0
  53. package/src/core/cli.ts +333 -0
  54. package/src/core/db.ts +53 -0
  55. package/src/core/event-bus.ts +33 -0
  56. package/src/core/index.ts +6 -0
  57. package/src/core/migrations.ts +303 -0
  58. package/src/core/router.ts +69 -0
  59. package/src/core/schema.sql +232 -0
  60. package/src/core/server.ts +308 -0
  61. package/src/core/validate.ts +22 -0
  62. package/src/discovery/index.ts +194 -0
  63. package/src/gateway/adapters/telegram.ts +148 -0
  64. package/src/gateway/index.ts +31 -0
  65. package/src/gateway/manager.ts +176 -0
  66. package/src/gateway/types.ts +77 -0
  67. package/src/inbox/index.ts +500 -0
  68. package/src/ops/artifact-sync.ts +65 -0
  69. package/src/ops/autopilot.ts +338 -0
  70. package/src/ops/gc.ts +252 -0
  71. package/src/ops/index.ts +226 -0
  72. package/src/ops/project-serial.ts +52 -0
  73. package/src/ops/role-dispatch.ts +111 -0
  74. package/src/ops/runtime-scheduler.ts +447 -0
  75. package/src/ops/task-blocking.ts +65 -0
  76. package/src/ops/task-deps.ts +37 -0
  77. package/src/ops/task-workspace.ts +60 -0
  78. package/src/roles/index.ts +258 -0
  79. package/src/roles/prompt-assembler.ts +85 -0
  80. package/src/roles/workspace-role.ts +155 -0
  81. package/src/scheduler/index.ts +461 -0
  82. package/src/session/output-parser.ts +75 -0
  83. package/src/session/realtime-parser.ts +40 -0
  84. package/src/skills/builtin.ts +155 -0
  85. package/src/skills/skill-extractor.ts +452 -0
  86. package/src/skills/skill-md.ts +282 -0
  87. package/src/transport/http-api.ts +75 -0
  88. package/src/transport/index.ts +4 -0
  89. package/src/transport/local-pty.ts +119 -0
  90. package/src/transport/ssh.ts +176 -0
  91. package/src/transport/types.ts +20 -0
  92. package/src/workflows/index.ts +231 -0
  93. package/index.js +0 -1
  94. package/maestro-agent-0.0.1.tgz +0 -0
@@ -0,0 +1,479 @@
1
+ import { appendFileSync, chmodSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
+ import { join } from "path";
3
+ import { generateId, now } from "../core/db";
4
+ import type { Router } from "../core/router";
5
+ import { body, json } from "../core/router";
6
+ import type { HubContext } from "../core/server";
7
+ import { required } from "../core/validate";
8
+ import { recordAgentExit } from "../ops";
9
+ import { isAtCapacity, notifyNextInQueue } from "../ops/runtime-scheduler";
10
+ import { assemblePrompt } from "../roles/prompt-assembler";
11
+ import { parseTranscript } from "../session/output-parser";
12
+ import { RealtimeParser, type SessionEvent } from "../session/realtime-parser";
13
+ import type { TransportProcess } from "../transport";
14
+ import { HttpApiTransport, SshTransport } from "../transport";
15
+ import { getWorkflowForTask, isActiveTaskStatus, transitionTask } from "../workflows";
16
+
17
+ export interface CreateSessionInput {
18
+ agent_id: string;
19
+ task_id?: string | null;
20
+ cmd?: string;
21
+ args?: string[];
22
+ }
23
+
24
+ interface RuntimeCommandPlan {
25
+ args: string[];
26
+ closeStdin: boolean;
27
+ }
28
+
29
+ export function registerSessionRoutes(router: Router, ctx: HubContext) {
30
+ router.post("/api/sessions", async (req) => {
31
+ const input = await body<CreateSessionInput>(req);
32
+ const result = await createSession(ctx, input);
33
+ if ("error" in result) return json({ error: result.error }, result.status);
34
+ return json(result.session, 201);
35
+ });
36
+
37
+ router.get("/api/sessions/:id", (_req, params) => {
38
+ const row = ctx.db.query("SELECT * FROM session WHERE id = ?").get(params.id);
39
+ if (!row) return json({ error: "Not found" }, 404);
40
+ return json(row);
41
+ });
42
+
43
+ // GET /api/sessions/:id/output?lines=N - get last N lines of terminal output
44
+ router.get("/api/sessions/:id/output", async (req, params) => {
45
+ const session = ctx.db.query("SELECT transcript_path FROM session WHERE id = ?").get(params.id) as any;
46
+ if (!session) return json({ error: "Not found" }, 404);
47
+ if (!session.transcript_path) return json({ lines: [] });
48
+ try {
49
+ const file = Bun.file(session.transcript_path);
50
+ const size = file.size;
51
+ if (size === 0) return json({ lines: [] });
52
+ const url = new URL(req.url, "http://localhost");
53
+ const maxLines = Math.min(parseInt(url.searchParams.get("lines") || "20", 10), 100);
54
+ // Only read last 32KB to avoid loading entire large transcript
55
+ const tailSize = Math.min(size, 32 * 1024);
56
+ const tailContent = await file.slice(size - tailSize, size).text();
57
+ const allLines = tailContent.split("\n").filter((l) => l.trim().length > 0);
58
+ const lines = allLines.slice(-maxLines);
59
+ return json({ lines });
60
+ } catch {
61
+ return json({ lines: [] });
62
+ }
63
+ });
64
+
65
+ // POST /api/sessions/:id/input - inject stdin via REST
66
+ router.post("/api/sessions/:id/input", async (req, params) => {
67
+ const { data } = await body<{ data: string }>(req);
68
+ if (!data) return json({ error: "data is required" }, 400);
69
+ const proc = ctx.sessions.get(params.id);
70
+ if (!proc) return json({ error: "Session not found or not running" }, 404);
71
+ if (!proc.stdin) return json({ error: "Session has no stdin" }, 400);
72
+ proc.stdin.write(data);
73
+ return json({ written: data.length });
74
+ });
75
+
76
+ router.delete("/api/sessions/:id", (_req, params) => {
77
+ const proc = ctx.sessions.get(params.id);
78
+ if (proc) {
79
+ proc.kill();
80
+ ctx.sessions.delete(params.id);
81
+ }
82
+ const ts = now();
83
+ ctx.db.run("UPDATE session SET status = 'ended', ended_at = ? WHERE id = ?", [ts, params.id]);
84
+ // Release queued session
85
+ const session = ctx.db.query("SELECT agent_id FROM session WHERE id = ?").get(params.id) as any;
86
+ if (session?.agent_id) {
87
+ const agentRow = ctx.db.query("SELECT runtime_id FROM agent WHERE id = ?").get(session.agent_id) as any;
88
+ if (agentRow?.runtime_id) {
89
+ notifyNextInQueue(ctx.sessionQueue, agentRow.runtime_id);
90
+ }
91
+ }
92
+ return json({ id: params.id, status: "ended" });
93
+ });
94
+ }
95
+
96
+ export async function createSession(ctx: HubContext, input: CreateSessionInput): Promise<
97
+ | { session: any }
98
+ | { error: string; status: number }
99
+ > {
100
+ const { agent_id, task_id, cmd, args } = input;
101
+ required({ agent_id }, ["agent_id"]);
102
+ const agent = ctx.db.query("SELECT * FROM agent WHERE id = ?").get(agent_id) as any;
103
+ if (!agent) return { error: "Agent not found", status: 404 };
104
+
105
+ const runtime = ctx.db.query("SELECT * FROM agent_runtime WHERE id = ?").get(agent.runtime_id) as any;
106
+ if (!runtime) return { error: "Runtime not found", status: 404 };
107
+
108
+ // Check capacity with oversubscription factor
109
+ const factor = runtime.oversubscription_factor ?? 1.0;
110
+ if (isAtCapacity(ctx.db, runtime.id, Number(runtime.capacity ?? -1), factor)) {
111
+ return { error: `Runtime capacity exceeded: ${runtime.id}`, status: 409 };
112
+ }
113
+
114
+ const id = generateId("sess");
115
+ const transcriptDir = join(ctx.hubDir, "transcripts");
116
+ mkdirSync(transcriptDir, { recursive: true });
117
+ const transcriptPath = join(transcriptDir, `${id}.log`);
118
+
119
+ const hasExplicitCommand = Boolean(cmd);
120
+ const command = cmd || runtime.cmd || "echo";
121
+ const commandPlan = args
122
+ ? { args, closeStdin: false }
123
+ : (hasExplicitCommand ? { args: [], closeStdin: false } : getDefaultCommandPlan(runtime.type, input.task_id ? assemblePrompt(ctx, { agentId: agent_id, taskId: input.task_id }) : undefined));
124
+ const cwd = resolveWorkdir(ctx, agent, task_id || null);
125
+
126
+ return spawnSessionProcess(ctx, {
127
+ id,
128
+ agent_id,
129
+ task_id: task_id || null,
130
+ runtime,
131
+ command,
132
+ cmdArgs: commandPlan.args,
133
+ closeStdin: commandPlan.closeStdin,
134
+ cwd,
135
+ transcriptPath,
136
+ });
137
+ }
138
+
139
+ // 默认启动参数:无权限限制模式
140
+ function getDefaultCommandPlan(runtimeType: string, prompt?: string): RuntimeCommandPlan {
141
+ switch (runtimeType) {
142
+ case "claude":
143
+ return { args: ["--print", "--dangerously-skip-permissions", ...(prompt ? [prompt] : [])], closeStdin: true };
144
+ case "codex":
145
+ return { args: ["exec", "--dangerously-bypass-approvals-and-sandbox", ...(prompt ? [prompt] : [])], closeStdin: true };
146
+ case "opencode":
147
+ return { args: ["run", "--dangerously-skip-permissions", ...(prompt ? [prompt] : [])], closeStdin: true };
148
+ case "blade":
149
+ return { args: ["--print", "--permission-mode=yolo", ...(prompt ? [prompt] : [])], closeStdin: true };
150
+ default:
151
+ return { args: prompt ? [prompt] : [], closeStdin: true };
152
+ }
153
+ }
154
+
155
+ function resolveWorkdir(ctx: HubContext, agent: any, taskId?: string | null): string {
156
+ const projectWorkdir = taskId ? resolveProjectWorkdir(ctx, taskId) : null;
157
+ const defaultWorkdir = join(ctx.hubDir, "workspaces", agent.id);
158
+ const dir = projectWorkdir || agent.workdir || defaultWorkdir;
159
+ mkdirSync(dir, { recursive: true });
160
+ if (!agent.workdir && !projectWorkdir) {
161
+ ctx.db.run("UPDATE agent SET workdir = ? WHERE id = ?", [defaultWorkdir, agent.id]);
162
+ }
163
+ return dir;
164
+ }
165
+
166
+ function resolveProjectWorkdir(ctx: HubContext, taskId: string): string | null {
167
+ const row = ctx.db.query(`
168
+ SELECT project.workdir
169
+ FROM task
170
+ JOIN project ON project.id = task.project_id
171
+ WHERE task.id = ?
172
+ AND project.workdir IS NOT NULL
173
+ AND project.workdir != ''
174
+ LIMIT 1
175
+ `).get(taskId) as any | null;
176
+ return row?.workdir || null;
177
+ }
178
+
179
+ function ensureMaestroBin(ctx: HubContext): string {
180
+ const binDir = join(ctx.hubDir, "bin");
181
+ mkdirSync(binDir, { recursive: true });
182
+ const binPath = join(binDir, "maestro");
183
+ const cliPath = join(ctx.workspaceRoot || process.cwd(), "bin", "maestro.ts");
184
+ writeFileSync(binPath, `#!/usr/bin/env sh\nexec bun run "${cliPath}" "$@" </dev/null\n`, "utf-8");
185
+ chmodSync(binPath, 0o755);
186
+ return binDir;
187
+ }
188
+
189
+ function streamProcessOutput(
190
+ ctx: HubContext,
191
+ sessionId: string,
192
+ stream: ReadableStream<Uint8Array>,
193
+ transcriptPath: string,
194
+ type: "stdout" | "stderr",
195
+ parser?: RealtimeParser,
196
+ ) {
197
+ (async () => {
198
+ const reader = stream.getReader();
199
+ while (true) {
200
+ const { done, value } = await reader.read();
201
+ if (done) break;
202
+ const text = new TextDecoder().decode(value);
203
+ try {
204
+ appendFileSync(transcriptPath, text);
205
+ } catch (err) {
206
+ if (!isExpectedShutdownError(err)) console.warn("[session] transcript write skipped:", err);
207
+ break;
208
+ }
209
+ broadcast(ctx, sessionId, { type, data: text });
210
+ if (parser) parser.feed(text);
211
+ }
212
+ })();
213
+ }
214
+
215
+ async function spawnSessionProcess(
216
+ ctx: HubContext,
217
+ input: {
218
+ id: string;
219
+ agent_id: string;
220
+ task_id: string | null;
221
+ runtime: any;
222
+ command: string;
223
+ cmdArgs: string[];
224
+ closeStdin: boolean;
225
+ cwd: string;
226
+ transcriptPath: string;
227
+ },
228
+ ): Promise<{ session: any } | { error: string; status: number }> {
229
+ const ts = now();
230
+ // Assemble four-layer prompt and write to file
231
+ const promptDir = join(ctx.hubDir, "prompts");
232
+ mkdirSync(promptDir, { recursive: true });
233
+ const promptPath = join(promptDir, `${input.id}.md`);
234
+ const promptContent = assemblePrompt(ctx, { agentId: input.agent_id, taskId: input.task_id });
235
+ writeFileSync(promptPath, promptContent, "utf-8");
236
+
237
+ const hubEnv = {
238
+ MAESTRO_HUB_URL: `http://localhost:${ctx.port}`,
239
+ MAESTRO_AGENT_ID: input.agent_id,
240
+ MAESTRO_SESSION_ID: input.id,
241
+ MAESTRO_TASK_ID: input.task_id || "",
242
+ MAESTRO_HUB_DIR: ctx.hubDir,
243
+ MAESTRO_WORKDIR: input.cwd,
244
+ MAESTRO_PROMPT_FILE: promptPath,
245
+ };
246
+ let proc: TransportProcess;
247
+ try {
248
+ proc = await createTransportProcess(ctx, input.runtime, input.command, input.cmdArgs, { cwd: input.cwd, env: hubEnv });
249
+ } catch (err: any) {
250
+ return { error: err.message, status: 502 };
251
+ }
252
+ if (input.closeStdin) {
253
+ (proc.stdin as any)?.end?.();
254
+ (proc.stdin as any)?.close?.();
255
+ }
256
+
257
+ ctx.db.run(
258
+ "INSERT INTO session (id, agent_id, task_id, pty_pid, transcript_path, status, started_at) VALUES (?, ?, ?, ?, ?, 'running', ?)",
259
+ [input.id, input.agent_id, input.task_id, proc.pid, input.transcriptPath, ts]
260
+ );
261
+ ctx.db.run("UPDATE agent SET status = 'working', last_active_at = ? WHERE id = ?", [ts, input.agent_id]);
262
+ startTrackedProcess(ctx, input, proc);
263
+
264
+ return {
265
+ session: { id: input.id, agent_id: input.agent_id, task_id: input.task_id, pid: proc.pid, status: "running", started_at: ts },
266
+ };
267
+ }
268
+
269
+ function createTransportProcess(
270
+ ctx: HubContext,
271
+ runtime: any,
272
+ command: string,
273
+ args: string[],
274
+ opts: { cwd: string; env: Record<string, string> },
275
+ ): TransportProcess | Promise<TransportProcess> {
276
+ if (runtime.transport === "http-api") {
277
+ if (!runtime.target) throw new Error("HTTP API runtime requires target");
278
+ return new HttpApiTransport({ target: runtime.target }).spawn(command, args, opts);
279
+ }
280
+ if (runtime.transport === "ssh") {
281
+ if (!runtime.target) throw new Error("SSH runtime requires target");
282
+ return new SshTransport({ target: runtime.target }).spawn(command, args, opts);
283
+ }
284
+
285
+ const maestroBinDir = ensureMaestroBin(ctx);
286
+ const path = process.env.PATH ? `${maestroBinDir}:${process.env.PATH}` : maestroBinDir;
287
+ const proc = Bun.spawn([command, ...args], {
288
+ cwd: opts.cwd,
289
+ env: { ...process.env, PATH: path, ...opts.env },
290
+ stdin: "pipe",
291
+ stdout: "pipe",
292
+ stderr: "pipe",
293
+ });
294
+ return {
295
+ pid: proc.pid,
296
+ stdin: proc.stdin,
297
+ stdout: proc.stdout,
298
+ stderr: proc.stderr,
299
+ exited: proc.exited,
300
+ kill: () => proc.kill(),
301
+ };
302
+ }
303
+
304
+ function handleSessionEvent(
305
+ ctx: HubContext,
306
+ input: { id: string; agent_id: string; task_id: string | null },
307
+ evt: SessionEvent,
308
+ ) {
309
+ if (!input.task_id) return;
310
+ const ts = now();
311
+
312
+ switch (evt.type) {
313
+ case "progress":
314
+ ctx.bus.publish("task.progress", { task_id: input.task_id, session_id: input.id, ...evt });
315
+ break;
316
+
317
+ case "artifact": {
318
+ const content = `Artifact: ${evt.kind} ${evt.path || ""}`.trim();
319
+ ctx.db.run(
320
+ "INSERT INTO task_thread_item (id, task_id, kind, author, content, ref_id, created_at) VALUES (?, ?, 'artifact', ?, ?, ?, ?)",
321
+ [generateId("ti"), input.task_id, input.agent_id, content, input.id, ts]
322
+ );
323
+ ctx.bus.publish("task.artifact", { task_id: input.task_id, session_id: input.id, ...evt });
324
+ break;
325
+ }
326
+
327
+ case "request_transition":
328
+ ctx.bus.publish("task.request_transition", {
329
+ task_id: input.task_id,
330
+ session_id: input.id,
331
+ to_status: evt.to_status,
332
+ });
333
+ break;
334
+
335
+ case "summary":
336
+ ctx.db.run(
337
+ "INSERT INTO task_thread_item (id, task_id, kind, author, content, ref_id, created_at) VALUES (?, ?, 'agent_output', ?, ?, ?, ?)",
338
+ [generateId("ti"), input.task_id, input.agent_id, String(evt.content || "").slice(0, 2000), input.id, ts]
339
+ );
340
+ break;
341
+ }
342
+ }
343
+
344
+ function startTrackedProcess(
345
+ ctx: HubContext,
346
+ input: {
347
+ id: string;
348
+ agent_id: string;
349
+ task_id: string | null;
350
+ transcriptPath: string;
351
+ },
352
+ proc: TransportProcess,
353
+ ) {
354
+ ctx.sessions.set(input.id, proc);
355
+
356
+ // Create realtime parser to capture structured JSON-line events from stdout
357
+ const parser = new RealtimeParser();
358
+ if (input.task_id) {
359
+ parser.on("event", (evt: SessionEvent) => {
360
+ handleSessionEvent(ctx, input, evt);
361
+ });
362
+ }
363
+
364
+ streamProcessOutput(ctx, input.id, proc.stdout, input.transcriptPath, "stdout", parser);
365
+ streamProcessOutput(ctx, input.id, proc.stderr, input.transcriptPath, "stderr");
366
+
367
+ proc.exited.then((code) => {
368
+ parser.flush();
369
+ const endTs = now();
370
+ ctx.sessions.delete(input.id);
371
+
372
+ try {
373
+ ctx.db.run("UPDATE session SET status = 'ended', ended_at = ? WHERE id = ?", [endTs, input.id]);
374
+ ctx.db.run("UPDATE agent SET status = 'idle', last_active_at = ? WHERE id = ?", [endTs, input.agent_id]);
375
+
376
+ // Post-session transcript parse: extract summary and write to task thread
377
+ if (input.task_id) {
378
+ try {
379
+ const transcript = readFileSync(input.transcriptPath, "utf-8");
380
+ const parsed = parseTranscript(transcript);
381
+ if (parsed.summary) {
382
+ ctx.db.run(
383
+ "INSERT INTO task_thread_item (id, task_id, kind, author, content, ref_id, created_at) VALUES (?, ?, 'agent_output', ?, ?, ?, ?)",
384
+ [generateId("ti"), input.task_id, input.agent_id, parsed.summary, input.id, endTs]
385
+ );
386
+ ctx.bus.publish("task.agent_output", { task_id: input.task_id, session_id: input.id, summary: parsed.summary });
387
+ }
388
+ } catch { /* transcript read failure is non-fatal */ }
389
+ }
390
+
391
+ const exit = recordAgentExit(ctx.db, { agentId: input.agent_id, sessionId: input.id, code });
392
+ if (exit.restart) {
393
+ for (let i = exit.attempts; i < 3; i++) {
394
+ recordAgentExit(ctx.db, { agentId: input.agent_id, sessionId: input.id, code });
395
+ }
396
+ }
397
+ recordMissingTaskTransition(ctx, input, code, endTs);
398
+ ctx.bus.publish("session.ended", { id: input.id, agent_id: input.agent_id, code });
399
+
400
+ // Release next queued session waiting for this runtime
401
+ const agentRow = ctx.db.query("SELECT runtime_id FROM agent WHERE id = ?").get(input.agent_id) as any;
402
+ if (agentRow?.runtime_id) {
403
+ notifyNextInQueue(ctx.sessionQueue, agentRow.runtime_id);
404
+ }
405
+ } catch (err) {
406
+ if (!isExpectedShutdownError(err)) console.warn("[session] exit handling skipped:", err);
407
+ }
408
+ broadcast(ctx, input.id, { type: "exit", code });
409
+ });
410
+ }
411
+
412
+ function isExpectedShutdownError(err: unknown) {
413
+ const code = (err as any)?.code;
414
+ return code === "ENOENT" || code === "SQLITE_IOERR_VNODE" || code === "SQLITE_IOERR";
415
+ }
416
+
417
+ function recordMissingTaskTransition(
418
+ ctx: HubContext,
419
+ input: {
420
+ id: string;
421
+ agent_id: string;
422
+ task_id: string | null;
423
+ },
424
+ code: number | null,
425
+ ts: number,
426
+ ) {
427
+ if (!input.task_id) return;
428
+ const task = ctx.db.query("SELECT * FROM task WHERE id = ?").get(input.task_id) as any | null;
429
+ if (!task) return;
430
+ if (task.assignee_agent_id !== input.agent_id) return;
431
+ if (!isActiveTaskStatus(ctx.db, task)) return;
432
+ const transitioned = ctx.db.query(
433
+ "SELECT id FROM task_thread_item WHERE task_id = ? AND kind = 'workflow_transition' AND created_at >= (SELECT started_at FROM session WHERE id = ?) LIMIT 1",
434
+ ).get(input.task_id, input.id);
435
+ if (transitioned) return;
436
+
437
+ // If agent exited successfully (code 0), auto-complete the task
438
+ // Only auto-complete if the session ran for a meaningful duration (>5s)
439
+ // to avoid treating instant exits (startup failures) as "work completed"
440
+ if (code === 0) {
441
+ const session = ctx.db.query("SELECT started_at FROM session WHERE id = ?").get(input.id) as any;
442
+ const sessionDuration = session ? ts - session.started_at : 0;
443
+ if (sessionDuration > 5000) {
444
+ const workflow = getWorkflowForTask(ctx.db, input.task_id);
445
+ // Find an action that leads to the "done" status from current status
446
+ const completeAction = workflow.actions.find(
447
+ (a) => a.from.includes(task.status) && a.to === workflow.done_status
448
+ );
449
+ if (completeAction) {
450
+ // Provide default values for required fields so auto-complete doesn't fail validation
451
+ const defaults: Record<string, string> = {};
452
+ for (const field of completeAction.requires || []) {
453
+ defaults[field] = "Auto-completed: agent session exited successfully";
454
+ }
455
+ const result = transitionTask(ctx, input.task_id, completeAction.id, defaults, { actorId: input.agent_id });
456
+ if (result.ok) return; // Successfully auto-completed
457
+ }
458
+ }
459
+ }
460
+
461
+ // Fallback: record diagnostic (non-zero exit or no valid transition found)
462
+ const content = code === 0
463
+ ? `Session ${input.id} ended without a workflow transition. The agent likely finished or stopped before running maestro inbox task transition.`
464
+ : `Session ${input.id} exited with code ${code ?? "unknown"} before a workflow transition.`;
465
+ ctx.db.run(
466
+ "INSERT INTO task_thread_item (id, task_id, kind, author, content, ref_id, created_at) VALUES (?, ?, 'session_diagnostic', 'system', ?, ?, ?)",
467
+ [generateId("ti"), input.task_id, content, input.id, ts],
468
+ );
469
+ ctx.bus.publish("task.session_diagnostic", { task_id: input.task_id, session_id: input.id, agent_id: input.agent_id, code });
470
+ }
471
+
472
+ function broadcast(ctx: HubContext, sessionId: string, payload: unknown) {
473
+ const clients = ctx.wsClients.get(sessionId);
474
+ if (!clients) return;
475
+ const msg = JSON.stringify(payload);
476
+ for (const ws of clients) {
477
+ ws.send(msg);
478
+ }
479
+ }