@loicngr/kobo 2.1.23 → 2.1.25

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 (103) hide show
  1. package/AGENTS.md +40 -1
  2. package/CHANGELOG.md +8 -0
  3. package/CONFIGURATION.md +104 -3
  4. package/dist/mcp-server/kobo-tasks-server.js +3 -2
  5. package/dist/server/routes/mcp.js +13 -7
  6. package/dist/server/routes/workspaces.js +41 -7
  7. package/dist/server/services/agent/engines/claude-code/engine.js +2 -0
  8. package/dist/server/services/agent/engines/claude-code/progress-reporting-hook.js +99 -0
  9. package/dist/server/services/dev-server-service.js +3 -59
  10. package/dist/server/services/forge/github/provider.js +4 -2
  11. package/dist/server/services/forge/types.js +0 -1
  12. package/dist/server/services/lifecycle-hook-service.js +0 -8
  13. package/dist/server/services/mcp-message-delivery-service.js +1 -1
  14. package/dist/server/services/mcp-message-request-service.js +11 -5
  15. package/dist/server/services/settings-service.js +15 -0
  16. package/dist/server/services/workspace-dialogue-service.js +2 -0
  17. package/dist/server/services/workspace-message-service.js +61 -5
  18. package/dist/server/utils/dev-server-instance.js +58 -0
  19. package/dist/server/utils/git-ops.js +29 -20
  20. package/dist/server/utils/script-runner.js +5 -2
  21. package/dist/shared/forge-merge.js +5 -0
  22. package/dist/shared/mcp-attachments.js +58 -0
  23. package/dist/shared/progress-bar-prompts.js +1 -1
  24. package/dist/shared/workspace-creation-tools.js +4 -28
  25. package/dist/shared/workspace-dialogue-tools.js +18 -6
  26. package/package.json +1 -1
  27. package/src/client/dist/pwa/assets/{ActivityFeed-1OKX_g3B.js → ActivityFeed-xJMPnXKA.js} +1 -1
  28. package/src/client/dist/pwa/assets/{AutoLoopFinalReviewSettings-VoA9uBw0.js → AutoLoopFinalReviewSettings-Dafi7Tue.js} +1 -1
  29. package/src/client/dist/pwa/assets/{ChangelogPage-DHl3hD3t.js → ChangelogPage-DH-zUtlf.js} +1 -1
  30. package/src/client/dist/pwa/assets/{ComparisonPanel-D6EKTtni.js → ComparisonPanel-DwaQyEgb.js} +1 -1
  31. package/src/client/dist/pwa/assets/{CreatePage-CZzEOVVq.js → CreatePage-DyF_efbs.js} +1 -1
  32. package/src/client/dist/pwa/assets/{DashboardPage-DjLoUvjA.js → DashboardPage-B8gTooRi.js} +1 -1
  33. package/src/client/dist/pwa/assets/DiffViewer-BivWNITj.js +8 -0
  34. package/src/client/dist/pwa/assets/{DiffViewer-DEPOa3D7.css → DiffViewer-XzCvEKLJ.css} +1 -1
  35. package/src/client/dist/pwa/assets/{DocumentsPanel-yrQviqCd.js → DocumentsPanel-DXjjhGUg.js} +1 -1
  36. package/src/client/dist/pwa/assets/{DrawerToggleButton-D5qNeXnn.js → DrawerToggleButton-CiVNMTbq.js} +1 -1
  37. package/src/client/dist/pwa/assets/{GitPanel-CNFtj6k0.css → GitPanel-CRaLJzjc.css} +1 -1
  38. package/src/client/dist/pwa/assets/GitPanel-DQjgGtoN.js +3 -0
  39. package/src/client/dist/pwa/assets/{HealthPage-COicuNQn.js → HealthPage-lc2Cuuks.js} +1 -1
  40. package/src/client/dist/pwa/assets/{MainLayout-ZcExZTqC.js → MainLayout-D3pGcTx8.js} +2 -2
  41. package/src/client/dist/pwa/assets/{MemoryPanel-D73Qz9f7.js → MemoryPanel-SrR94UVW.js} +1 -1
  42. package/src/client/dist/pwa/assets/{MemoryProposalList-BGlq2DkD.js → MemoryProposalList-B5uVf3KU.js} +1 -1
  43. package/src/client/dist/pwa/assets/{QBtnDropdown-CykBCmwa.js → QBtnDropdown-Cxr3lBPB.js} +1 -1
  44. package/src/client/dist/pwa/assets/{QExpansionItem-CAW9J1Uv.js → QExpansionItem-xIXDevKz.js} +1 -1
  45. package/src/client/dist/pwa/assets/{QScrollArea-C_d-8eO7.js → QScrollArea-DeczMHVs.js} +1 -1
  46. package/src/client/dist/pwa/assets/{QSelect-C1AN93yA.js → QSelect-GQAsf7pB.js} +1 -1
  47. package/src/client/dist/pwa/assets/{QTooltip-C9qaLW_h.js → QTooltip-be9mqhmB.js} +1 -1
  48. package/src/client/dist/pwa/assets/{SchedulePanel-CQpzDhq7.js → SchedulePanel-R6H-D-m5.js} +1 -1
  49. package/src/client/dist/pwa/assets/{SearchPage-fZlSSEu_.js → SearchPage-CaeYoIDw.js} +1 -1
  50. package/src/client/dist/pwa/assets/SettingsPage-BCWdQHS5.js +9 -0
  51. package/src/client/dist/pwa/assets/SettingsPage-Dcq1Jeoo.css +1 -0
  52. package/src/client/dist/pwa/assets/{SplitWorkspacePage-CfeO6TZb.js → SplitWorkspacePage-Dw1qUIFU.js} +1 -1
  53. package/src/client/dist/pwa/assets/{TerminalPanel-CfDZOteT.js → TerminalPanel-Dqw88QqH.js} +1 -1
  54. package/src/client/dist/pwa/assets/{ToolCallItem-CHE6tylq.js → ToolCallItem-BMn7eela.js} +1 -1
  55. package/src/client/dist/pwa/assets/{TouchPan-Csev_qU6.js → TouchPan-BwCpe_Oh.js} +1 -1
  56. package/src/client/dist/pwa/assets/{TourReplayButton-Crr7tF1b.js → TourReplayButton-yeGYf6xT.js} +1 -1
  57. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-DpUZOiJv.js → WorkspaceAttentionLabels-BJPw65vU.js} +1 -1
  58. package/src/client/dist/pwa/assets/WorkspacePage-BHO1Q8PG.js +4 -0
  59. package/src/client/dist/pwa/assets/{WorkspacePage-Bk-WWOYM.css → WorkspacePage-BnklZAYZ.css} +1 -1
  60. package/src/client/dist/pwa/assets/{browser-o7ftkZex.js → browser-C9U-mSDm.js} +1 -1
  61. package/src/client/dist/pwa/assets/{build-path-tree-D6dZQpIb.js → build-path-tree-DYdRW2T4.js} +1 -1
  62. package/src/client/dist/pwa/assets/{custom-sounds-DVX9zCET.js → custom-sounds-DPsfYErA.js} +1 -1
  63. package/src/client/dist/pwa/assets/{de-DOE4H_Jf.js → de-Bqil7RJE.js} +3 -3
  64. package/src/client/dist/pwa/assets/{editor.api-C8TYnUXm.js → editor.api-iu5YkEl3.js} +1 -1
  65. package/src/client/dist/pwa/assets/{efforts-DfsG0_o0.js → efforts-ja6uJadn.js} +1 -1
  66. package/src/client/dist/pwa/assets/{es-BW57JPlm.js → es-CNcMm1Zz.js} +3 -3
  67. package/src/client/dist/pwa/assets/{expand-template-CJZlWMfI.js → expand-template-BbgnJ8YH.js} +1 -1
  68. package/src/client/dist/pwa/assets/{format-DOCqk2Dn.js → format-CLXLy6Ov.js} +1 -1
  69. package/src/client/dist/pwa/assets/{formatters-BFocC6LJ.js → formatters-DeKgxYJe.js} +1 -1
  70. package/src/client/dist/pwa/assets/{fr-DnmIKHaQ.js → fr-BObHEohg.js} +3 -3
  71. package/src/client/dist/pwa/assets/{freemarker2-CfME4WOK.js → freemarker2-2IrnDDvu.js} +1 -1
  72. package/src/client/dist/pwa/assets/{handlebars-uvKdDnJl.js → handlebars-CeLN7FCP.js} +1 -1
  73. package/src/client/dist/pwa/assets/{html-BrEPgLuD.js → html-Bn3rhcp3.js} +1 -1
  74. package/src/client/dist/pwa/assets/i18n-Df_y96N2.js +1 -0
  75. package/src/client/dist/pwa/assets/{index-z1Oh2-I_.js → index-CH4JpI2a.js} +4 -4
  76. package/src/client/dist/pwa/assets/{it-DSrEQgZQ.js → it-DDMC7Emk.js} +3 -3
  77. package/src/client/dist/pwa/assets/{javascript-KH2xP4lQ.js → javascript-BMvON8oT.js} +1 -1
  78. package/src/client/dist/pwa/assets/{liquid-D08TlsVs.js → liquid-D_AQLvQl.js} +1 -1
  79. package/src/client/dist/pwa/assets/{mdx-y74A51cS.js → mdx-DDJzMB8Q.js} +1 -1
  80. package/src/client/dist/pwa/assets/{monaco-contributions-Cx8iaAhd.js → monaco-contributions-DFKwR-_X.js} +1 -1
  81. package/src/client/dist/pwa/assets/{monaco.contribution-CFq0guCl.js → monaco.contribution-4AwFLnjV.js} +2 -2
  82. package/src/client/dist/pwa/assets/{network-auth-DnGOOzCV.js → network-auth-CMERg4Qk.js} +1 -1
  83. package/src/client/dist/pwa/assets/{notify-theme-B9lUAgQl.js → notify-theme-CMzdjL91.js} +1 -1
  84. package/src/client/dist/pwa/assets/{project-color-TgENPFLi.js → project-color-C5mEKdlH.js} +1 -1
  85. package/src/client/dist/pwa/assets/{python-Cy3bAVPq.js → python-BOqcp9xc.js} +1 -1
  86. package/src/client/dist/pwa/assets/{razor-gD9QOuiV.js → razor-BrGwBzd7.js} +1 -1
  87. package/src/client/dist/pwa/assets/{typescript-CEItY0bH.js → typescript-mOGf86uY.js} +1 -1
  88. package/src/client/dist/pwa/assets/{use-search-index-status-BtJ0pOy9.js → use-search-index-status-P7O7EaV_.js} +1 -1
  89. package/src/client/dist/pwa/assets/{use-tours-COLDth2h.js → use-tours-CMFPIW6f.js} +2 -2
  90. package/src/client/dist/pwa/assets/{use-worktree-restore-CShbwpGy.js → use-worktree-restore-DF6P7h8g.js} +1 -1
  91. package/src/client/dist/pwa/assets/{workspace-templates-BOMNQgBl.js → workspace-templates-C_bgIQvs.js} +1 -1
  92. package/src/client/dist/pwa/assets/{xml-BJvL5qcE.js → xml-YcMBzYAm.js} +1 -1
  93. package/src/client/dist/pwa/assets/{yaml-DtkVRZaX.js → yaml-BP_67Sds.js} +1 -1
  94. package/src/client/dist/pwa/index.html +1 -1
  95. package/src/client/dist/pwa/sw.js +1 -1
  96. package/src/mcp-server/README.md +43 -4
  97. package/src/mcp-server/kobo-tasks-server.ts +3 -6
  98. package/src/client/dist/pwa/assets/DiffViewer-BQ6OEmoi.js +0 -8
  99. package/src/client/dist/pwa/assets/GitPanel-DCXlhcAD.js +0 -3
  100. package/src/client/dist/pwa/assets/SettingsPage-BCOF0ssp.css +0 -1
  101. package/src/client/dist/pwa/assets/SettingsPage-eyJZxeCJ.js +0 -9
  102. package/src/client/dist/pwa/assets/WorkspacePage-_QSKLO42.js +0 -4
  103. package/src/client/dist/pwa/assets/i18n-BmTCjikk.js +0 -1
@@ -1,4 +1,3 @@
1
- // src/server/services/forge/types.ts
2
1
  /**
3
2
  * True when nothing blocks merging the PR: it is open, CI is not failing or
4
3
  * in-flight (green `SUCCESS` or absent `null` — matching GitHub's "Ready to
@@ -3,7 +3,6 @@ import { getDb } from '../db/index.js';
3
3
  import { slugifyProjectName } from '../utils/project-slug.js';
4
4
  import { runScript } from '../utils/script-runner.js';
5
5
  import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
6
- import { resolveInstance, sanitizeBranchName } from './dev-server-service.js';
7
6
  import { getEffectiveSettings, getGlobalSettings, getProjectSettings } from './settings-service.js';
8
7
  import { listWorkspaces } from './workspace-service.js';
9
8
  export const SHUTDOWN_SCRIPT_TIMEOUT_MS = 60_000;
@@ -55,7 +54,6 @@ export async function runLifecycleHook(event, workspaceId, extraEnv) {
55
54
  }
56
55
  return null;
57
56
  }
58
- const instance = event === 'kobo-shutdown' ? resolveInstance(row.project_path, row.working_branch) : null;
59
57
  return await runScript({
60
58
  workspaceId,
61
59
  worktreePath,
@@ -70,12 +68,6 @@ export async function runLifecycleHook(event, workspaceId, extraEnv) {
70
68
  branchName: row.working_branch,
71
69
  sourceBranch: row.source_branch,
72
70
  projectPath: row.project_path,
73
- ...(event === 'kobo-shutdown'
74
- ? {
75
- projectName: instance?.projectName ?? '',
76
- instanceName: instance?.instanceName ?? sanitizeBranchName(row.working_branch),
77
- }
78
- : {}),
79
71
  },
80
72
  extraEnv: { ...extraEnv, KOBO_HOOK_EVENT: event },
81
73
  });
@@ -4,7 +4,7 @@ import { broadcastPersistedEvent, persistWorkspaceEvent } from './websocket-serv
4
4
  import { deliverWorkspaceMessage } from './workspace-message-service.js';
5
5
  export async function deliverKeyedMcpMessage(workspaceId, key, message) {
6
6
  const db = getDb();
7
- const request = reserveMessageRequest(db, workspaceId, key, message.content, message.sessionId);
7
+ const request = reserveMessageRequest(db, workspaceId, key, message.content, message.sessionId, message.attachments);
8
8
  if (!request.fresh)
9
9
  return request.result;
10
10
  let dispatched = false;
@@ -1,9 +1,15 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { nanoid } from 'nanoid';
3
- export function reserveMessageRequest(db, workspaceId, key, content, sessionId) {
4
- const fingerprint = createHash('sha256')
5
- .update(JSON.stringify([content, sessionId ?? null]))
6
- .digest('hex');
3
+ export function reserveMessageRequest(db, workspaceId, key, content, sessionId, attachments) {
4
+ // Keep text-only fingerprints compatible with receipts issued before attachment support.
5
+ const identity = [content, sessionId ?? null];
6
+ if (attachments?.length)
7
+ identity.push(attachments.map((file) => [
8
+ file.name,
9
+ file.mime_type,
10
+ createHash('sha256').update(file.data_base64).digest('hex'),
11
+ ]));
12
+ const fingerprint = createHash('sha256').update(JSON.stringify(identity)).digest('hex');
7
13
  return db
8
14
  .transaction(() => {
9
15
  const requestId = `mcp-${nanoid()}`;
@@ -23,7 +29,7 @@ export function reserveMessageRequest(db, workspaceId, key, content, sessionId)
23
29
  accepted: false,
24
30
  requestId: row.request_id,
25
31
  code: 'idempotency_conflict',
26
- message: 'This key was already used for different content or a different session',
32
+ message: 'This key was already used for different content, session or attachments',
27
33
  };
28
34
  else if (row.result_json) {
29
35
  result = JSON.parse(row.result_json);
@@ -1010,6 +1010,14 @@ const settingsMigrations = [
1010
1010
  }
1011
1011
  },
1012
1012
  },
1013
+ {
1014
+ version: 67,
1015
+ name: 'opt-in-immediate-chat-messages',
1016
+ migrate: ({ global }) => {
1017
+ if (typeof global.forceImmediateMessages !== 'boolean')
1018
+ global.forceImmediateMessages = false;
1019
+ },
1020
+ },
1013
1021
  ];
1014
1022
  /** Current settings schema version — always equals the highest migration version. */
1015
1023
  export const SETTINGS_SCHEMA_VERSION = settingsMigrations.length > 0 ? settingsMigrations[settingsMigrations.length - 1].version : 0;
@@ -1067,6 +1075,7 @@ function defaultSettings() {
1067
1075
  autoPurgeOnPrMerged: false,
1068
1076
  autoLoopMaxRetries: 5,
1069
1077
  agentWatchdogEnabled: false,
1078
+ forceImmediateMessages: false,
1070
1079
  showProgressBars: true,
1071
1080
  stopDevServersOnShutdown: true,
1072
1081
  awaitingUserReminderMinutes: 0,
@@ -1218,6 +1227,8 @@ export function runSettingsMigrations(raw) {
1218
1227
  current.global.workflowPolicy = resolveWorkflowPolicy(current.global.workflowPolicy);
1219
1228
  if (!isMemoryMode(current.global.memoryMode))
1220
1229
  current.global.memoryMode = 'hybrid';
1230
+ if (typeof current.global.forceImmediateMessages !== 'boolean')
1231
+ current.global.forceImmediateMessages = false;
1221
1232
  if (typeof current.global.showProgressBars !== 'boolean')
1222
1233
  current.global.showProgressBars = true;
1223
1234
  if (typeof current.global.stopDevServersOnShutdown !== 'boolean')
@@ -1588,6 +1599,9 @@ export function updateGlobalSettings(input) {
1588
1599
  if ('stopDevServersOnShutdown' in data && typeof data.stopDevServersOnShutdown !== 'boolean') {
1589
1600
  delete data.stopDevServersOnShutdown;
1590
1601
  }
1602
+ if ('forceImmediateMessages' in data && typeof data.forceImmediateMessages !== 'boolean') {
1603
+ delete data.forceImmediateMessages;
1604
+ }
1591
1605
  if ('showProgressBars' in data && typeof data.showProgressBars !== 'boolean') {
1592
1606
  delete data.showProgressBars;
1593
1607
  }
@@ -1659,6 +1673,7 @@ export function updateGlobalSettings(input) {
1659
1673
  'autoPurgeOnPrMerged',
1660
1674
  'autoLoopMaxRetries',
1661
1675
  'agentWatchdogEnabled',
1676
+ 'forceImmediateMessages',
1662
1677
  'showProgressBars',
1663
1678
  'stopDevServersOnShutdown',
1664
1679
  'awaitingUserReminderMinutes',
@@ -85,6 +85,7 @@ export async function executeWorkspaceDialogueTool(name, input, source = createM
85
85
  if (name === 'send_workspace_message' && args.idempotency_key)
86
86
  return deliverKeyedMcpMessage(workspaceId, args.idempotency_key, {
87
87
  content: args.content,
88
+ ...(args.attachments ? { attachments: args.attachments } : {}),
88
89
  sessionId: args.session_id,
89
90
  source,
90
91
  });
@@ -93,6 +94,7 @@ export async function executeWorkspaceDialogueTool(name, input, source = createM
93
94
  accepted: true,
94
95
  ...(await deliverWorkspaceMessage(workspaceId, {
95
96
  content: args.content,
97
+ ...(args.attachments ? { attachments: args.attachments } : {}),
96
98
  sessionId: args.session_id,
97
99
  clientMessageId: `mcp-${nanoid()}`,
98
100
  source,
@@ -1,5 +1,7 @@
1
- import { assertWorkspaceLifecycleAvailable } from '../utils/workspace-lifecycle-guard.js';
1
+ import { validateMcpAttachments } from '../../shared/mcp-attachments.js';
2
+ import { assertWorkspaceLifecycleAvailable, withWorkspaceLifecycleGuard } from '../utils/workspace-lifecycle-guard.js';
2
3
  import { isAgentUnavailableError, isShuttingDown, sendMessage, startAgent } from './agent/orchestrator.js';
4
+ import { attachmentReference, removeAttachments, saveAttachments } from './attachment-service.js';
3
5
  import { hasLoopMessage } from './auto-loop-message-service.js';
4
6
  import * as autoLoop from './auto-loop-service.js';
5
7
  import { createChatReceipt } from './chat-receipt-service.js';
@@ -7,10 +9,28 @@ import { emitEphemeral } from './websocket-service.js';
7
9
  import { getActiveSession, getWorkspace, updateWorkspaceStatus } from './workspace-service.js';
8
10
  /** Shared delivery for WebSocket chat and external MCP clients. */
9
11
  export async function deliverWorkspaceMessage(workspaceId, message, hooks) {
12
+ // Keep the caller's original content unchanged for durable idempotency fingerprints.
13
+ message = { ...message };
10
14
  const receipt = createChatReceipt(workspaceId, message, hooks?.persist);
15
+ let saved = [];
16
+ let attachmentWorktree;
17
+ let dispatched = false;
18
+ const beforeDispatch = () => {
19
+ if (saved.length) {
20
+ assertWorkspaceLifecycleAvailable(workspaceId);
21
+ const current = getWorkspace(workspaceId);
22
+ if (!current || current.archivedAt || current.worktreePurgedAt || current.worktreePath !== attachmentWorktree)
23
+ throw new Error('Workspace changed before attachment delivery');
24
+ if (current.status === 'compacting' || current.status === 'awaiting-user' || isShuttingDown())
25
+ throw new Error('Workspace is not ready to receive a message');
26
+ }
27
+ hooks?.beforeDispatch?.();
28
+ dispatched = true;
29
+ };
11
30
  let reason;
12
31
  try {
13
- if (!message.content.trim())
32
+ const attachments = validateMcpAttachments(message.attachments);
33
+ if (!message.content.trim() && !attachments.length)
14
34
  throw new Error('Message content must not be empty');
15
35
  if (isShuttingDown())
16
36
  throw new Error('Kōbō is shutting down');
@@ -21,6 +41,8 @@ export async function deliverWorkspaceMessage(workspaceId, message, hooks) {
21
41
  if (workspace.archivedAt || workspace.worktreePurgedAt)
22
42
  throw new Error('Restore the workspace before sending a message');
23
43
  if (message.delivery === 'next_iteration') {
44
+ if (attachments.length)
45
+ throw new Error('Attachments require immediate delivery');
24
46
  if (!message.clientMessageId)
25
47
  throw new Error('clientMessageId is required for queued delivery');
26
48
  const acceptedBefore = hasLoopMessage(workspaceId, message.clientMessageId);
@@ -45,11 +67,28 @@ export async function deliverWorkspaceMessage(workspaceId, message, hooks) {
45
67
  reason = 'awaiting-user';
46
68
  throw new Error('Answer the pending question or permission request before sending a chat message');
47
69
  }
70
+ if (attachments.length) {
71
+ attachmentWorktree = workspace.worktreePath;
72
+ await withWorkspaceLifecycleGuard(workspaceId, async () => {
73
+ const files = attachments.map((file) => new File([Buffer.from(file.data_base64, 'base64')], file.name, { type: file.mime_type }));
74
+ saved = await saveAttachments(workspace.worktreePath, files);
75
+ });
76
+ assertWorkspaceLifecycleAvailable(workspaceId);
77
+ const current = getWorkspace(workspaceId);
78
+ if (!current || current.archivedAt || current.worktreePurgedAt || current.worktreePath !== attachmentWorktree)
79
+ throw new Error('Workspace changed while saving attachments');
80
+ if (current.status === 'compacting' || current.status === 'awaiting-user' || isShuttingDown())
81
+ throw new Error('Workspace is not ready to receive a message');
82
+ message.content = [message.content.trim(), ...saved.map(attachmentReference)].filter(Boolean).join('\n\n');
83
+ // Binary data never belongs in the chat receipt or agent prompt.
84
+ delete message.attachments;
85
+ }
86
+ const trackDispatch = !!hooks?.beforeDispatch || saved.length > 0;
48
87
  const loop = autoLoop.getStatus(workspaceId);
49
88
  let sessionId = message.sessionId ?? getActiveSession(workspaceId)?.id;
50
89
  try {
51
- if (hooks?.beforeDispatch)
52
- await sendMessage(workspaceId, message.content, message.sessionId, hooks.beforeDispatch);
90
+ if (trackDispatch)
91
+ await sendMessage(workspaceId, message.content, message.sessionId, beforeDispatch);
53
92
  else
54
93
  await sendMessage(workspaceId, message.content, message.sessionId);
55
94
  }
@@ -61,7 +100,7 @@ export async function deliverWorkspaceMessage(workspaceId, message, hooks) {
61
100
  const current = getWorkspace(workspaceId);
62
101
  if (!current || current.archivedAt || current.worktreePurgedAt)
63
102
  throw new Error('Workspace is unavailable');
64
- const started = startAgent(workspaceId, current.worktreePath, message.content, current.model, true, message.agentPermissionModeOverride ?? current.agentPermissionMode, message.sessionId, current.reasoningEffort, ...(hooks?.beforeDispatch ? [hooks.beforeDispatch] : []));
103
+ const started = startAgent(workspaceId, current.worktreePath, message.content, current.model, true, message.agentPermissionModeOverride ?? current.agentPermissionMode, message.sessionId, current.reasoningEffort, ...(trackDispatch ? [beforeDispatch] : []));
65
104
  updateWorkspaceStatus(workspaceId, 'executing');
66
105
  sessionId = message.sessionId ?? started.agentSessionId;
67
106
  }
@@ -71,6 +110,23 @@ export async function deliverWorkspaceMessage(workspaceId, message, hooks) {
71
110
  return { sessionId };
72
111
  }
73
112
  catch (error) {
113
+ // Once dispatch may have happened, keep files available to the agent even if
114
+ // acknowledgement failed. Only remove files from a definitively rejected send.
115
+ if (saved.length && attachmentWorktree && !dispatched) {
116
+ const worktree = attachmentWorktree;
117
+ try {
118
+ await withWorkspaceLifecycleGuard(workspaceId, async () => {
119
+ const current = getWorkspace(workspaceId);
120
+ if (!current || current.worktreePurgedAt || current.worktreePath !== worktree)
121
+ return;
122
+ for (const warning of await removeAttachments(worktree, saved))
123
+ console.warn(`[message-attachments] ${warning}`);
124
+ });
125
+ }
126
+ catch {
127
+ // A competing lifecycle operation owns the checkout now; do not touch it.
128
+ }
129
+ }
74
130
  receipt.reject(error instanceof Error ? error.message : String(error), reason);
75
131
  throw error;
76
132
  }
@@ -0,0 +1,58 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ /**
4
+ * Sanitize a branch name for use as a Docker instance name.
5
+ * Replace `/` and `_` with `-`, lowercase.
6
+ */
7
+ export function sanitizeBranchName(branch) {
8
+ return branch.toLowerCase().replace(/[/_]/g, '-');
9
+ }
10
+ /**
11
+ * Parse a `.env` file content into key=value pairs.
12
+ * Skips empty lines and comments (#). Handles quotes.
13
+ */
14
+ export function parseEnvFile(content) {
15
+ const result = {};
16
+ for (const line of content.split('\n')) {
17
+ const trimmed = line.trim();
18
+ if (!trimmed || trimmed.startsWith('#'))
19
+ continue;
20
+ const eqIndex = trimmed.indexOf('=');
21
+ if (eqIndex === -1)
22
+ continue;
23
+ const key = trimmed.slice(0, eqIndex).trim();
24
+ let value = trimmed.slice(eqIndex + 1).trim();
25
+ // Strip surrounding quotes
26
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
27
+ value = value.slice(1, -1);
28
+ }
29
+ result[key] = value;
30
+ }
31
+ return result;
32
+ }
33
+ /**
34
+ * Resolve the dev-server instance config for a given project + branch.
35
+ * Looks in `<projectPath>/.container/instances/` for `.env` files.
36
+ */
37
+ export function resolveInstance(projectPath, workingBranch) {
38
+ const instancesDir = path.join(projectPath, '.container', 'instances');
39
+ if (!existsSync(instancesDir))
40
+ return null;
41
+ const sanitized = sanitizeBranchName(workingBranch);
42
+ const files = readdirSync(instancesDir).filter((f) => f.endsWith('.env'));
43
+ for (const file of files) {
44
+ const content = readFileSync(path.join(instancesDir, file), 'utf-8');
45
+ const parsed = parseEnvFile(content);
46
+ if (parsed.INSTANCE_NAME &&
47
+ parsed.INSTANCE_NAME.toLowerCase() === sanitized &&
48
+ parsed.PROJECT_NAME &&
49
+ /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(parsed.PROJECT_NAME)) {
50
+ return {
51
+ instanceName: parsed.INSTANCE_NAME,
52
+ projectName: parsed.PROJECT_NAME,
53
+ httpPort: parsed.HTTP_PORT ?? '',
54
+ };
55
+ }
56
+ }
57
+ return null;
58
+ }
@@ -1045,27 +1045,27 @@ export function getChangedFiles(repoPath, base, includeUntracked = false) {
1045
1045
  }
1046
1046
  return files;
1047
1047
  }
1048
- /**
1049
- * List committed files between `origin/<branch>` and local HEAD — the set
1050
- * of files the next `git push` would send. Working tree changes are NOT
1051
- * included: uncommitted edits aren't about to be pushed. Returns an empty
1052
- * list if there is no remote tracking branch yet.
1053
- */
1054
- export function getUnpushedChangedFiles(repoPath, branchName, remote = 'origin') {
1055
- const remoteRef = `${remote}/${branchName}`;
1056
- // Bail out cleanly if the remote branch doesn't exist (branch never pushed).
1048
+ /** Local changes relative to the pushed branch, including staged and working-tree edits. */
1049
+ export function getUnpushedChangedFiles(repoPath, branchName, remote = 'origin', sourceBranch = 'HEAD') {
1050
+ let ref = `${remote}/${branchName}`;
1057
1051
  try {
1058
- git(repoPath, ['rev-parse', '--verify', remoteRef]);
1052
+ git(repoPath, ['rev-parse', '--verify', ref]);
1059
1053
  }
1060
1054
  catch {
1061
- return [];
1062
- }
1063
- const files = [];
1064
- try {
1065
- files.push(...parseNameStatus(git(repoPath, ['diff', '--name-status', '-z', `${remoteRef}..HEAD`])));
1055
+ ref = resolveBase(repoPath, sourceBranch);
1066
1056
  }
1067
- catch {
1068
- // Unlikely after the rev-parse check, but keep the happy path robust.
1057
+ const files = parseNameStatus(git(repoPath, ['diff', '--name-status', '-z', ref, '--']));
1058
+ const untracked = git(repoPath, ['ls-files', '--others', '--exclude-standard', '-z']);
1059
+ return withUntrackedFiles(files, untracked);
1060
+ }
1061
+ /** Preserve NUL-delimited filenames and deduplicate tracked/untracked transitions. */
1062
+ function withUntrackedFiles(files, output) {
1063
+ const seen = new Set(files.map((file) => file.path));
1064
+ for (const filePath of output.split('\0')) {
1065
+ if (filePath && !seen.has(filePath)) {
1066
+ files.push({ path: filePath, status: 'untracked' });
1067
+ seen.add(filePath);
1068
+ }
1069
1069
  }
1070
1070
  return files;
1071
1071
  }
@@ -1555,16 +1555,25 @@ export async function getChangedFilesBetweenAsync(repoPath, fromRef, toRef) {
1555
1555
  return [];
1556
1556
  }
1557
1557
  }
1558
- export async function getUnpushedChangedFilesAsync(repoPath, branchName, remote = 'origin') {
1558
+ /** Use the source branch until this working branch has been pushed for the first time. */
1559
+ export async function resolveUnpushedBaseAsync(repoPath, branchName, sourceBranch = 'HEAD', remote = 'origin') {
1559
1560
  const ref = `${remote}/${branchName}`;
1560
1561
  try {
1561
1562
  await gitAsync(repoPath, ['rev-parse', '--verify', ref]);
1562
- return parseNameStatus(await gitAsync(repoPath, ['diff', '--name-status', '-z', `${ref}..HEAD`]));
1563
+ return ref;
1563
1564
  }
1564
1565
  catch {
1565
- return [];
1566
+ return resolveBaseAsync(repoPath, sourceBranch);
1566
1567
  }
1567
1568
  }
1569
+ export async function getUnpushedChangedFilesAsync(repoPath, branchName, remote = 'origin', sourceBranch = 'HEAD') {
1570
+ const ref = await resolveUnpushedBaseAsync(repoPath, branchName, sourceBranch, remote);
1571
+ const [changes, untracked] = await Promise.all([
1572
+ gitAsync(repoPath, ['diff', '--name-status', '-z', ref, '--']),
1573
+ gitAsync(repoPath, ['ls-files', '--others', '--exclude-standard', '-z']),
1574
+ ]);
1575
+ return withUntrackedFiles(parseNameStatus(changes), untracked);
1576
+ }
1568
1577
  export async function getChangedFilesAsync(repoPath, base, includeUntracked = false) {
1569
1578
  const ref = await resolveBaseAsync(repoPath, base);
1570
1579
  const [committed, working] = await Promise.allSettled([
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import * as wsService from '../services/websocket-service.js';
5
+ import { resolveInstance, sanitizeBranchName } from './dev-server-instance.js';
5
6
  import { getIndexLockPath } from './git-ops.js';
6
7
  import { ensureDirectoryInside } from './safe-path.js';
7
8
  /** Default wall-clock budget for a user script before it is force-killed. */
@@ -39,6 +40,8 @@ export function runScript(opts) {
39
40
  scriptDirectory = fs.mkdtempSync(path.join(storage, '.script-'));
40
41
  scriptPath = path.join(scriptDirectory, path.basename(tmpFileName));
41
42
  fs.writeFileSync(scriptPath, script, { mode: 0o700, flag: 'wx' });
43
+ // Resolve only from this workspace's persisted instance, never the host's env.
44
+ const instance = env?.projectPath && env.branchName ? resolveInstance(env.projectPath, env.branchName) : null;
42
45
  const proc = spawn('bash', [scriptPath], {
43
46
  cwd: worktreePath,
44
47
  env: {
@@ -49,8 +52,8 @@ export function runScript(opts) {
49
52
  BRANCH_NAME: env?.branchName ?? '',
50
53
  SOURCE_BRANCH: env?.sourceBranch ?? '',
51
54
  PROJECT_PATH: env?.projectPath ?? '',
52
- ...(env?.projectName !== undefined ? { PROJECT_NAME: env.projectName } : {}),
53
- ...(env?.instanceName !== undefined ? { INSTANCE: env.instanceName } : {}),
55
+ PROJECT_NAME: instance?.projectName ?? '',
56
+ INSTANCE: instance?.instanceName ?? sanitizeBranchName(env?.branchName ?? ''),
54
57
  },
55
58
  stdio: ['ignore', 'pipe', 'pipe'],
56
59
  // Detached so this is the leader of its own process group: a script
@@ -0,0 +1,5 @@
1
+ /** Explicit PR merge strategies. Availability is declared by each forge provider. */
2
+ export const MERGE_METHODS = ['merge', 'squash', 'rebase'];
3
+ export function isMergeMethod(value) {
4
+ return typeof value === 'string' && MERGE_METHODS.some((method) => method === value);
5
+ }
@@ -0,0 +1,58 @@
1
+ import { MAX_ATTACHMENTS, MAX_ATTACHMENTS_BYTES, validateAttachments } from './attachments.js';
2
+ /** Inline upload budget for creation and message tools, including base64 expansion. */
3
+ export const MAX_MCP_ATTACHMENT_REQUEST_BYTES = Math.ceil(MAX_ATTACHMENTS_BYTES / 3) * 4 + 1024 * 1024;
4
+ export const MCP_ATTACHMENTS_SCHEMA = {
5
+ type: 'array',
6
+ maxItems: MAX_ATTACHMENTS,
7
+ description: 'Inline files/images: up to 10 files, 50 MiB decoded total. Send canonical base64 bytes, never a server file path.',
8
+ items: {
9
+ type: 'object',
10
+ properties: {
11
+ name: { type: 'string', minLength: 1, maxLength: 255 },
12
+ mime_type: { type: 'string', maxLength: 200 },
13
+ data_base64: { type: 'string', minLength: 1, maxLength: Math.ceil(MAX_ATTACHMENTS_BYTES / 3) * 4 },
14
+ },
15
+ required: ['name', 'mime_type', 'data_base64'],
16
+ additionalProperties: false,
17
+ },
18
+ };
19
+ /** Validate types, names, canonical base64 and decoded bounds before any writes. */
20
+ export function validateMcpAttachments(value) {
21
+ if (value === undefined)
22
+ return [];
23
+ if (!Array.isArray(value) || value.length > MAX_ATTACHMENTS)
24
+ throw new Error('attachments must contain at most 10 files');
25
+ const files = value;
26
+ const metadata = files.map((input) => {
27
+ if (!input || typeof input !== 'object' || Array.isArray(input))
28
+ throw new Error('Invalid attachment');
29
+ const file = input;
30
+ if (Object.keys(file).some((key) => !['name', 'mime_type', 'data_base64'].includes(key)))
31
+ throw new Error('Unknown attachment field');
32
+ if (typeof file.name !== 'string' || !file.name.trim() || file.name.length > 255 || file.name.includes('\0'))
33
+ throw new Error('Invalid attachment name');
34
+ if (typeof file.mime_type !== 'string' || file.mime_type.length > 200 || file.mime_type.includes('\0'))
35
+ throw new Error('Invalid attachment MIME type');
36
+ if (typeof file.data_base64 !== 'string' || file.data_base64.length > Math.ceil(MAX_ATTACHMENTS_BYTES / 3) * 4)
37
+ throw new Error('Invalid attachment data_base64');
38
+ if (/[/\\]/.test(file.name) || file.name === '.' || file.name === '..')
39
+ throw new Error('Attachment name must be a filename, not a path');
40
+ const data = file.data_base64;
41
+ if (data.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(data))
42
+ throw new Error('Attachment data_base64 must be canonical base64');
43
+ // Reject non-zero pad bits too, before the server allocates decoded buffers.
44
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
45
+ if ((data.endsWith('==') && (alphabet.indexOf(data.at(-3)) & 15) !== 0) ||
46
+ (!data.endsWith('==') && data.endsWith('=') && (alphabet.indexOf(data.at(-2)) & 3) !== 0))
47
+ throw new Error('Attachment data_base64 must be canonical base64');
48
+ return {
49
+ name: file.name,
50
+ type: file.mime_type,
51
+ size: (data.length / 4) * 3 - (data.endsWith('==') ? 2 : data.endsWith('=') ? 1 : 0),
52
+ };
53
+ });
54
+ const error = validateAttachments(metadata);
55
+ if (error)
56
+ throw new Error(`Invalid attachments: ${error}`);
57
+ return files;
58
+ }
@@ -4,7 +4,7 @@ export const PROGRESS_BAR_PROMPT = `<kobo-progress-reporting>
4
4
  ${PROGRESS_BAR_PLAN_EXCEPTION}
5
5
  For each long-running activity (tests, audits, builds or delegated work), follow this reporting lifecycle:
6
6
  1. Call create_progress_bar with a clear label, current step and stable activity_key. Save its returned id and sequence; creating a bar is only the start of reporting.
7
- 2. Call update_progress_bar with that id and an increasing sequence: update the same bar at each actual phase change or meaningful completed milestone, including during a long phase. Even without a measured percentage, report the current step; omit both current and total for step-only updates. Use measured numbers only when known. Never invent percentages, poll, send unchanged updates or start timer-driven reporting loops; follow tool retry guidance.
7
+ 2. Call update_progress_bar with that id and an increasing sequence: update the same bar at each actual phase change or meaningful completed milestone, including during a long phase. Even without a measured percentage, report the current step; omit both current and total for step-only updates. Use measured numbers only when known: when tests report a total or your activity has a fixed checklist, provide current and total together and advance current only as those items actually finish. A step-only update changes the label, not the gauge; null numbers mean no percentage is available. Never invent percentages, poll, send unchanged updates or start timer-driven reporting loops; follow tool retry guidance.
8
8
  3. Call finish_progress_bar on that id before your final report (or return to the parent) when the activity's succeeded/failed outcome is known. Do not declare ongoing background work finished merely because you are yielding control.
9
9
  Pass this requirement to all subagents when delegating: each reports and finishes its own long activity through inherited Kōbō MCP tools; the parent must not duplicate it. If a custom subagent lacks these tools, the parent reports its observed milestones and outcome instead. Subagents must not schedule or replace workspace wakeups; the parent coordinates follow-up.
10
10
  A dismissed bar stays hidden: continue without recreating it; dismissal is not cancellation. Reporting never substitutes for verification, and an interrupted reporter does not prove background work stopped. After resume, use new activity keys for new work; do not reopen finished activities.
@@ -1,6 +1,6 @@
1
- import { MAX_ATTACHMENTS, MAX_ATTACHMENTS_BYTES, validateAttachments } from './attachments.js';
1
+ import { MAX_MCP_ATTACHMENT_REQUEST_BYTES, MCP_ATTACHMENTS_SCHEMA, validateMcpAttachments } from './mcp-attachments.js';
2
2
  /** Base64 expansion of the shared upload limit, plus bounded JSON metadata. */
3
- export const MAX_MCP_CREATION_REQUEST_BYTES = Math.ceil(MAX_ATTACHMENTS_BYTES / 3) * 4 + 1024 * 1024;
3
+ export const MAX_MCP_CREATION_REQUEST_BYTES = MAX_MCP_ATTACHMENT_REQUEST_BYTES;
4
4
  const string = (maxLength = 200, minLength = 1) => ({ type: 'string', minLength, maxLength });
5
5
  const choice = (...values) => ({ type: 'string', enum: values });
6
6
  const object = (properties, required = []) => ({
@@ -58,11 +58,7 @@ const properties = {
58
58
  worktree_path: path,
59
59
  comparison_id: string(),
60
60
  creation_id: string(),
61
- attachments: {
62
- type: 'array',
63
- maxItems: MAX_ATTACHMENTS,
64
- items: object({ name: string(255), mime_type: string(200, 0), data_base64: string(Math.ceil(MAX_ATTACHMENTS_BYTES / 3) * 4) }, ['name', 'mime_type', 'data_base64']),
65
- },
61
+ attachments: MCP_ATTACHMENTS_SCHEMA,
66
62
  pr_checkout: object({
67
63
  fingerprint: string(),
68
64
  decisions: object({
@@ -156,27 +152,7 @@ export function validateWorkspaceCreationArguments(name, input) {
156
152
  throw new Error('source_branch and working_branch are required unless using a PR or existing worktree');
157
153
  if (value.pr_checkout && !value.pr_url)
158
154
  throw new Error('pr_checkout requires pr_url');
159
- const files = (value.attachments ?? []);
160
- const metadata = files.map((file) => {
161
- if (/[/\\]/.test(file.name) || file.name === '.' || file.name === '..')
162
- throw new Error('Attachment name must be a filename, not a path');
163
- const data = file.data_base64;
164
- if (data.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(data))
165
- throw new Error('Attachment data_base64 must be canonical base64');
166
- // Reject non-zero pad bits too, before the server allocates decoded buffers.
167
- const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
168
- if ((data.endsWith('==') && (alphabet.indexOf(data.at(-3)) & 15) !== 0) ||
169
- (!data.endsWith('==') && data.endsWith('=') && (alphabet.indexOf(data.at(-2)) & 3) !== 0))
170
- throw new Error('Attachment data_base64 must be canonical base64');
171
- return {
172
- name: file.name,
173
- type: file.mime_type,
174
- size: (data.length / 4) * 3 - (data.endsWith('==') ? 2 : data.endsWith('=') ? 1 : 0),
175
- };
176
- });
177
- const error = validateAttachments(metadata);
178
- if (error)
179
- throw new Error(`Invalid attachments: ${error}`);
155
+ validateMcpAttachments(value.attachments);
180
156
  }
181
157
  return value;
182
158
  }
@@ -1,3 +1,4 @@
1
+ import { MCP_ATTACHMENTS_SCHEMA, validateMcpAttachments } from './mcp-attachments.js';
1
2
  const workspace = { type: 'string', minLength: 1, maxLength: 200, description: 'Workspace ID from list_workspaces.' };
2
3
  const session = {
3
4
  type: 'string',
@@ -27,17 +28,18 @@ export const WORKSPACE_DIALOGUE_TOOLS = [
27
28
  after_cursor: { type: 'string', minLength: 1, maxLength: 200 },
28
29
  limit: { type: 'integer', minimum: 1, maximum: 200, description: 'Maximum source events scanned (default 100).' },
29
30
  }, ['workspace_id']),
30
- tool('send_workspace_message', 'Send a message to an agent, resuming it if needed. Returns acceptance, not the agent reply; read_workspace_messages retrieves replies. Uses the configured permission mode and pauses active auto-loop. Do not retry blindly after an ambiguous connection failure. Do not send to your own workspace.', {
31
+ tool('send_workspace_message', 'Send text and/or inline base64 files/images to an agent, resuming it if needed. Attachments use the same format as create_workspace (10 files, 50 MiB total). Returns acceptance, not the agent reply; read_workspace_messages retrieves replies. Uses the configured permission mode and pauses active auto-loop. Do not retry blindly after an ambiguous connection failure. Do not send to your own workspace.', {
31
32
  workspace_id: workspace,
32
33
  session_id: session,
33
- content: { type: 'string', minLength: 1, maxLength: 100_000 },
34
+ content: { type: 'string', minLength: 0, maxLength: 100_000 },
35
+ attachments: MCP_ATTACHMENTS_SCHEMA,
34
36
  idempotency_key: {
35
37
  type: 'string',
36
38
  minLength: 1,
37
39
  maxLength: 200,
38
- description: 'Generate once per intended message; reuse on retries to prevent duplicate dispatch, including after restart. Reuse with different content/session is rejected.',
40
+ description: 'Generate once per intended message; reuse on retries to prevent duplicate dispatch, including after restart. Reuse with different content/session/attachments is rejected.',
39
41
  },
40
- }, ['workspace_id', 'content'], false),
42
+ }, ['workspace_id'], false),
41
43
  tool('get_workspace_questions', 'Read pending questions and whether a human tool approval is blocking the workspace.', { workspace_id: workspace }, ['workspace_id']),
42
44
  tool('answer_workspace_question', 'Answer the current pending question using its exact tool_call_id. Answer keys are question IDs for Codex, question text for Claude. Tool permission requests require human approval in Kōbō.', {
43
45
  workspace_id: workspace,
@@ -62,8 +64,13 @@ export function validateDialogueArguments(name, value) {
62
64
  const property = properties[key];
63
65
  if (!Object.hasOwn(properties, key))
64
66
  throw new Error(`Unknown argument '${key}'`);
65
- if (property.type === 'string') {
66
- if (typeof input !== 'string' || !input.trim() || input.length > (property.maxLength ?? 100_000))
67
+ if (key === 'attachments') {
68
+ validateMcpAttachments(input);
69
+ }
70
+ else if (property.type === 'string') {
71
+ if (typeof input !== 'string' ||
72
+ (property.minLength !== 0 && !input.trim()) ||
73
+ input.length > (property.maxLength ?? 100_000))
67
74
  throw new Error(`${key} must be a non-empty string within its length limit`);
68
75
  }
69
76
  else if (property.type === 'boolean') {
@@ -85,5 +92,10 @@ export function validateDialogueArguments(name, value) {
85
92
  throw new Error(`${key} must be a non-empty object of string answers`);
86
93
  }
87
94
  }
95
+ if (name === 'send_workspace_message') {
96
+ if (!args.content?.trim() && !validateMcpAttachments(args.attachments).length)
97
+ throw new Error('Message content or attachments are required');
98
+ return { ...args, content: args.content ?? '' };
99
+ }
88
100
  return args;
89
101
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loicngr/kobo",
3
- "version": "2.1.23",
3
+ "version": "2.1.25",
4
4
  "description": "Kōbō — local multi-workspace orchestrator for Claude Code and OpenAI Codex, with isolated Git worktrees, live review and optional automation.",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",