pi-orchestrator-config 4.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +808 -0
- package/agents/api-documenter.md +32 -0
- package/agents/bash-expert.md +53 -0
- package/agents/code-reviewer-docs.md +145 -0
- package/agents/code-reviewer-guidelines.md +97 -0
- package/agents/code-reviewer-quality.md +142 -0
- package/agents/code-reviewer-security.md +107 -0
- package/agents/code-reviewer-spec.md +164 -0
- package/agents/debugger.md +63 -0
- package/agents/docker-expert.md +40 -0
- package/agents/docs-fetcher.md +48 -0
- package/agents/git-expert.md +63 -0
- package/agents/github-expert.md +83 -0
- package/agents/go-expert.md +62 -0
- package/agents/issue-reviewer-feasibility.md +60 -0
- package/agents/issue-reviewer-scope.md +63 -0
- package/agents/issue-reviewer-spec.md +77 -0
- package/agents/java-expert.md +31 -0
- package/agents/jenkins-expert.md +38 -0
- package/agents/kubernetes-expert.md +40 -0
- package/agents/planner.md +42 -0
- package/agents/python-expert.md +61 -0
- package/agents/reviewer.md +26 -0
- package/agents/scout.md +40 -0
- package/agents/security-auditor.md +254 -0
- package/agents/technical-documentation-writer.md +37 -0
- package/agents/test-automator.md +55 -0
- package/agents/test-runner.md +47 -0
- package/agents/ts-expert.md +23 -0
- package/agents/worker.md +30 -0
- package/extensions/acpx-provider/index.ts +104 -0
- package/extensions/acpx-provider/load-runtime.ts +99 -0
- package/extensions/acpx-provider/runtime-models.ts +40 -0
- package/extensions/cli-provider/agents/claude.ts +128 -0
- package/extensions/cli-provider/agents/cursor.ts +84 -0
- package/extensions/cli-provider/agents/gemini.ts +100 -0
- package/extensions/cli-provider/discover.ts +77 -0
- package/extensions/cli-provider/index.ts +57 -0
- package/extensions/cli-provider/parsers.ts +304 -0
- package/extensions/cli-provider/providers.ts +58 -0
- package/extensions/cli-provider/runner.ts +225 -0
- package/extensions/cli-provider/runtime-models.ts +32 -0
- package/extensions/cli-provider/session-reaper.ts +122 -0
- package/extensions/cli-provider/sessions.ts +533 -0
- package/extensions/cli-provider/shared/discover-cache.ts +235 -0
- package/extensions/cli-provider/types.ts +48 -0
- package/extensions/coms/coms-net-server.ts +1642 -0
- package/extensions/coms/coms-net-wrapper.ts +521 -0
- package/extensions/coms/coms-net.ts +1589 -0
- package/extensions/coms/coms-p2p.ts +1879 -0
- package/extensions/coms/coms-shared.ts +510 -0
- package/extensions/coms/coms-wrapper.ts +138 -0
- package/extensions/coms/index.ts +20 -0
- package/extensions/coms/themeMap.ts +147 -0
- package/extensions/image-gen/image-gen.ts +332 -0
- package/extensions/image-gen/index.ts +14 -0
- package/extensions/orchestrator/agents.ts +134 -0
- package/extensions/orchestrator/ask-user.ts +293 -0
- package/extensions/orchestrator/async-agents.ts +1037 -0
- package/extensions/orchestrator/async-capability.ts +129 -0
- package/extensions/orchestrator/async-runner.ts +324 -0
- package/extensions/orchestrator/async-status-parse.ts +31 -0
- package/extensions/orchestrator/async-status-ui.ts +201 -0
- package/extensions/orchestrator/async-wait.ts +36 -0
- package/extensions/orchestrator/btw.ts +210 -0
- package/extensions/orchestrator/cron-status-format.ts +126 -0
- package/extensions/orchestrator/cron-status-ui.ts +132 -0
- package/extensions/orchestrator/cron.ts +730 -0
- package/extensions/orchestrator/dreaming.ts +355 -0
- package/extensions/orchestrator/enforcement-helpers.ts +530 -0
- package/extensions/orchestrator/enforcement-rules.ts +329 -0
- package/extensions/orchestrator/enforcement.ts +828 -0
- package/extensions/orchestrator/extended-autocomplete.ts +593 -0
- package/extensions/orchestrator/git-helpers.ts +420 -0
- package/extensions/orchestrator/github-autocomplete.ts +250 -0
- package/extensions/orchestrator/icons.ts +17 -0
- package/extensions/orchestrator/index.ts +100 -0
- package/extensions/orchestrator/memory-embeddings.ts +281 -0
- package/extensions/orchestrator/memory-promotion.ts +449 -0
- package/extensions/orchestrator/memory-provenance.ts +80 -0
- package/extensions/orchestrator/memory-query-class.ts +90 -0
- package/extensions/orchestrator/memory-scoring.ts +434 -0
- package/extensions/orchestrator/memory-tools.ts +883 -0
- package/extensions/orchestrator/memory-tree.ts +212 -0
- package/extensions/orchestrator/nvim.ts +169 -0
- package/extensions/orchestrator/overlay-dashboard-utils.ts +25 -0
- package/extensions/orchestrator/overlay-dashboard.ts +512 -0
- package/extensions/orchestrator/pi-config-review-state.ts +303 -0
- package/extensions/orchestrator/preference-extractor.ts +106 -0
- package/extensions/orchestrator/project-settings.ts +468 -0
- package/extensions/orchestrator/promotion-queue.ts +311 -0
- package/extensions/orchestrator/resolve-agent-model.ts +32 -0
- package/extensions/orchestrator/review-ui.ts +204 -0
- package/extensions/orchestrator/rule-placeholders.ts +12 -0
- package/extensions/orchestrator/rules.ts +481 -0
- package/extensions/orchestrator/session-search.ts +243 -0
- package/extensions/orchestrator/session-validation.ts +374 -0
- package/extensions/orchestrator/situation-report.ts +370 -0
- package/extensions/orchestrator/status-bar.ts +31 -0
- package/extensions/orchestrator/status-line.ts +192 -0
- package/extensions/orchestrator/status.ts +110 -0
- package/extensions/orchestrator/subagent-tool.ts +1748 -0
- package/extensions/orchestrator/task-lifecycle.ts +88 -0
- package/extensions/orchestrator/utils.ts +227 -0
- package/extensions/pidash/index.ts +15 -0
- package/extensions/pidash/pidash.ts +941 -0
- package/extensions/pidiff/index.ts +13 -0
- package/extensions/pidiff/pidiff.ts +369 -0
- package/extensions/providers/acpx-driver.ts +479 -0
- package/extensions/providers/built-in-drivers.ts +52 -0
- package/extensions/providers/claude-driver.ts +319 -0
- package/extensions/providers/cursor-acpx-driver.ts +398 -0
- package/extensions/providers/cursor-cli-driver.ts +484 -0
- package/extensions/providers/gemini-driver.ts +302 -0
- package/extensions/providers/index.ts +513 -0
- package/extensions/shared/build-system-prompt.ts +57 -0
- package/extensions/shared/create-runtime-provider.ts +190 -0
- package/extensions/shared/daemon-manager.ts +248 -0
- package/extensions/shared/file-logger.ts +115 -0
- package/extensions/shared/managed-refresh.ts +211 -0
- package/extensions/shared/provider-driver.ts +295 -0
- package/extensions/shared/provider-errors.ts +305 -0
- package/extensions/shared/provider-registry.ts +388 -0
- package/extensions/shared/stream-builder.ts +275 -0
- package/extensions/shared/types.ts +20 -0
- package/extensions/shared/ui/README.md +10 -0
- package/extensions/shared/ui/badge.tsx +52 -0
- package/extensions/shared/ui/button.tsx +58 -0
- package/extensions/shared/ui/collapsible.tsx +21 -0
- package/extensions/shared/ui/dropdown-menu.tsx +266 -0
- package/extensions/shared/ui/input.tsx +20 -0
- package/extensions/shared/ui/popover.tsx +114 -0
- package/extensions/shared/ui/resizable.tsx +48 -0
- package/extensions/shared/ui/scroll-area.tsx +53 -0
- package/extensions/shared/ui/select.tsx +201 -0
- package/extensions/shared/ui/separator.tsx +25 -0
- package/extensions/shared/ui/tooltip.tsx +64 -0
- package/extensions/shared/ws-client.ts +74 -0
- package/package.json +59 -0
- package/prompts/coderabbit-rate-limit.md +95 -0
- package/prompts/create-coms-feature-manager.md +186 -0
- package/prompts/create-skill.md +128 -0
- package/prompts/domain-model.md +109 -0
- package/prompts/external-ai.md +665 -0
- package/prompts/implement-and-review.md +25 -0
- package/prompts/implement.md +26 -0
- package/prompts/issue-review.md +256 -0
- package/prompts/pr-review.md +753 -0
- package/prompts/query-db.md +154 -0
- package/prompts/refine-review.md +208 -0
- package/prompts/release.md +235 -0
- package/prompts/remember.md +33 -0
- package/prompts/review-handler-status.md +27 -0
- package/prompts/review-handler.md +677 -0
- package/prompts/review-local.md +159 -0
- package/prompts/scout-and-plan.md +21 -0
- package/rules/00-orchestrator-core.md +42 -0
- package/rules/05-issue-first-workflow.md +119 -0
- package/rules/10-agent-routing.md +58 -0
- package/rules/15-mcp-launchpad.md +46 -0
- package/rules/20-code-review-loop.md +234 -0
- package/rules/25-documentation-updates.md +15 -0
- package/rules/30-prompt-templates.md +19 -0
- package/rules/35-memory.md +240 -0
- package/rules/40-critical-rules.md +124 -0
- package/rules/45-file-preview.md +29 -0
- package/rules/50-agent-bug-reporting.md +90 -0
- package/rules/55-coms-protocol.md +63 -0
- package/rules/60-task-tracking.md +66 -0
- package/skills/conflict-resolver/SKILL.md +213 -0
- package/skills/skill-creator/SKILL.md +85 -0
- package/skills/systematic-debugging/SKILL.md +353 -0
- package/skills/test-driven-development/SKILL.md +335 -0
- package/skills/update-agents-md/SKILL.md +282 -0
- package/templates/coms-feature-manager-prompt.md +313 -0
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cron-like scheduled tasks — pi-process-scoped.
|
|
3
|
+
*
|
|
4
|
+
* Tasks survive /reload, /resume, /new but die on pi exit.
|
|
5
|
+
* Persisted to a session-scoped file so they survive extension re-evaluation.
|
|
6
|
+
*
|
|
7
|
+
* The /cron command is a natural-language interface — the AI parses
|
|
8
|
+
* the user's intent and calls the cron_manage tool with structured params.
|
|
9
|
+
*
|
|
10
|
+
* Slash command tasks (starting with /) execute as commands.
|
|
11
|
+
* Prompt tasks run as async agents with triggerTurn: true.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as os from "node:os";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import { Type } from "typebox";
|
|
18
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { decideAsyncLlmDispatch } from "./async-capability.js";
|
|
20
|
+
import { discoverAgents } from "./agents.js";
|
|
21
|
+
import { formatCronSchedule, toCronStatusTaskView } from "./cron-status-format.js";
|
|
22
|
+
import { openCronStatusOverlay } from "./cron-status-ui.js";
|
|
23
|
+
import { setSlot } from "./status-bar.js";
|
|
24
|
+
import { getProjectTmpDir, parseProcStartTime } from "./utils.js";
|
|
25
|
+
|
|
26
|
+
// ── Types ────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
export interface CronTask {
|
|
29
|
+
id: number;
|
|
30
|
+
description: string; // human-readable description
|
|
31
|
+
task: string; // what to execute
|
|
32
|
+
intervalMs?: number; // for interval-based
|
|
33
|
+
atHour?: number; // for time-based (daily)
|
|
34
|
+
atMinute?: number;
|
|
35
|
+
createdAt: number;
|
|
36
|
+
lastRun?: number;
|
|
37
|
+
nextRun?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Shared mutable state + helpers passed to extracted registration functions */
|
|
41
|
+
interface CronInternals {
|
|
42
|
+
tasks: Map<number, CronTask>;
|
|
43
|
+
nextId: number;
|
|
44
|
+
lastCwd: string;
|
|
45
|
+
lastCtx: any;
|
|
46
|
+
startTask(task: CronTask): void;
|
|
47
|
+
stopTask(id: number): void;
|
|
48
|
+
updateCronStatus(): void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Persistence ──────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
let CRON_FILE = ""; // Set on session_start to project-scoped dir
|
|
54
|
+
/** Ignore fs.watch echoes from our own writes. */
|
|
55
|
+
let CRON_IGNORE_WATCH_UNTIL = 0;
|
|
56
|
+
|
|
57
|
+
/** Unique session suffix — prevents PID collisions across containers */
|
|
58
|
+
/** Suffix must be unique across containers but stable across reloads (same process).
|
|
59
|
+
* Use process start time from /proc or process.uptime() as a stable identifier. */
|
|
60
|
+
const SESSION_SUFFIX = (() => {
|
|
61
|
+
try {
|
|
62
|
+
const stat = fs.readFileSync("/proc/self/stat", "utf-8");
|
|
63
|
+
const startTime = parseProcStartTime(stat);
|
|
64
|
+
if (!startTime) throw new Error("failed to parse start time");
|
|
65
|
+
return `${process.pid}-${startTime}`;
|
|
66
|
+
} catch {
|
|
67
|
+
const seed = `${process.pid}-${process.ppid}-${process.argv.join(",")}`;
|
|
68
|
+
let hash = 0;
|
|
69
|
+
for (let i = 0; i < seed.length; i++) {
|
|
70
|
+
hash = ((hash << 5) - hash + seed.charCodeAt(i)) | 0;
|
|
71
|
+
}
|
|
72
|
+
return `${process.pid}-${Math.abs(hash).toString(36)}`;
|
|
73
|
+
}
|
|
74
|
+
})();
|
|
75
|
+
|
|
76
|
+
/** Matches both old (cron-{pid}.json) and new (cron-{pid}-{suffix}.json) formats */
|
|
77
|
+
const CRON_FILE_RE = /^cron-(\d+)(?:-[^.]+)?\.json$/;
|
|
78
|
+
|
|
79
|
+
/** Get current cron file path — used by autocomplete */
|
|
80
|
+
export function getCronFilePath(): string { return CRON_FILE; }
|
|
81
|
+
|
|
82
|
+
function saveCrons(tasks: CronTask[]): void {
|
|
83
|
+
if (!CRON_FILE) return;
|
|
84
|
+
try {
|
|
85
|
+
CRON_IGNORE_WATCH_UNTIL = Date.now() + 400;
|
|
86
|
+
fs.writeFileSync(CRON_FILE, JSON.stringify(tasks), { mode: 0o600 });
|
|
87
|
+
} catch (e: any) { console.debug("[cron] save crons failed:", e?.message || e); }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function loadCrons(): CronTask[] {
|
|
91
|
+
if (!CRON_FILE) return [];
|
|
92
|
+
try {
|
|
93
|
+
const data = JSON.parse(fs.readFileSync(CRON_FILE, "utf-8"));
|
|
94
|
+
return Array.isArray(data) ? data : [];
|
|
95
|
+
} catch {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function cleanupOrphanedCronFiles(): void {
|
|
101
|
+
try {
|
|
102
|
+
const dir = path.dirname(CRON_FILE);
|
|
103
|
+
const myFile = path.basename(CRON_FILE);
|
|
104
|
+
for (const f of fs.readdirSync(dir)) {
|
|
105
|
+
const m = f.match(CRON_FILE_RE);
|
|
106
|
+
if (!m || f === myFile) continue;
|
|
107
|
+
// Old format (no suffix) — always delete (legacy)
|
|
108
|
+
const isLegacy = /^cron-\d+\.json$/.test(f);
|
|
109
|
+
if (isLegacy) {
|
|
110
|
+
try { fs.unlinkSync(path.join(dir, f)); } catch {}
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
// New format — extract PID and suffix token
|
|
114
|
+
const pid = +m[1];
|
|
115
|
+
const suffixMatch = f.match(/^cron-\d+-(.+)\.json$/);
|
|
116
|
+
const fileToken = suffixMatch?.[1];
|
|
117
|
+
// Check /proc/{pid}/stat — if we can read it, verify start time matches
|
|
118
|
+
try {
|
|
119
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf-8");
|
|
120
|
+
const startTime = parseProcStartTime(stat);
|
|
121
|
+
if (startTime && fileToken && startTime === fileToken) {
|
|
122
|
+
// Same PID, same start time — process is alive, skip
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (startTime && fileToken && startTime !== fileToken) {
|
|
126
|
+
// PID reused — different process, safe to delete
|
|
127
|
+
try { fs.unlinkSync(path.join(dir, f)); } catch {}
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
// /proc/{pid} not readable — either dead process or different PID namespace
|
|
132
|
+
// Only delete if we can confirm PID is dead in our namespace
|
|
133
|
+
try { process.kill(pid, 0); } catch {
|
|
134
|
+
// PID dead in our namespace — safe to delete
|
|
135
|
+
try { fs.unlinkSync(path.join(dir, f)); } catch {}
|
|
136
|
+
}
|
|
137
|
+
// If kill(pid, 0) succeeds but /proc is unreadable — skip (can't verify)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} catch (e: any) { console.debug("[cron] cleanup orphaned cron files failed:", e?.message || e); }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
function formatSchedule(task: CronTask): string {
|
|
146
|
+
return formatCronSchedule(task);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function msUntilNextTime(hour: number, minute: number): number {
|
|
150
|
+
const now = new Date();
|
|
151
|
+
const target = new Date(now);
|
|
152
|
+
target.setHours(hour, minute, 0, 0);
|
|
153
|
+
if (target.getTime() <= now.getTime()) {
|
|
154
|
+
target.setDate(target.getDate() + 1);
|
|
155
|
+
}
|
|
156
|
+
return target.getTime() - now.getTime();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Extracted registration functions ─────────────────────────────────
|
|
160
|
+
|
|
161
|
+
function registerCronTool(pi: ExtensionAPI, state: CronInternals): void {
|
|
162
|
+
pi.registerTool({
|
|
163
|
+
name: "cron_manage",
|
|
164
|
+
description: "Manage scheduled cron tasks. Use this tool when the user wants to schedule, list, or remove recurring tasks.",
|
|
165
|
+
parameters: Type.Object({
|
|
166
|
+
action: Type.Union([Type.Literal("add"), Type.Literal("list"), Type.Literal("list-all"), Type.Literal("remove")], {
|
|
167
|
+
description: "The action to perform",
|
|
168
|
+
}),
|
|
169
|
+
description: Type.Optional(Type.String({
|
|
170
|
+
description: "Human-readable description of the task (for 'add')",
|
|
171
|
+
})),
|
|
172
|
+
task: Type.Optional(Type.String({
|
|
173
|
+
description: "What to execute — a prompt for the AI or a /slash-command (for 'add')",
|
|
174
|
+
})),
|
|
175
|
+
interval_seconds: Type.Optional(Type.Number({
|
|
176
|
+
description: "Run every N seconds (for interval-based 'add'). Minimum 10 seconds.",
|
|
177
|
+
})),
|
|
178
|
+
at_hour: Type.Optional(Type.Number({
|
|
179
|
+
description: "Hour (0-23) for daily time-based schedule (for 'add')",
|
|
180
|
+
})),
|
|
181
|
+
at_minute: Type.Optional(Type.Number({
|
|
182
|
+
description: "Minute (0-59) for daily time-based schedule (for 'add')",
|
|
183
|
+
})),
|
|
184
|
+
id: Type.Optional(Type.Number({
|
|
185
|
+
description: "Task ID to remove (for 'remove')",
|
|
186
|
+
})),
|
|
187
|
+
}),
|
|
188
|
+
async execute(_id, params) {
|
|
189
|
+
const action = params.action as string;
|
|
190
|
+
|
|
191
|
+
if (action === "add") {
|
|
192
|
+
// Block scheduling in one-shot modes
|
|
193
|
+
if (state.lastCtx?.mode === "print" || state.lastCtx?.mode === "json") {
|
|
194
|
+
return { content: [{ type: "text", text: "Error: cron scheduling is not available in one-shot modes (print/json)" }] };
|
|
195
|
+
}
|
|
196
|
+
if (!params.task) {
|
|
197
|
+
return { content: [{ type: "text", text: "Error: 'task' is required for add action" }] };
|
|
198
|
+
}
|
|
199
|
+
if (!params.interval_seconds && params.at_hour === undefined) {
|
|
200
|
+
return { content: [{ type: "text", text: "Error: either 'interval_seconds' or 'at_hour'+'at_minute' is required" }] };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const intervalMs = params.interval_seconds ? Math.max(10, params.interval_seconds) * 1000 : undefined;
|
|
204
|
+
const atHour = params.at_hour !== undefined ? Math.max(0, Math.min(23, params.at_hour)) : undefined;
|
|
205
|
+
const atMinute = params.at_minute !== undefined ? Math.max(0, Math.min(59, params.at_minute)) : (atHour !== undefined ? 0 : undefined);
|
|
206
|
+
|
|
207
|
+
const task: CronTask = {
|
|
208
|
+
id: state.nextId++,
|
|
209
|
+
description: (params.description as string) || (params.task as string).slice(0, 60),
|
|
210
|
+
task: params.task as string,
|
|
211
|
+
intervalMs,
|
|
212
|
+
atHour,
|
|
213
|
+
atMinute,
|
|
214
|
+
createdAt: Date.now(),
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
state.tasks.set(task.id, task);
|
|
218
|
+
state.startTask(task);
|
|
219
|
+
saveCrons([...state.tasks.values()]);
|
|
220
|
+
|
|
221
|
+
state.updateCronStatus();
|
|
222
|
+
return {
|
|
223
|
+
content: [{
|
|
224
|
+
type: "text",
|
|
225
|
+
text: `Cron #${task.id} created: ${formatSchedule(task)} → ${task.description}`,
|
|
226
|
+
}],
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (action === "list") {
|
|
231
|
+
if (state.tasks.size === 0) {
|
|
232
|
+
return { content: [{ type: "text", text: "No scheduled tasks." }] };
|
|
233
|
+
}
|
|
234
|
+
const lines = [...state.tasks.values()].map(t => {
|
|
235
|
+
const last = t.lastRun ? new Date(t.lastRun).toLocaleTimeString() : "never";
|
|
236
|
+
return `#${t.id} | ${formatSchedule(t)} | ${t.description} | last run: ${last}`;
|
|
237
|
+
});
|
|
238
|
+
return {
|
|
239
|
+
content: [{ type: "text", text: `Scheduled tasks:\n\n${lines.join("\n")}` }],
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (action === "list-all") {
|
|
244
|
+
const sections: string[] = [];
|
|
245
|
+
if (!CRON_FILE) {
|
|
246
|
+
return { content: [{ type: "text", text: "No scheduled tasks in any session." }] };
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
const dir = path.dirname(CRON_FILE);
|
|
250
|
+
for (const f of fs.readdirSync(dir)) {
|
|
251
|
+
const m = f.match(CRON_FILE_RE);
|
|
252
|
+
if (!m) continue;
|
|
253
|
+
const pid = +m[1];
|
|
254
|
+
const isMe = f === path.basename(CRON_FILE);
|
|
255
|
+
let alive = isMe;
|
|
256
|
+
if (!isMe) { try { process.kill(pid, 0); alive = true; } catch {} }
|
|
257
|
+
if (!alive) continue;
|
|
258
|
+
const cronTasks: CronTask[] = isMe
|
|
259
|
+
? [...state.tasks.values()]
|
|
260
|
+
: (() => { try { return JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8")); } catch { return []; } })();
|
|
261
|
+
if (cronTasks.length === 0) continue;
|
|
262
|
+
const label = isMe ? `PID ${pid} (this session)` : `PID ${pid}`;
|
|
263
|
+
const lines = cronTasks.map(t => {
|
|
264
|
+
const last = t.lastRun ? new Date(t.lastRun).toLocaleTimeString() : "never";
|
|
265
|
+
return ` #${t.id} | ${formatSchedule(t)} | ${t.description} | last run: ${last}`;
|
|
266
|
+
});
|
|
267
|
+
sections.push(`**${label}:**\n${lines.join("\n")}`);
|
|
268
|
+
}
|
|
269
|
+
} catch (e: any) { console.debug("[cron] list-all scan failed:", e?.message || e); }
|
|
270
|
+
if (sections.length === 0) {
|
|
271
|
+
return { content: [{ type: "text", text: "No scheduled tasks in any session." }] };
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
content: [{ type: "text", text: `All sessions:\n\n${sections.join("\n\n")}` }],
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (action === "remove") {
|
|
279
|
+
const id = params.id as number;
|
|
280
|
+
if (!id || !state.tasks.has(id)) {
|
|
281
|
+
return { content: [{ type: "text", text: `Task #${id || "?"} not found.` }] };
|
|
282
|
+
}
|
|
283
|
+
const task = state.tasks.get(id)!;
|
|
284
|
+
state.stopTask(id);
|
|
285
|
+
state.tasks.delete(id);
|
|
286
|
+
saveCrons([...state.tasks.values()]);
|
|
287
|
+
state.updateCronStatus();
|
|
288
|
+
return {
|
|
289
|
+
content: [{ type: "text", text: `Cron #${id} removed: ${task.description}` }],
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return { content: [{ type: "text", text: `Unknown action: ${action}` }] };
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function listLocalCronViews(state: CronInternals) {
|
|
299
|
+
const file = CRON_FILE || "";
|
|
300
|
+
return [...state.tasks.values()].map((t) =>
|
|
301
|
+
toCronStatusTaskView(t, {
|
|
302
|
+
overlayId: String(t.id),
|
|
303
|
+
isLocal: true,
|
|
304
|
+
cronFile: file,
|
|
305
|
+
}),
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function listAllCronViews(state: CronInternals) {
|
|
310
|
+
const views: ReturnType<typeof toCronStatusTaskView>[] = [];
|
|
311
|
+
if (!CRON_FILE) return views;
|
|
312
|
+
try {
|
|
313
|
+
const dir = path.dirname(CRON_FILE);
|
|
314
|
+
const myBase = path.basename(CRON_FILE);
|
|
315
|
+
for (const f of fs.readdirSync(dir)) {
|
|
316
|
+
const m = f.match(CRON_FILE_RE);
|
|
317
|
+
if (!m) continue;
|
|
318
|
+
const pid = +m[1];
|
|
319
|
+
const isMe = f === myBase;
|
|
320
|
+
let alive = isMe;
|
|
321
|
+
if (!isMe) {
|
|
322
|
+
try {
|
|
323
|
+
process.kill(pid, 0);
|
|
324
|
+
alive = true;
|
|
325
|
+
} catch {
|
|
326
|
+
/* dead */
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (!alive) continue;
|
|
330
|
+
const filePath = path.join(dir, f);
|
|
331
|
+
const cronTasks: CronTask[] = isMe
|
|
332
|
+
? [...state.tasks.values()]
|
|
333
|
+
: (() => {
|
|
334
|
+
try {
|
|
335
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
336
|
+
if (!Array.isArray(parsed)) return [];
|
|
337
|
+
return parsed;
|
|
338
|
+
} catch {
|
|
339
|
+
return [];
|
|
340
|
+
}
|
|
341
|
+
})();
|
|
342
|
+
if (cronTasks.length === 0) continue;
|
|
343
|
+
const label = isMe ? `PID ${pid} (this session)` : `PID ${pid}`;
|
|
344
|
+
for (const t of cronTasks) {
|
|
345
|
+
views.push(
|
|
346
|
+
toCronStatusTaskView(t, {
|
|
347
|
+
overlayId: `${f}:${t.id}`,
|
|
348
|
+
sessionLabel: label,
|
|
349
|
+
isLocal: isMe,
|
|
350
|
+
cronFile: filePath,
|
|
351
|
+
}),
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
} catch (e: any) {
|
|
356
|
+
console.debug("[cron] list-all scan failed:", e?.message || e);
|
|
357
|
+
}
|
|
358
|
+
return views;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function removeCronByOverlayId(state: CronInternals, overlayId: string): boolean {
|
|
362
|
+
const fromAll = listAllCronViews(state).find((v) => v.id === overlayId);
|
|
363
|
+
const fromLocal = listLocalCronViews(state).find((v) => v.id === overlayId);
|
|
364
|
+
const view = fromAll || fromLocal;
|
|
365
|
+
if (!view) return false;
|
|
366
|
+
|
|
367
|
+
if (view.isLocal || (view.cronFile && view.cronFile === CRON_FILE)) {
|
|
368
|
+
if (!state.tasks.has(view.taskId)) return false;
|
|
369
|
+
state.stopTask(view.taskId);
|
|
370
|
+
state.tasks.delete(view.taskId);
|
|
371
|
+
saveCrons([...state.tasks.values()]);
|
|
372
|
+
state.updateCronStatus();
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Other session — edit their JSON; their watcher (if running new code) resyncs timers.
|
|
377
|
+
if (!view.cronFile) return false;
|
|
378
|
+
try {
|
|
379
|
+
const raw = JSON.parse(fs.readFileSync(view.cronFile, "utf-8"));
|
|
380
|
+
if (!Array.isArray(raw)) return false;
|
|
381
|
+
const next = raw.filter((t: CronTask) => t.id !== view.taskId);
|
|
382
|
+
if (next.length === raw.length) return false;
|
|
383
|
+
fs.writeFileSync(view.cronFile, JSON.stringify(next), { mode: 0o600 });
|
|
384
|
+
return true;
|
|
385
|
+
} catch (e: any) {
|
|
386
|
+
console.debug("[cron] remote remove failed:", e?.message || e);
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function registerCronCommand(pi: ExtensionAPI, state: CronInternals): void {
|
|
392
|
+
pi.registerCommand("cron", {
|
|
393
|
+
description: "Schedule recurring tasks — /cron list|list-all|remove <id>|<natural language>",
|
|
394
|
+
handler: async (args, ctx) => {
|
|
395
|
+
state.lastCtx = ctx;
|
|
396
|
+
state.lastCwd = ctx.cwd;
|
|
397
|
+
const text = (args || "").trim();
|
|
398
|
+
const parts = text.split(/\s+/);
|
|
399
|
+
const sub = parts[0]?.toLowerCase();
|
|
400
|
+
|
|
401
|
+
// Direct handlers — no AI needed
|
|
402
|
+
if (sub === "list" && parts.length === 1) {
|
|
403
|
+
if (!ctx.hasUI) return;
|
|
404
|
+
await openCronStatusOverlay(ctx, {
|
|
405
|
+
listTasks: () => listLocalCronViews(state),
|
|
406
|
+
removeTask: (id) => removeCronByOverlayId(state, id),
|
|
407
|
+
});
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (sub === "list-all") {
|
|
412
|
+
if (!ctx.hasUI) return;
|
|
413
|
+
await openCronStatusOverlay(ctx, {
|
|
414
|
+
title: "Cron tasks (all sessions)",
|
|
415
|
+
emptyMessage: "No crons in any session.",
|
|
416
|
+
borderTitle: (tasks) => {
|
|
417
|
+
const sessions = new Set(
|
|
418
|
+
tasks.map((t) => t.sessionLabel || "?").filter(Boolean),
|
|
419
|
+
);
|
|
420
|
+
return `all sessions · ${tasks.length} tasks · ${sessions.size} session${sessions.size === 1 ? "" : "s"}`;
|
|
421
|
+
},
|
|
422
|
+
listTasks: () => listAllCronViews(state),
|
|
423
|
+
removeTask: (id) => removeCronByOverlayId(state, id),
|
|
424
|
+
});
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if ((sub === "remove" || sub === "rm" || sub === "delete" || sub === "kill") && parts.length > 1) {
|
|
429
|
+
const ids = parts.slice(1).map(p => parseInt(p, 10)).filter(n => !isNaN(n));
|
|
430
|
+
if (ids.length === 0) {
|
|
431
|
+
if (ctx.hasUI) ctx.ui.notify("No valid task IDs. Use /cron list", "warning");
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const removed: string[] = [];
|
|
435
|
+
const notFound: string[] = [];
|
|
436
|
+
for (const id of ids) {
|
|
437
|
+
if (state.tasks.has(id)) {
|
|
438
|
+
const task = state.tasks.get(id)!;
|
|
439
|
+
state.stopTask(id);
|
|
440
|
+
state.tasks.delete(id);
|
|
441
|
+
removed.push(`#${id} (${task.description})`);
|
|
442
|
+
} else {
|
|
443
|
+
notFound.push(`#${id}`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
saveCrons([...state.tasks.values()]);
|
|
447
|
+
state.updateCronStatus();
|
|
448
|
+
const lines: string[] = [];
|
|
449
|
+
if (removed.length) lines.push(`Removed: ${removed.join(", ")}`);
|
|
450
|
+
if (notFound.length) lines.push(`Not found: ${notFound.join(", ")}`);
|
|
451
|
+
if (ctx.hasUI) ctx.ui.notify(lines.join("\n"), removed.length ? "info" : "warning");
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (!text) {
|
|
456
|
+
if (ctx.hasUI) ctx.ui.notify("Usage:\n/cron <natural language task>\n/cron list\n/cron list-all\n/cron remove <id>", "info");
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Only "add" goes through the AI for natural language parsing
|
|
461
|
+
pi.sendUserMessage(
|
|
462
|
+
`The user wants to schedule a cron task. Parse their request and use the cron_manage tool with action "add".\n\nUser request: "${text}"\n\nInterpret the schedule and task from the natural language. For interval-based schedules, convert to seconds. For time-based schedules, extract hour and minute.`,
|
|
463
|
+
{ deliverAs: "followUp" },
|
|
464
|
+
);
|
|
465
|
+
},
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ── Registration ─────────────────────────────────────────────────────
|
|
470
|
+
|
|
471
|
+
export function registerCron(
|
|
472
|
+
pi: ExtensionAPI,
|
|
473
|
+
spawnAsyncAgent: (
|
|
474
|
+
agentName: string,
|
|
475
|
+
task: string,
|
|
476
|
+
cwd: string,
|
|
477
|
+
agents: any[],
|
|
478
|
+
options?: {
|
|
479
|
+
fireAndForget?: boolean;
|
|
480
|
+
name?: string;
|
|
481
|
+
parentModelId?: string;
|
|
482
|
+
parentProvider?: string;
|
|
483
|
+
},
|
|
484
|
+
) => { id: string; error?: string },
|
|
485
|
+
): { getCronTasks: () => CronTask[] } {
|
|
486
|
+
if (process.env.PI_SUBAGENT_CHILD === "1") return { getCronTasks: () => [] };
|
|
487
|
+
|
|
488
|
+
const tasks = new Map<number, CronTask>();
|
|
489
|
+
const timers = new Map<number, ReturnType<typeof setTimeout> | ReturnType<typeof setInterval>>();
|
|
490
|
+
|
|
491
|
+
const state: CronInternals = {
|
|
492
|
+
tasks,
|
|
493
|
+
nextId: 1,
|
|
494
|
+
lastCwd: "",
|
|
495
|
+
lastCtx: null,
|
|
496
|
+
startTask,
|
|
497
|
+
stopTask,
|
|
498
|
+
updateCronStatus,
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
function updateCronStatus() {
|
|
502
|
+
const count = tasks.size;
|
|
503
|
+
saveCrons([...tasks.values()]);
|
|
504
|
+
try {
|
|
505
|
+
if (state.lastCtx?.hasUI) {
|
|
506
|
+
if (count > 0) {
|
|
507
|
+
setSlot("crons", state.lastCtx.ui.theme.fg("muted", `⏰ ${count} cron${count > 1 ? "s" : ""}`), state.lastCtx);
|
|
508
|
+
} else {
|
|
509
|
+
setSlot("crons", state.lastCtx.ui.theme.fg("muted", `⏰ 0 crons`), state.lastCtx);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
} catch { /* stale ctx after session replacement */ }
|
|
513
|
+
pi.events.emit("pidash:cron-status", {
|
|
514
|
+
count,
|
|
515
|
+
tasks: [...tasks.values()].map(t => ({
|
|
516
|
+
id: t.id,
|
|
517
|
+
description: t.description,
|
|
518
|
+
schedule: formatSchedule(t),
|
|
519
|
+
lastRun: t.lastRun,
|
|
520
|
+
nextRun: t.nextRun,
|
|
521
|
+
})),
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function executeCronTask(task: CronTask) {
|
|
526
|
+
task.lastRun = Date.now();
|
|
527
|
+
saveCrons([...tasks.values()]);
|
|
528
|
+
|
|
529
|
+
const cmd = task.task.trim();
|
|
530
|
+
|
|
531
|
+
if (cmd.startsWith("/")) {
|
|
532
|
+
// Slash command — send as user message
|
|
533
|
+
pi.sendUserMessage(cmd, { deliverAs: "followUp" });
|
|
534
|
+
} else {
|
|
535
|
+
// Prompt task — run as async agent, result surfaces to AI
|
|
536
|
+
const cwd = state.lastCwd || process.cwd();
|
|
537
|
+
const parentProvider = state.lastCtx?.model?.provider as string | undefined;
|
|
538
|
+
const dispatch = decideAsyncLlmDispatch({
|
|
539
|
+
parentProvider,
|
|
540
|
+
cwd,
|
|
541
|
+
mustAsync: true,
|
|
542
|
+
});
|
|
543
|
+
if (dispatch.action === "skip") {
|
|
544
|
+
console.debug(`[cron] ${dispatch.note} (task #${task.id})`);
|
|
545
|
+
try {
|
|
546
|
+
state.lastCtx?.ui?.notify?.(
|
|
547
|
+
`Cron #${task.id} skipped: set async_llm_provider/async_llm_model for acpx`,
|
|
548
|
+
"warning",
|
|
549
|
+
);
|
|
550
|
+
} catch { /* stale UI */ }
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
const { agents } = discoverAgents(cwd, "user");
|
|
554
|
+
const sidecarOpts =
|
|
555
|
+
dispatch.action === "sidecar-async"
|
|
556
|
+
? {
|
|
557
|
+
parentProvider: dispatch.sidecar.provider,
|
|
558
|
+
parentModelId: dispatch.sidecar.model,
|
|
559
|
+
}
|
|
560
|
+
: {};
|
|
561
|
+
spawnAsyncAgent("worker", cmd, cwd, agents, {
|
|
562
|
+
name: `Cron: ${task.description.slice(0, 40)}`,
|
|
563
|
+
...sidecarOpts,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function startTask(task: CronTask) {
|
|
569
|
+
const existing = timers.get(task.id);
|
|
570
|
+
if (existing) { clearTimeout(existing as any); clearInterval(existing as any); }
|
|
571
|
+
|
|
572
|
+
if (task.intervalMs) {
|
|
573
|
+
const timer = setInterval(() => executeCronTask(task), task.intervalMs);
|
|
574
|
+
if ((timer as any).unref) (timer as any).unref();
|
|
575
|
+
timers.set(task.id, timer);
|
|
576
|
+
} else if (task.atHour !== undefined && task.atMinute !== undefined) {
|
|
577
|
+
const scheduleNext = () => {
|
|
578
|
+
const ms = msUntilNextTime(task.atHour!, task.atMinute!);
|
|
579
|
+
task.nextRun = Date.now() + ms;
|
|
580
|
+
saveCrons([...tasks.values()]);
|
|
581
|
+
const timer = setTimeout(() => {
|
|
582
|
+
executeCronTask(task);
|
|
583
|
+
scheduleNext();
|
|
584
|
+
}, ms);
|
|
585
|
+
if (timer.unref) timer.unref();
|
|
586
|
+
timers.set(task.id, timer);
|
|
587
|
+
};
|
|
588
|
+
scheduleNext();
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function stopTask(id: number) {
|
|
593
|
+
const timer = timers.get(id);
|
|
594
|
+
if (timer) {
|
|
595
|
+
clearTimeout(timer as any);
|
|
596
|
+
clearInterval(timer as any);
|
|
597
|
+
timers.delete(id);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Resync in-memory tasks from CRON_FILE (external list-all remove / peer edit). */
|
|
602
|
+
function syncTasksFromDisk(): void {
|
|
603
|
+
if (!CRON_FILE) return;
|
|
604
|
+
const onDisk = loadCrons();
|
|
605
|
+
const diskIds = new Set(onDisk.map((t) => t.id));
|
|
606
|
+
|
|
607
|
+
for (const id of [...tasks.keys()]) {
|
|
608
|
+
if (!diskIds.has(id)) {
|
|
609
|
+
stopTask(id);
|
|
610
|
+
tasks.delete(id);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
for (const task of onDisk) {
|
|
615
|
+
// Validate before starting — same rules as add path
|
|
616
|
+
if (!task.task || typeof task.task !== "string" || !task.task.trim()) continue;
|
|
617
|
+
if (task.intervalMs !== undefined && task.intervalMs < 10000) continue;
|
|
618
|
+
|
|
619
|
+
const existing = tasks.get(task.id);
|
|
620
|
+
if (!existing) {
|
|
621
|
+
if (task.id >= state.nextId) state.nextId = task.id + 1;
|
|
622
|
+
tasks.set(task.id, task);
|
|
623
|
+
startTask(task);
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
// Refresh mutable fields from disk without restarting timer unless schedule changed
|
|
627
|
+
const scheduleChanged =
|
|
628
|
+
existing.intervalMs !== task.intervalMs ||
|
|
629
|
+
existing.atHour !== task.atHour ||
|
|
630
|
+
existing.atMinute !== task.atMinute;
|
|
631
|
+
Object.assign(existing, task);
|
|
632
|
+
if (scheduleChanged) startTask(existing);
|
|
633
|
+
}
|
|
634
|
+
updateCronStatus();
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
let cronFileWatcher: fs.FSWatcher | null = null;
|
|
638
|
+
|
|
639
|
+
// Restore persisted crons on session start
|
|
640
|
+
pi.on("session_start", (_event, ctx) => {
|
|
641
|
+
state.lastCwd = ctx.cwd;
|
|
642
|
+
state.lastCtx = ctx;
|
|
643
|
+
CRON_FILE = path.join(getProjectTmpDir(ctx.cwd), `cron-${SESSION_SUFFIX}.json`);
|
|
644
|
+
cleanupOrphanedCronFiles();
|
|
645
|
+
// Skip cron scheduling in one-shot modes
|
|
646
|
+
if (ctx.mode === "print" || ctx.mode === "json") return;
|
|
647
|
+
|
|
648
|
+
// Restore from persistence (after /reload or /new)
|
|
649
|
+
const restored = loadCrons();
|
|
650
|
+
for (const task of restored) {
|
|
651
|
+
if (!tasks.has(task.id)) {
|
|
652
|
+
if (task.id >= state.nextId) state.nextId = task.id + 1;
|
|
653
|
+
tasks.set(task.id, task);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
// Restart all timers with fresh context
|
|
657
|
+
for (const task of tasks.values()) {
|
|
658
|
+
startTask(task);
|
|
659
|
+
}
|
|
660
|
+
updateCronStatus();
|
|
661
|
+
|
|
662
|
+
// Ensure file exists so we can watch peer list-all removes
|
|
663
|
+
if (!fs.existsSync(CRON_FILE)) saveCrons([...tasks.values()]);
|
|
664
|
+
|
|
665
|
+
// Watch own file so /cron list-all remove from another session stops our timers
|
|
666
|
+
try {
|
|
667
|
+
cronFileWatcher?.close();
|
|
668
|
+
} catch { /* ignore */ }
|
|
669
|
+
cronFileWatcher = null;
|
|
670
|
+
try {
|
|
671
|
+
let debounce: ReturnType<typeof setTimeout> | null = null;
|
|
672
|
+
cronFileWatcher = fs.watch(CRON_FILE, () => {
|
|
673
|
+
if (Date.now() < CRON_IGNORE_WATCH_UNTIL) return;
|
|
674
|
+
if (debounce) clearTimeout(debounce);
|
|
675
|
+
debounce = setTimeout(() => {
|
|
676
|
+
if (Date.now() < CRON_IGNORE_WATCH_UNTIL) return;
|
|
677
|
+
try {
|
|
678
|
+
syncTasksFromDisk();
|
|
679
|
+
} catch (e: any) {
|
|
680
|
+
console.debug("[cron] sync from disk failed:", e?.message || e);
|
|
681
|
+
}
|
|
682
|
+
}, 150);
|
|
683
|
+
});
|
|
684
|
+
cronFileWatcher.unref?.();
|
|
685
|
+
} catch (e: any) {
|
|
686
|
+
console.debug("[cron] watch setup failed:", e?.message || e);
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
// Handle cron kill from pidash browser
|
|
691
|
+
pi.events.on("pidash:cron-kill", (data: unknown) => {
|
|
692
|
+
const target = data as string;
|
|
693
|
+
if (target === "all") {
|
|
694
|
+
for (const id of [...tasks.keys()]) {
|
|
695
|
+
stopTask(id);
|
|
696
|
+
tasks.delete(id);
|
|
697
|
+
}
|
|
698
|
+
} else {
|
|
699
|
+
const id = parseInt(target, 10);
|
|
700
|
+
if (id && tasks.has(id)) {
|
|
701
|
+
stopTask(id);
|
|
702
|
+
tasks.delete(id);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
saveCrons([...tasks.values()]);
|
|
706
|
+
updateCronStatus();
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
// Stop all timers and persist on shutdown
|
|
710
|
+
pi.on("session_shutdown", () => {
|
|
711
|
+
try {
|
|
712
|
+
cronFileWatcher?.close();
|
|
713
|
+
} catch { /* ignore */ }
|
|
714
|
+
cronFileWatcher = null;
|
|
715
|
+
for (const id of timers.keys()) {
|
|
716
|
+
stopTask(id);
|
|
717
|
+
}
|
|
718
|
+
saveCrons([...tasks.values()]);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
// Register cron_manage tool — the AI calls this with structured params
|
|
722
|
+
registerCronTool(pi, state);
|
|
723
|
+
|
|
724
|
+
// /cron command
|
|
725
|
+
registerCronCommand(pi, state);
|
|
726
|
+
|
|
727
|
+
return {
|
|
728
|
+
getCronTasks: () => [...tasks.values()],
|
|
729
|
+
};
|
|
730
|
+
}
|