farming-code 2.2.11 → 2.2.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -7
- package/README.zh_cn.md +8 -7
- package/backend/agent-manager.js +104 -5
- package/backend/codex-session-archive.js +13 -4
- package/backend/config-manager.js +5 -0
- package/backend/executable-discovery.js +2 -0
- package/backend/server.js +62 -0
- package/backend/usage-monitor.js +97 -15
- package/backend/workspace-directory.js +80 -0
- package/dist/assets/App-DWsa7DXG.js +206 -0
- package/dist/assets/{FileEditorMarkdownPreview--Cri80nQ.js → FileEditorMarkdownPreview-UlVd0O4D.js} +1 -1
- package/dist/assets/FileEditorPane-Dxx4rKOg.js +2 -0
- package/dist/assets/IconGlyphs-AU22gPDY.js +1 -0
- package/dist/assets/{ProjectFilesSection-Cz1wG4NT.js → ProjectFilesSection-DE7Nb9Jk.js} +3 -3
- package/dist/assets/ReviewPage-CeDKDyZh.js +3 -0
- package/dist/assets/code-dark-DPiuyeSp.css +1 -0
- package/dist/assets/{index-miO2r9IZ.js → index-CUFPJoa5.js} +2 -2
- package/dist/assets/main-Bqtb5kuz.css +1 -0
- package/dist/assets/{monaco.contribution-CyLosfZI.js → monaco.contribution-BXz-lfFB.js} +2 -2
- package/dist/assets/{tsMode-DGZTlTj8.js → tsMode-B0oUTcXQ.js} +1 -1
- package/dist/assets/workspace-editor-monaco-BICJESG0.js +4 -0
- package/dist/assets/workspace-editor-monaco-DRJ7IaXk.js +1 -0
- package/dist/farming-2/site.webmanifest +2 -0
- package/dist/index.html +1 -1
- package/frontend/skins/crt/app.js +2 -0
- package/package.json +1 -1
- package/dist/assets/App-CHYpgECZ.js +0 -208
- package/dist/assets/FileEditorPane-DGJlEKdf.js +0 -2
- package/dist/assets/IconGlyphs-Cc14sepw.js +0 -1
- package/dist/assets/ReviewPage-LonRc6Fz.js +0 -3
- package/dist/assets/code-dark-B1pAtO_P.css +0 -1
- package/dist/assets/main-DNe0jPw-.css +0 -1
- package/dist/assets/workspace-editor-monaco-5gFbeDPF.js +0 -1
- package/dist/assets/workspace-editor-monaco-xPv8JzHH.js +0 -4
package/README.md
CHANGED
|
@@ -51,6 +51,13 @@ A keyboard-first control room for watching many agents, opening structured Chat
|
|
|
51
51
|
|
|
52
52
|
Switching interfaces does not restart or duplicate an Agent. If Farming Code cannot start or render, its bounded diagnostic view leaves the live CRT surface available rather than hiding the running sessions.
|
|
53
53
|
|
|
54
|
+
To switch without editing the URL:
|
|
55
|
+
|
|
56
|
+
- **Farming Code → CRT:** click the gear at the bottom-left, open **Interface**, then choose **Farming CRT**.
|
|
57
|
+
- **Farming CRT → Code:** press `S` (or choose **[S] SETTINGS**), then choose **Farming Code** under **UI Theme**.
|
|
58
|
+
|
|
59
|
+
Farming carries the focused Agent into the other interface when possible. Both views keep using the same live Agent and provider session; this is an interface switch, not a restart.
|
|
60
|
+
|
|
54
61
|
The complete current capability map and screenshot tour are in the [Farming 2 product overview](./docs/products/README.md). See the focused [Farming Code guide](./docs/products/code/README.md) and [Farming CRT guide](./docs/products/crt/README.md) for the full workflows.
|
|
55
62
|
|
|
56
63
|
## Farming Net: One Portal For Deployments
|
|
@@ -74,8 +81,6 @@ The portal keeps its token, signing identity, and private `instances.json` regis
|
|
|
74
81
|
|
|
75
82
|

|
|
76
83
|
|
|
77
|
-

|
|
78
|
-
|
|
79
84
|
## Supported Agent Paths
|
|
80
85
|
|
|
81
86
|
Farming discovers installed CLIs on the host. The richer structured runtime currently applies to providers with ACP support; other detected coding agents remain first-class terminal sessions.
|
|
@@ -119,10 +124,6 @@ The first authenticated start stores a readable random token in `~/.farming/.ses
|
|
|
119
124
|
|
|
120
125
|
Desktop keeps the project, conversation, files, and review close together. Mobile focuses one conversation, terminal, or file at a time and moves navigation into a drawer, making it useful for checking progress and sending a short intervention.
|
|
121
126
|
|
|
122
|
-
<p align="center">
|
|
123
|
-
<img src="./docs/products/code/assets/05-mobile-agent-chat.jpg" alt="Farming Code on mobile" width="320">
|
|
124
|
-
</p>
|
|
125
|
-
|
|
126
127
|
Farming CRT is currently a desktop interface. Use Farming Code from a phone; CRT mobile concepts are not part of the supported product yet.
|
|
127
128
|
|
|
128
129
|
## Installation And Updates
|
|
@@ -157,7 +158,7 @@ Development host
|
|
|
157
158
|
|
|
158
159
|
The backend owns lifecycle, authentication, session routing, workspace boundaries, history, and configuration. Interactive terminal sessions use a separate native PTY host by default, allowing browser and server reconnection without replacing the live process. The browser terminal renderer defaults to xterm.js WebGL as its single supported product path; the Ghostty web adapter remains an explicit debug path, not a runtime fallback.
|
|
159
160
|
|
|
160
|
-
Runtime settings live in `~/.farming/settings.json`. Farming session metadata, the project membership index, archived runs, theme settings, update state, logs, and the startup token use separate files under `~/.farming/`. External provider histories remain read-only integrations.
|
|
161
|
+
Runtime settings live in `~/.farming/settings.json`. Farming session metadata, the project membership index, archived runs, theme settings, update state, logs, and the startup token use separate files under `~/.farming/`. External provider histories remain read-only integrations except for explicit Codex archive and unarchive lifecycle actions.
|
|
161
162
|
|
|
162
163
|
## Security
|
|
163
164
|
|
package/README.zh_cn.md
CHANGED
|
@@ -51,6 +51,13 @@ Farming 2 在同一批 Agent 和 Session 上提供两套完整的浏览器界面
|
|
|
51
51
|
|
|
52
52
|
切换界面不会重启或复制 Agent。如果 Farming Code 启动或渲染失败,有限范围的诊断层仍会保留后面的实时 CRT 界面,而不是把正在运行的 Session 一起遮掉。
|
|
53
53
|
|
|
54
|
+
不修改地址也可以直接切换:
|
|
55
|
+
|
|
56
|
+
- **Farming Code → CRT:**点击左下角齿轮,打开**界面**,再选择 **Farming CRT**。
|
|
57
|
+
- **Farming CRT → Code:**按 `S`(或点击 **[S] SETTINGS**),在 **UI Theme** 中选择 **Farming Code**。
|
|
58
|
+
|
|
59
|
+
条件允许时,Farming 会把当前聚焦的 Agent 一起带到另一套界面。两边继续使用同一个实时 Agent 和 Provider Session;这里只切换界面,不会重启会话。
|
|
60
|
+
|
|
54
61
|
完整能力矩阵和截图导览见 [Farming 2 产品总览](./docs/products/README.zh_cn.md)。两套界面的完整流程分别见 [Farming Code 指南](./docs/products/code/README.zh_cn.md) 和 [Farming CRT 指南](./docs/products/crt/README.zh_cn.md)。
|
|
55
62
|
|
|
56
63
|
## Farming Net:部署门户
|
|
@@ -74,8 +81,6 @@ FARMING_NET_PORT=6693 FARMING_NET_BASE_PATH=/farming-net npm run start:net
|
|
|
74
81
|
|
|
75
82
|

|
|
76
83
|
|
|
77
|
-

|
|
78
|
-
|
|
79
84
|
## 支持的 Agent 路径
|
|
80
85
|
|
|
81
86
|
Farming 会发现开发机上已经安装的 CLI。有 ACP 支持的 Provider 使用更完整的结构化运行时,其他检测到的 Coding Agent 仍然可以作为一等 Terminal Session 使用。
|
|
@@ -119,10 +124,6 @@ farming stop
|
|
|
119
124
|
|
|
120
125
|
桌面端把项目、对话、文件和 Review 放在彼此靠近的位置。移动端一次聚焦一段对话、一个终端或一个文件,并把导航移入抽屉,更适合查看进度和发送短介入。
|
|
121
126
|
|
|
122
|
-
<p align="center">
|
|
123
|
-
<img src="./docs/products/code/assets/05-mobile-agent-chat.jpg" alt="Farming Code 手机界面" width="320">
|
|
124
|
-
</p>
|
|
125
|
-
|
|
126
127
|
Farming CRT 当前只作为桌面界面使用。手机请使用 Farming Code;CRT 手机方案目前仍是概念设计,不属于已支持的产品能力。
|
|
127
128
|
|
|
128
129
|
## 安装与更新
|
|
@@ -157,7 +158,7 @@ Development host
|
|
|
157
158
|
|
|
158
159
|
后端负责生命周期、鉴权、Session 路由、Workspace 边界、History 和配置。交互式 Terminal 默认由独立的原生 PTY Host 持有,因此浏览器和 Server 可以重新连接,而不需要替换实际进程。xterm.js WebGL 是唯一受支持的产品 Terminal Renderer;Ghostty Web Adapter 只保留为显式调试路径,不作为运行时 Fallback。
|
|
159
160
|
|
|
160
|
-
运行时设置存放在 `~/.farming/settings.json`。Farming Session 元数据、项目成员索引、归档运行、主题设置、更新状态、日志和启动 Token 使用 `~/.farming/` 下彼此独立的文件。外部 Provider History
|
|
161
|
+
运行时设置存放在 `~/.farming/settings.json`。Farming Session 元数据、项目成员索引、归档运行、主题设置、更新状态、日志和启动 Token 使用 `~/.farming/` 下彼此独立的文件。外部 Provider History 保持只读,但明确的 Codex archive / unarchive 生命周期操作除外。
|
|
161
162
|
|
|
162
163
|
## 安全
|
|
163
164
|
|
package/backend/agent-manager.js
CHANGED
|
@@ -11,7 +11,7 @@ const {
|
|
|
11
11
|
buildAgentSessionResumeCommand,
|
|
12
12
|
findAgentSession,
|
|
13
13
|
} = require('./agent-session-history');
|
|
14
|
-
const { unarchiveCodexSession } = require('./codex-session-archive');
|
|
14
|
+
const { archiveCodexSession, unarchiveCodexSession } = require('./codex-session-archive');
|
|
15
15
|
const { buildAgentProviderSessionPlan, sessionFromExactResumeSource } = require('./agent-provider-session');
|
|
16
16
|
const { resolveAgentExecutable, resolveCompatibleCodexExecutable } = require('./executable-discovery');
|
|
17
17
|
const { ensureMainAgentSkillFiles, renderMainAgentBootstrap } = require('./main-agent-skills');
|
|
@@ -62,6 +62,7 @@ const {
|
|
|
62
62
|
const { inspectGitWorktree } = require('./git-worktree-info');
|
|
63
63
|
const { deserializeTerminalState } = require('./terminal-state-serialization');
|
|
64
64
|
const { compareNativePtyRuntimeEpochs } = require('./native-pty-controller-generation');
|
|
65
|
+
const { canonicalWorkspacePath } = require('./workspace-root-registry');
|
|
65
66
|
|
|
66
67
|
const SESSION_OUTPUT_LIMIT = 10000;
|
|
67
68
|
const AGENT_USAGE_RATE_WINDOW_MS = 5 * 60 * 1000;
|
|
@@ -581,6 +582,7 @@ class AgentManager extends EventEmitter {
|
|
|
581
582
|
}
|
|
582
583
|
: {}),
|
|
583
584
|
});
|
|
585
|
+
this.archiveCodexSession = options.archiveCodexSession || archiveCodexSession;
|
|
584
586
|
this.unarchiveCodexSession = options.unarchiveCodexSession || unarchiveCodexSession;
|
|
585
587
|
this.heartbeatInterval = null;
|
|
586
588
|
this.disposed = false;
|
|
@@ -4201,23 +4203,26 @@ class AgentManager extends EventEmitter {
|
|
|
4201
4203
|
: this.markAgentReadCursor(agentId);
|
|
4202
4204
|
}
|
|
4203
4205
|
|
|
4204
|
-
async
|
|
4206
|
+
async resolveGitWorktreeSourceRoot(workspace) {
|
|
4205
4207
|
const sourceWorkspace = this.expandWorkspacePath(workspace);
|
|
4206
4208
|
if (!sourceWorkspace) {
|
|
4207
4209
|
throw new Error('Source workspace is empty');
|
|
4208
4210
|
}
|
|
4209
4211
|
|
|
4210
|
-
let root;
|
|
4211
4212
|
try {
|
|
4212
4213
|
const { stdout } = await execFileAsync('git', ['-C', sourceWorkspace, 'rev-parse', '--show-toplevel'], {
|
|
4213
4214
|
timeout: 15000,
|
|
4214
4215
|
maxBuffer: 1024 * 1024,
|
|
4215
4216
|
});
|
|
4216
|
-
|
|
4217
|
+
return stdout.trim();
|
|
4217
4218
|
} catch (error) {
|
|
4218
4219
|
const message = error && error.stderr ? String(error.stderr).trim() : '';
|
|
4219
4220
|
throw new Error(message || 'Source workspace is not inside a git repository', { cause: error });
|
|
4220
4221
|
}
|
|
4222
|
+
}
|
|
4223
|
+
|
|
4224
|
+
async createForkWorktree(workspace) {
|
|
4225
|
+
const root = await this.resolveGitWorktreeSourceRoot(workspace);
|
|
4221
4226
|
|
|
4222
4227
|
const parentDir = path.dirname(root);
|
|
4223
4228
|
const baseName = path.basename(root);
|
|
@@ -4242,6 +4247,70 @@ class AgentManager extends EventEmitter {
|
|
|
4242
4247
|
return target;
|
|
4243
4248
|
}
|
|
4244
4249
|
|
|
4250
|
+
async createPermanentWorktree(workspace) {
|
|
4251
|
+
const root = await this.resolveGitWorktreeSourceRoot(workspace);
|
|
4252
|
+
const parentDir = path.dirname(root);
|
|
4253
|
+
const baseName = path.basename(root);
|
|
4254
|
+
const slug = timestampSlug();
|
|
4255
|
+
let suffix = 1;
|
|
4256
|
+
|
|
4257
|
+
while (suffix < 1000) {
|
|
4258
|
+
const suffixText = suffix === 1 ? '' : `-${suffix}`;
|
|
4259
|
+
const target = path.join(parentDir, `${baseName}-farming-worktree-${slug}${suffixText}`);
|
|
4260
|
+
const branch = `farming/worktree-${slug}${suffixText}`;
|
|
4261
|
+
let branchExists = false;
|
|
4262
|
+
try {
|
|
4263
|
+
await execFileAsync('git', ['-C', root, 'show-ref', '--verify', '--quiet', `refs/heads/${branch}`], {
|
|
4264
|
+
timeout: 15000,
|
|
4265
|
+
maxBuffer: 1024 * 1024,
|
|
4266
|
+
});
|
|
4267
|
+
branchExists = true;
|
|
4268
|
+
} catch (error) {
|
|
4269
|
+
if (error?.code !== 1) {
|
|
4270
|
+
const message = error && error.stderr ? String(error.stderr).trim() : '';
|
|
4271
|
+
throw new Error(message || 'Failed to inspect git branches', { cause: error });
|
|
4272
|
+
}
|
|
4273
|
+
}
|
|
4274
|
+
|
|
4275
|
+
if (!fs.existsSync(target) && !branchExists) {
|
|
4276
|
+
try {
|
|
4277
|
+
await execFileAsync('git', ['-C', root, 'worktree', 'add', '-b', branch, target, 'HEAD'], {
|
|
4278
|
+
timeout: 60000,
|
|
4279
|
+
maxBuffer: 1024 * 1024 * 4,
|
|
4280
|
+
});
|
|
4281
|
+
} catch (error) {
|
|
4282
|
+
const message = error && error.stderr ? String(error.stderr).trim() : '';
|
|
4283
|
+
throw new Error(message || 'Failed to create permanent git worktree', { cause: error });
|
|
4284
|
+
}
|
|
4285
|
+
return { workspace: target, branch, sourceWorkspace: root };
|
|
4286
|
+
}
|
|
4287
|
+
suffix += 1;
|
|
4288
|
+
}
|
|
4289
|
+
|
|
4290
|
+
throw new Error('Unable to allocate a permanent worktree name');
|
|
4291
|
+
}
|
|
4292
|
+
|
|
4293
|
+
async rollbackPermanentWorktree(created) {
|
|
4294
|
+
if (!created?.workspace || !created?.sourceWorkspace) return;
|
|
4295
|
+
try {
|
|
4296
|
+
await execFileAsync('git', ['-C', created.sourceWorkspace, 'worktree', 'remove', '--force', created.workspace], {
|
|
4297
|
+
timeout: 60000,
|
|
4298
|
+
maxBuffer: 1024 * 1024 * 4,
|
|
4299
|
+
});
|
|
4300
|
+
} catch {
|
|
4301
|
+
// Rollback is best-effort so the original create/persist failure remains visible.
|
|
4302
|
+
}
|
|
4303
|
+
if (!created.branch) return;
|
|
4304
|
+
try {
|
|
4305
|
+
await execFileAsync('git', ['-C', created.sourceWorkspace, 'branch', '-D', created.branch], {
|
|
4306
|
+
timeout: 30000,
|
|
4307
|
+
maxBuffer: 1024 * 1024 * 4,
|
|
4308
|
+
});
|
|
4309
|
+
} catch {
|
|
4310
|
+
// The worktree removal may already have pruned the newly created branch.
|
|
4311
|
+
}
|
|
4312
|
+
}
|
|
4313
|
+
|
|
4245
4314
|
async inspectForkWorktreeProject(workspace) {
|
|
4246
4315
|
const expanded = this.expandWorkspacePath(workspace);
|
|
4247
4316
|
const resolvedWorkspace = expanded ? path.resolve(expanded) : '';
|
|
@@ -4537,8 +4606,38 @@ class AgentManager extends EventEmitter {
|
|
|
4537
4606
|
reason: 'manual-archive',
|
|
4538
4607
|
recordHistory: !isEphemeralShellAgent(agent),
|
|
4539
4608
|
});
|
|
4609
|
+
this.scheduleCodexSessionArchive(agent);
|
|
4540
4610
|
return { agentId, archived: true, removed: true, removedMainPageSessionKeys };
|
|
4541
4611
|
}
|
|
4612
|
+
|
|
4613
|
+
scheduleCodexSessionArchive(agent) {
|
|
4614
|
+
if (
|
|
4615
|
+
!agent
|
|
4616
|
+
|| agent.providerSessionProvider !== 'codex'
|
|
4617
|
+
|| !agent.providerSessionId
|
|
4618
|
+
|| agent.providerSessionTemporary === true
|
|
4619
|
+
) {
|
|
4620
|
+
return;
|
|
4621
|
+
}
|
|
4622
|
+
|
|
4623
|
+
const sessionId = agent.providerSessionId;
|
|
4624
|
+
const session = {
|
|
4625
|
+
cliVersion: agent.cliVersion || '',
|
|
4626
|
+
cwd: agent.cwd || '',
|
|
4627
|
+
workspace: agent.projectWorkspace || '',
|
|
4628
|
+
providerHomePath: agent.providerHomePath || '',
|
|
4629
|
+
};
|
|
4630
|
+
void Promise.resolve()
|
|
4631
|
+
.then(() => this.archiveCodexSession(sessionId, session))
|
|
4632
|
+
.then(result => {
|
|
4633
|
+
if (result?.error) {
|
|
4634
|
+
console.error(`Failed to archive Codex session ${sessionId}: ${result.error}`);
|
|
4635
|
+
}
|
|
4636
|
+
})
|
|
4637
|
+
.catch(error => {
|
|
4638
|
+
console.error(`Failed to archive Codex session ${sessionId}:`, error);
|
|
4639
|
+
});
|
|
4640
|
+
}
|
|
4542
4641
|
|
|
4543
4642
|
async killAgent(agentId, options = {}) {
|
|
4544
4643
|
const agent = this.agents.get(agentId);
|
|
@@ -4884,7 +4983,7 @@ class AgentManager extends EventEmitter {
|
|
|
4884
4983
|
command: agent.command,
|
|
4885
4984
|
engineName: agent.engineName || '',
|
|
4886
4985
|
cwd: agent.cwd,
|
|
4887
|
-
projectWorkspace: agent.projectWorkspace || '',
|
|
4986
|
+
projectWorkspace: canonicalWorkspacePath(agent.projectWorkspace || ''),
|
|
4888
4987
|
gitWorktree: publicAgentGitWorktree(agent),
|
|
4889
4988
|
output: agent.output.slice(-2000),
|
|
4890
4989
|
previewText: agent.previewText || '',
|
|
@@ -5,7 +5,7 @@ const { resolveCompatibleCodexExecutable } = require('./executable-discovery');
|
|
|
5
5
|
|
|
6
6
|
const execFileAsync = promisify(execFile);
|
|
7
7
|
|
|
8
|
-
async function
|
|
8
|
+
async function runCodexSessionArchiveCommand(action, sessionId, session = {}, options = {}) {
|
|
9
9
|
const resolveExecutable = options.resolveCompatibleCodexExecutable || resolveCompatibleCodexExecutable;
|
|
10
10
|
const runExecFile = options.execFileAsync || execFileAsync;
|
|
11
11
|
const processEnv = options.processEnv || process.env;
|
|
@@ -18,7 +18,7 @@ async function unarchiveCodexSession(sessionId, session = {}, options = {}) {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
try {
|
|
21
|
-
await runExecFile(codexResolution.path || 'codex', [
|
|
21
|
+
await runExecFile(codexResolution.path || 'codex', [action, sessionId], {
|
|
22
22
|
cwd: session.cwd || session.workspace || os.homedir(),
|
|
23
23
|
env: session.providerHomePath
|
|
24
24
|
? { ...processEnv, CODEX_HOME: session.providerHomePath }
|
|
@@ -26,13 +26,13 @@ async function unarchiveCodexSession(sessionId, session = {}, options = {}) {
|
|
|
26
26
|
timeout: 30_000,
|
|
27
27
|
maxBuffer: 1024 * 1024,
|
|
28
28
|
});
|
|
29
|
-
return { unarchived: true };
|
|
29
|
+
return { [action === 'archive' ? 'archived' : 'unarchived']: true };
|
|
30
30
|
} catch (error) {
|
|
31
31
|
const message = [
|
|
32
32
|
error && error.stdout ? String(error.stdout).trim() : '',
|
|
33
33
|
error && error.stderr ? String(error.stderr).trim() : '',
|
|
34
34
|
error && error.message ? String(error.message).trim() : '',
|
|
35
|
-
].filter(Boolean).join('\n') ||
|
|
35
|
+
].filter(Boolean).join('\n') || `failed to ${action} Codex session`;
|
|
36
36
|
return {
|
|
37
37
|
error: message,
|
|
38
38
|
status: 409,
|
|
@@ -40,6 +40,15 @@ async function unarchiveCodexSession(sessionId, session = {}, options = {}) {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
async function archiveCodexSession(sessionId, session = {}, options = {}) {
|
|
44
|
+
return runCodexSessionArchiveCommand('archive', sessionId, session, options);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function unarchiveCodexSession(sessionId, session = {}, options = {}) {
|
|
48
|
+
return runCodexSessionArchiveCommand('unarchive', sessionId, session, options);
|
|
49
|
+
}
|
|
50
|
+
|
|
43
51
|
module.exports = {
|
|
52
|
+
archiveCodexSession,
|
|
44
53
|
unarchiveCodexSession,
|
|
45
54
|
};
|
|
@@ -73,6 +73,7 @@ const PERSISTED_SETTING_KEYS = new Set([
|
|
|
73
73
|
'lastMainWorkspace',
|
|
74
74
|
'workspaceHistory',
|
|
75
75
|
'projectWorkspaces',
|
|
76
|
+
'pinnedProjectWorkspaces',
|
|
76
77
|
'projectNames',
|
|
77
78
|
'theme',
|
|
78
79
|
'appearance',
|
|
@@ -287,6 +288,7 @@ class ConfigManager {
|
|
|
287
288
|
lastMainWorkspace: this.farmingDir,
|
|
288
289
|
workspaceHistory: [],
|
|
289
290
|
projectWorkspaces: [],
|
|
291
|
+
pinnedProjectWorkspaces: [],
|
|
290
292
|
projectNames: {},
|
|
291
293
|
theme: 'terminal',
|
|
292
294
|
appearance: 'light',
|
|
@@ -322,6 +324,7 @@ class ConfigManager {
|
|
|
322
324
|
lastMainWorkspace: this.farmingDir,
|
|
323
325
|
workspaceHistory: [],
|
|
324
326
|
projectWorkspaces: [],
|
|
327
|
+
pinnedProjectWorkspaces: [],
|
|
325
328
|
projectNames: {},
|
|
326
329
|
theme: 'terminal',
|
|
327
330
|
appearance: 'light',
|
|
@@ -371,6 +374,7 @@ class ConfigManager {
|
|
|
371
374
|
this.settings.lastMainWorkspace = this.normalizeMainWorkspace(this.settings.lastMainWorkspace, this.farmingDir);
|
|
372
375
|
this.settings.workspaceHistory = this.normalizeWorkspaceHistory(this.settings.workspaceHistory);
|
|
373
376
|
this.settings.projectWorkspaces = this.normalizeProjectWorkspaces(this.settings.projectWorkspaces);
|
|
377
|
+
this.settings.pinnedProjectWorkspaces = this.normalizeProjectWorkspaces(this.settings.pinnedProjectWorkspaces);
|
|
374
378
|
this.settings.projectNames = this.normalizeProjectNames(this.settings.projectNames);
|
|
375
379
|
this.settings.agentHomes = this.normalizeAgentHomes(this.settings.agentHomes);
|
|
376
380
|
if (this.settings.updateUrl === LEGACY_DEFAULT_UPDATE_URL || this.settings.updateUrl === API_DEFAULT_UPDATE_URL) {
|
|
@@ -813,6 +817,7 @@ class ConfigManager {
|
|
|
813
817
|
this.settings.lastMainWorkspace = this.normalizeMainWorkspace(this.settings.lastMainWorkspace, previousMainWorkspace);
|
|
814
818
|
this.settings.workspaceHistory = this.normalizeWorkspaceHistory(this.settings.workspaceHistory);
|
|
815
819
|
this.settings.projectWorkspaces = this.normalizeProjectWorkspaces(this.settings.projectWorkspaces);
|
|
820
|
+
this.settings.pinnedProjectWorkspaces = this.normalizeProjectWorkspaces(this.settings.pinnedProjectWorkspaces);
|
|
816
821
|
this.settings.projectNames = this.normalizeProjectNames(this.settings.projectNames);
|
|
817
822
|
this.settings.agentHomes = this.normalizeAgentHomes(this.settings.agentHomes);
|
|
818
823
|
this.settings.updateUrl = this.normalizeUpdateUrl(this.settings.updateUrl);
|
|
@@ -4,6 +4,7 @@ const { execFileSync } = require('child_process');
|
|
|
4
4
|
const { getUserLaunchAgents } = require('./cli-agents');
|
|
5
5
|
|
|
6
6
|
const DEFAULT_CODEX_APP_BIN = '/Applications/Codex.app/Contents/Resources/codex';
|
|
7
|
+
const DEFAULT_CHATGPT_APP_CODEX_BIN = '/Applications/ChatGPT.app/Contents/Resources/codex';
|
|
7
8
|
const executableVersionCache = new Map();
|
|
8
9
|
|
|
9
10
|
function getPathDirectories(pathEnv = process.env.PATH || '') {
|
|
@@ -90,6 +91,7 @@ function getPreferredExecutableCandidates(agentName, pathEnv = process.env.PATH
|
|
|
90
91
|
return [
|
|
91
92
|
process.env.FARMING_CODEX_BIN || '',
|
|
92
93
|
DEFAULT_CODEX_APP_BIN,
|
|
94
|
+
DEFAULT_CHATGPT_APP_CODEX_BIN,
|
|
93
95
|
...pathCandidates,
|
|
94
96
|
].filter(Boolean);
|
|
95
97
|
}
|
package/backend/server.js
CHANGED
|
@@ -5,6 +5,8 @@ const path = require('path');
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const os = require('os');
|
|
7
7
|
const crypto = require('crypto');
|
|
8
|
+
const { execFile } = require('child_process');
|
|
9
|
+
const { promisify } = require('util');
|
|
8
10
|
const { URLSearchParams, pathToFileURL } = require('url');
|
|
9
11
|
const AgentManager = require('./agent-manager');
|
|
10
12
|
const { runtimeKind } = require('./agent-runtime-binding');
|
|
@@ -85,6 +87,7 @@ const WS_PATH = routePath(BASE_PATH, '/ws');
|
|
|
85
87
|
const encodeCookieToken = TokenAuth.encodeCookieToken;
|
|
86
88
|
const MAX_CODEX_TRANSCRIPT_TURNS = 1000;
|
|
87
89
|
const INTERACTIVE_REFRESH_CACHE_MAX_AGE_MS = 3_000;
|
|
90
|
+
const execFileAsync = promisify(execFile);
|
|
88
91
|
|
|
89
92
|
const app = express();
|
|
90
93
|
const server = http.createServer(app);
|
|
@@ -1427,6 +1430,65 @@ app.post(routePath(BASE_PATH, '/api/agents/:agentId/fork'), express.json(), asyn
|
|
|
1427
1430
|
res.status(201).json(result);
|
|
1428
1431
|
});
|
|
1429
1432
|
|
|
1433
|
+
function resolveProjectActionRoot(rootId) {
|
|
1434
|
+
const root = workspaceRootRegistry.resolve(rootId);
|
|
1435
|
+
if (!root || root.kind === 'global') {
|
|
1436
|
+
throw new WorkspaceFileError('project workspace is required', 400);
|
|
1437
|
+
}
|
|
1438
|
+
return root;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
app.post(routePath(BASE_PATH, '/api/projects/reveal'), express.json(), async (req, res) => {
|
|
1442
|
+
try {
|
|
1443
|
+
const root = resolveProjectActionRoot(req.body?.rootId);
|
|
1444
|
+
const command = process.platform === 'darwin'
|
|
1445
|
+
? 'open'
|
|
1446
|
+
: process.platform === 'win32'
|
|
1447
|
+
? 'explorer'
|
|
1448
|
+
: 'xdg-open';
|
|
1449
|
+
await execFileAsync(command, [root.canonicalPath], {
|
|
1450
|
+
timeout: 15000,
|
|
1451
|
+
maxBuffer: 1024 * 1024,
|
|
1452
|
+
});
|
|
1453
|
+
res.json({ revealed: true, workspace: root.canonicalPath });
|
|
1454
|
+
} catch (error) {
|
|
1455
|
+
const status = error instanceof WorkspaceFileError ? error.statusCode : 500;
|
|
1456
|
+
res.status(status).json({ error: error.message || 'Failed to reveal project workspace' });
|
|
1457
|
+
}
|
|
1458
|
+
});
|
|
1459
|
+
|
|
1460
|
+
app.post(routePath(BASE_PATH, '/api/projects/create-worktree'), express.json(), async (req, res) => {
|
|
1461
|
+
let created = null;
|
|
1462
|
+
let previousProjects = null;
|
|
1463
|
+
try {
|
|
1464
|
+
const root = resolveProjectActionRoot(req.body?.rootId);
|
|
1465
|
+
created = await agentManager.createPermanentWorktree(root.canonicalPath);
|
|
1466
|
+
previousProjects = configManager.getSettings().projectWorkspaces || [];
|
|
1467
|
+
configManager.updateSettings({
|
|
1468
|
+
projectWorkspaces: [
|
|
1469
|
+
created.workspace,
|
|
1470
|
+
...previousProjects.filter(workspace => workspace !== created.workspace),
|
|
1471
|
+
],
|
|
1472
|
+
});
|
|
1473
|
+
res.status(201).json({
|
|
1474
|
+
workspace: created.workspace,
|
|
1475
|
+
branch: created.branch,
|
|
1476
|
+
projectWorkspaces: configManager.getSettings().projectWorkspaces,
|
|
1477
|
+
});
|
|
1478
|
+
} catch (error) {
|
|
1479
|
+
if (created) await agentManager.rollbackPermanentWorktree(created);
|
|
1480
|
+
if (previousProjects) {
|
|
1481
|
+
try {
|
|
1482
|
+
configManager.updateSettings({ projectWorkspaces: previousProjects });
|
|
1483
|
+
} catch {
|
|
1484
|
+
// Keep the original error visible; the failed settings write is already the root cause.
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
const status = error instanceof WorkspaceFileError ? error.statusCode : 400;
|
|
1488
|
+
res.status(status).json({ error: error.message || 'Failed to create permanent worktree' });
|
|
1489
|
+
}
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1430
1492
|
app.post(routePath(BASE_PATH, '/api/projects/delete-worktree'), express.json(), async (req, res) => {
|
|
1431
1493
|
const body = req.body || {};
|
|
1432
1494
|
const workspace = typeof body.workspace === 'string' ? body.workspace : '';
|
package/backend/usage-monitor.js
CHANGED
|
@@ -9,8 +9,8 @@ const { attachQuotaForecasts } = require('./usage-forecast');
|
|
|
9
9
|
const execFileAsync = promisify(execFile);
|
|
10
10
|
|
|
11
11
|
const USAGE_WINDOW_MS = 5 * 60 * 1000;
|
|
12
|
-
const USAGE_TIMELINE_WINDOW_MS = 60 * 60 * 1000;
|
|
13
|
-
const USAGE_TIMELINE_BUCKET_COUNT =
|
|
12
|
+
const USAGE_TIMELINE_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
13
|
+
const USAGE_TIMELINE_BUCKET_COUNT = 24;
|
|
14
14
|
const USAGE_DAILY_DAYS = 52 * 7;
|
|
15
15
|
const USAGE_DAILY_CACHE_MS = 5 * 60 * 1000;
|
|
16
16
|
const USAGE_LIVE_DAY_CACHE_MS = 5 * 1000;
|
|
@@ -152,6 +152,32 @@ function addTokenBreakdown(target, source) {
|
|
|
152
152
|
return target;
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
function usageAgentIdFromFilePath(filePath) {
|
|
156
|
+
const basename = path.basename(String(filePath || ''), path.extname(String(filePath || '')));
|
|
157
|
+
const uuid = basename.match(/([0-9a-f]{8}-[0-9a-f-]{27})$/i);
|
|
158
|
+
return uuid?.[1] || basename || 'unattributed';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function usageAgentLabel(provider, agentId) {
|
|
162
|
+
const providerName = {
|
|
163
|
+
codex: 'Codex',
|
|
164
|
+
claude: 'Claude',
|
|
165
|
+
opencode: 'OpenCode',
|
|
166
|
+
qoder: 'Qoder',
|
|
167
|
+
}[provider] || provider;
|
|
168
|
+
if (!agentId || agentId === 'unattributed') return providerName;
|
|
169
|
+
const shortId = agentId.length > 12 ? `…${agentId.slice(-6)}` : agentId;
|
|
170
|
+
return `${providerName} · ${shortId}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function attributeUsageEvent(event, provider, agentId) {
|
|
174
|
+
return {
|
|
175
|
+
...event,
|
|
176
|
+
agentId,
|
|
177
|
+
agentLabel: usageAgentLabel(provider, agentId),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
155
181
|
function subtractTokenBreakdown(current, previous) {
|
|
156
182
|
const result = emptyTokenBreakdown();
|
|
157
183
|
for (const field of Object.keys(result)) {
|
|
@@ -268,7 +294,9 @@ function buildUsageTimeline(providerEvents, options = {}) {
|
|
|
268
294
|
const windowMs = Math.max(60_000, options.windowMs ?? USAGE_TIMELINE_WINDOW_MS);
|
|
269
295
|
const bucketCount = Math.max(1, Math.floor(options.bucketCount ?? USAGE_TIMELINE_BUCKET_COUNT));
|
|
270
296
|
const bucketMs = windowMs / bucketCount;
|
|
271
|
-
const endAt =
|
|
297
|
+
const endAt = options.alignToBucket
|
|
298
|
+
? Math.ceil(now / bucketMs) * bucketMs
|
|
299
|
+
: now;
|
|
272
300
|
const startAt = endAt - windowMs;
|
|
273
301
|
const providerNames = Object.keys(providerEvents || {});
|
|
274
302
|
const points = Array.from({ length: bucketCount }, (_, index) => ({
|
|
@@ -652,11 +680,14 @@ function buildUsageDayDetail(providerEvents, options = {}) {
|
|
|
652
680
|
hour,
|
|
653
681
|
label: String(hour).padStart(2, '0'),
|
|
654
682
|
...emptyTokenBreakdown(),
|
|
683
|
+
agents: {},
|
|
655
684
|
}));
|
|
656
685
|
const providers = Object.fromEntries(
|
|
657
686
|
providerNames.map(provider => [provider, emptyTokenBreakdown()]),
|
|
658
687
|
);
|
|
659
688
|
const total = emptyTokenBreakdown();
|
|
689
|
+
const agents = new Map();
|
|
690
|
+
const agentLabels = options.agentLabels instanceof Map ? options.agentLabels : new Map();
|
|
660
691
|
|
|
661
692
|
for (const provider of providerNames) {
|
|
662
693
|
for (const event of providerEvents[provider] || []) {
|
|
@@ -664,8 +695,24 @@ function buildUsageDayDetail(providerEvents, options = {}) {
|
|
|
664
695
|
if (localDateKey(timestamp) !== date) continue;
|
|
665
696
|
const hour = new Date(timestamp).getHours();
|
|
666
697
|
if (!Number.isInteger(hour) || hour < 0 || hour > 23) continue;
|
|
698
|
+
const agentId = String(event?.agentId || 'unattributed');
|
|
699
|
+
const agentKey = `${provider}:${agentId}`;
|
|
700
|
+
let agent = agents.get(agentKey);
|
|
701
|
+
if (!agent) {
|
|
702
|
+
agent = {
|
|
703
|
+
key: agentKey,
|
|
704
|
+
provider,
|
|
705
|
+
sessionId: agentId === 'unattributed' ? '' : agentId,
|
|
706
|
+
label: agentLabels.get(agentKey) || event?.agentLabel || usageAgentLabel(provider, agentId),
|
|
707
|
+
...emptyTokenBreakdown(),
|
|
708
|
+
};
|
|
709
|
+
agents.set(agentKey, agent);
|
|
710
|
+
}
|
|
711
|
+
if (!hours[hour].agents[agentKey]) hours[hour].agents[agentKey] = emptyTokenBreakdown();
|
|
667
712
|
addTokenBreakdown(hours[hour], event);
|
|
713
|
+
addTokenBreakdown(hours[hour].agents[agentKey], event);
|
|
668
714
|
addTokenBreakdown(providers[provider], event);
|
|
715
|
+
addTokenBreakdown(agent, event);
|
|
669
716
|
addTokenBreakdown(total, event);
|
|
670
717
|
}
|
|
671
718
|
}
|
|
@@ -677,6 +724,9 @@ function buildUsageDayDetail(providerEvents, options = {}) {
|
|
|
677
724
|
total,
|
|
678
725
|
hours,
|
|
679
726
|
providers,
|
|
727
|
+
agents: Array.from(agents.values()).sort((left, right) => (
|
|
728
|
+
right.totalTokens - left.totalTokens || left.label.localeCompare(right.label)
|
|
729
|
+
)),
|
|
680
730
|
};
|
|
681
731
|
}
|
|
682
732
|
|
|
@@ -709,9 +759,11 @@ async function readDailyFileEvents(filePath, provider, minimumMtimeMs = 0) {
|
|
|
709
759
|
}
|
|
710
760
|
}
|
|
711
761
|
|
|
762
|
+
const agentId = usageAgentIdFromFilePath(filePath);
|
|
763
|
+
const attributedEvents = events.map(event => attributeUsageEvent(event, provider, agentId));
|
|
712
764
|
const truncated = stat.size > JSONL_TAIL_BYTES;
|
|
713
|
-
dailyFileEventCache.set(cacheKey, { signature, events, truncated });
|
|
714
|
-
return { events, mtimeMs: stat.mtimeMs, truncated };
|
|
765
|
+
dailyFileEventCache.set(cacheKey, { signature, events: attributedEvents, truncated });
|
|
766
|
+
return { events: attributedEvents, mtimeMs: stat.mtimeMs, truncated };
|
|
715
767
|
}
|
|
716
768
|
|
|
717
769
|
async function collectCodexDailyEventsWithRipgrep(roots, options = {}) {
|
|
@@ -765,7 +817,9 @@ async function collectCodexDailyEventsWithRipgrep(roots, options = {}) {
|
|
|
765
817
|
states.set(filePath, state);
|
|
766
818
|
}
|
|
767
819
|
const event = codexTokenEventFromRecord(record, state);
|
|
768
|
-
if (event && event.timestamp >= cutoffMs && event.timestamp <= now + 60_000)
|
|
820
|
+
if (event && event.timestamp >= cutoffMs && event.timestamp <= now + 60_000) {
|
|
821
|
+
events.push(attributeUsageEvent(event, 'codex', usageAgentIdFromFilePath(filePath)));
|
|
822
|
+
}
|
|
769
823
|
});
|
|
770
824
|
child.once('close', code => {
|
|
771
825
|
if (unavailable || (code !== 0 && code !== 1)) {
|
|
@@ -892,7 +946,8 @@ async function collectOpenCodeDailyEvents(homePaths, options = {}) {
|
|
|
892
946
|
{ openCodeHome: session.openCodeHome, timeoutMs: OPENCODE_COMMAND_TIMEOUT_MS },
|
|
893
947
|
);
|
|
894
948
|
const exported = JSON.parse(String(result?.stdout || '{}'));
|
|
895
|
-
const sessionEvents = openCodeTokenEventsFromExport(exported, { cutoffMs, now })
|
|
949
|
+
const sessionEvents = openCodeTokenEventsFromExport(exported, { cutoffMs, now })
|
|
950
|
+
.map(event => attributeUsageEvent(event, 'opencode', session.id));
|
|
896
951
|
successfulExports += 1;
|
|
897
952
|
openCodeSessionEventCache.set(cacheKey, { updatedAt: session.updatedAt, events: sessionEvents });
|
|
898
953
|
events.push(...sessionEvents);
|
|
@@ -1212,6 +1267,33 @@ class UsageMonitor {
|
|
|
1212
1267
|
this.liveDayCache.fetchedAt = 0;
|
|
1213
1268
|
}
|
|
1214
1269
|
|
|
1270
|
+
usageAgentLabels() {
|
|
1271
|
+
const labels = new Map();
|
|
1272
|
+
const agents = this.agentManager?.getState?.().agents;
|
|
1273
|
+
if (!Array.isArray(agents)) return labels;
|
|
1274
|
+
for (const agent of agents) {
|
|
1275
|
+
const provider = String(agent?.providerSessionProvider || '').trim();
|
|
1276
|
+
const sessionId = String(agent?.providerSessionId || '').trim();
|
|
1277
|
+
if (!provider || !sessionId) continue;
|
|
1278
|
+
const label = String(
|
|
1279
|
+
agent.customTitle
|
|
1280
|
+
|| agent.task
|
|
1281
|
+
|| agent.sessionTitle
|
|
1282
|
+
|| agent.providerSessionTitle
|
|
1283
|
+
|| '',
|
|
1284
|
+
).trim();
|
|
1285
|
+
if (label) labels.set(`${provider}:${sessionId}`, label);
|
|
1286
|
+
}
|
|
1287
|
+
return labels;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
buildUsageDay(providerEvents, date) {
|
|
1291
|
+
return buildUsageDayDetail(providerEvents, {
|
|
1292
|
+
date,
|
|
1293
|
+
agentLabels: this.usageAgentLabels(),
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1215
1297
|
getDailyUsage(options = {}) {
|
|
1216
1298
|
const now = options.now ?? Date.now();
|
|
1217
1299
|
if (
|
|
@@ -1247,7 +1329,7 @@ class UsageMonitor {
|
|
|
1247
1329
|
if (options.live === true && String(date || '').trim() === localDateKey(now)) {
|
|
1248
1330
|
const liveDate = String(date).trim();
|
|
1249
1331
|
const dailyFallback = this.dailyCache.value?.providerEvents
|
|
1250
|
-
?
|
|
1332
|
+
? this.buildUsageDay(this.dailyCache.value.providerEvents, liveDate)
|
|
1251
1333
|
: null;
|
|
1252
1334
|
const cachedFallback = this.liveDayCache.date === liveDate
|
|
1253
1335
|
? this.liveDayCache.value
|
|
@@ -1283,7 +1365,7 @@ class UsageMonitor {
|
|
|
1283
1365
|
now,
|
|
1284
1366
|
days: 1,
|
|
1285
1367
|
}).then(history => {
|
|
1286
|
-
const detail =
|
|
1368
|
+
const detail = this.buildUsageDay(history.providerEvents, liveDate);
|
|
1287
1369
|
if (this.liveDayCache.date === liveDate) {
|
|
1288
1370
|
this.liveDayCache.value = detail;
|
|
1289
1371
|
this.liveDayCache.fetchedAt = now;
|
|
@@ -1299,7 +1381,7 @@ class UsageMonitor {
|
|
|
1299
1381
|
now,
|
|
1300
1382
|
force: options.fresh === true,
|
|
1301
1383
|
});
|
|
1302
|
-
return
|
|
1384
|
+
return this.buildUsageDay(history.providerEvents, date);
|
|
1303
1385
|
}
|
|
1304
1386
|
|
|
1305
1387
|
async getUsageSummary(options = {}) {
|
|
@@ -1330,11 +1412,11 @@ class UsageMonitor {
|
|
|
1330
1412
|
historyWindowMs,
|
|
1331
1413
|
source: 'opencode session export',
|
|
1332
1414
|
});
|
|
1333
|
-
const timeline = buildUsageTimeline({
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
}
|
|
1415
|
+
const timeline = buildUsageTimeline(history.providerEvents, {
|
|
1416
|
+
now,
|
|
1417
|
+
windowMs: historyWindowMs,
|
|
1418
|
+
alignToBucket: true,
|
|
1419
|
+
});
|
|
1338
1420
|
const openCodeCoverage = history.coverage.find(entry => entry.provider === 'opencode');
|
|
1339
1421
|
const qoderCoverage = history.coverage.find(entry => entry.provider === 'qoder');
|
|
1340
1422
|
|