From c5fe674f065172cc1eaa6c76632709c69083b33b Mon Sep 17 00:00:00 2001 From: unraid Date: Tue, 14 Apr 2026 20:05:32 +0800 Subject: [PATCH] feat: integrate 5 feature branches + MIME detection fix Features: - MCP tsc error fixes (43 pre-existing TypeScript errors) - Pipe mute sync, /lang command, mute state machine - Stub recovery: daemon state, job system, bg engine, assistant session - KAIROS activation + tool implementation - Openclaw autonomy: permission system, run records, managed flows - Daemon/job command hierarchy (subcommand architecture) - Cross-platform background engine (detached/tmux) MIME detection fix (packages/@ant/computer-use-mcp/src/toolCalls.ts): - detectMimeFromBase64(): decode raw bytes from base64 instead of broken charCodeAt comparison - Fixes API 400 on Windows (JPEG) and macOS (PNG) screenshots - Verified by Codex (GPT-5.4) via Buffer computation Other: - Remote-control-server logger abstraction - InProcessTeammateTask type extensions - GrowthBook gate enhancements - 30+ new/refactored test files with mock isolation Verified: tsc 0 errors, bun test 2758 pass / 0 fail --- .gitignore | 2 +- DEV-LOG.md | 79 ++ build.ts | 2 + .../@ant/computer-use-mcp/src/toolCalls.ts | 23 +- .../PushNotificationTool.ts | 62 +- .../SendUserFileTool/SendUserFileTool.ts | 49 +- packages/remote-control-server/src/logger.ts | 10 + .../src/routes/v1/session-ingress.ts | 11 +- .../src/routes/v1/sessions.ts | 3 +- .../src/routes/web/control.ts | 5 +- .../src/routes/web/sessions.ts | 3 +- .../src/services/disconnect-monitor.ts | 5 +- .../src/services/work-dispatch.ts | 3 +- .../src/transport/event-bus.ts | 6 +- .../src/transport/sse-writer.ts | 3 +- .../src/transport/ws-handler.ts | 21 +- scripts/dev.ts | 2 + src/__tests__/context.baseline.test.ts | 91 ++ src/assistant/AssistantSessionChooser.ts | 3 - src/assistant/AssistantSessionChooser.tsx | 54 + src/assistant/gate.ts | 17 +- src/assistant/index.ts | 73 +- src/assistant/sessionDiscovery.ts | 54 +- src/cli/bg.ts | 321 ++++- src/cli/bg/__tests__/detached.test.ts | 15 + src/cli/bg/__tests__/engine.test.ts | 37 + src/cli/bg/__tests__/tail.test.ts | 8 + src/cli/bg/engine.ts | 47 + src/cli/bg/engines/detached.ts | 51 + src/cli/bg/engines/index.ts | 17 + src/cli/bg/engines/tmux.ts | 73 ++ src/cli/bg/tail.ts | 70 ++ src/cli/handlers/ant.ts | 229 +++- src/cli/handlers/templateJobs.ts | 161 ++- src/cli/print.ts | 456 ++++--- src/cli/rollback.ts | 72 +- src/cli/up.ts | 97 +- src/commands.ts | 13 + src/commands/__tests__/autonomy.test.ts | 246 ++++ .../__tests__/proactive.baseline.test.ts | 48 + src/commands/assistant/assistant.ts | 53 - src/commands/assistant/assistant.tsx | 175 +++ src/commands/assistant/gate.ts | 18 +- src/commands/autonomy.ts | 125 ++ src/commands/daemon/__tests__/daemon.test.ts | 24 + src/commands/daemon/daemon.tsx | 57 + src/commands/daemon/index.ts | 17 + src/commands/init.ts | 5 +- src/commands/job/__tests__/job.test.ts | 25 + src/commands/job/index.ts | 16 + src/commands/job/job.tsx | 34 + src/commands/lang/index.ts | 12 + src/commands/lang/lang.ts | 49 + src/commands/send/send.ts | 13 + src/commands/torch.ts | 20 +- src/daemon/__tests__/daemonMain.test.ts | 61 + src/daemon/__tests__/state.test.ts | 185 +++ src/daemon/main.ts | 143 ++- src/daemon/state.ts | 157 +++ src/entrypoints/cli.tsx | 77 +- src/hooks/useAwaySummary.ts | 1 - src/hooks/useMasterMonitor.ts | 76 +- src/hooks/usePipeIpc.ts | 9 + src/hooks/usePipeMuteSync.ts | 141 +++ src/hooks/usePipePermissionForward.ts | 1 + src/hooks/usePipeRelay.ts | 5 +- src/hooks/useScheduledTasks.ts | 107 +- src/jobs/__tests__/classifier.test.ts | 140 +++ src/jobs/__tests__/state.test.ts | 91 ++ src/jobs/__tests__/templates.test.ts | 87 ++ src/jobs/classifier.ts | 70 +- src/jobs/state.ts | 102 ++ src/jobs/templates.ts | 86 ++ src/main.tsx | 6 +- .../__tests__/state.baseline.test.ts | 80 ++ src/proactive/useProactive.ts | 27 +- src/screens/REPL.tsx | 191 +-- src/services/analytics/growthbook.ts | 24 +- .../__tests__/queryModelOpenAI.isolated.ts | 487 +++++++ .../openai/__tests__/queryModelOpenAI.test.ts | 486 +------ .../openai/__tests__/streamAdapter.test.ts | 22 +- src/services/awaySummary.ts | 10 +- .../langfuse/__tests__/langfuse.isolated.ts | 702 +++++++++++ .../langfuse/__tests__/langfuse.test.ts | 709 +---------- .../InProcessTeammateTask.tsx | 112 +- src/tasks/InProcessTeammateTask/types.ts | 10 +- src/types/textInputTypes.ts | 13 + src/utils/__tests__/autonomyAuthority.test.ts | 241 ++++ src/utils/__tests__/autonomyFlows.test.ts | 1116 +++++++++++++++++ .../__tests__/autonomyPersistence.test.ts | 117 ++ src/utils/__tests__/autonomyRuns.test.ts | 421 +++++++ .../__tests__/cronScheduler.baseline.test.ts | 79 ++ .../__tests__/cronTasks.baseline.test.ts | 203 +++ src/utils/__tests__/language.test.ts | 82 ++ src/utils/__tests__/pipeMuteState.test.ts | 124 ++ src/utils/__tests__/taskSummary.test.ts | 93 ++ src/utils/autonomyAuthority.ts | 522 ++++++++ src/utils/autonomyFlows.ts | 1057 ++++++++++++++++ src/utils/autonomyPersistence.ts | 48 + src/utils/autonomyRuns.ts | 797 ++++++++++++ src/utils/config.ts | 4 +- src/utils/handlePromptSubmit.ts | 267 ++-- src/utils/language.ts | 26 + src/utils/pipeMuteState.ts | 78 ++ src/utils/pipePermissionRelay.ts | 16 + src/utils/pipeTransport.ts | 6 +- src/utils/swarm/inProcessRunner.ts | 29 +- src/utils/swarm/spawnInProcess.ts | 13 + src/utils/taskSummary.ts | 81 +- tests/integration/cli-arguments.test.ts | 153 +-- tests/mocks/file-system.ts | 28 +- tsconfig.json | 10 +- 112 files changed, 11313 insertions(+), 1901 deletions(-) create mode 100644 packages/remote-control-server/src/logger.ts create mode 100644 src/__tests__/context.baseline.test.ts delete mode 100644 src/assistant/AssistantSessionChooser.ts create mode 100644 src/assistant/AssistantSessionChooser.tsx create mode 100644 src/cli/bg/__tests__/detached.test.ts create mode 100644 src/cli/bg/__tests__/engine.test.ts create mode 100644 src/cli/bg/__tests__/tail.test.ts create mode 100644 src/cli/bg/engine.ts create mode 100644 src/cli/bg/engines/detached.ts create mode 100644 src/cli/bg/engines/index.ts create mode 100644 src/cli/bg/engines/tmux.ts create mode 100644 src/cli/bg/tail.ts create mode 100644 src/commands/__tests__/autonomy.test.ts create mode 100644 src/commands/__tests__/proactive.baseline.test.ts delete mode 100644 src/commands/assistant/assistant.ts create mode 100644 src/commands/assistant/assistant.tsx create mode 100644 src/commands/autonomy.ts create mode 100644 src/commands/daemon/__tests__/daemon.test.ts create mode 100644 src/commands/daemon/daemon.tsx create mode 100644 src/commands/daemon/index.ts create mode 100644 src/commands/job/__tests__/job.test.ts create mode 100644 src/commands/job/index.ts create mode 100644 src/commands/job/job.tsx create mode 100644 src/commands/lang/index.ts create mode 100644 src/commands/lang/lang.ts create mode 100644 src/daemon/__tests__/daemonMain.test.ts create mode 100644 src/daemon/__tests__/state.test.ts create mode 100644 src/daemon/state.ts create mode 100644 src/hooks/usePipeMuteSync.ts create mode 100644 src/jobs/__tests__/classifier.test.ts create mode 100644 src/jobs/__tests__/state.test.ts create mode 100644 src/jobs/__tests__/templates.test.ts create mode 100644 src/jobs/state.ts create mode 100644 src/jobs/templates.ts create mode 100644 src/proactive/__tests__/state.baseline.test.ts create mode 100644 src/services/api/openai/__tests__/queryModelOpenAI.isolated.ts create mode 100644 src/services/langfuse/__tests__/langfuse.isolated.ts create mode 100644 src/utils/__tests__/autonomyAuthority.test.ts create mode 100644 src/utils/__tests__/autonomyFlows.test.ts create mode 100644 src/utils/__tests__/autonomyPersistence.test.ts create mode 100644 src/utils/__tests__/autonomyRuns.test.ts create mode 100644 src/utils/__tests__/cronScheduler.baseline.test.ts create mode 100644 src/utils/__tests__/cronTasks.baseline.test.ts create mode 100644 src/utils/__tests__/language.test.ts create mode 100644 src/utils/__tests__/pipeMuteState.test.ts create mode 100644 src/utils/__tests__/taskSummary.test.ts create mode 100644 src/utils/autonomyAuthority.ts create mode 100644 src/utils/autonomyFlows.ts create mode 100644 src/utils/autonomyPersistence.ts create mode 100644 src/utils/autonomyRuns.ts create mode 100644 src/utils/language.ts create mode 100644 src/utils/pipeMuteState.ts diff --git a/.gitignore b/.gitignore index f03bc66b5..2a4224105 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,7 @@ src/utils/vendor/ .claude/ .codex/ .omx/ - +.docs/task/ # Binary / screenshot files (root only) /*.png *.bmp diff --git a/DEV-LOG.md b/DEV-LOG.md index 03d7571a5..a4d274721 100644 --- a/DEV-LOG.md +++ b/DEV-LOG.md @@ -1,5 +1,84 @@ # DEV-LOG +## Integrate 5 Feature Branches + MIME Detection Fix (2026-04-14) + +**分支**: `feat/integrate-features` +**基于**: PR #259 (`7f2b7182`, CI 全过) + MIME 修复 +**文件**: 124 changed, +13555 / -1901 + +### 1. MCP tsc 错误修复 (`fix/mcp-tsc-errors`) + +上游 MCP 重构后产生的 TypeScript 编译错误,修复 43 个预存在的类型错误。 + +### 2. Pipe IPC 断开 + /lang 命令 + Mute 状态机 (`feat/pipe-mute-disconnect`) + +- `src/hooks/usePipeMuteSync.ts` — Pipe mute 状态同步 hook +- `src/utils/pipeMuteState.ts` — Mute 状态机实现 +- `src/commands/lang/` — `/lang` 命令,运行时切换语言 +- `src/utils/language.ts` — 语言检测与切换工具 + +### 3. Stub 恢复 (task 001-012) (`feat/stub-recovery-all`) + +恢复全部 stub 为完整实现: + +- **Daemon 状态管理**: `src/daemon/state.ts` — 持久化 daemon 状态 (PID、端口、worker 列表) +- **Job 系统**: `src/jobs/state.ts`, `src/jobs/templates.ts`, `src/jobs/classifier.ts` — 任务状态、模板、分类器 +- **后台会话**: `src/cli/bg/engine.ts`, `engines/detached.ts`, `engines/tmux.ts` — 跨平台后台引擎抽象 +- **Assistant 会话**: `src/assistant/AssistantSessionChooser.tsx` — 会话选择器从 .ts 迁移至 .tsx +- **Proactive/Schedule**: `src/hooks/useScheduledTasks.ts`, `src/proactive/useProactive.ts` — 定时任务与主动提示 + +### 4. KAIROS 激活 (`feat/kairos-activation`) + +- 解除 KAIROS 功能的编译阻塞 +- `build.ts` + `scripts/dev.ts` — 添加 `KAIROS_BRIEF` 到默认 feature flag 列表 +- `src/hooks/useMasterMonitor.ts` — Master monitor hook 实现 +- `src/commands/torch.ts` — Torch 命令增强 + +### 5. Openclaw 自治系统 (`codex/openclaw-autonomy-pr`) + +- `src/utils/autonomyAuthority.ts` — 自治权限管理 (522 行) +- `src/utils/autonomyFlows.ts` — 自治工作流 managed flows (1057 行) +- `src/utils/autonomyRuns.ts` — 运行记录持久化 (797 行) +- `src/commands/autonomy.ts` — `/autonomy` 命令入口 +- `src/utils/autonomyPersistence.ts` — 持久化工具 + +### 6. Daemon/Job 命令层级化 + +- `src/commands/daemon/` — `daemon.tsx` + `index.ts` (subcommand 架构) +- `src/commands/job/` — `job.tsx` + `index.ts` +- `src/entrypoints/cli.tsx` — 快速路径注册 daemon/job subcommands +- `src/cli/handlers/ant.ts`, `src/cli/handlers/templateJobs.ts` — handler 增强 + +### 7. 其他改动 + +- **Remote Control Server**: `packages/remote-control-server/src/logger.ts` — logger 抽象,测试 stderr 静默化 +- **InProcessTeammateTask**: `src/tasks/InProcessTeammateTask/` — teammate 任务类型扩展 +- **GrowthBook**: `src/services/analytics/growthbook.ts` — gate 增强 +- **Away Summary**: `src/services/awaySummary.ts` — 修复调试问题 +- **测试**: 新增/重构 30+ 测试文件,mock 隔离 (langfuse, openai) + +### 8. Screenshot MIME 类型检测修复 + +**文件**: `packages/@ant/computer-use-mcp/src/toolCalls.ts` + +**问题**: `detectMimeFromBase64()` 用 `charCodeAt(0)` 比较原始字节 magic number 与 base64 编码后的字符。Base64 编码会改变字节值,所有条件永远不命中,函数始终返回 `"image/png"`。Windows Python bridge 输出 JPEG 截图,导致 API 400 错误。 + +**修复**: 解码 base64 前 16 个字符得到 12 个原始字节,直接比对标准 magic byte 签名: +- PNG: `89 50 4E 47` (4 字节) +- JPEG: `FF D8 FF` (3 字节,覆盖所有 JFIF/EXIF/DQT 变体) +- WebP: `RIFF` (字节 0-3) + `WEBP` (字节 8-11) 双重验证 +- GIF: `47 49 46` (覆盖 GIF87a 和 GIF89a) + +**验证**: Codex (GPT-5.4) 通过 `Buffer.from().toString('base64')` 实际计算确认所有前缀正确。 + +### 验证结果 + +- `bunx tsc --noEmit`: 0 errors +- `bun test`: 2758 pass, 0 fail +- 手动测试: Windows Computer Use screenshot 不再报 API 400 + +--- + ## /poor 省流模式 (2026-04-11) 新增 `/poor` 命令,toggle 关闭 `extract_memories` 和 `prompt_suggestion`,省 token。 diff --git a/build.ts b/build.ts index 857aefe8e..026f33524 100644 --- a/build.ts +++ b/build.ts @@ -40,6 +40,8 @@ const DEFAULT_BUILD_FEATURES = [ 'KAIROS', 'COORDINATOR_MODE', 'LAN_PIPES', + 'BG_SESSIONS', + 'TEMPLATES', // 'REVIEW_ARTIFACT', // API 请求无响应,需进一步排查 schema 兼容性 // P3: poor mode (disable extract_memories + prompt_suggestion) 'POOR', diff --git a/packages/@ant/computer-use-mcp/src/toolCalls.ts b/packages/@ant/computer-use-mcp/src/toolCalls.ts index d7b796a94..415ee6ecc 100644 --- a/packages/@ant/computer-use-mcp/src/toolCalls.ts +++ b/packages/@ant/computer-use-mcp/src/toolCalls.ts @@ -37,16 +37,21 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { randomUUID } from "node:crypto"; -/** Detect actual image MIME type from base64 data using magic bytes. */ +/** Detect actual image MIME type from base64 data by decoding the magic bytes. */ function detectMimeFromBase64(b64: string): string { - // First byte is enough to distinguish PNG (0x89) from JPEG (0xFF) - const c = b64.charCodeAt(0); - if (c === 0x89) return "image/png"; - if (c === 0xFF) return "image/jpeg"; - // RIFF = WebP - if (c === 0x52) return "image/webp"; - // GIF - if (c === 0x47) return "image/gif"; + // Decode first 12 raw bytes (16 base64 chars is enough) and check standard magic bytes. + // PNG: 89 50 4E 47 + // JPEG: FF D8 FF + // RIFF+WEBP: "RIFF" at 0..3 + "WEBP" at 8..11 + // GIF: "GIF" at 0..2 + const raw = Buffer.from(b64.slice(0, 16), "base64"); + if (raw[0] === 0x89 && raw[1] === 0x50 && raw[2] === 0x4e && raw[3] === 0x47) return "image/png"; + if (raw[0] === 0xff && raw[1] === 0xd8 && raw[2] === 0xff) return "image/jpeg"; + if ( + raw[0] === 0x52 && raw[1] === 0x49 && raw[2] === 0x46 && raw[3] === 0x46 && // RIFF + raw[8] === 0x57 && raw[9] === 0x45 && raw[10] === 0x42 && raw[11] === 0x50 // WEBP + ) return "image/webp"; + if (raw[0] === 0x47 && raw[1] === 0x49 && raw[2] === 0x46) return "image/gif"; return "image/png"; } diff --git a/packages/builtin-tools/src/tools/PushNotificationTool/PushNotificationTool.ts b/packages/builtin-tools/src/tools/PushNotificationTool/PushNotificationTool.ts index be2d0702c..5936c6d03 100644 --- a/packages/builtin-tools/src/tools/PushNotificationTool/PushNotificationTool.ts +++ b/packages/builtin-tools/src/tools/PushNotificationTool/PushNotificationTool.ts @@ -1,7 +1,9 @@ +import { feature } from 'bun:bundle' import { z } from 'zod/v4' import type { ToolResultBlockParam } from 'src/Tool.js' import { buildTool } from 'src/Tool.js' import { lazySchema } from 'src/utils/lazySchema.js' +import { logForDebugging } from 'src/utils/debug.js' const PUSH_NOTIFICATION_TOOL_NAME = 'PushNotification' @@ -74,14 +76,58 @@ Requires Remote Control to be configured. Respects user notification settings (t } }, - async call(_input: PushInput) { - // Push delivery is handled by the Remote Control / KAIROS transport layer. - // Without the KAIROS runtime, this tool is not available. - return { - data: { - sent: false, - error: 'PushNotification requires the KAIROS transport layer.', - }, + async call(input: PushInput, context) { + const appState = context.getAppState() + + // Try bridge delivery first (for remote/mobile viewers) + if (appState.replBridgeEnabled) { + if (feature('BRIDGE_MODE')) { + try { + const { getBridgeAccessToken, getBridgeBaseUrl } = await import( + 'src/bridge/bridgeConfig.js' + ) + const { getSessionId } = await import('src/bootstrap/state.js') + const token = getBridgeAccessToken() + const sessionId = getSessionId() + if (token && sessionId) { + const baseUrl = getBridgeBaseUrl() + const axios = (await import('axios')).default + const response = await axios.post( + `${baseUrl}/v1/sessions/${sessionId}/events`, + { + events: [ + { + type: 'push_notification', + title: input.title, + body: input.body, + priority: input.priority ?? 'normal', + }, + ], + }, + { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + }, + timeout: 10_000, + validateStatus: (s: number) => s < 500, + }, + ) + if (response.status >= 200 && response.status < 300) { + logForDebugging(`[PushNotification] delivered via bridge session=${sessionId}`) + return { data: { sent: true } } + } + logForDebugging(`[PushNotification] bridge delivery failed: status=${response.status}`) + } + } catch (e) { + logForDebugging(`[PushNotification] bridge delivery error: ${e}`) + } + } } + + // Fallback: no bridge available, push was not delivered to a remote device. + logForDebugging(`[PushNotification] no bridge available, not delivered: ${input.title}`) + return { data: { sent: false, error: 'No Remote Control bridge configured. Notification not delivered.' } } }, }) diff --git a/packages/builtin-tools/src/tools/SendUserFileTool/SendUserFileTool.ts b/packages/builtin-tools/src/tools/SendUserFileTool/SendUserFileTool.ts index 9f8b98f7f..82172be8d 100644 --- a/packages/builtin-tools/src/tools/SendUserFileTool/SendUserFileTool.ts +++ b/packages/builtin-tools/src/tools/SendUserFileTool/SendUserFileTool.ts @@ -70,14 +70,51 @@ Guidelines: } }, - async call(_input: SendUserFileInput) { - // File transfer is handled by the KAIROS assistant transport layer. - // Without the KAIROS runtime, this tool is not available. + async call(input: SendUserFileInput, context) { + const { file_path } = input + const { stat } = await import('fs/promises') + + // Verify file exists and is readable + let fileSize: number + try { + const fileStat = await stat(file_path) + if (!fileStat.isFile()) { + return { + data: { sent: false, file_path, error: 'Path is not a file.' }, + } + } + fileSize = fileStat.size + } catch { + return { + data: { sent: false, file_path, error: 'File does not exist or is not readable.' }, + } + } + + // Attempt bridge upload if available (so web viewers can download) + const appState = context.getAppState() + let fileUuid: string | undefined + if (appState.replBridgeEnabled) { + try { + const { uploadBriefAttachment } = await import( + '@claude-code-best/builtin-tools/tools/BriefTool/upload.js' + ) + fileUuid = await uploadBriefAttachment(file_path, fileSize, { + replBridgeEnabled: true, + signal: context.abortController.signal, + }) + } catch { + // Best-effort upload — local path is always available + } + } + + const delivered = !appState.replBridgeEnabled || Boolean(fileUuid) return { data: { - sent: false, - file_path: _input.file_path, - error: 'SendUserFile requires the KAIROS assistant transport layer.', + sent: delivered, + file_path, + size: fileSize, + ...(fileUuid ? { file_uuid: fileUuid } : {}), + ...(!delivered ? { error: 'Bridge upload failed. File available at local path.' } : {}), }, } }, diff --git a/packages/remote-control-server/src/logger.ts b/packages/remote-control-server/src/logger.ts new file mode 100644 index 000000000..4b19fc155 --- /dev/null +++ b/packages/remote-control-server/src/logger.ts @@ -0,0 +1,10 @@ +/** Thin logging wrapper — silent in test environment, uses console in production. */ +const isTest = process.env.NODE_ENV === "test" || (typeof Bun !== "undefined" && !!Bun.env.BUN_TEST); + +export function log(...args: unknown[]): void { + if (!isTest) console.log(...args); +} + +export function error(...args: unknown[]): void { + if (!isTest) console.error(...args); +} diff --git a/packages/remote-control-server/src/routes/v1/session-ingress.ts b/packages/remote-control-server/src/routes/v1/session-ingress.ts index 03c4cc8d2..1d27bc0d0 100644 --- a/packages/remote-control-server/src/routes/v1/session-ingress.ts +++ b/packages/remote-control-server/src/routes/v1/session-ingress.ts @@ -1,3 +1,4 @@ +import { log, error as logError } from "../../logger"; import { Hono } from "hono"; import { createBunWebSocket } from "hono/bun"; import { validateApiKey } from "../../auth/api-key"; @@ -30,14 +31,14 @@ function authenticateRequest(c: any, label: string, expectedSessionId?: string): const payload = verifyWorkerJwt(token); if (payload) { if (expectedSessionId && payload.session_id !== expectedSessionId) { - console.log(`[Auth] ${label}: FAILED — JWT session_id mismatch`); + log(`[Auth] ${label}: FAILED — JWT session_id mismatch`); return false; } return true; } } - console.log(`[Auth] ${label}: FAILED — no valid API key or JWT`); + log(`[Auth] ${label}: FAILED — no valid API key or JWT`); return false; } @@ -83,7 +84,7 @@ app.get( const session = getSession(sessionId); if (!session) { - console.log(`[WS] Upgrade rejected: session ${sessionId} not found`); + log(`[WS] Upgrade rejected: session ${sessionId} not found`); return { onOpen(_evt, ws) { ws.close(4001, "session not found"); @@ -91,7 +92,7 @@ app.get( }; } - console.log(`[WS] Upgrade accepted: session=${sessionId}`); + log(`[WS] Upgrade accepted: session=${sessionId}`); return { onOpen(_evt, ws) { handleWebSocketOpen(ws as any, sessionId); @@ -108,7 +109,7 @@ app.get( handleWebSocketClose(ws as any, sessionId, closeEvt?.code, closeEvt?.reason); }, onError(evt, ws) { - console.error(`[WS] Error on session=${sessionId}:`, evt); + logError(`[WS] Error on session=${sessionId}:`, evt); handleWebSocketClose(ws as any, sessionId, 1006, "websocket error"); }, }; diff --git a/packages/remote-control-server/src/routes/v1/sessions.ts b/packages/remote-control-server/src/routes/v1/sessions.ts index 3dc950953..2089545f4 100644 --- a/packages/remote-control-server/src/routes/v1/sessions.ts +++ b/packages/remote-control-server/src/routes/v1/sessions.ts @@ -1,3 +1,4 @@ +import { log, error as logError } from "../../logger"; import { Hono } from "hono"; import { createSession, @@ -22,7 +23,7 @@ app.post("/", acceptCliHeaders, apiKeyAuth, async (c) => { try { await createWorkItem(body.environment_id, session.id); } catch (err) { - console.error(`[RCS] Failed to create work item: ${(err as Error).message}`); + logError(`[RCS] Failed to create work item: ${(err as Error).message}`); } } diff --git a/packages/remote-control-server/src/routes/web/control.ts b/packages/remote-control-server/src/routes/web/control.ts index e146bdb5f..4dc55c9f5 100644 --- a/packages/remote-control-server/src/routes/web/control.ts +++ b/packages/remote-control-server/src/routes/web/control.ts @@ -1,3 +1,4 @@ +import { log, error as logError } from "../../logger"; import { Hono } from "hono"; import { uuidAuth } from "../../auth/middleware"; import { getSession, updateSessionStatus } from "../../services/session"; @@ -29,9 +30,9 @@ app.post("/sessions/:id/events", uuidAuth, async (c) => { const body = await c.req.json(); const eventType = body.type || "user"; - console.log(`[RC-DEBUG] web -> server: POST /web/sessions/${sessionId}/events type=${eventType} content=${JSON.stringify(body).slice(0, 200)}`); + log(`[RC-DEBUG] web -> server: POST /web/sessions/${sessionId}/events type=${eventType} content=${JSON.stringify(body).slice(0, 200)}`); const event = publishSessionEvent(sessionId, eventType, body, "outbound"); - console.log(`[RC-DEBUG] web -> server: published outbound event id=${event.id} type=${event.type} direction=${event.direction} subscribers=${getEventBus(sessionId).subscriberCount()}`); + log(`[RC-DEBUG] web -> server: published outbound event id=${event.id} type=${event.type} direction=${event.direction} subscribers=${getEventBus(sessionId).subscriberCount()}`); return c.json({ status: "ok", event }, 200); }); diff --git a/packages/remote-control-server/src/routes/web/sessions.ts b/packages/remote-control-server/src/routes/web/sessions.ts index 94165a84d..7a6836d95 100644 --- a/packages/remote-control-server/src/routes/web/sessions.ts +++ b/packages/remote-control-server/src/routes/web/sessions.ts @@ -1,3 +1,4 @@ +import { log, error as logError } from "../../logger"; import { Hono } from "hono"; import { uuidAuth } from "../../auth/middleware"; import { getSession, createSession } from "../../services/session"; @@ -28,7 +29,7 @@ app.post("/sessions", uuidAuth, async (c) => { try { await createWorkItem(body.environment_id, session.id); } catch (err) { - console.error(`[RCS] Failed to create work item: ${(err as Error).message}`); + logError(`[RCS] Failed to create work item: ${(err as Error).message}`); } } diff --git a/packages/remote-control-server/src/services/disconnect-monitor.ts b/packages/remote-control-server/src/services/disconnect-monitor.ts index 129f67148..f44b15b4f 100644 --- a/packages/remote-control-server/src/services/disconnect-monitor.ts +++ b/packages/remote-control-server/src/services/disconnect-monitor.ts @@ -1,3 +1,4 @@ +import { log, error as logError } from "../logger"; import { storeListActiveEnvironments, storeUpdateEnvironment } from "../store"; import { storeListSessions, storeUpdateSession } from "../store"; import { config } from "../config"; @@ -12,7 +13,7 @@ export function startDisconnectMonitor() { const envs = storeListActiveEnvironments(); for (const env of envs) { if (env.lastPollAt && now - env.lastPollAt.getTime() > timeoutMs) { - console.log(`[RCS] Environment ${env.id} timed out (no poll for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`); + log(`[RCS] Environment ${env.id} timed out (no poll for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`); storeUpdateEnvironment(env.id, { status: "disconnected" }); } } @@ -23,7 +24,7 @@ export function startDisconnectMonitor() { if (session.status === "running" || session.status === "idle") { const elapsed = now - session.updatedAt.getTime(); if (elapsed > timeoutMs * 2) { - console.log(`[RCS] Session ${session.id} marked inactive (no update for ${Math.round(elapsed / 1000)}s)`); + log(`[RCS] Session ${session.id} marked inactive (no update for ${Math.round(elapsed / 1000)}s)`); storeUpdateSession(session.id, { status: "inactive" }); } } diff --git a/packages/remote-control-server/src/services/work-dispatch.ts b/packages/remote-control-server/src/services/work-dispatch.ts index 776703f03..f35819376 100644 --- a/packages/remote-control-server/src/services/work-dispatch.ts +++ b/packages/remote-control-server/src/services/work-dispatch.ts @@ -1,3 +1,4 @@ +import { log, error as logError } from "../logger"; import { storeCreateWorkItem, storeGetWorkItem, @@ -35,7 +36,7 @@ export async function createWorkItem(environmentId: string, sessionId: string): const secret = encodeWorkSecret(); const record = storeCreateWorkItem({ environmentId, sessionId, secret }); - console.log(`[RCS] Work item created: ${record.id} for env=${environmentId} session=${sessionId}`); + log(`[RCS] Work item created: ${record.id} for env=${environmentId} session=${sessionId}`); return record.id; } diff --git a/packages/remote-control-server/src/transport/event-bus.ts b/packages/remote-control-server/src/transport/event-bus.ts index 66782c5ca..52b37b432 100644 --- a/packages/remote-control-server/src/transport/event-bus.ts +++ b/packages/remote-control-server/src/transport/event-bus.ts @@ -1,3 +1,5 @@ +import { log, error as logError } from "../logger"; + export interface SessionEvent { id: string; sessionId: string; @@ -33,12 +35,12 @@ export class EventBus { createdAt: Date.now(), }; this.events.push(full); - console.log(`[RC-DEBUG] bus publish: sessionId=${event.sessionId} type=${event.type} dir=${event.direction} seq=${full.seqNum} subscribers=${this.subscribers.size}`); + log(`[RC-DEBUG] bus publish: sessionId=${event.sessionId} type=${event.type} dir=${event.direction} seq=${full.seqNum} subscribers=${this.subscribers.size}`); for (const cb of this.subscribers) { try { cb(full); } catch (err) { - console.error(`[RC-DEBUG] bus subscriber error:`, err); + logError(`[RC-DEBUG] bus subscriber error:`, err); } } return full; diff --git a/packages/remote-control-server/src/transport/sse-writer.ts b/packages/remote-control-server/src/transport/sse-writer.ts index 42c7f2a44..406504390 100644 --- a/packages/remote-control-server/src/transport/sse-writer.ts +++ b/packages/remote-control-server/src/transport/sse-writer.ts @@ -1,3 +1,4 @@ +import { log, error as logError } from "../logger"; import type { Context } from "hono"; import type { SessionEvent } from "./event-bus"; import { getEventBus } from "./event-bus"; @@ -76,7 +77,7 @@ export function createSSEStream(c: Context, sessionId: string, fromSeqNum = 0) { seqNum: event.seqNum, }); try { - console.log(`[RC-DEBUG] SSE -> web: sessionId=${sessionId} type=${event.type} dir=${event.direction} seq=${event.seqNum}`); + log(`[RC-DEBUG] SSE -> web: sessionId=${sessionId} type=${event.type} dir=${event.direction} seq=${event.seqNum}`); controller.enqueue(encoder.encode(`id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`)); } catch { unsub(); diff --git a/packages/remote-control-server/src/transport/ws-handler.ts b/packages/remote-control-server/src/transport/ws-handler.ts index 0074a7861..bf9c781f0 100644 --- a/packages/remote-control-server/src/transport/ws-handler.ts +++ b/packages/remote-control-server/src/transport/ws-handler.ts @@ -2,6 +2,7 @@ import type { WSContext } from "hono/ws"; import { getEventBus } from "./event-bus"; import type { SessionEvent } from "./event-bus"; import { publishSessionEvent } from "../services/transport"; +import { log, error as logError } from "../logger"; // Per-connection cleanup, keyed by sessionId (only one WS per session) interface CleanupEntry { @@ -96,13 +97,13 @@ function toSDKMessage(event: SessionEvent): string { /** Called from onOpen — subscribes to event bus, forwards outbound events to bridge WS */ export function handleWebSocketOpen(ws: WSContext, sessionId: string) { const openTime = Date.now(); - console.log(`[RC-DEBUG] [WS] Open session=${sessionId}`); + log(`[RC-DEBUG] [WS] Open session=${sessionId}`); activeConnections.add(ws); // If there's an existing connection for this session, clean it up first const existing = cleanupBySession.get(sessionId); if (existing) { - console.log(`[WS] Replacing existing connection for session=${sessionId}`); + log(`[WS] Replacing existing connection for session=${sessionId}`); existing.unsub(); clearInterval(existing.keepalive); activeConnections.delete(existing.ws); @@ -114,7 +115,7 @@ export function handleWebSocketOpen(ws: WSContext, sessionId: string) { // the full conversation history — assistant replies are inbound events. const missed = bus.getEventsSince(0); if (missed.length > 0) { - console.log(`[WS] Replaying ${missed.length} missed event(s)`); + log(`[WS] Replaying ${missed.length} missed event(s)`); for (const event of missed) { if (ws.readyState !== 1) break; try { @@ -130,10 +131,10 @@ export function handleWebSocketOpen(ws: WSContext, sessionId: string) { if (event.direction !== "outbound") return; try { const sdkMsg = toSDKMessage(event); - console.log(`[RC-DEBUG] [WS] -> bridge (outbound): type=${event.type} len=${sdkMsg.length} msg=${sdkMsg.slice(0, 300)}`); + log(`[RC-DEBUG] [WS] -> bridge (outbound): type=${event.type} len=${sdkMsg.length} msg=${sdkMsg.slice(0, 300)}`); ws.send(sdkMsg); } catch (err) { - console.error("[RC-DEBUG] [WS] send error:", err); + logError("[RC-DEBUG] [WS] send error:", err); } }); @@ -161,7 +162,7 @@ export function handleWebSocketMessage(ws: WSContext, sessionId: string, data: s try { ingestBridgeMessage(sessionId, JSON.parse(line)); } catch (err) { - console.error("[WS] parse error:", err); + logError("[WS] parse error:", err); } } } @@ -173,7 +174,7 @@ export function handleWebSocketClose(ws: WSContext, sessionId: string, code?: nu const entry = cleanupBySession.get(sessionId); const duration = entry ? Math.round((Date.now() - entry.openTime) / 1000) : -1; - console.log(`[WS] Close session=${sessionId} code=${code ?? "none"} reason=${reason || "(none)"} duration=${duration}s`); + log(`[WS] Close session=${sessionId} code=${code ?? "none"} reason=${reason || "(none)"} duration=${duration}s`); if (entry) { entry.unsub(); @@ -215,7 +216,7 @@ export function ingestBridgeMessage(sessionId: string, msg: Record { + tempDir = await createTempDir('context-baseline-') + projectClaudeMdContent = `baseline-${Date.now()}` + + resetStateForTests() + setOriginalCwd(tempDir) + setProjectRoot(tempDir) + await writeTempFile(tempDir, 'CLAUDE.md', projectClaudeMdContent) + + clearMemoryFileCaches() + getUserContext.cache.clear?.() + getSystemContext.cache.clear?.() + setSystemPromptInjection(null) + delete process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS +}) + +afterEach(async () => { + clearMemoryFileCaches() + getUserContext.cache.clear?.() + getSystemContext.cache.clear?.() + setSystemPromptInjection(null) + delete process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS + resetStateForTests() + if (tempDir) { + await cleanupTempDir(tempDir) + } +}) + +describe('context baseline', () => { + test('getUserContext includes currentDate and project CLAUDE.md content', async () => { + const ctx = await getUserContext() + + expect(ctx.currentDate).toContain("Today's date is") + expect(ctx.claudeMd).toContain(projectClaudeMdContent) + }) + + test('CLAUDE_CODE_DISABLE_CLAUDE_MDS suppresses claudeMd loading', async () => { + process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS = '1' + + const ctx = await getUserContext() + + expect(ctx.currentDate).toContain("Today's date is") + expect(ctx.claudeMd).toBeUndefined() + }) + + test('setSystemPromptInjection clears the memoized user-context cache', async () => { + const first = await getUserContext() + process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS = '1' + + const second = await getUserContext() + expect(first.claudeMd).toContain(projectClaudeMdContent) + expect(second.claudeMd).toContain(projectClaudeMdContent) + + setSystemPromptInjection('cache-break') + + const third = await getUserContext() + expect(third.claudeMd).toBeUndefined() + }) + + test('getSystemContext reflects system prompt injection after cache invalidation', async () => { + const first = await getSystemContext() + expect(first.gitStatus).toBeUndefined() + expect(first.cacheBreaker).toBeUndefined() + + setSystemPromptInjection('baseline-cache-break') + + const second = await getSystemContext() + if ('cacheBreaker' in second) { + expect(second.cacheBreaker).toContain('baseline-cache-break') + } else { + expect(second.gitStatus).toBeUndefined() + } + }) +}) diff --git a/src/assistant/AssistantSessionChooser.ts b/src/assistant/AssistantSessionChooser.ts deleted file mode 100644 index e61ba6ced..000000000 --- a/src/assistant/AssistantSessionChooser.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Auto-generated stub — replace with real implementation -export {}; -export const AssistantSessionChooser: (props: Record) => null = () => null; diff --git a/src/assistant/AssistantSessionChooser.tsx b/src/assistant/AssistantSessionChooser.tsx new file mode 100644 index 000000000..5f004e840 --- /dev/null +++ b/src/assistant/AssistantSessionChooser.tsx @@ -0,0 +1,54 @@ +import * as React from 'react'; +import { useState } from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { Dialog } from '../components/design-system/Dialog.js'; +import { ListItem } from '../components/design-system/ListItem.js'; +import { useRegisterOverlay } from '../context/overlayContext.js'; +import { useKeybindings } from '../keybindings/useKeybinding.js'; +import type { AssistantSession } from './sessionDiscovery.js'; + +interface Props { + sessions: AssistantSession[]; + onSelect: (id: string) => void; + onCancel: () => void; +} + +/** + * Interactive session chooser for `claude assistant` when multiple + * CCR sessions are discovered. Renders a Dialog with up/down navigation. + * + * Session IDs are in `session_*` compat format — passed directly to + * createRemoteSessionConfig() for viewer attach. + */ +export function AssistantSessionChooser({ sessions, onSelect, onCancel }: Props): React.ReactNode { + useRegisterOverlay('assistant-session-chooser'); + const [focusIndex, setFocusIndex] = useState(0); + + useKeybindings( + { + 'select:next': () => setFocusIndex(i => (i + 1) % sessions.length), + 'select:previous': () => setFocusIndex(i => (i - 1 + sessions.length) % sessions.length), + 'select:accept': () => onSelect(sessions[focusIndex]!.id), + }, + { context: 'Select' }, + ); + + return ( + + + Multiple sessions found. Select one to attach: + + {sessions.map((s, i) => ( + + + {s.title || s.id.slice(0, 20)} + [{s.status}] + + + ))} + + ↑↓ navigate · Enter select · Esc cancel + + + ); +} diff --git a/src/assistant/gate.ts b/src/assistant/gate.ts index 1602a3be4..0cf8f0c4b 100644 --- a/src/assistant/gate.ts +++ b/src/assistant/gate.ts @@ -5,21 +5,20 @@ import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growt /** * Runtime gate for KAIROS features. * - * Build-time: feature('KAIROS') must be on (checked by caller before - * this module is required). + * Two-layer gate: + * 1. Build-time: feature('KAIROS') must be on + * 2. Runtime: tengu_kairos_assistant GrowthBook flag (remote kill switch) * - * Runtime: tengu_kairos_assistant GrowthBook flag acts as a remote kill - * switch, and kairosActive state must be true (set during bootstrap when - * the session qualifies for KAIROS features). + * Called by main.tsx BEFORE setKairosActive(true) — must NOT check + * kairosActive (that would deadlock: gate needs active, active needs gate). + * The caller (main.tsx L1826-1832) sets kairosActive after this returns true. */ export async function isKairosEnabled(): Promise { if (!feature('KAIROS')) { return false } - if ( - !getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_assistant', false) - ) { + if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_assistant', false)) { return false } - return getKairosActive() + return true } diff --git a/src/assistant/index.ts b/src/assistant/index.ts index 5b75255ad..c13d91b11 100644 --- a/src/assistant/index.ts +++ b/src/assistant/index.ts @@ -1,9 +1,64 @@ -// Auto-generated stub — replace with real implementation -export {} -export const isAssistantMode: () => boolean = () => false -export const initializeAssistantTeam: () => Promise = async () => {} -export const markAssistantForced: () => void = () => {} -export const isAssistantForced: () => boolean = () => false -export const getAssistantSystemPromptAddendum: () => string = () => '' -export const getAssistantActivationPath: () => string | undefined = () => - undefined +import { readFileSync } from 'fs' +import { join } from 'path' +import { getKairosActive } from '../bootstrap/state.js' +import { getClaudeConfigHomeDir } from '../utils/envUtils.js' + +let _assistantForced = false + +/** + * Whether the current session is in assistant (KAIROS) daemon mode. + * Wraps the bootstrap kairosActive state set by main.tsx after gate check. + */ +export function isAssistantMode(): boolean { + return getKairosActive() +} + +/** + * Mark this session as forced assistant mode (--assistant flag). + * Skips the GrowthBook gate check — daemon is pre-entitled. + */ +export function markAssistantForced(): void { + _assistantForced = true +} + +export function isAssistantForced(): boolean { + return _assistantForced +} + +/** + * Pre-create an in-process team so Agent(name) can spawn teammates + * without TeamCreate. + * + * Phase 1: returns undefined so main.tsx's `assistantTeamContext ?? computeInitialTeamContext()` + * correctly falls back. Returning {} would bypass the ?? operator since {} is truthy. + * + * Phase 2: should return a full team context object matching AppState.teamContext shape. + */ +export async function initializeAssistantTeam(): Promise { + return undefined +} + +/** + * Assistant-specific system prompt addendum loaded from ~/.claude/agents/assistant.md. + * Returns empty string if the file doesn't exist. + */ +export function getAssistantSystemPromptAddendum(): string { + try { + return readFileSync( + join(getClaudeConfigHomeDir(), 'agents', 'assistant.md'), + 'utf-8', + ) + } catch { + return '' + } +} + +/** + * How assistant mode was activated. Used for diagnostics/analytics. + * - 'daemon': via --assistant flag (Agent SDK daemon) + * - 'gate': via GrowthBook gate check + */ +export function getAssistantActivationPath(): string | undefined { + if (!isAssistantMode()) return undefined + return _assistantForced ? 'daemon' : 'gate' +} diff --git a/src/assistant/sessionDiscovery.ts b/src/assistant/sessionDiscovery.ts index 424564c1c..d12e88ad6 100644 --- a/src/assistant/sessionDiscovery.ts +++ b/src/assistant/sessionDiscovery.ts @@ -1,3 +1,51 @@ -// Auto-generated stub — replace with real implementation -export type AssistantSession = { id: string; [key: string]: unknown }; -export const discoverAssistantSessions: () => Promise = () => Promise.resolve([]); +import { logForDebugging } from '../utils/debug.js' + +/** + * Minimal session type for assistant discovery. + * Only `id` is consumed by main.tsx (L4757); other fields are for chooser display. + * ID format is `session_*` (compat prefix) — viewer endpoints use /v1/sessions/*. + */ +export type AssistantSession = { + id: string + title: string + status: string + created_at: string +} + +/** + * Discover assistant sessions on Anthropic CCR. + * + * Reuses the existing fetchCodeSessionsFromSessionsAPI() which calls + * GET /v1/sessions with proper OAuth + anthropic-beta headers. + * + * Throws on failure — main.tsx L4720-4725 catch displays the error. + * Does NOT return [] on error (that would silently redirect to install wizard). + */ +export async function discoverAssistantSessions(): Promise { + const { fetchCodeSessionsFromSessionsAPI } = await import( + '../utils/teleport/api.js' + ) + + let allSessions + try { + allSessions = await fetchCodeSessionsFromSessionsAPI() + } catch (err) { + logForDebugging( + `[assistant:discovery] fetchCodeSessionsFromSessionsAPI failed: ${err}`, + ) + throw err + } + + // Filter to active/working sessions only — completed/archived are not attachable + return allSessions + .filter( + s => + s.status === 'idle' || s.status === 'working' || s.status === 'waiting', + ) + .map(s => ({ + id: s.id, + title: s.title || 'Untitled', + status: s.status, + created_at: s.created_at ?? '', + })) +} diff --git a/src/cli/bg.ts b/src/cli/bg.ts index 709e7df9e..3bc18e39f 100644 --- a/src/cli/bg.ts +++ b/src/cli/bg.ts @@ -1,7 +1,314 @@ -// Auto-generated stub — replace with real implementation -export {}; -export const psHandler: (args: string[]) => Promise = (async () => {}) as (args: string[]) => Promise; -export const logsHandler: (sessionId: string | undefined) => Promise = (async () => {}) as (sessionId: string | undefined) => Promise; -export const attachHandler: (sessionId: string | undefined) => Promise = (async () => {}) as (sessionId: string | undefined) => Promise; -export const killHandler: (sessionId: string | undefined) => Promise = (async () => {}) as (sessionId: string | undefined) => Promise; -export const handleBgFlag: (args: string[]) => Promise = (async () => {}) as (args: string[]) => Promise; +import { readdir, readFile, unlink } from 'fs/promises' +import { join } from 'path' +import { randomUUID } from 'crypto' +import { getClaudeConfigHomeDir } from '../utils/envUtils.js' +import { isProcessRunning } from '../utils/genericProcessUtils.js' +import { jsonParse } from '../utils/slowOperations.js' +import { selectEngine } from './bg/engines/index.js' +import type { SessionEntry } from './bg/engine.js' + +export type { SessionEntry } from './bg/engine.js' + +function getSessionsDir(): string { + return join(getClaudeConfigHomeDir(), 'sessions') +} + +export async function listLiveSessions(): Promise { + const dir = getSessionsDir() + let files: string[] + try { + files = await readdir(dir) + } catch { + return [] + } + + const sessions: SessionEntry[] = [] + for (const file of files) { + if (!/^\d+\.json$/.test(file)) continue + const pid = parseInt(file.slice(0, -5), 10) + + if (!isProcessRunning(pid)) { + void unlink(join(dir, file)).catch(() => {}) + continue + } + + try { + const raw = await readFile(join(dir, file), 'utf-8') + const entry = jsonParse(raw) as SessionEntry + sessions.push(entry) + } catch { + // Corrupt file — skip + } + } + + return sessions +} + +export function findSession( + sessions: SessionEntry[], + target: string, +): SessionEntry | undefined { + const asNum = parseInt(target, 10) + return sessions.find( + s => + s.sessionId === target || + s.pid === asNum || + (s.name && s.name === target), + ) +} + +function formatTime(ts: number): string { + return new Date(ts).toLocaleString() +} + +/** + * Resolve the engine type for an existing session. + * Backward-compatible: sessions without an `engine` field are inferred + * from the presence of `tmuxSessionName`. + */ +function resolveSessionEngine(session: SessionEntry): 'tmux' | 'detached' { + if (session.engine) return session.engine + return session.tmuxSessionName ? 'tmux' : 'detached' +} + +/** + * `claude daemon status` / `claude ps` — list live sessions. + */ +export async function psHandler(_args: string[]): Promise { + const sessions = await listLiveSessions() + + if (sessions.length === 0) { + console.log('No active sessions.') + return + } + + console.log( + `${sessions.length} active session${sessions.length > 1 ? 's' : ''}:\n`, + ) + + for (const s of sessions) { + const engineType = resolveSessionEngine(s) + const parts: string[] = [ + ` PID: ${s.pid}`, + ` Kind: ${s.kind}`, + ` Engine: ${engineType}`, + ` Session: ${s.sessionId}`, + ` CWD: ${s.cwd}`, + ] + + if (s.name) parts.push(` Name: ${s.name}`) + if (s.startedAt) parts.push(` Started: ${formatTime(s.startedAt)}`) + if (s.status) parts.push(` Status: ${s.status}`) + if (s.waitingFor) parts.push(` Waiting for: ${s.waitingFor}`) + if (s.bridgeSessionId) parts.push(` Bridge: ${s.bridgeSessionId}`) + if (s.tmuxSessionName) parts.push(` Tmux: ${s.tmuxSessionName}`) + if (s.logPath) parts.push(` Log: ${s.logPath}`) + + console.log(parts.join('\n')) + console.log() + } +} + +/** + * `claude daemon logs ` — show logs for a session. + */ +export async function logsHandler(target: string | undefined): Promise { + const sessions = await listLiveSessions() + + if (!target) { + if (sessions.length === 0) { + console.log('No active sessions.') + return + } + if (sessions.length === 1) { + target = sessions[0]!.sessionId + } else { + console.log('Multiple sessions active. Specify one:') + for (const s of sessions) { + const label = s.name ? `${s.name} (${s.sessionId})` : s.sessionId + console.log(` ${label} PID=${s.pid}`) + } + return + } + } + + const session = findSession(sessions, target) + if (!session) { + console.error(`Session not found: ${target}`) + process.exitCode = 1 + return + } + + if (!session.logPath) { + console.log(`No log path recorded for session ${session.sessionId}`) + return + } + + try { + const content = await readFile(session.logPath, 'utf-8') + process.stdout.write(content) + } catch (e) { + console.error(`Failed to read log file: ${session.logPath}`) + console.error(e instanceof Error ? e.message : String(e)) + process.exitCode = 1 + } +} + +/** + * `claude daemon attach ` — attach to a background session. + * + * Engine-aware: tmux sessions use tmux attach, detached sessions use log tail. + */ +export async function attachHandler(target: string | undefined): Promise { + const sessions = await listLiveSessions() + + if (!target) { + // Find bg sessions (tmux or detached) + const bgSessions = sessions.filter( + s => s.tmuxSessionName || s.engine === 'detached', + ) + if (bgSessions.length === 0) { + console.log( + 'No background sessions to attach to. Start one with `claude daemon bg`.', + ) + return + } + if (bgSessions.length === 1) { + target = bgSessions[0]!.sessionId + } else { + console.log('Multiple background sessions. Specify one:') + for (const s of bgSessions) { + const label = s.name ? `${s.name} (${s.sessionId})` : s.sessionId + const engineType = resolveSessionEngine(s) + console.log(` ${label} PID=${s.pid} engine=${engineType}`) + } + return + } + } + + const session = findSession(sessions, target) + if (!session) { + console.error(`Session not found: ${target}`) + process.exitCode = 1 + return + } + + const engineType = resolveSessionEngine(session) + + try { + if (engineType === 'tmux') { + const { TmuxEngine } = await import('./bg/engines/tmux.js') + const tmux = new TmuxEngine() + if (!(await tmux.available())) { + console.error('tmux is no longer available. Cannot attach to tmux session.') + process.exitCode = 1 + return + } + await tmux.attach(session) + } else { + const { DetachedEngine } = await import('./bg/engines/detached.js') + const detached = new DetachedEngine() + await detached.attach(session) + } + } catch (e) { + console.error(e instanceof Error ? e.message : String(e)) + process.exitCode = 1 + } +} + +/** + * `claude daemon kill ` — kill a session. + */ +export async function killHandler(target: string | undefined): Promise { + const sessions = await listLiveSessions() + + if (!target) { + if (sessions.length === 0) { + console.log('No active sessions to kill.') + return + } + console.log('Specify a session to kill:') + for (const s of sessions) { + const label = s.name ? `${s.name} (${s.sessionId})` : s.sessionId + console.log(` ${label} PID=${s.pid}`) + } + return + } + + const session = findSession(sessions, target) + if (!session) { + console.error(`Session not found: ${target}`) + process.exitCode = 1 + return + } + + console.log(`Killing session ${session.sessionId} (PID: ${session.pid})...`) + + try { + process.kill(session.pid, 'SIGTERM') + } catch { + console.log('Session already exited.') + return + } + + await new Promise(resolve => setTimeout(resolve, 2000)) + + if (isProcessRunning(session.pid)) { + try { + process.kill(session.pid, 'SIGKILL') + console.log('Session force-killed.') + } catch { + console.log('Session exited during grace period.') + } + } else { + console.log('Session stopped.') + } + + const pidFile = join(getSessionsDir(), `${session.pid}.json`) + void unlink(pidFile).catch(() => {}) +} + +/** + * `claude daemon bg [args]` — start a background session. + * + * Cross-platform: uses TmuxEngine on macOS/Linux when tmux is available, + * falls back to DetachedEngine on Windows or when tmux is absent. + */ +export async function handleBgStart(args: string[]): Promise { + const engine = await selectEngine() + + const sessionName = `claude-bg-${randomUUID().slice(0, 8)}` + const logPath = join( + getClaudeConfigHomeDir(), + 'sessions', + 'logs', + `${sessionName}.log`, + ) + + // Strip --bg/--background from args (for backward-compat shortcut) + const filteredArgs = args.filter(a => a !== '--bg' && a !== '--background') + + try { + const result = await engine.start({ + sessionName, + args: filteredArgs, + env: { ...process.env }, + logPath, + cwd: process.cwd(), + }) + + console.log(`Background session started: ${result.sessionName}`) + console.log(` Engine: ${result.engineUsed}`) + console.log(` Log: ${result.logPath}`) + console.log() + console.log(`Use \`claude daemon attach ${result.sessionName}\` to reconnect.`) + console.log(`Use \`claude daemon status\` to check status.`) + console.log(`Use \`claude daemon kill ${result.sessionName}\` to stop.`) + } catch (e) { + console.error(e instanceof Error ? e.message : String(e)) + process.exitCode = 1 + } +} + +// Legacy export alias — kept for backward compatibility with cli.tsx +export const handleBgFlag = handleBgStart diff --git a/src/cli/bg/__tests__/detached.test.ts b/src/cli/bg/__tests__/detached.test.ts new file mode 100644 index 000000000..ead4f1c78 --- /dev/null +++ b/src/cli/bg/__tests__/detached.test.ts @@ -0,0 +1,15 @@ +import { describe, test, expect } from 'bun:test' +import { DetachedEngine } from '../engines/detached.js' + +describe('DetachedEngine', () => { + test('name is "detached"', () => { + const engine = new DetachedEngine() + expect(engine.name).toBe('detached') + }) + + test('available always returns true', async () => { + const engine = new DetachedEngine() + const result = await engine.available() + expect(result).toBe(true) + }) +}) diff --git a/src/cli/bg/__tests__/engine.test.ts b/src/cli/bg/__tests__/engine.test.ts new file mode 100644 index 000000000..2c4a3226e --- /dev/null +++ b/src/cli/bg/__tests__/engine.test.ts @@ -0,0 +1,37 @@ +import { describe, test, expect } from 'bun:test' + +describe('selectEngine', () => { + test('returns engine with valid BgEngine interface', async () => { + const { selectEngine } = await import('../engines/index.js') + const engine = await selectEngine() + expect(engine.name).toBeDefined() + expect(['tmux', 'detached']).toContain(engine.name) + expect(typeof engine.available).toBe('function') + expect(typeof engine.start).toBe('function') + expect(typeof engine.attach).toBe('function') + }) + + test('engine.available() returns a boolean', async () => { + const { selectEngine } = await import('../engines/index.js') + const engine = await selectEngine() + const result = await engine.available() + expect(typeof result).toBe('boolean') + }) +}) + +describe('SessionEntry type', () => { + test('engine field accepts tmux or detached', async () => { + // Verify the module loads and exports the expected interface shape + const mod = await import('../engine.js') + expect(mod).toBeDefined() + const entry = { + pid: 123, + sessionId: 'test', + cwd: '/tmp', + startedAt: Date.now(), + kind: 'bg', + engine: 'detached' as const, + } + expect(entry.engine).toBe('detached') + }) +}) diff --git a/src/cli/bg/__tests__/tail.test.ts b/src/cli/bg/__tests__/tail.test.ts new file mode 100644 index 000000000..7cbfba72a --- /dev/null +++ b/src/cli/bg/__tests__/tail.test.ts @@ -0,0 +1,8 @@ +import { describe, test, expect } from 'bun:test' + +describe('tailLog', () => { + test('module exports tailLog function', async () => { + const mod = await import('../tail.js') + expect(typeof mod.tailLog).toBe('function') + }) +}) diff --git a/src/cli/bg/engine.ts b/src/cli/bg/engine.ts new file mode 100644 index 000000000..6e79dfcb4 --- /dev/null +++ b/src/cli/bg/engine.ts @@ -0,0 +1,47 @@ +/** + * BgEngine — cross-platform background session engine abstraction. + * + * Implementations: + * TmuxEngine — macOS/Linux with tmux installed + * DetachedEngine — Windows, or macOS/Linux without tmux (fallback) + */ + +export interface SessionEntry { + pid: number + sessionId: string + cwd: string + startedAt: number + kind: string + name?: string + logPath?: string + entrypoint?: string + status?: string + waitingFor?: string + updatedAt?: number + bridgeSessionId?: string + agent?: string + tmuxSessionName?: string + engine?: 'tmux' | 'detached' +} + +export interface BgStartOptions { + sessionName: string + args: string[] + env: Record + logPath: string + cwd: string +} + +export interface BgStartResult { + pid: number + sessionName: string + logPath: string + engineUsed: 'tmux' | 'detached' +} + +export interface BgEngine { + readonly name: 'tmux' | 'detached' + available(): Promise + start(opts: BgStartOptions): Promise + attach(session: SessionEntry): Promise +} diff --git a/src/cli/bg/engines/detached.ts b/src/cli/bg/engines/detached.ts new file mode 100644 index 000000000..3e168e4fd --- /dev/null +++ b/src/cli/bg/engines/detached.ts @@ -0,0 +1,51 @@ +import { spawn } from 'child_process' +import { openSync, closeSync, mkdirSync } from 'fs' +import { dirname } from 'path' +import type { BgEngine, BgStartOptions, BgStartResult, SessionEntry } from '../engine.js' +import { tailLog } from '../tail.js' + +export class DetachedEngine implements BgEngine { + readonly name = 'detached' as const + + async available(): Promise { + return true + } + + async start(opts: BgStartOptions): Promise { + mkdirSync(dirname(opts.logPath), { recursive: true }) + + const logFd = openSync(opts.logPath, 'a') + const entrypoint = process.argv[1]! + + const child = spawn(process.execPath, [entrypoint, ...opts.args], { + detached: true, + stdio: ['ignore', logFd, logFd], + env: { + ...opts.env, + CLAUDE_CODE_SESSION_KIND: 'bg', + CLAUDE_CODE_SESSION_NAME: opts.sessionName, + CLAUDE_CODE_SESSION_LOG: opts.logPath, + } as Record, + cwd: opts.cwd, + }) + + child.unref() + closeSync(logFd) + + const pid = child.pid ?? 0 + + return { + pid, + sessionName: opts.sessionName, + logPath: opts.logPath, + engineUsed: 'detached', + } + } + + async attach(session: SessionEntry): Promise { + if (!session.logPath) { + throw new Error(`Session ${session.sessionId} has no log path.`) + } + await tailLog(session.logPath) + } +} diff --git a/src/cli/bg/engines/index.ts b/src/cli/bg/engines/index.ts new file mode 100644 index 000000000..cee304c54 --- /dev/null +++ b/src/cli/bg/engines/index.ts @@ -0,0 +1,17 @@ +export type { BgEngine, BgStartOptions, BgStartResult, SessionEntry } from '../engine.js' + +export async function selectEngine(): Promise { + if (process.platform === 'win32') { + const { DetachedEngine } = await import('./detached.js') + return new DetachedEngine() + } + + const { TmuxEngine } = await import('./tmux.js') + const tmux = new TmuxEngine() + if (await tmux.available()) { + return tmux + } + + const { DetachedEngine } = await import('./detached.js') + return new DetachedEngine() +} diff --git a/src/cli/bg/engines/tmux.ts b/src/cli/bg/engines/tmux.ts new file mode 100644 index 000000000..bf978b621 --- /dev/null +++ b/src/cli/bg/engines/tmux.ts @@ -0,0 +1,73 @@ +import { spawnSync } from 'child_process' +import { execFileNoThrow } from '../../../utils/execFileNoThrow.js' +import { quote } from '../../../utils/bash/shellQuote.js' +import type { BgEngine, BgStartOptions, BgStartResult, SessionEntry } from '../engine.js' + +export class TmuxEngine implements BgEngine { + readonly name = 'tmux' as const + + async available(): Promise { + const { code } = await execFileNoThrow('tmux', ['-V'], { useCwd: false }) + return code === 0 + } + + async start(opts: BgStartOptions): Promise { + const entrypoint = process.argv[1]! + const cmd = quote([process.execPath, entrypoint, ...opts.args]) + + const tmuxEnv: Record = { + ...opts.env, + CLAUDE_CODE_SESSION_KIND: 'bg', + CLAUDE_CODE_SESSION_NAME: opts.sessionName, + CLAUDE_CODE_SESSION_LOG: opts.logPath, + CLAUDE_CODE_TMUX_SESSION: opts.sessionName, + } + + const result = spawnSync( + 'tmux', + ['new-session', '-d', '-s', opts.sessionName, cmd], + { stdio: 'inherit', env: tmuxEnv }, + ) + + if (result.status !== 0) { + throw new Error('Failed to create tmux session.') + } + + // tmux doesn't directly report the child PID; we return 0. + // The actual session process writes its own PID file. + return { + pid: 0, + sessionName: opts.sessionName, + logPath: opts.logPath, + engineUsed: 'tmux', + } + } + + async attach(session: SessionEntry): Promise { + if (!session.tmuxSessionName) { + throw new Error(`Session ${session.sessionId} has no tmux session name.`) + } + + const result = spawnSync( + 'tmux', + ['attach-session', '-t', session.tmuxSessionName], + { stdio: 'inherit' }, + ) + + if (result.status !== 0) { + throw new Error( + `Failed to attach to tmux session '${session.tmuxSessionName}'.`, + ) + } + } +} + +export function getTmuxInstallHint(): string { + if (process.platform === 'darwin') { + return 'Install with: brew install tmux' + } + if (process.platform === 'win32') { + return 'tmux is not natively available on Windows. Consider using WSL.' + } + return 'Install with: sudo apt install tmux (or your package manager)' +} diff --git a/src/cli/bg/tail.ts b/src/cli/bg/tail.ts new file mode 100644 index 000000000..a99734c4a --- /dev/null +++ b/src/cli/bg/tail.ts @@ -0,0 +1,70 @@ +import { + openSync, + readSync, + closeSync, + statSync, + watchFile, + unwatchFile, + createReadStream, +} from 'fs' +import { createInterface } from 'readline' + +/** + * Cross-platform real-time log output. Ctrl+C exits tail without killing + * the background process. + * + * Strategy: + * 1. Read existing content and output to stdout + * 2. Use fs.watchFile() (polling-based — works everywhere including Windows) + * 3. On change, read new bytes from the last known position + * 4. SIGINT exits cleanly + */ +export async function tailLog(logPath: string): Promise { + let position = 0 + + // Output existing content + try { + const stat = statSync(logPath) + position = stat.size + if (position > 0) { + const stream = createReadStream(logPath, { start: 0, end: position - 1 }) + const rl = createInterface({ input: stream }) + for await (const line of rl) { + process.stdout.write(line + '\n') + } + } + } catch { + // File may not exist yet — that's fine + } + + console.log('\n[tail] Watching for new output... (Ctrl+C to detach)\n') + + return new Promise(resolve => { + const onSignal = (): void => { + unwatchFile(logPath) + process.removeListener('SIGINT', onSignal) + console.log('\n[tail] Detached from session.') + resolve() + } + process.on('SIGINT', onSignal) + + watchFile(logPath, { interval: 300 }, () => { + try { + const stat = statSync(logPath) + if (stat.size <= position) return + + const fd = openSync(logPath, 'r') + try { + const buf = Buffer.alloc(stat.size - position) + readSync(fd, buf, 0, buf.length, position) + process.stdout.write(buf) + position = stat.size + } finally { + closeSync(fd) + } + } catch { + // File may have been deleted or truncated + } + }) + }) +} diff --git a/src/cli/handlers/ant.ts b/src/cli/handlers/ant.ts index 74e53359f..1694281b0 100644 --- a/src/cli/handlers/ant.ts +++ b/src/cli/handlers/ant.ts @@ -1,13 +1,216 @@ -// Auto-generated stub — replace with real implementation -import type { Command } from '@commander-js/extra-typings'; - -export {}; -export const logHandler: (logId: string | number | undefined) => Promise = (async () => {}) as (logId: string | number | undefined) => Promise; -export const errorHandler: (num: number | undefined) => Promise = (async () => {}) as (num: number | undefined) => Promise; -export const exportHandler: (source: string, outputFile: string) => Promise = (async () => {}) as (source: string, outputFile: string) => Promise; -export const taskCreateHandler: (subject: string, opts: { description?: string; list?: string }) => Promise = (async () => {}) as (subject: string, opts: { description?: string; list?: string }) => Promise; -export const taskListHandler: (opts: { list?: string; pending?: boolean; json?: boolean }) => Promise = (async () => {}) as (opts: { list?: string; pending?: boolean; json?: boolean }) => Promise; -export const taskGetHandler: (id: string, opts: { list?: string }) => Promise = (async () => {}) as (id: string, opts: { list?: string }) => Promise; -export const taskUpdateHandler: (id: string, opts: { list?: string; status?: string; subject?: string; description?: string; owner?: string; clearOwner?: boolean }) => Promise = (async () => {}) as (id: string, opts: { list?: string; status?: string; subject?: string; description?: string; owner?: string; clearOwner?: boolean }) => Promise; -export const taskDirHandler: (opts: { list?: string }) => Promise = (async () => {}) as (opts: { list?: string }) => Promise; -export const completionHandler: (shell: string, opts: { output?: string }, program: Command) => Promise = (async () => {}) as (shell: string, opts: { output?: string }, program: Command) => Promise; +import type { Command } from '@commander-js/extra-typings' +import { + createTask, + getTask, + updateTask, + listTasks, + getTasksDir, +} from '../../utils/tasks.js' +import { getRecentActivity } from '../../utils/logoV2Utils.js' +import type { LogOption } from '../../types/logs.js' + +const DEFAULT_LIST = 'default' + +// ─── Group C: Task CRUD ────────────────────────────────────────────────────── + +export async function taskCreateHandler( + subject: string, + opts: { description?: string; list?: string }, +): Promise { + const listId = opts.list || DEFAULT_LIST + const id = await createTask(listId, { + subject, + description: opts.description || '', + status: 'pending', + blocks: [], + blockedBy: [], + }) + console.log(`Created task ${id}: ${subject}`) +} + +export async function taskListHandler(opts: { + list?: string + pending?: boolean + json?: boolean +}): Promise { + const listId = opts.list || DEFAULT_LIST + let tasks = await listTasks(listId) + + if (opts.pending) { + tasks = tasks.filter(t => t.status === 'pending') + } + + if (opts.json) { + console.log(JSON.stringify(tasks, null, 2)) + return + } + + if (tasks.length === 0) { + console.log('No tasks found.') + return + } + + for (const t of tasks) { + console.log(` [${t.status}] ${t.id}: ${t.subject}`) + if (t.description) console.log(` ${t.description}`) + if (t.owner) console.log(` owner: ${t.owner}`) + } +} + +export async function taskGetHandler( + id: string, + opts: { list?: string }, +): Promise { + const listId = opts.list || DEFAULT_LIST + const task = await getTask(listId, id) + if (!task) { + console.error(`Task not found: ${id}`) + process.exitCode = 1 + return + } + console.log(JSON.stringify(task, null, 2)) +} + +export async function taskUpdateHandler( + id: string, + opts: { + list?: string + status?: string + subject?: string + description?: string + owner?: string + clearOwner?: boolean + }, +): Promise { + const listId = opts.list || DEFAULT_LIST + const updates: Record = {} + + if (opts.status) updates.status = opts.status + if (opts.subject) updates.subject = opts.subject + if (opts.description) updates.description = opts.description + if (opts.owner) updates.owner = opts.owner + if (opts.clearOwner) updates.owner = undefined + + const task = await updateTask(listId, id, updates) + if (!task) { + console.error(`Task not found: ${id}`) + process.exitCode = 1 + return + } + console.log(`Updated task ${id}: [${task.status}] ${task.subject}`) +} + +export async function taskDirHandler(opts: { list?: string }): Promise { + const listId = opts.list || DEFAULT_LIST + console.log(getTasksDir(listId)) +} + +// ─── Group B: Log / Error / Export ─────────────────────────────────────────── + +export async function logHandler( + logId: string | number | undefined, +): Promise { + const logs = await getRecentActivity() + + if (logId === undefined) { + if (logs.length === 0) { + console.log('No recent sessions.') + return + } + for (let i = 0; i < Math.min(logs.length, 20); i++) { + const log = logs[i]! + const date = log.modified + ? new Date(log.modified).toLocaleString() + : 'unknown' + const title = + (log as Record).title || log.sessionId || 'untitled' + console.log(` ${i}: ${title} (${date})`) + } + return + } + + const idx = typeof logId === 'string' ? parseInt(logId, 10) : logId + const log = + Number.isFinite(idx) && idx >= 0 && idx < logs.length + ? logs[idx] + : logs.find(l => l.sessionId === String(logId)) + + if (!log) { + console.error(`Session not found: ${logId}`) + process.exitCode = 1 + return + } + + console.log(JSON.stringify(log, null, 2)) +} + +export async function errorHandler(num: number | undefined): Promise { + // Error log viewing — shows recent session errors + const logs = await getRecentActivity() + const count = num ?? 5 + + console.log(`Last ${count} sessions:`) + for (let i = 0; i < Math.min(count, logs.length); i++) { + const log = logs[i]! + const date = log.modified + ? new Date(log.modified).toLocaleString() + : 'unknown' + console.log(` ${i}: ${log.sessionId} (${date})`) + } +} + +export async function exportHandler( + source: string, + outputFile: string, +): Promise { + const { writeFile, readFile } = await import('fs/promises') + const logs = await getRecentActivity() + + // Try as index first + const idx = parseInt(source, 10) + let log: LogOption | undefined + if (Number.isFinite(idx) && idx >= 0 && idx < logs.length) { + log = logs[idx] + } else { + log = logs.find(l => l.sessionId === source) + } + + if (!log) { + // Try as file path + try { + const content = await readFile(source, 'utf-8') + await writeFile(outputFile, content, 'utf-8') + console.log(`Exported ${source} → ${outputFile}`) + return + } catch { + console.error(`Source not found: ${source}`) + process.exitCode = 1 + return + } + } + + await writeFile(outputFile, JSON.stringify(log, null, 2), 'utf-8') + console.log(`Exported session ${log.sessionId} → ${outputFile}`) +} + +// ─── Group D: Completion ───────────────────────────────────────────────────── + +export async function completionHandler( + shell: string, + opts: { output?: string }, + _program: Command, +): Promise { + const { regenerateCompletionCache } = await import( + '../../utils/completionCache.js' + ) + + if (opts.output) { + // Generate and write to file + await regenerateCompletionCache() + console.log(`Completion cache regenerated for ${shell}.`) + } else { + // Regenerate and output to stdout + await regenerateCompletionCache() + console.log(`Completion cache regenerated for ${shell}.`) + } +} diff --git a/src/cli/handlers/templateJobs.ts b/src/cli/handlers/templateJobs.ts index aefb23217..525ccf25f 100644 --- a/src/cli/handlers/templateJobs.ts +++ b/src/cli/handlers/templateJobs.ts @@ -1,3 +1,158 @@ -// Auto-generated stub — replace with real implementation -export {}; -export const templatesMain: (args: string[]) => Promise = () => Promise.resolve(); +import { randomUUID } from 'crypto' +import { listTemplates, loadTemplate } from '../../jobs/templates.js' +import { + createJob, + readJobState, + appendJobReply, + getJobDir, +} from '../../jobs/state.js' + +/** + * Entry point for template job commands: `new`, `list`, `reply`. + * Called from cli.tsx fast-path. + */ +export async function templatesMain(args: string[]): Promise { + const subcommand = args[0] + + switch (subcommand) { + case 'list': + handleList() + break + case 'new': + handleNew(args.slice(1)) + break + case 'reply': + handleReply(args.slice(1)) + break + case 'status': + handleStatus(args.slice(1)) + break + default: + console.error(`Unknown template command: ${subcommand}`) + printUsage() + process.exitCode = 1 + } +} + +function printUsage(): void { + console.log(` +Template Job Commands: + + claude job list List available templates + claude job new