bosun 0.27.0 → 0.27.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/autofix.mjs CHANGED
@@ -28,7 +28,7 @@
28
28
  */
29
29
 
30
30
  import { spawn, execSync } from "node:child_process";
31
- import { existsSync, mkdirSync, createWriteStream } from "node:fs";
31
+ import { existsSync, mkdirSync, createWriteStream, readFileSync } from "node:fs";
32
32
  import { readFile, writeFile } from "node:fs/promises";
33
33
  import { resolve, dirname } from "node:path";
34
34
  import { fileURLToPath } from "node:url";
package/bosun.schema.json CHANGED
@@ -221,6 +221,53 @@
221
221
  "type": "string",
222
222
  "enum": ["codex-sdk", "kanban", "disabled"]
223
223
  },
224
+ "activeWorkspace": {
225
+ "type": "string",
226
+ "description": "ID of the currently active workspace"
227
+ },
228
+ "workspaces": {
229
+ "type": "array",
230
+ "description": "Multi-repo workspace definitions",
231
+ "items": {
232
+ "type": "object",
233
+ "additionalProperties": true,
234
+ "required": ["id", "name"],
235
+ "properties": {
236
+ "id": {
237
+ "type": "string",
238
+ "description": "Unique workspace identifier (lowercase, alphanumeric, dashes)"
239
+ },
240
+ "name": {
241
+ "type": "string",
242
+ "description": "Human-readable workspace name"
243
+ },
244
+ "repos": {
245
+ "type": "array",
246
+ "description": "Repositories in this workspace",
247
+ "items": {
248
+ "type": "object",
249
+ "additionalProperties": true,
250
+ "required": ["name"],
251
+ "properties": {
252
+ "name": { "type": "string", "description": "Repository directory name" },
253
+ "url": { "type": "string", "description": "Git clone URL" },
254
+ "slug": { "type": "string", "description": "GitHub slug (org/repo)" },
255
+ "primary": { "type": "boolean", "description": "Whether this is the primary repo" },
256
+ "branch": { "type": "string", "description": "Default branch to track" }
257
+ }
258
+ }
259
+ },
260
+ "activeRepo": {
261
+ "type": ["string", "null"],
262
+ "description": "Currently active repository name within the workspace"
263
+ },
264
+ "createdAt": {
265
+ "type": "string",
266
+ "description": "ISO 8601 creation timestamp"
267
+ }
268
+ }
269
+ }
270
+ },
224
271
  "defaultRepository": { "type": "string" },
225
272
  "repositoryDefaults": { "$ref": "#/$defs/repositoryDefaults" },
226
273
  "repositories": {
package/cli.mjs CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  mkdirSync,
25
25
  } from "node:fs";
26
26
  import { fileURLToPath } from "node:url";
27
- import { fork, spawn } from "node:child_process";
27
+ import { execFileSync, fork, spawn } from "node:child_process";
28
28
  import os from "node:os";
29
29
  import { createDaemonCrashTracker } from "./daemon-restart-policy.mjs";
30
30
  import {
@@ -109,6 +109,12 @@ function showHelp() {
109
109
  CONTAINER_ENABLED=1 Enable container isolation for agent execution
110
110
  CONTAINER_RUNTIME=docker Runtime to use (docker|podman|container)
111
111
 
112
+ WORKSPACES
113
+ --workspace-list List configured workspaces
114
+ --workspace-add <name> Create a new workspace
115
+ --workspace-switch <id> Switch active workspace
116
+ --workspace-add-repo Add repo to workspace (interactive)
117
+
112
118
  VIBE-KANBAN
113
119
  --no-vk-spawn Don't auto-spawn Vibe-Kanban
114
120
  --vk-ensure-interval <ms> VK health check interval (default: 60000)
@@ -329,7 +335,6 @@ function getDaemonPid() {
329
335
  if (!existsSync(PID_FILE)) return null;
330
336
  const pid = parseInt(readFileSync(PID_FILE, "utf8").trim(), 10);
331
337
  if (isNaN(pid)) return null;
332
- // Check if process is alive
333
338
  try {
334
339
  process.kill(pid, 0);
335
340
  return pid;
@@ -341,6 +346,28 @@ function getDaemonPid() {
341
346
  }
342
347
  }
343
348
 
349
+ /**
350
+ * Scan for ghost bosun daemon-child processes that are alive but have no PID
351
+ * file (e.g. PID file was removed by compat migration). Uses pgrep on Linux/Mac.
352
+ * Returns an array of PIDs (may be empty).
353
+ */
354
+ function findGhostDaemonPids() {
355
+ if (process.platform === "win32") return [];
356
+ try {
357
+ const out = execFileSync(
358
+ "pgrep",
359
+ ["-f", "bosun.*--daemon-child|cli\.mjs.*--daemon-child"],
360
+ { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 3000 },
361
+ ).trim();
362
+ return out
363
+ .split("\n")
364
+ .map((s) => parseInt(s.trim(), 10))
365
+ .filter((n) => Number.isFinite(n) && n > 0 && n !== process.pid);
366
+ } catch {
367
+ return [];
368
+ }
369
+ }
370
+
344
371
  function writePidFile(pid) {
345
372
  try {
346
373
  mkdirSync(dirname(PID_FILE), { recursive: true });
@@ -366,6 +393,28 @@ function startDaemon() {
366
393
  process.exit(1);
367
394
  }
368
395
 
396
+ // Check for ghost processes that have no PID file (e.g. after compat migration
397
+ // deleted the old codex-monitor directory and its PID file with it).
398
+ const ghosts = findGhostDaemonPids();
399
+ if (ghosts.length > 0) {
400
+ console.log(` ⚠️ Found ${ghosts.length} ghost bosun daemon process(es) with no PID file: ${ghosts.join(", ")}`);
401
+ console.log(` Stopping ghost process(es) before starting fresh...`);
402
+ for (const gpid of ghosts) {
403
+ try { process.kill(gpid, "SIGTERM"); } catch { /* already dead */ }
404
+ }
405
+ // Give them a moment to exit
406
+ const deadline = Date.now() + 3000;
407
+ let alive = ghosts.filter((p) => isProcessAlive(p));
408
+ while (alive.length > 0 && Date.now() < deadline) {
409
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200);
410
+ alive = alive.filter((p) => isProcessAlive(p));
411
+ }
412
+ for (const gpid of alive) {
413
+ try { process.kill(gpid, "SIGKILL"); } catch { /* ok */ }
414
+ }
415
+ console.log(` ✅ Ghost process(es) stopped.`);
416
+ }
417
+
369
418
  // Ensure log directory exists
370
419
  try {
371
420
  mkdirSync(dirname(DAEMON_LOG), { recursive: true });
@@ -456,8 +505,16 @@ function daemonStatus() {
456
505
  if (pid) {
457
506
  console.log(` bosun daemon is running (PID ${pid})`);
458
507
  } else {
459
- console.log(" bosun daemon is not running.");
460
- removePidFile();
508
+ // Check for ghost processes (alive but no PID file)
509
+ const ghosts = findGhostDaemonPids();
510
+ if (ghosts.length > 0) {
511
+ console.log(` ⚠️ bosun daemon is NOT tracked (no PID file), but ${ghosts.length} ghost process(es) found: ${ghosts.join(", ")}`);
512
+ console.log(` The daemon is likely running but its PID file was lost.`);
513
+ console.log(` Run --stop-daemon to clean up, then --daemon to restart.`);
514
+ } else {
515
+ console.log(" bosun daemon is not running.");
516
+ removePidFile();
517
+ }
461
518
  }
462
519
  process.exit(0);
463
520
  }
@@ -667,6 +724,97 @@ async function main() {
667
724
  // agent sessions that happen to have hook config files in their tree.
668
725
  process.env.VE_MANAGED = "1";
669
726
 
727
+ // Handle workspace commands
728
+ if (args.includes("--workspace-list") || args.includes("workspace-list")) {
729
+ const { listWorkspaces, getActiveWorkspace } = await import("./workspace-manager.mjs");
730
+ const configDirArg = getArgValue("--config-dir");
731
+ const configDir = configDirArg || process.env.BOSUN_DIR || resolve(os.homedir(), "bosun");
732
+ const workspaces = listWorkspaces(configDir);
733
+ const active = getActiveWorkspace(configDir);
734
+ if (workspaces.length === 0) {
735
+ console.log("\n No workspaces configured. Run 'bosun --setup' to create one.\n");
736
+ } else {
737
+ console.log("\n Workspaces:");
738
+ for (const ws of workspaces) {
739
+ const marker = ws.id === active?.id ? " ← active" : "";
740
+ console.log(` ${ws.name} (${ws.id})${marker}`);
741
+ for (const repo of ws.repos || []) {
742
+ const primary = repo.primary ? " [primary]" : "";
743
+ const exists = repo.exists ? "✓" : "✗";
744
+ console.log(` ${exists} ${repo.name} — ${repo.slug || repo.url || "local"}${primary}`);
745
+ }
746
+ }
747
+ console.log("");
748
+ }
749
+ process.exit(0);
750
+ }
751
+
752
+ if (args.includes("--workspace-add")) {
753
+ const { createWorkspace } = await import("./workspace-manager.mjs");
754
+ const configDirArg = getArgValue("--config-dir");
755
+ const configDir = configDirArg || process.env.BOSUN_DIR || resolve(os.homedir(), "bosun");
756
+ const name = getArgValue("--workspace-add");
757
+ if (!name) {
758
+ console.error(" Error: workspace name is required. Usage: bosun --workspace-add <name>");
759
+ process.exit(1);
760
+ }
761
+ try {
762
+ const ws = createWorkspace(configDir, { name });
763
+ console.log(`\n ✓ Workspace "${ws.name}" created at ${ws.path}\n`);
764
+ } catch (err) {
765
+ console.error(` Error: ${err.message}`);
766
+ process.exit(1);
767
+ }
768
+ process.exit(0);
769
+ }
770
+
771
+ if (args.includes("--workspace-switch")) {
772
+ const { setActiveWorkspace, getWorkspace } = await import("./workspace-manager.mjs");
773
+ const configDirArg = getArgValue("--config-dir");
774
+ const configDir = configDirArg || process.env.BOSUN_DIR || resolve(os.homedir(), "bosun");
775
+ const wsId = getArgValue("--workspace-switch");
776
+ if (!wsId) {
777
+ console.error(" Error: workspace ID required. Usage: bosun --workspace-switch <id>");
778
+ process.exit(1);
779
+ }
780
+ try {
781
+ setActiveWorkspace(configDir, wsId);
782
+ const ws = getWorkspace(configDir, wsId);
783
+ console.log(`\n ✓ Switched to workspace "${ws?.name || wsId}"\n`);
784
+ } catch (err) {
785
+ console.error(` Error: ${err.message}`);
786
+ process.exit(1);
787
+ }
788
+ process.exit(0);
789
+ }
790
+
791
+ if (args.includes("--workspace-add-repo")) {
792
+ const { addRepoToWorkspace, getActiveWorkspace, listWorkspaces } = await import("./workspace-manager.mjs");
793
+ const configDirArg = getArgValue("--config-dir");
794
+ const configDir = configDirArg || process.env.BOSUN_DIR || resolve(os.homedir(), "bosun");
795
+ const active = getActiveWorkspace(configDir);
796
+ if (!active) {
797
+ console.error(" No active workspace. Create one first: bosun --workspace-add <name>");
798
+ process.exit(1);
799
+ }
800
+ const url = getArgValue("--workspace-add-repo");
801
+ if (!url) {
802
+ console.error(" Error: repo URL required. Usage: bosun --workspace-add-repo <git-url>");
803
+ process.exit(1);
804
+ }
805
+ try {
806
+ console.log(` Cloning into workspace "${active.name}"...`);
807
+ const repo = addRepoToWorkspace(configDir, active.id, { url });
808
+ console.log(`\n ✓ Added repo "${repo.name}" to workspace "${active.name}"`);
809
+ if (repo.cloned) console.log(` Cloned to: ${repo.path}`);
810
+ console.log("");
811
+ } catch (err) {
812
+ console.error(` Error: ${err.message}`);
813
+ process.exit(1);
814
+ }
815
+ process.exit(0);
816
+ }
817
+
670
818
  // Handle --setup
671
819
  if (args.includes("--setup") || args.includes("setup")) {
672
820
  const configDirArg = getArgValue("--config-dir");
package/compat.mjs CHANGED
@@ -11,7 +11,7 @@
11
11
  * should be imported as early as possible in the startup path.
12
12
  */
13
13
 
14
- import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from "node:fs";
14
+ import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, rmSync } from "node:fs";
15
15
  import { resolve, join } from "node:path";
16
16
 
17
17
  // ── Legacy config file names accepted from old codex-monitor installations ───
@@ -255,12 +255,29 @@ function rewriteEnvContent(content) {
255
255
  }
256
256
 
257
257
  /**
258
- * If BOSUN_DIR is not set but CODEX_MONITOR_DIR is (or legacy dir exists),
259
- * transparently set BOSUN_DIR to point at the legacy dir so bosun reads
260
- * from it without requiring a migration step.
258
+ * Remove the legacy codex-monitor config directory after successful migration.
259
+ * Runs in a deferred setTimeout so it doesn't block startup.
260
+ */
261
+ function scheduleLegacyCleanup(legacyDir) {
262
+ if (!legacyDir || !existsSync(legacyDir)) return;
263
+ setTimeout(() => {
264
+ try {
265
+ rmSync(legacyDir, { recursive: true, force: true });
266
+ console.log(`[compat] Cleaned up legacy config directory: ${legacyDir}`);
267
+ } catch (err) {
268
+ console.warn(
269
+ `[compat] Could not remove legacy directory ${legacyDir}: ${err.message}`,
270
+ );
271
+ }
272
+ }, 5000);
273
+ }
274
+
275
+ /**
276
+ * If BOSUN_DIR is not set but a legacy codex-monitor dir exists,
277
+ * automatically migrate config to ~/bosun and set BOSUN_DIR to the new location.
278
+ * After successful migration, schedule legacy directory removal.
261
279
  *
262
- * This is the "zero-friction" path: existing users just upgrade the package and
263
- * it works. Migration is optional (improves going forward).
280
+ * Returns true if legacy dir was detected and migration was performed.
264
281
  */
265
282
  export function autoApplyLegacyDir() {
266
283
  // Already set — nothing to do
@@ -269,10 +286,39 @@ export function autoApplyLegacyDir() {
269
286
  const legacyDir = getLegacyConfigDir();
270
287
  if (!legacyDir) return false;
271
288
 
272
- process.env.BOSUN_DIR = legacyDir;
289
+ const newDir = getNewConfigDir();
290
+
291
+ // If new dir already has config, just use it (already migrated)
292
+ if (hasLegacyMarkers(newDir)) {
293
+ // Legacy dir still exists but new dir is set up — clean up legacy
294
+ scheduleLegacyCleanup(legacyDir);
295
+ return false;
296
+ }
297
+
298
+ // Perform migration
273
299
  console.log(
274
- `[compat] Legacy codex-monitor config detected at ${legacyDir} — using it as BOSUN_DIR.`,
300
+ `[compat] Legacy codex-monitor config detected at ${legacyDir} — migrating to ${newDir}...`,
275
301
  );
302
+ const result = migrateFromLegacy(legacyDir, newDir);
303
+
304
+ if (result.errors.length > 0) {
305
+ console.warn(
306
+ `[compat] Migration had errors: ${result.errors.join(", ")}`,
307
+ );
308
+ // Fall back to legacy dir if migration failed
309
+ process.env.BOSUN_DIR = legacyDir;
310
+ return true;
311
+ }
312
+
313
+ if (result.migrated.length > 0) {
314
+ console.log(
315
+ `[compat] Migrated ${result.migrated.length} files to ${newDir}: ${result.migrated.join(", ")}`,
316
+ );
317
+ }
318
+
319
+ // Schedule cleanup of legacy directory
320
+ scheduleLegacyCleanup(legacyDir);
321
+
276
322
  return true;
277
323
  }
278
324
 
package/config.mjs CHANGED
@@ -59,11 +59,6 @@ function isWslInteropRuntime() {
59
59
  }
60
60
 
61
61
  function resolveConfigDir(repoRoot) {
62
- const repoPath = resolve(repoRoot || process.cwd());
63
- const packageDir = resolve(__dirname);
64
- if (isPathInside(repoPath, packageDir) || hasConfigFiles(packageDir)) {
65
- return packageDir;
66
- }
67
62
  const preferWindowsDirs =
68
63
  process.platform === "win32" && !isWslInteropRuntime();
69
64
  const baseDir = preferWindowsDirs
@@ -737,6 +732,15 @@ export function loadConfig(argv = process.argv, options = {}) {
737
732
  let configData = configFile.data || {};
738
733
 
739
734
  const repoRootOverride = cli["repo-root"] || process.env.REPO_ROOT || "";
735
+
736
+ // Load workspace configuration
737
+ const workspacesDir = resolve(configDir, "workspaces");
738
+ const activeWorkspace = cli["workspace"] ||
739
+ process.env.BOSUN_WORKSPACE ||
740
+ configData.activeWorkspace ||
741
+ configData.defaultWorkspace ||
742
+ "";
743
+
740
744
  let repositories = loadRepoConfig(configDir, configData, {
741
745
  repoRootOverride,
742
746
  });
@@ -1626,9 +1630,11 @@ export function loadConfig(argv = process.argv, options = {}) {
1626
1630
  executorConfig,
1627
1631
  scheduler,
1628
1632
 
1629
- // Multi-repo
1633
+ // Multi-repo / Workspaces
1630
1634
  repositories,
1631
1635
  selectedRepository,
1636
+ workspacesDir,
1637
+ activeWorkspace,
1632
1638
 
1633
1639
  // Agent prompts
1634
1640
  agentPrompts,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.27.0",
3
+ "version": "0.27.2",
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",
@@ -43,6 +43,7 @@
43
43
  "./maintenance": "./maintenance.mjs",
44
44
  "./telegram-bot": "./telegram-bot.mjs",
45
45
  "./ui-server": "./ui-server.mjs",
46
+ "./workspace-manager": "./workspace-manager.mjs",
46
47
  "./workspace-registry": "./workspace-registry.mjs",
47
48
  "./shared-workspace-registry": "./shared-workspace-registry.mjs",
48
49
  "./workspace-reaper": "./workspace-reaper.mjs",
@@ -173,6 +174,7 @@
173
174
  "vibe-kanban-wrapper.mjs",
174
175
  "vk-error-resolver.mjs",
175
176
  "vk-log-stream.mjs",
177
+ "workspace-manager.mjs",
176
178
  "workspace-monitor.mjs",
177
179
  "workspace-reaper.mjs",
178
180
  "workspace-registry.mjs",
package/publish.mjs CHANGED
@@ -224,10 +224,8 @@ function main() {
224
224
  const status = run(NPM_BIN, publishArgs, env);
225
225
  if (status === 0 && !dryRun) {
226
226
  console.log(
227
- "\n[publish] REMINDER: deprecate the legacy npm package to redirect users:\n" +
228
- " npm deprecate bosun@'*' \"Renamed to bosun. Install: npm install -g bosun\"\n" +
229
- " # If a scoped legacy package exists:\n" +
230
- " npm deprecate bosun@'*' \"Renamed to bosun. Install: npm install -g bosun\"\n",
227
+ "\n[publish] :\n" +
228
+ " npm deprecate openfleet@'*' \"⚠️ openfleet has been renamed to bosun. Install the latest: npm install -g bosun\"\n",
231
229
  );
232
230
  }
233
231
  process.exit(status);
package/setup.mjs CHANGED
@@ -1815,52 +1815,130 @@ async function main() {
1815
1815
  );
1816
1816
  configJson.projectName = env.PROJECT_NAME;
1817
1817
 
1818
- // ── Step 3: Repository ─────────────────────────────────
1819
- heading("Step 3 of 9 — Repository Configuration");
1820
- const multiRepo = isAdvancedSetup
1821
- ? await prompt.confirm(
1822
- "Do you have multiple repositories (e.g. separate backend/frontend)?",
1823
- false,
1824
- )
1825
- : false;
1826
-
1827
- if (multiRepo) {
1828
- info("Configure each repository. The first is the primary.\n");
1829
- let addMore = true;
1830
- let repoIdx = 0;
1831
- while (addMore) {
1832
- const repoName = await prompt.ask(
1833
- ` Repo ${repoIdx + 1} — name`,
1834
- repoIdx === 0 ? basename(repoRoot) : "",
1835
- );
1836
- const repoPath = await prompt.ask(
1837
- ` Repo ${repoIdx + 1} local path`,
1838
- repoIdx === 0 ? repoRoot : "",
1839
- );
1840
- const repoSlug = await prompt.ask(
1841
- ` Repo ${repoIdx + 1} — GitHub slug`,
1842
- repoIdx === 0 ? env.GITHUB_REPO : "",
1818
+ // ── Step 3: Workspace & Repository ─────────────────────
1819
+ heading("Step 3 of 9 — Workspace & Repository Configuration");
1820
+
1821
+ const useWorkspaces = await prompt.confirm(
1822
+ "Set up multi-repo workspaces? (organizes repos into ~/bosun/workspaces/)",
1823
+ isAdvancedSetup,
1824
+ );
1825
+
1826
+ if (useWorkspaces) {
1827
+ info("Workspaces group related repositories together.\n");
1828
+ info(`Repositories will be cloned into: ${resolve(configDir, "workspaces")}\n`);
1829
+
1830
+ configJson.workspaces = [];
1831
+ let addMoreWs = true;
1832
+ let wsIdx = 0;
1833
+
1834
+ while (addMoreWs) {
1835
+ const wsName = await prompt.ask(
1836
+ ` Workspace ${wsIdx + 1} — name`,
1837
+ wsIdx === 0 ? projectName : "",
1843
1838
  );
1844
- configJson.repositories.push({
1845
- name: repoName,
1846
- path: repoPath,
1847
- slug: repoSlug,
1848
- primary: repoIdx === 0,
1839
+ const wsId = wsName.toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
1840
+
1841
+ const wsRepos = [];
1842
+ let addMoreRepos = true;
1843
+ let repoIdx = 0;
1844
+
1845
+ while (addMoreRepos) {
1846
+ const repoUrl = await prompt.ask(
1847
+ ` Repo ${repoIdx + 1} — git URL (SSH or HTTPS)`,
1848
+ repoIdx === 0 ? (env.GITHUB_REPO ? `git@github.com:${env.GITHUB_REPO}.git` : "") : "",
1849
+ );
1850
+ const defaultName = repoUrl
1851
+ ? (repoUrl.match(/[/:]([^/]+?)(?:\.git)?$/) || [])[1] || ""
1852
+ : "";
1853
+ const repoName = await prompt.ask(
1854
+ ` Repo ${repoIdx + 1} — directory name`,
1855
+ defaultName || (repoIdx === 0 ? basename(repoRoot) : ""),
1856
+ );
1857
+ const repoSlug = await prompt.ask(
1858
+ ` Repo ${repoIdx + 1} — GitHub slug (org/repo)`,
1859
+ repoIdx === 0 ? env.GITHUB_REPO : "",
1860
+ );
1861
+
1862
+ wsRepos.push({
1863
+ name: repoName,
1864
+ url: repoUrl,
1865
+ slug: repoSlug,
1866
+ primary: repoIdx === 0,
1867
+ });
1868
+ repoIdx++;
1869
+ addMoreRepos = await prompt.confirm(" Add another repo to this workspace?", false);
1870
+ }
1871
+
1872
+ configJson.workspaces.push({
1873
+ id: wsId,
1874
+ name: wsName,
1875
+ repos: wsRepos,
1876
+ createdAt: new Date().toISOString(),
1877
+ activeRepo: wsRepos[0]?.name || null,
1849
1878
  });
1850
- repoIdx++;
1851
- addMore = await prompt.confirm("Add another repository?", false);
1879
+
1880
+ // Also populate legacy repositories array for backward compat
1881
+ for (const repo of wsRepos) {
1882
+ configJson.repositories.push({
1883
+ name: repo.name,
1884
+ slug: repo.slug,
1885
+ primary: repo.primary,
1886
+ });
1887
+ }
1888
+
1889
+ wsIdx++;
1890
+ addMoreWs = await prompt.confirm("Add another workspace?", false);
1891
+ }
1892
+
1893
+ if (configJson.workspaces.length > 0) {
1894
+ configJson.activeWorkspace = configJson.workspaces[0].id;
1852
1895
  }
1853
1896
  } else {
1854
- // Single-repo: omit pathconfig.mjs auto-detects via git
1855
- configJson.repositories.push({
1856
- name: basename(repoRoot),
1857
- slug: env.GITHUB_REPO,
1858
- primary: true,
1859
- });
1860
- if (!isAdvancedSetup) {
1861
- info(
1862
- "Using single-repo defaults (recommended mode). Re-run setup in Advanced mode for multi-repo config.",
1863
- );
1897
+ // Single-repo mode (classic)still works as before
1898
+ const multiRepo = isAdvancedSetup
1899
+ ? await prompt.confirm(
1900
+ "Do you have multiple repositories (e.g. separate backend/frontend)?",
1901
+ false,
1902
+ )
1903
+ : false;
1904
+
1905
+ if (multiRepo) {
1906
+ info("Configure each repository. The first is the primary.\n");
1907
+ let addMore = true;
1908
+ let repoIdx = 0;
1909
+ while (addMore) {
1910
+ const repoName = await prompt.ask(
1911
+ ` Repo ${repoIdx + 1} — name`,
1912
+ repoIdx === 0 ? basename(repoRoot) : "",
1913
+ );
1914
+ const repoPath = await prompt.ask(
1915
+ ` Repo ${repoIdx + 1} — local path`,
1916
+ repoIdx === 0 ? repoRoot : "",
1917
+ );
1918
+ const repoSlug = await prompt.ask(
1919
+ ` Repo ${repoIdx + 1} — GitHub slug`,
1920
+ repoIdx === 0 ? env.GITHUB_REPO : "",
1921
+ );
1922
+ configJson.repositories.push({
1923
+ name: repoName,
1924
+ path: repoPath,
1925
+ slug: repoSlug,
1926
+ primary: repoIdx === 0,
1927
+ });
1928
+ repoIdx++;
1929
+ addMore = await prompt.confirm("Add another repository?", false);
1930
+ }
1931
+ } else {
1932
+ configJson.repositories.push({
1933
+ name: basename(repoRoot),
1934
+ slug: env.GITHUB_REPO,
1935
+ primary: true,
1936
+ });
1937
+ if (!isAdvancedSetup) {
1938
+ info(
1939
+ "Using single-repo defaults (recommended mode). Re-run setup in Advanced mode for multi-repo config.",
1940
+ );
1941
+ }
1864
1942
  }
1865
1943
  }
1866
1944
 
package/task-store.mjs CHANGED
@@ -144,6 +144,9 @@ function defaultTask(overrides = {}) {
144
144
  tags: [],
145
145
  draft: false,
146
146
  projectId: null,
147
+ workspace: null,
148
+ repository: null,
149
+ repositories: [],
147
150
  baseBranch: null,
148
151
  branchName: null,
149
152
  prNumber: null,
package/ui/app.js CHANGED
@@ -65,6 +65,7 @@ import {
65
65
  selectedSessionId,
66
66
  sessionsData,
67
67
  } from "./components/session-list.js";
68
+ import { WorkspaceSwitcher, loadWorkspaces } from "./components/workspace-switcher.js";
68
69
  import { DiffViewer } from "./components/diff-viewer.js";
69
70
  import {
70
71
  CommandPalette,
@@ -112,15 +113,16 @@ if (typeof document !== "undefined" && !document.getElementById("offline-banner-
112
113
  gap: 12px;
113
114
  padding: 12px 16px;
114
115
  margin: 8px 16px;
115
- background: rgba(239, 68, 68, 0.15);
116
- border: 1px solid rgba(239, 68, 68, 0.3);
117
- border-radius: 12px;
118
- backdrop-filter: blur(8px);
116
+ background: rgba(239, 68, 68, 0.08);
117
+ border: 1px solid rgba(239, 68, 68, 0.2);
118
+ border-radius: 14px;
119
+ box-shadow: var(--shadow-sm);
120
+ backdrop-filter: blur(6px);
119
121
  animation: slideDown 0.3s ease-out;
120
122
  }
121
- .offline-banner-icon { font-size: 24px; }
123
+ .offline-banner-icon { font-size: 20px; }
122
124
  .offline-banner-content { flex: 1; }
123
- .offline-banner-title { font-weight: 600; font-size: 14px; color: #ef4444; }
125
+ .offline-banner-title { font-weight: 600; font-size: 13px; color: #ef4444; }
124
126
  .offline-banner-meta { font-size: 12px; opacity: 0.7; margin-top: 2px; }
125
127
  `;
126
128
  document.head.appendChild(style);
@@ -257,15 +259,18 @@ function Header() {
257
259
  ? html`<div class="app-header-hint">${navHint}</div>`
258
260
  : null}
259
261
  </div>
262
+ <${WorkspaceSwitcher} />
260
263
  </div>
261
264
  <div class="header-actions">
262
- <div class="connection-pill ${connClass}">
263
- <span class="connection-dot"></span>
264
- ${connLabel}
265
+ <div class="header-status">
266
+ <div class="connection-pill ${connClass}">
267
+ <span class="connection-dot"></span>
268
+ ${connLabel}
269
+ </div>
270
+ ${freshnessLabel
271
+ ? html`<div class="header-freshness">${freshnessLabel}</div>`
272
+ : null}
265
273
  </div>
266
- ${freshnessLabel
267
- ? html`<div class="header-freshness" style="font-size:11px;opacity:0.55;margin-top:2px">${freshnessLabel}</div>`
268
- : null}
269
274
  ${user
270
275
  ? html`<div class="app-header-user">@${user.username || user.first_name}</div>`
271
276
  : null}