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.
Files changed (34) hide show
  1. package/README.md +8 -7
  2. package/README.zh_cn.md +8 -7
  3. package/backend/agent-manager.js +104 -5
  4. package/backend/codex-session-archive.js +13 -4
  5. package/backend/config-manager.js +5 -0
  6. package/backend/executable-discovery.js +2 -0
  7. package/backend/server.js +62 -0
  8. package/backend/usage-monitor.js +97 -15
  9. package/backend/workspace-directory.js +80 -0
  10. package/dist/assets/App-DWsa7DXG.js +206 -0
  11. package/dist/assets/{FileEditorMarkdownPreview--Cri80nQ.js → FileEditorMarkdownPreview-UlVd0O4D.js} +1 -1
  12. package/dist/assets/FileEditorPane-Dxx4rKOg.js +2 -0
  13. package/dist/assets/IconGlyphs-AU22gPDY.js +1 -0
  14. package/dist/assets/{ProjectFilesSection-Cz1wG4NT.js → ProjectFilesSection-DE7Nb9Jk.js} +3 -3
  15. package/dist/assets/ReviewPage-CeDKDyZh.js +3 -0
  16. package/dist/assets/code-dark-DPiuyeSp.css +1 -0
  17. package/dist/assets/{index-miO2r9IZ.js → index-CUFPJoa5.js} +2 -2
  18. package/dist/assets/main-Bqtb5kuz.css +1 -0
  19. package/dist/assets/{monaco.contribution-CyLosfZI.js → monaco.contribution-BXz-lfFB.js} +2 -2
  20. package/dist/assets/{tsMode-DGZTlTj8.js → tsMode-B0oUTcXQ.js} +1 -1
  21. package/dist/assets/workspace-editor-monaco-BICJESG0.js +4 -0
  22. package/dist/assets/workspace-editor-monaco-DRJ7IaXk.js +1 -0
  23. package/dist/farming-2/site.webmanifest +2 -0
  24. package/dist/index.html +1 -1
  25. package/frontend/skins/crt/app.js +2 -0
  26. package/package.json +1 -1
  27. package/dist/assets/App-CHYpgECZ.js +0 -208
  28. package/dist/assets/FileEditorPane-DGJlEKdf.js +0 -2
  29. package/dist/assets/IconGlyphs-Cc14sepw.js +0 -1
  30. package/dist/assets/ReviewPage-LonRc6Fz.js +0 -3
  31. package/dist/assets/code-dark-B1pAtO_P.css +0 -1
  32. package/dist/assets/main-DNe0jPw-.css +0 -1
  33. package/dist/assets/workspace-editor-monaco-5gFbeDPF.js +0 -1
  34. 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
  ![Farming Code project files and blame](./docs/products/code/assets/04-files-editor-blame.png)
76
83
 
77
- ![Separate Review entry points for tracked and untracked workspace changes](./docs/products/code/assets/10-review-workflow.png)
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
  ![Farming Code 项目文件与 Blame](./docs/products/code/assets/04-files-editor-blame.png)
76
83
 
77
- ![Files 中分开的已跟踪与未跟踪 Review 入口](./docs/products/code/assets/10-review-workflow.png)
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
 
@@ -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 createForkWorktree(workspace) {
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
- root = stdout.trim();
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 unarchiveCodexSession(sessionId, session = {}, options = {}) {
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', ['unarchive', sessionId], {
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') || 'failed to unarchive Codex session';
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 : '';
@@ -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 = 30;
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 = now;
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) events.push(event);
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
- ? buildUsageDayDetail(this.dailyCache.value.providerEvents, { date: liveDate })
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 = buildUsageDayDetail(history.providerEvents, { date: liveDate });
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 buildUsageDayDetail(history.providerEvents, { date });
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
- codex: codexUsage.tokenEvents,
1335
- claude: claudeUsage.tokenEvents,
1336
- opencode: openCodeUsage.tokenEvents,
1337
- }, { now, windowMs: historyWindowMs });
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