@plmbr/notebook-intelligence 5.0.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 (137) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +412 -0
  3. package/lib/api.d.ts +288 -0
  4. package/lib/api.js +927 -0
  5. package/lib/cell-output-bundle.d.ts +25 -0
  6. package/lib/cell-output-bundle.js +129 -0
  7. package/lib/cell-output-toolbar.d.ts +26 -0
  8. package/lib/cell-output-toolbar.js +188 -0
  9. package/lib/chat-progress-feedback.d.ts +3 -0
  10. package/lib/chat-progress-feedback.js +27 -0
  11. package/lib/chat-sidebar.d.ts +92 -0
  12. package/lib/chat-sidebar.js +3452 -0
  13. package/lib/command-ids.d.ts +39 -0
  14. package/lib/command-ids.js +44 -0
  15. package/lib/components/ask-user-question.d.ts +2 -0
  16. package/lib/components/ask-user-question.js +85 -0
  17. package/lib/components/checkbox.d.ts +2 -0
  18. package/lib/components/checkbox.js +30 -0
  19. package/lib/components/claude-mcp-panel.d.ts +2 -0
  20. package/lib/components/claude-mcp-panel.js +275 -0
  21. package/lib/components/claude-mcp-paste.d.ts +7 -0
  22. package/lib/components/claude-mcp-paste.js +104 -0
  23. package/lib/components/claude-session-picker.d.ts +8 -0
  24. package/lib/components/claude-session-picker.js +127 -0
  25. package/lib/components/form-dialog.d.ts +25 -0
  26. package/lib/components/form-dialog.js +35 -0
  27. package/lib/components/launcher-picker.d.ts +6 -0
  28. package/lib/components/launcher-picker.js +135 -0
  29. package/lib/components/mcp-util.d.ts +2 -0
  30. package/lib/components/mcp-util.js +37 -0
  31. package/lib/components/notebook-generation-popover.d.ts +7 -0
  32. package/lib/components/notebook-generation-popover.js +60 -0
  33. package/lib/components/pill.d.ts +2 -0
  34. package/lib/components/pill.js +5 -0
  35. package/lib/components/plugins-panel.d.ts +3 -0
  36. package/lib/components/plugins-panel.js +466 -0
  37. package/lib/components/settings-panel.d.ts +11 -0
  38. package/lib/components/settings-panel.js +742 -0
  39. package/lib/components/skills-panel.d.ts +2 -0
  40. package/lib/components/skills-panel.js +1264 -0
  41. package/lib/handler.d.ts +8 -0
  42. package/lib/handler.js +36 -0
  43. package/lib/icons.d.ts +45 -0
  44. package/lib/icons.js +54 -0
  45. package/lib/index.d.ts +8 -0
  46. package/lib/index.js +2079 -0
  47. package/lib/markdown-renderer.d.ts +10 -0
  48. package/lib/markdown-renderer.js +64 -0
  49. package/lib/notebook-generation-toolbar.d.ts +16 -0
  50. package/lib/notebook-generation-toolbar.js +197 -0
  51. package/lib/notebook-generation.d.ts +8 -0
  52. package/lib/notebook-generation.js +12 -0
  53. package/lib/open-file-refresh-watcher-env.d.ts +4 -0
  54. package/lib/open-file-refresh-watcher-env.js +33 -0
  55. package/lib/open-file-refresh-watcher.d.ts +97 -0
  56. package/lib/open-file-refresh-watcher.js +190 -0
  57. package/lib/shell-utils.d.ts +6 -0
  58. package/lib/shell-utils.js +9 -0
  59. package/lib/task-target-notebook.d.ts +2 -0
  60. package/lib/task-target-notebook.js +28 -0
  61. package/lib/terminal-drag-format.d.ts +9 -0
  62. package/lib/terminal-drag-format.js +23 -0
  63. package/lib/terminal-drag.d.ts +12 -0
  64. package/lib/terminal-drag.js +268 -0
  65. package/lib/tokens.d.ts +149 -0
  66. package/lib/tokens.js +88 -0
  67. package/lib/tour/tour-anchors.d.ts +18 -0
  68. package/lib/tour/tour-anchors.js +18 -0
  69. package/lib/tour/tour-config.d.ts +66 -0
  70. package/lib/tour/tour-config.js +99 -0
  71. package/lib/tour/tour-defaults.json +58 -0
  72. package/lib/tour/tour-events.d.ts +19 -0
  73. package/lib/tour/tour-events.js +30 -0
  74. package/lib/tour/tour-overlay.d.ts +6 -0
  75. package/lib/tour/tour-overlay.js +350 -0
  76. package/lib/tour/tour-state.d.ts +20 -0
  77. package/lib/tour/tour-state.js +81 -0
  78. package/lib/tour/tour-steps.d.ts +33 -0
  79. package/lib/tour/tour-steps.js +216 -0
  80. package/lib/utils.d.ts +53 -0
  81. package/lib/utils.js +385 -0
  82. package/package.json +258 -0
  83. package/schema/plugin.json +42 -0
  84. package/src/api.ts +1424 -0
  85. package/src/cell-output-bundle.ts +176 -0
  86. package/src/cell-output-toolbar.ts +232 -0
  87. package/src/chat-progress-feedback.ts +35 -0
  88. package/src/chat-sidebar.tsx +5147 -0
  89. package/src/command-ids.ts +67 -0
  90. package/src/components/ask-user-question.tsx +151 -0
  91. package/src/components/checkbox.tsx +62 -0
  92. package/src/components/claude-mcp-panel.tsx +543 -0
  93. package/src/components/claude-mcp-paste.ts +132 -0
  94. package/src/components/claude-session-picker.tsx +214 -0
  95. package/src/components/form-dialog.tsx +75 -0
  96. package/src/components/launcher-picker.tsx +237 -0
  97. package/src/components/mcp-util.ts +53 -0
  98. package/src/components/notebook-generation-popover.tsx +127 -0
  99. package/src/components/pill.tsx +15 -0
  100. package/src/components/plugins-panel.tsx +774 -0
  101. package/src/components/settings-panel.tsx +1631 -0
  102. package/src/components/skills-panel.tsx +2084 -0
  103. package/src/handler.ts +51 -0
  104. package/src/icons.ts +71 -0
  105. package/src/index.ts +2583 -0
  106. package/src/markdown-renderer.tsx +153 -0
  107. package/src/notebook-generation-toolbar.tsx +281 -0
  108. package/src/notebook-generation.ts +23 -0
  109. package/src/open-file-refresh-watcher-env.ts +52 -0
  110. package/src/open-file-refresh-watcher.ts +260 -0
  111. package/src/shell-utils.ts +10 -0
  112. package/src/svg.d.ts +4 -0
  113. package/src/task-target-notebook.ts +37 -0
  114. package/src/terminal-drag-format.ts +29 -0
  115. package/src/terminal-drag.ts +382 -0
  116. package/src/tokens.ts +171 -0
  117. package/src/tour/tour-anchors.ts +21 -0
  118. package/src/tour/tour-config.ts +160 -0
  119. package/src/tour/tour-events.ts +34 -0
  120. package/src/tour/tour-overlay.tsx +474 -0
  121. package/src/tour/tour-state.ts +87 -0
  122. package/src/tour/tour-steps.ts +281 -0
  123. package/src/utils.ts +455 -0
  124. package/style/base.css +3238 -0
  125. package/style/icons/cell-toolbar-bug.svg +5 -0
  126. package/style/icons/cell-toolbar-chat.svg +5 -0
  127. package/style/icons/cell-toolbar-sparkle.svg +5 -0
  128. package/style/icons/claude.svg +1 -0
  129. package/style/icons/copilot-warning.svg +1 -0
  130. package/style/icons/copilot.svg +1 -0
  131. package/style/icons/copy.svg +1 -0
  132. package/style/icons/openai.svg +1 -0
  133. package/style/icons/opencode.svg +1 -0
  134. package/style/icons/sparkles-warning.svg +5 -0
  135. package/style/icons/sparkles.svg +1 -0
  136. package/style/index.css +1 -0
  137. package/style/index.js +1 -0
package/src/api.ts ADDED
@@ -0,0 +1,1424 @@
1
+ // Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
2
+
3
+ import { ServerConnection } from '@jupyterlab/services';
4
+ import { requestAPI } from './handler';
5
+ import { URLExt } from '@jupyterlab/coreutils';
6
+ import { Signal } from '@lumino/signaling';
7
+ import {
8
+ GITHUB_COPILOT_PROVIDER_ID,
9
+ IChatCompletionResponseEmitter,
10
+ IChatParticipant,
11
+ IContextItem,
12
+ ITelemetryEvent,
13
+ IToolSelections,
14
+ RequestDataType,
15
+ BackendMessageType,
16
+ AssistantMode
17
+ } from './tokens';
18
+
19
+ export enum GitHubCopilotLoginStatus {
20
+ NotLoggedIn = 'NOT_LOGGED_IN',
21
+ ActivatingDevice = 'ACTIVATING_DEVICE',
22
+ LoggingIn = 'LOGGING_IN',
23
+ LoggedIn = 'LOGGED_IN'
24
+ }
25
+
26
+ export interface IDeviceVerificationInfo {
27
+ verificationURI: string;
28
+ userCode: string;
29
+ }
30
+
31
+ export enum ClaudeModelType {
32
+ None = 'none',
33
+ Inherit = 'inherit',
34
+ Default = ''
35
+ }
36
+
37
+ export interface IClaudeModelInfo {
38
+ id: string;
39
+ name: string;
40
+ contextWindow: number;
41
+ }
42
+
43
+ export interface IClaudeSessionInfo {
44
+ session_id: string;
45
+ path: string;
46
+ modified_at: number;
47
+ created_at: number;
48
+ preview: string;
49
+ cwd: string;
50
+ }
51
+
52
+ export interface IClaudeSessionList {
53
+ sessions: IClaudeSessionInfo[];
54
+ // The realpath-resolved JupyterLab working directory. `claude --resume
55
+ // <id>` is cwd-scoped, so the frontend pairs this with the session id to
56
+ // produce a copyable shell command.
57
+ currentCwd: string;
58
+ }
59
+
60
+ export type ClaudeSessionScope = 'cwd' | 'all';
61
+
62
+ export enum ClaudeToolType {
63
+ ClaudeCodeTools = 'claude-code:built-in-tools',
64
+ JupyterUITools = 'nbi:built-in-jupyter-ui-tools'
65
+ }
66
+
67
+ export type SkillScope = 'user' | 'project';
68
+
69
+ export type ClaudeMCPScope = 'user' | 'project' | 'local';
70
+ export type ClaudeMCPTransport = 'stdio' | 'sse' | 'http';
71
+
72
+ export type PluginScope = 'user' | 'project' | 'local';
73
+
74
+ // Claude's `claude plugin list --json` output schema isn't formally
75
+ // documented; we forward the raw object and let the panel fish out fields
76
+ // it cares about. Defining only the fields we observe today as optional
77
+ // keeps newer Claude releases from getting truncated.
78
+ export interface IPluginInfo {
79
+ id?: string;
80
+ name?: string;
81
+ scope?: PluginScope | string;
82
+ enabled?: boolean;
83
+ marketplace?: string;
84
+ version?: string;
85
+ description?: string;
86
+ [key: string]: unknown;
87
+ }
88
+
89
+ export interface IPluginMarketplaceInfo {
90
+ name?: string;
91
+ source?: string;
92
+ scope?: PluginScope | string;
93
+ description?: string;
94
+ version?: string;
95
+ plugin_count?: number;
96
+ plugin_names?: string[];
97
+ [key: string]: unknown;
98
+ }
99
+
100
+ export interface IPluginMarketplacePluginInfo extends IPluginInfo {
101
+ source?: unknown;
102
+ category?: string;
103
+ tags?: string[];
104
+ }
105
+
106
+ export interface IClaudeMCPServer {
107
+ name: string;
108
+ scope: ClaudeMCPScope;
109
+ transport: ClaudeMCPTransport | string;
110
+ command: string;
111
+ args: string[];
112
+ env: Record<string, string>;
113
+ url: string;
114
+ headers: Record<string, string>;
115
+ disabledForWorkspace: boolean;
116
+ }
117
+
118
+ export interface IClaudeMCPAddInput {
119
+ name: string;
120
+ scope: ClaudeMCPScope;
121
+ transport: ClaudeMCPTransport;
122
+ commandOrUrl: string;
123
+ args?: string[];
124
+ env?: Record<string, string>;
125
+ headers?: Record<string, string>;
126
+ }
127
+
128
+ function claudeMCPServerFromWire(wire: any): IClaudeMCPServer {
129
+ return {
130
+ name: String(wire?.name ?? ''),
131
+ scope: (wire?.scope ?? 'user') as ClaudeMCPScope,
132
+ transport: String(wire?.transport ?? 'stdio'),
133
+ command: String(wire?.command ?? ''),
134
+ args: Array.isArray(wire?.args) ? wire.args.map(String) : [],
135
+ env:
136
+ wire?.env && typeof wire.env === 'object'
137
+ ? Object.fromEntries(
138
+ Object.entries(wire.env).map(([k, v]) => [String(k), String(v)])
139
+ )
140
+ : {},
141
+ url: String(wire?.url ?? ''),
142
+ headers:
143
+ wire?.headers && typeof wire.headers === 'object'
144
+ ? Object.fromEntries(
145
+ Object.entries(wire.headers).map(([k, v]) => [String(k), String(v)])
146
+ )
147
+ : {},
148
+ disabledForWorkspace: Boolean(wire?.disabled_for_workspace)
149
+ };
150
+ }
151
+
152
+ export interface ISkillSummary {
153
+ scope: SkillScope;
154
+ name: string;
155
+ description: string;
156
+ allowedTools: string[];
157
+ rootPath: string;
158
+ files: string[];
159
+ source: string;
160
+ managed: boolean;
161
+ managedSource: string;
162
+ managedRef: string;
163
+ // User-imported GitHub skills that opted into auto-sync. Distinct
164
+ // from `managed`: tracking skills are editable and never auto-removed,
165
+ // only manually replaced when the user clicks Sync.
166
+ tracksUpstream: boolean;
167
+ trackingRef: string;
168
+ }
169
+
170
+ export interface IReconcileResult {
171
+ added: number;
172
+ updated: number;
173
+ removed: number;
174
+ unchanged: number;
175
+ errors: string[];
176
+ }
177
+
178
+ export interface ISkillDetail extends ISkillSummary {
179
+ body: string;
180
+ }
181
+
182
+ export interface ISkillsContext {
183
+ projectRoot: string;
184
+ projectName: string;
185
+ userSkillsDir: string;
186
+ projectSkillsDir: string;
187
+ }
188
+
189
+ export interface ISkillImportPreview {
190
+ name: string;
191
+ description: string;
192
+ allowedTools: string[];
193
+ body: string;
194
+ files: string[];
195
+ sourceUrl: string;
196
+ canonicalUrl: string;
197
+ existsInUserScope: boolean;
198
+ existsInProjectScope: boolean;
199
+ }
200
+
201
+ // Exported for direct testing of the wire-format contract. The snake_case
202
+ // keys it consumes (managed_source, managed_ref, tracks_upstream,
203
+ // tracking_ref, allowed_tools) are the load-bearing JSON shape between
204
+ // the Tornado handlers and the panel; a typo here would silently corrupt
205
+ // user state ("I toggled it on but it didn't stick").
206
+ export function skillFromWire(wire: any): ISkillDetail {
207
+ return {
208
+ scope: wire.scope,
209
+ name: wire.name,
210
+ description: wire.description,
211
+ allowedTools: wire.allowed_tools ?? [],
212
+ rootPath: wire.root_path,
213
+ files: wire.files ?? [],
214
+ source: wire.source ?? '',
215
+ managed: Boolean(wire.managed),
216
+ managedSource: wire.managed_source ?? '',
217
+ managedRef: wire.managed_ref ?? '',
218
+ tracksUpstream: Boolean(wire.tracks_upstream),
219
+ trackingRef: wire.tracking_ref ?? '',
220
+ body: wire.body ?? ''
221
+ };
222
+ }
223
+
224
+ export interface ISyncSkillResult {
225
+ updated: boolean;
226
+ ref: string;
227
+ }
228
+
229
+ export interface ISyncAllTrackingEntry {
230
+ scope: SkillScope;
231
+ name: string;
232
+ updated?: boolean;
233
+ ref?: string;
234
+ error?: string;
235
+ }
236
+
237
+ function claudeModelFromWire(wire: any): IClaudeModelInfo {
238
+ return {
239
+ id: wire.id,
240
+ name: wire.name,
241
+ contextWindow: wire.context_window
242
+ };
243
+ }
244
+
245
+ export interface ICellOutputFeatureFlag {
246
+ enabled: boolean;
247
+ locked: boolean;
248
+ }
249
+
250
+ export interface ICellOutputFeatures {
251
+ explain_error: ICellOutputFeatureFlag;
252
+ output_followup: ICellOutputFeatureFlag;
253
+ output_toolbar: ICellOutputFeatureFlag;
254
+ }
255
+
256
+ // Per-action flags (the whole-toolbar gate `output_toolbar` is checked
257
+ // separately by callers).
258
+ export type CellOutputActionFlag = Exclude<
259
+ keyof ICellOutputFeatures,
260
+ 'output_toolbar'
261
+ >;
262
+
263
+ // Boolean admin policies covering settings panel toggles. Mirrors
264
+ // FEATURE_POLICY_NAMES in extension.py — keep them in sync.
265
+ export type FeaturePolicyName =
266
+ | 'explain_error'
267
+ | 'output_followup'
268
+ | 'output_toolbar'
269
+ | 'claude_mode'
270
+ | 'claude_continue_conversation'
271
+ | 'claude_code_tools'
272
+ | 'claude_jupyter_ui_tools'
273
+ | 'claude_setting_source_user'
274
+ | 'claude_setting_source_project'
275
+ | 'store_github_access_token'
276
+ | 'skills_management'
277
+ | 'claude_mcp_management'
278
+ | 'claude_plugins_management'
279
+ | 'terminal_drag_drop'
280
+ | 'refresh_open_files_on_disk_change';
281
+
282
+ export type IFeaturePolicies = Record<
283
+ FeaturePolicyName,
284
+ ICellOutputFeatureFlag
285
+ >;
286
+
287
+ // Non-boolean settings whose value is locked when an admin sets the
288
+ // corresponding env var. The value itself is served via its existing
289
+ // capabilities field; this only carries the locked flag.
290
+ export type SettingLockName =
291
+ | 'chat_model_provider'
292
+ | 'chat_model_id'
293
+ | 'inline_completion_model_provider'
294
+ | 'inline_completion_model_id'
295
+ | 'claude_chat_model'
296
+ | 'claude_inline_completion_model'
297
+ | 'claude_api_key'
298
+ | 'claude_base_url';
299
+
300
+ export type ISettingLocks = Record<SettingLockName, { locked: boolean }>;
301
+
302
+ // Shared frozen object returned by NBIConfig.tourOverrides when no
303
+ // admin overrides are present. Stable identity matters for downstream
304
+ // consumers (memoized command-palette label, useMemo deps).
305
+ const EMPTY_TOUR_OVERRIDES: Readonly<Record<string, any>> = Object.freeze({});
306
+
307
+ export class NBIConfig {
308
+ get userHomeDir(): string {
309
+ return this.capabilities.user_home_dir;
310
+ }
311
+
312
+ get userConfigDir(): string {
313
+ return this.capabilities.nbi_user_config_dir;
314
+ }
315
+
316
+ get llmProviders(): [any] {
317
+ return this.capabilities.llm_providers;
318
+ }
319
+
320
+ get chatModels(): [any] {
321
+ return this.capabilities.chat_models;
322
+ }
323
+
324
+ get inlineCompletionModels(): [any] {
325
+ return this.capabilities.inline_completion_models;
326
+ }
327
+
328
+ get defaultChatMode(): string {
329
+ return this.capabilities.default_chat_mode;
330
+ }
331
+
332
+ get chatModel(): any {
333
+ return this.capabilities.chat_model;
334
+ }
335
+
336
+ get chatModelSupportsVision(): boolean {
337
+ return this.capabilities.chat_model_supports_vision === true;
338
+ }
339
+
340
+ get inlineCompletionModel(): any {
341
+ return this.capabilities.inline_completion_model;
342
+ }
343
+
344
+ get usingGitHubCopilotModel(): boolean {
345
+ return (
346
+ this.chatModel.provider === GITHUB_COPILOT_PROVIDER_ID ||
347
+ this.inlineCompletionModel.provider === GITHUB_COPILOT_PROVIDER_ID
348
+ );
349
+ }
350
+
351
+ get storeGitHubAccessToken(): boolean {
352
+ return this.capabilities.store_github_access_token === true;
353
+ }
354
+
355
+ get inlineCompletionDebouncerDelay(): number {
356
+ return Number.isInteger(this.capabilities.inline_completion_debouncer_delay)
357
+ ? this.capabilities.inline_completion_debouncer_delay
358
+ : 200;
359
+ }
360
+
361
+ get toolConfig(): any {
362
+ return this.capabilities.tool_config;
363
+ }
364
+
365
+ get mcpServers(): any {
366
+ return this.toolConfig.mcpServers;
367
+ }
368
+
369
+ getMCPServer(serverId: string): any {
370
+ return this.toolConfig.mcpServers.find(
371
+ (server: any) => server.id === serverId
372
+ );
373
+ }
374
+
375
+ getMCPServerPrompt(serverId: string, promptName: string): any {
376
+ const server = this.getMCPServer(serverId);
377
+ if (server) {
378
+ return server.prompts.find((prompt: any) => prompt.name === promptName);
379
+ }
380
+ return null;
381
+ }
382
+
383
+ get mcpServerSettings(): any {
384
+ return this.capabilities.mcp_server_settings;
385
+ }
386
+
387
+ get claudeSettings(): any {
388
+ return this.capabilities.claude_settings;
389
+ }
390
+
391
+ get claudeModels(): IClaudeModelInfo[] {
392
+ return (this.capabilities.claude_models ?? []).map(claudeModelFromWire);
393
+ }
394
+
395
+ get isInClaudeCodeMode(): boolean {
396
+ return this.claudeSettings.enabled === true;
397
+ }
398
+
399
+ get isClaudeCliAvailable(): boolean {
400
+ return this.capabilities.claude_cli_available === true;
401
+ }
402
+
403
+ get isOpenCodeCliAvailable(): boolean {
404
+ return this.capabilities.opencode_cli_available === true;
405
+ }
406
+
407
+ get isPiCliAvailable(): boolean {
408
+ return this.capabilities.pi_cli_available === true;
409
+ }
410
+
411
+ get isGitHubCopilotCliAvailable(): boolean {
412
+ return this.capabilities.github_copilot_cli_available === true;
413
+ }
414
+
415
+ get isCodexCliAvailable(): boolean {
416
+ return this.capabilities.codex_cli_available === true;
417
+ }
418
+
419
+ isCodingAgentLauncherDisabledByPolicy(launcherId: string): boolean {
420
+ // Fail closed when the field is missing or malformed: an admin denylist
421
+ // must not silently disappear if capabilities haven't loaded yet or a
422
+ // backend regression drops the field. The companion `is*CliAvailable`
423
+ // getters already default to false until capabilities arrive, so on
424
+ // first paint the tile is hidden regardless; this just ensures the
425
+ // policy gate stays in effect even if a future change pre-seeds those
426
+ // flags.
427
+ const list = this.capabilities.disabled_coding_agent_launchers;
428
+ if (Array.isArray(list)) {
429
+ return list.includes(launcherId);
430
+ }
431
+ return true;
432
+ }
433
+
434
+ get chatFeedbackEnabled(): boolean {
435
+ return this.capabilities.chat_feedback_enabled === true;
436
+ }
437
+
438
+ // Admin-supplied tour-copy overrides, served from the capabilities
439
+ // response after server-side validation. Returns the raw dict; the
440
+ // tour module decides how to apply it. Defaults to a shared frozen
441
+ // empty object so callers can spread/access keys without
442
+ // null-checking AND the getter doesn't allocate a fresh `{}` on every
443
+ // read (the JupyterLab command palette polls a command's label
444
+ // thunk on every keystroke, so identity stability matters).
445
+ get tourOverrides(): Record<string, any> {
446
+ const v = this.capabilities.tour_overrides;
447
+ return v && typeof v === 'object' ? v : EMPTY_TOUR_OVERRIDES;
448
+ }
449
+
450
+ get allowGithubSkillImport(): boolean {
451
+ return this.capabilities.allow_github_skill_import !== false;
452
+ }
453
+
454
+ get additionalSkippedWorkspaceDirectories(): string[] {
455
+ const v = this.capabilities.additional_skipped_workspace_directories;
456
+ return Array.isArray(v) ? v : [];
457
+ }
458
+
459
+ get allowGithubPluginImport(): boolean {
460
+ // Default-open: missing/undefined means the org hasn't gated this, so
461
+ // older backends without the flag continue to allow the GitHub
462
+ // affordance. Mirrors `cellOutputFeatures` polarity.
463
+ return this.capabilities.allow_github_plugin_import !== false;
464
+ }
465
+
466
+ get cellOutputFeatures(): ICellOutputFeatures {
467
+ const v = this.capabilities.cell_output_features ?? {};
468
+ return {
469
+ explain_error: {
470
+ enabled: v.explain_error?.enabled !== false,
471
+ locked: v.explain_error?.locked === true
472
+ },
473
+ output_followup: {
474
+ enabled: v.output_followup?.enabled !== false,
475
+ locked: v.output_followup?.locked === true
476
+ },
477
+ output_toolbar: {
478
+ enabled: v.output_toolbar?.enabled !== false,
479
+ locked: v.output_toolbar?.locked === true
480
+ }
481
+ };
482
+ }
483
+
484
+ get featurePolicies(): IFeaturePolicies {
485
+ const v = this.capabilities.feature_policies ?? {};
486
+ const names: FeaturePolicyName[] = [
487
+ 'explain_error',
488
+ 'output_followup',
489
+ 'output_toolbar',
490
+ 'claude_mode',
491
+ 'claude_continue_conversation',
492
+ 'claude_code_tools',
493
+ 'claude_jupyter_ui_tools',
494
+ 'claude_setting_source_user',
495
+ 'claude_setting_source_project',
496
+ 'store_github_access_token',
497
+ 'skills_management',
498
+ 'claude_mcp_management',
499
+ 'claude_plugins_management',
500
+ 'terminal_drag_drop',
501
+ 'refresh_open_files_on_disk_change'
502
+ ];
503
+ // Policies that default *open* when the capability field is missing,
504
+ // covering two cases: admin-only management gates (no user toggle) where
505
+ // a new frontend on an older backend must keep the tab visible, and the
506
+ // open-files refresh watcher whose documented default is on so its
507
+ // first ticks before capabilities land don't silently no-op. The other
508
+ // policies pair with a user toggle and default closed (missing means
509
+ // "no user pref recorded yet, treat as off").
510
+ const defaultOpen: ReadonlySet<FeaturePolicyName> = new Set([
511
+ 'skills_management',
512
+ 'claude_mcp_management',
513
+ 'claude_plugins_management',
514
+ 'refresh_open_files_on_disk_change'
515
+ ]);
516
+ const result = {} as IFeaturePolicies;
517
+ for (const name of names) {
518
+ const entry = v[name];
519
+ // Strict polarity: only default-open when the entry is wholly absent
520
+ // (old backend). A malformed entry (string "false", null, missing
521
+ // `enabled` field) falls through to closed for default-closed gates
522
+ // and stays open only when the field is explicitly true for default-open
523
+ // gates — never silently land in the open bucket.
524
+ let enabled: boolean;
525
+ if (entry === undefined) {
526
+ enabled = defaultOpen.has(name);
527
+ } else {
528
+ enabled = entry.enabled === true;
529
+ }
530
+ result[name] = {
531
+ enabled,
532
+ locked: entry?.locked === true
533
+ };
534
+ }
535
+ return result;
536
+ }
537
+
538
+ get settingLocks(): ISettingLocks {
539
+ const v = this.capabilities.setting_locks ?? {};
540
+ const names: SettingLockName[] = [
541
+ 'chat_model_provider',
542
+ 'chat_model_id',
543
+ 'inline_completion_model_provider',
544
+ 'inline_completion_model_id',
545
+ 'claude_chat_model',
546
+ 'claude_inline_completion_model',
547
+ 'claude_api_key',
548
+ 'claude_base_url'
549
+ ];
550
+ const result = {} as ISettingLocks;
551
+ for (const name of names) {
552
+ result[name] = { locked: v[name]?.locked === true };
553
+ }
554
+ return result;
555
+ }
556
+
557
+ capabilities: any = {};
558
+ chatParticipants: IChatParticipant[] = [];
559
+
560
+ changed = new Signal<this, void>(this);
561
+ }
562
+
563
+ export class NBIAPI {
564
+ static _loginStatus = GitHubCopilotLoginStatus.NotLoggedIn;
565
+ static _deviceVerificationInfo: IDeviceVerificationInfo = {
566
+ verificationURI: '',
567
+ userCode: ''
568
+ };
569
+ static _webSocket: WebSocket;
570
+ static _messageReceived = new Signal<unknown, any>(this);
571
+ static config = new NBIConfig();
572
+ static configChanged = this.config.changed;
573
+ static githubLoginStatusChanged = new Signal<unknown, void>(this);
574
+ static skillsReloaded = new Signal<unknown, void>(this);
575
+ // Emits each time the Claude agent sends its 20s keepalive (#252 follow-up).
576
+ // The chat sidebar uses it to drive the "Generating" indicator's pulse
577
+ // and to swap to a "server may be slow" copy when the gap stretches.
578
+ static claudeCodeHeartbeat = new Signal<unknown, void>(this);
579
+
580
+ static async initialize() {
581
+ await this.fetchCapabilities();
582
+ this.updateGitHubLoginStatus();
583
+
584
+ NBIAPI.initializeWebsocket();
585
+
586
+ this._messageReceived.connect((_, msg) => {
587
+ msg = JSON.parse(msg);
588
+ if (
589
+ msg.type === BackendMessageType.MCPServerStatusChange ||
590
+ msg.type === BackendMessageType.ClaudeCodeStatusChange
591
+ ) {
592
+ this.fetchCapabilities();
593
+ } else if (
594
+ msg.type === BackendMessageType.GitHubCopilotLoginStatusChange
595
+ ) {
596
+ // The Copilot chat-model catalogue is fetched lazily once the bearer
597
+ // token is minted (issue #258), so the model dropdown depends on a
598
+ // capabilities refresh in addition to the login-status update.
599
+ Promise.all([
600
+ this.updateGitHubLoginStatus(),
601
+ this.fetchCapabilities()
602
+ ]).then(() => {
603
+ this.githubLoginStatusChanged.emit();
604
+ });
605
+ } else if (msg.type === BackendMessageType.SkillsReloaded) {
606
+ this.skillsReloaded.emit();
607
+ } else if (msg.type === BackendMessageType.ClaudeCodeHeartbeat) {
608
+ this.claudeCodeHeartbeat.emit();
609
+ }
610
+ });
611
+ }
612
+
613
+ static async initializeWebsocket() {
614
+ const serverSettings = ServerConnection.makeSettings();
615
+ const wsUrl = URLExt.join(
616
+ serverSettings.wsUrl,
617
+ 'notebook-intelligence',
618
+ 'copilot'
619
+ );
620
+
621
+ this._webSocket = new serverSettings.WebSocket(wsUrl);
622
+ this._webSocket.onmessage = msg => {
623
+ this._messageReceived.emit(msg.data);
624
+ };
625
+
626
+ this._webSocket.onerror = msg => {
627
+ console.error(`Websocket error: ${msg}. Closing...`);
628
+ this._webSocket.close();
629
+ };
630
+
631
+ this._webSocket.onclose = msg => {
632
+ console.log(`Websocket is closed: ${msg.reason}. Reconnecting...`);
633
+ setTimeout(() => {
634
+ NBIAPI.initializeWebsocket();
635
+ }, 1000);
636
+ };
637
+ }
638
+
639
+ static getLoginStatus(): GitHubCopilotLoginStatus {
640
+ return this._loginStatus;
641
+ }
642
+
643
+ static getDeviceVerificationInfo(): IDeviceVerificationInfo {
644
+ return this._deviceVerificationInfo;
645
+ }
646
+
647
+ static getGHLoginRequired() {
648
+ return (
649
+ this.config.usingGitHubCopilotModel &&
650
+ NBIAPI.getLoginStatus() === GitHubCopilotLoginStatus.NotLoggedIn
651
+ );
652
+ }
653
+
654
+ static getChatEnabled() {
655
+ return (
656
+ this.config.isInClaudeCodeMode ||
657
+ (this.config.chatModel.provider === GITHUB_COPILOT_PROVIDER_ID
658
+ ? !this.getGHLoginRequired()
659
+ : this.config.llmProviders.find(
660
+ provider => provider.id === this.config.chatModel.provider
661
+ ))
662
+ );
663
+ }
664
+
665
+ static getInlineCompletionEnabled() {
666
+ return (
667
+ this.config.isInClaudeCodeMode ||
668
+ (this.config.inlineCompletionModel.provider === GITHUB_COPILOT_PROVIDER_ID
669
+ ? !this.getGHLoginRequired()
670
+ : this.config.llmProviders.find(
671
+ provider =>
672
+ provider.id === this.config.inlineCompletionModel.provider
673
+ ))
674
+ );
675
+ }
676
+
677
+ static async loginToGitHub() {
678
+ this._loginStatus = GitHubCopilotLoginStatus.ActivatingDevice;
679
+ return new Promise((resolve, reject) => {
680
+ requestAPI<any>('gh-login', { method: 'POST' })
681
+ .then(data => {
682
+ resolve({
683
+ verificationURI: data.verification_uri,
684
+ userCode: data.user_code
685
+ });
686
+ this.updateGitHubLoginStatus();
687
+ })
688
+ .catch(reason => {
689
+ console.error(`Failed to login to GitHub Copilot.\n${reason}`);
690
+ reject(reason);
691
+ });
692
+ });
693
+ }
694
+
695
+ static async logoutFromGitHub() {
696
+ this._loginStatus = GitHubCopilotLoginStatus.ActivatingDevice;
697
+ return new Promise((resolve, reject) => {
698
+ requestAPI<any>('gh-logout', { method: 'GET' })
699
+ .then(data => {
700
+ this.updateGitHubLoginStatus().then(() => {
701
+ resolve(data);
702
+ });
703
+ })
704
+ .catch(reason => {
705
+ console.error(`Failed to logout from GitHub Copilot.\n${reason}`);
706
+ reject(reason);
707
+ });
708
+ });
709
+ }
710
+
711
+ static async updateGitHubLoginStatus() {
712
+ return new Promise<void>((resolve, reject) => {
713
+ requestAPI<any>('gh-login-status')
714
+ .then(response => {
715
+ this._loginStatus = response.status;
716
+ this._deviceVerificationInfo.verificationURI =
717
+ response.verification_uri || '';
718
+ this._deviceVerificationInfo.userCode = response.user_code || '';
719
+ resolve();
720
+ })
721
+ .catch(reason => {
722
+ console.error(
723
+ `Failed to fetch GitHub Copilot login status.\n${reason}`
724
+ );
725
+ reject(reason);
726
+ });
727
+ });
728
+ }
729
+
730
+ static async fetchCapabilities(): Promise<void> {
731
+ return new Promise<void>((resolve, reject) => {
732
+ requestAPI<any>('capabilities', { method: 'GET' })
733
+ .then(data => {
734
+ const oldConfig = {
735
+ capabilities: structuredClone(this.config.capabilities),
736
+ chatParticipants: structuredClone(this.config.chatParticipants)
737
+ };
738
+ this.config.capabilities = structuredClone(data);
739
+ this.config.chatParticipants = structuredClone(
740
+ data.chat_participants
741
+ );
742
+ const newConfig = {
743
+ capabilities: structuredClone(this.config.capabilities),
744
+ chatParticipants: structuredClone(this.config.chatParticipants)
745
+ };
746
+ if (JSON.stringify(newConfig) !== JSON.stringify(oldConfig)) {
747
+ this.configChanged.emit();
748
+ }
749
+ resolve();
750
+ })
751
+ .catch(reason => {
752
+ console.error(`Failed to get extension capabilities.\n${reason}`);
753
+ reject(reason);
754
+ });
755
+ });
756
+ }
757
+
758
+ static async setConfig(config: any) {
759
+ requestAPI<any>('config', {
760
+ method: 'POST',
761
+ body: JSON.stringify(config)
762
+ })
763
+ .then(data => {
764
+ NBIAPI.fetchCapabilities();
765
+ })
766
+ .catch(reason => {
767
+ console.error(`Failed to set NBI config.\n${reason}`);
768
+ });
769
+ }
770
+
771
+ static async updateOllamaModelList(): Promise<void> {
772
+ return new Promise<void>((resolve, reject) => {
773
+ requestAPI<any>('update-provider-models', {
774
+ method: 'POST',
775
+ body: JSON.stringify({ provider: 'ollama' })
776
+ })
777
+ .then(async data => {
778
+ await NBIAPI.fetchCapabilities();
779
+ resolve();
780
+ })
781
+ .catch(reason => {
782
+ console.error(`Failed to update ollama model list.\n${reason}`);
783
+ reject(reason);
784
+ });
785
+ });
786
+ }
787
+
788
+ static async updateClaudeModelList(): Promise<void> {
789
+ return new Promise<void>((resolve, reject) => {
790
+ requestAPI<any>('update-provider-models', {
791
+ method: 'POST',
792
+ body: JSON.stringify({ provider: 'claude' })
793
+ })
794
+ .then(async data => {
795
+ await NBIAPI.fetchCapabilities();
796
+ resolve();
797
+ })
798
+ .catch(reason => {
799
+ console.error(`Failed to update Claude model list.\n${reason}`);
800
+ reject(reason);
801
+ });
802
+ });
803
+ }
804
+
805
+ static async getMCPConfigFile(): Promise<any> {
806
+ return new Promise<any>((resolve, reject) => {
807
+ requestAPI<any>('mcp-config-file', { method: 'GET' })
808
+ .then(async data => {
809
+ resolve(data);
810
+ })
811
+ .catch(reason => {
812
+ console.error(`Failed to get MCP config file.\n${reason}`);
813
+ reject(reason);
814
+ });
815
+ });
816
+ }
817
+
818
+ static async setMCPConfigFile(config: any): Promise<any> {
819
+ return new Promise<any>((resolve, reject) => {
820
+ requestAPI<any>('mcp-config-file', {
821
+ method: 'POST',
822
+ body: JSON.stringify(config)
823
+ })
824
+ .then(async data => {
825
+ resolve(data);
826
+ })
827
+ .catch(reason => {
828
+ console.error(`Failed to set MCP config file.\n${reason}`);
829
+ reject(reason);
830
+ });
831
+ });
832
+ }
833
+
834
+ static async listSkills(): Promise<ISkillSummary[]> {
835
+ const data = await requestAPI<any>('skills', { method: 'GET' });
836
+ return (data.skills ?? []).map(skillFromWire);
837
+ }
838
+
839
+ static async getSkillsContext(): Promise<ISkillsContext> {
840
+ const data = await requestAPI<any>('skills/context', { method: 'GET' });
841
+ return {
842
+ projectRoot: data.project_root ?? '',
843
+ projectName: data.project_name ?? '',
844
+ userSkillsDir: data.user_skills_dir ?? '',
845
+ projectSkillsDir: data.project_skills_dir ?? ''
846
+ };
847
+ }
848
+
849
+ static async readSkill(
850
+ scope: SkillScope,
851
+ name: string
852
+ ): Promise<ISkillDetail> {
853
+ const data = await requestAPI<any>(
854
+ `skills/${scope}/${encodeURIComponent(name)}`,
855
+ { method: 'GET' }
856
+ );
857
+ return skillFromWire(data.skill);
858
+ }
859
+
860
+ static async createSkill(payload: {
861
+ scope: SkillScope;
862
+ name: string;
863
+ description: string;
864
+ allowedTools: string[];
865
+ body: string;
866
+ }): Promise<ISkillDetail> {
867
+ const data = await requestAPI<any>('skills', {
868
+ method: 'POST',
869
+ body: JSON.stringify({
870
+ scope: payload.scope,
871
+ name: payload.name,
872
+ description: payload.description,
873
+ allowed_tools: payload.allowedTools,
874
+ body: payload.body
875
+ })
876
+ });
877
+ return skillFromWire(data.skill);
878
+ }
879
+
880
+ static async updateSkill(
881
+ scope: SkillScope,
882
+ name: string,
883
+ payload: {
884
+ description?: string;
885
+ allowedTools?: string[];
886
+ body?: string;
887
+ tracksUpstream?: boolean;
888
+ }
889
+ ): Promise<ISkillDetail> {
890
+ const wire: any = {};
891
+ if (payload.description !== undefined) {
892
+ wire.description = payload.description;
893
+ }
894
+ if (payload.allowedTools !== undefined) {
895
+ wire.allowed_tools = payload.allowedTools;
896
+ }
897
+ if (payload.body !== undefined) {
898
+ wire.body = payload.body;
899
+ }
900
+ if (payload.tracksUpstream !== undefined) {
901
+ wire.tracks_upstream = payload.tracksUpstream;
902
+ }
903
+ const data = await requestAPI<any>(
904
+ `skills/${scope}/${encodeURIComponent(name)}`,
905
+ {
906
+ method: 'PUT',
907
+ body: JSON.stringify(wire)
908
+ }
909
+ );
910
+ return skillFromWire(data.skill);
911
+ }
912
+
913
+ static async deleteSkill(scope: SkillScope, name: string): Promise<void> {
914
+ await requestAPI<any>(`skills/${scope}/${encodeURIComponent(name)}`, {
915
+ method: 'DELETE'
916
+ });
917
+ }
918
+
919
+ static async previewSkillImport(url: string): Promise<ISkillImportPreview> {
920
+ const data = await requestAPI<any>('skills/import/preview', {
921
+ method: 'POST',
922
+ body: JSON.stringify({ url })
923
+ });
924
+ const p = data.preview;
925
+ return {
926
+ name: p.name,
927
+ description: p.description ?? '',
928
+ allowedTools: p.allowed_tools ?? [],
929
+ body: p.body ?? '',
930
+ files: p.files ?? [],
931
+ sourceUrl: p.source_url ?? '',
932
+ canonicalUrl: p.canonical_url ?? '',
933
+ existsInUserScope: p.exists_in_user_scope === true,
934
+ existsInProjectScope: p.exists_in_project_scope === true
935
+ };
936
+ }
937
+
938
+ static async importSkill(payload: {
939
+ url: string;
940
+ scope: SkillScope;
941
+ name?: string;
942
+ overwrite?: boolean;
943
+ tracksUpstream?: boolean;
944
+ }): Promise<ISkillDetail> {
945
+ const wire: any = { url: payload.url, scope: payload.scope };
946
+ if (payload.name) {
947
+ wire.name = payload.name;
948
+ }
949
+ if (payload.overwrite) {
950
+ wire.overwrite = true;
951
+ }
952
+ if (payload.tracksUpstream) {
953
+ wire.tracks_upstream = true;
954
+ }
955
+ const data = await requestAPI<any>('skills/import', {
956
+ method: 'POST',
957
+ body: JSON.stringify(wire)
958
+ });
959
+ return skillFromWire(data.skill);
960
+ }
961
+
962
+ static async syncTrackingSkill(
963
+ scope: SkillScope,
964
+ name: string
965
+ ): Promise<ISyncSkillResult> {
966
+ const data = await requestAPI<any>(
967
+ `skills/${scope}/${encodeURIComponent(name)}/sync`,
968
+ { method: 'POST' }
969
+ );
970
+ return {
971
+ updated: Boolean(data.updated),
972
+ ref: data.ref ?? ''
973
+ };
974
+ }
975
+
976
+ static async syncAllTrackingSkills(): Promise<ISyncAllTrackingEntry[]> {
977
+ const data = await requestAPI<any>('skills/sync-all-tracking', {
978
+ method: 'POST'
979
+ });
980
+ if (!Array.isArray(data?.results)) {
981
+ return [];
982
+ }
983
+ return data.results.map((r: any) => ({
984
+ scope: r.scope,
985
+ name: r.name,
986
+ updated: typeof r.updated === 'boolean' ? r.updated : undefined,
987
+ ref: typeof r.ref === 'string' ? r.ref : undefined,
988
+ error: typeof r.error === 'string' ? r.error : undefined
989
+ }));
990
+ }
991
+
992
+ static async listClaudeMCPServers(): Promise<IClaudeMCPServer[]> {
993
+ const data = await requestAPI<any>('claude-mcp');
994
+ return Array.isArray(data?.servers)
995
+ ? data.servers.map(claudeMCPServerFromWire)
996
+ : [];
997
+ }
998
+
999
+ static async addClaudeMCPServer(
1000
+ input: IClaudeMCPAddInput
1001
+ ): Promise<IClaudeMCPServer> {
1002
+ const body: any = {
1003
+ name: input.name,
1004
+ scope: input.scope,
1005
+ transport: input.transport,
1006
+ command_or_url: input.commandOrUrl
1007
+ };
1008
+ if (input.args && input.args.length) {
1009
+ body.args = input.args;
1010
+ }
1011
+ if (input.env && Object.keys(input.env).length) {
1012
+ body.env = input.env;
1013
+ }
1014
+ if (input.headers && Object.keys(input.headers).length) {
1015
+ body.headers = input.headers;
1016
+ }
1017
+ const data = await requestAPI<any>('claude-mcp', {
1018
+ method: 'POST',
1019
+ body: JSON.stringify(body)
1020
+ });
1021
+ return claudeMCPServerFromWire(data.server);
1022
+ }
1023
+
1024
+ static async removeClaudeMCPServer(
1025
+ name: string,
1026
+ scope: ClaudeMCPScope
1027
+ ): Promise<void> {
1028
+ await requestAPI<any>(`claude-mcp/${scope}/${encodeURIComponent(name)}`, {
1029
+ method: 'DELETE'
1030
+ });
1031
+ }
1032
+
1033
+ static async setClaudeMCPServerDisabled(
1034
+ name: string,
1035
+ scope: ClaudeMCPScope,
1036
+ disabled: boolean
1037
+ ): Promise<IClaudeMCPServer> {
1038
+ const data = await requestAPI<any>(
1039
+ `claude-mcp/${scope}/${encodeURIComponent(name)}`,
1040
+ {
1041
+ method: 'PATCH',
1042
+ body: JSON.stringify({ disabled_for_workspace: disabled })
1043
+ }
1044
+ );
1045
+ return claudeMCPServerFromWire(data.server);
1046
+ }
1047
+
1048
+ static async listPlugins(): Promise<IPluginInfo[]> {
1049
+ const data = await requestAPI<any>('plugins');
1050
+ return Array.isArray(data?.plugins) ? (data.plugins as IPluginInfo[]) : [];
1051
+ }
1052
+
1053
+ static async installPlugin(
1054
+ plugin: string,
1055
+ scope: PluginScope = 'user'
1056
+ ): Promise<void> {
1057
+ await requestAPI<any>('plugins', {
1058
+ method: 'POST',
1059
+ body: JSON.stringify({ plugin, scope })
1060
+ });
1061
+ }
1062
+
1063
+ static async uninstallPlugin(
1064
+ plugin: string,
1065
+ scope: PluginScope = 'user'
1066
+ ): Promise<void> {
1067
+ await requestAPI<any>(`plugins/${scope}/${encodeURIComponent(plugin)}`, {
1068
+ method: 'DELETE'
1069
+ });
1070
+ }
1071
+
1072
+ static async setPluginEnabled(
1073
+ plugin: string,
1074
+ scope: PluginScope,
1075
+ enabled: boolean
1076
+ ): Promise<void> {
1077
+ await requestAPI<any>(`plugins/${scope}/${encodeURIComponent(plugin)}`, {
1078
+ method: 'POST',
1079
+ body: JSON.stringify({ action: enabled ? 'enable' : 'disable' })
1080
+ });
1081
+ }
1082
+
1083
+ static async listPluginMarketplaces(): Promise<IPluginMarketplaceInfo[]> {
1084
+ const data = await requestAPI<any>('plugins/marketplace');
1085
+ return Array.isArray(data?.marketplaces)
1086
+ ? (data.marketplaces as IPluginMarketplaceInfo[])
1087
+ : [];
1088
+ }
1089
+
1090
+ static async listPluginMarketplacePlugins(
1091
+ marketplace: string
1092
+ ): Promise<IPluginMarketplacePluginInfo[]> {
1093
+ const data = await requestAPI<any>(
1094
+ `plugins/marketplace/${encodeURIComponent(marketplace)}/plugins`
1095
+ );
1096
+ return Array.isArray(data?.plugins)
1097
+ ? (data.plugins as IPluginMarketplacePluginInfo[])
1098
+ : [];
1099
+ }
1100
+
1101
+ static async addPluginMarketplace(
1102
+ source: string,
1103
+ scope: PluginScope = 'user'
1104
+ ): Promise<void> {
1105
+ await requestAPI<any>('plugins/marketplace', {
1106
+ method: 'POST',
1107
+ body: JSON.stringify({ source, scope })
1108
+ });
1109
+ }
1110
+
1111
+ static async removePluginMarketplace(name: string): Promise<void> {
1112
+ await requestAPI<any>(`plugins/marketplace/${encodeURIComponent(name)}`, {
1113
+ method: 'DELETE'
1114
+ });
1115
+ }
1116
+
1117
+ static async updatePluginMarketplace(name: string): Promise<void> {
1118
+ await requestAPI<any>(
1119
+ `plugins/marketplace/${encodeURIComponent(name)}/update`,
1120
+ {
1121
+ method: 'POST',
1122
+ body: '{}'
1123
+ }
1124
+ );
1125
+ }
1126
+
1127
+ static async reconcileManagedSkills(): Promise<IReconcileResult> {
1128
+ const data = await requestAPI<any>('skills/reconcile', {
1129
+ method: 'POST'
1130
+ });
1131
+ return {
1132
+ added: Number(data.added ?? 0),
1133
+ updated: Number(data.updated ?? 0),
1134
+ removed: Number(data.removed ?? 0),
1135
+ unchanged: Number(data.unchanged ?? 0),
1136
+ errors: Array.isArray(data.errors) ? data.errors.map(String) : []
1137
+ };
1138
+ }
1139
+
1140
+ static async renameSkill(
1141
+ scope: SkillScope,
1142
+ name: string,
1143
+ newName: string
1144
+ ): Promise<ISkillDetail> {
1145
+ const data = await requestAPI<any>(
1146
+ `skills/${scope}/${encodeURIComponent(name)}/rename`,
1147
+ {
1148
+ method: 'POST',
1149
+ body: JSON.stringify({ new_name: newName })
1150
+ }
1151
+ );
1152
+ return skillFromWire(data.skill);
1153
+ }
1154
+
1155
+ static async readBundleFile(
1156
+ scope: SkillScope,
1157
+ name: string,
1158
+ path: string
1159
+ ): Promise<string> {
1160
+ const data = await requestAPI<any>(
1161
+ `skills/${scope}/${encodeURIComponent(name)}/files?path=${encodeURIComponent(path)}`,
1162
+ { method: 'GET' }
1163
+ );
1164
+ return data.content;
1165
+ }
1166
+
1167
+ static async writeBundleFile(
1168
+ scope: SkillScope,
1169
+ name: string,
1170
+ path: string,
1171
+ content: string
1172
+ ): Promise<void> {
1173
+ await requestAPI<any>(
1174
+ `skills/${scope}/${encodeURIComponent(name)}/files?path=${encodeURIComponent(path)}`,
1175
+ {
1176
+ method: 'PUT',
1177
+ body: JSON.stringify({ content })
1178
+ }
1179
+ );
1180
+ }
1181
+
1182
+ static async deleteBundleFile(
1183
+ scope: SkillScope,
1184
+ name: string,
1185
+ path: string
1186
+ ): Promise<void> {
1187
+ await requestAPI<any>(
1188
+ `skills/${scope}/${encodeURIComponent(name)}/files?path=${encodeURIComponent(path)}`,
1189
+ { method: 'DELETE' }
1190
+ );
1191
+ }
1192
+
1193
+ static async renameBundleFile(
1194
+ scope: SkillScope,
1195
+ name: string,
1196
+ from: string,
1197
+ to: string
1198
+ ): Promise<void> {
1199
+ await requestAPI<any>(
1200
+ `skills/${scope}/${encodeURIComponent(name)}/files/rename`,
1201
+ {
1202
+ method: 'POST',
1203
+ body: JSON.stringify({ from, to })
1204
+ }
1205
+ );
1206
+ }
1207
+
1208
+ /**
1209
+ * Subscribe to inbound websocket messages for a single request, forwarding
1210
+ * them to `responseEmitter`. The subscription auto-disconnects when the
1211
+ * server emits StreamEnd, preventing per-request listener accumulation.
1212
+ */
1213
+ private static _subscribeUntilStreamEnd(
1214
+ messageId: string,
1215
+ responseEmitter: IChatCompletionResponseEmitter
1216
+ ): void {
1217
+ const handler = (_: unknown, msg: any) => {
1218
+ const parsed = JSON.parse(msg);
1219
+ if (parsed.id !== messageId) {
1220
+ return;
1221
+ }
1222
+ responseEmitter.emit(parsed);
1223
+ if (parsed.type === BackendMessageType.StreamEnd) {
1224
+ this._messageReceived.disconnect(handler);
1225
+ }
1226
+ };
1227
+ this._messageReceived.connect(handler);
1228
+ }
1229
+
1230
+ static async chatRequest(
1231
+ messageId: string,
1232
+ chatId: string,
1233
+ prompt: string,
1234
+ language: string,
1235
+ currentDirectory: string,
1236
+ filename: string,
1237
+ additionalContext: IContextItem[],
1238
+ chatMode: string,
1239
+ toolSelections: IToolSelections,
1240
+ responseEmitter: IChatCompletionResponseEmitter
1241
+ ) {
1242
+ this._subscribeUntilStreamEnd(messageId, responseEmitter);
1243
+ this._webSocket.send(
1244
+ JSON.stringify({
1245
+ id: messageId,
1246
+ type: RequestDataType.ChatRequest,
1247
+ data: {
1248
+ chatId,
1249
+ prompt,
1250
+ language,
1251
+ currentDirectory,
1252
+ filename,
1253
+ additionalContext,
1254
+ chatMode,
1255
+ toolSelections
1256
+ }
1257
+ })
1258
+ );
1259
+ }
1260
+
1261
+ static async reloadMCPServers(): Promise<any> {
1262
+ return new Promise<any>((resolve, reject) => {
1263
+ requestAPI<any>('reload-mcp-servers', { method: 'POST' })
1264
+ .then(async data => {
1265
+ await NBIAPI.fetchCapabilities();
1266
+ resolve(data);
1267
+ })
1268
+ .catch(reason => {
1269
+ console.error(`Failed to reload MCP servers.\n${reason}`);
1270
+ reject(reason);
1271
+ });
1272
+ });
1273
+ }
1274
+
1275
+ static async generateCode(
1276
+ messageId: string,
1277
+ chatId: string,
1278
+ prompt: string,
1279
+ prefix: string,
1280
+ suffix: string,
1281
+ existingCode: string,
1282
+ language: string,
1283
+ filename: string,
1284
+ responseEmitter: IChatCompletionResponseEmitter
1285
+ ) {
1286
+ this._subscribeUntilStreamEnd(messageId, responseEmitter);
1287
+ this._webSocket.send(
1288
+ JSON.stringify({
1289
+ id: messageId,
1290
+ type: RequestDataType.GenerateCode,
1291
+ data: {
1292
+ chatId,
1293
+ prompt,
1294
+ prefix,
1295
+ suffix,
1296
+ existingCode,
1297
+ language,
1298
+ filename
1299
+ }
1300
+ })
1301
+ );
1302
+ }
1303
+
1304
+ static async sendChatUserInput(messageId: string, data: any) {
1305
+ this._webSocket.send(
1306
+ JSON.stringify({
1307
+ id: messageId,
1308
+ type: RequestDataType.ChatUserInput,
1309
+ data
1310
+ })
1311
+ );
1312
+ }
1313
+
1314
+ static async sendWebSocketMessage(
1315
+ messageId: string,
1316
+ messageType: RequestDataType,
1317
+ data: any
1318
+ ) {
1319
+ this._webSocket.send(
1320
+ JSON.stringify({ id: messageId, type: messageType, data })
1321
+ );
1322
+ }
1323
+
1324
+ static async inlineCompletionsRequest(
1325
+ chatId: string,
1326
+ messageId: string,
1327
+ prefix: string,
1328
+ suffix: string,
1329
+ language: string,
1330
+ filename: string,
1331
+ responseEmitter: IChatCompletionResponseEmitter
1332
+ ) {
1333
+ this._subscribeUntilStreamEnd(messageId, responseEmitter);
1334
+ this._webSocket.send(
1335
+ JSON.stringify({
1336
+ id: messageId,
1337
+ type: RequestDataType.InlineCompletionRequest,
1338
+ data: {
1339
+ chatId,
1340
+ prefix,
1341
+ suffix,
1342
+ language,
1343
+ filename
1344
+ }
1345
+ })
1346
+ );
1347
+ }
1348
+
1349
+ static async uploadFile(
1350
+ file: File
1351
+ ): Promise<{ serverPath: string; filename: string }> {
1352
+ const formData = new FormData();
1353
+ formData.append('file', file, file.name);
1354
+ return requestAPI<{ serverPath: string; filename: string }>('upload-file', {
1355
+ method: 'POST',
1356
+ body: formData
1357
+ });
1358
+ }
1359
+
1360
+ static async listClaudeSessions(
1361
+ scope: ClaudeSessionScope = 'all'
1362
+ ): Promise<IClaudeSessionList> {
1363
+ interface IWireResponse {
1364
+ sessions?: IClaudeSessionInfo[];
1365
+ current_cwd?: string;
1366
+ }
1367
+ return new Promise<IClaudeSessionList>((resolve, reject) => {
1368
+ requestAPI<IWireResponse>(`claude-sessions?scope=${scope}`, {
1369
+ method: 'GET'
1370
+ })
1371
+ .then(data => {
1372
+ resolve({
1373
+ sessions: data.sessions ?? [],
1374
+ currentCwd: data.current_cwd ?? ''
1375
+ });
1376
+ })
1377
+ .catch(reason => {
1378
+ console.error(`Failed to list Claude sessions.\n${reason}`);
1379
+ reject(reason);
1380
+ });
1381
+ });
1382
+ }
1383
+
1384
+ static async resumeClaudeSession(sessionId: string): Promise<void> {
1385
+ return new Promise<void>((resolve, reject) => {
1386
+ requestAPI<any>('claude-sessions/resume', {
1387
+ method: 'POST',
1388
+ body: JSON.stringify({ session_id: sessionId })
1389
+ })
1390
+ .then(() => {
1391
+ resolve();
1392
+ })
1393
+ .catch(reason => {
1394
+ console.error(`Failed to resume Claude session.\n${reason}`);
1395
+ reject(reason);
1396
+ });
1397
+ });
1398
+ }
1399
+
1400
+ static async emitTelemetryEvent(event: ITelemetryEvent): Promise<void> {
1401
+ const assistantMode = this.config.isInClaudeCodeMode
1402
+ ? AssistantMode.Claude
1403
+ : AssistantMode.Default;
1404
+
1405
+ event.data = {
1406
+ ...(event.data || {}),
1407
+ assistantMode
1408
+ };
1409
+
1410
+ return new Promise<void>((resolve, reject) => {
1411
+ requestAPI<any>('emit-telemetry-event', {
1412
+ method: 'POST',
1413
+ body: JSON.stringify(event)
1414
+ })
1415
+ .then(async data => {
1416
+ resolve();
1417
+ })
1418
+ .catch(reason => {
1419
+ console.error(`Failed to emit telemetry event.\n${reason}`);
1420
+ reject(reason);
1421
+ });
1422
+ });
1423
+ }
1424
+ }