@triflux/remote 10.50.1 → 10.51.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.
- package/cto/brief.mjs +0 -12
- package/cto/collect.mjs +16 -247
- package/cto/current.schema.json +4 -20
- package/cto/events.mjs +13 -294
- package/cto/hygiene.mjs +26 -346
- package/cto/lake-root.mjs +114 -16
- package/cto/status.mjs +33 -128
- package/hub/index.mjs +0 -8
- package/hub/store-adapter.mjs +1 -1
- package/hub/team/agent-map.json +0 -1
- package/hub/team/ansi.mjs +1 -35
- package/hub/team/backend.mjs +9 -145
- package/hub/team/cli/commands/attach.mjs +1 -17
- package/hub/team/cli/commands/control.mjs +12 -38
- package/hub/team/cli/commands/debug.mjs +1 -37
- package/hub/team/cli/commands/focus.mjs +5 -31
- package/hub/team/cli/commands/interrupt.mjs +6 -25
- package/hub/team/cli/commands/kill.mjs +1 -30
- package/hub/team/cli/commands/list.mjs +0 -16
- package/hub/team/cli/commands/send.mjs +6 -26
- package/hub/team/cli/commands/start/index.mjs +41 -133
- package/hub/team/cli/commands/start/parse-args.mjs +15 -40
- package/hub/team/cli/commands/start/start-headless.mjs +138 -105
- package/hub/team/cli/commands/start/start-mux.mjs +0 -3
- package/hub/team/cli/commands/status.mjs +2 -57
- package/hub/team/cli/commands/stop.mjs +14 -19
- package/hub/team/cli/help.mjs +4 -5
- package/hub/team/cli/manifest.mjs +5 -1
- package/hub/team/cli/render.mjs +0 -12
- package/hub/team/cli/services/hub-client.mjs +2 -88
- package/hub/team/cli/services/runtime-mode.mjs +27 -98
- package/hub/team/handoff.mjs +3 -34
- package/hub/team/headless.mjs +14 -755
- package/hub/team/orchestrator.mjs +4 -30
- package/hub/team/pane.mjs +6 -28
- package/hub/team/psmux.mjs +16 -403
- package/hub/team/remote-session.mjs +1 -2
- package/hub/team/session.mjs +8 -119
- package/hub/team/swarm-intent.mjs +0 -198
- package/hub/team/terminal-opener.mjs +11 -35
- package/hub/team/tui-core.mjs +1 -1
- package/hub/team/tui-viewer.mjs +2 -2
- package/hub/team/tui.mjs +8 -269
- package/hub/team/wt-manager.mjs +2 -2
- package/hub/workers/factory.mjs +4 -37
- package/hub/workers/lib/jsonrpc-ws-uds.mjs +0 -2
- package/package.json +1 -1
- package/scripts/lib/agent-route-policy.mjs +162 -14
- package/scripts/lib/claudemd-scanner.mjs +2 -199
- package/scripts/lib/cli-claude.mjs +0 -2
- package/scripts/lib/context.mjs +3 -19
- package/scripts/lib/env-probe.mjs +5 -64
- package/scripts/lib/gemini-profiles.mjs +2 -69
- package/scripts/lib/legacy-mcp-cleanup.mjs +14 -3
- package/scripts/lib/machine-profile.mjs +3 -17
- package/scripts/lib/mcp-filter.mjs +109 -53
- package/scripts/lib/mcp-health.mjs +2 -2
- package/scripts/lib/mcp-server-catalog.mjs +2 -2
- package/scripts/lib/psmux-info.mjs +25 -104
- package/scripts/lib/skill-template.mjs +0 -271
- package/cto/hygiene-actions.mjs +0 -355
- package/cto/hygiene-notify.mjs +0 -170
- package/hub/team/build-worker-prompt.mjs +0 -92
- package/hub/team/check-mcp-hub.mjs +0 -49
- package/hub/team/claude-native-bridge.mjs +0 -987
- package/hub/team/cli/commands/start/start-in-process.mjs +0 -50
- package/hub/team/cli/commands/start/start-wt.mjs +0 -86
- package/hub/team/cli/services/native-control.mjs +0 -145
- package/hub/team/codex-review.mjs +0 -520
- package/hub/team/conductor-mesh-bridge.mjs +0 -122
- package/hub/team/conductor.mjs +0 -1529
- package/hub/team/consensus-meta.mjs +0 -176
- package/hub/team/cto-pull.mjs +0 -31
- package/hub/team/daemon-pty-tmux-bridge.mjs +0 -146
- package/hub/team/dashboard.mjs +0 -322
- package/hub/team/event-log.mjs +0 -84
- package/hub/team/execution-mode.mjs +0 -324
- package/hub/team/extract-completion-payload.mjs +0 -110
- package/hub/team/headless-bridge-session.mjs +0 -17
- package/hub/team/health-probe.mjs +0 -435
- package/hub/team/interactive-native-launcher.mjs +0 -119
- package/hub/team/interactive-native-worker.mjs +0 -80
- package/hub/team/interactive-tui-transport.mjs +0 -479
- package/hub/team/launcher-template.mjs +0 -144
- package/hub/team/mcp-selector.mjs +0 -145
- package/hub/team/native-supervisor.mjs +0 -460
- package/hub/team/native.mjs +0 -727
- package/hub/team/recovery-store.mjs +0 -149
- package/hub/team/remote-probe.mjs +0 -292
- package/hub/team/remote-watcher.mjs +0 -506
- package/hub/team/runtime-strategy.mjs +0 -200
- package/hub/team/schemas/worker-completion.json +0 -39
- package/hub/team/sentinel-capture.mjs +0 -127
- package/hub/team/swarm-cli.mjs +0 -390
- package/hub/team/swarm-hypervisor.mjs +0 -2206
- package/hub/team/swarm-locks.mjs +0 -335
- package/hub/team/swarm-planner.mjs +0 -371
- package/hub/team/swarm-preflight.mjs +0 -366
- package/hub/team/swarm-reconciler.mjs +0 -242
- package/hub/team/tui-remote-adapter.mjs +0 -377
- package/hub/team/worker-completion-validator.mjs +0 -79
- package/hub/team/worktree-lifecycle.mjs +0 -639
- package/hub/team/wt-templates.json +0 -43
- package/hub/workers/antigravity-route-worker.mjs +0 -179
- package/hub/workers/gemini-worker.mjs +0 -217
- package/scripts/lib/agent-json.mjs +0 -27
- package/scripts/lib/async.mjs +0 -174
- package/scripts/lib/cli-agy.mjs +0 -85
- package/scripts/lib/cli-codex.mjs +0 -62
- package/scripts/lib/cli-gemini.mjs +0 -37
- package/scripts/lib/dynamic-route-cli.mjs +0 -107
- package/scripts/lib/env.mjs +0 -14
- package/scripts/lib/hub.mjs +0 -48
- package/scripts/lib/pid.mjs +0 -63
- package/scripts/lib/quota.mjs +0 -47
- package/scripts/lib/skill-state.mjs +0 -220
- package/scripts/lib/team.mjs +0 -75
- package/scripts/lib/timeout.mjs +0 -66
- package/scripts/lib/tmp.mjs +0 -26
- package/scripts/lib/toml.mjs +0 -132
|
@@ -1,506 +0,0 @@
|
|
|
1
|
-
// hub/team/remote-watcher.mjs — tfx-spawn-* 세션 완료/입력대기 watcher
|
|
2
|
-
//
|
|
3
|
-
// 요구사항:
|
|
4
|
-
// 1) listSpawnSessions()로 tfx-spawn-* 세션 목록 조회
|
|
5
|
-
// 2) 각 세션의 pane 마지막 50줄을 10초 간격으로 폴링
|
|
6
|
-
// 3) 완료 패턴(__TRIFLUX_DONE__ 또는 프롬프트 idle) 감지
|
|
7
|
-
// 4) 완료/실패/입력대기 이벤트 emit
|
|
8
|
-
// 5) immutable status snapshot 제공
|
|
9
|
-
|
|
10
|
-
import { execFileSync } from "node:child_process";
|
|
11
|
-
import { EventEmitter } from "node:events";
|
|
12
|
-
import { detectHostOs, shellQuoteForHost } from "@triflux/core/hub/lib/ssh-command.mjs";
|
|
13
|
-
import { detectInputWait, PROBE_LEVELS } from "./health-probe.mjs";
|
|
14
|
-
|
|
15
|
-
export const REMOTE_WATCHER_STATES = Object.freeze({
|
|
16
|
-
WATCHING: "watching",
|
|
17
|
-
INPUT_WAIT: "input_wait",
|
|
18
|
-
COMPLETED: "completed",
|
|
19
|
-
FAILED: "failed",
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
export const REMOTE_WATCHER_DEFAULTS = Object.freeze({
|
|
23
|
-
captureLines: 50,
|
|
24
|
-
execTimeoutMs: 10_000,
|
|
25
|
-
intervalMs: 10_000,
|
|
26
|
-
paneSuffix: ":0.0",
|
|
27
|
-
sessionPrefix: "tfx-spawn-",
|
|
28
|
-
sshConnectTimeoutSec: 5,
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
const COMPLETION_TOKEN_RE = /__TRIFLUX_DONE__(?::([^:\r\n]+))?(?::(-?\d+))?/gu;
|
|
32
|
-
const BARE_PROMPT_RE = /^\s*(?:\u276f|\u2795|>)\s*$/u;
|
|
33
|
-
const BARE_INPUT_WAIT_PATTERN = />\s*$/.source;
|
|
34
|
-
const PROMPT_IDLE_PATTERNS = Object.freeze([
|
|
35
|
-
/^\s*PS [^\r\n>]*>\s*$/u,
|
|
36
|
-
/^\s*[a-zA-Z]:\\[^>\r\n]*>\s*$/u,
|
|
37
|
-
/^\s*[\w.-]+@[\w.-]+(?::[^\r\n]*)?[#$%]\s*$/u,
|
|
38
|
-
BARE_PROMPT_RE,
|
|
39
|
-
]);
|
|
40
|
-
|
|
41
|
-
/** @deprecated shellQuoteForHost(value, os) 사용 권장 — OS-aware 쿼팅 */
|
|
42
|
-
function _shellQuote(value, os) {
|
|
43
|
-
if (os) return shellQuoteForHost(value, os);
|
|
44
|
-
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function freezeErrorRecord(error) {
|
|
48
|
-
if (!error) return null;
|
|
49
|
-
return Object.freeze({
|
|
50
|
-
message: String(error.message || error),
|
|
51
|
-
name: error.name || "Error",
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function freezeSessionRecord(record) {
|
|
56
|
-
return Object.freeze({ ...record });
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function freezeStatus(status) {
|
|
60
|
-
const frozenSessions = Object.freeze(
|
|
61
|
-
Object.fromEntries(
|
|
62
|
-
Object.entries(status.sessions || {})
|
|
63
|
-
.sort(([left], [right]) => left.localeCompare(right))
|
|
64
|
-
.map(([sessionName, record]) => [
|
|
65
|
-
sessionName,
|
|
66
|
-
freezeSessionRecord(record),
|
|
67
|
-
]),
|
|
68
|
-
),
|
|
69
|
-
);
|
|
70
|
-
|
|
71
|
-
return Object.freeze({
|
|
72
|
-
...status,
|
|
73
|
-
lastError: freezeErrorRecord(status.lastError),
|
|
74
|
-
sessions: frozenSessions,
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function toErrorRecord(error) {
|
|
79
|
-
return {
|
|
80
|
-
message: String(error?.message || error),
|
|
81
|
-
name: error?.name || "Error",
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function getNonEmptyLines(text) {
|
|
86
|
-
return String(text || "")
|
|
87
|
-
.split(/\r?\n/u)
|
|
88
|
-
.map((line) => line.trimEnd())
|
|
89
|
-
.filter((line) => line.trim().length > 0);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function detectPromptIdle(captured) {
|
|
93
|
-
const lastLine = getNonEmptyLines(captured).at(-1) || "";
|
|
94
|
-
if (!lastLine) {
|
|
95
|
-
return { detected: false, line: "", pattern: null };
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
for (const pattern of PROMPT_IDLE_PATTERNS) {
|
|
99
|
-
if (pattern.test(lastLine)) {
|
|
100
|
-
return {
|
|
101
|
-
detected: true,
|
|
102
|
-
line: lastLine,
|
|
103
|
-
pattern: pattern.source,
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
return { detected: false, line: lastLine, pattern: null };
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function hasMeaningfulActivity(captured) {
|
|
112
|
-
const lines = getNonEmptyLines(captured);
|
|
113
|
-
if (lines.length === 0) return false;
|
|
114
|
-
|
|
115
|
-
const promptIdle = detectPromptIdle(captured);
|
|
116
|
-
if (!promptIdle.detected) return true;
|
|
117
|
-
|
|
118
|
-
return lines.slice(0, -1).some((line) => line.trim().length > 0);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function detectCompletion(captured) {
|
|
122
|
-
const matches = Array.from(
|
|
123
|
-
String(captured || "").matchAll(COMPLETION_TOKEN_RE),
|
|
124
|
-
);
|
|
125
|
-
const match = matches.at(-1);
|
|
126
|
-
if (!match) {
|
|
127
|
-
return {
|
|
128
|
-
detected: false,
|
|
129
|
-
exitCode: null,
|
|
130
|
-
match: null,
|
|
131
|
-
token: null,
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const parsedExitCode =
|
|
136
|
-
match[2] == null ? null : Number.parseInt(match[2], 10);
|
|
137
|
-
return {
|
|
138
|
-
detected: true,
|
|
139
|
-
exitCode: Number.isFinite(parsedExitCode) ? parsedExitCode : null,
|
|
140
|
-
match: match[0],
|
|
141
|
-
token: match[1] || null,
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function parseSessionNames(rawOutput, sessionPrefix) {
|
|
146
|
-
return String(rawOutput || "")
|
|
147
|
-
.split(/\r?\n/u)
|
|
148
|
-
.map((line) => line.trim())
|
|
149
|
-
.filter(Boolean)
|
|
150
|
-
.map((line) => line.split(":")[0]?.trim())
|
|
151
|
-
.filter(
|
|
152
|
-
(sessionName) =>
|
|
153
|
-
Boolean(sessionName) && sessionName.startsWith(sessionPrefix),
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function buildExecOptions(config) {
|
|
158
|
-
return {
|
|
159
|
-
encoding: "utf8",
|
|
160
|
-
timeout: config.execTimeoutMs,
|
|
161
|
-
windowsHide: true,
|
|
162
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function runPsmuxCommand(args, config) {
|
|
167
|
-
const execFn = config.deps?.execFileSync || execFileSync;
|
|
168
|
-
const execOptions = buildExecOptions(config);
|
|
169
|
-
|
|
170
|
-
if (config.host) {
|
|
171
|
-
const hostOs = detectHostOs(config.host);
|
|
172
|
-
const sshArgs = [
|
|
173
|
-
"-o",
|
|
174
|
-
`ConnectTimeout=${config.sshConnectTimeoutSec}`,
|
|
175
|
-
"-o",
|
|
176
|
-
"BatchMode=yes",
|
|
177
|
-
config.host,
|
|
178
|
-
`psmux ${args.map((a) => shellQuoteForHost(a, hostOs)).join(" ")}`,
|
|
179
|
-
];
|
|
180
|
-
return execFn("ssh", sshArgs, execOptions);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
return execFn("psmux", args, execOptions);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function buildPaneTarget(sessionName, config) {
|
|
187
|
-
return `${sessionName}${config.paneSuffix}`;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function captureSpawnSession(sessionName, config) {
|
|
191
|
-
const paneTarget = buildPaneTarget(sessionName, config);
|
|
192
|
-
const output = runPsmuxCommand(
|
|
193
|
-
["capture-pane", "-t", paneTarget, "-p", "-S", `-${config.captureLines}`],
|
|
194
|
-
config,
|
|
195
|
-
);
|
|
196
|
-
return {
|
|
197
|
-
paneTarget,
|
|
198
|
-
captured: String(output || "")
|
|
199
|
-
.replace(/\r/g, "")
|
|
200
|
-
.trimEnd(),
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
export function listSpawnSessions(opts = {}) {
|
|
205
|
-
const config = Object.freeze({
|
|
206
|
-
...REMOTE_WATCHER_DEFAULTS,
|
|
207
|
-
...opts,
|
|
208
|
-
});
|
|
209
|
-
const rawOutput = runPsmuxCommand(["list-sessions"], config);
|
|
210
|
-
return parseSessionNames(rawOutput, config.sessionPrefix);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function createSessionRecord(sessionName, config, now) {
|
|
214
|
-
return {
|
|
215
|
-
completionMatch: null,
|
|
216
|
-
exitCode: null,
|
|
217
|
-
hasActivity: false,
|
|
218
|
-
host: config.host || null,
|
|
219
|
-
inputWaitPattern: null,
|
|
220
|
-
lastEventAt: null,
|
|
221
|
-
lastOutput: "",
|
|
222
|
-
lastPollAt: null,
|
|
223
|
-
lastProbeLevel: PROBE_LEVELS.L1,
|
|
224
|
-
lastSeenAt: now,
|
|
225
|
-
paneTarget: buildPaneTarget(sessionName, config),
|
|
226
|
-
promptIdlePattern: null,
|
|
227
|
-
reason: "watching",
|
|
228
|
-
sessionName,
|
|
229
|
-
state: REMOTE_WATCHER_STATES.WATCHING,
|
|
230
|
-
};
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function isTerminalState(state) {
|
|
234
|
-
return (
|
|
235
|
-
state === REMOTE_WATCHER_STATES.COMPLETED ||
|
|
236
|
-
state === REMOTE_WATCHER_STATES.FAILED
|
|
237
|
-
);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
export function createRemoteWatcher(opts = {}) {
|
|
241
|
-
const config = Object.freeze({
|
|
242
|
-
...REMOTE_WATCHER_DEFAULTS,
|
|
243
|
-
...opts,
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
const emitter = new EventEmitter();
|
|
247
|
-
const clearIntervalFn = config.deps?.clearInterval || clearInterval;
|
|
248
|
-
const nowFn = config.deps?.now || Date.now;
|
|
249
|
-
const setIntervalFn = config.deps?.setInterval || setInterval;
|
|
250
|
-
|
|
251
|
-
let intervalHandle = null;
|
|
252
|
-
let polling = false;
|
|
253
|
-
let status = freezeStatus({
|
|
254
|
-
host: config.host || null,
|
|
255
|
-
intervalMs: config.intervalMs,
|
|
256
|
-
lastError: null,
|
|
257
|
-
lastPollAt: null,
|
|
258
|
-
running: false,
|
|
259
|
-
sessions: {},
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
function setStatus(nextStatus) {
|
|
263
|
-
status = freezeStatus(nextStatus);
|
|
264
|
-
return status;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
function emitSessionEvent(eventName, nextRecord, now) {
|
|
268
|
-
emitter.emit(
|
|
269
|
-
eventName,
|
|
270
|
-
Object.freeze({
|
|
271
|
-
exitCode: nextRecord.exitCode,
|
|
272
|
-
host: nextRecord.host,
|
|
273
|
-
inputWaitPattern: nextRecord.inputWaitPattern,
|
|
274
|
-
output: nextRecord.lastOutput,
|
|
275
|
-
paneTarget: nextRecord.paneTarget,
|
|
276
|
-
probeLevel: nextRecord.lastProbeLevel,
|
|
277
|
-
promptIdlePattern: nextRecord.promptIdlePattern,
|
|
278
|
-
reason: nextRecord.reason,
|
|
279
|
-
sessionName: nextRecord.sessionName,
|
|
280
|
-
state: nextRecord.state,
|
|
281
|
-
ts: now,
|
|
282
|
-
}),
|
|
283
|
-
);
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
function classifySession(previousRecord, captured, now) {
|
|
287
|
-
const completion = detectCompletion(captured);
|
|
288
|
-
const inputWait = detectInputWait(captured);
|
|
289
|
-
const promptIdle = detectPromptIdle(captured);
|
|
290
|
-
const hasActivity =
|
|
291
|
-
previousRecord.hasActivity || hasMeaningfulActivity(captured);
|
|
292
|
-
|
|
293
|
-
let nextState = REMOTE_WATCHER_STATES.WATCHING;
|
|
294
|
-
let reason = "watching";
|
|
295
|
-
let lastProbeLevel = PROBE_LEVELS.L1;
|
|
296
|
-
let exitCode = null;
|
|
297
|
-
let eventName = null;
|
|
298
|
-
|
|
299
|
-
if (completion.detected) {
|
|
300
|
-
exitCode = completion.exitCode;
|
|
301
|
-
lastProbeLevel = PROBE_LEVELS.L3;
|
|
302
|
-
if (completion.exitCode != null && completion.exitCode !== 0) {
|
|
303
|
-
nextState = REMOTE_WATCHER_STATES.FAILED;
|
|
304
|
-
reason = "completion_token_nonzero";
|
|
305
|
-
eventName = "sessionFailed";
|
|
306
|
-
} else {
|
|
307
|
-
nextState = REMOTE_WATCHER_STATES.COMPLETED;
|
|
308
|
-
reason = "completion_token";
|
|
309
|
-
eventName = "sessionCompleted";
|
|
310
|
-
}
|
|
311
|
-
} else if (
|
|
312
|
-
promptIdle.detected &&
|
|
313
|
-
hasActivity &&
|
|
314
|
-
(!inputWait.detected || inputWait.pattern === BARE_INPUT_WAIT_PATTERN)
|
|
315
|
-
) {
|
|
316
|
-
nextState = REMOTE_WATCHER_STATES.COMPLETED;
|
|
317
|
-
reason = "prompt_idle";
|
|
318
|
-
lastProbeLevel = PROBE_LEVELS.L3;
|
|
319
|
-
eventName = "sessionCompleted";
|
|
320
|
-
} else if (inputWait.detected) {
|
|
321
|
-
nextState = REMOTE_WATCHER_STATES.INPUT_WAIT;
|
|
322
|
-
reason = "input_wait";
|
|
323
|
-
lastProbeLevel = PROBE_LEVELS.L1;
|
|
324
|
-
if (previousRecord.state !== REMOTE_WATCHER_STATES.INPUT_WAIT) {
|
|
325
|
-
eventName = "sessionInputWait";
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
const nextRecord = {
|
|
330
|
-
...previousRecord,
|
|
331
|
-
completionMatch: completion.match,
|
|
332
|
-
exitCode,
|
|
333
|
-
hasActivity,
|
|
334
|
-
inputWaitPattern: inputWait.detected ? inputWait.pattern : null,
|
|
335
|
-
lastOutput: captured,
|
|
336
|
-
lastPollAt: now,
|
|
337
|
-
lastProbeLevel,
|
|
338
|
-
lastSeenAt: now,
|
|
339
|
-
promptIdlePattern: promptIdle.detected ? promptIdle.pattern : null,
|
|
340
|
-
reason,
|
|
341
|
-
state: nextState,
|
|
342
|
-
};
|
|
343
|
-
|
|
344
|
-
if (
|
|
345
|
-
eventName &&
|
|
346
|
-
previousRecord.state === nextRecord.state &&
|
|
347
|
-
previousRecord.reason === nextRecord.reason &&
|
|
348
|
-
previousRecord.exitCode === nextRecord.exitCode
|
|
349
|
-
) {
|
|
350
|
-
return { eventName: null, nextRecord };
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
if (eventName) {
|
|
354
|
-
nextRecord.lastEventAt = now;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
return { eventName, nextRecord };
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
function markMissingSession(previousRecord, now) {
|
|
361
|
-
if (isTerminalState(previousRecord.state)) {
|
|
362
|
-
return { eventName: null, nextRecord: previousRecord };
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
const nextRecord = {
|
|
366
|
-
...previousRecord,
|
|
367
|
-
lastEventAt: now,
|
|
368
|
-
lastPollAt: now,
|
|
369
|
-
lastProbeLevel: PROBE_LEVELS.L0,
|
|
370
|
-
reason: "session_missing",
|
|
371
|
-
state: REMOTE_WATCHER_STATES.FAILED,
|
|
372
|
-
};
|
|
373
|
-
|
|
374
|
-
return { eventName: "sessionFailed", nextRecord };
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
function pollSessions() {
|
|
378
|
-
if (polling) return;
|
|
379
|
-
polling = true;
|
|
380
|
-
|
|
381
|
-
const now = nowFn();
|
|
382
|
-
const queuedEvents = [];
|
|
383
|
-
const nextSessions = { ...status.sessions };
|
|
384
|
-
|
|
385
|
-
try {
|
|
386
|
-
const activeSessions = listSpawnSessions(config);
|
|
387
|
-
const activeSet = new Set(activeSessions);
|
|
388
|
-
|
|
389
|
-
for (const sessionName of activeSessions) {
|
|
390
|
-
const previousRecord = nextSessions[sessionName]
|
|
391
|
-
? { ...nextSessions[sessionName] }
|
|
392
|
-
: createSessionRecord(sessionName, config, now);
|
|
393
|
-
|
|
394
|
-
try {
|
|
395
|
-
const { paneTarget, captured } = captureSpawnSession(
|
|
396
|
-
sessionName,
|
|
397
|
-
config,
|
|
398
|
-
);
|
|
399
|
-
const { eventName, nextRecord } = classifySession(
|
|
400
|
-
{ ...previousRecord, paneTarget },
|
|
401
|
-
captured,
|
|
402
|
-
now,
|
|
403
|
-
);
|
|
404
|
-
nextSessions[sessionName] = nextRecord;
|
|
405
|
-
|
|
406
|
-
if (eventName) {
|
|
407
|
-
queuedEvents.push({ eventName, record: nextRecord });
|
|
408
|
-
}
|
|
409
|
-
} catch (error) {
|
|
410
|
-
const failedRecord = {
|
|
411
|
-
...previousRecord,
|
|
412
|
-
lastEventAt: now,
|
|
413
|
-
lastPollAt: now,
|
|
414
|
-
lastProbeLevel: PROBE_LEVELS.L0,
|
|
415
|
-
reason: "capture_failed",
|
|
416
|
-
state: REMOTE_WATCHER_STATES.FAILED,
|
|
417
|
-
};
|
|
418
|
-
nextSessions[sessionName] = failedRecord;
|
|
419
|
-
queuedEvents.push({
|
|
420
|
-
eventName: "sessionFailed",
|
|
421
|
-
record: failedRecord,
|
|
422
|
-
});
|
|
423
|
-
setStatus({
|
|
424
|
-
...status,
|
|
425
|
-
lastError: toErrorRecord(error),
|
|
426
|
-
lastPollAt: now,
|
|
427
|
-
running: true,
|
|
428
|
-
sessions: nextSessions,
|
|
429
|
-
});
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
for (const [sessionName, previousRecord] of Object.entries(
|
|
434
|
-
status.sessions,
|
|
435
|
-
)) {
|
|
436
|
-
if (activeSet.has(sessionName)) continue;
|
|
437
|
-
const { eventName, nextRecord } = markMissingSession(
|
|
438
|
-
previousRecord,
|
|
439
|
-
now,
|
|
440
|
-
);
|
|
441
|
-
nextSessions[sessionName] = nextRecord;
|
|
442
|
-
if (eventName) {
|
|
443
|
-
queuedEvents.push({ eventName, record: nextRecord });
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
setStatus({
|
|
448
|
-
...status,
|
|
449
|
-
lastError: null,
|
|
450
|
-
lastPollAt: now,
|
|
451
|
-
running: true,
|
|
452
|
-
sessions: nextSessions,
|
|
453
|
-
});
|
|
454
|
-
|
|
455
|
-
for (const { eventName, record } of queuedEvents) {
|
|
456
|
-
emitSessionEvent(eventName, record, now);
|
|
457
|
-
}
|
|
458
|
-
} catch (error) {
|
|
459
|
-
setStatus({
|
|
460
|
-
...status,
|
|
461
|
-
lastError: toErrorRecord(error),
|
|
462
|
-
lastPollAt: now,
|
|
463
|
-
running: true,
|
|
464
|
-
});
|
|
465
|
-
} finally {
|
|
466
|
-
polling = false;
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
function start() {
|
|
471
|
-
if (intervalHandle) return;
|
|
472
|
-
|
|
473
|
-
setStatus({
|
|
474
|
-
...status,
|
|
475
|
-
lastError: null,
|
|
476
|
-
running: true,
|
|
477
|
-
});
|
|
478
|
-
|
|
479
|
-
intervalHandle = setIntervalFn(() => {
|
|
480
|
-
pollSessions();
|
|
481
|
-
}, config.intervalMs);
|
|
482
|
-
intervalHandle?.unref?.();
|
|
483
|
-
|
|
484
|
-
pollSessions();
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
function stop() {
|
|
488
|
-
if (intervalHandle) {
|
|
489
|
-
clearIntervalFn(intervalHandle);
|
|
490
|
-
intervalHandle = null;
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
setStatus({
|
|
494
|
-
...status,
|
|
495
|
-
running: false,
|
|
496
|
-
});
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
return Object.freeze({
|
|
500
|
-
getStatus: () => status,
|
|
501
|
-
off: emitter.off.bind(emitter),
|
|
502
|
-
on: emitter.on.bind(emitter),
|
|
503
|
-
start,
|
|
504
|
-
stop,
|
|
505
|
-
});
|
|
506
|
-
}
|
|
@@ -1,200 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createPsmuxSession,
|
|
3
|
-
killPsmuxSession,
|
|
4
|
-
psmuxSessionExists,
|
|
5
|
-
} from "./psmux.mjs";
|
|
6
|
-
import { tmuxExec } from "./session.mjs";
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* @typedef {object} RuntimeStatus
|
|
10
|
-
* @property {string} name
|
|
11
|
-
* @property {string} sessionName
|
|
12
|
-
* @property {boolean} alive
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* @typedef {object} TeamRuntime
|
|
17
|
-
* @property {(sessionName: string, opts?: object) => unknown} start
|
|
18
|
-
* @property {(sessionName: string) => void} stop
|
|
19
|
-
* @property {(sessionName: string) => boolean} isAlive
|
|
20
|
-
* @property {(sessionName: string) => RuntimeStatus} getStatus
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
const defaultPsmuxAdapter = {
|
|
24
|
-
createSession: createPsmuxSession,
|
|
25
|
-
killSession: killPsmuxSession,
|
|
26
|
-
hasSession: psmuxSessionExists,
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* @param {{
|
|
31
|
-
* createSession: typeof createPsmuxSession,
|
|
32
|
-
* killSession: typeof killPsmuxSession,
|
|
33
|
-
* hasSession: typeof psmuxSessionExists,
|
|
34
|
-
* }} [adapter]
|
|
35
|
-
* @returns {TeamRuntime & { name: "psmux" }}
|
|
36
|
-
*/
|
|
37
|
-
export function createPsmuxRuntime(adapter = defaultPsmuxAdapter) {
|
|
38
|
-
return {
|
|
39
|
-
name: "psmux",
|
|
40
|
-
start(sessionName, opts = {}) {
|
|
41
|
-
return adapter.createSession(sessionName, opts);
|
|
42
|
-
},
|
|
43
|
-
stop(sessionName) {
|
|
44
|
-
adapter.killSession(sessionName);
|
|
45
|
-
},
|
|
46
|
-
isAlive(sessionName) {
|
|
47
|
-
return adapter.hasSession(sessionName);
|
|
48
|
-
},
|
|
49
|
-
getStatus(sessionName) {
|
|
50
|
-
return {
|
|
51
|
-
name: "psmux",
|
|
52
|
-
sessionName,
|
|
53
|
-
alive: adapter.hasSession(sessionName),
|
|
54
|
-
};
|
|
55
|
-
},
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// ---------------------------------------------------------------------------
|
|
60
|
-
// tmux 어댑터
|
|
61
|
-
// ---------------------------------------------------------------------------
|
|
62
|
-
|
|
63
|
-
// tmux 세션 이름은 tmuxExec 명령 문자열에 직접 보간되므로 shell 메타문자가 들어오면 위험.
|
|
64
|
-
// 현 호출처는 모두 safe identifier (slug/uuid 류) 이지만 어댑터 export 가 contract 를
|
|
65
|
-
// 강제하지 않아 외부 호출자가 임의 문자열을 주입할 수 있다 → 보수적으로 검증.
|
|
66
|
-
const TMUX_SAFE_SESSION_NAME = /^[A-Za-z0-9_.:-]+$/;
|
|
67
|
-
|
|
68
|
-
function assertSafeTmuxSessionName(sessionName) {
|
|
69
|
-
const value = String(sessionName ?? "");
|
|
70
|
-
if (!TMUX_SAFE_SESSION_NAME.test(value)) {
|
|
71
|
-
throw new Error(
|
|
72
|
-
`Unsafe tmux session name: ${JSON.stringify(value)} (allowed: A-Za-z0-9_.:-)`,
|
|
73
|
-
);
|
|
74
|
-
}
|
|
75
|
-
return value;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function tmuxSessionExists(sessionName) {
|
|
79
|
-
const safe = assertSafeTmuxSessionName(sessionName);
|
|
80
|
-
try {
|
|
81
|
-
tmuxExec(`has-session -t ${safe}`);
|
|
82
|
-
return true;
|
|
83
|
-
} catch {
|
|
84
|
-
return false;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function createTmuxSession(sessionName, opts = {}) {
|
|
89
|
-
const safe = assertSafeTmuxSessionName(sessionName);
|
|
90
|
-
tmuxExec(`new-session -d -s ${safe} -x 220 -y 55`);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function sendPromptToTmuxSession(sessionName, prompt) {
|
|
94
|
-
const safe = assertSafeTmuxSessionName(sessionName);
|
|
95
|
-
const quotedPrompt = String(prompt).replace(/'/g, "'\\''");
|
|
96
|
-
tmuxExec(`send-keys -t ${safe}:0.0 '${quotedPrompt}' Enter`);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function killTmuxSessionByName(sessionName) {
|
|
100
|
-
let safe;
|
|
101
|
-
try {
|
|
102
|
-
safe = assertSafeTmuxSessionName(sessionName);
|
|
103
|
-
} catch {
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
try {
|
|
107
|
-
tmuxExec(`kill-session -t ${safe}`);
|
|
108
|
-
} catch {
|
|
109
|
-
// 이미 종료된 세션 — 무시
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const defaultTmuxAdapter = {
|
|
114
|
-
createSession: createTmuxSession,
|
|
115
|
-
sendPrompt: sendPromptToTmuxSession,
|
|
116
|
-
killSession: killTmuxSessionByName,
|
|
117
|
-
hasSession: tmuxSessionExists,
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* @param {{
|
|
122
|
-
* createSession: typeof createTmuxSession,
|
|
123
|
-
* sendPrompt: typeof sendPromptToTmuxSession,
|
|
124
|
-
* killSession: typeof killTmuxSessionByName,
|
|
125
|
-
* hasSession: typeof tmuxSessionExists,
|
|
126
|
-
* }} [opts.adapter]
|
|
127
|
-
* @param {string} [opts.sessionName]
|
|
128
|
-
* @returns {{
|
|
129
|
-
* name: "tmux",
|
|
130
|
-
* kind: "tmux",
|
|
131
|
-
* sessionName: string,
|
|
132
|
-
* createSession: (opts?: object) => unknown,
|
|
133
|
-
* sendPrompt: (prompt: string) => unknown,
|
|
134
|
-
* start: (sessionName: string, opts?: object) => unknown,
|
|
135
|
-
* stop: (sessionName: string) => void,
|
|
136
|
-
* isAlive: (sessionName: string) => boolean,
|
|
137
|
-
* getStatus: (sessionName: string) => RuntimeStatus,
|
|
138
|
-
* }}
|
|
139
|
-
*/
|
|
140
|
-
export function createTmuxRuntime(opts = {}) {
|
|
141
|
-
const { adapter = defaultTmuxAdapter, sessionName = "" } = opts;
|
|
142
|
-
|
|
143
|
-
return {
|
|
144
|
-
name: "tmux",
|
|
145
|
-
kind: "tmux",
|
|
146
|
-
sessionName,
|
|
147
|
-
// start() 와 동일하게 첫 인자로 sessionName override 를 받는다.
|
|
148
|
-
// 첫 인자가 string 이 아니면 (옵션 객체 등) closure sessionName 을 사용하는 legacy 형태로 폴백.
|
|
149
|
-
createSession(sessionNameArg, opts) {
|
|
150
|
-
if (typeof sessionNameArg === "string") {
|
|
151
|
-
return adapter.createSession(sessionNameArg, opts ?? {});
|
|
152
|
-
}
|
|
153
|
-
return adapter.createSession(sessionName, sessionNameArg ?? {});
|
|
154
|
-
},
|
|
155
|
-
sendPrompt(prompt, sessionNameArg = sessionName) {
|
|
156
|
-
return adapter.sendPrompt(sessionNameArg, prompt);
|
|
157
|
-
},
|
|
158
|
-
start(sessionNameArg = sessionName, opts = {}) {
|
|
159
|
-
return adapter.createSession(sessionNameArg, opts);
|
|
160
|
-
},
|
|
161
|
-
stop(sessionNameArg = sessionName) {
|
|
162
|
-
adapter.killSession(sessionNameArg);
|
|
163
|
-
},
|
|
164
|
-
isAlive(sessionNameArg = sessionName) {
|
|
165
|
-
return adapter.hasSession(sessionNameArg);
|
|
166
|
-
},
|
|
167
|
-
getStatus(sessionNameArg = sessionName) {
|
|
168
|
-
return {
|
|
169
|
-
name: "tmux",
|
|
170
|
-
sessionName: sessionNameArg,
|
|
171
|
-
alive: adapter.hasSession(sessionNameArg),
|
|
172
|
-
};
|
|
173
|
-
},
|
|
174
|
-
};
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* @param {string} mode
|
|
179
|
-
* @param {object} [opts]
|
|
180
|
-
* @returns {TeamRuntime & { name: string }}
|
|
181
|
-
*/
|
|
182
|
-
export function createRuntime(mode, opts = {}) {
|
|
183
|
-
const normalizedMode = String(mode || "")
|
|
184
|
-
.trim()
|
|
185
|
-
.toLowerCase();
|
|
186
|
-
|
|
187
|
-
if (normalizedMode === "psmux") {
|
|
188
|
-
return createPsmuxRuntime();
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
if (normalizedMode === "tmux") {
|
|
192
|
-
return createTmuxRuntime(opts);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
if (normalizedMode === "native" || normalizedMode === "wt") {
|
|
196
|
-
throw new Error(`Runtime mode "${normalizedMode}" is not implemented yet.`);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
throw new Error(`Unsupported runtime mode: ${mode}`);
|
|
200
|
-
}
|