@rynx-ai/runtime 0.1.11-beta.1 → 0.1.11-beta.3

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.
Files changed (38) hide show
  1. package/dist/claude/native-bridge.js +3 -8
  2. package/dist/claude/native-integration.d.ts +12 -1
  3. package/dist/claude/native-integration.js +16 -2
  4. package/dist/claude/transcript.d.ts +0 -7
  5. package/dist/claude/transcript.js +6 -20
  6. package/dist/codex-app-server/client.d.ts +2 -1
  7. package/dist/codex-app-server/forwarder.d.ts +4 -1
  8. package/dist/codex-app-server/forwarder.js +19 -1
  9. package/dist/codex-app-server/protocol.d.ts +45 -1
  10. package/dist/codex-home.d.ts +9 -26
  11. package/dist/codex-home.js +37 -65
  12. package/dist/codex-session-store.d.ts +22 -10
  13. package/dist/codex-session-store.js +277 -12
  14. package/dist/host.d.ts +47 -47
  15. package/dist/host.js +799 -351
  16. package/dist/index.d.ts +2 -3
  17. package/dist/index.js +1 -2
  18. package/dist/models-catalog.d.ts +1 -0
  19. package/dist/models-catalog.js +43 -1
  20. package/dist/provider-workspace.d.ts +56 -0
  21. package/dist/provider-workspace.js +83 -0
  22. package/dist/runner/child.d.ts +54 -6
  23. package/dist/runner/child.js +42 -17
  24. package/dist/runner/manager.d.ts +92 -19
  25. package/dist/runner/manager.js +838 -83
  26. package/dist/runner/protocol.d.ts +7 -18
  27. package/dist/runner-main.js +12 -4
  28. package/dist/runtime-state-paths.d.ts +10 -0
  29. package/dist/runtime-state-paths.js +53 -0
  30. package/dist/terminal/claude-tui.d.ts +8 -1
  31. package/dist/terminal/claude-tui.js +7 -1
  32. package/dist/terminal/codex-tui.d.ts +5 -1
  33. package/dist/terminal/codex-tui.js +12 -3
  34. package/dist/terminal/tmux.d.ts +8 -0
  35. package/dist/terminal/tmux.js +36 -3
  36. package/package.json +2 -2
  37. package/dist/codex/rollout-synth.d.ts +0 -42
  38. package/dist/codex/rollout-synth.js +0 -245
@@ -13,8 +13,8 @@
13
13
  */
14
14
  import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
15
15
  import { createHash } from "node:crypto";
16
- import { tmpdir } from "node:os";
17
16
  import { join } from "node:path";
17
+ import { adoptLegacyRuntimeDirectory, legacyRuntimeStateRoot, runtimeSessionDigest, runtimeSessionStateDir, } from "../runtime-state-paths.js";
18
18
  export const HOOKS_FILE = "hooks.jsonl";
19
19
  export const STATE_FILE = "state.json";
20
20
  export const DELTAS_FILE = "message_deltas.jsonl";
@@ -26,15 +26,9 @@ export const INTERACTION_RESULTS_DIR = "interaction-results";
26
26
  export const INTERACTION_CLAIMS_DIR = "interaction-claims";
27
27
  export const INTERACTION_LEASES_DIR = "interaction-leases";
28
28
  export const MANAGED_SETTINGS_FILE = "managed-settings.json";
29
- /** Uid-scoped root so other users on a shared host cannot read interaction data. */
30
- function bridgeRoot() {
31
- const uid = typeof process.getuid === "function" ? process.getuid() : "nouid";
32
- return join(tmpdir(), `rynx-${uid}`, "claude-native");
33
- }
34
29
  /** The deterministic bridge directory for a rynx session id. */
35
30
  export function claudeBridgeDir(sessionId) {
36
- const digest = createHash("sha256").update(sessionId).digest("hex").slice(0, 32);
37
- return join(bridgeRoot(), digest);
31
+ return join(runtimeSessionStateDir(sessionId), "claude-bridge");
38
32
  }
39
33
  /**
40
34
  * Create/refresh the bridge dir and clear stale files. Native interactions use
@@ -43,6 +37,7 @@ export function claudeBridgeDir(sessionId) {
43
37
  */
44
38
  export function prepareClaudeBridgeDir(sessionId) {
45
39
  const dir = claudeBridgeDir(sessionId);
40
+ adoptLegacyRuntimeDirectory(join(legacyRuntimeStateRoot(), "claude-native", runtimeSessionDigest(sessionId)), dir);
46
41
  mkdirSync(dir, { recursive: true, mode: 0o700 });
47
42
  for (const file of [
48
43
  HOOKS_FILE,
@@ -32,6 +32,9 @@ export interface ClaudeForwarderSink {
32
32
  /** Fired once SessionStart reveals claude's session id + transcript path, so
33
33
  * the host can persist the id and release its readiness gate. */
34
34
  onSessionDiscovered?(claudeSessionId: string, transcriptPath: string): void;
35
+ /** A resumed TUI announced a different native session id than the persisted
36
+ * binding. The host keeps the original binding and fails readiness. */
37
+ onSessionResumeError?(error: Error): void;
35
38
  /** The claude session rotated (`/clear` → fresh, `/fork` → derived): a new
36
39
  * SessionStart reported a new session id + transcript. The forwarder has
37
40
  * already re-pointed to the new transcript and reset its per-session state; the
@@ -45,8 +48,13 @@ export interface ClaudeLiveSessionOptions {
45
48
  /** Resume: a known transcript path to tail immediately (else await SessionStart). */
46
49
  transcriptPath?: string;
47
50
  /** Resume: the claude session id for that transcript — used to match the
48
- * persisted tail cursor so a re-launch resumes instead of re-mirroring. */
51
+ * persisted binding. It may be supplied without `transcriptPath`; Claude's
52
+ * SessionStart hook remains the source of truth for the actual file path. */
49
53
  claudeSessionId?: string;
54
+ /** Rynx already owns the persisted history for a resumed Session. When no
55
+ * matching forwarder cursor exists, follow only records appended after the
56
+ * SessionStart discovery instead of mirroring native history a second time. */
57
+ resumeAtEndOnDiscovery?: boolean;
50
58
  /** Poll interval (ms). The files are append-only, so polling is simplest. */
51
59
  pollMs?: number;
52
60
  /** Inactivity (ms) FALLBACK close for a turn whose Stop hook never fired. */
@@ -83,6 +91,9 @@ export declare class ClaudeLiveSession {
83
91
  private discovered;
84
92
  /** claude's current session uuid (changes on `/clear`·`/fork`·resume). */
85
93
  private currentClaudeSessionId?;
94
+ /** Persisted native id requested through `claude --resume`. */
95
+ private readonly expectedClaudeSessionId?;
96
+ private readonly resumeAtEndOnDiscovery;
86
97
  /** Every claude session uuid seen — a resume into an UNSEEN id + a forkedFrom
87
98
  * marker signals a `/fork` (vs. resuming a known branch). */
88
99
  private readonly seenClaudeSessionIds;
@@ -155,6 +155,9 @@ export class ClaudeLiveSession {
155
155
  discovered = false;
156
156
  /** claude's current session uuid (changes on `/clear`·`/fork`·resume). */
157
157
  currentClaudeSessionId;
158
+ /** Persisted native id requested through `claude --resume`. */
159
+ expectedClaudeSessionId;
160
+ resumeAtEndOnDiscovery;
158
161
  /** Every claude session uuid seen — a resume into an UNSEEN id + a forkedFrom
159
162
  * marker signals a `/fork` (vs. resuming a known branch). */
160
163
  seenClaudeSessionIds = new Set();
@@ -223,6 +226,8 @@ export class ClaudeLiveSession {
223
226
  this.isProcessAlive = opts.isProcessAlive ?? processIsAlive;
224
227
  this.leaseUpdatedAt = opts.interactionLeaseUpdatedAt ??
225
228
  ((interactionId) => interactionLeaseUpdatedAt(this.bridgeDir, interactionId));
229
+ this.expectedClaudeSessionId = opts.claudeSessionId;
230
+ this.resumeAtEndOnDiscovery = opts.resumeAtEndOnDiscovery ?? false;
226
231
  this.transcriptPath = opts.transcriptPath;
227
232
  // Resume path: bind the session id + restore the persisted forwarder cursor so
228
233
  // we continue from where a prior forwarder left off (no re-mirror on relaunch).
@@ -245,7 +250,7 @@ export class ClaudeLiveSession {
245
250
  restoreForwardState(transcriptPath) {
246
251
  const state = readForwardState(this.bridgeDir);
247
252
  if (!state || state.transcriptPath !== transcriptPath)
248
- return;
253
+ return false;
249
254
  for (const id of state.seenSourceIds)
250
255
  this.seenSourceIds.add(id);
251
256
  const fingerprint = jsonlCursorFingerprint(transcriptPath, state.byteOffset);
@@ -253,6 +258,7 @@ export class ClaudeLiveSession {
253
258
  fingerprint !== undefined && fingerprint === state.cursorFingerprint
254
259
  ? state.byteOffset // cursor valid → resume here
255
260
  : fileSize(transcriptPath); // truncated/replaced → skip to EOF
261
+ return true;
256
262
  }
257
263
  /** Persist the durable forwarder cursor (byte offset + seen ids + fingerprint). */
258
264
  persistForwardState() {
@@ -437,6 +443,11 @@ export class ClaudeLiveSession {
437
443
  return;
438
444
  // First discovery: bind the transcript + reveal the session id.
439
445
  if (!this.transcriptPath) {
446
+ if (this.expectedClaudeSessionId &&
447
+ ev.sessionId !== this.expectedClaudeSessionId) {
448
+ this.sink.onSessionResumeError?.(new Error(`Claude resumed ${ev.sessionId ?? "an unknown session"} instead of ${this.expectedClaudeSessionId}`));
449
+ return;
450
+ }
440
451
  this.transcriptPath = ev.transcriptPath;
441
452
  this.transcriptOffset = 0;
442
453
  this.currentClaudeSessionId = ev.sessionId;
@@ -444,7 +455,10 @@ export class ClaudeLiveSession {
444
455
  this.seenClaudeSessionIds.add(ev.sessionId);
445
456
  // Resume from the persisted cursor so a relaunched forwarder doesn't re-mirror
446
457
  // already-logged turns.
447
- this.restoreForwardState(ev.transcriptPath);
458
+ const restored = this.restoreForwardState(ev.transcriptPath);
459
+ if (!restored && this.resumeAtEndOnDiscovery) {
460
+ this.transcriptOffset = fileSize(ev.transcriptPath);
461
+ }
448
462
  if (!this.discovered && ev.sessionId) {
449
463
  this.discovered = true;
450
464
  this.sink.onSessionDiscovered?.(ev.sessionId, ev.transcriptPath);
@@ -1,11 +1,4 @@
1
1
  import type { AgentEvent, TerminalCommandData } from "@rynx-ai/core";
2
- /** Claude Code encodes a cwd into a project-dir name by replacing `/` and `.`
3
- * with `-` (e.g. `/Users/x/Workspace/rynx` → `-Users-x-Workspace-rynx`). */
4
- export declare function encodeClaudeProjectDir(cwd: string): string;
5
- /** The directory Claude Code writes this cwd's session transcripts into. */
6
- export declare function claudeProjectDir(cwd: string, home?: string): string;
7
- /** The transcript file for a specific claude session id. */
8
- export declare function claudeTranscriptPath(cwd: string, sessionId: string, home?: string): string;
9
2
  /** A sub-agent (Task) writes its own transcript to
10
3
  * `<project>/<sessionId>/subagents/agent-<agentId>.jsonl`, alongside the parent
11
4
  * `<sessionId>.jsonl`. Derive that path from the parent transcript path. */
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * Claude Code transcript reader (Phase E, claude-native). Claude Code appends a
3
- * JSONL transcript per session at
4
- * `~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl`; the interactive TUI's
5
- * stdout is a full-screen UI, so — like reference implementation's claude-native — we take the
6
- * structured story from this file, not the PTY. Each `user`/`assistant` record
7
- * wraps an Anthropic message (`{ role, content: [blocks] }`), so we map its
8
- * blocks to the same typed {@link AgentEvent}s the headless executor emits.
3
+ * JSONL transcript per session. Its `SessionStart` hook supplies the actual
4
+ * transcript path; Rynx never derives that path from Claude's private directory
5
+ * layout. The interactive TUI's stdout is a full-screen UI, so we read this
6
+ * hook-provided file for the UI mirror only. Each `user`/`assistant` record wraps
7
+ * an Anthropic message (`{ role, content: [blocks] }`), which we map to the same
8
+ * typed {@link AgentEvent}s the headless executor emits.
9
9
  *
10
10
  * Records are full messages (not deltas), so text → a completed message,
11
11
  * thinking → completed reasoning, tool_use → tool start, tool_result → tool end.
@@ -14,21 +14,7 @@
14
14
  */
15
15
  import { readFileSync } from "node:fs";
16
16
  import { open, stat } from "node:fs/promises";
17
- import { homedir } from "node:os";
18
17
  import { join } from "node:path";
19
- /** Claude Code encodes a cwd into a project-dir name by replacing `/` and `.`
20
- * with `-` (e.g. `/Users/x/Workspace/rynx` → `-Users-x-Workspace-rynx`). */
21
- export function encodeClaudeProjectDir(cwd) {
22
- return cwd.replace(/[/.]/g, "-");
23
- }
24
- /** The directory Claude Code writes this cwd's session transcripts into. */
25
- export function claudeProjectDir(cwd, home = homedir()) {
26
- return join(home, ".claude", "projects", encodeClaudeProjectDir(cwd));
27
- }
28
- /** The transcript file for a specific claude session id. */
29
- export function claudeTranscriptPath(cwd, sessionId, home = homedir()) {
30
- return join(claudeProjectDir(cwd, home), `${sessionId}.jsonl`);
31
- }
32
18
  /** A sub-agent (Task) writes its own transcript to
33
19
  * `<project>/<sessionId>/subagents/agent-<agentId>.jsonl`, alongside the parent
34
20
  * `<sessionId>.jsonl`. Derive that path from the parent transcript path. */
@@ -67,7 +67,8 @@ export declare class CodexAppServerClient {
67
67
  threadFork(params: ThreadForkParams): Promise<{
68
68
  thread: {
69
69
  id: string;
70
- };
70
+ forkedFromId?: string | null;
71
+ } & Record<string, unknown>;
71
72
  } & Record<string, unknown>>;
72
73
  /** Update per-thread settings (model / mode / personality / sandbox …). */
73
74
  threadSettingsUpdate(params: ThreadSettingsUpdateParams): Promise<Record<string, unknown>>;
@@ -37,9 +37,12 @@ export interface CodexForwarderSink {
37
37
  /** The user's turn text (sourced from codex's `userMessage` item), so a
38
38
  * co-driving TUI's prompt is recorded even though this process never injected it. */
39
39
  onUserMessage?(content: string | UserContentPart[]): void;
40
+ /** A managed Core fork also broadcasts `thread/started`, but does not switch
41
+ * the source TUI. Discard that notification before changing the bound thread. */
42
+ shouldIgnoreThreadStarted?(threadId: string, forkedFromId?: string): boolean;
40
43
  /** A thread was announced on the app-server (a `--remote` TUI creating a thread,
41
44
  * or a resume). The host persists the id + starts the subscribe loop. */
42
- onThreadStarted?(threadId: string): void;
45
+ onThreadStarted?(threadId: string, forkedFromId?: string): void;
43
46
  /** The thread showed activity (a turn/item began, so its rollout now exists).
44
47
  * Fired once; lets a parked `thread/resume` retry (reference implementation's ready signal). */
45
48
  onThreadActive?(): void;
@@ -115,11 +115,29 @@ export class CodexSessionForwarder {
115
115
  if (method === "thread/started" || method === "thread.started") {
116
116
  const tid = threadIdFrom(params);
117
117
  if (tid && tid !== this.currentThreadIdValue) {
118
+ const forkedFromId = params?.thread?.forkedFromId;
119
+ const normalizedForkedFromId = typeof forkedFromId === "string" ? forkedFromId : undefined;
120
+ if (this.sink.shouldIgnoreThreadStarted?.(tid, normalizedForkedFromId)) {
121
+ return;
122
+ }
123
+ this.flushPendingCompletion();
124
+ this.flushDeferredAssistantMessage();
125
+ if (this.turnOpen) {
126
+ this.turnOpen = false;
127
+ this.sink.onTurnEnd();
128
+ }
129
+ this.currentTurnIdValue = null;
130
+ this.activeSignaled = false;
118
131
  this.currentThreadIdValue = tid;
119
- this.sink.onThreadStarted?.(tid);
132
+ this.sink.onThreadStarted?.(tid, normalizedForkedFromId);
120
133
  }
121
134
  return;
122
135
  }
136
+ const notificationThreadId = threadIdFrom(params);
137
+ if (this.currentThreadIdValue &&
138
+ notificationThreadId &&
139
+ notificationThreadId !== this.currentThreadIdValue)
140
+ return;
123
141
  // Release a parked resume as soon as the thread shows activity (rollout now
124
142
  // exists). Fire once.
125
143
  if (!this.activeSignaled && indicatesActive(method, params)) {
@@ -58,14 +58,46 @@ export interface GetAuthStatusResponse {
58
58
  }
59
59
  export type AskForApproval = "untrusted" | "on-failure" | "on-request" | "never";
60
60
  export type SandboxMode = "read-only" | "workspace-write" | "danger-full-access";
61
+ export type SandboxPolicy = {
62
+ type: "readOnly";
63
+ networkAccess: boolean;
64
+ } | {
65
+ type: "externalSandbox";
66
+ networkAccess: "restricted" | "enabled";
67
+ } | {
68
+ type: "workspaceWrite";
69
+ writableRoots: string[];
70
+ networkAccess: boolean;
71
+ excludeTmpdirEnvVar: boolean;
72
+ excludeSlashTmp: boolean;
73
+ } | {
74
+ type: "dangerFullAccess";
75
+ };
76
+ /** Traex 0.200 named permission profile selection. Codex currently accepts a
77
+ * string profile id instead, so callers must choose the runtime-specific shape. */
78
+ export interface PermissionProfileSelectionParams {
79
+ type: "profile";
80
+ id: string;
81
+ modifications?: PermissionProfileModificationParams[] | null;
82
+ }
83
+ export interface PermissionProfileModificationParams {
84
+ type: "additionalWritableRoot";
85
+ path: string;
86
+ }
87
+ export type PermissionSelection = string | PermissionProfileSelectionParams;
61
88
  /** Open since Codex App Server 0.144; values are advertised by `model/list`. */
62
89
  export type ReasoningEffort = string;
63
90
  export interface ThreadStartParams {
64
91
  model?: string | null;
65
92
  modelProvider?: string | null;
66
93
  cwd?: string | null;
94
+ /** Codex-only runtime workspace roots. Traex uses a permission selection with
95
+ * `additionalWritableRoot` modifications instead. */
96
+ runtimeWorkspaceRoots?: string[] | null;
67
97
  approvalPolicy?: AskForApproval | null;
68
98
  sandbox?: SandboxMode | null;
99
+ /** Named permissions profile. Cannot be combined with `sandbox`. */
100
+ permissions?: PermissionSelection | null;
69
101
  config?: Record<string, unknown> | null;
70
102
  baseInstructions?: string | null;
71
103
  developerInstructions?: string | null;
@@ -115,7 +147,12 @@ export interface TurnStartParams {
115
147
  threadId: string;
116
148
  input: UserInput[];
117
149
  cwd?: string | null;
150
+ /** Codex-only sticky runtime workspace roots. */
151
+ runtimeWorkspaceRoots?: string[] | null;
118
152
  approvalPolicy?: AskForApproval | null;
153
+ sandboxPolicy?: SandboxPolicy | null;
154
+ /** Named permissions profile. Cannot be combined with `sandboxPolicy`. */
155
+ permissions?: PermissionSelection | null;
119
156
  model?: string | null;
120
157
  effort?: ReasoningEffort | null;
121
158
  }
@@ -240,18 +277,25 @@ export interface ThreadListResponse {
240
277
  }
241
278
  export interface ThreadForkParams {
242
279
  threadId: string;
280
+ /** Omit the copied turn backlog from the RPC response. The Provider still
281
+ * persists and inherits the complete thread context. */
282
+ excludeTurns?: boolean;
243
283
  path?: string | null;
244
284
  model?: string | null;
245
285
  modelProvider?: string | null;
246
286
  cwd?: string | null;
287
+ /** Codex-only runtime workspace roots. */
288
+ runtimeWorkspaceRoots?: string[] | null;
247
289
  approvalPolicy?: AskForApproval | null;
248
290
  sandbox?: SandboxMode | null;
291
+ /** Named permissions profile. Cannot be combined with `sandbox`. */
292
+ permissions?: PermissionSelection | null;
249
293
  }
250
294
  export interface ThreadSettingsUpdateParams {
251
295
  threadId: string;
252
296
  cwd?: string | null;
253
297
  approvalPolicy?: AskForApproval | null;
254
- sandboxPolicy?: Record<string, unknown> | null;
298
+ sandboxPolicy?: SandboxPolicy | null;
255
299
  /** Named permissions profile id; cannot be combined with `sandboxPolicy`. */
256
300
  permissions?: string | null;
257
301
  model?: string | null;
@@ -1,11 +1,11 @@
1
1
  import { type AgentRuntimeId } from "@rynx-ai/core";
2
2
  export type CodexLineageRuntime = Exclude<AgentRuntimeId, "claude">;
3
3
  /**
4
- * The deterministic private CODEX_HOME path for a rynx session. PER-SESSION
5
- * (uid-scoped + `sha256(sessionId)[:32]`), mirroring reference implementation's per-session
4
+ * The deterministic private CODEX_HOME path for a rynx session. PER-SESSION,
5
+ * under the daemon-owned RYNX_HOME runtime tree,
6
6
  * `bridge_dir/codex-home` and rynx's own `claudeBridgeDir`. Pure — computes the
7
- * path without touching disk, so the daemon (rollout-synth) and the runner child
8
- * (app-server) both locate the SAME session's home from `sessionId` alone.
7
+ * path without touching disk, so the runner child and its app-server locate the
8
+ * SAME Rynx-owned runtime home from `sessionId` alone.
9
9
  */
10
10
  export declare function codexHomePath(sessionId: string): string;
11
11
  /** Deterministic private home for a Codex-lineage runtime. */
@@ -16,9 +16,9 @@ export declare function legacyCodexHomePath(): string;
16
16
  /**
17
17
  * Prepare a private CODEX_HOME that inherits the user's login (symlinked
18
18
  * `auth.json`) and settings (copied `config.toml`) but NOT the real home's pending
19
- * update / first-run (NUX) state so a co-driven `codex` app-server + `--remote`
20
- * TUI never block on an "Update now / Press enter to continue" prompt (which would
21
- * wedge terminal injection). Ports reference implementation's `_CODEX_HOME_SYMLINK_FILES` /
19
+ * update / first-run (NUX) state. Managed TUI launches separately disable
20
+ * startup update checks, while keeping this mutable state isolated from the
21
+ * user's real home. Ports reference implementation's `_CODEX_HOME_SYMLINK_FILES` /
22
22
  * `_CODEX_HOME_COPY_FILES`.
23
23
  *
24
24
  * PER-SESSION: one private home per rynx session (matching reference implementation), so concurrent
@@ -32,25 +32,8 @@ export declare function prepareCodexHome(sessionId: string, realHome?: string):
32
32
  * real home and cannot wedge a managed app-server/TUI pair.
33
33
  */
34
34
  export declare function prepareRuntimeHome(sessionId: string, runtime: CodexLineageRuntime, realHome?: string): string;
35
- /**
36
- * Link a resolved skill set into `<codexHome>/skills/<name>/` so the native Codex
37
- * discovers them at `$CODEX_HOME/skills/` — the SAME filesystem mechanism reference implementation
38
- * uses (`populate_codex_skills_from_bundle` → `_populate_codex_skills`), NOT a
39
- * `<skills_instructions>` block injected into the prompt. Codex's app-server
40
- * watches this dir and re-scans on change, so a live `codex --remote` TUI — whose
41
- * thread rynx never creates, so it can't carry `threadStart.developerInstructions`
42
- * — still sees the agent's skills.
43
- *
44
- * Each skill is a symlink to its source dir; a filesystem without symlink support
45
- * falls back to a recursive copy (matches reference implementation's fallback).
46
- *
47
- * reference implementation boots a fresh per-session CODEX_HOME, so it only ever links into an
48
- * empty dir. rynx shares ONE private home per runtime (see {@link prepareCodexHome}),
49
- * so this CONVERGES the dir to `skills`: it links the missing ones and removes
50
- * entries no longer selected, honouring the agent spec's gating. (Concurrent
51
- * sessions of DIFFERENT agents on one runtime share this dir — the same shared-home
52
- * tradeoff already accepted for auth/config.)
53
- */
35
+ /** Persist immutable skill copies in a native Provider home. Fork descendants
36
+ * share that home, so entries must outlive every runner's temporary sources. */
54
37
  export declare function populateCodexSkills(codexHome: string, skills: {
55
38
  name: string;
56
39
  dir: string;
@@ -1,8 +1,9 @@
1
- import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, rmSync, symlinkSync } from "node:fs";
2
- import { createHash } from "node:crypto";
3
- import { homedir, tmpdir } from "node:os";
1
+ import { randomUUID } from "node:crypto";
2
+ import { copyFileSync, cpSync, existsSync, mkdirSync, renameSync, rmSync, symlinkSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
- import { getRuntimeProfile, resolveRuntimeHome, } from "@rynx-ai/core";
5
+ import { getRuntimeProfile, resolveRuntimeHome, SAFE_SKILL_NAME, } from "@rynx-ai/core";
6
+ import { adoptLegacyRuntimeDirectory, legacyRuntimeStateRoot, runtimeSessionDigest, runtimeSessionStateDir, } from "./runtime-state-paths.js";
6
7
  /** Inherit the user's LIVE login by symlink (stays in sync). */
7
8
  const SYMLINK_FILES = ["auth.json"];
8
9
  /** Inherit the user's settings by snapshot copy (not the mutable NUX/update state). */
@@ -21,40 +22,33 @@ const RUNTIME_HOME_FILES = {
21
22
  function realCodexHome() {
22
23
  return process.env.CODEX_HOME?.trim() || join(homedir(), ".codex");
23
24
  }
24
- /** Uid-scoped root so other users on a shared host can't read the tree. */
25
- function rynxUidRoot() {
26
- const uid = typeof process.getuid === "function" ? process.getuid() : "nouid";
27
- return join(tmpdir(), `rynx-${uid}`);
28
- }
29
25
  /**
30
- * The deterministic private CODEX_HOME path for a rynx session. PER-SESSION
31
- * (uid-scoped + `sha256(sessionId)[:32]`), mirroring reference implementation's per-session
26
+ * The deterministic private CODEX_HOME path for a rynx session. PER-SESSION,
27
+ * under the daemon-owned RYNX_HOME runtime tree,
32
28
  * `bridge_dir/codex-home` and rynx's own `claudeBridgeDir`. Pure — computes the
33
- * path without touching disk, so the daemon (rollout-synth) and the runner child
34
- * (app-server) both locate the SAME session's home from `sessionId` alone.
29
+ * path without touching disk, so the runner child and its app-server locate the
30
+ * SAME Rynx-owned runtime home from `sessionId` alone.
35
31
  */
36
32
  export function codexHomePath(sessionId) {
37
- const digest = createHash("sha256").update(sessionId).digest("hex").slice(0, 32);
38
- return join(rynxUidRoot(), "codex-native", digest, "codex-home");
33
+ return join(runtimeSessionStateDir(sessionId), "codex-home");
39
34
  }
40
35
  /** Deterministic private home for a Codex-lineage runtime. */
41
36
  export function runtimeHomePath(sessionId, runtime) {
42
37
  if (runtime === "codex")
43
38
  return codexHomePath(sessionId);
44
- const digest = createHash("sha256").update(sessionId).digest("hex").slice(0, 32);
45
- return join(rynxUidRoot(), "traex-native", digest, "trae-home");
39
+ return join(runtimeSessionStateDir(sessionId), "trae-home");
46
40
  }
47
41
  /** The OLD uid-scoped shared home (pre per-session). Kept ONLY for back-compat
48
42
  * resume fallback — a session's rollout may still live under here. */
49
43
  export function legacyCodexHomePath() {
50
- return join(rynxUidRoot(), "codex-home");
44
+ return join(legacyRuntimeStateRoot(), "codex-home");
51
45
  }
52
46
  /**
53
47
  * Prepare a private CODEX_HOME that inherits the user's login (symlinked
54
48
  * `auth.json`) and settings (copied `config.toml`) but NOT the real home's pending
55
- * update / first-run (NUX) state so a co-driven `codex` app-server + `--remote`
56
- * TUI never block on an "Update now / Press enter to continue" prompt (which would
57
- * wedge terminal injection). Ports reference implementation's `_CODEX_HOME_SYMLINK_FILES` /
49
+ * update / first-run (NUX) state. Managed TUI launches separately disable
50
+ * startup update checks, while keeping this mutable state isolated from the
51
+ * user's real home. Ports reference implementation's `_CODEX_HOME_SYMLINK_FILES` /
58
52
  * `_CODEX_HOME_COPY_FILES`.
59
53
  *
60
54
  * PER-SESSION: one private home per rynx session (matching reference implementation), so concurrent
@@ -73,6 +67,11 @@ export function prepareRuntimeHome(sessionId, runtime, realHome = runtime === "c
73
67
  ? realCodexHome()
74
68
  : resolveRuntimeHome(getRuntimeProfile(runtime))) {
75
69
  const dir = runtimeHomePath(sessionId, runtime);
70
+ const digest = runtimeSessionDigest(sessionId);
71
+ const legacy = runtime === "codex"
72
+ ? join(legacyRuntimeStateRoot(), "codex-native", digest, "codex-home")
73
+ : join(legacyRuntimeStateRoot(), "traex-native", digest, "trae-home");
74
+ adoptLegacyRuntimeDirectory(legacy, dir);
76
75
  mkdirSync(dir, { recursive: true, mode: 0o700 });
77
76
  const files = RUNTIME_HOME_FILES[runtime];
78
77
  for (const name of files.symlink) {
@@ -109,58 +108,31 @@ export function prepareRuntimeHome(sessionId, runtime, realHome = runtime === "c
109
108
  }
110
109
  return dir;
111
110
  }
112
- /**
113
- * Link a resolved skill set into `<codexHome>/skills/<name>/` so the native Codex
114
- * discovers them at `$CODEX_HOME/skills/` — the SAME filesystem mechanism reference implementation
115
- * uses (`populate_codex_skills_from_bundle` → `_populate_codex_skills`), NOT a
116
- * `<skills_instructions>` block injected into the prompt. Codex's app-server
117
- * watches this dir and re-scans on change, so a live `codex --remote` TUI — whose
118
- * thread rynx never creates, so it can't carry `threadStart.developerInstructions`
119
- * — still sees the agent's skills.
120
- *
121
- * Each skill is a symlink to its source dir; a filesystem without symlink support
122
- * falls back to a recursive copy (matches reference implementation's fallback).
123
- *
124
- * reference implementation boots a fresh per-session CODEX_HOME, so it only ever links into an
125
- * empty dir. rynx shares ONE private home per runtime (see {@link prepareCodexHome}),
126
- * so this CONVERGES the dir to `skills`: it links the missing ones and removes
127
- * entries no longer selected, honouring the agent spec's gating. (Concurrent
128
- * sessions of DIFFERENT agents on one runtime share this dir — the same shared-home
129
- * tradeoff already accepted for auth/config.)
130
- */
111
+ /** Persist immutable skill copies in a native Provider home. Fork descendants
112
+ * share that home, so entries must outlive every runner's temporary sources. */
131
113
  export function populateCodexSkills(codexHome, skills) {
132
114
  const skillsDir = join(codexHome, "skills");
133
- const want = new Map(skills.map((s) => [s.name, s.dir]));
134
- // Converge: drop entries no longer selected (shared-home adaptation; reference implementation's
135
- // per-session home never needs this). `.system` holds codex's own embedded system
136
- // skills (the app-server installs them into `$CODEX_HOME/skills/.system`) — never
137
- // rynx-managed, so leave it untouched; only converge the entries we linked.
138
- if (existsSync(skillsDir)) {
139
- for (const name of readdirSync(skillsDir)) {
140
- if (name === ".system")
141
- continue;
142
- if (!want.has(name))
143
- rmSync(join(skillsDir, name), { recursive: true, force: true });
115
+ for (const skill of skills) {
116
+ if (!SAFE_SKILL_NAME.test(skill.name)) {
117
+ throw new Error(`unsafe skill name: ${skill.name}`);
144
118
  }
145
119
  }
146
- if (want.size === 0)
120
+ if (skills.length === 0)
147
121
  return;
148
122
  mkdirSync(skillsDir, { recursive: true });
149
- for (const [name, src] of want) {
150
- const link = join(skillsDir, name);
151
- rmSync(link, { recursive: true, force: true });
123
+ for (const { name, dir } of skills) {
124
+ const target = join(skillsDir, name);
125
+ if (existsSync(target))
126
+ continue;
127
+ const staged = join(skillsDir, `.rynx-${name}-${process.pid}-${randomUUID()}`);
152
128
  try {
153
- symlinkSync(src, link);
129
+ cpSync(dir, staged, { recursive: true, errorOnExist: true, force: false });
130
+ renameSync(staged, target);
154
131
  }
155
- catch {
156
- // no symlink support (e.g. some Windows configs) → copy. A skill-link failure
157
- // must not break the terminal launch, so swallow a copy failure too.
158
- try {
159
- cpSync(src, link, { recursive: true });
160
- }
161
- catch {
162
- // best-effort
163
- }
132
+ catch (error) {
133
+ rmSync(staged, { recursive: true, force: true });
134
+ if (!existsSync(target))
135
+ throw error;
164
136
  }
165
137
  }
166
138
  }
@@ -1,17 +1,20 @@
1
- import { type AgentRuntimeId, type ReasoningEffort } from "@rynx-ai/core";
2
1
  import type { AppConfig } from "@rynx-ai/core";
3
2
  export interface CodexSessionRecord {
4
3
  localThreadId: string;
5
4
  codexSessionId: string;
6
- cwd: string;
7
- model: string;
8
- reasoningEffort?: ReasoningEffort;
9
- /** Runtime this thread is bound to. Legacy records backfill to `codex`. */
10
- runtime: AgentRuntimeId;
11
- /** Declarative agent spec name bound to this thread, if any. */
12
- agent?: string;
13
- /** The source rynx session this one was derived from (claude `/fork`). */
5
+ /** The source rynx Session this Provider-native fork was derived from. */
14
6
  parentSessionId?: string;
7
+ /** Provider-internal durable home ownership for native forks. Machine Session
8
+ * metadata remains self-contained; this only tells a runner where the
9
+ * Provider persisted its rollout/state. */
10
+ runtimeHomeOwnerSessionId?: string;
11
+ updatedAt: string;
12
+ }
13
+ export interface ClaudeForkIntent {
14
+ targetSessionId: string;
15
+ sourceSessionId: string;
16
+ sourceClaudeSessionId: string;
17
+ targetClaudeSessionId: string;
15
18
  updatedAt: string;
16
19
  }
17
20
  export interface CodexSessionStore {
@@ -25,6 +28,11 @@ export interface CodexSessionStore {
25
28
  listAll(): Promise<CodexSessionRecord[]>;
26
29
  /** Reverse lookup by Codex thread id (the rollout-file UUID). */
27
30
  findByCodexSessionId(codexSessionId: string): Promise<CodexSessionRecord | null>;
31
+ /** A Claude fork is not a Provider binding until the target TUI has emitted a
32
+ * matching SessionStart. Keep its launch intent in a separate namespace. */
33
+ getClaudeForkIntent?(targetSessionId: string): Promise<ClaudeForkIntent | null>;
34
+ setClaudeForkIntent?(intent: ClaudeForkIntent): Promise<void>;
35
+ deleteClaudeForkIntent?(targetSessionId: string): Promise<void>;
28
36
  }
29
37
  export declare class FileCodexSessionStore implements CodexSessionStore {
30
38
  readonly filePath: string;
@@ -35,9 +43,13 @@ export declare class FileCodexSessionStore implements CodexSessionStore {
35
43
  delete(localThreadId: string): Promise<void>;
36
44
  listAll(): Promise<CodexSessionRecord[]>;
37
45
  findByCodexSessionId(codexSessionId: string): Promise<CodexSessionRecord | null>;
46
+ getClaudeForkIntent(targetSessionId: string): Promise<ClaudeForkIntent | null>;
47
+ setClaudeForkIntent(intent: ClaudeForkIntent): Promise<void>;
48
+ deleteClaudeForkIntent(targetSessionId: string): Promise<void>;
38
49
  isWritable(): Promise<boolean>;
39
50
  private readAll;
40
51
  private writeAll;
41
52
  private runExclusive;
53
+ private acquireFileLock;
42
54
  }
43
- export declare function resolveCodexSessionStorePath(config: AppConfig): string;
55
+ export declare function resolveCodexSessionStorePath(config: AppConfig, env?: NodeJS.ProcessEnv): string;