@prestyj/cli 5.30.2 → 5.31.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 (142) 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 +6 -0
  8. package/dist/app-sidecar/goal-routes.js +360 -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 +37 -27
  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/prompt-routes.js +1 -2
  16. package/dist/app-sidecar/prompt-routes.js.map +1 -1
  17. package/dist/app-sidecar/route-context.d.ts +12 -0
  18. package/dist/app-sidecar/run-control-routes.js +2 -1
  19. package/dist/app-sidecar/run-control-routes.js.map +1 -1
  20. package/dist/app-sidecar/session-routes.js +41 -0
  21. package/dist/app-sidecar/session-routes.js.map +1 -1
  22. package/dist/app-sidecar/session-types.d.ts +2 -0
  23. package/dist/app-sidecar/workspace-routes.js +13 -95
  24. package/dist/app-sidecar/workspace-routes.js.map +1 -1
  25. package/dist/app-sidecar.js +219 -67
  26. package/dist/app-sidecar.js.map +1 -1
  27. package/dist/cli/shared.js +1 -1
  28. package/dist/cli/shared.js.map +1 -1
  29. package/dist/core/acceptance-checks.js +17 -4
  30. package/dist/core/acceptance-checks.js.map +1 -1
  31. package/dist/core/agent-notifications.d.ts +1 -1
  32. package/dist/core/agent-session.d.ts +90 -5
  33. package/dist/core/agent-session.js +185 -63
  34. package/dist/core/agent-session.js.map +1 -1
  35. package/dist/core/ask-user.d.ts +11 -0
  36. package/dist/core/ask-user.js +6 -1
  37. package/dist/core/ask-user.js.map +1 -1
  38. package/dist/core/auth-providers.d.ts +2 -80
  39. package/dist/core/auth-providers.js +4 -183
  40. package/dist/core/auth-providers.js.map +1 -1
  41. package/dist/core/cache-diagnostics.d.ts +1 -1
  42. package/dist/core/cache-diagnostics.js +2 -1
  43. package/dist/core/cache-diagnostics.js.map +1 -1
  44. package/dist/core/compaction/tool-result-pruner.d.ts +10 -0
  45. package/dist/core/compaction/tool-result-pruner.js +12 -0
  46. package/dist/core/compaction/tool-result-pruner.js.map +1 -1
  47. package/dist/core/context-files.d.ts +8 -0
  48. package/dist/core/context-files.js +46 -0
  49. package/dist/core/context-files.js.map +1 -0
  50. package/dist/core/custom-commands.d.ts +6 -0
  51. package/dist/core/custom-commands.js +17 -0
  52. package/dist/core/custom-commands.js.map +1 -1
  53. package/dist/core/env-delta.d.ts +9 -1
  54. package/dist/core/env-delta.js +20 -0
  55. package/dist/core/env-delta.js.map +1 -1
  56. package/dist/core/goal-summary.d.ts +32 -0
  57. package/dist/core/goal-summary.js +104 -0
  58. package/dist/core/goal-summary.js.map +1 -0
  59. package/dist/core/goals-store.d.ts +208 -0
  60. package/dist/core/goals-store.js +521 -0
  61. package/dist/core/goals-store.js.map +1 -0
  62. package/dist/core/local-endpoint-store.js +15 -11
  63. package/dist/core/local-endpoint-store.js.map +1 -1
  64. package/dist/core/plan-handoff.d.ts +8 -0
  65. package/dist/core/plan-handoff.js +11 -0
  66. package/dist/core/plan-handoff.js.map +1 -1
  67. package/dist/core/project-discovery.d.ts +7 -0
  68. package/dist/core/project-discovery.js +9 -3
  69. package/dist/core/project-discovery.js.map +1 -1
  70. package/dist/core/radio.d.ts +2 -3
  71. package/dist/core/radio.js.map +1 -1
  72. package/dist/core/session-manager.d.ts +7 -1
  73. package/dist/core/session-manager.js +17 -7
  74. package/dist/core/session-manager.js.map +1 -1
  75. package/dist/core/session-storage.d.ts +3 -0
  76. package/dist/core/session-storage.js +8 -2
  77. package/dist/core/session-storage.js.map +1 -1
  78. package/dist/core/steroids.d.ts +8 -0
  79. package/dist/core/steroids.js +19 -3
  80. package/dist/core/steroids.js.map +1 -1
  81. package/dist/core/subagent-manager.d.ts +51 -0
  82. package/dist/core/subagent-manager.js +103 -16
  83. package/dist/core/subagent-manager.js.map +1 -1
  84. package/dist/core/subagent-store.js +2 -1
  85. package/dist/core/subagent-store.js.map +1 -1
  86. package/dist/goals-agent/goal-team.d.ts +2 -0
  87. package/dist/goals-agent/goal-team.js +83 -0
  88. package/dist/goals-agent/goal-team.js.map +1 -0
  89. package/dist/goals-agent/goals-agent.d.ts +90 -0
  90. package/dist/goals-agent/goals-agent.js +246 -0
  91. package/dist/goals-agent/goals-agent.js.map +1 -0
  92. package/dist/index.js +1 -1
  93. package/dist/index.js.map +1 -1
  94. package/dist/interactive.js +4 -3
  95. package/dist/interactive.js.map +1 -1
  96. package/dist/modes/acp-autopilot.d.ts +27 -0
  97. package/dist/modes/acp-autopilot.js +54 -0
  98. package/dist/modes/acp-autopilot.js.map +1 -0
  99. package/dist/modes/acp-goals.d.ts +48 -0
  100. package/dist/modes/acp-goals.js +103 -0
  101. package/dist/modes/acp-goals.js.map +1 -0
  102. package/dist/modes/acp-mode.d.ts +128 -3
  103. package/dist/modes/acp-mode.js +1082 -52
  104. package/dist/modes/acp-mode.js.map +1 -1
  105. package/dist/modes/subagent-worker-mode.d.ts +14 -0
  106. package/dist/modes/subagent-worker-mode.js +32 -1
  107. package/dist/modes/subagent-worker-mode.js.map +1 -1
  108. package/dist/system-prompt.d.ts +8 -0
  109. package/dist/system-prompt.js +21 -9
  110. package/dist/system-prompt.js.map +1 -1
  111. package/dist/tools/bash-read-evidence.d.ts +13 -0
  112. package/dist/tools/bash-read-evidence.js +48 -1
  113. package/dist/tools/bash-read-evidence.js.map +1 -1
  114. package/dist/tools/grep-scan.d.ts +96 -0
  115. package/dist/tools/grep-scan.js +283 -0
  116. package/dist/tools/grep-scan.js.map +1 -0
  117. package/dist/tools/grep.d.ts +10 -0
  118. package/dist/tools/grep.js +418 -10
  119. package/dist/tools/grep.js.map +1 -1
  120. package/dist/tools/index.d.ts +7 -1
  121. package/dist/tools/index.js +10 -2
  122. package/dist/tools/index.js.map +1 -1
  123. package/dist/tools/read-tracker.d.ts +6 -12
  124. package/dist/tools/read-tracker.js +21 -36
  125. package/dist/tools/read-tracker.js.map +1 -1
  126. package/dist/tools/read.js +2 -2
  127. package/dist/tools/read.js.map +1 -1
  128. package/dist/tools/subagent-control.js +22 -3
  129. package/dist/tools/subagent-control.js.map +1 -1
  130. package/dist/tools/write.js +110 -8
  131. package/dist/tools/write.js.map +1 -1
  132. package/dist/ui/components/ActivityIndicator.d.ts +5 -5
  133. package/dist/ui/components/InputArea.d.ts +2 -2
  134. package/dist/ui/components/InputArea.js +3 -3
  135. package/dist/ui/components/InputArea.js.map +1 -1
  136. package/dist/ui/components/ToolExecution.d.ts +2 -2
  137. package/dist/ui/hooks/useContextCompaction.js +38 -31
  138. package/dist/ui/hooks/useContextCompaction.js.map +1 -1
  139. package/dist/utils/format.d.ts +0 -4
  140. package/dist/utils/format.js +0 -7
  141. package/dist/utils/format.js.map +1 -1
  142. package/package.json +4 -4
@@ -23,7 +23,7 @@ import { setStreamDiagnostic } from "@prestyj/agent";
23
23
  import { AgentSession } from "./core/agent-session.js";
24
24
  import { RunLifecycle } from "./core/run-lifecycle.js";
25
25
  import { RunClaim } from "./core/run-claim.js";
26
- import { shouldOfferPendingPlan } from "./core/plan-handoff.js";
26
+ import { IMPLEMENT_PLAN_PROMPT as SHARED_IMPLEMENT_PLAN_PROMPT, PLAN_SUBMITTED_FOR_REVIEW, shouldOfferPendingPlan, } from "./core/plan-handoff.js";
27
27
  import { createChatAgent, parseChatAgentId, switchChatAgent, } from "./chat-agents/index.js";
28
28
  import { createMotionAgentSession } from "./motion-agent/motion-agent.js";
29
29
  import { buildJiwaTools, JiwaStore } from "./chat-agents/jiwa.js";
@@ -42,20 +42,18 @@ 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";
49
48
  import { getSupportedThinkingLevels, isThinkingLevelSupported } from "./core/thinking-level.js";
50
49
  import { PROMPT_COMMANDS } from "./core/prompt-commands.js";
51
- import { loadCustomCommands } from "./core/custom-commands.js";
52
50
  import { loadTasksSync, pruneDoneTasksSync } from "./core/task-store.js";
53
51
  import { initLogger, log } from "./core/logger.js";
54
52
  import { installTerminationHandlers } from "./core/shutdown.js";
55
53
  import { stopRadio } from "./core/radio.js";
56
54
  import { enrichProcessPath } from "./core/shell-path.js";
57
55
  import { createElicitationBridge } from "./core/mcp/index.js";
58
- import { askSoftDeadlineMs, createAskUserBridge, deliverLateAnswer } from "./core/ask-user.js";
56
+ import { askSoftDeadlineMs, createAskUserBridge, deliverLateAnswer, } from "./core/ask-user.js";
59
57
  import { createAskUserTool } from "./tools/ask-user.js";
60
58
  import { createUsageService } from "./app-sidecar/usage.js";
61
59
  import { daemonReadBody, daemonJson, json } from "./app-sidecar/http.js";
@@ -65,6 +63,10 @@ import { ALL_PROVIDERS, loadProjectModelPrefs, loadNolanModelPref, loadAutopilot
65
63
  import { NOLAN_ALLOWED_TOOLS } from "./app-sidecar/nolan-context.js";
66
64
  import { createProgressManager } from "./app-sidecar/progress-manager.js";
67
65
  import { handleSessionRoutes } from "./app-sidecar/session-routes.js";
66
+ import { handleGoalRoutes } from "./app-sidecar/goal-routes.js";
67
+ import { readGitState, resolveGitCwd } from "./app-sidecar/git-target.js";
68
+ import { goalPhase, goalsRoot, isGoalDir, startGoalOnAnswer, readGoal, } from "./core/goals-store.js";
69
+ import { createGoalsAgentSession, goalCritterPick, loadGoalBriefExtras, } from "./goals-agent/goals-agent.js";
68
70
  import { handleWorkspaceRoutes } from "./app-sidecar/workspace-routes.js";
69
71
  import { handleHistoryRoutes } from "./app-sidecar/history-routes.js";
70
72
  import { handlePromptRoutes } from "./app-sidecar/prompt-routes.js";
@@ -177,6 +179,18 @@ async function main() {
177
179
  for (const ctx of sessions.values())
178
180
  ctx.broadcast(type, data);
179
181
  };
182
+ const sessionIdsAt = (cwd) => {
183
+ const target = path.resolve(cwd);
184
+ return [...sessions.values()]
185
+ .filter((ctx) => path.resolve(ctx.cwd) === target)
186
+ .map((ctx) => ctx.id)
187
+ .sort();
188
+ };
189
+ /** Whether any live window session in `cwd` is working right now. */
190
+ const busyAt = (cwd) => {
191
+ const target = path.resolve(cwd);
192
+ return [...sessions.values()].some((ctx) => path.resolve(ctx.cwd) === target && ctx.isBusy());
193
+ };
180
194
  // Providers currently mid-OAuth in some window. Daemon-level so two windows
181
195
  // cannot race two browser flows for the same provider into one auth file.
182
196
  const oauthInFlightProviders = new Set();
@@ -271,6 +285,8 @@ async function main() {
271
285
  broadcastAll,
272
286
  oauthInFlightProviders,
273
287
  repoPolls,
288
+ sessionIdsAt,
289
+ busyAt,
274
290
  }, { id, mode, chatAgent, cwd: sessionCwd, sessionPath });
275
291
  sessions.set(id, ctx);
276
292
  log("INFO", "app-sidecar", "session created", {
@@ -279,7 +295,13 @@ async function main() {
279
295
  chatAgent,
280
296
  cwd: sessionCwd,
281
297
  });
282
- daemonJson(res, 200, { sessionId: id });
298
+ // The conversation file too, so the app can reopen this exact
299
+ // session if the daemon restarts (a new session has no path yet
300
+ // on the app's side).
301
+ daemonJson(res, 200, {
302
+ sessionId: id,
303
+ sessionPath: ctx.session.getState().sessionPath ?? null,
304
+ });
283
305
  }
284
306
  catch (err) {
285
307
  const message = err instanceof Error ? err.message : String(err);
@@ -433,7 +455,7 @@ async function main() {
433
455
  * logger, PATH, shared auth file, and radio live at the daemon level.
434
456
  */
435
457
  async function createSession(deps, opts) {
436
- const { auth, progress, memoryStore, jiwaStore, broadcastAll, oauthInFlightProviders, repoPolls, } = deps;
458
+ const { auth, progress, memoryStore, jiwaStore, broadcastAll, oauthInFlightProviders, repoPolls, sessionIdsAt, busyAt, } = deps;
437
459
  const paths = deps.paths;
438
460
  const mode = opts.mode;
439
461
  let chatAgent = opts.chatAgent;
@@ -442,6 +464,10 @@ async function createSession(deps, opts) {
442
464
  // root; create it on first use so a fresh install can start a video at once.
443
465
  if (mode === "motion")
444
466
  await fs.mkdir(cwd, { recursive: true });
467
+ // A coding session whose cwd is a Goal folder is that goal's coordinator.
468
+ let goal = mode === "code" && isGoalDir(goalsRoot(paths.agentDir), cwd)
469
+ ? await readGoal(cwd).then((record) => (record ? { dir: cwd, record } : null))
470
+ : null;
445
471
  // Base host for parsing request-URL query params (value is irrelevant to
446
472
  // parsing); the daemon owns the real listen host.
447
473
  const host = "127.0.0.1";
@@ -587,7 +613,9 @@ async function createSession(deps, opts) {
587
613
  onClosed: (ids) => broadcast("ask_user_closed", { ids }),
588
614
  onLateAnswer: (late) => {
589
615
  log("INFO", "app-sidecar", "ask_user late answer queued", { id: late.prompt.id });
590
- deliverLateAnswer(late, {
616
+ // A late "Start the goal now" starts the goal before EZ Goals hears it,
617
+ // so the run it wakes into may already hand out work.
618
+ void startGoalFrom({ action: "answer", answers: late.answers }, late.prompt.questions).then(() => deliverLateAnswer(late, {
591
619
  queueMessage: (text) => session.queueMessage(text),
592
620
  isBusy: () => running || runClaim.active || autopilotActive,
593
621
  onQueued: () => broadcast("queued", {
@@ -595,13 +623,44 @@ async function createSession(deps, opts) {
595
623
  messages: session.listQueuedMessages(),
596
624
  }),
597
625
  startIdleRun: () => void runStrandedQueue(),
598
- });
626
+ }));
599
627
  },
600
628
  });
601
- const askUserTool = createAskUserTool(asks.park);
629
+ // A goal starts when the user picks "Start the goal now" from EZ Goals'
630
+ // question: answered while it waits, or late, after its deadline (see
631
+ // onLateAnswer). Recorded once, and announced so the window can switch to
632
+ // the goal's world view. Never throws: a failed write leaves it briefing.
633
+ const startGoalFrom = async (result, questions) => {
634
+ const current = goal;
635
+ if (!current)
636
+ return;
637
+ const started = await startGoalOnAnswer(current.dir, current.record, result, new Date(), questions).catch((err) => {
638
+ log("WARN", "app-sidecar", "couldn't record goal start", {
639
+ error: err instanceof Error ? err.message : String(err),
640
+ });
641
+ return null;
642
+ });
643
+ if (started) {
644
+ goal = { dir: current.dir, record: started };
645
+ broadcast("goal_state", { id: started.id, phase: goalPhase(started) });
646
+ }
647
+ };
648
+ const askUserTool = createAskUserTool(async (request) => {
649
+ const result = await asks.park(request);
650
+ await startGoalFrom(result, request.questions);
651
+ return result;
652
+ });
602
653
  // 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;
654
+ // empty/unset starts a fresh session. A file that's gone (retention cleaned
655
+ // it up, or its goal was deleted) starts fresh too: failing instead left
656
+ // the window with no session at all, so not even its goal list loaded.
657
+ const resumeSessionPath = opts.sessionPath &&
658
+ (await fs.access(opts.sessionPath).then(() => true, () => false))
659
+ ? opts.sessionPath
660
+ : undefined;
661
+ if (opts.sessionPath && !resumeSessionPath) {
662
+ log("WARN", "app-sidecar", "session file gone, starting fresh", { cwd });
663
+ }
605
664
  let abort = new AbortController();
606
665
  const baseSessionOptions = {
607
666
  provider,
@@ -648,6 +707,19 @@ async function createSession(deps, opts) {
648
707
  additionalTools: [askUserTool],
649
708
  });
650
709
  }
710
+ else if (goal) {
711
+ // A Goal window talks to EZ Goals, the orchestrator, not the coding agent.
712
+ session = createGoalsAgentSession({
713
+ ...baseSessionOptions,
714
+ goal,
715
+ // Memory and the attached projects' rules, read once for this session.
716
+ extras: await loadGoalBriefExtras(goal),
717
+ // Read live: the goal turns active mid-session when the user starts it.
718
+ isActive: () => Boolean(goal?.record.startedAt),
719
+ getChildModel: () => goalCritterPick(goal?.record),
720
+ additionalTools: [askUserTool],
721
+ });
722
+ }
651
723
  else {
652
724
  session = new AgentSession({
653
725
  ...baseSessionOptions,
@@ -681,11 +753,23 @@ async function createSession(deps, opts) {
681
753
  }
682
754
  setPendingPlan(planPath, content);
683
755
  broadcast("plan_exit", { planPath, content });
684
- return "Plan submitted for user review. Wait for the user to approve, reject, or dismiss it before implementing.";
756
+ return PLAN_SUBMITTED_FOR_REVIEW;
685
757
  },
686
758
  });
687
759
  }
688
760
  await session.initialize();
761
+ // A goal's attached folders join its workspace so the coordinator can read
762
+ // them. A resumed session may already carry them; `addDirectory` skips those.
763
+ if (goal) {
764
+ for (const item of goal.record.context) {
765
+ if (item.kind !== "folder")
766
+ continue;
767
+ const added = await session.addDirectory(item.path);
768
+ if (!added.ok && !added.error.startsWith("Already in the workspace")) {
769
+ log("WARN", "app-sidecar", "goal context folder unavailable", { error: added.error });
770
+ }
771
+ }
772
+ }
689
773
  if (mode === "chat") {
690
774
  const restoredAgent = [...session.getAppMarkers()]
691
775
  .reverse()
@@ -799,20 +883,18 @@ async function createSession(deps, opts) {
799
883
  });
800
884
  }
801
885
  // 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;
886
+ // is resolved at startup and refreshed after every run; the context window
887
+ // follows the active model. A Goal window reads git from its attached
888
+ // project folder, not the goal folder (see resolveGitCwd), and re-targets
889
+ // when the goal's folders change (`retargetGit`).
890
+ let gitCwd = await resolveGitCwd(cwd, goal?.record ?? null);
891
+ const initialGit = await readGitState(gitCwd);
892
+ let gitBranch = initialGit.branch;
893
+ let gitIsRepo = initialGit.isRepo;
894
+ let gitDirtyFileCount = initialGit.dirtyFileCount;
813
895
  // Open issue/PR counts for the origin repo's GitHub slug, via the `gh` CLI's
814
896
  // auth. null = unknown (gh missing/unauthed, non-GitHub origin) → chips hidden.
815
- const gitHubSlug = initialGitHubSlug;
897
+ let gitHubSlug = initialGit.gitHubSlug;
816
898
  let gitHubIssues = null;
817
899
  let gitHubPRs = null;
818
900
  let gitHubCI = null;
@@ -1190,12 +1272,12 @@ async function createSession(deps, opts) {
1190
1272
  log("INFO", "app-sidecar", "plan handed to user for review", { planPath: plan.planPath });
1191
1273
  broadcast("plan_review", plan);
1192
1274
  }
1193
- // Workflow (prompt-template) commands: built-in + the project's custom
1194
- // `.ezcoder/commands/*.md`. Used to gate autopilot off command turns and to label
1275
+ // Workflow (prompt-template) commands: built-in + the custom
1276
+ // `.ezcoder/commands/*.md` of every workspace root (a goal's attached repos too). Used to gate autopilot off command turns and to label
1195
1277
  // expanded templates in Nolan's digests. Loaded fresh so a newly added custom
1196
1278
  // command is picked up without a restart (mirrors GET /commands).
1197
1279
  async function loadWorkflowCommandSpecs() {
1198
- const custom = await loadCustomCommands(cwd).catch(() => []);
1280
+ const custom = await session.loadCustomCommands().catch(() => []);
1199
1281
  return [
1200
1282
  ...PROMPT_COMMANDS.map((c) => ({ name: c.name, aliases: c.aliases, prompt: c.prompt })),
1201
1283
  ...custom.map((c) => ({ name: c.name, aliases: [], prompt: c.prompt })),
@@ -1444,15 +1526,15 @@ async function createSession(deps, opts) {
1444
1526
  // A run may have switched branches, changed files, or spawned/finished
1445
1527
  // background tasks. Refresh the workspace extras once it settles.
1446
1528
  [gitBranch, gitIsRepo, gitDirtyFileCount] = await Promise.all([
1447
- getGitBranch(cwd).catch(() => gitBranch),
1448
- isGitRepo(cwd).catch(() => gitIsRepo),
1449
- getGitDirtyFileCount(cwd).catch(() => gitDirtyFileCount),
1529
+ getGitBranch(gitCwd).catch(() => gitBranch),
1530
+ isGitRepo(gitCwd).catch(() => gitIsRepo),
1531
+ getGitDirtyFileCount(gitCwd).catch(() => gitDirtyFileCount),
1450
1532
  ]);
1451
1533
  // A run may have opened/closed issues or PRs — refresh fire-and-forget so
1452
1534
  // teardown isn't delayed by the network. Broadcasts itself on change.
1453
- gitHubCountsPoll?.refresh();
1454
- ciPoll.refresh();
1455
- healthPoll?.refresh();
1535
+ repoWatch.gitHubCounts?.refresh();
1536
+ repoWatch.ci.refresh();
1537
+ repoWatch.health?.refresh();
1456
1538
  // Serialize behind any marker/tool-triggered refresh so the terminal
1457
1539
  // progress snapshot uses the live plan file. Once every canonical step
1458
1540
  // is complete, remove the approved plan from future system prompts and
@@ -1518,7 +1600,7 @@ async function createSession(deps, opts) {
1518
1600
  // string the webview sends on a manual Accept (see PlanReviewModal's accept
1519
1601
  // handler in ezcoder-app/src/App.tsx). Keep the two in lockstep so auto- and
1520
1602
  // manual approval produce identical implementation turns.
1521
- const IMPLEMENT_PLAN_PROMPT = "The plan has been approved. Implement it now, following each step in order.";
1603
+ const IMPLEMENT_PLAN_PROMPT = SHARED_IMPLEMENT_PLAN_PROMPT;
1522
1604
  // ── Stranded-queue drain ───────────────────────────────
1523
1605
  // A prompt POSTed while an autopilot cycle is between injected runs (build
1524
1606
  // idle, Nolan reviewing) queues — but the queue only drains INTO a running
@@ -1795,39 +1877,82 @@ async function createSession(deps, opts) {
1795
1877
  // (they change outside the agent: editor saves, commits, teammates). Branch/
1796
1878
  // repo state already refreshes after agent runs. The pollers are shared with
1797
1879
  // 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)
1880
+ function watchRepo(dir) {
1881
+ const repoKey = path.resolve(dir);
1882
+ const dirtyFiles = repoPolls.dirtyFiles.subscribe(repoKey, (next) => {
1883
+ if (next === gitDirtyFileCount)
1810
1884
  return;
1811
- gitHubIssues = counts.issues;
1812
- gitHubPRs = counts.prs;
1885
+ gitDirtyFileCount = next;
1813
1886
  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))
1887
+ });
1888
+ // Failed checks publish nothing, so the chips keep their last-known numbers
1889
+ // instead of flickering off on a timeout. None when origin isn't GitHub.
1890
+ const gitHubCounts = gitHubSlug
1891
+ ? repoPolls.gitHubCounts.subscribe(gitHubSlug, (counts) => {
1892
+ if (counts.issues === gitHubIssues && counts.prs === gitHubPRs)
1893
+ return;
1894
+ gitHubIssues = counts.issues;
1895
+ gitHubPRs = counts.prs;
1896
+ broadcast("extras", footerExtras());
1897
+ })
1898
+ : null;
1899
+ const ci = repoPolls.gitHubCI.subscribe(repoKey, (next) => {
1900
+ if (JSON.stringify(next) === JSON.stringify(gitHubCI))
1826
1901
  return;
1827
- projectHealthScan = next;
1902
+ gitHubCI = next;
1828
1903
  broadcast("extras", footerExtras());
1829
- })
1830
- : null;
1904
+ });
1905
+ // Project Health is a code-workspace signal; chat and motion windows skip the scan.
1906
+ const health = mode === "code"
1907
+ ? repoPolls.projectHealth.subscribe(repoKey, (next) => {
1908
+ if (JSON.stringify(next) === JSON.stringify(projectHealthScan))
1909
+ return;
1910
+ projectHealthScan = next;
1911
+ broadcast("extras", footerExtras());
1912
+ })
1913
+ : null;
1914
+ return {
1915
+ gitHubCounts,
1916
+ ci,
1917
+ health,
1918
+ unsubscribe() {
1919
+ dirtyFiles.unsubscribe();
1920
+ gitHubCounts?.unsubscribe();
1921
+ ci.unsubscribe();
1922
+ health?.unsubscribe();
1923
+ },
1924
+ };
1925
+ }
1926
+ let repoWatch = watchRepo(gitCwd);
1927
+ let repoWatchStopped = false;
1928
+ let gitTargetGeneration = 0;
1929
+ /**
1930
+ * A goal's attached folders changed: point the titlebar's git state at the
1931
+ * new project folder. No-op when the target is unchanged; a newer call or
1932
+ * teardown supersedes one still reading git.
1933
+ */
1934
+ async function retargetGit() {
1935
+ const generation = ++gitTargetGeneration;
1936
+ const next = await resolveGitCwd(cwd, goal?.record ?? null);
1937
+ if (generation !== gitTargetGeneration || repoWatchStopped || next === gitCwd)
1938
+ return;
1939
+ const state = await readGitState(next);
1940
+ if (generation !== gitTargetGeneration || repoWatchStopped)
1941
+ return;
1942
+ repoWatch.unsubscribe();
1943
+ gitCwd = next;
1944
+ gitBranch = state.branch;
1945
+ gitIsRepo = state.isRepo;
1946
+ gitDirtyFileCount = state.dirtyFileCount;
1947
+ gitHubSlug = state.gitHubSlug;
1948
+ // The old repo's numbers don't apply; the new watchers fill these in.
1949
+ gitHubIssues = null;
1950
+ gitHubPRs = null;
1951
+ gitHubCI = null;
1952
+ projectHealthScan = null;
1953
+ repoWatch = watchRepo(gitCwd);
1954
+ broadcast("extras", footerExtras());
1955
+ }
1831
1956
  const routeCtx = {
1832
1957
  get session() {
1833
1958
  return session;
@@ -1879,6 +2004,23 @@ async function createSession(deps, opts) {
1879
2004
  cwd,
1880
2005
  asks,
1881
2006
  runClaim,
2007
+ sessionId: opts.id,
2008
+ sessionIdsAt,
2009
+ busyAt,
2010
+ get goal() {
2011
+ return goal;
2012
+ },
2013
+ set goal(v) {
2014
+ const foldersBefore = JSON.stringify(goal?.record.context ?? []);
2015
+ goal = v;
2016
+ if (JSON.stringify(goal?.record.context ?? []) !== foldersBefore) {
2017
+ retargetGit().catch((err) => {
2018
+ log("WARN", "app-sidecar", "goal git retarget failed", {
2019
+ message: err instanceof Error ? err.message : String(err),
2020
+ });
2021
+ });
2022
+ }
2023
+ },
1882
2024
  broadcast,
1883
2025
  get scheduledRunActive() {
1884
2026
  return scheduledRunActive;
@@ -2036,6 +2178,8 @@ async function createSession(deps, opts) {
2036
2178
  return;
2037
2179
  if (handleWorkspaceRoutes(routeCtx, req, res, url, method))
2038
2180
  return;
2181
+ if (handleGoalRoutes(routeCtx, req, res, url, method))
2182
+ return;
2039
2183
  if (handleHistoryRoutes(routeCtx, req, res, url, method))
2040
2184
  return;
2041
2185
  if (handlePromptRoutes(routeCtx, req, res, url, method))
@@ -2053,6 +2197,12 @@ async function createSession(deps, opts) {
2053
2197
  json(res, 404, { error: "not found" });
2054
2198
  }
2055
2199
  async function dispose() {
2200
+ // A window closed (or switched project) mid-run: stop the run itself, any
2201
+ // run-all sweep or autopilot turn, and a model download this window
2202
+ // started. Otherwise they keep streaming tokens and running tools on a
2203
+ // session that is being torn down.
2204
+ abortOwnedWork();
2205
+ cancelHfPull();
2056
2206
  releaseRunAwake?.();
2057
2207
  releaseRunAwake = null;
2058
2208
  releaseSubagentAwake?.();
@@ -2062,10 +2212,8 @@ async function createSession(deps, opts) {
2062
2212
  tasksPollStopped = true;
2063
2213
  if (tasksPoll)
2064
2214
  clearTimeout(tasksPoll);
2065
- dirtyFilesPoll.unsubscribe();
2066
- gitHubCountsPoll?.unsubscribe();
2067
- ciPoll.unsubscribe();
2068
- healthPoll?.unsubscribe();
2215
+ repoWatchStopped = true;
2216
+ repoWatch.unsubscribe();
2069
2217
  // Stop the Telegram serve loop + dispose its per-chat sessions.
2070
2218
  if (serveController)
2071
2219
  await serveController.stop().catch(() => { });
@@ -2096,6 +2244,10 @@ async function createSession(deps, opts) {
2096
2244
  handle,
2097
2245
  dispose,
2098
2246
  stopBackgroundProcesses,
2247
+ isBusy: () => running ||
2248
+ runClaim.active ||
2249
+ autopilotActive ||
2250
+ session.listSubAgents().some((w) => w.state === "running" || w.state === "starting"),
2099
2251
  };
2100
2252
  }
2101
2253
  main().catch(async (err) => {