feat: add host commander skeleton
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
FROM oven/bun:1-alpine
|
||||
|
||||
WORKDIR /app/src/components/microservices/host-codex-commander
|
||||
COPY src/components/microservices/host-codex-commander/package.json ./package.json
|
||||
RUN bun install --production
|
||||
COPY src/components/microservices/host-codex-commander/tsconfig.json ./tsconfig.json
|
||||
COPY src/components/shared /app/src/components/shared
|
||||
COPY src/components/microservices/host-codex-commander/src ./src
|
||||
|
||||
EXPOSE 4261
|
||||
CMD ["bun", "run", "src/index.ts"]
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@unidesk/host-codex-commander",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "bun run src/index.ts",
|
||||
"check": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/node": "latest",
|
||||
"typescript": "latest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
export const commanderServiceId = "host-codex-commander";
|
||||
|
||||
export const commanderPhase = "source-contract";
|
||||
|
||||
export const commanderHighRiskActions = [
|
||||
"code-queue-backend-restart",
|
||||
"code-queue-backend-rebuild",
|
||||
"code-queue-execution-plane-restart",
|
||||
"code-queue-task-interrupt",
|
||||
"code-queue-task-cancel",
|
||||
"prod-runtime-mutation",
|
||||
] as const;
|
||||
|
||||
export type CommanderHighRiskAction = typeof commanderHighRiskActions[number];
|
||||
|
||||
export type CommanderSessionState =
|
||||
| "unknown"
|
||||
| "discovered"
|
||||
| "planned"
|
||||
| "starting"
|
||||
| "running"
|
||||
| "attention_required"
|
||||
| "stopping"
|
||||
| "stopped"
|
||||
| "degraded";
|
||||
|
||||
export type CommanderPromptState =
|
||||
| "draft"
|
||||
| "planned"
|
||||
| "queued_for_injection"
|
||||
| "injected"
|
||||
| "rejected"
|
||||
| "failed";
|
||||
|
||||
export type CommanderApprovalState =
|
||||
| "draft"
|
||||
| "requested"
|
||||
| "approved"
|
||||
| "rejected"
|
||||
| "expired"
|
||||
| "consumed";
|
||||
|
||||
export interface CommanderContract {
|
||||
ok: true;
|
||||
phase: typeof commanderPhase;
|
||||
serviceId: typeof commanderServiceId;
|
||||
currentImplementation: "host-codex-commander-skeleton";
|
||||
daemonImplemented: false;
|
||||
liveOperationsImplemented: false;
|
||||
purpose: string;
|
||||
ownershipBoundary: {
|
||||
hostCodexProcess: string;
|
||||
controlMicroservice: string;
|
||||
codeQueue: string;
|
||||
claudeqq: string;
|
||||
};
|
||||
requiredCapabilities: string[];
|
||||
apiContract: {
|
||||
health: string;
|
||||
contract: string;
|
||||
sessions: string;
|
||||
sessionPlan: string;
|
||||
promptPlan: string;
|
||||
traceSummary: string;
|
||||
issueWritePlan: string;
|
||||
approvalRequest: string;
|
||||
};
|
||||
stateModel: {
|
||||
sessionStates: CommanderSessionState[];
|
||||
promptStates: CommanderPromptState[];
|
||||
approvalStates: CommanderApprovalState[];
|
||||
storageRoot: ".state/commander/";
|
||||
redactionPolicy: string;
|
||||
};
|
||||
safetyBoundary: {
|
||||
phaseOneMutationAllowed: false;
|
||||
forbiddenWithoutExplicitUserApproval: CommanderHighRiskAction[];
|
||||
alwaysForbidden: string[];
|
||||
confirmationPolicy: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function commanderContract(): CommanderContract {
|
||||
return {
|
||||
ok: true,
|
||||
phase: commanderPhase,
|
||||
serviceId: commanderServiceId,
|
||||
currentImplementation: "host-codex-commander-skeleton",
|
||||
daemonImplemented: false,
|
||||
liveOperationsImplemented: false,
|
||||
purpose: "Keep host Codex supervision observable through a local-only skeleton that can persist state, summarize traces, and draft approvals without touching live bridges.",
|
||||
ownershipBoundary: {
|
||||
hostCodexProcess: "Long-lived Codex process on the master server host.",
|
||||
controlMicroservice: "Local skeleton for health, state, trace summary, and approval drafting with no live bridge or executor.",
|
||||
codeQueue: "Execution plane remains separate and is never restarted or attached by this skeleton.",
|
||||
claudeqq: "Approval draft destination only; no messages are sent from this stage.",
|
||||
},
|
||||
requiredCapabilities: [
|
||||
"host-codex-process-discovery",
|
||||
"host-codex-start-plan",
|
||||
"ssh-bridge-contract",
|
||||
"pty-bridge-contract",
|
||||
"stdio-bridge-contract",
|
||||
"prompt-guidance-plan",
|
||||
"trace-summary-plan",
|
||||
"issue-20-board-read-write-entry",
|
||||
"issue-46-brief-read-write-entry",
|
||||
"claudeqq-high-risk-approval-entry",
|
||||
],
|
||||
apiContract: {
|
||||
health: "GET /health",
|
||||
contract: "GET /api/commander/contract",
|
||||
sessions: "GET /api/commander/sessions",
|
||||
sessionPlan: "POST /api/commander/sessions/:sessionId/plan-start",
|
||||
promptPlan: "POST /api/commander/sessions/:sessionId/prompt-plan",
|
||||
traceSummary: "GET /api/commander/trace-summary",
|
||||
issueWritePlan: "POST /api/commander/issues/:issueNumber/write-plan",
|
||||
approvalRequest: "POST /api/commander/approvals",
|
||||
},
|
||||
stateModel: {
|
||||
sessionStates: ["unknown", "discovered", "planned", "starting", "running", "attention_required", "stopping", "stopped", "degraded"],
|
||||
promptStates: ["draft", "planned", "queued_for_injection", "injected", "rejected", "failed"],
|
||||
approvalStates: ["draft", "requested", "approved", "rejected", "expired", "consumed"],
|
||||
storageRoot: ".state/commander/",
|
||||
redactionPolicy: "Never persist or print token, secret, password, key, cookie, authorization, or credential URLs in cleartext.",
|
||||
},
|
||||
safetyBoundary: {
|
||||
phaseOneMutationAllowed: false,
|
||||
forbiddenWithoutExplicitUserApproval: commanderHighRiskActions,
|
||||
alwaysForbidden: [
|
||||
"print-token-values",
|
||||
"read-token-files-for-display",
|
||||
"direct-database-state-patch",
|
||||
"bypass-code-queue-backend-confirmation-policy",
|
||||
"replace-code-queue-runner",
|
||||
"deploy-or-restart-production-runtime-from-this-contract-stub",
|
||||
],
|
||||
confirmationPolicy: "High-risk actions must draft a ClaudeQQ request, wait for explicit user approval, bind approval to one exact action, and record the decision before any future live executor may proceed.",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { createHourlyJsonlWriter, logRetentionBytesForService } from "../../../shared/src/rotating-jsonl";
|
||||
import { commanderContract } from "./contract";
|
||||
import { commanderHighRiskActions } from "./contract";
|
||||
import {
|
||||
appendCommanderTraceEvent,
|
||||
buildCommanderApprovalDraft,
|
||||
commanderApprovalPreview,
|
||||
commanderHealth,
|
||||
commanderSessionPreview,
|
||||
commanderStatePaths,
|
||||
listCommanderSessions,
|
||||
normalizeCommanderApprovalState,
|
||||
normalizeCommanderPromptState,
|
||||
normalizeCommanderSessionState,
|
||||
readCommanderApproval,
|
||||
readCommanderSession,
|
||||
writeCommanderApproval,
|
||||
writeCommanderSession,
|
||||
summarizeCommanderTrace,
|
||||
type CommanderStorageConfig,
|
||||
type CommanderApprovalDraftRecord,
|
||||
type CommanderSessionRecord,
|
||||
} from "./state";
|
||||
|
||||
export interface RuntimeConfig extends CommanderStorageConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
logFile: string;
|
||||
serviceId: string;
|
||||
stateRoot: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
const startedAt = new Date().toISOString();
|
||||
const recentLogs: JsonRecord[] = [];
|
||||
const isCheckMode = process.execArgv.includes("--check");
|
||||
|
||||
function envString(name: string, fallback: string): string {
|
||||
const value = process.env[name];
|
||||
return value === undefined || value.length === 0 ? fallback : value;
|
||||
}
|
||||
|
||||
function envNumber(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw.trim().length === 0) return fallback;
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
|
||||
}
|
||||
|
||||
function buildConfig(): RuntimeConfig {
|
||||
const stateRoot = envString("COMMANDER_STATE_ROOT", "/var/lib/unidesk/commander");
|
||||
const logFile = envString("LOG_FILE", "/var/log/unidesk/commander.jsonl");
|
||||
return {
|
||||
rootDir: stateRoot,
|
||||
host: envString("HOST", "0.0.0.0"),
|
||||
port: envNumber("PORT", 4261),
|
||||
logFile,
|
||||
serviceId: "host-codex-commander",
|
||||
stateRoot,
|
||||
sessionId: envString("COMMANDER_SESSION_ID", "primary"),
|
||||
};
|
||||
}
|
||||
|
||||
let config = buildConfig();
|
||||
let logWriter: ReturnType<typeof createHourlyJsonlWriter> | null = null;
|
||||
|
||||
export function setCommanderRuntimeConfig(next: RuntimeConfig): void {
|
||||
config = next;
|
||||
logWriter = null;
|
||||
}
|
||||
|
||||
function getLogWriter(): ReturnType<typeof createHourlyJsonlWriter> {
|
||||
if (logWriter === null) {
|
||||
mkdirSync(dirname(config.logFile), { recursive: true });
|
||||
mkdirSync(config.stateRoot, { recursive: true });
|
||||
logWriter = createHourlyJsonlWriter({
|
||||
baseLogFile: config.logFile,
|
||||
service: "host-codex-commander",
|
||||
maxBytes: logRetentionBytesForService("host-codex-commander"),
|
||||
});
|
||||
logWriter.prune();
|
||||
}
|
||||
return logWriter;
|
||||
}
|
||||
|
||||
function log(event: string, detail: JsonRecord = {}): void {
|
||||
const record: JsonRecord = { at: new Date().toISOString(), service: config.serviceId, event, ...detail };
|
||||
recentLogs.push(record);
|
||||
while (recentLogs.length > 200) recentLogs.shift();
|
||||
try {
|
||||
getLogWriter().appendJson(record, new Date(String(record.at)));
|
||||
} catch {
|
||||
// Logging must never block the skeleton service.
|
||||
}
|
||||
console.log(JSON.stringify(record));
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json; charset=utf-8", ...headers },
|
||||
});
|
||||
}
|
||||
|
||||
function errorToJson(error: unknown): JsonRecord {
|
||||
if (error instanceof Error) return { name: error.name, message: error.message, stack: error.stack ?? "" };
|
||||
return { message: String(error) };
|
||||
}
|
||||
|
||||
function errorResponse(error: unknown, status = 500): Response {
|
||||
log("request_failed", { status, error: errorToJson(error) });
|
||||
return jsonResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }, status);
|
||||
}
|
||||
|
||||
function readJsonBody(body: Request): Promise<unknown> {
|
||||
return body.json();
|
||||
}
|
||||
|
||||
function routePath(url: URL): string {
|
||||
return url.pathname.replace(/\/+$/u, "") || "/";
|
||||
}
|
||||
|
||||
function commanderInfo(): JsonRecord {
|
||||
const paths = commanderStatePaths(config);
|
||||
const session = readCommanderSession(config, config.sessionId);
|
||||
return {
|
||||
ok: true,
|
||||
service: config.serviceId,
|
||||
serviceId: config.serviceId,
|
||||
status: "healthy",
|
||||
startedAt,
|
||||
config: {
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
stateRoot: config.stateRoot,
|
||||
logFile: config.logFile,
|
||||
sessionId: config.sessionId,
|
||||
},
|
||||
state: commanderSessionPreview(session),
|
||||
files: {
|
||||
stateFile: paths.stateFile,
|
||||
approvalFile: paths.approvalFile,
|
||||
traceFile: paths.traceFile,
|
||||
logFile: paths.logFile,
|
||||
stateRootExists: existsSync(config.stateRoot),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createCommanderRequestHandler(runtimeConfig: RuntimeConfig): (req: Request) => Promise<Response> | Response {
|
||||
return async (req: Request) => {
|
||||
const previousConfig = config;
|
||||
config = runtimeConfig;
|
||||
try {
|
||||
return await Promise.resolve(handleCommanderRequest(req));
|
||||
} finally {
|
||||
config = previousConfig;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSessionUpdate(input: unknown): CommanderSessionRecord {
|
||||
const body = typeof input === "object" && input !== null && !Array.isArray(input) ? input as Record<string, unknown> : {};
|
||||
const current = readCommanderSession(config, typeof body.sessionId === "string" && body.sessionId.length > 0 ? body.sessionId : config.sessionId);
|
||||
current.state = typeof body.state === "string" ? normalizeCommanderSessionState(body.state) : current.state;
|
||||
current.promptState = typeof body.promptState === "string" ? normalizeCommanderPromptState(body.promptState) : current.promptState;
|
||||
current.approvalState = typeof body.approvalState === "string" ? normalizeCommanderApprovalState(body.approvalState) : current.approvalState;
|
||||
current.pid = typeof body.pid === "number" && Number.isFinite(body.pid) ? Math.floor(body.pid) : current.pid;
|
||||
current.cwd = typeof body.cwd === "string" ? body.cwd : current.cwd;
|
||||
current.lastSeq = typeof body.lastSeq === "number" && Number.isFinite(body.lastSeq) ? Math.max(0, Math.floor(body.lastSeq)) : current.lastSeq;
|
||||
current.heartbeatAt = typeof body.heartbeatAt === "string" ? body.heartbeatAt : current.heartbeatAt;
|
||||
current.updatedAt = new Date().toISOString();
|
||||
if (Array.isArray(body.notes)) {
|
||||
current.notes = body.notes.filter((item): item is string => typeof item === "string");
|
||||
}
|
||||
return writeCommanderSession(config, current);
|
||||
}
|
||||
|
||||
function updateSessionFromTrace(input: unknown): JsonRecord {
|
||||
const body = typeof input === "object" && input !== null && !Array.isArray(input) ? input as Record<string, unknown> : {};
|
||||
const sessionId = typeof body.sessionId === "string" && body.sessionId.length > 0 ? body.sessionId : config.sessionId;
|
||||
const traceJsonl = typeof body.traceJsonl === "string" ? body.traceJsonl : "";
|
||||
const taskSummary = typeof body.taskSummary === "string" ? body.taskSummary : null;
|
||||
const taskId = typeof body.taskId === "string" ? body.taskId : null;
|
||||
const summary = summarizeCommanderTrace({ taskId, sessionId, traceJsonl, taskSummary });
|
||||
appendCommanderTraceEvent(config, sessionId, {
|
||||
type: "trace-summary-dry-run",
|
||||
taskId,
|
||||
summary,
|
||||
});
|
||||
const nextState = summary.status === "terminal"
|
||||
? "stopped"
|
||||
: summary.status === "attention_required"
|
||||
? "attention_required"
|
||||
: summary.status === "blocked"
|
||||
? "degraded"
|
||||
: "running";
|
||||
const session = writeCommanderSession(config, {
|
||||
...readCommanderSession(config, sessionId),
|
||||
sessionId,
|
||||
state: nextState,
|
||||
promptState: summary.status === "attention_required" ? "planned" : "draft",
|
||||
approvalState: summary.status === "attention_required" ? "requested" : "draft",
|
||||
lastSeq: summary.lastSeq,
|
||||
heartbeatAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
notes: [...readCommanderSession(config, sessionId).notes, `trace-summary:${summary.status}`].slice(-20),
|
||||
pid: readCommanderSession(config, sessionId).pid,
|
||||
cwd: readCommanderSession(config, sessionId).cwd,
|
||||
});
|
||||
return { ok: true, summary, session: commanderSessionPreview(session) };
|
||||
}
|
||||
|
||||
function writeApprovalDraft(input: unknown): JsonRecord {
|
||||
const body = typeof input === "object" && input !== null && !Array.isArray(input) ? input as Record<string, unknown> : {};
|
||||
const action = typeof body.action === "string" ? body.action : "unknown";
|
||||
if (!commanderHighRiskActions.includes(action as typeof commanderHighRiskActions[number])) {
|
||||
return { ok: false, error: "validation-failed", message: `unsupported high-risk action: ${action}`, highRiskActions: commanderHighRiskActions };
|
||||
}
|
||||
const taskId = typeof body.taskId === "string" ? body.taskId : null;
|
||||
const reason = typeof body.reason === "string" ? body.reason : "";
|
||||
const approvalId = typeof body.approvalId === "string" && body.approvalId.length > 0 ? body.approvalId : "draft";
|
||||
const draft = buildCommanderApprovalDraft({
|
||||
approvalId,
|
||||
action,
|
||||
taskId,
|
||||
reason,
|
||||
sessionId: config.sessionId,
|
||||
});
|
||||
const stored = writeCommanderApproval(config, draft);
|
||||
return { ok: true, approval: commanderApprovalPreview(stored), previewMarkdown: stored.previewMarkdown, previewJson: stored.previewJson };
|
||||
}
|
||||
|
||||
export function handleCommanderRequest(req: Request): Promise<Response> | Response {
|
||||
const url = new URL(req.url);
|
||||
const path = routePath(url);
|
||||
if (req.method === "GET" && (path === "/" || path === "/health")) return jsonResponse(commanderHealth(config, startedAt));
|
||||
if (req.method === "GET" && path === "/logs") return jsonResponse({ ok: true, logs: recentLogs.slice(-100), logFile: config.logFile, stateRoot: config.stateRoot, startedAt });
|
||||
if (req.method === "GET" && path === "/api/commander/contract") return jsonResponse(commanderContract());
|
||||
if (req.method === "GET" && path === "/api/commander/sessions") return jsonResponse({ ok: true, sessions: listCommanderSessions(config).map(commanderSessionPreview) });
|
||||
if (req.method === "GET" && path === "/api/commander/state") return jsonResponse({ ok: true, session: commanderSessionPreview(readCommanderSession(config, config.sessionId)), approval: commanderApprovalPreview(readCommanderApproval(config, "draft")) });
|
||||
if (req.method === "POST" && path === "/api/commander/state") {
|
||||
return readJsonBody(req).then((body) => jsonResponse({ ok: true, session: commanderSessionPreview(normalizeSessionUpdate(body)) }));
|
||||
}
|
||||
if (req.method === "GET" && path === "/api/commander/trace-summary") {
|
||||
const traceJsonl = url.searchParams.get("traceJsonl") ?? "";
|
||||
const taskSummary = url.searchParams.get("taskSummary");
|
||||
const taskId = url.searchParams.get("taskId");
|
||||
return jsonResponse({ ok: true, summary: summarizeCommanderTrace({ taskId, sessionId: config.sessionId, traceJsonl, taskSummary }) });
|
||||
}
|
||||
if (req.method === "POST" && path === "/api/commander/trace-summary") {
|
||||
return readJsonBody(req).then((body) => jsonResponse(updateSessionFromTrace(body)));
|
||||
}
|
||||
if (req.method === "POST" && path === "/api/commander/approvals") {
|
||||
return readJsonBody(req).then((body) => {
|
||||
const result = writeApprovalDraft(body);
|
||||
return jsonResponse(result, result.ok === false ? 400 : 200);
|
||||
});
|
||||
}
|
||||
return jsonResponse({
|
||||
ok: false,
|
||||
service: config.serviceId,
|
||||
status: "not_found",
|
||||
available: ["/health", "/logs", "/api/commander/contract", "/api/commander/sessions", "/api/commander/state", "/api/commander/trace-summary", "/api/commander/approvals"],
|
||||
}, 404);
|
||||
}
|
||||
|
||||
if (import.meta.main && !isCheckMode) {
|
||||
const server = Bun.serve({
|
||||
hostname: config.host,
|
||||
port: config.port,
|
||||
idleTimeout: 120,
|
||||
fetch(req) {
|
||||
return Promise.resolve().then(() => handleCommanderRequest(req)).catch((error) => errorResponse(error));
|
||||
},
|
||||
});
|
||||
|
||||
log("service_started", { startedAt, service: config.serviceId, stateRoot: config.stateRoot, logFile: config.logFile, port: server.port });
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
const secretPatterns = [
|
||||
/\b(?:sk|ghp|github_pat|xoxb|xoxp|AKIA)[A-Za-z0-9_=-]{8,}\b/gu,
|
||||
/\b(?:token|secret|password|passwd|authorization|cookie|api[_-]?key)\s*[:=]\s*[^,\s]+/giu,
|
||||
/\bBearer\s+[A-Za-z0-9._~+/-]+=*\b/giu,
|
||||
/https?:\/\/[^/\s]+:[^@\s]+@[^/\s]+/giu,
|
||||
] as const;
|
||||
|
||||
export interface RedactionResult {
|
||||
text: string;
|
||||
redactionsApplied: number;
|
||||
}
|
||||
|
||||
export function redactText(value: string): RedactionResult {
|
||||
let redactionsApplied = 0;
|
||||
let text = value;
|
||||
for (const pattern of secretPatterns) {
|
||||
text = text.replace(pattern, () => {
|
||||
redactionsApplied += 1;
|
||||
return "<redacted>";
|
||||
});
|
||||
}
|
||||
return { text, redactionsApplied };
|
||||
}
|
||||
|
||||
export function redactJsonValue(value: unknown): unknown {
|
||||
if (typeof value === "string") return redactText(value).text;
|
||||
if (Array.isArray(value)) return value.map((item) => redactJsonValue(item));
|
||||
if (typeof value !== "object" || value === null) return value;
|
||||
const record = value as Record<string, unknown>;
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(record)) {
|
||||
result[key] = redactJsonValue(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { CommanderApprovalState, CommanderPromptState, CommanderSessionState } from "./contract";
|
||||
import { redactJsonValue, redactText } from "./redaction";
|
||||
|
||||
export interface CommanderStorageConfig {
|
||||
rootDir: string;
|
||||
}
|
||||
|
||||
export interface CommanderSessionRecord {
|
||||
sessionId: string;
|
||||
state: CommanderSessionState;
|
||||
promptState: CommanderPromptState;
|
||||
approvalState: CommanderApprovalState;
|
||||
pid: number | null;
|
||||
cwd: string | null;
|
||||
lastSeq: number;
|
||||
heartbeatAt: string | null;
|
||||
updatedAt: string;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface CommanderApprovalDraftRecord {
|
||||
id: string;
|
||||
action: string;
|
||||
taskId: string | null;
|
||||
reason: string;
|
||||
status: CommanderApprovalState;
|
||||
previewMarkdown: string;
|
||||
previewJson: Record<string, unknown>;
|
||||
redactionsApplied: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CommanderApprovalDraftInput {
|
||||
approvalId?: string;
|
||||
action: string;
|
||||
taskId?: string | null;
|
||||
reason: string;
|
||||
sessionId?: string | null;
|
||||
}
|
||||
|
||||
export interface CommanderTraceInput {
|
||||
taskId: string | null;
|
||||
sessionId: string;
|
||||
traceJsonl: string;
|
||||
taskSummary?: string | null;
|
||||
}
|
||||
|
||||
export interface CommanderTraceSummary {
|
||||
taskId: string | null;
|
||||
sessionId: string;
|
||||
lastSeq: number;
|
||||
status: "running" | "attention_required" | "blocked" | "terminal" | "unknown";
|
||||
keyEvents: string[];
|
||||
openQuestions: string[];
|
||||
recommendedNextActions: string[];
|
||||
redactionsApplied: number;
|
||||
sourceCount: number;
|
||||
taskSummaryPreview: string | null;
|
||||
traceLineCount: number;
|
||||
}
|
||||
|
||||
export interface CommanderHealth {
|
||||
ok: true;
|
||||
service: "host-codex-commander";
|
||||
status: "healthy";
|
||||
startedAt: string;
|
||||
stateRoot: string;
|
||||
stateRootExists: boolean;
|
||||
currentStateFile: string;
|
||||
currentApprovalFile: string;
|
||||
currentTraceFile: string;
|
||||
currentLogFile: string;
|
||||
}
|
||||
|
||||
interface PersistedSessionFile {
|
||||
sessionId: string;
|
||||
state: CommanderSessionState;
|
||||
promptState: CommanderPromptState;
|
||||
approvalState: CommanderApprovalState;
|
||||
pid: number | null;
|
||||
cwd: string | null;
|
||||
lastSeq: number;
|
||||
heartbeatAt: string | null;
|
||||
updatedAt: string;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
interface PersistedApprovalFile {
|
||||
id: string;
|
||||
action: string;
|
||||
taskId: string | null;
|
||||
reason: string;
|
||||
status: CommanderApprovalState;
|
||||
previewMarkdown: string;
|
||||
previewJson: Record<string, unknown>;
|
||||
redactionsApplied: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const defaultSessionId = "primary";
|
||||
|
||||
function ensureDir(path: string): void {
|
||||
mkdirSync(path, { recursive: true });
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function safeJsonParse(text: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clampText(value: string, maxChars = 1200): string {
|
||||
const compact = value.replace(/\s+/gu, " ").trim();
|
||||
return compact.length <= maxChars ? compact : `${compact.slice(0, Math.max(0, maxChars - 12))}…<truncated>`;
|
||||
}
|
||||
|
||||
function stateFilePath(rootDir: string, sessionId = defaultSessionId): string {
|
||||
return join(rootDir, "sessions", `${sessionId}.json`);
|
||||
}
|
||||
|
||||
function traceFilePath(rootDir: string, sessionId = defaultSessionId): string {
|
||||
return join(rootDir, "events", `${sessionId}.jsonl`);
|
||||
}
|
||||
|
||||
function approvalFilePath(rootDir: string, approvalId = "draft"): string {
|
||||
return join(rootDir, "approvals", `${approvalId}.json`);
|
||||
}
|
||||
|
||||
function logFilePath(rootDir: string): string {
|
||||
return join(rootDir, "logs", "commander.jsonl");
|
||||
}
|
||||
|
||||
export function commanderStatePaths(config: CommanderStorageConfig, sessionId = defaultSessionId, approvalId = "draft"): {
|
||||
stateFile: string;
|
||||
approvalFile: string;
|
||||
traceFile: string;
|
||||
logFile: string;
|
||||
} {
|
||||
return {
|
||||
stateFile: stateFilePath(config.rootDir, sessionId),
|
||||
approvalFile: approvalFilePath(config.rootDir, approvalId),
|
||||
traceFile: traceFilePath(config.rootDir, sessionId),
|
||||
logFile: logFilePath(config.rootDir),
|
||||
};
|
||||
}
|
||||
|
||||
export function readCommanderSession(config: CommanderStorageConfig, sessionId = defaultSessionId): CommanderSessionRecord {
|
||||
const path = stateFilePath(config.rootDir, sessionId);
|
||||
if (!existsSync(path)) {
|
||||
return {
|
||||
sessionId,
|
||||
state: "unknown",
|
||||
promptState: "draft",
|
||||
approvalState: "draft",
|
||||
pid: null,
|
||||
cwd: null,
|
||||
lastSeq: 0,
|
||||
heartbeatAt: null,
|
||||
updatedAt: nowIso(),
|
||||
notes: [],
|
||||
};
|
||||
}
|
||||
const parsed = safeJsonParse(readFileSync(path, "utf8"));
|
||||
if (parsed === null) throw new Error(`invalid commander session file: ${path}`);
|
||||
return normalizeSessionRecord(parsed, sessionId);
|
||||
}
|
||||
|
||||
export function writeCommanderSession(config: CommanderStorageConfig, session: CommanderSessionRecord): CommanderSessionRecord {
|
||||
const path = stateFilePath(config.rootDir, session.sessionId);
|
||||
ensureDir(dirname(path));
|
||||
const normalized = normalizeSessionRecord(session, session.sessionId);
|
||||
writeFileSync(path, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function readCommanderApproval(config: CommanderStorageConfig, approvalId = "draft"): CommanderApprovalDraftRecord {
|
||||
const path = approvalFilePath(config.rootDir, approvalId);
|
||||
if (!existsSync(path)) {
|
||||
return {
|
||||
id: approvalId,
|
||||
action: "unknown",
|
||||
taskId: null,
|
||||
reason: "",
|
||||
status: "draft",
|
||||
previewMarkdown: "",
|
||||
previewJson: { ok: true, status: "draft" },
|
||||
redactionsApplied: 0,
|
||||
createdAt: nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
}
|
||||
const parsed = safeJsonParse(readFileSync(path, "utf8"));
|
||||
if (parsed === null) throw new Error(`invalid commander approval file: ${path}`);
|
||||
return normalizeApprovalRecord(parsed, approvalId);
|
||||
}
|
||||
|
||||
export function writeCommanderApproval(config: CommanderStorageConfig, approval: CommanderApprovalDraftRecord): CommanderApprovalDraftRecord {
|
||||
const path = approvalFilePath(config.rootDir, approval.id);
|
||||
ensureDir(dirname(path));
|
||||
const normalized = normalizeApprovalRecord(approval, approval.id);
|
||||
writeFileSync(path, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function buildCommanderApprovalDraft(input: CommanderApprovalDraftInput): CommanderApprovalDraftRecord {
|
||||
const approvalId = input.approvalId ?? "draft";
|
||||
const reason = redactText(input.reason);
|
||||
const action = String(input.action || "unknown");
|
||||
const taskId = input.taskId ?? null;
|
||||
const previewMarkdown = [
|
||||
"# Commander approval draft",
|
||||
"",
|
||||
`- approvalId: ${approvalId}`,
|
||||
`- action: ${action}`,
|
||||
`- taskId: ${taskId ?? "null"}`,
|
||||
`- status: draft`,
|
||||
`- redactionsApplied: ${reason.redactionsApplied}`,
|
||||
input.sessionId ? `- sessionId: ${input.sessionId}` : "- sessionId: null",
|
||||
"",
|
||||
"## Reason",
|
||||
"",
|
||||
reason.text || "(empty)",
|
||||
"",
|
||||
"## Boundary",
|
||||
"",
|
||||
"- ClaudeQQ send is not implemented in this skeleton.",
|
||||
"- Approval is preview-only until explicit user approval is recorded.",
|
||||
].join("\n");
|
||||
return {
|
||||
id: approvalId,
|
||||
action,
|
||||
taskId,
|
||||
reason: reason.text,
|
||||
status: "draft",
|
||||
previewMarkdown,
|
||||
previewJson: redactJsonValue({
|
||||
ok: true,
|
||||
service: "host-codex-commander",
|
||||
approvalId,
|
||||
action,
|
||||
taskId,
|
||||
reason: reason.text,
|
||||
redactionsApplied: reason.redactionsApplied,
|
||||
requiresExplicitUserApproval: true,
|
||||
sendImplemented: false,
|
||||
claudeqq: {
|
||||
mutation: false,
|
||||
target: "preview-only",
|
||||
messageTemplate: `Approval required for ${action}. Reason: ${reason.text}.`,
|
||||
},
|
||||
blockedUntilApproved: [action],
|
||||
}) as Record<string, unknown>,
|
||||
redactionsApplied: reason.redactionsApplied,
|
||||
createdAt: nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
}
|
||||
|
||||
export function commanderSessionPreview(session: CommanderSessionRecord): Record<string, unknown> {
|
||||
return redactJsonValue({
|
||||
sessionId: session.sessionId,
|
||||
state: session.state,
|
||||
promptState: session.promptState,
|
||||
approvalState: session.approvalState,
|
||||
pid: session.pid,
|
||||
cwd: session.cwd,
|
||||
lastSeq: session.lastSeq,
|
||||
heartbeatAt: session.heartbeatAt,
|
||||
updatedAt: session.updatedAt,
|
||||
notes: session.notes,
|
||||
}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function commanderApprovalPreview(approval: CommanderApprovalDraftRecord): Record<string, unknown> {
|
||||
return redactJsonValue({
|
||||
id: approval.id,
|
||||
action: approval.action,
|
||||
taskId: approval.taskId,
|
||||
reason: approval.reason,
|
||||
status: approval.status,
|
||||
redactionsApplied: approval.redactionsApplied,
|
||||
createdAt: approval.createdAt,
|
||||
updatedAt: approval.updatedAt,
|
||||
}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function listCommanderSessions(config: CommanderStorageConfig): CommanderSessionRecord[] {
|
||||
const dir = join(config.rootDir, "sessions");
|
||||
if (!existsSync(dir)) return [readCommanderSession(config)];
|
||||
const sessions = readdirSync(dir)
|
||||
.filter((name) => name.endsWith(".json"))
|
||||
.map((name) => name.slice(0, -".json".length))
|
||||
.map((sessionId) => {
|
||||
try {
|
||||
return readCommanderSession(config, sessionId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((item): item is CommanderSessionRecord => item !== null);
|
||||
if (sessions.length === 0) sessions.push(readCommanderSession(config));
|
||||
return sessions.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
||||
}
|
||||
|
||||
export function appendCommanderTraceEvent(config: CommanderStorageConfig, sessionId: string, event: Record<string, unknown>): string {
|
||||
const path = traceFilePath(config.rootDir, sessionId);
|
||||
ensureDir(dirname(path));
|
||||
const redactedEvent = redactJsonValue({ ...event, at: nowIso(), sessionId });
|
||||
writeFileSync(path, `${JSON.stringify(redactedEvent)}\n`, { flag: "a" });
|
||||
return path;
|
||||
}
|
||||
|
||||
export function summarizeCommanderTrace(input: CommanderTraceInput): CommanderTraceSummary {
|
||||
const lines = input.traceJsonl.split(/\r?\n/gu).map((line) => line.trim()).filter(Boolean);
|
||||
const keyEvents: string[] = [];
|
||||
const openQuestions: string[] = [];
|
||||
const recommendedNextActions: string[] = [];
|
||||
let lastSeq = 0;
|
||||
let status: CommanderTraceSummary["status"] = "unknown";
|
||||
let redactionsApplied = 0;
|
||||
for (const line of lines) {
|
||||
const parsed = safeJsonParse(line);
|
||||
if (parsed === null) continue;
|
||||
const seq = Number(parsed.seq ?? parsed.sequence ?? parsed.eventSequence ?? 0);
|
||||
if (Number.isFinite(seq)) lastSeq = Math.max(lastSeq, Math.floor(seq));
|
||||
const rawText = String(parsed.summary ?? parsed.text ?? parsed.message ?? parsed.command ?? parsed.output ?? "");
|
||||
const redacted = redactText(rawText);
|
||||
redactionsApplied += redacted.redactionsApplied;
|
||||
const compact = clampText(redacted.text, 180);
|
||||
const kind = String(parsed.kind ?? parsed.channel ?? parsed.type ?? "event");
|
||||
const statusText = String(parsed.status ?? "").toLowerCase();
|
||||
if (statusText.includes("failed") || statusText.includes("error")) status = "blocked";
|
||||
else if (statusText.includes("completed") || statusText.includes("succeeded")) status = "terminal";
|
||||
else if (statusText.includes("blocked") || statusText.includes("attention")) status = "attention_required";
|
||||
else if (status === "unknown") status = "running";
|
||||
if (compact.length > 0 && keyEvents.length < 8) keyEvents.push(`${kind}: ${compact}`);
|
||||
}
|
||||
const taskSummary = input.taskSummary ? redactText(input.taskSummary) : null;
|
||||
if (taskSummary !== null) redactionsApplied += taskSummary.redactionsApplied;
|
||||
const summaryPreview = taskSummary === null ? null : clampText(taskSummary.text, 300);
|
||||
if (status === "unknown" && lines.length > 0) status = "running";
|
||||
if (status === "running") {
|
||||
openQuestions.push("Confirm whether current host Codex session still needs direct control.");
|
||||
recommendedNextActions.push("Review the last session events and keep the state file current.");
|
||||
} else if (status === "attention_required") {
|
||||
openQuestions.push("Identify the action that needs approval or operator intervention.");
|
||||
recommendedNextActions.push("Draft approval text before any live command path is considered.");
|
||||
} else if (status === "blocked") {
|
||||
openQuestions.push("Identify the blocking error or missing prerequisite.");
|
||||
recommendedNextActions.push("Capture the failure summary and keep the action non-live.");
|
||||
} else if (status === "terminal") {
|
||||
recommendedNextActions.push("Persist the terminal summary and mark the session read.");
|
||||
} else {
|
||||
recommendedNextActions.push("Collect more trace lines before making a state claim.");
|
||||
}
|
||||
return {
|
||||
taskId: input.taskId,
|
||||
sessionId: input.sessionId,
|
||||
lastSeq,
|
||||
status,
|
||||
keyEvents,
|
||||
openQuestions,
|
||||
recommendedNextActions,
|
||||
redactionsApplied,
|
||||
sourceCount: lines.length,
|
||||
taskSummaryPreview: summaryPreview,
|
||||
traceLineCount: lines.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function commanderHealth(config: CommanderStorageConfig, startedAt: string): CommanderHealth {
|
||||
const paths = commanderStatePaths(config);
|
||||
return {
|
||||
ok: true,
|
||||
service: "host-codex-commander",
|
||||
status: "healthy",
|
||||
startedAt,
|
||||
stateRoot: config.rootDir,
|
||||
stateRootExists: existsSync(config.rootDir),
|
||||
currentStateFile: paths.stateFile,
|
||||
currentApprovalFile: paths.approvalFile,
|
||||
currentTraceFile: paths.traceFile,
|
||||
currentLogFile: paths.logFile,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSessionRecord(record: Record<string, unknown>, sessionId: string): CommanderSessionRecord {
|
||||
const notes = Array.isArray(record.notes) ? record.notes.filter((item): item is string => typeof item === "string").map((item) => redactText(item).text) : [];
|
||||
return {
|
||||
sessionId: String(record.sessionId ?? sessionId),
|
||||
state: normalizeSessionState(String(record.state ?? "unknown")),
|
||||
promptState: normalizePromptState(String(record.promptState ?? "draft")),
|
||||
approvalState: normalizeApprovalState(String(record.approvalState ?? "draft")),
|
||||
pid: typeof record.pid === "number" && Number.isFinite(record.pid) ? Math.floor(record.pid) : null,
|
||||
cwd: typeof record.cwd === "string" && record.cwd.length > 0 ? record.cwd : null,
|
||||
lastSeq: Number.isFinite(Number(record.lastSeq ?? 0)) ? Math.max(0, Math.floor(Number(record.lastSeq ?? 0))) : 0,
|
||||
heartbeatAt: typeof record.heartbeatAt === "string" && record.heartbeatAt.length > 0 ? record.heartbeatAt : null,
|
||||
updatedAt: typeof record.updatedAt === "string" && record.updatedAt.length > 0 ? record.updatedAt : nowIso(),
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeApprovalRecord(record: Record<string, unknown>, approvalId: string): CommanderApprovalDraftRecord {
|
||||
const previewMarkdown = typeof record.previewMarkdown === "string" ? redactText(record.previewMarkdown).text : "";
|
||||
const previewJson = typeof record.previewJson === "object" && record.previewJson !== null && !Array.isArray(record.previewJson)
|
||||
? redactJsonValue(record.previewJson) as Record<string, unknown>
|
||||
: { ok: true, status: "draft" };
|
||||
return {
|
||||
id: String(record.id ?? approvalId),
|
||||
action: typeof record.action === "string" ? record.action : "unknown",
|
||||
taskId: typeof record.taskId === "string" ? record.taskId : null,
|
||||
reason: typeof record.reason === "string" ? redactText(record.reason).text : "",
|
||||
status: normalizeApprovalState(String(record.status ?? "draft")),
|
||||
previewMarkdown,
|
||||
previewJson,
|
||||
redactionsApplied: Number.isFinite(Number(record.redactionsApplied ?? 0)) ? Math.max(0, Math.floor(Number(record.redactionsApplied ?? 0))) : 0,
|
||||
createdAt: typeof record.createdAt === "string" && record.createdAt.length > 0 ? record.createdAt : nowIso(),
|
||||
updatedAt: typeof record.updatedAt === "string" && record.updatedAt.length > 0 ? record.updatedAt : nowIso(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSessionState(value: string): CommanderSessionState {
|
||||
return ["unknown", "discovered", "planned", "starting", "running", "attention_required", "stopping", "stopped", "degraded"].includes(value)
|
||||
? value as CommanderSessionState
|
||||
: "unknown";
|
||||
}
|
||||
|
||||
function normalizePromptState(value: string): CommanderPromptState {
|
||||
return ["draft", "planned", "queued_for_injection", "injected", "rejected", "failed"].includes(value)
|
||||
? value as CommanderPromptState
|
||||
: "draft";
|
||||
}
|
||||
|
||||
function normalizeApprovalState(value: string): CommanderApprovalState {
|
||||
return ["draft", "requested", "approved", "rejected", "expired", "consumed"].includes(value)
|
||||
? value as CommanderApprovalState
|
||||
: "draft";
|
||||
}
|
||||
|
||||
export function normalizeCommanderSessionState(value: string): CommanderSessionState {
|
||||
return normalizeSessionState(value);
|
||||
}
|
||||
|
||||
export function normalizeCommanderPromptState(value: string): CommanderPromptState {
|
||||
return normalizePromptState(value);
|
||||
}
|
||||
|
||||
export function normalizeCommanderApprovalState(value: string): CommanderApprovalState {
|
||||
return normalizeApprovalState(value);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"rootDir": "../../",
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["bun", "node"],
|
||||
"strict": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "dist",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "../../shared/src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user