1// clock — a zut extension written in plain Node (no dependencies).
2//
3// Registers two slash commands:
4// /now — pushes the current local time into the chat as
5// a one-shot note (no model call, no transcript)
6// /uptime — submits a prompt asking the agent to comment on
7// how long this extension has been running
8//
9// Why .js and not .ts: this file uses JSDoc types so it can be
10// type-checked by tsc / tsserver without a build step. Renaming to
11// .ts and updating extension.json's args to ["--import","tsx",
12// "index.ts"] (with tsx installed) works too. The extension protocol
13// itself is language-agnostic; what matters is that `exec` produces
14// a process that reads JSON lines from stdin and writes them to
15// stdout.
16//
17// Install:
18// zut ext install /path/to/this/dir
19//
20// Then in zut:
21// /now
22// /uptime
23
24import { createInterface } from "node:readline";
25import { stdin, stdout, stderr } from "node:process";
26
27const NAME = "clock";
28const VERSION = "1.0.0";
29const STARTED_AT = Date.now();
30
31/** @typedef {{type: string, id?: string, [k: string]: unknown}} Frame */
32
33/**
34 * Send a frame to zut. One JSON object per line; flush immediately
35 * so the host doesn't sit waiting on a buffer.
36 * @param {Frame} obj
37 */
38function send(obj) {
39 stdout.write(JSON.stringify(obj) + "\n");
40}
41
42/**
43 * stderr is captured by zut to $ZUT_HOME/logs/ext-clock.log; perfect
44 * for debug output. Anything written to stdout would corrupt the
45 * protocol stream.
46 * @param {string} msg
47 */
48function log(msg) {
49 stderr.write(`[${NAME}] ${msg}\n`);
50}
51
52// 1. Hello first.
53send({
54 type: "hello",
55 protocol_version: 2,
56 name: NAME,
57 version: VERSION,
58 capabilities: ["commands"],
59});
60
61// 2. Register every command we can handle.
62send({
63 type: "register_command",
64 name: "now",
65 description: "show the current local time (no model call)",
66});
67send({
68 type: "register_command",
69 name: "uptime",
70 description: "ask the agent to riff on how long the clock ext has run",
71});
72
73// 3. Read frames until stdin closes (zut shuts us down).
74const rl = createInterface({ input: stdin, crlfDelay: Infinity });
75
76rl.on("line", (line) => {
77 /** @type {Frame} */
78 let frame;
79 try {
80 frame = JSON.parse(line);
81 } catch (err) {
82 log(`malformed frame: ${err}`);
83 return;
84 }
85
86 switch (frame.type) {
87 case "hello_ack":
88 if (frame.protocol_version !== 2) {
89 log(`unsupported protocol version: ${frame.protocol_version}`);
90 process.exitCode = 1;
91 rl.close();
92 break;
93 }
94 log(
95 `connected to zut ${frame.zut_version} (${frame.provider}/${frame.model})`,
96 );
97 break;
98
99 case "command_invoked":
100 handleCommand(frame);
101 break;
102
103 case "shutdown":
104 send({ type: "shutdown_ack" });
105 rl.close();
106 break;
107
108 default:
109 log(`unknown frame type: ${frame.type}`);
110 }
111});
112
113rl.on("close", () => {
114 log("read loop closed; exiting");
115 process.exit(0);
116});
117
118/**
119 * @param {Frame & {name?: string, args?: string}} frame
120 */
121function handleCommand(frame) {
122 const name = String(frame.name ?? "");
123 const args = String(frame.args ?? "").trim();
124 const id = String(frame.id ?? "");
125
126 switch (name) {
127 case "now": {
128 const now = new Date();
129 const human = now.toLocaleString(undefined, {
130 weekday: "short",
131 year: "numeric",
132 month: "short",
133 day: "numeric",
134 hour: "2-digit",
135 minute: "2-digit",
136 second: "2-digit",
137 });
138 const iso = now.toISOString();
139 send({
140 type: "command_response",
141 id,
142 action: "display",
143 display: `local: ${human}\niso : ${iso}`,
144 });
145 return;
146 }
147
148 case "uptime": {
149 const ms = Date.now() - STARTED_AT;
150 const seconds = Math.round(ms / 1000);
151 const focus = args ? `Focus on the topic: ${args}.` : "";
152 send({
153 type: "command_response",
154 id,
155 action: "prompt",
156 prompt:
157 `The clock extension has been running for ${seconds}s in this zut session. ` +
158 `Riff on that briefly in one short sentence — be a little dramatic. ${focus}`.trim(),
159 });
160 return;
161 }
162
163 default:
164 send({
165 type: "command_response",
166 id,
167 action: "noop",
168 error: `clock: unknown command /${name}`,
169 });
170 }
171}