dsh-lark-agent 0.1.2 → 0.2.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/dist/bridge.js CHANGED
@@ -1,4 +1,6 @@
1
- import { createHash } from 'node:crypto';
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { realpath, stat } from 'node:fs/promises';
3
+ import { basename, isAbsolute, relative, sep } from 'node:path';
2
4
  import { DshRuntimeError } from './dsh-runtime.js';
3
5
  import { authorize, chunksByBytes, ownerKey, parseMessage, sessionKey } from './message.js';
4
6
  import { MemoryStoreError } from './memory-store.js';
@@ -10,14 +12,58 @@ import { modelChoice, thinkingChoice, modelControlPanel, modelStateText, sameSel
10
12
  import { botMenuCommand, BOT_MENU_EVENT } from './bot-menu.js';
11
13
  import { CommandFeedback } from './command-feedback.js';
12
14
  import { PresentationError, presentationDraft, presentationCard, presentationText } from './presentation.js';
15
+ import { LiveReply } from './live-reply.js';
16
+ import { answeredText, approvalCard, questionAnswers, questionCard, simpleQuestion } from './interaction-cards.js';
17
+ import { permissionPanel, permissionStateText } from './permission-controls.js';
13
18
  class ArtifactDeliveryError extends Error {
14
19
  }
20
+ const APPROVAL_TIMEOUT_MS = 5 * 60_000;
21
+ const QUESTION_TIMEOUT_MS = 10 * 60_000;
22
+ // After a live card cannot be created (for example, before the CardKit scope is
23
+ // granted), use ordinary replies for a while instead of failing every turn.
24
+ const LIVE_RETRY_MS = 10 * 60_000;
25
+ const MAX_FILE_BYTES = 30 * 1024 * 1024;
26
+ const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
27
+ const TIMEOUT_TEXT = '本次处理超时,已请求取消。请缩小问题范围后重试。';
28
+ const STOPPING_TEXT = '连接正在关闭,本次处理已停止。';
29
+ /** Only a regular file inside the user's own workspace is uploaded; anything else is just named. */
30
+ async function uploadable(workspace, path) {
31
+ let real;
32
+ try {
33
+ real = await realpath(path);
34
+ }
35
+ catch {
36
+ return { reason: '找不到这个文件' };
37
+ }
38
+ const inside = relative(workspace, real);
39
+ if (!inside || inside === '..' || inside.startsWith('..' + sep) || isAbsolute(inside))
40
+ return { reason: '不在这个私聊的工作区内,没有上传' };
41
+ let info;
42
+ try {
43
+ info = await stat(real);
44
+ }
45
+ catch {
46
+ return { reason: '无法读取' };
47
+ }
48
+ if (!info.isFile())
49
+ return { reason: '不是普通文件' };
50
+ if (info.size === 0)
51
+ return { reason: '是空文件' };
52
+ if (info.size > MAX_FILE_BYTES)
53
+ return { reason: '超过飞书 30 MB 的文件上限' };
54
+ if (/[\u0000-\u001f\u007f]/.test(basename(real)))
55
+ return { reason: '文件名含有无法发送的字符' };
56
+ return { path: real, image: /\.(?:png|jpe?g|gif|webp)$/i.test(real) && info.size <= MAX_IMAGE_BYTES };
57
+ }
15
58
  const HELP = `直接把想做的事告诉我就好。
16
59
 
17
60
  比如:帮我理清一个问题、整理一段文字,或者接着刚才的话题继续。你也可以在私聊告诉我,以后希望我怎样配合你。
18
61
 
19
62
  私聊底部菜单可以新建话题、找历史对话,或调整模型和思考深度。
20
63
  也可以发 /new 换话题、/sessions 找对话、/model 看模型、/thinking 调深度、/stop 停下来。`;
64
+ const HARNESS_HELP = `
65
+
66
+ 这里连着你电脑上的 Harness:可以让我查资料、读网页、处理工作区里的文件,回复会实时显示,思考过程折叠在卡片里;需要你确认的操作,我会先发卡片问你。发 /permission(或在 设置 → 模型 里点「权限模式」)可以切换权限,比如改成不再询问的「完全权限」。`;
21
67
  const ADVANCED_HELP = `高级命令(按需使用):
22
68
  /new 或 /reset:归档当前会话,下一条消息开启新会话;长期记忆保留
23
69
  /sessions:选择以前的对话;/sessions detail 查看完整列表和 ID
@@ -27,6 +73,7 @@ const ADVANCED_HELP = `高级命令(按需使用):
27
73
  /stop:停止当前处理并取消排队消息
28
74
  /results:查看当前话题的作品;/result <ID>:查看内容;/publish <ID>:在本人私聊生成飞书文档或画板
29
75
  /model:查看当前模型并选择;/thinking:选择当前模型支持的思考深度
76
+ /permission:本人私聊切换 Harness 权限模式(如仅可查看、工作区内修改、完全权限)
30
77
  /remember <内容>:私聊保存私密个人记忆;群聊仅保存当前聊天记忆
31
78
  /remember --chat <内容>:只在当前聊天/话题保存记忆
32
79
  /memory:列出可用记忆(最多 20 条)
@@ -86,6 +133,8 @@ export class BotBridge {
86
133
  failedDisposals = new Set();
87
134
  cardActionsReady = false;
88
135
  progress;
136
+ interactions = new Map();
137
+ liveUnavailableUntil = 0;
89
138
  constructor(config, runtime, transport, ledger, state, audit = () => undefined, progressTransport, artifactPublisher) {
90
139
  this.config = config;
91
140
  this.runtime = runtime;
@@ -125,7 +174,7 @@ export class BotBridge {
125
174
  return;
126
175
  }
127
176
  let formValues;
128
- if (binding.command.startsWith('/answer ')) {
177
+ if (binding.command.startsWith('/answer ') || binding.command.startsWith('/reply ')) {
129
178
  if (data.action_name !== 'dsh_submit' || typeof data.form_value !== 'string' || data.form_value.length > 8000)
130
179
  return;
131
180
  try {
@@ -137,7 +186,7 @@ export class BotBridge {
137
186
  if (!formValues || typeof formValues !== 'object' || Array.isArray(formValues))
138
187
  return;
139
188
  }
140
- const mutation = /^\/(?:(?:new|resume|forget|answer)(?: |$)|(?:model|thinking) use )/.test(binding.command);
189
+ const mutation = /^\/(?:(?:new|resume|forget|answer)(?: |$)|(?:model|thinking|permission) use )/.test(binding.command);
141
190
  return { messageId: data.message_id, senderId: binding.ownerId, chatId: binding.chatId,
142
191
  chatType: binding.chatType, threadId: binding.threadId, text: binding.command,
143
192
  requestId: `card:${data.event_id}`, actionNonce: mutation ? binding.nonce : undefined,
@@ -201,6 +250,12 @@ export class BotBridge {
201
250
  return;
202
251
  if (!message.menu && message.chatType === 'p2p')
203
252
  await this.state.directChats?.record(message.senderId, message.chatId);
253
+ // A running turn holds its queue while it waits for its approval or
254
+ // question, so those answers must not queue behind it.
255
+ if (message.requestId?.startsWith('card:') && /^\/(?:approve|deny|choose|reply) /.test(message.text)) {
256
+ await this.answerInteraction(message);
257
+ return;
258
+ }
204
259
  const key = sessionKey(message, this.config);
205
260
  let lane = this.lanes.get(key);
206
261
  if (message.text === '/stop') {
@@ -228,7 +283,7 @@ export class BotBridge {
228
283
  const generation = lane.generation;
229
284
  current.pending++;
230
285
  this.pending++;
231
- const finishProgress = message.menu ? () => undefined : this.progress.begin(message.messageId);
286
+ const finishProgress = message.menu || this.streams(message, user) ? () => undefined : this.progress.begin(message.messageId);
232
287
  const acceptedAt = Date.now();
233
288
  const job = current.tail.then(async () => {
234
289
  if (this.stopped || generation !== current.generation)
@@ -354,48 +409,88 @@ export class BotBridge {
354
409
  lane.controller = controller;
355
410
  let timedOut = false;
356
411
  const timer = setTimeout(() => { timedOut = true; controller.abort(); }, this.config.turnTimeoutMs);
412
+ const harness = message.chatType === 'p2p' && lane.user.directMode === 'harness';
413
+ let live;
414
+ // Once a live card exists it carries this turn's whole outcome; nothing else is sent as text.
415
+ const conclude = async (final) => {
416
+ const card = live;
417
+ live = undefined;
418
+ if (!await card.finish(final))
419
+ this.audit({ stage: 'live-card-unconfirmed', messageId: message.messageId });
420
+ };
357
421
  try {
358
422
  await this.ensureSession(message, lane);
359
423
  if (controller.signal.aborted) {
360
424
  if (timedOut)
361
- await this.reply(message, '本次处理超时,已请求取消。请缩小问题范围后重试。');
425
+ await this.reply(message, TIMEOUT_TEXT);
362
426
  return;
363
427
  }
364
428
  const context = this.context(message);
365
429
  const memoryText = promptMemories(await this.state.memories.list(context));
366
430
  const artifactsText = this.state.artifacts && lane.saved ? JSON.stringify((await this.state.artifacts.list(this.artifactContext(message, lane.saved.id)))
367
431
  .map(row => ({ id: row.id, title: row.draft.title, kind: row.draft.kind }))) : undefined;
368
- const result = await lane.session.run(text, controller.signal, { memoryText, ...(artifactsText ? { artifactsText } : {}) });
432
+ if (this.streams(message, lane.user)) {
433
+ const candidate = new LiveReply(this.liveTransport(), { messageId: message.messageId, inThread: Boolean(message.threadId),
434
+ idempotencyKey: createHash('sha256').update(JSON.stringify([this.config.botOpenId, message.requestId ?? message.messageId, 'live'])).digest('hex').slice(0, 48) }, { audit: stage => this.audit({ stage, messageId: message.messageId }) });
435
+ if (await candidate.open())
436
+ live = candidate;
437
+ else
438
+ this.liveUnavailableUntil = Date.now() + LIVE_RETRY_MS;
439
+ }
440
+ const turnContext = { memoryText, ...(artifactsText ? { artifactsText } : {}) };
441
+ // Only the owner's harness-mode chat can be asked anything; a restricted session gets no hooks at all.
442
+ const observer = live || harness ? {
443
+ ...(live ? { stream: (event) => live?.push(event) } : {}),
444
+ ...(harness ? { approve: (prompt, signal) => this.approve(message, prompt, signal),
445
+ ask: (questions, signal) => this.ask(message, questions, signal) } : {}),
446
+ } : undefined;
447
+ const result = await (observer ? lane.session.run(text, controller.signal, turnContext, observer)
448
+ : lane.session.run(text, controller.signal, turnContext));
369
449
  // Native conversation data must be durable before acknowledging a turn.
370
450
  await lane.session.checkpoint();
371
451
  await this.state.sessions.touch(this.sessionContext(message), lane.saved.id);
372
452
  clearTimeout(timer);
373
453
  if (controller.signal.aborted || this.stopped) {
374
- if (timedOut && !this.stopped)
375
- await this.reply(message, '本次处理超时,已请求取消。请缩小问题范围后重试。');
454
+ if (live)
455
+ await conclude({ phase: 'stopped', note: timedOut ? TIMEOUT_TEXT : this.stopped ? STOPPING_TEXT : '已停止。' });
456
+ else if (timedOut && !this.stopped)
457
+ await this.reply(message, TIMEOUT_TEXT);
376
458
  return;
377
459
  }
378
460
  const answer = typeof result === 'string' ? result : result.text;
379
461
  const artifacts = typeof result === 'string' ? [] : result.artifacts;
462
+ const files = typeof result === 'string' ? [] : result.files ?? [];
380
463
  const bounded = Array.from(answer).slice(0, this.config.maxOutputChars).join('');
464
+ const truncated = bounded.length < answer.length;
381
465
  // The answer is already in native history. Send it before artifact work
382
466
  // that can fail, so a storage limit cannot swallow a complete reply.
383
- if (bounded || !artifacts.length)
384
- await this.reply(message, bounded ? bounded + (bounded.length < answer.length ? '\n\n(回复已截断)' : '') : 'dsh 本次没有返回文本。', controller.signal);
467
+ if (live)
468
+ await conclude({ phase: 'done', answer: bounded || (artifacts.length || files.length ? '已整理好,见下方。' : 'dsh 本次没有返回文本。'),
469
+ ...(typeof result !== 'string' && result.reasoning ? { reasoning: result.reasoning } : {}), ...(truncated ? { note: '回复已截断。' } : {}) });
470
+ else if (bounded || !artifacts.length)
471
+ await this.reply(message, bounded ? bounded + (truncated ? '\n\n(回复已截断)' : '') : 'dsh 本次没有返回文本。', controller.signal);
385
472
  await this.presentArtifacts(message, lane, artifacts, controller.signal);
473
+ await this.deliverFiles(message, lane, files, controller.signal);
386
474
  this.audit({ stage: 'replied', messageId: message.messageId });
387
475
  }
388
476
  catch (error) {
389
477
  clearTimeout(timer);
390
478
  if (error instanceof ArtifactDeliveryError)
391
479
  throw error;
480
+ const known = error instanceof DshRuntimeError || error instanceof MemoryStoreError || error instanceof SessionStoreError || error instanceof ModelPreferencesError || error instanceof PresentationError;
481
+ if (live) {
482
+ const stopped = timedOut || controller.signal.aborted || this.stopped;
483
+ await conclude({ phase: stopped ? 'stopped' : 'failed', note: timedOut ? TIMEOUT_TEXT : this.stopped ? STOPPING_TEXT
484
+ : controller.signal.aborted ? '已停止。' : known ? error.message : '处理没有完成,请稍后重试。' }).catch(() => undefined);
485
+ return;
486
+ }
392
487
  if (this.stopped)
393
488
  return;
394
489
  if (timedOut)
395
- await this.reply(message, '本次处理超时,已请求取消。请缩小问题范围后重试。');
490
+ await this.reply(message, TIMEOUT_TEXT);
396
491
  else if (!controller.signal.aborted) {
397
492
  // Transport failures may have delivered partial output. Do not send an extra ambiguous retry.
398
- if (error instanceof DshRuntimeError || error instanceof MemoryStoreError || error instanceof SessionStoreError || error instanceof ModelPreferencesError || error instanceof PresentationError)
493
+ if (known)
399
494
  await this.reply(message, error.message);
400
495
  else
401
496
  this.audit({ stage: 'delivery-or-runtime-failed', messageId: message.messageId });
@@ -406,6 +501,161 @@ export class BotBridge {
406
501
  lane.controller = undefined;
407
502
  }
408
503
  }
504
+ /** Whether this message's reply streams into a live card, which makes the typing reaction redundant. */
505
+ streams(message, user) {
506
+ return message.chatType === 'p2p' && user.directMode === 'harness' && this.config.streamingCards && this.config.responseMode !== 'text'
507
+ && !message.text.startsWith('/') && Date.now() >= this.liveUnavailableUntil && this.liveTransport() !== undefined;
508
+ }
509
+ liveTransport() {
510
+ const transport = this.transport;
511
+ return transport.createCardEntity && transport.replyCardEntity && transport.streamCardText && transport.updateCardEntity
512
+ ? transport : undefined;
513
+ }
514
+ canInteract(message) {
515
+ return message.chatType === 'p2p' && !this.stopped && this.cardActionsReady && this.config.cardInteractions && this.config.responseMode !== 'text'
516
+ && Boolean(this.state.actions && this.transport.createFeedback && this.transport.updateFeedback);
517
+ }
518
+ issueAction(message, command) {
519
+ return this.state.actions.issue({ ownerId: message.senderId, chatId: message.chatId, chatType: message.chatType,
520
+ threadId: message.threadId, sourceMessageId: message.messageId, command });
521
+ }
522
+ /** Ask the owner about one Harness approval. Anything but an explicit allow denies the call. */
523
+ async approve(message, prompt, signal) {
524
+ if (!this.canInteract(message) || signal.aborted)
525
+ return false;
526
+ const id = randomBytes(6).toString('hex');
527
+ // Register before the card exists, so even an immediate click finds it.
528
+ const pending = this.waitForAnswer(id, message, answer => answer.verb === 'approve' ? true : answer.verb === 'deny' ? false : undefined, signal, APPROVAL_TIMEOUT_MS);
529
+ const card = await this.openInteraction(message, id, approvalCard(prompt, { allow: this.issueAction(message, `/approve ${id}`),
530
+ deny: this.issueAction(message, `/deny ${id}`) }));
531
+ if (!card) {
532
+ pending.cancel();
533
+ return false;
534
+ }
535
+ const outcome = await pending.result;
536
+ const allowed = outcome.kind === 'answer' && outcome.value;
537
+ await this.closeInteraction(card, approvalCard(prompt, undefined, outcome.kind === 'answer' ? (allowed ? '已允许这一次。' : '已拒绝,这次不会执行。')
538
+ : outcome.kind === 'timeout' ? '5 分钟内没有确认,已按拒绝处理。' : '本次处理已停止,没有执行。'));
539
+ return allowed;
540
+ }
541
+ /** Ask the owner a Harness question. Without an answer the host gets an error and continues. */
542
+ async ask(message, questions, signal) {
543
+ const unavailable = '这里无法用卡片提问。请把问题写在回复里,由主人直接回答。';
544
+ if (!this.canInteract(message))
545
+ throw new Error(unavailable);
546
+ const id = randomBytes(6).toString('hex');
547
+ const first = questions[0];
548
+ const tokens = simpleQuestion(questions)
549
+ ? { choose: Array.from({ length: first.options.length + 1 }, (_unused, index) => this.issueAction(message, `/choose ${id} ${index}`)) }
550
+ : { reply: this.issueAction(message, `/reply ${id}`) };
551
+ const pending = this.waitForAnswer(id, message, answer => questionAnswers(questions, answer), signal, QUESTION_TIMEOUT_MS);
552
+ const card = await this.openInteraction(message, id, questionCard(questions, tokens));
553
+ if (!card) {
554
+ pending.cancel();
555
+ throw new Error(unavailable);
556
+ }
557
+ const outcome = await pending.result;
558
+ await this.closeInteraction(card, questionCard(questions, undefined, outcome.kind === 'answer' ? answeredText(questions, outcome.value)
559
+ : outcome.kind === 'timeout' ? '10 分钟内没有回答,已按未回答继续。' : '本次处理已停止,提问已关闭。'));
560
+ if (outcome.kind !== 'answer')
561
+ throw new Error(outcome.kind === 'timeout' ? '主人没有在 10 分钟内回答。' : '提问已取消。');
562
+ return outcome.value;
563
+ }
564
+ waitForAnswer(id, message, parse, signal, timeoutMs) {
565
+ let finish;
566
+ const result = new Promise(resolve => {
567
+ let done = false;
568
+ const timer = setTimeout(() => finish({ kind: 'timeout' }), timeoutMs);
569
+ const onAbort = () => finish({ kind: 'cancelled' });
570
+ const entry = { ownerId: message.senderId, chatId: message.chatId, stop: onAbort,
571
+ settle: answer => {
572
+ const value = parse(answer);
573
+ if (value === undefined || done)
574
+ return false;
575
+ finish({ kind: 'answer', value });
576
+ return true;
577
+ } };
578
+ finish = outcome => {
579
+ if (done)
580
+ return;
581
+ done = true;
582
+ clearTimeout(timer);
583
+ signal.removeEventListener('abort', onAbort);
584
+ if (this.interactions.get(id) === entry)
585
+ this.interactions.delete(id);
586
+ resolve(outcome);
587
+ };
588
+ this.interactions.set(id, entry);
589
+ signal.addEventListener('abort', onAbort, { once: true });
590
+ if (signal.aborted)
591
+ onAbort();
592
+ });
593
+ return { result, cancel: () => finish({ kind: 'cancelled' }) };
594
+ }
595
+ async openInteraction(message, id, payload) {
596
+ if (payload.kind !== 'card' || this.stopped)
597
+ return;
598
+ const idempotencyKey = createHash('sha256').update(JSON.stringify([this.config.botOpenId, message.requestId ?? message.messageId, 'interaction', id]))
599
+ .digest('hex').slice(0, 48);
600
+ try {
601
+ return await this.transport.createFeedback({ messageId: message.messageId, inThread: Boolean(message.threadId) }, payload, { idempotencyKey });
602
+ }
603
+ catch {
604
+ this.audit({ stage: 'interaction-card-failed', messageId: message.messageId });
605
+ return;
606
+ }
607
+ }
608
+ async closeInteraction(messageId, payload) {
609
+ if (payload.kind !== 'card')
610
+ return;
611
+ try {
612
+ await this.transport.updateFeedback(messageId, payload);
613
+ }
614
+ catch {
615
+ this.audit({ stage: 'interaction-update-failed', messageId });
616
+ }
617
+ }
618
+ async answerInteraction(message) {
619
+ const match = /^\/(approve|deny|choose|reply) ([a-f0-9]{12})(?: ([0-9]{1,2}))?$/.exec(message.text);
620
+ const pending = match ? this.interactions.get(match[2]) : undefined;
621
+ if (!match || !pending || pending.ownerId !== message.senderId || pending.chatId !== message.chatId) {
622
+ await this.reply(message, '这张卡片已经失效。需要的话,直接在聊天里告诉我。');
623
+ return;
624
+ }
625
+ if (!pending.settle({ verb: match[1], ...(match[3] === undefined ? {} : { index: Number(match[3]) }),
626
+ ...(message.formValues === undefined ? {} : { formValues: message.formValues }) }))
627
+ await this.reply(message, '这次选择无效,请在卡片上重新选择后提交。');
628
+ }
629
+ /** Send files the host's delivery tool presented, from this user's workspace only. */
630
+ async deliverFiles(message, lane, files, signal) {
631
+ if (!files.length || signal.aborted || this.stopped)
632
+ return;
633
+ const workspace = await realpath(lane.user.cwd).catch(() => undefined);
634
+ const notes = [];
635
+ for (const [index, file] of files.entries()) {
636
+ if (signal.aborted || this.stopped)
637
+ return;
638
+ const name = basename(file.path);
639
+ const checked = workspace && this.transport.replyFile ? await uploadable(workspace, file.path) : { reason: '这个连接不能发送文件' };
640
+ if ('reason' in checked) {
641
+ notes.push(`${name}:${checked.reason}(电脑上的位置:${file.path})`);
642
+ continue;
643
+ }
644
+ const idempotencyKey = createHash('sha256').update(JSON.stringify([this.config.botOpenId, message.requestId ?? message.messageId, 'file', index, checked.path]))
645
+ .digest('hex').slice(0, 48);
646
+ try {
647
+ await this.transport.replyFile(message.messageId, checked.path, { idempotencyKey, inThread: Boolean(message.threadId), image: checked.image });
648
+ this.audit({ stage: 'file-sent', messageId: message.messageId });
649
+ }
650
+ catch {
651
+ // The upload may still have arrived. Name it instead of sending again.
652
+ notes.push(`${name}:发送结果没有确认,请在电脑上查看 ${file.path}`);
653
+ this.audit({ stage: 'file-send-unknown', messageId: message.messageId });
654
+ }
655
+ }
656
+ if (notes.length)
657
+ await this.reply(message, `有 ${notes.length} 个文件没有发到这里:\n${notes.join('\n')}`, signal);
658
+ }
409
659
  commandFeedback(message) {
410
660
  // Private controls contain no model-generated output. Group replies retain
411
661
  // their existing authorization gate and native typing reaction.
@@ -456,6 +706,7 @@ export class BotBridge {
456
706
  const context = this.context(message);
457
707
  return {
458
708
  cwd: lane.user.cwd, allowedTools: lane.user.allowedTools,
709
+ mode: message.chatType === 'p2p' && lane.user.directMode === 'harness' ? 'harness' : 'restricted',
459
710
  provider: this.config.provider, model: this.config.model,
460
711
  presentation: Boolean(this.state.artifacts),
461
712
  ...selection,
@@ -498,6 +749,7 @@ export class BotBridge {
498
749
  : await this.runtime.create(this.policy(message, lane, modelPreference?.selection));
499
750
  try {
500
751
  await handle.checkpoint();
752
+ await this.applyPermission(message, lane, handle);
501
753
  if (!modelPreference && handle.modelSelection && this.state.modelPreferences) {
502
754
  await this.state.modelPreferences.set(this.context(message), handle.modelSelection(), { expectedRevision: null });
503
755
  }
@@ -550,9 +802,12 @@ export class BotBridge {
550
802
  const context = this.context(message);
551
803
  const conversation = this.sessionContext(message);
552
804
  if (command === 'help')
553
- return args === 'advanced' ? ADVANCED_HELP : HELP;
805
+ return args === 'advanced' ? ADVANCED_HELP
806
+ : HELP + (message.chatType === 'p2p' && lane.user.directMode === 'harness' ? HARNESS_HELP : '');
554
807
  if (command === 'model' || command === 'thinking')
555
808
  return this.modelCommand(message, lane, command, args);
809
+ if (command === 'permission')
810
+ return this.permissionCommand(message, lane, args);
556
811
  if (command === 'whoami')
557
812
  return `用户:${message.senderId}\n聊天:${message.chatId}\n范围:${message.chatType === 'p2p' ? '私聊' : '群聊'}${message.threadId ? '/独立话题' : ''}\n身份由飞书验证。个人记忆默认仅本人私聊可读,群聊仅可用本人明确授权的条目。`;
558
813
  if (command === 'status') {
@@ -603,6 +858,7 @@ export class BotBridge {
603
858
  const handle = await this.runtime.resume(this.policy(message, lane, preference?.selection), target.nativeId);
604
859
  try {
605
860
  await handle.checkpoint();
861
+ await this.applyPermission(message, lane, handle);
606
862
  if (!preference && handle.modelSelection && this.state.modelPreferences)
607
863
  await this.state.modelPreferences.set(context, handle.modelSelection(), { expectedRevision: null });
608
864
  await this.bindRestoredSession(conversation, target, handle);
@@ -672,6 +928,57 @@ export class BotBridge {
672
928
  }
673
929
  return '未知命令。发送 /help 查看可用命令。';
674
930
  }
931
+ harness(message, lane) {
932
+ return message.chatType === 'p2p' && lane.user.directMode === 'harness';
933
+ }
934
+ /** The host's presets, from the live session when there is one. Undefined when the host offers none. */
935
+ permissionView(lane) {
936
+ const live = lane.session?.permissionState?.();
937
+ if (live)
938
+ return live;
939
+ const options = this.runtime.permissionOptions?.() ?? [];
940
+ return options.length ? { options } : undefined;
941
+ }
942
+ /** What this chat runs: the live session's preset, else the chat's saved choice. */
943
+ async permissionChoice(message, lane) {
944
+ if (lane.unavailable)
945
+ await this.unload(lane);
946
+ const view = this.permissionView(lane);
947
+ if (!view)
948
+ return;
949
+ if (view.current)
950
+ return view;
951
+ const saved = await this.state.permissionPreferences?.get(this.context(message));
952
+ return saved && view.options.some(option => option.id === saved) ? { ...view, current: saved } : view;
953
+ }
954
+ /** Owner-only: switch this private chat's Harness permission preset, like the App's selector. */
955
+ async permissionCommand(message, lane, args) {
956
+ if (!this.harness(message, lane))
957
+ return '权限模式只能在你和我的私聊里切换。';
958
+ const view = await this.permissionChoice(message, lane);
959
+ if (!view)
960
+ throw new DshRuntimeError('PERMISSION_UNAVAILABLE');
961
+ if (!args)
962
+ return permissionStateText(view);
963
+ const choice = /^use ([a-z][a-z0-9-]{0,39})$/.exec(args)?.[1];
964
+ const option = view.options.find(item => item.id === choice);
965
+ if (!option)
966
+ return '请通过权限模式卡片选择,或发送 /permission 查看可用选项。';
967
+ // Save first: a later session in this chat starts in the chosen mode even if the live switch fails.
968
+ await this.state.permissionPreferences?.set(this.context(message), option.id);
969
+ lane.session?.setPermission?.(option.id);
970
+ message.permissionChanged = true;
971
+ return `已切换为「${option.name}」。这个私聊里的对话都会使用它;电脑上其他会话不受影响。`;
972
+ }
973
+ /** Bring a newly opened harness session to the chat's saved permission preset. */
974
+ async applyPermission(message, lane, handle) {
975
+ if (!this.harness(message, lane) || !handle.setPermission)
976
+ return;
977
+ const saved = await this.state.permissionPreferences?.get(this.context(message));
978
+ const state = saved ? handle.permissionState?.() : undefined;
979
+ if (state && state.current !== saved && state.options.some(option => option.id === saved))
980
+ handle.setPermission(saved);
981
+ }
675
982
  async modelState(message, lane) {
676
983
  if (!this.runtime.catalog || !this.runtime.resolveModelSelection || !this.state.modelPreferences)
677
984
  throw new DshRuntimeError('MODEL_UNAVAILABLE');
@@ -740,18 +1047,32 @@ export class BotBridge {
740
1047
  async commandPanel(message, lane) {
741
1048
  const match = /^\/(\S+)(?:\s+([\s\S]*))?$/.exec(message.text);
742
1049
  const command = match?.[1]?.toLowerCase(), args = (match?.[2] ?? '').trim();
1050
+ const interactive = this.cardActionsReady && this.config.cardInteractions && Boolean(this.state.actions);
1051
+ const issue = (target, extra = {}) => this.state.actions.issue({
1052
+ ownerId: message.senderId, chatId: message.chatId, chatType: message.chatType, threadId: message.threadId,
1053
+ sourceMessageId: message.messageId, command: target, ...extra
1054
+ });
743
1055
  if ((command === 'model' || command === 'thinking') && (!args || /^page [0-9]{1,4}$/.test(args) || message.modelChanged)) {
744
1056
  const state = await this.modelState(message, lane);
745
1057
  const current = await this.state.sessions.current(this.sessionContext(message));
746
- const interactive = this.cardActionsReady && this.config.cardInteractions && Boolean(this.state.actions);
747
- return modelControlPanel(command, state.catalog, state.selection, (label, target, mutation, primary) => {
1058
+ const panel = modelControlPanel(command, state.catalog, state.selection, (label, target, mutation, primary) => {
748
1059
  if (!interactive)
749
1060
  return;
750
- const token = this.state.actions.issue({ ownerId: message.senderId, chatId: message.chatId, chatType: message.chatType,
751
- threadId: message.threadId, sourceMessageId: message.messageId, command: target, expectedSessionId: current?.id ?? null,
1061
+ const token = issue(target, { expectedSessionId: current?.id ?? null,
752
1062
  ...(mutation ? { expectedModelRevision: state.preference?.revision ?? null } : {}) });
753
1063
  return { label, value: { token }, primary };
754
1064
  }, /^page /.test(args) ? Number(args.slice(5)) : 0, message.modelChanged);
1065
+ // The settings menu reaches the permission switch from here, without another menu entry.
1066
+ if (interactive && this.harness(message, lane) && this.permissionView(lane))
1067
+ panel.actions = [...panel.actions ?? [], { label: '权限模式', value: { token: issue('/permission') } }];
1068
+ return panel;
1069
+ }
1070
+ if (command === 'permission' && (!args || message.permissionChanged) && this.harness(message, lane)) {
1071
+ const view = await this.permissionChoice(message, lane);
1072
+ if (!view)
1073
+ return;
1074
+ return permissionPanel(view, (label, target, primary, confirm) => interactive
1075
+ ? { label, value: { token: issue(target) }, primary, ...(confirm ? { confirm } : {}) } : undefined, message.permissionChanged);
755
1076
  }
756
1077
  // Ordinary conversation and diagnostics stay out of management cards.
757
1078
  // A card is useful here because the user explicitly wants to pick a topic.
@@ -762,7 +1083,6 @@ export class BotBridge {
762
1083
  const sessions = await this.state.sessions.list(conversation);
763
1084
  if (!sessions.length)
764
1085
  return;
765
- const interactive = this.cardActionsReady && this.config.cardInteractions && Boolean(this.state.actions);
766
1086
  const actions = [];
767
1087
  const add = (label, target, options = {}) => {
768
1088
  if (!interactive)
@@ -961,6 +1281,8 @@ export class BotBridge {
961
1281
  lane.controller?.abort();
962
1282
  lane.session?.cancel();
963
1283
  }
1284
+ for (const interaction of [...this.interactions.values()])
1285
+ interaction.stop();
964
1286
  await Promise.allSettled([...this.admissions]);
965
1287
  await Promise.all([...this.lanes.values()].map(lane => lane.tail));
966
1288
  await this.sweepTail;
@@ -5,7 +5,7 @@ import { join } from 'node:path';
5
5
  const TTL_MS = 24 * 60 * 60 * 1000;
6
6
  const MAX_TOKEN_LENGTH = 3000;
7
7
  const HEX_ID = /^[a-f0-9]{12}$/;
8
- const COMMAND = /^(?:\/(?:help|status|sessions|memory|new|model|thinking|results)|\/(?:model|thinking) (?:use [a-f0-9]{16}|page [0-9]{1,4})|\/memory (?:global|chat|show [a-f0-9]{12})|\/(?:resume|forget|result|publish|answer) [a-f0-9]{12})$/;
8
+ const COMMAND = /^(?:\/(?:help|status|sessions|memory|new|model|thinking|results)|\/(?:model|thinking) (?:use [a-f0-9]{16}|page [0-9]{1,4})|\/memory (?:global|chat|show [a-f0-9]{12})|\/(?:resume|forget|result|publish|answer|approve|deny|reply) [a-f0-9]{12}|\/choose [a-f0-9]{12} [0-9]{1,2}|\/permission(?: use [a-z][a-z0-9-]{0,39})?)$/;
9
9
  const PAYLOAD_KEYS = ['version', 'namespace', 'issuedAt', 'expiresAt', 'nonce', 'binding'];
10
10
  const BINDING_KEYS = ['ownerId', 'chatId', 'chatType', 'threadId', 'sourceMessageId', 'command', 'expectedSessionId', 'expectedModelRevision'];
11
11
  function record(value) {
package/dist/client.cjs CHANGED
@@ -531,8 +531,8 @@ function ConnectionCard({ api }) {
531
531
  ] }),
532
532
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "lark-connected-row", children: [
533
533
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [
534
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { children: "\u6587\u6863\u4E0E\u753B\u677F" }),
535
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { children: snapshot.capabilities.documents ? "\u5DF2\u542F\u7528 \xB7 \u79C1\u804A\u4F5C\u54C1\u4EC5\u4F9B\u672C\u4EBA\u67E5\u770B" : "\u628A\u590D\u6742\u65B9\u6848\u6574\u7406\u6210\u53EF\u6253\u5F00\u7684\u6587\u6863\u548C\u56FE\u793A" })
534
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { children: "\u5B9E\u65F6\u56DE\u590D\u3001\u6587\u6863\u4E0E\u753B\u677F" }),
535
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { children: snapshot.capabilities.documents ? "\u5DF2\u542F\u7528 \xB7 \u56DE\u590D\u5B9E\u65F6\u663E\u793A\uFF0C\u79C1\u804A\u4F5C\u54C1\u4EC5\u4F9B\u672C\u4EBA\u67E5\u770B" : "\u5B9E\u65F6\u663E\u793A\u56DE\u590D\u548C\u601D\u8003\u8FC7\u7A0B\uFF0C\u5E76\u628A\u590D\u6742\u65B9\u6848\u6574\u7406\u6210\u6587\u6863\u548C\u56FE\u793A" })
536
536
  ] }),
537
537
  !snapshot.capabilities.documents && !snapshot.documentSetup && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { className: "lark-secondary", disabled, onClick: () => void mutate("enable-documents"), children: "\u8865\u5145\u9ED8\u8BA4\u6743\u9650" })
538
538
  ] }),
@@ -540,7 +540,7 @@ function ConnectionCard({ api }) {
540
540
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { children: snapshot.documentSetup.message }),
541
541
  snapshot.documentSetup.phase === "preparing" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "lark-spinner", "aria-label": "\u6B63\u5728\u51C6\u5907\u6388\u6743" }),
542
542
  snapshot.documentSetup.qr && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
543
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "lark-qr", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("img", { src: snapshot.documentSetup.qr.image, alt: "\u6587\u6863\u4E0E\u753B\u677F\u8865\u5145\u6388\u6743\u4E8C\u7EF4\u7801" }) }),
543
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "lark-qr", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("img", { src: snapshot.documentSetup.qr.image, alt: "\u9ED8\u8BA4\u6743\u9650\u8865\u5145\u6388\u6743\u4E8C\u7EF4\u7801" }) }),
544
544
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { children: snapshot.documentSetup.qr.expiresAt <= now ? "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u53D6\u6D88\u540E\u91CD\u8BD5\u3002" : "\u626B\u7801\u786E\u8BA4\u540E\u81EA\u52A8\u5B8C\u6210\uFF0C\u804A\u5929\u8FDE\u63A5\u7EE7\u7EED\u4FDD\u6301\u3002" }),
545
545
  snapshot.documentSetup.qr.expiresAt > now && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(External, { url: snapshot.documentSetup.qr.url, children: "\u6253\u5F00\u98DE\u4E66\u6388\u6743" })
546
546
  ] }),
package/dist/config.d.ts CHANGED
@@ -4,6 +4,12 @@ export interface UserPolicy {
4
4
  groupChats: string[];
5
5
  cwd: string;
6
6
  allowedTools: string[];
7
+ /**
8
+ * 'harness': this user's private chat is a channel into an ordinary Harness
9
+ * session, with the host's prompt, tools and approvals. Group chats always
10
+ * use the restricted assistant and allowedTools.
11
+ */
12
+ directMode: 'harness' | 'restricted';
7
13
  }
8
14
  export interface BotConfig {
9
15
  profile: string;
@@ -26,6 +32,8 @@ export interface BotConfig {
26
32
  cardInteractions: boolean;
27
33
  loadingReaction: boolean;
28
34
  botMenu: boolean;
35
+ /** Stream harness-mode private replies into a CardKit card (needs cardkit:card:write). */
36
+ streamingCards: boolean;
29
37
  }
30
38
  export declare function parseConfig(value: unknown, baseDir?: string): BotConfig;
31
39
  export declare function loadConfig(path: string): Promise<BotConfig>;
package/dist/config.js CHANGED
@@ -45,11 +45,13 @@ export function parseConfig(value, baseDir = process.cwd()) {
45
45
  const allowedTools = strings(user.allowedTools ?? [], 'user.allowedTools');
46
46
  if (allowedTools.some(t => !/^[a-zA-Z0-9_.:-]+$/.test(t) || t === 'run_code'))
47
47
  throw new Error('allowedTools requires exact tool names; run_code and wildcards are forbidden');
48
+ if (user.directMode !== undefined && user.directMode !== 'harness' && user.directMode !== 'restricted')
49
+ throw new Error('user.directMode must be harness or restricted');
48
50
  return {
49
51
  openId: id(user.openId, 'ou', 'user.openId'),
50
52
  direct: user.direct === true,
51
53
  groupChats: strings(user.groupChats ?? [], 'user.groupChats').map(v => id(v, 'oc', 'group chat')),
52
- cwd: resolve(cwd), allowedTools,
54
+ cwd: resolve(cwd), allowedTools, directMode: user.directMode ?? 'restricted',
53
55
  };
54
56
  });
55
57
  if (new Set(users.map(u => u.openId)).size !== users.length)
@@ -62,6 +64,8 @@ export function parseConfig(value, baseDir = process.cwd()) {
62
64
  throw new Error('loadingReaction must be boolean');
63
65
  if (data.botMenu !== undefined && typeof data.botMenu !== 'boolean')
64
66
  throw new Error('botMenu must be boolean');
67
+ if (data.streamingCards !== undefined && typeof data.streamingCards !== 'boolean')
68
+ throw new Error('streamingCards must be boolean');
65
69
  return {
66
70
  profile, botOpenId: id(data.botOpenId, 'ou', 'botOpenId'),
67
71
  executable: string(data.executable ?? 'lark-cli', 'executable'),
@@ -69,7 +73,8 @@ export function parseConfig(value, baseDir = process.cwd()) {
69
73
  provider: data.provider === undefined ? undefined : string(data.provider, 'provider'),
70
74
  model: data.model === undefined ? undefined : string(data.model, 'model'),
71
75
  users,
72
- turnTimeoutMs: integer(data.turnTimeoutMs, 120_000, 1000, 1_800_000, 'turnTimeoutMs'),
76
+ // A harness-mode chat does research and tool work, and may wait minutes for an approval.
77
+ turnTimeoutMs: integer(data.turnTimeoutMs, users.some(user => user.directMode === 'harness') ? 900_000 : 120_000, 1000, 1_800_000, 'turnTimeoutMs'),
73
78
  idleTimeoutMs: integer(data.idleTimeoutMs, 1_800_000, 1000, 86_400_000, 'idleTimeoutMs'),
74
79
  maxSessions: integer(data.maxSessions, 100, 1, 1000, 'maxSessions'),
75
80
  maxSessionsPerUser: integer(data.maxSessionsPerUser, 10, 1, 100, 'maxSessionsPerUser'),
@@ -82,6 +87,7 @@ export function parseConfig(value, baseDir = process.cwd()) {
82
87
  cardInteractions: data.cardInteractions === true,
83
88
  loadingReaction: data.loadingReaction === true,
84
89
  botMenu: data.botMenu === true,
90
+ streamingCards: data.streamingCards === true,
85
91
  };
86
92
  }
87
93
  export async function loadConfig(path) {