@turns/dev 0.0.1

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.
@@ -0,0 +1,241 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { workingDirectoryIsAllowed } from "./directory-policy.js";
5
+ import { uploadFileBytes, uploadFileStream } from "./path-resolution.js";
6
+ /**
7
+ * Answering "what is in that directory?" from the machine that knows.
8
+ *
9
+ * A reader's browser cannot reach this machine, and this machine will not open
10
+ * a port to let it try. So a browse is served the way an embedded image already
11
+ * is: the daemon reads the path, puts what it found in the account's own
12
+ * prefix, and the server hands the reader a signed URL for that object. Regular
13
+ * files stream from disk to that object; nothing stays open once the one browse
14
+ * upload finishes.
15
+ *
16
+ * A directory has no bytes, so what is uploaded for one is a listing of it,
17
+ * stored at `_index.json` beneath the directory. Every path is canonicalised
18
+ * before the allowlist is consulted, so a symlink pointing out of an allowed
19
+ * tree is not a way to read the rest of the disk.
20
+ */
21
+ /** Kept in step with `MAX_DIRECTORY_ENTRIES` in the Worker's shared listing. */
22
+ export const MAX_DIRECTORY_ENTRIES = 2_000;
23
+ /** Kept in step with `DIRECTORY_INDEX_NAME` in the Worker's shared listing. */
24
+ export const DIRECTORY_INDEX_NAME = '_index.json';
25
+ /** One MB below the Worker request-body ceiling, matching the R2 endpoint. */
26
+ export const MAX_BROWSE_FILE_BYTES = 199 * 1024 * 1024;
27
+ /** The derived label used in reader-facing errors. */
28
+ export const BROWSE_FILE_LIMIT_LABEL = `${Math.round(MAX_BROWSE_FILE_BYTES / (1024 * 1024))} MB`;
29
+ /** Extensions a reader can be shown as text, whatever produced them. */
30
+ const TEXT_EXTENSIONS = new Set([
31
+ '.bash',
32
+ '.c',
33
+ '.cc',
34
+ '.cfg',
35
+ '.conf',
36
+ '.cpp',
37
+ '.cs',
38
+ '.css',
39
+ '.csv',
40
+ '.diff',
41
+ '.env',
42
+ '.go',
43
+ '.gradle',
44
+ '.h',
45
+ '.hpp',
46
+ '.htm',
47
+ '.html',
48
+ '.ini',
49
+ '.java',
50
+ '.jsonl',
51
+ '.jsx',
52
+ '.kt',
53
+ '.less',
54
+ '.lock',
55
+ '.log',
56
+ '.lua',
57
+ '.m',
58
+ '.md',
59
+ '.mjs',
60
+ '.cjs',
61
+ '.mts',
62
+ '.patch',
63
+ '.php',
64
+ '.pl',
65
+ '.properties',
66
+ '.py',
67
+ '.r',
68
+ '.rb',
69
+ '.rs',
70
+ '.scss',
71
+ '.sh',
72
+ '.sql',
73
+ '.swift',
74
+ '.tf',
75
+ '.toml',
76
+ '.ts',
77
+ '.tsx',
78
+ '.txt',
79
+ '.vue',
80
+ '.xml',
81
+ '.yaml',
82
+ '.yml',
83
+ '.zsh',
84
+ ]);
85
+ /** Extensions carrying their own type, where guessing text would be wrong. */
86
+ const BINARY_CONTENT_TYPES = new Map([
87
+ ['.apng', 'image/apng'],
88
+ ['.avif', 'image/avif'],
89
+ ['.bmp', 'image/bmp'],
90
+ ['.gif', 'image/gif'],
91
+ ['.gz', 'application/gzip'],
92
+ ['.heic', 'image/heic'],
93
+ ['.ico', 'image/x-icon'],
94
+ ['.jpeg', 'image/jpeg'],
95
+ ['.jpg', 'image/jpeg'],
96
+ ['.mp3', 'audio/mpeg'],
97
+ ['.mp4', 'video/mp4'],
98
+ ['.pdf', 'application/pdf'],
99
+ ['.png', 'image/png'],
100
+ ['.svg', 'image/svg+xml'],
101
+ ['.wasm', 'application/wasm'],
102
+ ['.webm', 'video/webm'],
103
+ ['.webp', 'image/webp'],
104
+ ['.zip', 'application/zip'],
105
+ ]);
106
+ /**
107
+ * How one browsed file should be served back.
108
+ *
109
+ * A name with no extension at all — `README`, `Makefile`, `Dockerfile` — is
110
+ * nearly always text, and is the one guess worth making. Everything unrecognised
111
+ * is served as bytes: the Worker sends it with `nosniff` under a sandboxing
112
+ * policy, so an unhelpful type is only ever unhelpful.
113
+ */
114
+ export function browseContentType(file) {
115
+ const extension = path.extname(file).toLowerCase();
116
+ const known = BINARY_CONTENT_TYPES.get(extension);
117
+ if (known)
118
+ return known;
119
+ if (extension === '.json')
120
+ return 'application/json';
121
+ if (!extension || TEXT_EXTENSIONS.has(extension))
122
+ return 'text/plain; charset=utf-8';
123
+ return 'application/octet-stream';
124
+ }
125
+ function isoTime(value) {
126
+ return Number.isFinite(value) ? new Date(value).toISOString() : '';
127
+ }
128
+ /**
129
+ * The path a browse settled on, with what the filesystem says it is.
130
+ *
131
+ * A relative path or a bare `~` is the reader's shorthand for somewhere inside
132
+ * the session's own tree, and is expanded here rather than being refused: the
133
+ * browser navigates by clicking, and the paths it clicks come from listings
134
+ * this machine wrote. Canonicalisation happens before the allowlist test, and
135
+ * the allowlist is the daemon's, not the session's.
136
+ */
137
+ export function resolveBrowsePath(requested, { workingDirectory = '', allowedDirectories = [] } = {}) {
138
+ const raw = String(requested || '').trim();
139
+ const home = os.homedir();
140
+ const expanded = raw === '~' || raw.startsWith('~/') ? path.join(home, raw.slice(1)) : raw;
141
+ const base = workingDirectory || home;
142
+ const absolute = !expanded ? base : path.isAbsolute(expanded) ? expanded : path.resolve(base, expanded);
143
+ let canonical;
144
+ let stat;
145
+ try {
146
+ canonical = fs.realpathSync(absolute);
147
+ stat = fs.statSync(canonical);
148
+ }
149
+ catch {
150
+ throw new Error(`No such file or directory: ${absolute}`);
151
+ }
152
+ if (!stat.isFile() && !stat.isDirectory())
153
+ throw new Error(`Not a readable file or directory: ${canonical}`);
154
+ // The same allowlist the daemon runs harnesses under, asked about the
155
+ // directory a browse would show: an allowed root is browsable, and a file is
156
+ // reachable exactly when the directory holding it is.
157
+ if (!workingDirectoryIsAllowed(stat.isDirectory() ? canonical : path.dirname(canonical), allowedDirectories)) {
158
+ throw new Error(`Path is outside the daemon allowlist: ${canonical}`);
159
+ }
160
+ return { path: canonical, stat };
161
+ }
162
+ /**
163
+ * One directory as this machine sees it now.
164
+ *
165
+ * Each name is stat'ed through its symlink so a listing says what clicking it
166
+ * would open, and keeps the fact that it is a link. A name the machine cannot
167
+ * stat — a dangling link, a directory it may not enter — is still listed, with
168
+ * nothing claimed about it: leaving it out would misdescribe the directory.
169
+ */
170
+ export function readDirectory(directory) {
171
+ const names = fs.readdirSync(directory, { withFileTypes: true });
172
+ const entries = names.slice(0, MAX_DIRECTORY_ENTRIES).map((name) => {
173
+ const symlink = name.isSymbolicLink();
174
+ let stat = null;
175
+ try {
176
+ stat = fs.statSync(path.join(directory, name.name));
177
+ }
178
+ catch { }
179
+ const kind = stat ? (stat.isDirectory() ? 'directory' : stat.isFile() ? 'file' : 'other') : 'other';
180
+ return {
181
+ name: name.name,
182
+ kind,
183
+ ...(stat?.isFile() ? { size: stat.size } : {}),
184
+ ...(stat ? { modifiedAt: isoTime(stat.mtimeMs) } : {}),
185
+ ...(symlink ? { symlink: true } : {}),
186
+ };
187
+ });
188
+ // No timestamp: the object's own upload time says when this was read, and a
189
+ // clock inside the body would make every read produce different bytes.
190
+ return { path: directory, entries, truncated: names.length > MAX_DIRECTORY_ENTRIES };
191
+ }
192
+ /** Where one directory's listing is stored, as a path in the machine's space. */
193
+ export function directoryIndexPath(directory) {
194
+ return `${String(directory || '').replace(/\/+$/u, '')}/${DIRECTORY_INDEX_NAME}`;
195
+ }
196
+ /**
197
+ * Store one path for a reader, and say what was stored.
198
+ *
199
+ * An empty file is a legitimate thing to open and has no bytes to upload, so it
200
+ * is reported rather than sent — the reader is told the file is empty instead of
201
+ * being handed a URL for an object that was never written. A file past the
202
+ * upload ceiling is refused here, where the size is known, rather than by the
203
+ * Worker after the bytes have crossed the network.
204
+ */
205
+ export async function browseMachinePath({ requested, machine, baseUrl, options, workingDirectory = '', fetchImpl = fetch, }) {
206
+ const resolved = resolveBrowsePath(requested, {
207
+ workingDirectory,
208
+ allowedDirectories: options.allowedDirectories || [],
209
+ });
210
+ if (resolved.stat.isDirectory()) {
211
+ const body = new TextEncoder().encode(JSON.stringify(readDirectory(resolved.path)));
212
+ if (body.byteLength > MAX_BROWSE_FILE_BYTES)
213
+ throw new Error(`Directory listing is too large: ${resolved.path}`);
214
+ await uploadFileBytes({
215
+ baseUrl,
216
+ options,
217
+ machine,
218
+ absolutePath: directoryIndexPath(resolved.path),
219
+ contentType: 'application/json',
220
+ body,
221
+ fetchImpl,
222
+ });
223
+ return { path: resolved.path, kind: 'directory', bytes: body.byteLength, contentType: 'application/json' };
224
+ }
225
+ const contentType = browseContentType(resolved.path);
226
+ if (resolved.stat.size > MAX_BROWSE_FILE_BYTES) {
227
+ throw new Error(`File is larger than the ${BROWSE_FILE_LIMIT_LABEL} browse limit.`);
228
+ }
229
+ if (resolved.stat.size === 0)
230
+ return { path: resolved.path, kind: 'file', bytes: 0, contentType };
231
+ await uploadFileStream({
232
+ baseUrl,
233
+ options,
234
+ machine,
235
+ absolutePath: resolved.path,
236
+ contentType,
237
+ bytes: resolved.stat.size,
238
+ fetchImpl,
239
+ });
240
+ return { path: resolved.path, kind: 'file', bytes: resolved.stat.size, contentType };
241
+ }
@@ -0,0 +1,136 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ const MAX_WALK = 64;
4
+ const MAX_REMOTE_NAME = 80;
5
+ const MAX_REMOTE_URL = 2_000;
6
+ const MAX_REMOTES = 20;
7
+ const MAX_ROOT = 2_000;
8
+ function existingDirectory(start) {
9
+ let dir = path.resolve(String(start || ''));
10
+ if (!dir)
11
+ return '';
12
+ for (let index = 0; index < MAX_WALK; index += 1) {
13
+ try {
14
+ const stat = fs.statSync(dir);
15
+ if (stat.isDirectory())
16
+ return dir;
17
+ if (stat.isFile())
18
+ dir = path.dirname(dir);
19
+ else
20
+ break;
21
+ }
22
+ catch {
23
+ const parent = path.dirname(dir);
24
+ if (parent === dir)
25
+ break;
26
+ dir = parent;
27
+ }
28
+ }
29
+ return '';
30
+ }
31
+ function commonGitDir(gitDir) {
32
+ try {
33
+ const raw = fs.readFileSync(path.join(gitDir, 'commondir'), 'utf8').trim();
34
+ if (raw) {
35
+ const resolved = path.isAbsolute(raw) ? raw : path.resolve(gitDir, raw);
36
+ if (fs.statSync(resolved).isDirectory())
37
+ return resolved;
38
+ }
39
+ }
40
+ catch { }
41
+ try {
42
+ return fs.statSync(gitDir).isDirectory() ? gitDir : '';
43
+ }
44
+ catch {
45
+ return '';
46
+ }
47
+ }
48
+ function resolveGitDir(gitPath) {
49
+ let stat;
50
+ try {
51
+ stat = fs.statSync(gitPath);
52
+ }
53
+ catch {
54
+ return '';
55
+ }
56
+ if (stat.isDirectory())
57
+ return commonGitDir(gitPath);
58
+ if (!stat.isFile())
59
+ return '';
60
+ let text = '';
61
+ try {
62
+ text = fs.readFileSync(gitPath, 'utf8');
63
+ }
64
+ catch {
65
+ return '';
66
+ }
67
+ const match = /^\s*gitdir:\s*(.+?)\s*$/mu.exec(text);
68
+ if (!match)
69
+ return '';
70
+ const pointed = path.isAbsolute(match[1]) ? match[1] : path.resolve(path.dirname(gitPath), match[1]);
71
+ return commonGitDir(pointed);
72
+ }
73
+ export function readGitRemotes(gitDir) {
74
+ let config = '';
75
+ try {
76
+ config = fs.readFileSync(path.join(gitDir, 'config'), 'utf8');
77
+ }
78
+ catch {
79
+ return [];
80
+ }
81
+ const remotes = [];
82
+ const seen = new Set();
83
+ let current = '';
84
+ for (const line of config.split(/\r?\n/u)) {
85
+ const section = /^\s*\[remote\s+"([^"]+)"\]\s*$/iu.exec(line) || /^\s*\[remote\s+([^\s\]]+)\]\s*$/iu.exec(line);
86
+ if (section) {
87
+ current = section[1];
88
+ continue;
89
+ }
90
+ if (/^\s*\[/u.test(line)) {
91
+ current = '';
92
+ continue;
93
+ }
94
+ if (!current)
95
+ continue;
96
+ const url = /^\s*url\s*=\s*(.+?)\s*$/iu.exec(line);
97
+ if (!url)
98
+ continue;
99
+ const name = String(current).trim().slice(0, MAX_REMOTE_NAME);
100
+ const value = String(url[1]).trim().slice(0, MAX_REMOTE_URL);
101
+ const key = `${name}\0${value}`;
102
+ if (!name || !value || seen.has(key))
103
+ continue;
104
+ seen.add(key);
105
+ remotes.push({ name, url: value });
106
+ if (remotes.length >= MAX_REMOTES)
107
+ break;
108
+ }
109
+ return remotes;
110
+ }
111
+ /** Nearest ancestor git work tree, including gitfile / worktree layouts. */
112
+ export function findGitWorkTree(cwd) {
113
+ let dir = existingDirectory(cwd);
114
+ if (!dir)
115
+ return null;
116
+ for (let index = 0; index < MAX_WALK; index += 1) {
117
+ const gitDir = resolveGitDir(path.join(dir, '.git'));
118
+ if (gitDir)
119
+ return { root: dir.slice(0, MAX_ROOT), gitDir };
120
+ const parent = path.dirname(dir);
121
+ if (parent === dir)
122
+ break;
123
+ dir = parent;
124
+ }
125
+ return null;
126
+ }
127
+ /** Associate a session cwd with its enclosing git remotes, if any. */
128
+ export function discoverGitAssociation(cwd) {
129
+ const found = findGitWorkTree(cwd);
130
+ if (!found)
131
+ return null;
132
+ const remotes = readGitRemotes(found.gitDir);
133
+ if (!found.root && !remotes.length)
134
+ return null;
135
+ return { root: found.root, remotes };
136
+ }
@@ -0,0 +1,323 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ const HOME = os.homedir();
5
+ // Turns owns this catalog. It mirrors the transports verified by
6
+ // turns/scaffolding; queue execution must never depend on another product's
7
+ // discovery or process-launch implementation.
8
+ export const ACP_PROFILES = {
9
+ grok: {
10
+ command: 'grok',
11
+ args: (permissionMode) => [
12
+ 'agent',
13
+ // A shared leader retains its current always-approve state across ACP
14
+ // clients. Turns needs each anchor's requested permission mode to be
15
+ // authoritative, especially after a cold restart.
16
+ '--no-leader',
17
+ ...(permissionMode === 'bypassPermissions' ? ['--always-approve'] : []),
18
+ 'stdio',
19
+ ],
20
+ mcp: true,
21
+ },
22
+ kimi: {
23
+ command: 'kimi',
24
+ args: ['acp'],
25
+ homePaths: [['.kimi-code', 'bin', 'kimi']],
26
+ options: (permissionMode) => [
27
+ {
28
+ id: 'mode',
29
+ value: permissionMode === 'bypassPermissions'
30
+ ? 'yolo'
31
+ : permissionMode === 'plan'
32
+ ? 'plan'
33
+ : permissionMode === 'default'
34
+ ? 'default'
35
+ : 'auto',
36
+ },
37
+ ],
38
+ mcp: true,
39
+ },
40
+ opencode: {
41
+ command: 'opencode',
42
+ args: ['acp'],
43
+ homePaths: [['.opencode', 'bin', 'opencode']],
44
+ options: [{ id: 'mode', value: 'build' }],
45
+ mcp: true,
46
+ },
47
+ devin: {
48
+ command: 'devin',
49
+ args: ['acp'],
50
+ mode: (permissionMode) => ({
51
+ auto: 'accept-edits',
52
+ default: 'accept-edits',
53
+ acceptEdits: 'accept-edits',
54
+ plan: 'plan',
55
+ bypassPermissions: 'bypass',
56
+ })[permissionMode] || 'accept-edits',
57
+ },
58
+ hermes: {
59
+ command: 'hermes',
60
+ args: ['acp'],
61
+ env: (_permissionMode, cwd) => ({
62
+ HERMES_HOME: path.join(HOME, '.hermes'),
63
+ HERMES_MANAGED_DIR: path.join(cwd, '.turns-hermes-managed'),
64
+ }),
65
+ mcp: true,
66
+ },
67
+ copilot: {
68
+ command: 'copilot',
69
+ args: (permissionMode) => ['--acp', ...(permissionMode === 'bypassPermissions' ? ['--allow-all-tools'] : [])],
70
+ },
71
+ openclaw: { command: 'openclaw', args: ['acp'] },
72
+ cline: {
73
+ command: 'cline',
74
+ args: (permissionMode) => ['--acp', '--auto-approve', permissionMode === 'bypassPermissions' ? 'true' : 'false'],
75
+ mode: 'act',
76
+ // Pin an authenticated, tool-capable provider/model so daemon-driven
77
+ // captures do not inherit a concurrently changing interactive selection.
78
+ options: [{ id: 'provider', value: 'openai-codex' }],
79
+ model: 'gpt-5.6-luna',
80
+ },
81
+ qwen: {
82
+ command: 'qwen',
83
+ args: ['--acp'],
84
+ // Qwen persists tools.approvalMode globally. Make the anchor's requested
85
+ // mode authoritative for every ACP session so a prior interactive YOLO
86
+ // selection cannot silently bypass daemon-driven permission requests.
87
+ mode: (permissionMode) => ({ acceptEdits: 'auto-edit', bypassPermissions: 'yolo' })[permissionMode] || permissionMode,
88
+ mcp: true,
89
+ },
90
+ kilo: {
91
+ command: 'kilo',
92
+ args: ['acp'],
93
+ homePaths: [['.kilo', 'bin', 'kilo']],
94
+ // Kilo's ACP model selector uses provider-qualified config values. The
95
+ // free auto route is available without a Kilo account and remains capable
96
+ // of exercising the local tools used by the driver.
97
+ model: 'kilo/kilo-auto/free',
98
+ mcp: true,
99
+ },
100
+ poolside: {
101
+ command: 'pool',
102
+ args: ['acp'],
103
+ resumeMethods: ['session/load'],
104
+ // Poolside exposes permission policy as an ACP config option rather than
105
+ // a session mode. Apply it per anchor so bypass runs cannot inherit its
106
+ // interactive `default` (always ask) selection.
107
+ options: (permissionMode) => [
108
+ {
109
+ id: 'mode',
110
+ value: permissionMode === 'bypassPermissions'
111
+ ? 'always-allow'
112
+ : permissionMode === 'acceptEdits'
113
+ ? 'accept-edits'
114
+ : 'default',
115
+ },
116
+ ],
117
+ mcp: true,
118
+ },
119
+ goose: {
120
+ command: 'goose',
121
+ args: ['acp', '--with-builtin', 'developer,summon'],
122
+ // Goose defaults resumed sessions to `auto`. Set the advertised ACP mode
123
+ // from the daemon request so permission companions actually ask.
124
+ mode: (permissionMode) => ({ default: 'approve', acceptEdits: 'smart_approve', bypassPermissions: 'auto' })[permissionMode] || 'auto',
125
+ mcp: true,
126
+ },
127
+ codebuddy: {
128
+ command: 'codebuddy',
129
+ args: (permissionMode) => ['--acp', '--permission-mode', permissionMode],
130
+ mcp: true,
131
+ },
132
+ gemini: {
133
+ command: 'gemini',
134
+ args: (permissionMode) => [
135
+ '--acp',
136
+ '--approval-mode',
137
+ { acceptEdits: 'auto_edit', bypassPermissions: 'yolo' }[permissionMode] || permissionMode,
138
+ ],
139
+ // Gemini can persist very short sessions without making them discoverable
140
+ // to session/load. Starting a replacement session is safe for callers that
141
+ // do not require prior context; context-sensitive restart tests still
142
+ // detect an unexpected fallback through their semantic assertions.
143
+ resumeFallbackToNew: true,
144
+ mcp: true,
145
+ },
146
+ vibe: { command: 'vibe-acp', args: [], mcp: true },
147
+ };
148
+ // These harnesses keep one bidirectional process attached to the anchor. They
149
+ // are listed separately from process harnesses so queued turns cannot
150
+ // accidentally fall back to one-shot CLI invocations.
151
+ export const RESIDENT_HARNESSES = new Set(['claude', 'codex']);
152
+ export const RPC_PROFILES = {
153
+ // Prime selects an existing session only at process launch. The transcript
154
+ // path is authoritative (its header id can differ from its filename id), so
155
+ // resume the exact file; omit --resume only for an explicit new-chat launch.
156
+ prime: {
157
+ command: 'prime-agent',
158
+ args: (anchor, createSession = false) => [
159
+ '--mode',
160
+ 'rpc',
161
+ '--cwd',
162
+ anchor.cwd,
163
+ ...(createSession ? [] : ['--resume', anchor.transcriptPath]),
164
+ ],
165
+ },
166
+ omp: {
167
+ command: 'omp',
168
+ args: (anchor, createSession = false) => [
169
+ '--mode',
170
+ 'rpc',
171
+ '--cwd',
172
+ anchor.cwd,
173
+ '--approval-mode',
174
+ anchor.permissionMode === 'default' ? 'always-ask' : 'yolo',
175
+ ...(createSession ? [] : ['--resume', anchor.transcriptPath]),
176
+ ],
177
+ },
178
+ };
179
+ export const PROCESS_HARNESSES = new Set(['cursor', 'pi', 'muse', 'antigravity']);
180
+ export function processEventSessionId(harness, event) {
181
+ if (!PROCESS_HARNESSES.has(harness) || !event || typeof event !== 'object' || Array.isArray(event))
182
+ return '';
183
+ for (const key of ['session_id', 'sessionId', 'run_id', 'runId', 'conversation_id', 'conversationId']) {
184
+ const value = String(event[key] || '').trim();
185
+ if (value)
186
+ return value;
187
+ }
188
+ return '';
189
+ }
190
+ export function rpcFrameSessionIdentity(frame) {
191
+ if (!frame || typeof frame !== 'object' || Array.isArray(frame))
192
+ return { sessionId: '', transcriptPath: '' };
193
+ const state = frame.data || frame.state;
194
+ if (!state || typeof state !== 'object' || Array.isArray(state))
195
+ return { sessionId: '', transcriptPath: '' };
196
+ return {
197
+ sessionId: String(state.sessionId || state.id || '').trim(),
198
+ transcriptPath: String(state.sessionFile || '').trim(),
199
+ };
200
+ }
201
+ export function rpcUiRequestIsInformational(frame) {
202
+ if (frame?.type !== 'extension_ui_request')
203
+ return false;
204
+ const method = String(frame.method || '');
205
+ return (!['confirm', 'select', 'input', 'editor'].includes(method) &&
206
+ !/permission|approval/iu.test(`${method} ${frame.title || ''}`));
207
+ }
208
+ export const HARNESS_IDS = [
209
+ ...RESIDENT_HARNESSES,
210
+ ...PROCESS_HARNESSES,
211
+ ...Object.keys(RPC_PROFILES),
212
+ 'deepseek',
213
+ ...Object.keys(ACP_PROFILES),
214
+ ];
215
+ function executable(candidate) {
216
+ try {
217
+ fs.accessSync(candidate, fs.constants.X_OK);
218
+ return candidate;
219
+ }
220
+ catch {
221
+ return '';
222
+ }
223
+ }
224
+ export function resolveHarnessCommand(harness, profile = {}) {
225
+ const envName = `TURNS_${String(harness).toUpperCase().replaceAll('-', '_')}_BIN`;
226
+ const configured = String(process.env[envName] || '').trim();
227
+ if (configured) {
228
+ const found = executable(path.resolve(configured));
229
+ if (found)
230
+ return found;
231
+ throw new Error(`${envName} is not executable: ${configured}`);
232
+ }
233
+ const command = profile.command || ({ cursor: 'cursor-agent', antigravity: 'agy' }[harness] ?? harness);
234
+ for (const directory of String(process.env.PATH || '').split(path.delimiter)) {
235
+ const found = executable(path.join(directory, command));
236
+ if (found)
237
+ return found;
238
+ }
239
+ for (const segments of profile.homePaths || []) {
240
+ const found = executable(path.join(HOME, ...segments));
241
+ if (found)
242
+ return found;
243
+ }
244
+ if (harness === 'cursor') {
245
+ for (const candidate of [
246
+ path.join(HOME, '.local', 'bin', 'cursor-agent'),
247
+ path.join(HOME, '.local', 'bin', 'agent'),
248
+ ]) {
249
+ const found = executable(candidate);
250
+ if (found)
251
+ return found;
252
+ }
253
+ }
254
+ throw new Error(`Agent executable not found for harness "${harness}" (${command}).`);
255
+ }
256
+ export function processInvocation({ harness, sessionId, transcriptPath, cwd, prompt, createSession, model, thinkingLevel, timeoutMs, }) {
257
+ const command = resolveHarnessCommand(harness);
258
+ if (harness === 'cursor')
259
+ return {
260
+ command,
261
+ args: [
262
+ '--print',
263
+ '--output-format',
264
+ 'stream-json',
265
+ '--stream-partial-output',
266
+ '--force',
267
+ '--trust',
268
+ '--workspace',
269
+ cwd,
270
+ ...(model && model !== 'auto' ? ['--model', model] : []),
271
+ ...(createSession ? [] : ['--resume', sessionId]),
272
+ prompt,
273
+ ],
274
+ };
275
+ if (harness === 'pi')
276
+ return {
277
+ command,
278
+ args: [
279
+ '--mode',
280
+ 'json',
281
+ '--approve',
282
+ ...(model && model !== 'auto' ? ['--model', model] : []),
283
+ ...(thinkingLevel ? ['--thinking', thinkingLevel] : []),
284
+ ...(createSession ? ['--session-id', sessionId] : ['--session', transcriptPath || sessionId]),
285
+ prompt,
286
+ ],
287
+ };
288
+ if (harness === 'muse')
289
+ return {
290
+ command,
291
+ args: [
292
+ 'exec',
293
+ '--json',
294
+ '--workspace',
295
+ cwd,
296
+ '--yolo',
297
+ '--session-id',
298
+ sessionId,
299
+ ...(!createSession ? ['--allow-workspace-switch'] : []),
300
+ ...(model && model !== 'auto' ? ['--model', model] : []),
301
+ ...(thinkingLevel ? ['--reasoning-effort', thinkingLevel === 'max' ? 'ultra' : thinkingLevel] : []),
302
+ prompt,
303
+ ],
304
+ };
305
+ if (harness === 'antigravity')
306
+ return {
307
+ command,
308
+ args: [
309
+ '--print',
310
+ prompt,
311
+ '--output-format',
312
+ 'stream-json',
313
+ '--add-dir',
314
+ cwd,
315
+ ...(createSession ? [] : ['--conversation', sessionId]),
316
+ ...(model && model !== 'auto' ? ['--model', model] : []),
317
+ '--dangerously-skip-permissions',
318
+ '--print-timeout',
319
+ `${Math.max(1, Math.ceil(timeoutMs / 1000))}s`,
320
+ ],
321
+ };
322
+ throw new Error(`No Turns process profile exists for harness "${harness}".`);
323
+ }