@loicngr/kobo 2.1.23 → 2.1.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +40 -1
- package/CHANGELOG.md +8 -0
- package/CONFIGURATION.md +104 -3
- package/dist/mcp-server/kobo-tasks-server.js +3 -2
- package/dist/server/routes/mcp.js +13 -7
- package/dist/server/routes/workspaces.js +41 -7
- package/dist/server/services/agent/engines/claude-code/engine.js +2 -0
- package/dist/server/services/agent/engines/claude-code/progress-reporting-hook.js +99 -0
- package/dist/server/services/dev-server-service.js +3 -59
- package/dist/server/services/forge/github/provider.js +4 -2
- package/dist/server/services/forge/types.js +0 -1
- 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/git-ops.js +29 -20
- package/dist/server/utils/script-runner.js +5 -2
- package/dist/shared/forge-merge.js +5 -0
- package/dist/shared/mcp-attachments.js +58 -0
- package/dist/shared/progress-bar-prompts.js +1 -1
- 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-xJMPnXKA.js} +1 -1
- package/src/client/dist/pwa/assets/{AutoLoopFinalReviewSettings-VoA9uBw0.js → AutoLoopFinalReviewSettings-Dafi7Tue.js} +1 -1
- package/src/client/dist/pwa/assets/{ChangelogPage-DHl3hD3t.js → ChangelogPage-DH-zUtlf.js} +1 -1
- package/src/client/dist/pwa/assets/{ComparisonPanel-D6EKTtni.js → ComparisonPanel-DwaQyEgb.js} +1 -1
- package/src/client/dist/pwa/assets/{CreatePage-CZzEOVVq.js → CreatePage-DyF_efbs.js} +1 -1
- package/src/client/dist/pwa/assets/{DashboardPage-DjLoUvjA.js → DashboardPage-B8gTooRi.js} +1 -1
- package/src/client/dist/pwa/assets/DiffViewer-BivWNITj.js +8 -0
- package/src/client/dist/pwa/assets/{DiffViewer-DEPOa3D7.css → DiffViewer-XzCvEKLJ.css} +1 -1
- package/src/client/dist/pwa/assets/{DocumentsPanel-yrQviqCd.js → DocumentsPanel-DXjjhGUg.js} +1 -1
- package/src/client/dist/pwa/assets/{DrawerToggleButton-D5qNeXnn.js → DrawerToggleButton-CiVNMTbq.js} +1 -1
- package/src/client/dist/pwa/assets/{GitPanel-CNFtj6k0.css → GitPanel-CRaLJzjc.css} +1 -1
- package/src/client/dist/pwa/assets/GitPanel-DQjgGtoN.js +3 -0
- package/src/client/dist/pwa/assets/{HealthPage-COicuNQn.js → HealthPage-lc2Cuuks.js} +1 -1
- package/src/client/dist/pwa/assets/{MainLayout-ZcExZTqC.js → MainLayout-D3pGcTx8.js} +2 -2
- package/src/client/dist/pwa/assets/{MemoryPanel-D73Qz9f7.js → MemoryPanel-SrR94UVW.js} +1 -1
- package/src/client/dist/pwa/assets/{MemoryProposalList-BGlq2DkD.js → MemoryProposalList-B5uVf3KU.js} +1 -1
- package/src/client/dist/pwa/assets/{QBtnDropdown-CykBCmwa.js → QBtnDropdown-Cxr3lBPB.js} +1 -1
- package/src/client/dist/pwa/assets/{QExpansionItem-CAW9J1Uv.js → QExpansionItem-xIXDevKz.js} +1 -1
- package/src/client/dist/pwa/assets/{QScrollArea-C_d-8eO7.js → QScrollArea-DeczMHVs.js} +1 -1
- package/src/client/dist/pwa/assets/{QSelect-C1AN93yA.js → QSelect-GQAsf7pB.js} +1 -1
- package/src/client/dist/pwa/assets/{QTooltip-C9qaLW_h.js → QTooltip-be9mqhmB.js} +1 -1
- package/src/client/dist/pwa/assets/{SchedulePanel-CQpzDhq7.js → SchedulePanel-R6H-D-m5.js} +1 -1
- package/src/client/dist/pwa/assets/{SearchPage-fZlSSEu_.js → SearchPage-CaeYoIDw.js} +1 -1
- package/src/client/dist/pwa/assets/SettingsPage-BCWdQHS5.js +9 -0
- package/src/client/dist/pwa/assets/SettingsPage-Dcq1Jeoo.css +1 -0
- package/src/client/dist/pwa/assets/{SplitWorkspacePage-CfeO6TZb.js → SplitWorkspacePage-Dw1qUIFU.js} +1 -1
- package/src/client/dist/pwa/assets/{TerminalPanel-CfDZOteT.js → TerminalPanel-Dqw88QqH.js} +1 -1
- package/src/client/dist/pwa/assets/{ToolCallItem-CHE6tylq.js → ToolCallItem-BMn7eela.js} +1 -1
- package/src/client/dist/pwa/assets/{TouchPan-Csev_qU6.js → TouchPan-BwCpe_Oh.js} +1 -1
- package/src/client/dist/pwa/assets/{TourReplayButton-Crr7tF1b.js → TourReplayButton-yeGYf6xT.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-DpUZOiJv.js → WorkspaceAttentionLabels-BJPw65vU.js} +1 -1
- package/src/client/dist/pwa/assets/WorkspacePage-BHO1Q8PG.js +4 -0
- package/src/client/dist/pwa/assets/{WorkspacePage-Bk-WWOYM.css → WorkspacePage-BnklZAYZ.css} +1 -1
- package/src/client/dist/pwa/assets/{browser-o7ftkZex.js → browser-C9U-mSDm.js} +1 -1
- package/src/client/dist/pwa/assets/{build-path-tree-D6dZQpIb.js → build-path-tree-DYdRW2T4.js} +1 -1
- package/src/client/dist/pwa/assets/{custom-sounds-DVX9zCET.js → custom-sounds-DPsfYErA.js} +1 -1
- package/src/client/dist/pwa/assets/{de-DOE4H_Jf.js → de-Bqil7RJE.js} +3 -3
- package/src/client/dist/pwa/assets/{editor.api-C8TYnUXm.js → editor.api-iu5YkEl3.js} +1 -1
- package/src/client/dist/pwa/assets/{efforts-DfsG0_o0.js → efforts-ja6uJadn.js} +1 -1
- package/src/client/dist/pwa/assets/{es-BW57JPlm.js → es-CNcMm1Zz.js} +3 -3
- package/src/client/dist/pwa/assets/{expand-template-CJZlWMfI.js → expand-template-BbgnJ8YH.js} +1 -1
- package/src/client/dist/pwa/assets/{format-DOCqk2Dn.js → format-CLXLy6Ov.js} +1 -1
- package/src/client/dist/pwa/assets/{formatters-BFocC6LJ.js → formatters-DeKgxYJe.js} +1 -1
- package/src/client/dist/pwa/assets/{fr-DnmIKHaQ.js → fr-BObHEohg.js} +3 -3
- package/src/client/dist/pwa/assets/{freemarker2-CfME4WOK.js → freemarker2-2IrnDDvu.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-uvKdDnJl.js → handlebars-CeLN7FCP.js} +1 -1
- package/src/client/dist/pwa/assets/{html-BrEPgLuD.js → html-Bn3rhcp3.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-Df_y96N2.js +1 -0
- package/src/client/dist/pwa/assets/{index-z1Oh2-I_.js → index-CH4JpI2a.js} +4 -4
- package/src/client/dist/pwa/assets/{it-DSrEQgZQ.js → it-DDMC7Emk.js} +3 -3
- package/src/client/dist/pwa/assets/{javascript-KH2xP4lQ.js → javascript-BMvON8oT.js} +1 -1
- package/src/client/dist/pwa/assets/{liquid-D08TlsVs.js → liquid-D_AQLvQl.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-y74A51cS.js → mdx-DDJzMB8Q.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco-contributions-Cx8iaAhd.js → monaco-contributions-DFKwR-_X.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco.contribution-CFq0guCl.js → monaco.contribution-4AwFLnjV.js} +2 -2
- package/src/client/dist/pwa/assets/{network-auth-DnGOOzCV.js → network-auth-CMERg4Qk.js} +1 -1
- package/src/client/dist/pwa/assets/{notify-theme-B9lUAgQl.js → notify-theme-CMzdjL91.js} +1 -1
- package/src/client/dist/pwa/assets/{project-color-TgENPFLi.js → project-color-C5mEKdlH.js} +1 -1
- package/src/client/dist/pwa/assets/{python-Cy3bAVPq.js → python-BOqcp9xc.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-gD9QOuiV.js → razor-BrGwBzd7.js} +1 -1
- package/src/client/dist/pwa/assets/{typescript-CEItY0bH.js → typescript-mOGf86uY.js} +1 -1
- package/src/client/dist/pwa/assets/{use-search-index-status-BtJ0pOy9.js → use-search-index-status-P7O7EaV_.js} +1 -1
- package/src/client/dist/pwa/assets/{use-tours-COLDth2h.js → use-tours-CMFPIW6f.js} +2 -2
- package/src/client/dist/pwa/assets/{use-worktree-restore-CShbwpGy.js → use-worktree-restore-DF6P7h8g.js} +1 -1
- package/src/client/dist/pwa/assets/{workspace-templates-BOMNQgBl.js → workspace-templates-C_bgIQvs.js} +1 -1
- package/src/client/dist/pwa/assets/{xml-BJvL5qcE.js → xml-YcMBzYAm.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-DtkVRZaX.js → yaml-BP_67Sds.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 +43 -4
- package/src/mcp-server/kobo-tasks-server.ts +3 -6
- package/src/client/dist/pwa/assets/DiffViewer-BQ6OEmoi.js +0 -8
- package/src/client/dist/pwa/assets/GitPanel-DCXlhcAD.js +0 -3
- 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/WorkspacePage-_QSKLO42.js +0 -4
- package/src/client/dist/pwa/assets/i18n-BmTCjikk.js +0 -1
|
@@ -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
|
+
}
|
|
@@ -1045,27 +1045,27 @@ export function getChangedFiles(repoPath, base, includeUntracked = false) {
|
|
|
1045
1045
|
}
|
|
1046
1046
|
return files;
|
|
1047
1047
|
}
|
|
1048
|
-
/**
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
* included: uncommitted edits aren't about to be pushed. Returns an empty
|
|
1052
|
-
* list if there is no remote tracking branch yet.
|
|
1053
|
-
*/
|
|
1054
|
-
export function getUnpushedChangedFiles(repoPath, branchName, remote = 'origin') {
|
|
1055
|
-
const remoteRef = `${remote}/${branchName}`;
|
|
1056
|
-
// Bail out cleanly if the remote branch doesn't exist (branch never pushed).
|
|
1048
|
+
/** Local changes relative to the pushed branch, including staged and working-tree edits. */
|
|
1049
|
+
export function getUnpushedChangedFiles(repoPath, branchName, remote = 'origin', sourceBranch = 'HEAD') {
|
|
1050
|
+
let ref = `${remote}/${branchName}`;
|
|
1057
1051
|
try {
|
|
1058
|
-
git(repoPath, ['rev-parse', '--verify',
|
|
1052
|
+
git(repoPath, ['rev-parse', '--verify', ref]);
|
|
1059
1053
|
}
|
|
1060
1054
|
catch {
|
|
1061
|
-
|
|
1062
|
-
}
|
|
1063
|
-
const files = [];
|
|
1064
|
-
try {
|
|
1065
|
-
files.push(...parseNameStatus(git(repoPath, ['diff', '--name-status', '-z', `${remoteRef}..HEAD`])));
|
|
1055
|
+
ref = resolveBase(repoPath, sourceBranch);
|
|
1066
1056
|
}
|
|
1067
|
-
|
|
1068
|
-
|
|
1057
|
+
const files = parseNameStatus(git(repoPath, ['diff', '--name-status', '-z', ref, '--']));
|
|
1058
|
+
const untracked = git(repoPath, ['ls-files', '--others', '--exclude-standard', '-z']);
|
|
1059
|
+
return withUntrackedFiles(files, untracked);
|
|
1060
|
+
}
|
|
1061
|
+
/** Preserve NUL-delimited filenames and deduplicate tracked/untracked transitions. */
|
|
1062
|
+
function withUntrackedFiles(files, output) {
|
|
1063
|
+
const seen = new Set(files.map((file) => file.path));
|
|
1064
|
+
for (const filePath of output.split('\0')) {
|
|
1065
|
+
if (filePath && !seen.has(filePath)) {
|
|
1066
|
+
files.push({ path: filePath, status: 'untracked' });
|
|
1067
|
+
seen.add(filePath);
|
|
1068
|
+
}
|
|
1069
1069
|
}
|
|
1070
1070
|
return files;
|
|
1071
1071
|
}
|
|
@@ -1555,16 +1555,25 @@ export async function getChangedFilesBetweenAsync(repoPath, fromRef, toRef) {
|
|
|
1555
1555
|
return [];
|
|
1556
1556
|
}
|
|
1557
1557
|
}
|
|
1558
|
-
|
|
1558
|
+
/** Use the source branch until this working branch has been pushed for the first time. */
|
|
1559
|
+
export async function resolveUnpushedBaseAsync(repoPath, branchName, sourceBranch = 'HEAD', remote = 'origin') {
|
|
1559
1560
|
const ref = `${remote}/${branchName}`;
|
|
1560
1561
|
try {
|
|
1561
1562
|
await gitAsync(repoPath, ['rev-parse', '--verify', ref]);
|
|
1562
|
-
return
|
|
1563
|
+
return ref;
|
|
1563
1564
|
}
|
|
1564
1565
|
catch {
|
|
1565
|
-
return
|
|
1566
|
+
return resolveBaseAsync(repoPath, sourceBranch);
|
|
1566
1567
|
}
|
|
1567
1568
|
}
|
|
1569
|
+
export async function getUnpushedChangedFilesAsync(repoPath, branchName, remote = 'origin', sourceBranch = 'HEAD') {
|
|
1570
|
+
const ref = await resolveUnpushedBaseAsync(repoPath, branchName, sourceBranch, remote);
|
|
1571
|
+
const [changes, untracked] = await Promise.all([
|
|
1572
|
+
gitAsync(repoPath, ['diff', '--name-status', '-z', ref, '--']),
|
|
1573
|
+
gitAsync(repoPath, ['ls-files', '--others', '--exclude-standard', '-z']),
|
|
1574
|
+
]);
|
|
1575
|
+
return withUntrackedFiles(parseNameStatus(changes), untracked);
|
|
1576
|
+
}
|
|
1568
1577
|
export async function getChangedFilesAsync(repoPath, base, includeUntracked = false) {
|
|
1569
1578
|
const ref = await resolveBaseAsync(repoPath, base);
|
|
1570
1579
|
const [committed, working] = await Promise.allSettled([
|
|
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import * as wsService from '../services/websocket-service.js';
|
|
5
|
+
import { resolveInstance, sanitizeBranchName } from './dev-server-instance.js';
|
|
5
6
|
import { getIndexLockPath } from './git-ops.js';
|
|
6
7
|
import { ensureDirectoryInside } from './safe-path.js';
|
|
7
8
|
/** Default wall-clock budget for a user script before it is force-killed. */
|
|
@@ -39,6 +40,8 @@ export function runScript(opts) {
|
|
|
39
40
|
scriptDirectory = fs.mkdtempSync(path.join(storage, '.script-'));
|
|
40
41
|
scriptPath = path.join(scriptDirectory, path.basename(tmpFileName));
|
|
41
42
|
fs.writeFileSync(scriptPath, script, { mode: 0o700, flag: 'wx' });
|
|
43
|
+
// Resolve only from this workspace's persisted instance, never the host's env.
|
|
44
|
+
const instance = env?.projectPath && env.branchName ? resolveInstance(env.projectPath, env.branchName) : null;
|
|
42
45
|
const proc = spawn('bash', [scriptPath], {
|
|
43
46
|
cwd: worktreePath,
|
|
44
47
|
env: {
|
|
@@ -49,8 +52,8 @@ export function runScript(opts) {
|
|
|
49
52
|
BRANCH_NAME: env?.branchName ?? '',
|
|
50
53
|
SOURCE_BRANCH: env?.sourceBranch ?? '',
|
|
51
54
|
PROJECT_PATH: env?.projectPath ?? '',
|
|
52
|
-
|
|
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
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Explicit PR merge strategies. Availability is declared by each forge provider. */
|
|
2
|
+
export const MERGE_METHODS = ['merge', 'squash', 'rebase'];
|
|
3
|
+
export function isMergeMethod(value) {
|
|
4
|
+
return typeof value === 'string' && MERGE_METHODS.some((method) => method === value);
|
|
5
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { MAX_ATTACHMENTS, MAX_ATTACHMENTS_BYTES, validateAttachments } from './attachments.js';
|
|
2
|
+
/** Inline upload budget for creation and message tools, including base64 expansion. */
|
|
3
|
+
export const MAX_MCP_ATTACHMENT_REQUEST_BYTES = Math.ceil(MAX_ATTACHMENTS_BYTES / 3) * 4 + 1024 * 1024;
|
|
4
|
+
export const MCP_ATTACHMENTS_SCHEMA = {
|
|
5
|
+
type: 'array',
|
|
6
|
+
maxItems: MAX_ATTACHMENTS,
|
|
7
|
+
description: 'Inline files/images: up to 10 files, 50 MiB decoded total. Send canonical base64 bytes, never a server file path.',
|
|
8
|
+
items: {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {
|
|
11
|
+
name: { type: 'string', minLength: 1, maxLength: 255 },
|
|
12
|
+
mime_type: { type: 'string', maxLength: 200 },
|
|
13
|
+
data_base64: { type: 'string', minLength: 1, maxLength: Math.ceil(MAX_ATTACHMENTS_BYTES / 3) * 4 },
|
|
14
|
+
},
|
|
15
|
+
required: ['name', 'mime_type', 'data_base64'],
|
|
16
|
+
additionalProperties: false,
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
/** Validate types, names, canonical base64 and decoded bounds before any writes. */
|
|
20
|
+
export function validateMcpAttachments(value) {
|
|
21
|
+
if (value === undefined)
|
|
22
|
+
return [];
|
|
23
|
+
if (!Array.isArray(value) || value.length > MAX_ATTACHMENTS)
|
|
24
|
+
throw new Error('attachments must contain at most 10 files');
|
|
25
|
+
const files = value;
|
|
26
|
+
const metadata = files.map((input) => {
|
|
27
|
+
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
28
|
+
throw new Error('Invalid attachment');
|
|
29
|
+
const file = input;
|
|
30
|
+
if (Object.keys(file).some((key) => !['name', 'mime_type', 'data_base64'].includes(key)))
|
|
31
|
+
throw new Error('Unknown attachment field');
|
|
32
|
+
if (typeof file.name !== 'string' || !file.name.trim() || file.name.length > 255 || file.name.includes('\0'))
|
|
33
|
+
throw new Error('Invalid attachment name');
|
|
34
|
+
if (typeof file.mime_type !== 'string' || file.mime_type.length > 200 || file.mime_type.includes('\0'))
|
|
35
|
+
throw new Error('Invalid attachment MIME type');
|
|
36
|
+
if (typeof file.data_base64 !== 'string' || file.data_base64.length > Math.ceil(MAX_ATTACHMENTS_BYTES / 3) * 4)
|
|
37
|
+
throw new Error('Invalid attachment data_base64');
|
|
38
|
+
if (/[/\\]/.test(file.name) || file.name === '.' || file.name === '..')
|
|
39
|
+
throw new Error('Attachment name must be a filename, not a path');
|
|
40
|
+
const data = file.data_base64;
|
|
41
|
+
if (data.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(data))
|
|
42
|
+
throw new Error('Attachment data_base64 must be canonical base64');
|
|
43
|
+
// Reject non-zero pad bits too, before the server allocates decoded buffers.
|
|
44
|
+
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
45
|
+
if ((data.endsWith('==') && (alphabet.indexOf(data.at(-3)) & 15) !== 0) ||
|
|
46
|
+
(!data.endsWith('==') && data.endsWith('=') && (alphabet.indexOf(data.at(-2)) & 3) !== 0))
|
|
47
|
+
throw new Error('Attachment data_base64 must be canonical base64');
|
|
48
|
+
return {
|
|
49
|
+
name: file.name,
|
|
50
|
+
type: file.mime_type,
|
|
51
|
+
size: (data.length / 4) * 3 - (data.endsWith('==') ? 2 : data.endsWith('=') ? 1 : 0),
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
const error = validateAttachments(metadata);
|
|
55
|
+
if (error)
|
|
56
|
+
throw new Error(`Invalid attachments: ${error}`);
|
|
57
|
+
return files;
|
|
58
|
+
}
|
|
@@ -4,7 +4,7 @@ export const PROGRESS_BAR_PROMPT = `<kobo-progress-reporting>
|
|
|
4
4
|
${PROGRESS_BAR_PLAN_EXCEPTION}
|
|
5
5
|
For each long-running activity (tests, audits, builds or delegated work), follow this reporting lifecycle:
|
|
6
6
|
1. Call create_progress_bar with a clear label, current step and stable activity_key. Save its returned id and sequence; creating a bar is only the start of reporting.
|
|
7
|
-
2. Call update_progress_bar with that id and an increasing sequence: update the same bar at each actual phase change or meaningful completed milestone, including during a long phase. Even without a measured percentage, report the current step; omit both current and total for step-only updates. Use measured numbers only when known. Never invent percentages, poll, send unchanged updates or start timer-driven reporting loops; follow tool retry guidance.
|
|
7
|
+
2. Call update_progress_bar with that id and an increasing sequence: update the same bar at each actual phase change or meaningful completed milestone, including during a long phase. Even without a measured percentage, report the current step; omit both current and total for step-only updates. Use measured numbers only when known: when tests report a total or your activity has a fixed checklist, provide current and total together and advance current only as those items actually finish. A step-only update changes the label, not the gauge; null numbers mean no percentage is available. Never invent percentages, poll, send unchanged updates or start timer-driven reporting loops; follow tool retry guidance.
|
|
8
8
|
3. Call finish_progress_bar on that id before your final report (or return to the parent) when the activity's succeeded/failed outcome is known. Do not declare ongoing background work finished merely because you are yielding control.
|
|
9
9
|
Pass this requirement to all subagents when delegating: each reports and finishes its own long activity through inherited Kōbō MCP tools; the parent must not duplicate it. If a custom subagent lacks these tools, the parent reports its observed milestones and outcome instead. Subagents must not schedule or replace workspace wakeups; the parent coordinates follow-up.
|
|
10
10
|
A dismissed bar stays hidden: continue without recreating it; dismissal is not cancellation. Reporting never substitutes for verification, and an interrupted reporter does not prove background work stopped. After resume, use new activity keys for new work; do not reopen finished activities.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { MAX_MCP_ATTACHMENT_REQUEST_BYTES, MCP_ATTACHMENTS_SCHEMA, validateMcpAttachments } from './mcp-attachments.js';
|
|
2
2
|
/** Base64 expansion of the shared upload limit, plus bounded JSON metadata. */
|
|
3
|
-
export const MAX_MCP_CREATION_REQUEST_BYTES =
|
|
3
|
+
export const MAX_MCP_CREATION_REQUEST_BYTES = MAX_MCP_ATTACHMENT_REQUEST_BYTES;
|
|
4
4
|
const string = (maxLength = 200, minLength = 1) => ({ type: 'string', minLength, maxLength });
|
|
5
5
|
const choice = (...values) => ({ type: 'string', enum: values });
|
|
6
6
|
const object = (properties, required = []) => ({
|
|
@@ -58,11 +58,7 @@ const properties = {
|
|
|
58
58
|
worktree_path: path,
|
|
59
59
|
comparison_id: string(),
|
|
60
60
|
creation_id: string(),
|
|
61
|
-
attachments:
|
|
62
|
-
type: 'array',
|
|
63
|
-
maxItems: MAX_ATTACHMENTS,
|
|
64
|
-
items: object({ name: string(255), mime_type: string(200, 0), data_base64: string(Math.ceil(MAX_ATTACHMENTS_BYTES / 3) * 4) }, ['name', 'mime_type', 'data_base64']),
|
|
65
|
-
},
|
|
61
|
+
attachments: MCP_ATTACHMENTS_SCHEMA,
|
|
66
62
|
pr_checkout: object({
|
|
67
63
|
fingerprint: string(),
|
|
68
64
|
decisions: object({
|
|
@@ -156,27 +152,7 @@ export function validateWorkspaceCreationArguments(name, input) {
|
|
|
156
152
|
throw new Error('source_branch and working_branch are required unless using a PR or existing worktree');
|
|
157
153
|
if (value.pr_checkout && !value.pr_url)
|
|
158
154
|
throw new Error('pr_checkout requires pr_url');
|
|
159
|
-
|
|
160
|
-
const metadata = files.map((file) => {
|
|
161
|
-
if (/[/\\]/.test(file.name) || file.name === '.' || file.name === '..')
|
|
162
|
-
throw new Error('Attachment name must be a filename, not a path');
|
|
163
|
-
const data = file.data_base64;
|
|
164
|
-
if (data.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(data))
|
|
165
|
-
throw new Error('Attachment data_base64 must be canonical base64');
|
|
166
|
-
// Reject non-zero pad bits too, before the server allocates decoded buffers.
|
|
167
|
-
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
168
|
-
if ((data.endsWith('==') && (alphabet.indexOf(data.at(-3)) & 15) !== 0) ||
|
|
169
|
-
(!data.endsWith('==') && data.endsWith('=') && (alphabet.indexOf(data.at(-2)) & 3) !== 0))
|
|
170
|
-
throw new Error('Attachment data_base64 must be canonical base64');
|
|
171
|
-
return {
|
|
172
|
-
name: file.name,
|
|
173
|
-
type: file.mime_type,
|
|
174
|
-
size: (data.length / 4) * 3 - (data.endsWith('==') ? 2 : data.endsWith('=') ? 1 : 0),
|
|
175
|
-
};
|
|
176
|
-
});
|
|
177
|
-
const error = validateAttachments(metadata);
|
|
178
|
-
if (error)
|
|
179
|
-
throw new Error(`Invalid attachments: ${error}`);
|
|
155
|
+
validateMcpAttachments(value.attachments);
|
|
180
156
|
}
|
|
181
157
|
return value;
|
|
182
158
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { MCP_ATTACHMENTS_SCHEMA, validateMcpAttachments } from './mcp-attachments.js';
|
|
1
2
|
const workspace = { type: 'string', minLength: 1, maxLength: 200, description: 'Workspace ID from list_workspaces.' };
|
|
2
3
|
const session = {
|
|
3
4
|
type: 'string',
|
|
@@ -27,17 +28,18 @@ export const WORKSPACE_DIALOGUE_TOOLS = [
|
|
|
27
28
|
after_cursor: { type: 'string', minLength: 1, maxLength: 200 },
|
|
28
29
|
limit: { type: 'integer', minimum: 1, maximum: 200, description: 'Maximum source events scanned (default 100).' },
|
|
29
30
|
}, ['workspace_id']),
|
|
30
|
-
tool('send_workspace_message', 'Send
|
|
31
|
+
tool('send_workspace_message', 'Send text and/or inline base64 files/images to an agent, resuming it if needed. Attachments use the same format as create_workspace (10 files, 50 MiB total). Returns acceptance, not the agent reply; read_workspace_messages retrieves replies. Uses the configured permission mode and pauses active auto-loop. Do not retry blindly after an ambiguous connection failure. Do not send to your own workspace.', {
|
|
31
32
|
workspace_id: workspace,
|
|
32
33
|
session_id: session,
|
|
33
|
-
content: { type: 'string', minLength:
|
|
34
|
+
content: { type: 'string', minLength: 0, maxLength: 100_000 },
|
|
35
|
+
attachments: MCP_ATTACHMENTS_SCHEMA,
|
|
34
36
|
idempotency_key: {
|
|
35
37
|
type: 'string',
|
|
36
38
|
minLength: 1,
|
|
37
39
|
maxLength: 200,
|
|
38
|
-
description: 'Generate once per intended message; reuse on retries to prevent duplicate dispatch, including after restart. Reuse with different content/session is rejected.',
|
|
40
|
+
description: 'Generate once per intended message; reuse on retries to prevent duplicate dispatch, including after restart. Reuse with different content/session/attachments is rejected.',
|
|
39
41
|
},
|
|
40
|
-
}, ['workspace_id'
|
|
42
|
+
}, ['workspace_id'], false),
|
|
41
43
|
tool('get_workspace_questions', 'Read pending questions and whether a human tool approval is blocking the workspace.', { workspace_id: workspace }, ['workspace_id']),
|
|
42
44
|
tool('answer_workspace_question', 'Answer the current pending question using its exact tool_call_id. Answer keys are question IDs for Codex, question text for Claude. Tool permission requests require human approval in Kōbō.', {
|
|
43
45
|
workspace_id: workspace,
|
|
@@ -62,8 +64,13 @@ export function validateDialogueArguments(name, value) {
|
|
|
62
64
|
const property = properties[key];
|
|
63
65
|
if (!Object.hasOwn(properties, key))
|
|
64
66
|
throw new Error(`Unknown argument '${key}'`);
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
+
if (key === 'attachments') {
|
|
68
|
+
validateMcpAttachments(input);
|
|
69
|
+
}
|
|
70
|
+
else if (property.type === 'string') {
|
|
71
|
+
if (typeof input !== 'string' ||
|
|
72
|
+
(property.minLength !== 0 && !input.trim()) ||
|
|
73
|
+
input.length > (property.maxLength ?? 100_000))
|
|
67
74
|
throw new Error(`${key} must be a non-empty string within its length limit`);
|
|
68
75
|
}
|
|
69
76
|
else if (property.type === 'boolean') {
|
|
@@ -85,5 +92,10 @@ export function validateDialogueArguments(name, value) {
|
|
|
85
92
|
throw new Error(`${key} must be a non-empty object of string answers`);
|
|
86
93
|
}
|
|
87
94
|
}
|
|
95
|
+
if (name === 'send_workspace_message') {
|
|
96
|
+
if (!args.content?.trim() && !validateMcpAttachments(args.attachments).length)
|
|
97
|
+
throw new Error('Message content or attachments are required');
|
|
98
|
+
return { ...args, content: args.content ?? '' };
|
|
99
|
+
}
|
|
88
100
|
return args;
|
|
89
101
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loicngr/kobo",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.25",
|
|
4
4
|
"description": "Kōbō — local multi-workspace orchestrator for Claude Code and OpenAI Codex, with isolated Git worktrees, live review and optional automation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "GPL-3.0-or-later",
|