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 +61 -15
- package/extensions/bootstrap/index.ts +12 -95
- package/extensions/chronos/chronos.ts +324 -0
- package/extensions/chronos/index.ts +47 -68
- package/extensions/cleave/dispatcher.ts +48 -1
- package/extensions/cleave/index.ts +3 -3
- package/package.json +1 -1
- package/extensions/chronos/chronos.sh +0 -487
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
|
-
//
|
|
142
|
-
//
|
|
139
|
+
// CLI launch — subprocess 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
|
-
|
|
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(
|
|
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
|
-
|
|
183
|
+
return () => {
|
|
174
184
|
clearInterval(spinTimer);
|
|
175
|
-
process.removeListener(
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
|
441
|
-
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
27
|
+
function executeChronos(params: { subcommand?: string; expression?: string; from_date?: string; to_date?: string }): string {
|
|
28
|
+
const sub = params.subcommand || "week";
|
|
24
29
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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,
|
|
72
|
-
|
|
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
|
|
102
|
-
details: { subcommand:
|
|
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
|
-
|
|
110
|
+
try {
|
|
111
|
+
const result = executeChronos({ subcommand: sub });
|
|
121
112
|
pi.sendMessage({
|
|
122
113
|
customType: "view",
|
|
123
|
-
content:
|
|
114
|
+
content: `**Chronos**\n\n\`\`\`\n${result}\n\`\`\``,
|
|
124
115
|
display: true,
|
|
125
116
|
});
|
|
126
|
-
|
|
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: `❌
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|