triflux 3.3.0-dev.8 → 4.0.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 (91) hide show
  1. package/README.ko.md +108 -199
  2. package/README.md +108 -199
  3. package/bin/triflux.mjs +2415 -1762
  4. package/hooks/keyword-rules.json +361 -354
  5. package/hooks/pipeline-stop.mjs +5 -2
  6. package/hub/assign-callbacks.mjs +136 -136
  7. package/hub/bridge.mjs +734 -684
  8. package/hub/delegator/contracts.mjs +38 -38
  9. package/hub/delegator/index.mjs +14 -14
  10. package/hub/delegator/schema/delegator-tools.schema.json +250 -250
  11. package/hub/delegator/service.mjs +302 -118
  12. package/hub/delegator/tool-definitions.mjs +35 -35
  13. package/hub/hitl.mjs +67 -67
  14. package/hub/paths.mjs +28 -0
  15. package/hub/pipe.mjs +589 -561
  16. package/hub/pipeline/state.mjs +23 -0
  17. package/hub/public/dashboard.html +349 -0
  18. package/hub/public/tray-icon.ico +0 -0
  19. package/hub/public/tray-icon.png +0 -0
  20. package/hub/router.mjs +782 -782
  21. package/hub/schema.sql +40 -40
  22. package/hub/server.mjs +810 -637
  23. package/hub/store.mjs +706 -706
  24. package/hub/team/cli/commands/attach.mjs +37 -0
  25. package/hub/team/cli/commands/control.mjs +43 -0
  26. package/hub/team/cli/commands/debug.mjs +74 -0
  27. package/hub/team/cli/commands/focus.mjs +53 -0
  28. package/hub/team/cli/commands/interrupt.mjs +36 -0
  29. package/hub/team/cli/commands/kill.mjs +37 -0
  30. package/hub/team/cli/commands/list.mjs +24 -0
  31. package/hub/team/cli/commands/send.mjs +37 -0
  32. package/hub/team/cli/commands/start/index.mjs +87 -0
  33. package/hub/team/cli/commands/start/parse-args.mjs +32 -0
  34. package/hub/team/cli/commands/start/start-in-process.mjs +40 -0
  35. package/hub/team/cli/commands/start/start-mux.mjs +73 -0
  36. package/hub/team/cli/commands/start/start-wt.mjs +69 -0
  37. package/hub/team/cli/commands/status.mjs +87 -0
  38. package/hub/team/cli/commands/stop.mjs +31 -0
  39. package/hub/team/cli/commands/task.mjs +30 -0
  40. package/hub/team/cli/commands/tasks.mjs +13 -0
  41. package/hub/team/{cli.mjs → cli/help.mjs} +38 -99
  42. package/hub/team/cli/index.mjs +39 -0
  43. package/hub/team/cli/manifest.mjs +28 -0
  44. package/hub/team/cli/render.mjs +30 -0
  45. package/hub/team/cli/services/attach-fallback.mjs +54 -0
  46. package/hub/team/cli/services/hub-client.mjs +171 -0
  47. package/hub/team/cli/services/member-selector.mjs +30 -0
  48. package/hub/team/cli/services/native-control.mjs +115 -0
  49. package/hub/team/cli/services/runtime-mode.mjs +60 -0
  50. package/hub/team/cli/services/state-store.mjs +34 -0
  51. package/hub/team/cli/services/task-model.mjs +30 -0
  52. package/hub/team/native-supervisor.mjs +69 -63
  53. package/hub/team/native.mjs +367 -367
  54. package/hub/team/nativeProxy.mjs +217 -173
  55. package/hub/team/pane.mjs +149 -149
  56. package/hub/team/psmux.mjs +946 -946
  57. package/hub/team/session.mjs +608 -608
  58. package/hub/team/staleState.mjs +369 -299
  59. package/hub/tools.mjs +107 -107
  60. package/hub/tray.mjs +332 -0
  61. package/hub/workers/claude-worker.mjs +446 -446
  62. package/hub/workers/codex-mcp.mjs +414 -414
  63. package/hub/workers/delegator-mcp.mjs +1045 -1045
  64. package/hub/workers/factory.mjs +21 -21
  65. package/hub/workers/gemini-worker.mjs +349 -349
  66. package/hub/workers/interface.mjs +41 -41
  67. package/package.json +61 -60
  68. package/scripts/__tests__/keyword-detector.test.mjs +234 -234
  69. package/scripts/hub-ensure.mjs +102 -101
  70. package/scripts/keyword-detector.mjs +272 -272
  71. package/scripts/keyword-rules-expander.mjs +521 -521
  72. package/scripts/lib/keyword-rules.mjs +168 -168
  73. package/scripts/lib/mcp-filter.mjs +642 -642
  74. package/scripts/lib/mcp-server-catalog.mjs +118 -118
  75. package/scripts/mcp-check.mjs +126 -126
  76. package/scripts/preflight-cache.mjs +19 -0
  77. package/scripts/run.cjs +62 -62
  78. package/scripts/setup.mjs +68 -31
  79. package/scripts/test-tfx-route-no-claude-native.mjs +57 -57
  80. package/scripts/tfx-route-worker.mjs +161 -161
  81. package/scripts/tfx-route.sh +1360 -1326
  82. package/skills/tfx-auto/SKILL.md +196 -196
  83. package/skills/tfx-auto-codex/SKILL.md +77 -77
  84. package/skills/tfx-multi/SKILL.md +378 -378
  85. package/hub/team/cli-team-common.mjs +0 -348
  86. package/hub/team/cli-team-control.mjs +0 -393
  87. package/hub/team/cli-team-start.mjs +0 -516
  88. package/hub/team/cli-team-status.mjs +0 -283
  89. package/skills/auto-verify/SKILL.md +0 -145
  90. package/skills/manage-skills/SKILL.md +0 -192
  91. package/skills/verify-implementation/SKILL.md +0 -138
@@ -1,299 +1,369 @@
1
- // hub/team/staleState.mjs
2
- // .omc/state 아래에 남은 stale team 상태를 탐지/정리한다.
3
-
4
- import { existsSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync } from "node:fs";
5
- import { execFileSync } from "node:child_process";
6
- import { dirname, join, resolve } from "node:path";
7
-
8
- export const TEAM_STATE_FILE_NAME = "team-state.json";
9
- export const STALE_TEAM_MAX_AGE_MS = 60 * 60 * 1000;
10
-
11
- function safeStat(path) {
12
- try {
13
- return statSync(path);
14
- } catch {
15
- return null;
16
- }
17
- }
18
-
19
- function parseStartedAtMs(value) {
20
- if (typeof value !== "string" || !value.trim()) return null;
21
- const parsed = Date.parse(value);
22
- return Number.isFinite(parsed) ? parsed : null;
23
- }
24
-
25
- function findPidCandidates(state) {
26
- const pidSet = new Set();
27
- const pushPid = (value) => {
28
- const pid = Number(value);
29
- if (Number.isInteger(pid) && pid > 0) pidSet.add(pid);
30
- };
31
-
32
- pushPid(state?.pid);
33
- pushPid(state?.processId);
34
- pushPid(state?.process_id);
35
- pushPid(state?.leadPid);
36
- pushPid(state?.lead_pid);
37
- pushPid(state?.native?.supervisorPid);
38
- pushPid(state?.native?.supervisor_pid);
39
-
40
- return Array.from(pidSet);
41
- }
42
-
43
- function findSessionNames(state) {
44
- const sessionNameSet = new Set();
45
- const pushName = (value) => {
46
- if (typeof value !== "string") return;
47
- const trimmed = value.trim();
48
- if (trimmed) sessionNameSet.add(trimmed);
49
- };
50
-
51
- pushName(state?.sessionName);
52
- pushName(state?.session_name);
53
- pushName(state?.native?.teamName);
54
- pushName(state?.native?.team_name);
55
-
56
- return Array.from(sessionNameSet);
57
- }
58
-
59
- function findProcessTokens(state, sessionId) {
60
- const tokenSet = new Set();
61
- const pushToken = (value) => {
62
- if (typeof value !== "string") return;
63
- const trimmed = value.trim();
64
- if (trimmed.length >= 6) tokenSet.add(trimmed.toLowerCase());
65
- };
66
-
67
- pushToken(sessionId);
68
- pushToken(state?.session_id);
69
- pushToken(state?.sessionId);
70
- pushToken(state?.teamName);
71
- pushToken(state?.team_name);
72
- pushToken(state?.name);
73
- pushToken(state?.native?.teamName);
74
- pushToken(state?.native?.team_name);
75
-
76
- return Array.from(tokenSet);
77
- }
78
-
79
- function isPidAlive(pid) {
80
- try {
81
- process.kill(pid, 0);
82
- return true;
83
- } catch {
84
- return false;
85
- }
86
- }
87
-
88
- function normalizeProcessEntries(processEntries = []) {
89
- if (!Array.isArray(processEntries)) return [];
90
-
91
- return processEntries.map((entry) => ({
92
- pid: Number(entry?.pid ?? entry?.ProcessId ?? 0),
93
- command: String(entry?.command ?? entry?.CommandLine ?? entry?.Name ?? "").toLowerCase(),
94
- }));
95
- }
96
-
97
- function readProcessEntries() {
98
- try {
99
- if (process.platform === "win32") {
100
- const raw = execFileSync(
101
- "powershell",
102
- [
103
- "-NoProfile",
104
- "-Command",
105
- "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Select-Object ProcessId,Name,CommandLine | ConvertTo-Json -Compress",
106
- ],
107
- {
108
- encoding: "utf8",
109
- timeout: 10000,
110
- stdio: ["ignore", "pipe", "ignore"],
111
- windowsHide: true,
112
- },
113
- ).trim();
114
-
115
- if (!raw) return [];
116
- const parsed = JSON.parse(raw);
117
- return normalizeProcessEntries(Array.isArray(parsed) ? parsed : [parsed]);
118
- }
119
-
120
- const raw = execFileSync("ps", ["-ax", "-o", "pid=,command="], {
121
- encoding: "utf8",
122
- timeout: 10000,
123
- stdio: ["ignore", "pipe", "ignore"],
124
- }).trim();
125
-
126
- if (!raw) return [];
127
- return raw
128
- .split(/\r?\n/)
129
- .map((line) => line.trim())
130
- .filter(Boolean)
131
- .map((line) => {
132
- const match = /^(\d+)\s+(.*)$/.exec(line);
133
- return {
134
- pid: Number(match?.[1] || 0),
135
- command: String(match?.[2] || "").toLowerCase(),
136
- };
137
- });
138
- } catch {
139
- return [];
140
- }
141
- }
142
-
143
- function resolveLiveness(state, sessionId, liveSessionNames, processEntries) {
144
- const pidCandidates = findPidCandidates(state);
145
- for (const pid of pidCandidates) {
146
- if (isPidAlive(pid)) {
147
- return { active: true, reason: `pid:${pid}` };
148
- }
149
- }
150
-
151
- const sessionNames = findSessionNames(state);
152
- for (const sessionName of sessionNames) {
153
- if (liveSessionNames.has(sessionName)) {
154
- return { active: true, reason: `session:${sessionName}` };
155
- }
156
- }
157
-
158
- const processTokens = findProcessTokens(state, sessionId);
159
- if (processTokens.length > 0) {
160
- const matched = processEntries.find((entry) => (
161
- entry.pid > 0 && processTokens.some((token) => entry.command.includes(token))
162
- ));
163
- if (matched) {
164
- return { active: true, reason: `command:${matched.pid}` };
165
- }
166
- }
167
-
168
- return { active: false, reason: "process_missing" };
169
- }
170
-
171
- function collectTeamStateTargets(stateRoot) {
172
- const targets = [];
173
- const rootStateFile = join(stateRoot, TEAM_STATE_FILE_NAME);
174
- if (existsSync(rootStateFile)) {
175
- targets.push({
176
- scope: "root",
177
- sessionId: "root",
178
- stateFile: rootStateFile,
179
- cleanupPath: rootStateFile,
180
- cleanupType: "file",
181
- });
182
- }
183
-
184
- const sessionsDir = join(stateRoot, "sessions");
185
- const sessionsStat = safeStat(sessionsDir);
186
- if (!sessionsStat?.isDirectory()) {
187
- return targets;
188
- }
189
-
190
- for (const entry of readdirSync(sessionsDir, { withFileTypes: true })) {
191
- if (!entry.isDirectory()) continue;
192
- const sessionDir = join(sessionsDir, entry.name);
193
- const stateFile = join(sessionDir, TEAM_STATE_FILE_NAME);
194
- if (!existsSync(stateFile)) continue;
195
-
196
- targets.push({
197
- scope: "session",
198
- sessionId: entry.name,
199
- stateFile,
200
- cleanupPath: sessionDir,
201
- cleanupType: "dir",
202
- });
203
- }
204
-
205
- return targets;
206
- }
207
-
208
- export function findNearestOmcStateDir(startDir = process.cwd()) {
209
- let currentDir = resolve(startDir);
210
-
211
- while (true) {
212
- const candidate = join(currentDir, ".omc", "state");
213
- const candidateStat = safeStat(candidate);
214
- if (candidateStat?.isDirectory()) {
215
- return candidate;
216
- }
217
-
218
- const parentDir = dirname(currentDir);
219
- if (parentDir === currentDir) {
220
- return null;
221
- }
222
- currentDir = parentDir;
223
- }
224
- }
225
-
226
- export function inspectStaleOmcTeams(options = {}) {
227
- const stateRoot = options.stateRoot || findNearestOmcStateDir(options.startDir || process.cwd());
228
- if (!stateRoot) {
229
- return { stateRoot: null, entries: [] };
230
- }
231
-
232
- const liveSessionNames = new Set(options.liveSessionNames || []);
233
- const processEntries = normalizeProcessEntries(options.processEntries || readProcessEntries());
234
- const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
235
- const maxAgeMs = Number.isFinite(options.maxAgeMs) ? options.maxAgeMs : STALE_TEAM_MAX_AGE_MS;
236
- const targets = collectTeamStateTargets(stateRoot);
237
- const entries = [];
238
-
239
- for (const target of targets) {
240
- let state = null;
241
- try {
242
- state = JSON.parse(readFileSync(target.stateFile, "utf8"));
243
- } catch {
244
- continue;
245
- }
246
-
247
- const fileStat = safeStat(target.stateFile);
248
- const startedAtMs = parseStartedAtMs(state?.started_at)
249
- ?? parseStartedAtMs(state?.startedAt)
250
- ?? fileStat?.mtimeMs
251
- ?? null;
252
- const ageMs = startedAtMs == null ? null : Math.max(0, nowMs - startedAtMs);
253
- const liveness = resolveLiveness(state, target.sessionId, liveSessionNames, processEntries);
254
- const stale = ageMs != null && ageMs >= maxAgeMs && !liveness.active;
255
-
256
- entries.push({
257
- ...target,
258
- teamName: state?.teamName || state?.team_name || state?.native?.teamName || state?.name || null,
259
- state,
260
- startedAtMs,
261
- ageMs,
262
- ageSec: ageMs == null ? null : Math.floor(ageMs / 1000),
263
- active: liveness.active,
264
- activeReason: liveness.reason,
265
- stale,
266
- });
267
- }
268
-
269
- return {
270
- stateRoot,
271
- entries: entries
272
- .filter((entry) => entry.stale)
273
- .sort((left, right) => (right.ageMs || 0) - (left.ageMs || 0)),
274
- };
275
- }
276
-
277
- export function cleanupStaleOmcTeams(entries = []) {
278
- let cleaned = 0;
279
- let failed = 0;
280
- const results = [];
281
-
282
- for (const entry of entries) {
283
- try {
284
- if (entry.cleanupType === "dir") {
285
- rmSync(entry.cleanupPath, { recursive: true, force: true });
286
- } else {
287
- unlinkSync(entry.cleanupPath);
288
- }
289
-
290
- cleaned += 1;
291
- results.push({ ok: true, entry });
292
- } catch (error) {
293
- failed += 1;
294
- results.push({ ok: false, entry, error });
295
- }
296
- }
297
-
298
- return { cleaned, failed, results };
299
- }
1
+ // hub/team/staleState.mjs
2
+ // .omc/state 아래에 남은 stale team 상태를 탐지/정리한다.
3
+
4
+ import { existsSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync } from "node:fs";
5
+ import { execFileSync } from "node:child_process";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { homedir } from "node:os";
8
+
9
+ import { forceCleanupTeam } from "./nativeProxy.mjs";
10
+
11
+ export const TEAM_STATE_FILE_NAME = "team-state.json";
12
+ export const STALE_TEAM_MAX_AGE_MS = 60 * 60 * 1000;
13
+ const CLAUDE_TEAMS_ROOT = join(homedir(), ".claude", "teams");
14
+
15
+ function safeStat(path) {
16
+ try {
17
+ return statSync(path);
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ function parseStartedAtMs(value) {
24
+ if (typeof value !== "string" || !value.trim()) return null;
25
+ const parsed = Date.parse(value);
26
+ return Number.isFinite(parsed) ? parsed : null;
27
+ }
28
+
29
+ function findPidCandidates(state) {
30
+ const pidSet = new Set();
31
+ const pushPid = (value) => {
32
+ const pid = Number(value);
33
+ if (Number.isInteger(pid) && pid > 0) pidSet.add(pid);
34
+ };
35
+
36
+ pushPid(state?.pid);
37
+ pushPid(state?.processId);
38
+ pushPid(state?.process_id);
39
+ pushPid(state?.leadPid);
40
+ pushPid(state?.lead_pid);
41
+ pushPid(state?.native?.supervisorPid);
42
+ pushPid(state?.native?.supervisor_pid);
43
+
44
+ return Array.from(pidSet);
45
+ }
46
+
47
+ function findSessionNames(state) {
48
+ const sessionNameSet = new Set();
49
+ const pushName = (value) => {
50
+ if (typeof value !== "string") return;
51
+ const trimmed = value.trim();
52
+ if (trimmed) sessionNameSet.add(trimmed);
53
+ };
54
+
55
+ pushName(state?.sessionName);
56
+ pushName(state?.session_name);
57
+ pushName(state?.sessionId);
58
+ pushName(state?.session_id);
59
+ pushName(state?.leadSessionId);
60
+ pushName(state?.lead_session_id);
61
+ pushName(state?.native?.teamName);
62
+ pushName(state?.native?.team_name);
63
+
64
+ return Array.from(sessionNameSet);
65
+ }
66
+
67
+ function findProcessTokens(state, sessionId) {
68
+ const tokenSet = new Set();
69
+ const pushToken = (value) => {
70
+ if (typeof value !== "string") return;
71
+ const trimmed = value.trim();
72
+ if (trimmed.length >= 6) tokenSet.add(trimmed.toLowerCase());
73
+ };
74
+
75
+ pushToken(sessionId);
76
+ pushToken(state?.session_id);
77
+ pushToken(state?.sessionId);
78
+ pushToken(state?.leadSessionId);
79
+ pushToken(state?.lead_session_id);
80
+ pushToken(state?.teamName);
81
+ pushToken(state?.team_name);
82
+ pushToken(state?.name);
83
+ pushToken(state?.native?.teamName);
84
+ pushToken(state?.native?.team_name);
85
+ if (Array.isArray(state?.members)) {
86
+ for (const member of state.members) {
87
+ pushToken(member?.agentId);
88
+ pushToken(String(member?.agentId || "").split("@")[0]);
89
+ pushToken(member?.name);
90
+ }
91
+ }
92
+
93
+ return Array.from(tokenSet);
94
+ }
95
+
96
+ function isPidAlive(pid) {
97
+ try {
98
+ process.kill(pid, 0);
99
+ return true;
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ function normalizeProcessEntries(processEntries = []) {
106
+ if (!Array.isArray(processEntries)) return [];
107
+
108
+ return processEntries.map((entry) => ({
109
+ pid: Number(entry?.pid ?? entry?.ProcessId ?? 0),
110
+ command: String(entry?.command ?? entry?.CommandLine ?? entry?.Name ?? "").toLowerCase(),
111
+ }));
112
+ }
113
+
114
+ function readProcessEntries() {
115
+ try {
116
+ if (process.platform === "win32") {
117
+ const raw = execFileSync(
118
+ "powershell",
119
+ [
120
+ "-NoProfile",
121
+ "-Command",
122
+ "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Select-Object ProcessId,Name,CommandLine | ConvertTo-Json -Compress",
123
+ ],
124
+ {
125
+ encoding: "utf8",
126
+ timeout: 10000,
127
+ stdio: ["ignore", "pipe", "ignore"],
128
+ windowsHide: true,
129
+ },
130
+ ).trim();
131
+
132
+ if (!raw) return [];
133
+ const parsed = JSON.parse(raw);
134
+ return normalizeProcessEntries(Array.isArray(parsed) ? parsed : [parsed]);
135
+ }
136
+
137
+ const raw = execFileSync("ps", ["-ax", "-o", "pid=,command="], {
138
+ encoding: "utf8",
139
+ timeout: 10000,
140
+ stdio: ["ignore", "pipe", "ignore"],
141
+ }).trim();
142
+
143
+ if (!raw) return [];
144
+ return raw
145
+ .split(/\r?\n/)
146
+ .map((line) => line.trim())
147
+ .filter(Boolean)
148
+ .map((line) => {
149
+ const match = /^(\d+)\s+(.*)$/.exec(line);
150
+ return {
151
+ pid: Number(match?.[1] || 0),
152
+ command: String(match?.[2] || "").toLowerCase(),
153
+ };
154
+ });
155
+ } catch {
156
+ return [];
157
+ }
158
+ }
159
+
160
+ function resolveLiveness(state, sessionId, liveSessionNames, processEntries) {
161
+ const pidCandidates = findPidCandidates(state);
162
+ for (const pid of pidCandidates) {
163
+ if (isPidAlive(pid)) {
164
+ return { active: true, reason: `pid:${pid}` };
165
+ }
166
+ }
167
+
168
+ const sessionNames = findSessionNames(state);
169
+ for (const sessionName of sessionNames) {
170
+ if (liveSessionNames.has(sessionName)) {
171
+ return { active: true, reason: `session:${sessionName}` };
172
+ }
173
+ }
174
+
175
+ const processTokens = findProcessTokens(state, sessionId);
176
+ if (processTokens.length > 0) {
177
+ const matched = processEntries.find((entry) => (
178
+ entry.pid > 0 && processTokens.some((token) => entry.command.includes(token))
179
+ ));
180
+ if (matched) {
181
+ return { active: true, reason: `command:${matched.pid}` };
182
+ }
183
+ }
184
+
185
+ return { active: false, reason: "process_missing" };
186
+ }
187
+
188
+ function collectTeamStateTargets(stateRoot) {
189
+ const targets = [];
190
+ const rootStateFile = join(stateRoot, TEAM_STATE_FILE_NAME);
191
+ if (existsSync(rootStateFile)) {
192
+ targets.push({
193
+ scope: "root",
194
+ sessionId: "root",
195
+ stateFile: rootStateFile,
196
+ cleanupPath: rootStateFile,
197
+ cleanupType: "file",
198
+ });
199
+ }
200
+
201
+ const sessionsDir = join(stateRoot, "sessions");
202
+ const sessionsStat = safeStat(sessionsDir);
203
+ if (!sessionsStat?.isDirectory()) {
204
+ return targets;
205
+ }
206
+
207
+ for (const entry of readdirSync(sessionsDir, { withFileTypes: true })) {
208
+ if (!entry.isDirectory()) continue;
209
+ const sessionDir = join(sessionsDir, entry.name);
210
+ const stateFile = join(sessionDir, TEAM_STATE_FILE_NAME);
211
+ if (!existsSync(stateFile)) continue;
212
+
213
+ targets.push({
214
+ scope: "session",
215
+ sessionId: entry.name,
216
+ stateFile,
217
+ cleanupPath: sessionDir,
218
+ cleanupType: "dir",
219
+ });
220
+ }
221
+
222
+ return targets;
223
+ }
224
+
225
+ function collectClaudeTeamTargets(teamsRoot) {
226
+ const teamsStat = safeStat(teamsRoot);
227
+ if (!teamsStat?.isDirectory()) {
228
+ return [];
229
+ }
230
+
231
+ const targets = [];
232
+ for (const entry of readdirSync(teamsRoot, { withFileTypes: true })) {
233
+ if (!entry.isDirectory()) continue;
234
+
235
+ const teamDir = join(teamsRoot, entry.name);
236
+ targets.push({
237
+ scope: "claude_team",
238
+ sessionId: entry.name,
239
+ stateFile: join(teamDir, "config.json"),
240
+ cleanupPath: teamDir,
241
+ cleanupType: "claude_team",
242
+ teamDir,
243
+ teamName: entry.name,
244
+ });
245
+ }
246
+
247
+ return targets;
248
+ }
249
+
250
+ export function findNearestOmcStateDir(startDir = process.cwd()) {
251
+ let currentDir = resolve(startDir);
252
+
253
+ while (true) {
254
+ const candidate = join(currentDir, ".omc", "state");
255
+ const candidateStat = safeStat(candidate);
256
+ if (candidateStat?.isDirectory()) {
257
+ return candidate;
258
+ }
259
+
260
+ const parentDir = dirname(currentDir);
261
+ if (parentDir === currentDir) {
262
+ return null;
263
+ }
264
+ currentDir = parentDir;
265
+ }
266
+ }
267
+
268
+ export function inspectStaleOmcTeams(options = {}) {
269
+ const stateRoot = options.stateRoot || findNearestOmcStateDir(options.startDir || process.cwd());
270
+ const requestedTeamsRoot = options.teamsRoot || CLAUDE_TEAMS_ROOT;
271
+ const teamsRoot = safeStat(requestedTeamsRoot)?.isDirectory() ? requestedTeamsRoot : null;
272
+
273
+ const liveSessionNames = new Set(options.liveSessionNames || []);
274
+ const processEntries = normalizeProcessEntries(options.processEntries || readProcessEntries());
275
+ const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
276
+ const maxAgeMs = Number.isFinite(options.maxAgeMs) ? options.maxAgeMs : STALE_TEAM_MAX_AGE_MS;
277
+ const targets = [
278
+ ...(stateRoot ? collectTeamStateTargets(stateRoot) : []),
279
+ ...collectClaudeTeamTargets(teamsRoot),
280
+ ];
281
+ if (!stateRoot && targets.length === 0) {
282
+ return { stateRoot: null, teamsRoot, entries: [] };
283
+ }
284
+ const entries = [];
285
+
286
+ for (const target of targets) {
287
+ let state = null;
288
+ if (target.scope === "claude_team") {
289
+ try {
290
+ state = JSON.parse(readFileSync(target.stateFile, "utf8"));
291
+ } catch {}
292
+ } else {
293
+ try {
294
+ state = JSON.parse(readFileSync(target.stateFile, "utf8"));
295
+ } catch {
296
+ continue;
297
+ }
298
+ }
299
+
300
+ const fileStat = safeStat(target.stateFile);
301
+ const teamDirStat = target.teamDir ? safeStat(target.teamDir) : null;
302
+ const createdAtMs = Number.isFinite(state?.createdAt) ? state.createdAt : null;
303
+ const startedAtMs = parseStartedAtMs(state?.started_at)
304
+ ?? parseStartedAtMs(state?.startedAt)
305
+ ?? createdAtMs
306
+ ?? fileStat?.mtimeMs
307
+ ?? teamDirStat?.mtimeMs
308
+ ?? null;
309
+ const ageMs = startedAtMs == null ? null : Math.max(0, nowMs - startedAtMs);
310
+ const teamName = state?.teamName || state?.team_name || state?.native?.teamName || state?.name || target.teamName || null;
311
+ const livenessState = target.scope === "claude_team"
312
+ ? {
313
+ ...(state || {}),
314
+ name: teamName,
315
+ teamName,
316
+ sessionName: state?.leadSessionId || state?.lead_session_id || state?.sessionName || target.sessionId,
317
+ sessionId: state?.leadSessionId || state?.lead_session_id || state?.sessionId || target.sessionId,
318
+ }
319
+ : state;
320
+ const liveness = resolveLiveness(livenessState, target.sessionId, liveSessionNames, processEntries);
321
+ const stale = ageMs != null && ageMs >= maxAgeMs && !liveness.active;
322
+
323
+ entries.push({
324
+ ...target,
325
+ teamName,
326
+ state,
327
+ startedAtMs,
328
+ ageMs,
329
+ ageSec: ageMs == null ? null : Math.floor(ageMs / 1000),
330
+ active: liveness.active,
331
+ activeReason: liveness.reason,
332
+ stale,
333
+ });
334
+ }
335
+
336
+ return {
337
+ stateRoot,
338
+ teamsRoot,
339
+ entries: entries
340
+ .filter((entry) => entry.stale)
341
+ .sort((left, right) => (right.ageMs || 0) - (left.ageMs || 0)),
342
+ };
343
+ }
344
+
345
+ export async function cleanupStaleOmcTeams(entries = []) {
346
+ let cleaned = 0;
347
+ let failed = 0;
348
+ const results = [];
349
+
350
+ for (const entry of entries) {
351
+ try {
352
+ if (entry.cleanupType === "claude_team") {
353
+ await forceCleanupTeam(entry.teamName || entry.sessionId);
354
+ } else if (entry.cleanupType === "dir") {
355
+ rmSync(entry.cleanupPath, { recursive: true, force: true });
356
+ } else {
357
+ unlinkSync(entry.cleanupPath);
358
+ }
359
+
360
+ cleaned += 1;
361
+ results.push({ ok: true, entry });
362
+ } catch (error) {
363
+ failed += 1;
364
+ results.push({ ok: false, entry, error });
365
+ }
366
+ }
367
+
368
+ return { cleaned, failed, results };
369
+ }