dsh-hooks 0.4.0 → 0.5.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.
@@ -0,0 +1,119 @@
1
+ import type { registerApp } from '@larksuiteoapi/node-sdk';
2
+ /** Feishu config dir: credentials + the stable copy of the notify script. */
3
+ export declare const FEISHU_CONFIG_DIR: string;
4
+ export declare const FEISHU_CONFIG_PATH: string;
5
+ /** Card content truncation length written by setup (notify script default). */
6
+ export declare const FEISHU_RESULT_MAX_CHARS_DEFAULT = 300;
7
+ /** UI-accepted truncation range (characters). */
8
+ export declare const FEISHU_RESULT_MAX_CHARS_MIN = 50;
9
+ export declare const FEISHU_RESULT_MAX_CHARS_MAX = 5000;
10
+ /** Injectable file paths, so tests (and the web routes) stay off the real home. */
11
+ export interface FeishuSetupPaths {
12
+ /** Credential file (default ~/.dsh/dsh-hooks/feishu-config.json). */
13
+ configPath?: string;
14
+ /** Profile patch file (default ~/.dsh/profiles/<profile>/cordis.patch.yml). */
15
+ patchFile?: string;
16
+ /** Stable notify-script location the hooks reference. */
17
+ notifyScript?: string;
18
+ }
19
+ export interface FeishuQRCodeInfo {
20
+ url: string;
21
+ /** Seconds until the QR authorization expires. */
22
+ expireIn: number;
23
+ }
24
+ export interface RunFeishuSetupOptions {
25
+ /** Profile whose cordis.patch.yml receives the dsh-hooks config block. */
26
+ profile?: string;
27
+ /** Overrides the real registerApp (tests). */
28
+ registerAppFn?: typeof registerApp;
29
+ print?: (line: string) => void;
30
+ printErr?: (line: string) => void;
31
+ /** Called when the QR authorization is ready (CLI prints it; web renders it). */
32
+ onQRCodeReady?: (qr: FeishuQRCodeInfo) => void | Promise<void>;
33
+ /** Abort the scan wait (web cancel). */
34
+ signal?: AbortSignal;
35
+ /** Card content truncation length written into the credential file. */
36
+ resultMaxChars?: number;
37
+ paths?: FeishuSetupPaths;
38
+ }
39
+ export interface FeishuSetupResult {
40
+ appId: string;
41
+ ownerOpenId: string;
42
+ }
43
+ /** Profile patch file for a profile name. */
44
+ export declare function patchPath(profile: string): string;
45
+ /** Which hooks the setup installs into the profile. */
46
+ export declare function setupHooks(scriptPath: string): ({
47
+ on: string;
48
+ when: string;
49
+ run: string;
50
+ timeoutMs: number;
51
+ } | {
52
+ when?: undefined;
53
+ on: string;
54
+ run: string;
55
+ timeoutMs: number;
56
+ })[];
57
+ /** Absolute path of the shipped notify script (works from both lib/ and src/). */
58
+ export declare function notifyScriptPath(): string;
59
+ /**
60
+ * Resolve the stable notify-script location hooks should reference. The npx
61
+ * cache (where the CLI often runs from) is ephemeral, so the setup copies the
62
+ * zero-dependency script next to feishu-config.json:
63
+ * ~/.dsh/dsh-hooks/notify-feishu.mjs. Re-copies on every setup so the stable
64
+ * copy tracks the installed version.
65
+ */
66
+ export declare function stableScriptPath(paths?: FeishuSetupPaths): string;
67
+ /**
68
+ * Write the credential file with 0600 perms (owner-only): secrets stay out of
69
+ * the repo and argv.
70
+ */
71
+ export declare function writeConfig(configPath: string, { appId, appSecret, targetType, targetId, resultMaxChars }: {
72
+ appId: string;
73
+ appSecret: string;
74
+ targetType?: string;
75
+ targetId: string;
76
+ resultMaxChars?: number;
77
+ }): void;
78
+ /**
79
+ * Merge the dsh-hooks config block into a profile's cordis.patch.yml:
80
+ * existing dsh-hooks entries keep unrelated config and get their hooks
81
+ * replaced with `setupHooks`; other entries stay untouched. Idempotent.
82
+ */
83
+ export declare function mergePatchYaml(existingText: string, { scriptPath }: {
84
+ scriptPath: string;
85
+ }): string;
86
+ /**
87
+ * Full setup flow: registerApp (QR scan creates the Feishu app), write
88
+ * credentials + the stable notify script, merge the card hooks into the
89
+ * profile patch, and send a welcome card to the scanning user.
90
+ */
91
+ export declare function runFeishuSetup(options?: RunFeishuSetupOptions): Promise<FeishuSetupResult>;
92
+ export interface RunFeishuTestOptions {
93
+ print?: (line: string) => void;
94
+ paths?: FeishuSetupPaths;
95
+ }
96
+ /** Test the stored credentials and send a test card to the configured target. */
97
+ export declare function runFeishuTest(options?: RunFeishuTestOptions): Promise<string>;
98
+ /** Mask an identifier for display: `cli_a1b2…9012`. Never shows the secret. */
99
+ export declare function maskId(value: string | null): string | null;
100
+ /** Readable connection summary for the settings UI (credentials never leave this module). */
101
+ export interface FeishuSummary {
102
+ configured: boolean;
103
+ appId: string | null;
104
+ targetKind: string | null;
105
+ target: string | null;
106
+ /** Card content truncation length (from the credential file, or the default). */
107
+ resultMaxChars: number;
108
+ }
109
+ /**
110
+ * Inspect the credential file for a display-only summary. The app secret is
111
+ * read for presence only and never enters any returned value.
112
+ */
113
+ export declare function readFeishuSummary(configPath?: string): FeishuSummary;
114
+ /**
115
+ * Update the card truncation length in an existing credential file, keeping
116
+ * every other field (credentials, target) untouched. Throws a user-facing
117
+ * error for invalid input or a missing/unparsable file.
118
+ */
119
+ export declare function updateFeishuResultMaxChars(configPath: string | undefined, value: number): number;
package/lib/feishu.js ADDED
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Shared Feishu notification setup: the one-shot app-creation flow
3
+ * (`registerApp` + QR scan) plus credential/config plumbing. Used by both
4
+ * the `dsh-hooks feishu-setup` CLI and the web profile's
5
+ * `/dsh-hooks/feishu/*` routes — the CLI is a thin wrapper adding terminal
6
+ * QR printing and the browser opener.
7
+ *
8
+ * The Feishu SDK and the qrcode renderer are never loaded at plugin apply
9
+ * time: the SDK resolves through a dynamic import on first setup, so
10
+ * headless/CLI profiles without a web server pay nothing for the UI path.
11
+ */
12
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
13
+ import { homedir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import YAML from 'yaml';
16
+ import { run as notifyRun } from '../examples/notify-feishu.mjs';
17
+ /** Feishu config dir: credentials + the stable copy of the notify script. */
18
+ export const FEISHU_CONFIG_DIR = join(homedir(), '.dsh', 'dsh-hooks');
19
+ export const FEISHU_CONFIG_PATH = join(FEISHU_CONFIG_DIR, 'feishu-config.json');
20
+ /** Card content truncation length written by setup (notify script default). */
21
+ export const FEISHU_RESULT_MAX_CHARS_DEFAULT = 300;
22
+ /** UI-accepted truncation range (characters). */
23
+ export const FEISHU_RESULT_MAX_CHARS_MIN = 50;
24
+ export const FEISHU_RESULT_MAX_CHARS_MAX = 5000;
25
+ /** Profile patch file for a profile name. */
26
+ export function patchPath(profile) {
27
+ return join(homedir(), '.dsh', 'profiles', profile, 'cordis.patch.yml');
28
+ }
29
+ /** Which hooks the setup installs into the profile. */
30
+ export function setupHooks(scriptPath) {
31
+ return [
32
+ { on: 'turn/end', when: 'completed', run: `node ${JSON.stringify(scriptPath)}`, timeoutMs: 30000 },
33
+ { on: 'turn/end', when: 'error', run: `node ${JSON.stringify(scriptPath)}`, timeoutMs: 30000 },
34
+ { on: 'turn/end', when: 'aborted', run: `node ${JSON.stringify(scriptPath)}`, timeoutMs: 30000 },
35
+ { on: 'approval/asked', run: `node ${JSON.stringify(scriptPath)} --approval`, timeoutMs: 30000 },
36
+ { on: 'agent/error', run: `node ${JSON.stringify(scriptPath)}`, timeoutMs: 30000 },
37
+ ];
38
+ }
39
+ /** Absolute path of the shipped notify script (works from both lib/ and src/). */
40
+ export function notifyScriptPath() {
41
+ return new URL('../examples/notify-feishu.mjs', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1');
42
+ }
43
+ /**
44
+ * Resolve the stable notify-script location hooks should reference. The npx
45
+ * cache (where the CLI often runs from) is ephemeral, so the setup copies the
46
+ * zero-dependency script next to feishu-config.json:
47
+ * ~/.dsh/dsh-hooks/notify-feishu.mjs. Re-copies on every setup so the stable
48
+ * copy tracks the installed version.
49
+ */
50
+ export function stableScriptPath(paths = {}) {
51
+ return paths.notifyScript ?? join(FEISHU_CONFIG_DIR, 'notify-feishu.mjs');
52
+ }
53
+ /**
54
+ * Write the credential file with 0600 perms (owner-only): secrets stay out of
55
+ * the repo and argv.
56
+ */
57
+ export function writeConfig(configPath, { appId, appSecret, targetType = 'open_id', targetId, resultMaxChars = 300 }) {
58
+ mkdirSync(join(configPath, '..'), { recursive: true, mode: 0o700 });
59
+ const doc = JSON.stringify({
60
+ app_id: appId,
61
+ app_secret: appSecret,
62
+ target_type: targetType,
63
+ target_id: targetId,
64
+ result_max_chars: resultMaxChars,
65
+ }, null, 2);
66
+ writeFileSync(configPath, doc + '\n', 'utf8');
67
+ try {
68
+ chmodSync(configPath, 0o600);
69
+ }
70
+ catch {
71
+ // Windows: ACL-based protection; the file lives under the user profile.
72
+ }
73
+ }
74
+ /**
75
+ * Merge the dsh-hooks config block into a profile's cordis.patch.yml:
76
+ * existing dsh-hooks entries keep unrelated config and get their hooks
77
+ * replaced with `setupHooks`; other entries stay untouched. Idempotent.
78
+ */
79
+ export function mergePatchYaml(existingText, { scriptPath }) {
80
+ let entries;
81
+ try {
82
+ entries = YAML.parse(existingText || '[]\n');
83
+ }
84
+ catch {
85
+ throw new Error('profile 的 cordis.patch.yml 解析失败,请先修复该文件');
86
+ }
87
+ if (!Array.isArray(entries))
88
+ throw new Error('cordis.patch.yml 顶层必须是 YAML 数组');
89
+ const hooks = setupHooks(scriptPath);
90
+ let found = false;
91
+ for (const entry of entries) {
92
+ if (entry && typeof entry === 'object' && entry.id === 'dsh-hooks') {
93
+ ;
94
+ entry.name = 'dsh-hooks';
95
+ entry.config = { hooks };
96
+ found = true;
97
+ break;
98
+ }
99
+ }
100
+ if (!found)
101
+ entries.push({ id: 'dsh-hooks', name: 'dsh-hooks', config: { hooks } });
102
+ return YAML.stringify(entries);
103
+ }
104
+ /**
105
+ * Full setup flow: registerApp (QR scan creates the Feishu app), write
106
+ * credentials + the stable notify script, merge the card hooks into the
107
+ * profile patch, and send a welcome card to the scanning user.
108
+ */
109
+ export async function runFeishuSetup(options = {}) {
110
+ const profile = options.profile ?? 'web';
111
+ const print = options.print ?? console.log;
112
+ const printErr = options.printErr ?? console.error;
113
+ const paths = options.paths ?? {};
114
+ const configPath = paths.configPath ?? FEISHU_CONFIG_PATH;
115
+ const patchFile = paths.patchFile ?? patchPath(profile);
116
+ const notifyScript = stableScriptPath(paths);
117
+ print('dsh-hooks feishu-setup');
118
+ print('1/4 正在生成飞书「一键创建应用」二维码…');
119
+ // The SDK stays out of the module graph until the first setup actually runs.
120
+ const registerAppFn = options.registerAppFn ?? (await import('@larksuiteoapi/node-sdk')).registerApp;
121
+ const result = await registerAppFn({
122
+ source: 'dsh-hooks',
123
+ createOnly: true,
124
+ appPreset: {
125
+ name: 'DSH 通知机器人',
126
+ desc: 'DeepSeek Harness 会话事件通知(dsh-hooks)',
127
+ },
128
+ addons: {
129
+ preset: false,
130
+ scopes: {
131
+ tenant: ['im:message:send_as_bot'],
132
+ },
133
+ },
134
+ signal: options.signal,
135
+ onQRCodeReady: (authorization) => {
136
+ // The SDK fires this once the authorization URL exists; the scan wait
137
+ // is the registerApp promise itself. Caller-side failures must never
138
+ // break the SDK's polling loop.
139
+ void Promise.resolve(options.onQRCodeReady?.(authorization)).catch(() => undefined);
140
+ },
141
+ });
142
+ const appId = result.client_id;
143
+ const appSecret = result.client_secret;
144
+ const ownerOpenId = result.user_info?.open_id;
145
+ if (!appId || !appSecret)
146
+ throw new Error('扫码创建未完成,未拿到应用凭证');
147
+ if (!ownerOpenId)
148
+ throw new Error('扫码结果缺少 open_id,请重试');
149
+ print('');
150
+ print(`2/4 应用创建成功:${appId}(机器人将私聊通知你)`);
151
+ writeConfig(configPath, {
152
+ appId,
153
+ appSecret,
154
+ targetType: 'open_id',
155
+ targetId: ownerOpenId,
156
+ resultMaxChars: options.resultMaxChars ?? FEISHU_RESULT_MAX_CHARS_DEFAULT,
157
+ });
158
+ print(`3/4 凭据已写入 ${configPath}(权限 0600,勿提交到仓库)`);
159
+ // Copy the notify script to its stable location so hooks never reference
160
+ // the ephemeral npx cache.
161
+ if (!paths.notifyScript) {
162
+ mkdirSync(FEISHU_CONFIG_DIR, { recursive: true, mode: 0o700 });
163
+ writeFileSync(notifyScript, readFileSync(notifyScriptPath(), 'utf8'), 'utf8');
164
+ }
165
+ const existing = existsSync(patchFile) ? readFileSync(patchFile, 'utf8') : '[]\n';
166
+ const merged = mergePatchYaml(existing, { scriptPath: notifyScript });
167
+ writeFileSync(patchFile, merged, 'utf8');
168
+ print(`4/4 hook 配置已写入 ${patchFile}`);
169
+ print('发送欢迎卡片验证…');
170
+ try {
171
+ await notifyRun({
172
+ appId,
173
+ appSecret,
174
+ to: ownerOpenId,
175
+ event: 'agent/created',
176
+ sessionId: 'dsh-hooks-setup',
177
+ cwd: process.cwd(),
178
+ timestamp: new Date().toISOString(),
179
+ });
180
+ print('✅ 欢迎卡片已发送。请重启 dsh web 使 hooks 生效。');
181
+ }
182
+ catch (error) {
183
+ printErr(`⚠ 欢迎卡片发送失败(配置已就绪,可稍后用 feishu-test 重试):${error instanceof Error ? error.message : String(error)}`);
184
+ }
185
+ return { appId, ownerOpenId };
186
+ }
187
+ /** Test the stored credentials and send a test card to the configured target. */
188
+ export async function runFeishuTest(options = {}) {
189
+ const print = options.print ?? console.log;
190
+ const configPath = options.paths?.configPath ?? FEISHU_CONFIG_PATH;
191
+ if (!existsSync(configPath)) {
192
+ throw new Error(`未找到配置文件 ${configPath},请先运行 feishu-setup`);
193
+ }
194
+ let file;
195
+ try {
196
+ file = JSON.parse(readFileSync(configPath, 'utf8'));
197
+ }
198
+ catch {
199
+ throw new Error(`配置文件 ${configPath} 解析失败,请重新运行 feishu-setup`);
200
+ }
201
+ const { app_id: appId, app_secret: appSecret, target_id: targetId } = file;
202
+ if (typeof appId !== 'string' || appId === '' || typeof appSecret !== 'string' || appSecret === '' || typeof targetId !== 'string' || targetId === '') {
203
+ throw new Error('配置文件不完整,请重新运行 feishu-setup');
204
+ }
205
+ await notifyRun({
206
+ appId,
207
+ appSecret,
208
+ to: targetId,
209
+ event: 'agent/status',
210
+ status: 'connected',
211
+ sessionId: 'feishu-test',
212
+ cwd: process.cwd(),
213
+ timestamp: new Date().toISOString(),
214
+ });
215
+ const line = '✅ 测试卡片已发送';
216
+ print(line);
217
+ return line;
218
+ }
219
+ /** Mask an identifier for display: `cli_a1b2…9012`. Never shows the secret. */
220
+ export function maskId(value) {
221
+ if (value === null)
222
+ return null;
223
+ if (value.length <= 12)
224
+ return value;
225
+ return `${value.slice(0, 8)}…${value.slice(-4)}`;
226
+ }
227
+ /**
228
+ * Inspect the credential file for a display-only summary. The app secret is
229
+ * read for presence only and never enters any returned value.
230
+ */
231
+ export function readFeishuSummary(configPath = FEISHU_CONFIG_PATH) {
232
+ const empty = {
233
+ configured: false,
234
+ appId: null,
235
+ targetKind: null,
236
+ target: null,
237
+ resultMaxChars: FEISHU_RESULT_MAX_CHARS_DEFAULT,
238
+ };
239
+ if (!existsSync(configPath))
240
+ return empty;
241
+ try {
242
+ const file = JSON.parse(readFileSync(configPath, 'utf8'));
243
+ const appId = typeof file.app_id === 'string' && file.app_id !== '' ? file.app_id : null;
244
+ const secret = typeof file.app_secret === 'string' && file.app_secret !== '';
245
+ const targetKind = typeof file.target_type === 'string' && file.target_type !== '' ? file.target_type : null;
246
+ const target = typeof file.target_id === 'string' && file.target_id !== '' ? file.target_id : null;
247
+ const resultMaxChars = typeof file.result_max_chars === 'number' && Number.isFinite(file.result_max_chars) && file.result_max_chars > 0
248
+ ? Math.floor(file.result_max_chars)
249
+ : FEISHU_RESULT_MAX_CHARS_DEFAULT;
250
+ if (appId === null || !secret || target === null)
251
+ return { ...empty, resultMaxChars };
252
+ return { configured: true, appId: maskId(appId), targetKind, target: maskId(target), resultMaxChars };
253
+ }
254
+ catch {
255
+ return empty;
256
+ }
257
+ }
258
+ /**
259
+ * Update the card truncation length in an existing credential file, keeping
260
+ * every other field (credentials, target) untouched. Throws a user-facing
261
+ * error for invalid input or a missing/unparsable file.
262
+ */
263
+ export function updateFeishuResultMaxChars(configPath = FEISHU_CONFIG_PATH, value) {
264
+ if (!Number.isFinite(value) || value < FEISHU_RESULT_MAX_CHARS_MIN || value > FEISHU_RESULT_MAX_CHARS_MAX) {
265
+ throw new Error(`截断长度必须是 ${FEISHU_RESULT_MAX_CHARS_MIN}–${FEISHU_RESULT_MAX_CHARS_MAX} 之间的数字`);
266
+ }
267
+ const rounded = Math.floor(value);
268
+ if (!existsSync(configPath))
269
+ throw new Error('尚未连接飞书,请先扫码连接');
270
+ let file;
271
+ try {
272
+ file = JSON.parse(readFileSync(configPath, 'utf8'));
273
+ }
274
+ catch {
275
+ throw new Error('飞书配置文件解析失败,请重新连接');
276
+ }
277
+ if (typeof file !== 'object' || file === null)
278
+ throw new Error('飞书配置文件解析失败,请重新连接');
279
+ file.result_max_chars = rounded;
280
+ writeFileSync(configPath, JSON.stringify(file, null, 2) + '\n', 'utf8');
281
+ return rounded;
282
+ }
package/lib/history.d.ts CHANGED
@@ -29,6 +29,11 @@ export interface HistorySink {
29
29
  record(record: Omit<HookRunRecord, 'ts'>): void;
30
30
  /** Most recent records, oldest first. */
31
31
  recent(): readonly HookRunRecord[];
32
+ /**
33
+ * Ingest JSONL bytes appended since the last read (startup seed or another
34
+ * process). Idempotent and best-effort: failures leave the buffer as-is.
35
+ */
36
+ sync(): void;
32
37
  dispose(): void;
33
38
  }
34
39
  export declare function createHistorySink(options?: HistorySinkOptions): HistorySink;
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, 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/server.d.ts CHANGED
@@ -1,14 +1,17 @@
1
1
  /**
2
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
3
+ * a dry-run-style test trigger, and the Feishu connect flow (QR setup /
4
+ * cancel / test card). Registered only when the shared webserver service
5
+ * exists (web profile) CLI/headless environments never see them.
6
+ * Loopback-only with JSON envelopes; POSTs require an explicit
6
7
  * application/json content-type (CSRF hardening, same posture as
7
8
  * dsh-aionui-panel).
8
9
  */
9
10
  import type { IncomingMessage, ServerResponse } from 'node:http';
10
11
  import type { HookSpec } from './config.js';
11
12
  import type { HistorySink } from './history.js';
13
+ import { type FeishuSetupManager } from './feishu-session.js';
14
+ import { runFeishuTest } from './feishu.js';
12
15
  /** Minimal structural shape of the shared web server (dsh-host-webserver). */
13
16
  export interface WebServerLike {
14
17
  register(spec: {
@@ -21,10 +24,19 @@ export interface WebServerLike {
21
24
  export declare function pluginVersion(): string;
22
25
  /** Loopback fence: never let a LAN client reach /dsh-hooks operations. */
23
26
  export declare function isLoopbackRequest(req: IncomingMessage): boolean;
27
+ export interface FeishuRouteDeps {
28
+ /** QR-scan session manager (one in-flight flow at a time). */
29
+ manager: FeishuSetupManager;
30
+ /** Test-card sender, injectable for tests. */
31
+ runTest?: typeof runFeishuTest;
32
+ /** Credential file the status route summarizes. */
33
+ configPath?: string;
34
+ }
24
35
  export interface HookRoutesOptions {
25
36
  hooks: readonly HookSpec[];
26
37
  history: HistorySink;
27
38
  version?: string;
39
+ feishu?: FeishuRouteDeps;
28
40
  }
29
41
  /** Create the /dsh-hooks route handler (exported for tests). */
30
42
  export declare function createHookHandler(options: HookRoutesOptions): (req: IncomingMessage, res: ServerResponse) => Promise<void>;