farming-code 2.2.8 → 2.2.11

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 (102) hide show
  1. package/README.md +36 -23
  2. package/README.zh_cn.md +36 -23
  3. package/THIRD_PARTY_NOTICES.md +11 -2
  4. package/backend/acp-checkpoint-store.js +198 -0
  5. package/backend/acp-runtime.js +306 -83
  6. package/backend/acp-session-state.js +202 -6
  7. package/backend/acp-transcript.js +112 -0
  8. package/backend/agent-activity.js +6 -157
  9. package/backend/agent-manager.js +1482 -730
  10. package/backend/agent-provider-session.js +17 -242
  11. package/backend/agent-runtime-binding.js +219 -0
  12. package/backend/agent-session-history.js +66 -1
  13. package/backend/auth.js +79 -6
  14. package/backend/codex-models.js +81 -84
  15. package/backend/codex-session-archive.js +45 -0
  16. package/backend/codex-terminal-profile.js +500 -0
  17. package/backend/codex-transcript-sanitizer.js +12 -0
  18. package/backend/codex-transcript.js +230 -8
  19. package/backend/config-manager.js +30 -0
  20. package/backend/control-api.js +192 -17
  21. package/backend/farming-net-pass.js +285 -0
  22. package/backend/farming-net-registry.js +112 -0
  23. package/backend/farming-net-server.js +298 -0
  24. package/backend/farming-session-store.js +5 -13
  25. package/backend/git-worktree-info.js +181 -0
  26. package/backend/local-session-engine.js +411 -186
  27. package/backend/main-page-session.js +5 -2
  28. package/backend/native-pty-controller-generation.js +106 -0
  29. package/backend/native-pty-host-client.js +275 -7
  30. package/backend/native-pty-host-identity.js +86 -0
  31. package/backend/native-pty-host.js +813 -114
  32. package/backend/native-session-engine.js +100 -28
  33. package/backend/packaged-node-pty.js +22 -2
  34. package/backend/provider-adapters.js +253 -0
  35. package/backend/provider-session-service.js +241 -0
  36. package/backend/runtime-observation.js +81 -0
  37. package/backend/server.js +294 -86
  38. package/backend/session-engine-bridge.js +21 -2
  39. package/backend/session-engine-router.js +1 -1
  40. package/backend/session-engine.js +1 -1
  41. package/backend/session-stream-protocol.js +185 -0
  42. package/backend/storage-layout.js +55 -0
  43. package/backend/terminal-attach-checkpoint.js +74 -0
  44. package/backend/terminal-exit-quiescence.js +39 -0
  45. package/backend/terminal-reducer-flow-control.js +97 -0
  46. package/backend/terminal-screen-state.js +11 -2
  47. package/backend/terminal-screen-worker-pool.js +59 -6
  48. package/backend/terminal-screen-worker-thread.js +97 -57
  49. package/backend/terminal-screen-worker.js +133 -51
  50. package/backend/terminal-state-serialization.js +127 -0
  51. package/backend/terminal-status.js +23 -4
  52. package/backend/usage-monitor.js +81 -4
  53. package/backend/workspace-directory.js +152 -0
  54. package/backend/workspace-file-router.js +182 -76
  55. package/backend/workspace-file-service.js +319 -4
  56. package/backend/workspace-root-registry.js +164 -0
  57. package/dist/assets/App-CHYpgECZ.js +208 -0
  58. package/dist/assets/{FileEditorMarkdownPreview-elKWc8Im.js → FileEditorMarkdownPreview--Cri80nQ.js} +92 -92
  59. package/dist/assets/FileEditorPane-DGJlEKdf.js +2 -0
  60. package/dist/assets/IconGlyphs-Cc14sepw.js +1 -0
  61. package/dist/assets/ProjectFilesSection-Cz1wG4NT.js +12 -0
  62. package/dist/assets/{ReviewPage-BaXu1ZdX.js → ReviewPage-LonRc6Fz.js} +1 -1
  63. package/dist/assets/code-dark-B1pAtO_P.css +1 -0
  64. package/dist/assets/file-icons-Bw2qd5iT.js +1 -0
  65. package/dist/assets/{index-BrbljRqn.js → index-miO2r9IZ.js} +3 -3
  66. package/dist/assets/main-DNe0jPw-.css +1 -0
  67. package/dist/assets/workspace-editor-model-BQol4qbA.js +1 -0
  68. package/dist/assets/workspace-editor-monaco-5gFbeDPF.js +1 -0
  69. package/dist/assets/workspace-editor-monaco-xPv8JzHH.js +4 -0
  70. package/dist/assets/workspace-view-state-DvYG_9PH.js +7 -0
  71. package/dist/assets/workspace-working-copy-D8-s_Sgh.js +1 -0
  72. package/dist/index.html +1 -1
  73. package/frontend/farming-net/app.css +625 -0
  74. package/frontend/farming-net/app.js +268 -0
  75. package/frontend/farming-net/index.html +86 -0
  76. package/frontend/reading-anchor.js +198 -0
  77. package/frontend/session-bridge.js +12 -3
  78. package/frontend/session-modal-bridge.js +5 -12
  79. package/frontend/skins/crt/app.js +1976 -793
  80. package/frontend/skins/crt/index.html +313 -23
  81. package/frontend/skins/crt/styles/billing.css +294 -223
  82. package/frontend/skins/crt/styles/monochrome-green.css +7 -2
  83. package/frontend/terminal-replay.js +372 -0
  84. package/package.json +10 -3
  85. package/shared/browser-protocol.d.ts +5 -0
  86. package/shared/browser-protocol.js +130 -0
  87. package/dist/assets/App-8dYAM6ql.js +0 -124
  88. package/dist/assets/FileEditorPane-RWiFD2cq.js +0 -5
  89. package/dist/assets/IconGlyphs-DfL0EBnj.js +0 -1
  90. package/dist/assets/ProjectFilesSection-Q4PDsWmM.js +0 -12
  91. package/dist/assets/code-dark-CDkOQAtK.css +0 -1
  92. package/dist/assets/file-icons-EFUGSSwf.js +0 -1
  93. package/dist/assets/main-D073SnW4.css +0 -1
  94. package/dist/assets/qoder-C9LmmOSf.svg +0 -1
  95. package/dist/assets/qoder-Cf9gl0Y5.svg +0 -1
  96. package/dist/assets/qoder-gHCinseV.svg +0 -1
  97. package/dist/assets/workspace-view-state-CTyDzk2D.js +0 -1
  98. package/dist/assets/zsh-CLpveKlF.svg +0 -1
  99. package/dist/assets/zsh-FxSpMPbz.svg +0 -1
  100. /package/dist/assets/{api-D1lyBYIQ.js → api-D8nyOEbz.js} +0 -0
  101. /package/dist/assets/{core-ZlAPicox.js → core-D0LFJkDt.js} +0 -0
  102. /package/dist/assets/{useWorkspaceMenuKeyboard-CneKAZUJ.js → useWorkspaceMenuKeyboard-Brws6Ar9.js} +0 -0
@@ -0,0 +1,298 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const http = require('http');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const TokenAuth = require('./auth');
8
+ const {
9
+ createFarmingNetPass,
10
+ loadOrCreateFarmingNetSigningIdentity,
11
+ PASS_QUERY_PARAM,
12
+ } = require('./farming-net-pass');
13
+ const { loadFarmingNetRegistry } = require('./farming-net-registry');
14
+ const storageLayout = require('./storage-layout');
15
+
16
+ const DEFAULT_PORT = 6693;
17
+ const DEFAULT_BASE_PATH = '/farming-net';
18
+
19
+ function normalizeBasePath(basePath) {
20
+ const value = String(basePath || '').trim();
21
+ if (!value || value === '/') return '';
22
+ const normalized = value.startsWith('/') ? value : `/${value}`;
23
+ return normalized.endsWith('/') ? normalized.slice(0, -1) : normalized;
24
+ }
25
+
26
+ function isTruthy(value) {
27
+ return ['1', 'true', 'yes', 'on'].includes(String(value || '').toLowerCase());
28
+ }
29
+
30
+ function safePort(value, fallback = DEFAULT_PORT) {
31
+ const port = Number(value);
32
+ return Number.isInteger(port) && port > 0 && port <= 65535 ? port : fallback;
33
+ }
34
+
35
+ function loadPackageVersion(projectRoot) {
36
+ try {
37
+ return String(JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')).version || 'dev');
38
+ } catch {
39
+ return 'dev';
40
+ }
41
+ }
42
+
43
+ function setSecurityHeaders(res) {
44
+ res.setHeader('Content-Security-Policy', [
45
+ "default-src 'self'",
46
+ "connect-src 'self' http: https:",
47
+ "img-src 'self' data:",
48
+ "style-src 'self'",
49
+ "script-src 'self'",
50
+ "base-uri 'none'",
51
+ "frame-ancestors 'none'",
52
+ "form-action 'self'",
53
+ ].join('; '));
54
+ res.setHeader('Cross-Origin-Opener-Policy', 'same-origin-allow-popups');
55
+ res.setHeader('Referrer-Policy', 'no-referrer');
56
+ res.setHeader('X-Content-Type-Options', 'nosniff');
57
+ res.setHeader('X-Frame-Options', 'DENY');
58
+ }
59
+
60
+ function sendBuffer(req, res, statusCode, body, contentType, cacheControl = 'no-store') {
61
+ const buffer = Buffer.isBuffer(body) ? body : Buffer.from(String(body));
62
+ res.writeHead(statusCode, {
63
+ 'Cache-Control': cacheControl,
64
+ 'Content-Length': buffer.length,
65
+ 'Content-Type': contentType,
66
+ });
67
+ if (req.method === 'HEAD') res.end();
68
+ else res.end(buffer);
69
+ }
70
+
71
+ function sendJson(req, res, statusCode, payload) {
72
+ sendBuffer(req, res, statusCode, `${JSON.stringify(payload)}\n`, 'application/json; charset=utf-8');
73
+ }
74
+
75
+ function readAsset(filePath) {
76
+ try {
77
+ return fs.readFileSync(filePath);
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ function browserRegistry(registry, basePath) {
84
+ return {
85
+ ...registry,
86
+ instances: registry.instances.map(instance => ({
87
+ ...instance,
88
+ endpoints: instance.endpoints.map((endpoint, index) => ({
89
+ ...endpoint,
90
+ launchUrl: instance.federated
91
+ ? `${basePath}/open/${encodeURIComponent(instance.id)}/${index}`
92
+ : endpoint.url,
93
+ })),
94
+ })),
95
+ };
96
+ }
97
+
98
+ function createFarmingNetServer(options = {}) {
99
+ const env = options.env || process.env;
100
+ const projectRoot = options.projectRoot || path.resolve(__dirname, '..');
101
+ const basePath = normalizeBasePath(options.basePath ?? env.FARMING_NET_BASE_PATH ?? DEFAULT_BASE_PATH);
102
+ const configDir = options.configDir
103
+ || env.FARMING_NET_CONFIG_DIR
104
+ || path.join(env.HOME || os.homedir(), '.farming-net');
105
+ const assetDir = options.assetDir || path.join(projectRoot, 'frontend', 'farming-net');
106
+ const registryFile = options.registryFile || storageLayout.farmingNetInstancesFile(configDir);
107
+ const stateFile = options.stateFile || storageLayout.farmingNetServerStateFile(configDir);
108
+ const packageVersion = options.packageVersion || loadPackageVersion(projectRoot);
109
+ const iconCandidates = [
110
+ options.iconFile,
111
+ path.join(projectRoot, 'public', 'farming-2', 'app-icon-v2-180.png'),
112
+ path.join(projectRoot, 'dist', 'farming-2', 'app-icon-v2-180.png'),
113
+ ].filter(Boolean);
114
+ const iconFile = iconCandidates.find(candidate => fs.existsSync(candidate)) || '';
115
+ fs.mkdirSync(configDir, { recursive: true });
116
+ loadFarmingNetRegistry(registryFile);
117
+ const signingIdentity = options.signingIdentity || loadOrCreateFarmingNetSigningIdentity({
118
+ privateKeyFile: storageLayout.farmingNetSigningPrivateKeyFile(configDir),
119
+ publicKeyFile: storageLayout.farmingNetSigningPublicKeyFile(configDir),
120
+ });
121
+
122
+ const authEnv = {
123
+ ...env,
124
+ FARMING_DISABLE_AUTH: env.FARMING_NET_DISABLE_AUTH || '',
125
+ FARMING_TOKEN: env.FARMING_NET_TOKEN || '',
126
+ };
127
+ const tokenAuth = options.tokenAuth || new TokenAuth({
128
+ basePath: basePath || '/',
129
+ cookieName: 'farming_net_token',
130
+ cookiePath: basePath || '/',
131
+ disabled: options.authDisabled === true || isTruthy(env.FARMING_NET_DISABLE_AUTH),
132
+ env: authEnv,
133
+ farmingDir: configDir,
134
+ farmingNetPassVerifier: false,
135
+ redirectQueryToken: true,
136
+ token: env.FARMING_NET_TOKEN || '',
137
+ });
138
+
139
+ const routePath = suffix => `${basePath}${suffix}` || '/';
140
+ const staticAssets = new Map([
141
+ [routePath('/app.css'), { file: path.join(assetDir, 'app.css'), type: 'text/css; charset=utf-8' }],
142
+ [routePath('/app.js'), { file: path.join(assetDir, 'app.js'), type: 'text/javascript; charset=utf-8' }],
143
+ [routePath('/icon.png'), { file: iconFile, type: 'image/png' }],
144
+ ]);
145
+
146
+ const server = http.createServer((req, res) => {
147
+ setSecurityHeaders(res);
148
+ tokenAuth.middleware()(req, res, () => {
149
+ try {
150
+ const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
151
+ const pathname = url.pathname;
152
+ if (!['GET', 'HEAD'].includes(String(req.method || 'GET').toUpperCase())) {
153
+ sendJson(req, res, 405, { error: 'Method not allowed' });
154
+ return;
155
+ }
156
+
157
+ if (pathname === routePath('/api/auth/status')) {
158
+ sendJson(req, res, 200, { authRequired: tokenAuth.isEnabled() });
159
+ return;
160
+ }
161
+ if (pathname === routePath('/api/status')) {
162
+ sendJson(req, res, 200, {
163
+ name: 'Farming Net',
164
+ version: packageVersion,
165
+ basePath: basePath || '/',
166
+ });
167
+ return;
168
+ }
169
+ if (pathname === routePath('/api/instances')) {
170
+ sendJson(req, res, 200, browserRegistry(loadFarmingNetRegistry(registryFile), basePath));
171
+ return;
172
+ }
173
+ const openPrefix = routePath('/open/');
174
+ if (pathname.startsWith(openPrefix)) {
175
+ const parts = pathname.slice(openPrefix.length).split('/');
176
+ const instanceId = parts[0] || '';
177
+ const endpointIndex = Number(parts[1]);
178
+ const registry = loadFarmingNetRegistry(registryFile);
179
+ const instance = registry.instances.find(item => item.id === instanceId);
180
+ const endpoint = Number.isInteger(endpointIndex) && endpointIndex >= 0
181
+ ? instance && instance.endpoints[endpointIndex]
182
+ : null;
183
+ if (!instance || !endpoint || parts.length !== 2) {
184
+ sendJson(req, res, 404, { error: 'Farming instance endpoint not found' });
185
+ return;
186
+ }
187
+ const target = new URL(endpoint.url);
188
+ if (instance.federated) {
189
+ target.searchParams.set(PASS_QUERY_PARAM, createFarmingNetPass(signingIdentity, {
190
+ audience: instance.id,
191
+ subject: 'owner',
192
+ ttlSeconds: env.FARMING_NET_PASS_TTL_SECONDS,
193
+ }));
194
+ }
195
+ res.writeHead(302, {
196
+ 'Cache-Control': 'no-store',
197
+ Location: target.toString(),
198
+ });
199
+ res.end();
200
+ return;
201
+ }
202
+ if (basePath && pathname === basePath) {
203
+ res.writeHead(302, { Location: `${basePath || ''}/` });
204
+ res.end();
205
+ return;
206
+ }
207
+ if (pathname === (basePath ? `${basePath}/` : '/')) {
208
+ const index = readAsset(path.join(assetDir, 'index.html'));
209
+ if (!index) {
210
+ sendJson(req, res, 500, { error: 'Farming Net frontend assets are missing' });
211
+ return;
212
+ }
213
+ sendBuffer(req, res, 200, index, 'text/html; charset=utf-8');
214
+ return;
215
+ }
216
+
217
+ const asset = staticAssets.get(pathname);
218
+ if (asset && asset.file) {
219
+ const body = readAsset(asset.file);
220
+ if (body) {
221
+ sendBuffer(req, res, 200, body, asset.type, 'public, max-age=3600');
222
+ return;
223
+ }
224
+ }
225
+ sendJson(req, res, 404, { error: 'Not found' });
226
+ } catch (error) {
227
+ console.error('Farming Net request failed:', error);
228
+ if (!res.headersSent) sendJson(req, res, 500, { error: 'Farming Net request failed' });
229
+ else res.end();
230
+ }
231
+ });
232
+ });
233
+
234
+ server.on('listening', () => {
235
+ const address = server.address();
236
+ const listeningPort = address && typeof address === 'object' ? address.port : null;
237
+ fs.writeFileSync(stateFile, `${JSON.stringify({
238
+ pid: process.pid,
239
+ port: listeningPort,
240
+ basePath: basePath || '/',
241
+ configDir,
242
+ registryFile,
243
+ signingIssuer: signingIdentity.issuer,
244
+ updatedAt: new Date().toISOString(),
245
+ }, null, 2)}\n`, { mode: 0o600 });
246
+ });
247
+ server.on('close', () => {
248
+ try {
249
+ const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
250
+ if (state.pid === process.pid) fs.unlinkSync(stateFile);
251
+ } catch {
252
+ // Ignore missing or stale state files.
253
+ }
254
+ });
255
+
256
+ return {
257
+ basePath,
258
+ configDir,
259
+ packageVersion,
260
+ registryFile,
261
+ server,
262
+ stateFile,
263
+ signingIdentity,
264
+ tokenAuth,
265
+ };
266
+ }
267
+
268
+ function startFromCommandLine() {
269
+ const service = createFarmingNetServer();
270
+ const port = safePort(process.env.FARMING_NET_PORT || process.env.PORT, DEFAULT_PORT);
271
+ const host = process.env.FARMING_NET_HOST || '0.0.0.0';
272
+ service.server.listen(port, host, () => {
273
+ const entryPath = `${service.basePath || ''}/`;
274
+ const token = service.tokenAuth.getToken();
275
+ const query = token ? `?token=${encodeURIComponent(token)}` : '';
276
+ console.log(`Farming Net ${service.packageVersion}`);
277
+ console.log(`URL: http://127.0.0.1:${port}${entryPath}${query}`);
278
+ console.log(`Registry: ${service.registryFile}`);
279
+ });
280
+
281
+ const shutdown = signal => {
282
+ console.log(`Farming Net received ${signal}; shutting down.`);
283
+ service.server.close(() => process.exit(0));
284
+ setTimeout(() => process.exit(1), 5000).unref();
285
+ };
286
+ process.once('SIGINT', () => shutdown('SIGINT'));
287
+ process.once('SIGTERM', () => shutdown('SIGTERM'));
288
+ }
289
+
290
+ if (require.main === module) startFromCommandLine();
291
+
292
+ module.exports = {
293
+ DEFAULT_BASE_PATH,
294
+ DEFAULT_PORT,
295
+ createFarmingNetServer,
296
+ normalizeBasePath,
297
+ safePort,
298
+ };
@@ -1,6 +1,7 @@
1
1
  const crypto = require('crypto');
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
+ const { legacyRuntimeMetadata } = require('./agent-runtime-binding');
4
5
  const storageLayout = require('./storage-layout');
5
6
 
6
7
  const SESSION_ID_PREFIX = 'fsess';
@@ -166,24 +167,14 @@ class FarmingSessionStore {
166
167
  providerSessionSource: typeof agent.providerSessionSource === 'string' ? agent.providerSessionSource : '',
167
168
  providerSessionResolvedAt: typeof agent.providerSessionResolvedAt === 'number' ? agent.providerSessionResolvedAt : null,
168
169
  providerSessionTitle: typeof agent.providerSessionTitle === 'string' ? agent.providerSessionTitle : '',
170
+ providerSessionWorkspace: typeof agent.providerSessionWorkspace === 'string' ? agent.providerSessionWorkspace : '',
169
171
  terminalInputReceived: agent.terminalInputReceived === true,
170
- codexRuntimeMode: typeof agent.codexRuntimeMode === 'string' ? agent.codexRuntimeMode : '',
171
- agentRuntimeMode: typeof agent.agentRuntimeMode === 'string' ? agent.agentRuntimeMode : 'terminal',
172
- acpState: typeof agent.acpState === 'string' ? agent.acpState : '',
173
- acpError: typeof agent.acpError === 'string' ? agent.acpError : '',
174
- acpStopReason: typeof agent.acpStopReason === 'string' ? agent.acpStopReason : '',
175
- acpSessionUpdatedAt: typeof agent.acpSessionUpdatedAt === 'string' ? agent.acpSessionUpdatedAt : '',
176
- codexAppServerHomePath: typeof agent.codexAppServerHomePath === 'string' ? agent.codexAppServerHomePath : '',
177
- codexAppServerState: typeof agent.codexAppServerState === 'string' ? agent.codexAppServerState : '',
178
- codexAppServerEndpoint: typeof agent.codexAppServerEndpoint === 'string' ? agent.codexAppServerEndpoint : '',
179
- codexAppServerThreadId: typeof agent.codexAppServerThreadId === 'string' ? agent.codexAppServerThreadId : '',
180
- codexAppServerTurnId: typeof agent.codexAppServerTurnId === 'string' ? agent.codexAppServerTurnId : '',
181
- codexAppServerError: typeof agent.codexAppServerError === 'string' ? agent.codexAppServerError : '',
182
- codexCliObserverDeferred: agent.codexCliObserverDeferred === true,
172
+ ...legacyRuntimeMetadata(agent),
183
173
  engine: typeof agent.engineName === 'string' ? agent.engineName : '',
184
174
  category: typeof agent.category === 'string' ? agent.category : '',
185
175
  task: typeof agent.task === 'string' ? agent.task : '',
186
176
  workflowTemplate: typeof agent.workflowTemplate === 'string' ? agent.workflowTemplate : '',
177
+ wantsMain: agent.wantsMain === true,
187
178
  pinned: agent.pinned === true,
188
179
  projectOrder: typeof agent.projectOrder === 'number' && Number.isFinite(agent.projectOrder) ? agent.projectOrder : null,
189
180
  pinnedOrder: typeof agent.pinnedOrder === 'number' && Number.isFinite(agent.pinnedOrder) ? agent.pinnedOrder : null,
@@ -195,6 +186,7 @@ class FarmingSessionStore {
195
186
  attentionOutputSeq: typeof agent.attentionOutputSeq === 'number' ? agent.attentionOutputSeq : null,
196
187
  archived: agent.archived === true,
197
188
  archivedAt: typeof agent.archivedAt === 'number' ? agent.archivedAt : null,
189
+ customTitle: typeof agent.customTitle === 'string' ? agent.customTitle : '',
198
190
  title: typeof agent.customTitle === 'string' && agent.customTitle
199
191
  ? agent.customTitle
200
192
  : (typeof agent.providerSessionTitle === 'string' && agent.providerSessionTitle
@@ -0,0 +1,181 @@
1
+ const { execFile } = require('child_process');
2
+ const path = require('path');
3
+ const { promisify } = require('util');
4
+
5
+ const execFileAsync = promisify(execFile);
6
+ const DEFAULT_TIMEOUT_MS = 5000;
7
+ const DEFAULT_CACHE_MS = 3000;
8
+ const cache = new Map();
9
+
10
+ function normalizePathValue(value) {
11
+ if (typeof value !== 'string') return '';
12
+ const trimmed = value.trim();
13
+ if (!trimmed) return '';
14
+ return path.resolve(trimmed);
15
+ }
16
+
17
+ function isSameOrDescendantPath(root, target) {
18
+ const relative = path.relative(root, target);
19
+ return relative === '' || Boolean(relative) && !relative.startsWith('..') && !path.isAbsolute(relative);
20
+ }
21
+
22
+ function normalizeBranchRef(value) {
23
+ const branch = String(value || '').trim();
24
+ return branch.startsWith('refs/heads/') ? branch.slice('refs/heads/'.length) : branch;
25
+ }
26
+
27
+ function parseGitWorktreeList(output) {
28
+ const records = [];
29
+ let current = null;
30
+
31
+ for (const token of String(output || '').split('\0')) {
32
+ if (!token) {
33
+ if (current) records.push(current);
34
+ current = null;
35
+ continue;
36
+ }
37
+
38
+ const separator = token.indexOf(' ');
39
+ const key = separator === -1 ? token : token.slice(0, separator);
40
+ const value = separator === -1 ? '' : token.slice(separator + 1);
41
+ if (key === 'worktree') {
42
+ if (current) records.push(current);
43
+ current = { path: normalizePathValue(value) };
44
+ continue;
45
+ }
46
+ if (!current) continue;
47
+
48
+ if (key === 'HEAD') current.head = value;
49
+ if (key === 'branch') current.branch = normalizeBranchRef(value);
50
+ if (key === 'bare') current.bare = true;
51
+ if (key === 'detached') current.detached = true;
52
+ if (key === 'locked') {
53
+ current.locked = true;
54
+ current.lockReason = value || '';
55
+ }
56
+ if (key === 'prunable') {
57
+ current.prunable = true;
58
+ current.pruneReason = value || '';
59
+ }
60
+ }
61
+
62
+ if (current) records.push(current);
63
+ return records.filter(record => record.path);
64
+ }
65
+
66
+ function matchingWorktree(records, target) {
67
+ return records
68
+ .filter(record => isSameOrDescendantPath(record.path, target))
69
+ .sort((a, b) => b.path.length - a.path.length)[0] || null;
70
+ }
71
+
72
+ async function inspectGitWorktreeUncached(workspace, options = {}) {
73
+ const candidate = normalizePathValue(workspace);
74
+ if (!candidate) return null;
75
+ const timeout = Number.isFinite(options.timeoutMs)
76
+ ? Math.max(250, options.timeoutMs)
77
+ : DEFAULT_TIMEOUT_MS;
78
+ const exec = options.execFileAsync || execFileAsync;
79
+
80
+ try {
81
+ const commonDirPromise = exec('git', ['-C', candidate, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
82
+ timeout,
83
+ maxBuffer: 1024 * 1024,
84
+ }).catch(async () => {
85
+ const { stdout } = await exec('git', ['-C', candidate, 'rev-parse', '--git-common-dir'], {
86
+ timeout,
87
+ maxBuffer: 1024 * 1024,
88
+ });
89
+ const value = String(stdout || '').trim();
90
+ return { stdout: path.isAbsolute(value) ? value : path.resolve(candidate, value) };
91
+ });
92
+ const [{ stdout: topLevelOutput }, { stdout: commonDirOutput }, { stdout: listOutput }] = await Promise.all([
93
+ exec('git', ['-C', candidate, 'rev-parse', '--show-toplevel'], {
94
+ timeout,
95
+ maxBuffer: 1024 * 1024,
96
+ }),
97
+ commonDirPromise,
98
+ exec('git', ['-C', candidate, 'worktree', 'list', '--porcelain', '-z'], {
99
+ timeout,
100
+ maxBuffer: 4 * 1024 * 1024,
101
+ }),
102
+ ]);
103
+
104
+ const topLevel = normalizePathValue(topLevelOutput);
105
+ const commonDir = normalizePathValue(commonDirOutput);
106
+ const worktrees = parseGitWorktreeList(listOutput);
107
+ const worktree = matchingWorktree(worktrees, topLevel || candidate);
108
+ if (!topLevel || !commonDir || !worktree) return null;
109
+ const mainWorktree = worktrees.find(record => !record.bare) || null;
110
+ const linked = Boolean(mainWorktree && mainWorktree.path !== worktree.path);
111
+ const mainWorkspace = mainWorktree ? mainWorktree.path : worktree.path;
112
+ const worktreeItems = worktrees.map(record => ({
113
+ workspace: record.path,
114
+ head: record.head || '',
115
+ branch: record.branch || '',
116
+ bare: record.bare === true,
117
+ detached: record.detached === true,
118
+ locked: record.locked === true,
119
+ lockReason: record.lockReason || '',
120
+ prunable: record.prunable === true,
121
+ pruneReason: record.pruneReason || '',
122
+ current: record.path === worktree.path,
123
+ main: record.path === mainWorkspace,
124
+ }));
125
+
126
+ return {
127
+ workspace: worktree.path,
128
+ commonDir,
129
+ mainWorkspace,
130
+ linked,
131
+ branch: worktree.branch || '',
132
+ head: worktree.head || '',
133
+ detached: worktree.detached === true,
134
+ locked: worktree.locked === true,
135
+ lockReason: worktree.lockReason || '',
136
+ prunable: worktree.prunable === true,
137
+ pruneReason: worktree.pruneReason || '',
138
+ worktrees: worktreeItems,
139
+ };
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+
145
+ async function inspectGitWorktree(workspace, options = {}) {
146
+ if (options.execFileAsync) return inspectGitWorktreeUncached(workspace, options);
147
+ const candidate = normalizePathValue(workspace);
148
+ if (!candidate) return null;
149
+ const now = Date.now();
150
+ const maxAgeMs = Number.isFinite(options.cacheMs)
151
+ ? Math.max(0, options.cacheMs)
152
+ : DEFAULT_CACHE_MS;
153
+ const cached = cache.get(candidate);
154
+ if (cached && now - cached.createdAt <= maxAgeMs) return cached.promise;
155
+
156
+ const promise = inspectGitWorktreeUncached(candidate, options);
157
+ cache.set(candidate, { createdAt: now, promise });
158
+ promise.catch(() => {
159
+ if (cache.get(candidate)?.promise === promise) cache.delete(candidate);
160
+ });
161
+ return promise;
162
+ }
163
+
164
+ async function isLinkedWorktreeOf(workspace, candidate, options = {}) {
165
+ const [sourceInfo, candidateInfo] = await Promise.all([
166
+ inspectGitWorktree(workspace, options),
167
+ inspectGitWorktree(candidate, options),
168
+ ]);
169
+ return Boolean(
170
+ sourceInfo
171
+ && candidateInfo
172
+ && sourceInfo.commonDir === candidateInfo.commonDir
173
+ && candidateInfo.linked
174
+ );
175
+ }
176
+
177
+ module.exports = {
178
+ inspectGitWorktree,
179
+ isLinkedWorktreeOf,
180
+ parseGitWorktreeList,
181
+ };