orchestrating 0.1.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/README.md +58 -0
- package/bin/orch +858 -0
- package/bin/pty-helper.py +139 -0
- package/package.json +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# orchestrating
|
|
2
|
+
|
|
3
|
+
Stream terminal sessions to the [orchestrat.ing](https://orchestrat.ing) dashboard. Monitor and interact with AI coding agents (Claude Code, etc.) from anywhere.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm i -g orchestrating
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
**Requirements:** Node.js 18+, Python 3 (for PTY mode)
|
|
12
|
+
|
|
13
|
+
## Authentication
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
orch login # Opens browser for authentication
|
|
17
|
+
orch logout # Clears stored credentials
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# Wrap any command
|
|
24
|
+
orch bash
|
|
25
|
+
orch claude "refactor the auth module"
|
|
26
|
+
|
|
27
|
+
# With a label
|
|
28
|
+
orch -l "deploy fix" claude "fix the CI pipeline"
|
|
29
|
+
|
|
30
|
+
# Continue a previous conversation
|
|
31
|
+
orch claude -c "now add tests"
|
|
32
|
+
|
|
33
|
+
# Auto-approve all permissions (yolo mode)
|
|
34
|
+
orch -y claude "build a website"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Environment Variables
|
|
38
|
+
|
|
39
|
+
| Variable | Description | Default |
|
|
40
|
+
|----------|-------------|---------|
|
|
41
|
+
| `ORC_URL` | WebSocket server URL | `wss://api.orchestrat.ing/ws` |
|
|
42
|
+
| `ORC_TOKEN` | Auth token (overrides stored credentials) | — |
|
|
43
|
+
|
|
44
|
+
## Local Development
|
|
45
|
+
|
|
46
|
+
For local development, point to your local server:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
ORC_URL=ws://localhost:3456/ws AUTH_TOKEN=dev-token-123 orch claude "hello"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## How It Works
|
|
53
|
+
|
|
54
|
+
`orch` wraps your command in a PTY (or structured JSON mode for supported tools), streams the output over WebSocket to the orchestrat.ing server, and displays it in a web dashboard. You can interact with sessions remotely — send input, approve permissions, and monitor progress from any browser.
|
|
55
|
+
|
|
56
|
+
## License
|
|
57
|
+
|
|
58
|
+
MIT
|
package/bin/orch
ADDED
|
@@ -0,0 +1,858 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import os from "os";
|
|
4
|
+
import http from "http";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import readline from "readline";
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync } from "fs";
|
|
8
|
+
import { spawn, execSync } from "child_process";
|
|
9
|
+
import { randomUUID } from "crypto";
|
|
10
|
+
import { fileURLToPath } from "url";
|
|
11
|
+
import WebSocket from "ws";
|
|
12
|
+
|
|
13
|
+
// Load .env from project root (same dir as the server uses)
|
|
14
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const envPath = path.join(__dirname, "..", "..", ".env");
|
|
16
|
+
try {
|
|
17
|
+
const envContent = readFileSync(envPath, "utf-8");
|
|
18
|
+
for (const line of envContent.split("\n")) {
|
|
19
|
+
const trimmed = line.trim();
|
|
20
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
21
|
+
const eqIdx = trimmed.indexOf("=");
|
|
22
|
+
if (eqIdx === -1) continue;
|
|
23
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
24
|
+
const val = trimmed.slice(eqIdx + 1).trim();
|
|
25
|
+
if (!process.env[key]) {
|
|
26
|
+
process.env[key] = val;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// No .env file — use env vars directly
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// --- Auth credentials ---
|
|
34
|
+
const CONFIG_DIR = path.join(os.homedir(), ".config", "orchestrating");
|
|
35
|
+
const AUTH_FILE = path.join(CONFIG_DIR, "auth.json");
|
|
36
|
+
|
|
37
|
+
function loadStoredAuth() {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(readFileSync(AUTH_FILE, "utf-8"));
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function saveAuth(data) {
|
|
46
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
47
|
+
writeFileSync(AUTH_FILE, JSON.stringify(data, null, 2) + "\n");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function clearAuth() {
|
|
51
|
+
try {
|
|
52
|
+
unlinkSync(AUTH_FILE);
|
|
53
|
+
} catch {}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getAuthToken() {
|
|
57
|
+
// 1. Env var override
|
|
58
|
+
if (process.env.ORC_TOKEN || process.env.CAST_TOKEN || process.env.AUTH_TOKEN) {
|
|
59
|
+
return process.env.ORC_TOKEN || process.env.CAST_TOKEN || process.env.AUTH_TOKEN;
|
|
60
|
+
}
|
|
61
|
+
// 2. Stored credentials
|
|
62
|
+
const auth = loadStoredAuth();
|
|
63
|
+
if (auth && auth.access_token) {
|
|
64
|
+
// Check expiry — if expired and we have a refresh token, caller should refresh
|
|
65
|
+
if (auth.expires_at && Date.now() / 1000 > auth.expires_at) {
|
|
66
|
+
// Token expired — still return it, server will reject and CLI can prompt re-login
|
|
67
|
+
return auth.access_token;
|
|
68
|
+
}
|
|
69
|
+
return auth.access_token;
|
|
70
|
+
}
|
|
71
|
+
return "";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- Login command ---
|
|
75
|
+
async function handleLogin() {
|
|
76
|
+
const loginUrl = process.env.ORC_LOGIN_URL || "https://orchestrat.ing/cli-auth";
|
|
77
|
+
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
const server = http.createServer((req, res) => {
|
|
80
|
+
const url = new URL(req.url, `http://localhost`);
|
|
81
|
+
if (url.pathname === "/callback") {
|
|
82
|
+
const accessToken = url.searchParams.get("access_token");
|
|
83
|
+
const refreshToken = url.searchParams.get("refresh_token");
|
|
84
|
+
const expiresAt = url.searchParams.get("expires_at");
|
|
85
|
+
|
|
86
|
+
if (accessToken) {
|
|
87
|
+
saveAuth({
|
|
88
|
+
access_token: accessToken,
|
|
89
|
+
refresh_token: refreshToken || "",
|
|
90
|
+
expires_at: expiresAt ? Number(expiresAt) : 0,
|
|
91
|
+
});
|
|
92
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
93
|
+
res.end("<html><body><h2>Authenticated! You can close this tab.</h2><script>window.close()</script></body></html>");
|
|
94
|
+
console.log("\x1b[32mLogged in successfully.\x1b[0m");
|
|
95
|
+
} else {
|
|
96
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
97
|
+
res.end("<html><body><h2>Authentication failed. Please try again.</h2></body></html>");
|
|
98
|
+
console.error("Authentication failed — no token received.");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
server.close();
|
|
102
|
+
resolve();
|
|
103
|
+
} else {
|
|
104
|
+
res.writeHead(404);
|
|
105
|
+
res.end();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
server.listen(0, "127.0.0.1", () => {
|
|
110
|
+
const port = server.address().port;
|
|
111
|
+
const authUrl = `${loginUrl}?port=${port}`;
|
|
112
|
+
console.log(`Opening browser for authentication...`);
|
|
113
|
+
console.log(`If it doesn't open, visit: ${authUrl}`);
|
|
114
|
+
|
|
115
|
+
// Open browser
|
|
116
|
+
const openCmd = process.platform === "darwin" ? "open"
|
|
117
|
+
: process.platform === "win32" ? "start"
|
|
118
|
+
: "xdg-open";
|
|
119
|
+
try {
|
|
120
|
+
execSync(`${openCmd} "${authUrl}"`, { stdio: "ignore" });
|
|
121
|
+
} catch {
|
|
122
|
+
// Browser open failed — user can visit URL manually
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Timeout after 2 minutes
|
|
126
|
+
setTimeout(() => {
|
|
127
|
+
console.error("Login timed out.");
|
|
128
|
+
server.close();
|
|
129
|
+
resolve();
|
|
130
|
+
}, 120_000);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// --- Handle login/logout subcommands ---
|
|
136
|
+
const firstArg = process.argv[2];
|
|
137
|
+
if (firstArg === "login") {
|
|
138
|
+
await handleLogin();
|
|
139
|
+
process.exit(0);
|
|
140
|
+
}
|
|
141
|
+
if (firstArg === "logout") {
|
|
142
|
+
clearAuth();
|
|
143
|
+
console.log("Logged out.");
|
|
144
|
+
process.exit(0);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// --- Structured adapters ---
|
|
148
|
+
// Commands that match an adapter key get spawned with structured JSON I/O
|
|
149
|
+
// instead of PTY wrapping. Future adapters (codex, gemini) go here.
|
|
150
|
+
const ADAPTERS = {
|
|
151
|
+
claude: {
|
|
152
|
+
buildArgs(prompt, flags) {
|
|
153
|
+
const args = [
|
|
154
|
+
"--output-format", "stream-json",
|
|
155
|
+
"--input-format", "stream-json",
|
|
156
|
+
"--verbose",
|
|
157
|
+
];
|
|
158
|
+
if (flags.continue) {
|
|
159
|
+
args.push("-c");
|
|
160
|
+
if (prompt) args.push("-p", prompt);
|
|
161
|
+
} else {
|
|
162
|
+
args.push("-p", prompt);
|
|
163
|
+
}
|
|
164
|
+
return args;
|
|
165
|
+
},
|
|
166
|
+
mode: "structured",
|
|
167
|
+
provider: "claude-code",
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// --- Parse args ---
|
|
172
|
+
const args = process.argv.slice(2);
|
|
173
|
+
let label = null;
|
|
174
|
+
let yoloFlag = false;
|
|
175
|
+
let commandArgs = [];
|
|
176
|
+
|
|
177
|
+
// Parse flags before the command
|
|
178
|
+
let i = 0;
|
|
179
|
+
while (i < args.length) {
|
|
180
|
+
if (args[i] === "-l" && i + 1 < args.length) {
|
|
181
|
+
label = args[i + 1];
|
|
182
|
+
i += 2;
|
|
183
|
+
} else if (args[i] === "-y" || args[i] === "--yolo") {
|
|
184
|
+
yoloFlag = true;
|
|
185
|
+
i += 1;
|
|
186
|
+
} else if (args[i] === "--help" || args[i] === "-h") {
|
|
187
|
+
console.error("Usage: orch [-l label] [-y] <command> [args...]");
|
|
188
|
+
console.error(" orch login — Authenticate with orchestrat.ing");
|
|
189
|
+
console.error(" orch logout — Clear stored credentials");
|
|
190
|
+
console.error("");
|
|
191
|
+
console.error(" -l <label> Optional human-readable session label");
|
|
192
|
+
console.error(" -y, --yolo Skip all permission prompts (auto-approve everything)");
|
|
193
|
+
console.error("");
|
|
194
|
+
console.error("Examples:");
|
|
195
|
+
console.error(' orch claude "refactor auth"');
|
|
196
|
+
console.error(' orch -y claude "build a website"');
|
|
197
|
+
console.error(' orch -l "deploy fix" codex');
|
|
198
|
+
console.error(" orch bash");
|
|
199
|
+
console.error("");
|
|
200
|
+
console.error("Environment:");
|
|
201
|
+
console.error(" ORC_URL WebSocket server URL (default: wss://api.orchestrat.ing/ws)");
|
|
202
|
+
console.error(" ORC_TOKEN Auth token (overrides stored credentials)");
|
|
203
|
+
process.exit(0);
|
|
204
|
+
} else {
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
commandArgs = args.slice(i);
|
|
209
|
+
|
|
210
|
+
if (commandArgs.length === 0) {
|
|
211
|
+
console.error("Usage: orch [-l label] [-y] <command> [args...]");
|
|
212
|
+
console.error(" orch login | logout");
|
|
213
|
+
process.exit(1);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const command = commandArgs[0];
|
|
217
|
+
const spawnArgs = commandArgs.slice(1);
|
|
218
|
+
const sessionId = randomUUID();
|
|
219
|
+
const hostname = os.hostname();
|
|
220
|
+
const serverUrl = process.env.ORC_URL || process.env.CAST_URL || "wss://api.orchestrat.ing/ws";
|
|
221
|
+
const authToken = getAuthToken();
|
|
222
|
+
const effectiveLabel = label || commandArgs.join(" ") || "continue";
|
|
223
|
+
|
|
224
|
+
// Warn if no auth and connecting to remote server
|
|
225
|
+
if (!authToken && !serverUrl.includes("localhost") && !serverUrl.includes("127.0.0.1")) {
|
|
226
|
+
console.error("\x1b[33mNo credentials found. Run 'orch login' to authenticate.\x1b[0m");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// --- WebSocket connection with reconnect ---
|
|
230
|
+
const BUFFER_MAX = 50 * 1024; // 50KB reconnect buffer
|
|
231
|
+
let ws = null;
|
|
232
|
+
let wsReady = false;
|
|
233
|
+
let reconnectTimer = null;
|
|
234
|
+
const sendBuffer = [];
|
|
235
|
+
let bufferSize = 0;
|
|
236
|
+
|
|
237
|
+
function sendToServer(msg) {
|
|
238
|
+
if (wsReady && ws && ws.readyState === WebSocket.OPEN) {
|
|
239
|
+
ws.send(JSON.stringify(msg));
|
|
240
|
+
} else {
|
|
241
|
+
const encoded = JSON.stringify(msg);
|
|
242
|
+
sendBuffer.push(encoded);
|
|
243
|
+
bufferSize += encoded.length;
|
|
244
|
+
while (bufferSize > BUFFER_MAX && sendBuffer.length > 1) {
|
|
245
|
+
const removed = sendBuffer.shift();
|
|
246
|
+
bufferSize -= removed.length;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- Terminal colors ---
|
|
252
|
+
const CYAN = "\x1b[36m";
|
|
253
|
+
const DIM = "\x1b[2m";
|
|
254
|
+
const GREEN = "\x1b[32m";
|
|
255
|
+
const RED = "\x1b[31m";
|
|
256
|
+
const RESET = "\x1b[0m";
|
|
257
|
+
const BOLD = "\x1b[1m";
|
|
258
|
+
|
|
259
|
+
// --- Permission mode ---
|
|
260
|
+
let yoloMode = yoloFlag;
|
|
261
|
+
|
|
262
|
+
// --- Session-only permissions (cleaned up on exit) ---
|
|
263
|
+
const sessionPermissions = []; // { settingsPath, tool }
|
|
264
|
+
|
|
265
|
+
// --- Permission state helpers ---
|
|
266
|
+
const projectSettingsPath = path.join(process.cwd(), ".claude", "settings.local.json");
|
|
267
|
+
|
|
268
|
+
function readPermissions() {
|
|
269
|
+
const result = { project: [] };
|
|
270
|
+
try {
|
|
271
|
+
const ps = JSON.parse(readFileSync(projectSettingsPath, "utf-8"));
|
|
272
|
+
result.project = ps?.permissions?.allow || [];
|
|
273
|
+
} catch {}
|
|
274
|
+
return result;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function broadcastPermissions() {
|
|
278
|
+
const perms = readPermissions();
|
|
279
|
+
process.stderr.write(`${DIM}[perms] Broadcasting: ${perms.project.length} project${RESET}\n`);
|
|
280
|
+
sendToServer({ type: "agent_event", sessionId, event: { kind: "permission_state", ...perms } });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function removePermission(tool) {
|
|
284
|
+
try {
|
|
285
|
+
const settings = JSON.parse(readFileSync(projectSettingsPath, "utf-8"));
|
|
286
|
+
if (Array.isArray(settings?.permissions?.allow)) {
|
|
287
|
+
settings.permissions.allow = settings.permissions.allow.filter((t) => t !== tool);
|
|
288
|
+
writeFileSync(projectSettingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
289
|
+
}
|
|
290
|
+
} catch {}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// --- Determine mode: structured adapter or PTY ---
|
|
294
|
+
const adapter = ADAPTERS[command];
|
|
295
|
+
|
|
296
|
+
// Check for python3 in PTY mode
|
|
297
|
+
if (!adapter) {
|
|
298
|
+
try {
|
|
299
|
+
execSync("python3 --version", { stdio: "ignore" });
|
|
300
|
+
} catch {
|
|
301
|
+
console.error("python3 is required for PTY mode. Please install Python 3.");
|
|
302
|
+
process.exit(1);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
let child;
|
|
307
|
+
let handleServerMessage; // set per-mode
|
|
308
|
+
|
|
309
|
+
if (adapter) {
|
|
310
|
+
// ======== STRUCTURED MODE (Claude Code JSON streaming) ========
|
|
311
|
+
// Parse adapter-specific flags (e.g., -c for continue)
|
|
312
|
+
const adapterFlags = { continue: false };
|
|
313
|
+
const promptParts = [];
|
|
314
|
+
for (const arg of spawnArgs) {
|
|
315
|
+
if (arg === "-c" || arg === "--continue") {
|
|
316
|
+
adapterFlags.continue = true;
|
|
317
|
+
} else {
|
|
318
|
+
promptParts.push(arg);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const prompt = promptParts.join(" ");
|
|
322
|
+
if (!prompt && !adapterFlags.continue) {
|
|
323
|
+
console.error(`Usage: orch ${command} "your prompt here"`);
|
|
324
|
+
console.error(` orch ${command} -c # continue last conversation`);
|
|
325
|
+
console.error(` orch ${command} -c "follow up" # continue with message`);
|
|
326
|
+
process.exit(1);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const claudeArgs = adapter.buildArgs(prompt, adapterFlags);
|
|
330
|
+
if (yoloMode) {
|
|
331
|
+
claudeArgs.push("--dangerously-skip-permissions");
|
|
332
|
+
}
|
|
333
|
+
child = spawn(command, claudeArgs, {
|
|
334
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
335
|
+
cwd: process.cwd(),
|
|
336
|
+
env: (() => {
|
|
337
|
+
const e = { ...process.env };
|
|
338
|
+
delete e.CLAUDECODE;
|
|
339
|
+
delete e.ANTHROPIC_API_KEY;
|
|
340
|
+
return e;
|
|
341
|
+
})(),
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// Parse NDJSON from stdout line-by-line
|
|
345
|
+
const rl = readline.createInterface({ input: child.stdout });
|
|
346
|
+
|
|
347
|
+
// Broadcast initial permission state once connected
|
|
348
|
+
let permissionsSent = false;
|
|
349
|
+
|
|
350
|
+
rl.on("line", (line) => {
|
|
351
|
+
if (!permissionsSent) {
|
|
352
|
+
permissionsSent = true;
|
|
353
|
+
broadcastPermissions();
|
|
354
|
+
}
|
|
355
|
+
if (!line.trim()) return;
|
|
356
|
+
let raw;
|
|
357
|
+
try {
|
|
358
|
+
raw = JSON.parse(line);
|
|
359
|
+
} catch {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Normalize and relay each event
|
|
364
|
+
const events = normalizeClaudeEvent(raw);
|
|
365
|
+
for (const event of events) {
|
|
366
|
+
printLocalEvent(event);
|
|
367
|
+
sendToServer({ type: "agent_event", sessionId, event });
|
|
368
|
+
|
|
369
|
+
// Yolo mode: auto-approve permission denials immediately
|
|
370
|
+
if (yoloMode && event.kind === "permission_denied") {
|
|
371
|
+
process.stderr.write(`${GREEN}[yolo] Auto-approving: ${event.toolName}${RESET}\n`);
|
|
372
|
+
approvePermission(event.toolName, "session");
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
child.stderr.on("data", (buf) => {
|
|
378
|
+
process.stderr.write(buf);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
child.on("exit", (code) => {
|
|
382
|
+
const exitCode = code ?? 0;
|
|
383
|
+
sendToServer({ type: "exit", sessionId, exitCode });
|
|
384
|
+
setTimeout(() => process.exit(exitCode), 200);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
child.on("error", (err) => {
|
|
388
|
+
console.error("Failed to start:", err.message);
|
|
389
|
+
process.exit(1);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// Confirmation-type tools — these need "yes" response, not permission grants
|
|
393
|
+
const CONFIRMATION_TOOLS = new Set(["ExitPlanMode", "EnterPlanMode"]);
|
|
394
|
+
|
|
395
|
+
// Auto-approve a tool permission (used by yolo mode and manual approval)
|
|
396
|
+
function approvePermission(toolName, scope) {
|
|
397
|
+
// For confirmation prompts, just send "yes" — no settings change needed
|
|
398
|
+
if (CONFIRMATION_TOOLS.has(toolName)) {
|
|
399
|
+
const confirmMsg = JSON.stringify({
|
|
400
|
+
type: "user",
|
|
401
|
+
message: { role: "user", content: "yes" },
|
|
402
|
+
});
|
|
403
|
+
child.stdin.write(confirmMsg + "\n");
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
let permEntry = toolName;
|
|
408
|
+
const mcpMatch = toolName.match(/^(mcp__[^_]+(?:__[^_]+)?)__/);
|
|
409
|
+
if (mcpMatch) {
|
|
410
|
+
permEntry = mcpMatch[1] + "__*";
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
let settings = {};
|
|
414
|
+
try {
|
|
415
|
+
settings = JSON.parse(readFileSync(projectSettingsPath, "utf-8"));
|
|
416
|
+
} catch {}
|
|
417
|
+
if (!settings.permissions) settings.permissions = {};
|
|
418
|
+
if (!Array.isArray(settings.permissions.allow)) settings.permissions.allow = [];
|
|
419
|
+
|
|
420
|
+
const alreadyCovered = settings.permissions.allow.some((existing) => {
|
|
421
|
+
if (existing === permEntry) return true;
|
|
422
|
+
if (existing.endsWith("*") && permEntry.startsWith(existing.slice(0, -1))) return true;
|
|
423
|
+
return false;
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
if (!alreadyCovered) {
|
|
427
|
+
if (permEntry.endsWith("*")) {
|
|
428
|
+
const prefix = permEntry.slice(0, -1);
|
|
429
|
+
settings.permissions.allow = settings.permissions.allow.filter(
|
|
430
|
+
(e) => !e.startsWith(prefix) || e.endsWith("*")
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
settings.permissions.allow.push(permEntry);
|
|
434
|
+
mkdirSync(path.dirname(projectSettingsPath), { recursive: true });
|
|
435
|
+
writeFileSync(projectSettingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (scope === "session") {
|
|
439
|
+
sessionPermissions.push({ settingsPath: projectSettingsPath, tool: permEntry });
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
broadcastPermissions();
|
|
443
|
+
|
|
444
|
+
const retryMsg = JSON.stringify({
|
|
445
|
+
type: "user",
|
|
446
|
+
message: { role: "user", content: `Permission granted for ${toolName}. Please retry.` },
|
|
447
|
+
});
|
|
448
|
+
child.stdin.write(retryMsg + "\n");
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// No local stdin forwarding in structured mode — input comes from server only
|
|
452
|
+
// (stdin is reserved for stream-json messages to Claude)
|
|
453
|
+
|
|
454
|
+
handleServerMessage = (msg) => {
|
|
455
|
+
if (msg.type === "agent_input" && (msg.text || msg.images)) {
|
|
456
|
+
// Forward user message from dashboard to Claude's stdin as stream-json
|
|
457
|
+
let content;
|
|
458
|
+
if (msg.images && msg.images.length > 0) {
|
|
459
|
+
// Build content array with images + optional text
|
|
460
|
+
content = [];
|
|
461
|
+
for (const img of msg.images) {
|
|
462
|
+
content.push({
|
|
463
|
+
type: "image",
|
|
464
|
+
source: { type: "base64", media_type: img.mediaType, data: img.data },
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
if (msg.text) {
|
|
468
|
+
content.push({ type: "text", text: msg.text });
|
|
469
|
+
}
|
|
470
|
+
} else {
|
|
471
|
+
content = msg.text;
|
|
472
|
+
}
|
|
473
|
+
const inputMsg = JSON.stringify({
|
|
474
|
+
type: "user",
|
|
475
|
+
message: { role: "user", content },
|
|
476
|
+
});
|
|
477
|
+
child.stdin.write(inputMsg + "\n");
|
|
478
|
+
} else if (msg.type === "agent_permission" && msg.tool && msg.action === "allow") {
|
|
479
|
+
const scope = msg.scope || "session";
|
|
480
|
+
approvePermission(msg.tool, scope);
|
|
481
|
+
process.stderr.write(`${GREEN}Permission granted (${scope}): ${msg.tool}${RESET}\n`);
|
|
482
|
+
} else if (msg.type === "agent_permission" && msg.tool && msg.action === "revoke") {
|
|
483
|
+
removePermission(msg.tool);
|
|
484
|
+
broadcastPermissions();
|
|
485
|
+
process.stderr.write(`${RED}Permission revoked: ${msg.tool}${RESET}\n`);
|
|
486
|
+
} else if (msg.type === "session_mode") {
|
|
487
|
+
const newMode = msg.mode;
|
|
488
|
+
yoloMode = newMode === "yolo";
|
|
489
|
+
process.stderr.write(`${BOLD}[mode] ${yoloMode ? "YOLO" : "Normal"}${RESET}\n`);
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
} else {
|
|
494
|
+
// ======== PTY MODE (existing behavior for bash, etc.) ========
|
|
495
|
+
const cols = process.stdout.columns || 80;
|
|
496
|
+
const rows = process.stdout.rows || 24;
|
|
497
|
+
const shell = process.env.SHELL || "/bin/zsh";
|
|
498
|
+
const helperPath = path.join(__dirname, "pty-helper.py");
|
|
499
|
+
|
|
500
|
+
function shellEscape(arg) {
|
|
501
|
+
if (/^[a-zA-Z0-9_./:@=-]+$/.test(arg)) return arg;
|
|
502
|
+
return "'" + arg.replace(/'/g, "'\\''") + "'";
|
|
503
|
+
}
|
|
504
|
+
const shellCmd = commandArgs.map(shellEscape).join(" ");
|
|
505
|
+
|
|
506
|
+
child = spawn("python3", [helperPath, String(cols), String(rows), shell, "-c", shellCmd], {
|
|
507
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
508
|
+
cwd: process.cwd(),
|
|
509
|
+
env: (() => {
|
|
510
|
+
const e = { ...process.env, TERM: "xterm-256color" };
|
|
511
|
+
delete e.CLAUDECODE;
|
|
512
|
+
delete e.ANTHROPIC_API_KEY;
|
|
513
|
+
return e;
|
|
514
|
+
})(),
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
child.stdout.on("data", (buf) => {
|
|
518
|
+
process.stdout.write(buf);
|
|
519
|
+
sendToServer({ type: "output", sessionId, data: buf.toString("base64") });
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
child.stderr.on("data", (buf) => {
|
|
523
|
+
process.stderr.write(buf);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
child.on("exit", (code) => {
|
|
527
|
+
const exitCode = code ?? 0;
|
|
528
|
+
sendToServer({ type: "exit", sessionId, exitCode });
|
|
529
|
+
if (process.stdin.isTTY) {
|
|
530
|
+
process.stdin.setRawMode(false);
|
|
531
|
+
}
|
|
532
|
+
setTimeout(() => process.exit(exitCode), 200);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
child.on("error", (err) => {
|
|
536
|
+
console.error("Failed to start:", err.message);
|
|
537
|
+
process.exit(1);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
if (process.stdin.isTTY) {
|
|
541
|
+
process.stdin.setRawMode(true);
|
|
542
|
+
}
|
|
543
|
+
process.stdin.resume();
|
|
544
|
+
process.stdin.on("data", (data) => {
|
|
545
|
+
child.stdin.write(data);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
process.stdout.on("resize", () => {
|
|
549
|
+
const newCols = process.stdout.columns;
|
|
550
|
+
const newRows = process.stdout.rows;
|
|
551
|
+
child.stdin.write(`\x1b]R;${newCols};${newRows}\x07`);
|
|
552
|
+
sendToServer({ type: "resize", sessionId, cols: newCols, rows: newRows });
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
handleServerMessage = (msg) => {
|
|
556
|
+
if (msg.type === "input" && msg.data) {
|
|
557
|
+
child.stdin.write(Buffer.from(msg.data, "base64"));
|
|
558
|
+
} else if (msg.type === "resize" && msg.cols && msg.rows) {
|
|
559
|
+
child.stdin.write(`\x1b]R;${msg.cols};${msg.rows}\x07`);
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// --- Normalize Claude Code stream-json events into provider-agnostic events ---
|
|
565
|
+
|
|
566
|
+
let blockCounter = 0;
|
|
567
|
+
const toolInfoMap = {}; // toolId -> { toolName, input }
|
|
568
|
+
|
|
569
|
+
function normalizeClaudeEvent(raw) {
|
|
570
|
+
const events = [];
|
|
571
|
+
|
|
572
|
+
if (raw.type === "system" && raw.subtype === "init") {
|
|
573
|
+
events.push({
|
|
574
|
+
kind: "status",
|
|
575
|
+
status: "init",
|
|
576
|
+
sessionId: raw.session_id,
|
|
577
|
+
model: raw.model,
|
|
578
|
+
tools: raw.tools,
|
|
579
|
+
});
|
|
580
|
+
return events;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (raw.type === "assistant") {
|
|
584
|
+
const msg = raw.message;
|
|
585
|
+
if (!msg || !msg.content) return events;
|
|
586
|
+
|
|
587
|
+
events.push({ kind: "message_start", messageId: msg.id });
|
|
588
|
+
|
|
589
|
+
for (const block of msg.content) {
|
|
590
|
+
if (block.type === "text") {
|
|
591
|
+
const blockId = `text-${++blockCounter}`;
|
|
592
|
+
events.push({ kind: "text_delta", blockId, text: block.text });
|
|
593
|
+
} else if (block.type === "thinking") {
|
|
594
|
+
const blockId = `thinking-${++blockCounter}`;
|
|
595
|
+
events.push({ kind: "thinking_delta", blockId, text: block.thinking });
|
|
596
|
+
events.push({ kind: "thinking_done", blockId });
|
|
597
|
+
} else if (block.type === "tool_use") {
|
|
598
|
+
toolInfoMap[block.id] = { toolName: block.name, input: block.input };
|
|
599
|
+
events.push({
|
|
600
|
+
kind: "tool_start",
|
|
601
|
+
blockId: block.id,
|
|
602
|
+
toolName: block.name,
|
|
603
|
+
toolId: block.id,
|
|
604
|
+
input: JSON.stringify(block.input),
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
events.push({
|
|
610
|
+
kind: "message_end",
|
|
611
|
+
messageId: msg.id,
|
|
612
|
+
stopReason: msg.stop_reason,
|
|
613
|
+
});
|
|
614
|
+
return events;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (raw.type === "user" && raw.tool_use_result !== undefined) {
|
|
618
|
+
const content = raw.message?.content;
|
|
619
|
+
if (Array.isArray(content)) {
|
|
620
|
+
for (const block of content) {
|
|
621
|
+
if (block.type === "tool_result") {
|
|
622
|
+
const result = raw.tool_use_result || {};
|
|
623
|
+
// Handle content that may be string or array of content blocks
|
|
624
|
+
let outputText = "";
|
|
625
|
+
if (result.stdout) {
|
|
626
|
+
outputText = result.stdout;
|
|
627
|
+
} else if (typeof block.content === "string") {
|
|
628
|
+
outputText = block.content;
|
|
629
|
+
} else if (Array.isArray(block.content)) {
|
|
630
|
+
outputText = block.content
|
|
631
|
+
.filter((c) => c.type === "text")
|
|
632
|
+
.map((c) => c.text)
|
|
633
|
+
.join("\n");
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
events.push({
|
|
637
|
+
kind: "tool_result",
|
|
638
|
+
toolId: block.tool_use_id,
|
|
639
|
+
output: outputText,
|
|
640
|
+
isError: block.is_error || false,
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// Detect permission denial errors
|
|
644
|
+
if (block.is_error && outputText) {
|
|
645
|
+
let toolName = null;
|
|
646
|
+
|
|
647
|
+
// "Claude requested permissions to use <ToolName>"
|
|
648
|
+
const useMatch = outputText.match(/Claude requested permissions? to use (\S+)/);
|
|
649
|
+
if (useMatch) {
|
|
650
|
+
toolName = useMatch[1].replace(/[,.]$/, "");
|
|
651
|
+
}
|
|
652
|
+
// "Claude requested permissions to write/edit/execute ..." — use actual tool name from tracker
|
|
653
|
+
if (!toolName && /Claude requested permissions? to (write|edit|execute|read|run|create|delete) /i.test(outputText)) {
|
|
654
|
+
const info = toolInfoMap[block.tool_use_id];
|
|
655
|
+
toolName = info?.toolName || "Write";
|
|
656
|
+
}
|
|
657
|
+
// "This command requires approval" — look up tool name from tracker
|
|
658
|
+
if (!toolName && /requires? approval/i.test(outputText)) {
|
|
659
|
+
const info = toolInfoMap[block.tool_use_id];
|
|
660
|
+
toolName = info?.toolName || "Unknown";
|
|
661
|
+
}
|
|
662
|
+
// Generic fallback: "Claude requested permissions..."
|
|
663
|
+
if (!toolName && /Claude requested permissions?/i.test(outputText)) {
|
|
664
|
+
const info = toolInfoMap[block.tool_use_id];
|
|
665
|
+
toolName = info?.toolName || "Unknown";
|
|
666
|
+
}
|
|
667
|
+
// "haven't granted it yet"
|
|
668
|
+
if (!toolName && /haven't granted/i.test(outputText)) {
|
|
669
|
+
const info = toolInfoMap[block.tool_use_id];
|
|
670
|
+
toolName = info?.toolName || "Unknown";
|
|
671
|
+
}
|
|
672
|
+
// Confirmation prompts (e.g., "Exit plan mode?") — short errors ending with "?"
|
|
673
|
+
if (!toolName && outputText.trim().endsWith("?") && outputText.length < 200) {
|
|
674
|
+
const info = toolInfoMap[block.tool_use_id];
|
|
675
|
+
toolName = info?.toolName || "Unknown";
|
|
676
|
+
}
|
|
677
|
+
// Broad fallback: any short error for a known tool that isn't a real error message
|
|
678
|
+
if (!toolName && outputText.length < 100) {
|
|
679
|
+
const info = toolInfoMap[block.tool_use_id];
|
|
680
|
+
if (info) {
|
|
681
|
+
toolName = info.toolName;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (toolName) {
|
|
686
|
+
events.push({
|
|
687
|
+
kind: "permission_denied",
|
|
688
|
+
toolName,
|
|
689
|
+
toolId: block.tool_use_id,
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
return events;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (raw.type === "result") {
|
|
700
|
+
events.push({
|
|
701
|
+
kind: "status",
|
|
702
|
+
status: raw.subtype === "success" ? "complete" : "error",
|
|
703
|
+
cost: raw.total_cost_usd,
|
|
704
|
+
duration: raw.duration_ms,
|
|
705
|
+
result: raw.result,
|
|
706
|
+
});
|
|
707
|
+
return events;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
return events;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// --- Print structured events locally for terminal feedback ---
|
|
714
|
+
|
|
715
|
+
function printLocalEvent(event) {
|
|
716
|
+
switch (event.kind) {
|
|
717
|
+
case "text_delta":
|
|
718
|
+
process.stdout.write(event.text);
|
|
719
|
+
break;
|
|
720
|
+
case "thinking_delta":
|
|
721
|
+
process.stdout.write(`${DIM}${event.text}${RESET}`);
|
|
722
|
+
break;
|
|
723
|
+
case "tool_start":
|
|
724
|
+
process.stdout.write(`\n${CYAN}${BOLD}Tool: ${event.toolName}${RESET}\n`);
|
|
725
|
+
if (event.input) {
|
|
726
|
+
try {
|
|
727
|
+
const parsed = JSON.parse(event.input);
|
|
728
|
+
const display = parsed.command || parsed.pattern || parsed.file_path || event.input;
|
|
729
|
+
process.stdout.write(`${DIM}${typeof display === "string" ? display : event.input}${RESET}\n`);
|
|
730
|
+
} catch {
|
|
731
|
+
process.stdout.write(`${DIM}${event.input}${RESET}\n`);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
break;
|
|
735
|
+
case "tool_result":
|
|
736
|
+
if (event.isError) {
|
|
737
|
+
process.stdout.write(`${RED}Error: ${event.output.slice(0, 200)}${RESET}\n`);
|
|
738
|
+
} else if (event.output) {
|
|
739
|
+
const preview = event.output.length > 300
|
|
740
|
+
? event.output.slice(0, 300) + "..."
|
|
741
|
+
: event.output;
|
|
742
|
+
process.stdout.write(`${DIM}${preview}${RESET}\n`);
|
|
743
|
+
}
|
|
744
|
+
break;
|
|
745
|
+
case "status":
|
|
746
|
+
if (event.status === "complete") {
|
|
747
|
+
process.stdout.write(`\n${GREEN}Done${RESET}${event.cost ? ` ($${event.cost.toFixed(4)})` : ""}\n`);
|
|
748
|
+
}
|
|
749
|
+
break;
|
|
750
|
+
case "message_end":
|
|
751
|
+
process.stdout.write("\n");
|
|
752
|
+
break;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// --- WebSocket connection ---
|
|
757
|
+
|
|
758
|
+
function connectWs() {
|
|
759
|
+
if (ws) {
|
|
760
|
+
ws.removeAllListeners();
|
|
761
|
+
ws.close();
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
ws = new WebSocket(serverUrl);
|
|
765
|
+
|
|
766
|
+
ws.on("open", () => {
|
|
767
|
+
ws.send(JSON.stringify({
|
|
768
|
+
type: "register",
|
|
769
|
+
token: authToken,
|
|
770
|
+
sessionId,
|
|
771
|
+
label: effectiveLabel,
|
|
772
|
+
command,
|
|
773
|
+
args: spawnArgs,
|
|
774
|
+
cwd: process.cwd(),
|
|
775
|
+
hostname,
|
|
776
|
+
mode: adapter ? adapter.mode : "pty",
|
|
777
|
+
provider: adapter ? adapter.provider : null,
|
|
778
|
+
permissionMode: yoloMode ? "yolo" : "normal",
|
|
779
|
+
cols: process.stdout.columns || 80,
|
|
780
|
+
rows: process.stdout.rows || 24,
|
|
781
|
+
}));
|
|
782
|
+
wsReady = true;
|
|
783
|
+
|
|
784
|
+
for (const msg of sendBuffer) {
|
|
785
|
+
ws.send(msg);
|
|
786
|
+
}
|
|
787
|
+
sendBuffer.length = 0;
|
|
788
|
+
bufferSize = 0;
|
|
789
|
+
|
|
790
|
+
// Broadcast permission state after registration (structured mode only)
|
|
791
|
+
if (adapter) {
|
|
792
|
+
broadcastPermissions();
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
ws.on("message", (raw) => {
|
|
797
|
+
try {
|
|
798
|
+
const msg = JSON.parse(raw.toString());
|
|
799
|
+
handleServerMessage(msg);
|
|
800
|
+
} catch {}
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
ws.on("close", () => {
|
|
804
|
+
wsReady = false;
|
|
805
|
+
ws = null;
|
|
806
|
+
reconnectTimer = setTimeout(connectWs, 2000);
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
ws.on("error", () => {
|
|
810
|
+
// Will trigger close
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
connectWs();
|
|
815
|
+
|
|
816
|
+
// --- Graceful shutdown ---
|
|
817
|
+
function revokeSessionPermissions() {
|
|
818
|
+
// Group by settings file to minimize I/O
|
|
819
|
+
const byFile = {};
|
|
820
|
+
for (const { settingsPath, tool } of sessionPermissions) {
|
|
821
|
+
if (!byFile[settingsPath]) byFile[settingsPath] = [];
|
|
822
|
+
byFile[settingsPath].push(tool);
|
|
823
|
+
}
|
|
824
|
+
for (const [filePath, tools] of Object.entries(byFile)) {
|
|
825
|
+
try {
|
|
826
|
+
const settings = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
827
|
+
if (Array.isArray(settings?.permissions?.allow)) {
|
|
828
|
+
settings.permissions.allow = settings.permissions.allow.filter((t) => !tools.includes(t));
|
|
829
|
+
writeFileSync(filePath, JSON.stringify(settings, null, 2) + "\n");
|
|
830
|
+
}
|
|
831
|
+
} catch {
|
|
832
|
+
// File gone or unreadable — nothing to revoke
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
sessionPermissions.length = 0;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function cleanup() {
|
|
839
|
+
revokeSessionPermissions();
|
|
840
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
841
|
+
if (ws) ws.close();
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
process.on("SIGINT", () => {
|
|
845
|
+
if (adapter) {
|
|
846
|
+
child.kill("SIGINT");
|
|
847
|
+
} else {
|
|
848
|
+
child.stdin.write("\x03");
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
process.on("SIGTERM", () => {
|
|
853
|
+
child.kill();
|
|
854
|
+
cleanup();
|
|
855
|
+
process.exit(0);
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
process.on("exit", cleanup);
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Minimal PTY bridge: creates a real PTY, forks a child process onto it,
|
|
4
|
+
and bridges stdin/stdout pipes to the PTY master. This lets Node.js
|
|
5
|
+
get full PTY behavior (colors, cursor control, raw mode) without
|
|
6
|
+
native modules like node-pty.
|
|
7
|
+
|
|
8
|
+
Usage: pty-helper.py <cols> <rows> <command> [args...]
|
|
9
|
+
|
|
10
|
+
Resize protocol: send \x1b]R;<cols>;<rows>\x07 on stdin to resize the PTY.
|
|
11
|
+
"""
|
|
12
|
+
import pty, os, sys, select, signal, struct, fcntl, termios, errno
|
|
13
|
+
|
|
14
|
+
if len(sys.argv) < 4:
|
|
15
|
+
print("Usage: pty-helper.py <cols> <rows> <command> [args...]", file=sys.stderr)
|
|
16
|
+
sys.exit(1)
|
|
17
|
+
|
|
18
|
+
cols = int(sys.argv[1])
|
|
19
|
+
rows = int(sys.argv[2])
|
|
20
|
+
cmd = sys.argv[3]
|
|
21
|
+
cmd_args = sys.argv[3:]
|
|
22
|
+
|
|
23
|
+
# Create PTY pair
|
|
24
|
+
master_fd, slave_fd = pty.openpty()
|
|
25
|
+
|
|
26
|
+
# Set initial window size
|
|
27
|
+
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
|
28
|
+
fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, winsize)
|
|
29
|
+
|
|
30
|
+
pid = os.fork()
|
|
31
|
+
if pid == 0:
|
|
32
|
+
# Child: attach to slave PTY
|
|
33
|
+
os.close(master_fd)
|
|
34
|
+
os.setsid()
|
|
35
|
+
fcntl.ioctl(slave_fd, termios.TIOCSCTTY, 0)
|
|
36
|
+
os.dup2(slave_fd, 0)
|
|
37
|
+
os.dup2(slave_fd, 1)
|
|
38
|
+
os.dup2(slave_fd, 2)
|
|
39
|
+
if slave_fd > 2:
|
|
40
|
+
os.close(slave_fd)
|
|
41
|
+
os.execvp(cmd, cmd_args)
|
|
42
|
+
else:
|
|
43
|
+
# Parent: bridge pipes <-> PTY master
|
|
44
|
+
os.close(slave_fd)
|
|
45
|
+
|
|
46
|
+
# Make master non-blocking
|
|
47
|
+
flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
|
|
48
|
+
fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
|
49
|
+
|
|
50
|
+
stdin_open = True
|
|
51
|
+
child_alive = True
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
while child_alive:
|
|
55
|
+
fds = [master_fd]
|
|
56
|
+
if stdin_open:
|
|
57
|
+
fds.append(0)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
r, _, _ = select.select(fds, [], [], 0.05)
|
|
61
|
+
except (select.error, ValueError, OSError):
|
|
62
|
+
break
|
|
63
|
+
|
|
64
|
+
# Read from stdin -> write to PTY master
|
|
65
|
+
if 0 in r:
|
|
66
|
+
try:
|
|
67
|
+
data = os.read(0, 65536)
|
|
68
|
+
if not data:
|
|
69
|
+
stdin_open = False
|
|
70
|
+
else:
|
|
71
|
+
# Check for resize escape: \x1b]R;<cols>;<rows>\x07
|
|
72
|
+
while b"\x1b]R;" in data:
|
|
73
|
+
idx = data.index(b"\x1b]R;")
|
|
74
|
+
try:
|
|
75
|
+
end = data.index(b"\x07", idx)
|
|
76
|
+
resize_data = data[idx+4:end].decode()
|
|
77
|
+
parts = resize_data.split(";")
|
|
78
|
+
if len(parts) == 2:
|
|
79
|
+
c, rr = int(parts[0]), int(parts[1])
|
|
80
|
+
ws = struct.pack("HHHH", rr, c, 0, 0)
|
|
81
|
+
fcntl.ioctl(master_fd, termios.TIOCSWINSZ, ws)
|
|
82
|
+
os.kill(pid, signal.SIGWINCH)
|
|
83
|
+
data = data[:idx] + data[end+1:]
|
|
84
|
+
except (ValueError, IndexError):
|
|
85
|
+
break
|
|
86
|
+
if data:
|
|
87
|
+
os.write(master_fd, data)
|
|
88
|
+
except OSError as e:
|
|
89
|
+
if e.errno == errno.EIO:
|
|
90
|
+
stdin_open = False
|
|
91
|
+
elif e.errno != errno.EAGAIN:
|
|
92
|
+
stdin_open = False
|
|
93
|
+
|
|
94
|
+
# Read from PTY master -> write to stdout
|
|
95
|
+
if master_fd in r:
|
|
96
|
+
try:
|
|
97
|
+
data = os.read(master_fd, 65536)
|
|
98
|
+
if data:
|
|
99
|
+
os.write(1, data)
|
|
100
|
+
except OSError as e:
|
|
101
|
+
if e.errno == errno.EIO:
|
|
102
|
+
# PTY closed — child exited
|
|
103
|
+
child_alive = False
|
|
104
|
+
elif e.errno != errno.EAGAIN:
|
|
105
|
+
child_alive = False
|
|
106
|
+
|
|
107
|
+
# Check if child exited (non-blocking)
|
|
108
|
+
try:
|
|
109
|
+
result = os.waitpid(pid, os.WNOHANG)
|
|
110
|
+
if result[0] != 0:
|
|
111
|
+
# Drain remaining output from master
|
|
112
|
+
while True:
|
|
113
|
+
try:
|
|
114
|
+
data = os.read(master_fd, 65536)
|
|
115
|
+
if not data:
|
|
116
|
+
break
|
|
117
|
+
os.write(1, data)
|
|
118
|
+
except OSError:
|
|
119
|
+
break
|
|
120
|
+
status = result[1]
|
|
121
|
+
code = os.WEXITSTATUS(status) if os.WIFEXITED(status) else 1
|
|
122
|
+
os.close(master_fd)
|
|
123
|
+
sys.exit(code)
|
|
124
|
+
except ChildProcessError:
|
|
125
|
+
child_alive = False
|
|
126
|
+
|
|
127
|
+
except KeyboardInterrupt:
|
|
128
|
+
pass
|
|
129
|
+
finally:
|
|
130
|
+
try:
|
|
131
|
+
os.close(master_fd)
|
|
132
|
+
except OSError:
|
|
133
|
+
pass
|
|
134
|
+
try:
|
|
135
|
+
_, status = os.waitpid(pid, 0)
|
|
136
|
+
code = os.WEXITSTATUS(status) if os.WIFEXITED(status) else 1
|
|
137
|
+
sys.exit(code)
|
|
138
|
+
except ChildProcessError:
|
|
139
|
+
pass
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "orchestrating",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Stream terminal sessions to the orchestrat.ing dashboard",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"orch": "./bin/orch"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/orch",
|
|
11
|
+
"bin/pty-helper.py",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18.0.0"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://orchestrat.ing",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://github.com/orchestrating/orchestrating"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"keywords": [
|
|
24
|
+
"cli",
|
|
25
|
+
"terminal",
|
|
26
|
+
"streaming",
|
|
27
|
+
"ai",
|
|
28
|
+
"claude",
|
|
29
|
+
"orchestration",
|
|
30
|
+
"dashboard"
|
|
31
|
+
],
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"ws": "^8.18.0"
|
|
34
|
+
}
|
|
35
|
+
}
|