@tumnel/codex 0.1.2 → 0.1.4

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
@@ -10,13 +10,13 @@ with the same experience as the OpenCode connector: sessions, streaming turns, t
10
10
  The standard setup command prepares the host identity and starts the connector:
11
11
 
12
12
  ```sh
13
- npx @tumnel/codex@0.1.2 install
13
+ npx @tumnel/codex@0.1.4 install
14
14
  ```
15
15
 
16
16
  `connect` remains available as an explicit alias:
17
17
 
18
18
  ```sh
19
- npx @tumnel/codex@0.1.2 connect
19
+ npx @tumnel/codex@0.1.4 connect
20
20
  ```
21
21
 
22
22
  The connector starts the Codex agent bridge and keeps it connected to the Tumnel relay until
@@ -58,8 +58,10 @@ Legacy Codex identities are migrated automatically when no shared or OpenCode id
58
58
  - A turn (`session.prompt`) runs `thread.runStreamed(input, { signal })`; every thread event is
59
59
  relayed as a `session.status` / `message.updated` / `message.part.updated` / `session.idle`
60
60
  bridge event, and the observed messages are served back through `session.messages`.
61
- - Threads are discovered from `~/.codex/sessions/**/rollout-*.jsonl`, so sessions started in the
62
- Codex CLI or app appear in the web UI and can be resumed.
61
+ - Projects and threads are discovered from the Codex desktop catalog (`~/.codex/.codex-global-state.json`
62
+ and `~/.codex/session_index.jsonl`) plus the persisted rollout files under
63
+ `~/.codex/sessions/**/rollout-*.jsonl`. This keeps project names/order and session titles aligned
64
+ with the local Codex app while still allowing older rollouts to be reconstructed and resumed.
63
65
 
64
66
  ## Known limitations
65
67
 
package/dist/cli.js CHANGED
@@ -10,7 +10,7 @@ import { z as z2 } from "zod";
10
10
  import { createHash, randomBytes } from "crypto";
11
11
  import { createReadStream } from "fs";
12
12
  import { homedir } from "os";
13
- import { join } from "path";
13
+ import { dirname, join } from "path";
14
14
  import { readFile, readdir, stat } from "fs/promises";
15
15
  import { z } from "zod";
16
16
  var sessionMetaSchema = z.object({
@@ -24,8 +24,10 @@ var sessionMetaSchema = z.object({
24
24
  }).passthrough()
25
25
  });
26
26
  function defaultSessionsDir(environment = process.env) {
27
- const root = environment.CODEX_HOME ?? join(homedir(), ".codex");
28
- return join(root, "sessions");
27
+ return join(defaultCodexHome(environment), "sessions");
28
+ }
29
+ function defaultCodexHome(environment = process.env) {
30
+ return environment.CODEX_HOME ?? join(homedir(), ".codex");
29
31
  }
30
32
  function projectId(directory) {
31
33
  const hash = createHash("sha256").update(directory.toLowerCase()).digest("base64url").slice(0, 12);
@@ -80,6 +82,87 @@ async function readRolloutTitle(path, fallback) {
80
82
  }
81
83
  return title;
82
84
  }
85
+ async function readSessionIndex(path) {
86
+ const result = /* @__PURE__ */ new Map();
87
+ let source;
88
+ try {
89
+ source = await readFile(path, "utf8");
90
+ } catch {
91
+ return result;
92
+ }
93
+ for (const line of source.split(/\r?\n/)) {
94
+ if (!line.trim()) continue;
95
+ try {
96
+ const value = JSON.parse(line);
97
+ const id = stringValue(value.id);
98
+ if (!id) continue;
99
+ result.set(id, {
100
+ thread_name: stringValue(value.thread_name) ?? void 0,
101
+ updated_at: stringValue(value.updated_at) ?? void 0
102
+ });
103
+ } catch {
104
+ }
105
+ }
106
+ return result;
107
+ }
108
+ async function readGlobalProjects(path) {
109
+ let source;
110
+ try {
111
+ source = await readFile(path, "utf8");
112
+ } catch {
113
+ return [];
114
+ }
115
+ try {
116
+ const root = asRecord(JSON.parse(source));
117
+ const projects = asRecord(root?.["local-projects"]);
118
+ const order = Array.isArray(root?.["project-order"]) ? root?.["project-order"].filter((value) => typeof value === "string") : [];
119
+ const orderById = new Map(order.map((id, index) => [id, index]));
120
+ const result = [];
121
+ for (const [id, value] of Object.entries(projects ?? {})) {
122
+ const project = asRecord(value);
123
+ const name = stringValue(project?.name) ?? void 0;
124
+ const roots = Array.isArray(project?.rootPaths) ? project.rootPaths.filter((path2) => typeof path2 === "string" && path2.length > 0) : [];
125
+ if (!roots.length) continue;
126
+ const created = timestampValue(project?.createdAt, 0);
127
+ const updated = timestampValue(project?.updatedAt, created);
128
+ for (const directory of roots) {
129
+ result.push({
130
+ id,
131
+ name,
132
+ directory,
133
+ created,
134
+ updated,
135
+ order: orderById.get(id) ?? Number.MAX_SAFE_INTEGER
136
+ });
137
+ }
138
+ }
139
+ return result.sort((left, right) => left.order - right.order || left.created - right.created);
140
+ } catch {
141
+ return [];
142
+ }
143
+ }
144
+ async function readGlobalSessionAssignments(path) {
145
+ let source;
146
+ try {
147
+ source = await readFile(path, "utf8");
148
+ } catch {
149
+ return /* @__PURE__ */ new Map();
150
+ }
151
+ try {
152
+ const root = asRecord(JSON.parse(source));
153
+ const assignments = asRecord(root?.["thread-project-assignments"]);
154
+ const result = /* @__PURE__ */ new Map();
155
+ for (const [threadId, value] of Object.entries(assignments ?? {})) {
156
+ const assignment = asRecord(value);
157
+ const projectId2 = stringValue(assignment?.projectId);
158
+ const projectKind = stringValue(assignment?.projectKind);
159
+ if (projectId2 && (!projectKind || projectKind === "local")) result.set(threadId, projectId2);
160
+ }
161
+ return result;
162
+ } catch {
163
+ return /* @__PURE__ */ new Map();
164
+ }
165
+ }
83
166
  async function walkRollouts(dir) {
84
167
  let entries;
85
168
  try {
@@ -289,8 +372,9 @@ async function readRolloutHistory(path, sessionId) {
289
372
  }
290
373
  return { messages, title, updated };
291
374
  }
292
- async function discoverSessions(sessionsDir) {
375
+ async function discoverSessions(sessionsDir, sessionIndexPath = join(dirname(sessionsDir), "session_index.jsonl"), projectRootsById = /* @__PURE__ */ new Map(), projectAssignments = /* @__PURE__ */ new Map()) {
293
376
  const rollouts = await walkRollouts(sessionsDir);
377
+ const index = await readSessionIndex(sessionIndexPath);
294
378
  const sessions = [];
295
379
  for (const path of rollouts) {
296
380
  const line = await readFirstLine(path);
@@ -305,7 +389,11 @@ async function discoverSessions(sessionsDir) {
305
389
  if (!result.success) continue;
306
390
  const meta = result.data.payload;
307
391
  const id = meta.session_id ?? meta.id;
308
- if (!id || !meta.cwd) continue;
392
+ if (!id) continue;
393
+ const workingDirectory = meta.cwd;
394
+ const assignedProjectId = projectAssignments.get(id);
395
+ const directory = (assignedProjectId ? projectRootsById.get(assignedProjectId) : void 0) ?? workingDirectory;
396
+ if (!directory) continue;
309
397
  const created = meta.timestamp ? Date.parse(meta.timestamp) : NaN;
310
398
  let mtime = created;
311
399
  try {
@@ -314,13 +402,16 @@ async function discoverSessions(sessionsDir) {
314
402
  } catch {
315
403
  mtime = Number.isFinite(created) ? created : 0;
316
404
  }
405
+ const indexed = index.get(id);
406
+ const indexedUpdated = indexed?.updated_at ? Date.parse(indexed.updated_at) : NaN;
317
407
  sessions.push({
318
408
  id,
319
- directory: meta.cwd,
409
+ directory,
410
+ ...workingDirectory && workingDirectory !== directory ? { workingDirectory } : {},
320
411
  version: meta.cli_version ?? "unknown",
321
412
  created: Number.isFinite(created) ? created : mtime,
322
- updated: mtime,
323
- title: await readRolloutTitle(path, "(untitled session)"),
413
+ updated: Number.isFinite(indexedUpdated) ? Math.max(mtime, indexedUpdated) : mtime,
414
+ title: await readRolloutTitle(path, indexed?.thread_name ?? "(untitled session)"),
324
415
  rolloutPath: path
325
416
  });
326
417
  }
@@ -336,12 +427,19 @@ var CodexSessionStore = class {
336
427
  at: 0,
337
428
  byDirectory: /* @__PURE__ */ new Map()
338
429
  };
430
+ projectCache = {
431
+ at: 0,
432
+ projects: [],
433
+ rootsById: /* @__PURE__ */ new Map(),
434
+ assignments: /* @__PURE__ */ new Map()
435
+ };
339
436
  create(input) {
340
437
  const now = Date.now();
341
438
  const record = {
342
439
  id: input.id,
343
440
  projectID: projectId(input.directory),
344
441
  directory: input.directory,
442
+ workingDirectory: input.workingDirectory,
345
443
  title: input.title ?? "(untitled session)",
346
444
  version: input.version,
347
445
  created: now,
@@ -377,7 +475,13 @@ var CodexSessionStore = class {
377
475
  async refreshDiscovery() {
378
476
  const now = Date.now();
379
477
  if (this.discoveryCache.at !== 0 && now - this.discoveryCache.at <= 3e4) return;
380
- const discovered = await discoverSessions(this.sessionsDir);
478
+ await this.refreshProjectCatalog(now);
479
+ const discovered = await discoverSessions(
480
+ this.sessionsDir,
481
+ join(dirname(this.sessionsDir), "session_index.jsonl"),
482
+ this.projectCache.rootsById,
483
+ this.projectCache.assignments
484
+ );
381
485
  const byDirectory = /* @__PURE__ */ new Map();
382
486
  for (const session of discovered) {
383
487
  const list = byDirectory.get(session.directory) ?? [];
@@ -391,6 +495,24 @@ var CodexSessionStore = class {
391
495
  await this.refreshDiscovery();
392
496
  return directory ? [...this.discoveryCache.byDirectory.entries()].filter(([dir]) => samePath(dir, directory)).flatMap(([, sessions]) => sessions) : [...this.discoveryCache.byDirectory.values()].flat();
393
497
  }
498
+ async discoverProjects(directory) {
499
+ const now = Date.now();
500
+ await this.refreshProjectCatalog(now);
501
+ return directory ? this.projectCache.projects.filter((project) => samePath(project.directory, directory)) : [...this.projectCache.projects];
502
+ }
503
+ async refreshProjectCatalog(now = Date.now()) {
504
+ if (this.projectCache.at !== 0 && now - this.projectCache.at <= 3e4) return;
505
+ const globalStatePath = join(dirname(this.sessionsDir), ".codex-global-state.json");
506
+ const projects = await readGlobalProjects(globalStatePath);
507
+ const rootsById = /* @__PURE__ */ new Map();
508
+ for (const project of projects) {
509
+ if (!rootsById.has(project.id)) rootsById.set(project.id, project.directory);
510
+ }
511
+ this.projectCache.projects = projects;
512
+ this.projectCache.rootsById = rootsById;
513
+ this.projectCache.assignments = await readGlobalSessionAssignments(globalStatePath);
514
+ this.projectCache.at = now;
515
+ }
394
516
  async discover(directory) {
395
517
  const matching = await this.discoverAll(directory);
396
518
  const liveThreadIds = new Set(
@@ -760,13 +882,14 @@ function ensureThread(codex, record, config, model) {
760
882
  const selectedModel = model ?? record.model ?? config.model;
761
883
  if (record.thread && record.model === selectedModel) return record.thread;
762
884
  record.model = selectedModel;
885
+ const workingDirectory = record.workingDirectory ?? record.directory;
763
886
  if (record.codexThreadId) {
764
887
  record.thread = codex.resumeThread(
765
888
  record.codexThreadId,
766
- threadOptions(config, record.directory, selectedModel)
889
+ threadOptions(config, workingDirectory, selectedModel)
767
890
  );
768
891
  } else {
769
- record.thread = codex.startThread(threadOptions(config, record.directory, selectedModel));
892
+ record.thread = codex.startThread(threadOptions(config, workingDirectory, selectedModel));
770
893
  }
771
894
  return record.thread;
772
895
  }
@@ -876,25 +999,42 @@ function createCodexAdapter(options) {
876
999
  case "project.list": {
877
1000
  const params = projectListSchema.parse(rawParams);
878
1001
  const directory = params.directory || void 0;
1002
+ const catalogProjects = await sessions.discoverProjects(directory);
879
1003
  const records = sessions.list(directory);
880
1004
  const discovered = await sessions.discoverAll(directory);
881
1005
  const directories = /* @__PURE__ */ new Map();
1006
+ for (const project of catalogProjects) {
1007
+ directories.set(project.directory, {
1008
+ created: project.created,
1009
+ name: project.name,
1010
+ order: project.order
1011
+ });
1012
+ }
882
1013
  for (const record of records) {
883
1014
  const existing = directories.get(record.directory);
884
- if (existing === void 0 || record.created < existing) {
885
- directories.set(record.directory, record.created);
1015
+ if (existing === void 0 || record.created < existing.created) {
1016
+ directories.set(record.directory, {
1017
+ created: record.created,
1018
+ name: existing?.name,
1019
+ order: existing?.order ?? Number.MAX_SAFE_INTEGER
1020
+ });
886
1021
  }
887
1022
  }
888
1023
  for (const session of discovered) {
889
1024
  const existing = directories.get(session.directory);
890
- if (existing === void 0 || session.created < existing) {
891
- directories.set(session.directory, session.created);
1025
+ if (existing === void 0 || session.created < existing.created) {
1026
+ directories.set(session.directory, {
1027
+ created: session.created,
1028
+ name: existing?.name,
1029
+ order: existing?.order ?? Number.MAX_SAFE_INTEGER
1030
+ });
892
1031
  }
893
1032
  }
894
- return [...directories.entries()].map(([worktree, created]) => ({
1033
+ return [...directories.entries()].sort(([, left], [, right]) => left.order - right.order || left.created - right.created).map(([worktree, project]) => ({
895
1034
  id: projectId(worktree),
1035
+ ...project.name ? { name: project.name } : {},
896
1036
  worktree,
897
- time: { created }
1037
+ time: { created: project.created }
898
1038
  }));
899
1039
  }
900
1040
  case "session.list": {
@@ -965,6 +1105,7 @@ function createCodexAdapter(options) {
965
1105
  record = sessions.create({
966
1106
  id: params.sessionId,
967
1107
  directory: session.directory,
1108
+ workingDirectory: session.workingDirectory,
968
1109
  title: history?.title ?? session.title,
969
1110
  version: session.version,
970
1111
  codexThreadId: session.id,
@@ -1247,7 +1388,7 @@ import {
1247
1388
  verify
1248
1389
  } from "crypto";
1249
1390
  import { homedir as homedir2 } from "os";
1250
- import { dirname, join as join2 } from "path";
1391
+ import { dirname as dirname2, join as join2 } from "path";
1251
1392
  import { mkdir, open, readFile as readFile2 } from "fs/promises";
1252
1393
  import { z as z4 } from "zod";
1253
1394
  var DEVICE_PROOF_PREFIX = "tumnel-device-proof-v1";
@@ -1307,7 +1448,7 @@ async function readIdentity(path) {
1307
1448
  return validateKeyPair(identitySchema.parse(JSON.parse(contents)));
1308
1449
  }
1309
1450
  async function persistNewIdentity(path, identity) {
1310
- await mkdir(dirname(path), { recursive: true, mode: 448 });
1451
+ await mkdir(dirname2(path), { recursive: true, mode: 448 });
1311
1452
  const handle = await open(path, "wx", 384);
1312
1453
  try {
1313
1454
  await handle.writeFile(`${JSON.stringify(identity, null, 2)}
@@ -1504,7 +1645,7 @@ var TumnelBridgeClient = class {
1504
1645
  version: BRIDGE_PROTOCOL_VERSION,
1505
1646
  type: "device.hello",
1506
1647
  clientId: this.identity.deviceId,
1507
- agentVersion: "0.1.1",
1648
+ agentVersion: "0.1.3",
1508
1649
  connectorId: "codex"
1509
1650
  };
1510
1651
  socket.send(encodeBridgeMessage(hello));
@@ -1812,6 +1953,13 @@ async function createCodexConnector(options = {}) {
1812
1953
  }
1813
1954
 
1814
1955
  // src/cli.ts
1956
+ var waitForTumnelConnector = async (bridge, timeoutMs = 15e3) => {
1957
+ const deadline = Date.now() + timeoutMs;
1958
+ while (bridge.state !== "connected" && Date.now() < deadline) {
1959
+ await new Promise((resolve) => setTimeout(resolve, 100));
1960
+ }
1961
+ return bridge.state === "connected";
1962
+ };
1815
1963
  var printHelp = () => {
1816
1964
  console.log(`Tumnel connector for Codex
1817
1965
 
@@ -1892,14 +2040,23 @@ var run = async () => {
1892
2040
  flag("--relay-url");
1893
2041
  flag("--sandbox");
1894
2042
  flag("--approval-policy");
1895
- if (command === "install") {
1896
- console.log("Tumnel Codex connector is installed and ready.");
1897
- }
2043
+ const packageJson = JSON.parse(
2044
+ await import("fs/promises").then(
2045
+ ({ readFile: readFile3 }) => readFile3(new URL("../package.json", import.meta.url), "utf8")
2046
+ )
2047
+ );
2048
+ if (command === "install") console.log(`Installed @tumnel/codex@${packageJson.version}`);
1898
2049
  const connector = await createCodexConnector({ config });
1899
2050
  const port = connector.pairingServer?.port;
1900
- console.log("Tumnel Codex connector started");
2051
+ console.log("Starting Tumnel Codex connector...");
1901
2052
  console.log(` Device ID: ${connector.bridge.deviceId}`);
1902
2053
  if (port) console.log(` Pairing port: http://127.0.0.1:${port}`);
2054
+ console.log("Waiting for the Tumnel connector...");
2055
+ if (await waitForTumnelConnector(connector.bridge)) {
2056
+ console.log("Tumnel connected.");
2057
+ } else {
2058
+ console.log("Tumnel connector started; waiting for the relay in background.");
2059
+ }
1903
2060
  console.log("Press Ctrl+C to stop.\n");
1904
2061
  let stopping = false;
1905
2062
  const stop = async () => {