orchestrating 0.3.3 → 0.4.1
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/bin/orch +129 -37
- package/package.json +1 -1
package/bin/orch
CHANGED
|
@@ -57,12 +57,17 @@ function clearAuth() {
|
|
|
57
57
|
const SUPABASE_URL = process.env.SUPABASE_URL || "https://vhyqfzdskgrmnxrdaqql.supabase.co";
|
|
58
58
|
const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY || "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZoeXFmemRza2dybW54cmRhcXFsIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzE1NjU5NTYsImV4cCI6MjA4NzE0MTk1Nn0.hWAceFshHoeTlnwThM08enNWG-ZeXICl3uyi-AmIJEk";
|
|
59
59
|
|
|
60
|
+
// Returns one of:
|
|
61
|
+
// { ok: true, token } — refreshed successfully
|
|
62
|
+
// { ok: false, permanent: true } — refresh token invalid/revoked; re-auth required
|
|
63
|
+
// { ok: false, permanent: false } — transient failure (network/5xx); safe to retry with backoff
|
|
60
64
|
async function refreshAuthToken() {
|
|
61
65
|
const auth = loadStoredAuth();
|
|
62
|
-
if (!auth || !auth.refresh_token) return
|
|
66
|
+
if (!auth || !auth.refresh_token) return { ok: false, permanent: true };
|
|
63
67
|
|
|
68
|
+
let res;
|
|
64
69
|
try {
|
|
65
|
-
|
|
70
|
+
res = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, {
|
|
66
71
|
method: "POST",
|
|
67
72
|
headers: {
|
|
68
73
|
"Content-Type": "application/json",
|
|
@@ -70,20 +75,30 @@ async function refreshAuthToken() {
|
|
|
70
75
|
},
|
|
71
76
|
body: JSON.stringify({ refresh_token: auth.refresh_token }),
|
|
72
77
|
});
|
|
78
|
+
} catch {
|
|
79
|
+
// Network error / server unreachable — transient
|
|
80
|
+
return { ok: false, permanent: false };
|
|
81
|
+
}
|
|
73
82
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (data.access_token) {
|
|
83
|
+
if (res.ok) {
|
|
84
|
+
let data;
|
|
85
|
+
try { data = await res.json(); } catch { return { ok: false, permanent: false }; }
|
|
86
|
+
if (data && data.access_token) {
|
|
78
87
|
saveAuth({
|
|
79
88
|
access_token: data.access_token,
|
|
80
89
|
refresh_token: data.refresh_token || auth.refresh_token,
|
|
81
90
|
expires_at: data.expires_at || 0,
|
|
82
91
|
});
|
|
83
|
-
return data.access_token;
|
|
92
|
+
return { ok: true, token: data.access_token };
|
|
84
93
|
}
|
|
85
|
-
|
|
86
|
-
|
|
94
|
+
return { ok: false, permanent: true };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 400/401/403 → refresh token rejected (permanent). 408/429/5xx/522 → transient.
|
|
98
|
+
if (res.status === 400 || res.status === 401 || res.status === 403) {
|
|
99
|
+
return { ok: false, permanent: true };
|
|
100
|
+
}
|
|
101
|
+
return { ok: false, permanent: false };
|
|
87
102
|
}
|
|
88
103
|
|
|
89
104
|
function getAuthToken() {
|
|
@@ -480,12 +495,16 @@ async function handleDaemon(projectsDir) {
|
|
|
480
495
|
process.exit(1);
|
|
481
496
|
}
|
|
482
497
|
if (isTokenExpired()) {
|
|
483
|
-
const
|
|
484
|
-
if (
|
|
485
|
-
token =
|
|
486
|
-
} else {
|
|
498
|
+
const r = await refreshAuthToken();
|
|
499
|
+
if (r.ok) {
|
|
500
|
+
token = r.token;
|
|
501
|
+
} else if (r.permanent) {
|
|
487
502
|
console.error(`${RED}Session expired. Run 'orch login' to re-authenticate.${RESET}`);
|
|
488
503
|
process.exit(1);
|
|
504
|
+
} else {
|
|
505
|
+
// Auth server unreachable at startup — keep the (stale) token and let the
|
|
506
|
+
// reconnect loop retry with backoff rather than crash-loop under launchd.
|
|
507
|
+
console.error(`${RED}Could not reach auth server — will keep retrying in the background.${RESET}`);
|
|
489
508
|
}
|
|
490
509
|
}
|
|
491
510
|
|
|
@@ -497,11 +516,17 @@ async function handleDaemon(projectsDir) {
|
|
|
497
516
|
const PING_INTERVAL_MS = 30_000;
|
|
498
517
|
const PONG_TIMEOUT_MS = 10_000;
|
|
499
518
|
const HEARTBEAT_TIMEOUT_MS = 65_000; // expect server heartbeat every 30s, allow 2 missed + margin
|
|
519
|
+
const RECONNECT_BASE_SEC = 2; // initial reconnect delay
|
|
520
|
+
const RECONNECT_MAX_SEC = 300; // cap backoff at 5 min so a down server isn't hammered
|
|
521
|
+
const REAUTH_POLL_SEC = 300; // while paused for re-auth, re-check the token every 5 min
|
|
500
522
|
let ws = null;
|
|
501
523
|
let pingTimer = null;
|
|
502
524
|
let pongTimer = null;
|
|
503
525
|
let reconnectTimer = null;
|
|
504
526
|
let heartbeatTimer = null;
|
|
527
|
+
let reauthTimer = null;
|
|
528
|
+
let reconnectAttempts = 0; // grows on consecutive failures; reset on a successful connect
|
|
529
|
+
let needsReauth = false; // true once the refresh token is rejected; pauses the reconnect loop
|
|
505
530
|
|
|
506
531
|
// Scan project directories
|
|
507
532
|
const homeDir = os.homedir();
|
|
@@ -536,25 +561,59 @@ async function handleDaemon(projectsDir) {
|
|
|
536
561
|
}, HEARTBEAT_TIMEOUT_MS);
|
|
537
562
|
}
|
|
538
563
|
|
|
539
|
-
function scheduleReconnect(
|
|
540
|
-
if (reconnecting) return;
|
|
564
|
+
function scheduleReconnect(minSec) {
|
|
565
|
+
if (reconnecting || needsReauth) return;
|
|
541
566
|
reconnecting = true;
|
|
567
|
+
// Exponential backoff with full jitter, capped — bounds the request rate when the
|
|
568
|
+
// server or auth endpoint is down (a fixed delay meant relentless retries forever).
|
|
569
|
+
const exp = Math.min(RECONNECT_MAX_SEC, RECONNECT_BASE_SEC * 2 ** reconnectAttempts);
|
|
570
|
+
const ceil = Math.max(minSec, exp);
|
|
571
|
+
const delay = Math.round(ceil / 2 + Math.random() * (ceil / 2));
|
|
572
|
+
reconnectAttempts++;
|
|
542
573
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
543
574
|
reconnectTimer = setTimeout(() => {
|
|
544
575
|
reconnecting = false;
|
|
545
576
|
connect();
|
|
546
|
-
},
|
|
577
|
+
}, delay * 1000);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Refresh token was rejected. Stop reconnecting (no point hammering the server) and
|
|
581
|
+
// wait for the user to run `orch login`. Staying alive — rather than exiting — avoids a
|
|
582
|
+
// launchd/systemd KeepAlive respawn loop that would just re-trigger the request flood.
|
|
583
|
+
function enterReauthWait() {
|
|
584
|
+
if (needsReauth) return;
|
|
585
|
+
needsReauth = true;
|
|
586
|
+
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
587
|
+
process.stderr.write(`${RED}${PREFIX} Session invalid — run 'orch login' to re-authenticate.${RESET}\n`);
|
|
588
|
+
process.stderr.write(`${DIM}${PREFIX} Paused; will resume automatically once re-authenticated.${RESET}\n`);
|
|
589
|
+
const check = () => {
|
|
590
|
+
const auth = loadStoredAuth();
|
|
591
|
+
if (auth && auth.access_token && !isTokenExpired()) {
|
|
592
|
+
needsReauth = false;
|
|
593
|
+
reconnectAttempts = 0;
|
|
594
|
+
token = auth.access_token;
|
|
595
|
+
process.stderr.write(`${GREEN}${PREFIX} Re-authenticated — reconnecting${RESET}\n`);
|
|
596
|
+
connect();
|
|
597
|
+
} else {
|
|
598
|
+
reauthTimer = setTimeout(check, REAUTH_POLL_SEC * 1000);
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
reauthTimer = setTimeout(check, REAUTH_POLL_SEC * 1000);
|
|
547
602
|
}
|
|
548
603
|
|
|
549
604
|
async function connect() {
|
|
605
|
+
if (needsReauth) return; // paused until the user re-authenticates
|
|
550
606
|
// Refresh token if needed before connecting
|
|
551
607
|
if (isTokenExpired()) {
|
|
552
|
-
const
|
|
553
|
-
if (
|
|
554
|
-
token =
|
|
608
|
+
const r = await refreshAuthToken();
|
|
609
|
+
if (r.ok) {
|
|
610
|
+
token = r.token;
|
|
555
611
|
process.stderr.write(`${DIM}${PREFIX} Token refreshed${RESET}\n`);
|
|
612
|
+
} else if (r.permanent) {
|
|
613
|
+
enterReauthWait();
|
|
614
|
+
return;
|
|
556
615
|
} else {
|
|
557
|
-
process.stderr.write(`${RED}${PREFIX} Token refresh failed —
|
|
616
|
+
process.stderr.write(`${RED}${PREFIX} Token refresh failed (auth server unreachable) — backing off${RESET}\n`);
|
|
558
617
|
scheduleReconnect(10);
|
|
559
618
|
return;
|
|
560
619
|
}
|
|
@@ -568,6 +627,7 @@ async function handleDaemon(projectsDir) {
|
|
|
568
627
|
ws = sock;
|
|
569
628
|
|
|
570
629
|
sock.on("open", () => {
|
|
630
|
+
reconnectAttempts = 0; // healthy connection — reset backoff
|
|
571
631
|
sock.send(JSON.stringify({
|
|
572
632
|
type: "register_daemon",
|
|
573
633
|
token,
|
|
@@ -610,14 +670,14 @@ async function handleDaemon(projectsDir) {
|
|
|
610
670
|
process.stderr.write(`${RED}${PREFIX} Server: ${msg.error}${RESET}\n`);
|
|
611
671
|
if (/unauthorized|auth|token/i.test(msg.error || "")) {
|
|
612
672
|
// Don't reconnect from here — let the close handler do it after refreshing
|
|
613
|
-
refreshAuthToken().then((
|
|
614
|
-
if (
|
|
615
|
-
token =
|
|
673
|
+
refreshAuthToken().then((r) => {
|
|
674
|
+
if (r.ok) {
|
|
675
|
+
token = r.token;
|
|
616
676
|
process.stderr.write(`${DIM}${PREFIX} Token refreshed — will reconnect${RESET}\n`);
|
|
617
|
-
} else {
|
|
618
|
-
|
|
619
|
-
process.exit(1);
|
|
677
|
+
} else if (r.permanent) {
|
|
678
|
+
enterReauthWait();
|
|
620
679
|
}
|
|
680
|
+
// transient: leave it — the close handler's backoff retry will try again
|
|
621
681
|
});
|
|
622
682
|
}
|
|
623
683
|
return;
|
|
@@ -769,6 +829,7 @@ async function handleDaemon(projectsDir) {
|
|
|
769
829
|
if (pongTimer) clearTimeout(pongTimer);
|
|
770
830
|
if (heartbeatTimer) clearTimeout(heartbeatTimer);
|
|
771
831
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
832
|
+
if (reauthTimer) clearTimeout(reauthTimer);
|
|
772
833
|
if (ws) ws.close();
|
|
773
834
|
process.exit(0);
|
|
774
835
|
};
|
|
@@ -1057,6 +1118,7 @@ if (adapter) {
|
|
|
1057
1118
|
// Spawn claude with bidirectional stdin/stdout (stream-json mode)
|
|
1058
1119
|
// Follow-ups are sent via stdin — no more kill/respawn.
|
|
1059
1120
|
let claudeSessionId = null; // Claude's internal session ID (from init event)
|
|
1121
|
+
let pendingRespawn = null; // { prompt } — set when permission grant needs respawn
|
|
1060
1122
|
|
|
1061
1123
|
function spawnClaude(claudeArgs) {
|
|
1062
1124
|
const proc = spawn(command, claudeArgs, {
|
|
@@ -1104,8 +1166,8 @@ if (adapter) {
|
|
|
1104
1166
|
if (yoloMode && event.kind === "permission_denied") {
|
|
1105
1167
|
process.stderr.write(`${GREEN}[yolo] Auto-approving: ${event.toolName}${RESET}\n`);
|
|
1106
1168
|
approvePermission(event.toolName, "session");
|
|
1107
|
-
//
|
|
1108
|
-
|
|
1169
|
+
// Queue respawn — Claude will exit after this turn, then we respawn with -c
|
|
1170
|
+
pendingRespawn = { prompt: `Permission for ${event.toolName} was granted. Please retry your last action.` };
|
|
1109
1171
|
}
|
|
1110
1172
|
}
|
|
1111
1173
|
});
|
|
@@ -1117,8 +1179,23 @@ if (adapter) {
|
|
|
1117
1179
|
proc.on("exit", (code) => {
|
|
1118
1180
|
childRunning = false;
|
|
1119
1181
|
const exitCode = code ?? 0;
|
|
1120
|
-
|
|
1121
|
-
|
|
1182
|
+
|
|
1183
|
+
if (exitCode !== 0 || exitRequested) {
|
|
1184
|
+
// Non-zero exit or user requested stop — terminate
|
|
1185
|
+
sendToServer({ type: "exit", sessionId, exitCode });
|
|
1186
|
+
setTimeout(() => process.exit(exitCode), 200);
|
|
1187
|
+
} else if (pendingRespawn) {
|
|
1188
|
+
// Permission grant/deny triggered a respawn — do it now
|
|
1189
|
+
const { prompt: respawnPrompt } = pendingRespawn;
|
|
1190
|
+
pendingRespawn = null;
|
|
1191
|
+
respawnWithContinue(respawnPrompt);
|
|
1192
|
+
} else {
|
|
1193
|
+
// Normal exit (turn complete) — stay alive, emit idle, wait for follow-ups
|
|
1194
|
+
sendToServer({
|
|
1195
|
+
type: "agent_event", sessionId,
|
|
1196
|
+
event: { kind: "status", status: "idle" },
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1122
1199
|
});
|
|
1123
1200
|
|
|
1124
1201
|
proc.on("error", (err) => {
|
|
@@ -1283,10 +1360,9 @@ if (adapter) {
|
|
|
1283
1360
|
|
|
1284
1361
|
handleServerMessage = (msg) => {
|
|
1285
1362
|
if (msg.type === "agent_input" && (msg.text || msg.images)) {
|
|
1286
|
-
// Follow-up from dashboard
|
|
1363
|
+
// Follow-up from dashboard
|
|
1287
1364
|
if (childRunning) {
|
|
1288
1365
|
process.stderr.write(`${DIM}[orch] Queuing input — claude is still processing${RESET}\n`);
|
|
1289
|
-
// TODO: queue and send after current turn completes
|
|
1290
1366
|
return;
|
|
1291
1367
|
}
|
|
1292
1368
|
let promptText = msg.text || "continue";
|
|
@@ -1303,17 +1379,33 @@ if (adapter) {
|
|
|
1303
1379
|
const fileList = imagePaths.map((p) => `[Attached image: ${p} — use Read tool to view]`).join("\n");
|
|
1304
1380
|
promptText = `${fileList}\n\n${promptText}`;
|
|
1305
1381
|
}
|
|
1306
|
-
|
|
1382
|
+
// If process is still alive, send via stdin; otherwise respawn
|
|
1383
|
+
if (child && child.exitCode === null) {
|
|
1384
|
+
sendFollowUp(promptText);
|
|
1385
|
+
} else {
|
|
1386
|
+
respawnWithContinue(promptText);
|
|
1387
|
+
}
|
|
1307
1388
|
} else if (msg.type === "agent_permission" && msg.tool && msg.action === "allow") {
|
|
1308
1389
|
const scope = msg.scope || "session";
|
|
1309
1390
|
approvePermission(msg.tool, scope);
|
|
1310
1391
|
process.stderr.write(`${GREEN}Permission granted (${scope}): ${msg.tool}${RESET}\n`);
|
|
1311
|
-
// Permissions are file-based
|
|
1312
|
-
|
|
1313
|
-
|
|
1392
|
+
// Permissions are file-based — Claude needs restart to pick up settings.local.json
|
|
1393
|
+
const respawnPrompt = `Permission for ${msg.tool} was granted. Please retry your last action.`;
|
|
1394
|
+
if (child && child.exitCode === null) {
|
|
1395
|
+
// Process still running — queue respawn for when it exits
|
|
1396
|
+
pendingRespawn = { prompt: respawnPrompt };
|
|
1397
|
+
} else {
|
|
1398
|
+
// Process already exited — respawn now
|
|
1399
|
+
respawnWithContinue(respawnPrompt);
|
|
1400
|
+
}
|
|
1314
1401
|
} else if (msg.type === "agent_permission" && msg.tool && msg.action === "deny") {
|
|
1315
1402
|
process.stderr.write(`${RED}Permission denied: ${msg.tool}${RESET}\n`);
|
|
1316
|
-
|
|
1403
|
+
const denyPrompt = `Permission for ${msg.tool} was denied by the user. Do not retry this tool — find an alternative approach or skip this step.`;
|
|
1404
|
+
if (child && child.exitCode === null) {
|
|
1405
|
+
pendingRespawn = { prompt: denyPrompt };
|
|
1406
|
+
} else {
|
|
1407
|
+
respawnWithContinue(denyPrompt);
|
|
1408
|
+
}
|
|
1317
1409
|
} else if (msg.type === "agent_permission" && msg.tool && msg.action === "revoke") {
|
|
1318
1410
|
removePermission(msg.tool);
|
|
1319
1411
|
broadcastPermissions();
|