kanna-code 0.19.0 → 0.20.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.
@@ -1,4 +1,4 @@
1
- import { query, type CanUseTool, type PermissionResult, type Query } from "@anthropic-ai/claude-agent-sdk"
1
+ import { query, type CanUseTool, type PermissionResult, type Query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"
2
2
  import type {
3
3
  AgentProvider,
4
4
  ChatAttachment,
@@ -64,11 +64,41 @@ interface ActiveTurn {
64
64
  cancelRecorded: boolean
65
65
  }
66
66
 
67
+ interface ClaudeSessionHandle {
68
+ provider: "claude"
69
+ stream: AsyncIterable<HarnessEvent>
70
+ getAccountInfo?: () => Promise<any>
71
+ interrupt: () => Promise<void>
72
+ close: () => void
73
+ sendPrompt: (content: string) => Promise<void>
74
+ setModel: (model: string) => Promise<void>
75
+ setPermissionMode: (planMode: boolean) => Promise<void>
76
+ }
77
+
78
+ interface ClaudeSessionState {
79
+ chatId: string
80
+ session: ClaudeSessionHandle
81
+ localPath: string
82
+ model: string
83
+ effort?: string
84
+ planMode: boolean
85
+ sessionToken: string | null
86
+ accountInfoLoaded: boolean
87
+ }
88
+
67
89
  interface AgentCoordinatorArgs {
68
90
  store: EventStore
69
91
  onStateChange: () => void
70
92
  codexManager?: CodexAppServerManager
71
93
  generateTitle?: (messageContent: string, cwd: string) => Promise<GenerateChatTitleResult>
94
+ startClaudeSession?: (args: {
95
+ localPath: string
96
+ model: string
97
+ effort?: string
98
+ planMode: boolean
99
+ sessionToken: string | null
100
+ onToolRequest: (request: HarnessToolRequest) => Promise<unknown>
101
+ }) => Promise<ClaudeSessionHandle>
72
102
  }
73
103
 
74
104
  function timestamped<T extends Omit<TranscriptEntry, "_id" | "createdAt">>(
@@ -269,15 +299,61 @@ async function* createClaudeHarnessStream(q: Query): AsyncGenerator<HarnessEvent
269
299
  }
270
300
  }
271
301
 
272
- async function startClaudeTurn(args: {
273
- content: string
302
+ class AsyncMessageQueue<T> implements AsyncIterable<T> {
303
+ private readonly values: T[] = []
304
+ private readonly waiters: Array<(result: IteratorResult<T>) => void> = []
305
+ private closed = false
306
+
307
+ push(value: T) {
308
+ if (this.closed) {
309
+ throw new Error("Cannot push to a closed queue")
310
+ }
311
+
312
+ const waiter = this.waiters.shift()
313
+ if (waiter) {
314
+ waiter({ done: false, value })
315
+ return
316
+ }
317
+
318
+ this.values.push(value)
319
+ }
320
+
321
+ close() {
322
+ if (this.closed) return
323
+ this.closed = true
324
+ while (this.waiters.length > 0) {
325
+ const waiter = this.waiters.shift()
326
+ waiter?.({ done: true, value: undefined as never })
327
+ }
328
+ }
329
+
330
+ [Symbol.asyncIterator](): AsyncIterator<T> {
331
+ return {
332
+ next: async () => {
333
+ if (this.values.length > 0) {
334
+ return { done: false, value: this.values.shift() as T }
335
+ }
336
+
337
+ if (this.closed) {
338
+ return { done: true, value: undefined as never }
339
+ }
340
+
341
+ return await new Promise<IteratorResult<T>>((resolve) => {
342
+ this.waiters.push(resolve)
343
+ })
344
+ },
345
+ }
346
+ }
347
+ }
348
+
349
+ async function startClaudeSession(args: {
274
350
  localPath: string
275
351
  model: string
276
352
  effort?: string
277
353
  planMode: boolean
278
354
  sessionToken: string | null
279
355
  onToolRequest: (request: HarnessToolRequest) => Promise<unknown>
280
- }): Promise<HarnessTurn> {
356
+ }): Promise<ClaudeSessionHandle> {
281
357
  const canUseTool: CanUseTool = async (toolName, input, options) => {
282
358
  if (toolName !== "AskUserQuestion" && toolName !== "ExitPlanMode") {
283
359
  return {
@@ -333,8 +409,10 @@ async function startClaudeTurn(args: {
333
409
  } satisfies PermissionResult
334
410
  }
335
411
 
412
+ const promptQueue = new AsyncMessageQueue<SDKUserMessage>()
413
+
336
414
  const q = query({
337
- prompt: args.content,
415
+ prompt: promptQueue,
338
416
  options: {
339
417
  cwd: args.localPath,
340
418
  model: args.model,
@@ -361,7 +439,25 @@ async function startClaudeTurn(args: {
361
439
  interrupt: async () => {
362
440
  await q.interrupt()
363
441
  },
442
+ sendPrompt: async (content: string) => {
443
+ promptQueue.push({
444
+ type: "user",
445
+ message: {
446
+ role: "user",
447
+ content,
448
+ },
449
+ parent_tool_use_id: null,
450
+ session_id: args.sessionToken ?? "",
451
+ })
452
+ },
453
+ setModel: async (model: string) => {
454
+ await q.setModel(model)
455
+ },
456
+ setPermissionMode: async (planMode: boolean) => {
457
+ await q.setPermissionMode(planMode ? "plan" : "acceptEdits")
458
+ },
364
459
  close: () => {
460
+ promptQueue.close()
365
461
  q.close()
366
462
  },
367
463
  }
@@ -372,14 +468,18 @@ export class AgentCoordinator {
372
468
  private readonly onStateChange: () => void
373
469
  private readonly codexManager: CodexAppServerManager
374
470
  private readonly generateTitle: (messageContent: string, cwd: string) => Promise<GenerateChatTitleResult>
471
+ private readonly startClaudeSessionFn: NonNullable<AgentCoordinatorArgs["startClaudeSession"]>
375
472
  private reportBackgroundError: ((message: string) => void) | null = null
376
473
  readonly activeTurns = new Map<string, ActiveTurn>()
474
+ readonly drainingStreams = new Map<string, { turn: HarnessTurn }>()
475
+ readonly claudeSessions = new Map<string, ClaudeSessionState>()
377
476
 
378
477
  constructor(args: AgentCoordinatorArgs) {
379
478
  this.store = args.store
380
479
  this.onStateChange = args.onStateChange
381
480
  this.codexManager = args.codexManager ?? new CodexAppServerManager()
382
481
  this.generateTitle = args.generateTitle ?? generateTitleForChatDetailed
482
+ this.startClaudeSessionFn = args.startClaudeSession ?? startClaudeSession
383
483
  }
384
484
 
385
485
  setBackgroundErrorReporter(report: ((message: string) => void) | null) {
@@ -400,6 +500,28 @@ export class AgentCoordinator {
400
500
  return { toolUseId: pending.toolUseId, toolKind: pending.tool.toolKind }
401
501
  }
402
502
 
503
+ getDrainingChatIds(): Set<string> {
504
+ return new Set(this.drainingStreams.keys())
505
+ }
506
+
507
+ async stopDraining(chatId: string) {
508
+ const draining = this.drainingStreams.get(chatId)
509
+ if (!draining) return
510
+ draining.turn.close()
511
+ this.drainingStreams.delete(chatId)
512
+ this.onStateChange()
513
+ }
514
+
515
+ async closeChat(chatId: string) {
516
+ await this.stopDraining(chatId)
517
+ const claudeSession = this.claudeSessions.get(chatId)
518
+ if (claudeSession) {
519
+ claudeSession.session.close()
520
+ this.claudeSessions.delete(chatId)
521
+ }
522
+ this.onStateChange()
523
+ }
524
+
403
525
  private resolveProvider(command: Extract<ClientCommand, { type: "chat.send" }>, currentProvider: AgentProvider | null) {
404
526
  if (currentProvider) return currentProvider
405
527
  return command.provider ?? "claude"
@@ -438,6 +560,13 @@ export class AgentCoordinator {
438
560
  planMode: boolean
439
561
  appendUserPrompt: boolean
440
562
  }) {
563
+ // Close any lingering draining stream before starting a new turn.
564
+ const draining = this.drainingStreams.get(args.chatId)
565
+ if (draining) {
566
+ draining.turn.close()
567
+ this.drainingStreams.delete(args.chatId)
568
+ }
569
+
441
570
  const chat = this.store.requireChat(args.chatId)
442
571
  if (this.activeTurns.has(args.chatId)) {
443
572
  throw new Error("Chat is already running")
@@ -493,8 +622,8 @@ export class AgentCoordinator {
493
622
 
494
623
  let turn: HarnessTurn
495
624
  if (args.provider === "claude") {
496
- turn = await startClaudeTurn({
497
- content: buildPromptText(args.content, args.attachments),
625
+ turn = await this.startClaudeTurn({
626
+ chatId: args.chatId,
498
627
  localPath: project.localPath,
499
628
  model: args.model,
500
629
  effort: args.effort,
@@ -529,7 +658,7 @@ export class AgentCoordinator {
529
658
  effort: args.effort,
530
659
  serviceTier: args.serviceTier,
531
660
  planMode: args.planMode,
532
- status: "starting",
661
+ status: args.provider === "claude" ? "running" : "starting",
533
662
  pendingTool: null,
534
663
  postToolFollowUp: null,
535
664
  hasFinalResult: false,
@@ -543,15 +672,93 @@ export class AgentCoordinator {
543
672
  void turn.getAccountInfo()
544
673
  .then(async (accountInfo) => {
545
674
  if (!accountInfo) return
675
+ if (args.provider === "claude") {
676
+ const session = this.claudeSessions.get(args.chatId)
677
+ if (session) {
678
+ if (session.accountInfoLoaded) return
679
+ session.accountInfoLoaded = true
680
+ } else {
681
+ return
682
+ }
683
+ }
546
684
  await this.store.appendMessage(args.chatId, timestamped({ kind: "account_info", accountInfo }))
547
685
  this.onStateChange()
548
686
  })
549
687
  .catch(() => undefined)
550
688
  }
551
689
 
690
+ if (args.provider === "claude") {
691
+ const session = this.claudeSessions.get(args.chatId)
692
+ if (!session) {
693
+ throw new Error("Claude session was not initialized")
694
+ }
695
+ await session.session.sendPrompt(buildPromptText(args.content, args.attachments))
696
+ return
697
+ }
698
+
552
699
  void this.runTurn(active)
553
700
  }
554
701
 
702
+ private async startClaudeTurn(args: {
703
+ chatId: string
704
+ localPath: string
705
+ model: string
706
+ effort?: string
707
+ planMode: boolean
708
+ sessionToken: string | null
709
+ onToolRequest: (request: HarnessToolRequest) => Promise<unknown>
710
+ }): Promise<HarnessTurn> {
711
+ let session = this.claudeSessions.get(args.chatId)
712
+
713
+ if (!session || session.localPath !== args.localPath || session.effort !== args.effort) {
714
+ if (session) {
715
+ session.session.close()
716
+ this.claudeSessions.delete(args.chatId)
717
+ }
718
+
719
+ const started = await this.startClaudeSessionFn({
720
+ localPath: args.localPath,
721
+ model: args.model,
722
+ effort: args.effort,
723
+ planMode: args.planMode,
724
+ sessionToken: args.sessionToken,
725
+ onToolRequest: args.onToolRequest,
726
+ })
727
+
728
+ session = {
729
+ chatId: args.chatId,
730
+ session: started,
731
+ localPath: args.localPath,
732
+ model: args.model,
733
+ effort: args.effort,
734
+ planMode: args.planMode,
735
+ sessionToken: args.sessionToken,
736
+ accountInfoLoaded: false,
737
+ }
738
+ this.claudeSessions.set(args.chatId, session)
739
+ void this.runClaudeSession(session)
740
+ } else {
741
+ if (session.model !== args.model) {
742
+ await session.session.setModel(args.model)
743
+ session.model = args.model
744
+ }
745
+ if (session.planMode !== args.planMode) {
746
+ await session.session.setPermissionMode(args.planMode)
747
+ session.planMode = args.planMode
748
+ }
749
+ }
750
+
751
+ return {
752
+ provider: "claude",
753
+ stream: {
754
+ async *[Symbol.asyncIterator]() {},
755
+ },
756
+ getAccountInfo: session.session.getAccountInfo,
757
+ interrupt: session.session.interrupt,
758
+ close: () => {},
759
+ }
760
+ }
761
+
555
762
  async send(command: Extract<ClientCommand, { type: "chat.send" }>) {
556
763
  let chatId = command.chatId
557
764
 
@@ -581,6 +788,66 @@ export class AgentCoordinator {
581
788
  return { chatId }
582
789
  }
583
790
 
791
+ private async runClaudeSession(session: ClaudeSessionState) {
792
+ try {
793
+ for await (const event of session.session.stream) {
794
+ if (event.type === "session_token" && event.sessionToken) {
795
+ session.sessionToken = event.sessionToken
796
+ await this.store.setSessionToken(session.chatId, event.sessionToken)
797
+ this.onStateChange()
798
+ continue
799
+ }
800
+
801
+ if (!event.entry) continue
802
+ await this.store.appendMessage(session.chatId, event.entry)
803
+
804
+ const active = this.activeTurns.get(session.chatId)
805
+ if (event.entry.kind === "system_init" && active) {
806
+ active.status = "running"
807
+ }
808
+
809
+ if (event.entry.kind === "result" && active) {
810
+ active.hasFinalResult = true
811
+ if (event.entry.isError) {
812
+ await this.store.recordTurnFailed(session.chatId, event.entry.result || "Turn failed")
813
+ } else if (!active.cancelRequested) {
814
+ await this.store.recordTurnFinished(session.chatId)
815
+ }
816
+ this.activeTurns.delete(session.chatId)
817
+ }
818
+
819
+ this.onStateChange()
820
+ }
821
+ } catch (error) {
822
+ const active = this.activeTurns.get(session.chatId)
823
+ if (active && !active.cancelRequested) {
824
+ const message = error instanceof Error ? error.message : String(error)
825
+ await this.store.appendMessage(
826
+ session.chatId,
827
+ timestamped({
828
+ kind: "result",
829
+ subtype: "error",
830
+ isError: true,
831
+ durationMs: 0,
832
+ result: message,
833
+ })
834
+ )
835
+ await this.store.recordTurnFailed(session.chatId, message)
836
+ }
837
+ } finally {
838
+ this.claudeSessions.delete(session.chatId)
839
+ const active = this.activeTurns.get(session.chatId)
840
+ if (active?.provider === "claude") {
841
+ if (active.cancelRequested && !active.cancelRecorded) {
842
+ await this.store.recordTurnCancelled(session.chatId)
843
+ }
844
+ this.activeTurns.delete(session.chatId)
845
+ }
846
+ session.session.close()
847
+ this.onStateChange()
848
+ }
849
+ }
850
+
584
851
  private async generateTitleInBackground(chatId: string, messageContent: string, cwd: string, expectedCurrentTitle: string) {
585
852
  try {
586
853
  const result = await this.generateTitle(messageContent, cwd)
@@ -607,6 +874,10 @@ export class AgentCoordinator {
607
874
  private async runTurn(active: ActiveTurn) {
608
875
  try {
609
876
  for await (const event of active.turn.stream) {
877
+ // Once cancelled, stop processing further stream events.
878
+ // cancel() already removed us from activeTurns and notified the UI.
879
+ if (active.cancelRequested) break
880
+
610
881
  if (event.type === "session_token" && event.sessionToken) {
611
882
  await this.store.setSessionToken(active.chatId, event.sessionToken)
612
883
  this.onStateChange()
@@ -627,6 +898,14 @@ export class AgentCoordinator {
627
898
  } else if (!active.cancelRequested) {
628
899
  await this.store.recordTurnFinished(active.chatId)
629
900
  }
901
+ // Remove from activeTurns as soon as the result arrives so the UI
902
+ // transitions to idle immediately. The stream may still be open
903
+ // (e.g. background tasks), but the user should be able to send
904
+ // new messages without having to hit stop first.
905
+ this.activeTurns.delete(active.chatId)
906
+ // Track the still-open stream so the UI can show a draining
907
+ // indicator and the user can stop background tasks.
908
+ this.drainingStreams.set(active.chatId, { turn: active.turn })
630
909
  }
631
910
 
632
911
  this.onStateChange()
@@ -651,7 +930,14 @@ export class AgentCoordinator {
651
930
  await this.store.recordTurnCancelled(active.chatId)
652
931
  }
653
932
  active.turn.close()
654
- this.activeTurns.delete(active.chatId)
933
+ // Only remove if we're still the active turn for this chat.
934
+ // We may have already been removed by result handling or cancel(),
935
+ // and a new turn may have started for the same chatId.
936
+ if (this.activeTurns.get(active.chatId) === active) {
937
+ this.activeTurns.delete(active.chatId)
938
+ }
939
+ // Stream has fully ended — no longer draining.
940
+ this.drainingStreams.delete(active.chatId)
655
941
  this.onStateChange()
656
942
 
657
943
  if (active.postToolFollowUp && !active.cancelRequested) {
@@ -687,9 +973,18 @@ export class AgentCoordinator {
687
973
  }
688
974
 
689
975
  async cancel(chatId: string) {
976
+ // Also clean up any draining stream for this chat.
977
+ const draining = this.drainingStreams.get(chatId)
978
+ if (draining) {
979
+ draining.turn.close()
980
+ this.drainingStreams.delete(chatId)
981
+ }
982
+
690
983
  const active = this.activeTurns.get(chatId)
691
984
  if (!active) return
692
985
 
986
+ // Guard against concurrent cancel() calls — only the first one does work.
987
+ if (active.cancelRequested) return
693
988
  active.cancelRequested = true
694
989
 
695
990
  const pendingTool = active.pendingTool
@@ -715,14 +1010,23 @@ export class AgentCoordinator {
715
1010
  active.cancelRecorded = true
716
1011
  active.hasFinalResult = true
717
1012
 
1013
+ // Remove from activeTurns immediately so the UI reflects the cancellation
1014
+ // right away, rather than waiting for interrupt() which may hang.
1015
+ this.activeTurns.delete(chatId)
1016
+ this.onStateChange()
1017
+
1018
+ // Now attempt to interrupt/close the underlying stream in the background.
1019
+ // This is best-effort — the turn is already removed from active state above,
1020
+ // and runTurn()'s finally block will also call close().
718
1021
  try {
719
- await active.turn.interrupt()
1022
+ await Promise.race([
1023
+ active.turn.interrupt(),
1024
+ new Promise((resolve) => setTimeout(resolve, 5_000)),
1025
+ ])
720
1026
  } catch {
721
- active.turn.close()
1027
+ // interrupt() failed — force close
722
1028
  }
723
-
724
- this.activeTurns.delete(chatId)
725
- this.onStateChange()
1029
+ active.turn.close()
726
1030
  }
727
1031
 
728
1032
  async respondTool(command: Extract<ClientCommand, { type: "chat.respondTool" }>) {
@@ -54,7 +54,7 @@ describe("read models", () => {
54
54
  lastTurnOutcome: null,
55
55
  })
56
56
 
57
- const chat = deriveChatSnapshot(state, new Map(), "chat-1", () => [])
57
+ const chat = deriveChatSnapshot(state, new Map(), new Set(), "chat-1", () => [])
58
58
  expect(chat?.runtime.provider).toBe("claude")
59
59
  expect(chat?.availableProviders.length).toBeGreaterThan(1)
60
60
  expect(chat?.availableProviders.find((provider) => provider.id === "codex")?.models.map((model) => model.id)).toEqual([
@@ -98,6 +98,7 @@ export function deriveLocalProjectsSnapshot(
98
98
  export function deriveChatSnapshot(
99
99
  state: StoreState,
100
100
  activeStatuses: Map<string, KannaStatus>,
101
+ drainingChatIds: Set<string>,
101
102
  chatId: string,
102
103
  getMessages: (chatId: string) => ChatSnapshot["messages"]
103
104
  ): ChatSnapshot | null {
@@ -112,6 +113,7 @@ export function deriveChatSnapshot(
112
113
  localPath: project.localPath,
113
114
  title: chat.title,
114
115
  status: deriveStatus(chat, activeStatuses.get(chat.id)),
116
+ isDraining: drainingChatIds.has(chat.id),
115
117
  provider: chat.provider,
116
118
  planMode: chat.planMode,
117
119
  sessionToken: chat.sessionToken,
@@ -41,7 +41,7 @@ describe("ws-router", () => {
41
41
  test("acks system.ping without broadcasting snapshots", () => {
42
42
  const router = createWsRouter({
43
43
  store: { state: createEmptyState() } as never,
44
- agent: { getActiveStatuses: () => new Map() } as never,
44
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
45
45
  terminals: {
46
46
  getSnapshot: () => null,
47
47
  onEvent: () => () => {},
@@ -80,7 +80,7 @@ describe("ws-router", () => {
80
80
  test("acks terminal.input without rebroadcasting terminal snapshots", () => {
81
81
  const router = createWsRouter({
82
82
  store: { state: createEmptyState() } as never,
83
- agent: { getActiveStatuses: () => new Map() } as never,
83
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
84
84
  terminals: {
85
85
  getSnapshot: () => null,
86
86
  onEvent: () => () => {},
@@ -124,7 +124,7 @@ describe("ws-router", () => {
124
124
  test("subscribes and unsubscribes chat topics", () => {
125
125
  const router = createWsRouter({
126
126
  store: { state: createEmptyState() } as never,
127
- agent: { getActiveStatuses: () => new Map() } as never,
127
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
128
128
  terminals: {
129
129
  getSnapshot: () => null,
130
130
  onEvent: () => () => {},
@@ -373,7 +373,7 @@ describe("ws-router", () => {
373
373
 
374
374
  const router = createWsRouter({
375
375
  store: { state: createEmptyState() } as never,
376
- agent: { getActiveStatuses: () => new Map() } as never,
376
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
377
377
  terminals: {
378
378
  getSnapshot: () => null,
379
379
  onEvent: () => () => {},
@@ -474,7 +474,7 @@ describe("ws-router", () => {
474
474
 
475
475
  const router = createWsRouter({
476
476
  store: { state: createEmptyState() } as never,
477
- agent: { getActiveStatuses: () => new Map() } as never,
477
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
478
478
  terminals: {
479
479
  getSnapshot: () => null,
480
480
  onEvent: () => () => {},
@@ -121,7 +121,7 @@ export function createWsRouter({
121
121
  id,
122
122
  snapshot: {
123
123
  type: "chat",
124
- data: deriveChatSnapshot(store.state, agent.getActiveStatuses(), topic.chatId, (chatId) => store.getMessages(chatId)),
124
+ data: deriveChatSnapshot(store.state, agent.getActiveStatuses(), agent.getDrainingChatIds(), topic.chatId, (chatId) => store.getMessages(chatId)),
125
125
  },
126
126
  }
127
127
  }
@@ -258,6 +258,7 @@ export function createWsRouter({
258
258
  const project = store.getProject(command.projectId)
259
259
  for (const chat of store.listChatsByProject(command.projectId)) {
260
260
  await agent.cancel(chat.id)
261
+ await agent.closeChat(chat.id)
261
262
  }
262
263
  if (project) {
263
264
  terminals.closeByCwd(project.localPath)
@@ -283,6 +284,7 @@ export function createWsRouter({
283
284
  }
284
285
  case "chat.delete": {
285
286
  await agent.cancel(command.chatId)
287
+ await agent.closeChat(command.chatId)
286
288
  await store.deleteChat(command.chatId)
287
289
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
288
290
  break
@@ -302,6 +304,11 @@ export function createWsRouter({
302
304
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
303
305
  break
304
306
  }
307
+ case "chat.stopDraining": {
308
+ await agent.stopDraining(command.chatId)
309
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
310
+ break
311
+ }
305
312
  case "chat.respondTool": {
306
313
  await agent.respondTool(command)
307
314
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
@@ -76,6 +76,7 @@ export type ClientCommand =
76
76
  planMode?: boolean
77
77
  }
78
78
  | { type: "chat.cancel"; chatId: string }
79
+ | { type: "chat.stopDraining"; chatId: string }
79
80
  | { type: "chat.respondTool"; chatId: string; toolUseId: string; result: unknown }
80
81
  | { type: "terminal.create"; projectId: string; terminalId: string; cols: number; rows: number; scrollback: number }
81
82
  | { type: "terminal.input"; terminalId: string; data: string }
@@ -592,6 +592,7 @@ export interface ChatRuntime {
592
592
  localPath: string
593
593
  title: string
594
594
  status: KannaStatus
595
+ isDraining: boolean
595
596
  provider: AgentProvider | null
596
597
  planMode: boolean
597
598
  sessionToken: string | null