@gh-symphony/cli 0.0.1

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.
@@ -0,0 +1,101 @@
1
+ import * as p from "@clack/prompts";
2
+ import { loadGlobalConfig, saveGlobalConfig, loadWorkspaceConfig, } from "../config.js";
3
+ const handler = async (args, options) => {
4
+ const [subcommand] = args;
5
+ switch (subcommand) {
6
+ case "list":
7
+ await projectList(options);
8
+ break;
9
+ case "switch":
10
+ await projectSwitch(options);
11
+ break;
12
+ case "status":
13
+ await projectStatus(options);
14
+ break;
15
+ default:
16
+ process.stderr.write("Usage: gh-symphony project <list|switch|status>\n");
17
+ process.exitCode = 2;
18
+ }
19
+ };
20
+ export default handler;
21
+ // ── 6.1: project list ────────────────────────────────────────────────────────
22
+ async function projectList(options) {
23
+ const global = await loadGlobalConfig(options.configDir);
24
+ if (!global || global.workspaces.length === 0) {
25
+ process.stdout.write("No workspaces configured. Run 'gh-symphony init'.\n");
26
+ return;
27
+ }
28
+ if (options.json) {
29
+ const configs = [];
30
+ for (const wsId of global.workspaces) {
31
+ const ws = await loadWorkspaceConfig(options.configDir, wsId);
32
+ configs.push({
33
+ id: wsId,
34
+ active: wsId === global.activeWorkspace,
35
+ repos: ws?.repositories.length ?? 0,
36
+ });
37
+ }
38
+ process.stdout.write(JSON.stringify(configs, null, 2) + "\n");
39
+ return;
40
+ }
41
+ process.stdout.write("Workspaces:\n\n");
42
+ for (const wsId of global.workspaces) {
43
+ const ws = await loadWorkspaceConfig(options.configDir, wsId);
44
+ const active = wsId === global.activeWorkspace ? " (active)" : "";
45
+ const repos = ws?.repositories.length ?? 0;
46
+ process.stdout.write(` ${wsId}${active} — ${repos} repo${repos === 1 ? "" : "s"}\n`);
47
+ }
48
+ }
49
+ // ── 6.2: project switch ──────────────────────────────────────────────────────
50
+ async function projectSwitch(options) {
51
+ const global = await loadGlobalConfig(options.configDir);
52
+ if (!global || global.workspaces.length === 0) {
53
+ process.stderr.write("No workspaces configured. Run 'gh-symphony init'.\n");
54
+ process.exitCode = 1;
55
+ return;
56
+ }
57
+ if (global.workspaces.length === 1) {
58
+ process.stdout.write(`Only one workspace exists: ${global.workspaces[0]}\n`);
59
+ return;
60
+ }
61
+ const selected = await p.select({
62
+ message: "Select workspace to activate:",
63
+ options: global.workspaces.map((wsId) => ({
64
+ value: wsId,
65
+ label: wsId,
66
+ hint: wsId === global.activeWorkspace ? "current" : undefined,
67
+ })),
68
+ });
69
+ if (p.isCancel(selected)) {
70
+ p.cancel("Cancelled.");
71
+ return;
72
+ }
73
+ global.activeWorkspace = selected;
74
+ await saveGlobalConfig(options.configDir, global);
75
+ process.stdout.write(`Switched to workspace: ${selected}\n`);
76
+ }
77
+ // ── 6.3: project status ──────────────────────────────────────────────────────
78
+ async function projectStatus(options) {
79
+ const global = await loadGlobalConfig(options.configDir);
80
+ if (!global?.activeWorkspace) {
81
+ process.stderr.write("No active workspace.\n");
82
+ process.exitCode = 1;
83
+ return;
84
+ }
85
+ const ws = await loadWorkspaceConfig(options.configDir, global.activeWorkspace);
86
+ if (!ws) {
87
+ process.stderr.write(`Workspace config missing: ${global.activeWorkspace}\n`);
88
+ process.exitCode = 1;
89
+ return;
90
+ }
91
+ if (options.json) {
92
+ process.stdout.write(JSON.stringify(ws, null, 2) + "\n");
93
+ return;
94
+ }
95
+ process.stdout.write(`Workspace: ${ws.workspaceId}\n`);
96
+ process.stdout.write(`Tracker: ${ws.tracker.adapter} (${ws.tracker.bindingId})\n`);
97
+ process.stdout.write(`Repositories:\n`);
98
+ for (const repo of ws.repositories) {
99
+ process.stdout.write(` - ${repo.owner}/${repo.name}\n`);
100
+ }
101
+ }
@@ -0,0 +1,3 @@
1
+ import type { GlobalOptions } from "../index.js";
2
+ declare const handler: (args: string[], options: GlobalOptions) => Promise<void>;
3
+ export default handler;
@@ -0,0 +1,117 @@
1
+ import { readFile, readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { runCli as orchestratorRunCli } from "@hojinzs/gh-symphony-orchestrator";
4
+ import { resolveRuntimeRoot, resolveWorkspaceConfig, syncWorkspaceToRuntime, } from "../orchestrator-runtime.js";
5
+ function parseRecoverArgs(args) {
6
+ const parsed = { dryRun: false };
7
+ for (let i = 0; i < args.length; i += 1) {
8
+ const arg = args[i];
9
+ if (arg === "--dry-run") {
10
+ parsed.dryRun = true;
11
+ }
12
+ if (arg === "--workspace" || arg === "--workspace-id") {
13
+ parsed.workspaceId = args[i + 1];
14
+ i += 1;
15
+ }
16
+ }
17
+ return parsed;
18
+ }
19
+ const handler = async (args, options) => {
20
+ const parsed = parseRecoverArgs(args);
21
+ const wsConfig = await resolveWorkspaceConfig(options.configDir, parsed.workspaceId);
22
+ if (!wsConfig) {
23
+ process.stderr.write("No workspace configured. Run 'gh-symphony init' first.\n");
24
+ process.exitCode = 1;
25
+ return;
26
+ }
27
+ const runtimeRoot = resolveRuntimeRoot(options.configDir);
28
+ const workspaceId = wsConfig.workspaceId;
29
+ await syncWorkspaceToRuntime(options.configDir, wsConfig);
30
+ if (parsed.dryRun) {
31
+ process.stdout.write("Dry run — scanning for stalled runs...\n");
32
+ const candidates = await listRecoverCandidates(runtimeRoot, workspaceId);
33
+ if (options.json) {
34
+ process.stdout.write(JSON.stringify(candidates, null, 2) + "\n");
35
+ return;
36
+ }
37
+ if (candidates.length === 0) {
38
+ process.stdout.write("No recoverable runs found.\n");
39
+ return;
40
+ }
41
+ for (const candidate of candidates) {
42
+ process.stdout.write(`${candidate.issueIdentifier} (${candidate.runId}) — ${candidate.reason}\n`);
43
+ }
44
+ return;
45
+ }
46
+ process.stdout.write("Recovering stalled runs...\n");
47
+ await orchestratorRunCli([
48
+ "recover",
49
+ "--runtime-root",
50
+ runtimeRoot,
51
+ "--workspace-id",
52
+ workspaceId,
53
+ ]);
54
+ };
55
+ export default handler;
56
+ async function listRecoverCandidates(runtimeRoot, workspaceId) {
57
+ const runsDir = join(runtimeRoot, "orchestrator", "runs");
58
+ const candidates = [];
59
+ let entries = [];
60
+ try {
61
+ entries = await readdir(runsDir);
62
+ }
63
+ catch {
64
+ return candidates;
65
+ }
66
+ for (const entry of entries) {
67
+ const runPath = join(runsDir, entry, "run.json");
68
+ try {
69
+ const raw = await readFile(runPath, "utf8");
70
+ const run = JSON.parse(raw);
71
+ if (run.workspaceId !== workspaceId) {
72
+ continue;
73
+ }
74
+ const reason = detectRecoveryReason(run);
75
+ if (!reason) {
76
+ continue;
77
+ }
78
+ candidates.push({
79
+ runId: run.runId,
80
+ issueIdentifier: run.issueIdentifier,
81
+ status: run.status,
82
+ reason,
83
+ });
84
+ }
85
+ catch {
86
+ // Skip malformed or partial run records.
87
+ }
88
+ }
89
+ return candidates;
90
+ }
91
+ function detectRecoveryReason(run) {
92
+ if (run.processId) {
93
+ const startedAt = run.startedAt ? new Date(run.startedAt).getTime() : 0;
94
+ const runningForMs = Date.now() - startedAt;
95
+ if (isProcessRunning(run.processId) && runningForMs > 30 * 60 * 1000) {
96
+ return "worker appears stuck";
97
+ }
98
+ if (!isProcessRunning(run.processId)) {
99
+ return "worker process is no longer running";
100
+ }
101
+ }
102
+ if (run.status === "retrying" &&
103
+ run.nextRetryAt &&
104
+ new Date(run.nextRetryAt).getTime() <= Date.now()) {
105
+ return "retry window has elapsed";
106
+ }
107
+ return null;
108
+ }
109
+ function isProcessRunning(pid) {
110
+ try {
111
+ process.kill(pid, 0);
112
+ return true;
113
+ }
114
+ catch {
115
+ return false;
116
+ }
117
+ }
@@ -0,0 +1,3 @@
1
+ import type { GlobalOptions } from "../index.js";
2
+ declare const handler: (args: string[], options: GlobalOptions) => Promise<void>;
3
+ export default handler;
@@ -0,0 +1,103 @@
1
+ import { loadActiveWorkspaceConfig, loadGlobalConfig, saveWorkspaceConfig, } from "../config.js";
2
+ const handler = async (args, options) => {
3
+ const [subcommand, ...rest] = args;
4
+ switch (subcommand) {
5
+ case "list":
6
+ await repoList(options);
7
+ break;
8
+ case "add":
9
+ await repoAdd(rest, options);
10
+ break;
11
+ case "remove":
12
+ await repoRemove(rest, options);
13
+ break;
14
+ default:
15
+ process.stderr.write("Usage: gh-symphony repo <list|add|remove> [repo]\n");
16
+ process.exitCode = 2;
17
+ }
18
+ };
19
+ export default handler;
20
+ // ── 6.4: repo list / add / remove ────────────────────────────────────────────
21
+ async function repoList(options) {
22
+ const ws = await loadActiveWorkspaceConfig(options.configDir);
23
+ if (!ws) {
24
+ process.stderr.write("No workspace configured.\n");
25
+ process.exitCode = 1;
26
+ return;
27
+ }
28
+ if (options.json) {
29
+ process.stdout.write(JSON.stringify(ws.repositories, null, 2) + "\n");
30
+ return;
31
+ }
32
+ process.stdout.write("Repositories:\n");
33
+ for (const repo of ws.repositories) {
34
+ process.stdout.write(` ${repo.owner}/${repo.name}\n`);
35
+ }
36
+ }
37
+ async function repoAdd(args, options) {
38
+ const [repoSpec] = args;
39
+ if (!repoSpec || !repoSpec.includes("/")) {
40
+ process.stderr.write("Usage: gh-symphony repo add <owner/name>\n");
41
+ process.exitCode = 2;
42
+ return;
43
+ }
44
+ const global = await loadGlobalConfig(options.configDir);
45
+ if (!global?.activeWorkspace) {
46
+ process.stderr.write("No active workspace.\n");
47
+ process.exitCode = 1;
48
+ return;
49
+ }
50
+ const ws = await loadActiveWorkspaceConfig(options.configDir);
51
+ if (!ws) {
52
+ process.stderr.write("Workspace config missing.\n");
53
+ process.exitCode = 1;
54
+ return;
55
+ }
56
+ const [owner, name] = repoSpec.split("/");
57
+ if (!owner || !name) {
58
+ process.stderr.write("Invalid repo format. Use: owner/name\n");
59
+ process.exitCode = 2;
60
+ return;
61
+ }
62
+ if (ws.repositories.some((r) => r.owner === owner && r.name === name)) {
63
+ process.stdout.write(`Repository ${repoSpec} is already configured.\n`);
64
+ return;
65
+ }
66
+ ws.repositories.push({
67
+ owner,
68
+ name,
69
+ cloneUrl: `https://github.com/${owner}/${name}.git`,
70
+ });
71
+ await saveWorkspaceConfig(options.configDir, global.activeWorkspace, ws);
72
+ process.stdout.write(`Added repository: ${repoSpec}\n`);
73
+ }
74
+ async function repoRemove(args, options) {
75
+ const [repoSpec] = args;
76
+ if (!repoSpec || !repoSpec.includes("/")) {
77
+ process.stderr.write("Usage: gh-symphony repo remove <owner/name>\n");
78
+ process.exitCode = 2;
79
+ return;
80
+ }
81
+ const global = await loadGlobalConfig(options.configDir);
82
+ if (!global?.activeWorkspace) {
83
+ process.stderr.write("No active workspace.\n");
84
+ process.exitCode = 1;
85
+ return;
86
+ }
87
+ const ws = await loadActiveWorkspaceConfig(options.configDir);
88
+ if (!ws) {
89
+ process.stderr.write("Workspace config missing.\n");
90
+ process.exitCode = 1;
91
+ return;
92
+ }
93
+ const [owner, name] = repoSpec.split("/");
94
+ const idx = ws.repositories.findIndex((r) => r.owner === owner && r.name === name);
95
+ if (idx === -1) {
96
+ process.stderr.write(`Repository ${repoSpec} is not configured.\n`);
97
+ process.exitCode = 1;
98
+ return;
99
+ }
100
+ ws.repositories.splice(idx, 1);
101
+ await saveWorkspaceConfig(options.configDir, global.activeWorkspace, ws);
102
+ process.stdout.write(`Removed repository: ${repoSpec}\n`);
103
+ }
@@ -0,0 +1,3 @@
1
+ import type { GlobalOptions } from "../index.js";
2
+ declare const handler: (args: string[], options: GlobalOptions) => Promise<void>;
3
+ export default handler;
@@ -0,0 +1,69 @@
1
+ import { runCli as orchestratorRunCli } from "@hojinzs/gh-symphony-orchestrator";
2
+ import { resolveRuntimeRoot, resolveWorkspaceConfig, syncWorkspaceToRuntime, } from "../orchestrator-runtime.js";
3
+ function parseRunArgs(args) {
4
+ const parsed = {
5
+ watch: false,
6
+ };
7
+ for (let i = 0; i < args.length; i += 1) {
8
+ const arg = args[i];
9
+ if (arg === "--watch" || arg === "-w") {
10
+ parsed.watch = true;
11
+ }
12
+ else if (arg === "--workspace" || arg === "--workspace-id") {
13
+ parsed.workspaceId = args[i + 1];
14
+ i += 1;
15
+ }
16
+ else if (!arg?.startsWith("--")) {
17
+ // Positional arg = issue identifier
18
+ parsed.issue = arg;
19
+ }
20
+ }
21
+ return parsed;
22
+ }
23
+ const handler = async (args, options) => {
24
+ const parsed = parseRunArgs(args);
25
+ if (!parsed.issue) {
26
+ process.stderr.write("Usage: gh-symphony run <owner/repo#number>\n");
27
+ process.exitCode = 2;
28
+ return;
29
+ }
30
+ const wsConfig = await resolveWorkspaceConfig(options.configDir, parsed.workspaceId);
31
+ if (!wsConfig) {
32
+ process.stderr.write("No workspace configured. Run 'gh-symphony init' first.\n");
33
+ process.exitCode = 1;
34
+ return;
35
+ }
36
+ const runtimeRoot = resolveRuntimeRoot(options.configDir);
37
+ const workspaceId = wsConfig.workspaceId;
38
+ await syncWorkspaceToRuntime(options.configDir, wsConfig);
39
+ // Validate the issue identifier belongs to a configured repo
40
+ const [repoSpec] = parsed.issue.split("#");
41
+ if (repoSpec &&
42
+ !wsConfig.repositories.some((r) => `${r.owner}/${r.name}` === repoSpec)) {
43
+ process.stderr.write(`Repository "${repoSpec}" is not configured in this workspace.\n` +
44
+ `Configured repos: ${wsConfig.repositories.map((r) => `${r.owner}/${r.name}`).join(", ")}\n`);
45
+ process.exitCode = 1;
46
+ return;
47
+ }
48
+ process.stdout.write(`Dispatching issue: ${parsed.issue}\n`);
49
+ await orchestratorRunCli([
50
+ "run-issue",
51
+ "--runtime-root",
52
+ runtimeRoot,
53
+ "--workspace-id",
54
+ workspaceId,
55
+ "--issue",
56
+ parsed.issue,
57
+ ]);
58
+ if (parsed.watch) {
59
+ process.stdout.write("\nWatching for status changes...\n");
60
+ await orchestratorRunCli([
61
+ "status",
62
+ "--runtime-root",
63
+ runtimeRoot,
64
+ "--workspace-id",
65
+ workspaceId,
66
+ ]);
67
+ }
68
+ };
69
+ export default handler;
@@ -0,0 +1,3 @@
1
+ import type { GlobalOptions } from "../index.js";
2
+ declare const handler: (args: string[], options: GlobalOptions) => Promise<void>;
3
+ export default handler;
@@ -0,0 +1,210 @@
1
+ import { writeFile, mkdir } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { spawn } from "node:child_process";
4
+ import { daemonPidPath, orchestratorLogPath, logsDir } from "../config.js";
5
+ import { OrchestratorService, createStore, startOrchestratorStatusServer, } from "@hojinzs/gh-symphony-orchestrator";
6
+ import { resolveWorkspaceConfig, resolveRuntimeRoot, syncWorkspaceToRuntime, } from "../orchestrator-runtime.js";
7
+ // ── ANSI helpers ──────────────────────────────────────────────────────────────
8
+ const ESC = "\x1b[";
9
+ const _bold = (s) => `${ESC}1m${s}${ESC}0m`;
10
+ const _dim = (s) => `${ESC}2m${s}${ESC}0m`;
11
+ const _green = (s) => `${ESC}32m${s}${ESC}0m`;
12
+ const _red = (s) => `${ESC}31m${s}${ESC}0m`;
13
+ const _yellow = (s) => `${ESC}33m${s}${ESC}0m`;
14
+ const _cyan = (s) => `${ESC}36m${s}${ESC}0m`;
15
+ let noColor = false;
16
+ const bold = (s) => (noColor ? s : _bold(s));
17
+ const dim = (s) => (noColor ? s : _dim(s));
18
+ const green = (s) => (noColor ? s : _green(s));
19
+ const red = (s) => (noColor ? s : _red(s));
20
+ const yellow = (s) => (noColor ? s : _yellow(s));
21
+ const cyan = (s) => (noColor ? s : _cyan(s));
22
+ function timestamp() {
23
+ const now = new Date();
24
+ const hh = String(now.getHours()).padStart(2, "0");
25
+ const mm = String(now.getMinutes()).padStart(2, "0");
26
+ const ss = String(now.getSeconds()).padStart(2, "0");
27
+ return dim(`${hh}:${mm}:${ss}`);
28
+ }
29
+ function logLine(icon, msg) {
30
+ process.stdout.write(`${timestamp()} ${icon} ${msg}\n`);
31
+ }
32
+ // ── Arg parsing ───────────────────────────────────────────────────────────────
33
+ function parseStartArgs(args) {
34
+ const parsed = { daemon: false };
35
+ for (let i = 0; i < args.length; i += 1) {
36
+ const arg = args[i];
37
+ if (arg === "--daemon" || arg === "-d") {
38
+ parsed.daemon = true;
39
+ }
40
+ if (arg === "--workspace" || arg === "--workspace-id") {
41
+ parsed.workspaceId = args[i + 1];
42
+ i += 1;
43
+ }
44
+ }
45
+ return parsed;
46
+ }
47
+ // ── Tick logging ──────────────────────────────────────────────────────────────
48
+ function logTickResult(snapshots, prevSnapshots, isFirst) {
49
+ for (const snap of snapshots) {
50
+ const prev = prevSnapshots.find((p) => p.workspaceId === snap.workspaceId);
51
+ if (isFirst) {
52
+ const healthColor = snap.health === "degraded"
53
+ ? red
54
+ : snap.health === "running"
55
+ ? green
56
+ : cyan;
57
+ logLine(green("\u25CF"), `Workspace ${bold(snap.slug)} connected ${dim("(")}${healthColor(snap.health)}${dim(")")}`);
58
+ if (snap.summary.activeRuns > 0) {
59
+ logLine(cyan("\u25B8"), `${snap.summary.activeRuns} active run(s)`);
60
+ }
61
+ continue;
62
+ }
63
+ // Health changes
64
+ if (prev && prev.health !== snap.health) {
65
+ const icon = snap.health === "degraded" ? red("\u25CF") : green("\u25CF");
66
+ logLine(icon, `Health changed: ${prev.health} \u2192 ${bold(snap.health)}`);
67
+ }
68
+ // New error
69
+ if (snap.lastError && snap.lastError !== prev?.lastError) {
70
+ logLine(red("\u2717"), red(snap.lastError));
71
+ }
72
+ // Error cleared
73
+ if (!snap.lastError && prev?.lastError) {
74
+ logLine(green("\u2713"), green("Error cleared"));
75
+ }
76
+ // Dispatched delta
77
+ const prevDispatched = prev?.summary.dispatched ?? 0;
78
+ if (snap.summary.dispatched > prevDispatched) {
79
+ const delta = snap.summary.dispatched - prevDispatched;
80
+ logLine(yellow("\u25B8"), `Dispatched ${bold(String(delta))} new run(s)`);
81
+ }
82
+ // Active run changes
83
+ const prevRunIds = new Set(prev?.activeRuns.map((r) => r.runId) ?? []);
84
+ for (const run of snap.activeRuns) {
85
+ if (!prevRunIds.has(run.runId)) {
86
+ logLine(cyan("\u25B8"), `Run started: ${bold(run.issueIdentifier)} ${dim("phase=")}${run.phase} ${dim("status=")}${run.status}`);
87
+ }
88
+ }
89
+ // Completed runs (were active, now gone)
90
+ const currentRunIds = new Set(snap.activeRuns.map((r) => r.runId));
91
+ for (const prevRun of prev?.activeRuns ?? []) {
92
+ if (!currentRunIds.has(prevRun.runId)) {
93
+ logLine(green("\u2713"), `Run finished: ${bold(prevRun.issueIdentifier)} ${dim("(")}${prevRun.status}${dim(")")}`);
94
+ }
95
+ }
96
+ // Suppressed delta
97
+ const prevSuppressed = prev?.summary.suppressed ?? 0;
98
+ if (snap.summary.suppressed > prevSuppressed) {
99
+ const delta = snap.summary.suppressed - prevSuppressed;
100
+ logLine(dim("\u25CB"), dim(`${delta} issue(s) suppressed (already running or at limit)`));
101
+ }
102
+ // Recovered delta
103
+ const prevRecovered = prev?.summary.recovered ?? 0;
104
+ if (snap.summary.recovered > prevRecovered) {
105
+ const delta = snap.summary.recovered - prevRecovered;
106
+ logLine(yellow("\u21BA"), `Recovered ${bold(String(delta))} stalled run(s)`);
107
+ }
108
+ // Retry queue changes
109
+ const prevRetryCount = prev?.retryQueue.length ?? 0;
110
+ if (snap.retryQueue.length > prevRetryCount) {
111
+ const delta = snap.retryQueue.length - prevRetryCount;
112
+ logLine(yellow("\u25CC"), `${delta} run(s) queued for retry`);
113
+ }
114
+ // Quiet tick — no changes
115
+ const changed = snap.health !== prev?.health ||
116
+ snap.lastError !== prev?.lastError ||
117
+ snap.summary.dispatched !== prev?.summary.dispatched ||
118
+ snap.summary.suppressed !== prev?.summary.suppressed ||
119
+ snap.summary.recovered !== prev?.summary.recovered ||
120
+ snap.activeRuns.length !== (prev?.activeRuns.length ?? 0) ||
121
+ snap.retryQueue.length !== (prev?.retryQueue.length ?? 0);
122
+ if (!changed) {
123
+ logLine(dim("\u00B7"), dim(`tick \u2014 ${snap.summary.activeRuns} active, ${snap.health}`));
124
+ }
125
+ }
126
+ }
127
+ // ── Handler ───────────────────────────────────────────────────────────────────
128
+ const handler = async (args, options) => {
129
+ noColor = options.noColor;
130
+ const parsed = parseStartArgs(args);
131
+ const wsConfig = await resolveWorkspaceConfig(options.configDir, parsed.workspaceId);
132
+ if (!wsConfig) {
133
+ process.stderr.write("No workspace configured. Run 'gh-symphony init' first.\n");
134
+ process.exitCode = 1;
135
+ return;
136
+ }
137
+ const runtimeRoot = resolveRuntimeRoot(options.configDir);
138
+ const workspaceId = wsConfig.workspaceId;
139
+ await syncWorkspaceToRuntime(options.configDir, wsConfig);
140
+ if (parsed.daemon) {
141
+ await startDaemon(options, workspaceId);
142
+ return;
143
+ }
144
+ // ── 5.1: Foreground mode with live logging ────────────────────────────────
145
+ const store = createStore(runtimeRoot);
146
+ const service = new OrchestratorService(store);
147
+ // Start status server
148
+ startOrchestratorStatusServer({
149
+ host: "127.0.0.1",
150
+ port: 4680,
151
+ getWorkspaceStatus: {
152
+ all: () => service.status(),
153
+ byWorkspaceId: async (id) => {
154
+ const [snapshot] = await service.status(id);
155
+ return snapshot ?? null;
156
+ },
157
+ },
158
+ });
159
+ logLine(green("\u25B2"), `Starting orchestrator for workspace: ${bold(workspaceId)}`);
160
+ logLine(dim("\u00B7"), dim("Press Ctrl+C to stop"));
161
+ let running = true;
162
+ const shutdown = () => {
163
+ running = false;
164
+ logLine(yellow("\u25BC"), "Shutting down...");
165
+ process.exit(0);
166
+ };
167
+ process.on("SIGINT", shutdown);
168
+ process.on("SIGTERM", shutdown);
169
+ let prevSnapshots = [];
170
+ let isFirst = true;
171
+ while (running) {
172
+ try {
173
+ const snapshots = await service.runOnce({ workspaceId });
174
+ logTickResult(snapshots, prevSnapshots, isFirst);
175
+ prevSnapshots = snapshots;
176
+ isFirst = false;
177
+ }
178
+ catch (error) {
179
+ logLine(red("\u2717"), red(`Tick error: ${error instanceof Error ? error.message : "Unknown error"}`));
180
+ }
181
+ // Poll interval: default 30s
182
+ await new Promise((r) => setTimeout(r, 30_000));
183
+ }
184
+ };
185
+ export default handler;
186
+ // ── 5.2: Daemon mode ─────────────────────────────────────────────────────────
187
+ async function startDaemon(options, workspaceId) {
188
+ const logPath = orchestratorLogPath(options.configDir);
189
+ await mkdir(logsDir(options.configDir), { recursive: true });
190
+ const { openSync } = await import("node:fs");
191
+ const logFd = openSync(logPath, "a");
192
+ const child = spawn(process.execPath, [process.argv[1], "start", "--workspace", workspaceId], {
193
+ cwd: process.cwd(),
194
+ env: {
195
+ ...process.env,
196
+ GH_SYMPHONY_CONFIG_DIR: options.configDir,
197
+ },
198
+ detached: true,
199
+ stdio: ["ignore", logFd, logFd],
200
+ });
201
+ const pidPath = daemonPidPath(options.configDir);
202
+ await mkdir(dirname(pidPath), { recursive: true });
203
+ await writeFile(pidPath, String(child.pid), "utf8");
204
+ child.unref();
205
+ const { closeSync } = await import("node:fs");
206
+ closeSync(logFd);
207
+ process.stdout.write(`Orchestrator started in background (PID: ${child.pid}).\n` +
208
+ `Logs: ${logPath}\n` +
209
+ `Stop with: gh-symphony stop\n`);
210
+ }
@@ -0,0 +1,3 @@
1
+ import type { GlobalOptions } from "../index.js";
2
+ declare const handler: (args: string[], options: GlobalOptions) => Promise<void>;
3
+ export default handler;