@loicngr/kobo 2.1.22 → 2.1.24

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 (99) hide show
  1. package/AGENTS.md +72 -1
  2. package/CHANGELOG.md +8 -0
  3. package/CONFIGURATION.md +145 -3
  4. package/dist/mcp-server/kobo-tasks-server.js +7 -6
  5. package/dist/server/index.js +21 -6
  6. package/dist/server/routes/mcp.js +13 -7
  7. package/dist/server/services/agent/orchestrator.js +1 -0
  8. package/dist/server/services/dev-server-service.js +44 -72
  9. package/dist/server/services/lifecycle-hook-service.js +21 -2
  10. package/dist/server/services/mcp-message-delivery-service.js +1 -1
  11. package/dist/server/services/mcp-message-request-service.js +11 -5
  12. package/dist/server/services/settings-service.js +51 -0
  13. package/dist/server/services/workspace-dialogue-service.js +2 -0
  14. package/dist/server/services/workspace-message-service.js +61 -5
  15. package/dist/server/utils/dev-server-instance.js +58 -0
  16. package/dist/server/utils/script-runner.js +6 -1
  17. package/dist/shared/launch-instructions.js +8 -4
  18. package/dist/shared/mcp-attachments.js +58 -0
  19. package/dist/shared/memory-tools.js +2 -2
  20. package/dist/shared/workspace-creation-tools.js +4 -28
  21. package/dist/shared/workspace-dialogue-tools.js +18 -6
  22. package/docker-compose.vps.yml +1 -1
  23. package/docs/deployment-vps.md +11 -0
  24. package/package.json +1 -1
  25. package/scripts/kobo-update +6 -2
  26. package/src/client/dist/pwa/assets/ActivityFeed-BYspOKKg.js +2 -0
  27. package/src/client/dist/pwa/assets/{ActivityFeed-DdJvMzRI.css → ActivityFeed-CmAFv-2c.css} +1 -1
  28. package/src/client/dist/pwa/assets/{AutoLoopFinalReviewSettings-CwnNUWCh.js → AutoLoopFinalReviewSettings-DXX3CqrY.js} +1 -1
  29. package/src/client/dist/pwa/assets/{ChangelogPage-D9ke0TYz.js → ChangelogPage-DKkI5IU-.js} +1 -1
  30. package/src/client/dist/pwa/assets/{ComparisonPanel-ytDu1L7a.js → ComparisonPanel-Cw5Dkan0.js} +1 -1
  31. package/src/client/dist/pwa/assets/{CreatePage-YQ_jGilf.js → CreatePage-B7PLFOH6.js} +1 -1
  32. package/src/client/dist/pwa/assets/{DashboardPage-C-PyArla.js → DashboardPage-Dw8nfkYR.js} +1 -1
  33. package/src/client/dist/pwa/assets/{DiffViewer-CPobwSwm.js → DiffViewer-DFlDwkmd.js} +3 -3
  34. package/src/client/dist/pwa/assets/{DocumentsPanel-BjZGNI-p.js → DocumentsPanel-CH0KzdJV.js} +1 -1
  35. package/src/client/dist/pwa/assets/{DrawerToggleButton-Dtd6fhYu.js → DrawerToggleButton-CZVpU8HH.js} +1 -1
  36. package/src/client/dist/pwa/assets/{GitPanel-BHJ3lpJZ.js → GitPanel-DjKL30gM.js} +3 -3
  37. package/src/client/dist/pwa/assets/{HealthPage-Lv_IdF1l.js → HealthPage-DnPp8GSx.js} +1 -1
  38. package/src/client/dist/pwa/assets/{MainLayout-DYumlWcP.js → MainLayout-1PaKfzdy.js} +2 -2
  39. package/src/client/dist/pwa/assets/{MemoryPanel-DS2F3x-S.js → MemoryPanel-BarliK2r.js} +1 -1
  40. package/src/client/dist/pwa/assets/{MemoryProposalList-D_KDS3uX.js → MemoryProposalList-BtVjVPp7.js} +1 -1
  41. package/src/client/dist/pwa/assets/{QBtnDropdown-XkBxmJO9.js → QBtnDropdown-Rzurb7nO.js} +1 -1
  42. package/src/client/dist/pwa/assets/{QExpansionItem-B5vbHxJ2.js → QExpansionItem-CaKEBcyx.js} +1 -1
  43. package/src/client/dist/pwa/assets/{QScrollArea-BE_fAg20.js → QScrollArea-hrsAyyJf.js} +1 -1
  44. package/src/client/dist/pwa/assets/{QSelect-BjZk4-r8.js → QSelect-Dr7rAt6u.js} +1 -1
  45. package/src/client/dist/pwa/assets/{QTooltip-Dhn6VXWR.js → QTooltip-CNMsXTmD.js} +1 -1
  46. package/src/client/dist/pwa/assets/{SchedulePanel-BDhIt9pX.js → SchedulePanel-nDw2eZ9K.js} +1 -1
  47. package/src/client/dist/pwa/assets/{SearchPage-CJrtIenZ.js → SearchPage-BXJ6iVt1.js} +1 -1
  48. package/src/client/dist/pwa/assets/SettingsPage-Dcq1Jeoo.css +1 -0
  49. package/src/client/dist/pwa/assets/SettingsPage-SHpE2q9Z.js +9 -0
  50. package/src/client/dist/pwa/assets/{SplitWorkspacePage-BV62otQw.js → SplitWorkspacePage-tfZ1kAdZ.js} +1 -1
  51. package/src/client/dist/pwa/assets/{TerminalPanel-TQUHtZ-q.js → TerminalPanel-Cw8R7lAw.js} +1 -1
  52. package/src/client/dist/pwa/assets/{ToolCallItem-DxdYefuz.js → ToolCallItem-DEKhFJEv.js} +2 -2
  53. package/src/client/dist/pwa/assets/{TouchPan-CtwfZgkX.js → TouchPan-DybGzRRK.js} +1 -1
  54. package/src/client/dist/pwa/assets/{TourReplayButton-DEC_Yt6E.js → TourReplayButton-BNdlAZUR.js} +1 -1
  55. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-BehGsWhI.js → WorkspaceAttentionLabels-B0H9omcK.js} +1 -1
  56. package/src/client/dist/pwa/assets/{WorkspacePage-Bk-WWOYM.css → WorkspacePage-B-82Rw9T.css} +1 -1
  57. package/src/client/dist/pwa/assets/{WorkspacePage-CQz6QL5L.js → WorkspacePage-tWGXnmrz.js} +3 -3
  58. package/src/client/dist/pwa/assets/{browser-B3bGJv7H.js → browser-DUTZhU-w.js} +1 -1
  59. package/src/client/dist/pwa/assets/{build-path-tree-Bwwe_8vU.js → build-path-tree-ZThcsIN5.js} +1 -1
  60. package/src/client/dist/pwa/assets/{custom-sounds-1Rb1a5LA.js → custom-sounds-CquHn2gw.js} +1 -1
  61. package/src/client/dist/pwa/assets/{de-bfOiFUu1.js → de-B3H0T-ft.js} +2 -2
  62. package/src/client/dist/pwa/assets/{editor.api-D2PTZoSF.js → editor.api-pSvhgban.js} +1 -1
  63. package/src/client/dist/pwa/assets/{efforts-JkWc_mU5.js → efforts-DJaFsLgd.js} +1 -1
  64. package/src/client/dist/pwa/assets/{es-C5Sk8pec.js → es-B4lACW6k.js} +2 -2
  65. package/src/client/dist/pwa/assets/{expand-template-C-aGZz7k.js → expand-template-C5D_p5eV.js} +1 -1
  66. package/src/client/dist/pwa/assets/{format-BbXB3nKS.js → format-DRfnkeRP.js} +1 -1
  67. package/src/client/dist/pwa/assets/{formatters-Bu3lC0Jk.js → formatters-Cog2icjt.js} +1 -1
  68. package/src/client/dist/pwa/assets/{fr-BNVJp67K.js → fr-3uNi02Jz.js} +2 -2
  69. package/src/client/dist/pwa/assets/{freemarker2-qq6O820q.js → freemarker2-W-OLsqOk.js} +1 -1
  70. package/src/client/dist/pwa/assets/{handlebars-nXATCa7t.js → handlebars-Kt5RbWuO.js} +1 -1
  71. package/src/client/dist/pwa/assets/{html-ZSNyTQeY.js → html-D5VKPBbB.js} +1 -1
  72. package/src/client/dist/pwa/assets/i18n-CEzCwMir.js +1 -0
  73. package/src/client/dist/pwa/assets/{index-BZKHE14f.js → index-BZwhT2-I.js} +4 -4
  74. package/src/client/dist/pwa/assets/{it-B9g24VRZ.js → it-DtFWyLHb.js} +2 -2
  75. package/src/client/dist/pwa/assets/{javascript-B3Elzrc0.js → javascript-DBjx8Bg2.js} +1 -1
  76. package/src/client/dist/pwa/assets/{liquid-CrMGh9mA.js → liquid-DYT4a7D7.js} +1 -1
  77. package/src/client/dist/pwa/assets/{mdx-C6nxSGQ5.js → mdx-9L1kysEY.js} +1 -1
  78. package/src/client/dist/pwa/assets/{monaco-contributions-WtuTHJJ8.js → monaco-contributions-DAme5TZS.js} +1 -1
  79. package/src/client/dist/pwa/assets/{monaco.contribution--KeSH1BU.js → monaco.contribution-C58k7TPw.js} +2 -2
  80. package/src/client/dist/pwa/assets/{network-auth-DknPaiQS.js → network-auth-BDUlgV-U.js} +1 -1
  81. package/src/client/dist/pwa/assets/{notify-theme-DTaB0RsK.js → notify-theme-DrQB-mrb.js} +1 -1
  82. package/src/client/dist/pwa/assets/{project-color-bm_jpHka.js → project-color-BlV5PUUW.js} +1 -1
  83. package/src/client/dist/pwa/assets/{python-CQI_FwfK.js → python-DJIlKr-Y.js} +1 -1
  84. package/src/client/dist/pwa/assets/{razor-BT2wh4_7.js → razor-CC3uqdaH.js} +1 -1
  85. package/src/client/dist/pwa/assets/{typescript-D3ibItMf.js → typescript-BnnkT9oE.js} +1 -1
  86. package/src/client/dist/pwa/assets/{use-search-index-status-BnFR-_2l.js → use-search-index-status-CExdHIar.js} +1 -1
  87. package/src/client/dist/pwa/assets/{use-tours-CsPhvrxf.js → use-tours-DOUjqO2f.js} +2 -2
  88. package/src/client/dist/pwa/assets/{use-worktree-restore-B5s0BuHk.js → use-worktree-restore-zIOYNIcb.js} +1 -1
  89. package/src/client/dist/pwa/assets/{workspace-templates-GLO1mBlj.js → workspace-templates-DXJG6flv.js} +1 -1
  90. package/src/client/dist/pwa/assets/{xml-EIEQlO3Y.js → xml-DJtVmsJT.js} +1 -1
  91. package/src/client/dist/pwa/assets/{yaml-CJdHLr6G.js → yaml-Dwuw8FcP.js} +1 -1
  92. package/src/client/dist/pwa/index.html +1 -1
  93. package/src/client/dist/pwa/sw.js +1 -1
  94. package/src/mcp-server/README.md +48 -4
  95. package/src/mcp-server/kobo-tasks-server.ts +8 -10
  96. package/src/client/dist/pwa/assets/ActivityFeed-2wu6qXx1.js +0 -2
  97. package/src/client/dist/pwa/assets/SettingsPage-BSwN45zt.js +0 -9
  98. package/src/client/dist/pwa/assets/SettingsPage-CNIpxG-U.css +0 -1
  99. package/src/client/dist/pwa/assets/i18n-B_b02gtS.js +0 -1
@@ -1,10 +1,11 @@
1
1
  import { execFile, spawn } from 'node:child_process';
2
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
3
- import path from 'node:path';
2
+ import { existsSync } from 'node:fs';
3
+ import { resolveInstance, sanitizeBranchName } from '../utils/dev-server-instance.js';
4
4
  import { assertWorkspaceLifecycleAvailable } from '../utils/workspace-lifecycle-guard.js';
5
5
  import { getProjectSettings } from './settings-service.js';
6
6
  import { emitEphemeral } from './websocket-service.js';
7
7
  import { getWorkspace, listWorkspaces, updateDevServerStatus } from './workspace-service.js';
8
+ export { parseEnvFile, resolveInstance, sanitizeBranchName } from '../utils/dev-server-instance.js';
8
9
  // ── Helpers ───────────────────────────────────────────────────────────────────
9
10
  /** Build a clean env for child processes, stripping Kobo-specific variables. */
10
11
  function cleanEnv() {
@@ -26,68 +27,16 @@ function runCommand(command, args, options = {}) {
26
27
  const trackedProcesses = new Map();
27
28
  const stoppingProcesses = new Map();
28
29
  const generations = new Map();
30
+ let shuttingDown = false;
31
+ export function beginDevServerShutdown() {
32
+ shuttingDown = true;
33
+ }
29
34
  /** Test-only: clear the tracked-processes map between tests. */
30
35
  export function _resetTrackedProcessesForTests() {
31
36
  trackedProcesses.clear();
32
37
  stoppingProcesses.clear();
33
38
  generations.clear();
34
- }
35
- // ── Pure helpers ───────────────────────────────────────────────────────────────
36
- /**
37
- * Sanitize a branch name for use as a Docker instance name.
38
- * Replace `/` and `_` with `-`, lowercase.
39
- */
40
- export function sanitizeBranchName(branch) {
41
- return branch.toLowerCase().replace(/[/_]/g, '-');
42
- }
43
- /**
44
- * Parse a `.env` file content into key=value pairs.
45
- * Skips empty lines and comments (#). Handles quotes.
46
- */
47
- export function parseEnvFile(content) {
48
- const result = {};
49
- for (const line of content.split('\n')) {
50
- const trimmed = line.trim();
51
- if (!trimmed || trimmed.startsWith('#'))
52
- continue;
53
- const eqIndex = trimmed.indexOf('=');
54
- if (eqIndex === -1)
55
- continue;
56
- const key = trimmed.slice(0, eqIndex).trim();
57
- let value = trimmed.slice(eqIndex + 1).trim();
58
- // Strip surrounding quotes
59
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
60
- value = value.slice(1, -1);
61
- }
62
- result[key] = value;
63
- }
64
- return result;
65
- }
66
- /**
67
- * Resolve the dev-server instance config for a given project + branch.
68
- * Looks in `<projectPath>/.container/instances/` for `.env` files.
69
- */
70
- export function resolveInstance(projectPath, workingBranch) {
71
- const instancesDir = path.join(projectPath, '.container', 'instances');
72
- if (!existsSync(instancesDir))
73
- return null;
74
- const sanitized = sanitizeBranchName(workingBranch);
75
- const files = readdirSync(instancesDir).filter((f) => f.endsWith('.env'));
76
- for (const file of files) {
77
- const content = readFileSync(path.join(instancesDir, file), 'utf-8');
78
- const parsed = parseEnvFile(content);
79
- if (parsed.INSTANCE_NAME &&
80
- parsed.INSTANCE_NAME.toLowerCase() === sanitized &&
81
- parsed.PROJECT_NAME &&
82
- /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(parsed.PROJECT_NAME)) {
83
- return {
84
- instanceName: parsed.INSTANCE_NAME,
85
- projectName: parsed.PROJECT_NAME,
86
- httpPort: parsed.HTTP_PORT ?? '',
87
- };
88
- }
89
- }
90
- return null;
39
+ shuttingDown = false;
91
40
  }
92
41
  // ── Docker helpers ─────────────────────────────────────────────────────────────
93
42
  /**
@@ -177,6 +126,8 @@ export async function getStatus(projectPath, workingBranch, workspaceId) {
177
126
  * Start the dev-server for a workspace.
178
127
  */
179
128
  export function startDevServer(workspaceId) {
129
+ if (shuttingDown)
130
+ throw new Error('Kōbō is shutting down');
180
131
  assertWorkspaceLifecycleAvailable(workspaceId);
181
132
  const workspace = getWorkspace(workspaceId);
182
133
  if (!workspace) {
@@ -231,7 +182,7 @@ export function startDevServer(workspaceId) {
231
182
  return;
232
183
  trackedProcesses.delete(workspaceId);
233
184
  const currentStatus = await getStatus(workspace.projectPath, workspace.workingBranch);
234
- if (generations.get(workspaceId) !== generation || stoppingProcesses.has(workspaceId))
185
+ if (shuttingDown || generations.get(workspaceId) !== generation || stoppingProcesses.has(workspaceId))
235
186
  return;
236
187
  updateDevServerStatus(workspaceId, currentStatus.status);
237
188
  emitEphemeral(workspaceId, 'devserver:status', currentStatus);
@@ -244,7 +195,7 @@ export function startDevServer(workspaceId) {
244
195
  console.error(`[dev-server] Process exited with code ${code} for workspace ${workspaceId}`);
245
196
  });
246
197
  proc.on('error', (err) => {
247
- if (trackedProcesses.get(workspaceId) !== proc)
198
+ if (shuttingDown || trackedProcesses.get(workspaceId) !== proc || generations.get(workspaceId) !== generation)
248
199
  return;
249
200
  // A signal failure is also an 'error' event; it does not prove process exit.
250
201
  if (!proc.pid)
@@ -373,6 +324,16 @@ function signalAndWait(proc, signal, timeoutMs) {
373
324
  }
374
325
  });
375
326
  }
327
+ async function stopTrackedProcess(workspaceId) {
328
+ const tracked = trackedProcesses.get(workspaceId);
329
+ if (!tracked)
330
+ return;
331
+ const exited = (await signalAndWait(tracked, 'SIGTERM', 3_000)) || (await signalAndWait(tracked, 'SIGKILL', 1_000));
332
+ if (!exited)
333
+ throw new Error('Dev server stop is not confirmed; retry after the process exits');
334
+ if (trackedProcesses.get(workspaceId) === tracked)
335
+ trackedProcesses.delete(workspaceId);
336
+ }
376
337
  async function stopDevServerOwned(workspaceId) {
377
338
  const workspace = getWorkspace(workspaceId);
378
339
  if (!workspace) {
@@ -383,14 +344,7 @@ async function stopDevServerOwned(workspaceId) {
383
344
  const worktreePath = workspace.worktreePath;
384
345
  const cwd = worktreePath && existsSync(worktreePath) ? worktreePath : workspace.projectPath;
385
346
  // Kill tracked process first (covers Node servers and any spawned process)
386
- const tracked = trackedProcesses.get(workspaceId);
387
- if (tracked) {
388
- const exited = (await signalAndWait(tracked, 'SIGTERM', 3_000)) || (await signalAndWait(tracked, 'SIGKILL', 1_000));
389
- if (!exited)
390
- throw new Error('Dev server stop is not confirmed; retry after the process exits');
391
- if (trackedProcesses.get(workspaceId) === tracked)
392
- trackedProcesses.delete(workspaceId);
393
- }
347
+ await stopTrackedProcess(workspaceId);
394
348
  const settings = getProjectSettings(workspace.projectPath);
395
349
  let stopError;
396
350
  let customStopSucceeded = false;
@@ -448,12 +402,30 @@ async function stopDevServerOwned(workspaceId) {
448
402
  emitEphemeral(workspaceId, 'devserver:status', status);
449
403
  return status;
450
404
  }
451
- /** Stop direct and Docker dev servers for every persisted workspace. */
452
- export async function stopAllDevServers() {
405
+ /** Shut down local processes; optionally leave independently running Docker services alone. */
406
+ export async function stopAllDevServers(options = {}) {
407
+ beginDevServerShutdown();
453
408
  const workspaceIds = new Set([...listWorkspaces(true).map((workspace) => workspace.id), ...trackedProcesses.keys()]);
454
409
  await Promise.all([...workspaceIds].map(async (workspaceId) => {
455
410
  try {
456
- await stopDevServer(workspaceId);
411
+ if (!options.preserveDocker) {
412
+ await stopDevServer(workspaceId);
413
+ }
414
+ else {
415
+ // A stop already requested by the user must still finish.
416
+ const stopping = stoppingProcesses.get(workspaceId);
417
+ if (stopping) {
418
+ await stopping;
419
+ return;
420
+ }
421
+ const hadProcess = trackedProcesses.has(workspaceId);
422
+ await stopTrackedProcess(workspaceId);
423
+ const workspace = getWorkspace(workspaceId);
424
+ // Preserve Docker state; getStatus re-inspects Compose after restart.
425
+ if (hadProcess && workspace && !resolveInstance(workspace.projectPath, workspace.workingBranch)) {
426
+ updateDevServerStatus(workspaceId, 'stopped');
427
+ }
428
+ }
457
429
  }
458
430
  catch (err) {
459
431
  console.error(`[dev-server] Failed to stop '${workspaceId}' during shutdown:`, err);
@@ -4,11 +4,14 @@ 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
6
  import { getEffectiveSettings, getGlobalSettings, getProjectSettings } from './settings-service.js';
7
+ import { listWorkspaces } from './workspace-service.js';
8
+ export const SHUTDOWN_SCRIPT_TIMEOUT_MS = 60_000;
7
9
  /** Which effective-settings key holds the script for each event. */
8
10
  const SCRIPT_KEY = {
9
11
  'session-ended': 'sessionEndedScript',
10
12
  'pr-merged': 'prMergedScript',
11
13
  'autoloop-disabled': 'autoLoopDisabledScript',
14
+ 'kobo-shutdown': 'shutdownScript',
12
15
  };
13
16
  function getRow(workspaceId) {
14
17
  const row = getDb()
@@ -33,7 +36,7 @@ export async function runLifecycleHook(event, workspaceId, extraEnv) {
33
36
  const script = getEffectiveSettings(row.project_path)[SCRIPT_KEY[event]];
34
37
  // Empty (or blank) means the user never wrote one. Hooks are opt-in: they
35
38
  // run arbitrary shell, so absence of configuration is absence of a hook.
36
- if (!script.trim())
39
+ if (!script?.trim())
37
40
  return null;
38
41
  const global = getGlobalSettings();
39
42
  const projectSettings = getProjectSettings(row.project_path);
@@ -55,10 +58,11 @@ export async function runLifecycleHook(event, workspaceId, extraEnv) {
55
58
  workspaceId,
56
59
  worktreePath,
57
60
  script,
58
- // Namespaced per event: three hooks streaming into one `hook:output` feed
61
+ // Namespaced per event: multiple hooks streaming into one `hook:output` feed
59
62
  // would be indistinguishable in the UI.
60
63
  eventPrefix: `hook:${event}`,
61
64
  tmpFileName: `.hook-${event}.tmp`,
65
+ ...(event === 'kobo-shutdown' ? { timeoutMs: SHUTDOWN_SCRIPT_TIMEOUT_MS } : {}),
62
66
  env: {
63
67
  workspaceName: row.name,
64
68
  branchName: row.working_branch,
@@ -73,6 +77,21 @@ export async function runLifecycleHook(event, workspaceId, extraEnv) {
73
77
  return null;
74
78
  }
75
79
  }
80
+ /** Called once by graceful shutdown, after stopping writers and before Docker teardown. */
81
+ export async function onKoboShutdown(signal, isAgentStillRunning) {
82
+ await Promise.all(listWorkspaces(true)
83
+ .filter((workspace) => !workspace.worktreePurgedAt)
84
+ .map(async (workspace) => {
85
+ if (isAgentStillRunning(workspace.id)) {
86
+ console.warn(`[lifecycle-hook] 'kobo-shutdown' skipped for '${workspace.id}': agent stop unconfirmed`);
87
+ return;
88
+ }
89
+ const result = await runLifecycleHook('kobo-shutdown', workspace.id, { KOBO_SHUTDOWN_SIGNAL: signal });
90
+ if (result && result.exitCode !== 0) {
91
+ console.error(`[lifecycle-hook] 'kobo-shutdown' failed for '${workspace.id}' with code ${result.exitCode}`);
92
+ }
93
+ }));
94
+ }
76
95
  /**
77
96
  * An agent session ended. Fires for every real end — clean finish, error, user
78
97
  * stop, watchdog kill — and lets the script tell them apart via
@@ -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);
@@ -996,6 +996,28 @@ const settingsMigrations = [
996
996
  global.showProgressBars = true;
997
997
  },
998
998
  },
999
+ {
1000
+ version: 66,
1001
+ name: 'shutdown-services-and-hooks',
1002
+ migrate: ({ global, projects }) => {
1003
+ if (typeof global.stopDevServersOnShutdown !== 'boolean')
1004
+ global.stopDevServersOnShutdown = true;
1005
+ if (typeof global.shutdownScript !== 'string')
1006
+ global.shutdownScript = '';
1007
+ for (const project of projects) {
1008
+ if (typeof project.shutdownScript !== 'string')
1009
+ project.shutdownScript = '';
1010
+ }
1011
+ },
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
+ },
999
1021
  ];
1000
1022
  /** Current settings schema version — always equals the highest migration version. */
1001
1023
  export const SETTINGS_SCHEMA_VERSION = settingsMigrations.length > 0 ? settingsMigrations[settingsMigrations.length - 1].version : 0;
@@ -1044,6 +1066,7 @@ function defaultSettings() {
1044
1066
  archiveScript: '',
1045
1067
  changeSourceBranchScript: DEFAULT_CHANGE_SOURCE_BRANCH_SCRIPT,
1046
1068
  sessionEndedScript: '',
1069
+ shutdownScript: '',
1047
1070
  prMergedScript: '',
1048
1071
  autoLoopDisabledScript: '',
1049
1072
  editorCommand: '',
@@ -1052,7 +1075,9 @@ function defaultSettings() {
1052
1075
  autoPurgeOnPrMerged: false,
1053
1076
  autoLoopMaxRetries: 5,
1054
1077
  agentWatchdogEnabled: false,
1078
+ forceImmediateMessages: false,
1055
1079
  showProgressBars: true,
1080
+ stopDevServersOnShutdown: true,
1056
1081
  awaitingUserReminderMinutes: 0,
1057
1082
  activityDigestEnabled: true,
1058
1083
  maxConcurrentAgents: 2,
@@ -1156,6 +1181,7 @@ function defaultProjectSettings(projectPath) {
1156
1181
  archiveScript: '',
1157
1182
  changeSourceBranchScript: '',
1158
1183
  sessionEndedScript: '',
1184
+ shutdownScript: '',
1159
1185
  prMergedScript: '',
1160
1186
  autoLoopDisabledScript: '',
1161
1187
  devServer: {
@@ -1201,11 +1227,19 @@ export function runSettingsMigrations(raw) {
1201
1227
  current.global.workflowPolicy = resolveWorkflowPolicy(current.global.workflowPolicy);
1202
1228
  if (!isMemoryMode(current.global.memoryMode))
1203
1229
  current.global.memoryMode = 'hybrid';
1230
+ if (typeof current.global.forceImmediateMessages !== 'boolean')
1231
+ current.global.forceImmediateMessages = false;
1204
1232
  if (typeof current.global.showProgressBars !== 'boolean')
1205
1233
  current.global.showProgressBars = true;
1234
+ if (typeof current.global.stopDevServersOnShutdown !== 'boolean')
1235
+ current.global.stopDevServersOnShutdown = true;
1236
+ if (typeof current.global.shutdownScript !== 'string')
1237
+ current.global.shutdownScript = '';
1206
1238
  if (typeof current.global.agentWatchdogEnabled !== 'boolean')
1207
1239
  current.global.agentWatchdogEnabled = false;
1208
1240
  for (const project of current.projects) {
1241
+ if (typeof project.shutdownScript !== 'string')
1242
+ project.shutdownScript = '';
1209
1243
  if (!isWorkflowPolicy(project.workflowPolicy))
1210
1244
  project.workflowPolicy = {};
1211
1245
  }
@@ -1478,6 +1512,7 @@ export function getEffectiveSettings(projectPath) {
1478
1512
  archiveScript: settings.global.archiveScript,
1479
1513
  changeSourceBranchScript: settings.global.changeSourceBranchScript,
1480
1514
  sessionEndedScript: settings.global.sessionEndedScript,
1515
+ shutdownScript: settings.global.shutdownScript,
1481
1516
  prMergedScript: settings.global.prMergedScript,
1482
1517
  autoLoopDisabledScript: settings.global.autoLoopDisabledScript,
1483
1518
  notionStatusProperty: settings.global.notionStatusProperty,
@@ -1508,6 +1543,7 @@ export function getEffectiveSettings(projectPath) {
1508
1543
  archiveScript: project.archiveScript || settings.global.archiveScript,
1509
1544
  changeSourceBranchScript: project.changeSourceBranchScript || settings.global.changeSourceBranchScript,
1510
1545
  sessionEndedScript: project.sessionEndedScript || settings.global.sessionEndedScript,
1546
+ shutdownScript: project.shutdownScript || settings.global.shutdownScript,
1511
1547
  prMergedScript: project.prMergedScript || settings.global.prMergedScript,
1512
1548
  autoLoopDisabledScript: project.autoLoopDisabledScript || settings.global.autoLoopDisabledScript,
1513
1549
  notionStatusProperty: settings.global.notionStatusProperty,
@@ -1557,6 +1593,15 @@ export function updateGlobalSettings(input) {
1557
1593
  delete data.autoLoopMaxRetries;
1558
1594
  }
1559
1595
  }
1596
+ if ('shutdownScript' in data && typeof data.shutdownScript !== 'string') {
1597
+ delete data.shutdownScript;
1598
+ }
1599
+ if ('stopDevServersOnShutdown' in data && typeof data.stopDevServersOnShutdown !== 'boolean') {
1600
+ delete data.stopDevServersOnShutdown;
1601
+ }
1602
+ if ('forceImmediateMessages' in data && typeof data.forceImmediateMessages !== 'boolean') {
1603
+ delete data.forceImmediateMessages;
1604
+ }
1560
1605
  if ('showProgressBars' in data && typeof data.showProgressBars !== 'boolean') {
1561
1606
  delete data.showProgressBars;
1562
1607
  }
@@ -1619,6 +1664,7 @@ export function updateGlobalSettings(input) {
1619
1664
  'archiveScript',
1620
1665
  'changeSourceBranchScript',
1621
1666
  'sessionEndedScript',
1667
+ 'shutdownScript',
1622
1668
  'prMergedScript',
1623
1669
  'autoLoopDisabledScript',
1624
1670
  'editorCommand',
@@ -1627,7 +1673,9 @@ export function updateGlobalSettings(input) {
1627
1673
  'autoPurgeOnPrMerged',
1628
1674
  'autoLoopMaxRetries',
1629
1675
  'agentWatchdogEnabled',
1676
+ 'forceImmediateMessages',
1630
1677
  'showProgressBars',
1678
+ 'stopDevServersOnShutdown',
1631
1679
  'awaitingUserReminderMinutes',
1632
1680
  'activityDigestEnabled',
1633
1681
  'maxConcurrentAgents',
@@ -1823,6 +1871,8 @@ export function upsertProject(projectPath, data) {
1823
1871
  }
1824
1872
  if ('workflowPolicy' in data && !isWorkflowPolicy(data.workflowPolicy))
1825
1873
  throw new Error('Invalid workflowPolicy');
1874
+ if ('shutdownScript' in data && typeof data.shutdownScript !== 'string')
1875
+ throw new Error('Invalid shutdownScript');
1826
1876
  const allowedProjectKeys = [
1827
1877
  'workflowPolicy',
1828
1878
  'displayName',
@@ -1842,6 +1892,7 @@ export function upsertProject(projectPath, data) {
1842
1892
  'archiveScript',
1843
1893
  'changeSourceBranchScript',
1844
1894
  'sessionEndedScript',
1895
+ 'shutdownScript',
1845
1896
  'prMergedScript',
1846
1897
  'autoLoopDisabledScript',
1847
1898
  'devServer',
@@ -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
+ }
@@ -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,6 +52,8 @@ export function runScript(opts) {
49
52
  BRANCH_NAME: env?.branchName ?? '',
50
53
  SOURCE_BRANCH: env?.sourceBranch ?? '',
51
54
  PROJECT_PATH: env?.projectPath ?? '',
55
+ PROJECT_NAME: instance?.projectName ?? '',
56
+ INSTANCE: instance?.instanceName ?? sanitizeBranchName(env?.branchName ?? ''),
52
57
  },
53
58
  stdio: ['ignore', 'pipe', 'pipe'],
54
59
  // Detached so this is the leader of its own process group: a script
@@ -74,7 +79,7 @@ export function runScript(opts) {
74
79
  proc.stdout?.destroy();
75
80
  proc.stderr?.destroy();
76
81
  wsService.emit(workspaceId, `${eventPrefix}:output`, {
77
- text: `[kobo] Script timed out after ${Math.round(timeoutMs / 60000)} minutes`,
82
+ text: `[kobo] Script timed out after ${timeoutMs < 60000 ? `${Math.round(timeoutMs / 1000)} seconds` : `${Math.round(timeoutMs / 60000)} minutes`}`,
78
83
  });
79
84
  // SIGKILL on the process group gives a running `git commit` no chance to
80
85
  // release its index lock. Name the file here, while the user is looking