@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
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -25,5 +25,8 @@ Local changes: DSCODE effort, shell control, TUI commands and footer.
|
|
|
25
25
|
@deepseek-ai/dsh-terminal-bash@0.1.5-rc.2: MIT; {"type":"git","url":"git+https://github.com/deepseek-ai/deepseek-harness.git","directory":"packages/terminal/terminal-bash"}
|
|
26
26
|
Local changes: DSCODE effort, shell control, TUI commands and footer.
|
|
27
27
|
|
|
28
|
+
@deepseek-ai/dsh-command-goal@0.1.5-rc.2: MIT; {"type":"git","url":"git+https://github.com/deepseek-ai/deepseek-harness.git","directory":"packages/goal/command-goal"}
|
|
29
|
+
Local changes: DSCODE effort, shell control, TUI commands and footer.
|
|
30
|
+
|
|
28
31
|
dsh-code (DSCODE vendored terminal, forked from dsh-code@1.2.0): MIT; https://github.com/unlinearity/dsh-code
|
|
29
32
|
Local changes: DSCODE UI (welcome header, activity line, footer telemetry, effort bar), commands, panels and paste handling.
|
package/cordis.patch.yml
CHANGED
|
@@ -850,6 +850,8 @@
|
|
|
850
850
|
name: "@toddzheng024/dscode-bundle/tui-tools"
|
|
851
851
|
- id: dscode-email-tools
|
|
852
852
|
name: "@toddzheng024/dscode-bundle/email-tools"
|
|
853
|
+
- id: dscode-triggers
|
|
854
|
+
name: "@toddzheng024/dscode-bundle/triggers"
|
|
853
855
|
- id: dscode-hooks
|
|
854
856
|
name: "@deepseek-ai/dsh-hooks-codex"
|
|
855
857
|
inject: &a1
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.7.
|
|
2
|
+
"version": "0.7.24",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Todd Zheng",
|
|
@@ -58,7 +58,9 @@
|
|
|
58
58
|
"./session-metrics": "./plugins/session-metrics/index.mjs",
|
|
59
59
|
"./openrouter": "./plugins/openrouter/index.mjs",
|
|
60
60
|
"./grok": "./plugins/grok/index.mjs",
|
|
61
|
-
"./tui-tools": "./plugins/tui-tools/index.mjs"
|
|
61
|
+
"./tui-tools": "./plugins/tui-tools/index.mjs",
|
|
62
|
+
"./triggers": "./plugins/triggers/index.mjs",
|
|
63
|
+
"./command-goal": "./vendor/command-goal/index.js"
|
|
62
64
|
},
|
|
63
65
|
"dependencies": {
|
|
64
66
|
"@deepseek-ai/cordis": "4.0.2",
|
package/plugins/dscode/index.mjs
CHANGED
|
@@ -30,7 +30,7 @@ const DELEGATION_TOOLS = ['subagent', 'subagent_fork', 'workflow', 'ralph'];
|
|
|
30
30
|
// repository there instead of pointing at a path that does not exist.
|
|
31
31
|
const DOCS_DIR = fileURLToPath(new URL('../../docs/', import.meta.url));
|
|
32
32
|
const DOCS_REPO = 'https://github.com/qiz029/dscode/blob/main/docs/';
|
|
33
|
-
export const DOCS_SECTION = `To answer questions about DSCODE itself - what it supports, how a feature is configured, what a command does - treat the bundled user guides as the source of truth instead of guessing: ${existsSync(DOCS_DIR) ? `${DOCS_DIR} plus the repository root README.md (session-bridge.md, session-communication.md, session-cards.md, memory.md, exec.md, email.md, skills.md, tui-commands.md, session-metrics.md, dscode-ultra.md, auto-review.md, demo.md)` : `the repository documentation at ${DOCS_REPO} plus the root README.md (session-bridge.md, session-communication.md, session-cards.md, memory.md, exec.md, email.md, skills.md, tui-commands.md, session-metrics.md, dscode-ultra.md, auto-review.md, demo.md)`}. Read the relevant guide before answering rather than relying on memory of an earlier session. docs/CONTEXT-HANDOFF.md, docs/session-messaging-design.md, docs/cloud-webapp-host.md, docs/verification.md, docs/maintainability.md and docs/vendored-tui-upgrade.md are internal development records, not user documentation: never quote them to a user or treat them as a specification. When the guides do not cover something, describe only what the running installation actually does and say plainly that it is not documented.`;
|
|
33
|
+
export const DOCS_SECTION = `To answer questions about DSCODE itself - what it supports, how a feature is configured, what a command does - treat the bundled user guides as the source of truth instead of guessing: ${existsSync(DOCS_DIR) ? `${DOCS_DIR} plus the repository root README.md (session-bridge.md, session-communication.md, session-cards.md, memory.md, exec.md, email.md, skills.md, tui-commands.md, session-metrics.md, dscode-ultra.md, auto-review.md, demo.md, triggers.md)` : `the repository documentation at ${DOCS_REPO} plus the root README.md (session-bridge.md, session-communication.md, session-cards.md, memory.md, exec.md, email.md, skills.md, tui-commands.md, session-metrics.md, dscode-ultra.md, auto-review.md, demo.md, triggers.md)`}. Read the relevant guide before answering rather than relying on memory of an earlier session. docs/CONTEXT-HANDOFF.md, docs/session-messaging-design.md, docs/cloud-webapp-host.md, docs/triggers-design.md, docs/verification.md, docs/maintainability.md and docs/vendored-tui-upgrade.md are internal development records, not user documentation: never quote them to a user or treat them as a specification. When the guides do not cover something, describe only what the running installation actually does and say plainly that it is not documented.`;
|
|
34
34
|
|
|
35
35
|
export function apply(ctx) {
|
|
36
36
|
ctx.systemPrompt.section({ name: 'dscode:shell-policy', order: 1050, text: SHELL_POLICY });
|
|
@@ -10,6 +10,27 @@ export const DEFAULT_OUTPUT_CAP = 131072;
|
|
|
10
10
|
const APP_URL = 'https://github.com/qiz029/dscode';
|
|
11
11
|
const IMAGE_POLICY = Object.freeze({ maxPixels: 2048 * 2048, maxBytes: 1024 * 1024 });
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* What `/model` offers on this route: the labs DSCODE tunes and tests, plus the current
|
|
15
|
+
* flagship line of the mainstream Western labs. OpenRouter carries hundreds of tool-calling
|
|
16
|
+
* models, and an alphabetical wall of them is not a picker, so the list is curated by hand;
|
|
17
|
+
* an id OpenRouter retires drops out of the picker until this list is edited.
|
|
18
|
+
* Only the picker narrows: `resolveModel` still serves every id the listing knows, so a
|
|
19
|
+
* session already on a trimmed model, `exec --model` and provider switches keep working.
|
|
20
|
+
*/
|
|
21
|
+
export const LISTED_MODELS = Object.freeze([
|
|
22
|
+
'deepseek/deepseek-v4-pro', 'deepseek/deepseek-v4-flash', 'deepseek/deepseek-v4.1-flash',
|
|
23
|
+
'z-ai/glm-5.3', 'z-ai/glm-5.3-flash', 'z-ai/glm-5.3-flashx',
|
|
24
|
+
'moonshotai/kimi-k3', 'moonshotai/kimi-k2.7-code', 'moonshotai/kimi-k2.6',
|
|
25
|
+
'qwen/qwen3.8-max-0902', 'qwen/qwen3.8-flash', 'qwen/qwen3.8-27b', 'qwen/qwen3.7-plus',
|
|
26
|
+
'xiaomi/mimo-v2.6-pro', 'xiaomi/mimo-v2.6-flash', 'xiaomi/mimo-v2.6-pro-ultraspeed',
|
|
27
|
+
'anthropic/claude-opus-5', 'anthropic/claude-sonnet-5', 'anthropic/claude-fable-5.1', 'anthropic/claude-haiku-4.5',
|
|
28
|
+
'openai/gpt-6-astra-pro', 'openai/gpt-6-astra', 'openai/gpt-5.6-luna-pro', 'openai/gpt-5.6-terra-pro',
|
|
29
|
+
'google/gemini-3.8-flash', 'google/gemini-3.1-pro-preview',
|
|
30
|
+
'x-ai/grok-4.7', 'x-ai/grok-build-0.1',
|
|
31
|
+
]);
|
|
32
|
+
const LISTED = new Set(LISTED_MODELS);
|
|
33
|
+
|
|
13
34
|
function modelInfo(provider, id, entry) {
|
|
14
35
|
return { provider, id, name: entry?.name ?? id, inputModalities: entry?.inputModalities?.length ? [...entry.inputModalities] : ['text'] };
|
|
15
36
|
}
|
|
@@ -45,10 +66,10 @@ export class OpenRouterAdapter extends LlmAdapter {
|
|
|
45
66
|
return this.config.options().retryPolicy;
|
|
46
67
|
}
|
|
47
68
|
|
|
48
|
-
/**
|
|
69
|
+
/** The curated picker list, narrowed to entries that can drive an agent: text output and tool calls. */
|
|
49
70
|
async listModels(provider) {
|
|
50
71
|
await this.config.ensureModels();
|
|
51
|
-
return listOpenRouterModels().filter(([, entry]) => entry.tools !== false && entry.textOutput !== false).map(([id, entry]) => modelInfo(provider, id, entry));
|
|
72
|
+
return listOpenRouterModels().filter(([id, entry]) => LISTED.has(id) && entry.tools !== false && entry.textOutput !== false).map(([id, entry]) => modelInfo(provider, id, entry));
|
|
52
73
|
}
|
|
53
74
|
|
|
54
75
|
async resolveModel(provider, model) {
|
|
@@ -14,7 +14,8 @@ const TOOL_RESULT_IMAGE_TEXT = 'Attached image(s) from tool result:';
|
|
|
14
14
|
const DEEPSEEK_WIRE = Object.freeze({ ...OPENROUTER_EFFORTS, ultra: OPENROUTER_EFFORTS.max });
|
|
15
15
|
const DEEPSEEK_V4 = /^deepseek\/deepseek-v4/;
|
|
16
16
|
// Models whose provider rejects an assistant tool-call turn without `reasoning_content`.
|
|
17
|
-
|
|
17
|
+
// MiMo needs it too: Xiaomi's API answers 400 Invalid Format when thinking mode is not passed back.
|
|
18
|
+
const REASONING_CONTENT_MODELS = /^(?:deepseek\/deepseek-v4|moonshotai\/kimi-k2\.6|xiaomi\/mimo-v2\.6)/;
|
|
18
19
|
// Alibaba caches only at explicit breakpoints.
|
|
19
20
|
const EXPLICIT_CACHE_MODELS = /^qwen\//;
|
|
20
21
|
const DEFAULT_LEVELS = ['low', 'medium', 'high'];
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
// The portable half of `dscode trigger`: argument parsing, deciding, recording,
|
|
2
|
+
// and the read-only or file-editing commands. It imports nothing from the
|
|
3
|
+
// launcher or the harness, so the same module serves a source checkout
|
|
4
|
+
// (scripts/trigger.mjs supplies the spawning) and the published launcher (which
|
|
5
|
+
// ships this file and supplies its own).
|
|
6
|
+
//
|
|
7
|
+
// Everything that needs a process — spawning the Host, calling launchctl — is
|
|
8
|
+
// injected, which is also what makes the whole command testable.
|
|
9
|
+
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { dirname, join, resolve } from 'node:path';
|
|
12
|
+
import { parse as parseYaml } from 'yaml';
|
|
13
|
+
import { loadTriggerDefinitions, formatTrigger } from './config.mjs';
|
|
14
|
+
import { formatEvent, emitEvent, listEvents, consumeEvent } from './spool.mjs';
|
|
15
|
+
import { appendRun, formatRun, readRuns, writeRunTail } from './log.mjs';
|
|
16
|
+
import { finishTriggerRun, planTriggerRun, releaseTriggerRun } from './run.mjs';
|
|
17
|
+
import { renderPrompt } from './options.mjs';
|
|
18
|
+
import { evaluateCheck } from './poll.mjs';
|
|
19
|
+
import { agentPath, crontabLine, launchAgent } from './launchd.mjs';
|
|
20
|
+
|
|
21
|
+
export const USAGE = `Usage: dscode trigger <command> [options]
|
|
22
|
+
|
|
23
|
+
Commands:
|
|
24
|
+
run <id> Start one run now (respecting the trigger's limits)
|
|
25
|
+
fire <id> Post an event and run it: --text "..." or --event FILE
|
|
26
|
+
emit <id> Only post an event for the next drain
|
|
27
|
+
events [id] Print the events waiting in a trigger's spool
|
|
28
|
+
list List the trigger definitions and their last outcome
|
|
29
|
+
show <id> Print one definition
|
|
30
|
+
log [id] Print recorded runs, newest first (--failed for failures only)
|
|
31
|
+
new <id> Write a starter definition into the project
|
|
32
|
+
enable|disable <id> Set the definition's enabled flag in its own file
|
|
33
|
+
install <id> Schedule the trigger with launchd (or print the crontab)
|
|
34
|
+
uninstall <id> Remove the schedule
|
|
35
|
+
|
|
36
|
+
Options:
|
|
37
|
+
--event FILE Event body as JSON; "-" reads stdin
|
|
38
|
+
--text TEXT Event body text (with "fire"/"emit")
|
|
39
|
+
--event-id ID Identity of the posted event; retry with the same one
|
|
40
|
+
--workspace DIR Workspace for "new" (default: the project directory)
|
|
41
|
+
--project DIR Project whose .dsh/triggers definitions are read (default: cwd)
|
|
42
|
+
-h, --help Show this help
|
|
43
|
+
|
|
44
|
+
Exit codes are the run's: 0 completed, skipped or nothing to do; 1 failed;
|
|
45
|
+
2 round or cost cap; 3 the goal is blocked or paused; 124 timeout; 130 interrupted.
|
|
46
|
+
An orderly stop writes a result file; only a Host that died leaves none behind.`;
|
|
47
|
+
|
|
48
|
+
const COMMANDS = ['run', 'fire', 'emit', 'events', 'list', 'show', 'log', 'new', 'enable', 'disable', 'install', 'uninstall'];
|
|
49
|
+
|
|
50
|
+
/** Parse the arguments after `trigger`. */
|
|
51
|
+
export function parseTriggerArgs(argv) {
|
|
52
|
+
const options = { command: '', id: '', event: undefined, text: undefined, eventId: undefined, project: undefined, workspace: undefined, failed: false, help: false, error: undefined };
|
|
53
|
+
const words = [...argv];
|
|
54
|
+
options.command = words.shift() ?? '';
|
|
55
|
+
if (options.command === '' || options.command === '--help' || options.command === '-h') { options.help = true; return options; }
|
|
56
|
+
if (!COMMANDS.includes(options.command)) { options.error = `unknown command "${options.command}"`; return options; }
|
|
57
|
+
if (['list', 'log'].includes(options.command)) {
|
|
58
|
+
if (words.length > 0 && !words[0].startsWith('-')) options.id = words.shift();
|
|
59
|
+
} else {
|
|
60
|
+
const id = words.shift() ?? '';
|
|
61
|
+
if (id === '' || id.startsWith('-')) { options.error = `${options.command} needs a trigger id`; return options; }
|
|
62
|
+
options.id = id;
|
|
63
|
+
}
|
|
64
|
+
while (words.length > 0) {
|
|
65
|
+
const flag = words.shift();
|
|
66
|
+
const value = () => { const next = words.shift(); if (next === undefined) { options.error = `${flag} needs a value`; return undefined; } return next; };
|
|
67
|
+
if (flag === '--event') { const next = value(); if (next === undefined) return options; options.event = next; continue; }
|
|
68
|
+
if (flag === '--text') { const next = value(); if (next === undefined) return options; options.text = next; continue; }
|
|
69
|
+
if (flag === '--event-id') { const next = value(); if (next === undefined) return options; options.eventId = next; continue; }
|
|
70
|
+
if (flag === '--project') { const next = value(); if (next === undefined) return options; options.project = next; continue; }
|
|
71
|
+
if (flag === '--workspace') { const next = value(); if (next === undefined) return options; options.workspace = next; continue; }
|
|
72
|
+
if (flag === '--failed') { options.failed = true; continue; }
|
|
73
|
+
if (flag === '-h' || flag === '--help') { options.help = true; return options; }
|
|
74
|
+
options.error = `unknown option "${flag}"`;
|
|
75
|
+
return options;
|
|
76
|
+
}
|
|
77
|
+
return options;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The identity of a firing the scheduler produced. It must be stable per cadence
|
|
82
|
+
* window, not per invocation: a replayed or duplicated scheduler tick inside the
|
|
83
|
+
* same window has to be recognised as the same firing, which is what the run
|
|
84
|
+
* decision de-duplicates on. External producers pass their own id instead.
|
|
85
|
+
* @param definition - a normalized definition.
|
|
86
|
+
* @param now - the invocation time.
|
|
87
|
+
* @returns an identity string recorded with the run.
|
|
88
|
+
*/
|
|
89
|
+
export function firingIdentity(definition, now) {
|
|
90
|
+
const minute = new Date(now).toISOString().slice(0, 16);
|
|
91
|
+
switch (definition.source.kind) {
|
|
92
|
+
case 'interval': return `interval:${Math.floor(now / (definition.source.seconds * 1000))}`;
|
|
93
|
+
case 'poll': return `poll:${Math.floor(now / (definition.source.everySeconds * 1000))}`;
|
|
94
|
+
case 'calendar': return `calendar:${minute}`;
|
|
95
|
+
// A file event has no payload of its own, so changes inside one minute
|
|
96
|
+
// collapse into one run rather than firing a session per touched file.
|
|
97
|
+
case 'watch': return `watch:${minute}`;
|
|
98
|
+
default: return `manual:${new Date(now).toISOString()}`;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The state directory: the child gets it as DSH_HOME, so all three agree. */
|
|
103
|
+
export function stateHome(env = {}) {
|
|
104
|
+
const value = env.DSH_HOME ?? env.DSCODE_HOME;
|
|
105
|
+
return value === undefined ? undefined : resolve(value);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A starter definition a person can edit; the file name must equal the id. */
|
|
109
|
+
export function scaffoldTrigger(id, { workspace }) {
|
|
110
|
+
return [
|
|
111
|
+
`id: ${id}`,
|
|
112
|
+
`workspace: ${workspace}`,
|
|
113
|
+
'prompt: describe what this run should do',
|
|
114
|
+
'source: { kind: interval, seconds: 300 }',
|
|
115
|
+
'goal: { objective: describe the finished state, maxRounds: 20 }',
|
|
116
|
+
'limits: { timeoutSeconds: 1800, maxRunsPerDay: 24, minIntervalSeconds: 60 }',
|
|
117
|
+
'',
|
|
118
|
+
].join('\n');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Set the `enabled` flag in one definition file's text, preserving everything
|
|
123
|
+
* else byte for byte: these files are hand-written and reviewed in a pull
|
|
124
|
+
* request, so an edit must not reformat them.
|
|
125
|
+
* @param text - the file's current text.
|
|
126
|
+
* @param enabled - the value to set.
|
|
127
|
+
* @returns the new text.
|
|
128
|
+
*/
|
|
129
|
+
export function setEnabledFlag(text, enabled) {
|
|
130
|
+
// Matching a top-level line is only safe for a single-document file: a second
|
|
131
|
+
// document could carry its own `id:` and be edited instead of this one.
|
|
132
|
+
if (/^(?:---|\.\.\.)\s*$/mu.test(text.replace(/^---\s*\n/u, ''))) {
|
|
133
|
+
throw new Error('this file holds more than one YAML document; set "enabled" by hand');
|
|
134
|
+
}
|
|
135
|
+
const line = `enabled: ${enabled ? 'true' : 'false'}`;
|
|
136
|
+
const next = /^enabled:[^\n]*$/mu.test(text) ? text.replace(/^enabled:[^\n]*$/mu, line)
|
|
137
|
+
: (() => {
|
|
138
|
+
const idLine = /^id:[^\n]*$/mu.exec(text);
|
|
139
|
+
return idLine === null ? `${line}\n${text}`
|
|
140
|
+
: `${text.slice(0, idLine.index + idLine[0].length)}\n${line}${text.slice(idLine.index + idLine[0].length)}`;
|
|
141
|
+
})();
|
|
142
|
+
// The edit is a line match, so prove what it did: parse both sides and require
|
|
143
|
+
// every other top-level key to be untouched. A quoted multi-line scalar can put
|
|
144
|
+
// a column-0 `id:`/`enabled:` line inside a value, which the match alone cannot
|
|
145
|
+
// tell from a key — this catches it instead of corrupting the prompt.
|
|
146
|
+
if (!sameExceptEnabled(text, next, enabled)) {
|
|
147
|
+
throw new Error('refusing to edit: the change would alter more than the enabled flag; set it by hand');
|
|
148
|
+
}
|
|
149
|
+
return next;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** True when two definition texts parse to the same mapping apart from `enabled`. */
|
|
153
|
+
function sameExceptEnabled(before, after, enabled) {
|
|
154
|
+
const canonical = value => JSON.stringify(Object.fromEntries(
|
|
155
|
+
Object.entries(value ?? {}).sort(([left], [right]) => left.localeCompare(right)),
|
|
156
|
+
));
|
|
157
|
+
try {
|
|
158
|
+
return canonical({ ...parseYaml(before), enabled }) === canonical(parseYaml(after));
|
|
159
|
+
} catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Read an event body from a file, stdin, or the `--text` shorthand. */
|
|
165
|
+
async function readEventBody(options, stdin) {
|
|
166
|
+
if (options.text !== undefined) return { text: options.text, source: 'cli' };
|
|
167
|
+
if (options.event === undefined) return undefined;
|
|
168
|
+
const fromStdin = options.event === '-';
|
|
169
|
+
const raw = fromStdin ? await readStream(stdin) : readFileSync(options.event, 'utf8');
|
|
170
|
+
if (raw.trim() === '') throw new Error(`${fromStdin ? 'stdin' : options.event} is empty: an event body is required`);
|
|
171
|
+
try {
|
|
172
|
+
const body = JSON.parse(raw);
|
|
173
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('it must be a JSON object');
|
|
174
|
+
return body;
|
|
175
|
+
} catch (error) {
|
|
176
|
+
throw new Error(`${fromStdin ? 'stdin' : options.event} is not a valid event body: ${error?.message ?? error}`, { cause: error });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function readStream(stream) {
|
|
181
|
+
return new Promise((resolveRead, reject) => {
|
|
182
|
+
let text = '';
|
|
183
|
+
stream.setEncoding('utf8');
|
|
184
|
+
stream.on('data', chunk => { text += chunk; });
|
|
185
|
+
stream.on('end', () => resolveRead(text));
|
|
186
|
+
stream.on('error', reject);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Resolve one definition by id from the user and project layers. */
|
|
191
|
+
function findDefinition(home, project, id) {
|
|
192
|
+
const { definitions, problems } = loadTriggerDefinitions({ home, workspace: project });
|
|
193
|
+
const definition = definitions.find(entry => entry.id === id);
|
|
194
|
+
if (definition === undefined) {
|
|
195
|
+
const known = definitions.map(entry => entry.id).join(', ') || '(none)';
|
|
196
|
+
throw new Error(`no trigger "${id}" in ${join(home ?? '', 'triggers')} or ${join(project, '.dsh', 'triggers')}; known ids: ${known}${problems.length === 0 ? '' : ` (${problems.length} unreadable definition file(s))`}`);
|
|
197
|
+
}
|
|
198
|
+
return definition;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Map a bare exit code to a record when the Host could not report one. */
|
|
202
|
+
export function outcomeForCode(code) {
|
|
203
|
+
if (!Number.isSafeInteger(code)) return { outcome: 'failed', reason: 'interrupted' };
|
|
204
|
+
if (code === 0) return { outcome: 'completed', reason: null };
|
|
205
|
+
if (code === 2) return { outcome: 'overrun', reason: 'round_cap' };
|
|
206
|
+
if (code === 3) return { outcome: 'blocked', reason: 'goal_blocked' };
|
|
207
|
+
if (code === 124) return { outcome: 'timedout', reason: 'timeout' };
|
|
208
|
+
if (code === 130) return { outcome: 'failed', reason: 'interrupted' };
|
|
209
|
+
return { outcome: 'failed', reason: 'model_error' };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Record a decision that started no session, before any lock was taken.
|
|
214
|
+
* @returns the exit code, always 0: a skip is not a failure.
|
|
215
|
+
*/
|
|
216
|
+
function recordSkip(home, definition, { reason, eventId, now }) {
|
|
217
|
+
appendRun(home, {
|
|
218
|
+
triggerId: definition.id, runId: `skip-${now}`, startedAt: now, endedAt: now,
|
|
219
|
+
eventId, outcome: 'skipped', reason, exitCode: 0, cwd: definition.workspace,
|
|
220
|
+
});
|
|
221
|
+
return 0;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Record a run that WAS locked but started no session (a poll with nothing to
|
|
226
|
+
* do). It must go through `finishTriggerRun` with that same handle, or the lock
|
|
227
|
+
* outlives the attempt and every later drain reads `already_running`.
|
|
228
|
+
* @returns the exit code, always 0: nothing to do is not a failure.
|
|
229
|
+
*/
|
|
230
|
+
function recordLockedSkip(home, definition, handle, { reason, eventId, details, now }) {
|
|
231
|
+
const record = finishTriggerRun(home, handle, { outcome: 'skipped', reason, exitCode: 0, eventId, endedAt: now });
|
|
232
|
+
if (details !== undefined && details !== '') {
|
|
233
|
+
try { writeRunTail(home, definition.id, record.runId, details); } catch { /* the record still explains the skip */ }
|
|
234
|
+
}
|
|
235
|
+
return 0;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Run the CLI. `deps` supplies the process-facing parts; see the module header. */
|
|
239
|
+
export async function runTriggerCli(argv, deps = {}) {
|
|
240
|
+
const options = parseTriggerArgs(argv);
|
|
241
|
+
const out = text => (deps.stdout ?? process.stdout).write(text + '\n');
|
|
242
|
+
const err = text => (deps.stderr ?? process.stderr).write(text + '\n');
|
|
243
|
+
if (options.help) { out(USAGE); return 0; }
|
|
244
|
+
if (options.error !== undefined) { err(options.error); err(USAGE); return 1; }
|
|
245
|
+
|
|
246
|
+
const home = stateHome(deps.home === undefined ? process.env : { DSH_HOME: deps.home });
|
|
247
|
+
if (home === undefined) { err('dscode trigger needs a state directory: set DSH_HOME or DSCODE_HOME'); return 1; }
|
|
248
|
+
const project = resolve(options.project ?? deps.project ?? process.cwd());
|
|
249
|
+
const now = deps.now ?? Date.now();
|
|
250
|
+
const platform = deps.platform ?? process.platform;
|
|
251
|
+
// No fallback: the driver always knows its own entry point, and guessing here
|
|
252
|
+
// would write a schedule that runs the wrong program (or a module launchd
|
|
253
|
+
// cannot execute at all).
|
|
254
|
+
const dscodePath = deps.dscodePath;
|
|
255
|
+
const launchctl = deps.launchctl;
|
|
256
|
+
|
|
257
|
+
// A CLI function returns an exit code: an unknown id, an unreadable definition
|
|
258
|
+
// or a missing workspace is a user-facing message, not a thrown stack.
|
|
259
|
+
try {
|
|
260
|
+
if (options.command === 'list') {
|
|
261
|
+
const { definitions, problems } = loadTriggerDefinitions({ home, workspace: project });
|
|
262
|
+
if (definitions.length === 0 && problems.length === 0) { out('No triggers defined.'); return 0; }
|
|
263
|
+
for (const definition of definitions) out(formatTrigger(definition, { lastRun: readRuns(home, { triggerId: definition.id, limit: 1 })[0] }));
|
|
264
|
+
for (const problem of problems) err(`${problem.path}: ${problem.message}`);
|
|
265
|
+
return problems.length === 0 ? 0 : 1;
|
|
266
|
+
}
|
|
267
|
+
if (options.command === 'show') {
|
|
268
|
+
const definition = findDefinition(home, project, options.id);
|
|
269
|
+
out(formatTrigger(definition, { lastRun: readRuns(home, { triggerId: definition.id, limit: 1 })[0] }));
|
|
270
|
+
return 0;
|
|
271
|
+
}
|
|
272
|
+
if (options.command === 'log') {
|
|
273
|
+
const all = readRuns(home, options.id === '' ? {} : { triggerId: options.id });
|
|
274
|
+
const runs = options.failed ? all.filter(run => run.outcome !== 'completed' && run.outcome !== 'skipped') : all;
|
|
275
|
+
if (runs.length === 0) out(options.failed ? 'No failed runs recorded.' : 'No runs recorded.');
|
|
276
|
+
else for (const run of runs) out(`${run.triggerId} ${formatRun(run)}`);
|
|
277
|
+
return 0;
|
|
278
|
+
}
|
|
279
|
+
if (options.command === 'events') {
|
|
280
|
+
if (options.id === '') {
|
|
281
|
+
const { definitions } = loadTriggerDefinitions({ home, workspace: project });
|
|
282
|
+
let total = 0;
|
|
283
|
+
for (const definition of definitions) for (const event of listEvents(home, definition.id)) { out(`${definition.id} ${formatEvent(event)}`); total += 1; }
|
|
284
|
+
if (total === 0) out('No pending events.');
|
|
285
|
+
return 0;
|
|
286
|
+
}
|
|
287
|
+
const pending = listEvents(home, options.id);
|
|
288
|
+
if (pending.length === 0) out(`No pending events for ${options.id}.`);
|
|
289
|
+
else for (const event of pending) out(formatEvent(event));
|
|
290
|
+
return 0;
|
|
291
|
+
}
|
|
292
|
+
if (options.command === 'new') {
|
|
293
|
+
const directory = join(project, '.dsh', 'triggers');
|
|
294
|
+
const path = join(directory, `${options.id}.yml`);
|
|
295
|
+
if (existsSync(path)) { err(`${path} already exists`); return 1; }
|
|
296
|
+
const workspace = resolve(options.workspace ?? project);
|
|
297
|
+
mkdirSync(directory, { recursive: true });
|
|
298
|
+
writeFileSync(path, scaffoldTrigger(options.id, { workspace }), { mode: 0o644 });
|
|
299
|
+
out(`wrote ${path}`);
|
|
300
|
+
out('Edit the prompt, the goal objective and the source, then run it with: dscode trigger run ' + options.id);
|
|
301
|
+
return 0;
|
|
302
|
+
}
|
|
303
|
+
if (options.command === 'enable' || options.command === 'disable') {
|
|
304
|
+
const definition = findDefinition(home, project, options.id);
|
|
305
|
+
if (definition.path === '') { err(`cannot edit ${options.id}: its definition path is unknown`); return 1; }
|
|
306
|
+
const enabled = options.command === 'enable';
|
|
307
|
+
const text = readFileSync(definition.path, 'utf8');
|
|
308
|
+
const next = setEnabledFlag(text, enabled);
|
|
309
|
+
if (next !== text) writeFileSync(definition.path, next, { mode: 0o644 });
|
|
310
|
+
out(`${enabled ? 'enabled' : 'disabled'} ${options.id} in ${definition.path}`);
|
|
311
|
+
return 0;
|
|
312
|
+
}
|
|
313
|
+
if (options.command === 'install' || options.command === 'uninstall') {
|
|
314
|
+
const definition = findDefinition(home, project, options.id);
|
|
315
|
+
const path = agentPath(home, definition.id);
|
|
316
|
+
if (options.command === 'uninstall') {
|
|
317
|
+
if (launchctl !== undefined && platform === 'darwin') await launchctl(['bootout', `gui/${process.getuid?.() ?? ''}/${'ai.dscode.trigger.' + definition.id}`], { ignoreFailure: true });
|
|
318
|
+
rmSync(path, { force: true });
|
|
319
|
+
out(`removed the schedule for ${options.id} (${path})`);
|
|
320
|
+
return 0;
|
|
321
|
+
}
|
|
322
|
+
if (dscodePath === undefined) {
|
|
323
|
+
err(`cannot install ${options.id}: this build does not know its own dscode program path`);
|
|
324
|
+
return 1;
|
|
325
|
+
}
|
|
326
|
+
// launchd executes the program directly: it must be a real, executable file.
|
|
327
|
+
let runnable = existsSync(dscodePath);
|
|
328
|
+
if (runnable) {
|
|
329
|
+
try { runnable = (statSync(dscodePath).mode & 0o111) !== 0; } catch { runnable = false; }
|
|
330
|
+
}
|
|
331
|
+
if (!runnable) {
|
|
332
|
+
err(`cannot install ${options.id}: ${dscodePath} is not an executable file, and launchd runs no shell profile`);
|
|
333
|
+
return 1;
|
|
334
|
+
}
|
|
335
|
+
const plist = launchAgent(definition, { home, dscodePath, project });
|
|
336
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
337
|
+
writeFileSync(path, plist, { mode: 0o600 });
|
|
338
|
+
out(`wrote ${path}`);
|
|
339
|
+
if (platform !== 'darwin' || launchctl === undefined) {
|
|
340
|
+
out('launchd is not available here; add this line to crontab instead:');
|
|
341
|
+
} else {
|
|
342
|
+
await launchctl(['bootout', `gui/${process.getuid?.() ?? ''}/ai.dscode.trigger.${definition.id}`], { ignoreFailure: true });
|
|
343
|
+
await launchctl(['bootstrap', `gui/${process.getuid?.() ?? ''}`, path]);
|
|
344
|
+
out(`loaded ${'ai.dscode.trigger.' + definition.id} into launchd; the crontab equivalent is:`);
|
|
345
|
+
}
|
|
346
|
+
out(` ${crontabLine(definition, { dscodePath, project })}`);
|
|
347
|
+
return 0;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const definition = findDefinition(home, project, options.id);
|
|
351
|
+
if (options.command === 'emit') {
|
|
352
|
+
const body = (await readEventBody(options, deps.stdin ?? process.stdin)) ?? {};
|
|
353
|
+
const event = emitEvent(home, definition.id, { source: 'cli', ...body }, { eventId: options.eventId ?? deps.eventId ?? `manual-${new Date(now).toISOString()}`, now });
|
|
354
|
+
out(`posted ${event.eventId} to ${definition.id} (${listEvents(home, definition.id).length} pending)`);
|
|
355
|
+
return 0;
|
|
356
|
+
}
|
|
357
|
+
let fired;
|
|
358
|
+
if (options.command === 'fire') {
|
|
359
|
+
const body = (await readEventBody(options, deps.stdin ?? process.stdin)) ?? {};
|
|
360
|
+
fired = emitEvent(home, definition.id, { source: 'cli', ...body }, { eventId: options.eventId ?? deps.eventId ?? `fire-${new Date(now).toISOString()}`, now });
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const pending = listEvents(home, definition.id);
|
|
364
|
+
const wanted = options.eventId ?? deps.eventId;
|
|
365
|
+
const matching = wanted === undefined ? undefined : pending.find(event => event.eventId === wanted);
|
|
366
|
+
if (wanted !== undefined && fired === undefined && matching === undefined) {
|
|
367
|
+
// Naming an identity that is not waiting would otherwise run the trigger
|
|
368
|
+
// with no event payload at all, which is never what the caller meant.
|
|
369
|
+
err(`no pending event "${wanted}" for ${definition.id}; post it with "emit", or omit --event-id`);
|
|
370
|
+
return 1;
|
|
371
|
+
}
|
|
372
|
+
const chosen = fired ?? matching ?? (wanted === undefined ? pending[0] : undefined);
|
|
373
|
+
const eventId = chosen?.eventId ?? wanted ?? firingIdentity(definition, now);
|
|
374
|
+
const plan = planTriggerRun(definition, { home, now, eventId });
|
|
375
|
+
if (plan.action === 'skip') {
|
|
376
|
+
err(`skipped ${definition.id}: ${plan.reason}`);
|
|
377
|
+
return recordSkip(home, definition, { reason: plan.reason, eventId, now });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// A poll source only runs when its predicate says there is work; without a
|
|
381
|
+
// resident listener this scheduled invocation IS the poll.
|
|
382
|
+
if (definition.source.kind === 'poll') {
|
|
383
|
+
const check = await evaluateCheck(definition.source.check, { cwd: definition.workspace, timeoutMs: 60000, ...(deps.spawnCheck === undefined ? {} : { spawn: deps.spawnCheck }) });
|
|
384
|
+
if (!check.matched) {
|
|
385
|
+
err(`nothing to do for ${definition.id} (${check.code === null ? 'the check did not finish' : `check exited ${check.code}`})`);
|
|
386
|
+
return recordLockedSkip(home, definition, plan.handle, { reason: 'no_match', eventId, details: check.output, now });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const spec = {
|
|
391
|
+
triggerId: definition.id,
|
|
392
|
+
runId: plan.handle.runId,
|
|
393
|
+
workspace: definition.workspace,
|
|
394
|
+
prompt: renderPrompt(definition.prompt, chosen),
|
|
395
|
+
preset: definition.preset,
|
|
396
|
+
permission: definition.permission,
|
|
397
|
+
...(definition.model === undefined ? {} : { model: definition.model }),
|
|
398
|
+
...(definition.effort === undefined ? {} : { effort: definition.effort }),
|
|
399
|
+
goal: { objective: definition.goal.objective, maxRounds: definition.goal.maxRounds },
|
|
400
|
+
limits: definition.limits,
|
|
401
|
+
eventId,
|
|
402
|
+
};
|
|
403
|
+
let code;
|
|
404
|
+
let result;
|
|
405
|
+
try {
|
|
406
|
+
({ code, result } = await (deps.spawnRun ?? missingSpawn)({ spec, home, cwd: definition.workspace }));
|
|
407
|
+
} catch (error) {
|
|
408
|
+
// The Host never ran, so the event is still pending and the lock must not
|
|
409
|
+
// outlive the attempt: a later drain should be free to try it again.
|
|
410
|
+
releaseTriggerRun(home, plan.handle);
|
|
411
|
+
throw error;
|
|
412
|
+
}
|
|
413
|
+
// Consumed only once the Host has actually run: a failure before that leaves
|
|
414
|
+
// the event pending rather than dropping it.
|
|
415
|
+
if (chosen !== undefined) consumeEvent(home, definition.id, chosen.eventId);
|
|
416
|
+
const fallback = outcomeForCode(code);
|
|
417
|
+
const record = finishTriggerRun(home, plan.handle, {
|
|
418
|
+
outcome: result?.outcome ?? fallback.outcome,
|
|
419
|
+
reason: result?.reason ?? fallback.reason,
|
|
420
|
+
// A Host killed by a signal reports no code; the record still needs one.
|
|
421
|
+
exitCode: Number.isSafeInteger(result?.exitCode) ? result.exitCode : (Number.isSafeInteger(code) ? code : 130),
|
|
422
|
+
sessionId: result?.sessionId ?? null,
|
|
423
|
+
cost: result?.cost ?? null,
|
|
424
|
+
rounds: result?.rounds ?? null,
|
|
425
|
+
eventId,
|
|
426
|
+
endedAt: Date.now(),
|
|
427
|
+
});
|
|
428
|
+
out(`${definition.id} ${formatRun(record)}${record.sessionId === null ? '' : ` · ${record.sessionId}`}`);
|
|
429
|
+
return record.exitCode;
|
|
430
|
+
} catch (error) {
|
|
431
|
+
err(error?.message ?? String(error));
|
|
432
|
+
return 1;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** The launcher and the source driver both must inject a spawn. */
|
|
437
|
+
function missingSpawn() {
|
|
438
|
+
throw new Error('this build has no way to start a run: inject spawnRun');
|
|
439
|
+
}
|