@triflux/remote 10.1.0 → 10.26.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/hub/pipe.mjs +239 -121
- package/hub/public/dashboard.html +61 -0
- package/hub/server.mjs +2179 -709
- package/hub/store-adapter.mjs +59 -26
- package/hub/store.mjs +356 -153
- package/hub/team/agent-map.json +26 -7
- package/hub/team/ansi.mjs +245 -146
- package/hub/team/backend.mjs +78 -17
- package/hub/team/build-worker-prompt.mjs +73 -0
- package/hub/team/check-mcp-hub.mjs +49 -0
- package/hub/team/claude-daemon-control.mjs +1130 -0
- package/hub/team/claude-native-bridge.mjs +1011 -0
- package/hub/team/claude-session-projection.mjs +74 -0
- package/hub/team/cli/commands/attach.mjs +57 -37
- package/hub/team/cli/commands/control.mjs +29 -8
- package/hub/team/cli/commands/debug.mjs +95 -74
- package/hub/team/cli/commands/focus.mjs +80 -53
- package/hub/team/cli/commands/interrupt.mjs +18 -6
- package/hub/team/cli/commands/kill.mjs +16 -5
- package/hub/team/cli/commands/list.mjs +31 -24
- package/hub/team/cli/commands/send.mjs +19 -6
- package/hub/team/cli/commands/start/index.mjs +151 -33
- package/hub/team/cli/commands/start/parse-args.mjs +97 -13
- package/hub/team/cli/commands/start/start-headless.mjs +155 -37
- package/hub/team/cli/commands/start/start-in-process.mjs +50 -40
- package/hub/team/cli/commands/start/start-mux.mjs +122 -73
- package/hub/team/cli/commands/start/start-wt.mjs +86 -69
- package/hub/team/cli/commands/status.mjs +43 -14
- package/hub/team/cli/commands/stop.mjs +11 -4
- package/hub/team/cli/commands/task.mjs +8 -3
- package/hub/team/cli/commands/tasks.mjs +13 -13
- package/hub/team/cli/help.mjs +1 -1
- package/hub/team/cli/index.mjs +2 -2
- package/hub/team/cli/manifest.mjs +38 -8
- package/hub/team/cli/render.mjs +52 -30
- package/hub/team/cli/services/attach-fallback.mjs +74 -54
- package/hub/team/cli/services/hub-client.mjs +44 -16
- package/hub/team/cli/services/member-selector.mjs +37 -30
- package/hub/team/cli/services/native-control.mjs +144 -116
- package/hub/team/cli/services/runtime-mode.mjs +61 -4
- package/hub/team/cli/services/state-store.mjs +66 -9
- package/hub/team/cli/services/task-model.mjs +40 -30
- package/hub/team/codex-review.mjs +521 -0
- package/hub/team/conductor-mesh-bridge.mjs +25 -24
- package/hub/team/conductor-registry.mjs +121 -0
- package/hub/team/conductor.mjs +1379 -671
- package/hub/team/consensus-meta.mjs +176 -0
- package/hub/team/dashboard-anchor.mjs +4 -5
- package/hub/team/dashboard-layout.mjs +3 -1
- package/hub/team/dashboard-open.mjs +20 -118
- package/hub/team/dashboard.mjs +76 -28
- package/hub/team/event-log.mjs +18 -10
- package/hub/team/execution-mode.mjs +256 -0
- package/hub/team/extract-completion-payload.mjs +110 -0
- package/hub/team/git-preflight.mjs +286 -0
- package/hub/team/handoff.mjs +49 -18
- package/hub/team/headless-bridge-session.mjs +17 -0
- package/hub/team/headless.mjs +2213 -1156
- package/hub/team/health-probe.mjs +232 -69
- package/hub/team/interactive-native-launcher.mjs +106 -0
- package/hub/team/interactive-native-worker.mjs +80 -0
- package/hub/team/interactive-tui-transport.mjs +465 -0
- package/hub/team/launcher-template.mjs +49 -17
- package/hub/team/lead-control.mjs +91 -4
- package/hub/team/mcp-selector.mjs +145 -0
- package/hub/team/native-supervisor.mjs +90 -26
- package/hub/team/native.mjs +120 -42
- package/hub/team/nativeProxy.mjs +184 -113
- package/hub/team/notify.mjs +147 -78
- package/hub/team/orchestrator.mjs +173 -161
- package/hub/team/pane.mjs +43 -20
- package/hub/team/process-cleanup.mjs +69 -25
- package/hub/team/psmux.mjs +1022 -281
- package/hub/team/recovery-store.mjs +149 -0
- package/hub/team/remote-probe.mjs +68 -52
- package/hub/team/remote-session.mjs +131 -59
- package/hub/team/remote-watcher.mjs +61 -33
- package/hub/team/retry-state-machine.mjs +336 -0
- package/hub/team/routing.mjs +51 -25
- package/hub/team/runtime-strategy.mjs +200 -0
- package/hub/team/schemas/worker-completion.json +39 -0
- package/hub/team/sentinel-capture.mjs +127 -0
- package/hub/team/session-sync.mjs +153 -6
- package/hub/team/session.mjs +574 -611
- package/hub/team/shared.mjs +13 -13
- package/hub/team/staleState.mjs +72 -30
- package/hub/team/swarm-cli.mjs +390 -0
- package/hub/team/swarm-hypervisor.mjs +1544 -193
- package/hub/team/swarm-intent.mjs +216 -0
- package/hub/team/swarm-locks.mjs +140 -25
- package/hub/team/swarm-planner.mjs +121 -21
- package/hub/team/swarm-preflight.mjs +319 -0
- package/hub/team/swarm-reconciler.mjs +128 -23
- package/hub/team/synapse-cli.mjs +458 -0
- package/hub/team/synapse-http.mjs +60 -0
- package/hub/team/synapse-registry.mjs +329 -0
- package/hub/team/terminal-opener.mjs +216 -0
- package/hub/team/tui-core.mjs +292 -0
- package/hub/team/tui-lite.mjs +237 -148
- package/hub/team/tui-remote-adapter.mjs +18 -34
- package/hub/team/tui-synapse.mjs +223 -0
- package/hub/team/tui-viewer.mjs +108 -48
- package/hub/team/tui-widgets.mjs +262 -0
- package/hub/team/tui.mjs +931 -439
- package/hub/team/worker-completion-validator.mjs +84 -0
- package/hub/team/worker-sandbox.mjs +97 -0
- package/hub/team/worker-signal.mjs +263 -0
- package/hub/team/worker-signal.types.d.ts +52 -0
- package/hub/team/worktree-lifecycle.mjs +517 -63
- package/hub/team/wt-manager.mjs +297 -26
- package/hub/tools.mjs +477 -252
- package/hub/tray.mjs +411 -368
- package/hub/workers/antigravity-route-worker.mjs +169 -0
- package/hub/workers/claude-worker.mjs +162 -118
- package/hub/workers/codex-app-server-worker.mjs +1015 -0
- package/hub/workers/codex-mcp.mjs +724 -507
- package/hub/workers/delegator-mcp.mjs +570 -499
- package/hub/workers/factory.mjs +103 -21
- package/hub/workers/gemini-worker.mjs +126 -284
- package/hub/workers/interface.mjs +24 -2
- package/hub/workers/lib/jsonrpc-stdio.mjs +463 -0
- package/hub/workers/worker-utils.mjs +23 -17
- package/package.json +4 -3
- package/scripts/lib/agent-json.mjs +27 -0
- package/scripts/lib/async.mjs +174 -0
- package/scripts/lib/claudemd-scanner.mjs +223 -0
- package/scripts/lib/cli-agy.mjs +62 -0
- package/scripts/lib/cli-claude.mjs +78 -0
- package/scripts/lib/cli-codex.mjs +199 -0
- package/scripts/lib/cli-gemini.mjs +37 -0
- package/scripts/lib/codex-recovery.sh +50 -0
- package/scripts/lib/context.mjs +67 -0
- package/scripts/lib/cross-review-utils.mjs +59 -0
- package/scripts/lib/doctor-env-checks.mjs +121 -0
- package/scripts/lib/dynamic-route-cli.mjs +107 -0
- package/scripts/lib/env-probe.mjs +269 -0
- package/scripts/lib/env.mjs +14 -0
- package/scripts/lib/gemini-profiles.mjs +119 -0
- package/scripts/lib/handoff.mjs +187 -0
- package/scripts/lib/hook-utils.mjs +16 -0
- package/scripts/lib/hub.mjs +48 -0
- package/scripts/lib/keyword-rules.mjs +195 -0
- package/scripts/lib/logger.mjs +105 -0
- package/scripts/lib/mcp-filter.mjs +883 -0
- package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
- package/scripts/lib/mcp-gateway-servers.mjs +70 -0
- package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
- package/scripts/lib/mcp-guard-engine.mjs +2120 -0
- package/scripts/lib/mcp-health.mjs +773 -0
- package/scripts/lib/mcp-manifest.mjs +89 -0
- package/scripts/lib/mcp-server-catalog.mjs +355 -0
- package/scripts/lib/pid.mjs +63 -0
- package/scripts/lib/process-utils.mjs +26 -0
- package/scripts/lib/psmux-info.mjs +224 -0
- package/scripts/lib/quota.mjs +47 -0
- package/scripts/lib/remote-spawn-transfer.mjs +226 -0
- package/scripts/lib/skill-state.mjs +220 -0
- package/scripts/lib/skill-template.mjs +375 -0
- package/scripts/lib/team.mjs +75 -0
- package/scripts/lib/timeout.mjs +66 -0
- package/scripts/lib/tmp.mjs +26 -0
- package/scripts/lib/toml.mjs +113 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export const PSMUX_RECOMMENDED_VERSION = "3.3.1";
|
|
4
|
+
export const PSMUX_REQUIRED_COMMANDS = [
|
|
5
|
+
"new-session",
|
|
6
|
+
"attach-session",
|
|
7
|
+
"kill-session",
|
|
8
|
+
"capture-pane",
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
export const PSMUX_OPTIONAL_COMMANDS = ["detach-client"];
|
|
12
|
+
|
|
13
|
+
// Windows 패키지 매니저 — winget/scoop/choco 는 Windows 전용
|
|
14
|
+
export const PSMUX_INSTALL_COMMANDS = [
|
|
15
|
+
"winget install psmux",
|
|
16
|
+
"scoop install psmux",
|
|
17
|
+
"choco install psmux",
|
|
18
|
+
"cargo install psmux",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export const PSMUX_UPDATE_COMMANDS = [
|
|
22
|
+
"winget upgrade psmux",
|
|
23
|
+
"scoop update psmux",
|
|
24
|
+
"choco upgrade psmux",
|
|
25
|
+
"cargo install psmux --force",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// macOS 전용 — brew 가 표준, cargo 는 cross-platform fallback
|
|
29
|
+
export const PSMUX_INSTALL_COMMANDS_DARWIN = [
|
|
30
|
+
"brew install psmux",
|
|
31
|
+
"cargo install psmux",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export const PSMUX_UPDATE_COMMANDS_DARWIN = [
|
|
35
|
+
"brew upgrade psmux",
|
|
36
|
+
"cargo install psmux --force",
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// Linux 전용 — cargo 만 cross-platform 지원, distro 패키지는 미제공
|
|
40
|
+
export const PSMUX_INSTALL_COMMANDS_LINUX = ["cargo install psmux"];
|
|
41
|
+
|
|
42
|
+
export const PSMUX_UPDATE_COMMANDS_LINUX = ["cargo install psmux --force"];
|
|
43
|
+
|
|
44
|
+
// PR #258 OS-primary 분기 정책: macOS/Linux 는 tmux 가 POSIX 표준 primary,
|
|
45
|
+
// psmux 는 선택형이다. 따라서 macOS/Linux 에서 "psmux 미설치"는 fallback 사유가
|
|
46
|
+
// 아니며, tmux 까지 없을 때만 native/in-process fallback 을 고려한다.
|
|
47
|
+
export const PSMUX_FALLBACK_NOTE_DARWIN =
|
|
48
|
+
"macOS 에서는 tmux 가 표준 멀티플렉서이며, " +
|
|
49
|
+
"psmux 미설치는 fallback 사유가 아닙니다. tmux 도 없을 때만 in-process/native fallback 을 사용합니다.";
|
|
50
|
+
|
|
51
|
+
export const PSMUX_FALLBACK_NOTE_LINUX =
|
|
52
|
+
"Linux 에서는 tmux 가 표준 멀티플렉서이며, " +
|
|
53
|
+
"psmux 미설치는 fallback 사유가 아닙니다. tmux 도 없을 때만 in-process/native fallback 을 사용합니다.";
|
|
54
|
+
|
|
55
|
+
export function getPsmuxInstallCommandsFor(platform = process.platform) {
|
|
56
|
+
if (platform === "darwin") return PSMUX_INSTALL_COMMANDS_DARWIN;
|
|
57
|
+
if (platform === "linux") return PSMUX_INSTALL_COMMANDS_LINUX;
|
|
58
|
+
// win32 또는 기타 (BSD 등) → Windows 풀 리스트로 폴백 (변경 전 동작 유지)
|
|
59
|
+
return PSMUX_INSTALL_COMMANDS;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getPsmuxUpdateCommandsFor(platform = process.platform) {
|
|
63
|
+
if (platform === "darwin") return PSMUX_UPDATE_COMMANDS_DARWIN;
|
|
64
|
+
if (platform === "linux") return PSMUX_UPDATE_COMMANDS_LINUX;
|
|
65
|
+
return PSMUX_UPDATE_COMMANDS;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function getPsmuxFallbackNoteFor(platform = process.platform) {
|
|
69
|
+
if (platform === "darwin") return PSMUX_FALLBACK_NOTE_DARWIN;
|
|
70
|
+
if (platform === "linux") return PSMUX_FALLBACK_NOTE_LINUX;
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function formatPsmuxCommandList(
|
|
75
|
+
commands = PSMUX_INSTALL_COMMANDS,
|
|
76
|
+
indent = "",
|
|
77
|
+
) {
|
|
78
|
+
return commands.map((command) => `${indent}${command}`).join("\n");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function formatPsmuxInstallGuidance(
|
|
82
|
+
indent = "",
|
|
83
|
+
platform = process.platform,
|
|
84
|
+
) {
|
|
85
|
+
const commands = getPsmuxInstallCommandsFor(platform);
|
|
86
|
+
const list = formatPsmuxCommandList(commands, indent);
|
|
87
|
+
const note = getPsmuxFallbackNoteFor(platform);
|
|
88
|
+
return note ? `${list}\n${indent}(${note})` : list;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function formatPsmuxUpdateGuidance(
|
|
92
|
+
indent = "",
|
|
93
|
+
platform = process.platform,
|
|
94
|
+
) {
|
|
95
|
+
return formatPsmuxCommandList(getPsmuxUpdateCommandsFor(platform), indent);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function parsePsmuxVersion(output = "") {
|
|
99
|
+
const match = String(output).match(/psmux\s+v?(\d+\.\d+\.\d+)/i);
|
|
100
|
+
return match?.[1] || null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function compareSemver(a, b) {
|
|
104
|
+
const left = String(a || "")
|
|
105
|
+
.split(".")
|
|
106
|
+
.map((part) => Number.parseInt(part, 10) || 0);
|
|
107
|
+
const right = String(b || "")
|
|
108
|
+
.split(".")
|
|
109
|
+
.map((part) => Number.parseInt(part, 10) || 0);
|
|
110
|
+
for (let index = 0; index < 3; index += 1) {
|
|
111
|
+
if (left[index] > right[index]) return 1;
|
|
112
|
+
if (left[index] < right[index]) return -1;
|
|
113
|
+
}
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function isRecommendedPsmuxVersion(version) {
|
|
118
|
+
if (!version) return false;
|
|
119
|
+
return compareSemver(version, PSMUX_RECOMMENDED_VERSION) >= 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function probePsmuxSupport(options = {}) {
|
|
123
|
+
const execFileSyncFn = options.execFileSyncFn || execFileSync;
|
|
124
|
+
const bin = options.bin || "psmux";
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const versionOutput = execFileSyncFn(bin, ["-V"], {
|
|
128
|
+
encoding: "utf8",
|
|
129
|
+
timeout: 2000,
|
|
130
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
131
|
+
windowsHide: true,
|
|
132
|
+
});
|
|
133
|
+
const version = parsePsmuxVersion(versionOutput);
|
|
134
|
+
|
|
135
|
+
let helpOutput = "";
|
|
136
|
+
try {
|
|
137
|
+
helpOutput = execFileSyncFn(bin, ["--help"], {
|
|
138
|
+
encoding: "utf8",
|
|
139
|
+
timeout: 2000,
|
|
140
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
141
|
+
windowsHide: true,
|
|
142
|
+
});
|
|
143
|
+
} catch {
|
|
144
|
+
helpOutput = "";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const missingCommands = PSMUX_REQUIRED_COMMANDS.filter(
|
|
148
|
+
(command) => !helpOutput?.includes(command),
|
|
149
|
+
);
|
|
150
|
+
const missingOptionalCommands = PSMUX_OPTIONAL_COMMANDS.filter(
|
|
151
|
+
(command) => !helpOutput?.includes(command),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
ok: missingCommands.length === 0,
|
|
156
|
+
installed: true,
|
|
157
|
+
version,
|
|
158
|
+
recommendedVersion: PSMUX_RECOMMENDED_VERSION,
|
|
159
|
+
recommended: isRecommendedPsmuxVersion(version),
|
|
160
|
+
missingCommands,
|
|
161
|
+
missingOptionalCommands,
|
|
162
|
+
hasHelp: helpOutput.length > 0,
|
|
163
|
+
installHint: formatPsmuxInstallGuidance(" "),
|
|
164
|
+
updateHint: formatPsmuxUpdateGuidance(" "),
|
|
165
|
+
};
|
|
166
|
+
} catch {
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
installed: false,
|
|
170
|
+
version: null,
|
|
171
|
+
recommendedVersion: PSMUX_RECOMMENDED_VERSION,
|
|
172
|
+
recommended: false,
|
|
173
|
+
missingCommands: [...PSMUX_REQUIRED_COMMANDS],
|
|
174
|
+
missingOptionalCommands: [...PSMUX_OPTIONAL_COMMANDS],
|
|
175
|
+
hasHelp: false,
|
|
176
|
+
installHint: formatPsmuxInstallGuidance(" "),
|
|
177
|
+
updateHint: formatPsmuxUpdateGuidance(" "),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function probePrimaryMultiplexerSupport(options = {}) {
|
|
183
|
+
const platform = options.platform || process.platform;
|
|
184
|
+
const execFileSyncFn = options.execFileSyncFn || execFileSync;
|
|
185
|
+
|
|
186
|
+
if (platform === "win32") {
|
|
187
|
+
return {
|
|
188
|
+
kind: "psmux",
|
|
189
|
+
...probePsmuxSupport(options),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const bin = options.bin || "tmux";
|
|
194
|
+
try {
|
|
195
|
+
const versionOutput = execFileSyncFn(bin, ["-V"], {
|
|
196
|
+
encoding: "utf8",
|
|
197
|
+
timeout: 2000,
|
|
198
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
199
|
+
windowsHide: true,
|
|
200
|
+
});
|
|
201
|
+
return {
|
|
202
|
+
ok: true,
|
|
203
|
+
installed: true,
|
|
204
|
+
kind: "tmux",
|
|
205
|
+
version: String(versionOutput || "").trim() || null,
|
|
206
|
+
recommendedVersion: null,
|
|
207
|
+
recommended: true,
|
|
208
|
+
missingCommands: [],
|
|
209
|
+
missingOptionalCommands: [],
|
|
210
|
+
};
|
|
211
|
+
} catch (error) {
|
|
212
|
+
return {
|
|
213
|
+
ok: false,
|
|
214
|
+
installed: false,
|
|
215
|
+
kind: "tmux",
|
|
216
|
+
version: null,
|
|
217
|
+
recommendedVersion: null,
|
|
218
|
+
recommended: false,
|
|
219
|
+
missingCommands: ["tmux"],
|
|
220
|
+
missingOptionalCommands: [],
|
|
221
|
+
error: error?.message || String(error),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export const QUOTA_PATTERNS = [
|
|
2
|
+
/usage limit exceeded/i,
|
|
3
|
+
/rate limit exceeded/i,
|
|
4
|
+
/rate limit reached/i,
|
|
5
|
+
/try again at/i,
|
|
6
|
+
/purchase more credits/i,
|
|
7
|
+
/quota exceeded/i,
|
|
8
|
+
/RESOURCE_EXHAUSTED/i,
|
|
9
|
+
/rateLimitExceeded/i,
|
|
10
|
+
/Too Many Requests/i,
|
|
11
|
+
/rate_limit_error/i,
|
|
12
|
+
/overloaded_error/i,
|
|
13
|
+
/insufficient_quota/i,
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export class QuotaStreamBuffer {
|
|
17
|
+
constructor({ maxBytes = 65536 } = {}) {
|
|
18
|
+
this.maxBytes = maxBytes;
|
|
19
|
+
this.text = "";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
push(chunk) {
|
|
23
|
+
this.text += Buffer.isBuffer(chunk)
|
|
24
|
+
? chunk.toString("utf8")
|
|
25
|
+
: String(chunk);
|
|
26
|
+
if (this.text.length > this.maxBytes) {
|
|
27
|
+
this.text = this.text.slice(-this.maxBytes);
|
|
28
|
+
}
|
|
29
|
+
return detectQuotaText(this.text);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function detectQuotaText(text = "") {
|
|
34
|
+
return QUOTA_PATTERNS.some((pattern) => pattern.test(text));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getRerouteTarget(cliType) {
|
|
38
|
+
switch (cliType) {
|
|
39
|
+
case "codex":
|
|
40
|
+
case "gemini":
|
|
41
|
+
return "antigravity";
|
|
42
|
+
case "antigravity":
|
|
43
|
+
return "codex";
|
|
44
|
+
default:
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "fs";
|
|
2
|
+
import { basename, dirname, isAbsolute, resolve } from "path";
|
|
3
|
+
|
|
4
|
+
const URL_LIKE_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//u;
|
|
5
|
+
const WINDOWS_ABS_RE = /^[a-zA-Z]:[\\/]/u;
|
|
6
|
+
|
|
7
|
+
function normalizeMarkdownTarget(raw) {
|
|
8
|
+
const trimmed = String(raw || "").trim();
|
|
9
|
+
const match = /^<?([^>\s]+)>?(?:\s+["'][^"']*["'])?$/u.exec(trimmed);
|
|
10
|
+
return match ? match[1] : trimmed;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function sanitizeToken(raw) {
|
|
14
|
+
let token = String(raw || "").trim();
|
|
15
|
+
if (!token) return "";
|
|
16
|
+
token = token
|
|
17
|
+
.replace(/^[<(]+/u, "")
|
|
18
|
+
.replace(/[)>.,;:!?]+$/u, "")
|
|
19
|
+
.trim();
|
|
20
|
+
return token;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isLikelyPathToken(token) {
|
|
24
|
+
if (!token || URL_LIKE_RE.test(token)) return false;
|
|
25
|
+
if (
|
|
26
|
+
token.startsWith("app://") ||
|
|
27
|
+
token.startsWith("plugin://") ||
|
|
28
|
+
token.startsWith("mention://")
|
|
29
|
+
)
|
|
30
|
+
return false;
|
|
31
|
+
if (token === "~") return false;
|
|
32
|
+
|
|
33
|
+
const hasPathSeparator = token.includes("/") || token.includes("\\");
|
|
34
|
+
const hasDotPrefix =
|
|
35
|
+
token.startsWith("./") || token.startsWith("../") || token.startsWith(".");
|
|
36
|
+
const isHomeRelative = token.startsWith("~/") || token.startsWith("~\\");
|
|
37
|
+
const isWindowsAbs = WINDOWS_ABS_RE.test(token);
|
|
38
|
+
const hasFileLikeSuffix = /\.[a-zA-Z0-9]{1,16}$/u.test(token);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
hasPathSeparator ||
|
|
42
|
+
hasDotPrefix ||
|
|
43
|
+
isHomeRelative ||
|
|
44
|
+
isWindowsAbs ||
|
|
45
|
+
hasFileLikeSuffix
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function extractExplicitFileTokens(text) {
|
|
50
|
+
const content = String(text || "");
|
|
51
|
+
const candidates = [];
|
|
52
|
+
|
|
53
|
+
for (const match of content.matchAll(/\[[^\]]+\]\(([^)\n]+)\)/gu)) {
|
|
54
|
+
candidates.push(normalizeMarkdownTarget(match[1]));
|
|
55
|
+
}
|
|
56
|
+
for (const match of content.matchAll(/`([^`\n]+)`/gu)) {
|
|
57
|
+
candidates.push(match[1]);
|
|
58
|
+
}
|
|
59
|
+
for (const match of content.matchAll(/"([^"\n]+)"/gu)) {
|
|
60
|
+
candidates.push(match[1]);
|
|
61
|
+
}
|
|
62
|
+
for (const match of content.matchAll(/'([^'\n]+)'/gu)) {
|
|
63
|
+
candidates.push(match[1]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const unique = new Set();
|
|
67
|
+
for (const raw of candidates) {
|
|
68
|
+
const token = sanitizeToken(raw);
|
|
69
|
+
if (!isLikelyPathToken(token)) continue;
|
|
70
|
+
unique.add(token);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return Array.from(unique);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function validateTransferFile(filePath, maxBytes) {
|
|
77
|
+
if (!existsSync(filePath)) {
|
|
78
|
+
throw new Error(`referenced file not found: ${filePath}`);
|
|
79
|
+
}
|
|
80
|
+
const stats = statSync(filePath);
|
|
81
|
+
if (!stats.isFile()) {
|
|
82
|
+
throw new Error(`referenced path is not a file: ${filePath}`);
|
|
83
|
+
}
|
|
84
|
+
if (stats.size > maxBytes) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`referenced file too large: ${stats.size} bytes (max ${maxBytes}) for ${filePath}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return stats.size;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function resolveReferencePath(token, handoffAbsPath, cwd) {
|
|
93
|
+
if (WINDOWS_ABS_RE.test(token) || isAbsolute(token)) {
|
|
94
|
+
return resolve(token);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (token.startsWith("~/") || token.startsWith("~\\")) {
|
|
98
|
+
if (!process.env.HOME && !process.env.USERPROFILE) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE;
|
|
102
|
+
return resolve(homeDir, token.slice(2));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const handoffDir = dirname(handoffAbsPath);
|
|
106
|
+
const primary = resolve(handoffDir, token);
|
|
107
|
+
if (existsSync(primary)) {
|
|
108
|
+
return primary;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const fallback = resolve(cwd, token);
|
|
112
|
+
return existsSync(fallback) ? fallback : primary;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function toRemotePath(stageRoot, relativePath) {
|
|
116
|
+
const root = String(stageRoot).replace(/\\/gu, "/").replace(/\/+$/u, "");
|
|
117
|
+
const rel = String(relativePath).replace(/\\/gu, "/").replace(/^\/+/, "");
|
|
118
|
+
return `${root}/${rel}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function buildRemoteTransferPlan(options = {}) {
|
|
122
|
+
const {
|
|
123
|
+
cwd = process.cwd(),
|
|
124
|
+
handoffPath = null,
|
|
125
|
+
maxBytes,
|
|
126
|
+
remoteStageRoot,
|
|
127
|
+
userPrompt = null,
|
|
128
|
+
maxReferenceFiles = 32,
|
|
129
|
+
} = options;
|
|
130
|
+
|
|
131
|
+
if (!handoffPath) {
|
|
132
|
+
return {
|
|
133
|
+
prompt: userPrompt || "",
|
|
134
|
+
replacements: [],
|
|
135
|
+
stagedHandoffPath: null,
|
|
136
|
+
transfers: [],
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!remoteStageRoot) {
|
|
141
|
+
throw new Error("remoteStageRoot is required when handoffPath is provided");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!Number.isFinite(maxBytes) || maxBytes <= 0) {
|
|
145
|
+
throw new Error("maxBytes must be a positive number");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const absoluteHandoffPath = resolve(cwd, handoffPath);
|
|
149
|
+
validateTransferFile(absoluteHandoffPath, maxBytes);
|
|
150
|
+
const handoffContent = readFileSync(absoluteHandoffPath, "utf8").trim();
|
|
151
|
+
|
|
152
|
+
const stagedHandoffPath = toRemotePath(
|
|
153
|
+
remoteStageRoot,
|
|
154
|
+
`handoff/${basename(absoluteHandoffPath)}`,
|
|
155
|
+
);
|
|
156
|
+
const transfers = [
|
|
157
|
+
{
|
|
158
|
+
localPath: absoluteHandoffPath,
|
|
159
|
+
remotePath: stagedHandoffPath,
|
|
160
|
+
type: "handoff",
|
|
161
|
+
},
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
const tokens = extractExplicitFileTokens(handoffContent);
|
|
165
|
+
if (tokens.length > maxReferenceFiles) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`too many referenced files: ${tokens.length} (max ${maxReferenceFiles})`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const stagedByLocalPath = new Map();
|
|
172
|
+
const replacements = [];
|
|
173
|
+
let fileIndex = 0;
|
|
174
|
+
|
|
175
|
+
for (const token of tokens) {
|
|
176
|
+
const resolvedPath = resolveReferencePath(token, absoluteHandoffPath, cwd);
|
|
177
|
+
validateTransferFile(resolvedPath, maxBytes);
|
|
178
|
+
|
|
179
|
+
if (!stagedByLocalPath.has(resolvedPath)) {
|
|
180
|
+
fileIndex += 1;
|
|
181
|
+
const stagedPath = toRemotePath(
|
|
182
|
+
remoteStageRoot,
|
|
183
|
+
`refs/${String(fileIndex).padStart(2, "0")}-${basename(resolvedPath)}`,
|
|
184
|
+
);
|
|
185
|
+
stagedByLocalPath.set(resolvedPath, stagedPath);
|
|
186
|
+
transfers.push({
|
|
187
|
+
localPath: resolvedPath,
|
|
188
|
+
remotePath: stagedPath,
|
|
189
|
+
type: "reference",
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
replacements.push({
|
|
194
|
+
token,
|
|
195
|
+
stagedPath: stagedByLocalPath.get(resolvedPath),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const replacementEntries = Array.from(
|
|
200
|
+
replacements
|
|
201
|
+
.reduce((map, entry) => map.set(entry.token, entry.stagedPath), new Map())
|
|
202
|
+
.entries(),
|
|
203
|
+
).sort((a, b) => b[0].length - a[0].length);
|
|
204
|
+
|
|
205
|
+
let rewrittenHandoff = handoffContent;
|
|
206
|
+
for (const [token, stagedPath] of replacementEntries) {
|
|
207
|
+
rewrittenHandoff = rewrittenHandoff.split(token).join(stagedPath);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const prefix = `Staged handoff file: ${stagedHandoffPath}`;
|
|
211
|
+
let prompt = rewrittenHandoff ? `${prefix}\n\n${rewrittenHandoff}` : prefix;
|
|
212
|
+
|
|
213
|
+
if (userPrompt) {
|
|
214
|
+
prompt = `${prompt}\n\n---\n\n${userPrompt}`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
prompt,
|
|
219
|
+
replacements: replacementEntries.map(([token, stagedPath]) => ({
|
|
220
|
+
stagedPath,
|
|
221
|
+
token,
|
|
222
|
+
})),
|
|
223
|
+
stagedHandoffPath,
|
|
224
|
+
transfers,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import {
|
|
2
|
+
access,
|
|
3
|
+
mkdir,
|
|
4
|
+
open,
|
|
5
|
+
readdir,
|
|
6
|
+
readFile,
|
|
7
|
+
rename,
|
|
8
|
+
rm,
|
|
9
|
+
writeFile,
|
|
10
|
+
} from "node:fs/promises";
|
|
11
|
+
import { basename, join } from "node:path";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_STATE_DIR = join(process.cwd(), ".tfx", "state");
|
|
14
|
+
const STOP_HOOKS = new Map();
|
|
15
|
+
|
|
16
|
+
function stateFilePath(stateDir, skillName) {
|
|
17
|
+
return join(stateDir, `${skillName}-active.json`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function assertValidSkillName(skillName) {
|
|
21
|
+
if (basename(skillName) !== skillName) {
|
|
22
|
+
throw new Error(`Invalid skill name: ${skillName}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function assertValidOnStop(onStop) {
|
|
27
|
+
if (onStop !== undefined && typeof onStop !== "function") {
|
|
28
|
+
throw new TypeError("onStop must be a function");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function readStateFile(filePath) {
|
|
33
|
+
try {
|
|
34
|
+
const raw = await readFile(filePath, "utf8");
|
|
35
|
+
return JSON.parse(raw);
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function rememberStopHook(filePath, onStop) {
|
|
42
|
+
if (typeof onStop === "function") {
|
|
43
|
+
STOP_HOOKS.set(filePath, onStop);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
STOP_HOOKS.delete(filePath);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function logStopHookWarning(message, error) {
|
|
50
|
+
if (error) {
|
|
51
|
+
console.warn(message, error);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
console.warn(message);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function runStopHook(filePath, state, onStop) {
|
|
58
|
+
if (!state?.hasStopHook) {
|
|
59
|
+
STOP_HOOKS.delete(filePath);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const hook = onStop ?? STOP_HOOKS.get(filePath);
|
|
64
|
+
STOP_HOOKS.delete(filePath);
|
|
65
|
+
|
|
66
|
+
if (typeof hook !== "function") {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
await hook({
|
|
72
|
+
skillName: state.skillName,
|
|
73
|
+
filePath,
|
|
74
|
+
state,
|
|
75
|
+
});
|
|
76
|
+
} catch (error) {
|
|
77
|
+
logStopHookWarning(
|
|
78
|
+
`Failed to run stop-hook for skill: ${state.skillName}`,
|
|
79
|
+
error,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Activate a skill by writing its state file.
|
|
86
|
+
* Throws if the skill is already active.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} skillName
|
|
89
|
+
* @param {{ stateDir?: string, onStop?: (() => Promise<void> | void) }} options
|
|
90
|
+
*/
|
|
91
|
+
export async function activateSkill(
|
|
92
|
+
skillName,
|
|
93
|
+
{ stateDir = DEFAULT_STATE_DIR, onStop } = {},
|
|
94
|
+
) {
|
|
95
|
+
assertValidSkillName(skillName);
|
|
96
|
+
assertValidOnStop(onStop);
|
|
97
|
+
|
|
98
|
+
await mkdir(stateDir, { recursive: true });
|
|
99
|
+
|
|
100
|
+
const filePath = stateFilePath(stateDir, skillName);
|
|
101
|
+
const lockPath = `${filePath}.lock`;
|
|
102
|
+
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
103
|
+
let lockHandle;
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
lockHandle = await open(lockPath, "wx");
|
|
107
|
+
try {
|
|
108
|
+
await access(filePath);
|
|
109
|
+
throw new Error(`Skill already active: ${skillName}`);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error?.message === `Skill already active: ${skillName}`) {
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
if (error?.code !== "ENOENT") {
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const state = {
|
|
120
|
+
skillName,
|
|
121
|
+
pid: process.pid,
|
|
122
|
+
activatedAt: Date.now(),
|
|
123
|
+
hasStopHook: typeof onStop === "function",
|
|
124
|
+
};
|
|
125
|
+
await writeFile(tmpPath, JSON.stringify(state), "utf8");
|
|
126
|
+
await rename(tmpPath, filePath);
|
|
127
|
+
rememberStopHook(filePath, onStop);
|
|
128
|
+
} finally {
|
|
129
|
+
await rm(tmpPath, { force: true }).catch(() => {});
|
|
130
|
+
if (lockHandle) {
|
|
131
|
+
await lockHandle.close().catch(() => {});
|
|
132
|
+
}
|
|
133
|
+
await rm(lockPath, { force: true }).catch(() => {});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Deactivate a skill by removing its state file.
|
|
139
|
+
* Does not throw if the file does not exist.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} skillName
|
|
142
|
+
* @param {{ stateDir?: string, onStop?: (() => Promise<void> | void) }} options
|
|
143
|
+
*/
|
|
144
|
+
export async function deactivateSkill(
|
|
145
|
+
skillName,
|
|
146
|
+
{ stateDir = DEFAULT_STATE_DIR, onStop } = {},
|
|
147
|
+
) {
|
|
148
|
+
assertValidOnStop(onStop);
|
|
149
|
+
|
|
150
|
+
const filePath = stateFilePath(stateDir, skillName);
|
|
151
|
+
const state = await readStateFile(filePath);
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
await runStopHook(filePath, state, onStop);
|
|
155
|
+
} finally {
|
|
156
|
+
STOP_HOOKS.delete(filePath);
|
|
157
|
+
await rm(filePath, { force: true }).catch(() => {});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Return all currently active skills by scanning *-active.json files.
|
|
163
|
+
*
|
|
164
|
+
* @param {{ stateDir?: string }} options
|
|
165
|
+
* @returns {Promise<Array<{ skillName: string, pid: number, activatedAt: number, hasStopHook?: boolean }>>}
|
|
166
|
+
*/
|
|
167
|
+
export async function getActiveSkills({ stateDir = DEFAULT_STATE_DIR } = {}) {
|
|
168
|
+
let entries;
|
|
169
|
+
try {
|
|
170
|
+
entries = await readdir(stateDir);
|
|
171
|
+
} catch {
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const results = [];
|
|
176
|
+
for (const entry of entries) {
|
|
177
|
+
if (!entry.endsWith("-active.json")) continue;
|
|
178
|
+
const state = await readStateFile(join(stateDir, entry));
|
|
179
|
+
if (state) {
|
|
180
|
+
results.push(state);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return results;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Remove state files for skills whose processes are no longer alive.
|
|
188
|
+
*
|
|
189
|
+
* @param {{ stateDir?: string }} options
|
|
190
|
+
* @returns {Promise<string[]>} list of pruned skill names
|
|
191
|
+
*/
|
|
192
|
+
export async function pruneOrphanSkillStates({
|
|
193
|
+
stateDir = DEFAULT_STATE_DIR,
|
|
194
|
+
} = {}) {
|
|
195
|
+
const active = await getActiveSkills({ stateDir });
|
|
196
|
+
const pruned = [];
|
|
197
|
+
|
|
198
|
+
for (const state of active) {
|
|
199
|
+
let alive = true;
|
|
200
|
+
try {
|
|
201
|
+
process.kill(state.pid, 0);
|
|
202
|
+
} catch {
|
|
203
|
+
alive = false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!alive) {
|
|
207
|
+
const filePath = stateFilePath(stateDir, state.skillName);
|
|
208
|
+
STOP_HOOKS.delete(filePath);
|
|
209
|
+
if (state.hasStopHook) {
|
|
210
|
+
logStopHookWarning(
|
|
211
|
+
`Skipping stop-hook for orphaned skill state: ${state.skillName}`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
await rm(filePath, { force: true }).catch(() => {});
|
|
215
|
+
pruned.push(state.skillName);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return pruned;
|
|
220
|
+
}
|