@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/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
+ }
@@ -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 type { ScheduledSubagent, ScheduleStoreData } from "./types.js";
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
- /** Load from disk into the in-memory cache. Silent on parse errors. */
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
- const data: ScheduleStoreData = JSON.parse(readFileSync(this.filePath, "utf-8"));
78
- this.jobs.clear();
79
- for (const j of data.jobs ?? []) this.jobs.set(j.id, j);
80
- } catch { /* corrupt — start fresh, next save rewrites */ }
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
- const data: ScheduleStoreData = { version: 1, jobs: [...this.jobs.values()] };
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
- writeFileSync(tmp, JSON.stringify(data, null, 2));
88
- renameSync(tmp, this.filePath);
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
- if (!this.jobs.has(id)) return false;
144
- return this.withLock(() => this.jobs.delete(id));
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
  }