makdoong2-team 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +193 -0
  3. package/agents/makdoong2-analyzer.md +135 -0
  4. package/agents/makdoong2-engineer.md +165 -0
  5. package/agents/makdoong2-planner.md +267 -0
  6. package/agents/makdoong2-publisher.md +481 -0
  7. package/agents/makdoong2-team-leader.md +249 -0
  8. package/agents/makdoong2-verifier.md +353 -0
  9. package/assets/makdoong2-team.default.json +46 -0
  10. package/assets/makdoong2-team.schema.json +357 -0
  11. package/bin/cli.js +404 -0
  12. package/dist/agent-stage-config.d.ts +15 -0
  13. package/dist/agent-stage-config.js +119 -0
  14. package/dist/config.d.ts +79 -0
  15. package/dist/config.js +96 -0
  16. package/dist/logger.d.ts +14 -0
  17. package/dist/logger.js +131 -0
  18. package/dist/mcp-secret-injector.d.ts +56 -0
  19. package/dist/mcp-secret-injector.js +89 -0
  20. package/dist/model-chain-cli.d.ts +1 -0
  21. package/dist/model-chain-cli.js +21 -0
  22. package/dist/model-fallback-policy.d.ts +69 -0
  23. package/dist/model-fallback-policy.js +211 -0
  24. package/dist/opencode-plugin.d.ts +8 -0
  25. package/dist/opencode-plugin.js +2457 -0
  26. package/dist/poll-sub-session.d.ts +139 -0
  27. package/dist/poll-sub-session.js +494 -0
  28. package/dist/redact-secrets.d.ts +3 -0
  29. package/dist/redact-secrets.js +68 -0
  30. package/dist/session-index.d.ts +11 -0
  31. package/dist/session-index.js +71 -0
  32. package/dist/skill-mcp-registry.d.ts +59 -0
  33. package/dist/skill-mcp-registry.js +178 -0
  34. package/dist/stall-escalation.d.ts +1 -0
  35. package/dist/stall-escalation.js +22 -0
  36. package/dist/tmux-monitor.d.ts +193 -0
  37. package/dist/tmux-monitor.js +694 -0
  38. package/dist/verdict-hash.d.ts +1 -0
  39. package/dist/verdict-hash.js +62 -0
  40. package/gates/stage-analysis-verify.sh +84 -0
  41. package/gates/stage2-requirements-verify.sh +13 -0
  42. package/gates/stage3-scope-verify.sh +45 -0
  43. package/gates/stage4-dev-post-verify.sh +64 -0
  44. package/gates/stage4-dev-verify.sh +36 -0
  45. package/gates/stage5-coverage-verify.sh +36 -0
  46. package/gates/stage5-test-verify.sh +24 -0
  47. package/gates/stage6-commit-verify.sh +41 -0
  48. package/gates/stage6-post-commit-verify.sh +131 -0
  49. package/gates/stage7-post-pr-verify.sh +53 -0
  50. package/gates/stage7-pr-verify.sh +48 -0
  51. package/gates/stage8-post-review-verify.sh +84 -0
  52. package/gates/stage8-review-verify.sh +45 -0
  53. package/gates/verify.sh +44 -0
  54. package/opencode.json.example +40 -0
  55. package/package.json +84 -0
  56. package/postinstall.mjs +56 -0
  57. package/references/commit-convention.md +130 -0
  58. package/references/jira-issue-templates.md +203 -0
  59. package/references/pr-template.md +381 -0
  60. package/scripts/config.sh +46 -0
  61. package/scripts/coverage-record.sh +67 -0
  62. package/scripts/gate-policy-test.sh +152 -0
  63. package/scripts/install-lib.mjs +1029 -0
  64. package/scripts/lint-agent-prompts.sh +74 -0
  65. package/scripts/log-event.sh +44 -0
  66. package/scripts/model-policy.mjs +183 -0
  67. package/scripts/publish-if-changed.sh +207 -0
  68. package/scripts/release.sh +276 -0
  69. package/scripts/rollback-commits.sh +35 -0
  70. package/scripts/smoke-test.mjs +194 -0
  71. package/scripts/state.sh +192 -0
  72. package/scripts/test-postinstall.mjs +141 -0
  73. package/scripts/with-fallback.sh +56 -0
  74. package/scripts/wt-sync-ignored.sh +193 -0
  75. package/skills/_lib/load-secret.sh +149 -0
  76. package/skills/bamboo-ci/SKILL.md +81 -0
  77. package/skills/bamboo-ci/run-bamboo.sh +23 -0
  78. package/skills/bitbucket-research/SKILL.md +87 -0
  79. package/skills/bitbucket-research/run-repos.sh +23 -0
  80. package/skills/confluence-research/SKILL.md +75 -0
  81. package/skills/confluence-research/run-docs.sh +23 -0
  82. package/skills/github-oss-research/SKILL.md +59 -0
  83. package/skills/jira-research/SKILL.md +75 -0
  84. package/skills/jira-research/run-works.sh +23 -0
  85. package/src/hooks/guard-bash.sh +67 -0
  86. package/src/hooks/session-start.sh +96 -0
  87. package/src/hooks/sync-state.sh +47 -0
  88. package/stages/01-jira.md +43 -0
  89. package/stages/01-planning.md +229 -0
  90. package/stages/02-requirements.md +298 -0
  91. package/stages/03-scope.md +81 -0
  92. package/stages/04-analysis.md +281 -0
  93. package/stages/05-worktree-dev.md +124 -0
  94. package/stages/06-test.md +161 -0
  95. package/stages/07-commit.md +229 -0
  96. package/stages/08-pr.md +177 -0
  97. package/stages/09-review-comments.md +277 -0
@@ -0,0 +1,694 @@
1
+ // tmux-monitor.ts — sub-session pane monitor for makdoong2-team.
2
+ //
3
+ // Adapted from oh-my-opencode's `features/tmux-subagent` pattern:
4
+ // - buildTmuxAttachCommand(serverUrl, sessionId) → `opencode attach <id>`
5
+ // - tmux split-window -h -P -F '#{pane_id}' opens a side pane attached to
6
+ // the sub-session, letting 부장님 watch each 막둥이 live.
7
+ // - polling/eviction logic from omo's manager is dropped because
8
+ // dispatch_stage runs at most one sub-session at a time.
9
+ //
10
+ // Activated only when (a) MAKDOONG2 is invoked from inside tmux AND
11
+ // (b) tmux.enabled=true in makdoong2-team.json. Otherwise every method is a no-op so the
12
+ // non-tmux happy path is untouched.
13
+ import { logger } from "./logger.js";
14
+ export const FOCUS_POLL_INTERVAL_MS = 2_000;
15
+ export function isInsideTmux() {
16
+ return Boolean(process.env.TMUX);
17
+ }
18
+ export function buildWindowName(stage, sessionId) {
19
+ const safeStage = stage.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
20
+ return `mdn2-${safeStage || "stage"}-${sessionId.slice(-8)}`;
21
+ }
22
+ function shellSingleQuote(value) {
23
+ return `'${value.replace(/'/g, "'\\''")}'`;
24
+ }
25
+ export function buildPlaceholderCommand(stage, agent, sessionId) {
26
+ const banner = shellSingleQuote(`막둥이 ${agent} — ${stage} [${sessionId.slice(-8)}]`);
27
+ const hint = shellSingleQuote("이 창을 선택하면 세션 화면으로 자동 전환됩니다.");
28
+ return `printf '%s\\n%s\\n' ${banner} ${hint}; while :; do sleep 86400; done`;
29
+ }
30
+ // Pane ownership markers stored as tmux pane user options (`set-option -p`,
31
+ // tmux >= 3.0 — enforced by checkTmuxVersion at plugin init).
32
+ // Written by spawnPaneInner, read by cleanupOrphans / closePane fallback / reap pass.
33
+ export const MARKER_SESSION = "@mdn2_session";
34
+ export const MARKER_PID = "@mdn2_pid";
35
+ export const MARKER_STAGE = "@mdn2_stage";
36
+ export const MARKER_STARTED = "@mdn2_started_at";
37
+ // Parent opencode discriminator: the `oc` wrapper script always launches the
38
+ // parent as `opencode "$@" --port`, so any pane whose start_command contains
39
+ // `--port` MUST be excluded from cleanup regardless of markers.
40
+ export const PARENT_MARKER_PATTERN = /--port(\s|$)/;
41
+ export function readTmuxConfig(block) {
42
+ const b = block ?? {};
43
+ const layoutRaw = b.layout ?? "main-vertical";
44
+ const layout = ["main-vertical", "main-horizontal", "tiled",
45
+ "even-horizontal", "even-vertical"].includes(layoutRaw)
46
+ ? layoutRaw
47
+ : "main-vertical";
48
+ const splitRaw = b.split_direction ?? "-h";
49
+ // Defaults to "window": "pane" resizes 부장님's pane on every spawn/kill.
50
+ // ARCHITECTURE.md §17.6.
51
+ const placement = b.placement === "pane" ? "pane" : "window";
52
+ return {
53
+ enabled: b.enabled === true,
54
+ placement,
55
+ layout,
56
+ mainPaneSize: typeof b.main_pane_size === "number" ? b.main_pane_size : 60,
57
+ agentPaneMinWidth: typeof b.agent_pane_min_width === "number" ? b.agent_pane_min_width : 52,
58
+ splitDirection: splitRaw === "-v" ? "-v" : "-h",
59
+ attachCommand: b.attach_command ?? "opencode attach",
60
+ serverUrl: b.server_url || undefined,
61
+ keepPaneOnSuccess: b.keep_pane_on_success === true,
62
+ autoCloseOnFailure: b.auto_close_on_failure === true,
63
+ paneCloseDelaySeconds: typeof b.pane_close_delay_seconds === "number" ? b.pane_close_delay_seconds : 5,
64
+ };
65
+ }
66
+ /** EPERM means the pid exists but belongs to another user — still alive. */
67
+ export function isPidAlive(pid) {
68
+ if (!Number.isFinite(pid) || pid <= 0)
69
+ return false;
70
+ try {
71
+ process.kill(pid, 0);
72
+ return true;
73
+ }
74
+ catch (e) {
75
+ const code = e.code;
76
+ return code === "EPERM";
77
+ }
78
+ }
79
+ /**
80
+ * Grace window applied to freshly spawned panes during the orphan-scan tick.
81
+ * opencode's session.status() is scoped to the requesting directory, so a
82
+ * sub-session created inside a worktree never appears in the parent's status
83
+ * map. Without a spawn grace the very first tick after split-window reads
84
+ * `status=undefined` and kills a perfectly healthy pane.
85
+ */
86
+ export const ORPHAN_SPAWN_GRACE_MS = 120_000;
87
+ const EVICTION_DISABLED = 0;
88
+ /** @returns the guard that fired, or undefined when the pane may be cleaned up. */
89
+ export function orphanCleanupGuard(pane, ctx) {
90
+ const alive = ctx.isPidAlive ?? isPidAlive;
91
+ if (pane.ownerPid !== undefined && pane.ownerPid !== ctx.ownerPid && alive(pane.ownerPid)) {
92
+ return "foreign-live-owner";
93
+ }
94
+ const spawnGraceMs = ctx.spawnGraceMs ?? ORPHAN_SPAWN_GRACE_MS;
95
+ if (spawnGraceMs > 0 && pane.startedAt !== undefined) {
96
+ if (ctx.nowMs - pane.startedAt * 1000 < spawnGraceMs)
97
+ return "spawn-grace";
98
+ }
99
+ if ((ctx.activeToolCount ?? 0) > 0)
100
+ return "tool-activity";
101
+ const toolAliveWindowMs = ctx.toolAliveWindowMs ?? 300_000;
102
+ if (typeof ctx.lastToolExecuteAtMs === "number"
103
+ && ctx.nowMs - ctx.lastToolExecuteAtMs < toolAliveWindowMs) {
104
+ return "tool-activity";
105
+ }
106
+ return undefined;
107
+ }
108
+ export class TmuxMonitor {
109
+ panes;
110
+ sourcePaneId;
111
+ $;
112
+ ownerPid;
113
+ tmuxVersionCache = null;
114
+ versionBlocked = false;
115
+ focusTimer = null;
116
+ focusPollInFlight = false;
117
+ config;
118
+ constructor(shell, config, ownerPid) {
119
+ this.$ = shell;
120
+ this.config = config;
121
+ this.panes = new Map();
122
+ this.sourcePaneId = process.env.TMUX_PANE || undefined;
123
+ this.ownerPid = ownerPid ?? process.pid;
124
+ }
125
+ get active() {
126
+ return this.config.enabled && isInsideTmux() && !this.versionBlocked;
127
+ }
128
+ get trackedPaneCount() {
129
+ return this.panes.size;
130
+ }
131
+ get ownerProcessId() {
132
+ return this.ownerPid;
133
+ }
134
+ async checkTmuxVersion() {
135
+ if (this.tmuxVersionCache)
136
+ return this.tmuxVersionCache;
137
+ const r = await this.$ `tmux -V`.quiet().nothrow();
138
+ if (r.exitCode !== 0) {
139
+ this.tmuxVersionCache = { ok: false, version: "unknown" };
140
+ return this.tmuxVersionCache;
141
+ }
142
+ const raw = (r.stdout?.toString().trim() ?? "");
143
+ const m = raw.match(/tmux(?:\s+(?:next-)?)\s*(\d+)\.(\d+)/);
144
+ if (!m) {
145
+ this.tmuxVersionCache = { ok: false, version: raw };
146
+ return this.tmuxVersionCache;
147
+ }
148
+ const major = Number(m[1]);
149
+ const minor = Number(m[2]);
150
+ if (major < 3) {
151
+ this.versionBlocked = true;
152
+ throw new Error(`tmux ${major}.${minor} is not supported. makdoong2-team@1.x requires tmux >= 3.0 ` +
153
+ `for pane-scoped user options (\`set-option -p\`). Upgrade tmux or downgrade to ` +
154
+ `makdoong2-team@0.x which supports tmux 2.7+.`);
155
+ }
156
+ this.tmuxVersionCache = { ok: true, version: `${major}.${minor}` };
157
+ return this.tmuxVersionCache;
158
+ }
159
+ async spawnPane(sessionId, stage, agent, worktree) {
160
+ if (!this.active)
161
+ return null;
162
+ const existing = this.panes.get(sessionId);
163
+ if (existing) {
164
+ logger.debug(`[tmux-monitor] spawnPane DUPLICATE-GUARD session=${sessionId} ` +
165
+ `existing_paneId="${existing.paneId}" pending=${!!existing.pending} — returning cached`);
166
+ if (existing.pending)
167
+ return existing.pending;
168
+ return existing.paneId || null;
169
+ }
170
+ logger.debug(`[tmux-monitor] spawnPane ENTER session=${sessionId} stage=${stage} agent=${agent}`);
171
+ const placeholder = {
172
+ sessionId,
173
+ paneId: "",
174
+ stage,
175
+ agent,
176
+ createdAt: new Date(),
177
+ };
178
+ this.panes.set(sessionId, placeholder);
179
+ const pending = (async () => {
180
+ try {
181
+ const paneId = await this.spawnPaneInner(sessionId, stage, agent, worktree);
182
+ if (paneId) {
183
+ placeholder.paneId = paneId;
184
+ return paneId;
185
+ }
186
+ this.panes.delete(sessionId);
187
+ return null;
188
+ }
189
+ catch (e) {
190
+ this.panes.delete(sessionId);
191
+ throw e;
192
+ }
193
+ finally {
194
+ placeholder.pending = undefined;
195
+ }
196
+ })();
197
+ placeholder.pending = pending;
198
+ return pending;
199
+ }
200
+ async spawnPaneInner(sessionId, stage, agent, worktree) {
201
+ const effectiveUrl = this.config.serverUrl ?? await this.discoverServerUrl();
202
+ if (!effectiveUrl) {
203
+ logger.warn(`[tmux-monitor] SERVER_URL_UNRESOLVED session=${sessionId} — ` +
204
+ `pane attach will use fallback (no URL). ` +
205
+ `Set tmux.server_url in makdoong2-team.json or ensure discoverServerUrl can find the process.`);
206
+ }
207
+ const dirFlag = worktree ? ` --dir '${worktree.replace(/'/g, "'\\''")}'` : "";
208
+ const attachArgs = effectiveUrl
209
+ ? `${this.config.attachCommand} ${effectiveUrl} --session ${sessionId}${dirFlag}`
210
+ : `${this.config.attachCommand} ${sessionId}`;
211
+ // The pane always starts as a placeholder: `opencode attach` is a full TUI
212
+ // that fires 19 terminal palette queries on startup, and tmux can deliver a
213
+ // fragmented reply to 부장님's prompt as literal text. ARCHITECTURE.md §17.7.
214
+ const innerCmd = ["sh", "-c", buildPlaceholderCommand(stage, agent, sessionId)];
215
+ const capacity = await this.computeCapacity();
216
+ if (capacity > 0 && this.panes.size > capacity) {
217
+ const evictable = [...this.panes.values()]
218
+ .filter(p => p.sessionId !== sessionId && p.paneId && p.lastSuccess !== false)
219
+ .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
220
+ const oldest = evictable[0];
221
+ if (oldest) {
222
+ await this.killPane(oldest.paneId);
223
+ this.panes.delete(oldest.sessionId);
224
+ this.stopFocusWatchIfIdle();
225
+ }
226
+ }
227
+ // Target the source (부장님) pane when known; otherwise the active pane.
228
+ const targetArgs = this.sourcePaneId
229
+ ? ["-t", this.sourcePaneId]
230
+ : [];
231
+ // Use source pane's CWD (main repo) so `opencode attach` finds the
232
+ // right server — the server is bound to the repo where opencode started,
233
+ // not to the worktree. Falls back to worktree if unreadable.
234
+ const srcCwdResult = this.sourcePaneId
235
+ ? await this.$ `tmux display-message -t ${this.sourcePaneId} -p ${"#{pane_current_path}"}`.quiet().nothrow()
236
+ : null;
237
+ const serverCwd = srcCwdResult?.exitCode === 0 && srcCwdResult.stdout?.toString().trim()
238
+ ? srcCwdResult.stdout.toString().trim()
239
+ : undefined;
240
+ const cwdArgs = serverCwd ? ["-c", serverCwd] : [];
241
+ const useOwnWindow = this.config.placement === "window";
242
+ const paneId = useOwnWindow
243
+ ? await this.spawnInNewWindow(sessionId, stage, cwdArgs, innerCmd, serverCwd, attachArgs)
244
+ : await this.spawnInSplitPane(sessionId, stage, targetArgs, cwdArgs, innerCmd, serverCwd, attachArgs);
245
+ if (!paneId)
246
+ return null;
247
+ await this.$ `tmux select-pane -t ${paneId} -T ${`${stage} [${sessionId.slice(-8)}]`}`
248
+ .quiet().nothrow();
249
+ await this.writePaneMarkers(paneId, sessionId, stage);
250
+ if (!useOwnWindow) {
251
+ await this.rebalanceLayout();
252
+ if (this.sourcePaneId) {
253
+ await this.$ `tmux select-pane -t ${this.sourcePaneId}`.quiet().nothrow();
254
+ }
255
+ }
256
+ const tracked = this.panes.get(sessionId);
257
+ if (tracked) {
258
+ tracked.attachCommand = attachArgs;
259
+ tracked.awaitingFocus = true;
260
+ }
261
+ this.startFocusWatch();
262
+ return paneId;
263
+ }
264
+ /**
265
+ * Swap a placeholder pane over to the real `opencode attach` TUI.
266
+ * Pane user options (@mdn2_*) survive respawn-pane, so ownership tracking is
267
+ * unaffected — verified against tmux 3.6.
268
+ */
269
+ async activatePane(sessionId) {
270
+ if (!this.active)
271
+ return false;
272
+ const tracked = this.panes.get(sessionId);
273
+ if (!tracked?.awaitingFocus || !tracked.paneId || !tracked.attachCommand)
274
+ return false;
275
+ tracked.awaitingFocus = false;
276
+ const r = await this.$ `tmux respawn-pane -k -t ${tracked.paneId} ${["sh", "-c", tracked.attachCommand]}`
277
+ .quiet().nothrow();
278
+ if (r.exitCode !== 0) {
279
+ tracked.awaitingFocus = true;
280
+ logger.warn(`[tmux-monitor] activatePane FAILED session=${sessionId} pane=${tracked.paneId} ` +
281
+ `exit=${r.exitCode} stderr=${r.stderr?.toString().trim() ?? ""} — will retry on next focus poll`);
282
+ return false;
283
+ }
284
+ logger.debug(`[tmux-monitor] activatePane session=${sessionId} pane=${tracked.paneId}`);
285
+ this.stopFocusWatchIfIdle();
286
+ return true;
287
+ }
288
+ /**
289
+ * One focus sweep. A pane counts as focused only when it is the active pane
290
+ * of the active window — `pane_active` alone is 1 for every window's own
291
+ * active pane, so both flags are required.
292
+ */
293
+ async pollFocusOnce() {
294
+ if (!this.active)
295
+ return 0;
296
+ const waiting = [...this.panes.values()].filter(p => p.awaitingFocus && p.paneId);
297
+ if (waiting.length === 0) {
298
+ this.stopFocusWatchIfIdle();
299
+ return 0;
300
+ }
301
+ const r = await this.$ `tmux list-panes -aF ${"#{pane_id}\t#{pane_active}\t#{window_active}"}`
302
+ .quiet().nothrow();
303
+ if (r.exitCode !== 0)
304
+ return 0;
305
+ const focused = new Set();
306
+ for (const line of (r.stdout?.toString() ?? "").split("\n")) {
307
+ const [paneId, paneActive, windowActive] = line.split("\t");
308
+ if (paneId && paneActive?.trim() === "1" && windowActive?.trim() === "1")
309
+ focused.add(paneId);
310
+ }
311
+ let activated = 0;
312
+ for (const p of waiting) {
313
+ if (!focused.has(p.paneId))
314
+ continue;
315
+ if (await this.activatePane(p.sessionId))
316
+ activated++;
317
+ }
318
+ return activated;
319
+ }
320
+ startFocusWatch() {
321
+ if (this.focusTimer)
322
+ return;
323
+ this.focusTimer = setInterval(() => {
324
+ if (this.focusPollInFlight)
325
+ return;
326
+ this.focusPollInFlight = true;
327
+ void this.pollFocusOnce()
328
+ .catch(() => undefined)
329
+ .finally(() => { this.focusPollInFlight = false; });
330
+ }, FOCUS_POLL_INTERVAL_MS);
331
+ this.focusTimer.unref?.();
332
+ }
333
+ stopFocusWatchIfIdle() {
334
+ if (!this.focusTimer)
335
+ return;
336
+ if ([...this.panes.values()].some(p => p.awaitingFocus))
337
+ return;
338
+ clearInterval(this.focusTimer);
339
+ this.focusTimer = null;
340
+ }
341
+ /**
342
+ * placement=window — own tmux window; `-d` leaves the active window (and thus
343
+ * 부장님's pane geometry) untouched. ARCHITECTURE.md §17.6.
344
+ */
345
+ async spawnInNewWindow(sessionId, stage, cwdArgs, innerCmd, serverCwd, attachArgs) {
346
+ const windowName = buildWindowName(stage, sessionId);
347
+ // `new-window -t` rejects a pane id ("can't specify pane here") — it needs a
348
+ // target-window, so resolve the source pane's <session_id>:<window_index>.
349
+ // session_id ($N) is used instead of session_name because names may contain ':'.
350
+ const windowTarget = await this.resolveSourceWindowTarget();
351
+ if (!windowTarget) {
352
+ // An untargeted `new-window` lands in whatever session/window the server
353
+ // considers current — possibly another user's. Skip instead.
354
+ logger.warn(`[tmux-monitor] new-window SKIPPED session=${sessionId} stage=${stage} — ` +
355
+ `could not resolve source window target from pane=${this.sourcePaneId ?? "-"}`);
356
+ return null;
357
+ }
358
+ const placeArgs = ["-a", "-t", windowTarget];
359
+ logger.debug(`[tmux-monitor] new-window session=${sessionId} stage=${stage} ` +
360
+ `name=${windowName} target=${windowTarget ?? "-"} cwd=${serverCwd ?? "-"} ` +
361
+ `attach="${attachArgs}"`);
362
+ const result = await this.$ `tmux new-window ${placeArgs} -d -P -F ${"#{pane_id}"} -n ${windowName} ${cwdArgs} ${innerCmd}`
363
+ .quiet().nothrow();
364
+ if (result.exitCode !== 0) {
365
+ logger.warn(`[tmux-monitor] new-window FAILED session=${sessionId} stage=${stage} ` +
366
+ `exit=${result.exitCode} stderr=${result.stderr?.toString().trim() ?? ""}`);
367
+ return null;
368
+ }
369
+ return result.stdout?.toString().trim() || null;
370
+ }
371
+ async resolveSourceWindowTarget() {
372
+ if (!this.sourcePaneId)
373
+ return null;
374
+ const r = await this.$ `tmux display-message -t ${this.sourcePaneId} -p ${"#{session_id}:#{window_index}"}`
375
+ .quiet().nothrow();
376
+ if (r.exitCode !== 0)
377
+ return null;
378
+ return r.stdout?.toString().trim() || null;
379
+ }
380
+ /** placement=pane — legacy split of 부장님's window. Resizes the source pane. */
381
+ async spawnInSplitPane(sessionId, stage, targetArgs, cwdArgs, innerCmd, serverCwd, attachArgs) {
382
+ logger.debug(`[tmux-monitor] split-window session=${sessionId} stage=${stage} ` +
383
+ `direction=${this.config.splitDirection} cwd=${serverCwd ?? "-"} ` +
384
+ `attach="${attachArgs}"`);
385
+ const result = await this.$ `tmux split-window ${targetArgs} ${this.config.splitDirection} -d -P -F ${"#{pane_id}"} ${cwdArgs} ${innerCmd}`
386
+ .quiet().nothrow();
387
+ if (result.exitCode !== 0)
388
+ return null;
389
+ return result.stdout?.toString().trim() || null;
390
+ }
391
+ async rebalanceLayout() {
392
+ // main-vertical uses main-pane-width; main-horizontal uses main-pane-height.
393
+ // Other layouts (tiled, even-*) distribute panes equally and ignore this option.
394
+ if (this.config.layout === "main-vertical" || this.config.layout === "main-horizontal") {
395
+ const isWidth = this.config.layout === "main-vertical";
396
+ const dimFormat = isWidth ? "#{window_width}" : "#{window_height}";
397
+ const dimResult = await this.$ `tmux display-message -p ${dimFormat}`.quiet().nothrow();
398
+ const dim = Number(dimResult.stdout?.toString().trim() ?? 0);
399
+ if (Number.isFinite(dim) && dim > 0) {
400
+ const mainDim = Math.floor((dim * this.config.mainPaneSize) / 100);
401
+ const optName = isWidth ? "main-pane-width" : "main-pane-height";
402
+ await this.$ `tmux set-window-option ${optName} ${String(mainDim)}`.quiet().nothrow();
403
+ }
404
+ }
405
+ await this.$ `tmux select-layout ${this.config.layout}`.quiet().nothrow();
406
+ }
407
+ /**
408
+ * Sub-session 완료 시 pane 처리.
409
+ * - opts.success === true: paneCloseDelaySeconds 초 대기 후 kill (성공 로그 확인 시간 확보).
410
+ * - opts.success === false: autoCloseOnFailure이 true면 kill, 아니면 유지 (실패 진단용).
411
+ * - opts 미지정: 기존 호환 동작 — 즉시 kill.
412
+ *
413
+ * Fallback: if the in-memory Map lost the entry (plugin re-init between spawn
414
+ * and close), a tmux marker scan finds the pane by @mdn2_session and kills it.
415
+ */
416
+ async closePane(sessionId, opts) {
417
+ if (!this.active)
418
+ return;
419
+ const tracked = this.panes.get(sessionId);
420
+ if (!tracked) {
421
+ const orphan = await this.findPaneBySession(sessionId);
422
+ if (!orphan)
423
+ return;
424
+ if (opts && !opts.success && !this.config.autoCloseOnFailure)
425
+ return;
426
+ await this.killPane(orphan);
427
+ return;
428
+ }
429
+ if (opts !== undefined) {
430
+ tracked.lastSuccess = opts.success;
431
+ if (opts.success) {
432
+ if (this.config.paneCloseDelaySeconds > 0) {
433
+ await new Promise(r => setTimeout(r, this.config.paneCloseDelaySeconds * 1000));
434
+ }
435
+ await this.killPane(tracked.paneId);
436
+ this.panes.delete(sessionId);
437
+ this.stopFocusWatchIfIdle();
438
+ return;
439
+ }
440
+ if (!this.config.autoCloseOnFailure) {
441
+ // Pane is kept for diagnosis, so a lazy placeholder must stay
442
+ // focus-activatable — otherwise 부장님 is left with an inert banner
443
+ // instead of the failed 막둥이's transcript.
444
+ return;
445
+ }
446
+ await this.killPane(tracked.paneId);
447
+ this.panes.delete(sessionId);
448
+ this.stopFocusWatchIfIdle();
449
+ return;
450
+ }
451
+ await this.killPane(tracked.paneId);
452
+ this.panes.delete(sessionId);
453
+ this.stopFocusWatchIfIdle();
454
+ }
455
+ /** Kill every tracked pane — used when the plugin shuts down. */
456
+ async cleanup() {
457
+ if (!this.active)
458
+ return;
459
+ for (const t of this.panes.values()) {
460
+ await this.killPane(t.paneId);
461
+ }
462
+ this.panes.clear();
463
+ this.stopFocusWatchIfIdle();
464
+ }
465
+ async reapDeadOwnerPanes(opts = {}) {
466
+ if (!this.active)
467
+ return 0;
468
+ const graceSeconds = opts.graceSeconds ?? 10;
469
+ const nowMs = (opts.now ?? Date.now)();
470
+ const scanned = await this.scanOrphans();
471
+ let killed = 0;
472
+ for (const p of scanned) {
473
+ if (p.ownerPid === this.ownerPid)
474
+ continue;
475
+ if (p.ownerPid !== undefined && this.isPidAlive(p.ownerPid))
476
+ continue;
477
+ if (graceSeconds > 0 && p.startedAt !== undefined) {
478
+ const ageSeconds = (nowMs - p.startedAt * 1000) / 1000;
479
+ if (ageSeconds < graceSeconds)
480
+ continue;
481
+ }
482
+ await this.killPane(p.paneId);
483
+ killed++;
484
+ }
485
+ return killed;
486
+ }
487
+ /**
488
+ * Discover every pane on the tmux server that carries our @mdn2_session
489
+ * marker. Parent opencode panes (--port) are NEVER returned regardless of
490
+ * markers — safety guard against accidental self-kill if the parent ever
491
+ * inherited a stale marker.
492
+ */
493
+ async scanOrphans() {
494
+ if (!this.active)
495
+ return [];
496
+ const format = [
497
+ "#{pane_id}",
498
+ `#{${MARKER_SESSION}}`,
499
+ `#{${MARKER_STAGE}}`,
500
+ `#{${MARKER_PID}}`,
501
+ `#{${MARKER_STARTED}}`,
502
+ "#{pane_start_command}",
503
+ ].join("\t");
504
+ const r = await this.$ `tmux list-panes -aF ${format}`.quiet().nothrow();
505
+ if (r.exitCode !== 0)
506
+ return [];
507
+ const results = [];
508
+ for (const line of (r.stdout?.toString() ?? "").split("\n")) {
509
+ if (!line.trim())
510
+ continue;
511
+ const cols = line.split("\t");
512
+ const [paneId, sessionId, stage, pidStr, startedStr, startCmd] = cols;
513
+ if (!paneId)
514
+ continue;
515
+ const cmd = startCmd ?? "";
516
+ if (PARENT_MARKER_PATTERN.test(cmd))
517
+ continue;
518
+ if (!sessionId || sessionId.trim().length === 0)
519
+ continue;
520
+ results.push({
521
+ paneId,
522
+ sessionId: sessionId.trim(),
523
+ stage: stage?.trim() || undefined,
524
+ ownerPid: pidStr && /^\d+$/.test(pidStr.trim()) ? Number(pidStr.trim()) : undefined,
525
+ startedAt: startedStr && /^\d+$/.test(startedStr.trim()) ? Number(startedStr.trim()) : undefined,
526
+ startCommand: cmd || undefined,
527
+ });
528
+ }
529
+ return results;
530
+ }
531
+ /**
532
+ * Cleanup entry point invoked by the cleanup_panes tool. Kills every
533
+ * @mdn2_session-marked pane on the tmux server.
534
+ *
535
+ * Panes newer than opts.graceSeconds (default 0) are skipped to avoid
536
+ * killing panes still in the spawn window.
537
+ */
538
+ async cleanupOrphans(opts = {}) {
539
+ const trackedCount = this.panes.size;
540
+ let orphansKilled = 0;
541
+ let parentsSkipped = 0;
542
+ let freshSkipped = 0;
543
+ if (!this.active) {
544
+ return {
545
+ tracked_closed: 0,
546
+ orphans_closed: 0,
547
+ parents_skipped: 0,
548
+ fresh_skipped: 0,
549
+ total_closed: 0,
550
+ };
551
+ }
552
+ await this.cleanup();
553
+ const now = opts.now ?? (() => Math.floor(Date.now() / 1000));
554
+ const grace = Math.max(0, opts.graceSeconds ?? 0);
555
+ const nowSec = now();
556
+ const panes = await this.scanOrphans();
557
+ for (const p of panes) {
558
+ if (p.startedAt !== undefined && grace > 0 && nowSec - p.startedAt < grace) {
559
+ freshSkipped++;
560
+ continue;
561
+ }
562
+ await this.killPane(p.paneId);
563
+ orphansKilled++;
564
+ }
565
+ return {
566
+ tracked_closed: trackedCount,
567
+ orphans_closed: orphansKilled,
568
+ parents_skipped: parentsSkipped,
569
+ fresh_skipped: freshSkipped,
570
+ total_closed: trackedCount + orphansKilled,
571
+ };
572
+ }
573
+ /**
574
+ * Locate a pane by its @mdn2_session marker. Survives plugin re-init
575
+ * (markers persist on the tmux server even when the in-memory Map is empty).
576
+ * Parent opencode panes (--port) are filtered by scanOrphans.
577
+ */
578
+ async findPaneBySession(sessionId) {
579
+ const panes = await this.scanOrphans();
580
+ for (const p of panes) {
581
+ if (p.sessionId === sessionId)
582
+ return p.paneId;
583
+ }
584
+ return null;
585
+ }
586
+ /**
587
+ * Force-kill the marker-matched pane for the given session id. Unlike
588
+ * closePane this bypasses in-memory tracking entirely and only cares
589
+ * about the tmux state, so it survives plugin restarts.
590
+ *
591
+ * Never touches parent opencode panes — the scanOrphans PARENT filter
592
+ * excludes any `--port` process.
593
+ */
594
+ async forceKillBySessionId(sessionId) {
595
+ if (!this.active)
596
+ return false;
597
+ if (!sessionId || !sessionId.startsWith("ses_"))
598
+ return false;
599
+ const paneId = await this.findPaneBySession(sessionId);
600
+ if (!paneId)
601
+ return false;
602
+ await this.killPane(paneId);
603
+ this.panes.delete(sessionId);
604
+ return true;
605
+ }
606
+ async writePaneMarkers(paneId, sessionId, stage) {
607
+ let v;
608
+ try {
609
+ v = await this.checkTmuxVersion();
610
+ }
611
+ catch (e) {
612
+ logger.debug(`[tmux-monitor] writePaneMarkers SKIP session=${sessionId} pane=${paneId} ` +
613
+ `— ${e.message}`);
614
+ return;
615
+ }
616
+ if (!v.ok) {
617
+ logger.debug(`[tmux-monitor] writePaneMarkers SKIP session=${sessionId} pane=${paneId} ` +
618
+ `tmux=${v.version} (requires >= 3.0 for set-option -p; in-memory tracking only)`);
619
+ return;
620
+ }
621
+ const started = String(Math.floor(Date.now() / 1000));
622
+ const pid = String(this.ownerPid);
623
+ await this.$ `tmux set-option -p -t ${paneId} ${MARKER_SESSION} ${sessionId}`.quiet().nothrow();
624
+ await this.$ `tmux set-option -p -t ${paneId} ${MARKER_PID} ${pid}`.quiet().nothrow();
625
+ await this.$ `tmux set-option -p -t ${paneId} ${MARKER_STAGE} ${stage}`.quiet().nothrow();
626
+ await this.$ `tmux set-option -p -t ${paneId} ${MARKER_STARTED} ${started}`.quiet().nothrow();
627
+ }
628
+ isPidAlive(pid) {
629
+ return isPidAlive(pid);
630
+ }
631
+ async killPane(paneId) {
632
+ if (!paneId)
633
+ return;
634
+ await this.$ `tmux kill-pane -t ${paneId}`.quiet().nothrow();
635
+ }
636
+ async discoverServerUrl() {
637
+ if (!this.sourcePaneId)
638
+ return undefined;
639
+ const pidResult = await this.$ `tmux display-message -t ${this.sourcePaneId} -p ${"#{pane_pid}"}`.quiet().nothrow();
640
+ if (pidResult.exitCode !== 0)
641
+ return undefined;
642
+ const panePid = pidResult.stdout?.toString().trim();
643
+ if (!panePid || panePid === "0")
644
+ return undefined;
645
+ const pids = new Set([panePid]);
646
+ const childResult = await this.$ `pgrep -P ${panePid}`.quiet().nothrow();
647
+ if (childResult.exitCode === 0) {
648
+ for (const p of childResult.stdout.toString().trim().split("\n")) {
649
+ if (p)
650
+ pids.add(p);
651
+ }
652
+ }
653
+ const level1 = [...pids].filter(p => p !== panePid);
654
+ for (const parent of level1) {
655
+ const grandResult = await this.$ `pgrep -P ${parent}`.quiet().nothrow();
656
+ if (grandResult.exitCode === 0) {
657
+ for (const p of grandResult.stdout.toString().trim().split("\n")) {
658
+ if (p)
659
+ pids.add(p);
660
+ }
661
+ }
662
+ }
663
+ const ssResult = await this.$ `ss -tlnp`.quiet().nothrow();
664
+ if (ssResult.exitCode !== 0)
665
+ return undefined;
666
+ for (const line of (ssResult.stdout?.toString() ?? "").split("\n")) {
667
+ for (const pid of pids) {
668
+ if (!line.includes(`pid=${pid},`) && !line.includes(`pid=${pid})`))
669
+ continue;
670
+ const m = line.match(/(?:127\.0\.0\.1|\*|0\.0\.0\.0|\[::1\]|\[::\]):(\d+)/);
671
+ if (m && Number(m[1]) > 0)
672
+ return `http://127.0.0.1:${m[1]}`;
673
+ }
674
+ }
675
+ return undefined;
676
+ }
677
+ /** Maximum number of agent panes that fit beside the main pane. */
678
+ async computeCapacity() {
679
+ if (this.config.placement === "window")
680
+ return EVICTION_DISABLED;
681
+ const r = await this.$ `tmux display-message -p ${"#{window_width}"}`
682
+ .quiet().nothrow();
683
+ if (r.exitCode !== 0)
684
+ return EVICTION_DISABLED;
685
+ const width = Number(r.stdout?.toString().trim() ?? 0);
686
+ if (!Number.isFinite(width) || width <= 0)
687
+ return EVICTION_DISABLED;
688
+ const main = Math.floor((width * this.config.mainPaneSize) / 100);
689
+ const remaining = Math.max(0, width - main);
690
+ if (remaining < this.config.agentPaneMinWidth)
691
+ return EVICTION_DISABLED;
692
+ return Math.max(1, Math.floor(remaining / this.config.agentPaneMinWidth));
693
+ }
694
+ }
@@ -0,0 +1 @@
1
+ export declare function computeVerdictHash(raw: string, stage: string): string;