niahere 0.2.36 → 0.2.38

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.
package/README.md CHANGED
@@ -35,6 +35,7 @@ nia start # starts daemon + registers OS service
35
35
  - **Terminal chat** — REPL with session resume support
36
36
  - **Scheduled jobs** — recurring jobs and crons that run Claude and can message you back
37
37
  - **Persona system** — customizable identity, soul, owner profile, and on-demand memory
38
+ - **Agents** — domain specialists (marketer, senior-dev) via Claude Agent SDK subagents
38
39
  - **Skills** — loads skills from multiple directories, invokable as slash commands
39
40
  - **Cross-platform service** — launchd (macOS), systemd (Linux), service-aware restart
40
41
  - **MCP tools** — 18 tools for job management, messaging, memory, and channel control
@@ -58,12 +59,16 @@ nia version — show version
58
59
  nia job list — list all jobs
59
60
  nia job show [name] — full details + recent runs
60
61
  nia job add <n> <s> <p> — add a job (active hours only)
62
+ nia job add <n> <s> <p> --agent <name> — add a job using an agent
61
63
  nia job add <n> <s> <p> --always — add a cron (runs 24/7)
62
64
  nia job remove <name> — delete a job
63
65
  nia job enable / disable <n> — toggle a job
64
66
  nia job run <name> — run a job once
65
67
  nia job log [name] — show recent run history
66
68
 
69
+ nia agent list — list available agents
70
+ nia agent show <name> — show agent details and prompt
71
+
67
72
  nia db setup — install PostgreSQL + create database + migrate
68
73
  nia db migrate — run database migrations
69
74
  nia db status — check database connection
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niahere",
3
- "version": "0.2.36",
3
+ "version": "0.2.38",
4
4
  "description": "A personal AI assistant daemon — scheduled jobs, chat across Telegram and Slack, persona system, and visual identity.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -120,7 +120,7 @@ class SlackChannel implements Channel {
120
120
  }
121
121
  const queued = state.lock !== Promise.resolve();
122
122
  if (queued) log.debug({ key }, "slack: message queued behind active lock");
123
- state.lock = state.lock.then(fn, fn);
123
+ state.lock = state.lock.then(fn, fn).catch((err) => log.error({ err, key }, "unhandled error in locked handler"));
124
124
  }
125
125
 
126
126
  const self = this;
@@ -479,7 +479,13 @@ class SlackChannel implements Channel {
479
479
 
480
480
  log.info({ channel: msg.channel, key, text: text.slice(0, 100), isDm, watched: isWatched, attachments: attachments?.length || 0 }, "slack message received");
481
481
 
482
- const state = await getState(key);
482
+ let state: ChatState;
483
+ try {
484
+ state = await getState(key);
485
+ } catch (err) {
486
+ log.error({ err, key }, "slack: failed to create chat engine");
487
+ return;
488
+ }
483
489
 
484
490
  withLock(key, async () => {
485
491
  // Add thinking reaction inside the lock so cleanup is guaranteed
@@ -6,6 +6,7 @@ import { join } from "path";
6
6
  import { homedir } from "os";
7
7
  import { randomUUID } from "crypto";
8
8
  import { buildSystemPrompt } from "./identity";
9
+ import { getAgentDefinitions } from "../core/agents";
9
10
  import { Session, Message, ActiveEngine } from "../db/models";
10
11
  import type { Attachment, SendResult, StreamCallback, ActivityCallback, SendCallbacks, ChatEngine, EngineOptions } from "../types";
11
12
  import { truncate, formatToolUse } from "../utils/format-activity";
@@ -177,6 +178,11 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
177
178
  options.mcpServers = mcpServers;
178
179
  }
179
180
 
181
+ const agentDefs = getAgentDefinitions();
182
+ if (Object.keys(agentDefs).length > 0) {
183
+ options.agents = agentDefs;
184
+ }
185
+
180
186
  queryHandle = query({
181
187
  prompt: stream as any,
182
188
  options: options as any,
@@ -3,6 +3,7 @@ import { join } from "path";
3
3
  import { getPaths } from "../utils/paths";
4
4
  import { getEnvironmentPrompt, getModePrompt, getChannelPrompt } from "../prompts";
5
5
  import { getSkillsSummary } from "../core/skills";
6
+ import { getAgentsSummary } from "../core/agents";
6
7
  import type { Mode } from "../types";
7
8
 
8
9
  // Re-export for backwards compat
@@ -37,5 +38,8 @@ export function buildSystemPrompt(mode: Mode = "chat", channel: string = "termin
37
38
  const skills = getSkillsSummary();
38
39
  if (skills) parts.push(skills);
39
40
 
41
+ const agents = getAgentsSummary();
42
+ if (agents) parts.push(agents);
43
+
40
44
  return parts.join("\n\n");
41
45
  }
@@ -0,0 +1,47 @@
1
+ import { scanAgents } from "../core/agents";
2
+
3
+ export async function agentCommand(): Promise<void> {
4
+ const subcommand = process.argv[3];
5
+
6
+ switch (subcommand) {
7
+ case "list": {
8
+ const agents = scanAgents();
9
+ if (agents.length === 0) {
10
+ console.log("No agents found. Create agents in ~/.niahere/agents/<name>/AGENT.md");
11
+ } else {
12
+ for (const a of agents) {
13
+ const model = a.model ? ` (${a.model})` : "";
14
+ console.log(` ${a.name}${model} [${a.source}]`);
15
+ if (a.description) console.log(` ${a.description}`);
16
+ }
17
+ }
18
+ break;
19
+ }
20
+
21
+ case "show": {
22
+ const name = process.argv[4];
23
+ if (!name) {
24
+ console.error("Usage: nia agent show <name>");
25
+ process.exit(1);
26
+ }
27
+ const agents = scanAgents();
28
+ const agent = agents.find((a) => a.name === name);
29
+ if (!agent) {
30
+ console.error(`Agent "${name}" not found.`);
31
+ process.exit(1);
32
+ }
33
+ console.log(`Name: ${agent.name}`);
34
+ console.log(`Description: ${agent.description}`);
35
+ if (agent.model) console.log(`Model: ${agent.model}`);
36
+ console.log(`Source: ${agent.source}`);
37
+ console.log(`\n--- Prompt ---\n`);
38
+ console.log(agent.body);
39
+ break;
40
+ }
41
+
42
+ default:
43
+ console.log("Usage: nia agent <list|show>");
44
+ console.log(" list List all available agents");
45
+ console.log(" show <name> Show agent details and prompt");
46
+ }
47
+ }
package/src/cli/index.ts CHANGED
@@ -14,6 +14,7 @@ import { statusCommand } from "./status";
14
14
  import { sendCommand, telegramCommand, slackCommand } from "./channels";
15
15
  import { rulesCommand, memoryCommand } from "./self";
16
16
  import { watchCommand } from "./watch";
17
+ import { agentCommand } from "./agent";
17
18
 
18
19
  // Set LOG_LEVEL from config before anything else logs
19
20
  try {
@@ -286,6 +287,11 @@ switch (command) {
286
287
  break;
287
288
  }
288
289
 
290
+ case "agent": {
291
+ await agentCommand();
292
+ break;
293
+ }
294
+
289
295
  case "skills": {
290
296
  const { scanSkills: loadSkills } = await import("../core/skills");
291
297
  const filter = process.argv[3]; // e.g. "project", "nia", "shared", "claude"
@@ -485,6 +491,7 @@ switch (command) {
485
491
  console.log(" rules [show|reset] — view or reset rules.md");
486
492
  console.log(" memory [show|reset] — view or reset memory.md");
487
493
  console.log(" db <sub> — database setup/status/migrate");
494
+ console.log(" agent <sub> — list/show agents");
488
495
  console.log(" skills — list available skills");
489
496
  console.log(" watch <sub> — manage Slack watch channels");
490
497
  console.log(" validate — validate config.yaml");
package/src/cli/job.ts CHANGED
@@ -50,7 +50,8 @@ export async function jobCommand(): Promise<void> {
50
50
  for (const job of jobs) {
51
51
  const tag = job.always ? " always" : "";
52
52
  const type = job.scheduleType !== "cron" ? ` (${job.scheduleType})` : "";
53
- console.log(` ${job.enabled ? "●" : ""} ${job.name} ${job.schedule}${type}${tag}`);
53
+ const agentTag = job.agent ? ` [${job.agent}]` : "";
54
+ console.log(` ${job.enabled ? "●" : "○"} ${job.name} ${job.schedule}${type}${tag}${agentTag}`);
54
55
  }
55
56
  }
56
57
  });
@@ -75,12 +76,20 @@ export async function jobCommand(): Promise<void> {
75
76
  }
76
77
  }
77
78
 
79
+ // Parse --agent flag
80
+ let agent: string | undefined;
81
+ const agentIdx = cliArgs.indexOf("--agent");
82
+ if (agentIdx !== -1 && cliArgs[agentIdx + 1]) {
83
+ agent = cliArgs[agentIdx + 1];
84
+ cliArgs.splice(agentIdx, 2);
85
+ }
86
+
78
87
  const name = cliArgs[0];
79
88
  const schedule = cliArgs[1];
80
89
  const prompt = cliArgs.slice(2).join(" ");
81
90
 
82
91
  if (!name || !schedule || !prompt) {
83
- console.log('Usage: nia job add <name> <schedule> <prompt> [--always] [--type cron|interval|once]');
92
+ console.log('Usage: nia job add <name> <schedule> <prompt> [--always] [--type cron|interval|once] [--agent <name>]');
84
93
  fail('Example: nia job add heartbeat "*/10 * * * *" Check system health --always');
85
94
  }
86
95
 
@@ -93,7 +102,7 @@ export async function jobCommand(): Promise<void> {
93
102
  const config = getConfig();
94
103
  const nextRunAt = computeInitialNextRun(scheduleType, schedule, config.timezone);
95
104
  await withDb(async () => {
96
- await Job.create(name, schedule, prompt, always, scheduleType, nextRunAt);
105
+ await Job.create(name, schedule, prompt, always, scheduleType, nextRunAt, agent);
97
106
  console.log(`Job "${name}" added (${scheduleType}: ${schedule}).${always ? " (runs 24/7)" : ""}`);
98
107
  });
99
108
  } catch (err) {
@@ -178,6 +187,7 @@ export async function jobCommand(): Promise<void> {
178
187
  console.log(` schedule: ${job.schedule}`);
179
188
  console.log(` enabled: ${job.enabled}`);
180
189
  console.log(` type: ${job.always ? "cron (runs 24/7)" : "job (active hours only)"}`);
190
+ if (job.agent) console.log(` agent: ${job.agent}`);
181
191
  console.log(` prompt: ${job.prompt}`);
182
192
 
183
193
  const state = readState();
@@ -307,6 +317,7 @@ export async function jobCommand(): Promise<void> {
307
317
  console.log(" status [name] — quick status check");
308
318
  console.log(" add <name> <schedule> <prompt> — add a job (active hours only)")
309
319
  console.log(" --always — run 24/7 regardless of active hours");
320
+ console.log(" --agent <name> — assign an agent to the job");
310
321
  console.log(" update <name> [--schedule s] [--prompt p] [--always] — update a job");
311
322
  console.log(" remove <name> — delete a job");
312
323
  console.log(" enable <name> — enable a job");
package/src/cli/status.ts CHANGED
@@ -23,6 +23,7 @@ type JobStatusLine = {
23
23
  enabled: boolean;
24
24
  always: boolean;
25
25
  scheduleType: ScheduleType;
26
+ agent: string | null;
26
27
  status: JobStateStatus | "never";
27
28
  lastRun: string | null;
28
29
  nextRunAt: string | null;
@@ -121,6 +122,7 @@ export async function statusCommand(argv: string[] = []): Promise<void> {
121
122
  enabled: job.enabled,
122
123
  always: job.always,
123
124
  scheduleType: job.scheduleType,
125
+ agent: job.agent,
124
126
  status: stateInfo?.status ?? (job.lastRunAt ? "ok" : "never"),
125
127
  lastRun: safeDate(lastRun)?.toISOString() ?? null,
126
128
  nextRunAt: safeDate(job.nextRunAt)?.toISOString() ?? null,
@@ -142,6 +144,7 @@ export async function statusCommand(argv: string[] = []): Promise<void> {
142
144
  enabled: false,
143
145
  always: false,
144
146
  scheduleType: "cron",
147
+ agent: null,
145
148
  status: info.status,
146
149
  lastRun,
147
150
  nextRunAt: null,
@@ -253,7 +256,8 @@ export async function statusCommand(argv: string[] = []): Promise<void> {
253
256
  const lastText = lastRun ? formatTimeLine(lastRun, now) : "never";
254
257
  const staleText = stale ? " ⚠ stale" : "";
255
258
 
256
- console.log(` ${job.enabled ? "\u25cf" : "\u25cb"} ${job.name.padEnd(20)} ${job.enabled ? "enabled" : "disabled"}`);
259
+ const agentTag = job.agent ? ` [${job.agent}]` : "";
260
+ console.log(` ${job.enabled ? "\u25cf" : "\u25cb"} ${job.name.padEnd(20)} ${job.enabled ? "enabled" : "disabled"}${agentTag}`);
257
261
  console.log(` ${statusIcon} ${status} last: ${lastText} next: ${nextText} duration: ${durationText}${staleText}`);
258
262
  }
259
263
  } else {
@@ -44,7 +44,10 @@ function buildPlist(): string {
44
44
  <key>RunAtLoad</key>
45
45
  <true/>
46
46
  <key>KeepAlive</key>
47
- <true/>
47
+ <dict>
48
+ <key>SuccessfulExit</key>
49
+ <false/>
50
+ </dict>
48
51
  <key>StandardOutPath</key>
49
52
  <string>${paths.daemonLog}</string>
50
53
  <key>StandardErrorPath</key>
@@ -0,0 +1,94 @@
1
+ import { existsSync, readFileSync, readdirSync } from "fs";
2
+ import { join, resolve } from "path";
3
+ import { homedir } from "os";
4
+ import yaml from "js-yaml";
5
+ import { getNiaHome } from "../utils/paths";
6
+ import { log } from "../utils/log";
7
+ import type { AgentInfo } from "../types/agent";
8
+
9
+ // niahere project root (resolved from this file's location)
10
+ const PROJECT_ROOT = resolve(import.meta.dir, "../..");
11
+
12
+ function getAgentDirs(): { dir: string; source: string }[] {
13
+ const niaHome = getNiaHome();
14
+ const dirs: { dir: string; source: string }[] = [
15
+ { dir: join(process.cwd(), "agents"), source: "cwd" },
16
+ { dir: join(PROJECT_ROOT, "agents"), source: "project" },
17
+ { dir: join(niaHome, "agents"), source: "nia" },
18
+ { dir: join(homedir(), ".shared", "agents"), source: "shared" },
19
+ ];
20
+ // Deduplicate paths (cwd, project, and nia may overlap)
21
+ const seen = new Set<string>();
22
+ return dirs.filter(({ dir }) => {
23
+ const resolved = resolve(dir);
24
+ if (seen.has(resolved)) return false;
25
+ seen.add(resolved);
26
+ return true;
27
+ });
28
+ }
29
+
30
+ export function scanAgents(): AgentInfo[] {
31
+ const agents: AgentInfo[] = [];
32
+ const seen = new Set<string>();
33
+
34
+ for (const { dir, source } of getAgentDirs()) {
35
+ if (!existsSync(dir)) continue;
36
+
37
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
38
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
39
+
40
+ const agentFile = join(dir, entry.name, "AGENT.md");
41
+ if (!existsSync(agentFile)) continue;
42
+
43
+ const content = readFileSync(agentFile, "utf8");
44
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
45
+ if (!fmMatch) continue;
46
+
47
+ let meta: Record<string, unknown> = {};
48
+ try {
49
+ meta = (yaml.load(fmMatch[1]) as Record<string, unknown>) || {};
50
+ } catch (err) {
51
+ log.warn({ err, agent: entry.name, path: agentFile }, "failed to parse agent metadata, skipping");
52
+ continue;
53
+ }
54
+ const name = (typeof meta.name === "string" ? meta.name : "") || entry.name;
55
+
56
+ if (seen.has(name)) continue;
57
+ seen.add(name);
58
+
59
+ const body = content.replace(/^---\n[\s\S]*?\n---\n*/, "").trim();
60
+
61
+ agents.push({
62
+ name,
63
+ description: typeof meta.description === "string" ? meta.description : "",
64
+ body,
65
+ model: typeof meta.model === "string" ? meta.model : undefined,
66
+ source,
67
+ });
68
+ }
69
+ }
70
+
71
+ return agents;
72
+ }
73
+
74
+ export function getAgentsSummary(): string {
75
+ const agents = scanAgents();
76
+ if (agents.length === 0) return "";
77
+ const lines = agents.map((a) => a.description ? `- @${a.name}: ${a.description}` : `- @${a.name}`);
78
+ return `Available agents:\n${lines.join("\n")}`;
79
+ }
80
+
81
+ export function getAgentDefinitions(): Record<string, { description: string; prompt: string; model?: string }> {
82
+ const agents = scanAgents();
83
+ const defs: Record<string, { description: string; prompt: string; model?: string }> = {};
84
+
85
+ for (const agent of agents) {
86
+ defs[agent.name] = {
87
+ description: agent.description,
88
+ prompt: agent.body,
89
+ ...(agent.model ? { model: agent.model } : {}),
90
+ };
91
+ }
92
+
93
+ return defs;
94
+ }
@@ -155,8 +155,8 @@ export async function runDaemon(): Promise<void> {
155
155
  if (existingPid !== null && existingPid !== process.pid) {
156
156
  try {
157
157
  process.kill(existingPid, 0); // Check if alive
158
- log.warn({ existingPid, myPid: process.pid }, "another daemon is already running, exiting");
159
- process.exit(1);
158
+ log.debug({ existingPid, myPid: process.pid }, "another daemon is already running, exiting");
159
+ process.exit(0);
160
160
  } catch {
161
161
  // Dead PID in pidfile — safe to take over
162
162
  }
@@ -171,7 +171,8 @@ export async function runDaemon(): Promise<void> {
171
171
  process.exit(1);
172
172
  });
173
173
  process.on("unhandledRejection", (reason) => {
174
- log.fatal({ reason }, "unhandled rejection cleaning up");
174
+ const err = reason instanceof Error ? reason : new Error(String(reason));
175
+ log.fatal({ err }, "unhandled rejection — cleaning up");
175
176
  removePid();
176
177
  process.exit(1);
177
178
  });
@@ -7,6 +7,7 @@ import { appendAudit, readState, writeState } from "../utils/logger";
7
7
  import type { AuditEntry, JobState } from "../types";
8
8
  import { getConfig } from "../utils/config";
9
9
  import { buildSystemPrompt } from "../chat/identity";
10
+ import { scanAgents } from "./agents";
10
11
  import { truncate, formatToolUse } from "../utils/format-activity";
11
12
 
12
13
  export type ActivityCallback = (line: string) => void;
@@ -195,12 +196,30 @@ export async function runJob(job: JobInput, onActivity?: ActivityCallback): Prom
195
196
  const cwd = homedir();
196
197
  let output: RunnerOutput;
197
198
 
199
+ // Resolve system prompt: use agent body if job references an agent, else default
200
+ let systemPrompt: string;
201
+ let agentModel: string | undefined;
202
+ if (job.agent) {
203
+ const agents = scanAgents();
204
+ const agentDef = agents.find((a) => a.name === job.agent);
205
+ if (agentDef) {
206
+ systemPrompt = agentDef.body;
207
+ agentModel = agentDef.model;
208
+ } else {
209
+ systemPrompt = buildSystemPrompt("job");
210
+ }
211
+ } else {
212
+ systemPrompt = buildSystemPrompt("job");
213
+ }
214
+
215
+ const jobPrompt = job.prompt
216
+ ? `Job: ${job.name} (schedule: ${job.schedule})\n\n${job.prompt}`
217
+ : `Job: ${job.name} (schedule: ${job.schedule})\n\nExecute your scheduled tasks.`;
218
+
198
219
  if (config.runner === "codex") {
199
- const fullPrompt = `${buildSystemPrompt("job")}\n\n---\n\nJob: ${job.name} (schedule: ${job.schedule})\n\n${job.prompt}`;
200
- output = await runJobWithCodex(fullPrompt, cwd, config.model);
220
+ const fullPrompt = `${systemPrompt}\n\n---\n\n${jobPrompt}`;
221
+ output = await runJobWithCodex(fullPrompt, cwd, agentModel || config.model);
201
222
  } else {
202
- const systemPrompt = buildSystemPrompt("job");
203
- const jobPrompt = `Job: ${job.name} (schedule: ${job.schedule})\n\n${job.prompt}`;
204
223
  output = await runJobWithClaude(systemPrompt, jobPrompt, cwd, onActivity);
205
224
  }
206
225
 
@@ -0,0 +1,7 @@
1
+ import type postgres from "postgres";
2
+
3
+ export const name = "007_jobs_agent";
4
+
5
+ export async function up(sql: postgres.Sql): Promise<void> {
6
+ await sql`ALTER TABLE jobs ADD COLUMN IF NOT EXISTS agent TEXT`;
7
+ }
@@ -8,6 +8,7 @@ export interface Job {
8
8
  enabled: boolean;
9
9
  always: boolean;
10
10
  scheduleType: ScheduleType;
11
+ agent: string | null;
11
12
  nextRunAt: string | null;
12
13
  lastRunAt: string | null;
13
14
  createdAt: string;
@@ -22,6 +23,7 @@ function toJob(r: Record<string, any>): Job {
22
23
  enabled: r.enabled,
23
24
  always: r.always ?? false,
24
25
  scheduleType: r.schedule_type || "cron",
26
+ agent: r.agent || null,
25
27
  nextRunAt: r.next_run_at ? String(r.next_run_at) : null,
26
28
  lastRunAt: r.last_run_at ? String(r.last_run_at) : null,
27
29
  createdAt: String(r.created_at),
@@ -41,6 +43,7 @@ export async function create(
41
43
  always = false,
42
44
  scheduleType: ScheduleType = "cron",
43
45
  nextRunAt?: Date,
46
+ agent?: string,
44
47
  ): Promise<void> {
45
48
  const existing = await get(name);
46
49
  if (existing) {
@@ -48,27 +51,27 @@ export async function create(
48
51
  }
49
52
  const sql = getSql();
50
53
  await sql`
51
- INSERT INTO jobs (name, schedule, prompt, always, schedule_type, next_run_at)
52
- VALUES (${name}, ${schedule}, ${prompt}, ${always}, ${scheduleType}, ${nextRunAt ?? null})
54
+ INSERT INTO jobs (name, schedule, prompt, always, schedule_type, next_run_at, agent)
55
+ VALUES (${name}, ${schedule}, ${prompt}, ${always}, ${scheduleType}, ${nextRunAt ?? null}, ${agent ?? null})
53
56
  `;
54
57
  await notifyChange();
55
58
  }
56
59
 
57
60
  export async function list(): Promise<Job[]> {
58
61
  const sql = getSql();
59
- const rows = await sql`SELECT name, schedule, prompt, enabled, always, schedule_type, next_run_at, last_run_at, created_at, updated_at FROM jobs ORDER BY name`;
62
+ const rows = await sql`SELECT name, schedule, prompt, enabled, always, schedule_type, agent, next_run_at, last_run_at, created_at, updated_at FROM jobs ORDER BY name`;
60
63
  return rows.map(toJob);
61
64
  }
62
65
 
63
66
  export async function get(name: string): Promise<Job | null> {
64
67
  const sql = getSql();
65
- const rows = await sql`SELECT name, schedule, prompt, enabled, always, schedule_type, next_run_at, last_run_at, created_at, updated_at FROM jobs WHERE name = ${name}`;
68
+ const rows = await sql`SELECT name, schedule, prompt, enabled, always, schedule_type, agent, next_run_at, last_run_at, created_at, updated_at FROM jobs WHERE name = ${name}`;
66
69
  return rows.length > 0 ? toJob(rows[0]) : null;
67
70
  }
68
71
 
69
72
  export async function update(
70
73
  name: string,
71
- fields: Partial<{ schedule: string; prompt: string; enabled: boolean; always: boolean }>,
74
+ fields: Partial<{ schedule: string; prompt: string; enabled: boolean; always: boolean; agent: string | null }>,
72
75
  ): Promise<boolean> {
73
76
  const sql = getSql();
74
77
  const existing = await get(name);
@@ -78,10 +81,11 @@ export async function update(
78
81
  const prompt = fields.prompt ?? existing.prompt;
79
82
  const enabled = fields.enabled ?? existing.enabled;
80
83
  const always = fields.always ?? existing.always;
84
+ const agent = fields.agent !== undefined ? fields.agent : existing.agent;
81
85
 
82
86
  await sql`
83
87
  UPDATE jobs
84
- SET schedule = ${schedule}, prompt = ${prompt}, enabled = ${enabled}, always = ${always}, updated_at = NOW()
88
+ SET schedule = ${schedule}, prompt = ${prompt}, enabled = ${enabled}, always = ${always}, agent = ${agent}, updated_at = NOW()
85
89
  WHERE name = ${name}
86
90
  `;
87
91
  await notifyChange();
@@ -97,14 +101,14 @@ export async function remove(name: string): Promise<boolean> {
97
101
 
98
102
  export async function listEnabled(): Promise<Job[]> {
99
103
  const sql = getSql();
100
- const rows = await sql`SELECT name, schedule, prompt, enabled, always, schedule_type, next_run_at, last_run_at, created_at, updated_at FROM jobs WHERE enabled = TRUE ORDER BY name`;
104
+ const rows = await sql`SELECT name, schedule, prompt, enabled, always, schedule_type, agent, next_run_at, last_run_at, created_at, updated_at FROM jobs WHERE enabled = TRUE ORDER BY name`;
101
105
  return rows.map(toJob);
102
106
  }
103
107
 
104
108
  export async function listDue(): Promise<Job[]> {
105
109
  const sql = getSql();
106
110
  const rows = await sql`
107
- SELECT name, schedule, prompt, enabled, always, schedule_type, next_run_at, last_run_at, created_at, updated_at
111
+ SELECT name, schedule, prompt, enabled, always, schedule_type, agent, next_run_at, last_run_at, created_at, updated_at
108
112
  FROM jobs
109
113
  WHERE enabled = TRUE AND next_run_at <= NOW()
110
114
  ORDER BY next_run_at
package/src/mcp/server.ts CHANGED
@@ -24,6 +24,7 @@ export function createNiaMcpServer() {
24
24
  prompt: z.string().describe("What the job should do"),
25
25
  schedule_type: z.enum(["cron", "interval", "once"]).default("cron").describe("Schedule type"),
26
26
  always: z.boolean().default(false).describe("If true, runs 24/7 ignoring active hours"),
27
+ agent: z.string().optional().describe("Agent name to use for this job (loads agent's AGENT.md as system prompt)"),
27
28
  },
28
29
  async (args) => ({
29
30
  content: [{ type: "text" as const, text: await handlers.addJob(args) }],
@@ -31,12 +32,13 @@ export function createNiaMcpServer() {
31
32
  ),
32
33
  tool(
33
34
  "update_job",
34
- "Update an existing job's schedule, prompt, or always flag. Only pass fields you want to change.",
35
+ "Update an existing job's schedule, prompt, always flag, or agent. Only pass fields you want to change.",
35
36
  {
36
37
  name: z.string().describe("Job name to update"),
37
38
  schedule: z.string().optional().describe("New schedule (cron expression, interval duration, or ISO timestamp)"),
38
39
  prompt: z.string().optional().describe("New prompt"),
39
40
  always: z.boolean().optional().describe("If true, runs 24/7 ignoring active hours"),
41
+ agent: z.string().nullable().optional().describe("Agent name (set null to remove agent)"),
40
42
  },
41
43
  async (args) => ({
42
44
  content: [{ type: "text" as const, text: await handlers.updateJob(args) }],
@@ -166,6 +168,14 @@ export function createNiaMcpServer() {
166
168
  content: [{ type: "text" as const, text: handlers.addMemory(args.entry) }],
167
169
  }),
168
170
  ),
171
+ tool(
172
+ "list_agents",
173
+ "List all available agents. Agents are role/domain specialists that can be delegated to via the Agent tool or referenced by jobs.",
174
+ {},
175
+ async () => ({
176
+ content: [{ type: "text" as const, text: handlers.listAgents() }],
177
+ }),
178
+ ),
169
179
  ],
170
180
  });
171
181
  }
package/src/mcp/tools.ts CHANGED
@@ -8,6 +8,7 @@ import { getPaths } from "../utils/paths";
8
8
  import { getChannel } from "../channels/registry";
9
9
  import { log } from "../utils/log";
10
10
  import { classifyMime } from "../utils/attachment";
11
+ import { scanAgents } from "../core/agents";
11
12
 
12
13
  export async function listJobs(): Promise<string> {
13
14
  const jobs = await Job.list();
@@ -21,14 +22,16 @@ export async function addJob(args: {
21
22
  prompt: string;
22
23
  schedule_type?: ScheduleType;
23
24
  always?: boolean;
25
+ agent?: string;
24
26
  }): Promise<string> {
25
27
  const scheduleType = args.schedule_type || "cron";
26
28
  const always = args.always || false;
27
29
  const config = getConfig();
28
30
 
29
31
  const nextRunAt = computeInitialNextRun(scheduleType, args.schedule, config.timezone);
30
- await Job.create(args.name, args.schedule, args.prompt, always, scheduleType, nextRunAt);
31
- return `Job "${args.name}" created (${scheduleType}: ${args.schedule}). Next run: ${nextRunAt.toISOString()}`;
32
+ await Job.create(args.name, args.schedule, args.prompt, always, scheduleType, nextRunAt, args.agent);
33
+ const agentNote = args.agent ? ` [agent: ${args.agent}]` : "";
34
+ return `Job "${args.name}" created (${scheduleType}: ${args.schedule})${agentNote}. Next run: ${nextRunAt.toISOString()}`;
32
35
  }
33
36
 
34
37
  export async function updateJob(args: {
@@ -36,13 +39,15 @@ export async function updateJob(args: {
36
39
  schedule?: string;
37
40
  prompt?: string;
38
41
  always?: boolean;
42
+ agent?: string | null;
39
43
  }): Promise<string> {
40
- const fields: Partial<{ schedule: string; prompt: string; always: boolean }> = {};
44
+ const fields: Partial<{ schedule: string; prompt: string; always: boolean; agent: string | null }> = {};
41
45
  if (args.schedule) fields.schedule = args.schedule;
42
46
  if (args.prompt) fields.prompt = args.prompt;
43
47
  if (args.always !== undefined) fields.always = args.always;
48
+ if (args.agent !== undefined) fields.agent = args.agent;
44
49
 
45
- if (Object.keys(fields).length === 0) return "Nothing to update. Pass at least one field (schedule, prompt, or always).";
50
+ if (Object.keys(fields).length === 0) return "Nothing to update. Pass at least one field (schedule, prompt, always, or agent).";
46
51
 
47
52
  const updated = await Job.update(args.name, fields);
48
53
  if (!updated) return `Job "${args.name}" not found.`;
@@ -327,3 +332,13 @@ export function addMemory(entry: string): string {
327
332
  }
328
333
  return `Memory saved.`;
329
334
  }
335
+
336
+ export function listAgents(): string {
337
+ const agents = scanAgents();
338
+ if (agents.length === 0) return "No agents found.";
339
+ return JSON.stringify(
340
+ agents.map((a) => ({ name: a.name, description: a.description, model: a.model, source: a.source })),
341
+ null,
342
+ 2,
343
+ );
344
+ }
@@ -0,0 +1,7 @@
1
+ export interface AgentInfo {
2
+ name: string;
3
+ description: string;
4
+ body: string;
5
+ model?: string;
6
+ source: string;
7
+ }
@@ -8,3 +8,4 @@ export type { ChatState } from "./chat-state";
8
8
  export type { Config, ChannelsConfig, TelegramConfig, SlackConfig } from "./config";
9
9
  export type { Paths } from "./paths";
10
10
  export type { SaveMessageParams, RoomStats, RecentMessage } from "./message";
11
+ export type { AgentInfo } from "./agent";
package/src/types/job.ts CHANGED
@@ -4,6 +4,7 @@ export interface JobInput {
4
4
  name: string;
5
5
  schedule: string;
6
6
  prompt: string;
7
+ agent?: string | null;
7
8
  }
8
9
 
9
10
  export interface JobResult {