@ccmsg/cli 0.11.4 → 0.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ccmsg/cli",
3
- "version": "0.11.4",
3
+ "version": "0.12.0",
4
4
  "description": "The ccmsg daemon, CLI and agent plugins for one instance (= one config home)",
5
5
  "license": "MIT",
6
6
  "author": "kawaz",
package/src/cli.ts CHANGED
@@ -91,9 +91,9 @@ function ownSid(): string | undefined {
91
91
  * of its own to write to. */
92
92
  export type Read = () => Promise<string>;
93
93
 
94
- /** The system speech binary. Absolute on purpose: a `say` shim earlier on PATH
95
- * is what delegates here, so resolving through PATH again would re-enter the
96
- * shim. */
94
+ /** The system speech binary. Absolute on purpose: what is on `PATH` under this
95
+ * name is whatever the host has arranged, and a notification is meant to be
96
+ * heard rather than routed. */
97
97
  const SYSTEM_SAY = "/usr/bin/say";
98
98
 
99
99
  /** What every command may be given, and what every command reads from.
@@ -137,8 +137,8 @@ interface Command {
137
137
  * `service status` take nothing, and printing their help instead of their
138
138
  * answer would make them unreachable. */
139
139
  readonly bare?: boolean;
140
- /** A command that takes its arguments over rather than parsing them, which
141
- * is what `say` is: its arguments belong to another program. */
140
+ /** A command that takes its arguments over rather than parsing them: they
141
+ * belong to another program. */
142
142
  readonly raw?: (args: readonly string[]) => Promise<number>;
143
143
  }
144
144
 
@@ -530,17 +530,6 @@ const ROOT: Command = {
530
530
  env: sessionEnv(),
531
531
  run: (args) => hello(args),
532
532
  },
533
- {
534
- name: "say",
535
- summary: `${SYSTEM_SAY} で発声し、どのセッションが喋ったかを知らせる`,
536
- usage: "ccmsg say [say-options] [text...]",
537
- options: [["", `引数は ${SYSTEM_SAY} へそのまま渡す (単独の --help だけが例外)`]],
538
- env: [
539
- [SESSION_ENV.join(" / "), "喋ったセッションの名乗り"],
540
- ["CCMSG_SAY_BIN", `発声に使うバイナリ (既定は ${SYSTEM_SAY})`],
541
- ],
542
- raw: (args) => say(args),
543
- },
544
533
  ],
545
534
  };
546
535
 
@@ -1421,20 +1410,60 @@ function reply(args: readonly string[]): Promise<unknown> {
1421
1410
 
1422
1411
  /** `ccmsg notify <text>`: a line for whoever is watching. Nothing is held and
1423
1412
  * nothing is acknowledged, so there is no outcome to report beyond the op
1424
- * having been accepted. */
1425
- function notify(args: readonly string[]): Promise<unknown> {
1426
- const parsed = options(args, ["sid", "about"]);
1413
+ * having been accepted.
1414
+ *
1415
+ * `--hook` is the same line, taken from what the harness's own notification
1416
+ * tool was given: a session that pushed a notification said something to the
1417
+ * person it works for, and this is that host's two ways of reaching them — a
1418
+ * line on the page they are watching, and the machine saying it aloud. Both
1419
+ * run whatever the tool itself decided to do with it. */
1420
+ export async function notify(args: readonly string[], read?: Read): Promise<unknown> {
1421
+ const parsed = options(args, ["sid", "about"], ["hook"]);
1422
+ if (parsed.flags.has("hook")) return await pushed(await hookEvent(read));
1427
1423
  const [text] = parsed.rest;
1428
1424
  if (text === undefined) {
1429
- throw new CommandError("invalid_args", "使い方: ccmsg notify <text> [--about <sid>]");
1425
+ throw new CommandError("invalid_args", "使い方: ccmsg notify <text> [--about <sid>] | --hook");
1430
1426
  }
1431
1427
  const about = parsed.named.get("about");
1432
- return announce(parsed.named.get("sid"), {
1428
+ return await announce(parsed.named.get("sid"), {
1433
1429
  text,
1434
1430
  ...(about === undefined ? {} : { sid: about }),
1435
1431
  });
1436
1432
  }
1437
1433
 
1434
+ /** What a pushed notification becomes here: said aloud, and written to the
1435
+ * page.
1436
+ *
1437
+ * Nothing to say is nothing to do — a hook fires on the tool whatever the tool
1438
+ * was given — and a failure on either half leaves the other alone: the speech
1439
+ * is what a person in the room hears and the line is what a person at the page
1440
+ * reads, and neither is worth losing because the other could not be had. */
1441
+ export async function pushed(
1442
+ event: { sid?: string; tool_message?: string },
1443
+ spawn: Spawn = spawnSpeech,
1444
+ ): Promise<unknown> {
1445
+ const text = event.tool_message?.trim();
1446
+ if (text === undefined || text === "") return { pushed: false };
1447
+ const spoken = spoke(text, spawn);
1448
+ let sent = false;
1449
+ try {
1450
+ await announce(event.sid, { text });
1451
+ sent = true;
1452
+ } catch {
1453
+ // No instance, or one that refused: the person in the room still hears it.
1454
+ }
1455
+ return { pushed: true, spoken: await spoken, sent };
1456
+ }
1457
+
1458
+ /** Say it aloud, and answer whether the machine could. */
1459
+ async function spoke(text: string, spawn: Spawn): Promise<boolean> {
1460
+ try {
1461
+ return (await spawn([process.env["CCMSG_SAY_BIN"] ?? SYSTEM_SAY, text]).exited) === 0;
1462
+ } catch {
1463
+ return false;
1464
+ }
1465
+ }
1466
+
1438
1467
  /** `ccmsg stopping`: this session is about to go.
1439
1468
  *
1440
1469
  * What it buys is the difference between Paused and Disappeared (DESIGN §4.3): the
@@ -1681,74 +1710,12 @@ async function exchange(
1681
1710
  }
1682
1711
  }
1683
1712
 
1684
- /** What one attempt to record a `say` may cost before the speech goes ahead
1685
- * without it. The record is a nicety — which session spoke — and the speech is
1686
- * what the caller asked for, so an instance that has wedged costs latency once
1687
- * rather than silence. */
1688
- const SAY_POST_MS = 1_500;
1689
-
1690
1713
  /** How a speech process is started. Named so a test can watch the arguments
1691
1714
  * without the machine making a sound. */
1692
1715
  export type Spawn = (command: string[]) => { exited: Promise<number> };
1693
1716
 
1694
1717
  const spawnSpeech: Spawn = (command) =>
1695
- Bun.spawn(command, { stdin: "inherit", stdout: "inherit", stderr: "inherit" });
1696
-
1697
- /** `ccmsg say [say-options] [text...]`: speak, and say who spoke.
1698
- *
1699
- * Every argument goes to the speech binary untouched, so its own flags work and
1700
- * a PATH shim delegating here changes nothing about what the caller gets. The
1701
- * one exception is a lone `--help`, which the binary does not define.
1702
- *
1703
- * No arguments is not an error: `echo hi | say` reads its text from stdin, and
1704
- * that is the form a shim exists to preserve. */
1705
- export async function say(args: readonly string[], spawn: Spawn = spawnSpeech): Promise<number> {
1706
- if (args.length === 1 && (args[0] === "--help" || args[0] === "-h")) {
1707
- process.stdout.write(help([ROOT, ROOT.children?.find((one) => one.name === "say") as Command]));
1708
- return 0;
1709
- }
1710
- await posted(args.join(" "));
1711
- const binary = process.env["CCMSG_SAY_BIN"] ?? SYSTEM_SAY;
1712
- return await spawn([binary, ...args]).exited;
1713
- }
1714
-
1715
- /** Tell the instance this session spoke, and say nothing if it cannot be told.
1716
- *
1717
- * Best effort on purpose: no instance, a refused greeting or a socket that goes
1718
- * away under us all leave the speech itself untouched, and a message about the
1719
- * record would be noise in front of the thing the caller wanted. Text the
1720
- * contract will not take — a bare `say` reading its text from stdin has none —
1721
- * is nothing to record either. */
1722
- async function posted(text: string): Promise<void> {
1723
- const sid = ownSid();
1724
- if (text === "" || sid === undefined || sid === "") return;
1725
- const conn = await connect(resolvePaths().socket);
1726
- if (conn === undefined) return;
1727
- let timer: ReturnType<typeof setTimeout> | undefined;
1728
- const record = async (): Promise<void> => {
1729
- const greeting = await conn.ask({
1730
- op: "hello.session",
1731
- sid,
1732
- protocol_version: PROTOCOL_VERSION,
1733
- ...statedMeta(),
1734
- });
1735
- if (greeting["ok"] === true) await conn.ask({ op: "say.post", text });
1736
- };
1737
- const budget = new Promise<void>((resolve) => {
1738
- timer = setTimeout(resolve, SAY_POST_MS);
1739
- });
1740
- try {
1741
- // Raced rather than cancelled: an exchange that never answers is one this
1742
- // waits out, and closing under it would leave a reply nobody resolves.
1743
- await Promise.race([record(), budget]);
1744
- } catch {
1745
- // The instance went away mid-exchange. The speech is what was asked for
1746
- // and it happens regardless.
1747
- } finally {
1748
- clearTimeout(timer);
1749
- conn.close();
1750
- }
1751
- }
1718
+ Bun.spawn(command, { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
1752
1719
 
1753
1720
  /** Long options and what is left over.
1754
1721
  *
@@ -196,6 +196,7 @@ const STARTING_PRESETS = [
196
196
  "message.sub.in",
197
197
  "message.team.in",
198
198
  "notice",
199
+ "tool.PushNotification",
199
200
  "thinking",
200
201
  ],
201
202
  },
@@ -10,6 +10,11 @@ export interface HookEvent {
10
10
  readonly cwd?: string;
11
11
  readonly transcript_path?: string;
12
12
  readonly reason?: string;
13
+ /** Which tool the event is about, on the events that are about one. */
14
+ readonly tool_name?: string;
15
+ /** What that tool was asked to say. A tool event carries the whole of the
16
+ * tool's input; this is the one field of it a hook of ours reads. */
17
+ readonly tool_message?: string;
13
18
  }
14
19
 
15
20
  /** Read one such event, or nothing to go on.
@@ -30,11 +35,16 @@ export async function hookEvent(
30
35
  }
31
36
  if (typeof parsed !== "object" || parsed === null) return {};
32
37
  const event = parsed as Record<string, unknown>;
38
+ const input = event["tool_input"];
33
39
  return {
34
40
  ...text(event, "session_id", "sid"),
35
41
  ...text(event, "cwd", "cwd"),
36
42
  ...text(event, "transcript_path", "transcript_path"),
37
43
  ...text(event, "reason", "reason"),
44
+ ...text(event, "tool_name", "tool_name"),
45
+ ...(typeof input === "object" && input !== null
46
+ ? text(input as Record<string, unknown>, "message", "tool_message")
47
+ : {}),
38
48
  };
39
49
  }
40
50
 
@@ -9,7 +9,8 @@ import { DESCRIPTION, SKILL } from "./skill.ts";
9
9
  *
10
10
  * The plugin itself is thin on purpose. Messages reach a session through the
11
11
  * harness's own socket, so nothing here listens, polls or holds a connection —
12
- * the skill says how to speak, and the two hooks say hello and goodbye. */
12
+ * the skill says how to speak, and the hooks say hello, goodbye, and whatever
13
+ * the session pushed to the person it works for. */
13
14
 
14
15
  /** The plugin's name, the marketplace's name, and therefore the id Claude Code
15
16
  * knows it by. One word for all three: there is one plugin here and a
@@ -43,6 +44,19 @@ const HOOKS = {
43
44
  hooks: [{ type: "command", command: throughPath("hello --hook"), timeout: HOOK_TIMEOUT_S }],
44
45
  },
45
46
  ],
47
+ PostToolUse: [
48
+ {
49
+ // What a session tells the person it is working for goes out the way
50
+ // this host tells them anything: aloud, and as a line on the page they
51
+ // are watching. The harness's own tool decides whether to raise a
52
+ // notification of its own; this runs either way, because a session that
53
+ // said something meant to say it.
54
+ matcher: "PushNotification",
55
+ hooks: [
56
+ { type: "command", command: throughPath("notify --hook"), timeout: HOOK_TIMEOUT_S },
57
+ ],
58
+ },
59
+ ],
46
60
  SessionEnd: [
47
61
  {
48
62
  hooks: [
@@ -77,12 +77,15 @@ preset の一覧は \`ccmsg dump presets\`。
77
77
 
78
78
  \`\`\`
79
79
  ccmsg notify <text> 一行知らせる (保持されない、返事も来ない)
80
- ccmsg say <text> 声に出して知らせる
81
80
  \`\`\`
82
81
 
83
82
  手が空いた・判断を仰ぎたい・長い作業が終わった、を人に伝えるときに使う。
84
83
  セッション同士のやり取りには使わない。
85
84
 
85
+ 声で届けたいときは \`PushNotification\` ツールを使う。この環境では hook が受けて
86
+ 本文をそのまま音声と webui の通知に流すので、「離席中のときだけ」といった
87
+ 一般の遠慮は要らない。
88
+
86
89
  ## これから終わるとき
87
90
 
88
91
  \`\`\`
@@ -107,6 +107,13 @@ const USE: Record<string, Reader> = {
107
107
  const cron = str(input["cron"]);
108
108
  return cron === undefined ? undefined : { cron, ...optional("prompt", str(input["prompt"])) };
109
109
  },
110
+ /** What a session told the person it works for, as `text`: the same field a
111
+ * message carries, because this is the same thing — words meant for a person
112
+ * to read, rather than a call whose arguments happen to include some. */
113
+ PushNotification: (input) => {
114
+ const text = str(input["message"]);
115
+ return text === undefined ? undefined : { text };
116
+ },
110
117
  };
111
118
 
112
119
  function pattern(input: Record<string, unknown>): Record<string, unknown> | undefined {