@fickydev/pigent 0.1.10 → 0.1.11

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
@@ -13,6 +13,9 @@
13
13
  - Added `task_configs` table and `TaskConfigRepository` for runtime task CRUD.
14
14
  - Added `/task list`, `/task create <intervalMs> <prompt>`, `/task remove <id>` Telegram commands.
15
15
  - Scheduler loads tasks from both config file and database, supports hot-add and hot-remove.
16
+ - Scheduler now shares AgentRunner's per-session lock instead of using its own PiAgentRunner.
17
+ - Tasks skip message persistence (inbound/outbound) in AgentRunner to avoid DB noise.
18
+ - Added `source` field to InboundMessage type (`"user"` default, `"task"` for scheduled runs).
16
19
 
17
20
  - Added `bunx @fickydev/pigent <dir>` scaffold/setup/run CLI path.
18
21
  - Added one-command setup-and-run CLI at `src/cli/run.ts` and `bun run run`.
package/PLAN.md CHANGED
@@ -77,17 +77,18 @@ Skill is Pi-compatible instruction bundle, usually a `SKILL.md` folder. Agents c
77
77
 
78
78
  Extension is Pi-compatible TypeScript/JavaScript module that adds tools, hooks, commands, or runtime behavior. Agents can load different extensions.
79
79
 
80
- ### Heartbeat
80
+ ### Scheduled Tasks
81
81
 
82
- Heartbeat is scheduled self-activation. It prompts an agent periodically to check tasks, channels, external state, or pending work.
82
+ Scheduled tasks are periodic self-activations defined in `pigent.yaml` or created at runtime via `/task create`. Each task references an agent, an interval, a prompt, and a target channel.
83
83
 
84
- Heartbeat must avoid spam via:
84
+ The global `Scheduler` ticks on a configurable interval and evaluates every task's due-ness against its last run timestamp.
85
85
 
86
- - `NOOP` convention
87
- - cooldowns
88
- - dedupe
89
- - max messages per hour
90
- - lock per agent/session
86
+ Constraints:
87
+
88
+ - `NOOP` convention — agent returns "NOOP" to skip output
89
+ - Per-task lock prevent stacking runs of the same task
90
+ - Max messages per hour — rate limit (not yet implemented)
91
+ - Tasks must share the same session lock as user messages to avoid concurrent Pi runs for the same agent+channel+chat
91
92
 
92
93
  ## Target Architecture
93
94
 
@@ -252,9 +253,10 @@ Initial entities:
252
253
  - telegram chats
253
254
  - telegram chat agents
254
255
  - messages
255
- - heartbeats
256
+ - task_runs (history)
257
+ - task_configs (runtime task definitions)
258
+ - runtime_kv (offsets, daemon state)
256
259
  - session model/thinking overrides
257
- - tasks/events later
258
260
 
259
261
  SQLite first. Keep schema types portable so PostgreSQL migration is straightforward.
260
262
 
@@ -282,7 +284,8 @@ src/
282
284
 
283
285
  daemon/
284
286
  AgentDaemon.ts
285
- HeartbeatScheduler.ts
287
+ Scheduler.ts
288
+ taskDue.ts
286
289
 
287
290
  channels/
288
291
  types.ts
@@ -299,15 +302,19 @@ src/
299
302
 
300
303
  pi/
301
304
  PiAgentRunner.ts
302
- PiSessionFactory.ts
305
+ PiAvailableModels.ts
306
+ PiModelResolver.ts
303
307
 
304
308
  db/
305
309
  client.ts
306
310
  schema.ts
307
311
  repositories/
308
312
  AgentRepository.ts
313
+ HeartbeatRepository.ts
309
314
  MessageRepository.ts
310
315
  SessionRepository.ts
316
+ TaskRepository.ts
317
+ TaskConfigRepository.ts
311
318
  TelegramRepository.ts
312
319
 
313
320
  config/
@@ -384,13 +391,16 @@ drizzle/
384
391
  - return responses
385
392
  - persist inbound/outbound messages
386
393
 
387
- ### Milestone 7: Heartbeat
394
+ ### Milestone 7: Scheduler
388
395
 
389
- - scheduler
390
- - per-agent lock
396
+ - global scheduler tick
397
+ - config-based tasks from `pigent.yaml`
398
+ - DB-based tasks (runtime CRUD via `/task` commands)
399
+ - per-task lock
400
+ - shared session lock with user messages (prevent concurrent Pi runs)
391
401
  - `NOOP` handling
392
- - notification cooldown
393
- - heartbeat history
402
+ - notification rate limits (future)
403
+ - `/task list`, `/task create`, `/task remove` commands
394
404
 
395
405
  ### Milestone 8: Safety
396
406
 
@@ -425,8 +435,9 @@ Add Hono only after daemon works. Use it for:
425
435
 
426
436
  - Should `/model <agentId> ...` support non-default agent sessions in the command itself?
427
437
  - Should model availability errors block `/model` saves or save with a warning?
428
- - Which Pi SDK session manager should be used for persistent sessions?
429
438
  - How exactly should per-agent skills/extensions be loaded into Pi runtime?
430
439
  - Should group instructions live in YAML, DB, or both?
431
440
  - Should each chat have one default agent or multiple active agents?
432
441
  - What minimum approval flow is needed before shell/file-write tools?
442
+ - Should scheduled tasks share the same session lock as user messages, or allow concurrent Pi runs?
443
+ - Should `/task create` enforce permission checks before allowing runtime task creation?
package/TODO.md CHANGED
@@ -160,6 +160,7 @@
160
160
  - [x] Add `/task create <intervalMs> <prompt>` command
161
161
  - [x] Add `/task remove <id>` command
162
162
  - [x] Scheduler loads tasks from both config file + DB
163
+ - [x] Share session lock between Scheduler and AgentRunner (prevent concurrent Pi runs for same agent+chat)
163
164
  - [ ] Add max task runs per hour rate limit
164
165
  - [ ] Add `/task status` or similar command
165
166
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fickydev/pigent",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Autonomous multi-agent daemon using Pi as core execution engine.",
@@ -1,6 +1,5 @@
1
1
  import type { InboundMessage } from "../channels/types";
2
2
  import type { Repositories } from "../db/repositories";
3
- import type { AgentSessionRow } from "../db/schema";
4
3
  import { logger } from "../logging/logger";
5
4
  import { PiAgentRunner } from "../pi/PiAgentRunner";
6
5
  import type { AgentRegistry } from "./AgentRegistry";
@@ -11,17 +10,12 @@ export type AgentRunInput = {
11
10
  message: InboundMessage;
12
11
  };
13
12
 
14
- type AgentRunResponse = {
15
- text: string;
16
- error?: string;
17
- };
18
-
19
13
  function sessionLockKey(input: AgentRunInput): string {
20
14
  return [input.agentId, input.message.channel, input.message.chatId, input.message.threadId ?? ""].join(":");
21
15
  }
22
16
 
23
17
  export class AgentRunner {
24
- private readonly piRunner = new PiAgentRunner();
18
+ readonly piRunner = new PiAgentRunner();
25
19
  private readonly sessionLocks = new Map<string, Promise<void>>();
26
20
 
27
21
  constructor(
@@ -41,42 +35,38 @@ export class AgentRunner {
41
35
  threadId: input.message.threadId,
42
36
  });
43
37
 
44
- await this.repositories.messages.create({
45
- agentId: input.agentId,
46
- sessionId: session.id,
47
- channel: input.message.channel,
48
- direction: "inbound",
49
- senderId: input.message.senderId,
50
- chatId: input.message.chatId,
51
- threadId: input.message.threadId,
52
- content: input.text,
53
- rawJson: input.message.raw,
54
- });
55
-
56
- const chatInstructions = await this.chatInstructions(input);
57
- const response = await this.createResponse(input, session, chatInstructions);
58
-
59
- if (response.error) {
38
+ // Skip DB persistence for task-triggered runs (Scheduler handles its own tracking)
39
+ if (input.message.source !== "task") {
60
40
  await this.repositories.messages.create({
61
41
  agentId: input.agentId,
62
42
  sessionId: session.id,
63
43
  channel: input.message.channel,
64
- direction: "internal",
44
+ direction: "inbound",
45
+ senderId: input.message.senderId,
65
46
  chatId: input.message.chatId,
66
47
  threadId: input.message.threadId,
67
- content: response.error,
48
+ content: input.text,
49
+ rawJson: input.message.raw,
68
50
  });
69
51
  }
70
52
 
71
- await this.repositories.messages.create({
72
- agentId: input.agentId,
73
- sessionId: session.id,
74
- channel: input.message.channel,
75
- direction: "outbound",
76
- chatId: input.message.chatId,
77
- threadId: input.message.threadId,
78
- content: response.text,
79
- });
53
+ const chatInstructions = await this.chatInstructions(input);
54
+ const response = await this.createResponse(input, session, chatInstructions);
55
+
56
+ if (!response.handlerError) {
57
+ // Task runs persist their own messages via TaskRepository
58
+ if (input.message.source !== "task") {
59
+ await this.repositories.messages.create({
60
+ agentId: input.agentId,
61
+ sessionId: session.id,
62
+ channel: input.message.channel,
63
+ direction: "outbound",
64
+ chatId: input.message.chatId,
65
+ threadId: input.message.threadId,
66
+ content: response.text,
67
+ });
68
+ }
69
+ }
80
70
 
81
71
  return response.text;
82
72
  }
@@ -103,9 +93,9 @@ export class AgentRunner {
103
93
 
104
94
  private async createResponse(
105
95
  input: AgentRunInput,
106
- session: AgentSessionRow,
96
+ session: any,
107
97
  chatInstructions: string,
108
- ): Promise<AgentRunResponse> {
98
+ ): Promise<{ text: string; handlerError?: string }> {
109
99
  if (process.env.PIGENT_FAKE_AGENT === "1") {
110
100
  return { text: this.fakeResponse(input.agentId, input.text) };
111
101
  }
@@ -131,26 +121,34 @@ export class AgentRunner {
131
121
  });
132
122
 
133
123
  if (process.env.PIGENT_FALLBACK_FAKE_AGENT === "1") {
134
- return { text: this.fakeResponse(input.agentId, input.text), error: errorMessage };
124
+ return { text: this.fakeResponse(input.agentId, input.text), handlerError: errorMessage };
135
125
  }
136
126
 
137
- return { text: "Agent failed to respond. Please try again later.", error: errorMessage };
127
+ return { text: "Agent failed to respond. Please try again later.", handlerError: errorMessage };
138
128
  }
139
129
  }
140
130
 
141
131
  private composePrompt(input: AgentRunInput, chatInstructions: string): string {
142
- return [
132
+ const parts: string[] = [
143
133
  `[Channel]\n${input.message.channel}`,
144
134
  `[Chat]\n${input.message.chatId}`,
145
- input.message.threadId ? `[Thread]\n${input.message.threadId}` : null,
146
- input.message.senderName || input.message.senderId
147
- ? `[User]\n${input.message.senderName ?? input.message.senderId}`
148
- : null,
149
- chatInstructions ? `[Chat Instructions]\n${chatInstructions}` : null,
150
- `[Message]\n${input.text}`,
151
- ]
152
- .filter(Boolean)
153
- .join("\n\n");
135
+ ];
136
+
137
+ if (input.message.threadId) {
138
+ parts.push(`[Thread]\n${input.message.threadId}`);
139
+ }
140
+
141
+ if (input.message.senderName || input.message.senderId) {
142
+ parts.push(`[User]\n${input.message.senderName ?? input.message.senderId}`);
143
+ }
144
+
145
+ if (chatInstructions) {
146
+ parts.push(`[Chat Instructions]\n${chatInstructions}`);
147
+ }
148
+
149
+ parts.push(`[Message]\n${input.text}`);
150
+
151
+ return parts.join("\n\n");
154
152
  }
155
153
 
156
154
  private async chatInstructions(input: AgentRunInput): Promise<string> {
@@ -1,5 +1,7 @@
1
1
  export type ChannelId = "telegram";
2
2
 
3
+ export type MessageSource = "user" | "task";
4
+
3
5
  export type InboundMessage = {
4
6
  id: string;
5
7
  channel: ChannelId;
@@ -10,6 +12,7 @@ export type InboundMessage = {
10
12
  text: string;
11
13
  raw: unknown;
12
14
  receivedAt: Date;
15
+ source?: MessageSource;
13
16
  };
14
17
 
15
18
  export type InlineKeyboardButton = {
@@ -26,10 +26,10 @@ export class AgentDaemon {
26
26
  ) {
27
27
  this.adapters = createAdapters(repositories);
28
28
  this.registry = new AgentRegistry(config, repositories);
29
- this.scheduler = new Scheduler(config.scheduler, this.registry, repositories, this.adapters);
29
+ this.runner = new AgentRunner(this.registry, repositories);
30
+ this.scheduler = new Scheduler(config.scheduler, this.registry, repositories, this.adapters, this.runner);
30
31
  this.commands = new BotCommandHandler(this.registry, repositories, config.modelChoices, this.scheduler);
31
32
  this.router = new MessageRouter(repositories, this.registry);
32
- this.runner = new AgentRunner(this.registry, repositories);
33
33
  }
34
34
 
35
35
  static async create(): Promise<AgentDaemon> {
@@ -2,8 +2,8 @@ import type { SchedulerConfig, TaskConfig } from "../config/schemas";
2
2
  import type { Repositories } from "../db/repositories";
3
3
  import type { TaskConfigRow } from "../db/schema";
4
4
  import { logger } from "../logging/logger";
5
- import { PiAgentRunner } from "../pi/PiAgentRunner";
6
5
  import type { AgentRegistry } from "../agents/AgentRegistry";
6
+ import type { AgentRunner } from "../agents/AgentRunner";
7
7
  import type { ChannelAdapter } from "../channels/types";
8
8
  import { isTaskDue } from "./taskDue";
9
9
 
@@ -11,7 +11,6 @@ export class Scheduler {
11
11
  private running = false;
12
12
  private tickTimer: ReturnType<typeof setTimeout> | null = null;
13
13
  private readonly taskLocks = new Map<string, boolean>();
14
- private readonly piRunner = new PiAgentRunner();
15
14
  private tasks: TaskConfig[] = [];
16
15
 
17
16
  constructor(
@@ -19,6 +18,7 @@ export class Scheduler {
19
18
  private readonly registry: AgentRegistry,
20
19
  private readonly repositories: Repositories,
21
20
  private readonly adapters: ChannelAdapter[],
21
+ private readonly agentRunner: AgentRunner,
22
22
  ) {}
23
23
 
24
24
  async start(): Promise<void> {
@@ -124,8 +124,7 @@ export class Scheduler {
124
124
  }
125
125
 
126
126
  private async runTaskIfDue(task: TaskConfig): Promise<void> {
127
- const lockKey = task.id;
128
- if (this.taskLocks.get(lockKey)) return;
127
+ if (this.taskLocks.get(task.id)) return;
129
128
 
130
129
  const lastRun = await this.repositories.taskRuns.findLatest(task.agent, task.id);
131
130
  if (!isTaskDue(lastRun?.createdAt ?? null, task.intervalMs)) return;
@@ -136,12 +135,12 @@ export class Scheduler {
136
135
  return;
137
136
  }
138
137
 
139
- this.taskLocks.set(lockKey, true);
138
+ this.taskLocks.set(task.id, true);
140
139
 
141
140
  try {
142
141
  await this.executeTask(task, agent);
143
142
  } finally {
144
- this.taskLocks.set(lockKey, false);
143
+ this.taskLocks.set(task.id, false);
145
144
  }
146
145
  }
147
146
 
@@ -157,21 +156,21 @@ export class Scheduler {
157
156
  await this.repositories.taskRuns.markRunning(run.id);
158
157
 
159
158
  try {
160
- const session = await this.repositories.sessions.getOrCreate({
161
- agentId: task.agent,
162
- channel: task.channel,
163
- chatId: task.chatId ?? `task:${task.id}`,
164
- threadId: null,
165
- });
166
-
167
159
  const fullPrompt = this.composeTaskPrompt(task);
168
- const profile = this.registry.getProfile(agent.profile);
169
160
 
170
- const response = await this.piRunner.run({
171
- agent,
172
- profile,
173
- session,
174
- prompt: fullPrompt,
161
+ // Use AgentRunner to run the prompt — shares the session lock with user messages
162
+ const response = await this.agentRunner.run({
163
+ agentId: task.agent,
164
+ text: fullPrompt,
165
+ message: {
166
+ id: "task:" + task.id,
167
+ channel: task.channel as any,
168
+ chatId: task.chatId!,
169
+ text: fullPrompt,
170
+ raw: { type: "task", taskId: task.id },
171
+ receivedAt: new Date(),
172
+ source: "task",
173
+ },
175
174
  });
176
175
 
177
176
  const trimmed = response.trim();