codex-relay 1.0.0 → 1.0.1-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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,9 +708,11 @@ 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 output = await git(selectedWorkspacePath.path, ["checkout", parsed.data.branch]);
700
714
  const response = WorkspaceGitActionResponseSchema.parse({
701
- branch: await currentGitBranch(workspacePath),
715
+ branch: await currentGitBranch(selectedWorkspacePath.path),
702
716
  message: `Checked out ${parsed.data.branch}.`,
703
717
  output
704
718
  });
@@ -711,23 +725,25 @@ function createApp(options = {}) {
711
725
  const parsed = await parseRequestJson(c, options.pairing, secureSessionsByTokenHash, CommitPushWorkspaceRequestSchema);
712
726
  if (!parsed.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, validationError(parsed.error), 400);
713
727
  try {
714
- await git(workspacePath, ["add", "--all"]);
715
- const commitOutput = await git(workspacePath, [
728
+ const selectedWorkspacePath = await validateThreadWorkspacePath(workspacePath, parsed.data.workspacePath);
729
+ if (!selectedWorkspacePath.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("invalid_workspace_path", selectedWorkspacePath.error), 400);
730
+ await git(selectedWorkspacePath.path, ["add", "--all"]);
731
+ const commitOutput = await git(selectedWorkspacePath.path, [
716
732
  "commit",
717
733
  "-m",
718
734
  parsed.data.message
719
735
  ]);
720
- const branch = await currentGitBranch(workspacePath);
721
- const pushOutput = await git(workspacePath, [
736
+ const branch = await currentGitBranch(selectedWorkspacePath.path);
737
+ const pushOutput = await git(selectedWorkspacePath.path, [
722
738
  "rev-parse",
723
739
  "--abbrev-ref",
724
740
  "@{upstream}"
725
- ]).catch(() => null) ? await git(workspacePath, ["push"]) : branch ? await git(workspacePath, [
741
+ ]).catch(() => null) ? await git(selectedWorkspacePath.path, ["push"]) : branch ? await git(selectedWorkspacePath.path, [
726
742
  "push",
727
743
  "-u",
728
744
  "origin",
729
745
  branch
730
- ]) : await git(workspacePath, ["push"]);
746
+ ]) : await git(selectedWorkspacePath.path, ["push"]);
731
747
  const response = WorkspaceGitActionResponseSchema.parse({
732
748
  branch,
733
749
  message: "Committed and pushed workspace changes.",
@@ -981,6 +997,10 @@ function normalizeApprovalCode(value) {
981
997
  const normalized = value.toUpperCase().replace(/[^A-Z0-9]/g, "").replaceAll("O", "0").replaceAll("I", "1");
982
998
  return normalized.length === 8 ? `${normalized.slice(0, 4)}-${normalized.slice(4)}` : normalized;
983
999
  }
1000
+ function normalizeClientSessionId(value) {
1001
+ const trimmed = value?.trim();
1002
+ return trimmed ? trimmed.slice(0, 120) : void 0;
1003
+ }
984
1004
  async function validateThreadWorkspacePath(rootPath, requestedPath) {
985
1005
  const resolved = resolve(requestedPath ?? rootPath);
986
1006
  try {
@@ -1513,7 +1533,8 @@ async function parseRequestJson(c, pairing, secureSessionsByTokenHash, schema) {
1513
1533
  const envelope = EncryptedPayloadSchema.safeParse(payload);
1514
1534
  if (!envelope.success) return schema.safeParse({ __invalidEncryptedPayload: true });
1515
1535
  try {
1516
- payload = JSON.parse(decryptFromMobile(secureSession, envelope.data));
1536
+ payload = JSON.parse(decryptFromMobile(secureSession.session, envelope.data));
1537
+ await secureSession.persist();
1517
1538
  } catch {
1518
1539
  payload = { __invalidEncryptedPayload: true };
1519
1540
  }
@@ -1529,15 +1550,30 @@ async function parsePlainJson(request, schema) {
1529
1550
  }
1530
1551
  return schema.safeParse(payload);
1531
1552
  }
1532
- function secureJson(c, pairing, secureSessionsByTokenHash, payload, status) {
1553
+ async function secureJson(c, pairing, secureSessionsByTokenHash, payload, status) {
1533
1554
  const secureSession = getSecureSessionForRequest(c, pairing, secureSessionsByTokenHash);
1534
1555
  if (!secureSession) return c.json(payload, status);
1535
- const encrypted = EncryptedPayloadSchema.parse(encryptForMobile(secureSession, JSON.stringify(payload)));
1556
+ const encrypted = EncryptedPayloadSchema.parse(encryptForMobile(secureSession.session, JSON.stringify(payload)));
1557
+ await secureSession.persist();
1536
1558
  return c.json(encrypted, status);
1537
1559
  }
1538
1560
  function getSecureSessionForRequest(c, pairing, secureSessionsByTokenHash) {
1539
1561
  const token = parseBearerToken(c.req.header("authorization"));
1540
- return token && pairing ? secureSessionsByTokenHash.get(pairing.hashClientToken(token)) : void 0;
1562
+ if (!token || !pairing) return;
1563
+ const tokenHash = pairing.hashClientToken(token);
1564
+ const session = secureSessionsByTokenHash.get(tokenHash);
1565
+ return session ? createSecureSessionHandle(pairing, tokenHash, session) : void 0;
1566
+ }
1567
+ function createSecureSessionHandle(pairing, tokenHash, session) {
1568
+ let pendingPersist = Promise.resolve();
1569
+ return {
1570
+ persist: () => {
1571
+ pendingPersist = pendingPersist.catch(() => void 0).then(() => pairing.sessions.updateSecureSession(tokenHash, session));
1572
+ return pendingPersist;
1573
+ },
1574
+ session,
1575
+ tokenHash
1576
+ };
1541
1577
  }
1542
1578
  function sortedThreads(threads) {
1543
1579
  return [...threads.values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
@@ -1664,7 +1700,8 @@ function updateThread(threads, messagesByThreadId, threadId, update) {
1664
1700
  }
1665
1701
  function sendSse(controller, encoder, secureSession, event) {
1666
1702
  const parsed = StreamThreadRunEventSchema.parse(event);
1667
- const data = secureSession ? EncryptedPayloadSchema.parse(encryptForMobile(secureSession, JSON.stringify(parsed))) : parsed;
1703
+ const data = secureSession ? EncryptedPayloadSchema.parse(encryptForMobile(secureSession.session, JSON.stringify(parsed))) : parsed;
1704
+ if (secureSession) secureSession.persist().catch(() => void 0);
1668
1705
  controller.enqueue(encoder.encode(`event: ${parsed.type}\n`));
1669
1706
  controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
1670
1707
  }
@@ -2417,7 +2454,7 @@ function normalizeFileChanges(value) {
2417
2454
  async function readWorkspaceChanges(workspacePath) {
2418
2455
  const repo = await openRepository(workspacePath);
2419
2456
  const [currentBranch, branches] = await Promise.all([currentGitBranch(workspacePath), listGitBranches(workspacePath)]);
2420
- const statusEntries = collectIterator(repo.statuses().iter());
2457
+ const statusEntries = collectIterator(repo.statuses().iter()).filter((entry) => !entry.status().ignored);
2421
2458
  const statusByPath = new Map(statusEntries.map((entry) => [entry.path(), entry]));
2422
2459
  const status = statusEntries.map((entry) => formatStatusLine(entry.path(), entry.status())).join("\n");
2423
2460
  const structuredDiff = createWorkspaceDiff(repo);
@@ -2465,6 +2502,11 @@ async function readWorkspaceChanges(workspacePath) {
2465
2502
  });
2466
2503
  }
2467
2504
  const files = [...filesByPath.values()].sort((left, right) => left.path.localeCompare(right.path));
2505
+ const fileStats = {
2506
+ additions: files.reduce((total, file) => total + file.additions, 0),
2507
+ deletions: files.reduce((total, file) => total + file.deletions, 0),
2508
+ filesChanged: files.length
2509
+ };
2468
2510
  return {
2469
2511
  branches,
2470
2512
  currentBranch,
@@ -2472,10 +2514,10 @@ async function readWorkspaceChanges(workspacePath) {
2472
2514
  files,
2473
2515
  hasChanges: files.length > 0 || Boolean(status.trim() || diff.trim()),
2474
2516
  status,
2475
- stats: {
2517
+ stats: files.length > 0 ? fileStats : {
2476
2518
  additions: Number(stats.insertions),
2477
2519
  deletions: Number(stats.deletions),
2478
- filesChanged: files.length || Number(stats.filesChanged)
2520
+ filesChanged: Number(stats.filesChanged)
2479
2521
  }
2480
2522
  };
2481
2523
  }
@@ -2596,14 +2638,21 @@ async function createTursoPairingSessionStore(path) {
2596
2638
  await db.exec(`
2597
2639
  CREATE TABLE IF NOT EXISTS pairing_sessions (
2598
2640
  token_hash TEXT PRIMARY KEY,
2641
+ client_session_id TEXT,
2599
2642
  client_name TEXT,
2600
2643
  expires_at INTEGER NOT NULL,
2644
+ key_epoch INTEGER,
2645
+ mobile_to_server_key TEXT,
2646
+ server_to_mobile_key TEXT,
2647
+ last_mobile_counter INTEGER,
2648
+ next_server_counter INTEGER,
2601
2649
  created_at INTEGER NOT NULL,
2602
2650
  updated_at INTEGER NOT NULL
2603
2651
  );
2604
2652
 
2605
2653
  CREATE TABLE IF NOT EXISTS pending_pairings (
2606
2654
  approval_code TEXT PRIMARY KEY,
2655
+ client_session_id TEXT,
2607
2656
  client_name TEXT,
2608
2657
  client_ephemeral_public_key TEXT NOT NULL,
2609
2658
  client_nonce TEXT NOT NULL,
@@ -2614,8 +2663,9 @@ async function createTursoPairingSessionStore(path) {
2614
2663
  updated_at INTEGER NOT NULL
2615
2664
  );
2616
2665
  `);
2666
+ await ensurePairingSessionColumns();
2617
2667
  async function countActive(now) {
2618
- const row = await db.prepare("SELECT COUNT(*) AS count FROM pairing_sessions WHERE expires_at > ?").get(now);
2668
+ 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
2669
  return Number(row?.count ?? 0);
2620
2670
  }
2621
2671
  async function deleteSession(tokenHash) {
@@ -2626,6 +2676,7 @@ async function createTursoPairingSessionStore(path) {
2626
2676
  }
2627
2677
  async function getPendingPairing(approvalCode, now) {
2628
2678
  const row = await db.prepare(`SELECT approval_code AS approvalCode,
2679
+ client_session_id AS clientSessionId,
2629
2680
  client_name AS clientName,
2630
2681
  client_ephemeral_public_key AS clientEphemeralPublicKey,
2631
2682
  client_nonce AS clientNonce,
@@ -2644,6 +2695,7 @@ async function createTursoPairingSessionStore(path) {
2644
2695
  approvalCode: String(row.approvalCode),
2645
2696
  approved: Number(row.approved) === 1,
2646
2697
  clientEphemeralPublicKey: String(row.clientEphemeralPublicKey),
2698
+ clientSessionId: typeof row.clientSessionId === "string" ? row.clientSessionId : void 0,
2647
2699
  clientName: typeof row.clientName === "string" ? row.clientName : void 0,
2648
2700
  clientNonce: String(row.clientNonce),
2649
2701
  expiresAt,
@@ -2665,6 +2717,7 @@ async function createTursoPairingSessionStore(path) {
2665
2717
  const now = Date.now();
2666
2718
  await db.prepare(`INSERT INTO pending_pairings (
2667
2719
  approval_code,
2720
+ client_session_id,
2668
2721
  client_name,
2669
2722
  client_ephemeral_public_key,
2670
2723
  client_nonce,
@@ -2674,19 +2727,45 @@ async function createTursoPairingSessionStore(path) {
2674
2727
  created_at,
2675
2728
  updated_at
2676
2729
  )
2677
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(pairing.approvalCode, pairing.clientName ?? null, pairing.clientEphemeralPublicKey, pairing.clientNonce, pairing.serverUrl, pairing.approved ? 1 : 0, pairing.expiresAt, now, now);
2730
+ 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
2731
  },
2679
2732
  async createSession(tokenHash, session) {
2680
2733
  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);
2734
+ const secure = encodeSecureSession(session.secureSession);
2735
+ if (session.clientSessionId) {
2736
+ await db.prepare("DELETE FROM pairing_sessions WHERE client_session_id = ?").run(session.clientSessionId);
2737
+ if (session.clientName) await db.prepare("DELETE FROM pairing_sessions WHERE client_session_id IS NULL AND client_name = ?").run(session.clientName);
2738
+ }
2739
+ await db.prepare(`INSERT INTO pairing_sessions (
2740
+ token_hash,
2741
+ client_session_id,
2742
+ client_name,
2743
+ expires_at,
2744
+ key_epoch,
2745
+ mobile_to_server_key,
2746
+ server_to_mobile_key,
2747
+ last_mobile_counter,
2748
+ next_server_counter,
2749
+ created_at,
2750
+ updated_at
2751
+ )
2752
+ 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
2753
  return countActive(now);
2684
2754
  },
2685
2755
  deleteSession,
2686
2756
  deletePendingPairing,
2687
2757
  getPendingPairing,
2688
2758
  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);
2759
+ const row = await db.prepare(`SELECT client_name AS clientName,
2760
+ client_session_id AS clientSessionId,
2761
+ expires_at AS expiresAt,
2762
+ key_epoch AS keyEpoch,
2763
+ mobile_to_server_key AS mobileToServerKey,
2764
+ server_to_mobile_key AS serverToMobileKey,
2765
+ last_mobile_counter AS lastMobileCounter,
2766
+ next_server_counter AS nextServerCounter
2767
+ FROM pairing_sessions
2768
+ WHERE token_hash = ?`).get(tokenHash);
2690
2769
  if (!row) return;
2691
2770
  const expiresAt = Number(row.expiresAt);
2692
2771
  if (now > expiresAt) {
@@ -2694,8 +2773,10 @@ async function createTursoPairingSessionStore(path) {
2694
2773
  return;
2695
2774
  }
2696
2775
  return {
2776
+ clientSessionId: typeof row.clientSessionId === "string" ? row.clientSessionId : void 0,
2697
2777
  clientName: typeof row.clientName === "string" ? row.clientName : void 0,
2698
- expiresAt
2778
+ expiresAt,
2779
+ secureSession: decodeSecureSession(row)
2699
2780
  };
2700
2781
  },
2701
2782
  async pruneExpired(now) {
@@ -2704,14 +2785,82 @@ async function createTursoPairingSessionStore(path) {
2704
2785
  },
2705
2786
  async rotateSession(oldTokenHash, newTokenHash, session) {
2706
2787
  const now = Date.now();
2788
+ const secure = encodeSecureSession(session.secureSession);
2707
2789
  await db.transaction(async () => {
2708
2790
  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);
2791
+ if (session.clientSessionId) {
2792
+ await db.prepare("DELETE FROM pairing_sessions WHERE client_session_id = ?").run(session.clientSessionId);
2793
+ if (session.clientName) await db.prepare("DELETE FROM pairing_sessions WHERE client_session_id IS NULL AND client_name = ?").run(session.clientName);
2794
+ }
2795
+ await db.prepare(`INSERT INTO pairing_sessions (
2796
+ token_hash,
2797
+ client_session_id,
2798
+ client_name,
2799
+ expires_at,
2800
+ key_epoch,
2801
+ mobile_to_server_key,
2802
+ server_to_mobile_key,
2803
+ last_mobile_counter,
2804
+ next_server_counter,
2805
+ created_at,
2806
+ updated_at
2807
+ )
2808
+ 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
2809
  })();
2712
2810
  return countActive(now);
2811
+ },
2812
+ async updateSecureSession(tokenHash, secureSession) {
2813
+ const secure = encodeSecureSession(secureSession);
2814
+ const now = Date.now();
2815
+ await db.prepare(`UPDATE pairing_sessions
2816
+ SET key_epoch = ?,
2817
+ mobile_to_server_key = ?,
2818
+ server_to_mobile_key = ?,
2819
+ last_mobile_counter = ?,
2820
+ next_server_counter = ?,
2821
+ updated_at = ?
2822
+ WHERE token_hash = ?`).run(secure.keyEpoch, secure.mobileToServerKey, secure.serverToMobileKey, secure.lastMobileCounter, secure.nextServerCounter, now, tokenHash);
2713
2823
  }
2714
2824
  };
2825
+ async function ensurePairingSessionColumns() {
2826
+ const rows = await db.prepare("PRAGMA table_info(pairing_sessions)").all();
2827
+ const columns = new Set(resultRows(rows).map((row) => String(row.name)));
2828
+ for (const [column, sql] of [
2829
+ ["client_session_id", "ALTER TABLE pairing_sessions ADD COLUMN client_session_id TEXT"],
2830
+ ["key_epoch", "ALTER TABLE pairing_sessions ADD COLUMN key_epoch INTEGER"],
2831
+ ["mobile_to_server_key", "ALTER TABLE pairing_sessions ADD COLUMN mobile_to_server_key TEXT"],
2832
+ ["server_to_mobile_key", "ALTER TABLE pairing_sessions ADD COLUMN server_to_mobile_key TEXT"],
2833
+ ["last_mobile_counter", "ALTER TABLE pairing_sessions ADD COLUMN last_mobile_counter INTEGER"],
2834
+ ["next_server_counter", "ALTER TABLE pairing_sessions ADD COLUMN next_server_counter INTEGER"]
2835
+ ]) if (!columns.has(column)) await db.exec(sql);
2836
+ const pendingRows = await db.prepare("PRAGMA table_info(pending_pairings)").all();
2837
+ 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");
2838
+ }
2839
+ }
2840
+ function encodeSecureSession(session) {
2841
+ if (!session) return;
2842
+ return {
2843
+ keyEpoch: session.keyEpoch,
2844
+ lastMobileCounter: session.lastMobileCounter,
2845
+ mobileToServerKey: fromByteArray(session.mobileToServerKey),
2846
+ nextServerCounter: session.nextServerCounter,
2847
+ serverToMobileKey: fromByteArray(session.serverToMobileKey)
2848
+ };
2849
+ }
2850
+ function decodeSecureSession(row) {
2851
+ if (typeof row.mobileToServerKey !== "string" || typeof row.serverToMobileKey !== "string" || row.keyEpoch === null || row.lastMobileCounter === null || row.nextServerCounter === null) return;
2852
+ return {
2853
+ keyEpoch: Number(row.keyEpoch),
2854
+ lastMobileCounter: Number(row.lastMobileCounter),
2855
+ mobileToServerKey: toByteArray(row.mobileToServerKey),
2856
+ nextServerCounter: Number(row.nextServerCounter),
2857
+ serverToMobileKey: toByteArray(row.serverToMobileKey)
2858
+ };
2859
+ }
2860
+ function resultRows(result) {
2861
+ if (Array.isArray(result)) return result;
2862
+ if (result && typeof result === "object" && Array.isArray(result.rows)) return result.rows;
2863
+ return [];
2715
2864
  }
2716
2865
  //#endregion
2717
2866
  //#region src/index.ts
@@ -2846,7 +2995,7 @@ async function writeBackgroundPid() {
2846
2995
  await writeFile(path, `${process.pid}\n`, { mode: 384 });
2847
2996
  }
2848
2997
  function formatApprovalCommand(approvalCode, activePort) {
2849
- return activePort === 8787 ? `npx codex-relay approve ${approvalCode}` : `PORT=${activePort} npx codex-relay approve ${approvalCode}`;
2998
+ return activePort === 8787 ? `npx codex-relay@latest approve ${approvalCode}` : `PORT=${activePort} npx codex-relay@latest approve ${approvalCode}`;
2850
2999
  }
2851
3000
  function getLocalNetworkConnectUrl(port) {
2852
3001
  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.0",
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": {