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 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
+ }