From 3a1bac588dc547fac8dc404928fe73ebbead50b0 Mon Sep 17 00:00:00 2001 From: Jiang Chaoqun Date: Sat, 4 Jul 2026 17:32:03 +0800 Subject: [PATCH 1/3] feat: add life codex remote console --- .gitignore | 4 + README.MD | 3 + build.sh | 6 + cli/life_codex_agent/main.go | 127 ++ cli/life_codex_server/main.go | 110 ++ docs/cli/life_codex.md | 146 ++ docs/install.md | 38 + install.sh | 42 +- internal/life_codex/agent_runtime.go | 221 +++ internal/life_codex/attachments.go | 132 ++ internal/life_codex/attachments_test.go | 51 + internal/life_codex/audit.go | 122 ++ internal/life_codex/audit_test.go | 52 + internal/life_codex/codex_client.go | 489 +++++++ internal/life_codex/codex_client_test.go | 26 + internal/life_codex/config.go | 148 ++ internal/life_codex/config_test.go | 21 + internal/life_codex/server_http.go | 338 +++++ internal/life_codex/state.go | 538 +++++++ internal/life_codex/state_test.go | 122 ++ internal/life_codex/types.go | 148 ++ sample/life_tools/life_codex_agent.json | 13 + sample/life_tools/life_codex_server.json | 11 + web/life_codex/index.html | 12 + web/life_codex/package-lock.json | 1703 ++++++++++++++++++++++ web/life_codex/package.json | 14 + web/life_codex/src/main.jsx | 358 +++++ web/life_codex/src/styles.css | 394 +++++ 28 files changed, 5386 insertions(+), 3 deletions(-) create mode 100644 cli/life_codex_agent/main.go create mode 100644 cli/life_codex_server/main.go create mode 100644 docs/cli/life_codex.md create mode 100644 internal/life_codex/agent_runtime.go create mode 100644 internal/life_codex/attachments.go create mode 100644 internal/life_codex/attachments_test.go create mode 100644 internal/life_codex/audit.go create mode 100644 internal/life_codex/audit_test.go create mode 100644 internal/life_codex/codex_client.go create mode 100644 internal/life_codex/codex_client_test.go create mode 100644 internal/life_codex/config.go create mode 100644 internal/life_codex/config_test.go create mode 100644 internal/life_codex/server_http.go create mode 100644 internal/life_codex/state.go create mode 100644 internal/life_codex/state_test.go create mode 100644 internal/life_codex/types.go create mode 100644 sample/life_tools/life_codex_agent.json create mode 100644 sample/life_tools/life_codex_server.json create mode 100644 web/life_codex/index.html create mode 100644 web/life_codex/package-lock.json create mode 100644 web/life_codex/package.json create mode 100644 web/life_codex/src/main.jsx create mode 100644 web/life_codex/src/styles.css diff --git a/.gitignore b/.gitignore index 84a3b76..ac7d20a 100644 --- a/.gitignore +++ b/.gitignore @@ -98,6 +98,10 @@ venv/ # Go test scratch directories renameV1/tmp/ +# Frontend dependencies and build outputs +web/**/node_modules/ +web/**/dist/ + # .NET build artifacts **/bin/ **/obj/ diff --git a/README.MD b/README.MD index 2dbb2ba..1d8e7b1 100644 --- a/README.MD +++ b/README.MD @@ -31,6 +31,7 @@ ./install.sh --tool renameV1 --tool check_keywords ./install.sh --tools retry_exec,codex_hook_notify ./install.sh --tool file_share +./install.sh --tool life_codex_server --tool life_codex_agent ``` 自定义安装目录和配置目录: @@ -58,6 +59,8 @@ | CLI | `file_share` | `cli/file_share/` | `file_share` | 临时启动只读 HTTP 文件分享服务 | [docs/cli/file_share.md](docs/cli/file_share.md) | | CLI | `video_subtitle` | `cli/video_subtitle/` | `video_subtitle` | 为单个视频生成简体中文字幕 | [docs/cli/video_subtitle.md](docs/cli/video_subtitle.md) | | Experimental CLI | `codex_inspector` | `cli/codex_inspector/` | `codex_inspector` | 本机只读查看 Codex 会话、token 用量、活跃度和记忆内容 | [docs/cli/codex_inspector.md](docs/cli/codex_inspector.md) | +| Experimental CLI | `life_codex_server` | `cli/life_codex_server/` | `life_codex_server` | 远程 Codex 控制台中心服务和网页 | [docs/cli/life_codex.md](docs/cli/life_codex.md) | +| Experimental CLI | `life_codex_agent` | `cli/life_codex_agent/` | `life_codex_agent` | 连接中心服务并执行本机 Codex app-server 命令 | [docs/cli/life_codex.md](docs/cli/life_codex.md) | | Plugin | Emby 字幕插件 | `emby_plugins/video_subtitle/` | `LifeTools.Emby.VideoSubtitle.Emby.dll` | 在 Emby 后台调用 `video_subtitle` 生成字幕 | [docs/plugins/emby_video_subtitle.md](docs/plugins/emby_video_subtitle.md) | | GUI | InterviewTimer | `gui/interview_timer/` | `InterviewTimer.app` | macOS 面试悬浮计时器 | [docs/gui/interview_timer.md](docs/gui/interview_timer.md) | diff --git a/build.sh b/build.sh index 679c842..55087ed 100755 --- a/build.sh +++ b/build.sh @@ -16,3 +16,9 @@ go build -v -o ./output/codex_hook_notify ./cli/codex_hook_notify/... # HTTP 文件快速分享 go build -v -o ./output/file_share ./cli/file_share/... + +# 远程 Codex 控制台中心服务 +go build -v -o ./output/life_codex_server ./cli/life_codex_server/... + +# 远程 Codex 控制台机器 agent +go build -v -o ./output/life_codex_agent ./cli/life_codex_agent/... diff --git a/cli/life_codex_agent/main.go b/cli/life_codex_agent/main.go new file mode 100644 index 0000000..951a3f2 --- /dev/null +++ b/cli/life_codex_agent/main.go @@ -0,0 +1,127 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "os/signal" + "strings" + + lifecodex "life_tools/internal/life_codex" +) + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + switch os.Args[1] { + case "enroll": + runEnroll(os.Args[2:]) + case "serve": + runServe(os.Args[2:]) + default: + usage() + os.Exit(2) + } +} + +func runEnroll(args []string) { + flags := flag.NewFlagSet("enroll", flag.ExitOnError) + configPath := flags.String("config", lifecodex.DefaultAgentConfigPath, "agent config path") + serverURL := flags.String("server", "", "life_codex_server URL") + token := flags.String("token", "", "enrollment token generated by server") + name := flags.String("name", "", "machine name") + roots := flags.String("roots", "", "comma-separated allowed roots") + codexPath := flags.String("codex-path", "", "codex executable path") + attachmentDir := flags.String("attachment-dir", "", "attachment directory") + maxActive := flags.Int("max-active-sessions", 0, "max concurrent sessions") + pollSeconds := flags.Int("poll-seconds", 0, "poll interval seconds") + flags.Parse(args) + + if *token == "" { + log.Fatal("--token is required") + } + config := loadAgentConfigLoose(*configPath) + if *serverURL != "" { + config.ServerURL = *serverURL + } + if *name != "" { + config.MachineName = *name + } + if *roots != "" { + config.AllowedRoots = splitCSV(*roots) + } + if *codexPath != "" { + config.CodexPath = *codexPath + } + if *attachmentDir != "" { + config.AttachmentDir = *attachmentDir + } + if *maxActive > 0 { + config.MaxActiveSessions = *maxActive + } + if *pollSeconds > 0 { + config.PollSeconds = *pollSeconds + } + if config.MachineName == "" { + host, _ := os.Hostname() + config.MachineName = host + } + if len(config.AllowedRoots) == 0 { + log.Fatal("--roots is required") + } + resp, err := lifecodex.EnrollAgent(context.Background(), *configPath, config, *token) + if err != nil { + log.Fatal(err) + } + fmt.Printf("enrolled machine %s\n", resp.MachineID) +} + +func runServe(args []string) { + flags := flag.NewFlagSet("serve", flag.ExitOnError) + configPath := flags.String("config", lifecodex.DefaultAgentConfigPath, "agent config path") + flags.Parse(args) + config, err := lifecodex.LoadAgentConfig(*configPath) + if err != nil { + log.Fatal(err) + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + if err := lifecodex.RunAgent(ctx, config); err != nil && ctx.Err() == nil { + log.Fatal(err) + } +} + +func loadAgentConfigLoose(path string) lifecodex.AgentConfig { + config, err := lifecodex.LoadAgentConfig(path) + if err == nil { + return config + } + if path != "" && os.IsNotExist(err) { + return lifecodex.DefaultAgentConfig() + } + log.Fatal(err) + return lifecodex.AgentConfig{} +} + +func splitCSV(value string) []string { + parts := strings.Split(value, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} + +func usage() { + fmt.Fprintf(os.Stderr, `Usage: + life_codex_agent enroll --server URL --token TOKEN --roots ROOT[,ROOT] [options] + life_codex_agent serve --config PATH +`) +} diff --git a/cli/life_codex_server/main.go b/cli/life_codex_server/main.go new file mode 100644 index 0000000..f2630ff --- /dev/null +++ b/cli/life_codex_server/main.go @@ -0,0 +1,110 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "time" + + lifecodex "life_tools/internal/life_codex" +) + +func main() { + if len(os.Args) > 1 && os.Args[1] == "audit-clean" { + runAuditClean(os.Args[2:]) + return + } + runServe(os.Args[1:]) +} + +func runServe(args []string) { + flags := flag.NewFlagSet("life_codex_server", flag.ExitOnError) + configPath := flags.String("config", lifecodex.DefaultServerConfigPath, "server config path") + addr := flags.String("addr", "", "listen address override") + dataDir := flags.String("data-dir", "", "data directory override") + auditDir := flags.String("audit-dir", "", "audit directory override") + webRoot := flags.String("web-root", "", "web root override") + adminToken := flags.String("admin-token", "", "admin token override") + flags.Parse(args) + + config, err := loadServerConfigForCLI(*configPath) + if err != nil { + log.Fatal(err) + } + if *addr != "" { + config.Addr = *addr + } + if *dataDir != "" { + config.DataDir = *dataDir + } + if *auditDir != "" { + config.AuditDir = *auditDir + } + if *webRoot != "" { + config.WebRoot = *webRoot + } + if *adminToken != "" { + config.AdminToken = *adminToken + } + if config.AdminToken == "" { + log.Fatal("admin_token is required") + } + store, err := lifecodex.NewStateStore(config) + if err != nil { + log.Fatal(err) + } + server := lifecodex.NewHTTPServer(config, store) + httpServer := &http.Server{Addr: config.Addr, Handler: server.Handler()} + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = httpServer.Shutdown(shutdownCtx) + }() + log.Printf("life_codex_server listening on http://%s", config.Addr) + if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatal(err) + } +} + +func runAuditClean(args []string) { + flags := flag.NewFlagSet("audit-clean", flag.ExitOnError) + configPath := flags.String("config", lifecodex.DefaultServerConfigPath, "server config path") + beforeValue := flags.String("before", "", "delete audit files before YYYY-MM-DD") + confirm := flags.Bool("confirm", false, "confirm deletion") + flags.Parse(args) + if !*confirm { + log.Fatal("audit-clean requires --confirm") + } + before, err := time.Parse("2006-01-02", *beforeValue) + if err != nil { + log.Fatal("--before must be YYYY-MM-DD") + } + config, err := loadServerConfigForCLI(*configPath) + if err != nil { + log.Fatal(err) + } + removed, err := lifecodex.ClearAuditBefore(config.AuditDir, before) + if err != nil { + log.Fatal(err) + } + fmt.Printf("removed %d audit file(s)\n", removed) +} + +func loadServerConfigForCLI(path string) (lifecodex.ServerConfig, error) { + config, err := lifecodex.LoadServerConfig(path) + if err == nil { + return config, nil + } + if path != "" && os.IsNotExist(err) { + return lifecodex.DefaultServerConfig(), nil + } + return config, err +} diff --git a/docs/cli/life_codex.md b/docs/cli/life_codex.md new file mode 100644 index 0000000..89a53dc --- /dev/null +++ b/docs/cli/life_codex.md @@ -0,0 +1,146 @@ +# life_codex 远程 Codex 控制台 + +`life_codex` 用网页控制远端机器上的 Codex CLI。它只做远程控制台的核心闭环,不复制完整 Codex App。 + +## 组件 + +| 组件 | 入口 | 运行位置 | 职责 | +|---|---|---|---| +| `life_codex_server` | `cli/life_codex_server/` | 中心机器 | 提供网页、机器绑定、Session 索引、队列、fork、审计和 agent API | +| `life_codex_agent` | `cli/life_codex_agent/` | 每台计算机器 | 主动连接 server,拉起本机 `codex app-server --stdio`,执行 Session 命令 | +| `web/life_codex` | Vite/React | server 静态资源 | 机器列表、Session 切换、聊天、图片粘贴、BTW/Fork、审计清理 | +| `internal/life_codex` | Go package | 共享 | 配置、协议、状态、审计、附件和 Codex adapter | + +## 数据和路径 + +| 类型 | 默认路径 | +|---|---| +| server 配置 | `/etc/life_tools/life_codex_server.json` | +| agent 配置 | `/etc/life_tools/life_codex_agent.json` | +| server 数据 | `/var/lib/life_tools/life_codex_server/` | +| server 审计 | `/var/log/life_tools/life_codex_server/` | +| agent 图片附件 | `/var/lib/life_tools/life_codex_agent/attachments/` | +| 网页静态资源 | `/usr/local/share/life_tools/life_codex/` | + +本机模拟部署时不要写真实系统目录,统一使用 `/private/tmp/life_codex_e2e/` 下的 `config`、`data`、`audit`、`attachments` 和 `web`。 + +## 行为边界 + +| 能力 | 当前实现 | +|---|---| +| 机器绑定 | 网页生成 15 分钟 enrolment token,agent enrol 后保存长期 agent token | +| Session | Session 绑定机器;server 保存索引和最近 500 条事件;完整 Codex 状态留在机器本地 | +| 并发 | 不同 Session 可并行;同一 Session 单 active turn;运行中输入进入 Session FIFO 队列 | +| 机器限流 | 按 agent 上报的 `max_active_sessions` 控制同时运行的 Session 数 | +| `/btw` / Fork | 输入 `/btw <问题>` 或右侧 Fork;优先调用 `thread/fork`,本机 Codex 不支持时降级新线程 | +| 图片粘贴 | 网页转 base64,server 校验 MIME/大小/数量,agent 落盘后以 `localImage` 输入交给 Codex | +| allowed roots | 创建 Session 的 cwd 必须在 agent `allowed_roots` 内 | +| 审计 | 文本原文和工具事件写 JSONL;图片只记录 metadata,`data_base64` 在 logger 层强制脱敏 | +| 审计清理 | CLI 和网页都要求二次确认 | + +永久保存文本原文审计是高风险选择。不要把 server 部署到不可信网络,也不要把 audit 目录放到宽权限共享盘。 + +## 安装 + +先构建网页: + +```bash +npm --prefix web/life_codex install +npm --prefix web/life_codex run build +``` + +安装 server 和 agent: + +```bash +./install.sh --tool life_codex_server --tool life_codex_agent +``` + +安装脚本会安装二进制、网页资源和示例配置。已有配置文件不会被覆盖。 + +## server 配置 + +示例文件:`sample/life_tools/life_codex_server.json` + +```json +{ + "addr": "127.0.0.1:8899", + "admin_token": "change-me", + "data_dir": "/var/lib/life_tools/life_codex_server", + "audit_dir": "/var/log/life_tools/life_codex_server", + "web_root": "/usr/local/share/life_tools/life_codex", + "max_image_bytes": 10485760, + "max_images_per_turn": 5, + "default_approval_policy": "on-request", + "default_approvals_reviewer": "auto_review" +} +``` + +启动: + +```bash +life_codex_server -config /etc/life_tools/life_codex_server.json +``` + +网页访问: + +```text +http://127.0.0.1:8899 +``` + +## agent 绑定和运行 + +在网页点击 `Token` 生成 enrolment token,然后在目标机器执行: + +```bash +life_codex_agent enroll \ + -config /etc/life_tools/life_codex_agent.json \ + -server http://127.0.0.1:8899 \ + -token \ + -name local \ + -roots /Users/bytedance/go/src/github.com/mcoder2014 \ + -attachment-dir /var/lib/life_tools/life_codex_agent/attachments \ + -max-active-sessions 2 +``` + +前台运行: + +```bash +life_codex_agent serve -config /etc/life_tools/life_codex_agent.json +``` + +agent 使用本机已安装并登录的 `codex`。如果 Codex 未登录,先在计算机器上完成 Codex 登录;不要用 mock 伪造成功。 + +## 本机模拟部署 + +```bash +mkdir -p /private/tmp/life_codex_e2e/{config,data,audit,attachments,web} +cp -R web/life_codex/dist/. /private/tmp/life_codex_e2e/web/ +``` + +server 配置写到 `/private/tmp/life_codex_e2e/config/server.json`,其中目录都指向 `/private/tmp/life_codex_e2e/`。agent 配置写到 `/private/tmp/life_codex_e2e/config/agent.json`。 + +启动顺序: + +```bash +./output/life_codex_server -config /private/tmp/life_codex_e2e/config/server.json +./output/life_codex_agent enroll -config /private/tmp/life_codex_e2e/config/agent.json -server http://127.0.0.1:8899 -token -name local -roots /Users/bytedance/go/src/github.com/mcoder2014/worktrees/life_codex_remote -attachment-dir /private/tmp/life_codex_e2e/attachments -max-active-sessions 2 +./output/life_codex_agent serve -config /private/tmp/life_codex_e2e/config/agent.json +``` + +验收重点: + +| 场景 | 期望 | +|---|---| +| 创建 Session 后连续发送两轮 | 同一个 Codex thread 上返回两次结果 | +| 两个 Session 并行运行 | 切换页面不打断后台运行 | +| 主 Session 运行时 `/btw` | fork Session 独立返回,主 Session 继续运行 | +| 粘贴图片提问 | agent 附件目录落盘,Codex 能读取图片内容 | +| 审计清理 | 未勾选确认时拒绝,确认后按日期删除 JSONL | + +## 已知限制 + +- V1 只支持私有网络、VPN 或 Tailscale;TLS 由反向代理提供。 +- 不内置 systemd/launchd,不自动升级 Codex CLI。 +- 不做完整文件树、diff 编辑器、插件管理或实时语音。 +- 当前本机 `codex app-server` 如果不支持 `thread/fork`,BTW 会退化为新线程;这不是完整上下文 fork。 +- 网页审批只保留配置字段,V1 仍主要依赖 `approvalPolicy=on-request` 和 `approvalsReviewer=auto_review`。 diff --git a/docs/install.md b/docs/install.md index 62ac636..72829e8 100644 --- a/docs/install.md +++ b/docs/install.md @@ -12,6 +12,7 @@ - 默认安装路径:可执行文件放到 `/usr/local/bin`,Python 工具文件放到 `/usr/local/lib/life_tools`。 - 默认配置路径:`/etc/life_tools`,可用 `--config-dir` 改变安装脚本写入位置。 - `codex_inspector` 是实验工具,使用 `cli/codex_inspector/install.sh` 单独安装;默认安装到 `$HOME/.local/bin`,不写系统目录。 +- `life_codex_server` 和 `life_codex_agent` 是实验工具,默认不安装;必须显式指定 `--tool`。 写入 `/usr/local`、`/etc/life_tools` 和 Linux 的 `/var/log` 时可能需要 `sudo`。脚本会在需要时调用 `sudo`,不会覆盖已经存在的配置文件。 @@ -26,6 +27,8 @@ | `video_subtitle` | `video_subtitle` | Python | 是 | `/etc/life_tools/video_subtitle.json` | | `file_share` | `file_share` | Go | 是 | `/etc/life_tools/file_share.json` | | `codex_inspector` | `codex_inspector` | Go | 否 | 无配置文件,默认只读 `~/.codex` | +| `life_codex_server` | `life_codex_server` | Go + Web | 否 | `/etc/life_tools/life_codex_server.json` | +| `life_codex_agent` | `life_codex_agent` | Go | 否 | `/etc/life_tools/life_codex_agent.json` | | `InterviewTimer` | `InterviewTimer.app` | SwiftPM macOS App | 否 | `~/Library/Application Support/InterviewTimer/` | ## 快速安装 @@ -43,6 +46,7 @@ ./install.sh --tool renameV1 --tool check_keywords ./install.sh --tools retry_exec,codex_hook_notify ./install.sh --tool file_share +./install.sh --tool life_codex_server --tool life_codex_agent ``` 安装到自定义前缀: @@ -59,6 +63,40 @@ 这只改变安装脚本写入示例配置的位置。部分工具源码里的默认配置路径仍是 `/etc/life_tools`,运行时需要用命令参数指定自定义配置路径。 +## life_codex + +`life_codex_server` 提供网页和中心服务,`life_codex_agent` 在每台计算机器前台运行并调用本机 `codex app-server --stdio`。 + +安装前先构建网页: + +```bash +npm --prefix web/life_codex install +npm --prefix web/life_codex run build +``` + +安装: + +```bash +./install.sh --tool life_codex_server --tool life_codex_agent +``` + +配置文件: + +```text +/etc/life_tools/life_codex_server.json +/etc/life_tools/life_codex_agent.json +``` + +运行: + +```bash +life_codex_server -config /etc/life_tools/life_codex_server.json +life_codex_agent enroll -config /etc/life_tools/life_codex_agent.json -server http://127.0.0.1:8899 -token -name local -roots /path/to/workspace +life_codex_agent serve -config /etc/life_tools/life_codex_agent.json +``` + +详细行为、审计风险和本机模拟部署见 [cli/life_codex.md](cli/life_codex.md)。 + ## file_share diff --git a/install.sh b/install.sh index fd0f5ba..37e2c1c 100755 --- a/install.sh +++ b/install.sh @@ -9,7 +9,7 @@ HOOKS_FILE="$HOME/.codex/hooks.json" OS_NAME="$(uname -s)" STABLE_TOOLS=(renameV1 check_keywords retry_exec codex_hook_notify video_subtitle file_share) -ALL_TOOLS=(renameV1 check_keywords retry_exec codex_hook_notify video_subtitle file_share) +ALL_TOOLS=(renameV1 check_keywords retry_exec codex_hook_notify video_subtitle file_share life_codex_server life_codex_agent) REQUESTED_TOOLS=() SELECTED_TOOLS=() INSTALL_ALL=0 @@ -38,7 +38,7 @@ Stable tools installed by default: renameV1, check_keywords, retry_exec, codex_hook_notify, video_subtitle, file_share All tool names: - renameV1, check_keywords, retry_exec, codex_hook_notify, video_subtitle, file_share + renameV1, check_keywords, retry_exec, codex_hook_notify, video_subtitle, file_share, life_codex_server, life_codex_agent Examples: ./install.sh @@ -47,6 +47,7 @@ Examples: ./install.sh --tool video_subtitle --with-python-deps ./install.sh --tool codex_hook_notify --install-codex-hook ./install.sh --tool file_share + ./install.sh --tool life_codex_server --tool life_codex_agent EOF } @@ -175,6 +176,12 @@ normalize_tool_name() { file|share|file-share|file_share) echo "file_share" ;; + life-codex-server|life_codex_server) + echo "life_codex_server" + ;; + life-codex-agent|life_codex_agent) + echo "life_codex_agent" + ;; *) return 1 ;; @@ -312,7 +319,7 @@ needs_go() { local tool for tool in "${SELECTED_TOOLS[@]}"; do case "$tool" in - renameV1|check_keywords|retry_exec|codex_hook_notify|file_share) + renameV1|check_keywords|retry_exec|codex_hook_notify|file_share|life_codex_server|life_codex_agent) return 0 ;; esac @@ -530,6 +537,29 @@ Examples: EOF } +install_life_codex_web() { + local dist_dir="$ROOT_DIR/web/life_codex/dist" + local target_dir="$PREFIX/share/life_tools/life_codex" + + if [ ! -d "$dist_dir" ]; then + echo "web/life_codex/dist not found; run: npm --prefix web/life_codex install && npm --prefix web/life_codex run build" >&2 + exit 1 + fi + copy_dir_contents "$dist_dir" "$target_dir" + echo "installed web assets: $target_dir" +} + +install_life_codex_server() { + install_go_tool life_codex_server life_codex_server ./cli/life_codex_server/... + install_life_codex_web + install_config_if_missing "$ROOT_DIR/sample/life_tools/life_codex_server.json" "$CONFIG_DIR/life_codex_server.json" +} + +install_life_codex_agent() { + install_go_tool life_codex_agent life_codex_agent ./cli/life_codex_agent/... + install_config_if_missing "$ROOT_DIR/sample/life_tools/life_codex_agent.json" "$CONFIG_DIR/life_codex_agent.json" +} + install_video_subtitle() { local lib_dir="$PREFIX/lib/life_tools/video_subtitle" local wrapper="$OUTPUT_DIR/video_subtitle" @@ -578,6 +608,12 @@ install_selected_tool() { file_share) install_file_share ;; + life_codex_server) + install_life_codex_server + ;; + life_codex_agent) + install_life_codex_agent + ;; *) echo "unsupported tool: $1" >&2 exit 1 diff --git a/internal/life_codex/agent_runtime.go b/internal/life_codex/agent_runtime.go new file mode 100644 index 0000000..4658adb --- /dev/null +++ b/internal/life_codex/agent_runtime.go @@ -0,0 +1,221 @@ +package life_codex + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "sync" + "time" +) + +type AgentRuntime struct { + config AgentConfig + client *http.Client + codex *AppServerClient +} + +func EnrollAgent(ctx context.Context, configPath string, config AgentConfig, enrollToken string) (EnrollResponse, error) { + req := EnrollRequest{ + Token: enrollToken, + Name: config.MachineName, + AllowedRoots: config.AllowedRoots, + CodexPath: config.CodexPath, + MaxActiveSessions: config.MaxActiveSessions, + } + var resp EnrollResponse + if err := postJSON(ctx, config.ServerURL, "/agent/enroll", req, &resp); err != nil { + return EnrollResponse{}, err + } + config.MachineID = resp.MachineID + config.AgentToken = resp.Token + if err := WriteAgentConfig(configPath, config); err != nil { + return EnrollResponse{}, err + } + return resp, nil +} + +func RunAgent(ctx context.Context, config AgentConfig) error { + if config.MachineID == "" || config.AgentToken == "" { + return fmt.Errorf("agent is not enrolled") + } + codex, err := NewAppServerClient(ctx, config.CodexPath) + if err != nil { + return err + } + defer codex.Close() + runtime := &AgentRuntime{ + config: config, + client: &http.Client{Timeout: 60 * time.Second}, + codex: codex, + } + limit := config.MaxActiveSessions + if limit <= 0 { + limit = 1 + } + sem := make(chan struct{}, limit) + var wg sync.WaitGroup + defer wg.Wait() + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + commands, err := runtime.poll(ctx) + if err != nil { + time.Sleep(time.Duration(config.PollSeconds) * time.Second) + continue + } + for _, command := range commands { + cmd := command + sem <- struct{}{} + wg.Add(1) + go func() { + defer wg.Done() + defer func() { <-sem }() + runtime.execute(ctx, cmd) + }() + } + time.Sleep(time.Duration(config.PollSeconds) * time.Second) + } +} + +func (a *AgentRuntime) poll(ctx context.Context) ([]AgentCommand, error) { + req := AgentPollRequest{ + MachineID: a.config.MachineID, + Token: a.config.AgentToken, + Name: a.config.MachineName, + AllowedRoots: a.config.AllowedRoots, + CodexPath: a.config.CodexPath, + MaxActiveSessions: a.config.MaxActiveSessions, + } + var resp struct { + Commands []AgentCommand `json:"commands"` + } + if err := a.post(ctx, "/agent/poll", req, &resp); err != nil { + return nil, err + } + return resp.Commands, nil +} + +func (a *AgentRuntime) execute(parent context.Context, command AgentCommand) { + ctx, cancel := context.WithTimeout(parent, 30*time.Minute) + defer cancel() + var err error + switch command.Type { + case CommandCreateSession: + var threadID string + threadID, err = a.codex.StartThread(ctx, command.CWD, command.ApprovalPolicy, command.ApprovalsReviewer) + if err == nil { + a.report(ctx, AgentReport{CommandID: command.ID, SessionID: command.SessionID, ThreadID: threadID, Status: "completed"}) + return + } + case CommandStartTurn: + err = a.executeTurn(ctx, command) + case CommandForkSession: + err = a.executeFork(ctx, command) + default: + err = fmt.Errorf("unknown command type: %s", command.Type) + } + if err != nil { + a.report(ctx, AgentReport{CommandID: command.ID, SessionID: command.SessionID, Status: "failed", Error: err.Error()}) + } +} + +func (a *AgentRuntime) executeFork(ctx context.Context, command AgentCommand) error { + threadID, err := a.codex.ForkThread(ctx, command.ThreadID, command.CWD, command.ApprovalPolicy, command.ApprovalsReviewer) + if err != nil { + return err + } + a.report(ctx, AgentReport{SessionID: command.SessionID, ThreadID: threadID}) + command.ThreadID = threadID + return a.executeTurn(ctx, command) +} + +func (a *AgentRuntime) executeTurn(ctx context.Context, command AgentCommand) error { + images, err := SaveImagePayloads(a.config.AttachmentDir, command.SessionID, command.ID, command.Images) + if err != nil { + return err + } + if len(images) > 0 { + a.report(ctx, AgentReport{ + SessionID: command.SessionID, + CommandID: command.ID, + Event: &Event{ + ID: mustRandomID("event"), + SessionID: command.SessionID, + MachineID: a.config.MachineID, + Type: EventImage, + Text: fmt.Sprintf("%d image attachment(s) saved on agent", len(images)), + Payload: mustMarshalRaw(ImageMetadata(images)), + CreatedUnix: nowUnix(), + }, + }) + } + err = a.codex.StartTurn(ctx, command.SessionID, a.config.MachineID, command.ThreadID, command.CWD, command.Text, command.Skill, images, command.ApprovalPolicy, command.ApprovalsReviewer, func(event Event) { + a.report(ctx, AgentReport{CommandID: command.ID, SessionID: command.SessionID, Event: &event}) + }) + if err != nil { + return err + } + a.report(ctx, AgentReport{CommandID: command.ID, SessionID: command.SessionID, Status: "completed"}) + return nil +} + +func (a *AgentRuntime) report(ctx context.Context, report AgentReport) { + report.MachineID = a.config.MachineID + report.Token = a.config.AgentToken + _ = a.post(ctx, "/agent/report", report, nil) +} + +func (a *AgentRuntime) post(ctx context.Context, path string, request any, response any) error { + return postJSONWithClient(ctx, a.client, a.config.ServerURL, path, request, response) +} + +func postJSON(ctx context.Context, baseURL string, path string, request any, response any) error { + return postJSONWithClient(ctx, &http.Client{Timeout: 60 * time.Second}, baseURL, path, request, response) +} + +func postJSONWithClient(ctx context.Context, client *http.Client, baseURL string, path string, request any, response any) error { + content, err := json.Marshal(request) + if err != nil { + return err + } + url := strings.TrimRight(baseURL, "/") + path + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(content)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + var body struct { + Error string `json:"error"` + } + _ = json.NewDecoder(resp.Body).Decode(&body) + if body.Error == "" { + body.Error = resp.Status + } + return errors.New(body.Error) + } + if response == nil { + return nil + } + return json.NewDecoder(resp.Body).Decode(response) +} + +func mustMarshalRaw(value any) json.RawMessage { + content, err := json.Marshal(value) + if err != nil { + return nil + } + return content +} diff --git a/internal/life_codex/attachments.go b/internal/life_codex/attachments.go new file mode 100644 index 0000000..7cdfe64 --- /dev/null +++ b/internal/life_codex/attachments.go @@ -0,0 +1,132 @@ +package life_codex + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" +) + +var allowedImageMIMEs = map[string]string{ + "image/png": ".png", + "image/jpeg": ".jpg", + "image/webp": ".webp", +} + +func ValidateImagePayloads(images []ImagePayload, maxCount int, maxBytes int64) error { + if len(images) == 0 { + return nil + } + if maxCount <= 0 { + maxCount = 5 + } + if maxBytes <= 0 { + maxBytes = 10 << 20 + } + if len(images) > maxCount { + return fmt.Errorf("too many images: %d > %d", len(images), maxCount) + } + for _, image := range images { + if image.DataBase64 == "" && image.LocalPath == "" { + return fmt.Errorf("image %q has no data", image.Name) + } + if image.DataBase64 != "" { + decoded, err := base64.StdEncoding.DecodeString(image.DataBase64) + if err != nil { + return fmt.Errorf("decode image %q: %w", image.Name, err) + } + if int64(len(decoded)) > maxBytes { + return fmt.Errorf("image %q is too large", image.Name) + } + contentType := image.ContentType + if contentType == "" { + contentType = http.DetectContentType(decoded) + } + if _, ok := allowedImageMIMEs[contentType]; !ok { + return fmt.Errorf("unsupported image content type: %s", contentType) + } + } + if strings.Contains(image.Name, "/") || strings.Contains(image.Name, "\\") || strings.Contains(image.Name, "..") { + return fmt.Errorf("unsafe image name: %s", image.Name) + } + } + return nil +} + +func SaveImagePayloads(root string, sessionID string, commandID string, images []ImagePayload) ([]ImagePayload, error) { + if len(images) == 0 { + return nil, nil + } + if root == "" { + return nil, fmt.Errorf("attachment dir is required") + } + targetDir := filepath.Join(root, safePathPart(sessionID), safePathPart(commandID)) + if err := os.MkdirAll(targetDir, 0700); err != nil { + return nil, err + } + out := make([]ImagePayload, 0, len(images)) + for i, image := range images { + if image.DataBase64 == "" { + out = append(out, image) + continue + } + decoded, err := base64.StdEncoding.DecodeString(image.DataBase64) + if err != nil { + return nil, err + } + contentType := image.ContentType + if contentType == "" { + contentType = http.DetectContentType(decoded) + } + ext, ok := allowedImageMIMEs[contentType] + if !ok { + return nil, fmt.Errorf("unsupported image content type: %s", contentType) + } + sum := sha256.Sum256(decoded) + sha := hex.EncodeToString(sum[:]) + name := safePathPart(image.Name) + if name == "" { + name = fmt.Sprintf("image-%d%s", i+1, ext) + } + if filepath.Ext(name) == "" { + name += ext + } + path := filepath.Join(targetDir, name) + if !PathInAllowedRoots(path, []string{targetDir}) { + return nil, fmt.Errorf("unsafe attachment path") + } + if err := os.WriteFile(path, decoded, 0600); err != nil { + return nil, err + } + image.ContentType = contentType + image.Size = int64(len(decoded)) + image.SHA256 = sha + image.LocalPath = path + image.DataBase64 = "" + out = append(out, image) + } + return out, nil +} + +func ImageMetadata(images []ImagePayload) []ImagePayload { + out := make([]ImagePayload, 0, len(images)) + for _, image := range images { + image.DataBase64 = "" + out = append(out, image) + } + return out +} + +func safePathPart(value string) string { + value = filepath.Base(strings.TrimSpace(value)) + value = strings.ReplaceAll(value, " ", "_") + value = strings.ReplaceAll(value, string(filepath.Separator), "_") + if value == "." || value == "/" || value == "\\" { + return "" + } + return value +} diff --git a/internal/life_codex/attachments_test.go b/internal/life_codex/attachments_test.go new file mode 100644 index 0000000..3ac70e2 --- /dev/null +++ b/internal/life_codex/attachments_test.go @@ -0,0 +1,51 @@ +package life_codex + +import ( + "encoding/base64" + "os" + "testing" +) + +var tinyPNG = []byte{ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, + 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, + 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, + 0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d, + 0xb0, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, + 0x44, 0xae, 0x42, 0x60, 0x82, +} + +func TestSaveImagePayloads(t *testing.T) { + image := ImagePayload{ + Name: "paste.png", + ContentType: "image/png", + DataBase64: base64.StdEncoding.EncodeToString(tinyPNG), + } + if err := ValidateImagePayloads([]ImagePayload{image}, 1, 1024); err != nil { + t.Fatalf("validate image: %v", err) + } + saved, err := SaveImagePayloads(t.TempDir(), "session", "cmd", []ImagePayload{image}) + if err != nil { + t.Fatalf("save image: %v", err) + } + if len(saved) != 1 || saved[0].LocalPath == "" || saved[0].DataBase64 != "" || saved[0].SHA256 == "" { + t.Fatalf("unexpected saved metadata: %+v", saved) + } + if _, err := os.Stat(saved[0].LocalPath); err != nil { + t.Fatalf("saved file missing: %v", err) + } +} + +func TestValidateImageRejectsTraversal(t *testing.T) { + image := ImagePayload{ + Name: "../secret.png", + ContentType: "image/png", + DataBase64: base64.StdEncoding.EncodeToString(tinyPNG), + } + if err := ValidateImagePayloads([]ImagePayload{image}, 1, 1024); err == nil { + t.Fatalf("expected traversal name to be rejected") + } +} diff --git a/internal/life_codex/audit.go b/internal/life_codex/audit.go new file mode 100644 index 0000000..f084646 --- /dev/null +++ b/internal/life_codex/audit.go @@ -0,0 +1,122 @@ +package life_codex + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "time" +) + +type AuditLogger struct { + dir string +} + +type AuditEntry struct { + TimeUnix int64 `json:"time_unix"` + Action string `json:"action"` + MachineID string `json:"machine_id,omitempty"` + SessionID string `json:"session_id,omitempty"` + CommandID string `json:"command_id,omitempty"` + Payload json.RawMessage `json:"payload,omitempty"` +} + +func NewAuditLogger(dir string) *AuditLogger { + return &AuditLogger{dir: dir} +} + +func (l *AuditLogger) Log(action string, machineID string, sessionID string, commandID string, payload any) error { + if l == nil || l.dir == "" { + return nil + } + if err := os.MkdirAll(l.dir, 0700); err != nil { + return err + } + content, err := json.Marshal(sanitizeAuditPayload(payload)) + if err != nil { + return err + } + entry := AuditEntry{ + TimeUnix: time.Now().Unix(), + Action: action, + MachineID: machineID, + SessionID: sessionID, + CommandID: commandID, + Payload: content, + } + line, err := json.Marshal(entry) + if err != nil { + return err + } + path := filepath.Join(l.dir, time.Now().Format("2006-01-02")+".jsonl") + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + if err != nil { + return err + } + defer file.Close() + _, err = file.Write(append(line, '\n')) + return err +} + +func ClearAuditBefore(dir string, before time.Time) (int, error) { + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return 0, nil + } + return 0, err + } + removed := 0 + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".jsonl" { + continue + } + if len(entry.Name()) < len("2006-01-02") { + continue + } + day, err := time.Parse("2006-01-02", entry.Name()[:len("2006-01-02")]) + if err != nil || !day.Before(before) { + continue + } + if err := os.Remove(filepath.Join(dir, entry.Name())); err != nil { + return removed, err + } + removed++ + } + return removed, nil +} + +func sanitizeAuditPayload(payload any) any { + content, err := json.Marshal(payload) + if err != nil { + return payload + } + var value any + if err := json.Unmarshal(content, &value); err != nil { + return payload + } + return removeImageData(value) +} + +func removeImageData(value any) any { + switch typed := value.(type) { + case map[string]any: + for key, child := range typed { + if strings.EqualFold(key, "data_base64") { + if child != nil && child != "" { + typed[key] = "" + } + continue + } + typed[key] = removeImageData(child) + } + return typed + case []any: + for i, child := range typed { + typed[i] = removeImageData(child) + } + return typed + default: + return value + } +} diff --git a/internal/life_codex/audit_test.go b/internal/life_codex/audit_test.go new file mode 100644 index 0000000..bbe7467 --- /dev/null +++ b/internal/life_codex/audit_test.go @@ -0,0 +1,52 @@ +package life_codex + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestAuditRedactsImageData(t *testing.T) { + dir := t.TempDir() + logger := NewAuditLogger(dir) + err := logger.Log("image", "machine", "session", "cmd", map[string]any{ + "images": []ImagePayload{{Name: "a.png", DataBase64: "raw-image"}}, + }) + if err != nil { + t.Fatalf("log audit: %v", err) + } + files, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read audit dir: %v", err) + } + content, err := os.ReadFile(filepath.Join(dir, files[0].Name())) + if err != nil { + t.Fatalf("read audit file: %v", err) + } + if strings.Contains(string(content), "raw-image") { + t.Fatalf("audit contains image data: %s", content) + } +} + +func TestClearAuditBefore(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "2026-01-01.jsonl"), []byte("{}\n"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "bad.jsonl"), []byte("{}\n"), 0600); err != nil { + t.Fatal(err) + } + before, _ := time.Parse("2006-01-02", "2026-01-02") + removed, err := ClearAuditBefore(dir, before) + if err != nil { + t.Fatalf("clear audit: %v", err) + } + if removed != 1 { + t.Fatalf("removed = %d, want 1", removed) + } + if _, err := os.Stat(filepath.Join(dir, "bad.jsonl")); err != nil { + t.Fatalf("bad filename should be preserved: %v", err) + } +} diff --git a/internal/life_codex/codex_client.go b/internal/life_codex/codex_client.go new file mode 100644 index 0000000..8ee17c6 --- /dev/null +++ b/internal/life_codex/codex_client.go @@ -0,0 +1,489 @@ +package life_codex + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os/exec" + "strings" + "sync" + "time" +) + +type AppServerClient struct { + cmd *exec.Cmd + stdin io.WriteCloser + waiters map[int]chan rpcMessage + subs map[chan rpcMessage]struct{} + nextID int + writeMu sync.Mutex + mu sync.Mutex + closed bool + closeOnce sync.Once +} + +type rpcMessage struct { + JSONRPC string `json:"jsonrpc,omitempty"` + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +type rpcError struct { + Code int `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +func NewAppServerClient(ctx context.Context, codexPath string) (*AppServerClient, error) { + if codexPath == "" { + codexPath = "codex" + } + cmd := exec.CommandContext(ctx, codexPath, "app-server", "--stdio") + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, err + } + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + if err := cmd.Start(); err != nil { + return nil, err + } + client := &AppServerClient{ + cmd: cmd, + stdin: stdin, + waiters: map[int]chan rpcMessage{}, + subs: map[chan rpcMessage]struct{}{}, + nextID: 1, + } + go client.readStdout(stdout) + go client.drainStderr(stderr) + initCtx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + if _, err := client.Request(initCtx, "initialize", map[string]any{ + "clientInfo": map[string]string{"name": "life_codex_agent", "version": "0.1"}, + "capabilities": map[string]any{}, + }); err != nil { + _ = client.Close() + return nil, err + } + return client, nil +} + +func (c *AppServerClient) Close() error { + var err error + c.closeOnce.Do(func() { + c.mu.Lock() + c.closed = true + for id, ch := range c.waiters { + delete(c.waiters, id) + close(ch) + } + for ch := range c.subs { + delete(c.subs, ch) + close(ch) + } + c.mu.Unlock() + if c.stdin != nil { + err = c.stdin.Close() + } + if c.cmd != nil && c.cmd.Process != nil { + _ = c.cmd.Process.Kill() + _ = c.cmd.Wait() + } + }) + return err +} + +func (c *AppServerClient) Request(ctx context.Context, method string, params any) (json.RawMessage, error) { + content, err := json.Marshal(params) + if err != nil { + return nil, err + } + id, ch, err := c.nextRequest() + if err != nil { + return nil, err + } + req := map[string]any{ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": json.RawMessage(content), + } + line, err := json.Marshal(req) + if err != nil { + c.removeWaiter(id) + return nil, err + } + c.writeMu.Lock() + _, err = c.stdin.Write(append(line, '\n')) + c.writeMu.Unlock() + if err != nil { + c.removeWaiter(id) + return nil, err + } + select { + case <-ctx.Done(): + c.removeWaiter(id) + return nil, ctx.Err() + case msg, ok := <-ch: + if !ok { + return nil, fmt.Errorf("codex app-server closed") + } + if msg.Error != nil { + return nil, fmt.Errorf("codex rpc %s failed: %s", method, msg.Error.Message) + } + return msg.Result, nil + } +} + +func (c *AppServerClient) StartThread(ctx context.Context, cwd string, approvalPolicy string, approvalsReviewer string) (string, error) { + params := map[string]any{ + "cwd": cwd, + "approvalPolicy": approvalPolicy, + "approvalsReviewer": approvalsReviewer, + "sandbox": "workspace-write", + "config": map[string]any{ + "features": map[string]any{ + "multi_agent": false, + "multi_agent_v2": map[string]any{ + "enabled": false, + }, + }, + }, + } + result, err := c.Request(ctx, "thread/start", params) + if err != nil { + return "", err + } + return extractThreadID(result) +} + +func (c *AppServerClient) ForkThread(ctx context.Context, sourceThreadID string, cwd string, approvalPolicy string, approvalsReviewer string) (string, error) { + params := map[string]any{ + "threadId": sourceThreadID, + "cwd": cwd, + "approvalPolicy": approvalPolicy, + "approvalsReviewer": approvalsReviewer, + "sandbox": "workspace-write", + } + result, err := c.Request(ctx, "thread/fork", params) + if err == nil { + return extractThreadID(result) + } + return c.StartThread(ctx, cwd, approvalPolicy, approvalsReviewer) +} + +func (c *AppServerClient) StartTurn(ctx context.Context, sessionID string, machineID string, threadID string, cwd string, text string, skill *SkillInput, images []ImagePayload, approvalPolicy string, approvalsReviewer string, onEvent func(Event)) error { + sub := c.subscribe() + defer c.unsubscribe(sub) + input := buildTurnInput(text, skill, images) + result, err := c.Request(ctx, "turn/start", map[string]any{ + "threadId": threadID, + "input": input, + "cwd": cwd, + "approvalPolicy": approvalPolicy, + "approvalsReviewer": approvalsReviewer, + "sandbox": "workspace-write", + }) + if err != nil { + return err + } + turnID := extractTurnID(result) + for { + select { + case <-ctx.Done(): + return ctx.Err() + case msg, ok := <-sub: + if !ok { + return fmt.Errorf("codex app-server closed") + } + if event := codexNotificationEvent(sessionID, machineID, msg); event != nil && onEvent != nil { + onEvent(*event) + } + if msg.Method == "turn/completed" && notificationThreadID(msg.Params) == threadID { + if turnID == "" || notificationTurnID(msg.Params) == "" || notificationTurnID(msg.Params) == turnID { + if status := notificationTurnStatus(msg.Params); status != "" && status != "completed" { + if message := notificationTurnError(msg.Params); message != "" { + return fmt.Errorf("turn %s: %s", status, message) + } + return fmt.Errorf("turn %s", status) + } + return nil + } + } + if msg.Method == "turn/aborted" && notificationThreadID(msg.Params) == threadID { + return fmt.Errorf("turn aborted") + } + } + } +} + +func (c *AppServerClient) nextRequest() (int, chan rpcMessage, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.closed { + return 0, nil, fmt.Errorf("codex app-server closed") + } + id := c.nextID + c.nextID++ + ch := make(chan rpcMessage, 1) + c.waiters[id] = ch + return id, ch, nil +} + +func (c *AppServerClient) removeWaiter(id int) { + c.mu.Lock() + delete(c.waiters, id) + c.mu.Unlock() +} + +func (c *AppServerClient) subscribe() chan rpcMessage { + ch := make(chan rpcMessage, 64) + c.mu.Lock() + c.subs[ch] = struct{}{} + c.mu.Unlock() + return ch +} + +func (c *AppServerClient) unsubscribe(ch chan rpcMessage) { + c.mu.Lock() + if _, ok := c.subs[ch]; ok { + delete(c.subs, ch) + close(ch) + } + c.mu.Unlock() +} + +func (c *AppServerClient) readStdout(stdout io.Reader) { + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 64*1024), 8*1024*1024) + for scanner.Scan() { + line := bytes.TrimSpace(scanner.Bytes()) + if len(line) == 0 { + continue + } + var msg rpcMessage + if err := json.Unmarshal(line, &msg); err != nil { + continue + } + if msg.Method != "" && len(msg.ID) > 0 { + c.replyToServerRequest(msg) + continue + } + if msg.Method != "" { + c.publish(msg) + continue + } + if len(msg.ID) > 0 { + var id int + if err := json.Unmarshal(msg.ID, &id); err != nil { + continue + } + c.mu.Lock() + ch := c.waiters[id] + delete(c.waiters, id) + c.mu.Unlock() + if ch != nil { + ch <- msg + close(ch) + } + } + } + _ = c.Close() +} + +func (c *AppServerClient) drainStderr(stderr io.Reader) { + _, _ = io.Copy(io.Discard, stderr) +} + +func (c *AppServerClient) publish(msg rpcMessage) { + c.mu.Lock() + defer c.mu.Unlock() + for ch := range c.subs { + select { + case ch <- msg: + default: + } + } +} + +func (c *AppServerClient) replyToServerRequest(msg rpcMessage) { + resp := map[string]any{ + "jsonrpc": "2.0", + "id": msg.ID, + "result": map[string]any{"action": "decline"}, + } + line, err := json.Marshal(resp) + if err != nil { + return + } + c.writeMu.Lock() + _, _ = c.stdin.Write(append(line, '\n')) + c.writeMu.Unlock() +} + +func buildTurnInput(text string, skill *SkillInput, images []ImagePayload) []map[string]any { + if skill != nil && skill.Name != "" { + if skill.Path != "" { + text = fmt.Sprintf("[$%s](%s)\n%s", skill.Name, skill.Path, text) + } else { + text = fmt.Sprintf("[$%s]\n%s", skill.Name, text) + } + } + input := []map[string]any{{"type": "text", "text": text, "text_elements": []any{}}} + for _, image := range images { + if image.LocalPath == "" { + continue + } + input = append(input, map[string]any{"type": "localImage", "path": image.LocalPath}) + } + return input +} + +func extractThreadID(result json.RawMessage) (string, error) { + var decoded struct { + Thread struct { + ID string `json:"id"` + SessionID string `json:"sessionId"` + } `json:"thread"` + ID string `json:"id"` + } + if err := json.Unmarshal(result, &decoded); err != nil { + return "", err + } + if decoded.Thread.ID != "" { + return decoded.Thread.ID, nil + } + if decoded.Thread.SessionID != "" { + return decoded.Thread.SessionID, nil + } + if decoded.ID != "" { + return decoded.ID, nil + } + return "", fmt.Errorf("codex response did not include a thread id") +} + +func extractTurnID(result json.RawMessage) string { + var decoded struct { + Turn struct { + ID string `json:"id"` + } `json:"turn"` + ID string `json:"id"` + } + _ = json.Unmarshal(result, &decoded) + if decoded.Turn.ID != "" { + return decoded.Turn.ID + } + return decoded.ID +} + +func codexNotificationEvent(sessionID string, machineID string, msg rpcMessage) *Event { + switch msg.Method { + case "item/agentMessage/delta": + text := notificationString(msg.Params, "delta") + if text == "" { + text = notificationString(msg.Params, "text") + } + if text == "" { + return nil + } + return &Event{ID: mustRandomID("event"), SessionID: sessionID, MachineID: machineID, Type: EventAssistant, Text: text, Payload: msg.Params, CreatedUnix: nowUnix()} + case "item/completed": + item := notificationObject(msg.Params, "item") + itemType, _ := item["type"].(string) + switch itemType { + case "agentMessage": + text, _ := item["text"].(string) + if text == "" { + return nil + } + return &Event{ID: mustRandomID("event"), SessionID: sessionID, MachineID: machineID, Type: EventAssistant, Text: text, Payload: msg.Params, CreatedUnix: nowUnix()} + case "commandExecution", "mcpToolCall", "dynamicToolCall": + content, _ := json.Marshal(item) + return &Event{ID: mustRandomID("event"), SessionID: sessionID, MachineID: machineID, Type: EventTool, Text: itemType + " completed", Payload: content, CreatedUnix: nowUnix()} + } + case "item/started": + item := notificationObject(msg.Params, "item") + itemType, _ := item["type"].(string) + if itemType == "commandExecution" || itemType == "mcpToolCall" || itemType == "dynamicToolCall" { + content, _ := json.Marshal(item) + return &Event{ID: mustRandomID("event"), SessionID: sessionID, MachineID: machineID, Type: EventTool, Text: itemType + " started", Payload: content, CreatedUnix: nowUnix()} + } + case "turn/completed": + return &Event{ID: mustRandomID("event"), SessionID: sessionID, MachineID: machineID, Type: EventInfo, Text: "turn completed", Payload: msg.Params, CreatedUnix: nowUnix()} + case "turn/aborted": + return &Event{ID: mustRandomID("event"), SessionID: sessionID, MachineID: machineID, Type: EventError, Text: "turn aborted", Payload: msg.Params, CreatedUnix: nowUnix()} + case "warning": + message := notificationString(msg.Params, "message") + if message == "" { + message = "warning" + } + return &Event{ID: mustRandomID("event"), SessionID: sessionID, MachineID: machineID, Type: EventInfo, Text: message, Payload: msg.Params, CreatedUnix: nowUnix()} + } + if strings.HasPrefix(msg.Method, "mcp/") || strings.HasPrefix(msg.Method, "serverRequest/") { + return &Event{ID: mustRandomID("event"), SessionID: sessionID, MachineID: machineID, Type: EventTool, Text: msg.Method, Payload: msg.Params, CreatedUnix: nowUnix()} + } + return nil +} + +func notificationThreadID(params json.RawMessage) string { + value := notificationString(params, "threadId") + if value != "" { + return value + } + return notificationString(params, "thread_id") +} + +func notificationTurnID(params json.RawMessage) string { + item := notificationObject(params, "turn") + if id, _ := item["id"].(string); id != "" { + return id + } + return notificationString(params, "turnId") +} + +func notificationTurnStatus(params json.RawMessage) string { + item := notificationObject(params, "turn") + if status, _ := item["status"].(string); status != "" { + return status + } + return notificationString(params, "status") +} + +func notificationTurnError(params json.RawMessage) string { + item := notificationObject(params, "turn") + errValue, _ := item["error"].(map[string]any) + if message, _ := errValue["message"].(string); message != "" { + return message + } + return notificationString(params, "error") +} + +func notificationString(params json.RawMessage, key string) string { + var decoded map[string]any + if err := json.Unmarshal(params, &decoded); err != nil { + return "" + } + value, _ := decoded[key].(string) + return value +} + +func notificationObject(params json.RawMessage, key string) map[string]any { + var decoded map[string]any + if err := json.Unmarshal(params, &decoded); err != nil { + return nil + } + value, _ := decoded[key].(map[string]any) + return value +} diff --git a/internal/life_codex/codex_client_test.go b/internal/life_codex/codex_client_test.go new file mode 100644 index 0000000..42b52c7 --- /dev/null +++ b/internal/life_codex/codex_client_test.go @@ -0,0 +1,26 @@ +package life_codex + +import ( + "encoding/json" + "testing" +) + +func TestExtractThreadID(t *testing.T) { + id, err := extractThreadID(json.RawMessage(`{"thread":{"id":"thread-1"}}`)) + if err != nil { + t.Fatalf("extract thread id: %v", err) + } + if id != "thread-1" { + t.Fatalf("id = %q", id) + } +} + +func TestCodexNotificationEvent(t *testing.T) { + event := codexNotificationEvent("session", "machine", rpcMessage{ + Method: "item/completed", + Params: json.RawMessage(`{"item":{"type":"agentMessage","text":"done"}}`), + }) + if event == nil || event.Type != EventAssistant || event.Text != "done" { + t.Fatalf("unexpected event: %+v", event) + } +} diff --git a/internal/life_codex/config.go b/internal/life_codex/config.go new file mode 100644 index 0000000..e213d31 --- /dev/null +++ b/internal/life_codex/config.go @@ -0,0 +1,148 @@ +package life_codex + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +type ServerConfig struct { + Addr string `json:"addr"` + AdminToken string `json:"admin_token"` + DataDir string `json:"data_dir"` + AuditDir string `json:"audit_dir"` + WebRoot string `json:"web_root"` + MaxImageBytes int64 `json:"max_image_bytes"` + MaxImagesPerTurn int `json:"max_images_per_turn"` + DefaultApprovalPolicy string `json:"default_approval_policy"` + DefaultApprovalsReviewer string `json:"default_approvals_reviewer"` +} + +type AgentConfig struct { + ServerURL string `json:"server_url"` + MachineID string `json:"machine_id"` + AgentToken string `json:"agent_token"` + MachineName string `json:"machine_name"` + AllowedRoots []string `json:"allowed_roots"` + CodexPath string `json:"codex_path"` + AttachmentDir string `json:"attachment_dir"` + MaxActiveSessions int `json:"max_active_sessions"` + PollSeconds int `json:"poll_seconds"` +} + +func DefaultServerConfig() ServerConfig { + return ServerConfig{ + Addr: DefaultServerAddr, + DataDir: "/var/lib/life_tools/life_codex_server", + AuditDir: "/var/log/life_tools/life_codex_server", + WebRoot: "/usr/local/share/life_tools/life_codex", + MaxImageBytes: 10 << 20, + MaxImagesPerTurn: 5, + DefaultApprovalPolicy: "on-request", + DefaultApprovalsReviewer: "auto_review", + } +} + +func DefaultAgentConfig() AgentConfig { + return AgentConfig{ + ServerURL: "http://127.0.0.1:8899", + CodexPath: "codex", + AttachmentDir: "/var/lib/life_tools/life_codex_agent/attachments", + MaxActiveSessions: 2, + PollSeconds: 1, + } +} + +func LoadServerConfig(path string) (ServerConfig, error) { + config := DefaultServerConfig() + if strings.TrimSpace(path) == "" { + return config, nil + } + content, err := os.ReadFile(path) + if err != nil { + return ServerConfig{}, err + } + if err := json.Unmarshal(content, &config); err != nil { + return ServerConfig{}, err + } + if config.AdminToken == "" { + return ServerConfig{}, fmt.Errorf("admin_token is required") + } + if config.MaxImageBytes <= 0 { + config.MaxImageBytes = 10 << 20 + } + if config.MaxImagesPerTurn <= 0 { + config.MaxImagesPerTurn = 5 + } + if config.DefaultApprovalPolicy == "" { + config.DefaultApprovalPolicy = "on-request" + } + if config.DefaultApprovalsReviewer == "" { + config.DefaultApprovalsReviewer = "auto_review" + } + return config, nil +} + +func LoadAgentConfig(path string) (AgentConfig, error) { + config := DefaultAgentConfig() + if strings.TrimSpace(path) == "" { + return config, nil + } + content, err := os.ReadFile(path) + if err != nil { + return AgentConfig{}, err + } + if err := json.Unmarshal(content, &config); err != nil { + return AgentConfig{}, err + } + if config.CodexPath == "" { + config.CodexPath = "codex" + } + if config.PollSeconds <= 0 { + config.PollSeconds = 1 + } + if config.MaxActiveSessions <= 0 { + config.MaxActiveSessions = 1 + } + return config, nil +} + +func WriteAgentConfig(path string, config AgentConfig) error { + content, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + return os.WriteFile(path, append(content, '\n'), 0600) +} + +func PathInAllowedRoots(path string, roots []string) bool { + if path == "" || len(roots) == 0 { + return false + } + cleanPath, err := filepath.Abs(path) + if err != nil { + return false + } + for _, root := range roots { + if strings.TrimSpace(root) == "" { + continue + } + cleanRoot, err := filepath.Abs(root) + if err != nil { + continue + } + if cleanPath == cleanRoot { + return true + } + rel, err := filepath.Rel(cleanRoot, cleanPath) + if err == nil && rel != "." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != ".." { + return true + } + } + return false +} diff --git a/internal/life_codex/config_test.go b/internal/life_codex/config_test.go new file mode 100644 index 0000000..d749c45 --- /dev/null +++ b/internal/life_codex/config_test.go @@ -0,0 +1,21 @@ +package life_codex + +import ( + "path/filepath" + "testing" +) + +func TestPathInAllowedRoots(t *testing.T) { + root := t.TempDir() + inside := filepath.Join(root, "project", "file.txt") + outside := filepath.Join(t.TempDir(), "file.txt") + if !PathInAllowedRoots(inside, []string{root}) { + t.Fatalf("inside path rejected") + } + if PathInAllowedRoots(outside, []string{root}) { + t.Fatalf("outside path accepted") + } + if PathInAllowedRoots(filepath.Join(root, ".."), []string{root}) { + t.Fatalf("parent traversal accepted") + } +} diff --git a/internal/life_codex/server_http.go b/internal/life_codex/server_http.go new file mode 100644 index 0000000..6fde5c9 --- /dev/null +++ b/internal/life_codex/server_http.go @@ -0,0 +1,338 @@ +package life_codex + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +type HTTPServer struct { + config ServerConfig + store *StateStore + mux *http.ServeMux +} + +func NewHTTPServer(config ServerConfig, store *StateStore) *HTTPServer { + server := &HTTPServer{config: config, store: store, mux: http.NewServeMux()} + server.routes() + return server +} + +func (s *HTTPServer) Handler() http.Handler { + return s.mux +} + +func (s *HTTPServer) routes() { + s.mux.HandleFunc("/api/state", s.requireAdmin(s.handleState)) + s.mux.HandleFunc("/api/events", s.handleEvents) + s.mux.HandleFunc("/api/enrollment-tokens", s.requireAdmin(s.handleEnrollmentTokens)) + s.mux.HandleFunc("/api/sessions", s.requireAdmin(s.handleSessions)) + s.mux.HandleFunc("/api/sessions/", s.requireAdmin(s.handleSessionAction)) + s.mux.HandleFunc("/api/audit/clear", s.requireAdmin(s.handleAuditClear)) + s.mux.HandleFunc("/agent/enroll", s.handleAgentEnroll) + s.mux.HandleFunc("/agent/poll", s.handleAgentPoll) + s.mux.HandleFunc("/agent/report", s.handleAgentReport) + s.mux.HandleFunc("/", s.handleStatic) +} + +func (s *HTTPServer) handleState(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + writeJSON(w, http.StatusOK, s.store.Snapshot()) +} + +func (s *HTTPServer) handleEvents(w http.ResponseWriter, r *http.Request) { + if !s.validAdmin(r) { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + flusher, ok := w.(http.Flusher) + if !ok { + writeError(w, http.StatusInternalServerError, "streaming is unsupported") + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + ch, cancel := s.store.Subscribe() + defer cancel() + writeSSE(w, s.store.Snapshot()) + flusher.Flush() + ticker := time.NewTicker(20 * time.Second) + defer ticker.Stop() + for { + select { + case <-r.Context().Done(): + return + case <-ch: + writeSSE(w, s.store.Snapshot()) + flusher.Flush() + case <-ticker.C: + _, _ = fmt.Fprint(w, ": ping\n\n") + flusher.Flush() + } + } +} + +func (s *HTTPServer) handleEnrollmentTokens(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + token, err := s.store.CreateEnrollToken() + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"token": token, "expires_in_seconds": 900}) +} + +func (s *HTTPServer) handleSessions(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var req struct { + MachineID string `json:"machine_id"` + CWD string `json:"cwd"` + } + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + session, err := s.store.CreateSession(req.MachineID, req.CWD) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, session) +} + +func (s *HTTPServer) handleSessionAction(w http.ResponseWriter, r *http.Request) { + rest := strings.TrimPrefix(r.URL.Path, "/api/sessions/") + parts := strings.Split(strings.Trim(rest, "/"), "/") + if len(parts) != 2 { + writeError(w, http.StatusNotFound, "not found") + return + } + sessionID := parts[0] + action := parts[1] + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + switch action { + case "turn": + var req struct { + Text string `json:"text"` + Skill *SkillInput `json:"skill"` + Images []ImagePayload `json:"images"` + } + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if strings.TrimSpace(req.Text) == "" && len(req.Images) == 0 { + writeError(w, http.StatusBadRequest, "text or image is required") + return + } + if err := ValidateImagePayloads(req.Images, s.config.MaxImagesPerTurn, s.config.MaxImageBytes); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if err := s.store.StartTurn(sessionID, req.Text, req.Skill, req.Images); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) + case "fork": + var req struct { + Text string `json:"text"` + } + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if strings.TrimSpace(req.Text) == "" { + writeError(w, http.StatusBadRequest, "text is required") + return + } + session, err := s.store.ForkSession(sessionID, req.Text) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, session) + default: + writeError(w, http.StatusNotFound, "not found") + } +} + +func (s *HTTPServer) handleAuditClear(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var req struct { + Before string `json:"before"` + Confirm bool `json:"confirm"` + } + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if !req.Confirm { + writeError(w, http.StatusBadRequest, "confirm is required") + return + } + before, err := time.Parse("2006-01-02", req.Before) + if err != nil { + writeError(w, http.StatusBadRequest, "before must be YYYY-MM-DD") + return + } + removed, err := s.store.ClearAudit(before) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"removed": removed}) +} + +func (s *HTTPServer) handleAgentEnroll(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var req EnrollRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + resp, err := s.store.Enroll(req) + if err != nil { + writeError(w, http.StatusUnauthorized, err.Error()) + return + } + writeJSON(w, http.StatusOK, resp) +} + +func (s *HTTPServer) handleAgentPoll(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var req AgentPollRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + commands, err := s.store.Poll(req) + if err != nil { + writeError(w, http.StatusUnauthorized, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"commands": commands}) +} + +func (s *HTTPServer) handleAgentReport(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var report AgentReport + if err := decodeJSON(r, &report); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if err := s.store.Report(report); err != nil { + writeError(w, http.StatusUnauthorized, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +func (s *HTTPServer) handleStatic(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" || r.URL.Path == "/index.html" { + s.serveStaticFile(w, r, "index.html") + return + } + clean := filepath.Clean(strings.TrimPrefix(r.URL.Path, "/")) + if strings.HasPrefix(clean, "..") { + writeError(w, http.StatusNotFound, "not found") + return + } + if s.serveStaticFile(w, r, clean) { + return + } + s.serveStaticFile(w, r, "index.html") +} + +func (s *HTTPServer) serveStaticFile(w http.ResponseWriter, r *http.Request, name string) bool { + if s.config.WebRoot == "" { + writeError(w, http.StatusNotFound, "web root is not configured") + return false + } + path := filepath.Join(s.config.WebRoot, name) + if !PathInAllowedRoots(path, []string{s.config.WebRoot}) { + writeError(w, http.StatusNotFound, "not found") + return false + } + info, err := os.Stat(path) + if err != nil || info.IsDir() { + if name == "index.html" { + writeError(w, http.StatusNotFound, "web build is missing") + } + return false + } + http.ServeFile(w, r, path) + return true +} + +func (s *HTTPServer) requireAdmin(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !s.validAdmin(r) { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + next(w, r) + } +} + +func (s *HTTPServer) validAdmin(r *http.Request) bool { + token := r.Header.Get("X-Life-Codex-Token") + if token == "" { + token = r.URL.Query().Get("token") + } + return token != "" && token == s.config.AdminToken +} + +func decodeJSON(r *http.Request, target any) error { + defer r.Body.Close() + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + return decoder.Decode(target) +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]any{"error": message}) +} + +func writeSSE(w http.ResponseWriter, value any) { + content, err := json.Marshal(value) + if err != nil { + return + } + _, _ = fmt.Fprintf(w, "event: state\ndata: %s\n\n", content) +} diff --git a/internal/life_codex/state.go b/internal/life_codex/state.go new file mode 100644 index 0000000..5516ae6 --- /dev/null +++ b/internal/life_codex/state.go @@ -0,0 +1,538 @@ +package life_codex + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "sync" + "time" +) + +type StateStore struct { + mu sync.Mutex + path string + machines map[string]Machine + agentTokens map[string]string + enrollTokens map[string]int64 + sessions map[string]Session + commands map[string][]AgentCommand + audit *AuditLogger + subscribers map[chan struct{}]struct{} + config ServerConfig +} + +type persistedState struct { + Machines map[string]Machine `json:"machines"` + AgentTokens map[string]string `json:"agent_tokens"` + Sessions map[string]Session `json:"sessions"` +} + +func NewStateStore(config ServerConfig) (*StateStore, error) { + if err := os.MkdirAll(config.DataDir, 0700); err != nil { + return nil, err + } + store := &StateStore{ + path: filepath.Join(config.DataDir, "state.json"), + machines: map[string]Machine{}, + agentTokens: map[string]string{}, + enrollTokens: map[string]int64{}, + sessions: map[string]Session{}, + commands: map[string][]AgentCommand{}, + audit: NewAuditLogger(config.AuditDir), + subscribers: map[chan struct{}]struct{}{}, + config: config, + } + if err := store.load(); err != nil { + return nil, err + } + return store, nil +} + +func (s *StateStore) CreateEnrollToken() (string, error) { + token, err := randomID("enroll") + if err != nil { + return "", err + } + s.mu.Lock() + s.enrollTokens[token] = time.Now().Add(15 * time.Minute).Unix() + s.mu.Unlock() + s.notify() + _ = s.audit.Log("enroll_token_created", "", "", "", map[string]any{"token_suffix": suffix(token)}) + return token, nil +} + +func (s *StateStore) Enroll(req EnrollRequest) (EnrollResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + expires, ok := s.enrollTokens[req.Token] + if !ok || expires < nowUnix() { + return EnrollResponse{}, fmt.Errorf("invalid enrollment token") + } + delete(s.enrollTokens, req.Token) + machineID, err := randomID("machine") + if err != nil { + return EnrollResponse{}, err + } + agentToken, err := randomID("agent") + if err != nil { + return EnrollResponse{}, err + } + if req.MaxActiveSessions <= 0 { + req.MaxActiveSessions = 1 + } + s.machines[machineID] = Machine{ + ID: machineID, + Name: req.Name, + Status: "online", + AllowedRoots: req.AllowedRoots, + CodexPath: req.CodexPath, + MaxActiveSessions: req.MaxActiveSessions, + LastSeenUnix: nowUnix(), + } + s.agentTokens[machineID] = agentToken + if err := s.saveLocked(); err != nil { + return EnrollResponse{}, err + } + _ = s.audit.Log("machine_enrolled", machineID, "", "", s.machines[machineID]) + s.notifyLocked() + return EnrollResponse{MachineID: machineID, Token: agentToken}, nil +} + +func (s *StateStore) Poll(req AgentPollRequest) ([]AgentCommand, error) { + s.mu.Lock() + defer s.mu.Unlock() + if !s.validAgentLocked(req.MachineID, req.Token) { + return nil, fmt.Errorf("invalid agent token") + } + machine := s.machines[req.MachineID] + machine.Status = "online" + machine.LastSeenUnix = nowUnix() + machine.Name = first(machine.Name, req.Name) + machine.AllowedRoots = req.AllowedRoots + machine.CodexPath = req.CodexPath + if req.MaxActiveSessions > 0 { + machine.MaxActiveSessions = req.MaxActiveSessions + } + s.machines[req.MachineID] = machine + commands := s.commands[req.MachineID] + s.commands[req.MachineID] = nil + _ = s.saveLocked() + return commands, nil +} + +func (s *StateStore) Report(report AgentReport) error { + s.mu.Lock() + defer s.mu.Unlock() + if !s.validAgentLocked(report.MachineID, report.Token) { + return fmt.Errorf("invalid agent token") + } + if report.ThreadID != "" && report.SessionID != "" { + session := s.sessions[report.SessionID] + session.ThreadID = report.ThreadID + session.UpdatedUnix = nowUnix() + if session.Status == SessionCreating { + session.Status = SessionIdle + } + s.sessions[session.ID] = session + } + if report.Event != nil { + s.appendEventLocked(*report.Event) + _ = s.audit.Log("agent_event", report.MachineID, report.SessionID, report.CommandID, report.Event) + } + if report.CommandID != "" && (report.Status == "completed" || report.Status == "failed") { + s.finishCommandLocked(report) + } + _ = s.saveLocked() + s.notifyLocked() + return nil +} + +func (s *StateStore) Snapshot() StateSnapshot { + s.mu.Lock() + defer s.mu.Unlock() + machines := make([]Machine, 0, len(s.machines)) + for _, machine := range s.machines { + if nowUnix()-machine.LastSeenUnix > 10 { + machine.Status = "offline" + } + machines = append(machines, machine) + } + sort.Slice(machines, func(i, j int) bool { return machines[i].Name < machines[j].Name }) + sessions := make([]Session, 0, len(s.sessions)) + for _, session := range s.sessions { + sessions = append(sessions, session) + } + sort.Slice(sessions, func(i, j int) bool { return sessions[i].UpdatedUnix > sessions[j].UpdatedUnix }) + return StateSnapshot{Machines: machines, Sessions: sessions, NowUnix: nowUnix()} +} + +func (s *StateStore) CreateSession(machineID string, cwd string) (Session, error) { + s.mu.Lock() + defer s.mu.Unlock() + machine, ok := s.machines[machineID] + if !ok { + return Session{}, fmt.Errorf("machine not found") + } + if !PathInAllowedRoots(cwd, machine.AllowedRoots) { + return Session{}, fmt.Errorf("cwd is outside allowed roots") + } + id, err := randomID("session") + if err != nil { + return Session{}, err + } + session := Session{ + ID: id, + MachineID: machineID, + Title: "New session", + CWD: cwd, + Status: SessionCreating, + CreatedUnix: nowUnix(), + UpdatedUnix: nowUnix(), + } + s.sessions[id] = session + command := s.baseCommandLocked(machineID, id, CommandCreateSession) + command.CWD = cwd + s.commands[machineID] = append(s.commands[machineID], command) + _ = s.audit.Log("session_create_requested", machineID, id, command.ID, command) + _ = s.saveLocked() + s.notifyLocked() + return session, nil +} + +func (s *StateStore) StartTurn(sessionID string, text string, skill *SkillInput, images []ImagePayload) error { + s.mu.Lock() + defer s.mu.Unlock() + session, ok := s.sessions[sessionID] + if !ok { + return fmt.Errorf("session not found") + } + queued := QueuedTurn{ID: mustRandomID("turn"), Text: text, Skill: skill, Images: images, CreatedUnix: nowUnix()} + if session.ThreadID == "" || session.Active || s.activeCountLocked(session.MachineID) >= s.machineLimitLocked(session.MachineID) { + session.Queue = append(session.Queue, queued) + session.Status = SessionQueued + session.UpdatedUnix = nowUnix() + s.sessions[sessionID] = session + _ = s.audit.Log("turn_queued", session.MachineID, sessionID, "", queued) + _ = s.saveLocked() + s.notifyLocked() + return nil + } + s.dispatchTurnLocked(session, queued) + _ = s.saveLocked() + s.notifyLocked() + return nil +} + +func (s *StateStore) ForkSession(sourceSessionID string, text string) (Session, error) { + s.mu.Lock() + defer s.mu.Unlock() + source, ok := s.sessions[sourceSessionID] + if !ok { + return Session{}, fmt.Errorf("source session not found") + } + if source.ThreadID == "" { + return Session{}, fmt.Errorf("source session is not ready") + } + id, err := randomID("session") + if err != nil { + return Session{}, err + } + fork := Session{ + ID: id, + MachineID: source.MachineID, + Title: "BTW from " + source.Title, + CWD: source.CWD, + Status: SessionQueued, + ForkedFromID: source.ID, + PendingText: text, + CreatedUnix: nowUnix(), + UpdatedUnix: nowUnix(), + } + s.sessions[id] = fork + if s.activeCountLocked(source.MachineID) >= s.machineLimitLocked(source.MachineID) { + _ = s.audit.Log("session_fork_queued", source.MachineID, id, "", fork) + _ = s.saveLocked() + s.notifyLocked() + return fork, nil + } + s.dispatchForkLocked(fork) + _ = s.saveLocked() + s.notifyLocked() + return s.sessions[id], nil +} + +func (s *StateStore) dispatchForkLocked(session Session) { + session.Active = true + session.Status = SessionRunning + session.UpdatedUnix = nowUnix() + s.sessions[session.ID] = session + source := s.sessions[session.ForkedFromID] + command := s.baseCommandLocked(source.MachineID, session.ID, CommandForkSession) + command.SourceSessionID = source.ID + command.ThreadID = source.ThreadID + command.CWD = source.CWD + command.Text = session.PendingText + s.commands[source.MachineID] = append(s.commands[source.MachineID], command) + _ = s.audit.Log("session_fork_requested", source.MachineID, session.ID, command.ID, command) + s.appendEventLocked(Event{ + ID: mustRandomID("event"), + SessionID: session.ID, + MachineID: session.MachineID, + Type: EventUser, + Text: session.PendingText, + CreatedUnix: nowUnix(), + }) +} + +func (s *StateStore) Subscribe() (chan struct{}, func()) { + ch := make(chan struct{}, 1) + s.mu.Lock() + s.subscribers[ch] = struct{}{} + s.mu.Unlock() + return ch, func() { + s.mu.Lock() + delete(s.subscribers, ch) + close(ch) + s.mu.Unlock() + } +} + +func (s *StateStore) ClearAudit(before time.Time) (int, error) { + removed, err := ClearAuditBefore(s.config.AuditDir, before) + if err == nil { + _ = s.audit.Log("audit_cleared", "", "", "", map[string]any{"before": before.Format(time.RFC3339), "removed": removed}) + } + return removed, err +} + +func (s *StateStore) dispatchTurnLocked(session Session, turn QueuedTurn) { + session.Active = true + session.Status = SessionRunning + session.UpdatedUnix = nowUnix() + s.sessions[session.ID] = session + command := s.baseCommandLocked(session.MachineID, session.ID, CommandStartTurn) + command.ThreadID = session.ThreadID + command.Text = turn.Text + command.Skill = turn.Skill + command.Images = turn.Images + s.commands[session.MachineID] = append(s.commands[session.MachineID], command) + s.appendEventLocked(Event{ + ID: mustRandomID("event"), + SessionID: session.ID, + MachineID: session.MachineID, + Type: EventUser, + Text: turn.Text, + CreatedUnix: nowUnix(), + }) + _ = s.audit.Log("turn_started", session.MachineID, session.ID, command.ID, command) +} + +func (s *StateStore) finishCommandLocked(report AgentReport) { + session, ok := s.sessions[report.SessionID] + if !ok { + return + } + session.Active = false + session.UpdatedUnix = nowUnix() + if report.Status == "failed" { + session.Status = SessionFailed + session.LastError = report.Error + s.appendEventLocked(Event{ + ID: mustRandomID("event"), + SessionID: session.ID, + MachineID: session.MachineID, + Type: EventError, + Text: report.Error, + CreatedUnix: nowUnix(), + }) + } else { + if len(session.Queue) > 0 { + session.Status = SessionQueued + } else { + session.Status = SessionIdle + } + session.LastError = "" + } + s.sessions[session.ID] = session + _ = s.audit.Log("command_finished", report.MachineID, report.SessionID, report.CommandID, report) + s.dispatchQueuedMachineLocked(report.MachineID) +} + +func (s *StateStore) dispatchQueuedMachineLocked(machineID string) { + for s.activeCountLocked(machineID) < s.machineLimitLocked(machineID) { + var selected *Session + for _, session := range s.sessions { + if session.MachineID != machineID || session.Active || session.Status != SessionQueued { + continue + } + if session.ForkedFromID != "" && session.ThreadID == "" && session.PendingText != "" { + copySession := session + selected = ©Session + break + } + if session.ThreadID != "" && len(session.Queue) > 0 { + copySession := session + selected = ©Session + break + } + } + if selected == nil { + return + } + if selected.ForkedFromID != "" && selected.ThreadID == "" && selected.PendingText != "" { + s.dispatchForkLocked(*selected) + continue + } + next := selected.Queue[0] + selected.Queue = selected.Queue[1:] + s.sessions[selected.ID] = *selected + s.dispatchTurnLocked(*selected, next) + } +} + +func (s *StateStore) appendEventLocked(event Event) { + session := s.sessions[event.SessionID] + if event.ID == "" { + event.ID = mustRandomID("event") + } + if event.CreatedUnix == 0 { + event.CreatedUnix = nowUnix() + } + session.Events = append(session.Events, event) + if len(session.Events) > 500 { + session.Events = session.Events[len(session.Events)-500:] + } + if session.Title == "New session" && event.Type == EventUser && event.Text != "" { + session.Title = trimTitle(event.Text) + } + session.UpdatedUnix = nowUnix() + s.sessions[session.ID] = session +} + +func (s *StateStore) baseCommandLocked(machineID string, sessionID string, typ string) AgentCommand { + return AgentCommand{ + ID: mustRandomID("cmd"), + Type: typ, + SessionID: sessionID, + ApprovalPolicy: s.config.DefaultApprovalPolicy, + ApprovalsReviewer: s.config.DefaultApprovalsReviewer, + CreatedUnix: nowUnix(), + } +} + +func (s *StateStore) activeCountLocked(machineID string) int { + count := 0 + for _, session := range s.sessions { + if session.MachineID == machineID && session.Active { + count++ + } + } + return count +} + +func (s *StateStore) machineLimitLocked(machineID string) int { + limit := s.machines[machineID].MaxActiveSessions + if limit <= 0 { + return 1 + } + return limit +} + +func (s *StateStore) validAgentLocked(machineID string, token string) bool { + return machineID != "" && token != "" && s.agentTokens[machineID] == token +} + +func (s *StateStore) load() error { + content, err := os.ReadFile(s.path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + var data persistedState + if err := json.Unmarshal(content, &data); err != nil { + return err + } + if data.Machines != nil { + s.machines = data.Machines + } + if data.AgentTokens != nil { + s.agentTokens = data.AgentTokens + } + if data.Sessions != nil { + s.sessions = data.Sessions + } + return nil +} + +func (s *StateStore) saveLocked() error { + content, err := json.MarshalIndent(persistedState{ + Machines: s.machines, + AgentTokens: s.agentTokens, + Sessions: s.sessions, + }, "", " ") + if err != nil { + return err + } + return os.WriteFile(s.path, append(content, '\n'), 0600) +} + +func (s *StateStore) notify() { + s.mu.Lock() + defer s.mu.Unlock() + s.notifyLocked() +} + +func (s *StateStore) notifyLocked() { + for ch := range s.subscribers { + select { + case ch <- struct{}{}: + default: + } + } +} + +func randomID(prefix string) (string, error) { + var buf [16]byte + if _, err := rand.Read(buf[:]); err != nil { + return "", err + } + return prefix + "_" + hex.EncodeToString(buf[:]), nil +} + +func mustRandomID(prefix string) string { + id, err := randomID(prefix) + if err != nil { + panic(err) + } + return id +} + +func trimTitle(value string) string { + runes := []rune(value) + if len(runes) <= 64 { + return value + } + return string(runes[:64]) +} + +func suffix(value string) string { + if len(value) <= 6 { + return value + } + return value[len(value)-6:] +} + +func first(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} diff --git a/internal/life_codex/state_test.go b/internal/life_codex/state_test.go new file mode 100644 index 0000000..3189f26 --- /dev/null +++ b/internal/life_codex/state_test.go @@ -0,0 +1,122 @@ +package life_codex + +import ( + "testing" +) + +func TestStateStoreQueuesSingleSessionTurns(t *testing.T) { + root := t.TempDir() + store := newTestStore(t) + machineID, token := enrollTestMachine(t, store, root, 1) + session, err := store.CreateSession(machineID, root) + if err != nil { + t.Fatalf("create session: %v", err) + } + commands, err := store.Poll(AgentPollRequest{MachineID: machineID, Token: token, AllowedRoots: []string{root}, MaxActiveSessions: 1}) + if err != nil { + t.Fatalf("poll create: %v", err) + } + if len(commands) != 1 || commands[0].Type != CommandCreateSession { + t.Fatalf("unexpected create commands: %+v", commands) + } + if err := store.Report(AgentReport{MachineID: machineID, Token: token, CommandID: commands[0].ID, SessionID: session.ID, ThreadID: "thread-1", Status: "completed"}); err != nil { + t.Fatalf("report create: %v", err) + } + if err := store.StartTurn(session.ID, "first", nil, nil); err != nil { + t.Fatalf("start first: %v", err) + } + if err := store.StartTurn(session.ID, "second", nil, nil); err != nil { + t.Fatalf("start second: %v", err) + } + snapshot := store.Snapshot() + got := findSession(snapshot.Sessions, session.ID) + if got == nil || !got.Active || len(got.Queue) != 1 { + t.Fatalf("turn was not queued: %+v", got) + } + commands, err = store.Poll(AgentPollRequest{MachineID: machineID, Token: token, AllowedRoots: []string{root}, MaxActiveSessions: 1}) + if err != nil { + t.Fatalf("poll turn: %v", err) + } + if len(commands) != 1 || commands[0].Text != "first" { + t.Fatalf("unexpected first command: %+v", commands) + } + if err := store.Report(AgentReport{MachineID: machineID, Token: token, CommandID: commands[0].ID, SessionID: session.ID, Status: "completed"}); err != nil { + t.Fatalf("report first: %v", err) + } + commands, err = store.Poll(AgentPollRequest{MachineID: machineID, Token: token, AllowedRoots: []string{root}, MaxActiveSessions: 1}) + if err != nil { + t.Fatalf("poll second: %v", err) + } + if len(commands) != 1 || commands[0].Text != "second" { + t.Fatalf("unexpected second command: %+v", commands) + } +} + +func TestForkQueuesWhenMachineLimitReached(t *testing.T) { + root := t.TempDir() + store := newTestStore(t) + machineID, token := enrollTestMachine(t, store, root, 1) + session, err := store.CreateSession(machineID, root) + if err != nil { + t.Fatal(err) + } + commands, _ := store.Poll(AgentPollRequest{MachineID: machineID, Token: token, AllowedRoots: []string{root}, MaxActiveSessions: 1}) + if err := store.Report(AgentReport{MachineID: machineID, Token: token, CommandID: commands[0].ID, SessionID: session.ID, ThreadID: "thread-1", Status: "completed"}); err != nil { + t.Fatal(err) + } + if err := store.StartTurn(session.ID, "long", nil, nil); err != nil { + t.Fatal(err) + } + commands, _ = store.Poll(AgentPollRequest{MachineID: machineID, Token: token, AllowedRoots: []string{root}, MaxActiveSessions: 1}) + fork, err := store.ForkSession(session.ID, "quick") + if err != nil { + t.Fatalf("fork: %v", err) + } + got := findSession(store.Snapshot().Sessions, fork.ID) + if got == nil || got.Status != SessionQueued || got.Active { + t.Fatalf("fork should be queued: %+v", got) + } + if err := store.Report(AgentReport{MachineID: machineID, Token: token, CommandID: commands[0].ID, SessionID: session.ID, Status: "completed"}); err != nil { + t.Fatal(err) + } + commands, _ = store.Poll(AgentPollRequest{MachineID: machineID, Token: token, AllowedRoots: []string{root}, MaxActiveSessions: 1}) + if len(commands) != 1 || commands[0].Type != CommandForkSession || commands[0].Text != "quick" { + t.Fatalf("unexpected fork command: %+v", commands) + } +} + +func newTestStore(t *testing.T) *StateStore { + t.Helper() + config := DefaultServerConfig() + config.AdminToken = "test" + config.DataDir = t.TempDir() + config.AuditDir = t.TempDir() + store, err := NewStateStore(config) + if err != nil { + t.Fatalf("new store: %v", err) + } + return store +} + +func enrollTestMachine(t *testing.T, store *StateStore, root string, limit int) (string, string) { + t.Helper() + enrollToken, err := store.CreateEnrollToken() + if err != nil { + t.Fatalf("create token: %v", err) + } + resp, err := store.Enroll(EnrollRequest{Token: enrollToken, Name: "local", AllowedRoots: []string{root}, CodexPath: "codex", MaxActiveSessions: limit}) + if err != nil { + t.Fatalf("enroll: %v", err) + } + return resp.MachineID, resp.Token +} + +func findSession(sessions []Session, id string) *Session { + for _, session := range sessions { + if session.ID == id { + copySession := session + return ©Session + } + } + return nil +} diff --git a/internal/life_codex/types.go b/internal/life_codex/types.go new file mode 100644 index 0000000..f8046ff --- /dev/null +++ b/internal/life_codex/types.go @@ -0,0 +1,148 @@ +package life_codex + +import ( + "encoding/json" + "time" +) + +const ( + DefaultServerAddr = "127.0.0.1:8899" + DefaultServerConfigPath = "/etc/life_tools/life_codex_server.json" + DefaultAgentConfigPath = "/etc/life_tools/life_codex_agent.json" + + CommandCreateSession = "create_session" + CommandStartTurn = "start_turn" + CommandForkSession = "fork_session" + + SessionCreating = "creating" + SessionIdle = "idle" + SessionQueued = "queued" + SessionRunning = "running" + SessionFailed = "failed" + + EventInfo = "info" + EventUser = "user" + EventAssistant = "assistant" + EventTool = "tool" + EventError = "error" + EventImage = "image" +) + +type Machine struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + AllowedRoots []string `json:"allowed_roots"` + CodexPath string `json:"codex_path"` + MaxActiveSessions int `json:"max_active_sessions"` + LastSeenUnix int64 `json:"last_seen_unix"` + Remark string `json:"remark"` +} + +type Session struct { + ID string `json:"id"` + MachineID string `json:"machine_id"` + ThreadID string `json:"thread_id"` + Title string `json:"title"` + CWD string `json:"cwd"` + Status string `json:"status"` + Active bool `json:"active"` + ForkedFromID string `json:"forked_from_id,omitempty"` + PendingText string `json:"pending_text,omitempty"` + Queue []QueuedTurn `json:"queue"` + Events []Event `json:"events"` + CreatedUnix int64 `json:"created_unix"` + UpdatedUnix int64 `json:"updated_unix"` + LastError string `json:"last_error,omitempty"` +} + +type QueuedTurn struct { + ID string `json:"id"` + Text string `json:"text"` + Skill *SkillInput `json:"skill,omitempty"` + Images []ImagePayload `json:"images,omitempty"` + CreatedUnix int64 `json:"created_unix"` +} + +type SkillInput struct { + Name string `json:"name"` + Path string `json:"path"` +} + +type ImagePayload struct { + Name string `json:"name"` + ContentType string `json:"content_type"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` + DataBase64 string `json:"data_base64,omitempty"` + LocalPath string `json:"local_path,omitempty"` +} + +type Event struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + MachineID string `json:"machine_id"` + Type string `json:"type"` + Text string `json:"text"` + Payload json.RawMessage `json:"payload,omitempty"` + CreatedUnix int64 `json:"created_unix"` +} + +type AgentCommand struct { + ID string `json:"id"` + Type string `json:"type"` + SessionID string `json:"session_id"` + SourceSessionID string `json:"source_session_id,omitempty"` + ThreadID string `json:"thread_id,omitempty"` + CWD string `json:"cwd,omitempty"` + Text string `json:"text,omitempty"` + Skill *SkillInput `json:"skill,omitempty"` + Images []ImagePayload `json:"images,omitempty"` + ApprovalPolicy string `json:"approval_policy,omitempty"` + ApprovalsReviewer string `json:"approvals_reviewer,omitempty"` + CreatedUnix int64 `json:"created_unix"` +} + +type AgentReport struct { + MachineID string `json:"machine_id"` + Token string `json:"token"` + CommandID string `json:"command_id,omitempty"` + SessionID string `json:"session_id,omitempty"` + ThreadID string `json:"thread_id,omitempty"` + Status string `json:"status,omitempty"` + Error string `json:"error,omitempty"` + Event *Event `json:"event,omitempty"` + Payload json.RawMessage `json:"payload,omitempty"` +} + +type AgentPollRequest struct { + MachineID string `json:"machine_id"` + Token string `json:"token"` + Name string `json:"name"` + AllowedRoots []string `json:"allowed_roots"` + CodexPath string `json:"codex_path"` + MaxActiveSessions int `json:"max_active_sessions"` +} + +type EnrollRequest struct { + Token string `json:"token"` + Name string `json:"name"` + AllowedRoots []string `json:"allowed_roots"` + CodexPath string `json:"codex_path"` + MaxActiveSessions int `json:"max_active_sessions"` +} + +type EnrollResponse struct { + MachineID string `json:"machine_id"` + Token string `json:"token"` +} + +type StateSnapshot struct { + Machines []Machine `json:"machines"` + Sessions []Session `json:"sessions"` + NowUnix int64 `json:"now_unix"` +} + +func nowUnix() int64 { + return time.Now().Unix() +} diff --git a/sample/life_tools/life_codex_agent.json b/sample/life_tools/life_codex_agent.json new file mode 100644 index 0000000..f6f4ae5 --- /dev/null +++ b/sample/life_tools/life_codex_agent.json @@ -0,0 +1,13 @@ +{ + "server_url": "http://127.0.0.1:8899", + "machine_id": "", + "agent_token": "", + "machine_name": "local", + "allowed_roots": [ + "/Users/bytedance" + ], + "codex_path": "codex", + "attachment_dir": "/var/lib/life_tools/life_codex_agent/attachments", + "max_active_sessions": 2, + "poll_seconds": 1 +} diff --git a/sample/life_tools/life_codex_server.json b/sample/life_tools/life_codex_server.json new file mode 100644 index 0000000..4bbed96 --- /dev/null +++ b/sample/life_tools/life_codex_server.json @@ -0,0 +1,11 @@ +{ + "addr": "127.0.0.1:8899", + "admin_token": "change-me", + "data_dir": "/var/lib/life_tools/life_codex_server", + "audit_dir": "/var/log/life_tools/life_codex_server", + "web_root": "/usr/local/share/life_tools/life_codex", + "max_image_bytes": 10485760, + "max_images_per_turn": 5, + "default_approval_policy": "on-request", + "default_approvals_reviewer": "auto_review" +} diff --git a/web/life_codex/index.html b/web/life_codex/index.html new file mode 100644 index 0000000..f95194b --- /dev/null +++ b/web/life_codex/index.html @@ -0,0 +1,12 @@ + + + + + + life_codex + + +
+ + + diff --git a/web/life_codex/package-lock.json b/web/life_codex/package-lock.json new file mode 100644 index 0000000..e5321d7 --- /dev/null +++ b/web/life_codex/package-lock.json @@ -0,0 +1,1703 @@ +{ + "name": "life_codex", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@vitejs/plugin-react": "^5.1.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "vite": "^7.2.7" + }, + "devDependencies": {} + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.41", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.41.tgz", + "integrity": "sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001800", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", + "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.387", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz", + "integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==", + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + } + } +} diff --git a/web/life_codex/package.json b/web/life_codex/package.json new file mode 100644 index 0000000..f3e2fdd --- /dev/null +++ b/web/life_codex/package.json @@ -0,0 +1,14 @@ +{ + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "vite build", + "preview": "vite preview --host 127.0.0.1" + }, + "dependencies": { + "@vitejs/plugin-react": "^5.1.0", + "vite": "^7.2.7", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": {} +} diff --git a/web/life_codex/src/main.jsx b/web/life_codex/src/main.jsx new file mode 100644 index 0000000..2128789 --- /dev/null +++ b/web/life_codex/src/main.jsx @@ -0,0 +1,358 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import './styles.css'; + +const DEFAULT_SKILLS = [ + { name: 'grill-me', path: '/Users/bytedance/.codex/skills/grill-me/SKILL.md' }, + { name: 'openai-docs', path: '/Users/bytedance/.codex/skills/.system/openai-docs/SKILL.md' }, + { name: 'browser:control-in-app-browser', path: '/Users/bytedance/.codex/plugins/cache/openai-bundled/browser/26.623.101652/skills/control-in-app-browser/SKILL.md' } +]; + +function App() { + const [token, setToken] = useState(localStorage.getItem('life_codex_token') || ''); + const [state, setState] = useState({ machines: [], sessions: [], now_unix: 0 }); + const [selectedSessionID, setSelectedSessionID] = useState(''); + const [error, setError] = useState(''); + const selectedSession = state.sessions.find((item) => item.id === selectedSessionID) || state.sessions[0]; + + useEffect(() => { + if (!token) return; + localStorage.setItem('life_codex_token', token); + refreshState(token, setState, setError); + const events = new EventSource(`/api/events?token=${encodeURIComponent(token)}`); + events.addEventListener('state', (event) => { + setState(JSON.parse(event.data)); + }); + events.onerror = () => setError('state stream disconnected'); + return () => events.close(); + }, [token]); + + useEffect(() => { + if (!selectedSessionID && state.sessions.length > 0) { + setSelectedSessionID(state.sessions[0].id); + } + }, [state.sessions, selectedSessionID]); + + if (!token) { + return ; + } + + return ( +
+
+
+ life_codex + {state.machines.length} machines + {state.sessions.length} sessions +
+ +
+ {error &&
{error}
} +
+ + + +
+
+ ); +} + +function Login({ onSubmit }) { + const [value, setValue] = useState(''); + return ( +
+
{ event.preventDefault(); onSubmit(value.trim()); }}> +

life_codex

+ setValue(event.target.value)} placeholder="Admin token" autoFocus /> + +
+
+ ); +} + +function Machines({ token, machines, onError }) { + const [enrollToken, setEnrollToken] = useState(''); + async function createToken() { + try { + const data = await api(token, '/api/enrollment-tokens', { method: 'POST' }); + setEnrollToken(data.token); + } catch (err) { + onError(err.message); + } + } + return ( +
+
+

Machines

+ +
+
+ {machines.map((machine) => ( +
+
+ {machine.name || machine.id} + {machine.status} +
+ {machine.allowed_roots?.join(', ')} +
+ ))} +
+ {enrollToken && {enrollToken}} +
+ ); +} + +function SessionCreator({ token, machines, onCreated, onError }) { + const [machineID, setMachineID] = useState(''); + const [cwd, setCwd] = useState(''); + const selectedMachine = machines.find((machine) => machine.id === machineID) || machines[0]; + useEffect(() => { + if (!machineID && machines.length > 0) { + setMachineID(machines[0].id); + setCwd(machines[0].allowed_roots?.[0] || ''); + } + }, [machines, machineID]); + async function createSession(event) { + event.preventDefault(); + try { + const session = await api(token, '/api/sessions', { + method: 'POST', + body: JSON.stringify({ machine_id: machineID, cwd }) + }); + onCreated(session); + } catch (err) { + onError(err.message); + } + } + return ( +
+

New Session

+
+ + setCwd(event.target.value)} list="allowed-roots" placeholder="cwd" /> + + {selectedMachine?.allowed_roots?.map((root) => + +
+
+ ); +} + +function SessionList({ sessions, selectedID, onSelect }) { + return ( +
+

Sessions

+
+ {sessions.map((session) => ( + + ))} +
+
+ ); +} + +function ChatPanel({ token, session, onError }) { + const [text, setText] = useState(''); + const [images, setImages] = useState([]); + const [skill, setSkill] = useState(''); + const selectedSkill = DEFAULT_SKILLS.find((item) => item.name === skill); + const events = session?.events || []; + const groupedEvents = useMemo(() => compactEvents(events), [events]); + + async function send() { + if (!session) return; + const value = text.trim(); + if (!value && images.length === 0) return; + try { + if (value.startsWith('/btw ')) { + await api(token, `/api/sessions/${session.id}/fork`, { + method: 'POST', + body: JSON.stringify({ text: value.slice(5).trim() }) + }); + } else { + await api(token, `/api/sessions/${session.id}/turn`, { + method: 'POST', + body: JSON.stringify({ text: value, skill: selectedSkill || null, images }) + }); + } + setText(''); + setImages([]); + } catch (err) { + onError(err.message); + } + } + + async function onPaste(event) { + const files = [...event.clipboardData.files].filter((file) => file.type.startsWith('image/')); + if (files.length === 0) return; + const converted = await Promise.all(files.map(fileToPayload)); + setImages((current) => [...current, ...converted]); + } + + return ( +
+
+
+

{session?.title || 'No session'}

+ {session && {session.cwd}} +
+ {session && } +
+
+ {groupedEvents.map((event) => )} +
+
+
+ {images.map((image, index) => ( + + ))} +
+
+ +