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.
- package/dist-app/assets/{abnfDiagram-VCTEODGH-BmlsnR28.js → abnfDiagram-VCTEODGH-BT9l2JtT.js} +1 -1
- package/dist-app/assets/{arc-4k4Kddh9.js → arc-BNeep0yS.js} +1 -1
- package/dist-app/assets/{architectureDiagram-5GKGNRK7-BuacmIpL.js → architectureDiagram-5GKGNRK7-BHbXwadP.js} +1 -1
- package/dist-app/assets/{blockDiagram-I7D4REHJ-JX7h4WWl.js → blockDiagram-I7D4REHJ-D3nGTeD_.js} +1 -1
- package/dist-app/assets/{c4Diagram-7LVT6UL2-Cp3pP9V9.js → c4Diagram-7LVT6UL2-YVhti_2s.js} +1 -1
- package/dist-app/assets/{canonical-B-X5o-6D.js → canonical-B6qH6YYL.js} +2 -2
- package/dist-app/assets/{canvas-record-CxDxerX8.js → canvas-record-DkWI-CqO.js} +1 -1
- package/dist-app/assets/channel-gkBiIKOr.js +1 -0
- package/dist-app/assets/{chunk-2Q5K7J3B-mR0xNUcf.js → chunk-2Q5K7J3B-DYzCoUl3.js} +1 -1
- package/dist-app/assets/{chunk-5VM5RSS4-B3FJnk33.js → chunk-5VM5RSS4-DVhgGApv.js} +1 -1
- package/dist-app/assets/{chunk-F27PBJKO-GG0xbyKU.js → chunk-F27PBJKO-IlrE7p6D.js} +1 -1
- package/dist-app/assets/{chunk-IMKFNOWR-CK5rKklp.js → chunk-IMKFNOWR-5rRCs1Ch.js} +1 -1
- package/dist-app/assets/{chunk-JWPE2WC7-CnjfwwXP.js → chunk-JWPE2WC7-C9Q2AivI.js} +1 -1
- package/dist-app/assets/{chunk-POPQ4Y6H-ISG0Axt5.js → chunk-POPQ4Y6H-DZVQLfkD.js} +1 -1
- package/dist-app/assets/{chunk-SVP7TREG-Di8Y7pYg.js → chunk-SVP7TREG-B4fYf5pA.js} +1 -1
- package/dist-app/assets/{chunk-TICWLB2K-DlYo009w.js → chunk-TICWLB2K-C5XVQCn2.js} +1 -1
- package/dist-app/assets/{chunk-XXDRQBXY-DSu27P5i.js → chunk-XXDRQBXY-BpSGHcjQ.js} +1 -1
- package/dist-app/assets/classDiagram-ZZMXUADV-D80YaymG.js +1 -0
- package/dist-app/assets/classDiagram-v2-VYDZK3BY-D80YaymG.js +1 -0
- package/dist-app/assets/{claude-code-session-BYx2_4G1.js → claude-code-session-CovsRibh.js} +1 -1
- package/dist-app/assets/{codex-session-BH6Ogwin.js → codex-session-uqqsgPZi.js} +1 -1
- package/dist-app/assets/{cose-bilkent-JH36ORCC-2jNLoiin.js → cose-bilkent-JH36ORCC-DbPE-v0C.js} +1 -1
- package/dist-app/assets/{cynefin-OW5HDTMX-CYtWRYx6.js → cynefin-OW5HDTMX-DOP83gQe.js} +1 -1
- package/dist-app/assets/{cynefinDiagram-5FMLGOSQ-Dht0kBaK.js → cynefinDiagram-5FMLGOSQ-CfgRM3kY.js} +1 -1
- package/dist-app/assets/{dagre-GXQ25YYZ-CdJwfp-j.js → dagre-GXQ25YYZ-DQIOhH_w.js} +1 -1
- package/dist-app/assets/{diagram-S7CK7UJ4-DvVIz9wN.js → diagram-S7CK7UJ4-rR0rrvz6.js} +1 -1
- package/dist-app/assets/{diagram-UQ7AKVKN-zaxruSox.js → diagram-UQ7AKVKN-CqOdeawW.js} +1 -1
- package/dist-app/assets/{diagram-VSXAHHWV-BpisZmMI.js → diagram-VSXAHHWV-BBezLj_3.js} +1 -1
- package/dist-app/assets/{diagram-VX7I27RA-CEC2DdCe.js → diagram-VX7I27RA-BHl93n8F.js} +1 -1
- package/dist-app/assets/{diagram-Z3DM3KII-BccyzzPj.js → diagram-Z3DM3KII-DkDme_j4.js} +1 -1
- package/dist-app/assets/{dsh-session-Bgvf-2ie.js → dsh-session-Bp2W7dlB.js} +1 -1
- package/dist-app/assets/{ebnfDiagram-PWID7BFC-iAT6gvfL.js → ebnfDiagram-PWID7BFC-OooS7sQm.js} +1 -1
- package/dist-app/assets/{erDiagram-RLTQ6QDP-Zixusc5i.js → erDiagram-RLTQ6QDP-fOqW-zut.js} +1 -1
- package/dist-app/assets/{experiment-loop-gxZgdQ3u.js → experiment-loop-C9J42t9U.js} +1 -1
- package/dist-app/assets/{flowDiagram-HODETNUW-B_X_09yE.js → flowDiagram-HODETNUW-CNd6YAVF.js} +1 -1
- package/dist-app/assets/{ganttDiagram-EL5Y4UJY-BcvE7rsg.js → ganttDiagram-EL5Y4UJY-KxqfLxb8.js} +1 -1
- package/dist-app/assets/{gitGraphDiagram-WWUBYQGX-hxOTtQbp.js → gitGraphDiagram-WWUBYQGX-dQIQH3pC.js} +1 -1
- package/dist-app/assets/{http-bridge-J5k9wd7t.js → http-bridge-CcF4Mr_J.js} +1 -1
- package/dist-app/assets/{index-Mh4X9Asm.js → index-BB-ek2_R.js} +1 -1
- package/dist-app/assets/index-BpLFj6tz.css +1 -0
- package/dist-app/assets/{index-DqAUkEeY.js → index-CnmO3-jd.js} +3 -3
- package/dist-app/assets/{index-CaJu4U8U.js → index-DDS2ZyPK.js} +212 -212
- package/dist-app/assets/{index-Tx9fHuzW.js → index-DLkiHg7Y.js} +1 -1
- package/dist-app/assets/{infoDiagram-27XIBGKW-BOyfWDQh.js → infoDiagram-27XIBGKW-DavckIjk.js} +1 -1
- package/dist-app/assets/{ishikawaDiagram-5VMMS53U-ubN-F3uw.js → ishikawaDiagram-5VMMS53U-i7Xpo8IQ.js} +1 -1
- package/dist-app/assets/{journeyDiagram-3NMN7TZE-EEUt8KFZ.js → journeyDiagram-3NMN7TZE-DE1ndGzq.js} +1 -1
- package/dist-app/assets/{kanban-definition-UXKFOSKX-BHoTyZ-H.js → kanban-definition-UXKFOSKX-C-blr0eT.js} +1 -1
- package/dist-app/assets/{layout-CQ6ZrisW.js → layout-p-7Amz1g.js} +1 -1
- package/dist-app/assets/{linear-LOsR28TU.js → linear-h0ohmGSS.js} +1 -1
- package/dist-app/assets/{mermaid.core-C0BcALIm.js → mermaid.core-VTvY09Mm.js} +6 -6
- package/dist-app/assets/{mindmap-definition-YA3MSWOX-C9OJtKZo.js → mindmap-definition-YA3MSWOX-C96efhfb.js} +1 -1
- package/dist-app/assets/{pegDiagram-XKGWAZYB-BVaiwlYa.js → pegDiagram-XKGWAZYB-9d_7fjiQ.js} +1 -1
- package/dist-app/assets/{pi-session-C1TO9XXu.js → pi-session-BeYQ7waM.js} +1 -1
- package/dist-app/assets/{pieDiagram-E7YTZNPT-Cl7s5yat.js → pieDiagram-E7YTZNPT-CziJ72ZE.js} +1 -1
- package/dist-app/assets/{quadrantDiagram-AXDQQJYC-D1468IWb.js → quadrantDiagram-AXDQQJYC-AyRLT_gg.js} +1 -1
- package/dist-app/assets/{railroadDiagram-O6MQD6OU-Qrp26LKj.js → railroadDiagram-O6MQD6OU-HtNgXPip.js} +1 -1
- package/dist-app/assets/{requirementDiagram-BXWQKSXE-DbN5V886.js → requirementDiagram-BXWQKSXE-CTko3n4U.js} +1 -1
- package/dist-app/assets/{sankeyDiagram-P5KCCOFB-LbmTEAz2.js → sankeyDiagram-P5KCCOFB-3BwvcXHm.js} +1 -1
- package/dist-app/assets/{sensitive-scan-Bra40lYM.js → sensitive-scan-DsbGxo7P.js} +1 -1
- package/dist-app/assets/{sequenceDiagram-WJ2MYXX4-CPXoMkcM.js → sequenceDiagram-WJ2MYXX4-BClxx6iH.js} +1 -1
- package/dist-app/assets/{session-handoff-CScqkePm.js → session-handoff-BKEd57_h.js} +2 -2
- package/dist-app/assets/{shared-Dli4ryBj.js → shared-EjEL_kdj.js} +1 -1
- package/dist-app/assets/{sizeCapture-INFHLROL-Ver6XJXZ.js → sizeCapture-INFHLROL-B0Qw24Wo.js} +1 -1
- package/dist-app/assets/{stateDiagram-D77RDMKH-CoTk522r.js → stateDiagram-D77RDMKH-DZ3bHvNK.js} +1 -1
- package/dist-app/assets/stateDiagram-v2-MP3YSRHH-DtcQuXpd.js +1 -0
- package/dist-app/assets/{swimlanes-42K2YHIH-CxnYRWCO.js → swimlanes-42K2YHIH-sooeHTXv.js} +1 -1
- package/dist-app/assets/swimlanesDiagram-VR7AAH4N-BGBHev6H.js +8 -0
- package/dist-app/assets/{timeline-definition-24CTP7MA-BPTTWz0t.js → timeline-definition-24CTP7MA-CQPwGFbP.js} +1 -1
- package/dist-app/assets/{turndown-plugin-gfm.cjs-BOuki0om.js → turndown-plugin-gfm.cjs-B_Wz5eXI.js} +1 -1
- package/dist-app/assets/{update-check-BsnTW6VR.js → update-check-D2R41pt1.js} +1 -1
- package/dist-app/assets/{vennDiagram-4TSXK5OY-KQ0VCvz7.js → vennDiagram-4TSXK5OY-D5w4wj7I.js} +1 -1
- package/dist-app/assets/{wardleyDiagram-VM6X3IG4-DoiF8Mk5.js → wardleyDiagram-VM6X3IG4-BLv5nXsU.js} +1 -1
- package/dist-app/assets/{xychartDiagram-S5SC5T6Z-CiO0NJVo.js → xychartDiagram-S5SC5T6Z-clbjh4DN.js} +1 -1
- package/dist-app/index.html +2 -2
- package/lib/index.js +296 -25
- package/lib/runtime/agents/README.md +4 -2
- package/lib/runtime/agents/claude.cjs +44 -3
- package/lib/runtime/agents/codex.cjs +31 -17
- package/lib/runtime/agents/ops.cjs +1 -1
- package/package.json +1 -1
- package/dist-app/assets/channel-GKL8NncG.js +0 -1
- package/dist-app/assets/classDiagram-ZZMXUADV-CfiMnbJv.js +0 -1
- package/dist-app/assets/classDiagram-v2-VYDZK3BY-CfiMnbJv.js +0 -1
- package/dist-app/assets/index-CTPL9Dm_.css +0 -1
- package/dist-app/assets/stateDiagram-v2-MP3YSRHH-DJacHmOW.js +0 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
986
|
+
name, query, arguments: args, ...(rule ? { rule } : {}),
|
|
795
987
|
} })
|
|
796
|
-
|
|
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
|
-
|
|
810
|
-
|
|
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
|
-
|
|
1332
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
//
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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'
|
|
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.
|
|
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};
|