roger-roger 0.1.4 → 0.1.13

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.
@@ -2,8 +2,9 @@
2
2
  // `<directory> <tab label> <agent label>`. Out of the box that reads "code-red 2 claude" — the tab
3
3
  // number and the agent's kind — which says nothing about *which* claude, while the user's Slack
4
4
  // messages arrive signed "🍯 amber". This closes that gap: when a session runs inside a Herdr pane,
5
- // its tab is labelled with the session's badge (`🍯 amber`) and its agent with the kind and the work
6
- // (`claude-build-process-improvements`), so the sidebar and Slack name the same thing.
5
+ // its tab is labelled with the session's badge and a word or two of the work (`🍯 amber | build
6
+ // process`) and its agent with the work
7
+ // (`build-process-improvements`), so the sidebar and Slack name the same thing.
7
8
  //
8
9
  // The pane's context comes from the environment Herdr injects into the pane (HERDR_ENV=1 and the
9
10
  // ids). The CLI runs there and passes it along with its identity; the daemon, which runs somewhere
@@ -40,20 +41,46 @@ export function herdrContext(env = process.env) {
40
41
 
41
42
  /** What the tab says: the session's badge, `🍯 amber`. */
42
43
  export function tabLabel(session) {
43
- return session?.nickname ? badge(session.nickname) : "";
44
+ if (!session?.nickname) return "";
45
+ const work = shortWork(session.name);
46
+ return work ? `${badge(session.nickname)} | ${work}` : badge(session.nickname);
44
47
  }
45
48
 
49
+ // Words that say nothing on their own, skipped when the work is cut down to two.
50
+ const FILLER = new Set(["a", "an", "the", "and", "or", "of", "for", "to", "in", "on", "with", "my", "our", "some", "into", "from", "about", "new"]);
51
+ const MAX_TAB_WORK = 24;
52
+
46
53
  /**
47
- * What the agent is called: its kind and its work, in the only shape Herdr accepts —
48
- * `claude-build-process-improvements`. Without a `--session` name there is nothing to add to what
54
+ * The work in a word or two, for the tab: "security audit of the billing service" → "security
55
+ * audit", "Home Assistant MCP setup" → "home assistant". Lowercase, punctuation gone, filler words
56
+ * skipped, and never longer than a tab can show.
57
+ */
58
+ export function shortWork(text) {
59
+ const words = String(text ?? "")
60
+ .toLowerCase()
61
+ .replace(/[^\p{L}\p{N}\s-]+/gu, " ")
62
+ .split(/\s+/)
63
+ .map((w) => w.replace(/^-+|-+$/g, ""))
64
+ .filter((w) => w && !FILLER.has(w));
65
+ let out = "";
66
+ for (const word of words.slice(0, 2)) {
67
+ const next = out ? `${out} ${word}` : word;
68
+ if (next.length > MAX_TAB_WORK) break;
69
+ out = next;
70
+ }
71
+ return out || (words[0] ?? "").slice(0, MAX_TAB_WORK);
72
+ }
73
+
74
+ /**
75
+ * What the agent is called: its work, in the only shape Herdr accepts —
76
+ * `build-process-improvements`. Without a `--session` name there is nothing to add to what
49
77
  * Herdr already shows (the kind), so the agent is left alone.
50
78
  */
51
79
  export function agentName(session) {
52
- const work = slug(session?.name);
53
- if (!work) return "";
54
- const kind = slug(session?.agent) || "agent";
55
- const name = `${kind}-${work}`.slice(0, MAX_AGENT_NAME).replace(/[-_]+$/, "");
56
- return AGENT_NAME.test(name) ? name : "";
80
+ // The work alone: which agent it is (claude, codex) was a guess from the environment, and Herdr
81
+ // already shows the kind beside it.
82
+ const name = slug(session?.name).slice(0, MAX_AGENT_NAME).replace(/[-_]+$/, "");
83
+ return name && AGENT_NAME.test(name) ? name : "";
57
84
  }
58
85
 
59
86
  function slug(text) {
@@ -110,17 +137,62 @@ function errorText(text) {
110
137
  }
111
138
  }
112
139
 
140
+ /**
141
+ * What the pane's agent is working on, from its terminal title: Claude Code, Codex and others set it
142
+ * to the conversation's topic ("Home Assistant MCP setup"). Empty when the title is only the shell
143
+ * or the program's own name, which says nothing about the work.
144
+ */
145
+ export function workFromTitle(title, agent = "") {
146
+ const t = str(title).replace(/^[^\p{L}\p{N}]+/u, "").trim();
147
+ if (!t) return "";
148
+ if (/[\\/]|\.exe$|^(powershell|pwsh|cmd|bash|zsh|fish|sh|node)$/i.test(t)) return "";
149
+ if (agent && t.toLowerCase() === String(agent).toLowerCase()) return "";
150
+ if (/^(claude( code)?|codex|opencode|gemini|cursor|copilot)$/i.test(t)) return "";
151
+ return t;
152
+ }
153
+
154
+ // Herdr is asked for the title at most this often per session; a label follows the topic, not keystrokes.
155
+ const TITLE_EVERY_MS = 60_000;
156
+ const titleAsked = new Map();
157
+
158
+ /** The work to label with: the `--session` name if the agent gave one, else the pane's title. */
159
+ async function workFor(session, run, now = Date.now()) {
160
+ if (str(session.name)) return str(session.name);
161
+ const last = titleAsked.get(session.id);
162
+ if (last && now - last.at < TITLE_EVERY_MS) return last.work;
163
+ let work = "";
164
+ try {
165
+ const out = await run(["agent", "get", session.herdr.pane], { bin: session.herdr.bin, socket: session.herdr.socket });
166
+ const agent = JSON.parse(out)?.result?.agent ?? {};
167
+ work = workFromTitle(agent.terminal_title_stripped ?? agent.terminal_title, agent.agent ?? session.agent);
168
+ } catch {
169
+ // No title is no label, not an error: the tab still gets the badge.
170
+ }
171
+ titleAsked.set(session.id, { at: now, work });
172
+ return work;
173
+ }
174
+
113
175
  const inFlight = new Set();
114
176
 
115
177
  /**
116
178
  * Put the session's labels on its Herdr pane, if it has one and they are not there yet. Never
117
179
  * throws: a pane that can't be labelled is a line in the log, not a failed command. `touch` records
118
180
  * what was put on, so it isn't done again; `run` is the herdr invocation (replaced in tests).
181
+ * Without a `--session` name, the work is taken from the pane's terminal title.
119
182
  */
120
183
  export async function labelPane(session, { log = () => {}, touch = () => {}, run = runHerdr } = {}) {
121
- const commands = labelCommands(session);
122
- if (!commands.length || inFlight.has(session.id)) return { labelled: false };
184
+ if (!session?.herdr?.tab || !session?.herdr?.pane || inFlight.has(session.id)) return { labelled: false };
123
185
  inFlight.add(session.id);
186
+ let commands;
187
+ try {
188
+ commands = labelCommands({ ...session, name: await workFor(session, run) });
189
+ } catch {
190
+ commands = [];
191
+ }
192
+ if (!commands.length) {
193
+ inFlight.delete(session.id);
194
+ return { labelled: false };
195
+ }
124
196
  const done = { ...(session.herdrLabel ?? {}) };
125
197
  try {
126
198
  for (const args of commands) {
@@ -18,6 +18,7 @@ import { routeMessage } from "./router.mjs";
18
18
  import * as progressStore from "./progress.mjs";
19
19
  import * as sessions from "./sessions.mjs";
20
20
  import * as decisions from "./decisions.mjs";
21
+ import * as activity from "./activity.mjs";
21
22
 
22
23
  const KEEP = 200;
23
24
 
@@ -166,6 +167,20 @@ export async function ingest(message, { log = () => {}, live = false, waiting =
166
167
  const route = entry.route;
167
168
  const files = entry.files ?? [];
168
169
  log(`message ${message.ts} → ${route.kind}${route.key ? ` ${route.key}` : route.id ? ` ${route.id}` : ""}`);
170
+ // An answer in a question's thread is logged when the question settles, as the answer it is.
171
+ if (route.kind !== "decision") {
172
+ const to = route.kind === "session" ? sessions.get(route.id) : route.kind === "progress" && route.session ? sessions.get(route.session) : null;
173
+ const where = { progress: "a note on the progress message", ask: "asked which agent", inbox: "saved for the next agent" }[route.kind] ?? "";
174
+ activity.record({
175
+ from: "user",
176
+ kind: "message",
177
+ agent: to ? activity.agentOf(sessions.decorate(to)) : null,
178
+ text: text || (files.length ? files.map((f) => f.name).join(", ") : ""),
179
+ detail: where,
180
+ at: tsIso(message.ts),
181
+ ref: message.ts,
182
+ });
183
+ }
169
184
 
170
185
  await addReaction(channel, message.ts, reactionFor(route)).catch((e) => log(`reaction failed: ${e.message}`));
171
186
 
@@ -268,6 +283,7 @@ export async function resolvePick(messageTs, sessionId, log = () => {}) {
268
283
  const chosen = sessionId === NOBODY ? null : sessions.get(sessionId);
269
284
  const route = chosen ? { kind: "session", id: chosen.id, via: "picked" } : { kind: "inbox" };
270
285
  writeState({ ...state, messages: state.messages.map((m) => (m.ts === messageTs ? { ...m, route } : m)) });
286
+ activity.retarget(messageTs, chosen ? activity.agentOf(sessions.decorate(chosen)) : null, chosen ? "" : "saved for the next agent");
271
287
 
272
288
  if (chosen) {
273
289
  deliver(chosen.id, { text: entry.text, by: entry.user, at: tsIso(entry.ts), ts: entry.ts, channel: entry.channel, via: "picked" });