dsh-thoughtdag 0.5.20 → 0.5.21

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.
Files changed (86) hide show
  1. package/dist-app/assets/{abnfDiagram-VCTEODGH-BmlsnR28.js → abnfDiagram-VCTEODGH-BT9l2JtT.js} +1 -1
  2. package/dist-app/assets/{arc-4k4Kddh9.js → arc-BNeep0yS.js} +1 -1
  3. package/dist-app/assets/{architectureDiagram-5GKGNRK7-BuacmIpL.js → architectureDiagram-5GKGNRK7-BHbXwadP.js} +1 -1
  4. package/dist-app/assets/{blockDiagram-I7D4REHJ-JX7h4WWl.js → blockDiagram-I7D4REHJ-D3nGTeD_.js} +1 -1
  5. package/dist-app/assets/{c4Diagram-7LVT6UL2-Cp3pP9V9.js → c4Diagram-7LVT6UL2-YVhti_2s.js} +1 -1
  6. package/dist-app/assets/{canonical-B-X5o-6D.js → canonical-B6qH6YYL.js} +2 -2
  7. package/dist-app/assets/{canvas-record-CxDxerX8.js → canvas-record-DkWI-CqO.js} +1 -1
  8. package/dist-app/assets/channel-gkBiIKOr.js +1 -0
  9. package/dist-app/assets/{chunk-2Q5K7J3B-mR0xNUcf.js → chunk-2Q5K7J3B-DYzCoUl3.js} +1 -1
  10. package/dist-app/assets/{chunk-5VM5RSS4-B3FJnk33.js → chunk-5VM5RSS4-DVhgGApv.js} +1 -1
  11. package/dist-app/assets/{chunk-F27PBJKO-GG0xbyKU.js → chunk-F27PBJKO-IlrE7p6D.js} +1 -1
  12. package/dist-app/assets/{chunk-IMKFNOWR-CK5rKklp.js → chunk-IMKFNOWR-5rRCs1Ch.js} +1 -1
  13. package/dist-app/assets/{chunk-JWPE2WC7-CnjfwwXP.js → chunk-JWPE2WC7-C9Q2AivI.js} +1 -1
  14. package/dist-app/assets/{chunk-POPQ4Y6H-ISG0Axt5.js → chunk-POPQ4Y6H-DZVQLfkD.js} +1 -1
  15. package/dist-app/assets/{chunk-SVP7TREG-Di8Y7pYg.js → chunk-SVP7TREG-B4fYf5pA.js} +1 -1
  16. package/dist-app/assets/{chunk-TICWLB2K-DlYo009w.js → chunk-TICWLB2K-C5XVQCn2.js} +1 -1
  17. package/dist-app/assets/{chunk-XXDRQBXY-DSu27P5i.js → chunk-XXDRQBXY-BpSGHcjQ.js} +1 -1
  18. package/dist-app/assets/classDiagram-ZZMXUADV-D80YaymG.js +1 -0
  19. package/dist-app/assets/classDiagram-v2-VYDZK3BY-D80YaymG.js +1 -0
  20. package/dist-app/assets/{claude-code-session-BYx2_4G1.js → claude-code-session-CovsRibh.js} +1 -1
  21. package/dist-app/assets/{codex-session-BH6Ogwin.js → codex-session-uqqsgPZi.js} +1 -1
  22. package/dist-app/assets/{cose-bilkent-JH36ORCC-2jNLoiin.js → cose-bilkent-JH36ORCC-DbPE-v0C.js} +1 -1
  23. package/dist-app/assets/{cynefin-OW5HDTMX-CYtWRYx6.js → cynefin-OW5HDTMX-DOP83gQe.js} +1 -1
  24. package/dist-app/assets/{cynefinDiagram-5FMLGOSQ-Dht0kBaK.js → cynefinDiagram-5FMLGOSQ-CfgRM3kY.js} +1 -1
  25. package/dist-app/assets/{dagre-GXQ25YYZ-CdJwfp-j.js → dagre-GXQ25YYZ-DQIOhH_w.js} +1 -1
  26. package/dist-app/assets/{diagram-S7CK7UJ4-DvVIz9wN.js → diagram-S7CK7UJ4-rR0rrvz6.js} +1 -1
  27. package/dist-app/assets/{diagram-UQ7AKVKN-zaxruSox.js → diagram-UQ7AKVKN-CqOdeawW.js} +1 -1
  28. package/dist-app/assets/{diagram-VSXAHHWV-BpisZmMI.js → diagram-VSXAHHWV-BBezLj_3.js} +1 -1
  29. package/dist-app/assets/{diagram-VX7I27RA-CEC2DdCe.js → diagram-VX7I27RA-BHl93n8F.js} +1 -1
  30. package/dist-app/assets/{diagram-Z3DM3KII-BccyzzPj.js → diagram-Z3DM3KII-DkDme_j4.js} +1 -1
  31. package/dist-app/assets/{dsh-session-Bgvf-2ie.js → dsh-session-Bp2W7dlB.js} +1 -1
  32. package/dist-app/assets/{ebnfDiagram-PWID7BFC-iAT6gvfL.js → ebnfDiagram-PWID7BFC-OooS7sQm.js} +1 -1
  33. package/dist-app/assets/{erDiagram-RLTQ6QDP-Zixusc5i.js → erDiagram-RLTQ6QDP-fOqW-zut.js} +1 -1
  34. package/dist-app/assets/{experiment-loop-gxZgdQ3u.js → experiment-loop-C9J42t9U.js} +1 -1
  35. package/dist-app/assets/{flowDiagram-HODETNUW-B_X_09yE.js → flowDiagram-HODETNUW-CNd6YAVF.js} +1 -1
  36. package/dist-app/assets/{ganttDiagram-EL5Y4UJY-BcvE7rsg.js → ganttDiagram-EL5Y4UJY-KxqfLxb8.js} +1 -1
  37. package/dist-app/assets/{gitGraphDiagram-WWUBYQGX-hxOTtQbp.js → gitGraphDiagram-WWUBYQGX-dQIQH3pC.js} +1 -1
  38. package/dist-app/assets/{http-bridge-J5k9wd7t.js → http-bridge-CcF4Mr_J.js} +1 -1
  39. package/dist-app/assets/{index-Mh4X9Asm.js → index-BB-ek2_R.js} +1 -1
  40. package/dist-app/assets/index-BpLFj6tz.css +1 -0
  41. package/dist-app/assets/{index-DqAUkEeY.js → index-CnmO3-jd.js} +3 -3
  42. package/dist-app/assets/{index-CaJu4U8U.js → index-DDS2ZyPK.js} +212 -212
  43. package/dist-app/assets/{index-Tx9fHuzW.js → index-DLkiHg7Y.js} +1 -1
  44. package/dist-app/assets/{infoDiagram-27XIBGKW-BOyfWDQh.js → infoDiagram-27XIBGKW-DavckIjk.js} +1 -1
  45. package/dist-app/assets/{ishikawaDiagram-5VMMS53U-ubN-F3uw.js → ishikawaDiagram-5VMMS53U-i7Xpo8IQ.js} +1 -1
  46. package/dist-app/assets/{journeyDiagram-3NMN7TZE-EEUt8KFZ.js → journeyDiagram-3NMN7TZE-DE1ndGzq.js} +1 -1
  47. package/dist-app/assets/{kanban-definition-UXKFOSKX-BHoTyZ-H.js → kanban-definition-UXKFOSKX-C-blr0eT.js} +1 -1
  48. package/dist-app/assets/{layout-CQ6ZrisW.js → layout-p-7Amz1g.js} +1 -1
  49. package/dist-app/assets/{linear-LOsR28TU.js → linear-h0ohmGSS.js} +1 -1
  50. package/dist-app/assets/{mermaid.core-C0BcALIm.js → mermaid.core-VTvY09Mm.js} +6 -6
  51. package/dist-app/assets/{mindmap-definition-YA3MSWOX-C9OJtKZo.js → mindmap-definition-YA3MSWOX-C96efhfb.js} +1 -1
  52. package/dist-app/assets/{pegDiagram-XKGWAZYB-BVaiwlYa.js → pegDiagram-XKGWAZYB-9d_7fjiQ.js} +1 -1
  53. package/dist-app/assets/{pi-session-C1TO9XXu.js → pi-session-BeYQ7waM.js} +1 -1
  54. package/dist-app/assets/{pieDiagram-E7YTZNPT-Cl7s5yat.js → pieDiagram-E7YTZNPT-CziJ72ZE.js} +1 -1
  55. package/dist-app/assets/{quadrantDiagram-AXDQQJYC-D1468IWb.js → quadrantDiagram-AXDQQJYC-AyRLT_gg.js} +1 -1
  56. package/dist-app/assets/{railroadDiagram-O6MQD6OU-Qrp26LKj.js → railroadDiagram-O6MQD6OU-HtNgXPip.js} +1 -1
  57. package/dist-app/assets/{requirementDiagram-BXWQKSXE-DbN5V886.js → requirementDiagram-BXWQKSXE-CTko3n4U.js} +1 -1
  58. package/dist-app/assets/{sankeyDiagram-P5KCCOFB-LbmTEAz2.js → sankeyDiagram-P5KCCOFB-3BwvcXHm.js} +1 -1
  59. package/dist-app/assets/{sensitive-scan-Bra40lYM.js → sensitive-scan-DsbGxo7P.js} +1 -1
  60. package/dist-app/assets/{sequenceDiagram-WJ2MYXX4-CPXoMkcM.js → sequenceDiagram-WJ2MYXX4-BClxx6iH.js} +1 -1
  61. package/dist-app/assets/{session-handoff-CScqkePm.js → session-handoff-BKEd57_h.js} +2 -2
  62. package/dist-app/assets/{shared-Dli4ryBj.js → shared-EjEL_kdj.js} +1 -1
  63. package/dist-app/assets/{sizeCapture-INFHLROL-Ver6XJXZ.js → sizeCapture-INFHLROL-B0Qw24Wo.js} +1 -1
  64. package/dist-app/assets/{stateDiagram-D77RDMKH-CoTk522r.js → stateDiagram-D77RDMKH-DZ3bHvNK.js} +1 -1
  65. package/dist-app/assets/stateDiagram-v2-MP3YSRHH-DtcQuXpd.js +1 -0
  66. package/dist-app/assets/{swimlanes-42K2YHIH-CxnYRWCO.js → swimlanes-42K2YHIH-sooeHTXv.js} +1 -1
  67. package/dist-app/assets/swimlanesDiagram-VR7AAH4N-BGBHev6H.js +8 -0
  68. package/dist-app/assets/{timeline-definition-24CTP7MA-BPTTWz0t.js → timeline-definition-24CTP7MA-CQPwGFbP.js} +1 -1
  69. package/dist-app/assets/{turndown-plugin-gfm.cjs-BOuki0om.js → turndown-plugin-gfm.cjs-B_Wz5eXI.js} +1 -1
  70. package/dist-app/assets/{update-check-BsnTW6VR.js → update-check-D2R41pt1.js} +1 -1
  71. package/dist-app/assets/{vennDiagram-4TSXK5OY-KQ0VCvz7.js → vennDiagram-4TSXK5OY-D5w4wj7I.js} +1 -1
  72. package/dist-app/assets/{wardleyDiagram-VM6X3IG4-DoiF8Mk5.js → wardleyDiagram-VM6X3IG4-BLv5nXsU.js} +1 -1
  73. package/dist-app/assets/{xychartDiagram-S5SC5T6Z-CiO0NJVo.js → xychartDiagram-S5SC5T6Z-clbjh4DN.js} +1 -1
  74. package/dist-app/index.html +2 -2
  75. package/lib/index.js +296 -25
  76. package/lib/runtime/agents/README.md +4 -2
  77. package/lib/runtime/agents/claude.cjs +44 -3
  78. package/lib/runtime/agents/codex.cjs +31 -17
  79. package/lib/runtime/agents/ops.cjs +1 -1
  80. package/package.json +1 -1
  81. package/dist-app/assets/channel-GKL8NncG.js +0 -1
  82. package/dist-app/assets/classDiagram-ZZMXUADV-CfiMnbJv.js +0 -1
  83. package/dist-app/assets/classDiagram-v2-VYDZK3BY-CfiMnbJv.js +0 -1
  84. package/dist-app/assets/index-CTPL9Dm_.css +0 -1
  85. package/dist-app/assets/stateDiagram-v2-MP3YSRHH-DJacHmOW.js +0 -1
  86. package/dist-app/assets/swimlanesDiagram-VR7AAH4N-BgesyEx1.js +0 -8
package/lib/index.js CHANGED
@@ -570,7 +570,8 @@ async function modelsPayload(ctx, ownProviders = []) {
570
570
  return {
571
571
  models,
572
572
  default: def && models.some(m => m.id === def) ? def : (models[0]?.id ?? null),
573
- capabilities: { webSearch: ownProviders.some(p => isOpenRouterURL(p.baseURL)), searchEngine: 'none', scholarSearch: false, vision: models.some(m => m.vision) },
573
+ // the harness's own models search through the host's web service; an own OpenRouter interface through `:online`
574
+ capabilities: { webSearch: typeof ctx.web?.search === 'function' || ownProviders.some(p => isOpenRouterURL(p.baseURL)), searchEngine: 'none', scholarSearch: false, vision: models.some(m => m.vision) },
574
575
  harness: { routableProviders: cat.routableProviders ?? [], failures: (cat.failures ?? []).map(f => ({ provider: f.id, message: f.message })) },
575
576
  }
576
577
  }
@@ -734,6 +735,150 @@ async function callOf(ctx, body, target) {
734
735
  return { provider: target.provider, model: target.model, messages, ...(system.length ? { system: system.join('\n\n') } : {}) }
735
736
  }
736
737
 
738
+ // ── web search for the harness's own models ────────────────────────────
739
+ // The canvas's web toggle reaches a bare model as one tool, web_search,
740
+ // served by the host's web service (ctx.web): the host owns the backend
741
+ // (its own search, or DeepSeek's Search API once the person turns that on
742
+ // in the harness's web search settings) and the credential, so this plugin
743
+ // never holds a key. Results are numbered across the whole answer and
744
+ // stream back in the frames the app's own proxy uses: a { tool } ping per
745
+ // search, { sources } at the end.
746
+ // searches per answer, as the tool's description promises the model
747
+ const SEARCH_LIMIT = 3
748
+ const SEARCH_RESULTS = 5
749
+ // keep in step with makeTools() and the directive in server.mjs
750
+ const WEB_SEARCH_TOOL = {
751
+ name: 'web_search',
752
+ description:
753
+ 'ONLY for current events, time-sensitive facts, or specific verifiable claims you cannot answer confidently from your own knowledge. ' +
754
+ 'NEVER use for conceptual, definitional, reasoning or creative questions — answer those directly. ' +
755
+ 'Results are numbered [1], [2], ... — when you use information from a result, cite it inline as [n]. At most 3 searches per answer.',
756
+ parameters: {
757
+ type: 'object',
758
+ properties: { query: { type: 'string', description: 'The search query, in the language most likely to find good results' } },
759
+ required: ['query'],
760
+ additionalProperties: false,
761
+ },
762
+ }
763
+ const SEARCH_DIRECTIVE = [
764
+ 'Tools are AVAILABLE, not mandatory: first decide whether your own knowledge answers the question. Conceptual, definitional, reasoning and creative questions must be answered DIRECTLY, with no tool calls. Search only when the answer depends on current events, specific verifiable facts you are unsure of, or literature citations — or when the user explicitly asks you to look something up.',
765
+ 'After using any search tool, you MUST follow up with a complete answer that SYNTHESIZES the results in your own words — analyze and conclude, never just list the results.',
766
+ 'Cite sources inline as [n], using EXACTLY the bracket numbers shown in this turn\'s search results (they always start at [1]). Ignore any citation numbers appearing in earlier conversation messages — they refer to different sources.',
767
+ 'If the search results are not actually relevant to the question, say so explicitly and answer from your own knowledge instead of forcing citations.',
768
+ 'Never end your turn immediately after a search.',
769
+ ].join(' ')
770
+ const SEARCH_LIMIT_NOTE = '\n\n(That was the last search for this answer: write the final answer now from the results above.)'
771
+
772
+ /** Chunks of one model call assembled the way the host's own assembler
773
+ * does it (a closed block as the adapter sent it, an open one from its
774
+ * deltas), so a round's blocks line up with its replay state. */
775
+ function chunkAssembler() {
776
+ const parts = new Map()
777
+ const order = []
778
+ let finish = { kind: 'stop' }
779
+ let replayState
780
+ const at = (index, blockType) => {
781
+ let p = parts.get(index)
782
+ if (!p) { p = { blockType, text: '', args: '' }; parts.set(index, p); order.push(index) }
783
+ return p
784
+ }
785
+ return {
786
+ push(chunk) {
787
+ switch (chunk.type) {
788
+ case 'block-start': at(chunk.index, chunk.blockType); return
789
+ case 'text-delta':
790
+ case 'reasoning-delta': { const p = at(chunk.index, chunk.type === 'text-delta' ? 'text' : 'reasoning'); if (!p.block) p.text += chunk.text; return }
791
+ case 'tool-call-delta': {
792
+ const p = at(chunk.index, 'tool-call')
793
+ if (p.block) return
794
+ p.id = chunk.id
795
+ if (chunk.name) p.name = chunk.name
796
+ p.args += chunk.argumentsDelta ?? ''
797
+ return
798
+ }
799
+ case 'block-end': { const p = at(chunk.index, chunk.block.type); if (!p.block) p.block = chunk.block; return }
800
+ case 'finish': finish = chunk.reason; replayState = chunk.replayState; return
801
+ }
802
+ },
803
+ blocks: () => order.map((index) => {
804
+ const p = parts.get(index)
805
+ if (p.block) return p.block
806
+ if (p.blockType === 'tool-call') return { type: 'tool-call', id: p.id ?? `call-${index}`, name: p.name ?? '', arguments: p.args }
807
+ return p.blockType === 'text' || p.blockType === 'reasoning' ? { type: p.blockType, text: p.text } : null
808
+ }).filter(Boolean),
809
+ get finish() { return finish },
810
+ get replayState() { return replayState },
811
+ }
812
+ }
813
+
814
+ /** One web_search call run through the host; its results join `sources`
815
+ * under the next numbers, and the text the model reads comes back. */
816
+ async function webSearchFor(ctx, call, sources, write) {
817
+ let query = ''
818
+ try { query = String(JSON.parse(call.arguments || '{}')?.query ?? '').trim() } catch { /* malformed arguments */ }
819
+ if (!query) return { text: 'web_search needs a query.', isError: true }
820
+ write({ tool: { name: 'web_search', query } })
821
+ try {
822
+ const r = await ctx.web.search({ query, maxResults: SEARCH_RESULTS }, AbortSignal.timeout(60000))
823
+ const results = (r?.sources ?? []).filter(s => s?.url).map(s => ({
824
+ title: s.title || s.url,
825
+ url: s.url,
826
+ content: String(s.snippet ?? '').slice(0, 600),
827
+ ...(s.publishedAt ? { date: String(s.publishedAt).slice(0, 10) } : {}),
828
+ }))
829
+ const start = sources.length
830
+ sources.push(...results)
831
+ if (results.length === 0) return { text: 'No results found.' }
832
+ return { text: results.map((x, i) => `[${start + i + 1}] ${x.title}${x.date ? ` (${x.date})` : ''}\n${x.url}\n${x.content}`).join('\n\n') }
833
+ } catch (error) {
834
+ const why = [error?.code, error instanceof Error ? error.message : String(error)].filter(Boolean).join(': ')
835
+ return { text: `Search failed (${why}). Answer from your own knowledge and say that the search did not run.`, isError: true }
836
+ }
837
+ }
838
+
839
+ /** One answer from a harness model, streamed to the canvas as its frames.
840
+ * With search on, the model may call web_search up to SEARCH_LIMIT times:
841
+ * each round's blocks and results join the request (the way an agent keeps
842
+ * its own tool history) and the next round continues; a call past the
843
+ * limit is answered with "no more searches". */
844
+ async function harnessStream(ctx, target, options, search, write, isClosed) {
845
+ const sources = []
846
+ const messages = [...options.messages]
847
+ const request = search
848
+ ? { ...options, tools: [WEB_SEARCH_TOOL], system: options.system ? `${options.system}\n\n${SEARCH_DIRECTIVE}` : SEARCH_DIRECTIVE }
849
+ : options
850
+ let searched = 0
851
+ for (let round = 0; round <= SEARCH_LIMIT + 1; round++) {
852
+ const asm = chunkAssembler()
853
+ for await (const chunk of ctx.llm.stream({ ...request, messages })) {
854
+ if (isClosed()) return
855
+ asm.push(chunk)
856
+ if (chunk.type === 'text-delta' && chunk.text) write({ text: chunk.text })
857
+ else if (chunk.type === 'reasoning-delta' && chunk.text) write({ reasoning: chunk.text })
858
+ }
859
+ const finish = asm.finish
860
+ if (finish.kind === 'error') throw new Error(finish.failure?.message ?? finish.failure?.code ?? 'the model call failed')
861
+ if (finish.kind === 'aborted') return
862
+ // a call cut off by the output limit cannot run safely (the host's rule)
863
+ const calls = finish.kind === 'max-tokens' ? [] : asm.blocks().filter(b => b.type === 'tool-call')
864
+ if (!search || calls.length === 0 || round === SEARCH_LIMIT + 1) break
865
+ messages.push({
866
+ id: 'td-' + randomUUID(), role: 'assistant', content: asm.blocks(),
867
+ source: { kind: 'model', provider: target.provider, model: target.model, ...(asm.replayState ? { replayState: asm.replayState } : {}) },
868
+ })
869
+ for (const call of calls) {
870
+ const allowed = call.name === WEB_SEARCH_TOOL.name && searched < SEARCH_LIMIT
871
+ if (allowed) searched++
872
+ const r = allowed
873
+ ? await webSearchFor(ctx, call, sources, write)
874
+ : { text: 'No more searches for this answer: write the final answer now from the results above.', isError: true }
875
+ const text = allowed && searched === SEARCH_LIMIT ? r.text + SEARCH_LIMIT_NOTE : r.text
876
+ messages.push({ id: 'td-' + randomUUID(), role: 'tool', toolCallId: call.id, content: [{ type: 'text', text }], source: { kind: 'tool', callId: call.id }, ...(r.isError ? { isError: true } : {}) })
877
+ }
878
+ }
879
+ if (sources.length > 0) write({ sources })
880
+ }
881
+
737
882
  /** The canvas's compiled messages, split for the agent: the last user
738
883
  * message is the question; everything before it is the context the canvas
739
884
  * wired in, rendered as one block the harness receives ahead of the turn. */
@@ -774,43 +919,152 @@ function toolQuery(name, args) {
774
919
  /** sessionId → the canvas turn running in it: its frame emitter, its
775
920
  * close probe, and the tool calls seen so far (callId → name, arguments). */
776
921
  const canvasTurns = new Map()
777
- /** approval id → the resolver waiting for the person's decision */
922
+ /** approval or question id → the resolver waiting for the person's decision */
778
923
  const pendingApprovals = new Map()
924
+ /** sessionId → the exact steps the person allowed for the rest of the
925
+ * session ("tool name + its arguments"; the harness's own approvals are
926
+ * one-shot, so the standing allowance lives here, in memory) */
927
+ const sessionRules = new Map()
928
+
929
+ const APPROVAL_OUTCOMES = new Set(['allowed-once', 'allowed-session', 'rejected', 'cancelled'])
930
+
931
+ /** Wait for the person's answer to one canvas card, the canvas stream going
932
+ * away, or the harness withdrawing the request — whichever comes first. */
933
+ async function personDecides(id, sessionId, entry, signal, kind) {
934
+ let clear
935
+ const decision = new Promise(resolve => { pendingApprovals.set(id, { resolve, sessionId, kind }) })
936
+ const closed = new Promise(resolve => { const t = setInterval(() => { if (entry.isClosed()) { clearInterval(t); resolve('closed') } }, 500); clear = () => clearInterval(t) })
937
+ const aborted = new Promise(resolve => {
938
+ if (!signal) return
939
+ if (signal.aborted) resolve('aborted')
940
+ else signal.addEventListener('abort', () => resolve('aborted'), { once: true })
941
+ })
942
+ const r = await Promise.race([decision, closed, aborted])
943
+ pendingApprovals.delete(id)
944
+ clear?.()
945
+ return r
946
+ }
947
+
948
+ // what a step does, without the prose the model writes around it each time
949
+ // (a bash call carries a fresh description and justification on every try)
950
+ const PROSE_ARGS = new Set(['description', 'justification', 'reason', 'rationale', 'explanation', 'summary', 'title'])
951
+ function stepKey(args) {
952
+ let v = args
953
+ if (typeof v === 'string') { try { v = JSON.parse(v) } catch { return v } }
954
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return JSON.stringify(v ?? null)
955
+ return JSON.stringify(Object.fromEntries(Object.keys(v).filter(k => !PROSE_ARGS.has(k)).sort().map(k => [k, v[k]])))
956
+ }
779
957
 
780
- const APPROVAL_OUTCOMES = new Set(['allowed-once', 'rejected'])
958
+ /** The canvas turn a harness request belongs to, or null when it is the
959
+ * person's own (another turn of the same session) or no canvas is watching. */
960
+ function canvasTurnOf(agent) {
961
+ const entry = canvasTurns.get(agent?.id)
962
+ if (!entry || entry.turn === null || entry.current() !== entry.turn) return null
963
+ return entry
964
+ }
781
965
 
782
966
  function installApprovalAnswerer(ctx) {
783
967
  ctx.on('approval/request', async function (req, next) {
784
- const entry = canvasTurns.get(req?.agent?.id)
785
- if (!entry) return next()
786
968
  // an approval raised by another turn of the same session (the person's
787
969
  // own tool call, running before or after ours) stays with the harness UI
788
- if (entry.turn === null || entry.current() !== entry.turn) return next()
970
+ const entry = canvasTurnOf(req?.agent)
971
+ if (!entry) return next()
972
+ const sessionId = req.agent.id
789
973
  const id = 'td-' + randomUUID()
790
974
  const call = req.callId !== undefined ? entry.calls.get(req.callId) : undefined
791
975
  const name = call?.name ?? req.toolName
976
+ const args = typeof call?.arguments === 'string' ? call.arguments : (call?.arguments ? JSON.stringify(call.arguments) : null)
977
+ const query = call ? toolQuery(name, call.arguments) : ''
978
+ // a standing allowance covers exactly this step: same tool, same arguments
979
+ const rule = call ? `harness:${name}:${stepKey(call.arguments)}` : null
980
+ if (rule && sessionRules.get(sessionId)?.has(rule)) {
981
+ entry.emit({ approvalDecided: { id, outcome: 'allowed-session', auto: true, name, query, rule } })
982
+ return 'allowed-once'
983
+ }
792
984
  entry.emit({ approval: {
793
985
  id, toolName: req.toolName, callId: req.callId ?? null, reason: req.reason ?? null,
794
- name, query: call ? toolQuery(name, call.arguments) : '', arguments: typeof call?.arguments === 'string' ? call.arguments : (call?.arguments ? JSON.stringify(call.arguments) : null),
986
+ name, query, arguments: args, ...(rule ? { rule } : {}),
795
987
  } })
796
- let clear
797
- const decision = new Promise(resolve => { pendingApprovals.set(id, { resolve, sessionId: req.agent.id }) })
798
- const closed = new Promise(resolve => { const t = setInterval(() => { if (entry.isClosed()) { clearInterval(t); resolve('closed') } }, 500); clear = () => clearInterval(t) })
799
- const aborted = new Promise(resolve => {
800
- if (!req.signal) return
801
- if (req.signal.aborted) resolve('aborted')
802
- else req.signal.addEventListener('abort', () => resolve('aborted'), { once: true })
803
- })
804
- const r = await Promise.race([decision, closed, aborted])
805
- pendingApprovals.delete(id)
806
- clear?.()
988
+ const r = await personDecides(id, sessionId, entry, req.signal, 'approval')
807
989
  if (r === 'closed') return next()
808
990
  const outcome = r === 'aborted' ? 'cancelled' : r
809
- entry.emit({ approvalDecided: { id, outcome } })
810
- return outcome
991
+ if (outcome === 'allowed-session' && rule) {
992
+ if (!sessionRules.has(sessionId)) sessionRules.set(sessionId, new Set())
993
+ sessionRules.get(sessionId).add(rule)
994
+ }
995
+ entry.emit({ approvalDecided: { id, outcome, ...(outcome === 'allowed-session' && rule ? { rule } : {}) } })
996
+ return outcome === 'allowed-session' ? 'allowed-once' : outcome
997
+ }, true)
998
+
999
+ // the agent's own questions (ask_user_question, and plan review on leaving
1000
+ // plan mode): one form on the node, the answers back in the harness's
1001
+ // vocabulary — a skip answers every question with nothing picked
1002
+ ctx.on('user-questions/request', async function (req, next) {
1003
+ const entry = canvasTurnOf(req?.agent)
1004
+ if (!entry) return next()
1005
+ const questions = (Array.isArray(req.questions) ? req.questions : []).filter(q => q && typeof q.id === 'string').map(q => ({
1006
+ id: q.id, question: String(q.question ?? ''),
1007
+ ...(typeof q.header === 'string' ? { header: q.header } : {}),
1008
+ ...(typeof q.detail === 'string' ? { detail: q.detail } : {}),
1009
+ options: (Array.isArray(q.options) ? q.options : []).filter(o => o && typeof o.label === 'string').map(o => ({ label: o.label, ...(typeof o.description === 'string' ? { description: o.description } : {}) })),
1010
+ multiSelect: !!q.multiSelect, custom: true,
1011
+ }))
1012
+ if (questions.length === 0) return next()
1013
+ const id = 'td-' + randomUUID()
1014
+ entry.emit({ approval: {
1015
+ id, kind: 'form', toolName: 'ask_user_question', callId: req.callId ?? null, reason: null,
1016
+ name: 'ask_user_question', query: questions.map(q => q.question).join('\n'), arguments: null, questions,
1017
+ } })
1018
+ const r = await personDecides(id, req.agent.id, entry, req.signal, 'form')
1019
+ if (r === 'closed') return next()
1020
+ if (r === 'aborted') { entry.emit({ approvalDecided: { id, outcome: 'cancelled' } }); throw new Error('the question was withdrawn') }
1021
+ const given = r !== 'cancelled' && Array.isArray(r?.answers) ? r.answers : []
1022
+ const answers = questions.map((q) => {
1023
+ const a = given.find(x => x && x.id === q.id)
1024
+ const selected = Array.isArray(a?.selected) ? a.selected.filter(x => typeof x === 'string' && q.options.some(o => o.label === x)) : []
1025
+ const custom = typeof a?.custom === 'string' && a.custom.trim() ? a.custom.trim() : undefined
1026
+ return { id: q.id, selected, ...(custom !== undefined ? { custom } : {}) }
1027
+ })
1028
+ const shown = answers.filter(a => a.selected.length || a.custom).map(a => `${questions.find(q => q.id === a.id)?.header ?? a.id} → ${[...a.selected, ...(a.custom ? [a.custom] : [])].join(', ')}`).join('; ')
1029
+ entry.emit({ approvalDecided: { id, outcome: given.length ? 'answered' : 'cancelled', value: shown || null } })
1030
+ return { answers }
811
1031
  }, true)
812
1032
  }
813
1033
 
1034
+ // ── the canvas's permission mode, for the turns it starts ───────────────
1035
+ // The canvas has one switch for every agent it runs (ask / auto / allow).
1036
+ // Here it maps onto the harness's own permission presets: 'auto' is its
1037
+ // Auto review (present when the person installed that integration),
1038
+ // 'allow' is full access; 'ask' leaves the session as the person set it.
1039
+ // The preset is switched for the canvas's turn only and switched back when
1040
+ // the turn ends, so the session keeps its own setting in the harness.
1041
+ const CANVAS_PRESETS = { auto: 'auto', allow: 'danger-full-access' }
1042
+ let permissionPresets = null
1043
+
1044
+ /** Apply the canvas's mode to one turn; returns the undo, or null. */
1045
+ function canvasPermission(ctx, agent, mode, emit) {
1046
+ const want = CANVAS_PRESETS[mode]
1047
+ if (!want) return null
1048
+ const presets = permissionPresets
1049
+ const session = agent?.session
1050
+ const report = (applied, reason) => emit({ permission: { mode, applied, ...(reason ? { reason } : {}) } })
1051
+ if (!presets || !session) { report(false, 'unavailable'); return null }
1052
+ try {
1053
+ if (!presets.names.includes(want)) { report(false, 'unavailable'); return null }
1054
+ const before = presets.current(session)
1055
+ if (before === want) { report(true); return null }
1056
+ // a hand-tuned mix of sandbox and approval has no preset to come back to
1057
+ if (before === 'custom') { report(false, 'custom'); return null }
1058
+ const set = (name) => presets.apply(session, name, (policy) => ctx.approval.setPolicy(agent, policy))
1059
+ set(want)
1060
+ report(true)
1061
+ return () => { try { if (presets.current(session) === want) set(before) } catch { /* the session closed */ } }
1062
+ } catch (error) {
1063
+ report(false, 'failed')
1064
+ return null
1065
+ }
1066
+ }
1067
+
814
1068
  /** Run one question through the harness's agent loop, continuing a mirrored
815
1069
  * session, forking a dsh mirror at the parent's exact turn, or creating a
816
1070
  * fresh session. Report progress as SSE-style frames via `emit`; resolves
@@ -859,6 +1113,7 @@ async function runAgentTurn(ctx, body, emit, isClosed, { answerApprovals = true
859
1113
  const picked = agentTargetOf(body?.model)
860
1114
  if (picked && !hasImages) await ctx.sessionController.selectModel({ sessionId, provider: picked.provider, model: picked.model }).catch(() => {})
861
1115
  const agent = await agentOf(ctx, sessionId)
1116
+ const restorePermission = canvasPermission(ctx, agent, body?.harness?.permission, emit)
862
1117
  let sawChunk = false // text deltas reached us live
863
1118
  let sawReasoning = false // reasoning deltas reached us live
864
1119
  let fullText = ''
@@ -957,6 +1212,7 @@ async function runAgentTurn(ctx, body, emit, isClosed, { answerApprovals = true
957
1212
  if (typeof off === 'function') off()
958
1213
  if (typeof offStream === 'function') offStream()
959
1214
  if (canvasTurns.get(sessionId) === turnEntry) canvasTurns.delete(sessionId)
1215
+ restorePermission?.()
960
1216
  }
961
1217
  return { sessionId, text: fullText }
962
1218
  }
@@ -1272,12 +1528,24 @@ export async function apply(ctx, config) {
1272
1528
  registeredProviders = ownProvidersOf(body?.providers)
1273
1529
  return sendJson(res, 200, await modelsPayload(ctx, registeredProviders))
1274
1530
  }
1531
+ // which of the canvas's permission modes this host can honour for its own agent
1532
+ if (path === '/permission-modes' && req.method === 'GET') {
1533
+ let names = []
1534
+ try { names = permissionPresets ? [...permissionPresets.names] : [] } catch { /* the service went away */ }
1535
+ return sendJson(res, 200, { auto: names.includes(CANVAS_PRESETS.auto), allow: names.includes(CANVAS_PRESETS.allow) })
1536
+ }
1275
1537
  const approvalRoute = /^\/approvals\/([^/]+)$/.exec(path)
1276
1538
  if (approvalRoute !== null && req.method === 'POST') {
1277
1539
  const id = decodeURIComponent(approvalRoute[1])
1278
1540
  const body = await readJson(req, MAX_WRITE_BODY_BYTES)
1279
1541
  const pending = pendingApprovals.get(id)
1280
1542
  if (!pending) return sendJson(res, 404, { error: 'no such pending approval' })
1543
+ if (pending.kind === 'form') {
1544
+ // a form takes its answers, or a skip
1545
+ if (Array.isArray(body?.answers)) { pending.resolve({ answers: body.answers }); return sendJson(res, 200, { id, outcome: 'answered' }) }
1546
+ pending.resolve('cancelled')
1547
+ return sendJson(res, 200, { id, outcome: 'cancelled' })
1548
+ }
1281
1549
  const outcome = APPROVAL_OUTCOMES.has(body?.outcome) ? body.outcome : 'rejected'
1282
1550
  pending.resolve(outcome)
1283
1551
  return sendJson(res, 200, { id, outcome })
@@ -1328,11 +1596,8 @@ export async function apply(ctx, config) {
1328
1596
  let closed = false
1329
1597
  req.on('close', () => { closed = true })
1330
1598
  try {
1331
- for await (const chunk of ctx.llm.stream(options)) {
1332
- if (closed) break
1333
- if (chunk.type === 'text-delta' && chunk.text) res.write(`data: ${JSON.stringify({ text: chunk.text })}\n\n`)
1334
- else if (chunk.type === 'reasoning-delta' && chunk.text) res.write(`data: ${JSON.stringify({ reasoning: chunk.text })}\n\n`)
1335
- }
1599
+ const search = body?.webSearch === true && typeof ctx.web?.search === 'function'
1600
+ await harnessStream(ctx, target, options, search, frame => { if (!closed) res.write(`data: ${JSON.stringify(frame)}\n\n`) }, () => closed)
1336
1601
  if (!closed) res.write('data: [DONE]\n\n')
1337
1602
  } catch (error) {
1338
1603
  if (!closed) res.write(`data: ${JSON.stringify({ error: error instanceof Error ? error.message : String(error) })}\n\n`)
@@ -1414,5 +1679,11 @@ export async function apply(ctx, config) {
1414
1679
  ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: prefix, handler: staticHandler }), 'thoughtdag: static')
1415
1680
  ctx.logger.info('[dsh-thoughtdag] ThoughtDAG mounted at ' + prefix + '/')
1416
1681
  installApprovalAnswerer(ctx)
1682
+ // the permission presets service is optional: hosts without it keep the canvas's 'ask'
1683
+ ctx.inject(['permissionPresets'], (pctx) => {
1684
+ const svc = pctx.permissionPresets
1685
+ permissionPresets = svc
1686
+ pctx.effect(() => () => { if (permissionPresets === svc) permissionPresets = null }, 'thoughtdag: permission presets')
1687
+ })
1417
1688
  await installWhyLayer(ctx, config)
1418
1689
  }
@@ -5,7 +5,7 @@ One file per runtime (CommonJS, `.cjs`, so it loads the same under any package t
5
5
  - `available()` → where the binary is, or null
6
6
  - `models()` → `{ installed, models: [{ provider, id, name, reasoning, vision, efforts, defaultEffort }], default }`; when the CLI is nowhere, `{ installed: false, searched: [dirs] }` names every directory the lookup walked (see `where.cjs`: the process PATH, then the login shell's PATH, then the usual homes and Node version managers), and the next ask looks again — `efforts` are the effort levels the CLI accepts for that model, in its own words, read from the CLI (empty when it has none); `defaultEffort` its own default when it says
7
7
  - `run(request, onEvent)` → run id at once; events follow, tagged with it. The request names the runtime, an absolute `cwd`, one `prompt`, optional `images`, a `model` from that runtime's own list, a session to continue (`sessionPath`, plus `forkEntryId` to branch it), and optionally `effort`: one of the levels the runtime itself listed for that model, in its own words (Codex from `model/list`, Claude Code and Pi from their `--help`), which it passes on as is (`--effort`, the turn's `effort`, `set_thinking_level`). A word the runtime did not list is dropped: the CLI's own setting stands.
8
- - `abort(runId)`, `answer(runId, requestId, { confirmed } | { value } | { cancelled: true })`, `shutdown()`
8
+ - `abort(runId)`, `answer(runId, requestId, { confirmed } | { value } | { answers } | { cancelled: true })`, `shutdown()`
9
9
 
10
10
  A run emits exactly these event kinds (the renderer reads nothing else):
11
11
 
@@ -14,11 +14,13 @@ A run emits exactly these event kinds (the renderer reads nothing else):
14
14
  | `session` | which session file this run writes to, and where (cwd); `model.resolved` names the real model when the run used an alias (Claude Code's `opus` → `claude-opus-5`); `effort` is the level the turn actually ran at, read back from the runtime's own record (Pi's state, Codex's rollout `turn_context`, Claude Code's transcript entries), never assumed |
15
15
  | `message_update` with a text or thinking delta | the answer or the reasoning growing |
16
16
  | `tool_execution_start` / `tool_execution_end` | a tool call, for the live trace |
17
- | `question` | the agent asks the person: confirm / select / input / editor. A confirm may carry `rule`, an opaque key naming what "allow for this conversation" would cover (Claude Code: its own permission suggestion; Codex: the command, or all file changes); the answer `{ confirmed: true, scope: 'session' }` allows it for good — the runtime tells the CLI where it can (Claude Code `updatedPermissions` scoped to the session, Codex `acceptForSession`) and the canvas keeps the rule on the record, sending every such rule back as `allowRules` on later runs of this conversation |
17
+ | `question` | the agent asks the person: confirm / select / input / editor, or a `form` — several `questions` answered together (Claude Code's AskUserQuestion, Codex's request_user_input), each with options (label, description), `multiSelect`, `custom` (own words taken) and `secret`; the answer is `{ answers: [{ id, selected, custom? }] }`. A confirm may carry `rule`, an opaque key naming what "allow for this conversation" would cover (Claude Code: its own permission suggestion; Codex: the command, or all file changes); the answer `{ confirmed: true, scope: 'session' }` allows it for good — the runtime tells the CLI where it can (Claude Code `updatedPermissions` scoped to the session, Codex `acceptForSession`) and the canvas keeps the rule on the record, sending every such rule back as `allowRules` on later runs of this conversation |
18
18
  | `question_answered` | the person's answer went back (record); with `auto: true` the runtime decided from a standing rule of this conversation and nobody was asked — the record is made from its `title`/`message`/`rule` |
19
19
  | `fs_changes` | what the shell saw change on disk (produced here, not by the agent) |
20
20
  | `run_end` / `run_error` | the turn is over |
21
21
 
22
+ The canvas's permission mode reaches every runtime through `<cwd>/.thoughtdag/guard.json` (`{ mode: 'ask' | 'auto' | 'allow', allow: [dirs] }`): Pi's guard reads it on every call (`auto` runs as `ask`; `allow` dirs pass); Codex takes it at thread start or resume (ask: on-request in the workspace sandbox; auto: the same with `approvalsReviewer: 'auto_review'`; allow: never, full access); Claude Code at spawn (ask: `default`; auto: `auto`; allow: `bypassPermissions`). Claude Code's prompt channel stays open in every mode, so its questions reach the person even when nothing needs approval.
23
+
22
24
  Nothing starts at launch. A runtime's process (Pi's rpc, Codex's app-server, a Pi catalog read) is started by the first request that needs it and retires after ten minutes without one (`TD_AGENT_IDLE_MS`); Claude Code runs one process per turn. A launch that never opens the agent group never starts a CLI.
23
25
 
24
26
  The finished turn is adopted from the session file by the atlas's adapter for that
@@ -95,11 +95,15 @@ async function sessionFileFor(cwd, sessionId) {
95
95
  // UUID of its own (a project path that names a session), so take the LAST
96
96
  const sessionIdOf = (sessionPath) => { const all = String(sessionPath ?? '').match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi); return all && all.length ? all[all.length - 1] : null; };
97
97
 
98
- /** The guard file the canvas writes per working directory, as a permission mode. */
98
+ /** The guard file the canvas writes per working directory, as a permission
99
+ * mode: ask → the CLI's default (it asks before edits and commands), auto →
100
+ * its classifier decides and asks only what it holds risky, allow → nothing
101
+ * asked. Questions the model puts to the person reach the canvas in all three. */
99
102
  async function permissionModeFor(cwd) {
100
103
  try {
101
104
  const raw = JSON.parse(await fsp.readFile(path.join(cwd, '.thoughtdag', 'guard.json'), 'utf8'));
102
105
  if (raw?.mode === 'allow') return 'bypassPermissions';
106
+ if (raw?.mode === 'auto') return 'auto';
103
107
  } catch { /* default */ }
104
108
  return 'default';
105
109
  }
@@ -148,8 +152,9 @@ function createClaudeRuntime({ log } = {}) {
148
152
  const mode = await permissionModeFor(cwd);
149
153
  const model = req.model && typeof req.model === 'object' && req.model.id && req.model.id !== 'default' ? req.model.id : null;
150
154
  const resumeId = req.sessionPath ? sessionIdOf(req.sessionPath) : null;
151
- const args = ['-p', '--output-format', 'stream-json', '--input-format', 'stream-json', '--verbose', '--include-partial-messages', '--permission-mode', mode];
152
- if (mode !== 'bypassPermissions') args.push('--permission-prompt-tool', 'stdio');
155
+ // the prompt channel stays open in every mode: with nothing to
156
+ // approve, AskUserQuestion still comes through it
157
+ const args = ['-p', '--output-format', 'stream-json', '--input-format', 'stream-json', '--verbose', '--include-partial-messages', '--permission-mode', mode, '--permission-prompt-tool', 'stdio'];
153
158
  if (model) args.push('--model', model);
154
159
  // only a level the CLI itself lists; anything else means its default
155
160
  if (req.effort && (await effortsOf(b)).includes(req.effort)) args.push('--effort', req.effort);
@@ -213,6 +218,42 @@ function createClaudeRuntime({ log } = {}) {
213
218
  if (r.subtype !== 'can_use_tool') { write(proc, { type: 'control_response', response: { subtype: 'error', request_id: m.request_id, error: 'unsupported request' } }); break; }
214
219
  const id = `q-${m.request_id}`;
215
220
  const input = r.input ?? {};
221
+ // the model's own questions for the person: one form, answered
222
+ // through the same channel — the answers ride back in the tool's
223
+ // input, keyed by each question's text (a skip is a refusal the
224
+ // model reads as "not answered")
225
+ if (r.tool_name === 'AskUserQuestion' && Array.isArray(input.questions)) {
226
+ const qs = input.questions.filter((q) => q && typeof q.question === 'string');
227
+ state.asks.set(id, (answer) => {
228
+ const given = Array.isArray(answer?.answers) ? answer.answers : null;
229
+ if (!given || answer?.cancelled) {
230
+ write(proc, { type: 'control_response', response: { subtype: 'success', request_id: m.request_id, response: { behavior: 'deny', message: 'The person skipped these questions without answering.' } } });
231
+ mark({ type: 'question_answered', id, outcome: 'cancelled', value: null });
232
+ return;
233
+ }
234
+ const answers = {};
235
+ qs.forEach((q, i) => {
236
+ const a = given.find((x) => x && x.id === String(i));
237
+ if (!a) return;
238
+ const own = typeof a.custom === 'string' && a.custom.trim() ? a.custom.trim() : null;
239
+ const picked = (Array.isArray(a.selected) ? a.selected : []).filter((x) => typeof x === 'string');
240
+ if (q.multiSelect) { const all = own ? [...picked, own] : picked; if (all.length) answers[q.question] = all; }
241
+ else if (own || picked[0]) answers[q.question] = own ?? picked[0];
242
+ });
243
+ write(proc, { type: 'control_response', response: { subtype: 'success', request_id: m.request_id, response: { behavior: 'allow', updatedInput: { ...input, answers } } } });
244
+ mark({ type: 'question_answered', id, outcome: 'answered', value: Object.entries(answers).map(([k, v]) => `${k} → ${Array.isArray(v) ? v.join(', ') : v}`).join('; ') || null });
245
+ });
246
+ mark({
247
+ type: 'question', kind: 'form', id, title: 'AskUserQuestion', message: qs.map((q) => q.question).join('\n'),
248
+ questions: qs.map((q, i) => ({
249
+ id: String(i), question: q.question, ...(typeof q.header === 'string' ? { header: q.header } : {}),
250
+ options: (Array.isArray(q.options) ? q.options : []).filter((o) => o && typeof o.label === 'string').map((o) => ({ label: o.label, ...(typeof o.description === 'string' ? { description: o.description } : {}) })),
251
+ multiSelect: !!q.multiSelect, custom: true,
252
+ })),
253
+ options: [], placeholder: null, prefill: null, paths: [], suggest: null,
254
+ });
255
+ break;
256
+ }
216
257
  const line = typeof input.command === 'string' ? input.command : (input.file_path ?? input.path ?? JSON.stringify(input).slice(0, 200));
217
258
  // the CLI's own "don't ask again" suggestion names what a standing
218
259
  // allowance covers; sent back scoped to the session, never to settings
@@ -25,13 +25,15 @@ let located = null;
25
25
  const findCodex = () => (located ??= locate('codex', EXTRA_HOMES));
26
26
  const childEnv = () => envWith(EXTRA_HOMES);
27
27
 
28
- /** The guard file the canvas writes per working directory, as Codex policy. */
28
+ /** The guard file the canvas writes per working directory, as Codex policy:
29
+ * ask → the workspace sandbox, escalations asked of the person; auto → the
30
+ * same, with escalations decided by Codex's own reviewer (anything it turns
31
+ * down still comes to the person); allow → no sandbox, nothing asked. */
29
32
  async function policyFor(cwd) {
30
- try {
31
- const raw = JSON.parse(await fsp.readFile(path.join(cwd, '.thoughtdag', 'guard.json'), 'utf8'));
32
- if (raw?.mode === 'allow') return { approvalPolicy: 'never', sandbox: 'danger-full-access' };
33
- } catch { /* default */ }
34
- return { approvalPolicy: 'on-request', sandbox: 'workspace-write' };
33
+ let mode = 'ask';
34
+ try { mode = JSON.parse(await fsp.readFile(path.join(cwd, '.thoughtdag', 'guard.json'), 'utf8'))?.mode ?? 'ask'; } catch { /* default */ }
35
+ if (mode === 'allow') return { approvalPolicy: 'never', sandbox: 'danger-full-access', approvalsReviewer: 'user' };
36
+ return { approvalPolicy: 'on-request', sandbox: 'workspace-write', approvalsReviewer: mode === 'auto' ? 'auto_review' : 'user' };
35
37
  }
36
38
 
37
39
  // the thread id is in the rollout's file name; take the LAST UUID in the
@@ -242,17 +244,29 @@ function createCodexRuntime({ log } = {}) {
242
244
  return ask({ kind: 'confirm', title: 'more permissions requested', message: [params.reason, JSON.stringify(params.permissions ?? {})].filter(Boolean).join('\n'), options: [], placeholder: null, prefill: null, paths: [], suggest: null },
243
245
  (a, id) => { const yes = !!a?.confirmed; reply({ permissions: yes ? (params.permissions ?? {}) : {}, scope: 'turn' }); decided(id, yes ? 'allowed-once' : 'rejected'); });
244
246
  case 'item/tool/requestUserInput': {
245
- // one question at a time; the answers go back together
246
- const qs = Array.isArray(params.questions) ? params.questions : [];
247
- const answers = {};
248
- const next = (i) => {
249
- if (i >= qs.length) { reply({ answers }); return; }
250
- const q = qs[i];
251
- const opts = (q.options ?? []).map((o) => o.label);
252
- ask({ kind: opts.length ? 'select' : 'input', title: q.header || 'question', message: q.question || '', options: opts, placeholder: q.isOther ? 'other…' : null, prefill: null, paths: [], suggest: null },
253
- (a, id) => { const v = typeof a?.value === 'string' ? a.value : ''; answers[q.id] = { answers: v ? [v] : [] }; decided(id, v ? 'answered' : 'cancelled', v || null); next(i + 1); });
254
- };
255
- return next(0);
247
+ // the questions as one form; the answers go back together, keyed by
248
+ // question id (a secret answer stays out of the record)
249
+ const qs = (Array.isArray(params.questions) ? params.questions : []).filter((q) => q && typeof q.id === 'string');
250
+ return ask({
251
+ kind: 'form', title: 'request_user_input', message: qs.map((q) => q.question || q.header || '').join('\n'),
252
+ questions: qs.map((q) => ({
253
+ id: q.id, question: q.question || '', ...(q.header ? { header: q.header } : {}),
254
+ options: (Array.isArray(q.options) ? q.options : []).filter((o) => o && typeof o.label === 'string').map((o) => ({ label: o.label, ...(o.description ? { description: o.description } : {}) })),
255
+ custom: !!q.isOther || !(Array.isArray(q.options) && q.options.length), secret: !!q.isSecret,
256
+ })),
257
+ options: [], placeholder: null, prefill: null, paths: [], suggest: null,
258
+ }, (a, id) => {
259
+ const given = Array.isArray(a?.answers) ? a.answers : [];
260
+ const answers = {};
261
+ for (const q of qs) {
262
+ const g = given.find((x) => x && x.id === q.id);
263
+ const own = typeof g?.custom === 'string' && g.custom.trim() ? [g.custom.trim()] : [];
264
+ answers[q.id] = { answers: [...(Array.isArray(g?.selected) ? g.selected.filter((x) => typeof x === 'string') : []), ...own] };
265
+ }
266
+ reply({ answers });
267
+ const shown = qs.filter((q) => !q.isSecret && answers[q.id].answers.length).map((q) => `${q.header || q.question} → ${answers[q.id].answers.join(', ')}`).join('; ');
268
+ decided(id, given.length ? 'answered' : 'cancelled', shown || null);
269
+ });
256
270
  }
257
271
  default:
258
272
  return write({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: `unsupported request ${msg.method}` } });
@@ -45,7 +45,7 @@ async function writeGuard(cwd, config) {
45
45
  if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) return false;
46
46
  const dir = path.join(cwd, '.thoughtdag');
47
47
  await fsp.mkdir(dir, { recursive: true });
48
- const mode = config?.mode === 'allow' ? 'allow' : 'ask';
48
+ const mode = config?.mode === 'allow' || config?.mode === 'auto' ? config.mode : 'ask';
49
49
  const allow = Array.isArray(config?.allow) ? config.allow.filter((x) => typeof x === 'string' && path.isAbsolute(x)).slice(0, 100) : [];
50
50
  await fsp.writeFile(path.join(dir, 'guard.json'), JSON.stringify({ mode, allow }, null, 2));
51
51
  return true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-thoughtdag",
3
- "version": "0.5.20",
3
+ "version": "0.5.21",
4
4
  "description": "ThoughtDAG for DeepSeek Harness: turn the current DSH session into an editable thought graph on an infinite canvas. Source, releases and issues: github.com/chenxiachan/thoughtdag",
5
5
  "author": "Xia Chen",
6
6
  "repository": {
@@ -1 +0,0 @@
1
- import{U as a,C as n}from"./mermaid.core-C0BcALIm.js";const t=(r,o)=>a.lang.round(n.parse(r)[o]);export{t as c};
@@ -1 +0,0 @@
1
- import{s as a,c as s,a as e,C as t}from"./chunk-TICWLB2K-DlYo009w.js";import{_ as i}from"./mermaid.core-C0BcALIm.js";import"./index-CaJu4U8U.js";import"./chunk-5VM5RSS4-B3FJnk33.js";import"./chunk-XXDRQBXY-DSu27P5i.js";import"./chunk-POPQ4Y6H-ISG0Axt5.js";import"./chunk-F27PBJKO-GG0xbyKU.js";import"./purify.es-Cz4mVeUR.js";var f={parser:e,get db(){return new t},renderer:s,styles:a,init:i(r=>{r.class||(r.class={}),r.class.arrowMarkerAbsolute=r.arrowMarkerAbsolute},"init")};export{f as diagram};
@@ -1 +0,0 @@
1
- import{s as a,c as s,a as e,C as t}from"./chunk-TICWLB2K-DlYo009w.js";import{_ as i}from"./mermaid.core-C0BcALIm.js";import"./index-CaJu4U8U.js";import"./chunk-5VM5RSS4-B3FJnk33.js";import"./chunk-XXDRQBXY-DSu27P5i.js";import"./chunk-POPQ4Y6H-ISG0Axt5.js";import"./chunk-F27PBJKO-GG0xbyKU.js";import"./purify.es-Cz4mVeUR.js";var f={parser:e,get db(){return new t},renderer:s,styles:a,init:i(r=>{r.class||(r.class={}),r.class.arrowMarkerAbsolute=r.arrowMarkerAbsolute},"init")};export{f as diagram};