overlord-cli 0.2605281741.0 → 0.2605291046.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -60,6 +60,8 @@ Common commands:
60
60
  ovld auth login
61
61
  ovld auth status
62
62
  ovld attach
63
+ ovld claude "Refactor the auth middleware" --model opus --thinking high
64
+ ovld codex "Investigate the memory leak" -- --search --full-auto
63
65
  ovld create "Investigate the failing build" --agent codex
64
66
  ovld prompt "Draft a fix for the onboarding flow"
65
67
  ovld version
@@ -85,6 +87,8 @@ ovld doctor
85
87
 
86
88
  For ticket-scoped protocol and launch commands, `ticket_id` values such as `1:899` carry the organization id. The CLI uses that first, then `--organization-id` for UUID compatibility, then stored auth. `ovld auth login` stores the first available organization as a default when an account belongs to multiple organizations; pass `--organization-id <id>` only when you want a different default.
87
89
 
90
+ When `ovld launch` has an explicit `--working-directory`, or when you run it from a registered project directory containing `.overlord/project.json`, it exports `TMPDIR`, `TMP`, `TEMP`, and `OVERLORD_TMPDIR` to that project's `.overlord/tmp/` directory before spawning the agent. Otherwise it falls back to the system temp directory.
91
+
88
92
  ## Requirements
89
93
 
90
94
  - Node.js 20 or newer
@@ -96,6 +100,7 @@ Find full CLI docs here: https://www.ovld.ai/docs/surfaces/cli
96
100
 
97
101
  Top-level commands (see `ovld help`):
98
102
 
103
+ - `<agent> "<prompt>"` - create a ticket from the prompt (project inferred from the current directory) and launch the agent on it in one step, e.g. `ovld claude "fix the flaky login test" --model opus`. Built-in agents need an installed connector (`ovld setup <agent>`, override with `--allow-uninstalled`); your custom agents launch by id. Flags after a standalone `--` pass through to the agent binary.
99
104
  - `attach` - search tickets and launch an agent interactively (`ovld attach [ticketId] [agent]`)
100
105
  - `create` - create a ticket with numbered project selection; supports `--agent`, `--model`, `--delegate` (same delegate flags as `ovld protocol create`)
101
106
  - `prompt` - create a ticket, then launch an agent on it
@@ -0,0 +1,370 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Direct agent launch: `ovld <agent> "<prompt>" [overlord flags] [-- passthrough]`.
5
+ *
6
+ * Creates a ticket from the prompt (project inferred from the working directory),
7
+ * then launches the chosen agent on it — giving power users all of Overlord's
8
+ * tracking without ever opening the desktop/web UI. Launch is limited to agents
9
+ * with an installed connector (built-ins) or one of the user's custom agents.
10
+ *
11
+ * This is a thin composition over existing pieces:
12
+ * - `/api/protocol/prompt` creates + auto-titles the ticket and resolves the
13
+ * project from the working directory.
14
+ * - `runLauncherCommand('run' | 'launch-custom', …)` launches the agent locally.
15
+ */
16
+
17
+ import { buildAuthHeaders, resolveAuth } from './credentials.mjs';
18
+ import {
19
+ BUILTIN_LAUNCH_AGENTS,
20
+ agentIdentifierMap,
21
+ runLauncherCommand
22
+ } from './launcher.mjs';
23
+ import { isAgentConnectorInstalled } from './setup.mjs';
24
+
25
+ /** Overlord flags that take a value (everything else after `--key` is a boolean). */
26
+ const VALUE_FLAGS = new Set([
27
+ 'project-id',
28
+ 'priority',
29
+ 'title',
30
+ 'acceptance-criteria',
31
+ 'available-tools',
32
+ 'working-directory',
33
+ 'model',
34
+ 'thinking',
35
+ 'delegate',
36
+ 'pre-command',
37
+ 'launch-mode',
38
+ 'organization-id',
39
+ 'ssh-command',
40
+ 'remote-working-directory',
41
+ 'server-multiplexer',
42
+ 'tmux-command',
43
+ 'feed-post-id',
44
+ 'initial-question'
45
+ ]);
46
+
47
+ const BOOLEAN_FLAGS = new Set(['personal', 'for-human', 'allow-uninstalled']);
48
+
49
+ /** Launch flags forwarded verbatim to `ovld launch`/`launch-custom`. */
50
+ const FORWARDED_VALUE_FLAGS = [
51
+ 'pre-command',
52
+ 'launch-mode',
53
+ 'organization-id',
54
+ 'ssh-command',
55
+ 'remote-working-directory',
56
+ 'server-multiplexer',
57
+ 'tmux-command',
58
+ 'feed-post-id',
59
+ 'initial-question'
60
+ ];
61
+
62
+ export function isBuiltinLaunchAgent(name) {
63
+ return BUILTIN_LAUNCH_AGENTS.includes(String(name ?? '').trim().toLowerCase());
64
+ }
65
+
66
+ /**
67
+ * Substitute `{{token}}` placeholders in a custom-agent command template.
68
+ * Mirrors `resolveCustomAgentCommand` in lib/helpers/custom-agent.ts (kept in
69
+ * sync by hand because the published CLI cannot import the app's TypeScript).
70
+ */
71
+ export function resolveTemplate(template, values) {
72
+ const substituted = String(template).replace(
73
+ /\{\{\s*([\w.-]+)\s*\}\}/g,
74
+ (_match, token) => {
75
+ const value = values[token];
76
+ return typeof value === 'string' ? value.trim() : '';
77
+ }
78
+ );
79
+ return substituted.replace(/\s+/g, ' ').trim();
80
+ }
81
+
82
+ /** Default placeholder values for a custom agent given a model/thinking choice. */
83
+ export function buildCustomAgentValues(agent, model, thinking) {
84
+ const values = {};
85
+ for (const placeholder of agent.placeholders ?? []) {
86
+ if (placeholder.role === 'model' && model) {
87
+ values[placeholder.token] = model;
88
+ } else if (placeholder.role === 'thinking' && thinking) {
89
+ values[placeholder.token] = thinking;
90
+ } else if (Array.isArray(placeholder.options) && placeholder.options.length > 0) {
91
+ values[placeholder.token] = placeholder.options[0].value;
92
+ }
93
+ }
94
+ return values;
95
+ }
96
+
97
+ /**
98
+ * Parse direct-launch args into the objective, recognized Overlord flags, the
99
+ * repeatable `--flag` values, and everything after a standalone `--`.
100
+ */
101
+ export function parseDirectLaunchArgs(rawArgs) {
102
+ const args = Array.isArray(rawArgs) ? rawArgs : [];
103
+ const sepIdx = args.indexOf('--');
104
+ const head = sepIdx === -1 ? args : args.slice(0, sepIdx);
105
+ const passthrough = sepIdx === -1 ? [] : args.slice(sepIdx + 1);
106
+
107
+ const flags = {};
108
+ const repeatedFlags = [];
109
+ const positionals = [];
110
+
111
+ for (let i = 0; i < head.length; i++) {
112
+ const arg = head[i];
113
+ if (!arg.startsWith('--')) {
114
+ positionals.push(arg);
115
+ continue;
116
+ }
117
+
118
+ const eqIdx = arg.indexOf('=');
119
+ const key = eqIdx === -1 ? arg.slice(2) : arg.slice(2, eqIdx);
120
+ const inlineValue = eqIdx === -1 ? undefined : arg.slice(eqIdx + 1);
121
+
122
+ if (key === 'flag') {
123
+ const value = inlineValue !== undefined ? inlineValue : head[++i];
124
+ if (value !== undefined) repeatedFlags.push(value);
125
+ continue;
126
+ }
127
+
128
+ if (BOOLEAN_FLAGS.has(key)) {
129
+ flags[key] =
130
+ inlineValue === undefined ? true : /^(1|true|yes|)$/i.test(inlineValue);
131
+ continue;
132
+ }
133
+
134
+ if (inlineValue !== undefined) {
135
+ flags[key] = inlineValue;
136
+ continue;
137
+ }
138
+
139
+ // Value-taking flag (known or unknown): consume the next token unless it
140
+ // looks like another flag.
141
+ if (i + 1 < head.length && !head[i + 1].startsWith('--')) {
142
+ flags[key] = head[++i];
143
+ } else {
144
+ flags[key] = true;
145
+ }
146
+ }
147
+
148
+ return {
149
+ objective: positionals.join(' ').trim(),
150
+ flags,
151
+ repeatedFlags,
152
+ passthrough
153
+ };
154
+ }
155
+
156
+ function resolveForHuman(flags) {
157
+ const raw = flags['for-human'];
158
+ if (raw === undefined) return false;
159
+ if (raw === true) return true;
160
+ const normalized = String(raw).trim().toLowerCase();
161
+ return normalized === '' || normalized === 'true' || normalized === '1' || normalized === 'yes';
162
+ }
163
+
164
+ async function readStdin() {
165
+ const chunks = [];
166
+ for await (const chunk of process.stdin) {
167
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
168
+ }
169
+ return Buffer.concat(chunks).toString('utf8');
170
+ }
171
+
172
+ async function fetchUserAgents({ platformUrl, bearerToken, localSecret, organizationId }) {
173
+ const res = await fetch(`${platformUrl}/api/protocol/agents`, {
174
+ headers: buildAuthHeaders(bearerToken, localSecret, organizationId)
175
+ });
176
+ const data = await res.json().catch(() => ({}));
177
+ if (!res.ok) {
178
+ throw new Error(
179
+ `Failed to list agents (${res.status}): ${data.error ?? JSON.stringify(data)}`
180
+ );
181
+ }
182
+ return {
183
+ builtins: Array.isArray(data.builtins) ? data.builtins : [],
184
+ customAgents: Array.isArray(data.customAgents) ? data.customAgents : []
185
+ };
186
+ }
187
+
188
+ async function createPromptTicket(
189
+ { platformUrl, bearerToken, localSecret, organizationId },
190
+ body
191
+ ) {
192
+ const res = await fetch(`${platformUrl}/api/protocol/prompt`, {
193
+ method: 'POST',
194
+ headers: {
195
+ ...buildAuthHeaders(bearerToken, localSecret, organizationId),
196
+ 'Content-Type': 'application/json'
197
+ },
198
+ body: JSON.stringify(body)
199
+ });
200
+ const data = await res.json().catch(() => ({}));
201
+ if (!res.ok) {
202
+ const detail = data.error ?? JSON.stringify(data);
203
+ throw new Error(`Failed to create ticket (${res.status}): ${detail}`);
204
+ }
205
+ return data;
206
+ }
207
+
208
+ function printDirectLaunchHelp(agent = '<agent>') {
209
+ console.log(`Usage:
210
+ ovld ${agent} "<prompt>" [overlord flags] [-- <agent passthrough flags>]
211
+
212
+ Creates a ticket from the prompt (project inferred from the current directory),
213
+ then launches the agent on it and runs the normal ticket lifecycle.
214
+
215
+ Overlord flags:
216
+ --model <identifier> Preferred model (sets the ticket delegate and is forwarded to the agent)
217
+ --thinking <level> Agent reasoning/effort level
218
+ --flag <value> Extra agent flag (repeatable)
219
+ --pre-command <command> Command to run before the agent binary (e.g. ollama)
220
+ --project-id <uuid> Target project (skips working-directory resolution)
221
+ --personal Create the ticket in your personal project
222
+ --for-human Create a human-review ticket
223
+ --priority <low|medium|high|urgent>
224
+ --title "<title>" Override the auto-generated title
225
+ --working-directory <path> Directory used for project resolution and launch
226
+ --allow-uninstalled Launch a built-in agent even if its connector isn't installed
227
+ --launch-mode <run|ask> Ask mode adjusts the fetched prompt context
228
+ --ssh-command / --remote-working-directory / --server-multiplexer / --tmux-command
229
+
230
+ Agent passthrough:
231
+ Anything after a standalone \`--\` is forwarded verbatim to the agent binary.
232
+
233
+ Examples:
234
+ ovld claude "refactor the auth middleware" --model opus --thinking high
235
+ ovld codex "investigate the memory leak" -- --search --full-auto
236
+ ovld cursor "tidy the dashboard styles" --project-id <uuid> --for-human
237
+ echo "summarize today's diffs" | ovld claude`);
238
+ }
239
+
240
+ /**
241
+ * Entry point for `ovld <agent> …`. `agent` is argv[0]; `rawArgs` is the rest.
242
+ */
243
+ export async function runDirectLaunch(agent, rawArgs = []) {
244
+ if (rawArgs[0] === '--help' || rawArgs[0] === '-h' || rawArgs[0] === 'help') {
245
+ printDirectLaunchHelp(agent);
246
+ return;
247
+ }
248
+
249
+ const normalizedAgent = String(agent ?? '').trim().toLowerCase();
250
+ const { objective: parsedObjective, flags, repeatedFlags, passthrough } =
251
+ parseDirectLaunchArgs(rawArgs);
252
+
253
+ let objective = parsedObjective;
254
+ if (!objective && !process.stdin.isTTY) {
255
+ objective = (await readStdin()).trim();
256
+ }
257
+
258
+ const builtin = isBuiltinLaunchAgent(normalizedAgent);
259
+ const auth = await resolveAuth();
260
+
261
+ // Classify + gate the agent before we create a ticket.
262
+ let customAgent = null;
263
+ if (builtin) {
264
+ if (!flags['allow-uninstalled'] && !isAgentConnectorInstalled(normalizedAgent)) {
265
+ console.error(
266
+ `The ${normalizedAgent} connector is not installed. Run \`ovld setup ${normalizedAgent}\` first, ` +
267
+ `or pass --allow-uninstalled to launch anyway.`
268
+ );
269
+ process.exit(1);
270
+ }
271
+ } else {
272
+ const { customAgents } = await fetchUserAgents(auth);
273
+ customAgent =
274
+ customAgents.find(candidate => candidate.id === agent) ??
275
+ customAgents.find(candidate => candidate.id === normalizedAgent) ??
276
+ customAgents.find(candidate => candidate.name === agent) ??
277
+ null;
278
+ if (!customAgent) {
279
+ console.error(`Unknown agent: "${agent}".`);
280
+ console.error(`Built-in agents: ${BUILTIN_LAUNCH_AGENTS.join(', ')}`);
281
+ console.error(
282
+ customAgents.length
283
+ ? `Your custom agents: ${customAgents.map(a => a.id).join(', ')}`
284
+ : 'You have no custom agents. Create one in Overlord settings to launch it by id.'
285
+ );
286
+ process.exit(1);
287
+ }
288
+ }
289
+
290
+ if (!objective) {
291
+ console.error('An objective is required. Provide it as a quoted argument or pipe it via stdin.');
292
+ printDirectLaunchHelp(agent);
293
+ process.exit(1);
294
+ }
295
+
296
+ const personal = Boolean(flags.personal);
297
+ const explicitProject =
298
+ typeof flags['project-id'] === 'string' && flags['project-id'].trim().length > 0;
299
+ const explicitWorkingDirectory =
300
+ typeof flags['working-directory'] === 'string' && flags['working-directory'].trim().length > 0
301
+ ? flags['working-directory'].trim()
302
+ : '';
303
+ // Send cwd so the server can resolve the project, unless the caller pinned a
304
+ // project or asked for a personal ticket.
305
+ const workingDirectory =
306
+ explicitWorkingDirectory || (!explicitProject && !personal ? process.cwd() : '');
307
+
308
+ const model = typeof flags.model === 'string' ? flags.model.trim() : '';
309
+ const thinking = typeof flags.thinking === 'string' ? flags.thinking.trim() : '';
310
+ const agentIdentifier = builtin ? agentIdentifierMap[normalizedAgent] : 'custom';
311
+ const delegate =
312
+ (typeof flags.delegate === 'string' && flags.delegate.trim()) || model || agentIdentifier;
313
+
314
+ const body = {
315
+ objectives: [{ objective }],
316
+ agentIdentifier,
317
+ connectionMethod: 'cli',
318
+ metadata: { launchedVia: 'ovld-direct', ...(model ? { model } : {}) },
319
+ delegate,
320
+ ...(flags.title ? { title: String(flags.title) } : {}),
321
+ ...(flags.priority ? { priority: String(flags.priority) } : {}),
322
+ ...(explicitProject ? { projectId: String(flags['project-id']).trim() } : {}),
323
+ ...(personal ? { personal: true } : {}),
324
+ ...(workingDirectory ? { workingDirectory } : {}),
325
+ ...(flags['acceptance-criteria']
326
+ ? { acceptanceCriteria: String(flags['acceptance-criteria']) }
327
+ : {}),
328
+ ...(flags['available-tools'] ? { availableTools: String(flags['available-tools']) } : {}),
329
+ ...(resolveForHuman(flags) ? { forHuman: true } : {})
330
+ };
331
+
332
+ const result = await createPromptTicket(auth, body);
333
+ const ticketId = result.ticket?.ticket_id ?? result.ticket?.id;
334
+ if (!ticketId) {
335
+ console.error('Ticket creation did not return a ticket id.');
336
+ process.exit(1);
337
+ }
338
+
339
+ console.log(`Created ticket ${ticketId} — launching ${agent}…`);
340
+ process.env.TICKET_ID = String(ticketId);
341
+
342
+ const baseLaunchArgs = ['--ticket-id', String(ticketId)];
343
+ if (workingDirectory) baseLaunchArgs.push('--working-directory', workingDirectory);
344
+ for (const name of FORWARDED_VALUE_FLAGS) {
345
+ const value = flags[name];
346
+ if (typeof value === 'string' && value.trim()) {
347
+ baseLaunchArgs.push(`--${name}`, value.trim());
348
+ }
349
+ }
350
+
351
+ if (builtin) {
352
+ const launchArgs = [normalizedAgent, ...baseLaunchArgs];
353
+ if (model) launchArgs.push('--model', model);
354
+ if (thinking) launchArgs.push('--thinking', thinking);
355
+ for (const flag of repeatedFlags) launchArgs.push('--flag', flag);
356
+ // Forward `--` passthrough as individual agent flags (same channel as --flag).
357
+ for (const token of passthrough) launchArgs.push('--flag', token);
358
+ await runLauncherCommand('run', launchArgs);
359
+ return;
360
+ }
361
+
362
+ // Custom agent: resolve the template locally, then append any --flag/passthrough
363
+ // tokens to the resolved command (launch-custom runs `<command> <context>`).
364
+ const values = buildCustomAgentValues(customAgent, model, thinking);
365
+ const extraTokens = [...repeatedFlags, ...passthrough].filter(Boolean);
366
+ const resolvedCommand = [resolveTemplate(customAgent.commandTemplate, values), ...extraTokens]
367
+ .join(' ')
368
+ .trim();
369
+ await runLauncherCommand('launch-custom', ['--command', resolvedCommand, ...baseLaunchArgs]);
370
+ }
@@ -29,6 +29,7 @@ function printHelp(primaryCommand) {
29
29
  Primary command: ${primaryCommand}
30
30
 
31
31
  Usage:
32
+ ${primaryCommand} <agent> "<prompt>" [flags] Create a ticket from the prompt and launch the agent on it
32
33
  ${primaryCommand} attach [ticketId] [agent] Search tickets and launch an agent (interactive)
33
34
  ${primaryCommand} create "<objective>" Create a ticket with numbered project selection; supports --agent/--model/--delegate
34
35
  ${primaryCommand} prompt "<objective>" Create a ticket, then launch an agent on it
@@ -48,6 +49,10 @@ Usage:
48
49
  ${primaryCommand} help Show this help message
49
50
 
50
51
  Agents:
52
+ ${primaryCommand} <agent> "<prompt>" [--model <id>] [--thinking <level>] [-- <agent flags>]
53
+ Built-in agents (claude, codex, cursor, antigravity, opencode) must have an
54
+ installed connector (${primaryCommand} setup <agent>); your custom agents launch by id.
55
+ Anything after a standalone -- is forwarded verbatim to the agent binary.
51
56
  Use ${primaryCommand} protocol help for ticket lifecycle commands.
52
57
  Key protocol commands: auth-status, discover-project, create, prompt, attach, connect, load-context.
53
58
 
@@ -172,12 +177,23 @@ export async function runCli({ primaryCommand }) {
172
177
  command === 'connect' ||
173
178
  command === 'restart' ||
174
179
  command === 'run' ||
175
- command === 'resume'
180
+ command === 'resume' ||
181
+ command === 'launch-custom'
176
182
  ) {
177
183
  await runLauncherCommand(command, rest);
178
184
  return;
179
185
  }
180
186
 
187
+ // Direct agent launch: `ovld <agent> "<prompt>" [flags]`. Built-in agents
188
+ // dispatch immediately; an unknown command with a candidate objective may be
189
+ // a user's custom agent, so we let direct-launch resolve and report it.
190
+ const { isBuiltinLaunchAgent, runDirectLaunch } = await import('./direct-launch.mjs');
191
+ const hasCandidateObjective = rest.some(arg => !arg.startsWith('--'));
192
+ if (isBuiltinLaunchAgent(command) || hasCandidateObjective) {
193
+ await runDirectLaunch(command, rest);
194
+ return;
195
+ }
196
+
181
197
  console.error(`Unknown command: ${command}\n`);
182
198
  printHelp(primaryCommand);
183
199
  process.exit(1);
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { execFileSync } from 'node:child_process';
9
+ import crypto from 'node:crypto';
9
10
  import fs from 'node:fs';
10
11
  import os from 'node:os';
11
12
  import path from 'node:path';
@@ -17,6 +18,29 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
18
  const PACKAGE_CLAUDE_PLUGIN_DIR = path.resolve(__dirname, '..', '..', 'plugins', 'claude');
18
19
  const REPO_CLAUDE_PLUGIN_DIR = path.resolve(__dirname, '..', '..', '..', '..', 'plugins', 'claude');
19
20
 
21
+ function resolveLaunchScratchDir(workingDirectory, { explicit = false } = {}) {
22
+ const trimmed = typeof workingDirectory === 'string' ? workingDirectory.trim() : '';
23
+ const candidate = trimmed || process.cwd();
24
+ const useProjectScratch =
25
+ explicit || fs.existsSync(path.join(candidate, '.overlord', 'project.json'));
26
+ if (!useProjectScratch) {
27
+ return os.tmpdir();
28
+ }
29
+ const scratchDir = path.join(candidate, '.overlord', 'tmp');
30
+ fs.mkdirSync(scratchDir, { recursive: true });
31
+ return scratchDir;
32
+ }
33
+
34
+ function withLaunchTempEnv(baseEnv, scratchDir) {
35
+ return {
36
+ ...baseEnv,
37
+ TMPDIR: scratchDir,
38
+ TMP: scratchDir,
39
+ TEMP: scratchDir,
40
+ OVERLORD_TMPDIR: scratchDir
41
+ };
42
+ }
43
+
20
44
  function claudeSourcePluginDir() {
21
45
  if (fs.existsSync(PACKAGE_CLAUDE_PLUGIN_DIR)) return PACKAGE_CLAUDE_PLUGIN_DIR;
22
46
  if (fs.existsSync(REPO_CLAUDE_PLUGIN_DIR)) return REPO_CLAUDE_PLUGIN_DIR;
@@ -53,8 +77,8 @@ function getInstructionMode(agent) {
53
77
  return 'legacy';
54
78
  }
55
79
 
56
- function buildAgyLaunchArgv({ contextFile, mode, sessionId, extraArgs }) {
57
- const argv = [...extraArgs, '--add-dir', os.tmpdir()];
80
+ function buildAgyLaunchArgv({ contextFile, scratchDir, mode, sessionId, extraArgs }) {
81
+ const argv = [...extraArgs, '--add-dir', scratchDir];
58
82
  if (mode === 'resume') {
59
83
  const agySessionId = sessionId?.trim();
60
84
  if (agySessionId) {
@@ -67,12 +91,21 @@ function buildAgyLaunchArgv({ contextFile, mode, sessionId, extraArgs }) {
67
91
  return argv;
68
92
  }
69
93
 
70
- async function fetchContext(platformUrl, bearerToken, localSecret, organizationId, ticketId, agent) {
94
+ async function fetchContext(platformUrl, bearerToken, localSecret, organizationId, ticketId, agent, options = {}) {
71
95
  const params = new URLSearchParams({
72
96
  context: 'cli',
73
97
  agent,
74
98
  instructionMode: getInstructionMode(agent)
75
99
  });
100
+ if (options.launchMode === 'ask') params.set('mode', 'ask');
101
+ if (options.sessionId) params.set('sessionId', options.sessionId);
102
+ if (options.workspace) params.set('workspace', options.workspace);
103
+ if (options.feedPostId) {
104
+ params.set('feedPostId', options.feedPostId);
105
+ if (typeof options.initialQuestion === 'string') {
106
+ params.set('initialQuestion', options.initialQuestion);
107
+ }
108
+ }
76
109
  const url = `${platformUrl}/api/protocol/context/${ticketId}?${params.toString()}`;
77
110
  const response = await fetch(url, {
78
111
  headers: buildAuthHeaders(bearerToken, localSecret, organizationId)
@@ -84,7 +117,10 @@ async function fetchContext(platformUrl, bearerToken, localSecret, organizationI
84
117
  );
85
118
  }
86
119
 
87
- return response.text();
120
+ const context = await response.text();
121
+ const workingDirectory = response.headers.get('X-Working-Directory') ?? '';
122
+ const humanTicketId = response.headers.get('X-Ticket-Id') ?? '';
123
+ return { context, workingDirectory, humanTicketId };
88
124
  }
89
125
 
90
126
  const agentIdentifierMap = {
@@ -98,10 +134,29 @@ const agentIdentifierMap = {
98
134
 
99
135
  const supportedAgents = ['claude', 'codex', 'cursor', 'antigravity', 'opencode', 'pi'];
100
136
 
137
+ // Re-exported under clearer names for the direct-launch dispatcher in index.mjs.
138
+ export { supportedAgents as BUILTIN_LAUNCH_AGENTS, agentIdentifierMap };
139
+
101
140
  function shellQuote(value) {
102
141
  return `'${String(value).replace(/'/g, `'\\''`)}'`;
103
142
  }
104
143
 
144
+ /**
145
+ * Run an agent binary, optionally routed through a user-defined pre-command
146
+ * (e.g. `ollama` to run claude-code through Ollama). When a pre-command is set,
147
+ * its first token becomes the executable and the agent binary + args follow it:
148
+ * preCommand="ollama" → ollama claude <args...>
149
+ */
150
+ function execAgentBinary(binary, args, opts, preCommand) {
151
+ const pre = typeof preCommand === 'string' ? preCommand.trim() : '';
152
+ if (pre) {
153
+ const preTokens = pre.split(/\s+/).filter(Boolean);
154
+ execFileSync(preTokens[0], [...preTokens.slice(1), binary, ...args], opts);
155
+ return;
156
+ }
157
+ execFileSync(binary, args, opts);
158
+ }
159
+
105
160
  function toTomlString(value) {
106
161
  return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
107
162
  }
@@ -178,6 +233,9 @@ function buildRemoteLaunchCommand(agent, options) {
178
233
  if (options.launchMode === 'ask') {
179
234
  nestedParts.push('--launch-mode', 'ask');
180
235
  }
236
+ if (options.preCommand) {
237
+ nestedParts.push('--pre-command', shellQuote(options.preCommand));
238
+ }
181
239
  if (options.model) {
182
240
  nestedParts.push('--model', shellQuote(options.model));
183
241
  }
@@ -234,10 +292,13 @@ Options:
234
292
  --model <identifier> Preferred model identifier
235
293
  --thinking <level> Agent reasoning/effort level
236
294
  --flag <value> Extra agent flag (repeatable)
295
+ --pre-command <command> Command to run before the agent binary (e.g. ollama)
237
296
  --ssh-command <command> Launch remotely over SSH by running ovld on the target host
238
297
  --remote-working-directory <path> Change to this path on the remote host before launch
239
298
  --server-multiplexer <none|tmux> Wrap remote launches in tmux
240
299
  --tmux-command <template> Remote tmux template; use {script} as the placeholder
300
+ --feed-post-id <uuid> Include a feed post in the context (desktop feed discuss)
301
+ --initial-question <text> First user question for feed discuss (paired with --feed-post-id)
241
302
 
242
303
  Notes:
243
304
  - ovld launch is the primary user-facing launcher.
@@ -307,6 +368,10 @@ async function runAgent(agent, mode = 'run', options = {}) {
307
368
  process.chdir(options.workingDirectory);
308
369
  }
309
370
 
371
+ const scratchDir = resolveLaunchScratchDir(options.workingDirectory, {
372
+ explicit: Boolean(options.workingDirectory)
373
+ });
374
+
310
375
  const launchOrganizationId = resolveLaunchOrganizationId(
311
376
  ticketId,
312
377
  options.organizationId,
@@ -320,7 +385,10 @@ async function runAgent(agent, mode = 'run', options = {}) {
320
385
  options.organizationId,
321
386
  organizationId
322
387
  );
323
- if (options.sshCommand?.trim()) {
388
+ const launchSessionId = crypto.randomUUID();
389
+ const isRemote = Boolean(options.sshCommand?.trim());
390
+
391
+ if (isRemote) {
324
392
  const remoteCommand = buildRemoteLaunchCommand(agent, {
325
393
  ...options,
326
394
  organizationId: resolvedLaunchOrganizationId
@@ -335,22 +403,44 @@ async function runAgent(agent, mode = 'run', options = {}) {
335
403
  }
336
404
  }
337
405
 
338
- const context = await fetchContext(
406
+ const { context, workingDirectory: apiWorkingDirectory, humanTicketId } = await fetchContext(
339
407
  platformUrl,
340
408
  bearerToken,
341
409
  localSecret,
342
410
  resolvedLaunchOrganizationId,
343
411
  ticketId,
344
- agent
412
+ agent,
413
+ {
414
+ launchMode: options.launchMode,
415
+ sessionId: launchSessionId,
416
+ workspace: isRemote ? 'ssh' : undefined,
417
+ feedPostId: options.feedPostId,
418
+ initialQuestion: options.initialQuestion
419
+ }
345
420
  );
346
421
 
347
- const childEnv = {
422
+ if (humanTicketId) {
423
+ process.env.TICKET_ID = humanTicketId;
424
+ }
425
+
426
+ if (!options.workingDirectory && apiWorkingDirectory) {
427
+ try {
428
+ process.chdir(apiWorkingDirectory);
429
+ } catch {
430
+ // Best-effort; the agent will run in the current directory.
431
+ }
432
+ }
433
+
434
+ const childEnv = withLaunchTempEnv({
348
435
  ...process.env,
349
436
  AGENT_IDENTIFIER: agentIdentifierMap[agent],
437
+ OVERLORD_LAUNCH_SESSION_ID: launchSessionId,
438
+ OVERLORD_MODEL_IDENTIFIER: options.model ?? '',
439
+ MODEL_IDENTIFIER: options.model ?? '',
350
440
  ...(resolvedLaunchOrganizationId
351
441
  ? { OVERLORD_ORGANIZATION_ID: String(resolvedLaunchOrganizationId) }
352
442
  : {})
353
- };
443
+ }, scratchDir);
354
444
  const extraArgs = buildExtraArgs(agent, options);
355
445
 
356
446
  try {
@@ -363,7 +453,7 @@ async function runAgent(agent, mode = 'run', options = {}) {
363
453
  : ['--continue', context];
364
454
  args.unshift(...extraArgs);
365
455
  if (pluginDir) args.unshift('--plugin-dir', pluginDir);
366
- execFileSync('claude', args, { stdio: 'inherit', env: childEnv });
456
+ execAgentBinary('claude', args, { stdio: 'inherit', env: childEnv }, options.preCommand);
367
457
  } else {
368
458
  const args = [
369
459
  '--append-system-prompt',
@@ -372,11 +462,7 @@ async function runAgent(agent, mode = 'run', options = {}) {
372
462
  'Begin working on this ticket. Start by calling the attach endpoint, then proceed with the objective described in your system prompt.'
373
463
  ];
374
464
  if (pluginDir) args.unshift('--plugin-dir', pluginDir);
375
- execFileSync(
376
- 'claude',
377
- args,
378
- { stdio: 'inherit', env: childEnv }
379
- );
465
+ execAgentBinary('claude', args, { stdio: 'inherit', env: childEnv }, options.preCommand);
380
466
  }
381
467
  } else if (agent === 'codex') {
382
468
  if (mode === 'resume') {
@@ -385,12 +471,22 @@ async function runAgent(agent, mode = 'run', options = {}) {
385
471
  ? ['resume', codexSessionId, context]
386
472
  : ['resume', '--last', context];
387
473
  args.splice(1, 0, ...extraArgs);
388
- execFileSync('codex', args, { stdio: 'inherit', env: childEnv });
474
+ execAgentBinary('codex', args, { stdio: 'inherit', env: childEnv }, options.preCommand);
389
475
  } else {
390
- execFileSync('codex', [...extraArgs, context], { stdio: 'inherit', env: childEnv });
476
+ execAgentBinary(
477
+ 'codex',
478
+ [...extraArgs, context],
479
+ { stdio: 'inherit', env: childEnv },
480
+ options.preCommand
481
+ );
391
482
  }
392
483
  } else if (agent === 'cursor') {
393
- execFileSync('agent', [...extraArgs, context], { stdio: 'inherit', env: childEnv });
484
+ execAgentBinary(
485
+ 'agent',
486
+ [...extraArgs, context],
487
+ { stdio: 'inherit', env: childEnv },
488
+ options.preCommand
489
+ );
394
490
  } else if (agent === 'opencode') {
395
491
  if (mode === 'resume') {
396
492
  const openCodeSessionId = process.env.OPENCODE_SESSION_ID?.trim();
@@ -398,12 +494,14 @@ async function runAgent(agent, mode = 'run', options = {}) {
398
494
  ? ['--continue', '--session', openCodeSessionId, '--prompt', context]
399
495
  : ['--continue', '--prompt', context];
400
496
  args.unshift(...extraArgs);
401
- execFileSync('opencode', args, { stdio: 'inherit', env: childEnv });
497
+ execAgentBinary('opencode', args, { stdio: 'inherit', env: childEnv }, options.preCommand);
402
498
  } else {
403
- execFileSync('opencode', [...extraArgs, '--prompt', context], {
404
- stdio: 'inherit',
405
- env: childEnv
406
- });
499
+ execAgentBinary(
500
+ 'opencode',
501
+ [...extraArgs, '--prompt', context],
502
+ { stdio: 'inherit', env: childEnv },
503
+ options.preCommand
504
+ );
407
505
  }
408
506
  } else if (agent === 'pi') {
409
507
  if (mode === 'resume') {
@@ -412,27 +510,34 @@ async function runAgent(agent, mode = 'run', options = {}) {
412
510
  ? ['--session', piSessionId, context]
413
511
  : ['--continue', context];
414
512
  args.unshift(...extraArgs);
415
- execFileSync('pi', args, { stdio: 'inherit', env: childEnv });
513
+ execAgentBinary('pi', args, { stdio: 'inherit', env: childEnv }, options.preCommand);
416
514
  } else {
417
- execFileSync('pi', [...extraArgs, context], { stdio: 'inherit', env: childEnv });
515
+ execAgentBinary(
516
+ 'pi',
517
+ [...extraArgs, context],
518
+ { stdio: 'inherit', env: childEnv },
519
+ options.preCommand
520
+ );
418
521
  }
419
522
  } else if (agent === 'antigravity') {
420
523
  const tag = `overlord-${ticketId.slice(-8)}-${Date.now()}`;
421
- const contextFile = path.join(os.tmpdir(), `${tag}-ctx.md`);
524
+ const contextFile = path.join(scratchDir, `${tag}-ctx.md`);
422
525
  fs.writeFileSync(contextFile, context, 'utf-8');
423
526
  setTimeout(() => { try { fs.unlinkSync(contextFile); } catch { /* already gone */ } }, 30 * 60_000).unref();
424
527
 
425
528
  const agySessionId =
426
529
  process.env.AGY_SESSION_ID?.trim() ?? process.env.GEMINI_SESSION_ID?.trim();
427
- execFileSync(
530
+ execAgentBinary(
428
531
  'agy',
429
532
  buildAgyLaunchArgv({
430
533
  contextFile,
534
+ scratchDir,
431
535
  mode,
432
536
  sessionId: agySessionId,
433
537
  extraArgs
434
538
  }),
435
- { stdio: 'inherit', env: childEnv }
539
+ { stdio: 'inherit', env: childEnv },
540
+ options.preCommand
436
541
  );
437
542
  }
438
543
  } catch (error) {
@@ -460,12 +565,97 @@ async function runAgent(agent, mode = 'run', options = {}) {
460
565
  }
461
566
  }
462
567
 
568
+ /**
569
+ * Launch a user-defined custom agent. The resolved launch command (with all
570
+ * `{{token}}` placeholders already substituted) is passed via --command; we
571
+ * fetch the ticket context and run `<command> <context>` in the shell.
572
+ */
573
+ async function runCustomAgent(args) {
574
+ const { flags } = parseLauncherArgs(args);
575
+ const command = typeof flags.command === 'string' ? flags.command.trim() : '';
576
+ const ticketId = typeof flags['ticket-id'] === 'string' ? flags['ticket-id'].trim() : '';
577
+ if (!command) {
578
+ console.error('Missing required option: --command "<resolved launch command>"');
579
+ process.exit(1);
580
+ }
581
+ if (ticketId) {
582
+ process.env.TICKET_ID = ticketId;
583
+ }
584
+ const resolvedTicketId = process.env.TICKET_ID;
585
+ if (!resolvedTicketId) {
586
+ console.error('Missing required environment variable: TICKET_ID');
587
+ process.exit(1);
588
+ }
589
+
590
+ if (typeof flags['working-directory'] === 'string' && flags['working-directory'].trim()) {
591
+ process.chdir(flags['working-directory'].trim());
592
+ }
593
+
594
+ const scratchDir = resolveLaunchScratchDir(flags['working-directory'], {
595
+ explicit: typeof flags['working-directory'] === 'string' && flags['working-directory'].trim().length > 0
596
+ });
597
+
598
+ const launchOrganizationId = resolveLaunchOrganizationId(
599
+ resolvedTicketId,
600
+ flags['organization-id'],
601
+ null
602
+ );
603
+ const { platformUrl, bearerToken, localSecret, organizationId } = await resolveAuth({
604
+ organizationIdHint: launchOrganizationId
605
+ });
606
+ const resolvedLaunchOrganizationId = resolveLaunchOrganizationId(
607
+ resolvedTicketId,
608
+ flags['organization-id'],
609
+ organizationId
610
+ );
611
+ const launchSessionId = crypto.randomUUID();
612
+ const feedPostId = typeof flags['feed-post-id'] === 'string' ? flags['feed-post-id'].trim() : '';
613
+ const initialQuestion = typeof flags['initial-question'] === 'string' ? flags['initial-question'].trim() : '';
614
+ // Custom agents have no Overlord plugin/bundle; use the generic "claude" context.
615
+ const { context } = await fetchContext(
616
+ platformUrl,
617
+ bearerToken,
618
+ localSecret,
619
+ resolvedLaunchOrganizationId,
620
+ resolvedTicketId,
621
+ 'claude',
622
+ {
623
+ sessionId: launchSessionId,
624
+ ...(feedPostId ? { feedPostId, initialQuestion } : {})
625
+ }
626
+ );
627
+
628
+ const childEnv = withLaunchTempEnv({
629
+ ...process.env,
630
+ AGENT_IDENTIFIER: 'custom',
631
+ OVERLORD_LAUNCH_SESSION_ID: launchSessionId,
632
+ ...(resolvedLaunchOrganizationId
633
+ ? { OVERLORD_ORGANIZATION_ID: String(resolvedLaunchOrganizationId) }
634
+ : {})
635
+ }, scratchDir);
636
+
637
+ try {
638
+ execFileSync('sh', ['-lc', `${command} ${shellQuote(context)}`], {
639
+ stdio: 'inherit',
640
+ env: childEnv
641
+ });
642
+ } catch (error) {
643
+ console.error(error instanceof Error ? error.message : String(error));
644
+ process.exit(1);
645
+ }
646
+ }
647
+
463
648
  export async function runLauncherCommand(command, args) {
464
649
  if (args[0] === '--help' || args[0] === '-h' || args[0] === 'help') {
465
650
  printLauncherHelp();
466
651
  return;
467
652
  }
468
653
 
654
+ if (command === 'launch-custom') {
655
+ await runCustomAgent(args);
656
+ return;
657
+ }
658
+
469
659
  const normalizedCommand =
470
660
  command === 'launch' || command === 'connect'
471
661
  ? 'run'
@@ -486,6 +676,7 @@ export async function runLauncherCommand(command, args) {
486
676
  launchMode: flags['launch-mode'] === 'ask' ? 'ask' : 'run',
487
677
  model: typeof flags.model === 'string' ? flags.model.trim() : '',
488
678
  thinking: typeof flags.thinking === 'string' ? flags.thinking.trim() : '',
679
+ preCommand: typeof flags['pre-command'] === 'string' ? flags['pre-command'].trim() : '',
489
680
  flags: repeatedFlags,
490
681
  organizationId:
491
682
  typeof flags['organization-id'] === 'string' ? flags['organization-id'].trim() : '',
@@ -496,7 +687,9 @@ export async function runLauncherCommand(command, args) {
496
687
  : '',
497
688
  serverMultiplexer:
498
689
  flags['server-multiplexer'] === 'tmux' ? 'tmux' : 'none',
499
- tmuxCommand: typeof flags['tmux-command'] === 'string' ? flags['tmux-command'].trim() : ''
690
+ tmuxCommand: typeof flags['tmux-command'] === 'string' ? flags['tmux-command'].trim() : '',
691
+ feedPostId: typeof flags['feed-post-id'] === 'string' ? flags['feed-post-id'].trim() : '',
692
+ initialQuestion: typeof flags['initial-question'] === 'string' ? flags['initial-question'].trim() : ''
500
693
  };
501
694
 
502
695
  if (normalizedCommand === 'run') {
@@ -8,11 +8,13 @@ import path from 'node:path';
8
8
 
9
9
  const OVLD_ENTRY = process.argv[1];
10
10
  const DEVICE_FILE = path.join(os.homedir(), '.ovld', 'device.json');
11
+ const DEFAULT_TMUX_COMMAND = 'tmux new-session bash {script}';
11
12
 
12
13
  /** @internal Test-only overrides for protocol/launch subprocess calls. */
13
14
  export const runnerTestHooks = {
14
15
  execFileSync: null,
15
- spawn: null
16
+ spawn: null,
17
+ platform: null
16
18
  };
17
19
 
18
20
  function printRunnerHelp(primaryCommand = 'ovld') {
@@ -131,9 +133,167 @@ export function buildLaunchArgs(launch) {
131
133
  }
132
134
  if (launch.serverMultiplexer === 'tmux') args.push('--server-multiplexer', 'tmux');
133
135
  if (launch.tmuxCommand) args.push('--tmux-command', launch.tmuxCommand);
136
+ if (launch.feedPostId) args.push('--feed-post-id', launch.feedPostId);
137
+ if (launch.initialQuestion) args.push('--initial-question', launch.initialQuestion);
134
138
  return args;
135
139
  }
136
140
 
141
+ function shellQuote(value) {
142
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
143
+ }
144
+
145
+ function appleScriptString(value) {
146
+ return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
147
+ }
148
+
149
+ function normalizeRunnerTerminalProfile(value) {
150
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
151
+ return {
152
+ terminalApp: typeof value.terminalApp === 'string' ? value.terminalApp : 'default',
153
+ terminalLaunchMode:
154
+ typeof value.terminalLaunchMode === 'string' ? value.terminalLaunchMode : 'tab',
155
+ terminalCustomHotkey:
156
+ typeof value.terminalCustomHotkey === 'string' ? value.terminalCustomHotkey : '',
157
+ customTerminalApp:
158
+ typeof value.customTerminalApp === 'string' ? value.customTerminalApp : '',
159
+ terminalTmuxHostApp:
160
+ typeof value.terminalTmuxHostApp === 'string' ? value.terminalTmuxHostApp : 'terminal',
161
+ customTerminalTmuxHostApp:
162
+ typeof value.customTerminalTmuxHostApp === 'string'
163
+ ? value.customTerminalTmuxHostApp
164
+ : '',
165
+ terminalTmuxCommand:
166
+ typeof value.terminalTmuxCommand === 'string' && value.terminalTmuxCommand.trim()
167
+ ? value.terminalTmuxCommand
168
+ : DEFAULT_TMUX_COMMAND
169
+ };
170
+ }
171
+
172
+ function buildRunnerLaunchShellCommand(args, deviceFingerprint) {
173
+ return [
174
+ `export OVERLORD_DEVICE_FINGERPRINT=${shellQuote(deviceFingerprint)}`,
175
+ `exec ${shellQuote(process.execPath)} ${[OVLD_ENTRY, ...args].map(shellQuote).join(' ')}`
176
+ ].join('\n');
177
+ }
178
+
179
+ function writeRunnerLaunchScript(launchCommand) {
180
+ const dir = path.join(os.tmpdir(), 'ovld-runner');
181
+ fs.mkdirSync(dir, { recursive: true });
182
+ const scriptPath = path.join(
183
+ dir,
184
+ `launch-${process.pid}-${Date.now()}-${crypto.randomUUID()}.sh`
185
+ );
186
+ fs.writeFileSync(scriptPath, `#!/usr/bin/env bash\nset -e\n${launchCommand}\n`, {
187
+ mode: 0o700
188
+ });
189
+ return scriptPath;
190
+ }
191
+
192
+ function buildTmuxCommand(template, scriptPath) {
193
+ const resolvedTemplate =
194
+ typeof template === 'string' && template.trim().includes('{script}')
195
+ ? template.trim()
196
+ : DEFAULT_TMUX_COMMAND;
197
+ return resolvedTemplate.replaceAll('{script}', shellQuote(scriptPath));
198
+ }
199
+
200
+ function terminalAppName(profile) {
201
+ if (profile.terminalApp === 'custom') return profile.customTerminalApp.trim();
202
+ const appMap = {
203
+ default: 'Terminal',
204
+ terminal: 'Terminal',
205
+ iterm: 'iTerm',
206
+ warp: 'Warp',
207
+ ghostty: 'Ghostty',
208
+ alacritty: 'Alacritty',
209
+ kitty: 'Kitty',
210
+ hyper: 'Hyper'
211
+ };
212
+ return appMap[profile.terminalApp] ?? '';
213
+ }
214
+
215
+ export function buildRunnerTerminalOpenCommand(
216
+ profileValue,
217
+ launchCommand,
218
+ platform = process.platform
219
+ ) {
220
+ const profile = normalizeRunnerTerminalProfile(profileValue);
221
+ if (!profile) return null;
222
+
223
+ const command =
224
+ profile.terminalApp === 'tmux'
225
+ ? buildTmuxCommand(profile.terminalTmuxCommand, writeRunnerLaunchScript(launchCommand))
226
+ : launchCommand;
227
+
228
+ if (profile.terminalApp === 'tmux' && platform !== 'darwin') {
229
+ return command;
230
+ }
231
+
232
+ if (platform !== 'darwin') return null;
233
+
234
+ const appName =
235
+ profile.terminalApp === 'tmux'
236
+ ? terminalAppName({
237
+ ...profile,
238
+ terminalApp: profile.terminalTmuxHostApp,
239
+ customTerminalApp: profile.customTerminalTmuxHostApp
240
+ })
241
+ : terminalAppName(profile);
242
+ if (!appName) return null;
243
+
244
+ if (appName === 'iTerm') {
245
+ return [
246
+ 'osascript',
247
+ '-e',
248
+ shellQuote('tell application "iTerm"'),
249
+ '-e',
250
+ shellQuote(`create window with default profile command ${appleScriptString(command)}`),
251
+ '-e',
252
+ shellQuote('activate'),
253
+ '-e',
254
+ shellQuote('end tell')
255
+ ].join(' ');
256
+ }
257
+
258
+ return [
259
+ 'osascript',
260
+ '-e',
261
+ shellQuote(
262
+ `tell application ${appleScriptString(appName)} to do script ${appleScriptString(command)}`
263
+ ),
264
+ '-e',
265
+ shellQuote(`tell application ${appleScriptString(appName)} to activate`)
266
+ ].join(' ');
267
+ }
268
+
269
+ function spawnLaunchProcess(args, claim, deviceFingerprint) {
270
+ const spawnImpl = runnerTestHooks.spawn ?? spawn;
271
+ const launchCommand = buildRunnerLaunchShellCommand(args, deviceFingerprint);
272
+ const terminalCommand = buildRunnerTerminalOpenCommand(
273
+ claim.launch?.runnerTerminalProfile,
274
+ launchCommand,
275
+ runnerTestHooks.platform ?? process.platform
276
+ );
277
+
278
+ if (!terminalCommand) {
279
+ return {
280
+ child: spawnImpl(process.execPath, [OVLD_ENTRY, ...args], {
281
+ stdio: 'inherit',
282
+ env: { ...process.env, OVERLORD_DEVICE_FINGERPRINT: deviceFingerprint }
283
+ }),
284
+ completeOnClose: false
285
+ };
286
+ }
287
+
288
+ return {
289
+ child: spawnImpl('sh', ['-lc', terminalCommand], {
290
+ stdio: 'inherit',
291
+ env: { ...process.env, OVERLORD_DEVICE_FINGERPRINT: deviceFingerprint }
292
+ }),
293
+ completeOnClose: true
294
+ };
295
+ }
296
+
137
297
  export async function launchClaimedRequest(claim, deviceFingerprint) {
138
298
  const requestId = claim.request?.id;
139
299
  if (!requestId || !claim.launch?.agent || !claim.launch?.ticketId) return false;
@@ -144,13 +304,10 @@ export async function launchClaimedRequest(claim, deviceFingerprint) {
144
304
  );
145
305
 
146
306
  await new Promise((resolve, reject) => {
147
- const spawnImpl = runnerTestHooks.spawn ?? spawn;
148
- const child = spawnImpl(process.execPath, [OVLD_ENTRY, ...args], {
149
- stdio: 'inherit',
150
- env: { ...process.env, OVERLORD_DEVICE_FINGERPRINT: deviceFingerprint }
151
- });
307
+ const { child, completeOnClose } = spawnLaunchProcess(args, claim, deviceFingerprint);
152
308
 
153
309
  child.once('spawn', () => {
310
+ if (completeOnClose) return;
154
311
  try {
155
312
  completeLaunch(requestId, deviceFingerprint);
156
313
  } catch (error) {
@@ -168,6 +325,19 @@ export async function launchClaimedRequest(claim, deviceFingerprint) {
168
325
  });
169
326
 
170
327
  child.once('close', code => {
328
+ if (completeOnClose && (!code || code === 0)) {
329
+ try {
330
+ completeLaunch(requestId, deviceFingerprint);
331
+ } catch (error) {
332
+ process.stderr.write(`[runner] failed to mark request launched: ${error}\n`);
333
+ }
334
+ } else if (completeOnClose && code) {
335
+ try {
336
+ failLaunch(requestId, deviceFingerprint, `terminal launcher exited with code ${code}`);
337
+ } catch {
338
+ // Preserve the launcher exit below.
339
+ }
340
+ }
171
341
  if (code && code !== 0) {
172
342
  process.stderr.write(`[runner] launch process exited with code ${code}\n`);
173
343
  }
@@ -485,6 +485,24 @@ function writeManifest(manifest) {
485
485
  writeJsonFile(MANIFEST_FILE, manifest);
486
486
  }
487
487
 
488
+ /**
489
+ * Quietly report whether an agent connector is installed: present in the bundle
490
+ * manifest with all of its recorded files still on disk. Unlike `doctorAgent`,
491
+ * this logs nothing and ignores version/hash staleness — a stale-but-present
492
+ * connector can still launch. Used to gate `ovld <agent>` direct launches.
493
+ */
494
+ export function isAgentConnectorInstalled(agent) {
495
+ const manifest = readManifest();
496
+ const entry = manifest[agent];
497
+ if (!entry || !Array.isArray(entry.files)) return false;
498
+ return entry.files.every(file => fs.existsSync(file));
499
+ }
500
+
501
+ /** The built-in agents whose connectors are currently installed. */
502
+ export function listInstalledConnectors() {
503
+ return supportedAgents.filter(agent => isAgentConnectorInstalled(agent));
504
+ }
505
+
488
506
  function slashCommandFiles(agent) {
489
507
  if (agent === 'claude') {
490
508
  const base = path.join(os.homedir(), '.claude', 'commands');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "overlord-cli",
3
- "version": "0.2605281741.0",
3
+ "version": "0.2605291046.0",
4
4
  "description": "Overlord CLI — launch AI agents on tickets from anywhere",
5
5
  "homepage": "https://www.ovld.ai/docs/",
6
6
  "type": "module",