faberun 0.17.1 → 0.18.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/skills/faberun/references/operations.md +14 -7
- package/src/campaign/metrics.mjs +3 -1
- package/src/host/preflight.mjs +5 -2
- package/src/notify/index.mjs +70 -10
- package/src/notify/session.mjs +58 -23
- package/src/report/locale.mjs +20 -0
- package/src/report/message.mjs +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberun",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Faberun is a development orchestration system that turns intent into verified software: harness- and model-agnostic, it keeps the intent, coordinates the work, verifies the result and decides what happens next.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -111,7 +111,10 @@ the contract is frozen with a digest, and the phone's middle ground is a note.
|
|
|
111
111
|
|
|
112
112
|
On `node.terminal`, `run.terminal` and `attention` the controller renders one
|
|
113
113
|
message from persisted state, in the operator's own language (detected from
|
|
114
|
-
the campaign goal, journal notes and node objectives
|
|
114
|
+
the campaign goal, journal notes and node objectives, or `FABERUN_NOTIFY_LANG`;
|
|
115
|
+
English otherwise). Only `run.terminal`, `attention` and `advisory` leave by
|
|
116
|
+
default -- a phase settling and a person being needed -- while a node settling
|
|
117
|
+
gets a `filtered` receipt; `FABERUN_NOTIFY_EVENTS` widens or narrows the list:
|
|
115
118
|
line one is the outcome (`✅ <node> · done in 8m · $0.09`, `🏁 run 15 · <name>
|
|
116
119
|
· 2/2 done`, or `👀 <node> needs you · <error>`), then asked / done / proof
|
|
117
120
|
for a node, what every node delivered for a run, or why / asked / do for
|
|
@@ -123,12 +126,16 @@ same text to every bound transport at once, appending one receipt
|
|
|
123
126
|
`<run-dir>/notify.jsonl`. Delivery is lossy: **exactly one attempt**, no retry,
|
|
124
127
|
no backoff. `FABERUN_NOTIFY_BIN` names an executable called with the event as
|
|
125
128
|
JSON on stdin (`os-macos` selects the bundled `osascript` adapter); it pushes to
|
|
126
|
-
a person, `canWake: false`. `FABERUN_NOTIFY_SESSION
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
129
|
+
a person, `canWake: false`. `FABERUN_NOTIFY_SESSION` wakes harness sessions,
|
|
130
|
+
`canWake: true`: `auto` is the Claude Code inbox and Codex thread the
|
|
131
|
+
environment names; `claude:<socket>` adds another session's inbox, so the
|
|
132
|
+
operator's own interactive session hears a run a background session launched
|
|
133
|
+
(`echo $CLAUDE_CODE_MESSAGING_SOCKET` in that session gives the path). A seat
|
|
134
|
+
window sets `auto`; nothing else does, so a test suite never wakes a session.
|
|
135
|
+
**On an inbound `🐦 faberun` message**: repeat it to the operator verbatim,
|
|
136
|
+
then one line on what you do next -- the message exists to inform the person,
|
|
137
|
+
not you, and it reaches them only through your reply; `👀`, act on the `do`
|
|
138
|
+
command it names. A
|
|
132
139
|
resume never re-sends a notification already recorded for the same node, attempt
|
|
133
140
|
and outcome. No transport is a default: `doctor`, `preflight` and the foreground
|
|
134
141
|
launch warn when both variables are empty, and `--wake` names what will wake.
|
package/src/campaign/metrics.mjs
CHANGED
|
@@ -46,7 +46,9 @@ const DONE_STATUSES = new Set(["done", "no-op"]);
|
|
|
46
46
|
/** Gate review that blocks the node on a failing verdict (TECH-SPEC lean, rule 2). */
|
|
47
47
|
const BLOCKING_REVIEW = "blocking";
|
|
48
48
|
/** A receipt this settled: delivered, no transport bound, or the retry budget spent. */
|
|
49
|
-
|
|
49
|
+
// `filtered` is a decided outcome too: the event type was kept out of every
|
|
50
|
+
// transport on purpose (`FABERUN_NOTIFY_EVENTS`), not lost.
|
|
51
|
+
const SETTLED_NOTIFY_STATUSES = new Set(["delivered", "no_transport", "filtered"]);
|
|
50
52
|
/** Target latency for a terminal/attention event to carry a settled receipt (TECH-SPEC section 6). */
|
|
51
53
|
const NOTIFY_TARGET_SEC = 60;
|
|
52
54
|
const SECONDS_PER_HOUR = 3600;
|
package/src/host/preflight.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import { DISCOVERY_RUNTIME_DEFINITIONS, discoverRuntimes } from "../engine/runti
|
|
|
26
26
|
import { errorMessage } from "../util.mjs";
|
|
27
27
|
import { boundedGitSync } from "../repo/worktree.mjs";
|
|
28
28
|
import { routeRuntime } from "../contract/runtime.mjs";
|
|
29
|
-
import { NOTIFY_BIN_ENV, noTransportWarning } from "../notify/index.mjs";
|
|
29
|
+
import { NOTIFY_BIN_ENV, deliverableEventTypes, noTransportWarning, notifySettingProblems } from "../notify/index.mjs";
|
|
30
30
|
import { NOTIFY_SESSION_ENV, sessionWakeNotice } from "../notify/session.mjs";
|
|
31
31
|
import { findExecutable } from "./platform.mjs";
|
|
32
32
|
import { colorLevel, statusToken } from "../cli/brand.mjs";
|
|
@@ -209,10 +209,13 @@ export function environmentPreflight(options) {
|
|
|
209
209
|
* @returns {EnvCheck}
|
|
210
210
|
*/
|
|
211
211
|
export function notifyTransportCheck(env = process.env) {
|
|
212
|
+
const problems = notifySettingProblems(env);
|
|
213
|
+
if (problems.length) return fail("notify transport", problems.join("; "), true);
|
|
212
214
|
const warning = noTransportWarning(env);
|
|
213
215
|
if (warning) return fail("notify transport", warning, true);
|
|
214
216
|
const external = env[NOTIFY_BIN_ENV] ? `${NOTIFY_BIN_ENV}=${env[NOTIFY_BIN_ENV]}` : `${NOTIFY_BIN_ENV} unset`;
|
|
215
|
-
|
|
217
|
+
const events = [...deliverableEventTypes(env)].join(",");
|
|
218
|
+
return pass("notify transport", `${external} · ${sessionWakeNotice(env)} · events: ${events}`);
|
|
216
219
|
}
|
|
217
220
|
|
|
218
221
|
/** @param {EnvReport} report @returns {EnvCheck[]} the checks that block a dispatch */
|
package/src/notify/index.mjs
CHANGED
|
@@ -86,7 +86,59 @@ const MACOS_TRANSPORT = "os-macos";
|
|
|
86
86
|
* fixtures its worker ran. `withoutNotifyEnv` is the boundary every child
|
|
87
87
|
* crosses; `test/setup.mjs` neutralises the same names inside the suite.
|
|
88
88
|
*/
|
|
89
|
-
export const
|
|
89
|
+
export const NOTIFY_EVENTS_ENV = "FABERUN_NOTIFY_EVENTS";
|
|
90
|
+
export const NOTIFY_LANG_ENV = "FABERUN_NOTIFY_LANG";
|
|
91
|
+
export const NOTIFY_ENV_NAMES = Object.freeze([NOTIFY_BIN_ENV, NOTIFY_SESSION_ENV, NOTIFY_EVENTS_ENV, NOTIFY_LANG_ENV]);
|
|
92
|
+
|
|
93
|
+
/** Every event type the dispatcher can be asked to deliver. */
|
|
94
|
+
export const NOTIFY_EVENT_TYPES = Object.freeze(["node.terminal", "run.terminal", "attention", "advisory"]);
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* What leaves the controller when `FABERUN_NOTIFY_EVENTS` is unset: a phase
|
|
98
|
+
* settling, a node that waits on a person, and an advisory threshold the
|
|
99
|
+
* operator declared. A node settling stays in `notify.jsonl` as a `filtered`
|
|
100
|
+
* receipt. The operator's own words, 2026-09-22, after a run of two nodes
|
|
101
|
+
* produced four wake-ups: "só fechamento de fase e atenção acordam, nó
|
|
102
|
+
* individual fica no log" -- and before that, after the phone flood, "só
|
|
103
|
+
* milestones e fechamentos de fase". Measured on the campaign that prompted
|
|
104
|
+
* it: five phases of two or three nodes would be about 20 messages with every
|
|
105
|
+
* event, about 7 with these.
|
|
106
|
+
*/
|
|
107
|
+
export const DEFAULT_NOTIFY_EVENTS = Object.freeze(["run.terminal", "attention", "advisory"]);
|
|
108
|
+
|
|
109
|
+
/** The two languages the message renders its wording in; `FABERUN_NOTIFY_LANG` may name either. */
|
|
110
|
+
export const NOTIFY_LANGUAGES = Object.freeze(["en", "pt"]);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The event types the environment lets out, as a set. Unknown items are left
|
|
114
|
+
* out here and reported by `notifySettingProblems`; an empty value is the
|
|
115
|
+
* default, never "nothing".
|
|
116
|
+
*
|
|
117
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
118
|
+
* @returns {Set<string>}
|
|
119
|
+
*/
|
|
120
|
+
export function deliverableEventTypes(env = process.env) {
|
|
121
|
+
const items = (env[NOTIFY_EVENTS_ENV] ?? "").split(",").map((item) => item.trim()).filter((item) => item.length > 0);
|
|
122
|
+
return new Set(items.length ? items.filter((item) => NOTIFY_EVENT_TYPES.includes(item)) : DEFAULT_NOTIFY_EVENTS);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Every notify setting the environment gets wrong, one sentence each, for
|
|
127
|
+
* `doctor` and `preflight`. Empty when everything parses.
|
|
128
|
+
*
|
|
129
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
130
|
+
* @returns {string[]}
|
|
131
|
+
*/
|
|
132
|
+
export function notifySettingProblems(env = process.env) {
|
|
133
|
+
const problems = [];
|
|
134
|
+
const events = (env[NOTIFY_EVENTS_ENV] ?? "").split(",").map((item) => item.trim()).filter((item) => item.length > 0);
|
|
135
|
+
for (const item of events) {
|
|
136
|
+
if (!NOTIFY_EVENT_TYPES.includes(item)) problems.push(`${NOTIFY_EVENTS_ENV} item "${item}" is not one of ${NOTIFY_EVENT_TYPES.join(", ")}`);
|
|
137
|
+
}
|
|
138
|
+
const lang = (env[NOTIFY_LANG_ENV] ?? "").trim();
|
|
139
|
+
if (lang && !NOTIFY_LANGUAGES.includes(lang)) problems.push(`${NOTIFY_LANG_ENV}=${lang} is not one of ${NOTIFY_LANGUAGES.join(", ")}`);
|
|
140
|
+
return problems;
|
|
141
|
+
}
|
|
90
142
|
|
|
91
143
|
/**
|
|
92
144
|
* @param {NodeJS.ProcessEnv} env
|
|
@@ -156,7 +208,7 @@ export const NOTIFY_NO_TRANSPORT_WARNING = "no human notification transport is c
|
|
|
156
208
|
/** @typedef {Record<string, unknown>} JsonObject */
|
|
157
209
|
/** @typedef {{schemaVersion: number, eventId: string, at: string, type: string, campaignId: string|null, runId: string|null, nodeId: string|null, status: string|null, errorCode: string|null, dedupeKey: string, summary: string}} InboxEntry */
|
|
158
210
|
/** @typedef {{type: string, dedupeKey: string, summary: string, at?: string, campaignId?: string|null, runId?: string|null, nodeId?: string|null, status?: string|null, errorCode?: string|null}} InboxEvent */
|
|
159
|
-
/** @typedef {{type: "node.terminal"|"run.terminal"|"attention", runId: string|null, campaignId?: string|null, nodeId?: string|null, status?: string|null, attempt?: number|null, errorCode?: string|null, done?: number|null, total?: number|null, dedupeKey?: string|null, runDir?: string|null, costUsd?: number|null, summary?: string|null, eventId?: string}} NotifyEvent */
|
|
211
|
+
/** @typedef {{type: "node.terminal"|"run.terminal"|"attention"|"advisory", runId: string|null, campaignId?: string|null, nodeId?: string|null, status?: string|null, attempt?: number|null, errorCode?: string|null, done?: number|null, total?: number|null, dedupeKey?: string|null, runDir?: string|null, costUsd?: number|null, summary?: string|null, eventId?: string}} NotifyEvent */
|
|
160
212
|
/** @typedef {{id: string, ok: boolean, error?: string}} TransportOutcome one transport's own outcome, named so the receipt says which took the message */
|
|
161
213
|
/** @typedef {{ok: boolean, error?: string, noTransport?: boolean, transports?: TransportOutcome[]}} DeliveryResult */
|
|
162
214
|
|
|
@@ -503,14 +555,22 @@ export class NotifyQueue {
|
|
|
503
555
|
// one), so every delivery carries a stable id derived from the dedupe key.
|
|
504
556
|
const eventId = enriched.eventId
|
|
505
557
|
?? createHash("sha256").update(enriched.dedupeKey ?? JSON.stringify(enriched)).digest("hex");
|
|
558
|
+
// An event type the environment keeps out of every transport is still a
|
|
559
|
+
// receipt -- `filtered`, with the rendered summary -- so the log says what
|
|
560
|
+
// happened to it, and the resume's dedupe sees it as already handled.
|
|
561
|
+
const filtered = !deliverableEventTypes().has(enriched.type);
|
|
506
562
|
/** @type {DeliveryResult} */
|
|
507
563
|
let result;
|
|
508
|
-
|
|
509
|
-
result =
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
564
|
+
if (filtered) {
|
|
565
|
+
result = { ok: false, transports: [] };
|
|
566
|
+
} else {
|
|
567
|
+
try {
|
|
568
|
+
result = await this.deliver({ ...enriched, summary, eventId });
|
|
569
|
+
} catch (error) {
|
|
570
|
+
// A transport that rejects is a failed delivery, not a controller fault:
|
|
571
|
+
// the receipt is still appended and the failure is dropped like any other.
|
|
572
|
+
result = { ok: false, error: errorMessage(error) };
|
|
573
|
+
}
|
|
514
574
|
}
|
|
515
575
|
/** @type {JsonObject} */
|
|
516
576
|
const receipt = {
|
|
@@ -525,13 +585,13 @@ export class NotifyQueue {
|
|
|
525
585
|
dedupeKey: enriched.dedupeKey ?? null,
|
|
526
586
|
summary,
|
|
527
587
|
attempt: 1,
|
|
528
|
-
status: result.ok ? "delivered" : result.noTransport ? "no_transport" : "failed",
|
|
588
|
+
status: filtered ? "filtered" : result.ok ? "delivered" : result.noTransport ? "no_transport" : "failed",
|
|
529
589
|
// One entry per bound transport, so a receipt that says `delivered`
|
|
530
590
|
// also says whether the phone, the session, or both took the message.
|
|
531
591
|
transports: result.transports ?? [],
|
|
532
592
|
at: new Date(this.now()).toISOString(),
|
|
533
593
|
};
|
|
534
|
-
if (!result.ok && !result.noTransport) receipt.error = result.error ?? null;
|
|
594
|
+
if (!filtered && !result.ok && !result.noTransport) receipt.error = result.error ?? null;
|
|
535
595
|
appendFileSync(join(this.runDir, NOTIFY_LOG_FILE), `${JSON.stringify(receipt)}\n`);
|
|
536
596
|
}
|
|
537
597
|
}
|
package/src/notify/session.mjs
CHANGED
|
@@ -44,7 +44,7 @@ export const CODEX_THREAD_ENV = "CODEX_THREAD_ID";
|
|
|
44
44
|
*/
|
|
45
45
|
export const SESSION_DELIVERY_TIMEOUT_MS = 5_000;
|
|
46
46
|
|
|
47
|
-
/** The
|
|
47
|
+
/** The bare words an item of the setting may be; `codex:<thread>` and `claude:<socket>` carry an address. */
|
|
48
48
|
const SETTINGS = new Set(["off", "auto", "claude", "codex"]);
|
|
49
49
|
|
|
50
50
|
/** @typedef {Record<string, unknown>} JsonObject */
|
|
@@ -59,32 +59,66 @@ const SETTINGS = new Set(["off", "auto", "claude", "codex"]);
|
|
|
59
59
|
/** @typedef {(path: string) => SessionSocket} ConnectFunction */
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
|
-
* The sessions the variable and the environment together name.
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
62
|
+
* The sessions the variable and the environment together name. The value is
|
|
63
|
+
* a comma-separated list; each item is one of:
|
|
64
|
+
*
|
|
65
|
+
* auto every session whose address this process inherited --
|
|
66
|
+
* a Codex thread opened from a Claude Code shell inherits
|
|
67
|
+
* both, and both are supervising
|
|
68
|
+
* claude the inherited Claude Code inbox alone
|
|
69
|
+
* codex the inherited Codex thread alone
|
|
70
|
+
* codex:<thread> a Codex thread by id or name
|
|
71
|
+
* claude:<socket> a Claude Code inbox by socket path -- the operator's
|
|
72
|
+
* own interactive session, which did not launch the run
|
|
73
|
+
* and would otherwise never hear of it (the session that
|
|
74
|
+
* launches a campaign is often a background one nobody
|
|
75
|
+
* reads); the token travels only to the inherited inbox,
|
|
76
|
+
* since it belongs to that session and no other
|
|
77
|
+
* off nothing, whatever else the list says
|
|
78
|
+
*
|
|
79
|
+
* An item whose address is absent resolves to nothing; the doctor reports
|
|
80
|
+
* why through `sessionSettingProblem`. This function never throws, because it
|
|
81
|
+
* backs a lossy dispatcher. Duplicates collapse: `auto,claude:<own socket>`
|
|
82
|
+
* is one target.
|
|
68
83
|
*
|
|
69
84
|
* @param {NodeJS.ProcessEnv} [env]
|
|
70
85
|
* @returns {SessionTarget[]}
|
|
71
86
|
*/
|
|
72
87
|
export function resolveSessionTargets(env = process.env) {
|
|
73
|
-
const
|
|
74
|
-
if (!
|
|
88
|
+
const items = settingItems(env);
|
|
89
|
+
if (!items.length || items.includes("off")) return [];
|
|
75
90
|
/** @type {SessionTarget[]} */
|
|
76
91
|
const targets = [];
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
92
|
+
/** @param {SessionTarget} target */
|
|
93
|
+
const add = (target) => {
|
|
94
|
+
const address = target.kind === "claude" ? target.socketPath : target.thread;
|
|
95
|
+
if (!targets.some((known) => known.kind === target.kind && (known.kind === "claude" ? known.socketPath : known.thread) === address)) targets.push(target);
|
|
96
|
+
};
|
|
97
|
+
const inheritedSocket = env[CLAUDE_SOCKET_ENV];
|
|
98
|
+
const inheritedThread = env[CODEX_THREAD_ENV];
|
|
99
|
+
for (const item of items) {
|
|
100
|
+
if ((item === "auto" || item === "claude") && inheritedSocket) {
|
|
101
|
+
add({ kind: "claude", id: "claude-session", socketPath: inheritedSocket, token: env[CLAUDE_TOKEN_ENV] || null });
|
|
102
|
+
}
|
|
103
|
+
if ((item === "auto" || item === "codex") && inheritedThread) {
|
|
104
|
+
add({ kind: "codex", id: "codex-session", thread: inheritedThread });
|
|
105
|
+
}
|
|
106
|
+
if (item.startsWith("codex:") && item.slice("codex:".length).trim()) {
|
|
107
|
+
add({ kind: "codex", id: "codex-session", thread: item.slice("codex:".length).trim() });
|
|
108
|
+
}
|
|
109
|
+
if (item.startsWith("claude:") && item.slice("claude:".length).trim()) {
|
|
110
|
+
const socketPath = item.slice("claude:".length).trim();
|
|
111
|
+
add({ kind: "claude", id: "claude-session", socketPath, token: socketPath === inheritedSocket ? env[CLAUDE_TOKEN_ENV] || null : null });
|
|
112
|
+
}
|
|
84
113
|
}
|
|
85
114
|
return targets;
|
|
86
115
|
}
|
|
87
116
|
|
|
117
|
+
/** @param {NodeJS.ProcessEnv} env @returns {string[]} the non-empty items of the setting */
|
|
118
|
+
function settingItems(env) {
|
|
119
|
+
return (env[NOTIFY_SESSION_ENV] ?? "").split(",").map((item) => item.trim()).filter((item) => item.length > 0);
|
|
120
|
+
}
|
|
121
|
+
|
|
88
122
|
/**
|
|
89
123
|
* Why the setting names no session, in one sentence for `doctor` and
|
|
90
124
|
* `--wake`; `null` when it is unset, `off`, or resolves to at least one.
|
|
@@ -93,15 +127,16 @@ export function resolveSessionTargets(env = process.env) {
|
|
|
93
127
|
* @returns {string|null}
|
|
94
128
|
*/
|
|
95
129
|
export function sessionSettingProblem(env = process.env) {
|
|
96
|
-
const
|
|
97
|
-
if (!
|
|
98
|
-
|
|
99
|
-
|
|
130
|
+
const items = settingItems(env);
|
|
131
|
+
if (!items.length || items.includes("off")) return null;
|
|
132
|
+
const unknown = items.find((item) => !SETTINGS.has(item) && !(item.startsWith("codex:") && item.length > "codex:".length) && !(item.startsWith("claude:") && item.length > "claude:".length));
|
|
133
|
+
if (unknown !== undefined) {
|
|
134
|
+
return `${NOTIFY_SESSION_ENV} item "${unknown}" is not one of off, auto, claude, codex, codex:<thread>, claude:<socket>`;
|
|
100
135
|
}
|
|
101
136
|
if (resolveSessionTargets(env).length) return null;
|
|
137
|
+
const [setting] = items;
|
|
102
138
|
if (setting === "claude") return `${NOTIFY_SESSION_ENV}=claude but ${CLAUDE_SOCKET_ENV} is not set: this process was not started from inside a Claude Code session`;
|
|
103
139
|
if (setting === "codex") return `${NOTIFY_SESSION_ENV}=codex but ${CODEX_THREAD_ENV} is not set: this process was not started from inside a Codex session`;
|
|
104
|
-
if (setting.startsWith("codex:")) return `${NOTIFY_SESSION_ENV}=codex: names an empty thread id`;
|
|
105
140
|
return `${NOTIFY_SESSION_ENV}=auto found neither ${CLAUDE_SOCKET_ENV} nor ${CODEX_THREAD_ENV}: no harness session to wake`;
|
|
106
141
|
}
|
|
107
142
|
|
|
@@ -228,13 +263,13 @@ export function createCodexSessionNotifier({ spawn = /** @type {SpawnFunction} *
|
|
|
228
263
|
* @param {SessionEvent} event
|
|
229
264
|
* @param {SessionTarget[]} targets
|
|
230
265
|
* @param {{connect?: ConnectFunction, spawn?: SpawnFunction, timeoutMs?: number, env?: NodeJS.ProcessEnv}} [options]
|
|
231
|
-
* @returns {Promise<{id: string, ok: boolean, error?: string}[]>}
|
|
266
|
+
* @returns {Promise<{id: string, address: string, ok: boolean, error?: string}[]>}
|
|
232
267
|
*/
|
|
233
268
|
export function deliverToSessions(event, targets, options = {}) {
|
|
234
269
|
return Promise.all(targets.map(async (target) => {
|
|
235
270
|
const result = target.kind === "claude"
|
|
236
271
|
? await createClaudeSessionNotifier(options).deliver(event, target)
|
|
237
272
|
: await createCodexSessionNotifier(options).deliver(event, target);
|
|
238
|
-
return { id: target.id, ...result };
|
|
273
|
+
return { id: target.id, address: target.kind === "claude" ? target.socketPath : target.thread, ...result };
|
|
239
274
|
}));
|
|
240
275
|
}
|
package/src/report/locale.mjs
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* function there.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import { NOTIFY_LANG_ENV } from "../notify/index.mjs";
|
|
20
|
+
|
|
19
21
|
/** @typedef {"en"|"pt"} Language */
|
|
20
22
|
|
|
21
23
|
// `a`, `do` and `no` are left out on purpose: each is also an English word,
|
|
@@ -51,6 +53,24 @@ export function detectLanguage(...groups) {
|
|
|
51
53
|
return "en";
|
|
52
54
|
}
|
|
53
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The language the message is written in: `FABERUN_NOTIFY_LANG` when the
|
|
58
|
+
* operator set it to a language this module has (a campaign whose goal an
|
|
59
|
+
* orchestrator wrote in English still belongs to a person who reads
|
|
60
|
+
* Portuguese), else detection over the groups. The override moves the
|
|
61
|
+
* wording only; quoted text keeps the language it was written in, so a
|
|
62
|
+
* Portuguese label may sit beside an English objective -- honest, if uneven.
|
|
63
|
+
*
|
|
64
|
+
* @param {NodeJS.ProcessEnv} env
|
|
65
|
+
* @param {...readonly (string|null|undefined)[]} groups
|
|
66
|
+
* @returns {Language}
|
|
67
|
+
*/
|
|
68
|
+
export function chooseLanguage(env, ...groups) {
|
|
69
|
+
const forced = (env[NOTIFY_LANG_ENV] ?? "").trim();
|
|
70
|
+
if (forced === "pt" || forced === "en") return forced;
|
|
71
|
+
return detectLanguage(...groups);
|
|
72
|
+
}
|
|
73
|
+
|
|
54
74
|
/**
|
|
55
75
|
* Every phrase the message renders, per language. Node ids, run ids, model
|
|
56
76
|
* names and quoted text are never here: they are data, not wording.
|
package/src/report/message.mjs
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import { renderStatusJson } from "./render.mjs";
|
|
26
26
|
import { buildCampaignProgress, remainingEstimateMs } from "./progress.mjs";
|
|
27
|
-
import {
|
|
27
|
+
import { chooseLanguage, labelsFor } from "./locale.mjs";
|
|
28
28
|
import { readNodeSnapshot } from "../run/node-store.mjs";
|
|
29
29
|
import { campaignDir } from "../campaign/layout.mjs";
|
|
30
30
|
import { readJournal } from "../campaign/journal.mjs";
|
|
@@ -106,7 +106,7 @@ export function renderRunProgress(runDir, event) {
|
|
|
106
106
|
const objectives = readObjectives(runDir);
|
|
107
107
|
const snapshots = new Map(payload.nodes.map((node) => [node.id, readSnapshotSafe(runDir, node.id)]));
|
|
108
108
|
const campaign = campaignSummary(runsDir, payload.campaignId);
|
|
109
|
-
const label = labelsFor(
|
|
109
|
+
const label = labelsFor(chooseLanguage(process.env, [campaign?.goal], journalTexts(runsDir, payload.campaignId), [...objectives.values()]));
|
|
110
110
|
const subject = subjectNode(payload.nodes, event.nodeId ?? null);
|
|
111
111
|
/** @type {View} */
|
|
112
112
|
const view = { runDir, runId: event.runId ?? basename(runDir), payload, objectives, snapshots, campaign, label, subject };
|