@cad0p/pi-subagents-tintinweb 0.14.2 → 0.22.0
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 +20 -1
- package/CONTRIBUTING.md +8 -0
- package/README.md +98 -58
- package/docs/fork-differences.md +51 -0
- package/docs/verification-checklist.md +132 -0
- package/examples/agent-tool-description.md +3 -9
- package/package.json +13 -11
- package/pnpm-workspace.yaml +9 -0
- package/src/abortable.ts +43 -0
- package/src/agent-manager.ts +404 -140
- package/src/agent-runner.ts +462 -89
- package/src/agent-types.ts +269 -23
- package/src/checkpoint.ts +49 -0
- package/src/child-context.ts +30 -0
- package/src/cross-extension-rpc.ts +65 -5
- package/src/custom-agents.ts +161 -14
- package/src/default-agents.ts +1 -1
- package/src/index.ts +1014 -980
- package/src/invocation-config.ts +81 -12
- package/src/model-resolver.ts +18 -0
- package/src/model-scope.ts +62 -0
- package/src/nested-tools.ts +406 -0
- package/src/output-file.ts +18 -1
- package/src/prompts.ts +71 -0
- package/src/result-report.ts +170 -0
- package/src/schedule-store.ts +300 -13
- package/src/schedule.ts +222 -77
- package/src/settings.ts +206 -32
- package/src/status-note.ts +18 -1
- package/src/text-safety.ts +69 -0
- package/src/types.ts +101 -58
- package/src/ui/agent-widget.ts +104 -36
- package/src/ui/conversation-viewer.ts +36 -18
- package/src/ui/fleet-list.ts +19 -8
- package/src/ui/schedule-menu.ts +63 -19
- package/src/usage.ts +128 -8
- package/src/worktree.ts +19 -0
- package/src/group-join.ts +0 -141
package/src/prompts.ts
CHANGED
|
@@ -110,6 +110,77 @@ ${envBlock}`;
|
|
|
110
110
|
return activeAgentTag + replaceHeader + worktreeSection + "\n\n" + config.systemPrompt + extrasSuffix;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/** One-line tool snippets keyed by tool name, as pi's `<tools>` section prints them. */
|
|
114
|
+
export type ToolSnippets = Record<string, string>;
|
|
115
|
+
|
|
116
|
+
/** The line pi puts after the tool list in a `<tools>` section. */
|
|
117
|
+
const TOOLS_TRAILER =
|
|
118
|
+
"In addition to the tools above, you may have access to other custom tools depending on the project.";
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Replace the `<tools>` block of an inherited prompt with one describing the tools
|
|
122
|
+
* THIS agent declares.
|
|
123
|
+
*
|
|
124
|
+
* An append-mode agent inherits the parent's prompt verbatim, `<tools>` block
|
|
125
|
+
* included — so a child that lacks `codemode`, `tool_search`, or an extension tool the
|
|
126
|
+
* parent has is told it has them and then calls a tool that does not exist. pi cannot
|
|
127
|
+
* rebuild the block for us: the child's prompt arrives as `systemPromptOverride`, i.e.
|
|
128
|
+
* as a custom prompt, and pi only generates `<tools>` for its own default prompt.
|
|
129
|
+
*
|
|
130
|
+
* Snippets come from `snippets` (pi's built-in tool definitions and the child's own
|
|
131
|
+
* extension tools) and, as a fallback, from the inherited block, which is the only
|
|
132
|
+
* place a tool this fork knows nothing about is described. `snippets` wins on a name
|
|
133
|
+
* both have. A tool without a snippet is left out, as pi does, and an agent declaring
|
|
134
|
+
* no tools at all loses the block entirely rather than advertising an empty list.
|
|
135
|
+
*
|
|
136
|
+
* A prompt with no block at all gets one prepended: a parent running a custom system
|
|
137
|
+
* prompt has none, and the child's prompt is a custom prompt to pi, so pi will not add
|
|
138
|
+
* one either — an agent that declares tools and advertises none is the same lie in the
|
|
139
|
+
* other direction.
|
|
140
|
+
*/
|
|
141
|
+
export function retargetToolsSection(
|
|
142
|
+
prompt: string,
|
|
143
|
+
toolNames: readonly string[],
|
|
144
|
+
snippets: ToolSnippets = {},
|
|
145
|
+
): string {
|
|
146
|
+
// Tolerate CRLF: pi writes `\n`, but an inherited custom prompt may not, and a
|
|
147
|
+
// failed match would leave the child with no block at all.
|
|
148
|
+
const match = /<tools>\r?\n([\s\S]*?)\r?\n<\/tools>(?:\r?\n){0,2}/.exec(prompt);
|
|
149
|
+
|
|
150
|
+
const inherited = new Map<string, string>();
|
|
151
|
+
if (match) {
|
|
152
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
153
|
+
const entry = /^- ([^:]+): (.+?)\r?$/.exec(line);
|
|
154
|
+
if (entry) inherited.set(entry[1], entry[2]);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const lines: string[] = [];
|
|
159
|
+
for (const name of toolNames) {
|
|
160
|
+
const snippet = snippets[name] ?? inherited.get(name);
|
|
161
|
+
if (snippet) lines.push(`- ${name}: ${snippet}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const block = lines.length > 0 ? `<tools>\n${lines.join("\n")}\n\n${TOOLS_TRAILER}\n</tools>` : "";
|
|
165
|
+
|
|
166
|
+
if (!match) {
|
|
167
|
+
// A prompt that already mentions `<tools` in a shape this parser does not
|
|
168
|
+
// recognise — same-line content, an attribute, a fenced XML example — must
|
|
169
|
+
// not gain a second, contradictory block. Leaving it alone under-advertises;
|
|
170
|
+
// duplicating it advertises two different lists.
|
|
171
|
+
if (prompt.includes("<tools")) return prompt;
|
|
172
|
+
// Append rather than prepend. Append mode exists so the parent prompt stays a
|
|
173
|
+
// byte-identical prefix of the child's (KV-cache reuse, see buildAgentPrompt),
|
|
174
|
+
// and a leading block would break that for exactly the parents that have no
|
|
175
|
+
// block to replace. It also keeps `<active_agent>` first in replace mode.
|
|
176
|
+
return block ? `${prompt}\n\n${block}` : prompt;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// The match consumed the separator that followed `</tools>`; put one back when
|
|
180
|
+
// a block is written, and none when the block is stripped.
|
|
181
|
+
return prompt.slice(0, match.index) + (block ? `${block}\n\n` : "") + prompt.slice(match.index + match[0].length);
|
|
182
|
+
}
|
|
183
|
+
|
|
113
184
|
/** Fallback base prompt when parent system prompt is unavailable in append mode. */
|
|
114
185
|
const genericBase = `# Role
|
|
115
186
|
You are a general-purpose coding agent for complex, multi-step tasks.
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* result-report.ts — the `get_subagent_result` report, shared by the top-level
|
|
3
|
+
* tool and the nested delegation tools.
|
|
4
|
+
*
|
|
5
|
+
* Both surfaces must describe a run the same way: the turn/max-turns header, the
|
|
6
|
+
* latest checkpoint, the artifact paths, and the honest status note on partial
|
|
7
|
+
* output. A nested parent reads the same shape as the main session, so the
|
|
8
|
+
* report lives here rather than inside the extension entry point (which the
|
|
9
|
+
* nested tools cannot import).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { checkpointsFilePath } from "./checkpoint.js";
|
|
14
|
+
import { DEFAULT_SHOW_COST } from "./settings.js";
|
|
15
|
+
import { getStatusNote, partialOutputSuffix } from "./status-note.js";
|
|
16
|
+
import { toSingleLine } from "./text-safety.js";
|
|
17
|
+
import type { AgentRecord } from "./types.js";
|
|
18
|
+
import { formatCost, formatDuration, formatTokens, getDisplayName } from "./ui/agent-widget.js";
|
|
19
|
+
import { getLifetimeCost, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage } from "./usage.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether the report shows an estimated cost beside the token count — the
|
|
23
|
+
* user's `showCost` setting, mirrored here by `index.ts`'s applier.
|
|
24
|
+
*
|
|
25
|
+
* Module state rather than a parameter because BOTH report callers must agree:
|
|
26
|
+
* the top-level `get_subagent_result` and its nested twin (nested-tools.ts),
|
|
27
|
+
* which a child session builds without access to the parent's settings. One
|
|
28
|
+
* source beats threading the same flag through two independent tool factories.
|
|
29
|
+
*/
|
|
30
|
+
let showCost = DEFAULT_SHOW_COST;
|
|
31
|
+
|
|
32
|
+
/** Mirror the live `showCost` setting into the report renderer. */
|
|
33
|
+
export function setReportShowCost(b: boolean): void { showCost = b; }
|
|
34
|
+
|
|
35
|
+
/** Format an agent's lifetime token total, or "" when zero. */
|
|
36
|
+
export function formatLifetimeTokens(o: { lifetimeUsage: LifetimeUsage }): string {
|
|
37
|
+
const t = getLifetimeTotal(o.lifetimeUsage);
|
|
38
|
+
return t > 0 ? formatTokens(t) : "";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A record's artifact paths, each gated on the file actually existing.
|
|
42
|
+
* `checkpointsFileOk` is latching-true, so existsSync covers out-of-band deletion. */
|
|
43
|
+
function reportPaths(record: AgentRecord): { checkpointsPath?: string; transcriptPath?: string } {
|
|
44
|
+
const candidate = checkpointsFilePath(record.outputFile);
|
|
45
|
+
return {
|
|
46
|
+
checkpointsPath: record.checkpointsFileOk && candidate && existsSync(candidate) ? candidate : undefined,
|
|
47
|
+
transcriptPath: record.outputFile && existsSync(record.outputFile) ? record.outputFile : undefined,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The full report for a record — running, queued, or terminal. */
|
|
52
|
+
export function renderAgentReport(record: AgentRecord): string {
|
|
53
|
+
const { checkpointsPath, transcriptPath } = reportPaths(record);
|
|
54
|
+
if (record.status === "running") return renderRunning(record, checkpointsPath, transcriptPath);
|
|
55
|
+
if (record.status === "queued") return renderQueued(record);
|
|
56
|
+
return renderTerminal(record, checkpointsPath, transcriptPath, record.status === "error");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Queued subagent: no file paths, result body, or footer — nothing produced yet. */
|
|
60
|
+
export function renderQueued(record: AgentRecord): string {
|
|
61
|
+
const displayName = toSingleLine(getDisplayName(record.type));
|
|
62
|
+
return (
|
|
63
|
+
`Agent: ${toSingleLine(record.id)} (queued — not started yet)\n` +
|
|
64
|
+
`Type: ${displayName} | Description: ${toSingleLine(record.description)}\n\n` +
|
|
65
|
+
`This agent is waiting to start. It will begin running when a concurrent-agent slot frees up.`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Running subagent: header (turn/elapsed), latest checkpoint, file paths, polling-discouragement footer. */
|
|
70
|
+
function renderRunning(
|
|
71
|
+
record: AgentRecord,
|
|
72
|
+
checkpointsPath: string | undefined,
|
|
73
|
+
transcriptPath: string | undefined,
|
|
74
|
+
): string {
|
|
75
|
+
const displayName = toSingleLine(getDisplayName(record.type));
|
|
76
|
+
const turn = record.turnCount ?? 0;
|
|
77
|
+
const maxTurns = record.effectiveMaxTurns;
|
|
78
|
+
const elapsedSeconds = Math.round((Date.now() - record.startedAt) / 1000);
|
|
79
|
+
const turnLabel = maxTurns != null ? `turn ${turn}/${maxTurns}` : `turn ${turn}`;
|
|
80
|
+
|
|
81
|
+
let output =
|
|
82
|
+
`Agent: ${toSingleLine(record.id)} (still running — ${turnLabel}, ${elapsedSeconds}s elapsed)\n` +
|
|
83
|
+
`Type: ${displayName} | Description: ${toSingleLine(record.description)}\n\n`;
|
|
84
|
+
|
|
85
|
+
if (record.lastCheckpoint) {
|
|
86
|
+
output +=
|
|
87
|
+
`Latest checkpoint (turn ${record.lastCheckpoint.turn}):\n` +
|
|
88
|
+
` ${record.lastCheckpoint.summary}\n\n`;
|
|
89
|
+
output += fileSection(checkpointsPath, transcriptPath, footerForPaths(checkpointsPath, transcriptPath, true));
|
|
90
|
+
} else {
|
|
91
|
+
output += `No checkpoint yet — the subagent hasn't called the checkpoint tool.\n\n`;
|
|
92
|
+
output += fileSection(checkpointsPath, transcriptPath,
|
|
93
|
+
" grep or read the transcript for detail on what it's doing. Do not poll repeatedly.");
|
|
94
|
+
}
|
|
95
|
+
return output;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Completed/errored subagent: completed header, result or error block, latest checkpoint, file paths. */
|
|
99
|
+
function renderTerminal(
|
|
100
|
+
record: AgentRecord,
|
|
101
|
+
checkpointsPath: string | undefined,
|
|
102
|
+
transcriptPath: string | undefined,
|
|
103
|
+
errored: boolean,
|
|
104
|
+
): string {
|
|
105
|
+
const header = completedHeader(record);
|
|
106
|
+
const body = errored
|
|
107
|
+
? `Error: ${record.error}${partialOutputSuffix(record)}`
|
|
108
|
+
: record.result?.trim() || "No output.";
|
|
109
|
+
|
|
110
|
+
let output = `${header}\n\n${body}`;
|
|
111
|
+
if (record.lastCheckpoint) {
|
|
112
|
+
output +=
|
|
113
|
+
`\n\nLatest checkpoint (turn ${record.lastCheckpoint.turn}):\n` +
|
|
114
|
+
` ${record.lastCheckpoint.summary}`;
|
|
115
|
+
}
|
|
116
|
+
output += "\n\n" + fileSection(checkpointsPath, transcriptPath, footerForPaths(checkpointsPath, transcriptPath, false));
|
|
117
|
+
return output;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Shared header for completed/errored shapes (Status + stats + status note). */
|
|
121
|
+
function completedHeader(record: AgentRecord): string {
|
|
122
|
+
const displayName = toSingleLine(getDisplayName(record.type));
|
|
123
|
+
const duration = formatDuration(record.startedAt, record.completedAt);
|
|
124
|
+
const tokens = formatLifetimeTokens(record);
|
|
125
|
+
const contextPercent = getSessionContextPercent(record.session);
|
|
126
|
+
const statsParts = [`Tool uses: ${record.toolUses}`];
|
|
127
|
+
if (tokens) statsParts.push(tokens);
|
|
128
|
+
// Labelled field, like its pipe-separated neighbours. Omitted entirely when
|
|
129
|
+
// there is no cost to show: an unpriced model would otherwise read `Cost:
|
|
130
|
+
// $0.00`, which claims the run was measured and free rather than never priced.
|
|
131
|
+
const cost = showCost ? formatCost(getLifetimeCost(record.lifetimeUsage)) : "";
|
|
132
|
+
if (cost) statsParts.push(`Cost: ${cost}`);
|
|
133
|
+
if (contextPercent !== null) statsParts.push(`Context: ${Math.round(contextPercent)}%`);
|
|
134
|
+
if (record.compactionCount) statsParts.push(`Compactions: ${record.compactionCount}`);
|
|
135
|
+
statsParts.push(`Duration: ${duration}`);
|
|
136
|
+
return (
|
|
137
|
+
`Agent: ${toSingleLine(record.id)}\n` +
|
|
138
|
+
`Type: ${displayName} | Status: ${toSingleLine(record.status)}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
|
|
139
|
+
`Description: ${toSingleLine(record.description)}`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The checkpoint-history / full-transcript path block, aligned to a shared column, plus the footer. */
|
|
144
|
+
function fileSection(
|
|
145
|
+
checkpointsPath: string | undefined,
|
|
146
|
+
transcriptPath: string | undefined,
|
|
147
|
+
footer: string,
|
|
148
|
+
): string {
|
|
149
|
+
let s = "";
|
|
150
|
+
if (checkpointsPath) s += `Checkpoint history: ${toSingleLine(checkpointsPath)}\n`;
|
|
151
|
+
if (transcriptPath) s += `Full transcript: ${toSingleLine(transcriptPath)}\n`;
|
|
152
|
+
s += footer;
|
|
153
|
+
return s;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Footer matching the paths actually rendered (existsSync can suppress either independently). */
|
|
157
|
+
function footerForPaths(
|
|
158
|
+
checkpointsPath: string | undefined,
|
|
159
|
+
transcriptPath: string | undefined,
|
|
160
|
+
running: boolean,
|
|
161
|
+
): string {
|
|
162
|
+
const suffix = running ? " Do not poll repeatedly." : "";
|
|
163
|
+
if (checkpointsPath && transcriptPath) {
|
|
164
|
+
return ` grep or read the checkpoints / transcript for more detail.${suffix}`;
|
|
165
|
+
}
|
|
166
|
+
if (checkpointsPath) {
|
|
167
|
+
return ` grep or read the checkpoints file for more detail.${suffix}`;
|
|
168
|
+
}
|
|
169
|
+
return ` grep or read the transcript for more detail.${suffix}`;
|
|
170
|
+
}
|
package/src/schedule-store.ts
CHANGED
|
@@ -12,11 +12,117 @@
|
|
|
12
12
|
|
|
13
13
|
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
14
14
|
import { dirname, join } from "node:path";
|
|
15
|
-
import
|
|
15
|
+
import { isValidType } from "./agent-types.js";
|
|
16
|
+
import { SubagentScheduler } from "./schedule.js";
|
|
17
|
+
import type { IsolationMode, ScheduledSubagent, ScheduleStoreData, SubagentType, ThinkingLevel } from "./types.js";
|
|
16
18
|
|
|
17
19
|
const LOCK_RETRY_MS = 50;
|
|
18
20
|
const LOCK_MAX_RETRIES = 100;
|
|
19
21
|
|
|
22
|
+
const SCHEDULE_TYPES: readonly string[] = ["cron", "once", "interval"];
|
|
23
|
+
const LAST_STATUSES: readonly string[] = ["success", "error", "running"];
|
|
24
|
+
const ISOLATION_MODES: readonly string[] = ["worktree"];
|
|
25
|
+
|
|
26
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
27
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Coerce one parsed store entry into a `ScheduledSubagent`, dropping optional
|
|
32
|
+
* fields whose type is wrong. Returns undefined when the record cannot run or
|
|
33
|
+
* be managed — no usable id, or a wrong-typed load-bearing field — so load()
|
|
34
|
+
* can preserve it on disk without exposing it to the scheduler.
|
|
35
|
+
*/
|
|
36
|
+
function sanitizeJob(raw: unknown): ScheduledSubagent | undefined {
|
|
37
|
+
if (!isRecord(raw)) return undefined;
|
|
38
|
+
const { id, name, description, schedule, scheduleType, subagent_type, prompt } = raw;
|
|
39
|
+
if (typeof id !== "string" || id.length === 0) return undefined;
|
|
40
|
+
if (
|
|
41
|
+
typeof name !== "string" ||
|
|
42
|
+
typeof description !== "string" ||
|
|
43
|
+
typeof schedule !== "string" ||
|
|
44
|
+
typeof subagent_type !== "string" ||
|
|
45
|
+
typeof prompt !== "string" ||
|
|
46
|
+
typeof scheduleType !== "string" ||
|
|
47
|
+
!SCHEDULE_TYPES.includes(scheduleType)
|
|
48
|
+
) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
// Records that cannot run as declared must not appear active: a bad cron
|
|
52
|
+
// would arm nothing while the menu shows it as healthy, and an unregistered
|
|
53
|
+
// type silently falls back to a write-capable config at spawn time.
|
|
54
|
+
if (scheduleType === "cron" && !SubagentScheduler.validateCronExpression(schedule).valid) return undefined;
|
|
55
|
+
if (!isValidType(subagent_type)) return undefined;
|
|
56
|
+
return {
|
|
57
|
+
id,
|
|
58
|
+
name,
|
|
59
|
+
description,
|
|
60
|
+
schedule,
|
|
61
|
+
scheduleType: scheduleType as ScheduledSubagent["scheduleType"],
|
|
62
|
+
intervalMs: typeof raw.intervalMs === "number" && Number.isFinite(raw.intervalMs) ? raw.intervalMs : undefined,
|
|
63
|
+
subagent_type: subagent_type as SubagentType,
|
|
64
|
+
prompt,
|
|
65
|
+
model: typeof raw.model === "string" ? raw.model : undefined,
|
|
66
|
+
thinking: typeof raw.thinking === "string" ? (raw.thinking as ThinkingLevel) : undefined,
|
|
67
|
+
max_turns: typeof raw.max_turns === "number" && Number.isFinite(raw.max_turns) ? raw.max_turns : undefined,
|
|
68
|
+
isolated: typeof raw.isolated === "boolean" ? raw.isolated : undefined,
|
|
69
|
+
isolation: typeof raw.isolation === "string" && ISOLATION_MODES.includes(raw.isolation) ? (raw.isolation as IsolationMode) : undefined,
|
|
70
|
+
// A non-boolean flag cannot be trusted to mean "arm me": load disabled and
|
|
71
|
+
// keep the record visible so the user can fix or cancel it.
|
|
72
|
+
enabled: raw.enabled === true,
|
|
73
|
+
createdAt: typeof raw.createdAt === "string" ? raw.createdAt : "",
|
|
74
|
+
lastRun: typeof raw.lastRun === "string" ? raw.lastRun : undefined,
|
|
75
|
+
lastStatus: typeof raw.lastStatus === "string" && LAST_STATUSES.includes(raw.lastStatus) ? (raw.lastStatus as ScheduledSubagent["lastStatus"]) : undefined,
|
|
76
|
+
nextRun: typeof raw.nextRun === "string" ? raw.nextRun : undefined,
|
|
77
|
+
runCount: typeof raw.runCount === "number" && Number.isFinite(raw.runCount) ? raw.runCount : 0,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Stable per-entry summary for the warn-once signature. */
|
|
82
|
+
function skipSignature(raw: unknown): string {
|
|
83
|
+
if (isRecord(raw) && typeof raw.id === "string") return raw.id || "(empty-id)";
|
|
84
|
+
return typeof raw === "object" && raw !== null ? "object" : typeof raw;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Deepest JSON nesting a preserved record may have. A real scheduled job is a
|
|
89
|
+
* flat object; anything past a handful of levels is hand-written or corrupt.
|
|
90
|
+
* The bound is deliberately far below the smallest JSON.stringify stack
|
|
91
|
+
* ceiling observed on the supported engines (~6k nesting frames on the Node
|
|
92
|
+
* 22/24 main thread; worker threads are much higher), so an accepted record
|
|
93
|
+
* can always be re-serialized and the drop decision does not depend on the
|
|
94
|
+
* ambient stack depth.
|
|
95
|
+
*/
|
|
96
|
+
export const MAX_PRESERVED_DEPTH = 64;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* True when `value` nests no deeper than `budget` JSON levels. Recursion is
|
|
100
|
+
* bounded by `budget`, so an arbitrarily deep value cannot overflow this
|
|
101
|
+
* check itself (JSON.parse accepts far more nesting than stringify).
|
|
102
|
+
*/
|
|
103
|
+
function isWithinDepth(value: unknown, budget: number): boolean {
|
|
104
|
+
if (!Array.isArray(value) && !isRecord(value)) return true;
|
|
105
|
+
if (budget <= 0) return false;
|
|
106
|
+
const children = Array.isArray(value) ? value : Object.values(value);
|
|
107
|
+
for (const child of children) {
|
|
108
|
+
if (!isWithinDepth(child, budget - 1)) return false;
|
|
109
|
+
}
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Queue a record for verbatim re-write on save, but only when it nests no
|
|
115
|
+
* deeper than MAX_PRESERVED_DEPTH. Preserved records come straight from
|
|
116
|
+
* JSON.parse output, so the depth bound is the whole serializability
|
|
117
|
+
* guarantee: parsed values cannot carry cycles, BigInt, getters, or a
|
|
118
|
+
* `toJSON`. Returns false when the record was dropped.
|
|
119
|
+
*/
|
|
120
|
+
function preserveRecord(raw: unknown, into: unknown[]): boolean {
|
|
121
|
+
if (!isWithinDepth(raw, MAX_PRESERVED_DEPTH)) return false;
|
|
122
|
+
into.push(raw);
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
20
126
|
function isProcessRunning(pid: number): boolean {
|
|
21
127
|
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
22
128
|
}
|
|
@@ -58,6 +164,47 @@ export class ScheduleStore {
|
|
|
58
164
|
private filePath: string;
|
|
59
165
|
private lockPath: string;
|
|
60
166
|
private jobs = new Map<string, ScheduledSubagent>();
|
|
167
|
+
/** Raw entries load() could not sanitize; written back on save so no user data is erased. */
|
|
168
|
+
private skipped: unknown[] = [];
|
|
169
|
+
/**
|
|
170
|
+
* Raw records shadowed by an earlier duplicate id; written back verbatim in
|
|
171
|
+
* their own list so a freed id can never re-promote them to live jobs.
|
|
172
|
+
*/
|
|
173
|
+
private shadowed: unknown[] = [];
|
|
174
|
+
/** Records dropped at load instead of preserved verbatim — too deeply nested. */
|
|
175
|
+
private droppedCount = 0;
|
|
176
|
+
private jobsContainerInvalid = false;
|
|
177
|
+
private fileShapeInvalid = false;
|
|
178
|
+
/** Signature of the last skip set we warned about, so repeated locked loads stay quiet. */
|
|
179
|
+
private warnedSkips: string | undefined;
|
|
180
|
+
/** Ids the last locked load promoted into the live set; drained once the lock is gone. */
|
|
181
|
+
private pendingPromoted: string[] = [];
|
|
182
|
+
/**
|
|
183
|
+
* Called by load() while the store lock is held, with the ids that were live
|
|
184
|
+
* in the previous cache but are no longer live after an existing-file reload:
|
|
185
|
+
* the record no longer sanitizes into a live job (for example an
|
|
186
|
+
* unregistered agent type, an unparseable cron, a wrong-typed load-bearing
|
|
187
|
+
* field, or an invalid `scheduleType`), the `jobs` container is not an array
|
|
188
|
+
* (every live id is reclassified at once), or the record is no longer the
|
|
189
|
+
* file's live entry (deleted by another writer, or dropped for exceeding the
|
|
190
|
+
* depth bound). The scheduler binds this to clear timers that would
|
|
191
|
+
* otherwise keep ticking a record the live set no longer contains, so the
|
|
192
|
+
* callback must not write to the store (the lock is not re-entrant). Never
|
|
193
|
+
* called when load() keeps the previous state: a missing file (the kept
|
|
194
|
+
* in-memory state is re-written by the next save, so a mid-session deletion
|
|
195
|
+
* only sticks if the session ends before that write) or corrupt JSON.
|
|
196
|
+
*/
|
|
197
|
+
onReclassified: ((ids: string[]) => void) | undefined;
|
|
198
|
+
/**
|
|
199
|
+
* Called from withLock() after the mutation lock is released, with the ids
|
|
200
|
+
* that were not live in the previous cache but are live after the reload —
|
|
201
|
+
* records promoted out of `skipped` once their agent type is registered
|
|
202
|
+
* again, or records another writer added to the file. The scheduler binds
|
|
203
|
+
* this to arm enabled records that hold no timer. Never called when load()
|
|
204
|
+
* keeps the previous state (missing or corrupt file), and never while the
|
|
205
|
+
* lock is held, so the callback may safely mutate the store.
|
|
206
|
+
*/
|
|
207
|
+
onPromoted: ((ids: string[]) => void) | undefined;
|
|
61
208
|
|
|
62
209
|
constructor(filePath: string) {
|
|
63
210
|
this.filePath = filePath;
|
|
@@ -70,22 +217,123 @@ export class ScheduleStore {
|
|
|
70
217
|
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
71
218
|
}
|
|
72
219
|
|
|
73
|
-
/**
|
|
220
|
+
/**
|
|
221
|
+
* Load from disk into the in-memory cache. A stray record must not abort the
|
|
222
|
+
* scan (later valid entries would vanish and the next save erase them), so
|
|
223
|
+
* entries that fail validation are skipped and kept in `this.skipped` for
|
|
224
|
+
* the next save to write back. A record that duplicates a live id is kept
|
|
225
|
+
* verbatim in `this.shadowed` and is never promoted. Corrupt JSON keeps the
|
|
226
|
+
* current in-memory state rather than clearing it.
|
|
227
|
+
*/
|
|
74
228
|
private load(): void {
|
|
229
|
+
// A reload that keeps the previous state must not preserve an earlier
|
|
230
|
+
// promotion report: withLock drains pendingPromoted even when one of the
|
|
231
|
+
// kept-state early returns below fires, and records that were live before
|
|
232
|
+
// and after the reload are not promotions.
|
|
233
|
+
this.pendingPromoted = [];
|
|
75
234
|
if (!existsSync(this.filePath)) return;
|
|
235
|
+
let parsed: unknown;
|
|
76
236
|
try {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
237
|
+
parsed = JSON.parse(readFileSync(this.filePath, "utf-8"));
|
|
238
|
+
} catch {
|
|
239
|
+
return; // corrupt JSON — keep the current in-memory state
|
|
240
|
+
}
|
|
241
|
+
const previousLiveIds = [...this.jobs.keys()];
|
|
242
|
+
const data = isRecord(parsed) ? parsed : {};
|
|
243
|
+
const rawJobs = data.jobs;
|
|
244
|
+
const entries = Array.isArray(rawJobs) ? rawJobs : [];
|
|
245
|
+
const rawShadowed = data.shadowed;
|
|
246
|
+
const jobs = new Map<string, ScheduledSubagent>();
|
|
247
|
+
const skipped: unknown[] = [];
|
|
248
|
+
const shadowed: unknown[] = [];
|
|
249
|
+
let dropped = 0;
|
|
250
|
+
for (const raw of entries) {
|
|
251
|
+
const job = sanitizeJob(raw);
|
|
252
|
+
if (!job) {
|
|
253
|
+
if (!preserveRecord(raw, skipped)) dropped++;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (jobs.has(job.id)) {
|
|
257
|
+
// First record wins. The shadowed duplicate goes to its own list, so a
|
|
258
|
+
// later load can never promote it back to live even if the id is freed.
|
|
259
|
+
if (!preserveRecord(raw, shadowed)) dropped++;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
jobs.set(job.id, job);
|
|
263
|
+
}
|
|
264
|
+
// Shadowed records are never candidates for the live set — that is the
|
|
265
|
+
// point of the separate list.
|
|
266
|
+
for (const raw of Array.isArray(rawShadowed) ? rawShadowed : []) {
|
|
267
|
+
if (!preserveRecord(raw, shadowed)) dropped++;
|
|
268
|
+
}
|
|
269
|
+
this.jobs = jobs;
|
|
270
|
+
this.skipped = skipped;
|
|
271
|
+
this.shadowed = shadowed;
|
|
272
|
+
this.droppedCount = dropped;
|
|
273
|
+
this.jobsContainerInvalid = rawJobs !== undefined && !Array.isArray(rawJobs);
|
|
274
|
+
this.fileShapeInvalid = !isRecord(parsed);
|
|
275
|
+
this.warnAboutSkips();
|
|
276
|
+
// A record that left the live set must not keep a timer armed: the timer
|
|
277
|
+
// would keep firing a record the store no longer considers live, and a
|
|
278
|
+
// later load (e.g. after the agent type returns) would re-promote it.
|
|
279
|
+
const reclassified = previousLiveIds.filter(id => !jobs.has(id));
|
|
280
|
+
if (reclassified.length > 0) this.onReclassified?.(reclassified);
|
|
281
|
+
// Promotion is reported after the lock (see withLock): arming a promoted
|
|
282
|
+
// record can write to the store on the arm-guard branches, which cannot
|
|
283
|
+
// run while withLock holds the non-re-entrant lock.
|
|
284
|
+
const previousLive = new Set(previousLiveIds);
|
|
285
|
+
this.pendingPromoted = [...jobs.keys()].filter(id => !previousLive.has(id));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Warn once per distinct invalid-entry summary — load() runs before every mutation. */
|
|
289
|
+
private warnAboutSkips(): void {
|
|
290
|
+
const signature = [
|
|
291
|
+
this.skipped.map(skipSignature).join(","),
|
|
292
|
+
this.shadowed.map(skipSignature).join(","),
|
|
293
|
+
`dropped:${this.droppedCount}`,
|
|
294
|
+
`container:${this.jobsContainerInvalid}`,
|
|
295
|
+
`file:${this.fileShapeInvalid}`,
|
|
296
|
+
].join("|");
|
|
297
|
+
if (signature === this.warnedSkips) return;
|
|
298
|
+
this.warnedSkips = signature;
|
|
299
|
+
const details: string[] = [];
|
|
300
|
+
if (this.skipped.length > 0) details.push(`${this.skipped.length} invalid scheduled-job record(s) kept on disk`);
|
|
301
|
+
if (this.shadowed.length > 0) details.push(`${this.shadowed.length} shadowed duplicate record(s) retained verbatim on disk and discarded when their id is deleted`);
|
|
302
|
+
if (this.droppedCount > 0) details.push(`${this.droppedCount} scheduled-job record(s) dropped (too deeply nested)`);
|
|
303
|
+
if (this.jobsContainerInvalid) details.push("the jobs container is not an array and will be rewritten");
|
|
304
|
+
if (this.fileShapeInvalid) details.push("the file is not a versioned store object and will be rewritten");
|
|
305
|
+
if (details.length === 0) return;
|
|
306
|
+
console.warn(`[pi-subagents] ${this.filePath}: ${details.join("; ")}. Repair or remove invalid entries there.`);
|
|
81
307
|
}
|
|
82
308
|
|
|
83
309
|
/** Atomic write via temp file + rename (POSIX-atomic). */
|
|
84
310
|
private save(): void {
|
|
85
|
-
|
|
311
|
+
// Preserved entries are written back verbatim so an unrelated mutation
|
|
312
|
+
// cannot silently erase a record the user can still repair by hand.
|
|
313
|
+
// Shadowed duplicates live under their own key: never promoted, never armed.
|
|
314
|
+
const data: ScheduleStoreData = {
|
|
315
|
+
version: 1,
|
|
316
|
+
jobs: [...this.jobs.values(), ...(this.skipped as ScheduledSubagent[])],
|
|
317
|
+
shadowed: this.shadowed as ScheduledSubagent[],
|
|
318
|
+
};
|
|
319
|
+
let json: string;
|
|
320
|
+
try {
|
|
321
|
+
json = JSON.stringify(data, null, 2);
|
|
322
|
+
} catch (err) {
|
|
323
|
+
// Fail before touching the file: the previous contents stay intact and a
|
|
324
|
+
// clear error reaches the caller instead of a bare RangeError.
|
|
325
|
+
throw new Error(
|
|
326
|
+
`Failed to serialize schedule store ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
86
329
|
const tmp = this.filePath + ".tmp";
|
|
87
|
-
|
|
88
|
-
|
|
330
|
+
try {
|
|
331
|
+
writeFileSync(tmp, json);
|
|
332
|
+
renameSync(tmp, this.filePath);
|
|
333
|
+
} catch (err) {
|
|
334
|
+
try { unlinkSync(tmp); } catch { /* ignore */ }
|
|
335
|
+
throw err;
|
|
336
|
+
}
|
|
89
337
|
}
|
|
90
338
|
|
|
91
339
|
/** Acquire lock → reload → mutate → save → release. */
|
|
@@ -99,6 +347,27 @@ export class ScheduleStore {
|
|
|
99
347
|
return result;
|
|
100
348
|
} finally {
|
|
101
349
|
releaseLock(this.lockPath);
|
|
350
|
+
this.drainPromoted();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Report ids the last load promoted, now that the lock is gone. The mutation
|
|
356
|
+
* has either committed or already failed by the time this runs: it is called
|
|
357
|
+
* from withLock's `finally` after the lock is released, so a callback throw
|
|
358
|
+
* must not replace the mutation's own outcome. A callback failure aborts
|
|
359
|
+
* that report — the warning names the affected ids, and they are not
|
|
360
|
+
* re-reported; a later start() re-arms the live enabled records.
|
|
361
|
+
*/
|
|
362
|
+
private drainPromoted(): void {
|
|
363
|
+
const ids = this.pendingPromoted;
|
|
364
|
+
if (ids.length === 0) return;
|
|
365
|
+
this.pendingPromoted = [];
|
|
366
|
+
try {
|
|
367
|
+
this.onPromoted?.(ids);
|
|
368
|
+
} catch (err) {
|
|
369
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
370
|
+
console.warn(`[pi-subagents] ${this.filePath}: onPromoted callback failed for [${ids.join(", ")}]: ${message}`);
|
|
102
371
|
}
|
|
103
372
|
}
|
|
104
373
|
|
|
@@ -139,14 +408,32 @@ export class ScheduleStore {
|
|
|
139
408
|
}
|
|
140
409
|
|
|
141
410
|
remove(id: string): boolean {
|
|
142
|
-
// No-op fast path — see update().
|
|
143
|
-
|
|
144
|
-
|
|
411
|
+
// No-op fast path — see update(). A record can be live, or already
|
|
412
|
+
// reclassified into a preserved list by an earlier load, so check all
|
|
413
|
+
// three before deciding there is nothing to remove.
|
|
414
|
+
const sameId = (raw: unknown) => isRecord(raw) && raw.id === id;
|
|
415
|
+
const known = this.jobs.has(id) || this.skipped.some(sameId) || this.shadowed.some(sameId);
|
|
416
|
+
if (!known) return false;
|
|
417
|
+
return this.withLock(() => {
|
|
418
|
+
// The user deleted this id, so purge every top-level record whose id
|
|
419
|
+
// matches from every list: a record that load() reclassified
|
|
420
|
+
// mid-mutation (e.g. its agent type was invalidated) must not survive in
|
|
421
|
+
// `skipped` and get re-promoted once the type is back, and a shadowed
|
|
422
|
+
// duplicate must go with its live twin. A same-id record nested inside a
|
|
423
|
+
// preserved container is left verbatim — it is inert, because every load
|
|
424
|
+
// rejects the wrapper and sends it back to `skipped`.
|
|
425
|
+
const removedLive = this.jobs.delete(id);
|
|
426
|
+
const skippedBefore = this.skipped.length;
|
|
427
|
+
const shadowedBefore = this.shadowed.length;
|
|
428
|
+
this.skipped = this.skipped.filter(raw => !sameId(raw));
|
|
429
|
+
this.shadowed = this.shadowed.filter(raw => !sameId(raw));
|
|
430
|
+
return removedLive || this.skipped.length !== skippedBefore || this.shadowed.length !== shadowedBefore;
|
|
431
|
+
});
|
|
145
432
|
}
|
|
146
433
|
|
|
147
434
|
/** Delete the backing file (used when no jobs remain, optional cleanup). */
|
|
148
435
|
deleteFileIfEmpty(): void {
|
|
149
|
-
if (this.jobs.size === 0 && existsSync(this.filePath)) {
|
|
436
|
+
if (this.jobs.size === 0 && this.skipped.length === 0 && this.shadowed.length === 0 && existsSync(this.filePath)) {
|
|
150
437
|
try { unlinkSync(this.filePath); } catch { /* ignore */ }
|
|
151
438
|
}
|
|
152
439
|
}
|