bus, cli, webui: every message to the human, read state, Messages page (#1588)
Row 94, round 2. Rocko built it, Dewey approved it (comment 28104) on manifest 53b01ea9 (16 paths, basee918a915). The bus keeps a messages view with read state, where delivered but not read stays read:false. The CLI gains messages and follow, and inbox leaves out messages already read while inbox --json stays byte-compatible. The Console gets a Messages page and an unread count in the nav. Mos gave the go under decision 123 (public/ files). Sage gate on645489e4plus the patch, 12:52:14Z to 12:55:44Z: bus 115, cli 129, webui 104, all fail 0. Every scripts/test-*.sh exited 0 (auth 15, conductor 17, config 24, discord 66, extension-package 18, foundation 44, queue 27, release 14, task 98). Manifest 16/16 before and after. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
+55
-19
@@ -29,7 +29,8 @@ export const USAGE = `usage:
|
||||
mosaic tasks [--business <id>] [--json]
|
||||
mosaic agents [--business <id>] [--json]
|
||||
mosaic trail <task|decision> [--business <id>] [--json]
|
||||
mosaic talk <role> <text> [--wait <seconds>] [--business <id>]
|
||||
mosaic talk <role> <text> [--wait <seconds> | --follow] [--business <id>]
|
||||
mosaic follow [--business <id>]
|
||||
mosaic stop <run>
|
||||
mosaic launches off|on [--business <id>]
|
||||
mosaic launches list [--json]
|
||||
@@ -170,7 +171,19 @@ async function human(verb, rest, io, deps) {
|
||||
return print(await call("trail", { subject }), (rows) => formatTrail(subject, rows));
|
||||
}
|
||||
if (parsed.positional.length !== 0) throw usage(`${verb} takes no arguments`);
|
||||
if (verb === "inbox") return print(await call("inbox"), formatInbox);
|
||||
if (verb === "inbox") {
|
||||
const decisions = await call("inbox");
|
||||
if (json) return print(decisions, formatInbox);
|
||||
const messages = [];
|
||||
let after;
|
||||
do {
|
||||
const page = await call("messages", { limit: 200, ...(after ? { after } : {}) });
|
||||
messages.push(...page.messages.filter(m => !m.read));
|
||||
after = page.next;
|
||||
} while (after);
|
||||
return print({ decisions, messages }, value => formatInbox(value.decisions) +
|
||||
`\nmessages: ${value.messages.length} unread\n` + value.messages.map(formatMessage).join(""));
|
||||
}
|
||||
if (verb === "tasks") return print(await call("tasks"), formatTasks);
|
||||
return print(await call("agents"), formatAgents);
|
||||
}
|
||||
@@ -184,11 +197,14 @@ function formatMessage(m) {
|
||||
}
|
||||
|
||||
// `mosaic talk <role> <text>`: a REQUEST to the role, then the human's inbox
|
||||
// until the reply arrives or --wait runs out. Every message received on the
|
||||
// way is printed and marked read, so a late reply shows up on the next talk.
|
||||
async function talk(rest, io, deps) {
|
||||
const parsed = parse(rest, { values: ["--business", "--wait"] });
|
||||
if (parsed.positional.length !== 2) throw usage("talk takes a role and one quoted text");
|
||||
// until the reply arrives or --wait runs out. --follow keeps receiving after
|
||||
// that reply; follow starts the same receive loop without sending.
|
||||
async function talk(rest, io, deps, receiveOnly = false) {
|
||||
const parsed = parse(rest, { flags: ["--follow"], values: ["--business", ...(receiveOnly ? [] : ["--wait"])] });
|
||||
if (parsed.positional.length !== (receiveOnly ? 0 : 2)) throw usage(receiveOnly ? "follow takes no arguments" : "talk takes a role and one quoted text");
|
||||
if (receiveOnly && parsed.flags.has("--follow")) throw usage("follow already follows");
|
||||
const follow = receiveOnly || parsed.flags.has("--follow");
|
||||
if (follow && parsed.values["--wait"] !== undefined) throw usage("--follow and --wait cannot be combined");
|
||||
const wait = parsed.values["--wait"] === undefined ? TALK_WAIT_S : Number(parsed.values["--wait"]);
|
||||
if (!Number.isInteger(wait) || wait < 0) throw usage("--wait takes whole seconds, 0 or more");
|
||||
refuseInsideAgent(io.env);
|
||||
@@ -196,21 +212,40 @@ async function talk(rest, io, deps) {
|
||||
const business = businessFor(parsed, system.dataRoot);
|
||||
const call = deps.transport ? deps.transport(business) : humanTransport({ socket: socketPath(system.dataRoot), business, env: io.env });
|
||||
const [to, body] = parsed.positional;
|
||||
const sent = await call("message.send", { to, body, class: "REQUEST" });
|
||||
io.stdout.write(`sent ${shortId(sent.id)} to ${to}\n`);
|
||||
if (wait === 0) return;
|
||||
let sent;
|
||||
if (!receiveOnly) {
|
||||
sent = await call("message.send", { to, body, class: "REQUEST" });
|
||||
io.stdout.write(`sent ${shortId(sent.id)} to ${to}\n`);
|
||||
if (!follow && wait === 0) return;
|
||||
}
|
||||
const pollMs = deps.talkPollMs ?? TALK_POLL_MS;
|
||||
const deadline = Date.now() + wait * 1000;
|
||||
for (;;) {
|
||||
let answered = false;
|
||||
for (const m of await call("message.receive")) {
|
||||
io.stdout.write(formatMessage(m));
|
||||
await call("message.read", { id: m.id });
|
||||
if (m.in_reply_to === sent.id) answered = true;
|
||||
const controller = new AbortController();
|
||||
const stop = () => controller.abort();
|
||||
// Only continuous commands take ownership of Ctrl-C, and release it on every exit.
|
||||
if (follow) process.on("SIGINT", stop);
|
||||
deps.signal?.addEventListener("abort", stop, { once: true });
|
||||
if (deps.signal?.aborted) stop();
|
||||
try {
|
||||
while (!controller.signal.aborted) {
|
||||
let answered = false;
|
||||
for (const m of await call("message.receive")) {
|
||||
io.stdout.write(formatMessage(m));
|
||||
await call("message.read", { id: m.id });
|
||||
if (m.in_reply_to === sent?.id) answered = true;
|
||||
}
|
||||
if (!follow && answered) return;
|
||||
if (!follow && Date.now() >= deadline) throw new CliError(`no reply from ${to} within ${wait} s; it may still come, and the next mosaic talk shows it`, 1);
|
||||
if (controller.signal.aborted) break;
|
||||
await new Promise(resolve => {
|
||||
const wake = () => { clearTimeout(timer); controller.signal.removeEventListener("abort", wake); resolve(); };
|
||||
const timer = setTimeout(wake, pollMs);
|
||||
controller.signal.addEventListener("abort", wake, { once: true });
|
||||
});
|
||||
}
|
||||
if (answered) return;
|
||||
if (Date.now() >= deadline) throw new CliError(`no reply from ${to} within ${wait} s; it may still come, and the next mosaic talk shows it`, 1);
|
||||
await new Promise((r) => setTimeout(r, pollMs));
|
||||
} finally {
|
||||
if (follow) process.removeListener("SIGINT", stop);
|
||||
deps.signal?.removeEventListener("abort", stop);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,6 +371,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
const [verb, ...rest] = argv;
|
||||
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
|
||||
if (verb === "talk") return talk(rest, io, deps);
|
||||
if (verb === "follow") return talk(rest, io, deps, true);
|
||||
if (verb === "stop") return stop(rest, io, deps);
|
||||
if (verb === "launches") return launches(rest, io, deps);
|
||||
if (verb === "bus") return bus(rest, io, deps);
|
||||
|
||||
Reference in New Issue
Block a user