@prestyj/cli 4.12.1 → 4.14.0

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 (148) hide show
  1. package/dist/app-sidecar.js +258 -45
  2. package/dist/app-sidecar.js.map +1 -1
  3. package/dist/cli/auth.d.ts.map +1 -1
  4. package/dist/cli/auth.js +5 -2
  5. package/dist/cli/auth.js.map +1 -1
  6. package/dist/cli/shared.d.ts.map +1 -1
  7. package/dist/cli/shared.js +2 -0
  8. package/dist/cli/shared.js.map +1 -1
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +6 -1
  11. package/dist/cli.js.map +1 -1
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +1 -0
  14. package/dist/config.js.map +1 -1
  15. package/dist/core/auth-providers.d.ts.map +1 -1
  16. package/dist/core/auth-providers.js +7 -0
  17. package/dist/core/auth-providers.js.map +1 -1
  18. package/dist/core/encode-cwd.d.ts +25 -0
  19. package/dist/core/encode-cwd.d.ts.map +1 -0
  20. package/dist/core/encode-cwd.js +40 -0
  21. package/dist/core/encode-cwd.js.map +1 -0
  22. package/dist/core/encode-cwd.test.d.ts +2 -0
  23. package/dist/core/encode-cwd.test.d.ts.map +1 -0
  24. package/dist/core/encode-cwd.test.js +61 -0
  25. package/dist/core/encode-cwd.test.js.map +1 -0
  26. package/dist/core/mcp/client.d.ts +11 -0
  27. package/dist/core/mcp/client.d.ts.map +1 -1
  28. package/dist/core/mcp/client.js +87 -35
  29. package/dist/core/mcp/client.js.map +1 -1
  30. package/dist/core/mcp/defaults.d.ts +1 -1
  31. package/dist/core/mcp/defaults.d.ts.map +1 -1
  32. package/dist/core/mcp/defaults.js +9 -40
  33. package/dist/core/mcp/defaults.js.map +1 -1
  34. package/dist/core/mcp/loopback.d.ts +27 -0
  35. package/dist/core/mcp/loopback.d.ts.map +1 -0
  36. package/dist/core/mcp/loopback.js +66 -0
  37. package/dist/core/mcp/loopback.js.map +1 -0
  38. package/dist/core/mcp/loopback.test.d.ts +2 -0
  39. package/dist/core/mcp/loopback.test.d.ts.map +1 -0
  40. package/dist/core/mcp/loopback.test.js +87 -0
  41. package/dist/core/mcp/loopback.test.js.map +1 -0
  42. package/dist/core/mcp/parse-add-command.d.ts.map +1 -1
  43. package/dist/core/mcp/parse-add-command.js +1 -0
  44. package/dist/core/mcp/parse-add-command.js.map +1 -1
  45. package/dist/core/mcp/parse-add-command.test.js +8 -2
  46. package/dist/core/mcp/parse-add-command.test.js.map +1 -1
  47. package/dist/core/mcp/resolve-stdio.d.ts +27 -0
  48. package/dist/core/mcp/resolve-stdio.d.ts.map +1 -0
  49. package/dist/core/mcp/resolve-stdio.js +149 -0
  50. package/dist/core/mcp/resolve-stdio.js.map +1 -0
  51. package/dist/core/mcp/resolve-stdio.test.d.ts +2 -0
  52. package/dist/core/mcp/resolve-stdio.test.d.ts.map +1 -0
  53. package/dist/core/mcp/resolve-stdio.test.js +63 -0
  54. package/dist/core/mcp/resolve-stdio.test.js.map +1 -0
  55. package/dist/core/mcp/store.d.ts +4 -4
  56. package/dist/core/mcp/store.d.ts.map +1 -1
  57. package/dist/core/mcp/store.js +7 -1
  58. package/dist/core/mcp/store.js.map +1 -1
  59. package/dist/core/mcp/store.test.js +11 -2
  60. package/dist/core/mcp/store.test.js.map +1 -1
  61. package/dist/core/mcp/types.d.ts +5 -1
  62. package/dist/core/mcp/types.d.ts.map +1 -1
  63. package/dist/core/project-discovery.d.ts.map +1 -1
  64. package/dist/core/project-discovery.js +2 -9
  65. package/dist/core/project-discovery.js.map +1 -1
  66. package/dist/core/radio.d.ts +7 -2
  67. package/dist/core/radio.d.ts.map +1 -1
  68. package/dist/core/radio.js.map +1 -1
  69. package/dist/core/resolve-start.test.js +1 -0
  70. package/dist/core/resolve-start.test.js.map +1 -1
  71. package/dist/core/session-manager.d.ts.map +1 -1
  72. package/dist/core/session-manager.js +1 -3
  73. package/dist/core/session-manager.js.map +1 -1
  74. package/dist/core/settings-manager.d.ts +1 -0
  75. package/dist/core/settings-manager.d.ts.map +1 -1
  76. package/dist/core/settings-manager.js +1 -0
  77. package/dist/core/settings-manager.js.map +1 -1
  78. package/dist/session.d.ts.map +1 -1
  79. package/dist/session.js +1 -3
  80. package/dist/session.js.map +1 -1
  81. package/dist/system-prompt.d.ts.map +1 -1
  82. package/dist/system-prompt.js +9 -4
  83. package/dist/system-prompt.js.map +1 -1
  84. package/dist/system-prompt.test.js +6 -3
  85. package/dist/system-prompt.test.js.map +1 -1
  86. package/dist/tools/bash.d.ts.map +1 -1
  87. package/dist/tools/bash.js +10 -2
  88. package/dist/tools/bash.js.map +1 -1
  89. package/dist/tools/code-skeleton.d.ts +29 -0
  90. package/dist/tools/code-skeleton.d.ts.map +1 -0
  91. package/dist/tools/code-skeleton.js +115 -0
  92. package/dist/tools/code-skeleton.js.map +1 -0
  93. package/dist/tools/code-skeleton.test.d.ts +2 -0
  94. package/dist/tools/code-skeleton.test.d.ts.map +1 -0
  95. package/dist/tools/code-skeleton.test.js +115 -0
  96. package/dist/tools/code-skeleton.test.js.map +1 -0
  97. package/dist/tools/compress-integration.test.d.ts +2 -0
  98. package/dist/tools/compress-integration.test.d.ts.map +1 -0
  99. package/dist/tools/compress-integration.test.js +59 -0
  100. package/dist/tools/compress-integration.test.js.map +1 -0
  101. package/dist/tools/compress.d.ts +57 -0
  102. package/dist/tools/compress.d.ts.map +1 -0
  103. package/dist/tools/compress.js +181 -0
  104. package/dist/tools/compress.js.map +1 -0
  105. package/dist/tools/compress.test.d.ts +2 -0
  106. package/dist/tools/compress.test.d.ts.map +1 -0
  107. package/dist/tools/compress.test.js +60 -0
  108. package/dist/tools/compress.test.js.map +1 -0
  109. package/dist/tools/edit-diff.d.ts.map +1 -1
  110. package/dist/tools/edit-diff.js +25 -8
  111. package/dist/tools/edit-diff.js.map +1 -1
  112. package/dist/tools/ls.d.ts.map +1 -1
  113. package/dist/tools/ls.js +7 -4
  114. package/dist/tools/ls.js.map +1 -1
  115. package/dist/tools/task-output.d.ts.map +1 -1
  116. package/dist/tools/task-output.js +13 -3
  117. package/dist/tools/task-output.js.map +1 -1
  118. package/dist/tools/tasks.d.ts +6 -0
  119. package/dist/tools/tasks.d.ts.map +1 -1
  120. package/dist/tools/tasks.js +41 -4
  121. package/dist/tools/tasks.js.map +1 -1
  122. package/dist/tools/tasks.test.d.ts +2 -0
  123. package/dist/tools/tasks.test.d.ts.map +1 -0
  124. package/dist/tools/tasks.test.js +62 -0
  125. package/dist/tools/tasks.test.js.map +1 -0
  126. package/dist/ui/App.d.ts.map +1 -1
  127. package/dist/ui/App.js.map +1 -1
  128. package/dist/ui/components/ChatScreen.d.ts +5 -5
  129. package/dist/ui/components/ChatScreen.d.ts.map +1 -1
  130. package/dist/ui/components/InputArea.d.ts +5 -5
  131. package/dist/ui/components/InputArea.d.ts.map +1 -1
  132. package/dist/ui/components/ModelSelector.d.ts.map +1 -1
  133. package/dist/ui/components/ModelSelector.js +1 -0
  134. package/dist/ui/components/ModelSelector.js.map +1 -1
  135. package/dist/ui/components/TaskPickerMenu.d.ts +2 -2
  136. package/dist/ui/components/TaskPickerMenu.d.ts.map +1 -1
  137. package/dist/ui/components/TaskPickerMenu.js.map +1 -1
  138. package/dist/ui/hooks/useAgentLoop.d.ts.map +1 -1
  139. package/dist/ui/hooks/useAgentLoop.js +13 -4
  140. package/dist/ui/hooks/useAgentLoop.js.map +1 -1
  141. package/dist/ui/login.d.ts.map +1 -1
  142. package/dist/ui/login.js +1 -0
  143. package/dist/ui/login.js.map +1 -1
  144. package/package.json +6 -5
  145. package/dist/core/tasks-store.d.ts +0 -24
  146. package/dist/core/tasks-store.d.ts.map +0 -1
  147. package/dist/core/tasks-store.js +0 -81
  148. package/dist/core/tasks-store.js.map +0 -1
@@ -15,6 +15,7 @@ import http from "node:http";
15
15
  import fs from "node:fs/promises";
16
16
  import os from "node:os";
17
17
  import path from "node:path";
18
+ import { randomUUID } from "node:crypto";
18
19
  import { parseArgs } from "node:util";
19
20
  import { runJsonMode } from "./modes/json-mode.js";
20
21
  import { AgentSession } from "./core/agent-session.js";
@@ -34,7 +35,7 @@ import { getNextThinkingLevel, getSupportedThinkingLevels, isThinkingLevelSuppor
34
35
  import { PROMPT_COMMANDS } from "./core/prompt-commands.js";
35
36
  import { loadCustomCommands } from "./core/custom-commands.js";
36
37
  import { discoverProjects, listRecentSessions } from "./core/project-discovery.js";
37
- import { loadTasksSync, saveTasksSync, getNextPendingTask, markTaskInProgress, } from "./core/tasks-store.js";
38
+ import { loadTasksSync, saveTasksSync, getNextPendingTask, markTaskInProgress, } from "./core/task-store.js";
38
39
  import { initLogger, log } from "./core/logger.js";
39
40
  import { RADIO_STATIONS, getCurrentStation, playRadio, stopRadio } from "./core/radio.js";
40
41
  import { enrichProcessPath } from "./core/shell-path.js";
@@ -52,6 +53,7 @@ const ALL_PROVIDERS = [
52
53
  "minimax",
53
54
  "deepseek",
54
55
  "openrouter",
56
+ "sakana",
55
57
  ];
56
58
  function appSettingsFile() {
57
59
  return path.join(os.homedir(), ".ezcoder", "ezcoder-app.json");
@@ -362,11 +364,28 @@ async function runJsonModeIfRequested() {
362
364
  });
363
365
  return true;
364
366
  }
367
+ // ── Daemon-level HTTP helpers (shared by the session-management routes) ─────
368
+ // The per-session route table has its own local copies; these serve the
369
+ // daemon's own POST /session / DELETE /session routes.
370
+ function daemonReadBody(req) {
371
+ return new Promise((resolve, reject) => {
372
+ const chunks = [];
373
+ req.on("data", (c) => chunks.push(c));
374
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
375
+ req.on("error", reject);
376
+ });
377
+ }
378
+ function daemonJson(res, status, body) {
379
+ res.writeHead(status, {
380
+ "content-type": "application/json",
381
+ "access-control-allow-origin": "*",
382
+ });
383
+ res.end(JSON.stringify(body));
384
+ }
365
385
  async function main() {
366
386
  // Sub-agent JSON-mode dispatch must win before any sidecar/server setup.
367
387
  if (await runJsonModeIfRequested())
368
388
  return;
369
- const cwd = process.env.GG_APP_CWD ?? process.cwd();
370
389
  // Default to an ephemeral port (0) so concurrent/orphaned instances never
371
390
  // collide on a fixed port. The actual port is reported via the
372
391
  // GG_APP_LISTENING handshake and consumed by the shell.
@@ -404,6 +423,119 @@ async function main() {
404
423
  await enrichProcessPath();
405
424
  const auth = new AuthStorage(paths.authFile);
406
425
  await auth.load();
426
+ // Every window's session lives here as an in-process object, keyed by the id
427
+ // the daemon hands back from POST /session. The Rust shell routes each proxy
428
+ // request to its window's session via the `x-gg-session` header (and the
429
+ // `?session=` query for the SSE /events stream).
430
+ const sessions = new Map();
431
+ /** Resolve the target session id: the `x-gg-session` header, else a
432
+ * `?session=` query param (used by the SSE /events connection). */
433
+ function sessionIdFromReq(req, url) {
434
+ const header = req.headers["x-gg-session"];
435
+ if (typeof header === "string" && header.length > 0)
436
+ return header;
437
+ try {
438
+ return new URL(url, `http://${host}`).searchParams.get("session");
439
+ }
440
+ catch {
441
+ return null;
442
+ }
443
+ }
444
+ const server = http.createServer((req, res) => {
445
+ const url = req.url ?? "/";
446
+ const method = req.method ?? "GET";
447
+ // CORS preflight — the webview origin differs from 127.0.0.1.
448
+ if (method === "OPTIONS") {
449
+ res.writeHead(204, {
450
+ "access-control-allow-origin": "*",
451
+ "access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
452
+ "access-control-allow-headers": "content-type, x-gg-session",
453
+ });
454
+ res.end();
455
+ return;
456
+ }
457
+ // ── Daemon-level routes (session lifecycle) ──────────────────────────
458
+ // Create a session for a window: { cwd, sessionPath? } → { sessionId }.
459
+ if (method === "POST" && url === "/session") {
460
+ void daemonReadBody(req).then(async (raw) => {
461
+ let body = {};
462
+ try {
463
+ body = raw ? JSON.parse(raw) : {};
464
+ }
465
+ catch {
466
+ /* empty/invalid body → defaults below */
467
+ }
468
+ const sessionCwd = typeof body.cwd === "string" && body.cwd
469
+ ? body.cwd
470
+ : (process.env.GG_APP_CWD ?? process.cwd());
471
+ const sessionPath = typeof body.sessionPath === "string" && body.sessionPath ? body.sessionPath : undefined;
472
+ const id = randomUUID();
473
+ try {
474
+ const ctx = await createSession({ auth, paths }, { id, cwd: sessionCwd, sessionPath });
475
+ sessions.set(id, ctx);
476
+ log("INFO", "app-sidecar", "session created", { id, cwd: sessionCwd });
477
+ daemonJson(res, 200, { sessionId: id });
478
+ }
479
+ catch (err) {
480
+ const message = err instanceof Error ? err.message : String(err);
481
+ log("ERROR", "app-sidecar", "session create failed", { message });
482
+ daemonJson(res, 500, { error: message });
483
+ }
484
+ });
485
+ return;
486
+ }
487
+ // Dispose a session: DELETE /session/:id.
488
+ if (method === "DELETE" && url.startsWith("/session/")) {
489
+ const id = decodeURIComponent(url.slice("/session/".length));
490
+ const ctx = sessions.get(id);
491
+ if (ctx) {
492
+ sessions.delete(id);
493
+ void ctx.dispose().catch(() => { });
494
+ log("INFO", "app-sidecar", "session disposed", { id });
495
+ }
496
+ daemonJson(res, 200, { ok: true });
497
+ return;
498
+ }
499
+ // ── Per-session delegation ───────────────────────────────────────────
500
+ const id = sessionIdFromReq(req, url);
501
+ const ctx = id ? sessions.get(id) : undefined;
502
+ if (!ctx) {
503
+ daemonJson(res, 404, { error: "unknown session" });
504
+ return;
505
+ }
506
+ ctx.handle(req, res, url, method);
507
+ });
508
+ server.listen(port, host, () => {
509
+ const addr = server.address();
510
+ // The Rust shell reads this line to learn the daemon port.
511
+ process.stdout.write(`GG_APP_LISTENING ${addr.port}\n`);
512
+ log("INFO", "app-sidecar", "daemon listening", { port: String(addr.port), host });
513
+ });
514
+ const shutdown = async () => {
515
+ // Radio playback is app-wide (one stream across all windows), so it stops
516
+ // at the daemon level, not per session.
517
+ stopRadio();
518
+ await Promise.all([...sessions.values()].map((c) => c.dispose().catch(() => { })));
519
+ server.close();
520
+ process.exit(0);
521
+ };
522
+ process.on("SIGINT", () => void shutdown());
523
+ process.on("SIGTERM", () => void shutdown());
524
+ }
525
+ /**
526
+ * Build one in-process agent session: its AgentSession, SSE client set, event
527
+ * bridge, task runner, auth/login bridge, and the full HTTP route table exposed
528
+ * as a `handle()` method. Many of these live inside one daemon process, fully
529
+ * isolated (separate AgentSession, cwd, history, model) — only the HTTP server,
530
+ * logger, PATH, shared auth file, and radio live at the daemon level.
531
+ */
532
+ async function createSession(deps, opts) {
533
+ const { auth } = deps;
534
+ const paths = deps.paths;
535
+ const cwd = opts.cwd;
536
+ // Base host for parsing request-URL query params (value is irrelevant to
537
+ // parsing); the daemon owns the real listen host.
538
+ const host = "127.0.0.1";
407
539
  const saved = loadSavedSettings(paths.settingsFile);
408
540
  // Per-project model/thinking prefs win over the shared global settings.json:
409
541
  // each window (one project cwd) restores its own selection instead of every
@@ -435,9 +567,9 @@ async function main() {
435
567
  for (const c of clients)
436
568
  c.res.write(frame);
437
569
  }
438
- // When the shell respawns this sidecar for a chosen project, it passes the
439
- // session file path to resume; empty/unset starts a fresh session.
440
- const resumeSessionPath = process.env.GG_APP_SESSION_ID || undefined;
570
+ // The session file path to resume (passed by the daemon's POST /session);
571
+ // empty/unset starts a fresh session.
572
+ const resumeSessionPath = opts.sessionPath;
441
573
  let abort = new AbortController();
442
574
  const session = new AgentSession({
443
575
  provider,
@@ -495,26 +627,33 @@ async function main() {
495
627
  tasks: session.listBackgroundProcesses(),
496
628
  };
497
629
  }
630
+ // tool_call_end carries no tool name (only the id), so remember each call's
631
+ // name from tool_call_start to log a useful line on completion. Mirrors the
632
+ // CLI's logging so the app sidecar's ~/.ezcoder/ezcoder-app-sidecar.log records tool
633
+ // failures (e.g. repeated invalid-argument errors) instead of leaving the
634
+ // fatal-abort path with no forensic trail.
635
+ const toolCallNames = new Map();
498
636
  // Forward every relevant bus event to the webview.
499
637
  session.eventBus.on("text_delta", (d) => broadcast("text_delta", d));
500
638
  session.eventBus.on("thinking_delta", (d) => broadcast("thinking_delta", d));
501
- // tool_call_end omits the tool name, so remember it from tool_call_start (keyed
502
- // by toolCallId) to detect when the `tasks` tool just mutated the task store.
503
- const toolNamesById = new Map();
504
639
  session.eventBus.on("tool_call_start", (d) => {
505
- if (d?.toolCallId && typeof d.name === "string")
506
- toolNamesById.set(d.toolCallId, d.name);
640
+ toolCallNames.set(d.toolCallId, d.name);
507
641
  broadcast("tool_call_start", d);
508
642
  });
509
643
  session.eventBus.on("tool_call_update", (d) => broadcast("tool_call_update", d));
510
644
  session.eventBus.on("tool_call_end", (d) => {
645
+ const name = toolCallNames.get(d.toolCallId) ?? "unknown";
646
+ toolCallNames.delete(d.toolCallId);
647
+ log(d.isError ? "ERROR" : "INFO", "tool", `Tool call ended: ${name}`, {
648
+ id: d.toolCallId,
649
+ durationMs: String(d.durationMs),
650
+ isError: String(d.isError),
651
+ ...(d.isError ? { result: d.result.slice(0, 500) } : {}),
652
+ });
511
653
  broadcast("tool_call_end", d);
512
654
  // The agent can add/done/remove project tasks via the `tasks` tool during a
513
655
  // normal chat run. Push the refreshed list so the webview's Tasks modal and
514
656
  // footer badge update live instead of going stale until the modal reopens.
515
- const name = d?.toolCallId ? toolNamesById.get(d.toolCallId) : undefined;
516
- if (d?.toolCallId)
517
- toolNamesById.delete(d.toolCallId);
518
657
  if (name === "tasks") {
519
658
  broadcast("tasks_list", { tasks: loadTasksSync(cwd) });
520
659
  }
@@ -526,7 +665,11 @@ async function main() {
526
665
  session.eventBus.on("server_tool_call", (d) => broadcast("server_tool_call", d));
527
666
  session.eventBus.on("turn_end", (d) => broadcast("turn_end", d));
528
667
  session.eventBus.on("agent_done", (d) => broadcast("agent_done", d));
529
- session.eventBus.on("error", (d) => broadcast("error", { message: d.error instanceof Error ? d.error.message : String(d.error) }));
668
+ session.eventBus.on("error", (d) => {
669
+ const message = d.error instanceof Error ? d.error.message : String(d.error);
670
+ log("ERROR", "app-sidecar", "agent error", { message });
671
+ broadcast("error", { message });
672
+ });
530
673
  session.eventBus.on("model_change", (d) => broadcast("model_change", d));
531
674
  session.eventBus.on("hook", (d) => broadcast("hook", d));
532
675
  session.eventBus.on("compaction_start", (d) => broadcast("compaction_start", d));
@@ -694,19 +837,8 @@ async function main() {
694
837
  });
695
838
  res.end(payload);
696
839
  }
697
- const server = http.createServer((req, res) => {
698
- const url = req.url ?? "/";
699
- const method = req.method ?? "GET";
700
- // CORS preflight — the webview origin differs from 127.0.0.1.
701
- if (method === "OPTIONS") {
702
- res.writeHead(204, {
703
- "access-control-allow-origin": "*",
704
- "access-control-allow-methods": "GET, POST, OPTIONS",
705
- "access-control-allow-headers": "content-type",
706
- });
707
- res.end();
708
- return;
709
- }
840
+ // OPTIONS/CORS preflight is handled at the daemon level before delegation.
841
+ function handle(req, res, url, method) {
710
842
  if (method === "GET" && url === "/state") {
711
843
  const st = session.getState();
712
844
  json(res, 200, {
@@ -720,7 +852,7 @@ async function main() {
720
852
  });
721
853
  return;
722
854
  }
723
- if (method === "GET" && url === "/events") {
855
+ if (method === "GET" && (url === "/events" || url.startsWith("/events?"))) {
724
856
  res.writeHead(200, {
725
857
  "content-type": "text/event-stream",
726
858
  "cache-control": "no-cache",
@@ -1064,9 +1196,14 @@ async function main() {
1064
1196
  json(res, 200, { tasks: loadTasksSync(cwd) });
1065
1197
  return;
1066
1198
  }
1067
- // ── Radio ─────────────────────────────────────────────────
1068
- // Playback runs in THIS sidecar process, which is unique per window, so a
1069
- // station only plays in the window that started it.
1199
+ // ── Radio (app-wide) ──────────────────────────────────────
1200
+ // Radio is now APP-WIDE: all windows share one daemon process, and the
1201
+ // player lives in `core/radio.ts` module-level singletons (one stream for
1202
+ // the whole app). Any window's /radio reads/controls that single stream —
1203
+ // starting a station in one window replaces whatever was playing, and every
1204
+ // window's footer reflects the same `current`. This intentionally prevents
1205
+ // duplicate audio across windows (the original per-window goal), now for
1206
+ // free. (To restore per-window radio, key playback by sessionId.)
1070
1207
  if (method === "GET" && url === "/radio") {
1071
1208
  json(res, 200, { stations: RADIO_STATIONS, current: getCurrentStation() });
1072
1209
  return;
@@ -1117,6 +1254,83 @@ async function main() {
1117
1254
  });
1118
1255
  return;
1119
1256
  }
1257
+ if (method === "POST" && url === "/tasks/add") {
1258
+ void readBody(req).then((raw) => {
1259
+ let title;
1260
+ let prompt;
1261
+ try {
1262
+ const body = JSON.parse(raw);
1263
+ title = (body.title ?? "").trim();
1264
+ prompt = (body.prompt ?? "").trim();
1265
+ }
1266
+ catch {
1267
+ json(res, 400, { error: "invalid JSON body" });
1268
+ return;
1269
+ }
1270
+ if (!title) {
1271
+ json(res, 400, { error: "missing task title" });
1272
+ return;
1273
+ }
1274
+ const tasks = loadTasksSync(cwd);
1275
+ const newTask = {
1276
+ id: randomUUID(),
1277
+ title,
1278
+ prompt: prompt || title,
1279
+ status: "pending",
1280
+ createdAt: new Date().toISOString(),
1281
+ };
1282
+ const next = [...tasks, newTask];
1283
+ saveTasksSync(cwd, next);
1284
+ broadcast("tasks_list", { tasks: next });
1285
+ json(res, 200, { tasks: next });
1286
+ });
1287
+ return;
1288
+ }
1289
+ if (method === "POST" && url === "/tasks/update") {
1290
+ void readBody(req).then((raw) => {
1291
+ let id;
1292
+ let status;
1293
+ let title;
1294
+ let prompt;
1295
+ try {
1296
+ const body = JSON.parse(raw);
1297
+ id = (body.id ?? "").trim();
1298
+ status = body.status;
1299
+ title = body.title;
1300
+ prompt = body.prompt;
1301
+ }
1302
+ catch {
1303
+ json(res, 400, { error: "invalid JSON body" });
1304
+ return;
1305
+ }
1306
+ if (!id) {
1307
+ json(res, 400, { error: "missing task id" });
1308
+ return;
1309
+ }
1310
+ if (status && status !== "pending" && status !== "in-progress" && status !== "done") {
1311
+ json(res, 400, { error: `invalid status: ${status}` });
1312
+ return;
1313
+ }
1314
+ const tasks = loadTasksSync(cwd);
1315
+ const task = tasks.find((t) => t.id === id || t.id.startsWith(id));
1316
+ if (!task) {
1317
+ json(res, 404, { error: `no task found matching id "${id}"` });
1318
+ return;
1319
+ }
1320
+ const next = tasks.map((t) => t.id === task.id
1321
+ ? {
1322
+ ...t,
1323
+ ...(status ? { status: status } : {}),
1324
+ ...(typeof title === "string" && title.trim() ? { title: title.trim() } : {}),
1325
+ ...(typeof prompt === "string" && prompt.trim() ? { prompt: prompt.trim() } : {}),
1326
+ }
1327
+ : t);
1328
+ saveTasksSync(cwd, next);
1329
+ broadcast("tasks_list", { tasks: next });
1330
+ json(res, 200, { tasks: next });
1331
+ });
1332
+ return;
1333
+ }
1120
1334
  if (method === "POST" && url === "/tasks/delete") {
1121
1335
  void readBody(req).then((raw) => {
1122
1336
  let id;
@@ -1133,6 +1347,7 @@ async function main() {
1133
1347
  }
1134
1348
  const remaining = loadTasksSync(cwd).filter((t) => t.id !== id && !t.id.startsWith(id));
1135
1349
  saveTasksSync(cwd, remaining);
1350
+ broadcast("tasks_list", { tasks: remaining });
1136
1351
  json(res, 200, { tasks: remaining });
1137
1352
  });
1138
1353
  return;
@@ -1705,30 +1920,28 @@ async function main() {
1705
1920
  return;
1706
1921
  }
1707
1922
  json(res, 404, { error: "not found" });
1708
- });
1709
- server.listen(port, host, () => {
1710
- const addr = server.address();
1711
- // The Rust shell reads this line to learn the port.
1712
- process.stdout.write(`GG_APP_LISTENING ${addr.port}\n`);
1713
- log("INFO", "app-sidecar", "listening", { port: String(addr.port), host });
1714
- });
1715
- const shutdown = async () => {
1923
+ }
1924
+ async function dispose() {
1716
1925
  tasksPollStopped = true;
1717
1926
  if (tasksPoll)
1718
1927
  clearTimeout(tasksPoll);
1719
- // Kill any playing radio so the stream dies with its window.
1720
- stopRadio();
1721
1928
  // Stop the Telegram serve loop + dispose its per-chat sessions.
1722
1929
  if (serveController)
1723
1930
  await serveController.stop().catch(() => { });
1724
1931
  for (const c of clients)
1725
1932
  c.res.end();
1726
- server.close();
1727
1933
  await session.dispose().catch(() => { });
1728
- process.exit(0);
1934
+ }
1935
+ return {
1936
+ id: opts.id,
1937
+ cwd,
1938
+ sessionPath: opts.sessionPath,
1939
+ session,
1940
+ clients,
1941
+ broadcast,
1942
+ handle,
1943
+ dispose,
1729
1944
  };
1730
- process.on("SIGINT", () => void shutdown());
1731
- process.on("SIGTERM", () => void shutdown());
1732
1945
  }
1733
1946
  main().catch((err) => {
1734
1947
  const message = err instanceof Error ? err.message : String(err);