codex-relay 1.0.0 → 1.0.1-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,7 +13,7 @@ Codex Relay runs a local bridge server for the Codex Relay mobile app. Keep Code
13
13
  Run the server from the workspace you want Codex to use:
14
14
 
15
15
  ```sh
16
- npx codex-relay
16
+ npx codex-relay@latest
17
17
  ```
18
18
 
19
19
  The CLI prints a QR code, a mobile URL, and a `codex-relay://pair...` pairing payload. Scan the QR code from the mobile app. If scanning is not available, paste the full pairing payload into the app.
@@ -21,7 +21,7 @@ The CLI prints a QR code, a mobile URL, and a `codex-relay://pair...` pairing pa
21
21
  When the app shows an approval code, approve it on the computer:
22
22
 
23
23
  ```sh
24
- npx codex-relay approve XXXX-XXXX
24
+ npx codex-relay@latest approve XXXX-XXXX
25
25
  ```
26
26
 
27
27
  After approval, the phone can list Codex threads, start new work, stream messages, and handle approval prompts from the local Codex runtime.
@@ -31,7 +31,7 @@ After approval, the phone can list Codex threads, start new work, stream message
31
31
  To keep the relay running after the command returns:
32
32
 
33
33
  ```sh
34
- npx codex-relay --bg
34
+ npx codex-relay@latest --bg
35
35
  ```
36
36
 
37
37
  Background mode writes runtime files under `.codex-relay/` in the current directory:
@@ -44,7 +44,7 @@ Background mode writes runtime files under `.codex-relay/` in the current direct
44
44
  Print the current pairing QR again:
45
45
 
46
46
  ```sh
47
- npx codex-relay qr
47
+ npx codex-relay@latest qr
48
48
  ```
49
49
 
50
50
  Stop a background server with the printed process id:
@@ -56,25 +56,25 @@ kill -TERM <pid>
56
56
  ## Commands
57
57
 
58
58
  ```sh
59
- npx codex-relay
59
+ npx codex-relay@latest
60
60
  ```
61
61
 
62
62
  Start the relay in the foreground.
63
63
 
64
64
  ```sh
65
- npx codex-relay --bg
65
+ npx codex-relay@latest --bg
66
66
  ```
67
67
 
68
68
  Start the relay in the background.
69
69
 
70
70
  ```sh
71
- npx codex-relay qr
71
+ npx codex-relay@latest qr
72
72
  ```
73
73
 
74
74
  Print the latest pairing QR for an already running relay.
75
75
 
76
76
  ```sh
77
- npx codex-relay approve XXXX-XXXX
77
+ npx codex-relay@latest approve XXXX-XXXX
78
78
  ```
79
79
 
80
80
  Approve a pending mobile pairing request.
@@ -83,29 +83,29 @@ Approve a pending mobile pairing request.
83
83
 
84
84
  The relay listens on `0.0.0.0:8787` by default. Configure it with environment variables:
85
85
 
86
- | Variable | Purpose |
87
- | ----------------------------- | ------------------------------------------------------------------------------------------- |
88
- | `PORT` | Server port. Defaults to `8787`. |
89
- | `HOST` | Listen host. Defaults to `0.0.0.0`. |
90
- | `CODEX_RELAY_PUBLIC_URL` | URL printed into the pairing QR, for example a Tailscale or tunnel URL. |
91
- | `CODEX_RELAY_WORKSPACE_PATH` | Workspace path Codex should use. Defaults to the directory where you run `npx codex-relay`. |
92
- | `CODEX_RELAY_AUTH_DB_PATH` | Pairing and session database path. Defaults to `.codex-relay/auth.db`. |
93
- | `CODEX_RELAY_APPROVAL_SECRET` | Secret used by the local approve command. Usually generated automatically. |
94
- | `CODEX_HOME` | Codex home directory, used when reading Codex session metadata. |
95
- | `CODEX_BIN` | Codex CLI executable path. |
86
+ | Variable | Purpose |
87
+ | ----------------------------- | -------------------------------------------------------------------------------------------------- |
88
+ | `PORT` | Server port. Defaults to `8787`. |
89
+ | `HOST` | Listen host. Defaults to `0.0.0.0`. |
90
+ | `CODEX_RELAY_PUBLIC_URL` | URL printed into the pairing QR, for example a Tailscale or tunnel URL. |
91
+ | `CODEX_RELAY_WORKSPACE_PATH` | Workspace path Codex should use. Defaults to the directory where you run `npx codex-relay@latest`. |
92
+ | `CODEX_RELAY_AUTH_DB_PATH` | Pairing and session database path. Defaults to `.codex-relay/auth.db`. |
93
+ | `CODEX_RELAY_APPROVAL_SECRET` | Secret used by the local approve command. Usually generated automatically. |
94
+ | `CODEX_HOME` | Codex home directory, used when reading Codex session metadata. |
95
+ | `CODEX_BIN` | Codex CLI executable path. |
96
96
 
97
97
  Examples:
98
98
 
99
99
  ```sh
100
- PORT=8788 npx codex-relay
100
+ PORT=8788 npx codex-relay@latest
101
101
  ```
102
102
 
103
103
  ```sh
104
- CODEX_RELAY_PUBLIC_URL=http://100.64.0.10:8787 npx codex-relay
104
+ CODEX_RELAY_PUBLIC_URL=http://100.64.0.10:8787 npx codex-relay@latest
105
105
  ```
106
106
 
107
107
  ```sh
108
- CODEX_RELAY_WORKSPACE_PATH=/path/to/project npx codex-relay
108
+ CODEX_RELAY_WORKSPACE_PATH=/path/to/project npx codex-relay@latest
109
109
  ```
110
110
 
111
111
  ## Network Notes
@@ -118,16 +118,16 @@ The phone must be able to reach the URL printed as `Mobile:` by the relay.
118
118
 
119
119
  ## Troubleshooting
120
120
 
121
- If `npx codex-relay qr` cannot find a server, start one first:
121
+ If `npx codex-relay@latest qr` cannot find a server, start one first:
122
122
 
123
123
  ```sh
124
- npx codex-relay
124
+ npx codex-relay@latest
125
125
  ```
126
126
 
127
127
  If the relay says another process is using the local pairing database, use the existing server:
128
128
 
129
129
  ```sh
130
- npx codex-relay qr
130
+ npx codex-relay@latest qr
131
131
  ```
132
132
 
133
133
  Or stop the background process shown by the CLI:
package/dist/cli.js CHANGED
@@ -31,7 +31,7 @@ async function startBackgroundServer() {
31
31
  if (existingPid) {
32
32
  console.log(`codex-relay is already running in the background (pid ${existingPid}).`);
33
33
  console.log(`Logs: ${logPath}`);
34
- console.log("Print the current pairing QR with: npx codex-relay qr");
34
+ console.log("Print the current pairing QR with: npx codex-relay@latest qr");
35
35
  return;
36
36
  }
37
37
  await unlink(pidPath).catch(() => void 0);
@@ -66,7 +66,7 @@ async function startBackgroundServer() {
66
66
  }
67
67
  console.log(`Started codex-relay in the background (pid ${startedPid}).`);
68
68
  console.log(`Logs: ${logPath}`);
69
- console.log("Print the pairing QR later with: npx codex-relay qr");
69
+ console.log("Print the pairing QR later with: npx codex-relay@latest qr");
70
70
  }
71
71
  async function readRunningPid(pidPath) {
72
72
  const value = await readFile(pidPath, "utf8").catch(() => void 0);
@@ -126,8 +126,8 @@ async function printPairingQr() {
126
126
  const state = storedState?.pairingPayload ? storedState : await readServerLogState();
127
127
  if (!state?.pairingPayload) {
128
128
  console.error("No running Codex Relay server state was found.");
129
- console.error("Start the server first with: npx codex-relay");
130
- console.error("Or run it in the background with: npx codex-relay --bg");
129
+ console.error("Start the server first with: npx codex-relay@latest");
130
+ console.error("Or run it in the background with: npx codex-relay@latest --bg");
131
131
  process.exitCode = 1;
132
132
  return;
133
133
  }
@@ -152,7 +152,7 @@ async function handleServerStartError(error) {
152
152
  if (existingPid) {
153
153
  console.error(`A background server appears to be running (pid ${existingPid}).`);
154
154
  console.error("Use the existing server instead of starting a second one:");
155
- console.error(" npx codex-relay qr");
155
+ console.error(" npx codex-relay@latest qr");
156
156
  console.error("");
157
157
  console.error("To stop the background server:");
158
158
  console.error(` kill -TERM ${existingPid}`);
@@ -162,7 +162,7 @@ async function handleServerStartError(error) {
162
162
  console.error("Another Codex Relay process is already running or exited without cleanup.");
163
163
  if (state?.pairingPayload) {
164
164
  console.error("Use the existing server instead of starting a second one:");
165
- console.error(" npx codex-relay qr");
165
+ console.error(" npx codex-relay@latest qr");
166
166
  console.error("");
167
167
  }
168
168
  console.error("Find it with:");
@@ -174,7 +174,7 @@ async function handleServerStartError(error) {
174
174
  }
175
175
  console.error("");
176
176
  console.error("If you wanted a persistent server, start it once with:");
177
- console.error(" npx codex-relay --bg");
177
+ console.error(" npx codex-relay@latest --bg");
178
178
  process.exitCode = 1;
179
179
  }
180
180
  async function readApprovalSecret() {
package/dist/src.js CHANGED
@@ -181,8 +181,9 @@ const WorkspaceChangesResponseSchema = z.object({
181
181
  worktreeStatus: z.string().nullable()
182
182
  })).default([])
183
183
  });
184
- const CheckoutWorkspaceBranchRequestSchema = z.object({ branch: z.string().trim().min(1).refine((branch) => !branch.startsWith("-"), "Branch name cannot start with '-'.") });
185
- const CommitPushWorkspaceRequestSchema = z.object({ message: z.string().trim().min(1).max(240) });
184
+ const WorkspaceSelectionRequestSchema = z.object({ workspacePath: z.string().trim().min(1).optional() });
185
+ const CheckoutWorkspaceBranchRequestSchema = WorkspaceSelectionRequestSchema.extend({ branch: z.string().trim().min(1).refine((branch) => !branch.startsWith("-"), "Branch name cannot start with '-'.") });
186
+ const CommitPushWorkspaceRequestSchema = WorkspaceSelectionRequestSchema.extend({ message: z.string().trim().min(1).max(240) });
186
187
  const WorkspaceGitActionResponseSchema = z.object({
187
188
  branch: z.string().nullable(),
188
189
  message: z.string().min(1),
@@ -206,6 +207,7 @@ const WebPreviewTargetSchema = z.object({
206
207
  detectedAt: IsoDateTimeSchema
207
208
  });
208
209
  const PairRequestSchema = z.object({
210
+ clientSessionId: z.string().trim().min(1).max(120).optional(),
209
211
  clientName: z.string().trim().min(1).max(80).optional(),
210
212
  secure: z.object({
211
213
  clientEphemeralPublicKey: z.string().min(1),
package/dist/src2.js CHANGED
@@ -540,7 +540,8 @@ function createApp(options = {}) {
540
540
  const tokenHash = token ? options.pairing.hashClientToken(token) : void 0;
541
541
  const validSession = tokenHash ? await options.pairing.sessions.getValidSession(tokenHash, Date.now()) : void 0;
542
542
  if (!tokenHash || !validSession) return c.json(apiError("unauthorized", "Pair this device with the Codex Relay server."), 401);
543
- if (options.pairing.serverIdentity && !secureSessionsByTokenHash.has(tokenHash)) return c.json(apiError("secure_session_required", "Secure session expired. Pair this device again."), 401);
543
+ if (options.pairing.serverIdentity && !secureSessionsByTokenHash.has(tokenHash)) if (validSession.secureSession) secureSessionsByTokenHash.set(tokenHash, validSession.secureSession);
544
+ else return c.json(apiError("secure_session_required", "Secure session expired. Pair this device again."), 401);
544
545
  await next();
545
546
  });
546
547
  app.post(apiPaths.pair, async (c) => {
@@ -556,6 +557,7 @@ function createApp(options = {}) {
556
557
  approved: false,
557
558
  approvalCode,
558
559
  clientEphemeralPublicKey: parsed.data.secure.clientEphemeralPublicKey,
560
+ clientSessionId: parsed.data.clientSessionId,
559
561
  clientName: parsed.data.clientName,
560
562
  clientNonce: parsed.data.secure.clientNonce,
561
563
  expiresAt,
@@ -584,15 +586,6 @@ function createApp(options = {}) {
584
586
  const clientToken = options.pairing.createClientToken();
585
587
  const expiresAt = Date.now() + options.pairing.tokenTtlMs;
586
588
  const tokenHash = options.pairing.hashClientToken(clientToken);
587
- const tokenCount = await options.pairing.sessions.createSession(tokenHash, {
588
- clientName: pending.clientName,
589
- expiresAt
590
- });
591
- await options.pairing.sessions.deletePendingPairing(approvalCode);
592
- options.pairing.onPaired?.({
593
- clientName: pending.clientName,
594
- tokenCount
595
- });
596
589
  const clientTokenExpiresAt = new Date(expiresAt).toISOString();
597
590
  const pairing = createSecurePairing({
598
591
  approvalCode,
@@ -604,6 +597,17 @@ function createApp(options = {}) {
604
597
  serverIdentity: options.pairing.serverIdentity,
605
598
  serverUrl: pending.serverUrl
606
599
  });
600
+ const tokenCount = await options.pairing.sessions.createSession(tokenHash, {
601
+ clientSessionId: pending.clientSessionId,
602
+ clientName: pending.clientName,
603
+ expiresAt,
604
+ secureSession: pairing.session
605
+ });
606
+ await options.pairing.sessions.deletePendingPairing(approvalCode);
607
+ options.pairing.onPaired?.({
608
+ clientName: pending.clientName,
609
+ tokenCount
610
+ });
607
611
  secureSessionsByTokenHash.set(tokenHash, pairing.session);
608
612
  return c.json(PairResponseSchema.parse({ secure: pairing.response }), 201);
609
613
  });
@@ -630,15 +634,15 @@ function createApp(options = {}) {
630
634
  const expiresAt = Date.now() + options.pairing.tokenTtlMs;
631
635
  const oldTokenHash = options.pairing.hashClientToken(oldToken);
632
636
  const newTokenHash = options.pairing.hashClientToken(clientToken);
637
+ const clientSessionId = normalizeClientSessionId(c.req.header("x-codex-relay-client-session-id")) ?? oldSession.clientSessionId;
633
638
  const tokenCount = await options.pairing.sessions.rotateSession(oldTokenHash, newTokenHash, {
639
+ clientSessionId,
634
640
  clientName: oldSession.clientName,
635
- expiresAt
641
+ expiresAt,
642
+ secureSession: secureSessionsByTokenHash.get(oldTokenHash) ?? oldSession.secureSession
636
643
  });
637
- const secureSession = secureSessionsByTokenHash.get(oldTokenHash);
638
- if (secureSession) {
639
- secureSessionsByTokenHash.delete(oldTokenHash);
640
- secureSessionsByTokenHash.set(newTokenHash, secureSession);
641
- }
644
+ const secureSession = secureSessionsByTokenHash.get(oldTokenHash) ?? oldSession.secureSession;
645
+ if (secureSession) secureSessionsByTokenHash.set(oldTokenHash, secureSession);
642
646
  options.pairing.onTokenRefreshed?.({
643
647
  clientName: oldSession.clientName,
644
648
  tokenCount
@@ -647,7 +651,13 @@ function createApp(options = {}) {
647
651
  clientToken,
648
652
  clientTokenExpiresAt: new Date(expiresAt).toISOString()
649
653
  });
650
- return secureJson(c, options.pairing, secureSessionsByTokenHash, response, 201);
654
+ const jsonResponse = await secureJson(c, options.pairing, secureSessionsByTokenHash, response, 201);
655
+ if (secureSession) {
656
+ secureSessionsByTokenHash.delete(oldTokenHash);
657
+ secureSessionsByTokenHash.set(newTokenHash, secureSession);
658
+ await options.pairing.sessions.updateSecureSession(newTokenHash, secureSession);
659
+ }
660
+ return jsonResponse;
651
661
  });
652
662
  app.get(apiPaths.status, (c) => {
653
663
  const response = StatusResponseSchema.parse({
@@ -682,9 +692,11 @@ function createApp(options = {}) {
682
692
  });
683
693
  app.get(apiPaths.workspaceChanges, async (c) => {
684
694
  try {
685
- const changes = await readWorkspaceChanges(workspacePath);
695
+ const selectedWorkspacePath = await validateThreadWorkspacePath(workspacePath, c.req.query("workspacePath"));
696
+ if (!selectedWorkspacePath.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("invalid_workspace_path", selectedWorkspacePath.error), 400);
697
+ const changes = await readWorkspaceChanges(selectedWorkspacePath.path);
686
698
  const response = WorkspaceChangesResponseSchema.parse({
687
- workspacePath,
699
+ workspacePath: selectedWorkspacePath.path,
688
700
  ...changes
689
701
  });
690
702
  return secureJson(c, options.pairing, secureSessionsByTokenHash, response);
@@ -696,10 +708,17 @@ function createApp(options = {}) {
696
708
  const parsed = await parseRequestJson(c, options.pairing, secureSessionsByTokenHash, CheckoutWorkspaceBranchRequestSchema);
697
709
  if (!parsed.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, validationError(parsed.error), 400);
698
710
  try {
699
- const output = await git(workspacePath, ["checkout", parsed.data.branch]);
711
+ const selectedWorkspacePath = await validateThreadWorkspacePath(workspacePath, parsed.data.workspacePath);
712
+ if (!selectedWorkspacePath.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("invalid_workspace_path", selectedWorkspacePath.error), 400);
713
+ const branchExists = await localGitBranchExists(selectedWorkspacePath.path, parsed.data.branch);
714
+ const output = branchExists ? await git(selectedWorkspacePath.path, ["checkout", parsed.data.branch]) : await git(selectedWorkspacePath.path, [
715
+ "checkout",
716
+ "-b",
717
+ parsed.data.branch
718
+ ]);
700
719
  const response = WorkspaceGitActionResponseSchema.parse({
701
- branch: await currentGitBranch(workspacePath),
702
- message: `Checked out ${parsed.data.branch}.`,
720
+ branch: await currentGitBranch(selectedWorkspacePath.path),
721
+ message: branchExists ? `Checked out ${parsed.data.branch}.` : `Created and checked out ${parsed.data.branch}.`,
703
722
  output
704
723
  });
705
724
  return secureJson(c, options.pairing, secureSessionsByTokenHash, response);
@@ -711,23 +730,25 @@ function createApp(options = {}) {
711
730
  const parsed = await parseRequestJson(c, options.pairing, secureSessionsByTokenHash, CommitPushWorkspaceRequestSchema);
712
731
  if (!parsed.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, validationError(parsed.error), 400);
713
732
  try {
714
- await git(workspacePath, ["add", "--all"]);
715
- const commitOutput = await git(workspacePath, [
733
+ const selectedWorkspacePath = await validateThreadWorkspacePath(workspacePath, parsed.data.workspacePath);
734
+ if (!selectedWorkspacePath.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("invalid_workspace_path", selectedWorkspacePath.error), 400);
735
+ await git(selectedWorkspacePath.path, ["add", "--all"]);
736
+ const commitOutput = await git(selectedWorkspacePath.path, [
716
737
  "commit",
717
738
  "-m",
718
739
  parsed.data.message
719
740
  ]);
720
- const branch = await currentGitBranch(workspacePath);
721
- const pushOutput = await git(workspacePath, [
741
+ const branch = await currentGitBranch(selectedWorkspacePath.path);
742
+ const pushOutput = await git(selectedWorkspacePath.path, [
722
743
  "rev-parse",
723
744
  "--abbrev-ref",
724
745
  "@{upstream}"
725
- ]).catch(() => null) ? await git(workspacePath, ["push"]) : branch ? await git(workspacePath, [
746
+ ]).catch(() => null) ? await git(selectedWorkspacePath.path, ["push"]) : branch ? await git(selectedWorkspacePath.path, [
726
747
  "push",
727
748
  "-u",
728
749
  "origin",
729
750
  branch
730
- ]) : await git(workspacePath, ["push"]);
751
+ ]) : await git(selectedWorkspacePath.path, ["push"]);
731
752
  const response = WorkspaceGitActionResponseSchema.parse({
732
753
  branch,
733
754
  message: "Committed and pushed workspace changes.",
@@ -981,6 +1002,10 @@ function normalizeApprovalCode(value) {
981
1002
  const normalized = value.toUpperCase().replace(/[^A-Z0-9]/g, "").replaceAll("O", "0").replaceAll("I", "1");
982
1003
  return normalized.length === 8 ? `${normalized.slice(0, 4)}-${normalized.slice(4)}` : normalized;
983
1004
  }
1005
+ function normalizeClientSessionId(value) {
1006
+ const trimmed = value?.trim();
1007
+ return trimmed ? trimmed.slice(0, 120) : void 0;
1008
+ }
984
1009
  async function validateThreadWorkspacePath(rootPath, requestedPath) {
985
1010
  const resolved = resolve(requestedPath ?? rootPath);
986
1011
  try {
@@ -1513,7 +1538,8 @@ async function parseRequestJson(c, pairing, secureSessionsByTokenHash, schema) {
1513
1538
  const envelope = EncryptedPayloadSchema.safeParse(payload);
1514
1539
  if (!envelope.success) return schema.safeParse({ __invalidEncryptedPayload: true });
1515
1540
  try {
1516
- payload = JSON.parse(decryptFromMobile(secureSession, envelope.data));
1541
+ payload = JSON.parse(decryptFromMobile(secureSession.session, envelope.data));
1542
+ await secureSession.persist();
1517
1543
  } catch {
1518
1544
  payload = { __invalidEncryptedPayload: true };
1519
1545
  }
@@ -1529,15 +1555,30 @@ async function parsePlainJson(request, schema) {
1529
1555
  }
1530
1556
  return schema.safeParse(payload);
1531
1557
  }
1532
- function secureJson(c, pairing, secureSessionsByTokenHash, payload, status) {
1558
+ async function secureJson(c, pairing, secureSessionsByTokenHash, payload, status) {
1533
1559
  const secureSession = getSecureSessionForRequest(c, pairing, secureSessionsByTokenHash);
1534
1560
  if (!secureSession) return c.json(payload, status);
1535
- const encrypted = EncryptedPayloadSchema.parse(encryptForMobile(secureSession, JSON.stringify(payload)));
1561
+ const encrypted = EncryptedPayloadSchema.parse(encryptForMobile(secureSession.session, JSON.stringify(payload)));
1562
+ await secureSession.persist();
1536
1563
  return c.json(encrypted, status);
1537
1564
  }
1538
1565
  function getSecureSessionForRequest(c, pairing, secureSessionsByTokenHash) {
1539
1566
  const token = parseBearerToken(c.req.header("authorization"));
1540
- return token && pairing ? secureSessionsByTokenHash.get(pairing.hashClientToken(token)) : void 0;
1567
+ if (!token || !pairing) return;
1568
+ const tokenHash = pairing.hashClientToken(token);
1569
+ const session = secureSessionsByTokenHash.get(tokenHash);
1570
+ return session ? createSecureSessionHandle(pairing, tokenHash, session) : void 0;
1571
+ }
1572
+ function createSecureSessionHandle(pairing, tokenHash, session) {
1573
+ let pendingPersist = Promise.resolve();
1574
+ return {
1575
+ persist: () => {
1576
+ pendingPersist = pendingPersist.catch(() => void 0).then(() => pairing.sessions.updateSecureSession(tokenHash, session));
1577
+ return pendingPersist;
1578
+ },
1579
+ session,
1580
+ tokenHash
1581
+ };
1541
1582
  }
1542
1583
  function sortedThreads(threads) {
1543
1584
  return [...threads.values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
@@ -1664,7 +1705,8 @@ function updateThread(threads, messagesByThreadId, threadId, update) {
1664
1705
  }
1665
1706
  function sendSse(controller, encoder, secureSession, event) {
1666
1707
  const parsed = StreamThreadRunEventSchema.parse(event);
1667
- const data = secureSession ? EncryptedPayloadSchema.parse(encryptForMobile(secureSession, JSON.stringify(parsed))) : parsed;
1708
+ const data = secureSession ? EncryptedPayloadSchema.parse(encryptForMobile(secureSession.session, JSON.stringify(parsed))) : parsed;
1709
+ if (secureSession) secureSession.persist().catch(() => void 0);
1668
1710
  controller.enqueue(encoder.encode(`event: ${parsed.type}\n`));
1669
1711
  controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
1670
1712
  }
@@ -2417,7 +2459,7 @@ function normalizeFileChanges(value) {
2417
2459
  async function readWorkspaceChanges(workspacePath) {
2418
2460
  const repo = await openRepository(workspacePath);
2419
2461
  const [currentBranch, branches] = await Promise.all([currentGitBranch(workspacePath), listGitBranches(workspacePath)]);
2420
- const statusEntries = collectIterator(repo.statuses().iter());
2462
+ const statusEntries = collectIterator(repo.statuses().iter()).filter((entry) => !entry.status().ignored);
2421
2463
  const statusByPath = new Map(statusEntries.map((entry) => [entry.path(), entry]));
2422
2464
  const status = statusEntries.map((entry) => formatStatusLine(entry.path(), entry.status())).join("\n");
2423
2465
  const structuredDiff = createWorkspaceDiff(repo);
@@ -2465,6 +2507,11 @@ async function readWorkspaceChanges(workspacePath) {
2465
2507
  });
2466
2508
  }
2467
2509
  const files = [...filesByPath.values()].sort((left, right) => left.path.localeCompare(right.path));
2510
+ const fileStats = {
2511
+ additions: files.reduce((total, file) => total + file.additions, 0),
2512
+ deletions: files.reduce((total, file) => total + file.deletions, 0),
2513
+ filesChanged: files.length
2514
+ };
2468
2515
  return {
2469
2516
  branches,
2470
2517
  currentBranch,
@@ -2472,16 +2519,29 @@ async function readWorkspaceChanges(workspacePath) {
2472
2519
  files,
2473
2520
  hasChanges: files.length > 0 || Boolean(status.trim() || diff.trim()),
2474
2521
  status,
2475
- stats: {
2522
+ stats: files.length > 0 ? fileStats : {
2476
2523
  additions: Number(stats.insertions),
2477
2524
  deletions: Number(stats.deletions),
2478
- filesChanged: files.length || Number(stats.filesChanged)
2525
+ filesChanged: Number(stats.filesChanged)
2479
2526
  }
2480
2527
  };
2481
2528
  }
2482
2529
  async function currentGitBranch(workspacePath) {
2483
2530
  return (await git(workspacePath, ["branch", "--show-current"]).catch(() => "")).trim() || null;
2484
2531
  }
2532
+ async function localGitBranchExists(workspacePath, branch) {
2533
+ try {
2534
+ await git(workspacePath, [
2535
+ "show-ref",
2536
+ "--verify",
2537
+ "--quiet",
2538
+ `refs/heads/${branch}`
2539
+ ]);
2540
+ return true;
2541
+ } catch {
2542
+ return false;
2543
+ }
2544
+ }
2485
2545
  async function listGitBranches(workspacePath) {
2486
2546
  return (await git(workspacePath, [
2487
2547
  "branch",
@@ -2596,14 +2656,21 @@ async function createTursoPairingSessionStore(path) {
2596
2656
  await db.exec(`
2597
2657
  CREATE TABLE IF NOT EXISTS pairing_sessions (
2598
2658
  token_hash TEXT PRIMARY KEY,
2659
+ client_session_id TEXT,
2599
2660
  client_name TEXT,
2600
2661
  expires_at INTEGER NOT NULL,
2662
+ key_epoch INTEGER,
2663
+ mobile_to_server_key TEXT,
2664
+ server_to_mobile_key TEXT,
2665
+ last_mobile_counter INTEGER,
2666
+ next_server_counter INTEGER,
2601
2667
  created_at INTEGER NOT NULL,
2602
2668
  updated_at INTEGER NOT NULL
2603
2669
  );
2604
2670
 
2605
2671
  CREATE TABLE IF NOT EXISTS pending_pairings (
2606
2672
  approval_code TEXT PRIMARY KEY,
2673
+ client_session_id TEXT,
2607
2674
  client_name TEXT,
2608
2675
  client_ephemeral_public_key TEXT NOT NULL,
2609
2676
  client_nonce TEXT NOT NULL,
@@ -2614,8 +2681,9 @@ async function createTursoPairingSessionStore(path) {
2614
2681
  updated_at INTEGER NOT NULL
2615
2682
  );
2616
2683
  `);
2684
+ await ensurePairingSessionColumns();
2617
2685
  async function countActive(now) {
2618
- const row = await db.prepare("SELECT COUNT(*) AS count FROM pairing_sessions WHERE expires_at > ?").get(now);
2686
+ const row = await db.prepare("SELECT COUNT(DISTINCT COALESCE(client_session_id, token_hash)) AS count FROM pairing_sessions WHERE expires_at > ?").get(now);
2619
2687
  return Number(row?.count ?? 0);
2620
2688
  }
2621
2689
  async function deleteSession(tokenHash) {
@@ -2626,6 +2694,7 @@ async function createTursoPairingSessionStore(path) {
2626
2694
  }
2627
2695
  async function getPendingPairing(approvalCode, now) {
2628
2696
  const row = await db.prepare(`SELECT approval_code AS approvalCode,
2697
+ client_session_id AS clientSessionId,
2629
2698
  client_name AS clientName,
2630
2699
  client_ephemeral_public_key AS clientEphemeralPublicKey,
2631
2700
  client_nonce AS clientNonce,
@@ -2644,6 +2713,7 @@ async function createTursoPairingSessionStore(path) {
2644
2713
  approvalCode: String(row.approvalCode),
2645
2714
  approved: Number(row.approved) === 1,
2646
2715
  clientEphemeralPublicKey: String(row.clientEphemeralPublicKey),
2716
+ clientSessionId: typeof row.clientSessionId === "string" ? row.clientSessionId : void 0,
2647
2717
  clientName: typeof row.clientName === "string" ? row.clientName : void 0,
2648
2718
  clientNonce: String(row.clientNonce),
2649
2719
  expiresAt,
@@ -2665,6 +2735,7 @@ async function createTursoPairingSessionStore(path) {
2665
2735
  const now = Date.now();
2666
2736
  await db.prepare(`INSERT INTO pending_pairings (
2667
2737
  approval_code,
2738
+ client_session_id,
2668
2739
  client_name,
2669
2740
  client_ephemeral_public_key,
2670
2741
  client_nonce,
@@ -2674,19 +2745,45 @@ async function createTursoPairingSessionStore(path) {
2674
2745
  created_at,
2675
2746
  updated_at
2676
2747
  )
2677
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(pairing.approvalCode, pairing.clientName ?? null, pairing.clientEphemeralPublicKey, pairing.clientNonce, pairing.serverUrl, pairing.approved ? 1 : 0, pairing.expiresAt, now, now);
2748
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(pairing.approvalCode, pairing.clientSessionId ?? null, pairing.clientName ?? null, pairing.clientEphemeralPublicKey, pairing.clientNonce, pairing.serverUrl, pairing.approved ? 1 : 0, pairing.expiresAt, now, now);
2678
2749
  },
2679
2750
  async createSession(tokenHash, session) {
2680
2751
  const now = Date.now();
2681
- await db.prepare(`INSERT INTO pairing_sessions (token_hash, client_name, expires_at, created_at, updated_at)
2682
- VALUES (?, ?, ?, ?, ?)`).run(tokenHash, session.clientName ?? null, session.expiresAt, now, now);
2752
+ const secure = encodeSecureSession(session.secureSession);
2753
+ if (session.clientSessionId) {
2754
+ await db.prepare("DELETE FROM pairing_sessions WHERE client_session_id = ?").run(session.clientSessionId);
2755
+ if (session.clientName) await db.prepare("DELETE FROM pairing_sessions WHERE client_session_id IS NULL AND client_name = ?").run(session.clientName);
2756
+ }
2757
+ await db.prepare(`INSERT INTO pairing_sessions (
2758
+ token_hash,
2759
+ client_session_id,
2760
+ client_name,
2761
+ expires_at,
2762
+ key_epoch,
2763
+ mobile_to_server_key,
2764
+ server_to_mobile_key,
2765
+ last_mobile_counter,
2766
+ next_server_counter,
2767
+ created_at,
2768
+ updated_at
2769
+ )
2770
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(tokenHash, session.clientSessionId ?? null, session.clientName ?? null, session.expiresAt, secure?.keyEpoch ?? null, secure?.mobileToServerKey ?? null, secure?.serverToMobileKey ?? null, secure?.lastMobileCounter ?? null, secure?.nextServerCounter ?? null, now, now);
2683
2771
  return countActive(now);
2684
2772
  },
2685
2773
  deleteSession,
2686
2774
  deletePendingPairing,
2687
2775
  getPendingPairing,
2688
2776
  async getValidSession(tokenHash, now) {
2689
- const row = await db.prepare("SELECT client_name AS clientName, expires_at AS expiresAt FROM pairing_sessions WHERE token_hash = ?").get(tokenHash);
2777
+ const row = await db.prepare(`SELECT client_name AS clientName,
2778
+ client_session_id AS clientSessionId,
2779
+ expires_at AS expiresAt,
2780
+ key_epoch AS keyEpoch,
2781
+ mobile_to_server_key AS mobileToServerKey,
2782
+ server_to_mobile_key AS serverToMobileKey,
2783
+ last_mobile_counter AS lastMobileCounter,
2784
+ next_server_counter AS nextServerCounter
2785
+ FROM pairing_sessions
2786
+ WHERE token_hash = ?`).get(tokenHash);
2690
2787
  if (!row) return;
2691
2788
  const expiresAt = Number(row.expiresAt);
2692
2789
  if (now > expiresAt) {
@@ -2694,8 +2791,10 @@ async function createTursoPairingSessionStore(path) {
2694
2791
  return;
2695
2792
  }
2696
2793
  return {
2794
+ clientSessionId: typeof row.clientSessionId === "string" ? row.clientSessionId : void 0,
2697
2795
  clientName: typeof row.clientName === "string" ? row.clientName : void 0,
2698
- expiresAt
2796
+ expiresAt,
2797
+ secureSession: decodeSecureSession(row)
2699
2798
  };
2700
2799
  },
2701
2800
  async pruneExpired(now) {
@@ -2704,14 +2803,82 @@ async function createTursoPairingSessionStore(path) {
2704
2803
  },
2705
2804
  async rotateSession(oldTokenHash, newTokenHash, session) {
2706
2805
  const now = Date.now();
2806
+ const secure = encodeSecureSession(session.secureSession);
2707
2807
  await db.transaction(async () => {
2708
2808
  await db.prepare("DELETE FROM pairing_sessions WHERE token_hash = ?").run(oldTokenHash);
2709
- await db.prepare(`INSERT INTO pairing_sessions (token_hash, client_name, expires_at, created_at, updated_at)
2710
- VALUES (?, ?, ?, ?, ?)`).run(newTokenHash, session.clientName ?? null, session.expiresAt, now, now);
2809
+ if (session.clientSessionId) {
2810
+ await db.prepare("DELETE FROM pairing_sessions WHERE client_session_id = ?").run(session.clientSessionId);
2811
+ if (session.clientName) await db.prepare("DELETE FROM pairing_sessions WHERE client_session_id IS NULL AND client_name = ?").run(session.clientName);
2812
+ }
2813
+ await db.prepare(`INSERT INTO pairing_sessions (
2814
+ token_hash,
2815
+ client_session_id,
2816
+ client_name,
2817
+ expires_at,
2818
+ key_epoch,
2819
+ mobile_to_server_key,
2820
+ server_to_mobile_key,
2821
+ last_mobile_counter,
2822
+ next_server_counter,
2823
+ created_at,
2824
+ updated_at
2825
+ )
2826
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newTokenHash, session.clientSessionId ?? null, session.clientName ?? null, session.expiresAt, secure?.keyEpoch ?? null, secure?.mobileToServerKey ?? null, secure?.serverToMobileKey ?? null, secure?.lastMobileCounter ?? null, secure?.nextServerCounter ?? null, now, now);
2711
2827
  })();
2712
2828
  return countActive(now);
2829
+ },
2830
+ async updateSecureSession(tokenHash, secureSession) {
2831
+ const secure = encodeSecureSession(secureSession);
2832
+ const now = Date.now();
2833
+ await db.prepare(`UPDATE pairing_sessions
2834
+ SET key_epoch = ?,
2835
+ mobile_to_server_key = ?,
2836
+ server_to_mobile_key = ?,
2837
+ last_mobile_counter = ?,
2838
+ next_server_counter = ?,
2839
+ updated_at = ?
2840
+ WHERE token_hash = ?`).run(secure.keyEpoch, secure.mobileToServerKey, secure.serverToMobileKey, secure.lastMobileCounter, secure.nextServerCounter, now, tokenHash);
2713
2841
  }
2714
2842
  };
2843
+ async function ensurePairingSessionColumns() {
2844
+ const rows = await db.prepare("PRAGMA table_info(pairing_sessions)").all();
2845
+ const columns = new Set(resultRows(rows).map((row) => String(row.name)));
2846
+ for (const [column, sql] of [
2847
+ ["client_session_id", "ALTER TABLE pairing_sessions ADD COLUMN client_session_id TEXT"],
2848
+ ["key_epoch", "ALTER TABLE pairing_sessions ADD COLUMN key_epoch INTEGER"],
2849
+ ["mobile_to_server_key", "ALTER TABLE pairing_sessions ADD COLUMN mobile_to_server_key TEXT"],
2850
+ ["server_to_mobile_key", "ALTER TABLE pairing_sessions ADD COLUMN server_to_mobile_key TEXT"],
2851
+ ["last_mobile_counter", "ALTER TABLE pairing_sessions ADD COLUMN last_mobile_counter INTEGER"],
2852
+ ["next_server_counter", "ALTER TABLE pairing_sessions ADD COLUMN next_server_counter INTEGER"]
2853
+ ]) if (!columns.has(column)) await db.exec(sql);
2854
+ const pendingRows = await db.prepare("PRAGMA table_info(pending_pairings)").all();
2855
+ if (!new Set(resultRows(pendingRows).map((row) => String(row.name))).has("client_session_id")) await db.exec("ALTER TABLE pending_pairings ADD COLUMN client_session_id TEXT");
2856
+ }
2857
+ }
2858
+ function encodeSecureSession(session) {
2859
+ if (!session) return;
2860
+ return {
2861
+ keyEpoch: session.keyEpoch,
2862
+ lastMobileCounter: session.lastMobileCounter,
2863
+ mobileToServerKey: fromByteArray(session.mobileToServerKey),
2864
+ nextServerCounter: session.nextServerCounter,
2865
+ serverToMobileKey: fromByteArray(session.serverToMobileKey)
2866
+ };
2867
+ }
2868
+ function decodeSecureSession(row) {
2869
+ if (typeof row.mobileToServerKey !== "string" || typeof row.serverToMobileKey !== "string" || row.keyEpoch === null || row.lastMobileCounter === null || row.nextServerCounter === null) return;
2870
+ return {
2871
+ keyEpoch: Number(row.keyEpoch),
2872
+ lastMobileCounter: Number(row.lastMobileCounter),
2873
+ mobileToServerKey: toByteArray(row.mobileToServerKey),
2874
+ nextServerCounter: Number(row.nextServerCounter),
2875
+ serverToMobileKey: toByteArray(row.serverToMobileKey)
2876
+ };
2877
+ }
2878
+ function resultRows(result) {
2879
+ if (Array.isArray(result)) return result;
2880
+ if (result && typeof result === "object" && Array.isArray(result.rows)) return result.rows;
2881
+ return [];
2715
2882
  }
2716
2883
  //#endregion
2717
2884
  //#region src/index.ts
@@ -2846,7 +3013,7 @@ async function writeBackgroundPid() {
2846
3013
  await writeFile(path, `${process.pid}\n`, { mode: 384 });
2847
3014
  }
2848
3015
  function formatApprovalCommand(approvalCode, activePort) {
2849
- return activePort === 8787 ? `npx codex-relay approve ${approvalCode}` : `PORT=${activePort} npx codex-relay approve ${approvalCode}`;
3016
+ return activePort === 8787 ? `npx codex-relay@latest approve ${approvalCode}` : `PORT=${activePort} npx codex-relay@latest approve ${approvalCode}`;
2850
3017
  }
2851
3018
  function getLocalNetworkConnectUrl(port) {
2852
3019
  for (const addresses of Object.values(networkInterfaces())) for (const address of addresses ?? []) if (address.family === "IPv4" && !address.internal) return `http://${address.address}:${port}`;
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "codex-relay",
3
- "version": "1.0.0",
3
+ "version": "1.0.1-beta.1",
4
4
  "description": "Local Codex Relay CLI bridge for the Codex Relay mobile app.",
5
5
  "repository": {
6
6
  "type": "git",
7
- "url": "git+https://github.com/gronxb/hot-codex.git",
7
+ "url": "git+https://github.com/gronxb/codex-relay.git",
8
8
  "directory": "apps/server"
9
9
  },
10
10
  "bin": {