dsh-dispatch 0.1.0 → 0.3.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/index.js CHANGED
@@ -262,7 +262,7 @@ function installCommands(hub, deps) {
262
262
  handler: () => ({ kind: "success", text: statusText(hub, deps) })
263
263
  }), "dsh-dispatch: /dispatch-status");
264
264
  }
265
- function pairingResult(hub, prefix = "") {
265
+ function pairingResult(hub, prefix2 = "") {
266
266
  if (hub.config.relay === "") {
267
267
  return {
268
268
  kind: "error",
@@ -272,7 +272,7 @@ function pairingResult(hub, prefix = "") {
272
272
  const code = hub.pairing.pairingCode(hub.config.relay, hub.config.machineName);
273
273
  return {
274
274
  kind: "success",
275
- text: `${prefix}\u914D\u5BF9\u7801\uFF08${hub.config.machineName}\uFF09\uFF1A
275
+ text: `${prefix2}\u914D\u5BF9\u7801\uFF08${hub.config.machineName}\uFF09\uFF1A
276
276
  ${code}
277
277
 
278
278
  \u6216\u5728\u624B\u673A\u4E0A\u6253\u5F00\uFF1A
@@ -289,7 +289,9 @@ function statusText(hub, deps) {
289
289
  `\u623F\u95F4: ${status.room.slice(0, 8)}\u2026`,
290
290
  `\u5DF2\u914D\u5BF9: ${hub.pairing.everPaired ? "yes" : "no"}${status.phoneOnline ? "\uFF08\u624B\u673A\u5728\u7EBF\uFF09" : "\uFF08\u624B\u673A\u79BB\u7EBF\uFF09"}`,
291
291
  `\u5F85\u6279\u5BA1\u6279: ${String(deps.approvals.openCount())}`,
292
+ `\u5F85\u7B54\u63D0\u95EE: ${String(deps.questions.openCount())}`,
292
293
  `\u6D3E\u53D1\u4F1A\u8BDD: ${String(deps.dispatch.dispatchedCount())}`,
294
+ `full-access \u6D3E\u53D1: ${hub.config.allowFullAccessDispatch ? "\u5DF2\u5F00\u542F \u26A0\uFE0F" : "\u5DF2\u7981\u7528"}`,
293
295
  `\u89E3\u5BC6\u5931\u8D25: ${String(status.decryptFailures)}${status.decryptFailures > 0 ? " \u2014 \u5BC6\u94A5\u4E0D\u5339\u914D\uFF0C\u8BF7\u91CD\u65B0\u914D\u5BF9" : ""}`,
294
296
  `allowedRoots: ${hub.config.allowedRoots.length === 0 ? "(\u7A7A \u2014 \u8FDC\u7A0B\u6D3E\u4EFB\u52A1\u5DF2\u7981\u7528)" : hub.config.allowedRoots.join(", ")}`
295
297
  ].join("\n");
@@ -411,7 +413,8 @@ async function prepareWorkspace(options) {
411
413
  if (!await isGitRepo(options.cwd)) {
412
414
  return {
413
415
  cwd: options.cwd,
414
- note: `\u5DF2\u964D\u7EA7\uFF1A${options.cwd} \u4E0D\u662F git \u4ED3\u5E93\uFF0C\u4EFB\u52A1\u76F4\u63A5\u5728\u8BE5\u76EE\u5F55\u8FD0\u884C\uFF08\u672A\u521B\u5EFA worktree\uFF09`
416
+ note: `\u5DF2\u964D\u7EA7\uFF1A${options.cwd} \u4E0D\u662F git \u4ED3\u5E93\uFF0C\u4EFB\u52A1\u76F4\u63A5\u5728\u8BE5\u76EE\u5F55\u8FD0\u884C\uFF08\u672A\u521B\u5EFA worktree\uFF09`,
417
+ noteReason: { code: "not-a-git-repo", params: { cwd: options.cwd } }
415
418
  };
416
419
  }
417
420
  const root = join2(options.dataDir, "worktrees");
@@ -454,8 +457,23 @@ function stderrOf(error) {
454
457
 
455
458
  // src/dispatch.ts
456
459
  var SEEN_LIMIT = 256;
457
- var DISPATCH_DISABLED = "dispatch \u672A\u542F\u7528\uFF1A\u8BF7\u5728\u63D2\u4EF6\u914D\u7F6E\u4E2D\u8BBE\u7F6E allowedRoots";
458
- var NO_REMOTE_FOLLOWUP = "\u8BE5\u4F1A\u8BDD\u4E0D\u652F\u6301\u8FDC\u7A0B\u8FFD\u52A0";
460
+ function complain(text, code, params) {
461
+ return { text, reason: params === void 0 ? { code } : { code, params } };
462
+ }
463
+ var DISPATCH_DISABLED = complain(
464
+ "dispatch \u672A\u542F\u7528\uFF1A\u8BF7\u5728\u63D2\u4EF6\u914D\u7F6E\u4E2D\u8BBE\u7F6E allowedRoots",
465
+ "dispatch-disabled"
466
+ );
467
+ var NO_REMOTE_FOLLOWUP = complain("\u8BE5\u4F1A\u8BDD\u4E0D\u652F\u6301\u8FDC\u7A0B\u8FFD\u52A0", "no-remote-followup");
468
+ var FULL_ACCESS_DISABLED = complain(
469
+ "full-access \u6D3E\u53D1\u672A\u542F\u7528\uFF1A\u8BF7\u5728\u63D2\u4EF6\u914D\u7F6E\u4E2D\u5F00\u542F allowFullAccessDispatch",
470
+ "full-access-disabled"
471
+ );
472
+ var FULL_ACCESS_UNAVAILABLE = complain(
473
+ "full-access \u4E0D\u53EF\u7528\uFF1A\u672C\u673A dsh \u672A\u7EC4\u5408 permission-presets \u63D2\u4EF6",
474
+ "full-access-unavailable"
475
+ );
476
+ var FULL_ACCESS_PRESET = "danger-full-access";
459
477
  function installDispatch(hub) {
460
478
  const state = {
461
479
  handles: /* @__PURE__ */ new Map(),
@@ -480,6 +498,11 @@ function installDispatch(hub) {
480
498
  };
481
499
  }
482
500
  async function handleRequest(hub, state, message) {
501
+ const rejected = validate(hub, message);
502
+ if (rejected !== void 0) {
503
+ hub.relay.send(failure(message.requestId, rejected));
504
+ return;
505
+ }
483
506
  const replay = state.seen.get(message.requestId);
484
507
  if (replay !== void 0) {
485
508
  if (replay !== null) hub.relay.send(replay);
@@ -497,10 +520,9 @@ async function handleRequest(hub, state, message) {
497
520
  }
498
521
  async function startSession(hub, state, message) {
499
522
  const prompt = message.prompt;
500
- const oversized = overLimit("prompt", prompt);
501
- if (oversized !== void 0) return failure(message.requestId, oversized);
502
523
  const target = resolveCwd(hub, message.cwd);
503
524
  if (typeof target !== "string") return failure(message.requestId, target.error);
525
+ const full = message.access === "full";
504
526
  try {
505
527
  const workspace = await prepareWorkspace({
506
528
  cwd: target,
@@ -509,28 +531,48 @@ async function startSession(hub, state, message) {
509
531
  });
510
532
  state.loaderReady ??= Promise.resolve(hub.ctx.get("loader")?.await()).then(() => void 0);
511
533
  await state.loaderReady;
512
- const handle = await createSession(hub.ctx, workspace.cwd, prompt);
513
- state.handles.set(handle.agent.id, handle);
514
- hub.sessions.markDispatched(handle.agent.id, prompt);
515
- const result = ok(message.requestId, handle.agent.id);
516
- return workspace.note === void 0 ? result : { ...result, note: workspace.note };
534
+ const created = await createSession(hub.ctx, workspace.cwd, prompt, full);
535
+ state.handles.set(created.handle.agent.id, created.handle);
536
+ hub.sessions.markDispatched(created.handle.agent.id, prompt, full ? "full" : "standard");
537
+ if (created.fullAccessError !== void 0) {
538
+ hub.report(`dispatch ${message.requestId}`, `\u5B8C\u5168\u8BBF\u95EE\u6743\u9650\u672A\u80FD\u5E94\u7528\uFF1A${created.fullAccessError}`);
539
+ }
540
+ const result = ok(message.requestId, created.handle.agent.id);
541
+ const advisory = created.fullAccessError === void 0 ? workspace.note === void 0 ? void 0 : { text: workspace.note, reason: workspace.noteReason ?? { code: "not-a-git-repo" } } : complain(
542
+ `\u5B8C\u5168\u8BBF\u95EE\u6743\u9650\u672A\u80FD\u5E94\u7528\uFF0C\u5DF2\u5728\u6807\u51C6\u6743\u9650\u4E0B\u8FD0\u884C\uFF1A${created.fullAccessError}`,
543
+ "full-access-not-applied",
544
+ { detail: created.fullAccessError }
545
+ );
546
+ return advisory === void 0 ? result : { ...result, note: advisory.text, reason: advisory.reason };
517
547
  } catch (error) {
518
548
  const detail = error instanceof WorktreeError ? error.message : String(error);
519
549
  hub.log.error("dispatch %s failed: %s", message.requestId, detail);
520
- return failure(message.requestId, detail);
550
+ return failure(message.requestId, complain(
551
+ detail,
552
+ error instanceof WorktreeError ? "worktree-failed" : "dispatch-failed",
553
+ { detail }
554
+ ));
521
555
  }
522
556
  }
523
557
  async function handleFollowup(hub, state, message) {
524
- const oversized = overLimit("\u6D88\u606F", message.text);
558
+ const oversized = overLimit("message", message.text);
525
559
  if (oversized !== void 0) {
526
- hub.report(`session.message ${message.sessionId}`, oversized);
560
+ hub.report(
561
+ `session.message ${message.sessionId}`,
562
+ oversized.text,
563
+ { sessionId: message.sessionId, reason: oversized.reason }
564
+ );
527
565
  return;
528
566
  }
529
567
  if (state.seen.get(message.requestId) !== void 0) return;
530
568
  state.seen.set(message.requestId, null);
531
569
  const agent = state.handles.get(message.sessionId)?.agent ?? hub.sessions.agentOf(message.sessionId);
532
570
  if (agent === void 0) {
533
- hub.report(`session.message ${message.sessionId}`, NO_REMOTE_FOLLOWUP);
571
+ hub.report(
572
+ `session.message ${message.sessionId}`,
573
+ NO_REMOTE_FOLLOWUP.text,
574
+ { sessionId: message.sessionId, reason: NO_REMOTE_FOLLOWUP.reason }
575
+ );
534
576
  return;
535
577
  }
536
578
  agent.followup(createUserMessage({
@@ -539,13 +581,22 @@ async function handleFollowup(hub, state, message) {
539
581
  }));
540
582
  }
541
583
  function overLimit(field, text) {
584
+ const label = field === "prompt" ? "\u63D0\u793A\u8BCD" : "\u6D88\u606F";
542
585
  const tail = "\u8BF7\u5728\u624B\u673A\u7AEF\u7F29\u77ED\u540E\u91CD\u53D1\u3002";
543
586
  if (text.length > MAX_PROMPT_CHARS) {
544
- return `${field} \u8D85\u957F\uFF1A${String(text.length)} \u5B57\u7B26\uFF0C\u4E0A\u9650 ${String(MAX_PROMPT_CHARS)} \u5B57\u7B26\u3002${tail}`;
587
+ return complain(
588
+ `${label} \u8D85\u957F\uFF1A${String(text.length)} \u5B57\u7B26\uFF0C\u4E0A\u9650 ${String(MAX_PROMPT_CHARS)} \u5B57\u7B26\u3002${tail}`,
589
+ `${field}-too-long-chars`,
590
+ { actual: text.length, limit: MAX_PROMPT_CHARS }
591
+ );
545
592
  }
546
593
  const bytes = utf8Length(text);
547
594
  if (bytes > MAX_PROMPT_BYTES) {
548
- return `${field} \u8D85\u957F\uFF1A${String(bytes)} \u5B57\u8282\uFF08UTF-8\uFF09\uFF0C\u4E0A\u9650 ${String(MAX_PROMPT_BYTES)} \u5B57\u8282\u3002${tail}`;
595
+ return complain(
596
+ `${label} \u8D85\u957F\uFF1A${String(bytes)} \u5B57\u8282\uFF08UTF-8\uFF09\uFF0C\u4E0A\u9650 ${String(MAX_PROMPT_BYTES)} \u5B57\u8282\u3002${tail}`,
597
+ `${field}-too-long-bytes`,
598
+ { actual: bytes, limit: MAX_PROMPT_BYTES }
599
+ );
549
600
  }
550
601
  return void 0;
551
602
  }
@@ -570,7 +621,22 @@ async function composeAgent(ctx, selection) {
570
621
  }
571
622
  };
572
623
  }
573
- async function createSession(ctx, cwd, prompt) {
624
+ function validate(hub, message) {
625
+ const oversized = overLimit("prompt", message.prompt);
626
+ if (oversized !== void 0) return oversized;
627
+ if (message.access !== void 0 && message.access !== "standard" && message.access !== "full") {
628
+ return complain(
629
+ `access \u53D6\u503C\u65E0\u6548\uFF1A${String(message.access)}`,
630
+ "bad-access-value",
631
+ { value: String(message.access) }
632
+ );
633
+ }
634
+ if (message.access !== "full") return void 0;
635
+ if (!hub.config.allowFullAccessDispatch) return FULL_ACCESS_DISABLED;
636
+ if (hub.ctx.get("permissionPresets") === void 0) return FULL_ACCESS_UNAVAILABLE;
637
+ return void 0;
638
+ }
639
+ async function createSession(ctx, cwd, prompt, fullAccess) {
574
640
  const selection = ctx.get("agentDefaultModel")?.currentSelection();
575
641
  const composition = await composeAgent(ctx, selection);
576
642
  const handle = await ctx.agents.create({
@@ -582,30 +648,54 @@ async function createSession(ctx, cwd, prompt) {
582
648
  setup: composition.setup
583
649
  });
584
650
  await handle.agent.whenIdle();
651
+ const fullAccessError = fullAccess ? applyFullAccess(ctx, handle) : void 0;
585
652
  handle.agent.followup(createUserMessage({
586
653
  content: [{ type: "text", text: prompt }],
587
654
  source: { kind: "user" }
588
655
  }));
589
- return handle;
656
+ return fullAccessError === void 0 ? { handle } : { handle, fullAccessError };
657
+ }
658
+ function applyFullAccess(ctx, handle) {
659
+ try {
660
+ const presets = ctx.get("permissionPresets");
661
+ if (presets === void 0) return "permission-presets \u63D2\u4EF6\u672A\u7EC4\u5408";
662
+ presets.set(handle.agent.session, FULL_ACCESS_PRESET);
663
+ return void 0;
664
+ } catch (error) {
665
+ return error instanceof Error ? error.message : String(error);
666
+ }
667
+ }
668
+ function resolvedRoots(hub) {
669
+ return hub.config.allowedRoots.map((root) => resolve2(expandHome(root)));
590
670
  }
591
671
  function resolveCwd(hub, requested) {
592
- const roots = hub.config.allowedRoots.map((root) => resolve2(expandHome(root)));
672
+ const roots = resolvedRoots(hub);
593
673
  if (roots.length === 0) return { error: DISPATCH_DISABLED };
594
674
  const first = roots[0];
595
675
  if (requested === void 0) return first === void 0 ? { error: DISPATCH_DISABLED } : first;
596
676
  const target = resolve2(isAbsolute(requested) ? requested : expandHome(requested));
597
677
  const inside = roots.some((root) => target === root || target.startsWith(root + sep));
598
- return inside ? target : { error: `cwd \u4E0D\u5728\u5141\u8BB8\u76EE\u5F55\u5185\uFF1A${target}` };
678
+ return inside ? target : { error: complain(`cwd \u4E0D\u5728\u5141\u8BB8\u76EE\u5F55\u5185\uFF1A${target}`, "cwd-not-allowed", { cwd: target }) };
599
679
  }
600
680
  async function reportTurnFinal(hub, sessionId) {
601
681
  const snapshot = await hub.ctx.sessionQuery.readSurface(SessionId(sessionId));
602
682
  const ok2 = !hub.sessions.hasError(sessionId);
683
+ const text = lastAssistantText(snapshot.events);
684
+ const placeholder = text === void 0 ? complain("\uFF08\u672C\u8F6E\u6CA1\u6709\u4EA7\u751F\u52A9\u624B\u6587\u672C\uFF09", "no-assistant-text") : void 0;
603
685
  const summary = clampBytes(
604
- truncate(lastAssistantText(snapshot.events), MAX_SUMMARY_CHARS),
686
+ truncate(text ?? placeholder.text, MAX_SUMMARY_CHARS),
605
687
  MAX_FIELD_BYTES
606
688
  );
607
689
  hub.relay.send(
608
- { v: 1, ts: Date.now(), type: "turn.final", sessionId, ok: ok2, summary },
690
+ {
691
+ v: 1,
692
+ ts: Date.now(),
693
+ type: "turn.final",
694
+ sessionId,
695
+ ok: ok2,
696
+ summary,
697
+ ...placeholder === void 0 ? {} : { reason: placeholder.reason }
698
+ },
609
699
  {
610
700
  v: 1,
611
701
  ts: Date.now(),
@@ -623,13 +713,21 @@ function lastAssistantText(events) {
623
713
  const text = event.data.message.content.filter((block) => block.type === "text").map((block) => block.text).join("").trim();
624
714
  if (text !== "") return text;
625
715
  }
626
- return "\uFF08\u672C\u8F6E\u6CA1\u6709\u4EA7\u751F\u52A9\u624B\u6587\u672C\uFF09";
716
+ return void 0;
627
717
  }
628
718
  function ok(requestId, sessionId) {
629
719
  return { v: 1, ts: Date.now(), type: "dispatch.result", requestId, ok: true, sessionId };
630
720
  }
631
- function failure(requestId, error) {
632
- return { v: 1, ts: Date.now(), type: "dispatch.result", requestId, ok: false, error };
721
+ function failure(requestId, complaint) {
722
+ return {
723
+ v: 1,
724
+ ts: Date.now(),
725
+ type: "dispatch.result",
726
+ requestId,
727
+ ok: false,
728
+ error: complaint.text,
729
+ reason: complaint.reason
730
+ };
633
731
  }
634
732
  var ResultCache = class {
635
733
  #entries = /* @__PURE__ */ new Map();
@@ -650,14 +748,16 @@ var ResultCache = class {
650
748
  // src/hub.ts
651
749
  var ERROR_CHARS = 1e3;
652
750
  function createHub(parts) {
653
- const report = (context, message) => {
751
+ const report = (context, message, about = {}) => {
654
752
  parts.log.error("%s: %s", context, message);
655
753
  parts.relay.send({
656
754
  v: 1,
657
755
  ts: Date.now(),
658
756
  type: "error",
659
757
  message: truncate(message, ERROR_CHARS),
660
- context
758
+ context,
759
+ ...about.sessionId === void 0 ? {} : { sessionId: about.sessionId },
760
+ ...about.reason === void 0 ? {} : { reason: about.reason }
661
761
  });
662
762
  };
663
763
  return {
@@ -669,6 +769,195 @@ function createHub(parts) {
669
769
  };
670
770
  }
671
771
 
772
+ // src/questions.ts
773
+ import { randomUUID as randomUUID3 } from "crypto";
774
+ var ASK_USER_QUESTION = "ask_user_question";
775
+ var PROMPT_CHARS = 2e3;
776
+ var PROMPT_BYTES = 4e3;
777
+ var LABEL_CHARS = 300;
778
+ var LABEL_BYTES = 600;
779
+ var REMINDER_MS2 = 5 * 6e4;
780
+ var MAX_REMINDERS2 = 6;
781
+ function installQuestions(hub) {
782
+ const pending = /* @__PURE__ */ new Map();
783
+ hub.ctx.on("tools/execute", (exec, next) => {
784
+ if (exec.name !== ASK_USER_QUESTION || exec.agent === void 0) return next();
785
+ if (!hub.pairing.everPaired || !hub.relay.connected) return next();
786
+ if (exec.signal.aborted) return next();
787
+ return forward2(hub, pending, exec, next);
788
+ });
789
+ return {
790
+ respond(message) {
791
+ const entry = pending.get(message.questionId);
792
+ if (entry === void 0) {
793
+ hub.relay.send(closedMsg2(message.questionId, "expired"));
794
+ return;
795
+ }
796
+ const value = toAnswerValue(entry, message.answers);
797
+ if (typeof value === "string") {
798
+ hub.report(`question ${entry.questionId}`, value);
799
+ return;
800
+ }
801
+ entry.settle(successResult(value), "phone");
802
+ },
803
+ resendPending() {
804
+ for (const entry of pending.values()) hub.relay.send(entry.frame, entry.push);
805
+ },
806
+ openCount: () => pending.size
807
+ };
808
+ }
809
+ function forward2(hub, pending, exec, next) {
810
+ const normalized = normalize(exec.arguments);
811
+ if (normalized === null) {
812
+ hub.report("question", "ask_user_question \u53C2\u6570\u65E0\u6CD5\u89E3\u6790\uFF0C\u5DF2\u4EA4\u56DE\u672C\u673A\u5904\u7406");
813
+ return next();
814
+ }
815
+ const questionId = randomUUID3();
816
+ const sessionId = exec.agent?.id ?? "";
817
+ const frame = {
818
+ v: 1,
819
+ ts: Date.now(),
820
+ type: "question.request",
821
+ questionId,
822
+ sessionId,
823
+ items: normalized.items,
824
+ createdAt: Date.now()
825
+ };
826
+ const push = {
827
+ v: 1,
828
+ ts: Date.now(),
829
+ type: "push",
830
+ kind: "question",
831
+ sessionId,
832
+ title: normalized.items[0]?.prompt.slice(0, 120) ?? "\u9700\u8981\u4F60\u56DE\u7B54\u4E00\u4E2A\u95EE\u9898"
833
+ };
834
+ if (!hub.relay.send(frame, push)) {
835
+ hub.report("question", "\u63D0\u95EE\u672A\u80FD\u63A8\u9001\u5230\u624B\u673A\uFF08relay \u672A\u8FDE\u63A5\u6216\u5185\u5BB9\u8FC7\u5927\uFF09\uFF0C\u5DF2\u4EA4\u56DE\u672C\u673A\u5904\u7406");
836
+ return next();
837
+ }
838
+ const forwarded = { questionId, sessionId, frame, push, labels: normalized.labels };
839
+ return race2(hub, pending, forwarded, exec, next);
840
+ }
841
+ function race2(hub, pending, forwarded, exec, next) {
842
+ const { questionId } = forwarded;
843
+ return new Promise((resolve3) => {
844
+ let settled = false;
845
+ let localFailure;
846
+ const settle = (result, resolution) => {
847
+ if (settled) return;
848
+ settled = true;
849
+ const entry2 = pending.get(questionId);
850
+ if (entry2?.timer !== void 0) clearTimeout(entry2.timer);
851
+ pending.delete(questionId);
852
+ exec.signal.removeEventListener("abort", onAbort);
853
+ hub.relay.send(closedMsg2(questionId, resolution));
854
+ resolve3(result);
855
+ };
856
+ function onAbort() {
857
+ settle(localFailure ?? abortedResult(), "cancelled");
858
+ }
859
+ exec.signal.addEventListener("abort", onAbort, { once: true });
860
+ const entry = { ...forwarded, settle, reminders: 0, timer: void 0 };
861
+ pending.set(questionId, entry);
862
+ armReminder2(hub, entry);
863
+ void next().then((result) => {
864
+ if (!result.isError) settle(result, "local");
865
+ else localFailure = result;
866
+ }, (error) => {
867
+ hub.fail(`question ${questionId}: local provider failed`, error);
868
+ });
869
+ });
870
+ }
871
+ function armReminder2(hub, entry) {
872
+ entry.timer = setTimeout(() => {
873
+ entry.timer = void 0;
874
+ entry.reminders += 1;
875
+ hub.relay.send(entry.frame, entry.push);
876
+ if (entry.reminders >= MAX_REMINDERS2) {
877
+ hub.log.warn(
878
+ "question %s still unanswered after %d reminders; it stays open (never auto-answered)",
879
+ entry.questionId,
880
+ entry.reminders
881
+ );
882
+ return;
883
+ }
884
+ armReminder2(hub, entry);
885
+ }, REMINDER_MS2);
886
+ entry.timer.unref?.();
887
+ }
888
+ function normalize(args) {
889
+ const questions = args?.questions;
890
+ if (!Array.isArray(questions) || questions.length === 0) return null;
891
+ const items = [];
892
+ const labels = /* @__PURE__ */ new Map();
893
+ for (const raw of questions) {
894
+ if (typeof raw?.id !== "string" || typeof raw.question !== "string") return null;
895
+ const options = [];
896
+ const byId = /* @__PURE__ */ new Map();
897
+ (raw.options ?? []).forEach((option, index) => {
898
+ if (typeof option?.label !== "string") return;
899
+ const id = `o${String(index)}`;
900
+ byId.set(id, option.label);
901
+ options.push({ id, label: clamp(decorate(option.label, option.description), LABEL_CHARS, LABEL_BYTES) });
902
+ });
903
+ labels.set(raw.id, byId);
904
+ items.push({
905
+ id: raw.id,
906
+ prompt: clamp(prefix(raw.header, raw.question), PROMPT_CHARS, PROMPT_BYTES),
907
+ options,
908
+ multiSelect: raw.multi_select === true,
909
+ // The tool's output schema always permits `custom`, so free text is
910
+ // always a valid answer — and the only answer when there are no options.
911
+ allowFreeText: true
912
+ });
913
+ }
914
+ return items.length === 0 ? null : { items, labels };
915
+ }
916
+ var decorate = (label, description) => description === void 0 || description === "" ? label : `${label} \u2014 ${description}`;
917
+ var prefix = (header, question) => header === void 0 || header === "" ? question : `${header}
918
+
919
+ ${question}`;
920
+ var clamp = (text, chars, bytes) => clampBytes(truncate(text, chars), bytes);
921
+ function toAnswerValue(entry, answers) {
922
+ if (!Array.isArray(answers)) return "\u56DE\u7B54\u683C\u5F0F\u65E0\u6548\uFF1Aanswers \u4E0D\u662F\u6570\u7EC4";
923
+ const seen = /* @__PURE__ */ new Set();
924
+ const projected = [];
925
+ for (const answer of answers) {
926
+ const options = entry.labels.get(answer.itemId);
927
+ if (options === void 0) return `\u56DE\u7B54\u5F15\u7528\u4E86\u672A\u77E5\u7684\u95EE\u9898 id\uFF1A${String(answer.itemId)}`;
928
+ if (seen.has(answer.itemId)) return `\u95EE\u9898 ${answer.itemId} \u88AB\u56DE\u7B54\u4E86\u591A\u6B21`;
929
+ seen.add(answer.itemId);
930
+ const selected = [];
931
+ for (const optionId of answer.optionIds ?? []) {
932
+ const label = options.get(optionId);
933
+ if (label === void 0) return `\u95EE\u9898 ${answer.itemId} \u7684\u9009\u9879 id \u672A\u77E5\uFF1A${String(optionId)}`;
934
+ selected.push(label);
935
+ }
936
+ const item = entry.frame.items.find((candidate) => candidate.id === answer.itemId);
937
+ if (item !== void 0 && !item.multiSelect && selected.length > 1) {
938
+ return `\u95EE\u9898 ${answer.itemId} \u662F\u5355\u9009\uFF0C\u5374\u6536\u5230 ${String(selected.length)} \u4E2A\u9009\u9879`;
939
+ }
940
+ const custom = answer.freeText;
941
+ if (selected.length === 0 && (custom === void 0 || custom === "")) {
942
+ return `\u95EE\u9898 ${answer.itemId} \u65E2\u6CA1\u6709\u9009\u9879\u4E5F\u6CA1\u6709\u6587\u5B57\u56DE\u7B54`;
943
+ }
944
+ projected.push({ id: answer.itemId, selected, ...custom === void 0 || custom === "" ? {} : { custom } });
945
+ }
946
+ const missing = entry.frame.items.filter((item) => !seen.has(item.id)).map((item) => item.id);
947
+ if (missing.length > 0) return `\u8FD8\u6709\u95EE\u9898\u672A\u56DE\u7B54\uFF1A${missing.join(", ")}`;
948
+ return { answers: projected };
949
+ }
950
+ function successResult(value) {
951
+ return { isError: false, value, content: [{ type: "text", text: JSON.stringify(value) }] };
952
+ }
953
+ function abortedResult() {
954
+ const message = "ask_user_question was cancelled before the user answered";
955
+ return { isError: true, error: { message }, content: [{ type: "text", text: `Error: ${message}` }] };
956
+ }
957
+ function closedMsg2(questionId, resolution) {
958
+ return { v: 1, ts: Date.now(), type: "question.closed", questionId, resolution };
959
+ }
960
+
672
961
  // src/relay-client.ts
673
962
  var BACKOFF_BASE_MS = 500;
674
963
  var BACKOFF_MAX_MS = 3e4;
@@ -818,7 +1107,12 @@ var RelayClient = class {
818
1107
  #onPresence(frame) {
819
1108
  const { role, online } = frame;
820
1109
  if (role !== "phone" || typeof online !== "boolean") return;
821
- this.#setPhoneOnline(online);
1110
+ if (online) {
1111
+ this.#phoneOnline = true;
1112
+ this.#options.onPhonePresence(true);
1113
+ return;
1114
+ }
1115
+ this.#setPhoneOnline(false);
822
1116
  }
823
1117
  #setPhoneOnline(online) {
824
1118
  if (this.#phoneOnline === online) return;
@@ -904,6 +1198,7 @@ var RelayClient = class {
904
1198
  var INBOUND_FIELDS = {
905
1199
  "sessions.get": [],
906
1200
  "approval.respond": ["requestId", "approvalId", "decision"],
1201
+ "question.respond": ["requestId", "questionId"],
907
1202
  "dispatch.request": ["requestId", "prompt"],
908
1203
  "session.message": ["requestId", "sessionId", "text"]
909
1204
  };
@@ -921,18 +1216,33 @@ function parseInbound(value) {
921
1216
  return null;
922
1217
  }
923
1218
  if (type === "dispatch.request" && typeof record["worktree"] !== "boolean") return null;
1219
+ if (type === "question.respond" && !isAnswerList(record["answers"])) return null;
924
1220
  return record;
925
1221
  }
1222
+ function isAnswerList(value) {
1223
+ if (!Array.isArray(value) || value.length === 0) return false;
1224
+ return value.every((entry) => {
1225
+ const answer = entry;
1226
+ if (typeof answer?.itemId !== "string") return false;
1227
+ if (!Array.isArray(answer.optionIds)) return false;
1228
+ if (!answer.optionIds.every((id) => typeof id === "string")) return false;
1229
+ return answer.freeText === void 0 || typeof answer.freeText === "string";
1230
+ });
1231
+ }
926
1232
 
927
1233
  // src/sessions.ts
928
1234
  import { SessionId as SessionId2 } from "@deepseek-ai/dsh-session";
929
1235
  var TITLE_CHARS2 = 80;
1236
+ var ACTIVITY_CHARS = 40;
1237
+ var ACTIVITY_MIN_GAP_MS = 800;
930
1238
  var SessionTracker = class {
931
1239
  #ctx;
932
1240
  #relay;
933
1241
  #log;
934
1242
  #tracked = /* @__PURE__ */ new Map();
935
1243
  #published = /* @__PURE__ */ new Map();
1244
+ #activityAt = /* @__PURE__ */ new Map();
1245
+ #activityTimers = /* @__PURE__ */ new Map();
936
1246
  constructor(ctx, relay, log) {
937
1247
  this.#ctx = ctx;
938
1248
  this.#relay = relay;
@@ -962,21 +1272,37 @@ var SessionTracker = class {
962
1272
  });
963
1273
  });
964
1274
  this.#ctx.on("agent/turn-stopping", ({ agent }) => {
1275
+ this.#update(agent.id, (entry) => {
1276
+ entry.activity = void 0;
1277
+ entry.activityCallId = void 0;
1278
+ });
965
1279
  void this.#refreshTitle(agent);
966
1280
  });
967
1281
  this.#ctx.on("agent/disposed", ({ agent }) => {
968
1282
  this.#update(agent.id, (entry) => {
969
1283
  entry.base = "done";
1284
+ entry.activity = void 0;
970
1285
  });
971
1286
  this.#tracked.delete(agent.id);
972
1287
  this.#published.delete(agent.id);
1288
+ this.#clearActivityTimer(agent.id);
1289
+ });
1290
+ this.#ctx.on("session/event", (session, event) => {
1291
+ if (event.type === "tool/call") {
1292
+ this.#setActivity(session.id, truncate(event.data.name, ACTIVITY_CHARS), event.data.callId);
1293
+ return;
1294
+ }
1295
+ if (event.type === "tool/result") {
1296
+ this.#clearActivity(session.id, event.data.message.content[0].toolCallId);
1297
+ }
973
1298
  });
974
1299
  }
975
1300
  /** Mark a session as started by us, with the dispatch prompt as its title. */
976
- markDispatched(sessionId, prompt) {
1301
+ markDispatched(sessionId, prompt, access = "standard") {
977
1302
  this.#update(sessionId, (entry) => {
978
1303
  entry.dispatched = true;
979
1304
  entry.title = truncate(prompt.trim().split("\n", 1)[0] ?? "", TITLE_CHARS2);
1305
+ if (access === "full") entry.access = "full";
980
1306
  });
981
1307
  }
982
1308
  isDispatched(sessionId) {
@@ -999,6 +1325,10 @@ var SessionTracker = class {
999
1325
  agentOf(sessionId) {
1000
1326
  return this.#ctx.agents.get(SessionId2(sessionId));
1001
1327
  }
1328
+ /** Whether a follow-up would land: exactly the check `session.message` makes. */
1329
+ #isLive(sessionId) {
1330
+ return this.agentOf(sessionId) !== void 0;
1331
+ }
1002
1332
  /** An approval for this session is now waiting on a human. */
1003
1333
  approvalOpened(sessionId) {
1004
1334
  this.#update(sessionId, (entry) => {
@@ -1018,7 +1348,7 @@ var SessionTracker = class {
1018
1348
  if (!record.live) continue;
1019
1349
  const id = record.header.id;
1020
1350
  const known = this.#tracked.get(id) ?? this.#adopt(this.#ctx.agents.get(id));
1021
- wire.push(known === void 0 ? this.#fallbackWire(id, record.header.cwd ?? "", record.header.createdAt) : toWire(known));
1351
+ wire.push(known === void 0 ? this.#fallbackWire(id, record.header.cwd ?? "", record.header.createdAt) : toWire(known, this.#isLive(id)));
1022
1352
  }
1023
1353
  return wire;
1024
1354
  }
@@ -1029,7 +1359,10 @@ var SessionTracker = class {
1029
1359
  cwd,
1030
1360
  state: "idle",
1031
1361
  lastActivity: createdAt,
1032
- dispatched: false
1362
+ dispatched: false,
1363
+ access: "standard",
1364
+ // listSessions() already filtered to live records.
1365
+ live: true
1033
1366
  };
1034
1367
  }
1035
1368
  #adopt(agent) {
@@ -1044,11 +1377,52 @@ var SessionTracker = class {
1044
1377
  openApprovals: 0,
1045
1378
  lastActivity: Date.now(),
1046
1379
  dispatched: false,
1047
- finalized: false
1380
+ access: "standard",
1381
+ finalized: false,
1382
+ activity: void 0,
1383
+ activityCallId: void 0
1048
1384
  };
1049
1385
  this.#tracked.set(agent.id, entry);
1050
1386
  return entry;
1051
1387
  }
1388
+ #setActivity(sessionId, name2, callId) {
1389
+ const entry = this.#tracked.get(sessionId);
1390
+ if (entry === void 0 || entry.activity === name2) return;
1391
+ entry.activity = name2;
1392
+ entry.activityCallId = callId;
1393
+ this.#publishActivity(sessionId);
1394
+ }
1395
+ /** Only the call that set the activity may clear it (tools can overlap). */
1396
+ #clearActivity(sessionId, callId) {
1397
+ const entry = this.#tracked.get(sessionId);
1398
+ if (entry === void 0 || entry.activityCallId !== callId) return;
1399
+ entry.activity = void 0;
1400
+ entry.activityCallId = void 0;
1401
+ this.#publishActivity(sessionId);
1402
+ }
1403
+ /** Trailing-edge coalescing, so a tool-heavy turn cannot flood the phone. */
1404
+ #publishActivity(sessionId) {
1405
+ const elapsed = Date.now() - (this.#activityAt.get(sessionId) ?? 0);
1406
+ if (elapsed >= ACTIVITY_MIN_GAP_MS) {
1407
+ this.#activityAt.set(sessionId, Date.now());
1408
+ this.#publish(sessionId);
1409
+ return;
1410
+ }
1411
+ if (this.#activityTimers.has(sessionId)) return;
1412
+ const timer = setTimeout(() => {
1413
+ this.#activityTimers.delete(sessionId);
1414
+ this.#activityAt.set(sessionId, Date.now());
1415
+ this.#publish(sessionId);
1416
+ }, ACTIVITY_MIN_GAP_MS - elapsed);
1417
+ timer.unref?.();
1418
+ this.#activityTimers.set(sessionId, timer);
1419
+ }
1420
+ #clearActivityTimer(sessionId) {
1421
+ const timer = this.#activityTimers.get(sessionId);
1422
+ if (timer !== void 0) clearTimeout(timer);
1423
+ this.#activityTimers.delete(sessionId);
1424
+ this.#activityAt.delete(sessionId);
1425
+ }
1052
1426
  #update(sessionId, mutate) {
1053
1427
  const entry = this.#tracked.get(sessionId) ?? this.#adopt(this.#ctx.agents.get(SessionId2(sessionId)));
1054
1428
  if (entry === void 0) return;
@@ -1060,8 +1434,8 @@ var SessionTracker = class {
1060
1434
  #publish(sessionId) {
1061
1435
  const entry = this.#tracked.get(sessionId);
1062
1436
  if (entry === void 0) return;
1063
- const session = toWire(entry);
1064
- const fingerprint = `${session.state}|${session.title}|${session.cwd}|${String(session.dispatched)}`;
1437
+ const session = toWire(entry, this.#isLive(sessionId));
1438
+ const fingerprint = `${session.state}|${session.title}|${session.cwd}|${String(session.dispatched)}|${String(session.access)}|${String(session.live)}|${session.activity ?? ""}`;
1065
1439
  if (this.#published.get(sessionId) === fingerprint) return;
1066
1440
  this.#published.set(sessionId, fingerprint);
1067
1441
  this.#relay.send({ v: 1, ts: Date.now(), type: "session.update", session });
@@ -1080,14 +1454,17 @@ var SessionTracker = class {
1080
1454
  }
1081
1455
  }
1082
1456
  };
1083
- function toWire(entry) {
1457
+ function toWire(entry, live) {
1084
1458
  return {
1085
1459
  sessionId: entry.sessionId,
1086
1460
  title: entry.title,
1087
1461
  cwd: entry.cwd,
1088
1462
  state: stateOf(entry),
1089
1463
  lastActivity: entry.lastActivity,
1090
- dispatched: entry.dispatched
1464
+ dispatched: entry.dispatched,
1465
+ access: entry.access,
1466
+ live,
1467
+ ...entry.activity === void 0 ? {} : { activity: entry.activity }
1091
1468
  };
1092
1469
  }
1093
1470
  function stateOf(entry) {
@@ -1104,7 +1481,7 @@ function firstPrompt(agent) {
1104
1481
  }
1105
1482
 
1106
1483
  // src/version.ts
1107
- var PLUGIN_VERSION = "0.1.0";
1484
+ var PLUGIN_VERSION = "0.3.0";
1108
1485
 
1109
1486
  // src/config.ts
1110
1487
  import { hostname } from "os";
@@ -1113,6 +1490,7 @@ var Config = z.object({
1113
1490
  relay: z.string().required(),
1114
1491
  machineName: z.string().default(hostname()),
1115
1492
  allowedRoots: z.array(z.string()).default([]),
1493
+ allowFullAccessDispatch: z.boolean().default(false),
1116
1494
  dataDir: z.string().default("~/.dsh-dispatch"),
1117
1495
  pwaUrl: z.string().default("http://localhost:5173")
1118
1496
  });
@@ -1150,17 +1528,20 @@ function apply(ctx, config) {
1150
1528
  const sessions = new SessionTracker(ctx, relay, log);
1151
1529
  const hub = createHub({ ctx, config, log, relay, sessions, pairing });
1152
1530
  const approvals = installApprovals(hub);
1531
+ const questions = installQuestions(hub);
1153
1532
  const dispatch = installDispatch(hub);
1533
+ const routers = { approvals, questions, dispatch };
1154
1534
  deferred.message = (message) => {
1155
- route(hub, approvals, dispatch, message);
1535
+ route(hub, routers, message);
1156
1536
  };
1157
1537
  deferred.hello = () => {
1158
1538
  publishStatus(hub);
1159
1539
  approvals.resendPending();
1540
+ questions.resendPending();
1160
1541
  void publishSnapshot(hub);
1161
1542
  };
1162
1543
  sessions.install();
1163
- installCommands(hub, { approvals, dispatch });
1544
+ installCommands(hub, routers);
1164
1545
  startRelay(hub);
1165
1546
  }
1166
1547
  function startRelay(hub) {
@@ -1191,7 +1572,8 @@ function startRelay(hub) {
1191
1572
  };
1192
1573
  }, "dsh-dispatch: status heartbeat");
1193
1574
  }
1194
- function route(hub, approvals, dispatch, message) {
1575
+ function route(hub, routers, message) {
1576
+ const { approvals, questions, dispatch } = routers;
1195
1577
  switch (message.type) {
1196
1578
  case "sessions.get":
1197
1579
  void publishSnapshot(hub);
@@ -1199,6 +1581,9 @@ function route(hub, approvals, dispatch, message) {
1199
1581
  case "approval.respond":
1200
1582
  approvals.respond(message);
1201
1583
  return;
1584
+ case "question.respond":
1585
+ questions.respond(message);
1586
+ return;
1202
1587
  case "dispatch.request":
1203
1588
  void dispatch.request(message).catch((error) => {
1204
1589
  hub.fail(`dispatch.request ${message.requestId}`, error);
@@ -1216,7 +1601,12 @@ function publishStatus(hub) {
1216
1601
  ts: Date.now(),
1217
1602
  type: "machine.status",
1218
1603
  machine: hub.config.machineName,
1219
- pluginVersion: PLUGIN_VERSION
1604
+ pluginVersion: PLUGIN_VERSION,
1605
+ capabilities: {
1606
+ fullAccessDispatch: hub.config.allowFullAccessDispatch,
1607
+ questionForwarding: true
1608
+ },
1609
+ allowedRoots: resolvedRoots(hub)
1220
1610
  });
1221
1611
  }
1222
1612
  async function publishSnapshot(hub) {