@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 +6 -4
- package/dist/cli.js +181 -24
- package/dist/cli.js.map +1 -1
- package/dist/index.js +161 -20
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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.
|
|
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.
|
|
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
|
-
-
|
|
62
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
889
|
+
threadOptions(config, workingDirectory, selectedModel)
|
|
767
890
|
);
|
|
768
891
|
} else {
|
|
769
|
-
record.thread = codex.startThread(threadOptions(config,
|
|
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,
|
|
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,
|
|
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,
|
|
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(
|
|
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.
|
|
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
|
-
|
|
1896
|
-
|
|
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
|
|
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 () => {
|