@tumnel/codex 0.1.3 → 0.1.5

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.3 install
13
+ npx @tumnel/codex@0.1.5 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.3 connect
19
+ npx @tumnel/codex@0.1.5 connect
20
20
  ```
21
21
 
22
22
  The connector starts the Codex agent bridge and keeps it connected to the Tumnel relay until
@@ -26,6 +26,9 @@ interrupted:
26
26
  npx @tumnel/codex --model gpt-5-mini --sandbox workspace-write --approval-policy on-request
27
27
  ```
28
28
 
29
+ If `--model` (or `CODEX_MODEL`) is omitted, the web model picker still exposes a
30
+ `Codex default` option and the SDK resolves the model from the local Codex configuration.
31
+
29
32
  It binds a pairing-only endpoint to `127.0.0.1:43817` (use `--port` to override). It accepts
30
33
  approved OpenBridge origins, returns short-lived signed proofs, and never exposes Codex API keys
31
34
  or model credentials over localhost.
@@ -58,8 +61,10 @@ Legacy Codex identities are migrated automatically when no shared or OpenCode id
58
61
  - A turn (`session.prompt`) runs `thread.runStreamed(input, { signal })`; every thread event is
59
62
  relayed as a `session.status` / `message.updated` / `message.part.updated` / `session.idle`
60
63
  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.
64
+ - Projects and threads are discovered from the Codex desktop catalog (`~/.codex/.codex-global-state.json`
65
+ and `~/.codex/session_index.jsonl`) plus the persisted rollout files under
66
+ `~/.codex/sessions/**/rollout-*.jsonl`. This keeps project names/order and session titles aligned
67
+ with the local Codex app while still allowing older rollouts to be reconstructed and resumed.
63
68
 
64
69
  ## Known limitations
65
70
 
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(
@@ -538,6 +660,11 @@ var questionRejectSchema = z2.object({
538
660
  requestId: z2.string().min(1).max(256),
539
661
  directory: directorySchema
540
662
  }).strict();
663
+ var CODEX_DEFAULT_MODEL_ID = "__codex_default__";
664
+ var CODEX_DEFAULT_MODEL_NAME = "Codex default";
665
+ function normalizeModelId(model) {
666
+ return model === CODEX_DEFAULT_MODEL_ID ? void 0 : model;
667
+ }
541
668
  var EventBus = class {
542
669
  queue = [];
543
670
  wake = null;
@@ -760,13 +887,14 @@ function ensureThread(codex, record, config, model) {
760
887
  const selectedModel = model ?? record.model ?? config.model;
761
888
  if (record.thread && record.model === selectedModel) return record.thread;
762
889
  record.model = selectedModel;
890
+ const workingDirectory = record.workingDirectory ?? record.directory;
763
891
  if (record.codexThreadId) {
764
892
  record.thread = codex.resumeThread(
765
893
  record.codexThreadId,
766
- threadOptions(config, record.directory, selectedModel)
894
+ threadOptions(config, workingDirectory, selectedModel)
767
895
  );
768
896
  } else {
769
- record.thread = codex.startThread(threadOptions(config, record.directory, selectedModel));
897
+ record.thread = codex.startThread(threadOptions(config, workingDirectory, selectedModel));
770
898
  }
771
899
  return record.thread;
772
900
  }
@@ -876,25 +1004,42 @@ function createCodexAdapter(options) {
876
1004
  case "project.list": {
877
1005
  const params = projectListSchema.parse(rawParams);
878
1006
  const directory = params.directory || void 0;
1007
+ const catalogProjects = await sessions.discoverProjects(directory);
879
1008
  const records = sessions.list(directory);
880
1009
  const discovered = await sessions.discoverAll(directory);
881
1010
  const directories = /* @__PURE__ */ new Map();
1011
+ for (const project of catalogProjects) {
1012
+ directories.set(project.directory, {
1013
+ created: project.created,
1014
+ name: project.name,
1015
+ order: project.order
1016
+ });
1017
+ }
882
1018
  for (const record of records) {
883
1019
  const existing = directories.get(record.directory);
884
- if (existing === void 0 || record.created < existing) {
885
- directories.set(record.directory, record.created);
1020
+ if (existing === void 0 || record.created < existing.created) {
1021
+ directories.set(record.directory, {
1022
+ created: record.created,
1023
+ name: existing?.name,
1024
+ order: existing?.order ?? Number.MAX_SAFE_INTEGER
1025
+ });
886
1026
  }
887
1027
  }
888
1028
  for (const session of discovered) {
889
1029
  const existing = directories.get(session.directory);
890
- if (existing === void 0 || session.created < existing) {
891
- directories.set(session.directory, session.created);
1030
+ if (existing === void 0 || session.created < existing.created) {
1031
+ directories.set(session.directory, {
1032
+ created: session.created,
1033
+ name: existing?.name,
1034
+ order: existing?.order ?? Number.MAX_SAFE_INTEGER
1035
+ });
892
1036
  }
893
1037
  }
894
- return [...directories.entries()].map(([worktree, created]) => ({
1038
+ return [...directories.entries()].sort(([, left], [, right]) => left.order - right.order || left.created - right.created).map(([worktree, project]) => ({
895
1039
  id: projectId(worktree),
1040
+ ...project.name ? { name: project.name } : {},
896
1041
  worktree,
897
- time: { created }
1042
+ time: { created: project.created }
898
1043
  }));
899
1044
  }
900
1045
  case "session.list": {
@@ -953,7 +1098,7 @@ function createCodexAdapter(options) {
953
1098
  }
954
1099
  case "session.prompt": {
955
1100
  const params = sessionPromptSchema.parse(rawParams);
956
- const selectedModel = params.model ? params.model.providerID === "codex" ? params.model.modelID : (() => {
1101
+ const selectedModel = params.model ? params.model.providerID === "codex" ? normalizeModelId(params.model.modelID) : (() => {
957
1102
  throw new Error(`Codex does not support provider ${params.model?.providerID}`);
958
1103
  })() : void 0;
959
1104
  let record = sessions.get(params.sessionId);
@@ -965,6 +1110,7 @@ function createCodexAdapter(options) {
965
1110
  record = sessions.create({
966
1111
  id: params.sessionId,
967
1112
  directory: session.directory,
1113
+ workingDirectory: session.workingDirectory,
968
1114
  title: history?.title ?? session.title,
969
1115
  version: session.version,
970
1116
  codexThreadId: session.id,
@@ -987,17 +1133,18 @@ function createCodexAdapter(options) {
987
1133
  }
988
1134
  case "provider.list": {
989
1135
  providerListSchema.parse(rawParams);
990
- if (!config.model) return toSafeJson({ default: {}, providers: [] });
1136
+ const modelID = config.model ?? CODEX_DEFAULT_MODEL_ID;
1137
+ const modelName = config.model ?? CODEX_DEFAULT_MODEL_NAME;
991
1138
  return toSafeJson({
992
- default: { codex: config.model },
1139
+ default: { codex: modelID },
993
1140
  providers: [
994
1141
  {
995
1142
  id: "codex",
996
1143
  name: "Codex",
997
1144
  models: [
998
1145
  {
999
- id: config.model,
1000
- name: config.model,
1146
+ id: modelID,
1147
+ name: modelName,
1001
1148
  attachment: false,
1002
1149
  reasoning: true,
1003
1150
  toolCall: true,
@@ -1247,7 +1394,7 @@ import {
1247
1394
  verify
1248
1395
  } from "crypto";
1249
1396
  import { homedir as homedir2 } from "os";
1250
- import { dirname, join as join2 } from "path";
1397
+ import { dirname as dirname2, join as join2 } from "path";
1251
1398
  import { mkdir, open, readFile as readFile2 } from "fs/promises";
1252
1399
  import { z as z4 } from "zod";
1253
1400
  var DEVICE_PROOF_PREFIX = "tumnel-device-proof-v1";
@@ -1307,7 +1454,7 @@ async function readIdentity(path) {
1307
1454
  return validateKeyPair(identitySchema.parse(JSON.parse(contents)));
1308
1455
  }
1309
1456
  async function persistNewIdentity(path, identity) {
1310
- await mkdir(dirname(path), { recursive: true, mode: 448 });
1457
+ await mkdir(dirname2(path), { recursive: true, mode: 448 });
1311
1458
  const handle = await open(path, "wx", 384);
1312
1459
  try {
1313
1460
  await handle.writeFile(`${JSON.stringify(identity, null, 2)}