omegon 0.10.2 → 0.10.4

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/bin/omegon.mjs CHANGED
@@ -135,20 +135,31 @@ function purgeSelfReferentialPackages() {
135
135
  }
136
136
  purgeSelfReferentialPackages();
137
137
 
138
- process.argv = injectBundledResourceArgs(process.argv);
139
-
140
138
  // ---------------------------------------------------------------------------
141
- // Pre-import splashshow a simple loading indicator while the module graph
142
- // resolves. The TUI takes over once interactive mode starts.
139
+ // CLI launchsubprocess with restart-loop support.
140
+ //
141
+ // Instead of importing the CLI directly (which makes restart impossible since
142
+ // Node can't replace its own process image), we spawn it as a child process.
143
+ // If the child exits with code 75 (EX_TEMPFAIL), we re-spawn — this is the
144
+ // restart signal from /update and /restart commands.
145
+ //
146
+ // This keeps the wrapper as the foreground process group leader throughout,
147
+ // so the re-spawned CLI always owns the terminal and can receive input.
143
148
  // ---------------------------------------------------------------------------
149
+ import { spawn as nodeSpawn } from "node:child_process";
150
+
151
+ const RESTART_EXIT_CODE = 75;
152
+
153
+ const cliArgs = injectBundledResourceArgs(process.argv).slice(2);
154
+
144
155
  const isInteractive = process.stdout.isTTY &&
145
156
  !process.argv.includes("-p") &&
146
157
  !process.argv.includes("--print") &&
147
158
  !process.argv.includes("--help") &&
148
159
  !process.argv.includes("-h");
149
160
 
150
- let preImportCleanup;
151
- if (isInteractive) {
161
+ function showPreImportSpinner() {
162
+ if (!isInteractive) return undefined;
152
163
  const PRIMARY = "\x1b[38;2;42;180;200m";
153
164
  const DIM = "\x1b[38;2;64;88;112m";
154
165
  const RST = "\x1b[0m";
@@ -157,9 +168,8 @@ if (isInteractive) {
157
168
  const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
158
169
  let frame = 0;
159
170
 
160
- // Safety net: restore cursor on any exit path (crash, SIGTERM, etc.)
161
171
  const restoreCursor = () => { try { process.stdout.write(SHOW_CURSOR); } catch {} };
162
- process.on('exit', restoreCursor);
172
+ process.on("exit", restoreCursor);
163
173
 
164
174
  process.stdout.write(HIDE_CURSOR);
165
175
  process.stdout.write(`\n ${PRIMARY}omegon${RST} ${DIM}loading…${RST}`);
@@ -170,16 +180,52 @@ if (isInteractive) {
170
180
  frame++;
171
181
  }, 80);
172
182
 
173
- preImportCleanup = () => {
183
+ return () => {
174
184
  clearInterval(spinTimer);
175
- process.removeListener('exit', restoreCursor);
176
- // Clear the loading line and restore cursor
185
+ process.removeListener("exit", restoreCursor);
177
186
  process.stdout.write(`\r\x1b[2K${SHOW_CURSOR}`);
178
187
  };
179
188
  }
180
189
 
181
- try {
182
- await import(cli);
183
- } finally {
184
- preImportCleanup?.();
190
+ function launchCli() {
191
+ return new Promise((resolve) => {
192
+ const cleanup = showPreImportSpinner();
193
+
194
+ const child = nodeSpawn(process.execPath, [cli, ...cliArgs], {
195
+ stdio: "inherit",
196
+ env: process.env,
197
+ });
198
+
199
+ // Let the child handle SIGINT (Ctrl+C) — the wrapper ignores it.
200
+ const ignoreInt = () => {};
201
+ process.on("SIGINT", ignoreInt);
202
+ // Forward SIGTERM so graceful shutdown works.
203
+ const fwdTerm = () => child.kill("SIGTERM");
204
+ process.on("SIGTERM", fwdTerm);
205
+
206
+ // Clean up spinner once the child's TUI takes over. The child will
207
+ // clear the screen on startup anyway, but a brief delay ensures the
208
+ // spinner doesn't flicker.
209
+ if (cleanup) {
210
+ setTimeout(() => cleanup(), 200);
211
+ }
212
+
213
+ child.on("exit", (code, signal) => {
214
+ process.removeListener("SIGINT", ignoreInt);
215
+ process.removeListener("SIGTERM", fwdTerm);
216
+ if (signal) {
217
+ // Re-raise the signal so the wrapper exits with the right status
218
+ process.kill(process.pid, signal);
219
+ }
220
+ resolve(code ?? 1);
221
+ });
222
+ });
185
223
  }
224
+
225
+ // Main loop — restart on exit code 75
226
+ let exitCode;
227
+ do {
228
+ exitCode = await launchCli();
229
+ } while (exitCode === RESTART_EXIT_CODE);
230
+
231
+ process.exit(exitCode);
@@ -25,7 +25,7 @@ import { dirname, join } from "node:path";
25
25
  import { fileURLToPath } from "node:url";
26
26
  import { homedir, tmpdir } from "node:os";
27
27
  import type { ExtensionAPI } from "@styrene-lab/pi-coding-agent";
28
- import { resolveOmegonSubprocess } from "../lib/omegon-subprocess.ts";
28
+
29
29
  import { checkAllProviders, type AuthResult } from "../01-auth/auth.ts";
30
30
  import { loadPiConfig } from "../lib/model-preferences.ts";
31
31
  import {
@@ -436,101 +436,18 @@ export default function (pi: ExtensionAPI) {
436
436
  * exit and release the terminal), then exec's the new Omegon. This avoids
437
437
  * two TUI processes fighting over the same terminal simultaneously.
438
438
  */
439
+ /**
440
+ * Restart Omegon by exiting with code 75.
441
+ *
442
+ * The bin/omegon.mjs wrapper runs the CLI in a subprocess loop. When it sees
443
+ * exit code 75 (EX_TEMPFAIL), it re-spawns a fresh CLI process. Because the
444
+ * wrapper stays as the foreground process group leader throughout, the new
445
+ * CLI always owns the terminal and can receive input — no detached spawn,
446
+ * no competing with the shell for stdin.
447
+ */
439
448
  function restartOmegon(): never {
440
- const { command, argvPrefix } = resolveOmegonSubprocess();
441
- const userArgs = process.argv.slice(2).filter(a =>
442
- !a.startsWith("--extensions-dir=") &&
443
- !a.startsWith("--themes-dir=") &&
444
- !a.startsWith("--skills-dir=") &&
445
- !a.startsWith("--prompts-dir=") &&
446
- !a.startsWith("--extension=") && !a.startsWith("--extension ") &&
447
- !a.startsWith("--skill=") && !a.startsWith("--skill ") &&
448
- !a.startsWith("--prompt-template=") && !a.startsWith("--prompt-template ") &&
449
- !a.startsWith("--theme=") && !a.startsWith("--theme ") &&
450
- !a.startsWith("--no-skills") &&
451
- !a.startsWith("--no-prompt-templates") &&
452
- !a.startsWith("--no-themes") &&
453
- !a.startsWith("--no-extensions")
454
- );
455
-
456
- const parts = [command, ...argvPrefix, ...userArgs].map(shellEscape);
457
- const script = join(tmpdir(), `omegon-restart-${process.pid}.sh`);
458
- const oldPid = process.pid;
459
- writeFileSync(script, [
460
- "#!/bin/sh",
461
- // Trap signals so Ctrl+C works; ignore HUP so parent death doesn't kill us
462
- "trap 'stty sane 2>/dev/null; exit 130' INT TERM",
463
- "trap '' HUP",
464
- // Wait for the old process to fully die (poll with timeout)
465
- "_w=0",
466
- `while kill -0 ${oldPid} 2>/dev/null; do`,
467
- " sleep 0.1",
468
- " _w=$((_w + 1))",
469
- // Bail after ~5 seconds — proceed anyway
470
- ' [ "$_w" -ge 50 ] && break',
471
- "done",
472
- // Extra grace period for fd/terminal release
473
- "sleep 0.3",
474
- // Hard terminal reset via /dev/tty — avoids stdout buffering that
475
- // bleeds into the exec'd process. RIS clears all protocol state
476
- // (kitty keyboard protocol, bracketed paste, mouse tracking, etc.).
477
- // Do NOT use `reset` here — it outputs terminfo init strings to
478
- // stdout which the new TUI interprets as input (causing stray
479
- // characters and double renders).
480
- "printf '\\033c' >/dev/tty 2>/dev/null",
481
- "stty sane 2>/dev/null",
482
- // Clean up this script
483
- `rm -f "${script}"`,
484
- // Replace this shell with new omegon
485
- `exec ${parts.join(" ")}`,
486
- ].join("\n") + "\n", { mode: 0o755 });
487
-
488
- // Reset terminal to cooked mode BEFORE exiting so the restart script
489
- // (and the user) aren't stuck with raw-mode terminal if something goes wrong.
490
- try {
491
- // RIS (Reset to Initial State) — the only reliable way to ensure ALL
492
- // terminal protocol state is cleared. Write directly to the TTY fd
493
- // to bypass pi's TUI layer which intercepts process.stdout and would
494
- // mangle the escape sequence into visible ANSI garbage.
495
- const { openSync, writeSync, closeSync } = require("fs") as typeof import("fs");
496
- let ttyFd = -1;
497
- try {
498
- ttyFd = openSync("/dev/tty", "w");
499
- writeSync(ttyFd, "\x1bc");
500
- closeSync(ttyFd);
501
- } catch {
502
- // Fallback if /dev/tty isn't available (shouldn't happen on macOS/Linux)
503
- process.stdout.write("\x1bc");
504
- }
505
- // Pause stdin to prevent buffered input from being re-interpreted
506
- // after raw mode is disabled (prevents Ctrl+D from closing parent shell).
507
- process.stdin.pause();
508
- if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
509
- process.stdin.setRawMode(false);
510
- }
511
- // stty sane resets line discipline to known-good state.
512
- spawnSync("stty", ["sane"], { stdio: ["inherit", "ignore", "ignore"], timeout: 2000 });
513
- } catch { /* best-effort */ }
514
-
515
- // Spawn restart script detached (survives parent exit) but with SIGHUP
516
- // ignored in the script so process-group teardown doesn't kill it.
517
- // detached: true puts it in its own process group; the script's signal
518
- // traps ensure Ctrl+C still works once the new omegon exec's.
519
- const child = spawn("sh", [script], {
520
- stdio: "inherit",
521
- detached: true,
522
- env: process.env,
523
- });
524
- child.unref();
525
-
526
- // Clean exit — the script waits for us to die before starting new omegon
527
- process.exit(0);
528
- }
529
-
530
- /** Escape a string for POSIX shell */
531
- function shellEscape(s: string): string {
532
- if (/^[a-zA-Z0-9_./:=-]+$/.test(s)) return s;
533
- return `'${s.replace(/'/g, "'\\''")}'`;
449
+ const RESTART_EXIT_CODE = 75;
450
+ process.exit(RESTART_EXIT_CODE);
534
451
  }
535
452
 
536
453
  /** Run a command, collect stdout+stderr, resolve with exit code. */
@@ -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) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omegon",
3
- "version": "0.10.2",
3
+ "version": "0.10.4",
4
4
  "description": "Omegon — an opinionated distribution of pi (by Mario Zechner) with extensions for lifecycle management, memory, orchestration, and visualization",
5
5
  "bin": {
6
6
  "omegon": "bin/omegon.mjs",
@@ -1,487 +0,0 @@
1
- #!/bin/bash
2
- # chronos.sh - Comprehensive date and time context
3
- # Outputs structured date context to eliminate AI date calculation errors
4
- # Cross-platform: macOS (BSD date) and Linux (GNU date)
5
-
6
- set -uo pipefail
7
-
8
- # Detect date command flavor
9
- if date --version >/dev/null 2>&1; then
10
- DATE_FLAVOR="gnu"
11
- else
12
- DATE_FLAVOR="bsd"
13
- fi
14
-
15
- # Helper: add/subtract days from a date
16
- # Usage: date_add "2026-01-25" -3 -> outputs date 3 days earlier
17
- date_add() {
18
- local base_date=$1
19
- local days=$2
20
- if [[ "$DATE_FLAVOR" == "gnu" ]]; then
21
- date -d "$base_date $days days" "+%Y-%m-%d"
22
- else
23
- # BSD date: convert to epoch, add seconds, convert back
24
- local epoch=$(date -j -f "%Y-%m-%d" "$base_date" "+%s" 2>/dev/null)
25
- local new_epoch=$((epoch + days * 86400))
26
- date -r "$new_epoch" "+%Y-%m-%d"
27
- fi
28
- }
29
-
30
- # Helper: format date as "Mon D"
31
- format_short() {
32
- local ymd=$1
33
- local month=$(echo "$ymd" | cut -d'-' -f2)
34
- local day=$(echo "$ymd" | cut -d'-' -f3 | sed 's/^0//')
35
- case $month in
36
- 01) mon="Jan" ;; 02) mon="Feb" ;; 03) mon="Mar" ;;
37
- 04) mon="Apr" ;; 05) mon="May" ;; 06) mon="Jun" ;;
38
- 07) mon="Jul" ;; 08) mon="Aug" ;; 09) mon="Sep" ;;
39
- 10) mon="Oct" ;; 11) mon="Nov" ;; 12) mon="Dec" ;;
40
- esac
41
- echo "$mon $day"
42
- }
43
-
44
- # Helper: get epoch from date string
45
- date_to_epoch() {
46
- local ymd=$1
47
- if [[ "$DATE_FLAVOR" == "gnu" ]]; then
48
- date -d "$ymd" "+%s"
49
- else
50
- date -j -f "%Y-%m-%d" "$ymd" "+%s" 2>/dev/null
51
- fi
52
- }
53
-
54
- # Helper: resolve a relative date expression
55
- resolve_relative() {
56
- local expr=$1
57
- if [[ "$DATE_FLAVOR" == "gnu" ]]; then
58
- date -d "$expr" "+%Y-%m-%d" 2>/dev/null
59
- else
60
- # BSD: handle common expressions manually
61
- case "$expr" in
62
- *"days ago")
63
- local n=$(echo "$expr" | grep -oE '[0-9]+')
64
- date_add "$TODAY" "-$n"
65
- ;;
66
- *"days from now"|*"days ahead")
67
- local n=$(echo "$expr" | grep -oE '[0-9]+')
68
- date_add "$TODAY" "$n"
69
- ;;
70
- "yesterday")
71
- date_add "$TODAY" "-1"
72
- ;;
73
- "tomorrow")
74
- date_add "$TODAY" "1"
75
- ;;
76
- "next Monday"|"next monday")
77
- local days_ahead=$(( (8 - DOW_NUM) % 7 ))
78
- [[ $days_ahead -eq 0 ]] && days_ahead=7
79
- date_add "$TODAY" "$days_ahead"
80
- ;;
81
- "next Friday"|"next friday")
82
- local target=5
83
- local days_ahead=$(( (target - DOW_NUM + 7) % 7 ))
84
- [[ $days_ahead -eq 0 ]] && days_ahead=7
85
- date_add "$TODAY" "$days_ahead"
86
- ;;
87
- "last Monday"|"last monday")
88
- local days_back=$(( (DOW_NUM - 1 + 7) % 7 ))
89
- [[ $days_back -eq 0 ]] && days_back=7
90
- date_add "$TODAY" "-$days_back"
91
- ;;
92
- "last Friday"|"last friday")
93
- local target=5
94
- local days_back=$(( (DOW_NUM - target + 7) % 7 ))
95
- [[ $days_back -eq 0 ]] && days_back=7
96
- date_add "$TODAY" "-$days_back"
97
- ;;
98
- *"weeks ago")
99
- local n=$(echo "$expr" | grep -oE '[0-9]+')
100
- date_add "$TODAY" "-$((n * 7))"
101
- ;;
102
- *"months ago")
103
- local n=$(echo "$expr" | grep -oE '[0-9]+')
104
- local y=$(echo "$TODAY" | cut -d'-' -f1)
105
- local m=$(echo "$TODAY" | cut -d'-' -f2 | sed 's/^0//')
106
- local d=$(echo "$TODAY" | cut -d'-' -f3)
107
- m=$((m - n))
108
- while [[ $m -le 0 ]]; do
109
- m=$((m + 12))
110
- y=$((y - 1))
111
- done
112
- printf "%04d-%02d-%s" "$y" "$m" "$d"
113
- ;;
114
- *)
115
- echo "ERROR: Cannot parse '$expr' on BSD date. Use GNU date for complex expressions." >&2
116
- return 1
117
- ;;
118
- esac
119
- fi
120
- }
121
-
122
- # Get today's info
123
- TODAY=$(date "+%Y-%m-%d")
124
- TODAY_DOW=$(date "+%A")
125
- DOW_NUM=$(date "+%u") # 1=Monday, 7=Sunday
126
-
127
- # ---- Subcommand functions ----
128
-
129
- compute_week() {
130
- DAYS_SINCE_MON=$((DOW_NUM - 1))
131
- CURR_MON=$(date_add "$TODAY" "-$DAYS_SINCE_MON")
132
- CURR_FRI=$(date_add "$CURR_MON" "4")
133
-
134
- PREV_MON=$(date_add "$CURR_MON" "-7")
135
- PREV_FRI=$(date_add "$PREV_MON" "4")
136
-
137
- # Extract years for range formatting
138
- CURR_MON_YEAR=$(echo "$CURR_MON" | cut -d'-' -f1)
139
- CURR_FRI_YEAR=$(echo "$CURR_FRI" | cut -d'-' -f1)
140
- PREV_MON_YEAR=$(echo "$PREV_MON" | cut -d'-' -f1)
141
- PREV_FRI_YEAR=$(echo "$PREV_FRI" | cut -d'-' -f1)
142
-
143
- # Format short dates
144
- CURR_MON_FMT=$(format_short "$CURR_MON")
145
- CURR_FRI_FMT=$(format_short "$CURR_FRI")
146
- PREV_MON_FMT=$(format_short "$PREV_MON")
147
- PREV_FRI_FMT=$(format_short "$PREV_FRI")
148
-
149
- # Build range strings (handle year boundaries)
150
- if [ "$CURR_MON_YEAR" = "$CURR_FRI_YEAR" ]; then
151
- CURR_WEEK_RANGE="$CURR_MON_FMT - $CURR_FRI_FMT, $CURR_FRI_YEAR"
152
- else
153
- CURR_WEEK_RANGE="$CURR_MON_FMT, $CURR_MON_YEAR - $CURR_FRI_FMT, $CURR_FRI_YEAR"
154
- fi
155
-
156
- if [ "$PREV_MON_YEAR" = "$PREV_FRI_YEAR" ]; then
157
- PREV_WEEK_RANGE="$PREV_MON_FMT - $PREV_FRI_FMT, $PREV_FRI_YEAR"
158
- else
159
- PREV_WEEK_RANGE="$PREV_MON_FMT, $PREV_MON_YEAR - $PREV_FRI_FMT, $PREV_FRI_YEAR"
160
- fi
161
-
162
- echo "DATE_CONTEXT:"
163
- echo " TODAY: $TODAY ($TODAY_DOW)"
164
- echo " CURR_WEEK_START: $CURR_MON (Monday)"
165
- echo " CURR_WEEK_END: $CURR_FRI (Friday)"
166
- echo " CURR_WEEK_RANGE: $CURR_WEEK_RANGE"
167
- echo " PREV_WEEK_START: $PREV_MON (Monday)"
168
- echo " PREV_WEEK_END: $PREV_FRI (Friday)"
169
- echo " PREV_WEEK_RANGE: $PREV_WEEK_RANGE"
170
- }
171
-
172
- compute_month() {
173
- local year=$(echo "$TODAY" | cut -d'-' -f1)
174
- local month=$(echo "$TODAY" | cut -d'-' -f2 | sed 's/^0//')
175
-
176
- # Current month boundaries
177
- local curr_month_start
178
- curr_month_start=$(printf "%04d-%02d-01" "$year" "$month")
179
-
180
- # Next month first day, then subtract 1 day for end of current month
181
- local next_month=$((month + 1))
182
- local next_year=$year
183
- if [[ $next_month -gt 12 ]]; then
184
- next_month=1
185
- next_year=$((year + 1))
186
- fi
187
- local next_month_start
188
- next_month_start=$(printf "%04d-%02d-01" "$next_year" "$next_month")
189
- local curr_month_end
190
- curr_month_end=$(date_add "$next_month_start" "-1")
191
-
192
- # Previous month boundaries
193
- local prev_month=$((month - 1))
194
- local prev_year=$year
195
- if [[ $prev_month -lt 1 ]]; then
196
- prev_month=12
197
- prev_year=$((year - 1))
198
- fi
199
- local prev_month_start
200
- prev_month_start=$(printf "%04d-%02d-01" "$prev_year" "$prev_month")
201
- local prev_month_end
202
- prev_month_end=$(date_add "$curr_month_start" "-1")
203
-
204
- local curr_month_fmt
205
- curr_month_fmt=$(format_short "$curr_month_start")
206
- local curr_month_end_fmt
207
- curr_month_end_fmt=$(format_short "$curr_month_end")
208
- local prev_month_fmt
209
- prev_month_fmt=$(format_short "$prev_month_start")
210
- local prev_month_end_fmt
211
- prev_month_end_fmt=$(format_short "$prev_month_end")
212
-
213
- echo "MONTH_CONTEXT:"
214
- echo " TODAY: $TODAY ($TODAY_DOW)"
215
- echo " CURR_MONTH_START: $curr_month_start"
216
- echo " CURR_MONTH_END: $curr_month_end"
217
- echo " CURR_MONTH_RANGE: $curr_month_fmt - $curr_month_end_fmt, $year"
218
- echo " PREV_MONTH_START: $prev_month_start"
219
- echo " PREV_MONTH_END: $prev_month_end"
220
- echo " PREV_MONTH_RANGE: $prev_month_fmt, $prev_year - $prev_month_end_fmt, $year"
221
- }
222
-
223
- compute_quarter() {
224
- local year=$(echo "$TODAY" | cut -d'-' -f1)
225
- local month=$(echo "$TODAY" | cut -d'-' -f2 | sed 's/^0//')
226
-
227
- # Calendar quarter (Q1=Jan-Mar, Q2=Apr-Jun, Q3=Jul-Sep, Q4=Oct-Dec)
228
- local quarter=$(( (month - 1) / 3 + 1 ))
229
- local q_start_month=$(( (quarter - 1) * 3 + 1 ))
230
- local q_end_month=$(( quarter * 3 ))
231
- local q_start
232
- q_start=$(printf "%04d-%02d-01" "$year" "$q_start_month")
233
-
234
- # Quarter end: first day of next quarter minus 1
235
- local next_q_month=$(( q_end_month + 1 ))
236
- local next_q_year=$year
237
- if [[ $next_q_month -gt 12 ]]; then
238
- next_q_month=1
239
- next_q_year=$((year + 1))
240
- fi
241
- local next_q_start
242
- next_q_start=$(printf "%04d-%02d-01" "$next_q_year" "$next_q_month")
243
- local q_end
244
- q_end=$(date_add "$next_q_start" "-1")
245
-
246
- # Fiscal year (Oct-Sep): FY starts in October of previous calendar year
247
- local fy_year
248
- local fy_start fy_end
249
- if [[ $month -ge 10 ]]; then
250
- fy_year=$((year + 1))
251
- fy_start="$year-10-01"
252
- fy_end="$fy_year-09-30"
253
- else
254
- fy_year=$year
255
- fy_start="$((year - 1))-10-01"
256
- fy_end="$year-09-30"
257
- fi
258
-
259
- # Fiscal quarter
260
- local fy_month_offset
261
- if [[ $month -ge 10 ]]; then
262
- fy_month_offset=$((month - 10 + 1))
263
- else
264
- fy_month_offset=$((month + 3))
265
- fi
266
- local fq=$(( (fy_month_offset - 1) / 3 + 1 ))
267
-
268
- echo "QUARTER_CONTEXT:"
269
- echo " TODAY: $TODAY ($TODAY_DOW)"
270
- echo " CALENDAR_QUARTER: Q$quarter $year"
271
- echo " QUARTER_START: $q_start"
272
- echo " QUARTER_END: $q_end"
273
- echo " FISCAL_YEAR: FY$fy_year (Oct-Sep)"
274
- echo " FISCAL_QUARTER: FQ$fq"
275
- echo " FY_START: $fy_start"
276
- echo " FY_END: $fy_end"
277
- }
278
-
279
- compute_relative() {
280
- local expr=$1
281
- local resolved
282
- resolved=$(resolve_relative "$expr")
283
- if [[ $? -ne 0 || -z "$resolved" ]]; then
284
- echo "RELATIVE_DATE:"
285
- echo " ERROR: Could not resolve '$expr'"
286
- return 1
287
- fi
288
-
289
- local resolved_dow
290
- if [[ "$DATE_FLAVOR" == "gnu" ]]; then
291
- resolved_dow=$(date -d "$resolved" "+%A")
292
- else
293
- local epoch=$(date_to_epoch "$resolved")
294
- resolved_dow=$(date -r "$epoch" "+%A")
295
- fi
296
-
297
- echo "RELATIVE_DATE:"
298
- echo " EXPRESSION: $expr"
299
- echo " RESOLVED: $resolved ($resolved_dow)"
300
- echo " TODAY: $TODAY ($TODAY_DOW)"
301
- }
302
-
303
- compute_iso() {
304
- local iso_week iso_year day_of_year
305
- if [[ "$DATE_FLAVOR" == "gnu" ]]; then
306
- iso_week=$(date -d "$TODAY" "+%V")
307
- iso_year=$(date -d "$TODAY" "+%G")
308
- day_of_year=$(date -d "$TODAY" "+%j")
309
- else
310
- iso_week=$(date -j -f "%Y-%m-%d" "$TODAY" "+%V" 2>/dev/null)
311
- iso_year=$(date -j -f "%Y-%m-%d" "$TODAY" "+%G" 2>/dev/null || echo "$TODAY" | cut -d'-' -f1)
312
- day_of_year=$(date -j -f "%Y-%m-%d" "$TODAY" "+%j" 2>/dev/null)
313
- fi
314
-
315
- echo "ISO_CONTEXT:"
316
- echo " TODAY: $TODAY ($TODAY_DOW)"
317
- echo " ISO_WEEK: W$iso_week"
318
- echo " ISO_YEAR: $iso_year"
319
- echo " ISO_WEEKDATE: $iso_year-W$iso_week-$DOW_NUM"
320
- echo " DAY_OF_YEAR: $day_of_year"
321
- }
322
-
323
- compute_epoch() {
324
- local epoch
325
- if [[ "$DATE_FLAVOR" == "gnu" ]]; then
326
- epoch=$(date "+%s")
327
- else
328
- epoch=$(date "+%s")
329
- fi
330
- local millis="${epoch}000"
331
-
332
- echo "EPOCH_CONTEXT:"
333
- echo " TODAY: $TODAY ($TODAY_DOW)"
334
- echo " UNIX_SECONDS: $epoch"
335
- echo " UNIX_MILLIS: $millis"
336
- }
337
-
338
- compute_tz() {
339
- local tz_abbrev tz_offset
340
- tz_abbrev=$(date "+%Z")
341
- tz_offset=$(date "+%z")
342
-
343
- echo "TIMEZONE_CONTEXT:"
344
- echo " TODAY: $TODAY ($TODAY_DOW)"
345
- echo " TIMEZONE: $tz_abbrev"
346
- echo " UTC_OFFSET: $tz_offset"
347
- }
348
-
349
- compute_range() {
350
- local date1=$1
351
- local date2=$2
352
-
353
- # Validate date formats
354
- if ! echo "$date1" | grep -qE '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'; then
355
- echo "RANGE_ERROR: Invalid date format '$date1'. Use YYYY-MM-DD." >&2
356
- return 1
357
- fi
358
- if ! echo "$date2" | grep -qE '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'; then
359
- echo "RANGE_ERROR: Invalid date format '$date2'. Use YYYY-MM-DD." >&2
360
- return 1
361
- fi
362
-
363
- local epoch1 epoch2
364
- epoch1=$(date_to_epoch "$date1")
365
- epoch2=$(date_to_epoch "$date2")
366
-
367
- if [[ -z "$epoch1" || -z "$epoch2" ]]; then
368
- echo "RANGE_ERROR: Could not parse dates." >&2
369
- return 1
370
- fi
371
-
372
- local diff_seconds=$((epoch2 - epoch1))
373
- local calendar_days=$((diff_seconds / 86400))
374
-
375
- # Count business days (Mon-Fri)
376
- local business_days=0
377
- local current="$date1"
378
- local step=1
379
- if [[ $calendar_days -lt 0 ]]; then
380
- step=-1
381
- calendar_days=$(( -calendar_days ))
382
- fi
383
-
384
- local i=0
385
- while [[ $i -lt $calendar_days ]]; do
386
- local dow
387
- if [[ "$DATE_FLAVOR" == "gnu" ]]; then
388
- dow=$(date -d "$current" "+%u")
389
- else
390
- local e=$(date_to_epoch "$current")
391
- dow=$(date -r "$e" "+%u")
392
- fi
393
- # Count if weekday (Mon=1 through Fri=5)
394
- if [[ $dow -ge 1 && $dow -le 5 ]]; then
395
- business_days=$((business_days + 1))
396
- fi
397
- current=$(date_add "$current" "$step")
398
- i=$((i + 1))
399
- done
400
-
401
- echo "RANGE_CONTEXT:"
402
- echo " FROM: $date1"
403
- echo " TO: $date2"
404
- echo " CALENDAR_DAYS: $calendar_days"
405
- echo " BUSINESS_DAYS: $business_days"
406
- }
407
-
408
- usage() {
409
- echo "Usage: chronos.sh [SUBCOMMAND] [ARGS]"
410
- echo ""
411
- echo "Subcommands:"
412
- echo " (none), week Week boundaries (DATE_CONTEXT block)"
413
- echo " month Current/previous month boundaries"
414
- echo " quarter Calendar quarter, fiscal year (Oct-Sep)"
415
- echo " relative \"expr\" Resolve relative date expression"
416
- echo " iso ISO week number, year, day-of-year"
417
- echo " epoch Unix timestamp (seconds and milliseconds)"
418
- echo " tz Timezone abbreviation and UTC offset"
419
- echo " range D1 D2 Calendar and business days between dates"
420
- echo " all All of the above combined"
421
- echo ""
422
- echo "Examples:"
423
- echo " chronos.sh # Week context (default)"
424
- echo " chronos.sh month # Month boundaries"
425
- echo " chronos.sh quarter # Quarter and fiscal year"
426
- echo " chronos.sh relative \"3 days ago\" # Resolve expression"
427
- echo " chronos.sh range 2026-01-01 2026-02-01"
428
- echo " chronos.sh all # Everything"
429
- }
430
-
431
- # ---- Main dispatch ----
432
-
433
- case "${1:-week}" in
434
- -h|--help)
435
- usage
436
- ;;
437
- week|"")
438
- compute_week
439
- ;;
440
- month)
441
- compute_month
442
- ;;
443
- quarter)
444
- compute_quarter
445
- ;;
446
- relative)
447
- if [[ -z "${2:-}" ]]; then
448
- echo "Usage: chronos.sh relative \"expression\"" >&2
449
- exit 1
450
- fi
451
- compute_relative "$2"
452
- ;;
453
- iso)
454
- compute_iso
455
- ;;
456
- epoch)
457
- compute_epoch
458
- ;;
459
- tz)
460
- compute_tz
461
- ;;
462
- range)
463
- if [[ -z "${2:-}" || -z "${3:-}" ]]; then
464
- echo "Usage: chronos.sh range YYYY-MM-DD YYYY-MM-DD" >&2
465
- exit 1
466
- fi
467
- compute_range "$2" "$3"
468
- ;;
469
- all)
470
- compute_week
471
- echo ""
472
- compute_month
473
- echo ""
474
- compute_quarter
475
- echo ""
476
- compute_iso
477
- echo ""
478
- compute_epoch
479
- echo ""
480
- compute_tz
481
- ;;
482
- *)
483
- echo "Unknown subcommand: $1" >&2
484
- usage >&2
485
- exit 1
486
- ;;
487
- esac