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 +5 -0
- package/bin/_cli/direct-launch.mjs +370 -0
- package/bin/_cli/index.mjs +17 -1
- package/bin/_cli/launcher.mjs +222 -29
- package/bin/_cli/runner.mjs +176 -6
- package/bin/_cli/setup.mjs +18 -0
- package/package.json +1 -1
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
|
+
}
|
package/bin/_cli/index.mjs
CHANGED
|
@@ -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);
|
package/bin/_cli/launcher.mjs
CHANGED
|
@@ -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',
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
474
|
+
execAgentBinary('codex', args, { stdio: 'inherit', env: childEnv }, options.preCommand);
|
|
389
475
|
} else {
|
|
390
|
-
|
|
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
|
-
|
|
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
|
-
|
|
497
|
+
execAgentBinary('opencode', args, { stdio: 'inherit', env: childEnv }, options.preCommand);
|
|
402
498
|
} else {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
513
|
+
execAgentBinary('pi', args, { stdio: 'inherit', env: childEnv }, options.preCommand);
|
|
416
514
|
} else {
|
|
417
|
-
|
|
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(
|
|
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
|
-
|
|
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') {
|
package/bin/_cli/runner.mjs
CHANGED
|
@@ -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
|
|
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
|
}
|
package/bin/_cli/setup.mjs
CHANGED
|
@@ -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');
|