@loicngr/kobo 1.7.17 → 1.7.19

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 (85) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/server/db/migrations.js +19 -0
  3. package/dist/server/db/schema.js +1 -0
  4. package/dist/server/routes/settings.js +2 -1
  5. package/dist/server/routes/workspaces.js +227 -55
  6. package/dist/server/services/agent/orchestrator.js +1 -0
  7. package/dist/server/services/ci-fix-template-service.js +34 -0
  8. package/dist/server/services/pr-watcher-service.js +25 -4
  9. package/dist/server/services/settings-service.js +37 -0
  10. package/dist/server/services/workspace-service.js +18 -0
  11. package/dist/server/utils/branch-resolver.js +48 -0
  12. package/package.json +1 -1
  13. package/src/client/dist/spa/assets/{ActivityFeed-CDhLuhI3.js → ActivityFeed-4FmMcUzT.js} +1 -1
  14. package/src/client/dist/spa/assets/ChangelogPage-BTIcoTHM.js +1 -0
  15. package/src/client/dist/spa/assets/ChangelogPage-CQ33An2f.css +1 -0
  16. package/src/client/dist/spa/assets/{ClosePopup-A-tSm4aa.js → ClosePopup-cTUqStmn.js} +1 -1
  17. package/src/client/dist/spa/assets/CreatePage-DNaNhVRI.js +2 -0
  18. package/src/client/dist/spa/assets/CreatePage-DcqSlkD5.css +1 -0
  19. package/src/client/dist/spa/assets/{DiffViewer-CcgF65Mo.js → DiffViewer-CyQ3xZ9y.js} +3 -3
  20. package/src/client/dist/spa/assets/{HealthPage-Bw-9__wY.js → HealthPage-DeVdQ57j.js} +1 -1
  21. package/src/client/dist/spa/assets/{MainLayout-KEr19FOv.css → MainLayout-Cq4nlTx3.css} +1 -1
  22. package/src/client/dist/spa/assets/{MainLayout-C2ULeep-.js → MainLayout-Dbh8sZj9.js} +4 -4
  23. package/src/client/dist/spa/assets/QChip-D2TVel5I.js +1 -0
  24. package/src/client/dist/spa/assets/{QExpansionItem-CgJQdznK.js → QExpansionItem-DEwaFJ-l.js} +1 -1
  25. package/src/client/dist/spa/assets/{QMenu-NVDU7D3u.js → QMenu-DqPMRjtc.js} +1 -1
  26. package/src/client/dist/spa/assets/QScrollArea-BRZX618r.js +1 -0
  27. package/src/client/dist/spa/assets/QScrollObserver-B-U-JUNE.js +1 -0
  28. package/src/client/dist/spa/assets/{QTooltip-BC7PnZJ1.js → QTooltip-CayfN3AV.js} +1 -1
  29. package/src/client/dist/spa/assets/{SearchPage-D2x2X7K7.js → SearchPage-CoStu9JM.js} +1 -1
  30. package/src/client/dist/spa/assets/SettingsPage-BGHYXEmO.js +9 -0
  31. package/src/client/dist/spa/assets/{SettingsPage-BTGPZaqC.css → SettingsPage-DV10Se1K.css} +1 -1
  32. package/src/client/dist/spa/assets/{TouchPan-D0fJnlOC.js → TouchPan-CPNDMpOw.js} +1 -1
  33. package/src/client/dist/spa/assets/WorkspacePage-BWMcLN6l.js +4 -0
  34. package/src/client/dist/spa/assets/WorkspacePage-iHGFGSo5.css +1 -0
  35. package/src/client/dist/spa/assets/{build-path-tree-CyqReJkk.js → build-path-tree-C-c8LVcf.js} +1 -1
  36. package/src/client/dist/spa/assets/{cssMode-BsT_HBz-.js → cssMode-iA5eJq1h.js} +1 -1
  37. package/src/client/dist/spa/assets/{editor.api-CIxiApSC.js → editor.api-B85Aec2m.js} +1 -1
  38. package/src/client/dist/spa/assets/{editor.main-D-1-e3_n.js → editor.main-D55xuhHL.js} +3 -3
  39. package/src/client/dist/spa/assets/{engineFeatures-baMvMT98.js → engineFeatures-BIgXMO3j.js} +1 -1
  40. package/src/client/dist/spa/assets/{expand-template-CF0lBr4L.js → expand-template-B3Mua3eb.js} +1 -1
  41. package/src/client/dist/spa/assets/{freemarker2-q2PyKiM2.js → freemarker2-D_SY8XAy.js} +1 -1
  42. package/src/client/dist/spa/assets/{handlebars-DoaZIK6r.js → handlebars-DYHJKLiX.js} +1 -1
  43. package/src/client/dist/spa/assets/{html-DHcse-fd.js → html-CBV84hsj.js} +1 -1
  44. package/src/client/dist/spa/assets/{htmlMode-DPZCU7DB.js → htmlMode-oAFa-pC8.js} +1 -1
  45. package/src/client/dist/spa/assets/i18n-BSaDh2Jk.js +1 -0
  46. package/src/client/dist/spa/assets/index-Dxua-s9Z.js +52 -0
  47. package/src/client/dist/spa/assets/{javascript-Ddw7c3eO.js → javascript-Dtki-9M1.js} +1 -1
  48. package/src/client/dist/spa/assets/{jsonMode-vmAmvg_N.js → jsonMode-Dxgt3z_U.js} +1 -1
  49. package/src/client/dist/spa/assets/kobo-commands-ItyV88dT.js +9 -0
  50. package/src/client/dist/spa/assets/{liquid-Bwz3vr4k.js → liquid-AqH0N0cI.js} +1 -1
  51. package/src/client/dist/spa/assets/{mdx-B2uBtVef.js → mdx-B2rkMIhO.js} +1 -1
  52. package/src/client/dist/spa/assets/{monaco.contribution-B3cRHiXp.js → monaco.contribution-fnrc8bv7.js} +2 -2
  53. package/src/client/dist/spa/assets/{notifications-Hq-6rEYv.js → notifications-BmNlCl7A.js} +1 -1
  54. package/src/client/dist/spa/assets/permissionModes-B2nlp6IT.js +1 -0
  55. package/src/client/dist/spa/assets/{python-D5ykDuc8.js → python-DoQszzJj.js} +1 -1
  56. package/src/client/dist/spa/assets/{razor-CStvtec5.js → razor-CLSwglaW.js} +1 -1
  57. package/src/client/dist/spa/assets/render-chat-markdown-B3NEX5ju.js +60 -0
  58. package/src/client/dist/spa/assets/skill-suite-prompts-8f_JW79j.js +36 -0
  59. package/src/client/dist/spa/assets/{tsMode-WEgYYKFt.js → tsMode-CshiJr76.js} +1 -1
  60. package/src/client/dist/spa/assets/{typescript-BzFHuirT.js → typescript-CCaWpP5V.js} +1 -1
  61. package/src/client/dist/spa/assets/{use-onboarding-C98jCHZu.js → use-onboarding-DxlBVNno.js} +1 -1
  62. package/src/client/dist/spa/assets/{xml-NuCdCQMI.js → xml-WKm1dO8L.js} +1 -1
  63. package/src/client/dist/spa/assets/{yaml-CkTTgcUh.js → yaml-CSs38mRE.js} +1 -1
  64. package/src/client/dist/spa/index.html +2 -2
  65. package/src/client/dist/spa/assets/CreatePage-DgHjL4cZ.css +0 -1
  66. package/src/client/dist/spa/assets/CreatePage-xIjxPliD.js +0 -2
  67. package/src/client/dist/spa/assets/QChip-DnJyQVs2.js +0 -36
  68. package/src/client/dist/spa/assets/QScrollArea-_Ji1cgqL.js +0 -1
  69. package/src/client/dist/spa/assets/SettingsPage-BLb9B9iY.js +0 -9
  70. package/src/client/dist/spa/assets/WorkspacePage-BlAVs03z.js +0 -4
  71. package/src/client/dist/spa/assets/WorkspacePage-DTV0oWHS.css +0 -1
  72. package/src/client/dist/spa/assets/i18n-DwzfgKc3.js +0 -1
  73. package/src/client/dist/spa/assets/index-DMUFfCIq.js +0 -52
  74. package/src/client/dist/spa/assets/kobo-commands-B2AhWe1S.js +0 -9
  75. package/src/client/dist/spa/assets/permissionModes-DuwIe4ty.js +0 -1
  76. package/src/client/dist/spa/assets/render-chat-markdown-BywKNkXe.js +0 -60
  77. /package/src/client/dist/spa/assets/{QBadge-NEwszYs7.js → QBadge-DNQSqPg-.js} +0 -0
  78. /package/src/client/dist/spa/assets/{QBanner-Jsq4uJZs.js → QBanner-C_TvxtXi.js} +0 -0
  79. /package/src/client/dist/spa/assets/{QList-B3TuWSqL.js → QList-CRYZxnPD.js} +0 -0
  80. /package/src/client/dist/spa/assets/{QPage-DO_bQyV_.js → QPage-3-ah4oor.js} +0 -0
  81. /package/src/client/dist/spa/assets/{QSpace-DONPiIes.js → QSpace-Crcx82On.js} +0 -0
  82. /package/src/client/dist/spa/assets/{_plugin-vue_export-helper-Cj6tcsj6.js → _plugin-vue_export-helper-r4mAJOHR.js} +0 -0
  83. /package/src/client/dist/spa/assets/{documents-B3nitIYF.js → documents-CX2-4fhr.js} +0 -0
  84. /package/src/client/dist/spa/assets/{formatters-9dcj2tyJ.js → formatters-BKR66G3t.js} +0 -0
  85. /package/src/client/dist/spa/assets/{use-quasar-k24tGxE-.js → use-quasar-q6dh7QVJ.js} +0 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,20 @@ 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.7.19
8
+
9
+ - feat(client): archived banner, Fix-CI button, changelog page, prompt-retry banner (Open archived workspace)
10
+ - feat(server): workspace lifecycle, CI failure UX, collision-safe creation (error in setup script)
11
+ - feat(client): disable mutating actions on archived workspaces
12
+ - feat(pr-watcher): mark workspace unread on attention transitions (ci request changes)
13
+ - chore(deps): npm audit fix
14
+ - chore(CHANGELOG): update
15
+
16
+ ## 1.7.18
17
+
18
+ - chore(audit): fix npm audit
19
+ - feat(client): collapsible ask-user-question panel
20
+
7
21
  ## 1.7.17
8
22
 
9
23
  - feat: per-workspace chat history + inline file editing in the diff viewer
@@ -300,6 +300,25 @@ export const migrations = [
300
300
  `);
301
301
  },
302
302
  },
303
+ {
304
+ version: 25,
305
+ name: 'add-workspace-initial-prompt',
306
+ migrate: (db) => {
307
+ // Stores the initial agent prompt assembled at workspace-creation time so
308
+ // it survives a setup-script crash. Cleared after the agent successfully
309
+ // ingests it; null otherwise (= nothing pending or already consumed).
310
+ // Defensive: skip if the workspaces table doesn't exist yet — covers
311
+ // synthetic test DBs that seed only a subset of tables before running
312
+ // migrations.
313
+ const table = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='workspaces'").get();
314
+ if (!table)
315
+ return;
316
+ const cols = db.prepare('PRAGMA table_info(workspaces)').all();
317
+ if (!cols.some((c) => c.name === 'initial_prompt')) {
318
+ db.prepare('ALTER TABLE workspaces ADD COLUMN initial_prompt TEXT').run();
319
+ }
320
+ },
321
+ },
303
322
  ];
304
323
  /** Current schema version — always equals the highest migration version. */
305
324
  export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
@@ -29,6 +29,7 @@ export function initSchema(db) {
29
29
  agent_permission_mode TEXT NOT NULL DEFAULT 'bypass',
30
30
  description TEXT,
31
31
  agent_description TEXT,
32
+ initial_prompt TEXT,
32
33
  created_at TEXT NOT NULL,
33
34
  updated_at TEXT NOT NULL
34
35
  );
@@ -3,7 +3,7 @@ import { DEFAULT_NOTION_INITIAL_PROMPT, DEFAULT_SENTRY_INITIAL_PROMPT, } from '.
3
3
  import { DEFAULT_REVIEW_PROMPT_TEMPLATE } from '../services/review-template-service.js';
4
4
  import { DEFAULT_CHANGE_SOURCE_BRANCH_SCRIPT } from '../services/settings-defaults.js';
5
5
  import * as settingsService from '../services/settings-service.js';
6
- import { DEFAULT_GIT_CONVENTIONS, DEFAULT_PR_PROMPT_TEMPLATE, } from '../services/settings-service.js';
6
+ import { DEFAULT_CI_FIX_PROMPT_TEMPLATE, DEFAULT_GIT_CONVENTIONS, DEFAULT_PR_PROMPT_TEMPLATE, } from '../services/settings-service.js';
7
7
  import { listTemplates, replaceAllTemplates } from '../services/templates-service.js';
8
8
  /** Hono sub-router for global and per-project settings CRUD. */
9
9
  const app = new Hono();
@@ -37,6 +37,7 @@ app.get('/defaults', (c) => {
37
37
  return c.json({
38
38
  prPromptTemplate: DEFAULT_PR_PROMPT_TEMPLATE,
39
39
  reviewPromptTemplate: DEFAULT_REVIEW_PROMPT_TEMPLATE,
40
+ ciFixPromptTemplate: DEFAULT_CI_FIX_PROMPT_TEMPLATE,
40
41
  gitConventions: DEFAULT_GIT_CONVENTIONS,
41
42
  notionInitialPromptTemplate: DEFAULT_NOTION_INITIAL_PROMPT,
42
43
  sentryInitialPromptTemplate: DEFAULT_SENTRY_INITIAL_PROMPT,
@@ -13,6 +13,7 @@ import * as archiveScriptService from '../services/archive-script-service.js';
13
13
  import * as autoLoopService from '../services/auto-loop-service.js';
14
14
  import { changeSourceBranch } from '../services/change-source-branch-service.js';
15
15
  import { listChatHistory, pushChatHistory } from '../services/chat-history-service.js';
16
+ import { renderCiFixTemplate } from '../services/ci-fix-template-service.js';
16
17
  import * as cronService from '../services/cron-service.js';
17
18
  import * as devServerService from '../services/dev-server-service.js';
18
19
  import { saveWorkspaceFile, shaOf } from '../services/file-editor-service.js';
@@ -35,9 +36,10 @@ import * as wakeupService from '../services/wakeup-service.js';
35
36
  import * as wsService from '../services/websocket-service.js';
36
37
  import * as workspaceService from '../services/workspace-service.js';
37
38
  import * as worktreeService from '../services/worktree-service.js';
39
+ import { resolveUniqueBranchAndPath } from '../utils/branch-resolver.js';
38
40
  import * as gitOps from '../utils/git-ops.js';
39
41
  import { slugifyProjectName } from '../utils/project-slug.js';
40
- import { resolveSiblingWorkspaceWorktreePath, resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
42
+ import { resolveSiblingWorkspaceWorktreePath } from '../utils/worktree-paths.js';
41
43
  /** Hono sub-router for workspace CRUD, tasks, agent lifecycle, git operations, and PR creation. */
42
44
  const app = new Hono();
43
45
  /** Tracks workspaces currently running a setup script to prevent concurrent executions. */
@@ -242,17 +244,33 @@ app.post('/', migrationGuard, async (c) => {
242
244
  ? slugifyProjectName(projectSettings?.displayName ?? '', body.projectPath)
243
245
  : undefined;
244
246
  // Resolve the prospective worktree path unconditionally so that:
245
- // 1. We can refuse the request before any DB write when the path exists.
246
- // 2. createWorkspace always receives the correct slug-prefixed path and
247
+ // 1. createWorkspace always receives the correct slug-prefixed path and
247
248
  // never falls back to the no-slug resolver inside workspace-service.
249
+ // 2. When the requested branch / on-disk path is already taken we append
250
+ // a short hash (e.g. `feature/foo-A45C`) instead of rejecting the
251
+ // request — keeps the user's flow smooth at the cost of a longer
252
+ // branch name. The same hash is applied to BOTH the branch and the
253
+ // worktree path so they stay aligned.
248
254
  let prospectiveWorktreePath;
255
+ let workingBranchAdjusted = false;
249
256
  if (useReusedWorktree) {
250
257
  prospectiveWorktreePath = body.worktreePath;
251
258
  }
252
259
  else {
253
- prospectiveWorktreePath = resolveWorkspaceWorktreePath(body.projectPath, workingBranch, globalSettings.worktreesPath, projectSlug);
254
- if (fs.existsSync(prospectiveWorktreePath)) {
255
- return c.json({ error: `Worktree path already exists: ${prospectiveWorktreePath}` }, 409);
260
+ try {
261
+ const resolved = resolveUniqueBranchAndPath({
262
+ projectPath: body.projectPath,
263
+ baseBranch: workingBranch,
264
+ worktreesPath: globalSettings.worktreesPath,
265
+ projectSlug,
266
+ });
267
+ workingBranch = resolved.workingBranch;
268
+ prospectiveWorktreePath = resolved.worktreePath;
269
+ workingBranchAdjusted = resolved.adjusted;
270
+ }
271
+ catch (err) {
272
+ const message = err instanceof Error ? err.message : String(err);
273
+ return c.json({ error: message }, 409);
256
274
  }
257
275
  }
258
276
  let workspace = workspaceService.createWorkspace({
@@ -413,33 +431,10 @@ app.post('/', migrationGuard, async (c) => {
413
431
  console.error('[workspaces] Failed to write .git-conventions.md:', err);
414
432
  }
415
433
  }
416
- // Run setup script if configured and not skipped
434
+ // The setup script runs LATER (after the brainstorm prompt is built and
435
+ // persisted via setInitialPrompt). This guarantees the prompt survives a
436
+ // setup-script crash and can be replayed by /:id/start.
417
437
  let setupScriptFailed = false;
418
- // Skip the setup script when reusing an existing worktree — the user
419
- // already has the environment set up there and rerunning it could be
420
- // destructive (drop a node_modules they curated, etc.).
421
- if (effectiveSettings.setupScript && !body.skipSetupScript && !useReusedWorktree) {
422
- workspaceService.updateWorkspaceStatus(workspace.id, 'extracting');
423
- wsService.emit(workspace.id, 'setup:output', { text: '[kobo] Running setup script...' });
424
- try {
425
- const result = await runSetupScript(workspace.id, worktreePath, effectiveSettings.setupScript, {
426
- workspaceName: workspace.name,
427
- branchName: workingBranch,
428
- sourceBranch: body.sourceBranch,
429
- projectPath: body.projectPath,
430
- });
431
- if (result.exitCode !== 0) {
432
- workspaceService.updateWorkspaceStatus(workspace.id, 'error');
433
- setupScriptFailed = true;
434
- }
435
- }
436
- catch (err) {
437
- const message = err instanceof Error ? err.message : String(err);
438
- console.error(`[workspaces] Setup script error: ${message}`);
439
- workspaceService.updateWorkspaceStatus(workspace.id, 'error');
440
- setupScriptFailed = true;
441
- }
442
- }
443
438
  // Save Notion content as markdown in worktree
444
439
  let notionFilePath = null;
445
440
  if (notionContent && body.notionUrl) {
@@ -551,10 +546,15 @@ app.post('/', migrationGuard, async (c) => {
551
546
  console.error('[workspaces] Failed to update Notion status:', err);
552
547
  });
553
548
  }
554
- // Skip agent launch if setup script failed workspace stays in 'error' status
555
- if (!setupScriptFailed) {
556
- // Transition to brainstorming and build the initial agent prompt
557
- workspaceService.updateWorkspaceStatus(workspace.id, 'brainstorming');
549
+ // Build the initial agent prompt BEFORE the setup script runs so a crash
550
+ // there cannot lose user input (description, Notion/Sentry context, tasks).
551
+ // The prompt is persisted to workspace.initial_prompt; the agent-start path
552
+ // clears it once successfully consumed. The workspace status is moved to
553
+ // `brainstorming` LATER — either after a successful setup script, or
554
+ // directly if no setup script is configured. This keeps the
555
+ // VALID_TRANSITIONS contract intact (`created → extracting → brainstorming`
556
+ // vs `created → brainstorming` when setup is skipped).
557
+ {
558
558
  // Resolve the per-feature initial-prompt templates with single-fallback
559
559
  // semantics: project || global is already handled inside getEffectiveSettings,
560
560
  // and a whitespace-only string acts as a user escape hatch (skip injection).
@@ -684,20 +684,79 @@ ${AUTO_LOOP_HARD_RULES}`;
684
684
 
685
685
  Once the brainstorming + planning steps above are complete and you have a saved plan file, output [BRAINSTORM_COMPLETE] on its own line BEFORE starting implementation. Kōbō uses that marker to transition the workspace from \`brainstorming\` to \`executing\`. Then proceed with implementation.`;
686
686
  }
687
+ // Persist the assembled prompt so a setup-script crash (or any later
688
+ // failure) doesn't lose the user's input. Cleared once the agent
689
+ // successfully ingests it below or by POST /:id/start on retry.
687
690
  try {
688
- const agent = agentManager.startAgent(workspace.id, worktreePath, brainstormPrompt, workspace.model, false, workspace.agentPermissionMode, undefined, workspace.reasoningEffort);
689
- // Persist the initial prompt in the feed so it's visible in the chat,
690
- // tagged with the freshly created session id so the strict session filter shows it.
691
- wsService.emit(workspace.id, 'user:message', { content: brainstormPrompt, sender: 'system-prompt' }, agent.agentSessionId);
691
+ workspaceService.setInitialPrompt(workspace.id, brainstormPrompt);
692
692
  }
693
693
  catch (err) {
694
- const message = err instanceof Error ? err.message : String(err);
695
- console.error(`[workspaces] Failed to start agent: ${message}`);
694
+ console.error('[workspaces] setInitialPrompt failed:', err);
695
+ }
696
+ // Setup script — runs AFTER the prompt is persisted so a crash here
697
+ // leaves the workspace in `error` state with `initial_prompt` ready for
698
+ // a retry via POST /:id/start. Skipped when reusing an existing worktree
699
+ // (rerunning could be destructive — drop a curated node_modules, etc.).
700
+ const setupScriptConfigured = effectiveSettings.setupScript && !body.skipSetupScript && !useReusedWorktree;
701
+ if (setupScriptConfigured) {
702
+ workspaceService.updateWorkspaceStatus(workspace.id, 'extracting');
703
+ wsService.emit(workspace.id, 'setup:output', { text: '[kobo] Running setup script...' });
696
704
  try {
705
+ const result = await runSetupScript(workspace.id, worktreePath, effectiveSettings.setupScript, {
706
+ workspaceName: workspace.name,
707
+ branchName: workingBranch,
708
+ sourceBranch: body.sourceBranch,
709
+ projectPath: body.projectPath,
710
+ });
711
+ if (result.exitCode !== 0) {
712
+ workspaceService.updateWorkspaceStatus(workspace.id, 'error');
713
+ setupScriptFailed = true;
714
+ }
715
+ else {
716
+ workspaceService.updateWorkspaceStatus(workspace.id, 'brainstorming');
717
+ }
718
+ }
719
+ catch (err) {
720
+ const message = err instanceof Error ? err.message : String(err);
721
+ console.error(`[workspaces] Setup script error: ${message}`);
697
722
  workspaceService.updateWorkspaceStatus(workspace.id, 'error');
723
+ setupScriptFailed = true;
724
+ }
725
+ }
726
+ else {
727
+ // No setup step → go straight from `created` to `brainstorming`.
728
+ workspaceService.updateWorkspaceStatus(workspace.id, 'brainstorming');
729
+ }
730
+ if (setupScriptFailed) {
731
+ wsService.emit(workspace.id, 'setup:output', {
732
+ text: '[kobo] Setup script failed — the agent was NOT started. Your initial prompt has been saved. ' +
733
+ 'Fix the setup script (Settings → Scripts) and click Start to retry with the original prompt.',
734
+ });
735
+ }
736
+ else {
737
+ try {
738
+ const agent = agentManager.startAgent(workspace.id, worktreePath, brainstormPrompt, workspace.model, false, workspace.agentPermissionMode, undefined, workspace.reasoningEffort);
739
+ // Persist the initial prompt in the feed so it's visible in the chat,
740
+ // tagged with the freshly created session id so the strict session filter shows it.
741
+ wsService.emit(workspace.id, 'user:message', { content: brainstormPrompt, sender: 'system-prompt' }, agent.agentSessionId);
742
+ // Agent successfully ingested the prompt — clear it so /:id/start
743
+ // doesn't replay it on a future restart.
744
+ try {
745
+ workspaceService.clearInitialPrompt(workspace.id);
746
+ }
747
+ catch (err) {
748
+ console.error('[workspaces] clearInitialPrompt failed:', err);
749
+ }
698
750
  }
699
- catch {
700
- /* already logged */
751
+ catch (err) {
752
+ const message = err instanceof Error ? err.message : String(err);
753
+ console.error(`[workspaces] Failed to start agent: ${message}`);
754
+ try {
755
+ workspaceService.updateWorkspaceStatus(workspace.id, 'error');
756
+ }
757
+ catch {
758
+ /* already logged */
759
+ }
701
760
  }
702
761
  }
703
762
  }
@@ -719,6 +778,12 @@ Once the brainstorming + planning steps above are complete and you have a saved
719
778
  }
720
779
  // Return created workspace with tasks
721
780
  const workspaceWithTasks = workspaceService.getWorkspaceWithTasks(workspace.id);
781
+ if (workingBranchAdjusted) {
782
+ // Surface the auto-suffix via a custom header so the client can toast
783
+ // "Branch already existed — created <new-branch> instead". The actual
784
+ // resolved branch is on the returned workspace already.
785
+ c.header('X-Kobo-Branch-Adjusted', '1');
786
+ }
722
787
  return c.json(workspaceWithTasks, 201);
723
788
  }
724
789
  catch (err) {
@@ -1979,7 +2044,13 @@ app.post('/:id/start', migrationGuard, async (c) => {
1979
2044
  const body = await c.req
1980
2045
  .json()
1981
2046
  .catch(() => ({ prompt: undefined, agentSessionId: undefined, resume: undefined }));
1982
- const prompt = body.prompt ?? 'Continue the previous task where you left off.';
2047
+ // Prompt resolution order:
2048
+ // 1. Explicit body.prompt (user-typed in the chat input)
2049
+ // 2. Pending workspace.initial_prompt — set by workspace creation when
2050
+ // a setup-script crash prevented the original agent launch
2051
+ // 3. Generic resume fallback
2052
+ const pendingInitialPrompt = workspace.initialPrompt && workspace.initialPrompt.length > 0 ? workspace.initialPrompt : null;
2053
+ const prompt = body.prompt ?? pendingInitialPrompt ?? 'Continue the previous task where you left off.';
1983
2054
  const agentSessionId = body.agentSessionId;
1984
2055
  const resume = body.resume === true;
1985
2056
  // Stop existing agent if running
@@ -1998,6 +2069,21 @@ app.post('/:id/start', migrationGuard, async (c) => {
1998
2069
  if (body.prompt) {
1999
2070
  wsService.emit(id, 'user:message', { content: body.prompt, sender: 'user' }, agent.agentSessionId);
2000
2071
  }
2072
+ else if (pendingInitialPrompt) {
2073
+ // Pending brainstorm prompt — surface it in the feed so the user sees
2074
+ // what the agent just received, mirroring the workspace-creation flow.
2075
+ wsService.emit(id, 'user:message', { content: pendingInitialPrompt, sender: 'system-prompt' }, agent.agentSessionId);
2076
+ }
2077
+ // Clear the pending prompt once the agent has been handed it — subsequent
2078
+ // /:id/start calls fall back to the generic "Continue…" string.
2079
+ if (pendingInitialPrompt) {
2080
+ try {
2081
+ workspaceService.clearInitialPrompt(id);
2082
+ }
2083
+ catch (err) {
2084
+ console.error('[workspaces] clearInitialPrompt after start failed:', err);
2085
+ }
2086
+ }
2001
2087
  return c.json({ status: 'started' });
2002
2088
  }
2003
2089
  catch (err) {
@@ -2032,11 +2118,13 @@ app.get('/:id/git-stats', async (c) => {
2032
2118
  }
2033
2119
  });
2034
2120
  // GET /api/workspaces/:id/diff?mode=branch|unpushed — list changed files
2035
- // - `branch` (default): committed + working tree changes vs sourceBranch,
2036
- // i.e. what the PR will contain.
2121
+ // - `branch` (default): committed + working tree changes vs
2122
+ // `origin/<sourceBranch>` (which can be ahead of the local ref when
2123
+ // upstream landed merges Kōbō hasn't pulled). The handler refreshes
2124
+ // `origin/<sourceBranch>` synchronously so the diff is never stale.
2037
2125
  // - `unpushed`: committed-only changes vs `origin/<workingBranch>`,
2038
2126
  // i.e. what the next `git push` will send.
2039
- app.get('/:id/diff', (c) => {
2127
+ app.get('/:id/diff', async (c) => {
2040
2128
  try {
2041
2129
  const id = c.req.param('id');
2042
2130
  const mode = c.req.query('mode') === 'unpushed' ? 'unpushed' : 'branch';
@@ -2048,6 +2136,13 @@ app.get('/:id/diff', (c) => {
2048
2136
  return c.json({ error: `Workspace '${id}' not found` }, 404);
2049
2137
  }
2050
2138
  const worktreePath = workspace.worktreePath;
2139
+ // Sync fetch in `branch` mode so the diff reflects upstream's HEAD, not a
2140
+ // stale local copy of the source branch. Best-effort: a failed fetch
2141
+ // (offline, no remote configured) still returns the diff against whatever
2142
+ // `origin/<source>` we have in cache.
2143
+ if (mode === 'branch') {
2144
+ await gitOps.fetchSourceBranchAsync(worktreePath, workspace.sourceBranch);
2145
+ }
2051
2146
  const files = mode === 'unpushed'
2052
2147
  ? gitOps.getUnpushedChangedFiles(worktreePath, workspace.workingBranch)
2053
2148
  : gitOps.getChangedFiles(worktreePath, workspace.sourceBranch, includeUntracked);
@@ -2132,10 +2227,11 @@ app.post('/:id/rollback-file', async (c) => {
2132
2227
  }
2133
2228
  });
2134
2229
  // GET /api/workspaces/:id/branch-divergence?limit=50
2135
- // Returns commits on the working branch ahead of source (`ahead`) and
2136
- // commits on source not yet on the working branch (`behind`). One round-trip
2137
- // for the BranchDivergenceDialog.
2138
- app.get('/:id/branch-divergence', (c) => {
2230
+ // Returns commits on the working branch ahead of `origin/<sourceBranch>`
2231
+ // (`ahead`) and commits on `origin/<sourceBranch>` not yet on the working
2232
+ // branch (`behind`). Refreshes `origin/<sourceBranch>` synchronously so the
2233
+ // counts and lists are never computed against a stale local source ref.
2234
+ app.get('/:id/branch-divergence', async (c) => {
2139
2235
  try {
2140
2236
  const id = c.req.param('id');
2141
2237
  const workspace = workspaceService.getWorkspace(id);
@@ -2146,6 +2242,7 @@ app.get('/:id/branch-divergence', (c) => {
2146
2242
  const parsed = parseInt(limitRaw ?? '50', 10);
2147
2243
  const limit = Math.min(Math.max(1, Number.isNaN(parsed) ? 50 : parsed), 200);
2148
2244
  const worktreePath = workspace.worktreePath;
2245
+ await gitOps.fetchSourceBranchAsync(worktreePath, workspace.sourceBranch);
2149
2246
  const ahead = gitOps.listBranchCommits(worktreePath, workspace.sourceBranch, workspace.workingBranch, limit);
2150
2247
  const behind = gitOps.listCommitsBehind(worktreePath, workspace.sourceBranch, workspace.workingBranch, limit);
2151
2248
  c.header('Cache-Control', 'no-store');
@@ -2161,9 +2258,11 @@ app.get('/:id/branch-divergence', (c) => {
2161
2258
  return c.json({ error: message }, 500);
2162
2259
  }
2163
2260
  });
2164
- // GET /api/workspaces/:id/commits?limit=50 — list commits between sourceBranch
2165
- // and HEAD, each tagged with whether it's already pushed to origin/<branch>.
2166
- app.get('/:id/commits', (c) => {
2261
+ // GET /api/workspaces/:id/commits?limit=50 — list commits between
2262
+ // `origin/<sourceBranch>` and HEAD, each tagged with whether it's already
2263
+ // pushed to origin/<branch>. Refreshes `origin/<sourceBranch>` synchronously
2264
+ // so the list is not computed against a stale local source ref.
2265
+ app.get('/:id/commits', async (c) => {
2167
2266
  try {
2168
2267
  const id = c.req.param('id');
2169
2268
  const workspace = workspaceService.getWorkspace(id);
@@ -2173,6 +2272,7 @@ app.get('/:id/commits', (c) => {
2173
2272
  const limitRaw = c.req.query('limit');
2174
2273
  const limit = Math.min(Math.max(1, parseInt(limitRaw ?? '50', 10) || 50), 200);
2175
2274
  const worktreePath = workspace.worktreePath;
2275
+ await gitOps.fetchSourceBranchAsync(worktreePath, workspace.sourceBranch);
2176
2276
  const commits = gitOps.listBranchCommits(worktreePath, workspace.sourceBranch, workspace.workingBranch, limit);
2177
2277
  c.header('Cache-Control', 'no-store');
2178
2278
  return c.json({ commits, sourceBranch: workspace.sourceBranch, workingBranch: workspace.workingBranch });
@@ -2835,6 +2935,78 @@ app.post('/:id/start-review', async (c) => {
2835
2935
  return c.json({ error: message }, 500);
2836
2936
  }
2837
2937
  });
2938
+ // POST /api/workspaces/:id/start-ci-fix — dispatch the configured CI-fix
2939
+ // prompt to the agent when the workspace's PR has failing CI.
2940
+ // Resumes the current session (or starts a fresh one if none is alive).
2941
+ app.post('/:id/start-ci-fix', migrationGuard, async (c) => {
2942
+ try {
2943
+ const id = c.req.param('id');
2944
+ const workspace = workspaceService.getWorkspace(id);
2945
+ if (!workspace) {
2946
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
2947
+ }
2948
+ if (workspace.archivedAt) {
2949
+ return c.json({ error: `Workspace '${id}' is archived` }, 400);
2950
+ }
2951
+ // Refresh the PR snapshot best-effort so the action operates on the
2952
+ // freshest CI rollup; fall back to the cache if the refresh fails.
2953
+ let snapshot = await refreshPrSnapshot(id).catch(() => null);
2954
+ if (!snapshot) {
2955
+ snapshot = getAllPrSnapshots()[id] ?? null;
2956
+ }
2957
+ if (!snapshot || snapshot.state !== 'OPEN' || snapshot.ci.rollup !== 'FAILURE') {
2958
+ return c.json({ error: 'No failing CI detected on this workspace' }, 400);
2959
+ }
2960
+ const effective = settingsService.getEffectiveSettings(workspace.projectPath);
2961
+ const template = effective.ciFixPromptTemplate;
2962
+ if (!template || template.trim().length === 0) {
2963
+ return c.json({ error: 'No CI-fix prompt template configured. Set one in Settings → Prompts.' }, 400);
2964
+ }
2965
+ const failedChecks = snapshot.ci.checks
2966
+ .filter((check) => check.conclusion === 'FAILURE')
2967
+ .map((check) => ({ name: check.name, detailsUrl: check.detailsUrl }));
2968
+ // Best-effort first details URL as a stand-in for `ci_run_url` — the
2969
+ // forge providers don't expose a top-level run URL, but the first failed
2970
+ // check's `detailsUrl` reliably points back at the failing run.
2971
+ const ciRunUrl = failedChecks.find((c) => c.detailsUrl)?.detailsUrl ?? null;
2972
+ const rendered = renderCiFixTemplate(template, {
2973
+ workspace,
2974
+ prNumber: snapshot.number,
2975
+ prUrl: snapshot.url,
2976
+ prTitle: snapshot.title,
2977
+ failedChecks,
2978
+ ciRunUrl,
2979
+ });
2980
+ try {
2981
+ wakeupService.cancel(workspace.id, 'user-message');
2982
+ }
2983
+ catch {
2984
+ /* swallow */
2985
+ }
2986
+ const session = workspaceService.getActiveSession(workspace.id);
2987
+ let emitSessionId = session?.id;
2988
+ try {
2989
+ agentManager.sendMessage(workspace.id, rendered);
2990
+ }
2991
+ catch {
2992
+ try {
2993
+ const agent = agentManager.startAgent(workspace.id, workspace.worktreePath, rendered, workspace.model, true /* resume */, workspace.agentPermissionMode, undefined, workspace.reasoningEffort);
2994
+ workspaceService.updateWorkspaceStatus(workspace.id, 'executing');
2995
+ emitSessionId = agent?.agentSessionId ?? emitSessionId;
2996
+ }
2997
+ catch (resumeErr) {
2998
+ const msg = resumeErr instanceof Error ? resumeErr.message : String(resumeErr);
2999
+ return c.json({ error: `Failed to dispatch CI-fix prompt: ${msg}` }, 500);
3000
+ }
3001
+ }
3002
+ wsService.emit(workspace.id, 'user:message', { content: rendered, sender: 'user' }, emitSessionId);
3003
+ return c.json({ ok: true, failedChecksCount: failedChecks.length });
3004
+ }
3005
+ catch (err) {
3006
+ const message = err instanceof Error ? err.message : String(err);
3007
+ return c.json({ error: message }, 500);
3008
+ }
3009
+ });
2838
3010
  /** POST /api/workspaces/:id/mark-read — mark workspace as read (clear unread indicator). */
2839
3011
  app.post('/:id/mark-read', (c) => {
2840
3012
  try {
@@ -303,6 +303,7 @@ function readEffectiveSettingsSafe(projectPath) {
303
303
  dangerouslySkipPermissions: true,
304
304
  prPromptTemplate: '',
305
305
  reviewPromptTemplate: '',
306
+ ciFixPromptTemplate: '',
306
307
  notionInitialPromptTemplate: '',
307
308
  sentryInitialPromptTemplate: '',
308
309
  gitConventions: '',
@@ -0,0 +1,34 @@
1
+ import path from 'node:path';
2
+ function formatFailedJobs(checks) {
3
+ if (checks.length === 0)
4
+ return '(no failed jobs reported)';
5
+ return checks.map((c) => (c.detailsUrl ? `- ${c.name} — ${c.detailsUrl}` : `- ${c.name}`)).join('\n');
6
+ }
7
+ function buildVariableMap(ctx) {
8
+ return {
9
+ pr_number: ctx.prNumber != null ? String(ctx.prNumber) : '',
10
+ pr_url: ctx.prUrl ?? '',
11
+ pr_title: ctx.prTitle ?? '',
12
+ branch_name: ctx.workspace.workingBranch,
13
+ source_branch: ctx.workspace.sourceBranch,
14
+ workspace_name: ctx.workspace.name,
15
+ workspace_id: ctx.workspace.id,
16
+ project_name: path.basename(ctx.workspace.projectPath),
17
+ failed_jobs: formatFailedJobs(ctx.failedChecks),
18
+ ci_run_url: ctx.ciRunUrl ?? '',
19
+ };
20
+ }
21
+ /**
22
+ * Render a CI-fix prompt template by substituting {{variable}} placeholders.
23
+ * Pure function — no I/O. Unknown variables are left as-is so user-defined
24
+ * placeholders can be resolved downstream by the agent itself.
25
+ */
26
+ export function renderCiFixTemplate(template, ctx) {
27
+ const vars = buildVariableMap(ctx);
28
+ return template.replace(/\{\{(\w+)\}\}/g, (match, name) => {
29
+ if (Object.hasOwn(vars, name)) {
30
+ return vars[name];
31
+ }
32
+ return match;
33
+ });
34
+ }
@@ -5,7 +5,7 @@ import { resolveForge } from './forge/resolve.js';
5
5
  import { computeGitStats } from './git-stats-service.js';
6
6
  import { destroyTerminal } from './terminal-service.js';
7
7
  import { emitEphemeral } from './websocket-service.js';
8
- import { archiveWorkspace, getWorkspace, listWorkspaces, updateWorkspaceSourceBranch } from './workspace-service.js';
8
+ import { archiveWorkspace, getWorkspace, listWorkspaces, markWorkspaceUnread, updateWorkspaceSourceBranch, } from './workspace-service.js';
9
9
  // ── PR Watcher ────────────────────────────────────────────────────────────────
10
10
  // Polls GitHub every POLL_INTERVAL_MS to detect merged/closed PRs and
11
11
  // automatically archive the corresponding workspace.
@@ -50,6 +50,19 @@ export function _resetForTest() {
50
50
  lastKnownPr.clear();
51
51
  lastKnownGitStats.clear();
52
52
  }
53
+ /**
54
+ * Flip a workspace to unread (DB + WS event) on a PR-attention transition.
55
+ * Best-effort: a failure here must never break the watcher loop.
56
+ */
57
+ function markUnread(workspaceId) {
58
+ try {
59
+ markWorkspaceUnread(workspaceId);
60
+ emitEphemeral(workspaceId, 'workspace:unread', { hasUnread: true });
61
+ }
62
+ catch (err) {
63
+ console.error('[pr-watcher] markUnread failed:', err instanceof Error ? err.message : err);
64
+ }
65
+ }
53
66
  export async function checkPrStatuses() {
54
67
  const workspaces = listWorkspaces(false); // non-archived only
55
68
  // Clean up entries for workspaces that no longer exist
@@ -117,15 +130,20 @@ export async function checkPrStatuses() {
117
130
  });
118
131
  continue; // do not run base-change detection on a workspace we just archived
119
132
  }
120
- // Review-decision transitions (only on OPEN PRs; first-sight is silent).
121
- // Reuses the baseline rule from base-change detection: emits only when we
122
- // observe an actual transition between two known states.
133
+ // Review-decision and CI transitions (only on OPEN PRs; first-sight is
134
+ // silent). Reuses the baseline rule from base-change detection: act only
135
+ // on an actual transition between two known states. Each notable
136
+ // transition (changes-requested newly raised, CI newly failing) flips
137
+ // `hasUnread` so the workspace card stands out as "something new to
138
+ // look at" in the drawer — the unread bit persists until the user opens
139
+ // the workspace, matching the existing read/unread UX.
123
140
  if (pr.state === 'OPEN' && prev) {
124
141
  if (prev.reviewDecision !== 'CHANGES_REQUESTED' && pr.reviewDecision === 'CHANGES_REQUESTED') {
125
142
  emitEphemeral(ws.id, 'pr:changes-requested', {
126
143
  prNumber: pr.number,
127
144
  prUrl: pr.url,
128
145
  });
146
+ markUnread(ws.id);
129
147
  }
130
148
  else if (prev.reviewDecision === 'CHANGES_REQUESTED' && pr.reviewDecision === 'APPROVED') {
131
149
  emitEphemeral(ws.id, 'pr:approved', {
@@ -133,6 +151,9 @@ export async function checkPrStatuses() {
133
151
  prUrl: pr.url,
134
152
  });
135
153
  }
154
+ if (prev.ci.rollup !== 'FAILURE' && pr.ci.rollup === 'FAILURE') {
155
+ markUnread(ws.id);
156
+ }
136
157
  }
137
158
  // Base-branch change detection. Only relevant for OPEN PRs — closed/
138
159
  // merged PRs don't accept base changes. Skip if the GitHub response