@ccmsg/cli 0.11.5 → 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 +1 -1
- package/src/cli.ts +51 -84
- package/src/daemon/registry.ts +1 -0
- package/src/greeting/hook.ts +10 -0
- package/src/plugin/claude.ts +15 -1
- package/src/plugin/skill.ts +4 -1
- package/src/transcript/items/tools.ts +7 -0
package/package.json
CHANGED
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:
|
|
95
|
-
* is
|
|
96
|
-
*
|
|
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
|
|
141
|
-
*
|
|
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
|
-
|
|
1426
|
-
|
|
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: "
|
|
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
|
*
|
package/src/daemon/registry.ts
CHANGED
package/src/greeting/hook.ts
CHANGED
|
@@ -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
|
|
package/src/plugin/claude.ts
CHANGED
|
@@ -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
|
|
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: [
|
package/src/plugin/skill.ts
CHANGED
|
@@ -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 {
|