@rynx-ai/runtime 0.1.11-beta.23 → 0.1.11-beta.24

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.
@@ -1,15 +1,29 @@
1
1
  import type { SkillMeta } from "@rynx-ai/core";
2
+ export interface MaterializedClaudePlugin {
3
+ pluginDir: string;
4
+ }
5
+ /** Stable Claude `--plugin-dir` owned by one Rynx Session. */
6
+ export declare function claudePluginDir(sessionId: string): string;
2
7
  /**
3
- * Materialize a selected catalog skill subset into a throwaway claude
8
+ * Return the existing Session-owned plugin directory as-is. It is the durable
9
+ * Provider input for this Session, not an immutable shared cache: runtime files
10
+ * created inside it (for example Python `__pycache__`) remain part of that
11
+ * Session and do not trigger SkillRef replay.
12
+ */
13
+ export declare function reuseClaudePlugin(sessionId: string): Promise<MaterializedClaudePlugin | null>;
14
+ /**
15
+ * Materialize a selected catalog skill subset into a Session-owned Claude
4
16
  * `--plugin-dir` (a dir with `skills/<name>/SKILL.md` + a `.claude-plugin/plugin.json`
5
17
  * manifest — claude's mechanism for exposing skills outside its host dirs, since
6
18
  * it doesn't scan `~/.rynx/skills`). Consumed by the claude-native live launch.
7
19
  *
20
+ * The committed path is deterministic and survives Provider/runner restarts.
21
+ * An existing directory is returned as-is. The first materialization is built
22
+ * in a sibling staging directory and renamed into the stable path so a crash
23
+ * cannot expose a partially copied plugin.
24
+ *
8
25
  * Returns `null` when there's nothing to add. Rynx-managed Claude sessions load
9
26
  * no host/project/local setting sources, so this explicit plugin directory is
10
27
  * the only native skill channel; the selected subset is enforced exactly.
11
28
  */
12
- export declare function materializeSkillPlugin(selected: SkillMeta[] | null | undefined): Promise<{
13
- pluginDir?: string;
14
- cleanup?: () => Promise<void>;
15
- } | null>;
29
+ export declare function materializeClaudePlugin(sessionId: string, selected: SkillMeta[] | null | undefined): Promise<MaterializedClaudePlugin | null>;
@@ -1,26 +1,70 @@
1
- import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
- import { tmpdir } from "node:os";
1
+ import { cp, mkdir, mkdtemp, rename, rm, stat, writeFile } from "node:fs/promises";
3
2
  import path from "node:path";
3
+ import { runtimeSessionStateDir } from "../runtime-state-paths.js";
4
+ /** Stable Claude `--plugin-dir` owned by one Rynx Session. */
5
+ export function claudePluginDir(sessionId) {
6
+ return path.join(runtimeSessionStateDir(sessionId), "claude-plugin");
7
+ }
4
8
  /**
5
- * Materialize a selected catalog skill subset into a throwaway claude
9
+ * Return the existing Session-owned plugin directory as-is. It is the durable
10
+ * Provider input for this Session, not an immutable shared cache: runtime files
11
+ * created inside it (for example Python `__pycache__`) remain part of that
12
+ * Session and do not trigger SkillRef replay.
13
+ */
14
+ export async function reuseClaudePlugin(sessionId) {
15
+ const pluginDir = claudePluginDir(sessionId);
16
+ try {
17
+ return (await stat(pluginDir)).isDirectory() ? { pluginDir } : null;
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ /**
24
+ * Materialize a selected catalog skill subset into a Session-owned Claude
6
25
  * `--plugin-dir` (a dir with `skills/<name>/SKILL.md` + a `.claude-plugin/plugin.json`
7
26
  * manifest — claude's mechanism for exposing skills outside its host dirs, since
8
27
  * it doesn't scan `~/.rynx/skills`). Consumed by the claude-native live launch.
9
28
  *
29
+ * The committed path is deterministic and survives Provider/runner restarts.
30
+ * An existing directory is returned as-is. The first materialization is built
31
+ * in a sibling staging directory and renamed into the stable path so a crash
32
+ * cannot expose a partially copied plugin.
33
+ *
10
34
  * Returns `null` when there's nothing to add. Rynx-managed Claude sessions load
11
35
  * no host/project/local setting sources, so this explicit plugin directory is
12
36
  * the only native skill channel; the selected subset is enforced exactly.
13
37
  */
14
- export async function materializeSkillPlugin(selected) {
38
+ export async function materializeClaudePlugin(sessionId, selected) {
15
39
  if (selected == null || selected.length === 0)
16
40
  return null;
17
- const dir = await mkdtemp(path.join(tmpdir(), "rynx-claude-skills-"));
18
- const skillsRoot = path.join(dir, "skills");
19
- await mkdir(skillsRoot, { recursive: true });
20
- for (const skill of selected) {
21
- await cp(skill.dir, path.join(skillsRoot, skill.name), { recursive: true });
41
+ const reused = await reuseClaudePlugin(sessionId);
42
+ if (reused)
43
+ return reused;
44
+ const pluginDir = claudePluginDir(sessionId);
45
+ const stateDir = runtimeSessionStateDir(sessionId);
46
+ await mkdir(stateDir, { recursive: true, mode: 0o700 });
47
+ const stagingDir = await mkdtemp(path.join(stateDir, ".claude-plugin-stage-"));
48
+ try {
49
+ const skillsRoot = path.join(stagingDir, "skills");
50
+ await mkdir(skillsRoot, { recursive: true });
51
+ for (const skill of selected) {
52
+ await cp(skill.dir, path.join(skillsRoot, skill.name), { recursive: true });
53
+ }
54
+ await mkdir(path.join(stagingDir, ".claude-plugin"), { recursive: true });
55
+ await writeFile(path.join(stagingDir, ".claude-plugin", "plugin.json"), `${JSON.stringify({ name: "rynx-agent-skills", description: "Per-agent skill subset" }, null, 2)}\n`);
56
+ try {
57
+ await rename(stagingDir, pluginDir);
58
+ }
59
+ catch (error) {
60
+ const concurrent = await reuseClaudePlugin(sessionId);
61
+ if (!concurrent)
62
+ throw error;
63
+ return concurrent;
64
+ }
65
+ return { pluginDir };
66
+ }
67
+ finally {
68
+ await rm(stagingDir, { recursive: true, force: true });
22
69
  }
23
- await mkdir(path.join(dir, ".claude-plugin"), { recursive: true });
24
- await writeFile(path.join(dir, ".claude-plugin", "plugin.json"), `${JSON.stringify({ name: "rynx-agent-skills", description: "Per-agent skill subset" }, null, 2)}\n`);
25
- return { pluginDir: dir, cleanup: () => rm(dir, { recursive: true, force: true }) };
26
70
  }
package/dist/host.js CHANGED
@@ -9,7 +9,7 @@ import { getRuntimeProfile, } from "@rynx-ai/core";
9
9
  import { resolveRuntimeBinary, } from "@rynx-ai/core";
10
10
  import { createCodexChildEnv } from "./codex-child-env.js";
11
11
  import { prepareRuntimeHome, populateCodexSkills, runtimeHomePath, } from "./codex-home.js";
12
- import { materializeSkillPlugin } from "./claude/executor.js";
12
+ import { materializeClaudePlugin, reuseClaudePlugin, } from "./claude/executor.js";
13
13
  import { listClaudeModels } from "./claude/models.js";
14
14
  import { CodexAppServerClient, buildRuntimeUserInput, } from "./codex-app-server/client.js";
15
15
  import { buildAppServerBaseArgs, CodexTransportError, } from "./codex-app-server/transport.js";
@@ -529,16 +529,21 @@ export class LocalAgentHost {
529
529
  return sameSessionSnapshots(existingClaude.workspace, existingClaude.execution, snapshotOpts);
530
530
  }
531
531
  // Dedupe concurrent triggers so only one forwarder is ever created per
532
- // session (two would double-mirror every turn). Check this before the live
533
- // map: startLiveCodexSession publishes its partially initialized session
534
- // before thread/start completes, so a concurrent Terminal start must await
535
- // the thread id instead of launching a bare `codex --remote` pane.
532
+ // session (two would double-mirror every turn). startLiveCodexSession
533
+ // publishes its partially initialized session before thread/start completes,
534
+ // so wait for that startup and then adopt any newer mutable Turn settings.
536
535
  const inflight = this.liveEnsuring.get(localThreadId);
537
- if (inflight)
538
- return inflight;
536
+ if (inflight) {
537
+ if (!await inflight)
538
+ return false;
539
+ const startedLive = this.liveSessions.get(localThreadId);
540
+ return startedLive
541
+ ? updateLiveCodexTurnSettings(startedLive, snapshotOpts)
542
+ : false;
543
+ }
539
544
  const live = this.liveSessions.get(localThreadId);
540
545
  if (live)
541
- return sameSessionSnapshots(live.workspace, live.execution, snapshotOpts);
546
+ return updateLiveCodexTurnSettings(live, snapshotOpts);
542
547
  const started = this.startLiveCodexSession(localThreadId, emit, snapshotOpts);
543
548
  this.liveEnsuring.set(localThreadId, started);
544
549
  try {
@@ -670,6 +675,8 @@ export class LocalAgentHost {
670
675
  approvalPolicy,
671
676
  model,
672
677
  reasoningEffort,
678
+ appliedModel: model,
679
+ appliedReasoningEffort: reasoningEffort,
673
680
  ...(execution.instructions ? { instructions: execution.instructions } : {}),
674
681
  threadId: record?.codexSessionId ?? null,
675
682
  ready,
@@ -681,6 +688,8 @@ export class LocalAgentHost {
681
688
  injectLock: Promise.resolve(),
682
689
  pendingInjectedInputs: [],
683
690
  publishInjectedInput: () => undefined,
691
+ publishTurnAdmission: () => undefined,
692
+ publishTurnAdmissionFailure: () => undefined,
684
693
  publishInterrupted: () => undefined,
685
694
  interruptedResponseId: null,
686
695
  subscribing: false,
@@ -737,6 +746,21 @@ export class LocalAgentHost {
737
746
  for (const se of n.userInput(content))
738
747
  emitCurrent(se);
739
748
  };
749
+ live.publishTurnAdmission = () => {
750
+ emitCurrent({
751
+ type: "session.status",
752
+ sessionId: currentSessionId,
753
+ status: "running",
754
+ });
755
+ };
756
+ live.publishTurnAdmissionFailure = (message) => {
757
+ emitCurrent({
758
+ type: "session.status",
759
+ sessionId: currentSessionId,
760
+ status: "failed",
761
+ note: message,
762
+ });
763
+ };
740
764
  const clearPendingInputsForResponse = (responseId) => {
741
765
  if (!responseId)
742
766
  return;
@@ -1389,6 +1413,13 @@ export class LocalAgentHost {
1389
1413
  };
1390
1414
  live.pendingInjectedInputs.push(pendingInput);
1391
1415
  let injectionMethod = "turn/start";
1416
+ let admissionPublished = false;
1417
+ const publishAdmission = () => {
1418
+ if (admissionPublished)
1419
+ return;
1420
+ admissionPublished = true;
1421
+ live.publishTurnAdmission();
1422
+ };
1392
1423
  const forgetPendingInput = () => {
1393
1424
  const index = live.pendingInjectedInputs.indexOf(pendingInput);
1394
1425
  if (index >= 0)
@@ -1396,6 +1427,12 @@ export class LocalAgentHost {
1396
1427
  };
1397
1428
  const injectionClient = this.injectionClientFactory(live.appServerUrl);
1398
1429
  try {
1430
+ // Omnigent publishes codex-native running only after the native Terminal
1431
+ // is ready and the runner has accepted the message, but before the
1432
+ // short-lived app-server client initializes and starts the Turn. Claude
1433
+ // deliberately has no matching synthesized edge.
1434
+ if (!live.forwarder.isTurnOpen())
1435
+ publishAdmission();
1399
1436
  await injectionClient.ensureInitialized();
1400
1437
  // Match Omnigent's executor: each message uses one initialized client
1401
1438
  // that closes as soon as turn/start or turn/steer is acknowledged.
@@ -1419,15 +1456,34 @@ export class LocalAgentHost {
1419
1456
  return { outcome: "steered", responseId: `resp_codex_${steered.turnId}` };
1420
1457
  }
1421
1458
  }
1422
- // Carry the agent-spec model on the turn so a web-injected turn runs the
1423
- // agent's model even if the TUI's config default differs.
1459
+ // Match Omnigent's turn boundary: change the native thread settings
1460
+ // under the same lock immediately before starting the next Turn. Never
1461
+ // put settings on turn/start or mutate a Turn that is already open.
1462
+ const desiredModel = live.model;
1463
+ const desiredReasoningEffort = live.reasoningEffort;
1464
+ const settings = {
1465
+ threadId,
1466
+ ...(desiredModel !== live.appliedModel
1467
+ ? { model: desiredModel || null }
1468
+ : {}),
1469
+ ...(desiredReasoningEffort !== live.appliedReasoningEffort
1470
+ ? { effort: desiredReasoningEffort ?? null }
1471
+ : {}),
1472
+ };
1473
+ if (Object.keys(settings).length > 1) {
1474
+ await injectionClient.threadSettingsUpdate(settings);
1475
+ // Record exactly what this RPC carried. A concurrent settings save may
1476
+ // already have advanced the desired fields and must remain pending for
1477
+ // the following new Turn.
1478
+ live.appliedModel = desiredModel;
1479
+ live.appliedReasoningEffort = desiredReasoningEffort;
1480
+ }
1481
+ publishAdmission();
1424
1482
  const started = await injectionClient.turnStart({
1425
1483
  threadId,
1426
1484
  input: nativeInput,
1427
1485
  ...turnWorkspaceParams(live.runtime, live.workspace, live.sandbox),
1428
1486
  approvalPolicy: live.approvalPolicy,
1429
- ...(live.model ? { model: live.model } : {}),
1430
- ...(live.reasoningEffort ? { effort: live.reasoningEffort } : {}),
1431
1487
  });
1432
1488
  if (pendingInput.observed) {
1433
1489
  forgetPendingInput();
@@ -1449,6 +1505,13 @@ export class LocalAgentHost {
1449
1505
  const baseDetail = codexRpcError(error, injectionMethod);
1450
1506
  const startupDetail = live.forwarder.mcpStartupDetail();
1451
1507
  const detail = startupDetail ? `${baseDetail} (${startupDetail})` : baseDetail;
1508
+ // A turn may open on the observer while this short-lived client is
1509
+ // initializing, converting the submission to steer. A rejected steer
1510
+ // does not fail that already-running Turn; only a new-Turn admission
1511
+ // owns the terminal failed edge.
1512
+ if (admissionPublished && injectionMethod === "turn/start") {
1513
+ live.publishTurnAdmissionFailure(detail);
1514
+ }
1452
1515
  console.error(`[codex-live] session=${localThreadId} runtime=${live.runtime} injection failed: ${detail}`);
1453
1516
  throw nativeLiveFailure(live.runtime, "native_message_injection_failed", `message injection via ${injectionMethod} failed`, detail);
1454
1517
  }
@@ -1551,8 +1614,6 @@ export class LocalAgentHost {
1551
1614
  claude.forwarder.finalizeStop();
1552
1615
  }
1553
1616
  removeManagedClaudeSettings(claude.bridgeDir);
1554
- // Remove the throwaway skills --plugin-dir temp so it doesn't leak per launch.
1555
- void claude.skillCleanup?.();
1556
1617
  return;
1557
1618
  }
1558
1619
  const live = this.liveSessions.get(localThreadId);
@@ -1789,17 +1850,27 @@ export class LocalAgentHost {
1789
1850
  throw new CodexRuntimeError(`cannot launch Claude from ${execution.provider} execution snapshot`, 422, "invalid_execution_snapshot");
1790
1851
  }
1791
1852
  const cwd = workspace.cwd;
1792
- let snapshotSkills;
1793
- try {
1794
- snapshotSkills = await this.prepareExecutionSkills(execution);
1795
- }
1796
- catch (err) {
1797
- console.error(`[session-snapshot] session=${localThreadId} skill materialization failed: ${err instanceof Error ? err.message : String(err)}`);
1798
- return false;
1853
+ const hasSkillSnapshot = execution.skills.length > 0 || execution.pluginSkills.length > 0;
1854
+ let skillPlugin = hasSkillSnapshot
1855
+ ? await reuseClaudePlugin(localThreadId)
1856
+ : null;
1857
+ if (!skillPlugin && hasSkillSnapshot) {
1858
+ let snapshotSkills;
1859
+ try {
1860
+ snapshotSkills = await this.prepareExecutionSkills(execution);
1861
+ }
1862
+ catch (err) {
1863
+ console.error(`[session-snapshot] session=${localThreadId} skill materialization failed: ${err instanceof Error ? err.message : String(err)}`);
1864
+ return false;
1865
+ }
1866
+ try {
1867
+ skillPlugin = await materializeClaudePlugin(localThreadId, snapshotSkills.selectedSkills);
1868
+ }
1869
+ finally {
1870
+ await snapshotSkills.skillsCleanup();
1871
+ }
1799
1872
  }
1800
1873
  const model = execution.model ?? "";
1801
- const skillPlugin = await materializeSkillPlugin(snapshotSkills.selectedSkills);
1802
- void snapshotSkills.skillsCleanup();
1803
1874
  const permissionMode = execution.permissionMode;
1804
1875
  const launchExtraArgs = [
1805
1876
  ...(skillPlugin?.pluginDir ? ["--plugin-dir", skillPlugin.pluginDir] : []),
@@ -1876,7 +1947,6 @@ export class LocalAgentHost {
1876
1947
  launchExtraArgs,
1877
1948
  permissionMode,
1878
1949
  ...(forkIntent ? { forkIntent } : {}),
1879
- ...(skillPlugin?.cleanup ? { skillCleanup: skillPlugin.cleanup } : {}),
1880
1950
  };
1881
1951
  live.publishInterrupted = (responseId) => {
1882
1952
  if (live.interruptedResponseId === responseId)
@@ -2411,6 +2481,21 @@ function sameSessionSnapshots(workspace, execution, opts) {
2411
2481
  execution: opts.execution,
2412
2482
  }));
2413
2483
  }
2484
+ function sameImmutableSessionSnapshots(workspace, execution, opts) {
2485
+ const current = { ...execution, model: null, reasoningEffort: null };
2486
+ const requested = { ...opts.execution, model: null, reasoningEffort: null };
2487
+ return JSON.stringify(stableValue({ workspace, execution: current })) ===
2488
+ JSON.stringify(stableValue({ workspace: opts.workspace, execution: requested }));
2489
+ }
2490
+ function updateLiveCodexTurnSettings(live, opts) {
2491
+ if (!sameImmutableSessionSnapshots(live.workspace, live.execution, opts)) {
2492
+ return false;
2493
+ }
2494
+ live.execution = structuredClone(opts.execution);
2495
+ live.model = opts.execution.model ?? "";
2496
+ live.reasoningEffort = opts.execution.reasoningEffort ?? undefined;
2497
+ return true;
2498
+ }
2414
2499
  /** Freeze the Session's permission choice into the explicit managed settings.
2415
2500
  * Host/project settings may still contribute allow/deny rules, but changing
2416
2501
  * their defaultMode cannot silently mutate an existing Session on resume. */
@@ -340,6 +340,7 @@ export declare class RunnerManager implements AgentCapabilities {
340
340
  private isNativePaneBusy;
341
341
  private isManagedNativeHandle;
342
342
  private failActiveResponses;
343
+ private interruptActiveResponses;
343
344
  /** Track the provider-authoritative response lifecycle. Native-pane busy
344
345
  * classification consumes this level directly; output volume is separately
345
346
  * grounded in tmux's own activity clock. */
@@ -1335,6 +1335,7 @@ export class RunnerManager {
1335
1335
  catch {
1336
1336
  // The process signal below remains the authoritative shutdown path.
1337
1337
  }
1338
+ this.interruptActiveResponses(handle);
1338
1339
  this.failHandle(handle, reason, { failActiveResponses: false });
1339
1340
  }
1340
1341
  this.signalChild(handle.child, "SIGTERM", handle.processGroup);
@@ -1477,6 +1478,18 @@ export class RunnerManager {
1477
1478
  });
1478
1479
  }
1479
1480
  }
1481
+ interruptActiveResponses(handle) {
1482
+ const responseIds = [...handle.activeResponseIds];
1483
+ handle.activeResponseIds.clear();
1484
+ const sessionId = this.currentTerminalSessionId(handle, handle.key);
1485
+ for (const responseId of responseIds) {
1486
+ this.mirrorListener?.(sessionId, {
1487
+ type: "session.interrupted",
1488
+ sessionId,
1489
+ responseId,
1490
+ });
1491
+ }
1492
+ }
1480
1493
  /** Track the provider-authoritative response lifecycle. Native-pane busy
1481
1494
  * classification consumes this level directly; output volume is separately
1482
1495
  * grounded in tmux's own activity clock. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rynx-ai/runtime",
3
- "version": "0.1.11-beta.23",
3
+ "version": "0.1.11-beta.24",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/rynx-ai/rynx.git",
@@ -27,7 +27,7 @@
27
27
  "node-pty": "1.2.0-beta.15",
28
28
  "smol-toml": "1.7.1",
29
29
  "ws": "^8.21.0",
30
- "@rynx-ai/core": "0.1.11-beta.23"
30
+ "@rynx-ai/core": "0.1.11-beta.24"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/ws": "^8.18.1"