passalong 0.5.0 → 0.6.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/bin/passalong CHANGED
@@ -41,7 +41,8 @@
41
41
  // passalong now what this worktree holds and what is waiting (the hooks call --hook)
42
42
  // passalong mcp run the MCP server over stdio
43
43
  // passalong version print the installed version (also -v, --version)
44
- import { execFileSync, spawnSync } from "node:child_process";
44
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
45
+ import { createHash } from "node:crypto";
45
46
  import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
46
47
  import { homedir } from "node:os";
47
48
  import { dirname, join, resolve } from "node:path";
@@ -130,6 +131,53 @@ const MONTHS = "January February March April May June July August September Octo
130
131
  /** "September 2026" from an ISO timestamp, without pulling in a date library for one heading. */
131
132
  const monthOf = (iso) => `${MONTHS[Number(iso.slice(5, 7)) - 1]} ${iso.slice(0, 4)}`;
132
133
 
134
+ // ---- the status line cache --------------------------------------------------------------------------
135
+
136
+ /** One small file per working directory, so two sessions in two repos each see their own. */
137
+ const statusPath = (cwd) =>
138
+ join(store.HOME, "statusline", `${createHash("sha1").update(cwd).digest("hex").slice(0, 16)}.json`);
139
+
140
+ function writeStatus(cwd, text) {
141
+ const p = statusPath(cwd);
142
+ mkdirSync(dirname(p), { recursive: true });
143
+ writeFileSync(p, JSON.stringify({ at: Date.now(), text }));
144
+ }
145
+
146
+ /** How old a cached line may be before a refresh is started behind it. */
147
+ const STATUS_TTL_MS = 30_000;
148
+
149
+ /**
150
+ * Print the cached line for this directory at once, and when it is stale start a detached refresh
151
+ * that writes the next one. The first run in a directory prints a placeholder; the refresh lands
152
+ * within a second or two, and the next update shows it. Offline or signed out, it says nothing.
153
+ */
154
+ async function statusLine() {
155
+ let input = {};
156
+ if (!process.stdin.isTTY) {
157
+ let raw = "";
158
+ for await (const chunk of process.stdin) raw += chunk;
159
+ try {
160
+ input = JSON.parse(raw || "{}");
161
+ } catch {}
162
+ }
163
+ if (!api.loggedIn()) return;
164
+ const cwd = input.workspace?.current_dir || input.cwd || process.cwd();
165
+ let cached = null;
166
+ try {
167
+ cached = JSON.parse(readFileSync(statusPath(cwd), "utf8"));
168
+ } catch {}
169
+ process.stdout.write(cached?.text ?? "passalong");
170
+ if (!cached || Date.now() - cached.at > STATUS_TTL_MS) {
171
+ // Marked fresh before the refresh starts, so a burst of updates starts one refresh, not ten.
172
+ writeStatus(cwd, cached?.text ?? "passalong");
173
+ spawn(process.execPath, [fileURLToPath(import.meta.url), "now", "--statusline-refresh", cwd], {
174
+ detached: true,
175
+ stdio: "ignore",
176
+ env: process.env,
177
+ }).unref();
178
+ }
179
+ }
180
+
133
181
  const commands = {
134
182
  async share(f) {
135
183
  let path = f._[0];
@@ -598,14 +646,18 @@ const commands = {
598
646
  // over them, and a no-op when they are already there.
599
647
  const settingsPath = join(homedir(), ".claude", "settings.json");
600
648
  try {
601
- const { withHooks } = await import("../src/hooks.js");
649
+ const { withHooks, withStatusLine } = await import("../src/hooks.js");
602
650
  const before = existsSync(settingsPath) ? JSON.parse(readFileSync(settingsPath, "utf8")) : {};
603
- const after = withHooks(before);
651
+ const after = withStatusLine(withHooks(before));
652
+ if (before.statusLine && before.statusLine.command !== "passalong now --statusline")
653
+ console.error(
654
+ "you already have a status line, so it was left alone. To show Passalong in it, call `passalong now --statusline` from your script",
655
+ );
604
656
  if (JSON.stringify(after) === JSON.stringify(before)) console.error("Claude Code hooks already installed");
605
657
  else {
606
658
  mkdirSync(dirname(settingsPath), { recursive: true });
607
659
  writeFileSync(settingsPath, `${JSON.stringify(after, null, 2)}\n`);
608
- console.error(`installed Claude Code hooks (session start, stop) → ${settingsPath}`);
660
+ console.error(`installed Claude Code hooks (session start, stop) and status line → ${settingsPath}`);
609
661
  }
610
662
  } catch (err) {
611
663
  console.error(`could not install Claude Code hooks (${err.message}); ${settingsPath} left as it was`);
@@ -621,7 +673,18 @@ const commands = {
621
673
  * nothing at all when anything goes wrong — a hook must never get in the way of a session.
622
674
  */
623
675
  async now(f) {
624
- const { nowText, stopVerdict } = await import("../src/hooks.js");
676
+ const { nowText, statusText, stopVerdict } = await import("../src/hooks.js");
677
+ // The status line: printed from a cache, refreshed behind it. Claude Code cancels a status
678
+ // line still running when the next update comes, so this never waits on the network.
679
+ if (f.statusline) return statusLine(f);
680
+ if (f["statusline-refresh"]) {
681
+ const cwd = typeof f["statusline-refresh"] === "string" ? f["statusline-refresh"] : process.cwd();
682
+ try {
683
+ const r = await passalong.now({ cwd });
684
+ writeStatus(cwd, statusText({ held: r.held, needs: r.needs, ready: r.waiting.ready }));
685
+ } catch {}
686
+ return;
687
+ }
625
688
  if (!f.hook) {
626
689
  const r = await passalong.now();
627
690
  console.log(nowText(r) || "Nothing held here, and nothing waiting.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "passalong",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Hand a finished implementation from one context to another, in a form an AI agent can act on.",
5
5
  "keywords": [
6
6
  "mcp",
package/src/api.js CHANGED
@@ -122,6 +122,7 @@ export const releaseTask = (id) =>
122
122
  call(`/v1/tasks/${encodeURIComponent(id)}/release`, { method: "POST", body: {} });
123
123
  /** The four verbs every guide answers to. See docs/V2.md §11. */
124
124
  export const working = () => call("/v1/working");
125
+ export const handedIn = () => call("/v1/handed_in");
125
126
  export const take = (body) => call("/v1/take", { method: "POST", body });
126
127
  export const progress = (id, body) =>
127
128
  call(`/v1/guides/${encodeURIComponent(id)}/progress`, { method: "PUT", body });
package/src/hooks.js CHANGED
@@ -80,3 +80,47 @@ export function withHooks(settings = {}) {
80
80
  }
81
81
  return out;
82
82
  }
83
+
84
+ // ---- the status line -------------------------------------------------------------------------------
85
+ //
86
+ // Claude Code draws a status line from a command's output, under the prompt, all session long. It is
87
+ // the one place a person sees what their agent holds without asking. The command is
88
+ // `passalong now --statusline`, which prints from a local cache and refreshes it in the background:
89
+ // Claude Code cancels a status line that is still running when the next update arrives, so a
90
+ // network call in the foreground would leave the line blank.
91
+
92
+ /** The status line command, as written into settings. */
93
+ export const STATUS_LINE = "passalong now --statusline";
94
+
95
+ /** Keep a one-line note to what fits beside everything else in a terminal footer. */
96
+ const clip = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
97
+
98
+ /**
99
+ * One line: what this worktree's agent holds, then what needs you and what is ready here.
100
+ * `needs` counts everything waiting on the person: work handed in for review, agents stuck on
101
+ * them, and guides handed to them.
102
+ */
103
+ export function statusText({ held, needs = 0, ready = 0 }) {
104
+ const parts = [];
105
+ if (held) {
106
+ const said = held.note ? ` "${clip(held.note, 32)}"` : "";
107
+ parts.push(`▸ ${held.id}${held.state === "stalled" ? " (stalled)" : ""}${said}`);
108
+ } else parts.push("passalong");
109
+ if (needs) parts.push(`${needs} need${needs === 1 ? "s" : ""} you`);
110
+ if (ready) parts.push(`${ready} ready`);
111
+ if (parts.length === 1 && !held) parts.push("clear");
112
+ return parts.join(" · ");
113
+ }
114
+
115
+ /**
116
+ * Settings with the status line added, when there is none. A status line someone already has is
117
+ * theirs — a script with their git branch or their context meter — and setup never replaces it.
118
+ * `refreshInterval` keeps it current while the session sits idle.
119
+ */
120
+ export function withStatusLine(settings = {}) {
121
+ if (settings.statusLine) return settings;
122
+ return {
123
+ ...structuredClone(settings),
124
+ statusLine: { type: "command", command: STATUS_LINE, refreshInterval: 30 },
125
+ };
126
+ }
package/src/passalong.js CHANGED
@@ -376,18 +376,31 @@ export async function now({ cwd = process.cwd() } = {}) {
376
376
  // Read, never minted: the hooks run in every repo a session opens, and a worktree that has never
377
377
  // taken anything has no agent — and should not be given a .passalong/ folder for asking.
378
378
  const who = knownAgent(cwd);
379
- const { working } = await api.working();
380
- const held = (who.agent && working.find((w) => w.agent === who.agent && w.by?.you)) || null;
381
- const waiting = { ready: 0, inbox: 0 };
382
- if (!held) {
383
- const [t, i] = await Promise.allSettled([api.tasks(), api.inbox()]);
384
- if (t.status === "fulfilled")
385
- waiting.ready = t.value.tasks.filter(
386
- (x) => x.state === "ready" && x.target === who.repo,
387
- ).length;
388
- if (i.status === "fulfilled") waiting.inbox = i.value.guides.length;
389
- }
390
- return { held, waiting, agent: who.agent };
379
+ const [w, t, i, h] = await Promise.allSettled([
380
+ api.working(),
381
+ api.tasks(),
382
+ api.inbox(),
383
+ api.handedIn(),
384
+ ]);
385
+ if (w.status === "rejected") throw w.reason;
386
+ const held =
387
+ (who.agent && w.value.working.find((x) => x.agent === who.agent && x.by?.you)) || null;
388
+ const tasks = t.status === "fulfilled" ? t.value.tasks : [];
389
+ const waiting = {
390
+ ready: tasks.filter((x) => x.state === "ready" && x.target === who.repo).length,
391
+ inbox: i.status === "fulfilled" ? i.value.guides.length : 0,
392
+ };
393
+ // Everything waiting on the person, the way the hub's Needs you counts it: their tasks handed in
394
+ // or stuck, handoffs of theirs handed in, and guides handed to them.
395
+ const review = tasks.filter(
396
+ (x) =>
397
+ x.mine &&
398
+ (x.state === "review" ||
399
+ x.state === "stalled" ||
400
+ (x.state === "claimed" && /^BLOCKED:/i.test(x.claim?.note || ""))),
401
+ ).length;
402
+ const handed = h.status === "fulfilled" ? h.value.handed_in.length : 0;
403
+ return { held, waiting, needs: review + handed + waiting.inbox, agent: who.agent };
391
404
  }
392
405
 
393
406
  /**