@h-rig/server 0.0.6-alpha.2 → 0.0.6-alpha.21

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.
@@ -4,8 +4,8 @@ var __require = import.meta.require;
4
4
  // packages/server/src/server-helpers/http-router.ts
5
5
  import { randomUUID } from "crypto";
6
6
  import { spawnSync as spawnSync2 } from "child_process";
7
- import { basename, dirname as dirname6, isAbsolute as isAbsolute2, resolve as resolve11 } from "path";
8
- import { copyFileSync, existsSync as existsSync8, mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync7 } from "fs";
7
+ import { basename, dirname as dirname9, isAbsolute as isAbsolute3, resolve as resolve13 } from "path";
8
+ import { copyFileSync as copyFileSync2, existsSync as existsSync10, mkdirSync as mkdirSync9, readFileSync as readFileSync7, writeFileSync as writeFileSync9 } from "fs";
9
9
 
10
10
  // packages/server/src/server.ts
11
11
  import {
@@ -224,6 +224,18 @@ function readJsonlFileTail(path, options) {
224
224
  const completeLines = start > 0 ? lines.slice(1) : lines;
225
225
  return parseJsonlRecords(completeLines.filter(Boolean).slice(-limit));
226
226
  }
227
+ async function readRunTimelinePage(projectRoot, runId, options = {}) {
228
+ const limit = Math.max(1, Math.min(Math.trunc(options.limit ?? 200), 500));
229
+ const cursor = options.cursor == null ? 0 : Number.parseInt(options.cursor, 10);
230
+ const entries = readJsonlFile(runTimelinePath(projectRoot, runId)).filter((entry) => Boolean(entry && typeof entry === "object" && !Array.isArray(entry)));
231
+ const startInclusive = Number.isFinite(cursor) ? Math.max(0, Math.min(cursor, entries.length)) : 0;
232
+ const endExclusive = Math.min(entries.length, startInclusive + limit);
233
+ return {
234
+ entries: entries.slice(startInclusive, endExclusive).map((entry, offset) => ({ ...entry, cursor: startInclusive + offset + 1 })),
235
+ nextCursor: String(endExclusive),
236
+ hasMore: endExclusive < entries.length
237
+ };
238
+ }
227
239
  var INITIAL_RUN_LOG_TAIL_MAX_BYTES = 8 * 1024 * 1024;
228
240
  async function readRunLogsPage(projectRoot, runId, options = {}) {
229
241
  const limit = Math.max(1, Math.min(Math.trunc(options.limit ?? 200), 500));
@@ -469,6 +481,273 @@ async function refreshTaskProjection(projectRoot, input) {
469
481
  });
470
482
  }
471
483
 
484
+ // packages/server/src/server-helpers/issue-analysis.ts
485
+ import { createHash } from "crypto";
486
+ function stableIssueHash(issue) {
487
+ const labels = Array.isArray(issue.labels) ? [...issue.labels].map(String).sort() : [];
488
+ const body = typeof issue.body === "string" ? issue.body : "";
489
+ const title = typeof issue.title === "string" ? issue.title : "";
490
+ return createHash("sha256").update(JSON.stringify({ id: issue.id, title, body, labels, deps: issue.deps, status: issue.status })).digest("hex");
491
+ }
492
+ function renderIssueAnalysisPrompt(input) {
493
+ const issue = input.issue;
494
+ const neighbors = input.neighbors ?? [];
495
+ return [
496
+ "You are Rig issue analysis running inside Pi.",
497
+ "Return JSON only with optional metadataPatch, labelsToAdd, labelsToRemove, and generatedIssues.",
498
+ "Preserve all human-authored issue body content. Only propose edits for Rig-owned metadata/status sections, labels, and generated issues.",
499
+ "Generated issues must be concrete, minimal follow-up tasks and will be labeled rig:generated by Rig.",
500
+ "",
501
+ "Issue:",
502
+ JSON.stringify({
503
+ id: issue.id,
504
+ title: issue.title,
505
+ body: issue.body,
506
+ labels: issue.labels,
507
+ deps: issue.deps,
508
+ status: issue.status
509
+ }, null, 2),
510
+ "",
511
+ "Neighbor tasks:",
512
+ JSON.stringify(neighbors.map((task) => ({ id: task.id, title: task.title, status: task.status, deps: task.deps })), null, 2)
513
+ ].join(`
514
+ `);
515
+ }
516
+ function isRecord(value) {
517
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
518
+ }
519
+ function stringArray(value) {
520
+ if (!Array.isArray(value))
521
+ return;
522
+ return value.map(String).filter((entry) => entry.trim().length > 0);
523
+ }
524
+ function generatedIssues(value) {
525
+ if (!Array.isArray(value))
526
+ return;
527
+ return value.flatMap((entry) => {
528
+ if (!isRecord(entry) || typeof entry.title !== "string")
529
+ return [];
530
+ return [{
531
+ title: entry.title,
532
+ body: typeof entry.body === "string" ? entry.body : "",
533
+ labels: stringArray(entry.labels) ?? [],
534
+ ...Array.isArray(entry.dependsOn) ? { dependsOn: entry.dependsOn.map(String) } : {}
535
+ }];
536
+ });
537
+ }
538
+ function findJsonLikeText(value) {
539
+ if (typeof value === "string") {
540
+ const trimmed = value.trim();
541
+ if (trimmed.startsWith("{") || trimmed.startsWith("```"))
542
+ return trimmed;
543
+ return null;
544
+ }
545
+ if (Array.isArray(value)) {
546
+ for (const entry of value) {
547
+ const found = findJsonLikeText(entry);
548
+ if (found)
549
+ return found;
550
+ }
551
+ return null;
552
+ }
553
+ if (!isRecord(value))
554
+ return null;
555
+ for (const key of ["text", "content", "message", "output_text", "response", "stdout"]) {
556
+ const found = findJsonLikeText(value[key]);
557
+ if (found)
558
+ return found;
559
+ }
560
+ for (const entry of Object.values(value)) {
561
+ const found = findJsonLikeText(entry);
562
+ if (found)
563
+ return found;
564
+ }
565
+ return null;
566
+ }
567
+ function candidateAnalysisObject(value) {
568
+ if (!isRecord(value))
569
+ return null;
570
+ if (isRecord(value.result))
571
+ return candidateAnalysisObject(value.result) ?? value.result;
572
+ if (isRecord(value.analysis))
573
+ return candidateAnalysisObject(value.analysis) ?? value.analysis;
574
+ if (isRecord(value.metadataPatch) || Array.isArray(value.labelsToAdd) || Array.isArray(value.labelsToRemove) || Array.isArray(value.generatedIssues)) {
575
+ return value;
576
+ }
577
+ const nested = findJsonLikeText(value);
578
+ if (nested && nested !== JSON.stringify(value)) {
579
+ try {
580
+ const parsedNested = JSON.parse(nested.match(/```(?:json)?\s*([\s\S]*?)\s*```/i)?.[1]?.trim() ?? nested);
581
+ return candidateAnalysisObject(parsedNested);
582
+ } catch {
583
+ return null;
584
+ }
585
+ }
586
+ return null;
587
+ }
588
+ function parseIssueAnalysisResult(raw) {
589
+ let parsed = raw;
590
+ if (typeof raw === "string") {
591
+ const trimmed = raw.trim();
592
+ const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i)?.[1]?.trim();
593
+ try {
594
+ parsed = JSON.parse(fenced ?? trimmed);
595
+ } catch {
596
+ const lastJsonLine = trimmed.split(/\r?\n/).reverse().find((line) => line.trim().startsWith("{"));
597
+ parsed = lastJsonLine ? JSON.parse(lastJsonLine) : {};
598
+ }
599
+ }
600
+ const candidate = candidateAnalysisObject(parsed);
601
+ if (!candidate)
602
+ return {};
603
+ const result = {};
604
+ if (isRecord(candidate.metadataPatch))
605
+ result.metadataPatch = candidate.metadataPatch;
606
+ const add = stringArray(candidate.labelsToAdd);
607
+ if (add?.length)
608
+ result.labelsToAdd = add;
609
+ const remove = stringArray(candidate.labelsToRemove);
610
+ if (remove?.length)
611
+ result.labelsToRemove = remove;
612
+ const generated = generatedIssues(candidate.generatedIssues);
613
+ if (generated?.length)
614
+ result.generatedIssues = generated;
615
+ return result;
616
+ }
617
+ function createDefaultPiIssueAnalysisCommandRunner() {
618
+ return async (command, args, options) => {
619
+ const env = options.env ? { ...process.env, ...options.env } : process.env;
620
+ const proc = Bun.spawn([command, ...args], {
621
+ stdout: "pipe",
622
+ stderr: "pipe",
623
+ env
624
+ });
625
+ let timedOut = false;
626
+ const timer = setTimeout(() => {
627
+ timedOut = true;
628
+ proc.kill();
629
+ }, options.timeoutMs);
630
+ try {
631
+ const [stdout, stderr, exitCode] = await Promise.all([
632
+ new Response(proc.stdout).text(),
633
+ new Response(proc.stderr).text(),
634
+ proc.exited
635
+ ]);
636
+ return {
637
+ exitCode: timedOut && exitCode === 0 ? 1 : exitCode,
638
+ stdout,
639
+ stderr: timedOut && stderr.trim().length === 0 ? `Pi issue analysis timed out after ${options.timeoutMs}ms` : stderr
640
+ };
641
+ } finally {
642
+ clearTimeout(timer);
643
+ }
644
+ };
645
+ }
646
+ function createPiIssueAnalyzer(input = {}) {
647
+ const piBinary = input.piBinary ?? process.env.RIG_ISSUE_ANALYSIS_PI_BINARY ?? "pi";
648
+ const timeoutMs = Math.max(1000, Math.trunc(input.timeoutMs ?? Number(process.env.RIG_ISSUE_ANALYSIS_TIMEOUT_MS ?? 120000)));
649
+ const runCommand = input.runCommand ?? createDefaultPiIssueAnalysisCommandRunner();
650
+ return async ({ prompt }) => {
651
+ const args = ["--print", "--mode", "json", "--no-session"];
652
+ const provider = input.provider?.trim() || process.env.RIG_ISSUE_ANALYSIS_PROVIDER?.trim() || process.env.RIG_PI_PROVIDER?.trim();
653
+ const model = input.model?.trim() || process.env.RIG_ISSUE_ANALYSIS_MODEL?.trim() || process.env.RIG_PI_MODEL?.trim() || "openai-codex/gpt-5.5";
654
+ if (provider)
655
+ args.push("--provider", provider);
656
+ if (model)
657
+ args.push("--model", model);
658
+ args.push(prompt);
659
+ const result = await runCommand(piBinary, args, { timeoutMs, ...input.env ? { env: input.env } : {} });
660
+ if (result.exitCode !== 0) {
661
+ throw new Error(`Pi issue analysis failed (exit ${result.exitCode}): ${result.stderr ?? result.stdout}`);
662
+ }
663
+ return parseIssueAnalysisResult(result.stdout);
664
+ };
665
+ }
666
+ function defaultStatusComment(input) {
667
+ const changes = [
668
+ input.result.metadataPatch ? "metadata" : null,
669
+ input.result.labelsToAdd?.length ? `labels added: ${input.result.labelsToAdd.join(", ")}` : null,
670
+ input.result.labelsToRemove?.length ? `labels removed: ${input.result.labelsToRemove.join(", ")}` : null,
671
+ input.result.generatedIssues?.length ? `generated issues: ${input.result.generatedIssues.length}` : null
672
+ ].filter((entry) => Boolean(entry));
673
+ if (changes.length === 0)
674
+ return null;
675
+ return [
676
+ "<!-- rig:status-comment -->",
677
+ "### Rig issue analysis",
678
+ "",
679
+ `Analyzed issue ${input.issue.id}${input.reason ? ` (${input.reason})` : ""}.`,
680
+ "",
681
+ ...changes.map((change) => `- ${change}`)
682
+ ].join(`
683
+ `);
684
+ }
685
+ function uniqueLabels(labels, required = []) {
686
+ return [...new Set([...labels ?? [], ...required].map((label) => label.trim()).filter(Boolean))];
687
+ }
688
+ function createIssueAnalysisWriteBack(input) {
689
+ return async ({ issue, result, reason }) => {
690
+ if (result.metadataPatch && Object.keys(result.metadataPatch).length > 0) {
691
+ if (!input.target.updateTask)
692
+ throw new Error("Issue analysis writeback requires updateTask for metadata patches.");
693
+ await input.target.updateTask(issue.id, { metadata: result.metadataPatch });
694
+ }
695
+ if (result.labelsToAdd?.length) {
696
+ if (!input.target.addLabels)
697
+ throw new Error("Issue analysis writeback requires addLabels for labelsToAdd.");
698
+ await input.target.addLabels(issue.id, uniqueLabels(result.labelsToAdd));
699
+ }
700
+ if (result.labelsToRemove?.length) {
701
+ if (!input.target.removeLabels)
702
+ throw new Error("Issue analysis writeback requires removeLabels for labelsToRemove.");
703
+ await input.target.removeLabels(issue.id, uniqueLabels(result.labelsToRemove));
704
+ }
705
+ const comment = (input.buildStatusComment ?? defaultStatusComment)({ issue, result, reason });
706
+ if (comment?.trim()) {
707
+ if (!input.target.updateTask)
708
+ throw new Error("Issue analysis writeback requires updateTask for sticky status comments.");
709
+ await input.target.updateTask(issue.id, { comment });
710
+ }
711
+ for (const generated of result.generatedIssues ?? []) {
712
+ if (!input.target.createIssue)
713
+ throw new Error("Issue analysis writeback requires createIssue for generated issues.");
714
+ await input.target.createIssue({
715
+ title: generated.title,
716
+ body: generated.dependsOn?.length ? `${generated.body.trimEnd()}
717
+
718
+ depends-on: ${generated.dependsOn.map((dep) => dep.startsWith("#") ? dep : `#${dep}`).join(", ")}
719
+ ` : generated.body,
720
+ labels: uniqueLabels(generated.labels, ["rig:generated"])
721
+ });
722
+ }
723
+ };
724
+ }
725
+ function createIssueAnalysisService(input) {
726
+ const analyzedHashes = new Map;
727
+ return {
728
+ async analyze(issues, options = {}) {
729
+ const results = [];
730
+ const neighbors = options.neighbors ?? issues;
731
+ for (const issue of issues) {
732
+ const hash = stableIssueHash(issue);
733
+ if (analyzedHashes.get(issue.id) === hash)
734
+ continue;
735
+ const prompt = renderIssueAnalysisPrompt({ issue, neighbors: neighbors.filter((candidate) => candidate.id !== issue.id) });
736
+ const result = await input.analyzer({ issue, neighbors, prompt });
737
+ analyzedHashes.set(issue.id, hash);
738
+ if (result.metadataPatch || result.labelsToAdd?.length || result.labelsToRemove?.length || result.generatedIssues?.length) {
739
+ await input.writeBack?.({ issue, result, reason: options.reason });
740
+ }
741
+ results.push({ issue, result });
742
+ }
743
+ return results;
744
+ },
745
+ clearCache() {
746
+ analyzedHashes.clear();
747
+ }
748
+ };
749
+ }
750
+
472
751
  // packages/server/src/server-helpers/terminal-runtime.ts
473
752
  import { WS_CHANNELS as WS_CHANNELS2 } from "@rig/contracts";
474
753
 
@@ -521,6 +800,11 @@ import {
521
800
  buildTaskRunLifecycleComment,
522
801
  updateConfiguredTaskSourceTask
523
802
  } from "@rig/runtime/control-plane/tasks/source-lifecycle";
803
+ import {
804
+ closeIssueAfterMergedPr,
805
+ commitRunChanges,
806
+ runPrAutomation
807
+ } from "@rig/runtime/control-plane/native/pr-automation";
524
808
 
525
809
  // packages/server/src/scheduler.ts
526
810
  import { normalizeTaskLifecycleStatus } from "@rig/runtime/control-plane/state-sync/types";
@@ -536,8 +820,8 @@ import {
536
820
 
537
821
  // packages/server/src/server-helpers/github-auth-store.ts
538
822
  import { randomBytes } from "crypto";
539
- import { chmodSync, existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
540
- import { resolve as resolve7 } from "path";
823
+ import { chmodSync, copyFileSync, existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
824
+ import { dirname as dirname3, resolve as resolve7 } from "path";
541
825
  function cleanString(value) {
542
826
  return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
543
827
  }
@@ -567,6 +851,26 @@ function parseApiSessions(value) {
567
851
  }];
568
852
  });
569
853
  }
854
+ function parsePendingDevice(value) {
855
+ if (!value || typeof value !== "object")
856
+ return null;
857
+ const record = value;
858
+ const pollId = cleanString(record.pollId);
859
+ const deviceCode = cleanString(record.deviceCode);
860
+ const expiresAt = cleanString(record.expiresAt);
861
+ const intervalSeconds = typeof record.intervalSeconds === "number" && Number.isFinite(record.intervalSeconds) ? Math.max(1, Math.floor(record.intervalSeconds)) : null;
862
+ if (!pollId || !deviceCode || !expiresAt || !intervalSeconds)
863
+ return null;
864
+ return { pollId, deviceCode, expiresAt, intervalSeconds };
865
+ }
866
+ function parsePendingDevices(value) {
867
+ if (!Array.isArray(value))
868
+ return [];
869
+ return value.flatMap((entry) => {
870
+ const pending = parsePendingDevice(entry);
871
+ return pending ? [pending] : [];
872
+ });
873
+ }
570
874
  function readStoredAuth(stateFile) {
571
875
  if (!existsSync4(stateFile))
572
876
  return {};
@@ -580,6 +884,7 @@ function readStoredAuth(stateFile) {
580
884
  selectedRepo: cleanString(parsed.selectedRepo),
581
885
  tokenSource: parsed.tokenSource === "oauth-device" || parsed.tokenSource === "manual-token" || parsed.tokenSource === "env" ? parsed.tokenSource : undefined,
582
886
  pendingDevice: parsePendingDevice(parsed.pendingDevice),
887
+ pendingDevices: parsePendingDevices(parsed.pendingDevices),
583
888
  apiSessions: parseApiSessions(parsed.apiSessions),
584
889
  updatedAt: cleanString(parsed.updatedAt) ?? undefined
585
890
  };
@@ -587,34 +892,36 @@ function readStoredAuth(stateFile) {
587
892
  return {};
588
893
  }
589
894
  }
590
- function parsePendingDevice(value) {
591
- if (!value || typeof value !== "object")
592
- return null;
593
- const record = value;
594
- const pollId = cleanString(record.pollId);
595
- const deviceCode = cleanString(record.deviceCode);
596
- const expiresAt = cleanString(record.expiresAt);
597
- const intervalSeconds = typeof record.intervalSeconds === "number" && Number.isFinite(record.intervalSeconds) ? Math.max(1, Math.floor(record.intervalSeconds)) : null;
598
- if (!pollId || !deviceCode || !expiresAt || !intervalSeconds)
599
- return null;
600
- return { pollId, deviceCode, expiresAt, intervalSeconds };
601
- }
602
895
  function newApiSessionToken() {
603
896
  return `rig_${randomBytes(32).toString("base64url")}`;
604
897
  }
605
898
  function writeStoredAuth(stateFile, payload) {
606
- mkdirSync3(resolve7(stateFile, ".."), { recursive: true });
899
+ mkdirSync3(dirname3(stateFile), { recursive: true });
607
900
  writeFileSync3(stateFile, `${JSON.stringify(payload, null, 2)}
608
901
  `, { encoding: "utf8", mode: 384 });
609
902
  try {
610
903
  chmodSync(stateFile, 384);
611
904
  } catch {}
612
905
  }
906
+ function localProjectAuthStateFile(projectRoot) {
907
+ return resolve7(projectRoot, ".rig", "state", "github-auth.json");
908
+ }
613
909
  function resolveGitHubAuthStateFile(projectRoot) {
614
910
  return resolve7(resolveServerAuthorityPaths(projectRoot).stateDir, "github-auth.json");
615
911
  }
616
- function createGitHubAuthStore(projectRoot) {
617
- const stateFile = resolveGitHubAuthStateFile(projectRoot);
912
+ function copyGitHubAuthStateToLocalProjectRoot(stateFile, projectRoot) {
913
+ const targetFile = localProjectAuthStateFile(projectRoot);
914
+ mkdirSync3(dirname3(targetFile), { recursive: true });
915
+ if (existsSync4(stateFile)) {
916
+ copyFileSync(stateFile, targetFile);
917
+ try {
918
+ chmodSync(targetFile, 384);
919
+ } catch {}
920
+ return;
921
+ }
922
+ writeStoredAuth(targetFile, {});
923
+ }
924
+ function createGitHubAuthStoreFromStateFile(stateFile) {
618
925
  return {
619
926
  stateFile,
620
927
  status(options) {
@@ -644,6 +951,7 @@ function createGitHubAuthStore(projectRoot) {
644
951
  scopes: input.scopes ?? [],
645
952
  selectedRepo: input.selectedRepo ?? previous.selectedRepo ?? null,
646
953
  pendingDevice: null,
954
+ pendingDevices: [],
647
955
  apiSessions: previous.apiSessions ?? [],
648
956
  updatedAt: new Date().toISOString()
649
957
  });
@@ -672,15 +980,24 @@ function createGitHubAuthStore(projectRoot) {
672
980
  const session = (previous.apiSessions ?? []).find((candidate) => candidate.token === clean);
673
981
  return session ? { login: cleanString(session.login), userId: cleanString(session.userId) } : null;
674
982
  },
675
- copyToProjectRoot(projectRoot2) {
676
- const targetFile = resolveGitHubAuthStateFile(projectRoot2);
983
+ copyToProjectRoot(projectRoot) {
984
+ const targetFile = resolveGitHubAuthStateFile(projectRoot);
677
985
  writeStoredAuth(targetFile, readStoredAuth(stateFile));
678
986
  },
987
+ copyToLocalProjectRoot(projectRoot) {
988
+ copyGitHubAuthStateToLocalProjectRoot(stateFile, projectRoot);
989
+ },
679
990
  savePendingDevice(input) {
680
991
  const previous = readStoredAuth(stateFile);
992
+ const pendingDevices = [
993
+ ...previous.pendingDevice ? [previous.pendingDevice] : [],
994
+ ...previous.pendingDevices ?? [],
995
+ input
996
+ ].filter((entry, index, entries) => entries.findIndex((candidate) => candidate.pollId === entry.pollId) === index);
681
997
  writeStoredAuth(stateFile, {
682
998
  ...previous,
683
- pendingDevice: input,
999
+ pendingDevice: null,
1000
+ pendingDevices,
684
1001
  updatedAt: new Date().toISOString()
685
1002
  });
686
1003
  },
@@ -693,23 +1010,32 @@ function createGitHubAuthStore(projectRoot) {
693
1010
  });
694
1011
  },
695
1012
  readPendingDevice(pollId) {
696
- const pending = readStoredAuth(stateFile).pendingDevice ?? null;
697
- if (!pending || pending.pollId !== pollId)
1013
+ const previous = readStoredAuth(stateFile);
1014
+ const pending = [
1015
+ ...previous.pendingDevice ? [previous.pendingDevice] : [],
1016
+ ...previous.pendingDevices ?? []
1017
+ ].find((entry) => entry.pollId === pollId) ?? null;
1018
+ if (!pending)
698
1019
  return null;
699
1020
  if (Date.parse(pending.expiresAt) <= Date.now())
700
1021
  return null;
701
1022
  return pending;
702
1023
  },
703
- clearPendingDevice() {
1024
+ clearPendingDevice(pollId) {
704
1025
  const previous = readStoredAuth(stateFile);
1026
+ const remaining = pollId ? (previous.pendingDevices ?? []).filter((entry) => entry.pollId !== pollId) : [];
705
1027
  writeStoredAuth(stateFile, {
706
1028
  ...previous,
707
1029
  pendingDevice: null,
1030
+ pendingDevices: remaining,
708
1031
  updatedAt: new Date().toISOString()
709
1032
  });
710
1033
  }
711
1034
  };
712
1035
  }
1036
+ function createGitHubAuthStore(projectRoot) {
1037
+ return createGitHubAuthStoreFromStateFile(resolveGitHubAuthStateFile(projectRoot));
1038
+ }
713
1039
 
714
1040
  // packages/server/src/server-helpers/github-projects.ts
715
1041
  function asRecord(value) {
@@ -718,6 +1044,9 @@ function asRecord(value) {
718
1044
  function asString(value) {
719
1045
  return typeof value === "string" && value.trim().length > 0 ? value : undefined;
720
1046
  }
1047
+ function asNumber(value) {
1048
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
1049
+ }
721
1050
  async function defaultGraphQLFetch(query, variables, token) {
722
1051
  const response = await fetch("https://api.github.com/graphql", {
723
1052
  method: "POST",
@@ -734,6 +1063,32 @@ async function defaultGraphQLFetch(query, variables, token) {
734
1063
  }
735
1064
  return json.data;
736
1065
  }
1066
+ function projectNodesFrom(data) {
1067
+ const root = asRecord(data);
1068
+ const owner = asRecord(root?.organization) ?? asRecord(root?.user);
1069
+ const projects = asRecord(owner?.projectsV2);
1070
+ const nodes = projects?.nodes;
1071
+ return Array.isArray(nodes) ? nodes : [];
1072
+ }
1073
+ async function listGitHubProjects(input) {
1074
+ const query = `
1075
+ query RigListProjects($owner: String!, $first: Int!) {
1076
+ organization(login: $owner) { projectsV2(first: $first, orderBy: { field: UPDATED_AT, direction: DESC }) { nodes { id number title url } } }
1077
+ user(login: $owner) { projectsV2(first: $first, orderBy: { field: UPDATED_AT, direction: DESC }) { nodes { id number title url } } }
1078
+ }
1079
+ `;
1080
+ const fetchGraphQL = input.fetchGraphQL ?? defaultGraphQLFetch;
1081
+ const data = await fetchGraphQL(query, { owner: input.owner, first: input.first ?? 20 }, input.token);
1082
+ return projectNodesFrom(data).flatMap((node) => {
1083
+ const record = asRecord(node);
1084
+ const id = asString(record?.id);
1085
+ const number = asNumber(record?.number);
1086
+ const title = asString(record?.title);
1087
+ if (!id || number === undefined || !title)
1088
+ return [];
1089
+ return [{ id, number, title, ...asString(record?.url) ? { url: asString(record?.url) } : {} }];
1090
+ });
1091
+ }
737
1092
  async function resolveProjectStatusField(input) {
738
1093
  const query = `
739
1094
  query RigProjectStatusField($projectId: ID!) {
@@ -828,6 +1183,7 @@ var DEFAULT_PROJECT_STATUSES = {
828
1183
  running: "In Progress",
829
1184
  prOpen: "In Review",
830
1185
  ciFixing: "In Review",
1186
+ merging: "Merging",
831
1187
  done: "Done",
832
1188
  needsAttention: "Needs Attention"
833
1189
  };
@@ -841,6 +1197,8 @@ function lifecycleStatusForTaskStatus(status) {
841
1197
  return "prOpen";
842
1198
  if (normalized === "ci_fixing" || normalized === "fixing")
843
1199
  return "ciFixing";
1200
+ if (normalized === "merging" || normalized === "merge")
1201
+ return "merging";
844
1202
  if (normalized === "failed" || normalized === "needs_attention" || normalized === "blocked")
845
1203
  return "needsAttention";
846
1204
  if (normalized === "in_progress" || normalized === "running" || normalized === "ready" || normalized === "open")
@@ -916,7 +1274,8 @@ var TERMINAL_RUN_STATUSES2 = new Set([
916
1274
  "needs-attention",
917
1275
  "stopped"
918
1276
  ]);
919
- var ORPHANABLE_LOCAL_RUN_STATUSES = new Set(["preparing", "running"]);
1277
+ var RESUMABLE_SERVER_CLOSEOUT_STATUSES = new Set(["pending", "running"]);
1278
+ var ACTIVE_LOCAL_RUN_STATUSES = new Set(["created", "preparing", "running", "validating", "reviewing"]);
920
1279
 
921
1280
  // packages/server/src/server-helpers/ws-router.ts
922
1281
  import {
@@ -1098,7 +1457,7 @@ import {
1098
1457
  } from "@rig/runtime/control-plane/remote";
1099
1458
 
1100
1459
  // packages/server/src/server-helpers/run-steering.ts
1101
- import { dirname as dirname3, resolve as resolve8 } from "path";
1460
+ import { dirname as dirname4, resolve as resolve8 } from "path";
1102
1461
  import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync3 } from "fs";
1103
1462
  import { appendJsonlRecord as appendJsonlRecord2, readAuthorityRun as readAuthorityRun7, resolveAuthorityRunDir as resolveAuthorityRunDir4 } from "@rig/runtime/control-plane/authority-files";
1104
1463
  var steeringSequence = 0;
@@ -1185,7 +1544,7 @@ function queueRunSteeringMessage(projectRoot, runId, input) {
1185
1544
  delivered: false
1186
1545
  };
1187
1546
  const path = runSteeringPath(projectRoot, runId);
1188
- mkdirSync4(dirname3(path), { recursive: true });
1547
+ mkdirSync4(dirname4(path), { recursive: true });
1189
1548
  appendJsonlRecord2(path, entry);
1190
1549
  appendRunTimelineEntry(projectRoot, runId, {
1191
1550
  id: entry.id,
@@ -1222,24 +1581,205 @@ import {
1222
1581
  updateConfiguredTaskSourceTask as updateConfiguredTaskSourceTask2
1223
1582
  } from "@rig/runtime/control-plane/tasks/source-lifecycle";
1224
1583
 
1584
+ // packages/server/src/server-helpers/github-api-session-index.ts
1585
+ import { chmodSync as chmodSync3, existsSync as existsSync7, mkdirSync as mkdirSync6, readFileSync as readFileSync5, writeFileSync as writeFileSync6 } from "fs";
1586
+ import { dirname as dirname6, resolve as resolve10 } from "path";
1587
+
1588
+ // packages/server/src/server-helpers/github-user-namespace.ts
1589
+ import { chmodSync as chmodSync2, existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "fs";
1590
+ import { dirname as dirname5, isAbsolute, relative, resolve as resolve9 } from "path";
1591
+ function cleanString3(value) {
1592
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
1593
+ }
1594
+ function sanitizePathSegment(value) {
1595
+ return value.trim().toLowerCase().replace(/[^a-z0-9._-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 96);
1596
+ }
1597
+ function deriveGitHubUserNamespaceKey(identity) {
1598
+ const userId = cleanString3(identity.userId);
1599
+ if (userId) {
1600
+ const safeId = sanitizePathSegment(userId);
1601
+ if (safeId)
1602
+ return `ghu-${safeId}`;
1603
+ }
1604
+ const login = cleanString3(identity.login);
1605
+ if (login) {
1606
+ const safeLogin = sanitizePathSegment(login);
1607
+ if (safeLogin)
1608
+ return `ghu-login-${safeLogin}`;
1609
+ }
1610
+ throw new Error("GitHub user namespace requires a user id or login");
1611
+ }
1612
+ function resolveRemoteUserNamespacesRoot(projectRoot) {
1613
+ const explicitRoot = cleanString3(process.env.RIG_REMOTE_USER_NAMESPACE_ROOT);
1614
+ if (explicitRoot)
1615
+ return resolve9(explicitRoot);
1616
+ const stateDir2 = cleanString3(process.env.RIG_STATE_DIR);
1617
+ if (stateDir2)
1618
+ return resolve9(dirname5(resolve9(stateDir2)), "users");
1619
+ return resolve9(projectRoot, ".rig", "users");
1620
+ }
1621
+ function resolveRemoteUserNamespace(projectRoot, identity) {
1622
+ const key = deriveGitHubUserNamespaceKey(identity);
1623
+ const root = resolve9(resolveRemoteUserNamespacesRoot(projectRoot), key);
1624
+ const stateDir2 = resolve9(root, ".rig", "state");
1625
+ return {
1626
+ key,
1627
+ userId: cleanString3(identity.userId),
1628
+ login: cleanString3(identity.login),
1629
+ root,
1630
+ stateDir: stateDir2,
1631
+ authStateFile: resolve9(stateDir2, "github-auth.json"),
1632
+ metadataFile: resolve9(stateDir2, "user-namespace.json"),
1633
+ checkoutBaseDir: resolve9(root, "remote-checkouts"),
1634
+ snapshotBaseDir: resolve9(root, "remote-snapshots")
1635
+ };
1636
+ }
1637
+ function serializeRemoteUserNamespace(namespace) {
1638
+ return {
1639
+ key: namespace.key,
1640
+ userId: namespace.userId,
1641
+ login: namespace.login,
1642
+ root: namespace.root,
1643
+ checkoutBaseDir: namespace.checkoutBaseDir,
1644
+ snapshotBaseDir: namespace.snapshotBaseDir
1645
+ };
1646
+ }
1647
+ function isPathInsideNamespace(namespaceRoot, candidatePath) {
1648
+ const root = resolve9(namespaceRoot);
1649
+ const candidate = resolve9(candidatePath);
1650
+ const rel = relative(root, candidate);
1651
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
1652
+ }
1653
+ function writeRemoteUserNamespaceMetadata(namespace) {
1654
+ mkdirSync5(namespace.stateDir, { recursive: true });
1655
+ const previous = (() => {
1656
+ if (!existsSync6(namespace.metadataFile))
1657
+ return null;
1658
+ try {
1659
+ const parsed = JSON.parse(readFileSync4(namespace.metadataFile, "utf8"));
1660
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
1661
+ } catch {
1662
+ return null;
1663
+ }
1664
+ })();
1665
+ const now = new Date().toISOString();
1666
+ writeFileSync5(namespace.metadataFile, `${JSON.stringify({
1667
+ key: namespace.key,
1668
+ userId: namespace.userId,
1669
+ login: namespace.login,
1670
+ root: namespace.root,
1671
+ checkoutBaseDir: namespace.checkoutBaseDir,
1672
+ snapshotBaseDir: namespace.snapshotBaseDir,
1673
+ createdAt: typeof previous?.createdAt === "string" ? previous.createdAt : now,
1674
+ updatedAt: now
1675
+ }, null, 2)}
1676
+ `, { encoding: "utf8", mode: 384 });
1677
+ try {
1678
+ chmodSync2(namespace.metadataFile, 384);
1679
+ } catch {}
1680
+ }
1681
+
1682
+ // packages/server/src/server-helpers/github-api-session-index.ts
1683
+ function cleanString4(value) {
1684
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
1685
+ }
1686
+ function resolveGitHubApiSessionIndexFile(projectRoot) {
1687
+ return resolve10(resolveRemoteUserNamespacesRoot(projectRoot), ".api-sessions.json");
1688
+ }
1689
+ function parseEntry(value) {
1690
+ if (!value || typeof value !== "object" || Array.isArray(value))
1691
+ return null;
1692
+ const record = value;
1693
+ const token = cleanString4(record.token);
1694
+ const namespaceKey = cleanString4(record.namespaceKey);
1695
+ const namespaceRoot = cleanString4(record.namespaceRoot);
1696
+ const authStateFile = cleanString4(record.authStateFile);
1697
+ const checkoutBaseDir = cleanString4(record.checkoutBaseDir);
1698
+ const snapshotBaseDir = cleanString4(record.snapshotBaseDir);
1699
+ const createdAt = cleanString4(record.createdAt);
1700
+ if (!token || !namespaceKey || !namespaceRoot || !authStateFile || !checkoutBaseDir || !snapshotBaseDir || !createdAt)
1701
+ return null;
1702
+ return {
1703
+ token,
1704
+ namespaceKey,
1705
+ namespaceRoot,
1706
+ authStateFile,
1707
+ checkoutBaseDir,
1708
+ snapshotBaseDir,
1709
+ createdAt,
1710
+ login: cleanString4(record.login),
1711
+ userId: cleanString4(record.userId),
1712
+ selectedRepo: cleanString4(record.selectedRepo)
1713
+ };
1714
+ }
1715
+ function readIndex(indexFile) {
1716
+ if (!existsSync7(indexFile))
1717
+ return [];
1718
+ try {
1719
+ const parsed = JSON.parse(readFileSync5(indexFile, "utf8"));
1720
+ return Array.isArray(parsed.sessions) ? parsed.sessions.flatMap((entry) => {
1721
+ const parsedEntry = parseEntry(entry);
1722
+ return parsedEntry ? [parsedEntry] : [];
1723
+ }) : [];
1724
+ } catch {
1725
+ return [];
1726
+ }
1727
+ }
1728
+ function writeIndex(indexFile, sessions) {
1729
+ mkdirSync6(dirname6(indexFile), { recursive: true });
1730
+ writeFileSync6(indexFile, `${JSON.stringify({ sessions }, null, 2)}
1731
+ `, { encoding: "utf8", mode: 384 });
1732
+ try {
1733
+ chmodSync3(indexFile, 384);
1734
+ } catch {}
1735
+ }
1736
+ function registerGitHubApiSession(input) {
1737
+ const cleanToken = cleanString4(input.token);
1738
+ if (!cleanToken)
1739
+ throw new Error("GitHub API session token is required");
1740
+ const indexFile = resolveGitHubApiSessionIndexFile(input.projectRoot);
1741
+ const createdAt = new Date().toISOString();
1742
+ const entry = {
1743
+ token: cleanToken,
1744
+ login: input.namespace.login,
1745
+ userId: input.namespace.userId,
1746
+ namespaceKey: input.namespace.key,
1747
+ namespaceRoot: input.namespace.root,
1748
+ authStateFile: input.namespace.authStateFile,
1749
+ checkoutBaseDir: input.namespace.checkoutBaseDir,
1750
+ snapshotBaseDir: input.namespace.snapshotBaseDir,
1751
+ selectedRepo: cleanString4(input.selectedRepo),
1752
+ createdAt
1753
+ };
1754
+ const previous = readIndex(indexFile).filter((session) => session.token !== cleanToken);
1755
+ writeIndex(indexFile, [...previous.slice(-199), entry]);
1756
+ return entry;
1757
+ }
1758
+ function readGitHubApiSession(input) {
1759
+ const cleanToken = cleanString4(input.token);
1760
+ if (!cleanToken)
1761
+ return null;
1762
+ return readIndex(resolveGitHubApiSessionIndexFile(input.projectRoot)).find((entry) => entry.token === cleanToken) ?? null;
1763
+ }
1764
+
1225
1765
  // packages/server/src/server-helpers/project-registry.ts
1226
- import { createHash } from "crypto";
1766
+ import { createHash as createHash2 } from "crypto";
1227
1767
  import { spawnSync } from "child_process";
1228
- import { existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync4, readdirSync, writeFileSync as writeFileSync5 } from "fs";
1229
- import { dirname as dirname4, resolve as resolve9 } from "path";
1768
+ import { existsSync as existsSync8, mkdirSync as mkdirSync7, readFileSync as readFileSync6, readdirSync, writeFileSync as writeFileSync7 } from "fs";
1769
+ import { dirname as dirname7, resolve as resolve11 } from "path";
1230
1770
  function normalizeRepoSlug(value) {
1231
1771
  const trimmed = value.trim();
1232
1772
  return /^[^/\s]+\/[^/\s]+$/.test(trimmed) ? trimmed : null;
1233
1773
  }
1234
1774
  function registryPath(projectRoot) {
1235
- return resolve9(projectRoot, ".rig", "state", "projects.json");
1775
+ return resolve11(projectRoot, ".rig", "state", "projects.json");
1236
1776
  }
1237
1777
  function readRegistry(projectRoot) {
1238
1778
  const path = registryPath(projectRoot);
1239
- if (!existsSync6(path))
1779
+ if (!existsSync8(path))
1240
1780
  return {};
1241
1781
  try {
1242
- const payload = JSON.parse(readFileSync4(path, "utf8"));
1782
+ const payload = JSON.parse(readFileSync6(path, "utf8"));
1243
1783
  if (!payload || typeof payload !== "object" || Array.isArray(payload))
1244
1784
  return {};
1245
1785
  const projects = payload.projects;
@@ -1250,14 +1790,14 @@ function readRegistry(projectRoot) {
1250
1790
  }
1251
1791
  function writeRegistry(projectRoot, projects) {
1252
1792
  const path = registryPath(projectRoot);
1253
- mkdirSync5(dirname4(path), { recursive: true });
1254
- writeFileSync5(path, `${JSON.stringify({ projects }, null, 2)}
1793
+ mkdirSync7(dirname7(path), { recursive: true });
1794
+ writeFileSync7(path, `${JSON.stringify({ projects }, null, 2)}
1255
1795
  `, "utf8");
1256
1796
  }
1257
1797
  function resolveConfigPath(projectRoot) {
1258
1798
  for (const name of ["rig.config.ts", "rig.config.mts", "rig.config.json"]) {
1259
- const path = resolve9(projectRoot, name);
1260
- if (existsSync6(path))
1799
+ const path = resolve11(projectRoot, name);
1800
+ if (existsSync8(path))
1261
1801
  return path;
1262
1802
  }
1263
1803
  return null;
@@ -1266,7 +1806,7 @@ function hashFile(path) {
1266
1806
  if (!path)
1267
1807
  return null;
1268
1808
  try {
1269
- return createHash("sha256").update(readFileSync4(path)).digest("hex");
1809
+ return createHash2("sha256").update(readFileSync6(path)).digest("hex");
1270
1810
  } catch {
1271
1811
  return null;
1272
1812
  }
@@ -1282,11 +1822,11 @@ function readDefaultBranch(projectRoot) {
1282
1822
  return head.status === 0 && head.stdout.trim() && head.stdout.trim() !== "HEAD" ? head.stdout.trim() : null;
1283
1823
  }
1284
1824
  function buildRunSummary(projectRoot) {
1285
- const runsDir = resolve9(projectRoot, ".rig", "runs");
1825
+ const runsDir = resolve11(projectRoot, ".rig", "runs");
1286
1826
  try {
1287
1827
  const runs = readdirSync(runsDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).flatMap((entry) => {
1288
1828
  try {
1289
- const run = JSON.parse(readFileSync4(resolve9(runsDir, entry.name, "run.json"), "utf8"));
1829
+ const run = JSON.parse(readFileSync6(resolve11(runsDir, entry.name, "run.json"), "utf8"));
1290
1830
  return [{ runId: typeof run.runId === "string" ? run.runId : entry.name, status: typeof run.status === "string" ? run.status : "unknown", updatedAt: typeof run.updatedAt === "string" ? run.updatedAt : "" }];
1291
1831
  } catch {
1292
1832
  return [];
@@ -1338,10 +1878,14 @@ function upsertProjectRecord(projectRoot, input) {
1338
1878
  function linkProjectCheckout(projectRoot, repoSlug, checkout) {
1339
1879
  return upsertProjectRecord(projectRoot, { repoSlug, checkout });
1340
1880
  }
1881
+ function projectRegistryContainsCheckout(projectRoot, checkoutPath) {
1882
+ const target = resolve11(checkoutPath);
1883
+ return Object.values(readRegistry(projectRoot)).some((project) => project.checkouts.some((checkout) => checkout.path ? resolve11(checkout.path) === target : false));
1884
+ }
1341
1885
 
1342
1886
  // packages/server/src/server-helpers/remote-checkout.ts
1343
- import { existsSync as existsSync7, mkdirSync as mkdirSync6, writeFileSync as writeFileSync6 } from "fs";
1344
- import { dirname as dirname5, isAbsolute, relative, resolve as resolve10 } from "path";
1887
+ import { existsSync as existsSync9, mkdirSync as mkdirSync8, writeFileSync as writeFileSync8 } from "fs";
1888
+ import { dirname as dirname8, isAbsolute as isAbsolute2, relative as relative2, resolve as resolve12 } from "path";
1345
1889
  function safeSlugSegments(repoSlug) {
1346
1890
  const segments = repoSlug.split("/").map((part) => part.trim()).filter(Boolean);
1347
1891
  if (segments.length !== 2 || segments.some((segment) => segment === "." || segment === ".." || segment.includes("\\"))) {
@@ -1358,7 +1902,7 @@ function safeCheckoutKey(value) {
1358
1902
  }
1359
1903
  function repoSlugPath(baseDir, repoSlug, checkoutKey) {
1360
1904
  const key = safeCheckoutKey(checkoutKey);
1361
- return resolve10(baseDir, ...key ? [key] : [], ...safeSlugSegments(repoSlug));
1905
+ return resolve12(baseDir, ...key ? [key] : [], ...safeSlugSegments(repoSlug));
1362
1906
  }
1363
1907
  function sanitizeSnapshotId(value, fallback) {
1364
1908
  const raw = (value ?? fallback).trim();
@@ -1366,7 +1910,7 @@ function sanitizeSnapshotId(value, fallback) {
1366
1910
  return safe || fallback;
1367
1911
  }
1368
1912
  function assertWithinRoot(root, relativePath) {
1369
- if (!relativePath || isAbsolute(relativePath) || relativePath.includes("\x00")) {
1913
+ if (!relativePath || isAbsolute2(relativePath) || relativePath.includes("\x00")) {
1370
1914
  throw new Error(`Invalid snapshot file path: ${relativePath}`);
1371
1915
  }
1372
1916
  const normalizedRelative = relativePath.replace(/\\/g, "/");
@@ -1374,9 +1918,9 @@ function assertWithinRoot(root, relativePath) {
1374
1918
  if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
1375
1919
  throw new Error(`Unsafe snapshot file path: ${relativePath}`);
1376
1920
  }
1377
- const target = resolve10(root, ...segments);
1378
- const rel = relative(root, target);
1379
- if (rel === ".." || rel.split(/[\\/]/)[0] === ".." || isAbsolute(rel)) {
1921
+ const target = resolve12(root, ...segments);
1922
+ const rel = relative2(root, target);
1923
+ if (rel === ".." || rel.split(/[\\/]/)[0] === ".." || isAbsolute2(rel)) {
1380
1924
  throw new Error(`Snapshot file path escapes checkout root: ${relativePath}`);
1381
1925
  }
1382
1926
  return target;
@@ -1394,17 +1938,17 @@ function parseSnapshotArchiveContentBase64(contentBase64) {
1394
1938
  function extractUploadedSnapshotArchive(input) {
1395
1939
  const archive = decodeSnapshotArchive(input.archive);
1396
1940
  const snapshotId = sanitizeSnapshotId(input.snapshotId, `snapshot-${(input.now?.() ?? new Date).toISOString().replace(/[:.]/g, "-")}`);
1397
- const checkoutPath = resolve10(repoSlugPath(input.baseDir, input.repoSlug, input.checkoutKey), snapshotId);
1398
- mkdirSync6(checkoutPath, { recursive: true });
1941
+ const checkoutPath = resolve12(repoSlugPath(input.baseDir, input.repoSlug, input.checkoutKey), snapshotId);
1942
+ mkdirSync8(checkoutPath, { recursive: true });
1399
1943
  for (const file of archive.files) {
1400
1944
  if (!file || typeof file.path !== "string" || typeof file.contentBase64 !== "string") {
1401
1945
  throw new Error("Invalid snapshot archive file entry");
1402
1946
  }
1403
1947
  const target = assertWithinRoot(checkoutPath, file.path);
1404
- mkdirSync6(dirname5(target), { recursive: true });
1405
- writeFileSync6(target, Buffer.from(file.contentBase64, "base64"));
1948
+ mkdirSync8(dirname8(target), { recursive: true });
1949
+ writeFileSync8(target, Buffer.from(file.contentBase64, "base64"));
1406
1950
  }
1407
- writeFileSync6(resolve10(checkoutPath, ".rig-uploaded-snapshot.json"), `${JSON.stringify({
1951
+ writeFileSync8(resolve12(checkoutPath, ".rig-uploaded-snapshot.json"), `${JSON.stringify({
1408
1952
  repoSlug: input.repoSlug,
1409
1953
  snapshotId,
1410
1954
  fileCount: archive.files.length,
@@ -1439,7 +1983,7 @@ function gitCredentialConfig(token) {
1439
1983
  };
1440
1984
  }
1441
1985
  async function prepareRemoteCheckout(input) {
1442
- const exists = input.exists ?? existsSync7;
1986
+ const exists = input.exists ?? existsSync9;
1443
1987
  const strategy = input.strategy;
1444
1988
  if (strategy.kind === "uploaded-snapshot") {
1445
1989
  return extractUploadedSnapshotArchive({
@@ -1451,7 +1995,7 @@ async function prepareRemoteCheckout(input) {
1451
1995
  });
1452
1996
  }
1453
1997
  if (strategy.kind === "existing-path") {
1454
- const checkoutPath2 = resolve10(strategy.path);
1998
+ const checkoutPath2 = resolve12(strategy.path);
1455
1999
  if (!exists(checkoutPath2)) {
1456
2000
  throw new Error(`Existing remote checkout path does not exist: ${checkoutPath2}`);
1457
2001
  }
@@ -1487,9 +2031,9 @@ function buildServerControlStatus() {
1487
2031
  };
1488
2032
  }
1489
2033
  function buildProjectConfigStatus(root) {
1490
- const hasConfigTs = existsSync8(resolve11(root, "rig.config.ts"));
1491
- const hasConfigJson = existsSync8(resolve11(root, "rig.config.json"));
1492
- const hasLegacyTaskConfig = existsSync8(resolve11(root, ".rig", "task-config.json"));
2034
+ const hasConfigTs = existsSync10(resolve13(root, "rig.config.ts"));
2035
+ const hasConfigJson = existsSync10(resolve13(root, "rig.config.json"));
2036
+ const hasLegacyTaskConfig = existsSync10(resolve13(root, ".rig", "task-config.json"));
1493
2037
  let kind = "missing";
1494
2038
  if (hasConfigTs)
1495
2039
  kind = "rig-config-ts";
@@ -1506,6 +2050,75 @@ function buildProjectConfigStatus(root) {
1506
2050
  suggestion: kind === "missing" ? "Run `rig init` in the project root to scaffold rig.config.ts." : null
1507
2051
  };
1508
2052
  }
2053
+ var RIG_GITHUB_LIFECYCLE_LABELS = [
2054
+ "ready",
2055
+ "blocked",
2056
+ "in-progress",
2057
+ "under-review",
2058
+ "failed",
2059
+ "cancelled",
2060
+ "rig:running",
2061
+ "rig:pr-open",
2062
+ "rig:ci-fixing",
2063
+ "rig:merging",
2064
+ "rig:done",
2065
+ "rig:needs-attention"
2066
+ ];
2067
+ function githubProjectsEnabled(config) {
2068
+ if (!config || typeof config !== "object" || Array.isArray(config))
2069
+ return false;
2070
+ const root = config;
2071
+ const github = root.github && typeof root.github === "object" && !Array.isArray(root.github) ? root.github : null;
2072
+ const projects = github?.projects && typeof github.projects === "object" && !Array.isArray(github.projects) ? github.projects : null;
2073
+ return projects?.enabled === true;
2074
+ }
2075
+ function githubIssueSourceRepo(config) {
2076
+ if (!config || typeof config !== "object" || Array.isArray(config))
2077
+ return null;
2078
+ const root = config;
2079
+ const taskSource = root.taskSource && typeof root.taskSource === "object" && !Array.isArray(root.taskSource) ? root.taskSource : null;
2080
+ const owner = normalizeString(taskSource?.owner);
2081
+ const repo = normalizeString(taskSource?.repo);
2082
+ if (taskSource?.kind === "github-issues" && owner && repo)
2083
+ return { owner, repo };
2084
+ const project = root.project && typeof root.project === "object" && !Array.isArray(root.project) ? root.project : null;
2085
+ const slug = normalizeString(project?.repo) ?? normalizeString(project?.name);
2086
+ const match = slug?.match(/^([^/]+)\/([^/]+)$/);
2087
+ return match ? { owner: match[1], repo: match[2] } : null;
2088
+ }
2089
+ async function ensureGitHubLifecycleLabels(projectRoot, config) {
2090
+ const repo = githubIssueSourceRepo(config);
2091
+ if (!repo)
2092
+ return { ok: false, ready: false, labelsReady: false, reason: "not-github-issues-source", labels: RIG_GITHUB_LIFECYCLE_LABELS };
2093
+ const token = createGitHubAuthStore(projectRoot).readToken();
2094
+ if (!token)
2095
+ return { ok: false, ready: false, labelsReady: false, reason: "missing-token", repo, labels: RIG_GITHUB_LIFECYCLE_LABELS };
2096
+ const existingResponse = await fetch(`https://api.github.com/repos/${repo.owner}/${repo.repo}/labels?per_page=100`, {
2097
+ headers: { accept: "application/vnd.github+json", authorization: `Bearer ${token}`, "user-agent": "rig-server" }
2098
+ });
2099
+ const existingJson = await existingResponse.json().catch(() => []);
2100
+ const existing = new Set(Array.isArray(existingJson) ? existingJson.flatMap((entry) => entry && typeof entry === "object" && typeof entry.name === "string" ? [entry.name] : []) : []);
2101
+ const created = [];
2102
+ const alreadyPresent = [];
2103
+ const failed = [];
2104
+ for (const label of RIG_GITHUB_LIFECYCLE_LABELS) {
2105
+ if (existing.has(label)) {
2106
+ alreadyPresent.push(label);
2107
+ continue;
2108
+ }
2109
+ const response = await fetch(`https://api.github.com/repos/${repo.owner}/${repo.repo}/labels`, {
2110
+ method: "POST",
2111
+ headers: { accept: "application/vnd.github+json", authorization: `Bearer ${token}`, "content-type": "application/json", "user-agent": "rig-server" },
2112
+ body: JSON.stringify({ name: label, color: label.startsWith("rig:") ? "6f42c1" : "ededed", description: label.startsWith("rig:") ? "Task status managed by Rig" : "Task lifecycle status managed by Rig" })
2113
+ });
2114
+ if (response.ok || response.status === 422) {
2115
+ (response.status === 422 ? alreadyPresent : created).push(label);
2116
+ } else {
2117
+ failed.push({ label, error: await response.text().catch(() => response.statusText) });
2118
+ }
2119
+ }
2120
+ return { ok: failed.length === 0, ready: failed.length === 0, labelsReady: failed.length === 0, repo, labels: RIG_GITHUB_LIFECYCLE_LABELS, created, existing: alreadyPresent, failed };
2121
+ }
1509
2122
  function normalizeCommit(value) {
1510
2123
  const raw = normalizeString(value);
1511
2124
  return raw && /^[0-9a-f]{7,40}$/i.test(raw) ? raw : null;
@@ -1525,24 +2138,24 @@ function repoParts(repoSlug) {
1525
2138
  return { owner, repo, slug: `${owner}/${repo}` };
1526
2139
  }
1527
2140
  function repairDir(checkoutPath) {
1528
- const dir = resolve11(checkoutPath, ".rig", "state", "repairs", new Date().toISOString().replace(/[:.]/g, "-"));
1529
- mkdirSync7(dir, { recursive: true });
2141
+ const dir = resolve13(checkoutPath, ".rig", "state", "repairs", new Date().toISOString().replace(/[:.]/g, "-"));
2142
+ mkdirSync9(dir, { recursive: true });
1530
2143
  return dir;
1531
2144
  }
1532
2145
  function backupCheckoutFile(checkoutPath, relativePath) {
1533
- const source = resolve11(checkoutPath, relativePath);
1534
- const backupPath = resolve11(repairDir(checkoutPath), relativePath.replace(/[\\/]/g, "__"));
1535
- mkdirSync7(dirname6(backupPath), { recursive: true });
1536
- copyFileSync(source, backupPath);
2146
+ const source = resolve13(checkoutPath, relativePath);
2147
+ const backupPath = resolve13(repairDir(checkoutPath), relativePath.replace(/[\\/]/g, "__"));
2148
+ mkdirSync9(dirname9(backupPath), { recursive: true });
2149
+ copyFileSync2(source, backupPath);
1537
2150
  return backupPath;
1538
2151
  }
1539
2152
  function parsePackageJsonLosslessly(checkoutPath) {
1540
- const packagePath = resolve11(checkoutPath, "package.json");
1541
- if (!existsSync8(packagePath)) {
2153
+ const packagePath = resolve13(checkoutPath, "package.json");
2154
+ if (!existsSync10(packagePath)) {
1542
2155
  return { existed: false, packageJson: { name: basename(checkoutPath) || "rig-project", private: true } };
1543
2156
  }
1544
2157
  try {
1545
- const parsed = JSON.parse(readFileSync5(packagePath, "utf8"));
2158
+ const parsed = JSON.parse(readFileSync7(packagePath, "utf8"));
1546
2159
  return {
1547
2160
  existed: true,
1548
2161
  packageJson: parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : { name: basename(checkoutPath) || "rig-project", private: true }
@@ -1556,9 +2169,9 @@ function parsePackageJsonLosslessly(checkoutPath) {
1556
2169
  }
1557
2170
  }
1558
2171
  function ensureRemoteCheckoutRigPackageDeps(checkoutPath) {
1559
- const hasConfig = ["rig.config.ts", "rig.config.mts", "rig.config.json"].some((name) => existsSync8(resolve11(checkoutPath, name)));
1560
- const packagePath = resolve11(checkoutPath, "package.json");
1561
- if (!hasConfig && !existsSync8(packagePath)) {
2172
+ const hasConfig = ["rig.config.ts", "rig.config.mts", "rig.config.json"].some((name) => existsSync10(resolve13(checkoutPath, name)));
2173
+ const packagePath = resolve13(checkoutPath, "package.json");
2174
+ if (!hasConfig && !existsSync10(packagePath)) {
1562
2175
  return { skipped: true, reason: "package.json and rig.config missing" };
1563
2176
  }
1564
2177
  const parsed = parsePackageJsonLosslessly(checkoutPath);
@@ -1577,7 +2190,7 @@ function ensureRemoteCheckoutRigPackageDeps(checkoutPath) {
1577
2190
  }
1578
2191
  const changed = !parsed.existed || Boolean(parsed.backupPath) || added.length > 0 || updated.length > 0 || existingDevDependencies !== devDependencies && (!existingDevDependencies || typeof existingDevDependencies !== "object" || Array.isArray(existingDevDependencies));
1579
2192
  if (changed) {
1580
- writeFileSync7(packagePath, `${JSON.stringify({ ...parsed.packageJson, devDependencies }, null, 2)}
2193
+ writeFileSync9(packagePath, `${JSON.stringify({ ...parsed.packageJson, devDependencies }, null, 2)}
1581
2194
  `, "utf8");
1582
2195
  }
1583
2196
  return {
@@ -1603,11 +2216,11 @@ function configLooksStructurallyUsable(source) {
1603
2216
  return /taskSource\s*:/.test(source) && /workspace\s*:/.test(source) && /project\s*:/.test(source);
1604
2217
  }
1605
2218
  function ensureRemoteCheckoutRigConfig(checkoutPath, repoSlug, reason = "missing or incomplete rig config") {
1606
- const configPath = resolve11(checkoutPath, "rig.config.ts");
1607
- const existingConfigName = ["rig.config.ts", "rig.config.mts", "rig.config.json"].find((name) => existsSync8(resolve11(checkoutPath, name)));
2219
+ const configPath = resolve13(checkoutPath, "rig.config.ts");
2220
+ const existingConfigName = ["rig.config.ts", "rig.config.mts", "rig.config.json"].find((name) => existsSync10(resolve13(checkoutPath, name)));
1608
2221
  if (existingConfigName) {
1609
- const existingPath = resolve11(checkoutPath, existingConfigName);
1610
- const source = readFileSync5(existingPath, "utf8");
2222
+ const existingPath = resolve13(checkoutPath, existingConfigName);
2223
+ const source = readFileSync7(existingPath, "utf8");
1611
2224
  if (existingConfigName !== "rig.config.json" && configLooksStructurallyUsable(source)) {
1612
2225
  return { path: existingPath, changed: false, reason: "config structurally complete" };
1613
2226
  }
@@ -1621,7 +2234,7 @@ function ensureRemoteCheckoutRigConfig(checkoutPath, repoSlug, reason = "missing
1621
2234
  }
1622
2235
  }
1623
2236
  const backupPath = existingConfigName ? backupCheckoutFile(checkoutPath, existingConfigName) : undefined;
1624
- writeFileSync7(configPath, generatedRigConfigSource(repoSlug), "utf8");
2237
+ writeFileSync9(configPath, generatedRigConfigSource(repoSlug), "utf8");
1625
2238
  return {
1626
2239
  path: configPath,
1627
2240
  changed: true,
@@ -1630,7 +2243,7 @@ function ensureRemoteCheckoutRigConfig(checkoutPath, repoSlug, reason = "missing
1630
2243
  };
1631
2244
  }
1632
2245
  function validateRemoteCheckoutRigConfig(checkoutPath) {
1633
- const configFile = ["rig.config.ts", "rig.config.mts", "rig.config.json"].find((name) => existsSync8(resolve11(checkoutPath, name)));
2246
+ const configFile = ["rig.config.ts", "rig.config.mts", "rig.config.json"].find((name) => existsSync10(resolve13(checkoutPath, name)));
1634
2247
  if (!configFile)
1635
2248
  return { ok: false, error: "missing rig config" };
1636
2249
  if (process.env.RIG_TEST_SKIP_REMOTE_CHECKOUT_INSTALL === "1") {
@@ -1652,7 +2265,7 @@ function validateRemoteCheckoutRigConfig(checkoutPath) {
1652
2265
  return { ok: true, configFile };
1653
2266
  }
1654
2267
  function installRemoteCheckoutPackages(checkoutPath) {
1655
- if (!existsSync8(resolve11(checkoutPath, "package.json"))) {
2268
+ if (!existsSync10(resolve13(checkoutPath, "package.json"))) {
1656
2269
  return { skipped: true, reason: "package.json missing" };
1657
2270
  }
1658
2271
  if (process.env.RIG_TEST_SKIP_REMOTE_CHECKOUT_INSTALL === "1") {
@@ -1665,8 +2278,8 @@ function installRemoteCheckoutPackages(checkoutPath) {
1665
2278
  return { ok: true, command: "bun install", stdout: result.stdout?.trim() || undefined };
1666
2279
  }
1667
2280
  function repairRemoteCheckoutForRig(checkoutPath, repoSlug) {
1668
- const hasConfig = ["rig.config.ts", "rig.config.mts", "rig.config.json"].some((name) => existsSync8(resolve11(checkoutPath, name)));
1669
- const hasPackage = existsSync8(resolve11(checkoutPath, "package.json"));
2281
+ const hasConfig = ["rig.config.ts", "rig.config.mts", "rig.config.json"].some((name) => existsSync10(resolve13(checkoutPath, name)));
2282
+ const hasPackage = existsSync10(resolve13(checkoutPath, "package.json"));
1670
2283
  if (!hasConfig && !hasPackage) {
1671
2284
  return {
1672
2285
  packageJson: { skipped: true, reason: "package.json and rig.config missing" },
@@ -1764,26 +2377,26 @@ function buildRemoteRunLogEntry(body, identifiers) {
1764
2377
  }
1765
2378
  function readGitHeadCommit(projectRoot) {
1766
2379
  try {
1767
- let gitDir = resolve11(projectRoot, ".git");
2380
+ let gitDir = resolve13(projectRoot, ".git");
1768
2381
  try {
1769
- const dotGit = readFileSync5(gitDir, "utf8").trim();
2382
+ const dotGit = readFileSync7(gitDir, "utf8").trim();
1770
2383
  const gitDirPrefix = "gitdir:";
1771
2384
  if (dotGit.startsWith(gitDirPrefix)) {
1772
- gitDir = resolve11(projectRoot, dotGit.slice(gitDirPrefix.length).trim());
2385
+ gitDir = resolve13(projectRoot, dotGit.slice(gitDirPrefix.length).trim());
1773
2386
  }
1774
2387
  } catch {}
1775
- const head = readFileSync5(resolve11(gitDir, "HEAD"), "utf8").trim();
2388
+ const head = readFileSync7(resolve13(gitDir, "HEAD"), "utf8").trim();
1776
2389
  const refPrefix = "ref:";
1777
2390
  if (!head.startsWith(refPrefix)) {
1778
2391
  return normalizeCommit(head);
1779
2392
  }
1780
2393
  const ref = head.slice(refPrefix.length).trim();
1781
- const refPath = resolve11(gitDir, ref);
1782
- if (existsSync8(refPath)) {
1783
- return normalizeCommit(readFileSync5(refPath, "utf8").trim());
2394
+ const refPath = resolve13(gitDir, ref);
2395
+ if (existsSync10(refPath)) {
2396
+ return normalizeCommit(readFileSync7(refPath, "utf8").trim());
1784
2397
  }
1785
- const commonDir = normalizeString(readFileSync5(resolve11(gitDir, "commondir"), "utf8"));
1786
- return commonDir ? normalizeCommit(readFileSync5(resolve11(gitDir, commonDir, ref), "utf8").trim()) : null;
2398
+ const commonDir = normalizeString(readFileSync7(resolve13(gitDir, "commondir"), "utf8"));
2399
+ return commonDir ? normalizeCommit(readFileSync7(resolve13(gitDir, commonDir, ref), "utf8").trim()) : null;
1787
2400
  } catch {
1788
2401
  return null;
1789
2402
  }
@@ -1821,9 +2434,9 @@ function configuredRepoFromTaskSource(taskSource) {
1821
2434
  const repo = normalizeString(taskSource?.repo);
1822
2435
  return owner && repo ? `${owner}/${repo}` : null;
1823
2436
  }
1824
- async function buildTaskSourceStatus(state, config) {
2437
+ async function buildTaskSourceStatus(state, config, requestAuth) {
1825
2438
  const diagnostics = state.snapshotService.getTaskSourceErrors();
1826
- const selectedRepo = createGitHubAuthStore(state.projectRoot).status({
2439
+ const selectedRepo = requestScopedAuthStore(state.projectRoot, requestAuth).status({
1827
2440
  oauthConfigured: Boolean(process.env.RIG_GITHUB_OAUTH_CLIENT_ID?.trim())
1828
2441
  }).selectedRepo;
1829
2442
  try {
@@ -1876,37 +2489,146 @@ function isLoopbackRequest(req) {
1876
2489
  }
1877
2490
  }
1878
2491
  function isPublicRigAuthBootstrapRoute(pathname) {
1879
- return pathname === "/" || pathname === "/health" || pathname === "/api/health" || pathname === "/api/github/auth/status" || pathname === "/api/github/auth/token" || pathname === "/api/github/auth/device/start" || pathname === "/api/github/auth/device/poll";
2492
+ return pathname === "/" || pathname === "/install" || pathname === "/health" || pathname === "/api/health" || pathname === "/api/github/auth/status" || pathname === "/api/github/auth/token" || pathname === "/api/github/auth/device/start" || pathname === "/api/github/auth/device/poll";
2493
+ }
2494
+ function buildRigInstallScript() {
2495
+ return `#!/usr/bin/env bash
2496
+ set -euo pipefail
2497
+
2498
+ say() {
2499
+ printf 'rig-install: %s
2500
+ ' "$*"
2501
+ }
2502
+
2503
+ if ! command -v bun >/dev/null 2>&1; then
2504
+ say "Bun not found; installing Bun first"
2505
+ curl -fsSL https://bun.sh/install | bash
2506
+ export BUN_INSTALL="\${BUN_INSTALL:-$HOME/.bun}"
2507
+ export PATH="$BUN_INSTALL/bin:$PATH"
2508
+ fi
2509
+
2510
+ if ! command -v bun >/dev/null 2>&1; then
2511
+ printf 'rig-install: bun install completed, but bun is still not on PATH. Add ~/.bun/bin to PATH and retry.
2512
+ ' >&2
2513
+ exit 1
2514
+ fi
2515
+
2516
+ say "Installing @h-rig/cli@latest"
2517
+ bun add -g --force @h-rig/cli@latest
2518
+
2519
+ export BUN_INSTALL="\${BUN_INSTALL:-$HOME/.bun}"
2520
+ BUN_RIG="$BUN_INSTALL/bin/rig"
2521
+ if [ ! -x "$BUN_RIG" ]; then
2522
+ printf 'rig-install: expected Bun global rig at %s but it was not executable.
2523
+ ' "$BUN_RIG" >&2
2524
+ exit 1
2525
+ fi
2526
+
2527
+ USER_BIN="$HOME/.local/bin"
2528
+ mkdir -p "$USER_BIN"
2529
+ cat > "$USER_BIN/rig" <<'RIG_SHIM'
2530
+ #!/usr/bin/env bash
2531
+ set -euo pipefail
2532
+ exec "\${BUN_INSTALL:-$HOME/.bun}/bin/rig" "$@"
2533
+ RIG_SHIM
2534
+ chmod +x "$USER_BIN/rig"
2535
+
2536
+ export PATH="$USER_BIN:$BUN_INSTALL/bin:$PATH"
2537
+ if command -v hash >/dev/null 2>&1; then hash -r; fi
2538
+
2539
+ if ! command -v rig >/dev/null 2>&1; then
2540
+ printf 'rig-install: rig installed, but rig is not on PATH. Add %s and %s/bin to PATH and retry.
2541
+ ' "$USER_BIN" "$BUN_INSTALL" >&2
2542
+ exit 1
2543
+ fi
2544
+
2545
+ say "Verifying rig"
2546
+ "$BUN_RIG" --help >/dev/null
2547
+ rig --help >/dev/null
2548
+ say "Done. Run: rig --help"
2549
+ `;
1880
2550
  }
1881
2551
  function normalizePrMode(value) {
1882
2552
  const mode = normalizeString(value);
1883
2553
  return mode === "auto" || mode === "ask" || mode === "off" ? mode : undefined;
1884
2554
  }
2555
+ function requestAuthResult(input) {
2556
+ return {
2557
+ authorized: input.authorized,
2558
+ actor: input.actor ?? null,
2559
+ reason: input.reason,
2560
+ login: input.login ?? null,
2561
+ userId: input.userId ?? null,
2562
+ userNamespace: input.userNamespace ?? null,
2563
+ authStateFile: input.authStateFile ?? null
2564
+ };
2565
+ }
2566
+ function namespaceFromSessionIndex(entry) {
2567
+ const stateDir2 = dirname9(entry.authStateFile);
2568
+ return {
2569
+ key: entry.namespaceKey,
2570
+ userId: entry.userId,
2571
+ login: entry.login,
2572
+ root: entry.namespaceRoot,
2573
+ stateDir: stateDir2,
2574
+ authStateFile: entry.authStateFile,
2575
+ metadataFile: resolve13(stateDir2, "user-namespace.json"),
2576
+ checkoutBaseDir: entry.checkoutBaseDir,
2577
+ snapshotBaseDir: entry.snapshotBaseDir
2578
+ };
2579
+ }
1885
2580
  function authorizeRigHttpRequest(input) {
1886
2581
  if (input.legacyAuthorized) {
1887
- return { authorized: true, actor: "rig-local-server", reason: "server-token" };
2582
+ return requestAuthResult({ authorized: true, actor: "rig-local-server", reason: "server-token" });
1888
2583
  }
1889
2584
  const bearer = bearerTokenFromRequest(input.req);
1890
2585
  const store = createGitHubAuthStore(input.projectRoot);
1891
2586
  const storedToken = store.readToken();
1892
2587
  const session = bearer ? store.readApiSession(bearer) : null;
1893
2588
  if (session) {
1894
- return { authorized: true, actor: session.login ?? "github-operator", reason: "github-session" };
2589
+ return requestAuthResult({
2590
+ authorized: true,
2591
+ actor: session.login ?? "github-operator",
2592
+ reason: "github-session",
2593
+ login: session.login,
2594
+ userId: session.userId,
2595
+ authStateFile: store.stateFile
2596
+ });
1895
2597
  }
1896
2598
  if (bearer && storedToken && bearer === storedToken) {
1897
2599
  const status = store.status({ oauthConfigured: Boolean(process.env.RIG_GITHUB_OAUTH_CLIENT_ID?.trim()) });
1898
- return { authorized: true, actor: status.login ?? "github-operator", reason: "github-token" };
2600
+ return requestAuthResult({
2601
+ authorized: true,
2602
+ actor: status.login ?? "github-operator",
2603
+ reason: "github-token",
2604
+ login: status.login,
2605
+ userId: status.userId,
2606
+ authStateFile: store.stateFile
2607
+ });
2608
+ }
2609
+ const indexedSession = readGitHubApiSession({ projectRoot: input.projectRoot, token: bearer });
2610
+ if (indexedSession) {
2611
+ const userNamespace = namespaceFromSessionIndex(indexedSession);
2612
+ return requestAuthResult({
2613
+ authorized: true,
2614
+ actor: indexedSession.login ?? "github-operator",
2615
+ reason: "github-user-session",
2616
+ login: indexedSession.login,
2617
+ userId: indexedSession.userId,
2618
+ userNamespace,
2619
+ authStateFile: indexedSession.authStateFile
2620
+ });
1899
2621
  }
1900
2622
  if (isPublicRigAuthBootstrapRoute(input.pathname)) {
1901
- return { authorized: true, actor: null, reason: "public-bootstrap" };
2623
+ return requestAuthResult({ authorized: true, actor: null, reason: "public-bootstrap" });
1902
2624
  }
1903
2625
  if (!input.serverAuthToken && !storedToken) {
1904
2626
  if (isLoopbackRequest(input.req)) {
1905
- return { authorized: true, actor: null, reason: "loopback-dev-no-auth" };
2627
+ return requestAuthResult({ authorized: true, actor: null, reason: "loopback-dev-no-auth" });
1906
2628
  }
1907
- return { authorized: false, actor: null, reason: "auth-required" };
2629
+ return requestAuthResult({ authorized: false, actor: null, reason: "auth-required" });
1908
2630
  }
1909
- return { authorized: false, actor: null, reason: storedToken ? "github-token-required" : "auth-required" };
2631
+ return requestAuthResult({ authorized: false, actor: null, reason: storedToken ? "github-token-required" : "auth-required" });
1910
2632
  }
1911
2633
  async function fetchGitHubUserInfo(token) {
1912
2634
  const response = await fetch("https://api.github.com/user", {
@@ -1926,6 +2648,67 @@ async function fetchGitHubUserInfo(token) {
1926
2648
  scopes: cleanHeaderScopes(response.headers.get("x-oauth-scopes"))
1927
2649
  };
1928
2650
  }
2651
+ function shouldWriteRootAuthCompat(projectRoot) {
2652
+ if (process.env.RIG_REMOTE_USER_NAMESPACE_ROOT?.trim())
2653
+ return false;
2654
+ const stateDir2 = normalizeString(process.env.RIG_STATE_DIR);
2655
+ if (!stateDir2)
2656
+ return true;
2657
+ return resolve13(stateDir2) === resolve13(projectRoot, ".rig", "state");
2658
+ }
2659
+ function requestScopedRegistryRoot(stateProjectRoot, requestAuth) {
2660
+ return requestAuth.userNamespace?.root ?? stateProjectRoot;
2661
+ }
2662
+ function requestScopedAuthStore(stateProjectRoot, requestAuth) {
2663
+ return requestAuth.authStateFile ? createGitHubAuthStoreFromStateFile(requestAuth.authStateFile) : createGitHubAuthStore(stateProjectRoot);
2664
+ }
2665
+ function userNamespaceResponse(namespace) {
2666
+ return namespace ? serializeRemoteUserNamespace(namespace) : undefined;
2667
+ }
2668
+ function resolveNamespacedBaseDir(input) {
2669
+ if (input.explicitBaseDir)
2670
+ return input.explicitBaseDir;
2671
+ const envBase = normalizeString(process.env[input.envName]);
2672
+ if (input.userNamespace) {
2673
+ return envBase ? resolve13(envBase, input.userNamespace.key) : input.userNamespace[input.legacySubdir === "remote-checkouts" ? "checkoutBaseDir" : "snapshotBaseDir"];
2674
+ }
2675
+ return envBase ?? (normalizeString(process.env.RIG_STATE_DIR) ? resolve13(normalizeString(process.env.RIG_STATE_DIR), input.legacySubdir) : resolve13(input.legacyProjectRoot, ".rig", input.legacySubdir));
2676
+ }
2677
+ function explicitCheckoutKey(body, checkoutInput, requestAuth) {
2678
+ return normalizeString(body.checkoutKey) ?? normalizeString(checkoutInput.checkoutKey) ?? normalizeString(checkoutInput.key) ?? (requestAuth.userNamespace ? undefined : "default");
2679
+ }
2680
+ function saveGitHubTokenForRemoteUser(input) {
2681
+ const namespace = resolveRemoteUserNamespace(input.projectRoot, { userId: input.user.userId, login: input.user.login });
2682
+ writeRemoteUserNamespaceMetadata(namespace);
2683
+ const store = createGitHubAuthStoreFromStateFile(namespace.authStateFile);
2684
+ store.saveToken({
2685
+ token: input.token,
2686
+ tokenSource: input.tokenSource,
2687
+ login: input.user.login,
2688
+ userId: input.user.userId,
2689
+ scopes: input.user.scopes,
2690
+ selectedRepo: input.selectedRepo
2691
+ });
2692
+ const apiSession = store.createApiSession();
2693
+ registerGitHubApiSession({ projectRoot: input.projectRoot, token: apiSession.token, namespace, selectedRepo: input.selectedRepo });
2694
+ const requestedRoot = normalizeString(input.requestedProjectRoot);
2695
+ if (requestedRoot && isAbsolute3(requestedRoot) && existsSync10(resolve13(requestedRoot))) {
2696
+ copyGitHubAuthStateToLocalProjectRoot(namespace.authStateFile, resolve13(requestedRoot));
2697
+ }
2698
+ if (shouldWriteRootAuthCompat(input.projectRoot)) {
2699
+ const rootStore = createGitHubAuthStore(input.projectRoot);
2700
+ rootStore.saveToken({
2701
+ token: input.token,
2702
+ tokenSource: input.tokenSource,
2703
+ login: input.user.login,
2704
+ userId: input.user.userId,
2705
+ scopes: input.user.scopes,
2706
+ selectedRepo: input.selectedRepo
2707
+ });
2708
+ rootStore.createApiSession();
2709
+ }
2710
+ return { store, namespace, apiSessionToken: apiSession.token };
2711
+ }
1929
2712
  async function postGitHubForm(endpoint, body) {
1930
2713
  const response = await fetch(endpoint, {
1931
2714
  method: "POST",
@@ -1943,11 +2726,11 @@ function resolveRequestedProjectRoot(currentRoot, rawRoot) {
1943
2726
  const requestedRoot = normalizeString(rawRoot);
1944
2727
  if (!requestedRoot)
1945
2728
  return currentRoot;
1946
- if (!isAbsolute2(requestedRoot)) {
2729
+ if (!isAbsolute3(requestedRoot)) {
1947
2730
  throw new Error("projectRoot must be an absolute path on the Rig server host");
1948
2731
  }
1949
- const normalizedRoot = resolve11(requestedRoot);
1950
- if (!existsSync8(normalizedRoot)) {
2732
+ const normalizedRoot = resolve13(requestedRoot);
2733
+ if (!existsSync10(normalizedRoot)) {
1951
2734
  throw new Error("projectRoot does not exist on the Rig server host");
1952
2735
  }
1953
2736
  return normalizedRoot;
@@ -2175,6 +2958,27 @@ function filterWorkspaceTasks(projectRoot, tasks, searchParams) {
2175
2958
  }
2176
2959
  return filtered;
2177
2960
  }
2961
+ function issueAnalysisTargetFor(source) {
2962
+ if (!source)
2963
+ return null;
2964
+ const candidate = source;
2965
+ if (typeof candidate.updateTask !== "function")
2966
+ return null;
2967
+ return {
2968
+ ...typeof candidate.get === "function" ? { get: candidate.get.bind(candidate) } : {},
2969
+ updateTask: candidate.updateTask.bind(candidate),
2970
+ ...typeof candidate.addLabels === "function" ? { addLabels: candidate.addLabels.bind(candidate) } : {},
2971
+ ...typeof candidate.removeLabels === "function" ? { removeLabels: candidate.removeLabels.bind(candidate) } : {},
2972
+ ...typeof candidate.createIssue === "function" ? { createIssue: candidate.createIssue.bind(candidate) } : {}
2973
+ };
2974
+ }
2975
+ function uniqueStringList(value) {
2976
+ const raw = Array.isArray(value) ? value : typeof value === "string" ? [value] : [];
2977
+ return [...new Set(raw.map((entry) => String(entry).trim()).filter(Boolean))];
2978
+ }
2979
+ function taskRecordId(task) {
2980
+ return String(task.id ?? "");
2981
+ }
2178
2982
  function redactRemoteEndpoint(endpoint) {
2179
2983
  const { token, ...rest } = endpoint;
2180
2984
  return {
@@ -2259,6 +3063,13 @@ function createRigServerFetch(state, deps) {
2259
3063
  notifications: state.targets.length
2260
3064
  });
2261
3065
  }
3066
+ if (url.pathname === "/install" && req.method === "GET") {
3067
+ return new Response(buildRigInstallScript(), {
3068
+ headers: {
3069
+ "Content-Type": "text/x-shellscript; charset=utf-8"
3070
+ }
3071
+ });
3072
+ }
2262
3073
  const isLinearWebhook = url.pathname === "/api/linear/webhook" && req.method === "POST";
2263
3074
  const isInspectorStream = url.pathname === "/api/inspector/stream" && req.method === "GET";
2264
3075
  const legacyAuthorizedHttpRequest = Boolean(state.authToken) && (isInspectorStream && isAuthorizedInspectorStreamRequest(req, state.authToken) ? true : deps.isAuthorizedHttpRequest(req, state.authToken));
@@ -2471,16 +3282,12 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
2471
3282
  if (!source) {
2472
3283
  return deps.badRequest("No task source is configured");
2473
3284
  }
3285
+ if (!source.updateTask && !(update.status && source.updateStatus)) {
3286
+ return deps.badRequest("Configured task source does not support updates");
3287
+ }
2474
3288
  const taskBeforeUpdate = source.get ? await source.get(id).catch(() => {
2475
3289
  return;
2476
3290
  }) : (await deps.snapshotService.getWorkspaceTasks().catch(() => [])).find((task) => task.id === id);
2477
- if (source.updateTask) {
2478
- await source.updateTask(id, update);
2479
- } else if (update.status && source.updateStatus) {
2480
- await source.updateStatus(id, update.status);
2481
- } else {
2482
- return deps.badRequest("Configured task source does not support updates");
2483
- }
2484
3291
  const issueNodeId = normalizeString(body.issueNodeId) ?? extractGitHubIssueNodeId(taskBeforeUpdate);
2485
3292
  const projectSync = update.status ? await syncGitHubProjectStatusForTaskUpdate({
2486
3293
  taskId: id,
@@ -2489,6 +3296,35 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
2489
3296
  token: createGitHubAuthStore(state.projectRoot).readToken(),
2490
3297
  config: ctx?.config
2491
3298
  }).catch((error) => ({ synced: false, reason: `error:${error instanceof Error ? error.message : String(error)}` })) : { synced: false, reason: "missing-status" };
3299
+ if (update.status && githubProjectsEnabled(ctx?.config) && projectSync.synced === false) {
3300
+ return deps.jsonResponse({ ok: false, id, projectSync, error: `GitHub Project status sync failed: ${String(projectSync.reason)}` }, 502);
3301
+ }
3302
+ try {
3303
+ if (source.updateTask) {
3304
+ await source.updateTask(id, update);
3305
+ } else if (update.status && source.updateStatus) {
3306
+ await source.updateStatus(id, update.status);
3307
+ }
3308
+ } catch (error) {
3309
+ let rollback = null;
3310
+ const previousStatus = normalizeString(taskBeforeUpdate?.status) ?? normalizeString(taskBeforeUpdate?.sourceStatus);
3311
+ if (update.status && previousStatus && githubProjectsEnabled(ctx?.config) && projectSync.synced !== false) {
3312
+ rollback = await syncGitHubProjectStatusForTaskUpdate({
3313
+ taskId: id,
3314
+ status: previousStatus,
3315
+ issueNodeId,
3316
+ token: createGitHubAuthStore(state.projectRoot).readToken(),
3317
+ config: ctx?.config
3318
+ }).catch((rollbackError) => ({ synced: false, reason: `rollback-error:${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}` }));
3319
+ }
3320
+ return deps.jsonResponse({
3321
+ ok: false,
3322
+ id,
3323
+ projectSync,
3324
+ rollback,
3325
+ error: `Task source update failed: ${error instanceof Error ? error.message : String(error)}`
3326
+ }, 502);
3327
+ }
2492
3328
  deps.snapshotService.invalidate("github-issue-updated");
2493
3329
  await state.taskProjectionReconciler?.tick("github-issue-updated").catch(() => {
2494
3330
  return;
@@ -2497,29 +3333,105 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
2497
3333
  return deps.jsonResponse({ ok: true, id, projectSync });
2498
3334
  }
2499
3335
  if (url.pathname === "/api/workspace/task-labels") {
3336
+ const ctx = await getCachedPluginHostContext(state.projectRoot).catch(() => null);
3337
+ if (url.searchParams.get("ensure") === "1" || req.method === "POST") {
3338
+ return deps.jsonResponse(await ensureGitHubLifecycleLabels(state.projectRoot, ctx?.config));
3339
+ }
2500
3340
  return deps.jsonResponse({
2501
3341
  ok: true,
2502
3342
  ready: true,
2503
3343
  labelsReady: true,
2504
- labels: [
2505
- "ready",
2506
- "blocked",
2507
- "in-progress",
2508
- "under-review",
2509
- "failed",
2510
- "cancelled",
2511
- "rig:running",
2512
- "rig:pr-open",
2513
- "rig:ci-fixing",
2514
- "rig:done",
2515
- "rig:needs-attention"
2516
- ],
2517
- note: "GitHub issue lifecycle labels are created on demand by the configured task source when supported."
3344
+ labels: [...RIG_GITHUB_LIFECYCLE_LABELS],
3345
+ note: "Lifecycle labels are required during init; call POST /api/workspace/task-labels or ?ensure=1 to proactively create them."
3346
+ });
3347
+ }
3348
+ if (url.pathname === "/api/github/projects" && req.method === "GET") {
3349
+ const owner = normalizeString(url.searchParams.get("owner"));
3350
+ if (!owner)
3351
+ return deps.badRequest("owner is required");
3352
+ const token = createGitHubAuthStore(state.projectRoot).readToken();
3353
+ if (!token)
3354
+ return deps.jsonResponse({ ok: false, error: "missing-token", projects: [] }, 401);
3355
+ const projects = await listGitHubProjects({ owner, token }).catch((error) => {
3356
+ throw new Error(error instanceof Error ? error.message : String(error));
3357
+ });
3358
+ return deps.jsonResponse({ ok: true, projects });
3359
+ }
3360
+ const projectStatusMatch = url.pathname.match(/^\/api\/github\/projects\/([^/]+)\/status-field$/);
3361
+ if (projectStatusMatch && req.method === "GET") {
3362
+ const projectId = decodeURIComponent(projectStatusMatch[1]);
3363
+ const token = createGitHubAuthStore(state.projectRoot).readToken();
3364
+ if (!token)
3365
+ return deps.jsonResponse({ ok: false, error: "missing-token" }, 401);
3366
+ const field = await resolveProjectStatusField({ projectId, token }).catch((error) => {
3367
+ throw new Error(error instanceof Error ? error.message : String(error));
3368
+ });
3369
+ return deps.jsonResponse({ ok: true, field });
3370
+ }
3371
+ if (url.pathname === "/api/workspace/issue-analysis/run" && req.method === "POST") {
3372
+ const body = await deps.readJsonBody(req);
3373
+ const ids = uniqueStringList(body.ids ?? body.id);
3374
+ const analyzeAll = deps.isTruthyQuery(String(body.all ?? ""));
3375
+ if (ids.length === 0 && !analyzeAll) {
3376
+ return deps.badRequest("ids is required unless all=true");
3377
+ }
3378
+ const ctx = await getCachedPluginHostContext(state.projectRoot);
3379
+ const [source] = ctx?.taskSourceRegistry.list() ?? [];
3380
+ const target = issueAnalysisTargetFor(source);
3381
+ if (!source || !target) {
3382
+ return deps.badRequest("Configured task source does not support issue-analysis writeback");
3383
+ }
3384
+ const allTasks = [...await source.list()];
3385
+ const issues = analyzeAll ? allTasks.slice(0, Math.max(1, Math.min(25, Number(body.limit ?? 25) || 25))) : (await Promise.all(ids.map(async (id) => {
3386
+ const cached = allTasks.find((task) => taskRecordId(task) === id);
3387
+ if (cached)
3388
+ return cached;
3389
+ return typeof source.get === "function" ? await source.get(id) : undefined;
3390
+ }))).filter((task) => Boolean(task));
3391
+ if (issues.length === 0) {
3392
+ return deps.jsonResponse({ ok: false, error: "No matching issues found for issue analysis", ids }, 404);
3393
+ }
3394
+ const config = ctx?.config && typeof ctx.config === "object" ? ctx.config : {};
3395
+ const issueAnalysis = config.issueAnalysis && typeof config.issueAnalysis === "object" ? config.issueAnalysis : {};
3396
+ const runtime = config.runtime && typeof config.runtime === "object" ? config.runtime : {};
3397
+ const model = normalizeString(issueAnalysis.model) ?? normalizeString(runtime.model);
3398
+ const service = createIssueAnalysisService({
3399
+ analyzer: createPiIssueAnalyzer({
3400
+ ...model ? { model } : {},
3401
+ env: { RIG_PROJECT_ROOT: state.projectRoot }
3402
+ }),
3403
+ writeBack: createIssueAnalysisWriteBack({ target })
3404
+ });
3405
+ const reason = normalizeString(body.reason) ?? "http-issue-analysis";
3406
+ let results;
3407
+ try {
3408
+ results = await service.analyze(issues, { reason, neighbors: ids.length > 0 ? issues : allTasks });
3409
+ } catch (error) {
3410
+ return deps.jsonResponse({
3411
+ ok: false,
3412
+ error: `Issue analysis failed: ${error instanceof Error ? error.message : String(error)}`,
3413
+ reason,
3414
+ ids: issues.map((issue) => issue.id)
3415
+ }, 502);
3416
+ }
3417
+ deps.snapshotService.invalidate("issue-analysis-http-run");
3418
+ await state.taskProjectionReconciler?.tick("issue-analysis-http-run").catch(() => {
3419
+ return;
3420
+ });
3421
+ deps.broadcastSnapshotInvalidation(state, "issue-analysis-http-run");
3422
+ return deps.jsonResponse({
3423
+ ok: true,
3424
+ reason,
3425
+ analyzed: results.map((entry) => ({
3426
+ id: entry.issue.id,
3427
+ title: entry.issue.title ?? null,
3428
+ result: entry.result
3429
+ }))
2518
3430
  });
2519
3431
  }
2520
3432
  if (url.pathname === "/api/server/status") {
2521
3433
  const config = buildProjectConfigStatus(state.projectRoot);
2522
- const taskSource = await buildTaskSourceStatus(state, config);
3434
+ const taskSource = await buildTaskSourceStatus(state, config, requestAuth);
2523
3435
  return deps.jsonResponse({
2524
3436
  ok: true,
2525
3437
  projectRoot: state.projectRoot,
@@ -2543,8 +3455,9 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
2543
3455
  path: normalizeString(rawCheckout?.path) ?? state.projectRoot,
2544
3456
  ref: normalizeString(rawCheckout?.ref) ?? undefined
2545
3457
  } : undefined;
2546
- const record = upsertProjectRecord(state.projectRoot, { repoSlug, checkout });
2547
- return deps.jsonResponse({ ok: true, project: record });
3458
+ const registryRoot = requestScopedRegistryRoot(state.projectRoot, requestAuth);
3459
+ const record = upsertProjectRecord(registryRoot, { repoSlug, checkout });
3460
+ return deps.jsonResponse({ ok: true, project: record, userNamespace: userNamespaceResponse(requestAuth.userNamespace) });
2548
3461
  }
2549
3462
  const snapshotUploadMatch = url.pathname.match(/^\/api\/projects\/(.+?)\/upload-snapshot$/);
2550
3463
  if (snapshotUploadMatch && req.method === "POST") {
@@ -2557,8 +3470,15 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
2557
3470
  if (!archiveContentBase64) {
2558
3471
  return deps.badRequest("archiveContentBase64 is required");
2559
3472
  }
2560
- const baseDir = normalizeString(body.baseDir) ?? normalizeString(process.env.RIG_REMOTE_SNAPSHOT_BASE_DIR) ?? (normalizeString(process.env.RIG_STATE_DIR) ? resolve11(normalizeString(process.env.RIG_STATE_DIR), "remote-snapshots") : resolve11(state.projectRoot, ".rig", "remote-snapshots"));
2561
- const checkoutKey = normalizeString(body.checkoutKey) ?? "default";
3473
+ const registryRoot = requestScopedRegistryRoot(state.projectRoot, requestAuth);
3474
+ const baseDir = resolveNamespacedBaseDir({
3475
+ explicitBaseDir: normalizeString(body.baseDir),
3476
+ envName: "RIG_REMOTE_SNAPSHOT_BASE_DIR",
3477
+ userNamespace: requestAuth.userNamespace,
3478
+ legacyProjectRoot: state.projectRoot,
3479
+ legacySubdir: "remote-snapshots"
3480
+ });
3481
+ const checkoutKey = explicitCheckoutKey(body, body, requestAuth);
2562
3482
  try {
2563
3483
  const archive = parseSnapshotArchiveContentBase64(archiveContentBase64);
2564
3484
  const checkout = extractUploadedSnapshotArchive({
@@ -2571,14 +3491,14 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
2571
3491
  const checkoutRepair = repairRemoteCheckoutForRig(checkout.path, repoSlug);
2572
3492
  const packageInstall = installRemoteCheckoutPackages(checkout.path);
2573
3493
  const postInstallConfigValidation = validateRemoteCheckoutRigConfig(checkout.path);
2574
- const project = linkProjectCheckout(state.projectRoot, repoSlug, {
3494
+ const project = linkProjectCheckout(registryRoot, repoSlug, {
2575
3495
  kind: "uploaded-snapshot",
2576
3496
  path: checkout.path,
2577
3497
  ref: checkout.snapshotId
2578
3498
  });
2579
3499
  deps.snapshotService.invalidate("uploaded-snapshot-checkout");
2580
3500
  deps.broadcastSnapshotInvalidation(state, "uploaded-snapshot-checkout");
2581
- return deps.jsonResponse({ ok: true, checkout, project, checkoutRepair, packageInstall, postInstallConfigValidation });
3501
+ return deps.jsonResponse({ ok: true, checkout, project, checkoutRepair, packageInstall, postInstallConfigValidation, userNamespace: userNamespaceResponse(requestAuth.userNamespace) });
2582
3502
  } catch (error) {
2583
3503
  return deps.jsonResponse({
2584
3504
  ok: false,
@@ -2598,10 +3518,17 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
2598
3518
  if (kind !== "managed-clone" && kind !== "current-ref" && kind !== "existing-path") {
2599
3519
  return deps.jsonResponse({ ok: false, error: "checkout kind must be managed-clone, current-ref, or existing-path" }, 400);
2600
3520
  }
2601
- const baseDir = normalizeString(body.baseDir) ?? normalizeString(checkoutInput.baseDir) ?? normalizeString(process.env.RIG_REMOTE_CHECKOUT_BASE_DIR) ?? (normalizeString(process.env.RIG_STATE_DIR) ? resolve11(normalizeString(process.env.RIG_STATE_DIR), "remote-checkouts") : resolve11(state.projectRoot, ".rig", "remote-checkouts"));
2602
- const checkoutKey = normalizeString(body.checkoutKey) ?? normalizeString(checkoutInput.checkoutKey) ?? normalizeString(checkoutInput.key) ?? "default";
3521
+ const registryRoot = requestScopedRegistryRoot(state.projectRoot, requestAuth);
3522
+ const baseDir = resolveNamespacedBaseDir({
3523
+ explicitBaseDir: normalizeString(body.baseDir) ?? normalizeString(checkoutInput.baseDir),
3524
+ envName: "RIG_REMOTE_CHECKOUT_BASE_DIR",
3525
+ userNamespace: requestAuth.userNamespace,
3526
+ legacyProjectRoot: state.projectRoot,
3527
+ legacySubdir: "remote-checkouts"
3528
+ });
3529
+ const checkoutKey = explicitCheckoutKey(body, checkoutInput, requestAuth);
2603
3530
  const repoUrl = normalizeString(body.repoUrl) ?? normalizeString(checkoutInput.repoUrl) ?? `https://github.com/${repoSlug}.git`;
2604
- const credentialToken = createGitHubAuthStore(state.projectRoot).readToken();
3531
+ const credentialToken = requestScopedAuthStore(state.projectRoot, requestAuth).readToken();
2605
3532
  try {
2606
3533
  const checkout = await prepareRemoteCheckout({
2607
3534
  command: runRemoteCheckoutCommand,
@@ -2610,14 +3537,14 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
2610
3537
  const checkoutRepair = repairRemoteCheckoutForRig(checkout.path, repoSlug);
2611
3538
  const packageInstall = installRemoteCheckoutPackages(checkout.path);
2612
3539
  const postInstallConfigValidation = validateRemoteCheckoutRigConfig(checkout.path);
2613
- const project = linkProjectCheckout(state.projectRoot, repoSlug, {
3540
+ const project = linkProjectCheckout(registryRoot, repoSlug, {
2614
3541
  kind: checkout.kind,
2615
3542
  path: checkout.path,
2616
3543
  ref: checkout.ref ?? checkout.snapshotId ?? undefined
2617
3544
  });
2618
3545
  deps.snapshotService.invalidate("remote-checkout-prepared");
2619
3546
  deps.broadcastSnapshotInvalidation(state, "remote-checkout-prepared");
2620
- return deps.jsonResponse({ ok: true, checkout, project, checkoutRepair, packageInstall, postInstallConfigValidation });
3547
+ return deps.jsonResponse({ ok: true, checkout, project, checkoutRepair, packageInstall, postInstallConfigValidation, userNamespace: userNamespaceResponse(requestAuth.userNamespace) });
2621
3548
  } catch (error) {
2622
3549
  return deps.jsonResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }, 400);
2623
3550
  }
@@ -2634,16 +3561,18 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
2634
3561
  if (kind !== "local" && kind !== "managed-clone" && kind !== "current-ref" && kind !== "uploaded-snapshot" && kind !== "existing-path") {
2635
3562
  return deps.jsonResponse({ ok: false, error: "checkout kind is required" }, 400);
2636
3563
  }
2637
- const project = linkProjectCheckout(state.projectRoot, repoSlug, {
3564
+ const registryRoot = requestScopedRegistryRoot(state.projectRoot, requestAuth);
3565
+ const project = linkProjectCheckout(registryRoot, repoSlug, {
2638
3566
  kind,
2639
3567
  path: normalizeString(body.path) ?? state.projectRoot,
2640
3568
  ref: normalizeString(body.ref) ?? undefined
2641
3569
  });
2642
- return deps.jsonResponse({ ok: true, project });
3570
+ return deps.jsonResponse({ ok: true, project, userNamespace: userNamespaceResponse(requestAuth.userNamespace) });
2643
3571
  }
2644
3572
  if (req.method === "GET") {
2645
- const project = getProjectRecord(state.projectRoot, repoSlug);
2646
- return project ? deps.jsonResponse({ ok: true, project }) : deps.notFound();
3573
+ const registryRoot = requestScopedRegistryRoot(state.projectRoot, requestAuth);
3574
+ const project = getProjectRecord(registryRoot, repoSlug);
3575
+ return project ? deps.jsonResponse({ ok: true, project, userNamespace: userNamespaceResponse(requestAuth.userNamespace) }) : deps.notFound();
2647
3576
  }
2648
3577
  }
2649
3578
  if (url.pathname === "/api/server/project-root" && req.method === "POST") {
@@ -2652,13 +3581,26 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
2652
3581
  if (!requestedRoot) {
2653
3582
  return deps.badRequest("projectRoot is required");
2654
3583
  }
2655
- if (!isAbsolute2(requestedRoot)) {
3584
+ if (!isAbsolute3(requestedRoot)) {
2656
3585
  return deps.badRequest("projectRoot must be an absolute path on the Rig server host");
2657
3586
  }
2658
- const normalizedRoot = resolve11(requestedRoot);
2659
- const exists = existsSync8(normalizedRoot);
2660
- if (exists) {
2661
- createGitHubAuthStore(state.projectRoot).copyToProjectRoot(normalizedRoot);
3587
+ const normalizedRoot = resolve13(requestedRoot);
3588
+ const exists = existsSync10(normalizedRoot);
3589
+ if (exists && requestAuth.userNamespace) {
3590
+ const allowedByNamespace = isPathInsideNamespace(requestAuth.userNamespace.root, normalizedRoot);
3591
+ const allowedByRegistry = projectRegistryContainsCheckout(requestAuth.userNamespace.root, normalizedRoot);
3592
+ if (!allowedByNamespace && !allowedByRegistry) {
3593
+ return deps.jsonResponse({
3594
+ ok: false,
3595
+ error: "Requested project root is outside the authenticated GitHub user namespace.",
3596
+ projectRoot: state.projectRoot,
3597
+ requestedProjectRoot: normalizedRoot,
3598
+ userNamespace: userNamespaceResponse(requestAuth.userNamespace)
3599
+ }, 403);
3600
+ }
3601
+ copyGitHubAuthStateToLocalProjectRoot(requestAuth.userNamespace.authStateFile, normalizedRoot);
3602
+ } else if (exists) {
3603
+ createGitHubAuthStore(state.projectRoot).copyToLocalProjectRoot(normalizedRoot);
2662
3604
  }
2663
3605
  const control = buildServerControlStatus();
2664
3606
  const switchCommand = process.env.RIG_PROJECT_ROOT_SWITCH_COMMAND?.trim();
@@ -2673,7 +3615,7 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
2673
3615
  message: "Requested project root does not exist on the Rig server host."
2674
3616
  }, 404);
2675
3617
  }
2676
- if (!existsSync8(resolve11(normalizedRoot, "rig.config.ts")) && !existsSync8(resolve11(normalizedRoot, "rig.config.json"))) {
3618
+ if (!existsSync10(resolve13(normalizedRoot, "rig.config.ts")) && !existsSync10(resolve13(normalizedRoot, "rig.config.json"))) {
2677
3619
  return deps.jsonResponse({
2678
3620
  ok: false,
2679
3621
  projectRoot: state.projectRoot,
@@ -2708,6 +3650,7 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
2708
3650
  exists,
2709
3651
  control,
2710
3652
  requiresRestart: false,
3653
+ userNamespace: userNamespaceResponse(requestAuth.userNamespace),
2711
3654
  message: "Project-root switch accepted. Rig server restart has been scheduled."
2712
3655
  }, 202);
2713
3656
  }
@@ -2722,11 +3665,11 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
2722
3665
  }, 409);
2723
3666
  }
2724
3667
  if (url.pathname === "/api/github/auth/status") {
2725
- const store = createGitHubAuthStore(state.projectRoot);
2726
- return deps.jsonResponse({ ok: true, ...store.status({ oauthConfigured: Boolean(process.env.RIG_GITHUB_OAUTH_CLIENT_ID?.trim()) }) });
3668
+ const store = requestScopedAuthStore(state.projectRoot, requestAuth);
3669
+ return deps.jsonResponse({ ok: true, ...store.status({ oauthConfigured: Boolean(process.env.RIG_GITHUB_OAUTH_CLIENT_ID?.trim()) }), userNamespace: userNamespaceResponse(requestAuth.userNamespace) });
2727
3670
  }
2728
3671
  if (url.pathname === "/api/github/repo/permissions") {
2729
- const store = createGitHubAuthStore(state.projectRoot);
3672
+ const store = requestScopedAuthStore(state.projectRoot, requestAuth);
2730
3673
  const auth = store.status({ oauthConfigured: Boolean(process.env.RIG_GITHUB_OAUTH_CLIENT_ID?.trim()) });
2731
3674
  if (!auth.signedIn) {
2732
3675
  return deps.jsonResponse({ ok: false, signedIn: false, canOpenPullRequest: false, reason: "not-authenticated" }, 401);
@@ -2754,24 +3697,20 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
2754
3697
  }
2755
3698
  try {
2756
3699
  const user = await fetchGitHubUserInfo(token);
2757
- const storeRoots = [
2758
- state.projectRoot,
2759
- ...requestedProjectRoot && isAbsolute2(requestedProjectRoot) && existsSync8(resolve11(requestedProjectRoot)) ? [resolve11(requestedProjectRoot)] : []
2760
- ].filter((root, index, roots) => roots.indexOf(root) === index);
2761
- const stores = storeRoots.map((root) => createGitHubAuthStore(root));
2762
- for (const store2 of stores) {
2763
- store2.saveToken({
2764
- token,
2765
- tokenSource: "manual-token",
2766
- login: user.login,
2767
- userId: user.userId,
2768
- scopes: user.scopes,
2769
- selectedRepo
2770
- });
2771
- }
2772
- const store = stores[stores.length - 1] ?? createGitHubAuthStore(state.projectRoot);
2773
- const apiSession = store.createApiSession();
2774
- return deps.jsonResponse({ ok: true, ...store.status({ oauthConfigured: Boolean(process.env.RIG_GITHUB_OAUTH_CLIENT_ID?.trim()) }), apiSessionToken: apiSession.token });
3700
+ const saved = saveGitHubTokenForRemoteUser({
3701
+ projectRoot: state.projectRoot,
3702
+ token,
3703
+ tokenSource: "manual-token",
3704
+ user,
3705
+ selectedRepo,
3706
+ requestedProjectRoot
3707
+ });
3708
+ return deps.jsonResponse({
3709
+ ok: true,
3710
+ ...saved.store.status({ oauthConfigured: Boolean(process.env.RIG_GITHUB_OAUTH_CLIENT_ID?.trim()) }),
3711
+ apiSessionToken: saved.apiSessionToken,
3712
+ userNamespace: userNamespaceResponse(saved.namespace)
3713
+ });
2775
3714
  } catch (error) {
2776
3715
  const message = error instanceof Error ? error.message : String(error);
2777
3716
  return deps.jsonResponse({ ok: false, error: message }, 400);
@@ -2838,9 +3777,21 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
2838
3777
  }
2839
3778
  const token = result.payload.access_token;
2840
3779
  const user = await fetchGitHubUserInfo(token);
2841
- store.saveToken({ token, tokenSource: "oauth-device", login: user.login, userId: user.userId, scopes: user.scopes });
2842
- const apiSession = store.createApiSession();
2843
- return deps.jsonResponse({ ok: true, status: "signed-in", ...store.status({ oauthConfigured: true }), apiSessionToken: apiSession.token });
3780
+ const saved = saveGitHubTokenForRemoteUser({
3781
+ projectRoot: state.projectRoot,
3782
+ token,
3783
+ tokenSource: "oauth-device",
3784
+ user,
3785
+ selectedRepo: null
3786
+ });
3787
+ store.clearPendingDevice(pollId);
3788
+ return deps.jsonResponse({
3789
+ ok: true,
3790
+ status: "signed-in",
3791
+ ...saved.store.status({ oauthConfigured: true }),
3792
+ apiSessionToken: saved.apiSessionToken,
3793
+ userNamespace: userNamespaceResponse(saved.namespace)
3794
+ });
2844
3795
  }
2845
3796
  if (url.pathname === "/api/github/repo/probe" && req.method === "POST") {
2846
3797
  const body = await deps.readJsonBody(req);
@@ -2849,7 +3800,7 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
2849
3800
  if (!owner || !repo) {
2850
3801
  return deps.badRequest("owner and repo are required");
2851
3802
  }
2852
- const store = createGitHubAuthStore(state.projectRoot);
3803
+ const store = requestScopedAuthStore(state.projectRoot, requestAuth);
2853
3804
  const authStatus = store.status({ oauthConfigured: Boolean(process.env.RIG_GITHUB_OAUTH_CLIENT_ID?.trim()) });
2854
3805
  const probe = await probeGitHubRepository({ owner, repo, token: store.readToken(), scopes: authStatus.scopes });
2855
3806
  return deps.jsonResponse({ ok: probe.ok, probe }, probe.ok ? 200 : 400);
@@ -2870,7 +3821,7 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
2870
3821
  return deps.badRequest(error instanceof Error ? error.message : String(error));
2871
3822
  }
2872
3823
  const authStatus = createGitHubAuthStore(state.projectRoot).status({ oauthConfigured: Boolean(process.env.RIG_GITHUB_OAUTH_CLIENT_ID?.trim()) });
2873
- const configPath = resolve11(targetRoot, "rig.config.ts");
3824
+ const configPath = resolve13(targetRoot, "rig.config.ts");
2874
3825
  const source = buildGitHubProjectConfigSource({
2875
3826
  projectName: rawProjectName,
2876
3827
  owner,
@@ -2882,8 +3833,8 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
2882
3833
  ok: true,
2883
3834
  projectRoot: targetRoot,
2884
3835
  configPath,
2885
- exists: existsSync8(configPath),
2886
- requiresOverwrite: existsSync8(configPath),
3836
+ exists: existsSync10(configPath),
3837
+ requiresOverwrite: existsSync10(configPath),
2887
3838
  source,
2888
3839
  owner,
2889
3840
  repo,
@@ -2919,8 +3870,8 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
2919
3870
  assignee,
2920
3871
  githubUserId: authStatus.userId ?? authStatus.login
2921
3872
  });
2922
- const configPath = resolve11(targetRoot, "rig.config.ts");
2923
- if (existsSync8(configPath) && !overwrite) {
3873
+ const configPath = resolve13(targetRoot, "rig.config.ts");
3874
+ if (existsSync10(configPath) && !overwrite) {
2924
3875
  return deps.jsonResponse({
2925
3876
  ok: false,
2926
3877
  error: "rig.config.ts already exists. Confirm overwrite to replace it; Rig will create a backup first.",
@@ -2936,11 +3887,11 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
2936
3887
  return deps.jsonResponse({ ok: false, error: repoProbe.message, repoProbe }, 400);
2937
3888
  }
2938
3889
  let backupPath = null;
2939
- if (existsSync8(configPath)) {
3890
+ if (existsSync10(configPath)) {
2940
3891
  backupPath = backupConfigPath(configPath);
2941
- copyFileSync(configPath, backupPath);
3892
+ copyFileSync2(configPath, backupPath);
2942
3893
  }
2943
- writeFileSync7(configPath, source, "utf8");
3894
+ writeFileSync9(configPath, source, "utf8");
2944
3895
  const selectedRepo = `${owner}/${repo}`;
2945
3896
  store.saveSelectedRepo(selectedRepo);
2946
3897
  const targetStore = createGitHubAuthStore(targetRoot);
@@ -3212,11 +4163,12 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
3212
4163
  const runId = normalizeString(body.runId);
3213
4164
  const createdAt = normalizeString(body.createdAt) ?? new Date().toISOString();
3214
4165
  const promptOverride = normalizeString(body.promptOverride);
4166
+ const restart = body.restart === true;
3215
4167
  if (!runId) {
3216
4168
  return deps.badRequest("runId is required");
3217
4169
  }
3218
4170
  try {
3219
- await deps.resumeRunRecord(state, { runId, createdAt, promptOverride });
4171
+ await deps.resumeRunRecord(state, { runId, createdAt, promptOverride, restart });
3220
4172
  deps.broadcastSnapshotInvalidation(state);
3221
4173
  return deps.jsonResponse({ ok: true, runId, createdAt });
3222
4174
  } catch (error) {
@@ -3225,7 +4177,7 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
3225
4177
  }
3226
4178
  if (url.pathname === "/api/pi-rig/install" && req.method === "POST") {
3227
4179
  const configuredPackageSource = normalizeString(process.env.RIG_PI_RIG_PACKAGE_SOURCE);
3228
- const packageSource = configuredPackageSource ?? [process.env.RIG_HOST_PROJECT_ROOT, process.cwd(), state.projectRoot].map((root) => normalizeString(root)).filter((root) => Boolean(root)).map((root) => resolve11(root, "packages", "pi-rig")).find((candidate) => existsSync8(resolve11(candidate, "package.json"))) ?? "npm:@rig/pi-rig";
4180
+ const packageSource = configuredPackageSource ?? [process.env.RIG_HOST_PROJECT_ROOT, process.cwd(), state.projectRoot].map((root) => normalizeString(root)).filter((root) => Boolean(root)).map((root) => resolve13(root, "packages", "pi-rig")).find((candidate) => existsSync10(resolve13(candidate, "package.json"))) ?? "npm:@h-rig/pi-rig";
3229
4181
  if (process.env.RIG_TEST_FAKE_PI_INSTALL === "1") {
3230
4182
  return deps.jsonResponse({ ok: true, installed: true, piOk: true, piRigOk: true, extensionPath: "remote:~/.pi/agent/extensions/pi-rig", packageSource });
3231
4183
  }
@@ -3541,9 +4493,9 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
3541
4493
  } catch {
3542
4494
  return deps.badRequest("Invalid artifact path");
3543
4495
  }
3544
- mkdirSync7(dirname6(artifactPath), { recursive: true });
4496
+ mkdirSync9(dirname9(artifactPath), { recursive: true });
3545
4497
  const bytes = Buffer.from(contentBase64, "base64");
3546
- writeFileSync7(artifactPath, bytes);
4498
+ writeFileSync9(artifactPath, bytes);
3547
4499
  writeJsonFile4(`${artifactPath}.json`, {
3548
4500
  workspaceId: normalizeString(body.workspaceId) ?? RIG_WORKSPACE_ID,
3549
4501
  runId,
@@ -3580,13 +4532,75 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
3580
4532
  }
3581
4533
  const run = leaseValidation.run;
3582
4534
  const completedAt = new Date().toISOString();
4535
+ const workspaceDir = normalizeString(body.workspaceDir) ?? normalizeString(body.runtimeWorkspace) ?? normalizeString(run.worktreePath);
4536
+ if (run.taskId && workspaceDir) {
4537
+ patchRunRecord(state.projectRoot, runId, {
4538
+ status: "reviewing",
4539
+ completedAt: null,
4540
+ hostId,
4541
+ endpointId: leaseId,
4542
+ worktreePath: workspaceDir,
4543
+ serverCloseout: {
4544
+ status: "pending",
4545
+ phase: "queued",
4546
+ requestedAt: completedAt,
4547
+ updatedAt: completedAt,
4548
+ runtimeWorkspace: workspaceDir,
4549
+ branch: normalizeString(body.branch) ?? normalizeString(run.branch) ?? `rig/${run.taskId}-${runId}`,
4550
+ taskId: run.taskId,
4551
+ source: "remote-complete"
4552
+ }
4553
+ });
4554
+ deps.appendRunLogEntryAndBroadcast(state, runId, {
4555
+ id: `log:${runId}:remote-server-closeout-requested`,
4556
+ title: "Server-owned closeout requested",
4557
+ detail: "Remote run completed provider work and handed commit/PR/review/merge closeout to the Rig server.",
4558
+ tone: "info",
4559
+ status: "reviewing",
4560
+ createdAt: completedAt,
4561
+ payload: { workspaceDir, hostId, leaseId }
4562
+ }, "remote-server-closeout-requested");
4563
+ deps.runServerOwnedPrCloseout(state, runId).catch((error) => {
4564
+ const detail = error instanceof Error ? error.message : String(error);
4565
+ patchRunRecord(state.projectRoot, runId, {
4566
+ status: "failed",
4567
+ completedAt: new Date().toISOString(),
4568
+ errorText: detail,
4569
+ serverCloseout: {
4570
+ status: "failed",
4571
+ phase: "failed",
4572
+ updatedAt: new Date().toISOString(),
4573
+ error: detail
4574
+ }
4575
+ });
4576
+ deps.appendRunLogEntryAndBroadcast(state, runId, {
4577
+ id: `log:${runId}:remote-server-closeout-failed`,
4578
+ title: "Server-owned closeout failed",
4579
+ detail,
4580
+ tone: "error",
4581
+ status: "failed",
4582
+ createdAt: new Date().toISOString()
4583
+ }, "remote-server-closeout-failed");
4584
+ }).finally(() => {
4585
+ deps.reconcileScheduler(state, "remote-server-closeout-terminal");
4586
+ });
4587
+ deps.broadcastSnapshotInvalidation(state);
4588
+ return deps.jsonResponse({
4589
+ ok: true,
4590
+ workspaceId: normalizeString(body.workspaceId) ?? RIG_WORKSPACE_ID,
4591
+ hostId,
4592
+ runId,
4593
+ leaseId,
4594
+ closeout: "server-owned",
4595
+ acceptedAt: new Date().toISOString()
4596
+ });
4597
+ }
3583
4598
  patchRunRecord(state.projectRoot, runId, {
3584
4599
  status: "completed",
3585
4600
  completedAt,
3586
4601
  hostId,
3587
4602
  endpointId: leaseId
3588
4603
  });
3589
- await updateRemoteRunTaskSourceLifecycle(state.projectRoot, { ...run, status: "completed", completedAt, hostId, endpointId: leaseId }, "closed", "Remote Rig task run completed and closed this task.");
3590
4604
  await deps.enqueueRunLinearEvent(state.projectRoot, {
3591
4605
  type: "run.completed",
3592
4606
  runId,
@@ -3705,12 +4719,12 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
3705
4719
  try {
3706
4720
  const runsRoot = resolveAuthorityPaths(state.projectRoot).runsDir;
3707
4721
  const runRoot = deps.normalizeRelativePath(runsRoot, runId);
3708
- const artifactsRoot = resolve11(runRoot, "remote-artifacts");
4722
+ const artifactsRoot = resolve13(runRoot, "remote-artifacts");
3709
4723
  artifactPath = deps.normalizeRelativePath(artifactsRoot, fileName);
3710
4724
  } catch {
3711
4725
  return deps.badRequest("Invalid artifact path");
3712
4726
  }
3713
- if (!existsSync8(artifactPath)) {
4727
+ if (!existsSync10(artifactPath)) {
3714
4728
  return deps.notFound();
3715
4729
  }
3716
4730
  return new Response(Bun.file(artifactPath));
@@ -3723,6 +4737,14 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
3723
4737
  const page = await readRunLogsPage(state.projectRoot, runId, { limit, cursor });
3724
4738
  return deps.jsonResponse(page);
3725
4739
  }
4740
+ const runTimelineMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/timeline$/);
4741
+ if (runTimelineMatch) {
4742
+ const runId = decodeURIComponent(runTimelineMatch[1]);
4743
+ const limit = Number.parseInt(url.searchParams.get("limit") || "500", 10);
4744
+ const cursor = normalizeString(url.searchParams.get("cursor"));
4745
+ const page = await readRunTimelinePage(state.projectRoot, runId, { limit, cursor });
4746
+ return deps.jsonResponse(page);
4747
+ }
3726
4748
  const runSteerMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/steer$/);
3727
4749
  if (runSteerMatch && req.method === "POST") {
3728
4750
  const runId = decodeURIComponent(runSteerMatch[1]);