@made-by-moonlight/athene-cli 0.9.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/LICENSE +22 -0
- package/dist/assets/plugin-registry.json +67 -0
- package/dist/assets/scripts/athene-doctor.ps1 +352 -0
- package/dist/assets/scripts/athene-doctor.sh +552 -0
- package/dist/assets/scripts/athene-update.ps1 +224 -0
- package/dist/assets/scripts/athene-update.sh +252 -0
- package/dist/commands/completion.d.ts +3 -0
- package/dist/commands/completion.d.ts.map +1 -0
- package/dist/commands/completion.js +26 -0
- package/dist/commands/completion.js.map +1 -0
- package/dist/commands/config.d.ts +11 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +89 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/dashboard.d.ts +3 -0
- package/dist/commands/dashboard.d.ts.map +1 -0
- package/dist/commands/dashboard.js +103 -0
- package/dist/commands/dashboard.js.map +1 -0
- package/dist/commands/doctor.d.ts +3 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +329 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/events.d.ts +3 -0
- package/dist/commands/events.d.ts.map +1 -0
- package/dist/commands/events.js +172 -0
- package/dist/commands/events.js.map +1 -0
- package/dist/commands/migrate-storage.d.ts +3 -0
- package/dist/commands/migrate-storage.d.ts.map +1 -0
- package/dist/commands/migrate-storage.js +78 -0
- package/dist/commands/migrate-storage.js.map +1 -0
- package/dist/commands/notify.d.ts +3 -0
- package/dist/commands/notify.d.ts.map +1 -0
- package/dist/commands/notify.js +143 -0
- package/dist/commands/notify.js.map +1 -0
- package/dist/commands/open.d.ts +3 -0
- package/dist/commands/open.d.ts.map +1 -0
- package/dist/commands/open.js +167 -0
- package/dist/commands/open.js.map +1 -0
- package/dist/commands/plugin.d.ts +3 -0
- package/dist/commands/plugin.d.ts.map +1 -0
- package/dist/commands/plugin.js +462 -0
- package/dist/commands/plugin.js.map +1 -0
- package/dist/commands/project.d.ts +3 -0
- package/dist/commands/project.d.ts.map +1 -0
- package/dist/commands/project.js +143 -0
- package/dist/commands/project.js.map +1 -0
- package/dist/commands/report.d.ts +19 -0
- package/dist/commands/report.d.ts.map +1 -0
- package/dist/commands/report.js +114 -0
- package/dist/commands/report.js.map +1 -0
- package/dist/commands/review-check.d.ts +3 -0
- package/dist/commands/review-check.d.ts.map +1 -0
- package/dist/commands/review-check.js +122 -0
- package/dist/commands/review-check.js.map +1 -0
- package/dist/commands/review.d.ts +3 -0
- package/dist/commands/review.d.ts.map +1 -0
- package/dist/commands/review.js +215 -0
- package/dist/commands/review.js.map +1 -0
- package/dist/commands/send.d.ts +3 -0
- package/dist/commands/send.d.ts.map +1 -0
- package/dist/commands/send.js +187 -0
- package/dist/commands/send.js.map +1 -0
- package/dist/commands/session.d.ts +3 -0
- package/dist/commands/session.d.ts.map +1 -0
- package/dist/commands/session.js +439 -0
- package/dist/commands/session.js.map +1 -0
- package/dist/commands/setup.d.ts +5 -0
- package/dist/commands/setup.d.ts.map +1 -0
- package/dist/commands/setup.js +297 -0
- package/dist/commands/setup.js.map +1 -0
- package/dist/commands/spawn.d.ts +4 -0
- package/dist/commands/spawn.d.ts.map +1 -0
- package/dist/commands/spawn.js +436 -0
- package/dist/commands/spawn.js.map +1 -0
- package/dist/commands/start.d.ts +21 -0
- package/dist/commands/start.d.ts.map +1 -0
- package/dist/commands/start.js +1836 -0
- package/dist/commands/start.js.map +1 -0
- package/dist/commands/status.d.ts +3 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +556 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/update.d.ts +15 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/update.js +652 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/commands/verify.d.ts +3 -0
- package/dist/commands/verify.d.ts.map +1 -0
- package/dist/commands/verify.js +131 -0
- package/dist/commands/verify.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/bun-tmp-janitor.d.ts +18 -0
- package/dist/lib/bun-tmp-janitor.d.ts.map +1 -0
- package/dist/lib/bun-tmp-janitor.js +127 -0
- package/dist/lib/bun-tmp-janitor.js.map +1 -0
- package/dist/lib/caller-context.d.ts +13 -0
- package/dist/lib/caller-context.d.ts.map +1 -0
- package/dist/lib/caller-context.js +20 -0
- package/dist/lib/caller-context.js.map +1 -0
- package/dist/lib/cli-errors.d.ts +8 -0
- package/dist/lib/cli-errors.d.ts.map +1 -0
- package/dist/lib/cli-errors.js +20 -0
- package/dist/lib/cli-errors.js.map +1 -0
- package/dist/lib/completion.d.ts +13 -0
- package/dist/lib/completion.d.ts.map +1 -0
- package/dist/lib/completion.js +428 -0
- package/dist/lib/completion.js.map +1 -0
- package/dist/lib/composio-setup.d.ts +65 -0
- package/dist/lib/composio-setup.d.ts.map +1 -0
- package/dist/lib/composio-setup.js +3255 -0
- package/dist/lib/composio-setup.js.map +1 -0
- package/dist/lib/config-instruction.d.ts +2 -0
- package/dist/lib/config-instruction.d.ts.map +1 -0
- package/dist/lib/config-instruction.js +193 -0
- package/dist/lib/config-instruction.js.map +1 -0
- package/dist/lib/constants.d.ts +3 -0
- package/dist/lib/constants.d.ts.map +1 -0
- package/dist/lib/constants.js +3 -0
- package/dist/lib/constants.js.map +1 -0
- package/dist/lib/create-session-manager.d.ts +26 -0
- package/dist/lib/create-session-manager.d.ts.map +1 -0
- package/dist/lib/create-session-manager.js +55 -0
- package/dist/lib/create-session-manager.js.map +1 -0
- package/dist/lib/credential-resolver.d.ts +37 -0
- package/dist/lib/credential-resolver.d.ts.map +1 -0
- package/dist/lib/credential-resolver.js +105 -0
- package/dist/lib/credential-resolver.js.map +1 -0
- package/dist/lib/daemon.d.ts +69 -0
- package/dist/lib/daemon.d.ts.map +1 -0
- package/dist/lib/daemon.js +77 -0
- package/dist/lib/daemon.js.map +1 -0
- package/dist/lib/dashboard-rebuild.d.ts +53 -0
- package/dist/lib/dashboard-rebuild.d.ts.map +1 -0
- package/dist/lib/dashboard-rebuild.js +188 -0
- package/dist/lib/dashboard-rebuild.js.map +1 -0
- package/dist/lib/dashboard-setup.d.ts +14 -0
- package/dist/lib/dashboard-setup.d.ts.map +1 -0
- package/dist/lib/dashboard-setup.js +192 -0
- package/dist/lib/dashboard-setup.js.map +1 -0
- package/dist/lib/dashboard-url.d.ts +19 -0
- package/dist/lib/dashboard-url.d.ts.map +1 -0
- package/dist/lib/dashboard-url.js +25 -0
- package/dist/lib/dashboard-url.js.map +1 -0
- package/dist/lib/desktop-setup.d.ts +21 -0
- package/dist/lib/desktop-setup.d.ts.map +1 -0
- package/dist/lib/desktop-setup.js +556 -0
- package/dist/lib/desktop-setup.js.map +1 -0
- package/dist/lib/detect-agent.d.ts +24 -0
- package/dist/lib/detect-agent.d.ts.map +1 -0
- package/dist/lib/detect-agent.js +69 -0
- package/dist/lib/detect-agent.js.map +1 -0
- package/dist/lib/detect-env.d.ts +14 -0
- package/dist/lib/detect-env.d.ts.map +1 -0
- package/dist/lib/detect-env.js +46 -0
- package/dist/lib/detect-env.js.map +1 -0
- package/dist/lib/discord-setup.d.ts +20 -0
- package/dist/lib/discord-setup.d.ts.map +1 -0
- package/dist/lib/discord-setup.js +584 -0
- package/dist/lib/discord-setup.js.map +1 -0
- package/dist/lib/format.d.ts +11 -0
- package/dist/lib/format.d.ts.map +1 -0
- package/dist/lib/format.js +116 -0
- package/dist/lib/format.js.map +1 -0
- package/dist/lib/git-utils.d.ts +14 -0
- package/dist/lib/git-utils.d.ts.map +1 -0
- package/dist/lib/git-utils.js +45 -0
- package/dist/lib/git-utils.js.map +1 -0
- package/dist/lib/install-helpers.d.ts +24 -0
- package/dist/lib/install-helpers.d.ts.map +1 -0
- package/dist/lib/install-helpers.js +76 -0
- package/dist/lib/install-helpers.js.map +1 -0
- package/dist/lib/lifecycle-service.d.ts +11 -0
- package/dist/lib/lifecycle-service.d.ts.map +1 -0
- package/dist/lib/lifecycle-service.js +65 -0
- package/dist/lib/lifecycle-service.js.map +1 -0
- package/dist/lib/notifier-routing.d.ts +35 -0
- package/dist/lib/notifier-routing.d.ts.map +1 -0
- package/dist/lib/notifier-routing.js +133 -0
- package/dist/lib/notifier-routing.js.map +1 -0
- package/dist/lib/notify-test.d.ts +72 -0
- package/dist/lib/notify-test.d.ts.map +1 -0
- package/dist/lib/notify-test.js +674 -0
- package/dist/lib/notify-test.js.map +1 -0
- package/dist/lib/openclaw-probe.d.ts +38 -0
- package/dist/lib/openclaw-probe.d.ts.map +1 -0
- package/dist/lib/openclaw-probe.js +146 -0
- package/dist/lib/openclaw-probe.js.map +1 -0
- package/dist/lib/openclaw-setup.d.ts +19 -0
- package/dist/lib/openclaw-setup.d.ts.map +1 -0
- package/dist/lib/openclaw-setup.js +684 -0
- package/dist/lib/openclaw-setup.js.map +1 -0
- package/dist/lib/path-equality.d.ts +29 -0
- package/dist/lib/path-equality.d.ts.map +1 -0
- package/dist/lib/path-equality.js +52 -0
- package/dist/lib/path-equality.js.map +1 -0
- package/dist/lib/plugin-marketplace.d.ts +24 -0
- package/dist/lib/plugin-marketplace.d.ts.map +1 -0
- package/dist/lib/plugin-marketplace.js +175 -0
- package/dist/lib/plugin-marketplace.js.map +1 -0
- package/dist/lib/plugin-scaffold.d.ts +14 -0
- package/dist/lib/plugin-scaffold.d.ts.map +1 -0
- package/dist/lib/plugin-scaffold.js +174 -0
- package/dist/lib/plugin-scaffold.js.map +1 -0
- package/dist/lib/plugin-store.d.ts +9 -0
- package/dist/lib/plugin-store.d.ts.map +1 -0
- package/dist/lib/plugin-store.js +121 -0
- package/dist/lib/plugin-store.js.map +1 -0
- package/dist/lib/plugins.d.ts +17 -0
- package/dist/lib/plugins.d.ts.map +1 -0
- package/dist/lib/plugins.js +65 -0
- package/dist/lib/plugins.js.map +1 -0
- package/dist/lib/portfolio-display.d.ts +10 -0
- package/dist/lib/portfolio-display.d.ts.map +1 -0
- package/dist/lib/portfolio-display.js +17 -0
- package/dist/lib/portfolio-display.js.map +1 -0
- package/dist/lib/preflight.d.ts +27 -0
- package/dist/lib/preflight.d.ts.map +1 -0
- package/dist/lib/preflight.js +77 -0
- package/dist/lib/preflight.js.map +1 -0
- package/dist/lib/prevent-sleep.d.ts +34 -0
- package/dist/lib/prevent-sleep.d.ts.map +1 -0
- package/dist/lib/prevent-sleep.js +65 -0
- package/dist/lib/prevent-sleep.js.map +1 -0
- package/dist/lib/project-detection.d.ts +11 -0
- package/dist/lib/project-detection.d.ts.map +1 -0
- package/dist/lib/project-detection.js +206 -0
- package/dist/lib/project-detection.js.map +1 -0
- package/dist/lib/project-resolution.d.ts +10 -0
- package/dist/lib/project-resolution.d.ts.map +1 -0
- package/dist/lib/project-resolution.js +17 -0
- package/dist/lib/project-resolution.js.map +1 -0
- package/dist/lib/project-supervisor.d.ts +28 -0
- package/dist/lib/project-supervisor.d.ts.map +1 -0
- package/dist/lib/project-supervisor.js +167 -0
- package/dist/lib/project-supervisor.js.map +1 -0
- package/dist/lib/prompts.d.ts +7 -0
- package/dist/lib/prompts.d.ts.map +1 -0
- package/dist/lib/prompts.js +37 -0
- package/dist/lib/prompts.js.map +1 -0
- package/dist/lib/repo-utils.d.ts +16 -0
- package/dist/lib/repo-utils.d.ts.map +1 -0
- package/dist/lib/repo-utils.js +26 -0
- package/dist/lib/repo-utils.js.map +1 -0
- package/dist/lib/resolve-project.d.ts +113 -0
- package/dist/lib/resolve-project.d.ts.map +1 -0
- package/dist/lib/resolve-project.js +433 -0
- package/dist/lib/resolve-project.js.map +1 -0
- package/dist/lib/routes.d.ts +2 -0
- package/dist/lib/routes.d.ts.map +1 -0
- package/dist/lib/routes.js +5 -0
- package/dist/lib/routes.js.map +1 -0
- package/dist/lib/running-state.d.ts +76 -0
- package/dist/lib/running-state.d.ts.map +1 -0
- package/dist/lib/running-state.js +338 -0
- package/dist/lib/running-state.js.map +1 -0
- package/dist/lib/script-runner.d.ts +10 -0
- package/dist/lib/script-runner.d.ts.map +1 -0
- package/dist/lib/script-runner.js +189 -0
- package/dist/lib/script-runner.js.map +1 -0
- package/dist/lib/session-utils.d.ts +14 -0
- package/dist/lib/session-utils.d.ts.map +1 -0
- package/dist/lib/session-utils.js +58 -0
- package/dist/lib/session-utils.js.map +1 -0
- package/dist/lib/shell.d.ts +17 -0
- package/dist/lib/shell.d.ts.map +1 -0
- package/dist/lib/shell.js +90 -0
- package/dist/lib/shell.js.map +1 -0
- package/dist/lib/shutdown.d.ts +30 -0
- package/dist/lib/shutdown.d.ts.map +1 -0
- package/dist/lib/shutdown.js +177 -0
- package/dist/lib/shutdown.js.map +1 -0
- package/dist/lib/slack-setup.d.ts +17 -0
- package/dist/lib/slack-setup.d.ts.map +1 -0
- package/dist/lib/slack-setup.js +485 -0
- package/dist/lib/slack-setup.js.map +1 -0
- package/dist/lib/startup-preflight.d.ts +36 -0
- package/dist/lib/startup-preflight.d.ts.map +1 -0
- package/dist/lib/startup-preflight.js +273 -0
- package/dist/lib/startup-preflight.js.map +1 -0
- package/dist/lib/update-channel-onboarding.d.ts +52 -0
- package/dist/lib/update-channel-onboarding.d.ts.map +1 -0
- package/dist/lib/update-channel-onboarding.js +107 -0
- package/dist/lib/update-channel-onboarding.js.map +1 -0
- package/dist/lib/update-check.d.ts +161 -0
- package/dist/lib/update-check.d.ts.map +1 -0
- package/dist/lib/update-check.js +504 -0
- package/dist/lib/update-check.js.map +1 -0
- package/dist/lib/web-dir.d.ts +47 -0
- package/dist/lib/web-dir.d.ts.map +1 -0
- package/dist/lib/web-dir.js +179 -0
- package/dist/lib/web-dir.js.map +1 -0
- package/dist/lib/webhook-setup.d.ts +16 -0
- package/dist/lib/webhook-setup.d.ts.map +1 -0
- package/dist/lib/webhook-setup.js +383 -0
- package/dist/lib/webhook-setup.js.map +1 -0
- package/dist/options/version.d.ts +2 -0
- package/dist/options/version.d.ts.map +1 -0
- package/dist/options/version.js +7 -0
- package/dist/options/version.js.map +1 -0
- package/dist/program.d.ts +3 -0
- package/dist/program.d.ts.map +1 -0
- package/dist/program.js +63 -0
- package/dist/program.js.map +1 -0
- package/package.json +80 -0
- package/templates/rules/base.md +4 -0
- package/templates/rules/go.md +8 -0
- package/templates/rules/javascript.md +4 -0
- package/templates/rules/nextjs.md +7 -0
- package/templates/rules/pnpm-workspaces.md +4 -0
- package/templates/rules/python.md +8 -0
- package/templates/rules/react.md +8 -0
- package/templates/rules/typescript.md +6 -0
|
@@ -0,0 +1,1836 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `athene start` and `athene stop` commands — unified orchestrator startup.
|
|
3
|
+
*
|
|
4
|
+
* Supports two modes:
|
|
5
|
+
* 1. `athene start [project]` — start from existing config
|
|
6
|
+
* 2. `athene start <url>` — clone repo, auto-generate config, then start
|
|
7
|
+
*
|
|
8
|
+
* The orchestrator prompt is passed to the agent via --append-system-prompt
|
|
9
|
+
* (or equivalent flag) at launch time — no file writing required.
|
|
10
|
+
*/
|
|
11
|
+
import {} from "node:child_process";
|
|
12
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { resolve, basename, dirname } from "node:path";
|
|
14
|
+
import { cwd } from "node:process";
|
|
15
|
+
import chalk from "chalk";
|
|
16
|
+
import ora from "ora";
|
|
17
|
+
import { loadConfig, generateOrchestratorPrompt, generateSessionPrefix, getOrchestratorSessionId, isRepoUrl, configToYaml, isCanonicalGlobalConfigPath, isTerminalSession, getDefaultRuntime, isWindows, isMac, isLinux, findPidByPort, killProcessTree, loadLocalProjectConfigDetailed, recordActivityEvent, registerProjectInGlobalConfig, getGlobalConfigPath, writeLocalProjectConfig, spawnManagedDaemonChild, sweepDaemonChildren, scanAoOrphans, reapAoOrphans, } from "@made-by-moonlight/athene-core";
|
|
18
|
+
import { parse as yamlParse, stringify as yamlStringify } from "yaml";
|
|
19
|
+
import { exec, execSilent, git } from "../lib/shell.js";
|
|
20
|
+
import { getSessionManager } from "../lib/create-session-manager.js";
|
|
21
|
+
import { listLifecycleWorkers } from "../lib/lifecycle-service.js";
|
|
22
|
+
import { startBunTmpJanitor } from "../lib/bun-tmp-janitor.js";
|
|
23
|
+
import { findWebDir, buildDashboardEnv, waitForPortAndOpen, openUrl, isPortAvailable, findFreePort, MAX_PORT_SCAN, } from "../lib/web-dir.js";
|
|
24
|
+
import { clearStaleCacheIfNeeded, rebuildDashboardProductionArtifacts, } from "../lib/dashboard-rebuild.js";
|
|
25
|
+
import { preflight } from "../lib/preflight.js";
|
|
26
|
+
import { register, isAlreadyRunning, getRunning, unregister, acquireStartupLock, writeLastStop, readLastStop, clearLastStop, } from "../lib/running-state.js";
|
|
27
|
+
import { attachToDaemon, killExistingDaemon } from "../lib/daemon.js";
|
|
28
|
+
import { startProjectSupervisor } from "../lib/project-supervisor.js";
|
|
29
|
+
import { isHumanCaller } from "../lib/caller-context.js";
|
|
30
|
+
import { detectEnvironment } from "../lib/detect-env.js";
|
|
31
|
+
import { detectAgentRuntime, detectAvailableAgents, } from "../lib/detect-agent.js";
|
|
32
|
+
import { detectDefaultBranch } from "../lib/git-utils.js";
|
|
33
|
+
import { dashboardUrl } from "../lib/dashboard-url.js";
|
|
34
|
+
import { promptConfirm, promptSelect, promptText } from "../lib/prompts.js";
|
|
35
|
+
import { extractOwnerRepo, isValidRepoString } from "../lib/repo-utils.js";
|
|
36
|
+
import { detectProjectType, generateRulesFromTemplates, formatProjectTypeForDisplay, } from "../lib/project-detection.js";
|
|
37
|
+
import { formatCommandError } from "../lib/cli-errors.js";
|
|
38
|
+
import { findProjectForDirectory } from "../lib/project-resolution.js";
|
|
39
|
+
import { canPromptForInstall, genericInstallHints, askYesNo, runInteractiveCommand, tryInstallWithAttempts, } from "../lib/install-helpers.js";
|
|
40
|
+
import { ensureGit, runtimePreflight } from "../lib/startup-preflight.js";
|
|
41
|
+
import { installShutdownHandlers, isShutdownInProgress } from "../lib/shutdown.js";
|
|
42
|
+
import { resolveOrCreateProject } from "../lib/resolve-project.js";
|
|
43
|
+
import { pathsEqual } from "../lib/path-equality.js";
|
|
44
|
+
import { maybePromptForUpdateChannel } from "../lib/update-channel-onboarding.js";
|
|
45
|
+
import { DEFAULT_PORT } from "../lib/constants.js";
|
|
46
|
+
import { projectSessionUrl } from "../lib/routes.js";
|
|
47
|
+
// =============================================================================
|
|
48
|
+
// HELPERS
|
|
49
|
+
// =============================================================================
|
|
50
|
+
class CliFailureEventRecordedError extends Error {
|
|
51
|
+
constructor(message, options) {
|
|
52
|
+
super(message, options);
|
|
53
|
+
this.name = "CliFailureEventRecordedError";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function isCliFailureEventRecordedError(err) {
|
|
57
|
+
return err instanceof CliFailureEventRecordedError;
|
|
58
|
+
}
|
|
59
|
+
function readProjectBehaviorConfig(projectPath) {
|
|
60
|
+
const localConfig = loadLocalProjectConfigDetailed(projectPath);
|
|
61
|
+
if (localConfig.kind === "loaded") {
|
|
62
|
+
return { ...localConfig.config };
|
|
63
|
+
}
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
function writeProjectBehaviorConfig(projectPath, config) {
|
|
67
|
+
writeLocalProjectConfig(projectPath, config);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Register a flat local config (agent-orchestrator.yaml without `projects:`)
|
|
71
|
+
* into the global config so loadConfig can resolve it.
|
|
72
|
+
* Returns the registered project ID, or null if registration failed.
|
|
73
|
+
*/
|
|
74
|
+
async function registerFlatConfig(configPath) {
|
|
75
|
+
const projectPath = resolve(dirname(configPath));
|
|
76
|
+
const projectId = basename(projectPath);
|
|
77
|
+
// Read flat config fields
|
|
78
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
79
|
+
const parsed = yamlParse(raw);
|
|
80
|
+
if (!parsed || typeof parsed !== "object")
|
|
81
|
+
return null;
|
|
82
|
+
// If it has a projects key, it's not a flat config
|
|
83
|
+
if ("projects" in parsed)
|
|
84
|
+
return null;
|
|
85
|
+
const repo = typeof parsed["repo"] === "string" ? parsed["repo"] : undefined;
|
|
86
|
+
const defaultBranch = typeof parsed["defaultBranch"] === "string"
|
|
87
|
+
? parsed["defaultBranch"]
|
|
88
|
+
: await detectDefaultBranch(projectPath, repo ?? null);
|
|
89
|
+
// Strip characters invalid in sessionPrefix (Zod: [a-zA-Z0-9_-]+)
|
|
90
|
+
// so folder names like "my.app" don't produce invalid prefixes.
|
|
91
|
+
const prefixInput = projectId.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/^-+|-+$/g, "");
|
|
92
|
+
const prefix = generateSessionPrefix(prefixInput || projectId);
|
|
93
|
+
console.log(chalk.dim(`\n Registering project "${projectId}" in global config...\n`));
|
|
94
|
+
const registeredProjectId = registerProjectInGlobalConfig(projectId, projectId, projectPath, {
|
|
95
|
+
defaultBranch,
|
|
96
|
+
sessionPrefix: prefix,
|
|
97
|
+
...(repo ? { repo } : {}),
|
|
98
|
+
});
|
|
99
|
+
recordActivityEvent({
|
|
100
|
+
projectId: registeredProjectId,
|
|
101
|
+
source: "cli",
|
|
102
|
+
kind: "cli.config_migrated",
|
|
103
|
+
level: "info",
|
|
104
|
+
summary: `flat config registered into global config`,
|
|
105
|
+
data: { projectPath, configPath },
|
|
106
|
+
});
|
|
107
|
+
console.log(chalk.green(` ✓ Registered "${registeredProjectId}"\n`));
|
|
108
|
+
return registeredProjectId;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Resolve project from config.
|
|
112
|
+
* If projectArg is provided, use it. If only one project exists, use that.
|
|
113
|
+
* Otherwise, error with helpful message.
|
|
114
|
+
*/
|
|
115
|
+
async function resolveProject(config, projectArg, action = "start") {
|
|
116
|
+
const projectIds = Object.keys(config.projects);
|
|
117
|
+
if (projectIds.length === 0) {
|
|
118
|
+
throw new Error("No projects configured. Add a project to agent-orchestrator.yaml.");
|
|
119
|
+
}
|
|
120
|
+
// Explicit project argument
|
|
121
|
+
if (projectArg) {
|
|
122
|
+
const project = config.projects[projectArg];
|
|
123
|
+
if (!project) {
|
|
124
|
+
throw new Error(`Project "${projectArg}" not found. Available projects:\n ${projectIds.join(", ")}`);
|
|
125
|
+
}
|
|
126
|
+
return { projectId: projectArg, project, config };
|
|
127
|
+
}
|
|
128
|
+
// Only one project — use it
|
|
129
|
+
if (projectIds.length === 1) {
|
|
130
|
+
const projectId = projectIds[0];
|
|
131
|
+
return { projectId, project: config.projects[projectId], config };
|
|
132
|
+
}
|
|
133
|
+
// Multiple projects — try matching cwd to a project path
|
|
134
|
+
// Note: loadConfig() already expands ~ in project paths via expandPaths()
|
|
135
|
+
const currentDir = resolve(cwd());
|
|
136
|
+
const matchedProjectId = findProjectForDirectory(config.projects, currentDir);
|
|
137
|
+
if (matchedProjectId) {
|
|
138
|
+
return { projectId: matchedProjectId, project: config.projects[matchedProjectId], config };
|
|
139
|
+
}
|
|
140
|
+
// No match — prompt if interactive, otherwise error
|
|
141
|
+
if (isHumanCaller()) {
|
|
142
|
+
// Check if cwd is a git repo not yet in the config — offer to add it
|
|
143
|
+
const currentDirResolved = resolve(cwd());
|
|
144
|
+
const cwdAlreadyInConfig = projectIds.some((id) => {
|
|
145
|
+
try {
|
|
146
|
+
return pathsEqual(config.projects[id].path, currentDirResolved);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
const cwdIsGitRepo = existsSync(resolve(currentDirResolved, ".git"));
|
|
153
|
+
const addOption = !cwdAlreadyInConfig && cwdIsGitRepo
|
|
154
|
+
? [
|
|
155
|
+
{
|
|
156
|
+
value: "__add_cwd__",
|
|
157
|
+
label: `Add ${basename(currentDirResolved)}`,
|
|
158
|
+
hint: "register this directory as a new project",
|
|
159
|
+
},
|
|
160
|
+
]
|
|
161
|
+
: [];
|
|
162
|
+
const projectId = await promptSelect(`Choose project to ${action}:`, [
|
|
163
|
+
...projectIds.map((id) => ({
|
|
164
|
+
value: id,
|
|
165
|
+
label: config.projects[id].name ?? id,
|
|
166
|
+
hint: id,
|
|
167
|
+
})),
|
|
168
|
+
...addOption,
|
|
169
|
+
]);
|
|
170
|
+
if (projectId === "__add_cwd__") {
|
|
171
|
+
const addedId = await addProjectToConfig(config, currentDirResolved);
|
|
172
|
+
// Return the reloaded config too — addProjectToConfig writes the
|
|
173
|
+
// (possibly hashed) project ID to disk, so any caller that holds the
|
|
174
|
+
// pre-add `config` reference would not see the new key. Without this,
|
|
175
|
+
// downstream consumers like `ensureLifecycleWorker(config, projectId)`
|
|
176
|
+
// throw `Unknown project: ...` even though the registration succeeded.
|
|
177
|
+
const reloadedConfig = loadConfig(config.configPath);
|
|
178
|
+
return {
|
|
179
|
+
projectId: addedId,
|
|
180
|
+
project: reloadedConfig.projects[addedId],
|
|
181
|
+
config: reloadedConfig,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return { projectId, project: config.projects[projectId], config };
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
throw new Error(`Multiple projects configured. Specify which one to ${action}:\n ${projectIds.map((id) => `ao ${action} ${id}`).join("\n ")}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Resolve project from config by matching against a repo URL's ownerRepo.
|
|
192
|
+
* Used when `athene start <url>` loads an existing multi-project config — the user
|
|
193
|
+
* can't pass both a URL and a project name since they share the same arg slot.
|
|
194
|
+
*
|
|
195
|
+
* Falls back to `resolveProject` (which handles single-project configs or
|
|
196
|
+
* errors with a helpful message for ambiguous multi-project cases).
|
|
197
|
+
*/
|
|
198
|
+
async function resolveProjectByRepo(config, parsed) {
|
|
199
|
+
const projectIds = Object.keys(config.projects);
|
|
200
|
+
// Try to match by repo field (e.g. "owner/repo")
|
|
201
|
+
for (const id of projectIds) {
|
|
202
|
+
const project = config.projects[id];
|
|
203
|
+
if (project.repo === parsed.ownerRepo) {
|
|
204
|
+
return { projectId: id, project, config };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// No repo match — fall back to standard resolution (works for single-project)
|
|
208
|
+
return await resolveProject(config);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Prompt the user to optionally switch orchestrator/worker agents at startup.
|
|
212
|
+
* Shows only agents detected on the current system (reuses detectAvailableAgents).
|
|
213
|
+
* Returns the chosen agents
|
|
214
|
+
*/
|
|
215
|
+
async function promptAgentSelection() {
|
|
216
|
+
if (canPromptForInstall()) {
|
|
217
|
+
const available = await detectAvailableAgents();
|
|
218
|
+
if (available.length === 0) {
|
|
219
|
+
console.log(chalk.yellow("No agent runtimes detected — using existing config."));
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
const agentOptions = available.map((a) => ({ value: a.name, label: a.displayName }));
|
|
223
|
+
const orchestratorAgent = await promptSelect("Orchestrator agent:", agentOptions);
|
|
224
|
+
const workerAgent = await promptSelect("Worker agent:", agentOptions);
|
|
225
|
+
return { orchestratorAgent, workerAgent };
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function ghInstallAttempts() {
|
|
232
|
+
if (isMac()) {
|
|
233
|
+
return [{ cmd: "brew", args: ["install", "gh"], label: "brew install gh" }];
|
|
234
|
+
}
|
|
235
|
+
if (isLinux()) {
|
|
236
|
+
return [
|
|
237
|
+
{
|
|
238
|
+
cmd: "sudo",
|
|
239
|
+
args: ["apt-get", "install", "-y", "gh"],
|
|
240
|
+
label: "sudo apt-get install -y gh",
|
|
241
|
+
},
|
|
242
|
+
{ cmd: "sudo", args: ["dnf", "install", "-y", "gh"], label: "sudo dnf install -y gh" },
|
|
243
|
+
];
|
|
244
|
+
}
|
|
245
|
+
if (isWindows()) {
|
|
246
|
+
return [
|
|
247
|
+
{
|
|
248
|
+
cmd: "winget",
|
|
249
|
+
args: ["install", "--id", "GitHub.cli", "-e", "--source", "winget"],
|
|
250
|
+
label: "winget install --id GitHub.cli -e --source winget",
|
|
251
|
+
},
|
|
252
|
+
];
|
|
253
|
+
}
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
const AGENT_INSTALL_OPTIONS = [
|
|
257
|
+
{
|
|
258
|
+
id: "claude-code",
|
|
259
|
+
label: "Claude Code",
|
|
260
|
+
cmd: "npm",
|
|
261
|
+
args: ["install", "-g", "@anthropic-ai/claude-code"],
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
id: "codex",
|
|
265
|
+
label: "OpenAI Codex",
|
|
266
|
+
cmd: "npm",
|
|
267
|
+
args: ["install", "-g", "@openai/codex"],
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
id: "aider",
|
|
271
|
+
label: "Aider",
|
|
272
|
+
cmd: "pipx",
|
|
273
|
+
args: ["install", "aider-chat"],
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
id: "opencode",
|
|
277
|
+
label: "OpenCode",
|
|
278
|
+
cmd: "npm",
|
|
279
|
+
args: ["install", "-g", "opencode-ai"],
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
id: "kimicode",
|
|
283
|
+
label: "Kimi Code",
|
|
284
|
+
cmd: "uv",
|
|
285
|
+
args: ["tool", "install", "kimi-cli"],
|
|
286
|
+
},
|
|
287
|
+
];
|
|
288
|
+
async function promptInstallAgentRuntime(available) {
|
|
289
|
+
if (available.length > 0 || !canPromptForInstall())
|
|
290
|
+
return available;
|
|
291
|
+
console.log(chalk.yellow("⚠ No supported agent runtime detected."));
|
|
292
|
+
console.log(chalk.dim(" You can install one now (recommended) or continue and install later.\n"));
|
|
293
|
+
const choice = await promptSelect("Choose runtime to install:", [
|
|
294
|
+
...AGENT_INSTALL_OPTIONS.map((option) => ({
|
|
295
|
+
value: option.id,
|
|
296
|
+
label: option.label,
|
|
297
|
+
hint: [option.cmd, ...option.args].join(" "),
|
|
298
|
+
})),
|
|
299
|
+
{ value: "skip", label: "Skip for now" },
|
|
300
|
+
]);
|
|
301
|
+
if (choice === "skip") {
|
|
302
|
+
return available;
|
|
303
|
+
}
|
|
304
|
+
const selected = AGENT_INSTALL_OPTIONS.find((option) => option.id === choice);
|
|
305
|
+
if (!selected) {
|
|
306
|
+
return available;
|
|
307
|
+
}
|
|
308
|
+
console.log(chalk.dim(` Installing ${selected.label}...`));
|
|
309
|
+
try {
|
|
310
|
+
await runInteractiveCommand(selected.cmd, selected.args, {
|
|
311
|
+
action: `install ${selected.label}`,
|
|
312
|
+
installHints: genericInstallHints(selected.cmd),
|
|
313
|
+
});
|
|
314
|
+
const refreshed = await detectAvailableAgents();
|
|
315
|
+
if (refreshed.length > 0) {
|
|
316
|
+
console.log(chalk.green(` ✓ ${selected.label} installed successfully`));
|
|
317
|
+
}
|
|
318
|
+
return refreshed;
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
console.log(chalk.yellow(` ⚠ Could not install ${selected.label} automatically.`));
|
|
322
|
+
return available;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Clone a repo with authentication support.
|
|
327
|
+
*
|
|
328
|
+
* Strategy:
|
|
329
|
+
* 1. Try `gh repo clone owner/repo target -- --depth 1` — handles GitHub auth
|
|
330
|
+
* for private repos via the user's `gh auth` token.
|
|
331
|
+
* 2. Fall back to `git clone --depth 1` with SSH URL — works for users with
|
|
332
|
+
* SSH keys configured (common for private repos without gh).
|
|
333
|
+
* 3. Final fallback to `git clone --depth 1` with HTTPS URL — works for
|
|
334
|
+
* public repos without any auth setup.
|
|
335
|
+
*/
|
|
336
|
+
async function cloneRepo(parsed, targetDir, cwd) {
|
|
337
|
+
// 1. Try gh repo clone (handles GitHub auth automatically)
|
|
338
|
+
if (parsed.host === "github.com") {
|
|
339
|
+
const ghAvailable = (await execSilent("gh", ["auth", "status"])) !== null;
|
|
340
|
+
if (ghAvailable) {
|
|
341
|
+
try {
|
|
342
|
+
await runInteractiveCommand("gh", ["repo", "clone", parsed.ownerRepo, targetDir, "--", "--depth", "1"], { cwd, action: "clone repository via gh" });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
// gh clone failed — fall through to git clone with SSH
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
// 2. Try git clone with SSH URL (works for SSH keys, may prompt for host key)
|
|
351
|
+
const sshUrl = `git@${parsed.host}:${parsed.ownerRepo}.git`;
|
|
352
|
+
try {
|
|
353
|
+
await runInteractiveCommand("git", ["clone", "--depth", "1", sshUrl, targetDir], {
|
|
354
|
+
cwd,
|
|
355
|
+
action: "clone repository via git (ssh)",
|
|
356
|
+
});
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
// SSH failed — fall through to HTTPS
|
|
361
|
+
}
|
|
362
|
+
// 3. Final fallback: HTTPS (works for public repos)
|
|
363
|
+
await runInteractiveCommand("git", ["clone", "--depth", "1", parsed.cloneUrl, targetDir], {
|
|
364
|
+
cwd,
|
|
365
|
+
action: "clone repository via git (https)",
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Auto-create agent-orchestrator.yaml when no config exists.
|
|
370
|
+
* Detects environment, project type, and generates config with smart defaults.
|
|
371
|
+
* Returns the loaded config.
|
|
372
|
+
*/
|
|
373
|
+
export async function autoCreateConfig(workingDir) {
|
|
374
|
+
console.log(chalk.bold.cyan("\n Athene — First Run Setup\n"));
|
|
375
|
+
console.log(chalk.dim(" Detecting project and generating config...\n"));
|
|
376
|
+
const env = await detectEnvironment(workingDir);
|
|
377
|
+
if (!env.isGitRepo) {
|
|
378
|
+
throw new Error(`"${workingDir}" is not a git repository.\n` +
|
|
379
|
+
` ao requires a git repo to manage worktrees and branches.\n` +
|
|
380
|
+
` Run \`git init\` first, then try again.`);
|
|
381
|
+
}
|
|
382
|
+
const projectType = detectProjectType(workingDir);
|
|
383
|
+
// Show detection results
|
|
384
|
+
if (env.isGitRepo) {
|
|
385
|
+
console.log(chalk.green(" ✓ Git repository detected"));
|
|
386
|
+
if (env.ownerRepo) {
|
|
387
|
+
console.log(chalk.dim(` Remote: ${env.ownerRepo}`));
|
|
388
|
+
}
|
|
389
|
+
if (env.currentBranch) {
|
|
390
|
+
console.log(chalk.dim(` Branch: ${env.currentBranch}`));
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (projectType.languages.length > 0 || projectType.frameworks.length > 0) {
|
|
394
|
+
console.log(chalk.green(" ✓ Project type detected"));
|
|
395
|
+
const formattedType = formatProjectTypeForDisplay(projectType);
|
|
396
|
+
formattedType.split("\n").forEach((line) => {
|
|
397
|
+
console.log(chalk.dim(` ${line}`));
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
console.log();
|
|
401
|
+
const agentRules = generateRulesFromTemplates(projectType);
|
|
402
|
+
// Build config with smart defaults
|
|
403
|
+
const projectId = basename(workingDir);
|
|
404
|
+
let repo = env.ownerRepo ?? undefined;
|
|
405
|
+
const path = workingDir;
|
|
406
|
+
const defaultBranch = env.defaultBranch || "main";
|
|
407
|
+
// If no repo detected, inform the user and ask
|
|
408
|
+
/* c8 ignore start -- interactive prompt, tested via onboarding integration */
|
|
409
|
+
if (!repo && isHumanCaller()) {
|
|
410
|
+
console.log(chalk.yellow(" ⚠ Could not auto-detect a GitHub/GitLab remote."));
|
|
411
|
+
const entered = await promptText(" Enter repo (owner/repo or group/subgroup/repo) or leave empty to skip:", "owner/repo");
|
|
412
|
+
const trimmed = (entered || "").trim();
|
|
413
|
+
if (trimmed && isValidRepoString(trimmed)) {
|
|
414
|
+
repo = trimmed;
|
|
415
|
+
console.log(chalk.green(` ✓ Repo: ${repo}`));
|
|
416
|
+
}
|
|
417
|
+
else if (trimmed) {
|
|
418
|
+
console.log(chalk.yellow(` ⚠ "${trimmed}" doesn't look like a valid repo path — skipping.`));
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/* c8 ignore stop */
|
|
422
|
+
// Detect available agent runtimes via plugin registry
|
|
423
|
+
let detectedAgents = await detectAvailableAgents();
|
|
424
|
+
detectedAgents = await promptInstallAgentRuntime(detectedAgents);
|
|
425
|
+
const agent = await detectAgentRuntime(detectedAgents);
|
|
426
|
+
console.log(chalk.green(` ✓ Agent runtime: ${agent}`));
|
|
427
|
+
const localConfig = {
|
|
428
|
+
runtime: getDefaultRuntime(),
|
|
429
|
+
agent,
|
|
430
|
+
workspace: "worktree",
|
|
431
|
+
...(agentRules ? { agentRules } : {}),
|
|
432
|
+
};
|
|
433
|
+
const outputPath = resolve(workingDir, "agent-orchestrator.yaml");
|
|
434
|
+
if (existsSync(outputPath)) {
|
|
435
|
+
console.log(chalk.yellow(`⚠ Config already exists: ${outputPath}`));
|
|
436
|
+
console.log(chalk.dim(" Use 'athene start' to start with the existing config.\n"));
|
|
437
|
+
return loadConfig(outputPath);
|
|
438
|
+
}
|
|
439
|
+
writeLocalProjectConfig(workingDir, localConfig, outputPath);
|
|
440
|
+
try {
|
|
441
|
+
const registeredProjectId = registerProjectInGlobalConfig(projectId, projectId, path, {
|
|
442
|
+
...(repo ? { repo } : {}),
|
|
443
|
+
defaultBranch,
|
|
444
|
+
sessionPrefix: generateSessionPrefix(projectId),
|
|
445
|
+
});
|
|
446
|
+
console.log(chalk.green(`✓ Registered "${registeredProjectId}" in global config\n`));
|
|
447
|
+
}
|
|
448
|
+
catch (err) {
|
|
449
|
+
rmSync(outputPath, { force: true });
|
|
450
|
+
throw err;
|
|
451
|
+
}
|
|
452
|
+
console.log(chalk.green(`✓ Config created: ${outputPath}\n`));
|
|
453
|
+
if (!repo) {
|
|
454
|
+
console.log(chalk.yellow("⚠ No repo configured — issue tracking and PR features will be unavailable."));
|
|
455
|
+
console.log(chalk.dim(" Add a 'repo' field (owner/repo) to the config to enable them.\n"));
|
|
456
|
+
}
|
|
457
|
+
if (!env.hasTmux && getDefaultRuntime() === "tmux") {
|
|
458
|
+
console.log(chalk.yellow("⚠ tmux not found — will prompt to install at startup"));
|
|
459
|
+
}
|
|
460
|
+
if (!env.hasGh) {
|
|
461
|
+
console.log(chalk.yellow("⚠ GitHub CLI (gh) not found — optional, but recommended for GitHub workflows."));
|
|
462
|
+
const shouldInstallGh = await askYesNo("Install GitHub CLI now?", false);
|
|
463
|
+
if (shouldInstallGh) {
|
|
464
|
+
const installedGh = await tryInstallWithAttempts(ghInstallAttempts(), async () => (await execSilent("gh", ["--version"])) !== null);
|
|
465
|
+
if (installedGh) {
|
|
466
|
+
env.hasGh = true;
|
|
467
|
+
console.log(chalk.green(" ✓ GitHub CLI installed successfully"));
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
console.log(chalk.yellow(" ⚠ Could not install GitHub CLI automatically."));
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (!env.ghAuthed && env.hasGh) {
|
|
475
|
+
console.log(chalk.yellow("⚠ GitHub CLI not authenticated — run: gh auth login"));
|
|
476
|
+
}
|
|
477
|
+
return loadConfig(outputPath);
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Add a new project to an existing config.
|
|
481
|
+
* Detects git info, project type, generates rules, appends to config YAML.
|
|
482
|
+
* Returns the project ID that was added.
|
|
483
|
+
*/
|
|
484
|
+
async function addProjectToConfig(config, projectPath) {
|
|
485
|
+
const resolvedPath = resolve(projectPath.replace(/^~/, process.env["HOME"] || ""));
|
|
486
|
+
// Check if this path is already registered under any project name.
|
|
487
|
+
// pathsEqual canonicalizes via realpathSync and lowercases on Windows so
|
|
488
|
+
// drive-letter case and 8.3-vs-long-name differences don't cause a miss.
|
|
489
|
+
// Done before ensureGit so already-registered paths return early without requiring git.
|
|
490
|
+
const existingByPath = Object.entries(config.projects).find(([, p]) => {
|
|
491
|
+
try {
|
|
492
|
+
return pathsEqual(p.path, resolvedPath);
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
if (existingByPath) {
|
|
499
|
+
console.log(chalk.dim(` Path already configured as project "${existingByPath[0]}" — skipping add.`));
|
|
500
|
+
return existingByPath[0];
|
|
501
|
+
}
|
|
502
|
+
await ensureGit("adding projects");
|
|
503
|
+
let projectId = basename(resolvedPath);
|
|
504
|
+
// Avoid overwriting an existing project with the same directory name
|
|
505
|
+
if (config.projects[projectId]) {
|
|
506
|
+
let i = 2;
|
|
507
|
+
while (config.projects[`${projectId}-${i}`])
|
|
508
|
+
i++;
|
|
509
|
+
const newId = `${projectId}-${i}`;
|
|
510
|
+
console.log(chalk.yellow(` ⚠ Project "${projectId}" already exists — using "${newId}" instead.`));
|
|
511
|
+
projectId = newId;
|
|
512
|
+
}
|
|
513
|
+
console.log(chalk.dim(`\n Adding project "${projectId}"...\n`));
|
|
514
|
+
// Validate git repo
|
|
515
|
+
const isGitRepo = (await git(["rev-parse", "--git-dir"], resolvedPath)) !== null;
|
|
516
|
+
if (!isGitRepo) {
|
|
517
|
+
throw new Error(`"${resolvedPath}" is not a git repository.`);
|
|
518
|
+
}
|
|
519
|
+
// Detect git remote
|
|
520
|
+
let ownerRepo = null;
|
|
521
|
+
const gitRemote = await git(["remote", "get-url", "origin"], resolvedPath);
|
|
522
|
+
if (gitRemote) {
|
|
523
|
+
ownerRepo = extractOwnerRepo(gitRemote);
|
|
524
|
+
}
|
|
525
|
+
// If no repo detected, prompt the user (same as autoCreateConfig)
|
|
526
|
+
/* c8 ignore start -- interactive prompt */
|
|
527
|
+
if (!ownerRepo && isHumanCaller()) {
|
|
528
|
+
console.log(chalk.yellow(" ⚠ Could not auto-detect a GitHub/GitLab remote."));
|
|
529
|
+
const entered = await promptText(" Enter repo (owner/repo or group/subgroup/repo) or leave empty to skip:", "owner/repo");
|
|
530
|
+
const trimmed = (entered || "").trim();
|
|
531
|
+
if (trimmed && isValidRepoString(trimmed)) {
|
|
532
|
+
ownerRepo = trimmed;
|
|
533
|
+
console.log(chalk.green(` ✓ Repo: ${ownerRepo}`));
|
|
534
|
+
}
|
|
535
|
+
else if (trimmed) {
|
|
536
|
+
console.log(chalk.yellow(` ⚠ "${trimmed}" doesn't look like a valid repo path — skipping.`));
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
/* c8 ignore stop */
|
|
540
|
+
const defaultBranch = await detectDefaultBranch(resolvedPath, ownerRepo);
|
|
541
|
+
// Generate unique session prefix
|
|
542
|
+
let prefix = generateSessionPrefix(projectId);
|
|
543
|
+
const existingPrefixes = new Set(Object.values(config.projects).map((p) => p.sessionPrefix || generateSessionPrefix(basename(p.path))));
|
|
544
|
+
if (existingPrefixes.has(prefix)) {
|
|
545
|
+
let i = 2;
|
|
546
|
+
while (existingPrefixes.has(`${prefix}${i}`))
|
|
547
|
+
i++;
|
|
548
|
+
prefix = `${prefix}${i}`;
|
|
549
|
+
}
|
|
550
|
+
// Detect project type and generate rules
|
|
551
|
+
const projectType = detectProjectType(resolvedPath);
|
|
552
|
+
const agentRules = generateRulesFromTemplates(projectType);
|
|
553
|
+
// Show what was detected
|
|
554
|
+
console.log(chalk.green(` ✓ Git repository`));
|
|
555
|
+
if (ownerRepo) {
|
|
556
|
+
console.log(chalk.dim(` Remote: ${ownerRepo}`));
|
|
557
|
+
}
|
|
558
|
+
console.log(chalk.dim(` Default branch: ${defaultBranch}`));
|
|
559
|
+
console.log(chalk.dim(` Session prefix: ${prefix}`));
|
|
560
|
+
if (projectType.languages.length > 0 || projectType.frameworks.length > 0) {
|
|
561
|
+
console.log(chalk.green(" ✓ Project type detected"));
|
|
562
|
+
const formattedType = formatProjectTypeForDisplay(projectType);
|
|
563
|
+
formattedType.split("\n").forEach((line) => {
|
|
564
|
+
console.log(chalk.dim(` ${line}`));
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
if (isCanonicalGlobalConfigPath(config.configPath)) {
|
|
568
|
+
const registeredProjectId = registerProjectInGlobalConfig(projectId, projectId, resolvedPath, { defaultBranch, sessionPrefix: prefix }, config.configPath);
|
|
569
|
+
writeProjectBehaviorConfig(resolvedPath, agentRules ? { agentRules } : {});
|
|
570
|
+
console.log(chalk.green(`\n✓ Added "${registeredProjectId}" to ${config.configPath}\n`));
|
|
571
|
+
return registeredProjectId;
|
|
572
|
+
}
|
|
573
|
+
else {
|
|
574
|
+
// Load raw YAML, append project, rewrite
|
|
575
|
+
const rawYaml = readFileSync(config.configPath, "utf-8");
|
|
576
|
+
const rawConfig = yamlParse(rawYaml);
|
|
577
|
+
if (!rawConfig.projects)
|
|
578
|
+
rawConfig.projects = {};
|
|
579
|
+
rawConfig.projects[projectId] = {
|
|
580
|
+
name: projectId,
|
|
581
|
+
...(ownerRepo ? { repo: ownerRepo } : {}),
|
|
582
|
+
path: resolvedPath,
|
|
583
|
+
defaultBranch,
|
|
584
|
+
sessionPrefix: prefix,
|
|
585
|
+
...(agentRules ? { agentRules } : {}),
|
|
586
|
+
};
|
|
587
|
+
writeFileSync(config.configPath, configToYaml(rawConfig));
|
|
588
|
+
console.log(chalk.green(`\n✓ Added "${projectId}" to ${config.configPath}\n`));
|
|
589
|
+
}
|
|
590
|
+
if (!ownerRepo) {
|
|
591
|
+
console.log(chalk.yellow("⚠ No repo configured — issue tracking and PR features will be unavailable."));
|
|
592
|
+
console.log(chalk.dim(" Add a 'repo' field (owner/repo) to the config to enable them.\n"));
|
|
593
|
+
}
|
|
594
|
+
return projectId;
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Start dashboard server in the background.
|
|
598
|
+
* Returns the child process handle for cleanup.
|
|
599
|
+
*/
|
|
600
|
+
/* c8 ignore start -- process-spawning startup code, tested via integration/onboarding */
|
|
601
|
+
async function startDashboard(port, webDir, configPath, terminalPort, directTerminalPort, devMode) {
|
|
602
|
+
const env = await buildDashboardEnv(port, configPath, terminalPort, directTerminalPort);
|
|
603
|
+
// Detect monorepo vs npm install: the `server/` source directory only exists
|
|
604
|
+
// in the monorepo. Published npm packages only have `dist-server/`.
|
|
605
|
+
const isMonorepo = existsSync(resolve(webDir, "server"));
|
|
606
|
+
// In monorepo: use HMR dev server only when --dev is passed explicitly.
|
|
607
|
+
// Default is optimized production server for faster loading.
|
|
608
|
+
const useDevServer = isMonorepo && devMode === true;
|
|
609
|
+
let child;
|
|
610
|
+
if (useDevServer) {
|
|
611
|
+
// Monorepo with --dev: use pnpm run dev (tsx watch, HMR, etc.)
|
|
612
|
+
console.log(chalk.dim(" Mode: development (HMR enabled)"));
|
|
613
|
+
child = spawnManagedDaemonChild("dashboard", "pnpm", ["run", "dev"], {
|
|
614
|
+
cwd: webDir,
|
|
615
|
+
stdio: "inherit",
|
|
616
|
+
detached: !isWindows(),
|
|
617
|
+
env,
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
// Production: use pre-built start-all script.
|
|
622
|
+
if (isMonorepo) {
|
|
623
|
+
console.log(chalk.dim(" Mode: optimized (production bundles)"));
|
|
624
|
+
console.log(chalk.dim(" Tip: use --dev for hot reload when editing dashboard UI\n"));
|
|
625
|
+
}
|
|
626
|
+
const startScript = resolve(webDir, "dist-server", "start-all.js");
|
|
627
|
+
child = spawnManagedDaemonChild("dashboard", "node", [startScript], {
|
|
628
|
+
cwd: webDir,
|
|
629
|
+
stdio: "inherit",
|
|
630
|
+
detached: !isWindows(),
|
|
631
|
+
env,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
child.on("error", (err) => {
|
|
635
|
+
const cmd = useDevServer ? "pnpm" : "node";
|
|
636
|
+
const args = useDevServer ? ["run", "dev"] : [resolve(webDir, "dist-server", "start-all.js")];
|
|
637
|
+
const formatted = formatCommandError(err, {
|
|
638
|
+
cmd,
|
|
639
|
+
args,
|
|
640
|
+
action: "start the AO dashboard",
|
|
641
|
+
installHints: genericInstallHints(cmd),
|
|
642
|
+
});
|
|
643
|
+
console.error(chalk.red("Dashboard failed to start:"), formatted.message);
|
|
644
|
+
// Emit synthetic exit so callers listening on "exit" can clean up
|
|
645
|
+
child.emit("exit", 1, null);
|
|
646
|
+
});
|
|
647
|
+
return child;
|
|
648
|
+
}
|
|
649
|
+
/* c8 ignore stop */
|
|
650
|
+
/**
|
|
651
|
+
* Shared startup logic: launch dashboard + orchestrator session, print summary.
|
|
652
|
+
* Used by both normal and URL-based start flows.
|
|
653
|
+
*/
|
|
654
|
+
async function runStartup(config, projectId, project, opts) {
|
|
655
|
+
await runtimePreflight(config);
|
|
656
|
+
// Ask about the auto-update channel once on first `athene start` after this
|
|
657
|
+
// feature ships. No-op on subsequent runs (idempotent — guarded by the
|
|
658
|
+
// presence of `updateChannel` in the global config).
|
|
659
|
+
await maybePromptForUpdateChannel();
|
|
660
|
+
// Install the parent shutdown path before spawning any managed children.
|
|
661
|
+
// This guarantees a SIGINT/SIGTERM in the middle of startup still performs
|
|
662
|
+
// the full AO cleanup instead of relying on Node's default signal exit.
|
|
663
|
+
installShutdownHandlers({ configPath: config.configPath, projectId });
|
|
664
|
+
const shouldStartLifecycle = opts?.dashboard !== false || opts?.orchestrator !== false;
|
|
665
|
+
let port = config.port ?? DEFAULT_PORT;
|
|
666
|
+
console.log(chalk.bold(`\nStarting orchestrator for ${chalk.cyan(project.name)}\n`));
|
|
667
|
+
const spinner = ora();
|
|
668
|
+
let dashboardProcess = null;
|
|
669
|
+
let restored = false;
|
|
670
|
+
// Start dashboard (unless --no-dashboard)
|
|
671
|
+
if (opts?.dashboard !== false) {
|
|
672
|
+
const requestedDashboardPort = port;
|
|
673
|
+
if (!(await isPortAvailable(port))) {
|
|
674
|
+
const newPort = await findFreePort(port + 1);
|
|
675
|
+
if (newPort === null) {
|
|
676
|
+
throw new Error(`Port ${port} is busy and no free port found in range ${port + 1}–${port + MAX_PORT_SCAN}. Free port ${port} or set a different 'port' in agent-orchestrator.yaml.`);
|
|
677
|
+
}
|
|
678
|
+
console.log(chalk.yellow(`Port ${port} is busy — using ${newPort} instead.`));
|
|
679
|
+
port = newPort;
|
|
680
|
+
}
|
|
681
|
+
const webDir = findWebDir(); // throws with install-specific guidance if not found
|
|
682
|
+
// Dev mode (HMR) only works in the monorepo where `server/` source exists.
|
|
683
|
+
// For npm installs, --dev is silently ignored and production server runs,
|
|
684
|
+
// so preflight must still verify production artifacts exist.
|
|
685
|
+
const isMonorepo = existsSync(resolve(webDir, "server"));
|
|
686
|
+
const willUseDevServer = isMonorepo && opts?.dev === true;
|
|
687
|
+
if (opts?.rebuild) {
|
|
688
|
+
await rebuildDashboardProductionArtifacts(webDir, [
|
|
689
|
+
...new Set([requestedDashboardPort, port]),
|
|
690
|
+
]);
|
|
691
|
+
}
|
|
692
|
+
else if (!willUseDevServer) {
|
|
693
|
+
await preflight.checkBuilt(webDir);
|
|
694
|
+
await clearStaleCacheIfNeeded(webDir);
|
|
695
|
+
}
|
|
696
|
+
spinner.start("Starting dashboard");
|
|
697
|
+
dashboardProcess = await startDashboard(port, webDir, config.configPath, config.terminalPort, config.directTerminalPort, opts?.dev);
|
|
698
|
+
spinner.succeed(`Dashboard starting on ${dashboardUrl(port)}`);
|
|
699
|
+
console.log(chalk.dim(" (Dashboard will be ready in a few seconds)\n"));
|
|
700
|
+
}
|
|
701
|
+
let selectedOrchestratorId = null;
|
|
702
|
+
if (opts?.orchestrator !== false) {
|
|
703
|
+
const sm = await getSessionManager(config);
|
|
704
|
+
try {
|
|
705
|
+
spinner.start("Ensuring orchestrator session");
|
|
706
|
+
const systemPrompt = generateOrchestratorPrompt({ config, projectId, project });
|
|
707
|
+
const before = await sm.get(getOrchestratorSessionId(project));
|
|
708
|
+
const session = await sm.ensureOrchestrator({ projectId, systemPrompt });
|
|
709
|
+
selectedOrchestratorId = session.id;
|
|
710
|
+
restored = Boolean(session.restoredAt);
|
|
711
|
+
if (before && session.id === before.id && !restored) {
|
|
712
|
+
spinner.succeed(`Using orchestrator session: ${session.id}`);
|
|
713
|
+
}
|
|
714
|
+
else if (restored) {
|
|
715
|
+
spinner.succeed(`Restored orchestrator session: ${session.id}`);
|
|
716
|
+
}
|
|
717
|
+
else {
|
|
718
|
+
spinner.succeed(`Orchestrator session ready: ${session.id}`);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
catch (err) {
|
|
722
|
+
spinner.fail("Orchestrator setup failed");
|
|
723
|
+
recordActivityEvent({
|
|
724
|
+
projectId,
|
|
725
|
+
source: "cli",
|
|
726
|
+
kind: "cli.start_failed",
|
|
727
|
+
level: "error",
|
|
728
|
+
summary: `orchestrator setup failed`,
|
|
729
|
+
data: {
|
|
730
|
+
reason: "orchestrator_setup",
|
|
731
|
+
errorMessage: err instanceof Error ? err.message : String(err),
|
|
732
|
+
},
|
|
733
|
+
});
|
|
734
|
+
if (dashboardProcess) {
|
|
735
|
+
dashboardProcess.kill();
|
|
736
|
+
}
|
|
737
|
+
throw new CliFailureEventRecordedError(`Failed to setup orchestrator: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
if (shouldStartLifecycle) {
|
|
741
|
+
try {
|
|
742
|
+
spinner.start("Starting project supervisor");
|
|
743
|
+
await startProjectSupervisor({ configPath: config.configPath });
|
|
744
|
+
spinner.succeed("Lifecycle project supervisor started");
|
|
745
|
+
}
|
|
746
|
+
catch (err) {
|
|
747
|
+
spinner.fail("Project supervisor failed to start");
|
|
748
|
+
recordActivityEvent({
|
|
749
|
+
projectId,
|
|
750
|
+
source: "cli",
|
|
751
|
+
kind: "cli.start_failed",
|
|
752
|
+
level: "error",
|
|
753
|
+
summary: `project supervisor failed to start`,
|
|
754
|
+
data: {
|
|
755
|
+
reason: "supervisor_start",
|
|
756
|
+
errorMessage: err instanceof Error ? err.message : String(err),
|
|
757
|
+
},
|
|
758
|
+
});
|
|
759
|
+
if (dashboardProcess) {
|
|
760
|
+
dashboardProcess.kill();
|
|
761
|
+
}
|
|
762
|
+
throw new CliFailureEventRecordedError(`Failed to start project supervisor: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
// Check for sessions from last `athene stop` and restore/prompt/skip based on caller intent.
|
|
766
|
+
if (opts?.restore !== false && (opts?.restore === true || isHumanCaller())) {
|
|
767
|
+
try {
|
|
768
|
+
const lastStop = await readLastStop();
|
|
769
|
+
const totalLastStopSessions = (lastStop?.sessionIds.length ?? 0) +
|
|
770
|
+
(lastStop?.otherProjects ?? []).reduce((sum, p) => sum + p.sessionIds.length, 0);
|
|
771
|
+
if (lastStop && totalLastStopSessions > 0) {
|
|
772
|
+
const stoppedAgo = `stopped at ${new Date(lastStop.stoppedAt).toLocaleString()}`;
|
|
773
|
+
const otherProjects = lastStop.otherProjects ?? [];
|
|
774
|
+
const restoreProjectBySessionId = new Map();
|
|
775
|
+
// Build flat list of all sessions to restore, grouped for display
|
|
776
|
+
const allRestoreSessions = [
|
|
777
|
+
...(lastStop.projectId === projectId ? lastStop.sessionIds : []),
|
|
778
|
+
...otherProjects.flatMap((p) => p.sessionIds),
|
|
779
|
+
];
|
|
780
|
+
for (const sessionId of lastStop.sessionIds) {
|
|
781
|
+
restoreProjectBySessionId.set(sessionId, lastStop.projectId);
|
|
782
|
+
}
|
|
783
|
+
for (const otherProject of otherProjects) {
|
|
784
|
+
for (const sessionId of otherProject.sessionIds) {
|
|
785
|
+
restoreProjectBySessionId.set(sessionId, otherProject.projectId);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
// Display grouped by project
|
|
789
|
+
const currentProjectSessions = lastStop.projectId === projectId ? lastStop.sessionIds : [];
|
|
790
|
+
if (currentProjectSessions.length > 0) {
|
|
791
|
+
console.log(chalk.yellow(`\n ${currentProjectSessions.length} session(s) were active before last athene stop (${stoppedAgo}):`));
|
|
792
|
+
console.log(chalk.dim(` ${currentProjectSessions.join(", ")}\n`));
|
|
793
|
+
}
|
|
794
|
+
if (otherProjects.length > 0) {
|
|
795
|
+
const otherTotal = otherProjects.reduce((sum, p) => sum + p.sessionIds.length, 0);
|
|
796
|
+
console.log(chalk.yellow(` ${otherTotal} session(s) from other projects were also stopped:`));
|
|
797
|
+
for (const p of otherProjects) {
|
|
798
|
+
console.log(chalk.dim(` ${p.projectId}: ${p.sessionIds.join(", ")}`));
|
|
799
|
+
}
|
|
800
|
+
console.log();
|
|
801
|
+
}
|
|
802
|
+
if (allRestoreSessions.length > 0) {
|
|
803
|
+
const shouldRestore = opts?.restore === true ? true : await promptConfirm("Restore these sessions?", true);
|
|
804
|
+
if (shouldRestore) {
|
|
805
|
+
recordActivityEvent({
|
|
806
|
+
projectId,
|
|
807
|
+
source: "cli",
|
|
808
|
+
kind: "cli.restore_started",
|
|
809
|
+
level: "info",
|
|
810
|
+
summary: `restoring ${allRestoreSessions.length} session(s) from last-stop`,
|
|
811
|
+
data: {
|
|
812
|
+
sessionCount: allRestoreSessions.length,
|
|
813
|
+
stoppedAt: lastStop.stoppedAt,
|
|
814
|
+
},
|
|
815
|
+
});
|
|
816
|
+
// Use global config so the session manager can see all projects
|
|
817
|
+
let restoreConfig = config;
|
|
818
|
+
if (otherProjects.length > 0) {
|
|
819
|
+
const globalPath = getGlobalConfigPath();
|
|
820
|
+
if (existsSync(globalPath)) {
|
|
821
|
+
restoreConfig = loadConfig(globalPath);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
const sm = await getSessionManager(restoreConfig);
|
|
825
|
+
const restoreSpinner = ora(`Restoring ${allRestoreSessions.length} session(s)`).start();
|
|
826
|
+
let restoredCount = 0;
|
|
827
|
+
const failedSessionIds = new Set();
|
|
828
|
+
const warnings = [];
|
|
829
|
+
for (const sessionId of allRestoreSessions) {
|
|
830
|
+
// Skip the orchestrator — it was already restored by ensureOrchestrator above
|
|
831
|
+
if (selectedOrchestratorId && sessionId === selectedOrchestratorId) {
|
|
832
|
+
restoredCount++;
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
try {
|
|
836
|
+
await sm.restore(sessionId);
|
|
837
|
+
restoredCount++;
|
|
838
|
+
}
|
|
839
|
+
catch (err) {
|
|
840
|
+
failedSessionIds.add(sessionId);
|
|
841
|
+
const restoreProjectId = restoreProjectBySessionId.get(sessionId) ?? projectId;
|
|
842
|
+
recordActivityEvent({
|
|
843
|
+
projectId: restoreProjectId,
|
|
844
|
+
sessionId,
|
|
845
|
+
source: "cli",
|
|
846
|
+
kind: "cli.restore_session_failed",
|
|
847
|
+
level: "warn",
|
|
848
|
+
summary: `failed to restore session`,
|
|
849
|
+
data: { errorMessage: err instanceof Error ? err.message : String(err) },
|
|
850
|
+
});
|
|
851
|
+
warnings.push(` Warning: could not restore ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
recordActivityEvent({
|
|
855
|
+
projectId,
|
|
856
|
+
source: "cli",
|
|
857
|
+
kind: "cli.restore_completed",
|
|
858
|
+
level: "info",
|
|
859
|
+
summary: `restored ${restoredCount}/${allRestoreSessions.length} session(s)`,
|
|
860
|
+
data: {
|
|
861
|
+
requested: allRestoreSessions.length,
|
|
862
|
+
restored: restoredCount,
|
|
863
|
+
failed: failedSessionIds.size,
|
|
864
|
+
},
|
|
865
|
+
});
|
|
866
|
+
if (restoredCount === allRestoreSessions.length) {
|
|
867
|
+
restoreSpinner.succeed(`Restored ${restoredCount}/${allRestoreSessions.length} session(s)`);
|
|
868
|
+
}
|
|
869
|
+
else {
|
|
870
|
+
restoreSpinner.warn(`Restored ${restoredCount}/${allRestoreSessions.length} session(s)`);
|
|
871
|
+
}
|
|
872
|
+
for (const w of warnings) {
|
|
873
|
+
console.log(chalk.yellow(w));
|
|
874
|
+
}
|
|
875
|
+
// Preserve restore state for sessions that failed (transient
|
|
876
|
+
// workspace/runtime errors). Without this, a single failure on
|
|
877
|
+
// the first `athene start` would erase the only persisted record
|
|
878
|
+
// and the remaining sessions would never be retryable. When
|
|
879
|
+
// every session restored (or was skipped), clear the file.
|
|
880
|
+
if (failedSessionIds.size > 0) {
|
|
881
|
+
const remainingTarget = lastStop.sessionIds.filter((id) => failedSessionIds.has(id));
|
|
882
|
+
const remainingOther = otherProjects
|
|
883
|
+
.map((p) => ({
|
|
884
|
+
projectId: p.projectId,
|
|
885
|
+
sessionIds: p.sessionIds.filter((id) => failedSessionIds.has(id)),
|
|
886
|
+
}))
|
|
887
|
+
.filter((p) => p.sessionIds.length > 0);
|
|
888
|
+
if (remainingTarget.length > 0 || remainingOther.length > 0) {
|
|
889
|
+
await writeLastStop({
|
|
890
|
+
stoppedAt: lastStop.stoppedAt,
|
|
891
|
+
projectId: lastStop.projectId,
|
|
892
|
+
sessionIds: remainingTarget,
|
|
893
|
+
...(remainingOther.length > 0 ? { otherProjects: remainingOther } : {}),
|
|
894
|
+
});
|
|
895
|
+
console.log(chalk.dim(` Kept ${failedSessionIds.size} session(s) in last-stop record for retry on next athene start.\n`));
|
|
896
|
+
}
|
|
897
|
+
else {
|
|
898
|
+
await clearLastStop();
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
else {
|
|
902
|
+
await clearLastStop();
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
else {
|
|
906
|
+
// User declined restore — clear the record.
|
|
907
|
+
await clearLastStop();
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
else {
|
|
911
|
+
await clearLastStop();
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
catch (err) {
|
|
916
|
+
recordActivityEvent({
|
|
917
|
+
projectId,
|
|
918
|
+
source: "cli",
|
|
919
|
+
kind: "cli.last_stop_read_failed",
|
|
920
|
+
level: "warn",
|
|
921
|
+
summary: `failed to read or process last-stop state during startup`,
|
|
922
|
+
data: { errorMessage: err instanceof Error ? err.message : String(err) },
|
|
923
|
+
});
|
|
924
|
+
// Non-fatal: don't block startup if last-stop handling fails
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
// Print summary
|
|
928
|
+
console.log(chalk.bold.green("\n✓ Startup complete\n"));
|
|
929
|
+
if (opts?.dashboard !== false) {
|
|
930
|
+
console.log(chalk.cyan("Dashboard:"), dashboardUrl(port));
|
|
931
|
+
}
|
|
932
|
+
if (shouldStartLifecycle) {
|
|
933
|
+
const supervisedProjects = listLifecycleWorkers().sort();
|
|
934
|
+
const projectSummary = supervisedProjects.length > 0 ? `: ${supervisedProjects.join(", ")}` : "";
|
|
935
|
+
console.log(chalk.cyan("Lifecycle:"), `supervised (polling ${supervisedProjects.length} project(s)${projectSummary})`);
|
|
936
|
+
}
|
|
937
|
+
if (opts?.orchestrator !== false && selectedOrchestratorId) {
|
|
938
|
+
const restoreNote = restored ? " (restored)" : "";
|
|
939
|
+
const target = opts?.dashboard !== false
|
|
940
|
+
? projectSessionUrl(port, projectId, selectedOrchestratorId)
|
|
941
|
+
: `athene session attach ${selectedOrchestratorId}`;
|
|
942
|
+
console.log(chalk.cyan("Orchestrator:"), `${target}${restoreNote}`);
|
|
943
|
+
}
|
|
944
|
+
console.log(chalk.dim(`Config: ${config.configPath}`));
|
|
945
|
+
// Auto-open browser once the server is ready.
|
|
946
|
+
// Navigate directly to the deterministic main orchestrator when one is available.
|
|
947
|
+
// Polls the port instead of using a fixed delay — deterministic and works regardless of
|
|
948
|
+
// how long Next.js takes to compile. AbortController cancels polling on early exit.
|
|
949
|
+
let openAbort;
|
|
950
|
+
if (opts?.dashboard !== false) {
|
|
951
|
+
openAbort = new AbortController();
|
|
952
|
+
const orchestratorUrl = selectedOrchestratorId
|
|
953
|
+
? projectSessionUrl(port, projectId, selectedOrchestratorId)
|
|
954
|
+
: dashboardUrl(port);
|
|
955
|
+
void waitForPortAndOpen(port, orchestratorUrl, openAbort.signal);
|
|
956
|
+
}
|
|
957
|
+
// Keep dashboard process alive if it was started
|
|
958
|
+
if (dashboardProcess) {
|
|
959
|
+
dashboardProcess.on("exit", (code) => {
|
|
960
|
+
if (openAbort)
|
|
961
|
+
openAbort.abort();
|
|
962
|
+
if (isShutdownInProgress())
|
|
963
|
+
return;
|
|
964
|
+
if (code !== 0 && code !== null) {
|
|
965
|
+
console.error(chalk.red(`Dashboard exited with code ${code}`));
|
|
966
|
+
}
|
|
967
|
+
process.exit(code ?? 0);
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
return port;
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* Stop dashboard server.
|
|
974
|
+
* Uses platform adapter to find the process listening on the port, then kills it.
|
|
975
|
+
* Best effort — if it fails, just warn the user.
|
|
976
|
+
*/
|
|
977
|
+
/** Pattern matching AO dashboard processes (production and dev mode). */
|
|
978
|
+
const DASHBOARD_CMD_PATTERN = /next-server|start-all\.js|next dev|ao-web/;
|
|
979
|
+
/**
|
|
980
|
+
* Check whether a process listening on the given port is an AO dashboard
|
|
981
|
+
* (next-server, start-all.js, or next dev). Only kills matching PIDs,
|
|
982
|
+
* leaving unrelated co-listeners (sidecars, SO_REUSEPORT) untouched.
|
|
983
|
+
*/
|
|
984
|
+
async function killDashboardOnPort(port) {
|
|
985
|
+
try {
|
|
986
|
+
const pid = await findPidByPort(port);
|
|
987
|
+
if (!pid)
|
|
988
|
+
return false;
|
|
989
|
+
// On Unix, verify the process is actually a dashboard before killing so
|
|
990
|
+
// unrelated co-listeners (sidecars, SO_REUSEPORT) are left untouched.
|
|
991
|
+
// findPidByPort on Windows uses netstat; we trust the port match there.
|
|
992
|
+
if (!isWindows()) {
|
|
993
|
+
try {
|
|
994
|
+
const { stdout: cmdline } = await exec("ps", ["-p", String(pid), "-o", "args="]);
|
|
995
|
+
if (!DASHBOARD_CMD_PATTERN.test(cmdline))
|
|
996
|
+
return false;
|
|
997
|
+
}
|
|
998
|
+
catch {
|
|
999
|
+
return false;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
await killProcessTree(Number(pid));
|
|
1003
|
+
return true;
|
|
1004
|
+
}
|
|
1005
|
+
catch {
|
|
1006
|
+
return false;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
async function stopDashboard(port) {
|
|
1010
|
+
// 1. Try the expected port — verify it's a dashboard before killing
|
|
1011
|
+
if (await killDashboardOnPort(port)) {
|
|
1012
|
+
console.log(chalk.green("Dashboard stopped"));
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
// 2. Fallback: scan nearby ports to find an orphaned dashboard
|
|
1016
|
+
// that was auto-reassigned when the original port was busy.
|
|
1017
|
+
// Uses killDashboardOnPort to verify the process is actually an
|
|
1018
|
+
// AO dashboard before killing, avoiding collateral damage.
|
|
1019
|
+
for (let p = port + 1; p <= port + MAX_PORT_SCAN; p++) {
|
|
1020
|
+
if (await killDashboardOnPort(p)) {
|
|
1021
|
+
console.log(chalk.green(`Dashboard stopped (was on port ${p})`));
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
console.log(chalk.yellow("Could not stop dashboard (may not be running)"));
|
|
1026
|
+
}
|
|
1027
|
+
function formatSweepSummary(result) {
|
|
1028
|
+
return `${result.terminated} graceful, ${result.forceKilled} force-killed${result.failed > 0 ? `, ${result.failed} failed` : ""}`;
|
|
1029
|
+
}
|
|
1030
|
+
async function sweepRegisteredDaemonChildren(ownerPid) {
|
|
1031
|
+
const result = await sweepDaemonChildren({ ownerPid });
|
|
1032
|
+
if (result.attempted > 0) {
|
|
1033
|
+
console.log(chalk.dim(` Swept ${result.attempted} registered daemon child(ren): ${formatSweepSummary(result)}`));
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
function describeAoOrphans(orphans) {
|
|
1037
|
+
return orphans
|
|
1038
|
+
.map((orphan) => `${orphan.pid} (${orphan.role})`)
|
|
1039
|
+
.slice(0, 8)
|
|
1040
|
+
.join(", ");
|
|
1041
|
+
}
|
|
1042
|
+
async function maybeSweepAoOrphansOnStart(reapOrphans) {
|
|
1043
|
+
const orphans = await scanAoOrphans();
|
|
1044
|
+
if (orphans.length === 0)
|
|
1045
|
+
return;
|
|
1046
|
+
if (!reapOrphans && isHumanCaller()) {
|
|
1047
|
+
console.log(chalk.yellow(`\n Found ${orphans.length} orphaned AO child process(es): ${describeAoOrphans(orphans)}`));
|
|
1048
|
+
reapOrphans = await promptConfirm("Kill orphaned AO child processes before starting?", true);
|
|
1049
|
+
}
|
|
1050
|
+
if (!reapOrphans) {
|
|
1051
|
+
console.log(chalk.yellow(` Found ${orphans.length} orphaned AO child process(es). Run \`athene start --reap-orphans\` to clean them up.`));
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
const result = await reapAoOrphans(orphans);
|
|
1055
|
+
console.log(chalk.green(` Reaped ${result.attempted} orphaned AO child process(es): ${formatSweepSummary(result)}`));
|
|
1056
|
+
}
|
|
1057
|
+
/**
|
|
1058
|
+
* Spawn an orchestrator session against an already-running daemon, invalidate
|
|
1059
|
+
* the dashboard's project cache, and surface enough context for the user to
|
|
1060
|
+
* find the new session.
|
|
1061
|
+
*
|
|
1062
|
+
* Replaces the per-arg-shape inline blocks (§3.2 URL/path-while-running and
|
|
1063
|
+
* §3.3 project-id-while-running) that previously each carried their own
|
|
1064
|
+
* messaging + reload + browser-open code. The two flows differ only in which
|
|
1065
|
+
* line of "registered" vs "reattached" they print, driven by `justCreated`.
|
|
1066
|
+
*/
|
|
1067
|
+
async function attachAndSpawnOrchestrator(opts) {
|
|
1068
|
+
const { running, config, projectId, project, justCreated } = opts;
|
|
1069
|
+
const daemon = attachToDaemon(running);
|
|
1070
|
+
console.log(chalk.dim(justCreated
|
|
1071
|
+
? "\n Spawning orchestrator session...\n"
|
|
1072
|
+
: "\n Attaching to running AO instance...\n"));
|
|
1073
|
+
const sm = await getSessionManager(config);
|
|
1074
|
+
const systemPrompt = generateOrchestratorPrompt({ config, projectId, project });
|
|
1075
|
+
const session = await sm.ensureOrchestrator({ projectId, systemPrompt });
|
|
1076
|
+
if (justCreated) {
|
|
1077
|
+
console.log(chalk.green(`\n✓ Project "${projectId}" registered in the global config.`));
|
|
1078
|
+
console.log(chalk.green(`✓ Orchestrator session ready: ${session.id}`));
|
|
1079
|
+
}
|
|
1080
|
+
else {
|
|
1081
|
+
console.log(chalk.green(`✓ Orchestrator session ready: ${session.id}`));
|
|
1082
|
+
console.log(chalk.green(`✓ Project "${projectId}" reattached to running daemon (PID ${daemon.pid}).`));
|
|
1083
|
+
}
|
|
1084
|
+
const notifyResult = await daemon.notifyProjectChange();
|
|
1085
|
+
if (notifyResult.ok) {
|
|
1086
|
+
console.log(chalk.dim(` Dashboard config reloaded.`));
|
|
1087
|
+
}
|
|
1088
|
+
else {
|
|
1089
|
+
console.log(chalk.yellow(` ⚠ ${notifyResult.reason}. Refresh the page if the project doesn't show up.`));
|
|
1090
|
+
}
|
|
1091
|
+
if (!running.projects.includes(projectId)) {
|
|
1092
|
+
console.log(chalk.yellow(`\nℹ Lifecycle polling for "${projectId}" will attach within ~60s\n` +
|
|
1093
|
+
` because the running athene start process now supervises active global projects.\n`));
|
|
1094
|
+
}
|
|
1095
|
+
if (isHumanCaller()) {
|
|
1096
|
+
console.log(chalk.dim(` Opening dashboard: ${dashboardUrl(daemon.port)}\n`));
|
|
1097
|
+
openUrl(dashboardUrl(daemon.port));
|
|
1098
|
+
}
|
|
1099
|
+
else {
|
|
1100
|
+
console.log(`Dashboard: ${dashboardUrl(daemon.port)}`);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
// =============================================================================
|
|
1104
|
+
// COMMAND REGISTRATION
|
|
1105
|
+
// =============================================================================
|
|
1106
|
+
export function registerStart(program) {
|
|
1107
|
+
program
|
|
1108
|
+
.command("start [project]")
|
|
1109
|
+
.description("Start orchestrator agent and dashboard (auto-creates config on first run, adds projects by path/URL)")
|
|
1110
|
+
.option("--no-dashboard", "Skip starting the dashboard server")
|
|
1111
|
+
.option("--no-orchestrator", "Skip starting the orchestrator agent")
|
|
1112
|
+
.option("--rebuild", "Clean and rebuild dashboard before starting")
|
|
1113
|
+
.option("--dev", "Use Next.js dev server with hot reload (for dashboard UI development)")
|
|
1114
|
+
.option("--interactive", "Prompt to configure config settings")
|
|
1115
|
+
.option("--reap-orphans", "Kill orphaned AO child processes before starting")
|
|
1116
|
+
.option("--restore", "Restore sessions from last athene stop without prompting")
|
|
1117
|
+
.option("--no-restore", "Skip restoring sessions from last athene stop")
|
|
1118
|
+
.action(async (projectArg, opts) => {
|
|
1119
|
+
recordActivityEvent({
|
|
1120
|
+
source: "cli",
|
|
1121
|
+
kind: "cli.start_invoked",
|
|
1122
|
+
level: "info",
|
|
1123
|
+
summary: "athene start invoked",
|
|
1124
|
+
data: {
|
|
1125
|
+
projectArg: projectArg ?? null,
|
|
1126
|
+
dashboard: opts?.dashboard !== false,
|
|
1127
|
+
orchestrator: opts?.orchestrator !== false,
|
|
1128
|
+
rebuild: opts?.rebuild === true,
|
|
1129
|
+
dev: opts?.dev === true,
|
|
1130
|
+
interactive: opts?.interactive === true,
|
|
1131
|
+
},
|
|
1132
|
+
});
|
|
1133
|
+
let releaseStartupLock;
|
|
1134
|
+
let startupLockReleased = false;
|
|
1135
|
+
const unlockStartup = () => {
|
|
1136
|
+
if (startupLockReleased || !releaseStartupLock)
|
|
1137
|
+
return;
|
|
1138
|
+
startupLockReleased = true;
|
|
1139
|
+
releaseStartupLock();
|
|
1140
|
+
};
|
|
1141
|
+
try {
|
|
1142
|
+
releaseStartupLock = await acquireStartupLock();
|
|
1143
|
+
await maybeSweepAoOrphansOnStart(opts?.reapOrphans);
|
|
1144
|
+
let config;
|
|
1145
|
+
let projectId;
|
|
1146
|
+
let project;
|
|
1147
|
+
// ── Already-running detection (before any config mutation) ──
|
|
1148
|
+
let running = await isAlreadyRunning();
|
|
1149
|
+
let startNewOrchestrator = false;
|
|
1150
|
+
const isProjectId = projectArg && !isRepoUrl(projectArg) && !isLocalPath(projectArg);
|
|
1151
|
+
const projectArgIsUrlOrPath = !!projectArg && (isRepoUrl(projectArg) || isLocalPath(projectArg));
|
|
1152
|
+
// ── Already-running dispatch ──
|
|
1153
|
+
// Whether we attach to a live daemon or spawn a new one, the
|
|
1154
|
+
// project-resolution + orchestrator-spawn steps are the same.
|
|
1155
|
+
// The fork lives in two places: this menu (human caller, no
|
|
1156
|
+
// arg) where the user can quit/open/add-cwd/restart/spawn-new,
|
|
1157
|
+
// and the post-resolve branch below that calls either
|
|
1158
|
+
// attachAndSpawnOrchestrator (running) or runStartup (not).
|
|
1159
|
+
if (running) {
|
|
1160
|
+
if (!isHumanCaller() && !isProjectId) {
|
|
1161
|
+
// Non-human caller, no arg or URL/path arg: print info and
|
|
1162
|
+
// exit. Project-id args fall through to attach+spawn so
|
|
1163
|
+
// automation can `athene start <id>` against a live daemon.
|
|
1164
|
+
console.log(`AO is already running.`);
|
|
1165
|
+
console.log(`Dashboard: ${dashboardUrl(running.port)}`);
|
|
1166
|
+
console.log(`PID: ${running.pid}`);
|
|
1167
|
+
console.log(`Projects: ${running.projects.join(", ")}`);
|
|
1168
|
+
console.log(`To restart: athene stop && athene start`);
|
|
1169
|
+
unlockStartup();
|
|
1170
|
+
process.exit(0);
|
|
1171
|
+
}
|
|
1172
|
+
if (isHumanCaller() && !projectArg) {
|
|
1173
|
+
console.log(chalk.cyan(`\nℹ AO is already running.`));
|
|
1174
|
+
console.log(` Dashboard: ${chalk.cyan(dashboardUrl(running.port))}`);
|
|
1175
|
+
console.log(` PID: ${running.pid} | Up since: ${running.startedAt}`);
|
|
1176
|
+
console.log(` Projects: ${running.projects.join(", ")}\n`);
|
|
1177
|
+
const cwdResolved = resolve(cwd());
|
|
1178
|
+
const cwdIsRegistered = running.projects.some((p) => {
|
|
1179
|
+
try {
|
|
1180
|
+
const loadedCfg = loadConfig();
|
|
1181
|
+
const proj = loadedCfg.projects[p];
|
|
1182
|
+
return proj !== undefined && pathsEqual(proj.path, cwdResolved);
|
|
1183
|
+
}
|
|
1184
|
+
catch {
|
|
1185
|
+
return false;
|
|
1186
|
+
}
|
|
1187
|
+
});
|
|
1188
|
+
const cwdHasGit = existsSync(resolve(cwdResolved, ".git"));
|
|
1189
|
+
const addCwdOption = !cwdIsRegistered && cwdHasGit
|
|
1190
|
+
? [
|
|
1191
|
+
{
|
|
1192
|
+
value: "add",
|
|
1193
|
+
label: `Add ${basename(cwdResolved)}`,
|
|
1194
|
+
hint: "register this directory and start",
|
|
1195
|
+
},
|
|
1196
|
+
]
|
|
1197
|
+
: [];
|
|
1198
|
+
const choice = await promptSelect("AO is already running. What do you want to do?", [
|
|
1199
|
+
{ value: "open", label: "Open dashboard", hint: "Keep the current instance" },
|
|
1200
|
+
{
|
|
1201
|
+
value: "new",
|
|
1202
|
+
label: "Start new orchestrator",
|
|
1203
|
+
hint: "Add a new session for this project",
|
|
1204
|
+
},
|
|
1205
|
+
...addCwdOption,
|
|
1206
|
+
{
|
|
1207
|
+
value: "restart",
|
|
1208
|
+
label: "Restart everything",
|
|
1209
|
+
hint: "Stop the current instance first",
|
|
1210
|
+
},
|
|
1211
|
+
{ value: "quit", label: "Quit" },
|
|
1212
|
+
], "open");
|
|
1213
|
+
if (choice === "open") {
|
|
1214
|
+
openUrl(dashboardUrl(running.port));
|
|
1215
|
+
unlockStartup();
|
|
1216
|
+
process.exit(0);
|
|
1217
|
+
}
|
|
1218
|
+
else if (choice === "quit") {
|
|
1219
|
+
unlockStartup();
|
|
1220
|
+
process.exit(0);
|
|
1221
|
+
}
|
|
1222
|
+
else if (choice === "add") {
|
|
1223
|
+
// Persist cwd against whatever config loadConfig() walks up
|
|
1224
|
+
// to from the current directory. addProjectToConfig is
|
|
1225
|
+
// canonical-aware: when that config happens to be the global
|
|
1226
|
+
// one (the canonical fallback), the project lands in the
|
|
1227
|
+
// global registry; when it is a cwd-local agent-orchestrator
|
|
1228
|
+
// .yaml, the project is appended there. This matches the
|
|
1229
|
+
// pre-B.2 behavior — the menu's "add" path deliberately does
|
|
1230
|
+
// not spawn an orchestrator session, so the user can review
|
|
1231
|
+
// the registration and start one explicitly via `athene start
|
|
1232
|
+
// <id>` or the "new" menu choice.
|
|
1233
|
+
const loadedCfg = loadConfig();
|
|
1234
|
+
const addedId = await addProjectToConfig(loadedCfg, cwdResolved);
|
|
1235
|
+
console.log(chalk.green(`\n✓ Added "${addedId}" — open the dashboard to start an orchestrator.\n`));
|
|
1236
|
+
const notifyResult = await attachToDaemon(running).notifyProjectChange();
|
|
1237
|
+
if (!notifyResult.ok) {
|
|
1238
|
+
console.log(chalk.yellow(` ⚠ ${notifyResult.reason}. Refresh the page if the project doesn't show up.`));
|
|
1239
|
+
}
|
|
1240
|
+
openUrl(dashboardUrl(running.port));
|
|
1241
|
+
unlockStartup();
|
|
1242
|
+
process.exit(0);
|
|
1243
|
+
}
|
|
1244
|
+
else if (choice === "new") {
|
|
1245
|
+
// Spawn a new orchestrator entry against this daemon.
|
|
1246
|
+
// Resolve happens below; the suffix mutation runs after.
|
|
1247
|
+
startNewOrchestrator = true;
|
|
1248
|
+
}
|
|
1249
|
+
else if (choice === "restart") {
|
|
1250
|
+
recordActivityEvent({
|
|
1251
|
+
source: "cli",
|
|
1252
|
+
kind: "cli.daemon_restart",
|
|
1253
|
+
level: "info",
|
|
1254
|
+
summary: `user chose restart, killing existing daemon`,
|
|
1255
|
+
data: { existingPid: running.pid, existingPort: running.port },
|
|
1256
|
+
});
|
|
1257
|
+
await killExistingDaemon(running);
|
|
1258
|
+
console.log(chalk.yellow("\n Stopped existing instance. Restarting...\n"));
|
|
1259
|
+
running = null;
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
// Unified project resolution. See lib/resolve-project.ts for the
|
|
1264
|
+
// per-arg-shape dispatch (URL / path / project id / cwd). When
|
|
1265
|
+
// a daemon is up, the resolver registers URL clones / new paths
|
|
1266
|
+
// in the global config — the daemon's source of truth — so they
|
|
1267
|
+
// are visible to the project supervisor without a daemon restart.
|
|
1268
|
+
const resolvedProject = await resolveOrCreateProject(projectArg, {
|
|
1269
|
+
addProjectToConfig,
|
|
1270
|
+
autoCreateConfig,
|
|
1271
|
+
resolveProject,
|
|
1272
|
+
resolveProjectByRepo,
|
|
1273
|
+
registerFlatConfig,
|
|
1274
|
+
cloneRepo,
|
|
1275
|
+
}, { targetGlobalRegistry: !!running });
|
|
1276
|
+
({ config, projectId, project } = resolvedProject);
|
|
1277
|
+
// ── Handle "new orchestrator" choice (deferred from already-running check) ──
|
|
1278
|
+
if (startNewOrchestrator) {
|
|
1279
|
+
let mutationConfigPath = config.configPath;
|
|
1280
|
+
let rawYaml = readFileSync(mutationConfigPath, "utf-8");
|
|
1281
|
+
let rawConfig = yamlParse(rawYaml);
|
|
1282
|
+
let projects = rawConfig &&
|
|
1283
|
+
typeof rawConfig === "object" &&
|
|
1284
|
+
rawConfig["projects"] &&
|
|
1285
|
+
typeof rawConfig["projects"] === "object"
|
|
1286
|
+
? rawConfig["projects"]
|
|
1287
|
+
: null;
|
|
1288
|
+
if (!projects && !isCanonicalGlobalConfigPath(mutationConfigPath)) {
|
|
1289
|
+
const globalPath = getGlobalConfigPath();
|
|
1290
|
+
if (existsSync(globalPath)) {
|
|
1291
|
+
mutationConfigPath = globalPath;
|
|
1292
|
+
rawYaml = readFileSync(mutationConfigPath, "utf-8");
|
|
1293
|
+
rawConfig = yamlParse(rawYaml);
|
|
1294
|
+
projects =
|
|
1295
|
+
rawConfig &&
|
|
1296
|
+
typeof rawConfig === "object" &&
|
|
1297
|
+
rawConfig["projects"] &&
|
|
1298
|
+
typeof rawConfig["projects"] === "object"
|
|
1299
|
+
? rawConfig["projects"]
|
|
1300
|
+
: null;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
if (!rawConfig || !projects || !projects[projectId]) {
|
|
1304
|
+
throw new Error(`Project "${projectId}" not found in a writable project registry.`);
|
|
1305
|
+
}
|
|
1306
|
+
// Collect existing prefixes to avoid collisions
|
|
1307
|
+
const existingPrefixes = new Set(Object.values(projects)
|
|
1308
|
+
.filter((p) => p !== undefined)
|
|
1309
|
+
.map((p) => p.sessionPrefix)
|
|
1310
|
+
.filter(Boolean));
|
|
1311
|
+
let newId;
|
|
1312
|
+
let newPrefix;
|
|
1313
|
+
do {
|
|
1314
|
+
const suffix = Math.random().toString(36).slice(2, 6);
|
|
1315
|
+
newId = `${projectId}-${suffix}`;
|
|
1316
|
+
newPrefix = generateSessionPrefix(newId);
|
|
1317
|
+
} while (projects[newId] || existingPrefixes.has(newPrefix));
|
|
1318
|
+
projects[newId] = {
|
|
1319
|
+
...projects[projectId],
|
|
1320
|
+
sessionPrefix: newPrefix,
|
|
1321
|
+
};
|
|
1322
|
+
const nextYaml = isCanonicalGlobalConfigPath(mutationConfigPath)
|
|
1323
|
+
? yamlStringify(rawConfig, { indent: 2 })
|
|
1324
|
+
: configToYaml(rawConfig);
|
|
1325
|
+
writeFileSync(mutationConfigPath, nextYaml);
|
|
1326
|
+
console.log(chalk.green(`\n✓ New orchestrator "${newId}" added to config\n`));
|
|
1327
|
+
config = loadConfig(mutationConfigPath);
|
|
1328
|
+
projectId = newId;
|
|
1329
|
+
project = config.projects[newId];
|
|
1330
|
+
}
|
|
1331
|
+
// ── Daemon-running short-circuit and attach pipeline ──
|
|
1332
|
+
if (running) {
|
|
1333
|
+
// URL/path arg whose project is already registered and supervised
|
|
1334
|
+
// by the running daemon: don't even spawn an orchestrator session,
|
|
1335
|
+
// just open the dashboard. Mirrors the original §3.2 fast path.
|
|
1336
|
+
if (projectArgIsUrlOrPath &&
|
|
1337
|
+
!resolvedProject.justCreated &&
|
|
1338
|
+
running.projects.includes(projectId)) {
|
|
1339
|
+
console.log(chalk.cyan(`\nℹ AO is already running.`));
|
|
1340
|
+
console.log(` Dashboard: ${chalk.cyan(dashboardUrl(running.port))}`);
|
|
1341
|
+
console.log(` Project "${projectId}" is already registered and running.\n`);
|
|
1342
|
+
openUrl(dashboardUrl(running.port));
|
|
1343
|
+
unlockStartup();
|
|
1344
|
+
process.exit(0);
|
|
1345
|
+
}
|
|
1346
|
+
await attachAndSpawnOrchestrator({
|
|
1347
|
+
running,
|
|
1348
|
+
config,
|
|
1349
|
+
projectId,
|
|
1350
|
+
project,
|
|
1351
|
+
justCreated: resolvedProject.justCreated,
|
|
1352
|
+
});
|
|
1353
|
+
unlockStartup();
|
|
1354
|
+
process.exit(0);
|
|
1355
|
+
}
|
|
1356
|
+
// ── Agent selection prompt (not-running spawn path only) ──
|
|
1357
|
+
// Skipped when attaching to an existing daemon: changing agents
|
|
1358
|
+
// mid-flight against a live orchestrator session would not take
|
|
1359
|
+
// effect until the next restart anyway.
|
|
1360
|
+
const agentOverride = opts?.interactive ? await promptAgentSelection() : null;
|
|
1361
|
+
if (agentOverride) {
|
|
1362
|
+
const { orchestratorAgent, workerAgent } = agentOverride;
|
|
1363
|
+
let updatedProject = null;
|
|
1364
|
+
if (isCanonicalGlobalConfigPath(config.configPath)) {
|
|
1365
|
+
const nextLocalConfig = readProjectBehaviorConfig(project.path);
|
|
1366
|
+
nextLocalConfig.orchestrator = {
|
|
1367
|
+
...(nextLocalConfig.orchestrator ?? {}),
|
|
1368
|
+
agent: orchestratorAgent,
|
|
1369
|
+
};
|
|
1370
|
+
nextLocalConfig.worker = {
|
|
1371
|
+
...(nextLocalConfig.worker ?? {}),
|
|
1372
|
+
agent: workerAgent,
|
|
1373
|
+
};
|
|
1374
|
+
writeProjectBehaviorConfig(project.path, nextLocalConfig);
|
|
1375
|
+
console.log(chalk.dim(` ✓ Saved to ${project.path}/agent-orchestrator.yaml\n`));
|
|
1376
|
+
}
|
|
1377
|
+
else {
|
|
1378
|
+
const rawYaml = readFileSync(config.configPath, "utf-8");
|
|
1379
|
+
const rawConfig = yamlParse(rawYaml);
|
|
1380
|
+
const projects = rawConfig &&
|
|
1381
|
+
typeof rawConfig === "object" &&
|
|
1382
|
+
rawConfig["projects"] &&
|
|
1383
|
+
typeof rawConfig["projects"] === "object"
|
|
1384
|
+
? rawConfig["projects"]
|
|
1385
|
+
: null;
|
|
1386
|
+
if (projects) {
|
|
1387
|
+
const proj = projects[projectId];
|
|
1388
|
+
if (!proj) {
|
|
1389
|
+
throw new Error(`Project "${projectId}" not found in ${config.configPath}`);
|
|
1390
|
+
}
|
|
1391
|
+
proj.orchestrator = {
|
|
1392
|
+
...(proj.orchestrator ?? {}),
|
|
1393
|
+
agent: orchestratorAgent,
|
|
1394
|
+
};
|
|
1395
|
+
proj.worker = {
|
|
1396
|
+
...(proj.worker ?? {}),
|
|
1397
|
+
agent: workerAgent,
|
|
1398
|
+
};
|
|
1399
|
+
writeFileSync(config.configPath, configToYaml(rawConfig));
|
|
1400
|
+
}
|
|
1401
|
+
else {
|
|
1402
|
+
const nextLocalConfig = readProjectBehaviorConfig(project.path);
|
|
1403
|
+
nextLocalConfig.orchestrator = {
|
|
1404
|
+
...(nextLocalConfig.orchestrator ?? {}),
|
|
1405
|
+
agent: orchestratorAgent,
|
|
1406
|
+
};
|
|
1407
|
+
nextLocalConfig.worker = {
|
|
1408
|
+
...(nextLocalConfig.worker ?? {}),
|
|
1409
|
+
agent: workerAgent,
|
|
1410
|
+
};
|
|
1411
|
+
writeProjectBehaviorConfig(project.path, nextLocalConfig);
|
|
1412
|
+
updatedProject = {
|
|
1413
|
+
...project,
|
|
1414
|
+
orchestrator: {
|
|
1415
|
+
...(project.orchestrator ?? {}),
|
|
1416
|
+
agent: orchestratorAgent,
|
|
1417
|
+
},
|
|
1418
|
+
worker: {
|
|
1419
|
+
...(project.worker ?? {}),
|
|
1420
|
+
agent: workerAgent,
|
|
1421
|
+
},
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
console.log(chalk.dim(` ✓ Saved to ${config.configPath}\n`));
|
|
1425
|
+
}
|
|
1426
|
+
if (updatedProject) {
|
|
1427
|
+
project = updatedProject;
|
|
1428
|
+
config = {
|
|
1429
|
+
...config,
|
|
1430
|
+
projects: {
|
|
1431
|
+
...config.projects,
|
|
1432
|
+
[projectId]: updatedProject,
|
|
1433
|
+
},
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
else {
|
|
1437
|
+
config = loadConfig(config.configPath);
|
|
1438
|
+
project = config.projects[projectId];
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
const actualPort = await runStartup(config, projectId, project, opts);
|
|
1442
|
+
// ── Register in running.json (Step 11) ──
|
|
1443
|
+
// During daemon startup, the project supervisor is the authoritative
|
|
1444
|
+
// writer for lifecycle polling coverage across all active projects.
|
|
1445
|
+
await register({
|
|
1446
|
+
pid: process.pid,
|
|
1447
|
+
configPath: config.configPath,
|
|
1448
|
+
port: actualPort,
|
|
1449
|
+
startedAt: new Date().toISOString(),
|
|
1450
|
+
projects: listLifecycleWorkers(),
|
|
1451
|
+
});
|
|
1452
|
+
unlockStartup();
|
|
1453
|
+
// Start the Bun-extracted /tmp/.*.{so,dylib} janitor once per AO
|
|
1454
|
+
// process. Single-instance is enforced by running.json + the
|
|
1455
|
+
// startup lock above, so this call site is reached at most once
|
|
1456
|
+
// per process. The janitor uses an unref'd interval timer, so it
|
|
1457
|
+
// does not keep the event loop alive on its own and dies with the
|
|
1458
|
+
// process on SIGTERM/SIGINT.
|
|
1459
|
+
startBunTmpJanitor({
|
|
1460
|
+
onSweep: ({ removed, freedBytes, errors }) => {
|
|
1461
|
+
if (removed > 0) {
|
|
1462
|
+
console.info(`[bun-tmp-janitor] reclaimed ${removed} file(s) / ${freedBytes} bytes`);
|
|
1463
|
+
}
|
|
1464
|
+
if (errors > 0) {
|
|
1465
|
+
console.warn(`[bun-tmp-janitor] sweep had ${errors} error(s)`);
|
|
1466
|
+
}
|
|
1467
|
+
},
|
|
1468
|
+
});
|
|
1469
|
+
// Ctrl+C and `athene stop` (which sends SIGTERM) perform a full
|
|
1470
|
+
// graceful shutdown via the handler installed inside runStartup().
|
|
1471
|
+
}
|
|
1472
|
+
catch (err) {
|
|
1473
|
+
if (!isCliFailureEventRecordedError(err)) {
|
|
1474
|
+
recordActivityEvent({
|
|
1475
|
+
source: "cli",
|
|
1476
|
+
kind: "cli.start_failed",
|
|
1477
|
+
level: "error",
|
|
1478
|
+
summary: `athene start action failed`,
|
|
1479
|
+
data: {
|
|
1480
|
+
reason: "outer",
|
|
1481
|
+
errorMessage: err instanceof Error ? err.message : String(err),
|
|
1482
|
+
},
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1485
|
+
if (err instanceof Error) {
|
|
1486
|
+
console.error(chalk.red("\nError:"), err.message);
|
|
1487
|
+
}
|
|
1488
|
+
else {
|
|
1489
|
+
console.error(chalk.red("\nError:"), String(err));
|
|
1490
|
+
}
|
|
1491
|
+
unlockStartup();
|
|
1492
|
+
process.exit(1);
|
|
1493
|
+
}
|
|
1494
|
+
finally {
|
|
1495
|
+
unlockStartup();
|
|
1496
|
+
}
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
/**
|
|
1500
|
+
* Check if arg looks like a local path (not a project ID).
|
|
1501
|
+
* Paths contain / or ~ or . at the start.
|
|
1502
|
+
*/
|
|
1503
|
+
function isLocalPath(arg) {
|
|
1504
|
+
if (arg.startsWith("/") || arg.startsWith("~") || arg.startsWith("./") || arg.startsWith("..")) {
|
|
1505
|
+
return true;
|
|
1506
|
+
}
|
|
1507
|
+
// Windows paths: drive-letter (C:\, D:/), UNC (\\server\share), or relative backslash paths.
|
|
1508
|
+
if (/^[A-Za-z]:[\\/]/.test(arg))
|
|
1509
|
+
return true;
|
|
1510
|
+
if (arg.startsWith("\\\\") || arg.startsWith(".\\") || arg.startsWith("..\\"))
|
|
1511
|
+
return true;
|
|
1512
|
+
return false;
|
|
1513
|
+
}
|
|
1514
|
+
/**
|
|
1515
|
+
* Lazy import + invoke the runtime-process plugin's Windows pty-host sweep.
|
|
1516
|
+
* Kept lazy so non-Windows users don't pay the import cost on every `athene stop`,
|
|
1517
|
+
* and so the cli isn't tightly coupled to the plugin's surface.
|
|
1518
|
+
*
|
|
1519
|
+
* Errors are swallowed: a sweep failure must not prevent `athene stop` from killing
|
|
1520
|
+
* the parent process — the user explicitly asked us to stop AO.
|
|
1521
|
+
*/
|
|
1522
|
+
async function sweepWindowsPtyHostsBeforeParentKill() {
|
|
1523
|
+
if (!isWindows())
|
|
1524
|
+
return;
|
|
1525
|
+
try {
|
|
1526
|
+
const mod = (await import("@made-by-moonlight/athene-plugin-runtime-process"));
|
|
1527
|
+
if (typeof mod.sweepWindowsPtyHosts !== "function")
|
|
1528
|
+
return;
|
|
1529
|
+
const result = await mod.sweepWindowsPtyHosts();
|
|
1530
|
+
if (result.attempted > 0) {
|
|
1531
|
+
console.log(chalk.dim(` Swept ${result.attempted} pty-host(s): ` +
|
|
1532
|
+
`${result.gracefullyExited} graceful, ` +
|
|
1533
|
+
`${result.forceKilled} force-killed` +
|
|
1534
|
+
(result.failed > 0 ? `, ${result.failed} failed` : "")));
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
catch {
|
|
1538
|
+
/* sweep is best-effort; don't block athene stop on it */
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
export function registerStop(program) {
|
|
1542
|
+
program
|
|
1543
|
+
.command("stop [project]")
|
|
1544
|
+
.description("Stop orchestrator agent and dashboard")
|
|
1545
|
+
.option("--purge-session", "Delete mapped OpenCode session when stopping")
|
|
1546
|
+
.option("--all", "Stop all running AO instances")
|
|
1547
|
+
.option("-y, --yes", "Confirm stopping active sessions without prompting")
|
|
1548
|
+
.action(async (projectArg, opts = {}) => {
|
|
1549
|
+
recordActivityEvent({
|
|
1550
|
+
source: "cli",
|
|
1551
|
+
kind: "cli.stop_invoked",
|
|
1552
|
+
level: "info",
|
|
1553
|
+
summary: "athene stop invoked",
|
|
1554
|
+
data: {
|
|
1555
|
+
projectArg: projectArg ?? null,
|
|
1556
|
+
all: opts.all === true,
|
|
1557
|
+
purgeSession: opts.purgeSession === true,
|
|
1558
|
+
},
|
|
1559
|
+
});
|
|
1560
|
+
try {
|
|
1561
|
+
// Check running.json first
|
|
1562
|
+
const running = await getRunning();
|
|
1563
|
+
if (opts.all) {
|
|
1564
|
+
// --all: kill via running.json if available, then fallback to config
|
|
1565
|
+
if (running) {
|
|
1566
|
+
// Sweep detached Windows pty-hosts BEFORE killing the parent.
|
|
1567
|
+
// detached:true puts them outside the parent's process tree, so
|
|
1568
|
+
// taskkill /T cannot reach them. The sweep speaks the named-pipe
|
|
1569
|
+
// protocol so node-pty disposes ConPTY gracefully (avoids WER
|
|
1570
|
+
// 0x800700e8). No-op on non-Windows.
|
|
1571
|
+
await sweepWindowsPtyHostsBeforeParentKill();
|
|
1572
|
+
await sweepRegisteredDaemonChildren(running.pid);
|
|
1573
|
+
// killProcessTree handles process trees on Windows (taskkill /T /F)
|
|
1574
|
+
// and process groups on Unix; it swallows "already dead" internally.
|
|
1575
|
+
await killProcessTree(running.pid, "SIGTERM");
|
|
1576
|
+
await unregister();
|
|
1577
|
+
console.log(chalk.green(`\n✓ Stopped AO on port ${running.port}`));
|
|
1578
|
+
console.log(chalk.dim(` Projects: ${running.projects.join(", ")}\n`));
|
|
1579
|
+
}
|
|
1580
|
+
else {
|
|
1581
|
+
console.log(chalk.yellow("No running AO instance found in running.json."));
|
|
1582
|
+
}
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
let config = loadConfig();
|
|
1586
|
+
// athene stop affects all projects (it kills the parent athene start process),
|
|
1587
|
+
// so load the global config which has all registered projects.
|
|
1588
|
+
// When a specific project is targeted, only fall back to global if
|
|
1589
|
+
// the project isn't in the local config.
|
|
1590
|
+
if (!projectArg || !config.projects[projectArg]) {
|
|
1591
|
+
const globalPath = getGlobalConfigPath();
|
|
1592
|
+
if (existsSync(globalPath)) {
|
|
1593
|
+
config = loadConfig(globalPath);
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
let _projectId;
|
|
1597
|
+
let project;
|
|
1598
|
+
if (projectArg) {
|
|
1599
|
+
({ projectId: _projectId, project } = await resolveProject(config, projectArg, "stop"));
|
|
1600
|
+
}
|
|
1601
|
+
else {
|
|
1602
|
+
const projectIds = Object.keys(config.projects);
|
|
1603
|
+
if (projectIds.length === 0) {
|
|
1604
|
+
throw new Error("No projects configured. Add a project to agent-orchestrator.yaml.");
|
|
1605
|
+
}
|
|
1606
|
+
const currentDir = resolve(cwd());
|
|
1607
|
+
const cwdProjectId = findProjectForDirectory(config.projects, currentDir);
|
|
1608
|
+
_projectId =
|
|
1609
|
+
running?.projects.find((id) => config.projects[id]) ??
|
|
1610
|
+
cwdProjectId ??
|
|
1611
|
+
projectIds[0];
|
|
1612
|
+
project = config.projects[_projectId];
|
|
1613
|
+
}
|
|
1614
|
+
const port = config.port ?? DEFAULT_PORT;
|
|
1615
|
+
if (projectArg) {
|
|
1616
|
+
console.log(chalk.bold(`\nStopping orchestrator for ${chalk.cyan(project.name)}\n`));
|
|
1617
|
+
}
|
|
1618
|
+
else {
|
|
1619
|
+
console.log(chalk.bold(`\nStopping AO across all projects\n`));
|
|
1620
|
+
}
|
|
1621
|
+
const sm = await getSessionManager(config);
|
|
1622
|
+
try {
|
|
1623
|
+
// When no explicit project is given, list ALL sessions — athene stop
|
|
1624
|
+
// kills the parent process which affects all projects. When a
|
|
1625
|
+
// specific project is targeted, scope to that project only.
|
|
1626
|
+
const stopAll = !projectArg;
|
|
1627
|
+
const rawSessions = await sm.list(stopAll ? undefined : _projectId);
|
|
1628
|
+
// Defensive consumer-side filter. `sm.list(projectId)` already scopes
|
|
1629
|
+
// to the named project, but the kill loop hard-stops processes — a
|
|
1630
|
+
// contract regression here would silently kill another project's
|
|
1631
|
+
// work. When a project arg is given, drop anything that isn't ours.
|
|
1632
|
+
const allSessions = stopAll
|
|
1633
|
+
? rawSessions
|
|
1634
|
+
: rawSessions.filter((s) => s.projectId === _projectId);
|
|
1635
|
+
const activeSessions = allSessions.filter((s) => !isTerminalSession(s));
|
|
1636
|
+
const killedSessionIds = [];
|
|
1637
|
+
// Separate sessions by project for display and recording
|
|
1638
|
+
const targetActive = activeSessions.filter((s) => s.projectId === _projectId);
|
|
1639
|
+
const otherActive = activeSessions.filter((s) => s.projectId !== _projectId);
|
|
1640
|
+
// Group other-project sessions by projectId (used for display + recording)
|
|
1641
|
+
const otherByProject = new Map();
|
|
1642
|
+
if (activeSessions.length > 0) {
|
|
1643
|
+
if (!projectArg && opts.yes !== true && isHumanCaller()) {
|
|
1644
|
+
const confirmed = await promptConfirm(`Stop AO and ${activeSessions.length} active session(s)?`, false);
|
|
1645
|
+
if (!confirmed) {
|
|
1646
|
+
console.log(chalk.yellow("Stop cancelled."));
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
const spinner = ora(`Stopping ${activeSessions.length} active session(s)`).start();
|
|
1651
|
+
const purgeOpenCode = opts?.purgeSession === true;
|
|
1652
|
+
const warnings = [];
|
|
1653
|
+
for (const session of activeSessions) {
|
|
1654
|
+
try {
|
|
1655
|
+
const result = await sm.kill(session.id, { purgeOpenCode });
|
|
1656
|
+
if (result.cleaned || result.alreadyTerminated) {
|
|
1657
|
+
killedSessionIds.push(session.id);
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
catch (err) {
|
|
1661
|
+
recordActivityEvent({
|
|
1662
|
+
projectId: session.projectId ?? _projectId,
|
|
1663
|
+
sessionId: session.id,
|
|
1664
|
+
source: "cli",
|
|
1665
|
+
kind: "cli.stop_session_failed",
|
|
1666
|
+
level: "warn",
|
|
1667
|
+
summary: `failed to kill session during athene stop`,
|
|
1668
|
+
data: { errorMessage: err instanceof Error ? err.message : String(err) },
|
|
1669
|
+
});
|
|
1670
|
+
warnings.push(` Warning: failed to stop ${session.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
if (killedSessionIds.length === 0) {
|
|
1674
|
+
spinner.fail("Failed to stop any sessions");
|
|
1675
|
+
}
|
|
1676
|
+
else if (killedSessionIds.length < activeSessions.length) {
|
|
1677
|
+
spinner.warn(`Stopped ${killedSessionIds.length}/${activeSessions.length} session(s)`);
|
|
1678
|
+
}
|
|
1679
|
+
else {
|
|
1680
|
+
spinner.succeed(`Stopped ${killedSessionIds.length} session(s)`);
|
|
1681
|
+
}
|
|
1682
|
+
for (const w of warnings) {
|
|
1683
|
+
console.log(chalk.yellow(w));
|
|
1684
|
+
}
|
|
1685
|
+
// Show stopped sessions grouped by project
|
|
1686
|
+
const killedTarget = targetActive
|
|
1687
|
+
.filter((s) => killedSessionIds.includes(s.id))
|
|
1688
|
+
.map((s) => s.id);
|
|
1689
|
+
if (killedTarget.length > 0) {
|
|
1690
|
+
console.log(chalk.green(` ${project.name}: ${killedTarget.join(", ")}`));
|
|
1691
|
+
}
|
|
1692
|
+
for (const s of otherActive) {
|
|
1693
|
+
if (!killedSessionIds.includes(s.id))
|
|
1694
|
+
continue;
|
|
1695
|
+
const list = otherByProject.get(s.projectId ?? "unknown") ?? [];
|
|
1696
|
+
list.push(s.id);
|
|
1697
|
+
otherByProject.set(s.projectId ?? "unknown", list);
|
|
1698
|
+
}
|
|
1699
|
+
for (const [pid, ids] of otherByProject) {
|
|
1700
|
+
console.log(chalk.green(` ${pid}: ${ids.join(", ")}`));
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
else {
|
|
1704
|
+
console.log(chalk.yellow(`No active sessions found`));
|
|
1705
|
+
}
|
|
1706
|
+
// Record stopped sessions for restore on next `athene start`
|
|
1707
|
+
if (killedSessionIds.length > 0) {
|
|
1708
|
+
const otherProjects = [];
|
|
1709
|
+
for (const [pid, ids] of otherByProject) {
|
|
1710
|
+
otherProjects.push({ projectId: pid, sessionIds: ids });
|
|
1711
|
+
}
|
|
1712
|
+
const targetSessionIds = killedSessionIds.filter((id) => targetActive.some((s) => s.id === id));
|
|
1713
|
+
try {
|
|
1714
|
+
await writeLastStop({
|
|
1715
|
+
stoppedAt: new Date().toISOString(),
|
|
1716
|
+
projectId: _projectId,
|
|
1717
|
+
sessionIds: targetSessionIds,
|
|
1718
|
+
otherProjects: otherProjects.length > 0 ? otherProjects : undefined,
|
|
1719
|
+
});
|
|
1720
|
+
recordActivityEvent({
|
|
1721
|
+
projectId: _projectId,
|
|
1722
|
+
source: "cli",
|
|
1723
|
+
kind: "cli.last_stop_written",
|
|
1724
|
+
level: "info",
|
|
1725
|
+
summary: `last-stop state written with ${killedSessionIds.length} session(s)`,
|
|
1726
|
+
data: {
|
|
1727
|
+
targetSessionCount: targetSessionIds.length,
|
|
1728
|
+
otherProjectCount: otherProjects.length,
|
|
1729
|
+
totalKilled: killedSessionIds.length,
|
|
1730
|
+
},
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
1733
|
+
catch (err) {
|
|
1734
|
+
recordActivityEvent({
|
|
1735
|
+
projectId: _projectId,
|
|
1736
|
+
source: "cli",
|
|
1737
|
+
kind: "cli.last_stop_write_failed",
|
|
1738
|
+
level: "error",
|
|
1739
|
+
summary: `failed to write last-stop state during athene stop`,
|
|
1740
|
+
data: {
|
|
1741
|
+
targetSessionCount: targetSessionIds.length,
|
|
1742
|
+
otherProjectCount: otherProjects.length,
|
|
1743
|
+
totalKilled: killedSessionIds.length,
|
|
1744
|
+
errorMessage: err instanceof Error ? err.message : String(err),
|
|
1745
|
+
},
|
|
1746
|
+
});
|
|
1747
|
+
console.log(chalk.yellow(` Could not write last-stop state: ${err instanceof Error ? err.message : String(err)}`));
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
catch (err) {
|
|
1752
|
+
console.log(chalk.yellow(` Could not list sessions: ${err instanceof Error ? err.message : String(err)}`));
|
|
1753
|
+
}
|
|
1754
|
+
// Only kill the parent `athene start` process and dashboard when stopping
|
|
1755
|
+
// everything (no project arg). When targeting a specific project, the
|
|
1756
|
+
// parent process and dashboard serve all projects and must stay alive.
|
|
1757
|
+
if (!projectArg) {
|
|
1758
|
+
// Lifecycle polling runs in-process inside the `athene start` process
|
|
1759
|
+
// (registered via `running.json`). Sending SIGTERM to that PID below
|
|
1760
|
+
// triggers the shared shutdown handler in `lifecycle-service`, which
|
|
1761
|
+
// stops every per-project loop. No explicit stop call needed here —
|
|
1762
|
+
// this CLI invocation is a separate process with an empty active map.
|
|
1763
|
+
if (running) {
|
|
1764
|
+
// Sweep detached Windows pty-hosts BEFORE killing the parent.
|
|
1765
|
+
// detached:true puts them outside the parent's process tree, so
|
|
1766
|
+
// taskkill /T cannot reach them. The sweep speaks the named-pipe
|
|
1767
|
+
// protocol so node-pty disposes ConPTY gracefully (avoids WER
|
|
1768
|
+
// 0x800700e8). No-op on non-Windows.
|
|
1769
|
+
await sweepWindowsPtyHostsBeforeParentKill();
|
|
1770
|
+
await sweepRegisteredDaemonChildren(running.pid);
|
|
1771
|
+
try {
|
|
1772
|
+
await killProcessTree(running.pid, "SIGTERM");
|
|
1773
|
+
recordActivityEvent({
|
|
1774
|
+
projectId: _projectId,
|
|
1775
|
+
source: "cli",
|
|
1776
|
+
kind: "cli.daemon_killed",
|
|
1777
|
+
level: "info",
|
|
1778
|
+
summary: `SIGTERM sent to parent athene start`,
|
|
1779
|
+
data: { pid: running.pid, port: running.port },
|
|
1780
|
+
});
|
|
1781
|
+
}
|
|
1782
|
+
catch (err) {
|
|
1783
|
+
recordActivityEvent({
|
|
1784
|
+
projectId: _projectId,
|
|
1785
|
+
source: "cli",
|
|
1786
|
+
kind: "cli.daemon_killed",
|
|
1787
|
+
level: "warn",
|
|
1788
|
+
summary: `parent athene start was already dead`,
|
|
1789
|
+
data: {
|
|
1790
|
+
pid: running.pid,
|
|
1791
|
+
errorMessage: err instanceof Error ? err.message : String(err),
|
|
1792
|
+
},
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1795
|
+
await unregister();
|
|
1796
|
+
}
|
|
1797
|
+
else {
|
|
1798
|
+
await sweepRegisteredDaemonChildren();
|
|
1799
|
+
}
|
|
1800
|
+
await stopDashboard(running?.port ?? port);
|
|
1801
|
+
}
|
|
1802
|
+
// Targeted stop deliberately does NOT edit `running.json` from this
|
|
1803
|
+
// child CLI process. The long-lived parent supervises lifecycle
|
|
1804
|
+
// workers and will remove the project from `running.projects` after
|
|
1805
|
+
// it observes that the last session became terminal.
|
|
1806
|
+
if (projectArg) {
|
|
1807
|
+
console.log(chalk.bold.green(`\n✓ Stopped sessions for ${project.name}\n`));
|
|
1808
|
+
}
|
|
1809
|
+
else {
|
|
1810
|
+
console.log(chalk.bold.green("\n✓ Orchestrator stopped\n"));
|
|
1811
|
+
console.log(chalk.dim(` Uptime: since ${running?.startedAt ?? "unknown"}`));
|
|
1812
|
+
console.log(chalk.dim(` Projects: ${Object.keys(config.projects).join(", ")}\n`));
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
catch (err) {
|
|
1816
|
+
recordActivityEvent({
|
|
1817
|
+
source: "cli",
|
|
1818
|
+
kind: "cli.stop_failed",
|
|
1819
|
+
level: "error",
|
|
1820
|
+
summary: `athene stop action failed`,
|
|
1821
|
+
data: {
|
|
1822
|
+
projectArg: projectArg ?? null,
|
|
1823
|
+
errorMessage: err instanceof Error ? err.message : String(err),
|
|
1824
|
+
},
|
|
1825
|
+
});
|
|
1826
|
+
if (err instanceof Error) {
|
|
1827
|
+
console.error(chalk.red("\nError:"), err.message);
|
|
1828
|
+
}
|
|
1829
|
+
else {
|
|
1830
|
+
console.error(chalk.red("\nError:"), String(err));
|
|
1831
|
+
}
|
|
1832
|
+
process.exit(1);
|
|
1833
|
+
}
|
|
1834
|
+
});
|
|
1835
|
+
}
|
|
1836
|
+
//# sourceMappingURL=start.js.map
|