@toddzheng024/dscode-bundle 0.7.25 → 0.7.27

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
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.7.25",
2
+ "version": "0.7.27",
3
3
  "type": "module",
4
4
  "license": "MIT",
5
5
  "author": "Todd Zheng",
@@ -308,6 +308,7 @@
308
308
  "@deepseek-ai/schemastery": "3.18.2",
309
309
  "@anionex/dsh-computer-use": "0.3.2",
310
310
  "chrome-devtools-mcp": "1.9.0",
311
+ "cron-parser": "5.7.0",
311
312
  "imapflow": "2.0.5",
312
313
  "mailparser": "3.9.28",
313
314
  "nodemailer": "10.0.10",
@@ -219,6 +219,9 @@ export function errorCode(status, error) {
219
219
  const code = Number.isInteger(status) ? status : Number(error?.code);
220
220
  // A 402 names token counts ("fewer max_tokens"); its status decides before any wording does.
221
221
  if (code === 402) return QUOTA_EXCEEDED_CODE;
222
+ // OpenRouter can reject a replay before generation without typed metadata.
223
+ // Keep this narrow: an ordinary permission-denied 403 still means AUTH.
224
+ if (code === 403 && typeof error?.message === 'string' && /^Request blocked by content filter\b/i.test(error.message)) return 'CONTENT_POLICY';
222
225
  const detail = [error?.message, error?.metadata?.raw].filter(value => typeof value === 'string').join(' ');
223
226
  if (isContextWindowExceededError(detail)) return CONTEXT_WINDOW_EXCEEDED_CODE;
224
227
  if (isQuotaExceededError(detail)) return QUOTA_EXCEEDED_CODE;
@@ -6,7 +6,7 @@ import { BALANCE_PROVIDERS, refreshBalance } from './balance.mjs';
6
6
  import { refreshOpenRouterModels } from '../openrouter/models.mjs';
7
7
  import { REPLAY_KIND } from '../openrouter/wire.mjs';
8
8
  import { providerSpec } from '../providers/catalog.mjs';
9
- import { createWindowRate } from './rate.mjs';
9
+ import { createSmoothedRate } from './rate.mjs';
10
10
  import { currentCharge } from './attribution.mjs';
11
11
 
12
12
  // Chunks that carry generated output; the first one marks time to first token.
@@ -15,18 +15,20 @@ export const name = 'dscode-session-metrics';
15
15
  export const inject = ['llm', 'agents', 'tokenMeter', 'sessionProjections'];
16
16
  export function apply(ctx) {
17
17
  const snapshots = new WeakMap();
18
- const liveRate = createWindowRate();
18
+ const smoothedRate = createSmoothedRate();
19
+ const activeRequests = new WeakMap();
19
20
  ctx.effect(() => setMetricSource(id => {
20
21
  const agent = ctx.agents.get(id);
21
22
  if (!agent) return undefined;
22
23
  const session = agent.session;
23
24
  const cached = snapshots.get(session);
24
- if (cached?.seq === session.seq) return { ...cached.value, currentTps: liveRate.get(session) };
25
+ const requestActive = (activeRequests.get(session) ?? 0) > 0;
26
+ if (cached?.seq === session.seq) return { ...cached.value, currentTps: smoothedRate.get(session), requestActive };
25
27
  const state = ctx.sessionProjections.stateOf(session, 'contextPressure');
26
28
  const measurement = ctx.tokenMeter.measure(session);
27
29
  const value = { events: session.snapshotEvents(), used: measurement.totalTokens, capacity: state?.contextWindow };
28
30
  snapshots.set(session, { seq: session.seq, value });
29
- return { ...value, currentTps: liveRate.get(session) };
31
+ return { ...value, currentTps: smoothedRate.get(session), requestActive };
30
32
  }));
31
33
  const home = process.env.DSH_HOME;
32
34
  // Remaining balance is best-effort decoration: resolve the key lazily (never
@@ -72,19 +74,26 @@ export function apply(ctx) {
72
74
  save({ kind: 'start', id, time, provider: options.provider, model: options.model, purpose });
73
75
  let usage, firstTokenTime, billed;
74
76
  const liveSession = purpose === 'agent' ? ctx.agents.get(sessionId)?.session : undefined;
77
+ const settleRate = liveSession
78
+ ? smoothedRate.begin(liveSession, JSON.stringify([options.provider, options.model, options.reasoningEffort ?? null]))
79
+ : undefined;
80
+ if (liveSession) activeRequests.set(liveSession, (activeRequests.get(liveSession) ?? 0) + 1);
81
+ let completed = false;
75
82
  try {
76
83
  for await (const chunk of next()) {
77
84
  if (chunk.type === 'usage') usage = chunk.usage;
78
85
  else if (firstTokenTime === undefined && OUTPUT_CHUNKS.has(chunk.type)) firstTokenTime = Date.now();
79
86
  // OpenRouter reports what it charged; the finish of its response carries it.
80
87
  if (chunk.type === 'finish' && chunk.replayState?.response?.kind === REPLAY_KIND && Number.isFinite(chunk.replayState.response.cost)) billed = chunk.replayState.response.cost;
81
- if (liveSession) liveRate.add(liveSession, chunk);
82
88
  yield chunk;
83
89
  }
90
+ completed = true;
84
91
  } finally {
85
- if (liveSession) liveRate.calibrate(liveSession, usage?.outputTokens);
92
+ const endTime = Date.now();
93
+ if (liveSession) activeRequests.set(liveSession, (activeRequests.get(liveSession) ?? 1) - 1);
94
+ if (completed) settleRate?.({ start: time, end: endTime, outputTokens: usage?.outputTokens });
86
95
  // `time` stays the start (it prices the call); `endTime` and `firstTokenTime` time it.
87
- save({ kind: 'end', id, time, endTime: Date.now(), ...(firstTokenTime === undefined ? {} : { firstTokenTime }), usage: usage ?? null, ...(billed === undefined
96
+ save({ kind: 'end', id, time, endTime, ...(firstTokenTime === undefined ? {} : { firstTokenTime }), usage: usage ?? null, ...(billed === undefined
88
97
  ? { cost: estimateCost(options.provider, options.model, usage, time), priceVersion: priceVersionFor(options.provider, options.model) }
89
98
  : { cost: billed, priceVersion: 'openrouter-billed' }) });
90
99
  }
@@ -1,69 +1,34 @@
1
- const WINDOW_MS = 5000;
2
- // A pause longer than this (tool execution, the wait for the first token) ends
3
- // the current output burst: the next chunk starts a fresh window instead of
4
- // averaging over the silence.
5
- const GAP_MS = 1500;
6
- const MIN_SPAN_MS = 500;
7
-
8
- // Providers report exact output tokens only when a request settles. During
9
- // streaming, estimate from UTF-8 bytes without rounding each small chunk.
10
- export function estimatedDeltaTokens(chunk) {
11
- const text = chunk.type === 'text-delta' || chunk.type === 'reasoning-delta'
12
- ? chunk.text
13
- : chunk.type === 'tool-call-delta' ? chunk.argumentsDelta : undefined;
14
- return typeof text === 'string' ? Buffer.byteLength(text, 'utf8') / 4 : 0;
15
- }
1
+ // Each valid completed request halves every older sample's weight.
2
+ const DECAY = 0.5;
16
3
 
17
4
  /**
18
- * Live output rate: tokens seen in the last five seconds divided by the time
19
- * that window actually spans, so the rate is right from the first second of a
20
- * burst. Settled usage calibrates the byte-based estimate per session.
5
+ * Normalized exponential average of per-request TPS, from final API usage.
6
+ * New samples have weight 1; existing weights multiply by DECAY. Normalizing
7
+ * the startup weights keeps newer requests more influential from sample two.
8
+ * Idle time and unusable samples do not age the history.
21
9
  */
22
- export function createWindowRate() {
23
- const samples = new WeakMap();
24
- const stateOf = session => {
25
- let state = samples.get(session);
26
- if (!state) { state = { values: [], head: 0, sum: 0, factor: 1, pending: 0 }; samples.set(session, state); }
27
- return state;
28
- };
29
- const prune = (state, now) => {
30
- while (state.head < state.values.length && state.values[state.head].time <= now - WINDOW_MS) {
31
- state.sum -= state.values[state.head++].tokens;
32
- }
33
- if (state.head > 128 && state.head * 2 > state.values.length) {
34
- state.values.splice(0, state.head);
35
- state.head = 0;
36
- }
37
- };
10
+ export function createSmoothedRate() {
11
+ const sessions = new WeakMap();
38
12
  return {
39
- add(session, chunk, now = Date.now()) {
40
- const tokens = estimatedDeltaTokens(chunk);
41
- if (!(tokens > 0)) return;
42
- const state = stateOf(session);
43
- const last = state.values.at(-1);
44
- if (last && now - last.time > GAP_MS) { state.values = []; state.head = 0; state.sum = 0; }
45
- state.values.push({ time: now, tokens });
46
- state.sum += tokens;
47
- state.pending += tokens;
48
- prune(state, now);
49
- },
50
- /** Feed the provider's settled output count for the request whose chunks were just added. */
51
- calibrate(session, outputTokens) {
52
- const state = samples.get(session);
53
- if (!state) return;
54
- const pending = state.pending;
55
- state.pending = 0;
56
- if (!(pending > 0) || !Number.isFinite(outputTokens) || outputTokens <= 0) return;
57
- const ratio = Math.min(2, Math.max(0.5, outputTokens / pending));
58
- state.factor = state.factor * 0.5 + ratio * 0.5;
13
+ begin(session, route) {
14
+ let state = sessions.get(session);
15
+ if (!state || state.route !== route) {
16
+ state = { route, weightedRate: 0, weight: 0 };
17
+ sessions.set(session, state);
18
+ }
19
+ // A late completion from an older model must not contaminate the new one.
20
+ return ({ start, end, outputTokens }) => {
21
+ if (sessions.get(session) !== state || !Number.isFinite(start) || !Number.isFinite(end)
22
+ || end <= start || !Number.isFinite(outputTokens) || outputTokens < 0) return;
23
+ const rate = outputTokens / ((end - start) / 1000);
24
+ if (!Number.isFinite(rate)) return;
25
+ state.weightedRate = state.weightedRate * DECAY + rate;
26
+ state.weight = state.weight * DECAY + 1;
27
+ };
59
28
  },
60
- get(session, now = Date.now()) {
61
- const state = samples.get(session);
62
- if (!state) return null;
63
- prune(state, now);
64
- if (state.head >= state.values.length) return 0;
65
- const span = Math.min(WINDOW_MS, Math.max(MIN_SPAN_MS, now - state.values[state.head].time));
66
- return Math.max(0, state.sum) * state.factor / (span / 1000);
29
+ get(session) {
30
+ const state = sessions.get(session);
31
+ return state?.weight > 0 ? state.weightedRate / state.weight : null;
67
32
  },
68
33
  };
69
34
  }
@@ -111,7 +111,7 @@ function resetStamp(iso, now) {
111
111
  * terminal width decides which segments the drop ladder keeps.
112
112
  */
113
113
  const FIGURE = {
114
- /** `~999.9` decode rates; the average reads the same scale without the tilde. */
114
+ /** Request and session-average rates share the same fixed figure width. */
115
115
  rate: 6,
116
116
  /** `100%` context occupancy. */
117
117
  percent: 4,
@@ -135,7 +135,7 @@ function figure(text, width) {
135
135
  return padding > 0 ? ' '.repeat(padding) + text : text;
136
136
  }
137
137
 
138
- export function formatFooter(metrics, context, columns = 80, rates, locale = 'en', provider = 'deepseek-official') {
138
+ function footerFigures(metrics, context, rates, locale = 'en', provider = 'deepseek-official') {
139
139
  const label = key => t(locale, key);
140
140
  const ctx = figure(Number.isFinite(context) ? `${Math.round(context)}%` : '--', FIGURE.percent);
141
141
  const cache = figure(metrics.cache === null ? '--' : `${metrics.cache.toFixed(1)}%`, FIGURE.share);
@@ -161,13 +161,21 @@ export function formatFooter(metrics, context, columns = 80, rates, locale = 'en
161
161
  // priceable, the same mark the total uses.
162
162
  const turn = metrics.lastTurn === undefined || !Number.isFinite(metrics.lastTurn.cost) ? ''
163
163
  : `#${metrics.lastTurn.turn} $${metrics.lastTurn.cost.toFixed(2)}${metrics.lastTurn.unknown ? '+' : ''}`;
164
- // Every figure reads value first and carries its own short qualifier, so the
165
- // cluster stays scannable without a `label:` prefix in front of each number.
166
- const base = rates ? [
167
- `${figure(Number.isFinite(rates.current) ? '~' + rates.current.toFixed(1) : '--', FIGURE.rate)} tps`,
168
- `${figure(Number.isFinite(rates.average) ? rates.average.toFixed(1) : '--', FIGURE.rate)} tps ${label('footer.average')}`,
169
- `${ctx} ${label('footer.context')}`, money, `${cache} ${label('footer.cache')}`,
170
- ] : [`${ctx} ${label('footer.context')}`, money, `${cache} ${label('footer.cache')}`];
164
+ return {
165
+ current: rates ? `${rates.active ? '◌' : ' '} ${figure(Number.isFinite(rates.current) ? rates.current.toFixed(1) : '--', FIGURE.rate)} tps` : '',
166
+ average: rates ? `${figure(Number.isFinite(rates.average) ? rates.average.toFixed(1) : '--', FIGURE.rate)} tps ${label('footer.average')}` : '',
167
+ context: `${ctx} ${label('footer.context')}`,
168
+ money,
169
+ cache: `${cache} ${label('footer.cache')}`,
170
+ turn,
171
+ };
172
+ }
173
+
174
+ /** Flat footer for text consumers; the TUI uses the same figures as separate groups. */
175
+ export function formatFooter(metrics, context, columns = 80, rates, locale = 'en', provider = 'deepseek-official') {
176
+ const figures = footerFigures(metrics, context, rates, locale, provider);
177
+ const { current, average, context: ctx, money, cache, turn } = figures;
178
+ const base = rates ? [current, average, ctx, money, cache] : [ctx, money, cache];
171
179
  // The slot is reserved even when no turn has cost anything yet: the drop
172
180
  // ladder's indices are fixed against this array, and an unfilled slot renders
173
181
  // as '' (skipped by `render`) exactly like the absent figure would.
@@ -204,7 +212,7 @@ export function sessionSpend(id) {
204
212
 
205
213
  /** Per-events memo: the status line renders up to once a second, and summarize/average are O(events). */
206
214
  const footerCache = new WeakMap();
207
- export function footerFor(id, stats, columns, provider = 'deepseek-official', locale = 'en') {
215
+ export function footerFor(id, stats, columns, provider = 'deepseek-official', locale = 'en', format = formatFooter) {
208
216
  const limit = parseBudget(process.env.DSCODE_SESSION_BUDGET_USD);
209
217
  try {
210
218
  const data = id ? source?.(id) : undefined;
@@ -222,6 +230,12 @@ export function footerFor(id, stats, columns, provider = 'deepseek-official', lo
222
230
  // The budget comes from the environment for this process only: it is a
223
231
  // per-machine spending guard, not a session property worth persisting.
224
232
  const budget = limit === null ? undefined : evaluateBudget(summary.cost, limit);
225
- return formatFooter({ ...summary, ...(budget === undefined ? {} : { budget }) }, Number.isFinite(used) && capacity > 0 ? used / capacity * 100 : undefined, columns, { current: data?.currentTps, average }, locale, provider);
226
- } catch { return formatFooter({ cost: 0, unknown: true, cache: null }, undefined, columns, { current: null, average: null }, locale, provider); }
233
+ return format({ ...summary, ...(budget === undefined ? {} : { budget }) }, Number.isFinite(used) && capacity > 0 ? used / capacity * 100 : undefined, columns, { current: data?.currentTps, average, active: data?.requestActive }, locale, provider);
234
+ } catch { return format({ cost: 0, unknown: true, cache: null }, undefined, columns, { current: null, average: null }, locale, provider); }
235
+ }
236
+
237
+ /** Structured figures preserve money/cache as one right-aligned group in the TUI. */
238
+ export function footerFiguresFor(id, stats, provider = 'deepseek-official', locale = 'en') {
239
+ return footerFor(id, stats, 0, provider, locale,
240
+ (metrics, context, _columns, rates, language, route) => footerFigures(metrics, context, rates, language, route));
227
241
  }
@@ -8,6 +8,13 @@
8
8
  // injected, which is also what makes the whole command testable.
9
9
 
10
10
  import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
11
+ import { randomUUID } from 'node:crypto';
12
+ import { setTimeout as sleep } from 'node:timers/promises';
13
+ import { acquireTriggerLease } from './lease.mjs';
14
+ import { handleJobCommand, JOB_COMMANDS, executeJob } from './job-cli.mjs';
15
+ import { emitToSource } from './source-ingress.mjs';
16
+ import { JobStore } from './jobs.mjs';
17
+ import { schedulerService } from './scheduler-service.mjs';
11
18
  import { dirname, join, resolve } from 'node:path';
12
19
  import { parse as parseYaml } from 'yaml';
13
20
  import { loadTriggerDefinitions, formatTrigger } from './config.mjs';
@@ -23,17 +30,25 @@ export const USAGE = `Usage: dscode trigger <command> [options]
23
30
  Commands:
24
31
  run <id> Start one run now (respecting the trigger's limits)
25
32
  fire <id> Post an event and run it: --text "..." or --event FILE
26
- emit <id> Only post an event for the next drain
33
+ emit <id> Commit an event to the scheduler queue
27
34
  events [id] Print the events waiting in a trigger's spool
28
35
  list List the trigger definitions and their last outcome
29
36
  show <id> Print one definition
30
37
  log [id] Print recorded runs, newest first (--failed for failures only)
31
38
  new <id> Write a starter definition into the project
32
39
  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)
40
+ schedule <id> Schedule one event: --after 30m or --at ISO_TIMESTAMP
41
+ jobs [id] List scheduled jobs and their state
42
+ cancel <jobId> Cancel a pending job (never stops a running one)
43
+ source <id> <action> status | start | stop | restart | logs
44
+ scheduler <action> start | tick | install | uninstall | status
45
+ run-job <jobId> Execute one due job (used by the scheduler)
46
+ install <id> Register a recurring source and install the scheduler
34
47
  uninstall <id> Remove the schedule
35
48
 
36
49
  Options:
50
+ --after DURATION Delay such as 30s, 10m, 2h or 1d (schedule only)
51
+ --at TIMESTAMP ISO timestamp with an explicit offset (schedule only)
37
52
  --event FILE Event body as JSON; "-" reads stdin
38
53
  --text TEXT Event body text (with "fire"/"emit")
39
54
  --event-id ID Identity of the posted event; retry with the same one
@@ -45,7 +60,7 @@ Exit codes are the run's: 0 completed, skipped or nothing to do; 1 failed;
45
60
  2 round or cost cap; 3 the goal is blocked or paused; 124 timeout; 130 interrupted.
46
61
  An orderly stop writes a result file; only a Host that died leaves none behind.`;
47
62
 
48
- const COMMANDS = ['run', 'fire', 'emit', 'events', 'list', 'show', 'log', 'new', 'enable', 'disable', 'install', 'uninstall'];
63
+ const COMMANDS = ['source', 'run', 'fire', 'emit', 'events', 'list', 'show', 'log', 'new', 'enable', 'disable', 'install', 'uninstall', ...JOB_COMMANDS];
49
64
 
50
65
  /** Parse the arguments after `trigger`. */
51
66
  export function parseTriggerArgs(argv) {
@@ -54,16 +69,18 @@ export function parseTriggerArgs(argv) {
54
69
  options.command = words.shift() ?? '';
55
70
  if (options.command === '' || options.command === '--help' || options.command === '-h') { options.help = true; return options; }
56
71
  if (!COMMANDS.includes(options.command)) { options.error = `unknown command "${options.command}"`; return options; }
57
- if (['list', 'log'].includes(options.command)) {
72
+ if (['list', 'log', 'events', 'jobs'].includes(options.command)) {
58
73
  if (words.length > 0 && !words[0].startsWith('-')) options.id = words.shift();
59
74
  } else {
60
75
  const id = words.shift() ?? '';
61
76
  if (id === '' || id.startsWith('-')) { options.error = `${options.command} needs a trigger id`; return options; }
62
77
  options.id = id;
63
78
  }
79
+ if (options.command === 'source') options.action = words.shift() ?? 'status';
64
80
  while (words.length > 0) {
65
81
  const flag = words.shift();
66
82
  const value = () => { const next = words.shift(); if (next === undefined) { options.error = `${flag} needs a value`; return undefined; } return next; };
83
+ if (flag === '--after' || flag === '--at') { const next = value(); if (next === undefined) return options; options[flag.slice(2)] = next; continue; }
67
84
  if (flag === '--event') { const next = value(); if (next === undefined) return options; options.event = next; continue; }
68
85
  if (flag === '--text') { const next = value(); if (next === undefined) return options; options.text = next; continue; }
69
86
  if (flag === '--event-id') { const next = value(); if (next === undefined) return options; options.eventId = next; continue; }
@@ -74,6 +91,7 @@ export function parseTriggerArgs(argv) {
74
91
  options.error = `unknown option "${flag}"`;
75
92
  return options;
76
93
  }
94
+ if (options.command !== 'schedule' && (options.after !== undefined || options.at !== undefined)) options.error = '--after and --at are only accepted by schedule';
77
95
  return options;
78
96
  }
79
97
 
@@ -112,6 +130,7 @@ export function scaffoldTrigger(id, { workspace }) {
112
130
  `workspace: ${workspace}`,
113
131
  'prompt: describe what this run should do',
114
132
  'source: { kind: interval, seconds: 300 }',
133
+ 'session: { mode: new }',
115
134
  'goal: { objective: describe the finished state, maxRounds: 20 }',
116
135
  'limits: { timeoutSeconds: 1800, maxRunsPerDay: 24, minIntervalSeconds: 60 }',
117
136
  '',
@@ -257,6 +276,28 @@ export async function runTriggerCli(argv, deps = {}) {
257
276
  // A CLI function returns an exit code: an unknown id, an unreadable definition
258
277
  // or a missing workspace is a user-facing message, not a thrown stack.
259
278
  try {
279
+ if (process.env.DSCODE_SOURCE_SOCKET) {
280
+ if (options.command !== 'emit') throw new Error('script source ingress only supports emit');
281
+ const payload = (await readEventBody(options, deps.stdin ?? process.stdin)) ?? {};
282
+ out(JSON.stringify(await emitToSource({ triggerId: options.id, eventId: options.eventId, payload })));
283
+ return 0;
284
+ }
285
+ if (options.command === 'source') {
286
+ const definition = findDefinition(home, project, options.id);
287
+ if (definition.source.kind !== 'script') throw new Error('source management requires a script source');
288
+ const store = new JobStore(home);
289
+ try {
290
+ if (['status', 'logs'].includes(options.action)) {
291
+ const source = store.source(definition.id, project);
292
+ out(options.action === 'logs' ? source?.log || '(no source output)' : JSON.stringify(source ?? { status: 'unregistered' }));
293
+ } else { out(JSON.stringify(store.controlSource(definition, project, options.action))); out('The shared scheduler must be running to supervise sources.'); }
294
+ } finally { store.close(); }
295
+ return 0;
296
+ }
297
+ if (JOB_COMMANDS.includes(options.command)) return await handleJobCommand(options, {
298
+ home, project, now, platform, dscodePath, launchctl, out, err, deps,
299
+ findDefinition, readEventBody, executeEvent,
300
+ });
260
301
  if (options.command === 'list') {
261
302
  const { definitions, problems } = loadTriggerDefinitions({ home, workspace: project });
262
303
  if (definitions.length === 0 && problems.length === 0) { out('No triggers defined.'); return 0; }
@@ -277,15 +318,23 @@ export async function runTriggerCli(argv, deps = {}) {
277
318
  return 0;
278
319
  }
279
320
  if (options.command === 'events') {
321
+ let durableCount = 0;
322
+ const store = new JobStore(home);
323
+ try {
324
+ for (const job of store.list(options.id || undefined).filter(j => j.project === project && j.kind === 'event' && ['pending', 'running'].includes(j.state))) {
325
+ durableCount += 1;
326
+ out(`${job.triggerId} ${job.state} ${store.eventIdentity(job.id)} ${JSON.parse(job.payload).text}`);
327
+ }
328
+ } finally { store.close(); }
280
329
  if (options.id === '') {
281
330
  const { definitions } = loadTriggerDefinitions({ home, workspace: project });
282
331
  let total = 0;
283
332
  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.');
333
+ if (total === 0 && durableCount === 0) out('No pending events.');
285
334
  return 0;
286
335
  }
287
336
  const pending = listEvents(home, options.id);
288
- if (pending.length === 0) out(`No pending events for ${options.id}.`);
337
+ if (pending.length === 0 && durableCount === 0) out(`No pending events for ${options.id}.`);
289
338
  else for (const event of pending) out(formatEvent(event));
290
339
  return 0;
291
340
  }
@@ -314,6 +363,8 @@ export async function runTriggerCli(argv, deps = {}) {
314
363
  const definition = findDefinition(home, project, options.id);
315
364
  const path = agentPath(home, definition.id);
316
365
  if (options.command === 'uninstall') {
366
+ const store = new JobStore(home);
367
+ try { store.unregister(definition.id, project, now); } finally { store.close(); }
317
368
  if (launchctl !== undefined && platform === 'darwin') await launchctl(['bootout', `gui/${process.getuid?.() ?? ''}/${'ai.dscode.trigger.' + definition.id}`], { ignoreFailure: true });
318
369
  rmSync(path, { force: true });
319
370
  out(`removed the schedule for ${options.id} (${path})`);
@@ -332,6 +383,15 @@ export async function runTriggerCli(argv, deps = {}) {
332
383
  err(`cannot install ${options.id}: ${dscodePath} is not an executable file, and launchd runs no shell profile`);
333
384
  return 1;
334
385
  }
386
+ if (['calendar', 'interval', 'poll', 'script'].includes(definition.source.kind)) {
387
+ const store = new JobStore(home);
388
+ try { store.register(definition, project, now); } finally { store.close(); }
389
+ if (platform === 'darwin' && launchctl) await launchctl(['bootout', `gui/${process.getuid()}/ai.dscode.trigger.${definition.id}`], { ignoreFailure: true });
390
+ rmSync(path, { force: true });
391
+ out(`registered ${definition.id} with the scheduler`);
392
+ await schedulerService('install', { home, dscodePath, platform, launchctl, agentsDirectory: deps.agentsDirectory, out });
393
+ return 0;
394
+ }
335
395
  const plist = launchAgent(definition, { home, dscodePath, project });
336
396
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
337
397
  writeFileSync(path, plist, { mode: 0o600 });
@@ -350,14 +410,26 @@ export async function runTriggerCli(argv, deps = {}) {
350
410
  const definition = findDefinition(home, project, options.id);
351
411
  if (options.command === 'emit') {
352
412
  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)`);
413
+ const store = new JobStore(home);
414
+ try {
415
+ const job = store.acceptEvent({ definition, project, payload: { source: 'cli', ...body }, eventId: options.eventId ?? deps.eventId ?? `manual-${randomUUID()}`, now });
416
+ out(`queued ${store.eventIdentity(job.id)} to ${definition.id}: ${job.id} (${job.state})`);
417
+ out('Delivery requires the shared scheduler, or dscode trigger run ' + definition.id);
418
+ } finally { store.close(); }
355
419
  return 0;
356
420
  }
421
+ if (options.command === 'run') {
422
+ const store = new JobStore(home);
423
+ try {
424
+ const wanted = options.eventId ?? deps.eventId;
425
+ const job = store.list(definition.id).reverse().find(j => j.project === project && j.kind === 'event' && (wanted === undefined ? j.state === 'pending' : store.eventIdentity(j.id) === wanted));
426
+ if (job) return await executeJob(store, job.id, { home, project, now, deps, out, err, executeEvent, findDefinition });
427
+ } finally { store.close(); }
428
+ }
357
429
  let fired;
358
430
  if (options.command === 'fire') {
359
431
  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 });
432
+ fired = emitEvent(home, definition.id, { source: 'cli', ...body }, { eventId: options.eventId ?? deps.eventId ?? `fire-${randomUUID()}`, now });
361
433
  }
362
434
 
363
435
  const pending = listEvents(home, definition.id);
@@ -369,21 +441,80 @@ export async function runTriggerCli(argv, deps = {}) {
369
441
  err(`no pending event "${wanted}" for ${definition.id}; post it with "emit", or omit --event-id`);
370
442
  return 1;
371
443
  }
372
- const chosen = fired ?? matching ?? (wanted === undefined ? pending[0] : undefined);
444
+ const scheduled = definition.session.mode === 'persistent' && definition.source.kind !== 'external' && options.command === 'run' && wanted === undefined;
445
+ const chosen = fired ?? matching ?? (!scheduled && wanted === undefined ? pending[0] : undefined);
373
446
  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 });
447
+ const persistent = definition.session.mode === 'persistent';
448
+ // Scheduled invocations also become durable events when a session is reused.
449
+ // An explicit external drain processes the backlog without inventing a tick.
450
+ if (persistent && chosen === undefined) {
451
+ emitEvent(home, definition.id, { source: definition.source.kind }, { eventId, now });
378
452
  }
453
+ const triggerLease = await acquireTriggerLease(home, definition.id, { wait: persistent });
454
+ if (!triggerLease) {
455
+ err(`skipped ${definition.id}: already_running`);
456
+ return recordSkip(home, definition, { reason: 'already_running', eventId, now });
457
+ }
458
+ try {
459
+ if (!persistent) return (await executeEvent(definition, { home, now, eventId, chosen, deps, out, err, triggerLease })).code;
460
+ let exitCode = 0;
461
+ const clock = deps.clock ?? (() => deps.now ?? Date.now());
462
+ for (;;) {
463
+ const next = listEvents(home, definition.id, { limit: 1 })[0];
464
+ if (!next) {
465
+ const own = readRuns(home, { triggerId: definition.id, limit: 0 }).find(run => run.eventId === eventId && run.outcome !== 'skipped');
466
+ return exitCode || own?.exitCode || 0;
467
+ }
468
+ const current = findDefinition(home, project, definition.id);
469
+ if (current.session.mode !== 'persistent') return exitCode;
470
+ const result = await executeEvent(current, { home, now: clock(), eventId: next.eventId, chosen: next, deps, out, err, triggerLease });
471
+ if (result.skip === 'duplicate') {
472
+ consumeEvent(home, definition.id, next.eventId);
473
+ continue;
474
+ }
475
+ if (result.skip === 'too_soon') {
476
+ const previous = readRuns(home, { triggerId: definition.id, limit: 0 }).find(run => run.outcome !== 'skipped');
477
+ const remaining = previous.startedAt + current.limits.minIntervalSeconds * 1000 - clock();
478
+ await (deps.delay ?? sleep)(Math.max(1, Math.min(remaining, 1000)));
479
+ continue;
480
+ }
481
+ if (result.skip) return exitCode; // disabled, daily cap, or a legacy run: leave events pending
482
+ if (result.code !== 0) exitCode = result.code;
483
+ if (result.code === 130) return exitCode;
484
+ }
485
+ } finally { triggerLease.release(); }
486
+ } catch (error) {
487
+ err(error?.message ?? String(error));
488
+ return 1;
489
+ }
490
+ }
379
491
 
492
+ /** The launcher and the source driver both must inject a spawn. */
493
+ function missingSpawn() {
494
+ throw new Error('this build has no way to start a run: inject spawnRun');
495
+ }
496
+
497
+ /** Execute one event while the caller holds the cross-process trigger lease. */
498
+ async function executeEvent(definition, { home, now, eventId, chosen, deps, out, err, triggerLease, beforeRun, jobId, quietSkips = false }) {
499
+ const plan = planTriggerRun(definition, { home, now, eventId });
500
+ if (plan.action === 'skip') {
501
+ if (quietSkips || definition.session.mode === 'persistent' && plan.reason === 'too_soon') return { code: 0, skip: plan.reason };
502
+ err(`skipped ${definition.id}: ${plan.reason}`);
503
+ recordSkip(home, definition, { reason: plan.reason, eventId, now });
504
+ return { code: 0, skip: plan.reason };
505
+ }
506
+
507
+ try {
508
+ if (beforeRun && !beforeRun(plan.handle)) return { code: 0, skip: 'cancelled' };
380
509
  // A poll source only runs when its predicate says there is work; without a
381
510
  // resident listener this scheduled invocation IS the poll.
382
511
  if (definition.source.kind === 'poll') {
383
- const check = await evaluateCheck(definition.source.check, { cwd: definition.workspace, timeoutMs: 60000, ...(deps.spawnCheck === undefined ? {} : { spawn: deps.spawnCheck }) });
512
+ const check = await evaluateCheck(definition.source.check, { home, permission: definition.permission, cwd: definition.workspace, timeoutMs: 60000, ...(deps.spawnCheck === undefined ? {} : { spawn: deps.spawnCheck }) });
384
513
  if (!check.matched) {
385
514
  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 });
515
+ recordLockedSkip(home, definition, plan.handle, { reason: 'no_match', eventId, details: check.output, now });
516
+ if (chosen !== undefined) consumeEvent(home, definition.id, chosen.eventId);
517
+ return { code: 0 };
387
518
  }
388
519
  }
389
520
 
@@ -391,6 +522,7 @@ export async function runTriggerCli(argv, deps = {}) {
391
522
  triggerId: definition.id,
392
523
  runId: plan.handle.runId,
393
524
  workspace: definition.workspace,
525
+ session: definition.session,
394
526
  prompt: renderPrompt(definition.prompt, chosen),
395
527
  preset: definition.preset,
396
528
  permission: definition.permission,
@@ -399,17 +531,9 @@ export async function runTriggerCli(argv, deps = {}) {
399
531
  goal: { objective: definition.goal.objective, maxRounds: definition.goal.maxRounds },
400
532
  limits: definition.limits,
401
533
  eventId,
534
+ ...(jobId ? { jobId } : {}),
402
535
  };
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
- }
536
+ const { code, result } = await (deps.spawnRun ?? missingSpawn)({ spec, home, cwd: definition.workspace, triggerLease });
413
537
  // Consumed only once the Host has actually run: a failure before that leaves
414
538
  // the event pending rather than dropping it.
415
539
  if (chosen !== undefined) consumeEvent(home, definition.id, chosen.eventId);
@@ -424,16 +548,11 @@ export async function runTriggerCli(argv, deps = {}) {
424
548
  rounds: result?.rounds ?? null,
425
549
  eventId,
426
550
  endedAt: Date.now(),
551
+ cwd: definition.workspace,
552
+ source: chosen?.source ?? definition.source.kind,
553
+ ...(jobId ? { jobId } : {}),
427
554
  });
428
555
  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');
556
+ return { code: record.exitCode, record };
557
+ } finally { releaseTriggerRun(home, plan.handle); }
439
558
  }