@rynx-ai/daemon 0.1.0

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,13 @@
1
+ import { type LoadedAgentSpec } from "@rynx-ai/core";
2
+ /** Absolute path to an agent's spec file. */
3
+ export declare function agentSpecPath(id: string): string;
4
+ /**
5
+ * Validate + atomically write a spec into the agent's config directory. The id
6
+ * is the directory name and is never persisted inside the file (any `id` in the
7
+ * input is dropped by the schema). Throws on a schema violation.
8
+ */
9
+ export declare function writeAgentSpec(id: string, spec: Record<string, unknown>): LoadedAgentSpec;
10
+ /** Remove an agent (its whole config directory). Returns false if it didn't exist. */
11
+ export declare function removeAgentSpec(id: string): boolean;
12
+ /** Write a minimal, valid starter spec for `rynx agent add`. */
13
+ export declare function scaffoldAgentSpec(id: string): LoadedAgentSpec;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `~/.rynx/agents/<id>/agent.json` — CLI/web-managed declarative agents. Each
3
+ * agent is a directory whose **name is the agent id**; the spec lives at
4
+ * `agent.json` inside it (alongside any instruction markdown the spec references).
5
+ *
6
+ * Reading + validation live in `@rynx-ai/core` (`loadAgentSpec` / `listAgentSpecs`);
7
+ * this module owns the write/remove side (atomic tmp+rename for the spec file).
8
+ */
9
+ import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
10
+ import { agentSpecSchema, assertValidAgentId, rynxAgentDir, rynxAgentSpecFile, } from "@rynx-ai/core";
11
+ /** Absolute path to an agent's spec file. */
12
+ export function agentSpecPath(id) {
13
+ return rynxAgentSpecFile(id);
14
+ }
15
+ /**
16
+ * Validate + atomically write a spec into the agent's config directory. The id
17
+ * is the directory name and is never persisted inside the file (any `id` in the
18
+ * input is dropped by the schema). Throws on a schema violation.
19
+ */
20
+ export function writeAgentSpec(id, spec) {
21
+ assertValidAgentId(id); // id is a directory name — reject path-escaping / unsafe ids
22
+ const parsed = agentSpecSchema.parse(spec); // unknown keys (incl. `id`) are stripped
23
+ mkdirSync(rynxAgentDir(id), { recursive: true });
24
+ const path = rynxAgentSpecFile(id);
25
+ const tmp = `${path}.tmp`;
26
+ writeFileSync(tmp, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
27
+ renameSync(tmp, path);
28
+ return { ...parsed, id };
29
+ }
30
+ /** Remove an agent (its whole config directory). Returns false if it didn't exist. */
31
+ export function removeAgentSpec(id) {
32
+ assertValidAgentId(id); // guard rmSync against `..`/path-escaping ids
33
+ const dir = rynxAgentDir(id);
34
+ if (!existsSync(dir)) {
35
+ return false;
36
+ }
37
+ rmSync(dir, { recursive: true, force: true });
38
+ return true;
39
+ }
40
+ /** Write a minimal, valid starter spec for `rynx agent add`. */
41
+ export function scaffoldAgentSpec(id) {
42
+ return writeAgentSpec(id, {
43
+ specVersion: 1,
44
+ name: id,
45
+ description: "A rynx agent.",
46
+ instructions: "You are a helpful agent.",
47
+ executor: {
48
+ runtime: "claude",
49
+ model: "claude-sonnet-4-6",
50
+ reasoningEffort: "medium",
51
+ // Runtime budget (edit/remove as needed): timeout is enforced on every
52
+ // runtime; retry maps to per-runtime env. maxIterations (claude only) and
53
+ // contextWindow (codex only) are also accepted.
54
+ timeout: 1800,
55
+ retry: { maxRetries: 2 },
56
+ },
57
+ osEnv: { sandbox: "danger-full-access", approvalPolicy: "never" },
58
+ tools: { allowed: [], disallowed: [], mcp: [] },
59
+ params: {},
60
+ });
61
+ }
@@ -0,0 +1,28 @@
1
+ export interface ChannelConfig {
2
+ /** Stable system-assigned id (never changes). */
3
+ id: string;
4
+ /** Human-visible, unique, editable label. */
5
+ name: string;
6
+ /** Channel type (which plugin-contributed factory). */
7
+ type: string;
8
+ options?: Record<string, unknown>;
9
+ }
10
+ export declare function listChannels(): ChannelConfig[];
11
+ export declare function getChannel(id: string): ChannelConfig | undefined;
12
+ /** Create a channel with a system-assigned id and a unique `name` label. */
13
+ export declare function createChannel(input: {
14
+ name: string;
15
+ type: string;
16
+ options?: Record<string, unknown>;
17
+ }): ChannelConfig;
18
+ export declare function renameChannel(id: string, name: string): void;
19
+ /** Merge credentials/options into a channel (e.g. the result of an authorize flow). */
20
+ export declare function setChannelOptions(id: string, options: Record<string, unknown>): void;
21
+ /** Remove a channel; its bound instance (if any) cascades away (FK ON DELETE CASCADE). */
22
+ export declare function removeChannel(id: string): void;
23
+ /**
24
+ * Bridge each channel's stored `options` into `process.env`, so a channel reads
25
+ * its credentials from the environment as usual. Existing env (including `.env`)
26
+ * wins — only undefined keys are filled.
27
+ */
28
+ export declare function applyChannelEnv(): void;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `channels` table CRUD — a channel is a plugin type + its config/credentials
3
+ * (`options`), configured and authorized on its own. No agent binding here;
4
+ * that lives on an {@link import("./instance-store.js")} instance.
5
+ *
6
+ * Credentials stay in each channel's `options` (and `.env`); {@link applyChannelEnv}
7
+ * bridges them into `process.env` at boot, with explicit env always winning.
8
+ */
9
+ import { randomUUID } from "node:crypto";
10
+ import { db } from "./db.js";
11
+ function parseOptions(raw) {
12
+ if (!raw)
13
+ return undefined;
14
+ try {
15
+ const parsed = JSON.parse(raw);
16
+ return parsed && typeof parsed === "object" ? parsed : undefined;
17
+ }
18
+ catch {
19
+ return undefined;
20
+ }
21
+ }
22
+ function toConfig(row) {
23
+ return { id: row.id, name: row.name, type: row.type, options: parseOptions(row.options) };
24
+ }
25
+ export function listChannels() {
26
+ const rows = db().prepare("SELECT * FROM channels ORDER BY name").all();
27
+ return rows.map(toConfig);
28
+ }
29
+ export function getChannel(id) {
30
+ const row = db().prepare("SELECT * FROM channels WHERE id = ?").get(id);
31
+ return row ? toConfig(row) : undefined;
32
+ }
33
+ /** Create a channel with a system-assigned id and a unique `name` label. */
34
+ export function createChannel(input) {
35
+ const id = randomUUID();
36
+ const now = new Date().toISOString();
37
+ db()
38
+ .prepare(`INSERT INTO channels (id, name, type, options, created_at, updated_at)
39
+ VALUES (@id, @name, @type, @options, @now, @now)`)
40
+ .run({
41
+ id,
42
+ name: input.name,
43
+ type: input.type,
44
+ options: input.options ? JSON.stringify(input.options) : null,
45
+ now,
46
+ });
47
+ return getChannel(id);
48
+ }
49
+ export function renameChannel(id, name) {
50
+ db().prepare("UPDATE channels SET name = ?, updated_at = ? WHERE id = ?").run(name, new Date().toISOString(), id);
51
+ }
52
+ /** Merge credentials/options into a channel (e.g. the result of an authorize flow). */
53
+ export function setChannelOptions(id, options) {
54
+ const current = getChannel(id);
55
+ if (!current)
56
+ return;
57
+ const merged = { ...(current.options ?? {}), ...options };
58
+ db()
59
+ .prepare("UPDATE channels SET options = ?, updated_at = ? WHERE id = ?")
60
+ .run(JSON.stringify(merged), new Date().toISOString(), id);
61
+ }
62
+ /** Remove a channel; its bound instance (if any) cascades away (FK ON DELETE CASCADE). */
63
+ export function removeChannel(id) {
64
+ db().prepare("DELETE FROM channels WHERE id = ?").run(id);
65
+ }
66
+ /**
67
+ * Bridge each channel's stored `options` into `process.env`, so a channel reads
68
+ * its credentials from the environment as usual. Existing env (including `.env`)
69
+ * wins — only undefined keys are filled.
70
+ */
71
+ export function applyChannelEnv() {
72
+ for (const channel of listChannels()) {
73
+ if (!channel.options)
74
+ continue;
75
+ for (const [key, value] of Object.entries(channel.options)) {
76
+ if (typeof value === "string" && process.env[key] === undefined) {
77
+ process.env[key] = value;
78
+ }
79
+ }
80
+ }
81
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,284 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `rynx` — the daemon CLI.
4
+ *
5
+ * rynx start | stop | restart | status | logs process lifecycle (pm2)
6
+ * rynx plugin add <spec> | ls | rm <name> | enable <name> | disable <name>
7
+ * rynx agent list | show <name> | add <name> | rm <name>
8
+ *
9
+ * The CLI owns process lifecycle, plugin install/registry, and the file-based
10
+ * declarative agent specs. Channel instances (a channel + an agent) are
11
+ * configured in the web control console (`rynx start`, then open it).
12
+ *
13
+ * Hand-rolled arg dispatch (no arg-parser dep) to match the repo's zero-dep CLI
14
+ * style.
15
+ */
16
+ import { spawn } from "node:child_process";
17
+ import { existsSync } from "node:fs";
18
+ import { createRequire } from "node:module";
19
+ import path from "node:path";
20
+ import { confirm, isCancel } from "@clack/prompts";
21
+ import { listAgentSpecs, loadAgentSpec, loadConfig, rynxAgentsDir } from "@rynx-ai/core";
22
+ import { agentSpecPath, removeAgentSpec, scaffoldAgentSpec } from "./agent-file.js";
23
+ import { dbPath } from "./db.js";
24
+ import { cleanupLegacySessions } from "./migrations/cleanup-legacy-sessions.js";
25
+ import { installPlugin, uninstallPlugin } from "./plugin-installer.js";
26
+ import { getPlugin, listPlugins, setPluginEnabled } from "./plugin-store.js";
27
+ import { startDaemon, statusDaemon, stopDaemon, streamLogs } from "./pm2.js";
28
+ import { runDoctor, runSetup } from "./setup.js";
29
+ import { runUpdate } from "./update.js";
30
+ const USAGE = `Usage: rynx <command>
31
+
32
+ Setup:
33
+ setup interactive wizard — configure ~/.rynx/config.json step by step
34
+ doctor read-only health check (creates nothing; non-zero on failure)
35
+
36
+ Lifecycle:
37
+ start start the resident server (pm2-supervised)
38
+ restart reload the running server
39
+ stop stop and remove the server
40
+ status print whether the server is running
41
+ logs tail the server logs
42
+ update [version] upgrade @rynx-ai/daemon (npm) + restart (--check: report only)
43
+
44
+ Plugins (~/.rynx/rynx.db · channel plugins):
45
+ plugin ls list installed plugins
46
+ plugin add <spec> [--force]
47
+ install from an npm spec or a local directory
48
+ (local is copied into ~/.rynx/plugins; --force overwrites a same-named plugin)
49
+ plugin rm <name> uninstall a plugin
50
+ plugin enable <name> enable / disable an installed plugin
51
+ plugin disable <name>
52
+ (plugin changes apply on \`rynx restart\`)
53
+
54
+ Agents (~/.rynx/agents/<id>/agent.json — dir name is the id):
55
+ agent list list agents (id · runtime · model · [display name])
56
+ agent show <id> print a parsed + validated agent spec
57
+ agent add <id> scaffold an agent dir + starter spec, then edit it
58
+ agent rm <id> remove an agent (its whole config dir)
59
+ (select an agent in a chat with \`/agent <id>\`;
60
+ set a fleet default via the RYNX_DEFAULT_AGENT env var)
61
+
62
+ Maintenance:
63
+ cleanup sessions [--dry-run]
64
+ delete legacy (pre-uuid) sessions so the system starts
65
+ clean on opaque ids (runs once at boot too). Stop the
66
+ daemon before running manually.
67
+
68
+ Emulator:
69
+ emulator <args…> passthrough to the bundled \`rynx-emulator\` CLI
70
+ (same commands/flags; run \`rynx emulator\` for its help)
71
+
72
+ Channel instances (a channel + an agent) are configured in the web control
73
+ console (run \`rynx start\`, then open the printed URL).
74
+ `;
75
+ function fail(message) {
76
+ console.error(message);
77
+ process.exit(2);
78
+ }
79
+ async function pluginCommand(sub, arg) {
80
+ switch (sub) {
81
+ case "ls":
82
+ case "list": {
83
+ const plugins = listPlugins();
84
+ console.log(`Plugins (db: ${dbPath()})`);
85
+ if (plugins.length === 0) {
86
+ console.log(" (none)");
87
+ return;
88
+ }
89
+ for (const p of plugins) {
90
+ const state = p.enabled ? "enabled" : "disabled";
91
+ console.log(` ${p.name.padEnd(16)} ${(p.version ?? "-").padEnd(10)} ${p.source.padEnd(6)} ${state}`);
92
+ }
93
+ return;
94
+ }
95
+ case "add":
96
+ case "install": {
97
+ if (!arg)
98
+ fail("plugin add: missing spec (an npm package or a local directory)");
99
+ const force = process.argv.includes("--force");
100
+ const result = await installPlugin(arg, (line) => console.log(line), {
101
+ onConflict: async (existing) => {
102
+ if (force)
103
+ return true;
104
+ const desc = `${existing.source}${existing.version ? ` @${existing.version}` : ""}`;
105
+ if (!process.stdin.isTTY) {
106
+ fail(`plugin "${existing.name}" already installed (${desc}). ` +
107
+ `Re-run with --force, or \`rynx plugin rm ${existing.name}\` first.`);
108
+ }
109
+ const yes = await confirm({
110
+ message: `插件 "${existing.name}" 已存在(${desc}),覆盖吗?`,
111
+ initialValue: false,
112
+ });
113
+ return !isCancel(yes) && yes === true;
114
+ },
115
+ });
116
+ if (result.skipped) {
117
+ console.log(`已取消,保留现有插件 "${result.name}"。`);
118
+ }
119
+ else {
120
+ console.log(`Plugin "${result.name}" (${result.source}${result.version ? ` @${result.version}` : ""}) installed. Run \`rynx restart\` to apply.`);
121
+ }
122
+ return;
123
+ }
124
+ case "rm":
125
+ case "remove":
126
+ case "uninstall": {
127
+ if (!arg)
128
+ fail("plugin rm: missing plugin name");
129
+ if (!(await uninstallPlugin(arg, (line) => console.log(line))))
130
+ fail(`plugin "${arg}" not found`);
131
+ console.log(`Plugin "${arg}" uninstalled. Run \`rynx restart\` to apply.`);
132
+ return;
133
+ }
134
+ case "enable":
135
+ case "disable": {
136
+ if (!arg)
137
+ fail(`plugin ${sub}: missing plugin name`);
138
+ if (!getPlugin(arg))
139
+ fail(`plugin "${arg}" not found`);
140
+ setPluginEnabled(arg, sub === "enable");
141
+ console.log(`Plugin "${arg}" ${sub}d. Run \`rynx restart\` to apply.`);
142
+ return;
143
+ }
144
+ default:
145
+ fail(USAGE);
146
+ }
147
+ }
148
+ async function agentCommand(sub, name) {
149
+ switch (sub) {
150
+ case "list": {
151
+ const specs = await listAgentSpecs();
152
+ console.log(`Agents (dir: ${rynxAgentsDir()})`);
153
+ if (specs.length === 0) {
154
+ console.log(" (none)");
155
+ return;
156
+ }
157
+ for (const spec of specs) {
158
+ const runtime = spec.executor?.runtime ?? "(default)";
159
+ const model = spec.executor?.model ?? "(default)";
160
+ const display = spec.name && spec.name !== spec.id ? ` (${spec.name})` : "";
161
+ console.log(` ${spec.id.padEnd(16)} ${runtime.padEnd(8)} ${model}${display}`);
162
+ }
163
+ return;
164
+ }
165
+ case "show": {
166
+ if (!name)
167
+ fail("agent show: missing agent name");
168
+ const spec = await loadAgentSpec(name);
169
+ if (!spec)
170
+ fail(`agent "${name}" not found at ${agentSpecPath(name)}`);
171
+ console.log(JSON.stringify(spec, null, 2));
172
+ return;
173
+ }
174
+ case "add": {
175
+ if (!name)
176
+ fail("agent add: missing agent name");
177
+ if (existsSync(agentSpecPath(name)))
178
+ fail(`agent "${name}" already exists at ${agentSpecPath(name)}`);
179
+ scaffoldAgentSpec(name);
180
+ console.log(`Agent "${name}" created at ${agentSpecPath(name)}. Edit it, then select it in a chat with \`/agent ${name}\`.`);
181
+ return;
182
+ }
183
+ case "rm":
184
+ case "remove": {
185
+ if (!name)
186
+ fail("agent rm: missing agent name");
187
+ if (!removeAgentSpec(name))
188
+ fail(`agent "${name}" not found`);
189
+ console.log(`Agent "${name}" removed.`);
190
+ return;
191
+ }
192
+ default:
193
+ fail(USAGE);
194
+ }
195
+ }
196
+ async function cleanupCommand(sub) {
197
+ if (sub !== "sessions")
198
+ fail("cleanup: only `cleanup sessions` is supported");
199
+ const dryRun = process.argv.includes("--dry-run");
200
+ const result = cleanupLegacySessions({
201
+ codexStorePath: loadConfig().AGENT_SESSION_STORE_PATH,
202
+ dryRun,
203
+ log: (entry) => console.log(JSON.stringify(entry)),
204
+ });
205
+ const summary = `${result.deletedSessions} session(s), ${result.deletedLogRows} log row(s), ${result.prunedStoreEntries} store entr(ies)`;
206
+ console.log(dryRun ? `Dry run: would delete ${summary}.` : `Deleted ${summary}.`);
207
+ }
208
+ /** Pass `rynx emulator <args…>` through to the dependency's own CLI, verbatim.
209
+ * Resolved from @rynx-ai/emulator (dist/cli.js sits beside the main entry), so it
210
+ * runs the exact locked version — no PATH or npx involved. Stdio is inherited
211
+ * so interactive flows (the skill install agent picker) work. */
212
+ function emulatorPassthrough(args) {
213
+ const require = createRequire(import.meta.url);
214
+ const cliPath = path.join(path.dirname(require.resolve("@rynx-ai/emulator")), "cli.js");
215
+ const child = spawn(process.execPath, [cliPath, ...args], { stdio: "inherit" });
216
+ child.on("exit", (code) => process.exit(code ?? 1));
217
+ child.on("error", (error) => {
218
+ console.error(`rynx emulator: failed to launch the bundled CLI: ${error.message}`);
219
+ process.exit(1);
220
+ });
221
+ }
222
+ async function main() {
223
+ const [command, sub, arg] = process.argv.slice(2);
224
+ switch (command) {
225
+ case "setup":
226
+ process.exit(await runSetup());
227
+ break;
228
+ case "doctor":
229
+ process.exit(await runDoctor());
230
+ break;
231
+ case "update": {
232
+ const rest = process.argv.slice(3);
233
+ const resultIdx = rest.indexOf("--result-file");
234
+ const resultFile = resultIdx >= 0 ? rest[resultIdx + 1] : undefined;
235
+ const version = rest.find((a) => !a.startsWith("--") && a !== resultFile);
236
+ process.exit(await runUpdate({
237
+ check: rest.includes("--check"),
238
+ json: rest.includes("--json"),
239
+ version,
240
+ resultFile,
241
+ }));
242
+ break;
243
+ }
244
+ case "start":
245
+ process.exit(startDaemon({ force: false }));
246
+ break;
247
+ case "restart":
248
+ process.exit(startDaemon({ force: true }));
249
+ break;
250
+ case "stop":
251
+ process.exit(stopDaemon());
252
+ break;
253
+ case "status":
254
+ process.exit(statusDaemon());
255
+ break;
256
+ case "logs":
257
+ streamLogs();
258
+ break;
259
+ case "plugin":
260
+ await pluginCommand(sub, arg);
261
+ break;
262
+ case "agent":
263
+ await agentCommand(sub, arg);
264
+ break;
265
+ case "cleanup":
266
+ await cleanupCommand(sub);
267
+ break;
268
+ case "emulator":
269
+ emulatorPassthrough(process.argv.slice(3));
270
+ break;
271
+ case undefined:
272
+ case "help":
273
+ case "-h":
274
+ case "--help":
275
+ console.log(USAGE);
276
+ break;
277
+ default:
278
+ fail(`Unknown command: ${command}\n\n${USAGE}`);
279
+ }
280
+ }
281
+ main().catch((err) => {
282
+ console.error(err instanceof Error ? err.message : String(err));
283
+ process.exit(1);
284
+ });
@@ -0,0 +1,2 @@
1
+ import type { ControlPlaneDeps } from "@rynx-ai/server";
2
+ export declare function buildControlDeps(): ControlPlaneDeps;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Assemble the daemon-side {@link ControlPlaneDeps} the server needs to serve
3
+ * the control console: db-backed channel + instance mutators, file-backed agent
4
+ * specs, channel-type info from the loaded plugins, and a log tail. Plugins are
5
+ * managed via the CLI, not here. Keeps `~/.rynx` knowledge in the daemon (the
6
+ * composition root); the server stays storage-agnostic.
7
+ */
8
+ import { readFile } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ import { listAgentSpecs, loadAgentSpec, rynxHome } from "@rynx-ai/core";
11
+ import { createEmulatorControlDeps } from "@rynx-ai/emulator";
12
+ import { removeAgentSpec, writeAgentSpec } from "./agent-file.js";
13
+ import { getCatalogSkill, installCatalogSkill, listCatalogSkills, readCatalogSkillFile, removeCatalogSkill, } from "./skills-catalog.js";
14
+ import { createChannel, listChannels, removeChannel, renameChannel, setChannelOptions, } from "./channel-store.js";
15
+ import { createInstance, listInstances, removeInstance, setInstanceAgent, setInstanceEnabled, } from "./instance-store.js";
16
+ import { getChannelType, listChannelTypes } from "./registry.js";
17
+ import { createSessionMeta, getSessionMeta, listSessionMetas, removeSessionMeta, setSessionTitle, } from "./session-meta-store.js";
18
+ export function buildControlDeps() {
19
+ return {
20
+ // ── channel types (from loaded plugins) ──────────────────────────────
21
+ listChannelTypes: () => listChannelTypes().map((descriptor) => ({
22
+ type: descriptor.type,
23
+ configSchema: descriptor.configSchema,
24
+ authorize: Boolean(descriptor.authorize),
25
+ })),
26
+ // ── channels (config + credentials) ──────────────────────────────────
27
+ listChannels: () => listChannels(),
28
+ createChannel: (input) => ({ id: createChannel(input).id }),
29
+ setChannel: (id, patch) => {
30
+ if (patch.name !== undefined)
31
+ renameChannel(id, patch.name);
32
+ if (patch.options !== undefined)
33
+ setChannelOptions(id, patch.options);
34
+ },
35
+ removeChannel: (id) => {
36
+ removeChannel(id);
37
+ },
38
+ authorizeChannel: (type, emit, signal) => {
39
+ const descriptor = getChannelType(type);
40
+ if (!descriptor?.authorize) {
41
+ throw new Error(`channel type "${type}" has no authorize flow`);
42
+ }
43
+ return descriptor.authorize(emit, signal);
44
+ },
45
+ // ── instances (channel + agent binding) ──────────────────────────────
46
+ listInstancesConfig: () => listInstances().map((i) => ({
47
+ id: i.id,
48
+ channelId: i.channelId,
49
+ channelName: i.channelName,
50
+ type: i.type,
51
+ agent: i.agent,
52
+ enabled: i.enabled,
53
+ })),
54
+ createInstance: (input) => ({ id: createInstance(input).id }),
55
+ setInstance: (id, patch) => {
56
+ if (patch.agent !== undefined)
57
+ setInstanceAgent(id, patch.agent);
58
+ if (patch.enabled !== undefined)
59
+ setInstanceEnabled(id, patch.enabled);
60
+ },
61
+ removeInstance: (id) => {
62
+ removeInstance(id);
63
+ },
64
+ // ── declarative agents ───────────────────────────────────────────────
65
+ listAgents: async () => (await listAgentSpecs()).map((spec) => ({
66
+ id: spec.id,
67
+ name: spec.name,
68
+ runtime: spec.executor?.runtime,
69
+ model: spec.executor?.model,
70
+ })),
71
+ getAgent: (id) => loadAgentSpec(id),
72
+ writeAgent: (id, spec) => {
73
+ writeAgentSpec(id, spec);
74
+ },
75
+ removeAgent: (id) => removeAgentSpec(id),
76
+ // ── skills catalog ───────────────────────────────────────────────────
77
+ listSkills: () => listCatalogSkills(),
78
+ getSkill: (name) => getCatalogSkill(name),
79
+ readSkillFile: (name, file) => readCatalogSkillFile(name, file),
80
+ installSkill: (recipe) => installCatalogSkill(recipe),
81
+ removeSkill: (name) => removeCatalogSkill(name),
82
+ // ── emulator proxy (single seam: the emulator package owns the surface) ─
83
+ emulator: createEmulatorControlDeps(),
84
+ // ── control-plane sessions (meta persisted; status held by the server) ─
85
+ listSessionMetas: () => listSessionMetas(),
86
+ getSessionMeta: (id) => getSessionMeta(id),
87
+ createSessionMeta: (meta) => createSessionMeta(meta),
88
+ setSessionTitle: (id, title) => setSessionTitle(id, title),
89
+ removeSessionMeta: (id) => removeSessionMeta(id),
90
+ tailLogs: (instanceId, lines) => tailLogs(instanceId, lines),
91
+ };
92
+ }
93
+ /** Tail the daemon's merged pm2 logs, best-effort filtered by instance id. */
94
+ async function tailLogs(instanceId, lines) {
95
+ const dir = join(rynxHome(), "logs");
96
+ const files = await Promise.all(["out.log", "err.log"].map(async (name) => {
97
+ try {
98
+ return await readFile(join(dir, name), "utf8");
99
+ }
100
+ catch {
101
+ return "";
102
+ }
103
+ }));
104
+ let all = files.join("\n").split(/\r?\n/).filter(Boolean);
105
+ if (instanceId) {
106
+ all = all.filter((line) => line.includes(instanceId));
107
+ }
108
+ // out.log + err.log are two separate pm2 streams; a plain concat shows all of
109
+ // stdout then all of stderr, scrambling the timeline. Merge by the leading
110
+ // `YYYY-MM-DDTHH:MM:SS` timestamp pm2 prefixes each line with, so lines read in
111
+ // time order (stable sort keeps same-second lines in their per-stream order).
112
+ const tsOf = (line) => line.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)?.[0] ?? "";
113
+ all.sort((a, b) => tsOf(a).localeCompare(tsOf(b)));
114
+ return all.slice(-Math.max(1, lines)).join("\n");
115
+ }
@@ -0,0 +1,7 @@
1
+ import { type AppConfig } from "@rynx-ai/core";
2
+ import { startServer } from "@rynx-ai/server";
3
+ export interface StartRynxDaemonServerOptions {
4
+ config?: AppConfig;
5
+ warn?: (message: string) => void;
6
+ }
7
+ export declare function startRynxDaemonServer({ config, warn, }?: StartRynxDaemonServerOptions): Promise<Awaited<ReturnType<typeof startServer>>>;