@katyella/legio 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +422 -0
- package/LICENSE +21 -0
- package/README.md +555 -0
- package/agents/builder.md +141 -0
- package/agents/coordinator.md +351 -0
- package/agents/cto.md +196 -0
- package/agents/gateway.md +276 -0
- package/agents/lead.md +281 -0
- package/agents/merger.md +156 -0
- package/agents/monitor.md +212 -0
- package/agents/reviewer.md +142 -0
- package/agents/scout.md +131 -0
- package/agents/supervisor.md +416 -0
- package/bin/legio.mjs +38 -0
- package/package.json +77 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +102 -0
- package/src/agents/hooks-deployer.test.ts +1820 -0
- package/src/agents/hooks-deployer.ts +574 -0
- package/src/agents/identity.test.ts +614 -0
- package/src/agents/identity.ts +385 -0
- package/src/agents/lifecycle.test.ts +202 -0
- package/src/agents/lifecycle.ts +184 -0
- package/src/agents/manifest.test.ts +558 -0
- package/src/agents/manifest.ts +297 -0
- package/src/agents/overlay.test.ts +592 -0
- package/src/agents/overlay.ts +316 -0
- package/src/beads/client.test.ts +210 -0
- package/src/beads/client.ts +227 -0
- package/src/beads/molecules.test.ts +320 -0
- package/src/beads/molecules.ts +209 -0
- package/src/commands/agents.test.ts +325 -0
- package/src/commands/agents.ts +286 -0
- package/src/commands/clean.test.ts +730 -0
- package/src/commands/clean.ts +653 -0
- package/src/commands/completions.test.ts +346 -0
- package/src/commands/completions.ts +950 -0
- package/src/commands/coordinator.test.ts +1524 -0
- package/src/commands/coordinator.ts +880 -0
- package/src/commands/costs.test.ts +1015 -0
- package/src/commands/costs.ts +473 -0
- package/src/commands/dashboard.test.ts +94 -0
- package/src/commands/dashboard.ts +607 -0
- package/src/commands/doctor.test.ts +295 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/down.test.ts +308 -0
- package/src/commands/down.ts +124 -0
- package/src/commands/errors.test.ts +648 -0
- package/src/commands/errors.ts +255 -0
- package/src/commands/feed.test.ts +579 -0
- package/src/commands/feed.ts +368 -0
- package/src/commands/gateway.test.ts +698 -0
- package/src/commands/gateway.ts +419 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +539 -0
- package/src/commands/hooks.test.ts +292 -0
- package/src/commands/hooks.ts +210 -0
- package/src/commands/init.test.ts +211 -0
- package/src/commands/init.ts +622 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +455 -0
- package/src/commands/log.test.ts +1556 -0
- package/src/commands/log.ts +752 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +544 -0
- package/src/commands/mail.test.ts +1726 -0
- package/src/commands/mail.ts +926 -0
- package/src/commands/merge.test.ts +676 -0
- package/src/commands/merge.ts +374 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +150 -0
- package/src/commands/monitor.test.ts +151 -0
- package/src/commands/monitor.ts +394 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +373 -0
- package/src/commands/prime.test.ts +467 -0
- package/src/commands/prime.ts +386 -0
- package/src/commands/replay.test.ts +742 -0
- package/src/commands/replay.ts +367 -0
- package/src/commands/run.test.ts +443 -0
- package/src/commands/run.ts +365 -0
- package/src/commands/server.test.ts +626 -0
- package/src/commands/server.ts +298 -0
- package/src/commands/sling.test.ts +810 -0
- package/src/commands/sling.ts +700 -0
- package/src/commands/spec.test.ts +206 -0
- package/src/commands/spec.ts +171 -0
- package/src/commands/status.test.ts +276 -0
- package/src/commands/status.ts +339 -0
- package/src/commands/stop.test.ts +357 -0
- package/src/commands/stop.ts +119 -0
- package/src/commands/supervisor.test.ts +186 -0
- package/src/commands/supervisor.ts +544 -0
- package/src/commands/trace.test.ts +746 -0
- package/src/commands/trace.ts +332 -0
- package/src/commands/up.test.ts +597 -0
- package/src/commands/up.ts +275 -0
- package/src/commands/watch.test.ts +152 -0
- package/src/commands/watch.ts +238 -0
- package/src/commands/worktree.test.ts +648 -0
- package/src/commands/worktree.ts +266 -0
- package/src/config.test.ts +496 -0
- package/src/config.ts +616 -0
- package/src/doctor/agents.test.ts +448 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +184 -0
- package/src/doctor/config-check.ts +185 -0
- package/src/doctor/consistency.test.ts +645 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +284 -0
- package/src/doctor/databases.ts +211 -0
- package/src/doctor/dependencies.test.ts +150 -0
- package/src/doctor/dependencies.ts +179 -0
- package/src/doctor/logs.test.ts +244 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +210 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +285 -0
- package/src/doctor/structure.ts +195 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +130 -0
- package/src/doctor/version.ts +131 -0
- package/src/e2e/chat-flow.test.ts +346 -0
- package/src/e2e/init-sling-lifecycle.test.ts +288 -0
- package/src/errors.test.ts +21 -0
- package/src/errors.ts +246 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +344 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/global-setup.ts +14 -0
- package/src/index.ts +339 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +118 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +812 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +258 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +873 -0
- package/src/mail/client.ts +236 -0
- package/src/mail/store.test.ts +815 -0
- package/src/mail/store.ts +402 -0
- package/src/merge/queue.test.ts +449 -0
- package/src/merge/queue.ts +262 -0
- package/src/merge/resolver.test.ts +1453 -0
- package/src/merge/resolver.ts +759 -0
- package/src/metrics/store.test.ts +1167 -0
- package/src/metrics/store.ts +511 -0
- package/src/metrics/summary.test.ts +397 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +643 -0
- package/src/metrics/transcript.ts +351 -0
- package/src/mulch/client.test.ts +547 -0
- package/src/mulch/client.ts +416 -0
- package/src/server/audit-store.test.ts +384 -0
- package/src/server/audit-store.ts +257 -0
- package/src/server/headless.test.ts +180 -0
- package/src/server/headless.ts +151 -0
- package/src/server/index.test.ts +241 -0
- package/src/server/index.ts +317 -0
- package/src/server/public/app.js +187 -0
- package/src/server/public/apple-touch-icon.png +0 -0
- package/src/server/public/components/agent-badge.js +37 -0
- package/src/server/public/components/data-table.js +114 -0
- package/src/server/public/components/gateway-chat.js +256 -0
- package/src/server/public/components/issue-card.js +96 -0
- package/src/server/public/components/layout.js +88 -0
- package/src/server/public/components/message-bubble.js +120 -0
- package/src/server/public/components/stat-card.js +26 -0
- package/src/server/public/components/terminal-panel.js +140 -0
- package/src/server/public/favicon-16.png +0 -0
- package/src/server/public/favicon-32.png +0 -0
- package/src/server/public/favicon.ico +0 -0
- package/src/server/public/favicon.png +0 -0
- package/src/server/public/index.html +64 -0
- package/src/server/public/lib/api.js +35 -0
- package/src/server/public/lib/markdown.js +8 -0
- package/src/server/public/lib/preact-setup.js +8 -0
- package/src/server/public/lib/state.js +99 -0
- package/src/server/public/lib/utils.js +309 -0
- package/src/server/public/lib/ws.js +79 -0
- package/src/server/public/views/chat.js +983 -0
- package/src/server/public/views/costs.js +692 -0
- package/src/server/public/views/dashboard.js +781 -0
- package/src/server/public/views/gateway-chat.js +622 -0
- package/src/server/public/views/inspect.js +399 -0
- package/src/server/public/views/issues.js +470 -0
- package/src/server/public/views/setup.js +94 -0
- package/src/server/public/views/task-detail.js +422 -0
- package/src/server/routes.test.ts +3816 -0
- package/src/server/routes.ts +1964 -0
- package/src/server/websocket.test.ts +288 -0
- package/src/server/websocket.ts +196 -0
- package/src/sessions/compat.test.ts +109 -0
- package/src/sessions/compat.ts +17 -0
- package/src/sessions/store.test.ts +969 -0
- package/src/sessions/store.ts +480 -0
- package/src/test-helpers.test.ts +97 -0
- package/src/test-helpers.ts +143 -0
- package/src/types.ts +708 -0
- package/src/watchdog/daemon.test.ts +1233 -0
- package/src/watchdog/daemon.ts +533 -0
- package/src/watchdog/health.test.ts +371 -0
- package/src/watchdog/health.ts +248 -0
- package/src/watchdog/triage.test.ts +162 -0
- package/src/watchdog/triage.ts +193 -0
- package/src/worktree/manager.test.ts +444 -0
- package/src/worktree/manager.ts +224 -0
- package/src/worktree/tmux.test.ts +1238 -0
- package/src/worktree/tmux.ts +644 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +132 -0
- package/templates/overlay.md.tmpl +79 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: legio gateway start|stop|status
|
|
3
|
+
*
|
|
4
|
+
* Manages the persistent gateway agent lifecycle. The gateway runs
|
|
5
|
+
* at the project root (NOT in a worktree), acts as a planning companion,
|
|
6
|
+
* and communicates via mail.
|
|
7
|
+
*
|
|
8
|
+
* Unlike regular agents spawned by sling, the gateway:
|
|
9
|
+
* - Has no worktree (operates on the main working tree)
|
|
10
|
+
* - Has no bead assignment (it plans and advises, not implements)
|
|
11
|
+
* - Has no overlay CLAUDE.md (context comes via mail + legio prime)
|
|
12
|
+
* - Persists across planning sessions
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
16
|
+
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { deployHooks } from "../agents/hooks-deployer.ts";
|
|
19
|
+
import { createIdentity, loadIdentity } from "../agents/identity.ts";
|
|
20
|
+
import { createManifestLoader, resolveModel } from "../agents/manifest.ts";
|
|
21
|
+
import { loadConfig } from "../config.ts";
|
|
22
|
+
import { AgentError, ValidationError } from "../errors.ts";
|
|
23
|
+
import { openSessionStore } from "../sessions/compat.ts";
|
|
24
|
+
import type { AgentSession } from "../types.ts";
|
|
25
|
+
import { createSession, isSessionAlive, killSession, sendKeys } from "../worktree/tmux.ts";
|
|
26
|
+
|
|
27
|
+
/** Default gateway agent name. */
|
|
28
|
+
const GATEWAY_NAME = "gateway";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build the tmux session name for the gateway.
|
|
32
|
+
* Includes the project name to prevent cross-project collisions.
|
|
33
|
+
*/
|
|
34
|
+
function gatewayTmuxSession(projectName: string): string {
|
|
35
|
+
return `legio-${projectName}-${GATEWAY_NAME}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Check if a file exists at the given path.
|
|
40
|
+
*/
|
|
41
|
+
async function fileExists(path: string): Promise<boolean> {
|
|
42
|
+
try {
|
|
43
|
+
await access(path);
|
|
44
|
+
return true;
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Dependency injection for testing. Uses real implementations when omitted. */
|
|
51
|
+
export interface GatewayDeps {
|
|
52
|
+
_tmux?: {
|
|
53
|
+
createSession: (
|
|
54
|
+
name: string,
|
|
55
|
+
cwd: string,
|
|
56
|
+
command: string,
|
|
57
|
+
env?: Record<string, string>,
|
|
58
|
+
) => Promise<number>;
|
|
59
|
+
isSessionAlive: (name: string) => Promise<boolean>;
|
|
60
|
+
killSession: (name: string) => Promise<void>;
|
|
61
|
+
sendKeys: (name: string, keys: string) => Promise<void>;
|
|
62
|
+
};
|
|
63
|
+
_sleep?: (ms: number) => Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build the gateway startup beacon — the first message sent to the gateway
|
|
68
|
+
* via tmux send-keys after Claude Code initializes.
|
|
69
|
+
*/
|
|
70
|
+
export function buildGatewayBeacon(): string {
|
|
71
|
+
const timestamp = new Date().toISOString();
|
|
72
|
+
const parts = [
|
|
73
|
+
`[LEGIO] ${GATEWAY_NAME} (gateway) ${timestamp}`,
|
|
74
|
+
"Depth: 0 | Role: planning companion",
|
|
75
|
+
"READONLY: No Write/Edit",
|
|
76
|
+
"ISSUES: Use bd create",
|
|
77
|
+
`Startup: run mulch prime, check mail (legio mail check --agent ${GATEWAY_NAME}), respond to user`,
|
|
78
|
+
];
|
|
79
|
+
return parts.join(" — ");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Determine whether to auto-attach to the tmux session after starting.
|
|
84
|
+
* Exported for testing.
|
|
85
|
+
*/
|
|
86
|
+
export function resolveAttach(args: string[], isTTY: boolean): boolean {
|
|
87
|
+
if (args.includes("--attach")) return true;
|
|
88
|
+
if (args.includes("--no-attach")) return false;
|
|
89
|
+
return isTTY;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Start the gateway agent.
|
|
94
|
+
*
|
|
95
|
+
* 1. Verify no gateway is already running
|
|
96
|
+
* 2. Load config
|
|
97
|
+
* 3. Deploy hooks via deployHooks(projectRoot, GATEWAY_NAME, 'gateway')
|
|
98
|
+
* 4. Create identity if first run
|
|
99
|
+
* 5. Resolve model — default to 'sonnet'
|
|
100
|
+
* 6. Read agent def from .legio/agent-defs/gateway.md
|
|
101
|
+
* 7. Build settings JSON with skipDangerousModePermissionPrompt + appendSystemPrompt
|
|
102
|
+
* 8. Spawn tmux session at project root with Claude Code
|
|
103
|
+
* 9. Record session: capability 'gateway', worktreePath projectRoot, depth 0, parentAgent null
|
|
104
|
+
* 10. Send beacon after delay, optionally attach
|
|
105
|
+
*/
|
|
106
|
+
async function startGateway(args: string[], deps: GatewayDeps = {}): Promise<void> {
|
|
107
|
+
const tmux = deps._tmux ?? { createSession, isSessionAlive, killSession, sendKeys };
|
|
108
|
+
const sleep =
|
|
109
|
+
deps._sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
110
|
+
|
|
111
|
+
const json = args.includes("--json");
|
|
112
|
+
const shouldAttach = resolveAttach(args, !!process.stdout.isTTY);
|
|
113
|
+
const cwd = process.cwd();
|
|
114
|
+
const config = await loadConfig(cwd);
|
|
115
|
+
const projectRoot = config.project.root;
|
|
116
|
+
const tmuxSession = gatewayTmuxSession(config.project.name);
|
|
117
|
+
|
|
118
|
+
// Check for existing gateway
|
|
119
|
+
const legioDir = join(projectRoot, ".legio");
|
|
120
|
+
const { store } = openSessionStore(legioDir);
|
|
121
|
+
try {
|
|
122
|
+
const existing = store.getByName(GATEWAY_NAME);
|
|
123
|
+
|
|
124
|
+
if (
|
|
125
|
+
existing &&
|
|
126
|
+
existing.capability === "gateway" &&
|
|
127
|
+
existing.state !== "completed" &&
|
|
128
|
+
existing.state !== "zombie"
|
|
129
|
+
) {
|
|
130
|
+
const alive = await tmux.isSessionAlive(existing.tmuxSession);
|
|
131
|
+
if (alive) {
|
|
132
|
+
throw new AgentError(
|
|
133
|
+
`Gateway is already running (tmux: ${existing.tmuxSession}, since: ${existing.startedAt})`,
|
|
134
|
+
{ agentName: GATEWAY_NAME },
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
// Session recorded but tmux is dead — mark as completed and continue
|
|
138
|
+
store.updateState(GATEWAY_NAME, "completed");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Deploy hooks to the project root so the gateway gets event logging,
|
|
142
|
+
// mail check --inject, and activity tracking via the standard hook pipeline.
|
|
143
|
+
await deployHooks(projectRoot, GATEWAY_NAME, "gateway");
|
|
144
|
+
|
|
145
|
+
// Create gateway identity if first run
|
|
146
|
+
const identityBaseDir = join(projectRoot, ".legio", "agents");
|
|
147
|
+
await mkdir(identityBaseDir, { recursive: true });
|
|
148
|
+
const existingIdentity = await loadIdentity(identityBaseDir, GATEWAY_NAME);
|
|
149
|
+
if (!existingIdentity) {
|
|
150
|
+
await createIdentity(identityBaseDir, {
|
|
151
|
+
name: GATEWAY_NAME,
|
|
152
|
+
capability: "gateway",
|
|
153
|
+
created: new Date().toISOString(),
|
|
154
|
+
sessionsCompleted: 0,
|
|
155
|
+
expertiseDomains: config.mulch.enabled ? config.mulch.domains : [],
|
|
156
|
+
recentTasks: [],
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Resolve model from config > manifest > fallback (opus for gateway)
|
|
161
|
+
const manifestLoader = createManifestLoader(
|
|
162
|
+
join(projectRoot, config.agents.manifestPath),
|
|
163
|
+
join(projectRoot, config.agents.baseDir),
|
|
164
|
+
);
|
|
165
|
+
const manifest = await manifestLoader.load();
|
|
166
|
+
const model = resolveModel(config, manifest, "gateway", "opus");
|
|
167
|
+
|
|
168
|
+
// Build settings JSON file to skip the bypass dialog and inject the
|
|
169
|
+
// agent definition. Avoids --append-system-prompt's ERR_STREAM_DESTROYED
|
|
170
|
+
// crash with large payloads on Claude Code v2.1.50.
|
|
171
|
+
const agentDefPath = join(projectRoot, ".legio", "agent-defs", "gateway.md");
|
|
172
|
+
const settings: Record<string, unknown> = { skipDangerousModePermissionPrompt: true };
|
|
173
|
+
if (await fileExists(agentDefPath)) {
|
|
174
|
+
settings.appendSystemPrompt = await readFile(agentDefPath, "utf-8");
|
|
175
|
+
}
|
|
176
|
+
const settingsPath = join(legioDir, `settings-${GATEWAY_NAME}.json`);
|
|
177
|
+
await writeFile(settingsPath, JSON.stringify(settings), "utf-8");
|
|
178
|
+
const claudeCmd = `claude --model ${model} --dangerously-skip-permissions --settings ${settingsPath}`;
|
|
179
|
+
const pid = await tmux.createSession(tmuxSession, projectRoot, claudeCmd, {
|
|
180
|
+
LEGIO_AGENT_NAME: GATEWAY_NAME,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Record session BEFORE sending the beacon so that hook-triggered
|
|
184
|
+
// updateLastActivity() can find the entry and transition booting->working.
|
|
185
|
+
const session: AgentSession = {
|
|
186
|
+
id: `session-${Date.now()}-${GATEWAY_NAME}`,
|
|
187
|
+
agentName: GATEWAY_NAME,
|
|
188
|
+
capability: "gateway",
|
|
189
|
+
worktreePath: projectRoot, // Gateway uses project root, not a worktree
|
|
190
|
+
branchName: config.project.canonicalBranch, // Operates on canonical branch
|
|
191
|
+
beadId: "", // No specific bead assignment
|
|
192
|
+
tmuxSession,
|
|
193
|
+
state: "booting",
|
|
194
|
+
pid,
|
|
195
|
+
parentAgent: null, // No parent
|
|
196
|
+
depth: 0,
|
|
197
|
+
runId: null,
|
|
198
|
+
startedAt: new Date().toISOString(),
|
|
199
|
+
lastActivity: new Date().toISOString(),
|
|
200
|
+
escalationLevel: 0,
|
|
201
|
+
stalledSince: null,
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
store.upsert(session);
|
|
205
|
+
|
|
206
|
+
// Write output BEFORE the blocking sleep+sendKeys so that callers
|
|
207
|
+
// reading stdout (e.g., runLegio in the server) get the response
|
|
208
|
+
// immediately and don't hang waiting for the pipe to close.
|
|
209
|
+
const output = {
|
|
210
|
+
agentName: GATEWAY_NAME,
|
|
211
|
+
capability: "gateway",
|
|
212
|
+
tmuxSession,
|
|
213
|
+
projectRoot,
|
|
214
|
+
pid,
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
if (json) {
|
|
218
|
+
process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
219
|
+
} else {
|
|
220
|
+
process.stdout.write("Gateway started\n");
|
|
221
|
+
process.stdout.write(` Tmux: ${tmuxSession}\n`);
|
|
222
|
+
process.stdout.write(` Root: ${projectRoot}\n`);
|
|
223
|
+
process.stdout.write(` PID: ${pid}\n`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Send beacon after TUI initialization delay
|
|
227
|
+
await sleep(3_000);
|
|
228
|
+
const beacon = buildGatewayBeacon();
|
|
229
|
+
await tmux.sendKeys(tmuxSession, beacon);
|
|
230
|
+
|
|
231
|
+
// Follow-up Enter to ensure submission (same pattern as sling.ts)
|
|
232
|
+
await sleep(500);
|
|
233
|
+
await tmux.sendKeys(tmuxSession, "");
|
|
234
|
+
|
|
235
|
+
if (shouldAttach) {
|
|
236
|
+
spawnSync("tmux", ["attach-session", "-t", tmuxSession], {
|
|
237
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
} finally {
|
|
241
|
+
store.close();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Stop the gateway agent.
|
|
247
|
+
*
|
|
248
|
+
* 1. Find the active gateway session
|
|
249
|
+
* 2. Kill the tmux session (with process tree cleanup)
|
|
250
|
+
* 3. Mark session as completed in SessionStore
|
|
251
|
+
*/
|
|
252
|
+
async function stopGateway(args: string[], deps: GatewayDeps = {}): Promise<void> {
|
|
253
|
+
const tmux = deps._tmux ?? { createSession, isSessionAlive, killSession, sendKeys };
|
|
254
|
+
|
|
255
|
+
const json = args.includes("--json");
|
|
256
|
+
const cwd = process.cwd();
|
|
257
|
+
const config = await loadConfig(cwd);
|
|
258
|
+
const projectRoot = config.project.root;
|
|
259
|
+
|
|
260
|
+
const legioDir = join(projectRoot, ".legio");
|
|
261
|
+
const { store } = openSessionStore(legioDir);
|
|
262
|
+
try {
|
|
263
|
+
const session = store.getByName(GATEWAY_NAME);
|
|
264
|
+
|
|
265
|
+
if (
|
|
266
|
+
!session ||
|
|
267
|
+
session.capability !== "gateway" ||
|
|
268
|
+
session.state === "completed" ||
|
|
269
|
+
session.state === "zombie"
|
|
270
|
+
) {
|
|
271
|
+
throw new AgentError("No active gateway session found", {
|
|
272
|
+
agentName: GATEWAY_NAME,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Kill tmux session with process tree cleanup
|
|
277
|
+
const alive = await tmux.isSessionAlive(session.tmuxSession);
|
|
278
|
+
if (alive) {
|
|
279
|
+
await tmux.killSession(session.tmuxSession);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Update session state
|
|
283
|
+
store.updateState(GATEWAY_NAME, "completed");
|
|
284
|
+
store.updateLastActivity(GATEWAY_NAME);
|
|
285
|
+
|
|
286
|
+
if (json) {
|
|
287
|
+
process.stdout.write(`${JSON.stringify({ stopped: true, sessionId: session.id })}\n`);
|
|
288
|
+
} else {
|
|
289
|
+
process.stdout.write(`Gateway stopped (session: ${session.id})\n`);
|
|
290
|
+
}
|
|
291
|
+
} finally {
|
|
292
|
+
store.close();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Show gateway status.
|
|
298
|
+
*
|
|
299
|
+
* Checks session registry and tmux liveness to report actual state.
|
|
300
|
+
*/
|
|
301
|
+
async function statusGateway(args: string[], deps: GatewayDeps = {}): Promise<void> {
|
|
302
|
+
const tmux = deps._tmux ?? { createSession, isSessionAlive, killSession, sendKeys };
|
|
303
|
+
|
|
304
|
+
const json = args.includes("--json");
|
|
305
|
+
const cwd = process.cwd();
|
|
306
|
+
const config = await loadConfig(cwd);
|
|
307
|
+
const projectRoot = config.project.root;
|
|
308
|
+
|
|
309
|
+
const legioDir = join(projectRoot, ".legio");
|
|
310
|
+
const { store } = openSessionStore(legioDir);
|
|
311
|
+
try {
|
|
312
|
+
const session = store.getByName(GATEWAY_NAME);
|
|
313
|
+
|
|
314
|
+
if (
|
|
315
|
+
!session ||
|
|
316
|
+
session.capability !== "gateway" ||
|
|
317
|
+
session.state === "completed" ||
|
|
318
|
+
session.state === "zombie"
|
|
319
|
+
) {
|
|
320
|
+
if (json) {
|
|
321
|
+
process.stdout.write(`${JSON.stringify({ running: false })}\n`);
|
|
322
|
+
} else {
|
|
323
|
+
process.stdout.write("Gateway is not running\n");
|
|
324
|
+
}
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const alive = await tmux.isSessionAlive(session.tmuxSession);
|
|
329
|
+
|
|
330
|
+
// Reconcile state: if session says active but tmux is dead, update.
|
|
331
|
+
// We already filtered out completed/zombie states above, so if tmux is dead
|
|
332
|
+
// this session needs to be marked as zombie.
|
|
333
|
+
if (!alive) {
|
|
334
|
+
store.updateState(GATEWAY_NAME, "zombie");
|
|
335
|
+
session.state = "zombie";
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const status = {
|
|
339
|
+
running: alive,
|
|
340
|
+
sessionId: session.id,
|
|
341
|
+
state: session.state,
|
|
342
|
+
tmuxSession: session.tmuxSession,
|
|
343
|
+
pid: session.pid,
|
|
344
|
+
startedAt: session.startedAt,
|
|
345
|
+
lastActivity: session.lastActivity,
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
if (json) {
|
|
349
|
+
process.stdout.write(`${JSON.stringify(status)}\n`);
|
|
350
|
+
} else {
|
|
351
|
+
const stateLabel = alive ? "running" : session.state;
|
|
352
|
+
process.stdout.write(`Gateway: ${stateLabel}\n`);
|
|
353
|
+
process.stdout.write(` Session: ${session.id}\n`);
|
|
354
|
+
process.stdout.write(` Tmux: ${session.tmuxSession}\n`);
|
|
355
|
+
process.stdout.write(` PID: ${session.pid}\n`);
|
|
356
|
+
process.stdout.write(` Started: ${session.startedAt}\n`);
|
|
357
|
+
process.stdout.write(` Activity: ${session.lastActivity}\n`);
|
|
358
|
+
}
|
|
359
|
+
} finally {
|
|
360
|
+
store.close();
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const GATEWAY_HELP = `legio gateway — Manage the gateway planning agent
|
|
365
|
+
|
|
366
|
+
Usage: legio gateway <subcommand> [flags]
|
|
367
|
+
|
|
368
|
+
Subcommands:
|
|
369
|
+
start Start the gateway (spawns Claude Code at project root)
|
|
370
|
+
stop Stop the gateway (kills tmux session)
|
|
371
|
+
status Show gateway state
|
|
372
|
+
|
|
373
|
+
Start options:
|
|
374
|
+
--attach Always attach to tmux session after start
|
|
375
|
+
--no-attach Never attach to tmux session after start
|
|
376
|
+
Default: attach when running in an interactive TTY
|
|
377
|
+
|
|
378
|
+
General options:
|
|
379
|
+
--json Output as JSON
|
|
380
|
+
--help, -h Show this help
|
|
381
|
+
|
|
382
|
+
The gateway agent is a planning companion that:
|
|
383
|
+
- Helps decompose objectives into tasks
|
|
384
|
+
- Advises on architecture and approach
|
|
385
|
+
- Creates beads issues (bd create)
|
|
386
|
+
- Communicates via mail with the team`;
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Entry point for `legio gateway <subcommand>`.
|
|
390
|
+
*
|
|
391
|
+
* @param args - CLI arguments after "gateway"
|
|
392
|
+
* @param deps - Optional dependency injection for testing (tmux)
|
|
393
|
+
*/
|
|
394
|
+
export async function gatewayCommand(args: string[], deps: GatewayDeps = {}): Promise<void> {
|
|
395
|
+
if (args.includes("--help") || args.includes("-h") || args.length === 0) {
|
|
396
|
+
process.stdout.write(`${GATEWAY_HELP}\n`);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const subcommand = args[0];
|
|
401
|
+
const subArgs = args.slice(1);
|
|
402
|
+
|
|
403
|
+
switch (subcommand) {
|
|
404
|
+
case "start":
|
|
405
|
+
await startGateway(subArgs, deps);
|
|
406
|
+
break;
|
|
407
|
+
case "stop":
|
|
408
|
+
await stopGateway(subArgs, deps);
|
|
409
|
+
break;
|
|
410
|
+
case "status":
|
|
411
|
+
await statusGateway(subArgs, deps);
|
|
412
|
+
break;
|
|
413
|
+
default:
|
|
414
|
+
throw new ValidationError(
|
|
415
|
+
`Unknown gateway subcommand: ${subcommand}. Run 'legio gateway --help' for usage.`,
|
|
416
|
+
{ field: "subcommand", value: subcommand },
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for legio group command.
|
|
3
|
+
*
|
|
4
|
+
* Uses real temp directories for groups.json I/O. Does NOT mock bd CLI --
|
|
5
|
+
* tests focus on the JSON storage layer and validation logic.
|
|
6
|
+
* The beads validation is tested with --skip-validation flag since
|
|
7
|
+
* bd is an external CLI not available in unit tests.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
13
|
+
import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
|
|
14
|
+
import type { TaskGroup } from "../types.ts";
|
|
15
|
+
import { loadGroups } from "./group.ts";
|
|
16
|
+
|
|
17
|
+
let tempDir: string;
|
|
18
|
+
let legioDir: string;
|
|
19
|
+
let groupsJsonPath: string;
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
tempDir = await createTempGitRepo();
|
|
23
|
+
legioDir = join(tempDir, ".legio");
|
|
24
|
+
await mkdir(legioDir, { recursive: true });
|
|
25
|
+
groupsJsonPath = join(legioDir, "groups.json");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(async () => {
|
|
29
|
+
await cleanupTempDir(tempDir);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Helper to write groups.json directly for test setup.
|
|
34
|
+
*/
|
|
35
|
+
async function writeGroups(groups: TaskGroup[]): Promise<void> {
|
|
36
|
+
await writeFile(groupsJsonPath, `${JSON.stringify(groups, null, "\t")}\n`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Helper to read groups.json directly for assertions.
|
|
41
|
+
*/
|
|
42
|
+
async function readGroups(): Promise<TaskGroup[]> {
|
|
43
|
+
const text = await readFile(groupsJsonPath, "utf-8");
|
|
44
|
+
return JSON.parse(text) as TaskGroup[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeGroup(overrides?: Partial<TaskGroup>): TaskGroup {
|
|
48
|
+
return {
|
|
49
|
+
id: `group-${crypto.randomUUID().slice(0, 8)}`,
|
|
50
|
+
name: "Test Group",
|
|
51
|
+
memberIssueIds: ["issue-1", "issue-2"],
|
|
52
|
+
status: "active",
|
|
53
|
+
createdAt: new Date().toISOString(),
|
|
54
|
+
completedAt: null,
|
|
55
|
+
...overrides,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("loadGroups", () => {
|
|
60
|
+
test("returns empty array when groups.json does not exist", async () => {
|
|
61
|
+
const groups = await loadGroups(tempDir);
|
|
62
|
+
expect(groups).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("returns empty array when groups.json is malformed", async () => {
|
|
66
|
+
await writeFile(groupsJsonPath, "not valid json");
|
|
67
|
+
const groups = await loadGroups(tempDir);
|
|
68
|
+
expect(groups).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("loads groups from valid groups.json", async () => {
|
|
72
|
+
const group = makeGroup({ name: "My Group" });
|
|
73
|
+
await writeGroups([group]);
|
|
74
|
+
const groups = await loadGroups(tempDir);
|
|
75
|
+
expect(groups).toHaveLength(1);
|
|
76
|
+
expect(groups[0]?.name).toBe("My Group");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("group create (via JSON storage)", () => {
|
|
81
|
+
test("creates a group with correct structure", async () => {
|
|
82
|
+
const group = makeGroup({
|
|
83
|
+
name: "Feature Batch",
|
|
84
|
+
memberIssueIds: ["abc-123", "def-456"],
|
|
85
|
+
});
|
|
86
|
+
await writeGroups([group]);
|
|
87
|
+
|
|
88
|
+
const groups = await readGroups();
|
|
89
|
+
expect(groups).toHaveLength(1);
|
|
90
|
+
const saved = groups[0];
|
|
91
|
+
expect(saved?.name).toBe("Feature Batch");
|
|
92
|
+
expect(saved?.memberIssueIds).toEqual(["abc-123", "def-456"]);
|
|
93
|
+
expect(saved?.status).toBe("active");
|
|
94
|
+
expect(saved?.completedAt).toBeNull();
|
|
95
|
+
expect(saved?.id).toMatch(/^group-[a-f0-9]{8}$/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("group ID has correct format", () => {
|
|
99
|
+
const id = `group-${crypto.randomUUID().slice(0, 8)}`;
|
|
100
|
+
expect(id).toMatch(/^group-[a-f0-9]{8}$/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("groups.json has trailing newline", async () => {
|
|
104
|
+
await writeGroups([makeGroup()]);
|
|
105
|
+
const raw = await readFile(groupsJsonPath, "utf-8");
|
|
106
|
+
expect(raw.endsWith("\n")).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("group add (via JSON storage)", () => {
|
|
111
|
+
test("adds issues to existing group", async () => {
|
|
112
|
+
const group = makeGroup({ memberIssueIds: ["issue-1"] });
|
|
113
|
+
await writeGroups([group]);
|
|
114
|
+
|
|
115
|
+
// Simulate add
|
|
116
|
+
const groups = await readGroups();
|
|
117
|
+
const target = groups[0];
|
|
118
|
+
expect(target).toBeDefined();
|
|
119
|
+
if (target) {
|
|
120
|
+
target.memberIssueIds.push("issue-2", "issue-3");
|
|
121
|
+
await writeGroups(groups);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const updated = await readGroups();
|
|
125
|
+
expect(updated[0]?.memberIssueIds).toEqual(["issue-1", "issue-2", "issue-3"]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("reopens completed group when adding issues", async () => {
|
|
129
|
+
const group = makeGroup({
|
|
130
|
+
status: "completed",
|
|
131
|
+
completedAt: new Date().toISOString(),
|
|
132
|
+
});
|
|
133
|
+
await writeGroups([group]);
|
|
134
|
+
|
|
135
|
+
const groups = await readGroups();
|
|
136
|
+
const target = groups[0];
|
|
137
|
+
expect(target).toBeDefined();
|
|
138
|
+
if (target) {
|
|
139
|
+
target.memberIssueIds.push("new-issue");
|
|
140
|
+
target.status = "active";
|
|
141
|
+
target.completedAt = null;
|
|
142
|
+
await writeGroups(groups);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const updated = await readGroups();
|
|
146
|
+
expect(updated[0]?.status).toBe("active");
|
|
147
|
+
expect(updated[0]?.completedAt).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("detects duplicate members", () => {
|
|
151
|
+
const group = makeGroup({ memberIssueIds: ["issue-1", "issue-2"] });
|
|
152
|
+
const isDuplicate = group.memberIssueIds.includes("issue-1");
|
|
153
|
+
expect(isDuplicate).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("group remove (via JSON storage)", () => {
|
|
158
|
+
test("removes issues from group", async () => {
|
|
159
|
+
const group = makeGroup({ memberIssueIds: ["a", "b", "c"] });
|
|
160
|
+
await writeGroups([group]);
|
|
161
|
+
|
|
162
|
+
const groups = await readGroups();
|
|
163
|
+
const target = groups[0];
|
|
164
|
+
expect(target).toBeDefined();
|
|
165
|
+
if (target) {
|
|
166
|
+
target.memberIssueIds = target.memberIssueIds.filter((id) => id !== "b");
|
|
167
|
+
await writeGroups(groups);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const updated = await readGroups();
|
|
171
|
+
expect(updated[0]?.memberIssueIds).toEqual(["a", "c"]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("cannot remove all issues (would leave empty group)", () => {
|
|
175
|
+
const group = makeGroup({ memberIssueIds: ["only-one"] });
|
|
176
|
+
const toRemove = ["only-one"];
|
|
177
|
+
const remaining = group.memberIssueIds.filter((id) => !toRemove.includes(id));
|
|
178
|
+
expect(remaining.length).toBe(0);
|
|
179
|
+
// The command should throw GroupError in this case
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("detects non-member issue", () => {
|
|
183
|
+
const group = makeGroup({ memberIssueIds: ["a", "b"] });
|
|
184
|
+
const isNotMember = !group.memberIssueIds.includes("c");
|
|
185
|
+
expect(isNotMember).toBe(true);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("auto-close logic", () => {
|
|
190
|
+
test("marks group completed when all issues are closed", async () => {
|
|
191
|
+
const group = makeGroup({
|
|
192
|
+
status: "active",
|
|
193
|
+
memberIssueIds: ["done-1", "done-2"],
|
|
194
|
+
});
|
|
195
|
+
await writeGroups([group]);
|
|
196
|
+
|
|
197
|
+
// Simulate auto-close: all completed
|
|
198
|
+
const groups = await readGroups();
|
|
199
|
+
const target = groups[0];
|
|
200
|
+
expect(target).toBeDefined();
|
|
201
|
+
if (target && target.status === "active") {
|
|
202
|
+
// All issues closed -> auto-close
|
|
203
|
+
target.status = "completed";
|
|
204
|
+
target.completedAt = new Date().toISOString();
|
|
205
|
+
await writeGroups(groups);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const updated = await readGroups();
|
|
209
|
+
expect(updated[0]?.status).toBe("completed");
|
|
210
|
+
expect(updated[0]?.completedAt).not.toBeNull();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("does not auto-close when some issues are still open", async () => {
|
|
214
|
+
const group = makeGroup({ status: "active" });
|
|
215
|
+
await writeGroups([group]);
|
|
216
|
+
|
|
217
|
+
// No change -- some still open
|
|
218
|
+
const groups = await readGroups();
|
|
219
|
+
expect(groups[0]?.status).toBe("active");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("does not auto-close already-completed group", () => {
|
|
223
|
+
const group = makeGroup({ status: "completed", completedAt: "2025-01-01T00:00:00Z" });
|
|
224
|
+
// Already completed, should not re-trigger
|
|
225
|
+
expect(group.status).toBe("completed");
|
|
226
|
+
expect(group.completedAt).toBe("2025-01-01T00:00:00Z");
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("group list (via JSON storage)", () => {
|
|
231
|
+
test("lists all groups", async () => {
|
|
232
|
+
const g1 = makeGroup({ name: "Group A" });
|
|
233
|
+
const g2 = makeGroup({ name: "Group B", status: "completed" });
|
|
234
|
+
await writeGroups([g1, g2]);
|
|
235
|
+
|
|
236
|
+
const groups = await readGroups();
|
|
237
|
+
expect(groups).toHaveLength(2);
|
|
238
|
+
expect(groups[0]?.name).toBe("Group A");
|
|
239
|
+
expect(groups[1]?.name).toBe("Group B");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("empty list when no groups exist", async () => {
|
|
243
|
+
const groups = await loadGroups(tempDir);
|
|
244
|
+
expect(groups).toEqual([]);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("error cases", () => {
|
|
249
|
+
test("group not found by ID", async () => {
|
|
250
|
+
await writeGroups([makeGroup()]);
|
|
251
|
+
const groups = await readGroups();
|
|
252
|
+
const found = groups.find((g) => g.id === "group-nonexist");
|
|
253
|
+
expect(found).toBeUndefined();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("multiple groups can be stored", async () => {
|
|
257
|
+
const groups = [makeGroup({ name: "A" }), makeGroup({ name: "B" }), makeGroup({ name: "C" })];
|
|
258
|
+
await writeGroups(groups);
|
|
259
|
+
const loaded = await readGroups();
|
|
260
|
+
expect(loaded).toHaveLength(3);
|
|
261
|
+
});
|
|
262
|
+
});
|