@loicngr/kobo 2.1.23 → 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.
- package/AGENTS.md +25 -1
- package/CHANGELOG.md +4 -0
- package/CONFIGURATION.md +56 -3
- package/dist/mcp-server/kobo-tasks-server.js +3 -2
- package/dist/server/routes/mcp.js +13 -7
- package/dist/server/services/dev-server-service.js +3 -59
- package/dist/server/services/lifecycle-hook-service.js +0 -8
- package/dist/server/services/mcp-message-delivery-service.js +1 -1
- package/dist/server/services/mcp-message-request-service.js +11 -5
- package/dist/server/services/settings-service.js +15 -0
- package/dist/server/services/workspace-dialogue-service.js +2 -0
- package/dist/server/services/workspace-message-service.js +61 -5
- package/dist/server/utils/dev-server-instance.js +58 -0
- package/dist/server/utils/script-runner.js +5 -2
- package/dist/shared/mcp-attachments.js +58 -0
- package/dist/shared/workspace-creation-tools.js +4 -28
- package/dist/shared/workspace-dialogue-tools.js +18 -6
- package/package.json +1 -1
- package/src/client/dist/pwa/assets/{ActivityFeed-1OKX_g3B.js → ActivityFeed-BYspOKKg.js} +1 -1
- package/src/client/dist/pwa/assets/{AutoLoopFinalReviewSettings-VoA9uBw0.js → AutoLoopFinalReviewSettings-DXX3CqrY.js} +1 -1
- package/src/client/dist/pwa/assets/{ChangelogPage-DHl3hD3t.js → ChangelogPage-DKkI5IU-.js} +1 -1
- package/src/client/dist/pwa/assets/{ComparisonPanel-D6EKTtni.js → ComparisonPanel-Cw5Dkan0.js} +1 -1
- package/src/client/dist/pwa/assets/{CreatePage-CZzEOVVq.js → CreatePage-B7PLFOH6.js} +1 -1
- package/src/client/dist/pwa/assets/{DashboardPage-DjLoUvjA.js → DashboardPage-Dw8nfkYR.js} +1 -1
- package/src/client/dist/pwa/assets/{DiffViewer-BQ6OEmoi.js → DiffViewer-DFlDwkmd.js} +3 -3
- package/src/client/dist/pwa/assets/{DocumentsPanel-yrQviqCd.js → DocumentsPanel-CH0KzdJV.js} +1 -1
- package/src/client/dist/pwa/assets/{DrawerToggleButton-D5qNeXnn.js → DrawerToggleButton-CZVpU8HH.js} +1 -1
- package/src/client/dist/pwa/assets/{GitPanel-DCXlhcAD.js → GitPanel-DjKL30gM.js} +3 -3
- package/src/client/dist/pwa/assets/{HealthPage-COicuNQn.js → HealthPage-DnPp8GSx.js} +1 -1
- package/src/client/dist/pwa/assets/{MainLayout-ZcExZTqC.js → MainLayout-1PaKfzdy.js} +2 -2
- package/src/client/dist/pwa/assets/{MemoryPanel-D73Qz9f7.js → MemoryPanel-BarliK2r.js} +1 -1
- package/src/client/dist/pwa/assets/{MemoryProposalList-BGlq2DkD.js → MemoryProposalList-BtVjVPp7.js} +1 -1
- package/src/client/dist/pwa/assets/{QBtnDropdown-CykBCmwa.js → QBtnDropdown-Rzurb7nO.js} +1 -1
- package/src/client/dist/pwa/assets/{QExpansionItem-CAW9J1Uv.js → QExpansionItem-CaKEBcyx.js} +1 -1
- package/src/client/dist/pwa/assets/{QScrollArea-C_d-8eO7.js → QScrollArea-hrsAyyJf.js} +1 -1
- package/src/client/dist/pwa/assets/{QSelect-C1AN93yA.js → QSelect-Dr7rAt6u.js} +1 -1
- package/src/client/dist/pwa/assets/{QTooltip-C9qaLW_h.js → QTooltip-CNMsXTmD.js} +1 -1
- package/src/client/dist/pwa/assets/{SchedulePanel-CQpzDhq7.js → SchedulePanel-nDw2eZ9K.js} +1 -1
- package/src/client/dist/pwa/assets/{SearchPage-fZlSSEu_.js → SearchPage-BXJ6iVt1.js} +1 -1
- package/src/client/dist/pwa/assets/SettingsPage-Dcq1Jeoo.css +1 -0
- package/src/client/dist/pwa/assets/SettingsPage-SHpE2q9Z.js +9 -0
- package/src/client/dist/pwa/assets/{SplitWorkspacePage-CfeO6TZb.js → SplitWorkspacePage-tfZ1kAdZ.js} +1 -1
- package/src/client/dist/pwa/assets/{TerminalPanel-CfDZOteT.js → TerminalPanel-Cw8R7lAw.js} +1 -1
- package/src/client/dist/pwa/assets/{ToolCallItem-CHE6tylq.js → ToolCallItem-DEKhFJEv.js} +1 -1
- package/src/client/dist/pwa/assets/{TouchPan-Csev_qU6.js → TouchPan-DybGzRRK.js} +1 -1
- package/src/client/dist/pwa/assets/{TourReplayButton-Crr7tF1b.js → TourReplayButton-BNdlAZUR.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-DpUZOiJv.js → WorkspaceAttentionLabels-B0H9omcK.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspacePage-Bk-WWOYM.css → WorkspacePage-B-82Rw9T.css} +1 -1
- package/src/client/dist/pwa/assets/{WorkspacePage-_QSKLO42.js → WorkspacePage-tWGXnmrz.js} +3 -3
- package/src/client/dist/pwa/assets/{browser-o7ftkZex.js → browser-DUTZhU-w.js} +1 -1
- package/src/client/dist/pwa/assets/{build-path-tree-D6dZQpIb.js → build-path-tree-ZThcsIN5.js} +1 -1
- package/src/client/dist/pwa/assets/{custom-sounds-DVX9zCET.js → custom-sounds-CquHn2gw.js} +1 -1
- package/src/client/dist/pwa/assets/{de-DOE4H_Jf.js → de-B3H0T-ft.js} +2 -2
- package/src/client/dist/pwa/assets/{editor.api-C8TYnUXm.js → editor.api-pSvhgban.js} +1 -1
- package/src/client/dist/pwa/assets/{efforts-DfsG0_o0.js → efforts-DJaFsLgd.js} +1 -1
- package/src/client/dist/pwa/assets/{es-BW57JPlm.js → es-B4lACW6k.js} +2 -2
- package/src/client/dist/pwa/assets/{expand-template-CJZlWMfI.js → expand-template-C5D_p5eV.js} +1 -1
- package/src/client/dist/pwa/assets/{format-DOCqk2Dn.js → format-DRfnkeRP.js} +1 -1
- package/src/client/dist/pwa/assets/{formatters-BFocC6LJ.js → formatters-Cog2icjt.js} +1 -1
- package/src/client/dist/pwa/assets/{fr-DnmIKHaQ.js → fr-3uNi02Jz.js} +2 -2
- package/src/client/dist/pwa/assets/{freemarker2-CfME4WOK.js → freemarker2-W-OLsqOk.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-uvKdDnJl.js → handlebars-Kt5RbWuO.js} +1 -1
- package/src/client/dist/pwa/assets/{html-BrEPgLuD.js → html-D5VKPBbB.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-CEzCwMir.js +1 -0
- package/src/client/dist/pwa/assets/{index-z1Oh2-I_.js → index-BZwhT2-I.js} +4 -4
- package/src/client/dist/pwa/assets/{it-DSrEQgZQ.js → it-DtFWyLHb.js} +2 -2
- package/src/client/dist/pwa/assets/{javascript-KH2xP4lQ.js → javascript-DBjx8Bg2.js} +1 -1
- package/src/client/dist/pwa/assets/{liquid-D08TlsVs.js → liquid-DYT4a7D7.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-y74A51cS.js → mdx-9L1kysEY.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco-contributions-Cx8iaAhd.js → monaco-contributions-DAme5TZS.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco.contribution-CFq0guCl.js → monaco.contribution-C58k7TPw.js} +2 -2
- package/src/client/dist/pwa/assets/{network-auth-DnGOOzCV.js → network-auth-BDUlgV-U.js} +1 -1
- package/src/client/dist/pwa/assets/{notify-theme-B9lUAgQl.js → notify-theme-DrQB-mrb.js} +1 -1
- package/src/client/dist/pwa/assets/{project-color-TgENPFLi.js → project-color-BlV5PUUW.js} +1 -1
- package/src/client/dist/pwa/assets/{python-Cy3bAVPq.js → python-DJIlKr-Y.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-gD9QOuiV.js → razor-CC3uqdaH.js} +1 -1
- package/src/client/dist/pwa/assets/{typescript-CEItY0bH.js → typescript-BnnkT9oE.js} +1 -1
- package/src/client/dist/pwa/assets/{use-search-index-status-BtJ0pOy9.js → use-search-index-status-CExdHIar.js} +1 -1
- package/src/client/dist/pwa/assets/{use-tours-COLDth2h.js → use-tours-DOUjqO2f.js} +2 -2
- package/src/client/dist/pwa/assets/{use-worktree-restore-CShbwpGy.js → use-worktree-restore-zIOYNIcb.js} +1 -1
- package/src/client/dist/pwa/assets/{workspace-templates-BOMNQgBl.js → workspace-templates-DXJG6flv.js} +1 -1
- package/src/client/dist/pwa/assets/{xml-BJvL5qcE.js → xml-DJtVmsJT.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-DtkVRZaX.js → yaml-Dwuw8FcP.js} +1 -1
- package/src/client/dist/pwa/index.html +1 -1
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/mcp-server/README.md +35 -4
- package/src/mcp-server/kobo-tasks-server.ts +3 -6
- package/src/client/dist/pwa/assets/SettingsPage-BCOF0ssp.css +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-eyJZxeCJ.js +0 -9
- package/src/client/dist/pwa/assets/i18n-BmTCjikk.js +0 -1
package/AGENTS.md
CHANGED
|
@@ -243,7 +243,23 @@ Claude SDK `task_*` messages cover agents **and** background jobs. Preserve `tas
|
|
|
243
243
|
|
|
244
244
|
### External MCP dialogue and update checks
|
|
245
245
|
|
|
246
|
-
Workspace-bound stdio, global stdio and HTTP expose shared `create_workspace` / `diagnose_workspace_pr` definitions in `src/shared/workspace-creation-tools.ts`. Stdio forwards them to the backend. `workspace-creation-mcp-service.ts` validates inputs and dispatches through the existing workspace and PR routes in process; preserve UI validation, checkout fingerprints, lifecycle guards and rollback. Inputs use snake_case except the existing camelCase PR checkout decisions. Attachments are inline base64, bounded to the shared 10-file/50 MiB decoded limit before checkout mutations. Keep SDK HTTP/stdio limits aligned with the shared creation envelope budget.
|
|
246
|
+
Workspace-bound stdio, global stdio and HTTP expose shared `create_workspace` / `diagnose_workspace_pr` definitions in `src/shared/workspace-creation-tools.ts`. Stdio forwards them to the backend. `workspace-creation-mcp-service.ts` validates inputs and dispatches through the existing workspace and PR routes in process; preserve UI validation, checkout fingerprints, lifecycle guards and rollback. Inputs use snake_case except the existing camelCase PR checkout decisions. Attachments are inline base64, bounded to the shared 10-file/50 MiB decoded limit before checkout mutations. Keep SDK HTTP/stdio limits aligned with the shared creation envelope budget. Creation and send_workspace_message envelopes with attachments get the larger base64 HTTP body budget; ordinary MCP requests remain 1 MiB. After a known PR creation failure, rollback only a checkout proven newly created, unchanged, clean (including ignored files) and unowned, under the Git repository lock; retain other checkouts and expose recovery details. Do not retry uncertain creation automatically or treat `creation_id` as an idempotency key. Restricted review/handoff launches cannot create workspaces or invoke lifecycle mutations. Shared lifecycle tools (`workspace-lifecycle-tools.ts` / `workspace-lifecycle-mcp-service.ts`) also expose archive, purge, delete, unarchive and restore through the same transports. Reject reserved/bulk/path-like IDs, require explicit purge/delete flags, and preserve warnings. Single-workspace DELETE accepts optional `confirmationBranch`, rechecked under its lifecycle guard before stopping processes; MCP always supplies it. Branch rename/resync share this guard so the confirmed branch and checkout path cannot change during teardown. Recovery uses `restore-worktree` before unarchive and never starts an agent.
|
|
247
|
+
|
|
248
|
+
MCP creation and single-workspace messages share `shared/mcp-attachments.ts`
|
|
249
|
+
validation and inline `{name, mime_type, data_base64}` schemas. Validate before
|
|
250
|
+
writing, reserve keyed-message identity before uploading, and fingerprint the
|
|
251
|
+
original content/session plus attachment names, types and bytes (preserving old
|
|
252
|
+
text-only fingerprints). Delivery saves files under the target worktree using
|
|
253
|
+
the existing attachment service, then passes references to the engine and chat.
|
|
254
|
+
Clean up definitively rejected files; retain them if dispatch is uncertain.
|
|
255
|
+
Never expose raw server filesystem paths as attachment inputs. Group messages
|
|
256
|
+
remain text-only.
|
|
257
|
+
|
|
258
|
+
The global JSON setting `forceImmediateMessages` (v67, default false) bypasses
|
|
259
|
+
client chat queueing for new messages using confirmed immediate delivery. It
|
|
260
|
+
also bypasses the auto-loop next-iteration UI path; normal backend immediate
|
|
261
|
+
message delivery pauses a ready loop. Do not flush existing queues on toggle,
|
|
262
|
+
remove compaction/permission/handoff guards, or queue rejected forced sends.
|
|
247
263
|
|
|
248
264
|
`POST /api/mcp` exposes stateless Streamable HTTP discovery/dialogue tools, behind the existing Host/Origin and network-token gates. The global stdio server forwards dialogue calls through this backend so agent runtime ownership remains centralized. `workspace-message-service.ts` shares delivery with WebSocket chat.
|
|
249
265
|
|
|
@@ -654,6 +670,14 @@ reuse outside PR import still forces setup off. Worktree ownership is unchanged.
|
|
|
654
670
|
|
|
655
671
|
Each event maps to one effective-settings key (`sessionEndedScript` / `prMergedScript` / `autoLoopDisabledScript`, settings migration v55), cascading project-over-global with empty meaning inherit. Execution reuses `runScript` with `eventPrefix: 'hook:<event>'`; `runScript` gained an `extraEnv` option, merged **under** the identity variables so a hook payload can never claim a different `WORKSPACE_ID`. `runLifecycleHook` never rejects and returns `null` when there is nothing to run (no script, unknown workspace, worktree gone). The pr-watcher awaits the `pr-merged` hook before auto-purge so a deploy script does not lose its worktree mid-run. See [CONFIGURATION.md → Lifecycle hooks](CONFIGURATION.md#lifecycle-hooks).
|
|
656
672
|
|
|
673
|
+
The shared `utils/script-runner.ts` supplies `INSTANCE` and `PROJECT_NAME` to
|
|
674
|
+
setup, cleanup, archive and every lifecycle hook, including shutdown. Resolve
|
|
675
|
+
these at execution time through `utils/dev-server-instance.ts`, shared with the
|
|
676
|
+
dev-server service. Never inherit ambient Docker identity or invent a Compose
|
|
677
|
+
project name: `PROJECT_NAME` is empty when no matching instance metadata exists.
|
|
678
|
+
`ScriptVariablesHelp.vue` documents common and event-specific variables in both
|
|
679
|
+
global and project settings; keep this help and CONFIGURATION.md in sync.
|
|
680
|
+
|
|
657
681
|
### Engine comparison
|
|
658
682
|
|
|
659
683
|
Successful paired creation opens the existing `split` route with `left` and `right` workspace ids in creation order. A single successful creation keeps the normal workspace route, including when the second creation fails.
|
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.24
|
|
8
|
+
|
|
9
|
+
- feat: add MCP attachments and immediate chat delivery
|
|
10
|
+
|
|
7
11
|
## 2.1.23
|
|
8
12
|
|
|
9
13
|
- feat: configure shutdown hooks and improve agent guidance
|
package/CONFIGURATION.md
CHANGED
|
@@ -397,14 +397,43 @@ silently.
|
|
|
397
397
|
|
|
398
398
|
### Hook environment variables
|
|
399
399
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
the
|
|
400
|
+
Settings → Scripts and each project's script fields include expandable
|
|
401
|
+
**Available variables** help. Setup, cleanup, archive and lifecycle scripts all
|
|
402
|
+
run in the workspace worktree (`PWD`) and receive:
|
|
403
|
+
|
|
404
|
+
| Variable | Value |
|
|
405
|
+
|---|---|
|
|
406
|
+
| `WORKSPACE_ID`, `WORKSPACE_NAME` | Workspace identifier and display name |
|
|
407
|
+
| `BRANCH_NAME`, `SOURCE_BRANCH` | Working branch and source branch |
|
|
408
|
+
| `PROJECT_PATH` | Main repository path, not the worktree |
|
|
409
|
+
| `INSTANCE` | Matching Docker instance name, or the lowercased branch with `/` and `_` replaced by `-` |
|
|
410
|
+
| `PROJECT_NAME` | Exact Compose project name from the matching `PROJECT_PATH/.container/instances/*.env` file; empty without a known matching instance |
|
|
411
|
+
|
|
412
|
+
Docker identity is resolved at each script execution using the same instance
|
|
413
|
+
metadata as the dev-server panel. It never inherits a Docker project from the
|
|
414
|
+
server's environment or guesses one from a folder name. In particular, setup
|
|
415
|
+
before the first dev-server launch usually has an empty `PROJECT_NAME`.
|
|
416
|
+
Keep the metadata file if later archive/PR hooks need that identity. These
|
|
417
|
+
variables are specific to the shared workspace script runner; dev start/stop
|
|
418
|
+
and change-source-branch commands have their own contracts documented below.
|
|
419
|
+
|
|
420
|
+
Quote variables and guard destructive Docker commands against a missing project:
|
|
421
|
+
|
|
422
|
+
```bash
|
|
423
|
+
# Archive or PR merged — stop this stack, preserving volumes and images.
|
|
424
|
+
docker compose -p "${PROJECT_NAME:?Missing Docker project}" down --remove-orphans
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
Add `--volumes --rmi local` only to also remove its Compose-managed volumes and
|
|
428
|
+
images without custom tags. This does not remove every image on the Docker host.
|
|
429
|
+
|
|
430
|
+
Lifecycle hooks also receive `KOBO_HOOK_EVENT` naming the event. On top of that:
|
|
403
431
|
|
|
404
432
|
| Hook | Extra variables |
|
|
405
433
|
|---|---|
|
|
406
434
|
| Session ended | `KOBO_SESSION_ID`, `KOBO_SESSION_END_REASON` (`completed` / `error` / `killed` / `watchdog`), `KOBO_SESSION_EXIT_CODE` (empty when the engine reports none), `KOBO_SESSION_STOP_CAUSE` (`user`, `replacement`, `setup`, `archive`, or `shutdown`; empty for a natural end; superseded sessions and delete/purge stops skip the hook), `KOBO_AUTOLOOP_ACTIVE` (`1` when auto-loop is enabled, so a test run may overlap with the next agent's edits) |
|
|
407
435
|
| PR merged | `KOBO_PR_NUMBER`, `KOBO_PR_URL` |
|
|
436
|
+
| Kōbō shutdown | `KOBO_SHUTDOWN_SIGNAL` |
|
|
408
437
|
| Auto-loop stopped | `KOBO_AUTOLOOP_REASON` (`completed` / `user-action`; blocked missions retain their intent and do not fire this hook), `KOBO_TASKS_PENDING` |
|
|
409
438
|
|
|
410
439
|
Only `KOBO_`-prefixed variables are passed through from the event; a hook
|
|
@@ -851,6 +880,13 @@ The setting lives in `settings.json` under `global` and has no UI control yet:
|
|
|
851
880
|
|
|
852
881
|
An external MCP client can connect to `http://127.0.0.1:3000/api/mcp` using Streamable HTTP (use your backend's configured port). It can create workspaces with the same options as the form, diagnose and check out existing PRs, discover workspaces, read their conversations, send messages and answer pending questions. Workspace-bound stdio, global stdio and HTTP share the creation contract, including brainstorm settings, final auto-loop review, workflow policy, imports, existing worktrees, comparison grouping and inline attachments. All three transports also expose archive, disk-space purge, permanent deletion with explicit branch confirmation, unarchive and recovery of purged worktrees. See the [creation and PR guide](src/mcp-server/README.md#create_workspace) for fields, explicit checkout decisions and timeout/retry behavior.
|
|
853
882
|
|
|
883
|
+
Both `create_workspace` and `send_workspace_message` accept inline `attachments`
|
|
884
|
+
with `{name, mime_type, data_base64}`: 10 files, 50 MiB decoded total, same formats
|
|
885
|
+
as the interface. Messages may contain only attachments. Kōbō saves them in the
|
|
886
|
+
destination worktree and sends file/image references to the agent and chat.
|
|
887
|
+
Keyed retries include attachment identity and never upload or send twice. See
|
|
888
|
+
[Sending files and images](src/mcp-server/README.md#sending-files-and-images).
|
|
889
|
+
|
|
854
890
|
Remote HTTP clients follow the [network access](#network-access) settings and supply the Kōbō token as `Authorization: Bearer <token>` or `X-Kobo-Token`. The endpoint uses the same Host/Origin and reverse-proxy policy as the rest of the backend. No network setting is changed automatically.
|
|
855
891
|
|
|
856
892
|
See [Connect an external LLM](src/mcp-server/README.md#connect-an-external-llm) for connection examples, tool arguments, cursor polling and authentication details.
|
|
@@ -1425,6 +1461,23 @@ Kōbō talks to the embedded [`@anthropic-ai/claude-agent-sdk`](https://github.c
|
|
|
1425
1461
|
|
|
1426
1462
|
The model selector is populated from [`src/shared/models.ts`](./src/shared/models.ts), also used by backend validation. This checkout includes Fable 5.1, Mythos 5, Opus 5, Opus 4.8, Sonnet 5 and older variants. Pick the default in **Settings → Agents**. Catalogue entries describe what Kōbō offers; provider access still depends on your account. `auto` leaves model selection to the runtime.
|
|
1427
1463
|
|
|
1464
|
+
#### Immediate chat messages
|
|
1465
|
+
|
|
1466
|
+
**Settings → Agents → Force immediate message delivery** controls
|
|
1467
|
+
`forceImmediateMessages`, **false by default**, including existing installations.
|
|
1468
|
+
When enabled, new chat messages reach a busy Claude/Codex agent directly, like
|
|
1469
|
+
**Ctrl+Enter**, instead of waiting in Kōbō's message queue. Messages sent during
|
|
1470
|
+
an auto-loop go through immediate delivery and pause the active, ready loop.
|
|
1471
|
+
Previously queued instructions stay queued; changing the setting never sends
|
|
1472
|
+
old drafts automatically. The setting affects subsequent sends without restarting
|
|
1473
|
+
the server or agent.
|
|
1474
|
+
|
|
1475
|
+
Compaction, pending questions/permissions, archive and session handoff guards
|
|
1476
|
+
still apply. Failed or rejected sends restore the draft; they do not silently
|
|
1477
|
+
queue it. MCP messages already use direct delivery and do not depend on this
|
|
1478
|
+
interface setting. Explicit next-iteration scheduling remains available through
|
|
1479
|
+
its dedicated auto-loop API.
|
|
1480
|
+
|
|
1428
1481
|
#### Session watchdogs
|
|
1429
1482
|
|
|
1430
1483
|
**Settings → Agents → Enable agent inactivity watchdogs**, at the top of the tab,
|
|
@@ -3,9 +3,10 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
4
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
5
5
|
import { getDb } from '../server/db/index.js';
|
|
6
|
+
import { MAX_MCP_ATTACHMENT_REQUEST_BYTES } from '../shared/mcp-attachments.js';
|
|
6
7
|
import { EXTERNAL_MEMORY_TOOL_DEFINITIONS, isMemoryToolName, MEMORY_TOOL_DEFINITIONS } from '../shared/memory-tools.js';
|
|
7
8
|
import { INTERNAL_PROGRESS_BAR_TOOLS, isProgressBarTool, PROGRESS_BAR_TOOLS } from '../shared/progress-bar-tools.js';
|
|
8
|
-
import { isWorkspaceCreationTool,
|
|
9
|
+
import { isWorkspaceCreationTool, WORKSPACE_CREATION_TOOLS } from '../shared/workspace-creation-tools.js';
|
|
9
10
|
import { WORKSPACE_DIALOGUE_TOOLS } from '../shared/workspace-dialogue-tools.js';
|
|
10
11
|
import { isWorkspaceGroupMessageTool, WORKSPACE_GROUP_MESSAGE_TOOLS } from '../shared/workspace-group-message-tools.js';
|
|
11
12
|
import { isWorkspaceLifecycleTool, WORKSPACE_LIFECYCLE_TOOLS } from '../shared/workspace-lifecycle-tools.js';
|
|
@@ -1023,7 +1024,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1023
1024
|
});
|
|
1024
1025
|
const transport = new StdioServerTransport(process.stdin, process.stdout, {
|
|
1025
1026
|
// Include the JSON-lines delimiter in addition to the HTTP envelope budget.
|
|
1026
|
-
maxBufferSize:
|
|
1027
|
+
maxBufferSize: MAX_MCP_ATTACHMENT_REQUEST_BYTES + 1,
|
|
1027
1028
|
});
|
|
1028
1029
|
server.connect(transport).catch((err) => {
|
|
1029
1030
|
console.error('[kobo-tasks-server] Fatal:', err);
|
|
@@ -3,9 +3,10 @@ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/
|
|
|
3
3
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
4
4
|
import { Hono } from 'hono';
|
|
5
5
|
import { bodyLimit } from 'hono/body-limit';
|
|
6
|
+
import { MAX_MCP_ATTACHMENT_REQUEST_BYTES } from '../../shared/mcp-attachments.js';
|
|
6
7
|
import { EXTERNAL_MEMORY_TOOL_DEFINITIONS, isMemoryToolName } from '../../shared/memory-tools.js';
|
|
7
8
|
import { isProgressBarTool, PROGRESS_BAR_TOOLS } from '../../shared/progress-bar-tools.js';
|
|
8
|
-
import { isWorkspaceCreationTool,
|
|
9
|
+
import { isWorkspaceCreationTool, WORKSPACE_CREATION_TOOLS } from '../../shared/workspace-creation-tools.js';
|
|
9
10
|
import { EXTERNAL_MCP_TOOLS } from '../../shared/workspace-dialogue-tools.js';
|
|
10
11
|
import { isWorkspaceGroupMessageTool, WORKSPACE_GROUP_MESSAGE_TOOLS, } from '../../shared/workspace-group-message-tools.js';
|
|
11
12
|
import { isWorkspaceLifecycleTool, WORKSPACE_LIFECYCLE_TOOLS } from '../../shared/workspace-lifecycle-tools.js';
|
|
@@ -19,22 +20,27 @@ import { executeWorkspaceGroupMessageTool, WorkspaceGroupMessageMcpError, } from
|
|
|
19
20
|
import { executeWorkspaceLifecycleTool, WorkspaceLifecycleMcpError, } from '../services/workspace-lifecycle-mcp-service.js';
|
|
20
21
|
import { prepareMemoryToolEnvelope } from '../utils/memory-token-budget.js';
|
|
21
22
|
const app = new Hono();
|
|
22
|
-
// Only creation envelopes may carry the larger, base64 attachment payload.
|
|
23
|
+
// Only creation and message envelopes may carry the larger, base64 attachment payload.
|
|
23
24
|
// Other tools retain their existing 1 MiB limit, including malformed envelopes.
|
|
24
|
-
app.use('*', bodyLimit({ maxSize:
|
|
25
|
+
app.use('*', bodyLimit({ maxSize: MAX_MCP_ATTACHMENT_REQUEST_BYTES }));
|
|
25
26
|
app.use('*', async (c, next) => {
|
|
26
27
|
if (c.req.raw.body) {
|
|
27
28
|
const body = await c.req.raw.clone().arrayBuffer();
|
|
28
29
|
if (body.byteLength > 1024 * 1024) {
|
|
29
|
-
let
|
|
30
|
+
let attachmentTool = false;
|
|
30
31
|
try {
|
|
31
32
|
const envelope = JSON.parse(new TextDecoder().decode(body));
|
|
32
|
-
|
|
33
|
+
attachmentTool =
|
|
34
|
+
envelope?.method === 'tools/call' &&
|
|
35
|
+
(envelope?.params?.name === 'create_workspace' ||
|
|
36
|
+
(envelope?.params?.name === 'send_workspace_message' &&
|
|
37
|
+
Array.isArray(envelope?.params?.arguments?.attachments) &&
|
|
38
|
+
envelope.params.arguments.attachments.length > 0));
|
|
33
39
|
}
|
|
34
40
|
catch {
|
|
35
41
|
/* Preserve the normal body-size rejection for malformed input. */
|
|
36
42
|
}
|
|
37
|
-
if (!
|
|
43
|
+
if (!attachmentTool)
|
|
38
44
|
return c.json({ error: 'MCP request is too large' }, 413);
|
|
39
45
|
}
|
|
40
46
|
}
|
|
@@ -64,7 +70,7 @@ app.all('/', async (c) => {
|
|
|
64
70
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
65
71
|
sessionIdGenerator: undefined,
|
|
66
72
|
enableJsonResponse: true,
|
|
67
|
-
maxRequestBodySize:
|
|
73
|
+
maxRequestBodySize: MAX_MCP_ATTACHMENT_REQUEST_BYTES,
|
|
68
74
|
});
|
|
69
75
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
70
76
|
tools: [
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { execFile, spawn } from 'node:child_process';
|
|
2
|
-
import { existsSync
|
|
3
|
-
import
|
|
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() {
|
|
@@ -37,63 +38,6 @@ export function _resetTrackedProcessesForTests() {
|
|
|
37
38
|
generations.clear();
|
|
38
39
|
shuttingDown = false;
|
|
39
40
|
}
|
|
40
|
-
// ── Pure helpers ───────────────────────────────────────────────────────────────
|
|
41
|
-
/**
|
|
42
|
-
* Sanitize a branch name for use as a Docker instance name.
|
|
43
|
-
* Replace `/` and `_` with `-`, lowercase.
|
|
44
|
-
*/
|
|
45
|
-
export function sanitizeBranchName(branch) {
|
|
46
|
-
return branch.toLowerCase().replace(/[/_]/g, '-');
|
|
47
|
-
}
|
|
48
|
-
/**
|
|
49
|
-
* Parse a `.env` file content into key=value pairs.
|
|
50
|
-
* Skips empty lines and comments (#). Handles quotes.
|
|
51
|
-
*/
|
|
52
|
-
export function parseEnvFile(content) {
|
|
53
|
-
const result = {};
|
|
54
|
-
for (const line of content.split('\n')) {
|
|
55
|
-
const trimmed = line.trim();
|
|
56
|
-
if (!trimmed || trimmed.startsWith('#'))
|
|
57
|
-
continue;
|
|
58
|
-
const eqIndex = trimmed.indexOf('=');
|
|
59
|
-
if (eqIndex === -1)
|
|
60
|
-
continue;
|
|
61
|
-
const key = trimmed.slice(0, eqIndex).trim();
|
|
62
|
-
let value = trimmed.slice(eqIndex + 1).trim();
|
|
63
|
-
// Strip surrounding quotes
|
|
64
|
-
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
65
|
-
value = value.slice(1, -1);
|
|
66
|
-
}
|
|
67
|
-
result[key] = value;
|
|
68
|
-
}
|
|
69
|
-
return result;
|
|
70
|
-
}
|
|
71
|
-
/**
|
|
72
|
-
* Resolve the dev-server instance config for a given project + branch.
|
|
73
|
-
* Looks in `<projectPath>/.container/instances/` for `.env` files.
|
|
74
|
-
*/
|
|
75
|
-
export function resolveInstance(projectPath, workingBranch) {
|
|
76
|
-
const instancesDir = path.join(projectPath, '.container', 'instances');
|
|
77
|
-
if (!existsSync(instancesDir))
|
|
78
|
-
return null;
|
|
79
|
-
const sanitized = sanitizeBranchName(workingBranch);
|
|
80
|
-
const files = readdirSync(instancesDir).filter((f) => f.endsWith('.env'));
|
|
81
|
-
for (const file of files) {
|
|
82
|
-
const content = readFileSync(path.join(instancesDir, file), 'utf-8');
|
|
83
|
-
const parsed = parseEnvFile(content);
|
|
84
|
-
if (parsed.INSTANCE_NAME &&
|
|
85
|
-
parsed.INSTANCE_NAME.toLowerCase() === sanitized &&
|
|
86
|
-
parsed.PROJECT_NAME &&
|
|
87
|
-
/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(parsed.PROJECT_NAME)) {
|
|
88
|
-
return {
|
|
89
|
-
instanceName: parsed.INSTANCE_NAME,
|
|
90
|
-
projectName: parsed.PROJECT_NAME,
|
|
91
|
-
httpPort: parsed.HTTP_PORT ?? '',
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
return null;
|
|
96
|
-
}
|
|
97
41
|
// ── Docker helpers ─────────────────────────────────────────────────────────────
|
|
98
42
|
/**
|
|
99
43
|
* List all running Docker container names.
|
|
@@ -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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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 (
|
|
52
|
-
await sendMessage(workspaceId, message.content, message.sessionId,
|
|
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, ...(
|
|
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,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
|
-
|
|
53
|
-
|
|
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
|