@prestyj/cli 5.30.2 → 5.30.3

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 (116) hide show
  1. package/dist/app-sidecar/app-settings.d.ts +6 -2
  2. package/dist/app-sidecar/app-settings.js +39 -20
  3. package/dist/app-sidecar/app-settings.js.map +1 -1
  4. package/dist/app-sidecar/git-target.d.ts +17 -0
  5. package/dist/app-sidecar/git-target.js +31 -0
  6. package/dist/app-sidecar/git-target.js.map +1 -0
  7. package/dist/app-sidecar/goal-routes.d.ts +26 -0
  8. package/dist/app-sidecar/goal-routes.js +445 -0
  9. package/dist/app-sidecar/goal-routes.js.map +1 -0
  10. package/dist/app-sidecar/history-routes.d.ts +13 -0
  11. package/dist/app-sidecar/history-routes.js +36 -25
  12. package/dist/app-sidecar/history-routes.js.map +1 -1
  13. package/dist/app-sidecar/mcp-routes.js +1 -2
  14. package/dist/app-sidecar/mcp-routes.js.map +1 -1
  15. package/dist/app-sidecar/route-context.d.ts +12 -0
  16. package/dist/app-sidecar/run-control-routes.js +2 -1
  17. package/dist/app-sidecar/run-control-routes.js.map +1 -1
  18. package/dist/app-sidecar/session-routes.js +41 -0
  19. package/dist/app-sidecar/session-routes.js.map +1 -1
  20. package/dist/app-sidecar/session-types.d.ts +2 -0
  21. package/dist/app-sidecar/workspace-routes.js +13 -95
  22. package/dist/app-sidecar/workspace-routes.js.map +1 -1
  23. package/dist/app-sidecar.js +213 -60
  24. package/dist/app-sidecar.js.map +1 -1
  25. package/dist/cli/shared.js +1 -1
  26. package/dist/cli/shared.js.map +1 -1
  27. package/dist/core/acceptance-checks.js +17 -4
  28. package/dist/core/acceptance-checks.js.map +1 -1
  29. package/dist/core/agent-notifications.d.ts +1 -1
  30. package/dist/core/agent-session.d.ts +83 -5
  31. package/dist/core/agent-session.js +174 -59
  32. package/dist/core/agent-session.js.map +1 -1
  33. package/dist/core/ask-user.d.ts +11 -0
  34. package/dist/core/ask-user.js +6 -1
  35. package/dist/core/ask-user.js.map +1 -1
  36. package/dist/core/auth-providers.d.ts +2 -80
  37. package/dist/core/auth-providers.js +4 -183
  38. package/dist/core/auth-providers.js.map +1 -1
  39. package/dist/core/cache-diagnostics.d.ts +1 -1
  40. package/dist/core/cache-diagnostics.js +2 -1
  41. package/dist/core/cache-diagnostics.js.map +1 -1
  42. package/dist/core/compaction/tool-result-pruner.d.ts +10 -0
  43. package/dist/core/compaction/tool-result-pruner.js +12 -0
  44. package/dist/core/compaction/tool-result-pruner.js.map +1 -1
  45. package/dist/core/context-files.d.ts +8 -0
  46. package/dist/core/context-files.js +46 -0
  47. package/dist/core/context-files.js.map +1 -0
  48. package/dist/core/env-delta.d.ts +9 -1
  49. package/dist/core/env-delta.js +20 -0
  50. package/dist/core/env-delta.js.map +1 -1
  51. package/dist/core/goals-store.d.ts +208 -0
  52. package/dist/core/goals-store.js +521 -0
  53. package/dist/core/goals-store.js.map +1 -0
  54. package/dist/core/local-endpoint-store.js +15 -11
  55. package/dist/core/local-endpoint-store.js.map +1 -1
  56. package/dist/core/project-discovery.d.ts +7 -0
  57. package/dist/core/project-discovery.js +9 -3
  58. package/dist/core/project-discovery.js.map +1 -1
  59. package/dist/core/radio.d.ts +2 -3
  60. package/dist/core/radio.js.map +1 -1
  61. package/dist/core/session-manager.d.ts +7 -1
  62. package/dist/core/session-manager.js +17 -7
  63. package/dist/core/session-manager.js.map +1 -1
  64. package/dist/core/session-storage.d.ts +3 -0
  65. package/dist/core/session-storage.js +8 -2
  66. package/dist/core/session-storage.js.map +1 -1
  67. package/dist/core/steroids.d.ts +8 -0
  68. package/dist/core/steroids.js +19 -3
  69. package/dist/core/steroids.js.map +1 -1
  70. package/dist/core/subagent-manager.d.ts +51 -0
  71. package/dist/core/subagent-manager.js +103 -16
  72. package/dist/core/subagent-manager.js.map +1 -1
  73. package/dist/core/subagent-store.js +2 -1
  74. package/dist/core/subagent-store.js.map +1 -1
  75. package/dist/goals-agent/goal-team.d.ts +2 -0
  76. package/dist/goals-agent/goal-team.js +83 -0
  77. package/dist/goals-agent/goal-team.js.map +1 -0
  78. package/dist/goals-agent/goals-agent.d.ts +90 -0
  79. package/dist/goals-agent/goals-agent.js +246 -0
  80. package/dist/goals-agent/goals-agent.js.map +1 -0
  81. package/dist/index.js +1 -1
  82. package/dist/index.js.map +1 -1
  83. package/dist/interactive.js +4 -3
  84. package/dist/interactive.js.map +1 -1
  85. package/dist/modes/subagent-worker-mode.d.ts +14 -0
  86. package/dist/modes/subagent-worker-mode.js +32 -1
  87. package/dist/modes/subagent-worker-mode.js.map +1 -1
  88. package/dist/system-prompt.d.ts +8 -0
  89. package/dist/system-prompt.js +21 -9
  90. package/dist/system-prompt.js.map +1 -1
  91. package/dist/tools/bash-read-evidence.d.ts +13 -0
  92. package/dist/tools/bash-read-evidence.js +48 -1
  93. package/dist/tools/bash-read-evidence.js.map +1 -1
  94. package/dist/tools/index.d.ts +7 -1
  95. package/dist/tools/index.js +10 -2
  96. package/dist/tools/index.js.map +1 -1
  97. package/dist/tools/read-tracker.d.ts +6 -12
  98. package/dist/tools/read-tracker.js +21 -36
  99. package/dist/tools/read-tracker.js.map +1 -1
  100. package/dist/tools/read.js +2 -2
  101. package/dist/tools/read.js.map +1 -1
  102. package/dist/tools/subagent-control.js +22 -3
  103. package/dist/tools/subagent-control.js.map +1 -1
  104. package/dist/tools/write.js +110 -8
  105. package/dist/tools/write.js.map +1 -1
  106. package/dist/ui/components/ActivityIndicator.d.ts +5 -5
  107. package/dist/ui/components/InputArea.d.ts +2 -2
  108. package/dist/ui/components/InputArea.js +3 -3
  109. package/dist/ui/components/InputArea.js.map +1 -1
  110. package/dist/ui/components/ToolExecution.d.ts +2 -2
  111. package/dist/ui/hooks/useContextCompaction.js +38 -31
  112. package/dist/ui/hooks/useContextCompaction.js.map +1 -1
  113. package/dist/utils/format.d.ts +0 -4
  114. package/dist/utils/format.js +0 -7
  115. package/dist/utils/format.js.map +1 -1
  116. package/package.json +4 -4
@@ -42,7 +42,6 @@ import { SettingsManager } from "./core/settings-manager.js";
42
42
  import { getModel, getDefaultThinkingLevel, getContextWindow, clearRuntimeModels, registerRuntimeModels, } from "./core/model-registry.js";
43
43
  import { resolveStartOrFallback } from "./core/resolve-start.js";
44
44
  import { getGitBranch, getGitDirtyFileCount, isGitRepo } from "./utils/git.js";
45
- import { getGitHubRepoSlug } from "./utils/github.js";
46
45
  import { scoreProjectHealth } from "./core/project-health-score.js";
47
46
  import { createRepoPolls } from "./app-sidecar/repo-polls.js";
48
47
  import { extractPlanSteps } from "./utils/plan-steps.js";
@@ -55,7 +54,7 @@ import { installTerminationHandlers } from "./core/shutdown.js";
55
54
  import { stopRadio } from "./core/radio.js";
56
55
  import { enrichProcessPath } from "./core/shell-path.js";
57
56
  import { createElicitationBridge } from "./core/mcp/index.js";
58
- import { askSoftDeadlineMs, createAskUserBridge, deliverLateAnswer } from "./core/ask-user.js";
57
+ import { askSoftDeadlineMs, createAskUserBridge, deliverLateAnswer, } from "./core/ask-user.js";
59
58
  import { createAskUserTool } from "./tools/ask-user.js";
60
59
  import { createUsageService } from "./app-sidecar/usage.js";
61
60
  import { daemonReadBody, daemonJson, json } from "./app-sidecar/http.js";
@@ -65,6 +64,10 @@ import { ALL_PROVIDERS, loadProjectModelPrefs, loadNolanModelPref, loadAutopilot
65
64
  import { NOLAN_ALLOWED_TOOLS } from "./app-sidecar/nolan-context.js";
66
65
  import { createProgressManager } from "./app-sidecar/progress-manager.js";
67
66
  import { handleSessionRoutes } from "./app-sidecar/session-routes.js";
67
+ import { handleGoalRoutes } from "./app-sidecar/goal-routes.js";
68
+ import { readGitState, resolveGitCwd } from "./app-sidecar/git-target.js";
69
+ import { goalPhase, goalsRoot, isGoalDir, startGoalOnAnswer, readGoal, } from "./core/goals-store.js";
70
+ import { createGoalsAgentSession, goalCritterPick, loadGoalBriefExtras, } from "./goals-agent/goals-agent.js";
68
71
  import { handleWorkspaceRoutes } from "./app-sidecar/workspace-routes.js";
69
72
  import { handleHistoryRoutes } from "./app-sidecar/history-routes.js";
70
73
  import { handlePromptRoutes } from "./app-sidecar/prompt-routes.js";
@@ -177,6 +180,18 @@ async function main() {
177
180
  for (const ctx of sessions.values())
178
181
  ctx.broadcast(type, data);
179
182
  };
183
+ const sessionIdsAt = (cwd) => {
184
+ const target = path.resolve(cwd);
185
+ return [...sessions.values()]
186
+ .filter((ctx) => path.resolve(ctx.cwd) === target)
187
+ .map((ctx) => ctx.id)
188
+ .sort();
189
+ };
190
+ /** Whether any live window session in `cwd` is working right now. */
191
+ const busyAt = (cwd) => {
192
+ const target = path.resolve(cwd);
193
+ return [...sessions.values()].some((ctx) => path.resolve(ctx.cwd) === target && ctx.isBusy());
194
+ };
180
195
  // Providers currently mid-OAuth in some window. Daemon-level so two windows
181
196
  // cannot race two browser flows for the same provider into one auth file.
182
197
  const oauthInFlightProviders = new Set();
@@ -271,6 +286,8 @@ async function main() {
271
286
  broadcastAll,
272
287
  oauthInFlightProviders,
273
288
  repoPolls,
289
+ sessionIdsAt,
290
+ busyAt,
274
291
  }, { id, mode, chatAgent, cwd: sessionCwd, sessionPath });
275
292
  sessions.set(id, ctx);
276
293
  log("INFO", "app-sidecar", "session created", {
@@ -279,7 +296,13 @@ async function main() {
279
296
  chatAgent,
280
297
  cwd: sessionCwd,
281
298
  });
282
- daemonJson(res, 200, { sessionId: id });
299
+ // The conversation file too, so the app can reopen this exact
300
+ // session if the daemon restarts (a new session has no path yet
301
+ // on the app's side).
302
+ daemonJson(res, 200, {
303
+ sessionId: id,
304
+ sessionPath: ctx.session.getState().sessionPath ?? null,
305
+ });
283
306
  }
284
307
  catch (err) {
285
308
  const message = err instanceof Error ? err.message : String(err);
@@ -433,7 +456,7 @@ async function main() {
433
456
  * logger, PATH, shared auth file, and radio live at the daemon level.
434
457
  */
435
458
  async function createSession(deps, opts) {
436
- const { auth, progress, memoryStore, jiwaStore, broadcastAll, oauthInFlightProviders, repoPolls, } = deps;
459
+ const { auth, progress, memoryStore, jiwaStore, broadcastAll, oauthInFlightProviders, repoPolls, sessionIdsAt, busyAt, } = deps;
437
460
  const paths = deps.paths;
438
461
  const mode = opts.mode;
439
462
  let chatAgent = opts.chatAgent;
@@ -442,6 +465,10 @@ async function createSession(deps, opts) {
442
465
  // root; create it on first use so a fresh install can start a video at once.
443
466
  if (mode === "motion")
444
467
  await fs.mkdir(cwd, { recursive: true });
468
+ // A coding session whose cwd is a Goal folder is that goal's coordinator.
469
+ let goal = mode === "code" && isGoalDir(goalsRoot(paths.agentDir), cwd)
470
+ ? await readGoal(cwd).then((record) => (record ? { dir: cwd, record } : null))
471
+ : null;
445
472
  // Base host for parsing request-URL query params (value is irrelevant to
446
473
  // parsing); the daemon owns the real listen host.
447
474
  const host = "127.0.0.1";
@@ -587,7 +614,9 @@ async function createSession(deps, opts) {
587
614
  onClosed: (ids) => broadcast("ask_user_closed", { ids }),
588
615
  onLateAnswer: (late) => {
589
616
  log("INFO", "app-sidecar", "ask_user late answer queued", { id: late.prompt.id });
590
- deliverLateAnswer(late, {
617
+ // A late "Start the goal now" starts the goal before EZ Goals hears it,
618
+ // so the run it wakes into may already hand out work.
619
+ void startGoalFrom({ action: "answer", answers: late.answers }, late.prompt.questions).then(() => deliverLateAnswer(late, {
591
620
  queueMessage: (text) => session.queueMessage(text),
592
621
  isBusy: () => running || runClaim.active || autopilotActive,
593
622
  onQueued: () => broadcast("queued", {
@@ -595,13 +624,44 @@ async function createSession(deps, opts) {
595
624
  messages: session.listQueuedMessages(),
596
625
  }),
597
626
  startIdleRun: () => void runStrandedQueue(),
598
- });
627
+ }));
599
628
  },
600
629
  });
601
- const askUserTool = createAskUserTool(asks.park);
630
+ // A goal starts when the user picks "Start the goal now" from EZ Goals'
631
+ // question: answered while it waits, or late, after its deadline (see
632
+ // onLateAnswer). Recorded once, and announced so the window can switch to
633
+ // the goal's world view. Never throws: a failed write leaves it briefing.
634
+ const startGoalFrom = async (result, questions) => {
635
+ const current = goal;
636
+ if (!current)
637
+ return;
638
+ const started = await startGoalOnAnswer(current.dir, current.record, result, new Date(), questions).catch((err) => {
639
+ log("WARN", "app-sidecar", "couldn't record goal start", {
640
+ error: err instanceof Error ? err.message : String(err),
641
+ });
642
+ return null;
643
+ });
644
+ if (started) {
645
+ goal = { dir: current.dir, record: started };
646
+ broadcast("goal_state", { id: started.id, phase: goalPhase(started) });
647
+ }
648
+ };
649
+ const askUserTool = createAskUserTool(async (request) => {
650
+ const result = await asks.park(request);
651
+ await startGoalFrom(result, request.questions);
652
+ return result;
653
+ });
602
654
  // The session file path to resume (passed by the daemon's POST /session);
603
- // empty/unset starts a fresh session.
604
- const resumeSessionPath = opts.sessionPath;
655
+ // empty/unset starts a fresh session. A file that's gone (retention cleaned
656
+ // it up, or its goal was deleted) starts fresh too: failing instead left
657
+ // the window with no session at all, so not even its goal list loaded.
658
+ const resumeSessionPath = opts.sessionPath &&
659
+ (await fs.access(opts.sessionPath).then(() => true, () => false))
660
+ ? opts.sessionPath
661
+ : undefined;
662
+ if (opts.sessionPath && !resumeSessionPath) {
663
+ log("WARN", "app-sidecar", "session file gone, starting fresh", { cwd });
664
+ }
605
665
  let abort = new AbortController();
606
666
  const baseSessionOptions = {
607
667
  provider,
@@ -648,6 +708,19 @@ async function createSession(deps, opts) {
648
708
  additionalTools: [askUserTool],
649
709
  });
650
710
  }
711
+ else if (goal) {
712
+ // A Goal window talks to EZ Goals, the orchestrator, not the coding agent.
713
+ session = createGoalsAgentSession({
714
+ ...baseSessionOptions,
715
+ goal,
716
+ // Memory and the attached projects' rules, read once for this session.
717
+ extras: await loadGoalBriefExtras(goal),
718
+ // Read live: the goal turns active mid-session when the user starts it.
719
+ isActive: () => Boolean(goal?.record.startedAt),
720
+ getChildModel: () => goalCritterPick(goal?.record),
721
+ additionalTools: [askUserTool],
722
+ });
723
+ }
651
724
  else {
652
725
  session = new AgentSession({
653
726
  ...baseSessionOptions,
@@ -686,6 +759,18 @@ async function createSession(deps, opts) {
686
759
  });
687
760
  }
688
761
  await session.initialize();
762
+ // A goal's attached folders join its workspace so the coordinator can read
763
+ // them. A resumed session may already carry them; `addDirectory` skips those.
764
+ if (goal) {
765
+ for (const item of goal.record.context) {
766
+ if (item.kind !== "folder")
767
+ continue;
768
+ const added = await session.addDirectory(item.path);
769
+ if (!added.ok && !added.error.startsWith("Already in the workspace")) {
770
+ log("WARN", "app-sidecar", "goal context folder unavailable", { error: added.error });
771
+ }
772
+ }
773
+ }
689
774
  if (mode === "chat") {
690
775
  const restoredAgent = [...session.getAppMarkers()]
691
776
  .reverse()
@@ -799,20 +884,18 @@ async function createSession(deps, opts) {
799
884
  });
800
885
  }
801
886
  // Workspace extras (context window, git status, background tasks). Git state
802
- // is resolved once at startup and refreshed after every run; the context
803
- // window follows the active model.
804
- const [initialGitBranch, initialGitIsRepo, initialDirtyFileCount, initialGitHubSlug] = await Promise.all([
805
- getGitBranch(cwd).catch(() => null),
806
- isGitRepo(cwd).catch(() => false),
807
- getGitDirtyFileCount(cwd).catch(() => 0),
808
- getGitHubRepoSlug(cwd).catch(() => null),
809
- ]);
810
- let gitBranch = initialGitBranch;
811
- let gitIsRepo = initialGitIsRepo;
812
- let gitDirtyFileCount = initialDirtyFileCount;
887
+ // is resolved at startup and refreshed after every run; the context window
888
+ // follows the active model. A Goal window reads git from its attached
889
+ // project folder, not the goal folder (see resolveGitCwd), and re-targets
890
+ // when the goal's folders change (`retargetGit`).
891
+ let gitCwd = await resolveGitCwd(cwd, goal?.record ?? null);
892
+ const initialGit = await readGitState(gitCwd);
893
+ let gitBranch = initialGit.branch;
894
+ let gitIsRepo = initialGit.isRepo;
895
+ let gitDirtyFileCount = initialGit.dirtyFileCount;
813
896
  // Open issue/PR counts for the origin repo's GitHub slug, via the `gh` CLI's
814
897
  // auth. null = unknown (gh missing/unauthed, non-GitHub origin) → chips hidden.
815
- const gitHubSlug = initialGitHubSlug;
898
+ let gitHubSlug = initialGit.gitHubSlug;
816
899
  let gitHubIssues = null;
817
900
  let gitHubPRs = null;
818
901
  let gitHubCI = null;
@@ -1444,15 +1527,15 @@ async function createSession(deps, opts) {
1444
1527
  // A run may have switched branches, changed files, or spawned/finished
1445
1528
  // background tasks. Refresh the workspace extras once it settles.
1446
1529
  [gitBranch, gitIsRepo, gitDirtyFileCount] = await Promise.all([
1447
- getGitBranch(cwd).catch(() => gitBranch),
1448
- isGitRepo(cwd).catch(() => gitIsRepo),
1449
- getGitDirtyFileCount(cwd).catch(() => gitDirtyFileCount),
1530
+ getGitBranch(gitCwd).catch(() => gitBranch),
1531
+ isGitRepo(gitCwd).catch(() => gitIsRepo),
1532
+ getGitDirtyFileCount(gitCwd).catch(() => gitDirtyFileCount),
1450
1533
  ]);
1451
1534
  // A run may have opened/closed issues or PRs — refresh fire-and-forget so
1452
1535
  // teardown isn't delayed by the network. Broadcasts itself on change.
1453
- gitHubCountsPoll?.refresh();
1454
- ciPoll.refresh();
1455
- healthPoll?.refresh();
1536
+ repoWatch.gitHubCounts?.refresh();
1537
+ repoWatch.ci.refresh();
1538
+ repoWatch.health?.refresh();
1456
1539
  // Serialize behind any marker/tool-triggered refresh so the terminal
1457
1540
  // progress snapshot uses the live plan file. Once every canonical step
1458
1541
  // is complete, remove the approved plan from future system prompts and
@@ -1795,39 +1878,82 @@ async function createSession(deps, opts) {
1795
1878
  // (they change outside the agent: editor saves, commits, teammates). Branch/
1796
1879
  // repo state already refreshes after agent runs. The pollers are shared with
1797
1880
  // every other window on the same repo; each window broadcasts only changes.
1798
- const repoKey = path.resolve(cwd);
1799
- const dirtyFilesPoll = repoPolls.dirtyFiles.subscribe(repoKey, (next) => {
1800
- if (next === gitDirtyFileCount)
1801
- return;
1802
- gitDirtyFileCount = next;
1803
- broadcast("extras", footerExtras());
1804
- });
1805
- // Failed checks publish nothing, so the chips keep their last-known numbers
1806
- // instead of flickering off on a timeout. None when origin isn't GitHub.
1807
- const gitHubCountsPoll = gitHubSlug
1808
- ? repoPolls.gitHubCounts.subscribe(gitHubSlug, (counts) => {
1809
- if (counts.issues === gitHubIssues && counts.prs === gitHubPRs)
1881
+ function watchRepo(dir) {
1882
+ const repoKey = path.resolve(dir);
1883
+ const dirtyFiles = repoPolls.dirtyFiles.subscribe(repoKey, (next) => {
1884
+ if (next === gitDirtyFileCount)
1810
1885
  return;
1811
- gitHubIssues = counts.issues;
1812
- gitHubPRs = counts.prs;
1886
+ gitDirtyFileCount = next;
1813
1887
  broadcast("extras", footerExtras());
1814
- })
1815
- : null;
1816
- const ciPoll = repoPolls.gitHubCI.subscribe(repoKey, (next) => {
1817
- if (JSON.stringify(next) === JSON.stringify(gitHubCI))
1818
- return;
1819
- gitHubCI = next;
1820
- broadcast("extras", footerExtras());
1821
- });
1822
- // Project Health is a code-workspace signal; chat and motion windows skip the scan.
1823
- const healthPoll = mode === "code"
1824
- ? repoPolls.projectHealth.subscribe(repoKey, (next) => {
1825
- if (JSON.stringify(next) === JSON.stringify(projectHealthScan))
1888
+ });
1889
+ // Failed checks publish nothing, so the chips keep their last-known numbers
1890
+ // instead of flickering off on a timeout. None when origin isn't GitHub.
1891
+ const gitHubCounts = gitHubSlug
1892
+ ? repoPolls.gitHubCounts.subscribe(gitHubSlug, (counts) => {
1893
+ if (counts.issues === gitHubIssues && counts.prs === gitHubPRs)
1894
+ return;
1895
+ gitHubIssues = counts.issues;
1896
+ gitHubPRs = counts.prs;
1897
+ broadcast("extras", footerExtras());
1898
+ })
1899
+ : null;
1900
+ const ci = repoPolls.gitHubCI.subscribe(repoKey, (next) => {
1901
+ if (JSON.stringify(next) === JSON.stringify(gitHubCI))
1826
1902
  return;
1827
- projectHealthScan = next;
1903
+ gitHubCI = next;
1828
1904
  broadcast("extras", footerExtras());
1829
- })
1830
- : null;
1905
+ });
1906
+ // Project Health is a code-workspace signal; chat and motion windows skip the scan.
1907
+ const health = mode === "code"
1908
+ ? repoPolls.projectHealth.subscribe(repoKey, (next) => {
1909
+ if (JSON.stringify(next) === JSON.stringify(projectHealthScan))
1910
+ return;
1911
+ projectHealthScan = next;
1912
+ broadcast("extras", footerExtras());
1913
+ })
1914
+ : null;
1915
+ return {
1916
+ gitHubCounts,
1917
+ ci,
1918
+ health,
1919
+ unsubscribe() {
1920
+ dirtyFiles.unsubscribe();
1921
+ gitHubCounts?.unsubscribe();
1922
+ ci.unsubscribe();
1923
+ health?.unsubscribe();
1924
+ },
1925
+ };
1926
+ }
1927
+ let repoWatch = watchRepo(gitCwd);
1928
+ let repoWatchStopped = false;
1929
+ let gitTargetGeneration = 0;
1930
+ /**
1931
+ * A goal's attached folders changed: point the titlebar's git state at the
1932
+ * new project folder. No-op when the target is unchanged; a newer call or
1933
+ * teardown supersedes one still reading git.
1934
+ */
1935
+ async function retargetGit() {
1936
+ const generation = ++gitTargetGeneration;
1937
+ const next = await resolveGitCwd(cwd, goal?.record ?? null);
1938
+ if (generation !== gitTargetGeneration || repoWatchStopped || next === gitCwd)
1939
+ return;
1940
+ const state = await readGitState(next);
1941
+ if (generation !== gitTargetGeneration || repoWatchStopped)
1942
+ return;
1943
+ repoWatch.unsubscribe();
1944
+ gitCwd = next;
1945
+ gitBranch = state.branch;
1946
+ gitIsRepo = state.isRepo;
1947
+ gitDirtyFileCount = state.dirtyFileCount;
1948
+ gitHubSlug = state.gitHubSlug;
1949
+ // The old repo's numbers don't apply; the new watchers fill these in.
1950
+ gitHubIssues = null;
1951
+ gitHubPRs = null;
1952
+ gitHubCI = null;
1953
+ projectHealthScan = null;
1954
+ repoWatch = watchRepo(gitCwd);
1955
+ broadcast("extras", footerExtras());
1956
+ }
1831
1957
  const routeCtx = {
1832
1958
  get session() {
1833
1959
  return session;
@@ -1879,6 +2005,23 @@ async function createSession(deps, opts) {
1879
2005
  cwd,
1880
2006
  asks,
1881
2007
  runClaim,
2008
+ sessionId: opts.id,
2009
+ sessionIdsAt,
2010
+ busyAt,
2011
+ get goal() {
2012
+ return goal;
2013
+ },
2014
+ set goal(v) {
2015
+ const foldersBefore = JSON.stringify(goal?.record.context ?? []);
2016
+ goal = v;
2017
+ if (JSON.stringify(goal?.record.context ?? []) !== foldersBefore) {
2018
+ retargetGit().catch((err) => {
2019
+ log("WARN", "app-sidecar", "goal git retarget failed", {
2020
+ message: err instanceof Error ? err.message : String(err),
2021
+ });
2022
+ });
2023
+ }
2024
+ },
1882
2025
  broadcast,
1883
2026
  get scheduledRunActive() {
1884
2027
  return scheduledRunActive;
@@ -2036,6 +2179,8 @@ async function createSession(deps, opts) {
2036
2179
  return;
2037
2180
  if (handleWorkspaceRoutes(routeCtx, req, res, url, method))
2038
2181
  return;
2182
+ if (handleGoalRoutes(routeCtx, req, res, url, method))
2183
+ return;
2039
2184
  if (handleHistoryRoutes(routeCtx, req, res, url, method))
2040
2185
  return;
2041
2186
  if (handlePromptRoutes(routeCtx, req, res, url, method))
@@ -2053,6 +2198,12 @@ async function createSession(deps, opts) {
2053
2198
  json(res, 404, { error: "not found" });
2054
2199
  }
2055
2200
  async function dispose() {
2201
+ // A window closed (or switched project) mid-run: stop the run itself, any
2202
+ // run-all sweep or autopilot turn, and a model download this window
2203
+ // started. Otherwise they keep streaming tokens and running tools on a
2204
+ // session that is being torn down.
2205
+ abortOwnedWork();
2206
+ cancelHfPull();
2056
2207
  releaseRunAwake?.();
2057
2208
  releaseRunAwake = null;
2058
2209
  releaseSubagentAwake?.();
@@ -2062,10 +2213,8 @@ async function createSession(deps, opts) {
2062
2213
  tasksPollStopped = true;
2063
2214
  if (tasksPoll)
2064
2215
  clearTimeout(tasksPoll);
2065
- dirtyFilesPoll.unsubscribe();
2066
- gitHubCountsPoll?.unsubscribe();
2067
- ciPoll.unsubscribe();
2068
- healthPoll?.unsubscribe();
2216
+ repoWatchStopped = true;
2217
+ repoWatch.unsubscribe();
2069
2218
  // Stop the Telegram serve loop + dispose its per-chat sessions.
2070
2219
  if (serveController)
2071
2220
  await serveController.stop().catch(() => { });
@@ -2096,6 +2245,10 @@ async function createSession(deps, opts) {
2096
2245
  handle,
2097
2246
  dispose,
2098
2247
  stopBackgroundProcesses,
2248
+ isBusy: () => running ||
2249
+ runClaim.active ||
2250
+ autopilotActive ||
2251
+ session.listSubAgents().some((w) => w.state === "running" || w.state === "starting"),
2099
2252
  };
2100
2253
  }
2101
2254
  main().catch(async (err) => {