frely-cli 0.6.10 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +97 -293
  2. package/README.zh-CN.md +230 -0
  3. package/dist/agent/agent-service.d.ts +77 -0
  4. package/dist/agent/agent-service.js +348 -0
  5. package/dist/agent/agent-service.js.map +1 -0
  6. package/dist/agent/app-install.d.ts +49 -0
  7. package/dist/agent/app-install.js +133 -0
  8. package/dist/agent/app-install.js.map +1 -0
  9. package/dist/agent/app-key.d.ts +14 -0
  10. package/dist/agent/app-key.js +46 -0
  11. package/dist/agent/app-key.js.map +1 -0
  12. package/dist/agent/compose.d.ts +23 -0
  13. package/dist/agent/compose.js +42 -0
  14. package/dist/agent/compose.js.map +1 -0
  15. package/dist/agent/ops-server.d.ts +18 -0
  16. package/dist/agent/ops-server.js +175 -0
  17. package/dist/agent/ops-server.js.map +1 -0
  18. package/dist/agent/ops-stdio.d.ts +5 -0
  19. package/dist/agent/ops-stdio.js +47 -0
  20. package/dist/agent/ops-stdio.js.map +1 -0
  21. package/dist/agent/ops.d.ts +25 -0
  22. package/dist/agent/ops.js +82 -0
  23. package/dist/agent/ops.js.map +1 -0
  24. package/dist/agent/protocol.d.ts +72 -0
  25. package/dist/agent/protocol.js +195 -0
  26. package/dist/agent/protocol.js.map +1 -0
  27. package/dist/agent/supervisor.d.ts +80 -0
  28. package/dist/agent/supervisor.js +278 -0
  29. package/dist/agent/supervisor.js.map +1 -0
  30. package/dist/agent/task-store.d.ts +82 -0
  31. package/dist/agent/task-store.js +220 -0
  32. package/dist/agent/task-store.js.map +1 -0
  33. package/dist/agent/tool-executor.d.ts +28 -0
  34. package/dist/agent/tool-executor.js +113 -0
  35. package/dist/agent/tool-executor.js.map +1 -0
  36. package/dist/agent/worktrees.d.ts +35 -0
  37. package/dist/agent/worktrees.js +143 -0
  38. package/dist/agent/worktrees.js.map +1 -0
  39. package/dist/agent-help.d.ts +200 -176
  40. package/dist/agent-help.js +65 -32
  41. package/dist/agent-help.js.map +1 -1
  42. package/dist/auth.d.ts +3 -0
  43. package/dist/auth.js +52 -14
  44. package/dist/auth.js.map +1 -1
  45. package/dist/cloud.d.ts +1 -1
  46. package/dist/cloud.js +6 -13
  47. package/dist/cloud.js.map +1 -1
  48. package/dist/device/protocol.d.ts +11 -2
  49. package/dist/device/protocol.js +18 -2
  50. package/dist/device/protocol.js.map +1 -1
  51. package/dist/device/relay-client.d.ts +19 -2
  52. package/dist/device/relay-client.js +65 -13
  53. package/dist/device/relay-client.js.map +1 -1
  54. package/dist/diagnostics.js +12 -4
  55. package/dist/diagnostics.js.map +1 -1
  56. package/dist/index.js +203 -173
  57. package/dist/index.js.map +1 -1
  58. package/dist/key-budget.js +2 -2
  59. package/dist/key-budget.js.map +1 -1
  60. package/dist/mcp-authorization.js +9 -7
  61. package/dist/mcp-authorization.js.map +1 -1
  62. package/dist/mcp-command.d.ts +12 -4
  63. package/dist/mcp-command.js +54 -25
  64. package/dist/mcp-command.js.map +1 -1
  65. package/dist/provider/state.d.ts +2 -0
  66. package/dist/provider/state.js +1 -0
  67. package/dist/provider/state.js.map +1 -1
  68. package/dist/runtime/mcp-lease.js +1 -1
  69. package/dist/runtime/mcp-lease.js.map +1 -1
  70. package/dist/runtime/mcp.d.ts +139 -0
  71. package/dist/runtime/mcp.js +88 -29
  72. package/dist/runtime/mcp.js.map +1 -1
  73. package/dist/runtime/process-manager.d.ts +1 -1
  74. package/dist/runtime/process-manager.js +4 -2
  75. package/dist/runtime/process-manager.js.map +1 -1
  76. package/dist/runtime/relay-agent.d.ts +25 -0
  77. package/dist/runtime/relay-agent.js +63 -0
  78. package/dist/runtime/relay-agent.js.map +1 -0
  79. package/dist/runtime/relay-mcp.d.ts +5 -2
  80. package/dist/runtime/relay-mcp.js +6 -1
  81. package/dist/runtime/relay-mcp.js.map +1 -1
  82. package/dist/runtime/relay-session.d.ts +47 -0
  83. package/dist/runtime/relay-session.js +56 -0
  84. package/dist/runtime/relay-session.js.map +1 -0
  85. package/dist/runtime/sandbox.d.ts +16 -0
  86. package/dist/runtime/sandbox.js +149 -0
  87. package/dist/runtime/sandbox.js.map +1 -0
  88. package/dist/runtime/workspace-registry.d.ts +5 -0
  89. package/dist/runtime/workspace-registry.js +87 -0
  90. package/dist/runtime/workspace-registry.js.map +1 -0
  91. package/dist/runtime/workspace-router.d.ts +5 -0
  92. package/dist/runtime/workspace-router.js +32 -0
  93. package/dist/runtime/workspace-router.js.map +1 -0
  94. package/dist/runtime/workspace.js +3 -1
  95. package/dist/runtime/workspace.js.map +1 -1
  96. package/dist/skill/access.d.ts +4 -2
  97. package/dist/skill/access.js +7 -3
  98. package/dist/skill/access.js.map +1 -1
  99. package/dist/upgrade/installation.js +1 -1
  100. package/dist/upgrade/installation.js.map +1 -1
  101. package/dist/workspace-command.d.ts +8 -0
  102. package/dist/workspace-command.js +36 -0
  103. package/dist/workspace-command.js.map +1 -0
  104. package/package.json +2 -1
@@ -0,0 +1,230 @@
1
+ # frely-cli
2
+
3
+ [English](README.md) · [简体中文](README.zh-CN.md)
4
+
5
+ `frely-cli` 把你指定的一台电脑变成 AI Agent 可以远程操作的安全设备。浏览器 Agent(ChatGPT、Codex)和命令行 Agent(Claude Code、pi)通过 Model Context Protocol(MCP)连接,访问你授权的工作区(workspace)内的文件、命令与进程——在任何地方。
6
+
7
+ 两个可选功能,各自有独立的配置与授权:
8
+
9
+ - **Remote Agent Skill** —— 把已发布的 Frely Agent 安装为本地触发 Skill,从自动化流程中调用。
10
+ - **本地模型共享** —— 把本机 Ollama / OpenAI 兼容运行时发布为你的 Frely 个人 Provider。
11
+
12
+ 本仓库包含开源的 Frely 客户端与本地 MCP 运行时;托管的 Frely Relay 控制面是独立的服务依赖。不存在独立的 `friday-local` 项目,本地 MCP 执行属于 `frely-cli`。
13
+
14
+ ```text
15
+ Remote Agent Skill: 安装 -> frely login -> frely agent install -> frely agent run
16
+ Device MCP: 在被控电脑安装 -> frely login -> frely mcp -> 客户端添加 MCP URL -> OAuth 授权
17
+ ```
18
+
19
+ ## 安装
20
+
21
+ 独立安装器(standalone installer)会选择平台可执行文件、校验 SHA-256 并安装到用户目录,不需要 Node.js、npm 或 keyring。
22
+
23
+ ```sh
24
+ # macOS / Linux
25
+ curl -fsSL https://frely.cloud/install.sh | sh
26
+ ```
27
+
28
+ ```powershell
29
+ # Windows
30
+ irm https://github.com/FrelyHQ/frely-cli/releases/latest/download/install.ps1 | iex
31
+ ```
32
+
33
+ 或使用 npm(需要 Node.js 22 或更新版本):
34
+
35
+ ```sh
36
+ npm install --global --ignore-scripts frely-cli@latest
37
+ ```
38
+
39
+ 要安装指定版本,把 `latest` 换成版本号。带 tag 的发布会在跨平台验证后同时发布 npm 包与 GitHub Release 独立安装产物。
40
+
41
+ ### 从源码安装
42
+
43
+ 用 Bun 安装当前源码:
44
+
45
+ ```sh
46
+ cd /path/to/frely-cli
47
+ bun install
48
+ bun run build
49
+ bun install --global "$PWD"
50
+ ```
51
+
52
+ 全局安装命令请使用 `$PWD` 绝对路径。如果找不到 `frely`,把 Bun 全局 bin 目录加入 `PATH`:
53
+
54
+ ```sh
55
+ export PATH="$(bun pm bin -g):$PATH"
56
+ ```
57
+
58
+ 当前源码没有 keytar 依赖和原生 npm 构建步骤。依赖安装支持 `npm ci --ignore-scripts` 与 `bun install --ignore-scripts`。仓库以 npm 作为 CI 与发布的规范包管理器并提交 `package-lock.json`;Bun 仅用于本地开发,变更依赖时请保持 lockfile 同步。
59
+
60
+ ## 首次使用
61
+
62
+ 用消费端使用的 Frely 账号登录一次:
63
+
64
+ ```sh
65
+ frely login
66
+ ```
67
+
68
+ 浏览器会自动打开。要换浏览器或换账号,运行 `frely login --no-browser`,并把打印的 URL 只在你想要的那个账号已登录的浏览器中打开。在已登录状态下访问设备授权 URL,可能在点击 Approve 之前就把该码绑定到那个账号——如果默认浏览器用别的账号打开了,按 Ctrl+C,重新运行 `frely login --no-browser`,使用**新的 URL**。重新登录或复用旧 URL 都不会切换账号。使用自建 Relay 时请保留 `--relay <url>`。`FRELY_NO_BROWSER=1` 仍然支持。
69
+
70
+ ### Device MCP:让远程客户端控制这台电脑
71
+
72
+ 在被控电脑上开启文件、shell 与进程访问:
73
+
74
+ ```sh
75
+ frely mcp --workspace /path/to/project
76
+ ```
77
+
78
+ `frely login` 通过浏览器设备授权获取受限账号会话。首次运行 `frely mcp` 会初始化独立的 MCP 安全密钥、请求浏览器批准该设备与工作区(省略 `--workspace` 时为当前目录)、安装用户级 Device Relay 服务(macOS LaunchAgent、Linux systemd 用户单元或 Windows 任务计划程序),并打印 MCP URL。默认授权 90 天,`--days 1..180` 可选时长。
79
+
80
+ `frely mcp` 是幂等的:启用后再次运行只打印同一个 URL,需要地址时直接再运行即可。提示走 stderr,stdout 保持单一 URL(或 `--json` 时一个 JSON 对象);批准或安装失败则不打印 URL。要开放更多目录,用 `frely mcp workspace add <path>`;`frely mcp workspace` 列出所有目录。
81
+
82
+ 把打印的完整 URL 添加到支持 OAuth 的远程 MCP 客户端,选择 OAuth 并完成授权。保持电脑在线。验证首次连接:让客户端只列出所选工作区的顶层名称,不写文件、不跑 shell 命令——返回与目录一致的结果即连通。
83
+
84
+ 调用端的 Claude Code:
85
+
86
+ ```sh
87
+ claude mcp add --transport http frely-computer "<MCP_URL>"
88
+ ```
89
+
90
+ 在 Claude Code 中打开 `/mcp` 完成 OAuth 授权。每台设备使用不同的服务器名。让 Agent 用 Frely 工具做远程工作;它自带的 shell 仍跑在调用端电脑。同一设备的客户端共享其工作区与托管进程。
91
+
92
+ 授权生命周期:授权过期后,`frely mcp` 会请求新的批准并轮换 MCP 执行密钥;`frely mcp --days 180` 可提前续期。MCP URL 始终绑定该设备。登录刷新、OAuth 刷新与重启都不延长授权。`frely mcp stop|start` 暂停或恢复后台服务;`frely mcp remove` 撤销所有客户端的访问并卸载服务(本机有本地 Provider 时服务改为仅 Provider 模式继续运行)。在 Frely → **Device MCP**(`/user/account/connections`)管理你的设备。
93
+
94
+ ### 调用 Frely 托管的 Agent
95
+
96
+ 使用有账号或受限 API-key 访问权限的已发布 Agent。安装为本地触发 Skill(也可以用完整 manifest URL 代替 id):
97
+
98
+ ```sh
99
+ frely agent install <distribution-id> \
100
+ --host pi \
101
+ --scope global \
102
+ --json
103
+ ```
104
+
105
+ Creator 也可以提供一个现成的模型级、限额 API key 用于赞助/演示调用。只通过 stdin 传入,避免出现在 argv 或生成的 Skill 中:
106
+
107
+ ```sh
108
+ printf '%s' "$FRELY_AGENT_KEY" | \
109
+ frely agent install <distribution-id> \
110
+ --host chatgpt \
111
+ --scope global \
112
+ --api-key-stdin \
113
+ --json
114
+ ```
115
+
116
+ CLI 会用目标模型级 MCP `tools/list` 端点验证 key,存入安全凭证库,在托管 Skill 元数据中只记录 `authMode=api-key`。分享请使用短生命周期、单模型、限额的 key;不要把 Creator 主 key 贴进去。
117
+
118
+ 生成的 Skill 通过 Frely 的模型级 MCP 端点调用已发布 Agent。从自动化调用已安装 Agent,完整任务走 stdin:
119
+
120
+ ```sh
121
+ printf '%s' '你的完整任务' | \
122
+ frely agent run '<distribution-id>' --input-stdin --json
123
+ ```
124
+
125
+ `frely agent status <distribution-id>` 查看已安装的 Skill,API-key 安装时同时显示该 Key 的预算。`frely agent remove <distribution-id>` 删除 Skill 及其保存的 key。
126
+
127
+ ## 本地模型共享
128
+
129
+ 把回环(loopback)OpenAI 兼容运行时发布为 Frely 个人 Provider,Ollama 是默认驱动:
130
+
131
+ ```sh
132
+ frely provider share ollama
133
+ ```
134
+
135
+ 自定义端点与模型选择:
136
+
137
+ ```sh
138
+ frely provider share openai-compatible \
139
+ --url http://127.0.0.1:8080/v1 \
140
+ --models model-a,model-b \
141
+ --slot <personal-provider-slot-id> \
142
+ --name "Local GPU"
143
+ ```
144
+
145
+ 要求:已登录 Frely、一个空闲的活跃个人 Provider slot、回环 HTTP、OpenAI 兼容 `/v1`(Ollama 默认端点 `http://127.0.0.1:11434/v1`)。模型名不能包含空格或 `/`。
146
+
147
+ 该命令创建服务端管理的 `openai-compatible` 个人 Provider,把本地端点存入仅属主可读的 CLI 状态,启动 Device Relay 服务,用设备 Ed25519 key 签署 Provider 凭证,配置 CPA 并启用声明的模型。现有 Frely Access Point 与 API-key 流程即可消费该 Provider。
148
+
149
+ Provider 检查:
150
+
151
+ ```sh
152
+ frely provider list
153
+ ```
154
+
155
+ 如果 Provider 已准备但设置中途失败,再运行一次 `frely provider share` 即可:它会续完该 Provider,而不是新建一个。
156
+
157
+ ## 升级与诊断
158
+
159
+ ```sh
160
+ frely doctor # 当前账号、安装路径、发行形态、最新稳定版、MCP/服务状态
161
+ frely doctor -v # 另加配置路径、运行时细节、授权到期、最近心跳、脱敏错误
162
+ frely upgrade # 原地升级当前正在运行的安装
163
+ ```
164
+
165
+ `frely upgrade` 永不换安装器、不改 PATH、不降级更新版本。standalone 下载会先校验 SHA-256 并做启动测试再替换可执行文件;npm/Bun 安装保持原全局目录。匹配的、正在运行的 Device Relay 服务会被暂停维护、重启并在安装后检查;凭证、设备身份、MCP URL、工作区与授权到期均保留。Windows 上 `upgrade` 打印检测到安装方式对应的 PowerShell 命令,请在本地终端执行。
166
+
167
+ `frely doctor` 是唯一诊断入口,永不重启服务。“Connected” 表示匹配的账号/设备进程在 75 秒内收到 WebSocket 心跳,且 MCP 授权与工作区与运行中的 relay 匹配。两种模式都不代替客户端完成 OAuth 授权,也不代替执行工具调用。`frely doctor --mcp` 是检查受保护凭证和服务端授权状态的推荐方式。
168
+
169
+ 完整行为:[self-upgrade 契约](docs/self-upgrade.md) 与[服务维护与旧版本迁移](docs/service-maintenance.md)。若安装了多个 `frely`,升级前先查看 `doctor` 显示的路径。
170
+
171
+ ## 本地执行边界
172
+
173
+ 本地 MCP 服务暴露:工作区检查、文件搜索/读/写/补丁、目录创建/删除/移动、shell 命令、常驻进程管理。
174
+
175
+ 文件系统工具约束在所选工作区内:拒绝 symlink 逃逸、普通文件读写上限 1 MiB、no-follow 读、原子替换写。`run_command` 与常驻进程工具以当前 OS 用户权限执行;工作区只约束它们的工作目录,**不是** shell 沙箱。
176
+
177
+ 只读本地操作可以并行;写与 shell 操作走本地公平调度器——避免了设备级 `busy -> 429` 行为。
178
+
179
+ ## 认证与密钥
180
+
181
+ 基础账号会话与 Network 会话使用私有明文文件,不能批准 MCP 授权,也不能在显式范围外调用账号管理操作。Provider key 与 MCP 执行 key 相互独立。
182
+
183
+ MCP 密钥使用 AES-256-GCM 文件,主密钥存 macOS Keychain、Windows 凭据管理器或 Linux Secret Service。无头部署可通过 `FRELY_CREDENTIAL_KEY` 注入 32 字节 key 并设 `FRELY_CREDENTIAL_STORE=encrypted-file`。MCP 没有明文回退;安全存储失败不影响基础功能。
184
+
185
+ 稳定的 MCP URL 不含凭证。远程客户端持有 OAuth 凭证;CLI 持有 MCP 执行私钥。Relay 校验 OAuth 资源绑定与当前 MCP 执行租约。到期会阻塞请求与排队任务、取消托管执行,但不回滚写入,也不为任意 shell 程序提供沙箱。
186
+
187
+ 存储、迁移、服务注入、发布要求与威胁边界:[凭证与安装边界](docs/credential-storage.md)。
188
+
189
+ ## 架构
190
+
191
+ - 产品定义与通用客户端接入:[docs/device-mcp.md](docs/device-mcp.md)
192
+ - Device Relay 传输:子协议、连接 grant、重连、回退状态机:[docs/device-transport.md](docs/device-transport.md)
193
+ - Relay OAuth 2.1 Authorization Code + PKCE、发现与 token 端点:[docs/mcp-oauth-relay-contract.md](docs/mcp-oauth-relay-contract.md)
194
+ - Cloud 命令与授权:[docs/cloud.md](docs/cloud.md)
195
+ - Frely Network 命令(预览,不在 `frely --help` 中列出):[docs/frely-network.md](docs/frely-network.md)
196
+
197
+ 公网 MCP URL 是 Relay 返回的规范资源,例如 `https://connect.frely.cloud/mcp/<device-id>`。请使用 `frely mcp` 输出的地址,不要从控制面域名推导。URL 不含 bearer secret。
198
+
199
+ ## 命令
200
+
201
+ ```text
202
+ frely login [--relay <https-url>] [--no-browser]
203
+ frely logout
204
+ frely doctor [-v] [--json]
205
+ frely upgrade
206
+ frely mcp [--workspace <path>] [--days 1..180] [--json]
207
+ frely mcp workspace [add|remove <path>] [--json]
208
+ frely mcp stop|start|remove
209
+ frely agent install <distribution-id|manifest-url> [--host chatgpt|codex|claude-code|pi|generic] [--scope global|project] [--api-key-stdin] [--json]
210
+ frely agent run <distribution-id> (--input <text>|--input-stdin) [--json]
211
+ frely agent status (<distribution-id>|--api-key-stdin [--relay <url>]) [--json]
212
+ frely agent remove <distribution-id> [--json]
213
+ frely provider share [ollama|openai-compatible] [--url <loopback-v1-url>] [--models <a,b>] [--slot <slot-id>] [--name <name>]
214
+ frely provider list [--json]
215
+ frely cloud list|describe|call
216
+ ```
217
+
218
+ 以下命令不在 `frely --help` 中列出,但在 `frely help --agent --json` 中:`frely mcp stdio [--workspace <path>]` 通过 stdio 为本地 MCP 客户端提供工具;`frely mcp serve` 是后台服务运行的前台 Device Relay 客户端;`frely network` 是 Network 预览。
219
+
220
+ `frely logout` 删除账号会话、撤销 Cloud 授权并尝试停止后台服务。
221
+
222
+ ## 落地页
223
+
224
+ 开源 CLI 落地页在 [`site/`](site/README.md),与 CLI 同处 Apache-2.0 许可与商标政策下,为 `cli.frely.cloud` 准备,作为静态站点独立部署。网站资源不打进 npm 包。
225
+
226
+ ## 许可与商标
227
+
228
+ `frely-cli` 采用 Apache License 2.0,见 [`LICENSE`](LICENSE)。Frely 名称、logo 与产品名不作为商标授权,见 [`TRADEMARKS.md`](TRADEMARKS.md)。
229
+
230
+ 开发指引见 [`CONTRIBUTING.md`](CONTRIBUTING.md),私有漏洞报告见 [`SECURITY.md`](SECURITY.md)。
@@ -0,0 +1,77 @@
1
+ import { type DiagnosticLog } from "../runtime/diagnostics.js";
2
+ import { AgentHostSupervisor, type HostRequest } from "./supervisor.js";
3
+ import { AgentStoreError, type AgentTaskRecord, type AgentTaskSource, TaskStore, type StoredAgentEvent } from "./task-store.js";
4
+ import { type AgentHostTaskEvent } from "./protocol.js";
5
+ import { branchNameForTask } from "./worktrees.js";
6
+ export declare class AgentServiceError extends Error {
7
+ readonly code: AgentServiceErrorCode;
8
+ constructor(code: AgentServiceErrorCode, message?: string);
9
+ }
10
+ export type AgentServiceErrorCode = "invalid_args" | "task_not_found" | "workspace_not_git_repo" | "workspace_dirty" | "too_many_tasks" | "rate_limited" | "invalid_state" | "budget_exceeded" | "remote_control_disabled" | "app_not_installed" | "host_unavailable" | "internal";
11
+ export type AgentRuntimeConfig = {
12
+ schemaVersion: 1;
13
+ remoteControlEnabled: boolean;
14
+ defaultMaxCostUsd: number;
15
+ maxCostUsdLimit: number;
16
+ maxConcurrentTasks: number;
17
+ };
18
+ export declare const DEFAULT_AGENT_CONFIG: AgentRuntimeConfig;
19
+ export declare function loadAgentConfig(env?: NodeJS.ProcessEnv): Promise<AgentRuntimeConfig>;
20
+ export declare function saveAgentConfig(config: AgentRuntimeConfig, env?: NodeJS.ProcessEnv): Promise<void>;
21
+ export type StartTaskInput = {
22
+ workspace: string;
23
+ goal: string;
24
+ model?: string;
25
+ maxCostUsd?: number;
26
+ source: AgentTaskSource;
27
+ };
28
+ export declare class AgentService {
29
+ private readonly deps;
30
+ private readonly executors;
31
+ private readonly scheduler;
32
+ private credentialsSent;
33
+ private budgetCancels;
34
+ private _supervisor;
35
+ constructor(deps: {
36
+ store: TaskStore;
37
+ resolveSupervisor: () => AgentHostSupervisor;
38
+ modelCredentials?: () => {
39
+ apiKey: string;
40
+ baseUrl: string;
41
+ model: string;
42
+ } | null;
43
+ config?: () => Promise<AgentRuntimeConfig>;
44
+ log?: DiagnosticLog;
45
+ });
46
+ supervisor(): AgentHostSupervisor;
47
+ startTask(input: StartTaskInput): Promise<AgentTaskRecord>;
48
+ listTasks(workspace?: string): Promise<AgentTaskRecord[]>;
49
+ getTask(id: string): Promise<AgentTaskRecord>;
50
+ getEvents(id: string, cursor?: number): Promise<{
51
+ events: StoredAgentEvent[];
52
+ nextCursor: number | null;
53
+ }>;
54
+ sendMessage(id: string, message: string): Promise<void>;
55
+ cancelTask(id: string): Promise<AgentTaskRecord>;
56
+ private settleCancelled;
57
+ getDiff(id: string, path?: string): Promise<{
58
+ diff: string;
59
+ truncated: boolean;
60
+ }>;
61
+ requestMerge(id: string, note?: string): Promise<AgentTaskRecord>;
62
+ approveMerge(id: string, approvedBy: "gui" | "web"): Promise<AgentTaskRecord>;
63
+ discardTask(id: string): Promise<AgentTaskRecord>;
64
+ stopHost(): Promise<void>;
65
+ status(): {
66
+ host: ReturnType<AgentHostSupervisor["status"]>;
67
+ };
68
+ private ensureHostWithCredentials;
69
+ private debug;
70
+ private eventsQueue;
71
+ readonly onTaskEvent: (taskId: string, event: AgentHostTaskEvent) => void;
72
+ private handleTaskEvent;
73
+ readonly onHostLost: (error?: Error) => void;
74
+ private failRunningTasks;
75
+ readonly onToolRequest: (request: HostRequest, args: Record<string, unknown>, taskId: string) => Promise<unknown>;
76
+ }
77
+ export { TaskStore, AgentStoreError, branchNameForTask };
@@ -0,0 +1,348 @@
1
+ /**
2
+ * AgentService: orchestrates agent tasks end-to-end — validation, worktrees,
3
+ * the agent host process, events, budget, and the merge workflow. This is the
4
+ * single implementation behind the GUI, the device relay (`method: "agent"`),
5
+ * and the `frely app` CLI.
6
+ */
7
+ import { randomBytes } from "node:crypto";
8
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
9
+ import { dirname } from "node:path";
10
+ import { FairRwScheduler } from "../runtime/scheduler.js";
11
+ import { AgentToolExecutor, toolKindFromMethod } from "./tool-executor.js";
12
+ import { AGENT_DEFAULT_MAX_COST_USD, AGENT_MAX_COST_USD_LIMIT, AGENT_DEFAULT_MAX_CONCURRENT_TASKS, AGENT_START_RATE_LIMIT_PER_MINUTE, AGENT_TASK_MAX_GOAL_BYTES, AgentStoreError, agentConfigPath, isTerminalStatus, nextRuntimeStatus, TaskStore, } from "./task-store.js";
13
+ import { newTaskId } from "./protocol.js";
14
+ import { branchNameForTask, createTaskWorktree, mergeTaskBranch, removeTaskWorktree, taskDiff, WorktreeError } from "./worktrees.js";
15
+ export class AgentServiceError extends Error {
16
+ code;
17
+ constructor(code, message) {
18
+ super(message ?? code);
19
+ this.code = code;
20
+ this.name = "AgentServiceError";
21
+ }
22
+ }
23
+ export const DEFAULT_AGENT_CONFIG = {
24
+ schemaVersion: 1,
25
+ remoteControlEnabled: false,
26
+ defaultMaxCostUsd: AGENT_DEFAULT_MAX_COST_USD,
27
+ maxCostUsdLimit: AGENT_MAX_COST_USD_LIMIT,
28
+ maxConcurrentTasks: AGENT_DEFAULT_MAX_CONCURRENT_TASKS,
29
+ };
30
+ export async function loadAgentConfig(env = process.env) {
31
+ try {
32
+ const raw = JSON.parse(await readFile(agentConfigPath(env), "utf8"));
33
+ return {
34
+ schemaVersion: 1,
35
+ remoteControlEnabled: raw.remoteControlEnabled === true,
36
+ defaultMaxCostUsd: boundedCost(raw.defaultMaxCostUsd, AGENT_DEFAULT_MAX_COST_USD),
37
+ maxCostUsdLimit: boundedCost(raw.maxCostUsdLimit, AGENT_MAX_COST_USD_LIMIT),
38
+ maxConcurrentTasks: boundedConcurrency(raw.maxConcurrentTasks),
39
+ };
40
+ }
41
+ catch {
42
+ return { ...DEFAULT_AGENT_CONFIG };
43
+ }
44
+ }
45
+ export async function saveAgentConfig(config, env = process.env) {
46
+ const path = agentConfigPath(env);
47
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
48
+ const tmp = `${path}.${randomBytes(8).toString("hex")}.tmp`;
49
+ await writeFile(tmp, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
50
+ await rename(tmp, path);
51
+ }
52
+ function boundedCost(value, fallback) {
53
+ return typeof value === "number" && Number.isFinite(value) && value > 0 && value <= AGENT_MAX_COST_USD_LIMIT ? value : fallback;
54
+ }
55
+ function boundedConcurrency(value) {
56
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 1 && value <= 4 ? value : AGENT_DEFAULT_MAX_CONCURRENT_TASKS;
57
+ }
58
+ export class AgentService {
59
+ deps;
60
+ executors = new Map();
61
+ scheduler = new FairRwScheduler(4);
62
+ credentialsSent = false;
63
+ budgetCancels = new Set();
64
+ _supervisor = null;
65
+ constructor(deps) {
66
+ this.deps = deps;
67
+ }
68
+ supervisor() {
69
+ this._supervisor ??= this.deps.resolveSupervisor();
70
+ return this._supervisor;
71
+ }
72
+ async startTask(input) {
73
+ const goal = typeof input.goal === "string" ? input.goal.trim() : "";
74
+ if (goal.length === 0 || Buffer.byteLength(goal) > AGENT_TASK_MAX_GOAL_BYTES)
75
+ throw new AgentServiceError("invalid_args", "goal must be 1..65536 bytes.");
76
+ if (typeof input.workspace !== "string" || input.workspace.length === 0)
77
+ throw new AgentServiceError("invalid_args", "workspace is required.");
78
+ const config = (await this.deps.config?.()) ?? DEFAULT_AGENT_CONFIG;
79
+ if (input.source.kind !== "gui" && !config.remoteControlEnabled)
80
+ throw new AgentServiceError("remote_control_disabled", "Run `frely app remote enable` first.");
81
+ const maxCostUsd = boundedCost(input.maxCostUsd ?? config.defaultMaxCostUsd, config.defaultMaxCostUsd);
82
+ const tasks = await this.deps.store.list();
83
+ const now = Date.now();
84
+ const active = tasks.filter((task) => !isTerminalStatus(task.status));
85
+ if (active.length >= config.maxConcurrentTasks)
86
+ throw new AgentServiceError("too_many_tasks");
87
+ const recentStarts = tasks.filter((task) => now - Date.parse(task.createdAt) < 60_000).length;
88
+ if (recentStarts >= AGENT_START_RATE_LIMIT_PER_MINUTE)
89
+ throw new AgentServiceError("rate_limited");
90
+ let worktree;
91
+ const taskId = newTaskId();
92
+ try {
93
+ worktree = await createTaskWorktree(taskId, input.workspace);
94
+ }
95
+ catch (error) {
96
+ if (error instanceof WorktreeError) {
97
+ if (error.code === "not_a_git_repo")
98
+ throw new AgentServiceError("workspace_not_git_repo");
99
+ if (error.code === "dirty_workspace")
100
+ throw new AgentServiceError("workspace_dirty");
101
+ }
102
+ throw error;
103
+ }
104
+ const id = taskId;
105
+ const record = {
106
+ id,
107
+ source: input.source,
108
+ workspace: input.workspace,
109
+ worktreePath: worktree.path,
110
+ branch: worktree.branch,
111
+ baseBranch: worktree.baseBranch,
112
+ baseCommit: worktree.baseCommit,
113
+ goal,
114
+ model: input.model ?? null,
115
+ maxCostUsd,
116
+ status: "queued",
117
+ mergeStatus: "none",
118
+ usage: { inputTokens: 0, outputTokens: 0, costUsd: 0 },
119
+ error: null,
120
+ mergeRequest: null,
121
+ approval: null,
122
+ createdAt: new Date().toISOString(),
123
+ updatedAt: new Date().toISOString(),
124
+ settledAt: null,
125
+ };
126
+ await this.deps.store.create(record);
127
+ try {
128
+ const connection = await this.ensureHostWithCredentials();
129
+ this.executors.set(id, new AgentToolExecutor(worktree.path, this.scheduler));
130
+ await connection.request("task.start", {
131
+ taskId: id,
132
+ worktreePath: worktree.path,
133
+ goal,
134
+ model: input.model ?? null,
135
+ budgetUsd: maxCostUsd,
136
+ baseBranch: worktree.baseBranch,
137
+ baseCommit: worktree.baseCommit,
138
+ });
139
+ }
140
+ catch (error) {
141
+ this.executors.delete(id);
142
+ await this.deps.store.update(id, (task) => ({ ...task, status: "failed", error: error instanceof Error ? error.message : "task start failed", settledAt: new Date().toISOString() }));
143
+ await this.deps.store.appendEvent(id, { type: "status", status: "failed", detail: "Agent host unavailable." });
144
+ if (error instanceof AgentServiceError)
145
+ throw error;
146
+ throw new AgentServiceError("host_unavailable", error instanceof Error ? error.message : "agent host unavailable");
147
+ }
148
+ return (await this.deps.store.read(id));
149
+ }
150
+ async listTasks(workspace) {
151
+ const tasks = await this.deps.store.list();
152
+ return workspace ? tasks.filter((task) => task.workspace === workspace) : tasks;
153
+ }
154
+ async getTask(id) {
155
+ return this.deps.store.read(id);
156
+ }
157
+ async getEvents(id, cursor = 0) {
158
+ return this.deps.store.readEvents(id, cursor);
159
+ }
160
+ async sendMessage(id, message) {
161
+ if (typeof message !== "string" || message.length === 0 || message.length > 64 * 1024)
162
+ throw new AgentServiceError("invalid_args");
163
+ const task = await this.deps.store.read(id);
164
+ if (task.status !== "running" && task.status !== "waiting_input")
165
+ throw new AgentServiceError("invalid_state", `Cannot send a message to a ${task.status} task.`);
166
+ const connection = await this.supervisor().ensureHost();
167
+ await connection.request("task.message", { taskId: id, message });
168
+ }
169
+ async cancelTask(id) {
170
+ const task = await this.deps.store.read(id);
171
+ if (isTerminalStatus(task.status))
172
+ return task;
173
+ if (task.status === "queued" && !this.executors.has(id)) {
174
+ return this.settleCancelled(id, "Cancelled before start.");
175
+ }
176
+ try {
177
+ const connection = await this.supervisor().ensureHost();
178
+ await connection.request("task.cancel", { taskId: id, reason: "User requested cancellation." }, 10_000);
179
+ }
180
+ catch (error) {
181
+ this.debug("agent.task.cancel_failed", id, error);
182
+ return this.settleCancelled(id, "Cancelled (host unreachable).");
183
+ }
184
+ return this.deps.store.read(id);
185
+ }
186
+ async settleCancelled(id, detail) {
187
+ this.executors.delete(id);
188
+ const record = await this.deps.store.update(id, (task) => ({ ...task, status: "cancelled", settledAt: new Date().toISOString() }));
189
+ await this.deps.store.appendEvent(id, { type: "status", status: "cancelled", detail });
190
+ return record;
191
+ }
192
+ async getDiff(id, path) {
193
+ const task = await this.deps.store.read(id);
194
+ try {
195
+ return await taskDiff(id, task.workspace, path);
196
+ }
197
+ catch (error) {
198
+ if (error instanceof WorktreeError && error.code === "not_a_git_repo")
199
+ throw new AgentServiceError("workspace_not_git_repo");
200
+ throw new AgentServiceError("internal", error instanceof Error ? error.message : "diff failed");
201
+ }
202
+ }
203
+ async requestMerge(id, note) {
204
+ const task = await this.deps.store.read(id);
205
+ if (task.status !== "completed")
206
+ throw new AgentServiceError("invalid_state", "Only completed tasks can request a merge.");
207
+ if (task.mergeStatus !== "none")
208
+ throw new AgentServiceError("invalid_state", `Merge already ${task.mergeStatus}.`);
209
+ return this.deps.store.update(id, (record) => ({
210
+ ...record,
211
+ mergeStatus: "merge_requested",
212
+ mergeRequest: { requestedAt: new Date().toISOString(), ...(note !== undefined && note.length > 0 ? { note: note.slice(0, 2048) } : {}) },
213
+ }));
214
+ }
215
+ async approveMerge(id, approvedBy) {
216
+ const task = await this.deps.store.read(id);
217
+ if (task.mergeStatus !== "merge_requested")
218
+ throw new AgentServiceError("invalid_state", `Merge state is ${task.mergeStatus}.`);
219
+ const approved = await this.deps.store.update(id, (record) => ({
220
+ ...record,
221
+ mergeStatus: "approved",
222
+ approval: { approvedAt: new Date().toISOString(), approvedBy },
223
+ }));
224
+ const outcome = await mergeTaskBranch(id, task.workspace);
225
+ if (outcome.status === "merged") {
226
+ await removeTaskWorktree(id, task.workspace).catch(() => undefined);
227
+ return this.deps.store.update(id, (record) => ({ ...record, mergeStatus: "merged" }));
228
+ }
229
+ return this.deps.store.update(id, (record) => ({ ...record, mergeStatus: "merge_conflict", error: outcome.detail }));
230
+ }
231
+ async discardTask(id) {
232
+ const task = await this.deps.store.read(id);
233
+ if (task.mergeStatus === "merged" || task.mergeStatus === "discarded")
234
+ throw new AgentServiceError("invalid_state", `Task already ${task.mergeStatus}.`);
235
+ await removeTaskWorktree(id, task.workspace).catch(() => undefined);
236
+ this.executors.delete(id);
237
+ return this.deps.store.update(id, (record) => ({ ...record, mergeStatus: "discarded" }));
238
+ }
239
+ async stopHost() {
240
+ for (const executor of this.executors.values())
241
+ executor.dispose();
242
+ this.executors.clear();
243
+ await this.supervisor().stop();
244
+ }
245
+ status() {
246
+ return { host: this.supervisor().status() };
247
+ }
248
+ // --- host wiring ---------------------------------------------------------
249
+ async ensureHostWithCredentials() {
250
+ const connection = await this.supervisor().ensureHost();
251
+ if (!this.credentialsSent) {
252
+ const credentials = this.deps.modelCredentials?.();
253
+ if (credentials)
254
+ connection.notify("model.credentials", credentials);
255
+ this.credentialsSent = true;
256
+ }
257
+ return connection;
258
+ }
259
+ debug(event, taskId, error) {
260
+ this.deps.log?.(JSON.stringify({ timestamp: new Date().toISOString(), pid: process.pid, event, taskId, ...(error ? { error: error instanceof Error ? error.message : String(error) } : {}) }));
261
+ }
262
+ eventsQueue = Promise.resolve();
263
+ onTaskEvent = (taskId, event) => {
264
+ // Hosts may burst events in a single tick (e.g. running+completed); apply them
265
+ // strictly in arrival order so a late non-terminal write cannot overwrite a terminal one.
266
+ this.eventsQueue = this.eventsQueue
267
+ .then(() => this.handleTaskEvent(taskId, event))
268
+ .catch((error) => this.debug("agent.event_apply_failed", taskId, error));
269
+ };
270
+ async handleTaskEvent(taskId, event) {
271
+ if (event.type === "status") {
272
+ const current = await this.deps.store.read(taskId).catch(() => null);
273
+ if (!current)
274
+ return;
275
+ const next = nextRuntimeStatus(current.status, event);
276
+ if (next === null) {
277
+ this.debug("agent.task.status_rejected", taskId);
278
+ return;
279
+ }
280
+ await this.deps.store.update(taskId, (task) => ({
281
+ ...task,
282
+ status: next,
283
+ error: next === "failed" ? event.detail ?? "Task failed." : task.error,
284
+ settledAt: isTerminalStatus(next) ? new Date().toISOString() : task.settledAt,
285
+ }));
286
+ if (isTerminalStatus(next)) {
287
+ this.executors.get(taskId)?.dispose();
288
+ this.executors.delete(taskId);
289
+ void this.supervisor().ensureHost().then((connection) => connection.request("session.dispose", { taskId }, 5_000)).catch(() => undefined);
290
+ }
291
+ }
292
+ if (event.type === "usage") {
293
+ const task = await this.deps.store.read(taskId).catch(() => null);
294
+ if (!task)
295
+ return;
296
+ await this.deps.store.update(taskId, (record) => ({
297
+ ...record,
298
+ usage: {
299
+ inputTokens: record.usage.inputTokens + event.inputTokens,
300
+ outputTokens: record.usage.outputTokens + event.outputTokens,
301
+ costUsd: Math.max(record.usage.costUsd, event.costUsd),
302
+ },
303
+ }));
304
+ const latest = await this.deps.store.read(taskId);
305
+ if (latest.usage.costUsd >= latest.maxCostUsd && !isTerminalStatus(latest.status) && !this.budgetCancels.has(taskId)) {
306
+ this.budgetCancels.add(taskId);
307
+ void this.supervisor()
308
+ .ensureHost()
309
+ .then((connection) => connection.request("task.cancel", { taskId, reason: "Budget limit reached." }, 10_000))
310
+ .catch(() => this.settleCancelled(taskId, "Budget limit reached (host unreachable)."));
311
+ }
312
+ }
313
+ await this.deps.store.appendEvent(taskId, event);
314
+ }
315
+ onHostLost = (error) => {
316
+ this.credentialsSent = false;
317
+ this.budgetCancels.clear();
318
+ void this.failRunningTasks(error).catch(() => undefined);
319
+ };
320
+ async failRunningTasks(error) {
321
+ const detail = error ? `Agent host exited unexpectedly: ${error.message}` : "Agent host exited unexpectedly.";
322
+ for (const task of await this.deps.store.list()) {
323
+ if (isTerminalStatus(task.status))
324
+ continue;
325
+ this.executors.delete(task.id);
326
+ await this.deps.store.update(task.id, (record) => ({ ...record, status: "failed", error: detail, settledAt: new Date().toISOString() }));
327
+ await this.deps.store.appendEvent(task.id, { type: "status", status: "failed", detail });
328
+ }
329
+ }
330
+ onToolRequest = async (request, args, taskId) => {
331
+ const kind = toolKindFromMethod(request.method);
332
+ if (!kind)
333
+ throw new Error(`Unsupported tool method: ${request.method}`);
334
+ const executor = this.executors.get(taskId);
335
+ if (!executor) {
336
+ const task = await this.deps.store.read(taskId).catch(() => null);
337
+ if (!task)
338
+ throw new Error("Unknown task.");
339
+ throw new Error(`Task ${taskId} is ${task.status}; tools are unavailable.`);
340
+ }
341
+ const result = await executor.execute(kind, args);
342
+ if (!result.ok)
343
+ throw new Error(result.error.message);
344
+ return result.result;
345
+ };
346
+ }
347
+ export { TaskStore, AgentStoreError, branchNameForTask };
348
+ //# sourceMappingURL=agent-service.js.map