@toddzheng024/dscode-bundle 0.7.24 → 0.7.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/package.json +2 -1
  2. package/plugins/openrouter/wire.mjs +3 -0
  3. package/plugins/session-metrics/index.mjs +16 -7
  4. package/plugins/session-metrics/rate.mjs +26 -61
  5. package/plugins/session-metrics/view.mjs +26 -12
  6. package/plugins/triggers/cli.mjs +155 -36
  7. package/plugins/triggers/commands.mjs +140 -0
  8. package/plugins/triggers/config.mjs +32 -13
  9. package/plugins/triggers/host.mjs +28 -21
  10. package/plugins/triggers/index.mjs +17 -47
  11. package/plugins/triggers/job-cli.mjs +110 -0
  12. package/plugins/triggers/jobs.mjs +156 -0
  13. package/plugins/triggers/lease.mjs +24 -0
  14. package/plugins/triggers/management.mjs +181 -0
  15. package/plugins/triggers/options.mjs +8 -0
  16. package/plugins/triggers/poll.mjs +12 -4
  17. package/plugins/triggers/run.mjs +1 -0
  18. package/plugins/triggers/schedule.mjs +40 -0
  19. package/plugins/triggers/scheduler-service.mjs +46 -0
  20. package/plugins/triggers/scheduler.mjs +90 -0
  21. package/plugins/triggers/session.mjs +43 -0
  22. package/plugins/triggers/source-emit.mjs +19 -0
  23. package/plugins/triggers/source-host.mjs +78 -0
  24. package/plugins/triggers/source-ingress.mjs +70 -0
  25. package/plugins/triggers/source-sandbox.mjs +35 -0
  26. package/plugins/triggers/sources.mjs +45 -0
  27. package/plugins/triggers/spool.mjs +23 -6
  28. package/plugins/triggers/tools.mjs +74 -0
  29. package/vendor/tui/lib/app.mjs +66 -87
  30. package/vendor/tui/lib/dscode/preset.mjs +18 -0
  31. package/vendor/tui/lib/dscode/telemetry.mjs +25 -9
  32. package/vendor/tui/lib/index.mjs +22 -78
  33. package/vendor/tui/lib/kernel-panels.mjs +3 -2
  34. package/vendor/tui/lib/locales/en.mjs +3 -2
  35. package/vendor/tui/lib/locales/zh.mjs +3 -2
  36. package/vendor/tui/lib/models.mjs +8 -0
  37. package/vendor/tui/lib/render/inspector.mjs +1 -1
  38. package/vendor/tui/lib/render/projection.mjs +11 -6
  39. package/vendor/tui/lib/render/status.mjs +39 -10
  40. package/vendor/tui/lib/startup.mjs +4 -5
@@ -0,0 +1,140 @@
1
+ // Human slash commands share the same durable management service as agent tools.
2
+ import { randomUUID } from 'node:crypto';
3
+ import { formatTrigger } from './config.mjs';
4
+ import { formatJob } from './jobs.mjs';
5
+ import { formatRun, readRuns } from './log.mjs';
6
+ import { formatEvent, listEvents } from './spool.mjs';
7
+
8
+ export const TRIGGER_HELP = `Usage: /trigger (or /triggers)
9
+ list Definitions, registrations and scheduler status
10
+ show <id> Definition and last run
11
+ new <id> Create a disabled, project-local starter
12
+ create|update <id> <JSON> Create or edit definition fields
13
+ enable|disable <id> Enable or pause a trigger
14
+ register|unregister <id> Register or remove its recurring source
15
+ run <id> [text] Queue one event for the scheduler
16
+ schedule <id> <delay|ISO> [text] Queue a delayed event, e.g. 30m
17
+ jobs [id] Jobs and waiting reasons in this workspace
18
+ cancel <jobId> Cancel a pending job
19
+ events <id> Queued events and legacy spool entries
20
+ runs <id> Recent run outcomes
21
+ source <id> [action] status | start | stop | restart | logs
22
+ scheduler [action] status | install
23
+
24
+ A queued event needs a running scheduler. Disabling retains pending jobs;
25
+ stopping a source retains accepted events. Scheduler install starts the shared
26
+ service for all registered projects. Changes require a writable, non-plan session.`;
27
+
28
+ const ok = text => ({ kind: 'success', text });
29
+ const word = input => {
30
+ const match = input.trim().match(/^(\S+)(?:\s+([\s\S]*))?$/u);
31
+ return match ? [match[1], match[2] ?? ''] : ['', ''];
32
+ };
33
+ const usage = text => { throw new Error(`Usage: /trigger ${text}`); };
34
+ const exactId = (input, syntax) => { const [id, rest] = word(input); if (!id || rest) usage(syntax); return id; };
35
+ const schedulerText = state => `Scheduler: ${state.running ? 'running' : 'stopped — pending work waits; use /trigger scheduler install (macOS), or run dscode trigger scheduler start under a service manager'}`;
36
+
37
+ export function triggerCommand({ management, mutationProblem }) {
38
+ return async ({ agent, rawInput = '', commandId, signal }) => {
39
+ try {
40
+ signal?.throwIfAborted();
41
+ const [verb, input] = word(rawInput), action = verb || 'list';
42
+ if (['help', '--help', '-h'].includes(action)) return ok(TRIGGER_HELP);
43
+ const project = management.workspace(agent);
44
+ const mutate = () => {
45
+ signal?.throwIfAborted();
46
+ const problem = mutationProblem(agent);
47
+ if (problem) throw new Error(problem);
48
+ };
49
+ if (action === 'list') {
50
+ if (input) usage('list');
51
+ const result = await management.manage({ action: 'list' }, agent);
52
+ const rows = result.definitions.map(definition => {
53
+ const registration = result.sources.find(s => s.triggerId === definition.id) ?? result.schedules.find(s => s.triggerId === definition.id);
54
+ const source = registration?.desired ? `source ${registration.desired} (${registration.status})` : registration ? 'registered' : definition.source.kind === 'external' ? 'external event ingress' : 'not registered';
55
+ return `${formatTrigger(definition, { lastRun: readRuns(management.home, { triggerId: definition.id, limit: 1 })[0] })}\n scheduler: ${source}`;
56
+ });
57
+ return ok([schedulerText(result.scheduler), ...rows, ...(result.problems.length ? ['Unreadable definitions:'] : []), ...result.problems.map(p => `${p.path}: ${p.message}`),
58
+ ...(!rows.length ? ['No triggers defined. Use /trigger new <id> to create a disabled starter in .dsh/triggers/.'] : []),
59
+ 'Use /trigger help for management commands.'].join('\n'));
60
+ }
61
+ if (['show', 'runs', 'events'].includes(action)) {
62
+ const id = exactId(input, `${action} <id>`);
63
+ const definition = management.definition(project, id);
64
+ if (action === 'show') return ok(formatTrigger(definition, { lastRun: readRuns(management.home, { triggerId: id, limit: 1 })[0] }));
65
+ if (action === 'runs') {
66
+ const runs = readRuns(management.home, { triggerId: id, limit: 0 }).filter(r => !r.cwd || r.cwd === project).slice(0, 20);
67
+ return ok(runs.length ? runs.map(formatRun).join('\n') : `No runs recorded for ${id}.`);
68
+ }
69
+ const pending = listEvents(management.home, id);
70
+ management.withStore(store => {
71
+ for (const job of store.list(id).filter(j => j.project === project && j.workspace === project && j.kind === 'event' && ['pending', 'running'].includes(j.state))) {
72
+ pending.push({ ...JSON.parse(job.payload), eventId: store.eventIdentity(job.id), receivedAt: job.createdAt });
73
+ }
74
+ });
75
+ pending.sort((a, b) => a.receivedAt - b.receivedAt);
76
+ return ok(pending.length ? pending.map(formatEvent).join('\n') : `No pending events for ${id}.`);
77
+ }
78
+ if (action === 'jobs') {
79
+ const id = input ? exactId(input, 'jobs [id]') : undefined;
80
+ const result = await management.jobs({ action: 'list', trigger_id: id, limit: 100 }, agent);
81
+ return ok([result.jobs.length ? result.jobs.map(formatJob).join('\n') : 'No jobs scheduled in this workspace.', schedulerText(result.scheduler)].join('\n'));
82
+ }
83
+ if (action === 'source') {
84
+ const [id, rest] = word(input), operation = rest || 'status';
85
+ if (!id || !['status', 'start', 'stop', 'restart', 'logs'].includes(operation)) usage('source <id> [status|start|stop|restart|logs]');
86
+ if (!['status', 'logs'].includes(operation)) mutate();
87
+ const result = await management.source({ trigger_id: id, action: operation }, agent);
88
+ if (operation === 'logs') return ok(result.log || 'No source output recorded.');
89
+ const s = result.source;
90
+ return ok([`${id}: desired ${s.desired ?? 'unregistered'}; observed ${s.status}${s.pid ? `; pid ${s.pid}` : ''}`,
91
+ ...(s.error ? [`Last error: ${s.error}`] : []), ...(s.nextAt ? [`Next attempt: ${new Date(s.nextAt).toISOString()}`] : []),
92
+ schedulerText(result.scheduler), ...(!['status'].includes(operation) ? ['Saved desired state; the scheduler applies it asynchronously. Accepted jobs remain queued.'] : [])].join('\n'));
93
+ }
94
+ if (action === 'scheduler') {
95
+ const operation = input || 'status';
96
+ if (!['status', 'install'].includes(operation)) usage('scheduler [status|install]');
97
+ if (operation === 'install') mutate();
98
+ const result = await management.scheduler({ action: operation });
99
+ return ok([...(result.messages ?? []), schedulerText(result.scheduler ?? result)].join('\n'));
100
+ }
101
+ if (['new', 'create', 'update', 'enable', 'disable', 'register', 'unregister'].includes(action)) {
102
+ let id, definition;
103
+ if (['create', 'update'].includes(action)) {
104
+ let json; [id, json] = word(input);
105
+ if (!id || !json) usage(`${action} <id> <JSON definition fields>`);
106
+ try { definition = JSON.parse(json); } catch { throw new Error('Definition fields must be a JSON object; see /trigger help.'); }
107
+ } else id = exactId(input, `${action} <id>`);
108
+ mutate();
109
+ if (action === 'new') definition = { enabled: false, source: { kind: 'external' }, prompt: 'Describe the task for this trigger.', goal: { objective: 'Describe the outcome to complete.' } };
110
+ const result = await management.manage({ action: action === 'new' ? 'create' : action, trigger_id: id, definition }, agent);
111
+ return ok([`${action}: ${id}`, formatTrigger(result.definition), schedulerText(result.scheduler),
112
+ ...(action === 'new' ? ['Starter is disabled. Edit its prompt, goal and source with /trigger update <id> <JSON>, or ask the agent to configure it, then enable it.'] : []),
113
+ ...(action === 'disable' ? ['Pending jobs are retained; cancel them separately if needed.'] : []),
114
+ ...(action === 'unregister' ? ['Pending recurring jobs were cancelled. Delay and emitted jobs are retained.'] : [])].join('\n'));
115
+ }
116
+ if (action === 'cancel') {
117
+ const id = exactId(input, 'cancel <jobId>'); mutate();
118
+ const result = await management.jobs({ action: 'cancel', job_id: id }, agent);
119
+ return ok(formatJob(result.job));
120
+ }
121
+ if (action === 'schedule' || action === 'run') {
122
+ const [id, rest] = word(input);
123
+ if (!id) usage(action === 'run' ? 'run <id> [text]' : 'schedule <id> <delay|ISO> [text]');
124
+ const identity = `slash-${commandId ?? randomUUID()}`;
125
+ let result;
126
+ if (action === 'schedule') {
127
+ const [when, text] = word(rest);
128
+ if (!when) usage('schedule <id> <delay|ISO> [text]');
129
+ mutate();
130
+ result = await management.jobs({ action: 'schedule', trigger_id: id, ...(/^\d+(?:\.\d+)?[smhd]$/u.test(when) ? { after: when } : { at: when }), event: { source: 'tui', text }, idempotency_key: identity }, agent);
131
+ } else {
132
+ mutate();
133
+ result = await management.emit({ trigger_id: id, eventId: identity, event: { source: 'tui', text: rest } }, agent);
134
+ }
135
+ return ok([formatJob(result.job), schedulerText(result.scheduler), 'Queued durably; this command does not wait for the agent run.'].join('\n'));
136
+ }
137
+ return { kind: 'error', text: `Unknown action "${action}".\n${TRIGGER_HELP}` };
138
+ } catch (error) { return { kind: 'error', text: error.message }; }
139
+ };
140
+ }
@@ -1,6 +1,6 @@
1
1
  // Trigger definitions: the configuration half of the trigger mechanism
2
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
3
+ // folder the session binds to, what it is asked to do, and the limits that
4
4
  // keep an unattended run bounded. Nothing here starts a run: the ingress, the
5
5
  // runner and the source installers are separate work, and `/triggers` reads this
6
6
  // layer only.
@@ -11,11 +11,12 @@
11
11
  import { readdirSync, readFileSync } from 'node:fs';
12
12
  import { isAbsolute, join, resolve } from 'node:path';
13
13
  import { parse as parseYaml } from 'yaml';
14
+ import { validateCalendar } from './schedule.mjs';
14
15
 
15
16
  /** Source kinds this version accepts; each has its own required fields. */
16
- export const SOURCE_KINDS = Object.freeze(['interval', 'calendar', 'watch', 'poll', 'external']);
17
+ export const SOURCE_KINDS = Object.freeze(['interval', 'calendar', 'watch', 'poll', 'external', 'script']);
17
18
 
18
- /** The only overlap policy: a trigger whose run is still going is skipped. */
19
+ /** The default fresh-session overlap policy; persistent sessions queue instead. */
19
20
  export const OVERLAP = 'skip';
20
21
 
21
22
  /** The only notification policy for now: append to the run log. */
@@ -70,17 +71,27 @@ function normalizeSource(raw) {
70
71
  const kind = nonEmptyString(raw.kind, 'source.kind');
71
72
  if (!SOURCE_KINDS.includes(kind)) fail(`source.kind must be one of: ${SOURCE_KINDS.join(', ')}`);
72
73
  const fields = kind === 'interval' ? ['kind', 'seconds']
73
- : kind === 'calendar' ? ['kind', 'cron']
74
+ : kind === 'calendar' ? ['kind', 'cron', 'timezone', 'misfire']
74
75
  : kind === 'watch' ? ['kind', 'paths']
75
76
  : kind === 'poll' ? ['kind', 'everySeconds', 'check']
76
- : ['kind'];
77
+ : kind === 'script' ? ['kind', 'mode', 'command', 'everySeconds', 'timeoutSeconds', 'permission'] : ['kind'];
77
78
  knownKeys(raw, fields, `source (${kind})`);
79
+ if (kind === 'script') {
80
+ if (!['poll', 'daemon'].includes(raw.mode)) fail('source.mode must be poll or daemon');
81
+ if (!Array.isArray(raw.command) || !raw.command.length || raw.command.some(arg => typeof arg !== 'string' || arg.includes('\0')) || !raw.command[0].trim()) fail('source.command must be a non-empty argv array');
82
+ const permission = raw.permission ?? 'read-only';
83
+ if (!['read-only', 'workspace-write'].includes(permission)) fail('source.permission must be read-only or workspace-write');
84
+ if (raw.mode === 'daemon' && (raw.everySeconds !== undefined || raw.timeoutSeconds !== undefined)) fail('daemon sources do not accept everySeconds or timeoutSeconds');
85
+ return { kind, mode: raw.mode, command: raw.command, permission, ...(raw.mode === 'poll' ? { everySeconds: positiveInteger(raw.everySeconds, 'source.everySeconds'), timeoutSeconds: positiveInteger(raw.timeoutSeconds ?? 60, 'source.timeoutSeconds') } : {}) };
86
+ }
78
87
  if (kind === 'interval') return { kind, seconds: positiveInteger(raw.seconds, 'source.seconds') };
79
88
  if (kind === 'calendar') {
80
89
  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 };
90
+ const timezone = raw.timezone === undefined ? Intl.DateTimeFormat().resolvedOptions().timeZone : nonEmptyString(raw.timezone, 'source.timezone');
91
+ const misfire = raw.misfire ?? 'run-once';
92
+ if (!['run-once', 'skip'].includes(misfire)) fail('source.misfire must be run-once or skip');
93
+ validateCalendar(cron, timezone);
94
+ return { kind, cron, timezone, misfire };
84
95
  }
85
96
  if (kind === 'watch') {
86
97
  if (!Array.isArray(raw.paths) || raw.paths.length === 0) fail('source.paths must be a non-empty list of paths');
@@ -101,7 +112,7 @@ function normalizeSource(raw) {
101
112
  */
102
113
  export function normalizeTrigger(raw, { origin, path } = {}) {
103
114
  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');
115
+ knownKeys(raw, ['id', 'enabled', 'source', 'workspace', 'prompt', 'preset', 'permission', 'model', 'effort', 'session', 'goal', 'limits', 'overlap', 'notify'], 'the definition');
105
116
  const id = nonEmptyString(raw.id, 'id');
106
117
  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
118
  const workspace = nonEmptyString(raw.workspace, 'workspace');
@@ -115,6 +126,11 @@ export function normalizeTrigger(raw, { origin, path } = {}) {
115
126
  fail(`permission must be one of: ${UNATTENDED_PERMISSIONS.join(', ')} — an unattended run cannot ask for approval`);
116
127
  }
117
128
 
129
+ const session = raw.session === undefined ? { mode: 'new' } : raw.session;
130
+ if (!isPlainObject(session)) fail('session must be an object with mode: new or persistent');
131
+ knownKeys(session, ['mode'], 'session');
132
+ if (!['new', 'persistent'].includes(session.mode)) fail('session.mode must be new or persistent');
133
+
118
134
  const goal = raw.goal;
119
135
  if (!isPlainObject(goal)) fail('goal must be an object with the objective to finish');
120
136
  knownKeys(goal, ['objective', 'maxRounds'], 'goal');
@@ -129,7 +145,8 @@ export function normalizeTrigger(raw, { origin, path } = {}) {
129
145
  const minIntervalSeconds = limits.minIntervalSeconds === undefined ? DEFAULTS.minIntervalSeconds : positiveInteger(limits.minIntervalSeconds, 'limits.minIntervalSeconds');
130
146
  const maxCostUsd = limits.maxCostUsd === undefined ? undefined : positiveNumber(limits.maxCostUsd, 'limits.maxCostUsd');
131
147
 
132
- if (raw.overlap !== undefined && raw.overlap !== OVERLAP) fail(`overlap must be "${OVERLAP}"`);
148
+ const overlap = session.mode === 'persistent' ? 'queue' : OVERLAP;
149
+ if (raw.overlap !== undefined && raw.overlap !== overlap) fail(`overlap must be "${overlap}" for session.mode ${session.mode}`);
133
150
  if (raw.notify !== undefined && raw.notify !== NOTIFY) fail(`notify must be "${NOTIFY}"`);
134
151
 
135
152
  const source = normalizeSource(raw.source);
@@ -141,7 +158,8 @@ export function normalizeTrigger(raw, { origin, path } = {}) {
141
158
  ...(raw.effort === undefined ? {} : { effort: nonEmptyString(raw.effort, 'effort') }),
142
159
  goal: { objective, maxRounds },
143
160
  limits: { timeoutSeconds, maxRunsPerDay, minIntervalSeconds, ...(maxCostUsd === undefined ? {} : { maxCostUsd }) },
144
- overlap: OVERLAP,
161
+ session: { mode: session.mode },
162
+ overlap,
145
163
  notify: NOTIFY,
146
164
  origin: origin ?? 'user',
147
165
  path: path ?? '',
@@ -227,14 +245,15 @@ const inline = (text, limit = 96) => {
227
245
  */
228
246
  export function formatTrigger(definition, { lastRun } = {}) {
229
247
  const source = definition.source.kind === 'interval' ? `every ${definition.source.seconds}s`
230
- : definition.source.kind === 'calendar' ? `cron ${definition.source.cron}`
248
+ : definition.source.kind === 'calendar' ? `cron ${definition.source.cron} (${definition.source.timezone}, ${definition.source.misfire})`
231
249
  : definition.source.kind === 'watch' ? `watch ${definition.source.paths.join(', ')}`
232
250
  : definition.source.kind === 'poll' ? `poll ${definition.source.everySeconds}s: ${definition.source.check}`
233
- : 'external (emit only)';
251
+ : definition.source.kind === 'script' ? `script ${definition.source.mode}: ${definition.source.command.join(' ')}` : 'external (emit only)';
234
252
  return [
235
253
  `${definition.enabled ? 'on ' : 'off'} ${definition.id}${definition.overrides ? ' (project overrides user)' : ''}`,
236
254
  ` source: ${source}`,
237
255
  ` workspace: ${definition.workspace}`,
256
+ ` session: ${definition.session?.mode ?? 'new'}`,
238
257
  ` goal: ${inline(definition.goal.objective)} (max ${definition.goal.maxRounds} rounds)`,
239
258
  ` prompt: ${inline(definition.prompt)}`,
240
259
  ` limits: ${definition.limits.timeoutSeconds}s, ${definition.limits.maxRunsPerDay}/day${definition.limits.maxCostUsd === undefined ? '' : `, $${definition.limits.maxCostUsd}`}`,
@@ -1,4 +1,4 @@
1
- // Host-side half of one trigger run: the clean session an event starts. Unlike
1
+ // Host-side half of one trigger run: the fresh or resumed session an event uses. Unlike
2
2
  // `dscode exec`, which runs exactly one turn, this one keeps going while the
3
3
  // goal is active — the round driver supplies the continuation — and stops on the
4
4
  // goal's own end, a cap, the timeout, or a needed approval.
@@ -6,7 +6,7 @@
6
6
  // It owns the agent, the goal and the transcript; the parent process owns the
7
7
  // lock, the limits and the run record. The two swap files: `DSCODE_TRIGGER_OPTIONS`
8
8
  // in, `<options>.result.json` out.
9
- import { randomUUID } from 'node:crypto';
9
+ import { openTriggerSession } from './session.mjs';
10
10
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
11
11
  import { decideStop } from './run.mjs';
12
12
  import { readRunSpec, writeRunResult } from './options.mjs';
@@ -17,10 +17,10 @@ import { triggerOverlay } from './overlay.mjs';
17
17
  export { triggerOverlay };
18
18
 
19
19
  export const name = 'dscode-trigger-host';
20
- export const inject = ['agents', 'agentPresets', 'agentDefaultModel', 'permissionPresets', 'llm', 'goals', 'appExit'];
20
+ export const inject = ['agents', 'sessions', 'agentPresets', 'agentDefaultModel', 'permissionPresets', 'llm', 'goals', 'appExit'];
21
21
 
22
22
  export function apply(ctx) {
23
- void run(ctx).catch(error => { process.stderr.write(`dscode trigger run: ${error.message}\n`); ctx.get('appExit')(1); });
23
+ void runTriggerHost(ctx).catch(error => { process.stderr.write(`dscode trigger run: ${error.message}\n`); ctx.get('appExit')(1); });
24
24
  }
25
25
 
26
26
  function splitRoute(value) {
@@ -29,9 +29,8 @@ function splitRoute(value) {
29
29
  return [value.slice(0, at), value.slice(at + 1)];
30
30
  }
31
31
 
32
- async function run(ctx) {
32
+ export async function runTriggerHost(ctx, { optionsPath = process.env.DSCODE_TRIGGER_OPTIONS, home = process.env.DSH_HOME, spend = sessionSpend } = {}) {
33
33
  await ctx.get('loader').await();
34
- const optionsPath = process.env.DSCODE_TRIGGER_OPTIONS;
35
34
  if (!optionsPath) throw new Error('DSCODE_TRIGGER_OPTIONS is missing');
36
35
  const spec = readRunSpec(optionsPath);
37
36
  const resultPath = `${optionsPath}.result.json`;
@@ -42,12 +41,7 @@ async function run(ctx) {
42
41
  const agentOptions = { provider, model, ...(effort ? { reasoningEffort: effort } : {}) };
43
42
  const setup = async agentCtx => { await ctx.agentPresets.mount(agentCtx, spec.preset ?? 'dscode'); };
44
43
 
45
- const handle = await ctx.agents.create({
46
- sessionId: randomUUID(),
47
- meta: { cwd: spec.workspace, agentPreset: spec.preset ?? 'dscode' },
48
- agentOptions,
49
- setup,
50
- });
44
+ const handle = await openTriggerSession(ctx, spec, { home, agentOptions, setup });
51
45
  const agent = handle.agent;
52
46
  const session = agent.session;
53
47
  if (spec.permission) {
@@ -58,6 +52,17 @@ async function run(ctx) {
58
52
  // The goal is created through the SERVICE, not the `create_goal` tool: the tool
59
53
  // requires a direct human turn, and an unattended run has none. The service is
60
54
  // the same path the human-facing /goal command uses.
55
+ // Every event gets a new goal, including after a blocked/timed-out run. Clear
56
+ // through the service so the previous goal remains in the durable history.
57
+ const previousGoal = ctx.goals.get(agent);
58
+ if (previousGoal) ctx.goals.clear(agent, { id: previousGoal.id, revision: previousGoal.revision });
59
+ const initialCost = spend(session.id).cost;
60
+ const runCost = () => {
61
+ try {
62
+ const total = spend(session.id).cost;
63
+ return Number.isFinite(initialCost) && Number.isFinite(total) ? Math.max(0, total - initialCost) : undefined;
64
+ } catch { return undefined; }
65
+ };
61
66
  ctx.goals.create(agent, { objective: spec.goal.objective, maxGoalRounds: spec.goal.maxRounds });
62
67
 
63
68
  let finished = false;
@@ -65,16 +70,19 @@ async function run(ctx) {
65
70
  let lastText = '';
66
71
  const limiter = spec.limits ?? {};
67
72
 
68
- const finish = (result, tail) => {
73
+ const finish = async (result, tail) => {
69
74
  if (finished) return;
70
75
  finished = true;
71
76
  if (tail !== undefined && tail.trim() !== '') {
72
- try { writeRunTail(process.env.DSH_HOME ?? '.', spec.triggerId, spec.runId, tail); } catch { /* the record still explains the run */ }
77
+ try { writeRunTail(home ?? '.', spec.triggerId, spec.runId, tail); } catch { /* the record still explains the run */ }
73
78
  }
74
79
  try {
80
+ await ctx.sessions.flush(session);
75
81
  writeRunResult(resultPath, { ...result, sessionId: session.id });
76
82
  } catch (error) {
77
- process.stderr.write(`dscode trigger run: could not write the result file: ${error.message}\n`);
83
+ process.stderr.write(`dscode trigger run: could not persist the run result: ${error.message}\n`);
84
+ ctx.get('appExit')(1);
85
+ return;
78
86
  }
79
87
  ctx.get('appExit')(result.exitCode);
80
88
  };
@@ -84,11 +92,10 @@ async function run(ctx) {
84
92
  if (finished) return;
85
93
  let goal;
86
94
  try { goal = ctx.goals.get(agent); } catch { goal = undefined; }
87
- let costUsd;
88
- try { costUsd = sessionSpend(session.id).cost; } catch { costUsd = undefined; }
95
+ const costUsd = runCost();
89
96
  const decision = decideStop({ goal, costUsd, limits: limiter, approvalsRejected });
90
97
  if (!decision.stop) return;
91
- finish({
98
+ void finish({
92
99
  outcome: decision.outcome,
93
100
  reason: decision.reason ?? null,
94
101
  exitCode: decision.exitCode,
@@ -110,7 +117,7 @@ async function run(ctx) {
110
117
  });
111
118
  ctx.on('session/disposed', source => {
112
119
  if (source.id !== session.id) return;
113
- finish({ outcome: 'failed', reason: 'model_error', exitCode: 1, cost: null, rounds: null }, lastText);
120
+ void finish({ outcome: 'failed', reason: 'model_error', exitCode: 1, cost: null, rounds: null }, lastText);
114
121
  });
115
122
  // No human is present: an approval request is refused, and the run is marked
116
123
  // as having needed one so the operator sees it in the record.
@@ -124,11 +131,11 @@ async function run(ctx) {
124
131
  if (Number.isFinite(limiter.timeoutSeconds) && limiter.timeoutSeconds > 0) {
125
132
  setTimeout(() => {
126
133
  const goal = (() => { try { return ctx.goals.get(agent); } catch { return undefined; } })();
127
- finish({
134
+ void finish({
128
135
  outcome: 'timedout',
129
136
  reason: approvalsRejected ? 'approval_required' : 'timeout',
130
137
  exitCode: 124,
131
- cost: (() => { try { return sessionSpend(session.id).cost; } catch { return null; } })(),
138
+ cost: runCost() ?? null,
132
139
  rounds: Number.isFinite(goal?.roundsStarted) ? goal.roundsStarted : null,
133
140
  }, lastText);
134
141
  }, limiter.timeoutSeconds * 1000).unref();
@@ -1,57 +1,27 @@
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';
1
+ // TUI management and agent tools use one definitions/jobs/source service.
2
+ import { registerTriggerTools, mutationProblem } from './tools.mjs';
3
+ import { TriggerManagement } from './management.mjs';
4
+ import { triggerCommand } from './commands.mjs';
8
5
  import { homedir } from 'node:os';
9
6
  import { join } from 'node:path';
10
7
 
11
8
  export const name = 'dscode-triggers';
12
9
  export const inject = ['commands'];
13
-
14
- /** The state directory the launcher hands the child, as the other plugins read it. */
15
10
  const stateHome = () => process.env.DSH_HOME ?? process.env.DSCODE_HOME ?? join(homedir(), '.local/share/dscode-hub');
16
11
 
17
12
  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
- },
13
+ const home = stateHome();
14
+ let managementContext;
15
+ ctx.inject(['tools', 'systemPrompt', 'permissionPresets'], toolCtx => {
16
+ managementContext = toolCtx;
17
+ registerTriggerTools(toolCtx, { home });
18
+ });
19
+ const management = new TriggerManagement({ home, dscodePath: process.env.DSCODE_CLI_PATH });
20
+ const handler = triggerCommand({ management, mutationProblem: agent => managementContext
21
+ ? mutationProblem(managementContext, agent) : 'Trigger management services are not available yet.' });
22
+ for (const name of ['trigger', 'triggers']) ctx.commands.register({
23
+ name, description: 'Manage triggers, jobs and script sources; /trigger help',
24
+ input: { hint: '[list|show|new|update|enable|disable|run|schedule|jobs|cancel|source|scheduler|help]' },
25
+ handler,
56
26
  });
57
27
  }
@@ -0,0 +1,110 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { JobStore, formatJob } from './jobs.mjs';
3
+ import { normalizeEvent } from './spool.mjs';
4
+ import { dueTime } from './schedule.mjs';
5
+ import { runScheduler } from './scheduler.mjs';
6
+ import { schedulerService } from './scheduler-service.mjs';
7
+ import { acquireTriggerLease } from './lease.mjs';
8
+ import { appendRun, readRuns } from './log.mjs';
9
+
10
+ export const JOB_COMMANDS = ['schedule', 'jobs', 'cancel', 'scheduler', 'run-job'];
11
+
12
+ export async function handleJobCommand(options, context) {
13
+ const { home, project, now, platform, dscodePath, launchctl, out, err, deps, findDefinition, readEventBody } = context;
14
+ if (options.command === 'scheduler') {
15
+ if (!['start', 'tick', 'install', 'uninstall', 'status'].includes(options.id)) throw new Error('scheduler expects start, tick, install, uninstall or status');
16
+ if (options.id === 'status') {
17
+ const lease = await acquireTriggerLease(home, '_scheduler');
18
+ out(lease ? 'scheduler stopped' : 'scheduler running');
19
+ lease?.release();
20
+ return 0;
21
+ }
22
+ if (!dscodePath || !existsSync(dscodePath)) throw new Error('the scheduler needs a valid dscode program path');
23
+ if (['install', 'uninstall'].includes(options.id)) {
24
+ await schedulerService(options.id, { home, dscodePath, platform, launchctl, agentsDirectory: deps.agentsDirectory, out });
25
+ return 0;
26
+ }
27
+ const controller = new AbortController();
28
+ const stop = () => controller.abort();
29
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.once(signal, stop);
30
+ try {
31
+ out(`scheduler ${options.id}: ${home}`);
32
+ return await runScheduler({ home, dscodePath, once: options.id === 'tick', signal: controller.signal, spawnWorker: deps.spawnWorker, report: err });
33
+ } finally { for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.off(signal, stop); }
34
+ }
35
+ const store = new JobStore(home);
36
+ try {
37
+ if (options.command === 'jobs') {
38
+ const jobs = store.list(options.id || undefined);
39
+ out(jobs.length ? jobs.map(formatJob).join('\n') : 'No jobs scheduled.');
40
+ return 0;
41
+ }
42
+ if (options.command === 'cancel') {
43
+ store.cancel(options.id, now);
44
+ out(`cancelled ${options.id}`);
45
+ return 0;
46
+ }
47
+ if (options.command === 'schedule') {
48
+ const definition = findDefinition(home, project, options.id);
49
+ const dueAt = dueTime(options, now);
50
+ const payload = (await readEventBody(options, deps.stdin ?? process.stdin)) ?? {};
51
+ normalizeEvent(definition.id, payload, { eventId: 'validation', now });
52
+ if (options.eventId !== undefined) throw new Error('schedule assigns a jobId; --event-id is only for emit/fire/run');
53
+ const job = store.create({ triggerId: definition.id, project, workspace: definition.workspace, payload, dueAt, now });
54
+ out(formatJob(job));
55
+ out('The scheduler delivers this job: dscode trigger scheduler install (or scheduler start).');
56
+ return 0;
57
+ }
58
+ return await executeJob(store, options.id, context);
59
+ } finally { store.close(); }
60
+ }
61
+
62
+ export async function executeJob(store, id, context) {
63
+ const { home, now, deps, out, err, executeEvent, findDefinition } = context;
64
+ const job = store.get(id);
65
+ if (!job) throw new Error(`no job "${id}"`);
66
+ if (job.state !== 'pending' || job.availableAt > now) { out(formatJob(job)); return 0; }
67
+ const triggerLease = await acquireTriggerLease(home, job.triggerId);
68
+ if (!triggerLease) { store.defer(id, 'already_running', now + 1000); return 0; }
69
+ let handle;
70
+ try {
71
+ let definition;
72
+ try { definition = findDefinition(home, job.project, job.triggerId); }
73
+ catch (error) { store.defer(id, 'definition_missing', now + 30000); err(error.message); return 0; }
74
+ if (definition.workspace !== job.workspace) {
75
+ store.defer(id, 'workspace_changed', now + 30000);
76
+ err(`job ${id} belongs to ${job.workspace}; cancel it and schedule a new job for the changed workspace`);
77
+ return 0;
78
+ }
79
+ const eventId = store.eventIdentity(job.id) ?? `job:${job.id}`;
80
+ const chosen = normalizeEvent(job.triggerId, { source: job.kind, ...JSON.parse(job.payload) }, { eventId, now: job.createdAt });
81
+ const result = await executeEvent(definition, {
82
+ home, now, deps, out, err, triggerLease, chosen, eventId, jobId: id, quietSkips: true,
83
+ beforeRun: planned => { handle = planned; return store.claim(id, planned.runId, now); },
84
+ });
85
+ if (result.skip) {
86
+ let availableAt = now + 30000;
87
+ const runs = readRuns(home, { triggerId: job.triggerId, limit: 0 }).filter(r => r.outcome !== 'skipped');
88
+ if (result.skip === 'too_soon' && runs[0]) availableAt = runs[0].startedAt + definition.limits.minIntervalSeconds * 1000;
89
+ if (result.skip === 'over_daily_limit') {
90
+ const withinDay = runs.filter(r => r.startedAt > now - 86400000).sort((a, b) => a.startedAt - b.startedAt);
91
+ if (withinDay[0]) availableAt = withinDay[0].startedAt + 86400000;
92
+ }
93
+ if (result.skip === 'duplicate') {
94
+ // Reconcile a durable run record after a crash before job-state update.
95
+ const previous = runs.find(r => r.eventId === eventId);
96
+ if (store.claim(id, previous.runId, now)) store.finish(id, previous, now);
97
+ } else store.defer(id, result.skip, availableAt);
98
+ return 0;
99
+ }
100
+ store.finish(id, result.record ?? { exitCode: result.code, reason: 'no_match' });
101
+ return result.code;
102
+ } catch (error) {
103
+ if (handle && store.get(id).state === 'running') {
104
+ const record = { ...handle, jobId: id, outcome: 'failed', reason: 'model_error', exitCode: 1, endedAt: Date.now(), cwd: job.workspace };
105
+ appendRun(home, record);
106
+ store.finish(id, record);
107
+ }
108
+ throw error;
109
+ } finally { triggerLease.release(); }
110
+ }