bosun 0.28.2 → 0.28.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.
package/README.md CHANGED
@@ -10,7 +10,7 @@ Bosun is a production-grade supervisor for AI coding agents. It routes tasks acr
10
10
  </p>
11
11
 
12
12
  <p align="center">
13
- <img src="site/social-banner.png" alt="bosun — AI agent supervisor" width="100%" />
13
+ <img src="site/social-card.png" alt="bosun — AI agent supervisor" width="100%" />
14
14
  </p>
15
15
 
16
16
  <p align="center">
package/monitor.mjs CHANGED
@@ -157,6 +157,7 @@ import { WorkspaceMonitor } from "./workspace-monitor.mjs";
157
157
  import { VkLogStream } from "./vk-log-stream.mjs";
158
158
  import { VKErrorResolver } from "./vk-error-resolver.mjs";
159
159
  import { createAnomalyDetector } from "./anomaly-detector.mjs";
160
+ import { resolvePwshRuntime } from "./pwsh-runtime.mjs";
160
161
  import {
161
162
  getWorktreeManager,
162
163
  acquireWorktree,
@@ -7555,12 +7556,21 @@ function buildPlannerTaskDescription({
7555
7556
  reason,
7556
7557
  numTasks,
7557
7558
  runtimeContext,
7559
+ userPrompt,
7558
7560
  }) {
7559
7561
  return [
7560
7562
  "## Task Planner — Auto-created by bosun",
7561
7563
  "",
7562
7564
  `**Trigger reason:** ${reason || "manual"}`,
7563
7565
  `**Requested task count:** ${numTasks}`,
7566
+ ...(userPrompt
7567
+ ? [
7568
+ "",
7569
+ "### User Planning Prompt",
7570
+ "",
7571
+ userPrompt,
7572
+ ]
7573
+ : []),
7564
7574
  "",
7565
7575
  "### Planner Prompt (Injected by bosun)",
7566
7576
  "",
@@ -8689,6 +8699,7 @@ async function triggerTaskPlanner(
8689
8699
  details,
8690
8700
  {
8691
8701
  taskCount,
8702
+ userPrompt,
8692
8703
  notify = true,
8693
8704
  preferredMode,
8694
8705
  allowCodexWhenDisabled = false,
@@ -8724,6 +8735,7 @@ async function triggerTaskPlanner(
8724
8735
  try {
8725
8736
  result = await triggerTaskPlannerViaKanban(reason, details, {
8726
8737
  taskCount,
8738
+ userPrompt,
8727
8739
  notify,
8728
8740
  });
8729
8741
  } catch (kanbanErr) {
@@ -8753,6 +8765,7 @@ async function triggerTaskPlanner(
8753
8765
  }
8754
8766
  result = await triggerTaskPlannerViaCodex(reason, details, {
8755
8767
  taskCount,
8768
+ userPrompt,
8756
8769
  notify,
8757
8770
  allowWhenDisabled: allowCodexWhenDisabled,
8758
8771
  });
@@ -8761,6 +8774,7 @@ async function triggerTaskPlanner(
8761
8774
  try {
8762
8775
  result = await triggerTaskPlannerViaCodex(reason, details, {
8763
8776
  taskCount,
8777
+ userPrompt,
8764
8778
  notify,
8765
8779
  allowWhenDisabled: allowCodexWhenDisabled,
8766
8780
  });
@@ -8784,6 +8798,7 @@ async function triggerTaskPlanner(
8784
8798
 
8785
8799
  result = await triggerTaskPlannerViaKanban(reason, details, {
8786
8800
  taskCount,
8801
+ userPrompt,
8787
8802
  notify,
8788
8803
  });
8789
8804
  }
@@ -8818,7 +8833,7 @@ async function triggerTaskPlanner(
8818
8833
  async function triggerTaskPlannerViaKanban(
8819
8834
  reason,
8820
8835
  details,
8821
- { taskCount, notify = true } = {},
8836
+ { taskCount, userPrompt, notify = true } = {},
8822
8837
  ) {
8823
8838
  const defaultPlannerTaskCount = Number(
8824
8839
  process.env.TASK_PLANNER_DEFAULT_COUNT || "30",
@@ -8844,12 +8859,15 @@ async function triggerTaskPlannerViaKanban(
8844
8859
  );
8845
8860
  }
8846
8861
 
8847
- const desiredTitle = `[${plannerTaskSizeLabel}] Plan next tasks (${reason || "backlog-empty"})`;
8862
+ const desiredTitle = userPrompt
8863
+ ? `[${plannerTaskSizeLabel}] Plan next tasks (${reason || "backlog-empty"}) — ${userPrompt.slice(0, 60)}${userPrompt.length > 60 ? "…" : ""}`
8864
+ : `[${plannerTaskSizeLabel}] Plan next tasks (${reason || "backlog-empty"})`;
8848
8865
  const desiredDescription = buildPlannerTaskDescription({
8849
8866
  plannerPrompt,
8850
8867
  reason,
8851
8868
  numTasks,
8852
8869
  runtimeContext,
8870
+ userPrompt,
8853
8871
  });
8854
8872
 
8855
8873
  // Check for existing planner tasks to avoid duplicates
@@ -8941,7 +8959,7 @@ async function triggerTaskPlannerViaKanban(
8941
8959
  async function triggerTaskPlannerViaCodex(
8942
8960
  reason,
8943
8961
  details,
8944
- { taskCount, notify = true, allowWhenDisabled = false } = {},
8962
+ { taskCount, userPrompt, notify = true, allowWhenDisabled = false } = {},
8945
8963
  ) {
8946
8964
  if (!codexEnabled && !allowWhenDisabled) {
8947
8965
  throw new Error(
@@ -8973,6 +8991,17 @@ async function triggerTaskPlannerViaCodex(
8973
8991
  "## Execution Context",
8974
8992
  `- Trigger reason: ${reason || "manual"}`,
8975
8993
  `- Requested task count: ${numTasks}`,
8994
+ ...(userPrompt
8995
+ ? [
8996
+ "",
8997
+ "## User Planning Prompt",
8998
+ "",
8999
+ userPrompt,
9000
+ "",
9001
+ "Incorporate the above user prompt into any relevant planning decisions.",
9002
+ ]
9003
+ : []),
9004
+ "",
8976
9005
  "Context JSON:",
8977
9006
  "```json",
8978
9007
  safeJsonBlock(runtimeContext),
@@ -10590,21 +10619,16 @@ async function startProcess() {
10590
10619
  let orchestratorArgs = [...scriptArgs];
10591
10620
 
10592
10621
  if (scriptLower.endsWith(".ps1")) {
10593
- const configuredPwsh = String(process.env.PWSH_PATH || "").trim();
10594
- const bundledPwsh = resolve(__dirname, ".cache", "bosun", "pwsh", "pwsh");
10595
- const bundledPwshExists = existsSync(bundledPwsh);
10596
- const pwshCmd = configuredPwsh || (bundledPwshExists ? bundledPwsh : "pwsh");
10597
- const pwshExists = configuredPwsh
10598
- ? configuredPwsh.includes("/") || configuredPwsh.includes("\\")
10599
- ? existsSync(configuredPwsh)
10600
- : commandExists(configuredPwsh)
10601
- : bundledPwshExists || commandExists("pwsh");
10602
- if (!pwshExists) {
10603
- const pwshLabel = configuredPwsh
10604
- ? `PWSH_PATH (${configuredPwsh})`
10605
- : bundledPwshExists
10606
- ? `bundled pwsh (${bundledPwsh})`
10607
- : "pwsh on PATH";
10622
+ const pwshRuntime = resolvePwshRuntime({ preferBundled: true });
10623
+ if (!pwshRuntime.exists) {
10624
+ const pwshLabel =
10625
+ pwshRuntime.source === "env"
10626
+ ? `PWSH_PATH (${pwshRuntime.command})`
10627
+ : pwshRuntime.source === "bundled"
10628
+ ? `bundled pwsh (${pwshRuntime.command})`
10629
+ : pwshRuntime.source === "powershell"
10630
+ ? `powershell on PATH`
10631
+ : "pwsh on PATH";
10608
10632
  const pauseMs = Math.max(orchestratorPauseMs, 60_000);
10609
10633
  const pauseMin = Math.max(1, Math.round(pauseMs / 60_000));
10610
10634
  monitorSafeModeUntil = Math.max(monitorSafeModeUntil, Date.now() + pauseMs);
@@ -10622,7 +10646,7 @@ async function startProcess() {
10622
10646
  setTimeout(startProcess, pauseMs);
10623
10647
  return;
10624
10648
  }
10625
- orchestratorCmd = pwshCmd;
10649
+ orchestratorCmd = pwshRuntime.command;
10626
10650
  orchestratorArgs = ["-File", scriptPath, ...scriptArgs];
10627
10651
  } else if (scriptLower.endsWith(".sh")) {
10628
10652
  const shellCmd =
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.28.2",
3
+ "version": "0.28.3",
4
4
  "description": "AI-powered orchestrator supervisor — manages AI agent executors with failover, auto-restarts on failure, analyzes crashes with Codex SDK, creates PRs via Vibe-Kanban API, and sends Telegram notifications. Supports N executors with weighted distribution, multi-repo projects, and auto-setup.",
5
5
  "type": "module",
6
6
  "license": "Apache 2.0",
@@ -152,6 +152,7 @@
152
152
  "sdk-conflict-resolver.mjs",
153
153
  "session-tracker.mjs",
154
154
  "setup.mjs",
155
+ "pwsh-runtime.mjs",
155
156
  "shared-knowledge.mjs",
156
157
  "shared-state-manager.mjs",
157
158
  "shared-workspace-cli.mjs",
package/preflight.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { resolve } from "node:path";
3
3
  import os from "node:os";
4
+ import { resolvePwshRuntime } from "./pwsh-runtime.mjs";
4
5
 
5
6
  const isWindows = process.platform === "win32";
6
7
  const MIN_FREE_GB = Number(process.env.BOSUN_MIN_FREE_GB || "10");
@@ -179,6 +180,7 @@ function checkToolchain() {
179
180
  "node",
180
181
  shellMode ? "shell" : "pwsh",
181
182
  ]);
183
+ const pwshRuntime = resolvePwshRuntime({ preferBundled: true });
182
184
 
183
185
  const tools = [
184
186
  checkToolVersion(
@@ -213,7 +215,7 @@ function checkToolchain() {
213
215
  ),
214
216
  checkToolVersion(
215
217
  "pwsh",
216
- "pwsh",
218
+ pwshRuntime.command,
217
219
  ["-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"],
218
220
  "Install PowerShell 7+ (pwsh) and ensure it is on PATH.",
219
221
  ),
@@ -0,0 +1,62 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { execSync } from "node:child_process";
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const BUNDLED_PWSH_PATH = resolve(__dirname, ".cache", "bosun", "pwsh", "pwsh");
8
+
9
+ function commandExists(cmd) {
10
+ try {
11
+ execSync(`${process.platform === "win32" ? "where" : "which"} ${cmd}`, {
12
+ stdio: "ignore",
13
+ });
14
+ return true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ function isPathLike(value) {
21
+ return value.includes("/") || value.includes("\\");
22
+ }
23
+
24
+ export function resolvePwshRuntime({ preferBundled = true } = {}) {
25
+ const configured = String(process.env.PWSH_PATH || "").trim();
26
+ if (configured) {
27
+ if (isPathLike(configured)) {
28
+ if (existsSync(configured)) {
29
+ return { command: configured, source: "env", exists: true };
30
+ }
31
+ return { command: configured, source: "env", exists: false };
32
+ }
33
+ if (commandExists(configured)) {
34
+ return { command: configured, source: "env", exists: true };
35
+ }
36
+ return { command: configured, source: "env", exists: false };
37
+ }
38
+
39
+ if (preferBundled && existsSync(BUNDLED_PWSH_PATH)) {
40
+ return { command: BUNDLED_PWSH_PATH, source: "bundled", exists: true };
41
+ }
42
+
43
+ if (commandExists("pwsh")) {
44
+ return { command: "pwsh", source: "path", exists: true };
45
+ }
46
+
47
+ if (process.platform === "win32" && commandExists("powershell")) {
48
+ return { command: "powershell", source: "powershell", exists: true };
49
+ }
50
+
51
+ return { command: "pwsh", source: "missing", exists: false };
52
+ }
53
+
54
+ export function resolvePwshCommand(options = {}) {
55
+ return resolvePwshRuntime(options).command;
56
+ }
57
+
58
+ export function hasPwshRuntime(options = {}) {
59
+ return resolvePwshRuntime(options).exists;
60
+ }
61
+
62
+ export { BUNDLED_PWSH_PATH };
package/setup.mjs CHANGED
@@ -22,6 +22,7 @@
22
22
 
23
23
  import { createInterface } from "node:readline";
24
24
  import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
25
+ import { homedir } from "node:os";
25
26
  import { resolve, dirname, basename, relative, isAbsolute } from "node:path";
26
27
  import { execSync } from "node:child_process";
27
28
  import { execFileSync } from "node:child_process";
@@ -654,6 +655,18 @@ function detectRepoSlug(cwd) {
654
655
  }
655
656
  }
656
657
 
658
+ function detectRepoRemoteUrl(cwd) {
659
+ try {
660
+ return execSync("git remote get-url origin", {
661
+ encoding: "utf8",
662
+ cwd: cwd || process.cwd(),
663
+ stdio: ["pipe", "pipe", "ignore"],
664
+ }).trim();
665
+ } catch {
666
+ return "";
667
+ }
668
+ }
669
+
657
670
  function detectRepoRoot(cwd) {
658
671
  try {
659
672
  return execSync("git rev-parse --show-toplevel", {
@@ -679,6 +692,36 @@ function detectProjectName(repoRoot) {
679
692
  return basename(repoRoot);
680
693
  }
681
694
 
695
+ function hasSshKeyMaterial() {
696
+ if (process.env.SSH_AUTH_SOCK) return true;
697
+ const home = homedir();
698
+ if (!home) return false;
699
+ const candidates = [
700
+ ".ssh/id_rsa.pub",
701
+ ".ssh/id_ed25519.pub",
702
+ ".ssh/id_ecdsa.pub",
703
+ ".ssh/id_dsa.pub",
704
+ ];
705
+ return candidates.some((rel) => existsSync(resolve(home, rel)));
706
+ }
707
+
708
+ function isSshGitUrl(value) {
709
+ const text = String(value || "").trim();
710
+ if (!text) return false;
711
+ return text.startsWith("git@") || text.startsWith("ssh://");
712
+ }
713
+
714
+ function buildDefaultGitUrl(slug, repoRoot) {
715
+ const normalizedSlug = String(slug || "").trim();
716
+ if (!normalizedSlug) return "";
717
+ const remote = detectRepoRemoteUrl(repoRoot);
718
+ if (remote) return remote;
719
+ const preferSsh = hasSshKeyMaterial();
720
+ return preferSsh
721
+ ? `git@github.com:${normalizedSlug}.git`
722
+ : `https://github.com/${normalizedSlug}.git`;
723
+ }
724
+
682
725
  function formatModelVariant(profile) {
683
726
  if (!profile?.model && !profile?.variant) return "";
684
727
  if (profile?.model && profile?.variant) {
@@ -2222,10 +2265,27 @@ async function main() {
2222
2265
  let repoIdx = 0;
2223
2266
 
2224
2267
  while (addMoreRepos) {
2225
- const repoUrl = await prompt.ask(
2268
+ let repoUrl = await prompt.ask(
2226
2269
  ` Repo ${repoIdx + 1} — git URL (SSH or HTTPS)`,
2227
- repoIdx === 0 ? (env.GITHUB_REPO ? `git@github.com:${env.GITHUB_REPO}.git` : "") : "",
2270
+ repoIdx === 0
2271
+ ? buildDefaultGitUrl(env.GITHUB_REPO || slug, repoRoot)
2272
+ : "",
2228
2273
  );
2274
+ if (repoUrl && isSshGitUrl(repoUrl) && !hasSshKeyMaterial()) {
2275
+ warn(
2276
+ "SSH URL detected but no SSH agent/keys found. Cloning may fail unless SSH is configured.",
2277
+ );
2278
+ const switchToHttps = await prompt.confirm(
2279
+ "Use HTTPS URL instead?",
2280
+ true,
2281
+ );
2282
+ if (switchToHttps) {
2283
+ const parsedSlug = parseRepoSlugFromUrl(repoUrl);
2284
+ if (parsedSlug) {
2285
+ repoUrl = `https://github.com/${parsedSlug}.git`;
2286
+ }
2287
+ }
2288
+ }
2229
2289
  const parsedSlug = parseRepoSlugFromUrl(repoUrl);
2230
2290
  const parsedRepoName = parsedSlug ? parsedSlug.split("/")[1] : "";
2231
2291
  const defaultNameFromUrl = repoUrl
@@ -2872,11 +2932,12 @@ async function main() {
2872
2932
  if (ghStatus.ok) break;
2873
2933
 
2874
2934
  warn(
2875
- "GitHub auth is required to auto-detect projects, create boards, and sync issues.",
2935
+ `GitHub auth is required to auto-detect projects, create boards, and sync issues. ${ghStatus.reason || ""}`.trim(),
2876
2936
  );
2877
2937
  info(
2878
2938
  "If you do not plan to use GitHub as the task manager, pick Internal, Jira, or Vibe-Kanban.",
2879
2939
  );
2940
+ info("Authenticate with GitHub using: gh auth login");
2880
2941
  const ghActionIdx = await prompt.choose(
2881
2942
  "How do you want to proceed?",
2882
2943
  [
package/telegram-bot.mjs CHANGED
@@ -59,6 +59,7 @@ import {
59
59
  getWorktreeStats,
60
60
  } from "./worktree-manager.mjs";
61
61
  import { loadExecutorConfig } from "./config.mjs";
62
+ import { resolvePwshRuntime } from "./pwsh-runtime.mjs";
62
63
  import {
63
64
  getTelegramUiUrl,
64
65
  startTelegramUiServer,
@@ -2701,7 +2702,7 @@ const COMMANDS = {
2701
2702
  },
2702
2703
  "/plan": {
2703
2704
  handler: cmdPlan,
2704
- desc: "Trigger task planner: /plan [count] (default 5)",
2705
+ desc: "Trigger task planner: /plan [count] [prompt] (e.g. /plan 5 fix auth bugs)",
2705
2706
  },
2706
2707
  "/cleanup": {
2707
2708
  handler: cmdCleanupMerged,
@@ -3237,9 +3238,24 @@ const UI_INPUT_HANDLERS = {
3237
3238
  buildCommand: (input) => `/shell ${input}`,
3238
3239
  },
3239
3240
  plan_count: {
3240
- prompt: "How many tasks should the planner generate?",
3241
+ prompt: "How many tasks should the planner generate? (e.g. 5)",
3241
3242
  buildCommand: (input) => `/plan ${input}`,
3242
3243
  },
3244
+ plan_prompt: {
3245
+ prompt:
3246
+ "Describe what you want the planner to focus on.\n" +
3247
+ "You can prefix with a count: e.g. '10 fix auth bugs and add tests'\n" +
3248
+ "Or just a topic: 'improve error handling across API layer'",
3249
+ buildCommand: (input) => {
3250
+ const trimmed = input.trim();
3251
+ const firstWord = trimmed.split(/\s+/)[0];
3252
+ const maybeCount = parseInt(firstWord, 10);
3253
+ if (Number.isFinite(maybeCount) && maybeCount > 0) {
3254
+ return `/plan ${trimmed}`;
3255
+ }
3256
+ return `/plan 5 ${trimmed}`;
3257
+ },
3258
+ },
3243
3259
  starttask: {
3244
3260
  prompt:
3245
3261
  "Enter the task ID to start manually.\nNext you'll pick executor → SDK → model.",
@@ -4418,7 +4434,10 @@ Object.assign(UI_SCREENS, {
4418
4434
  uiButton("Plan 5", uiCmdAction("/plan 5")),
4419
4435
  uiButton("Plan 10", uiCmdAction("/plan 10")),
4420
4436
  ],
4421
- [uiButton("Custom Count", uiInputAction("plan_count"))],
4437
+ [
4438
+ uiButton("Custom Count", uiInputAction("plan_count")),
4439
+ uiButton("With Prompt", uiInputAction("plan_prompt")),
4440
+ ],
4422
4441
  uiNavRow("tasks"),
4423
4442
  ]),
4424
4443
  },
@@ -7097,11 +7116,26 @@ async function cmdPlan(chatId, args) {
7097
7116
  return;
7098
7117
  }
7099
7118
 
7100
- // Parse optional task count: /plan 5 or /plan 10
7101
- const parsed = parseInt(args?.trim(), 10);
7102
- const taskCount = Number.isFinite(parsed) && parsed > 0 ? parsed : 5;
7119
+ // Parse optional task count and/or free-form prompt:
7120
+ // /plan → 5 tasks, no prompt
7121
+ // /plan 10 → 10 tasks, no prompt
7122
+ // /plan fix auth → 5 tasks, userPrompt="fix auth"
7123
+ // /plan 10 fix auth → 10 tasks, userPrompt="fix auth"
7124
+ const rawArgs = (args || "").trim();
7125
+ const firstToken = rawArgs.split(/\s+/)[0];
7126
+ const parsedCount = parseInt(firstToken, 10);
7127
+ let taskCount = 5;
7128
+ let userPrompt;
7129
+ if (Number.isFinite(parsedCount) && parsedCount > 0) {
7130
+ taskCount = parsedCount;
7131
+ const remainder = rawArgs.slice(firstToken.length).trim();
7132
+ if (remainder) userPrompt = remainder;
7133
+ } else if (rawArgs) {
7134
+ userPrompt = rawArgs;
7135
+ }
7103
7136
 
7104
- await sendReply(chatId, `📋 Triggering task planner (${taskCount} tasks)...`);
7137
+ const promptSuffix = userPrompt ? ` — "${userPrompt.slice(0, 60)}${userPrompt.length > 60 ? "…" : ""}"` : "";
7138
+ await sendReply(chatId, `📋 Triggering task planner (${taskCount} tasks${promptSuffix})...`);
7105
7139
 
7106
7140
  try {
7107
7141
  const result = await _triggerTaskPlanner(
@@ -7109,6 +7143,7 @@ async function cmdPlan(chatId, args) {
7109
7143
  { source: "telegram /plan command" },
7110
7144
  {
7111
7145
  taskCount,
7146
+ userPrompt,
7112
7147
  notify: false,
7113
7148
  preferredMode: "codex-sdk",
7114
7149
  allowCodexWhenDisabled: true,
@@ -7398,7 +7433,9 @@ async function cmdShell(chatId, shellArgs) {
7398
7433
 
7399
7434
  function runPwsh(psScript, timeoutMs = 15000) {
7400
7435
  const isWin = process.platform === "win32";
7401
- const pwsh = isWin ? "powershell.exe" : "pwsh";
7436
+ const pwsh = isWin
7437
+ ? "powershell.exe"
7438
+ : resolvePwshRuntime({ preferBundled: true }).command;
7402
7439
  const script = `& { ${psScript} }`;
7403
7440
  const result = spawnSync(pwsh, ["-NoProfile", "-Command", script], {
7404
7441
  cwd: repoRoot,
@@ -83,6 +83,8 @@ export function ControlTab() {
83
83
  const [tasksLoading, setTasksLoading] = useState(false);
84
84
  const [startTaskError, setStartTaskError] = useState("");
85
85
  const [retryTaskError, setRetryTaskError] = useState("");
86
+ const [planPrompt, setPlanPrompt] = useState("");
87
+ const [planCount, setPlanCount] = useState("5");
86
88
  const startTaskIdRef = useRef("");
87
89
  const retryTaskIdRef = useRef("");
88
90
 
@@ -844,9 +846,52 @@ export function ControlTab() {
844
846
  >
845
847
  Retry Task
846
848
  </button>
847
- <button class="btn btn-ghost btn-sm" onClick=${() => sendCmd("/plan")}>
848
- 📋 Plan
849
- </button>
849
+ <div class="form-group" style="margin-top:0.5rem">
850
+ <div class="card-subtitle" style="margin-bottom:0.25rem">Task Planner</div>
851
+ <div class="input-row" style="display:flex;gap:0.4rem;align-items:center;flex-wrap:wrap">
852
+ <input
853
+ type="number"
854
+ class="input"
855
+ style="width:4.5rem;flex-shrink:0"
856
+ min="1"
857
+ max="50"
858
+ placeholder="5"
859
+ value=${planCount}
860
+ onInput=${(e) => setPlanCount(e.target.value)}
861
+ title="Number of tasks to generate"
862
+ />
863
+ <input
864
+ class="input"
865
+ style="flex:1;min-width:10rem"
866
+ placeholder="Optional: focus on X, fix Y issues…"
867
+ value=${planPrompt}
868
+ onInput=${(e) => setPlanPrompt(e.target.value)}
869
+ onKeyDown=${(e) => {
870
+ if (e.key === "Enter") {
871
+ const count = parseInt(planCount, 10);
872
+ const n = Number.isFinite(count) && count > 0 ? count : 5;
873
+ const cmd = planPrompt.trim()
874
+ ? `/plan ${n} ${planPrompt.trim()}`
875
+ : `/plan ${n}`;
876
+ sendCmd(cmd);
877
+ }
878
+ }}
879
+ />
880
+ <button
881
+ class="btn btn-ghost btn-sm"
882
+ onClick=${() => {
883
+ const count = parseInt(planCount, 10);
884
+ const n = Number.isFinite(count) && count > 0 ? count : 5;
885
+ const cmd = planPrompt.trim()
886
+ ? `/plan ${n} ${planPrompt.trim()}`
887
+ : `/plan ${n}`;
888
+ sendCmd(cmd);
889
+ }}
890
+ >
891
+ 📋 Plan
892
+ </button>
893
+ </div>
894
+ </div>
850
895
  </div>
851
896
  ${retryTaskError
852
897
  ? html`<div class="form-hint error">${retryTaskError}</div>`
@@ -432,12 +432,24 @@ export function pullWorkspaceRepos(configDir, workspaceId) {
432
432
  stdio: ["pipe", "pipe", "pipe"],
433
433
  });
434
434
  if (clone.status !== 0) {
435
+ const stderr = String(clone.stderr || clone.stdout || "");
436
+ let hint = "";
437
+ if (/permission denied \(publickey\)/i.test(stderr)) {
438
+ hint =
439
+ "SSH auth failed. Configure SSH keys or use an HTTPS URL instead.";
440
+ } else if (/authentication failed|fatal: authentication failed/i.test(stderr)) {
441
+ hint =
442
+ "HTTPS auth failed. Use a PAT/credential helper or switch to SSH.";
443
+ } else if (/repository .* not found|not found/i.test(stderr)) {
444
+ hint =
445
+ "Repository not found or access denied. Verify the org/repo and permissions.";
446
+ }
435
447
  results.push({
436
448
  name: repo.name,
437
449
  success: false,
438
- error: `git clone failed: ${
439
- clone.stderr || clone.stdout || clone.error?.message || "unknown error"
440
- }`,
450
+ error: `git clone failed (${repoUrl}): ${
451
+ stderr || clone.error?.message || "unknown error"
452
+ }${hint ? ` — ${hint}` : ""}`,
441
453
  });
442
454
  continue;
443
455
  }
@@ -446,18 +458,52 @@ export function pullWorkspaceRepos(configDir, workspaceId) {
446
458
  results.push({
447
459
  name: repo.name,
448
460
  success: false,
449
- error: `git clone failed: ${err.message || err}`,
461
+ error: `git clone failed (${repoUrl}): ${err.message || err}`,
450
462
  });
451
463
  continue;
452
464
  }
453
465
  }
454
- if (!existsSync(resolve(repoPath, ".git"))) {
455
- results.push({
456
- name: repo.name,
457
- success: false,
458
- error: "Directory exists but is not a git repository",
459
- });
460
- continue;
466
+ const gitDir = resolve(repoPath, ".git");
467
+ if (!existsSync(gitDir)) {
468
+ try {
469
+ const contents = existsSync(repoPath) ? readdirSync(repoPath) : [];
470
+ const isEmpty = contents.length === 0;
471
+ const repoUrl =
472
+ repo.url ||
473
+ (repo.slug ? `https://github.com/${repo.slug.replace(/\.git$/i, "")}.git` : "");
474
+ if (isEmpty && repoUrl) {
475
+ console.log(TAG, `Cloning ${repoUrl} into existing empty directory ${repoPath}...`);
476
+ const clone = spawnSync("git", ["clone", repoUrl, "."], {
477
+ encoding: "utf8",
478
+ timeout: 300000,
479
+ stdio: ["pipe", "pipe", "pipe"],
480
+ cwd: repoPath,
481
+ });
482
+ if (clone.status !== 0) {
483
+ const stderr = String(clone.stderr || clone.stdout || "");
484
+ results.push({
485
+ name: repo.name,
486
+ success: false,
487
+ error: `git clone failed (${repoUrl}): ${stderr || clone.error?.message || "unknown error"}`,
488
+ });
489
+ continue;
490
+ }
491
+ } else {
492
+ results.push({
493
+ name: repo.name,
494
+ success: false,
495
+ error: "Directory exists but is not a git repository",
496
+ });
497
+ continue;
498
+ }
499
+ } catch (err) {
500
+ results.push({
501
+ name: repo.name,
502
+ success: false,
503
+ error: `Directory check failed: ${err.message || err}`,
504
+ });
505
+ continue;
506
+ }
461
507
  }
462
508
  try {
463
509
  execSync("git pull --rebase", {