@loicngr/kobo 1.11.37 → 1.11.39

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 (86) hide show
  1. package/AGENTS.md +3 -1
  2. package/CHANGELOG.md +8 -0
  3. package/dist/server/index.js +9 -0
  4. package/dist/server/routes/workspaces.js +39 -18
  5. package/dist/server/services/agent/engines/claude-code/engine.js +11 -0
  6. package/dist/server/services/agent/engines/codex/event-mapper.js +3 -1
  7. package/dist/server/services/agent/event-router.js +1 -1
  8. package/dist/server/services/agent/orchestrator.js +73 -10
  9. package/dist/server/services/auto-loop-service.js +3 -3
  10. package/dist/server/services/pr-watcher-service.js +5 -3
  11. package/dist/server/services/websocket-service.js +10 -2
  12. package/dist/server/services/workspace-service.js +8 -4
  13. package/package.json +2 -2
  14. package/src/client/dist/pwa/assets/{ActivityFeed-C_-hxIo8.js → ActivityFeed-BzzvcOmq.js} +2 -2
  15. package/src/client/dist/pwa/assets/{ActivityFeed-BDyFBoxd.css → ActivityFeed-Chsw1Mh7.css} +1 -1
  16. package/src/client/dist/pwa/assets/{ChangelogPage-CBlGJ4zT.js → ChangelogPage-D8563008.js} +1 -1
  17. package/src/client/dist/pwa/assets/ComparisonPanel-Bd0X08sg.js +2 -0
  18. package/src/client/dist/pwa/assets/ComparisonPanel-CRRaDs2D.css +1 -0
  19. package/src/client/dist/pwa/assets/{CreatePage-ArmtcF4d.js → CreatePage-Bon8qIka.js} +1 -1
  20. package/src/client/dist/pwa/assets/DashboardPage-D8g7_r4S.css +1 -0
  21. package/src/client/dist/pwa/assets/DashboardPage-DYxdxEul.js +1 -0
  22. package/src/client/dist/pwa/assets/{DiffViewer-CaT7vuSr.js → DiffViewer-BpsLNxnj.js} +3 -3
  23. package/src/client/dist/pwa/assets/{DocumentsPanel-A0isCTaI.js → DocumentsPanel-BJsJ0fNs.js} +1 -1
  24. package/src/client/dist/pwa/assets/{DrawerToggleButton-T70fh8dp.js → DrawerToggleButton-BVLO5K5d.js} +1 -1
  25. package/src/client/dist/pwa/assets/{GitPanel-CAvk0G7h.js → GitPanel-Cal_wzC0.js} +3 -3
  26. package/src/client/dist/pwa/assets/{HealthPage-D8Mr0V6w.js → HealthPage-RFZS7Lao.js} +1 -1
  27. package/src/client/dist/pwa/assets/{MainLayout-x6Es4Da-.js → MainLayout-vvyiRcql.js} +2 -2
  28. package/src/client/dist/pwa/assets/{QBtnDropdown-CQqzzlFI.js → QBtnDropdown-X8oNU5Mb.js} +1 -1
  29. package/src/client/dist/pwa/assets/{QDrawer-Cwj2qh0r.js → QDrawer-Cij17skX.js} +1 -1
  30. package/src/client/dist/pwa/assets/{QSelect-BSPtuXJg.js → QSelect-xd-AlGo_.js} +1 -1
  31. package/src/client/dist/pwa/assets/{QTooltip-DC_kDKku.js → QTooltip-CwcCGSWq.js} +1 -1
  32. package/src/client/dist/pwa/assets/{SchedulePanel-9aJSoz6Y.js → SchedulePanel-CXRUsz6G.js} +1 -1
  33. package/src/client/dist/pwa/assets/{SearchPage-BjNvtE_U.js → SearchPage-DopbgLxh.js} +1 -1
  34. package/src/client/dist/pwa/assets/{SettingsPage-CvLnT73V.js → SettingsPage-Jx6TnOzR.js} +1 -1
  35. package/src/client/dist/pwa/assets/{SplitWorkspacePage-DTao0DHr.js → SplitWorkspacePage-D7lGFRKs.js} +1 -1
  36. package/src/client/dist/pwa/assets/{TerminalPanel-BrKIW-bJ.js → TerminalPanel-DI8DHb9e.js} +1 -1
  37. package/src/client/dist/pwa/assets/{TouchPan-_n1AQgoo.js → TouchPan-B3nrkCY8.js} +1 -1
  38. package/src/client/dist/pwa/assets/{TourReplayButton-CJF3nVvn.js → TourReplayButton-DU1UShMY.js} +1 -1
  39. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-CxesUiIm.js → WorkspaceAttentionLabels-C-yN5IX8.js} +1 -1
  40. package/src/client/dist/pwa/assets/{WorkspacePage-DoXtwpjV.css → WorkspacePage-BAC6u9OP.css} +1 -1
  41. package/src/client/dist/pwa/assets/WorkspacePage-DKRpdpPg.js +4 -0
  42. package/src/client/dist/pwa/assets/{browser-DGWpkvN3.js → browser-f9MHQjfi.js} +1 -1
  43. package/src/client/dist/pwa/assets/{build-path-tree-Ba1dSLgi.js → build-path-tree-BWBzP8os.js} +1 -1
  44. package/src/client/dist/pwa/assets/{de-DVK21RB6.js → de-QkDLrcGq.js} +1 -1
  45. package/src/client/dist/pwa/assets/{documents-BMHzjW_g.js → documents-C2cLnEBQ.js} +1 -1
  46. package/src/client/dist/pwa/assets/{editor.api-CYTuNwqu.js → editor.api-sw146MWN.js} +1 -1
  47. package/src/client/dist/pwa/assets/{efforts-BPlRSHXp.js → efforts-C5ZZgCH4.js} +1 -1
  48. package/src/client/dist/pwa/assets/{engineFeatures-DVB2rklO.js → engineFeatures-CAVMDawq.js} +1 -1
  49. package/src/client/dist/pwa/assets/{es-C8cQwDZM.js → es-BgQFKlo8.js} +1 -1
  50. package/src/client/dist/pwa/assets/{expand-template-Cj82YE-O.js → expand-template-B6xAlAaM.js} +1 -1
  51. package/src/client/dist/pwa/assets/{format-Dk7O3LhA.js → format-BwWP6Aay.js} +1 -1
  52. package/src/client/dist/pwa/assets/{formatters-TGjvs7Ca.js → formatters-DyWtO-nR.js} +1 -1
  53. package/src/client/dist/pwa/assets/{fr-BVOMZ2Bl.js → fr-DAdLWjNM.js} +1 -1
  54. package/src/client/dist/pwa/assets/{freemarker2-39lO_dWH.js → freemarker2-DZ5Hm8UH.js} +1 -1
  55. package/src/client/dist/pwa/assets/{handlebars-M_0V82cD.js → handlebars-BRUGpTA7.js} +1 -1
  56. package/src/client/dist/pwa/assets/{html-yILqYDsl.js → html-DNOayNNs.js} +1 -1
  57. package/src/client/dist/pwa/assets/i18n-CBez_vip.js +1 -0
  58. package/src/client/dist/pwa/assets/index-VL3fI9a3.js +20 -0
  59. package/src/client/dist/pwa/assets/{it-CxkAoOj4.js → it-Bumaewp1.js} +1 -1
  60. package/src/client/dist/pwa/assets/{javascript-DrW-amDc.js → javascript-C0tDQU3z.js} +1 -1
  61. package/src/client/dist/pwa/assets/{liquid-D_mXmUoL.js → liquid-CN46O6OF.js} +1 -1
  62. package/src/client/dist/pwa/assets/{mdx-E8O3lvR5.js → mdx-DM-lrr8Q.js} +1 -1
  63. package/src/client/dist/pwa/assets/{monaco-contributions-C2K3sl_V.js → monaco-contributions-CkN7y-8c.js} +1 -1
  64. package/src/client/dist/pwa/assets/{monaco.contribution-DLhHQIJn.js → monaco.contribution-2BcAOLjc.js} +2 -2
  65. package/src/client/dist/pwa/assets/{network-auth-BUwWtnui.js → network-auth-KYS4snQH.js} +1 -1
  66. package/src/client/dist/pwa/assets/{notify-theme-1mOpY2vN.js → notify-theme-a91Tcr6o.js} +1 -1
  67. package/src/client/dist/pwa/assets/{pending-diff-open-BL7dKRqF.js → pending-diff-open-DOtELZfB.js} +1 -1
  68. package/src/client/dist/pwa/assets/{permissionModes-BtC3_P-P.js → permissionModes-C-9Crj-c.js} +1 -1
  69. package/src/client/dist/pwa/assets/{project-color-BFBFZWeG.js → project-color-BRe4oyNx.js} +1 -1
  70. package/src/client/dist/pwa/assets/{python-DoZAYim6.js → python-BRpJG6sZ.js} +1 -1
  71. package/src/client/dist/pwa/assets/{razor-9bZ30g2y.js → razor-CehCmvHl.js} +1 -1
  72. package/src/client/dist/pwa/assets/{render-chat-markdown-7EtWNjG0.js → render-chat-markdown-DlbQLZ02.js} +1 -1
  73. package/src/client/dist/pwa/assets/{typescript-C1CBfL3L.js → typescript-Dt_8mVuj.js} +1 -1
  74. package/src/client/dist/pwa/assets/{use-tours-BJ81clv6.js → use-tours-CSVM_54K.js} +1 -1
  75. package/src/client/dist/pwa/assets/{workspace-templates-r1-bipjM.js → workspace-templates-GCObcjO6.js} +1 -1
  76. package/src/client/dist/pwa/assets/{xml-COuu5isK.js → xml-_gOwdzB4.js} +1 -1
  77. package/src/client/dist/pwa/assets/{yaml-ChZ7WqaJ.js → yaml-BxeBqpGf.js} +1 -1
  78. package/src/client/dist/pwa/index.html +1 -1
  79. package/src/client/dist/pwa/sw.js +1 -1
  80. package/src/client/dist/pwa/assets/ComparisonPanel-DZAE2X9j.js +0 -2
  81. package/src/client/dist/pwa/assets/ComparisonPanel-x1iuICgd.css +0 -1
  82. package/src/client/dist/pwa/assets/DashboardPage-DsWn5cWl.css +0 -1
  83. package/src/client/dist/pwa/assets/DashboardPage-eQTCXcdz.js +0 -1
  84. package/src/client/dist/pwa/assets/WorkspacePage-MxBE0mr3.js +0 -4
  85. package/src/client/dist/pwa/assets/i18n-2UeMfd5m.js +0 -1
  86. package/src/client/dist/pwa/assets/index-BJU5dnkF.js +0 -20
package/AGENTS.md CHANGED
@@ -114,7 +114,9 @@ src/
114
114
  | `workspace_chat_history` | chat-input history per workspace: message text + `created_at`, ordered by autoincrement id, capped at 200 entries by the service; CASCADE DELETE on workspace |
115
115
  | `workspace_permission_rules` | remembered per-workspace tool approvals, scoped to an exact operation or every invocation of a tool; CASCADE DELETE on workspace |
116
116
 
117
- `status` enum: `created | extracting | brainstorming | executing | completed | idle | error | quota`. Transitions are validated in `updateWorkspaceStatus` against `VALID_TRANSITIONS`.
117
+ `status` enum: `created | extracting | brainstorming | executing | compacting | awaiting-user | completed | idle | error | quota`. Transitions are validated in `updateWorkspaceStatus` against `VALID_TRANSITIONS`.
118
+
119
+ `compacting` is a server-owned busy state while the agent compacts context. The orchestrator associates the previous status with the current controller and restores it when compaction ends; stop, errors and startup reconciliation must not leave the workspace stuck in compaction. Normal workspace status updates make this state available after reload and across clients. Chat sends are refused during compaction before delivery or fallback starts, and the client keeps the user's draft while disabling send. The existing status column is unrestricted TEXT, so adding this state does not change the database schema.
118
120
 
119
121
  `archived_at` is **orthogonal** to `status`. Archiving is a visibility flag, not a lifecycle state. Unarchive restores the exact pre-archive `status`.
120
122
 
package/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ All notable changes to Kōbō are documented here. The format is based on
4
4
  [Keep a Changelog](https://keepachangelog.com/). Each release is an `## <version>`
5
5
  section — the in-app "What's new" dialog reads this file.
6
6
 
7
+ ## 1.11.39
8
+
9
+ - fix(deps): require Claude SDK with Fable 5.1 support
10
+
11
+ ## 1.11.38
12
+
13
+ - feat(workspaces): show compaction status and block chat sends
14
+
7
15
  ## 1.11.37
8
16
 
9
17
  - fix(kobo): harden workspace lifecycle and comparison navigation
@@ -346,6 +346,15 @@ terminalWss.on('connection', (ws, workspaceId) => {
346
346
  setMessageHandler(async (type, payload) => {
347
347
  const p = payload;
348
348
  if (type === 'chat:message' && p?.workspaceId && p?.content) {
349
+ if (getWorkspace(p.workspaceId)?.status === 'compacting') {
350
+ emitEphemeral(p.workspaceId, 'chat:rejected', {
351
+ reason: 'compacting',
352
+ sessionId: p.sessionId,
353
+ content: p.content,
354
+ message: 'Workspace is compacting its context; wait until compaction finishes before sending a message',
355
+ });
356
+ return;
357
+ }
349
358
  // Auto-loop owns the agent's turns. A user message means the user wants
350
359
  // to redirect the conversation, so disable the loop (idempotent — the
351
360
  // `autoloop:disabled` event is emitted with reason='user-action' so the
@@ -71,6 +71,27 @@ for (const route of ['/:id/archive', '/:id/run-setup-script', '/:id/cancel-sourc
71
71
  }
72
72
  });
73
73
  }
74
+ // User prompt actions must reject before cancelling timers, stopping engines,
75
+ // creating a PR or persisting input. The orchestrator also checks at delivery.
76
+ for (const route of [
77
+ '/:id/start',
78
+ '/:id/switch-engine',
79
+ '/:id/git/commit-with-agent',
80
+ '/:id/git/resolve-with-agent',
81
+ '/:id/open-pr',
82
+ '/:id/start-review',
83
+ '/:id/start-ci-fix',
84
+ ]) {
85
+ app.use(route, async (c, next) => {
86
+ if (c.req.method === 'POST' && workspaceService.getWorkspace(c.req.param('id'))?.status === 'compacting') {
87
+ return c.json({
88
+ code: 'compacting',
89
+ error: 'Workspace is compacting its context; wait until compaction finishes before sending a message',
90
+ }, 409);
91
+ }
92
+ await next();
93
+ });
94
+ }
74
95
  /** Tracks workspaces currently running a setup script to prevent concurrent executions. */
75
96
  const setupScriptRunning = new Set();
76
97
  /**
@@ -310,6 +331,12 @@ app.post('/:id/switch-engine', migrationGuard, async (c) => {
310
331
  const handoff = typeof body.handoff === 'string' && body.handoff.trim()
311
332
  ? body.handoff.trim().slice(0, 30_000)
312
333
  : buildEngineHandoff(workspace, workspace.engine, body.engine);
334
+ if (workspaceService.getWorkspace(workspace.id)?.status === 'compacting') {
335
+ return c.json({
336
+ code: 'compacting',
337
+ error: 'Workspace is compacting its context; wait until compaction finishes before sending a message',
338
+ }, 409);
339
+ }
313
340
  // A new engine is started three lines below: the previous one must be dead
314
341
  // first, or two agents share the worktree.
315
342
  if (agentManager.hasController(id))
@@ -3180,6 +3207,12 @@ app.post('/:id/start', migrationGuard, async (c) => {
3180
3207
  const prompt = body.prompt ?? pendingInitialPrompt ?? 'Continue the previous task where you left off.';
3181
3208
  const agentSessionId = body.agentSessionId;
3182
3209
  const resume = body.resume === true;
3210
+ if (workspaceService.getWorkspace(id)?.status === 'compacting') {
3211
+ return c.json({
3212
+ code: 'compacting',
3213
+ error: 'Workspace is compacting its context; wait until compaction finishes before sending a message',
3214
+ }, 409);
3215
+ }
3183
3216
  // A manual start supersedes any scheduled retry (quota or transient
3184
3217
  // watchdog recovery). Leaving its timer armed could spawn a second agent
3185
3218
  // after the user has already resumed the workspace.
@@ -3831,7 +3864,6 @@ app.post('/:id/git/commit-with-agent', migrationGuard, async (c) => {
3831
3864
  5. Do NOT push the branch.
3832
3865
 
3833
3866
  When finished, report the commit SHA, its message, the files included, and the checks you ran.`;
3834
- wakeupService.cancel(workspace.id, 'user-message');
3835
3867
  let messageSent = false;
3836
3868
  try {
3837
3869
  const { agentSessionId } = await deliverAgentPrompt(workspace, workspace.worktreePath, prompt);
@@ -3914,9 +3946,6 @@ ${fileList}
3914
3946
  6. Report the summary: which files you touched, the key decisions you made, and the final test result.${cherryPickNote}
3915
3947
 
3916
3948
  Start now.`;
3917
- // Cancel any pending wakeup: the user is driving this turn, the
3918
- // scheduler should not also wake the agent a few minutes later.
3919
- wakeupService.cancel(workspace.id, 'user-message');
3920
3949
  let messageSent = false;
3921
3950
  try {
3922
3951
  const { agentSessionId } = await deliverAgentPrompt(workspace, worktreePath, prompt);
@@ -4251,8 +4280,6 @@ app.post('/:id/open-pr', async (c) => {
4251
4280
  diffStats,
4252
4281
  tasks,
4253
4282
  });
4254
- // Cancel any pending wakeup: the user is driving this turn.
4255
- wakeupService.cancel(workspace.id, 'user-message');
4256
4283
  // Send to the running agent, or resume the agent with the PR prompt
4257
4284
  let messageSent = false;
4258
4285
  try {
@@ -4320,14 +4347,14 @@ app.post('/:id/start-review', async (c) => {
4320
4347
  baseCommit,
4321
4348
  additionalInstructions,
4322
4349
  });
4323
- try {
4324
- wakeupService.cancel(workspace.id, 'user-message');
4325
- }
4326
- catch {
4327
- /* swallow */
4328
- }
4329
4350
  let messageSent = false;
4330
4351
  let emitSessionId;
4352
+ if (workspaceService.getWorkspace(workspace.id)?.status === 'compacting') {
4353
+ return c.json({
4354
+ code: 'compacting',
4355
+ error: 'Workspace is compacting its context; wait until compaction finishes before sending a message',
4356
+ }, 409);
4357
+ }
4331
4358
  if (newSession) {
4332
4359
  // Stop current agent, wait for it, then start fresh.
4333
4360
  try {
@@ -4414,12 +4441,6 @@ app.post('/:id/start-ci-fix', migrationGuard, async (c) => {
4414
4441
  failedChecks,
4415
4442
  ciRunUrl,
4416
4443
  });
4417
- try {
4418
- wakeupService.cancel(workspace.id, 'user-message');
4419
- }
4420
- catch {
4421
- /* swallow */
4422
- }
4423
4444
  const session = workspaceService.getActiveSession(workspace.id);
4424
4445
  let emitSessionId = session?.id;
4425
4446
  try {
@@ -353,6 +353,7 @@ export function createClaudeCodeEngine() {
353
353
  return;
354
354
  console.warn(`[claude-engine] Compaction still reported active ${COMPACTION_STALL_TIMEOUT_MS}ms after it started — resuming the liveness deadline.`);
355
355
  isCompacting = false;
356
+ safeEmit({ kind: 'session:compacting', active: false });
356
357
  reevaluateLivenessPause();
357
358
  if (activeSubagentTaskIds.size > 0 && !subagentStallTimer) {
358
359
  // Resume declined because a subagent is still tracked — but the
@@ -468,6 +469,16 @@ export function createClaudeCodeEngine() {
468
469
  clearCompactionStallTimer();
469
470
  }
470
471
  }
472
+ // Actual foreground output proves work resumed even if the SDK
473
+ // omitted its trailing status/compact_boundary message. Tool results
474
+ // and background progress can drain during compaction, so exclude them.
475
+ if (isCompacting &&
476
+ !('parent_tool_use_id' in msg && msg.parent_tool_use_id != null) &&
477
+ events.some((ev) => ev.kind === 'message:text' || ev.kind === 'message:thinking' || ev.kind === 'tool:call')) {
478
+ isCompacting = false;
479
+ clearCompactionStallTimer();
480
+ safeEmit({ kind: 'session:compacting', active: false });
481
+ }
471
482
  for (const ev of events) {
472
483
  if (ev.kind === 'tool:call')
473
484
  pendingToolCallIds.add(ev.toolCallId);
@@ -179,7 +179,7 @@ export function handleItemStarted(item, state) {
179
179
  return events;
180
180
  }
181
181
  if (item.type === 'contextCompaction') {
182
- events.push({ kind: 'session:compacted' });
182
+ events.push({ kind: 'session:compacting', active: true });
183
183
  return events;
184
184
  }
185
185
  return events;
@@ -189,6 +189,8 @@ export function handleItemStarted(item, state) {
189
189
  */
190
190
  export function handleItemCompleted(item, state) {
191
191
  const events = [];
192
+ if (item.type === 'contextCompaction')
193
+ return [{ kind: 'session:compacted' }];
192
194
  if (item.type === 'agentMessage') {
193
195
  const text = item.text ?? '';
194
196
  const messageId = scopedId(state, item.id);
@@ -3,7 +3,7 @@ export function routeEvent(workspaceId, agentSessionId, event) {
3
3
  // The compaction "in progress" signal is a transient live indicator — deliver
4
4
  // it once and never persist/replay it. The persistent trace is `session:compacted`.
5
5
  if (event.kind === 'session:compacting') {
6
- emitEphemeral(workspaceId, 'agent:event', event);
6
+ emitEphemeral(workspaceId, 'agent:event', event, agentSessionId);
7
7
  return;
8
8
  }
9
9
  if (event.kind === 'mcp:status' && event.status !== 'error') {
@@ -131,6 +131,25 @@ const pendingQueue = new Map();
131
131
  * returns to that status instead of being yanked to `executing`.
132
132
  */
133
133
  const preAwaitStatus = new Map();
134
+ /** The lifecycle phase hidden by this controller's temporary compaction. */
135
+ const preCompactionStatus = new Map();
136
+ function finishCompaction(workspaceId, controller) {
137
+ const previous = preCompactionStatus.get(controller);
138
+ preCompactionStatus.delete(controller);
139
+ if (getWs(workspaceId)?.status !== 'compacting')
140
+ return;
141
+ const restored = peekPending(workspaceId)
142
+ ? 'awaiting-user'
143
+ : previous === 'awaiting-user'
144
+ ? consumePreAwaitStatus(workspaceId)
145
+ : (previous ?? 'executing');
146
+ updateWorkspaceStatus(workspaceId, restored);
147
+ }
148
+ export function assertWorkspaceNotCompacting(workspaceId) {
149
+ if (getWs(workspaceId)?.status === 'compacting') {
150
+ throw new Error('Workspace is compacting its context; wait until compaction finishes before sending a message');
151
+ }
152
+ }
134
153
  function enqueuePending(workspaceId, item) {
135
154
  const arr = pendingQueue.get(workspaceId) ?? [];
136
155
  arr.push(item);
@@ -198,6 +217,13 @@ function rememberPreAwaitStatus(workspaceId) {
198
217
  return;
199
218
  if (ws.status === 'awaiting-user')
200
219
  return;
220
+ if (ws.status === 'compacting') {
221
+ const controller = controllers.get(workspaceId);
222
+ const previous = controller && preCompactionStatus.get(controller);
223
+ if (previous && previous !== 'awaiting-user')
224
+ preAwaitStatus.set(workspaceId, previous);
225
+ return;
226
+ }
201
227
  preAwaitStatus.set(workspaceId, ws.status);
202
228
  }
203
229
  /**
@@ -298,6 +324,7 @@ function runWatchdog() {
298
324
  controllers.delete(workspaceId);
299
325
  notifyCapacityAvailable(workspaceId);
300
326
  }
327
+ preCompactionStatus.delete(ctrl);
301
328
  retryCounts.delete(workspaceId);
302
329
  // This end never goes through handleEvent → onSessionEnded, so the user's
303
330
  // hook would otherwise miss precisely the case it is most useful for.
@@ -363,7 +390,7 @@ export function reconcileOrphanSessions() {
363
390
  const db = getDb();
364
391
  const result = db
365
392
  .prepare(`UPDATE workspaces SET status = 'idle', updated_at = ?
366
- WHERE status IN ('executing', 'brainstorming', 'extracting', 'awaiting-user')`)
393
+ WHERE status IN ('executing', 'brainstorming', 'extracting', 'awaiting-user', 'compacting')`)
367
394
  .run(now);
368
395
  if (result.changes > 0) {
369
396
  console.log(`[orchestrator] Reconciled ${result.changes} orphan workspace status(es) at boot.`);
@@ -723,8 +750,24 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
723
750
  // `awaiting-user`/`executing`. Every branch that does one of those must
724
751
  // check this guard.
725
752
  const sourceControllerIsStopping = sourceController?.status === 'stopping';
753
+ if (sourceControllerIsStopping && ev.kind === 'session:compacting')
754
+ return;
726
755
  const routedEvent = ev.kind === 'session:ended' && (hasReplacement || sourceIsSuperseded) ? { ...ev, superseded: true } : ev;
727
756
  routeEvent(workspaceId, agentSessionId, routedEvent);
757
+ if (sourceController && !sourceControllerIsStopping) {
758
+ if (ev.kind === 'session:compacting' && ev.active) {
759
+ const status = getWs(workspaceId)?.status;
760
+ if (status && ['extracting', 'brainstorming', 'executing', 'awaiting-user'].includes(status)) {
761
+ preCompactionStatus.set(sourceController, status);
762
+ updateWorkspaceStatus(workspaceId, 'compacting');
763
+ }
764
+ }
765
+ else if ((ev.kind === 'session:compacting' && !ev.active) ||
766
+ ev.kind === 'session:compacted' ||
767
+ ev.kind === 'error') {
768
+ finishCompaction(workspaceId, sourceController);
769
+ }
770
+ }
728
771
  // A fresh tool invocation proves this controller resumed working (for
729
772
  // example after an account change). Tool results can still drain after a
730
773
  // rejection, so they must never count as recovery.
@@ -815,7 +858,9 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
815
858
  // nothing but this status transition (no separate bookkeeping to preserve),
816
859
  // so it is entirely behind the guard; the event itself is still
817
860
  // persisted/broadcast unconditionally via `routeEvent` above.
818
- if (ev.kind === 'session:brainstorm-complete' && !sourceControllerIsStopping) {
861
+ if (ev.kind === 'session:brainstorm-complete' &&
862
+ !sourceControllerIsStopping &&
863
+ getWs(workspaceId)?.status !== 'compacting') {
819
864
  try {
820
865
  const ws = getWs(workspaceId);
821
866
  if (ws && ws.status !== 'executing') {
@@ -930,7 +975,8 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
930
975
  }
931
976
  rememberPreAwaitStatus(workspaceId);
932
977
  try {
933
- updateWorkspaceStatus(workspaceId, 'awaiting-user');
978
+ if (getWs(workspaceId)?.status !== 'compacting')
979
+ updateWorkspaceStatus(workspaceId, 'awaiting-user');
934
980
  }
935
981
  catch (err) {
936
982
  console.warn('[orchestrator] Failed to transition to awaiting-user:', err);
@@ -1007,6 +1053,8 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
1007
1053
  }
1008
1054
  const registeredController = controllers.get(workspaceId);
1009
1055
  const isSuperseded = sourceController !== undefined && registeredController !== undefined && registeredController !== sourceController;
1056
+ if (sourceController)
1057
+ preCompactionStatus.delete(sourceController);
1010
1058
  if (isSuperseded)
1011
1059
  return false;
1012
1060
  // A superseded end (above) is a session that was replaced, not one that
@@ -1093,6 +1141,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1093
1141
  const workspace = getWs(workspaceId);
1094
1142
  if (!workspace)
1095
1143
  throw new Error(`Workspace '${workspaceId}' not found`);
1144
+ assertWorkspaceNotCompacting(workspaceId);
1096
1145
  if (workspace.archivedAt)
1097
1146
  throw new Error(`Workspace '${workspaceId}' is archived`);
1098
1147
  if (workspace.worktreePurgedAt)
@@ -1272,6 +1321,7 @@ export function interruptAgent(workspaceId, options = {}) {
1272
1321
  export const STOP_AGENT_TIMEOUT_MS = 15_000;
1273
1322
  async function stopController(workspaceId, ctrl, cause = 'user') {
1274
1323
  ctrl.stopCause = cause;
1324
+ preCompactionStatus.delete(ctrl);
1275
1325
  // Normalize the state synchronously so callers (archive, delete, manual
1276
1326
  // stop) see a clean workspace immediately — without waiting for the async
1277
1327
  // controller.stop() → session:ended round-trip, which onSessionEnded skips
@@ -1288,9 +1338,9 @@ async function stopController(workspaceId, ctrl, cause = 'user') {
1288
1338
  // Steps 1-2 are `awaiting-user`-specific: they only make sense when the
1289
1339
  // session actually left a pending question/permission behind.
1290
1340
  const wsBefore = getWs(workspaceId);
1291
- const activeStatuses = ['executing', 'brainstorming', 'extracting', 'awaiting-user'];
1341
+ const activeStatuses = ['executing', 'brainstorming', 'extracting', 'awaiting-user', 'compacting'];
1292
1342
  if (wsBefore && activeStatuses.includes(wsBefore.status)) {
1293
- if (wsBefore.status === 'awaiting-user') {
1343
+ if (wsBefore.status === 'awaiting-user' || wsBefore.status === 'compacting') {
1294
1344
  clearPendingForSession(workspaceId, ctrl.agentSessionId);
1295
1345
  purgeAllPersistedUserInputRequests(workspaceId, ctrl.agentSessionId);
1296
1346
  }
@@ -1432,7 +1482,7 @@ export function sendWakeupIfWaiting(workspaceId, content, expectedSessionId) {
1432
1482
  const workspace = getWs(workspaceId);
1433
1483
  if (!workspace || workspace.archivedAt || workspace.worktreePurgedAt)
1434
1484
  return false;
1435
- if (workspace.status === 'awaiting-user' || workspace.status === 'quota')
1485
+ if (workspace.status === 'awaiting-user' || workspace.status === 'quota' || workspace.status === 'compacting')
1436
1486
  return false;
1437
1487
  if (!ctrl.engineProcess?.sendWakeupIfWaiting?.(content))
1438
1488
  return false;
@@ -1441,6 +1491,7 @@ export function sendWakeupIfWaiting(workspaceId, content, expectedSessionId) {
1441
1491
  }
1442
1492
  /** Write a user message to the running agent. */
1443
1493
  export async function sendMessage(workspaceId, content, expectedSessionId) {
1494
+ assertWorkspaceNotCompacting(workspaceId);
1444
1495
  const ctrl = controllers.get(workspaceId);
1445
1496
  if (!ctrl) {
1446
1497
  throw new Error(`No agent running for workspace '${workspaceId}'`);
@@ -1452,15 +1503,17 @@ export async function sendMessage(workspaceId, content, expectedSessionId) {
1452
1503
  await ctrl.sendMessage(content);
1453
1504
  }
1454
1505
  export async function sendMessageForFallback(workspaceId, content) {
1506
+ assertWorkspaceNotCompacting(workspaceId);
1455
1507
  const capturedController = controllers.get(workspaceId);
1508
+ wakeupService.cancel(workspaceId, 'user-message');
1456
1509
  if (!capturedController)
1457
1510
  return { status: 'stopped' };
1458
- wakeupService.cancel(workspaceId, 'user-message');
1459
1511
  try {
1460
1512
  await capturedController.sendMessage(content);
1461
1513
  return { status: 'sent', sessionId: capturedController.agentSessionId };
1462
1514
  }
1463
1515
  catch {
1516
+ assertWorkspaceNotCompacting(workspaceId);
1464
1517
  const deadline = Date.now() + FALLBACK_CONTROLLER_TURNOVER_TIMEOUT_MS;
1465
1518
  while (controllers.get(workspaceId) === capturedController) {
1466
1519
  const remaining = deadline - Date.now();
@@ -1474,6 +1527,7 @@ export async function sendMessageForFallback(workspaceId, content) {
1474
1527
  const replacementController = controllers.get(workspaceId);
1475
1528
  if (!replacementController)
1476
1529
  return { status: 'stopped' };
1530
+ assertWorkspaceNotCompacting(workspaceId);
1477
1531
  await replacementController.sendMessage(content);
1478
1532
  return { status: 'sent', sessionId: replacementController.agentSessionId };
1479
1533
  }
@@ -1593,7 +1647,10 @@ export async function answerPendingQuestion(workspaceId, answers, expectedToolCa
1593
1647
  purgePersistedUserInputRequest(workspaceId, head.toolCallId);
1594
1648
  const restoreTo = peekPending(workspaceId) ? 'awaiting-user' : consumePreAwaitStatus(workspaceId);
1595
1649
  try {
1596
- updateWorkspaceStatus(workspaceId, restoreTo);
1650
+ if (getWs(workspaceId)?.status === 'compacting')
1651
+ preCompactionStatus.set(ctrl, restoreTo);
1652
+ else
1653
+ updateWorkspaceStatus(workspaceId, restoreTo);
1597
1654
  }
1598
1655
  catch (err) {
1599
1656
  console.warn(`[orchestrator] Failed to transition awaiting-user → ${restoreTo}:`, err);
@@ -1681,7 +1738,10 @@ export async function answerPendingPermission(workspaceId, decision) {
1681
1738
  purgePersistedUserInputRequest(workspaceId, head.toolCallId);
1682
1739
  const restoreTo = peekPending(workspaceId) ? 'awaiting-user' : consumePreAwaitStatus(workspaceId);
1683
1740
  try {
1684
- updateWorkspaceStatus(workspaceId, restoreTo);
1741
+ if (getWs(workspaceId)?.status === 'compacting')
1742
+ preCompactionStatus.set(ctrl, restoreTo);
1743
+ else
1744
+ updateWorkspaceStatus(workspaceId, restoreTo);
1685
1745
  }
1686
1746
  catch (err) {
1687
1747
  console.warn(`[orchestrator] Failed to transition awaiting-user → ${restoreTo}:`, err);
@@ -1739,7 +1799,10 @@ export async function cancelPendingQuestion(workspaceId, reason, expectedToolCal
1739
1799
  purgePersistedUserInputRequest(workspaceId, head.toolCallId);
1740
1800
  const restoreTo = peekPending(workspaceId) ? 'awaiting-user' : consumePreAwaitStatus(workspaceId);
1741
1801
  try {
1742
- updateWorkspaceStatus(workspaceId, restoreTo);
1802
+ if (getWs(workspaceId)?.status === 'compacting')
1803
+ preCompactionStatus.set(ctrl, restoreTo);
1804
+ else
1805
+ updateWorkspaceStatus(workspaceId, restoreTo);
1743
1806
  }
1744
1807
  catch (err) {
1745
1808
  console.warn(`[orchestrator] Failed to transition awaiting-user → ${restoreTo}:`, err);
@@ -131,7 +131,7 @@ export function onSessionEnded(workspaceId, reason, taskProgressDelta) {
131
131
  return;
132
132
  // Don't spawn a competing session while paused on canUseTool — the user
133
133
  // will resume the deferred turn explicitly.
134
- if (row.status === 'awaiting-user')
134
+ if (row.status === 'awaiting-user' || row.status === 'compacting')
135
135
  return;
136
136
  // A watchdog denotes Kōbō's own forced recovery from a stuck engine stream.
137
137
  // The orchestrator owns its bounded backoff/retry path; counting it as a
@@ -315,7 +315,7 @@ export function resumeWaitingWorkspaces(excluded = new Set()) {
315
315
  continue;
316
316
  // Same guards as spawnNextIteration's own callers: a quota backoff or a
317
317
  // pending question owns the next start of that workspace.
318
- if (row.status === 'awaiting-user' || row.status === 'quota')
318
+ if (row.status === 'awaiting-user' || row.status === 'compacting' || row.status === 'quota')
319
319
  continue;
320
320
  try {
321
321
  spawnNextIteration(row.id);
@@ -337,7 +337,7 @@ function spawnNextIteration(workspaceId, opts = {}) {
337
337
  if (orchestrator.hasController(workspaceId))
338
338
  return;
339
339
  // Same guard as onSessionEnded — never race a deferred-resume start.
340
- if (row.status === 'awaiting-user')
340
+ if (row.status === 'awaiting-user' || row.status === 'compacting')
341
341
  return;
342
342
  const task = pickNextTask(workspaceId);
343
343
  if (!task) {
@@ -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);
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loicngr/kobo",
3
- "version": "1.11.37",
3
+ "version": "1.11.39",
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",
@@ -68,7 +68,7 @@
68
68
  "prepublishOnly": "npm run build"
69
69
  },
70
70
  "dependencies": {
71
- "@anthropic-ai/claude-agent-sdk": "^0.3.252",
71
+ "@anthropic-ai/claude-agent-sdk": "^0.3.267",
72
72
  "@emnapi/core": "^1.11.3",
73
73
  "@emnapi/runtime": "^1.11.3",
74
74
  "@hono/node-server": "^2.1.0",