devez-vibe 0.1.46 → 0.1.47

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/bin/dvz.exe CHANGED
Binary file
@@ -7,10 +7,10 @@ import {
7
7
  deleteSession,
8
8
  forkSession,
9
9
  getSessionInfo,
10
- getSessionMessages,
11
- listSessions,
12
- startup,
13
- } from "@anthropic-ai/claude-agent-sdk";
10
+ getSessionMessages,
11
+ listSessions,
12
+ startup,
13
+ } from "@anthropic-ai/claude-agent-sdk";
14
14
 
15
15
  const VERSION = process.env.DEVEZ_VIBE_VERSION || "dev";
16
16
  const sessions = new Map();
@@ -82,23 +82,23 @@ function hostRequest(method, params, signal) {
82
82
  });
83
83
  }
84
84
 
85
- function sanitizedEnvironment() {
85
+ function sanitizedEnvironment() {
86
86
  const env = { ...process.env };
87
87
  delete env.ANTHROPIC_API_KEY;
88
88
  delete env.ANTHROPIC_AUTH_TOKEN;
89
89
  env.CLAUDE_AGENT_SDK_CLIENT_APP = `devez-vibe/${VERSION}`;
90
- return env;
91
- }
92
-
93
- function applyClaudeExecutable(options, params) {
94
- const executable = String(params.claudePath || "").trim();
95
- if (!executable) return;
96
- // On Windows use the SDK's matching native CLI bundle. It reads the same
97
- // ~/.claude credentials, settings, hooks, skills, and plugins, while avoiding
98
- // EINVAL from command shims and incompatible separately-installed CLI builds.
99
- if (process.platform === "win32") return;
100
- options.pathToClaudeCodeExecutable = executable;
101
- }
90
+ return env;
91
+ }
92
+
93
+ function applyClaudeExecutable(options, params) {
94
+ const executable = String(params.claudePath || "").trim();
95
+ if (!executable) return;
96
+ // On Windows use the SDK's matching native CLI bundle. It reads the same
97
+ // ~/.claude credentials, settings, hooks, skills, and plugins, while avoiding
98
+ // EINVAL from command shims and incompatible separately-installed CLI builds.
99
+ if (process.platform === "win32") return;
100
+ options.pathToClaudeCodeExecutable = executable;
101
+ }
102
102
 
103
103
  function modelCapabilities(models, model) {
104
104
  const value = stripClaudeModel(model);
@@ -135,7 +135,7 @@ function compactClaudeModelName(model) {
135
135
  return fallback || clean(model.displayName || model.resolvedModel || model.value);
136
136
  }
137
137
 
138
- function catalogEntry(model, defaultResolvedModel) {
138
+ function catalogEntry(model, defaultResolvedModel) {
139
139
  const value = String(model.value || "");
140
140
  const resolved = String(model.resolvedModel || value);
141
141
  const efforts = model.supportsEffort && Array.isArray(model.supportedEffortLevels)
@@ -148,7 +148,7 @@ function catalogEntry(model, defaultResolvedModel) {
148
148
  displayName: compactClaudeModelName(model),
149
149
  defaultReasoningEffort: efforts.includes("high") ? "high" : efforts.at(-1) || "",
150
150
  supportedReasoningEfforts: efforts.map((reasoningEffort) => ({ reasoningEffort })),
151
- isDefault: Boolean(defaultResolvedModel) && resolved === defaultResolvedModel,
151
+ isDefault: Boolean(defaultResolvedModel) && resolved === defaultResolvedModel,
152
152
  ...(contextWindow > 0 ? { contextWindow } : {}),
153
153
  };
154
154
  }
@@ -166,21 +166,21 @@ async function loadModelCatalog(params) {
166
166
  env: sanitizedEnvironment(),
167
167
  stderr: (data) => process.stderr.write(data),
168
168
  };
169
- applyClaudeExecutable(options, params);
170
- const agentQuery = await startAgentQuery(input, options);
169
+ applyClaudeExecutable(options, params);
170
+ const agentQuery = await startAgentQuery(input, options);
171
171
  const consumer = (async () => {
172
172
  try { for await (const _message of agentQuery) { /* initialization only */ } }
173
173
  catch { /* the caller receives the supportedModels error */ }
174
174
  })();
175
175
  try {
176
- const models = await agentQuery.supportedModels();
177
- const defaultResolvedModel = String(
178
- models.find((model) => model.value === "default")?.resolvedModel || "",
179
- );
180
- return {
181
- data: models
182
- .filter((model) => model.value && model.value !== "default")
183
- .map((model) => catalogEntry(model, defaultResolvedModel)),
176
+ const models = await agentQuery.supportedModels();
177
+ const defaultResolvedModel = String(
178
+ models.find((model) => model.value === "default")?.resolvedModel || "",
179
+ );
180
+ return {
181
+ data: models
182
+ .filter((model) => model.value && model.value !== "default")
183
+ .map((model) => catalogEntry(model, defaultResolvedModel)),
184
184
  };
185
185
  } finally {
186
186
  input.close();
@@ -215,7 +215,7 @@ function rawSession(id) {
215
215
  return id.startsWith("claude:") ? id.slice("claude:".length) : id;
216
216
  }
217
217
 
218
- function makeOptions(params, sessionId, resume) {
218
+ function makeOptions(params, sessionId, resume) {
219
219
  const options = {
220
220
  cwd: params.cwd || process.cwd(),
221
221
  includePartialMessages: true,
@@ -236,23 +236,23 @@ function makeOptions(params, sessionId, resume) {
236
236
  const model = stripClaudeModel(params.model);
237
237
  if (model) options.model = model;
238
238
  if (params.effort) options.effort = params.effort;
239
- applyClaudeExecutable(options, params);
239
+ applyClaudeExecutable(options, params);
240
240
  if (resume) options.resume = resume;
241
241
  else options.sessionId = sessionId;
242
242
  options.canUseTool = (toolName, input, permission) =>
243
243
  requestToolPermission(toolName, input, permission);
244
- return options;
245
- }
246
-
247
- async function startAgentQuery(prompt, options) {
248
- const warm = await startup({ options });
249
- try {
250
- return warm.query(prompt);
251
- } catch (error) {
252
- warm.close();
253
- throw error;
254
- }
255
- }
244
+ return options;
245
+ }
246
+
247
+ async function startAgentQuery(prompt, options) {
248
+ const warm = await startup({ options });
249
+ try {
250
+ return warm.query(prompt);
251
+ } catch (error) {
252
+ warm.close();
253
+ throw error;
254
+ }
255
+ }
256
256
 
257
257
  async function requestToolPermission(toolName, input, permission) {
258
258
  if (toolName === "AskUserQuestion") {
@@ -334,13 +334,17 @@ async function createSession(params, resumeId) {
334
334
  queue,
335
335
  query: null,
336
336
  turn: null,
337
+ // Prompts that arrived while a turn was running, run in order afterwards.
338
+ pendingPrompts: [],
337
339
  turnSequence: 1,
338
340
  itemSequence: 1,
339
341
  streamBlocks: new Map(),
340
342
  tools: new Map(),
341
343
  tasks: new Map(),
344
+ lastContextUsage: null,
345
+ lastContextWindow: 0,
342
346
  };
343
- const agentQuery = await startAgentQuery(queue, makeOptions(params, id, resumeId));
347
+ const agentQuery = await startAgentQuery(queue, makeOptions(params, id, resumeId));
344
348
  session.query = agentQuery;
345
349
  sessions.set(id, session);
346
350
  const consumer = consume(session).catch((error) => {
@@ -353,16 +357,16 @@ async function createSession(params, resumeId) {
353
357
  if (session.turn) finishTurn(session, error);
354
358
  });
355
359
  session.consumer = consumer;
356
- let initialization;
357
- try {
358
- initialization = await agentQuery.initializationResult();
359
- } catch (error) {
360
- sessions.delete(id);
361
- queue.close();
362
- agentQuery.close();
363
- await Promise.race([consumer, new Promise((resolve) => setTimeout(resolve, 1000))]);
364
- throw error;
365
- }
360
+ let initialization;
361
+ try {
362
+ initialization = await agentQuery.initializationResult();
363
+ } catch (error) {
364
+ sessions.delete(id);
365
+ queue.close();
366
+ agentQuery.close();
367
+ await Promise.race([consumer, new Promise((resolve) => setTimeout(resolve, 1000))]);
368
+ throw error;
369
+ }
366
370
  session.models = Array.isArray(initialization.models) ? initialization.models : [];
367
371
  session.effort = supportedEffort(modelCapabilities(session.models, params.model), params.effort);
368
372
  const account = initialization.account || await safeAccount(agentQuery);
@@ -391,6 +395,52 @@ function emitDelta(session, method, itemId, delta) {
391
395
  notify(method, { threadId: session.id, turnId: session.turn?.id, itemId, delta, provider: "Claude" });
392
396
  }
393
397
 
398
+ function tokenBreakdown(usage) {
399
+ if (!usage) return null;
400
+ const input = Number(usage.input_tokens ?? usage.inputTokens ?? 0);
401
+ const cached = Number(usage.cache_read_input_tokens ?? usage.cacheReadInputTokens ?? 0);
402
+ const cacheWrite = Number(usage.cache_creation_input_tokens ?? usage.cacheCreationInputTokens ?? 0);
403
+ const output = Number(usage.output_tokens ?? usage.outputTokens ?? 0);
404
+ return {
405
+ inputTokens: input + cached + cacheWrite,
406
+ cachedInputTokens: cached,
407
+ cacheWriteInputTokens: cacheWrite,
408
+ outputTokens: output,
409
+ totalTokens: input + cached + cacheWrite + output,
410
+ };
411
+ }
412
+
413
+ // A resumed session has no turn yet, so the status line would show no context
414
+ // until the next result. Rebuild both figures from the stored transcript.
415
+ function historyTokenUsage(messages, models, model) {
416
+ const total = {
417
+ inputTokens: 0,
418
+ cachedInputTokens: 0,
419
+ cacheWriteInputTokens: 0,
420
+ outputTokens: 0,
421
+ totalTokens: 0,
422
+ };
423
+ let last = null;
424
+ let counted = false;
425
+ for (const message of messages) {
426
+ if (message.type !== "assistant" || message.message?.model === "<synthetic>") continue;
427
+ const breakdown = tokenBreakdown(message.message?.usage);
428
+ if (!breakdown) continue;
429
+ counted = true;
430
+ for (const key of Object.keys(total)) total[key] += breakdown[key];
431
+ // Only the main thread occupies the context window; subagents run their own.
432
+ if (!message.parent_tool_use_id) last = breakdown;
433
+ }
434
+ if (!counted) return null;
435
+ const capabilities = modelCapabilities(models, model);
436
+ const contextWindow = Number(capabilities?.contextWindow || capabilities?.contextWindowSize || 0);
437
+ return {
438
+ total,
439
+ ...(last ? { last } : {}),
440
+ ...(contextWindow > 0 ? { modelContextWindow: contextWindow } : {}),
441
+ };
442
+ }
443
+
394
444
  function processStreamEvent(session, message) {
395
445
  if (!session.turn || message.parent_tool_use_id) return;
396
446
  const event = message.event || {};
@@ -433,6 +483,14 @@ function processStreamEvent(session, message) {
433
483
 
434
484
  function processAssistant(session, message) {
435
485
  if (!session.turn || message.parent_tool_use_id) return;
486
+ session.lastContextUsage = tokenBreakdown(message.message?.usage);
487
+ const capabilities = modelCapabilities(
488
+ session.models,
489
+ message.message?.model || session.model,
490
+ );
491
+ session.lastContextWindow = Number(
492
+ capabilities?.contextWindow || capabilities?.contextWindowSize || 0,
493
+ );
436
494
  const content = Array.isArray(message.message?.content) ? message.message.content : [];
437
495
  for (const block of content) {
438
496
  if (block.type === "tool_use") processToolUse(session, block);
@@ -499,18 +557,30 @@ function fileChanges(name, input) {
499
557
  return [{ path, kind: { type: name === "Write" ? "add" : "update" }, diff: `@@ -0,0 +1 @@\n${additions}` }];
500
558
  }
501
559
 
560
+ // Claude는 Codex의 update_plan처럼 계획 전체를 다시 보내지 않으므로, 새 계획이 시작될 때
561
+ // 이전 턴에서 이미 끝난 작업을 직접 걷어내야 목록이 턴마다 쌓이지 않는다.
562
+ function pruneFinishedTasks(tasks, turnId) {
563
+ for (const [key, task] of tasks) {
564
+ if (task.status === "completed" && task.turnId !== turnId) tasks.delete(key);
565
+ }
566
+ }
567
+
502
568
  function updatePlanFromToolUse(session, name, toolUseId, input) {
569
+ const turnId = session.turn?.id;
503
570
  if (name === "TaskCreate") {
571
+ pruneFinishedTasks(session.tasks, turnId);
504
572
  session.tasks.set(`pending:${toolUseId}`, {
505
573
  id: `pending:${toolUseId}`,
506
574
  subject: input.subject || input.description || "작업",
507
575
  status: "pending",
576
+ turnId,
508
577
  });
509
578
  } else if (name === "TaskUpdate") {
510
579
  const task = session.tasks.get(String(input.taskId));
511
580
  if (task) {
512
581
  if (input.subject) task.subject = input.subject;
513
582
  if (input.status) task.status = input.status;
583
+ task.turnId = turnId;
514
584
  }
515
585
  }
516
586
  emitPlan(session);
@@ -535,6 +605,7 @@ function updatePlanFromToolResult(session, pending, message) {
535
605
  id: String(task.id),
536
606
  subject: task.subject || task.description || "작업",
537
607
  status: task.status || "pending",
608
+ turnId: session.turn?.id,
538
609
  });
539
610
  }
540
611
  emitPlan(session);
@@ -628,8 +699,8 @@ async function processResult(session, message) {
628
699
  threadId: session.id,
629
700
  tokenUsage: {
630
701
  total: totals,
631
- last: totals,
632
- modelContextWindow: totals.contextWindow || undefined,
702
+ ...(session.lastContextUsage ? { last: session.lastContextUsage } : {}),
703
+ modelContextWindow: session.lastContextWindow || totals.contextWindow || undefined,
633
704
  },
634
705
  });
635
706
  const error = message.is_error && !interrupted
@@ -641,6 +712,25 @@ async function processResult(session, message) {
641
712
  account: await safeAccount(session.query),
642
713
  usage: await safeUsage(session.query),
643
714
  });
715
+ await runPendingPrompt(session);
716
+ }
717
+
718
+ // The turn that was waiting starts on its own, so the host sees it exactly like a
719
+ // prompt sent the moment the previous turn ended.
720
+ async function runPendingPrompt(session) {
721
+ const next = session.pendingPrompts.shift();
722
+ if (!next) return;
723
+ try {
724
+ await runPrompt(session, next);
725
+ } catch (error) {
726
+ notify("error", {
727
+ threadId: session.id,
728
+ provider: "Claude",
729
+ error: { message: error instanceof Error ? error.message : String(error) },
730
+ willRetry: false,
731
+ });
732
+ await runPendingPrompt(session);
733
+ }
644
734
  }
645
735
 
646
736
  function finishTurn(session, error, durationMs) {
@@ -673,34 +763,34 @@ async function consume(session) {
673
763
  }
674
764
  }
675
765
 
676
- const HANDOFF_HEADER = '<devez_provider_handoff chars="';
677
- const HANDOFF_SEPARATOR = '">\n';
678
- const HANDOFF_FOOTER = "\n</devez_provider_handoff>\n\n";
679
-
680
- function prependHandoff(content, handoffContext) {
681
- if (!handoffContext) return content;
682
- const handoff = String(handoffContext);
683
- const prefix = `${HANDOFF_HEADER}${handoff.length}${HANDOFF_SEPARATOR}${handoff}${HANDOFF_FOOTER}`;
684
- const firstText = content.find((item) => item.type === "text");
685
- if (firstText) firstText.text = `${prefix}${firstText.text || ""}`;
686
- else content.unshift({ type: "text", text: prefix });
687
- return content;
688
- }
689
-
690
- function stripHandoff(text) {
691
- if (!text.startsWith(HANDOFF_HEADER)) return text;
692
- const separator = text.indexOf(HANDOFF_SEPARATOR, HANDOFF_HEADER.length);
693
- if (separator < 0) return text;
694
- const length = Number(text.slice(HANDOFF_HEADER.length, separator));
695
- if (!Number.isSafeInteger(length) || length < 0) return text;
696
- const contextStart = separator + HANDOFF_SEPARATOR.length;
697
- const contextEnd = contextStart + length;
698
- if (text.slice(contextEnd, contextEnd + HANDOFF_FOOTER.length) !== HANDOFF_FOOTER) return text;
699
- return text.slice(contextEnd + HANDOFF_FOOTER.length);
700
- }
701
-
702
- async function inputContent(input, handoffContext) {
703
- const content = [];
766
+ const HANDOFF_HEADER = '<devez_provider_handoff chars="';
767
+ const HANDOFF_SEPARATOR = '">\n';
768
+ const HANDOFF_FOOTER = "\n</devez_provider_handoff>\n\n";
769
+
770
+ function prependHandoff(content, handoffContext) {
771
+ if (!handoffContext) return content;
772
+ const handoff = String(handoffContext);
773
+ const prefix = `${HANDOFF_HEADER}${handoff.length}${HANDOFF_SEPARATOR}${handoff}${HANDOFF_FOOTER}`;
774
+ const firstText = content.find((item) => item.type === "text");
775
+ if (firstText) firstText.text = `${prefix}${firstText.text || ""}`;
776
+ else content.unshift({ type: "text", text: prefix });
777
+ return content;
778
+ }
779
+
780
+ function stripHandoff(text) {
781
+ if (!text.startsWith(HANDOFF_HEADER)) return text;
782
+ const separator = text.indexOf(HANDOFF_SEPARATOR, HANDOFF_HEADER.length);
783
+ if (separator < 0) return text;
784
+ const length = Number(text.slice(HANDOFF_HEADER.length, separator));
785
+ if (!Number.isSafeInteger(length) || length < 0) return text;
786
+ const contextStart = separator + HANDOFF_SEPARATOR.length;
787
+ const contextEnd = contextStart + length;
788
+ if (text.slice(contextEnd, contextEnd + HANDOFF_FOOTER.length) !== HANDOFF_FOOTER) return text;
789
+ return text.slice(contextEnd + HANDOFF_FOOTER.length);
790
+ }
791
+
792
+ async function inputContent(input, handoffContext) {
793
+ const content = [];
704
794
  for (const item of Array.isArray(input) ? input : []) {
705
795
  if (item.type === "text") content.push({ type: "text", text: item.text || "" });
706
796
  else if (item.type === "localImage" && item.path) {
@@ -713,14 +803,24 @@ async function inputContent(input, handoffContext) {
713
803
  content.push({ type: "image", source: { type: "base64", media_type: mediaType, data: bytes.toString("base64") } });
714
804
  }
715
805
  }
716
- return prependHandoff(content.length ? content : [{ type: "text", text: "" }], handoffContext);
806
+ return prependHandoff(content.length ? content : [{ type: "text", text: "" }], handoffContext);
717
807
  }
718
808
 
719
809
  async function startPrompt(params) {
720
810
  const id = rawSession(params.sessionId);
721
811
  const session = sessions.get(id);
722
812
  if (!session) throw new Error(`Claude 세션을 찾을 수 없습니다: ${id}`);
723
- if (session.turn) throw new Error("Claude가 이전 작업을 아직 실행 중입니다.");
813
+ // Claude runs one turn at a time, so extra input waits its turn instead of
814
+ // failing — the same queueing the CLI does for a prompt typed while it works.
815
+ if (session.turn) {
816
+ session.pendingPrompts.push(params);
817
+ return { turn: { id: session.turn.id }, queued: true };
818
+ }
819
+ return runPrompt(session, params);
820
+ }
821
+
822
+ async function runPrompt(session, params) {
823
+ const id = session.id;
724
824
  if (params.model) {
725
825
  const model = stripClaudeModel(params.model);
726
826
  await session.query.setModel(model);
@@ -733,10 +833,11 @@ async function startPrompt(params) {
733
833
  session.effort = effort;
734
834
  const turnId = `claude-turn-${session.turnSequence++}-${randomUUID()}`;
735
835
  session.turn = { id: turnId, sawStreamText: false };
836
+ session.lastContextUsage = null;
736
837
  notify("turn/started", { threadId: id, turn: { id: turnId } });
737
- session.queue.push({
738
- type: "user",
739
- message: { role: "user", content: await inputContent(params.input, params.handoffContext) },
838
+ session.queue.push({
839
+ type: "user",
840
+ message: { role: "user", content: await inputContent(params.input, params.handoffContext) },
740
841
  parent_tool_use_id: null,
741
842
  session_id: id,
742
843
  origin: { kind: "human" },
@@ -750,6 +851,23 @@ function contentBlocks(message) {
750
851
  return Array.isArray(content) ? content : [];
751
852
  }
752
853
 
854
+ function isInternalHistoryText(message, text) {
855
+ if (message.isMeta || message.subtype === "local_command") return true;
856
+ const trimmed = text.trim();
857
+ const tag = trimmed.match(/^<([a-z0-9-]+)>/i)?.[1]?.toLowerCase();
858
+ return trimmed === "[Request interrupted by user]"
859
+ || [
860
+ "bash-input",
861
+ "bash-stdout",
862
+ "bash-stderr",
863
+ "command-name",
864
+ "local-command-caveat",
865
+ "local-command-stdout",
866
+ "local-command-stderr",
867
+ "task-notification",
868
+ ].includes(tag);
869
+ }
870
+
753
871
  function historyTurns(messages) {
754
872
  const turns = [];
755
873
  let turn = null;
@@ -757,10 +875,12 @@ function historyTurns(messages) {
757
875
  const tasks = new Map();
758
876
  for (const message of messages) {
759
877
  const blocks = contentBlocks(message.message);
760
- const userText = message.type === "user"
761
- ? stripHandoff(blocks.filter((block) => block.type === "text").map((block) => block.text || "").join("\n"))
878
+ const userText = message.type === "user"
879
+ ? stripHandoff(blocks.filter((block) => block.type === "text").map((block) => block.text || "").join("\n"))
762
880
  : "";
763
- if (userText && !blocks.some((block) => block.type === "tool_result")) {
881
+ if (userText
882
+ && !blocks.some((block) => block.type === "tool_result")
883
+ && !isInternalHistoryText(message, userText)) {
764
884
  turn = {
765
885
  id: `claude-turn-${message.uuid}`,
766
886
  status: "completed",
@@ -770,16 +890,28 @@ function historyTurns(messages) {
770
890
  }
771
891
  if (!turn) continue;
772
892
  if (message.type === "assistant") {
893
+ if (message.message?.model === "<synthetic>") {
894
+ turn.synthetic = true;
895
+ continue;
896
+ }
897
+ turn.synthetic = false;
898
+ if (!turn.model && message.message?.model) {
899
+ turn.model = visibleModel(message.message.model);
900
+ const prompt = turn.items.find((item) => item.type === "userMessage");
901
+ if (prompt) prompt.model = turn.model;
902
+ }
773
903
  for (const block of blocks) {
774
904
  if (block.type === "text") turn.items.push({ id: `${message.uuid}-text`, type: "agentMessage", text: block.text || "", provider: "Claude" });
775
905
  else if (block.type === "thinking") turn.items.push({ id: `${message.uuid}-thinking`, type: "reasoning", summary: [block.thinking || ""] });
776
906
  else if (block.type === "tool_use") {
777
907
  const pending = { name: block.name, input: block.input || {}, item: toolItem({}, block.id, block.name, block.input || {}) };
778
908
  tools.set(block.id, pending);
779
- if (block.name === "TaskCreate") tasks.set(`pending:${block.id}`, { id: `pending:${block.id}`, subject: block.input?.subject || "작업", status: "pending" });
780
- else if (block.name === "TaskUpdate") {
909
+ if (block.name === "TaskCreate") {
910
+ pruneFinishedTasks(tasks, turn.id);
911
+ tasks.set(`pending:${block.id}`, { id: `pending:${block.id}`, subject: block.input?.subject || "작업", status: "pending", turnId: turn.id });
912
+ } else if (block.name === "TaskUpdate") {
781
913
  const task = tasks.get(String(block.input?.taskId));
782
- if (task) Object.assign(task, block.input?.subject ? { subject: block.input.subject } : {}, block.input?.status ? { status: block.input.status } : {});
914
+ if (task) Object.assign(task, block.input?.subject ? { subject: block.input.subject } : {}, block.input?.status ? { status: block.input.status } : {}, { turnId: turn.id });
783
915
  } else if (!["TaskList", "AskUserQuestion"].includes(block.name)) turn.items.push(pending.item);
784
916
  }
785
917
  }
@@ -798,7 +930,7 @@ function historyTurns(messages) {
798
930
  }
799
931
  } else if (pending.name === "TaskList" && Array.isArray(message.tool_use_result?.tasks)) {
800
932
  tasks.clear();
801
- for (const task of message.tool_use_result.tasks) tasks.set(String(task.id), { id: String(task.id), subject: task.subject || "작업", status: task.status || "pending" });
933
+ for (const task of message.tool_use_result.tasks) tasks.set(String(task.id), { id: String(task.id), subject: task.subject || "작업", status: task.status || "pending", turnId: turn.id });
802
934
  } else if (pending.item) {
803
935
  const output = toolOutput(block.content, message.tool_use_result);
804
936
  Object.assign(pending.item, pending.item.type === "commandExecution"
@@ -814,7 +946,9 @@ function historyTurns(messages) {
814
946
  const text = [...tasks.values()].map((task, index) => `${task.status === "completed" ? "✓" : task.status === "in_progress" ? "▸" : "□"} ${numberedTaskSubject(task.subject, index)}`).join("\n");
815
947
  turn.items.push({ id: "claude-plan-latest", type: "plan", text });
816
948
  }
817
- return turns;
949
+ return turns
950
+ .filter((candidate) => !candidate.synthetic)
951
+ .map(({ synthetic: _, ...candidate }) => candidate);
818
952
  }
819
953
 
820
954
  async function dispatch(method, params = {}) {
@@ -835,6 +969,7 @@ async function dispatch(method, params = {}) {
835
969
  const id = rawSession(params.sessionId);
836
970
  const existing = sessions.get(id);
837
971
  if (existing) {
972
+ const messages = await getSessionMessages(id, { dir: existing.cwd, includeSystemMessages: true });
838
973
  return {
839
974
  id,
840
975
  thread: { id, turns: [] },
@@ -844,6 +979,7 @@ async function dispatch(method, params = {}) {
844
979
  reasoningEffort: existing.effort,
845
980
  account: await safeAccount(existing.query),
846
981
  usage: await safeUsage(existing.query),
982
+ tokenUsage: historyTokenUsage(messages, existing.models, existing.model),
847
983
  };
848
984
  }
849
985
  const info = await getSessionInfo(id, { dir: params.cwd });
@@ -851,6 +987,10 @@ async function dispatch(method, params = {}) {
851
987
  const messages = await getSessionMessages(id, { dir: params.cwd, includeSystemMessages: true });
852
988
  const lastModel = [...messages].reverse().find((message) => message.type === "assistant")?.message?.model;
853
989
  const { session, account, usage } = await createSession({ ...params, cwd: info.cwd || params.cwd, model: params.model || lastModel }, id);
990
+ const tokenUsage = historyTokenUsage(messages, session.models, session.model);
991
+ // Seed the live session so the next turn keeps reporting a full context.
992
+ session.lastContextUsage = tokenUsage?.last || null;
993
+ session.lastContextWindow = tokenUsage?.modelContextWindow || 0;
854
994
  return {
855
995
  id,
856
996
  thread: { id, turns: [] },
@@ -860,6 +1000,7 @@ async function dispatch(method, params = {}) {
860
1000
  reasoningEffort: session.effort,
861
1001
  account,
862
1002
  usage,
1003
+ tokenUsage,
863
1004
  };
864
1005
  }
865
1006
  if (method === "session/list") {
@@ -888,6 +1029,9 @@ async function dispatch(method, params = {}) {
888
1029
  if (method === "session/prompt") return startPrompt(params);
889
1030
  if (method === "session/interrupt") {
890
1031
  const session = sessions.get(rawSession(params.sessionId));
1032
+ // Stopping the run drops what was waiting behind it too, so nothing the user
1033
+ // just cancelled starts on its own afterwards.
1034
+ if (session) session.pendingPrompts.length = 0;
891
1035
  if (session?.turn) {
892
1036
  const turn = session.turn;
893
1037
  turn.interruptRequested = true;
@@ -944,6 +1088,59 @@ async function dispatch(method, params = {}) {
944
1088
  throw new Error(`지원하지 않는 Claude 브리지 메서드: ${method}`);
945
1089
  }
946
1090
 
1091
+ function runSelfTest() {
1092
+ const user = (uuid, text) => ({
1093
+ type: "user",
1094
+ uuid,
1095
+ message: { role: "user", content: [{ type: "text", text }] },
1096
+ origin: { kind: "human" },
1097
+ });
1098
+ const assistant = (uuid, model, text) => ({
1099
+ type: "assistant",
1100
+ uuid,
1101
+ message: { role: "assistant", model, content: [{ type: "text", text }] },
1102
+ });
1103
+ const turns = historyTurns([
1104
+ user("u1", "say hi"),
1105
+ assistant("a1", "claude-opus-5", "hi."),
1106
+ user("command", "<command-name>/model</command-name>"),
1107
+ user("stdout", "<local-command-stdout>Set model to sonnet</local-command-stdout>"),
1108
+ user("u2", "say hello"),
1109
+ assistant("a2", "claude-sonnet-5", "Hello."),
1110
+ user("bash", "<bash-stdout>hidden</bash-stdout>"),
1111
+ user("u3", "hay zzz"),
1112
+ assistant("a3", "claude-haiku-4-5-20251001", "hey."),
1113
+ user("synthetic-user", "duplicate"),
1114
+ assistant("synthetic", "<synthetic>", "No response requested."),
1115
+ ]);
1116
+ const prompts = turns.map((turn) => turn.items.find((item) => item.type === "userMessage"));
1117
+ const expected = [
1118
+ ["say hi", "claude:claude-opus-5"],
1119
+ ["say hello", "claude:claude-sonnet-5"],
1120
+ ["hay zzz", "claude:claude-haiku-4-5-20251001"],
1121
+ ];
1122
+ if (turns.length !== expected.length
1123
+ || prompts.some((prompt, index) => prompt?.content?.[0]?.text !== expected[index][0]
1124
+ || prompt.model !== expected[index][1])) {
1125
+ throw new Error(`Claude history self-test failed: ${JSON.stringify(turns)}`);
1126
+ }
1127
+ const usage = tokenBreakdown({
1128
+ input_tokens: 2,
1129
+ cache_read_input_tokens: 68_000,
1130
+ cache_creation_input_tokens: 500,
1131
+ output_tokens: 300,
1132
+ });
1133
+ if (usage.totalTokens !== 68_802 || usage.inputTokens !== 68_502) {
1134
+ throw new Error(`Claude usage self-test failed: ${JSON.stringify(usage)}`);
1135
+ }
1136
+ process.stdout.write("Claude bridge self-test passed\n");
1137
+ }
1138
+
1139
+ if (process.argv.includes("--self-test")) {
1140
+ runSelfTest();
1141
+ process.exit(0);
1142
+ }
1143
+
947
1144
  const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
948
1145
  lines.on("line", async (line) => {
949
1146
  if (!line.trim()) return;
package/package.json CHANGED
@@ -1,41 +1,41 @@
1
- {
2
- "name": "devez-vibe",
3
- "version": "0.1.46",
4
- "description": "Stable terminal UI for Codex and Claude Agent SDK",
5
- "keywords": [
6
- "codex",
7
- "cli",
8
- "tui",
9
- "terminal",
10
- "app-server",
11
- "claude-agent-sdk"
12
- ],
13
- "homepage": "https://github.com/MrHoje/Devez-vibe#readme",
14
- "bugs": "https://github.com/MrHoje/Devez-vibe/issues",
15
- "repository": {
16
- "type": "git",
17
- "url": "git+https://github.com/MrHoje/Devez-vibe.git"
18
- },
19
- "license": "MIT",
20
- "bin": {
21
- "dvz": "bin/dvz.exe"
22
- },
23
- "files": [
24
- "bin/dvz.exe",
25
- "bridge/claude-agent-sdk-bridge.mjs",
26
- "README.md",
27
- "LICENSE"
28
- ],
29
- "os": [
30
- "win32"
31
- ],
32
- "cpu": [
33
- "x64"
34
- ],
35
- "engines": {
36
- "node": ">=18"
37
- },
38
- "dependencies": {
39
- "@anthropic-ai/claude-agent-sdk": "0.3.223"
40
- }
41
- }
1
+ {
2
+ "name": "devez-vibe",
3
+ "version": "0.1.47",
4
+ "description": "Stable terminal UI for Codex and Claude Agent SDK",
5
+ "keywords": [
6
+ "codex",
7
+ "cli",
8
+ "tui",
9
+ "terminal",
10
+ "app-server",
11
+ "claude-agent-sdk"
12
+ ],
13
+ "homepage": "https://github.com/MrHoje/Devez-vibe#readme",
14
+ "bugs": "https://github.com/MrHoje/Devez-vibe/issues",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/MrHoje/Devez-vibe.git"
18
+ },
19
+ "license": "MIT",
20
+ "bin": {
21
+ "dvz": "bin/dvz.exe"
22
+ },
23
+ "files": [
24
+ "bin/dvz.exe",
25
+ "bridge/claude-agent-sdk-bridge.mjs",
26
+ "README.md",
27
+ "LICENSE"
28
+ ],
29
+ "os": [
30
+ "win32"
31
+ ],
32
+ "cpu": [
33
+ "x64"
34
+ ],
35
+ "engines": {
36
+ "node": ">=18"
37
+ },
38
+ "dependencies": {
39
+ "@anthropic-ai/claude-agent-sdk": "0.3.223"
40
+ }
41
+ }