pi-cue 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 purboo
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,80 @@
1
- # Temporary Holding Version
1
+ # pi-cue
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **A tap on your wrist when pi is done, or needs you.**
4
+
5
+ Pushes to your iPhone and Apple Watch through [Bark](https://github.com/Finb/Bark), but only when you are away. If you are at the keyboard, nothing happens.
6
+
7
+ ## Setup
8
+
9
+ 1. Install **Bark** from the App Store and allow notifications.
10
+ 2. `pi install npm:pi-cue`
11
+ 3. In pi: `/cue key <the URL Bark shows>`, then `/cue test`.
12
+
13
+ The key is stored in `~/.pi/agent/cue.json` (mode 600). `PI_CUE_KEY` and `PI_CUE_SERVER` work too.
14
+
15
+ ## Use
16
+
17
+ Cues are **off** in every session until you turn them on, so parallel sessions don't all tap you.
18
+
19
+ | | |
20
+ |---|---|
21
+ | `/cue` | Toggle for this session. Remembered when you resume it. |
22
+ | `/cue on` · `/cue off` | Explicit. |
23
+ | `/cue test` | Send a sample. |
24
+ | `/cue status` | On/off, key, server. |
25
+
26
+ While on, `cue` shows in the status line.
27
+
28
+ ## What you get
29
+
30
+ | | When | Level |
31
+ |---|---|---|
32
+ | `● repo` · Needs you | pi waits on a confirm / select / input for 8s, or the reply ends on a question | time-sensitive |
33
+ | `● repo` · Still waiting | the same decision is still open after 10 min (once) | time-sensitive |
34
+ | `✗ repo` · Failed | a run failed | time-sensitive |
35
+ | `✓ repo` · Done · 3m12s | pi stopped 15s ago, and you had been away 30s or more | normal |
36
+
37
+ The body is the question, the error, or the first line of the answer. The time is how long since you last touched pi.
38
+
39
+ ## Background subagents
40
+
41
+ When a background subagent wakes the agent for a quick wrap-up, that still cues: away time counts from your last key press, not from the start of the run. A subagent's question, relayed by the agent as a reply ending in `?`, cues as *Needs you*.
42
+
43
+ pi does not tell extensions about background work, so the turn that dispatches subagents cues once as *Done* (the body usually says what was dispatched), and each subagent that wakes the agent replaces that notification and taps again.
44
+
45
+ ## Quiet by design
46
+
47
+ - **Away only.** A cue waits out a grace period and is dropped if you pressed a key around that time. Escape-aborted runs never cue.
48
+ - **One per session.** Each session replaces its own notification instead of stacking.
49
+ - **Cleans up.** Answering, typing, starting a new run, or quitting removes the notification from the phone and watch. Removal needs Bark's *Background App Refresh*.
50
+ - **Never in the way.** Network errors are swallowed. Nested and headless sessions never cue.
51
+
52
+ The Watch only gets iPhone notifications while the iPhone is locked.
53
+
54
+ ## Config
55
+
56
+ `~/.pi/agent/cue.json`, all optional except `key`:
57
+
58
+ ```json
59
+ {
60
+ "key": "…",
61
+ "server": "https://api.day.app",
62
+ "default": "on",
63
+ "detail": false,
64
+ "graceMs": 15000,
65
+ "askGraceMs": 8000,
66
+ "minRunMs": 30000,
67
+ "remindMs": 600000
68
+ }
69
+ ```
70
+
71
+ `"default": "on"` (or `PI_CUE=1`) starts new sessions with cues on. `"detail": false` sends only the project and state, never text from the session; worth it on the public Bark server. `remindMs: 0` disables the reminder. `minRunMs` is the away time needed before *Done* or a question cues.
72
+
73
+ ## Development
74
+
75
+ ```bash
76
+ bun install
77
+ bun run link-pi
78
+ bun run check # tsc + bun test
79
+ pi -e ./index.ts # try it
80
+ ```
package/index.ts ADDED
@@ -0,0 +1,174 @@
1
+ /**
2
+ * pi-cue: a tap on the wrist when pi is done, or needs you.
3
+ *
4
+ * Off by default per session; `/cue` turns it on for the current one.
5
+ * All logic lives in src/core.ts; this file only wires pi events to it.
6
+ */
7
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
+ import { type Config, loadConfig, parseKey, push, remove, saveKey } from "./src/bark.ts";
9
+ import { type Cue, createCue, isHumanKey, type Note } from "./src/core.ts";
10
+ import { isChinese, question, render, repoName } from "./src/text.ts";
11
+
12
+ const ENTRY = "pi-cue";
13
+ const STATUS = "cue";
14
+
15
+ interface MessageLike {
16
+ role?: string;
17
+ stopReason?: string;
18
+ errorMessage?: string;
19
+ content?: unknown;
20
+ }
21
+
22
+ function textOf(content: unknown): string {
23
+ if (typeof content === "string") return content;
24
+ if (!Array.isArray(content)) return "";
25
+ return content
26
+ .map((p) => (p && (p as { type?: string }).type === "text" ? String((p as { text?: unknown }).text ?? "") : ""))
27
+ .join("\n");
28
+ }
29
+
30
+ export default function piCue(pi: ExtensionAPI): void {
31
+ let cfg: Config = loadConfig();
32
+ let cwd = process.cwd();
33
+ let sessionId = "";
34
+ let unlisten: (() => void) | undefined;
35
+ let last: { failed: boolean; text: string } = { failed: false, text: "" };
36
+ const zh = isChinese();
37
+
38
+ const id = () => `cue-${sessionId}`;
39
+
40
+ const cue: Cue = createCue(
41
+ {
42
+ now: Date.now,
43
+ after(ms, fn) {
44
+ const t = setTimeout(fn, ms);
45
+ t.unref?.(); // never keep pi alive for a tap
46
+ return () => clearTimeout(t);
47
+ },
48
+ send(note: Note) {
49
+ const m = render(note, cwd, { zh, detail: cfg.detail });
50
+ void push(cfg, { id: id(), group: repoName(cwd), ...m });
51
+ },
52
+ withdraw() {
53
+ void remove(cfg, id());
54
+ },
55
+ },
56
+ cfg.timing,
57
+ );
58
+
59
+ const showStatus = (ctx: ExtensionContext) => {
60
+ if (ctx.hasUI) ctx.ui.setStatus(STATUS, cue.isEnabled() ? "cue" : undefined);
61
+ };
62
+
63
+ const restored = (ctx: ExtensionContext): boolean | undefined => {
64
+ const branch = ctx.sessionManager.getBranch();
65
+ for (let i = branch.length - 1; i >= 0; i--) {
66
+ const e = branch[i] as { type?: string; customType?: string; data?: { on?: boolean } };
67
+ if (e.type === "custom" && e.customType === ENTRY && typeof e.data?.on === "boolean") return e.data.on;
68
+ }
69
+ return undefined;
70
+ };
71
+
72
+ pi.on("session_start", (_e, ctx) => {
73
+ cue.reset();
74
+ unlisten?.();
75
+ unlisten = undefined;
76
+ cfg = loadConfig();
77
+ cwd = ctx.cwd;
78
+ sessionId = ctx.sessionManager.getSessionId().slice(0, 8);
79
+ last = { failed: false, text: "" };
80
+ if (!ctx.hasUI) return; // nested and headless sessions never tap
81
+ unlisten = ctx.ui.onTerminalInput((data) => {
82
+ if (isHumanKey(data)) cue.input();
83
+ return undefined;
84
+ });
85
+ cue.setEnabled((restored(ctx) ?? cfg.on) && !!cfg.key);
86
+ showStatus(ctx);
87
+ });
88
+
89
+ pi.on("agent_start", (_e, ctx) => {
90
+ cwd = ctx.cwd;
91
+ last = { failed: false, text: "" };
92
+ cue.runStart();
93
+ });
94
+
95
+ // agent_end fires per attempt and pi may still retry; agent_settled is final.
96
+ pi.on("agent_end", (e) => {
97
+ const msg = [...e.messages].reverse().find((m) => (m as MessageLike).role === "assistant") as
98
+ | MessageLike
99
+ | undefined;
100
+ const failed = msg?.stopReason === "error";
101
+ last = { failed, text: failed ? (msg?.errorMessage ?? "") : textOf(msg?.content) };
102
+ });
103
+
104
+ pi.on("agent_settled", (e, ctx) => {
105
+ cwd = ctx.cwd;
106
+ cue.settled({
107
+ aborted: e.aborted,
108
+ failed: last.failed,
109
+ text: last.text,
110
+ question: last.failed ? undefined : question(last.text),
111
+ });
112
+ });
113
+
114
+ pi.on("ui_prompt_start", (e) => cue.promptStart(e.title));
115
+ pi.on("ui_prompt_end", () => cue.promptEnd());
116
+
117
+ pi.on("session_shutdown", async () => {
118
+ unlisten?.();
119
+ unlisten = undefined;
120
+ // Quitting means you are here: clear what is still on the phone, and wait for it
121
+ // so the request is not cut off by the process exiting.
122
+ const onPhone = cue.isLive();
123
+ cue.reset();
124
+ if (onPhone) await remove(cfg, id());
125
+ });
126
+
127
+ const say = (ctx: ExtensionContext, msg: string, type: "info" | "warning" | "error" = "info") =>
128
+ ctx.ui.notify(msg, type);
129
+
130
+ const setOn = (ctx: ExtensionContext, on: boolean) => {
131
+ if (on && !cfg.key) {
132
+ say(ctx, zh ? "还没有 Bark key。先运行 /cue key <key 或 Bark 显示的 URL>" : "No Bark key yet. Run /cue key <key or the URL Bark shows>", "warning");
133
+ return;
134
+ }
135
+ cue.setEnabled(on);
136
+ pi.appendEntry(ENTRY, { on });
137
+ showStatus(ctx);
138
+ say(ctx, on ? (zh ? "cue 已开启:完成或需要你时会推送" : "cue on: you will be tapped when pi is done or needs you") : zh ? "cue 已关闭" : "cue off");
139
+ };
140
+
141
+ pi.registerCommand("cue", {
142
+ description: "Tap on the wrist when pi is done or needs you (on | off | test | key <key>)",
143
+ getArgumentCompletions: (prefix) =>
144
+ ["on", "off", "test", "key", "status"].filter((s) => s.startsWith(prefix)).map((s) => ({ value: s, label: s })),
145
+ handler: async (args, ctx) => {
146
+ const [sub = "", ...rest] = args.trim().split(/\s+/);
147
+ switch (sub.toLowerCase()) {
148
+ case "":
149
+ return setOn(ctx, !cue.isEnabled());
150
+ case "on":
151
+ return setOn(ctx, true);
152
+ case "off":
153
+ return setOn(ctx, false);
154
+ case "key": {
155
+ const found = parseKey(rest.join(" "));
156
+ if (!found) return say(ctx, zh ? "没认出这个 key。粘贴 Bark App 首页的 URL 或 key。" : "That does not look like a Bark key or URL.", "error");
157
+ saveKey(found);
158
+ cfg = loadConfig();
159
+ return say(ctx, zh ? "key 已保存。运行 /cue test 试一条。" : "Key saved. Run /cue test to try one.");
160
+ }
161
+ case "test": {
162
+ if (!cfg.key) return say(ctx, zh ? "还没有 key,先运行 /cue key" : "No key yet. Run /cue key first.", "warning");
163
+ const m = render({ kind: "done", elapsedMs: 83_000, text: zh ? "这是一条测试" : "This is a test" }, cwd, { zh, detail: true });
164
+ const ok = await push(cfg, { id: `${id()}-test`, group: repoName(cwd), ...m });
165
+ return say(ctx, ok ? (zh ? "已发送" : "Sent") : zh ? "发送失败,检查 key 和网络" : "Send failed; check the key and network", ok ? "info" : "error");
166
+ }
167
+ case "status":
168
+ return say(ctx, `cue ${cue.isEnabled() ? "on" : "off"} · key ${cfg.key ? "set" : "missing"} · ${cfg.server}`);
169
+ default:
170
+ return say(ctx, "/cue [on | off | test | key <key> | status]", "warning");
171
+ }
172
+ },
173
+ });
174
+ }
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "pi-cue",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "A tap on your wrist when pi is done, or needs you. Bark push to iPhone and Apple Watch, only when you are away.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "purboo",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/purboo/pi-cue.git"
11
+ },
12
+ "homepage": "https://github.com/purboo/pi-cue#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/purboo/pi-cue/issues"
15
+ },
16
+ "keywords": [
17
+ "pi-package",
18
+ "pi-extension",
19
+ "notification",
20
+ "bark",
21
+ "apple-watch"
22
+ ],
23
+ "files": [
24
+ "index.ts",
25
+ "src"
26
+ ],
27
+ "pi": {
28
+ "extensions": [
29
+ "./index.ts"
30
+ ]
31
+ },
32
+ "scripts": {
33
+ "test": "bun test",
34
+ "typecheck": "tsc --noEmit",
35
+ "check": "tsc --noEmit && bun test",
36
+ "link-pi": "D=\"$(dirname \"$(readlink -f \"$(command -v pi)\")\")/../../..\" && mkdir -p node_modules/@earendil-works && for p in pi-ai pi-coding-agent pi-tui; do ln -sfn \"$(cd \"$D/$p\" && pwd)\" node_modules/@earendil-works/$p; done"
37
+ },
38
+ "peerDependencies": {
39
+ "@earendil-works/pi-ai": "*",
40
+ "@earendil-works/pi-coding-agent": "*",
41
+ "@earendil-works/pi-tui": "*"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^26.6.4",
45
+ "typescript": "5"
46
+ }
47
+ }
package/src/bark.ts ADDED
@@ -0,0 +1,109 @@
1
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { DEFAULT_TIMING, type Timing } from "./core.ts";
5
+
6
+ export const DEFAULT_SERVER = "https://api.day.app";
7
+
8
+ export interface Config {
9
+ key: string;
10
+ server: string;
11
+ /** Whether new sessions start with cues on. */
12
+ on: boolean;
13
+ /** Include the result / question text. Off sends only the project and state. */
14
+ detail: boolean;
15
+ timing: Timing;
16
+ }
17
+
18
+ export function configPath(env: NodeJS.ProcessEnv = process.env): string {
19
+ return join(env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"), "cue.json");
20
+ }
21
+
22
+ const positive = (v: unknown, fallback: number, allowZero = false): number =>
23
+ typeof v === "number" && Number.isFinite(v) && (allowZero ? v >= 0 : v > 0) ? v : fallback;
24
+
25
+ export function loadConfig(env: NodeJS.ProcessEnv = process.env, path = configPath(env)): Config {
26
+ let file: Record<string, unknown> = {};
27
+ try {
28
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
29
+ if (parsed && typeof parsed === "object") file = parsed;
30
+ } catch {
31
+ // no file yet
32
+ }
33
+ const str = (v: unknown) => (typeof v === "string" && v.trim() ? v.trim() : "");
34
+ const t = DEFAULT_TIMING;
35
+ return {
36
+ key: str(env.PI_CUE_KEY) || str(file.key),
37
+ server: (str(env.PI_CUE_SERVER) || str(file.server) || DEFAULT_SERVER).replace(/\/+$/, ""),
38
+ on: env.PI_CUE === "1" || file.default === "on",
39
+ detail: file.detail !== false,
40
+ timing: {
41
+ graceMs: positive(file.graceMs, t.graceMs),
42
+ askGraceMs: positive(file.askGraceMs, t.askGraceMs),
43
+ minRunMs: positive(file.minRunMs, t.minRunMs, true),
44
+ remindMs: positive(file.remindMs, t.remindMs, true),
45
+ },
46
+ };
47
+ }
48
+
49
+ /** Accepts a bare key or the URL the Bark app shows (`https://host/KEY` or `https://host/KEY/...`). */
50
+ export function parseKey(input: string): { key: string; server?: string } | undefined {
51
+ const text = input.trim();
52
+ if (!text) return undefined;
53
+ if (/^https?:\/\//i.test(text)) {
54
+ try {
55
+ const url = new URL(text);
56
+ const key = url.pathname.split("/").filter(Boolean)[0];
57
+ return key ? { key, server: url.origin } : undefined;
58
+ } catch {
59
+ return undefined;
60
+ }
61
+ }
62
+ return /^[\w-]{6,}$/.test(text) ? { key: text } : undefined;
63
+ }
64
+
65
+ /** Saves the key without disturbing other settings; the file holds a secret, so owner-only. */
66
+ export function saveKey(found: { key: string; server?: string }, path = configPath()): void {
67
+ let file: Record<string, unknown> = {};
68
+ try {
69
+ file = JSON.parse(readFileSync(path, "utf8"));
70
+ } catch {
71
+ // first save
72
+ }
73
+ file.key = found.key;
74
+ if (found.server && found.server !== DEFAULT_SERVER) file.server = found.server;
75
+ else delete file.server;
76
+ mkdirSync(dirname(path), { recursive: true });
77
+ writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
78
+ chmodSync(path, 0o600);
79
+ }
80
+
81
+ export interface Push {
82
+ id: string;
83
+ group: string;
84
+ title: string;
85
+ subtitle: string;
86
+ body: string;
87
+ level: string;
88
+ }
89
+
90
+ async function post(cfg: Config, payload: Record<string, unknown>, fetcher: typeof fetch): Promise<boolean> {
91
+ if (!cfg.key) return false;
92
+ try {
93
+ const res = await fetcher(`${cfg.server}/push`, {
94
+ method: "POST",
95
+ headers: { "content-type": "application/json; charset=utf-8" },
96
+ body: JSON.stringify({ device_key: cfg.key, ...payload }),
97
+ signal: AbortSignal.timeout(4_000),
98
+ });
99
+ return res.ok;
100
+ } catch {
101
+ return false; // best effort: a missed tap must never disturb pi
102
+ }
103
+ }
104
+
105
+ export const push = (cfg: Config, p: Push, fetcher: typeof fetch = fetch) => post(cfg, { ...p }, fetcher);
106
+
107
+ /** Deleting is a silent push, so Bark needs Background App Refresh to act on it. */
108
+ export const remove = (cfg: Config, id: string, fetcher: typeof fetch = fetch) =>
109
+ post(cfg, { id, delete: "1" }, fetcher);
package/src/core.ts ADDED
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The decision logic, free of pi and the network. Everything time-related and
3
+ * every side effect is injected, so it can be tested with a fake clock.
4
+ *
5
+ * The rule of thumb: a cue is a tap on the wrist, so it must only fire when
6
+ * you are away. Every cue therefore waits a grace period, and is dropped if
7
+ * you touched the keyboard around that time or the situation resolved itself.
8
+ */
9
+
10
+ export type Kind = "done" | "fail" | "ask";
11
+
12
+ export interface Note {
13
+ kind: Kind;
14
+ /** Time since you last touched pi (done/fail/ask), or time spent waiting (reminder). */
15
+ elapsedMs?: number;
16
+ /** One-line detail: result summary, error, or the question being asked. */
17
+ text?: string;
18
+ /** Second and last nudge for a decision that nobody answered. */
19
+ remind?: boolean;
20
+ }
21
+
22
+ export interface Timing {
23
+ /** Wait before announcing a finished run. */
24
+ graceMs: number;
25
+ /** Wait before announcing a decision; shorter, because the agent is blocked. */
26
+ askGraceMs: number;
27
+ /**
28
+ * Only cue once you have been away this long, counted from your last key press,
29
+ * so a quick wrap-up run woken by a background subagent still counts. Failures always cue.
30
+ */
31
+ minRunMs: number;
32
+ /** Nudge once more if a decision is still open after this long. 0 = never. */
33
+ remindMs: number;
34
+ }
35
+
36
+ export const DEFAULT_TIMING: Timing = {
37
+ graceMs: 15_000,
38
+ askGraceMs: 8_000,
39
+ minRunMs: 30_000,
40
+ remindMs: 10 * 60_000,
41
+ };
42
+
43
+ export interface Deps {
44
+ now(): number;
45
+ /** Run `fn` after `ms`; returns a canceller. */
46
+ after(ms: number, fn: () => void): () => void;
47
+ send(note: Note): void;
48
+ /** Remove whatever this session last put on the phone. */
49
+ withdraw(): void;
50
+ }
51
+
52
+ /** Keystrokes this recent before an event still count as "you are here". */
53
+ const PRESENT_MS = 5_000;
54
+
55
+ export interface Cue {
56
+ setEnabled(on: boolean): void;
57
+ isEnabled(): boolean;
58
+ /** A cue from this session is currently on the phone. */
59
+ isLive(): boolean;
60
+ /** A human key press. */
61
+ input(): void;
62
+ /** `agent_start`. */
63
+ runStart(): void;
64
+ /**
65
+ * `agent_settled`. `question` is set when the reply ends by asking you something:
66
+ * the ball is in your court, so it cues like a dialog does.
67
+ */
68
+ settled(run: { aborted: boolean; failed: boolean; text?: string; question?: string }): void;
69
+ promptStart(title?: string): void;
70
+ promptEnd(): void;
71
+ /** New session in the same process: forget everything, touch nothing remote. */
72
+ reset(): void;
73
+ }
74
+
75
+ export function createCue(deps: Deps, timing: Timing = DEFAULT_TIMING): Cue {
76
+ let enabled = false;
77
+ let lastInput = Number.NEGATIVE_INFINITY;
78
+ let startedAt: number | null = null;
79
+ let depth = 0;
80
+ let live = false;
81
+ /** `prompt`: waits on a dialog, so only the dialog closing resolves it, not a run boundary. */
82
+ let pending: { prompt: boolean; cancel: () => void } | null = null;
83
+ let cancelRemind: (() => void) | null = null;
84
+
85
+ const drop = () => {
86
+ pending?.cancel();
87
+ pending = null;
88
+ };
89
+
90
+ const withdraw = () => {
91
+ cancelRemind?.();
92
+ cancelRemind = null;
93
+ if (!live) return;
94
+ live = false;
95
+ deps.withdraw();
96
+ };
97
+
98
+ const dropRun = () => {
99
+ if (pending && !pending.prompt) drop();
100
+ };
101
+
102
+ const schedule = (graceMs: number, note: Note, prompt = false) => {
103
+ drop();
104
+ const at = deps.now();
105
+ const cancel = deps.after(graceMs, () => {
106
+ pending = null;
107
+ if (lastInput >= at - PRESENT_MS) return; // you are at the keyboard
108
+ live = true;
109
+ deps.send(note);
110
+ if (note.kind === "ask" && timing.remindMs > 0) {
111
+ cancelRemind = deps.after(timing.remindMs, () => {
112
+ cancelRemind = null;
113
+ deps.send({ ...note, remind: true, elapsedMs: timing.remindMs });
114
+ });
115
+ }
116
+ });
117
+ pending = { prompt, cancel };
118
+ };
119
+
120
+ return {
121
+ setEnabled(on) {
122
+ enabled = on;
123
+ if (!on) {
124
+ drop();
125
+ withdraw();
126
+ }
127
+ },
128
+ isEnabled: () => enabled,
129
+ isLive: () => live,
130
+
131
+ input() {
132
+ lastInput = deps.now();
133
+ // Typing means you have seen it; take it off the phone.
134
+ if (live) withdraw();
135
+ },
136
+
137
+ runStart() {
138
+ // Runs can restart on retry; keep the first start so the duration is the whole run.
139
+ if (startedAt === null) startedAt = deps.now();
140
+ dropRun();
141
+ withdraw();
142
+ },
143
+
144
+ settled({ aborted, failed, text, question }) {
145
+ const started = startedAt;
146
+ startedAt = null;
147
+ dropRun();
148
+ if (!enabled || aborted) return;
149
+ const now = deps.now();
150
+ const since = Number.isFinite(lastInput) ? lastInput : (started ?? now);
151
+ const elapsedMs = now - since;
152
+ if (failed) return schedule(timing.graceMs, { kind: "fail", elapsedMs, text });
153
+ if (elapsedMs < timing.minRunMs) return;
154
+ if (question) return schedule(timing.askGraceMs, { kind: "ask", elapsedMs, text: question });
155
+ schedule(timing.graceMs, { kind: "done", elapsedMs, text });
156
+ },
157
+
158
+ promptStart(title) {
159
+ depth += 1;
160
+ if (!enabled || depth > 1) return;
161
+ schedule(timing.askGraceMs, { kind: "ask", text: title }, true);
162
+ },
163
+
164
+ promptEnd() {
165
+ depth = Math.max(0, depth - 1);
166
+ if (depth > 0) return;
167
+ if (pending?.prompt) drop();
168
+ withdraw();
169
+ },
170
+
171
+ reset() {
172
+ drop();
173
+ cancelRemind?.();
174
+ cancelRemind = null;
175
+ live = false;
176
+ startedAt = null;
177
+ depth = 0;
178
+ },
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Raw terminal input also carries things you did not type: focus reports,
184
+ * mouse events, and replies to terminal queries. Only real keys show presence.
185
+ */
186
+ export function isHumanKey(data: string): boolean {
187
+ if (!data) return false;
188
+ if (/^\x1b\[[IO]$/.test(data)) return false; // focus in / out
189
+ if (/^\x1b\[[<?>]/.test(data)) return false; // SGR mouse, DEC and DA replies
190
+ if (/^\x1b\[\d+;\d+R$/.test(data)) return false; // cursor position report
191
+ if (/^\x1b[\]P_^]/.test(data)) return false; // OSC, DCS, APC, PM replies
192
+ return true;
193
+ }
package/src/text.ts ADDED
@@ -0,0 +1,88 @@
1
+ import { basename } from "node:path";
2
+ import type { Note } from "./core.ts";
3
+
4
+ export type Level = "active" | "timeSensitive";
5
+
6
+ export interface Message {
7
+ title: string;
8
+ subtitle: string;
9
+ body: string;
10
+ level: Level;
11
+ }
12
+
13
+ const COPY = {
14
+ zh: { done: "完成", fail: "失败", ask: "需要你", remind: "仍在等你", waiting: "等你回复" },
15
+ en: { done: "Done", fail: "Failed", ask: "Needs you", remind: "Still waiting", waiting: "Waiting for your reply" },
16
+ };
17
+
18
+ const MARK = { done: "✓", fail: "✗", ask: "●" } as const;
19
+
20
+ export function isChinese(env: NodeJS.ProcessEnv = process.env): boolean {
21
+ const hint = env.LC_ALL || env.LC_MESSAGES || env.LANG || Intl.DateTimeFormat().resolvedOptions().locale;
22
+ return /^zh/i.test(hint);
23
+ }
24
+
25
+ /** `45s`, `2m14s`, `1h3m`. */
26
+ export function duration(ms: number): string {
27
+ const total = Math.max(0, Math.round(ms / 1000));
28
+ if (total < 60) return `${total}s`;
29
+ const m = Math.floor(total / 60);
30
+ const s = total % 60;
31
+ if (m < 60) return s ? `${m}m${s}s` : `${m}m`;
32
+ const h = Math.floor(m / 60);
33
+ return m % 60 ? `${h}h${m % 60}m` : `${h}h`;
34
+ }
35
+
36
+ /** First meaningful line, stripped of markdown noise, cut to `max` characters. */
37
+ const clean = (raw: string) =>
38
+ raw
39
+ .replace(/^\s*(?:#{1,6}|>|[-*+]|\d+[.)])\s+/, "")
40
+ .replace(/[`*_~]/g, "")
41
+ .replace(/\s+/g, " ")
42
+ .trim();
43
+
44
+ const clip = (line: string, max: number) => {
45
+ const chars = Array.from(line);
46
+ return chars.length <= max ? line : `${chars.slice(0, max - 1).join("").trimEnd()}…`;
47
+ };
48
+
49
+ export function oneLine(text: string | undefined, max = 90): string {
50
+ if (!text) return "";
51
+ for (const raw of text.split(/\r?\n/)) {
52
+ const line = clean(raw);
53
+ if (line) return clip(line, max);
54
+ }
55
+ return "";
56
+ }
57
+
58
+ /**
59
+ * The question a reply ends on, if it ends on one: "Merge it now?" at the end means
60
+ * the agent is waiting for you even though no dialog opened. Heuristic by design.
61
+ */
62
+ export function question(text: string | undefined, max = 90): string | undefined {
63
+ if (!text) return undefined;
64
+ const lines = text.split(/\r?\n/).map(clean).filter(Boolean);
65
+ const last = lines.at(-1)?.replace(/[\s))"'”’]+$/, "");
66
+ if (!last || !/[??]$/.test(last)) return undefined;
67
+ // Keep only the final sentence of a long line.
68
+ const sentence = last.split(/(?<=[.!。!??])\s*/).filter(Boolean).at(-1) ?? last;
69
+ return clip(sentence, max);
70
+ }
71
+
72
+ export function repoName(cwd: string): string {
73
+ return basename(cwd) || cwd;
74
+ }
75
+
76
+ export function render(note: Note, cwd: string, opts: { zh: boolean; detail: boolean }): Message {
77
+ const c = opts.zh ? COPY.zh : COPY.en;
78
+ const label = note.remind ? c.remind : c[note.kind];
79
+ const subtitle = note.elapsedMs && note.elapsedMs >= 1000 ? `${label} · ${duration(note.elapsedMs)}` : label;
80
+ const detail = opts.detail ? oneLine(note.text) : "";
81
+ const body = detail || (note.kind === "ask" && opts.detail ? c.waiting : "");
82
+ return {
83
+ title: `${MARK[note.kind]} ${repoName(cwd)}`,
84
+ subtitle,
85
+ body,
86
+ level: note.kind === "done" ? "active" : "timeSensitive",
87
+ };
88
+ }