@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,59 @@
1
+ import { getDb } from '../db/index.js';
2
+ import { finishMessageRequest, markMessageDispatching, reserveMessageRequest, } from './mcp-message-request-service.js';
3
+ import { broadcastPersistedEvent, persistWorkspaceEvent } from './websocket-service.js';
4
+ import { deliverWorkspaceMessage } from './workspace-message-service.js';
5
+ export async function deliverKeyedMcpMessage(workspaceId, key, message) {
6
+ const db = getDb();
7
+ const request = reserveMessageRequest(db, workspaceId, key, message.content, message.sessionId);
8
+ if (!request.fresh)
9
+ return request.result;
10
+ let dispatched = false;
11
+ let accepted;
12
+ try {
13
+ await deliverWorkspaceMessage(workspaceId, { ...message, clientMessageId: request.requestId }, {
14
+ beforeDispatch() {
15
+ // The normal closed-engine fallback may enter this boundary again
16
+ // while resuming the very same request, never a second reservation.
17
+ if (dispatched)
18
+ return;
19
+ markMessageDispatching(db, request.requestId);
20
+ dispatched = true;
21
+ },
22
+ persist(payload, sessionId) {
23
+ const committed = db.transaction(() => {
24
+ const event = persistWorkspaceEvent(workspaceId, 'user:message', payload, sessionId);
25
+ const result = { accepted: true, requestId: request.requestId, eventId: event.id, sessionId };
26
+ finishMessageRequest(db, request.requestId, 'accepted', result);
27
+ return { event, result };
28
+ })();
29
+ accepted = committed.result;
30
+ broadcastPersistedEvent(committed.event);
31
+ },
32
+ });
33
+ if (!accepted)
34
+ throw new Error('Delivery completed without a durable receipt');
35
+ return accepted;
36
+ }
37
+ catch (error) {
38
+ // A notification failure cannot invalidate an already committed receipt.
39
+ if (accepted)
40
+ return accepted;
41
+ const result = {
42
+ accepted: false,
43
+ requestId: request.requestId,
44
+ code: dispatched ? 'delivery_unknown' : 'delivery_rejected',
45
+ message: dispatched
46
+ ? 'Delivery may have occurred; inspect history before sending a new request'
47
+ : error instanceof Error
48
+ ? error.message
49
+ : String(error),
50
+ };
51
+ try {
52
+ finishMessageRequest(db, request.requestId, dispatched ? 'unknown' : 'rejected', result);
53
+ }
54
+ catch {
55
+ /* The reserved/dispatching row still prevents redelivery if storage failed. */
56
+ }
57
+ return result;
58
+ }
59
+ }
@@ -0,0 +1,64 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { nanoid } from 'nanoid';
3
+ export function reserveMessageRequest(db, workspaceId, key, content, sessionId) {
4
+ const fingerprint = createHash('sha256')
5
+ .update(JSON.stringify([content, sessionId ?? null]))
6
+ .digest('hex');
7
+ return db
8
+ .transaction(() => {
9
+ const requestId = `mcp-${nanoid()}`;
10
+ const now = new Date().toISOString();
11
+ const inserted = db
12
+ .prepare(`INSERT INTO mcp_message_requests(workspace_id,idempotency_key,request_id,fingerprint,state,created_at,updated_at)
13
+ VALUES (?,?,?,?,'reserved',?,?) ON CONFLICT(workspace_id,idempotency_key) DO NOTHING`)
14
+ .run(workspaceId, key, requestId, fingerprint, now, now);
15
+ if (inserted.changes)
16
+ return { fresh: true, requestId };
17
+ const row = db
18
+ .prepare('SELECT request_id,fingerprint,state,result_json FROM mcp_message_requests WHERE workspace_id=? AND idempotency_key=?')
19
+ .get(workspaceId, key);
20
+ let result;
21
+ if (row.fingerprint !== fingerprint)
22
+ result = {
23
+ accepted: false,
24
+ requestId: row.request_id,
25
+ code: 'idempotency_conflict',
26
+ message: 'This key was already used for different content or a different session',
27
+ };
28
+ else if (row.result_json) {
29
+ result = JSON.parse(row.result_json);
30
+ if (result.accepted)
31
+ result = { ...result, replayed: true };
32
+ }
33
+ else
34
+ result = {
35
+ accepted: false,
36
+ requestId: row.request_id,
37
+ code: row.state === 'unknown' ? 'delivery_unknown' : 'in_progress',
38
+ message: row.state === 'unknown'
39
+ ? 'Delivery may have occurred; inspect history before sending a new request'
40
+ : 'This request is still in progress; retry the same key to check its outcome',
41
+ };
42
+ return { fresh: false, requestId: row.request_id, result };
43
+ })
44
+ .immediate();
45
+ }
46
+ export function markMessageDispatching(db, requestId) {
47
+ const result = db
48
+ .prepare("UPDATE mcp_message_requests SET state='dispatching',updated_at=? WHERE request_id=? AND state='reserved'")
49
+ .run(new Date().toISOString(), requestId);
50
+ if (result.changes !== 1)
51
+ throw new Error('MCP request is no longer reserved');
52
+ }
53
+ export function finishMessageRequest(db, requestId, state, result) {
54
+ const changed = db
55
+ .prepare(`UPDATE mcp_message_requests SET state=?,result_json=?,event_id=?,session_id=?,updated_at=?
56
+ WHERE request_id=? AND state IN ('reserved','dispatching')`)
57
+ .run(state, JSON.stringify(result), result.accepted ? result.eventId : null, result.accepted ? (result.sessionId ?? null) : null, new Date().toISOString(), requestId);
58
+ if (changed.changes !== 1)
59
+ throw new Error('MCP request is no longer pending');
60
+ }
61
+ /** Backend startup only: never assume an interrupted engine delivery did not happen. */
62
+ export function reconcileMessageRequests(db) {
63
+ db.prepare("UPDATE mcp_message_requests SET state='unknown',updated_at=? WHERE state IN ('reserved','dispatching')").run(new Date().toISOString());
64
+ }
@@ -9,7 +9,7 @@ import { fingerprintWorktree } from '../utils/git-worktree-fingerprint.js';
9
9
  import { withWorkspaceLifecycleGuard } from '../utils/workspace-lifecycle-guard.js';
10
10
  import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
11
11
  import { hasController } from './agent/orchestrator.js';
12
- import { createWorktree, listWorktrees, removeWorktreeUnlocked } from './worktree-service.js';
12
+ import { createWorktreeUnlocked, listWorktrees, removeWorktreeUnlocked } from './worktree-service.js';
13
13
  /**
14
14
  * A `pathCollision.worktreePath` decision is untrusted input reaching
15
15
  * `git worktree add` directly — including from a network-access client that
@@ -81,9 +81,9 @@ function hasCommonAncestor(repoPath, branch) {
81
81
  }
82
82
  }
83
83
  /** Locate any worktree already sitting on `branch`, or at the target path. */
84
- function diagnoseWorktree(projectPath, branch, targetPath, attachedPaths) {
84
+ async function diagnoseWorktree(projectPath, branch, targetPath, attachedPaths) {
85
85
  const blockers = [];
86
- const worktrees = listWorktrees(projectPath);
86
+ const worktrees = await listWorktrees(projectPath);
87
87
  const onBranch = worktrees.find((wt) => wt.branch === branch);
88
88
  if (onBranch) {
89
89
  if (!fs.existsSync(onBranch.path)) {
@@ -163,7 +163,7 @@ export async function computeFingerprint(report) {
163
163
  * `attachedPaths` maps worktree path -> workspace id; the route supplies it so
164
164
  * this function stays free of database access and easy to test.
165
165
  */
166
- export function diagnoseLocalState(projectPath, headBranch, worktreesPath, attachedPaths = new Map(), workspaces = []) {
166
+ export async function diagnoseLocalState(projectPath, headBranch, worktreesPath, attachedPaths = new Map(), workspaces = []) {
167
167
  const targetWorktreePath = resolveWorkspaceWorktreePath(projectPath, headBranch, worktreesPath);
168
168
  const blockers = [];
169
169
  const lockPath = gitOps.getIndexLockPath(projectPath);
@@ -174,7 +174,7 @@ export function diagnoseLocalState(projectPath, headBranch, worktreesPath, attac
174
174
  !hasCommonAncestor(projectPath, headBranch)) {
175
175
  blockers.push({ kind: 'no-common-ancestor', branch: headBranch });
176
176
  }
177
- const { worktree, blockers: worktreeBlockers } = diagnoseWorktree(projectPath, headBranch, targetWorktreePath, attachedPaths);
177
+ const { worktree, blockers: worktreeBlockers } = await diagnoseWorktree(projectPath, headBranch, targetWorktreePath, attachedPaths);
178
178
  blockers.push(...worktreeBlockers);
179
179
  const inspectPath = worktree.state === 'orphan' || worktree.state === 'attached' ? worktree.path : null;
180
180
  return {
@@ -256,8 +256,8 @@ export class StaleDiagnosisError extends Error {
256
256
  * `change-source-branch-service`, so it cannot interleave with a worktree purge
257
257
  * or a source-branch change.
258
258
  */
259
- export function resolvePrCheckout(input) {
260
- const initial = diagnoseLocalState(input.projectPath, input.headBranch, input.worktreesPath, input.attachedPaths, input.workspaces);
259
+ export async function resolvePrCheckout(input) {
260
+ const initial = await diagnoseLocalState(input.projectPath, input.headBranch, input.worktreesPath, input.attachedPaths, input.workspaces);
261
261
  const owner = initial.worktree.state === 'attached'
262
262
  ? initial.worktree.workspaceId
263
263
  : initial.workspace.state !== 'none'
@@ -274,7 +274,7 @@ function resolvePrCheckoutLocked(input, owner) {
274
274
  // 1. Refresh, then refuse a plan built on facts that no longer hold.
275
275
  gitOps.fetchSourceBranch(input.projectPath, input.headBranch);
276
276
  applied.push({ kind: 'fetch', detail: `origin/${input.headBranch}` });
277
- const fresh = diagnoseLocalState(input.projectPath, input.headBranch, input.worktreesPath, input.attachedPaths ?? new Map(), input.workspaces ?? []);
277
+ const fresh = await diagnoseLocalState(input.projectPath, input.headBranch, input.worktreesPath, input.attachedPaths ?? new Map(), input.workspaces ?? []);
278
278
  if ((await computeFingerprint(fresh)) !== input.fingerprint)
279
279
  throw new StaleDiagnosisError(fresh);
280
280
  if ((fresh.worktree.state === 'orphan' || fresh.worktree.state === 'attached') &&
@@ -346,7 +346,7 @@ function resolvePrCheckoutLocked(input, owner) {
346
346
  const baseRef = gitOps.localBranchExists(input.projectPath, input.headBranch)
347
347
  ? input.headBranch
348
348
  : `origin/${input.headBranch}`;
349
- const target = createWorktree(input.projectPath, input.headBranch, baseRef, input.worktreesPath, undefined, explicitPath);
349
+ const target = await createWorktreeUnlocked(input.projectPath, input.headBranch, baseRef, input.worktreesPath, undefined, explicitPath);
350
350
  worktreePath = target.worktreePath;
351
351
  created = true;
352
352
  applied.push({ kind: 'create-worktree', detail: worktreePath });
@@ -361,7 +361,7 @@ function resolvePrCheckoutLocked(input, owner) {
361
361
  catch (err) {
362
362
  if (created) {
363
363
  try {
364
- removeWorktreeUnlocked(input.projectPath, worktreePath);
364
+ await removeWorktreeUnlocked(input.projectPath, worktreePath);
365
365
  }
366
366
  catch (cleanupErr) {
367
367
  console.error('[pr-checkout] rollback failed to remove the worktree:', cleanupErr);
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import { fetchSourceBranchAsync } from '../utils/git-ops.js';
3
3
  import { withGitRepoLock } from '../utils/git-repo-lock.js';
4
- import { isWorkspaceLifecycleBusy } from '../utils/workspace-lifecycle-guard.js';
4
+ import { isWorkspaceLifecycleBusy, withWorkspaceLifecycleGuard } from '../utils/workspace-lifecycle-guard.js';
5
5
  import { hasController } from './agent/orchestrator.js';
6
6
  import { stopDevServer } from './dev-server-service.js';
7
7
  import { getForgeProvider } from './forge/registry.js';
@@ -99,19 +99,24 @@ function markUnread(workspaceId) {
99
99
  console.error('[pr-watcher] markUnread failed:', err instanceof Error ? err.message : err);
100
100
  }
101
101
  }
102
- function autoRestoreManuallyRecreatedWorktrees() {
102
+ async function autoRestoreManuallyRecreatedWorktrees() {
103
103
  for (const ws of listArchivedWorkspaces()) {
104
104
  if (!ws.worktreePurgedAt || !ws.worktreeOwned || isWorkspaceLifecycleBusy(ws.id))
105
105
  continue;
106
106
  if (!fs.existsSync(ws.worktreePath))
107
107
  continue;
108
- if (!isMatchingWorkspaceWorktree(ws))
109
- continue;
110
108
  try {
111
- const restored = restoreWorktreeFromDisk(ws.id);
112
- invalidateWorkspacePrCaches(ws.id);
113
- emitEphemeral(ws.id, 'workspace:worktree-restored', { workspace: restored });
114
- console.log(`[pr-watcher] auto-restored worktree for workspace '${ws.name}' (manual restore detected)`);
109
+ await withWorkspaceLifecycleGuard(ws.id, async () => {
110
+ const current = getWorkspace(ws.id);
111
+ if (!current?.worktreePurgedAt || !current.worktreeOwned)
112
+ return;
113
+ if (!(await isMatchingWorkspaceWorktree(current)))
114
+ return;
115
+ const restored = restoreWorktreeFromDisk(ws.id);
116
+ invalidateWorkspacePrCaches(ws.id);
117
+ emitEphemeral(ws.id, 'workspace:worktree-restored', { workspace: restored });
118
+ console.log(`[pr-watcher] auto-restored worktree for workspace '${ws.name}' (manual restore detected)`);
119
+ });
115
120
  }
116
121
  catch (err) {
117
122
  console.error(`[pr-watcher] auto-restore failed for '${ws.name}':`, err instanceof Error ? err.message : err);
@@ -119,7 +124,7 @@ function autoRestoreManuallyRecreatedWorktrees() {
119
124
  }
120
125
  }
121
126
  export async function checkPrStatuses() {
122
- autoRestoreManuallyRecreatedWorktrees();
127
+ await autoRestoreManuallyRecreatedWorktrees();
123
128
  const workspaces = listWorkspaces(false); // non-archived only
124
129
  // Clean up entries for workspaces that no longer exist
125
130
  for (const id of lastKnownPr.keys()) {
@@ -0,0 +1,232 @@
1
+ export const normalizeSearchText = (text) => text.normalize('NFC').toLowerCase();
2
+ const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
3
+ function readable(row) {
4
+ let p;
5
+ try {
6
+ p = JSON.parse(row.payload);
7
+ }
8
+ catch {
9
+ return null;
10
+ }
11
+ if (!p || typeof p !== 'object')
12
+ return null;
13
+ let text;
14
+ let messageId = row.id;
15
+ if (row.type === 'user:message' && typeof p.content === 'string')
16
+ text = p.content;
17
+ if (row.type === 'agent:event' && p.kind === 'message:text' && typeof p.text === 'string') {
18
+ text = p.text;
19
+ if (typeof p.messageId === 'string')
20
+ messageId = p.messageId;
21
+ }
22
+ if (row.type === 'agent:output') {
23
+ const content = p.message?.content;
24
+ if (Array.isArray(content))
25
+ text = content.flatMap((b) => (b?.type === 'text' && typeof b.text === 'string' ? [b.text] : [])).join('\n');
26
+ }
27
+ return text === undefined
28
+ ? null
29
+ : { text, key: JSON.stringify([row.workspace_id, row.session_id, row.type, messageId]) };
30
+ }
31
+ /** Map lowercased UTF-16 offsets back to display text (e.g. İ expands to i + dot). */
32
+ function snippet(text, match, needleLength) {
33
+ let normalizedOffset = 0;
34
+ let displayOffset = 0;
35
+ let startOffset = 0;
36
+ let endOffset = text.length;
37
+ for (const character of text) {
38
+ if (normalizedOffset <= match)
39
+ startOffset = displayOffset;
40
+ normalizedOffset += character.toLowerCase().length;
41
+ displayOffset += character.length;
42
+ if (normalizedOffset >= match + needleLength) {
43
+ endOffset = displayOffset;
44
+ break;
45
+ }
46
+ }
47
+ const start = Math.max(0, startOffset - 100);
48
+ const end = Math.min(text.length, endOffset + 100);
49
+ return `${start ? '…' : ''}${text.slice(start, end).trim()}${end < text.length ? '…' : ''}`;
50
+ }
51
+ /** Runs on a worker connection. Every batch is atomic and restartable. */
52
+ export class SearchIndexer {
53
+ db;
54
+ constructor(db) {
55
+ this.db = db;
56
+ }
57
+ state() {
58
+ return this.db.prepare('SELECT * FROM search_index_state WHERE id = 1').get();
59
+ }
60
+ status() {
61
+ const state = this.state();
62
+ const pending = this.db.prepare('SELECT 1 FROM search_changes LIMIT 1').get();
63
+ return {
64
+ state: state.complete && !pending ? 'ready' : 'building',
65
+ processed: state.processed,
66
+ total: Math.max(state.processed, state.total, 0),
67
+ };
68
+ }
69
+ rebuild(keys) {
70
+ const fragments = this.db.prepare('SELECT f.* FROM search_fragments f JOIN ws_events e ON e.id = f.event_id WHERE f.message_key = ? ORDER BY e.rowid');
71
+ const write = this.db.prepare(`INSERT INTO search_messages(message_key, workspace_id, session_id, event_id, type, created_at, text, normalized)
72
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(message_key) DO UPDATE SET
73
+ workspace_id=excluded.workspace_id, session_id=excluded.session_id, event_id=excluded.event_id,
74
+ type=excluded.type, created_at=excluded.created_at, text=excluded.text, normalized=excluded.normalized`);
75
+ for (const key of keys) {
76
+ const rows = fragments.all(key);
77
+ const first = rows[0];
78
+ if (!first) {
79
+ this.db.prepare('DELETE FROM search_messages WHERE message_key = ?').run(key);
80
+ continue;
81
+ }
82
+ const text = rows
83
+ .map((row) => row.text)
84
+ .join('')
85
+ .normalize('NFC');
86
+ write.run(key, first.workspace_id, first.session_id, first.event_id, first.type, first.created_at, text, normalizeSearchText(text));
87
+ }
88
+ }
89
+ consume(row, eventId, order, keys) {
90
+ const old = this.db
91
+ .prepare('SELECT message_key, event_order FROM search_fragments WHERE event_id = ?')
92
+ .get(eventId);
93
+ if (old)
94
+ keys.add(old.message_key);
95
+ const value = row ? readable(row) : null;
96
+ if (!value || !row) {
97
+ this.db.prepare('DELETE FROM search_fragments WHERE event_id = ?').run(eventId);
98
+ return;
99
+ }
100
+ keys.add(value.key);
101
+ this.db
102
+ .prepare(`INSERT INTO search_fragments(event_id, message_key, workspace_id, session_id, type, created_at, event_order, text)
103
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(event_id) DO UPDATE SET
104
+ message_key=excluded.message_key, workspace_id=excluded.workspace_id, session_id=excluded.session_id,
105
+ type=excluded.type, created_at=excluded.created_at, text=excluded.text`)
106
+ .run(row.id, value.key, row.workspace_id, row.session_id, row.type, row.created_at, old?.event_order ?? order, value.text);
107
+ }
108
+ tick(batchSize = 500) {
109
+ // Acquire the writer reservation before reading: a deferred WAL snapshot
110
+ // cannot be upgraded after another connection commits (SQLITE_BUSY_SNAPSHOT).
111
+ this.db
112
+ .transaction(() => {
113
+ const state = this.state();
114
+ if (state.total < 0)
115
+ this.db.prepare('UPDATE search_index_state SET total = (SELECT count(*) FROM ws_events) WHERE id = 1').run();
116
+ const keys = new Set();
117
+ const changeBudget = state.complete ? batchSize : Math.max(1, Math.floor(batchSize / 2));
118
+ const changes = this.db.prepare('SELECT * FROM search_changes ORDER BY id LIMIT ?').all(changeBudget);
119
+ for (const change of changes) {
120
+ const row = this.db.prepare('SELECT rowid AS rid, * FROM ws_events WHERE id = ?').get(change.event_id);
121
+ this.consume(row, change.event_id, change.operation === 'insert' ? state.base_order + change.id : (row?.rid ?? 0), keys);
122
+ }
123
+ if (changes.length)
124
+ this.db.prepare('DELETE FROM search_changes WHERE id <= ?').run(changes.at(-1).id);
125
+ const backfillBudget = batchSize - changes.length;
126
+ if (!state.complete && backfillBudget > 0) {
127
+ const rows = this.db
128
+ .prepare('SELECT rowid AS rid, * FROM ws_events WHERE id > ? ORDER BY id LIMIT ?')
129
+ .all(state.cursor, backfillBudget);
130
+ for (const row of rows)
131
+ this.consume(row, row.id, row.rid, keys);
132
+ this.db
133
+ .prepare('UPDATE search_index_state SET cursor = ?, processed = processed + ?, complete = ? WHERE id = 1')
134
+ .run(rows.at(-1)?.id ?? state.cursor, rows.length, rows.length < backfillBudget ? 1 : 0);
135
+ }
136
+ this.rebuild(keys);
137
+ })
138
+ .immediate();
139
+ return this.status();
140
+ }
141
+ /** Drain deletions ahead of queries, including those behind an insertion backlog. */
142
+ removeDeleted() {
143
+ this.db
144
+ .transaction(() => {
145
+ const rows = this.db
146
+ .prepare(`SELECT DISTINCT f.event_id, f.message_key FROM search_changes c
147
+ JOIN search_fragments f ON f.event_id = c.event_id
148
+ WHERE NOT EXISTS (SELECT 1 FROM ws_events e WHERE e.id = c.event_id)`)
149
+ .all();
150
+ const keys = new Set();
151
+ for (const row of rows) {
152
+ keys.add(row.message_key);
153
+ this.db.prepare('DELETE FROM search_fragments WHERE event_id = ?').run(row.event_id);
154
+ }
155
+ this.rebuild(keys);
156
+ })
157
+ .immediate();
158
+ }
159
+ /** Resolve a normalized match to the original event used by history deep links. */
160
+ matchEventId(messageKey, match, fallback) {
161
+ const fragments = this.db
162
+ .prepare(`SELECT f.event_id, f.text FROM search_fragments f JOIN ws_events e ON e.id = f.event_id
163
+ WHERE f.message_key = ? ORDER BY e.rowid`)
164
+ .all(messageKey);
165
+ if (fragments.length < 2)
166
+ return fragments[0]?.event_id ?? fallback;
167
+ // Normalize whole graphemes: NFC can compose characters across fragment
168
+ // boundaries, and lowercasing can expand them (İ → i + combining dot).
169
+ const source = fragments.map((fragment) => fragment.text).join('');
170
+ let normalizedEnd = 0;
171
+ let sourceOffset = 0;
172
+ for (const { segment, index } of graphemes.segment(source)) {
173
+ normalizedEnd += normalizeSearchText(segment).length;
174
+ if (normalizedEnd > match) {
175
+ sourceOffset = index;
176
+ break;
177
+ }
178
+ }
179
+ let fragmentEnd = 0;
180
+ for (const fragment of fragments) {
181
+ fragmentEnd += fragment.text.length;
182
+ if (fragmentEnd > sourceOffset)
183
+ return fragment.event_id;
184
+ }
185
+ return fallback;
186
+ }
187
+ async search(query, options = {}, cancelled = () => false) {
188
+ this.removeDeleted();
189
+ const needle = normalizeSearchText(query.trim());
190
+ if (!needle)
191
+ return [];
192
+ const trigram = [...needle].length >= 3 && !needle.includes('\0');
193
+ const clauses = [options.includeArchived ? '1' : 'w.archived_at IS NULL'];
194
+ const args = [];
195
+ if (trigram) {
196
+ clauses.push('search_messages_fts MATCH ?');
197
+ args.push(`"${needle.replaceAll('"', '""')}"`);
198
+ }
199
+ if (options.workspaceId) {
200
+ clauses.push('m.workspace_id = ?');
201
+ args.push(options.workspaceId);
202
+ }
203
+ const statement = this.db.prepare(`SELECT m.*, w.name AS workspace_name, w.archived_at
204
+ FROM search_messages m ${trigram ? 'JOIN search_messages_fts ON search_messages_fts.rowid = m.id' : ''}
205
+ JOIN workspaces w ON w.id = m.workspace_id WHERE ${clauses.join(' AND ')} ORDER BY m.created_at DESC, m.event_id DESC`);
206
+ const results = [];
207
+ let scanned = 0;
208
+ const limit = Math.min(200, Math.max(1, options.limit ?? 50));
209
+ for (const row of statement.iterate(...args)) {
210
+ if (cancelled())
211
+ throw new Error('Search cancelled');
212
+ const index = row.normalized.indexOf(needle);
213
+ if (index >= 0) {
214
+ results.push({
215
+ eventId: row.type === 'agent:event' ? this.matchEventId(row.message_key, index, row.event_id) : row.event_id,
216
+ sessionId: row.session_id,
217
+ workspaceId: row.workspace_id,
218
+ workspaceName: row.workspace_name,
219
+ archived: row.archived_at !== null,
220
+ type: row.type,
221
+ timestamp: row.created_at,
222
+ snippet: snippet(row.text, index, needle.length),
223
+ });
224
+ if (results.length >= limit)
225
+ break;
226
+ }
227
+ if (++scanned % 500 === 0)
228
+ await new Promise((resolve) => setImmediate(resolve));
229
+ }
230
+ return results;
231
+ }
232
+ }
@@ -0,0 +1,76 @@
1
+ import { parentPort, workerData } from 'node:worker_threads';
2
+ import Database from 'better-sqlite3';
3
+ import { SearchIndexer } from './indexer.js';
4
+ const port = parentPort;
5
+ const db = new Database(workerData.dbPath);
6
+ db.pragma('journal_mode=WAL');
7
+ db.pragma('foreign_keys=ON');
8
+ db.pragma('busy_timeout=5000');
9
+ const indexer = new SearchIndexer(db);
10
+ const cancelled = new Set();
11
+ const requests = new Set();
12
+ let queue = Promise.resolve();
13
+ let stopped = false;
14
+ let timer;
15
+ function report() {
16
+ port.postMessage({ status: indexer.status() });
17
+ }
18
+ function pump() {
19
+ if (stopped)
20
+ return;
21
+ queue = queue
22
+ .then(() => {
23
+ if (stopped)
24
+ return;
25
+ indexer.tick();
26
+ report();
27
+ })
28
+ .catch((err) => {
29
+ // A writer may outlast busy_timeout. The rolled-back batch remains queued
30
+ // in SQLite; retry it on the next pump instead of poisoning this worker.
31
+ if (err?.code === 'SQLITE_BUSY' || err?.code?.startsWith('SQLITE_BUSY_'))
32
+ return;
33
+ stopped = true;
34
+ port.postMessage({ status: { state: 'error', processed: 0, total: 0, error: String(err) } });
35
+ })
36
+ .finally(() => {
37
+ if (!stopped)
38
+ timer = setTimeout(pump, indexer.status().state === 'building' ? 10 : 200);
39
+ });
40
+ }
41
+ port.on('message', (message) => {
42
+ if (message.cancel) {
43
+ if (requests.has(message.id))
44
+ cancelled.add(message.id);
45
+ return;
46
+ }
47
+ if (message.stop) {
48
+ stopped = true;
49
+ clearTimeout(timer);
50
+ void queue.finally(() => {
51
+ db.close();
52
+ port.close();
53
+ });
54
+ return;
55
+ }
56
+ requests.add(message.id);
57
+ queue = queue
58
+ .then(async () => {
59
+ if (stopped)
60
+ throw new Error('Search index unavailable');
61
+ if (cancelled.has(message.id))
62
+ return;
63
+ indexer.tick();
64
+ const results = await indexer.search(message.query ?? '', message.options, () => cancelled.has(message.id));
65
+ report();
66
+ port.postMessage({ id: message.id, results });
67
+ })
68
+ .catch((err) => {
69
+ port.postMessage({ id: message.id, error: err instanceof Error ? err.message : String(err) });
70
+ })
71
+ .finally(() => {
72
+ cancelled.delete(message.id);
73
+ requests.delete(message.id);
74
+ });
75
+ });
76
+ pump();