@loicngr/kobo 1.11.36 → 1.11.38

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 (112) hide show
  1. package/AGENTS.md +18 -1
  2. package/CHANGELOG.md +8 -0
  3. package/dist/server/index.js +20 -24
  4. package/dist/server/routes/dev-server.js +2 -1
  5. package/dist/server/routes/pull-requests.js +5 -4
  6. package/dist/server/routes/workspaces.js +161 -109
  7. package/dist/server/services/agent/engines/claude-code/engine.js +11 -0
  8. package/dist/server/services/agent/engines/codex/client.js +1 -0
  9. package/dist/server/services/agent/engines/codex/engine.js +47 -10
  10. package/dist/server/services/agent/engines/codex/event-mapper.js +8 -2
  11. package/dist/server/services/agent/engines/codex/jsonrpc/peer.js +21 -8
  12. package/dist/server/services/agent/engines/codex/jsonrpc/transport.js +30 -3
  13. package/dist/server/services/agent/event-router.js +1 -1
  14. package/dist/server/services/agent/orchestrator.js +219 -78
  15. package/dist/server/services/agent/session-controller.js +12 -2
  16. package/dist/server/services/auto-loop-service.js +60 -6
  17. package/dist/server/services/change-source-branch-service.js +36 -4
  18. package/dist/server/services/cron-service.js +19 -0
  19. package/dist/server/services/db-backup-service.js +30 -0
  20. package/dist/server/services/dev-server-service.js +167 -38
  21. package/dist/server/services/image-service.js +25 -28
  22. package/dist/server/services/pr-checkout-service.js +34 -5
  23. package/dist/server/services/pr-watcher-service.js +5 -3
  24. package/dist/server/services/quota-backoff-service.js +25 -8
  25. package/dist/server/services/wakeup-service.js +23 -3
  26. package/dist/server/services/websocket-service.js +10 -2
  27. package/dist/server/services/workspace-service.js +8 -4
  28. package/dist/server/services/worktree-purge-service.js +3 -7
  29. package/dist/server/utils/agent-stop-result.js +12 -0
  30. package/dist/server/utils/git-ops.js +8 -1
  31. package/dist/server/utils/git-worktree-fingerprint.js +86 -0
  32. package/dist/server/utils/safe-path.js +21 -0
  33. package/dist/server/utils/script-runner.js +32 -9
  34. package/dist/server/utils/workspace-lifecycle-guard.js +27 -0
  35. package/package.json +1 -1
  36. package/src/client/dist/pwa/assets/ActivityFeed-BzzvcOmq.js +8 -0
  37. package/src/client/dist/pwa/assets/{ActivityFeed-Bakgvay6.css → ActivityFeed-Chsw1Mh7.css} +1 -1
  38. package/src/client/dist/pwa/assets/{ChangelogPage-BFGYQ6eY.js → ChangelogPage-D8563008.js} +1 -1
  39. package/src/client/dist/pwa/assets/ComparisonPanel-Bd0X08sg.js +2 -0
  40. package/src/client/dist/pwa/assets/ComparisonPanel-CRRaDs2D.css +1 -0
  41. package/src/client/dist/pwa/assets/CreatePage-A3pVradx.css +1 -0
  42. package/src/client/dist/pwa/assets/CreatePage-Bon8qIka.js +2 -0
  43. package/src/client/dist/pwa/assets/DashboardPage-D8g7_r4S.css +1 -0
  44. package/src/client/dist/pwa/assets/DashboardPage-DYxdxEul.js +1 -0
  45. package/src/client/dist/pwa/assets/{DiffViewer-DSlSAIiW.js → DiffViewer-BpsLNxnj.js} +3 -3
  46. package/src/client/dist/pwa/assets/{DocumentsPanel-C1eXTw_Q.js → DocumentsPanel-BJsJ0fNs.js} +1 -1
  47. package/src/client/dist/pwa/assets/{DrawerToggleButton-BxYpUb4y.js → DrawerToggleButton-BVLO5K5d.js} +1 -1
  48. package/src/client/dist/pwa/assets/{GitPanel-DnBWSdVp.js → GitPanel-Cal_wzC0.js} +3 -3
  49. package/src/client/dist/pwa/assets/{HealthPage-rX1TiaP9.js → HealthPage-RFZS7Lao.js} +1 -1
  50. package/src/client/dist/pwa/assets/{MainLayout-Ceuh5uJj.js → MainLayout-vvyiRcql.js} +2 -2
  51. package/src/client/dist/pwa/assets/{QBtnDropdown-K7syiyyH.js → QBtnDropdown-X8oNU5Mb.js} +1 -1
  52. package/src/client/dist/pwa/assets/{QDrawer-DapzE8DH.js → QDrawer-Cij17skX.js} +1 -1
  53. package/src/client/dist/pwa/assets/{QSelect-Bodml89k.js → QSelect-xd-AlGo_.js} +1 -1
  54. package/src/client/dist/pwa/assets/{QTooltip-K-9OdDfq.js → QTooltip-CwcCGSWq.js} +1 -1
  55. package/src/client/dist/pwa/assets/{SchedulePanel-CfumTzAa.js → SchedulePanel-CXRUsz6G.js} +1 -1
  56. package/src/client/dist/pwa/assets/{SearchPage-AhZYS-dS.js → SearchPage-DopbgLxh.js} +1 -1
  57. package/src/client/dist/pwa/assets/{SettingsPage-BoHXWvKn.js → SettingsPage-Jx6TnOzR.js} +1 -1
  58. package/src/client/dist/pwa/assets/{SplitWorkspacePage-3wK11mcu.js → SplitWorkspacePage-D7lGFRKs.js} +1 -1
  59. package/src/client/dist/pwa/assets/{TerminalPanel-CKg0BeNK.js → TerminalPanel-DI8DHb9e.js} +1 -1
  60. package/src/client/dist/pwa/assets/{TouchPan-D6ctyPYf.js → TouchPan-B3nrkCY8.js} +1 -1
  61. package/src/client/dist/pwa/assets/{TourReplayButton-Dn4Dy_kH.js → TourReplayButton-DU1UShMY.js} +1 -1
  62. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-DnoabsfK.js → WorkspaceAttentionLabels-C-yN5IX8.js} +1 -1
  63. package/src/client/dist/pwa/assets/{WorkspacePage-DYQrfeYw.css → WorkspacePage-BAC6u9OP.css} +1 -1
  64. package/src/client/dist/pwa/assets/WorkspacePage-DKRpdpPg.js +4 -0
  65. package/src/client/dist/pwa/assets/{browser-DHpSUK8-.js → browser-f9MHQjfi.js} +1 -1
  66. package/src/client/dist/pwa/assets/{build-path-tree-DtEjsZlh.js → build-path-tree-BWBzP8os.js} +1 -1
  67. package/src/client/dist/pwa/assets/{de-Dt_m-H83.js → de-QkDLrcGq.js} +1 -1
  68. package/src/client/dist/pwa/assets/{documents-B2dg1wRf.js → documents-C2cLnEBQ.js} +1 -1
  69. package/src/client/dist/pwa/assets/{editor.api-Cd1D35oj.js → editor.api-sw146MWN.js} +1 -1
  70. package/src/client/dist/pwa/assets/{efforts-IB1vPjJQ.js → efforts-C5ZZgCH4.js} +1 -1
  71. package/src/client/dist/pwa/assets/{engineFeatures-DYSyDEJe.js → engineFeatures-CAVMDawq.js} +1 -1
  72. package/src/client/dist/pwa/assets/{es-DFg25DCS.js → es-BgQFKlo8.js} +1 -1
  73. package/src/client/dist/pwa/assets/{expand-template-CNAHiesL.js → expand-template-B6xAlAaM.js} +1 -1
  74. package/src/client/dist/pwa/assets/{format-DidwgbbX.js → format-BwWP6Aay.js} +1 -1
  75. package/src/client/dist/pwa/assets/{formatters-BjzyUHlu.js → formatters-DyWtO-nR.js} +1 -1
  76. package/src/client/dist/pwa/assets/{fr-CQ1slpol.js → fr-DAdLWjNM.js} +1 -1
  77. package/src/client/dist/pwa/assets/{freemarker2-jNGWawVw.js → freemarker2-DZ5Hm8UH.js} +1 -1
  78. package/src/client/dist/pwa/assets/{handlebars-DfSqClrW.js → handlebars-BRUGpTA7.js} +1 -1
  79. package/src/client/dist/pwa/assets/{html-ByLX-n8T.js → html-DNOayNNs.js} +1 -1
  80. package/src/client/dist/pwa/assets/i18n-CBez_vip.js +1 -0
  81. package/src/client/dist/pwa/assets/index-VL3fI9a3.js +20 -0
  82. package/src/client/dist/pwa/assets/{it-w09rfaP7.js → it-Bumaewp1.js} +1 -1
  83. package/src/client/dist/pwa/assets/{javascript-Dja1fHfv.js → javascript-C0tDQU3z.js} +1 -1
  84. package/src/client/dist/pwa/assets/{liquid-D315hW6C.js → liquid-CN46O6OF.js} +1 -1
  85. package/src/client/dist/pwa/assets/{mdx-B5yUwpzv.js → mdx-DM-lrr8Q.js} +1 -1
  86. package/src/client/dist/pwa/assets/{monaco-contributions-NfxrU0r3.js → monaco-contributions-CkN7y-8c.js} +1 -1
  87. package/src/client/dist/pwa/assets/{monaco.contribution-D1hXPkDZ.js → monaco.contribution-2BcAOLjc.js} +2 -2
  88. package/src/client/dist/pwa/assets/{network-auth-CARVtbNe.js → network-auth-KYS4snQH.js} +1 -1
  89. package/src/client/dist/pwa/assets/{notify-theme-CKC3-EAZ.js → notify-theme-a91Tcr6o.js} +1 -1
  90. package/src/client/dist/pwa/assets/{pending-diff-open-BlLrQaW7.js → pending-diff-open-DOtELZfB.js} +1 -1
  91. package/src/client/dist/pwa/assets/{permissionModes-DMzFEQhr.js → permissionModes-C-9Crj-c.js} +1 -1
  92. package/src/client/dist/pwa/assets/{project-color-CFjdIj6W.js → project-color-BRe4oyNx.js} +1 -1
  93. package/src/client/dist/pwa/assets/{python-Dc6mnEip.js → python-BRpJG6sZ.js} +1 -1
  94. package/src/client/dist/pwa/assets/{razor-DArVFBGO.js → razor-CehCmvHl.js} +1 -1
  95. package/src/client/dist/pwa/assets/{render-chat-markdown-BTPAjU2g.js → render-chat-markdown-DlbQLZ02.js} +1 -1
  96. package/src/client/dist/pwa/assets/{typescript-COE1vIyB.js → typescript-Dt_8mVuj.js} +1 -1
  97. package/src/client/dist/pwa/assets/{use-tours-C8x2V1w5.js → use-tours-CSVM_54K.js} +1 -1
  98. package/src/client/dist/pwa/assets/{workspace-templates-DHyA_GEW.js → workspace-templates-GCObcjO6.js} +1 -1
  99. package/src/client/dist/pwa/assets/{xml-eYjr4cGR.js → xml-_gOwdzB4.js} +1 -1
  100. package/src/client/dist/pwa/assets/{yaml-UooOcssF.js → yaml-BxeBqpGf.js} +1 -1
  101. package/src/client/dist/pwa/index.html +1 -1
  102. package/src/client/dist/pwa/sw.js +1 -1
  103. package/src/client/dist/pwa/assets/ActivityFeed-BPx-xhFY.js +0 -8
  104. package/src/client/dist/pwa/assets/ComparisonPanel-C6DfjpZZ.js +0 -2
  105. package/src/client/dist/pwa/assets/ComparisonPanel-x1iuICgd.css +0 -1
  106. package/src/client/dist/pwa/assets/CreatePage-DxOyY65u.css +0 -1
  107. package/src/client/dist/pwa/assets/CreatePage-ypEzkAKv.js +0 -2
  108. package/src/client/dist/pwa/assets/DashboardPage-DsVRjzzy.js +0 -1
  109. package/src/client/dist/pwa/assets/DashboardPage-DsWn5cWl.css +0 -1
  110. package/src/client/dist/pwa/assets/WorkspacePage-BI7CW2yT.js +0 -4
  111. package/src/client/dist/pwa/assets/i18n-CnjXQjxy.js +0 -1
  112. package/src/client/dist/pwa/assets/index-DcwcFDrZ.js +0 -20
@@ -1,33 +1,15 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import fs from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { nanoid } from 'nanoid';
5
+ import { ensureDirectoryInside } from '../utils/safe-path.js';
4
6
  const ALLOWED_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp']);
5
7
  const IMAGES_DIR = '.ai/images';
6
8
  const INDEX_FILE = 'index.json';
7
9
  // Per-worktree lock to serialize index.json writes
8
10
  const locks = new Map();
9
11
  function ensureRealImagesDirectory(worktreePath) {
10
- const realWorktree = fs.realpathSync(worktreePath);
11
- let current = worktreePath;
12
- for (const segment of ['.ai', 'images']) {
13
- current = path.join(current, segment);
14
- if (fs.existsSync(current)) {
15
- const stat = fs.lstatSync(current);
16
- if (stat.isSymbolicLink())
17
- throw new Error(`Symbolic links are not allowed in image storage: ${current}`);
18
- if (!stat.isDirectory())
19
- throw new Error(`Image storage path is not a directory: ${current}`);
20
- }
21
- else {
22
- fs.mkdirSync(current);
23
- }
24
- const realCurrent = fs.realpathSync(current);
25
- const relative = path.relative(realWorktree, realCurrent);
26
- if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
27
- throw new Error('Image storage path escapes the worktree');
28
- }
29
- }
30
- return fs.realpathSync(current);
12
+ return ensureDirectoryInside(worktreePath, '.ai/images');
31
13
  }
32
14
  function withLock(worktreePath, fn) {
33
15
  const prev = locks.get(worktreePath) ?? Promise.resolve();
@@ -56,9 +38,17 @@ function writeIndex(imagesDir, entries) {
56
38
  if (fs.existsSync(indexPath) && fs.lstatSync(indexPath).isSymbolicLink()) {
57
39
  throw new Error('Symbolic links are not allowed for image index');
58
40
  }
59
- const tempPath = path.join(imagesDir, `.index-${process.pid}.tmp`);
60
- fs.writeFileSync(tempPath, JSON.stringify(entries, null, 2));
61
- fs.renameSync(tempPath, indexPath);
41
+ const tempPath = path.join(imagesDir, `.index-${randomUUID()}.tmp`);
42
+ let created = false;
43
+ try {
44
+ fs.writeFileSync(tempPath, JSON.stringify(entries, null, 2), { flag: 'wx', mode: 0o600 });
45
+ created = true;
46
+ fs.renameSync(tempPath, indexPath);
47
+ }
48
+ finally {
49
+ if (created && fs.existsSync(tempPath))
50
+ fs.unlinkSync(tempPath);
51
+ }
62
52
  }
63
53
  /** Save an image buffer to `.ai/images/` and update the index. Returns the UID and relative path. */
64
54
  export async function saveImage(worktreePath, fileBuffer, originalName) {
@@ -75,10 +65,17 @@ export async function saveImage(worktreePath, fileBuffer, originalName) {
75
65
  await withLock(worktreePath, () => {
76
66
  // Write the image file inside the lock so both the file write and index
77
67
  // update happen atomically — avoids orphan files on crash between the two.
78
- fs.writeFileSync(path.join(imagesDir, filename), fileBuffer);
79
- const entries = readIndex(imagesDir);
80
- entries.push({ uid, originalName, createdAt: new Date().toISOString() });
81
- writeIndex(imagesDir, entries);
68
+ const imagePath = path.join(imagesDir, filename);
69
+ fs.writeFileSync(imagePath, fileBuffer, { flag: 'wx', mode: 0o600 });
70
+ try {
71
+ const entries = readIndex(imagesDir);
72
+ entries.push({ uid, originalName, createdAt: new Date().toISOString() });
73
+ writeIndex(imagesDir, entries);
74
+ }
75
+ catch (err) {
76
+ fs.unlinkSync(imagePath);
77
+ throw err;
78
+ }
82
79
  });
83
80
  return { uid, relativePath: `${IMAGES_DIR}/${filename}` };
84
81
  }
@@ -5,7 +5,10 @@ import path from 'node:path';
5
5
  import * as gitOps from '../utils/git-ops.js';
6
6
  import { GitConflictError } from '../utils/git-ops.js';
7
7
  import { withGitRepoLock } from '../utils/git-repo-lock.js';
8
+ import { fingerprintWorktree } from '../utils/git-worktree-fingerprint.js';
9
+ import { withWorkspaceLifecycleGuard } from '../utils/workspace-lifecycle-guard.js';
8
10
  import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
11
+ import { hasController } from './agent/orchestrator.js';
9
12
  import { createWorktree, listWorktrees, removeWorktreeUnlocked } from './worktree-service.js';
10
13
  /**
11
14
  * A `pathCollision.worktreePath` decision is untrusted input reaching
@@ -114,8 +117,10 @@ function diagnoseChanges(worktreePath) {
114
117
  /** Classify the workspace, if any, that already tracks this branch.
115
118
  * A purged worktree outranks the archived flag: purging always archives, and
116
119
  * "recreate the worktree" is the useful offer, not "unarchive". */
117
- export function resolveWorkspaceState(workspaces, branch) {
118
- const match = workspaces.find((w) => w.workingBranch === branch);
120
+ export function resolveWorkspaceState(workspaces, branch, projectPath) {
121
+ const match = workspaces.find((w) => w.workingBranch === branch &&
122
+ typeof w.projectPath === 'string' &&
123
+ path.resolve(w.projectPath) === path.resolve(projectPath));
119
124
  if (!match)
120
125
  return { state: 'none' };
121
126
  if (match.worktreePurgedAt)
@@ -134,10 +139,13 @@ function safeRevParse(repoPath, ref) {
134
139
  }
135
140
  }
136
141
  /** Hash the observed state so `/resolve` can refuse a plan built on stale facts. */
137
- export function computeFingerprint(report) {
142
+ export async function computeFingerprint(report) {
138
143
  const localHead = safeRevParse(report.projectPath, report.headBranch);
139
144
  const remoteHead = safeRevParse(report.projectPath, `origin/${report.headBranch}`);
140
145
  const payload = JSON.stringify({
146
+ contents: report.worktree.state === 'orphan' || report.worktree.state === 'attached'
147
+ ? await fingerprintWorktree(report.worktree.path)
148
+ : null,
141
149
  localHead,
142
150
  remoteHead,
143
151
  worktree: report.worktree,
@@ -174,7 +182,7 @@ export function diagnoseLocalState(projectPath, headBranch, worktreesPath, attac
174
182
  headBranch,
175
183
  targetWorktreePath,
176
184
  blockers,
177
- workspace: resolveWorkspaceState(workspaces, headBranch),
185
+ workspace: resolveWorkspaceState(workspaces, headBranch, projectPath),
178
186
  worktree,
179
187
  localChanges: inspectPath ? diagnoseChanges(inspectPath) : EMPTY_CHANGES,
180
188
  ongoingOperation: inspectPath ? gitOps.getOngoingGitOperation(inspectPath) : null,
@@ -249,14 +257,35 @@ export class StaleDiagnosisError extends Error {
249
257
  * or a source-branch change.
250
258
  */
251
259
  export function resolvePrCheckout(input) {
260
+ const initial = diagnoseLocalState(input.projectPath, input.headBranch, input.worktreesPath, input.attachedPaths, input.workspaces);
261
+ const owner = initial.worktree.state === 'attached'
262
+ ? initial.worktree.workspaceId
263
+ : initial.workspace.state !== 'none'
264
+ ? initial.workspace.id
265
+ : null;
266
+ const resolve = () => resolvePrCheckoutLocked(input, owner);
267
+ return owner ? withWorkspaceLifecycleGuard(owner, resolve) : resolve();
268
+ }
269
+ function resolvePrCheckoutLocked(input, owner) {
252
270
  return withGitRepoLock(input.projectPath, async () => {
271
+ if (owner && hasController(owner))
272
+ throw new Error('Stop the workspace agent before changing its checkout');
253
273
  const applied = [];
254
274
  // 1. Refresh, then refuse a plan built on facts that no longer hold.
255
275
  gitOps.fetchSourceBranch(input.projectPath, input.headBranch);
256
276
  applied.push({ kind: 'fetch', detail: `origin/${input.headBranch}` });
257
277
  const fresh = diagnoseLocalState(input.projectPath, input.headBranch, input.worktreesPath, input.attachedPaths ?? new Map(), input.workspaces ?? []);
258
- if (computeFingerprint(fresh) !== input.fingerprint)
278
+ if ((await computeFingerprint(fresh)) !== input.fingerprint)
259
279
  throw new StaleDiagnosisError(fresh);
280
+ if ((fresh.worktree.state === 'orphan' || fresh.worktree.state === 'attached') &&
281
+ input.decisions.orphanWorktree === 'create-elsewhere') {
282
+ throw new Error('Branch is already checked out. Attach its existing worktree instead.');
283
+ }
284
+ if (fresh.localChanges.present &&
285
+ (input.decisions.localChanges ?? 'keep') === 'keep' &&
286
+ input.decisions.divergence === 'reset-hard') {
287
+ throw new Error('Cannot preserve local changes during a hard reset. Stash or commit them first, or keep the branch.');
288
+ }
260
289
  // 2. Clear stale worktree metadata before anything needs the path.
261
290
  if (fresh.worktree.state === 'stale-metadata') {
262
291
  git(input.projectPath, ['worktree', 'prune']);
@@ -226,7 +226,7 @@ export async function checkPrStatuses() {
226
226
  });
227
227
  prMergedHook = lifecycleHookService.onPrMerged(ws.id, { prNumber: pr.number, prUrl: pr.url });
228
228
  }
229
- if (['extracting', 'brainstorming', 'executing'].includes(ws.status) || hasController(ws.id)) {
229
+ if (['extracting', 'brainstorming', 'executing', 'compacting'].includes(ws.status) || hasController(ws.id)) {
230
230
  // Agent is working — update the cache but skip auto-archive.
231
231
  // (The defensive base preservation from the no-base branch doesn't apply here
232
232
  // because we ARE in the OPEN→MERGED/CLOSED branch which always has a base.)
@@ -252,7 +252,9 @@ export async function checkPrStatuses() {
252
252
  if (stale())
253
253
  return;
254
254
  const current = getWorkspace(ws.id);
255
- if (!current || ['extracting', 'brainstorming', 'executing'].includes(current.status) || hasController(ws.id))
255
+ if (!current ||
256
+ ['extracting', 'brainstorming', 'executing', 'compacting'].includes(current.status) ||
257
+ hasController(ws.id))
256
258
  return;
257
259
  try {
258
260
  destroyTerminal(ws.id);
@@ -320,7 +322,7 @@ export async function checkPrStatuses() {
320
322
  emitEphemeral(ws.id, 'pr:merge-conflict', payload);
321
323
  markUnread(ws.id);
322
324
  }
323
- const notBusy = !['extracting', 'brainstorming', 'executing'].includes(ws.status);
325
+ const notBusy = !['extracting', 'brainstorming', 'executing', 'compacting'].includes(ws.status);
324
326
  if (notBusy && !prev.readyToMerge && pr.readyToMerge) {
325
327
  emitEphemeral(ws.id, 'pr:ready-to-merge', payload);
326
328
  markUnread(ws.id);
@@ -2,7 +2,15 @@ import { getDb } from '../db/index.js';
2
2
  import { emitEphemeral } from './websocket-service.js';
3
3
  import { getWorkspace } from './workspace-service.js';
4
4
  const timers = new Map();
5
+ let suspended = false;
5
6
  let onFireCallback = null;
7
+ /** Stop in-memory delivery without consuming the schedules needed by the next boot. */
8
+ export function suspendForShutdown() {
9
+ suspended = true;
10
+ for (const timer of timers.values())
11
+ clearTimeout(timer);
12
+ timers.clear();
13
+ }
6
14
  function rowToPending(row) {
7
15
  return {
8
16
  workspaceId: row.workspace_id,
@@ -26,10 +34,9 @@ export function arm(workspaceId, delayMs, meta) {
26
34
  const db = getDb();
27
35
  const now = new Date();
28
36
  const targetAt = new Date(now.getTime() + delayMs).toISOString();
29
- const existing = db
30
- .prepare('SELECT retry_count FROM pending_quota_backoffs WHERE workspace_id = ?')
31
- .get(workspaceId);
32
- const retryCount = (existing?.retry_count ?? 0) + 1;
37
+ const retryCount = meta.retryCount;
38
+ if (!Number.isSafeInteger(retryCount) || retryCount < 1)
39
+ throw new Error('retryCount must be a positive integer');
33
40
  db.prepare(`INSERT INTO pending_quota_backoffs (workspace_id, target_at, resets_at, source, reason, retry_count, created_at)
34
41
  VALUES (?, ?, ?, ?, ?, ?, ?)
35
42
  ON CONFLICT(workspace_id) DO UPDATE SET
@@ -42,9 +49,12 @@ export function arm(workspaceId, delayMs, meta) {
42
49
  const previous = timers.get(workspaceId);
43
50
  if (previous)
44
51
  clearTimeout(previous);
45
- const timer = setTimeout(() => fireOrSkip(workspaceId), Math.max(0, delayMs));
46
- timer.unref?.();
47
- timers.set(workspaceId, timer);
52
+ timers.delete(workspaceId);
53
+ if (!suspended) {
54
+ const timer = setTimeout(() => fireOrSkip(workspaceId), Math.max(0, delayMs));
55
+ timer.unref?.();
56
+ timers.set(workspaceId, timer);
57
+ }
48
58
  emitEphemeral(workspaceId, 'agent:quota-backoff', {
49
59
  targetAt,
50
60
  resetsAt: meta.resetsAt,
@@ -90,6 +100,8 @@ export function setOnFireCallback(fn) {
90
100
  * archived or missing workspaces are deleted without firing.
91
101
  */
92
102
  export function restoreOnBoot(onFire) {
103
+ suspendForShutdown();
104
+ suspended = false;
93
105
  setOnFireCallback(onFire);
94
106
  const db = getDb();
95
107
  const rows = db.prepare('SELECT * FROM pending_quota_backoffs').all();
@@ -107,7 +119,12 @@ export function restoreOnBoot(onFire) {
107
119
  }
108
120
  /** Internal — invoked when a timer fires. */
109
121
  function fireOrSkip(workspaceId) {
122
+ if (suspended)
123
+ return;
110
124
  timers.delete(workspaceId);
125
+ const pending = getPending(workspaceId);
126
+ if (!pending)
127
+ return;
111
128
  // Final archive check before firing — workspace might have been archived
112
129
  // between the timer being armed and now.
113
130
  const ws = getWorkspace(workspaceId);
@@ -124,7 +141,7 @@ function fireOrSkip(workspaceId) {
124
141
  const cb = onFireCallback;
125
142
  if (!cb)
126
143
  return;
127
- cb(workspaceId);
144
+ cb(workspaceId, pending);
128
145
  }
129
146
  /** @internal test-only */
130
147
  export const _timers = timers;
@@ -1,5 +1,6 @@
1
1
  import { getDb } from '../db/index.js';
2
2
  import { slugifyProjectName } from '../utils/project-slug.js';
3
+ import { isWorkspaceLifecycleBusy } from '../utils/workspace-lifecycle-guard.js';
3
4
  import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
4
5
  import * as orchestrator from './agent/orchestrator.js';
5
6
  import * as settingsService from './settings-service.js';
@@ -12,6 +13,14 @@ const AUTONOMOUS_LOOP_SENTINEL = '<<autonomous-loop-dynamic>>';
12
13
  const AUTONOMOUS_LOOP_FALLBACK_PROMPT = 'Continue where you left off.';
13
14
  /** In-memory timers — cleared on cancel/fire; rebuilt on boot via rehydrate. */
14
15
  const timers = new Map();
16
+ let suspended = false;
17
+ /** Preserve persisted wakeups while preventing delivery during teardown. */
18
+ export function suspendForShutdown() {
19
+ suspended = true;
20
+ for (const timer of timers.values())
21
+ clearTimeout(timer);
22
+ timers.clear();
23
+ }
15
24
  function clamp(n, lo, hi) {
16
25
  return Math.max(lo, Math.min(hi, n));
17
26
  }
@@ -54,9 +63,12 @@ export function schedule(workspaceId, delaySeconds, prompt, reason, agentSession
54
63
  db.prepare(`INSERT OR REPLACE INTO pending_wakeups
55
64
  (workspace_id, target_at, prompt, reason, created_at, agent_session_id)
56
65
  VALUES (?, ?, ?, ?, ?, ?)`).run(workspaceId, targetAtIso, effectivePrompt, reason ?? null, new Date().toISOString(), agentSessionId ?? null);
57
- const timeout = setTimeout(() => fire(workspaceId), clampedSeconds * 1000);
58
- timeout.unref?.();
59
- timers.set(workspaceId, timeout);
66
+ timers.delete(workspaceId);
67
+ if (!suspended) {
68
+ const timeout = setTimeout(() => fire(workspaceId), clampedSeconds * 1000);
69
+ timeout.unref?.();
70
+ timers.set(workspaceId, timeout);
71
+ }
60
72
  emitEphemeral(workspaceId, 'wakeup:scheduled', { targetAt: targetAtIso, reason });
61
73
  }
62
74
  catch (err) {
@@ -105,6 +117,8 @@ export function isWakeupScheduled(workspaceId) {
105
117
  }
106
118
  /** Re-register timers for rows persisted across restart. Skips stale entries. */
107
119
  export function rehydrate() {
120
+ suspendForShutdown();
121
+ suspended = false;
108
122
  try {
109
123
  const db = getDb();
110
124
  const rows = db.prepare('SELECT * FROM pending_wakeups').all();
@@ -139,12 +153,18 @@ export function rehydrate() {
139
153
  }
140
154
  /** Internal — invoked by setTimeout. */
141
155
  function fire(workspaceId) {
156
+ if (suspended)
157
+ return;
142
158
  try {
143
159
  const db = getDb();
144
160
  const row = db.prepare('SELECT * FROM pending_wakeups WHERE workspace_id = ?').get(workspaceId);
145
161
  timers.delete(workspaceId);
146
162
  if (!row)
147
163
  return;
164
+ if (isWorkspaceLifecycleBusy(workspaceId)) {
165
+ defer(workspaceId, row);
166
+ return;
167
+ }
148
168
  if (orchestrator.hasController(workspaceId)) {
149
169
  // Claude can keep the stream open after a result while a background
150
170
  // task runs. Waiting for that controller to disappear deadlocks a
@@ -199,11 +199,19 @@ export function emit(workspaceId, type, payload, sessionId) {
199
199
  * Broadcast an event to subscribed clients WITHOUT persisting to the database.
200
200
  * Used for ephemeral status updates (e.g., dev-server status) that don't need replay.
201
201
  */
202
- export function emitEphemeral(workspaceId, type, payload) {
202
+ export function emitEphemeral(workspaceId, type, payload, sessionId) {
203
203
  recordActivity(workspaceId, type, payload);
204
204
  const id = nanoid();
205
205
  const createdAt = new Date().toISOString();
206
- const event = { id, workspaceId, type, payload, createdAt, replayable: false };
206
+ const event = {
207
+ id,
208
+ workspaceId,
209
+ type,
210
+ payload,
211
+ createdAt,
212
+ replayable: false,
213
+ ...(sessionId ? { sessionId } : {}),
214
+ };
207
215
  const message = JSON.stringify(event);
208
216
  let sendErrorLogged = false;
209
217
  for (const [ws, subs] of clients) {
@@ -16,10 +16,11 @@ const VALID_TRANSITIONS = {
16
16
  // workspace with a distinct brainstormModel silently lost its backoff: the
17
17
  // transition threw, the error was swallowed, and the very next lines
18
18
  // cancelled the timer that had just been armed.
19
- extracting: ['extracting', 'brainstorming', 'idle', 'error', 'awaiting-user', 'quota'],
20
- brainstorming: ['executing', 'completed', 'idle', 'error', 'awaiting-user', 'quota'],
21
- executing: ['completed', 'idle', 'error', 'quota', 'awaiting-user'],
22
- 'awaiting-user': ['executing', 'brainstorming', 'extracting', 'idle', 'error', 'completed', 'quota'],
19
+ extracting: ['compacting', 'extracting', 'brainstorming', 'idle', 'error', 'awaiting-user', 'quota'],
20
+ brainstorming: ['compacting', 'executing', 'completed', 'idle', 'error', 'awaiting-user', 'quota'],
21
+ executing: ['compacting', 'completed', 'idle', 'error', 'quota', 'awaiting-user'],
22
+ 'awaiting-user': ['compacting', 'executing', 'brainstorming', 'extracting', 'idle', 'error', 'completed', 'quota'],
23
+ compacting: ['extracting', 'brainstorming', 'executing', 'awaiting-user', 'completed', 'idle', 'error', 'quota'],
23
24
  completed: ['idle', 'executing'],
24
25
  idle: ['executing', 'brainstorming', 'extracting', 'error'],
25
26
  error: ['idle', 'executing', 'brainstorming', 'extracting'],
@@ -196,6 +197,9 @@ export function updateWorkspaceStatus(id, status) {
196
197
  }
197
198
  const now = new Date().toISOString();
198
199
  db.prepare('UPDATE workspaces SET status = ?, updated_at = ? WHERE id = ?').run(status, now, id);
200
+ if (status === 'compacting' || workspace.status === 'compacting') {
201
+ emitEphemeral(id, 'workspace:status', { status });
202
+ }
199
203
  return getWorkspace(id);
200
204
  }
201
205
  const WORKSPACE_NAME_MAX_LENGTH = 200;
@@ -1,6 +1,7 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import { promisify } from 'node:util';
4
+ import { assertAgentStopped } from '../utils/agent-stop-result.js';
4
5
  import { logError, logWarn } from '../utils/logger.js';
5
6
  import { withWorkspaceLifecycleGuard } from '../utils/workspace-lifecycle-guard.js';
6
7
  import * as agentManager from './agent/orchestrator.js';
@@ -29,19 +30,14 @@ async function purgeWorktreeGuarded(workspaceId, expectedArchivedAt) {
29
30
  const warnings = [];
30
31
  // `removeWorktree` runs later in this same function — the agent must be dead
31
32
  // by then, not merely asked to stop.
32
- try {
33
- await agentManager.stopAgentAndWait(workspaceId, undefined, 'purge');
34
- }
35
- catch (err) {
36
- const msg = err instanceof Error ? err.message : String(err);
37
- logError('purge', `stopAgent failed for '${workspace.name}'`, { workspaceId: workspace.id, error: msg });
38
- }
33
+ assertAgentStopped(await agentManager.stopAgentAndWait(workspaceId, undefined, 'purge'));
39
34
  try {
40
35
  await devServerService.stopDevServer(workspaceId);
41
36
  }
42
37
  catch (err) {
43
38
  const msg = err instanceof Error ? err.message : String(err);
44
39
  logError('purge', `stopDevServer failed for '${workspace.name}'`, { workspaceId: workspace.id, error: msg });
40
+ throw err;
45
41
  }
46
42
  try {
47
43
  destroyTerminal(workspaceId);
@@ -0,0 +1,12 @@
1
+ export class AgentStopError extends Error {
2
+ outcome;
3
+ code = 'agent-stop-incomplete';
4
+ constructor(outcome) {
5
+ super(`Agent stop is not confirmed (${outcome}). Wait for it to stop and retry.`);
6
+ this.outcome = outcome;
7
+ }
8
+ }
9
+ export function assertAgentStopped(outcome) {
10
+ if (outcome !== 'stopped' && outcome !== 'not-running')
11
+ throw new AgentStopError(outcome);
12
+ }
@@ -669,7 +669,13 @@ export function listProperCommits(repoPath, workingBranch, newBase, oldBase) {
669
669
  * `repoPath`. The caller (and the Kōbō worktree orchestrator) must ensure
670
670
  * `workingBranch` is the active branch — do NOT add a `git checkout` here.
671
671
  */
672
+ export function assertCurrentBranch(repoPath, expected) {
673
+ const actual = getCurrentBranch(repoPath);
674
+ if (actual !== expected)
675
+ throw new Error(`Expected checkout '${expected}', found '${actual || 'detached HEAD'}'`);
676
+ }
672
677
  export function reconstructBranchOnto(repoPath, workingBranch, newBase, commits) {
678
+ assertCurrentBranch(repoPath, workingBranch);
673
679
  const baseRef = resolveBase(repoPath, newBase);
674
680
  const backupBranch = `kobo-backup/${workingBranch}-${Date.now()}`;
675
681
  git(repoPath, ['branch', backupBranch, workingBranch]);
@@ -749,8 +755,9 @@ export function pruneBackupBranches(repoPath, workingBranch, keep = 3) {
749
755
  }
750
756
  /** Abort any in-progress operation, then hard-reset `workingBranch` to a backup branch. */
751
757
  export function restoreBranchFromBackup(repoPath, workingBranch, backupBranch) {
758
+ assertCurrentBranch(repoPath, workingBranch);
752
759
  abortOngoingGitOperation(repoPath);
753
- git(repoPath, ['checkout', '-q', workingBranch]);
760
+ assertCurrentBranch(repoPath, workingBranch);
754
761
  git(repoPath, ['reset', '--hard', backupBranch]);
755
762
  }
756
763
  /** Return structured diff shortstat between two refs (three-dot merge base). */
@@ -0,0 +1,86 @@
1
+ import { execFileSync, spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { constants } from 'node:fs';
4
+ import { lstat, open, readlink, realpath } from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ /** Hash diffs and untracked contents without loading large files into memory. */
7
+ export async function fingerprintWorktree(cwd) {
8
+ return fingerprintRepository(await realpath(cwd), new Set());
9
+ }
10
+ /** Nested repositories contribute HEAD and their own Git-filtered working state, never a .git traversal. */
11
+ async function fingerprintRepository(cwd, visited) {
12
+ if (visited.has(cwd))
13
+ throw new Error('Cannot fingerprint a recursive repository path');
14
+ visited.add(cwd);
15
+ const gitOptions = { cwd, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 };
16
+ const topLevel = execFileSync('git', ['rev-parse', '--show-toplevel'], gitOptions).trim();
17
+ if ((await realpath(topLevel)) !== cwd)
18
+ throw new Error('Working tree changed during fingerprinting; retry diagnosis');
19
+ const hash = createHash('sha256');
20
+ let head;
21
+ try {
22
+ head = execFileSync('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], gitOptions).trim();
23
+ }
24
+ catch (err) {
25
+ // A freshly initialized nested repository has no commit yet. Other Git failures remain fatal.
26
+ if (err.status !== 1)
27
+ throw err;
28
+ head = 'unborn';
29
+ }
30
+ hash.update(`HEAD\0${head}\0`);
31
+ for (const staged of [false, true]) {
32
+ hash.update(staged ? 'index\0' : 'working-tree\0');
33
+ await new Promise((resolve, reject) => {
34
+ const child = spawn('git', ['diff', '--no-ext-diff', '--no-textconv', '--binary', '--no-color', ...(staged ? ['--cached'] : []), '--'], { cwd, stdio: ['ignore', 'pipe', 'pipe'], timeout: 30_000 });
35
+ child.stdout.on('data', (chunk) => hash.update(chunk));
36
+ child.stderr.resume();
37
+ child.on('error', reject);
38
+ child.on('close', (code) => (code === 0 ? resolve() : reject(new Error('Cannot fingerprint working tree diff'))));
39
+ });
40
+ }
41
+ const names = execFileSync('git', ['ls-files', '--others', '--exclude-standard', '-z'], {
42
+ cwd,
43
+ encoding: 'utf8',
44
+ timeout: 30_000,
45
+ maxBuffer: 16 * 1024 * 1024,
46
+ })
47
+ .split('\0')
48
+ .filter(Boolean)
49
+ .sort();
50
+ for (const name of names) {
51
+ hash.update(`\0untracked\0${name}\0`);
52
+ // Remove Git's trailing slash for nested repos before lstat, so a link is never dereferenced.
53
+ const filePath = path.resolve(cwd, name);
54
+ const stat = await lstat(filePath);
55
+ hash.update(`${stat.mode}\0`);
56
+ if (stat.isSymbolicLink()) {
57
+ hash.update(await readlink(filePath));
58
+ }
59
+ else if (stat.isFile()) {
60
+ const file = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
61
+ try {
62
+ const before = await file.stat();
63
+ for await (const chunk of file.createReadStream({ autoClose: false }))
64
+ hash.update(chunk);
65
+ const after = await file.stat();
66
+ if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs)
67
+ throw new Error('Working tree changed during fingerprinting; retry diagnosis');
68
+ }
69
+ finally {
70
+ await file.close();
71
+ }
72
+ }
73
+ else if (stat.isDirectory()) {
74
+ // `ls-files --others` reports an embedded repository as a directory instead of its files.
75
+ const nestedPath = await realpath(filePath);
76
+ const relative = path.relative(cwd, nestedPath);
77
+ if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
78
+ throw new Error('Nested repository escapes the working tree');
79
+ hash.update(`\0repository\0${await fingerprintRepository(nestedPath, visited)}\0`);
80
+ }
81
+ else {
82
+ throw new Error('Cannot fingerprint a non-regular untracked file');
83
+ }
84
+ }
85
+ return hash.digest('hex');
86
+ }
@@ -66,3 +66,24 @@ function assertLeafIsNotSymlink(candidate) {
66
66
  throw err;
67
67
  }
68
68
  }
69
+ /** Create storage directories without accepting symlinked path components. */
70
+ export function ensureDirectoryInside(rootPath, relativePath) {
71
+ assertPathInside(rootPath, relativePath);
72
+ const root = fs.realpathSync(rootPath);
73
+ let current = root;
74
+ for (const segment of relativePath.split(path.sep).filter(Boolean)) {
75
+ current = path.join(current, segment);
76
+ try {
77
+ fs.mkdirSync(current);
78
+ }
79
+ catch (err) {
80
+ if (err.code !== 'EEXIST')
81
+ throw err;
82
+ }
83
+ const stat = fs.lstatSync(current);
84
+ if (stat.isSymbolicLink() || !stat.isDirectory())
85
+ throw new Error('Symbolic links are not allowed in storage directories');
86
+ current = resolveExistingPathInside(root, current);
87
+ }
88
+ return current;
89
+ }
@@ -3,6 +3,7 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import * as wsService from '../services/websocket-service.js';
5
5
  import { getIndexLockPath } from './git-ops.js';
6
+ import { ensureDirectoryInside } from './safe-path.js';
6
7
  /** Default wall-clock budget for a user script before it is force-killed. */
7
8
  export const SCRIPT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
8
9
  /**
@@ -13,10 +14,31 @@ export const SCRIPT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
13
14
  export function runScript(opts) {
14
15
  const { workspaceId, worktreePath, script, eventPrefix, tmpFileName, env, extraEnv } = opts;
15
16
  const timeoutMs = opts.timeoutMs ?? SCRIPT_TIMEOUT_MS;
17
+ let scriptPath;
18
+ let scriptDirectory;
19
+ const cleanup = () => {
20
+ if (scriptPath) {
21
+ try {
22
+ fs.unlinkSync(scriptPath);
23
+ }
24
+ catch {
25
+ /* best effort */
26
+ }
27
+ }
28
+ if (scriptDirectory) {
29
+ try {
30
+ fs.rmdirSync(scriptDirectory);
31
+ }
32
+ catch {
33
+ /* best effort */
34
+ }
35
+ }
36
+ };
16
37
  return new Promise((resolve) => {
17
- const scriptPath = path.join(worktreePath, '.ai', tmpFileName);
18
- fs.mkdirSync(path.dirname(scriptPath), { recursive: true });
19
- fs.writeFileSync(scriptPath, script, { mode: 0o755 });
38
+ const storage = ensureDirectoryInside(worktreePath, '.ai');
39
+ scriptDirectory = fs.mkdtempSync(path.join(storage, '.script-'));
40
+ scriptPath = path.join(scriptDirectory, path.basename(tmpFileName));
41
+ fs.writeFileSync(scriptPath, script, { mode: 0o700, flag: 'wx' });
20
42
  const proc = spawn('bash', [scriptPath], {
21
43
  cwd: worktreePath,
22
44
  env: {
@@ -93,12 +115,7 @@ export function runScript(opts) {
93
115
  return;
94
116
  settled = true;
95
117
  clearTimeout(timeout);
96
- try {
97
- fs.unlinkSync(scriptPath);
98
- }
99
- catch {
100
- /* best-effort */
101
- }
118
+ cleanup();
102
119
  if (exitCode === 0) {
103
120
  wsService.emitEphemeral(workspaceId, `${eventPrefix}:complete`, { hadOutput: outputEmitted });
104
121
  }
@@ -119,5 +136,11 @@ export function runScript(opts) {
119
136
  proc.on('close', (code) => {
120
137
  finish(code ?? 1);
121
138
  });
139
+ }).catch((err) => {
140
+ cleanup();
141
+ const message = err instanceof Error ? err.message : String(err);
142
+ wsService.emit(workspaceId, `${eventPrefix}:output`, { text: `[kobo] Script failed to start: ${message}` });
143
+ wsService.emitEphemeral(workspaceId, `${eventPrefix}:error`, { exitCode: 1, message });
144
+ return { exitCode: 1 };
122
145
  });
123
146
  }