dsh-hooks 0.2.1

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/lib/events.js ADDED
@@ -0,0 +1,213 @@
1
+ /** Per-session turn start timestamps for duration reporting. */
2
+ const turnStarts = new Map();
3
+ function sessionKey(session) {
4
+ return String(session.id);
5
+ }
6
+ /** Best-effort access to a session's event log (test fakes may omit it). */
7
+ function sessionEvents(session) {
8
+ return Array.isArray(session.events) ? session.events : [];
9
+ }
10
+ /** Concatenate the text blocks of a message's content, or undefined. */
11
+ function textOfBlocks(content) {
12
+ if (!Array.isArray(content))
13
+ return undefined;
14
+ const parts = [];
15
+ for (const block of content) {
16
+ if (block && block.type === 'text' && typeof block.text === 'string')
17
+ parts.push(block.text);
18
+ }
19
+ const text = parts.join('\n\n').trim();
20
+ return text || undefined;
21
+ }
22
+ /** Terminal-safe single line for a title: strip control/escape sequences. */
23
+ function oneLineTitle(input) {
24
+ return String(input)
25
+ .replace(/\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001B\\))/g, '')
26
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '')
27
+ .replace(/\s+/g, ' ')
28
+ .trim();
29
+ }
30
+ /**
31
+ * Readable session title for notification cards. Mirrors the harness
32
+ * session-title conventions without depending on the title service:
33
+ * prefer the latest `session/title` log event (explicit rename, LLM title, or
34
+ * deterministic fallback), otherwise derive one from the first direct human
35
+ * prompt, as `dsh-session-title`'s fallback does.
36
+ */
37
+ export function sessionTitle(session) {
38
+ const events = sessionEvents(session);
39
+ for (let i = events.length - 1; i >= 0; i--) {
40
+ const event = events[i];
41
+ if (event.type !== 'session/title')
42
+ continue;
43
+ const title = oneLineTitle(event.data?.title);
44
+ if (title)
45
+ return title.slice(0, 60);
46
+ }
47
+ for (const event of events) {
48
+ if (event.type !== 'user/message')
49
+ continue;
50
+ if (event.data.source.kind !== 'user')
51
+ continue;
52
+ const text = textOfBlocks(event.data.content);
53
+ if (!text)
54
+ continue;
55
+ const title = oneLineTitle(text);
56
+ if (title)
57
+ return title.slice(0, 60);
58
+ }
59
+ return undefined;
60
+ }
61
+ /**
62
+ * The turn's final assistant text, from the last `assistant/message` of that
63
+ * turn. Capped so the environment snapshot stays small — card builders apply
64
+ * their own display truncation.
65
+ */
66
+ export function turnContent(session, turn) {
67
+ let out;
68
+ for (const event of sessionEvents(session)) {
69
+ if (event.type !== 'assistant/message')
70
+ continue;
71
+ if (event.data.turn !== turn)
72
+ continue;
73
+ const text = textOfBlocks(event.data.message.content);
74
+ if (text)
75
+ out = text;
76
+ }
77
+ return out === undefined ? undefined : out.slice(0, 4000);
78
+ }
79
+ export function rememberTurnStart(session) {
80
+ turnStarts.set(sessionKey(session), Date.now());
81
+ }
82
+ function takeDuration(session) {
83
+ const key = sessionKey(session);
84
+ const started = turnStarts.get(key);
85
+ turnStarts.delete(key);
86
+ return started === undefined ? undefined : Date.now() - started;
87
+ }
88
+ export function clearTurnTracking(session) {
89
+ turnStarts.delete(sessionKey(session));
90
+ }
91
+ /** Does a declared hook match this event (type + optional `when` filter)? */
92
+ export function hookMatches(spec, event, reasonKind) {
93
+ if (spec.on !== event)
94
+ return false;
95
+ if (spec.when === undefined)
96
+ return true;
97
+ // v1 `when` semantics: only `turn/end` carries a reason to filter on.
98
+ if (event !== 'turn/end')
99
+ return true;
100
+ return spec.when === reasonKind;
101
+ }
102
+ export function turnEndContext(session, turn, reason) {
103
+ const kind = typeof reason === 'string' ? reason : reason.kind;
104
+ let error;
105
+ if (typeof reason === 'object' && reason !== null && kind === 'error') {
106
+ const failure = reason.error;
107
+ if (typeof failure?.message === 'string')
108
+ error = failure.message;
109
+ }
110
+ return {
111
+ event: 'turn/end',
112
+ sessionId: sessionKey(session),
113
+ sessionName: sessionTitle(session),
114
+ cwd: session.header.cwd,
115
+ turn,
116
+ reason: kind,
117
+ durationMs: takeDuration(session),
118
+ error,
119
+ content: turnContent(session, turn),
120
+ timestamp: new Date().toISOString(),
121
+ };
122
+ }
123
+ export function turnStartContext(session, turn) {
124
+ return {
125
+ event: 'turn/start',
126
+ sessionId: sessionKey(session),
127
+ sessionName: sessionTitle(session),
128
+ cwd: session.header.cwd,
129
+ turn,
130
+ timestamp: new Date().toISOString(),
131
+ };
132
+ }
133
+ export function approvalContext(session, data) {
134
+ return {
135
+ event: 'approval/asked',
136
+ sessionId: sessionKey(session),
137
+ sessionName: sessionTitle(session),
138
+ cwd: session.header.cwd,
139
+ tool: data.toolName,
140
+ callId: data.callId,
141
+ reason: data.reason,
142
+ timestamp: new Date().toISOString(),
143
+ };
144
+ }
145
+ export function agentCreatedContext(agent) {
146
+ return {
147
+ event: 'agent/created',
148
+ sessionId: String(agent.id),
149
+ timestamp: new Date().toISOString(),
150
+ };
151
+ }
152
+ export function agentDisposedContext(agent) {
153
+ return {
154
+ event: 'agent/disposed',
155
+ sessionId: String(agent.id),
156
+ timestamp: new Date().toISOString(),
157
+ };
158
+ }
159
+ export function agentErrorContext(agent, turn, error) {
160
+ return {
161
+ event: 'agent/error',
162
+ sessionId: String(agent.id),
163
+ turn,
164
+ error: errorText(error),
165
+ timestamp: new Date().toISOString(),
166
+ };
167
+ }
168
+ export function agentStatusContext(agent, status) {
169
+ return {
170
+ event: 'agent/status',
171
+ sessionId: String(agent.id),
172
+ status: statusText(status),
173
+ timestamp: new Date().toISOString(),
174
+ };
175
+ }
176
+ /** Classify a session event into a hook context, or undefined when unmapped. */
177
+ export function classifySessionEvent(session, event) {
178
+ switch (event.type) {
179
+ case 'turn/start':
180
+ rememberTurnStart(session);
181
+ return turnStartContext(session, event.data.turn);
182
+ case 'turn/end':
183
+ return turnEndContext(session, event.data.turn, event.data.reason);
184
+ case 'approval/asked':
185
+ return approvalContext(session, event.data);
186
+ default:
187
+ return undefined;
188
+ }
189
+ }
190
+ /** Best-effort error text from an arbitrary thrown value. */
191
+ export function errorText(error) {
192
+ if (error instanceof Error)
193
+ return error.message;
194
+ if (typeof error === 'string')
195
+ return error;
196
+ try {
197
+ return JSON.stringify(error);
198
+ }
199
+ catch {
200
+ return String(error);
201
+ }
202
+ }
203
+ /** Best-effort status text from an agent status payload. */
204
+ export function statusText(status) {
205
+ if (typeof status === 'string')
206
+ return status;
207
+ if (typeof status === 'object' && status !== null && 'kind' in status) {
208
+ const kind = status.kind;
209
+ if (typeof kind === 'string')
210
+ return kind;
211
+ }
212
+ return String(status);
213
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import './types.js';
3
+ import { Config } from './config.js';
4
+ import { clearTurnTracking } from './events.js';
5
+ export declare const name = "dsh-hooks";
6
+ export declare const inject: readonly ["sessions"];
7
+ export { Config };
8
+ export declare function apply(ctx: Context, config?: Config): void;
9
+ export declare const _internals: {
10
+ clearTurnTracking: typeof clearTurnTracking;
11
+ };
package/lib/index.js ADDED
@@ -0,0 +1,56 @@
1
+ import './types.js';
2
+ import { Config } from './config.js';
3
+ import { agentCreatedContext, agentDisposedContext, agentErrorContext, agentStatusContext, classifySessionEvent, clearTurnTracking, hookMatches, } from './events.js';
4
+ import { createHookRunner } from './runner.js';
5
+ export const name = 'dsh-hooks';
6
+ // Dependency on the session service: `session/event` only exists once a
7
+ // SessionStore is composed, and this plugin consumes the durable firehose.
8
+ export const inject = ['sessions'];
9
+ export { Config };
10
+ export function apply(ctx, config = {}) {
11
+ const hooks = config.hooks ?? [];
12
+ const runner = createHookRunner((line) => ctx.logger?.info(line));
13
+ const runMatching = (ctxValue, reasonKind) => {
14
+ for (const hook of hooks) {
15
+ if (!hookMatches(hook, ctxValue.event, reasonKind))
16
+ continue;
17
+ runner.run(hook, ctxValue);
18
+ }
19
+ };
20
+ // Durable session firehose: turn boundaries and approval requests.
21
+ ctx.on('session/event', (session, event) => {
22
+ const classified = classifySessionEvent(session, event);
23
+ if (classified === undefined)
24
+ return;
25
+ const reasonKind = extractReasonKind(event);
26
+ runMatching(classified, reasonKind);
27
+ });
28
+ // Agent lifecycle events.
29
+ ctx.on('agent/created', (payload) => {
30
+ runMatching(agentCreatedContext(payload.agent));
31
+ });
32
+ ctx.on('agent/disposed', (payload) => {
33
+ runMatching(agentDisposedContext(payload.agent));
34
+ });
35
+ ctx.on('agent/error', (payload) => {
36
+ runMatching(agentErrorContext(payload.agent, payload.turn, payload.error));
37
+ });
38
+ ctx.on('agent/status', (payload) => {
39
+ runMatching(agentStatusContext(payload.agent, payload.status));
40
+ });
41
+ ctx.effect(() => () => {
42
+ runner.dispose();
43
+ });
44
+ }
45
+ /** Extract the `turn/end` reason kind from a session event, when present. */
46
+ function extractReasonKind(event) {
47
+ if (typeof event !== 'object' || event === null)
48
+ return undefined;
49
+ const e = event;
50
+ if (e.type !== 'turn/end')
51
+ return undefined;
52
+ return typeof e.data?.reason?.kind === 'string' ? e.data.reason.kind : undefined;
53
+ }
54
+ // Referenced only for tree-shaking clarity of the module contract; clearTurnTracking
55
+ // is exported for tests that need deterministic duration bookkeeping.
56
+ export const _internals = { clearTurnTracking };
@@ -0,0 +1,29 @@
1
+ import { type ChildProcess } from 'node:child_process';
2
+ import type { HookContext } from './context.js';
3
+ import type { HookSpec } from './config.js';
4
+ export interface RunOutcome {
5
+ ok: boolean;
6
+ reason: 'ran' | 'timeout' | 'spawn-failed' | 'skipped';
7
+ detail?: string;
8
+ }
9
+ /** Track in-flight hook runs so a missing parent never outlives teardown. */
10
+ export interface HookRunner {
11
+ run(spec: HookSpec, ctx: HookContext): RunOutcome;
12
+ dispose(): void;
13
+ }
14
+ export declare const DEFAULT_TIMEOUT_MS = 10000;
15
+ /**
16
+ * Terminate a spawned hook process. With `shell: true` on Windows the direct
17
+ * child is cmd.exe — killing only the shell orphans the actual hook command
18
+ * (e.g. `node notify-feishu.mjs`), so kill the whole tree first. The direct
19
+ * kill stays as the fallback (and the only path off Windows).
20
+ */
21
+ export declare function terminate(child: ChildProcess): void;
22
+ /**
23
+ * Fire-and-forget command runner. Emissions are irreversible side effects:
24
+ * failures only warn, never retried, never block the agent loop.
25
+ * Context travels through environment variables (no data interpolation into
26
+ * the shell string); `{{var}}` placeholders are substituted from the same
27
+ * map for explicit templating by the user.
28
+ */
29
+ export declare function createHookRunner(log?: (line: string) => void): HookRunner;
package/lib/runner.js ADDED
@@ -0,0 +1,70 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { eventLabel, renderTemplate, toEnv } from './context.js';
3
+ export const DEFAULT_TIMEOUT_MS = 10000;
4
+ /**
5
+ * Terminate a spawned hook process. With `shell: true` on Windows the direct
6
+ * child is cmd.exe — killing only the shell orphans the actual hook command
7
+ * (e.g. `node notify-feishu.mjs`), so kill the whole tree first. The direct
8
+ * kill stays as the fallback (and the only path off Windows).
9
+ */
10
+ export function terminate(child) {
11
+ if (process.platform === 'win32' && child.pid !== undefined) {
12
+ try {
13
+ spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' }).unref();
14
+ }
15
+ catch {
16
+ // taskkill unavailable — fall through to the direct kill below.
17
+ }
18
+ }
19
+ child.kill();
20
+ }
21
+ /**
22
+ * Fire-and-forget command runner. Emissions are irreversible side effects:
23
+ * failures only warn, never retried, never block the agent loop.
24
+ * Context travels through environment variables (no data interpolation into
25
+ * the shell string); `{{var}}` placeholders are substituted from the same
26
+ * map for explicit templating by the user.
27
+ */
28
+ export function createHookRunner(log = console.log) {
29
+ const children = new Set();
30
+ function run(spec, ctx) {
31
+ const timeoutMs = spec.timeoutMs ?? DEFAULT_TIMEOUT_MS;
32
+ const env = toEnv(ctx);
33
+ const command = renderTemplate(spec.run, ctx);
34
+ log(`[dsh-hooks] 触发 ${eventLabel(ctx)} → ${command}`);
35
+ let child;
36
+ try {
37
+ child = spawn(command, {
38
+ shell: true,
39
+ env: { ...process.env, ...env },
40
+ stdio: 'ignore',
41
+ });
42
+ }
43
+ catch (error) {
44
+ const detail = error instanceof Error ? error.message : String(error);
45
+ console.warn(`[dsh-hooks] spawn 失败 (${eventLabel(ctx)}): ${detail}`);
46
+ return { ok: false, reason: 'spawn-failed', detail };
47
+ }
48
+ children.add(child);
49
+ const timer = setTimeout(() => {
50
+ terminate(child);
51
+ console.warn(`[dsh-hooks] 超时(${timeoutMs}ms),已终止:${eventLabel(ctx)}`);
52
+ }, timeoutMs);
53
+ // Never hold the process open for a hook.
54
+ child.unref();
55
+ child.on('error', (error) => {
56
+ console.warn(`[dsh-hooks] 执行出错 (${eventLabel(ctx)}): ${error.message}`);
57
+ });
58
+ child.on('close', () => {
59
+ clearTimeout(timer);
60
+ children.delete(child);
61
+ });
62
+ return { ok: true, reason: 'ran' };
63
+ }
64
+ function dispose() {
65
+ for (const child of children)
66
+ terminate(child);
67
+ children.clear();
68
+ }
69
+ return { run, dispose };
70
+ }
package/lib/types.d.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Event declarations for the `agent/*` lifecycle events emitted by
3
+ * `@deepseek-ai/dsh-agent`. This plugin does not hard-depend on dsh-agent:
4
+ * the payload shapes are structural and declared here so `ctx.on` calls
5
+ * type-check against the harness Events map without pulling the agent
6
+ * package into the dependency graph.
7
+ */
8
+ export interface AgentLike {
9
+ id: unknown;
10
+ }
11
+ declare module '@deepseek-ai/cordis' {
12
+ interface Events {
13
+ 'agent/created'(this: {
14
+ id: unknown;
15
+ }, payload: {
16
+ agent: AgentLike;
17
+ }): void;
18
+ 'agent/disposed'(this: {
19
+ id: unknown;
20
+ }, payload: {
21
+ agent: AgentLike;
22
+ }): void;
23
+ 'agent/error'(this: {
24
+ id: unknown;
25
+ }, payload: {
26
+ agent: AgentLike;
27
+ turn?: number;
28
+ step?: number;
29
+ error?: unknown;
30
+ }): void;
31
+ 'agent/status'(this: {
32
+ id: unknown;
33
+ }, payload: {
34
+ agent: AgentLike;
35
+ status?: unknown;
36
+ }): void;
37
+ }
38
+ }
package/lib/types.js ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Event declarations for the `agent/*` lifecycle events emitted by
3
+ * `@deepseek-ai/dsh-agent`. This plugin does not hard-depend on dsh-agent:
4
+ * the payload shapes are structural and declared here so `ctx.on` calls
5
+ * type-check against the harness Events map without pulling the agent
6
+ * package into the dependency graph.
7
+ */
8
+ export {};
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "dsh-hooks",
3
+ "version": "0.2.1",
4
+ "packageManager": "pnpm@11.21.0",
5
+ "description": "Config-driven lifecycle hooks plugin for DeepSeek Harness: declare event -> command hooks in cordis.patch.yml, no plugin code required.",
6
+ "author": "PeterBon",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/PeterBon/dsh-hooks.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/PeterBon/dsh-hooks/issues"
14
+ },
15
+ "homepage": "https://github.com/PeterBon/dsh-hooks#readme",
16
+ "keywords": [
17
+ "dsh-plugin",
18
+ "deepseek",
19
+ "deepseek-harness",
20
+ "hooks",
21
+ "automation",
22
+ "lifecycle",
23
+ "notification"
24
+ ],
25
+ "type": "module",
26
+ "main": "lib/index.js",
27
+ "bin": {
28
+ "dsh-hooks": "./bin/dsh-hooks.mjs"
29
+ },
30
+ "exports": {
31
+ ".": "./lib/index.js",
32
+ "./package.json": "./package.json"
33
+ },
34
+ "files": [
35
+ "lib",
36
+ "bin",
37
+ "cordis.patch.yml",
38
+ "examples",
39
+ "README.md",
40
+ "README.zh.md",
41
+ "LICENSE"
42
+ ],
43
+ "scripts": {
44
+ "build": "tsc -p tsconfig.json",
45
+ "typecheck": "tsc -p tsconfig.json --noEmit",
46
+ "typecheck:test": "tsc -p tsconfig.test.json --noEmit",
47
+ "test": "vitest run",
48
+ "check": "pnpm run typecheck && pnpm run typecheck:test && pnpm run test && pnpm run build"
49
+ },
50
+ "engines": {
51
+ "node": ">=22"
52
+ },
53
+ "dsh": {
54
+ "bundle": {
55
+ "patch": "./cordis.patch.yml"
56
+ }
57
+ },
58
+ "peerDependencies": {
59
+ "@deepseek-ai/cordis": "^4.0.1",
60
+ "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
61
+ "@deepseek-ai/schemastery": "^3.18.1"
62
+ },
63
+ "devDependencies": {
64
+ "@deepseek-ai/cordis": "^4.0.1",
65
+ "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
66
+ "@deepseek-ai/schemastery": "^3.18.1",
67
+ "@types/node": "^22.10.0",
68
+ "typescript": "^5.6.0",
69
+ "vitest": "^3.0.0"
70
+ },
71
+ "dependencies": {
72
+ "@larksuiteoapi/node-sdk": "^1.73.0",
73
+ "qrcode": "^1.5.4",
74
+ "yaml": "^2.9.0"
75
+ }
76
+ }