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.
Files changed (2) hide show
  1. package/bin/orch +129 -37
  2. 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 null;
66
+ if (!auth || !auth.refresh_token) return { ok: false, permanent: true };
63
67
 
68
+ let res;
64
69
  try {
65
- const res = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, {
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
- if (!res.ok) return null;
75
-
76
- const data = await res.json();
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
- } catch {}
86
- return null;
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 refreshed = await refreshAuthToken();
484
- if (refreshed) {
485
- token = refreshed;
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(delaySec) {
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
- }, delaySec * 1000);
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 refreshed = await refreshAuthToken();
553
- if (refreshed) {
554
- token = refreshed;
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 — retrying in 10s${RESET}\n`);
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((refreshed) => {
614
- if (refreshed) {
615
- token = refreshed;
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
- process.stderr.write(`${RED}${PREFIX} Auth failed. Run 'orch login'.${RESET}\n`);
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
- // Permissions are file-based need respawn for Claude to pick them up
1108
- respawnWithContinue(`Permission for ${event.toolName} was granted. Please retry your last action.`);
1169
+ // Queue respawnClaude 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
- sendToServer({ type: "exit", sessionId, exitCode });
1121
- setTimeout(() => process.exit(exitCode), 200);
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 — send via stdin (no respawn!)
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
- sendFollowUp(promptText);
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 in Claude Code need to restart the process
1312
- // so it picks up the updated settings.local.json
1313
- respawnWithContinue(`Permission for ${msg.tool} was granted. Please retry your last action.`);
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
- respawnWithContinue(`Permission for ${msg.tool} was denied by the user. Do not retry this tool — find an alternative approach or skip this step.`);
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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orchestrating",
3
- "version": "0.3.3",
3
+ "version": "0.4.1",
4
4
  "description": "Stream terminal sessions to the orchestrat.ing dashboard",
5
5
  "type": "module",
6
6
  "bin": {