pi-mox 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +104 -2
- package/extensions/mox.ts +8 -0
- package/package.json +54 -4
- package/src/mox.ts +313 -0
- package/src/wire.ts +70 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Each release of `pi-mox`. Versions follow [semantic versioning](https://semver.org); before 1.0.0 a minor
|
|
4
|
+
version may add commands and options and a patch version fixes.
|
|
5
|
+
|
|
6
|
+
## Unreleased
|
|
7
|
+
|
|
8
|
+
## 0.1.0
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `/mox <time> <message>` schedules a message for the agent. At that time the message is delivered as a
|
|
13
|
+
follow-up (after the current turn, or at once when Pi is idle) and starts a turn.
|
|
14
|
+
- Absolute times `10:30` (24-hour; today, or tomorrow when the time is not ahead) and relative times
|
|
15
|
+
`+2h15m`, `+90m`, `+45s`.
|
|
16
|
+
- `/mox list` (or bare `/mox`) shows the pending cues; `/mox cancel <id>` cancels one by Roman or Arabic id.
|
|
17
|
+
Several cues can wait at once, and ids are never reused within a session.
|
|
18
|
+
- Footer `·MOX· 10:30 · II h VI m` for the next cue, with `(+N)` when more wait.
|
|
19
|
+
- The delivered message is shown as `·MOX· 10:30 — <message>`.
|
|
20
|
+
- Cues live in memory only: closing Pi drops them. Each fires once and is not retried.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 jals1212
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,105 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-mox
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Schedule a message for the agent in [Pi](https://github.com/earendil-works/pi). Say when, say what, and at
|
|
4
|
+
that time the message reaches the agent and starts a turn. *Mox* is Latin for "soon".
|
|
5
|
+
|
|
6
|
+
Typical use: the session's quota runs out and says it refreshes at 10:30, but it is 8:24. Schedule the
|
|
7
|
+
next step once and walk away:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
/mox 10:30 continúa con T3
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
pi install npm:pi-mox
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
| Command | What it does |
|
|
22
|
+
| --- | --- |
|
|
23
|
+
| `/mox <time> <message>` | Deliver `<message>` to the agent at `<time>` |
|
|
24
|
+
| `/mox list` (or `/mox`) | Show the pending cues |
|
|
25
|
+
| `/mox cancel <id>` | Cancel a cue; the id is Roman (`II`, `ii`) or Arabic (`2`) |
|
|
26
|
+
|
|
27
|
+
`list` and `cancel` are reserved first words.
|
|
28
|
+
|
|
29
|
+
### Time
|
|
30
|
+
|
|
31
|
+
- Absolute: `10:30` or `9:05`, 24-hour (`0:00`–`23:59`). Today at that time; if it is equal to or before now,
|
|
32
|
+
tomorrow. `24:00`, `10:60` and `1030` are rejected.
|
|
33
|
+
- Relative: `+` followed by `<n>h`, `<n>m`, `<n>s` in that order: `+2h15m`, `+90m`, `+45s`, `+1h30s`.
|
|
34
|
+
The total must be above zero.
|
|
35
|
+
|
|
36
|
+
The message is the rest of the line, trimmed and delivered verbatim.
|
|
37
|
+
|
|
38
|
+
### Examples
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
/mox 10:30 continúa con T3
|
|
42
|
+
Scheduled I for 10:30 (in 2h06m): continúa con T3
|
|
43
|
+
|
|
44
|
+
/mox +5h36m corre la suite
|
|
45
|
+
Scheduled II for 14:00 (in 5h36m): corre la suite
|
|
46
|
+
|
|
47
|
+
/mox list
|
|
48
|
+
I 10:30 2h06m continúa con T3
|
|
49
|
+
II 14:00 5h36m corre la suite
|
|
50
|
+
|
|
51
|
+
/mox cancel II
|
|
52
|
+
Cancelled II (14:00): corre la suite
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The footer shows the next cue, with the time left in Roman numerals (hours omitted at zero, minutes rounded
|
|
56
|
+
up, under a minute shows `I m`), and `(+N)` when more cues wait:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
·MOX· 10:30 · II h VI m (+1)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
When a cue is due, the agent receives the message and the conversation shows:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
·MOX· 10:30 — continúa con T3
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Errors (bad time, missing message, unknown id) are shown with a usage line and change nothing.
|
|
69
|
+
|
|
70
|
+
## Behavior
|
|
71
|
+
|
|
72
|
+
- **Delivery.** The message is sent as a follow-up that triggers a turn: when Pi is idle a turn starts at
|
|
73
|
+
once; when Pi is busy the message waits for the current run to finish.
|
|
74
|
+
- **One shot.** A cue fires once and is removed. If delivery fails the error is shown like any other error;
|
|
75
|
+
there is no retry.
|
|
76
|
+
- **In memory.** Cues are kept only while Pi runs. Closing Pi (or ending the session) drops them.
|
|
77
|
+
- **Wall clock.** The next cue is checked against the clock at least every 30 seconds, so a cue that came due
|
|
78
|
+
while the computer was suspended is delivered as soon as it resumes. Timers never keep Pi from exiting.
|
|
79
|
+
- **Ids.** Roman numerals counting up from `I` in each session; an id is never reused, even after a cue is
|
|
80
|
+
cancelled or fired.
|
|
81
|
+
|
|
82
|
+
## Development
|
|
83
|
+
|
|
84
|
+
Requires Node.js 22.18 or newer (the sources are TypeScript run with type stripping; there is no build).
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
npm test
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
- `src/mox.ts`: core with no Pi runtime imports: time parsing, Roman numerals, the cue list and scheduler
|
|
91
|
+
(injectable clock and timers), the command runner.
|
|
92
|
+
- `src/wire.ts`: connects the core to Pi's extension API.
|
|
93
|
+
- `extensions/mox.ts`: the package's Pi extension entry.
|
|
94
|
+
|
|
95
|
+
## Releasing
|
|
96
|
+
|
|
97
|
+
Branches: work goes to `develop` through pull requests; `main` receives `develop` when a release closes.
|
|
98
|
+
|
|
99
|
+
1. On a `release/X.Y.Z` branch from `develop`: set `version` in `package.json`, rename `## Unreleased` in `CHANGELOG.md` to `## X.Y.Z`, and commit `release X.Y.Z: <summary>`. Merge it into `develop`, then `develop` into `main`.
|
|
100
|
+
2. Tag `main` with `vX.Y.Z` and push the tag.
|
|
101
|
+
3. The [release workflow](.github/workflows/release.yml) checks the tag against `package.json` and `CHANGELOG.md`, runs the tests, and stages the version on npm through trusted publishing, with provenance. Approve it with 2FA: `npm stage list`, then `npm stage approve <id>`, or on npmjs.com. It then creates the GitHub release with the version's CHANGELOG section as notes.
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// pi-mox: schedule a message for the agent. Adds /mox, the ·MOX· footer and the delivered-message renderer.
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
4
|
+
import { registerMox } from "../src/wire.ts";
|
|
5
|
+
|
|
6
|
+
export default function (pi: ExtensionAPI) {
|
|
7
|
+
registerMox(pi, { Text });
|
|
8
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-mox",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Schedule a message for the agent in Pi: /mox 10:30 <message> delivers it at that time and starts a turn.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "jals1212",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/jals1212/pi-mox.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/jals1212/pi-mox#readme",
|
|
12
|
+
"bugs": "https://github.com/jals1212/pi-mox/issues",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"pi-package",
|
|
15
|
+
"pi",
|
|
16
|
+
"mox",
|
|
17
|
+
"schedule",
|
|
18
|
+
"reminder",
|
|
19
|
+
"agent"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"pi": {
|
|
23
|
+
"extensions": [
|
|
24
|
+
"./extensions/mox.ts"
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"src",
|
|
29
|
+
"extensions",
|
|
30
|
+
"README.md",
|
|
31
|
+
"CHANGELOG.md",
|
|
32
|
+
"LICENSE"
|
|
33
|
+
],
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=22.18"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"test": "node --test \"tests/*.test.ts\""
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
42
|
+
"@earendil-works/pi-tui": "*"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"@earendil-works/pi-coding-agent": {
|
|
46
|
+
"optional": true
|
|
47
|
+
},
|
|
48
|
+
"@earendil-works/pi-tui": {
|
|
49
|
+
"optional": true
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"publishConfig": {
|
|
53
|
+
"access": "public",
|
|
54
|
+
"provenance": true
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/mox.ts
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// pi-mox core: time parsing, Roman numerals, the cue list and its wall-clock scheduler, and the /mox command.
|
|
2
|
+
// No Pi runtime imports here (see src/wire.ts for the wiring), so everything runs under plain `node --test`.
|
|
3
|
+
|
|
4
|
+
export const USAGE = "Usage: /mox <time> <message>, /mox list, /mox cancel <id> (time: 10:30 or +2h15m)";
|
|
5
|
+
|
|
6
|
+
/** A pending cue: a message to deliver at `at` (epoch milliseconds). `id` is shown as a Roman numeral. */
|
|
7
|
+
export interface Cue {
|
|
8
|
+
id: number;
|
|
9
|
+
at: number;
|
|
10
|
+
message: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Timers {
|
|
14
|
+
setTimer(fn: () => void, ms: number): unknown;
|
|
15
|
+
clearTimer(handle: unknown): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface MoxOptions extends Timers {
|
|
19
|
+
now(): number;
|
|
20
|
+
/** Deliver a due cue. Called once per cue, in time order; a throw goes to `onError`, never retried. */
|
|
21
|
+
send(cue: Cue): void;
|
|
22
|
+
/** Show the footer text, or clear it (`undefined`). */
|
|
23
|
+
setStatus(text: string | undefined): void;
|
|
24
|
+
onError?(error: unknown, cue: Cue): void;
|
|
25
|
+
/** Paint the footer with ANSI colors (only when there is a UI). */
|
|
26
|
+
color?(): boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface Mox {
|
|
30
|
+
add(at: number, message: string): Cue;
|
|
31
|
+
cancel(id: number): Cue | undefined;
|
|
32
|
+
/** Pending cues, nearest first. */
|
|
33
|
+
list(): Cue[];
|
|
34
|
+
/** Clear every timer, cue and the footer; ids start again from I. */
|
|
35
|
+
shutdown(): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CommandDeps {
|
|
39
|
+
ctx: { hasUI: boolean; ui: { notify(message: string, type?: "info" | "warning" | "error"): void } };
|
|
40
|
+
mox: Mox;
|
|
41
|
+
now(): number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// --- Colors (24-bit ANSI): bronze and marble gray. ---
|
|
45
|
+
|
|
46
|
+
const BRONZE = [205, 127, 50] as const; // #CD7F32
|
|
47
|
+
const MARBLE = [184, 184, 176] as const; // #B8B8B0
|
|
48
|
+
|
|
49
|
+
const paint = (rgb: readonly [number, number, number], text: string, on: boolean): string =>
|
|
50
|
+
on ? `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m${text}\x1b[39m` : text;
|
|
51
|
+
|
|
52
|
+
const BRAND = "·MOX·";
|
|
53
|
+
|
|
54
|
+
// --- Roman numerals ---
|
|
55
|
+
|
|
56
|
+
const ROMAN: [number, string][] = [[1000, "M"], [900, "CM"], [500, "D"], [400, "CD"], [100, "C"], [90, "XC"], [50, "L"], [40, "XL"], [10, "X"], [9, "IX"], [5, "V"], [4, "IV"], [1, "I"]];
|
|
57
|
+
|
|
58
|
+
/** Canonical Roman numeral for 1..3999; larger numbers (and anything below 1) fall back to Arabic digits. */
|
|
59
|
+
export function toRoman(n: number): string {
|
|
60
|
+
if (!Number.isInteger(n) || n < 1 || n > 3999) return String(n);
|
|
61
|
+
let rest = n;
|
|
62
|
+
let out = "";
|
|
63
|
+
for (const [value, letters] of ROMAN) {
|
|
64
|
+
while (rest >= value) {
|
|
65
|
+
out += letters;
|
|
66
|
+
rest -= value;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The number a canonical Roman numeral (any case) stands for, or undefined. */
|
|
73
|
+
export function fromRoman(text: string): number | undefined {
|
|
74
|
+
const upper = text.toUpperCase();
|
|
75
|
+
if (!/^[IVXLCDM]+$/.test(upper)) return undefined;
|
|
76
|
+
let total = 0;
|
|
77
|
+
let rest = upper;
|
|
78
|
+
for (const [value, letters] of ROMAN) {
|
|
79
|
+
while (rest.startsWith(letters)) {
|
|
80
|
+
total += value;
|
|
81
|
+
rest = rest.slice(letters.length);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return rest === "" && total <= 3999 && toRoman(total) === upper ? total : undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// --- Time ---
|
|
88
|
+
|
|
89
|
+
const pad2 = (n: number) => String(n).padStart(2, "0");
|
|
90
|
+
|
|
91
|
+
/** Local HH:MM. */
|
|
92
|
+
export function formatClock(at: number): string {
|
|
93
|
+
const d = new Date(at);
|
|
94
|
+
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const minutesLeft = (ms: number) => Math.max(1, Math.ceil(ms / 60_000));
|
|
98
|
+
|
|
99
|
+
/** Remaining time for lists: `2h06m`, or `45m` under an hour; minutes rounded up. */
|
|
100
|
+
export function formatRemaining(ms: number): string {
|
|
101
|
+
const total = minutesLeft(ms);
|
|
102
|
+
const hours = Math.floor(total / 60);
|
|
103
|
+
return hours > 0 ? `${hours}h${pad2(total % 60)}m` : `${total}m`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Remaining time for the footer, in Roman numerals: `II h VI m`; hours omitted at 0, minutes at 0. */
|
|
107
|
+
export function formatRemainingRoman(ms: number): string {
|
|
108
|
+
const total = minutesLeft(ms);
|
|
109
|
+
const hours = Math.floor(total / 60);
|
|
110
|
+
const minutes = total % 60;
|
|
111
|
+
const parts: string[] = [];
|
|
112
|
+
if (hours > 0) parts.push(`${toRoman(hours)} h`);
|
|
113
|
+
if (minutes > 0) parts.push(`${toRoman(minutes)} m`);
|
|
114
|
+
return parts.join(" ");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const ABSOLUTE = /^(\d{1,2}):(\d{2})$/;
|
|
118
|
+
const RELATIVE = /^\+(?:(\d{1,7})h)?(?:(\d{1,7})m)?(?:(\d{1,7})s)?$/;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Epoch milliseconds for a time token, or undefined when it is not valid.
|
|
122
|
+
* `H:MM`/`HH:MM` (24-hour) is today, or tomorrow when that time is not after `now`;
|
|
123
|
+
* `+2h15m`, `+90m`, `+45s` (h, m, s in that order, total > 0) is that long from `now`.
|
|
124
|
+
*/
|
|
125
|
+
export function parseSchedule(token: string, now: number): number | undefined {
|
|
126
|
+
const abs = ABSOLUTE.exec(token);
|
|
127
|
+
if (abs) {
|
|
128
|
+
const hours = Number(abs[1]);
|
|
129
|
+
const minutes = Number(abs[2]);
|
|
130
|
+
if (hours > 23 || minutes > 59) return undefined;
|
|
131
|
+
const target = new Date(now);
|
|
132
|
+
target.setHours(hours, minutes, 0, 0);
|
|
133
|
+
if (target.getTime() <= now) target.setDate(target.getDate() + 1);
|
|
134
|
+
return target.getTime();
|
|
135
|
+
}
|
|
136
|
+
const rel = RELATIVE.exec(token);
|
|
137
|
+
if (rel && (rel[1] !== undefined || rel[2] !== undefined || rel[3] !== undefined)) {
|
|
138
|
+
const total = (Number(rel[1] ?? 0) * 3600 + Number(rel[2] ?? 0) * 60 + Number(rel[3] ?? 0)) * 1000;
|
|
139
|
+
return total > 0 ? now + total : undefined;
|
|
140
|
+
}
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// --- Timers ---
|
|
145
|
+
|
|
146
|
+
/** Real timers; the handle is unref'd so a pending cue never keeps Pi alive. */
|
|
147
|
+
export const defaultTimers: Timers = {
|
|
148
|
+
setTimer(fn, ms) {
|
|
149
|
+
const handle = setTimeout(fn, ms);
|
|
150
|
+
handle.unref();
|
|
151
|
+
return handle;
|
|
152
|
+
},
|
|
153
|
+
clearTimer: (handle) => clearTimeout(handle as NodeJS.Timeout),
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// --- Cue list and scheduler ---
|
|
157
|
+
|
|
158
|
+
/** The timer re-checks the wall clock at least this often, so a machine that slept fires overdue cues on waking. */
|
|
159
|
+
const TICK_MS = 30_000;
|
|
160
|
+
|
|
161
|
+
const byTime = (a: Cue, b: Cue) => a.at - b.at || a.id - b.id;
|
|
162
|
+
|
|
163
|
+
export function createMox(options: MoxOptions): Mox {
|
|
164
|
+
let cues: Cue[] = [];
|
|
165
|
+
let nextId = 1;
|
|
166
|
+
let handle: unknown;
|
|
167
|
+
let armed = false;
|
|
168
|
+
|
|
169
|
+
const statusText = (): string | undefined => {
|
|
170
|
+
const sorted = [...cues].sort(byTime);
|
|
171
|
+
const next = sorted[0];
|
|
172
|
+
if (!next) return undefined;
|
|
173
|
+
const more = sorted.length > 1 ? ` (+${sorted.length - 1})` : "";
|
|
174
|
+
const rest = `${formatClock(next.at)} · ${formatRemainingRoman(next.at - options.now())}${more}`;
|
|
175
|
+
const color = options.color?.() ?? false;
|
|
176
|
+
return `${paint(BRONZE, BRAND, color)} ${paint(MARBLE, rest, color)}`;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const disarm = () => {
|
|
180
|
+
if (armed) options.clearTimer(handle);
|
|
181
|
+
armed = false;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const arm = () => {
|
|
185
|
+
disarm();
|
|
186
|
+
const next = [...cues].sort(byTime)[0];
|
|
187
|
+
if (!next) return;
|
|
188
|
+
handle = options.setTimer(tick, Math.max(0, Math.min(TICK_MS, next.at - options.now())));
|
|
189
|
+
armed = true;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const tick = () => {
|
|
193
|
+
armed = false;
|
|
194
|
+
const now = options.now();
|
|
195
|
+
const due = cues.filter((cue) => cue.at <= now).sort(byTime);
|
|
196
|
+
cues = cues.filter((cue) => cue.at > now);
|
|
197
|
+
for (const cue of due) {
|
|
198
|
+
try {
|
|
199
|
+
options.send(cue);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
options.onError?.(error, cue);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
options.setStatus(statusText());
|
|
205
|
+
arm();
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const changed = () => {
|
|
209
|
+
options.setStatus(statusText());
|
|
210
|
+
arm();
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
add(at, message) {
|
|
215
|
+
const cue = { id: nextId++, at, message };
|
|
216
|
+
cues.push(cue);
|
|
217
|
+
changed();
|
|
218
|
+
return cue;
|
|
219
|
+
},
|
|
220
|
+
cancel(id) {
|
|
221
|
+
const cue = cues.find((candidate) => candidate.id === id);
|
|
222
|
+
if (!cue) return undefined;
|
|
223
|
+
cues = cues.filter((candidate) => candidate !== cue);
|
|
224
|
+
changed();
|
|
225
|
+
return cue;
|
|
226
|
+
},
|
|
227
|
+
list: () => [...cues].sort(byTime).map((cue) => ({ ...cue })),
|
|
228
|
+
shutdown() {
|
|
229
|
+
disarm();
|
|
230
|
+
cues = [];
|
|
231
|
+
nextId = 1;
|
|
232
|
+
options.setStatus(undefined);
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// --- Command ---
|
|
238
|
+
|
|
239
|
+
const dayStart = (ms: number) => new Date(new Date(ms).setHours(0, 0, 0, 0)).getTime();
|
|
240
|
+
|
|
241
|
+
function dayLabel(at: number, now: number): string {
|
|
242
|
+
const days = Math.round((dayStart(at) - dayStart(now)) / 86_400_000);
|
|
243
|
+
if (days === 0) return "";
|
|
244
|
+
if (days === 1) return " tomorrow";
|
|
245
|
+
const d = new Date(at);
|
|
246
|
+
return ` ${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** `/mox <args>`: schedule, list or cancel. Results and errors go through `ctx.ui.notify`. */
|
|
250
|
+
export function runMoxCommand(args: string, deps: CommandDeps): void {
|
|
251
|
+
const { ctx, mox } = deps;
|
|
252
|
+
const info = (message: string) => ctx.ui.notify(message, "info");
|
|
253
|
+
const fail = (message: string) => ctx.ui.notify(`${message}\n${USAGE}`, "error");
|
|
254
|
+
const text = args.trim();
|
|
255
|
+
const match = /^(\S+)(?:\s+([\s\S]*))?$/.exec(text);
|
|
256
|
+
const word = match?.[1];
|
|
257
|
+
const rest = match?.[2]?.trim() ?? "";
|
|
258
|
+
|
|
259
|
+
if (word === undefined || word === "list") {
|
|
260
|
+
if (rest !== "") return fail("list takes no arguments.");
|
|
261
|
+
const cues = mox.list();
|
|
262
|
+
if (cues.length === 0) return info("No pending cues.");
|
|
263
|
+
const now = deps.now();
|
|
264
|
+
const width = Math.max(2, ...cues.map((cue) => toRoman(cue.id).length));
|
|
265
|
+
return info(
|
|
266
|
+
cues
|
|
267
|
+
.map((cue) => {
|
|
268
|
+
const line = `${formatClock(cue.at)} ${formatRemaining(cue.at - now)} ${cue.message}`;
|
|
269
|
+
return `${paint(BRONZE, toRoman(cue.id).padEnd(width), ctx.hasUI)} ${paint(MARBLE, line, ctx.hasUI)}`;
|
|
270
|
+
})
|
|
271
|
+
.join("\n"),
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (word === "cancel") {
|
|
276
|
+
if (rest === "") return fail("Missing cue id.");
|
|
277
|
+
const id = /^\d+$/.test(rest) ? Number(rest) : fromRoman(rest);
|
|
278
|
+
if (id === undefined || id < 1) return fail(`Invalid cue id "${rest}".`);
|
|
279
|
+
const cue = mox.cancel(id);
|
|
280
|
+
if (!cue) return fail(`No pending cue ${toRoman(id)}.`);
|
|
281
|
+
return info(`Cancelled ${toRoman(cue.id)} (${formatClock(cue.at)}): ${cue.message}`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const now = deps.now();
|
|
285
|
+
const at = parseSchedule(word, now);
|
|
286
|
+
if (at === undefined) return fail(`Invalid time "${word}".`);
|
|
287
|
+
if (rest === "") return fail("Missing message.");
|
|
288
|
+
const cue = mox.add(at, rest);
|
|
289
|
+
info(`Scheduled ${toRoman(cue.id)} for ${formatClock(at)}${dayLabel(at, now)} (in ${formatRemaining(at - now)}): ${rest}`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Autocompletion: `list`, `cancel`, and pending ids after `cancel`. */
|
|
293
|
+
export function completeMoxArgs(prefix: string, mox: Mox): { value: string; label: string }[] | null {
|
|
294
|
+
const words = prefix.split(" ");
|
|
295
|
+
if (words.length === 1) {
|
|
296
|
+
const items = ["list", "cancel"].filter((sub) => sub.startsWith(words[0])).map((sub) => ({ value: sub, label: sub }));
|
|
297
|
+
return items.length > 0 ? items : null;
|
|
298
|
+
}
|
|
299
|
+
if (words.length !== 2 || words[0] !== "cancel") return null;
|
|
300
|
+
const typed = words[1].toUpperCase();
|
|
301
|
+
const items = mox
|
|
302
|
+
.list()
|
|
303
|
+
.sort((a, b) => a.id - b.id)
|
|
304
|
+
.filter((cue) => toRoman(cue.id).startsWith(typed) || String(cue.id).startsWith(typed))
|
|
305
|
+
.map((cue) => ({ value: `cancel ${toRoman(cue.id)}`, label: `${toRoman(cue.id)} ${formatClock(cue.at)} ${cue.message}` }));
|
|
306
|
+
return items.length > 0 ? items : null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Text of a delivered cue: `·MOX· 10:30 — <message>`, ·MOX· in bronze, the rest in marble. */
|
|
310
|
+
export function renderMoxText(content: string, at: number | undefined): string {
|
|
311
|
+
const body = at === undefined ? content : `${formatClock(at)} — ${content}`;
|
|
312
|
+
return `${paint(BRONZE, BRAND, true)} ${paint(MARBLE, body, true)}`;
|
|
313
|
+
}
|
package/src/wire.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// pi-mox wiring: connects the core (src/mox.ts) to Pi's extension API. Pi runtime values (the `Text` component)
|
|
2
|
+
// are passed in by extensions/mox.ts, so this file has type-only imports and runs under plain `node --test`.
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { Text } from "@earendil-works/pi-tui";
|
|
5
|
+
import { completeMoxArgs, createMox, defaultTimers, renderMoxText, runMoxCommand, toRoman, type Timers } from "./mox.ts";
|
|
6
|
+
|
|
7
|
+
export interface WireOptions {
|
|
8
|
+
Text: typeof Text;
|
|
9
|
+
now?: () => number;
|
|
10
|
+
timers?: Timers;
|
|
11
|
+
/** Where notifications go when there is no UI (print and JSON modes); stdout stays free for Pi's output. */
|
|
12
|
+
stderr?: (text: string) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function registerMox(pi: ExtensionAPI, options: WireOptions): void {
|
|
16
|
+
const now = options.now ?? Date.now;
|
|
17
|
+
const timers = options.timers ?? defaultTimers;
|
|
18
|
+
const stderr = options.stderr ?? ((text: string) => void process.stderr.write(text));
|
|
19
|
+
|
|
20
|
+
// The UI of the latest session or command context; cues are shared by the whole Pi process.
|
|
21
|
+
let ui: { hasUI: boolean; ui: ExtensionContext["ui"] } | undefined;
|
|
22
|
+
const use = (ctx: ExtensionContext) => {
|
|
23
|
+
ui = { hasUI: ctx.hasUI, ui: ctx.ui };
|
|
24
|
+
};
|
|
25
|
+
const notify = (message: string, type: "info" | "warning" | "error" = "info") => {
|
|
26
|
+
if (ui?.hasUI) ui.ui.notify(message, type);
|
|
27
|
+
else stderr(`${message}\n`);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const mox = createMox({
|
|
31
|
+
...timers,
|
|
32
|
+
now,
|
|
33
|
+
color: () => ui?.hasUI === true,
|
|
34
|
+
setStatus: (text) => {
|
|
35
|
+
if (ui?.hasUI) ui.ui.setStatus("mox", text);
|
|
36
|
+
},
|
|
37
|
+
// Fired once, queued behind the current run: starts a turn when idle, waits when busy.
|
|
38
|
+
send: (cue) =>
|
|
39
|
+
pi.sendMessage(
|
|
40
|
+
{ customType: "mox", content: cue.message, display: true, details: { id: toRoman(cue.id), at: cue.at } },
|
|
41
|
+
{ triggerTurn: true, deliverAs: "followUp" },
|
|
42
|
+
),
|
|
43
|
+
onError: (error, cue) => notify(`·MOX· could not deliver ${toRoman(cue.id)}: ${error instanceof Error ? error.message : String(error)}`, "error"),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
pi.registerMessageRenderer("mox", (message, renderOptions) => {
|
|
47
|
+
const at = (message.details as { at?: unknown } | undefined)?.at;
|
|
48
|
+
const content = typeof message.content === "string" ? message.content : message.content.map((part) => (part.type === "text" ? part.text : "")).join("");
|
|
49
|
+
return new options.Text(renderMoxText(content, typeof at === "number" ? at : undefined), renderOptions.outputPad ?? 1, 0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
pi.registerCommand("mox", {
|
|
53
|
+
description: "Schedule a message for the agent: /mox <10:30|+2h15m> <message>, /mox list, /mox cancel <id>",
|
|
54
|
+
getArgumentCompletions: (prefix) => completeMoxArgs(prefix, mox),
|
|
55
|
+
handler: async (args, ctx) => {
|
|
56
|
+
use(ctx);
|
|
57
|
+
runMoxCommand(args, { ctx: { hasUI: ctx.hasUI, ui: { notify } }, mox, now });
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
62
|
+
use(ctx);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Cues live in memory only: a closed session keeps none of them.
|
|
66
|
+
pi.on("session_shutdown", async () => {
|
|
67
|
+
mox.shutdown();
|
|
68
|
+
ui = undefined;
|
|
69
|
+
});
|
|
70
|
+
}
|