devez-vibe 1.9.58 → 1.9.60

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
@@ -107,7 +107,7 @@ function rpcError(error) {
107
107
  */
108
108
  const HOST_REQUEST_RUN = Math.random().toString(36).slice(2, 8);
109
109
 
110
- function hostRequest(method, params, signal) {
110
+ function hostRequest(method, params, signal, sessionId) {
111
111
  const id = `claude-host-${HOST_REQUEST_RUN}-${nextHostRequest++}`;
112
112
  return new Promise((resolve, reject) => {
113
113
  const abort = () => {
@@ -117,6 +117,7 @@ function hostRequest(method, params, signal) {
117
117
  if (signal?.aborted) return abort();
118
118
  signal?.addEventListener("abort", abort, { once: true });
119
119
  pendingHostRequests.set(id, {
120
+ session: sessionId ? lookupSession(sessionId) : null,
120
121
  resolve: (value) => {
121
122
  signal?.removeEventListener("abort", abort);
122
123
  resolve(value);
@@ -538,7 +539,7 @@ async function applyPermissionMode(session) {
538
539
  return;
539
540
  }
540
541
  try {
541
- await session.query.setPermissionMode(PREFERRED_PERMISSION_MODE);
542
+ await withSessionQuery(session, (query) => query.setPermissionMode(PREFERRED_PERMISSION_MODE));
542
543
  session.permissionMode = PREFERRED_PERMISSION_MODE;
543
544
  session.permissionModeVerified = true;
544
545
  return;
@@ -766,7 +767,7 @@ function makeOptions(params, sessionId, resume) {
766
767
  if (resume) options.resume = resume;
767
768
  else options.sessionId = sessionId;
768
769
  options.canUseTool = (toolName, input, permission) =>
769
- requestToolPermission(toolName, input, permission);
770
+ requestToolPermission(toolName, input, permission, sessionId);
770
771
  // Hooks run whatever the permission mode, so a read-only role turn holds even
771
772
  // under bypass. The policy itself arrives with each prompt.
772
773
  options.hooks = {
@@ -884,7 +885,7 @@ function permissionSuggestionLabel(suggestions) {
884
885
  : `Don't ask again ${scope}`;
885
886
  }
886
887
 
887
- async function requestToolPermission(toolName, input, permission) {
888
+ async function requestToolPermission(toolName, input, permission, sessionId) {
888
889
  if (toolName === "AskUserQuestion") {
889
890
  const questions = (input.questions || []).map((question, index) => ({
890
891
  id: `q${index}`,
@@ -907,6 +908,7 @@ async function requestToolPermission(toolName, input, permission) {
907
908
  payload: Buffer.from(JSON.stringify({ questions }), "utf8").toString("base64"),
908
909
  },
909
910
  permission.signal,
911
+ sessionId,
910
912
  );
911
913
  let response;
912
914
  try {
@@ -981,7 +983,7 @@ async function requestToolPermission(toolName, input, permission) {
981
983
  reason: permission.decisionReason || permission.description,
982
984
  };
983
985
  }
984
- const response = await hostRequest(method, params, permission.signal);
986
+ const response = await hostRequest(method, params, permission.signal, sessionId);
985
987
  if (!approvalGranted(response)) return { behavior: "deny", message: "사용자가 작업을 거부했습니다." };
986
988
  return {
987
989
  behavior: "allow",
@@ -1778,7 +1780,7 @@ async function supportedClaudeSkills(params) {
1778
1780
  const cwd = params.cwd || process.cwd();
1779
1781
  const live = [...sessions.values()].find((session) =>
1780
1782
  session.query && sameCwd(session.cwd, cwd));
1781
- if (live) return filterClaudeSkillCommands(await live.query.supportedCommands());
1783
+ if (live) return withSessionQuery(live, async (query) => filterClaudeSkillCommands(await query.supportedCommands()));
1782
1784
  const cacheKey = `${params.claudePath || "claude"}\n${params.cwd || process.cwd()}`;
1783
1785
  if (params.forceReload) skillCatalogs.delete(cacheKey);
1784
1786
  if (skillCatalogs.has(cacheKey)) return skillCatalogs.get(cacheKey);
@@ -1864,18 +1866,20 @@ async function claudeMcpStatus(params) {
1864
1866
  unavailableReason: "Claude 세션이 아직 시작되지 않았습니다.",
1865
1867
  };
1866
1868
  }
1867
- return claudeMcpStatusValue(await session.query.mcpServerStatus());
1869
+ return withSessionQuery(session, async (query) => claudeMcpStatusValue(await query.mcpServerStatus()));
1868
1870
  }
1869
1871
 
1870
1872
  async function reconnectClaudeMcp(session, requested) {
1871
- const names = requested
1872
- ? [requested]
1873
- : (await session.query.mcpServerStatus())
1874
- .filter((server) => server?.status !== "disabled")
1875
- .map((server) => String(server.name || ""))
1876
- .filter(Boolean);
1877
- for (const name of names) await session.query.reconnectMcpServer(name);
1878
- return names;
1873
+ return withSessionQuery(session, async (query) => {
1874
+ const names = requested
1875
+ ? [requested]
1876
+ : (await query.mcpServerStatus())
1877
+ .filter((server) => server?.status !== "disabled")
1878
+ .map((server) => String(server.name || ""))
1879
+ .filter(Boolean);
1880
+ for (const name of names) await query.reconnectMcpServer(name);
1881
+ return names;
1882
+ });
1879
1883
  }
1880
1884
 
1881
1885
  function isQuestionDeliveryError(error) {
@@ -1918,6 +1922,7 @@ async function createSession(params, resumeId) {
1918
1922
  models: [],
1919
1923
  queue,
1920
1924
  query: null,
1925
+ initializing: true,
1921
1926
  turn: null,
1922
1927
  // Prompts that arrived while a turn was running, run in order afterwards.
1923
1928
  pendingPrompts: [],
@@ -1951,6 +1956,7 @@ async function createSession(params, resumeId) {
1951
1956
  sessions.set(id, session);
1952
1957
  const consumer = consume(session)
1953
1958
  .catch((error) => {
1959
+ if (session.closed) return;
1954
1960
  notify("error", {
1955
1961
  threadId: id,
1956
1962
  provider: "Claude",
@@ -1962,16 +1968,11 @@ async function createSession(params, resumeId) {
1962
1968
  .finally(() => {
1963
1969
  clearUsageLimitWait(session);
1964
1970
  if (session.turn) finishTurn(session, new Error("Claude 연결이 종료되었습니다."));
1965
- clearSubagents(session);
1966
1971
  // A crashed CLI (Windows reports an access violation as exit code
1967
1972
  // 3221225477) leaves a query that rejects every later request. Dropping
1968
1973
  // the session makes the next prompt fail as missing, which the host answers
1969
1974
  // by resuming it from the transcript instead of the user reopening it.
1970
- if (sessions.get(session.id) === session) {
1971
- sessions.delete(session.id);
1972
- session.queue.close();
1973
- session.query.close();
1974
- }
1975
+ closeLiveSession(session);
1975
1976
  });
1976
1977
  session.consumer = consumer;
1977
1978
  let initialization;
@@ -1979,9 +1980,7 @@ async function createSession(params, resumeId) {
1979
1980
  initialization = await agentQuery.initializationResult();
1980
1981
  await applyPermissionMode(session);
1981
1982
  } catch (error) {
1982
- sessions.delete(id);
1983
- queue.close();
1984
- agentQuery.close();
1983
+ closeLiveSession(session);
1985
1984
  await Promise.race([consumer, new Promise((resolve) => setTimeout(resolve, 1000))]);
1986
1985
  throw error;
1987
1986
  }
@@ -2018,9 +2017,70 @@ async function createSession(params, resumeId) {
2018
2017
  session.effort = supportedEffort(capabilities, params.effort);
2019
2018
  const account = initialization.account || await safeAccount(agentQuery);
2020
2019
  const usage = await safeUsage(agentQuery);
2020
+ if (session.closed || sessions.get(session.id) !== session || session.query !== agentQuery) {
2021
+ closeLiveSession(session);
2022
+ throw new Error("Claude 초기화 중 연결이 종료되었습니다.");
2023
+ }
2024
+ session.initializing = false;
2025
+ // 새 연결이 준비된 뒤 이전 유휴 연결을 거둔다. /btw의 부모처럼 작업 중인
2026
+ // 세션과 다른 createSession의 초기화 중인 연결은 그대로 둔다.
2027
+ for (const previous of [...sessions.values()]) {
2028
+ if (previous !== session && !sessionHasLiveWork(previous)) closeLiveSession(previous);
2029
+ }
2021
2030
  return { session, initialization, account, usage };
2022
2031
  }
2023
2032
 
2033
+ function sessionHasLiveWork(session) {
2034
+ return Boolean(session.initializing || session.promptStarting || session.queryRequests > 0 || session.turn
2035
+ || session.pendingPrompts?.length || session.queue?.values?.length
2036
+ || session.steerPending > 0 || session.automaticTurnsPending > 0 || session.usageLimitWait
2037
+ || session.subagents?.size || session.hiddenSubagentTasks?.size || session.ambientSubagentTasks?.size
2038
+ || [...pendingHostRequests.values()].some((request) => request.session === session));
2039
+ }
2040
+
2041
+ // await 뒤 같은 query로 이어지는 제어·조회 요청이 유휴 정리와 겹치지 않게 한다.
2042
+ async function withSessionQuery(session, action) {
2043
+ const query = session.query;
2044
+ session.queryRequests = (session.queryRequests || 0) + 1;
2045
+ try {
2046
+ return await action(query);
2047
+ } finally {
2048
+ session.queryRequests -= 1;
2049
+ }
2050
+ }
2051
+
2052
+ // 저장된 transcript는 남기고 CLI와 이 연결의 참조만 해제한다.
2053
+ function closeLiveSession(session) {
2054
+ if (session.closed) return;
2055
+ session.closed = true;
2056
+ clearUsageLimitWait(session);
2057
+ watchSubagent(session, null);
2058
+ clearSubagents(session);
2059
+ for (const [id, request] of pendingHostRequests) {
2060
+ if (request.session !== session) continue;
2061
+ pendingHostRequests.delete(id);
2062
+ request.reject(new Error("Claude 연결이 종료되어 사용자 입력 요청이 취소되었습니다."));
2063
+ }
2064
+ if (sessions.get(session.id) === session) {
2065
+ const aliases = [...sessionAliases.keys()].filter((from) => liveSessionId(from) === session.id);
2066
+ for (const from of aliases) sessionAliases.delete(from);
2067
+ sessions.delete(session.id);
2068
+ }
2069
+ session.queue?.close();
2070
+ if (session.queue?.values) session.queue.values.length = 0;
2071
+ session.query?.close();
2072
+ session.queue = null;
2073
+ session.query = null;
2074
+ session.consumer = null;
2075
+ session.pendingPrompts?.splice(0);
2076
+ session.steerPending = 0;
2077
+ session.automaticTurnsPending = 0;
2078
+ session.streamBlocks?.clear();
2079
+ session.tools?.clear();
2080
+ session.knownSubagents?.clear();
2081
+ session.rowTaskIds?.clear();
2082
+ }
2083
+
2024
2084
  async function safeAccount(agentQuery) {
2025
2085
  try { return await agentQuery.accountInfo(); } catch { return null; }
2026
2086
  }
@@ -2660,19 +2720,14 @@ function keepBackgroundSubagent(session, toolUseId, result) {
2660
2720
  running.lastSeenAt = Date.now();
2661
2721
  // level 스냅숏이 접수 결과보다 먼저 와서 자리표시자 행을 만들었으면 여기서 접는다.
2662
2722
  // task_started가 오지 않는 빌드에서는 이 경로가 유일한 병합 지점이다.
2663
- const placeholder = running.taskId ? session.subagents.get(`task:${running.taskId}`) : null;
2723
+ const placeholder = running.taskId ? [...session.subagents.values()]
2724
+ .find((agent) => agent !== running && agent.taskId === running.taskId) : null;
2664
2725
  if (placeholder && placeholder !== running) {
2665
2726
  running.command ||= placeholder.command;
2727
+ if (placeholder.runId && (!running.runId || running.runId < placeholder.runId)) running.runId = placeholder.runId;
2666
2728
  session.subagents.delete(placeholder.id);
2667
2729
  }
2668
- if (running.taskId) {
2669
- session.knownSubagents.set(running.taskId, {
2670
- rowId: running.id,
2671
- name: running.name,
2672
- description: running.description,
2673
- backgroundTask: running.backgroundTask === true,
2674
- });
2675
- }
2730
+ rememberSubagent(session, running);
2676
2731
  return true;
2677
2732
  }
2678
2733
 
@@ -2686,8 +2741,9 @@ function resumeBackgroundSubagent(session, toolUseId, pending, result) {
2686
2741
  if (existing) return true;
2687
2742
  const known = session.knownSubagents.get(taskId);
2688
2743
  const input = pending?.input || {};
2744
+ // 끝난 줄과 같은 id로 돌아와야 호스트가 같은 작업의 idle 줄과 실행 줄을 함께 세우지 않는다.
2689
2745
  const running = {
2690
- id: toolUseId,
2746
+ id: known?.rowId || toolUseId,
2691
2747
  toolUseId,
2692
2748
  taskId,
2693
2749
  background: true,
@@ -2697,8 +2753,10 @@ function resumeBackgroundSubagent(session, toolUseId, pending, result) {
2697
2753
  startedAt: Date.now(),
2698
2754
  lastSeenAt: Date.now(),
2699
2755
  };
2700
- session.subagents.set(toolUseId, running);
2756
+ session.subagents.set(running.id, running);
2701
2757
  session.knownSubagents.set(taskId, {
2758
+ ...known,
2759
+ rowId: running.id,
2702
2760
  name: running.name,
2703
2761
  description: running.description,
2704
2762
  backgroundTask: false,
@@ -2740,6 +2798,8 @@ function upsertStructuredSubagent(session, message) {
2740
2798
  || session.ambientSubagentTasks?.has(taskId))) return null;
2741
2799
  const byTool = toolUseId && findSubagent(session, toolUseId);
2742
2800
  const byTask = taskId && findSubagent(session, taskId);
2801
+ const runId = subagentRunId(message.run_id);
2802
+ if (isStaleSubagentRun(session, taskId, runId, byTool, byTask)) return null;
2743
2803
  let running = byTool || byTask;
2744
2804
  // The SDK does not order the background level snapshot against task_started.
2745
2805
  // If both paths created a row, keep the tool-use row and fold the placeholder
@@ -2749,6 +2809,7 @@ function upsertStructuredSubagent(session, message) {
2749
2809
  byTool.command ||= byTask.command;
2750
2810
  byTool.taskId ||= byTask.taskId;
2751
2811
  byTool.toolUseId ||= byTask.toolUseId;
2812
+ if (byTask.runId && (!byTool.runId || byTool.runId < byTask.runId)) byTool.runId = byTask.runId;
2752
2813
  if ((!byTool.name || byTool.name === "agent") && byTask.name) byTool.name = byTask.name;
2753
2814
  if (!byTool.tool) byTool.tool = byTask.tool;
2754
2815
  byTool.startedAt = Math.min(byTool.startedAt, byTask.startedAt);
@@ -2777,6 +2838,7 @@ function upsertStructuredSubagent(session, message) {
2777
2838
  }
2778
2839
  if (toolUseId) running.toolUseId = toolUseId;
2779
2840
  if (taskId) running.taskId = taskId;
2841
+ if (runId) running.runId = runId;
2780
2842
  // 스크립트 Monitor는 local_bash 작업으로 와서 스냅숏 이름이 Bash가 된다.
2781
2843
  if (session.tools?.get(toolUseId)?.name === "Monitor") running.name = "Monitor";
2782
2844
  const parentTaskId = firstLine(message.parent_task_id || "", 80);
@@ -2785,14 +2847,7 @@ function upsertStructuredSubagent(session, message) {
2785
2847
  if (subagentType && (!running.name || running.name === "agent")) running.name = subagentType;
2786
2848
  if (subagentType || isSubagentTaskType(message.task_type)) running.backgroundTask = false;
2787
2849
  running.lastSeenAt = Date.now();
2788
- if (running.taskId) {
2789
- session.knownSubagents.set(running.taskId, {
2790
- rowId: running.id,
2791
- name: running.name,
2792
- description: running.description,
2793
- backgroundTask: running.backgroundTask === true,
2794
- });
2795
- }
2850
+ rememberSubagent(session, running);
2796
2851
  return running;
2797
2852
  }
2798
2853
 
@@ -2800,9 +2855,49 @@ function hasLiveChildren(session, taskId) {
2800
2855
  return !!taskId && [...session.subagents.values()].some((agent) => agent.parentTaskId === taskId);
2801
2856
  }
2802
2857
 
2858
+ // 같은 작업의 실행 id는 열린 순서대로 문자열 정렬된다. 지금 행보다 앞선 실행의
2859
+ // 통지는 늦게 도착한 것이라 새 실행의 행을 지우면 안 된다.
2860
+ function subagentRunId(value) {
2861
+ return typeof value === "string" ? value : "";
2862
+ }
2863
+
2864
+ function isLateRun(running, runId) {
2865
+ return Boolean(runId && running?.runId && runId < running.runId);
2866
+ }
2867
+
2868
+ // 행을 거둬도 마지막 실행과 완료 실행을 기억한다. 같은 실행의 늦은 진행·스냅숏은
2869
+ // 행을 되살리지 못하고, 더 큰 실행 id로 시작한 정상 재실행은 그대로 허용한다.
2870
+ function rememberSubagent(session, running, completed = false, runId = running.runId) {
2871
+ if (!running.taskId) return;
2872
+ const known = (session.knownSubagents ||= new Map()).get(running.taskId);
2873
+ const latestRunId = [known?.runId, running.runId, runId].filter(Boolean).sort().at(-1);
2874
+ const completedRunId = [known?.completedRunId, completed && (runId || running.runId)].filter(Boolean).sort().at(-1);
2875
+ session.knownSubagents.set(running.taskId, {
2876
+ ...known,
2877
+ rowId: running.id || known?.rowId,
2878
+ name: running.name || known?.name,
2879
+ // 진행 요약 대신 호출 설명을 보존해 재실행한 행이 원래 설명으로 돌아오게 한다.
2880
+ description: known?.description || running.description || "",
2881
+ backgroundTask: running.backgroundTask === true,
2882
+ ...(latestRunId ? { runId: latestRunId } : {}),
2883
+ ...(completedRunId ? { completedRunId } : {}),
2884
+ });
2885
+ }
2886
+
2887
+ function isStaleSubagentRun(session, taskId, runId, ...running) {
2888
+ if (!runId) return false;
2889
+ const known = taskId && session.knownSubagents?.get(taskId);
2890
+ return Boolean((known?.completedRunId && runId <= known.completedRunId)
2891
+ || isLateRun(known, runId) || running.some((row) => isLateRun(row, runId)));
2892
+ }
2893
+
2803
2894
  function finishStructuredSubagent(session, message) {
2804
2895
  const taskId = firstLine(message.task_id || "", 80);
2805
2896
  const toolUseId = firstLine(message.tool_use_id || "", 80);
2897
+ const running = (taskId && findSubagent(session, taskId))
2898
+ || (toolUseId && findSubagent(session, toolUseId));
2899
+ const runId = subagentRunId(message.run_id);
2900
+ if (isStaleSubagentRun(session, taskId, runId, running)) return true;
2806
2901
  const wasTranscriptHidden = taskId
2807
2902
  ? session.hiddenSubagentTasks?.delete(taskId) === true
2808
2903
  : false;
@@ -2810,8 +2905,7 @@ function finishStructuredSubagent(session, message) {
2810
2905
  ? session.ambientSubagentTasks?.delete(taskId) === true
2811
2906
  : false;
2812
2907
  const wasHidden = wasTranscriptHidden || wasAmbient;
2813
- const running = (toolUseId && findSubagent(session, toolUseId))
2814
- || (taskId && findSubagent(session, taskId));
2908
+ rememberSubagent(session, running || { taskId, runId }, true, runId || running?.runId);
2815
2909
  if (!running) return wasHidden;
2816
2910
  // 자기 백그라운드 명령을 기다리며 멈춘 에이전트는 그 명령이 끝나면 다시 돌므로 행을 남긴다.
2817
2911
  if (hasLiveChildren(session, running.taskId)) return true;
@@ -2822,10 +2916,32 @@ function finishStructuredSubagent(session, message) {
2822
2916
 
2823
2917
  function syncBackgroundSubagents(session, tasks) {
2824
2918
  const snapshot = Array.isArray(tasks) ? tasks : [];
2919
+ const staleIds = new Set(snapshot
2920
+ .filter((task) => {
2921
+ const taskId = firstLine(task?.task_id || "", 80);
2922
+ return isStaleSubagentRun(session, taskId, subagentRunId(task?.run_id), findSubagent(session, taskId));
2923
+ })
2924
+ .map((task) => firstLine(task?.task_id || "", 80))
2925
+ .filter(Boolean));
2926
+ const previousAmbient = session.ambientSubagentTasks || new Set();
2927
+ for (const task of snapshot) {
2928
+ if (task?.ambient !== true) continue;
2929
+ const taskId = firstLine(task?.task_id || "", 80);
2930
+ const runId = subagentRunId(task?.run_id);
2931
+ const running = findSubagent(session, taskId);
2932
+ if (!isStaleSubagentRun(session, taskId, runId, running)) {
2933
+ rememberSubagent(session, running || { taskId }, false, runId);
2934
+ }
2935
+ }
2825
2936
  session.ambientSubagentTasks = new Set(snapshot
2826
- .filter((task) => task?.ambient === true)
2937
+ .filter((task) => task?.ambient === true && !isStaleSubagentRun(session,
2938
+ firstLine(task?.task_id || "", 80), subagentRunId(task?.run_id),
2939
+ findSubagent(session, task?.task_id)))
2827
2940
  .map((task) => firstLine(task?.task_id || "", 80))
2828
2941
  .filter(Boolean));
2942
+ for (const taskId of previousAmbient) {
2943
+ if (staleIds.has(taskId)) session.ambientSubagentTasks.add(taskId);
2944
+ }
2829
2945
  const live = snapshot.filter((task) => {
2830
2946
  const taskId = firstLine(task?.task_id || "", 80);
2831
2947
  return task?.ambient !== true
@@ -2835,12 +2951,15 @@ function syncBackgroundSubagents(session, tasks) {
2835
2951
  let changed = false;
2836
2952
  for (const [id, running] of session.subagents) {
2837
2953
  if (running.taskId && session.ambientSubagentTasks.has(running.taskId)) {
2954
+ rememberSubagent(session, running);
2838
2955
  session.subagents.delete(id);
2839
2956
  changed = true;
2840
2957
  continue;
2841
2958
  }
2842
- if (!running.background || !running.taskId || liveIds.has(running.taskId)
2959
+ if (!running.background || !running.taskId || liveIds.has(running.taskId) || staleIds.has(running.taskId)
2843
2960
  || live.some((task) => task?.parent_task_id === running.taskId)) continue;
2961
+ // level 스냅숏은 task_started와 순서가 정해져 있지 않아 완료 증거가 아니다.
2962
+ rememberSubagent(session, running);
2844
2963
  session.subagents.delete(id);
2845
2964
  changed = true;
2846
2965
  }
@@ -2849,6 +2968,8 @@ function syncBackgroundSubagents(session, tasks) {
2849
2968
  if (!taskId) continue;
2850
2969
  let running = findSubagent(session, taskId);
2851
2970
  const known = session.knownSubagents.get(taskId);
2971
+ const runId = subagentRunId(task?.run_id);
2972
+ if (isStaleSubagentRun(session, taskId, runId, running)) continue;
2852
2973
  if (!running) {
2853
2974
  running = {
2854
2975
  id: known?.rowId || `task:${taskId}`,
@@ -2868,6 +2989,10 @@ function syncBackgroundSubagents(session, tasks) {
2868
2989
  changed = true;
2869
2990
  }
2870
2991
  running.background = true;
2992
+ if (runId && runId !== running.runId) {
2993
+ running.runId = runId;
2994
+ changed = true;
2995
+ }
2871
2996
  running.lastSeenAt = Date.now();
2872
2997
  const parentTaskId = firstLine(task?.parent_task_id || "", 80);
2873
2998
  if (parentTaskId && parentTaskId !== running.parentTaskId) {
@@ -2882,11 +3007,19 @@ function syncBackgroundSubagents(session, tasks) {
2882
3007
  running.description = description;
2883
3008
  changed = true;
2884
3009
  }
3010
+ rememberSubagent(session, running);
2885
3011
  }
2886
3012
  if (changed || liveIds.size) emitSubagents(session);
2887
3013
  }
2888
3014
 
2889
3015
  function processSubagentSystemMessage(session, message) {
3016
+ if (String(message.subtype || "").startsWith("task_")) {
3017
+ const taskId = firstLine(message.task_id || "", 80);
3018
+ const runId = subagentRunId(message.run_id);
3019
+ const running = (taskId && findSubagent(session, taskId))
3020
+ || (message.tool_use_id && findSubagent(session, message.tool_use_id));
3021
+ if (isStaleSubagentRun(session, taskId, runId, running)) return true;
3022
+ }
2890
3023
  if (message.subtype === "task_started") {
2891
3024
  if (message.skip_transcript === true || message.ambient === true) {
2892
3025
  const taskId = firstLine(message.task_id || "", 80);
@@ -2899,6 +3032,7 @@ function processSubagentSystemMessage(session, message) {
2899
3032
  }
2900
3033
  const running = (toolUseId && findSubagent(session, toolUseId))
2901
3034
  || (taskId && findSubagent(session, taskId));
3035
+ rememberSubagent(session, running || { taskId }, false, subagentRunId(message.run_id));
2902
3036
  if (running) {
2903
3037
  session.subagents.delete(running.id);
2904
3038
  emitSubagents(session);
@@ -2927,9 +3061,15 @@ function processSubagentSystemMessage(session, message) {
2927
3061
  return finishStructuredSubagent(session, message);
2928
3062
  }
2929
3063
  const running = taskId && findSubagent(session, taskId);
2930
- if (!running) return false;
3064
+ const runId = subagentRunId(message.run_id);
3065
+ if (!running) {
3066
+ rememberSubagent(session, { taskId }, false, runId);
3067
+ return false;
3068
+ }
3069
+ if (runId) running.runId = runId;
2931
3070
  if (patch.is_backgrounded === true) running.background = true;
2932
3071
  running.lastSeenAt = Date.now();
3072
+ rememberSubagent(session, running);
2933
3073
  emitSubagents(session);
2934
3074
  return true;
2935
3075
  }
@@ -2985,6 +3125,7 @@ function subagentToolLabel(block) {
2985
3125
  function finishSubagent(session, toolUseId) {
2986
3126
  const running = findSubagent(session, toolUseId);
2987
3127
  if (!running) return;
3128
+ rememberSubagent(session, running, true);
2988
3129
  session.subagents.delete(running.id);
2989
3130
  emitSubagents(session);
2990
3131
  }
@@ -2993,6 +3134,7 @@ function finishSubagentTask(session, taskId) {
2993
3134
  if (!taskId) return false;
2994
3135
  const entry = [...session.subagents.entries()].find(([, agent]) => agent.taskId === taskId);
2995
3136
  if (!entry) return false;
3137
+ rememberSubagent(session, entry[1], true);
2996
3138
  session.subagents.delete(entry[0]);
2997
3139
  emitSubagents(session);
2998
3140
  return true;
@@ -3002,6 +3144,7 @@ function clearForegroundSubagents(session) {
3002
3144
  let changed = false;
3003
3145
  for (const [id, agent] of session.subagents) {
3004
3146
  if (agent.background) continue;
3147
+ rememberSubagent(session, agent, true);
3005
3148
  session.subagents.delete(id);
3006
3149
  changed = true;
3007
3150
  }
@@ -3129,6 +3272,7 @@ function taskNotifications(message) {
3129
3272
  toolUseId: notificationTag(body, "tool-use-id"),
3130
3273
  status: notificationTag(body, "status"),
3131
3274
  summary: notificationTag(body, "summary"),
3275
+ runId: subagentRunId(message.origin?.runId),
3132
3276
  });
3133
3277
  }
3134
3278
  return notifications;
@@ -3156,7 +3300,9 @@ function finishNotifiedSubagents(session, notifications) {
3156
3300
  .find((agent) => agent.background && agent.name === name);
3157
3301
  }
3158
3302
  }
3159
- if (!running) continue;
3303
+ if (isStaleSubagentRun(session, notification.taskId, notification.runId, running)) continue;
3304
+ rememberSubagent(session, running || { taskId: notification.taskId, runId: notification.runId }, true, notification.runId || running?.runId);
3305
+ if (!running || hasLiveChildren(session, running.taskId)) continue;
3160
3306
  session.subagents.delete(running.id);
3161
3307
  emitSubagents(session);
3162
3308
  }
@@ -3436,7 +3582,7 @@ async function processResult(session, message) {
3436
3582
  }
3437
3583
  // Not awaited: the next run's frames queue behind this message, and the
3438
3584
  // account lookup used to hold them back.
3439
- Promise.all([safeAccount(session.query), safeUsage(session.query)])
3585
+ withSessionQuery(session, (query) => Promise.all([safeAccount(query), safeUsage(query)]))
3440
3586
  .then(([account, usage]) => notify("claude/account/updated", { threadId: session.id, account, usage }))
3441
3587
  .catch(() => {});
3442
3588
  if (!steerFollows) await runPendingPrompt(session);
@@ -3538,6 +3684,7 @@ function beginUntrackedTurn(session, message) {
3538
3684
  }
3539
3685
 
3540
3686
  async function consumeMessage(session, message) {
3687
+ if (session.closed) return;
3541
3688
  adoptSessionId(session, message.session_id);
3542
3689
  beginUntrackedTurn(session, message);
3543
3690
  // A queued SDK response may beat the timer. It already resumes the work.
@@ -3712,55 +3859,60 @@ async function steerPrompt(params) {
3712
3859
  }
3713
3860
 
3714
3861
  async function runPrompt(session, params) {
3715
- const id = session.id;
3716
- // The session already runs this model when it started on it (a fork inherits
3717
- // the parent's), and re-setting it makes the CLI probe the API again, which
3718
- // fails the whole prompt when that check times out.
3719
- if (params.model && visibleModel(params.model) !== session.model) {
3720
- const model = stripClaudeModel(params.model);
3721
- await session.query.setModel(model);
3722
- session.model = visibleModel(params.model);
3723
- }
3724
- session.steerPending = 0;
3725
- // Steering carries no effort of its own, so a steer that lands here because the
3726
- // turn it meant to join is gone must keep the level the user picked.
3727
- const effort = supportedEffort(
3728
- modelCapabilities(session.models, params.model || session.model),
3729
- params.effort ?? session.effort,
3730
- );
3731
- // Ultracode is xhigh plus standing workflows; a steer keeps the session's.
3732
- const ultracode = (params.ultracode ?? session.ultracode) === true && effort === "xhigh";
3733
- if (effort) {
3734
- await session.query.applyFlagSettings({ effortLevel: effort, ultracode });
3735
- }
3736
- // The SDK takes the flag without complaint even where dynamic workflows are
3737
- // off, so ask whether it actually took hold.
3738
- if (ultracode) {
3739
- const settings = await session.query.getSettings?.().catch(() => null);
3740
- if (settings?.applied?.ultracode === false) notify("claude/ultracode/unavailable", { threadId: id });
3741
- }
3742
- session.effort = effort;
3743
- session.ultracode = ultracode;
3744
- // The role's read-only policy, or none: it binds this turn and the background
3745
- // work it starts, until the next prompt replaces it. Steering carries no
3746
- // policy of its own — it joins the turn already running — so a steer that
3747
- // lands here because that turn is gone must keep the policy in force rather
3748
- // than silently lifting the role's restrictions.
3749
- if ("toolPolicy" in params) {
3750
- session.toolPolicy = params.toolPolicy && typeof params.toolPolicy === "object"
3751
- ? params.toolPolicy
3752
- : null;
3862
+ session.promptStarting = (session.promptStarting || 0) + 1;
3863
+ try {
3864
+ const id = session.id;
3865
+ // The session already runs this model when it started on it (a fork inherits
3866
+ // the parent's), and re-setting it makes the CLI probe the API again, which
3867
+ // fails the whole prompt when that check times out.
3868
+ if (params.model && visibleModel(params.model) !== session.model) {
3869
+ const model = stripClaudeModel(params.model);
3870
+ await session.query.setModel(model);
3871
+ session.model = visibleModel(params.model);
3872
+ }
3873
+ session.steerPending = 0;
3874
+ // Steering carries no effort of its own, so a steer that lands here because the
3875
+ // turn it meant to join is gone must keep the level the user picked.
3876
+ const effort = supportedEffort(
3877
+ modelCapabilities(session.models, params.model || session.model),
3878
+ params.effort ?? session.effort,
3879
+ );
3880
+ // Ultracode is xhigh plus standing workflows; a steer keeps the session's.
3881
+ const ultracode = (params.ultracode ?? session.ultracode) === true && effort === "xhigh";
3882
+ if (effort) {
3883
+ await session.query.applyFlagSettings({ effortLevel: effort, ultracode });
3884
+ }
3885
+ // The SDK takes the flag without complaint even where dynamic workflows are
3886
+ // off, so ask whether it actually took hold.
3887
+ if (ultracode) {
3888
+ const settings = await session.query.getSettings?.().catch(() => null);
3889
+ if (settings?.applied?.ultracode === false) notify("claude/ultracode/unavailable", { threadId: id });
3890
+ }
3891
+ session.effort = effort;
3892
+ session.ultracode = ultracode;
3893
+ // The role's read-only policy, or none: it binds this turn and the background
3894
+ // work it starts, until the next prompt replaces it. Steering carries no
3895
+ // policy of its own — it joins the turn already running — so a steer that
3896
+ // lands here because that turn is gone must keep the policy in force rather
3897
+ // than silently lifting the role's restrictions.
3898
+ if ("toolPolicy" in params) {
3899
+ session.toolPolicy = params.toolPolicy && typeof params.toolPolicy === "object"
3900
+ ? params.toolPolicy
3901
+ : null;
3902
+ }
3903
+ const content = await inputContent(params.input, params.handoffContext);
3904
+ const turnId = beginTurn(session, params.input);
3905
+ session.queue.push({
3906
+ type: "user",
3907
+ message: { role: "user", content },
3908
+ parent_tool_use_id: null,
3909
+ session_id: id,
3910
+ origin: { kind: "human" },
3911
+ });
3912
+ return { turn: { id: turnId } };
3913
+ } finally {
3914
+ session.promptStarting -= 1;
3753
3915
  }
3754
- const content = await inputContent(params.input, params.handoffContext);
3755
- const turnId = beginTurn(session, params.input);
3756
- session.queue.push({
3757
- type: "user",
3758
- message: { role: "user", content },
3759
- parent_tool_use_id: null,
3760
- session_id: id,
3761
- origin: { kind: "human" },
3762
- });
3763
- return { turn: { id: turnId } };
3764
3916
  }
3765
3917
 
3766
3918
  // A background task notification is an internal user message that starts its
@@ -4332,7 +4484,7 @@ async function dispatch(method, params = {}) {
4332
4484
  if (!session) throw new Error("Claude 세션이 아직 시작되지 않았습니다.");
4333
4485
  const name = String(params.name || "").trim();
4334
4486
  if (!name) throw new Error("변경할 Claude MCP 서버 이름이 없습니다.");
4335
- await session.query.toggleMcpServer(name, Boolean(params.enabled));
4487
+ await withSessionQuery(session, (query) => query.toggleMcpServer(name, Boolean(params.enabled)));
4336
4488
  return { effectiveEnabled: Boolean(params.enabled) };
4337
4489
  }
4338
4490
  if (method === "plugin/list") return claudePluginCatalog(params);
@@ -4430,20 +4582,22 @@ async function dispatch(method, params = {}) {
4430
4582
  const id = liveSessionId(params.sessionId);
4431
4583
  const existing = sessions.get(id);
4432
4584
  if (existing) {
4433
- const messages = await sessionMessages(id, existing.cwd);
4434
- if (!existing.tasks.size) existing.tasks = historyState(messages).tasks;
4435
- restoreArtifacts(id, messages);
4436
- return {
4437
- id,
4438
- thread: { id, turns: [] },
4439
- initialTurnsPage: { data: [], nextCursor: null },
4440
- cwd: existing.cwd,
4441
- model: existing.model,
4442
- reasoningEffort: existing.effort,
4443
- account: await safeAccount(existing.query),
4444
- usage: await safeUsage(existing.query),
4445
- tokenUsage: historyTokenUsage(messages, existing.models, existing.model),
4446
- };
4585
+ return withSessionQuery(existing, async (query) => {
4586
+ const messages = await sessionMessages(id, existing.cwd);
4587
+ if (!existing.tasks.size) existing.tasks = historyState(messages).tasks;
4588
+ restoreArtifacts(id, messages);
4589
+ return {
4590
+ id,
4591
+ thread: { id, turns: [] },
4592
+ initialTurnsPage: { data: [], nextCursor: null },
4593
+ cwd: existing.cwd,
4594
+ model: existing.model,
4595
+ reasoningEffort: existing.effort,
4596
+ account: await safeAccount(query),
4597
+ usage: await safeUsage(query),
4598
+ tokenUsage: historyTokenUsage(messages, existing.models, existing.model),
4599
+ };
4600
+ });
4447
4601
  }
4448
4602
  const dir = await readableCwd(id, params.cwd);
4449
4603
  // The transcript itself decides whether there is anything to resume:
@@ -4493,7 +4647,7 @@ async function dispatch(method, params = {}) {
4493
4647
  const taskId = (session && (findSubagent(session, id)?.taskId || session.rowTaskIds?.get(id)))
4494
4648
  || (id.startsWith("task:") ? id.slice(5) : "");
4495
4649
  if (!session || !taskId || typeof session.query.getTaskOutput !== "function") return {};
4496
- const result = await session.query.getTaskOutput(taskId);
4650
+ const result = await withSessionQuery(session, (query) => query.getTaskOutput(taskId));
4497
4651
  return { output: String(result?.output ?? "") };
4498
4652
  }
4499
4653
  if (method === "session/watchSubagent") {
@@ -4521,7 +4675,7 @@ async function dispatch(method, params = {}) {
4521
4675
  const turn = session.turn;
4522
4676
  turn.interruptRequested = true;
4523
4677
  try {
4524
- await session.query.interrupt();
4678
+ await withSessionQuery(session, (query) => query.interrupt());
4525
4679
  } catch (error) {
4526
4680
  if (session.turn === turn) delete turn.interruptRequested;
4527
4681
  throw error;
@@ -4594,29 +4748,25 @@ async function dispatch(method, params = {}) {
4594
4748
  if (method === "session/close") {
4595
4749
  const session = lookupSession(params.sessionId);
4596
4750
  if (session) {
4597
- clearUsageLimitWait(session);
4598
- clearSubagents(session);
4599
- session.queue.close();
4600
- session.query.close();
4601
- sessions.delete(session.id);
4602
- for (const [from, to] of sessionAliases) {
4603
- if (to === session.id) sessionAliases.delete(from);
4604
- }
4751
+ closeLiveSession(session);
4605
4752
  if (params.delete) await deleteSession(session.id, { dir: session.cwd });
4606
4753
  }
4607
4754
  return {};
4608
4755
  }
4756
+ if (method === "session/release") {
4757
+ const session = lookupSession(params.sessionId);
4758
+ if (session && sessionHasLiveWork(session)) return { released: false, busy: true };
4759
+ if (session) closeLiveSession(session);
4760
+ return { released: true, busy: false };
4761
+ }
4609
4762
  if (method === "account/usage") {
4610
4763
  const session = lookupSession(params.sessionId) || [...sessions.values()][0];
4611
4764
  if (!session) return { account: null, usage: null };
4612
- return { account: await safeAccount(session.query), usage: await safeUsage(session.query) };
4765
+ return withSessionQuery(session, async (query) => ({ account: await safeAccount(query), usage: await safeUsage(query) }));
4613
4766
  }
4614
4767
  if (method === "shutdown") {
4615
4768
  for (const session of sessions.values()) {
4616
- clearUsageLimitWait(session);
4617
- clearSubagents(session);
4618
- session.queue.close();
4619
- session.query.close();
4769
+ closeLiveSession(session);
4620
4770
  }
4621
4771
  sessions.clear();
4622
4772
  sessionAliases.clear();
@@ -4994,6 +5144,271 @@ async function runSelfTest() {
4994
5144
  else process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
4995
5145
  await rm(configDir, { recursive: true, force: true });
4996
5146
  }
5147
+ // SDK 프로세스만 mock으로 바꾸고 저장 전사와 실제 release/resume 경로를 검사한다.
5148
+ const releaseConfigDir = join(tmpdir(), `devez-release-self-test-${randomUUID()}`);
5149
+ const releaseId = randomUUID();
5150
+ const releaseProjectDir = join(releaseConfigDir, "projects", "release-test");
5151
+ const releaseTranscriptPath = join(releaseProjectDir, `${releaseId}.jsonl`);
5152
+ const releaseTranscript = `${JSON.stringify({
5153
+ type: "user", uuid: randomUUID(), sessionId: releaseId, cwd: process.cwd(),
5154
+ message: { role: "user", content: "해제 뒤에도 남을 대화" },
5155
+ })}\n`;
5156
+ await mkdir(releaseProjectDir, { recursive: true });
5157
+ await writeFile(releaseTranscriptPath, releaseTranscript);
5158
+ const originalStartAgentQuery = startAgentQuery;
5159
+ const originalEnsureExecutable = ensureClaudeExecutableChoice;
5160
+ const releaseQueries = [];
5161
+ const releaseNotices = [];
5162
+ const releaseStdoutWrite = process.stdout.write;
5163
+ let nextReleaseInitialization = null;
5164
+ let nextReleaseUsage = null;
5165
+ process.stdout.write = (chunk) => { releaseNotices.push(JSON.parse(String(chunk))); return true; };
5166
+ process.env.CLAUDE_CONFIG_DIR = releaseConfigDir;
5167
+ ensureClaudeExecutableChoice = async () => {};
5168
+ startAgentQuery = async (_queue, options) => {
5169
+ const events = new AsyncQueue();
5170
+ const initialization = nextReleaseInitialization;
5171
+ nextReleaseInitialization = null;
5172
+ const pendingUsage = nextReleaseUsage;
5173
+ nextReleaseUsage = null;
5174
+ const query = {
5175
+ events, options, closeCalls: 0, finishOnClose: releaseQueries.length > 0,
5176
+ async initializationResult() { return initialization ? await initialization : { models: [], account: {} }; },
5177
+ async setPermissionMode() {},
5178
+ async accountInfo() { return {}; },
5179
+ async usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET() { return pendingUsage ? await pendingUsage : {}; },
5180
+ close() { this.closeCalls += 1; if (this.finishOnClose) events.close(); },
5181
+ [Symbol.asyncIterator]() {
5182
+ const iterator = events[Symbol.asyncIterator]();
5183
+ return {
5184
+ next: async () => {
5185
+ const step = await iterator.next();
5186
+ if (this.iteratorError) throw this.iteratorError;
5187
+ // 실제 SDK도 close 이후 for-await를 정상 완료 대신 AbortError로 끝낼 수 있다.
5188
+ if (step.done && this.closeCalls) {
5189
+ throw Object.assign(new Error("mock intended query close"), { name: "AbortError" });
5190
+ }
5191
+ return step;
5192
+ },
5193
+ };
5194
+ },
5195
+ };
5196
+ releaseQueries.push(query);
5197
+ return query;
5198
+ };
5199
+ try {
5200
+ const { session } = await createSession({ cwd: process.cwd() }, releaseId);
5201
+ const oldConsumer = session.consumer;
5202
+ const oldQueue = session.queue;
5203
+ const oldQuery = session.query;
5204
+ const assertBusy = async () => {
5205
+ const reply = await dispatch("session/release", { sessionId: releaseId });
5206
+ if (reply.released !== false || reply.busy !== true || lookupSession(releaseId) !== session
5207
+ || oldQuery.closeCalls || oldQueue.closed) throw new Error("Claude release terminated live work");
5208
+ };
5209
+ for (const key of ["initializing", "promptStarting", "steerPending", "automaticTurnsPending", "usageLimitWait", "turn"]) {
5210
+ session[key] = key === "turn" ? { id: "busy-turn" } : 1;
5211
+ await assertBusy();
5212
+ session[key] = null;
5213
+ }
5214
+ session.pendingPrompts.push({ input: [] });
5215
+ await assertBusy();
5216
+ session.pendingPrompts.length = 0;
5217
+ oldQueue.push({ type: "user" });
5218
+ await assertBusy();
5219
+ oldQueue.values.length = 0;
5220
+ session.subagents.set("busy-child", { id: "busy-child", background: true });
5221
+ await assertBusy();
5222
+ session.subagents.clear();
5223
+ for (const hidden of [session.hiddenSubagentTasks, session.ambientSubagentTasks]) {
5224
+ hidden.add("busy-hidden-child");
5225
+ await assertBusy();
5226
+ hidden.clear();
5227
+ }
5228
+ const question = hostRequest("item/tool/requestUserInput", {}, undefined, releaseId);
5229
+ await assertBusy();
5230
+ const [questionId, request] = [...pendingHostRequests].find(([, pending]) => pending.session === session);
5231
+ pendingHostRequests.delete(questionId);
5232
+ request.resolve({ answers: {} });
5233
+ await question;
5234
+ watchSubagent(session, "untracked-child");
5235
+ sessionAliases.set("release-old-alias", "release-middle-alias");
5236
+ sessionAliases.set("release-middle-alias", releaseId);
5237
+ const reply = await dispatch("session/release", { sessionId: "claude:release-old-alias" });
5238
+ const repeated = await dispatch("session/release", { sessionId: releaseId });
5239
+ if (!reply.released || reply.busy || !repeated.released || repeated.busy || sessions.has(releaseId)
5240
+ || oldQuery.closeCalls !== 1 || !oldQueue.closed || session.query || session.queue || session.subagentWatch
5241
+ || sessionAliases.has("release-old-alias") || sessionAliases.has("release-middle-alias")
5242
+ || await readFile(releaseTranscriptPath, "utf8") !== releaseTranscript) {
5243
+ throw new Error("Claude idle release did not clean live references while preserving its transcript");
5244
+ }
5245
+ const resumed = await dispatch("session/resume", { sessionId: releaseId, cwd: process.cwd() });
5246
+ const resumedSession = lookupSession(releaseId);
5247
+ if (resumed.id !== releaseId || !resumedSession || resumedSession === session
5248
+ || resumedSession.query.options.resume !== releaseId
5249
+ || !historyTurns(await sessionMessages(releaseId, process.cwd()))
5250
+ .some((turn) => turn.items.some((item) => item.content?.[0]?.text === "해제 뒤에도 남을 대화"))) {
5251
+ throw new Error("Claude released session could not resume its saved transcript");
5252
+ }
5253
+ oldQuery.events.push({ type: "system", subtype: "task_started", session_id: "stale-session-id", task_id: "stale-task" });
5254
+ oldQuery.events.push({ type: "system", subtype: "task_started", session_id: releaseId,
5255
+ task_id: "stale-task", task_type: "local_agent", subagent_type: "Explore", is_backgrounded: true });
5256
+ oldQuery.events.close();
5257
+ await oldConsumer;
5258
+ if (lookupSession(releaseId) !== resumedSession || resumedSession.query.closeCalls || session.subagents.size
5259
+ || sessions.has("stale-session-id")) throw new Error("Claude old consumer removed a resumed same-id query");
5260
+ const resumedConsumer = resumedSession.consumer;
5261
+ await dispatch("session/release", { sessionId: releaseId });
5262
+ await resumedConsumer;
5263
+ const { session: mainSession } = await createSession({ cwd: process.cwd() });
5264
+ const mainQuery = mainSession.query;
5265
+ const mainConsumer = mainSession.consumer;
5266
+ let finishMetadata;
5267
+ nextReleaseUsage = new Promise((resolve) => { finishMetadata = resolve; });
5268
+ const metadataCreate = createSession({ cwd: process.cwd() });
5269
+ await new Promise((resolve) => setImmediate(resolve));
5270
+ const metadataSession = [...sessions.values()].find((candidate) => candidate !== mainSession);
5271
+ const metadataConsumer = metadataSession?.consumer;
5272
+ if (!metadataSession?.query || !metadataConsumer) throw new Error("Claude metadata-wait fixture did not start");
5273
+ metadataSession.query.events.close();
5274
+ await metadataConsumer;
5275
+ finishMetadata({});
5276
+ let metadataFailed = false;
5277
+ try { await metadataCreate; } catch { metadataFailed = true; }
5278
+ if (!metadataFailed || lookupSession(mainSession.id) !== mainSession || mainQuery.closeCalls) {
5279
+ throw new Error("Claude closed initializing query returned success or pruned the previous query");
5280
+ }
5281
+ nextReleaseInitialization = Promise.reject(new Error("mock initialization failure"));
5282
+ let initializationFailed = false;
5283
+ try { await createSession({ cwd: process.cwd() }); } catch { initializationFailed = true; }
5284
+ if (!initializationFailed || lookupSession(mainSession.id) !== mainSession || mainQuery.closeCalls) {
5285
+ throw new Error("Claude failed new query closed the previous idle query");
5286
+ }
5287
+ let finishInitialization;
5288
+ nextReleaseInitialization = new Promise((resolve) => { finishInitialization = resolve; });
5289
+ const pendingCreate = createSession({ cwd: process.cwd() });
5290
+ await new Promise((resolve) => setImmediate(resolve));
5291
+ const initializingSession = [...sessions.values()].find((candidate) => candidate !== mainSession);
5292
+ if (!initializingSession?.initializing || mainQuery.closeCalls) {
5293
+ throw new Error("Claude initializing query prematurely closed the previous idle query");
5294
+ }
5295
+ const initializingQuery = initializingSession.query;
5296
+ const { session: readySession } = await createSession({ cwd: process.cwd() });
5297
+ await mainConsumer;
5298
+ if (!mainQuery.closeCalls || lookupSession(mainSession.id) || !initializingSession.initializing
5299
+ || initializingQuery.closeCalls) throw new Error("Claude ready query did not preserve another initializing query");
5300
+ readySession.turn = { id: "main-active-turn" };
5301
+ finishInitialization({ models: [], account: {} });
5302
+ const { session: btwSession } = await pendingCreate;
5303
+ if (lookupSession(readySession.id) !== readySession || readySession.query.closeCalls
5304
+ || lookupSession(btwSession.id) !== btwSession) throw new Error("Claude auxiliary query closed its working parent");
5305
+ readySession.turn = null;
5306
+ const readyConsumer = readySession.consumer;
5307
+ const btwConsumer = btwSession.consumer;
5308
+ const { session: newestSession } = await createSession({ cwd: process.cwd() });
5309
+ await Promise.all([readyConsumer, btwConsumer]);
5310
+ if (lookupSession(readySession.id) || lookupSession(btwSession.id) || sessions.size !== 1) {
5311
+ throw new Error("Claude successful new query retained idle main or auxiliary queries");
5312
+ }
5313
+ const newestConsumer = newestSession.consumer;
5314
+ await dispatch("session/release", { sessionId: newestSession.id });
5315
+ await newestConsumer;
5316
+ if (releaseNotices.some((notice) => notice.method === "error"
5317
+ || notice.method === "turn/subagents/updated")) {
5318
+ throw new Error("Claude intended release or idle pruning emitted an error or revived a subagent row");
5319
+ }
5320
+ // 명시적으로 닫지 않은 실제 연결 실패는 기존 오류 통지를 유지한다.
5321
+ const { session: failedSession } = await createSession({ cwd: process.cwd() });
5322
+ const failedConsumer = failedSession.consumer;
5323
+ failedSession.query.iteratorError = new Error("mock unexpected query failure");
5324
+ failedSession.query.events.close();
5325
+ await failedConsumer;
5326
+ const unexpectedErrors = releaseNotices.filter((notice) => notice.method === "error");
5327
+ if (unexpectedErrors.length !== 1 || unexpectedErrors[0].params.threadId !== failedSession.id
5328
+ || unexpectedErrors[0].params.error.message !== "mock unexpected query failure") {
5329
+ throw new Error("Claude unexpected open-query failure lost its error notification");
5330
+ }
5331
+ // 메인 MCP 요청처럼 턴 밖에서 await하는 실제 SDK 제어·조회 함수도 보호한다.
5332
+ for (const kind of ["reconnect", "status", "toggle", "permission", "account", "skills", "output", "resume"]) {
5333
+ const { session: requestSession } = await createSession({ cwd: process.cwd() });
5334
+ const requestQuery = requestSession.query;
5335
+ const requestConsumer = requestSession.consumer;
5336
+ let finishRequest;
5337
+ const pendingResult = new Promise((resolve) => { finishRequest = resolve; });
5338
+ let requestPromise;
5339
+ let result;
5340
+ if (kind === "reconnect" || kind === "status") {
5341
+ result = [{ name: "connected", status: "connected" }];
5342
+ requestQuery.mcpServerStatus = () => pendingResult;
5343
+ requestQuery.reconnectMcpServer = async () => {};
5344
+ requestPromise = kind === "reconnect"
5345
+ ? reconnectClaudeMcp(requestSession, "")
5346
+ : dispatch("mcpServerStatus/list", { sessionId: requestSession.id });
5347
+ } else if (kind === "toggle") {
5348
+ requestQuery.toggleMcpServer = () => pendingResult;
5349
+ requestPromise = dispatch("mcp/toggle", { sessionId: requestSession.id, name: "connected", enabled: true });
5350
+ } else if (kind === "permission") {
5351
+ requestSession.permissionModeVerified = false;
5352
+ requestQuery.setPermissionMode = () => pendingResult;
5353
+ requestPromise = applyPermissionMode(requestSession);
5354
+ } else if (kind === "account" || kind === "resume") {
5355
+ result = {};
5356
+ requestQuery.accountInfo = () => pendingResult;
5357
+ requestPromise = dispatch(kind === "account" ? "account/usage" : "session/resume", { sessionId: requestSession.id });
5358
+ } else if (kind === "skills") {
5359
+ result = [];
5360
+ requestQuery.supportedCommands = () => pendingResult;
5361
+ requestPromise = supportedClaudeSkills({ cwd: requestSession.cwd, forceReload: true });
5362
+ } else {
5363
+ result = { output: "saved task output" };
5364
+ requestSession.rowTaskIds = new Map([["finished-row", "finished-task"]]);
5365
+ requestQuery.getTaskOutput = () => pendingResult;
5366
+ requestPromise = dispatch("session/taskOutput", { sessionId: requestSession.id, id: "finished-row" });
5367
+ }
5368
+ const blockedRelease = await dispatch("session/release", { sessionId: requestSession.id });
5369
+ const { session: competingSession } = await createSession({ cwd: process.cwd() });
5370
+ if (!blockedRelease.busy || blockedRelease.released || !requestSession.queryRequests
5371
+ || lookupSession(requestSession.id) !== requestSession || requestQuery.closeCalls) {
5372
+ throw new Error(`Claude active SDK ${kind} request was treated as idle`);
5373
+ }
5374
+ finishRequest(result);
5375
+ await requestPromise;
5376
+ if (requestSession.queryRequests || sessionHasLiveWork(requestSession)) {
5377
+ throw new Error(`Claude completed SDK ${kind} request retained its busy guard`);
5378
+ }
5379
+ if (kind === "reconnect") {
5380
+ requestQuery.reconnectMcpServer = async () => { throw new Error("mock reconnect failure"); };
5381
+ let failedRequest = false;
5382
+ try { await reconnectClaudeMcp(requestSession, "connected"); } catch { failedRequest = true; }
5383
+ if (!failedRequest || requestSession.queryRequests || sessionHasLiveWork(requestSession)) {
5384
+ throw new Error("Claude rejected SDK request retained its busy guard");
5385
+ }
5386
+ }
5387
+ const { session: afterRequestSession } = await createSession({ cwd: process.cwd() });
5388
+ await requestConsumer;
5389
+ if (!requestQuery.closeCalls || lookupSession(requestSession.id) || lookupSession(competingSession.id)) {
5390
+ throw new Error(`Claude completed SDK ${kind} request prevented later idle pruning`);
5391
+ }
5392
+ const afterRequestConsumer = afterRequestSession.consumer;
5393
+ await dispatch("session/release", { sessionId: afterRequestSession.id });
5394
+ await afterRequestConsumer;
5395
+ }
5396
+ if (releaseNotices.filter((notice) => notice.method === "error").length !== 1) {
5397
+ throw new Error("Claude SDK request pruning emitted a false close error");
5398
+ }
5399
+ } finally {
5400
+ const live = lookupSession(releaseId);
5401
+ if (live) closeLiveSession(live);
5402
+ for (const query of releaseQueries) query.events.close();
5403
+ startAgentQuery = originalStartAgentQuery;
5404
+ ensureClaudeExecutableChoice = originalEnsureExecutable;
5405
+ if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
5406
+ else process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
5407
+ transcriptPaths.delete(releaseId);
5408
+ transcriptCwds.delete(releaseId);
5409
+ process.stdout.write = releaseStdoutWrite;
5410
+ await rm(releaseConfigDir, { recursive: true, force: true });
5411
+ }
4997
5412
  // A resumed transcript lists its Artifact pages once each at their latest
4998
5413
  // publish, read through the file's toolUseResult spelling.
4999
5414
  const artifactTranscript = [
@@ -5050,14 +5465,14 @@ async function runSelfTest() {
5050
5465
  || CLAUDE_TASK_TOOLS.some((tool) => !latestOptions.allowedTools.includes(tool))) {
5051
5466
  throw new Error(`Claude latest SDK options self-test failed: ${JSON.stringify(latestOptions.allowedTools)}`);
5052
5467
  }
5053
- const lookupSession = {
5468
+ const taskLookupSession = {
5054
5469
  turn: { id: "task-get-turn" },
5055
5470
  tools: new Map(),
5056
5471
  tasks: new Map(),
5057
5472
  planCreatePending: false,
5058
5473
  };
5059
- processToolUse(lookupSession, { id: "task-get", name: "TaskGet", input: { taskId: "1" } });
5060
- if (lookupSession.tools.get("task-get")?.suppressed !== true) {
5474
+ processToolUse(taskLookupSession, { id: "task-get", name: "TaskGet", input: { taskId: "1" } });
5475
+ if (taskLookupSession.tools.get("task-get")?.suppressed !== true) {
5061
5476
  throw new Error("Claude TaskGet transcript suppression self-test failed");
5062
5477
  }
5063
5478
  const pluginCatalog = buildClaudePluginCatalog(
@@ -6010,8 +6425,10 @@ async function runSelfTest() {
6010
6425
  message: { content: [{ type: "tool_result", tool_use_id: "toolu_2", content: "resumed" }] },
6011
6426
  tool_use_result: { success: true, resumedAgentId: "agent-1" },
6012
6427
  });
6013
- const resumed = lifecycleSession.subagents.get("toolu_2");
6014
- if (!resumed?.background || resumed.taskId !== "agent-1" || resumed.name !== "Explore") {
6428
+ // 재개된 줄은 끝난 줄과 같은 id(처음 Agent 호출 id)로 돌아오고, SendMessage 호출 id로도 찾힌다.
6429
+ const resumed = lifecycleSession.subagents.get("toolu_1");
6430
+ if (!resumed?.background || resumed.taskId !== "agent-1" || resumed.name !== "Explore"
6431
+ || findSubagent(lifecycleSession, "toolu_2") !== resumed) {
6015
6432
  throw new Error(`Claude resumed subagent self-test failed: ${JSON.stringify(resumed)}`);
6016
6433
  }
6017
6434
  finishTurn(lifecycleSession, null, 1);
@@ -6161,36 +6578,164 @@ async function runSelfTest() {
6161
6578
  }
6162
6579
  clearSubagents(nestedSession);
6163
6580
  // 자기 백그라운드 명령을 기다리며 멈춘 에이전트는 그 명령이 도는 동안 행을 지킨다.
6164
- processSubagentSystemMessage(nestedSession, {
6165
- type: "system",
6166
- subtype: "task_started",
6167
- task_id: "agent-wait",
6168
- tool_use_id: "toolu_wait",
6169
- task_type: "local_agent",
6170
- subagent_type: "general-purpose",
6171
- });
6172
- nestedSession.subagents.get("toolu_wait").background = true;
6581
+ // SDK 순서(접수 결과, task_started, 스냅숏, task_updated·task_notification, 스냅숏,
6582
+ // XML 통지)를 그대로 재생하고, 다시 실행된 뒤 늦게 온 첫 실행 통지도 무시한다.
6173
6583
  const waitSnapshot = (tasks) => processSubagentSystemMessage(nestedSession, {
6174
6584
  type: "system",
6175
6585
  subtype: "background_tasks_changed",
6176
6586
  tasks,
6177
6587
  });
6178
- waitSnapshot([{ task_id: "shell-wait", task_type: "local_bash", description: "sleep 300", parent_task_id: "agent-wait" }]);
6179
- processSubagentSystemMessage(nestedSession, {
6588
+ const waitStructured = (subtype, extra) => processSubagentSystemMessage(nestedSession, {
6180
6589
  type: "system",
6181
- subtype: "task_notification",
6590
+ subtype,
6182
6591
  task_id: "agent-wait",
6183
- status: "completed",
6592
+ ...extra,
6184
6593
  });
6594
+ const waitXml = (runId) => finishNotifiedSubagents(nestedSession, taskNotifications({
6595
+ origin: { kind: "task-notification", runId },
6596
+ message: { content: [{ type: "text", text: "<task-notification><task-id>agent-wait</task-id><status>completed</status><summary>Agent \"general-purpose\" finished</summary></task-notification>" }] },
6597
+ }));
6598
+ const agentRow = { task_id: "agent-wait", task_type: "local_agent", description: "wait" };
6599
+ const shellRow = { task_id: "shell-wait", task_type: "local_bash", description: "sleep 300", parent_task_id: "agent-wait" };
6600
+ startSubagent(nestedSession, { id: "toolu_wait", input: { description: "대기", subagent_type: "general-purpose" } });
6601
+ keepBackgroundSubagent(nestedSession, "toolu_wait", { isAsync: true, agentId: "agent-wait" });
6602
+ waitStructured("task_started", { tool_use_id: "toolu_wait", task_type: "local_agent", is_backgrounded: true, run_id: "r1" });
6603
+ waitSnapshot([agentRow, shellRow]);
6604
+ waitStructured("task_updated", { patch: { status: "completed" } });
6605
+ waitStructured("task_notification", { status: "completed", run_id: "r1" });
6606
+ waitSnapshot([shellRow]);
6607
+ waitXml("r1");
6185
6608
  const waitingKept = nestedSession.subagents.has("toolu_wait");
6186
6609
  waitSnapshot([]);
6187
- if (!waitingKept || nestedSession.subagents.size !== 0) {
6188
- throw new Error(`Claude waiting subagent self-test failed: ${waitingKept} ${nestedSession.subagents.size}`);
6610
+ const settledAfterChild = !nestedSession.subagents.has("toolu_wait");
6611
+ waitStructured("task_started", { task_type: "local_agent", is_backgrounded: true, run_id: "r2" });
6612
+ waitXml("r1");
6613
+ const keptAgainstLate = nestedSession.subagents.has("toolu_wait");
6614
+ waitStructured("task_notification", { status: "completed", run_id: "r2" });
6615
+ if (!waitingKept || !settledAfterChild || !keptAgainstLate || nestedSession.subagents.size !== 0) {
6616
+ throw new Error(`Claude waiting subagent self-test failed: ${waitingKept} ${settledAfterChild} ${keptAgainstLate} ${nestedSession.subagents.size}`);
6189
6617
  }
6190
6618
  clearSubagents(nestedSession);
6619
+ // 완료된 실행의 지연 프레임과 새 실행의 스냅숏을 함께 재생한다.
6620
+ const lateSession = {
6621
+ id: "late-run-self-test", turn: null, subagents: new Map(), knownSubagents: new Map(),
6622
+ hiddenSubagentTasks: new Set(), ambientSubagentTasks: new Set(),
6623
+ };
6624
+ const lateEvent = (subtype, runId, extra = {}) => processSubagentSystemMessage(lateSession, {
6625
+ type: "system", subtype, task_id: "agent-late", run_id: runId,
6626
+ task_type: "local_agent", subagent_type: "Explore", is_backgrounded: true, ...extra,
6627
+ });
6628
+ const lateSnapshot = (runId) => syncBackgroundSubagents(lateSession, [{
6629
+ task_id: "agent-late", run_id: runId, task_type: "local_agent",
6630
+ }]);
6631
+ const lateXml = (runId) => finishNotifiedSubagents(lateSession, [{
6632
+ taskId: "agent-late", runId, summary: 'Agent "Explore" finished',
6633
+ }]);
6634
+ try {
6635
+ lateEvent("task_started", "001", { tool_use_id: "toolu_late" });
6636
+ lateEvent("task_notification", "001", { status: "completed" });
6637
+ lateEvent("task_started", "002", { tool_use_id: "toolu_latest" });
6638
+ lateEvent("task_progress", "002", { summary: "최신 진행", last_tool_name: "Read" });
6639
+ const beforeLate = JSON.stringify([...lateSession.subagents]);
6640
+ lateEvent("task_progress", "001", { tool_use_id: "toolu_old", summary: "예전 진행", last_tool_name: "Write" });
6641
+ lateEvent("task_updated", "001", { patch: { is_backgrounded: true } });
6642
+ lateEvent("task_started", "001", { tool_use_id: "toolu_old", skip_transcript: true });
6643
+ lateSnapshot("001");
6644
+ syncBackgroundSubagents(lateSession, [{
6645
+ task_id: "agent-late", run_id: "001", task_type: "local_agent", ambient: true,
6646
+ }]);
6647
+ if (!sessionHasLiveWork(lateSession)) {
6648
+ throw new Error("Claude stale ambient snapshot made a newer task idle");
6649
+ }
6650
+ lateXml("001");
6651
+ lateEvent("task_notification", "001", { status: "completed" });
6652
+ if (JSON.stringify([...lateSession.subagents]) !== beforeLate) {
6653
+ throw new Error("Claude late run modified a newer subagent row");
6654
+ }
6655
+ lateEvent("task_notification", "002", { status: "completed" });
6656
+ for (const runId of ["001", "002"]) {
6657
+ lateEvent("task_progress", runId, { summary: "끝난 진행" });
6658
+ lateEvent("task_started", runId);
6659
+ lateSnapshot(runId);
6660
+ }
6661
+ if (lateSession.subagents.size) throw new Error("Claude late run resurrected a completed subagent");
6662
+ lateSnapshot("003");
6663
+ const fromSnapshot = findSubagent(lateSession, "agent-late");
6664
+ lateEvent("task_notification", "002", { status: "completed" });
6665
+ lateXml("002");
6666
+ if (!fromSnapshot || fromSnapshot.runId !== "003" || lateSession.subagents.size !== 1) {
6667
+ throw new Error("Claude late completion removed a newer snapshot run");
6668
+ }
6669
+ startSubagent(lateSession, { id: "toolu_merge", input: { name: "Explore" } });
6670
+ keepBackgroundSubagent(lateSession, "toolu_merge", { isAsync: true, agentId: "agent-late" });
6671
+ lateXml("002");
6672
+ if (lateSession.subagents.size !== 1 || findSubagent(lateSession, "agent-late")?.runId !== "003") {
6673
+ throw new Error("Claude placeholder merge forgot its newer run");
6674
+ }
6675
+ lateEvent("task_notification", "003", { status: "completed" });
6676
+ resumeBackgroundSubagent(lateSession, "toolu_send_late", {}, { resumedAgentId: "agent-late" });
6677
+ lateXml("003");
6678
+ lateEvent("task_progress", "003", { summary: "재개 전 진행" });
6679
+ lateEvent("task_started", "004", { tool_use_id: "toolu_send_late" });
6680
+ if (lateSession.subagents.size !== 1 || findSubagent(lateSession, "agent-late")?.runId !== "004") {
6681
+ throw new Error("Claude latest resumed run failed after a completed run");
6682
+ }
6683
+ lateXml("004");
6684
+ if (lateSession.subagents.size) throw new Error("Claude latest resumed run did not finish");
6685
+ // level/edge 순서가 뒤집혀 빈 스냅숏이 먼저 와도 같은 실행의 정상 진행은 복구된다.
6686
+ lateEvent("task_started", "005");
6687
+ syncBackgroundSubagents(lateSession, []);
6688
+ lateEvent("task_progress", "005", { summary: "스냅숏 뒤 정상 진행" });
6689
+ if (findSubagent(lateSession, "agent-late")?.description !== "스냅숏 뒤 정상 진행") {
6690
+ throw new Error("Claude empty snapshot blocked the current run's progress");
6691
+ }
6692
+ syncBackgroundSubagents(lateSession, []);
6693
+ lateSnapshot("005");
6694
+ if (findSubagent(lateSession, "agent-late")?.runId !== "005") {
6695
+ throw new Error("Claude empty snapshot blocked the current run's snapshot recovery");
6696
+ }
6697
+ lateEvent("task_notification", "005", { status: "completed" });
6698
+ lateEvent("task_progress", "005", { summary: "완료 뒤 지연 진행" });
6699
+ if (lateSession.subagents.size) throw new Error("Claude authoritative completion lost its tombstone");
6700
+ // 실행 식별자는 표시용 글자가 아니므로 길이·줄바꿈을 바꾸지 않는다.
6701
+ const longPrefix = "x".repeat(80);
6702
+ const firstLongRun = `${longPrefix}a`;
6703
+ const nextLongRun = `${longPrefix}b\nnext`;
6704
+ lateEvent("task_started", firstLongRun);
6705
+ lateXml(firstLongRun);
6706
+ lateEvent("task_started", nextLongRun);
6707
+ lateSnapshot(nextLongRun);
6708
+ if (findSubagent(lateSession, "agent-late")?.runId !== nextLongRun) {
6709
+ throw new Error("Claude truncated an opaque run identifier");
6710
+ }
6711
+ lateXml(firstLongRun);
6712
+ if (!sessionHasLiveWork(lateSession)) throw new Error("Claude older long run removed its successor");
6713
+ lateXml(nextLongRun);
6714
+ if (lateSession.subagents.size) throw new Error("Claude latest long run did not finish");
6715
+ syncBackgroundSubagents(lateSession, [{
6716
+ task_id: "agent-late", run_id: `${longPrefix}c`, task_type: "local_agent", ambient: true,
6717
+ }]);
6718
+ syncBackgroundSubagents(lateSession, [{
6719
+ task_id: "agent-late", run_id: firstLongRun, task_type: "local_agent", ambient: true,
6720
+ }]);
6721
+ if (!lateSession.ambientSubagentTasks.has("agent-late") || !sessionHasLiveWork(lateSession)) {
6722
+ throw new Error("Claude stale ambient snapshot erased a newer ambient task");
6723
+ }
6724
+ } finally {
6725
+ clearSubagents(lateSession);
6726
+ }
6191
6727
  // 끝난 뒤 SDK가 같은 작업을 다시 돌리면 설명 없는 새 줄이 아니라 같은 줄로 돌아온다.
6192
6728
  startSubagent(nestedSession, { id: "toolu_again", input: { description: "3분 대기", subagent_type: "general-purpose" } });
6193
6729
  keepBackgroundSubagent(nestedSession, "toolu_again", { isAsync: true, agentId: "agent-again" });
6730
+ // 진행 요약이 설명을 바꿔도 다시 실행된 줄은 호출 설명으로 돌아온다.
6731
+ for (let tick = 0; tick < 2; tick += 1) {
6732
+ processSubagentSystemMessage(nestedSession, {
6733
+ type: "system",
6734
+ subtype: "task_progress",
6735
+ task_id: "agent-again",
6736
+ summary: "로그를 살피는 중",
6737
+ });
6738
+ }
6194
6739
  waitSnapshot([]);
6195
6740
  processSubagentSystemMessage(nestedSession, {
6196
6741
  type: "system",
@@ -6642,6 +7187,8 @@ async function runSelfTest() {
6642
7187
  throw new Error("Claude idle background subagent self-test failed");
6643
7188
  }
6644
7189
  structuredSession.subagents.clear();
7190
+ // 턴 결과가 분리 실행한 계정 조회를 이 캡처 안에서 끝내 다음 표시 검사와 섞이지 않게 한다.
7191
+ await new Promise((resolve) => setImmediate(resolve));
6645
7192
  } finally {
6646
7193
  process.stdout.write = stdoutWrite;
6647
7194
  }
@@ -6977,8 +7524,6 @@ lines.on("line", async (line) => {
6977
7524
 
6978
7525
  lines.on("close", () => {
6979
7526
  for (const session of sessions.values()) {
6980
- clearUsageLimitWait(session);
6981
- clearSubagents(session);
6982
- session.query.close();
7527
+ closeLiveSession(session);
6983
7528
  }
6984
7529
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devez-vibe",
3
- "version": "1.9.58",
3
+ "version": "1.9.60",
4
4
  "type": "module",
5
5
  "description": "Stable terminal UI for Codex and Claude Agent SDK",
6
6
  "keywords": [