makdoong2-team 3.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.d.ts CHANGED
@@ -92,7 +92,7 @@ export declare function parseJsoncLoose(text: string): unknown;
92
92
  * 서브세션의 permission 요청이 auto-reject 됐다.
93
93
  * (install-lib 의 computeExternalDirPaths 가 그 값을 쓰는 쪽이다.)
94
94
  */
95
- export declare function loadOpencodeExternalDirAllows(): string[];
95
+ export declare function loadOpencodeExternalDirAllows(diag?: (reason: string) => void): string[];
96
96
  /** Resolve the five runtime path roots (JSON paths.* override → default).
97
97
  *
98
98
  * `skills` default is the opencode config dir (`${XDG_CONFIG_HOME:-$HOME/.config}/opencode/skills`)
package/dist/config.js CHANGED
@@ -175,20 +175,36 @@ export function parseJsoncLoose(text) {
175
175
  * 서브세션의 permission 요청이 auto-reject 됐다.
176
176
  * (install-lib 의 computeExternalDirPaths 가 그 값을 쓰는 쪽이다.)
177
177
  */
178
- export function loadOpencodeExternalDirAllows() {
178
+ export function loadOpencodeExternalDirAllows(diag) {
179
+ // 빈 배열로 끝나는 이유를 호출자가 알 수 있게 한다. 이 모듈은 logger 를 import
180
+ // 하지 못하므로(logger→config) 콜백으로 돌려준다. 같은 pid 의 두 플러그인 사본이
181
+ // 서로 다른 개수를 읽은 사례(issue #10: 7개→5개)에서 파일 부재였는지 파싱
182
+ // 실패였는지 키 부재였는지가 로그에 남지 않았다.
183
+ const file = join(configDir(), "opencode.json");
184
+ let raw;
179
185
  try {
180
- const raw = readFileSync(join(configDir(), "opencode.json"), "utf8");
181
- const oc = parseJsoncLoose(raw);
182
- const ext = oc?.permission?.external_directory;
183
- if (!ext || typeof ext !== "object")
184
- return [];
185
- return Object.entries(ext)
186
- .filter(([, action]) => action === "allow")
187
- .map(([pattern]) => pattern);
186
+ raw = readFileSync(file, "utf8");
188
187
  }
189
- catch {
190
- return []; // 부재 / 파싱 실패 → 설정된 allow 없음
188
+ catch (err) {
189
+ diag?.(`read failed: ${file}: ${err.message}`);
190
+ return []; // 부재 → 설정된 allow 없음
191
+ }
192
+ let oc;
193
+ try {
194
+ oc = parseJsoncLoose(raw);
195
+ }
196
+ catch (err) {
197
+ diag?.(`parse failed: ${file} (${raw.length} bytes): ${err.message}`);
198
+ return []; // 파싱 실패 → 설정된 allow 없음
199
+ }
200
+ const ext = oc?.permission?.external_directory;
201
+ if (!ext || typeof ext !== "object") {
202
+ diag?.(`permission.external_directory absent: ${file}`);
203
+ return [];
191
204
  }
205
+ return Object.entries(ext)
206
+ .filter(([, action]) => action === "allow")
207
+ .map(([pattern]) => pattern);
192
208
  }
193
209
  /** Resolve the five runtime path roots (JSON paths.* override → default).
194
210
  *
@@ -21,6 +21,7 @@ import { homedir } from "node:os";
21
21
  import { basename as pathBasename, dirname as pathDirname, join, resolve as pathResolve, sep as pathSep } from "node:path";
22
22
  import { tool } from "@opencode-ai/plugin";
23
23
  import { appendSessionIndex, findWorktreeRoot, lookupSessionFromIndex } from "./session-index.js";
24
+ import { activeToolCount, clearToolCalls, forgetSession, isToolExecuting as registryIsToolExecuting, normalizePermissionEvent, notifySessionDeleted, pendingPermissionsFor, permissionAsked, permissionReplied, settleToolCalls, sharedSubSessionRegistry, toolFinished, toolStarted, } from "./sub-session-registry.js";
24
25
  import { computeVerdictHash } from "./verdict-hash.js";
25
26
  import { classifyVerifierOutcome, nextVerifierErrorStreak, verifierErrorStreakExceeded, VERIFIER_ERROR_STREAK_LIMIT, } from "./verifier-verdict.js";
26
27
  import { nextModel, applyConfigOverrides, POLICIES } from "./model-fallback-policy.js";
@@ -327,6 +328,15 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
327
328
  const cwd = worktree || directory || ".";
328
329
  const config = loadConfig();
329
330
  applyConfigOverrides(config.agents, config.model_policy);
331
+ // opencode 는 디렉토리(Instance)마다 이 factory 를 따로 부른다 — 같은 pid 안에
332
+ // 사본이 여럿이다. 어느 사본이 어떤 로그를 찍는지 구분하려고 진입 시점에 남긴다.
333
+ // 로그에 worktree 경로의 `[init]` 이 없으면 서브세션 훅이 어디서도 발화하지
334
+ // 않는 것이고, 그것이 issue #10 의 `tool_call_stall` 처방 1번이다.
335
+ logger.debug(`[init] plugin instance directory=${directory} worktree=${worktree} pid=${process.pid}`);
336
+ // 사본을 가로지르는 세션 신호(툴 실행 · 권한 요청 · 이슈키 · worktree · deleted
337
+ // 대기자)는 프로세스 전역 레지스트리에 둔다. 사본마다 Map 을 들면 worktree 사본의
338
+ // 훅이 올린 신호를 main 사본의 폴러가 영영 보지 못한다 (issue #10).
339
+ const registry = sharedSubSessionRegistry();
330
340
  // Sub-session monitor — splits a tmux pane next to 부장님 for each spawned
331
341
  // 막둥이 when invoked inside tmux with tmux.enabled=true in makdoong2-team.json.
332
342
  // No-op otherwise, so non-tmux runs are unaffected.
@@ -422,8 +432,8 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
422
432
  const guard = orphanCleanupGuard(p, {
423
433
  nowMs,
424
434
  ownerPid: tmuxMonitor.ownerProcessId,
425
- lastToolExecuteAtMs: sessionLastToolExecuteAt.get(sid),
426
- activeToolCount: sessionActiveToolCount.get(sid),
435
+ lastToolExecuteAtMs: registry.lastToolExecuteAt.get(sid),
436
+ activeToolCount: activeToolCount(registry, sid),
427
437
  toolAliveWindowMs: TOOL_EXECUTE_ALIVE_WINDOW_MS,
428
438
  });
429
439
  if (guard) {
@@ -448,11 +458,9 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
448
458
  if (sessionGone) {
449
459
  logger.debug(`[orphan-scan] pane-kill-only (session gone) sid=${sid} reason=${reason}`);
450
460
  await tmuxMonitor.forceKillBySessionId(sid).catch(() => undefined);
451
- sessionIssue.delete(sid);
461
+ forgetSession(registry, sid);
452
462
  pendingDispatch.delete(sid);
453
463
  subSessionIds.delete(sid);
454
- sessionLastToolExecuteAt.delete(sid);
455
- sessionActiveToolCount.delete(sid);
456
464
  }
457
465
  else {
458
466
  logger.debug(`[orphan-scan] cleaning sid=${sid} reason=${reason}`);
@@ -607,29 +615,29 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
607
615
  const pluginOwnPatterns = pluginOwnAllowPatterns();
608
616
  const configuredAllowPatterns = [
609
617
  ...pluginOwnPatterns,
610
- ...loadOpencodeExternalDirAllows(),
618
+ ...loadOpencodeExternalDirAllows((reason) => logger.warn(`[permission] opencode.json external_directory allows unavailable — ${reason} directory=${directory}`)),
611
619
  ];
620
+ // 사본마다 한 번씩 찍힌다 — 같은 pid 에 개수가 다른 두 줄이 있으면 사본이
621
+ // 둘이라는 뜻이지 설정이 바뀐 것이 아니다 (issue #10 의 7개→5개). directory 를
622
+ // 같이 남겨 어느 사본이 몇 개를 읽었는지 바로 대응시킨다.
612
623
  logger.debug(`[permission] plugin-own allows: ${pluginOwnPatterns.length}개 ` +
613
- `(${pluginOwnPatterns.join(", ")})`);
624
+ `(${pluginOwnPatterns.join(", ")}) directory=${directory}`);
614
625
  logger.debug(`[permission] configured external_directory allows: ${configuredAllowPatterns.length}개` +
615
- (configuredAllowPatterns.length ? ` (${configuredAllowPatterns.slice(0, 3).join(", ")}…)` : ""));
616
- const sessionLastToolExecuteAt = new Map();
617
- const sessionActiveToolCount = new Map();
618
- /** 활성 카운터를 1 감소시킨다 (0 이면 항목 삭제). */
619
- function releaseActiveTool(sid) {
620
- const cur = sessionActiveToolCount.get(sid) ?? 0;
621
- if (cur <= 1)
622
- sessionActiveToolCount.delete(sid);
623
- else
624
- sessionActiveToolCount.set(sid, cur - 1);
625
- }
626
+ (configuredAllowPatterns.length ? ` (${configuredAllowPatterns.slice(0, 3).join(", ")}…)` : "") +
627
+ ` directory=${directory}`);
628
+ // 실행 신호는 레지스트리(`registry.activeToolCalls` / `lastToolExecuteAt`)
629
+ // 있다 before 훅에서 toolStarted, after 훅·툴 part 종결·idle 에서 toolFinished /
630
+ // clearToolCalls. 종전의 사본-로컬 카운터는 issue #10 으로 제거됐다.
626
631
  const TOOL_EXECUTE_ALIVE_WINDOW_MS = 300_000;
627
632
  // MESSAGE_STALL 후 client.session.abort() 는 즉시 반환하지만 opencode 서버는 잠시 후
628
633
  // session.deleted 이벤트를 fire 한다. 그 사이(관측 최대 112s) sub-agent 가 tool call 을
629
634
  // 계속 발사할 수 있어 좀비 실행이 발생한다. abort 직후 session.deleted 를 대기하는
630
635
  // 헬퍼로 이 race window 를 닫는다. event 핸들러가 session.deleted 를 수신하면 이 map 의
631
636
  // pending waiter 를 resolve 한다.
632
- const sessionDeletedWaiters = new Map();
637
+ // 대기자 목록은 레지스트리에 있다 — session.deleted 이벤트는 세션의 디렉토리
638
+ // 사본에만 도착하므로(worktree 서브세션이면 worktree 사본), 여기서 기다리는
639
+ // main 사본과 이벤트를 받는 사본이 같은 목록을 봐야 한다.
640
+ const sessionDeletedWaiters = registry.sessionDeletedWaiters;
633
641
  const waitForSessionDeleted = (sessionId, maxWaitMs) => {
634
642
  return new Promise((resolve) => {
635
643
  let done = false;
@@ -659,7 +667,9 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
659
667
  const SESSION_DELETED_WAIT_MS = 30_000;
660
668
  const eventMaxChars = readLoggingConfig(config.logging).eventMaxChars;
661
669
  const WRITE_TOOLS = new Set(["write", "edit", "apply_patch"]);
662
- const sessionWorktree = new Map();
670
+ // dispatch 사본이 채우고 worktree 사본의 tool.execute.after 가 읽는다 — 레지스트리.
671
+ // 디스크의 session-index.ndjson 은 사본이 다른 프로세스에 있을 때의 2차 경로로 남는다.
672
+ const sessionWorktree = registry.sessionWorktree;
663
673
  const extractFilePathFromToolArgs = (args) => {
664
674
  if (!args || typeof args !== "object")
665
675
  return undefined;
@@ -749,7 +759,41 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
749
759
  }
750
760
  return { ok: true, relPath };
751
761
  };
752
- const pollSubSession = (sessionId, timeoutMs = substageTimeoutMs, allowedWorktree, onNudge, messageStallThresholdMs) => pollSubSessionCore(client, sessionId, {
762
+ // 폴러에 건네는 권한 요청 소스. v1 SDK 클라이언트에는 `permission` 네임스페이스가
763
+ // 없고, `GET /permission` 은 클라이언트가 묶인 디렉토리 스코프라 worktree
764
+ // 서브세션의 요청이 보이지 않는다 — 종전에는 이 소스가 항상 undefined 라
765
+ // 자동 승인·거부 루프가 한 번도 돌지 않았고 stall 메시지는 언제나 "unknown" 이었다
766
+ // (issue #10). 대신 그 세션의 사본이 `permission.asked` 이벤트로 레지스트리에
767
+ // 넣은 것을 읽고, 응답은 세션-라우팅 엔드포인트
768
+ // (`POST /session/{id}/permissions/{permissionID}`) 로 보낸다 — 세션 ID 로
769
+ // 라우팅되므로 어느 사본에서 불러도 맞는 Instance 에 닿는다.
770
+ const permissionSourceFor = (sessionId) => ({
771
+ list: async () => ({
772
+ data: pendingPermissionsFor(registry, sessionId).map((p) => ({
773
+ id: p.id,
774
+ sessionID: p.sessionID,
775
+ permission: p.permission,
776
+ patterns: p.patterns,
777
+ })),
778
+ }),
779
+ reply: async (req) => {
780
+ const c = client;
781
+ if (typeof c.postSessionIdPermissionsPermissionId !== "function") {
782
+ throw new Error("permission reply endpoint unavailable on this client");
783
+ }
784
+ const res = await c.postSessionIdPermissionsPermissionId({
785
+ path: { id: sessionId, permissionID: req.path.requestID },
786
+ body: { response: req.body.reply },
787
+ });
788
+ // permission.replied 이벤트는 세션의 사본에 도착하지만, 그 사본이 이벤트를
789
+ // 놓치더라도 다음 폴이 같은 요청을 다시 처리하지 않도록 즉시 뺀다.
790
+ permissionReplied(registry, sessionId, req.path.requestID);
791
+ return res;
792
+ },
793
+ });
794
+ const pollSubSession = (sessionId, timeoutMs = substageTimeoutMs, allowedWorktree, onNudge, messageStallThresholdMs) =>
795
+ // 클래스 인스턴스를 spread 하면 프로토타입 메서드가 빠지므로 필요한 것만 집는다.
796
+ pollSubSessionCore({ session: client.session, permission: permissionSourceFor(sessionId) }, sessionId, {
753
797
  timeoutMs,
754
798
  allowedWorktree,
755
799
  configuredAllowPatterns,
@@ -764,16 +808,26 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
764
808
  contentStableCompletionMs: 300_000,
765
809
  preambleOnlyTextThreshold: 200,
766
810
  isRecentlyActive: () => {
767
- if ((sessionActiveToolCount.get(sessionId) ?? 0) > 0)
811
+ if (registryIsToolExecuting(registry, sessionId))
768
812
  return true;
769
- const last = sessionLastToolExecuteAt.get(sessionId);
813
+ const last = registry.lastToolExecuteAt.get(sessionId);
770
814
  return typeof last === "number" && Date.now() - last < TOOL_EXECUTE_ALIVE_WINDOW_MS;
771
815
  },
772
- // "지금 이 순간 툴이 실행 중인가" — before 훅에서 올리고 after 훅에서 내리는
773
- // 활성 카운터 그대로다. isRecentlyActive 의 5분 창과 달리 순간값이라 완료
816
+ // "지금 이 순간 툴이 실행 중인가" — before 훅에서 넣고 after 훅에서 빼는
817
+ // callID 집합 그대로다. isRecentlyActive 의 5분 창과 달리 순간값이라 완료
774
818
  // 판정에 쓸 수 있다. 폴러가 읽는 메시지 스냅샷보다 항상 앞선다 (issue #7:
775
819
  // tool.execute.before 발화 110ms 뒤의 폴이 tool part 를 아직 못 봤다).
776
- isToolExecuting: () => (sessionActiveToolCount.get(sessionId) ?? 0) > 0,
820
+ //
821
+ // 읽기 전에 스냅샷과 대조한다: 툴이 throw 하면 after 훅이 오지 않아 항목이
822
+ // 남는데, 스냅샷에서 이미 completed/error 인 callID 는 확실히 끝난 것이다.
823
+ // 스냅샷에 아직 없는 callID 는 그대로 둔다 (110ms 창).
824
+ isToolExecuting: (snapshot) => {
825
+ const settled = settleToolCalls(registry, sessionId, snapshot.settledCallIDs);
826
+ if (settled > 0) {
827
+ logger.debug(`[pollSubSession] settled ${settled} stale tool call(s) from snapshot sid=${sessionId}`);
828
+ }
829
+ return registryIsToolExecuting(registry, sessionId);
830
+ },
777
831
  });
778
832
  // ── sessionID → agent 매핑. chat.params hook에서 채우고, tool.execute.before
779
833
  // hook에서 조회한다. 이 매핑을 통해 hook input에 없는 agent 식별을 우회한다.
@@ -796,7 +850,9 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
796
850
  // git branch 이름이나 worktree 경로를 추론하지 않고 신뢰할 수 있는 ISSUE를
797
851
  // 직접 전달받을 수 있다. 이 설계로 multi-worktree / 비-makdoong2-team 워크트리
798
852
  // 오탐 문제가 근본적으로 해결된다.
799
- const sessionIssue = new Map();
853
+ // 레지스트리에 둔다 dispatch 사본이 넣고, 서브세션의 훅 사본(guard-bash.sh /
854
+ // sync-state.sh 인자)이 읽는다.
855
+ const sessionIssue = registry.sessionIssue;
800
856
  // ── skill_mcp lazy-load 방어 (mcp_name → skill_name 매핑).
801
857
  // SKILL.md frontmatter 를 스캔해 각 MCP 서버가 어떤 skill 에 embedded 인지
802
858
  // 캐시한다. `skill(name="...")` 로 skill 이 로드되기 전에는 opencode 가
@@ -873,11 +929,9 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
873
929
  }
874
930
  }
875
931
  finally {
876
- sessionIssue.delete(sid);
932
+ forgetSession(registry, sid);
877
933
  pendingDispatch.delete(sid);
878
934
  subSessionIds.delete(sid);
879
- sessionLastToolExecuteAt.delete(sid);
880
- sessionActiveToolCount.delete(sid);
881
935
  }
882
936
  };
883
937
  // team-leader가 직접 실행 시 물리적으로 차단할 툴 목록.
@@ -992,26 +1046,72 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
992
1046
  }
993
1047
  })();
994
1048
  return {
1049
+ // 이벤트는 **세션의 디렉토리 사본**에만 도착한다 (opencode 의 이벤트 브리지가
1050
+ // `location.directory` 로 필터링한다). worktree 서브세션의 permission.asked ·
1051
+ // message.part.updated · session.deleted 는 worktree 사본이 받고, 그 세션을
1052
+ // 폴링하는 것은 main 사본이다. 그래서 여기서 관측한 것은 전부 레지스트리에
1053
+ // 쓴다 — 사본-로컬 변수에 쓰면 폴러는 영영 보지 못한다 (issue #10).
995
1054
  event: async ({ event }) => {
996
- if (logger.isDebug() && event?.type?.startsWith("session.")) {
1055
+ if (logger.isDebug() && (event?.type?.startsWith("session.") || event?.type?.startsWith("permission."))) {
997
1056
  try {
998
- logger.debug(`[event] type=${event.type} properties=${JSON.stringify(event.properties).slice(0, eventMaxChars)}`);
1057
+ logger.debug(`[event] type=${event.type} directory=${directory} properties=${JSON.stringify(event.properties).slice(0, eventMaxChars)}`);
999
1058
  }
1000
1059
  catch { /* ignore */ }
1001
1060
  }
1061
+ if (event?.type === "permission.asked" || event?.type === "permission.updated") {
1062
+ const req = normalizePermissionEvent(event.type, event.properties);
1063
+ if (req) {
1064
+ permissionAsked(registry, req);
1065
+ logger.debug(`[event] permission pending sid=${req.sessionID} requestID=${req.id} ` +
1066
+ `permission=${req.permission} patterns=${JSON.stringify(req.patterns)}`);
1067
+ }
1068
+ return;
1069
+ }
1070
+ if (event?.type === "permission.replied") {
1071
+ const props = event.properties;
1072
+ const sid = typeof props?.sessionID === "string" ? props.sessionID : undefined;
1073
+ const requestID = typeof props?.requestID === "string" ? props.requestID
1074
+ : typeof props?.permissionID === "string" ? props.permissionID
1075
+ : undefined;
1076
+ if (sid && requestID)
1077
+ permissionReplied(registry, sid, requestID);
1078
+ return;
1079
+ }
1080
+ if (event?.type === "message.part.updated") {
1081
+ // 툴 part 가 completed/error 로 바뀌면 그 호출은 끝났다. 툴이 throw 한
1082
+ // 경우 tool.execute.after 가 오지 않으므로 이것이 유일한 종결 신호다.
1083
+ const props = event.properties;
1084
+ const part = props?.part;
1085
+ if (part && part.type === "tool") {
1086
+ const state = part.state;
1087
+ const status = typeof state?.status === "string" ? state.status : undefined;
1088
+ if (status === "completed" || status === "error") {
1089
+ const sid = typeof part.sessionID === "string" ? part.sessionID
1090
+ : typeof props?.sessionID === "string" ? props.sessionID
1091
+ : undefined;
1092
+ const callID = typeof part.callID === "string" ? part.callID : undefined;
1093
+ if (sid)
1094
+ toolFinished(registry, sid, callID);
1095
+ }
1096
+ }
1097
+ return;
1098
+ }
1099
+ if (event?.type === "session.idle") {
1100
+ // idle 로 전이한 세션에 실행 중인 툴은 정의상 없다 — 남은 항목은 전부 누수다.
1101
+ const props = event.properties;
1102
+ if (typeof props?.sessionID === "string")
1103
+ clearToolCalls(registry, props.sessionID);
1104
+ return;
1105
+ }
1002
1106
  if (event?.type === "session.deleted") {
1003
1107
  const props = event.properties;
1004
1108
  const sid = props?.info?.id ?? props?.sessionID;
1005
1109
  if (sid) {
1006
- const waiters = sessionDeletedWaiters.get(sid);
1007
- if (waiters && waiters.length > 0) {
1008
- for (const w of [...waiters]) {
1009
- try {
1010
- w();
1011
- }
1012
- catch { /* ignore */ }
1013
- }
1014
- }
1110
+ notifySessionDeleted(registry, sid);
1111
+ // 세션이 사라졌으니 실행 신호·권한 요청도 의미가 없다. sessionIssue /
1112
+ // sessionWorktree dispatch 사본의 cleanupSubSession 이 지운다.
1113
+ clearToolCalls(registry, sid);
1114
+ registry.pendingPermissions.delete(sid);
1015
1115
  }
1016
1116
  return;
1017
1117
  }
@@ -1066,16 +1166,17 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
1066
1166
  : undefined;
1067
1167
  const toolLower = (input.tool || "").toLowerCase();
1068
1168
  logger.debug(`[makdoong2-team hook] tool.execute.before fired: tool="${input.tool}" sessionID="${sessionID}" agent="${agent ?? "unknown"}" callID="${input.callID}"`);
1069
- // 활성 툴 카운터는 **증분과 감소가 반드시 짝을 이뤄야 한다**.
1169
+ // 활성 툴 집합은 **넣기와 빼기가 반드시 짝을 이뤄야 한다**.
1070
1170
  // tool.execute.after 는 툴이 실제로 실행된 뒤에만 돈다. 아래 가드들이 throw 하면
1071
1171
  // (차단된 bash · sealed 서브에이전트의 outer-world 툴 · leader 의 파일 쓰기 등)
1072
- // after 훅이 돌지 않아 카운터가 영구히 ≥1 로 남고, 그러면 isRecentlyActive() 가
1172
+ // after 훅이 돌지 않아 항목이 영구히 남고, 그러면 isRecentlyActive() 가
1073
1173
  // 항상 true 가 되어 그 세션의 gone 감지와 orphan 회수가 **영구 비활성**된다.
1074
1174
  // 차단은 정상 동작이라 반드시 일어나므로 확정적으로 누수됐다.
1075
- if (sessionID) {
1076
- sessionLastToolExecuteAt.set(sessionID, Date.now());
1077
- sessionActiveToolCount.set(sessionID, (sessionActiveToolCount.get(sessionID) ?? 0) + 1);
1078
- }
1175
+ // ( 자체가 throw 하는 경우 — 권한 거부 · 파일 없음 — 는 여기서 잡을 수 없다.
1176
+ // 그쪽은 폴러가 스냅샷의 completed/error part 로 정리한다: settleToolCalls.)
1177
+ const activeToolKey = sessionID
1178
+ ? toolStarted(registry, sessionID, input.callID)
1179
+ : undefined;
1079
1180
  try {
1080
1181
  if (sessionID && (toolLower === "dispatch_stage" || toolLower === "dispatch_verifier")) {
1081
1182
  const callID = input.callID;
@@ -1322,9 +1423,9 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
1322
1423
  }
1323
1424
  }
1324
1425
  catch (err) {
1325
- // 차단으로 끝난 호출은 실행되지 않았다 → 증분을 되돌린다.
1426
+ // 차단으로 끝난 호출은 실행되지 않았다 → 넣은 항목을 되돌린다.
1326
1427
  if (sessionID)
1327
- releaseActiveTool(sessionID);
1428
+ toolFinished(registry, sessionID, activeToolKey);
1328
1429
  throw err;
1329
1430
  }
1330
1431
  },
@@ -1337,8 +1438,7 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
1337
1438
  const toolLowerAfter = (input.tool || "").toLowerCase();
1338
1439
  const afterSessionID = input.sessionID;
1339
1440
  if (afterSessionID) {
1340
- sessionLastToolExecuteAt.set(afterSessionID, Date.now());
1341
- releaseActiveTool(afterSessionID);
1441
+ toolFinished(registry, afterSessionID, input.callID);
1342
1442
  }
1343
1443
  // ── Issue-reporter 표시 증명 기록·소멸 ──
1344
1444
  // 기록: 단독 `cat <payload>` 가 실행되면 그 시점의 파일 해시를 남긴다. 이
@@ -5,6 +5,19 @@ export type MessagePartLike = {
5
5
  state?: {
6
6
  status?: string;
7
7
  };
8
+ /** ToolPart 의 호출 ID — `tool.execute.before/after` 훅의 callID 와 같은 값. */
9
+ callID?: string;
10
+ };
11
+ /**
12
+ * 폴 시점 스냅샷에서 읽은 툴 호출 상태. `isToolExecuting` 에 넘겨 훅 레지스트리와
13
+ * 대조하게 한다 — throw 로 끝난 툴은 after 훅이 돌지 않아 레지스트리에 남는데,
14
+ * 스냅샷의 completed/error part 가 그 호출이 끝났음을 확정해 준다.
15
+ */
16
+ export type ToolSnapshot = {
17
+ /** completed / error 로 끝난 호출 ID (스냅샷 전체 메시지 기준). */
18
+ settledCallIDs: ReadonlySet<string>;
19
+ /** pending / running 인 호출 ID (마지막 assistant 메시지 기준). */
20
+ inFlightCallIDs: ReadonlySet<string>;
8
21
  };
9
22
  export type MessageInfoLike = {
10
23
  id?: string;
@@ -109,7 +122,7 @@ export interface PollOptions {
109
122
  messageStallThresholdMs?: number;
110
123
  statusAbsentGraceMs?: number;
111
124
  isRecentlyActive?: () => boolean;
112
- isToolExecuting?: () => boolean;
125
+ isToolExecuting?: (snapshot: ToolSnapshot) => boolean;
113
126
  contentStableCompletionMs?: number;
114
127
  preambleOnlyTextThreshold?: number;
115
128
  nudgeAtFraction?: number;
@@ -29,6 +29,37 @@
29
29
  const TOOL_CALL_PART_TYPES = new Set(["tool", "tool_call", "tool-call", "tool_use"]);
30
30
  /** 아직 끝나지 않은 툴 상태 (ToolStatePending / ToolStateRunning). */
31
31
  const IN_FLIGHT_TOOL_STATUSES = new Set(["pending", "running"]);
32
+ /** 확실히 끝난 툴 상태 (ToolStateCompleted / ToolStateError). */
33
+ const SETTLED_TOOL_STATUSES = new Set(["completed", "error"]);
34
+ /**
35
+ * 스냅샷에서 툴 호출 ID 를 상태별로 모은다. settled 는 전체 메시지를 훑는다 —
36
+ * 레지스트리에 남은 항목은 이전 turn 의 호출일 수도 있다. inFlight 는 마지막
37
+ * assistant 메시지만 본다 (`hasPendingToolCall` 과 같은 기준).
38
+ */
39
+ function collectToolSnapshot(messages, lastAssistant) {
40
+ const settledCallIDs = new Set();
41
+ const inFlightCallIDs = new Set();
42
+ for (const m of messages) {
43
+ if (m.info?.role !== "assistant")
44
+ continue;
45
+ for (const part of m.parts ?? []) {
46
+ if (!TOOL_CALL_PART_TYPES.has(part.type))
47
+ continue;
48
+ if (typeof part.callID !== "string" || part.callID.length === 0)
49
+ continue;
50
+ const status = part.state?.status;
51
+ if (typeof status === "string" && SETTLED_TOOL_STATUSES.has(status))
52
+ settledCallIDs.add(part.callID);
53
+ }
54
+ }
55
+ for (const part of lastAssistant?.parts ?? []) {
56
+ if (!isInFlightToolPart(part))
57
+ continue;
58
+ if (typeof part.callID === "string" && part.callID.length > 0)
59
+ inFlightCallIDs.add(part.callID);
60
+ }
61
+ return { settledCallIDs, inFlightCallIDs };
62
+ }
32
63
  /**
33
64
  * "지금 실행 중인 툴 호출이 있는가".
34
65
  *
@@ -236,7 +267,9 @@ export async function pollSubSession(client, sessionId, options = {}) {
236
267
  // 둘 중 하나라도 "툴이 떠 있다" 고 하면 완료로 판정하지 않는다 — 스냅샷은
237
268
  // 서버 반영이 늦고(issue #7), 훅 신호는 훅이 안 붙은 환경에서 아예 없다.
238
269
  // OR 로 묶어야 각자의 사각지대를 서로 덮는다.
239
- const toolExecuting = isToolExecuting?.() === true;
270
+ const toolExecuting = isToolExecuting
271
+ ? isToolExecuting(collectToolSnapshot(messages, lastAssistant)) === true
272
+ : false;
240
273
  const toolInFlight = hasPendingToolCall || toolExecuting;
241
274
  const stalledMs = now() - lastProgressAt;
242
275
  // session_gone (status_absent) has two admission paths with different
@@ -376,7 +409,9 @@ export async function pollSubSession(client, sessionId, options = {}) {
376
409
  err(`[pollSubSession] PERMISSION_STALL session=${sessionId} polls=${pollCount} stalledMs=${stalledMs}` +
377
410
  (stalledPerm
378
411
  ? ` pending permissionID=${stalledPerm.id} type=${stalledPerm.permission} patterns=${JSON.stringify(stalledPerm.patterns)}`
379
- : ` pending permission unknown (permission.list unavailable or empty)`));
412
+ : client.permission
413
+ ? ` no pending permission observed for this session (tool part in flight, no tool.execute signal)`
414
+ : ` pending permission unknown (permission source unavailable)`));
380
415
  await client.session.abort({ path: { id: sessionId } }).catch(() => undefined);
381
416
  return {
382
417
  kind: "permission_stall",
@@ -629,8 +664,10 @@ export function pollOutcomeToLegacy(outcome) {
629
664
  "에이전트 permission 설정 문제다. 해당 에이전트의 frontmatter `permission:` 블록을 점검하라 " +
630
665
  "(정식 키는 `edit`; `write` 키는 존재하지 않아 조용히 무시된다).";
631
666
  default:
632
- return "대기 대상을 특정하지 못했다. 점검: opencode.json permission.external_directory 시드 존재 여부 " +
633
- "(`npx makdoong2-team doctor`).";
667
+ return " 호출 part 있는데 실행 신호(tool.execute.before)도 권한 요청도 관측되지 않았다. 점검: " +
668
+ "(1) 세션의 디렉토리에서도 플러그인이 로드되는가 — 로그의 `[init] plugin instance directory=…` 가 " +
669
+ "worktree 경로로도 찍혀야 한다 (opencode 는 디렉토리마다 플러그인을 따로 초기화한다) " +
670
+ "(2) opencode.json 의 permission.external_directory 시드 (`npx makdoong2-team doctor`).";
634
671
  }
635
672
  })();
636
673
  return {
@@ -0,0 +1,74 @@
1
+ export type PendingPermission = {
2
+ id: string;
3
+ sessionID: string;
4
+ permission: string;
5
+ patterns: string[];
6
+ askedAt: number;
7
+ };
8
+ export interface SubSessionRegistry {
9
+ readonly version: 1;
10
+ /** sessionID → (callID → startedAt). before 에서 넣고 after / 툴 part 종결에서 뺀다. */
11
+ readonly activeToolCalls: Map<string, Map<string, number>>;
12
+ /** sessionID → 마지막 tool.execute.* 발화 시각. */
13
+ readonly lastToolExecuteAt: Map<string, number>;
14
+ /** sessionID → (requestID → 요청). permission.asked 로 넣고 replied 로 뺀다. */
15
+ readonly pendingPermissions: Map<string, Map<string, PendingPermission>>;
16
+ /** sessionID → 이슈키. dispatch 사본이 쓰고 훅 사본(guard-bash.sh 인자)이 읽는다. */
17
+ readonly sessionIssue: Map<string, string>;
18
+ /** sessionID → worktree 절대경로. auto-git-add 가 읽는다. */
19
+ readonly sessionWorktree: Map<string, string>;
20
+ /** session.deleted 대기자. abort 후 재디스패치 전 race window 를 닫는다. */
21
+ readonly sessionDeletedWaiters: Map<string, Array<() => void>>;
22
+ /** callID 가 없는 before 훅에 붙일 대체 키 일련번호. */
23
+ anonSeq: number;
24
+ }
25
+ export declare function createSubSessionRegistry(): SubSessionRegistry;
26
+ /**
27
+ * 프로세스 전역 레지스트리. 첫 호출이 만들고 이후 호출은 같은 객체를 돌려준다.
28
+ * 키에 버전을 박아 두어, 형태가 다른 미래 버전이 같은 프로세스에 섞여도 서로의
29
+ * 객체를 깨뜨리지 않는다.
30
+ */
31
+ export declare function sharedSubSessionRegistry(): SubSessionRegistry;
32
+ /** 테스트 전용 — 전역 객체를 비운다. */
33
+ export declare function resetSharedSubSessionRegistryForTests(): void;
34
+ /** `tool.execute.before` — 이 세션에서 callID 툴이 실행을 시작했다. 사용한 키를 돌려준다. */
35
+ export declare function toolStarted(reg: SubSessionRegistry, sessionID: string, callID: string | undefined, now?: number): string;
36
+ /**
37
+ * `tool.execute.after` 또는 툴 part 의 completed/error 관측 — 실행이 끝났다.
38
+ * 멱등이다: 같은 callID 를 두 번 끝내도, 모르는 callID 를 끝내도 아무 일도 없다.
39
+ * callID 가 없으면 가장 최근에 시작한 항목을 뺀다 (종전 카운터 의미 유지).
40
+ */
41
+ export declare function toolFinished(reg: SubSessionRegistry, sessionID: string, callID: string | undefined, now?: number): boolean;
42
+ /**
43
+ * 메시지 스냅샷과 대조해 이미 끝난 호출을 정리한다.
44
+ *
45
+ * 툴이 throw 하면(권한 거부 · 파일 없음 · MCP 오류) opencode 는 `tool.execute.after`
46
+ * 를 부르지 않는다 — 항목이 영영 남아 `isToolExecuting()` 이 참으로 굳고, 그러면
47
+ * 완료 판정이 유보돼 substage 가 절대 타임아웃까지 기다린다. 스냅샷의 tool part
48
+ * 가 completed/error 면 그 호출은 확실히 끝난 것이므로 여기서 뺀다. 스냅샷에
49
+ * **아직 없는** 호출은 건드리지 않는다 (issue #7 의 110ms 창 — 훅은 발화했는데
50
+ * 서버가 part 를 아직 반영하지 않은 상태). 정리한 개수를 돌려준다.
51
+ */
52
+ export declare function settleToolCalls(reg: SubSessionRegistry, sessionID: string, settledCallIDs: Iterable<string>): number;
53
+ /** 지금 이 순간 이 세션에서 실행 중인 툴이 있는가 (순간값). */
54
+ export declare function isToolExecuting(reg: SubSessionRegistry, sessionID: string): boolean;
55
+ export declare function activeToolCount(reg: SubSessionRegistry, sessionID: string): number;
56
+ /** 세션이 idle 로 전이했다 — 실행 중인 툴은 정의상 없다. */
57
+ export declare function clearToolCalls(reg: SubSessionRegistry, sessionID: string): void;
58
+ /**
59
+ * 플러그인 `event` 훅이 받는 권한 요청 이벤트를 공통 형태로 바꾼다.
60
+ *
61
+ * 1.18 v1 브리지는 `permission.asked` 를 `PermissionRequest` 그대로 싣는다
62
+ * (`id / sessionID / permission / patterns`). 더 오래된 SDK 형태 `permission.updated`
63
+ * 는 `type` · `pattern`(문자열 또는 배열) 을 쓴다. 둘 다 받아 둔다 — 어느 쪽이 오든
64
+ * 폴러가 보는 것은 같은 shape 이어야 한다. 요청으로 볼 수 없는 페이로드는 null.
65
+ */
66
+ export declare function normalizePermissionEvent(type: string, properties: unknown, now?: number): PendingPermission | null;
67
+ export declare function permissionAsked(reg: SubSessionRegistry, req: PendingPermission): void;
68
+ export declare function permissionReplied(reg: SubSessionRegistry, sessionID: string, requestID: string): void;
69
+ /** 이 세션에 답을 기다리는 권한 요청 — 오래된 순. */
70
+ export declare function pendingPermissionsFor(reg: SubSessionRegistry, sessionID: string): PendingPermission[];
71
+ /** 세션 하나의 흔적을 전부 지운다 (cleanupSubSession · session.deleted). */
72
+ export declare function forgetSession(reg: SubSessionRegistry, sessionID: string): void;
73
+ /** session.deleted — 대기자를 전부 깨운다. 깨운 수를 돌려준다. */
74
+ export declare function notifySessionDeleted(reg: SubSessionRegistry, sessionID: string): number;
@@ -0,0 +1,204 @@
1
+ // 프로세스 전역 서브세션 레지스트리 — 플러그인 사본 사이의 공유 신호.
2
+ //
3
+ // opencode 1.18 은 **디렉토리(Instance)마다 플러그인을 따로 초기화한다**.
4
+ // `dispatch_stage` 는 서브세션을 `directory=<worktree>` 로 만들므로 그 세션의
5
+ // `tool.execute.before/after` · `event` 훅은 worktree Instance 의 플러그인 사본에서
6
+ // 발화하고, 그 세션을 감시하는 `pollSubSession` 은 main Instance 의 사본에서 돈다.
7
+ // 사본마다 자기 Map 을 들고 있으면 폴러는 언제나 빈 Map 을 본다 —
8
+ // `isToolExecuting()` 이 영영 false 라 60초를 넘는 모든 툴(sbt test 등)이
9
+ // `permission_stall`(reason=tool_call_stall) 로 abort 됐다 (GitHub issue #10).
10
+ //
11
+ // 그래서 사본을 가로지르는 신호는 전부 여기, `globalThis` 에 둔다. 같은 프로세스
12
+ // 안의 모든 사본이 — 모듈 경로가 달라 module cache 가 갈리더라도 — 한 객체를 본다.
13
+ // 여기 두는 것은 "훅 사본이 쓰고 폴러 사본이 읽는" 세션 단위 신호뿐이다.
14
+ // `pendingDispatch` 같은 디스패치 소유 상태는 공유하지 않는다 — 공유하면 worktree
15
+ // 사본의 `session.created` 핸들러가 pane 을 한 번 더 띄운다.
16
+ //
17
+ // 이 파일은 src/opencode-plugin.ts 에서 import 만 한다 (re-export 금지 —
18
+ // 로더가 모든 named export 를 plugin factory 로 호출한다).
19
+ const SHARED_KEY = Symbol.for("makdoong2-team.sub-session-registry.v1");
20
+ export function createSubSessionRegistry() {
21
+ return {
22
+ version: 1,
23
+ activeToolCalls: new Map(),
24
+ lastToolExecuteAt: new Map(),
25
+ pendingPermissions: new Map(),
26
+ sessionIssue: new Map(),
27
+ sessionWorktree: new Map(),
28
+ sessionDeletedWaiters: new Map(),
29
+ anonSeq: 0,
30
+ };
31
+ }
32
+ /**
33
+ * 프로세스 전역 레지스트리. 첫 호출이 만들고 이후 호출은 같은 객체를 돌려준다.
34
+ * 키에 버전을 박아 두어, 형태가 다른 미래 버전이 같은 프로세스에 섞여도 서로의
35
+ * 객체를 깨뜨리지 않는다.
36
+ */
37
+ export function sharedSubSessionRegistry() {
38
+ const g = globalThis;
39
+ const existing = g[SHARED_KEY];
40
+ if (existing && existing.version === 1)
41
+ return existing;
42
+ const created = createSubSessionRegistry();
43
+ g[SHARED_KEY] = created;
44
+ return created;
45
+ }
46
+ /** 테스트 전용 — 전역 객체를 비운다. */
47
+ export function resetSharedSubSessionRegistryForTests() {
48
+ delete globalThis[SHARED_KEY];
49
+ }
50
+ // ── 툴 실행 신호 ─────────────────────────────────────────────────────────────
51
+ const ANON_PREFIX = "anon#";
52
+ /** `tool.execute.before` — 이 세션에서 callID 툴이 실행을 시작했다. 사용한 키를 돌려준다. */
53
+ export function toolStarted(reg, sessionID, callID, now = Date.now()) {
54
+ const key = typeof callID === "string" && callID.length > 0 ? callID : `${ANON_PREFIX}${++reg.anonSeq}`;
55
+ let calls = reg.activeToolCalls.get(sessionID);
56
+ if (!calls) {
57
+ calls = new Map();
58
+ reg.activeToolCalls.set(sessionID, calls);
59
+ }
60
+ calls.set(key, now);
61
+ reg.lastToolExecuteAt.set(sessionID, now);
62
+ return key;
63
+ }
64
+ /**
65
+ * `tool.execute.after` 또는 툴 part 의 completed/error 관측 — 실행이 끝났다.
66
+ * 멱등이다: 같은 callID 를 두 번 끝내도, 모르는 callID 를 끝내도 아무 일도 없다.
67
+ * callID 가 없으면 가장 최근에 시작한 항목을 뺀다 (종전 카운터 의미 유지).
68
+ */
69
+ export function toolFinished(reg, sessionID, callID, now = Date.now()) {
70
+ reg.lastToolExecuteAt.set(sessionID, now);
71
+ const calls = reg.activeToolCalls.get(sessionID);
72
+ if (!calls || calls.size === 0)
73
+ return false;
74
+ let key;
75
+ if (typeof callID === "string" && callID.length > 0) {
76
+ if (!calls.has(callID))
77
+ return false;
78
+ key = callID;
79
+ }
80
+ else {
81
+ let latest = -Infinity;
82
+ for (const [k, startedAt] of calls) {
83
+ if (startedAt >= latest) {
84
+ latest = startedAt;
85
+ key = k;
86
+ }
87
+ }
88
+ }
89
+ if (key === undefined)
90
+ return false;
91
+ calls.delete(key);
92
+ if (calls.size === 0)
93
+ reg.activeToolCalls.delete(sessionID);
94
+ return true;
95
+ }
96
+ /**
97
+ * 메시지 스냅샷과 대조해 이미 끝난 호출을 정리한다.
98
+ *
99
+ * 툴이 throw 하면(권한 거부 · 파일 없음 · MCP 오류) opencode 는 `tool.execute.after`
100
+ * 를 부르지 않는다 — 항목이 영영 남아 `isToolExecuting()` 이 참으로 굳고, 그러면
101
+ * 완료 판정이 유보돼 substage 가 절대 타임아웃까지 기다린다. 스냅샷의 tool part
102
+ * 가 completed/error 면 그 호출은 확실히 끝난 것이므로 여기서 뺀다. 스냅샷에
103
+ * **아직 없는** 호출은 건드리지 않는다 (issue #7 의 110ms 창 — 훅은 발화했는데
104
+ * 서버가 part 를 아직 반영하지 않은 상태). 정리한 개수를 돌려준다.
105
+ */
106
+ export function settleToolCalls(reg, sessionID, settledCallIDs) {
107
+ const calls = reg.activeToolCalls.get(sessionID);
108
+ if (!calls || calls.size === 0)
109
+ return 0;
110
+ let n = 0;
111
+ for (const id of settledCallIDs) {
112
+ if (calls.delete(id))
113
+ n++;
114
+ }
115
+ if (calls.size === 0)
116
+ reg.activeToolCalls.delete(sessionID);
117
+ return n;
118
+ }
119
+ /** 지금 이 순간 이 세션에서 실행 중인 툴이 있는가 (순간값). */
120
+ export function isToolExecuting(reg, sessionID) {
121
+ return (reg.activeToolCalls.get(sessionID)?.size ?? 0) > 0;
122
+ }
123
+ export function activeToolCount(reg, sessionID) {
124
+ return reg.activeToolCalls.get(sessionID)?.size ?? 0;
125
+ }
126
+ /** 세션이 idle 로 전이했다 — 실행 중인 툴은 정의상 없다. */
127
+ export function clearToolCalls(reg, sessionID) {
128
+ reg.activeToolCalls.delete(sessionID);
129
+ }
130
+ // ── 권한 요청 관측 ───────────────────────────────────────────────────────────
131
+ /**
132
+ * 플러그인 `event` 훅이 받는 권한 요청 이벤트를 공통 형태로 바꾼다.
133
+ *
134
+ * 1.18 v1 브리지는 `permission.asked` 를 `PermissionRequest` 그대로 싣는다
135
+ * (`id / sessionID / permission / patterns`). 더 오래된 SDK 형태 `permission.updated`
136
+ * 는 `type` · `pattern`(문자열 또는 배열) 을 쓴다. 둘 다 받아 둔다 — 어느 쪽이 오든
137
+ * 폴러가 보는 것은 같은 shape 이어야 한다. 요청으로 볼 수 없는 페이로드는 null.
138
+ */
139
+ export function normalizePermissionEvent(type, properties, now = Date.now()) {
140
+ if (type !== "permission.asked" && type !== "permission.updated")
141
+ return null;
142
+ if (!properties || typeof properties !== "object")
143
+ return null;
144
+ const p = properties;
145
+ const id = typeof p.id === "string" ? p.id : undefined;
146
+ const sessionID = typeof p.sessionID === "string" ? p.sessionID : undefined;
147
+ if (!id || !sessionID)
148
+ return null;
149
+ const permission = typeof p.permission === "string" ? p.permission
150
+ : typeof p.type === "string" ? p.type
151
+ : "unknown";
152
+ const rawPatterns = p.patterns ?? p.pattern;
153
+ const patterns = Array.isArray(rawPatterns)
154
+ ? rawPatterns.filter((x) => typeof x === "string")
155
+ : typeof rawPatterns === "string" ? [rawPatterns]
156
+ : [];
157
+ return { id, sessionID, permission, patterns, askedAt: now };
158
+ }
159
+ export function permissionAsked(reg, req) {
160
+ let bySession = reg.pendingPermissions.get(req.sessionID);
161
+ if (!bySession) {
162
+ bySession = new Map();
163
+ reg.pendingPermissions.set(req.sessionID, bySession);
164
+ }
165
+ bySession.set(req.id, req);
166
+ }
167
+ export function permissionReplied(reg, sessionID, requestID) {
168
+ const bySession = reg.pendingPermissions.get(sessionID);
169
+ if (!bySession)
170
+ return;
171
+ bySession.delete(requestID);
172
+ if (bySession.size === 0)
173
+ reg.pendingPermissions.delete(sessionID);
174
+ }
175
+ /** 이 세션에 답을 기다리는 권한 요청 — 오래된 순. */
176
+ export function pendingPermissionsFor(reg, sessionID) {
177
+ const bySession = reg.pendingPermissions.get(sessionID);
178
+ if (!bySession)
179
+ return [];
180
+ return [...bySession.values()].sort((a, b) => a.askedAt - b.askedAt);
181
+ }
182
+ // ── 수명 ─────────────────────────────────────────────────────────────────────
183
+ /** 세션 하나의 흔적을 전부 지운다 (cleanupSubSession · session.deleted). */
184
+ export function forgetSession(reg, sessionID) {
185
+ reg.activeToolCalls.delete(sessionID);
186
+ reg.lastToolExecuteAt.delete(sessionID);
187
+ reg.pendingPermissions.delete(sessionID);
188
+ reg.sessionIssue.delete(sessionID);
189
+ reg.sessionWorktree.delete(sessionID);
190
+ }
191
+ /** session.deleted — 대기자를 전부 깨운다. 깨운 수를 돌려준다. */
192
+ export function notifySessionDeleted(reg, sessionID) {
193
+ const waiters = reg.sessionDeletedWaiters.get(sessionID);
194
+ if (!waiters || waiters.length === 0)
195
+ return 0;
196
+ const copy = [...waiters];
197
+ for (const w of copy) {
198
+ try {
199
+ w();
200
+ }
201
+ catch { /* waiter 오류는 이벤트 처리를 막지 않는다 */ }
202
+ }
203
+ return copy.length;
204
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "makdoong2-team",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -76,6 +76,7 @@ const STEPS = [
76
76
  "node --test test/verdict-reason-injection.test.ts",
77
77
  "node --test test/verdict-hash-normalize.test.ts",
78
78
  "node --test test/session-index-fallback.test.ts",
79
+ "node --test test/sub-session-registry.test.ts",
79
80
  "node --test test/plugin-exports-shape.test.ts",
80
81
  "node --test test/stale-worktree-recovery.test.ts",
81
82
  "node --test test/rollback-commits.test.ts",