@dsdenes/devboard 0.1.560 → 0.1.562
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/AGENTS.md +59 -0
- package/README.md +2497 -26
- package/bin/devboard.mjs +352 -118
- package/client/App.svelte +523 -0
- package/client/FeedbackOverlay.svelte +1125 -0
- package/client/FlowMode.svelte +1015 -0
- package/client/app.css +3922 -0
- package/client/env.d.ts +23 -0
- package/client/lib/commands.ts +836 -0
- package/client/lib/components/ActivityBar.svelte +295 -0
- package/client/lib/components/AdrTable.svelte +241 -0
- package/client/lib/components/CommandPalette.svelte +198 -0
- package/client/lib/components/ConceptKind.svelte +66 -0
- package/client/lib/components/ConfirmDialog.svelte +62 -0
- package/client/lib/components/DataTable.svelte +243 -0
- package/client/lib/components/DetailSplit.svelte +68 -0
- package/client/lib/components/DiscoverOverview.svelte +898 -0
- package/client/lib/components/DiscoverWorkspace.svelte +437 -0
- package/client/lib/components/DraftsPage.svelte +186 -0
- package/client/lib/components/GoalWorkspace.svelte +1042 -0
- package/client/lib/components/IssuesAgentFocus.svelte +117 -0
- package/client/lib/components/IssuesAgentLog.svelte +50 -0
- package/client/lib/components/IssuesQuestion.svelte +95 -0
- package/client/lib/components/IssuesWorkspace.svelte +577 -0
- package/client/lib/components/MermaidDiagram.svelte +89 -0
- package/client/lib/components/MonacoEditor.svelte +190 -0
- package/client/lib/components/NotificationRegion.svelte +195 -0
- package/client/lib/components/PaneHandle.svelte +25 -0
- package/client/lib/components/ScoutIssues.svelte +215 -0
- package/client/lib/components/ScoutWorkspace.svelte +180 -0
- package/client/lib/components/Sidebar.svelte +622 -0
- package/client/lib/components/StatusBar.svelte +118 -0
- package/client/lib/components/Terminal.svelte +193 -0
- package/client/lib/components/WatchdogWorkspace.svelte +537 -0
- package/client/lib/components/ui/button.svelte +56 -0
- package/client/lib/components/ui/icon-button.svelte +56 -0
- package/client/lib/components/ui/input.svelte +19 -0
- package/client/lib/components/ui/page-header.svelte +120 -0
- package/client/lib/components/ui/stage-track.svelte +141 -0
- package/client/lib/components/watchdog-indicator.svelte +189 -0
- package/client/lib/connection.svelte.ts +210 -0
- package/client/lib/dependency-diagram.test.ts +90 -0
- package/client/lib/dependency-diagram.ts +75 -0
- package/client/lib/discover-format.test.ts +265 -0
- package/client/lib/discover-format.ts +394 -0
- package/client/lib/discover.svelte.ts +412 -0
- package/client/lib/draft-format.test.ts +48 -0
- package/client/lib/draft-format.ts +13 -0
- package/client/lib/flow-save.test.ts +148 -0
- package/client/lib/flow.svelte.ts +445 -0
- package/client/lib/goal-reconnect.test.ts +282 -0
- package/client/lib/goal.svelte.ts +652 -0
- package/client/lib/issues.svelte.ts +439 -0
- package/client/lib/layout.svelte.ts +234 -0
- package/client/lib/notification-issue.test.ts +53 -0
- package/client/lib/notification-issue.ts +27 -0
- package/client/lib/notifications.svelte.ts +62 -0
- package/client/lib/poll-status.test.ts +141 -0
- package/client/lib/poll-status.ts +88 -0
- package/client/lib/record-status.test.ts +19 -0
- package/client/lib/record-status.ts +25 -0
- package/client/lib/scout.svelte.ts +164 -0
- package/client/lib/skill-document.test.ts +77 -0
- package/client/lib/skill-document.ts +57 -0
- package/client/lib/status-tone.test.ts +40 -0
- package/client/lib/status-tone.ts +33 -0
- package/client/lib/types.ts +578 -0
- package/client/lib/utils.ts +6 -0
- package/client/lib/viewport.svelte.ts +20 -0
- package/client/lib/watchdog.svelte.ts +69 -0
- package/client/main.ts +7 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/abnfDiagram-VCTEODGH-CmBSMArZ.js +1 -0
- package/dist/assets/arc-BZgCSX2H.js +1 -0
- package/dist/assets/architecture-7GRP2DOG-CF9MHlfo.js +1 -0
- package/dist/assets/architectureDiagram-5GKGNRK7-DzWB6xiQ.js +36 -0
- package/dist/assets/array-BifhSqXX.js +1 -0
- package/dist/assets/blockDiagram-I7D4REHJ-BtmGJ0hn.js +129 -0
- package/dist/assets/c4Diagram-7LVT6UL2-D5CHrrRX.js +38 -0
- package/dist/assets/channel-CgM4lcUW.js +1 -0
- package/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/dist/assets/chunk-4HAMMTFA-DeIufjFl.js +62 -0
- package/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/dist/assets/chunk-75Z2AOVW-DbtzyKUg.js +2 -0
- package/dist/assets/chunk-DU6HZSFF-C0fdbx3K.js +127 -0
- package/dist/assets/chunk-F27PBJKO-HObL2IvF.js +1 -0
- package/dist/assets/chunk-FOHPRMQF-DD4dKubD.js +161 -0
- package/dist/assets/chunk-GMAD6QVW-fg8wvtPY.js +72 -0
- package/dist/assets/chunk-GVQU2GXP-C0TaHOrD.js +1 -0
- package/dist/assets/chunk-IMKFNOWR-BVoIdx20.js +231 -0
- package/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/dist/assets/chunk-L3NEJ4N5-B_kmqfk5.js +1 -0
- package/dist/assets/chunk-OSK3NFVY-DO0vhxHH.js +10 -0
- package/dist/assets/chunk-P2QGCYS3-CjljLvFj.js +1 -0
- package/dist/assets/chunk-POPQ4Y6H-BU0C7tw6.js +1 -0
- package/dist/assets/chunk-PWAF6VOD-Dn0OjBt0.js +1 -0
- package/dist/assets/chunk-SHT3W25Y-CMCZE2xx.js +168 -0
- package/dist/assets/chunk-SVP7TREG-BIRmRy_W.js +88 -0
- package/dist/assets/chunk-TICWLB2K-C1bc2CDE.js +206 -0
- package/dist/assets/chunk-XXDRQBXY-Cc38QUmR.js +1 -0
- package/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/dist/assets/classDiagram-ZZMXUADV-BDh5w6Nc.js +1 -0
- package/dist/assets/classDiagram-v2-VYDZK3BY-BDh5w6Nc.js +1 -0
- package/dist/assets/cose-bilkent-JH36ORCC-B7pU7qhM.js +1 -0
- package/dist/assets/cynefin-OW5HDTMX-76dswSqS.js +1 -0
- package/dist/assets/cynefinDiagram-5FMLGOSQ-2kS4q01d.js +62 -0
- package/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
- package/dist/assets/dagre-D7JM2Yhv.js +1 -0
- package/dist/assets/dagre-GXQ25YYZ-B5Y21TQM.js +4 -0
- package/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/dist/assets/diagram-S7CK7UJ4-CY6td7Et.js +30 -0
- package/dist/assets/diagram-UQ7AKVKN-DmDqnGZf.js +41 -0
- package/dist/assets/diagram-VSXAHHWV-KGLWOOcE.js +3 -0
- package/dist/assets/diagram-VX7I27RA-D5syUraJ.js +24 -0
- package/dist/assets/diagram-Z3DM3KII-Dtrbs1Om.js +24 -0
- package/dist/assets/dist-Dce-2YbA.js +1 -0
- package/dist/assets/ebnfDiagram-PWID7BFC-CQpJLtU3.js +1 -0
- package/dist/assets/editor.worker-BmdWdrHZ.js +33 -0
- package/dist/assets/erDiagram-RLTQ6QDP-cMTzKLu_.js +99 -0
- package/dist/assets/eventmodeling-NTZA5JFV-CMzLdNaL.js +1 -0
- package/dist/assets/flowDiagram-HODETNUW-BMUajB-G.js +1 -0
- package/dist/assets/ganttDiagram-EL5Y4UJY-DseBS5Fs.js +292 -0
- package/dist/assets/gitGraph-4MIJSDKK-CUQZRpxJ.js +1 -0
- package/dist/assets/gitGraphDiagram-WWUBYQGX-CfMfsJ3b.js +106 -0
- package/dist/assets/graphlib-DS17s2tU.js +1 -0
- package/dist/assets/index-ByQ-XX38.css +1 -0
- package/dist/assets/index-CIf5TpfY.js +214 -0
- package/dist/assets/info-A6RAGUB7-D3nAKZ8M.js +1 -0
- package/dist/assets/infoDiagram-27XIBGKW-zu1O0WOy.js +2 -0
- package/dist/assets/init-C-OQMol4.js +1 -0
- package/dist/assets/ishikawaDiagram-5VMMS53U-DKC7BO9p.js +70 -0
- package/dist/assets/journeyDiagram-3NMN7TZE-Ld5mXse7.js +139 -0
- package/dist/assets/kanban-definition-UXKFOSKX-CH7_QlHL.js +89 -0
- package/dist/assets/katex-DJhrJSbw.js +257 -0
- package/dist/assets/line-N9XiCREw.js +1 -0
- package/dist/assets/linear-C9hnqmJv.js +1 -0
- package/dist/assets/mermaid-parser.core-DgPwV5Bh.js +7 -0
- package/dist/assets/mermaid.core-BKqynXLR.js +44 -0
- package/dist/assets/mindmap-definition-YA3MSWOX-DkiL-wiW.js +96 -0
- package/dist/assets/monaco-DjpsE8to.css +1 -0
- package/dist/assets/monaco-DqzJaNIe.js +908 -0
- package/dist/assets/ordinal-BDEzSJ7C.js +1 -0
- package/dist/assets/packet-AYTQ26CC-DuMTvpEJ.js +1 -0
- package/dist/assets/path-fybaL0A-.js +1 -0
- package/dist/assets/pegDiagram-XKGWAZYB-DvMl_xSe.js +1 -0
- package/dist/assets/pie-WAS4IAKB-Da0xPaD8.js +1 -0
- package/dist/assets/pieDiagram-E7YTZNPT-amANNRJY.js +39 -0
- package/dist/assets/quadrantDiagram-AXDQQJYC-BrgvDMnk.js +7 -0
- package/dist/assets/radar-RG4KPBEZ-Dz3Ih5-N.js +1 -0
- package/dist/assets/railroad-74A4TZTK-CKMW-IV2.js +1 -0
- package/dist/assets/railroad-abnf-HS5TGJTU-ChqmSWSF.js +1 -0
- package/dist/assets/railroad-ebnf-LZEXJU2U-V3R4fFdb.js +1 -0
- package/dist/assets/railroad-peg-WCYAUIDC-B-RuhUQW.js +1 -0
- package/dist/assets/railroadDiagram-O6MQD6OU-FjG-6OLu.js +1 -0
- package/dist/assets/requirementDiagram-BXWQKSXE-Cnj8RfE4.js +84 -0
- package/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/dist/assets/sankeyDiagram-P5KCCOFB-DMu1fnEh.js +40 -0
- package/dist/assets/sequenceDiagram-WJ2MYXX4-CtCtaRpW.js +162 -0
- package/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
- package/dist/assets/src-CGp3bfio.js +1 -0
- package/dist/assets/stateDiagram-D77RDMKH-xcJbLNhz.js +1 -0
- package/dist/assets/stateDiagram-v2-MP3YSRHH-DYHrQmJJ.js +1 -0
- package/dist/assets/swimlanes-42K2YHIH-BwLln_ga.js +1 -0
- package/dist/assets/swimlanesDiagram-VR7AAH4N-BcDfUQ0A.js +8 -0
- package/dist/assets/timeline-definition-24CTP7MA-ucNGsUUz.js +120 -0
- package/dist/assets/treeView-Q6P3EWNA-3uOomRue.js +1 -0
- package/dist/assets/treemap-WGGIJYW6-iGK7vwID.js +1 -0
- package/dist/assets/vennDiagram-4TSXK5OY-B9LgGe16.js +34 -0
- package/dist/assets/wardley-WFR3VGLG-FupMOf6k.js +1 -0
- package/dist/assets/wardleyDiagram-VM6X3IG4-CGZdEXM2.js +78 -0
- package/dist/assets/xterm-BrP-ENHg.css +1 -0
- package/dist/assets/xterm-C8zLTNGk.js +16 -0
- package/dist/assets/xychartDiagram-S5SC5T6Z-DafZrSBJ.js +7 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.svg +6 -0
- package/dist/index.html +34 -0
- package/extensions/implement-goal-issue.ts +368 -0
- package/extensions/implement-next-epic.ts +835 -0
- package/index.html +28 -0
- package/install.sh +111 -0
- package/issues/control.ts +91 -0
- package/issues/guards.ts +12 -0
- package/issues/ledger.ts +122 -0
- package/issues/legacy.ts +68 -0
- package/issues/lock.ts +305 -0
- package/issues/paths.ts +76 -0
- package/issues/preflight.ts +189 -0
- package/issues/repository.ts +203 -0
- package/issues/scout/actions.ts +255 -0
- package/issues/scout/control.ts +80 -0
- package/issues/scout/github.ts +358 -0
- package/issues/scout/paths.ts +129 -0
- package/issues/scout/preflight.ts +277 -0
- package/issues/scout/recommendation.ts +400 -0
- package/issues/scout/scan.ts +143 -0
- package/issues/scout/state.ts +448 -0
- package/issues/scout/worker.ts +1282 -0
- package/issues/state.ts +584 -0
- package/issues/tracker.ts +271 -0
- package/issues/worker.ts +1065 -0
- package/lib/dependencies.ts +61 -0
- package/lib/forge-auth.ts +953 -0
- package/lib/forge.ts +141 -0
- package/lib/gitea.ts +709 -0
- package/lib/github-release.ts +318 -0
- package/lib/inventory.test.ts +79 -0
- package/lib/inventory.ts +53 -0
- package/lib/release.test.ts +269 -0
- package/lib/release.ts +801 -0
- package/omp/commands/adr-resolve.md +8 -0
- package/omp/commands/discover.md +6 -0
- package/omp/commands/epic-next.md +8 -0
- package/omp/commands/flow-change.md +8 -0
- package/omp/commands/flow-goal.md +8 -0
- package/omp/commands/flow-implement.md +8 -0
- package/omp/commands/flow-widget.md +8 -0
- package/omp/commands/goal-draft.md +8 -0
- package/omp/commands/goal-outcome.md +8 -0
- package/omp/commands/implement-issue.md +8 -0
- package/omp/commands/prepare-skills.md +6 -0
- package/omp/commands/scout-recommendations.md +8 -0
- package/omp/commands/setup-devboard.md +24 -0
- package/omp/config.yml +12 -0
- package/omp/extensions/implement-goal-issue.ts +2 -0
- package/omp/extensions/implement-next-epic.ts +2 -0
- package/omp/mcp.json +9 -0
- package/package.json +86 -7
- package/prompts/adr-resolve.md +130 -0
- package/prompts/discover.md +541 -0
- package/prompts/epic-next.md +533 -0
- package/prompts/flow-change.md +76 -0
- package/prompts/flow-goal.md +126 -0
- package/prompts/flow-implement.md +103 -0
- package/prompts/flow-widget.md +54 -0
- package/prompts/goal-draft.md +147 -0
- package/prompts/goal-outcome.md +87 -0
- package/prompts/implement-issue.md +66 -0
- package/prompts/prepare-skills.md +142 -0
- package/prompts/scout-recommendations.md +143 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +6 -0
- package/server/acp.ts +540 -0
- package/server/activity.test.ts +96 -0
- package/server/activity.ts +48 -0
- package/server/app.test.ts +882 -0
- package/server/app.ts +6179 -0
- package/server/ask.test.ts +543 -0
- package/server/ask.ts +589 -0
- package/server/dependencies.test.ts +118 -0
- package/server/discover-remote.test.ts +1602 -0
- package/server/discover-sheet.test.ts +176 -0
- package/server/discover.test.ts +1510 -0
- package/server/discover.ts +3198 -0
- package/server/feedback.test.ts +458 -0
- package/server/feedback.ts +369 -0
- package/server/fixtures/adr-source.ts +71 -0
- package/server/fixtures/hang-download.ts +14 -0
- package/server/fixtures/stub-agent.ts +1870 -0
- package/server/fixtures/stub-forge.ts +141 -0
- package/server/fixtures/stub-gh.ts +57 -0
- package/server/flow-pr.test.ts +976 -0
- package/server/flow.test.ts +1632 -0
- package/server/flow.ts +2655 -0
- package/server/forge-pr.ts +387 -0
- package/server/goal-creation.test.ts +957 -0
- package/server/goal-draft.test.ts +348 -0
- package/server/goal-drafts.ts +449 -0
- package/server/goal-proposal.test.ts +479 -0
- package/server/goal-proposal.ts +232 -0
- package/server/goal-revision.test.ts +613 -0
- package/server/goal-slug.test.ts +56 -0
- package/server/goal-slug.ts +96 -0
- package/server/goal.ts +20 -0
- package/server/index.ts +147 -0
- package/server/issue-close.test.ts +100 -0
- package/server/issue-close.ts +178 -0
- package/server/issues.test.ts +167 -0
- package/server/issues.ts +479 -0
- package/server/journal.test.ts +350 -0
- package/server/journal.ts +329 -0
- package/server/lifecycle-pause.test.ts +1795 -0
- package/server/lifecycle.test.ts +1879 -0
- package/server/parallel-lifecycle.test.ts +1029 -0
- package/server/persistence-restart.test.ts +723 -0
- package/server/persistence.test.ts +1935 -0
- package/server/records.test.ts +180 -0
- package/server/records.ts +99 -0
- package/server/reset-implementation.test.ts +997 -0
- package/server/run.test.ts +611 -0
- package/server/scout.test.ts +877 -0
- package/server/scout.ts +459 -0
- package/server/skills-preparation.test.ts +1073 -0
- package/server/state.ts +249 -0
- package/server/tracker.ts +369 -0
- package/server/updates.test.ts +444 -0
- package/server/updates.ts +198 -0
- package/server/version.test.ts +140 -0
- package/server/version.ts +151 -0
- package/server/watchdog.ts +389 -0
- package/svelte.config.js +5 -0
- package/templates/host-AGENTS.md +235 -0
- package/tsconfig.json +21 -0
- package/vite.config.ts +21 -0
- package/watchdog/cleanup-main.ts +98 -0
- package/watchdog/cleanup.ts +301 -0
- package/watchdog/collect.ts +311 -0
- package/watchdog/coordinator.ts +397 -0
- package/watchdog/delivery.ts +107 -0
- package/watchdog/discovery.ts +209 -0
- package/watchdog/findings.ts +211 -0
- package/watchdog/github.ts +189 -0
- package/watchdog/history.ts +138 -0
- package/watchdog/install.ts +103 -0
- package/watchdog/ledger.ts +188 -0
- package/watchdog/lock.ts +63 -0
- package/watchdog/main.ts +106 -0
- package/watchdog/status.ts +71 -0
- package/watchdog/systemd.ts +96 -0
- package/watchdog/uninstall.ts +30 -0
- package/watchdog/units/devboard-watchdog.service +9 -0
- package/watchdog/units/devboard-watchdog.timer +10 -0
package/server/ask.ts
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
import type { Subprocess } from "bun";
|
|
2
|
+
import { nextActivity, type ActivityStream } from "./acp";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The element-scoped Ask conversation. One controller lives with the server;
|
|
6
|
+
* one conversation lives with each open report panel.
|
|
7
|
+
*
|
|
8
|
+
* The peer is a minimal ACP client rather than `AcpConnection` because the
|
|
9
|
+
* transcript must show only assistant message text: `AcpConnection` forwards
|
|
10
|
+
* thoughts and tool-call titles through the same activity log, and its public
|
|
11
|
+
* surface carries no update kind. The protocol framing, permission answers,
|
|
12
|
+
* and session reuse mirror `AcpConnection`, and the update mapping is its
|
|
13
|
+
* exported `nextActivity`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const MAX_CONVERSATION_ID = 200;
|
|
17
|
+
const MAX_QUESTION = 4000;
|
|
18
|
+
/** Bounds shared with the client's capture; a larger value is refused. */
|
|
19
|
+
const ASK_CONTEXT_LIMITS = {
|
|
20
|
+
route: 2000,
|
|
21
|
+
url: 2000,
|
|
22
|
+
selector: 500,
|
|
23
|
+
viewport: 500,
|
|
24
|
+
captured_at: 100,
|
|
25
|
+
visible_text: 1500,
|
|
26
|
+
dom_excerpt: 4000,
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
type AskContextField = keyof typeof ASK_CONTEXT_LIMITS;
|
|
30
|
+
|
|
31
|
+
type AskContext = Record<AskContextField, string>;
|
|
32
|
+
|
|
33
|
+
interface AskRequest {
|
|
34
|
+
conversationId: string;
|
|
35
|
+
question: string;
|
|
36
|
+
context: AskContext;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface AskTranscriptEntry {
|
|
40
|
+
role: "developer" | "agent";
|
|
41
|
+
text: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A payload the server refuses without touching the agent. */
|
|
45
|
+
class AskRequestError extends Error {}
|
|
46
|
+
|
|
47
|
+
const READ_ONLY_SENTENCE =
|
|
48
|
+
"This conversation is read-only. Never edit files, run mutating commands, file issues, commit, or push.";
|
|
49
|
+
const BREVITY_SENTENCE =
|
|
50
|
+
"Answer in at most three short sentences and about 400 characters. No preamble and no restating the question.";
|
|
51
|
+
const PLAIN_SENTENCE = "Reply in plain text without Markdown.";
|
|
52
|
+
|
|
53
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
54
|
+
return typeof value === "object" && value !== null
|
|
55
|
+
? (value as Record<string, unknown>)
|
|
56
|
+
: undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function boundedField(
|
|
60
|
+
context: Record<string, unknown>,
|
|
61
|
+
name: AskContextField,
|
|
62
|
+
): string {
|
|
63
|
+
const value = context[name];
|
|
64
|
+
if (typeof value !== "string") throw new AskRequestError(`The ask context is missing ${name}.`);
|
|
65
|
+
if (value.length > ASK_CONTEXT_LIMITS[name]) {
|
|
66
|
+
throw new AskRequestError(`The ask context ${name} is longer than ${ASK_CONTEXT_LIMITS[name]}.`);
|
|
67
|
+
}
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Validates one client ask; unknown fields are ignored for forwards compatibility. */
|
|
72
|
+
function parseAskRequest(value: unknown): AskRequest {
|
|
73
|
+
const request = asRecord(value);
|
|
74
|
+
if (request === undefined) throw new AskRequestError("The ask is not an object.");
|
|
75
|
+
|
|
76
|
+
const conversationId = request.conversationId;
|
|
77
|
+
if (typeof conversationId !== "string") {
|
|
78
|
+
throw new AskRequestError("The ask has no conversation id.");
|
|
79
|
+
}
|
|
80
|
+
const id = conversationId.trim();
|
|
81
|
+
if (id.length === 0 || id.length > MAX_CONVERSATION_ID) {
|
|
82
|
+
throw new AskRequestError(`The conversation id must be 1-${MAX_CONVERSATION_ID} characters.`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const question = request.question;
|
|
86
|
+
if (typeof question !== "string") throw new AskRequestError("The ask has no question.");
|
|
87
|
+
const trimmed = question.trim();
|
|
88
|
+
if (trimmed.length === 0) throw new AskRequestError("The question is empty.");
|
|
89
|
+
if (trimmed.length > MAX_QUESTION) {
|
|
90
|
+
throw new AskRequestError(`The question is longer than ${MAX_QUESTION} characters.`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const context = asRecord(request.context);
|
|
94
|
+
if (context === undefined) throw new AskRequestError("The ask has no element context.");
|
|
95
|
+
return {
|
|
96
|
+
conversationId: id,
|
|
97
|
+
question: trimmed,
|
|
98
|
+
context: {
|
|
99
|
+
route: boundedField(context, "route"),
|
|
100
|
+
url: boundedField(context, "url"),
|
|
101
|
+
selector: boundedField(context, "selector"),
|
|
102
|
+
viewport: boundedField(context, "viewport"),
|
|
103
|
+
captured_at: boundedField(context, "captured_at"),
|
|
104
|
+
visible_text: boundedField(context, "visible_text"),
|
|
105
|
+
dom_excerpt: boundedField(context, "dom_excerpt"),
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function contextLines(context: AskContext): string[] {
|
|
111
|
+
return (Object.keys(ASK_CONTEXT_LIMITS) as AskContextField[]).map(
|
|
112
|
+
(name) => `${name}: ${context[name]}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The prompt's invariant instruction block, repeated on every turn. */
|
|
117
|
+
function instructions(): string[] {
|
|
118
|
+
return [READ_ONLY_SENTENCE, BREVITY_SENTENCE, PLAIN_SENTENCE];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function earlierTurns(transcript: AskTranscriptEntry[]): string[] {
|
|
122
|
+
if (transcript.length === 0) return [];
|
|
123
|
+
return [
|
|
124
|
+
"Earlier in this conversation:",
|
|
125
|
+
...transcript.map(
|
|
126
|
+
(entry) => `${entry.role === "developer" ? "Developer" : "Agent"}: ${entry.text}`,
|
|
127
|
+
),
|
|
128
|
+
"",
|
|
129
|
+
];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The first prompt of a conversation, or of a replacement peer. */
|
|
133
|
+
function askPrompt(request: AskRequest, transcript: AskTranscriptEntry[]): string {
|
|
134
|
+
return [
|
|
135
|
+
"You are answering a question about a UI element in the running Devboard interface.",
|
|
136
|
+
"Answer from the captured context and this conversation; change nothing.",
|
|
137
|
+
"",
|
|
138
|
+
...instructions(),
|
|
139
|
+
"",
|
|
140
|
+
...earlierTurns(transcript),
|
|
141
|
+
`Question: ${request.question}`,
|
|
142
|
+
"",
|
|
143
|
+
"Captured element context:",
|
|
144
|
+
...contextLines(request.context),
|
|
145
|
+
].join("\n");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A follow-up keeps the live session's context and repeats the instructions. */
|
|
149
|
+
function followUpPrompt(question: string): string {
|
|
150
|
+
return [...instructions(), "", `Question: ${question}`].join("\n");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
interface PendingRequest {
|
|
154
|
+
resolve: (value: unknown) => void;
|
|
155
|
+
reject: (error: Error) => void;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
interface JsonRpcMessage {
|
|
159
|
+
jsonrpc?: string;
|
|
160
|
+
id?: number | string | null;
|
|
161
|
+
method?: string;
|
|
162
|
+
params?: unknown;
|
|
163
|
+
result?: unknown;
|
|
164
|
+
error?: { code?: number; message?: string; data?: unknown };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface AskPeerOptions {
|
|
168
|
+
command: string;
|
|
169
|
+
cwd: string;
|
|
170
|
+
/** Receives assistant message text only; thoughts and tool activity are dropped. */
|
|
171
|
+
onText: (text: string) => void;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* One ACP peer: initialize, open a session, and prompt it repeatedly. Session
|
|
176
|
+
* updates are classified with `nextActivity`, so only `agent_message_chunk`
|
|
177
|
+
* text reaches the transcript.
|
|
178
|
+
*/
|
|
179
|
+
class AskPeer {
|
|
180
|
+
readonly #process: Subprocess<"pipe", "pipe", "pipe">;
|
|
181
|
+
readonly #cwd: string;
|
|
182
|
+
readonly #onText: (text: string) => void;
|
|
183
|
+
readonly #pending = new Map<string, PendingRequest>();
|
|
184
|
+
#nextRequestId = 1;
|
|
185
|
+
#closed = false;
|
|
186
|
+
#sessionId: string | undefined;
|
|
187
|
+
#stream: ActivityStream | undefined;
|
|
188
|
+
|
|
189
|
+
constructor(options: AskPeerOptions) {
|
|
190
|
+
const argv = options.command.trim().split(/\s+/).filter(Boolean);
|
|
191
|
+
if (argv.length === 0) throw new Error("AGENT_COMMAND is empty.");
|
|
192
|
+
this.#cwd = options.cwd;
|
|
193
|
+
this.#onText = options.onText;
|
|
194
|
+
this.#process = Bun.spawn(argv, {
|
|
195
|
+
cwd: options.cwd,
|
|
196
|
+
stdin: "pipe",
|
|
197
|
+
stdout: "pipe",
|
|
198
|
+
stderr: "pipe",
|
|
199
|
+
});
|
|
200
|
+
void this.#readStdout();
|
|
201
|
+
void this.#drainStderr();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
get closed(): boolean {
|
|
205
|
+
return this.#closed;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Whether the handshake completed far enough to prompt this peer. */
|
|
209
|
+
get hasSession(): boolean {
|
|
210
|
+
return this.#sessionId !== undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async run(prompt: string): Promise<unknown> {
|
|
214
|
+
await this.#request("initialize", {
|
|
215
|
+
protocolVersion: 1,
|
|
216
|
+
clientCapabilities: { elicitation: { form: {} } },
|
|
217
|
+
clientInfo: { name: "devboard-adr-ui", version: "0.1.0" },
|
|
218
|
+
});
|
|
219
|
+
const session = asRecord(
|
|
220
|
+
await this.#request("session/new", { cwd: this.#cwd, mcpServers: [] }),
|
|
221
|
+
);
|
|
222
|
+
const sessionId = session?.sessionId;
|
|
223
|
+
if (typeof sessionId !== "string") throw new Error("Agent did not return a session id.");
|
|
224
|
+
this.#sessionId = sessionId;
|
|
225
|
+
return await this.prompt(prompt);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Sends another prompt on the session this peer opened. */
|
|
229
|
+
async prompt(prompt: string): Promise<unknown> {
|
|
230
|
+
if (this.#sessionId === undefined) throw new Error("No agent session is open.");
|
|
231
|
+
return await this.#request("session/prompt", {
|
|
232
|
+
sessionId: this.#sessionId,
|
|
233
|
+
prompt: [{ type: "text", text: prompt }],
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
close(): void {
|
|
238
|
+
if (this.#closed) return;
|
|
239
|
+
this.#closed = true;
|
|
240
|
+
try {
|
|
241
|
+
this.#process.kill("SIGCONT");
|
|
242
|
+
} catch {
|
|
243
|
+
// The process already exited.
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
this.#process.kill();
|
|
247
|
+
} catch {
|
|
248
|
+
// The process already exited.
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async #readStdout(): Promise<void> {
|
|
253
|
+
const reader = this.#process.stdout.getReader();
|
|
254
|
+
const decoder = new TextDecoder();
|
|
255
|
+
let buffer = "";
|
|
256
|
+
try {
|
|
257
|
+
while (true) {
|
|
258
|
+
const { done, value } = await reader.read();
|
|
259
|
+
if (done) break;
|
|
260
|
+
buffer += decoder.decode(value, { stream: true });
|
|
261
|
+
let newline = buffer.indexOf("\n");
|
|
262
|
+
while (newline >= 0) {
|
|
263
|
+
const line = buffer.slice(0, newline).trim();
|
|
264
|
+
buffer = buffer.slice(newline + 1);
|
|
265
|
+
if (line.length > 0) this.#handleLine(line);
|
|
266
|
+
newline = buffer.indexOf("\n");
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (buffer.trim().length > 0) this.#handleLine(buffer.trim());
|
|
270
|
+
} finally {
|
|
271
|
+
this.#closed = true;
|
|
272
|
+
for (const request of this.#pending.values()) {
|
|
273
|
+
request.reject(new Error("Agent process exited before answering."));
|
|
274
|
+
}
|
|
275
|
+
this.#pending.clear();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async #drainStderr(): Promise<void> {
|
|
280
|
+
const reader = this.#process.stderr.getReader();
|
|
281
|
+
try {
|
|
282
|
+
while (true) {
|
|
283
|
+
const { done } = await reader.read();
|
|
284
|
+
if (done) break;
|
|
285
|
+
}
|
|
286
|
+
} catch {
|
|
287
|
+
// stderr is diagnostic only.
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
#handleLine(line: string): void {
|
|
292
|
+
let message: JsonRpcMessage;
|
|
293
|
+
try {
|
|
294
|
+
message = JSON.parse(line) as JsonRpcMessage;
|
|
295
|
+
} catch {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (typeof message.method === "string") {
|
|
300
|
+
if (message.id !== undefined && message.id !== null) {
|
|
301
|
+
this.#handleRequest(message.id, message.method, message.params);
|
|
302
|
+
} else if (message.method === "session/update") {
|
|
303
|
+
this.#handleSessionUpdate(message.params);
|
|
304
|
+
}
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (message.id === undefined || message.id === null) return;
|
|
309
|
+
const pending = this.#pending.get(String(message.id));
|
|
310
|
+
if (pending === undefined) return;
|
|
311
|
+
this.#pending.delete(String(message.id));
|
|
312
|
+
if (message.error !== undefined) {
|
|
313
|
+
const error: Error & { code?: number; data?: unknown } = new Error(
|
|
314
|
+
message.error.message ?? "Agent request failed.",
|
|
315
|
+
);
|
|
316
|
+
error.code = message.error.code;
|
|
317
|
+
error.data = message.error.data;
|
|
318
|
+
pending.reject(error);
|
|
319
|
+
} else {
|
|
320
|
+
pending.resolve(message.result);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
#handleRequest(id: number | string, method: string, params: unknown): void {
|
|
325
|
+
switch (method) {
|
|
326
|
+
case "session/update":
|
|
327
|
+
this.#handleSessionUpdate(params);
|
|
328
|
+
this.#respond(id, null);
|
|
329
|
+
return;
|
|
330
|
+
case "elicitation/create":
|
|
331
|
+
// The Ask conversation asks the agent nothing of its own.
|
|
332
|
+
this.#respond(id, { action: "cancel" });
|
|
333
|
+
return;
|
|
334
|
+
case "session/request_permission": {
|
|
335
|
+
const request = asRecord(params) ?? {};
|
|
336
|
+
const options = Array.isArray(request.options) ? request.options : [];
|
|
337
|
+
const option = options.find((candidate) => {
|
|
338
|
+
const kind = asRecord(candidate)?.kind;
|
|
339
|
+
return typeof kind === "string" && kind.startsWith("allow");
|
|
340
|
+
});
|
|
341
|
+
const optionId = asRecord(option)?.optionId;
|
|
342
|
+
this.#respond(
|
|
343
|
+
id,
|
|
344
|
+
typeof optionId === "string"
|
|
345
|
+
? { outcome: { outcome: "selected", optionId } }
|
|
346
|
+
: { outcome: { outcome: "cancelled" } },
|
|
347
|
+
);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
default:
|
|
351
|
+
this.#respondError(id, -32601, `Method not found: ${method}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
#handleSessionUpdate(params: unknown): void {
|
|
356
|
+
const next = nextActivity(asRecord(params)?.update, this.#stream);
|
|
357
|
+
if (next === undefined) return;
|
|
358
|
+
this.#stream = next.stream;
|
|
359
|
+
// Thoughts, tool calls, plans, and echo of the user prompt never render.
|
|
360
|
+
if (next.stream?.kind !== "agent_message_chunk") return;
|
|
361
|
+
this.#onText(next.activity.text);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async #request(method: string, params: unknown): Promise<unknown> {
|
|
365
|
+
if (this.#closed) throw new Error("Agent process is not running.");
|
|
366
|
+
const id = this.#nextRequestId++;
|
|
367
|
+
const { promise, resolve, reject } = Promise.withResolvers<unknown>();
|
|
368
|
+
this.#pending.set(String(id), { resolve, reject });
|
|
369
|
+
try {
|
|
370
|
+
this.#write({ jsonrpc: "2.0", id, method, params });
|
|
371
|
+
} catch (error) {
|
|
372
|
+
this.#pending.delete(String(id));
|
|
373
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
374
|
+
}
|
|
375
|
+
return await promise;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
#respond(id: number | string, result: unknown): void {
|
|
379
|
+
try {
|
|
380
|
+
this.#write({ jsonrpc: "2.0", id, result });
|
|
381
|
+
} catch {
|
|
382
|
+
// The peer is gone; nothing left to answer.
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
#respondError(id: number | string, code: number, message: string): void {
|
|
387
|
+
try {
|
|
388
|
+
this.#write({ jsonrpc: "2.0", id, error: { code, message } });
|
|
389
|
+
} catch {
|
|
390
|
+
// The peer is gone; nothing left to answer.
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
#write(message: unknown): void {
|
|
395
|
+
if (this.#closed) throw new Error("Agent process is not running.");
|
|
396
|
+
this.#process.stdin.write(`${JSON.stringify(message)}\n`);
|
|
397
|
+
void this.#process.stdin.flush();
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
interface AskSocket {
|
|
402
|
+
send(data: string): unknown;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
interface Conversation {
|
|
406
|
+
id: string;
|
|
407
|
+
socket: AskSocket;
|
|
408
|
+
peer: AskPeer | undefined;
|
|
409
|
+
/** Visible turns, replayed to a replacement peer. */
|
|
410
|
+
transcript: AskTranscriptEntry[];
|
|
411
|
+
busy: boolean;
|
|
412
|
+
/** The answer being streamed; a closed or replaced turn discards it. */
|
|
413
|
+
answer: string;
|
|
414
|
+
closed: boolean;
|
|
415
|
+
turn: number;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export interface AskControllerOptions {
|
|
419
|
+
/** `AGENT_COMMAND`: executable plus leading arguments of the ACP peer. */
|
|
420
|
+
command: string;
|
|
421
|
+
/** The host repository root every peer runs in. */
|
|
422
|
+
cwd: string;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Owns every open Ask conversation. A conversation is created by its first
|
|
427
|
+
* ask, bound to the asking socket, and destroyed by its close or the socket's.
|
|
428
|
+
*/
|
|
429
|
+
export class AskController {
|
|
430
|
+
readonly #options: AskControllerOptions;
|
|
431
|
+
readonly #conversations = new Map<string, Conversation>();
|
|
432
|
+
|
|
433
|
+
constructor(options: AskControllerOptions) {
|
|
434
|
+
this.#options = options;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
ask(socket: AskSocket, message: { conversationId?: unknown; question?: unknown; context?: unknown }): void {
|
|
438
|
+
const candidateId =
|
|
439
|
+
typeof message.conversationId === "string" ? message.conversationId : "";
|
|
440
|
+
const refuse = (text: string): void =>
|
|
441
|
+
this.#send(socket, { type: "askError", conversationId: candidateId, message: text });
|
|
442
|
+
|
|
443
|
+
let request: AskRequest;
|
|
444
|
+
try {
|
|
445
|
+
request = parseAskRequest(message);
|
|
446
|
+
} catch (error) {
|
|
447
|
+
if (error instanceof AskRequestError) {
|
|
448
|
+
refuse(error.message);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
throw error;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const existing = this.#conversations.get(request.conversationId);
|
|
455
|
+
if (existing !== undefined && existing.socket !== socket) {
|
|
456
|
+
refuse("This conversation belongs to another client.");
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (existing !== undefined && existing.busy) {
|
|
460
|
+
refuse("The agent is still answering the previous question.");
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const conversation: Conversation = existing ?? {
|
|
465
|
+
id: request.conversationId,
|
|
466
|
+
socket,
|
|
467
|
+
peer: undefined,
|
|
468
|
+
transcript: [],
|
|
469
|
+
busy: false,
|
|
470
|
+
answer: "",
|
|
471
|
+
closed: false,
|
|
472
|
+
turn: 0,
|
|
473
|
+
};
|
|
474
|
+
if (existing === undefined) this.#conversations.set(conversation.id, conversation);
|
|
475
|
+
|
|
476
|
+
const turn = ++conversation.turn;
|
|
477
|
+
conversation.busy = true;
|
|
478
|
+
conversation.answer = "";
|
|
479
|
+
const replacement = conversation.peer === undefined || conversation.peer.closed;
|
|
480
|
+
const prompt = replacement
|
|
481
|
+
? askPrompt(request, conversation.transcript)
|
|
482
|
+
: followUpPrompt(request.question);
|
|
483
|
+
conversation.transcript.push({ role: "developer", text: request.question });
|
|
484
|
+
|
|
485
|
+
const live = (): boolean => !conversation.closed && conversation.turn === turn;
|
|
486
|
+
const fail = (error: unknown): void => {
|
|
487
|
+
if (!live()) return;
|
|
488
|
+
conversation.busy = false;
|
|
489
|
+
// A peer that never opened a session cannot answer a follow-up, yet its
|
|
490
|
+
// process may stay alive after a refused handshake. Drop it so the next
|
|
491
|
+
// ask rebuilds the replacement prompt and spawns a fresh peer.
|
|
492
|
+
const failedPeer = conversation.peer;
|
|
493
|
+
if (failedPeer !== undefined && !failedPeer.hasSession) {
|
|
494
|
+
failedPeer.close();
|
|
495
|
+
conversation.peer = undefined;
|
|
496
|
+
}
|
|
497
|
+
this.#send(conversation.socket, {
|
|
498
|
+
type: "askError",
|
|
499
|
+
conversationId: conversation.id,
|
|
500
|
+
message: error instanceof Error ? error.message : String(error),
|
|
501
|
+
});
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
let peer: AskPeer;
|
|
505
|
+
try {
|
|
506
|
+
if (replacement) {
|
|
507
|
+
peer = new AskPeer({
|
|
508
|
+
command: this.#options.command,
|
|
509
|
+
cwd: this.#options.cwd,
|
|
510
|
+
onText: (text) => {
|
|
511
|
+
if (conversation.closed || !conversation.busy) return;
|
|
512
|
+
conversation.answer += text;
|
|
513
|
+
this.#send(conversation.socket, {
|
|
514
|
+
type: "askChunk",
|
|
515
|
+
conversationId: conversation.id,
|
|
516
|
+
text,
|
|
517
|
+
});
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
conversation.peer = peer;
|
|
521
|
+
} else {
|
|
522
|
+
peer = conversation.peer as AskPeer;
|
|
523
|
+
}
|
|
524
|
+
} catch (error) {
|
|
525
|
+
fail(error);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const turnPromise = replacement ? peer.run(prompt) : peer.prompt(prompt);
|
|
530
|
+
void turnPromise.then(
|
|
531
|
+
() => {
|
|
532
|
+
if (!live()) return;
|
|
533
|
+
conversation.busy = false;
|
|
534
|
+
if (conversation.answer.trim().length > 0) {
|
|
535
|
+
conversation.transcript.push({ role: "agent", text: conversation.answer });
|
|
536
|
+
}
|
|
537
|
+
conversation.answer = "";
|
|
538
|
+
this.#send(conversation.socket, {
|
|
539
|
+
type: "askDone",
|
|
540
|
+
conversationId: conversation.id,
|
|
541
|
+
});
|
|
542
|
+
},
|
|
543
|
+
(error: unknown) => {
|
|
544
|
+
fail(error);
|
|
545
|
+
},
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Closes one conversation and kills its peer; a stale id is ignored. */
|
|
550
|
+
close(socket: AskSocket, conversationId: unknown): void {
|
|
551
|
+
if (typeof conversationId !== "string") return;
|
|
552
|
+
const conversation = this.#conversations.get(conversationId);
|
|
553
|
+
if (conversation === undefined || conversation.socket !== socket) return;
|
|
554
|
+
this.#discard(conversation);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** A closed socket owns no conversation and no peer. */
|
|
558
|
+
disconnect(socket: AskSocket): void {
|
|
559
|
+
for (const conversation of [...this.#conversations.values()]) {
|
|
560
|
+
if (conversation.socket === socket) this.#discard(conversation);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Stops every peer; used when the server shuts down. */
|
|
565
|
+
dispose(): void {
|
|
566
|
+
for (const conversation of [...this.#conversations.values()]) {
|
|
567
|
+
this.#discard(conversation);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
#discard(conversation: Conversation): void {
|
|
572
|
+
conversation.closed = true;
|
|
573
|
+
conversation.busy = false;
|
|
574
|
+
conversation.turn += 1;
|
|
575
|
+
conversation.answer = "";
|
|
576
|
+
conversation.transcript = [];
|
|
577
|
+
conversation.peer?.close();
|
|
578
|
+
conversation.peer = undefined;
|
|
579
|
+
this.#conversations.delete(conversation.id);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
#send(socket: AskSocket, event: unknown): void {
|
|
583
|
+
try {
|
|
584
|
+
socket.send(JSON.stringify(event));
|
|
585
|
+
} catch {
|
|
586
|
+
// The socket closed between accepting the ask and sending the reply.
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { dependencyName, normalizeRepositoryUrl } from "../lib/dependencies.ts";
|
|
5
|
+
import { dependencyRepositories, packageRepository } from "./discover.ts";
|
|
6
|
+
|
|
7
|
+
const TEST_ROOT = join(import.meta.dir, "..", ".tmp", "dependencies-tests");
|
|
8
|
+
mkdirSync(TEST_ROOT, { recursive: true });
|
|
9
|
+
|
|
10
|
+
const cleanup: string[] = [];
|
|
11
|
+
|
|
12
|
+
afterAll(() => {
|
|
13
|
+
for (const directory of cleanup) rmSync(directory, { force: true, recursive: true });
|
|
14
|
+
rmSync(TEST_ROOT, { force: true, recursive: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function fixture(name: string): string {
|
|
18
|
+
const directory = join(
|
|
19
|
+
TEST_ROOT,
|
|
20
|
+
`${name}-${process.pid}-${Math.random().toString(16).slice(2, 8)}`,
|
|
21
|
+
);
|
|
22
|
+
mkdirSync(directory, { recursive: true });
|
|
23
|
+
cleanup.push(directory);
|
|
24
|
+
return directory;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function manifest(
|
|
28
|
+
root: string,
|
|
29
|
+
name: string,
|
|
30
|
+
value: Record<string, unknown>,
|
|
31
|
+
): void {
|
|
32
|
+
const directory = join(root, "node_modules", ...name.split("/"));
|
|
33
|
+
mkdirSync(directory, { recursive: true });
|
|
34
|
+
writeFileSync(join(directory, "package.json"), `${JSON.stringify(value)}\n`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("dependency names", () => {
|
|
38
|
+
test("derives the package name a stack entry starts with", () => {
|
|
39
|
+
expect(dependencyName("TypeScript")).toBe("TypeScript");
|
|
40
|
+
expect(dependencyName("@babel/parser ^7.29.9")).toBe("@babel/parser");
|
|
41
|
+
expect(dependencyName("typescript@5.4.2")).toBe("typescript");
|
|
42
|
+
expect(dependencyName(" ")).toBe("");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("repository URLs", () => {
|
|
47
|
+
test("normalizes the forms package manifests use", () => {
|
|
48
|
+
expect(normalizeRepositoryUrl("git+https://github.com/babel/babel.git")).toBe(
|
|
49
|
+
"https://github.com/babel/babel",
|
|
50
|
+
);
|
|
51
|
+
expect(normalizeRepositoryUrl("git@github.com:microsoft/TypeScript.git")).toBe(
|
|
52
|
+
"https://github.com/microsoft/TypeScript",
|
|
53
|
+
);
|
|
54
|
+
expect(normalizeRepositoryUrl("github:owner/repo")).toBe("https://github.com/owner/repo");
|
|
55
|
+
expect(normalizeRepositoryUrl("ssh://git@github.com/owner/repo.git")).toBe(
|
|
56
|
+
"https://github.com/owner/repo",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("keeps a GitHub monorepo directory in the link", () => {
|
|
61
|
+
expect(
|
|
62
|
+
normalizeRepositoryUrl("git+https://github.com/babel/babel.git", "packages/babel-parser"),
|
|
63
|
+
).toBe("https://github.com/babel/babel/tree/HEAD/packages/babel-parser");
|
|
64
|
+
expect(normalizeRepositoryUrl("git+https://gitlab.com/owner/repo.git", "packages/core")).toBe(
|
|
65
|
+
"https://gitlab.com/owner/repo",
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("rejects values with no browsable repository", () => {
|
|
70
|
+
expect(normalizeRepositoryUrl("")).toBeNull();
|
|
71
|
+
expect(normalizeRepositoryUrl("file:///srv/repo")).toBeNull();
|
|
72
|
+
expect(normalizeRepositoryUrl("not a url")).toBeNull();
|
|
73
|
+
expect(normalizeRepositoryUrl("https://github.com")).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("installed package repositories", () => {
|
|
78
|
+
test("reads the repository an installed manifest declares", () => {
|
|
79
|
+
const root = fixture("manifest");
|
|
80
|
+
manifest(root, "@scope/pkg", {
|
|
81
|
+
name: "@scope/pkg",
|
|
82
|
+
repository: {
|
|
83
|
+
type: "git",
|
|
84
|
+
url: "git+https://github.com/scope/monorepo.git",
|
|
85
|
+
directory: "packages/pkg",
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
expect(packageRepository(root, "@scope/pkg")).toBe(
|
|
89
|
+
"https://github.com/scope/monorepo/tree/HEAD/packages/pkg",
|
|
90
|
+
);
|
|
91
|
+
expect(packageRepository(root, "missing")).toBeNull();
|
|
92
|
+
expect(packageRepository(root, "../escape")).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("links only the sheet entries an installed package backs", () => {
|
|
96
|
+
const root = fixture("sheet");
|
|
97
|
+
manifest(root, "typescript", {
|
|
98
|
+
name: "typescript",
|
|
99
|
+
repository: "git+https://github.com/microsoft/TypeScript.git",
|
|
100
|
+
});
|
|
101
|
+
manifest(root, "zod", {
|
|
102
|
+
name: "zod",
|
|
103
|
+
repository: "git+https://github.com/colinhacks/zod.git",
|
|
104
|
+
});
|
|
105
|
+
const links = dependencyRepositories(root, {
|
|
106
|
+
stack: { value: ["TypeScript", "Bun"], confidence: "declared", evidence: [] },
|
|
107
|
+
dependencyMap: {
|
|
108
|
+
keyDependencies: {
|
|
109
|
+
value: ["zod ^4.0.0", "vitest ^3"],
|
|
110
|
+
confidence: "declared",
|
|
111
|
+
evidence: [],
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
// Only stack entries render as chips, so only stack names get links.
|
|
116
|
+
expect(links).toEqual({ TypeScript: "https://github.com/microsoft/TypeScript" });
|
|
117
|
+
});
|
|
118
|
+
});
|