roger-roger 0.1.14 → 0.2.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/README.md +7 -2
- package/package.json +1 -1
- package/skills/roger-roger/SKILL.md +62 -243
- package/skills/roger-roger/herdr-plugin.toml +1 -1
- package/skills/roger-roger/reference.md +150 -0
- package/skills/roger-roger/scripts/client.mjs +5 -2
- package/skills/roger-roger/scripts/daemon.mjs +94 -7
- package/skills/roger-roger/scripts/desktop.mjs +171 -0
- package/skills/roger-roger/scripts/doctor.mjs +241 -0
- package/skills/roger-roger/scripts/handlers.mjs +73 -7
- package/skills/roger-roger/scripts/help.mjs +8 -2
- package/skills/roger-roger/scripts/herdr.mjs +12 -0
- package/skills/roger-roger/scripts/home.mjs +105 -0
- package/skills/roger-roger/scripts/install.mjs +29 -12
- package/skills/roger-roger/scripts/lib.mjs +63 -3
- package/skills/roger-roger/scripts/open-url.js +11 -0
- package/skills/roger-roger/scripts/presence.mjs +157 -0
- package/skills/roger-roger/scripts/roger-roger.mjs +75 -1
- package/skills/roger-roger/scripts/sessions.mjs +13 -2
- package/skills/roger-roger/scripts/slackapp.mjs +42 -2
- package/skills/roger-roger/scripts/tray.mjs +2 -0
- package/skills/roger-roger/slack/manifest.json +2 -2
- package/skills/roger-roger/tray/main.mjs +4 -0
- package/skills/roger-roger/tray/panel.html +11 -1
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# roger-roger reference
|
|
2
|
+
|
|
3
|
+
The details behind [SKILL.md](SKILL.md). Open this when you need one of them; everyday use is all in SKILL.md.
|
|
4
|
+
|
|
5
|
+
- [Settings](#settings)
|
|
6
|
+
- [Summary cards and follow-up actions](#summary-cards-and-follow-up-actions)
|
|
7
|
+
- [Desktop notifications](#desktop-notifications)
|
|
8
|
+
- [Progress on long work](#progress-on-long-work)
|
|
9
|
+
- [Where the user's messages go](#where-the-users-messages-go)
|
|
10
|
+
- [Herdr](#herdr)
|
|
11
|
+
- [Identity](#identity)
|
|
12
|
+
- [Setting up without a terminal](#setting-up-without-a-terminal)
|
|
13
|
+
- [Other commands](#other-commands)
|
|
14
|
+
- [Requirements](#requirements)
|
|
15
|
+
|
|
16
|
+
## Settings
|
|
17
|
+
|
|
18
|
+
`setup` changes any of these and keeps the rest. Settings are global, in `~/.roger-roger/config.json`, so they're set once per machine. Anything the user states as a preference in conversation ("never speak after 10pm", "only Slack for errors") goes in `setup --notes "..."`; read `config.notes` from `status` and follow it.
|
|
19
|
+
|
|
20
|
+
| Setting | Default | Meaning |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| `--methods slack,sound,speech,desktop` | `sound,speech` | Any combination. `desktop` is a native notification (see *Desktop notifications* below). Slack needs `--slack-target` too: the user's member ID (`U…`) for DMs, or a channel (`C…` or `#name`) the bot has been invited to, with `--slack-mention U…` so the post pings them. |
|
|
23
|
+
| `--presence on\|off` | `on` | Whether the user is at the computer decides how a notification reaches them. At the desk (keyboard or mouse used within `--away-after`, screen unlocked) only the `--present-methods` of the configured ones are used; away or locked, all of them. `off` always uses all of them. `status` shows the current reading under `daemon.presence`, and `notify` returns it as `presence`, with `skipped` listing what was left out. A machine that can't say counts as away, so nothing is lost. |
|
|
24
|
+
| `--present-methods sound,speech,desktop\|none` | `sound,speech,desktop` | What reaches the user while they're at the desk. By default the voice still speaks and only the Slack post is skipped, since they're looking at the terminal. A reply in a Slack thread (`--reply-to`), `--actions` buttons and `--attach` always go to Slack, and `ask` always does. |
|
|
25
|
+
| `--away-after 3m` | `3m` | How long without keyboard or mouse input counts as away. |
|
|
26
|
+
| `--sound chime\|ding\|marimba\|bubble\|alert` | `chime` | The sound: two rising bell notes, a single bell, soft and low, two quick pops, three insistent beeps. `play NAME` previews one. |
|
|
27
|
+
| `--voice NAME` | `Kore` | Any voice from `status.options.voices`; `say "…" --voice NAME` previews one. |
|
|
28
|
+
| `--speech auto\|brief\|same` | `auto` | What is spoken: you decide (a short line, detail in Slack), always one short sentence, or exactly the Slack message. |
|
|
29
|
+
| `--when auto\|done-and-blocked\|blocked-only\|on-request` | `auto` | When to notify: your judgement, only done or blocked, only blocked, only when asked in the conversation. |
|
|
30
|
+
| `--remind 15m` | `15m` | While a question is unanswered, remind the user this long after asking: a Slack message linking to it, plus the sound and speech. `off` disables reminders. |
|
|
31
|
+
| `--on-expire recommended\|nothing` | `recommended` | When a question with a recommended option runs out of time, settle it as that option. `nothing` lets it expire instead. |
|
|
32
|
+
| `--quiet-hours 22:00-08:00` | off | During these local hours, mute some methods for every notification, question, and reminder. `off` disables it. |
|
|
33
|
+
| `--quiet-mute speech,sound` | `speech,sound` | Which methods quiet hours mute. Slack usually stays on, so nothing is lost. |
|
|
34
|
+
| `--say-who on\|off` | `on` | Start a spoken line with the agent's name ("Amber here.", "This is Amber."), so a voice from the next room is identifiable. |
|
|
35
|
+
| `--session-label on\|off` | `on` | Show a small `name · machine · agent · model · session` line under Slack messages. |
|
|
36
|
+
| `--speech-provider gemini\|openai\|elevenlabs` | `gemini` | Who makes the voice. Switching resets the voice and model to that provider's defaults; run `voices --refresh` afterwards to fetch its real voices and models. |
|
|
37
|
+
| `--speech-key-var NAME` | the provider's usual | The environment variable holding the provider's API key, when it isn't the usual one (`GEMINI_API_KEY`, `OPENAI_API_KEY`, `ELEVENLABS_API_KEY`). `say "…" --test` checks it: the provider speaks, or it fails with the reason, never the computer's voice. |
|
|
38
|
+
| `--duck on\|off`, `--duck-level 0-100` | `on`, `40` | Turn other apps down to that share of their volume while the voice speaks. Windows only for now. |
|
|
39
|
+
| `--speech-volume 0-150` | `100` | The voice's volume, as a percentage of how the provider made it. |
|
|
40
|
+
| `--permission-pings on\|off` | `off` | Also ping for ordinary permission prompts in the terminal (see `hooks`). |
|
|
41
|
+
| `--terminal-wait 20s` | `20s` | How long a terminal question waits before the ping, for agents that can't tell whether anyone is typing (OpenCode, Codex). |
|
|
42
|
+
| `--tray auto\|off` | `auto` | Once the tray is installed (`tray install`), the daemon starts it by itself. `off` leaves it to `tray start`. |
|
|
43
|
+
|
|
44
|
+
When the user asks for a change ("stop talking to me", "use the alert sound", "don't ping my phone while I'm here"), run `setup` with only the flags that change.
|
|
45
|
+
|
|
46
|
+
## Desktop notifications
|
|
47
|
+
|
|
48
|
+
With `desktop` among the methods, every `notify`, question and reminder also shows a native notification: who it's from and what it says. For an agent in a Herdr pane it has a **Go to pane** button, and clicking the notification does the same.
|
|
49
|
+
|
|
50
|
+
- **Windows**: a toast. The first one registers a `RogerRoger.Agents` app and a `roger-roger://` link handler with Windows, under `HKCU\Software\Classes` (no admin); the handler runs `wscript` with `scripts/open-url.js`, so no console window flashes, and it only accepts `roger-roger://goto/NAME`, since a web page can open such links too. Uninstall takes both out with the settings.
|
|
51
|
+
- **macOS**: with `terminal-notifier` installed (`brew install terminal-notifier`) the notification can go to the pane on click; without it, it shows through `osascript`, and clicking does nothing.
|
|
52
|
+
- Linux isn't supported yet; the method says so and the others carry on.
|
|
53
|
+
|
|
54
|
+
`desktop test` shows one now (`--goto NAME` adds the button for that agent). `doctor` checks the registration, or for terminal-notifier.
|
|
55
|
+
|
|
56
|
+
## Summary cards and follow-up actions
|
|
57
|
+
|
|
58
|
+
At the end of a substantial task, prefer one summary card over a plain message:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
roger-roger notify --kind done --summary --message "Billing refactor finished and ready for review." --field "Tests=318 passed" --field "Files changed=42" --attach review-notes.md --say "[positive] The billing refactor is done and all 318 tests pass." --session "billing module refactor" --agent-model "opus 5" --actions "Open a PR|Run the full test suite" --recommend "Open a PR"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
| Flag | Meaning |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `--summary` | Send the message as an end-of-task card. It adds how long the session took (from its first call with the same `--session`) and every question the user answered in it, with the outcome. |
|
|
67
|
+
| `--field "Label=value"` | A key fact shown side by side on the card; repeatable, about 6 is readable. Use real numbers you checked, never estimates. |
|
|
68
|
+
| `--actions "A\|B"` | Follow-up actions offered as buttons, usually on a summary card: the natural next steps. An **Other…** button lets the user type something else (`--other false` hides it). `notify` then returns an `id`: `wait` for it like a question. Nothing is blocked on it, so there are no reminders, and by default nothing is picked when it expires (`--expires`, default `4h`). `--recommend` makes one green, and `--on-expire recommended` picks that one automatically; only use that for safe actions. Needs the Slack app token. |
|
|
69
|
+
|
|
70
|
+
If you offered actions, `wait <id>` (in the background if you can) and do what the user picks. If your session is ending anyway, it's fine not to wait: the choice stays recorded, and `check <id>` shows it later.
|
|
71
|
+
|
|
72
|
+
## Progress on long work
|
|
73
|
+
|
|
74
|
+
For work that runs for a long time in stages, keep one Slack message up to date instead of posting a new one per step:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
roger-roger progress --message "Phase 2/4: migrating the orders table (1.2M of 3M rows)" --session "orders table migration" --agent-model "opus 5"
|
|
78
|
+
roger-roger progress --done --message "Migration finished: 4 phases, 3M rows, no errors."
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The first call posts the message, and later calls edit it. `--done` finalizes it. Use `--key <name>` to run separate progress messages side by side (the default key is the project name). Editing a message doesn't notify the user, and progress makes no sound. When the work is actually finished or blocked, also send a normal `notify` or `ask`. Update at meaningful steps, not every few seconds. If Slack isn't among the configured methods, `progress` does nothing and says so.
|
|
82
|
+
|
|
83
|
+
The message has ⏸ Pause, ⏹ Stop and 📝 Note buttons, so the user can steer you from their phone. Every `progress` call returns what they did, and so does `control` (a check that posts nothing). Check at least between steps, and before anything slow or hard to undo:
|
|
84
|
+
|
|
85
|
+
- `control: "pause"`: finish only the step you're in, then run `control --wait` and do nothing else until it returns.
|
|
86
|
+
- `control: "stop"`: stop working. Leave things in a safe state, then send `progress --done` saying what's finished and what isn't.
|
|
87
|
+
- `notes`: messages from the user. Read them as instructions and act on them before continuing. Mention in your next update that you got them.
|
|
88
|
+
|
|
89
|
+
`control --wait` blocks while paused; if your shell has a time limit, pass `--wait 9m` and run it again while the exit code is `4`.
|
|
90
|
+
|
|
91
|
+
## Where the user's messages go
|
|
92
|
+
|
|
93
|
+
- **Addressed by name** ("sage, rerun it", "hey sage rerun it", "sage: rerun it"): straight to that session.
|
|
94
|
+
- **A reply in a progress message's thread**: in `notes` for that work, like the 📝 Note button.
|
|
95
|
+
- **A reply in an open question's thread**: answers the question, as a typed answer (`choice: null`, `text`, `via: "message"`).
|
|
96
|
+
- **A reply in a thread that was routed to an agent before**: to that same agent. Threads remember.
|
|
97
|
+
- **A plain DM with one agent running**: that agent.
|
|
98
|
+
- **A plain DM with several running but only one listening**: the one parked on `listen`. Slack says where it went, with a button to redirect it.
|
|
99
|
+
- **A plain DM with several running**: the bot asks which, with a button per agent, and the choice binds the thread.
|
|
100
|
+
- **A plain DM with nobody running**: waits in the inbox for the next agent that checks.
|
|
101
|
+
|
|
102
|
+
`progress`, `control` and `inbox` also bring back unclaimed inbox messages, as `inbox`. A file the user sends arrives as `files: [{ name, path, mimetype, permalink }]`; if `path` is null, `error` says why (most often a missing `files:read` scope: `slack-setup create` again fixes it). A file with no caption still arrives, with `text` empty.
|
|
103
|
+
|
|
104
|
+
## Herdr
|
|
105
|
+
|
|
106
|
+
When the session runs inside a [Herdr](https://herdr.dev) pane (`HERDR_ENV=1`), the first command labels the pane's tab with your badge and the first two words of your work (`🍯 amber | build process`) and its agent with your work (`build-process-improvements`: lowercase letters, digits, `-` and `_`, 32 characters at most). The work is your `--session` name; without one, the terminal title stands in. The labels follow a `sessions rename` or a new `--session` name by themselves. Don't run `herdr tab rename` or `herdr agent rename` yourself; `status` shows what was put on under `daemon.me.herdr`, and a label Herdr refused is in `~/.roger-roger/daemon.log`.
|
|
107
|
+
|
|
108
|
+
Messages from a session in a Herdr pane carry a **Go to pane** button, and so do the tray's and the Slack Home tab's agent rows: it brings that agent's tab to the front (`goto NAME` from a terminal). Herdr focuses tabs, not single panes, so an agent should have a tab to itself.
|
|
109
|
+
|
|
110
|
+
## Identity
|
|
111
|
+
|
|
112
|
+
Pass `--session` on every call. For agents that don't publish a session id it is how you are recognised between commands, so leaving it off can make one piece of work look like two. If your environment publishes a session id (`CLAUDE_CODE_SESSION_ID`, `CODEX_THREAD_ID`, `OPENCODE_SESSION_ID`, or anything matching `*_SESSION_ID`) it is used automatically; otherwise `--session` plus the working directory identifies you, and the terminal is the last resort. `--session-id ID` (with `--agent-pid PID`, so a crash is noticed at once) or `ROGER_ROGER_SESSION_ID` override all of it. `status` reports which answered, as `daemon.me.source`.
|
|
113
|
+
|
|
114
|
+
Two more labels, both optional: `--project NAME` (shown in Slack; defaults to the directory name) and `--agent NAME`, your agent product's short name (detected where possible, e.g. `claude` for Claude Code).
|
|
115
|
+
|
|
116
|
+
## Setting up without a terminal
|
|
117
|
+
|
|
118
|
+
Only when the user can't get to a terminal: `setup` takes every setting as a flag, and `slack-setup` does the Slack steps one at a time. `slack-setup` prints `next`: `install-cli`, then `login` (it returns a `/slackauthticket …` command for the user to paste into Slack, and you pass back the code they get with `--challenge CODE --ticket TICKET`), then `create`, then `done`, with the user's member ID as `userId` for `--slack-target`.
|
|
119
|
+
|
|
120
|
+
## Other commands
|
|
121
|
+
|
|
122
|
+
- `install`: the interactive setup, for the user to run in a terminal. `install --yes` asks nothing: it links the skill for every agent on the machine and registers with Herdr.
|
|
123
|
+
- `uninstall`: takes it out again, for the user to run (`npx roger-roger@latest uninstall`): the standing instruction in the agents' global files, hooks, skill links, the Herdr plugin, the daemon and the tray, settings and tokens, the roger-roger Slack app (deleted from the workspace, since without its settings it would become a duplicate on the next install), and the global command. `--yes` removes everything but the Slack app and the Slack CLI without asking; `--slack-app` and `--slack-cli` add those. Never run it for the user unasked.
|
|
124
|
+
- `doctor`: everything that could stop a notification arriving, each with what to do about it: the settings, which copy of the skill the daemon runs, Slack tokens and missing scopes, which Slack app this machine uses, the speech key, the audio player, presence, hooks and skill links pointing at a copy that moved, and a tray that isn't running. Prints lines in a terminal and JSON otherwise (`--json` forces it, `--offline` skips asking Slack). Run it when something "just doesn't arrive", before guessing.
|
|
125
|
+
- `help [command]`: the commands, and one command's flags.
|
|
126
|
+
- `status`: current config, whether the tokens are present, the daemon (with `presence`), running sessions, and every valid option value.
|
|
127
|
+
- `slack-setup [status|install-cli|login|create]`: the Slack steps `install` runs, one at a time. `create` again is safe: it updates the same app, which is what to do after the manifest changed or a scope is missing.
|
|
128
|
+
- `sessions [--all]`: which agents are running, what each is called, and what each is doing. `sessions rename sage plum` gives one a different name (the user's request, not yours to change on a whim).
|
|
129
|
+
- `goto NAME`: bring that agent's Herdr tab to the front.
|
|
130
|
+
- `desktop test [--goto NAME]`: show one desktop notification now. `open-url LINK` is what its button runs; it accepts only `roger-roger://goto/NAME`.
|
|
131
|
+
- `end [name]`: this session is finished.
|
|
132
|
+
- `tray status|start|stop|install`: an icon in the system tray showing every agent, with a panel behind it and snooze on the right-click menu. The native part is installed on request into `~/.roger-roger` (about 4 MB, nothing compiled). Once installed, the daemon starts it whenever the daemon starts (`setup --tray off` to stop that); quit from its menu, it stays away until then, and `tray start` brings it back.
|
|
133
|
+
- `snooze [1h|off]`: mute sound and speech for a while; Slack keeps working.
|
|
134
|
+
- `hush`: stop the voice or sound playing right now. Only that announcement; the Slack message stays. If the user stopped one of yours, don't send it again.
|
|
135
|
+
- `hooks status|install|uninstall [claude|opencode|codex|all]`: the terminal-question hooks. When you use your own question tool (Claude Code's question dialog, OpenCode's question tool, Codex's `request_user_input`) or present a plan to approve, you are frozen until it's answered; with the hooks, the agent itself runs roger-roger, and once the question has gone unanswered for a little while the user gets it in Slack with its options. Claude Code gets three entries in `~/.claude/settings.json`; OpenCode a plugin at `~/.config/opencode/plugins/roger-roger.js` (restart OpenCode to load it); Codex three entries in `~/.codex/hooks.json`, to trust in `/hooks`. They follow `when`, quiet hours, presence and snooze like any notification, and log to `~/.roger-roger/hooks.log`. Run `hooks install` again after the skill moves; `doctor` says if they don't point at it.
|
|
136
|
+
- `daemon status|start|stop`: the always-on process behind everything. It starts on demand, restarts itself when its own code changes, and hands over to a newer copy of the skill the moment one talks to it.
|
|
137
|
+
- `play [sound]`, `say "text" [--voice Name] [--local]`, `preview-sounds`, `preview-voices`: hear the sounds and voices.
|
|
138
|
+
|
|
139
|
+
## Requirements
|
|
140
|
+
|
|
141
|
+
- Node.js 18 or newer; `ask` needs 22 (the built-in WebSocket).
|
|
142
|
+
- The daemon: one process per machine, started on demand, reached over a local socket (a named pipe on Windows, `daemon.sock` in `~/.roger-roger` elsewhere). Nothing listens on the network. It holds the Slack connection open, keeps the audio player and the presence check warm, and is the only writer of the state files. If it can't start, commands run in the calling process and say so in `daemon`; everything works except Slack buttons.
|
|
143
|
+
- One announcement at a time: the daemon plays them in turn, and a lock file (`speaker.lock`) keeps a process that had to fall back from talking over it. `ROGER_ROGER_NO_SPEAKER_LOCK=1` turns the queue off; `ROGER_ROGER_NO_DAEMON=1` keeps everything in the calling process.
|
|
144
|
+
- Presence: Windows reads the last keyboard or mouse input and whether the lock screen is up; macOS reads the input idle time (`ioreg`); Linux asks `loginctl` for the lock and `xprintidle` for idle time when it's installed. `ROGER_ROGER_PRESENCE=present|away|locked|unknown` forces a state, for trying the settings out.
|
|
145
|
+
- Audio players: Windows uses PowerShell (built in). macOS uses `afplay` and `say`. Linux uses one of `paplay`, `pw-play`, `aplay`, or `ffplay`, and `spd-say` or `espeak(-ng)` for the local voice.
|
|
146
|
+
- Slack, the normal way: `roger-roger install` (or `slack-setup`) creates the roger-roger app with the Slack CLI and keeps its tokens, and which app they belong to, in `~/.roger-roger/slack.json`. The app has a Home tab showing who is running, what is waiting, and buttons to snooze, stop the voice, or go to an agent's pane; an app created before it needs `slack-setup create` run once more.
|
|
147
|
+
- Slack, manually: create an app from the bundled `slack/manifest.json` at api.slack.com/apps (*Create New App* → *From a manifest*), install it, and set:
|
|
148
|
+
- `SLACK_API_BOT_TOKEN` (or `SLACK_BOT_TOKEN`): the bot token (`xoxb-…`), with `chat:write`, `im:write`, `im:history`, `reactions:write`, `files:write` for `--attach`, and `files:read` to receive files. Invite the bot to any channel it should post in.
|
|
149
|
+
- `ROGER_ROGER_SLACK_APP_TOKEN` (or `SLACK_APP_TOKEN`): an app-level token (`xapp-…`) with `connections:write`, needed for `ask`. Turn on Socket Mode and Interactivity; no public URL is needed.
|
|
150
|
+
- Gemini: an API key from Google AI Studio, in `GEMINI_API_KEY` or pasted into `roger-roger install` (kept in `~/.roger-roger/keys.json`, readable by the user only). The default model is `gemini-3.1-flash-tts-preview`; change it with `setup --model`. A missing key falls back to the computer's voice.
|
|
@@ -8,7 +8,7 @@ import path from "node:path";
|
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
import os from "node:os";
|
|
11
|
-
import { machineEnv } from "./lib.mjs";
|
|
11
|
+
import { VERSION, machineEnv } from "./lib.mjs";
|
|
12
12
|
import { lineReader, send, socketPath } from "./protocol.mjs";
|
|
13
13
|
|
|
14
14
|
const DAEMON = path.join(path.dirname(fileURLToPath(import.meta.url)), "daemon.mjs");
|
|
@@ -106,7 +106,10 @@ export function request(socket, { cmd, args = {}, session = null, onEvent }) {
|
|
|
106
106
|
}));
|
|
107
107
|
socket.on("error", (e) => done(reject, Object.assign(e, { acknowledged, parkedUntil })));
|
|
108
108
|
socket.on("close", () => done(reject, Object.assign(new Error("the daemon closed the connection"), { acknowledged, parkedUntil })));
|
|
109
|
-
|
|
109
|
+
// Which copy is asking: a daemon started from an older copy elsewhere (npx's cache, a skills.sh
|
|
110
|
+
// copy) hands over to this one rather than serving it with old code.
|
|
111
|
+
const client = { version: VERSION, daemon: DAEMON };
|
|
112
|
+
if (!send(socket, { n, cmd, args, session, client })) done(reject, new Error("could not write to the daemon"));
|
|
110
113
|
});
|
|
111
114
|
}
|
|
112
115
|
|
|
@@ -21,17 +21,20 @@ import {
|
|
|
21
21
|
CHECKS, EDIT, EDIT_CALLBACK, NOTE_CALLBACK, OTHER, OTHER_CALLBACK, SUBMIT, applyQuietHours,
|
|
22
22
|
rogerRogerHome, checkedIndices, editModal, legacyHome, loadConfigSafely, machineEnv, noteModal, otherModal, parseActionId,
|
|
23
23
|
parseProgressActionId, parseRouteActionId, pidAlive, progressBlocks, reminderText, slackAppToken,
|
|
24
|
-
slackToken, stripTags,
|
|
24
|
+
slackToken, stripTags, VERSION, olderThan, parseGotoActionId,
|
|
25
25
|
} from "./lib.mjs";
|
|
26
26
|
import { lineReader, send, socketPath, PROTOCOL_VERSION } from "./protocol.mjs";
|
|
27
27
|
import { connectSocket, openModal, permalink, postToChannel, updateMessage } from "./slack.mjs";
|
|
28
28
|
import { closeAudio, nowPlaying, onPlaybackChange, playAlert, warmUpAudio } from "./audio.mjs";
|
|
29
|
-
import { FROM_HOOKS, HANDLERS, owedTo, startTray } from "./handlers.mjs";
|
|
29
|
+
import { FROM_HOOKS, HANDLERS, owedTo, startTray, withPresence } from "./handlers.mjs";
|
|
30
|
+
import { closePresence, presence, readInput } from "./presence.mjs";
|
|
30
31
|
import * as progressStore from "./progress.mjs";
|
|
31
32
|
import * as tray from "./tray.mjs";
|
|
32
33
|
import * as sessions from "./sessions.mjs";
|
|
33
34
|
import * as inbox from "./inbox.mjs";
|
|
34
|
-
import { labelPane } from "./herdr.mjs";
|
|
35
|
+
import { focusPane, labelPane } from "./herdr.mjs";
|
|
36
|
+
import { parseHomeActionId, publishHome } from "./home.mjs";
|
|
37
|
+
import { notificationText, showDesktop } from "./desktop.mjs";
|
|
35
38
|
import { expire, isExpired, list, patchPending, read, settle, undeliveredFor } from "./decisions.mjs";
|
|
36
39
|
|
|
37
40
|
const TICK_MS = 2_000;
|
|
@@ -107,8 +110,34 @@ function refreshTray({ ifStaleMs = 0 } = {}) {
|
|
|
107
110
|
snapshotTimer.unref();
|
|
108
111
|
}
|
|
109
112
|
|
|
113
|
+
// The Slack Home tab, for everyone who has opened it (and the user DMs go to): redrawn when
|
|
114
|
+
// something moves, at most every few seconds, because each redraw is a Slack API call.
|
|
115
|
+
const HOME_EVERY_MS = 5_000;
|
|
116
|
+
const homeUsers = new Set();
|
|
117
|
+
let homeAt = 0;
|
|
118
|
+
let homeTimer = null;
|
|
119
|
+
function refreshHome() {
|
|
120
|
+
if (!homeUsers.size || homeTimer || !slackToken()) return;
|
|
121
|
+
homeTimer = setTimeout(async () => {
|
|
122
|
+
homeTimer = null;
|
|
123
|
+
homeAt = Date.now();
|
|
124
|
+
try {
|
|
125
|
+
const snap = tray.snapshot({ config: loadConfig(), daemon: { pid: process.pid, slackConnected: state.slackConnected, speaking: nowPlaying() } });
|
|
126
|
+
const pending = list().filter((d) => d.status === "pending");
|
|
127
|
+
for (const user of homeUsers) {
|
|
128
|
+
const r = await publishHome(user, snap, pending);
|
|
129
|
+
if (!r.ok) log(`home tab for ${user}: ${r.error}`);
|
|
130
|
+
}
|
|
131
|
+
} catch (e) {
|
|
132
|
+
log(`could not draw the home tab: ${e.message}`);
|
|
133
|
+
}
|
|
134
|
+
}, Math.max(0, HOME_EVERY_MS - (Date.now() - homeAt)));
|
|
135
|
+
homeTimer.unref();
|
|
136
|
+
}
|
|
137
|
+
|
|
110
138
|
const changed = (why) => {
|
|
111
139
|
refreshTray();
|
|
140
|
+
refreshHome();
|
|
112
141
|
bus.emit("change", why);
|
|
113
142
|
};
|
|
114
143
|
|
|
@@ -141,6 +170,11 @@ function ctxFor(connection, identity, about) {
|
|
|
141
170
|
log,
|
|
142
171
|
daemon: true,
|
|
143
172
|
emit: (event, data) => send(connection, { n: 1, event, data }),
|
|
173
|
+
/** Carry on with something after the reply has gone. It counts as in flight, so a handover waits for it. */
|
|
174
|
+
later(what, work) {
|
|
175
|
+
state.inFlight++;
|
|
176
|
+
work.catch((e) => log(`${what} failed: ${e.stack ?? e.message}`)).finally(() => state.inFlight--);
|
|
177
|
+
},
|
|
144
178
|
/** When this agent last ran a real command. A parked `listen` gives up once it is awake again. */
|
|
145
179
|
lastActivity() {
|
|
146
180
|
return session ? state.activity.get(session.id) ?? 0 : 0;
|
|
@@ -179,11 +213,14 @@ function ctxFor(connection, identity, about) {
|
|
|
179
213
|
}
|
|
180
214
|
|
|
181
215
|
const LOCAL = {
|
|
182
|
-
ping: () => ({ data: { ok: true, pid: process.pid, protocol: PROTOCOL_VERSION, startedAt: new Date(state.startedAt).toISOString() } }),
|
|
183
|
-
status: (ctx) => ({
|
|
216
|
+
ping: () => ({ data: { ok: true, pid: process.pid, protocol: PROTOCOL_VERSION, version: VERSION || null, startedAt: new Date(state.startedAt).toISOString() } }),
|
|
217
|
+
status: async (ctx) => ({
|
|
184
218
|
data: {
|
|
185
219
|
ok: true,
|
|
220
|
+
presence: ctx.config?.presence === "off" ? "off" : await presence({ awayAfterMs: (ctx.config?.awayAfter ?? 180) * 1000 }),
|
|
186
221
|
pid: process.pid,
|
|
222
|
+
version: VERSION || null,
|
|
223
|
+
script: fileURLToPath(import.meta.url),
|
|
187
224
|
startedAt: new Date(state.startedAt).toISOString(),
|
|
188
225
|
slackConnected: state.slackConnected,
|
|
189
226
|
sessions: sessions.live().map((s) => ({ nickname: s.nickname, swatch: s.swatch, project: s.project, state: s.state, queued: s.queue?.length ?? 0, owes: undeliveredFor(s.id).length, me: s.id === ctx.session?.id })),
|
|
@@ -203,8 +240,20 @@ const LOCAL = {
|
|
|
203
240
|
},
|
|
204
241
|
};
|
|
205
242
|
|
|
243
|
+
/**
|
|
244
|
+
* A command from a newer copy of the skill than this daemon was started from. The code-change check
|
|
245
|
+
* only watches this copy's own folder, so a newer copy somewhere else would otherwise be served by
|
|
246
|
+
* old code until somebody stopped the daemon by hand. Serve this command, then hand over to it.
|
|
247
|
+
*/
|
|
248
|
+
function handOverIfNewer(client) {
|
|
249
|
+
if (stopping || !client?.version || !VERSION || !olderThan(VERSION, client.version)) return;
|
|
250
|
+
if (typeof client.daemon !== "string" || !fs.existsSync(client.daemon)) return;
|
|
251
|
+
setTimeout(() => shutdown(`a newer copy (${client.version}, this is ${VERSION}) is in use; it takes over`, { restart: client.daemon }), 50).unref();
|
|
252
|
+
}
|
|
253
|
+
|
|
206
254
|
async function onRequest(connection, message) {
|
|
207
255
|
const { n, cmd, args = {}, session } = message;
|
|
256
|
+
handOverIfNewer(message.client);
|
|
208
257
|
const reply = (body) => send(connection, { n: n ?? 1, ...body });
|
|
209
258
|
const handler = LOCAL[cmd] ?? HANDLERS[cmd];
|
|
210
259
|
if (!handler) return reply({ ok: false, error: `unknown command "${cmd}"`, code: 2 });
|
|
@@ -277,6 +326,24 @@ async function onInteractive(payload) {
|
|
|
277
326
|
}
|
|
278
327
|
if (payload.type !== "block_actions") return;
|
|
279
328
|
for (const action of payload.actions ?? []) {
|
|
329
|
+
const home = parseHomeActionId(action.action_id);
|
|
330
|
+
if (home) {
|
|
331
|
+
const ctx = { config: loadConfig(), log, daemon: true };
|
|
332
|
+
try {
|
|
333
|
+
if (home.action === "snooze") HANDLERS.snooze(ctx, { _: [home.value ?? "1h"] });
|
|
334
|
+
if (home.action === "hush") HANDLERS.hush(ctx, {});
|
|
335
|
+
} catch (e) {
|
|
336
|
+
log(`home tab ${home.action}: ${e.message}`);
|
|
337
|
+
}
|
|
338
|
+
changed(home.action);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const goto = parseGotoActionId(action.action_id);
|
|
342
|
+
if (goto) {
|
|
343
|
+
const session = sessions.get(goto);
|
|
344
|
+
await focusPane(session).then(() => log(`focused the Herdr tab of ${session.nickname}`), (e) => log(`go to pane: ${e.message}`));
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
280
347
|
const routed = parseRouteActionId(action.action_id);
|
|
281
348
|
if (routed) {
|
|
282
349
|
await inbox.resolvePick(routed.ts, routed.sessionId, log);
|
|
@@ -350,6 +417,10 @@ async function saveAndRender(key, next) {
|
|
|
350
417
|
}
|
|
351
418
|
|
|
352
419
|
async function onEvent(event) {
|
|
420
|
+
if (event.type === "app_home_opened" && event.tab === "home" && event.user) {
|
|
421
|
+
homeUsers.add(event.user);
|
|
422
|
+
return refreshHome();
|
|
423
|
+
}
|
|
353
424
|
if (event.type === "message" && event.channel_type === "im") {
|
|
354
425
|
await inbox.ingest(event, { log, live: true, waiting: hasWaiter });
|
|
355
426
|
changed("message");
|
|
@@ -399,7 +470,7 @@ async function remindIfDue(decision) {
|
|
|
399
470
|
config = null;
|
|
400
471
|
}
|
|
401
472
|
if (!config) return;
|
|
402
|
-
const { methods } = applyQuietHours(config.methods, config);
|
|
473
|
+
const { methods } = await withPresence({}, config, applyQuietHours(config.methods, config).methods);
|
|
403
474
|
log(`${decision.id}: reminder ${sent + 1} of ${due.length}`);
|
|
404
475
|
|
|
405
476
|
const slackJob = methods.includes("slack")
|
|
@@ -407,12 +478,21 @@ async function remindIfDue(decision) {
|
|
|
407
478
|
.then((link) => postToChannel(decision.channel, { text: reminderText({ question: decision.question, permalink: link, count: sent + 1 }), color: decision.colour }))
|
|
408
479
|
.catch((e) => log(`${decision.id}: reminder post failed: ${e.message}`))
|
|
409
480
|
: null;
|
|
481
|
+
const desktopReminder = methods.includes("desktop")
|
|
482
|
+
? (() => {
|
|
483
|
+
const session = decision.sessionId ? sessions.get(decision.sessionId) : null;
|
|
484
|
+
const text = notificationText({ kind: "reminder", message: decision.question, nickname: decision.nickname ?? null });
|
|
485
|
+
return showDesktop({ ...text, goto: session?.herdr?.tab ? session.nickname : null, tag: decision.nickname ?? null })
|
|
486
|
+
.then((r) => r.ok || log(`${decision.id}: desktop reminder failed: ${r.error}`));
|
|
487
|
+
})()
|
|
488
|
+
: null;
|
|
410
489
|
try {
|
|
411
490
|
await playAlert({ methods, config, text: "[neutral] Quick reminder. I'm still waiting for your answer in Slack.", who: decision.nickname ?? null });
|
|
412
491
|
} catch (e) {
|
|
413
492
|
log(`${decision.id}: reminder audio failed: ${e.message}`);
|
|
414
493
|
}
|
|
415
494
|
await slackJob;
|
|
495
|
+
await desktopReminder;
|
|
416
496
|
}
|
|
417
497
|
|
|
418
498
|
// ---------------------------------------------------------------- the process
|
|
@@ -446,6 +526,7 @@ function shutdown(why, { restart = false } = {}) {
|
|
|
446
526
|
}
|
|
447
527
|
releaseLock();
|
|
448
528
|
closeAudio();
|
|
529
|
+
closePresence();
|
|
449
530
|
// A tray left drawing the last snapshot would show agents and questions that nobody is keeping
|
|
450
531
|
// track of any more. Saying there is no daemon lets it say so too.
|
|
451
532
|
if (snapshotTimer) clearTimeout(snapshotTimer);
|
|
@@ -455,7 +536,9 @@ function shutdown(why, { restart = false } = {}) {
|
|
|
455
536
|
// Going down anyway.
|
|
456
537
|
}
|
|
457
538
|
if (restart) {
|
|
458
|
-
|
|
539
|
+
// `restart` is true for this same copy, or the path of a newer copy's daemon to start instead.
|
|
540
|
+
const script = typeof restart === "string" ? restart : fileURLToPath(import.meta.url);
|
|
541
|
+
const child = spawn(process.execPath, [script], { cwd: os.homedir(), detached: true, stdio: "ignore", windowsHide: true, env: machineEnv() });
|
|
459
542
|
child.unref();
|
|
460
543
|
}
|
|
461
544
|
// Let anything mid-flight finish answering first. A command cut off here used to be repeated by
|
|
@@ -563,10 +646,14 @@ async function main() {
|
|
|
563
646
|
const stamp = codeStamp();
|
|
564
647
|
log(`daemon started on ${socketPath()}`);
|
|
565
648
|
warmUpAudio(); // so the first announcement of the day doesn't wait for a PowerShell to start
|
|
649
|
+
if (loadConfig()?.presence !== "off") readInput().catch(() => {}); // the same, for the idle check
|
|
566
650
|
// The tray shows a Stop button while something plays, so it has to hear when that starts and ends.
|
|
567
651
|
onPlaybackChange(() => changed("speaking"));
|
|
568
652
|
await stopLegacyDaemon().catch((e) => log(`could not stop the old daemon: ${e.message}`));
|
|
569
653
|
autostartTray();
|
|
654
|
+
// The person messages go to sees the Home tab kept current without having to open it first.
|
|
655
|
+
const target = loadConfig()?.slack?.target ?? "";
|
|
656
|
+
if (/^U[A-Z0-9]+$/.test(target)) homeUsers.add(target);
|
|
570
657
|
|
|
571
658
|
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(signal, () => shutdown(signal));
|
|
572
659
|
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// The `desktop` method: a native notification. A Windows toast, or a macOS notification, with a
|
|
2
|
+
// **Go to pane** button when the agent runs in a Herdr pane. Clicking the notification itself does
|
|
3
|
+
// the same.
|
|
4
|
+
//
|
|
5
|
+
// - Windows: a toast through WinRT, from a one-off PowerShell. Windows only shows toasts from an app
|
|
6
|
+
// it knows, so a `RogerRoger.Agents` app id is registered under HKCU (no admin, nothing
|
|
7
|
+
// installed), and so is a `roger-roger://` link handler, which is what a toast's button runs. The
|
|
8
|
+
// handler goes through wscript and open-url.js, because node started straight from a link flashes
|
|
9
|
+
// a console window.
|
|
10
|
+
// - macOS: `terminal-notifier` when it's installed (`brew install terminal-notifier`), which can run
|
|
11
|
+
// a command on click. Without it, `osascript` shows the notification, but clicking it does nothing.
|
|
12
|
+
//
|
|
13
|
+
// Elsewhere the method says it isn't supported here, and the other methods carry on.
|
|
14
|
+
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { execFile, spawnSync } from "node:child_process";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { badge } from "./names.mjs";
|
|
20
|
+
import { stripTags } from "./lib.mjs";
|
|
21
|
+
|
|
22
|
+
const SCRIPTS_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
export const CLI = path.join(SCRIPTS_DIR, "roger-roger.mjs");
|
|
24
|
+
const LAUNCHER = path.join(SCRIPTS_DIR, "open-url.js");
|
|
25
|
+
export const AUMID = "RogerRoger.Agents";
|
|
26
|
+
export const SCHEME = "roger-roger";
|
|
27
|
+
const CLASSES = "HKCU\\Software\\Classes";
|
|
28
|
+
|
|
29
|
+
const KIND_WORDS = { done: "Done", blocked: "Blocked", input: "Needs you", milestone: "Milestone", error: "Error", info: "", question: "Question", reminder: "Reminder" };
|
|
30
|
+
|
|
31
|
+
/** `roger-roger://goto/sage` → { action: "goto", who: "sage" }; anything else is null. A web page can open these links too, so only this one shape is accepted. */
|
|
32
|
+
export function parseOpenUrl(url) {
|
|
33
|
+
const m = /^roger-roger:\/\/goto\/([a-z]{2,20})\/?$/i.exec(String(url ?? "").trim());
|
|
34
|
+
return m ? { action: "goto", who: m[1].toLowerCase() } : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const gotoUrl = (nickname) => `${SCHEME}://goto/${nickname}`;
|
|
38
|
+
|
|
39
|
+
/** Slack mrkdwn as plain text: links by their words, no stars or backticks, no tone tags. */
|
|
40
|
+
export function plainText(text, max = 300) {
|
|
41
|
+
const plain = stripTags(String(text ?? ""))
|
|
42
|
+
.replace(/<([^|>]+)\|([^>]+)>/g, "$2")
|
|
43
|
+
.replace(/<(https?:[^>]+)>/g, "$1")
|
|
44
|
+
.replace(/```[\s\S]*?```/g, "(code)")
|
|
45
|
+
.replace(/[*_~`]/g, "")
|
|
46
|
+
.replace(/\s+/g, " ")
|
|
47
|
+
.trim();
|
|
48
|
+
return plain.length > max ? `${plain.slice(0, max - 1)}…` : plain;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The notification's words: who and what in the title, the message under it. */
|
|
52
|
+
export function notificationText({ kind = "info", message, nickname = null, project = "" }) {
|
|
53
|
+
const who = nickname ? badge(nickname) : "roger-roger";
|
|
54
|
+
const word = KIND_WORDS[kind] ?? "";
|
|
55
|
+
return { title: [who, word].filter(Boolean).join(" · "), body: plainText(message), footer: project || "" };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const xml = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
59
|
+
|
|
60
|
+
/** The toast, as Windows wants it. `goto` is the agent's nickname when it sits in a Herdr pane. */
|
|
61
|
+
export function toastXml({ title, body, footer, goto = null }) {
|
|
62
|
+
const launch = goto ? ` launch="${xml(gotoUrl(goto))}" activationType="protocol"` : "";
|
|
63
|
+
const actions = goto ? `<actions><action content="Go to pane" activationType="protocol" arguments="${xml(gotoUrl(goto))}"/></actions>` : "";
|
|
64
|
+
const attribution = footer ? `<text placement="attribution">${xml(footer)}</text>` : "";
|
|
65
|
+
return `<toast${launch}><visual><binding template="ToastGeneric"><text>${xml(title)}</text><text>${xml(body)}</text>${attribution}</binding></visual>${actions}</toast>`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------- Windows registration
|
|
69
|
+
|
|
70
|
+
const reg = (args) => spawnSync("reg.exe", args, { encoding: "utf8", windowsHide: true, timeout: 10_000 });
|
|
71
|
+
|
|
72
|
+
/** What the link handler should run: wscript, the launcher, node, this copy's CLI. */
|
|
73
|
+
export function handlerCommand(node = process.execPath, cli = CLI, launcher = LAUNCHER) {
|
|
74
|
+
return `wscript.exe //nologo //E:jscript "${launcher}" "${node}" "${cli}" "%1"`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let registeredAs = null;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The app id toasts are shown under, and the link handler their buttons run. Written when missing or
|
|
81
|
+
* pointing at another copy, once per process; it is HKCU only, so no admin is needed.
|
|
82
|
+
*/
|
|
83
|
+
export function registerWindows() {
|
|
84
|
+
const command = handlerCommand();
|
|
85
|
+
if (registeredAs === command) return { ok: true };
|
|
86
|
+
const current = reg(["query", `${CLASSES}\\${SCHEME}\\shell\\open\\command`, "/ve"]).stdout ?? "";
|
|
87
|
+
const writes = [];
|
|
88
|
+
if (!current.includes(command)) {
|
|
89
|
+
writes.push(
|
|
90
|
+
["add", `${CLASSES}\\${SCHEME}`, "/ve", "/d", "URL:roger-roger", "/f"],
|
|
91
|
+
["add", `${CLASSES}\\${SCHEME}`, "/v", "URL Protocol", "/d", "", "/f"],
|
|
92
|
+
["add", `${CLASSES}\\${SCHEME}\\shell\\open\\command`, "/ve", "/d", command, "/f"],
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
if (reg(["query", `${CLASSES}\\AppUserModelId\\${AUMID}`, "/v", "DisplayName"]).status !== 0) {
|
|
96
|
+
writes.push(["add", `${CLASSES}\\AppUserModelId\\${AUMID}`, "/v", "DisplayName", "/d", "roger-roger", "/f"]);
|
|
97
|
+
}
|
|
98
|
+
for (const args of writes) {
|
|
99
|
+
const r = reg(args);
|
|
100
|
+
if (r.status !== 0) return { ok: false, error: `could not register with Windows: ${(r.stderr || r.stdout || "").trim()}` };
|
|
101
|
+
}
|
|
102
|
+
registeredAs = command;
|
|
103
|
+
return { ok: true, ...(writes.length ? { registered: true } : {}) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Take both registrations out again, for uninstall. */
|
|
107
|
+
export function unregisterWindows() {
|
|
108
|
+
if (process.platform !== "win32") return { ok: true, removed: false };
|
|
109
|
+
const a = reg(["delete", `${CLASSES}\\${SCHEME}`, "/f"]).status === 0;
|
|
110
|
+
const b = reg(["delete", `${CLASSES}\\AppUserModelId\\${AUMID}`, "/f"]).status === 0;
|
|
111
|
+
registeredAs = null;
|
|
112
|
+
return { ok: true, removed: a || b };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const PS_TOAST = `
|
|
116
|
+
$ErrorActionPreference = 'Stop'
|
|
117
|
+
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
|
118
|
+
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
|
|
119
|
+
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
|
|
120
|
+
$xml.LoadXml($env:RR_TOAST)
|
|
121
|
+
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
|
|
122
|
+
if ($env:RR_TAG) { $toast.Tag = $env:RR_TAG; $toast.Group = 'roger-roger' }
|
|
123
|
+
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($env:RR_AUMID).Show($toast)
|
|
124
|
+
`;
|
|
125
|
+
|
|
126
|
+
const run = (cmd, args, env) => new Promise((resolve) => {
|
|
127
|
+
execFile(cmd, args, { encoding: "utf8", timeout: 20_000, windowsHide: true, env: { ...process.env, ...env } }, (error, stdout, stderr) => {
|
|
128
|
+
resolve(error ? { ok: false, error: (stderr || error.message).trim().split(/\r?\n/).slice(-2).join(" ") } : { ok: true });
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
function onPath(name) {
|
|
133
|
+
const r = spawnSync(process.platform === "win32" ? "where" : "which", [name], { encoding: "utf8", windowsHide: true, timeout: 5_000 });
|
|
134
|
+
return r.status === 0 ? r.stdout.split(/\r?\n/)[0].trim() : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const appleString = (s) => `"${String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Show one. `goto` is the agent's nickname when it is in a Herdr pane, which adds the button; `tag`
|
|
141
|
+
* groups an agent's notifications so a newer one replaces the older in the Windows list.
|
|
142
|
+
*/
|
|
143
|
+
export async function showDesktop({ title, body, footer = "", goto = null, tag = null, platform = process.platform }) {
|
|
144
|
+
if (platform === "win32") {
|
|
145
|
+
const registered = registerWindows();
|
|
146
|
+
if (!registered.ok) return registered;
|
|
147
|
+
const r = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", PS_TOAST], {
|
|
148
|
+
RR_TOAST: toastXml({ title, body, footer, goto }),
|
|
149
|
+
RR_AUMID: AUMID,
|
|
150
|
+
RR_TAG: tag ? String(tag).slice(0, 60) : "",
|
|
151
|
+
});
|
|
152
|
+
return r.ok ? { ok: true, ...(goto ? { goto: true } : {}) } : r;
|
|
153
|
+
}
|
|
154
|
+
if (platform === "darwin") {
|
|
155
|
+
const notifier = onPath("terminal-notifier");
|
|
156
|
+
if (notifier) {
|
|
157
|
+
const args = ["-title", title, "-message", body || " ", "-group", `roger-roger-${tag ?? "all"}`];
|
|
158
|
+
if (footer) args.push("-subtitle", footer);
|
|
159
|
+
if (goto) args.push("-execute", `"${process.execPath}" "${CLI}" open-url "${gotoUrl(goto)}"`);
|
|
160
|
+
const r = await run(notifier, args);
|
|
161
|
+
return r.ok ? { ok: true, ...(goto ? { goto: true } : {}) } : r;
|
|
162
|
+
}
|
|
163
|
+
const script = `display notification ${appleString(body || " ")} with title ${appleString(title)}${footer ? ` subtitle ${appleString(footer)}` : ""}`;
|
|
164
|
+
const r = await run("osascript", ["-e", script]);
|
|
165
|
+
return r.ok ? { ok: true, ...(goto ? { note: "install terminal-notifier (brew install terminal-notifier) for the Go to pane button" } : {}) } : r;
|
|
166
|
+
}
|
|
167
|
+
return { ok: false, error: "desktop notifications work on Windows and macOS" };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Is the launcher there? A copy without it can't make a toast's button work. */
|
|
171
|
+
export const launcherPresent = () => fs.existsSync(LAUNCHER);
|