reslop 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +28 -1
  2. package/README.md +30 -4
  3. package/lib/agents/acp.js +309 -0
  4. package/lib/agents/agents.js +274 -438
  5. package/lib/agents/allow.js +141 -52
  6. package/lib/agents/allow.json +16 -14
  7. package/lib/agents/models.js +250 -383
  8. package/lib/agents/registry.js +437 -0
  9. package/lib/agents/run.js +234 -0
  10. package/lib/agents/script.js +93 -0
  11. package/lib/agents/transcript.js +143 -0
  12. package/lib/cli.js +2 -1
  13. package/lib/common/process.js +26 -1
  14. package/lib/dashboard/git.js +11 -1
  15. package/lib/dashboard/reports.js +80 -0
  16. package/lib/dashboard/watch.js +43 -8
  17. package/lib/dashboard/workspace.js +19 -20
  18. package/lib/dashboard/worktrees.js +145 -0
  19. package/lib/git/dir.js +50 -0
  20. package/lib/git/linked.js +152 -0
  21. package/lib/git/local.js +179 -0
  22. package/lib/git/status.js +126 -0
  23. package/lib/git/worktree.js +113 -36
  24. package/lib/input/actions.js +83 -5
  25. package/lib/input/keys.js +30 -1
  26. package/lib/input/line-edit.js +5 -1
  27. package/lib/render/agents.js +84 -34
  28. package/lib/render/branches.js +55 -27
  29. package/lib/render/field.js +19 -5
  30. package/lib/render/footer.js +14 -6
  31. package/lib/render/frame.js +16 -2
  32. package/lib/render/menu.js +103 -6
  33. package/lib/render/name-edit.js +31 -0
  34. package/lib/render/repos.js +52 -8
  35. package/lib/render/status.js +22 -4
  36. package/lib/report/run.js +16 -23
  37. package/lib/review/agent-instructions.md +2 -2
  38. package/lib/runs/process.js +3 -3
  39. package/lib/runs/runs.js +27 -4
  40. package/lib/session/agents/agents.js +220 -205
  41. package/lib/session/agents/away.js +48 -0
  42. package/lib/session/agents/job.js +23 -86
  43. package/lib/session/agents/jobs.js +14 -27
  44. package/lib/session/agents/options.js +45 -101
  45. package/lib/session/agents/plans.js +53 -0
  46. package/lib/session/agents/view.js +34 -0
  47. package/lib/session/branches.js +18 -1
  48. package/lib/session/compose.js +13 -5
  49. package/lib/session/dispatch.js +43 -4
  50. package/lib/session/editor-pointer.js +98 -0
  51. package/lib/session/files-pane.js +3 -0
  52. package/lib/session/lifecycle.js +67 -10
  53. package/lib/session/navigation.js +5 -0
  54. package/lib/session/npm/history.js +2 -1
  55. package/lib/session/npm/npm.js +89 -12
  56. package/lib/session/ops.js +3 -0
  57. package/lib/session/pointer.js +304 -46
  58. package/lib/session/session.js +15 -2
  59. package/lib/session/terminal.js +2 -2
  60. package/lib/session/text-menu.js +50 -0
  61. package/lib/session/view.js +19 -2
  62. package/lib/session/workspace.js +179 -38
  63. package/lib/session/worktrees.js +280 -0
  64. package/lib/term/themes/dark.json +4 -0
  65. package/lib/term/themes/light.json +4 -0
  66. package/package.json +2 -2
@@ -2,164 +2,181 @@
2
2
 
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
- const { jsonParse } = require('metautil');
6
- const { IS_WIN, runProc, spawnBase } = require('../common/process.js');
7
- const { trimText } = require('../common/utilities.js');
5
+ const { IS_WIN, npmBin, npmOpts, spawnBase } = require('../common/process.js');
8
6
  const { commandEnv, PipeProcess } = require('../runs/process.js');
9
- const { ensurePlanAllows, PLAN_ALLOWS } = require('./allow.js');
7
+ const { trimText } = require('../common/utilities.js');
8
+ const { COMMANDS } = require('./allow.js');
10
9
  const models = require('./models.js');
11
- const { parseNameList, parseCursorModels, parseCursorWide } = models;
12
- const { parseJsonModels, catalogOf, emptyChoice, contextMenu } = models;
13
- const { parseJsonEfforts, parseHelpModels, optionValues } = models;
14
-
15
- const LIST_MS = 5000;
16
- const AUTH_NEED = /authentication required/i;
17
- const PERMISSION_FAIL =
18
- /permissions?[ -]?(?:denied|denial)|denied permissions?/i;
19
- const GITDIR_PREFIX = 'gitdir:';
20
-
21
- const plainEffort = (level) => level;
22
- const codexEffort = (level) => `model_reasoning_effort=${level}`;
23
-
24
- const bashAllow = (entry) => {
25
- if (!entry.startsWith('Shell(') || !entry.endsWith(')')) return '';
26
- const bin = entry.slice('Shell('.length, -1).split(/\s+/)[0];
27
- if (!bin) return '';
28
- return `Bash(${bin} *)`;
10
+ const { choiceOf } = models;
11
+ const { startRun, probeSession } = require('./run.js');
12
+ const registry = require('./registry.js');
13
+ const { installedRegistryAgent } = registry;
14
+ const { localBin, scriptUrl } = require('./script.js');
15
+ const { resolveGitDirs } = require('../git/dir.js');
16
+
17
+ const CODEX_SANDBOX = 'sandbox_workspace_write';
18
+ const CODEX_ROOTS = 'writable_roots';
19
+ const AGY_SERVER = IS_WIN ? 'agy_acp_server.exe' : 'agy_acp_server.par';
20
+ const AGY_ARGS = process.platform === 'linux' ? ['--uid='] : [];
21
+
22
+ const realPath = (file) => {
23
+ try {
24
+ return fs.realpathSync.native(file);
25
+ } catch {
26
+ return path.resolve(file);
27
+ }
29
28
  };
30
29
 
31
- const BASH_ALLOWS = [...new Set(PLAN_ALLOWS.map(bashAllow).filter(Boolean))];
32
-
33
- // A piped session has no approval host. Preapproved shells can still run tests.
34
- const CLAUDE_PREFIX = [
35
- '--print',
36
- '--permission-mode',
37
- 'acceptEdits',
38
- '--permission-prompts',
39
- 'none',
40
- '--allowedTools',
41
- ...BASH_ALLOWS,
42
- ];
30
+ // workspace-write remounts .git read-only unless that path is itself writable.
31
+ const gitWriteRoots = (cwd) => {
32
+ if (!cwd) return [];
33
+ const dirs = resolveGitDirs(cwd);
34
+ if (!dirs) return [];
35
+ const roots = [realPath(dirs.gitDir), realPath(dirs.commonDir)];
36
+ return [...new Set(roots)];
37
+ };
38
+
39
+ // The adapter merges this into the session config of codex
40
+ const codexEnv = (cwd, env = process.env) => {
41
+ const roots = gitWriteRoots(cwd);
42
+ if (!roots.length || env.CODEX_CONFIG) return {};
43
+ const config = { [CODEX_SANDBOX]: { [CODEX_ROOTS]: roots } };
44
+ return { CODEX_CONFIG: JSON.stringify(config) };
45
+ };
43
46
 
47
+ const antigravityEnv = (cwd, row) => {
48
+ if (!row || !row.bin) return {};
49
+ const name = IS_WIN ? 'localharness_external.exe' : 'localharness_external';
50
+ const helper = path.join(path.dirname(row.bin), name);
51
+ return fs.existsSync(helper) ? { ANTIGRAVITY_HARNESS_PATH: helper } : {};
52
+ };
53
+
54
+ // Each agent is reached through the command that serves ACP on its stdio:
55
+ // its own subcommand, or the adapter that wraps its SDK
44
56
  const defineAgent = (spec) => ({
45
57
  id: spec.id,
46
58
  name: spec.name ?? spec.id,
47
59
  title: spec.title,
48
- bins: spec.bins,
49
- models: spec.models,
50
- modelFlag: spec.modelFlag ?? '--model',
51
- effortFlag: spec.effortFlag ?? '',
52
- effortValue: spec.effortValue ?? plainEffort,
53
- efforts: spec.efforts ?? [],
54
- modelParams: spec.modelParams === true,
55
- prefix: spec.prefix ?? [],
56
- promptFlag: spec.promptFlag ?? '',
57
- login: spec.login ?? [],
58
- status: spec.status ?? [],
59
- contexts: spec.contexts ?? [],
60
- resumeWith: spec.resumeWith ?? null,
61
- listCmds: spec.listCmds,
62
- parseModels: spec.parseModels ?? parseNameList,
63
- wideModels: spec.wideModels ?? null,
64
- partialList: spec.partialList === true,
65
- modelEfforts: spec.modelEfforts ?? null,
66
- effortCmds: spec.effortCmds ?? [],
67
- allowlist: spec.allowlist === true,
68
- denied: spec.denied ?? null,
69
- savesExtra: spec.savesExtra === true,
60
+ commands: spec.commands,
61
+ package: spec.package ?? '',
62
+ // The cli of the agent that an adapter starts, as { bin, package }
63
+ requires: spec.requires ?? null,
64
+ registry: spec.registry ?? '',
65
+ // The install scripts of the vendor, as { unix, win } https urls
66
+ script: spec.script ?? null,
67
+ installLabel: spec.installLabel ?? '',
68
+ hint: spec.hint ?? '',
69
+ // A program of the vendor that does not serve ACP, as { bin, hint }
70
+ related: spec.related ?? null,
71
+ mode: spec.mode ?? '',
72
+ env: spec.env ?? (() => ({})),
70
73
  });
71
74
 
72
75
  const AGENTS = [
73
76
  defineAgent({
74
77
  id: 'claude',
75
78
  title: 'Claude Code',
76
- bins: ['claude'],
77
- models: ['default', 'sonnet', 'opus', 'haiku', 'fable'],
78
- effortFlag: '--effort',
79
- efforts: ['default', 'low', 'medium', 'high', 'max'],
80
- prefix: CLAUDE_PREFIX,
81
- resumeWith: (id) => ['--resume', id],
82
- // No command lists models: the help names the aliases and the efforts
83
- listCmds: [['--help']],
84
- parseModels: parseHelpModels,
85
- partialList: true,
86
- effortCmds: [['--help']],
79
+ commands: [['claude-agent-acp'], ['claude-code-acp']],
80
+ package: '@agentclientprotocol/claude-agent-acp',
81
+ // Edits are accepted by the agent, the rest is asked of the run
82
+ mode: 'acceptEdits',
87
83
  }),
88
84
  defineAgent({
89
85
  id: 'opencode',
90
86
  title: 'OpenCode',
91
- bins: ['opencode'],
92
- models: ['default', 'sonnet', 'opus', 'gpt-5'],
93
- resumeWith: (id) => ['--session', id],
94
- listCmds: [['models']],
87
+ commands: [['opencode', 'acp']],
88
+ package: 'opencode-ai',
95
89
  }),
96
90
  defineAgent({
97
91
  id: 'cursor',
98
92
  title: 'Cursor',
99
- bins: ['cursor-agent', 'agent'],
100
- models: ['default', 'auto', 'grok-4.6', 'composer-2', 'gpt-5', 'sonnet'],
101
- modelParams: true,
102
- efforts: ['default', 'low', 'medium', 'high', 'xhigh'],
103
- prefix: ['--print', '--trust'],
104
- allowlist: true,
105
- login: ['login'],
106
- status: ['status', '--format', 'json'],
107
- contexts: ['256k', '1m'],
108
- resumeWith: (id) => [`--resume=${id}`],
109
- listCmds: [['--list-models'], ['models']],
110
- parseModels: parseCursorModels,
111
- wideModels: parseCursorWide,
93
+ commands: [
94
+ ['cursor-agent', 'acp'],
95
+ ['agent', 'acp'],
96
+ ],
97
+ hint: 'needs the Cursor CLI from cursor.com/cli',
112
98
  }),
113
99
  defineAgent({
114
100
  id: 'codex',
115
101
  title: 'Codex',
116
- bins: ['codex'],
117
- models: ['default', 'gpt-5.1-codex', 'gpt-5', 'o3'],
118
- effortFlag: '-c',
119
- effortValue: codexEffort,
120
- efforts: ['default', 'low', 'medium', 'high', 'xhigh'],
121
- // The TUI rejects a closed stdin; exec is the non-interactive entry
122
- prefix: ['exec'],
123
- resumeWith: (id) => ['resume', id],
124
- listCmds: [
125
- ['debug', 'models', '--bundled'],
126
- ['debug', 'models'],
127
- ['models'],
128
- ],
129
- parseModels: parseJsonModels,
130
- modelEfforts: parseJsonEfforts,
102
+ commands: [['codex-acp']],
103
+ package: '@agentclientprotocol/codex-acp',
104
+ mode: 'workspace-write',
105
+ env: codexEnv,
131
106
  }),
132
107
  defineAgent({
133
108
  id: 'agy',
134
109
  title: 'Antigravity',
135
- bins: ['agy'],
136
- models: [
137
- 'default',
138
- 'gemini-3.8-flash-high',
139
- 'gemini-3.8-flash-medium',
140
- 'gemini-3.8-flash-low',
141
- 'gemini-3.7-flash-high',
142
- 'gemini-3.7-flash-medium',
143
- 'gemini-3.7-flash-low',
144
- 'gemini-3.6-flash-high',
145
- 'gemini-3.6-flash-medium',
146
- 'gemini-3.6-flash-low',
147
- 'gemini-3.1-pro-high',
148
- 'gemini-3.1-pro-low',
149
- 'claude-sonnet-4-6',
150
- 'claude-opus-4-6-thinking',
151
- 'gpt-oss-120b-medium',
110
+ commands: [[AGY_SERVER, ...AGY_ARGS], ['agy-acp']],
111
+ registry: 'antigravity-acp',
112
+ installLabel: 'Google Antigravity ACP',
113
+ hint: 'install Google Antigravity ACP',
114
+ related: {
115
+ bin: 'agy',
116
+ hint: 'agy installed; install Google Antigravity ACP',
117
+ },
118
+ env: antigravityEnv,
119
+ }),
120
+ defineAgent({
121
+ id: 'junie',
122
+ title: 'Junie',
123
+ commands: [['junie', '--acp=true']],
124
+ package: '@jetbrains/junie',
125
+ }),
126
+ defineAgent({
127
+ id: 'copilot',
128
+ title: 'Copilot',
129
+ commands: [['copilot', '--acp']],
130
+ package: '@github/copilot',
131
+ }),
132
+ defineAgent({
133
+ id: 'pi',
134
+ title: 'Pi',
135
+ commands: [['pi-acp']],
136
+ package: 'pi-acp',
137
+ requires: { bin: 'pi', package: '@earendil-works/pi-coding-agent' },
138
+ }),
139
+ defineAgent({
140
+ id: 'kiro',
141
+ title: 'Kiro',
142
+ commands: [['kiro-cli', 'acp', '--agent-engine=v3']],
143
+ script: {
144
+ unix: 'https://cli.kiro.dev/install',
145
+ win: 'https://cli.kiro.dev/install.ps1',
146
+ },
147
+ installLabel: 'Kiro CLI from cli.kiro.dev',
148
+ hint: 'install the Kiro CLI from cli.kiro.dev',
149
+ }),
150
+ defineAgent({
151
+ id: 'cline',
152
+ title: 'Cline',
153
+ commands: [['cline', '--acp']],
154
+ package: 'cline',
155
+ }),
156
+ // Windsurf's agent is now the Devin CLI of Cognition; the editor has no ACP
157
+ defineAgent({
158
+ id: 'devin',
159
+ title: 'Devin for Windsurf',
160
+ commands: [['devin', 'acp']],
161
+ script: {
162
+ unix: 'https://cli.devin.ai/install.sh',
163
+ win: 'https://static.devin.ai/cli/setup.ps1',
164
+ },
165
+ installLabel: 'Devin CLI from cli.devin.ai',
166
+ hint: 'install the Devin CLI, the agent of Windsurf',
167
+ related: {
168
+ bin: 'windsurf',
169
+ hint: 'windsurf installed; install the Devin CLI',
170
+ },
171
+ }),
172
+ defineAgent({
173
+ id: 'kilo',
174
+ title: 'Kilo Code',
175
+ commands: [
176
+ ['kilo', 'acp'],
177
+ ['kilocode', 'acp'],
152
178
  ],
153
- effortFlag: '--effort',
154
- efforts: ['default', 'low', 'medium', 'high'],
155
- promptFlag: '-p',
156
- // Exits 0 after a refused tool call, so the output tells the outcome
157
- denied: PERMISSION_FAIL,
158
- // Extra flags in .reslop are the opt-in to run without confirmations
159
- savesExtra: true,
160
- resumeWith: (id) => ['--conversation', id],
161
- listCmds: [['models']],
162
- effortCmds: [['--help']],
179
+ package: '@kilocode/cli',
163
180
  }),
164
181
  ];
165
182
 
@@ -194,8 +211,8 @@ const isBin = (file) => {
194
211
  }
195
212
  };
196
213
 
197
- const findBin = (name, env = process.env) => {
198
- for (const dir of pathDirs(env)) {
214
+ const findIn = (dirs, name, env) => {
215
+ for (const dir of dirs) {
199
216
  for (const file of binNames(name, env)) {
200
217
  const full = path.join(dir, file);
201
218
  if (isBin(full)) return full;
@@ -204,352 +221,178 @@ const findBin = (name, env = process.env) => {
204
221
  return '';
205
222
  };
206
223
 
207
- const findAgentBin = (spec, env) => {
208
- for (const name of spec.bins) {
209
- const found = findBin(name, env);
210
- if (found) return found;
211
- }
212
- return '';
213
- };
214
-
215
- const detectAgents = (env = process.env) =>
216
- AGENTS.map((spec) => ({
217
- id: spec.id,
218
- name: spec.name,
219
- spec,
220
- models: spec.models.slice(),
221
- wide: null,
222
- listedEfforts: null,
223
- modelEfforts: null,
224
- bin: findAgentBin(spec, env),
225
- }));
226
-
227
- const canLogin = (row) =>
228
- Boolean(row && row.bin && row.spec && row.spec.login.length);
224
+ const findBin = (name, env = process.env) => findIn(pathDirs(env), name, env);
229
225
 
230
- const tryList = async (run, bin, args, options) => {
231
- try {
232
- return await run(bin, args, options);
233
- } catch {
234
- return null;
235
- }
226
+ // An install script may put its command in a directory missing from PATH
227
+ const commandDirs = (spec, env) => {
228
+ const dirs = pathDirs(env);
229
+ const local = spec.script ? localBin(env) : '';
230
+ return local && !dirs.includes(local) ? [...dirs, local] : dirs;
236
231
  };
237
232
 
238
- const listedOf = (listed) => {
239
- const bare = Array.isArray(listed);
240
- const given = bare ? { names: listed, wide: listed.wide } : listed;
241
- const from = given && typeof given === 'object' ? given : {};
242
- const efforts = Array.isArray(from.efforts) ? from.efforts : [];
243
- const perModel = from.modelEfforts instanceof Map ? from.modelEfforts : null;
244
- return {
245
- names: Array.isArray(from.names) ? from.names : [],
246
- wide: Array.isArray(from.wide) ? from.wide : null,
247
- efforts: efforts.length ? efforts : null,
248
- modelEfforts: perModel && perModel.size ? perModel : null,
249
- };
250
- };
251
-
252
- // One answer per command line, so a command that serves two lists runs once
253
- class CliProbe {
254
- constructor(bin, options) {
255
- this.bin = bin;
256
- this.run = options.run ?? runProc;
257
- this.env = { ...process.env, ...options.env };
258
- this.timeout = options.timeout ?? LIST_MS;
259
- this.texts = new Map();
233
+ const findCommand = (spec, env) => {
234
+ const dirs = commandDirs(spec, env);
235
+ for (const [name, ...args] of spec.commands) {
236
+ const bin = findIn(dirs, name, env);
237
+ if (bin) return { bin, args };
260
238
  }
261
-
262
- async read(args) {
263
- const key = args.join(' ');
264
- if (this.texts.has(key)) return this.texts.get(key);
265
- const { env, timeout } = this;
266
- const result = await tryList(this.run, this.bin, args, { env, timeout });
267
- const { stdout = '', stderr = '' } = result ?? {};
268
- const text = result ? `${stdout}\n${stderr}` : '';
269
- this.texts.set(key, text);
270
- return text;
239
+ if (spec.registry) {
240
+ const installed = installedRegistryAgent(spec.registry, { env });
241
+ if (installed) return installed;
271
242
  }
243
+ return { bin: '', args: [] };
244
+ };
272
245
 
273
- async first(cmds, parse) {
274
- for (const args of cmds) {
275
- const text = await this.read(args);
276
- const found = text ? parse(text) : [];
277
- if (found.length) return { text, found };
278
- }
279
- return null;
280
- }
281
- }
282
-
283
- const listModels = async (bin, spec, options = {}) => {
284
- const listed = { names: [], wide: null, efforts: null, modelEfforts: null };
285
- if (!bin || !spec) return listed;
286
- const probe = new CliProbe(bin, options);
287
- const models = await probe.first(spec.listCmds, spec.parseModels);
288
- if (models) {
289
- const { text } = models;
290
- listed.names = models.found;
291
- listed.wide = spec.wideModels ? spec.wideModels(text) : null;
292
- listed.modelEfforts = spec.modelEfforts ? spec.modelEfforts(text) : null;
293
- }
294
- const levels = (text) => optionValues(text, spec.effortFlag);
295
- const efforts = await probe.first(spec.effortCmds, levels);
296
- if (efforts) listed.efforts = efforts.found;
297
- return listed;
246
+ // npm packages still missing: the adapter and the cli of the agent it starts
247
+ const missingPackages = (spec, env, found) => {
248
+ const names = [];
249
+ if (!found.bin && spec.package) names.push(spec.package);
250
+ const cli = spec.requires;
251
+ if (cli && !findBin(cli.bin, env)) names.push(cli.package);
252
+ return names;
298
253
  };
299
254
 
300
- const splitArgs = (text) => trimText(text).split(/\s+/).filter(Boolean);
255
+ // What a missing agent takes: npm packages, a registry binary, or a script
256
+ const installHint = (spec, env, packages) => {
257
+ if (packages.length) return `npm i -g ${packages.join(' ')}`;
258
+ if (spec.package) return `npm i -g ${spec.package}`;
259
+ const { related } = spec;
260
+ if (related && findBin(related.bin, env)) return related.hint;
261
+ return spec.hint;
262
+ };
301
263
 
302
- const quoteArg = (text) => {
303
- const arg = `${text ?? ''}`;
304
- if (!arg) return '""';
305
- if (!/[\s"'\\]/.test(arg)) return arg;
306
- return `"${arg.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
264
+ const detectAgent = (spec, env) => {
265
+ const found = findCommand(spec, env);
266
+ const packages = missingPackages(spec, env, found);
267
+ const command = packages.length ? { bin: '', args: [] } : found;
268
+ return {
269
+ id: spec.id,
270
+ name: spec.name,
271
+ spec,
272
+ package: packages.join(' ') || spec.package,
273
+ packages,
274
+ installable: Boolean(spec.package || spec.registry || spec.script),
275
+ installLabel: spec.installLabel,
276
+ hint: installHint(spec, env, packages),
277
+ catalog: null,
278
+ ...command,
279
+ };
307
280
  };
308
281
 
309
- const commandLine = (launch) => {
310
- if (!launch || !launch.ok || !launch.cmd) return '';
311
- const args = launch.args ?? [];
312
- const hasPrompt = launch.prompt && args.at(-1) === launch.prompt;
313
- const shown = hasPrompt ? args.slice(0, -1) : args;
314
- const plan = launch.plan ? quoteArg(launch.plan) : '';
315
- const bin = path.basename(launch.cmd);
316
- return [bin, ...shown.map(quoteArg), plan].filter(Boolean).join(' ');
282
+ const detectAgents = (env = process.env) =>
283
+ AGENTS.map((spec) => detectAgent(spec, env));
284
+
285
+ const listModels = (row, options = {}) => {
286
+ if (!row || !row.bin) return Promise.resolve(null);
287
+ return probeSession(row, options);
317
288
  };
318
289
 
319
290
  const planRef = (reviewPath, cwd) => {
320
291
  const file = `${reviewPath ?? ''}`;
321
292
  if (!file || !cwd) return file;
322
- const rel = path.relative(cwd, file);
293
+ const rel = path.relative(cwd, path.resolve(cwd, file));
323
294
  if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return file;
324
295
  return rel.split(path.sep).join('/');
325
296
  };
326
297
 
327
- const planPrompt = (reviewPath) =>
328
- `Read and execute repair plan at ${reviewPath}`;
329
-
330
- const needsAuth = (text) => AUTH_NEED.test(`${text ?? ''}`);
331
-
332
- const parsedAuth = (raw) => {
333
- const start = raw.indexOf('{');
334
- const end = raw.lastIndexOf('}');
335
- if (start < 0 || end <= start) return null;
336
- const body = raw.slice(start, end + 1);
337
- const data = jsonParse(body);
338
- if (!data || typeof data !== 'object') return null;
339
- if (typeof data.isAuthenticated === 'boolean') return data.isAuthenticated;
340
- if (data.status === 'authenticated') return true;
341
- if (data.status === 'unauthenticated') return false;
342
- if (data.status === 'partially-authenticated') return false;
343
- return null;
344
- };
345
-
346
- const authState = (text) => {
347
- const raw = trimText(text);
348
- if (!raw) return null;
349
- const parsed = parsedAuth(raw);
350
- if (parsed !== null) return parsed;
351
- if (/not logged in/i.test(raw)) return false;
352
- if (/logged in/i.test(raw)) return true;
353
- return null;
354
- };
298
+ // A refusal reads to an agent as a person saying no, and it stops to ask
299
+ const UNATTENDED = [
300
+ 'You are running unattended: nobody can answer questions or approve',
301
+ 'commands. Change files with your edit tools. The shell is limited to',
302
+ `${COMMANDS.join(', ')} and read-only filters such as cat, grep,`,
303
+ 'head and tail. When a command is refused, do the step another way',
304
+ 'and continue with the plan.',
305
+ ].join(' ');
355
306
 
356
- const probeAuth = async (row) => {
357
- const args = row && row.spec ? row.spec.status : [];
358
- if (!row || !row.bin || !args.length) return null;
359
- const result = await runProc(row.bin, args, {
360
- env: commandEnv(),
361
- timeout: LIST_MS,
362
- });
363
- if (!result || result.error) return null;
364
- const text = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
365
- return authState(text);
366
- };
367
-
368
- const effortArgs = (spec, effort) => {
369
- const level = trimText(effort);
370
- if (!spec.effortFlag || !level || level === 'default') return [];
371
- return [spec.effortFlag, spec.effortValue(level)];
372
- };
373
-
374
- const statOf = (file) => {
375
- try {
376
- return fs.statSync(file);
377
- } catch {
378
- return null;
379
- }
380
- };
381
-
382
- const readFirstLine = (file) => {
383
- try {
384
- return fs.readFileSync(file, 'utf8').split('\n')[0] ?? '';
385
- } catch {
386
- return '';
387
- }
388
- };
389
-
390
- const realPath = (file) => {
391
- try {
392
- return fs.realpathSync.native(file);
393
- } catch {
394
- return path.resolve(file);
395
- }
396
- };
397
-
398
- const gitDirOf = (dot) => {
399
- const line = readFirstLine(dot).trim();
400
- if (!line.startsWith(GITDIR_PREFIX)) return '';
401
- const raw = line.slice(GITDIR_PREFIX.length).trim();
402
- if (!raw) return '';
403
- return path.resolve(path.dirname(dot), raw);
404
- };
405
-
406
- const commonGitDir = (gitDir) => {
407
- const raw = readFirstLine(path.join(gitDir, 'commondir')).trim();
408
- if (!raw) return gitDir;
409
- return path.resolve(gitDir, raw);
410
- };
411
-
412
- // workspace-write remounts .git read-only unless that path is itself writable.
413
- const gitWriteRoots = (cwd) => {
414
- if (!cwd) return [];
415
- const dot = path.join(path.resolve(cwd), '.git');
416
- const stat = statOf(dot);
417
- if (!stat) return [];
418
- if (stat.isDirectory()) return [realPath(dot)];
419
- if (!stat.isFile()) return [];
420
- const gitDir = gitDirOf(dot);
421
- if (!gitDir) return [];
422
- const roots = [realPath(gitDir), realPath(commonGitDir(gitDir))];
423
- return [...new Set(roots)];
424
- };
425
-
426
- const tomlQuote = (text) => {
427
- const escaped = text.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
428
- return `"${escaped}"`;
429
- };
430
-
431
- const codexGitArgs = (cwd) => {
432
- const roots = gitWriteRoots(cwd);
433
- if (!roots.length) return [];
434
- const listed = roots.map(tomlQuote).join(',');
435
- const value = `sandbox_workspace_write.writable_roots=[${listed}]`;
436
- return ['--sandbox', 'workspace-write', '-c', value];
437
- };
438
-
439
- const paramPart = (key, value) => {
440
- const text = trimText(value);
441
- if (!text) return '';
442
- return `${key}=${text}`;
443
- };
444
-
445
- const withParams = (model, parts) => {
446
- const body = parts.filter(Boolean).join(',');
447
- if (!body || !model || model === 'default') return model;
448
- return `${model}[${body}]`;
449
- };
450
-
451
- const effortParam = (model, effort) => {
452
- const level = trimText(effort);
453
- if (!level || level === 'default') return '';
454
- if (`${model ?? ''}`.endsWith(`-${level}`)) return '';
455
- return level;
456
- };
457
-
458
- const fastParam = (model, fast) => {
459
- if (fast !== true) return '';
460
- if (`${model ?? ''}`.endsWith('-fast')) return '';
461
- return 'true';
462
- };
307
+ const planPrompt = (reviewPath) =>
308
+ `Read and execute repair plan at ${reviewPath}\n\n${UNATTENDED}`;
463
309
 
464
- const modelArg = (spec, resolved, context, fast) => {
465
- const parts = [paramPart('context', context)];
466
- const brackets = spec.modelParams && !resolved.encoded;
467
- if (!brackets || resolved.params === false) {
468
- return withParams(resolved.model, parts);
469
- }
470
- const level = effortParam(resolved.model, resolved.effort);
471
- parts.push(paramPart('effort', level));
472
- parts.push(paramPart('fast', fastParam(resolved.model, fast)));
473
- return withParams(resolved.model, parts);
310
+ // What a run is shown as: the settings it was started with and its plan
311
+ const launchLabel = (launch) => {
312
+ const { choice } = launch;
313
+ const fast = choice.fast ? 'fast' : '';
314
+ const parts = [launch.name, choice.model, choice.effort, fast];
315
+ return [...parts, choice.context, launch.plan].filter(Boolean).join(' ');
474
316
  };
475
317
 
476
- const createLaunch = (row, kind, args, plan = '', prompt = '') => {
318
+ const buildLaunch = (row, picked, reviewPath, cwd, sessionId = '') => {
319
+ if (!row || !row.bin) return { ok: false, error: 'not installed' };
320
+ if (!reviewPath) return { ok: false, error: 'no plan' };
321
+ const plan = planRef(reviewPath, cwd) || reviewPath;
477
322
  const launch = {
478
323
  ok: true,
479
- cmd: row.bin,
480
- args,
481
- prompt,
482
- plan,
483
324
  id: row.id,
484
325
  name: row.name,
485
- kind,
326
+ kind: 'run',
327
+ cmd: row.bin,
328
+ args: (row.args ?? []).slice(),
329
+ mode: row.spec.mode,
330
+ env: row.spec.env(cwd, row),
331
+ choice: choiceOf(picked),
332
+ plan,
333
+ prompt: planPrompt(plan),
334
+ session: trimText(sessionId),
486
335
  command: '',
487
336
  };
488
- launch.command = commandLine(launch);
337
+ launch.command = launchLabel(launch);
489
338
  return launch;
490
339
  };
491
340
 
492
- const resumeArgs = (spec, sessionId) => {
493
- const id = `${sessionId ?? ''}`;
494
- if (!id || !spec.resumeWith) return [];
495
- return spec.resumeWith(id);
496
- };
341
+ // A helper of reslop downloads a registry binary or runs a vendor script
342
+ const helperInstall = (row, installer, file, arg) => ({
343
+ installer,
344
+ cmd: process.execPath,
345
+ args: [path.join(__dirname, file), arg],
346
+ command: `install ${row.spec.installLabel || row.name}`,
347
+ });
497
348
 
498
- const buildLaunch = (row, choice, reviewPath, cwd, sessionId = '') => {
499
- if (!row || !row.bin) return { ok: false, error: 'not installed' };
500
- if (!reviewPath) return { ok: false, error: 'no plan' };
349
+ // The package lands in the global prefix of npm, where PATH finds its command
350
+ const installCommand = (row) => {
501
351
  const { spec } = row;
502
- const picked = choice ?? emptyChoice(row);
503
- const catalog = catalogOf(row.models);
504
- const resolved = catalog.withFast(catalog.resolve(picked), picked.fast);
505
- const asked = trimText(picked.context);
506
- const menu = contextMenu(resolved.model, row.wide, catalog, spec.contexts);
507
- const pass = menu.includes(asked) && asked !== menu[0] ? asked : '';
508
- const model = modelArg(spec, resolved, pass, picked.fast);
509
- const plan = planRef(reviewPath, cwd) || reviewPath;
510
- const prompt = planPrompt(plan);
511
- const continued = resumeArgs(spec, sessionId);
512
- const args = [...spec.prefix, ...continued];
513
- if (model && model !== 'default') args.push(spec.modelFlag, model);
514
- if (!spec.modelParams && !resolved.encoded) {
515
- args.push(...effortArgs(spec, resolved.effort));
352
+ if (spec.registry) {
353
+ return helperInstall(row, 'registry', 'registry.js', spec.registry);
516
354
  }
517
- if (spec.id === 'codex') args.push(...codexGitArgs(cwd));
518
- args.push(...splitArgs(picked.extra));
519
- if (spec.promptFlag) args.push(spec.promptFlag);
520
- args.push(prompt);
521
- const launch = createLaunch(row, 'run', args, plan, prompt);
522
- const id = `${sessionId ?? ''}`;
523
- if (id) launch.session = id;
524
- return launch;
525
- };
526
-
527
- const buildLogin = (row) => {
528
- if (!row || !row.bin) return { ok: false, error: 'not installed' };
529
- if (!canLogin(row)) return { ok: false, error: 'no login' };
530
- return createLaunch(row, 'login', row.spec.login.slice());
531
- };
532
-
533
- // The review screen forces raw output. An agent needs the reduced test report.
534
- const agentEnv = () => {
535
- const env = commandEnv();
536
- delete env.RESLOP_OUTPUT;
537
- return env;
355
+ if (spec.script) {
356
+ const url = scriptUrl(spec.script);
357
+ return helperInstall(row, 'script', 'script.js', url);
358
+ }
359
+ const names = row.packages ?? [spec.package].filter(Boolean);
360
+ if (!names.length) return null;
361
+ const args = ['install', '--global', ...names];
362
+ const command = `npm ${args.join(' ')}`;
363
+ return { installer: 'npm', cmd: npmBin(), args, command };
364
+ };
365
+
366
+ const buildInstall = (row) => {
367
+ if (!row) return { ok: false, error: 'no agent' };
368
+ if (row.bin) return { ok: false, error: 'installed' };
369
+ const install = installCommand(row);
370
+ if (!install) return { ok: false, error: row.hint || 'no installer' };
371
+ return {
372
+ ok: true,
373
+ id: row.id,
374
+ name: row.name,
375
+ kind: 'install',
376
+ ...install,
377
+ plan: '',
378
+ session: '',
379
+ };
538
380
  };
539
381
 
540
- const startPipe = (cwd, launch, onData, onClose) => {
541
- const options = spawnBase({ cwd, env: agentEnv(), detached: !IS_WIN });
382
+ const startInstall = (cwd, launch, onData, onClose) => {
383
+ const extra = { cwd, env: commandEnv(), detached: !IS_WIN };
384
+ const options =
385
+ launch.installer === 'npm' ? npmOpts(extra) : spawnBase(extra);
542
386
  const proc = new PipeProcess(onData, onClose);
543
387
  proc.start(launch.cmd, launch.args, options).closeInput();
544
388
  return proc;
545
389
  };
546
390
 
547
- // An agent that reads an allow list gets the plan entries before it runs
548
391
  const startAgent = (cwd, launch, onData, onClose) => {
549
- const spec = agentSpec(launch.id);
550
- const listed = launch.kind === 'run' && spec && spec.allowlist;
551
- if (listed) ensurePlanAllows();
552
- return startPipe(cwd, launch, onData, onClose);
392
+ if (launch.kind === 'install') {
393
+ return startInstall(cwd, launch, onData, onClose);
394
+ }
395
+ return startRun(cwd, launch, onData, onClose, launch.env);
553
396
  };
554
397
 
555
398
  module.exports = {
@@ -558,16 +401,9 @@ module.exports = {
558
401
  agentSpec,
559
402
  findBin,
560
403
  detectAgents,
561
- canLogin,
562
- listedOf,
563
404
  listModels,
564
- splitArgs,
565
405
  planPrompt,
566
- needsAuth,
567
- authState,
568
- probeAuth,
569
- commandLine,
570
406
  buildLaunch,
571
- buildLogin,
407
+ buildInstall,
572
408
  startAgent,
573
409
  };