@loicngr/kobo 1.7.6 → 1.7.8

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 (110) hide show
  1. package/AGENTS.md +29 -0
  2. package/README.md +146 -4
  3. package/dist/mcp-server/kobo-tasks-server.js +27 -0
  4. package/dist/server/index.js +2 -0
  5. package/dist/server/routes/health.js +14 -0
  6. package/dist/server/routes/voice.js +149 -0
  7. package/dist/server/routes/workspaces.js +33 -9
  8. package/dist/server/services/agent/engines/claude-code/capabilities.js +7 -0
  9. package/dist/server/services/agent/engines/codex/capabilities.js +18 -0
  10. package/dist/server/services/agent/engines/codex/client.js +36 -0
  11. package/dist/server/services/agent/engines/codex/engine.js +276 -0
  12. package/dist/server/services/agent/engines/codex/event-mapper.js +473 -0
  13. package/dist/server/services/agent/engines/codex/jsonrpc/peer.js +60 -0
  14. package/dist/server/services/agent/engines/codex/jsonrpc/transport.js +31 -0
  15. package/dist/server/services/agent/engines/codex/options-builder.js +81 -0
  16. package/dist/server/services/agent/engines/codex/protocol/types.js +11 -0
  17. package/dist/server/services/agent/engines/codex/server-requests.js +99 -0
  18. package/dist/server/services/agent/engines/codex/spawn.js +27 -0
  19. package/dist/server/services/agent/engines/registry.js +2 -0
  20. package/dist/server/services/agent/orchestrator.js +1 -1
  21. package/dist/server/services/settings-service.js +125 -6
  22. package/dist/server/services/transcription-service.js +206 -0
  23. package/dist/server/utils/paths.js +7 -0
  24. package/dist/shared/codex-models.js +43 -0
  25. package/package.json +13 -10
  26. package/src/client/dist/spa/assets/ActivityFeed-CPZdjJpH.js +8 -0
  27. package/src/client/dist/spa/assets/{ActivityFeed-tE4LVYck.css → ActivityFeed-WjiQ9716.css} +1 -1
  28. package/src/client/dist/spa/assets/{ClosePopup-D_UAdwkA.js → ClosePopup-C5JlH6Hy.js} +1 -1
  29. package/src/client/dist/spa/assets/CreatePage-CdfbFlXf.js +2 -0
  30. package/src/client/dist/spa/assets/CreatePage-ZyBHUbl0.css +1 -0
  31. package/src/client/dist/spa/assets/{DiffViewer-CblFgn8w.js → DiffViewer-DkiP6nWz.js} +3 -3
  32. package/src/client/dist/spa/assets/HealthPage-BHGZJTgS.js +1 -0
  33. package/src/client/dist/spa/assets/{MainLayout-DhaYycak.js → MainLayout-C0tClQZl.js} +17 -17
  34. package/src/client/dist/spa/assets/{MainLayout-drolsINz.css → MainLayout-DKnTGN_Q.css} +1 -1
  35. package/src/client/dist/spa/assets/{QBadge-DWH42dbo.js → QBadge-C7r6oPSi.js} +1 -1
  36. package/src/client/dist/spa/assets/{QBtn-a6jxWjmW.js → QBtn-DEuWKHbR.js} +1 -1
  37. package/src/client/dist/spa/assets/{QCheckbox-D5jfsxLV.js → QCheckbox-BvHfXBFY.js} +1 -1
  38. package/src/client/dist/spa/assets/{QChip-ByxK0Tuf.js → QChip-erWIZgxW.js} +1 -1
  39. package/src/client/dist/spa/assets/{QExpansionItem-CH1ipL9n.js → QExpansionItem-CW6sPoP9.js} +1 -1
  40. package/src/client/dist/spa/assets/QIcon-qfJNZLIW.js +1 -0
  41. package/src/client/dist/spa/assets/{QInput-Cm5-AGQ4.js → QInput-DCJEwE8V.js} +1 -1
  42. package/src/client/dist/spa/assets/{QItemLabel-DrTxqTqV.js → QItemLabel-CHkgkZVj.js} +1 -1
  43. package/src/client/dist/spa/assets/{QItemSection-5YpFpPDm.js → QItemSection-CQUDd0Vg.js} +1 -1
  44. package/src/client/dist/spa/assets/{QList-D0FtnQJI.js → QList-BbnN_oNX.js} +1 -1
  45. package/src/client/dist/spa/assets/{QMenu-B4xMxMGd.js → QMenu-CaVfoMu6.js} +1 -1
  46. package/src/client/dist/spa/assets/{QPage-DFi3K093.js → QPage-Co2h9wd_.js} +1 -1
  47. package/src/client/dist/spa/assets/{QRadio-B3aKjCVu.js → QRadio-DJxOyOA3.js} +1 -1
  48. package/src/client/dist/spa/assets/QSpace-DKIph84L.js +1 -0
  49. package/src/client/dist/spa/assets/{QSpinnerDots-CszPQQ9J.js → QSpinnerDots-Bfl2RMy4.js} +1 -1
  50. package/src/client/dist/spa/assets/{QTabPanels-D2ks0UIA.js → QTabPanels-E66qDYmr.js} +1 -1
  51. package/src/client/dist/spa/assets/{QToggle-1-N9qWq4.js → QToggle-DNOTC_3a.js} +1 -1
  52. package/src/client/dist/spa/assets/{QTooltip-fDNzBEfN.js → QTooltip-DYey0zHV.js} +1 -1
  53. package/src/client/dist/spa/assets/{SearchPage-cZTwP4Lf.js → SearchPage-BaI3iU58.js} +1 -1
  54. package/src/client/dist/spa/assets/SettingsPage-BqBOQKeM.js +9 -0
  55. package/src/client/dist/spa/assets/SettingsPage-Zeu2cZqi.css +1 -0
  56. package/src/client/dist/spa/assets/{TouchPan-DoE24Io3.js → TouchPan-DQILDzd3.js} +1 -1
  57. package/src/client/dist/spa/assets/WorkspacePage-C9eT5LAo.css +1 -0
  58. package/src/client/dist/spa/assets/WorkspacePage-DqMyUSFG.js +4 -0
  59. package/src/client/dist/spa/assets/{build-path-tree-B1Lvvqto.js → build-path-tree-BpcCBm9A.js} +1 -1
  60. package/src/client/dist/spa/assets/{cssMode-BFLYiiEw.js → cssMode-BaeNVqUm.js} +1 -1
  61. package/src/client/dist/spa/assets/{documents-kx0vLfSG.js → documents-soWtna0O.js} +1 -1
  62. package/src/client/dist/spa/assets/{editor.api-2asmmhth.js → editor.api-DMLl_PBy.js} +1 -1
  63. package/src/client/dist/spa/assets/{editor.main-ChCYZyez.js → editor.main-D2pRsQAX.js} +3 -3
  64. package/src/client/dist/spa/assets/{AutoLoopChip-w8D77bI5.js → engineFeatures-RffgP255.js} +1 -1
  65. package/src/client/dist/spa/assets/{expand-template-CXQFkQOJ.js → expand-template-z2wIJOD2.js} +1 -1
  66. package/src/client/dist/spa/assets/{formatters-DCAQ6ANJ.js → formatters-guwb-rzl.js} +1 -1
  67. package/src/client/dist/spa/assets/{freemarker2-BaBL9E9G.js → freemarker2-Bh6ItnVy.js} +1 -1
  68. package/src/client/dist/spa/assets/{handlebars-BxDour4L.js → handlebars-D8OXeysi.js} +1 -1
  69. package/src/client/dist/spa/assets/{html-C6hnkfIL.js → html-9Y1AHhvw.js} +1 -1
  70. package/src/client/dist/spa/assets/{htmlMode-9zT3-dmz.js → htmlMode-z00se0fQ.js} +1 -1
  71. package/src/client/dist/spa/assets/i18n-C-VMW7h5.js +1 -0
  72. package/src/client/dist/spa/assets/index-BLlWqEZC.js +2 -0
  73. package/src/client/dist/spa/assets/{javascript-C3YjvKbE.js → javascript-D0LSb7WU.js} +1 -1
  74. package/src/client/dist/spa/assets/{jsonMode-DcJDgMzf.js → jsonMode-BSmyaoX3.js} +1 -1
  75. package/src/client/dist/spa/assets/{liquid-CsT8SjJM.js → liquid-BsY5UXNl.js} +1 -1
  76. package/src/client/dist/spa/assets/{mdx-CT3yVSyc.js → mdx-BUcXih4e.js} +1 -1
  77. package/src/client/dist/spa/assets/{monaco.contribution-DKGNz1oQ.js → monaco.contribution-DrpufOT3.js} +2 -2
  78. package/src/client/dist/spa/assets/{notifications-OnPq4FrH.js → notifications-C255ApfS.js} +1 -1
  79. package/src/client/dist/spa/assets/permissionModes-BocOmzU8.js +1 -0
  80. package/src/client/dist/spa/assets/{purify.es-CPieV82n.js → purify.es-aV6SU8N4.js} +1 -1
  81. package/src/client/dist/spa/assets/{python-Ca5miKgj.js → python-C0PoB7M8.js} +1 -1
  82. package/src/client/dist/spa/assets/{razor-7qzusGRc.js → razor-Bu0-fwxD.js} +1 -1
  83. package/src/client/dist/spa/assets/{render-chat-markdown-Bqq2G-yI.js → render-chat-markdown-DALCdDVE.js} +1 -1
  84. package/src/client/dist/spa/assets/runtime-core.esm-bundler-9Z0QAO_7.js +1 -0
  85. package/src/client/dist/spa/assets/{tsMode-BdvO8jZ2.js → tsMode-Blc1d2dp.js} +1 -1
  86. package/src/client/dist/spa/assets/{typescript-BfVNzhgs.js → typescript-CV4ME9fo.js} +1 -1
  87. package/src/client/dist/spa/assets/{use-checkbox-D7zmRxGI.js → use-checkbox-y_fOkYZN.js} +1 -1
  88. package/src/client/dist/spa/assets/{use-id-CuaR1RiE.js → use-id-_7wiRcgb.js} +1 -1
  89. package/src/client/dist/spa/assets/{use-panel-D-8nAQns.js → use-panel-DCPiSURS.js} +1 -1
  90. package/src/client/dist/spa/assets/use-quasar-DQYS47mh.js +1 -0
  91. package/src/client/dist/spa/assets/{vue-i18n-BcfTCFFS.js → vue-i18n-DI-gS-CC.js} +1 -1
  92. package/src/client/dist/spa/assets/{xml-DGNXGqXL.js → xml-DLYRBBbI.js} +1 -1
  93. package/src/client/dist/spa/assets/{yaml-CtAtOyt5.js → yaml-QIBjI5Dl.js} +1 -1
  94. package/src/client/dist/spa/index.html +12 -12
  95. package/src/mcp-server/kobo-tasks-server.ts +27 -0
  96. package/src/client/dist/spa/assets/ActivityFeed-BboSPm4b.js +0 -7
  97. package/src/client/dist/spa/assets/CreatePage-BDObLDJc.js +0 -2
  98. package/src/client/dist/spa/assets/CreatePage-DssmsAsV.css +0 -1
  99. package/src/client/dist/spa/assets/HealthPage-CBSw7e5q.js +0 -1
  100. package/src/client/dist/spa/assets/QIcon-BJuyqdsT.js +0 -1
  101. package/src/client/dist/spa/assets/QSpace-CLtL3aPy.js +0 -1
  102. package/src/client/dist/spa/assets/SettingsPage-C1efO0VM.js +0 -1
  103. package/src/client/dist/spa/assets/SettingsPage-CMyeQ9_u.css +0 -1
  104. package/src/client/dist/spa/assets/WorkspacePage-3jcof896.js +0 -4
  105. package/src/client/dist/spa/assets/WorkspacePage-CCtIrBiR.css +0 -1
  106. package/src/client/dist/spa/assets/i18n-CLY0XI9-.js +0 -1
  107. package/src/client/dist/spa/assets/index-D6wj_wQ9.js +0 -2
  108. package/src/client/dist/spa/assets/models-BsjWUKqM.js +0 -1
  109. package/src/client/dist/spa/assets/runtime-core.esm-bundler-C3IgBgY5.js +0 -1
  110. package/src/client/dist/spa/assets/use-quasar-Sdcq6zzV.js +0 -1
@@ -0,0 +1,99 @@
1
+ export function handleServerRequest(args) {
2
+ const { method, params, requestId, emit, register, respondError } = args;
3
+ const p = (params ?? {});
4
+ const callId = typeof p.callId === 'string' ? p.callId : `srv_${requestId}`;
5
+ if (method === 'mcpServer/elicitation/request') {
6
+ // Codex asks an external MCP server's elicitation prompt to be surfaced to
7
+ // the user. Kōbō doesn't model MCP elicitations yet — respond with a
8
+ // JSON-RPC "method not supported" error so the server doesn't block.
9
+ respondError?.(requestId, -32601, 'MCP elicitations not supported by this client');
10
+ return true;
11
+ }
12
+ // v2 and v1 method aliases for the same approval semantics. v1 legacy names
13
+ // (`execCommandApproval`, `applyPatchApproval`) are kept for compat with
14
+ // older Codex CLI builds that haven't transitioned to the v2 namespace.
15
+ if (method === 'item/commandExecution/requestApproval' || method === 'execCommandApproval') {
16
+ register(callId, { requestId, kind: 'command', payload: p });
17
+ emit({
18
+ kind: 'session:user-input-requested',
19
+ requestKind: 'permission',
20
+ toolCallId: callId,
21
+ toolName: 'Bash',
22
+ payload: { command: p.command, cwd: p.cwd, reason: p.reason },
23
+ });
24
+ return true;
25
+ }
26
+ if (method === 'item/fileChange/requestApproval' || method === 'applyPatchApproval') {
27
+ register(callId, { requestId, kind: 'file_change', payload: p });
28
+ emit({
29
+ kind: 'session:user-input-requested',
30
+ requestKind: 'permission',
31
+ toolCallId: callId,
32
+ toolName: 'Edit',
33
+ payload: { changes: p.changes, reason: p.reason },
34
+ });
35
+ return true;
36
+ }
37
+ if (method === 'item/tool/requestUserInput') {
38
+ register(callId, { requestId, kind: 'user_input', payload: p });
39
+ emit({
40
+ kind: 'session:user-input-requested',
41
+ requestKind: 'question',
42
+ toolCallId: callId,
43
+ toolName: 'AskUserQuestion',
44
+ payload: { questions: p.questions },
45
+ });
46
+ return true;
47
+ }
48
+ if (method === 'item/permissions/requestApproval') {
49
+ register(callId, { requestId, kind: 'permissions', payload: p });
50
+ emit({
51
+ kind: 'session:user-input-requested',
52
+ requestKind: 'permission',
53
+ toolCallId: callId,
54
+ toolName: 'Permissions',
55
+ payload: p,
56
+ });
57
+ return true;
58
+ }
59
+ return false; // unknown method
60
+ }
61
+ /**
62
+ * Build the JSON-RPC response Codex expects for a given pending request.
63
+ *
64
+ * Decision enum values come from
65
+ * `codex-rs/protocol/src/approvals.rs:CommandExecutionApprovalDecision`
66
+ * (and the matching `FileChangeApprovalDecision`): `'accept' | 'acceptForSession' | 'decline' | 'cancel'`.
67
+ * NOT `'approve' / 'reject'` — those would be silently rejected as unknown
68
+ * variants, which breaks the strict and interactive permission modes.
69
+ *
70
+ * `PermissionsRequestApprovalResponse` has a completely different shape:
71
+ * `{ permissions, scope, strictAutoReview? }` — no `decision` field. Since
72
+ * Kōbō doesn't yet model permission grants, we deny the request by sending
73
+ * an empty permissions response. A future iteration could add a UI for
74
+ * granular permission grants.
75
+ */
76
+ export function buildResponseForResolve(pending, response) {
77
+ if (pending.kind === 'command' || pending.kind === 'file_change') {
78
+ if (response.kind === 'permission-allow')
79
+ return { decision: 'accept' };
80
+ return { decision: 'decline' };
81
+ }
82
+ if (pending.kind === 'permissions') {
83
+ // Codex's PermissionsRequestApprovalResponse shape — not { decision }.
84
+ // We don't yet model granular permission grants, so deny by returning an
85
+ // empty permissions object; Codex falls back to the existing turn policy.
86
+ return { permissions: {}, scope: 'turn' };
87
+ }
88
+ if (pending.kind === 'user_input') {
89
+ if (response.kind === 'question') {
90
+ const answers = {};
91
+ for (const [qid, val] of Object.entries(response.answers)) {
92
+ answers[qid] = { answers: [val] };
93
+ }
94
+ return { answers };
95
+ }
96
+ return { answers: {} };
97
+ }
98
+ return null;
99
+ }
@@ -0,0 +1,27 @@
1
+ import { spawn as nodeSpawn } from 'node:child_process';
2
+ import { createRequire } from 'node:module';
3
+ const requireFn = createRequire(import.meta.url);
4
+ export function resolveCodexBinary() {
5
+ try {
6
+ const pkgPath = requireFn.resolve('@openai/codex/package.json');
7
+ const pkg = requireFn(pkgPath);
8
+ const binRel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.codex;
9
+ if (binRel) {
10
+ const url = new URL(binRel, `file://${pkgPath}`);
11
+ return url.pathname;
12
+ }
13
+ }
14
+ catch {
15
+ // fall through to default
16
+ }
17
+ return 'codex';
18
+ }
19
+ export function spawnAppServer(opts) {
20
+ const bin = resolveCodexBinary();
21
+ return nodeSpawn(bin, ['app-server'], {
22
+ stdio: ['pipe', 'pipe', 'pipe'],
23
+ cwd: opts.cwd,
24
+ env: opts.env,
25
+ signal: opts.signal,
26
+ });
27
+ }
@@ -1,6 +1,8 @@
1
1
  import { createClaudeCodeEngine } from './claude-code/engine.js';
2
+ import { createCodexEngine } from './codex/engine.js';
2
3
  const ENGINES = {
3
4
  'claude-code': createClaudeCodeEngine(),
5
+ codex: createCodexEngine(),
4
6
  };
5
7
  export function listEngines() {
6
8
  return Object.values(ENGINES);
@@ -298,7 +298,7 @@ function readEffectiveSettingsSafe(projectPath) {
298
298
  catch (err) {
299
299
  console.warn('[orchestrator] Failed to load settings, using defaults:', err);
300
300
  return {
301
- model: 'claude-opus-4-7',
301
+ model: 'auto',
302
302
  dangerouslySkipPermissions: true,
303
303
  prPromptTemplate: '',
304
304
  reviewPromptTemplate: '',
@@ -266,6 +266,94 @@ const settingsMigrations = [
266
266
  }
267
267
  },
268
268
  },
269
+ {
270
+ version: 17,
271
+ name: 'add-voice-transcription-settings',
272
+ migrate({ global }) {
273
+ if (typeof global.voiceEnabled !== 'boolean')
274
+ global.voiceEnabled = false;
275
+ if (global.voicePttKey !== 'alt' && global.voicePttKey !== 'ctrl+space')
276
+ global.voicePttKey = 'alt';
277
+ if (typeof global.voiceLanguage !== 'string' || global.voiceLanguage.length === 0)
278
+ global.voiceLanguage = 'auto';
279
+ if (typeof global.voiceModel !== 'string' && global.voiceModel !== null)
280
+ global.voiceModel = null;
281
+ if (typeof global.voiceCommandPath !== 'string')
282
+ global.voiceCommandPath = '';
283
+ if (typeof global.voiceFfmpegPath !== 'string')
284
+ global.voiceFfmpegPath = '';
285
+ },
286
+ },
287
+ {
288
+ version: 18,
289
+ name: 'add-voice-advanced-settings',
290
+ migrate({ global }) {
291
+ const t = Number(global.voiceTemperature);
292
+ if (!Number.isFinite(t) || t < 0 || t > 1)
293
+ global.voiceTemperature = 0;
294
+ if (typeof global.voicePrompt !== 'string')
295
+ global.voicePrompt = '';
296
+ if (typeof global.voiceTranslateToEnglish !== 'boolean')
297
+ global.voiceTranslateToEnglish = false;
298
+ if (typeof global.voiceSuppressNonSpeechTokens !== 'boolean')
299
+ global.voiceSuppressNonSpeechTokens = true;
300
+ },
301
+ },
302
+ {
303
+ version: 19,
304
+ name: 'split-default-model-by-engine',
305
+ // Codex was added alongside Claude Code; the single `defaultModel` is now
306
+ // ambiguous (different engines have different model catalogues). Split it
307
+ // into a per-engine map. Preserve the legacy value as the claude-code
308
+ // default for back-compat, and seed codex with `'auto'`.
309
+ migrate({ global }) {
310
+ const existing = global.defaultModelByEngine;
311
+ if (typeof existing !== 'object' || existing === null || Array.isArray(existing)) {
312
+ const legacyModel = typeof global.defaultModel === 'string' && global.defaultModel.length > 0
313
+ ? global.defaultModel
314
+ : 'auto';
315
+ global.defaultModelByEngine = {
316
+ 'claude-code': legacyModel,
317
+ codex: 'auto',
318
+ };
319
+ }
320
+ else {
321
+ // Backfill any missing engine entries idempotently.
322
+ const map = existing;
323
+ if (typeof map['claude-code'] !== 'string')
324
+ map['claude-code'] = 'auto';
325
+ if (typeof map.codex !== 'string')
326
+ map.codex = 'auto';
327
+ }
328
+ // Drop the legacy field once migrated.
329
+ delete global.defaultModel;
330
+ },
331
+ },
332
+ {
333
+ version: 20,
334
+ name: 'split-default-permission-mode-by-engine',
335
+ // Mirrors v19 for permission modes. Both engines accept the full mode set.
336
+ migrate({ global }) {
337
+ const existing = global.defaultPermissionModeByEngine;
338
+ const legacyMode = typeof global.defaultPermissionMode === 'string' && global.defaultPermissionMode.length > 0
339
+ ? global.defaultPermissionMode
340
+ : 'plan';
341
+ if (typeof existing !== 'object' || existing === null || Array.isArray(existing)) {
342
+ global.defaultPermissionModeByEngine = {
343
+ 'claude-code': legacyMode,
344
+ codex: legacyMode,
345
+ };
346
+ }
347
+ else {
348
+ const map = existing;
349
+ if (typeof map['claude-code'] !== 'string')
350
+ map['claude-code'] = legacyMode;
351
+ if (typeof map.codex !== 'string')
352
+ map.codex = legacyMode;
353
+ }
354
+ delete global.defaultPermissionMode;
355
+ },
356
+ },
269
357
  ];
270
358
  /** Current settings schema version — always equals the highest migration version. */
271
359
  export const SETTINGS_SCHEMA_VERSION = settingsMigrations.length > 0 ? settingsMigrations[settingsMigrations.length - 1].version : 0;
@@ -293,7 +381,7 @@ function defaultSettings() {
293
381
  return {
294
382
  schemaVersion: SETTINGS_SCHEMA_VERSION,
295
383
  global: {
296
- defaultModel: 'claude-opus-4-7',
384
+ defaultModelByEngine: { 'claude-code': 'auto', codex: 'auto' },
297
385
  dangerouslySkipPermissions: true,
298
386
  prPromptTemplate: DEFAULT_PR_PROMPT_TEMPLATE,
299
387
  reviewPromptTemplate: DEFAULT_REVIEW_PROMPT_TEMPLATE,
@@ -307,12 +395,22 @@ function defaultSettings() {
307
395
  audioNotificationVolume: 1,
308
396
  notionStatusProperty: '',
309
397
  notionInProgressStatus: '',
310
- defaultPermissionMode: 'plan',
398
+ defaultPermissionModeByEngine: { 'claude-code': 'plan', codex: 'plan' },
311
399
  notionMcpKey: '',
312
400
  sentryMcpKey: '',
313
401
  tags: [...DEFAULT_WORKSPACE_TAGS],
314
402
  worktreesPath: WORKTREES_PATH,
315
403
  worktreesPrefixByProject: false,
404
+ voiceEnabled: false,
405
+ voicePttKey: 'alt',
406
+ voiceLanguage: 'auto',
407
+ voiceModel: null,
408
+ voiceCommandPath: '',
409
+ voiceFfmpegPath: '',
410
+ voiceTemperature: 0,
411
+ voicePrompt: '',
412
+ voiceTranslateToEnglish: false,
413
+ voiceSuppressNonSpeechTokens: true,
316
414
  },
317
415
  projects: [],
318
416
  };
@@ -497,8 +595,9 @@ export function getEffectiveSettings(projectPath) {
497
595
  const settings = readSettings();
498
596
  const project = settings.projects.find((p) => p.path === projectPath) ?? null;
499
597
  if (!project) {
598
+ const claudeCodeDefault = settings.global.defaultModelByEngine?.['claude-code'] ?? 'auto';
500
599
  return {
501
- model: settings.global.defaultModel,
600
+ model: claudeCodeDefault,
502
601
  dangerouslySkipPermissions: settings.global.dangerouslySkipPermissions,
503
602
  prPromptTemplate: settings.global.prPromptTemplate,
504
603
  reviewPromptTemplate: settings.global.reviewPromptTemplate,
@@ -512,8 +611,14 @@ export function getEffectiveSettings(projectPath) {
512
611
  notionInProgressStatus: settings.global.notionInProgressStatus,
513
612
  };
514
613
  }
614
+ // `model` here is the legacy single-string field exposed via EffectiveSettings
615
+ // for back-compat with existing callers. The engine-aware default lives in
616
+ // `global.defaultModelByEngine[engineId]` and is read directly by the create
617
+ // flow / settings UI. Fall back through claude-code's entry (the historical
618
+ // semantics) so this field never goes empty.
619
+ const claudeCodeDefault = settings.global.defaultModelByEngine?.['claude-code'] ?? 'auto';
515
620
  return {
516
- model: project.defaultModel || settings.global.defaultModel,
621
+ model: project.defaultModel || claudeCodeDefault,
517
622
  dangerouslySkipPermissions: project.dangerouslySkipPermissions ?? settings.global.dangerouslySkipPermissions,
518
623
  prPromptTemplate: project.prPromptTemplate || settings.global.prPromptTemplate,
519
624
  reviewPromptTemplate: project.reviewPromptTemplate || settings.global.reviewPromptTemplate,
@@ -531,7 +636,7 @@ export function getEffectiveSettings(projectPath) {
531
636
  export function updateGlobalSettings(data) {
532
637
  const settings = readSettings();
533
638
  const allowedGlobalKeys = [
534
- 'defaultModel',
639
+ 'defaultModelByEngine',
535
640
  'dangerouslySkipPermissions',
536
641
  'prPromptTemplate',
537
642
  'reviewPromptTemplate',
@@ -545,12 +650,22 @@ export function updateGlobalSettings(data) {
545
650
  'audioNotificationVolume',
546
651
  'notionStatusProperty',
547
652
  'notionInProgressStatus',
548
- 'defaultPermissionMode',
653
+ 'defaultPermissionModeByEngine',
549
654
  'notionMcpKey',
550
655
  'sentryMcpKey',
551
656
  'tags',
552
657
  'worktreesPath',
553
658
  'worktreesPrefixByProject',
659
+ 'voiceEnabled',
660
+ 'voicePttKey',
661
+ 'voiceLanguage',
662
+ 'voiceModel',
663
+ 'voiceCommandPath',
664
+ 'voiceFfmpegPath',
665
+ 'voiceTemperature',
666
+ 'voicePrompt',
667
+ 'voiceTranslateToEnglish',
668
+ 'voiceSuppressNonSpeechTokens',
554
669
  ];
555
670
  const filtered = pickKnownKeys(data, allowedGlobalKeys);
556
671
  if (filtered.tags !== undefined) {
@@ -564,6 +679,10 @@ export function updateGlobalSettings(data) {
564
679
  const v = Number(filtered.audioNotificationVolume);
565
680
  filtered.audioNotificationVolume = Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 1;
566
681
  }
682
+ if (filtered.voiceTemperature !== undefined) {
683
+ const t = Number(filtered.voiceTemperature);
684
+ filtered.voiceTemperature = Number.isFinite(t) ? Math.max(0, Math.min(1, t)) : settings.global.voiceTemperature;
685
+ }
567
686
  if (filtered.worktreesPath !== undefined) {
568
687
  filtered.worktreesPath = validateWorktreesPath(filtered.worktreesPath, { allowEmpty: false });
569
688
  ensureGlobalWorktreesRootExists(filtered.worktreesPath);
@@ -0,0 +1,206 @@
1
+ import { execFile } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { getKoboHome } from '../utils/paths.js';
7
+ import { getGlobalSettings } from './settings-service.js';
8
+ const execFileAsync = promisify(execFile);
9
+ const MAX_LANG_LENGTH = 16;
10
+ export class VoiceError extends Error {
11
+ code;
12
+ status;
13
+ constructor(message, code, status = 400) {
14
+ super(message);
15
+ this.code = code;
16
+ this.status = status;
17
+ this.name = 'VoiceError';
18
+ }
19
+ }
20
+ export const VOICE_MODELS = [
21
+ {
22
+ name: 'tiny',
23
+ fileName: 'ggml-tiny.bin',
24
+ url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin?download=true',
25
+ },
26
+ {
27
+ name: 'base',
28
+ fileName: 'ggml-base.bin',
29
+ url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin?download=true',
30
+ },
31
+ {
32
+ name: 'small',
33
+ fileName: 'ggml-small.bin',
34
+ url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin?download=true',
35
+ },
36
+ {
37
+ name: 'medium',
38
+ fileName: 'ggml-medium.bin',
39
+ url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin?download=true',
40
+ },
41
+ {
42
+ name: 'large-v3',
43
+ fileName: 'ggml-large-v3.bin',
44
+ url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin?download=true',
45
+ },
46
+ ];
47
+ function voiceHome() {
48
+ return path.join(getKoboHome(), 'voice');
49
+ }
50
+ function modelsDir() {
51
+ return path.join(voiceHome(), 'models', 'whisper');
52
+ }
53
+ function resolveWhisperCommand() {
54
+ const global = getGlobalSettings();
55
+ const fromSettings = (global.voiceCommandPath ?? '').trim();
56
+ if (fromSettings.length > 0)
57
+ return fromSettings;
58
+ return process.env.WHISPER_CPP_COMMAND || 'whisper-cli';
59
+ }
60
+ function resolveFfmpegCommand() {
61
+ const global = getGlobalSettings();
62
+ const fromSettings = (global.voiceFfmpegPath ?? '').trim();
63
+ if (fromSettings.length > 0)
64
+ return fromSettings;
65
+ return 'ffmpeg';
66
+ }
67
+ function ensureVoiceDirs() {
68
+ fs.mkdirSync(modelsDir(), { recursive: true });
69
+ }
70
+ function resolveModel(name) {
71
+ const model = VOICE_MODELS.find((m) => m.name === name);
72
+ if (!model)
73
+ throw new VoiceError(`Unknown voice model '${name}'`, 'MODEL_UNKNOWN', 400);
74
+ return model;
75
+ }
76
+ export function listVoiceModels() {
77
+ ensureVoiceDirs();
78
+ const settings = getGlobalSettings();
79
+ const available = VOICE_MODELS.map((m) => ({
80
+ name: m.name,
81
+ fileName: m.fileName,
82
+ installed: fs.existsSync(path.join(modelsDir(), m.fileName)),
83
+ }));
84
+ return { available, activeModel: settings.voiceModel };
85
+ }
86
+ export async function getVoiceRuntimeStatus() {
87
+ const command = resolveWhisperCommand();
88
+ let ffmpegAvailable = true;
89
+ let ffmpegError;
90
+ try {
91
+ await execFileAsync(resolveFfmpegCommand(), ['-version'], { timeout: 5000 });
92
+ }
93
+ catch (err) {
94
+ ffmpegAvailable = false;
95
+ ffmpegError = err instanceof Error ? err.message : String(err);
96
+ }
97
+ try {
98
+ await execFileAsync(command, ['-h'], { timeout: 5000 });
99
+ return { available: ffmpegAvailable, command, ffmpegAvailable, ffmpegError };
100
+ }
101
+ catch (err) {
102
+ const message = err instanceof Error ? err.message : String(err);
103
+ return { available: false, command, error: message, ffmpegAvailable, ffmpegError };
104
+ }
105
+ }
106
+ export async function downloadVoiceModel(name) {
107
+ ensureVoiceDirs();
108
+ const model = resolveModel(name);
109
+ const res = await fetch(model.url);
110
+ if (!res.ok) {
111
+ throw new VoiceError(`Failed to download model '${name}' (HTTP ${res.status})`, 'MODEL_DOWNLOAD_FAILED', 500);
112
+ }
113
+ const filePath = path.join(modelsDir(), model.fileName);
114
+ const tmpPath = `${filePath}.tmp`;
115
+ try {
116
+ const bytes = Buffer.from(await res.arrayBuffer());
117
+ fs.writeFileSync(tmpPath, bytes);
118
+ fs.renameSync(tmpPath, filePath);
119
+ }
120
+ finally {
121
+ if (fs.existsSync(tmpPath))
122
+ fs.rmSync(tmpPath, { force: true });
123
+ }
124
+ return { name, filePath };
125
+ }
126
+ export function deleteVoiceModel(name) {
127
+ const model = resolveModel(name);
128
+ const filePath = path.join(modelsDir(), model.fileName);
129
+ if (fs.existsSync(filePath))
130
+ fs.unlinkSync(filePath);
131
+ }
132
+ function getInstalledModelPath(name) {
133
+ const model = resolveModel(name);
134
+ const fullPath = path.join(modelsDir(), model.fileName);
135
+ if (!fs.existsSync(fullPath)) {
136
+ throw new VoiceError(`Model '${name}' is not installed`, 'MODEL_NOT_INSTALLED', 400);
137
+ }
138
+ return fullPath;
139
+ }
140
+ export async function transcribeAudio(params) {
141
+ const { audioBuffer, modelName } = params;
142
+ const language = params.language && params.language.trim().length > 0 ? params.language : 'auto';
143
+ const temperature = Number.isFinite(Number(params.temperature))
144
+ ? Math.max(0, Math.min(1, Number(params.temperature)))
145
+ : 0;
146
+ const prompt = (params.prompt ?? '').trim();
147
+ const translateToEnglish = params.translateToEnglish === true;
148
+ const suppressNst = params.suppressNonSpeechTokens !== false;
149
+ if (language.length > MAX_LANG_LENGTH || !/^[a-z-]+$/i.test(language)) {
150
+ throw new VoiceError(`Invalid language '${language}'`, 'LANGUAGE_INVALID', 400);
151
+ }
152
+ const modelPath = getInstalledModelPath(modelName);
153
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kobo-voice-'));
154
+ const audioPath = path.join(tmpDir, 'input.webm');
155
+ const wavPath = path.join(tmpDir, 'input.wav');
156
+ try {
157
+ fs.writeFileSync(audioPath, audioBuffer);
158
+ // Normalize browser-recorded audio (webm/ogg/...) to a mono WAV file that
159
+ // whisper-cli can decode reliably across platforms.
160
+ await execFileAsync(resolveFfmpegCommand(), ['-y', '-i', audioPath, '-ar', '16000', '-ac', '1', wavPath], {
161
+ timeout: 60000,
162
+ });
163
+ const cmd = resolveWhisperCommand();
164
+ const args = [
165
+ '-m',
166
+ modelPath,
167
+ '-f',
168
+ wavPath,
169
+ '-otxt',
170
+ '-of',
171
+ path.join(tmpDir, 'out'),
172
+ '--temperature',
173
+ String(temperature),
174
+ ];
175
+ if (language !== 'auto')
176
+ args.push('-l', language);
177
+ if (translateToEnglish)
178
+ args.push('--translate');
179
+ if (suppressNst)
180
+ args.push('--suppress-nst');
181
+ if (prompt.length > 0)
182
+ args.push('--prompt', prompt);
183
+ const start = Date.now();
184
+ const { stderr } = await execFileAsync(cmd, args, { timeout: 120000 });
185
+ const durationMs = Date.now() - start;
186
+ const outTxt = path.join(tmpDir, 'out.txt');
187
+ if (!fs.existsSync(outTxt)) {
188
+ throw new VoiceError(`Transcription output missing (${stderr || 'no stderr'})`, 'TRANSCRIPTION_FAILED', 500);
189
+ }
190
+ const text = fs.readFileSync(outTxt, 'utf-8').trim();
191
+ return { text, durationMs, model: modelName, language };
192
+ }
193
+ catch (err) {
194
+ const message = err instanceof Error ? err.message : String(err);
195
+ if (message.includes('ENOENT')) {
196
+ throw new VoiceError('Voice runtime missing (whisper-cli or ffmpeg)', 'VOICE_RUNTIME_MISSING', 500);
197
+ }
198
+ if (message.includes('timed out')) {
199
+ throw new VoiceError('Whisper transcription timed out', 'TRANSCRIPTION_TIMEOUT', 500);
200
+ }
201
+ throw err;
202
+ }
203
+ finally {
204
+ fs.rmSync(tmpDir, { recursive: true, force: true });
205
+ }
206
+ }
@@ -100,8 +100,15 @@ export function getTemplatesPath() {
100
100
  * Absolute path to the compiled MCP server entry (shipped in the published
101
101
  * package as dist/mcp-server/kobo-tasks-server.js). Returns null if not
102
102
  * present — callers (orchestrator) then fall back to the TS source for dev.
103
+ *
104
+ * `KOBO_ENFORCE_LOCAL_HOME=1` (set by `npm run dev`) forces the source path
105
+ * even if a stale `dist/mcp-server/` from a prior `npm run build` is hanging
106
+ * around. Without this guard, edits to `kobo-tasks-server.ts` are silently
107
+ * ignored during dev (the orchestrator spawns the months-old compiled binary).
103
108
  */
104
109
  export function getCompiledMcpServerPath() {
110
+ if (process.env.KOBO_ENFORCE_LOCAL_HOME === '1')
111
+ return null;
105
112
  const compiled = getPackageAssetPath('dist', 'mcp-server', 'kobo-tasks-server.js');
106
113
  return fs.existsSync(compiled) ? compiled : null;
107
114
  }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Codex model catalogue — kept in sync with the official roster published at
3
+ * developers.openai.com/codex/models. The Codex CLI accepts arbitrary strings
4
+ * in `--model`, so power users can still pin a model not listed here by
5
+ * editing the workspace `model` field directly. This list reflects the
6
+ * recommended set surfaced in the create-workspace selector.
7
+ *
8
+ * Auth caveat: `gpt-5.5` is currently only reachable when authenticated via
9
+ * ChatGPT (Plus/Pro/Team/Enterprise). API-key auth is limited to `gpt-5.4`
10
+ * and below.
11
+ */
12
+ export const CODEX_MODELS = [
13
+ {
14
+ id: 'auto',
15
+ label: 'Auto',
16
+ i18nLabelKey: 'model.auto',
17
+ i18nDescriptionKey: 'model.autoDescription',
18
+ },
19
+ {
20
+ id: 'gpt-5.5',
21
+ label: 'GPT-5.5',
22
+ i18nLabelKey: 'model.gpt55',
23
+ i18nDescriptionKey: 'model.gpt55Description',
24
+ },
25
+ {
26
+ id: 'gpt-5.4',
27
+ label: 'GPT-5.4',
28
+ i18nLabelKey: 'model.gpt54',
29
+ i18nDescriptionKey: 'model.gpt54Description',
30
+ },
31
+ {
32
+ id: 'gpt-5.4-mini',
33
+ label: 'GPT-5.4 mini',
34
+ i18nLabelKey: 'model.gpt54mini',
35
+ i18nDescriptionKey: 'model.gpt54miniDescription',
36
+ },
37
+ {
38
+ id: 'gpt-5.3-codex',
39
+ label: 'GPT-5.3 Codex',
40
+ i18nLabelKey: 'model.gpt53codex',
41
+ i18nDescriptionKey: 'model.gpt53codexDescription',
42
+ },
43
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loicngr/kobo",
3
- "version": "1.7.6",
3
+ "version": "1.7.8",
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",
@@ -66,25 +66,28 @@
66
66
  "prepublishOnly": "npm run build"
67
67
  },
68
68
  "dependencies": {
69
- "@anthropic-ai/claude-agent-sdk": "^0.2.126",
70
- "@hono/node-server": "^1.19.13",
69
+ "@anthropic-ai/claude-agent-sdk": "^0.2.90",
70
+ "@emnapi/core": "^1.10.0",
71
+ "@emnapi/runtime": "^1.10.0",
72
+ "@hono/node-server": "^2.0.2",
71
73
  "@modelcontextprotocol/sdk": "^1.29.0",
72
- "better-sqlite3": "^12.8.0",
74
+ "@openai/codex": "^0.130.0",
75
+ "better-sqlite3": "^12.9.0",
73
76
  "cron-parser": "^5.5.0",
74
- "hono": "^4.12.12",
75
- "nanoid": "^5.1.7",
77
+ "hono": "^4.12.18",
78
+ "nanoid": "^5.1.11",
76
79
  "node-pty": "^1.1.0",
77
80
  "ws": "^8.20.0"
78
81
  },
79
82
  "devDependencies": {
80
83
  "@biomejs/biome": "2.4.10",
81
84
  "@types/better-sqlite3": "^7.6.13",
82
- "@types/node": "^25.5.2",
85
+ "@types/node": "^25.6.2",
83
86
  "@types/ws": "^8.18.1",
84
- "@vitest/runner": "^4.1.2",
87
+ "@vitest/runner": "^4.1.5",
85
88
  "concurrently": "^9.2.1",
86
89
  "tsx": "^4.21.0",
87
- "typescript": "^6.0.2",
88
- "vitest": "^3.2.4"
90
+ "typescript": "^6.0.3",
91
+ "vitest": "^4.1.5"
89
92
  }
90
93
  }