@toddzheng024/dscode-bundle 0.7.22 → 0.7.24
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/THIRD_PARTY_NOTICES.md +3 -0
- package/cordis.patch.yml +2 -0
- package/package.json +4 -2
- package/plugins/dscode/index.mjs +1 -1
- package/plugins/i18n/messages.d.mts +21 -0
- package/plugins/i18n/messages.mjs +18 -6
- package/plugins/openrouter/adapter.mjs +23 -2
- package/plugins/openrouter/wire.mjs +2 -1
- package/plugins/session-bridge/index.mjs +6 -0
- package/plugins/session-bridge/tasks.mjs +227 -0
- package/plugins/session-metrics/turns.mjs +134 -0
- package/plugins/session-metrics/view.mjs +85 -33
- package/plugins/triggers/cli.mjs +439 -0
- package/plugins/triggers/config.mjs +245 -0
- package/plugins/triggers/host.mjs +138 -0
- package/plugins/triggers/index.mjs +57 -0
- package/plugins/triggers/launchd.mjs +101 -0
- package/plugins/triggers/log.mjs +104 -0
- package/plugins/triggers/options.mjs +109 -0
- package/plugins/triggers/overlay.mjs +7 -0
- package/plugins/triggers/poll.mjs +57 -0
- package/plugins/triggers/run.mjs +156 -0
- package/plugins/triggers/spool.mjs +128 -0
- package/plugins/tui-tools/workspace-discovery.mjs +11 -4
- package/presets/dscode/agent.cordis.yml +4 -3
- package/vendor/command-goal/LICENSE +21 -0
- package/vendor/command-goal/index.js +208 -0
- package/vendor/command-goal/types/index.d.ts +10 -0
- package/vendor/tui/lib/app.mjs +124 -36
- package/vendor/tui/lib/communication.mjs +262 -0
- package/vendor/tui/lib/dscode/chat.mjs +34 -0
- package/vendor/tui/lib/dscode/model-search.mjs +2 -2
- package/vendor/tui/lib/dscode/palette.mjs +100 -0
- package/vendor/tui/lib/dscode/telemetry.mjs +18 -11
- package/vendor/tui/lib/index.mjs +140 -6
- package/vendor/tui/lib/locales/en.mjs +3 -0
- package/vendor/tui/lib/locales/zh.mjs +3 -0
- package/vendor/tui/lib/mentions.mjs +1 -1
- package/vendor/tui/lib/render/status.mjs +56 -35
- package/vendor/tui/lib/render/text.mjs +33 -0
- package/vendor/tui/lib/render/usage.mjs +11 -2
- package/vendor/tui/lib/session-directory.mjs +25 -0
- package/vendor/tui/lib/skills.mjs +19 -7
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
// The portable half of `dscode trigger`: argument parsing, deciding, recording,
|
|
2
|
+
// and the read-only or file-editing commands. It imports nothing from the
|
|
3
|
+
// launcher or the harness, so the same module serves a source checkout
|
|
4
|
+
// (scripts/trigger.mjs supplies the spawning) and the published launcher (which
|
|
5
|
+
// ships this file and supplies its own).
|
|
6
|
+
//
|
|
7
|
+
// Everything that needs a process — spawning the Host, calling launchctl — is
|
|
8
|
+
// injected, which is also what makes the whole command testable.
|
|
9
|
+
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { dirname, join, resolve } from 'node:path';
|
|
12
|
+
import { parse as parseYaml } from 'yaml';
|
|
13
|
+
import { loadTriggerDefinitions, formatTrigger } from './config.mjs';
|
|
14
|
+
import { formatEvent, emitEvent, listEvents, consumeEvent } from './spool.mjs';
|
|
15
|
+
import { appendRun, formatRun, readRuns, writeRunTail } from './log.mjs';
|
|
16
|
+
import { finishTriggerRun, planTriggerRun, releaseTriggerRun } from './run.mjs';
|
|
17
|
+
import { renderPrompt } from './options.mjs';
|
|
18
|
+
import { evaluateCheck } from './poll.mjs';
|
|
19
|
+
import { agentPath, crontabLine, launchAgent } from './launchd.mjs';
|
|
20
|
+
|
|
21
|
+
export const USAGE = `Usage: dscode trigger <command> [options]
|
|
22
|
+
|
|
23
|
+
Commands:
|
|
24
|
+
run <id> Start one run now (respecting the trigger's limits)
|
|
25
|
+
fire <id> Post an event and run it: --text "..." or --event FILE
|
|
26
|
+
emit <id> Only post an event for the next drain
|
|
27
|
+
events [id] Print the events waiting in a trigger's spool
|
|
28
|
+
list List the trigger definitions and their last outcome
|
|
29
|
+
show <id> Print one definition
|
|
30
|
+
log [id] Print recorded runs, newest first (--failed for failures only)
|
|
31
|
+
new <id> Write a starter definition into the project
|
|
32
|
+
enable|disable <id> Set the definition's enabled flag in its own file
|
|
33
|
+
install <id> Schedule the trigger with launchd (or print the crontab)
|
|
34
|
+
uninstall <id> Remove the schedule
|
|
35
|
+
|
|
36
|
+
Options:
|
|
37
|
+
--event FILE Event body as JSON; "-" reads stdin
|
|
38
|
+
--text TEXT Event body text (with "fire"/"emit")
|
|
39
|
+
--event-id ID Identity of the posted event; retry with the same one
|
|
40
|
+
--workspace DIR Workspace for "new" (default: the project directory)
|
|
41
|
+
--project DIR Project whose .dsh/triggers definitions are read (default: cwd)
|
|
42
|
+
-h, --help Show this help
|
|
43
|
+
|
|
44
|
+
Exit codes are the run's: 0 completed, skipped or nothing to do; 1 failed;
|
|
45
|
+
2 round or cost cap; 3 the goal is blocked or paused; 124 timeout; 130 interrupted.
|
|
46
|
+
An orderly stop writes a result file; only a Host that died leaves none behind.`;
|
|
47
|
+
|
|
48
|
+
const COMMANDS = ['run', 'fire', 'emit', 'events', 'list', 'show', 'log', 'new', 'enable', 'disable', 'install', 'uninstall'];
|
|
49
|
+
|
|
50
|
+
/** Parse the arguments after `trigger`. */
|
|
51
|
+
export function parseTriggerArgs(argv) {
|
|
52
|
+
const options = { command: '', id: '', event: undefined, text: undefined, eventId: undefined, project: undefined, workspace: undefined, failed: false, help: false, error: undefined };
|
|
53
|
+
const words = [...argv];
|
|
54
|
+
options.command = words.shift() ?? '';
|
|
55
|
+
if (options.command === '' || options.command === '--help' || options.command === '-h') { options.help = true; return options; }
|
|
56
|
+
if (!COMMANDS.includes(options.command)) { options.error = `unknown command "${options.command}"`; return options; }
|
|
57
|
+
if (['list', 'log'].includes(options.command)) {
|
|
58
|
+
if (words.length > 0 && !words[0].startsWith('-')) options.id = words.shift();
|
|
59
|
+
} else {
|
|
60
|
+
const id = words.shift() ?? '';
|
|
61
|
+
if (id === '' || id.startsWith('-')) { options.error = `${options.command} needs a trigger id`; return options; }
|
|
62
|
+
options.id = id;
|
|
63
|
+
}
|
|
64
|
+
while (words.length > 0) {
|
|
65
|
+
const flag = words.shift();
|
|
66
|
+
const value = () => { const next = words.shift(); if (next === undefined) { options.error = `${flag} needs a value`; return undefined; } return next; };
|
|
67
|
+
if (flag === '--event') { const next = value(); if (next === undefined) return options; options.event = next; continue; }
|
|
68
|
+
if (flag === '--text') { const next = value(); if (next === undefined) return options; options.text = next; continue; }
|
|
69
|
+
if (flag === '--event-id') { const next = value(); if (next === undefined) return options; options.eventId = next; continue; }
|
|
70
|
+
if (flag === '--project') { const next = value(); if (next === undefined) return options; options.project = next; continue; }
|
|
71
|
+
if (flag === '--workspace') { const next = value(); if (next === undefined) return options; options.workspace = next; continue; }
|
|
72
|
+
if (flag === '--failed') { options.failed = true; continue; }
|
|
73
|
+
if (flag === '-h' || flag === '--help') { options.help = true; return options; }
|
|
74
|
+
options.error = `unknown option "${flag}"`;
|
|
75
|
+
return options;
|
|
76
|
+
}
|
|
77
|
+
return options;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The identity of a firing the scheduler produced. It must be stable per cadence
|
|
82
|
+
* window, not per invocation: a replayed or duplicated scheduler tick inside the
|
|
83
|
+
* same window has to be recognised as the same firing, which is what the run
|
|
84
|
+
* decision de-duplicates on. External producers pass their own id instead.
|
|
85
|
+
* @param definition - a normalized definition.
|
|
86
|
+
* @param now - the invocation time.
|
|
87
|
+
* @returns an identity string recorded with the run.
|
|
88
|
+
*/
|
|
89
|
+
export function firingIdentity(definition, now) {
|
|
90
|
+
const minute = new Date(now).toISOString().slice(0, 16);
|
|
91
|
+
switch (definition.source.kind) {
|
|
92
|
+
case 'interval': return `interval:${Math.floor(now / (definition.source.seconds * 1000))}`;
|
|
93
|
+
case 'poll': return `poll:${Math.floor(now / (definition.source.everySeconds * 1000))}`;
|
|
94
|
+
case 'calendar': return `calendar:${minute}`;
|
|
95
|
+
// A file event has no payload of its own, so changes inside one minute
|
|
96
|
+
// collapse into one run rather than firing a session per touched file.
|
|
97
|
+
case 'watch': return `watch:${minute}`;
|
|
98
|
+
default: return `manual:${new Date(now).toISOString()}`;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The state directory: the child gets it as DSH_HOME, so all three agree. */
|
|
103
|
+
export function stateHome(env = {}) {
|
|
104
|
+
const value = env.DSH_HOME ?? env.DSCODE_HOME;
|
|
105
|
+
return value === undefined ? undefined : resolve(value);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A starter definition a person can edit; the file name must equal the id. */
|
|
109
|
+
export function scaffoldTrigger(id, { workspace }) {
|
|
110
|
+
return [
|
|
111
|
+
`id: ${id}`,
|
|
112
|
+
`workspace: ${workspace}`,
|
|
113
|
+
'prompt: describe what this run should do',
|
|
114
|
+
'source: { kind: interval, seconds: 300 }',
|
|
115
|
+
'goal: { objective: describe the finished state, maxRounds: 20 }',
|
|
116
|
+
'limits: { timeoutSeconds: 1800, maxRunsPerDay: 24, minIntervalSeconds: 60 }',
|
|
117
|
+
'',
|
|
118
|
+
].join('\n');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Set the `enabled` flag in one definition file's text, preserving everything
|
|
123
|
+
* else byte for byte: these files are hand-written and reviewed in a pull
|
|
124
|
+
* request, so an edit must not reformat them.
|
|
125
|
+
* @param text - the file's current text.
|
|
126
|
+
* @param enabled - the value to set.
|
|
127
|
+
* @returns the new text.
|
|
128
|
+
*/
|
|
129
|
+
export function setEnabledFlag(text, enabled) {
|
|
130
|
+
// Matching a top-level line is only safe for a single-document file: a second
|
|
131
|
+
// document could carry its own `id:` and be edited instead of this one.
|
|
132
|
+
if (/^(?:---|\.\.\.)\s*$/mu.test(text.replace(/^---\s*\n/u, ''))) {
|
|
133
|
+
throw new Error('this file holds more than one YAML document; set "enabled" by hand');
|
|
134
|
+
}
|
|
135
|
+
const line = `enabled: ${enabled ? 'true' : 'false'}`;
|
|
136
|
+
const next = /^enabled:[^\n]*$/mu.test(text) ? text.replace(/^enabled:[^\n]*$/mu, line)
|
|
137
|
+
: (() => {
|
|
138
|
+
const idLine = /^id:[^\n]*$/mu.exec(text);
|
|
139
|
+
return idLine === null ? `${line}\n${text}`
|
|
140
|
+
: `${text.slice(0, idLine.index + idLine[0].length)}\n${line}${text.slice(idLine.index + idLine[0].length)}`;
|
|
141
|
+
})();
|
|
142
|
+
// The edit is a line match, so prove what it did: parse both sides and require
|
|
143
|
+
// every other top-level key to be untouched. A quoted multi-line scalar can put
|
|
144
|
+
// a column-0 `id:`/`enabled:` line inside a value, which the match alone cannot
|
|
145
|
+
// tell from a key — this catches it instead of corrupting the prompt.
|
|
146
|
+
if (!sameExceptEnabled(text, next, enabled)) {
|
|
147
|
+
throw new Error('refusing to edit: the change would alter more than the enabled flag; set it by hand');
|
|
148
|
+
}
|
|
149
|
+
return next;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** True when two definition texts parse to the same mapping apart from `enabled`. */
|
|
153
|
+
function sameExceptEnabled(before, after, enabled) {
|
|
154
|
+
const canonical = value => JSON.stringify(Object.fromEntries(
|
|
155
|
+
Object.entries(value ?? {}).sort(([left], [right]) => left.localeCompare(right)),
|
|
156
|
+
));
|
|
157
|
+
try {
|
|
158
|
+
return canonical({ ...parseYaml(before), enabled }) === canonical(parseYaml(after));
|
|
159
|
+
} catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Read an event body from a file, stdin, or the `--text` shorthand. */
|
|
165
|
+
async function readEventBody(options, stdin) {
|
|
166
|
+
if (options.text !== undefined) return { text: options.text, source: 'cli' };
|
|
167
|
+
if (options.event === undefined) return undefined;
|
|
168
|
+
const fromStdin = options.event === '-';
|
|
169
|
+
const raw = fromStdin ? await readStream(stdin) : readFileSync(options.event, 'utf8');
|
|
170
|
+
if (raw.trim() === '') throw new Error(`${fromStdin ? 'stdin' : options.event} is empty: an event body is required`);
|
|
171
|
+
try {
|
|
172
|
+
const body = JSON.parse(raw);
|
|
173
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('it must be a JSON object');
|
|
174
|
+
return body;
|
|
175
|
+
} catch (error) {
|
|
176
|
+
throw new Error(`${fromStdin ? 'stdin' : options.event} is not a valid event body: ${error?.message ?? error}`, { cause: error });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function readStream(stream) {
|
|
181
|
+
return new Promise((resolveRead, reject) => {
|
|
182
|
+
let text = '';
|
|
183
|
+
stream.setEncoding('utf8');
|
|
184
|
+
stream.on('data', chunk => { text += chunk; });
|
|
185
|
+
stream.on('end', () => resolveRead(text));
|
|
186
|
+
stream.on('error', reject);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Resolve one definition by id from the user and project layers. */
|
|
191
|
+
function findDefinition(home, project, id) {
|
|
192
|
+
const { definitions, problems } = loadTriggerDefinitions({ home, workspace: project });
|
|
193
|
+
const definition = definitions.find(entry => entry.id === id);
|
|
194
|
+
if (definition === undefined) {
|
|
195
|
+
const known = definitions.map(entry => entry.id).join(', ') || '(none)';
|
|
196
|
+
throw new Error(`no trigger "${id}" in ${join(home ?? '', 'triggers')} or ${join(project, '.dsh', 'triggers')}; known ids: ${known}${problems.length === 0 ? '' : ` (${problems.length} unreadable definition file(s))`}`);
|
|
197
|
+
}
|
|
198
|
+
return definition;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Map a bare exit code to a record when the Host could not report one. */
|
|
202
|
+
export function outcomeForCode(code) {
|
|
203
|
+
if (!Number.isSafeInteger(code)) return { outcome: 'failed', reason: 'interrupted' };
|
|
204
|
+
if (code === 0) return { outcome: 'completed', reason: null };
|
|
205
|
+
if (code === 2) return { outcome: 'overrun', reason: 'round_cap' };
|
|
206
|
+
if (code === 3) return { outcome: 'blocked', reason: 'goal_blocked' };
|
|
207
|
+
if (code === 124) return { outcome: 'timedout', reason: 'timeout' };
|
|
208
|
+
if (code === 130) return { outcome: 'failed', reason: 'interrupted' };
|
|
209
|
+
return { outcome: 'failed', reason: 'model_error' };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Record a decision that started no session, before any lock was taken.
|
|
214
|
+
* @returns the exit code, always 0: a skip is not a failure.
|
|
215
|
+
*/
|
|
216
|
+
function recordSkip(home, definition, { reason, eventId, now }) {
|
|
217
|
+
appendRun(home, {
|
|
218
|
+
triggerId: definition.id, runId: `skip-${now}`, startedAt: now, endedAt: now,
|
|
219
|
+
eventId, outcome: 'skipped', reason, exitCode: 0, cwd: definition.workspace,
|
|
220
|
+
});
|
|
221
|
+
return 0;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Record a run that WAS locked but started no session (a poll with nothing to
|
|
226
|
+
* do). It must go through `finishTriggerRun` with that same handle, or the lock
|
|
227
|
+
* outlives the attempt and every later drain reads `already_running`.
|
|
228
|
+
* @returns the exit code, always 0: nothing to do is not a failure.
|
|
229
|
+
*/
|
|
230
|
+
function recordLockedSkip(home, definition, handle, { reason, eventId, details, now }) {
|
|
231
|
+
const record = finishTriggerRun(home, handle, { outcome: 'skipped', reason, exitCode: 0, eventId, endedAt: now });
|
|
232
|
+
if (details !== undefined && details !== '') {
|
|
233
|
+
try { writeRunTail(home, definition.id, record.runId, details); } catch { /* the record still explains the skip */ }
|
|
234
|
+
}
|
|
235
|
+
return 0;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Run the CLI. `deps` supplies the process-facing parts; see the module header. */
|
|
239
|
+
export async function runTriggerCli(argv, deps = {}) {
|
|
240
|
+
const options = parseTriggerArgs(argv);
|
|
241
|
+
const out = text => (deps.stdout ?? process.stdout).write(text + '\n');
|
|
242
|
+
const err = text => (deps.stderr ?? process.stderr).write(text + '\n');
|
|
243
|
+
if (options.help) { out(USAGE); return 0; }
|
|
244
|
+
if (options.error !== undefined) { err(options.error); err(USAGE); return 1; }
|
|
245
|
+
|
|
246
|
+
const home = stateHome(deps.home === undefined ? process.env : { DSH_HOME: deps.home });
|
|
247
|
+
if (home === undefined) { err('dscode trigger needs a state directory: set DSH_HOME or DSCODE_HOME'); return 1; }
|
|
248
|
+
const project = resolve(options.project ?? deps.project ?? process.cwd());
|
|
249
|
+
const now = deps.now ?? Date.now();
|
|
250
|
+
const platform = deps.platform ?? process.platform;
|
|
251
|
+
// No fallback: the driver always knows its own entry point, and guessing here
|
|
252
|
+
// would write a schedule that runs the wrong program (or a module launchd
|
|
253
|
+
// cannot execute at all).
|
|
254
|
+
const dscodePath = deps.dscodePath;
|
|
255
|
+
const launchctl = deps.launchctl;
|
|
256
|
+
|
|
257
|
+
// A CLI function returns an exit code: an unknown id, an unreadable definition
|
|
258
|
+
// or a missing workspace is a user-facing message, not a thrown stack.
|
|
259
|
+
try {
|
|
260
|
+
if (options.command === 'list') {
|
|
261
|
+
const { definitions, problems } = loadTriggerDefinitions({ home, workspace: project });
|
|
262
|
+
if (definitions.length === 0 && problems.length === 0) { out('No triggers defined.'); return 0; }
|
|
263
|
+
for (const definition of definitions) out(formatTrigger(definition, { lastRun: readRuns(home, { triggerId: definition.id, limit: 1 })[0] }));
|
|
264
|
+
for (const problem of problems) err(`${problem.path}: ${problem.message}`);
|
|
265
|
+
return problems.length === 0 ? 0 : 1;
|
|
266
|
+
}
|
|
267
|
+
if (options.command === 'show') {
|
|
268
|
+
const definition = findDefinition(home, project, options.id);
|
|
269
|
+
out(formatTrigger(definition, { lastRun: readRuns(home, { triggerId: definition.id, limit: 1 })[0] }));
|
|
270
|
+
return 0;
|
|
271
|
+
}
|
|
272
|
+
if (options.command === 'log') {
|
|
273
|
+
const all = readRuns(home, options.id === '' ? {} : { triggerId: options.id });
|
|
274
|
+
const runs = options.failed ? all.filter(run => run.outcome !== 'completed' && run.outcome !== 'skipped') : all;
|
|
275
|
+
if (runs.length === 0) out(options.failed ? 'No failed runs recorded.' : 'No runs recorded.');
|
|
276
|
+
else for (const run of runs) out(`${run.triggerId} ${formatRun(run)}`);
|
|
277
|
+
return 0;
|
|
278
|
+
}
|
|
279
|
+
if (options.command === 'events') {
|
|
280
|
+
if (options.id === '') {
|
|
281
|
+
const { definitions } = loadTriggerDefinitions({ home, workspace: project });
|
|
282
|
+
let total = 0;
|
|
283
|
+
for (const definition of definitions) for (const event of listEvents(home, definition.id)) { out(`${definition.id} ${formatEvent(event)}`); total += 1; }
|
|
284
|
+
if (total === 0) out('No pending events.');
|
|
285
|
+
return 0;
|
|
286
|
+
}
|
|
287
|
+
const pending = listEvents(home, options.id);
|
|
288
|
+
if (pending.length === 0) out(`No pending events for ${options.id}.`);
|
|
289
|
+
else for (const event of pending) out(formatEvent(event));
|
|
290
|
+
return 0;
|
|
291
|
+
}
|
|
292
|
+
if (options.command === 'new') {
|
|
293
|
+
const directory = join(project, '.dsh', 'triggers');
|
|
294
|
+
const path = join(directory, `${options.id}.yml`);
|
|
295
|
+
if (existsSync(path)) { err(`${path} already exists`); return 1; }
|
|
296
|
+
const workspace = resolve(options.workspace ?? project);
|
|
297
|
+
mkdirSync(directory, { recursive: true });
|
|
298
|
+
writeFileSync(path, scaffoldTrigger(options.id, { workspace }), { mode: 0o644 });
|
|
299
|
+
out(`wrote ${path}`);
|
|
300
|
+
out('Edit the prompt, the goal objective and the source, then run it with: dscode trigger run ' + options.id);
|
|
301
|
+
return 0;
|
|
302
|
+
}
|
|
303
|
+
if (options.command === 'enable' || options.command === 'disable') {
|
|
304
|
+
const definition = findDefinition(home, project, options.id);
|
|
305
|
+
if (definition.path === '') { err(`cannot edit ${options.id}: its definition path is unknown`); return 1; }
|
|
306
|
+
const enabled = options.command === 'enable';
|
|
307
|
+
const text = readFileSync(definition.path, 'utf8');
|
|
308
|
+
const next = setEnabledFlag(text, enabled);
|
|
309
|
+
if (next !== text) writeFileSync(definition.path, next, { mode: 0o644 });
|
|
310
|
+
out(`${enabled ? 'enabled' : 'disabled'} ${options.id} in ${definition.path}`);
|
|
311
|
+
return 0;
|
|
312
|
+
}
|
|
313
|
+
if (options.command === 'install' || options.command === 'uninstall') {
|
|
314
|
+
const definition = findDefinition(home, project, options.id);
|
|
315
|
+
const path = agentPath(home, definition.id);
|
|
316
|
+
if (options.command === 'uninstall') {
|
|
317
|
+
if (launchctl !== undefined && platform === 'darwin') await launchctl(['bootout', `gui/${process.getuid?.() ?? ''}/${'ai.dscode.trigger.' + definition.id}`], { ignoreFailure: true });
|
|
318
|
+
rmSync(path, { force: true });
|
|
319
|
+
out(`removed the schedule for ${options.id} (${path})`);
|
|
320
|
+
return 0;
|
|
321
|
+
}
|
|
322
|
+
if (dscodePath === undefined) {
|
|
323
|
+
err(`cannot install ${options.id}: this build does not know its own dscode program path`);
|
|
324
|
+
return 1;
|
|
325
|
+
}
|
|
326
|
+
// launchd executes the program directly: it must be a real, executable file.
|
|
327
|
+
let runnable = existsSync(dscodePath);
|
|
328
|
+
if (runnable) {
|
|
329
|
+
try { runnable = (statSync(dscodePath).mode & 0o111) !== 0; } catch { runnable = false; }
|
|
330
|
+
}
|
|
331
|
+
if (!runnable) {
|
|
332
|
+
err(`cannot install ${options.id}: ${dscodePath} is not an executable file, and launchd runs no shell profile`);
|
|
333
|
+
return 1;
|
|
334
|
+
}
|
|
335
|
+
const plist = launchAgent(definition, { home, dscodePath, project });
|
|
336
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
337
|
+
writeFileSync(path, plist, { mode: 0o600 });
|
|
338
|
+
out(`wrote ${path}`);
|
|
339
|
+
if (platform !== 'darwin' || launchctl === undefined) {
|
|
340
|
+
out('launchd is not available here; add this line to crontab instead:');
|
|
341
|
+
} else {
|
|
342
|
+
await launchctl(['bootout', `gui/${process.getuid?.() ?? ''}/ai.dscode.trigger.${definition.id}`], { ignoreFailure: true });
|
|
343
|
+
await launchctl(['bootstrap', `gui/${process.getuid?.() ?? ''}`, path]);
|
|
344
|
+
out(`loaded ${'ai.dscode.trigger.' + definition.id} into launchd; the crontab equivalent is:`);
|
|
345
|
+
}
|
|
346
|
+
out(` ${crontabLine(definition, { dscodePath, project })}`);
|
|
347
|
+
return 0;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const definition = findDefinition(home, project, options.id);
|
|
351
|
+
if (options.command === 'emit') {
|
|
352
|
+
const body = (await readEventBody(options, deps.stdin ?? process.stdin)) ?? {};
|
|
353
|
+
const event = emitEvent(home, definition.id, { source: 'cli', ...body }, { eventId: options.eventId ?? deps.eventId ?? `manual-${new Date(now).toISOString()}`, now });
|
|
354
|
+
out(`posted ${event.eventId} to ${definition.id} (${listEvents(home, definition.id).length} pending)`);
|
|
355
|
+
return 0;
|
|
356
|
+
}
|
|
357
|
+
let fired;
|
|
358
|
+
if (options.command === 'fire') {
|
|
359
|
+
const body = (await readEventBody(options, deps.stdin ?? process.stdin)) ?? {};
|
|
360
|
+
fired = emitEvent(home, definition.id, { source: 'cli', ...body }, { eventId: options.eventId ?? deps.eventId ?? `fire-${new Date(now).toISOString()}`, now });
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const pending = listEvents(home, definition.id);
|
|
364
|
+
const wanted = options.eventId ?? deps.eventId;
|
|
365
|
+
const matching = wanted === undefined ? undefined : pending.find(event => event.eventId === wanted);
|
|
366
|
+
if (wanted !== undefined && fired === undefined && matching === undefined) {
|
|
367
|
+
// Naming an identity that is not waiting would otherwise run the trigger
|
|
368
|
+
// with no event payload at all, which is never what the caller meant.
|
|
369
|
+
err(`no pending event "${wanted}" for ${definition.id}; post it with "emit", or omit --event-id`);
|
|
370
|
+
return 1;
|
|
371
|
+
}
|
|
372
|
+
const chosen = fired ?? matching ?? (wanted === undefined ? pending[0] : undefined);
|
|
373
|
+
const eventId = chosen?.eventId ?? wanted ?? firingIdentity(definition, now);
|
|
374
|
+
const plan = planTriggerRun(definition, { home, now, eventId });
|
|
375
|
+
if (plan.action === 'skip') {
|
|
376
|
+
err(`skipped ${definition.id}: ${plan.reason}`);
|
|
377
|
+
return recordSkip(home, definition, { reason: plan.reason, eventId, now });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// A poll source only runs when its predicate says there is work; without a
|
|
381
|
+
// resident listener this scheduled invocation IS the poll.
|
|
382
|
+
if (definition.source.kind === 'poll') {
|
|
383
|
+
const check = await evaluateCheck(definition.source.check, { cwd: definition.workspace, timeoutMs: 60000, ...(deps.spawnCheck === undefined ? {} : { spawn: deps.spawnCheck }) });
|
|
384
|
+
if (!check.matched) {
|
|
385
|
+
err(`nothing to do for ${definition.id} (${check.code === null ? 'the check did not finish' : `check exited ${check.code}`})`);
|
|
386
|
+
return recordLockedSkip(home, definition, plan.handle, { reason: 'no_match', eventId, details: check.output, now });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const spec = {
|
|
391
|
+
triggerId: definition.id,
|
|
392
|
+
runId: plan.handle.runId,
|
|
393
|
+
workspace: definition.workspace,
|
|
394
|
+
prompt: renderPrompt(definition.prompt, chosen),
|
|
395
|
+
preset: definition.preset,
|
|
396
|
+
permission: definition.permission,
|
|
397
|
+
...(definition.model === undefined ? {} : { model: definition.model }),
|
|
398
|
+
...(definition.effort === undefined ? {} : { effort: definition.effort }),
|
|
399
|
+
goal: { objective: definition.goal.objective, maxRounds: definition.goal.maxRounds },
|
|
400
|
+
limits: definition.limits,
|
|
401
|
+
eventId,
|
|
402
|
+
};
|
|
403
|
+
let code;
|
|
404
|
+
let result;
|
|
405
|
+
try {
|
|
406
|
+
({ code, result } = await (deps.spawnRun ?? missingSpawn)({ spec, home, cwd: definition.workspace }));
|
|
407
|
+
} catch (error) {
|
|
408
|
+
// The Host never ran, so the event is still pending and the lock must not
|
|
409
|
+
// outlive the attempt: a later drain should be free to try it again.
|
|
410
|
+
releaseTriggerRun(home, plan.handle);
|
|
411
|
+
throw error;
|
|
412
|
+
}
|
|
413
|
+
// Consumed only once the Host has actually run: a failure before that leaves
|
|
414
|
+
// the event pending rather than dropping it.
|
|
415
|
+
if (chosen !== undefined) consumeEvent(home, definition.id, chosen.eventId);
|
|
416
|
+
const fallback = outcomeForCode(code);
|
|
417
|
+
const record = finishTriggerRun(home, plan.handle, {
|
|
418
|
+
outcome: result?.outcome ?? fallback.outcome,
|
|
419
|
+
reason: result?.reason ?? fallback.reason,
|
|
420
|
+
// A Host killed by a signal reports no code; the record still needs one.
|
|
421
|
+
exitCode: Number.isSafeInteger(result?.exitCode) ? result.exitCode : (Number.isSafeInteger(code) ? code : 130),
|
|
422
|
+
sessionId: result?.sessionId ?? null,
|
|
423
|
+
cost: result?.cost ?? null,
|
|
424
|
+
rounds: result?.rounds ?? null,
|
|
425
|
+
eventId,
|
|
426
|
+
endedAt: Date.now(),
|
|
427
|
+
});
|
|
428
|
+
out(`${definition.id} ${formatRun(record)}${record.sessionId === null ? '' : ` · ${record.sessionId}`}`);
|
|
429
|
+
return record.exitCode;
|
|
430
|
+
} catch (error) {
|
|
431
|
+
err(error?.message ?? String(error));
|
|
432
|
+
return 1;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** The launcher and the source driver both must inject a spawn. */
|
|
437
|
+
function missingSpawn() {
|
|
438
|
+
throw new Error('this build has no way to start a run: inject spawnRun');
|
|
439
|
+
}
|