@loicngr/kobo 1.11.39 → 1.11.40

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 (145) hide show
  1. package/AGENTS.md +12 -0
  2. package/CHANGELOG.md +4 -0
  3. package/dist/mcp-server/kobo-tasks-handlers.js +16 -9
  4. package/dist/mcp-server/kobo-tasks-server.js +13 -2
  5. package/dist/mcp-server/workspace-dialogue-client.js +28 -0
  6. package/dist/server/db/mcp-message-schema.js +16 -0
  7. package/dist/server/db/migrations.js +4 -0
  8. package/dist/server/db/schema.js +4 -0
  9. package/dist/server/db/search-schema.js +53 -0
  10. package/dist/server/index.js +17 -86
  11. package/dist/server/middleware/network-auth-middleware.js +5 -1
  12. package/dist/server/routes/changelog.js +4 -49
  13. package/dist/server/routes/git.js +2 -2
  14. package/dist/server/routes/mcp.js +47 -0
  15. package/dist/server/routes/pull-requests.js +1 -1
  16. package/dist/server/routes/search.js +13 -4
  17. package/dist/server/routes/settings.js +5 -0
  18. package/dist/server/routes/workspaces.js +225 -241
  19. package/dist/server/services/agent/engines/claude-code/engine.js +31 -33
  20. package/dist/server/services/agent/engines/codex/engine.js +37 -8
  21. package/dist/server/services/agent/engines/codex/event-mapper.js +3 -1
  22. package/dist/server/services/agent/engines/codex/options-builder.js +4 -3
  23. package/dist/server/services/agent/engines/codex/server-requests.js +8 -1
  24. package/dist/server/services/agent/orchestrator.js +20 -3
  25. package/dist/server/services/change-source-branch-service.js +10 -42
  26. package/dist/server/services/chat-receipt-service.js +33 -0
  27. package/dist/server/services/cron-service.js +27 -1
  28. package/dist/server/services/mcp-client-context.js +12 -0
  29. package/dist/server/services/mcp-connection-info-service.js +63 -0
  30. package/dist/server/services/mcp-message-delivery-service.js +59 -0
  31. package/dist/server/services/mcp-message-request-service.js +64 -0
  32. package/dist/server/services/pr-checkout-service.js +10 -10
  33. package/dist/server/services/pr-watcher-service.js +14 -9
  34. package/dist/server/services/search/indexer.js +232 -0
  35. package/dist/server/services/search/worker.js +76 -0
  36. package/dist/server/services/search-service.js +104 -110
  37. package/dist/server/services/update-check-service.js +119 -0
  38. package/dist/server/services/websocket-service.js +121 -35
  39. package/dist/server/services/workspace-dialogue-service.js +128 -0
  40. package/dist/server/services/workspace-message-service.js +58 -0
  41. package/dist/server/services/workspace-permission-policy-service.js +33 -3
  42. package/dist/server/services/worktree-restore-service.js +2 -2
  43. package/dist/server/services/worktree-service.js +197 -124
  44. package/dist/server/utils/bounded-process.js +137 -0
  45. package/dist/server/utils/git-ops.js +76 -128
  46. package/dist/server/utils/git-repo-lock.js +27 -13
  47. package/dist/shared/workspace-dialogue-tools.js +89 -0
  48. package/dist/shared/workspace-message-types.js +19 -0
  49. package/package.json +2 -1
  50. package/src/client/dist/pwa/assets/{ActivityFeed-Chsw1Mh7.css → ActivityFeed-BYR5x7aa.css} +1 -1
  51. package/src/client/dist/pwa/assets/ActivityFeed-MqsC-95i.js +8 -0
  52. package/src/client/dist/pwa/assets/{ChangelogPage-D8563008.js → ChangelogPage-BxURWYxa.js} +1 -1
  53. package/src/client/dist/pwa/assets/{ComparisonPanel-Bd0X08sg.js → ComparisonPanel-DjiMDAUi.js} +2 -2
  54. package/src/client/dist/pwa/assets/CreatePage-B-rhBmqv.js +2 -0
  55. package/src/client/dist/pwa/assets/DashboardPage-mA5RZf1t.js +1 -0
  56. package/src/client/dist/pwa/assets/DiffViewer-Ci7ucmy3.js +8 -0
  57. package/src/client/dist/pwa/assets/{DiffViewer-DnR79ycb.css → DiffViewer-DEPOa3D7.css} +1 -1
  58. package/src/client/dist/pwa/assets/{DocumentsPanel-BJsJ0fNs.js → DocumentsPanel-RZTvcJkT.js} +1 -1
  59. package/src/client/dist/pwa/assets/{DrawerToggleButton-BVLO5K5d.js → DrawerToggleButton-CTOc3yA0.js} +1 -1
  60. package/src/client/dist/pwa/assets/GitPanel-_kL2gMoy.js +3 -0
  61. package/src/client/dist/pwa/assets/{HealthPage-RFZS7Lao.js → HealthPage-D3__hKJ5.js} +1 -1
  62. package/src/client/dist/pwa/assets/MainLayout-B3JKUm3n.js +2 -0
  63. package/src/client/dist/pwa/assets/{QBtnDropdown-X8oNU5Mb.js → QBtnDropdown-td0RAVPx.js} +1 -1
  64. package/src/client/dist/pwa/assets/{QDrawer-Cij17skX.js → QDrawer-BkyFPV7a.js} +1 -1
  65. package/src/client/dist/pwa/assets/QSelect-WspK3KC7.js +1 -0
  66. package/src/client/dist/pwa/assets/{QTooltip-CwcCGSWq.js → QTooltip-7pw7iuV0.js} +1 -1
  67. package/src/client/dist/pwa/assets/SchedulePanel-BAA4Aajn.css +1 -0
  68. package/src/client/dist/pwa/assets/SchedulePanel-CLNM13oo.js +1 -0
  69. package/src/client/dist/pwa/assets/SearchPage-BGc1oQSM.css +1 -0
  70. package/src/client/dist/pwa/assets/SearchPage-CmRgIvu4.js +1 -0
  71. package/src/client/dist/pwa/assets/SettingsPage-Cj6Y_TlN.js +9 -0
  72. package/src/client/dist/pwa/assets/SettingsPage-sVlsJQ0b.css +1 -0
  73. package/src/client/dist/pwa/assets/{SplitWorkspacePage-D7lGFRKs.js → SplitWorkspacePage-CCHGEsKf.js} +1 -1
  74. package/src/client/dist/pwa/assets/{TerminalPanel-DI8DHb9e.js → TerminalPanel-ijmsYk-2.js} +9 -9
  75. package/src/client/dist/pwa/assets/{TouchPan-B3nrkCY8.js → TouchPan-DuQFjlQF.js} +1 -1
  76. package/src/client/dist/pwa/assets/{TourReplayButton-DU1UShMY.js → TourReplayButton-C5RF0lOK.js} +1 -1
  77. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-C-yN5IX8.js → WorkspaceAttentionLabels-BR_XElog.js} +1 -1
  78. package/src/client/dist/pwa/assets/WorkspacePage-BGCildyP.js +4 -0
  79. package/src/client/dist/pwa/assets/{WorkspacePage-BAC6u9OP.css → WorkspacePage-CqcMweNL.css} +1 -1
  80. package/src/client/dist/pwa/assets/{browser-f9MHQjfi.js → browser-DUqzdkbg.js} +1 -1
  81. package/src/client/dist/pwa/assets/{build-path-tree-BWBzP8os.js → build-path-tree-ImiSpzfI.js} +1 -1
  82. package/src/client/dist/pwa/assets/de-CaHID9mW.js +17 -0
  83. package/src/client/dist/pwa/assets/{documents-C2cLnEBQ.js → documents-2C1hn1pw.js} +1 -1
  84. package/src/client/dist/pwa/assets/{editor.api-sw146MWN.js → editor.api-Bm1vVaBr.js} +1 -1
  85. package/src/client/dist/pwa/assets/{efforts-C5ZZgCH4.js → efforts-_D8DFYXo.js} +1 -1
  86. package/src/client/dist/pwa/assets/engineFeatures-CZ9qfBYq.js +1 -0
  87. package/src/client/dist/pwa/assets/es-ZhzlCmer.js +17 -0
  88. package/src/client/dist/pwa/assets/{expand-template-B6xAlAaM.js → expand-template-CbDNnTPP.js} +1 -1
  89. package/src/client/dist/pwa/assets/{format-BwWP6Aay.js → format-pPImdFUy.js} +1 -1
  90. package/src/client/dist/pwa/assets/{formatters-DyWtO-nR.js → formatters-dTX38ZsV.js} +1 -1
  91. package/src/client/dist/pwa/assets/fr-BfXw7L7J.js +17 -0
  92. package/src/client/dist/pwa/assets/{freemarker2-DZ5Hm8UH.js → freemarker2-DGluDnAm.js} +1 -1
  93. package/src/client/dist/pwa/assets/{handlebars-BRUGpTA7.js → handlebars-BgviTnpD.js} +1 -1
  94. package/src/client/dist/pwa/assets/{html-DNOayNNs.js → html-CV-MrunL.js} +1 -1
  95. package/src/client/dist/pwa/assets/i18n-CmXBenUp.js +1 -0
  96. package/src/client/dist/pwa/assets/index-9tiw3NjK.js +20 -0
  97. package/src/client/dist/pwa/assets/it-CHAurcTF.js +17 -0
  98. package/src/client/dist/pwa/assets/{javascript-C0tDQU3z.js → javascript-C2VFcHf2.js} +1 -1
  99. package/src/client/dist/pwa/assets/{liquid-CN46O6OF.js → liquid-Dg9ZhbP5.js} +1 -1
  100. package/src/client/dist/pwa/assets/{mdx-DM-lrr8Q.js → mdx-ImgrPYNl.js} +1 -1
  101. package/src/client/dist/pwa/assets/{monaco-contributions-CkN7y-8c.js → monaco-contributions-waWZXS1T.js} +1 -1
  102. package/src/client/dist/pwa/assets/{monaco.contribution-2BcAOLjc.js → monaco.contribution-BB2rtCHM.js} +2 -2
  103. package/src/client/dist/pwa/assets/network-auth-h6O0qCMA.js +1 -0
  104. package/src/client/dist/pwa/assets/{notify-theme-a91Tcr6o.js → notify-theme-Dyg-FGI6.js} +1 -1
  105. package/src/client/dist/pwa/assets/{pending-diff-open-DOtELZfB.js → pending-diff-open-Dfpg92li.js} +1 -1
  106. package/src/client/dist/pwa/assets/{permissionModes-C-9Crj-c.js → permissionModes-DDWmNBW1.js} +1 -1
  107. package/src/client/dist/pwa/assets/{project-color-BRe4oyNx.js → project-color-XUfY_zbw.js} +1 -1
  108. package/src/client/dist/pwa/assets/{python-BRpJG6sZ.js → python-DVLs-DBA.js} +1 -1
  109. package/src/client/dist/pwa/assets/{razor-CehCmvHl.js → razor-D34BqMwB.js} +1 -1
  110. package/src/client/dist/pwa/assets/{render-chat-markdown-DlbQLZ02.js → render-chat-markdown-DwUkPy-O.js} +1 -1
  111. package/src/client/dist/pwa/assets/{typescript-Dt_8mVuj.js → typescript-C7-UpNXx.js} +1 -1
  112. package/src/client/dist/pwa/assets/use-search-index-status-DtLzygcy.js +1 -0
  113. package/src/client/dist/pwa/assets/use-tours-DfE-kwrX.js +2 -0
  114. package/src/client/dist/pwa/assets/{workspace-templates-GCObcjO6.js → workspace-templates-DwwdOEAq.js} +1 -1
  115. package/src/client/dist/pwa/assets/{xml-_gOwdzB4.js → xml-Cx1oQhEr.js} +1 -1
  116. package/src/client/dist/pwa/assets/{yaml-BxeBqpGf.js → yaml-BQOiJsdx.js} +1 -1
  117. package/src/client/dist/pwa/index.html +1 -1
  118. package/src/client/dist/pwa/sw.js +1 -1
  119. package/src/mcp-server/README.md +118 -2
  120. package/src/mcp-server/kobo-tasks-handlers.ts +14 -11
  121. package/src/mcp-server/kobo-tasks-server.ts +19 -2
  122. package/src/mcp-server/workspace-dialogue-client.ts +34 -0
  123. package/src/client/dist/pwa/assets/ActivityFeed-BzzvcOmq.js +0 -8
  124. package/src/client/dist/pwa/assets/CreatePage-Bon8qIka.js +0 -2
  125. package/src/client/dist/pwa/assets/DashboardPage-DYxdxEul.js +0 -1
  126. package/src/client/dist/pwa/assets/DiffViewer-BpsLNxnj.js +0 -8
  127. package/src/client/dist/pwa/assets/GitPanel-Cal_wzC0.js +0 -3
  128. package/src/client/dist/pwa/assets/MainLayout-vvyiRcql.js +0 -2
  129. package/src/client/dist/pwa/assets/QSelect-xd-AlGo_.js +0 -1
  130. package/src/client/dist/pwa/assets/SchedulePanel-BunlzsOY.css +0 -1
  131. package/src/client/dist/pwa/assets/SchedulePanel-CXRUsz6G.js +0 -1
  132. package/src/client/dist/pwa/assets/SearchPage-CVgRa025.css +0 -1
  133. package/src/client/dist/pwa/assets/SearchPage-DopbgLxh.js +0 -1
  134. package/src/client/dist/pwa/assets/SettingsPage-Do9d0Pov.css +0 -1
  135. package/src/client/dist/pwa/assets/SettingsPage-Jx6TnOzR.js +0 -9
  136. package/src/client/dist/pwa/assets/WorkspacePage-DKRpdpPg.js +0 -4
  137. package/src/client/dist/pwa/assets/de-QkDLrcGq.js +0 -17
  138. package/src/client/dist/pwa/assets/engineFeatures-CAVMDawq.js +0 -1
  139. package/src/client/dist/pwa/assets/es-BgQFKlo8.js +0 -17
  140. package/src/client/dist/pwa/assets/fr-DAdLWjNM.js +0 -17
  141. package/src/client/dist/pwa/assets/i18n-CBez_vip.js +0 -1
  142. package/src/client/dist/pwa/assets/index-VL3fI9a3.js +0 -20
  143. package/src/client/dist/pwa/assets/it-Bumaewp1.js +0 -17
  144. package/src/client/dist/pwa/assets/network-auth-KYS4snQH.js +0 -1
  145. package/src/client/dist/pwa/assets/use-tours-CSVM_54K.js +0 -2
@@ -0,0 +1,137 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execFileAsync = promisify(execFile);
4
+ /** A zombie cannot mutate a checkout, even before its parent reaps it. */
5
+ async function groupIsLive(pid) {
6
+ try {
7
+ process.kill(-pid, 0);
8
+ }
9
+ catch (err) {
10
+ if (err.code === 'ESRCH')
11
+ return false;
12
+ throw err;
13
+ }
14
+ const { stdout } = await execFileAsync('ps', ['-eo', 'pgid=,stat='], { timeout: 1000, encoding: 'utf8' });
15
+ return stdout.split('\n').some((line) => {
16
+ const [group, state] = line.trim().split(/\s+/);
17
+ return Number(group) === pid && !!state && !/^[ZX]/.test(state);
18
+ });
19
+ }
20
+ /**
21
+ * Stop the entire group on timeout, and retain the caller's mutation ownership
22
+ * until exit is confirmed. Inspection failures never authorize another mutation.
23
+ */
24
+ export function runBoundedProcess(command, args, options) {
25
+ return new Promise((resolve, reject) => {
26
+ const child = spawn(command, args, {
27
+ cwd: options.cwd,
28
+ env: options.env,
29
+ detached: true,
30
+ stdio: ['ignore', 'pipe', 'pipe'],
31
+ });
32
+ const stdout = [];
33
+ let stdoutBytes = 0;
34
+ let stderr = Buffer.alloc(0);
35
+ let failure;
36
+ let closed = false;
37
+ let settled = false;
38
+ let code = null;
39
+ let signal = null;
40
+ let killTimer;
41
+ let pollTimer;
42
+ let checking = false;
43
+ let warned = false;
44
+ let escalatedAt;
45
+ const finish = () => {
46
+ if (settled)
47
+ return;
48
+ settled = true;
49
+ clearTimeout(deadline);
50
+ clearTimeout(killTimer);
51
+ clearTimeout(pollTimer);
52
+ if (failure)
53
+ reject(failure);
54
+ else if (code === 0)
55
+ resolve(Buffer.concat(stdout, stdoutBytes).toString('utf8'));
56
+ else
57
+ reject(new Error(`${command} failed: ${stderr.toString('utf8').trim() || `exit code ${code ?? signal ?? 'unknown'}`}`));
58
+ };
59
+ const confirm = async () => {
60
+ if (settled || checking)
61
+ return;
62
+ checking = true;
63
+ try {
64
+ if (closed && (!child.pid || !(await groupIsLive(child.pid)))) {
65
+ finish();
66
+ return;
67
+ }
68
+ if (escalatedAt !== undefined && Date.now() - escalatedAt >= 5000 && !warned) {
69
+ warned = true;
70
+ console.error(`[process] ${command} has not exited after SIGKILL; retaining mutation ownership until exit is confirmed`);
71
+ }
72
+ }
73
+ catch (err) {
74
+ if (!warned) {
75
+ warned = true;
76
+ console.error(`[process] Cannot confirm ${command} process-group exit; retaining mutation ownership:`, err);
77
+ }
78
+ }
79
+ finally {
80
+ checking = false;
81
+ }
82
+ if (!settled)
83
+ pollTimer = setTimeout(() => void confirm(), 250);
84
+ };
85
+ const kill = (requestedSignal) => {
86
+ try {
87
+ if (child.pid)
88
+ process.kill(-child.pid, requestedSignal);
89
+ }
90
+ catch (err) {
91
+ if (err.code !== 'ESRCH')
92
+ console.error(`[process] ${requestedSignal} failed for ${command}:`, err);
93
+ }
94
+ };
95
+ const stop = (error) => {
96
+ if (failure || settled)
97
+ return;
98
+ failure = error;
99
+ kill('SIGTERM');
100
+ killTimer = setTimeout(() => {
101
+ kill('SIGKILL');
102
+ escalatedAt = Date.now();
103
+ void confirm();
104
+ }, options.graceMs ?? 5000);
105
+ void confirm();
106
+ };
107
+ const deadline = setTimeout(() => stop(new Error(`${command} timed out after ${options.timeoutMs}ms`)), options.timeoutMs);
108
+ child.stdout?.on('data', (chunk) => {
109
+ if (options.stdoutLimit === undefined)
110
+ return;
111
+ if (stdoutBytes + chunk.length > options.stdoutLimit) {
112
+ stop(new Error(`${command} output exceeded ${options.stdoutLimit} bytes`));
113
+ return;
114
+ }
115
+ stdout.push(chunk);
116
+ stdoutBytes += chunk.length;
117
+ });
118
+ child.stderr?.on('data', (chunk) => {
119
+ const limit = options.stderrLimit ?? 8192;
120
+ stderr = Buffer.concat([stderr, chunk]).subarray(-limit);
121
+ });
122
+ child.once('error', (err) => {
123
+ if (!child.pid) {
124
+ failure = new Error(`${command} failed to spawn: ${err.message}`);
125
+ finish();
126
+ }
127
+ else
128
+ stop(err);
129
+ });
130
+ child.once('close', (exitCode, exitSignal) => {
131
+ closed = true;
132
+ code = exitCode;
133
+ signal = exitSignal;
134
+ void confirm();
135
+ });
136
+ });
137
+ }
@@ -1,6 +1,6 @@
1
1
  import { execFile as execFileCb, execFileSync } from 'node:child_process';
2
2
  import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
- import { join } from 'node:path';
3
+ import { join, relative, resolve } from 'node:path';
4
4
  import { promisify } from 'node:util';
5
5
  import { resolvePathInside } from './safe-path.js';
6
6
  const execFileAsync = promisify(execFileCb);
@@ -922,6 +922,43 @@ export function localBranchExists(repoPath, name) {
922
922
  export function moveWorktree(projectPath, oldPath, newPath) {
923
923
  git(projectPath, ['worktree', 'move', oldPath, newPath]);
924
924
  }
925
+ /** Git -z never quotes filenames; renames have a second source record. */
926
+ function parsePorcelain(output) {
927
+ const records = output.split('\0');
928
+ const entries = [];
929
+ for (let i = 0; i < records.length; i++) {
930
+ const record = records[i];
931
+ if (!record)
932
+ continue;
933
+ const x = record[0];
934
+ const y = record[1];
935
+ entries.push({ path: record.slice(3), x, y });
936
+ if (x === 'R' || x === 'C' || y === 'R' || y === 'C')
937
+ i++;
938
+ }
939
+ return entries;
940
+ }
941
+ function parseNameStatus(output) {
942
+ const records = output.split('\0');
943
+ const files = [];
944
+ for (let i = 0; i < records.length - 1;) {
945
+ const code = records[i++];
946
+ let filePath = records[i++];
947
+ if (code.startsWith('R') || code.startsWith('C'))
948
+ filePath = records[i++];
949
+ if (!filePath)
950
+ continue;
951
+ const status = code.startsWith('R')
952
+ ? 'renamed'
953
+ : code.startsWith('A')
954
+ ? 'added'
955
+ : code.startsWith('D')
956
+ ? 'deleted'
957
+ : 'modified';
958
+ files.push({ path: filePath, status });
959
+ }
960
+ return files;
961
+ }
925
962
  /** List files changed between base and HEAD (committed), plus working tree changes. */
926
963
  /**
927
964
  * List the worktree's files — tracked plus untracked-but-not-git-ignored.
@@ -931,10 +968,10 @@ export function moveWorktree(projectPath, oldPath, newPath) {
931
968
  */
932
969
  export function listWorktreeFiles(worktreePath, limit = 5000) {
933
970
  try {
934
- const out = git(worktreePath, ['ls-files', '--cached', '--others', '--exclude-standard']);
971
+ const out = git(worktreePath, ['ls-files', '-z', '--cached', '--others', '--exclude-standard']);
935
972
  if (!out)
936
973
  return [];
937
- const files = out.split('\n').filter((line) => line.length > 0);
974
+ const files = out.split('\0').filter((line) => line.length > 0);
938
975
  return files.length > limit ? files.slice(0, limit) : files;
939
976
  }
940
977
  catch {
@@ -947,7 +984,7 @@ export function listWorktreeFiles(worktreePath, limit = 5000) {
947
984
  */
948
985
  export function worktreeHasChanges(worktreePath) {
949
986
  try {
950
- return git(worktreePath, ['status', '--porcelain']).length > 0;
987
+ return git(worktreePath, ['status', '--porcelain', '-z']).length > 0;
951
988
  }
952
989
  catch {
953
990
  return false;
@@ -958,7 +995,7 @@ export function worktreeHasChanges(worktreePath) {
958
995
  * the worktree holds uncommitted work, "unknown" must never be read as
959
996
  * "nothing to lose". */
960
997
  export function worktreeHasChangesStrict(worktreePath) {
961
- return git(worktreePath, ['status', '--porcelain']).length > 0;
998
+ return git(worktreePath, ['status', '--porcelain', '-z']).length > 0;
962
999
  }
963
1000
  export function getChangedFiles(repoPath, base, includeUntracked = false) {
964
1001
  const ref = resolveBase(repoPath, base);
@@ -966,29 +1003,9 @@ export function getChangedFiles(repoPath, base, includeUntracked = false) {
966
1003
  const seen = new Set();
967
1004
  // Committed changes (base..HEAD)
968
1005
  try {
969
- const output = git(repoPath, ['diff', '--name-status', `${ref}...HEAD`]);
970
- for (const line of output.split('\n')) {
971
- if (!line)
972
- continue;
973
- const [statusCode, ...pathParts] = line.split('\t');
974
- if (!statusCode || pathParts.length === 0)
975
- continue;
976
- // For renames/copies (R100 old new), use the new path (last element)
977
- const filePath = (statusCode.startsWith('R') || statusCode.startsWith('C')
978
- ? pathParts[pathParts.length - 1]
979
- : pathParts[0])?.replace(/\/$/, '') ?? '';
980
- if (!filePath)
981
- continue;
982
- let status = 'modified';
983
- if (statusCode.startsWith('A'))
984
- status = 'added';
985
- else if (statusCode.startsWith('D'))
986
- status = 'deleted';
987
- else if (statusCode.startsWith('R'))
988
- status = 'renamed';
989
- files.push({ path: filePath, status });
990
- seen.add(filePath);
991
- }
1006
+ files.push(...parseNameStatus(git(repoPath, ['diff', '--name-status', '-z', `${ref}...HEAD`])));
1007
+ for (const file of files)
1008
+ seen.add(file.path);
992
1009
  }
993
1010
  catch {
994
1011
  // No commits yet
@@ -1000,23 +1017,21 @@ export function getChangedFiles(repoPath, base, includeUntracked = false) {
1000
1017
  // we use `-uall` and surface them with status='added'.
1001
1018
  try {
1002
1019
  const flag = includeUntracked ? '-uall' : '-uno';
1003
- const output = git(repoPath, ['status', '--porcelain', flag]);
1004
- for (const line of output.split('\n')) {
1005
- if (!line)
1020
+ const output = git(repoPath, ['status', '--porcelain', '-z', flag]);
1021
+ for (const { path: filePath, x, y } of parsePorcelain(output)) {
1022
+ if (seen.has(filePath))
1006
1023
  continue;
1007
- const filePath = line.substring(3).replace(/\/$/, '');
1008
- if (!filePath || seen.has(filePath))
1009
- continue;
1010
- const x = line[0];
1011
- const y = line[1];
1012
1024
  let status = 'modified';
1013
1025
  if (x === '?' && y === '?')
1014
1026
  status = 'untracked';
1027
+ else if (x === 'R' || y === 'R')
1028
+ status = 'renamed';
1015
1029
  else if (x === 'A' || y === 'A')
1016
1030
  status = 'added';
1017
1031
  else if (x === 'D' || y === 'D')
1018
1032
  status = 'deleted';
1019
1033
  files.push({ path: filePath, status });
1034
+ seen.add(filePath);
1020
1035
  }
1021
1036
  }
1022
1037
  catch {
@@ -1041,27 +1056,7 @@ export function getUnpushedChangedFiles(repoPath, branchName, remote = 'origin')
1041
1056
  }
1042
1057
  const files = [];
1043
1058
  try {
1044
- const output = git(repoPath, ['diff', '--name-status', `${remoteRef}..HEAD`]);
1045
- for (const line of output.split('\n')) {
1046
- if (!line)
1047
- continue;
1048
- const [statusCode, ...pathParts] = line.split('\t');
1049
- if (!statusCode || pathParts.length === 0)
1050
- continue;
1051
- const filePath = (statusCode.startsWith('R') || statusCode.startsWith('C')
1052
- ? pathParts[pathParts.length - 1]
1053
- : pathParts[0])?.replace(/\/$/, '') ?? '';
1054
- if (!filePath)
1055
- continue;
1056
- let status = 'modified';
1057
- if (statusCode.startsWith('A'))
1058
- status = 'added';
1059
- else if (statusCode.startsWith('D'))
1060
- status = 'deleted';
1061
- else if (statusCode.startsWith('R'))
1062
- status = 'renamed';
1063
- files.push({ path: filePath, status });
1064
- }
1059
+ files.push(...parseNameStatus(git(repoPath, ['diff', '--name-status', '-z', `${remoteRef}..HEAD`])));
1065
1060
  }
1066
1061
  catch {
1067
1062
  // Unlikely after the rev-parse check, but keep the happy path robust.
@@ -1113,27 +1108,7 @@ export function commitExists(repoPath, ref) {
1113
1108
  export function getChangedFilesBetween(repoPath, fromRef, toRef) {
1114
1109
  const files = [];
1115
1110
  try {
1116
- const output = git(repoPath, ['diff', '--name-status', `${fromRef}..${toRef}`]);
1117
- for (const line of output.split('\n')) {
1118
- if (!line)
1119
- continue;
1120
- const [statusCode, ...pathParts] = line.split('\t');
1121
- if (!statusCode || pathParts.length === 0)
1122
- continue;
1123
- const filePath = (statusCode.startsWith('R') || statusCode.startsWith('C')
1124
- ? pathParts[pathParts.length - 1]
1125
- : pathParts[0])?.replace(/\/$/, '') ?? '';
1126
- if (!filePath)
1127
- continue;
1128
- let status = 'modified';
1129
- if (statusCode.startsWith('A'))
1130
- status = 'added';
1131
- else if (statusCode.startsWith('D'))
1132
- status = 'deleted';
1133
- else if (statusCode.startsWith('R'))
1134
- status = 'renamed';
1135
- files.push({ path: filePath, status });
1136
- }
1111
+ files.push(...parseNameStatus(git(repoPath, ['diff', '--name-status', '-z', `${fromRef}..${toRef}`])));
1137
1112
  }
1138
1113
  catch {
1139
1114
  // invalid refs / no diff → empty list
@@ -1158,42 +1133,27 @@ export function getChangedFilesBetween(repoPath, fromRef, toRef) {
1158
1133
  * feedback in the UI.
1159
1134
  */
1160
1135
  export function rollbackFile(repoPath, branchName, filePath, remote = 'origin') {
1161
- const absPath = resolvePathInside(repoPath, filePath);
1136
+ resolvePathInside(repoPath, filePath);
1137
+ const absPath = resolve(repoPath, filePath);
1138
+ filePath = relative(resolve(repoPath), absPath);
1162
1139
  const remoteRef = `${remote}/${branchName}`;
1163
- let remoteRefExists = false;
1164
- try {
1165
- git(repoPath, ['rev-parse', '--verify', remoteRef]);
1166
- remoteRefExists = true;
1167
- }
1168
- catch {
1169
- // Branch never pushed — fall through to HEAD.
1170
- }
1171
- if (remoteRefExists) {
1172
- try {
1173
- git(repoPath, ['cat-file', '-e', `${remoteRef}:${filePath}`]);
1174
- git(repoPath, ['checkout', remoteRef, '--', filePath]);
1175
- return 'remote';
1176
- }
1177
- catch {
1178
- // File doesn't exist at origin/<branch> (added locally) — fall through.
1179
- }
1180
- }
1181
- try {
1182
- git(repoPath, ['cat-file', '-e', `HEAD:${filePath}`]);
1183
- git(repoPath, ['checkout', 'HEAD', '--', filePath]);
1140
+ // show-ref distinguishes an absent ref from repository/IO errors. Tree
1141
+ // inspection must succeed before absence authorizes deletion.
1142
+ const refs = git(repoPath, ['for-each-ref', '--format=%(refname)', `refs/remotes/${remoteRef}`]).split('\n');
1143
+ const fileExistsAt = (ref) => git(repoPath, ['ls-tree', '-r', '-z', '--name-only', ref, '--', `:(literal)${filePath}`])
1144
+ .split('\0')
1145
+ .includes(filePath);
1146
+ if (refs.includes(`refs/remotes/${remoteRef}`) && fileExistsAt(remoteRef)) {
1147
+ git(repoPath, ['checkout', remoteRef, '--', `:(literal)${filePath}`]);
1148
+ return 'remote';
1149
+ }
1150
+ if (fileExistsAt('HEAD')) {
1151
+ git(repoPath, ['checkout', 'HEAD', '--', `:(literal)${filePath}`]);
1184
1152
  return 'head';
1185
1153
  }
1186
- catch {
1187
- // File is untracked OR has already been rolled back — delete it from
1188
- // disk if still present. Idempotent: if the file is already gone (race
1189
- // with a previous rollback, stale UI list, manual rm), we still return
1190
- // 'deleted' since the end state matches the user's intent. `rmSync`
1191
- // over `git clean -f` keeps the action narrow to one file.
1192
- if (existsSync(absPath)) {
1193
- rmSync(absPath, { force: true });
1194
- }
1195
- return 'deleted';
1196
- }
1154
+ if (existsSync(absPath))
1155
+ rmSync(absPath, { force: true });
1156
+ return 'deleted';
1197
1157
  }
1198
1158
  /** @deprecated kept for backwards-compat with older imports — use `rollbackFile`. */
1199
1159
  export const rollbackFileToRemote = rollbackFile;
@@ -1213,7 +1173,7 @@ export function writeFileInWorktree(absPath, content) {
1213
1173
  /** Parse `git status --porcelain` into counts of staged, modified, and untracked files. */
1214
1174
  export function getWorkingTreeStatus(repoPath) {
1215
1175
  try {
1216
- return parseWorkingTreeStatus(git(repoPath, ['status', '--porcelain']));
1176
+ return parseWorkingTreeStatus(git(repoPath, ['status', '--porcelain', '-z']));
1217
1177
  }
1218
1178
  catch {
1219
1179
  return { staged: 0, modified: 0, untracked: 0 };
@@ -1223,11 +1183,7 @@ function parseWorkingTreeStatus(output) {
1223
1183
  let staged = 0;
1224
1184
  let modified = 0;
1225
1185
  let untracked = 0;
1226
- for (const line of output.split('\n')) {
1227
- if (!line)
1228
- continue;
1229
- const x = line[0];
1230
- const y = line[1];
1186
+ for (const { x, y } of parsePorcelain(output)) {
1231
1187
  if (x === '?' && y === '?') {
1232
1188
  untracked++;
1233
1189
  }
@@ -1247,17 +1203,9 @@ function parseWorkingTreeStatus(output) {
1247
1203
  */
1248
1204
  export function getWorkingTreeFiles(repoPath) {
1249
1205
  try {
1250
- const output = git(repoPath, ['status', '--porcelain']);
1206
+ const output = git(repoPath, ['status', '--porcelain', '-z']);
1251
1207
  const files = [];
1252
- for (const line of output.split('\n')) {
1253
- if (!line)
1254
- continue;
1255
- const x = line[0];
1256
- const y = line[1];
1257
- let filePath = line.slice(3);
1258
- const arrowIdx = filePath.indexOf(' -> ');
1259
- if (arrowIdx !== -1)
1260
- filePath = filePath.slice(arrowIdx + 4);
1208
+ for (const { path: filePath, x, y } of parsePorcelain(output)) {
1261
1209
  const untracked = x === '?' && y === '?';
1262
1210
  files.push({
1263
1211
  path: filePath,
@@ -1436,7 +1384,7 @@ export async function getStructuredDiffStatsBetweenAsync(repoPath, base, head) {
1436
1384
  /** Non-blocking working-tree summary for request and polling hot paths. */
1437
1385
  export async function getWorkingTreeStatusAsync(repoPath) {
1438
1386
  try {
1439
- return parseWorkingTreeStatus(await gitAsync(repoPath, ['status', '--porcelain']));
1387
+ return parseWorkingTreeStatus(await gitAsync(repoPath, ['status', '--porcelain', '-z']));
1440
1388
  }
1441
1389
  catch {
1442
1390
  return { staged: 0, modified: 0, untracked: 0 };
@@ -1,5 +1,6 @@
1
- import { execFileSync } from 'node:child_process';
1
+ import { execFile } from 'node:child_process';
2
2
  import path from 'node:path';
3
+ import { promisify } from 'node:util';
3
4
  /**
4
5
  * One promise chain per shared git directory. Same mechanism as the per-worktree
5
6
  * lock in `image-service.ts`, keyed differently: remote refs, packed-refs and
@@ -8,6 +9,10 @@ import path from 'node:path';
8
9
  * are disjoint.
9
10
  */
10
11
  const locks = new Map();
12
+ const execFileAsync = promisify(execFile);
13
+ // Register operations in invocation order even when common-dir lookups finish
14
+ // out of order. This queue covers resolution only, never the locked operation.
15
+ let registrations = Promise.resolve();
11
16
  /**
12
17
  * Absolute path of the git directory shared by every worktree of a repository.
13
18
  *
@@ -15,15 +20,16 @@ const locks = new Map();
15
20
  * repository, git missing): a wrong key only costs parallelism, never
16
21
  * correctness, whereas throwing here would break every caller.
17
22
  */
18
- export function resolveGitCommonDir(repoPath) {
23
+ export async function resolveGitCommonDir(repoPath) {
19
24
  try {
20
- const out = execFileSync('git', ['rev-parse', '--git-common-dir'], {
25
+ const { stdout } = await execFileAsync('git', ['rev-parse', '--git-common-dir'], {
21
26
  cwd: repoPath,
22
27
  encoding: 'utf-8',
23
- timeout: 15_000,
28
+ timeout: 10_000,
29
+ killSignal: 'SIGKILL',
24
30
  env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
25
- }).trim();
26
- return path.resolve(repoPath, out);
31
+ });
32
+ return path.resolve(repoPath, stdout.replace(/\n$/, ''));
27
33
  }
28
34
  catch {
29
35
  return path.resolve(repoPath);
@@ -31,15 +37,23 @@ export function resolveGitCommonDir(repoPath) {
31
37
  }
32
38
  /** Run `fn` with exclusive access to `repoPath`'s shared git directory. */
33
39
  export function withGitRepoLock(repoPath, fn) {
34
- const key = resolveGitCommonDir(repoPath);
35
- const previous = locks.get(key) ?? Promise.resolve();
36
- // The second argument to .then() means: even if the previous operation in the
37
- // queue rejected, still run fn — one failure must not block the whole queue.
38
- const next = previous.then(fn, fn);
39
- locks.set(key, next.then(() => { }, () => { }));
40
- return next;
40
+ let next;
41
+ const registered = registrations.then(async () => {
42
+ const key = await resolveGitCommonDir(repoPath);
43
+ const previous = locks.get(key) ?? Promise.resolve();
44
+ next = previous.then(fn, fn);
45
+ const settled = next.then(() => { }, () => { });
46
+ locks.set(key, settled);
47
+ void settled.then(() => {
48
+ if (locks.get(key) === settled)
49
+ locks.delete(key);
50
+ });
51
+ });
52
+ registrations = registered.catch(() => { });
53
+ return registered.then(() => next);
41
54
  }
42
55
  /** Test-only: drop every queued chain so tests don't inherit each other's state. */
43
56
  export function _resetGitRepoLocksForTest() {
44
57
  locks.clear();
58
+ registrations = Promise.resolve();
45
59
  }
@@ -0,0 +1,89 @@
1
+ const workspace = { type: 'string', minLength: 1, maxLength: 200, description: 'Workspace ID from list_workspaces.' };
2
+ const session = {
3
+ type: 'string',
4
+ minLength: 1,
5
+ maxLength: 200,
6
+ description: 'Session ID from list_workspace_sessions.',
7
+ };
8
+ function tool(name, description, properties, required, readOnly = true) {
9
+ return {
10
+ name,
11
+ description,
12
+ inputSchema: { type: 'object', properties, required, additionalProperties: false },
13
+ annotations: { readOnlyHint: readOnly, destructiveHint: !readOnly, openWorldHint: !readOnly },
14
+ };
15
+ }
16
+ export const WORKSPACE_DISCOVERY_TOOL = tool('list_workspaces', 'Discover Kōbō workspaces and their current status.', {
17
+ include_archived: { type: 'boolean' },
18
+ }, []);
19
+ export const WORKSPACE_DIALOGUE_TOOLS = [
20
+ tool('get_workspace', 'Read workspace metadata, tasks and current agent session.', { workspace_id: workspace }, [
21
+ 'workspace_id',
22
+ ]),
23
+ tool('list_workspace_sessions', 'List the sessions of a workspace to select a conversation.', { workspace_id: workspace }, ['workspace_id']),
24
+ tool('read_workspace_messages', 'Read a bounded chronological page of conversation fragments. Start without after_cursor; continue with nextCursor. Reuse the final cursor to poll for new replies. Concatenate assistant fragments with the same messageId and sessionId.', {
25
+ workspace_id: workspace,
26
+ session_id: session,
27
+ after_cursor: { type: 'string', minLength: 1, maxLength: 200 },
28
+ limit: { type: 'integer', minimum: 1, maximum: 200, description: 'Maximum source events scanned (default 100).' },
29
+ }, ['workspace_id']),
30
+ tool('send_workspace_message', 'Send a message to an agent, resuming it if needed. 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
+ workspace_id: workspace,
32
+ session_id: session,
33
+ content: { type: 'string', minLength: 1, maxLength: 100_000 },
34
+ idempotency_key: {
35
+ type: 'string',
36
+ minLength: 1,
37
+ 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.',
39
+ },
40
+ }, ['workspace_id', 'content'], false),
41
+ tool('get_workspace_questions', 'Read pending questions and whether a human tool approval is blocking the workspace.', { workspace_id: workspace }, ['workspace_id']),
42
+ 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
+ workspace_id: workspace,
44
+ tool_call_id: { type: 'string', minLength: 1, maxLength: 200 },
45
+ answers: { type: 'object', minProperties: 1, additionalProperties: { type: 'string' } },
46
+ }, ['workspace_id', 'tool_call_id', 'answers'], false),
47
+ ];
48
+ export const EXTERNAL_MCP_TOOLS = [WORKSPACE_DISCOVERY_TOOL, ...WORKSPACE_DIALOGUE_TOOLS];
49
+ /** The low-level MCP server checks the envelope; validate tool arguments here. */
50
+ export function validateDialogueArguments(name, value) {
51
+ const definition = EXTERNAL_MCP_TOOLS.find((entry) => entry.name === name);
52
+ if (!definition)
53
+ throw new Error(`Unknown tool '${name}'`);
54
+ if (!value || typeof value !== 'object' || Array.isArray(value))
55
+ throw new Error('Tool arguments must be an object');
56
+ const args = value;
57
+ const properties = definition.inputSchema.properties;
58
+ for (const key of definition.inputSchema.required ?? [])
59
+ if (!Object.hasOwn(args, key))
60
+ throw new Error(`${key} is required`);
61
+ for (const [key, input] of Object.entries(args)) {
62
+ const property = properties[key];
63
+ if (!Object.hasOwn(properties, key))
64
+ throw new Error(`Unknown argument '${key}'`);
65
+ if (property.type === 'string') {
66
+ if (typeof input !== 'string' || !input.trim() || input.length > (property.maxLength ?? 100_000))
67
+ throw new Error(`${key} must be a non-empty string within its length limit`);
68
+ }
69
+ else if (property.type === 'boolean') {
70
+ if (typeof input !== 'boolean')
71
+ throw new Error(`${key} must be a boolean`);
72
+ }
73
+ else if (property.type === 'integer') {
74
+ if (typeof input !== 'number' ||
75
+ !Number.isInteger(input) ||
76
+ input < property.minimum ||
77
+ input > property.maximum)
78
+ throw new Error(`${key} is outside its allowed integer range`);
79
+ }
80
+ else if (!input ||
81
+ typeof input !== 'object' ||
82
+ Array.isArray(input) ||
83
+ Object.keys(input).length === 0 ||
84
+ Object.values(input).some((answer) => typeof answer !== 'string' || answer.length > 100_000)) {
85
+ throw new Error(`${key} must be a non-empty object of string answers`);
86
+ }
87
+ }
88
+ return args;
89
+ }
@@ -0,0 +1,19 @@
1
+ export function normalizeClientName(value) {
2
+ return ((typeof value === 'string'
3
+ ? Array.from(value
4
+ .replace(/[\p{Cc}\s]+/gu, ' ')
5
+ .replace(/\p{Cs}/gu, '�')
6
+ .trim())
7
+ .slice(0, 100)
8
+ .join('')
9
+ : '') || 'External MCP client');
10
+ }
11
+ /** Tolerant decoder for historical/untrusted payloads. Labels are not identities. */
12
+ export function parseMessageSource(value) {
13
+ if (!value || typeof value !== 'object')
14
+ return undefined;
15
+ const source = value;
16
+ if (source.kind !== 'mcp' || (source.transport !== 'http' && source.transport !== 'stdio'))
17
+ return undefined;
18
+ return { kind: 'mcp', clientName: normalizeClientName(source.clientName), transport: source.transport };
19
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loicngr/kobo",
3
- "version": "1.11.39",
3
+ "version": "1.11.40",
4
4
  "description": "Kōbō — multi-workspace agent manager for Claude Code. Orchestrates isolated git worktrees with dev servers, Notion integration, and MCP tools.",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",
@@ -57,6 +57,7 @@
57
57
  "install-all": "npm install && cd src/client && npm install && cd src-pwa && npm install",
58
58
  "start": "node dist/server/index.js",
59
59
  "test": "vitest run --config vitest.config.ts --root .",
60
+ "test:mcp:live": "vitest run --config vitest.live.config.ts",
60
61
  "test:watch": "vitest --config vitest.config.ts --root .",
61
62
  "test:client": "cd src/client && npm test",
62
63
  "test:all": "npm test && npm run test:client",