@loicngr/kobo 1.9.5 → 1.10.0
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.
- package/CHANGELOG.md +36 -0
- package/README.md +2 -2
- package/dist/mcp-server/kobo-tasks-handlers.js +28 -0
- package/dist/mcp-server/kobo-tasks-server.js +23 -2
- package/dist/server/db/migrations.js +13 -0
- package/dist/server/db/schema.js +1 -0
- package/dist/server/index.js +23 -6
- package/dist/server/routes/workspaces.js +25 -18
- package/dist/server/services/agent/engines/claude-code/engine.js +73 -28
- package/dist/server/services/agent/engines/claude-code/options-builder.js +6 -1
- package/dist/server/services/agent/engines/codex/client.js +3 -0
- package/dist/server/services/agent/engines/codex/engine.js +21 -4
- package/dist/server/services/agent/engines/codex/options-builder.js +1 -1
- package/dist/server/services/agent/engines/codex/server-requests.js +12 -1
- package/dist/server/services/agent/orchestrator.js +25 -15
- package/dist/server/services/auto-loop-service.js +1 -0
- package/dist/server/services/templates-service.js +1 -0
- package/dist/server/services/workspace-service.js +2 -0
- package/package.json +1 -1
- package/src/client/dist/spa/assets/{ActivityFeed-DWLJtRrK.js → ActivityFeed-CcUu5qpC.js} +1 -1
- package/src/client/dist/spa/assets/{ChangelogPage-n_42Jqdm.js → ChangelogPage-BdxtrOnp.js} +1 -1
- package/src/client/dist/spa/assets/{ClosePopup-DEM04O_b.js → ClosePopup-D0wmrKYA.js} +1 -1
- package/src/client/dist/spa/assets/CreatePage-BmMaRpPY.css +1 -0
- package/src/client/dist/spa/assets/CreatePage-PGgqwwa4.js +2 -0
- package/src/client/dist/spa/assets/{DiffViewer-8e6e5RC4.js → DiffViewer-PD-4yZTi.js} +3 -3
- package/src/client/dist/spa/assets/{HealthPage-CViMfsRz.js → HealthPage-DRqe033h.js} +1 -1
- package/src/client/dist/spa/assets/{MainLayout-x_uEqHwb.js → MainLayout-C-THtL4A.js} +3 -3
- package/src/client/dist/spa/assets/{QBtnToggle-CnP9Tf3U.js → QBtnToggle-C0OuVppD.js} +1 -1
- package/src/client/dist/spa/assets/{QChip-DFLtGMWP.js → QChip-Bq-3SzSI.js} +1 -1
- package/src/client/dist/spa/assets/{QExpansionItem-DiMxYRgY.js → QExpansionItem-Sh9QNOLB.js} +1 -1
- package/src/client/dist/spa/assets/{QScrollArea-DDktnSkh.js → QScrollArea-CbA9gH0b.js} +1 -1
- package/src/client/dist/spa/assets/{QSelect-BS5KsK_6.js → QSelect-Bi7mKFGZ.js} +1 -1
- package/src/client/dist/spa/assets/{QTooltip-BJ3q2ddB.js → QTooltip-BYnhp9Wu.js} +1 -1
- package/src/client/dist/spa/assets/{SearchPage-C-9dypx7.js → SearchPage-BpJsyMPU.js} +1 -1
- package/src/client/dist/spa/assets/{SettingsPage-5ox4DLxS.js → SettingsPage-CzRvaRwA.js} +1 -1
- package/src/client/dist/spa/assets/{TouchPan-DMRF6FQN.js → TouchPan-BL3ZAMdn.js} +1 -1
- package/src/client/dist/spa/assets/WorkspacePage-3jV0jpk0.js +4 -0
- package/src/client/dist/spa/assets/WorkspacePage-C71K0BRq.css +1 -0
- package/src/client/dist/spa/assets/{build-path-tree-CE8-gPul.js → build-path-tree-BYsWR1Ae.js} +1 -1
- package/src/client/dist/spa/assets/{cssMode-B4pMjeW3.js → cssMode-CB-UguH5.js} +1 -1
- package/src/client/dist/spa/assets/{documents-DeTIiQsc.js → documents-D4iGKwK5.js} +1 -1
- package/src/client/dist/spa/assets/{editor.api2-BU25jjFv.js → editor.api2-CQZx6qEF.js} +1 -1
- package/src/client/dist/spa/assets/{editor.main-189xz8QD.js → editor.main-CgIzM5Af.js} +2 -2
- package/src/client/dist/spa/assets/{expand-template-CbLIIqEY.js → expand-template-lWTPop0I.js} +1 -1
- package/src/client/dist/spa/assets/{formatters-C_mbKnk8.js → formatters-DczUaXQz.js} +1 -1
- package/src/client/dist/spa/assets/{freemarker2-BPzSVCkB.js → freemarker2-OeOcbnhX.js} +1 -1
- package/src/client/dist/spa/assets/{handlebars-DdssNach.js → handlebars-D9b92bp0.js} +1 -1
- package/src/client/dist/spa/assets/{html-D1Q4zaVk.js → html-DF7Tipz6.js} +1 -1
- package/src/client/dist/spa/assets/{htmlMode-CMF_eZU-.js → htmlMode-BXnuNcvL.js} +1 -1
- package/src/client/dist/spa/assets/i18n-BNoynAbv.js +1 -0
- package/src/client/dist/spa/assets/{index-D52x_y0L.js → index-Deinl-Hq.js} +7 -7
- package/src/client/dist/spa/assets/{javascript-BTBpKpf6.js → javascript-ClzpphE4.js} +1 -1
- package/src/client/dist/spa/assets/{jsonMode-CUGtCK30.js → jsonMode-Bzqsvs2S.js} +1 -1
- package/src/client/dist/spa/assets/{kobo-commands-B6Dv8R07.js → kobo-commands-DX9Bimii.js} +1 -1
- package/src/client/dist/spa/assets/{layout-B_HUD5tM.js → layout-CXDh7cOk.js} +1 -1
- package/src/client/dist/spa/assets/{liquid-CMWxqOEm.js → liquid-CPVa7-G3.js} +1 -1
- package/src/client/dist/spa/assets/{lspLanguageFeatures-BLf0RVkp.js → lspLanguageFeatures-D8wj-yjN.js} +1 -1
- package/src/client/dist/spa/assets/{mdx-CN8Vsu32.js → mdx-wcb1Z0A_.js} +1 -1
- package/src/client/dist/spa/assets/{monaco.contribution-xh9ndmsX.js → monaco.contribution-BozjOUwK.js} +2 -2
- package/src/client/dist/spa/assets/{network-auth-uiPlm6YP.js → network-auth-BkwyxSIr.js} +1 -1
- package/src/client/dist/spa/assets/{project-color-DfN85oj1.js → project-color-oMfwIMSg.js} +1 -1
- package/src/client/dist/spa/assets/{python-Dffg69Yt.js → python-jVYLZZL1.js} +1 -1
- package/src/client/dist/spa/assets/{razor-BKmItEtg.js → razor-BHjeVPxE.js} +1 -1
- package/src/client/dist/spa/assets/{render-chat-markdown-CmFoGD9u.js → render-chat-markdown-CaUfpvEV.js} +1 -1
- package/src/client/dist/spa/assets/{tsMode-Hw0nlE4U.js → tsMode-BlnXnkR-.js} +1 -1
- package/src/client/dist/spa/assets/{typescript-C1sdOzev.js → typescript-XYkMCAOm.js} +1 -1
- package/src/client/dist/spa/assets/{use-onboarding-66aKJfag.js → use-onboarding-DkEHbMS-.js} +1 -1
- package/src/client/dist/spa/assets/{use-quasar-CjzohfOc.js → use-quasar-CGpMLh-Q.js} +1 -1
- package/src/client/dist/spa/assets/{workers-j9N8klvU.js → workers-qP1P6pzp.js} +1 -1
- package/src/client/dist/spa/assets/{xml-DhjD8NFX.js → xml-DzTCWoOI.js} +1 -1
- package/src/client/dist/spa/assets/{yaml-Cf8KmnUN.js → yaml-Osiizqat.js} +1 -1
- package/src/client/dist/spa/index.html +1 -1
- package/src/mcp-server/kobo-tasks-handlers.ts +44 -0
- package/src/mcp-server/kobo-tasks-server.ts +27 -1
- package/src/client/dist/spa/assets/CreatePage-DfMdB1Jr.js +0 -2
- package/src/client/dist/spa/assets/CreatePage-Slheqpp6.css +0 -1
- package/src/client/dist/spa/assets/WorkspacePage-82_R1AUr.js +0 -4
- package/src/client/dist/spa/assets/WorkspacePage-axYxoa9m.css +0 -1
- package/src/client/dist/spa/assets/i18n-HeO_z76f.js +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,42 @@ 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.10.0
|
|
8
|
+
|
|
9
|
+
- fix(agent): make Claude sessions recover cleanly from a stuck post-result
|
|
10
|
+
stream, prevent native wakeups from leaving orphaned questions, and keep
|
|
11
|
+
live sessions out of the `idle` state
|
|
12
|
+
- feat(chat): keep queued messages scoped to the selected workspace session,
|
|
13
|
+
restore them after a rejected send, and confirm delivery only after the
|
|
14
|
+
engine accepts it
|
|
15
|
+
- feat(chat): send a queued message immediately to an active Claude session
|
|
16
|
+
or steer an active Codex turn; serialise concurrent Codex steer requests
|
|
17
|
+
against the current turn id
|
|
18
|
+
- feat(questions): support inline free-form answers for **Other**, option
|
|
19
|
+
previews, structured Codex question ids, missing option arrays, secret
|
|
20
|
+
answers, and stale-question-panel recovery
|
|
21
|
+
- fix(codex): map `request_user_input` and its auto-resolution metadata to the
|
|
22
|
+
shared question UI, and document that structured Codex questions require
|
|
23
|
+
`plan` mode
|
|
24
|
+
- docs: document live steering, interactive-question limitations, and the
|
|
25
|
+
updated Q&A experience in README and CONFIGURATION
|
|
26
|
+
|
|
27
|
+
## 1.9.6
|
|
28
|
+
|
|
29
|
+
- feat(chat): inject a queued message into a running Claude session, with a
|
|
30
|
+
session-scoped queue and explicit server acceptance
|
|
31
|
+
- feat(sessions): persist the model used by each agent session and display it
|
|
32
|
+
when it differs from the workspace default
|
|
33
|
+
- fix(auto-loop): apply the brainstorming model before the initial session
|
|
34
|
+
starts; add a separate brainstorming reasoning-effort selector
|
|
35
|
+
- feat(mcp): let agents read paginated user/agent conversation history across
|
|
36
|
+
workspace sessions as CSV, with optional session filtering
|
|
37
|
+
- fix(chat): start a fresh session automatically when a historical session
|
|
38
|
+
cannot be resumed
|
|
39
|
+
- ui(create): clarify auto-loop, session-mode, brainstorming, model and
|
|
40
|
+
reasoning-effort controls; remember reasoning effort per model locally
|
|
41
|
+
- docs: document the Claude queued-message behavior
|
|
42
|
+
|
|
7
43
|
## 1.9.5
|
|
8
44
|
|
|
9
45
|
- fix(tests): make macOS tests portable (#16)
|
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ Kōbō runs multiple coding agents in parallel, each isolated in its own git wor
|
|
|
17
17
|
|
|
18
18
|
- **Isolated worktrees**: each workspace is a dedicated git worktree on its own branch, so parallel sessions never collide.
|
|
19
19
|
- **Two agent engines**: Claude Code (via `@anthropic-ai/claude-agent-sdk`) and OpenAI Codex (via `codex app-server`), chosen per workspace.
|
|
20
|
-
- **Live chat**: streaming text, reasoning blocks, inline Edit/Write diffs, per-turn cards, a compaction-in-progress indicator, infinite scrollback. `/` autocompletes skills and commands, `@` fuzzy-autocompletes worktree file paths, and you can export any workspace's session events to CSV.
|
|
20
|
+
- **Live chat**: streaming text, reasoning blocks, inline Edit/Write diffs, per-turn cards, a compaction-in-progress indicator, infinite scrollback. `/` autocompletes skills and commands, `@` fuzzy-autocompletes worktree file paths, and you can export any workspace's session events to CSV. Messages queued while an agent is busy stay scoped to their workspace and session. **Send now** delivers a queued message to an active Claude Code stream or steers the active Codex turn; it remains queued until the engine confirms acceptance.
|
|
21
21
|
- **Full MCP toolset (`kobo-tasks`)**: a per-workspace MCP server the agent uses for far more than tasks — task/acceptance-criteria CRUD, starting/stopping the dev server and reading its logs, a unified `get_ticket` (Notion or Sentry), searching past conversations across every workspace, per-session token/cost usage, and a `.ai/thoughts` decision log. Native Claude Code Task tools complement it for lightweight sub-agent coordination. See [`AGENTS.md`](./AGENTS.md) for the full tool list.
|
|
22
22
|
|
|
23
23
|

|
|
@@ -26,7 +26,7 @@ Kōbō runs multiple coding agents in parallel, each isolated in its own git wor
|
|
|
26
26
|

|
|
27
27
|
- **Dev server panel**: start, stop, and tail logs for a workspace's dev server (Docker or npm) straight from the Tools panel — no need to leave the UI.
|
|
28
28
|
- **Attention indicators**: workspace cards surface CI failures, review-requested changes, and a conflict-aware **ready-to-merge** badge, plus a one-click **Fix CI** button that spawns a fix workspace automatically.
|
|
29
|
-
- **Interactive Q&A**: an agent can pause mid-session to ask a clarifying question through the UI; the workspace surfaces under "Needs Attention" until you answer.
|
|
29
|
+
- **Interactive Q&A**: an agent can pause mid-session to ask a clarifying question through the UI; the workspace surfaces under "Needs Attention" until you answer. Choosing **Other** opens an inline free-form field, and option previews are available on hover. Codex native interactive questions require its `plan` permission mode.
|
|
30
30
|
|
|
31
31
|

|
|
32
32
|
- **Auto-loop**: an opt-in mode that walks the task list, spawning a fresh session per task and stopping once there's nothing left to do, progress stalls, an error occurs, or the agent needs input from you. Optionally run the initial brainstorming session on a stronger model and every task after that on a cheaper one, without leaving the engine you picked.
|
|
@@ -441,6 +441,34 @@ export function getSessionUsageHandler(db, workspaceId) {
|
|
|
441
441
|
currentSession: { sessionId: currentSessionId, ...current },
|
|
442
442
|
};
|
|
443
443
|
}
|
|
444
|
+
function csvCell(value) {
|
|
445
|
+
return /[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
|
|
446
|
+
}
|
|
447
|
+
/** Read the user/agent conversation history as paginated CSV. */
|
|
448
|
+
export function readWorkspaceEventsCsvHandler(db, workspaceId, options = {}) {
|
|
449
|
+
const safeLimit = Math.min(Math.max(Math.floor(options.limit ?? 100), 1), 500);
|
|
450
|
+
const safeOffset = Math.max(Math.floor(options.offset ?? 0), 0);
|
|
451
|
+
const sessionFilter = options.sessionId ? ' AND session_id = ?' : '';
|
|
452
|
+
const params = options.sessionId ? [workspaceId, options.sessionId] : [workspaceId];
|
|
453
|
+
const conversationFilter = " AND (type = 'user:message' OR (type = 'agent:event' AND json_extract(payload, '$.kind') = 'message:text'))";
|
|
454
|
+
const rows = db
|
|
455
|
+
.prepare(`SELECT session_id, type, payload, created_at FROM ws_events WHERE workspace_id = ?${sessionFilter}${conversationFilter} ORDER BY rowid ASC LIMIT ? OFFSET ?`)
|
|
456
|
+
.all(...params, safeLimit, safeOffset);
|
|
457
|
+
const total = db
|
|
458
|
+
.prepare(`SELECT COUNT(*) AS count FROM ws_events WHERE workspace_id = ?${sessionFilter}${conversationFilter}`)
|
|
459
|
+
.get(...params).count;
|
|
460
|
+
const lines = [
|
|
461
|
+
['created_at', 'session_id', 'type', 'payload'],
|
|
462
|
+
...rows.map((r) => [r.created_at, r.session_id ?? '', r.type, r.payload]),
|
|
463
|
+
];
|
|
464
|
+
return {
|
|
465
|
+
csv: lines.map((row) => row.map(csvCell).join(',')).join('\n'),
|
|
466
|
+
eventCount: rows.length,
|
|
467
|
+
totalEvents: total,
|
|
468
|
+
offset: safeOffset,
|
|
469
|
+
nextOffset: safeOffset + rows.length < total ? safeOffset + rows.length : null,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
444
472
|
// ── Crons ────────────────────────────────────────────────────────────────────
|
|
445
473
|
/**
|
|
446
474
|
* List every cron currently armed for a workspace.
|
|
@@ -4,7 +4,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
4
4
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
5
5
|
import { getDb } from '../server/db/index.js';
|
|
6
6
|
import { runMigrations } from '../server/db/migrations.js';
|
|
7
|
-
import { createTaskHandler, cronListHandler, deleteTaskHandler, getDevServerStatusHandler, getSessionUsageHandler, getSettingsHandler, getTicketSourcesHandler, getWorkspaceInfoHandler, listDocumentsHandler, listTasksHandler, listWorkspaceImagesHandler, listWorkspacesHandler, logThoughtHandler, markAutoLoopReadyHandler, markTaskDoneHandler, readDocumentHandler, setWorkspaceAgentDescriptionHandler, updateTaskHandler, } from './kobo-tasks-handlers.js';
|
|
7
|
+
import { createTaskHandler, cronListHandler, deleteTaskHandler, getDevServerStatusHandler, getSessionUsageHandler, getSettingsHandler, getTicketSourcesHandler, getWorkspaceInfoHandler, listDocumentsHandler, listTasksHandler, listWorkspaceImagesHandler, listWorkspacesHandler, logThoughtHandler, markAutoLoopReadyHandler, markTaskDoneHandler, readDocumentHandler, readWorkspaceEventsCsvHandler, setWorkspaceAgentDescriptionHandler, updateTaskHandler, } from './kobo-tasks-handlers.js';
|
|
8
8
|
const workspaceId = process.env.KOBO_WORKSPACE_ID;
|
|
9
9
|
const dbPath = process.env.KOBO_DB_PATH;
|
|
10
10
|
const settingsPath = process.env.KOBO_SETTINGS_PATH;
|
|
@@ -276,7 +276,7 @@ const WORKSPACE_SCOPED_TOOLS = [
|
|
|
276
276
|
},
|
|
277
277
|
{
|
|
278
278
|
name: 'set_workspace_status',
|
|
279
|
-
description: 'CALL WHEN you believe the mission is done (`completed`)
|
|
279
|
+
description: 'CALL WHEN you believe the mission is done (`completed`) or blocked beyond recovery (`error`). Do NOT set `idle` while this agent session is active: ask the user with AskUserQuestion or end the turn instead. Transitions are validated by the backend — invalid or unsafe ones are rejected.',
|
|
280
280
|
inputSchema: {
|
|
281
281
|
type: 'object',
|
|
282
282
|
properties: {
|
|
@@ -414,6 +414,20 @@ const WORKSPACE_SCOPED_TOOLS = [
|
|
|
414
414
|
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
415
415
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
416
416
|
},
|
|
417
|
+
{
|
|
418
|
+
name: 'read_workspace_events_csv',
|
|
419
|
+
description: 'Read the user/agent conversation history for THIS workspace as paginated CSV. Use it to recover context from prior sessions without loading the whole history at once. Optionally filter to one session_id. Read-only; cannot access another workspace.',
|
|
420
|
+
inputSchema: {
|
|
421
|
+
type: 'object',
|
|
422
|
+
properties: {
|
|
423
|
+
session_id: { type: 'string', description: 'Optional Kōbō session id to restrict the history.' },
|
|
424
|
+
offset: { type: 'number', description: 'Zero-based offset for pagination, default 0.' },
|
|
425
|
+
limit: { type: 'number', description: 'Messages to return, default 100, max 500.' },
|
|
426
|
+
},
|
|
427
|
+
required: [],
|
|
428
|
+
},
|
|
429
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
430
|
+
},
|
|
417
431
|
{
|
|
418
432
|
name: 'schedule_wakeup',
|
|
419
433
|
description: 'WHEN: use this whenever you would otherwise WAIT for or POLL a long-running task (test suite, CI, build, deploy, or external state) — never block your turn with sleep/poll loops, and never end a turn merely "waiting" on a background job, because the session goes idle and will not resume on its own. CALL to schedule a follow-up turn on THIS workspace after a delay. End the current turn normally; once it finishes and the workspace is idle, Kōbō waits `delaySeconds`, then resumes the same conversation by injecting `prompt` as the next user message. The wakeup is scoped to the current workspace and resumes its latest session — you cannot target another workspace or another session. If a turn is still active when the timer fires, the wakeup is skipped (status: `session-active`). Replaces any previously pending wakeup on this workspace. Delay is clamped to [60, 21600] seconds (1min to 6h). Prefer this over the built-in `ScheduleWakeup` tool — it is the SDK-supported entry point.',
|
|
@@ -761,6 +775,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
761
775
|
if (name === 'get_session_usage') {
|
|
762
776
|
return ok(getSessionUsageHandler(db, workspaceId));
|
|
763
777
|
}
|
|
778
|
+
if (name === 'read_workspace_events_csv') {
|
|
779
|
+
return ok(readWorkspaceEventsCsvHandler(db, workspaceId, {
|
|
780
|
+
sessionId: typeof a.session_id === 'string' ? a.session_id : undefined,
|
|
781
|
+
offset: typeof a.offset === 'number' ? a.offset : undefined,
|
|
782
|
+
limit: typeof a.limit === 'number' ? a.limit : undefined,
|
|
783
|
+
}));
|
|
784
|
+
}
|
|
764
785
|
if (name === 'schedule_wakeup') {
|
|
765
786
|
const delaySeconds = a.delaySeconds;
|
|
766
787
|
const prompt = a.prompt;
|
|
@@ -407,6 +407,19 @@ export const migrations = [
|
|
|
407
407
|
}
|
|
408
408
|
},
|
|
409
409
|
},
|
|
410
|
+
{
|
|
411
|
+
version: 31,
|
|
412
|
+
name: 'add-agent-session-model',
|
|
413
|
+
migrate: (db) => {
|
|
414
|
+
const table = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='agent_sessions'").get();
|
|
415
|
+
if (!table)
|
|
416
|
+
return;
|
|
417
|
+
const cols = db.prepare('PRAGMA table_info(agent_sessions)').all();
|
|
418
|
+
if (!cols.some((c) => c.name === 'model')) {
|
|
419
|
+
db.prepare('ALTER TABLE agent_sessions ADD COLUMN model TEXT').run();
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
},
|
|
410
423
|
];
|
|
411
424
|
/** Current schema version — always equals the highest migration version. */
|
|
412
425
|
export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
|
package/dist/server/db/schema.js
CHANGED
package/dist/server/index.js
CHANGED
|
@@ -291,7 +291,7 @@ terminalWss.on('connection', (ws, workspaceId) => {
|
|
|
291
291
|
});
|
|
292
292
|
});
|
|
293
293
|
// Wire websocket-service message handler to the agent orchestrator
|
|
294
|
-
setMessageHandler((type, payload) => {
|
|
294
|
+
setMessageHandler(async (type, payload) => {
|
|
295
295
|
const p = payload;
|
|
296
296
|
if (type === 'chat:message' && p?.workspaceId && p?.content) {
|
|
297
297
|
// Auto-loop owns the agent's turns. A user message means the user wants
|
|
@@ -312,6 +312,8 @@ setMessageHandler((type, payload) => {
|
|
|
312
312
|
if (wsRow?.status === 'awaiting-user') {
|
|
313
313
|
emitEphemeral(p.workspaceId, 'chat:rejected', {
|
|
314
314
|
reason: 'awaiting-user',
|
|
315
|
+
sessionId: p.sessionId,
|
|
316
|
+
content: p.content,
|
|
315
317
|
message: 'Answer via the question panel — typing in chat would orphan the pending callback',
|
|
316
318
|
});
|
|
317
319
|
return;
|
|
@@ -321,10 +323,11 @@ setMessageHandler((type, payload) => {
|
|
|
321
323
|
// never steal the tagging.
|
|
322
324
|
const activeSession = getActiveSession(p.workspaceId);
|
|
323
325
|
const sessionTag = p.sessionId ?? activeSession?.id ?? undefined;
|
|
324
|
-
// Persist user message so it survives page refresh
|
|
325
|
-
emit(p.workspaceId, 'user:message', { content: p.content, sender: 'user' }, sessionTag);
|
|
326
326
|
try {
|
|
327
|
-
sendMessage(p.workspaceId, p.content);
|
|
327
|
+
await sendMessage(p.workspaceId, p.content, p.sessionId);
|
|
328
|
+
emit(p.workspaceId, 'user:message', { content: p.content, sender: 'user' }, sessionTag);
|
|
329
|
+
if (p.force)
|
|
330
|
+
emitEphemeral(p.workspaceId, 'chat:accepted', { sessionId: p.sessionId });
|
|
328
331
|
}
|
|
329
332
|
catch (err) {
|
|
330
333
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -332,6 +335,7 @@ setMessageHandler((type, payload) => {
|
|
|
332
335
|
// (stdin closed, process dead mid-write, etc.) should surface to the
|
|
333
336
|
// logs instead of silently respawning a fresh agent.
|
|
334
337
|
if (!msg.includes('No agent running')) {
|
|
338
|
+
emitEphemeral(p.workspaceId, 'chat:rejected', { sessionId: p.sessionId, content: p.content, message: msg });
|
|
335
339
|
console.error(`[ws] chat:message failed for workspace ${p.workspaceId}:`, err);
|
|
336
340
|
return;
|
|
337
341
|
}
|
|
@@ -345,12 +349,25 @@ setMessageHandler((type, payload) => {
|
|
|
345
349
|
// requires them (e.g. grooming), it sets the override to bypass the
|
|
346
350
|
// workspace default for this spawn only.
|
|
347
351
|
const effectiveMode = p.agentPermissionModeOverride ?? workspace.agentPermissionMode;
|
|
348
|
-
startAgent(p.workspaceId, worktreePath, p.content, workspace.model, true, effectiveMode, p.sessionId, workspace.reasoningEffort);
|
|
352
|
+
const started = startAgent(p.workspaceId, worktreePath, p.content, workspace.model, true, effectiveMode, p.sessionId, workspace.reasoningEffort);
|
|
349
353
|
updateWorkspaceStatus(p.workspaceId, 'executing');
|
|
354
|
+
const resumedSessionId = p.sessionId ?? started.agentSessionId;
|
|
355
|
+
emit(p.workspaceId, 'user:message', { content: p.content, sender: 'user' }, resumedSessionId);
|
|
356
|
+
if (p.force)
|
|
357
|
+
emitEphemeral(p.workspaceId, 'chat:accepted', { sessionId: resumedSessionId });
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
emitEphemeral(p.workspaceId, 'chat:rejected', {
|
|
361
|
+
sessionId: p.sessionId,
|
|
362
|
+
content: p.content,
|
|
363
|
+
message: `Workspace '${p.workspaceId}' not found`,
|
|
364
|
+
});
|
|
350
365
|
}
|
|
351
366
|
}
|
|
352
367
|
catch (restartErr) {
|
|
353
|
-
|
|
368
|
+
const message = restartErr instanceof Error ? restartErr.message : String(restartErr);
|
|
369
|
+
emitEphemeral(p.workspaceId, 'chat:rejected', { sessionId: p.sessionId, content: p.content, message });
|
|
370
|
+
console.error('[ws] Failed to resume agent:', message);
|
|
354
371
|
}
|
|
355
372
|
}
|
|
356
373
|
}
|
|
@@ -311,6 +311,23 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
311
311
|
engine: body.engine,
|
|
312
312
|
...(useReusedWorktree ? {} : { worktreesPath: globalSettings.worktreesPath }),
|
|
313
313
|
});
|
|
314
|
+
// Enable auto-loop before starting the initial brainstorming session so
|
|
315
|
+
// its model override is available when selecting that session's model.
|
|
316
|
+
if (body.autoLoop === true) {
|
|
317
|
+
const notionProducedTasks = body.notionUrl !== undefined &&
|
|
318
|
+
notionContent != null &&
|
|
319
|
+
notionContent.todos.length > 0 &&
|
|
320
|
+
notionContent.gherkinFeatures.length > 0;
|
|
321
|
+
const sessionMode = body.autoLoopSessionMode === 'continuous' ? 'continuous' : 'per_task';
|
|
322
|
+
const db = getDb();
|
|
323
|
+
db.prepare('UPDATE workspaces SET auto_loop = 1, auto_loop_ready = ?, auto_loop_session_mode = ? WHERE id = ?').run(notionProducedTasks ? 1 : 0, sessionMode, workspace.id);
|
|
324
|
+
workspace = workspaceService.getWorkspace(workspace.id) ?? workspace;
|
|
325
|
+
// Emit events so the frontend refreshes autoLoopStates without F5.
|
|
326
|
+
wsService.emitEphemeral(workspace.id, 'autoloop:enabled', {});
|
|
327
|
+
if (notionProducedTasks) {
|
|
328
|
+
wsService.emitEphemeral(workspace.id, 'autoloop:ready-flipped', {});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
314
331
|
// Auto-tag the workspace based on its creation source — `notion` when
|
|
315
332
|
// imported from a Notion page, `sentry` when bootstrapped from a Sentry
|
|
316
333
|
// issue URL. Pre-seeded in the global tag catalogue via migration v9.
|
|
@@ -784,7 +801,10 @@ Once the brainstorming + planning steps above are complete and you have a saved
|
|
|
784
801
|
// (see auto-loop-service.ts's spawnNextIteration) — brainstormModel
|
|
785
802
|
// is consumed exactly once, here.
|
|
786
803
|
const initialSessionModel = workspace.autoLoop && workspace.brainstormModel ? workspace.brainstormModel : workspace.model;
|
|
787
|
-
const
|
|
804
|
+
const initialSessionEffort = body.autoLoop === true && body.brainstormReasoningEffort
|
|
805
|
+
? body.brainstormReasoningEffort
|
|
806
|
+
: workspace.reasoningEffort;
|
|
807
|
+
const agent = agentManager.startAgent(workspace.id, worktreePath, brainstormPrompt, initialSessionModel, false, workspace.agentPermissionMode, undefined, initialSessionEffort);
|
|
788
808
|
// Persist the initial prompt in the feed so it's visible in the chat,
|
|
789
809
|
// tagged with the freshly created session id so the strict session filter shows it.
|
|
790
810
|
wsService.emit(workspace.id, 'user:message', { content: brainstormPrompt, sender: 'system-prompt' }, agent.agentSessionId);
|
|
@@ -809,23 +829,6 @@ Once the brainstorming + planning steps above are complete and you have a saved
|
|
|
809
829
|
}
|
|
810
830
|
}
|
|
811
831
|
}
|
|
812
|
-
// Apply the auto-loop checkbox from CreatePage. Notion-imported workspaces
|
|
813
|
-
// with both todos AND gherkin features auto-unlock `auto_loop_ready=1` —
|
|
814
|
-
// they're considered good enough to drive the loop without grooming.
|
|
815
|
-
if (body.autoLoop === true) {
|
|
816
|
-
const notionProducedTasks = body.notionUrl !== undefined &&
|
|
817
|
-
notionContent != null &&
|
|
818
|
-
notionContent.todos.length > 0 &&
|
|
819
|
-
notionContent.gherkinFeatures.length > 0;
|
|
820
|
-
const sessionMode = body.autoLoopSessionMode === 'continuous' ? 'continuous' : 'per_task';
|
|
821
|
-
const db = getDb();
|
|
822
|
-
db.prepare('UPDATE workspaces SET auto_loop = 1, auto_loop_ready = ?, auto_loop_session_mode = ? WHERE id = ?').run(notionProducedTasks ? 1 : 0, sessionMode, workspace.id);
|
|
823
|
-
// Emit events so the frontend refreshes autoLoopStates without F5.
|
|
824
|
-
wsService.emitEphemeral(workspace.id, 'autoloop:enabled', {});
|
|
825
|
-
if (notionProducedTasks) {
|
|
826
|
-
wsService.emitEphemeral(workspace.id, 'autoloop:ready-flipped', {});
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
832
|
// Return created workspace with tasks
|
|
830
833
|
const workspaceWithTasks = workspaceService.getWorkspaceWithTasks(workspace.id);
|
|
831
834
|
if (workingBranchAdjusted) {
|
|
@@ -1227,6 +1230,7 @@ app.post('/:id/deferred-tool-use/answer', async (c) => {
|
|
|
1227
1230
|
}
|
|
1228
1231
|
await agentManager.answerPendingQuestion(id, body.answers, body.toolCallId, {
|
|
1229
1232
|
awaitingFreeForm: body.awaitingFreeForm === true,
|
|
1233
|
+
...(typeof body.response === 'string' ? { response: body.response } : {}),
|
|
1230
1234
|
});
|
|
1231
1235
|
return c.json({ ok: true });
|
|
1232
1236
|
}
|
|
@@ -1792,6 +1796,9 @@ app.patch('/:id', migrationGuard, async (c) => {
|
|
|
1792
1796
|
updated = workspaceService.updateAgentPermissionMode(id, body.agentPermissionMode);
|
|
1793
1797
|
}
|
|
1794
1798
|
if (body.status) {
|
|
1799
|
+
if (body.status === 'idle' && agentManager.hasController(id)) {
|
|
1800
|
+
return c.json({ error: 'Cannot mark an active agent workspace idle; end or stop the agent session first' }, 409);
|
|
1801
|
+
}
|
|
1795
1802
|
updated = workspaceService.updateWorkspaceStatus(id, body.status);
|
|
1796
1803
|
}
|
|
1797
1804
|
if (body.name !== undefined) {
|
|
@@ -14,6 +14,7 @@ import { buildStopHookOutput } from './stop-hook.js';
|
|
|
14
14
|
* and auto-loop are not frozen forever.
|
|
15
15
|
*/
|
|
16
16
|
const RESULT_DRAIN_TIMEOUT_MS = 15_000;
|
|
17
|
+
const MAX_PENDING_USER_MESSAGES = 20;
|
|
17
18
|
function toMcpServersMap(specs) {
|
|
18
19
|
if (!specs || specs.length === 0)
|
|
19
20
|
return undefined;
|
|
@@ -27,6 +28,55 @@ function toMcpServersMap(specs) {
|
|
|
27
28
|
}
|
|
28
29
|
return map;
|
|
29
30
|
}
|
|
31
|
+
class ClaudeInputStream {
|
|
32
|
+
messages;
|
|
33
|
+
waiting;
|
|
34
|
+
closed = false;
|
|
35
|
+
queuedForcedMessages = 0;
|
|
36
|
+
yieldedMessages = 0;
|
|
37
|
+
constructor(initialPrompt) {
|
|
38
|
+
this.messages = [{ message: this.toUserMessage(initialPrompt), forced: false }];
|
|
39
|
+
}
|
|
40
|
+
send(text) {
|
|
41
|
+
if (this.closed)
|
|
42
|
+
throw new Error('Claude input stream is closed');
|
|
43
|
+
if (this.queuedForcedMessages >= MAX_PENDING_USER_MESSAGES) {
|
|
44
|
+
throw new Error(`Claude input queue is full (max ${MAX_PENDING_USER_MESSAGES} messages)`);
|
|
45
|
+
}
|
|
46
|
+
this.messages.push({ message: this.toUserMessage(text), forced: true });
|
|
47
|
+
this.queuedForcedMessages++;
|
|
48
|
+
const wake = this.waiting;
|
|
49
|
+
this.waiting = undefined;
|
|
50
|
+
wake?.();
|
|
51
|
+
}
|
|
52
|
+
close() {
|
|
53
|
+
this.closed = true;
|
|
54
|
+
const wake = this.waiting;
|
|
55
|
+
this.waiting = undefined;
|
|
56
|
+
wake?.();
|
|
57
|
+
}
|
|
58
|
+
hasUnansweredInput(completedResponses) {
|
|
59
|
+
return this.queuedForcedMessages > 0 || this.yieldedMessages > completedResponses;
|
|
60
|
+
}
|
|
61
|
+
async *[Symbol.asyncIterator]() {
|
|
62
|
+
while (!this.closed || this.messages.length > 0) {
|
|
63
|
+
const next = this.messages.shift();
|
|
64
|
+
if (next) {
|
|
65
|
+
if (next.forced)
|
|
66
|
+
this.queuedForcedMessages--;
|
|
67
|
+
this.yieldedMessages++;
|
|
68
|
+
yield next.message;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
await new Promise((resolve) => {
|
|
72
|
+
this.waiting = resolve;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
toUserMessage(text) {
|
|
77
|
+
return { type: 'user', message: { role: 'user', content: text }, parent_tool_use_id: null };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
30
80
|
export function createClaudeCodeEngine() {
|
|
31
81
|
return {
|
|
32
82
|
id: 'claude-code',
|
|
@@ -143,7 +193,8 @@ export function createClaudeCodeEngine() {
|
|
|
143
193
|
const explicitBinary = resolveClaudeBinaryPath();
|
|
144
194
|
if (explicitBinary)
|
|
145
195
|
sdkOptions.pathToClaudeCodeExecutable = explicitBinary;
|
|
146
|
-
const
|
|
196
|
+
const inputStream = new ClaudeInputStream(effectivePrompt);
|
|
197
|
+
const q = query({ prompt: inputStream, options: sdkOptions });
|
|
147
198
|
let discoveredSessionId;
|
|
148
199
|
// A throwing onEvent handler (e.g. DB query against a closed connection
|
|
149
200
|
// during async test teardown) must not escape as an unhandled rejection.
|
|
@@ -157,6 +208,7 @@ export function createClaudeCodeEngine() {
|
|
|
157
208
|
};
|
|
158
209
|
let iteratorRunning = false;
|
|
159
210
|
let userInterrupted = false;
|
|
211
|
+
let completedResponses = 0;
|
|
160
212
|
// Guard so the post-result drain watchdog and the natural loop exit (or
|
|
161
213
|
// catch block) never both emit `session:ended` for the same run.
|
|
162
214
|
let sessionEndedEmitted = false;
|
|
@@ -181,29 +233,12 @@ export function createClaudeCodeEngine() {
|
|
|
181
233
|
console.warn(`[claude-engine] SDK generator still open ${RESULT_DRAIN_TIMEOUT_MS}ms after 'result' — forcing session:ended`);
|
|
182
234
|
const reason = userInterrupted ? 'killed' : mapperState.sawErrorResult ? 'error' : 'completed';
|
|
183
235
|
emitSessionEnded(reason, reason === 'completed' ? 0 : null);
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
// controller is removed, letting the wakeup fire later); the parked
|
|
191
|
-
// generator drains on its own once the background work finishes.
|
|
192
|
-
//
|
|
193
|
-
// The wakeup check is a dynamic import: a static `import` of
|
|
194
|
-
// wakeup-service here would form an engine → wakeup-service →
|
|
195
|
-
// orchestrator → engine cycle that breaks module init under the test
|
|
196
|
-
// SSR transform. Resolving it at call-time (once, 15s after result)
|
|
197
|
-
// sidesteps the cycle with no hot-path cost.
|
|
198
|
-
void (async () => {
|
|
199
|
-
const { isWakeupScheduled } = await import('../../../wakeup-service.js');
|
|
200
|
-
if (isWakeupScheduled(options.workspaceId)) {
|
|
201
|
-
console.warn(`[claude-engine] generator still open ${RESULT_DRAIN_TIMEOUT_MS}ms after 'result' but a wakeup is scheduled for ${options.workspaceId} — leaving background work alive (no abort)`);
|
|
202
|
-
}
|
|
203
|
-
else {
|
|
204
|
-
abortController.abort();
|
|
205
|
-
}
|
|
206
|
-
})();
|
|
236
|
+
// A wakeup starts a new turn later; it cannot safely keep this SDK
|
|
237
|
+
// stream alive after Kōbō has emitted session:ended and detached its
|
|
238
|
+
// controller. Leaving it alive lets late hooks issue AskUserQuestion
|
|
239
|
+
// calls against a closed input stream, producing an unanswerable UI
|
|
240
|
+
// card followed by "Stream closed". Abort deterministically instead.
|
|
241
|
+
abortController.abort();
|
|
207
242
|
}, RESULT_DRAIN_TIMEOUT_MS);
|
|
208
243
|
resultDrainTimer.unref?.();
|
|
209
244
|
};
|
|
@@ -217,8 +252,14 @@ export function createClaudeCodeEngine() {
|
|
|
217
252
|
discoveredSessionId = ev.engineSessionId;
|
|
218
253
|
safeEmit(ev);
|
|
219
254
|
}
|
|
220
|
-
if (msg.type === 'result')
|
|
221
|
-
|
|
255
|
+
if (msg.type === 'result') {
|
|
256
|
+
completedResponses++;
|
|
257
|
+
// A queued forced message starts the next response on this same SDK stream.
|
|
258
|
+
if (!inputStream.hasUnansweredInput(completedResponses)) {
|
|
259
|
+
inputStream.close();
|
|
260
|
+
armResultDrainWatchdog();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
222
263
|
}
|
|
223
264
|
// If the SDK ended with a `result.subtype === 'error_*'`, the
|
|
224
265
|
// event-mapper already surfaced an `error` event but the iterator
|
|
@@ -272,6 +313,7 @@ export function createClaudeCodeEngine() {
|
|
|
272
313
|
}
|
|
273
314
|
pendingResolvers.clear();
|
|
274
315
|
iteratorRunning = false;
|
|
316
|
+
inputStream.close();
|
|
275
317
|
}
|
|
276
318
|
})();
|
|
277
319
|
const engineProcess = {
|
|
@@ -284,8 +326,10 @@ export function createClaudeCodeEngine() {
|
|
|
284
326
|
isAlive() {
|
|
285
327
|
return iteratorRunning;
|
|
286
328
|
},
|
|
287
|
-
sendMessage() {
|
|
288
|
-
|
|
329
|
+
sendMessage(text) {
|
|
330
|
+
if (!iteratorRunning)
|
|
331
|
+
throw new Error('Claude agent is no longer running');
|
|
332
|
+
inputStream.send(text);
|
|
289
333
|
},
|
|
290
334
|
interrupt() {
|
|
291
335
|
userInterrupted = true;
|
|
@@ -336,6 +380,7 @@ export function createClaudeCodeEngine() {
|
|
|
336
380
|
updatedInput: {
|
|
337
381
|
...(typeof questions !== 'undefined' ? { questions } : {}),
|
|
338
382
|
answers: response.answers,
|
|
383
|
+
...(response.response !== undefined ? { response: response.response } : {}),
|
|
339
384
|
},
|
|
340
385
|
});
|
|
341
386
|
return true;
|
|
@@ -8,8 +8,9 @@ const KOBO_MCP_BRIEF = [
|
|
|
8
8
|
'• `kobo__list_documents` / `kobo__read_document` to discover existing plans and specs under docs/ and .ai/thoughts/ before writing new ones.',
|
|
9
9
|
'• `kobo__log_thought` to persist notable decisions to `.ai/thoughts/logs/<date>-<slug>.md`.',
|
|
10
10
|
'• `kobo__search_codebase` to recall prior chat history (conversations, not source — use Grep for source).',
|
|
11
|
+
'• `kobo__read_workspace_events_csv` to read paginated user/agent history from this workspace across sessions when prior context is useful. Use `limit` + `offset` (and optional `session_id`) instead of loading everything.',
|
|
11
12
|
'• `kobo__get_workspace_info` / `kobo__get_git_info` / `kobo__get_ticket` for context.',
|
|
12
|
-
'• `kobo__set_workspace_status` when the mission is done
|
|
13
|
+
'• `kobo__set_workspace_status` when the mission is done or irrecoverably blocked. Never set `idle` while this session is active; use AskUserQuestion for clarification, or end the turn.',
|
|
13
14
|
'• `kobo__schedule_wakeup` / `kobo__cancel_wakeup` to schedule (or cancel) a follow-up session — prefer these over the built-in `ScheduleWakeup` tool.',
|
|
14
15
|
'Each tool carries its own "WHEN to use" guidance in its description — follow it.',
|
|
15
16
|
].join('\n');
|
|
@@ -27,6 +28,10 @@ export function buildClaudeOptions(input) {
|
|
|
27
28
|
const options = {
|
|
28
29
|
cwd: input.workingDir,
|
|
29
30
|
systemPrompt: { type: 'preset', preset: 'claude_code', append: KOBO_SYSTEM_APPEND },
|
|
31
|
+
// Kōbō persists wakeups independently of the SDK and creates a fresh
|
|
32
|
+
// controller when they fire. The native wakeup is bound to one SDK stream,
|
|
33
|
+
// so it can outlive Kōbō's controller and produce unanswerable questions.
|
|
34
|
+
disallowedTools: ['ScheduleWakeup'],
|
|
30
35
|
// Leave `tools` undefined: the `claude_code` preset EXCLUDES MCP tools, so
|
|
31
36
|
// under bypassPermissions MCP tools surface as "haven't granted it yet"
|
|
32
37
|
// even when the user chose bypass. Undefined → full toolbox (built-ins +
|
|
@@ -26,6 +26,9 @@ export function createAppServerClient(opts) {
|
|
|
26
26
|
startTurn(params) {
|
|
27
27
|
return peer.request('turn/start', params);
|
|
28
28
|
},
|
|
29
|
+
steerTurn(params) {
|
|
30
|
+
return peer.request('turn/steer', params);
|
|
31
|
+
},
|
|
29
32
|
async interruptTurn(params) {
|
|
30
33
|
await peer.request('turn/interrupt', params);
|
|
31
34
|
},
|
|
@@ -32,6 +32,8 @@ export function createCodexEngine() {
|
|
|
32
32
|
let iteratorRunning = false;
|
|
33
33
|
let userInterrupted = false;
|
|
34
34
|
let discoveredSessionId = options.resumeFromEngineSessionId;
|
|
35
|
+
let activeTurnId;
|
|
36
|
+
let steerChain = Promise.resolve();
|
|
35
37
|
const safeEmit = (ev) => {
|
|
36
38
|
try {
|
|
37
39
|
onEvent(ev);
|
|
@@ -99,7 +101,8 @@ export function createCodexEngine() {
|
|
|
99
101
|
const n = params;
|
|
100
102
|
for (const ev of handleTurnCompleted(n, mapperState))
|
|
101
103
|
safeEmit(ev);
|
|
102
|
-
|
|
104
|
+
if (!activeTurnId || !n.turn?.id || n.turn.id === activeTurnId)
|
|
105
|
+
resolveTurnDone();
|
|
103
106
|
return;
|
|
104
107
|
}
|
|
105
108
|
if (method === 'thread/tokenUsage/updated') {
|
|
@@ -176,11 +179,12 @@ export function createCodexEngine() {
|
|
|
176
179
|
safeEmit(ev);
|
|
177
180
|
// collaborationMode is sticky server-side — always send it explicitly,
|
|
178
181
|
// never omit (would leave a Bypass turn stuck in a previous Plan mode).
|
|
179
|
-
await client.startTurn({
|
|
182
|
+
const initialTurn = await client.startTurn({
|
|
180
183
|
threadId: discoveredSessionId,
|
|
181
184
|
input,
|
|
182
185
|
collaborationMode,
|
|
183
186
|
});
|
|
187
|
+
activeTurnId = initialTurn.turnId;
|
|
184
188
|
await turnDonePromise;
|
|
185
189
|
const reason = mapperState.sawErrorResult
|
|
186
190
|
? 'error'
|
|
@@ -235,8 +239,21 @@ export function createCodexEngine() {
|
|
|
235
239
|
isAlive() {
|
|
236
240
|
return iteratorRunning;
|
|
237
241
|
},
|
|
238
|
-
sendMessage() {
|
|
239
|
-
|
|
242
|
+
sendMessage(text) {
|
|
243
|
+
const steer = async () => {
|
|
244
|
+
if (!discoveredSessionId || !activeTurnId) {
|
|
245
|
+
throw new Error('Codex session is not ready to receive a message');
|
|
246
|
+
}
|
|
247
|
+
const response = await client.steerTurn({
|
|
248
|
+
threadId: discoveredSessionId,
|
|
249
|
+
expectedTurnId: activeTurnId,
|
|
250
|
+
input: [{ type: 'text', text, text_elements: [] }],
|
|
251
|
+
});
|
|
252
|
+
activeTurnId = response.turnId;
|
|
253
|
+
};
|
|
254
|
+
const queued = steerChain.then(steer);
|
|
255
|
+
steerChain = queued.catch(() => { });
|
|
256
|
+
return queued;
|
|
240
257
|
},
|
|
241
258
|
interrupt() {
|
|
242
259
|
userInterrupted = true;
|
|
@@ -7,7 +7,7 @@ const CODEX_KOBO_MCP_BRIEF = [
|
|
|
7
7
|
'• `mcp__kobo-tasks__log_thought` to persist notable decisions to `.ai/thoughts/logs/<date>-<slug>.md`.',
|
|
8
8
|
'• `mcp__kobo-tasks__search_codebase` to recall prior chat history (conversations, not source — use shell tools for source).',
|
|
9
9
|
'• `mcp__kobo-tasks__get_workspace_info` / `mcp__kobo-tasks__get_git_info` / `mcp__kobo-tasks__get_ticket` for context.',
|
|
10
|
-
'• `mcp__kobo-tasks__set_workspace_status` when the mission is done
|
|
10
|
+
'• `mcp__kobo-tasks__set_workspace_status` when the mission is done or irrecoverably blocked. Never set `idle` while this session is active; ask the user or end the turn instead.',
|
|
11
11
|
'• `mcp__kobo-tasks__schedule_wakeup` / `mcp__kobo-tasks__cancel_wakeup` to schedule (or cancel) a follow-up session.',
|
|
12
12
|
'Each tool carries its own "WHEN to use" guidance in its description — follow it.',
|
|
13
13
|
].join('\n');
|
|
@@ -36,12 +36,23 @@ export function handleServerRequest(args) {
|
|
|
36
36
|
}
|
|
37
37
|
if (method === 'item/tool/requestUserInput') {
|
|
38
38
|
register(callId, { requestId, kind: 'user_input', payload: p });
|
|
39
|
+
// Codex allows a free-form question to omit `options` (or send null),
|
|
40
|
+
// while the shared question panel always renders an array. Keep Codex's
|
|
41
|
+
// `id` so the UI can return the protocol key instead of the display text.
|
|
42
|
+
const questions = Array.isArray(p.questions)
|
|
43
|
+
? p.questions.map((question) => {
|
|
44
|
+
if (!question || typeof question !== 'object')
|
|
45
|
+
return question;
|
|
46
|
+
const raw = question;
|
|
47
|
+
return { ...raw, options: Array.isArray(raw.options) ? raw.options : [] };
|
|
48
|
+
})
|
|
49
|
+
: [];
|
|
39
50
|
emit({
|
|
40
51
|
kind: 'session:user-input-requested',
|
|
41
52
|
requestKind: 'question',
|
|
42
53
|
toolCallId: callId,
|
|
43
54
|
toolName: 'AskUserQuestion',
|
|
44
|
-
payload: { questions: p.
|
|
55
|
+
payload: { questions, autoResolutionMs: p.autoResolutionMs },
|
|
45
56
|
});
|
|
46
57
|
return true;
|
|
47
58
|
}
|