@fickydev/pigent 0.1.12 → 0.1.14

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
@@ -2,6 +2,8 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.14 - 2026-05-18
6
+
5
7
  ### Added
6
8
 
7
9
  - Added `task_runs` table and `TaskRepository` for scheduled task persistence.
@@ -59,6 +61,10 @@
59
61
  - Planned Telegram inline-button model and thinking level pickers backed by configured model choices.
60
62
  - Added Telegram inline-button pickers for `/model` and `/thinking`, callback handling, and bot command menu registration.
61
63
  - Added `/task` to Telegram bot command list (shows subcommands in help text).
64
+ - Added `/new` Telegram command to start a fresh active chat session.
65
+ - Added `/status` Telegram command with session/model/workspace/task info and estimated context usage.
66
+ - Added persistent Pi session files per Pigent session so chat memory survives across messages and daemon restarts.
67
+ - Added Pi session metadata to `/status`, with real Pi context usage when available.
62
68
  - Changed `/model` picker to use Pi's currently available models automatically when explicit `modelChoices` are not configured.
63
69
  - Kept daemon process alive after startup so CLI runs do not exit after `pigent ready`.
64
70
 
package/README.md CHANGED
@@ -354,6 +354,13 @@ Available Telegram commands:
354
354
  /help
355
355
  /start
356
356
  /agents
357
+ /new
358
+ /status
359
+ /model
360
+ /thinking
361
+ /task list
362
+ /task create <intervalMs> <prompt>
363
+ /task remove <id>
357
364
  /agent <agentId> <message>
358
365
  ```
359
366
 
@@ -373,6 +380,7 @@ Planned commands:
373
380
  - Keep Telegram-specific logic inside `src/channels/telegram` or routing config.
374
381
  - Channel adapters must not call Pi directly.
375
382
  - Pi runner must not know channel secrets.
383
+ - Pi sessions are persisted under `~/.pigent/pi-sessions` by default (`PIGENT_PI_SESSION_DIR` overrides this).
376
384
  - Use repositories for database access.
377
385
  - Keep SQLite/PostgreSQL portability in mind.
378
386
  - Never inject secrets or raw `.env` values into model prompts.
package/TODO.md CHANGED
@@ -72,6 +72,7 @@
72
72
  - [x] Define `runtime_kv` table for offsets and daemon state
73
73
  - [x] Add `agent_sessions.model` for session model override
74
74
  - [x] Add `agent_sessions.thinking_level` for session thinking override
75
+ - [x] Add active/inactive session lifecycle fields for `/new` sessions
75
76
  - [x] Implement repositories
76
77
  - [x] `AgentRepository`
77
78
  - [x] `SessionRepository`
@@ -138,7 +139,9 @@
138
139
  - [x] Apply session model override
139
140
  - [x] Apply thinking level selection
140
141
  - [x] Confirm SDK APIs needed for session creation
141
- - [ ] Confirm persistent session manager approach
142
+ - [x] Confirm persistent session manager approach
143
+ - [x] Persist Pi session file path per active Pigent session
144
+ - [x] Reuse Pi session files across messages
142
145
  - [ ] Confirm per-agent system prompt injection
143
146
  - [ ] Confirm per-agent skills loading
144
147
  - [ ] Confirm per-agent extensions loading
@@ -183,6 +186,8 @@
183
186
  - [ ] `/default-agent <id>` set chat default
184
187
  - [ ] `/instructions <text>` set chat instructions
185
188
  - [ ] `/sessions` list active sessions for chat
189
+ - [x] `/new` start a fresh active chat session
190
+ - [x] `/status` show current session, model, workspace, tasks, and estimated context usage
186
191
  - [ ] `/reset-session <agentId>` clear chat session
187
192
  - [ ] `/heartbeat status` show heartbeat state
188
193
  - [x] `/model` show current model for default chat agent session
@@ -0,0 +1,4 @@
1
+ ALTER TABLE `agent_sessions` ADD `active` integer DEFAULT true NOT NULL;
2
+ ALTER TABLE `agent_sessions` ADD `ended_at` integer;
3
+ DROP INDEX `agent_sessions_key_unique`;
4
+ CREATE INDEX `agent_sessions_active_key_idx` ON `agent_sessions` (`agent_id`, `channel`, `chat_id`, `thread_id`, `active`);
@@ -0,0 +1 @@
1
+ ALTER TABLE `agent_sessions` ADD `pi_session_path` text;
@@ -29,6 +29,20 @@
29
29
  "when": 1779097464805,
30
30
  "tag": "0003_secret_stone_men",
31
31
  "breakpoints": true
32
+ },
33
+ {
34
+ "idx": 4,
35
+ "version": "6",
36
+ "when": 1779099600000,
37
+ "tag": "0004_active_agent_sessions",
38
+ "breakpoints": true
39
+ },
40
+ {
41
+ "idx": 5,
42
+ "version": "6",
43
+ "when": 1779099900000,
44
+ "tag": "0005_pi_session_path",
45
+ "breakpoints": true
32
46
  }
33
47
  ]
34
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fickydev/pigent",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Autonomous multi-agent daemon using Pi as core execution engine.",
@@ -104,14 +104,21 @@ export class AgentRunner {
104
104
  if (!agent) return { text: `Unknown agent: ${input.agentId}` };
105
105
 
106
106
  try {
107
- const text = await this.piRunner.run({
107
+ const result = await this.piRunner.run({
108
108
  agent,
109
109
  profile: this.registry.getProfile(agent.profile),
110
110
  session,
111
111
  prompt: this.composePrompt(input, chatInstructions),
112
112
  });
113
113
 
114
- return { text };
114
+ if (session.piSessionId !== result.piSessionId || session.piSessionPath !== result.piSessionPath) {
115
+ await this.repositories.sessions.updatePiSession(session.id, {
116
+ piSessionId: result.piSessionId,
117
+ piSessionPath: result.piSessionPath,
118
+ });
119
+ }
120
+
121
+ return { text: result.text };
115
122
  } catch (error) {
116
123
  const errorMessage = error instanceof Error ? error.message : String(error);
117
124
 
@@ -1,8 +1,11 @@
1
+ import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
1
2
  import type { InboundMessage, InlineKeyboardButton } from "../channels/types";
2
- import type { ModelChoiceConfig } from "../config/schemas";
3
+ import type { LoadedAgentConfig, ModelChoiceConfig, ProfileConfig } from "../config/schemas";
4
+ import type { AgentSessionRow } from "../db/schema";
3
5
  import type { Repositories } from "../db/repositories";
6
+ import { PiAgentRunner, type PiContextUsage } from "../pi/PiAgentRunner";
4
7
  import { getAvailableModelChoices } from "../pi/PiAvailableModels";
5
- import { isValidModelRef } from "../pi/PiModelResolver";
8
+ import { isValidModelRef, resolveModelSelection } from "../pi/PiModelResolver";
6
9
  import type { AgentRegistry } from "./AgentRegistry";
7
10
  import type { Scheduler } from "../daemon/Scheduler";
8
11
 
@@ -20,6 +23,8 @@ const THINKING_LEVELS = ["off", "low", "medium", "high"] as const;
20
23
  type ThinkingLevel = (typeof THINKING_LEVELS)[number];
21
24
 
22
25
  export class BotCommandHandler {
26
+ private readonly piRunner = new PiAgentRunner();
27
+
23
28
  constructor(
24
29
  private readonly registry: AgentRegistry,
25
30
  private readonly repositories: Repositories,
@@ -38,6 +43,10 @@ export class BotCommandHandler {
38
43
  return { handled: true, text: this.helpText() };
39
44
  case "/agents":
40
45
  return { handled: true, text: await this.agentsText(message) };
46
+ case "/new":
47
+ return { handled: true, text: await this.newSessionText(message) };
48
+ case "/status":
49
+ return { handled: true, text: await this.statusText(message) };
41
50
  case "/model":
42
51
  return await this.modelResult(message);
43
52
  case "/thinking":
@@ -60,6 +69,8 @@ export class BotCommandHandler {
60
69
  "Pigent commands:",
61
70
  "/help - show this help",
62
71
  "/agents - list agents for this chat",
72
+ "/new - start a fresh session for this chat",
73
+ "/status - show current session status",
63
74
  "/model - show model picker for this chat session",
64
75
  "/model <provider/modelId> - set model for this chat session",
65
76
  "/model default - clear model override for this chat session",
@@ -96,6 +107,90 @@ export class BotCommandHandler {
96
107
  ].join("\n");
97
108
  }
98
109
 
110
+ private async newSessionText(message: InboundMessage): Promise<string> {
111
+ const sessionResult = await this.getDefaultSession(message);
112
+ if (!sessionResult.ok) return sessionResult.message;
113
+
114
+ const session = await this.repositories.sessions.startNew({
115
+ agentId: sessionResult.agentId,
116
+ channel: message.channel,
117
+ chatId: message.chatId,
118
+ threadId: message.threadId,
119
+ userId: message.senderId,
120
+ });
121
+
122
+ return [
123
+ "New session created.",
124
+ "",
125
+ "Agent: " + session.agentId,
126
+ "Session: " + session.id,
127
+ "Model: " + (session.model ?? "default"),
128
+ "Thinking: " + (session.thinkingLevel ?? "default"),
129
+ "Workspace: " + formatPath(sessionResult.agent.workspace),
130
+ ].join("\n");
131
+ }
132
+
133
+ private async statusText(message: InboundMessage): Promise<string> {
134
+ const sessionResult = await this.getDefaultSession(message);
135
+ if (!sessionResult.ok) return sessionResult.message;
136
+
137
+ const agent = sessionResult.agent;
138
+ const profile = this.registry.getProfile(agent.profile);
139
+ const messageStats = await this.repositories.messages.statsBySession(sessionResult.session.id);
140
+ const modelInfo = this.resolveStatusModel(sessionResult.session, agent, profile);
141
+ const piStatus = await this.loadPiStatus(sessionResult.session, agent, profile);
142
+ const contextText = piStatus?.contextUsage
143
+ ? formatPiContext(piStatus.contextUsage)
144
+ : formatEstimatedContext(messageStats.estimatedTokens, modelInfo.contextWindow);
145
+ const contextSource = piStatus?.contextUsage ? "Pi session" : "Pigent DB estimate";
146
+ const taskCount = this.scheduler
147
+ ? this.scheduler.getTasks().filter((task) => task.chatId === message.chatId || !task.chatId).length
148
+ : null;
149
+
150
+ return [
151
+ "Pigent status",
152
+ "",
153
+ "Agent: " + agent.id,
154
+ "Profile: " + agent.profile,
155
+ "Session: " + sessionResult.session.id,
156
+ "Session age: " + formatDuration(Date.now() - sessionResult.session.createdAt),
157
+ "Messages: " + messageStats.count,
158
+ "Last message: " + (messageStats.lastMessageAt ? formatDuration(Date.now() - messageStats.lastMessageAt) + " ago" : "none"),
159
+ "Model: " + modelInfo.model,
160
+ "Thinking: " + (sessionResult.session.thinkingLevel ?? agent.thinkingLevel ?? profile?.thinkingLevel ?? "default"),
161
+ "Context: " + contextText,
162
+ "Context source: " + contextSource,
163
+ "Pi session: " + (piStatus?.piSessionId ?? sessionResult.session.piSessionId ?? "not created"),
164
+ "Workspace: " + formatPath(agent.workspace),
165
+ "Chat: " + message.chatId,
166
+ "Thread: " + (message.threadId ?? "none"),
167
+ "Tasks: " + (taskCount === null ? "scheduler unavailable" : String(taskCount)),
168
+ "Fake runner: " + (process.env.PIGENT_FAKE_AGENT === "1" ? "on" : "off"),
169
+ ].join("\n");
170
+ }
171
+
172
+ private async loadPiStatus(session: AgentSessionRow, agent: LoadedAgentConfig, profile: ProfileConfig | null) {
173
+ try {
174
+ return await this.piRunner.status({ agent, profile, session });
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
179
+
180
+ private resolveStatusModel(session: { model: string | null }, agent: { model?: string | null }, profile: { model?: string | null } | null) {
181
+ const configuredModel = session.model ?? agent.model ?? profile?.model ?? null;
182
+ if (!configuredModel) return { model: "default", contextWindow: null as number | null };
183
+
184
+ const authStorage = AuthStorage.create();
185
+ const modelRegistry = ModelRegistry.create(authStorage);
186
+ const resolved = resolveModelSelection(modelRegistry, [session, agent, profile ?? {}]);
187
+
188
+ return {
189
+ model: configuredModel,
190
+ contextWindow: resolved.model?.contextWindow ?? null,
191
+ };
192
+ }
193
+
99
194
  private async modelResult(message: InboundMessage): Promise<BotCommandResult> {
100
195
  const sessionResult = await this.getDefaultSession(message);
101
196
  if (!sessionResult.ok) return { handled: true, text: sessionResult.message };
@@ -343,7 +438,8 @@ export class BotCommandHandler {
343
438
  return { ok: false as const, message: "No default agent configured for this chat." };
344
439
  }
345
440
 
346
- if (!this.registry.hasAgent(agentId)) {
441
+ const agent = this.registry.getAgent(agentId);
442
+ if (!agent) {
347
443
  return { ok: false as const, message: "Unknown agent: " + agentId };
348
444
  }
349
445
 
@@ -359,7 +455,7 @@ export class BotCommandHandler {
359
455
  userId: message.senderId,
360
456
  });
361
457
 
362
- return { ok: true as const, agentId, session };
458
+ return { ok: true as const, agentId, agent, session };
363
459
  }
364
460
  }
365
461
 
@@ -376,3 +472,50 @@ function chunk<T>(items: T[], size: number): T[][] {
376
472
 
377
473
  return rows;
378
474
  }
475
+
476
+ function formatPiContext(contextUsage: PiContextUsage): string {
477
+ if (contextUsage.percent === null || contextUsage.tokens === null) {
478
+ return "? / " + formatTokens(contextUsage.contextWindow);
479
+ }
480
+
481
+ return contextUsage.percent.toFixed(1) + "% / " + formatTokens(contextUsage.contextWindow);
482
+ }
483
+
484
+ function formatEstimatedContext(tokens: number, contextWindow: number | null): string {
485
+ if (!contextWindow || contextWindow <= 0) {
486
+ return "~" + formatTokens(tokens) + " tokens estimated / unknown limit";
487
+ }
488
+
489
+ const percent = (tokens / contextWindow) * 100;
490
+ return "~" + percent.toFixed(1) + "% / " + formatTokens(contextWindow) + " tokens estimated";
491
+ }
492
+
493
+ function formatTokens(count: number): string {
494
+ if (count < 1000) return String(count);
495
+ if (count < 10000) return (count / 1000).toFixed(1) + "k";
496
+ if (count < 1000000) return Math.round(count / 1000) + "k";
497
+ if (count < 10000000) return (count / 1000000).toFixed(1) + "M";
498
+ return Math.round(count / 1000000) + "M";
499
+ }
500
+
501
+ function formatDuration(ms: number): string {
502
+ const seconds = Math.max(0, Math.floor(ms / 1000));
503
+ if (seconds < 60) return seconds + "s";
504
+
505
+ const minutes = Math.floor(seconds / 60);
506
+ if (minutes < 60) return minutes + "m";
507
+
508
+ const hours = Math.floor(minutes / 60);
509
+ if (hours < 48) return hours + "h " + (minutes % 60) + "m";
510
+
511
+ return Math.floor(hours / 24) + "d " + (hours % 24) + "h";
512
+ }
513
+
514
+ function formatPath(path: string): string {
515
+ const home = process.env.HOME || process.env.USERPROFILE;
516
+ if (home && path.startsWith(home)) {
517
+ return "~" + path.slice(home.length);
518
+ }
519
+
520
+ return path;
521
+ }
@@ -178,6 +178,8 @@ function telegramCommands() {
178
178
  return [
179
179
  { command: "help", description: "Show Pigent commands" },
180
180
  { command: "agents", description: "List agents available in this chat" },
181
+ { command: "new", description: "Start a fresh chat session" },
182
+ { command: "status", description: "Show current session status" },
181
183
  { command: "model", description: "Choose model for this chat session" },
182
184
  { command: "thinking", description: "Choose thinking level for this chat session" },
183
185
  { command: "agent", description: "Send message to a specific agent" },
@@ -1,3 +1,4 @@
1
+ import { eq } from "drizzle-orm";
1
2
  import { nanoid } from "nanoid";
2
3
  import type { DbClient } from "../client";
3
4
  import { messages, type MessageRow } from "../schema";
@@ -42,4 +43,27 @@ export class MessageRepository {
42
43
  if (!row) throw new Error(`failed to create message ${id}`);
43
44
  return row;
44
45
  }
46
+
47
+ async statsBySession(sessionId: string): Promise<{ count: number; estimatedTokens: number; lastMessageAt: number | null }> {
48
+ const rows = await this.db
49
+ .select({ content: messages.content, createdAt: messages.createdAt })
50
+ .from(messages)
51
+ .where(eq(messages.sessionId, sessionId));
52
+
53
+ let chars = 0;
54
+ let lastMessageAt: number | null = null;
55
+
56
+ for (const row of rows) {
57
+ chars += row.content.length;
58
+ if (lastMessageAt === null || row.createdAt > lastMessageAt) {
59
+ lastMessageAt = row.createdAt;
60
+ }
61
+ }
62
+
63
+ return {
64
+ count: rows.length,
65
+ estimatedTokens: Math.ceil(chars / 4),
66
+ lastMessageAt,
67
+ };
68
+ }
45
69
  }
@@ -17,27 +17,25 @@ export class SessionRepository {
17
17
  constructor(private readonly db: DbClient) {}
18
18
 
19
19
  async getOrCreate(key: SessionKey): Promise<AgentSessionRow> {
20
- const existing = await this.findByKey(key);
20
+ const existing = await this.findActiveByKey(key);
21
21
  if (existing) return existing;
22
22
 
23
- const now = Date.now();
24
- const id = nanoid();
23
+ return await this.create(key);
24
+ }
25
25
 
26
- await this.db.insert(agentSessions).values({
27
- id,
28
- agentId: key.agentId,
29
- channel: key.channel,
30
- chatId: key.chatId,
31
- threadId: key.threadId ?? null,
32
- userId: key.userId ?? null,
33
- createdAt: now,
34
- updatedAt: now,
35
- });
26
+ async startNew(key: SessionKey): Promise<AgentSessionRow> {
27
+ const now = Date.now();
36
28
 
37
- const created = await this.findById(id);
38
- if (!created) throw new Error(`failed to create session ${id}`);
29
+ await this.db
30
+ .update(agentSessions)
31
+ .set({
32
+ active: false,
33
+ endedAt: now,
34
+ updatedAt: now,
35
+ })
36
+ .where(this.keyPredicate(key, true));
39
37
 
40
- return created;
38
+ return await this.create(key);
41
39
  }
42
40
 
43
41
  async updateModel(id: string, model: string | null): Promise<AgentSessionRow> {
@@ -64,6 +62,19 @@ export class SessionRepository {
64
62
  return this.requireById(id);
65
63
  }
66
64
 
65
+ async updatePiSession(id: string, input: { piSessionId: string; piSessionPath: string }): Promise<AgentSessionRow> {
66
+ await this.db
67
+ .update(agentSessions)
68
+ .set({
69
+ piSessionId: input.piSessionId,
70
+ piSessionPath: input.piSessionPath,
71
+ updatedAt: Date.now(),
72
+ })
73
+ .where(eq(agentSessions.id, id));
74
+
75
+ return this.requireById(id);
76
+ }
77
+
67
78
  async findById(id: string): Promise<AgentSessionRow | null> {
68
79
  const row = await this.db.query.agentSessions.findFirst({
69
80
  where: eq(agentSessions.id, id),
@@ -79,20 +90,52 @@ export class SessionRepository {
79
90
  return row;
80
91
  }
81
92
 
82
- private async findByKey(key: SessionKey): Promise<AgentSessionRow | null> {
83
- const threadPredicate = key.threadId
84
- ? eq(agentSessions.threadId, key.threadId)
85
- : isNull(agentSessions.threadId);
93
+ private async create(key: SessionKey): Promise<AgentSessionRow> {
94
+ const now = Date.now();
95
+ const id = nanoid();
96
+
97
+ await this.db.insert(agentSessions).values({
98
+ id,
99
+ agentId: key.agentId,
100
+ channel: key.channel,
101
+ chatId: key.chatId,
102
+ threadId: key.threadId ?? null,
103
+ userId: key.userId ?? null,
104
+ active: true,
105
+ createdAt: now,
106
+ updatedAt: now,
107
+ });
108
+
109
+ const created = await this.findById(id);
110
+ if (!created) throw new Error(`failed to create session ${id}`);
86
111
 
112
+ return created;
113
+ }
114
+
115
+ private async findActiveByKey(key: SessionKey): Promise<AgentSessionRow | null> {
87
116
  const row = await this.db.query.agentSessions.findFirst({
88
- where: and(
89
- eq(agentSessions.agentId, key.agentId),
90
- eq(agentSessions.channel, key.channel),
91
- eq(agentSessions.chatId, key.chatId),
92
- threadPredicate,
93
- ),
117
+ where: this.keyPredicate(key, true),
94
118
  });
95
119
 
96
120
  return row ?? null;
97
121
  }
122
+
123
+ private keyPredicate(key: SessionKey, active?: boolean) {
124
+ const threadPredicate = key.threadId
125
+ ? eq(agentSessions.threadId, key.threadId)
126
+ : isNull(agentSessions.threadId);
127
+
128
+ const predicates = [
129
+ eq(agentSessions.agentId, key.agentId),
130
+ eq(agentSessions.channel, key.channel),
131
+ eq(agentSessions.chatId, key.chatId),
132
+ threadPredicate,
133
+ ];
134
+
135
+ if (active !== undefined) {
136
+ predicates.push(eq(agentSessions.active, active));
137
+ }
138
+
139
+ return and(...predicates);
140
+ }
98
141
  }
package/src/db/schema.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
1
+ import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
2
2
 
3
3
  export const agents = sqliteTable("agents", {
4
4
  id: text("id").primaryKey(),
@@ -47,18 +47,22 @@ export const agentSessions = sqliteTable(
47
47
  threadId: text("thread_id"),
48
48
  userId: text("user_id"),
49
49
  piSessionId: text("pi_session_id"),
50
+ piSessionPath: text("pi_session_path"),
50
51
  instructionsHash: text("instructions_hash"),
51
52
  model: text("model"),
52
53
  thinkingLevel: text("thinking_level", { enum: ["off", "low", "medium", "high"] }),
54
+ active: integer("active", { mode: "boolean" }).notNull().default(true),
55
+ endedAt: integer("ended_at"),
53
56
  createdAt: integer("created_at").notNull(),
54
57
  updatedAt: integer("updated_at").notNull(),
55
58
  },
56
59
  (table) => ({
57
- sessionKeyUnique: uniqueIndex("agent_sessions_key_unique").on(
60
+ sessionActiveKeyIndex: index("agent_sessions_active_key_idx").on(
58
61
  table.agentId,
59
62
  table.channel,
60
63
  table.chatId,
61
64
  table.threadId,
65
+ table.active,
62
66
  ),
63
67
  }),
64
68
  );
@@ -4,7 +4,6 @@ import {
4
4
  DefaultResourceLoader,
5
5
  ModelRegistry,
6
6
  getAgentDir,
7
- SessionManager,
8
7
  SettingsManager,
9
8
  } from "@earendil-works/pi-coding-agent";
10
9
  import { mkdir } from "node:fs/promises";
@@ -12,6 +11,7 @@ import { resolve } from "node:path";
12
11
  import type { LoadedAgentConfig, ProfileConfig } from "../config/schemas";
13
12
  import type { AgentSessionRow } from "../db/schema";
14
13
  import { resolveModelSelection } from "./PiModelResolver";
14
+ import { loadOrCreatePiSession } from "./PiSessionFactory";
15
15
 
16
16
  export type PiAgentRunInput = {
17
17
  agent: LoadedAgentConfig;
@@ -20,8 +20,58 @@ export type PiAgentRunInput = {
20
20
  prompt: string;
21
21
  };
22
22
 
23
+ export type PiContextUsage = {
24
+ tokens: number | null;
25
+ contextWindow: number;
26
+ percent: number | null;
27
+ };
28
+
29
+ export type PiAgentRunResult = {
30
+ text: string;
31
+ piSessionId: string;
32
+ piSessionPath: string;
33
+ contextUsage?: PiContextUsage;
34
+ };
35
+
36
+ export type PiSessionStatus = {
37
+ piSessionId: string;
38
+ piSessionPath: string;
39
+ contextUsage?: PiContextUsage;
40
+ };
41
+
23
42
  export class PiAgentRunner {
24
- async run(input: PiAgentRunInput): Promise<string> {
43
+ async run(input: PiAgentRunInput): Promise<PiAgentRunResult> {
44
+ const prepared = await this.prepare(input);
45
+
46
+ let response = "";
47
+ prepared.session.subscribe((event) => {
48
+ if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
49
+ response += event.assistantMessageEvent.delta;
50
+ }
51
+ });
52
+
53
+ await prepared.session.prompt(input.prompt);
54
+
55
+ return {
56
+ text: response.trim() || "(no response)",
57
+ piSessionId: prepared.piSession.sessionId,
58
+ piSessionPath: prepared.piSession.sessionPath,
59
+ contextUsage: prepared.session.getContextUsage(),
60
+ };
61
+ }
62
+
63
+ async status(input: Omit<PiAgentRunInput, "prompt">): Promise<PiSessionStatus | null> {
64
+ if (!input.session.piSessionPath) return null;
65
+
66
+ const prepared = await this.prepare({ ...input, prompt: "" });
67
+ return {
68
+ piSessionId: prepared.piSession.sessionId,
69
+ piSessionPath: prepared.piSession.sessionPath,
70
+ contextUsage: prepared.session.getContextUsage(),
71
+ };
72
+ }
73
+
74
+ private async prepare(input: PiAgentRunInput) {
25
75
  const workspace = resolve(input.agent.workspace);
26
76
  await mkdir(workspace, { recursive: true });
27
77
 
@@ -41,6 +91,7 @@ export class PiAgentRunner {
41
91
  const authStorage = AuthStorage.create();
42
92
  const modelRegistry = ModelRegistry.create(authStorage);
43
93
  const modelSelection = resolveModelSelection(modelRegistry, [input.session, input.agent, input.profile ?? {}]);
94
+ const piSession = await loadOrCreatePiSession(workspace, input.session);
44
95
  const { session } = await createAgentSession({
45
96
  cwd: workspace,
46
97
  authStorage,
@@ -48,22 +99,13 @@ export class PiAgentRunner {
48
99
  model: modelSelection.model,
49
100
  thinkingLevel: modelSelection.thinkingLevel,
50
101
  resourceLoader,
51
- sessionManager: SessionManager.create(workspace),
102
+ sessionManager: piSession.manager,
52
103
  settingsManager,
53
104
  agentDir,
54
105
  tools: [],
55
106
  });
56
107
 
57
- let response = "";
58
- session.subscribe((event) => {
59
- if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
60
- response += event.assistantMessageEvent.delta;
61
- }
62
- });
63
-
64
- await session.prompt(input.prompt);
65
-
66
- return response.trim() || "(no response)";
108
+ return { session, piSession };
67
109
  }
68
110
  }
69
111
 
@@ -0,0 +1,49 @@
1
+ import { SessionManager } from "@earendil-works/pi-coding-agent";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import type { AgentSessionRow } from "../db/schema";
7
+
8
+ export type PiSessionHandle = {
9
+ manager: SessionManager;
10
+ sessionId: string;
11
+ sessionPath: string;
12
+ created: boolean;
13
+ };
14
+
15
+ export async function loadOrCreatePiSession(workspace: string, session: AgentSessionRow): Promise<PiSessionHandle> {
16
+ const sessionDir = piSessionDir();
17
+ await mkdir(sessionDir, { recursive: true });
18
+
19
+ if (session.piSessionPath && existsSync(session.piSessionPath)) {
20
+ const manager = SessionManager.open(session.piSessionPath, sessionDir, workspace);
21
+ return {
22
+ manager,
23
+ sessionId: manager.getSessionId(),
24
+ sessionPath: manager.getSessionFile() ?? session.piSessionPath,
25
+ created: false,
26
+ };
27
+ }
28
+
29
+ const manager = SessionManager.create(workspace, sessionDir);
30
+ manager.newSession({ id: session.piSessionId ?? session.id });
31
+
32
+ const sessionPath = manager.getSessionFile();
33
+ if (!sessionPath) {
34
+ throw new Error("failed to create persisted Pi session file");
35
+ }
36
+
37
+ await mkdir(dirname(sessionPath), { recursive: true });
38
+
39
+ return {
40
+ manager,
41
+ sessionId: manager.getSessionId(),
42
+ sessionPath,
43
+ created: true,
44
+ };
45
+ }
46
+
47
+ export function piSessionDir(): string {
48
+ return resolve(process.env.PIGENT_PI_SESSION_DIR ?? join(process.env.PIGENT_HOME ?? join(homedir(), ".pigent"), "pi-sessions"));
49
+ }