@loicngr/kobo 2.1.22 → 2.1.23

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 (88) hide show
  1. package/AGENTS.md +47 -0
  2. package/CHANGELOG.md +4 -0
  3. package/CONFIGURATION.md +89 -0
  4. package/dist/mcp-server/kobo-tasks-server.js +4 -4
  5. package/dist/server/index.js +21 -6
  6. package/dist/server/services/agent/orchestrator.js +1 -0
  7. package/dist/server/services/dev-server-service.js +41 -13
  8. package/dist/server/services/lifecycle-hook-service.js +29 -2
  9. package/dist/server/services/settings-service.js +36 -0
  10. package/dist/server/utils/script-runner.js +3 -1
  11. package/dist/shared/launch-instructions.js +8 -4
  12. package/dist/shared/memory-tools.js +2 -2
  13. package/docker-compose.vps.yml +1 -1
  14. package/docs/deployment-vps.md +11 -0
  15. package/package.json +1 -1
  16. package/scripts/kobo-update +6 -2
  17. package/src/client/dist/pwa/assets/ActivityFeed-1OKX_g3B.js +2 -0
  18. package/src/client/dist/pwa/assets/{ActivityFeed-DdJvMzRI.css → ActivityFeed-CmAFv-2c.css} +1 -1
  19. package/src/client/dist/pwa/assets/{AutoLoopFinalReviewSettings-CwnNUWCh.js → AutoLoopFinalReviewSettings-VoA9uBw0.js} +1 -1
  20. package/src/client/dist/pwa/assets/{ChangelogPage-D9ke0TYz.js → ChangelogPage-DHl3hD3t.js} +1 -1
  21. package/src/client/dist/pwa/assets/{ComparisonPanel-ytDu1L7a.js → ComparisonPanel-D6EKTtni.js} +1 -1
  22. package/src/client/dist/pwa/assets/{CreatePage-YQ_jGilf.js → CreatePage-CZzEOVVq.js} +1 -1
  23. package/src/client/dist/pwa/assets/{DashboardPage-C-PyArla.js → DashboardPage-DjLoUvjA.js} +1 -1
  24. package/src/client/dist/pwa/assets/{DiffViewer-CPobwSwm.js → DiffViewer-BQ6OEmoi.js} +3 -3
  25. package/src/client/dist/pwa/assets/{DocumentsPanel-BjZGNI-p.js → DocumentsPanel-yrQviqCd.js} +1 -1
  26. package/src/client/dist/pwa/assets/{DrawerToggleButton-Dtd6fhYu.js → DrawerToggleButton-D5qNeXnn.js} +1 -1
  27. package/src/client/dist/pwa/assets/{GitPanel-BHJ3lpJZ.js → GitPanel-DCXlhcAD.js} +3 -3
  28. package/src/client/dist/pwa/assets/{HealthPage-Lv_IdF1l.js → HealthPage-COicuNQn.js} +1 -1
  29. package/src/client/dist/pwa/assets/{MainLayout-DYumlWcP.js → MainLayout-ZcExZTqC.js} +2 -2
  30. package/src/client/dist/pwa/assets/{MemoryPanel-DS2F3x-S.js → MemoryPanel-D73Qz9f7.js} +1 -1
  31. package/src/client/dist/pwa/assets/{MemoryProposalList-D_KDS3uX.js → MemoryProposalList-BGlq2DkD.js} +1 -1
  32. package/src/client/dist/pwa/assets/{QBtnDropdown-XkBxmJO9.js → QBtnDropdown-CykBCmwa.js} +1 -1
  33. package/src/client/dist/pwa/assets/{QExpansionItem-B5vbHxJ2.js → QExpansionItem-CAW9J1Uv.js} +1 -1
  34. package/src/client/dist/pwa/assets/{QScrollArea-BE_fAg20.js → QScrollArea-C_d-8eO7.js} +1 -1
  35. package/src/client/dist/pwa/assets/{QSelect-BjZk4-r8.js → QSelect-C1AN93yA.js} +1 -1
  36. package/src/client/dist/pwa/assets/{QTooltip-Dhn6VXWR.js → QTooltip-C9qaLW_h.js} +1 -1
  37. package/src/client/dist/pwa/assets/{SchedulePanel-BDhIt9pX.js → SchedulePanel-CQpzDhq7.js} +1 -1
  38. package/src/client/dist/pwa/assets/{SearchPage-CJrtIenZ.js → SearchPage-fZlSSEu_.js} +1 -1
  39. package/src/client/dist/pwa/assets/{SettingsPage-CNIpxG-U.css → SettingsPage-BCOF0ssp.css} +1 -1
  40. package/src/client/dist/pwa/assets/SettingsPage-eyJZxeCJ.js +9 -0
  41. package/src/client/dist/pwa/assets/{SplitWorkspacePage-BV62otQw.js → SplitWorkspacePage-CfeO6TZb.js} +1 -1
  42. package/src/client/dist/pwa/assets/{TerminalPanel-TQUHtZ-q.js → TerminalPanel-CfDZOteT.js} +1 -1
  43. package/src/client/dist/pwa/assets/{ToolCallItem-DxdYefuz.js → ToolCallItem-CHE6tylq.js} +2 -2
  44. package/src/client/dist/pwa/assets/{TouchPan-CtwfZgkX.js → TouchPan-Csev_qU6.js} +1 -1
  45. package/src/client/dist/pwa/assets/{TourReplayButton-DEC_Yt6E.js → TourReplayButton-Crr7tF1b.js} +1 -1
  46. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-BehGsWhI.js → WorkspaceAttentionLabels-DpUZOiJv.js} +1 -1
  47. package/src/client/dist/pwa/assets/{WorkspacePage-CQz6QL5L.js → WorkspacePage-_QSKLO42.js} +3 -3
  48. package/src/client/dist/pwa/assets/{browser-B3bGJv7H.js → browser-o7ftkZex.js} +1 -1
  49. package/src/client/dist/pwa/assets/{build-path-tree-Bwwe_8vU.js → build-path-tree-D6dZQpIb.js} +1 -1
  50. package/src/client/dist/pwa/assets/{custom-sounds-1Rb1a5LA.js → custom-sounds-DVX9zCET.js} +1 -1
  51. package/src/client/dist/pwa/assets/{de-bfOiFUu1.js → de-DOE4H_Jf.js} +1 -1
  52. package/src/client/dist/pwa/assets/{editor.api-D2PTZoSF.js → editor.api-C8TYnUXm.js} +1 -1
  53. package/src/client/dist/pwa/assets/{efforts-JkWc_mU5.js → efforts-DfsG0_o0.js} +1 -1
  54. package/src/client/dist/pwa/assets/{es-C5Sk8pec.js → es-BW57JPlm.js} +1 -1
  55. package/src/client/dist/pwa/assets/{expand-template-C-aGZz7k.js → expand-template-CJZlWMfI.js} +1 -1
  56. package/src/client/dist/pwa/assets/{format-BbXB3nKS.js → format-DOCqk2Dn.js} +1 -1
  57. package/src/client/dist/pwa/assets/{formatters-Bu3lC0Jk.js → formatters-BFocC6LJ.js} +1 -1
  58. package/src/client/dist/pwa/assets/{fr-BNVJp67K.js → fr-DnmIKHaQ.js} +1 -1
  59. package/src/client/dist/pwa/assets/{freemarker2-qq6O820q.js → freemarker2-CfME4WOK.js} +1 -1
  60. package/src/client/dist/pwa/assets/{handlebars-nXATCa7t.js → handlebars-uvKdDnJl.js} +1 -1
  61. package/src/client/dist/pwa/assets/{html-ZSNyTQeY.js → html-BrEPgLuD.js} +1 -1
  62. package/src/client/dist/pwa/assets/i18n-BmTCjikk.js +1 -0
  63. package/src/client/dist/pwa/assets/{index-BZKHE14f.js → index-z1Oh2-I_.js} +3 -3
  64. package/src/client/dist/pwa/assets/{it-B9g24VRZ.js → it-DSrEQgZQ.js} +1 -1
  65. package/src/client/dist/pwa/assets/{javascript-B3Elzrc0.js → javascript-KH2xP4lQ.js} +1 -1
  66. package/src/client/dist/pwa/assets/{liquid-CrMGh9mA.js → liquid-D08TlsVs.js} +1 -1
  67. package/src/client/dist/pwa/assets/{mdx-C6nxSGQ5.js → mdx-y74A51cS.js} +1 -1
  68. package/src/client/dist/pwa/assets/{monaco-contributions-WtuTHJJ8.js → monaco-contributions-Cx8iaAhd.js} +1 -1
  69. package/src/client/dist/pwa/assets/{monaco.contribution--KeSH1BU.js → monaco.contribution-CFq0guCl.js} +2 -2
  70. package/src/client/dist/pwa/assets/{network-auth-DknPaiQS.js → network-auth-DnGOOzCV.js} +1 -1
  71. package/src/client/dist/pwa/assets/{notify-theme-DTaB0RsK.js → notify-theme-B9lUAgQl.js} +1 -1
  72. package/src/client/dist/pwa/assets/{project-color-bm_jpHka.js → project-color-TgENPFLi.js} +1 -1
  73. package/src/client/dist/pwa/assets/{python-CQI_FwfK.js → python-Cy3bAVPq.js} +1 -1
  74. package/src/client/dist/pwa/assets/{razor-BT2wh4_7.js → razor-gD9QOuiV.js} +1 -1
  75. package/src/client/dist/pwa/assets/{typescript-D3ibItMf.js → typescript-CEItY0bH.js} +1 -1
  76. package/src/client/dist/pwa/assets/{use-search-index-status-BnFR-_2l.js → use-search-index-status-BtJ0pOy9.js} +1 -1
  77. package/src/client/dist/pwa/assets/{use-tours-CsPhvrxf.js → use-tours-COLDth2h.js} +2 -2
  78. package/src/client/dist/pwa/assets/{use-worktree-restore-B5s0BuHk.js → use-worktree-restore-CShbwpGy.js} +1 -1
  79. package/src/client/dist/pwa/assets/{workspace-templates-GLO1mBlj.js → workspace-templates-BOMNQgBl.js} +1 -1
  80. package/src/client/dist/pwa/assets/{xml-EIEQlO3Y.js → xml-BJvL5qcE.js} +1 -1
  81. package/src/client/dist/pwa/assets/{yaml-CJdHLr6G.js → yaml-DtkVRZaX.js} +1 -1
  82. package/src/client/dist/pwa/index.html +1 -1
  83. package/src/client/dist/pwa/sw.js +1 -1
  84. package/src/mcp-server/README.md +13 -0
  85. package/src/mcp-server/kobo-tasks-server.ts +5 -4
  86. package/src/client/dist/pwa/assets/ActivityFeed-2wu6qXx1.js +0 -2
  87. package/src/client/dist/pwa/assets/SettingsPage-BSwN45zt.js +0 -9
  88. package/src/client/dist/pwa/assets/i18n-B_b02gtS.js +0 -1
package/AGENTS.md CHANGED
@@ -279,6 +279,26 @@ Two emit flavors in `websocket-service.ts`:
279
279
 
280
280
  ## External integrations
281
281
 
282
+ ### Graceful shutdown and development services
283
+
284
+ `stopDevServersOnShutdown` is a global boolean, default `true` (JSON settings
285
+ migration v66). Shutdown first rejects new dev-server starts and stops tracked
286
+ local processes/agents. Disabling the setting skips configured dev stop commands
287
+ and Compose teardown, preserving independent Docker services; manual Stop and
288
+ workspace deletion/purge remain unchanged. Status recovery uses the existing
289
+ instance-file and exact Compose-label inspection, never a duplicate startup.
290
+
291
+ `shutdownScript` follows the global/project script cascade: empty inherits,
292
+ whitespace disables. The `kobo-shutdown` lifecycle hook runs once per persisted
293
+ workspace with an existing, non-purged worktree (including archived workspaces),
294
+ after local writers stop and before optional Docker teardown. Skip unconfirmed
295
+ agent stops. Each hook has a 1-minute timeout, and the outer shutdown deadline
296
+ is 150 seconds (supervisors need at least 180 seconds). Inject `PROJECT_NAME` from
297
+ the workspace instance config, empty if unavailable, and `INSTANCE`; never guess
298
+ a Compose project. User hooks may explicitly remove Docker resources even when
299
+ automatic shutdown is disabled. Preserve hook outputs and keep SQLite open until
300
+ hooks settle. Do not run scripts on abrupt exits or claim they survive SIGKILL.
301
+
282
302
  ### VPS deployment tooling
283
303
 
284
304
  `docker-compose.vps.yml` is the standalone fresh-instance example for an existing
@@ -381,6 +401,33 @@ expand tool permissions. Initial writable auto-loop launches seed tasks and end;
381
401
  manual launches implement under the workflow policy. Preserve custom templates
382
402
  and frozen migration text when changing generated guidance.
383
403
 
404
+ Prefer dedicated managed MCP operations and require checking their results.
405
+ Writable launches synchronize task progress and blockers during work using only
406
+ supported statuses; restricted launches must not receive task mutation guidance.
407
+ Delegation passes the objective, worktree path, constraints, permissions, test
408
+ runtime and applicable MCP/reporting rules explicitly. Keep final reporting
409
+ evidence-based and phase-aware: distinguish actual checks, delegated results,
410
+ blocked/unverified work and jobs still running. Reuse the shared progress lifecycle
411
+ instead of duplicating it or finishing ongoing work at the end of a turn.
412
+
413
+ Dev-server lifecycle guidance must cover backend/integration dependencies as well
414
+ as frontend/E2E checks. Agents and delegates inspect/reuse the managed instance,
415
+ start/stop only through its MCP tools under writable launch permissions, and
416
+ report unavailable tools/configuration or startup failures instead of bypassing
417
+ Kōbō with direct shell startup commands. Keep tool descriptions in sync.
418
+ Distinguish the agent runtime from project containers, running status from
419
+ application readiness, and interrupted reporting from terminated work. Keep
420
+ readiness probes bounded and do not suggest bypassing failed MCP authentication
421
+ or replaying an ambiguous mutation without checking its outcome.
422
+
423
+ Launch guidance explicitly directs agents to consult injected Kōbō
424
+ memory before targeted retrieval, and to save confirmed reusable facts through
425
+ `remember` only under the current mode/permissions. Preserve retrieval budgets,
426
+ scope selection and Hybrid proposal semantics; native provider memory is not a
427
+ replacement for this shared store.
428
+ Keep detailed workflow guidance in the application launch instructions: the
429
+ separate mode-specific memory bootstrap has a strict total retrieval budget.
430
+
384
431
  The engine's `onPromptPrepared` callback supplies only its textual Kōbō prefix and
385
432
  system append. Initial creation and pending initial retries opt into a single
386
433
  session-scoped `user:message` with `sender: system-prompt`, displayed as Kōbō
package/CHANGELOG.md CHANGED
@@ -4,6 +4,10 @@ All notable changes to Kōbō are documented here. The format is based on
4
4
  [Keep a Changelog](https://keepachangelog.com/). Each release is an `## <version>`
5
5
  section — the in-app "What's new" dialog reads this file.
6
6
 
7
+ ## 2.1.23
8
+
9
+ - feat: configure shutdown hooks and improve agent guidance
10
+
7
11
  ## 2.1.22
8
12
 
9
13
  - feat(settings): make agent watchdogs opt-in and progress bars configurable
package/CONFIGURATION.md CHANGED
@@ -234,6 +234,8 @@ Projects override a subset of global settings. Anything you set here takes prece
234
234
  | `taskPromptTemplate` | `string` | Prompt used to prefill the mission description when this project is selected. Empty leaves the description without a project-provided prompt. |
235
235
  | `forge` | `'auto' \| 'github' \| 'gitlab' \| 'bitbucket-community' \| 'none'` | Which forge provides PR/MR features. `auto` detects from the git remote URL. See [Forge integration](#forge-integration). |
236
236
  | `devServer.startCommand` / `stopCommand` | `string` | Per-workspace dev server commands. Docker, npm, or any shell-startable process. See [Dev server](#dev-server) for the status/URL contract. |
237
+ | `stopDevServersOnShutdown` | `boolean` | Global, default `true`. Disable to leave Docker services running when Kōbō exits or updates. Tracked local processes always stop. Explicit Stop and workspace teardown still stop services. |
238
+ | `shutdownScript` | `string` | Optional global/per-project shutdown hook, run in each existing workspace worktree before automatic Docker teardown. Empty project value inherits; whitespace disables. See [Kōbō shutdown](#kōbō-shutdown). |
237
239
  | `e2e.framework` | `'cypress' \| 'playwright' \| 'jest' \| 'vitest' \| 'other' \| ''` | E2E framework auto-loop grooming should target. |
238
240
  | `e2e.skill` | `string` | Optional skill name injected into the E2E grooming prompt. |
239
241
  | `e2e.prompt` | `string` | Free-form prompt appended to every `[E2E] ` sub-task. |
@@ -1597,6 +1599,48 @@ logs contract does not provide general process health monitoring.
1597
1599
  **No instance file at all**: Start/Stop run your commands; the panel uses the
1598
1600
  saved status when available, otherwise `unknown`, with no URL or Docker logs.
1599
1601
 
1602
+ ### Kōbō shutdown
1603
+
1604
+ In **Settings → Scripts**, **Stop development servers when Kōbō exits** defaults
1605
+ to enabled. Disable it to preserve independently running Docker containers during
1606
+ an update/restart. Kōbō still terminates its tracked local processes, including
1607
+ startup commands. Use detached Docker services (`docker compose up -d`); a
1608
+ foreground Compose process or a custom signal trap can itself stop containers.
1609
+ After restart, the existing instance-file/Compose-label contract lets the panel
1610
+ inspect those containers and recover their URL and logs without starting them.
1611
+ The setting does not change manual Stop, workspace deletion/purge, or user hooks.
1612
+
1613
+ The optional **Hook: Kōbō shutdown** uses the global script by default. A project
1614
+ script replaces it; an empty project field inherits and whitespace disables it.
1615
+ It runs **once per workspace, in that workspace's worktree**, including archived
1616
+ workspaces with an existing checkout. Purged/missing worktrees and workspaces
1617
+ whose agent stop is unconfirmed are skipped. Scripts run after tracked agent/local
1618
+ process shutdown and before automatic Docker teardown, even when automatic Docker
1619
+ shutdown is disabled. Each script has a **1-minute timeout**; failures are logged
1620
+ and do not cancel shutdown. Output is recorded under `hook:kobo-shutdown`.
1621
+
1622
+ Available variables include `WORKSPACE_ID`, `WORKSPACE_NAME`, `BRANCH_NAME`,
1623
+ `SOURCE_BRANCH`, `PROJECT_PATH`, `KOBO_HOOK_EVENT=kobo-shutdown`, and
1624
+ `KOBO_SHUTDOWN_SIGNAL` (`SIGTERM`, `SIGINT`, etc.). `INSTANCE` and `PROJECT_NAME`
1625
+ come from that workspace's dev-server instance configuration. `PROJECT_NAME` is
1626
+ empty when no matching Docker instance is known; never guess a shared stack name.
1627
+
1628
+ For explicit destructive cleanup of the workspace's Compose resources:
1629
+
1630
+ ```bash
1631
+ docker compose -p "${PROJECT_NAME:?Missing Docker project}" down --remove-orphans --volumes --rmi local
1632
+ ```
1633
+
1634
+ This removes Compose-managed volumes and images without custom tags; external
1635
+ volumes and tagged images remain. It can stop containers **even with automatic
1636
+ Docker shutdown disabled**, because the hook is an explicit user command. Supply
1637
+ any `-f` or `--env-file` arguments your project's Compose setup requires.
1638
+
1639
+ These hooks require a graceful Kōbō shutdown; they cannot run after `SIGKILL`,
1640
+ a host power loss or a crash that bypasses teardown. Kōbō's total shutdown deadline
1641
+ is 150 seconds. Allow at least 180 seconds in a container/process supervisor
1642
+ (`stop_grace_period: 180s` in the Compose examples).
1643
+
1600
1644
  ### Example: the full convention end-to-end
1601
1645
 
1602
1646
  The reference implementation is the `sekur` project. Its Kōbō `startCommand` is effectively `make dev-docker`, where the `Makefile` calls `./sh/dev-docker.sh $(INSTANCE)` and `$(INSTANCE)` reads the `INSTANCE` env var Kōbō injects. The script:
@@ -2184,6 +2228,51 @@ Notion token environment overrides are applied consistently to both token variab
2184
2228
 
2185
2229
  New workspaces receive the mission, repository context, current permissions, current phase and expected result. Agents first consult the available managed MCP tool descriptions and schemas, then prefer relevant Kōbō tools for workspace operations. This is not a request to call every tool or poll. Long tasks and delegated work use labeled progress bars; dismissing a bar hides only that activity and does not cancel its work.
2186
2230
 
2231
+ For tasks, development services, shared memory, workspace messages and state,
2232
+ agents prefer the dedicated managed MCP tool and check its result. Writable
2233
+ launches keep existing tasks current during work: `in_progress` when starting,
2234
+ blockers promptly reported in the conversation with the affected task, and done
2235
+ only after required checks pass. Task requirements and evidence are preserved.
2236
+ Blocked tasks stay unfinished; no unsupported field or status is invented.
2237
+ Delegates receive the objective, exact worktree path, user constraints, launch
2238
+ permissions, test runtime and applicable MCP/reporting rules. When a delegate
2239
+ lacks the tools, its parent coordinates the operation under the same permissions.
2240
+ The final report identifies changes/findings, checks actually executed and their
2241
+ results, and remaining blockers or ongoing work. Delegated results are attributed;
2242
+ progress bars finish only for activities with a known outcome, and completing a
2243
+ phase or review does not imply the whole mission is done.
2244
+
2245
+ Agents explicitly use Kōbō internal memory for shared knowledge across sessions,
2246
+ workspaces and engines. At task start/resume, they consult injected facts before
2247
+ targeted retrieval of missing decisions, conventions and known pitfalls. When
2248
+ permitted, they save confirmed reusable facts through `remember` in the narrowest
2249
+ scope, updating existing entries rather than duplicating them. Native provider
2250
+ memory does not replace this shared store. Retrieval budgets, Manual/read-only
2251
+ restrictions and Hybrid approval requirements still apply; secrets, raw logs and
2252
+ routine progress are not memory entries.
2253
+
2254
+ Development services have an explicit managed lifecycle rule: before backend,
2255
+ frontend, integration or E2E checks that need workspace services, inspect
2256
+ `get_dev_server_status` and reuse the existing instance. Authorized writable
2257
+ launches start/stop it through the Kōbō MCP tools, never by invoking the startup
2258
+ script, `make dev-docker` or Docker Compose directly. Diagnose failures with
2259
+ `get_dev_server_logs`; unavailable tools or configuration are reported as blockers.
2260
+ Delegates follow the same rule. Read-only launches cannot start/stop services;
2261
+ test execution inside an existing container still follows current permissions.
2262
+
2263
+ Agents choose test commands and runtimes from the project Makefile, scripts and
2264
+ documentation. Missing dependencies in the agent environment do not establish a
2265
+ project configuration defect when tests belong in a service container. A running
2266
+ dev server is not proof of application readiness: dependent checks require a
2267
+ documented health check or non-mutating URL probe with a timeout and bounded
2268
+ retries. Independent unit tests need not wait for application readiness.
2269
+
2270
+ After interruption/resume, agents verify relevant task/Git state and ongoing
2271
+ jobs, tests and containers before repeating work. MCP failures must be reported
2272
+ as failures, with safe diagnostics; authentication/permission errors never justify
2273
+ modifying credentials or Kōbō storage to bypass the tools. Ambiguous mutation
2274
+ outcomes require checking available status/receipts before retrying.
2275
+
2187
2276
  Plan launches remain conversation-only. Writable auto-loop launches prepare the plan and task list before returning control to the loop; writable manual launches implement after plan approval, under the configured Git policy. Custom brainstorming, Notion and Sentry templates are preserved; they cannot expand launch permissions or authorize commits automatically. Review returns and handoff reports remain read-only, with only their scoped report and operational progress tools allowed. Plan approval does not change permissions; explicitly select an execution mode in Kōbō and start a new launch to implement.
2188
2277
 
2189
2278
  The expandable **Kōbō instructions** chat entry records the application-owned text prepared by the engine builder for the initial launch or a pending initial-prompt retry, including progress guidance, continuation rules, workflow policy and managed integration notices. The pending initial mission is retained until this entry is durably recorded, so a failure during asynchronous launch preparation remains retryable. A later replacement mission is never cleared by an earlier launch. Preparation is not proof of provider delivery. The provider's own system preset is not included. Retrieved memory bodies and capability tokens are not copied to chat; memory usage remains visible separately in the Memory panel. Existing historical prompt entries retain their original content. Ordinary resumes receive current instructions without creating another initial-prompt entry.
@@ -408,25 +408,25 @@ const WORKSPACE_SCOPED_TOOLS = [
408
408
  },
409
409
  {
410
410
  name: 'get_dev_server_status',
411
- description: 'CALL BEFORE asking the user whether the app is running, or when your change is dev-server-sensitive. Returns `not_configured` when this project has no dev server: do not start one manually in that case. Otherwise returns running/stopped/starting/error + URL, port, container names.',
411
+ description: 'CALL BEFORE backend, frontend, integration or E2E tests that need workspace services, or before asking the user whether the app is running. Reuse the existing instance and its returned URL/container names. Returns `not_configured` when this project has no dev server: report the blocker, do not launch one manually. Otherwise returns running/stopped/starting/error + URL, port, container names. Running is lifecycle status, not application readiness: use the documented health check with bounded retries before dependent tests.',
412
412
  inputSchema: { type: 'object', properties: {}, required: [] },
413
413
  annotations: { readOnlyHint: true, openWorldHint: false },
414
414
  },
415
415
  {
416
416
  name: 'start_dev_server',
417
- description: 'CALL WHEN the user asks you to test the running app and the dev server is stopped.',
417
+ description: 'CALL WHEN an authorized task needs workspace services for app checks or backend/frontend/integration/E2E tests and get_dev_server_status reports stopped. Kōbō runs the configured startup command and tracks the instance. Use this tool instead of shell lifecycle commands such as make dev-docker, sh/dev-docker.sh, docker compose up or npm run dev. Respect launch permissions; read-only sessions cannot start services. On failure, inspect get_dev_server_logs and report the blocker, without a manual shell fallback.',
418
418
  inputSchema: { type: 'object', properties: {}, required: [] },
419
419
  annotations: { destructiveHint: false, openWorldHint: false },
420
420
  },
421
421
  {
422
422
  name: 'stop_dev_server',
423
- description: 'CALL WHEN the user explicitly asks to stop the dev server, or before destructive operations that require a clean boot.',
423
+ description: 'CALL WHEN the user explicitly asks to stop the dev server, or when an authorized operation requires a restart. Use this tool instead of shell shutdown commands such as docker compose down or killing the server process directly. Respect launch permissions; read-only sessions cannot stop services. Do not stop a shared workspace instance merely because your tests finished.',
424
424
  inputSchema: { type: 'object', properties: {}, required: [] },
425
425
  annotations: { destructiveHint: false, openWorldHint: false },
426
426
  },
427
427
  {
428
428
  name: 'get_dev_server_logs',
429
- description: 'CALL WHEN debugging a runtime issue the user describes as happening in the running app. Returns the last N lines of logs (default 200). Cheaper than asking the user to paste them.',
429
+ description: 'CALL WHEN diagnosing a dev-server startup failure or a runtime issue during app checks or tests. Returns the last N lines of logs (default 200). Use these logs with get_dev_server_status before reporting a blocker; do not bypass a failed managed startup with a shell launch.',
430
430
  inputSchema: {
431
431
  type: 'object',
432
432
  properties: {
@@ -37,13 +37,14 @@ import voiceRouter from './routes/voice.js';
37
37
  import workspaceMessagesRouter from './routes/workspace-messages.js';
38
38
  import workspaceTemplatesRouter from './routes/workspace-templates.js';
39
39
  import workspacesRouter from './routes/workspaces.js';
40
- import { getAvailableSkills, reconcileOrphanSessions, restoreRetryCountsFromDb, resumePendingReviewReturns, setBackendPort, startAgent, startWatchdog, stopAgentAndWait, stopAllAgents, stopWatchdog, } from './services/agent/orchestrator.js';
40
+ import { getActiveSessionId, getAvailableSkills, reconcileOrphanSessions, restoreRetryCountsFromDb, resumePendingReviewReturns, setBackendPort, startAgent, startWatchdog, stopAgentAndWait, stopAllAgents, stopWatchdog, } from './services/agent/orchestrator.js';
41
41
  import * as autoLoopService from './services/auto-loop-service.js';
42
42
  import { startAwaitingUserReminder, stopAwaitingUserReminder } from './services/awaiting-user-reminder-service.js';
43
43
  import { runContentMigrationIfNeeded } from './services/content-migration-service.js';
44
44
  import * as cronService from './services/cron-service.js';
45
45
  import { createDailyDbBackupIfNeeded, createPreMigrationBackup, startDailyDbBackupScheduler, } from './services/db-backup-service.js';
46
- import { startDevServer, stopAllDevServers, stopDevServer } from './services/dev-server-service.js';
46
+ import { beginDevServerShutdown, startDevServer, stopAllDevServers, stopDevServer, } from './services/dev-server-service.js';
47
+ import { onKoboShutdown } from './services/lifecycle-hook-service.js';
47
48
  import { reconcileMessageRequests } from './services/mcp-message-request-service.js';
48
49
  import { reconcileMemoryContextsOnStartup } from './services/memory-context-service.js';
49
50
  import { authorizeWsUpgrade, generateToken, getLanHostnames, getLanUrls, isAllowedOrigin, isAllowedRequestHost, resolveBindHost, resolveDevClientOrigin, resolveNetworkAccessEnvOverrides, resolveProxyHostname, } from './services/network-access-service.js';
@@ -422,13 +423,15 @@ async function gracefulShutdown(signal, exitCode = 0) {
422
423
  if (isShuttingDown)
423
424
  return;
424
425
  isShuttingDown = true;
426
+ beginDevServerShutdown();
427
+ const stopDevServersOnShutdown = getGlobalSettings().stopDevServersOnShutdown !== false;
425
428
  stopUpdateChecker();
426
429
  const groupMessagesStopped = stopGroupMessageBatches();
427
430
  console.log(`\n[kobo] Received ${signal}, shutting down gracefully…`);
428
431
  const forceExitTimer = setTimeout(() => {
429
432
  console.error('[kobo] Graceful shutdown timed out; forcing exit');
430
433
  process.exit(1);
431
- }, 5_000);
434
+ }, 150_000);
432
435
  forceExitTimer.unref();
433
436
  // Ask WebSocket clients to leave so their upgraded HTTP connections do not
434
437
  // keep server.close() waiting indefinitely.
@@ -469,14 +472,26 @@ async function gracefulShutdown(signal, exitCode = 0) {
469
472
  // Best-effort
470
473
  }
471
474
  clearInterval(retentionTimer);
472
- // Stop managed agents and both direct/Docker dev servers before closing DB.
475
+ // Stop local writers before user hooks, leaving Docker available to those hooks.
473
476
  try {
474
- await Promise.all([stopAllAgents(), stopAllDevServers()]);
475
- console.log('[kobo] Agents and dev servers stopped');
477
+ await Promise.all([stopAllAgents(), stopAllDevServers({ preserveDocker: true })]);
478
+ await onKoboShutdown(signal, (workspaceId) => getActiveSessionId(workspaceId) !== undefined);
476
479
  }
477
480
  catch {
478
481
  // Best-effort
479
482
  }
483
+ try {
484
+ if (stopDevServersOnShutdown) {
485
+ await stopAllDevServers();
486
+ console.log('[kobo] Dev servers stopped');
487
+ }
488
+ else {
489
+ console.log('[kobo] Local dev processes stopped; Docker services left to the configured shutdown hooks');
490
+ }
491
+ }
492
+ catch (err) {
493
+ console.error('[kobo] Dev-server shutdown failed:', err);
494
+ }
480
495
  // Stop accepting new HTTP/WS work, then let in-flight handlers finish before
481
496
  // closing SQLite. Closing the DB earlier makes those handlers fail midway.
482
497
  try {
@@ -486,6 +486,7 @@ function readEffectiveSettingsSafe(projectPath) {
486
486
  archiveScript: '',
487
487
  changeSourceBranchScript: '',
488
488
  sessionEndedScript: '',
489
+ shutdownScript: '',
489
490
  prMergedScript: '',
490
491
  autoLoopDisabledScript: '',
491
492
  notionStatusProperty: '',
@@ -26,11 +26,16 @@ function runCommand(command, args, options = {}) {
26
26
  const trackedProcesses = new Map();
27
27
  const stoppingProcesses = new Map();
28
28
  const generations = new Map();
29
+ let shuttingDown = false;
30
+ export function beginDevServerShutdown() {
31
+ shuttingDown = true;
32
+ }
29
33
  /** Test-only: clear the tracked-processes map between tests. */
30
34
  export function _resetTrackedProcessesForTests() {
31
35
  trackedProcesses.clear();
32
36
  stoppingProcesses.clear();
33
37
  generations.clear();
38
+ shuttingDown = false;
34
39
  }
35
40
  // ── Pure helpers ───────────────────────────────────────────────────────────────
36
41
  /**
@@ -177,6 +182,8 @@ export async function getStatus(projectPath, workingBranch, workspaceId) {
177
182
  * Start the dev-server for a workspace.
178
183
  */
179
184
  export function startDevServer(workspaceId) {
185
+ if (shuttingDown)
186
+ throw new Error('Kōbō is shutting down');
180
187
  assertWorkspaceLifecycleAvailable(workspaceId);
181
188
  const workspace = getWorkspace(workspaceId);
182
189
  if (!workspace) {
@@ -231,7 +238,7 @@ export function startDevServer(workspaceId) {
231
238
  return;
232
239
  trackedProcesses.delete(workspaceId);
233
240
  const currentStatus = await getStatus(workspace.projectPath, workspace.workingBranch);
234
- if (generations.get(workspaceId) !== generation || stoppingProcesses.has(workspaceId))
241
+ if (shuttingDown || generations.get(workspaceId) !== generation || stoppingProcesses.has(workspaceId))
235
242
  return;
236
243
  updateDevServerStatus(workspaceId, currentStatus.status);
237
244
  emitEphemeral(workspaceId, 'devserver:status', currentStatus);
@@ -244,7 +251,7 @@ export function startDevServer(workspaceId) {
244
251
  console.error(`[dev-server] Process exited with code ${code} for workspace ${workspaceId}`);
245
252
  });
246
253
  proc.on('error', (err) => {
247
- if (trackedProcesses.get(workspaceId) !== proc)
254
+ if (shuttingDown || trackedProcesses.get(workspaceId) !== proc || generations.get(workspaceId) !== generation)
248
255
  return;
249
256
  // A signal failure is also an 'error' event; it does not prove process exit.
250
257
  if (!proc.pid)
@@ -373,6 +380,16 @@ function signalAndWait(proc, signal, timeoutMs) {
373
380
  }
374
381
  });
375
382
  }
383
+ async function stopTrackedProcess(workspaceId) {
384
+ const tracked = trackedProcesses.get(workspaceId);
385
+ if (!tracked)
386
+ return;
387
+ const exited = (await signalAndWait(tracked, 'SIGTERM', 3_000)) || (await signalAndWait(tracked, 'SIGKILL', 1_000));
388
+ if (!exited)
389
+ throw new Error('Dev server stop is not confirmed; retry after the process exits');
390
+ if (trackedProcesses.get(workspaceId) === tracked)
391
+ trackedProcesses.delete(workspaceId);
392
+ }
376
393
  async function stopDevServerOwned(workspaceId) {
377
394
  const workspace = getWorkspace(workspaceId);
378
395
  if (!workspace) {
@@ -383,14 +400,7 @@ async function stopDevServerOwned(workspaceId) {
383
400
  const worktreePath = workspace.worktreePath;
384
401
  const cwd = worktreePath && existsSync(worktreePath) ? worktreePath : workspace.projectPath;
385
402
  // 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
- }
403
+ await stopTrackedProcess(workspaceId);
394
404
  const settings = getProjectSettings(workspace.projectPath);
395
405
  let stopError;
396
406
  let customStopSucceeded = false;
@@ -448,12 +458,30 @@ async function stopDevServerOwned(workspaceId) {
448
458
  emitEphemeral(workspaceId, 'devserver:status', status);
449
459
  return status;
450
460
  }
451
- /** Stop direct and Docker dev servers for every persisted workspace. */
452
- export async function stopAllDevServers() {
461
+ /** Shut down local processes; optionally leave independently running Docker services alone. */
462
+ export async function stopAllDevServers(options = {}) {
463
+ beginDevServerShutdown();
453
464
  const workspaceIds = new Set([...listWorkspaces(true).map((workspace) => workspace.id), ...trackedProcesses.keys()]);
454
465
  await Promise.all([...workspaceIds].map(async (workspaceId) => {
455
466
  try {
456
- await stopDevServer(workspaceId);
467
+ if (!options.preserveDocker) {
468
+ await stopDevServer(workspaceId);
469
+ }
470
+ else {
471
+ // A stop already requested by the user must still finish.
472
+ const stopping = stoppingProcesses.get(workspaceId);
473
+ if (stopping) {
474
+ await stopping;
475
+ return;
476
+ }
477
+ const hadProcess = trackedProcesses.has(workspaceId);
478
+ await stopTrackedProcess(workspaceId);
479
+ const workspace = getWorkspace(workspaceId);
480
+ // Preserve Docker state; getStatus re-inspects Compose after restart.
481
+ if (hadProcess && workspace && !resolveInstance(workspace.projectPath, workspace.workingBranch)) {
482
+ updateDevServerStatus(workspaceId, 'stopped');
483
+ }
484
+ }
457
485
  }
458
486
  catch (err) {
459
487
  console.error(`[dev-server] Failed to stop '${workspaceId}' during shutdown:`, err);
@@ -3,12 +3,16 @@ 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';
6
7
  import { getEffectiveSettings, getGlobalSettings, getProjectSettings } from './settings-service.js';
8
+ import { listWorkspaces } from './workspace-service.js';
9
+ export const SHUTDOWN_SCRIPT_TIMEOUT_MS = 60_000;
7
10
  /** Which effective-settings key holds the script for each event. */
8
11
  const SCRIPT_KEY = {
9
12
  'session-ended': 'sessionEndedScript',
10
13
  'pr-merged': 'prMergedScript',
11
14
  'autoloop-disabled': 'autoLoopDisabledScript',
15
+ 'kobo-shutdown': 'shutdownScript',
12
16
  };
13
17
  function getRow(workspaceId) {
14
18
  const row = getDb()
@@ -33,7 +37,7 @@ export async function runLifecycleHook(event, workspaceId, extraEnv) {
33
37
  const script = getEffectiveSettings(row.project_path)[SCRIPT_KEY[event]];
34
38
  // Empty (or blank) means the user never wrote one. Hooks are opt-in: they
35
39
  // run arbitrary shell, so absence of configuration is absence of a hook.
36
- if (!script.trim())
40
+ if (!script?.trim())
37
41
  return null;
38
42
  const global = getGlobalSettings();
39
43
  const projectSettings = getProjectSettings(row.project_path);
@@ -51,19 +55,27 @@ export async function runLifecycleHook(event, workspaceId, extraEnv) {
51
55
  }
52
56
  return null;
53
57
  }
58
+ const instance = event === 'kobo-shutdown' ? resolveInstance(row.project_path, row.working_branch) : null;
54
59
  return await runScript({
55
60
  workspaceId,
56
61
  worktreePath,
57
62
  script,
58
- // Namespaced per event: three hooks streaming into one `hook:output` feed
63
+ // Namespaced per event: multiple hooks streaming into one `hook:output` feed
59
64
  // would be indistinguishable in the UI.
60
65
  eventPrefix: `hook:${event}`,
61
66
  tmpFileName: `.hook-${event}.tmp`,
67
+ ...(event === 'kobo-shutdown' ? { timeoutMs: SHUTDOWN_SCRIPT_TIMEOUT_MS } : {}),
62
68
  env: {
63
69
  workspaceName: row.name,
64
70
  branchName: row.working_branch,
65
71
  sourceBranch: row.source_branch,
66
72
  projectPath: row.project_path,
73
+ ...(event === 'kobo-shutdown'
74
+ ? {
75
+ projectName: instance?.projectName ?? '',
76
+ instanceName: instance?.instanceName ?? sanitizeBranchName(row.working_branch),
77
+ }
78
+ : {}),
67
79
  },
68
80
  extraEnv: { ...extraEnv, KOBO_HOOK_EVENT: event },
69
81
  });
@@ -73,6 +85,21 @@ export async function runLifecycleHook(event, workspaceId, extraEnv) {
73
85
  return null;
74
86
  }
75
87
  }
88
+ /** Called once by graceful shutdown, after stopping writers and before Docker teardown. */
89
+ export async function onKoboShutdown(signal, isAgentStillRunning) {
90
+ await Promise.all(listWorkspaces(true)
91
+ .filter((workspace) => !workspace.worktreePurgedAt)
92
+ .map(async (workspace) => {
93
+ if (isAgentStillRunning(workspace.id)) {
94
+ console.warn(`[lifecycle-hook] 'kobo-shutdown' skipped for '${workspace.id}': agent stop unconfirmed`);
95
+ return;
96
+ }
97
+ const result = await runLifecycleHook('kobo-shutdown', workspace.id, { KOBO_SHUTDOWN_SIGNAL: signal });
98
+ if (result && result.exitCode !== 0) {
99
+ console.error(`[lifecycle-hook] 'kobo-shutdown' failed for '${workspace.id}' with code ${result.exitCode}`);
100
+ }
101
+ }));
102
+ }
76
103
  /**
77
104
  * An agent session ended. Fires for every real end — clean finish, error, user
78
105
  * stop, watchdog kill — and lets the script tell them apart via
@@ -996,6 +996,20 @@ 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
+ },
999
1013
  ];
1000
1014
  /** Current settings schema version — always equals the highest migration version. */
1001
1015
  export const SETTINGS_SCHEMA_VERSION = settingsMigrations.length > 0 ? settingsMigrations[settingsMigrations.length - 1].version : 0;
@@ -1044,6 +1058,7 @@ function defaultSettings() {
1044
1058
  archiveScript: '',
1045
1059
  changeSourceBranchScript: DEFAULT_CHANGE_SOURCE_BRANCH_SCRIPT,
1046
1060
  sessionEndedScript: '',
1061
+ shutdownScript: '',
1047
1062
  prMergedScript: '',
1048
1063
  autoLoopDisabledScript: '',
1049
1064
  editorCommand: '',
@@ -1053,6 +1068,7 @@ function defaultSettings() {
1053
1068
  autoLoopMaxRetries: 5,
1054
1069
  agentWatchdogEnabled: false,
1055
1070
  showProgressBars: true,
1071
+ stopDevServersOnShutdown: true,
1056
1072
  awaitingUserReminderMinutes: 0,
1057
1073
  activityDigestEnabled: true,
1058
1074
  maxConcurrentAgents: 2,
@@ -1156,6 +1172,7 @@ function defaultProjectSettings(projectPath) {
1156
1172
  archiveScript: '',
1157
1173
  changeSourceBranchScript: '',
1158
1174
  sessionEndedScript: '',
1175
+ shutdownScript: '',
1159
1176
  prMergedScript: '',
1160
1177
  autoLoopDisabledScript: '',
1161
1178
  devServer: {
@@ -1203,9 +1220,15 @@ export function runSettingsMigrations(raw) {
1203
1220
  current.global.memoryMode = 'hybrid';
1204
1221
  if (typeof current.global.showProgressBars !== 'boolean')
1205
1222
  current.global.showProgressBars = true;
1223
+ if (typeof current.global.stopDevServersOnShutdown !== 'boolean')
1224
+ current.global.stopDevServersOnShutdown = true;
1225
+ if (typeof current.global.shutdownScript !== 'string')
1226
+ current.global.shutdownScript = '';
1206
1227
  if (typeof current.global.agentWatchdogEnabled !== 'boolean')
1207
1228
  current.global.agentWatchdogEnabled = false;
1208
1229
  for (const project of current.projects) {
1230
+ if (typeof project.shutdownScript !== 'string')
1231
+ project.shutdownScript = '';
1209
1232
  if (!isWorkflowPolicy(project.workflowPolicy))
1210
1233
  project.workflowPolicy = {};
1211
1234
  }
@@ -1478,6 +1501,7 @@ export function getEffectiveSettings(projectPath) {
1478
1501
  archiveScript: settings.global.archiveScript,
1479
1502
  changeSourceBranchScript: settings.global.changeSourceBranchScript,
1480
1503
  sessionEndedScript: settings.global.sessionEndedScript,
1504
+ shutdownScript: settings.global.shutdownScript,
1481
1505
  prMergedScript: settings.global.prMergedScript,
1482
1506
  autoLoopDisabledScript: settings.global.autoLoopDisabledScript,
1483
1507
  notionStatusProperty: settings.global.notionStatusProperty,
@@ -1508,6 +1532,7 @@ export function getEffectiveSettings(projectPath) {
1508
1532
  archiveScript: project.archiveScript || settings.global.archiveScript,
1509
1533
  changeSourceBranchScript: project.changeSourceBranchScript || settings.global.changeSourceBranchScript,
1510
1534
  sessionEndedScript: project.sessionEndedScript || settings.global.sessionEndedScript,
1535
+ shutdownScript: project.shutdownScript || settings.global.shutdownScript,
1511
1536
  prMergedScript: project.prMergedScript || settings.global.prMergedScript,
1512
1537
  autoLoopDisabledScript: project.autoLoopDisabledScript || settings.global.autoLoopDisabledScript,
1513
1538
  notionStatusProperty: settings.global.notionStatusProperty,
@@ -1557,6 +1582,12 @@ export function updateGlobalSettings(input) {
1557
1582
  delete data.autoLoopMaxRetries;
1558
1583
  }
1559
1584
  }
1585
+ if ('shutdownScript' in data && typeof data.shutdownScript !== 'string') {
1586
+ delete data.shutdownScript;
1587
+ }
1588
+ if ('stopDevServersOnShutdown' in data && typeof data.stopDevServersOnShutdown !== 'boolean') {
1589
+ delete data.stopDevServersOnShutdown;
1590
+ }
1560
1591
  if ('showProgressBars' in data && typeof data.showProgressBars !== 'boolean') {
1561
1592
  delete data.showProgressBars;
1562
1593
  }
@@ -1619,6 +1650,7 @@ export function updateGlobalSettings(input) {
1619
1650
  'archiveScript',
1620
1651
  'changeSourceBranchScript',
1621
1652
  'sessionEndedScript',
1653
+ 'shutdownScript',
1622
1654
  'prMergedScript',
1623
1655
  'autoLoopDisabledScript',
1624
1656
  'editorCommand',
@@ -1628,6 +1660,7 @@ export function updateGlobalSettings(input) {
1628
1660
  'autoLoopMaxRetries',
1629
1661
  'agentWatchdogEnabled',
1630
1662
  'showProgressBars',
1663
+ 'stopDevServersOnShutdown',
1631
1664
  'awaitingUserReminderMinutes',
1632
1665
  'activityDigestEnabled',
1633
1666
  'maxConcurrentAgents',
@@ -1823,6 +1856,8 @@ export function upsertProject(projectPath, data) {
1823
1856
  }
1824
1857
  if ('workflowPolicy' in data && !isWorkflowPolicy(data.workflowPolicy))
1825
1858
  throw new Error('Invalid workflowPolicy');
1859
+ if ('shutdownScript' in data && typeof data.shutdownScript !== 'string')
1860
+ throw new Error('Invalid shutdownScript');
1826
1861
  const allowedProjectKeys = [
1827
1862
  'workflowPolicy',
1828
1863
  'displayName',
@@ -1842,6 +1877,7 @@ export function upsertProject(projectPath, data) {
1842
1877
  'archiveScript',
1843
1878
  'changeSourceBranchScript',
1844
1879
  'sessionEndedScript',
1880
+ 'shutdownScript',
1845
1881
  'prMergedScript',
1846
1882
  'autoLoopDisabledScript',
1847
1883
  'devServer',
@@ -49,6 +49,8 @@ export function runScript(opts) {
49
49
  BRANCH_NAME: env?.branchName ?? '',
50
50
  SOURCE_BRANCH: env?.sourceBranch ?? '',
51
51
  PROJECT_PATH: env?.projectPath ?? '',
52
+ ...(env?.projectName !== undefined ? { PROJECT_NAME: env.projectName } : {}),
53
+ ...(env?.instanceName !== undefined ? { INSTANCE: env.instanceName } : {}),
52
54
  },
53
55
  stdio: ['ignore', 'pipe', 'pipe'],
54
56
  // Detached so this is the leader of its own process group: a script
@@ -74,7 +76,7 @@ export function runScript(opts) {
74
76
  proc.stdout?.destroy();
75
77
  proc.stderr?.destroy();
76
78
  wsService.emit(workspaceId, `${eventPrefix}:output`, {
77
- text: `[kobo] Script timed out after ${Math.round(timeoutMs / 60000)} minutes`,
79
+ text: `[kobo] Script timed out after ${timeoutMs < 60000 ? `${Math.round(timeoutMs / 1000)} seconds` : `${Math.round(timeoutMs / 60000)} minutes`}`,
78
80
  });
79
81
  // SIGKILL on the process group gives a running `git commit` no chance to
80
82
  // release its index lock. Name the file here, while the user is looking