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 +68 -5
- package/package.json +1 -1
- package/src/api.js +1 -0
- package/src/hooks.js +44 -0
- package/src/passalong.js +25 -12
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
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
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
/**
|