@neosh/api 0.1.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 (182) hide show
  1. package/package.json +31 -0
  2. package/src/generated/AccountKind.ts +9 -0
  3. package/src/generated/Activity.ts +85 -0
  4. package/src/generated/AgentCommand.ts +18 -0
  5. package/src/generated/AgentState.ts +6 -0
  6. package/src/generated/AgentSummary.ts +46 -0
  7. package/src/generated/Anchor.ts +12 -0
  8. package/src/generated/Animation.ts +40 -0
  9. package/src/generated/ApiCall.ts +520 -0
  10. package/src/generated/ApiError.ts +15 -0
  11. package/src/generated/ApiOk.ts +110 -0
  12. package/src/generated/ApiResponse.ts +8 -0
  13. package/src/generated/AscpMessage.ts +73 -0
  14. package/src/generated/AttachmentInfo.ts +25 -0
  15. package/src/generated/Attrs.ts +10 -0
  16. package/src/generated/AuthRef.ts +14 -0
  17. package/src/generated/BackgroundTask.ts +23 -0
  18. package/src/generated/BlockStartKind.ts +8 -0
  19. package/src/generated/BorderStyle.ts +3 -0
  20. package/src/generated/BranchInfo.ts +19 -0
  21. package/src/generated/Brand.ts +31 -0
  22. package/src/generated/BufferId.ts +6 -0
  23. package/src/generated/BuildId.ts +24 -0
  24. package/src/generated/Capability.ts +10 -0
  25. package/src/generated/ClientMessage.ts +67 -0
  26. package/src/generated/Color.ts +10 -0
  27. package/src/generated/CommandEntry.ts +13 -0
  28. package/src/generated/CommitInfo.ts +10 -0
  29. package/src/generated/ContentBlock.ts +39 -0
  30. package/src/generated/Contribution.ts +16 -0
  31. package/src/generated/CostBasis.ts +6 -0
  32. package/src/generated/CredentialInfo.ts +30 -0
  33. package/src/generated/CredentialSource.ts +18 -0
  34. package/src/generated/CursorMotion.ts +21 -0
  35. package/src/generated/CursorShape.ts +6 -0
  36. package/src/generated/DetachReason.ts +5 -0
  37. package/src/generated/DiffTarget.ts +10 -0
  38. package/src/generated/Dock.ts +9 -0
  39. package/src/generated/DriverCommand.ts +20 -0
  40. package/src/generated/DriverKind.ts +8 -0
  41. package/src/generated/Extent.ts +20 -0
  42. package/src/generated/ExtmarkId.ts +6 -0
  43. package/src/generated/ExtmarkInfo.ts +43 -0
  44. package/src/generated/ExtmarkOpts.ts +33 -0
  45. package/src/generated/ExtmarkRender.ts +44 -0
  46. package/src/generated/FileChange.ts +18 -0
  47. package/src/generated/FileState.ts +15 -0
  48. package/src/generated/FloatConfig.ts +63 -0
  49. package/src/generated/FrameSet.ts +9 -0
  50. package/src/generated/Gravity.ts +11 -0
  51. package/src/generated/HighlightDef.ts +13 -0
  52. package/src/generated/HighlightEntry.ts +14 -0
  53. package/src/generated/HighlightSpec.ts +19 -0
  54. package/src/generated/Hint.ts +18 -0
  55. package/src/generated/HlTarget.ts +10 -0
  56. package/src/generated/HookInvocation.ts +14 -0
  57. package/src/generated/HookName.ts +17 -0
  58. package/src/generated/HookOutcome.ts +14 -0
  59. package/src/generated/HookPayload.ts +95 -0
  60. package/src/generated/InputEvent.ts +31 -0
  61. package/src/generated/InstanceConfig.ts +26 -0
  62. package/src/generated/InstanceId.ts +6 -0
  63. package/src/generated/KeyCode.ts +20 -0
  64. package/src/generated/KeyContext.ts +24 -0
  65. package/src/generated/KeyMods.ts +8 -0
  66. package/src/generated/KeyPress.ts +5 -0
  67. package/src/generated/KeymapEntry.ts +11 -0
  68. package/src/generated/KeymapScope.ts +20 -0
  69. package/src/generated/LineDraw.ts +15 -0
  70. package/src/generated/LineRender.ts +9 -0
  71. package/src/generated/LinkState.ts +16 -0
  72. package/src/generated/MarkDraw.ts +40 -0
  73. package/src/generated/Message.ts +5 -0
  74. package/src/generated/MessageLevel.ts +3 -0
  75. package/src/generated/Mode.ts +6 -0
  76. package/src/generated/ModelCapabilities.ts +14 -0
  77. package/src/generated/ModelEntry.ts +13 -0
  78. package/src/generated/ModelId.ts +6 -0
  79. package/src/generated/ModelInfo.ts +30 -0
  80. package/src/generated/ModelSelection.ts +15 -0
  81. package/src/generated/ModelTier.ts +14 -0
  82. package/src/generated/NamespaceId.ts +6 -0
  83. package/src/generated/NodeCapabilities.ts +30 -0
  84. package/src/generated/NodeId.ts +11 -0
  85. package/src/generated/NodeInfo.ts +24 -0
  86. package/src/generated/NoticeKind.ts +11 -0
  87. package/src/generated/Offset.ts +7 -0
  88. package/src/generated/OnDelete.ts +7 -0
  89. package/src/generated/OptionChoice.ts +8 -0
  90. package/src/generated/OptionEntry.ts +23 -0
  91. package/src/generated/OptionSelection.ts +4 -0
  92. package/src/generated/OptionSpec.ts +17 -0
  93. package/src/generated/OptionType.ts +16 -0
  94. package/src/generated/OptionValue.ts +13 -0
  95. package/src/generated/PermissionDecision.ts +6 -0
  96. package/src/generated/PermissionMode.ts +3 -0
  97. package/src/generated/PermissionOption.ts +21 -0
  98. package/src/generated/PermissionOptionKind.ts +13 -0
  99. package/src/generated/PlanState.ts +6 -0
  100. package/src/generated/PlanStep.ts +7 -0
  101. package/src/generated/PluginActivation.ts +10 -0
  102. package/src/generated/PluginEvent.ts +77 -0
  103. package/src/generated/PluginId.ts +6 -0
  104. package/src/generated/PluginInbound.ts +10 -0
  105. package/src/generated/PluginInfo.ts +19 -0
  106. package/src/generated/PluginManifest.ts +54 -0
  107. package/src/generated/PluginOutbound.ts +11 -0
  108. package/src/generated/PluginPermission.ts +18 -0
  109. package/src/generated/PluginProvides.ts +19 -0
  110. package/src/generated/PluginRequest.ts +11 -0
  111. package/src/generated/PluginResponse.ts +10 -0
  112. package/src/generated/PointInfo.ts +16 -0
  113. package/src/generated/Pricing.ts +12 -0
  114. package/src/generated/ProjectKey.ts +21 -0
  115. package/src/generated/ProviderEmit.ts +11 -0
  116. package/src/generated/ProviderEvent.ts +32 -0
  117. package/src/generated/ProviderOptionDescriptor.ts +38 -0
  118. package/src/generated/ProviderOptionValue.ts +3 -0
  119. package/src/generated/QuestionAnswer.ts +24 -0
  120. package/src/generated/QuestionOption.ts +17 -0
  121. package/src/generated/QuotaCredits.ts +23 -0
  122. package/src/generated/QuotaSample.ts +19 -0
  123. package/src/generated/QuotaSeverity.ts +11 -0
  124. package/src/generated/QuotaSnapshot.ts +34 -0
  125. package/src/generated/QuotaSource.ts +6 -0
  126. package/src/generated/QuotaWindow.ts +40 -0
  127. package/src/generated/Rect.ts +7 -0
  128. package/src/generated/Refusal.ts +10 -0
  129. package/src/generated/RemoteProject.ts +19 -0
  130. package/src/generated/RepoInfo.ts +23 -0
  131. package/src/generated/RepoStatus.ts +5 -0
  132. package/src/generated/RequestId.ts +6 -0
  133. package/src/generated/Role.ts +3 -0
  134. package/src/generated/RunningTurn.ts +12 -0
  135. package/src/generated/SelectShape.ts +6 -0
  136. package/src/generated/ServerMessage.ts +15 -0
  137. package/src/generated/SessionId.ts +6 -0
  138. package/src/generated/SessionInfo.ts +171 -0
  139. package/src/generated/StatusAlign.ts +3 -0
  140. package/src/generated/StatusSegment.ts +49 -0
  141. package/src/generated/StopReason.ts +16 -0
  142. package/src/generated/StreamEvent.ts +26 -0
  143. package/src/generated/StreamId.ts +6 -0
  144. package/src/generated/SurfaceCell.ts +22 -0
  145. package/src/generated/SurfaceId.ts +6 -0
  146. package/src/generated/SwarmAgent.ts +11 -0
  147. package/src/generated/SwarmNode.ts +24 -0
  148. package/src/generated/SwarmStranger.ts +21 -0
  149. package/src/generated/TaskId.ts +11 -0
  150. package/src/generated/TaskStatus.ts +11 -0
  151. package/src/generated/TextEdit.ts +19 -0
  152. package/src/generated/ToolCall.ts +13 -0
  153. package/src/generated/ToolCallId.ts +6 -0
  154. package/src/generated/ToolDef.ts +12 -0
  155. package/src/generated/ToolInvocation.ts +11 -0
  156. package/src/generated/ToolResult.ts +3 -0
  157. package/src/generated/ToolSource.ts +12 -0
  158. package/src/generated/TurnId.ts +6 -0
  159. package/src/generated/TurnRequest.ts +56 -0
  160. package/src/generated/UiEvent.ts +80 -0
  161. package/src/generated/Usage.ts +15 -0
  162. package/src/generated/UsageBucket.ts +32 -0
  163. package/src/generated/UsageHistory.ts +23 -0
  164. package/src/generated/UsageResolution.ts +6 -0
  165. package/src/generated/UsageScanSource.ts +24 -0
  166. package/src/generated/UsageScanStatus.ts +3 -0
  167. package/src/generated/UserQuestion.ts +34 -0
  168. package/src/generated/VarScope.ts +18 -0
  169. package/src/generated/ViewId.ts +16 -0
  170. package/src/generated/ViewInfo.ts +23 -0
  171. package/src/generated/Viewport.ts +18 -0
  172. package/src/generated/VirtChunk.ts +6 -0
  173. package/src/generated/VirtTextPos.ts +6 -0
  174. package/src/generated/WindowId.ts +6 -0
  175. package/src/generated/WindowInfo.ts +22 -0
  176. package/src/generated/WindowLayout.ts +28 -0
  177. package/src/generated/WorkspaceStatus.ts +28 -0
  178. package/src/generated/WorktreeInfo.ts +16 -0
  179. package/src/globals.d.ts +39 -0
  180. package/src/index.ts +2961 -0
  181. package/src/ui.ts +3279 -0
  182. package/tsconfig.json +17 -0
package/src/index.ts ADDED
@@ -0,0 +1,2961 @@
1
+ /**
2
+ * `@neosh/api` — the entire plugin surface.
3
+ *
4
+ * This file is both the implementation and the types. It is embedded into the host binary and
5
+ * transpiled at load, and it is what plugin authors type-check against, so the two cannot drift
6
+ * apart — there is no second copy to forget to update.
7
+ *
8
+ * Everything here is built on the wire types in `./generated/`, which are emitted from the Rust
9
+ * side by ts-rs and drift-checked in CI. Nothing in this file reaches the host except as an
10
+ * `ApiCall`, which means an out-of-process plugin in another language has exactly this surface.
11
+ */
12
+
13
+ import type { Activity } from "./generated/Activity";
14
+ import type { AttachmentInfo } from "./generated/AttachmentInfo";
15
+ import type { ApiCall } from "./generated/ApiCall";
16
+ import type { ApiError } from "./generated/ApiError";
17
+ import type { ApiOk } from "./generated/ApiOk";
18
+ import type { ApiResponse } from "./generated/ApiResponse";
19
+ import type { BranchInfo } from "./generated/BranchInfo";
20
+ import type { BufferId } from "./generated/BufferId";
21
+ import type { Capability } from "./generated/Capability";
22
+ import type { CommitInfo } from "./generated/CommitInfo";
23
+ import type { CommandEntry } from "./generated/CommandEntry";
24
+ import type { AgentCommand } from "./generated/AgentCommand";
25
+ import type { AgentState } from "./generated/AgentState";
26
+ import type { AgentSummary } from "./generated/AgentSummary";
27
+ import type { Contribution } from "./generated/Contribution";
28
+ import type { AccountKind } from "./generated/AccountKind";
29
+ import type { Brand } from "./generated/Brand";
30
+ import type { CredentialInfo } from "./generated/CredentialInfo";
31
+ import type { CredentialSource } from "./generated/CredentialSource";
32
+ import type { CursorMotion } from "./generated/CursorMotion";
33
+ import type { CursorShape } from "./generated/CursorShape";
34
+ import type { SelectShape } from "./generated/SelectShape";
35
+ import type { DiffTarget } from "./generated/DiffTarget";
36
+ import type { Dock } from "./generated/Dock";
37
+ import type { Gravity } from "./generated/Gravity";
38
+ import type { Hint } from "./generated/Hint";
39
+ import type { ExtmarkId } from "./generated/ExtmarkId";
40
+ import type { FileChange } from "./generated/FileChange";
41
+ import type { FileState } from "./generated/FileState";
42
+ import type { ExtmarkInfo } from "./generated/ExtmarkInfo";
43
+ import type { ExtmarkOpts } from "./generated/ExtmarkOpts";
44
+ import type { FloatConfig } from "./generated/FloatConfig";
45
+ import type { HighlightDef } from "./generated/HighlightDef";
46
+ import type { HighlightEntry } from "./generated/HighlightEntry";
47
+ import type { HighlightSpec } from "./generated/HighlightSpec";
48
+ import type { HlTarget } from "./generated/HlTarget";
49
+ import type { HookName } from "./generated/HookName";
50
+ import type { HookOutcome } from "./generated/HookOutcome";
51
+ import type { HookPayload } from "./generated/HookPayload";
52
+ import type { InstanceConfig } from "./generated/InstanceConfig";
53
+ import type { KeyContext } from "./generated/KeyContext";
54
+ import type { KeymapEntry } from "./generated/KeymapEntry";
55
+ import type { KeymapScope } from "./generated/KeymapScope";
56
+ import type { MessageLevel } from "./generated/MessageLevel";
57
+ import type { Mode } from "./generated/Mode";
58
+ import type { ModelEntry } from "./generated/ModelEntry";
59
+ import type { ModelInfo } from "./generated/ModelInfo";
60
+ import type { ModelSelection } from "./generated/ModelSelection";
61
+ import type { ModelTier } from "./generated/ModelTier";
62
+ import type { NamespaceId } from "./generated/NamespaceId";
63
+ import type { OptionChoice } from "./generated/OptionChoice";
64
+ import type { OptionEntry } from "./generated/OptionEntry";
65
+ import type { OptionSelection } from "./generated/OptionSelection";
66
+ import type { OptionSpec } from "./generated/OptionSpec";
67
+ import type { OptionType } from "./generated/OptionType";
68
+ import type { OptionValue } from "./generated/OptionValue";
69
+ import type { PermissionDecision } from "./generated/PermissionDecision";
70
+ import type { PermissionMode } from "./generated/PermissionMode";
71
+ import type { PermissionOption } from "./generated/PermissionOption";
72
+ import type { PermissionOptionKind } from "./generated/PermissionOptionKind";
73
+ import type { QuestionAnswer } from "./generated/QuestionAnswer";
74
+ import type { QuestionOption } from "./generated/QuestionOption";
75
+ import type { UserQuestion } from "./generated/UserQuestion";
76
+ import type { PluginEvent } from "./generated/PluginEvent";
77
+ import type { PointInfo } from "./generated/PointInfo";
78
+ import type { PluginInfo } from "./generated/PluginInfo";
79
+ import type { PluginManifest } from "./generated/PluginManifest";
80
+ import type { Pricing } from "./generated/Pricing";
81
+ import type { QuotaCredits } from "./generated/QuotaCredits";
82
+ import type { QuotaSample } from "./generated/QuotaSample";
83
+ import type { QuotaSeverity } from "./generated/QuotaSeverity";
84
+ import type { QuotaSnapshot } from "./generated/QuotaSnapshot";
85
+ import type { QuotaSource } from "./generated/QuotaSource";
86
+ import type { QuotaWindow } from "./generated/QuotaWindow";
87
+ import type { UsageBucket } from "./generated/UsageBucket";
88
+ import type { UsageHistory } from "./generated/UsageHistory";
89
+ import type { UsageResolution } from "./generated/UsageResolution";
90
+ import type { UsageScanSource } from "./generated/UsageScanSource";
91
+ import type { CostBasis } from "./generated/CostBasis";
92
+ import type { DriverCommand } from "./generated/DriverCommand";
93
+ import type { PlanState } from "./generated/PlanState";
94
+ import type { PlanStep } from "./generated/PlanStep";
95
+ import type { ProviderEvent } from "./generated/ProviderEvent";
96
+ import type { TaskId } from "./generated/TaskId";
97
+ import type { TaskStatus } from "./generated/TaskStatus";
98
+ import type { ProviderOptionDescriptor } from "./generated/ProviderOptionDescriptor";
99
+ import type { Message } from "./generated/Message";
100
+ import type { Rect } from "./generated/Rect";
101
+ import type { RepoInfo } from "./generated/RepoInfo";
102
+ import type { RepoStatus } from "./generated/RepoStatus";
103
+ import type { SessionId } from "./generated/SessionId";
104
+ import type { SessionInfo } from "./generated/SessionInfo";
105
+ import type { StatusAlign } from "./generated/StatusAlign";
106
+ import type { StatusSegment } from "./generated/StatusSegment";
107
+ import type { StopReason } from "./generated/StopReason";
108
+ import type { SurfaceCell } from "./generated/SurfaceCell";
109
+ import type { TextEdit } from "./generated/TextEdit";
110
+ import type { SurfaceId } from "./generated/SurfaceId";
111
+ import type { ToolCall } from "./generated/ToolCall";
112
+ import type { ToolDef } from "./generated/ToolDef";
113
+ import type { ToolResult } from "./generated/ToolResult";
114
+ import type { TurnRequest } from "./generated/TurnRequest";
115
+ import type { Usage } from "./generated/Usage";
116
+ import type { NodeCapabilities } from "./generated/NodeCapabilities";
117
+ import type { NodeId } from "./generated/NodeId";
118
+ import type { NodeInfo } from "./generated/NodeInfo";
119
+ import type { ProjectKey } from "./generated/ProjectKey";
120
+ import type { RemoteProject } from "./generated/RemoteProject";
121
+ import type { StreamEvent } from "./generated/StreamEvent";
122
+ import type { SwarmAgent } from "./generated/SwarmAgent";
123
+ import type { SwarmNode } from "./generated/SwarmNode";
124
+ import type { SwarmStranger } from "./generated/SwarmStranger";
125
+ import type { VarScope } from "./generated/VarScope";
126
+ import type { ViewId } from "./generated/ViewId";
127
+ import type { ViewInfo } from "./generated/ViewInfo";
128
+ import type { Viewport } from "./generated/Viewport";
129
+ import type { WindowId } from "./generated/WindowId";
130
+ import type { WindowInfo } from "./generated/WindowInfo";
131
+ import type { WindowLayout } from "./generated/WindowLayout";
132
+ import type { WorktreeInfo } from "./generated/WorktreeInfo";
133
+
134
+ export type {
135
+ AccountKind, Activity, ApiError, BranchInfo, Brand, BufferId, Capability, CommandEntry, CommitInfo,
136
+ AgentCommand, AgentState, AgentSummary,
137
+ Contribution, CredentialInfo, CredentialSource, CursorShape, DiffTarget, Dock, CursorMotion, ExtmarkId, ExtmarkInfo, ExtmarkOpts, FileChange, FileState, FloatConfig,
138
+ Gravity, HighlightDef, HighlightEntry, HighlightSpec, Hint, HlTarget, HookName, HookOutcome, HookPayload, InstanceConfig, KeyContext,
139
+ AttachmentInfo,
140
+ KeymapEntry, KeymapScope, MessageLevel, Mode, ModelEntry, ModelInfo, ModelSelection, ModelTier, NamespaceId,
141
+ OptionChoice, OptionEntry, OptionSelection, OptionSpec, OptionType, OptionValue,
142
+ DriverCommand, PlanState, PlanStep, TaskId, TaskStatus,
143
+ Message, PermissionDecision, PermissionMode, PermissionOption, PermissionOptionKind, PluginEvent, PluginInfo, PluginManifest, PointInfo, Pricing, ProviderEvent, ProviderOptionDescriptor,
144
+ QuestionAnswer, QuestionOption, UserQuestion,
145
+ CostBasis, QuotaCredits, QuotaSample, QuotaSeverity, QuotaSnapshot, QuotaSource, QuotaWindow,
146
+ UsageBucket, UsageHistory, UsageResolution, UsageScanSource,
147
+ Rect, RepoInfo, RepoStatus, SelectShape, SessionId, SessionInfo, StatusAlign, StatusSegment, StopReason,
148
+ SurfaceCell, SurfaceId, TextEdit, ToolCall, ToolDef, ToolResult, TurnRequest, Usage,
149
+ NodeCapabilities, NodeId, NodeInfo, ProjectKey, RemoteProject, StreamEvent,
150
+ SwarmAgent, SwarmNode, SwarmStranger,
151
+ VarScope, ViewId, ViewInfo, Viewport,
152
+ WindowId, WindowInfo, WindowLayout,
153
+ WorktreeInfo,
154
+ };
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Columns
158
+ // ---------------------------------------------------------------------------
159
+
160
+ /**
161
+ * UTF-8 byte length of a string.
162
+ *
163
+ * Every column in this API is a **UTF-8 byte offset**, matching Neovim and for the same reason: it
164
+ * keeps display-width math in exactly one place, the frontend. JavaScript's `.length` is UTF-16
165
+ * code units, which agrees with bytes only for ASCII — so a highlight placed with `.length` on a
166
+ * line containing an emoji or any CJK lands in the wrong column, or inside a character.
167
+ */
168
+ export function byteLength(s: string): number {
169
+ let n = 0;
170
+ for (const ch of s) {
171
+ const c = ch.codePointAt(0) ?? 0;
172
+ n += c < 0x80 ? 1 : c < 0x800 ? 2 : c < 0x10000 ? 3 : 4;
173
+ }
174
+ return n;
175
+ }
176
+
177
+ /**
178
+ * Byte offset of each code point in `s`, plus one final entry holding the total length.
179
+ *
180
+ * `byteOffsets(s)[i]` is the column to pass for the `i`th code point, so a match found with
181
+ * `Array.from(s)` can be turned into marks without measuring the prefix again per position.
182
+ */
183
+ export function byteOffsets(s: string): number[] {
184
+ const out: number[] = [];
185
+ let n = 0;
186
+ for (const ch of s) {
187
+ out.push(n);
188
+ const c = ch.codePointAt(0) ?? 0;
189
+ n += c < 0x80 ? 1 : c < 0x800 ? 2 : c < 0x10000 ? 3 : 4;
190
+ }
191
+ out.push(n);
192
+ return out;
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Transport
197
+ // ---------------------------------------------------------------------------
198
+
199
+ /** The ops the host installs. Everything else is built on these. */
200
+ interface CoreOps {
201
+ op_neosh_send(msg: unknown): void;
202
+ op_neosh_next(): Promise<unknown>;
203
+ op_neosh_width(text: string): number;
204
+ op_neosh_clip(text: string, columns: number): string;
205
+ }
206
+ declare const Deno: { core: { ops: CoreOps } };
207
+
208
+ /**
209
+ * How many terminal columns a string occupies.
210
+ *
211
+ * Use this for any layout with a column in it. JavaScript offers `String.length` (UTF-16 units) and
212
+ * `Array.from(s).length` (code points), and both are wrong for the text a model produces: `"日本"`
213
+ * is 2 code points and **4** columns, `"👋🏽"` is 2 code points and **2**, `"é"` may be 2 code points
214
+ * and **1**. Padding with either draws a ragged rule the first time a CJK model name appears.
215
+ *
216
+ * Synchronous — it is an op, not a host call, so calling it per row costs nothing. It uses the same
217
+ * measurement the renderer does, so a plugin and the frontend agree by construction.
218
+ */
219
+ export function width(text: string): number {
220
+ return Deno.core.ops.op_neosh_width(text);
221
+ }
222
+
223
+ /** Truncate to at most `columns` columns, cutting on a grapheme boundary rather than mid-character. */
224
+ export function clipToWidth(text: string, columns: number): string {
225
+ return Deno.core.ops.op_neosh_clip(text, Math.max(0, Math.floor(columns)));
226
+ }
227
+
228
+ /** Pad on the right to exactly `columns`, clipping if it is already wider. */
229
+ export function padToWidth(text: string, columns: number): string {
230
+ const clipped = clipToWidth(text, columns);
231
+ return clipped + " ".repeat(Math.max(0, columns - width(clipped)));
232
+ }
233
+
234
+ /** Thrown when the host refuses a call. Carries the structured reason. */
235
+ export class NeoshError extends Error {
236
+ constructor(readonly cause_: ApiError) {
237
+ super(describeError(cause_));
238
+ this.name = "NeoshError";
239
+ }
240
+ }
241
+
242
+ function describeError(e: ApiError): string {
243
+ switch (e.kind) {
244
+ case "not_found": return `not found: ${e.what}`;
245
+ case "invalid_argument": return `invalid argument: ${e.message}`;
246
+ case "denied": return `denied: ${e.reason}`;
247
+ case "not_permitted": return `not permitted: ${e.capability}`;
248
+ case "busy": return `busy: ${e.message}`;
249
+ case "internal": return `internal error: ${e.message}`;
250
+ }
251
+ }
252
+
253
+ let seq = 0;
254
+ const pending = new Map<string, { resolve: (v: ApiOk) => void; reject: (e: unknown) => void }>();
255
+
256
+ function send(msg: unknown): void {
257
+ Deno.core.ops.op_neosh_send(msg);
258
+ }
259
+
260
+ /**
261
+ * Issue a call that wants an answer.
262
+ *
263
+ * Calls from one plugin are applied by the host in the order they were issued, so a caller that
264
+ * fires several mutations without awaiting cannot race itself.
265
+ */
266
+ function call(plugin: string, c: ApiCall, view?: ViewId): Promise<ApiOk> {
267
+ const id = `${plugin}#${++seq}`;
268
+ return new Promise<ApiOk>((resolve, reject) => {
269
+ pending.set(id, { resolve, reject });
270
+ send({ type: "plugin", plugin, msg: { type: "call", id, call: c, view } });
271
+ });
272
+ }
273
+
274
+ /** Fire-and-forget. Used on the streaming path so appending a token is not round-trip bound. */
275
+ function notify(plugin: string, c: ApiCall, view?: ViewId): void {
276
+ send({ type: "plugin", plugin, msg: { type: "notify", call: c, view } });
277
+ }
278
+
279
+ function settle(id: string, response: ApiResponse): void {
280
+ const p = pending.get(id);
281
+ if (!p) return;
282
+ pending.delete(id);
283
+ if (response.status === "ok") p.resolve(response.value);
284
+ else p.reject(new NeoshError(response.error));
285
+ }
286
+
287
+ /** Narrow an `ApiOk` to the variant a call is documented to return. */
288
+ function expect<K extends ApiOk["ok"]>(v: ApiOk, kind: K): Extract<ApiOk, { ok: K }> {
289
+ if (v.ok !== kind) {
290
+ throw new Error(`host returned ${v.ok} where ${kind} was expected`);
291
+ }
292
+ return v as Extract<ApiOk, { ok: K }>;
293
+ }
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // Public surface
297
+ // ---------------------------------------------------------------------------
298
+
299
+ export interface Disposable {
300
+ dispose(): void;
301
+ }
302
+
303
+ export interface PluginContext {
304
+ readonly neosh: Neosh;
305
+ readonly pluginId: string;
306
+ readonly config: unknown;
307
+ /** Anything pushed here is disposed when the plugin unloads. */
308
+ readonly subscriptions: Disposable[];
309
+ }
310
+
311
+ /** A plugin's entry module exports this. */
312
+ export type Activate = (ctx: PluginContext) => void | Promise<void>;
313
+
314
+ export interface VirtText {
315
+ text: string;
316
+ hlGroup?: string;
317
+ }
318
+
319
+ export interface MarkOptions {
320
+ endCol?: number;
321
+ hlGroup?: string;
322
+ /**
323
+ * A background for the whole rendered row, not just the bytes the mark covers.
324
+ *
325
+ * It sits *under* every ranged `hlGroup` on the row rather than competing with them, so a row can
326
+ * be banded — selected, changed, at fault — and the text on it keeps whatever colour said what it
327
+ * was. `hlGroup` across the same span would replace that colour instead, which is how a selected
328
+ * row ends up the only one that has stopped saying anything.
329
+ */
330
+ lineHlGroup?: string;
331
+ virtText?: VirtText[];
332
+ virtTextPos?: ExtmarkOpts["virt_text_pos"];
333
+ onDelete?: ExtmarkOpts["on_delete"];
334
+ priority?: number;
335
+ }
336
+
337
+ /** One mark of a {@link DrawnRow}, positioned on that row. */
338
+ export interface DrawnMark {
339
+ /**
340
+ * UTF-8 byte offset into the row's text — the unit every column on the wire uses. Build it with
341
+ * {@link byteLength}, never `.length`.
342
+ */
343
+ col: number;
344
+ opts?: MarkOptions;
345
+ }
346
+
347
+ /** One row of a repaint: its text, and everything drawn on it. */
348
+ export interface DrawnRow {
349
+ text: string;
350
+ marks?: DrawnMark[];
351
+ }
352
+
353
+ export interface FloatOptions {
354
+ anchor?: FloatConfig["anchor"];
355
+ offset?: { row: number; col: number };
356
+ width?: FloatConfig["width"];
357
+ height?: FloatConfig["height"];
358
+ z?: number;
359
+ border?: FloatConfig["border"];
360
+ borderHl?: string;
361
+ title?: string;
362
+ closeOnBlur?: boolean;
363
+ focusable?: boolean;
364
+ /**
365
+ * Take the keyboard while this float has focus.
366
+ *
367
+ * Global bindings stop resolving: `^N`, `^T`, `^G` and the rest do nothing until it closes,
368
+ * instead of opening a second panel behind the first. Your own bindings still work — window,
369
+ * buffer and kind scopes are all nearer than global — and so does anything in
370
+ * `ui.modal_escape_keys` (`^Q` and `^R` by default), so a panel that forgets to bind a way out
371
+ * is never a terminal somebody has to kill. A key nothing claimed is swallowed rather than
372
+ * falling through to the composer.
373
+ *
374
+ * For a panel you are meant to answer before doing anything else: a question, a confirmation, a
375
+ * control sheet. Not for a hint or a hover card.
376
+ */
377
+ modal?: boolean;
378
+ }
379
+
380
+ export interface Neosh {
381
+ readonly version: number;
382
+ readonly buf: BufferApi;
383
+ readonly win: WindowApi;
384
+ readonly float: FloatApi;
385
+ readonly edit: EditApi;
386
+ readonly ns: NamespaceApi;
387
+ readonly hl: HighlightApi;
388
+ readonly ui: RawCellApi;
389
+ readonly cmd: CommandApi;
390
+ readonly keymap: KeymapApi;
391
+ readonly focus: FocusApi;
392
+ readonly agent: AgentApi;
393
+ readonly tool: ToolApi;
394
+ readonly hook: HookApi;
395
+ readonly provider: ProviderApi;
396
+ readonly git: GitApi;
397
+ readonly gen: GenApi;
398
+ readonly session: SessionApi;
399
+ readonly view: ViewApi;
400
+ readonly status: StatusApi;
401
+ readonly hint: HintApi;
402
+ readonly opt: OptionApi;
403
+ readonly state: StateApi;
404
+ readonly vars: VarApi;
405
+ readonly ext: ExtensionApi;
406
+ readonly event: EventApi;
407
+ readonly swarm: SwarmApi;
408
+ readonly quota: QuotaApi;
409
+ readonly rtp: RuntimePathApi;
410
+ readonly path: PathApi;
411
+ readonly timer: TimerApi;
412
+ readonly log: Logger;
413
+ /**
414
+ * Say something in the corner, as a reply to a key the user just pressed.
415
+ *
416
+ * The default and by far the commonest case: feedback for a keystroke. It does not stack — a
417
+ * second one replaces the first, because two keys pressed quickly are two keys and the reply
418
+ * you want is the one for the second — it lives about six seconds, and it never leaves the
419
+ * terminal.
420
+ *
421
+ * What it is *not* for is the thing the user can already see. `favourited ~/proj` next to a row
422
+ * that just grew a pin is the same fact printed twice, and a corner that is usually saying
423
+ * something you did not need is a corner people stop reading. See
424
+ * {@link Neosh.progress} and {@link Neosh.alert} for the two things that are not this.
425
+ */
426
+ notify(message: string, level?: MessageLevel): void;
427
+ /**
428
+ * Say what is happening, in a row that gets replaced rather than stacked.
429
+ *
430
+ * Keyed: writing the same key again replaces the row, and {@link Neosh.done} takes it away. This
431
+ * is what `pulling…` should have been — it was pushed onto the message stack, and so was the
432
+ * `up to date` that superseded it, which is how one pull drew two rows.
433
+ *
434
+ * A row nobody finishes is dropped after a minute, so a plugin that crashes mid-operation cannot
435
+ * leave a permanent claim on screen. Relying on that is a row that lies for up to a minute.
436
+ */
437
+ progress(key: string, message: string): void;
438
+ /** Take a progress row off, because the thing it was about has finished. */
439
+ done(key: string): void;
440
+ /**
441
+ * News: something happened that the user did not ask for.
442
+ *
443
+ * Drawn in the corner like a message, and — if the host works out that nobody is looking —
444
+ * raised outside the terminal as well, as an escape sequence the terminal turns into a real
445
+ * notification. Whether that happens is the host's decision and not yours: only it knows which
446
+ * conversation is on screen, which terminals are attached and whether any has focus.
447
+ *
448
+ * Needs `notify` in `plugin.toml`, because a plugin that can interrupt somebody who is in
449
+ * another application is a capability rather than a way of drawing. Rejected with
450
+ * `not permitted` otherwise.
451
+ *
452
+ * @param session Which conversation this is about, if it is about one. The test for "can they
453
+ * see this already" is asked against it; an alert about no conversation is never on screen.
454
+ */
455
+ alert(
456
+ title: string,
457
+ message: string,
458
+ opts?: { level?: MessageLevel; session?: SessionId },
459
+ ): Promise<void>;
460
+ /** Ask the host whether a side effect is allowed. */
461
+ permit(capability: Capability): Promise<PermissionDecision>;
462
+ /**
463
+ * Ask the *person* a question, and wait for the answer.
464
+ *
465
+ * Not a permission and not a picker. A permission asks whether something may happen and policy
466
+ * can answer it without waking anybody; a picker is a list you opened. This is the panel an agent
467
+ * gets when it asks you which library, which approach, which of these to enable — several
468
+ * questions in one sitting, some of them taking more than one answer, any of them answerable with
469
+ * something nobody listed.
470
+ *
471
+ * Whatever serves the `ask_user` hook draws it, so your question and the agent's look the same
472
+ * and a plugin that replaces the panel replaces both.
473
+ *
474
+ * `null` is *nobody answered* — dismissed, or timed out. Not an error: treating it as one means
475
+ * reporting a failure every time somebody presses `<Esc>`.
476
+ */
477
+ ask(questions: UserQuestion[]): Promise<QuestionAnswer[] | null>;
478
+ /**
479
+ * A terminal attached to a workspace that was already running. Only a plugin drawing raw cells
480
+ * needs this — the core forwards a surface's cells and keeps no copy, so they have to be painted
481
+ * again; everything in a buffer is republished without help.
482
+ */
483
+ onViewAttached(cb: () => void): Disposable;
484
+ /**
485
+ * The workspace is stopping. `deactivate` is called after this; both are bounded, so say your
486
+ * goodbye quickly. Reload is not this: a reloaded plugin gets `deactivate` and nothing here.
487
+ */
488
+ onShutdown(cb: () => void): Disposable;
489
+ /**
490
+ * What the agent is allowed to do without asking, and how to change it.
491
+ *
492
+ * `setMode` lasts for this session only. A mode switched on to get through one task should not
493
+ * still be on next week, and writing it to a file is exactly how that happens — so it is not
494
+ * written to one.
495
+ */
496
+ readonly permission: PermissionApi;
497
+ }
498
+
499
+ export interface BufferApi {
500
+ /**
501
+ * `kind` is what this buffer *is* — `neosh.sidebar`, `acme.tasks`. Say it if anything you draw is
502
+ * a panel somebody else might want to extend: it is what `keymap.set(..., { scope: { kind } })`
503
+ * binds against and what `win.list()` reports, and it costs one argument. Reverse domain by
504
+ * convention.
505
+ */
506
+ create(opts?: { name?: string; scratch?: boolean; kind?: string }): Promise<BufferId>;
507
+ lineCount(buf: BufferId): Promise<number>;
508
+ /**
509
+ * `end` is exclusive. Negative indices are `len + 1 + i`, as in Neovim's `nvim_buf_set_lines`:
510
+ * **`-1` is one past the last line**, so `getLines(buf, 0, -1)` is the whole buffer. To address
511
+ * the last line itself, use `-2, -1`.
512
+ */
513
+ getLines(buf: BufferId, start: number, end: number): Promise<string[]>;
514
+ /**
515
+ * Range replacement. There is no whole-document write, by design — streaming a response must not
516
+ * resend the document once per token.
517
+ *
518
+ * `setLines(buf, 0, -1, lines)` replaces the buffer; `setLines(buf, -1, -1, lines)` appends.
519
+ */
520
+ setLines(buf: BufferId, start: number, end: number, lines: string[]): Promise<void>;
521
+ /**
522
+ * Replace a range of rows **and** `ns`'s marks on them, in one call. What a panel should use to
523
+ * draw itself.
524
+ *
525
+ * The atomic form of `setLines` + `ns.clear` + a `ns.mark` per mark. That sequence is correct at
526
+ * rest and wrong in flight: each call is a round trip, the frontend draws on a ~16 ms deadline
527
+ * that knows nothing about how far through a repaint you are, and a frame landing after the clear
528
+ * draws every row with no marks at all — in `Normal`, which is near-white. A dim panel redrawing
529
+ * ten times a second flashes bright. This has no halfway state to observe, and costs one round
530
+ * trip instead of one per mark.
531
+ *
532
+ * Indices resolve as {@link setLines}'s do, and the clear covers exactly the rows written — so a
533
+ * partial repaint leaves the rest of the panel alone. Other namespaces are untouched, which is
534
+ * what lets an overlay survive the panel under it redrawing.
535
+ */
536
+ render(
537
+ buf: BufferId,
538
+ ns: NamespaceId,
539
+ start: number,
540
+ end: number,
541
+ rows: DrawnRow[],
542
+ ): Promise<void>;
543
+ /** Append to the final line without resending it. The streaming fast path. */
544
+ appendText(buf: BufferId, text: string): Promise<void>;
545
+ setName(buf: BufferId, name: string): Promise<void>;
546
+ /** Declare — or with `null`, withdraw — what this buffer is. See `create`. */
547
+ setKind(buf: BufferId, kind: string | null): Promise<void>;
548
+ kind(buf: BufferId): Promise<string | null>;
549
+ onChange(
550
+ buf: BufferId,
551
+ cb: (e: { buf: BufferId; start: number; oldEnd: number; newEnd: number }) => void,
552
+ ): Promise<Disposable>;
553
+ }
554
+
555
+ export interface WindowApi {
556
+ /**
557
+ * `gravity` is which end short content settles against: `"start"` (the default) pins it to the
558
+ * top, `"end"` to the bottom, which is what makes a transcript read as a conversation rather
559
+ * than as a document that happens to be in a window.
560
+ *
561
+ * `wrap` makes long lines fold rather than clip. Docks clip by default — a side panel that
562
+ * wrapped a long path would reflow every row below it — but a text field is prose and wants
563
+ * this on. A bottom dock that wraps also grows to show the folded rows, `size` acting as its
564
+ * floor.
565
+ */
566
+ open(
567
+ buf: BufferId,
568
+ dock: Dock,
569
+ opts?: { size?: number; gravity?: Gravity; wrap?: boolean },
570
+ ): Promise<WindowId>;
571
+ close(win: WindowId): Promise<void>;
572
+ /**
573
+ * Change how wide (or tall) a docked window is, without closing it.
574
+ *
575
+ * Reopening is not the same thing: the window id changes and whatever had the keyboard loses it,
576
+ * so a panel resized from inside itself would throw the cursor back to the composer on every
577
+ * press. `null` gives the dock its default extent back. Floats are configured with
578
+ * {@link FloatApi.configure}, and are refused here.
579
+ */
580
+ resize(win: WindowId, size: number | null): Promise<void>;
581
+ setBuf(win: WindowId, buf: BufferId): Promise<void>;
582
+ /** `col` is a UTF-8 byte offset, not a character or display column. */
583
+ cursor(win: WindowId): Promise<{ row: number; col: number }>;
584
+ setCursor(win: WindowId, row: number, col: number): Promise<void>;
585
+ /**
586
+ * Put a buffer row at the top of a window, or hand the scroll position back.
587
+ *
588
+ * `null` is *unscrolled*, which is where a window starts and is not the same place as row `0`:
589
+ * a window that follows its content — the transcript — shows its last screenful unscrolled and
590
+ * its first row at `0`. Anything else shows the same thing either way.
591
+ */
592
+ scrollTo(win: WindowId, topLine: number | null): Promise<void>;
593
+ /**
594
+ * How big this window actually is, in cells.
595
+ *
596
+ * `null` until the frontend has drawn it once. This is the only way to learn real geometry:
597
+ * everything about display width is resolved by the frontend, so a plugin sizing a meter or
598
+ * deciding what to drop at 60 columns asks rather than computing an answer it cannot compute
599
+ * correctly.
600
+ */
601
+ viewport(win: WindowId): Promise<Viewport | null>;
602
+ /**
603
+ * Every window that is open, and what is in it.
604
+ *
605
+ * How you find somebody else's panel. A window id belongs to whoever opened it and changes every
606
+ * time the panel is reopened, so this plus a buffer `kind` is the only way to say "the sidebar,
607
+ * whichever window that is right now" — and therefore the only way to act on one you did not
608
+ * open.
609
+ */
610
+ list(): Promise<WindowInfo[]>;
611
+ /** The open windows showing a buffer of this kind. Sugar over `list()`, which is the common case. */
612
+ ofKind(kind: string): Promise<WindowInfo[]>;
613
+ /**
614
+ * Remap group names for one window, or for every window of a buffer kind — Neovim's
615
+ * `winhighlight`. `{ Normal: "Acme.Panel", "Sidebar.Selected": "Acme.Sel" }` on
616
+ * `{ kind: "neosh.sidebar" }` recolours the sidebar without redefining the groups anything else
617
+ * draws with. A window's map sits over its kind's. An empty map clears; the remap is yours and
618
+ * goes when your plugin does.
619
+ */
620
+ setHighlights(
621
+ target: { win: WindowId } | { kind: string },
622
+ map: Record<string, string>,
623
+ ): Promise<void>;
624
+ }
625
+
626
+ /**
627
+ * Moving and editing text where a window's cursor is.
628
+ *
629
+ * Verbs rather than positions, because grapheme and word boundaries are genuinely hard and nobody
630
+ * should have to get them right twice. A text field in your plugin behaves the same way the
631
+ * composer does because they are asking the same question, not because they each reimplemented it.
632
+ */
633
+ export interface EditApi {
634
+ /**
635
+ * Move the cursor. `select` extends a selection from wherever it was anchored — anchoring first
636
+ * if nothing was — which is shift-and-arrow. Without it the selection is dropped.
637
+ */
638
+ move(win: WindowId, motion: CursorMotion, opts?: { select?: boolean }): Promise<void>;
639
+ /** Edit at the cursor. Typing over a selection replaces it, as everywhere else. */
640
+ apply(win: WindowId, edit: TextEdit): Promise<void>;
641
+ /** Anchor a selection where the cursor is, or drop the one there is. */
642
+ select(win: WindowId, on: boolean): Promise<void>;
643
+ /**
644
+ * What the two ends of the selection *mean*.
645
+ *
646
+ * `"exclusive"` is a text field's: the cursor sits between characters and the one it is on is
647
+ * not selected. `"inclusive"` is a normal mode's — the cursor is *on* a character and that
648
+ * character is in — and `"line"` takes whole rows in whichever direction the selection runs.
649
+ * Dropping a selection puts this back to `"exclusive"`.
650
+ */
651
+ selectShape(win: WindowId, shape: SelectShape): Promise<void>;
652
+ /**
653
+ * What the caret looks like here: a bar between two characters, or a block on one.
654
+ *
655
+ * The one thing on screen that says whether keys are being typed or obeyed, before any of them
656
+ * is pressed.
657
+ */
658
+ cursorShape(win: WindowId, shape: CursorShape): Promise<void>;
659
+ /** What is selected. `""` when nothing is. */
660
+ selection(win: WindowId): Promise<string>;
661
+ /**
662
+ * Put text on the system clipboard.
663
+ *
664
+ * A capability rather than something you could do yourself: the runtime has no terminal, and the
665
+ * frontend is the only thing holding the stream this has to travel down. Over SSH it reaches the
666
+ * terminal you are sitting at, which a clipboard library on the remote host would not.
667
+ */
668
+ copy(text: string): Promise<void>;
669
+ }
670
+
671
+ export interface FloatApi {
672
+ open(buf: BufferId, opts?: FloatOptions): Promise<WindowId>;
673
+ configure(win: WindowId, opts?: FloatOptions): Promise<void>;
674
+ close(win: WindowId): Promise<void>;
675
+ }
676
+
677
+ export interface NamespaceApi {
678
+ create(name: string): Promise<NamespaceId>;
679
+ /** `col` is a UTF-8 byte offset. */
680
+ mark(ns: NamespaceId, buf: BufferId, row: number, col: number, opts?: MarkOptions): Promise<ExtmarkId>;
681
+ getMark(ns: NamespaceId, buf: BufferId, id: ExtmarkId): Promise<ExtmarkInfo | null>;
682
+ allMarks(ns: NamespaceId, buf: BufferId): Promise<ExtmarkInfo[]>;
683
+ delMark(ns: NamespaceId, buf: BufferId, id: ExtmarkId): Promise<void>;
684
+ clear(ns: NamespaceId, buf: BufferId, start?: number, end?: number): Promise<void>;
685
+ }
686
+
687
+ export interface HighlightApi {
688
+ /**
689
+ * Declare a semantic group. Prefer `link` so an unknown theme still looks right.
690
+ *
691
+ * Yours from then on: a theme switch leaves it alone, and unloading your plugin takes it back to
692
+ * the theme's definition or away. `default: true` is Neovim's `:hi default` — define only if
693
+ * nobody has — which is what to use for the groups your plugin introduces, so a user's `init.ts`
694
+ * wins whichever of you loaded first.
695
+ */
696
+ define(
697
+ name: string,
698
+ def: { link: string } | HighlightSpec,
699
+ opts?: { default?: boolean },
700
+ ): Promise<void>;
701
+ /**
702
+ * What a group is, and what it resolves to after following links. Both `null` for a name nobody
703
+ * defined. The way to compute "a little dimmer than `Normal`" rather than guess at it.
704
+ */
705
+ get(name: string): Promise<{ def: HighlightDef | null; resolved: HighlightSpec | null }>;
706
+ /** Every group, with which plugin owns it (`owner` absent for the theme's own). */
707
+ list(): Promise<HighlightEntry[]>;
708
+ /** Undo your definition of a group. Rejects for a group another plugin owns. */
709
+ reset(name: string): Promise<void>;
710
+ /**
711
+ * Groups changed — defined, reset, or all of them on a theme switch. `names` says which. A
712
+ * panel that cached a colour reads it again here; nothing else needs to, because the frontend
713
+ * redraws on its own.
714
+ */
715
+ onChange(cb: (e: { names: string[] }) => void): Disposable;
716
+ }
717
+
718
+ export interface RawCellApi {
719
+ claim(win: WindowId, rect: Rect): Promise<SurfaceId>;
720
+ put(surface: SurfaceId, cells: SurfaceCell[]): Promise<void>;
721
+ release(surface: SurfaceId): Promise<void>;
722
+ }
723
+
724
+ /** What a command handler is given and what it may give back. */
725
+ export type CommandHandler = (args: string[], key?: KeyContext) => unknown | Promise<unknown>;
726
+
727
+ export interface CommandApi {
728
+ /**
729
+ * Register a command by name. Keys bind to the name; `cmd.exec` runs it; `cmd.call` runs it and
730
+ * returns what the handler returned, so a command is also how one plugin asks another a
731
+ * question — `sidebar.cursor`, `git.status.of` — without importing it.
732
+ */
733
+ register(name: string, fn: CommandHandler, opts?: { desc?: string }): Promise<Disposable>;
734
+ /** Run a command and do not wait for it. A key press, from code. */
735
+ exec(name: string, args?: string[]): Promise<void>;
736
+ /**
737
+ * Run a command and wait for its answer.
738
+ *
739
+ * Whatever the handler returned, as JSON — `null` for a handler that returned nothing. Rejects
740
+ * with the handler's error if it threw, with `not found` if nothing registered the name, and
741
+ * after a long timeout if the owner never answered. Routed through the host, so it works for
742
+ * the host's own commands (which answer `null`) and does not care which plugin owns the name.
743
+ *
744
+ * For a typed, zero-round-trip call into a plugin you depend on, `import { api } from
745
+ * "plugin:<name>"` instead — see the `requires` manifest field.
746
+ */
747
+ call<T = unknown>(name: string, args?: string[]): Promise<T>;
748
+ list(): Promise<CommandEntry[]>;
749
+ }
750
+
751
+ export interface KeymapApi {
752
+ /**
753
+ * Bind a key to a *command name*, never to a callback.
754
+ *
755
+ * That indirection is what makes every binding listable and remappable by the user, and it lets
756
+ * the host resolve routing without calling into a plugin.
757
+ *
758
+ * Scope resolves window → buffer → buffer kind → global, first match winning. `{ kind: "buf_kind",
759
+ * name: "neosh.sidebar" }` is the one to reach for when the thing you are binding into is
760
+ * somebody else's panel: a window id is private to whoever opened it and dies with the window,
761
+ * whereas a kind is a name the panel publishes and every window of that kind — including ones
762
+ * opened tomorrow — is covered by one call.
763
+ */
764
+ set(mode: Mode, lhs: string, command: string, opts?: { scope?: KeymapScope; desc?: string }): Promise<void>;
765
+ del(mode: Mode, lhs: string, scope?: KeymapScope): Promise<void>;
766
+ list(mode?: Mode): Promise<KeymapEntry[]>;
767
+ /**
768
+ * While `win` is focused, receive every key the keymaps did not claim.
769
+ *
770
+ * For widgets that need raw input: a filter box, a text field, a modal list. Bindings still win,
771
+ * so `<C-q>` keeps quitting while your picker is open — you get what nothing else wanted. The
772
+ * command is invoked with a `KeyContext`, so one handler can switch on the key.
773
+ *
774
+ * Dispose to release. A capture is also dropped when the window closes or your plugin unloads,
775
+ * so a crash cannot leave the keyboard pointing at nothing.
776
+ */
777
+ capture(win: WindowId, command: string): Promise<Disposable>;
778
+ }
779
+
780
+ export interface FocusApi {
781
+ push(win: WindowId): Promise<void>;
782
+ pop(): Promise<void>;
783
+ current(): Promise<WindowId | null>;
784
+ /**
785
+ * The keyboard moved. `win` is `null` when nothing has it — the composer. The same fact also
786
+ * arrives as `neosh.win.enter` / `neosh.win.leave` on the event bus, with the buffer's kind,
787
+ * which is the form to use when you only care about one panel.
788
+ */
789
+ onChange(cb: (e: { win: WindowId | null }) => void): Disposable;
790
+ }
791
+
792
+ export interface AgentApi {
793
+ /**
794
+ * Send a message. Anything on the composer's attachment row goes with it.
795
+ *
796
+ * `images` are extra paths to attach on the way through, for a plugin that has *produced* a
797
+ * picture rather than one somebody pasted — a rendered chart, a screenshot it took. The
798
+ * bytes are copied into the workspace, so a temporary file may be handed over and forgotten.
799
+ */
800
+ send(text: string, opts?: { images?: string[] }): Promise<void>;
801
+ cancel(): Promise<void>;
802
+ /**
803
+ * Do something to a conversation by id, rather than to whichever one is on screen.
804
+ *
805
+ * The same vocabulary `swarm.command` carries to another machine — steer, interrupt, re-model,
806
+ * rename, archive, start — pointed at a conversation here. That symmetry is the point: an
807
+ * orchestrator that fans work out over several conversations and joins the results is one
808
+ * program whether the conversations are on this laptop or spread over the swarm, and until this
809
+ * existed it was only writable for the ones that were somewhere else.
810
+ *
811
+ * Everything that *watches* a conversation already names one — `onToken`, `onTurnEnd`,
812
+ * `sessions.messages` — so this is the half that was missing. Without it, driving a second
813
+ * conversation meant `sessions.switch` first, which moves the screen out from under whoever is
814
+ * reading it.
815
+ *
816
+ * Omit `session` for the conversation on screen. Answers with the conversation the command was
817
+ * about, which is how `new_session` says what it made.
818
+ *
819
+ * ```ts
820
+ * // Ask three conversations the same thing without touching the screen.
821
+ * for (const s of await neosh.session.list()) {
822
+ * await neosh.agent.command({ command: "send", text: "status?" }, s.id);
823
+ * }
824
+ * ```
825
+ */
826
+ command(command: AgentCommand, session?: string): Promise<string | null>;
827
+ selection(): Promise<ModelSelection | null>;
828
+ /** Hot-swap the model. Takes effect on the next turn. */
829
+ setSelection(selection: ModelSelection): Promise<void>;
830
+ /**
831
+ * Every reachable model, each paired with the instance that serves it.
832
+ *
833
+ * The pairing matters: a model id is unique per instance, not globally, so a picker that dropped
834
+ * it would have to guess the owner — and guessing wrong sends the conversation to a different
835
+ * endpoint with no visible error.
836
+ *
837
+ * Answers from a session cache. Discovery is a network round trip per configured provider, so
838
+ * pass `refresh` only when you have reason to believe a lineup changed.
839
+ */
840
+ listModels(instance?: string, opts?: { refresh?: boolean }): Promise<ModelEntry[]>;
841
+ listInstances(): Promise<InstanceConfig[]>;
842
+ /**
843
+ * What the driver behind this conversation accepts as a slash command.
844
+ *
845
+ * Reported by the driver at its handshake, not configured. Which commands exist depends on the
846
+ * install — `claude` counts project `.claude/commands/`, plugin commands and MCP prompts among
847
+ * its own — so any list written down in a plugin would be wrong on the first machine that had
848
+ * one of its own. Empty until the conversation has run a turn: there has been nothing to ask.
849
+ */
850
+ driverCommands(): Promise<DriverCommand[]>;
851
+ /**
852
+ * Replace what is in the composer, caret at the end.
853
+ *
854
+ * For completion: a `/` menu, an `@file` menu, a path menu. Pair with the `composerChanged`
855
+ * event, which is the other half — one says what has been typed, this puts the answer back.
856
+ */
857
+ setDraft(text: string): Promise<void>;
858
+ /**
859
+ * Attach an image to whatever is about to be sent.
860
+ *
861
+ * With a path, that file. Without one, whatever image is on the system clipboard — which is
862
+ * the only way a picture can reach a terminal at all: bracketed paste is a text protocol, and a
863
+ * screenshot pasted into one arrives as nothing. That is why `^V` is a key rather than a paste.
864
+ *
865
+ * The bytes are copied into the workspace's own directory, sniffed for what they actually are
866
+ * rather than what they are called, and shrunk if they are enormous. Rejects when there is no
867
+ * image to be had, with a reason worth showing.
868
+ */
869
+ attach(path?: string): Promise<AttachmentInfo>;
870
+ /** What is attached to the composer right now, oldest first. */
871
+ attachments(): Promise<AttachmentInfo[]>;
872
+ /**
873
+ * Take something off the attachment row: the one at `index`, or the newest.
874
+ *
875
+ * Answers with what came off, or nothing if there was nothing there — the row may have
876
+ * gone out with a send between asking and answering, and that is not an error.
877
+ */
878
+ detach(index?: number): Promise<AttachmentInfo | null>;
879
+ /** Take the whole attachment row off. Answers with what was on it. */
880
+ detachAll(): Promise<AttachmentInfo[]>;
881
+ /**
882
+ * Where each configured provider's key comes from — and never what it is.
883
+ *
884
+ * There is deliberately no call that returns a secret. A plugin can find out that `anthropic` is
885
+ * authenticated from the keychain, and can ask the host to collect a new key; it cannot read one,
886
+ * so it cannot leak one.
887
+ */
888
+ credentials(): Promise<CredentialInfo[]>;
889
+ /**
890
+ * Ask the host to collect an API key for `instance` from the keyboard.
891
+ *
892
+ * The host runs this prompt itself: a plugin one would have to put the key in a buffer, and a
893
+ * buffer is drawn — the value would cross the frontend boundary and land in whatever it logs.
894
+ * What comes back is whether a key was stored, never the key.
895
+ *
896
+ * Rejects when the instance signs in on its own (a CLI login) or needs no key at all, and when it
897
+ * already has one unless you pass `replace`.
898
+ */
899
+ setCredential(instance: string, opts?: { replace?: boolean }): Promise<boolean>;
900
+ /** Drop a stored key from memory and from the keychain. The environment is not ours to clear. */
901
+ forgetCredential(instance: string): Promise<void>;
902
+ /**
903
+ * The model this conversation will use changed, whoever changed it.
904
+ *
905
+ * Not the same as `opt.onChange` for `agent.model`: that option is a preference, and the
906
+ * selection also moves when a conversation is restored, when a stored model turns out not to
907
+ * authenticate, and when a provider registers late and the model somebody asked for finally
908
+ * becomes reachable. Anything that names the model — a footer, a context meter measuring against
909
+ * its window — wants this one.
910
+ */
911
+ onSelectionChange(cb: (e: { selection: ModelSelection }) => void): Disposable;
912
+ /**
913
+ * The composer's text changed — a keystroke, a paste, a conversation switch, a send.
914
+ *
915
+ * The other half of {@link AgentApi.setDraft}. Completion of any kind is these two: watch what
916
+ * has been typed, offer something, put the answer back.
917
+ */
918
+ onComposerChange(cb: (e: { text: string }) => void): Disposable;
919
+ /**
920
+ * What a driver's own loop said about itself — a sub-agent, a plan, a compaction, how full its
921
+ * context is.
922
+ *
923
+ * The only signal that moves *during* a turn. Everything else about usage arrives when the turn
924
+ * ends, which for an agent driver can be twenty minutes after the number changed.
925
+ */
926
+ onActivity(cb: (e: { session: SessionId; turn: string; activity: Activity }) => void): Disposable;
927
+ /**
928
+ * A turn has begun.
929
+ *
930
+ * Every turn event says which conversation it belongs to. A workspace runs several at once, so
931
+ * anything that draws a turn has to check: by the time one ends, the conversation it ran in may
932
+ * not be the one on screen. `neosh.session.list()` flags the active one.
933
+ */
934
+ onTurnStart(cb: (e: { session: string; turn: string }) => void): Disposable;
935
+ /** One streamed chunk of assistant text. Chunks are provider-sized, not characters. */
936
+ onToken(cb: (e: { session: string; turn: string; text: string }) => void): Disposable;
937
+ onThinking(cb: (e: { session: string; turn: string; text: string }) => void): Disposable;
938
+ onTurnEnd(
939
+ cb: (e: { session: string; turn: string; stopReason: StopReason; usage: Usage }) => void,
940
+ ): Disposable;
941
+ /**
942
+ * A tool is about to run.
943
+ *
944
+ * Distinct from the `tool_pre` hook: that one is asked *whether* the call may proceed and can
945
+ * veto it. This is told that it is happening, cannot influence it, and is therefore what a
946
+ * transcript wants.
947
+ */
948
+ onToolStart(cb: (e: { session: string; turn: string; call: ToolCall }) => void): Disposable;
949
+ onToolEnd(
950
+ cb: (e: { session: string; turn: string; call: ToolCall; result: ToolResult }) => void,
951
+ ): Disposable;
952
+ }
953
+
954
+ export interface ToolApi {
955
+ /** Lands in the same namespace and shape as a built-in or MCP tool. */
956
+ register(
957
+ def: { name: string; description: string; inputSchema: Record<string, unknown> },
958
+ handler: (input: unknown) => ToolResult | Promise<ToolResult>,
959
+ ): Promise<Disposable>;
960
+ list(): Promise<ToolDef[]>;
961
+ }
962
+
963
+ export interface HookApi {
964
+ /**
965
+ * Register a hook.
966
+ *
967
+ * `blocking: false` (the default) is a pure observer whose return value is ignored — that is what
968
+ * stops an audit plugin from wedging the agent loop. A blocking hook is awaited and may veto;
969
+ * **a blocking hook that does not answer in time is treated as a veto**, so a policy plugin fails
970
+ * closed.
971
+ */
972
+ register(
973
+ hook: HookName,
974
+ fn: (payload: HookPayload) => HookOutcome | Promise<HookOutcome>,
975
+ opts?: { blocking?: boolean; timeoutMs?: number },
976
+ ): Promise<Disposable>;
977
+ }
978
+
979
+ export interface ProviderApi {
980
+ /**
981
+ * Register a model provider implemented in this plugin.
982
+ *
983
+ * Supporting another vendor is a plugin, not a core change. Because a stream cannot cross the
984
+ * RPC boundary as a return value, `handler` receives an `emit` callback and pushes events until
985
+ * it emits `message_stop`.
986
+ */
987
+ register(
988
+ driver: string,
989
+ instances: InstanceConfig[],
990
+ handler: (req: TurnRequest, emit: (e: ProviderEvent) => void, signal: { cancelled: boolean }) => void | Promise<void>,
991
+ opts?: {
992
+ /**
993
+ * This driver runs its own agent loop — it has its own tools, and calls them itself.
994
+ *
995
+ * neosh then sends it no tool list, does not execute the calls in its stream, and records the
996
+ * conversation in the shape that actually happened. Leaving it off for such a driver makes
997
+ * the host run every tool call a second time.
998
+ */
999
+ agentLoop?: boolean;
1000
+ },
1001
+ ): Promise<Disposable>;
1002
+ }
1003
+
1004
+ /**
1005
+ * Version control.
1006
+ *
1007
+ * The plugin runtime has no process access, so `git` is a host capability rather than something a
1008
+ * plugin shells out to. One implementation means a sidebar, a branch picker and a commit UI agree
1009
+ * about what "dirty" means instead of each parsing porcelain slightly differently.
1010
+ *
1011
+ * Reads are free. Writes go through the permission layer as `exec` of `git <verb>`, so
1012
+ * `permissions.allow_commands = ["git"]` covers them and a policy hook watching exec sees them.
1013
+ *
1014
+ * Every call rejects with `not_found` when neosh was started outside a repository — check once with
1015
+ * `status()` rather than guarding each call.
1016
+ */
1017
+ export interface GitApi {
1018
+ /** The working tree's state. `cwd` is any checkout; omitted, the one this conversation is in. */
1019
+ status(opts?: { cwd?: string }): Promise<RepoStatus>;
1020
+ /** Local branches, most recently committed first. */
1021
+ branches(opts?: { includeRemote?: boolean; cwd?: string }): Promise<BranchInfo[]>;
1022
+ /**
1023
+ * Every checkout of the repository.
1024
+ *
1025
+ * `cwd` picks which repository to ask about; without it the answer is the one this conversation
1026
+ * is in, which is what a status bar or a branch picker means. A panel means the other thing —
1027
+ * it lists several projects at once, and the row under the cursor is not always the conversation
1028
+ * you are in.
1029
+ */
1030
+ worktrees(opts?: { cwd?: string }): Promise<WorktreeInfo[]>;
1031
+ log(limit?: number): Promise<CommitInfo[]>;
1032
+ /** The patch. Pass `stat` for `--stat`, which is what a prompt wants. */
1033
+ diff(target?: DiffTarget, opts?: { stat?: boolean }): Promise<string>;
1034
+ /** What this branch would merge into: `origin/HEAD`, else `main`/`master`. */
1035
+ defaultBranch(): Promise<string | null>;
1036
+ createBranch(name: string, opts?: { from?: string }): Promise<void>;
1037
+ /**
1038
+ * Move a branch to another name — `git branch -m`.
1039
+ *
1040
+ * One ref write. The working tree is untouched, so this is safe on a branch that is checked out
1041
+ * and safe while an agent is editing files against it — which is the case it exists for: naming
1042
+ * a worktree's branch from the first message, once there is a message to name it from.
1043
+ *
1044
+ * `cwd` is the checkout the branch belongs to, and you almost always want it: the worktree being
1045
+ * renamed is very often not the one the active conversation is standing in.
1046
+ *
1047
+ * Fails if `next` is taken — a generated name does not get to overwrite somebody's branch. Ask
1048
+ * `branches()` and pick a free one.
1049
+ */
1050
+ renameBranch(name: string, next: string, opts?: { cwd?: string }): Promise<void>;
1051
+ checkout(rev: string): Promise<void>;
1052
+ /** Empty `paths` stages everything, like `git add .` from the repository root. */
1053
+ stage(paths?: string[]): Promise<void>;
1054
+ unstage(paths?: string[]): Promise<void>;
1055
+ commit(message: string): Promise<CommitInfo>;
1056
+ /**
1057
+ * `git pull`, answering with git's own summary — "Already up to date.", the fast-forward range —
1058
+ * because those are different answers and a caller showing neither is a caller nobody trusts.
1059
+ * `cwd` picks the repository, as everywhere; absent means the conversation's own.
1060
+ */
1061
+ pull(opts?: { cwd?: string }): Promise<string>;
1062
+ addWorktree(
1063
+ path: string,
1064
+ branch: string,
1065
+ opts?: { create?: boolean; cwd?: string },
1066
+ ): Promise<void>;
1067
+ /**
1068
+ * `cwd` names the repository the worktree belongs to. `git worktree remove` must run from a
1069
+ * checkout other than the one being removed, and the active conversation may be standing in
1070
+ * exactly that one.
1071
+ */
1072
+ removeWorktree(path: string, opts?: { force?: boolean; cwd?: string }): Promise<void>;
1073
+ }
1074
+
1075
+ /**
1076
+ * One-shot generation: a prompt through a model, outside the conversation.
1077
+ *
1078
+ * Branch names, commit messages, thread titles and PR descriptions are all this call. It is
1079
+ * deliberately *not* `agent.send` — nothing here enters session history, so asking for a commit
1080
+ * message does not change what the agent believes it was asked to do.
1081
+ *
1082
+ * The model is `gen.model` when set, else the conversation's own. Point that option at something
1083
+ * cheap; naming a branch does not need a frontier model.
1084
+ */
1085
+ export interface GenApi {
1086
+ complete(prompt: string, opts?: { system?: string; selection?: ModelSelection }): Promise<string>;
1087
+ /**
1088
+ * Same, but parse the answer as JSON.
1089
+ *
1090
+ * The host tolerates what models actually return — code fences, a "Sure!" preamble — so callers
1091
+ * do not each reimplement that. Rejects if there is no JSON in the response at all.
1092
+ */
1093
+ json<T = unknown>(prompt: string, opts?: { system?: string; selection?: ModelSelection }): Promise<T>;
1094
+ }
1095
+
1096
+ /**
1097
+ * Conversations.
1098
+ *
1099
+ * A workspace is not one conversation: a branch you are on, a review you are half-way through, a
1100
+ * question from yesterday. These are the verbs a thread list needs; what it looks like is yours.
1101
+ *
1102
+ * Conversations are saved to the state directory as you go and restored at startup, so switching
1103
+ * away from one is not a way to lose it.
1104
+ */
1105
+ /**
1106
+ * The terminals looking at this workspace.
1107
+ *
1108
+ * A workspace can have several and they are not copies of each other: each has its own
1109
+ * conversation on screen, its own scroll position, its own composer and its own panels. What they
1110
+ * share is the work — the conversations themselves, the turns running in them, everything a plugin
1111
+ * registered.
1112
+ *
1113
+ * A plugin that owns a **dock** has to open one panel per view, and `onOpen` is when. A plugin that
1114
+ * only opens floats in answer to a key needs none of this: the host puts a float in the terminal
1115
+ * whose key press opened it.
1116
+ */
1117
+ export interface ViewApi {
1118
+ /** Every terminal, and what each is looking at. */
1119
+ list(): Promise<ViewInfo[]>;
1120
+ /** The one being served — the terminal whose key press is running. */
1121
+ current(): Promise<ViewInfo | null>;
1122
+ /**
1123
+ * The whole `neosh` namespace, bound to one terminal.
1124
+ *
1125
+ * Every call on it is the call it always was, except that a window opened through it lands
1126
+ * there. The same object a command handler is given as its third argument.
1127
+ */
1128
+ at(view: ViewId): Neosh;
1129
+ /**
1130
+ * A terminal arrived. Open your panel in it.
1131
+ *
1132
+ * Fired for every view that already exists when the plugin loads, too, so a plugin does not have
1133
+ * to decide whether it was here first.
1134
+ */
1135
+ onOpen(cb: (view: ViewId) => void): Disposable;
1136
+ /** A terminal went away. Its windows are already closed; let go of what you were keeping. */
1137
+ onClose(cb: (view: ViewId) => void): Disposable;
1138
+ }
1139
+
1140
+ export interface SessionApi {
1141
+ /**
1142
+ * Most recently active first, with exactly one flagged `is_active`.
1143
+ *
1144
+ * Archived conversations are left out unless you ask for them. That is what archiving is for, and
1145
+ * a list that included them by default would make every caller responsible for remembering.
1146
+ */
1147
+ list(opts?: { includeArchived?: boolean }): Promise<SessionInfo[]>;
1148
+ current(): Promise<SessionInfo>;
1149
+ /**
1150
+ * Start one. It inherits the model and system prompt you are using — starting a conversation
1151
+ * should not silently change what you are talking to.
1152
+ *
1153
+ * `cwd` opens it against another checkout, which is how a second project becomes visible.
1154
+ */
1155
+ create(opts?: { cwd?: string; title?: string; activate?: boolean }): Promise<SessionInfo>;
1156
+ /**
1157
+ * Look at another conversation.
1158
+ *
1159
+ * Never refused, including while a turn is running. A turn belongs to its conversation and keeps
1160
+ * streaming into it; what you see is rebuilt from whichever one you switched to, and switching
1161
+ * back puts you in the middle of the answer where you left it.
1162
+ */
1163
+ switch(session: SessionId): Promise<void>;
1164
+ /**
1165
+ * Close one. Closing the active conversation moves to the most recently used other; closing the
1166
+ * last one is an error, because there is always somewhere for the next thing you type.
1167
+ *
1168
+ * A turn running in it is cancelled: there is about to be nowhere to put its answer.
1169
+ *
1170
+ * A conversation this workspace never loaded — one past the restore cap, which {@link stored}
1171
+ * is how you find — is deleted from disk just the same. One verb, whether the store is holding it
1172
+ * or only the directory is.
1173
+ */
1174
+ close(session: SessionId): Promise<void>;
1175
+ /** Pass `null` to clear a title and go back to the first-message label. */
1176
+ rename(session: SessionId, title: string | null): Promise<void>;
1177
+ /**
1178
+ * Put a conversation away, or bring it back.
1179
+ *
1180
+ * Not `close`: nothing is deleted, every message survives, and `list({ includeArchived: true })`
1181
+ * still finds it. Archiving the active conversation moves you to the most recently used other
1182
+ * one — or to a fresh empty one if there is no other.
1183
+ */
1184
+ archive(session: SessionId, archived?: boolean): Promise<void>;
1185
+ /**
1186
+ * Every conversation *on disk*, loaded or not — newest first.
1187
+ *
1188
+ * {@link list} answers about the workspace's store, and the store is a window rather than the
1189
+ * whole directory: a workspace restores the most recent few hundred conversations and leaves the
1190
+ * rest as files. Those files are in no list, which is fine until something has to say what has
1191
+ * accumulated or take it away — so an archive that only ever asked `list` would report a number
1192
+ * that was not the number, and empty itself down to a directory that was still full.
1193
+ *
1194
+ * The rows are ordinary {@link SessionInfo}s, so one renderer draws both. Which of them this
1195
+ * workspace is actually holding is the difference between the two calls, and a caller that cares
1196
+ * asks both and compares ids.
1197
+ *
1198
+ * It reads and parses every file, so it is answered off the host loop and is not something to put
1199
+ * on a redraw.
1200
+ */
1201
+ stored(): Promise<SessionInfo[]>;
1202
+ /** The conversation itself, for a transcript view that renders rather than replays. */
1203
+ messages(session?: SessionId): Promise<Message[]>;
1204
+ /**
1205
+ * Fires whenever a terminal is looking at a different conversation — switched, created, closed.
1206
+ *
1207
+ * `view` is which terminal moved. A workspace can have several and each is somewhere, so
1208
+ * "the active conversation" is a question with as many answers as there are screens.
1209
+ */
1210
+ onChange(cb: (e: { session: SessionId; view: ViewId }) => void): Disposable;
1211
+ }
1212
+
1213
+ /**
1214
+ * The status line — the composer footer.
1215
+ *
1216
+ * The host owns the strip: one line, always visible, never scrolled. Plugins own what is in it.
1217
+ * That split is what lets the model switcher and the git plugin each put something there without
1218
+ * either knowing the other exists.
1219
+ *
1220
+ * Setting the same key again replaces that segment, so updating a meter every tick does not need a
1221
+ * clear first and cannot leave two. Segments are namespaced per plugin, so two plugins choosing
1222
+ * `"model"` cannot collide, and unloading a plugin takes its segments with it.
1223
+ */
1224
+ /**
1225
+ * The shortcut row under the composer.
1226
+ *
1227
+ * Whoever owns a feature owns its hint, which is the only arrangement that stays true: the row is
1228
+ * built from what is actually registered right now, so a plugin that is switched off takes its
1229
+ * shortcut with it rather than leaving a key advertised that no longer does anything.
1230
+ *
1231
+ * Write the key the way the user would press it — `^P`, `⇧⏎`, `^Z` — not the way a keymap spells
1232
+ * it. Hints are dropped from the end when the terminal is too narrow, so put the one you would
1233
+ * most want seen at the lowest priority.
1234
+ */
1235
+ export interface HintApi {
1236
+ set(key: string, hint: { keys: string; label: string; priority?: number }): Promise<void>;
1237
+ clear(key: string): Promise<void>;
1238
+ }
1239
+
1240
+ export interface PermissionApi {
1241
+ mode(): Promise<PermissionMode>;
1242
+ setMode(mode: PermissionMode): Promise<PermissionMode>;
1243
+ }
1244
+
1245
+ export interface StatusApi {
1246
+ /**
1247
+ * `keys` is drawn immediately after `text`, dimmed — the key that changes this thing, beside the
1248
+ * thing it changes. Write it the way the user would press it (`^P`, `^Z`), not the way a keymap
1249
+ * spells it.
1250
+ *
1251
+ * `short` is the same thing said in less room, and the strip asks for it before it drops your
1252
+ * segment. Give one to anything wide: without it a segment costs its full width or nothing, so
1253
+ * the widest thing in the strip is the first thing to vanish on a narrow terminal — which is
1254
+ * usually the thing worth the most. It is not a truncation and the host will not invent one; it
1255
+ * is the fact with a part left out, and only you know which part that is.
1256
+ *
1257
+ * `priority` is where the segment sits *and* what the strip gives up first, in reverse.
1258
+ */
1259
+ set(
1260
+ key: string,
1261
+ segment: {
1262
+ text: string;
1263
+ /** The same fact in fewer columns, used before this segment is dropped for want of room. */
1264
+ short?: string;
1265
+ keys?: string;
1266
+ hl?: string;
1267
+ align?: StatusAlign;
1268
+ priority?: number;
1269
+ },
1270
+ ): Promise<void>;
1271
+ clear(key: string): Promise<void>;
1272
+ }
1273
+
1274
+ export interface OptionApi {
1275
+ /**
1276
+ * Declare an option this plugin owns.
1277
+ *
1278
+ * neosh's own settings are declared through this same call at startup, so there is nothing the
1279
+ * built-in options can do that yours cannot — including being set from `config.toml` and shown
1280
+ * by a settings UI that has never heard of your plugin.
1281
+ *
1282
+ * Names are dot-separated lowercase. Namespace yours under your plugin id.
1283
+ */
1284
+ declare(spec: OptionSpec): Promise<Disposable>;
1285
+ /** Typed read. Rejects if the option was never declared. */
1286
+ get<T = OptionValue>(name: string): Promise<T>;
1287
+ /** Full entry, including type, default and owner — or `null` if undeclared. */
1288
+ entry(name: string): Promise<OptionEntry | null>;
1289
+ /**
1290
+ * Set a declared option. Rejects on an unknown name or a value that does not match the declared
1291
+ * type, rather than quietly doing nothing.
1292
+ */
1293
+ set(name: string, value: OptionValue): Promise<void>;
1294
+ /** Restore the declared default. */
1295
+ reset(name: string): Promise<void>;
1296
+ all(): Promise<OptionEntry[]>;
1297
+ /** Fires for every option, not just your own: a setting is shared state. */
1298
+ onChange(cb: (e: { name: string; value: OptionValue }) => void): Disposable;
1299
+ }
1300
+
1301
+ /**
1302
+ * What your plugin remembers between runs.
1303
+ *
1304
+ * Small facts a panel needs so it is still arranged the way you left it: which projects are pinned,
1305
+ * what order you dragged them into, which sections are folded. Deliberately *not* options — an
1306
+ * option is configuration the user writes, and a plugin that rewrites someone's config file because
1307
+ * they pressed a key is one they stop trusting with the file.
1308
+ *
1309
+ * Keyed by your plugin id, which the host knows and you cannot forge, so no other plugin can read
1310
+ * or clobber what you store. It is plain JSON on disk: **nothing secret belongs here.**
1311
+ */
1312
+ export interface StateApi {
1313
+ /**
1314
+ * `null` when nothing was stored — indistinguishable from having stored `null`, which no caller
1315
+ * has ever needed to tell apart.
1316
+ */
1317
+ get<T = unknown>(key: string): Promise<T | null>;
1318
+ set(key: string, value: unknown): Promise<void>;
1319
+ remove(key: string): Promise<void>;
1320
+ }
1321
+
1322
+ /**
1323
+ * What *everybody* remembers about a project or a conversation.
1324
+ *
1325
+ * The counterpart to `state`, and the difference is who may look. State is keyed by your plugin and
1326
+ * private, which is right for your fold set and wrong for "this project is a favourite" — with
1327
+ * state, a sidebar of somebody's own starts with no favourites and pinning one in ours is invisible
1328
+ * to it. A var is scoped to the thing it describes, and anyone may read or write it.
1329
+ *
1330
+ * Namespace your keys (`sidebar.favorite`, `acme.colour`) for the reason options are namespaced:
1331
+ * nothing stops two plugins choosing `colour`, and a prefix is what makes them not want to.
1332
+ *
1333
+ * Persisted, shared, and plain JSON on disk: **nothing secret belongs here.** A conversation's vars
1334
+ * are deleted with it; a project's outlive every conversation in it, because a project is a
1335
+ * directory and the directory is still there.
1336
+ */
1337
+ export interface VarApi {
1338
+ get<T = unknown>(scope: VarScope, key: string): Promise<T | null>;
1339
+ set(scope: VarScope, key: string, value: unknown): Promise<void>;
1340
+ remove(scope: VarScope, key: string): Promise<void>;
1341
+ /** Everything on one scope, in one round trip. What a panel reads per project rather than per key. */
1342
+ all(scope: VarScope): Promise<Record<string, unknown>>;
1343
+ /**
1344
+ * A var changed, whoever changed it — including you. The signal to redraw on.
1345
+ *
1346
+ * `value` is `undefined` when it was removed.
1347
+ */
1348
+ onChange(
1349
+ cb: (e: { scope: VarScope; key: string; value: unknown }) => void,
1350
+ ): Disposable;
1351
+ }
1352
+
1353
+ /** Sugar for the two scopes anything with a panel spends its time in. */
1354
+ export function projectScope(cwd: string): VarScope {
1355
+ return { scope: "project", cwd };
1356
+ }
1357
+
1358
+ export function sessionScope(session: SessionId): VarScope {
1359
+ return { scope: "session", session };
1360
+ }
1361
+
1362
+ /**
1363
+ * How your plugin puts something in somebody else's panel.
1364
+ *
1365
+ * A *point* is a name a plugin agrees to read — `sidebar.section`, `project.action`, `palette.entry`
1366
+ * — and a contribution is a JSON item on it, conventionally carrying the name of a command to run.
1367
+ * The indirection is the whole trick: the sidebar renders rows it did not write and invokes commands
1368
+ * it has never heard of, and neither side imports the other.
1369
+ *
1370
+ * Data rather than a callback on purpose. A contribution can be listed by the palette, described in
1371
+ * `^Z` and disabled by the user, none of which is possible for a function held inside your closure.
1372
+ *
1373
+ * Your contributions are withdrawn when your plugin unloads, so `plugins.disabled` takes your rows
1374
+ * with it and there is no way to leave a row behind pointing at a command that no longer exists.
1375
+ */
1376
+ export interface ExtensionApi {
1377
+ /**
1378
+ * Put an item on a point, replacing whatever you had there under the same `id`.
1379
+ *
1380
+ * Higher `priority` sorts first; ties break on plugin and id, so the order is stable across
1381
+ * restarts rather than being whatever order plugins happened to activate in.
1382
+ */
1383
+ contribute(
1384
+ point: string,
1385
+ id: string,
1386
+ item: unknown,
1387
+ opts?: { priority?: number },
1388
+ ): Promise<Disposable>;
1389
+ remove(point: string, id: string): Promise<void>;
1390
+ /** Everything on a point, in order, whoever contributed it. What a panel calls when it draws. */
1391
+ list<T = unknown>(point: string): Promise<Array<Contribution & { item: T }>>;
1392
+ /**
1393
+ * Somebody added to or withdrew from a point. Redraw.
1394
+ *
1395
+ * Without this a plugin that loads after your panel has drawn contributes rows nobody sees until
1396
+ * the next unrelated refresh — which on a quiet workspace is several seconds of a panel missing
1397
+ * half of itself.
1398
+ */
1399
+ onChange(cb: (e: { point: string }) => void): Disposable;
1400
+ /**
1401
+ * Every point anybody reads or writes: who declared it (`[provides] points` in their manifest)
1402
+ * and who has something on it. A point with contributors and no readers is almost always a
1403
+ * typo, and neosh says so at startup.
1404
+ */
1405
+ points(): Promise<PointInfo[]>;
1406
+ /**
1407
+ * Every plugin the workspace knows about, with its manifest and what became of it — `loaded`,
1408
+ * `held` until one of its activation triggers, or `failed` with the reason. The list a plugins
1409
+ * panel is drawn from.
1410
+ */
1411
+ plugins(): Promise<PluginInfo[]>;
1412
+ }
1413
+
1414
+ /**
1415
+ * Saying that something happened, to whoever cares.
1416
+ *
1417
+ * Plugin-defined and broadcast — Neovim's `User` autocmd. Nothing validates the names; namespace
1418
+ * them like everything else.
1419
+ *
1420
+ * Fire and forget by construction. There is no reply and no way to be blocked, because an emitter
1421
+ * that could be is an emitter every listener is on the critical path of, which is how one slow
1422
+ * plugin wedges a panel. When you need an answer, register a command or read a contribution point.
1423
+ *
1424
+ * The host emits one of its own: **`neosh.ready`**, `from: "neosh"`, once every plugin has loaded.
1425
+ * Your `activate` returning is not that moment — the others are still loading alongside you, so a
1426
+ * command you would call is a name nothing answers to yet, a contribution point somebody else
1427
+ * fills is still empty, and a model a plugin registers is not selectable. Anything that depends on
1428
+ * the *rest* of the workspace goes in a `neosh.ready` listener rather than at the end of
1429
+ * `activate`. It is said again after `^R`, which is the same fact being true a second time.
1430
+ */
1431
+ /** What the host says about a window on the bus: `neosh.win.enter`, `.leave`, `.open`. */
1432
+ export interface WindowEvent {
1433
+ win: WindowId;
1434
+ buf: BufferId | null;
1435
+ /** The buffer's kind — the field to filter on. */
1436
+ kind: string | null;
1437
+ }
1438
+
1439
+ export interface EventApi {
1440
+ emit(name: string, data?: unknown): Promise<void>;
1441
+ /**
1442
+ * Listen. `from` is the plugin that emitted it, stamped by the host — one of the few things in a
1443
+ * plugin message nobody can forge.
1444
+ *
1445
+ * You hear your own events too. Filtering on `from === ctx.plugin` is how you skip them, and it
1446
+ * is deliberately your choice: a panel that reacts to its own writes uniformly has one code path
1447
+ * instead of two.
1448
+ */
1449
+ /**
1450
+ * Hear one event by name. `kind` keeps only events whose `data.kind` matches — the way to
1451
+ * listen for `neosh.win.enter` on the sidebar and nothing else.
1452
+ *
1453
+ * The host's own, `from: "neosh"`: `neosh.ready`; `neosh.win.enter` / `neosh.win.leave` /
1454
+ * `neosh.win.open` (a {@link WindowEvent}); `neosh.win.close` (`{ win }`); `neosh.cursor`
1455
+ * (`{ win, row, col }`); `neosh.mode` (`{ mode }`); `neosh.viewport` (`{ win, width, height }`).
1456
+ * Neovim's autocmds, on the same bus a plugin's own events travel.
1457
+ */
1458
+ on(name: string, cb: (e: { data: unknown; from: string }) => void, opts?: { kind?: string }): Disposable;
1459
+ /** Every event, whatever it is called. For a logger or a debugger, rarely for a feature. */
1460
+ onAny(cb: (e: { name: string; data: unknown; from: string }) => void): Disposable;
1461
+ }
1462
+
1463
+ /**
1464
+ * The other computers.
1465
+ *
1466
+ * ASCP — see `docs/ascp/SPEC.md`. Everything here is a *description* of what another machine is
1467
+ * running, or a *request* to it, and never a handle: an agent belongs to the node it was started on
1468
+ * for the whole of its life, because its files, its shell and its credentials are there.
1469
+ *
1470
+ * Empty and harmless when no swarm is configured, which is the default. A plugin that draws remote
1471
+ * agents needs no special case for the single-machine setup — `nodes()` is simply empty.
1472
+ */
1473
+ /**
1474
+ * What the plan has left, and what the week went on.
1475
+ *
1476
+ * Two questions that look alike and are not. {@link list} is *now* — an opaque fraction of an
1477
+ * allowance the vendor enforces, which is the number that decides whether to start something.
1478
+ * {@link usage} is *history* — tokens and their money-equivalent, read out of the vendor CLIs' own
1479
+ * transcripts, which is the number that explains where the week went. Neither converts into the
1480
+ * other, and a chart that put them on one axis would be inventing an exchange rate.
1481
+ *
1482
+ * Everything here is data. Nothing in this API draws, which is what makes the bundled strip
1483
+ * replaceable by a panel of your own that reads exactly the same calls.
1484
+ */
1485
+ export interface QuotaApi {
1486
+ /**
1487
+ * The latest snapshot for every instance that has one, freshest observation first.
1488
+ *
1489
+ * Answers from what the workspace kept rather than by asking a vendor, so it costs nothing and is
1490
+ * safe to call on every redraw. `observed_at` is how stale each one is — draw it, because a
1491
+ * percentage with no age on it is one people will trust for longer than they should.
1492
+ */
1493
+ list(): Promise<QuotaSnapshot[]>;
1494
+ /**
1495
+ * Ask the vendor now, for one instance or for every instance that can be asked.
1496
+ *
1497
+ * Resolves as soon as the request is *made*. A poll is a network round trip or a process spawn,
1498
+ * so a panel that awaited the answer would be a panel that opens late; the answer arrives at
1499
+ * {@link onChange}, the same way an unprompted one does, so there is one code path rather than
1500
+ * two.
1501
+ */
1502
+ refresh(instance?: string): Promise<void>;
1503
+ /**
1504
+ * Publish a snapshot for an instance your own driver serves.
1505
+ *
1506
+ * The other half of {@link ProviderApi.register}: a provider written as a plugin knows its
1507
+ * vendor's allowance and nothing in the workspace does. Reported this way it is kept, sampled,
1508
+ * broadcast and drawn exactly like a built-in one. Rejects for an instance your plugin did not
1509
+ * register a driver for — a figure anybody could spoof is not one this should draw as fact.
1510
+ */
1511
+ report(snapshot: QuotaSnapshot): Promise<void>;
1512
+ /**
1513
+ * Every percentage this workspace has seen in a span, so a gauge can be a line.
1514
+ *
1515
+ * `since` and `until` are unix **seconds**, `until` exclusive. Sampled on change, so the points
1516
+ * are unevenly spaced and the gaps are real: a stretch with no samples is a stretch where the
1517
+ * machine was off, and drawing a straight line across it invents usage that did not happen.
1518
+ */
1519
+ history(opts: { since: number; until: number; instance?: string }): Promise<QuotaSample[]>;
1520
+ /**
1521
+ * Tokens and their money-equivalent over a span, from the vendor CLIs' transcripts.
1522
+ *
1523
+ * Not from this workspace's conversations: a turn you ran in `claude` directly spent the same
1524
+ * allowance, and a history that could not see it would answer a different question. Reads
1525
+ * thousands of files, so this is a call a panel makes when it opens — never one it makes on a
1526
+ * redraw.
1527
+ *
1528
+ * `cost_usd` is what those tokens would cost at API rates. It is not money spent: a subscription
1529
+ * bills separately. Check `fully_priced` before putting a currency symbol in a heading.
1530
+ */
1531
+ usage(opts: {
1532
+ since: number;
1533
+ until: number;
1534
+ resolution: UsageResolution;
1535
+ /** IANA zone to bucket days in. Defaults to this machine's. */
1536
+ timeZone?: string;
1537
+ }): Promise<UsageHistory>;
1538
+ /**
1539
+ * Any account's allowance changed, whatever moved it: a driver reporting mid-turn, a poll
1540
+ * landing, a plugin publishing its own.
1541
+ *
1542
+ * One thing to listen to instead of knowing which vendors exist — which is what lets a strip draw
1543
+ * a provider that shipped after it did.
1544
+ */
1545
+ onChange(cb: (snapshot: QuotaSnapshot) => void): Disposable;
1546
+ }
1547
+
1548
+ export interface SwarmApi {
1549
+ /** This machine, or `null` when the swarm is not running. */
1550
+ self(): Promise<NodeInfo | null>;
1551
+ /** Every node known, up or down. A node that has gone keeps its agents, marked `up: false`. */
1552
+ nodes(): Promise<SwarmNode[]>;
1553
+ /** Every agent on every reachable node, flattened, each carrying the node it belongs to. */
1554
+ agents(): Promise<SwarmAgent[]>;
1555
+ /**
1556
+ * Which *other* machines have this project.
1557
+ *
1558
+ * The answer to "is this project on more than this computer". Empty when it is only here. Keyed
1559
+ * on {@link ProjectKey}, which is the normalised git remote — the one thing about a checkout that
1560
+ * is the same on every machine that has it.
1561
+ */
1562
+ hostsOf(project: ProjectKey): Promise<string[]>;
1563
+ /**
1564
+ * Ask a node to do something with one of its agents.
1565
+ *
1566
+ * Settles when that machine answers — every ASCP command is answered exactly once — and rejects
1567
+ * with the owner's reason when it says no. A node may refuse anything; `NodeCapabilities` on its
1568
+ * {@link SwarmNode} says in advance what it is likely to accept, so a menu can grey out a verb
1569
+ * rather than offering one that will bounce.
1570
+ */
1571
+ command(node: NodeId, session: string, command: AgentCommand): Promise<void>;
1572
+ /**
1573
+ * Watch a remote conversation: its history now, then everything as it happens, delivered to
1574
+ * {@link onStream}.
1575
+ *
1576
+ * Drop it when you stop looking. A subscription is the difference between a quiet swarm and one
1577
+ * where every machine sends every token to every other one.
1578
+ */
1579
+ subscribe(node: NodeId, session: string): Promise<void>;
1580
+ unsubscribe(node: NodeId, session: string): Promise<void>;
1581
+ /**
1582
+ * Ask what machine is at an address, without joining it.
1583
+ *
1584
+ * The first half of pairing. A node presents its identity to anything that connects — as an SSH
1585
+ * server presents a host key — so what comes back was *proven*, not claimed, which is what makes
1586
+ * it safe to show somebody and ask. Rejects, with the address in the message, when nothing
1587
+ * answers.
1588
+ */
1589
+ probe(addr: string): Promise<NodeInfo>;
1590
+ /**
1591
+ * Authorise a machine and start connecting to it.
1592
+ *
1593
+ * Immediate: no restart. Written to the state directory rather than to `config.toml`, because an
1594
+ * editor that edits your config file because you pressed a key is one you stop trusting with it.
1595
+ */
1596
+ pair(node: NodeId, opts?: { name?: string; addr?: string }): Promise<void>;
1597
+ /** Withdraw authorisation and stop connecting. Refuses for a machine your config declared. */
1598
+ unpair(node: NodeId): Promise<void>;
1599
+ /**
1600
+ * Dial a down peer again now, rather than waiting out its retry delay.
1601
+ *
1602
+ * Also what lifts a {@link disconnect}: for a peer that dials *this* machine, being willing to
1603
+ * answer again is the whole of what "reconnect" can mean.
1604
+ */
1605
+ reconnect(node: NodeId): Promise<void>;
1606
+ /**
1607
+ * Close the connection to a peer and stop dialling it, keeping the pairing.
1608
+ *
1609
+ * Holds until {@link reconnect} or a restart — the peer stays authorised, so this is "leave it
1610
+ * alone for now", and {@link unpair} is the stronger verb. Its `SwarmNode` row goes
1611
+ * `link: down`, with its agents still described.
1612
+ */
1613
+ disconnect(node: NodeId): Promise<void>;
1614
+ /**
1615
+ * Machines that proved who they are and have not been paired with.
1616
+ *
1617
+ * `dialled` says which question to ask: `true` is "this is what is at that address — add it?",
1618
+ * `false` is "this machine wants to join — allow it?". Same button, different question.
1619
+ */
1620
+ strangers(): Promise<SwarmStranger[]>;
1621
+ /** A node joined, left, changed what it is running, or asked to join. Redraw. */
1622
+ onChange(cb: () => void): Disposable;
1623
+ onStream(
1624
+ cb: (e: { node: NodeId; session: string; event: StreamEvent }) => void,
1625
+ ): Disposable;
1626
+ }
1627
+
1628
+ export interface RuntimePathApi {
1629
+ /**
1630
+ * Add a directory to search for plugins.
1631
+ *
1632
+ * Only effective before discovery runs, which means: from `init.ts`. That is the point — your
1633
+ * config is the thing that decides what else loads, exactly as `init.lua` is.
1634
+ */
1635
+ add(path: string): Promise<void>;
1636
+ list(): Promise<string[]>;
1637
+ }
1638
+
1639
+ /**
1640
+ * Completing a typed path.
1641
+ *
1642
+ * The runtime has no filesystem — deliberately — and a path field without completion is a path
1643
+ * field you type wrong. Narrow on purpose: directory names, one level, never file contents and
1644
+ * never a recursive walk.
1645
+ */
1646
+ export interface PathApi {
1647
+ /**
1648
+ * Directories whose path begins with `prefix`, each with a trailing `/` so the answer can go
1649
+ * straight back into the field and be completed again.
1650
+ *
1651
+ * `~` expands against the home directory. A prefix with no `/` completes against the active
1652
+ * conversation's directory, which is what someone typing `src` means.
1653
+ */
1654
+ complete(prefix: string): Promise<string[]>;
1655
+ }
1656
+
1657
+ export interface TimerApi {
1658
+ /**
1659
+ * Run `fn` once, no sooner than `ms` from now.
1660
+ *
1661
+ * The returned `Disposable` cancels it, and **every timer is cancelled when your plugin
1662
+ * unloads** — which the global `setTimeout` cannot promise, because it has no idea who called it.
1663
+ * Prefer this one; reach for the global only when porting code that expects it.
1664
+ */
1665
+ after(ms: number, fn: () => void): Disposable;
1666
+ /** Run `fn` repeatedly until disposed. */
1667
+ every(ms: number, fn: () => void): Disposable;
1668
+ /**
1669
+ * Coalesce bursts: calling the returned function repeatedly runs `fn` once, `ms` after the last
1670
+ * call. The shape almost every "re-render after the tokens stop" wants.
1671
+ */
1672
+ debounce<A extends unknown[]>(ms: number, fn: (...args: A) => void): ((...args: A) => void) & Disposable;
1673
+ }
1674
+
1675
+ export interface Logger {
1676
+ info(msg: string): void;
1677
+ warn(msg: string): void;
1678
+ error(msg: string): void;
1679
+ }
1680
+
1681
+ // ---------------------------------------------------------------------------
1682
+ // Implementation
1683
+ // ---------------------------------------------------------------------------
1684
+
1685
+ interface Registered {
1686
+ commands: Map<string, (args: string[], key?: KeyContext, here?: Neosh) => unknown>;
1687
+ tools: Map<string, (input: unknown) => ToolResult | Promise<ToolResult>>;
1688
+ hooks: Map<HookName, (p: HookPayload) => HookOutcome | Promise<HookOutcome>>;
1689
+ providers: Map<string, (req: TurnRequest, emit: (e: ProviderEvent) => void, signal: { cancelled: boolean }) => unknown>;
1690
+ bufferListeners: Map<number, Array<(e: { buf: BufferId; start: number; oldEnd: number; newEnd: number }) => void>>;
1691
+ optionListeners: Array<(e: { name: string; value: OptionValue }) => void>;
1692
+ /// The protocol version this plugin was handed, kept so a namespace can be rebuilt for a
1693
+ /// view long after `__createContext` returned.
1694
+ version: number;
1695
+ sessionListeners: Array<(e: { session: SessionId; view: ViewId }) => void>;
1696
+ viewOpenListeners: Array<(view: ViewId) => void>;
1697
+ viewCloseListeners: Array<(view: ViewId) => void>;
1698
+ selectionListeners: Array<(e: { selection: ModelSelection }) => void>;
1699
+ composerListeners: Array<(e: { text: string }) => void>;
1700
+ activityListeners: Array<(e: { session: SessionId; turn: string; activity: Activity }) => void>;
1701
+ varListeners: Array<(e: { scope: VarScope; key: string; value: unknown }) => void>;
1702
+ swarmListeners: Array<() => void>;
1703
+ quotaListeners: Array<(snapshot: QuotaSnapshot) => void>;
1704
+ swarmStreamListeners: Array<
1705
+ (e: { node: NodeId; session: string; event: StreamEvent }) => void
1706
+ >;
1707
+ contributionListeners: Array<(e: { point: string }) => void>;
1708
+ highlightListeners: Array<(e: { names: string[] }) => void>;
1709
+ focusListeners: Array<(e: { win: WindowId | null }) => void>;
1710
+ viewListeners: Array<() => void>;
1711
+ shutdownListeners: Array<() => void>;
1712
+ /**
1713
+ * Listeners by event name, plus `null` for the ones that asked for everything.
1714
+ *
1715
+ * Filtered here rather than in the host on purpose: a subscription table on the far side of the
1716
+ * boundary is one the host can only ever guess is current, and getting it wrong means a plugin
1717
+ * silently stops hearing things. Broadcasting everything and matching a string is cheap.
1718
+ */
1719
+ eventListeners: Map<string | null, Array<(e: { name: string; data: unknown; from: string }) => void>>;
1720
+ options: Set<string>;
1721
+ /// Timer handles this plugin armed, cleared on unload.
1722
+ timers: Set<number>;
1723
+ agentListeners: { [K in "turnStart" | "token" | "thinking" | "turnEnd" | "toolStart" | "toolEnd"]: Array<(e: never) => void> };
1724
+ streams: Map<string, { cancelled: boolean }>;
1725
+ subscriptions: Disposable[];
1726
+ }
1727
+
1728
+ const plugins = new Map<string, Registered>();
1729
+
1730
+ function reg(plugin: string): Registered {
1731
+ let r = plugins.get(plugin);
1732
+ if (!r) {
1733
+ r = {
1734
+ commands: new Map(),
1735
+ tools: new Map(),
1736
+ hooks: new Map(),
1737
+ providers: new Map(),
1738
+ bufferListeners: new Map(),
1739
+ optionListeners: [],
1740
+ version: 0,
1741
+ sessionListeners: [],
1742
+ viewOpenListeners: [],
1743
+ viewCloseListeners: [],
1744
+ selectionListeners: [],
1745
+ composerListeners: [],
1746
+ activityListeners: [],
1747
+ varListeners: [],
1748
+ swarmListeners: [],
1749
+ quotaListeners: [],
1750
+ swarmStreamListeners: [],
1751
+ contributionListeners: [],
1752
+ highlightListeners: [],
1753
+ focusListeners: [],
1754
+ viewListeners: [],
1755
+ shutdownListeners: [],
1756
+ eventListeners: new Map(),
1757
+ options: new Set(),
1758
+ timers: new Set(),
1759
+ agentListeners: { turnStart: [], token: [], thinking: [], turnEnd: [], toolStart: [], toolEnd: [] },
1760
+ streams: new Map(),
1761
+ subscriptions: [],
1762
+ };
1763
+ plugins.set(plugin, r);
1764
+ }
1765
+ return r;
1766
+ }
1767
+
1768
+ /** A listener on one event name, or on all of them when `name` is `null`. */
1769
+ function eventListener(
1770
+ r: Registered,
1771
+ name: string | null,
1772
+ cb: (e: { name: string; data: unknown; from: string }) => void,
1773
+ ): Disposable {
1774
+ const list = r.eventListeners.get(name) ?? [];
1775
+ r.eventListeners.set(name, list);
1776
+ return listener(list, cb);
1777
+ }
1778
+
1779
+ function listener<T>(list: Array<(e: T) => void>, cb: (e: T) => void): Disposable {
1780
+ list.push(cb as (e: never) => void as (e: T) => void);
1781
+ return {
1782
+ dispose() {
1783
+ const i = list.indexOf(cb);
1784
+ if (i >= 0) list.splice(i, 1);
1785
+ },
1786
+ };
1787
+ }
1788
+
1789
+ function floatConfig(o: FloatOptions = {}): FloatConfig {
1790
+ return {
1791
+ anchor: o.anchor ?? { kind: "screen" },
1792
+ offset: o.offset ?? { row: 0, col: 0 },
1793
+ width: o.width ?? { kind: "auto" },
1794
+ height: o.height ?? { kind: "auto" },
1795
+ z: o.z ?? 100,
1796
+ border: o.border ?? "rounded",
1797
+ border_hl: o.borderHl ?? null,
1798
+ title: o.title ?? null,
1799
+ close_on_blur: o.closeOnBlur ?? false,
1800
+ focusable: o.focusable ?? true,
1801
+ modal: o.modal ?? false,
1802
+ };
1803
+ }
1804
+
1805
+ function markOpts(o: MarkOptions = {}): ExtmarkOpts {
1806
+ return {
1807
+ end_col: o.endCol ?? null,
1808
+ hl_group: o.hlGroup ?? null,
1809
+ line_hl_group: o.lineHlGroup ?? null,
1810
+ virt_text: (o.virtText ?? []).map((v) => ({ text: v.text, hl_group: v.hlGroup ?? null })),
1811
+ virt_text_pos: o.virtTextPos ?? "eol",
1812
+ on_delete: o.onDelete ?? "clamp",
1813
+ priority: o.priority ?? 0,
1814
+ };
1815
+ }
1816
+
1817
+ /**
1818
+ * Build the whole `neosh` namespace over one pair of call functions.
1819
+ *
1820
+ * Called once per plugin with calls that say nothing about where they land, which is the ordinary
1821
+ * `neosh`: the host works out which terminal a window belongs in, and for anything done in answer
1822
+ * to a key that is exact. And again, per view, with calls that name it — which is what a command
1823
+ * handler is handed as its third argument. Inside a handler, `here.win.open(...)` is a panel in
1824
+ * the terminal the key was pressed in, and every other call on `here` is the call it always was.
1825
+ */
1826
+ function build(
1827
+ plugin: string,
1828
+ version: number,
1829
+ r: ReturnType<typeof reg>,
1830
+ view: ViewId | null,
1831
+ ): Neosh {
1832
+ // On the envelope rather than in each call: a call comes *from* a terminal, exactly as a key
1833
+ // press does, and it is the same fact for all of them. It matters for far more than opening a
1834
+ // window — which conversation is on screen, what is in the composer, what `session.list` marks
1835
+ // as the one you are in are all questions with an answer per terminal.
1836
+ const c = (x: ApiCall) => call(plugin, x, view ?? undefined);
1837
+ const n = (x: ApiCall) => notify(plugin, x, view ?? undefined);
1838
+
1839
+ const api: Neosh = {
1840
+ version,
1841
+ notify(message, level) {
1842
+ n({ call: "notify", level: level ?? "info", message, kind: "reply" });
1843
+ },
1844
+ progress(key, message) {
1845
+ n({ call: "notify", level: "info", message, kind: "progress", key });
1846
+ },
1847
+ done(key) {
1848
+ n({ call: "notify_done", key });
1849
+ },
1850
+ async alert(title, message, opts) {
1851
+ // Awaited rather than fired and forgotten, unlike the three above: this is the one that can
1852
+ // be refused — for want of `notify` in the manifest — and a capability error nobody sees is
1853
+ // a plugin that silently never notifies.
1854
+ await c({
1855
+ call: "alert",
1856
+ level: opts?.level ?? "info",
1857
+ title,
1858
+ message,
1859
+ session: opts?.session,
1860
+ });
1861
+ },
1862
+ async permit(capability) {
1863
+ return expect(await c({ call: "permission_check", capability }), "permission").decision;
1864
+ },
1865
+ onViewAttached(cb) {
1866
+ return listener(r.viewListeners, cb);
1867
+ },
1868
+ onShutdown(cb) {
1869
+ return listener(r.shutdownListeners, cb);
1870
+ },
1871
+ async ask(questions) {
1872
+ return expect(await c({ call: "ask_user", questions }), "answers").answers ?? null;
1873
+ },
1874
+ opt: {
1875
+ async declare(spec) {
1876
+ await c({ call: "opt_declare", spec });
1877
+ r.options.add(spec.name);
1878
+ return { dispose: () => r.options.delete(spec.name) };
1879
+ },
1880
+ async get<T = OptionValue>(name: string): Promise<T> {
1881
+ const entry = expect(await c({ call: "opt_get", name }), "option").entry;
1882
+ if (!entry) throw new Error(`option ${name} is not declared`);
1883
+ return entry.value as T;
1884
+ },
1885
+ async entry(name) {
1886
+ return expect(await c({ call: "opt_get", name }), "option").entry;
1887
+ },
1888
+ async set(name, value) {
1889
+ await c({ call: "opt_set", name, value });
1890
+ },
1891
+ async reset(name) {
1892
+ await c({ call: "opt_reset", name });
1893
+ },
1894
+ async all() {
1895
+ return expect(await c({ call: "opt_all" }), "options").options;
1896
+ },
1897
+ onChange(cb) {
1898
+ return listener(r.optionListeners, cb);
1899
+ },
1900
+ },
1901
+ edit: {
1902
+ async move(win, motion, opts) {
1903
+ await c({ call: "win_motion", win, motion, select: opts?.select ?? false });
1904
+ },
1905
+ async apply(win, edit) {
1906
+ await c({ call: "win_edit", win, edit });
1907
+ },
1908
+ async select(win, on) {
1909
+ await c({ call: "win_select", win, on });
1910
+ },
1911
+ async selectShape(win, shape) {
1912
+ await c({ call: "win_select_shape", win, shape });
1913
+ },
1914
+ async cursorShape(win, shape) {
1915
+ await c({ call: "win_cursor_shape", win, shape });
1916
+ },
1917
+ async selection(win) {
1918
+ return expect(await c({ call: "win_selection", win }), "text").text;
1919
+ },
1920
+ async copy(text) {
1921
+ await c({ call: "clipboard_write", text });
1922
+ },
1923
+ },
1924
+ path: {
1925
+ async complete(prefix) {
1926
+ return expect(await c({ call: "path_complete", prefix }), "paths").paths;
1927
+ },
1928
+ },
1929
+ state: {
1930
+ async get<T = unknown>(key: string): Promise<T | null> {
1931
+ const value = expect(await c({ call: "state_get", key }), "json").value;
1932
+ return (value ?? null) as T | null;
1933
+ },
1934
+ async set(key, value) {
1935
+ await c({ call: "state_set", key, value });
1936
+ },
1937
+ async remove(key) {
1938
+ await c({ call: "state_delete", key });
1939
+ },
1940
+ },
1941
+ vars: {
1942
+ async get<T = unknown>(scope: VarScope, key: string): Promise<T | null> {
1943
+ const value = expect(await c({ call: "var_get", scope, key }), "json").value;
1944
+ return (value ?? null) as T | null;
1945
+ },
1946
+ async set(scope, key, value) {
1947
+ await c({ call: "var_set", scope, key, value });
1948
+ },
1949
+ async remove(scope, key) {
1950
+ await c({ call: "var_delete", scope, key });
1951
+ },
1952
+ async all(scope) {
1953
+ return expect(await c({ call: "var_all", scope }), "vars").vars;
1954
+ },
1955
+ onChange(cb) {
1956
+ return listener(r.varListeners, cb);
1957
+ },
1958
+ },
1959
+ ext: {
1960
+ async contribute(point, id, item, opts) {
1961
+ await c({ call: "ext_contribute", point, id, item, priority: opts?.priority ?? 0 });
1962
+ return {
1963
+ dispose() {
1964
+ void c({ call: "ext_remove", point, id });
1965
+ },
1966
+ };
1967
+ },
1968
+ async remove(point, id) {
1969
+ await c({ call: "ext_remove", point, id });
1970
+ },
1971
+ async list<T = unknown>(point: string) {
1972
+ const got = expect(await c({ call: "ext_list", point }), "contributions").contributions;
1973
+ return got as Array<Contribution & { item: T }>;
1974
+ },
1975
+ onChange(cb) {
1976
+ return listener(r.contributionListeners, cb);
1977
+ },
1978
+ async points() {
1979
+ return expect(await c({ call: "ext_points" }), "points").points;
1980
+ },
1981
+ async plugins() {
1982
+ return expect(await c({ call: "plugin_list" }), "plugins").plugins;
1983
+ },
1984
+ },
1985
+ swarm: {
1986
+ async self() {
1987
+ return expect(await c({ call: "swarm_self" }), "swarm_self").node ?? null;
1988
+ },
1989
+ async nodes() {
1990
+ return expect(await c({ call: "swarm_nodes" }), "swarm_nodes").nodes;
1991
+ },
1992
+ async agents() {
1993
+ return expect(await c({ call: "swarm_agents" }), "swarm_agents").agents;
1994
+ },
1995
+ async probe(addr) {
1996
+ const found = expect(await c({ call: "swarm_probe", addr }), "swarm_self").node;
1997
+ if (!found) throw new NeoshError({ kind: "not_found", what: addr });
1998
+ return found;
1999
+ },
2000
+ async pair(node, opts) {
2001
+ await c({
2002
+ call: "swarm_pair",
2003
+ node,
2004
+ name: opts?.name ?? "",
2005
+ addr: opts?.addr ?? null,
2006
+ });
2007
+ },
2008
+ async unpair(node) {
2009
+ await c({ call: "swarm_unpair", node });
2010
+ },
2011
+ async reconnect(node) {
2012
+ await c({ call: "swarm_reconnect", node });
2013
+ },
2014
+ async disconnect(node) {
2015
+ await c({ call: "swarm_disconnect", node });
2016
+ },
2017
+ async strangers() {
2018
+ return expect(await c({ call: "swarm_strangers" }), "swarm_strangers").strangers;
2019
+ },
2020
+ async hostsOf(project) {
2021
+ return expect(await c({ call: "swarm_hosts_of", project }), "names").names;
2022
+ },
2023
+ async command(node, session, command) {
2024
+ await c({ call: "swarm_command", node, session, command });
2025
+ },
2026
+ async subscribe(node, session) {
2027
+ await c({ call: "swarm_subscribe", node, session });
2028
+ },
2029
+ async unsubscribe(node, session) {
2030
+ await c({ call: "swarm_unsubscribe", node, session });
2031
+ },
2032
+ onChange(cb) {
2033
+ return listener(r.swarmListeners, cb);
2034
+ },
2035
+ onStream(cb) {
2036
+ return listener(r.swarmStreamListeners, cb);
2037
+ },
2038
+ },
2039
+ quota: {
2040
+ async list() {
2041
+ return expect(await c({ call: "quota_list" }), "quotas").quotas;
2042
+ },
2043
+ async refresh(instance) {
2044
+ await c({ call: "quota_refresh", instance: instance ?? null });
2045
+ },
2046
+ async report(snapshot) {
2047
+ await c({ call: "quota_report", snapshot });
2048
+ },
2049
+ async history(opts) {
2050
+ const got = await c({
2051
+ call: "quota_history",
2052
+ instance: opts.instance ?? null,
2053
+ since: opts.since,
2054
+ until: opts.until,
2055
+ });
2056
+ return expect(got, "quota_history").samples;
2057
+ },
2058
+ async usage(opts) {
2059
+ const got = await c({
2060
+ call: "usage_history",
2061
+ since: opts.since,
2062
+ until: opts.until,
2063
+ resolution: opts.resolution,
2064
+ time_zone: opts.timeZone ?? null,
2065
+ });
2066
+ return expect(got, "usage_history").history;
2067
+ },
2068
+ onChange(cb) {
2069
+ return listener(r.quotaListeners, cb);
2070
+ },
2071
+ },
2072
+ event: {
2073
+ async emit(name, data) {
2074
+ await c({ call: "event_emit", name, data: data ?? null });
2075
+ },
2076
+ on(name, cb, opts) {
2077
+ const kind = opts?.kind;
2078
+ return eventListener(r, name, (e) => {
2079
+ if (kind !== undefined) {
2080
+ const d = e.data as { kind?: unknown } | null | undefined;
2081
+ if (d?.kind !== kind) return;
2082
+ }
2083
+ cb({ data: e.data, from: e.from });
2084
+ });
2085
+ },
2086
+ onAny(cb) {
2087
+ return eventListener(r, null, cb);
2088
+ },
2089
+ },
2090
+ timer: {
2091
+ after(ms, fn) {
2092
+ const id = setTimeout(() => {
2093
+ r.timers.delete(id);
2094
+ fn();
2095
+ }, ms);
2096
+ r.timers.add(id);
2097
+ return {
2098
+ dispose() {
2099
+ clearTimeout(id);
2100
+ r.timers.delete(id);
2101
+ },
2102
+ };
2103
+ },
2104
+ every(ms, fn) {
2105
+ const id = setInterval(fn, ms);
2106
+ r.timers.add(id);
2107
+ return {
2108
+ dispose() {
2109
+ clearInterval(id);
2110
+ r.timers.delete(id);
2111
+ },
2112
+ };
2113
+ },
2114
+ debounce<A extends unknown[]>(ms: number, fn: (...args: A) => void) {
2115
+ let pending: number | undefined;
2116
+ const run = (...args: A) => {
2117
+ if (pending !== undefined) {
2118
+ clearTimeout(pending);
2119
+ r.timers.delete(pending);
2120
+ }
2121
+ const id = setTimeout(() => {
2122
+ pending = undefined;
2123
+ // Prune here too, not only on the re-arm and dispose paths: a debounce that settles is
2124
+ // the *normal* outcome, and leaving its handle behind grows the set once per burst for
2125
+ // the life of the plugin.
2126
+ r.timers.delete(id);
2127
+ fn(...args);
2128
+ }, ms);
2129
+ pending = id;
2130
+ r.timers.add(id);
2131
+ };
2132
+ run.dispose = () => {
2133
+ if (pending !== undefined) {
2134
+ clearTimeout(pending);
2135
+ r.timers.delete(pending);
2136
+ pending = undefined;
2137
+ }
2138
+ };
2139
+ return run;
2140
+ },
2141
+ },
2142
+ rtp: {
2143
+ async add(path) {
2144
+ await c({ call: "rtp_add", path });
2145
+ },
2146
+ async list() {
2147
+ return expect(await c({ call: "rtp_list" }), "paths").paths;
2148
+ },
2149
+ },
2150
+ log: {
2151
+ info: (message) => n({ call: "log", level: "info", message }),
2152
+ warn: (message) => n({ call: "log", level: "warn", message }),
2153
+ error: (message) => n({ call: "log", level: "error", message }),
2154
+ },
2155
+ buf: {
2156
+ async create(opts) {
2157
+ return expect(await c({
2158
+ call: "buf_create",
2159
+ name: opts?.name ?? null,
2160
+ scratch: opts?.scratch ?? false,
2161
+ kind: opts?.kind ?? null,
2162
+ }), "buf").buf;
2163
+ },
2164
+ async lineCount(buf) {
2165
+ return expect(await c({ call: "buf_line_count", buf }), "count").n;
2166
+ },
2167
+ async getLines(buf, start, end) {
2168
+ return expect(await c({ call: "buf_get_lines", buf, start, end }), "lines").lines;
2169
+ },
2170
+ async setLines(buf, start, end, lines) {
2171
+ await c({ call: "buf_set_lines", buf, start, end, lines });
2172
+ },
2173
+ async render(buf, ns, start, end, rows) {
2174
+ await c({
2175
+ call: "buf_render",
2176
+ buf,
2177
+ ns,
2178
+ start,
2179
+ end,
2180
+ lines: rows.map((r) => ({
2181
+ text: r.text,
2182
+ marks: (r.marks ?? []).map((m) => ({ col: m.col, ...markOpts(m.opts) })),
2183
+ })),
2184
+ });
2185
+ },
2186
+ async appendText(buf, text) {
2187
+ await c({ call: "buf_append_text", buf, text });
2188
+ },
2189
+ async setName(buf, name) {
2190
+ await c({ call: "buf_set_name", buf, name });
2191
+ },
2192
+ async setKind(buf, kind) {
2193
+ await c({ call: "buf_set_kind", buf, kind });
2194
+ },
2195
+ async kind(buf) {
2196
+ return expect(await c({ call: "buf_get_kind", buf }), "maybe_text").text ?? null;
2197
+ },
2198
+ async onChange(buf, cb) {
2199
+ await c({ call: "buf_attach", buf });
2200
+ const list = r.bufferListeners.get(buf) ?? [];
2201
+ r.bufferListeners.set(buf, list);
2202
+ return listener(list, cb);
2203
+ },
2204
+ },
2205
+ win: {
2206
+ async open(buf, dock, opts) {
2207
+ const layout: WindowLayout = {
2208
+ kind: "docked",
2209
+ dock,
2210
+ size: opts?.size ?? null,
2211
+ gravity: opts?.gravity ?? "start",
2212
+ wrap: opts?.wrap ?? null,
2213
+ };
2214
+ return expect(await c({ call: "win_open", buf, layout }), "win").win;
2215
+ },
2216
+ async close(win) {
2217
+ await c({ call: "win_close", win });
2218
+ },
2219
+ async resize(win, size) {
2220
+ await c({ call: "win_resize", win, size });
2221
+ },
2222
+ async setBuf(win, buf) {
2223
+ await c({ call: "win_set_buf", win, buf });
2224
+ },
2225
+ async cursor(win) {
2226
+ const v = expect(await c({ call: "win_get_cursor", win }), "cursor");
2227
+ return { row: v.row, col: v.col };
2228
+ },
2229
+ async setCursor(win, row, col) {
2230
+ await c({ call: "win_set_cursor", win, row, col });
2231
+ },
2232
+ async scrollTo(win, topLine) {
2233
+ await c({ call: "win_scroll_to", win, top_line: topLine });
2234
+ },
2235
+ async viewport(win) {
2236
+ return expect(await c({ call: "win_get_viewport", win }), "viewport").viewport ?? null;
2237
+ },
2238
+ async list() {
2239
+ return expect(await c({ call: "win_list" }), "windows").windows;
2240
+ },
2241
+ async ofKind(kind) {
2242
+ const windows = expect(await c({ call: "win_list" }), "windows").windows;
2243
+ return windows.filter((w) => w.kind === kind);
2244
+ },
2245
+ async setHighlights(target, map) {
2246
+ const t: HlTarget = "win" in target
2247
+ ? { kind: "window", win: target.win }
2248
+ : { kind: "kind", name: target.kind };
2249
+ await c({ call: "win_set_highlights", target: t, map });
2250
+ },
2251
+ },
2252
+ float: {
2253
+ async open(buf, opts) {
2254
+ return expect(await c({ call: "float_open", buf, config: floatConfig(opts) }), "win").win;
2255
+ },
2256
+ async configure(win, opts) {
2257
+ await c({ call: "float_configure", win, config: floatConfig(opts) });
2258
+ },
2259
+ async close(win) {
2260
+ await c({ call: "win_close", win });
2261
+ },
2262
+ },
2263
+ ns: {
2264
+ async create(name) {
2265
+ return expect(await c({ call: "ns_create", name }), "ns").ns;
2266
+ },
2267
+ async mark(ns, buf, row, col, opts) {
2268
+ return expect(await c({ call: "mark_set", ns, buf, row, col, opts: markOpts(opts) }), "mark").id;
2269
+ },
2270
+ async getMark(ns, buf, id) {
2271
+ return expect(await c({ call: "mark_get", ns, buf, id }), "mark_info").info;
2272
+ },
2273
+ async allMarks(ns, buf) {
2274
+ return expect(await c({ call: "mark_all", ns, buf }), "marks").marks;
2275
+ },
2276
+ async delMark(ns, buf, id) {
2277
+ await c({ call: "mark_del", ns, buf, id });
2278
+ },
2279
+ async clear(ns, buf, start, end) {
2280
+ await c({ call: "mark_clear", ns, buf, start: start ?? null, end: end ?? null });
2281
+ },
2282
+ },
2283
+ hl: {
2284
+ async define(name, def, opts) {
2285
+ const d: HighlightDef =
2286
+ "link" in def ? { kind: "link", to: def.link } : { kind: "spec", spec: def };
2287
+ await c({ call: "hl_define", name, def: d, default: opts?.default ?? false });
2288
+ },
2289
+ async get(name) {
2290
+ const v = expect(await c({ call: "hl_get", name }), "highlight");
2291
+ return { def: v.def ?? null, resolved: v.resolved ?? null };
2292
+ },
2293
+ async list() {
2294
+ return expect(await c({ call: "hl_list" }), "highlights").groups;
2295
+ },
2296
+ async reset(name) {
2297
+ await c({ call: "hl_reset", name });
2298
+ },
2299
+ onChange(cb) {
2300
+ return listener(r.highlightListeners, cb);
2301
+ },
2302
+ },
2303
+ ui: {
2304
+ async claim(win, rect) {
2305
+ return expect(await c({ call: "surface_claim", win, rect }), "surface").surface;
2306
+ },
2307
+ async put(surface, cells) {
2308
+ await c({ call: "surface_put", surface, cells });
2309
+ },
2310
+ async release(surface) {
2311
+ await c({ call: "surface_release", surface });
2312
+ },
2313
+ },
2314
+ cmd: {
2315
+ async register(name, fn, opts) {
2316
+ await c({ call: "cmd_register", name, desc: opts?.desc ?? null });
2317
+ r.commands.set(name, fn);
2318
+ return {
2319
+ dispose: () => {
2320
+ r.commands.delete(name);
2321
+ n({ call: "cmd_unregister", name });
2322
+ },
2323
+ };
2324
+ },
2325
+ async exec(name, args) {
2326
+ await c({ call: "cmd_exec", name, args: args ?? [] });
2327
+ },
2328
+ async call(name, args) {
2329
+ return expect(await c({ call: "cmd_call", name, args: args ?? [] }), "json").value as never;
2330
+ },
2331
+ async list() {
2332
+ return expect(await c({ call: "cmd_list" }), "commands").commands;
2333
+ },
2334
+ },
2335
+ keymap: {
2336
+ async set(mode, lhs, command, opts) {
2337
+ await c({ call: "keymap_set", mode, lhs, command, scope: opts?.scope ?? null, desc: opts?.desc ?? null });
2338
+ },
2339
+ async del(mode, lhs, scope) {
2340
+ await c({ call: "keymap_del", mode, lhs, scope: scope ?? null });
2341
+ },
2342
+ async list(mode) {
2343
+ return expect(await c({ call: "keymap_list", mode: mode ?? null }), "keymaps").keymaps;
2344
+ },
2345
+ async capture(win, command) {
2346
+ await c({ call: "keymap_capture", win, command });
2347
+ return { dispose: () => n({ call: "keymap_release", win }) };
2348
+ },
2349
+ },
2350
+ focus: {
2351
+ async push(win) {
2352
+ await c({ call: "focus_push", win });
2353
+ },
2354
+ async pop() {
2355
+ await c({ call: "focus_pop" });
2356
+ },
2357
+ async current() {
2358
+ return expect(await c({ call: "focus_current" }), "focused_win").win;
2359
+ },
2360
+ onChange(cb) {
2361
+ return listener(r.focusListeners, cb);
2362
+ },
2363
+ },
2364
+ agent: {
2365
+ async send(text, opts) {
2366
+ await c({ call: "agent_send", text, images: opts?.images ?? [] });
2367
+ },
2368
+ async cancel() {
2369
+ await c({ call: "agent_cancel" });
2370
+ },
2371
+ async command(command, session) {
2372
+ const v = await c({ call: "agent_command", session: session ?? null, command });
2373
+ return expect(v, "maybe_session").session;
2374
+ },
2375
+ async selection() {
2376
+ return expect(await c({ call: "agent_get_selection" }), "selection").selection;
2377
+ },
2378
+ async setSelection(selection) {
2379
+ await c({ call: "agent_set_selection", selection });
2380
+ },
2381
+ async listModels(instance, opts) {
2382
+ const v = await c({
2383
+ call: "agent_list_models",
2384
+ instance: instance ?? null,
2385
+ refresh: opts?.refresh ?? false,
2386
+ });
2387
+ return expect(v, "models").models;
2388
+ },
2389
+ async listInstances() {
2390
+ return expect(await c({ call: "agent_list_instances" }), "instances").instances;
2391
+ },
2392
+ async driverCommands() {
2393
+ return expect(await c({ call: "agent_driver_commands" }), "driver_commands").commands;
2394
+ },
2395
+ async setDraft(text) {
2396
+ await c({ call: "chat_set_draft", text });
2397
+ },
2398
+ async attach(path) {
2399
+ const v = await c({ call: "chat_attach", path: path ?? null });
2400
+ // Exactly one, because exactly one was asked for. An empty answer would mean the host
2401
+ // silently attached nothing, which it does not — it rejects.
2402
+ const [one] = expect(v, "attachments").attachments;
2403
+ if (!one) throw new Error("nothing was attached");
2404
+ return one;
2405
+ },
2406
+ async attachments() {
2407
+ return expect(await c({ call: "chat_attachments" }), "attachments").attachments;
2408
+ },
2409
+ async detach(index) {
2410
+ const v = await c({ call: "chat_detach", index: index ?? null });
2411
+ return expect(v, "attachments").attachments[0] ?? null;
2412
+ },
2413
+ async detachAll() {
2414
+ return expect(await c({ call: "chat_detach_all" }), "attachments").attachments;
2415
+ },
2416
+ async credentials() {
2417
+ return expect(await c({ call: "provider_credentials" }), "credentials").credentials;
2418
+ },
2419
+ async setCredential(instance, opts) {
2420
+ // Does not settle until the prompt closes — the answer is what the user did.
2421
+ const v = await c({
2422
+ call: "provider_set_credential",
2423
+ instance,
2424
+ replace: opts?.replace ?? false,
2425
+ });
2426
+ return expect(v, "bool").value;
2427
+ },
2428
+ async forgetCredential(instance) {
2429
+ await c({ call: "provider_forget_credential", instance });
2430
+ },
2431
+ onSelectionChange: (cb) => listener(r.selectionListeners, cb),
2432
+ onComposerChange: (cb) => listener(r.composerListeners, cb),
2433
+ onActivity: (cb) => listener(r.activityListeners, cb),
2434
+ onTurnStart: (cb) =>
2435
+ listener(r.agentListeners.turnStart as Array<(e: { session: string; turn: string }) => void>, cb),
2436
+ onToken: (cb) =>
2437
+ listener(r.agentListeners.token as Array<(e: { session: string; turn: string; text: string }) => void>, cb),
2438
+ onThinking: (cb) =>
2439
+ listener(r.agentListeners.thinking as Array<(e: { session: string; turn: string; text: string }) => void>, cb),
2440
+ onTurnEnd: (cb) =>
2441
+ listener(
2442
+ r.agentListeners.turnEnd as Array<
2443
+ (e: { session: string; turn: string; stopReason: StopReason; usage: Usage }) => void
2444
+ >,
2445
+ cb,
2446
+ ),
2447
+ onToolStart: (cb) =>
2448
+ listener(
2449
+ r.agentListeners.toolStart as Array<(e: { session: string; turn: string; call: ToolCall }) => void>,
2450
+ cb,
2451
+ ),
2452
+ onToolEnd: (cb) =>
2453
+ listener(
2454
+ r.agentListeners.toolEnd as Array<
2455
+ (e: { session: string; turn: string; call: ToolCall; result: ToolResult }) => void
2456
+ >,
2457
+ cb,
2458
+ ),
2459
+ },
2460
+ tool: {
2461
+ async register(def, handler) {
2462
+ await c({
2463
+ call: "tool_register",
2464
+ def: {
2465
+ name: def.name,
2466
+ description: def.description,
2467
+ input_schema: def.inputSchema,
2468
+ source: { kind: "builtin" },
2469
+ },
2470
+ });
2471
+ r.tools.set(def.name, handler);
2472
+ return {
2473
+ dispose: () => {
2474
+ r.tools.delete(def.name);
2475
+ n({ call: "tool_unregister", name: def.name });
2476
+ },
2477
+ };
2478
+ },
2479
+ async list() {
2480
+ return expect(await c({ call: "tool_list" }), "tools").tools;
2481
+ },
2482
+ },
2483
+ hook: {
2484
+ async register(hook, fn, opts) {
2485
+ const blocking = opts?.blocking ?? false;
2486
+ await c({ call: "hook_register", hook, blocking, timeout_ms: opts?.timeoutMs ?? null });
2487
+ r.hooks.set(hook, fn);
2488
+ return {
2489
+ dispose: () => {
2490
+ r.hooks.delete(hook);
2491
+ n({ call: "hook_unregister", hook });
2492
+ },
2493
+ };
2494
+ },
2495
+ },
2496
+ git: {
2497
+ async status(opts) {
2498
+ return expect(await c({ call: "git_status", cwd: opts?.cwd ?? null }), "status").status;
2499
+ },
2500
+ async branches(opts) {
2501
+ const v = await c({
2502
+ call: "git_branches",
2503
+ include_remote: opts?.includeRemote ?? false,
2504
+ cwd: opts?.cwd ?? null,
2505
+ });
2506
+ return expect(v, "branches").branches;
2507
+ },
2508
+ async worktrees(opts) {
2509
+ const v = await c({ call: "git_worktrees", cwd: opts?.cwd ?? null });
2510
+ return expect(v, "worktrees").worktrees;
2511
+ },
2512
+ async log(limit) {
2513
+ return expect(await c({ call: "git_log", limit: limit ?? 20 }), "commits").commits;
2514
+ },
2515
+ async diff(target, opts) {
2516
+ const v = await c({
2517
+ call: "git_diff",
2518
+ target: target ?? { kind: "unstaged" },
2519
+ stat: opts?.stat ?? false,
2520
+ });
2521
+ return expect(v, "text").text;
2522
+ },
2523
+ async defaultBranch() {
2524
+ return expect(await c({ call: "git_default_branch" }), "maybe_text").text ?? null;
2525
+ },
2526
+ async createBranch(name, opts) {
2527
+ await c({ call: "git_create_branch", name, from: opts?.from ?? null });
2528
+ },
2529
+ async renameBranch(name, next, opts) {
2530
+ await c({ call: "git_rename_branch", old: name, new: next, cwd: opts?.cwd ?? null });
2531
+ },
2532
+ async checkout(rev) {
2533
+ await c({ call: "git_checkout", rev });
2534
+ },
2535
+ async stage(paths) {
2536
+ await c({ call: "git_stage", paths: paths ?? [] });
2537
+ },
2538
+ async unstage(paths) {
2539
+ await c({ call: "git_unstage", paths: paths ?? [] });
2540
+ },
2541
+ async commit(message) {
2542
+ return expect(await c({ call: "git_commit", message }), "commit").commit;
2543
+ },
2544
+ async addWorktree(path, branch, opts) {
2545
+ await c({
2546
+ call: "git_add_worktree",
2547
+ path,
2548
+ branch,
2549
+ create: opts?.create ?? false,
2550
+ cwd: opts?.cwd ?? null,
2551
+ });
2552
+ },
2553
+ async pull(opts) {
2554
+ const v = await c({ call: "git_pull", cwd: opts?.cwd ?? null });
2555
+ return expect(v, "text").text;
2556
+ },
2557
+ async removeWorktree(path, opts) {
2558
+ await c({
2559
+ call: "git_remove_worktree",
2560
+ path,
2561
+ force: opts?.force ?? false,
2562
+ cwd: opts?.cwd ?? null,
2563
+ });
2564
+ },
2565
+ },
2566
+ gen: {
2567
+ async complete(prompt, opts) {
2568
+ const v = await c({
2569
+ call: "gen_complete",
2570
+ prompt,
2571
+ system: opts?.system ?? null,
2572
+ json: false,
2573
+ selection: opts?.selection ?? null,
2574
+ });
2575
+ return expect(v, "text").text;
2576
+ },
2577
+ async json(prompt, opts) {
2578
+ const v = await c({
2579
+ call: "gen_complete",
2580
+ prompt,
2581
+ system: opts?.system ?? null,
2582
+ json: true,
2583
+ selection: opts?.selection ?? null,
2584
+ });
2585
+ return expect(v, "json").value as never;
2586
+ },
2587
+ },
2588
+ view: {
2589
+ async list() {
2590
+ return expect(await c({ call: "view_list" }), "views").views;
2591
+ },
2592
+ async current() {
2593
+ const views = expect(await c({ call: "view_list" }), "views").views;
2594
+ return views.find((v) => v.current) ?? null;
2595
+ },
2596
+ at: (id) => build(plugin, version, r, id),
2597
+ onOpen(cb) {
2598
+ // The terminals that were already here, as well as the ones still to come. A plugin loaded
2599
+ // after them missed their arrival, and "open a panel in every view" would otherwise mean
2600
+ // every view *from now on* — which is every view except the one you are sitting in.
2601
+ //
2602
+ // Announced once each: a view that arrives while the list is in flight arrives by the
2603
+ // event too, and `seen` is what stops it being announced twice.
2604
+ const seen = new Set<ViewId>();
2605
+ const announce = (id: ViewId) => {
2606
+ if (seen.has(id)) return;
2607
+ seen.add(id);
2608
+ cb(id);
2609
+ };
2610
+ const d = listener(r.viewOpenListeners, announce);
2611
+ void c({ call: "view_list" })
2612
+ .then((v) => {
2613
+ for (const info of expect(v, "views").views) announce(info.view);
2614
+ })
2615
+ .catch(() => {});
2616
+ return d;
2617
+ },
2618
+ onClose: (cb) => listener(r.viewCloseListeners, cb),
2619
+ },
2620
+ session: {
2621
+ async list(opts) {
2622
+ const v = await c({
2623
+ call: "session_list",
2624
+ include_archived: opts?.includeArchived ?? false,
2625
+ });
2626
+ return expect(v, "sessions").sessions;
2627
+ },
2628
+ async current() {
2629
+ return expect(await c({ call: "session_current" }), "session").session;
2630
+ },
2631
+ async create(opts) {
2632
+ const v = await c({
2633
+ call: "session_new",
2634
+ cwd: opts?.cwd ?? null,
2635
+ title: opts?.title ?? null,
2636
+ activate: opts?.activate ?? true,
2637
+ });
2638
+ return expect(v, "session").session;
2639
+ },
2640
+ async switch(session) {
2641
+ await c({ call: "session_switch", session });
2642
+ },
2643
+ async close(session) {
2644
+ await c({ call: "session_close", session });
2645
+ },
2646
+ async rename(session, title) {
2647
+ await c({ call: "session_rename", session, title });
2648
+ },
2649
+ async archive(session, archived) {
2650
+ await c({ call: "session_archive", session, archived: archived ?? true });
2651
+ },
2652
+ async stored() {
2653
+ return expect(await c({ call: "sessions_stored" }), "sessions").sessions;
2654
+ },
2655
+ async messages(session) {
2656
+ const v = await c({ call: "session_messages", session: session ?? null });
2657
+ return expect(v, "messages").messages;
2658
+ },
2659
+ onChange: (cb) => listener(r.sessionListeners, cb),
2660
+ },
2661
+ permission: {
2662
+ async mode() {
2663
+ return expect(await c({ call: "permission_get_mode" }), "permission_mode").mode;
2664
+ },
2665
+ async setMode(mode) {
2666
+ return expect(await c({ call: "permission_set_mode", mode }), "permission_mode").mode;
2667
+ },
2668
+ },
2669
+ hint: {
2670
+ async set(key, hint) {
2671
+ await c({
2672
+ call: "hint_set",
2673
+ key,
2674
+ hint: { keys: hint.keys, label: hint.label, priority: hint.priority ?? 0 },
2675
+ });
2676
+ },
2677
+ async clear(key) {
2678
+ await c({ call: "hint_clear", key });
2679
+ },
2680
+ },
2681
+ status: {
2682
+ async set(key, segment) {
2683
+ await c({
2684
+ call: "status_set",
2685
+ key,
2686
+ segment: {
2687
+ text: segment.text,
2688
+ short: segment.short ?? null,
2689
+ keys: segment.keys ?? null,
2690
+ hl: segment.hl ?? null,
2691
+ align: segment.align ?? "left",
2692
+ priority: segment.priority ?? 0,
2693
+ },
2694
+ });
2695
+ },
2696
+ async clear(key) {
2697
+ await c({ call: "status_clear", key });
2698
+ },
2699
+ },
2700
+ provider: {
2701
+ async register(driver, instances, handler, opts) {
2702
+ await c({
2703
+ call: "provider_register_driver",
2704
+ driver,
2705
+ instances,
2706
+ agent_loop: opts?.agentLoop ?? false,
2707
+ });
2708
+ r.providers.set(driver, handler);
2709
+ return { dispose: () => r.providers.delete(driver) };
2710
+ },
2711
+ },
2712
+ };
2713
+ return api;
2714
+ }
2715
+
2716
+ /** Build the API object handed to one plugin. Internal; the host calls this. */
2717
+ export function __createContext(plugin: string, config: unknown, version: number): PluginContext {
2718
+ const r = reg(plugin);
2719
+ r.version = version;
2720
+ return {
2721
+ neosh: build(plugin, version, r, null),
2722
+ pluginId: plugin,
2723
+ config,
2724
+ subscriptions: r.subscriptions,
2725
+ };
2726
+ }
2727
+
2728
+ /** Route one host message. Internal; the host's bootstrap calls this. */
2729
+ export async function __dispatch(plugin: string, msg: Record<string, unknown>): Promise<void> {
2730
+ const r = reg(plugin);
2731
+
2732
+ if (msg.type === "response") {
2733
+ settle(msg.id as string, msg.response as ApiResponse);
2734
+ return;
2735
+ }
2736
+
2737
+ if (msg.type === "request") {
2738
+ const id = msg.id as string;
2739
+ const req = msg.request as Record<string, unknown>;
2740
+ const respond = (response: unknown) =>
2741
+ send({ type: "plugin", plugin, msg: { type: "response", id, response } });
2742
+
2743
+ try {
2744
+ if (req.type === "run_tool") {
2745
+ const h = r.tools.get(req.name as string);
2746
+ if (!h) {
2747
+ respond({ type: "error", message: `plugin ${plugin} has no tool ${req.name}` });
2748
+ return;
2749
+ }
2750
+ respond({ type: "tool", result: await h(req.input) });
2751
+ } else if (req.type === "command") {
2752
+ const name = req.name as string;
2753
+ const h = r.commands.get(name);
2754
+ if (!h) {
2755
+ respond({ type: "error", message: `plugin ${plugin} has no command ${name}` });
2756
+ return;
2757
+ }
2758
+ const value = await h((req.args as string[]) ?? [], undefined);
2759
+ // `undefined` is not JSON; a handler that returned nothing answers `null`.
2760
+ respond({ type: "command", value: value === undefined ? null : value });
2761
+ } else if (req.type === "hook") {
2762
+ const h = r.hooks.get(req.hook as HookName);
2763
+ // A hook the plugin no longer has must not block the action: continue, do not veto.
2764
+ const outcome: HookOutcome = h ? await h(req.payload as HookPayload) : { action: "continue" };
2765
+ respond({ type: "hook", outcome });
2766
+ } else if (req.type === "provider_stream") {
2767
+ const streamId = req.stream as string;
2768
+ const tr = req.request as TurnRequest;
2769
+ const h = r.providers.get(tr.selection.instance);
2770
+ const byDriver = h ?? [...r.providers.values()][0];
2771
+ if (!byDriver) {
2772
+ respond({ type: "error", message: `plugin ${plugin} has no provider` });
2773
+ return;
2774
+ }
2775
+ const signal = { cancelled: false };
2776
+ r.streams.set(streamId, signal);
2777
+ // Answer immediately: a stream cannot be a return value across this boundary.
2778
+ respond({ type: "provider_accepted" });
2779
+ void (async () => {
2780
+ try {
2781
+ await byDriver(tr, (e) => notify(plugin, { call: "provider_emit", stream: streamId, event: e }), signal);
2782
+ } finally {
2783
+ r.streams.delete(streamId);
2784
+ }
2785
+ })();
2786
+ } else {
2787
+ respond({ type: "error", message: `unknown request ${String(req.type)}` });
2788
+ }
2789
+ } catch (e) {
2790
+ respond({ type: "error", message: e instanceof Error ? e.message : String(e) });
2791
+ }
2792
+ return;
2793
+ }
2794
+
2795
+ if (msg.type === "event") {
2796
+ const ev = msg.event as PluginEvent;
2797
+ try {
2798
+ await dispatchEvent(plugin, r, ev);
2799
+ } catch (e) {
2800
+ // A listener that throws is one plugin's bug, and it used to be every plugin's: an
2801
+ // unhandled rejection stops the runtime, and the runtime is shared. Reported and survived.
2802
+ const what = e instanceof Error ? (e.stack ?? e.message) : String(e);
2803
+ notify(plugin, { call: "log", level: "error", message: `handling ${ev.type}: ${what}` });
2804
+ }
2805
+ return;
2806
+ }
2807
+ }
2808
+
2809
+ async function dispatchEvent(
2810
+ plugin: string,
2811
+ r: ReturnType<typeof reg>,
2812
+ ev: PluginEvent,
2813
+ ): Promise<void> {
2814
+ {
2815
+ switch (ev.type) {
2816
+ case "command_invoked": {
2817
+ const h = r.commands.get(ev.name);
2818
+ if (!h) break;
2819
+ // The third argument is the whole namespace bound to the terminal the key was pressed in.
2820
+ // A handler that opens a panel writes `here.win.open(...)` and it lands where the person
2821
+ // pressing the key is looking, without having to say so or to know that views exist.
2822
+ const here = ev.key ? build(plugin, r.version, r, ev.key.view) : undefined;
2823
+ await h(ev.args ?? [], ev.key ?? undefined, here);
2824
+ break;
2825
+ }
2826
+ case "buffer_changed": {
2827
+ for (const cb of r.bufferListeners.get(ev.buf) ?? []) {
2828
+ cb({ buf: ev.buf, start: ev.start, oldEnd: ev.old_end, newEnd: ev.new_end });
2829
+ }
2830
+ break;
2831
+ }
2832
+ case "turn_started":
2833
+ for (const cb of r.agentListeners.turnStart)
2834
+ (cb as (e: unknown) => void)({ session: ev.session, turn: ev.turn });
2835
+ break;
2836
+ case "token":
2837
+ for (const cb of r.agentListeners.token)
2838
+ (cb as (e: unknown) => void)({ session: ev.session, turn: ev.turn, text: ev.text });
2839
+ break;
2840
+ case "thinking_token":
2841
+ for (const cb of r.agentListeners.thinking)
2842
+ (cb as (e: unknown) => void)({ session: ev.session, turn: ev.turn, text: ev.text });
2843
+ break;
2844
+ case "turn_ended":
2845
+ for (const cb of r.agentListeners.turnEnd)
2846
+ (cb as (e: unknown) => void)({
2847
+ session: ev.session,
2848
+ turn: ev.turn,
2849
+ stopReason: ev.stop_reason,
2850
+ usage: ev.usage,
2851
+ });
2852
+ break;
2853
+ case "tool_started":
2854
+ for (const cb of r.agentListeners.toolStart)
2855
+ (cb as (e: unknown) => void)({ session: ev.session, turn: ev.turn, call: ev.call });
2856
+ break;
2857
+ case "tool_finished":
2858
+ for (const cb of r.agentListeners.toolEnd)
2859
+ (cb as (e: unknown) => void)({
2860
+ session: ev.session,
2861
+ turn: ev.turn,
2862
+ call: ev.call,
2863
+ result: ev.result,
2864
+ });
2865
+ break;
2866
+ case "hook_observed": {
2867
+ const h = r.hooks.get(ev.hook);
2868
+ if (h) await h(ev.payload);
2869
+ break;
2870
+ }
2871
+ case "provider_cancel": {
2872
+ const s = r.streams.get(ev.stream);
2873
+ if (s) s.cancelled = true;
2874
+ break;
2875
+ }
2876
+ case "option_changed":
2877
+ for (const cb of r.optionListeners) cb({ name: ev.name, value: ev.value });
2878
+ break;
2879
+ case "session_changed":
2880
+ for (const cb of r.sessionListeners) cb({ session: ev.session, view: ev.view });
2881
+ break;
2882
+ case "view_attached":
2883
+ for (const cb of r.viewOpenListeners) cb(ev.view);
2884
+ break;
2885
+ case "view_closed":
2886
+ for (const cb of r.viewCloseListeners) cb(ev.view);
2887
+ break;
2888
+ case "selection_changed":
2889
+ for (const cb of r.selectionListeners) cb({ selection: ev.selection });
2890
+ break;
2891
+ case "composer_changed":
2892
+ for (const cb of r.composerListeners) cb({ text: ev.text });
2893
+ break;
2894
+ case "activity":
2895
+ for (const cb of r.activityListeners)
2896
+ cb({ session: ev.session, turn: ev.turn, activity: ev.activity });
2897
+ break;
2898
+ case "var_changed":
2899
+ for (const cb of r.varListeners)
2900
+ cb({ scope: ev.scope, key: ev.key, value: ev.value });
2901
+ break;
2902
+ case "quota":
2903
+ for (const cb of [...r.quotaListeners]) cb(ev.snapshot);
2904
+ break;
2905
+ case "swarm_changed":
2906
+ for (const cb of [...r.swarmListeners]) cb();
2907
+ break;
2908
+ case "swarm_stream":
2909
+ for (const cb of [...r.swarmStreamListeners]) {
2910
+ cb({ node: ev.node, session: ev.session, event: ev.event });
2911
+ }
2912
+ break;
2913
+ case "contributions_changed":
2914
+ for (const cb of r.contributionListeners) cb({ point: ev.point });
2915
+ break;
2916
+ case "focus_changed":
2917
+ for (const cb of r.focusListeners) cb({ win: ev.win ?? null });
2918
+ break;
2919
+ case "view_attached":
2920
+ for (const cb of r.viewListeners) cb();
2921
+ break;
2922
+ case "shutdown":
2923
+ for (const cb of r.shutdownListeners) cb();
2924
+ break;
2925
+ case "highlight_changed":
2926
+ for (const cb of r.highlightListeners) cb({ names: ev.names });
2927
+ break;
2928
+ case "event": {
2929
+ // Copied before iterating: a listener that unsubscribes itself — the ordinary shape of
2930
+ // "wait for the thing to happen once" — would otherwise shorten the array underneath the
2931
+ // loop and skip whoever was next.
2932
+ const named = [...(r.eventListeners.get(ev.name) ?? [])];
2933
+ const all = [...(r.eventListeners.get(null) ?? [])];
2934
+ const e = { name: ev.name, data: ev.data ?? null, from: ev.from };
2935
+ for (const cb of named) cb(e);
2936
+ for (const cb of all) cb(e);
2937
+ break;
2938
+ }
2939
+ }
2940
+ }
2941
+ }
2942
+
2943
+ /** Dispose everything a plugin registered. Internal. */
2944
+ export function __teardown(plugin: string): void {
2945
+ const r = plugins.get(plugin);
2946
+ if (!r) return;
2947
+ // Before the disposers: a timer that fires mid-teardown would call into a plugin that is halfway
2948
+ // gone.
2949
+ for (const id of r.timers) {
2950
+ clearTimeout(id);
2951
+ }
2952
+ r.timers.clear();
2953
+ for (const d of r.subscriptions) {
2954
+ try {
2955
+ d.dispose();
2956
+ } catch {
2957
+ // A failing disposer must not stop the rest from running.
2958
+ }
2959
+ }
2960
+ plugins.delete(plugin);
2961
+ }