@fickydev/pigent 0.1.18 → 0.1.20

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/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.20 - 2026-05-18
4
+
5
+ ### Fixed
6
+
7
+ - Fixed custom tools (task_list, task_create, task_update, task_remove) being blocked by `tools: []` in PiAgentRunner. Changed to `noTools: "builtin"` which disables built-in tools but lets custom tools pass through the SDK's tool allowlist filter.
8
+
9
+ ## 0.1.19 - 2026-05-18
10
+
11
+ ### Added
12
+
13
+ - Added task management as a built-in skill + tools. Agents with `task-management` in their `skills[]` can now list, create, update, and remove scheduled tasks via natural language.
14
+ - Added `task_list`, `task_create`, `task_update`, `task_remove` Pi SDK custom tools.
15
+ - Added `skills/task-management/SKILL.md` built-in skill document.
16
+ - Skill path resolution now supports bare names (e.g., `task-management`) resolved against pigent's built-in skills directory.
17
+ - Added `TaskConfigRepository.update()` and `countByAgentAndChatId()` methods.
18
+ - Added `Scheduler.addDynamicTaskFromRow()` and `reloadTasks()` for tool-driven hot-reload.
19
+ - AgentRunner now accepts optional Scheduler reference and builds per-run custom tools with agent+chat context.
20
+ - Capped at 5 tasks per agent per chat.
21
+ - Default assistant agent now includes `task-management` skill.
22
+
3
23
  ## 0.1.18 - 2026-05-18
4
24
 
5
25
  ### Added
package/TODO.md CHANGED
@@ -146,7 +146,8 @@
146
146
  - [x] Make README more end-user facing
147
147
  - [x] Confirm per-agent system prompt injection
148
148
  - [x] Confirm per-agent skills loading
149
- - [ ] Confirm per-agent extensions loading
149
+ - [x] Confirm per-agent extensions loading
150
+ - [ ] Confirm per-agent custom tools loading
150
151
  - [x] Prototype one prompt through Pi SDK
151
152
  - [x] Decide CLI fallback strategy if SDK gaps appear
152
153
 
@@ -167,7 +168,8 @@
167
168
  - [x] Scheduler loads tasks from both config file + DB
168
169
  - [x] Share session lock between Scheduler and AgentRunner (prevent concurrent Pi runs for same agent+chat)
169
170
  - [x] Add max task runs per hour rate limit
170
- - [ ] Add `/task status` or similar command
171
+ - [x] Add `/task status` or similar command
172
+ - [x] Add task management as agent skill + tools (natural language task CRUD)
171
173
 
172
174
  ## Policy And Safety
173
175
 
@@ -5,7 +5,8 @@ model: null
5
5
  thinkingLevel: null
6
6
  workspace: ~/.pigent/workspaces/assistant
7
7
  systemPromptFile: ./SYSTEM.md
8
- skills: []
8
+ skills:
9
+ - task-management
9
10
  extensions: []
10
11
  channels:
11
12
  telegram:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fickydev/pigent",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Autonomous multi-agent daemon using Pi as core execution engine.",
@@ -22,6 +22,7 @@
22
22
  ],
23
23
  "files": [
24
24
  "src",
25
+ "skills",
25
26
  "agents",
26
27
  "profiles",
27
28
  "drizzle",
@@ -0,0 +1,59 @@
1
+ ---
2
+ name: task-management
3
+ description: Manage scheduled tasks — list, create, update, and remove recurring prompts for this agent and chat.
4
+ disable-model-invocation: false
5
+ ---
6
+
7
+ # Task Management
8
+
9
+ You can manage scheduled recurring tasks for this agent and chat.
10
+
11
+ ## Available Tools
12
+
13
+ ### `task_list`
14
+ List all scheduled tasks for this agent and chat.
15
+
16
+ ### `task_create`
17
+ Create a new scheduled task. Provide:
18
+ - `prompt` (required): what to do at each interval. Be specific.
19
+ - `intervalMs` (optional): how often to run. Default 600000 (10 minutes). Minimum 60000 (1 minute).
20
+
21
+ When the task runs, if the result is "NOOP", nothing is sent to the user.
22
+
23
+ ### `task_update`
24
+ Update an existing task's prompt or interval. Provide:
25
+ - `id` (required): task ID
26
+ - `prompt` (optional): new prompt text
27
+ - `intervalMs` (optional): new interval in milliseconds
28
+
29
+ ### `task_remove`
30
+ Remove a scheduled task by ID.
31
+
32
+ ## Limits
33
+
34
+ - Maximum 5 tasks per agent per chat.
35
+ - Minimum interval: 1 minute.
36
+
37
+ ## When to Use
38
+
39
+ - User asks to "check something periodically" or "monitor something"
40
+ - User asks to "run something every X minutes/hours"
41
+ - User wants to change or stop a recurring task
42
+ - User asks "what tasks do I have?" or "what's running?"
43
+
44
+ ## Examples
45
+
46
+ User: "check my build every 5 minutes"
47
+ → task_create({ prompt: "Check the build pipeline status. Report failures. NOOP if everything is green.", intervalMs: 300000 })
48
+
49
+ User: "remind me to review PRs every hour"
50
+ → task_create({ prompt: "Check for open pull requests that need review. List them. NOOP if none.", intervalMs: 3600000 })
51
+
52
+ User: "change the build checker to run every 2 minutes"
53
+ → task_list() to find the task, then task_update({ id: "abc123", intervalMs: 120000 })
54
+
55
+ User: "stop the build checker"
56
+ → task_list() to find the task, then task_remove({ id: "abc123" })
57
+
58
+ User: "what recurring tasks do I have?"
59
+ → task_list()
@@ -1,8 +1,12 @@
1
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
1
2
  import type { InboundMessage } from "../channels/types";
3
+ import type { LoadedAgentConfig } from "../config/schemas";
2
4
  import type { Repositories } from "../db/repositories";
3
5
  import { logger } from "../logging/logger";
4
6
  import { PiAgentRunner } from "../pi/PiAgentRunner";
7
+ import { createTaskTools, type TaskToolContext } from "../pi/tools/taskTools";
5
8
  import type { AgentRegistry } from "./AgentRegistry";
9
+ import type { Scheduler } from "../daemon/Scheduler";
6
10
 
7
11
  export type AgentRunInput = {
8
12
  agentId: string;
@@ -21,8 +25,13 @@ export class AgentRunner {
21
25
  constructor(
22
26
  private readonly registry: AgentRegistry,
23
27
  private readonly repositories: Repositories,
28
+ private scheduler: Scheduler | null = null,
24
29
  ) {}
25
30
 
31
+ setScheduler(scheduler: Scheduler): void {
32
+ this.scheduler = scheduler;
33
+ }
34
+
26
35
  async run(input: AgentRunInput): Promise<string> {
27
36
  return this.withSessionLock(input, () => this.runUnlocked(input));
28
37
  }
@@ -109,6 +118,7 @@ export class AgentRunner {
109
118
  profile: this.registry.getProfile(agent.profile),
110
119
  session,
111
120
  prompt: this.composePrompt(input, chatInstructions),
121
+ customTools: this.buildCustomTools(agent, input.message),
112
122
  });
113
123
 
114
124
  if (session.piSessionId !== result.piSessionId || session.piSessionPath !== result.piSessionPath) {
@@ -135,6 +145,45 @@ export class AgentRunner {
135
145
  }
136
146
  }
137
147
 
148
+ private buildCustomTools(agent: LoadedAgentConfig, message: InboundMessage): ToolDefinition[] | undefined {
149
+ const tools: ToolDefinition[] = [];
150
+
151
+ if (this.hasSkill(agent, "task-management") && this.scheduler) {
152
+ const ctx: TaskToolContext = {
153
+ agentId: agent.id,
154
+ channel: message.channel,
155
+ chatId: message.chatId,
156
+ taskConfigRepo: this.repositories.taskConfigs,
157
+ schedulerHotReload: async (action, task) => {
158
+ if (action === "add") {
159
+ const row = await this.repositories.taskConfigs.findById(task.id);
160
+ if (row) {
161
+ this.scheduler!.addDynamicTaskFromRow(row);
162
+ }
163
+ } else if (action === "remove") {
164
+ await this.scheduler!.removeDynamicTask(task.id);
165
+ } else if (action === "update") {
166
+ await this.scheduler!.reloadTasks();
167
+ }
168
+ },
169
+ };
170
+ tools.push(...createTaskTools(ctx));
171
+ }
172
+
173
+ return tools.length > 0 ? tools : undefined;
174
+ }
175
+
176
+ private hasSkill(agent: LoadedAgentConfig, skillName: string): boolean {
177
+ const allSkills = [
178
+ ...(agent.profile ? (this.registry.getProfile(agent.profile)?.defaultSkills ?? []) : []),
179
+ ...agent.skills,
180
+ ];
181
+ return allSkills.some((s) => {
182
+ const base = s.split("/").pop() ?? s;
183
+ return base === skillName || s === skillName;
184
+ });
185
+ }
186
+
138
187
  private composePrompt(input: AgentRunInput, chatInstructions: string): string {
139
188
  const parts: string[] = [
140
189
  `[Channel]\n${input.message.channel}`,
@@ -28,6 +28,7 @@ export class AgentDaemon {
28
28
  this.registry = new AgentRegistry(config, repositories);
29
29
  this.runner = new AgentRunner(this.registry, repositories);
30
30
  this.scheduler = new Scheduler(config.scheduler, this.registry, repositories, this.adapters, this.runner);
31
+ this.runner.setScheduler(this.scheduler);
31
32
  this.commands = new BotCommandHandler(this.registry, repositories, config.modelChoices, this.scheduler);
32
33
  this.router = new MessageRouter(repositories, this.registry);
33
34
  }
@@ -100,6 +100,34 @@ export class Scheduler {
100
100
  return removed;
101
101
  }
102
102
 
103
+ /** Add a task from an already-saved DB row (e.g., created by a tool). Hot-adds to scheduler. */
104
+ addDynamicTaskFromRow(row: TaskConfigRow): void {
105
+ // Avoid duplicates
106
+ if (this.tasks.some((t) => t.id === row.id)) return;
107
+
108
+ this.tasks.push({
109
+ id: row.id,
110
+ agent: row.agent,
111
+ intervalMs: row.intervalMs,
112
+ prompt: row.prompt,
113
+ channel: row.channel,
114
+ chatId: row.chatId,
115
+ maxRunsPerHour: row.maxRunsPerHour ?? 12,
116
+ });
117
+
118
+ if (!this.running && this.tasks.length === 1) {
119
+ this.running = true;
120
+ this.tick();
121
+ logger.info("scheduler started on first dynamic task");
122
+ }
123
+ }
124
+
125
+ /** Reload all tasks from config + DB. Used after task updates. */
126
+ async reloadTasks(): Promise<void> {
127
+ this.tasks = await this.loadAllTasks();
128
+ logger.info("scheduler tasks reloaded", { taskCount: this.tasks.length });
129
+ }
130
+
103
131
  private async loadAllTasks(): Promise<TaskConfig[]> {
104
132
  const configTasks: TaskConfig[] = this.schedulerConfig.tasks;
105
133
  const dbTasks = await this.repositories.taskConfigs.findAllEnabled();
@@ -1,4 +1,4 @@
1
- import { eq } from "drizzle-orm";
1
+ import { and, eq } from "drizzle-orm";
2
2
  import { nanoid } from "nanoid";
3
3
  import type { DbClient } from "../client";
4
4
  import { taskConfigs, type TaskConfigRow } from "../schema";
@@ -60,4 +60,20 @@ export class TaskConfigRepository {
60
60
  where: eq(taskConfigs.enabled, true),
61
61
  });
62
62
  }
63
+
64
+ async update(id: string, patch: { prompt?: string; intervalMs?: number }): Promise<TaskConfigRow | null> {
65
+ await this.db
66
+ .update(taskConfigs)
67
+ .set({ ...patch, updatedAt: Date.now() })
68
+ .where(eq(taskConfigs.id, id));
69
+
70
+ return this.findById(id);
71
+ }
72
+
73
+ async countByAgentAndChatId(agent: string, chatId: string): Promise<number> {
74
+ const rows = await this.db.query.taskConfigs.findMany({
75
+ where: and(eq(taskConfigs.agent, agent), eq(taskConfigs.chatId, chatId), eq(taskConfigs.enabled, true)),
76
+ });
77
+ return rows.length;
78
+ }
63
79
  }
@@ -5,6 +5,7 @@ import {
5
5
  ModelRegistry,
6
6
  getAgentDir,
7
7
  SettingsManager,
8
+ type ToolDefinition,
8
9
  } from "@earendil-works/pi-coding-agent";
9
10
  import { mkdir } from "node:fs/promises";
10
11
  import { resolve } from "node:path";
@@ -18,6 +19,7 @@ export type PiAgentRunInput = {
18
19
  profile: LoadedProfileConfig | null;
19
20
  session: AgentSessionRow;
20
21
  prompt: string;
22
+ customTools?: ToolDefinition[];
21
23
  };
22
24
 
23
25
  export type PiContextUsage = {
@@ -104,7 +106,8 @@ export class PiAgentRunner {
104
106
  sessionManager: piSession.manager,
105
107
  settingsManager,
106
108
  agentDir,
107
- tools: [],
109
+ noTools: "builtin",
110
+ customTools: input.customTools,
108
111
  });
109
112
 
110
113
  return { session, piSession };
@@ -116,17 +119,28 @@ function resolveSkillPaths(agent: LoadedAgentConfig, profile: LoadedProfileConfi
116
119
 
117
120
  if (profile) {
118
121
  for (const s of profile.defaultSkills) {
119
- paths.push(resolve(profile.baseDir, s));
122
+ paths.push(resolveSkillPath(s, profile.baseDir));
120
123
  }
121
124
  }
122
125
 
123
126
  for (const s of agent.skills) {
124
- paths.push(resolve(agent.baseDir, s));
127
+ paths.push(resolveSkillPath(s, agent.baseDir));
125
128
  }
126
129
 
127
130
  return paths;
128
131
  }
129
132
 
133
+ function resolveSkillPath(skillRef: string, baseDir: string): string {
134
+ // Absolute path
135
+ if (skillRef.startsWith("/")) return skillRef;
136
+ // Home-relative path
137
+ if (skillRef.startsWith("~/")) return resolve(skillRef);
138
+ // Explicit relative path
139
+ if (skillRef.startsWith("./") || skillRef.startsWith("../")) return resolve(baseDir, skillRef);
140
+ // Built-in pigent skill (bare name like "task-management")
141
+ return resolve(import.meta.dir, "../../skills", skillRef);
142
+ }
143
+
130
144
  function composeSystemPrompt(agent: LoadedAgentConfig, profile: LoadedProfileConfig | null): string {
131
145
  return [
132
146
  profile?.instructions,
@@ -0,0 +1,188 @@
1
+ import { Type } from "typebox";
2
+ import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
3
+ import type { TaskConfigRepository } from "../../db/repositories/TaskConfigRepository";
4
+
5
+ export type TaskToolContext = {
6
+ agentId: string;
7
+ channel: string;
8
+ chatId: string;
9
+ taskConfigRepo: TaskConfigRepository;
10
+ schedulerHotReload: (action: "add" | "remove" | "update", task: { id: string }) => Promise<void>;
11
+ };
12
+
13
+ const MAX_TASKS_PER_CHAT = 5;
14
+
15
+ export function createTaskTools(ctx: TaskToolContext): ToolDefinition[] {
16
+ return [
17
+ createTaskListTool(ctx),
18
+ createTaskCreateTool(ctx),
19
+ createTaskUpdateTool(ctx),
20
+ createTaskRemoveTool(ctx),
21
+ ];
22
+ }
23
+
24
+ function createTaskListTool(ctx: TaskToolContext): ToolDefinition {
25
+ return defineTool({
26
+ name: "task_list",
27
+ label: "List Scheduled Tasks",
28
+ description: "List all scheduled tasks for the current agent and chat.",
29
+ promptSnippet: "List scheduled tasks",
30
+ parameters: Type.Object({}),
31
+ async execute(_callId, _params, _signal, _onUpdate, _extCtx) {
32
+ const tasks = await ctx.taskConfigRepo.findAllByChatId(ctx.chatId);
33
+ const mine = tasks.filter((t) => t.agent === ctx.agentId && t.enabled);
34
+
35
+ if (mine.length === 0) {
36
+ return { content: [{ type: "text", text: "No scheduled tasks." }], details: { count: 0 } };
37
+ }
38
+
39
+ const lines = mine.map((t) => {
40
+ const mins = Math.round(t.intervalMs / 60_000);
41
+ return `- ${t.id}: "${t.prompt}" (every ${mins}m)`;
42
+ });
43
+
44
+ return {
45
+ content: [{ type: "text", text: `Scheduled tasks (${mine.length}):\n${lines.join("\n")}` }],
46
+ details: { count: mine.length, tasks: mine },
47
+ };
48
+ },
49
+ });
50
+ }
51
+
52
+ function createTaskCreateTool(ctx: TaskToolContext): ToolDefinition {
53
+ return defineTool({
54
+ name: "task_create",
55
+ label: "Create Scheduled Task",
56
+ description:
57
+ "Create a new scheduled task for the current agent and chat. " +
58
+ "The task will run the given prompt at the specified interval. " +
59
+ "If the prompt result is NOOP, nothing is sent to the chat. " +
60
+ `Maximum ${MAX_TASKS_PER_CHAT} tasks per agent per chat.`,
61
+ promptSnippet: "Create a scheduled task with a prompt and interval",
62
+ parameters: Type.Object({
63
+ prompt: Type.String({ description: "The prompt to run at each interval. Be specific about what to check or do." }),
64
+ intervalMs: Type.Optional(
65
+ Type.Number({ description: "Interval in milliseconds. Default: 600000 (10 minutes). Minimum: 60000 (1 minute)." }),
66
+ ),
67
+ }),
68
+ async execute(_callId, params, _signal, _onUpdate, _extCtx) {
69
+ const intervalMs = Math.max(60_000, params.intervalMs ?? 600_000);
70
+
71
+ const count = await ctx.taskConfigRepo.countByAgentAndChatId(ctx.agentId, ctx.chatId);
72
+ if (count >= MAX_TASKS_PER_CHAT) {
73
+ return {
74
+ content: [
75
+ {
76
+ type: "text" as const,
77
+ text: `Cannot create task: maximum ${MAX_TASKS_PER_CHAT} tasks per agent per chat reached. Remove an existing task first.`,
78
+ },
79
+ ],
80
+ details: { error: "max_tasks_reached", count },
81
+ };
82
+ }
83
+
84
+ const saved = await ctx.taskConfigRepo.create({
85
+ agent: ctx.agentId,
86
+ intervalMs,
87
+ prompt: params.prompt,
88
+ channel: ctx.channel,
89
+ chatId: ctx.chatId,
90
+ });
91
+
92
+ await ctx.schedulerHotReload("add", { id: saved.id });
93
+
94
+ const mins = Math.round(intervalMs / 60_000);
95
+ return {
96
+ content: [
97
+ {
98
+ type: "text" as const,
99
+ text: `Task created: ${saved.id}\nPrompt: "${params.prompt}"\nInterval: every ${mins}m`,
100
+ },
101
+ ],
102
+ details: { id: saved.id, intervalMs },
103
+ };
104
+ },
105
+ });
106
+ }
107
+
108
+ function createTaskUpdateTool(ctx: TaskToolContext): ToolDefinition {
109
+ return defineTool({
110
+ name: "task_update",
111
+ label: "Update Scheduled Task",
112
+ description: "Update the prompt or interval of an existing scheduled task.",
113
+ parameters: Type.Object({
114
+ id: Type.String({ description: "Task ID to update." }),
115
+ prompt: Type.Optional(Type.String({ description: "New prompt text." })),
116
+ intervalMs: Type.Optional(Type.Number({ description: "New interval in milliseconds. Minimum: 60000 (1 minute)." })),
117
+ }),
118
+ async execute(_callId, params, _signal, _onUpdate, _extCtx) {
119
+ if (!params.prompt && !params.intervalMs) {
120
+ return {
121
+ content: [{ type: "text", text: "Nothing to update. Provide at least a prompt or intervalMs." }],
122
+ details: { error: "no_changes" },
123
+ };
124
+ }
125
+
126
+ const existing = await ctx.taskConfigRepo.findById(params.id);
127
+ if (!existing || existing.agent !== ctx.agentId || existing.chatId !== ctx.chatId) {
128
+ return {
129
+ content: [{ type: "text", text: `Task not found: ${params.id}` }],
130
+ details: { error: "not_found" },
131
+ };
132
+ }
133
+
134
+ const patch: { prompt?: string; intervalMs?: number } = {};
135
+ if (params.prompt) patch.prompt = params.prompt;
136
+ if (params.intervalMs) patch.intervalMs = Math.max(60_000, params.intervalMs);
137
+
138
+ const updated = await ctx.taskConfigRepo.update(params.id, patch);
139
+ if (!updated) {
140
+ return {
141
+ content: [{ type: "text", text: `Failed to update task: ${params.id}` }],
142
+ details: { error: "update_failed" },
143
+ };
144
+ }
145
+
146
+ await ctx.schedulerHotReload("update", { id: params.id });
147
+
148
+ const mins = Math.round(updated.intervalMs / 60_000);
149
+ return {
150
+ content: [
151
+ {
152
+ type: "text",
153
+ text: `Task updated: ${updated.id}\nPrompt: "${updated.prompt}"\nInterval: every ${mins}m`,
154
+ },
155
+ ],
156
+ details: { id: updated.id },
157
+ };
158
+ },
159
+ });
160
+ }
161
+
162
+ function createTaskRemoveTool(ctx: TaskToolContext): ToolDefinition {
163
+ return defineTool({
164
+ name: "task_remove",
165
+ label: "Remove Scheduled Task",
166
+ description: "Remove a scheduled task by ID.",
167
+ parameters: Type.Object({
168
+ id: Type.String({ description: "Task ID to remove." }),
169
+ }),
170
+ async execute(_callId, params, _signal, _onUpdate, _extCtx) {
171
+ const existing = await ctx.taskConfigRepo.findById(params.id);
172
+ if (!existing || existing.agent !== ctx.agentId || existing.chatId !== ctx.chatId) {
173
+ return {
174
+ content: [{ type: "text", text: `Task not found: ${params.id}` }],
175
+ details: { error: "not_found" },
176
+ };
177
+ }
178
+
179
+ await ctx.taskConfigRepo.remove(params.id);
180
+ await ctx.schedulerHotReload("remove", { id: params.id });
181
+
182
+ return {
183
+ content: [{ type: "text", text: `Task removed: ${params.id}` }],
184
+ details: { id: params.id },
185
+ };
186
+ },
187
+ });
188
+ }