omegon 0.10.3 → 0.10.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.
@@ -0,0 +1,324 @@
1
+ /**
2
+ * chronos — Pure TypeScript date/time context functions
3
+ *
4
+ * Replaces chronos.sh. All functions accept an injectable `now` for deterministic testing.
5
+ * Output format matches the original shell script exactly for backward compatibility.
6
+ */
7
+
8
+ // ── Helpers ──────────────────────────────────────────────────────────────────
9
+
10
+ const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] as const;
11
+ const MONTHS_SHORT = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
12
+
13
+ /** Format as YYYY-MM-DD */
14
+ function ymd(d: Date): string {
15
+ const y = d.getFullYear();
16
+ const m = String(d.getMonth() + 1).padStart(2, "0");
17
+ const day = String(d.getDate()).padStart(2, "0");
18
+ return `${y}-${m}-${day}`;
19
+ }
20
+
21
+ /** Day of week name */
22
+ function dowName(d: Date): string {
23
+ return DAYS[d.getDay()];
24
+ }
25
+
26
+ /** ISO day of week: 1=Monday … 7=Sunday */
27
+ function isoDow(d: Date): number {
28
+ return d.getDay() === 0 ? 7 : d.getDay();
29
+ }
30
+
31
+ /** Format as "Mon D" (e.g. "Mar 18") */
32
+ function formatShort(d: Date): string {
33
+ return `${MONTHS_SHORT[d.getMonth()]} ${d.getDate()}`;
34
+ }
35
+
36
+ /** Add N days to a date (returns new Date) */
37
+ function addDays(d: Date, n: number): Date {
38
+ const r = new Date(d);
39
+ r.setDate(r.getDate() + n);
40
+ return r;
41
+ }
42
+
43
+ /** Add N months to a date */
44
+ function addMonths(d: Date, n: number): Date {
45
+ const r = new Date(d);
46
+ r.setMonth(r.getMonth() + n);
47
+ return r;
48
+ }
49
+
50
+ /** First day of month */
51
+ function monthStart(year: number, month: number): Date {
52
+ return new Date(year, month, 1);
53
+ }
54
+
55
+ /** Last day of month */
56
+ function monthEnd(year: number, month: number): Date {
57
+ return new Date(year, month + 1, 0);
58
+ }
59
+
60
+ /** Build range string like "Mar 16 - Mar 20, 2026" handling year boundaries */
61
+ function weekRange(mon: Date, fri: Date): string {
62
+ const monY = mon.getFullYear();
63
+ const friY = fri.getFullYear();
64
+ if (monY === friY) {
65
+ return `${formatShort(mon)} - ${formatShort(fri)}, ${friY}`;
66
+ }
67
+ return `${formatShort(mon)}, ${monY} - ${formatShort(fri)}, ${friY}`;
68
+ }
69
+
70
+ // ── Subcommands ──────────────────────────────────────────────────────────────
71
+
72
+ export function computeWeek(now: Date = new Date()): string {
73
+ const dow = isoDow(now);
74
+ const daysSinceMon = dow - 1;
75
+ const currMon = addDays(now, -daysSinceMon);
76
+ const currFri = addDays(currMon, 4);
77
+ const prevMon = addDays(currMon, -7);
78
+ const prevFri = addDays(prevMon, 4);
79
+
80
+ return [
81
+ "DATE_CONTEXT:",
82
+ ` TODAY: ${ymd(now)} (${dowName(now)})`,
83
+ ` CURR_WEEK_START: ${ymd(currMon)} (Monday)`,
84
+ ` CURR_WEEK_END: ${ymd(currFri)} (Friday)`,
85
+ ` CURR_WEEK_RANGE: ${weekRange(currMon, currFri)}`,
86
+ ` PREV_WEEK_START: ${ymd(prevMon)} (Monday)`,
87
+ ` PREV_WEEK_END: ${ymd(prevFri)} (Friday)`,
88
+ ` PREV_WEEK_RANGE: ${weekRange(prevMon, prevFri)}`,
89
+ ].join("\n");
90
+ }
91
+
92
+ export function computeMonth(now: Date = new Date()): string {
93
+ const year = now.getFullYear();
94
+ const month = now.getMonth();
95
+
96
+ const currStart = monthStart(year, month);
97
+ const currEnd = monthEnd(year, month);
98
+
99
+ const prevMonth = month === 0 ? 11 : month - 1;
100
+ const prevYear = month === 0 ? year - 1 : year;
101
+ const prevStart = monthStart(prevYear, prevMonth);
102
+ const prevEnd = monthEnd(prevYear, prevMonth);
103
+
104
+ return [
105
+ "MONTH_CONTEXT:",
106
+ ` TODAY: ${ymd(now)} (${dowName(now)})`,
107
+ ` CURR_MONTH_START: ${ymd(currStart)}`,
108
+ ` CURR_MONTH_END: ${ymd(currEnd)}`,
109
+ ` CURR_MONTH_RANGE: ${formatShort(currStart)} - ${formatShort(currEnd)}, ${year}`,
110
+ ` PREV_MONTH_START: ${ymd(prevStart)}`,
111
+ ` PREV_MONTH_END: ${ymd(prevEnd)}`,
112
+ ` PREV_MONTH_RANGE: ${formatShort(prevStart)}, ${prevYear} - ${formatShort(prevEnd)}, ${prevEnd.getFullYear()}`,
113
+ ].join("\n");
114
+ }
115
+
116
+ export function computeQuarter(now: Date = new Date()): string {
117
+ const year = now.getFullYear();
118
+ const month = now.getMonth() + 1; // 1-based
119
+
120
+ const quarter = Math.ceil(month / 3);
121
+ const qStartMonth = (quarter - 1) * 3; // 0-based
122
+ const qStart = monthStart(year, qStartMonth);
123
+ const qEnd = monthEnd(year, qStartMonth + 2);
124
+
125
+ let fyYear: number, fyStart: string, fyEnd: string;
126
+ if (month >= 10) {
127
+ fyYear = year + 1;
128
+ fyStart = `${year}-10-01`;
129
+ fyEnd = `${fyYear}-09-30`;
130
+ } else {
131
+ fyYear = year;
132
+ fyStart = `${year - 1}-10-01`;
133
+ fyEnd = `${year}-09-30`;
134
+ }
135
+
136
+ const fyMonthOffset = month >= 10 ? month - 10 + 1 : month + 3;
137
+ const fq = Math.ceil(fyMonthOffset / 3);
138
+
139
+ return [
140
+ "QUARTER_CONTEXT:",
141
+ ` TODAY: ${ymd(now)} (${dowName(now)})`,
142
+ ` CALENDAR_QUARTER: Q${quarter} ${year}`,
143
+ ` QUARTER_START: ${ymd(qStart)}`,
144
+ ` QUARTER_END: ${ymd(qEnd)}`,
145
+ ` FISCAL_YEAR: FY${fyYear} (Oct-Sep)`,
146
+ ` FISCAL_QUARTER: FQ${fq}`,
147
+ ` FY_START: ${fyStart}`,
148
+ ` FY_END: ${fyEnd}`,
149
+ ].join("\n");
150
+ }
151
+
152
+ /** Resolve a relative date expression. Throws on unrecognized expressions. */
153
+ export function resolveRelative(expression: string, now: Date = new Date()): Date {
154
+ const expr = expression.trim().toLowerCase();
155
+
156
+ if (expr === "yesterday") return addDays(now, -1);
157
+ if (expr === "tomorrow") return addDays(now, 1);
158
+ if (expr === "today") return now;
159
+
160
+ // N days/weeks/months ago
161
+ const agoMatch = expr.match(/^(\d+)\s+(day|days|week|weeks|month|months)\s+ago$/);
162
+ if (agoMatch) {
163
+ const n = parseInt(agoMatch[1], 10);
164
+ const unit = agoMatch[2];
165
+ if (unit.startsWith("day")) return addDays(now, -n);
166
+ if (unit.startsWith("week")) return addDays(now, -n * 7);
167
+ if (unit.startsWith("month")) return addMonths(now, -n);
168
+ }
169
+
170
+ // N days/weeks from now / ahead / from today
171
+ const aheadMatch = expr.match(/^(\d+)\s+(day|days|week|weeks)\s+(from now|ahead|from today)$/);
172
+ if (aheadMatch) {
173
+ const n = parseInt(aheadMatch[1], 10);
174
+ const unit = aheadMatch[2];
175
+ if (unit.startsWith("day")) return addDays(now, n);
176
+ if (unit.startsWith("week")) return addDays(now, n * 7);
177
+ }
178
+
179
+ // next/last {weekday}
180
+ const dayNames = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
181
+ const dayMatch = expr.match(/^(next|last)\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)$/);
182
+ if (dayMatch) {
183
+ const direction = dayMatch[1];
184
+ const targetDow = dayNames.indexOf(dayMatch[2]);
185
+ const currentDow = now.getDay();
186
+
187
+ if (direction === "next") {
188
+ let diff = targetDow - currentDow;
189
+ if (diff <= 0) diff += 7;
190
+ return addDays(now, diff);
191
+ } else {
192
+ let diff = currentDow - targetDow;
193
+ if (diff <= 0) diff += 7;
194
+ return addDays(now, -diff);
195
+ }
196
+ }
197
+
198
+ throw new Error(`Cannot parse relative expression: '${expression}'. Supported: N days/weeks/months ago, N days/weeks from now, yesterday, tomorrow, next/last {weekday}.`);
199
+ }
200
+
201
+ export function computeRelative(expression: string, now: Date = new Date()): string {
202
+ const resolved = resolveRelative(expression, now);
203
+ return [
204
+ "RELATIVE_DATE:",
205
+ ` EXPRESSION: ${expression}`,
206
+ ` RESOLVED: ${ymd(resolved)} (${dowName(resolved)})`,
207
+ ` TODAY: ${ymd(now)} (${dowName(now)})`,
208
+ ].join("\n");
209
+ }
210
+
211
+ /** ISO 8601 week number (Thursday-based) */
212
+ function isoWeekNumber(d: Date): { week: number; year: number } {
213
+ const tmp = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
214
+ const dayNum = tmp.getUTCDay() || 7;
215
+ tmp.setUTCDate(tmp.getUTCDate() + 4 - dayNum);
216
+ const yearStart = new Date(Date.UTC(tmp.getUTCFullYear(), 0, 1));
217
+ const week = Math.ceil(((tmp.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
218
+ return { week, year: tmp.getUTCFullYear() };
219
+ }
220
+
221
+ /** Day of year (1-366) */
222
+ function dayOfYear(d: Date): number {
223
+ const start = new Date(d.getFullYear(), 0, 0);
224
+ const diff = d.getTime() - start.getTime();
225
+ return Math.floor(diff / 86400000);
226
+ }
227
+
228
+ export function computeIso(now: Date = new Date()): string {
229
+ const { week, year } = isoWeekNumber(now);
230
+ const wStr = String(week).padStart(2, "0");
231
+ const doy = String(dayOfYear(now)).padStart(3, "0");
232
+ const dow = isoDow(now);
233
+
234
+ return [
235
+ "ISO_CONTEXT:",
236
+ ` TODAY: ${ymd(now)} (${dowName(now)})`,
237
+ ` ISO_WEEK: W${wStr}`,
238
+ ` ISO_YEAR: ${year}`,
239
+ ` ISO_WEEKDATE: ${year}-W${wStr}-${dow}`,
240
+ ` DAY_OF_YEAR: ${doy}`,
241
+ ].join("\n");
242
+ }
243
+
244
+ export function computeEpoch(now: Date = new Date()): string {
245
+ const seconds = Math.floor(now.getTime() / 1000);
246
+ const millis = now.getTime();
247
+
248
+ return [
249
+ "EPOCH_CONTEXT:",
250
+ ` TODAY: ${ymd(now)} (${dowName(now)})`,
251
+ ` UNIX_SECONDS: ${seconds}`,
252
+ ` UNIX_MILLIS: ${millis}`,
253
+ ].join("\n");
254
+ }
255
+
256
+ export function computeTz(now: Date = new Date()): string {
257
+ const tzParts = now.toTimeString().match(/\((.+)\)/);
258
+ const tzAbbrev = tzParts
259
+ ? tzParts[1].replace(/[a-z ]/g, "") || tzParts[1]
260
+ : Intl.DateTimeFormat(undefined, { timeZoneName: "short" }).formatToParts(now).find(p => p.type === "timeZoneName")?.value || "Unknown";
261
+
262
+ const offsetMin = now.getTimezoneOffset();
263
+ const sign = offsetMin <= 0 ? "+" : "-";
264
+ const absMin = Math.abs(offsetMin);
265
+ const hh = String(Math.floor(absMin / 60)).padStart(2, "0");
266
+ const mm = String(absMin % 60).padStart(2, "0");
267
+ const utcOffset = `${sign}${hh}${mm}`;
268
+
269
+ return [
270
+ "TIMEZONE_CONTEXT:",
271
+ ` TODAY: ${ymd(now)} (${dowName(now)})`,
272
+ ` TIMEZONE: ${tzAbbrev}`,
273
+ ` UTC_OFFSET: ${utcOffset}`,
274
+ ].join("\n");
275
+ }
276
+
277
+ export function computeRange(fromDate: string, toDate: string): string {
278
+ const dateRe = /^\d{4}-\d{2}-\d{2}$/;
279
+ if (!dateRe.test(fromDate)) throw new Error(`Invalid date format '${fromDate}'. Use YYYY-MM-DD.`);
280
+ if (!dateRe.test(toDate)) throw new Error(`Invalid date format '${toDate}'. Use YYYY-MM-DD.`);
281
+
282
+ const d1 = new Date(fromDate + "T00:00:00");
283
+ const d2 = new Date(toDate + "T00:00:00");
284
+
285
+ if (isNaN(d1.getTime())) throw new Error(`Could not parse date: ${fromDate}`);
286
+ if (isNaN(d2.getTime())) throw new Error(`Could not parse date: ${toDate}`);
287
+
288
+ const diffMs = d2.getTime() - d1.getTime();
289
+ const calendarDays = Math.round(diffMs / 86400000);
290
+ const absDays = Math.abs(calendarDays);
291
+
292
+ let businessDays = 0;
293
+ const step = calendarDays >= 0 ? 1 : -1;
294
+ let cursor = new Date(d1);
295
+ for (let i = 0; i < absDays; i++) {
296
+ const dow = cursor.getDay();
297
+ if (dow >= 1 && dow <= 5) businessDays++;
298
+ cursor = addDays(cursor, step);
299
+ }
300
+
301
+ return [
302
+ "RANGE_CONTEXT:",
303
+ ` FROM: ${fromDate}`,
304
+ ` TO: ${toDate}`,
305
+ ` CALENDAR_DAYS: ${absDays}`,
306
+ ` BUSINESS_DAYS: ${businessDays}`,
307
+ ].join("\n");
308
+ }
309
+
310
+ export function computeAll(now: Date = new Date()): string {
311
+ return [
312
+ computeWeek(now),
313
+ "",
314
+ computeMonth(now),
315
+ "",
316
+ computeQuarter(now),
317
+ "",
318
+ computeIso(now),
319
+ "",
320
+ computeEpoch(now),
321
+ "",
322
+ computeTz(now),
323
+ ].join("\n");
324
+ }
@@ -1,35 +1,56 @@
1
1
  /**
2
2
  * chronos — Authoritative date and time context from system clock
3
3
  *
4
- * Registers a `chronos` tool that executes the chronos.sh script and returns
5
- * structured date context. Eliminates AI date calculation errors by providing
6
- * an authoritative source of truth from the system clock.
7
- *
8
- * Also registers a `/chronos` command for interactive use.
4
+ * Pure TypeScript implementation no shell dependencies.
5
+ * Registers a `chronos` tool and `/chronos` command.
9
6
  *
10
7
  * Subcommands: week (default), month, quarter, relative, iso, epoch, tz, range, all
11
8
  */
12
9
 
13
- import { existsSync } from "node:fs";
14
- import { join } from "node:path";
15
10
  import { StringEnum } from "../lib/typebox-helpers";
16
11
  import type { ExtensionAPI } from "@styrene-lab/pi-coding-agent";
17
12
  import { Type } from "@sinclair/typebox";
18
-
19
- const CHRONOS_SH = join(import.meta.dirname ?? __dirname, "chronos.sh");
13
+ import {
14
+ computeWeek,
15
+ computeMonth,
16
+ computeQuarter,
17
+ computeRelative,
18
+ computeIso,
19
+ computeEpoch,
20
+ computeTz,
21
+ computeRange,
22
+ computeAll,
23
+ } from "./chronos";
20
24
 
21
25
  const SUBCOMMANDS = ["week", "month", "quarter", "relative", "iso", "epoch", "tz", "range", "all"] as const;
22
26
 
23
- export default function chronosExtension(pi: ExtensionAPI) {
27
+ function executeChronos(params: { subcommand?: string; expression?: string; from_date?: string; to_date?: string }): string {
28
+ const sub = params.subcommand || "week";
24
29
 
25
- // Ensure the script exists and is executable
26
- if (!existsSync(CHRONOS_SH)) {
27
- // Fail silently at load — the tool will report the error at call time
30
+ switch (sub) {
31
+ case "week": return computeWeek();
32
+ case "month": return computeMonth();
33
+ case "quarter": return computeQuarter();
34
+ case "relative":
35
+ if (!params.expression) {
36
+ throw new Error("The 'relative' subcommand requires an 'expression' parameter (e.g. '3 days ago').");
37
+ }
38
+ return computeRelative(params.expression);
39
+ case "iso": return computeIso();
40
+ case "epoch": return computeEpoch();
41
+ case "tz": return computeTz();
42
+ case "range":
43
+ if (!params.from_date || !params.to_date) {
44
+ throw new Error("The 'range' subcommand requires both 'from_date' and 'to_date' (YYYY-MM-DD).");
45
+ }
46
+ return computeRange(params.from_date, params.to_date);
47
+ case "all": return computeAll();
48
+ default: throw new Error(`Unknown subcommand: ${sub}`);
28
49
  }
50
+ }
51
+
52
+ export default function chronosExtension(pi: ExtensionAPI) {
29
53
 
30
- // ------------------------------------------------------------------
31
- // chronos tool — callable by the LLM
32
- // ------------------------------------------------------------------
33
54
  pi.registerTool({
34
55
  name: "chronos",
35
56
  label: "Chronos",
@@ -68,45 +89,15 @@ export default function chronosExtension(pi: ExtensionAPI) {
68
89
  ),
69
90
  }),
70
91
 
71
- async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
72
- if (!existsSync(CHRONOS_SH)) {
73
- throw new Error(
74
- `chronos.sh not found at ${CHRONOS_SH}. ` +
75
- `Expected alongside the chronos skill.`
76
- );
77
- }
78
-
79
- const sub = params.subcommand || "week";
80
- const args = [CHRONOS_SH, sub];
81
-
82
- if (sub === "relative") {
83
- if (!params.expression) {
84
- throw new Error("The 'relative' subcommand requires an 'expression' parameter (e.g. '3 days ago').");
85
- }
86
- args.push(params.expression);
87
- } else if (sub === "range") {
88
- if (!params.from_date || !params.to_date) {
89
- throw new Error("The 'range' subcommand requires both 'from_date' and 'to_date' (YYYY-MM-DD).");
90
- }
91
- args.push(params.from_date, params.to_date);
92
- }
93
-
94
- const result = await pi.exec("bash", args, { signal, timeout: 10_000 });
95
-
96
- if (result.code !== 0) {
97
- throw new Error(`chronos.sh failed (exit ${result.code}):\n${result.stderr || result.stdout}`);
98
- }
99
-
92
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
93
+ const result = executeChronos(params);
100
94
  return {
101
- content: [{ type: "text", text: result.stdout.trim() }],
102
- details: { subcommand: sub },
95
+ content: [{ type: "text", text: result }],
96
+ details: { subcommand: params.subcommand || "week" },
103
97
  };
104
98
  },
105
99
  });
106
100
 
107
- // ------------------------------------------------------------------
108
- // /chronos command — interactive shortcut
109
- // ------------------------------------------------------------------
110
101
  pi.registerCommand("chronos", {
111
102
  description: "Show date/time context (usage: /chronos [week|month|quarter|iso|epoch|tz|all])",
112
103
  getArgumentCompletions: (prefix: string) => {
@@ -116,33 +107,21 @@ export default function chronosExtension(pi: ExtensionAPI) {
116
107
  },
117
108
  handler: async (args, _ctx) => {
118
109
  const sub = (args || "").trim() || "week";
119
-
120
- if (!existsSync(CHRONOS_SH)) {
110
+ try {
111
+ const result = executeChronos({ subcommand: sub });
121
112
  pi.sendMessage({
122
113
  customType: "view",
123
- content: `❌ chronos.sh not found at \`${CHRONOS_SH}\``,
114
+ content: `**Chronos**\n\n\`\`\`\n${result}\n\`\`\``,
124
115
  display: true,
125
116
  });
126
- return;
127
- }
128
-
129
- const cliArgs = [CHRONOS_SH, sub];
130
- const result = await pi.exec("bash", cliArgs, { timeout: 10_000 });
131
-
132
- if (result.code !== 0) {
117
+ } catch (err: unknown) {
118
+ const msg = err instanceof Error ? err.message : String(err);
133
119
  pi.sendMessage({
134
120
  customType: "view",
135
- content: `❌ chronos.sh failed:\n\`\`\`\n${result.stderr || result.stdout}\n\`\`\``,
121
+ content: `❌ ${msg}`,
136
122
  display: true,
137
123
  });
138
- return;
139
124
  }
140
-
141
- pi.sendMessage({
142
- customType: "view",
143
- content: `**Chronos**\n\n\`\`\`\n${result.stdout.trim()}\n\`\`\``,
144
- display: true,
145
- });
146
125
  },
147
126
  });
148
127
  }
@@ -41,6 +41,20 @@ import { registerCleaveProc, deregisterCleaveProc, killCleaveProc } from "./subp
41
41
  */
42
42
  export const LARGE_RUN_THRESHOLD = 4;
43
43
 
44
+ /**
45
+ * Default per-child wall-clock timeout (15 minutes).
46
+ * Hard backstop — children that exceed this are killed regardless of activity.
47
+ */
48
+ export const DEFAULT_CHILD_TIMEOUT_MS = 15 * 60 * 1000;
49
+
50
+ /**
51
+ * Default RPC idle timeout (3 minutes).
52
+ * If no RPC event arrives within this window, the child is considered stalled
53
+ * and is killed. Resets on every event (tool_start, tool_end, assistant_message,
54
+ * etc.). Only applies to RPC mode — pipe mode children use wall-clock only.
55
+ */
56
+ export const IDLE_TIMEOUT_MS = 3 * 60 * 1000;
57
+
44
58
  // ─── Explicit model resolution ──────────────────────────────────────────────
45
59
 
46
60
  /**
@@ -505,6 +519,7 @@ async function spawnChildRpc(
505
519
  signal?: AbortSignal,
506
520
  localModel?: string,
507
521
  onEvent?: (event: RpcChildEvent) => void,
522
+ idleTimeoutMs: number = IDLE_TIMEOUT_MS,
508
523
  ): Promise<RpcChildResult> {
509
524
  const omegon = resolveOmegonSubprocess();
510
525
  const args = [...omegon.argvPrefix, "--mode", "rpc", "--no-session"];
@@ -540,6 +555,25 @@ async function spawnChildRpc(
540
555
  // Collect stderr
541
556
  proc.stderr?.on("data", (data) => { stderr += data.toString(); });
542
557
 
558
+ // ── Idle timeout ─────────────────────────────────────────────────
559
+ // Reset on every RPC event. If no event arrives within the idle
560
+ // window, the child is stalled — kill it.
561
+ let idleKilled = false;
562
+ let idleTimer: ReturnType<typeof setTimeout> | undefined;
563
+ const resetIdleTimer = () => {
564
+ if (idleTimer) clearTimeout(idleTimer);
565
+ if (idleTimeoutMs > 0) {
566
+ idleTimer = setTimeout(() => {
567
+ if (!killed && !proc.killed) {
568
+ idleKilled = true;
569
+ killed = true;
570
+ killCleaveProc(proc);
571
+ scheduleEscalation();
572
+ }
573
+ }, idleTimeoutMs);
574
+ }
575
+ };
576
+
543
577
  // Parse stdout exclusively via RPC event stream (no competing data listener)
544
578
  let eventsFinished: Promise<void> = Promise.resolve();
545
579
  if (proc.stdout) {
@@ -547,6 +581,7 @@ async function spawnChildRpc(
547
581
  try {
548
582
  for await (const event of parseRpcEventStream(proc.stdout!)) {
549
583
  events.push(event);
584
+ resetIdleTimer(); // activity — push back the idle deadline
550
585
  if (event.type === "pipe_closed") {
551
586
  pipeBroken = true;
552
587
  }
@@ -572,6 +607,10 @@ async function spawnChildRpc(
572
607
  }, 5_000);
573
608
  };
574
609
 
610
+ // Start the idle timer now — if the child never emits an event, it's
611
+ // caught within the idle window rather than waiting for the full wall clock.
612
+ resetIdleTimer();
613
+
575
614
  const timer = setTimeout(() => {
576
615
  killed = true;
577
616
  killCleaveProc(proc);
@@ -592,6 +631,7 @@ async function spawnChildRpc(
592
631
  deregisterCleaveProc(proc);
593
632
  clearTimeout(timer);
594
633
  clearTimeout(escalationTimer);
634
+ clearTimeout(idleTimer);
595
635
  signal?.removeEventListener("abort", onAbort);
596
636
 
597
637
  // Close stdin if still open (child has exited)
@@ -600,10 +640,16 @@ async function spawnChildRpc(
600
640
  // Wait for all RPC events to be consumed before resolving
601
641
  await eventsFinished;
602
642
 
643
+ const killReason = idleKilled
644
+ ? `Killed (idle — no RPC events for ${Math.round(idleTimeoutMs / 1000)}s)\n${stderr}`
645
+ : killed
646
+ ? `Killed (timeout or abort)\n${stderr}`
647
+ : stderr;
648
+
603
649
  resolve({
604
650
  exitCode: killed ? -1 : (code ?? 1),
605
651
  stdout: "",
606
- stderr: killed ? `Killed (timeout or abort)\n${stderr}` : stderr,
652
+ stderr: killReason,
607
653
  events,
608
654
  pipeBroken,
609
655
  });
@@ -615,6 +661,7 @@ async function spawnChildRpc(
615
661
  deregisterCleaveProc(proc);
616
662
  clearTimeout(timer);
617
663
  clearTimeout(escalationTimer);
664
+ clearTimeout(idleTimer);
618
665
  signal?.removeEventListener("abort", onAbort);
619
666
  resolve({
620
667
  exitCode: 1,
@@ -51,7 +51,7 @@ import {
51
51
  } from "./assessment.ts";
52
52
  import { detectConflicts, parseTaskResult } from "./conflicts.ts";
53
53
  import { emitResolvedBugCandidate } from "./lifecycle-emitter.ts";
54
- import { dispatchChildren, resolveExecuteModel } from "./dispatcher.ts";
54
+ import { DEFAULT_CHILD_TIMEOUT_MS, dispatchChildren, resolveExecuteModel } from "./dispatcher.ts";
55
55
  import { DEFAULT_REVIEW_CONFIG, type ReviewConfig } from "./review.ts";
56
56
  import {
57
57
  detectOpenSpec,
@@ -2144,7 +2144,7 @@ export default function cleaveExtension(pi: ExtensionAPI) {
2144
2144
  pi,
2145
2145
  state,
2146
2146
  4, // maxParallel
2147
- 120 * 60 * 1000,
2147
+ DEFAULT_CHILD_TIMEOUT_MS,
2148
2148
  undefined,
2149
2149
  signal,
2150
2150
  (msg) => emit(msg),
@@ -2581,7 +2581,7 @@ export default function cleaveExtension(pi: ExtensionAPI) {
2581
2581
  pi,
2582
2582
  state,
2583
2583
  maxParallel,
2584
- 120 * 60 * 1000, // 2 hour timeout per child
2584
+ DEFAULT_CHILD_TIMEOUT_MS,
2585
2585
  localModel,
2586
2586
  signal ?? undefined,
2587
2587
  (msg) => {
@@ -9071,6 +9071,40 @@ export declare const MODELS: {
9071
9071
  contextWindow: number;
9072
9072
  maxTokens: number;
9073
9073
  };
9074
+ readonly "openai/gpt-5.4-mini": {
9075
+ id: string;
9076
+ name: string;
9077
+ api: "openai-completions";
9078
+ provider: string;
9079
+ baseUrl: string;
9080
+ reasoning: true;
9081
+ input: ("image" | "text")[];
9082
+ cost: {
9083
+ input: number;
9084
+ output: number;
9085
+ cacheRead: number;
9086
+ cacheWrite: number;
9087
+ };
9088
+ contextWindow: number;
9089
+ maxTokens: number;
9090
+ };
9091
+ readonly "openai/gpt-5.4-nano": {
9092
+ id: string;
9093
+ name: string;
9094
+ api: "openai-completions";
9095
+ provider: string;
9096
+ baseUrl: string;
9097
+ reasoning: true;
9098
+ input: ("image" | "text")[];
9099
+ cost: {
9100
+ input: number;
9101
+ output: number;
9102
+ cacheRead: number;
9103
+ cacheWrite: number;
9104
+ };
9105
+ contextWindow: number;
9106
+ maxTokens: number;
9107
+ };
9074
9108
  readonly "openai/gpt-5.4-pro": {
9075
9109
  id: string;
9076
9110
  name: string;
@@ -12439,6 +12473,23 @@ export declare const MODELS: {
12439
12473
  contextWindow: number;
12440
12474
  maxTokens: number;
12441
12475
  };
12476
+ readonly "openai/gpt-5.4-mini": {
12477
+ id: string;
12478
+ name: string;
12479
+ api: "anthropic-messages";
12480
+ provider: string;
12481
+ baseUrl: string;
12482
+ reasoning: true;
12483
+ input: ("image" | "text")[];
12484
+ cost: {
12485
+ input: number;
12486
+ output: number;
12487
+ cacheRead: number;
12488
+ cacheWrite: number;
12489
+ };
12490
+ contextWindow: number;
12491
+ maxTokens: number;
12492
+ };
12442
12493
  readonly "openai/gpt-5.4-pro": {
12443
12494
  id: string;
12444
12495
  name: string;