@goondocks/myco 0.16.2 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/CONTRIBUTING.md +3 -3
  2. package/README.md +4 -3
  3. package/dist/{agent-run-MNU2QWHR.js → agent-run-ZDHXORE2.js} +7 -7
  4. package/dist/{agent-tasks-NCRKUU6E.js → agent-tasks-IPUWMMDZ.js} +7 -7
  5. package/dist/{chunk-P6C6ADBU.js → chunk-2IJ6C63F.js} +2 -2
  6. package/dist/{chunk-V2ZBYKDU.js → chunk-2WRXLYG6.js} +3 -3
  7. package/dist/{chunk-TIAYBVSI.js → chunk-4JPUC6RL.js} +170 -56
  8. package/dist/chunk-4JPUC6RL.js.map +1 -0
  9. package/dist/{chunk-34NHDRWI.js → chunk-6SDC6V3N.js} +2 -2
  10. package/dist/{chunk-34NHDRWI.js.map → chunk-6SDC6V3N.js.map} +1 -1
  11. package/dist/{chunk-6JZEAOLG.js → chunk-CPL76CYD.js} +3 -3
  12. package/dist/{chunk-VWXDSDJU.js → chunk-DKSQMH5X.js} +2 -2
  13. package/dist/{chunk-GSKXOCFG.js → chunk-EBIYONNZ.js} +21 -1
  14. package/dist/chunk-EBIYONNZ.js.map +1 -0
  15. package/dist/{chunk-XAXQ72L3.js → chunk-FEX6ALLH.js} +2 -2
  16. package/dist/{chunk-UILSK6DK.js → chunk-GBYLHPML.js} +2 -2
  17. package/dist/{chunk-DZWSHCAC.js → chunk-KGL5QSDN.js} +2 -2
  18. package/dist/{chunk-4JVHWBZF.js → chunk-KKEMVH6D.js} +2 -2
  19. package/dist/{chunk-BNAYBGPH.js → chunk-MSM775XQ.js} +6 -5
  20. package/dist/{chunk-BNAYBGPH.js.map → chunk-MSM775XQ.js.map} +1 -1
  21. package/dist/{chunk-CJ2KTRWI.js → chunk-OGNEW5CN.js} +2 -2
  22. package/dist/{chunk-2E7YGLLN.js → chunk-PFF4KB4O.js} +2 -2
  23. package/dist/{chunk-4U6X35TH.js → chunk-QBB6LX57.js} +3 -3
  24. package/dist/{chunk-C3GNF7RJ.js → chunk-QQ7CXA7Q.js} +5 -5
  25. package/dist/{chunk-I3S6L7QC.js → chunk-U3SSOSIR.js} +2 -2
  26. package/dist/{chunk-RJRRHTAA.js → chunk-U63GDHYJ.js} +3 -3
  27. package/dist/{chunk-IRSNOBGD.js → chunk-UDBCAFXS.js} +2 -2
  28. package/dist/{chunk-SGYYOTNM.js → chunk-VGVRBSLC.js} +2 -2
  29. package/dist/{chunk-W7ZOOZMK.js → chunk-VQQ57UPG.js} +3 -3
  30. package/dist/{chunk-ZMODJWI5.js → chunk-XVWJ273L.js} +4 -4
  31. package/dist/{chunk-RPILIIYT.js → chunk-Y7QCKCEJ.js} +2 -2
  32. package/dist/{chunk-D63XTGBV.js → chunk-YTOD6L6N.js} +6 -6
  33. package/dist/{chunk-D2NTFSVO.js → chunk-ZUSTCXHT.js} +3 -3
  34. package/dist/{cli-RYYABF2X.js → cli-BRDOPJW3.js} +40 -40
  35. package/dist/{client-5VXKGNN2.js → client-AQZMD3LB.js} +4 -4
  36. package/dist/{config-VHHCGE4F.js → config-K3CJEFFO.js} +3 -3
  37. package/dist/{detect-6FNYONJF.js → detect-NJ2OREDP.js} +2 -2
  38. package/dist/{detect-providers-R7QOB3H6.js → detect-providers-OE6HWW3M.js} +4 -4
  39. package/dist/{doctor-M4Q7VCDO.js → doctor-YOUCIJJY.js} +14 -13
  40. package/dist/doctor-YOUCIJJY.js.map +1 -0
  41. package/dist/{executor-ULRFWJCH.js → executor-UYNV4EOQ.js} +17 -17
  42. package/dist/{init-AEHAQFPK.js → init-MTMEFG4E.js} +17 -17
  43. package/dist/{init-wizard-SVKDS3LR.js → init-wizard-ZS3CV6CM.js} +7 -7
  44. package/dist/{installer-AARSFXI6.js → installer-CTWQB4RS.js} +2 -2
  45. package/dist/{llm-LS7U7BHC.js → llm-PGETQHZ2.js} +7 -7
  46. package/dist/{loader-QDWQTBX4.js → loader-AVWL7PNO.js} +3 -3
  47. package/dist/{loader-YQDG5GI5.js → loader-J56KP27U.js} +3 -3
  48. package/dist/{main-GAGOE6XB.js → main-ZIMYW2AB.js} +105 -60
  49. package/dist/main-ZIMYW2AB.js.map +1 -0
  50. package/dist/{open-4QMAL32X.js → open-77QXL7VY.js} +7 -7
  51. package/dist/{openai-embeddings-FUW6CSN2.js → openai-embeddings-LZKY6RV5.js} +4 -4
  52. package/dist/{openrouter-YSIUSUQL.js → openrouter-UTOZG6Z5.js} +4 -4
  53. package/dist/{post-compact-OAWEBEDK.js → post-compact-CA7O7QPL.js} +7 -7
  54. package/dist/{post-tool-use-B3KOEOIM.js → post-tool-use-JNXJIGNK.js} +6 -6
  55. package/dist/{post-tool-use-failure-2I5ELTTN.js → post-tool-use-failure-V52CZWVZ.js} +7 -7
  56. package/dist/{pre-compact-NOXNJ5EV.js → pre-compact-J2GSDQCJ.js} +7 -7
  57. package/dist/{provider-check-VEYONGNU.js → provider-check-CESRPIY5.js} +4 -4
  58. package/dist/{registry-5R3DLJQH.js → registry-SPKP2WLI.js} +4 -4
  59. package/dist/{remove-LX4G6KP7.js → remove-ODPLWK7O.js} +9 -9
  60. package/dist/{resolution-events-CHOKR35X.js → resolution-events-CLDXZF67.js} +4 -4
  61. package/dist/{restart-WSNBSALP.js → restart-6KKZODOI.js} +8 -8
  62. package/dist/{search-Q6N3SHKP.js → search-U6N2DSOI.js} +8 -8
  63. package/dist/{server-OFRKA6N7.js → server-SP4G7MY7.js} +5 -5
  64. package/dist/{server-OFRKA6N7.js.map → server-SP4G7MY7.js.map} +1 -1
  65. package/dist/{session-SKXJLJYH.js → session-6R3RNVKD.js} +9 -9
  66. package/dist/{session-end-5EIVRCPS.js → session-end-TCFCYDBL.js} +6 -6
  67. package/dist/{session-start-OL2ICLED.js → session-start-XXN2TVNI.js} +11 -11
  68. package/dist/{setup-llm-BRNQW7K2.js → setup-llm-GISFI73Z.js} +8 -8
  69. package/dist/src/cli.js +1 -1
  70. package/dist/src/daemon/main.js +1 -1
  71. package/dist/src/hooks/post-tool-use.js +1 -1
  72. package/dist/src/hooks/session-end.js +1 -1
  73. package/dist/src/hooks/session-start.js +1 -1
  74. package/dist/src/hooks/stop.js +1 -1
  75. package/dist/src/hooks/user-prompt-submit.js +1 -1
  76. package/dist/src/mcp/server.js +1 -1
  77. package/dist/src/symbionts/manifests/claude-code.yaml +4 -0
  78. package/dist/src/symbionts/manifests/opencode.yaml +26 -0
  79. package/dist/src/symbionts/templates/opencode/mcp.json +6 -0
  80. package/dist/src/symbionts/templates/opencode/package.json +5 -0
  81. package/dist/src/symbionts/templates/opencode/plugin.ts +690 -0
  82. package/dist/src/symbionts/templates/opencode/settings.json +8 -0
  83. package/dist/{stats-U5FHDIR7.js → stats-MYQD4OVD.js} +9 -9
  84. package/dist/{stop-YUZNQBRQ.js → stop-K6TDJORY.js} +6 -6
  85. package/dist/{stop-failure-6WFAKH2U.js → stop-failure-U635O5A3.js} +7 -7
  86. package/dist/{subagent-start-GWJXAAH3.js → subagent-start-LLGQBC5Q.js} +7 -7
  87. package/dist/{subagent-stop-B44SMV2R.js → subagent-stop-BO3GZWXD.js} +7 -7
  88. package/dist/{task-completed-GIUFSRTP.js → task-completed-3ZJHSI2E.js} +7 -7
  89. package/dist/{team-3YI3UWB3.js → team-OJLQCVE3.js} +5 -5
  90. package/dist/ui/assets/{index-RYHXSJv1.js → index-2UyTdjlV.js} +12 -12
  91. package/dist/ui/assets/index-Cts1wLEW.css +1 -0
  92. package/dist/ui/index.html +2 -2
  93. package/dist/{update-QPRTLGYU.js → update-HR7WMESA.js} +9 -9
  94. package/dist/{user-prompt-submit-FSYEPW7W.js → user-prompt-submit-NM73R4I3.js} +6 -6
  95. package/dist/{verify-ITBMLK67.js → verify-PSERIZPF.js} +8 -8
  96. package/dist/{version-VS2EDHBG.js → version-YCODQZSX.js} +2 -2
  97. package/package.json +1 -1
  98. package/skills/rules/SKILL.md +1 -1
  99. package/dist/chunk-GSKXOCFG.js.map +0 -1
  100. package/dist/chunk-TIAYBVSI.js.map +0 -1
  101. package/dist/doctor-M4Q7VCDO.js.map +0 -1
  102. package/dist/main-GAGOE6XB.js.map +0 -1
  103. package/dist/ui/assets/index-Bjv_ck3c.css +0 -1
  104. /package/dist/{agent-run-MNU2QWHR.js.map → agent-run-ZDHXORE2.js.map} +0 -0
  105. /package/dist/{agent-tasks-NCRKUU6E.js.map → agent-tasks-IPUWMMDZ.js.map} +0 -0
  106. /package/dist/{chunk-P6C6ADBU.js.map → chunk-2IJ6C63F.js.map} +0 -0
  107. /package/dist/{chunk-V2ZBYKDU.js.map → chunk-2WRXLYG6.js.map} +0 -0
  108. /package/dist/{chunk-6JZEAOLG.js.map → chunk-CPL76CYD.js.map} +0 -0
  109. /package/dist/{chunk-VWXDSDJU.js.map → chunk-DKSQMH5X.js.map} +0 -0
  110. /package/dist/{chunk-XAXQ72L3.js.map → chunk-FEX6ALLH.js.map} +0 -0
  111. /package/dist/{chunk-UILSK6DK.js.map → chunk-GBYLHPML.js.map} +0 -0
  112. /package/dist/{chunk-DZWSHCAC.js.map → chunk-KGL5QSDN.js.map} +0 -0
  113. /package/dist/{chunk-4JVHWBZF.js.map → chunk-KKEMVH6D.js.map} +0 -0
  114. /package/dist/{chunk-CJ2KTRWI.js.map → chunk-OGNEW5CN.js.map} +0 -0
  115. /package/dist/{chunk-2E7YGLLN.js.map → chunk-PFF4KB4O.js.map} +0 -0
  116. /package/dist/{chunk-4U6X35TH.js.map → chunk-QBB6LX57.js.map} +0 -0
  117. /package/dist/{chunk-C3GNF7RJ.js.map → chunk-QQ7CXA7Q.js.map} +0 -0
  118. /package/dist/{chunk-I3S6L7QC.js.map → chunk-U3SSOSIR.js.map} +0 -0
  119. /package/dist/{chunk-RJRRHTAA.js.map → chunk-U63GDHYJ.js.map} +0 -0
  120. /package/dist/{chunk-IRSNOBGD.js.map → chunk-UDBCAFXS.js.map} +0 -0
  121. /package/dist/{chunk-SGYYOTNM.js.map → chunk-VGVRBSLC.js.map} +0 -0
  122. /package/dist/{chunk-W7ZOOZMK.js.map → chunk-VQQ57UPG.js.map} +0 -0
  123. /package/dist/{chunk-ZMODJWI5.js.map → chunk-XVWJ273L.js.map} +0 -0
  124. /package/dist/{chunk-RPILIIYT.js.map → chunk-Y7QCKCEJ.js.map} +0 -0
  125. /package/dist/{chunk-D63XTGBV.js.map → chunk-YTOD6L6N.js.map} +0 -0
  126. /package/dist/{chunk-D2NTFSVO.js.map → chunk-ZUSTCXHT.js.map} +0 -0
  127. /package/dist/{cli-RYYABF2X.js.map → cli-BRDOPJW3.js.map} +0 -0
  128. /package/dist/{client-5VXKGNN2.js.map → client-AQZMD3LB.js.map} +0 -0
  129. /package/dist/{config-VHHCGE4F.js.map → config-K3CJEFFO.js.map} +0 -0
  130. /package/dist/{detect-6FNYONJF.js.map → detect-NJ2OREDP.js.map} +0 -0
  131. /package/dist/{detect-providers-R7QOB3H6.js.map → detect-providers-OE6HWW3M.js.map} +0 -0
  132. /package/dist/{executor-ULRFWJCH.js.map → executor-UYNV4EOQ.js.map} +0 -0
  133. /package/dist/{init-AEHAQFPK.js.map → init-MTMEFG4E.js.map} +0 -0
  134. /package/dist/{init-wizard-SVKDS3LR.js.map → init-wizard-ZS3CV6CM.js.map} +0 -0
  135. /package/dist/{installer-AARSFXI6.js.map → installer-CTWQB4RS.js.map} +0 -0
  136. /package/dist/{llm-LS7U7BHC.js.map → llm-PGETQHZ2.js.map} +0 -0
  137. /package/dist/{loader-QDWQTBX4.js.map → loader-AVWL7PNO.js.map} +0 -0
  138. /package/dist/{loader-YQDG5GI5.js.map → loader-J56KP27U.js.map} +0 -0
  139. /package/dist/{open-4QMAL32X.js.map → open-77QXL7VY.js.map} +0 -0
  140. /package/dist/{openai-embeddings-FUW6CSN2.js.map → openai-embeddings-LZKY6RV5.js.map} +0 -0
  141. /package/dist/{openrouter-YSIUSUQL.js.map → openrouter-UTOZG6Z5.js.map} +0 -0
  142. /package/dist/{post-compact-OAWEBEDK.js.map → post-compact-CA7O7QPL.js.map} +0 -0
  143. /package/dist/{post-tool-use-B3KOEOIM.js.map → post-tool-use-JNXJIGNK.js.map} +0 -0
  144. /package/dist/{post-tool-use-failure-2I5ELTTN.js.map → post-tool-use-failure-V52CZWVZ.js.map} +0 -0
  145. /package/dist/{pre-compact-NOXNJ5EV.js.map → pre-compact-J2GSDQCJ.js.map} +0 -0
  146. /package/dist/{provider-check-VEYONGNU.js.map → provider-check-CESRPIY5.js.map} +0 -0
  147. /package/dist/{registry-5R3DLJQH.js.map → registry-SPKP2WLI.js.map} +0 -0
  148. /package/dist/{remove-LX4G6KP7.js.map → remove-ODPLWK7O.js.map} +0 -0
  149. /package/dist/{resolution-events-CHOKR35X.js.map → resolution-events-CLDXZF67.js.map} +0 -0
  150. /package/dist/{restart-WSNBSALP.js.map → restart-6KKZODOI.js.map} +0 -0
  151. /package/dist/{search-Q6N3SHKP.js.map → search-U6N2DSOI.js.map} +0 -0
  152. /package/dist/{session-SKXJLJYH.js.map → session-6R3RNVKD.js.map} +0 -0
  153. /package/dist/{session-end-5EIVRCPS.js.map → session-end-TCFCYDBL.js.map} +0 -0
  154. /package/dist/{session-start-OL2ICLED.js.map → session-start-XXN2TVNI.js.map} +0 -0
  155. /package/dist/{setup-llm-BRNQW7K2.js.map → setup-llm-GISFI73Z.js.map} +0 -0
  156. /package/dist/{stats-U5FHDIR7.js.map → stats-MYQD4OVD.js.map} +0 -0
  157. /package/dist/{stop-YUZNQBRQ.js.map → stop-K6TDJORY.js.map} +0 -0
  158. /package/dist/{stop-failure-6WFAKH2U.js.map → stop-failure-U635O5A3.js.map} +0 -0
  159. /package/dist/{subagent-start-GWJXAAH3.js.map → subagent-start-LLGQBC5Q.js.map} +0 -0
  160. /package/dist/{subagent-stop-B44SMV2R.js.map → subagent-stop-BO3GZWXD.js.map} +0 -0
  161. /package/dist/{task-completed-GIUFSRTP.js.map → task-completed-3ZJHSI2E.js.map} +0 -0
  162. /package/dist/{team-3YI3UWB3.js.map → team-OJLQCVE3.js.map} +0 -0
  163. /package/dist/{update-QPRTLGYU.js.map → update-HR7WMESA.js.map} +0 -0
  164. /package/dist/{user-prompt-submit-FSYEPW7W.js.map → user-prompt-submit-NM73R4I3.js.map} +0 -0
  165. /package/dist/{verify-ITBMLK67.js.map → verify-PSERIZPF.js.map} +0 -0
  166. /package/dist/{version-VS2EDHBG.js.map → version-YCODQZSX.js.map} +0 -0
@@ -0,0 +1,690 @@
1
+ // Managed by Myco. Regenerated on `myco update`. Edit src/symbionts/templates/opencode/plugin.ts in the Myco repo instead.
2
+ // myco:plugin-marker:opencode
3
+ //
4
+ // Myco Codebase Intelligence Plugin for OpenCode.
5
+ //
6
+ // This plugin runs inside opencode's Bun runtime and communicates with the local
7
+ // Myco daemon over HTTP — no subprocess spawns, no hook CLI, no stdin piping.
8
+ //
9
+ // Capture: POST /sessions/register, /sessions/unregister, /events, /events/stop
10
+ // Context: GET /api/digest
11
+ // Inject: client.session.prompt({ noReply: true, parts: [{ synthetic: true }] })
12
+ //
13
+ // See https://opencode.ai/docs/plugins/
14
+ //
15
+ // Degraded-mode safety: this plugin ships committed inside any project that has
16
+ // run `myco init` — the file lives at .opencode/plugins/myco.ts in that project's
17
+ // repo. When a teammate clones such a project WITHOUT having Myco installed
18
+ // locally, opencode will still load this plugin (the file is right there in the
19
+ // cloned repo). To stay invisible in that case, the plugin has NO external
20
+ // runtime imports — only node:fs and node:path, which are always available in
21
+ // Bun's runtime. Every path that would contact the Myco daemon gracefully no-ops
22
+ // when `.myco/daemon.json` is absent or the daemon is unreachable, so the plugin
23
+ // becomes invisible rather than throwing. Do NOT add runtime imports from
24
+ // @opencode-ai/plugin or any other package — that would break this guarantee.
25
+
26
+ import { readFileSync, appendFileSync, mkdirSync } from "node:fs";
27
+ import { join } from "node:path";
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Constants
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /**
34
+ * Keep in sync with `TOOL_OUTPUT_PREVIEW_CHARS` in src/constants.ts (currently 200).
35
+ * The plugin file is standalone and cannot import from Myco — this value is copied
36
+ * so every symbiont records tool_output previews at the same length.
37
+ */
38
+ const TOOL_OUTPUT_PREVIEW_CHARS = 200;
39
+
40
+ /** Timeout for daemon HTTP calls — must be short so we never block opencode. */
41
+ const MYCO_FETCH_TIMEOUT_MS = 3000;
42
+
43
+ /** Tail window read from opencode when building the end-of-turn assistant summary. */
44
+ const SESSION_IDLE_TAIL_LIMIT = 12;
45
+
46
+ /** Heading prefix for compaction context — makes Myco's contribution recognizable in the compacted summary. */
47
+ const COMPACTION_HEADING = "## Myco — Project Context (preserved across compaction)\n\n";
48
+
49
+ /**
50
+ * Marker set on the `metadata` field of every synthetic TextPartInput this
51
+ * plugin injects via `client.session.prompt({ noReply: true, ... })`. The
52
+ * `chat.message` handler checks for this marker and skips matching messages
53
+ * so the injection doesn't re-enter as if it were a new user prompt.
54
+ *
55
+ * Why not the `synthetic` flag? opencode's own prompt.ts uses `synthetic: true`
56
+ * for ~20 distinct internal purposes (plan-mode prompts, build-switch
57
+ * transitions, subagent task summaries, shell-impl wrappers). Filtering on
58
+ * the synthetic flag rejects legitimate user messages whenever opencode has
59
+ * appended one of its own synthetic parts — which caused real user prompts
60
+ * to silently drop in live testing.
61
+ */
62
+ const MYCO_METADATA_MARKER = "myco";
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Types
66
+ // ---------------------------------------------------------------------------
67
+
68
+ type MessagePart = { type?: string; text?: string };
69
+ type SessionMessage = { info?: { role?: string }; parts?: MessagePart[] };
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Small helpers
73
+ // ---------------------------------------------------------------------------
74
+
75
+ function isRecord(value: unknown): value is Record<string, unknown> {
76
+ return typeof value === "object" && value !== null;
77
+ }
78
+
79
+ function pickString(
80
+ record: Record<string, unknown>,
81
+ keys: readonly string[],
82
+ ): string | undefined {
83
+ for (const key of keys) {
84
+ const value = record[key];
85
+ if (typeof value === "string" && value.length > 0) return value;
86
+ }
87
+ return undefined;
88
+ }
89
+
90
+ export function normalizeToolInput(toolInput: unknown): unknown {
91
+ if (!isRecord(toolInput)) return toolInput;
92
+
93
+ const filePath = pickString(toolInput, ["file_path", "filePath", "path"]);
94
+ const workdir = pickString(toolInput, ["workdir", "cwd"]);
95
+ const command = pickString(toolInput, ["command", "cmd"]);
96
+
97
+ return {
98
+ ...toolInput,
99
+ ...(filePath ? { file_path: filePath } : {}),
100
+ ...(workdir ? { workdir } : {}),
101
+ ...(command ? { command } : {}),
102
+ };
103
+ }
104
+
105
+ export function collectAssistantSummaryFromMessages(messages: SessionMessage[]): string {
106
+ const summaryParts: string[] = [];
107
+ let foundAssistantBlock = false;
108
+
109
+ for (let i = messages.length - 1; i >= 0; i--) {
110
+ const message = messages[i];
111
+ if (message?.info?.role !== "assistant") {
112
+ if (foundAssistantBlock) break;
113
+ continue;
114
+ }
115
+
116
+ foundAssistantBlock = true;
117
+ const text = (message.parts ?? [])
118
+ .filter((part) => part.type === "text" && part.text)
119
+ .map((part) => part.text as string)
120
+ .join("\n")
121
+ .trim();
122
+ if (text) summaryParts.unshift(text);
123
+ }
124
+
125
+ return summaryParts.join("\n").trim();
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Daemon HTTP transport — all communication with the local Myco daemon.
130
+ // Every function is best-effort: failures are swallowed so the plugin cannot
131
+ // interfere with opencode when Myco is absent or the daemon is unreachable.
132
+ // ---------------------------------------------------------------------------
133
+
134
+ /**
135
+ * Port cache for `.myco/daemon.json`. Read once on first access; refreshed on
136
+ * the next call that follows a failed HTTP request (handles daemon restarts
137
+ * mid-session). `undefined` = never loaded, `null` = loaded but absent.
138
+ */
139
+ let cachedDaemonPort: number | null | undefined = undefined;
140
+
141
+ /**
142
+ * Active opencode sessions tracked by this plugin instance. Populated on
143
+ * `session.created` and drained on `session.deleted` / `server.instance.disposed`.
144
+ *
145
+ * Opencode has no `session.end` event — when the TUI exits normally (Ctrl+C,
146
+ * close terminal), the session stays "active" from the daemon's perspective
147
+ * until the session-maintenance job sweeps it (1-hour threshold). To close
148
+ * sessions cleanly on TUI exit, we track them locally and call unregister
149
+ * for each one when `server.instance.disposed` fires.
150
+ */
151
+ const activeOpencodeSessions = new Set<string>();
152
+
153
+ /** Read the Myco daemon port from .myco/daemon.json in the project directory. */
154
+ function readDaemonPortFromDisk(directory: string): number | null {
155
+ try {
156
+ const raw = readFileSync(join(directory, ".myco", "daemon.json"), "utf-8");
157
+ const info = JSON.parse(raw) as { port?: number };
158
+ return typeof info.port === "number" ? info.port : null;
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ /** Get the cached daemon port, loading from disk on first access. */
165
+ function getDaemonPort(directory: string): number | null {
166
+ if (cachedDaemonPort === undefined) cachedDaemonPort = readDaemonPortFromDisk(directory);
167
+ return cachedDaemonPort;
168
+ }
169
+
170
+ /** Force-refresh the daemon port from disk — used after a fetch failure in case the daemon restarted. */
171
+ function refreshDaemonPort(directory: string): number | null {
172
+ cachedDaemonPort = readDaemonPortFromDisk(directory);
173
+ return cachedDaemonPort;
174
+ }
175
+
176
+ /** Fetch with a short timeout. Returns the Response on success, null on failure. */
177
+ async function fetchWithTimeout(url: string, init?: RequestInit): Promise<Response | null> {
178
+ const controller = new AbortController();
179
+ const timer = setTimeout(() => controller.abort(), MYCO_FETCH_TIMEOUT_MS);
180
+ try {
181
+ const res = await fetch(url, { ...init, signal: controller.signal });
182
+ return res.ok ? res : null;
183
+ } catch {
184
+ return null;
185
+ } finally {
186
+ clearTimeout(timer);
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Fetch from a daemon endpoint with a single retry after refreshing the port.
192
+ * The retry handles the case where the daemon restarted on a different port
193
+ * mid-session; the cache hot-path avoids a sync disk read on every HTTP call.
194
+ */
195
+ async function fetchFromDaemon(
196
+ directory: string,
197
+ path: string,
198
+ init?: RequestInit,
199
+ ): Promise<Response | null> {
200
+ const port = getDaemonPort(directory);
201
+ if (!port) return null;
202
+
203
+ const first = await fetchWithTimeout(`http://localhost:${port}${path}`, init);
204
+ if (first) return first;
205
+
206
+ // Retry once with a refreshed port — the daemon may have restarted.
207
+ const freshPort = refreshDaemonPort(directory);
208
+ if (!freshPort || freshPort === port) return null;
209
+ return fetchWithTimeout(`http://localhost:${freshPort}${path}`, init);
210
+ }
211
+
212
+ /**
213
+ * POST JSON to a daemon endpoint.
214
+ * Returns `{ ok, data }` — `ok` is true when the HTTP call succeeded, `data`
215
+ * is the parsed response body (may be absent if the body was empty or not JSON).
216
+ */
217
+ async function postJson(
218
+ directory: string,
219
+ path: string,
220
+ body: Record<string, unknown>,
221
+ ): Promise<{ ok: boolean; data?: unknown }> {
222
+ const res = await fetchFromDaemon(directory, path, {
223
+ method: "POST",
224
+ headers: { "Content-Type": "application/json" },
225
+ body: JSON.stringify(body),
226
+ });
227
+ if (!res) return { ok: false };
228
+ try {
229
+ return { ok: true, data: await res.json() };
230
+ } catch {
231
+ return { ok: true };
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Append an event to the local buffer at .myco/buffer/<session-id>.jsonl.
237
+ *
238
+ * Used as a fallback when the daemon HTTP POST fails (daemon down, network
239
+ * error, timeout). The daemon replays buffered events via reconcileBufferBatches
240
+ * at startup, so events captured here are NOT lost — they land in the vault
241
+ * the next time the daemon comes back up. Mirrors the fallback pattern in
242
+ * src/hooks/send-event.ts + src/capture/buffer.ts that every other symbiont
243
+ * uses (claude-code, cursor, codex, etc.) via the hook CLI path.
244
+ *
245
+ * Without this fallback, opencode would have a significant parity gap — all
246
+ * other symbionts preserve events across daemon downtime, but opencode events
247
+ * would be silently dropped the moment the daemon was unreachable.
248
+ *
249
+ * The entry shape matches EventBuffer.append(): event payload with session_id
250
+ * stripped (it's in the filename) and an auto-injected ISO timestamp.
251
+ */
252
+ function bufferEvent(
253
+ directory: string,
254
+ sessionId: string,
255
+ event: Record<string, unknown>,
256
+ ): void {
257
+ try {
258
+ const bufferDir = join(directory, ".myco", "buffer");
259
+ mkdirSync(bufferDir, { recursive: true });
260
+ const filePath = join(bufferDir, `${sessionId}.jsonl`);
261
+ // Strip session_id from the entry — it's encoded in the filename
262
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
263
+ const { session_id: _sid, ...payload } = event;
264
+ const line = JSON.stringify({
265
+ ...payload,
266
+ timestamp: payload.timestamp ?? new Date().toISOString(),
267
+ });
268
+ appendFileSync(filePath, line + "\n");
269
+ } catch (err) {
270
+ // eslint-disable-next-line no-console
271
+ console.error("[myco] Failed to buffer event:", err);
272
+ }
273
+ }
274
+
275
+ /**
276
+ * POST a capture event to the daemon with buffer fallback on failure.
277
+ * Used for user_prompt and tool_use events — both are replayed from the
278
+ * buffer by reconcileBufferBatches when the daemon restarts.
279
+ */
280
+ async function postEventWithBuffer(
281
+ directory: string,
282
+ sessionId: string,
283
+ event: Record<string, unknown>,
284
+ ): Promise<void> {
285
+ const result = await postJson(directory, "/events", event);
286
+ if (!result.ok) {
287
+ bufferEvent(directory, sessionId, event);
288
+ }
289
+ }
290
+
291
+ /** Register an opencode session with the daemon. */
292
+ async function mycoRegisterSession(
293
+ directory: string,
294
+ sessionId: string,
295
+ parentSessionId: string | undefined,
296
+ ): Promise<void> {
297
+ await postJson(directory, "/sessions/register", {
298
+ session_id: sessionId,
299
+ agent: "opencode",
300
+ parent_session_id: parentSessionId,
301
+ started_at: new Date().toISOString(),
302
+ });
303
+ }
304
+
305
+ /** Unregister an opencode session. */
306
+ async function mycoUnregisterSession(directory: string, sessionId: string): Promise<void> {
307
+ await postJson(directory, "/sessions/unregister", { session_id: sessionId });
308
+ }
309
+
310
+ /** Post a user prompt event. Images, if any, are shipped as an array of
311
+ * `{ data: base64, mediaType }` objects — the daemon's event dispatcher persists
312
+ * them as attachments keyed to the newly-opened prompt batch.
313
+ *
314
+ * Opencode has no on-disk transcript for Myco to mine, so images attached by
315
+ * the user in the TUI must travel with the prompt event itself. Other symbionts
316
+ * (claude-code, cursor) extract images from their JSONL transcripts at stop time.
317
+ *
318
+ * Falls back to the local buffer if the daemon is unreachable so events are
319
+ * replayed on daemon restart (same resilience the other symbionts get via
320
+ * src/hooks/send-event.ts).
321
+ */
322
+ async function mycoPostUserPrompt(
323
+ directory: string,
324
+ sessionId: string,
325
+ prompt: string,
326
+ images: Array<{ data: string; mediaType: string }>,
327
+ ): Promise<void> {
328
+ await postEventWithBuffer(directory, sessionId, {
329
+ type: "user_prompt",
330
+ session_id: sessionId,
331
+ agent: "opencode",
332
+ prompt,
333
+ ...(images.length > 0 ? { images } : {}),
334
+ });
335
+ }
336
+
337
+ /** Post a tool use event. Falls back to the local buffer on failure. */
338
+ async function mycoPostToolUse(
339
+ directory: string,
340
+ sessionId: string,
341
+ toolName: string,
342
+ toolInput: unknown,
343
+ toolOutput: string,
344
+ ): Promise<void> {
345
+ await postEventWithBuffer(directory, sessionId, {
346
+ type: "tool_use",
347
+ session_id: sessionId,
348
+ agent: "opencode",
349
+ tool_name: toolName,
350
+ tool_input: toolInput,
351
+ output_preview: toolOutput,
352
+ });
353
+ }
354
+
355
+ /** Post a stop event with the last assistant message as the response summary. */
356
+ async function mycoPostStop(
357
+ directory: string,
358
+ sessionId: string,
359
+ lastAssistantMessage: string | undefined,
360
+ ): Promise<void> {
361
+ await postJson(directory, "/events/stop", {
362
+ session_id: sessionId,
363
+ agent: "opencode",
364
+ last_assistant_message: lastAssistantMessage,
365
+ });
366
+ }
367
+
368
+ /**
369
+ * Fetch the session-start context for a new opencode session. Hits the daemon's
370
+ * config-aware `POST /context` endpoint, which selects the digest tier the user
371
+ * has configured (`config.context.digest_tier`, default 5000) and returns the
372
+ * full session context (digest + branch + session ID lines).
373
+ *
374
+ * This is the same endpoint Claude Code's session-start hook uses, so opencode
375
+ * sessions receive the same context the user has configured for every other agent.
376
+ */
377
+ async function fetchMycoSessionContext(
378
+ directory: string,
379
+ sessionId: string,
380
+ ): Promise<string | null> {
381
+ const result = await postJson(directory, "/context", { session_id: sessionId });
382
+ if (!result.ok) return null;
383
+ const data = result.data as { text?: string } | undefined;
384
+ const text = data?.text?.trim() ?? "";
385
+ return text.length > 0 ? text : null;
386
+ }
387
+
388
+ // ---------------------------------------------------------------------------
389
+ // Opencode session injection — push synthetic context into session history.
390
+ // ---------------------------------------------------------------------------
391
+
392
+ /**
393
+ * Inject text into an opencode session as a synthetic (plugin-authored) user turn
394
+ * without triggering an AI response. The text part carries:
395
+ * - `synthetic: true` so opencode's TUI hides it from the chat log
396
+ * - `metadata.myco: true` so our own `chat.message` handler can distinguish
397
+ * this re-entry from a real user message (see MYCO_METADATA_MARKER)
398
+ * Errors are swallowed — injection is best-effort.
399
+ */
400
+ async function injectSyntheticContext(
401
+ client: unknown,
402
+ sessionId: string,
403
+ text: string,
404
+ ): Promise<void> {
405
+ try {
406
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
407
+ const c = client as any;
408
+ await c.session.prompt({
409
+ path: { id: sessionId },
410
+ body: {
411
+ parts: [
412
+ {
413
+ type: "text",
414
+ text,
415
+ synthetic: true,
416
+ metadata: { [MYCO_METADATA_MARKER]: true },
417
+ },
418
+ ],
419
+ noReply: true,
420
+ },
421
+ });
422
+ } catch (error) {
423
+ // eslint-disable-next-line no-console
424
+ console.error("[myco] Failed to inject synthetic context:", error);
425
+ }
426
+ }
427
+
428
+ /** Flatten todo items into a newline-separated summary. */
429
+ function formatTodos(
430
+ todos: Array<{ id?: string; content?: string; status?: string }>,
431
+ ): string {
432
+ if (!todos || todos.length === 0) return "";
433
+ return todos
434
+ .map((t) => `[${t.status || "pending"}] ${t.content || ""}`)
435
+ .join("\n");
436
+ }
437
+
438
+ /** Truncate tool output for storage. */
439
+ function summarizeToolOutput(output: unknown): string {
440
+ if (typeof output !== "string") return "";
441
+ return output.length > TOOL_OUTPUT_PREVIEW_CHARS
442
+ ? output.slice(0, TOOL_OUTPUT_PREVIEW_CHARS) + "..."
443
+ : output;
444
+ }
445
+
446
+ // ---------------------------------------------------------------------------
447
+ // Plugin entry
448
+ // ---------------------------------------------------------------------------
449
+
450
+ /**
451
+ * Opencode plugin entry. The function signature matches opencode's Plugin type
452
+ * via duck typing — we deliberately do NOT import the Plugin type from
453
+ * @opencode-ai/plugin so this file has zero external runtime dependencies.
454
+ * That guarantee lets teammates who clone a project that uses Myco still run
455
+ * opencode cleanly even when they don't have Myco installed locally.
456
+ *
457
+ * @param {{ client: any, directory: string, worktree: string }} ctx
458
+ */
459
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
460
+ export const MycoPlugin = async ({ client, directory, worktree }: { client: any; directory: string; worktree: string }) => {
461
+ // Best-effort init log. Wrapped in try-catch so a future SDK shape change in
462
+ // opencode (e.g. client.app.log moving) cannot prevent the plugin from
463
+ // registering its handlers.
464
+ try {
465
+ await client.app.log({
466
+ service: "myco",
467
+ level: "info",
468
+ message: "Myco plugin initialized",
469
+ extra: { directory, worktree },
470
+ });
471
+ } catch {
472
+ // Swallow — init log is diagnostic only.
473
+ }
474
+
475
+ return {
476
+ /**
477
+ * Generic event handler: session lifecycle, todos.
478
+ */
479
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
480
+ event: async ({ event }: { event: any }) => {
481
+ if (event.type === "session.created") {
482
+ const info = event.properties?.info ?? {};
483
+ const sessionId: string | undefined = info.id;
484
+ if (!sessionId) return;
485
+
486
+ activeOpencodeSessions.add(sessionId);
487
+
488
+ // Run the capture (register session with Myco daemon) and context fetch
489
+ // concurrently — they don't depend on each other, and parallelizing saves
490
+ // one round-trip of latency before the user's first turn lands. Context
491
+ // is fetched only for fresh sessions; resume sessions inherit the parent's
492
+ // history and don't need another injection.
493
+ //
494
+ // Re-entrancy: the synthetic text part carries `synthetic: true`; if opencode
495
+ // fires chat.message for it, our handler's synthetic-flag check skips it.
496
+ const [, sessionContext] = await Promise.all([
497
+ mycoRegisterSession(directory, sessionId, info.parentID || undefined),
498
+ info.parentID ? Promise.resolve(null) : fetchMycoSessionContext(directory, sessionId),
499
+ ]);
500
+
501
+ if (sessionContext) {
502
+ await injectSyntheticContext(client, sessionId, sessionContext);
503
+ }
504
+ return;
505
+ }
506
+
507
+ if (event.type === "session.deleted") {
508
+ const info = event.properties?.info ?? {};
509
+ if (info.id) {
510
+ activeOpencodeSessions.delete(info.id);
511
+ await mycoUnregisterSession(directory, info.id);
512
+ }
513
+ return;
514
+ }
515
+
516
+ if (event.type === "server.instance.disposed") {
517
+ // Opencode TUI is shutting down. Flush all tracked sessions so the
518
+ // daemon can mark them completed immediately rather than waiting for
519
+ // the stale-session maintenance sweep (1-hour threshold).
520
+ //
521
+ // Fire-and-forget-parallel: the Bun process is about to exit, so we
522
+ // can't rely on awaited fetches completing. Promise.all gives the
523
+ // unregister calls their best shot at landing before teardown; any
524
+ // that don't make it fall back to the session-maintenance job.
525
+ if (activeOpencodeSessions.size === 0) return;
526
+ const toClose = Array.from(activeOpencodeSessions);
527
+ activeOpencodeSessions.clear();
528
+ await Promise.all(toClose.map((id) => mycoUnregisterSession(directory, id)));
529
+ return;
530
+ }
531
+
532
+ if (event.type === "session.idle") {
533
+ const sessionId = event.properties?.sessionID;
534
+ if (!sessionId) return;
535
+
536
+ // Fetch the last assistant message for a response summary.
537
+ let responseSummary = "";
538
+ try {
539
+ // `limit` is a tail-limit in opencode's server (returns the last N
540
+ // messages in chronological order — verified empirically against
541
+ // opencode v1.4.1 via `opencode serve`). The tail window is large
542
+ // enough to capture a multi-message assistant block at end-of-turn
543
+ // without reading the full session history on every idle event.
544
+ const result = await client.session.messages({
545
+ path: { id: sessionId },
546
+ query: { directory, limit: SESSION_IDLE_TAIL_LIMIT },
547
+ });
548
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
549
+ const messages = ((result as any)?.data ?? []) as SessionMessage[];
550
+ responseSummary = collectAssistantSummaryFromMessages(messages);
551
+ } catch (err) {
552
+ // eslint-disable-next-line no-console
553
+ console.error("[myco] Failed to fetch messages for summary:", err);
554
+ }
555
+
556
+ await mycoPostStop(directory, sessionId, responseSummary || undefined);
557
+ return;
558
+ }
559
+
560
+ if (event.type === "todo.updated") {
561
+ const sessionId = event.properties?.sessionID;
562
+ if (!sessionId) return;
563
+ const todos = event.properties?.todos ?? [];
564
+ await mycoPostToolUse(
565
+ directory,
566
+ sessionId,
567
+ "TodoUpdate",
568
+ { todos, count: todos.length },
569
+ formatTodos(todos),
570
+ );
571
+ }
572
+ },
573
+
574
+ /**
575
+ * Chat message: capture the user prompt + any image attachments.
576
+ *
577
+ * Per-turn spore injection is intentionally not done here. A previous iteration
578
+ * injected spores via session.prompt({ noReply: true }) inside this handler, but
579
+ * opencode re-fires chat.message for the synthetic turn and the first real user
580
+ * message landed during the re-entrancy window. Agents can fetch context on
581
+ * demand via the myco_context and myco_search MCP tools.
582
+ *
583
+ * Re-entrancy guard: we check for `metadata.myco === true` on any part to
584
+ * detect our session-start digest injection coming back around. Opencode
585
+ * itself sets `synthetic: true` for many internal purposes (plan-mode
586
+ * prompts, build-switch transitions, subagent task summaries), so the
587
+ * `synthetic` flag alone is NOT reliable as a re-entrancy signal — it
588
+ * would silently drop any user prompt that opencode touched for one of
589
+ * those internal reasons.
590
+ */
591
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
592
+ "chat.message": async (input: any, output: any) => {
593
+ const sessionId = input?.sessionID;
594
+ if (!sessionId) return;
595
+
596
+ // Part shapes we care about: text (for the prompt string) and file
597
+ // (for image attachments encoded as data URLs — FilePart.url is
598
+ // `data:<mime>;base64,<data>` per
599
+ // packages/app/src/components/prompt-input/attachments.ts in opencode).
600
+ const allParts = (output?.parts ?? []) as Array<{
601
+ type?: string;
602
+ text?: string;
603
+ mime?: string;
604
+ url?: string;
605
+ synthetic?: boolean;
606
+ metadata?: { [key: string]: unknown };
607
+ }>;
608
+ // Skip if any part carries the Myco metadata marker — that means
609
+ // chat.message is firing for our own injectSyntheticContext call.
610
+ if (allParts.some((p) => p.metadata?.[MYCO_METADATA_MARKER] === true)) return;
611
+
612
+ // Prompt text = user's real text only. opencode emits `synthetic: true`
613
+ // text parts for internal scaffolding when the message contains file
614
+ // mentions, plan-mode switches, subagent tasks, and similar — see
615
+ // packages/opencode/src/session/prompt.ts. Those parts include full
616
+ // file contents, tool-call scaffolding, plan instructions, etc. Joining
617
+ // them into prompt_text would bloat every captured user prompt with
618
+ // system-level content that the user never typed.
619
+ const textParts = allParts
620
+ .filter((p) => p.type === "text" && p.text && p.synthetic !== true)
621
+ .map((p) => p.text as string);
622
+ const prompt = textParts.join("\n");
623
+ if (!prompt) return;
624
+
625
+ // Extract any image attachments from FilePart data URLs. Non-image file
626
+ // parts (code snippets, documents) are ignored here — only images travel
627
+ // to Myco as binary attachments via the existing attachment pipeline.
628
+ const images: Array<{ data: string; mediaType: string }> = [];
629
+ for (const part of allParts) {
630
+ if (
631
+ part.type !== "file" ||
632
+ !part.mime?.startsWith("image/") ||
633
+ typeof part.url !== "string" ||
634
+ !part.url.startsWith("data:")
635
+ ) {
636
+ continue;
637
+ }
638
+ const commaIdx = part.url.indexOf(",");
639
+ if (commaIdx <= 0) continue;
640
+ const base64 = part.url.slice(commaIdx + 1);
641
+ if (base64) images.push({ data: base64, mediaType: part.mime });
642
+ }
643
+
644
+ await mycoPostUserPrompt(directory, sessionId, prompt, images);
645
+ },
646
+
647
+ /**
648
+ * Post-tool execution: ship tool usage to Myco.
649
+ *
650
+ * We forward `input.args` as `tool_input` — NOT `output.metadata` — because
651
+ * `args` carries the tool invocation arguments (including `filePath` for
652
+ * write/edit/patch tools), which Myco's plan-capture matcher needs to detect
653
+ * writes to .opencode/plans/*.md.
654
+ */
655
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
656
+ "tool.execute.after": async (input: any, output: any) => {
657
+ const sessionId = input?.sessionID;
658
+ if (!sessionId) return;
659
+
660
+ const toolName = input?.tool ?? "unknown";
661
+ const toolInput = normalizeToolInput(input?.args ?? output?.metadata ?? {});
662
+ const toolOutput = summarizeToolOutput(output?.output);
663
+
664
+ await mycoPostToolUse(directory, sessionId, toolName, toolInput, toolOutput);
665
+ },
666
+
667
+ /**
668
+ * Compaction hook: fires BEFORE opencode generates a continuation summary
669
+ * during session compaction. Pushing the session context into output.context
670
+ * ensures Myco's project knowledge survives compaction rather than being
671
+ * dropped. The fetched context respects the user's configured digest tier.
672
+ *
673
+ * See https://opencode.ai/docs/plugins/#compaction-hooks
674
+ */
675
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
676
+ "experimental.session.compacting": async (input: any, output: any) => {
677
+ const sessionId = input?.sessionID;
678
+ if (!sessionId) return;
679
+
680
+ const sessionContext = await fetchMycoSessionContext(directory, sessionId);
681
+ if (!sessionContext) return;
682
+
683
+ if (Array.isArray(output?.context)) {
684
+ output.context.push(COMPACTION_HEADING + sessionContext);
685
+ }
686
+ },
687
+ };
688
+ };
689
+
690
+ export default MycoPlugin;
@@ -0,0 +1,8 @@
1
+ {
2
+ "permission": {
3
+ "bash": {
4
+ "myco-run *": "allow",
5
+ "myco *": "allow"
6
+ }
7
+ }
8
+ }