@rynx-ai/runtime 0.1.11-beta.22 → 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
  }
@@ -22,6 +22,11 @@ export interface ClaudeForwarderSink {
22
22
  /** The current turn finished (a new user prompt, or the inactivity backstop).
23
23
  * `usage` carries the latest statusLine context/cost snapshot, when captured. */
24
24
  onTurnEnd(usage?: Record<string, unknown>): void;
25
+ /** The current turn ended because the user explicitly interrupted it. */
26
+ onTurnInterrupted?(usage?: Record<string, unknown>): void;
27
+ /** Escape was sent for the open Turn. Publish cancelled UI state immediately;
28
+ * the final close remains delayed so a late transcript record can join it. */
29
+ onTurnInterruptRequested?(): void;
25
30
  /** The runtime status changed according to Claude's session metadata. */
26
31
  onStatus?(status: ClaudeRunnerStatus, blockedOn?: string): void;
27
32
  /** The runtime went idle on the legacy hook fallback — surface idle WITHOUT finalizing
@@ -102,6 +107,8 @@ export declare class ClaudeLiveSession {
102
107
  private readonly seenClaudeSessionIds;
103
108
  private turnOpen;
104
109
  private currentTurnId?;
110
+ /** The open turn received an explicit Escape/Stop and must close cancelled. */
111
+ private turnInterrupted;
105
112
  /** The Turn opened from a pre-transcript interaction. Its unique provisional
106
113
  * id keeps responses distinct until the real prompt uuid can be adopted. */
107
114
  private syntheticTurn;
@@ -27,6 +27,10 @@ const MAX_SETTLED_INTERACTIONS = 512;
27
27
  const MAX_MESSAGE_CORRELATION_BACKLOG = 64;
28
28
  const MAX_SUBMISSION_OBSERVATIONS = 64;
29
29
  const INTERACTION_ACK_TIMEOUT_MS = 5_000;
30
+ // Claude writes this synthetic user record after Escape (including a tool-use
31
+ // interruption). It is lifecycle, not a new prompt. Kept aligned with
32
+ // Omnigent's `_CLAUDE_INTERRUPT_RECORD_RE`.
33
+ const CLAUDE_INTERRUPT_RECORD_RE = /^\[Request interrupted by user(?: for tool use)?\]$/;
30
34
  const INTERACTION_LEASE_TIMEOUT_MS = 30_000;
31
35
  function processIsAlive(pid) {
32
36
  try {
@@ -165,6 +169,8 @@ export class ClaudeLiveSession {
165
169
  seenClaudeSessionIds = new Set();
166
170
  turnOpen = false;
167
171
  currentTurnId;
172
+ /** The open turn received an explicit Escape/Stop and must close cancelled. */
173
+ turnInterrupted = false;
168
174
  /** The Turn opened from a pre-transcript interaction. Its unique provisional
169
175
  * id keeps responses distinct until the real prompt uuid can be adopted. */
170
176
  syntheticTurn = false;
@@ -846,6 +852,12 @@ export class ClaudeLiveSession {
846
852
  }
847
853
  const content = userStringContent(rec);
848
854
  if (content !== undefined) {
855
+ const firstLine = content.trim().split("\n", 1)[0] ?? "";
856
+ if (CLAUDE_INTERRUPT_RECORD_RE.test(firstLine)) {
857
+ this.noteInterrupted();
858
+ this.lastActivityAt = this.now();
859
+ return;
860
+ }
849
861
  const candidates = submissionCandidates(content);
850
862
  const queuedPromotion = rec.promptSource !== "typed" &&
851
863
  candidates.some((candidate) => this.consumeQueuedPromotion(candidate));
@@ -890,6 +902,7 @@ export class ClaudeLiveSession {
890
902
  this.closeTurn();
891
903
  this.currentTurnId = turnId;
892
904
  this.turnOpen = true;
905
+ this.turnInterrupted = false;
893
906
  this.syntheticTurn = false;
894
907
  this.providerIdleAt = null;
895
908
  this.openToolIds.clear();
@@ -1100,6 +1113,7 @@ export class ClaudeLiveSession {
1100
1113
  }
1101
1114
  this.providerIdleAt = null;
1102
1115
  this.turnOpen = true;
1116
+ this.turnInterrupted = false;
1103
1117
  this.sink.onTurnStart(this.currentTurnId);
1104
1118
  }
1105
1119
  /** Mirror a local `!` command as its own mini-turn: close any open turn, then
@@ -1120,8 +1134,10 @@ export class ClaudeLiveSession {
1120
1134
  * turn's close via the same short grace. The Escape has stopped claude, so this
1121
1135
  * does not race a still-running response. */
1122
1136
  noteInterrupted() {
1123
- if (!this.turnOpen)
1137
+ if (!this.turnOpen || this.turnInterrupted)
1124
1138
  return;
1139
+ this.turnInterrupted = true;
1140
+ this.sink.onTurnInterruptRequested?.();
1125
1141
  this.cancelPendingInteractions("turn_interrupted");
1126
1142
  this.stopSignalPending = false;
1127
1143
  if (this.statusPoller?.active)
@@ -1160,7 +1176,13 @@ export class ClaudeLiveSession {
1160
1176
  this.stopSignalPending = false;
1161
1177
  this.stopPendingAt = null;
1162
1178
  this.resetMessageCorrelation();
1163
- this.sink.onTurnEnd(this.statusUsage());
1179
+ const interrupted = this.turnInterrupted;
1180
+ this.turnInterrupted = false;
1181
+ const usage = this.statusUsage();
1182
+ if (interrupted && this.sink.onTurnInterrupted)
1183
+ this.sink.onTurnInterrupted(usage);
1184
+ else
1185
+ this.sink.onTurnEnd(usage);
1164
1186
  }
1165
1187
  closeTurnError(error) {
1166
1188
  if (!this.turnOpen)
@@ -1171,6 +1193,7 @@ export class ClaudeLiveSession {
1171
1193
  this.currentTurnId = undefined;
1172
1194
  this.syntheticTurn = false;
1173
1195
  this.openToolIds.clear();
1196
+ this.turnInterrupted = false;
1174
1197
  this.stopSignalPending = false;
1175
1198
  this.stopPendingAt = null;
1176
1199
  this.resetMessageCorrelation();
@@ -39,6 +39,8 @@ export interface CodexForwarderSink {
39
39
  onStatus?(note: string | undefined, statusKind?: "startup"): void;
40
40
  /** The current turn finished; `usage` is the runtime's raw snapshot if any. */
41
41
  onTurnEnd(usage?: Record<string, unknown>, reason?: "superseded"): void;
42
+ /** The provider confirmed that the active turn was explicitly interrupted. */
43
+ onTurnInterrupted?(usage?: Record<string, unknown>): void;
42
44
  /** Resume proved that the newest turn is terminal even though its live edge
43
45
  * was missed. This updates session state without replaying historical items. */
44
46
  onRecoveredTurnStatus?(status: "idle" | "failed", turnId: string | undefined, error?: Error): void;
@@ -299,7 +299,9 @@ export class CodexSessionForwarder {
299
299
  }
300
300
  this.scheduleCompletion(mapped.fatalError
301
301
  ? { kind: "error", error: mapped.fatalError }
302
- : { kind: "end", ...(mapped.usage ? { usage: mapped.usage } : {}) });
302
+ : mapped.turnInterrupted
303
+ ? { kind: "interrupted", ...(mapped.usage ? { usage: mapped.usage } : {}) }
304
+ : { kind: "end", ...(mapped.usage ? { usage: mapped.usage } : {}) });
303
305
  return;
304
306
  }
305
307
  // A late event from an older turn must not replace the app-server's active
@@ -359,7 +361,9 @@ export class CodexSessionForwarder {
359
361
  return;
360
362
  }
361
363
  if (mapped.turnCompleted) {
362
- this.scheduleCompletion({ kind: "end", ...(mapped.usage ? { usage: mapped.usage } : {}) });
364
+ this.scheduleCompletion(mapped.turnInterrupted
365
+ ? { kind: "interrupted", ...(mapped.usage ? { usage: mapped.usage } : {}) }
366
+ : { kind: "end", ...(mapped.usage ? { usage: mapped.usage } : {}) });
363
367
  }
364
368
  else {
365
369
  this.refreshCompletionGrace();
@@ -401,6 +405,12 @@ export class CodexSessionForwarder {
401
405
  this.pendingCompletionTurnId = null;
402
406
  if (completion.kind === "error")
403
407
  this.sink.onTurnError(completion.error);
408
+ else if (completion.kind === "interrupted") {
409
+ if (this.sink.onTurnInterrupted)
410
+ this.sink.onTurnInterrupted(completion.usage);
411
+ else
412
+ this.sink.onTurnEnd(completion.usage);
413
+ }
404
414
  else
405
415
  this.sink.onTurnEnd(completion.usage);
406
416
  }
@@ -11,6 +11,7 @@ export interface CodexMapResult {
11
11
  finalText?: string;
12
12
  usage?: Record<string, unknown>;
13
13
  turnCompleted?: boolean;
14
+ turnInterrupted?: boolean;
14
15
  fatalError?: Error;
15
16
  }
16
17
  export declare function codexTurnStatus(turn: unknown): string | undefined;
@@ -241,7 +241,14 @@ export function mapCodexNotification(method, params) {
241
241
  case "turn/failed": {
242
242
  const turnPayload = typed.params?.turn;
243
243
  const fatalError = terminalTurnError(turnPayload, typed.method);
244
- return { events, ...(fatalError ? { fatalError } : {}), turnCompleted: true };
244
+ const turnInterrupted = typed.method === "turn/completed" &&
245
+ ["interrupted", "cancelled", "canceled"].includes(codexTurnStatus(turnPayload) ?? "");
246
+ return {
247
+ events,
248
+ ...(fatalError ? { fatalError } : {}),
249
+ turnCompleted: true,
250
+ ...(turnInterrupted ? { turnInterrupted: true } : {}),
251
+ };
245
252
  }
246
253
  case "turn/plan/updated": {
247
254
  const planParams = typed.params;
package/dist/host.d.ts CHANGED
@@ -2,7 +2,7 @@ import { type ResolvedExecutionSnapshot, type ResolvedExecutionBudget, type Runt
2
2
  import { type AgentRuntimeId } from "@rynx-ai/core";
3
3
  import { type AppConfig } from "@rynx-ai/core";
4
4
  import { createCodexChildEnv } from "./codex-child-env.js";
5
- import type { InjectOutcome } from "./runner/protocol.js";
5
+ import type { InjectResult } from "./runner/protocol.js";
6
6
  import type { ResolveInteractionResult } from "./interactions.js";
7
7
  import { CodexAppServerClient } from "./codex-app-server/client.js";
8
8
  import type { ModelListResponse, ThreadGoal } from "./codex-app-server/protocol.js";
@@ -267,13 +267,13 @@ export declare class LocalAgentHost implements CodexCapabilities {
267
267
  * resumed from the persisted native id. Serialized per session so two
268
268
  * injects can't double-open a turn.
269
269
  *
270
- * Returns an {@link InjectOutcome}: `notLive` when this session has no live
270
+ * Returns an {@link InjectResult}: `notLive` when this session has no live
271
271
  * forwarder (caller may use the run path); `notReady`/`failed` are hard errors
272
272
  * the caller reports WITHOUT re-running (re-running double-writes alongside the
273
273
  * forwarder). Park-until-ready (~60s), aligning reference implementation's executor waiting for
274
274
  * the bridge instead of a short race that falls back to a second output path.
275
275
  */
276
- injectMessage(localThreadId: string, input: RuntimeUserInput | string): Promise<InjectOutcome>;
276
+ injectMessage(localThreadId: string, input: RuntimeUserInput | string): Promise<InjectResult>;
277
277
  /**
278
278
  * Interrupt the session's active turn — the web Stop button. codex: the
279
279
  * app-server `turn/interrupt` on the active `{threadId, turnId}` (exactly what
@@ -287,6 +287,11 @@ export declare class LocalAgentHost implements CodexCapabilities {
287
287
  stopLiveCodexSession(localThreadId: string, opts?: {
288
288
  deferClaudeInteractionCleanup?: boolean;
289
289
  }): void;
290
+ /** Tear down one codex-lineage native runtime without deleting its durable
291
+ * session-store binding. Omnigent couples its auxiliary Terminal, observer,
292
+ * forwarder and per-session app-server as one disposable runtime envelope;
293
+ * the next message recreates that envelope and cold-resumes the native id. */
294
+ teardownLiveCodexSession(localThreadId: string, error?: Error): boolean;
290
295
  /** Complete the second shutdown phase after the runner has killed all native
291
296
  * terminals and hook subprocesses. Must run before the runner process exits. */
292
297
  finalizeStoppedLiveSessions(): void;
@@ -308,7 +313,7 @@ export declare class LocalAgentHost implements CodexCapabilities {
308
313
  * serialized per session. Parks until the thread is ready AND the tmux injector
309
314
  * is (re)attached, then pastes; `injectViaTerminal` RAISES if the prompt never
310
315
  * appears (reference implementation RAISE), so a not-ready pane is a hard error — NOT a
311
- * fall-through-to-run signal. Returns {@link InjectOutcome}. */
316
+ * fall-through-to-run signal. Returns {@link InjectResult}. */
312
317
  private injectClaude;
313
318
  /** Park until the claude session's tmux injector is (re)attached by the
314
319
  * runner-child, or the deadline passes. Pane relaunch re-attaches it via