@rynx-ai/runtime 0.1.11-beta.2 → 0.1.11-beta.21

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.
@@ -0,0 +1,163 @@
1
+ import { readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ const STATUS_TO_RUNNER = {
5
+ busy: "running",
6
+ waiting: "running",
7
+ idle: "idle",
8
+ shell: "idle",
9
+ running: "running",
10
+ completed: "idle",
11
+ failed: "idle",
12
+ error: "idle",
13
+ done: "idle",
14
+ };
15
+ const SCAN_FRESHNESS_MS = 120_000;
16
+ const DEFAULT_MAX_RESOLVE_ATTEMPTS = 40;
17
+ function isRecord(value) {
18
+ return value !== null && typeof value === "object" && !Array.isArray(value);
19
+ }
20
+ function readJsonRecord(path) {
21
+ try {
22
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
23
+ return isRecord(parsed) ? parsed : undefined;
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ }
29
+ function matchesSession(record, expectedSessionId) {
30
+ if (record.kind !== "interactive")
31
+ return false;
32
+ return expectedSessionId === undefined || record.sessionId === expectedSessionId;
33
+ }
34
+ export function claudeSessionsDir(configDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude")) {
35
+ return join(configDir, "sessions");
36
+ }
37
+ export function resolveClaudeSessionStatusFile({ panePid, expectedSessionId, configDir, now = Date.now(), }) {
38
+ const directory = claudeSessionsDir(configDir);
39
+ if (panePid !== undefined) {
40
+ const candidate = join(directory, `${panePid}.json`);
41
+ const record = readJsonRecord(candidate);
42
+ if (record && matchesSession(record, expectedSessionId))
43
+ return candidate;
44
+ }
45
+ if (!expectedSessionId)
46
+ return undefined;
47
+ try {
48
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
49
+ if (!entry.isFile() || !entry.name.endsWith(".json"))
50
+ continue;
51
+ const candidate = join(directory, entry.name);
52
+ let modifiedAt;
53
+ try {
54
+ modifiedAt = statSync(candidate).mtimeMs;
55
+ }
56
+ catch {
57
+ continue;
58
+ }
59
+ if (now - modifiedAt > SCAN_FRESHNESS_MS)
60
+ continue;
61
+ const record = readJsonRecord(candidate);
62
+ if (record && matchesSession(record, expectedSessionId))
63
+ return candidate;
64
+ }
65
+ }
66
+ catch {
67
+ return undefined;
68
+ }
69
+ return undefined;
70
+ }
71
+ export function readClaudeSessionStatus(path) {
72
+ const record = readJsonRecord(path);
73
+ if (!record || typeof record.status !== "string")
74
+ return undefined;
75
+ const runnerStatus = STATUS_TO_RUNNER[record.status];
76
+ if (!runnerStatus)
77
+ return undefined;
78
+ const updatedAt = record.statusUpdatedAt;
79
+ const waitingFor = record.status === "waiting" ? record.waitingFor : undefined;
80
+ return {
81
+ runnerStatus,
82
+ rawStatus: record.status,
83
+ ...(Number.isSafeInteger(updatedAt) ? { statusUpdatedAt: updatedAt } : {}),
84
+ ...(typeof waitingFor === "string" && waitingFor ? { blockedOn: waitingFor } : {}),
85
+ };
86
+ }
87
+ export class ClaudeSessionStatusPoller {
88
+ options;
89
+ path;
90
+ attempts = 0;
91
+ exhausted = false;
92
+ lastMtime;
93
+ lastEdge;
94
+ lastStatus;
95
+ constructor(options) {
96
+ this.options = options;
97
+ }
98
+ get active() {
99
+ return this.path !== undefined && !this.exhausted;
100
+ }
101
+ get status() {
102
+ return this.lastStatus;
103
+ }
104
+ tick() {
105
+ if (this.exhausted)
106
+ return;
107
+ if (!this.path) {
108
+ this.tryResolve();
109
+ if (!this.path)
110
+ return;
111
+ }
112
+ this.readAndPublish();
113
+ }
114
+ retire() {
115
+ this.exhausted = true;
116
+ }
117
+ resync() {
118
+ if (!this.active)
119
+ return;
120
+ this.lastMtime = undefined;
121
+ this.lastEdge = undefined;
122
+ }
123
+ tryResolve() {
124
+ this.attempts += 1;
125
+ this.path = resolveClaudeSessionStatusFile({
126
+ panePid: this.options.panePid(),
127
+ expectedSessionId: this.options.sessionId(),
128
+ ...(this.options.configDir ? { configDir: this.options.configDir } : {}),
129
+ now: this.options.now?.() ?? Date.now(),
130
+ });
131
+ if (!this.path &&
132
+ this.attempts >= (this.options.maxResolveAttempts ?? DEFAULT_MAX_RESOLVE_ATTEMPTS)) {
133
+ this.exhausted = true;
134
+ }
135
+ }
136
+ readAndPublish() {
137
+ if (!this.path)
138
+ return;
139
+ let mtime;
140
+ try {
141
+ mtime = statSync(this.path).mtimeMs;
142
+ }
143
+ catch {
144
+ this.exhausted = true;
145
+ return;
146
+ }
147
+ if (this.lastMtime === mtime)
148
+ return;
149
+ this.lastMtime = mtime;
150
+ const status = readClaudeSessionStatus(this.path);
151
+ if (!status) {
152
+ this.lastStatus = undefined;
153
+ this.lastEdge = undefined;
154
+ return;
155
+ }
156
+ this.lastStatus = status;
157
+ const edge = `${status.runnerStatus}\0${status.blockedOn ?? ""}`;
158
+ if (edge === this.lastEdge)
159
+ return;
160
+ this.lastEdge = edge;
161
+ this.options.onStatus(status);
162
+ }
163
+ }
@@ -23,15 +23,25 @@
23
23
  */
24
24
  import type { AgentEvent, UserContentPart } from "@rynx-ai/core";
25
25
  import type { CodexAppServerClient } from "./client.js";
26
+ import type { McpStartupPlan } from "./mcp-startup.js";
26
27
  import type { ResumedTurn } from "./protocol.js";
27
28
  export interface CodexForwarderSink {
28
29
  /** A turn began. `turnId` is codex's turn id, used to derive a stable
29
30
  * `responseId`. Start a fresh normalizer/response. */
30
31
  onTurnStart(turnId?: string): void;
32
+ /** The observer received the provider's authoritative `turn/started` edge.
33
+ * Unlike `onTurnStart`, this is not fired early by turn/start acceptance. */
34
+ onTurnObserved?(turnId?: string): void;
31
35
  /** One mapped event within the current turn. */
32
36
  onEvent(event: AgentEvent): void;
37
+ /** Provider startup is session status, not a model item. Hosts that already
38
+ * published the response can forward it without synthesizing another start. */
39
+ onStatus?(note: string | undefined, statusKind?: "startup"): void;
33
40
  /** The current turn finished; `usage` is the runtime's raw snapshot if any. */
34
- onTurnEnd(usage?: Record<string, unknown>): void;
41
+ onTurnEnd(usage?: Record<string, unknown>, reason?: "superseded"): void;
42
+ /** Resume proved that the newest turn is terminal even though its live edge
43
+ * was missed. This updates session state without replaying historical items. */
44
+ onRecoveredTurnStatus?(status: "idle" | "failed", turnId: string | undefined, error?: Error): void;
35
45
  /** A turn failed on the runtime. */
36
46
  onTurnError(error: Error): void;
37
47
  /** The user's turn text (sourced from codex's `userMessage` item), so a
@@ -58,6 +68,9 @@ export interface CodexSessionForwarderOptions {
58
68
  assistantMessageGraceMs?: number;
59
69
  /** Surface Traex's provider-capacity queue as a canonical running status. */
60
70
  surfaceQueueStatus?: boolean;
71
+ /** Provider-configured MCP servers. Their startup round is synthesized because
72
+ * Codex currently sends per-server edges only to the thread-owning TUI. */
73
+ mcpStartup?: McpStartupPlan | null;
61
74
  }
62
75
  export declare class CodexSessionForwarder {
63
76
  private readonly client;
@@ -69,9 +82,16 @@ export declare class CodexSessionForwarder {
69
82
  private currentThreadIdValue;
70
83
  private activeSignaled;
71
84
  private completionTimer;
85
+ /** Turn id retained only for late-item dedup while a terminal response waits
86
+ * for its bounded output-ordering grace. It is not an active provider turn. */
87
+ private pendingCompletionTurnId;
72
88
  private assistantMessageTimer;
73
89
  private deferredAssistantMessage;
74
90
  private pendingCompletion;
91
+ private readonly pendingMcpServers;
92
+ private readonly failedMcpServers;
93
+ private mcpStartupTimer;
94
+ private lastMcpStatusNote;
75
95
  /** Completed-item dedup keys already mirrored (live vs resume backfill). Key =
76
96
  * `threadId:turnId:item.id`; anonymous items use a per-(thread,turn) position
77
97
  * counter. Mirrors reference implementation `_completed_item_key` + `synced_item_keys`. */
@@ -83,12 +103,29 @@ export declare class CodexSessionForwarder {
83
103
  /** Begin mirroring. Idempotent. */
84
104
  start(): void;
85
105
  stop(): void;
86
- /** True while a turn is open injection uses `turn/steer` then, else `turn/start`. */
106
+ /** True while the provider owns an active turn, including the short interval
107
+ * between injection acceptance and observer confirmation. */
87
108
  isTurnOpen(): boolean;
88
109
  /** The current turn's codex id (only meaningful while {@link isTurnOpen}). */
89
110
  currentTurnId(): string | null;
111
+ /**
112
+ * Record a turn accepted by the injection connection before the independent
113
+ * observer receives `turn/started`. This closes the read-decide-RPC-write race:
114
+ * another web message arriving in that window must steer this turn, not start
115
+ * a second one.
116
+ */
117
+ noteTurnAccepted(turnId: string): void;
118
+ hasPendingMcpStartup(): boolean;
119
+ /** Mark and return the startup servers cancelled by a web Stop. */
120
+ cancelMcpStartup(): string[];
121
+ /** Diagnostic suffix for an injection failure during Provider startup. */
122
+ mcpStartupDetail(): string | null;
90
123
  /** The bound codex thread id captured from `thread/started` (null until then). */
91
124
  threadId(): string | null;
125
+ /** Seed an already-persisted/resumed thread binding. The bridge retains this
126
+ * state even when app-server does not rebroadcast `thread/started`, so
127
+ * terminal-boundary recovery must know it too. */
128
+ noteThreadBound(threadId: string): void;
92
129
  /**
93
130
  * Replay the backlog turns from a `thread/resume` response as if they were live
94
131
  * `item/completed` notifications — the fresh-thread first-turn backfill. Each
@@ -97,11 +134,25 @@ export declare class CodexSessionForwarder {
97
134
  * not doubled.
98
135
  */
99
136
  replayBackfill(turns: ResumedTurn[]): void;
137
+ /** Reconcile only the exact active turn after an observer resume. Historical
138
+ * items are intentionally not replayed on an existing-thread reconnect. */
139
+ reconcileActiveTurn(turn: ResumedTurn | undefined): boolean;
140
+ /** Reconcile an observer reconnect from the explicit resume snapshot. An
141
+ * identified active turn must match exactly; with no active turn, only the
142
+ * newest explicit terminal status is published and no history is replayed. */
143
+ reconcileResumeTurns(turns: ResumedTurn[]): boolean;
144
+ /** Fail an open response exactly once when its observer or terminal exits. */
145
+ failOpenTurn(error: Error): boolean;
100
146
  private handle;
101
147
  private scheduleCompletion;
102
148
  private refreshCompletionGrace;
103
149
  private flushPendingCompletion;
104
150
  private settle;
151
+ private handleMcpStartupStatus;
152
+ private settleMcpStartup;
153
+ private clearMcpStartupTimer;
154
+ private emitMcpStartupStatus;
155
+ private emitMcpStatus;
105
156
  /** Map + emit one completed codex item, deduped by a TOTAL key and routing the
106
157
  * user echo to {@link CodexForwarderSink.onUserMessage}. Shared by live + backfill. */
107
158
  private processCompletedItem;
@@ -118,4 +169,17 @@ export declare class CodexSessionForwarder {
118
169
  private completedItemKey;
119
170
  private advanceAnonCounter;
120
171
  private ensureTurn;
172
+ /** Start (or confirm) the app-server's authoritative active turn. A newer
173
+ * start supersedes an older response whose terminal edge arrived late; a
174
+ * pending Traex completion is flushed first so its final item grace remains
175
+ * intact. */
176
+ private beginTurn;
177
+ /** Active-turn clearing contract:
178
+ *
179
+ * - an identified active turn is closed only by the same id;
180
+ * - an id-less boundary cannot close an identified active turn;
181
+ * - with no observed active turn, an identified boundary may recover a
182
+ * missed start only when it carries the currently-bound thread id. */
183
+ private terminalBoundaryMatchesActiveTurn;
184
+ private notificationMatchesCurrentThread;
121
185
  }