kanna-code 0.3.0 → 0.4.1

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.
@@ -0,0 +1,274 @@
1
+ import process from "node:process"
2
+ import { spawn, spawnSync } from "node:child_process"
3
+ import { APP_NAME, CLI_COMMAND, getDataDirDisplay, LOG_PREFIX, PACKAGE_NAME } from "../shared/branding"
4
+ import { PROD_SERVER_PORT } from "../shared/ports"
5
+
6
+ export interface CliOptions {
7
+ port: number
8
+ openBrowser: boolean
9
+ strictPort: boolean
10
+ }
11
+
12
+ export interface StartedCli {
13
+ kind: "started"
14
+ stop: () => Promise<void>
15
+ }
16
+
17
+ export interface ExitedCli {
18
+ kind: "exited"
19
+ code: number
20
+ }
21
+
22
+ export type CliRunResult = StartedCli | ExitedCli
23
+
24
+ export interface CliRuntimeDeps {
25
+ version: string
26
+ startServer: (options: CliOptions) => Promise<{ port: number; stop: () => Promise<void> }>
27
+ fetchLatestVersion: (packageName: string) => Promise<string>
28
+ installLatest: (packageName: string) => boolean
29
+ relaunch: (command: string, args: string[]) => number | null
30
+ openUrl: (url: string) => void
31
+ log: (message: string) => void
32
+ warn: (message: string) => void
33
+ }
34
+
35
+ type ParsedArgs =
36
+ | { kind: "run"; options: CliOptions }
37
+ | { kind: "help" }
38
+ | { kind: "version" }
39
+
40
+ function printHelp() {
41
+ console.log(`${APP_NAME} — local-only project chat UI
42
+
43
+ Usage:
44
+ ${CLI_COMMAND} [options]
45
+
46
+ Options:
47
+ --port <number> Port to listen on (default: ${PROD_SERVER_PORT})
48
+ --strict-port Fail instead of trying another port
49
+ --no-open Don't open browser automatically
50
+ --version Print version and exit
51
+ --help Show this help message`)
52
+ }
53
+
54
+ export function parseArgs(argv: string[]): ParsedArgs {
55
+ let port = PROD_SERVER_PORT
56
+ let openBrowser = true
57
+ let strictPort = false
58
+
59
+ for (let index = 0; index < argv.length; index += 1) {
60
+ const arg = argv[index]
61
+ if (arg === "--version" || arg === "-v") {
62
+ return { kind: "version" }
63
+ }
64
+ if (arg === "--help" || arg === "-h") {
65
+ return { kind: "help" }
66
+ }
67
+ if (arg === "--port") {
68
+ const next = argv[index + 1]
69
+ if (!next) throw new Error("Missing value for --port")
70
+ port = Number(next)
71
+ index += 1
72
+ continue
73
+ }
74
+ if (arg === "--no-open") {
75
+ openBrowser = false
76
+ continue
77
+ }
78
+ if (arg === "--strict-port") {
79
+ strictPort = true
80
+ continue
81
+ }
82
+ if (!arg.startsWith("-")) throw new Error(`Unexpected positional argument: ${arg}`)
83
+ }
84
+
85
+ return {
86
+ kind: "run",
87
+ options: {
88
+ port,
89
+ openBrowser,
90
+ strictPort,
91
+ },
92
+ }
93
+ }
94
+
95
+ export function compareVersions(currentVersion: string, latestVersion: string) {
96
+ const currentParts = normalizeVersion(currentVersion)
97
+ const latestParts = normalizeVersion(latestVersion)
98
+ const length = Math.max(currentParts.length, latestParts.length)
99
+
100
+ for (let index = 0; index < length; index += 1) {
101
+ const current = currentParts[index] ?? 0
102
+ const latest = latestParts[index] ?? 0
103
+ if (current === latest) continue
104
+ return current < latest ? -1 : 1
105
+ }
106
+
107
+ return 0
108
+ }
109
+
110
+ function normalizeVersion(version: string) {
111
+ return version
112
+ .trim()
113
+ .replace(/^v/i, "")
114
+ .split("-")[0]
115
+ .split(".")
116
+ .map((part) => Number.parseInt(part, 10))
117
+ .filter((part) => Number.isFinite(part))
118
+ }
119
+
120
+ async function maybeSelfUpdate(argv: string[], deps: CliRuntimeDeps) {
121
+ deps.log(`${LOG_PREFIX} checking for updates`)
122
+
123
+ let latestVersion: string
124
+ try {
125
+ latestVersion = await deps.fetchLatestVersion(PACKAGE_NAME)
126
+ }
127
+ catch (error) {
128
+ deps.warn(`${LOG_PREFIX} update check failed, continuing current version`)
129
+ if (error instanceof Error && error.message) {
130
+ deps.warn(`${LOG_PREFIX} ${error.message}`)
131
+ }
132
+ return null
133
+ }
134
+
135
+ if (!latestVersion || compareVersions(deps.version, latestVersion) >= 0) {
136
+ return null
137
+ }
138
+
139
+ deps.log(`${LOG_PREFIX} updating to ${latestVersion}`)
140
+ if (!deps.installLatest(PACKAGE_NAME)) {
141
+ deps.warn(`${LOG_PREFIX} update failed, continuing current version`)
142
+ return null
143
+ }
144
+
145
+ deps.log(`${LOG_PREFIX} restarting into updated version`)
146
+ const exitCode = deps.relaunch(CLI_COMMAND, argv)
147
+ if (exitCode === null) {
148
+ deps.warn(`${LOG_PREFIX} restart failed, continuing current version`)
149
+ return null
150
+ }
151
+
152
+ return exitCode
153
+ }
154
+
155
+ export async function runCli(argv: string[], deps: CliRuntimeDeps): Promise<CliRunResult> {
156
+ const parsedArgs = parseArgs(argv)
157
+ if (parsedArgs.kind === "version") {
158
+ deps.log(deps.version)
159
+ return { kind: "exited", code: 0 }
160
+ }
161
+ if (parsedArgs.kind === "help") {
162
+ printHelp()
163
+ return { kind: "exited", code: 0 }
164
+ }
165
+
166
+ const relaunchExitCode = await maybeSelfUpdate(argv, deps)
167
+ if (relaunchExitCode !== null) {
168
+ return { kind: "exited", code: relaunchExitCode }
169
+ }
170
+
171
+ const { port, stop } = await deps.startServer(parsedArgs.options)
172
+ const url = `http://localhost:${port}`
173
+ const launchUrl = url
174
+
175
+ deps.log(`${LOG_PREFIX} listening on ${url}`)
176
+ deps.log(`${LOG_PREFIX} data dir: ${getDataDirDisplay()}`)
177
+
178
+ if (parsedArgs.options.openBrowser) {
179
+ deps.openUrl(launchUrl)
180
+ }
181
+
182
+ return {
183
+ kind: "started",
184
+ stop,
185
+ }
186
+ }
187
+
188
+ function spawnDetached(command: string, args: string[]) {
189
+ spawn(command, args, { stdio: "ignore", detached: true }).unref()
190
+ }
191
+
192
+ function hasCommand(command: string) {
193
+ const result = spawnSync("sh", ["-lc", `command -v ${command}`], { stdio: "ignore" })
194
+ return result.status === 0
195
+ }
196
+
197
+ function canOpenMacApp(appName: string) {
198
+ const result = spawnSync("open", ["-Ra", appName], { stdio: "ignore" })
199
+ return result.status === 0
200
+ }
201
+
202
+ export function openUrl(url: string) {
203
+ const platform = process.platform
204
+ if (platform === "darwin") {
205
+ const appCandidates = [
206
+ "Google Chrome",
207
+ "Chromium",
208
+ "Brave Browser",
209
+ "Microsoft Edge",
210
+ "Arc",
211
+ ]
212
+
213
+ for (const appName of appCandidates) {
214
+ if (!canOpenMacApp(appName)) continue
215
+ spawnDetached("open", ["-a", appName, "--args", `--app=${url}`])
216
+ console.log(`${LOG_PREFIX} opened in app window via ${appName}`)
217
+ return
218
+ }
219
+
220
+ spawnDetached("open", [url])
221
+ console.log(`${LOG_PREFIX} opened in default browser`)
222
+ return
223
+ }
224
+ if (platform === "win32") {
225
+ const browserCommands = ["chrome", "msedge", "brave", "chromium"]
226
+ for (const command of browserCommands) {
227
+ if (!hasCommand(command)) continue
228
+ spawnDetached(command, [`--app=${url}`])
229
+ console.log(`${LOG_PREFIX} opened in app window via ${command}`)
230
+ return
231
+ }
232
+
233
+ spawnDetached("cmd", ["/c", "start", "", url])
234
+ console.log(`${LOG_PREFIX} opened in default browser`)
235
+ return
236
+ }
237
+
238
+ const browserCommands = ["google-chrome", "chromium", "brave-browser", "microsoft-edge"]
239
+ for (const command of browserCommands) {
240
+ if (!hasCommand(command)) continue
241
+ spawnDetached(command, [`--app=${url}`])
242
+ console.log(`${LOG_PREFIX} opened in app window via ${command}`)
243
+ return
244
+ }
245
+
246
+ spawnDetached("xdg-open", [url])
247
+ console.log(`${LOG_PREFIX} opened in default browser`)
248
+ }
249
+
250
+ export async function fetchLatestPackageVersion(packageName: string) {
251
+ const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`)
252
+ if (!response.ok) {
253
+ throw new Error(`registry returned ${response.status}`)
254
+ }
255
+
256
+ const payload = await response.json() as { version?: unknown }
257
+ if (typeof payload.version !== "string" || !payload.version.trim()) {
258
+ throw new Error("registry response did not include a version")
259
+ }
260
+
261
+ return payload.version
262
+ }
263
+
264
+ export function installLatestPackage(packageName: string) {
265
+ if (!hasCommand("bun")) return false
266
+ const result = spawnSync("bun", ["install", "-g", `${packageName}@latest`], { stdio: "inherit" })
267
+ return result.status === 0
268
+ }
269
+
270
+ export function relaunchCli(command: string, args: string[]) {
271
+ const result = spawnSync(command, args, { stdio: "inherit" })
272
+ if (result.error) return null
273
+ return result.status ?? 0
274
+ }
package/src/server/cli.ts CHANGED
@@ -1,141 +1,34 @@
1
1
  import process from "node:process"
2
- import { spawn, spawnSync } from "node:child_process"
3
- import { APP_NAME, CLI_COMMAND, getDataDirDisplay, LOG_PREFIX } from "../shared/branding"
4
- import { PROD_SERVER_PORT } from "../shared/ports"
2
+ import {
3
+ fetchLatestPackageVersion,
4
+ installLatestPackage,
5
+ openUrl,
6
+ relaunchCli,
7
+ runCli,
8
+ } from "./cli-runtime"
5
9
  import { startKannaServer } from "./server"
6
10
 
7
11
  // Read version from package.json at the package root
8
12
  const pkg = await Bun.file(new URL("../../package.json", import.meta.url)).json()
9
13
  const VERSION: string = pkg.version ?? "0.0.0"
10
14
 
11
- interface CliOptions {
12
- port: number
13
- openBrowser: boolean
14
- }
15
-
16
- function printHelp() {
17
- console.log(`${APP_NAME} — local-only project chat UI
18
-
19
- Usage:
20
- ${CLI_COMMAND} [options]
21
-
22
- Options:
23
- --port <number> Port to listen on (default: ${PROD_SERVER_PORT})
24
- --no-open Don't open browser automatically
25
- --version Print version and exit
26
- --help Show this help message`)
27
- }
28
-
29
- function parseArgs(argv: string[]): CliOptions {
30
- let port = PROD_SERVER_PORT
31
- let openBrowser = true
32
-
33
- for (let index = 0; index < argv.length; index += 1) {
34
- const arg = argv[index]
35
- if (arg === "--version" || arg === "-v") {
36
- console.log(VERSION)
37
- process.exit(0)
38
- }
39
- if (arg === "--help" || arg === "-h") {
40
- printHelp()
41
- process.exit(0)
42
- }
43
- if (arg === "--port") {
44
- const next = argv[index + 1]
45
- if (!next) throw new Error("Missing value for --port")
46
- port = Number(next)
47
- index += 1
48
- continue
49
- }
50
- if (arg === "--no-open") {
51
- openBrowser = false
52
- continue
53
- }
54
- if (!arg.startsWith("-")) throw new Error(`Unexpected positional argument: ${arg}`)
55
- }
56
-
57
- return {
58
- port,
59
- openBrowser,
60
- }
61
- }
62
-
63
- function spawnDetached(command: string, args: string[]) {
64
- spawn(command, args, { stdio: "ignore", detached: true }).unref()
65
- }
66
-
67
- function hasCommand(command: string) {
68
- const result = spawnSync("sh", ["-lc", `command -v ${command}`], { stdio: "ignore" })
69
- return result.status === 0
70
- }
71
-
72
- function canOpenMacApp(appName: string) {
73
- const result = spawnSync("open", ["-Ra", appName], { stdio: "ignore" })
74
- return result.status === 0
75
- }
76
-
77
- function openUrl(url: string) {
78
- const platform = process.platform
79
- if (platform === "darwin") {
80
- const appCandidates = [
81
- "Google Chrome",
82
- "Chromium",
83
- "Brave Browser",
84
- "Microsoft Edge",
85
- "Arc",
86
- ]
87
-
88
- for (const appName of appCandidates) {
89
- if (!canOpenMacApp(appName)) continue
90
- spawnDetached("open", ["-a", appName, "--args", `--app=${url}`])
91
- console.log(`${LOG_PREFIX} opened in app window via ${appName}`)
92
- return
93
- }
94
-
95
- spawnDetached("open", [url])
96
- console.log(`${LOG_PREFIX} opened in default browser`)
97
- return
98
- }
99
- if (platform === "win32") {
100
- const browserCommands = ["chrome", "msedge", "brave", "chromium"]
101
- for (const command of browserCommands) {
102
- if (!hasCommand(command)) continue
103
- spawnDetached(command, [`--app=${url}`])
104
- console.log(`${LOG_PREFIX} opened in app window via ${command}`)
105
- return
106
- }
107
-
108
- spawnDetached("cmd", ["/c", "start", "", url])
109
- console.log(`${LOG_PREFIX} opened in default browser`)
110
- return
111
- }
112
-
113
- const browserCommands = ["google-chrome", "chromium", "brave-browser", "microsoft-edge"]
114
- for (const command of browserCommands) {
115
- if (!hasCommand(command)) continue
116
- spawnDetached(command, [`--app=${url}`])
117
- console.log(`${LOG_PREFIX} opened in app window via ${command}`)
118
- return
119
- }
120
-
121
- spawnDetached("xdg-open", [url])
122
- console.log(`${LOG_PREFIX} opened in default browser`)
123
- }
124
-
125
- const options = parseArgs(process.argv.slice(2))
126
- const { port, stop } = await startKannaServer(options)
127
- const url = `http://localhost:${port}`
128
- const launchUrl = `${url}/projects`
129
-
130
- console.log(`${LOG_PREFIX} listening on ${url}`)
131
- console.log(`${LOG_PREFIX} data dir: ${getDataDirDisplay()}`)
15
+ const result = await runCli(process.argv.slice(2), {
16
+ version: VERSION,
17
+ startServer: startKannaServer,
18
+ fetchLatestVersion: fetchLatestPackageVersion,
19
+ installLatest: installLatestPackage,
20
+ relaunch: relaunchCli,
21
+ openUrl,
22
+ log: console.log,
23
+ warn: console.warn,
24
+ })
132
25
 
133
- if (options.openBrowser) {
134
- openUrl(launchUrl)
26
+ if (result.kind === "exited") {
27
+ process.exit(result.code)
135
28
  }
136
29
 
137
30
  const shutdown = async () => {
138
- await stop()
31
+ await result.stop()
139
32
  process.exit(0)
140
33
  }
141
34
 
@@ -1186,6 +1186,104 @@ describe("CodexAppServerManager", () => {
1186
1186
  })
1187
1187
  })
1188
1188
 
1189
+ test("infers multi-select Codex questions from prompt text and returns multiple answers", async () => {
1190
+ const process = new FakeCodexProcess((message, child) => {
1191
+ if (message.method === "initialize") {
1192
+ child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
1193
+ } else if (message.method === "thread/start") {
1194
+ child.writeServerMessage({
1195
+ id: message.id,
1196
+ result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
1197
+ })
1198
+ } else if (message.method === "turn/start") {
1199
+ child.writeServerMessage({
1200
+ id: message.id,
1201
+ result: { turn: { id: "turn-1", status: "inProgress", error: null } },
1202
+ })
1203
+ child.writeServerMessage({
1204
+ id: "req-1",
1205
+ method: "item/tool/requestUserInput",
1206
+ params: {
1207
+ threadId: "thread-1",
1208
+ turnId: "turn-1",
1209
+ itemId: "ask-1",
1210
+ questions: [
1211
+ {
1212
+ id: "runtimes",
1213
+ header: "Runtime",
1214
+ question: "Select all runtimes that apply",
1215
+ isOther: true,
1216
+ isSecret: false,
1217
+ options: [
1218
+ { label: "bun", description: null },
1219
+ { label: "node", description: null },
1220
+ ],
1221
+ },
1222
+ ],
1223
+ },
1224
+ })
1225
+ child.writeServerMessage({
1226
+ method: "turn/completed",
1227
+ params: {
1228
+ threadId: "thread-1",
1229
+ turn: { id: "turn-1", status: "completed", error: null },
1230
+ },
1231
+ })
1232
+ }
1233
+ })
1234
+
1235
+ const manager = new CodexAppServerManager({
1236
+ spawnProcess: () => process as never,
1237
+ })
1238
+
1239
+ await manager.startSession({
1240
+ chatId: "chat-1",
1241
+ cwd: "/tmp/project",
1242
+ model: "gpt-5.4",
1243
+ sessionToken: null,
1244
+ })
1245
+
1246
+ const turn = await manager.startTurn({
1247
+ chatId: "chat-1",
1248
+ model: "gpt-5.4",
1249
+ content: "ask me",
1250
+ planMode: false,
1251
+ onToolRequest: async ({ tool }) => {
1252
+ expect(tool.toolKind).toBe("ask_user_question")
1253
+ if (tool.toolKind !== "ask_user_question") {
1254
+ return {}
1255
+ }
1256
+
1257
+ expect(tool.input.questions[0]?.multiSelect).toBe(true)
1258
+
1259
+ return {
1260
+ questions: [{
1261
+ id: "runtimes",
1262
+ question: "Select all runtimes that apply",
1263
+ multiSelect: true,
1264
+ }],
1265
+ answers: {
1266
+ runtimes: ["bun", "node"],
1267
+ },
1268
+ }
1269
+ },
1270
+ })
1271
+
1272
+ await collectStream(turn.stream)
1273
+
1274
+ const response = process.messages.find((message: any) => message.id === "req-1")
1275
+ expect(response).toEqual({
1276
+ id: "req-1",
1277
+ result: {
1278
+ answers: {
1279
+ runtimes: {
1280
+ answers: ["bun", "node"],
1281
+ },
1282
+ },
1283
+ },
1284
+ })
1285
+ })
1286
+
1189
1287
  test("sends approval decisions back to the app-server", async () => {
1190
1288
  const process = new FakeCodexProcess((message, child) => {
1191
1289
  if (message.method === "initialize") {
@@ -1307,6 +1405,94 @@ describe("CodexAppServerManager", () => {
1307
1405
  })
1308
1406
  })
1309
1407
 
1408
+ test("interrupt clears a pending exit-plan wait so a new turn can start immediately", async () => {
1409
+ let resolveToolRequest!: (value: unknown) => void
1410
+
1411
+ const process = new FakeCodexProcess((message, child) => {
1412
+ if (message.method === "initialize") {
1413
+ child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
1414
+ } else if (message.method === "thread/start") {
1415
+ child.writeServerMessage({
1416
+ id: message.id,
1417
+ result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
1418
+ })
1419
+ } else if (message.method === "turn/start") {
1420
+ if (message.params.input[0]?.text === "make a plan") {
1421
+ child.writeServerMessage({
1422
+ id: message.id,
1423
+ result: { turn: { id: "turn-plan", status: "completed", error: null } },
1424
+ })
1425
+ child.writeServerMessage({
1426
+ method: "turn/plan/updated",
1427
+ params: {
1428
+ threadId: "thread-1",
1429
+ turnId: "turn-plan",
1430
+ explanation: "Plan the work",
1431
+ plan: [{ step: "Inspect repo", status: "completed" }],
1432
+ },
1433
+ })
1434
+ child.writeServerMessage({
1435
+ method: "turn/completed",
1436
+ params: {
1437
+ threadId: "thread-1",
1438
+ turn: { id: "turn-plan", status: "completed", error: null },
1439
+ },
1440
+ })
1441
+ } else {
1442
+ child.writeServerMessage({
1443
+ id: message.id,
1444
+ result: { turn: { id: "turn-next", status: "completed", error: null } },
1445
+ })
1446
+ child.writeServerMessage({
1447
+ method: "turn/completed",
1448
+ params: {
1449
+ threadId: "thread-1",
1450
+ turn: { id: "turn-next", status: "completed", error: null },
1451
+ },
1452
+ })
1453
+ }
1454
+ }
1455
+ })
1456
+
1457
+ const manager = new CodexAppServerManager({
1458
+ spawnProcess: () => process as never,
1459
+ })
1460
+
1461
+ await manager.startSession({
1462
+ chatId: "chat-1",
1463
+ cwd: "/tmp/project",
1464
+ model: "gpt-5.4",
1465
+ sessionToken: null,
1466
+ })
1467
+
1468
+ const turn = await manager.startTurn({
1469
+ chatId: "chat-1",
1470
+ model: "gpt-5.4",
1471
+ content: "make a plan",
1472
+ planMode: true,
1473
+ onToolRequest: async () => await new Promise((resolve) => {
1474
+ resolveToolRequest = resolve
1475
+ }),
1476
+ })
1477
+
1478
+ const iterator = turn.stream[Symbol.asyncIterator]()
1479
+ await iterator.next()
1480
+ await iterator.next()
1481
+ await iterator.next()
1482
+ await turn.interrupt()
1483
+
1484
+ const nextTurn = await manager.startTurn({
1485
+ chatId: "chat-1",
1486
+ model: "gpt-5.4",
1487
+ content: "continue",
1488
+ planMode: false,
1489
+ onToolRequest: async () => ({}),
1490
+ })
1491
+
1492
+ await collectStream(nextTurn.stream)
1493
+ resolveToolRequest({})
1494
+ })
1495
+
1310
1496
  test("emits an error result when the app-server exits mid-turn", async () => {
1311
1497
  const process = new FakeCodexProcess((message, child) => {
1312
1498
  if (message.method === "initialize") {
@@ -30,6 +30,7 @@ import {
30
30
  type ThreadStartParams,
31
31
  type ThreadStartResponse,
32
32
  type ToolRequestUserInputParams,
33
+ type ToolRequestUserInputQuestion,
33
34
  type ToolRequestUserInputResponse,
34
35
  type TurnPlanStep,
35
36
  type TurnPlanUpdatedNotification,
@@ -174,6 +175,13 @@ function isRecoverableResumeError(error: unknown): boolean {
174
175
  )
175
176
  }
176
177
 
178
+ const MULTI_SELECT_HINT_PATTERN = /\b(all that apply|select all|choose all|pick all|select multiple|choose multiple|pick multiple|multiple selections?|multiple choice|more than one|one or more)\b/i
179
+
180
+ function inferQuestionAllowsMultiple(question: ToolRequestUserInputQuestion): boolean {
181
+ const combinedText = [question.header, question.question].filter(Boolean).join(" ")
182
+ return MULTI_SELECT_HINT_PATTERN.test(combinedText)
183
+ }
184
+
177
185
  function toAskUserQuestionItems(params: ToolRequestUserInputParams): AskUserQuestionItem[] {
178
186
  return params.questions.map((question) => ({
179
187
  id: question.id,
@@ -183,7 +191,7 @@ function toAskUserQuestionItems(params: ToolRequestUserInputParams): AskUserQues
183
191
  label: option.label,
184
192
  description: option.description ?? undefined,
185
193
  })),
186
- multiSelect: false,
194
+ multiSelect: inferQuestionAllowsMultiple(question),
187
195
  }))
188
196
  }
189
197
 
@@ -782,7 +790,14 @@ export class CodexAppServerManager {
782
790
  stream: queue,
783
791
  interrupt: async () => {
784
792
  const pendingTurn = context.pendingTurn
785
- if (!pendingTurn?.turnId || !context.sessionToken) return
793
+ if (!pendingTurn) return
794
+
795
+ context.pendingTurn = null
796
+ pendingTurn.resolved = true
797
+ pendingTurn.queue.finish()
798
+
799
+ if (!pendingTurn.turnId || !context.sessionToken) return
800
+
786
801
  await this.sendRequest(context, "turn/interrupt", {
787
802
  threadId: context.sessionToken,
788
803
  turnId: pendingTurn.turnId,