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

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 (44) hide show
  1. package/dist/claude/executor.d.ts +19 -5
  2. package/dist/claude/executor.js +56 -12
  3. package/dist/claude/models.d.ts +0 -5
  4. package/dist/claude/models.js +1 -7
  5. package/dist/claude/native-bridge.d.ts +2 -0
  6. package/dist/claude/native-bridge.js +23 -0
  7. package/dist/claude/native-hook-main.js +62 -0
  8. package/dist/claude/native-integration.d.ts +50 -10
  9. package/dist/claude/native-integration.js +262 -37
  10. package/dist/claude/session-status.d.ts +39 -0
  11. package/dist/claude/session-status.js +163 -0
  12. package/dist/claude/transcript.js +27 -17
  13. package/dist/codex-app-server/client.d.ts +10 -6
  14. package/dist/codex-app-server/client.js +67 -15
  15. package/dist/codex-app-server/forwarder.d.ts +92 -3
  16. package/dist/codex-app-server/forwarder.js +509 -56
  17. package/dist/codex-app-server/mapping.d.ts +3 -6
  18. package/dist/codex-app-server/mapping.js +174 -28
  19. package/dist/codex-app-server/mcp-startup.d.ts +13 -0
  20. package/dist/codex-app-server/mcp-startup.js +63 -0
  21. package/dist/codex-app-server/protocol.d.ts +64 -7
  22. package/dist/codex-app-server/ws-channel.js +19 -19
  23. package/dist/codex-home.js +2 -4
  24. package/dist/host.d.ts +64 -21
  25. package/dist/host.js +1330 -441
  26. package/dist/index.d.ts +1 -1
  27. package/dist/input-resources.d.ts +4 -0
  28. package/dist/input-resources.js +21 -5
  29. package/dist/models-catalog.d.ts +2 -1
  30. package/dist/models-catalog.js +94 -6
  31. package/dist/runner/child.d.ts +48 -21
  32. package/dist/runner/child.js +550 -48
  33. package/dist/runner/manager.d.ts +54 -13
  34. package/dist/runner/manager.js +479 -114
  35. package/dist/runner/protocol.d.ts +62 -19
  36. package/dist/runner/protocol.js +5 -0
  37. package/dist/runner/startup-policy.d.ts +7 -0
  38. package/dist/runner/startup-policy.js +10 -0
  39. package/dist/terminal/claude-tui.d.ts +3 -1
  40. package/dist/terminal/claude-tui.js +3 -1
  41. package/dist/terminal/registry.js +3 -2
  42. package/dist/terminal/tmux.d.ts +50 -7
  43. package/dist/terminal/tmux.js +168 -47
  44. package/package.json +4 -3
@@ -1,9 +1,3 @@
1
- /**
2
- * Pure codex app-server notification → {@link AgentEvent} mapping. Extracted from
3
- * the executor so BOTH the per-turn executor and the persistent session
4
- * forwarder (which mirrors every thread turn — web- AND TUI-initiated — into the
5
- * canonical log, reference implementation's codex-native model) share one mapping.
6
- */
7
1
  import type { AgentEvent } from "@rynx-ai/core";
8
2
  import type { ThreadItem } from "./protocol.js";
9
3
  export interface CodexMapResult {
@@ -11,8 +5,11 @@ export interface CodexMapResult {
11
5
  finalText?: string;
12
6
  usage?: Record<string, unknown>;
13
7
  turnCompleted?: boolean;
8
+ turnInterrupted?: boolean;
14
9
  fatalError?: Error;
15
10
  }
11
+ export declare function codexTurnStatus(turn: unknown): string | undefined;
12
+ export declare function codexResumeTerminalStatus(turn: unknown): "idle" | "failed" | undefined;
16
13
  /** Map one thread item (`item/started` | `item/completed`) to events. */
17
14
  export declare function mapCodexItem(method: string, item: ThreadItem): CodexMapResult;
18
15
  /** Map one codex app-server notification to events (+ turn/usage/error signals). */
@@ -1,12 +1,105 @@
1
+ /**
2
+ * Pure codex app-server notification → {@link AgentEvent} mapping. Extracted from
3
+ * the executor so BOTH the per-turn executor and the persistent session
4
+ * forwarder (which mirrors every thread turn — web- AND TUI-initiated — into the
5
+ * canonical log, reference implementation's codex-native model) share one mapping.
6
+ */
7
+ import { extname, isAbsolute } from "node:path";
8
+ function isRecord(value) {
9
+ return value !== null && typeof value === "object" && !Array.isArray(value);
10
+ }
11
+ const CODEX_AUTH_ERROR_FRAGMENTS = [
12
+ "401",
13
+ "403",
14
+ "unauthorized",
15
+ "authentication",
16
+ "not logged in",
17
+ "not authenticated",
18
+ "log in",
19
+ "login",
20
+ "sign in",
21
+ "re-authenticate",
22
+ "reauthenticate",
23
+ "credentials",
24
+ "access token",
25
+ "token expired",
26
+ "expired token",
27
+ "session expired",
28
+ "api key",
29
+ ];
30
+ const CODEX_REAUTH_HINT = "Codex needs you to re-authenticate. Run `codex login` and retry.";
31
+ function codexErrorMessage(error) {
32
+ for (const key of ["message", "error", "text", "detail"]) {
33
+ const value = error[key];
34
+ if (typeof value === "string" && value.trim())
35
+ return value.trim();
36
+ }
37
+ return "Codex turn ended with an unspecified error.";
38
+ }
39
+ function codexErrorIsAuth(error, message) {
40
+ const info = error.codexErrorInfo;
41
+ let variant;
42
+ let httpStatusCode;
43
+ if (typeof info === "string") {
44
+ variant = info;
45
+ }
46
+ else if (isRecord(info)) {
47
+ variant = info.type ?? info.kind ?? info.variant;
48
+ httpStatusCode = info.httpStatusCode;
49
+ }
50
+ if (typeof variant === "string" && variant.toLowerCase() === "unauthorized")
51
+ return true;
52
+ if (httpStatusCode === 401 || httpStatusCode === 403)
53
+ return true;
54
+ const lowered = message.toLowerCase();
55
+ return CODEX_AUTH_ERROR_FRAGMENTS.some((fragment) => lowered.includes(fragment));
56
+ }
1
57
  function turnError(error, fallback) {
2
- if (typeof error === "string" && error.trim())
3
- return new Error(error);
4
- if (error && typeof error === "object" && "message" in error) {
5
- const message = error.message;
6
- if (typeof message === "string" && message.trim())
7
- return new Error(message);
58
+ if (!error)
59
+ return new Error(fallback);
60
+ const message = codexErrorMessage(error);
61
+ return new Error(codexErrorIsAuth(error, message)
62
+ ? `${message}\n\n${CODEX_REAUTH_HINT}`
63
+ : message);
64
+ }
65
+ export function codexTurnStatus(turn) {
66
+ if (!isRecord(turn))
67
+ return undefined;
68
+ const status = turn.status;
69
+ if (typeof status === "string")
70
+ return status;
71
+ if (!isRecord(status))
72
+ return undefined;
73
+ const value = status.type ?? status.status;
74
+ return typeof value === "string" ? value : undefined;
75
+ }
76
+ export function codexResumeTerminalStatus(turn) {
77
+ if (terminalTurnError(turn, "turn/completed"))
78
+ return "failed";
79
+ const status = codexTurnStatus(turn);
80
+ if (status === "completed" || status === "interrupted" ||
81
+ status === "cancelled" || status === "canceled") {
82
+ return "idle";
83
+ }
84
+ if (status === "failed" || status === "errored")
85
+ return "failed";
86
+ return undefined;
87
+ }
88
+ function terminalTurnError(turn, method) {
89
+ if (!isRecord(turn)) {
90
+ return method === "turn/failed" ? new Error("Codex turn failed") : undefined;
91
+ }
92
+ let error = turn.error;
93
+ if (!isRecord(error) && Array.isArray(turn.items)) {
94
+ error = turn.items.find((item) => isRecord(item) && item.type === "error");
8
95
  }
9
- return new Error(fallback);
96
+ if (isRecord(error))
97
+ return turnError(error, "Codex turn failed");
98
+ const status = codexTurnStatus(turn);
99
+ if (method === "turn/failed" || status === "failed" || status === "errored") {
100
+ return turnError(undefined, "Codex turn failed");
101
+ }
102
+ return undefined;
10
103
  }
11
104
  function webSearchInput(item) {
12
105
  const action = item.action;
@@ -29,6 +122,23 @@ function webSearchInput(item) {
29
122
  }
30
123
  return { id: item.id, query: item.query };
31
124
  }
125
+ function fileChangeSummary(changes) {
126
+ const lines = [];
127
+ for (const change of changes) {
128
+ if (!isRecord(change))
129
+ continue;
130
+ const kind = isRecord(change.kind) && typeof change.kind.type === "string" && change.kind.type
131
+ ? change.kind.type
132
+ : "change";
133
+ lines.push(`${kind} ${String(change.path)}`);
134
+ }
135
+ return lines.join("\n");
136
+ }
137
+ function isSupportedAbsoluteImagePath(path) {
138
+ if (!isAbsolute(path))
139
+ return false;
140
+ return [".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg"].includes(extname(path).toLowerCase());
141
+ }
32
142
  /** Map one thread item (`item/started` | `item/completed`) to events. */
33
143
  export function mapCodexItem(method, item) {
34
144
  const events = [];
@@ -58,16 +168,18 @@ export function mapCodexItem(method, item) {
58
168
  return { events };
59
169
  }
60
170
  case "fileChange": {
171
+ const fileChange = item;
172
+ const changes = Array.isArray(fileChange.changes) ? fileChange.changes : [];
61
173
  events.push({
62
174
  type: "tool",
63
175
  event: isStart ? "on_tool_start" : "on_tool_end",
64
- name: "file_change",
65
- input: isStart ? { id: item.id } : undefined,
176
+ name: "apply_patch",
177
+ input: isStart || isEnd ? { id: item.id, ...(changes.length ? { changes } : {}) } : undefined,
66
178
  output: isEnd
67
179
  ? {
68
180
  id: item.id,
69
- changes: item.changes,
70
- status: item.status,
181
+ status: fileChange.status,
182
+ aggregatedOutput: fileChangeSummary(changes),
71
183
  }
72
184
  : undefined,
73
185
  data: { method, item },
@@ -113,6 +225,30 @@ export function mapCodexItem(method, item) {
113
225
  });
114
226
  return { events };
115
227
  }
228
+ case "imageGeneration": {
229
+ const image = item;
230
+ events.push({
231
+ type: "tool",
232
+ event: isStart ? "on_tool_start" : "on_tool_end",
233
+ name: "image_generation",
234
+ input: isStart ? { id: image.id } : undefined,
235
+ output: isEnd
236
+ ? {
237
+ id: image.id,
238
+ status: image.status,
239
+ generatedImage: {
240
+ ...(!image.savedPath || !isSupportedAbsoluteImagePath(image.savedPath)
241
+ ? { result: image.result }
242
+ : {}),
243
+ revisedPrompt: image.revisedPrompt,
244
+ ...(image.savedPath ? { savedPath: image.savedPath } : {}),
245
+ },
246
+ }
247
+ : undefined,
248
+ data: { method, item: { ...image, result: image.result ? "[image data omitted]" : "" } },
249
+ });
250
+ return { events };
251
+ }
116
252
  case "agentMessage": {
117
253
  const text = item.text?.trim() ?? "";
118
254
  if (isEnd && text) {
@@ -122,7 +258,11 @@ export function mapCodexItem(method, item) {
122
258
  return { events };
123
259
  }
124
260
  case "plan": {
125
- events.push({ type: "runtime_debug", channel: "codexEvent", data: { method, item } });
261
+ const text = item.text?.trim() ?? "";
262
+ if (isEnd && text) {
263
+ events.push({ type: "message_completed", itemId: item.id, text });
264
+ return { events, finalText: text };
265
+ }
126
266
  return { events };
127
267
  }
128
268
  case "reasoning": {
@@ -151,23 +291,18 @@ export function mapCodexNotification(method, params) {
151
291
  return { events };
152
292
  case "turn/started":
153
293
  return { events };
154
- case "turn/completed": {
294
+ case "turn/completed":
295
+ case "turn/failed": {
155
296
  const turnPayload = typed.params?.turn;
156
- if (turnPayload?.status === "failed") {
157
- return {
158
- events,
159
- fatalError: turnError(turnPayload.error, "Codex turn failed"),
160
- turnCompleted: true,
161
- };
162
- }
163
- if (turnPayload?.status === "interrupted") {
164
- return {
165
- events,
166
- fatalError: turnError(turnPayload.error, "Codex turn was interrupted"),
167
- turnCompleted: true,
168
- };
169
- }
170
- return { events, turnCompleted: true };
297
+ const fatalError = terminalTurnError(turnPayload, typed.method);
298
+ const turnInterrupted = typed.method === "turn/completed" &&
299
+ ["interrupted", "cancelled", "canceled"].includes(codexTurnStatus(turnPayload) ?? "");
300
+ return {
301
+ events,
302
+ ...(fatalError ? { fatalError } : {}),
303
+ turnCompleted: true,
304
+ ...(turnInterrupted ? { turnInterrupted: true } : {}),
305
+ };
171
306
  }
172
307
  case "turn/plan/updated": {
173
308
  const planParams = typed.params;
@@ -195,6 +330,17 @@ export function mapCodexNotification(method, params) {
195
330
  }
196
331
  return { events };
197
332
  }
333
+ case "item/plan/delta": {
334
+ const delta = typed.params.delta ?? "";
335
+ if (delta) {
336
+ events.push({
337
+ type: "token",
338
+ text: delta,
339
+ metadata: { source: "app_server", itemId: typed.params.itemId },
340
+ });
341
+ }
342
+ return { events };
343
+ }
198
344
  case "item/reasoning/summaryTextDelta":
199
345
  case "item/reasoning/textDelta": {
200
346
  const p = typed.params;
@@ -0,0 +1,13 @@
1
+ import type { CodexLineageRuntime } from "../codex-home.js";
2
+ export interface McpStartupPlan {
3
+ servers: string[];
4
+ settleTimeoutMs: number;
5
+ }
6
+ /**
7
+ * Read the MCP table fields needed for startup tracking. Provider config stays
8
+ * authoritative: malformed TOML disables the synthesized status rather than
9
+ * preventing the native CLI from reporting its own startup error.
10
+ */
11
+ export declare function parseMcpStartupToml(input: string): McpStartupPlan | null;
12
+ /** Enabled Provider-configured MCP servers and their synthesized settle window. */
13
+ export declare function readMcpStartupPlan(runtimeHome: string, runtime: CodexLineageRuntime): McpStartupPlan | null;
@@ -0,0 +1,63 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parse } from "smol-toml";
4
+ const DEFAULT_SERVER_TIMEOUT_MS = 10_000;
5
+ const SETTLE_GRACE_MS = 15_000;
6
+ const MAX_SETTLE_TIMEOUT_MS = 240_000;
7
+ function configNames(runtime) {
8
+ return runtime === "codex"
9
+ ? ["config.toml"]
10
+ : ["traecli.toml"];
11
+ }
12
+ function isTomlTable(value) {
13
+ return typeof value === "object" && value !== null && !Array.isArray(value);
14
+ }
15
+ /**
16
+ * Read the MCP table fields needed for startup tracking. Provider config stays
17
+ * authoritative: malformed TOML disables the synthesized status rather than
18
+ * preventing the native CLI from reporting its own startup error.
19
+ */
20
+ export function parseMcpStartupToml(input) {
21
+ let config;
22
+ try {
23
+ config = parse(input);
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ const servers = config.mcp_servers;
29
+ if (!isTomlTable(servers))
30
+ return null;
31
+ const enabled = Object.entries(servers)
32
+ .filter(([name, server]) => Boolean(name) && isTomlTable(server) && server.enabled !== false)
33
+ .sort(([left], [right]) => left.localeCompare(right));
34
+ if (enabled.length === 0)
35
+ return null;
36
+ const slowest = Math.max(DEFAULT_SERVER_TIMEOUT_MS, ...enabled.map(([, server]) => {
37
+ if (!isTomlTable(server))
38
+ return DEFAULT_SERVER_TIMEOUT_MS;
39
+ const seconds = server.startup_timeout_sec;
40
+ return typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0
41
+ ? seconds * 1_000
42
+ : DEFAULT_SERVER_TIMEOUT_MS;
43
+ }));
44
+ return {
45
+ servers: enabled.map(([name]) => name),
46
+ settleTimeoutMs: Math.min(slowest + SETTLE_GRACE_MS, MAX_SETTLE_TIMEOUT_MS),
47
+ };
48
+ }
49
+ /** Enabled Provider-configured MCP servers and their synthesized settle window. */
50
+ export function readMcpStartupPlan(runtimeHome, runtime) {
51
+ for (const name of configNames(runtime)) {
52
+ try {
53
+ const plan = parseMcpStartupToml(readFileSync(join(runtimeHome, name), "utf8"));
54
+ if (plan)
55
+ return plan;
56
+ }
57
+ catch {
58
+ // Missing/unreadable config: the Provider still owns startup; Rynx simply
59
+ // cannot synthesize per-server progress for this launch.
60
+ }
61
+ }
62
+ return null;
63
+ }
@@ -56,6 +56,21 @@ export interface GetAuthStatusResponse {
56
56
  authToken: string | null;
57
57
  requiresOpenaiAuth: boolean;
58
58
  }
59
+ /** Read the Provider's effective configuration through the app-server rather
60
+ * than parsing a runtime-specific config file (Traex has used both YAML and
61
+ * TOML across releases). `cwd` includes project-scoped configuration layers. */
62
+ export interface ConfigReadParams {
63
+ includeLayers: boolean;
64
+ cwd?: string | null;
65
+ }
66
+ export interface ConfigReadResponse {
67
+ config: {
68
+ model: string | null;
69
+ model_reasoning_effort: ReasoningEffort | null;
70
+ [key: string]: unknown;
71
+ };
72
+ [key: string]: unknown;
73
+ }
59
74
  export type AskForApproval = "untrusted" | "on-failure" | "on-request" | "never";
60
75
  export type SandboxMode = "read-only" | "workspace-write" | "danger-full-access";
61
76
  export type SandboxPolicy = {
@@ -87,6 +102,16 @@ export interface PermissionProfileModificationParams {
87
102
  export type PermissionSelection = string | PermissionProfileSelectionParams;
88
103
  /** Open since Codex App Server 0.144; values are advertised by `model/list`. */
89
104
  export type ReasoningEffort = string;
105
+ export type CollaborationModeKind = "plan" | "default";
106
+ /** Full Codex-lineage mode snapshot required by the App Server wire protocol. */
107
+ export interface CollaborationMode {
108
+ mode: CollaborationModeKind;
109
+ settings: {
110
+ model: string;
111
+ reasoning_effort: ReasoningEffort | null;
112
+ developer_instructions: string | null;
113
+ };
114
+ }
90
115
  export interface ThreadStartParams {
91
116
  model?: string | null;
92
117
  modelProvider?: string | null;
@@ -102,14 +127,18 @@ export interface ThreadStartParams {
102
127
  baseInstructions?: string | null;
103
128
  developerInstructions?: string | null;
104
129
  ephemeral?: boolean | null;
130
+ /** Native UI intent for a context-clearing fresh thread. */
131
+ sessionStartSource?: "clear" | string;
105
132
  }
106
133
  export interface ThreadResumeParams extends ThreadStartParams {
107
134
  threadId: string;
108
- /** When true, the resume response omits the thread's `turns` backlog (used to
109
- * SUBSCRIBE without re-replaying history). When false/absent, the response
110
- * carries `thread.turns[].items[]` — the backfill the forwarder replays for a
111
- * fresh thread's first turn (reference implementation's `_replay_resume_response`). */
135
+ /** Suppress rollout history when the caller only needs to load/subscribe. */
112
136
  excludeTurns?: boolean;
137
+ initialTurnsPage?: {
138
+ limit?: number | null;
139
+ sortDirection?: "asc" | "desc" | null;
140
+ itemsView?: "notLoaded" | "summary" | "full" | null;
141
+ } | null;
113
142
  }
114
143
  /** One turn in a resumed thread's backlog (`thread/resume` response). */
115
144
  export interface ResumedTurn {
@@ -125,6 +154,13 @@ export interface ResumedThread {
125
154
  turns?: ResumedTurn[];
126
155
  [key: string]: unknown;
127
156
  }
157
+ /** Settings selected by the Provider while starting or resuming a thread.
158
+ * Older app-server builds may omit them, so the bridge treats them as optional
159
+ * and falls back to `config/read` only for a fresh, not-yet-created thread. */
160
+ export interface ThreadRuntimeSettings {
161
+ model?: string;
162
+ reasoningEffort?: ReasoningEffort | null;
163
+ }
128
164
  export interface ThreadDescriptor {
129
165
  id: string;
130
166
  cwd: string;
@@ -155,6 +191,7 @@ export interface TurnStartParams {
155
191
  permissions?: PermissionSelection | null;
156
192
  model?: string | null;
157
193
  effort?: ReasoningEffort | null;
194
+ collaborationMode?: CollaborationMode | null;
158
195
  }
159
196
  export interface TurnInterruptParams {
160
197
  threadId: string;
@@ -169,7 +206,7 @@ export interface TurnSteerParams {
169
206
  expectedTurnId: string;
170
207
  input: UserInput[];
171
208
  }
172
- export type TurnStatus = "completed" | "interrupted" | "failed" | "inProgress";
209
+ export type TurnStatus = "completed" | "interrupted" | "cancelled" | "canceled" | "failed" | "errored" | "inProgress";
173
210
  export interface TurnPlanStep {
174
211
  step: string;
175
212
  status: "pending" | "inProgress" | "completed";
@@ -252,7 +289,16 @@ export interface WebSearchItem extends ThreadItemBase {
252
289
  type: "other";
253
290
  } | null;
254
291
  }
255
- export type ThreadItem = UserMessageItem | AgentMessageItem | ReasoningItem | PlanItem | CommandExecutionItem | FileChangeItem | McpToolCallItem | DynamicToolCallItem | WebSearchItem | (ThreadItemBase & Record<string, unknown>);
292
+ export interface ImageGenerationItem extends ThreadItemBase {
293
+ type: "imageGeneration";
294
+ status: string;
295
+ revisedPrompt: string | null;
296
+ /** Base64 image bytes supplied when no durable saved path is available. */
297
+ result: string;
298
+ /** Codex App Server guarantees this is absolute when present. */
299
+ savedPath?: string;
300
+ }
301
+ export type ThreadItem = UserMessageItem | AgentMessageItem | ReasoningItem | PlanItem | CommandExecutionItem | FileChangeItem | McpToolCallItem | DynamicToolCallItem | WebSearchItem | ImageGenerationItem | (ThreadItemBase & Record<string, unknown>);
256
302
  export interface ThreadSummary {
257
303
  id?: string;
258
304
  threadId?: string;
@@ -302,7 +348,7 @@ export interface ThreadSettingsUpdateParams {
302
348
  serviceTier?: string | null;
303
349
  effort?: ReasoningEffort | null;
304
350
  summary?: string | null;
305
- collaborationMode?: Record<string, unknown> | null;
351
+ collaborationMode?: CollaborationMode | null;
306
352
  personality?: string | null;
307
353
  }
308
354
  export interface ModelListParams {
@@ -476,6 +522,14 @@ export interface QueueStatusNotificationParams {
476
522
  export type ServerNotification = {
477
523
  method: "thread/started";
478
524
  params: ThreadStartedNotificationParams;
525
+ } | {
526
+ method: "thread/settings/updated";
527
+ params: {
528
+ threadId: string;
529
+ threadSettings: {
530
+ collaborationMode?: CollaborationMode | null;
531
+ } & Record<string, unknown>;
532
+ };
479
533
  } | {
480
534
  method: "turn/started";
481
535
  params: TurnStartedNotificationParams;
@@ -497,6 +551,9 @@ export type ServerNotification = {
497
551
  } | {
498
552
  method: "item/agentMessage/delta";
499
553
  params: AgentMessageDeltaNotificationParams;
554
+ } | {
555
+ method: "item/plan/delta";
556
+ params: AgentMessageDeltaNotificationParams;
500
557
  } | {
501
558
  method: "item/reasoning/summaryTextDelta";
502
559
  params: ReasoningSummaryTextDeltaNotificationParams;
@@ -28,6 +28,7 @@ async function freeLoopbackPort() {
28
28
  });
29
29
  });
30
30
  }
31
+ const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
31
32
  export class WsRpcChannel {
32
33
  opts;
33
34
  child = null;
@@ -40,7 +41,7 @@ export class WsRpcChannel {
40
41
  url = "";
41
42
  constructor(opts) {
42
43
  this.opts = opts;
43
- this.readyTimeoutMs = opts.readyTimeoutMs ?? 15_000;
44
+ this.readyTimeoutMs = opts.readyTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
44
45
  }
45
46
  onLine(cb) {
46
47
  this.lineCb = cb;
@@ -114,7 +115,7 @@ export class WsRpcChannel {
114
115
  // eslint-disable-next-line no-constant-condition
115
116
  while (Date.now() < deadline) {
116
117
  try {
117
- return await this.tryConnect(url);
118
+ return await this.tryConnect(url, Math.max(1, deadline - Date.now()));
118
119
  }
119
120
  catch (error) {
120
121
  lastError = error;
@@ -123,9 +124,9 @@ export class WsRpcChannel {
123
124
  }
124
125
  throw new Error(`codex app-server ws did not become ready at ${url}: ${lastError?.message ?? "timeout"}`);
125
126
  }
126
- tryConnect(url) {
127
+ tryConnect(url, handshakeTimeout) {
127
128
  return new Promise((resolve, reject) => {
128
- const ws = new WebSocket(url);
129
+ const ws = new WebSocket(url, { handshakeTimeout });
129
130
  const onOpen = () => {
130
131
  ws.off("error", onError);
131
132
  resolve(ws);
@@ -166,7 +167,7 @@ export class ExternalWsChannel {
166
167
  /** The `ws://IP:PORT` of the already-running app-server to attach to. */
167
168
  url, opts = {}) {
168
169
  this.url = url;
169
- this.readyTimeoutMs = opts.readyTimeoutMs ?? 15_000;
170
+ this.readyTimeoutMs = opts.readyTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
170
171
  }
171
172
  onLine(cb) {
172
173
  this.lineCb = cb;
@@ -178,20 +179,16 @@ export class ExternalWsChannel {
178
179
  return this.ws?.readyState === WebSocket.OPEN;
179
180
  }
180
181
  async start() {
181
- const deadline = Date.now() + this.readyTimeoutMs;
182
- let lastError;
183
- while (Date.now() < deadline) {
184
- try {
185
- this.ws = await this.connect(this.url);
186
- break;
187
- }
188
- catch (error) {
189
- lastError = error;
190
- await new Promise((r) => setTimeout(r, 150));
191
- }
182
+ // A channel instance is reusable after an observer disconnect. Do not let
183
+ // the prior closed socket make a failed reconnect look successful, and arm
184
+ // close delivery for the newly connected socket.
185
+ this.ws = null;
186
+ this.closedEmitted = false;
187
+ try {
188
+ this.ws = await this.connect(this.url);
192
189
  }
193
- if (!this.ws) {
194
- throw new Error(`could not attach to app-server ws ${this.url}: ${lastError?.message ?? "timeout"}`);
190
+ catch (error) {
191
+ throw new Error(`could not attach to app-server ws ${this.url}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
195
192
  }
196
193
  this.ws.on("message", (data) => this.lineCb?.(data.toString()));
197
194
  this.ws.on("close", (code) => this.emitClose(code ?? null, null, null));
@@ -227,7 +224,10 @@ export class ExternalWsChannel {
227
224
  }
228
225
  connect(url) {
229
226
  return new Promise((resolve, reject) => {
230
- const ws = new WebSocket(url);
227
+ // The app-server owner has already completed its readiness probe. Use one
228
+ // bounded attach, then reuse this exact connection as the forwarder
229
+ // instead of running a second startup retry stage.
230
+ const ws = new WebSocket(url, { handshakeTimeout: this.readyTimeoutMs });
231
231
  const onOpen = () => {
232
232
  ws.off("error", onError);
233
233
  resolve(ws);
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { copyFileSync, cpSync, existsSync, mkdirSync, renameSync, rmSync, symlinkSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
- import { getRuntimeProfile, resolveRuntimeHome, SAFE_SKILL_NAME, } from "@rynx-ai/core";
5
+ import { assertSkillPathComponent, getRuntimeProfile, resolveRuntimeHome, } from "@rynx-ai/core";
6
6
  import { adoptLegacyRuntimeDirectory, legacyRuntimeStateRoot, runtimeSessionDigest, runtimeSessionStateDir, } from "./runtime-state-paths.js";
7
7
  /** Inherit the user's LIVE login by symlink (stays in sync). */
8
8
  const SYMLINK_FILES = ["auth.json"];
@@ -113,9 +113,7 @@ export function prepareRuntimeHome(sessionId, runtime, realHome = runtime === "c
113
113
  export function populateCodexSkills(codexHome, skills) {
114
114
  const skillsDir = join(codexHome, "skills");
115
115
  for (const skill of skills) {
116
- if (!SAFE_SKILL_NAME.test(skill.name)) {
117
- throw new Error(`unsafe skill name: ${skill.name}`);
118
- }
116
+ assertSkillPathComponent(skill.name);
119
117
  }
120
118
  if (skills.length === 0)
121
119
  return;