@loicngr/kobo 2.1.15 → 2.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/AGENTS.md +61 -0
  2. package/CHANGELOG.md +12 -0
  3. package/CONFIGURATION.md +74 -17
  4. package/README.md +7 -3
  5. package/dist/mcp-server/kobo-tasks-server.js +19 -104
  6. package/dist/mcp-server/workspace-dialogue-client.js +15 -3
  7. package/dist/server/db/migrations.js +2 -0
  8. package/dist/server/db/schema.js +2 -0
  9. package/dist/server/db/workspace-message-batch-schema.js +9 -0
  10. package/dist/server/index.js +8 -0
  11. package/dist/server/middleware/workspace-creation-maintenance.js +13 -0
  12. package/dist/server/routes/maintenance.js +32 -0
  13. package/dist/server/routes/mcp.js +69 -3
  14. package/dist/server/routes/pull-requests.js +2 -1
  15. package/dist/server/routes/workspace-messages.js +22 -0
  16. package/dist/server/routes/workspaces.js +60 -4
  17. package/dist/server/services/agent/orchestrator.js +21 -6
  18. package/dist/server/services/agent/session-controller.js +4 -1
  19. package/dist/server/services/cron-service.js +7 -0
  20. package/dist/server/services/pr-checkout-rollback-service.js +51 -0
  21. package/dist/server/services/pr-checkout-service.js +21 -1
  22. package/dist/server/services/quota-backoff-service.js +7 -0
  23. package/dist/server/services/update-maintenance-service.js +44 -0
  24. package/dist/server/services/wakeup-service.js +21 -3
  25. package/dist/server/services/workspace-creation-mcp-service.js +214 -0
  26. package/dist/server/services/workspace-group-message-mcp-service.js +58 -0
  27. package/dist/server/services/workspace-group-message-service.js +182 -0
  28. package/dist/server/services/workspace-lifecycle-mcp-service.js +72 -0
  29. package/dist/server/services/workspace-message-service.js +5 -0
  30. package/dist/shared/pr-checkout-steps.js +86 -0
  31. package/dist/shared/workspace-creation-tools.js +182 -0
  32. package/dist/shared/workspace-group-message-tools.js +134 -0
  33. package/dist/shared/workspace-group-messages.js +27 -0
  34. package/dist/shared/workspace-lifecycle-tools.js +112 -0
  35. package/docker-compose.vps.yml +60 -0
  36. package/docs/deployment-vps.md +506 -0
  37. package/docs/getting-started.md +9 -0
  38. package/package.json +8 -1
  39. package/scripts/kobo-gh +9 -0
  40. package/scripts/kobo-github-app-token +190 -0
  41. package/scripts/kobo-install +392 -0
  42. package/scripts/kobo-update +499 -0
  43. package/src/client/dist/pwa/assets/ActivityFeed-S3XGw3WW.js +2 -0
  44. package/src/client/dist/pwa/assets/{AutoLoopFinalReviewSettings-lKjfaC6V.js → AutoLoopFinalReviewSettings-CC8ICe_I.js} +1 -1
  45. package/src/client/dist/pwa/assets/{ChangelogPage-BplM7qEB.js → ChangelogPage-C1aiPBoi.js} +1 -1
  46. package/src/client/dist/pwa/assets/{ComparisonPanel-DxK5v9w_.js → ComparisonPanel-DElbn4ac.js} +1 -1
  47. package/src/client/dist/pwa/assets/CreatePage-Bx58t4YW.js +2 -0
  48. package/src/client/dist/pwa/assets/{DashboardPage-BsP-MATS.js → DashboardPage-D1S3QGp4.js} +1 -1
  49. package/src/client/dist/pwa/assets/DiffViewer-BH4X9ru1.js +8 -0
  50. package/src/client/dist/pwa/assets/{DocumentsPanel-CGrf8Q3H.js → DocumentsPanel-C4AvyZF3.js} +1 -1
  51. package/src/client/dist/pwa/assets/{DrawerToggleButton-BANeLSsR.js → DrawerToggleButton-LzIEeeJY.js} +1 -1
  52. package/src/client/dist/pwa/assets/GitPanel-BP3N_4nh.js +3 -0
  53. package/src/client/dist/pwa/assets/{HealthPage-BVTNx-92.js → HealthPage-Cm13vxOi.js} +1 -1
  54. package/src/client/dist/pwa/assets/MainLayout-DGmMkfuw.js +2 -0
  55. package/src/client/dist/pwa/assets/{MainLayout-C6nvIQjU.css → MainLayout-DoAEgANU.css} +1 -1
  56. package/src/client/dist/pwa/assets/{MemoryPanel-C71rTpv9.js → MemoryPanel-C6tzCv6D.js} +1 -1
  57. package/src/client/dist/pwa/assets/{MemoryProposalList-CoqhUGrv.js → MemoryProposalList-CzpzIirK.js} +1 -1
  58. package/src/client/dist/pwa/assets/{QBtnDropdown-U9Ey5W8h.js → QBtnDropdown-BnuKPevV.js} +1 -1
  59. package/src/client/dist/pwa/assets/{QExpansionItem-Be_A1MT1.js → QExpansionItem-DjaUhgjt.js} +1 -1
  60. package/src/client/dist/pwa/assets/{QScrollArea-D4lYzAPQ.js → QScrollArea-Dpksslru.js} +1 -1
  61. package/src/client/dist/pwa/assets/QSelect-Dh4CjliE.js +1 -0
  62. package/src/client/dist/pwa/assets/{QTooltip-kStOgY0X.js → QTooltip-CH45NuVs.js} +1 -1
  63. package/src/client/dist/pwa/assets/{SchedulePanel-C7nTGdWE.js → SchedulePanel-QAjXuTIN.js} +1 -1
  64. package/src/client/dist/pwa/assets/{SearchPage-C6J72pkQ.js → SearchPage-B1pbwDej.js} +1 -1
  65. package/src/client/dist/pwa/assets/SettingsPage-CUQefsDh.js +9 -0
  66. package/src/client/dist/pwa/assets/{SplitWorkspacePage-Bqn4gdVw.js → SplitWorkspacePage-CJ7e-KPh.js} +1 -1
  67. package/src/client/dist/pwa/assets/{TerminalPanel-zLeDCReI.js → TerminalPanel-DVomz3oo.js} +10 -10
  68. package/src/client/dist/pwa/assets/{ToolCallItem-B8b_Sagj.js → ToolCallItem-BntDHc6p.js} +3 -3
  69. package/src/client/dist/pwa/assets/{TouchPan-Bl8VzIDK.js → TouchPan-DwqfsAxH.js} +1 -1
  70. package/src/client/dist/pwa/assets/{TourReplayButton-pL0HCboU.js → TourReplayButton-OIavXdUg.js} +1 -1
  71. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-9Eomx0rQ.js → WorkspaceAttentionLabels-CYwN1-Rf.js} +1 -1
  72. package/src/client/dist/pwa/assets/WorkspacePage-CAsY7Hl-.js +4 -0
  73. package/src/client/dist/pwa/assets/{browser-C-PJ4alw.js → browser-Cl0d1gbu.js} +1 -1
  74. package/src/client/dist/pwa/assets/{build-path-tree-SW9rj7B_.js → build-path-tree-J3YQeMuV.js} +1 -1
  75. package/src/client/dist/pwa/assets/{custom-sounds-toz-4drX.js → custom-sounds-D-T5rpgv.js} +1 -1
  76. package/src/client/dist/pwa/assets/de-C-RtXLqL.js +17 -0
  77. package/src/client/dist/pwa/assets/{editor.api-BJTPhnh8.js → editor.api-BoFiCjBZ.js} +1 -1
  78. package/src/client/dist/pwa/assets/{efforts-BO9xGn9q.js → efforts-Y6XotW97.js} +1 -1
  79. package/src/client/dist/pwa/assets/es-CBlW91ZK.js +17 -0
  80. package/src/client/dist/pwa/assets/{expand-template-BOZMtWau.js → expand-template-sfOHWudd.js} +1 -1
  81. package/src/client/dist/pwa/assets/{format-hEnpRyit.js → format-mWov137C.js} +1 -1
  82. package/src/client/dist/pwa/assets/{formatters-DD_eFc4z.js → formatters-B_XsveM0.js} +1 -1
  83. package/src/client/dist/pwa/assets/fr-Hfzp_sF5.js +17 -0
  84. package/src/client/dist/pwa/assets/{freemarker2-B6nUofvT.js → freemarker2-2pGNakhH.js} +1 -1
  85. package/src/client/dist/pwa/assets/{handlebars-4P3gtM8p.js → handlebars-Bn2V6Liw.js} +1 -1
  86. package/src/client/dist/pwa/assets/{html-C9ZzWo-y.js → html-BBhkJkf2.js} +1 -1
  87. package/src/client/dist/pwa/assets/i18n-sBCWwf8Y.js +1 -0
  88. package/src/client/dist/pwa/assets/index-DCpe-Tfm.js +20 -0
  89. package/src/client/dist/pwa/assets/it-Bl-v27qg.js +17 -0
  90. package/src/client/dist/pwa/assets/{javascript-BGEC5ggd.js → javascript-Ba0G68U2.js} +1 -1
  91. package/src/client/dist/pwa/assets/{liquid-DNtF630O.js → liquid-C5VrzHr_.js} +1 -1
  92. package/src/client/dist/pwa/assets/{mdx-CICwyOd0.js → mdx-DrPRiuKb.js} +1 -1
  93. package/src/client/dist/pwa/assets/{monaco-contributions-BkuwvgrT.js → monaco-contributions-qQUY6VS7.js} +1 -1
  94. package/src/client/dist/pwa/assets/{monaco.contribution-GX8XA-PE.js → monaco.contribution-Dtq2RsyB.js} +2 -2
  95. package/src/client/dist/pwa/assets/network-auth-BPJpXK4L.js +1 -0
  96. package/src/client/dist/pwa/assets/notify-theme-Bl9iRsZB.js +1 -0
  97. package/src/client/dist/pwa/assets/{project-color-B1SMFjSe.js → project-color-Dzvvmaml.js} +1 -1
  98. package/src/client/dist/pwa/assets/{python-n5uPdTVS.js → python-Cuf_7umq.js} +1 -1
  99. package/src/client/dist/pwa/assets/{razor-AOj0nsUt.js → razor-DcVpxFeh.js} +1 -1
  100. package/src/client/dist/pwa/assets/{typescript-Uhkrqihh.js → typescript-Bjcc3kYC.js} +1 -1
  101. package/src/client/dist/pwa/assets/{use-search-index-status-CW0BdpBZ.js → use-search-index-status-Bwf40BEI.js} +1 -1
  102. package/src/client/dist/pwa/assets/use-tours-CWafZt-m.js +2 -0
  103. package/src/client/dist/pwa/assets/{use-worktree-restore-BZxibZS5.js → use-worktree-restore-DkcM2FAZ.js} +1 -1
  104. package/src/client/dist/pwa/assets/{workspace-templates-W557e1-m.js → workspace-templates-DL_Cs7mD.js} +1 -1
  105. package/src/client/dist/pwa/assets/{xml-B-hnMgEs.js → xml-BmJwqJOz.js} +1 -1
  106. package/src/client/dist/pwa/assets/{yaml-DqXkTvgm.js → yaml-DAssfLhS.js} +1 -1
  107. package/src/client/dist/pwa/index.html +1 -1
  108. package/src/client/dist/pwa/sw.js +1 -1
  109. package/src/mcp-server/README.md +130 -12
  110. package/src/mcp-server/kobo-tasks-server.ts +27 -105
  111. package/src/mcp-server/workspace-dialogue-client.ts +18 -3
  112. package/src/client/dist/pwa/assets/ActivityFeed-D7jouiuP.js +0 -2
  113. package/src/client/dist/pwa/assets/CreatePage-D5ilP5kt.js +0 -2
  114. package/src/client/dist/pwa/assets/DiffViewer-Cd15vFWj.js +0 -8
  115. package/src/client/dist/pwa/assets/GitPanel-CEPV5Qw0.js +0 -3
  116. package/src/client/dist/pwa/assets/MainLayout-DBKtwilW.js +0 -2
  117. package/src/client/dist/pwa/assets/QSelect-fS0DLOd1.js +0 -1
  118. package/src/client/dist/pwa/assets/SettingsPage-SuH-aIzv.js +0 -9
  119. package/src/client/dist/pwa/assets/WorkspacePage-B6clgURl.js +0 -4
  120. package/src/client/dist/pwa/assets/de-Ddxe8cc6.js +0 -17
  121. package/src/client/dist/pwa/assets/es-DBJIQeWW.js +0 -17
  122. package/src/client/dist/pwa/assets/fr-yBzIIPaQ.js +0 -17
  123. package/src/client/dist/pwa/assets/i18n-2-ULabk2.js +0 -1
  124. package/src/client/dist/pwa/assets/index-Vk99jtxD.js +0 -20
  125. package/src/client/dist/pwa/assets/it-D44U-PT1.js +0 -17
  126. package/src/client/dist/pwa/assets/network-auth-KnWzjYIc.js +0 -1
  127. package/src/client/dist/pwa/assets/notify-theme-CWuZTz4Z.js +0 -1
  128. package/src/client/dist/pwa/assets/use-tours-CndFlyL-.js +0 -2
@@ -0,0 +1,86 @@
1
+ /** Blockers the user CAN resolve, by picking another worktree path. Everything
2
+ * else (missing CLI, fork PR, deleted remote branch, index.lock, unrelated
3
+ * history) leaves nothing to choose and ends the flow. */
4
+ const RESOLVABLE_BLOCKERS = new Set(['path-occupied', 'worktree-other-branch']);
5
+ /**
6
+ * Derive the ordered steps for a report. A blocker short-circuits everything:
7
+ * there is no point asking about a stash when the forge CLI is missing.
8
+ * `operation` precedes `changes` because aborting a rebase changes what is dirty.
9
+ */
10
+ export function deriveSteps(report) {
11
+ const fatal = report.blockers.filter((b) => !RESOLVABLE_BLOCKERS.has(b.kind));
12
+ if (fatal.length > 0) {
13
+ return [{ id: 'blocked', blocking: true, titleKey: 'prCheckout.step.blocked' }];
14
+ }
15
+ const steps = [];
16
+ if (report.blockers.some((b) => RESOLVABLE_BLOCKERS.has(b.kind))) {
17
+ steps.push({ id: 'path', blocking: false, titleKey: 'prCheckout.step.path' });
18
+ }
19
+ if (report.workspace.state !== 'none') {
20
+ steps.push({ id: 'workspace', blocking: false, titleKey: 'prCheckout.step.workspace' });
21
+ }
22
+ // A purged workspace's worktree can reappear as an 'orphan' on disk during the
23
+ // pr-watcher's restore window (see AGENTS.md § Worktree purge) — restoring the
24
+ // workspace (via the 'workspace' step above) already handles reattaching it, so
25
+ // this must not also surface as a separate worktree step.
26
+ if (report.workspace.state !== 'purged' &&
27
+ (report.worktree.state === 'orphan' || report.worktree.state === 'attached')) {
28
+ steps.push({ id: 'worktree', blocking: false, titleKey: 'prCheckout.step.worktree' });
29
+ }
30
+ if (report.ongoingOperation) {
31
+ steps.push({ id: 'operation', blocking: false, titleKey: 'prCheckout.step.operation' });
32
+ }
33
+ if (report.localChanges.present) {
34
+ steps.push({ id: 'changes', blocking: false, titleKey: 'prCheckout.step.changes' });
35
+ }
36
+ if (report.branch.state === 'behind' || report.branch.state === 'ahead' || report.branch.state === 'diverged') {
37
+ steps.push({ id: 'divergence', blocking: false, titleKey: 'prCheckout.step.divergence' });
38
+ }
39
+ return steps;
40
+ }
41
+ /**
42
+ * The pre-selected choice for each step. The rule that matters: wherever local
43
+ * commits or local edits exist, the default is the one that loses nothing.
44
+ */
45
+ export function defaultDecisions(report) {
46
+ const decisions = {};
47
+ if (report.workspace.state === 'active')
48
+ decisions.existingWorkspace = 'open';
49
+ if (report.workspace.state === 'archived')
50
+ decisions.archivedWorkspace = 'unarchive';
51
+ if (report.workspace.state === 'purged')
52
+ decisions.purgedWorktree = 'restore';
53
+ // A purged workspace's worktree can reappear as an 'orphan' on disk during the
54
+ // pr-watcher's restore window (see AGENTS.md § Worktree purge) — restoring the
55
+ // workspace already handles reattaching it, so this must not also surface as a
56
+ // separate 'attach' decision.
57
+ if (report.workspace.state !== 'purged' && report.worktree.state === 'orphan')
58
+ decisions.orphanWorktree = 'attach';
59
+ // 'cancel' — not 'abort' — is the loses-nothing default: aborting a rebase
60
+ // or merge discards any conflict-resolution work already staged, exactly
61
+ // the kind of destructive default this function otherwise avoids.
62
+ if (report.ongoingOperation)
63
+ decisions.ongoingOperation = 'cancel';
64
+ if (report.localChanges.present)
65
+ decisions.localChanges = 'keep';
66
+ if (report.branch.state === 'behind')
67
+ decisions.divergence = 'fast-forward';
68
+ else if (report.branch.state === 'ahead' || report.branch.state === 'diverged')
69
+ decisions.divergence = 'keep';
70
+ return decisions;
71
+ }
72
+ /** Return an i18n key for choices that cannot preserve the requested work. */
73
+ export function checkoutDecisionError(report, decisions) {
74
+ if (decisions.divergence === 'fast-forward' &&
75
+ (report.branch.state === 'ahead' || report.branch.state === 'diverged'))
76
+ return 'prCheckout.divergence.notFastForward';
77
+ if ((report.worktree.state === 'orphan' || report.worktree.state === 'attached') &&
78
+ decisions.orphanWorktree === 'create-elsewhere')
79
+ return 'prCheckout.worktree.branchInUse';
80
+ if (report.localChanges.present &&
81
+ (decisions.localChanges ?? 'keep') === 'keep' &&
82
+ decisions.divergence === 'reset-hard') {
83
+ return 'prCheckout.changes.keepResetConflict';
84
+ }
85
+ return null;
86
+ }
@@ -0,0 +1,182 @@
1
+ import { MAX_ATTACHMENTS, MAX_ATTACHMENTS_BYTES, validateAttachments } from './attachments.js';
2
+ /** Base64 expansion of the shared upload limit, plus bounded JSON metadata. */
3
+ export const MAX_MCP_CREATION_REQUEST_BYTES = Math.ceil(MAX_ATTACHMENTS_BYTES / 3) * 4 + 1024 * 1024;
4
+ const string = (maxLength = 200, minLength = 1) => ({ type: 'string', minLength, maxLength });
5
+ const choice = (...values) => ({ type: 'string', enum: values });
6
+ const object = (properties, required = []) => ({
7
+ type: 'object',
8
+ properties,
9
+ required,
10
+ additionalProperties: false,
11
+ });
12
+ const effort = choice('auto', 'none', 'low', 'medium', 'high', 'xhigh', 'max');
13
+ const engine = choice('claude-code', 'codex');
14
+ const path = string(4096);
15
+ const url = string(4096);
16
+ const titles = { type: 'array', items: string(20_000), maxItems: 500 };
17
+ const properties = {
18
+ name: string(1000),
19
+ project_path: path,
20
+ source_branch: string(1024),
21
+ working_branch: string(1024),
22
+ engine,
23
+ model: string(),
24
+ reasoning_effort: effort,
25
+ brainstorm_model: string(),
26
+ brainstorm_reasoning_effort: effort,
27
+ description: string(200_000, 0),
28
+ tags: { type: 'array', items: string(200), maxItems: 100 },
29
+ tasks: titles,
30
+ acceptance_criteria: titles,
31
+ agent_permission_mode: choice('plan', 'bypass', 'strict', 'interactive'),
32
+ auto_loop: { type: 'boolean' },
33
+ auto_loop_session_mode: choice('per_task', 'continuous'),
34
+ skip_setup_script: { type: 'boolean' },
35
+ auto_loop_final_review: {
36
+ ...object({ engine, model: string(), reasoning_effort: effort, additional_instructions: string(20_000, 0) }, [
37
+ 'engine',
38
+ 'model',
39
+ 'reasoning_effort',
40
+ ]),
41
+ type: ['object', 'null'],
42
+ },
43
+ workflow_policy: object({
44
+ commit: choice('manual', 'automatic'),
45
+ push: choice('manual', 'automatic'),
46
+ publish: choice('manual', 'automatic'),
47
+ }),
48
+ notion_url: {
49
+ ...url,
50
+ description: 'Import this Notion ticket before launching the agent. Requires an enabled, configured Notion integration.',
51
+ },
52
+ notion_page_id: {
53
+ ...string(),
54
+ description: 'Optional stored Notion page identifier. Does not import content on its own; provide notion_url to import the ticket.',
55
+ },
56
+ sentry_url: url,
57
+ pr_url: url,
58
+ worktree_path: path,
59
+ comparison_id: string(),
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
+ },
66
+ pr_checkout: object({
67
+ fingerprint: string(),
68
+ decisions: object({
69
+ existingWorkspace: choice('open', 'continue'),
70
+ archivedWorkspace: choice('unarchive', 'continue'),
71
+ purgedWorktree: choice('restore'),
72
+ orphanWorktree: choice('attach', 'create-elsewhere'),
73
+ pathCollision: object({ worktreePath: path }, ['worktreePath']),
74
+ localChanges: choice('stash', 'commit', 'discard', 'keep'),
75
+ ongoingOperation: choice('abort', 'cancel'),
76
+ divergence: choice('fast-forward', 'rebase', 'reset-hard', 'keep'),
77
+ }),
78
+ }, ['fingerprint', 'decisions']),
79
+ };
80
+ const schemas = {
81
+ create_workspace: object(properties, ['name', 'project_path']),
82
+ diagnose_workspace_pr: object({ project_path: path, pr_url: url }, ['project_path', 'pr_url']),
83
+ };
84
+ export const WORKSPACE_CREATION_TOOLS = [
85
+ {
86
+ name: 'create_workspace',
87
+ description: 'Create a Kōbō workspace with the same configuration as the create form, including final review, workflow policy, imports and inline base64 attachments (10 files, 50 MiB total). Plain creation requires source_branch and working_branch; worktree_path requires source_branch. A pr_url derives canonical branches and diagnoses checkout first. Unsafe or ambiguous checkout returns requiresAction and a fingerprint: repeat with explicit pr_checkout decisions. Never silently discards local work. Existing workspaces can be opened or explicitly unarchived; no duplicate is created. skip_setup_script defaults to true for PR checkout. Project must be configured in Kōbō.',
88
+ inputSchema: schemas.create_workspace,
89
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
90
+ },
91
+ {
92
+ name: 'diagnose_workspace_pr',
93
+ description: 'Read the canonical PR and local checkout state for a configured project. Returns report, pr and fingerprint for explicit create_workspace pr_checkout decisions; creates no workspace and applies no checkout changes.',
94
+ inputSchema: schemas.diagnose_workspace_pr,
95
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
96
+ },
97
+ ];
98
+ export function isWorkspaceCreationTool(name) {
99
+ return Object.hasOwn(schemas, name);
100
+ }
101
+ function validate(schema, value, label) {
102
+ if (value === null && Array.isArray(schema.type))
103
+ return;
104
+ if (schema.type === 'object' || Array.isArray(schema.type)) {
105
+ if (!value || typeof value !== 'object' || Array.isArray(value))
106
+ throw new Error(`${label} must be an object`);
107
+ const input = value;
108
+ for (const key of Object.keys(input)) {
109
+ if (!Object.hasOwn(schema.properties, key))
110
+ throw new Error(`Unknown field ${label}.${key}`);
111
+ validate(schema.properties[key], input[key], `${label}.${key}`);
112
+ }
113
+ for (const key of schema.required ?? [])
114
+ if (!Object.hasOwn(input, key))
115
+ throw new Error(`${label}.${key} is required`);
116
+ }
117
+ else if (schema.type === 'array') {
118
+ if (!Array.isArray(value) || value.length > schema.maxItems)
119
+ throw new Error(`${label} must be an array with at most ${schema.maxItems} entries`);
120
+ for (const item of value)
121
+ validate(schema.items, item, label);
122
+ }
123
+ else if (schema.type === 'boolean') {
124
+ if (typeof value !== 'boolean')
125
+ throw new Error(`${label} must be a boolean`);
126
+ }
127
+ else {
128
+ if (typeof value !== 'string' ||
129
+ value.includes('\0') ||
130
+ value.length < (schema.minLength ?? 0) ||
131
+ value.length > (schema.maxLength ?? Infinity) ||
132
+ (schema.minLength && !value.trim()) ||
133
+ (schema.enum && !schema.enum.includes(value)))
134
+ throw new Error(`Invalid ${label}`);
135
+ }
136
+ }
137
+ /** Validates before dispatch, including uploads before a PR checkout can mutate Git. */
138
+ export function validateWorkspaceCreationArguments(name, input) {
139
+ if (!isWorkspaceCreationTool(name))
140
+ throw new Error(`Unknown workspace creation tool: ${name}`);
141
+ validate(schemas[name], input, name);
142
+ const value = input;
143
+ for (const key of ['notion_url', 'sentry_url', 'pr_url']) {
144
+ if (value[key] !== undefined) {
145
+ const parsed = new URL(value[key]);
146
+ if (!['https:', 'http:'].includes(parsed.protocol) || parsed.username || parsed.password)
147
+ throw new Error(`Invalid ${key}`);
148
+ }
149
+ }
150
+ if (name === 'create_workspace') {
151
+ const { attachments: _attachments, ...metadataFields } = value;
152
+ // Keep room for multipart file headers within the downstream 1 MiB allowance.
153
+ if (new TextEncoder().encode(JSON.stringify(metadataFields)).byteLength > 960 * 1024)
154
+ throw new Error('Workspace creation metadata exceeds 960 KiB');
155
+ if (!value.pr_url && (!value.source_branch || (!value.worktree_path && !value.working_branch)))
156
+ throw new Error('source_branch and working_branch are required unless using a PR or existing worktree');
157
+ if (value.pr_checkout && !value.pr_url)
158
+ throw new Error('pr_checkout requires pr_url');
159
+ const files = (value.attachments ?? []);
160
+ const metadata = files.map((file) => {
161
+ if (/[/\\]/.test(file.name) || file.name === '.' || file.name === '..')
162
+ throw new Error('Attachment name must be a filename, not a path');
163
+ const data = file.data_base64;
164
+ if (data.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(data))
165
+ throw new Error('Attachment data_base64 must be canonical base64');
166
+ // Reject non-zero pad bits too, before the server allocates decoded buffers.
167
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
168
+ if ((data.endsWith('==') && (alphabet.indexOf(data.at(-3)) & 15) !== 0) ||
169
+ (!data.endsWith('==') && data.endsWith('=') && (alphabet.indexOf(data.at(-2)) & 3) !== 0))
170
+ throw new Error('Attachment data_base64 must be canonical base64');
171
+ return {
172
+ name: file.name,
173
+ type: file.mime_type,
174
+ size: (data.length / 4) * 3 - (data.endsWith('==') ? 2 : data.endsWith('=') ? 1 : 0),
175
+ };
176
+ });
177
+ const error = validateAttachments(metadata);
178
+ if (error)
179
+ throw new Error(`Invalid attachments: ${error}`);
180
+ }
181
+ return value;
182
+ }
@@ -0,0 +1,134 @@
1
+ import { MAX_GROUP_MESSAGE_LENGTH, MAX_GROUP_MESSAGE_RECIPIENTS, parseGroupMessageInput, } from './workspace-group-messages.js';
2
+ const identifier = { type: 'string', pattern: '^[A-Za-z0-9_-]{1,200}$', minLength: 1, maxLength: 200 };
3
+ const statuses = [
4
+ 'created',
5
+ 'extracting',
6
+ 'brainstorming',
7
+ 'executing',
8
+ 'compacting',
9
+ 'awaiting-user',
10
+ 'completed',
11
+ 'idle',
12
+ 'error',
13
+ 'quota',
14
+ ];
15
+ export const WORKSPACE_GROUP_MESSAGE_TOOLS = [
16
+ {
17
+ name: 'preview_workspace_group_message',
18
+ description: 'Preview eligible non-archived, non-purged workspaces before sending a group message. Tags match ANY selected tag, statuses match ANY selected status, and all enabled filters intersect. Set dev_server_running to true to include only workspaces with a running development server. Empty filters select all eligible workspaces. Paginate with offset and limit (maximum 200); results sort by workspace ID. Delivery is immediate for manual workspaces and next_iteration for auto-loop. This is a live preview, not a reservation; explicitly select recipient IDs for send_workspace_group_message.',
19
+ inputSchema: {
20
+ type: 'object',
21
+ properties: {
22
+ tags: {
23
+ type: 'array',
24
+ maxItems: 200,
25
+ uniqueItems: true,
26
+ items: { type: 'string', minLength: 1, maxLength: 200 },
27
+ },
28
+ statuses: {
29
+ type: 'array',
30
+ uniqueItems: true,
31
+ maxItems: statuses.length,
32
+ items: { type: 'string', enum: statuses },
33
+ },
34
+ dev_server_running: {
35
+ type: 'boolean',
36
+ default: false,
37
+ description: 'Only include workspaces whose development server status is running. Intersects other filters.',
38
+ },
39
+ limit: {
40
+ type: 'integer',
41
+ minimum: 1,
42
+ maximum: MAX_GROUP_MESSAGE_RECIPIENTS,
43
+ default: MAX_GROUP_MESSAGE_RECIPIENTS,
44
+ },
45
+ offset: { type: 'integer', minimum: 0, default: 0 },
46
+ },
47
+ additionalProperties: false,
48
+ },
49
+ annotations: { readOnlyHint: true, openWorldHint: false },
50
+ },
51
+ {
52
+ name: 'send_workspace_group_message',
53
+ description: 'Send one message to an explicit selection of 1–200 unique workspace IDs after previewing recipients. A workspace-bound agent cannot target its own workspace. Supply a unique request_id; retrying the same ID and identical payload observes the existing batch without resending. Different payloads with that ID fail. Returns a durable asynchronous batch receipt: use get_workspace_group_message to inspect per-recipient outcomes. Manual workspaces receive immediately; auto-loop workspaces queue for their next iteration. Ineligible targets are rejected individually. After interruption, not_sent and unknown outcomes are never automatically retried; inspect history before submitting a new request.',
54
+ inputSchema: {
55
+ type: 'object',
56
+ properties: {
57
+ request_id: identifier,
58
+ workspace_ids: {
59
+ type: 'array',
60
+ minItems: 1,
61
+ maxItems: MAX_GROUP_MESSAGE_RECIPIENTS,
62
+ uniqueItems: true,
63
+ items: identifier,
64
+ },
65
+ content: { type: 'string', minLength: 1, maxLength: MAX_GROUP_MESSAGE_LENGTH },
66
+ },
67
+ required: ['request_id', 'workspace_ids', 'content'],
68
+ additionalProperties: false,
69
+ },
70
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
71
+ },
72
+ {
73
+ name: 'get_workspace_group_message',
74
+ description: 'Read a persisted group-message receipt by request_id, including complete and each recipient state: pending, sending, sent, queued, rejected, unknown, or not_sent. This read never sends or retries messages. Queued means accepted for a future auto-loop iteration, not executed.',
75
+ inputSchema: {
76
+ type: 'object',
77
+ properties: { request_id: identifier },
78
+ required: ['request_id'],
79
+ additionalProperties: false,
80
+ },
81
+ annotations: { readOnlyHint: true, openWorldHint: false },
82
+ },
83
+ ];
84
+ export function isWorkspaceGroupMessageTool(name) {
85
+ return WORKSPACE_GROUP_MESSAGE_TOOLS.some((tool) => tool.name === name);
86
+ }
87
+ export function validateWorkspaceGroupMessageArguments(name, raw) {
88
+ const tool = WORKSPACE_GROUP_MESSAGE_TOOLS.find((item) => item.name === name);
89
+ if (!tool)
90
+ throw new Error(`Unknown group message tool: ${name}`);
91
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
92
+ throw new Error('Arguments must be an object');
93
+ const input = raw;
94
+ for (const key of Object.keys(input))
95
+ if (!Object.hasOwn(tool.inputSchema.properties, key))
96
+ throw new Error(`Unknown field: ${key}`);
97
+ for (const key of tool.inputSchema.required ?? [])
98
+ if (!Object.hasOwn(input, key))
99
+ throw new Error(`${key} is required`);
100
+ if (name === 'send_workspace_group_message') {
101
+ parseGroupMessageInput({ requestId: input.request_id, workspaceIds: input.workspace_ids, content: input.content });
102
+ }
103
+ else if (name === 'get_workspace_group_message') {
104
+ if (typeof input.request_id !== 'string' || !/^[A-Za-z0-9_-]{1,200}$/.test(input.request_id))
105
+ throw new Error('Invalid request_id');
106
+ }
107
+ else {
108
+ if (input.dev_server_running !== undefined && typeof input.dev_server_running !== 'boolean')
109
+ throw new Error('Invalid dev_server_running');
110
+ for (const key of ['tags', 'statuses']) {
111
+ const value = input[key];
112
+ if (value === undefined)
113
+ continue;
114
+ if (!Array.isArray(value) ||
115
+ value.length > (key === 'tags' ? 200 : statuses.length) ||
116
+ new Set(value).size !== value.length ||
117
+ value.some((item) => typeof item !== 'string' ||
118
+ !item.trim() ||
119
+ item.length > 200 ||
120
+ (key === 'statuses' && !statuses.includes(item))))
121
+ throw new Error(`Invalid ${key}`);
122
+ }
123
+ if (input.limit !== undefined &&
124
+ (typeof input.limit !== 'number' ||
125
+ !Number.isSafeInteger(input.limit) ||
126
+ input.limit < 1 ||
127
+ input.limit > MAX_GROUP_MESSAGE_RECIPIENTS))
128
+ throw new Error('Invalid limit');
129
+ if (input.offset !== undefined &&
130
+ (typeof input.offset !== 'number' || !Number.isSafeInteger(input.offset) || input.offset < 0))
131
+ throw new Error('Invalid offset');
132
+ }
133
+ return input;
134
+ }
@@ -0,0 +1,27 @@
1
+ export const MAX_GROUP_MESSAGE_RECIPIENTS = 200;
2
+ export const MAX_GROUP_MESSAGE_LENGTH = 100_000;
3
+ export const MAX_GROUP_MESSAGE_REQUEST_BYTES = 1024 * 1024;
4
+ const identifier = /^[A-Za-z0-9_-]{1,200}$/;
5
+ export function parseGroupMessageInput(value) {
6
+ if (!value || typeof value !== 'object' || Array.isArray(value))
7
+ throw new Error('Invalid group message');
8
+ const input = value;
9
+ if (Object.keys(input).some((key) => !['requestId', 'workspaceIds', 'content'].includes(key)))
10
+ throw new Error('Unknown group message field');
11
+ if (typeof input.requestId !== 'string' || !identifier.test(input.requestId))
12
+ throw new Error('Invalid requestId');
13
+ if (!Array.isArray(input.workspaceIds) ||
14
+ input.workspaceIds.length === 0 ||
15
+ input.workspaceIds.length > MAX_GROUP_MESSAGE_RECIPIENTS ||
16
+ input.workspaceIds.some((id) => typeof id !== 'string' || !identifier.test(id)) ||
17
+ new Set(input.workspaceIds).size !== input.workspaceIds.length)
18
+ throw new Error('Select 1 to 200 unique workspace IDs');
19
+ if (typeof input.content !== 'string' || !input.content.trim() || input.content.length > MAX_GROUP_MESSAGE_LENGTH)
20
+ throw new Error('Message must contain 1 to 100000 characters');
21
+ return { requestId: input.requestId, workspaceIds: [...input.workspaceIds], content: input.content };
22
+ }
23
+ export function matchesGroupMessageFilters(workspace, filters) {
24
+ return ((!filters.tags?.length || filters.tags.some((tag) => workspace.tags.includes(tag))) &&
25
+ (!filters.statuses?.length || filters.statuses.includes(workspace.status)) &&
26
+ (!filters.devServerRunning || workspace.devServerStatus === 'running'));
27
+ }
@@ -0,0 +1,112 @@
1
+ const workspaceId = {
2
+ type: 'string',
3
+ minLength: 1,
4
+ maxLength: 200,
5
+ pattern: '^[A-Za-z0-9_-]+$',
6
+ description: 'Exact workspace ID from list_workspaces. The reserved bulk endpoint name archived is forbidden.',
7
+ };
8
+ const branch = {
9
+ type: 'string',
10
+ minLength: 1,
11
+ maxLength: 1024,
12
+ description: 'Current exact working branch name, from get_workspace. Required for every deletion, like the UI confirmation.',
13
+ };
14
+ export const WORKSPACE_LIFECYCLE_TOOLS = [
15
+ {
16
+ name: 'archive_workspace',
17
+ description: 'Archive a Kōbō workspace using the same lifecycle as the UI. Stops its agent, development server and terminal, disables auto-loop, and hides the workspace while retaining its checkout and conversation history. May run the configured archive script.',
18
+ inputSchema: {
19
+ type: 'object',
20
+ properties: { workspace_id: workspaceId },
21
+ required: ['workspace_id'],
22
+ additionalProperties: false,
23
+ },
24
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
25
+ },
26
+ {
27
+ name: 'purge_workspace_worktree',
28
+ description: 'Free disk space by removing a Kōbō-owned workspace checkout, stopping its processes and archiving it, while preserving conversation history and workspace metadata. Uncommitted files in the checkout can be lost. External attached worktrees are protected. Set confirm_purge=true to explicitly request removal. Inspect the returned outcome and warnings; removal-failed is not success.',
29
+ inputSchema: {
30
+ type: 'object',
31
+ properties: { workspace_id: workspaceId, confirm_purge: { type: 'boolean', const: true } },
32
+ required: ['workspace_id', 'confirm_purge'],
33
+ additionalProperties: false,
34
+ },
35
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
36
+ },
37
+ {
38
+ name: 'delete_workspace',
39
+ description: 'Permanently delete one Kōbō workspace, its tasks and conversation history and its owned checkout, using the UI lifecycle. Stops processes first; external attached worktrees are preserved. Requires confirm_delete=true and confirmation_branch matching the current working branch. Local and remote branch deletion default to false and require explicit flags; remote deletion also requires delete_local_branch=true. Cleanup warnings are returned and must not be treated as complete cleanup. Never deletes all archived workspaces.',
40
+ inputSchema: {
41
+ type: 'object',
42
+ properties: {
43
+ workspace_id: workspaceId,
44
+ confirm_delete: { type: 'boolean', const: true },
45
+ confirmation_branch: branch,
46
+ delete_local_branch: { type: 'boolean', default: false },
47
+ delete_remote_branch: { type: 'boolean', default: false },
48
+ },
49
+ required: ['workspace_id', 'confirm_delete', 'confirmation_branch'],
50
+ additionalProperties: false,
51
+ },
52
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
53
+ },
54
+ {
55
+ name: 'unarchive_workspace',
56
+ description: 'Unarchive a Kōbō workspace whose checkout is still available, preserving its prior status and conversation history. Does not start agents or setup scripts. A purged checkout is refused: use restore_workspace first.',
57
+ inputSchema: {
58
+ type: 'object',
59
+ properties: { workspace_id: workspaceId },
60
+ required: ['workspace_id'],
61
+ additionalProperties: false,
62
+ },
63
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
64
+ },
65
+ {
66
+ name: 'restore_workspace',
67
+ description: 'Restore an archived workspace, recreating its Kōbō-owned checkout when marked purged, then unarchiving it. Uses saved Git recovery metadata, preserves history and never starts agents or setup scripts. An already active unpurged workspace is unchanged. Missing checkout without purge metadata is refused (not-purged); a workspace permanently deleted from the database cannot be restored. External worktrees are only unarchived, never recreated. Return includes workspace and outcome, plus checkout source when restoration was needed.',
68
+ inputSchema: {
69
+ type: 'object',
70
+ properties: { workspace_id: workspaceId },
71
+ required: ['workspace_id'],
72
+ additionalProperties: false,
73
+ },
74
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
75
+ },
76
+ ];
77
+ export function isWorkspaceLifecycleTool(name) {
78
+ return WORKSPACE_LIFECYCLE_TOOLS.some((tool) => tool.name === name);
79
+ }
80
+ /** Reject ambiguous or bulk targets before any dispatch, including a read. */
81
+ export function validateWorkspaceLifecycleArguments(name, raw) {
82
+ const tool = WORKSPACE_LIFECYCLE_TOOLS.find((definition) => definition.name === name);
83
+ if (!tool)
84
+ throw new Error(`Unknown workspace lifecycle tool: ${name}`);
85
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
86
+ throw new Error('Tool arguments must be an object');
87
+ const input = raw;
88
+ const properties = tool.inputSchema.properties;
89
+ for (const required of tool.inputSchema.required ?? [])
90
+ if (!Object.hasOwn(input, required))
91
+ throw new Error(`${required} is required`);
92
+ for (const [key, value] of Object.entries(input)) {
93
+ if (!Object.hasOwn(properties, key))
94
+ throw new Error(`Unknown field: ${key}`);
95
+ const definition = properties[key];
96
+ if (definition.type === 'boolean') {
97
+ if (typeof value !== 'boolean' || (definition.const !== undefined && value !== definition.const))
98
+ throw new Error(`${key} must be ${definition.const === true ? 'true' : 'a boolean'}`);
99
+ }
100
+ else if (typeof value !== 'string' ||
101
+ !value.trim() ||
102
+ value.length > definition.maxLength ||
103
+ /[\0\r\n]/.test(value))
104
+ throw new Error(`Invalid ${key}`);
105
+ }
106
+ const id = input.workspace_id;
107
+ if (!/^[A-Za-z0-9_-]+$/.test(id) || id === 'archived')
108
+ throw new Error('Invalid workspace_id: use a single workspace ID, never a path or bulk endpoint');
109
+ if (input.delete_remote_branch === true && input.delete_local_branch !== true)
110
+ throw new Error('delete_remote_branch requires delete_local_branch=true');
111
+ return input;
112
+ }
@@ -0,0 +1,60 @@
1
+ # Fresh, independent Kōbō instance behind an EXISTING Traefik Docker provider.
2
+ # Setup, authentication and upgrades: docs/deployment-vps.md
3
+ # Copy to your deployment directory as compose.yaml and create its .env first.
4
+ name: kobo
5
+
6
+ services:
7
+ kobo:
8
+ # Build this initial image locally. kobo-update later pins a unique release tag.
9
+ image: kobo:local
10
+ init: true
11
+ restart: unless-stopped
12
+ # Use the host's SSH and `docker compose exec`; no container SSH daemon.
13
+ entrypoint: ["node"]
14
+ command: ["dist/server/index.js"]
15
+ environment:
16
+ NODE_ENV: production
17
+ SERVER_PORT: "3000"
18
+ KOBO_HOME: /root/.config/kobo
19
+ KOBO_NETWORK_ACCESS_ENABLED: "true"
20
+ KOBO_NETWORK_ACCESS_BEHIND_PROXY: "true"
21
+ KOBO_NETWORK_ACCESS_PROXY_HOST: ${KOBO_DOMAIN:?Set KOBO_DOMAIN to the public hostname}
22
+ volumes:
23
+ # Includes Kōbō, Claude/Codex state and Git configuration; no PC auth mounts.
24
+ - kobo-home:/root
25
+ # Same ABSOLUTE path in both namespaces for project Docker bind mounts.
26
+ # Create the host directory first; fail rather than silently making one.
27
+ - type: bind
28
+ source: ${KOBO_PROJECTS_DIR:?Set KOBO_PROJECTS_DIR to an existing absolute path}
29
+ target: ${KOBO_PROJECTS_DIR:?Set KOBO_PROJECTS_DIR to an existing absolute path}
30
+ bind:
31
+ create_host_path: false
32
+ # Optional: enables project Docker commands, with root-equivalent host access.
33
+ # - /var/run/docker.sock:/var/run/docker.sock
34
+ networks:
35
+ - proxy
36
+ labels:
37
+ traefik.enable: "true"
38
+ traefik.docker.network: ${TRAEFIK_NETWORK:-web}
39
+ traefik.http.routers.kobo.rule: Host(`${KOBO_DOMAIN:?Set KOBO_DOMAIN to the public hostname}`)
40
+ traefik.http.routers.kobo.entrypoints: ${TRAEFIK_ENTRYPOINT:-websecure}
41
+ traefik.http.routers.kobo.tls: "true"
42
+ traefik.http.routers.kobo.tls.certresolver: ${TRAEFIK_CERTRESOLVER:-letsencrypt}
43
+ traefik.http.services.kobo.loadbalancer.server.port: "3000"
44
+ # No published HTTP or SSH ports. Traefik handles HTTP and WebSockets.
45
+ stop_grace_period: 60s
46
+ healthcheck:
47
+ start_period: 60s
48
+ logging:
49
+ driver: json-file
50
+ options:
51
+ max-size: "20m"
52
+ max-file: "3"
53
+
54
+ networks:
55
+ proxy:
56
+ external: true
57
+ name: ${TRAEFIK_NETWORK:-web}
58
+
59
+ volumes:
60
+ kobo-home: