@toddzheng024/dscode-bundle 0.7.23 → 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/openrouter/adapter.mjs +23 -2
- package/plugins/openrouter/wire.mjs +2 -1
- 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/presets/dscode/agent.cordis.yml +1 -1
- 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
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Trigger definitions: the configuration half of the trigger mechanism
|
|
2
|
+
// (docs/triggers-design.md). A definition says what produces an event, which
|
|
3
|
+
// folder the fresh session binds to, what it is asked to do, and the limits that
|
|
4
|
+
// keep an unattended run bounded. Nothing here starts a run: the ingress, the
|
|
5
|
+
// runner and the source installers are separate work, and `/triggers` reads this
|
|
6
|
+
// layer only.
|
|
7
|
+
//
|
|
8
|
+
// One bad file never hides the rest: discovery returns the definitions it could
|
|
9
|
+
// read plus a per-file problem list, so a listing always shows what is usable.
|
|
10
|
+
|
|
11
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
12
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
13
|
+
import { parse as parseYaml } from 'yaml';
|
|
14
|
+
|
|
15
|
+
/** Source kinds this version accepts; each has its own required fields. */
|
|
16
|
+
export const SOURCE_KINDS = Object.freeze(['interval', 'calendar', 'watch', 'poll', 'external']);
|
|
17
|
+
|
|
18
|
+
/** The only overlap policy: a trigger whose run is still going is skipped. */
|
|
19
|
+
export const OVERLAP = 'skip';
|
|
20
|
+
|
|
21
|
+
/** The only notification policy for now: append to the run log. */
|
|
22
|
+
export const NOTIFY = 'log';
|
|
23
|
+
|
|
24
|
+
/** Defaults a definition may omit. */
|
|
25
|
+
export const DEFAULTS = Object.freeze({
|
|
26
|
+
enabled: true,
|
|
27
|
+
preset: 'dscode',
|
|
28
|
+
permission: 'workspace-write',
|
|
29
|
+
maxGoalRounds: 20,
|
|
30
|
+
timeoutSeconds: 1800,
|
|
31
|
+
maxRunsPerDay: 24,
|
|
32
|
+
minIntervalSeconds: 60,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/** Permission presets an unattended run may use; `ask` can never complete. */
|
|
36
|
+
const UNATTENDED_PERMISSIONS = Object.freeze(['auto', 'workspace-write', 'read-only', 'danger-full-access']);
|
|
37
|
+
|
|
38
|
+
const ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/u;
|
|
39
|
+
|
|
40
|
+
class TriggerConfigError extends Error {}
|
|
41
|
+
|
|
42
|
+
const fail = message => { throw new TriggerConfigError(message); };
|
|
43
|
+
|
|
44
|
+
const isPlainObject = value => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
45
|
+
|
|
46
|
+
function positiveNumber(value, label) {
|
|
47
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) fail(`${label} must be a positive number`);
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function positiveInteger(value, label) {
|
|
52
|
+
if (!Number.isSafeInteger(value) || value <= 0) fail(`${label} must be a positive whole number`);
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function nonEmptyString(value, label) {
|
|
57
|
+
if (typeof value !== 'string' || value.trim() === '') fail(`${label} must be a non-empty string`);
|
|
58
|
+
return value.trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Reject a key this version does not read, so a typo cannot fall back silently. */
|
|
62
|
+
function knownKeys(raw, allowed, label) {
|
|
63
|
+
const unknown = Object.keys(raw).filter(key => !allowed.includes(key));
|
|
64
|
+
if (unknown.length > 0) fail(`${label} has no such field: ${unknown.join(', ')} (accepted: ${allowed.join(', ')})`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Validate and normalize one source block. */
|
|
68
|
+
function normalizeSource(raw) {
|
|
69
|
+
if (!isPlainObject(raw)) fail('source must be an object');
|
|
70
|
+
const kind = nonEmptyString(raw.kind, 'source.kind');
|
|
71
|
+
if (!SOURCE_KINDS.includes(kind)) fail(`source.kind must be one of: ${SOURCE_KINDS.join(', ')}`);
|
|
72
|
+
const fields = kind === 'interval' ? ['kind', 'seconds']
|
|
73
|
+
: kind === 'calendar' ? ['kind', 'cron']
|
|
74
|
+
: kind === 'watch' ? ['kind', 'paths']
|
|
75
|
+
: kind === 'poll' ? ['kind', 'everySeconds', 'check']
|
|
76
|
+
: ['kind'];
|
|
77
|
+
knownKeys(raw, fields, `source (${kind})`);
|
|
78
|
+
if (kind === 'interval') return { kind, seconds: positiveInteger(raw.seconds, 'source.seconds') };
|
|
79
|
+
if (kind === 'calendar') {
|
|
80
|
+
const cron = nonEmptyString(raw.cron, 'source.cron');
|
|
81
|
+
// Five fields is the only shape the installer can translate to launchd.
|
|
82
|
+
if (cron.split(/\s+/u).length !== 5) fail('source.cron must have five fields (minute hour day month weekday)');
|
|
83
|
+
return { kind, cron };
|
|
84
|
+
}
|
|
85
|
+
if (kind === 'watch') {
|
|
86
|
+
if (!Array.isArray(raw.paths) || raw.paths.length === 0) fail('source.paths must be a non-empty list of paths');
|
|
87
|
+
return { kind, paths: raw.paths.map((path, index) => nonEmptyString(path, `source.paths[${index}]`)) };
|
|
88
|
+
}
|
|
89
|
+
if (kind === 'poll') {
|
|
90
|
+
return { kind, everySeconds: positiveInteger(raw.everySeconds, 'source.everySeconds'), check: nonEmptyString(raw.check, 'source.check') };
|
|
91
|
+
}
|
|
92
|
+
return { kind };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Validate one raw definition into the normalized shape.
|
|
97
|
+
* @param raw - the parsed YAML/JSON body.
|
|
98
|
+
* @param meta - where it came from: `{ origin, path }`.
|
|
99
|
+
* @returns the normalized definition.
|
|
100
|
+
* @throws {TriggerConfigError} with a user-facing message.
|
|
101
|
+
*/
|
|
102
|
+
export function normalizeTrigger(raw, { origin, path } = {}) {
|
|
103
|
+
if (!isPlainObject(raw)) fail('a trigger definition must be a mapping');
|
|
104
|
+
knownKeys(raw, ['id', 'enabled', 'source', 'workspace', 'prompt', 'preset', 'permission', 'model', 'effort', 'goal', 'limits', 'overlap', 'notify'], 'the definition');
|
|
105
|
+
const id = nonEmptyString(raw.id, 'id');
|
|
106
|
+
if (!ID_PATTERN.test(id)) fail('id must start with a letter or digit and use only a-z, 0-9, dot, underscore or dash');
|
|
107
|
+
const workspace = nonEmptyString(raw.workspace, 'workspace');
|
|
108
|
+
if (!isAbsolute(workspace)) fail('workspace must be an absolute path: a triggered session binds to it and cannot change folders');
|
|
109
|
+
const prompt = nonEmptyString(raw.prompt, 'prompt');
|
|
110
|
+
const enabled = raw.enabled === undefined ? DEFAULTS.enabled : raw.enabled;
|
|
111
|
+
if (typeof enabled !== 'boolean') fail('enabled must be true or false');
|
|
112
|
+
|
|
113
|
+
const permission = raw.permission === undefined ? DEFAULTS.permission : nonEmptyString(raw.permission, 'permission');
|
|
114
|
+
if (!UNATTENDED_PERMISSIONS.includes(permission)) {
|
|
115
|
+
fail(`permission must be one of: ${UNATTENDED_PERMISSIONS.join(', ')} — an unattended run cannot ask for approval`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const goal = raw.goal;
|
|
119
|
+
if (!isPlainObject(goal)) fail('goal must be an object with the objective to finish');
|
|
120
|
+
knownKeys(goal, ['objective', 'maxRounds'], 'goal');
|
|
121
|
+
const objective = nonEmptyString(goal.objective, 'goal.objective');
|
|
122
|
+
const maxRounds = goal.maxRounds === undefined ? DEFAULTS.maxGoalRounds : positiveInteger(goal.maxRounds, 'goal.maxRounds');
|
|
123
|
+
|
|
124
|
+
const limits = raw.limits === undefined ? {} : raw.limits;
|
|
125
|
+
if (!isPlainObject(limits)) fail('limits must be an object');
|
|
126
|
+
knownKeys(limits, ['timeoutSeconds', 'maxRunsPerDay', 'minIntervalSeconds', 'maxCostUsd'], 'limits');
|
|
127
|
+
const timeoutSeconds = limits.timeoutSeconds === undefined ? DEFAULTS.timeoutSeconds : positiveInteger(limits.timeoutSeconds, 'limits.timeoutSeconds');
|
|
128
|
+
const maxRunsPerDay = limits.maxRunsPerDay === undefined ? DEFAULTS.maxRunsPerDay : positiveInteger(limits.maxRunsPerDay, 'limits.maxRunsPerDay');
|
|
129
|
+
const minIntervalSeconds = limits.minIntervalSeconds === undefined ? DEFAULTS.minIntervalSeconds : positiveInteger(limits.minIntervalSeconds, 'limits.minIntervalSeconds');
|
|
130
|
+
const maxCostUsd = limits.maxCostUsd === undefined ? undefined : positiveNumber(limits.maxCostUsd, 'limits.maxCostUsd');
|
|
131
|
+
|
|
132
|
+
if (raw.overlap !== undefined && raw.overlap !== OVERLAP) fail(`overlap must be "${OVERLAP}"`);
|
|
133
|
+
if (raw.notify !== undefined && raw.notify !== NOTIFY) fail(`notify must be "${NOTIFY}"`);
|
|
134
|
+
|
|
135
|
+
const source = normalizeSource(raw.source);
|
|
136
|
+
return {
|
|
137
|
+
id, enabled, source, workspace, prompt,
|
|
138
|
+
preset: raw.preset === undefined ? DEFAULTS.preset : nonEmptyString(raw.preset, 'preset'),
|
|
139
|
+
permission,
|
|
140
|
+
...(raw.model === undefined ? {} : { model: nonEmptyString(raw.model, 'model') }),
|
|
141
|
+
...(raw.effort === undefined ? {} : { effort: nonEmptyString(raw.effort, 'effort') }),
|
|
142
|
+
goal: { objective, maxRounds },
|
|
143
|
+
limits: { timeoutSeconds, maxRunsPerDay, minIntervalSeconds, ...(maxCostUsd === undefined ? {} : { maxCostUsd }) },
|
|
144
|
+
overlap: OVERLAP,
|
|
145
|
+
notify: NOTIFY,
|
|
146
|
+
origin: origin ?? 'user',
|
|
147
|
+
path: path ?? '',
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Parse one file body: JSON or YAML by extension, YAML as the general form. */
|
|
152
|
+
function parseBody(text, path) {
|
|
153
|
+
if (/\.json$/iu.test(path)) {
|
|
154
|
+
try { return JSON.parse(text); } catch (error) { fail(`not valid JSON: ${error.message}`); }
|
|
155
|
+
}
|
|
156
|
+
try { return parseYaml(text); } catch (error) { fail(`not valid YAML: ${error.message}`); }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Read one directory of definitions, returning definitions and problems. */
|
|
160
|
+
function readDirectory(directory, origin) {
|
|
161
|
+
const definitions = [];
|
|
162
|
+
const problems = [];
|
|
163
|
+
let entries;
|
|
164
|
+
try {
|
|
165
|
+
entries = readdirSync(directory);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
// Absent is normal (most machines and projects have no triggers); anything
|
|
168
|
+
// else must be reported, or a permission problem reads as "none defined".
|
|
169
|
+
if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') {
|
|
170
|
+
problems.push({ path: directory, message: `cannot read the trigger directory: ${error?.code ?? error?.message ?? error}` });
|
|
171
|
+
}
|
|
172
|
+
return { definitions, problems };
|
|
173
|
+
}
|
|
174
|
+
for (const entry of entries.sort()) {
|
|
175
|
+
if (!/\.(ya?ml|json)$/iu.test(entry)) continue;
|
|
176
|
+
const path = join(directory, entry);
|
|
177
|
+
try {
|
|
178
|
+
const normalized = normalizeTrigger(parseBody(readFileSync(path, 'utf8'), path), { origin, path });
|
|
179
|
+
if (normalized.id !== entry.replace(/\.(ya?ml|json)$/iu, '')) {
|
|
180
|
+
fail(`id "${normalized.id}" must match the file name ("${entry}")`);
|
|
181
|
+
}
|
|
182
|
+
definitions.push(normalized);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
problems.push({ path, message: error instanceof TriggerConfigError ? error.message : String(error?.message ?? error) });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { definitions, problems };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Discover trigger definitions. User-level files live under `<state>/triggers/`;
|
|
192
|
+
* project files under `<workspace>/.dsh/triggers/` and win on an id collision, so
|
|
193
|
+
* a repository can pin its own definition and have it reviewed in a pull request.
|
|
194
|
+
* @param options - `{ home, workspace }`; either may be absent.
|
|
195
|
+
* @returns `{ definitions, problems }`, definitions sorted by id.
|
|
196
|
+
*/
|
|
197
|
+
export function loadTriggerDefinitions({ home, workspace } = {}) {
|
|
198
|
+
const user = home === undefined ? { definitions: [], problems: [] } : readDirectory(join(home, 'triggers'), 'user');
|
|
199
|
+
const project = workspace === undefined ? { definitions: [], problems: [] } : readDirectory(join(resolve(workspace), '.dsh', 'triggers'), 'project');
|
|
200
|
+
const byId = new Map();
|
|
201
|
+
for (const definition of user.definitions) byId.set(definition.id, definition);
|
|
202
|
+
for (const definition of project.definitions) byId.set(definition.id, { ...definition, overrides: byId.has(definition.id) });
|
|
203
|
+
return {
|
|
204
|
+
definitions: [...byId.values()].sort((left, right) => left.id.localeCompare(right.id)),
|
|
205
|
+
problems: [...user.problems, ...project.problems],
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The newest run in one line; `formatRun` in log.mjs owns the vocabulary. */
|
|
210
|
+
function formatLastRun(record) {
|
|
211
|
+
const when = new Date(record.startedAt).toISOString().replace('T', ' ').slice(0, 19);
|
|
212
|
+
const reason = record.reason === null || record.reason === undefined ? '' : ` (${record.reason})`;
|
|
213
|
+
return `${when} ${record.outcome}${reason} · exit ${record.exitCode}`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** One line, bounded: a listing row must never be forged out of a value. */
|
|
217
|
+
const inline = (text, limit = 96) => {
|
|
218
|
+
const folded = String(text).replace(/\s+/gu, ' ').trim();
|
|
219
|
+
return folded.length > limit ? `${folded.slice(0, limit - 1)}…` : folded;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* One definition as the text `/triggers` prints.
|
|
224
|
+
* @param definition - a normalized definition.
|
|
225
|
+
* @param options - `{ lastRun }`: the newest run record, when the log has one.
|
|
226
|
+
* @returns the aligned block.
|
|
227
|
+
*/
|
|
228
|
+
export function formatTrigger(definition, { lastRun } = {}) {
|
|
229
|
+
const source = definition.source.kind === 'interval' ? `every ${definition.source.seconds}s`
|
|
230
|
+
: definition.source.kind === 'calendar' ? `cron ${definition.source.cron}`
|
|
231
|
+
: definition.source.kind === 'watch' ? `watch ${definition.source.paths.join(', ')}`
|
|
232
|
+
: definition.source.kind === 'poll' ? `poll ${definition.source.everySeconds}s: ${definition.source.check}`
|
|
233
|
+
: 'external (emit only)';
|
|
234
|
+
return [
|
|
235
|
+
`${definition.enabled ? 'on ' : 'off'} ${definition.id}${definition.overrides ? ' (project overrides user)' : ''}`,
|
|
236
|
+
` source: ${source}`,
|
|
237
|
+
` workspace: ${definition.workspace}`,
|
|
238
|
+
` goal: ${inline(definition.goal.objective)} (max ${definition.goal.maxRounds} rounds)`,
|
|
239
|
+
` prompt: ${inline(definition.prompt)}`,
|
|
240
|
+
` limits: ${definition.limits.timeoutSeconds}s, ${definition.limits.maxRunsPerDay}/day${definition.limits.maxCostUsd === undefined ? '' : `, $${definition.limits.maxCostUsd}`}`,
|
|
241
|
+
` run as: ${definition.preset}/${definition.permission}${definition.model === undefined ? '' : ` · ${definition.model}`}`,
|
|
242
|
+
` file: ${definition.path === '' ? '(unknown)' : definition.path}`,
|
|
243
|
+
...(lastRun === undefined ? [] : [` last: ${formatLastRun(lastRun)}`]),
|
|
244
|
+
].join('\n');
|
|
245
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Host-side half of one trigger run: the clean session an event starts. Unlike
|
|
2
|
+
// `dscode exec`, which runs exactly one turn, this one keeps going while the
|
|
3
|
+
// goal is active — the round driver supplies the continuation — and stops on the
|
|
4
|
+
// goal's own end, a cap, the timeout, or a needed approval.
|
|
5
|
+
//
|
|
6
|
+
// It owns the agent, the goal and the transcript; the parent process owns the
|
|
7
|
+
// lock, the limits and the run record. The two swap files: `DSCODE_TRIGGER_OPTIONS`
|
|
8
|
+
// in, `<options>.result.json` out.
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
11
|
+
import { decideStop } from './run.mjs';
|
|
12
|
+
import { readRunSpec, writeRunResult } from './options.mjs';
|
|
13
|
+
import { writeRunTail } from './log.mjs';
|
|
14
|
+
import { sessionSpend } from '../session-metrics/view.mjs';
|
|
15
|
+
import { triggerOverlay } from './overlay.mjs';
|
|
16
|
+
|
|
17
|
+
export { triggerOverlay };
|
|
18
|
+
|
|
19
|
+
export const name = 'dscode-trigger-host';
|
|
20
|
+
export const inject = ['agents', 'agentPresets', 'agentDefaultModel', 'permissionPresets', 'llm', 'goals', 'appExit'];
|
|
21
|
+
|
|
22
|
+
export function apply(ctx) {
|
|
23
|
+
void run(ctx).catch(error => { process.stderr.write(`dscode trigger run: ${error.message}\n`); ctx.get('appExit')(1); });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function splitRoute(value) {
|
|
27
|
+
const at = value.indexOf('/');
|
|
28
|
+
if (at <= 0 || at === value.length - 1) throw new Error(`model expects provider/model, got ${value}`);
|
|
29
|
+
return [value.slice(0, at), value.slice(at + 1)];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function run(ctx) {
|
|
33
|
+
await ctx.get('loader').await();
|
|
34
|
+
const optionsPath = process.env.DSCODE_TRIGGER_OPTIONS;
|
|
35
|
+
if (!optionsPath) throw new Error('DSCODE_TRIGGER_OPTIONS is missing');
|
|
36
|
+
const spec = readRunSpec(optionsPath);
|
|
37
|
+
const resultPath = `${optionsPath}.result.json`;
|
|
38
|
+
|
|
39
|
+
const selection = ctx.agentDefaultModel.currentSelection();
|
|
40
|
+
const [provider, model] = spec.model ? splitRoute(spec.model) : [selection.provider, selection.model];
|
|
41
|
+
const effort = spec.effort ?? selection.reasoningEffort;
|
|
42
|
+
const agentOptions = { provider, model, ...(effort ? { reasoningEffort: effort } : {}) };
|
|
43
|
+
const setup = async agentCtx => { await ctx.agentPresets.mount(agentCtx, spec.preset ?? 'dscode'); };
|
|
44
|
+
|
|
45
|
+
const handle = await ctx.agents.create({
|
|
46
|
+
sessionId: randomUUID(),
|
|
47
|
+
meta: { cwd: spec.workspace, agentPreset: spec.preset ?? 'dscode' },
|
|
48
|
+
agentOptions,
|
|
49
|
+
setup,
|
|
50
|
+
});
|
|
51
|
+
const agent = handle.agent;
|
|
52
|
+
const session = agent.session;
|
|
53
|
+
if (spec.permission) {
|
|
54
|
+
ctx.permissionPresets.resolve(spec.permission);
|
|
55
|
+
ctx.permissionPresets.set(session, spec.permission);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The goal is created through the SERVICE, not the `create_goal` tool: the tool
|
|
59
|
+
// requires a direct human turn, and an unattended run has none. The service is
|
|
60
|
+
// the same path the human-facing /goal command uses.
|
|
61
|
+
ctx.goals.create(agent, { objective: spec.goal.objective, maxGoalRounds: spec.goal.maxRounds });
|
|
62
|
+
|
|
63
|
+
let finished = false;
|
|
64
|
+
let approvalsRejected = false;
|
|
65
|
+
let lastText = '';
|
|
66
|
+
const limiter = spec.limits ?? {};
|
|
67
|
+
|
|
68
|
+
const finish = (result, tail) => {
|
|
69
|
+
if (finished) return;
|
|
70
|
+
finished = true;
|
|
71
|
+
if (tail !== undefined && tail.trim() !== '') {
|
|
72
|
+
try { writeRunTail(process.env.DSH_HOME ?? '.', spec.triggerId, spec.runId, tail); } catch { /* the record still explains the run */ }
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
writeRunResult(resultPath, { ...result, sessionId: session.id });
|
|
76
|
+
} catch (error) {
|
|
77
|
+
process.stderr.write(`dscode trigger run: could not write the result file: ${error.message}\n`);
|
|
78
|
+
}
|
|
79
|
+
ctx.get('appExit')(result.exitCode);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Read durable goal state plus recorded spend, and stop if the run is over. */
|
|
83
|
+
const evaluate = () => {
|
|
84
|
+
if (finished) return;
|
|
85
|
+
let goal;
|
|
86
|
+
try { goal = ctx.goals.get(agent); } catch { goal = undefined; }
|
|
87
|
+
let costUsd;
|
|
88
|
+
try { costUsd = sessionSpend(session.id).cost; } catch { costUsd = undefined; }
|
|
89
|
+
const decision = decideStop({ goal, costUsd, limits: limiter, approvalsRejected });
|
|
90
|
+
if (!decision.stop) return;
|
|
91
|
+
finish({
|
|
92
|
+
outcome: decision.outcome,
|
|
93
|
+
reason: decision.reason ?? null,
|
|
94
|
+
exitCode: decision.exitCode,
|
|
95
|
+
cost: Number.isFinite(costUsd) ? costUsd : null,
|
|
96
|
+
rounds: Number.isFinite(goal?.roundsStarted) ? goal.roundsStarted : null,
|
|
97
|
+
}, lastText);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
ctx.on('session/event', (subject, event) => {
|
|
101
|
+
if (subject.id !== session.id) return;
|
|
102
|
+
if (event.type === 'assistant/message') {
|
|
103
|
+
const text = (event.data.message?.content ?? []).filter(block => block.type === 'text').map(block => block.text).join('');
|
|
104
|
+
if (text !== '') lastText = text;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// The goal round driver continues after a turn; only the goal's own state
|
|
108
|
+
// (or a cap) ends the run, so this is a check, not a completion signal.
|
|
109
|
+
if (event.type === 'turn/end') evaluate();
|
|
110
|
+
});
|
|
111
|
+
ctx.on('session/disposed', source => {
|
|
112
|
+
if (source.id !== session.id) return;
|
|
113
|
+
finish({ outcome: 'failed', reason: 'model_error', exitCode: 1, cost: null, rounds: null }, lastText);
|
|
114
|
+
});
|
|
115
|
+
// No human is present: an approval request is refused, and the run is marked
|
|
116
|
+
// as having needed one so the operator sees it in the record.
|
|
117
|
+
ctx.on('approval/request', (request, next) => {
|
|
118
|
+
if (request.agent?.id !== agent.id) return next();
|
|
119
|
+
approvalsRejected = true;
|
|
120
|
+
process.stderr.write(`approval needed for ${request.toolName}: rejected (a triggered run has no human to ask)\n`);
|
|
121
|
+
return 'rejected';
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (Number.isFinite(limiter.timeoutSeconds) && limiter.timeoutSeconds > 0) {
|
|
125
|
+
setTimeout(() => {
|
|
126
|
+
const goal = (() => { try { return ctx.goals.get(agent); } catch { return undefined; } })();
|
|
127
|
+
finish({
|
|
128
|
+
outcome: 'timedout',
|
|
129
|
+
reason: approvalsRejected ? 'approval_required' : 'timeout',
|
|
130
|
+
exitCode: 124,
|
|
131
|
+
cost: (() => { try { return sessionSpend(session.id).cost; } catch { return null; } })(),
|
|
132
|
+
rounds: Number.isFinite(goal?.roundsStarted) ? goal.roundsStarted : null,
|
|
133
|
+
}, lastText);
|
|
134
|
+
}, limiter.timeoutSeconds * 1000).unref();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
agent.followup(createUserMessage({ content: [{ type: 'text', text: spec.prompt }], source: { kind: 'user' } }));
|
|
138
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The read-only half of the trigger mechanism: `/triggers` lists the definitions
|
|
2
|
+
// this machine and this project carry, and `/triggers show <id>` prints one.
|
|
3
|
+
// Nothing here starts a run — the ingress, the runner and the source installers
|
|
4
|
+
// are separate work (docs/triggers-design.md).
|
|
5
|
+
import { loadTriggerDefinitions, formatTrigger } from './config.mjs';
|
|
6
|
+
import { formatRun, readRuns } from './log.mjs';
|
|
7
|
+
import { formatEvent, listEvents } from './spool.mjs';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
export const name = 'dscode-triggers';
|
|
12
|
+
export const inject = ['commands'];
|
|
13
|
+
|
|
14
|
+
/** The state directory the launcher hands the child, as the other plugins read it. */
|
|
15
|
+
const stateHome = () => process.env.DSH_HOME ?? process.env.DSCODE_HOME ?? join(homedir(), '.local/share/dscode-hub');
|
|
16
|
+
|
|
17
|
+
export function apply(ctx) {
|
|
18
|
+
ctx.commands.register({
|
|
19
|
+
name: 'triggers',
|
|
20
|
+
description: 'List trigger definitions, or show one by id',
|
|
21
|
+
handler({ agent, rawInput }) {
|
|
22
|
+
const [action, id, extra] = rawInput.trim().split(/\s+/u);
|
|
23
|
+
const workspace = agent?.session?.header?.cwd;
|
|
24
|
+
const { definitions, problems } = loadTriggerDefinitions({ home: stateHome(), workspace });
|
|
25
|
+
const problemsText = problems.length === 0 ? [] : ['', 'Unreadable definitions:', ...problems.map(problem => ` ${problem.path}: ${problem.message}`)];
|
|
26
|
+
const lastRun = id => readRuns(stateHome(), { triggerId: id, limit: 1 })[0];
|
|
27
|
+
if (action === 'events') {
|
|
28
|
+
if (id === undefined || id === '' || extra !== undefined) return { kind: 'error', text: 'Usage: /triggers events <id>' };
|
|
29
|
+
const pending = listEvents(stateHome(), id);
|
|
30
|
+
return pending.length === 0
|
|
31
|
+
? { kind: 'success', text: `No pending events for ${id}.` }
|
|
32
|
+
: { kind: 'success', text: pending.map(formatEvent).join('\n') };
|
|
33
|
+
}
|
|
34
|
+
if (action === 'runs') {
|
|
35
|
+
if (id === undefined || id === '' || extra !== undefined) return { kind: 'error', text: 'Usage: /triggers runs <id>' };
|
|
36
|
+
const runs = readRuns(stateHome(), { triggerId: id, limit: 20 });
|
|
37
|
+
return runs.length === 0
|
|
38
|
+
? { kind: 'success', text: `No runs recorded for ${id}.` }
|
|
39
|
+
: { kind: 'success', text: runs.map(formatRun).join('\n') };
|
|
40
|
+
}
|
|
41
|
+
if (action !== undefined && action !== '' && action !== 'list' && action !== 'show') {
|
|
42
|
+
return { kind: 'error', text: `Unknown action "${action}". Usage: /triggers [list|show <id>|events <id>|runs <id>]` };
|
|
43
|
+
}
|
|
44
|
+
if (action === 'show') {
|
|
45
|
+
if (id === undefined || id === '') return { kind: 'error', text: `Usage: /triggers show <id>\nKnown ids: ${definitions.map(definition => definition.id).join(', ') || '(none)'}` };
|
|
46
|
+
const found = definitions.find(definition => definition.id === id);
|
|
47
|
+
if (found === undefined) return { kind: 'error', text: `No trigger "${id}" in ${stateHome()}/triggers or ${workspace ?? '(no workspace)'}/.dsh/triggers` };
|
|
48
|
+
if (extra !== undefined) return { kind: 'error', text: `Usage: /triggers show <id>` };
|
|
49
|
+
return { kind: 'success', text: formatTrigger(found, { lastRun: lastRun(found.id) }) };
|
|
50
|
+
}
|
|
51
|
+
if (definitions.length === 0 && problems.length === 0) {
|
|
52
|
+
return { kind: 'success', text: `No triggers defined.\nAdd one as ${stateHome()}/triggers/<id>.yml, or per project as <workspace>/.dsh/triggers/<id>.yml.\nThe definition format is in docs/triggers-design.md; running them is not implemented yet.` };
|
|
53
|
+
}
|
|
54
|
+
return { kind: 'success', text: [...definitions.map(definition => formatTrigger(definition, { lastRun: lastRun(definition.id) })), ...problemsText].join('\n') };
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Installing a trigger into the system scheduler. launchd owns the clock
|
|
2
|
+
// (docs/triggers-design.md), so this module turns one definition into a
|
|
3
|
+
// LaunchAgent plist and, for anything launchd cannot express, into the crontab
|
|
4
|
+
// line a person can paste. Nothing here runs a session or decides anything about
|
|
5
|
+
// a run: it is text generation plus two launchctl calls.
|
|
6
|
+
//
|
|
7
|
+
// Only shapes launchd can express are accepted on purpose. A cron expression it
|
|
8
|
+
// cannot map (ranges, steps, lists) fails the install with a message rather than
|
|
9
|
+
// silently scheduling something else.
|
|
10
|
+
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
/** Label prefix for a DSCODE trigger agent; one label per trigger id. */
|
|
14
|
+
export const AGENT_LABEL_PREFIX = 'ai.dscode.trigger.';
|
|
15
|
+
|
|
16
|
+
/** Where an installed agent's plist and its launchd output live. */
|
|
17
|
+
export const agentPath = (home, id) => join(home, 'triggers', 'agents', `${id}.plist`);
|
|
18
|
+
export const agentStdoutPath = (home, id) => join(home, 'triggers', 'agents', `${id}.log`);
|
|
19
|
+
export const agentLabel = id => `${AGENT_LABEL_PREFIX}${id}`;
|
|
20
|
+
|
|
21
|
+
const CRON_FIELDS = ['Minute', 'Hour', 'Day', 'Month', 'Weekday'];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Map a five-field cron expression onto launchd's StartCalendarInterval keys.
|
|
25
|
+
* Only `*` and plain numbers are accepted: launchd has no syntax for ranges,
|
|
26
|
+
* steps or lists, so those are refused instead of approximated.
|
|
27
|
+
* @param cron - `minute hour day month weekday`.
|
|
28
|
+
* @returns the keys an integer-valued StartCalendarInterval may carry.
|
|
29
|
+
* @throws {Error} with a user-facing message for anything launchd cannot express.
|
|
30
|
+
*/
|
|
31
|
+
export function cronToCalendarInterval(cron) {
|
|
32
|
+
const fields = String(cron).trim().split(/\s+/u);
|
|
33
|
+
if (fields.length !== 5) throw new Error('the cron expression needs five fields (minute hour day month weekday)');
|
|
34
|
+
const interval = {};
|
|
35
|
+
fields.forEach((field, index) => {
|
|
36
|
+
if (field === '*') return;
|
|
37
|
+
if (!/^\d{1,2}$/u.test(field)) {
|
|
38
|
+
throw new Error(`launchd cannot schedule "${field}" in the ${CRON_FIELDS[index]} field: use intervals for a cadence, or plain numbers here`);
|
|
39
|
+
}
|
|
40
|
+
interval[CRON_FIELDS[index]] = Number(field);
|
|
41
|
+
});
|
|
42
|
+
if (Object.keys(interval).length === 0) throw new Error('a cron expression of five "*" fields would fire every minute; use an interval instead');
|
|
43
|
+
return interval;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Escape the five XML characters a plist value may contain. */
|
|
47
|
+
const xml = value => String(value).replace(/[<>&"']/gu, char => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[char]));
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build the LaunchAgent plist for one definition.
|
|
51
|
+
* @param definition - a normalized definition.
|
|
52
|
+
* @param options - `{ home, dscodePath, project }`; `dscodePath` must be absolute.
|
|
53
|
+
* @returns the plist XML.
|
|
54
|
+
* @throws {Error} when the source has no schedule to install.
|
|
55
|
+
*/
|
|
56
|
+
export function launchAgent(definition, { home, dscodePath, project }) {
|
|
57
|
+
const args = [dscodePath, 'trigger', 'run', definition.id, '--project', project];
|
|
58
|
+
const lines = [
|
|
59
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
60
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
61
|
+
'<plist version="1.0">',
|
|
62
|
+
'<dict>',
|
|
63
|
+
` <key>Label</key><string>${xml(agentLabel(definition.id))}</string>`,
|
|
64
|
+
' <key>ProgramArguments</key>',
|
|
65
|
+
' <array>',
|
|
66
|
+
...args.map(argument => ` <string>${xml(argument)}</string>`),
|
|
67
|
+
' </array>',
|
|
68
|
+
` <key>WorkingDirectory</key><string>${xml(definition.workspace)}</string>`,
|
|
69
|
+
' <key>ProcessType</key><string>Background</string>',
|
|
70
|
+
` <key>StandardOutPath</key><string>${xml(agentStdoutPath(home, definition.id))}</string>`,
|
|
71
|
+
` <key>StandardErrorPath</key><string>${xml(agentStdoutPath(home, definition.id))}</string>`,
|
|
72
|
+
];
|
|
73
|
+
const { kind } = definition.source;
|
|
74
|
+
if (kind === 'interval') lines.push(` <key>StartInterval</key><integer>${definition.source.seconds}</integer>`);
|
|
75
|
+
else if (kind === 'poll') lines.push(` <key>StartInterval</key><integer>${definition.source.everySeconds}</integer>`);
|
|
76
|
+
else if (kind === 'calendar') {
|
|
77
|
+
const interval = cronToCalendarInterval(definition.source.cron);
|
|
78
|
+
lines.push(' <key>StartCalendarInterval</key>', ' <dict>', ...Object.entries(interval).map(([key, value]) => ` <key>${key}</key><integer>${value}</integer>`), ' </dict>');
|
|
79
|
+
} else if (kind === 'watch') {
|
|
80
|
+
lines.push(' <key>WatchPaths</key>', ' <array>', ...definition.source.paths.map(path => ` <string>${xml(path)}</string>`), ' </array>');
|
|
81
|
+
} else {
|
|
82
|
+
throw new Error(`a "${kind}" source has nothing to schedule: producers post into its spool with "dscode trigger emit ${definition.id}"`);
|
|
83
|
+
}
|
|
84
|
+
lines.push('</dict>', '</plist>', '');
|
|
85
|
+
return lines.join('\n');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The crontab line for the same schedule, for a machine without launchd.
|
|
90
|
+
* @returns the line, or a comment explaining why there is none.
|
|
91
|
+
*/
|
|
92
|
+
export function crontabLine(definition, { dscodePath, project }) {
|
|
93
|
+
const command = `${dscodePath} trigger run ${definition.id} --project ${project}`;
|
|
94
|
+
if (definition.source.kind === 'calendar') return `${definition.source.cron} ${command}`;
|
|
95
|
+
const seconds = definition.source.kind === 'interval' ? definition.source.seconds
|
|
96
|
+
: definition.source.kind === 'poll' ? definition.source.everySeconds : undefined;
|
|
97
|
+
if (seconds === undefined) return `# ${definition.id}: a "${definition.source.kind}" source has no crontab form; producers post events instead`;
|
|
98
|
+
if (seconds % 60 !== 0) return `# ${definition.id}: ${seconds}s is not a whole number of minutes; cron runs at minute resolution`;
|
|
99
|
+
const minutes = seconds / 60;
|
|
100
|
+
return minutes < 60 ? `*/${minutes} * * * * ${command}` : `0 */${Math.round(minutes / 60)} * * * ${command}`;
|
|
101
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// The run log: the durable record every trigger run appends, and the pipe a
|
|
2
|
+
// failure notifier will read later (docs/triggers-design.md). One JSONL file for
|
|
3
|
+
// outcomes plus a per-run text tail for the transcript end. A record must carry
|
|
4
|
+
// enough to explain a run without the terminal: what fired, which session, how
|
|
5
|
+
// it ended, and why.
|
|
6
|
+
|
|
7
|
+
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
/** Outcomes a run record may carry. */
|
|
11
|
+
export const OUTCOMES = Object.freeze(['completed', 'skipped', 'failed', 'timedout', 'blocked', 'overrun']);
|
|
12
|
+
|
|
13
|
+
/** Stable skip/failure reasons; `null` on a clean completion. */
|
|
14
|
+
export const REASONS = Object.freeze([
|
|
15
|
+
'model_error', 'approval_required', 'goal_blocked', 'goal_paused', 'round_cap', 'cost_cap', 'timeout', 'interrupted',
|
|
16
|
+
'already_running', 'no_match', 'duplicate', 'disabled', 'over_daily_limit', 'too_soon', 'workspace_missing',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
const isPlainObject = value => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
20
|
+
|
|
21
|
+
class RunLogError extends Error {}
|
|
22
|
+
|
|
23
|
+
const fail = message => { throw new RunLogError(message); };
|
|
24
|
+
|
|
25
|
+
/** The append-only outcome log for one state directory. */
|
|
26
|
+
export const runsPath = home => join(home, 'triggers', 'runs.jsonl');
|
|
27
|
+
|
|
28
|
+
/** The text tail of one run (the agent's own last words), beside the outcome log. */
|
|
29
|
+
export const runTailPath = (home, triggerId, runId) => join(home, 'triggers', 'logs', triggerId, `${runId}.log`);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Append one run record.
|
|
33
|
+
* @param home - the state directory.
|
|
34
|
+
* @param record - the record; see the design record for the field set.
|
|
35
|
+
* @returns the record that was written.
|
|
36
|
+
* @throws {RunLogError} when a required field is missing or a value is outside the vocabulary.
|
|
37
|
+
*/
|
|
38
|
+
export function appendRun(home, record) {
|
|
39
|
+
if (!isPlainObject(record)) fail('a run record must be an object');
|
|
40
|
+
for (const field of ['triggerId', 'runId', 'startedAt', 'outcome', 'exitCode']) {
|
|
41
|
+
if (record[field] === undefined) fail(`a run record needs ${field}`);
|
|
42
|
+
}
|
|
43
|
+
if (typeof record.triggerId !== 'string' || record.triggerId === '') fail('triggerId must be a non-empty string');
|
|
44
|
+
if (typeof record.runId !== 'string' || record.runId === '') fail('runId must be a non-empty string');
|
|
45
|
+
if (!Number.isFinite(record.startedAt)) fail('startedAt must be a number (epoch milliseconds)');
|
|
46
|
+
if (!Number.isSafeInteger(record.exitCode)) fail('exitCode must be a whole number');
|
|
47
|
+
if (!OUTCOMES.includes(record.outcome)) fail(`outcome must be one of: ${OUTCOMES.join(', ')}`);
|
|
48
|
+
if (record.reason !== null && record.reason !== undefined && !REASONS.includes(record.reason)) {
|
|
49
|
+
fail(`reason must be null or one of: ${REASONS.join(', ')}`);
|
|
50
|
+
}
|
|
51
|
+
const path = runsPath(home);
|
|
52
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
53
|
+
appendFileSync(path, JSON.stringify({ endedAt: null, sessionId: null, cost: null, rounds: null, ...record, reason: record.reason ?? null }) + '\n', { mode: 0o600 });
|
|
54
|
+
return record;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read recent run records, newest first. A torn or unreadable line is skipped
|
|
59
|
+
* rather than failing the listing: the log is append-only and a half-written
|
|
60
|
+
* line is what a crash mid-append leaves behind.
|
|
61
|
+
* @param home - the state directory.
|
|
62
|
+
* @param options - `{ triggerId, limit }`.
|
|
63
|
+
* @returns run records, newest first.
|
|
64
|
+
*/
|
|
65
|
+
export function readRuns(home, { triggerId, limit = 20 } = {}) {
|
|
66
|
+
let raw;
|
|
67
|
+
try {
|
|
68
|
+
raw = readFileSync(runsPath(home), 'utf8');
|
|
69
|
+
} catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
const records = [];
|
|
73
|
+
for (const line of raw.split('\n')) {
|
|
74
|
+
if (line.trim() === '') continue;
|
|
75
|
+
let record;
|
|
76
|
+
try {
|
|
77
|
+
record = JSON.parse(line);
|
|
78
|
+
} catch {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (triggerId !== undefined && record.triggerId !== triggerId) continue;
|
|
82
|
+
records.push(record);
|
|
83
|
+
}
|
|
84
|
+
records.reverse();
|
|
85
|
+
return limit === 0 ? records : records.slice(0, limit);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Write (replacing) one run's text tail. */
|
|
89
|
+
export function writeRunTail(home, triggerId, runId, text) {
|
|
90
|
+
const path = runTailPath(home, triggerId, runId);
|
|
91
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
92
|
+
writeFileSync(path, text, { mode: 0o600 });
|
|
93
|
+
return path;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** One record as the single listing line `/triggers` shows. */
|
|
97
|
+
export function formatRun(record) {
|
|
98
|
+
if (record === undefined) return 'never ran';
|
|
99
|
+
const when = new Date(record.startedAt).toISOString().replace('T', ' ').slice(0, 19);
|
|
100
|
+
const reason = record.reason === null || record.reason === undefined ? '' : ` (${record.reason})`;
|
|
101
|
+
const cost = Number.isFinite(record.cost) ? ` $${record.cost.toFixed(2)}` : '';
|
|
102
|
+
const rounds = Number.isFinite(record.rounds) ? ` ${record.rounds}r` : '';
|
|
103
|
+
return `${when} ${record.outcome}${reason} exit ${record.exitCode}${cost}${rounds}`;
|
|
104
|
+
}
|