niahere 0.2.65 → 0.2.67

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niahere",
3
- "version": "0.2.65",
3
+ "version": "0.2.67",
4
4
  "description": "A personal AI assistant daemon — chat, scheduled jobs, persona system, extensible via skills.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -79,20 +79,26 @@ class SlackChannel implements Channel {
79
79
  return `slack-${key}-${index}`;
80
80
  }
81
81
 
82
- async function getState(key: string): Promise<ChatState> {
82
+ async function getState(key: string, watchBehavior?: { channel: string; behavior: string }): Promise<ChatState> {
83
83
  let state = chats.get(key);
84
84
  if (!state) {
85
85
  const prefix = roomPrefix(key);
86
86
  const idx = await Session.getLatestRoomIndex(prefix);
87
87
  const room = roomName(key, idx);
88
- const engine = await createChatEngine({ room, channel: "slack", resume: true, mcpServers: getMcpServers() });
88
+ const engine = await createChatEngine({
89
+ room,
90
+ channel: "slack",
91
+ resume: true,
92
+ mcpServers: getMcpServers(),
93
+ watchBehavior,
94
+ });
89
95
  state = { engine, roomIndex: idx, lock: Promise.resolve() };
90
96
  chats.set(key, state);
91
97
  }
92
98
  return state;
93
99
  }
94
100
 
95
- async function restartChat(key: string): Promise<ChatState> {
101
+ async function restartChat(key: string, watchBehavior?: { channel: string; behavior: string }): Promise<ChatState> {
96
102
  const old = chats.get(key);
97
103
  if (old) old.engine.close();
98
104
 
@@ -106,7 +112,13 @@ class SlackChannel implements Channel {
106
112
  await Session.create(`placeholder-${room}`, room);
107
113
 
108
114
  log.info({ key, room }, "slack: creating chat engine");
109
- const engine = await createChatEngine({ room, channel: "slack", resume: false, mcpServers: getMcpServers() });
115
+ const engine = await createChatEngine({
116
+ room,
117
+ channel: "slack",
118
+ resume: false,
119
+ mcpServers: getMcpServers(),
120
+ watchBehavior,
121
+ });
110
122
  const state: ChatState = { engine, roomIndex: newIdx, lock: Promise.resolve() };
111
123
  chats.set(key, state);
112
124
  log.info({ key, room, activeSessions: chats.size }, "slack: engine ready");
@@ -504,11 +516,10 @@ class SlackChannel implements Channel {
504
516
  }
505
517
  }
506
518
 
507
- // Prepend watch behavior context for watched channels
508
- if (watchConfig) {
509
- const behaviorLine = watchConfig.behavior ? `Behavior: ${watchConfig.behavior}\n` : "";
510
- text = `[Watch mode — #${watchConfig.name}]\n${behaviorLine}Respond with [NO_REPLY] if no action needed.\n\n${text}`;
511
- }
519
+ // Build watch behavior for system prompt injection (if watched channel)
520
+ const watchBehavior = watchConfig?.behavior
521
+ ? { channel: watchConfig.name, behavior: watchConfig.behavior }
522
+ : undefined;
512
523
 
513
524
  log.info(
514
525
  {
@@ -524,7 +535,7 @@ class SlackChannel implements Channel {
524
535
 
525
536
  let state: ChatState;
526
537
  try {
527
- state = await getState(key);
538
+ state = await getState(key, watchBehavior);
528
539
  } catch (err) {
529
540
  log.error({ err, key }, "slack: failed to create chat engine");
530
541
  return;
@@ -12,7 +12,7 @@ function loadFile(dir: string, name: string): string {
12
12
  return readFileSync(filePath, "utf8").trim();
13
13
  }
14
14
 
15
- export function buildEmployeePrompt(name: string): string {
15
+ export function buildEmployeePrompt(name: string, mode: "chat" | "job" = "chat"): string {
16
16
  const employee = getEmployee(name);
17
17
  if (!employee) return "";
18
18
 
@@ -25,7 +25,7 @@ export function buildEmployeePrompt(name: string): string {
25
25
  // Environment + mode + capabilities
26
26
  parts.push(getEnvironmentPrompt());
27
27
 
28
- const modePrompt = getModePrompt("chat");
28
+ const modePrompt = getModePrompt(mode);
29
29
  if (modePrompt) parts.push(modePrompt);
30
30
 
31
31
  const skills = getSkillsSummary();
@@ -5,7 +5,7 @@ import { existsSync } from "fs";
5
5
  import { join } from "path";
6
6
  import { homedir } from "os";
7
7
  import { randomUUID } from "crypto";
8
- import { buildSystemPrompt, getSessionContext } from "./identity";
8
+ import { buildSystemPrompt, buildContextSuffix, getSessionContext } from "./identity";
9
9
  import { buildEmployeePrompt } from "./employee-prompt";
10
10
  import { getEmployee } from "../core/employees";
11
11
  import { getAgentDefinitions, scanAgents } from "../core/agents";
@@ -146,7 +146,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
146
146
  } else if (opts.agent) {
147
147
  const agents = scanAgents();
148
148
  const agentDef = agents.find((a) => a.name === opts.agent);
149
- if (agentDef) systemPrompt = agentDef.body;
149
+ if (agentDef) systemPrompt = agentDef.body + "\n\n" + buildContextSuffix("chat");
150
150
  } else if (opts.job) {
151
151
  // Job chat: load job and use its context
152
152
  const jobData = await Job.get(opts.job);
@@ -158,15 +158,21 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
158
158
  const emp = getEmployee(jobData.employee);
159
159
  if (emp?.repo && existsSync(emp.repo)) cwd = emp.repo;
160
160
  } else if (jobData.agent) {
161
- // If job has an agent, use agent prompt
161
+ // If job has an agent, use agent prompt + context
162
162
  const agents = scanAgents();
163
163
  const agentDef = agents.find((a) => a.name === jobData.agent);
164
- if (agentDef) systemPrompt = agentDef.body;
164
+ if (agentDef) systemPrompt = agentDef.body + "\n\n" + buildContextSuffix("chat");
165
165
  }
166
166
  systemPrompt += `\n\n## Job Context\nYou are chatting in the context of job "${jobData.name}" (schedule: ${jobData.schedule}).\n\nJob prompt:\n${jobData.prompt}`;
167
167
  }
168
168
  }
169
169
 
170
+ // Watch mode: inject behavior into system prompt
171
+ if (opts.watchBehavior) {
172
+ const { channel: watchChannel, behavior } = opts.watchBehavior;
173
+ systemPrompt += `\n\n## Watch Mode — #${watchChannel}\n\nYou are monitoring this Slack channel. Follow the behavior instructions below.\nRespond with [NO_REPLY] if no action is needed — do not explain why.\n\n${behavior}`;
174
+ }
175
+
170
176
  let sessionId: string | null = null;
171
177
  if (typeof resume === "string") {
172
178
  // Specific session ID provided
@@ -52,6 +52,30 @@ export function buildSystemPrompt(mode: Mode = "chat", channel: string = "termin
52
52
  return parts.join("\n\n");
53
53
  }
54
54
 
55
+ /**
56
+ * Build the context suffix (env + mode + skills + agents + employees) that should
57
+ * be appended to any custom system prompt (agent body, watch behavior, etc).
58
+ * Does NOT include Nia's identity — that's the caller's responsibility.
59
+ */
60
+ export function buildContextSuffix(mode: Mode = "chat"): string {
61
+ const parts: string[] = [];
62
+ parts.push(getEnvironmentPrompt());
63
+
64
+ const modePrompt = getModePrompt(mode);
65
+ if (modePrompt) parts.push(modePrompt);
66
+
67
+ const skills = getSkillsSummary();
68
+ if (skills) parts.push(skills);
69
+
70
+ const agents = getAgentsSummary();
71
+ if (agents) parts.push(agents);
72
+
73
+ const employees = getEmployeesSummary();
74
+ if (employees) parts.push(employees);
75
+
76
+ return parts.join("\n\n");
77
+ }
78
+
55
79
  /**
56
80
  * Load recent session summaries for a room and format as a context block.
57
81
  * Returns empty string if no summaries are available.
package/src/cli/index.ts CHANGED
@@ -16,6 +16,7 @@ import { rulesCommand, memoryCommand } from "./self";
16
16
  import { watchCommand } from "./watch";
17
17
  import { agentCommand } from "./agent";
18
18
  import { employeeCommand } from "./employee";
19
+ import { guardActiveEngines, parseGuardFlags } from "../core/engine-guard";
19
20
 
20
21
  // Set LOG_LEVEL from config before anything else logs
21
22
  try {
@@ -112,7 +113,8 @@ switch (command) {
112
113
 
113
114
  case "stop": {
114
115
  if (!isRunning()) fail("nia is not running");
115
- // Unregister service first to prevent KeepAlive from respawning after kill
116
+ const stopGuard = parseGuardFlags(process.argv.slice(3));
117
+ if (!(await guardActiveEngines("stop", stopGuard))) process.exit(1);
116
118
  const { unregisterService } = await import("../commands/service");
117
119
  await unregisterService();
118
120
  stopDaemon();
@@ -132,9 +134,10 @@ switch (command) {
132
134
  }
133
135
 
134
136
  case "restart": {
137
+ const restartGuard = parseGuardFlags(process.argv.slice(3));
138
+ if (!(await guardActiveEngines("restart", restartGuard))) process.exit(1);
135
139
  const { isServiceInstalled, restartService } = await import("../commands/service");
136
140
  if (isServiceInstalled()) {
137
- // Service-aware: unload (stops KeepAlive respawn), kill, then reload
138
141
  await restartService();
139
142
  } else {
140
143
  stopDaemon();
@@ -494,8 +497,13 @@ switch (command) {
494
497
  }
495
498
 
496
499
  case "update": {
500
+ const updateGuard = parseGuardFlags(process.argv.slice(3));
497
501
  const { version: currentVersion } = await import("../../package.json");
498
502
  console.log(`Current: v${currentVersion}`);
503
+
504
+ // Check active engines before doing anything destructive
505
+ if (isRunning() && !(await guardActiveEngines("update", updateGuard))) process.exit(1);
506
+
499
507
  // Auto-backup before update
500
508
  try {
501
509
  const { createBackup } = await import("../commands/backup");
@@ -553,8 +561,9 @@ switch (command) {
553
561
 
554
562
  Daemon:
555
563
  start Start daemon + register service
556
- stop Stop daemon + unregister service
557
- restart Restart daemon
564
+ stop [--wait N] [--force] Stop daemon (guards active engines)
565
+ restart [--wait N] [--force] Restart daemon
566
+ update [--wait N] [--force] Update to latest version
558
567
  status [--json --rooms N --all] Show daemon, jobs, channels
559
568
  health Check daemon, db, channels, config
560
569
  logs [-f] [--channel ch] Daemon logs (filter by channel)
@@ -586,7 +595,6 @@ System:
586
595
  backup [list] Create or list backups
587
596
  validate Validate config.yaml
588
597
  db <sub> Database setup/status/migrate
589
- update Update to latest version
590
598
  init Initial setup
591
599
  test [-v] Run tests`;
592
600
 
package/src/cli/job.ts CHANGED
@@ -39,6 +39,8 @@ Commands:
39
39
  remove <name> Delete a job
40
40
  enable <name> Enable a job
41
41
  disable <name> Disable a job
42
+ archive <name> Archive a job (out of sight)
43
+ unarchive <name> Unarchive back to disabled
42
44
  run <name> Run a job once
43
45
  log [name] Show recent run history`;
44
46
 
@@ -46,7 +48,7 @@ async function pickJob(prompt = "Pick a job"): Promise<string> {
46
48
  let jobs: {
47
49
  name: string;
48
50
  schedule: string;
49
- enabled: boolean;
51
+ status: string;
50
52
  prompt: string;
51
53
  }[] = [];
52
54
  try {
@@ -68,7 +70,7 @@ async function pickJob(prompt = "Pick a job"): Promise<string> {
68
70
  try {
69
71
  const items = jobs.map((j) => ({
70
72
  name: j.name,
71
- label: `${j.enabled ? "●" : "○"} ${j.name} ${j.schedule}`,
73
+ label: `${j.status === "active" ? "●" : "○"} ${j.name} ${j.schedule}`,
72
74
  }));
73
75
  const name = await pickFromList(rl, items, prompt);
74
76
  if (!name) fail("Invalid selection.");
@@ -94,12 +96,20 @@ export async function jobCommand(): Promise<void> {
94
96
  if (jobs.length === 0) {
95
97
  console.log("No jobs configured. Use `nia job add` or `nia job import`.");
96
98
  } else {
97
- for (const job of jobs) {
99
+ const visible = jobs.filter((j) => j.status !== "archived");
100
+ const archived = jobs.filter((j) => j.status === "archived");
101
+ for (const job of visible) {
102
+ const icon = job.status === "active" ? "●" : "○";
98
103
  const tag = job.always ? " always" : "";
99
104
  const type = job.scheduleType !== "cron" ? ` (${job.scheduleType})` : "";
100
105
  const agentTag = job.agent ? ` [${job.agent}]` : "";
101
106
  const empTag = job.employee ? ` [emp:${job.employee}]` : "";
102
- console.log(` ${job.enabled ? "●" : "○"} ${job.name} ${job.schedule}${type}${tag}${agentTag}${empTag}`);
107
+ console.log(` ${icon} ${job.name} ${job.schedule}${type}${tag}${agentTag}${empTag}`);
108
+ }
109
+ if (archived.length > 0) {
110
+ console.log(
111
+ `\n ${archived.length} archived job${archived.length > 1 ? "s" : ""} (nia job list --all to show)`,
112
+ );
103
113
  }
104
114
  }
105
115
  });
@@ -178,15 +188,23 @@ export async function jobCommand(): Promise<void> {
178
188
  }
179
189
 
180
190
  case "enable":
181
- case "disable": {
191
+ case "disable":
192
+ case "archive":
193
+ case "unarchive": {
182
194
  const name = process.argv[4];
183
195
  if (!name) fail(`Usage: nia job ${subcommand} <name>`);
184
- const enabled = subcommand === "enable";
196
+ const statusMap: Record<string, string> = {
197
+ enable: "active",
198
+ disable: "disabled",
199
+ archive: "archived",
200
+ unarchive: "disabled",
201
+ };
202
+ const newStatus = statusMap[subcommand] as "active" | "disabled" | "archived";
185
203
 
186
204
  try {
187
205
  await withDb(async () => {
188
- const updated = await Job.update(name, { enabled });
189
- console.log(updated ? `Job "${name}" ${subcommand}d.` : `Job not found: ${name}`);
206
+ const updated = await Job.update(name, { status: newStatus });
207
+ console.log(updated ? `Job "${name}" ${newStatus}.` : `Job not found: ${name}`);
190
208
  });
191
209
  } catch (err) {
192
210
  fail(`Failed: ${errMsg(err)}`);
@@ -280,9 +298,10 @@ export async function jobCommand(): Promise<void> {
280
298
  const job = await Job.get(name);
281
299
  if (!job) fail(`Job not found: ${name}`);
282
300
 
283
- console.log(` ${job.enabled ? "●" : "○"} ${job.name}`);
301
+ const icon = job.status === "active" ? "●" : job.status === "archived" ? "◌" : "○";
302
+ console.log(` ${icon} ${job.name}`);
284
303
  console.log(` schedule: ${job.schedule} (${job.scheduleType})`);
285
- console.log(` enabled: ${job.enabled}`);
304
+ console.log(` status: ${job.status}`);
286
305
  console.log(` always: ${job.always}`);
287
306
  if (job.agent) console.log(` agent: ${job.agent}`);
288
307
  if (job.employee) console.log(` employee: ${job.employee}`);
@@ -333,7 +352,8 @@ export async function jobCommand(): Promise<void> {
333
352
  ? `${info.status} (${localTime(new Date(info.lastRun))}, ${formatDuration(info.duration_ms)})`
334
353
  : "never run";
335
354
  const tag = job.always ? " always" : "";
336
- console.log(` ${job.enabled ? "●" : "○"} ${job.name} [${job.schedule}]${tag} ${status}`);
355
+ const icon = job.status === "active" ? "●" : job.status === "archived" ? "◌" : "○";
356
+ console.log(` ${icon} ${job.name} [${job.schedule}]${tag} ${status}`);
337
357
  if (info?.error) console.log(` error: ${info.error}`);
338
358
  });
339
359
  } catch (err) {
package/src/cli/status.ts CHANGED
@@ -20,7 +20,7 @@ type StatusOptions = {
20
20
  type JobStatusLine = {
21
21
  name: string;
22
22
  schedule: string;
23
- enabled: boolean;
23
+ jobStatus: string;
24
24
  always: boolean;
25
25
  scheduleType: ScheduleType;
26
26
  agent: string | null;
@@ -111,7 +111,7 @@ export async function statusCommand(argv: string[] = []): Promise<void> {
111
111
  if (options.json) {
112
112
  const sortedJobs = [...jobs].sort(
113
113
  (a, b) =>
114
- Number(b.enabled) - Number(a.enabled) ||
114
+ (b.status === "active" ? 1 : 0) - (a.status === "active" ? 1 : 0) ||
115
115
  dateSortValue(a.nextRunAt) - dateSortValue(b.nextRunAt) ||
116
116
  a.name.localeCompare(b.name),
117
117
  );
@@ -122,7 +122,7 @@ export async function statusCommand(argv: string[] = []): Promise<void> {
122
122
  return {
123
123
  name: job.name,
124
124
  schedule: job.schedule,
125
- enabled: job.enabled,
125
+ jobStatus: job.status,
126
126
  always: job.always,
127
127
  scheduleType: job.scheduleType,
128
128
  agent: job.agent,
@@ -144,7 +144,7 @@ export async function statusCommand(argv: string[] = []): Promise<void> {
144
144
  return {
145
145
  name,
146
146
  schedule: "unavailable",
147
- enabled: false,
147
+ jobStatus: "disabled",
148
148
  always: false,
149
149
  scheduleType: "cron",
150
150
  agent: null,
@@ -232,13 +232,15 @@ export async function statusCommand(argv: string[] = []): Promise<void> {
232
232
  console.log("\nJobs:");
233
233
  const sortedJobs = [...jobs].sort(
234
234
  (a, b) =>
235
- Number(b.enabled) - Number(a.enabled) ||
235
+ (b.status === "active" ? 1 : 0) - (a.status === "active" ? 1 : 0) ||
236
236
  dateSortValue(a.nextRunAt) - dateSortValue(b.nextRunAt) ||
237
237
  a.name.localeCompare(b.name),
238
238
  );
239
239
 
240
240
  // Hide completed one-shot jobs
241
- const visibleJobs = sortedJobs.filter((j) => !(j.scheduleType === "once" && !j.enabled && j.lastRunAt));
241
+ const visibleJobs = sortedJobs.filter(
242
+ (j) => j.status !== "archived" && !(j.scheduleType === "once" && j.status !== "active" && j.lastRunAt),
243
+ );
242
244
 
243
245
  for (const job of visibleJobs) {
244
246
  const stateInfo = state[job.name];
@@ -246,7 +248,7 @@ export async function statusCommand(argv: string[] = []): Promise<void> {
246
248
  const lastRun = stateInfo?.lastRun ?? job.lastRunAt ?? null;
247
249
  const nextRun = job.nextRunAt ?? null;
248
250
  const stale =
249
- job.enabled &&
251
+ job.status === "active" &&
250
252
  status !== "running" &&
251
253
  nextRun !== null &&
252
254
  safeDate(nextRun) !== null &&
@@ -263,7 +265,7 @@ export async function statusCommand(argv: string[] = []): Promise<void> {
263
265
  const agentTag = job.agent ? ` [${job.agent}]` : "";
264
266
  const empTag = job.employee ? ` [emp:${job.employee}]` : "";
265
267
  console.log(
266
- ` ${job.enabled ? "\u25cf" : "\u25cb"} ${job.name.padEnd(20)} ${job.enabled ? "enabled" : "disabled"}${agentTag}${empTag}`,
268
+ ` ${job.status === "active" ? "\u25cf" : "\u25cb"} ${job.name.padEnd(20)} ${job.status}${agentTag}${empTag}`,
267
269
  );
268
270
  console.log(
269
271
  ` ${statusIcon} ${status} last: ${lastText} next: ${nextText} duration: ${durationText}${staleText}`,
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Guard against stopping/restarting while active engines are running.
3
+ *
4
+ * Default: warn and refuse.
5
+ * --wait <minutes>: poll until engines clear, then proceed. Times out with error.
6
+ * --force: skip the check entirely.
7
+ */
8
+
9
+ import { ActiveEngine } from "../db/models";
10
+ import { withDb } from "../db/with-db";
11
+ import { DIM, RESET, ICON_WARN } from "../utils/cli";
12
+
13
+ export interface GuardOptions {
14
+ /** Wait up to this many minutes for engines to clear. 0 = don't wait (default). */
15
+ waitMinutes: number;
16
+ /** Skip the guard entirely. */
17
+ force: boolean;
18
+ }
19
+
20
+ export function parseGuardFlags(args: string[]): GuardOptions {
21
+ const force = args.includes("--force") || args.includes("-f");
22
+
23
+ let waitMinutes = 0;
24
+ const waitIdx = args.indexOf("--wait");
25
+ if (waitIdx !== -1 && args[waitIdx + 1]) {
26
+ const parsed = parseInt(args[waitIdx + 1], 10);
27
+ if (!isNaN(parsed) && parsed > 0) waitMinutes = parsed;
28
+ }
29
+
30
+ return { waitMinutes, force };
31
+ }
32
+
33
+ interface ActiveSummary {
34
+ count: number;
35
+ rooms: string[];
36
+ }
37
+
38
+ async function getActiveEngines(): Promise<ActiveSummary> {
39
+ let count = 0;
40
+ let rooms: string[] = [];
41
+ try {
42
+ await withDb(async () => {
43
+ const engines = await ActiveEngine.list();
44
+ count = engines.length;
45
+ rooms = engines.map((e) => `${e.room} (${e.channel})`);
46
+ });
47
+ } catch {
48
+ // DB unreachable — no engines to worry about
49
+ }
50
+ return { count, rooms };
51
+ }
52
+
53
+ /**
54
+ * Check for active engines before a destructive operation.
55
+ * Returns true if safe to proceed, false if blocked.
56
+ */
57
+ export async function guardActiveEngines(action: string, opts: GuardOptions): Promise<boolean> {
58
+ if (opts.force) return true;
59
+
60
+ const { count, rooms } = await getActiveEngines();
61
+ if (count === 0) return true;
62
+
63
+ // Active engines found
64
+ console.log(`\n${ICON_WARN} ${count} active engine${count > 1 ? "s" : ""} running:`);
65
+ for (const room of rooms) {
66
+ console.log(` ${DIM}${room}${RESET}`);
67
+ }
68
+
69
+ if (opts.waitMinutes === 0) {
70
+ // Default: refuse
71
+ console.log(`\nCannot ${action} while engines are active.`);
72
+ console.log(`${DIM}Options:${RESET}`);
73
+ console.log(` --wait <minutes> Wait for engines to finish (checks every 5s)`);
74
+ console.log(` --force ${action} immediately, killing active sessions`);
75
+ return false;
76
+ }
77
+
78
+ // --wait: poll until clear or timeout
79
+ const deadlineMs = opts.waitMinutes * 60 * 1000;
80
+ const deadline = Date.now() + deadlineMs;
81
+ console.log(`\nWaiting up to ${opts.waitMinutes}m for engines to finish...`);
82
+
83
+ while (Date.now() < deadline) {
84
+ await new Promise((r) => setTimeout(r, 5_000));
85
+ const { count: remaining } = await getActiveEngines();
86
+ if (remaining === 0) {
87
+ console.log("All engines finished.");
88
+ return true;
89
+ }
90
+ const left = Math.ceil((deadline - Date.now()) / 1000);
91
+ process.stdout.write(`\r${DIM} ${remaining} active, ${left}s remaining${RESET}`);
92
+ }
93
+
94
+ process.stdout.write("\n");
95
+ console.log(`\nTimed out — ${count} engine${count > 1 ? "s" : ""} still active.`);
96
+ console.log(`Use --force to ${action} anyway.`);
97
+ return false;
98
+ }
@@ -7,7 +7,7 @@ import type { JobInput, JobResult } from "../types";
7
7
  import { appendAudit, readState, writeState } from "../utils/logger";
8
8
  import type { AuditEntry, JobState } from "../types";
9
9
  import { getConfig } from "../utils/config";
10
- import { buildSystemPrompt } from "../chat/identity";
10
+ import { buildSystemPrompt, buildContextSuffix } from "../chat/identity";
11
11
  import { buildEmployeePrompt } from "../chat/employee-prompt";
12
12
  import { getEmployee } from "./employees";
13
13
  import { scanAgents } from "./agents";
@@ -309,7 +309,7 @@ export async function runJob(job: JobInput, onActivity?: ActivityCallback): Prom
309
309
  let systemPrompt: string;
310
310
  let agentModel: string | undefined;
311
311
  if (job.employee) {
312
- const empPrompt = buildEmployeePrompt(job.employee);
312
+ const empPrompt = buildEmployeePrompt(job.employee, "job");
313
313
  if (empPrompt) {
314
314
  systemPrompt = empPrompt;
315
315
  } else {
@@ -322,7 +322,7 @@ export async function runJob(job: JobInput, onActivity?: ActivityCallback): Prom
322
322
  const agents = scanAgents();
323
323
  const agentDef = agents.find((a) => a.name === job.agent);
324
324
  if (agentDef) {
325
- systemPrompt = agentDef.body;
325
+ systemPrompt = agentDef.body + "\n\n" + buildContextSuffix("job");
326
326
  agentModel = agentDef.model;
327
327
  } else {
328
328
  systemPrompt = buildSystemPrompt("job");
@@ -72,7 +72,7 @@ async function tick(): Promise<void> {
72
72
  nextRun = computeNextRun(job.scheduleType, job.schedule, config.timezone, new Date());
73
73
  } catch (err) {
74
74
  log.error({ err, job: job.name, schedule: job.schedule }, "scheduler: invalid schedule, disabling job");
75
- await Job.update(job.name, { enabled: false }).catch(() => {});
75
+ await Job.update(job.name, { status: "disabled" }).catch(() => {});
76
76
  continue;
77
77
  }
78
78
  await Job.markRun(job.name, nextRun).catch((err) => {
@@ -81,7 +81,7 @@ async function tick(): Promise<void> {
81
81
 
82
82
  // Auto-disable one-shot jobs after execution
83
83
  if (job.scheduleType === "once") {
84
- await Job.update(job.name, { enabled: false }).catch(() => {});
84
+ await Job.update(job.name, { status: "disabled" }).catch(() => {});
85
85
  log.info({ job: job.name }, "scheduler: one-shot job completed, auto-disabled");
86
86
  }
87
87
  }
@@ -0,0 +1,10 @@
1
+ import type postgres from "postgres";
2
+
3
+ export const name = "016_jobs_status";
4
+
5
+ export async function up(sql: postgres.Sql): Promise<void> {
6
+ // Add status column: active | disabled | archived
7
+ await sql`ALTER TABLE jobs ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active'`;
8
+ // Backfill from enabled boolean
9
+ await sql`UPDATE jobs SET status = CASE WHEN enabled THEN 'active' ELSE 'disabled' END WHERE status = 'active'`;
10
+ }
@@ -3,7 +3,7 @@ import { CronExpressionParser } from "cron-parser";
3
3
  import { parseDuration } from "../../utils/duration";
4
4
  import { computeInitialNextRun } from "../../utils/schedule";
5
5
  import { getConfig } from "../../utils/config";
6
- import type { ScheduleType } from "../../types";
6
+ import type { ScheduleType, JobLifecycle } from "../../types";
7
7
 
8
8
  /** Validate that a schedule string matches its declared type. Throws on mismatch. */
9
9
  function validateSchedule(schedule: string, scheduleType: ScheduleType): void {
@@ -36,7 +36,7 @@ export interface Job {
36
36
  name: string;
37
37
  schedule: string;
38
38
  prompt: string;
39
- enabled: boolean;
39
+ status: JobLifecycle;
40
40
  always: boolean;
41
41
  scheduleType: ScheduleType;
42
42
  agent: string | null;
@@ -49,12 +49,23 @@ export interface Job {
49
49
  updatedAt: string;
50
50
  }
51
51
 
52
+ const COLS =
53
+ "name, schedule, prompt, status, always, schedule_type, agent, employee, model, stateless, next_run_at, last_run_at, created_at, updated_at";
54
+
52
55
  function toJob(r: Record<string, any>): Job {
56
+ // Support both old (enabled boolean) and new (status text) schema
57
+ let status: JobLifecycle;
58
+ if (typeof r.status === "string" && ["active", "disabled", "archived"].includes(r.status)) {
59
+ status = r.status as JobLifecycle;
60
+ } else {
61
+ status = r.enabled ? "active" : "disabled";
62
+ }
63
+
53
64
  return {
54
65
  name: r.name,
55
66
  schedule: r.schedule,
56
67
  prompt: r.prompt,
57
- enabled: r.enabled,
68
+ status,
58
69
  always: r.always ?? false,
59
70
  scheduleType: r.schedule_type || "cron",
60
71
  agent: r.agent || null,
@@ -92,23 +103,21 @@ export async function create(
92
103
  }
93
104
  const sql = getSql();
94
105
  await sql`
95
- INSERT INTO jobs (name, schedule, prompt, always, schedule_type, next_run_at, agent, stateless, model, employee)
96
- VALUES (${name}, ${schedule}, ${prompt}, ${always}, ${scheduleType}, ${nextRunAt ?? null}, ${agent ?? null}, ${stateless}, ${model ?? null}, ${employee ?? null})
106
+ INSERT INTO jobs (name, schedule, prompt, always, schedule_type, next_run_at, agent, stateless, model, employee, status)
107
+ VALUES (${name}, ${schedule}, ${prompt}, ${always}, ${scheduleType}, ${nextRunAt ?? null}, ${agent ?? null}, ${stateless}, ${model ?? null}, ${employee ?? null}, 'active')
97
108
  `;
98
109
  await notifyChange();
99
110
  }
100
111
 
101
112
  export async function list(): Promise<Job[]> {
102
113
  const sql = getSql();
103
- const rows =
104
- await sql`SELECT name, schedule, prompt, enabled, always, schedule_type, agent, employee, model, stateless, next_run_at, last_run_at, created_at, updated_at FROM jobs ORDER BY name`;
114
+ const rows = await sql`SELECT ${sql.unsafe(COLS)} FROM jobs ORDER BY name`;
105
115
  return rows.map(toJob);
106
116
  }
107
117
 
108
118
  export async function get(name: string): Promise<Job | null> {
109
119
  const sql = getSql();
110
- const rows =
111
- await sql`SELECT name, schedule, prompt, enabled, always, schedule_type, agent, employee, model, stateless, next_run_at, last_run_at, created_at, updated_at FROM jobs WHERE name = ${name}`;
120
+ const rows = await sql`SELECT ${sql.unsafe(COLS)} FROM jobs WHERE name = ${name}`;
112
121
  return rows.length > 0 ? toJob(rows[0]) : null;
113
122
  }
114
123
 
@@ -117,7 +126,7 @@ export async function update(
117
126
  fields: Partial<{
118
127
  schedule: string;
119
128
  prompt: string;
120
- enabled: boolean;
129
+ status: JobLifecycle;
121
130
  always: boolean;
122
131
  agent: string | null;
123
132
  employee: string | null;
@@ -133,7 +142,7 @@ export async function update(
133
142
  const schedule = fields.schedule ?? existing.schedule;
134
143
  const scheduleType = fields.scheduleType ?? existing.scheduleType;
135
144
  const prompt = fields.prompt ?? existing.prompt;
136
- const enabled = fields.enabled ?? existing.enabled;
145
+ const status = fields.status ?? existing.status;
137
146
  const always = fields.always ?? existing.always;
138
147
  const agent = fields.agent !== undefined ? fields.agent : existing.agent;
139
148
  const employee = fields.employee !== undefined ? fields.employee : existing.employee;
@@ -149,13 +158,13 @@ export async function update(
149
158
  const nextRun = computeInitialNextRun(scheduleType, schedule, getConfig().timezone);
150
159
  await sql`
151
160
  UPDATE jobs
152
- SET schedule = ${schedule}, schedule_type = ${scheduleType}, prompt = ${prompt}, enabled = ${enabled}, always = ${always}, agent = ${agent}, employee = ${employee}, model = ${model}, stateless = ${stateless}, next_run_at = ${nextRun}, updated_at = NOW()
161
+ SET schedule = ${schedule}, schedule_type = ${scheduleType}, prompt = ${prompt}, status = ${status}, always = ${always}, agent = ${agent}, employee = ${employee}, model = ${model}, stateless = ${stateless}, next_run_at = ${nextRun}, updated_at = NOW()
153
162
  WHERE name = ${name}
154
163
  `;
155
164
  } else {
156
165
  await sql`
157
166
  UPDATE jobs
158
- SET schedule = ${schedule}, schedule_type = ${scheduleType}, prompt = ${prompt}, enabled = ${enabled}, always = ${always}, agent = ${agent}, employee = ${employee}, model = ${model}, stateless = ${stateless}, updated_at = NOW()
167
+ SET schedule = ${schedule}, schedule_type = ${scheduleType}, prompt = ${prompt}, status = ${status}, always = ${always}, agent = ${agent}, employee = ${employee}, model = ${model}, stateless = ${stateless}, updated_at = NOW()
159
168
  WHERE name = ${name}
160
169
  `;
161
170
  }
@@ -172,17 +181,16 @@ export async function remove(name: string): Promise<boolean> {
172
181
 
173
182
  export async function listEnabled(): Promise<Job[]> {
174
183
  const sql = getSql();
175
- const rows =
176
- await sql`SELECT name, schedule, prompt, enabled, always, schedule_type, agent, employee, model, stateless, next_run_at, last_run_at, created_at, updated_at FROM jobs WHERE enabled = TRUE ORDER BY name`;
184
+ const rows = await sql`SELECT ${sql.unsafe(COLS)} FROM jobs WHERE status = 'active' ORDER BY name`;
177
185
  return rows.map(toJob);
178
186
  }
179
187
 
180
188
  export async function listDue(): Promise<Job[]> {
181
189
  const sql = getSql();
182
190
  const rows = await sql`
183
- SELECT name, schedule, prompt, enabled, always, schedule_type, agent, employee, model, stateless, next_run_at, last_run_at, created_at, updated_at
191
+ SELECT ${sql.unsafe(COLS)}
184
192
  FROM jobs
185
- WHERE enabled = TRUE AND next_run_at <= NOW()
193
+ WHERE status = 'active' AND next_run_at <= NOW()
186
194
  ORDER BY next_run_at
187
195
  `;
188
196
  return rows.map(toJob);
@@ -193,7 +201,7 @@ export async function markRun(name: string, nextRunAt: Date | null): Promise<voi
193
201
  if (nextRunAt) {
194
202
  await sql`UPDATE jobs SET last_run_at = NOW(), next_run_at = ${nextRunAt}, updated_at = NOW() WHERE name = ${name}`;
195
203
  } else {
196
- await sql`UPDATE jobs SET last_run_at = NOW(), enabled = FALSE, updated_at = NOW() WHERE name = ${name}`;
204
+ await sql`UPDATE jobs SET last_run_at = NOW(), status = 'disabled', updated_at = NOW() WHERE name = ${name}`;
197
205
  }
198
206
  await notifyChange();
199
207
  }
package/src/mcp/server.ts CHANGED
@@ -106,6 +106,22 @@ export function createNiaMcpServer() {
106
106
  ],
107
107
  }),
108
108
  ),
109
+ tool(
110
+ "archive_job",
111
+ "Archive a job (out of sight, won't run). Use unarchive_job to bring it back.",
112
+ { name: z.string().describe("Job name to archive") },
113
+ async (args) => ({
114
+ content: [{ type: "text" as const, text: await handlers.archiveJob(args.name) }],
115
+ }),
116
+ ),
117
+ tool(
118
+ "unarchive_job",
119
+ "Unarchive a job back to disabled state. Use enable_job after to start running it.",
120
+ { name: z.string().describe("Job name to unarchive") },
121
+ async (args) => ({
122
+ content: [{ type: "text" as const, text: await handlers.unarchiveJob(args.name) }],
123
+ }),
124
+ ),
109
125
  tool(
110
126
  "run_job",
111
127
  "Trigger a job to run immediately on the next scheduler tick",
package/src/mcp/tools.ts CHANGED
@@ -96,7 +96,7 @@ export async function removeJob(name: string): Promise<string> {
96
96
  }
97
97
 
98
98
  export async function enableJob(name: string): Promise<string> {
99
- const updated = await Job.update(name, { enabled: true });
99
+ const updated = await Job.update(name, { status: "active" });
100
100
  if (!updated) return `Job "${name}" not found.`;
101
101
 
102
102
  const job = await Job.get(name);
@@ -110,10 +110,20 @@ export async function enableJob(name: string): Promise<string> {
110
110
  }
111
111
 
112
112
  export async function disableJob(name: string): Promise<string> {
113
- const updated = await Job.update(name, { enabled: false });
113
+ const updated = await Job.update(name, { status: "disabled" });
114
114
  return updated ? `Job "${name}" disabled.` : `Job "${name}" not found.`;
115
115
  }
116
116
 
117
+ export async function archiveJob(name: string): Promise<string> {
118
+ const updated = await Job.update(name, { status: "archived" });
119
+ return updated ? `Job "${name}" archived.` : `Job "${name}" not found.`;
120
+ }
121
+
122
+ export async function unarchiveJob(name: string): Promise<string> {
123
+ const updated = await Job.update(name, { status: "disabled" });
124
+ return updated ? `Job "${name}" unarchived (disabled). Enable with enable_job.` : `Job "${name}" not found.`;
125
+ }
126
+
117
127
  export async function runJobNow(name: string): Promise<string> {
118
128
  const job = await Job.get(name);
119
129
  if (!job) return `Job "${name}" not found.`;
@@ -20,7 +20,7 @@ Prefer MCP tools for job/message management (faster, no subprocess overhead), bu
20
20
 
21
21
  You have MCP tools for managing jobs directly (preferred over CLI for speed):
22
22
 
23
- - **list_jobs** — see all scheduled jobs with status and next run time
23
+ - **list_jobs** — see all scheduled jobs with status and next run time. Jobs have three statuses: `active` (running on schedule), `disabled` (paused but visible), `archived` (hidden from list, won't run — use `nia job archive/unarchive` or MCP tools)
24
24
  - **add_job** — create a new job. Supports three schedule types:
25
25
  - `cron`: standard cron expression (e.g. `0 9 * * *` = daily at 9am, `*/5 * * * *` = every 5 min)
26
26
  - `interval`: duration string (e.g., "5m", "2h", "1d" = every 5 min/2 hours/1 day)
@@ -28,10 +28,14 @@ You have MCP tools for managing jobs directly (preferred over CLI for speed):
28
28
  - Set `always: true` to run 24/7 (ignores active hours)
29
29
  - Set `stateless: true` to disable working memory (no state.md or workspace)
30
30
  - Set `model` to override the default (e.g., `haiku`, `sonnet`, `opus`) — use cheaper models for high-frequency or simple jobs. Priority: job model > agent model > config model.
31
- - **update_job** update an existing job's schedule, prompt, always, stateless, agent, or model
31
+ - Set `employee` to assign the job to an employee (employee identity takes precedence over agent)
32
+ - **update_job** — update an existing job's schedule, prompt, always, stateless, agent, model, or employee
32
33
  - **remove_job** — delete a job by name
33
34
  - **enable_job** / **disable_job** — toggle a job on or off
35
+ - **archive_job** — archive a job (hidden from list, won't run)
36
+ - **unarchive_job** — unarchive a job back to disabled state
34
37
  - **run_job** — trigger a job to run immediately
38
+ - **list_employees** — list all employees with role, project, status
35
39
  - **send_message** — send a message to the user (via telegram, slack, or default channel). Supports `media_path` to send images/files.
36
40
  - **list_messages** — read recent chat history
37
41
  - **list_sessions** — browse past conversation sessions with previews and message counts. Returns session IDs.
@@ -89,6 +93,18 @@ Config reference:
89
93
  - `channels.slack.watch` — per-channel proactive monitoring. Keys use `channel_id#channel_name` format. The `behavior` field is optional and has three forms: (1) omitted — loads `~/.niahere/watches/<channel_name>/behavior.md`; (2) single word like `deal-monitor` — loads `~/.niahere/watches/deal-monitor/behavior.md` (dir-per-watch, like agents); (3) inline prose. File-backed watches hot-reload via mtime tracking, no restart needed.
90
94
  {{slackWatch}}
91
95
 
96
+ ## Employees
97
+
98
+ Employees are persistent co-founders scoped to projects — not just role prompts like agents, but full identities with their own memory, goals, decisions, and org chart position.
99
+
100
+ Each employee lives in `~/.niahere/employees/<name>/` and has an `EMPLOYEE.md` identity file plus working memory. Employee identity takes precedence over agent identity when both are present.
101
+
102
+ CLI: `nia employee add|list|show|pause|resume|remove|approvals`
103
+ Chat: `nia chat --employee <name>` or `nia employee <name>`
104
+ Jobs: assign via `--employee` flag or `employee` parameter in MCP tools
105
+
106
+ Use `list_employees` to see all employees with their role, project, and status.
107
+
92
108
  ## Conversation History
93
109
 
94
110
  You have access to all prior conversations stored in the database:
@@ -34,4 +34,6 @@ export interface EngineOptions {
34
34
  employee?: string;
35
35
  agent?: string;
36
36
  job?: string;
37
+ /** Watch channel behavior — injected into system prompt for watch-mode engines. */
38
+ watchBehavior?: { channel: string; behavior: string };
37
39
  }
@@ -4,6 +4,9 @@ export type JobStatus = "ok" | "error";
4
4
  /** Status of a job in the cron state (includes running). */
5
5
  export type JobStateStatus = "ok" | "error" | "running";
6
6
 
7
+ /** Lifecycle state of a job. */
8
+ export type JobLifecycle = "active" | "disabled" | "archived";
9
+
7
10
  /** Schedule type for jobs. */
8
11
  export type ScheduleType = "cron" | "interval" | "once";
9
12
 
@@ -1,5 +1,5 @@
1
1
  export type { Attachment } from "./attachment";
2
- export type { JobStatus, JobStateStatus, ScheduleType, Mode, AttachmentType, ChannelName } from "./enums";
2
+ export type { JobStatus, JobStateStatus, JobLifecycle, ScheduleType, Mode, AttachmentType, ChannelName } from "./enums";
3
3
  export type { JobInput, JobResult } from "./job";
4
4
  export type { SendResult, StreamCallback, ActivityCallback, SendCallbacks, ChatEngine, EngineOptions } from "./engine";
5
5
  export type { AuditEntry, JobState, CronState } from "./audit";