dsh-hooks 0.4.0 → 0.6.0

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/history.js CHANGED
@@ -2,8 +2,14 @@
2
2
  * Hook execution history: an in-memory ring buffer plus a best-effort
3
3
  * JSONL append log under ~/.dsh/dsh-hooks/ (0600, owner-only). History is
4
4
  * strictly best-effort — a failed write never breaks a hook.
5
+ *
6
+ * The buffer is not process-private memory only: it seeds from the JSONL at
7
+ * startup and `sync()` incrementally ingests bytes appended since the last
8
+ * read, so records written before a restart (or by another dsh process
9
+ * sharing the file, e.g. a task-board Host) surface in the web GUI instead
10
+ * of vanishing with the process.
5
11
  */
6
- import { appendFileSync, chmodSync, mkdirSync } from 'node:fs';
12
+ import { appendFileSync, chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, } from 'node:fs';
7
13
  import { homedir } from 'node:os';
8
14
  import { dirname, join } from 'node:path';
9
15
  export const DEFAULT_HISTORY_PATH = join(homedir(), '.dsh', 'dsh-hooks', 'history.jsonl');
@@ -15,11 +21,101 @@ export function createHistorySink(options = {}) {
15
21
  const buffer = [];
16
22
  let dirReady = false;
17
23
  let chmodded = false;
18
- function record(partial) {
19
- const entry = { ...partial, ts: Date.now() };
24
+ /** Bytes of `file` already ingested into the buffer. */
25
+ let syncedBytes = 0;
26
+ /** Trailing fragment of the last read that did not end with a newline. */
27
+ let pending = '';
28
+ function push(entry) {
20
29
  buffer.push(entry);
21
30
  if (buffer.length > max)
22
31
  buffer.splice(0, buffer.length - max);
32
+ }
33
+ /** Parse complete JSONL lines into the ring buffer; incomplete tails stay pending. */
34
+ function ingest(text) {
35
+ pending += text;
36
+ const lines = pending.split('\n');
37
+ pending = lines.pop() ?? '';
38
+ for (const line of lines) {
39
+ if (line === '')
40
+ continue;
41
+ try {
42
+ const entry = JSON.parse(line);
43
+ if (typeof entry !== 'object' || entry === null || typeof entry.ts !== 'number')
44
+ continue;
45
+ push(entry);
46
+ }
47
+ catch {
48
+ // Broken line (mid-write or foreign content): skip, never fail.
49
+ }
50
+ }
51
+ }
52
+ /** Rebuild the buffer from the whole file (startup seed / truncated file). */
53
+ function rebuild() {
54
+ buffer.length = 0;
55
+ pending = '';
56
+ syncedBytes = 0;
57
+ const text = readFileSync(file, 'utf8');
58
+ syncedBytes = Buffer.byteLength(text, 'utf8');
59
+ ingest(text);
60
+ }
61
+ /** Seed the ring buffer from an existing JSONL log (best-effort). */
62
+ function seed() {
63
+ if (!enabled)
64
+ return;
65
+ try {
66
+ if (!existsSync(file))
67
+ return;
68
+ rebuild();
69
+ }
70
+ catch {
71
+ // Seeding is best-effort; recording starts from an empty buffer.
72
+ }
73
+ }
74
+ /** Ingest every byte appended since the last read (own writes included). */
75
+ function sync() {
76
+ if (!enabled)
77
+ return;
78
+ try {
79
+ if (!existsSync(file))
80
+ return;
81
+ const size = statSync(file).size;
82
+ if (size === syncedBytes)
83
+ return;
84
+ if (size < syncedBytes) {
85
+ // The file shrank (rotation/truncation): rebuild from its tail.
86
+ rebuild();
87
+ return;
88
+ }
89
+ const deltaBytes = size - syncedBytes;
90
+ const fd = openSync(file, 'r');
91
+ try {
92
+ const chunk = Buffer.allocUnsafe(deltaBytes);
93
+ let total = 0;
94
+ while (total < deltaBytes) {
95
+ const n = readSync(fd, chunk, total, deltaBytes - total, syncedBytes + total);
96
+ if (n <= 0)
97
+ break;
98
+ total += n;
99
+ }
100
+ ingest(chunk.subarray(0, total).toString('utf8'));
101
+ }
102
+ finally {
103
+ closeSync(fd);
104
+ }
105
+ syncedBytes = size;
106
+ }
107
+ catch {
108
+ // Sync is best-effort; the next call retries.
109
+ }
110
+ }
111
+ function record(partial) {
112
+ const entry = { ...partial, ts: Date.now() };
113
+ // Ingest other processes' appends BEFORE our own entry so the buffer
114
+ // stays in file order, and `syncedBytes` stays a true prefix of the
115
+ // file (otherwise our own advance would skip the foreign appends).
116
+ if (enabled)
117
+ sync();
118
+ push(entry);
23
119
  if (!enabled)
24
120
  return;
25
121
  try {
@@ -28,6 +124,7 @@ export function createHistorySink(options = {}) {
28
124
  dirReady = true;
29
125
  }
30
126
  appendFileSync(file, JSON.stringify(entry) + '\n', 'utf8');
127
+ syncedBytes = statSync(file).size;
31
128
  if (!chmodded) {
32
129
  try {
33
130
  chmodSync(file, 0o600);
@@ -42,9 +139,11 @@ export function createHistorySink(options = {}) {
42
139
  // History is best-effort: a failed write never breaks a hook.
43
140
  }
44
141
  }
142
+ seed();
45
143
  return {
46
144
  record,
47
145
  recent: () => buffer,
146
+ sync,
48
147
  dispose: () => { },
49
148
  };
50
149
  }
package/lib/index.js CHANGED
@@ -5,6 +5,7 @@ import { eventLabel } from './context.js';
5
5
  import { createHookRunner } from './runner.js';
6
6
  import { fireNotify } from './notify.js';
7
7
  import { createHistorySink } from './history.js';
8
+ import { createFeishuSetupManager } from './feishu-session.js';
8
9
  import { registerHookRoutes } from './server.js';
9
10
  export const name = 'dsh-hooks';
10
11
  // Dependency on the session service: `session/event` only exists once a
@@ -22,12 +23,20 @@ export function apply(ctx, config = {}) {
22
23
  const hooks = config.hooks ?? [];
23
24
  const history = createHistorySink(config.history ?? undefined);
24
25
  const runner = createHookRunner((line) => ctx.logger?.info(line), (record) => history.record(record));
25
- // Web-profile extras: /dsh-hooks routes and the agent announcement. Both
26
- // services are optional — CLI/headless profiles provide neither, and the
27
- // plugin keeps working there untouched.
26
+ // Web-profile extras: /dsh-hooks routes (incl. the Feishu connect flow)
27
+ // and the agent announcement. Both services are optional — CLI/headless
28
+ // profiles provide neither, and the plugin keeps working there untouched.
28
29
  const webServer = ctx.get('webServer', false);
29
30
  if (webServer !== undefined) {
30
- ctx.effect(() => registerHookRoutes(webServer, { hooks, history }), 'dsh-hooks: /dsh-hooks routes');
31
+ const feishu = createFeishuSetupManager();
32
+ ctx.effect(() => {
33
+ const unregister = registerHookRoutes(webServer, { hooks, history, runner, feishu: { manager: feishu } });
34
+ return () => {
35
+ unregister();
36
+ // Abort an in-flight QR scan so it never outlives the plugin.
37
+ feishu.dispose();
38
+ };
39
+ }, 'dsh-hooks: /dsh-hooks routes');
31
40
  }
32
41
  const systemPrompt = ctx.get('systemPrompt', false);
33
42
  if (systemPrompt !== undefined) {
package/lib/notify.d.ts CHANGED
@@ -24,5 +24,5 @@ export declare function sendWebhook(spec: NotifySpec, ctx: HookContext, env?: No
24
24
  * through shell-string interpolation.
25
25
  */
26
26
  export declare function sendDesktop(spec: NotifySpec, ctx: HookContext): Promise<NotifyResult>;
27
- /** Fire a built-in notification; failures only warn. */
28
- export declare function fireNotify(spec: NotifySpec, ctx: HookContext, record?: NotifyRecord): Promise<void>;
27
+ /** Fire a built-in notification; failures only warn and surface in the result. */
28
+ export declare function fireNotify(spec: NotifySpec, ctx: HookContext, record?: NotifyRecord): Promise<NotifyResult>;
package/lib/notify.js CHANGED
@@ -196,7 +196,7 @@ function runAndWait(argv, env, timeoutMs) {
196
196
  });
197
197
  });
198
198
  }
199
- /** Fire a built-in notification; failures only warn. */
199
+ /** Fire a built-in notification; failures only warn and surface in the result. */
200
200
  export async function fireNotify(spec, ctx, record) {
201
201
  const startedAt = Date.now();
202
202
  const result = spec.channel === 'webhook' ? await sendWebhook(spec, ctx) : await sendDesktop(spec, ctx);
@@ -212,7 +212,7 @@ export async function fireNotify(spec, ctx, record) {
212
212
  durationMs: Date.now() - startedAt,
213
213
  error: result.error,
214
214
  });
215
- return;
215
+ return result;
216
216
  }
217
217
  record?.({
218
218
  kind: 'notify',
@@ -223,4 +223,5 @@ export async function fireNotify(spec, ctx, record) {
223
223
  outcome: 'sent',
224
224
  durationMs: Date.now() - startedAt,
225
225
  });
226
+ return result;
226
227
  }
@@ -0,0 +1,47 @@
1
+ /** Wire shape for one hook as the settings panel sends it (string regexes). */
2
+ export interface HookWireSpec {
3
+ on: string;
4
+ when?: string;
5
+ match?: Record<string, string>;
6
+ run?: string;
7
+ notify?: {
8
+ channel: 'webhook' | 'desktop';
9
+ url?: string;
10
+ slack?: boolean;
11
+ } | null;
12
+ input?: 'env' | 'stdin';
13
+ timeoutMs?: number;
14
+ retries?: number;
15
+ retryDelayMs?: number;
16
+ }
17
+ /** Parse a patch list; throws a user-facing error on malformed YAML. */
18
+ export declare function parsePatchText(text: string): unknown[];
19
+ /**
20
+ * Validate the wire hooks before they ever touch a file. Returns a
21
+ * user-facing error message, or null when every hook is valid.
22
+ */
23
+ export declare function validateHookWire(hooks: HookWireSpec[]): string | null;
24
+ /**
25
+ * Replace the dsh-hooks block's hooks in a patch list. Other entries and
26
+ * other config of the dsh-hooks entry (e.g. `history`) stay untouched; a
27
+ * missing dsh-hooks entry is appended.
28
+ */
29
+ export declare function patchTextWithHooks(existingText: string, hooks: HookWireSpec[]): string;
30
+ /** Timestamped backup path for a patch file. */
31
+ export declare function backupPathFor(patchFile: string, now?: Date): string;
32
+ export interface WriteHooksResult {
33
+ patchFile: string;
34
+ backupPath: string;
35
+ hookCount: number;
36
+ }
37
+ /**
38
+ * Validate and persist the hook list into a profile's cordis.patch.yml.
39
+ * The previous content is backed up beside the file first.
40
+ */
41
+ export declare function writeHooksConfig(patchFile: string, hooks: HookWireSpec[]): WriteHooksResult;
42
+ /**
43
+ * Drop every hook whose `run` references the given script (the stable
44
+ * notify-feishu.mjs copy), used by the Feishu disconnect flow. Other
45
+ * entries and config stay untouched.
46
+ */
47
+ export declare function removeScriptHooks(patchFile: string, scriptMarker: string): void;
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Patch-file plumbing for the settings-panel hook editor and the Feishu
3
+ * disconnect flow: read a profile's cordis.patch.yml, replace the dsh-hooks
4
+ * block's hooks while keeping every other entry (and other dsh-hooks
5
+ * config, e.g. `history`) untouched, and write back with a timestamped
6
+ * backup. The profile layer is hot-reloaded by the harness's patch watcher,
7
+ * so a save applies without a restart.
8
+ */
9
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
10
+ import YAML from 'yaml';
11
+ import { HOOK_EVENTS, TURN_END_REASONS } from './config.js';
12
+ /** Parse a patch list; throws a user-facing error on malformed YAML. */
13
+ export function parsePatchText(text) {
14
+ let entries;
15
+ try {
16
+ entries = YAML.parse(text || '[]\n');
17
+ }
18
+ catch {
19
+ throw new Error('cordis.patch.yml 解析失败,请先修复该文件');
20
+ }
21
+ if (!Array.isArray(entries))
22
+ throw new Error('cordis.patch.yml 顶层必须是 YAML 数组');
23
+ return entries;
24
+ }
25
+ /**
26
+ * Validate the wire hooks before they ever touch a file. Returns a
27
+ * user-facing error message, or null when every hook is valid.
28
+ */
29
+ export function validateHookWire(hooks) {
30
+ for (const [i, hook] of hooks.entries()) {
31
+ const label = `hook #${i + 1}`;
32
+ if (!HOOK_EVENTS.includes(hook.on)) {
33
+ return `${label}:无效事件 ${hook.on}`;
34
+ }
35
+ if (hook.when !== undefined && !TURN_END_REASONS.includes(hook.when)) {
36
+ return `${label}:无效 when 原因 ${hook.when}`;
37
+ }
38
+ if (hook.match !== undefined) {
39
+ for (const [field, pattern] of Object.entries(hook.match)) {
40
+ if (field === '')
41
+ return `${label}:match 字段名不能为空`;
42
+ try {
43
+ new RegExp(pattern);
44
+ }
45
+ catch (error) {
46
+ return `${label}:match.${field} 正则无效(${error instanceof Error ? error.message : String(error)})`;
47
+ }
48
+ }
49
+ }
50
+ const hasRun = typeof hook.run === 'string' && hook.run.trim() !== '';
51
+ const hasNotify = hook.notify !== undefined && hook.notify !== null;
52
+ if (hasRun === hasNotify) {
53
+ return `${label}:run 与 notify 必须且只能声明一个`;
54
+ }
55
+ if (hasNotify && hook.notify.channel !== 'webhook' && hook.notify.channel !== 'desktop') {
56
+ return `${label}:无效通知渠道 ${hook.notify.channel}`;
57
+ }
58
+ for (const key of ['timeoutMs', 'retries', 'retryDelayMs']) {
59
+ const value = hook[key];
60
+ if (value !== undefined && (!Number.isFinite(value) || value < 0)) {
61
+ return `${label}:${key} 必须是非负数字`;
62
+ }
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+ /**
68
+ * Replace the dsh-hooks block's hooks in a patch list. Other entries and
69
+ * other config of the dsh-hooks entry (e.g. `history`) stay untouched; a
70
+ * missing dsh-hooks entry is appended.
71
+ */
72
+ export function patchTextWithHooks(existingText, hooks) {
73
+ const entries = parsePatchText(existingText);
74
+ let found = false;
75
+ for (const entry of entries) {
76
+ if (entry !== null && typeof entry === 'object' && entry.id === 'dsh-hooks') {
77
+ const target = entry;
78
+ target.name = 'dsh-hooks';
79
+ target.config = { ...(target.config ?? {}), hooks };
80
+ found = true;
81
+ break;
82
+ }
83
+ }
84
+ if (!found)
85
+ entries.push({ id: 'dsh-hooks', name: 'dsh-hooks', config: { hooks } });
86
+ return YAML.stringify(entries);
87
+ }
88
+ /** Timestamped backup path for a patch file. */
89
+ export function backupPathFor(patchFile, now = new Date()) {
90
+ const pad = (n) => String(n).padStart(2, '0');
91
+ const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
92
+ return `${patchFile}.bak-${stamp}`;
93
+ }
94
+ /**
95
+ * Validate and persist the hook list into a profile's cordis.patch.yml.
96
+ * The previous content is backed up beside the file first.
97
+ */
98
+ export function writeHooksConfig(patchFile, hooks) {
99
+ const invalid = validateHookWire(hooks);
100
+ if (invalid !== null)
101
+ throw new Error(invalid);
102
+ const existing = existsSync(patchFile) ? readFileSync(patchFile, 'utf8') : '[]\n';
103
+ const backupPath = backupPathFor(patchFile);
104
+ writeFileSync(backupPath, existing, 'utf8');
105
+ writeFileSync(patchFile, patchTextWithHooks(existing, hooks), 'utf8');
106
+ return { patchFile, backupPath, hookCount: hooks.length };
107
+ }
108
+ /**
109
+ * Drop every hook whose `run` references the given script (the stable
110
+ * notify-feishu.mjs copy), used by the Feishu disconnect flow. Other
111
+ * entries and config stay untouched.
112
+ */
113
+ export function removeScriptHooks(patchFile, scriptMarker) {
114
+ if (!existsSync(patchFile))
115
+ return;
116
+ const existing = readFileSync(patchFile, 'utf8');
117
+ const entries = parsePatchText(existing);
118
+ let changed = false;
119
+ for (const entry of entries) {
120
+ if (entry === null || typeof entry !== 'object' || entry.id !== 'dsh-hooks')
121
+ continue;
122
+ const config = entry.config;
123
+ const hooks = Array.isArray(config?.hooks) ? config.hooks : [];
124
+ const kept = hooks.filter((hook) => {
125
+ if (typeof hook.run !== 'string')
126
+ return true;
127
+ if (hook.run.includes(scriptMarker)) {
128
+ changed = true;
129
+ return false;
130
+ }
131
+ return true;
132
+ });
133
+ if (changed) {
134
+ config.hooks = kept;
135
+ const backupPath = backupPathFor(patchFile);
136
+ writeFileSync(backupPath, existing, 'utf8');
137
+ writeFileSync(patchFile, YAML.stringify(entries), 'utf8');
138
+ }
139
+ break;
140
+ }
141
+ }
package/lib/runner.d.ts CHANGED
@@ -10,8 +10,16 @@ export interface RunOutcome {
10
10
  /** Track in-flight hook runs so a missing parent never outlives teardown. */
11
11
  export interface HookRunner {
12
12
  run(spec: HookSpec, ctx: HookContext): RunOutcome;
13
+ /** Live counters for the web-panel diagnostics. */
14
+ stats(): HookRunnerStats;
13
15
  dispose(): void;
14
16
  }
17
+ export interface HookRunnerStats {
18
+ /** Spawned children still running (waiting for their exit). */
19
+ inFlight: number;
20
+ /** Retry timers scheduled in the background. */
21
+ pendingRetries: number;
22
+ }
15
23
  export type RunRecord = (record: Omit<HookRunRecord, 'ts'>) => void;
16
24
  export declare const DEFAULT_TIMEOUT_MS = 10000;
17
25
  export declare const DEFAULT_RETRY_DELAY_MS = 500;
package/lib/runner.js CHANGED
@@ -142,5 +142,8 @@ export function createHookRunner(log = console.log, record) {
142
142
  terminate(child);
143
143
  children.clear();
144
144
  }
145
- return { run, dispose };
145
+ function stats() {
146
+ return { inFlight: children.size, pendingRetries: pendingRetries.size };
147
+ }
148
+ return { run, stats, dispose };
146
149
  }
package/lib/server.d.ts CHANGED
@@ -1,14 +1,20 @@
1
1
  /**
2
- * /dsh-hooks/* HTTP routes for the web profile: status, execution history,
3
- * and a dry-run-style test trigger. Registered only when the shared
4
- * webserver service exists (web profile) CLI/headless environments never
5
- * see them. Loopback-only with JSON envelopes; POSTs require an explicit
6
- * application/json content-type (CSRF hardening, same posture as
7
- * dsh-aionui-panel).
2
+ * /dsh-hooks/* HTTP routes for the web profile: status (incl. the hook
3
+ * list and live runner stats), execution history, a dry-run-style test
4
+ * trigger, notify-channel quick tests, the hook-list editor (writes back
5
+ * to the profile's cordis.patch.yml with a backup), and the Feishu connect
6
+ * flow (QR setup / cancel / config / test card / disconnect). Registered
7
+ * only when the shared webserver service exists (web profile) — CLI/headless
8
+ * environments never see them. Loopback-only with JSON envelopes; POSTs
9
+ * require an explicit application/json content-type (CSRF hardening, same
10
+ * posture as dsh-aionui-panel).
8
11
  */
9
12
  import type { IncomingMessage, ServerResponse } from 'node:http';
10
13
  import type { HookSpec } from './config.js';
11
14
  import type { HistorySink } from './history.js';
15
+ import { type HookRunner } from './runner.js';
16
+ import { type FeishuSetupManager } from './feishu-session.js';
17
+ import { runFeishuTest } from './feishu.js';
12
18
  /** Minimal structural shape of the shared web server (dsh-host-webserver). */
13
19
  export interface WebServerLike {
14
20
  register(spec: {
@@ -21,11 +27,43 @@ export interface WebServerLike {
21
27
  export declare function pluginVersion(): string;
22
28
  /** Loopback fence: never let a LAN client reach /dsh-hooks operations. */
23
29
  export declare function isLoopbackRequest(req: IncomingMessage): boolean;
30
+ export interface FeishuRouteDeps {
31
+ /** QR-scan session manager (one in-flight flow at a time). */
32
+ manager: FeishuSetupManager;
33
+ /** Test-card sender, injectable for tests. */
34
+ runTest?: typeof runFeishuTest;
35
+ /** Credential file the status route summarizes. */
36
+ configPath?: string;
37
+ }
24
38
  export interface HookRoutesOptions {
25
39
  hooks: readonly HookSpec[];
26
40
  history: HistorySink;
27
41
  version?: string;
42
+ feishu?: FeishuRouteDeps;
43
+ /** Live runner counters for the diagnostics badge. */
44
+ runner?: Pick<HookRunner, 'stats'>;
45
+ /** Profile → patch-file resolver, injectable so tests never touch the real home. */
46
+ resolvePatchFile?: (profile: string) => string;
28
47
  }
48
+ /** Sanitized per-hook description for the settings panel (regex sources, no RegExp objects). */
49
+ export declare function describeHooks(hooks: readonly HookSpec[]): {
50
+ index: number;
51
+ on: "agent/created" | "agent/disposed" | "agent/error" | "agent/status" | "approval/asked" | "session/created" | "session/disposed" | "session/title" | "step/end" | "tool/call" | "tool/result" | "turn/end" | "turn/start" | "user/message";
52
+ when: "aborted" | "blocked" | "completed" | "error" | "interrupted" | "max-tokens" | undefined;
53
+ match: {
54
+ [k: string]: string;
55
+ } | undefined;
56
+ run: string | undefined;
57
+ notify: {
58
+ channel: "desktop" | "webhook";
59
+ url: string | undefined;
60
+ slack: boolean | undefined;
61
+ } | undefined;
62
+ input: "env" | "stdin" | undefined;
63
+ timeoutMs: number | undefined;
64
+ retries: number | undefined;
65
+ retryDelayMs: number | undefined;
66
+ }[];
29
67
  /** Create the /dsh-hooks route handler (exported for tests). */
30
68
  export declare function createHookHandler(options: HookRoutesOptions): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
31
69
  /** Register the /dsh-hooks prefix route on the shared web server. */