passalong 0.19.0 → 0.20.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "passalong",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Hand tasks, bugs and finished work to AI coding agents, and get back a write-up with evidence.",
5
5
  "mcpName": "io.github.codellyson/passalong",
6
6
  "keywords": [
package/src/hooks.js CHANGED
@@ -21,28 +21,47 @@ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
21
21
  /**
22
22
  * What this worktree's agent holds and what is waiting, as a few lines for an agent to read.
23
23
  * Empty when there is nothing to say, so a session in an unrelated repo is not told anything.
24
+ *
25
+ * `held` is a list: an agent may hold several things, and several sessions in one worktree are one
26
+ * agent to the queue. So a session is told what the worktree holds, and to carry on only with what
27
+ * was its own before a restart — never to resume another session's work.
24
28
  */
25
- export function nowText({ held, waiting = {} }) {
26
- if (held) {
29
+ export function nowText({ held = [], waiting = {} }) {
30
+ if (held.length === 1) {
31
+ const [h] = held;
27
32
  const lines = [
28
33
  // `kind` is required on what /v1/working sends. The `|| "task"` that used to be here was
29
34
  // both dead and wrong — absent has meant transfer since kinds existed, never task.
30
- `Passalong: You hold ${held.id} (${held.kind}): ${held.title || held.id}.`,
35
+ `Passalong: This worktree holds ${h.id} (${h.kind}): ${h.title || h.id}.`,
31
36
  ];
32
- if (held.note) lines.push(`Last note: "${held.note}".`);
37
+ if (h.note) lines.push(`Last note: "${h.note}".`);
33
38
  // The one thing here that is new information rather than a reminder: the person has written, and
34
39
  // nothing else in the session will say so until the agent next checks in.
35
- if (held.replies)
40
+ if (h.replies)
36
41
  lines.push(
37
- `${plural(held.replies, "message", "messages")} from the person ${held.replies === 1 ? "is" : "are"} ` +
38
- `waiting for you: call take ${held.id} to read ${held.replies === 1 ? "it" : "them"} before anything else.`,
42
+ `${plural(h.replies, "message", "messages")} from the person ${h.replies === 1 ? "is" : "are"} ` +
43
+ `waiting: call take ${h.id} to read ${h.replies === 1 ? "it" : "them"} before anything else.`,
39
44
  );
40
- if (held.state === "stalled")
45
+ if (h.state === "stalled")
41
46
  lines.push("It is marked stalled: nobody has heard from its agent in 30 minutes.");
42
47
  lines.push(
43
- `To carry on, call take ${held.id} (it resumes what you hold), then progress at each ` +
44
- "milestone. When it is done, hand_in with evidence — what you ran and what came back — " +
45
- "so keep it as you go; if it is not yours or you are stuck, pass with the reason.",
48
+ `If it was yours before this session restarted, call take ${h.id} to carry on, then ` +
49
+ "progress at each milestone. When it is done, hand_in with evidence — what you ran and " +
50
+ "what came back — so keep it as you go; if it is not yours or you are stuck, pass with " +
51
+ "the reason. If another session here is on it, leave it to that session.",
52
+ );
53
+ return lines.join("\n");
54
+ }
55
+ if (held.length > 1) {
56
+ const lines = [`Passalong: This worktree holds ${held.length} things:`];
57
+ for (const h of held)
58
+ lines.push(
59
+ `- ${h.id} (${h.kind}): ${h.title || h.id}${h.state === "stalled" ? " — stalled" : ""}` +
60
+ `${h.replies ? ` — ${plural(h.replies, "message", "messages")} waiting` : ""}`,
61
+ );
62
+ lines.push(
63
+ "Several sessions may be working here. Carry on only with what was yours before this " +
64
+ "session restarted (take <id> resumes it) and leave the rest to the sessions on them.",
46
65
  );
47
66
  return lines.join("\n");
48
67
  }
@@ -59,19 +78,22 @@ export function nowText({ held, waiting = {} }) {
59
78
  * Whether a session may stop. `input` is what Claude Code sends the Stop hook on stdin. Null lets
60
79
  * it stop; an object is the hook's answer, blocking once with what to call. `stop_hook_active`
61
80
  * means this stop is already the session continuing because of this hook, and blocking again
62
- * would leave it no way to end.
81
+ * would leave it no way to end. The hook cannot tell which session took what, so it names
82
+ * everything still open here and lets the session settle its own.
63
83
  */
64
- export function stopVerdict({ held, input = {} }) {
84
+ export function stopVerdict({ held = [], input = {} }) {
65
85
  // An agent that asked a question was told to stop and wait, so it must be let: blocking it would
66
86
  // make the instruction and the hook contradict each other, and the agent would pick one.
67
- if (!held || held.asking || input.stop_hook_active) return null;
87
+ const open = held.filter((h) => !h.asking);
88
+ if (!open.length || input.stop_hook_active) return null;
89
+ const ids = open.map((h) => h.id).join(", ");
68
90
  return {
69
91
  decision: "block",
70
92
  reason:
71
- `You still hold ${held.id}: ${held.title || held.id}. Before ending, do one of: ` +
72
- `hand_in ${held.id} with your evidence if it is done; pass ${held.id} with the reason if ` +
73
- `it is not yours or you are stuck; or, if the user is stopping mid-way, progress ${held.id} ` +
74
- "with a note saying what is left — then end.",
93
+ `This worktree still holds ${ids}. For anything there you worked on in this session, do ` +
94
+ "one of before ending: hand_in it with your evidence if it is done; pass it with the reason " +
95
+ "if it is not yours or you are stuck; or, if the user is stopping mid-way, progress it with " +
96
+ "a note saying what is left. Leave what other sessions are on — then end.",
75
97
  };
76
98
  }
77
99
 
@@ -112,15 +134,19 @@ const clip = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
112
134
  * `needs` counts everything waiting on the person: work handed in for review, agents stuck on
113
135
  * them, and guides handed to them.
114
136
  */
115
- export function statusText({ held, needs = 0, ready = 0 }) {
137
+ export function statusText({ held = [], needs = 0, ready = 0 }) {
116
138
  const parts = [];
117
- if (held) {
118
- const said = held.note ? ` "${clip(held.note, 32)}"` : "";
119
- parts.push(`▸ ${held.id}${held.state === "stalled" ? " (stalled)" : ""}${said}`);
139
+ if (held.length === 1) {
140
+ const [h] = held;
141
+ const said = h.note ? ` "${clip(h.note, 32)}"` : "";
142
+ parts.push(`▸ ${h.id}${h.state === "stalled" ? " (stalled)" : ""}${said}`);
143
+ } else if (held.length > 1) {
144
+ const stalled = held.filter((h) => h.state === "stalled").length;
145
+ parts.push(`▸ ${held.length} held${stalled ? ` (${stalled} stalled)` : ""}`);
120
146
  } else parts.push("passalong");
121
147
  if (needs) parts.push(`${needs} need${needs === 1 ? "s" : ""} you`);
122
148
  if (ready) parts.push(`${ready} ready`);
123
- if (parts.length === 1 && !held) parts.push("clear");
149
+ if (parts.length === 1 && !held.length) parts.push("clear");
124
150
  return parts.join(" · ");
125
151
  }
126
152
 
package/src/mcp.js CHANGED
@@ -334,8 +334,8 @@ export function buildServer() {
334
334
  "session into a guide (guide_template kind transfer shows the shape) and call " +
335
335
  "publish_guide, with `to` as team, team/@handle for one teammate, or team/#group for the " +
336
336
  "people who do a thing. " +
337
- "BEFORE PUBLISHING ANYTHING, CHECK WHAT IS ALREADY OPEN: call take with no id, or read the " +
338
- "inbox, and see what you hold. If this session's work answers something you hold, hand_in " +
337
+ "BEFORE PUBLISHING ANYTHING, CHECK WHAT IS ALREADY OPEN: call board (it lists what this " +
338
+ "worktree holds) or read the inbox. If this session's work answers something you hold, hand_in " +
339
339
  "that — never publish a second guide about it. A transfer guide is for work that has to " +
340
340
  "cross a boundary: another repo, another machine, a teammate without your branch. Work you " +
341
341
  "committed and pushed where the team can already see it has crossed no boundary, and a " +
@@ -944,12 +944,17 @@ export function buildServer() {
944
944
  "The state of this user's transfers as queues: waiting on you (handed to you, not " +
945
945
  "pulled), not working (someone gave it a failing verdict — the most urgent), in flight " +
946
946
  "(handed over, nobody has taken it — `stale` means it has sat over a week), and landed " +
947
- "(someone else has it). Use it to answer 'what is outstanding?'.",
947
+ "(someone else has it), and `held`: what this worktree's agents hold now. Use it to answer " +
948
+ "'what is outstanding?' and to see what you hold, without taking anything.",
948
949
  inputSchema: {},
949
950
  },
950
951
  async () => {
951
952
  try {
952
- return json(await passalong.board());
953
+ const [board, now] = await Promise.all([
954
+ passalong.board(),
955
+ passalong.now().catch(() => ({ held: [] })),
956
+ ]);
957
+ return json({ ...board, held: now.held });
953
958
  } catch (err) {
954
959
  return fail(err);
955
960
  }
@@ -1100,9 +1105,9 @@ export function buildServer() {
1100
1105
  annotations: ADDS,
1101
1106
  description:
1102
1107
  "Say you are doing it, and get it. With `id`, that guide — any kind: a task, a bug, a " +
1103
- "handoff. With no id, the next thing waiting for this worktree's agent. While you hold it " +
1104
- "no other agent can take it here, and its sender sees you are on it. One thing at a time: " +
1105
- "if you already hold something, finish or pass that first. If somebody else has it, the " +
1108
+ "handoff. With no id, the next thing waiting — always something new, never what you hold. " +
1109
+ "While you hold it no other agent can take it here, and its sender sees you are on it. You " +
1110
+ "may hold several at once; the person watches them on the board. If somebody else has it, the " +
1106
1111
  "answer says who — tell the user rather than doing the work twice. Every answer ends with " +
1107
1112
  "what to call next.",
1108
1113
  inputSchema: {
@@ -1525,8 +1530,8 @@ export function buildServer() {
1525
1530
  title: "Publish guide",
1526
1531
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
1527
1532
  description:
1528
- "Publish a guide from markdown (frontmatter + sections). Check what you hold first (take " +
1529
- "with no id): if this session answers something you hold, hand_in that instead, and if the " +
1533
+ "Publish a guide from markdown (frontmatter + sections). Check what you hold first " +
1534
+ "(board): if this session answers something you hold, hand_in that instead, and if the " +
1530
1535
  "work never left a branch the team can see, publish what is still open as a bug or a task " +
1531
1536
  "rather than a write-up of the fix. A transfer guide by default, or " +
1532
1537
  "a single bug with `kind: bug`; use file_bugs for more than one; or a task for work nobody " +
package/src/passalong.js CHANGED
@@ -264,7 +264,7 @@ export async function tasks() {
264
264
  }
265
265
 
266
266
  /**
267
- * Take the next task for this worktree's repo, or get back the one this worktree already holds.
267
+ * Take the next task for this worktree's repo — always a new one, since an agent may hold several.
268
268
  * `null` when there is nothing to do. Written to `.passalong/<id>.md` only when `save` asks.
269
269
  */
270
270
  export async function nextTask({ cwd = process.cwd(), any = false, save = false } = {}) {
@@ -542,7 +542,7 @@ export async function ask(id, question, { cwd = process.cwd() } = {}) {
542
542
 
543
543
  /**
544
544
  * What this worktree's agent holds right now, and what is waiting for it: what the session-start
545
- * and stop hooks read (src/hooks.js). `held` is null when it holds nothing. Counts are best effort
545
+ * and stop hooks read (src/hooks.js). `held` lists everything it holds, empty when nothing. Counts are best effort
546
546
  * — a hook must never fail a session over a count.
547
547
  */
548
548
  export async function now({ cwd = process.cwd() } = {}) {
@@ -557,8 +557,7 @@ export async function now({ cwd = process.cwd() } = {}) {
557
557
  api.handedIn(),
558
558
  ]);
559
559
  if (w.status === "rejected") throw w.reason;
560
- const held =
561
- (who.agent && w.value.working.find((x) => x.agent === who.agent && x.by?.you)) || null;
560
+ const held = who.agent ? w.value.working.filter((x) => x.agent === who.agent && x.by?.you) : [];
562
561
  const tasks = t.status === "fulfilled" ? t.value.tasks : [];
563
562
  const waiting = {
564
563
  ready: tasks.filter((x) => x.state === "ready" && x.target === who.repo).length,
@@ -1099,9 +1098,8 @@ export function workPrompt(t) {
1099
1098
  * hand_in land on the claim this took. `agent` is a command the prompt is appended to;
1100
1099
  * `claude -p` by default, or whatever PASSALONG_AGENT says.
1101
1100
  *
1102
- * An agent that exits without finishing stops the loop. Asking for the next task would hand the
1103
- * same one back, since a worktree holds one task at a time, and the loop would run the agent on it
1104
- * forever. The task stays claimed here, for the next run to resume or a person to release.
1101
+ * An agent that exits without finishing stops the loop, rather than piling up half-done work. The
1102
+ * task stays claimed here, for a person to see on the board and resume (take it) or release.
1105
1103
  */
1106
1104
  export async function work({
1107
1105
  cwd = process.cwd(),
@@ -1122,8 +1120,9 @@ export async function work({
1122
1120
  stdio: "inherit",
1123
1121
  env: { ...process.env, PASSALONG_TASK: t.id },
1124
1122
  });
1123
+ // Finishing is final, so a finished task is Done; "review" is a server from before that.
1125
1124
  const state = (await tasks()).find((x) => x.id === t.id)?.state;
1126
- if (state !== "review")
1125
+ if (state !== "done" && state !== "review")
1127
1126
  return {
1128
1127
  finished,
1129
1128
  stopped: "unfinished",
package/src/update.js CHANGED
@@ -15,7 +15,31 @@
15
15
  // feature would have shipped dead while looking fine in review. So the line is read from a file and
16
16
  // the file is refreshed by a process nobody waits for. What a person sees is a day old at worst,
17
17
  // which is the right age for "there is a new version".
18
- import { readConfig, writeConfig } from "./store.js";
18
+ //
19
+ // The answer is kept in its own file, never in config.json. The refresh runs beside whatever
20
+ // command started it, and config.json is read, patched and written whole: a refresh that read it
21
+ // just before `logout` or `use` wrote it, and wrote it back after, put the old accounts and the old
22
+ // default back. This file has one writer, so there is nothing to lose.
23
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { HOME } from "./store.js";
26
+
27
+ const FILE = join(HOME, "update.json");
28
+
29
+ function readUpdate() {
30
+ try {
31
+ return JSON.parse(readFileSync(FILE, "utf8"));
32
+ } catch {
33
+ return {};
34
+ }
35
+ }
36
+
37
+ function writeUpdate(held) {
38
+ if (!existsSync(HOME)) mkdirSync(HOME, { recursive: true, mode: 0o700 });
39
+ const tmp = `${FILE}.${process.pid}.tmp`;
40
+ writeFileSync(tmp, `${JSON.stringify(held)}\n`, { mode: 0o600 });
41
+ renameSync(tmp, FILE);
42
+ }
19
43
 
20
44
  /** How long a cached answer lasts before a refresh is worth starting. */
21
45
  export const CHECK_EVERY_MS = 24 * 60 * 60 * 1000;
@@ -70,7 +94,7 @@ export function cachedNotice({
70
94
  current,
71
95
  now = Date.now(),
72
96
  env = process.env,
73
- config = readConfig,
97
+ config = readUpdate,
74
98
  }) {
75
99
  if (env.PASSALONG_NO_UPDATE_CHECK === "1") return { notice: "", stale: false };
76
100
  const held = config();
@@ -90,7 +114,7 @@ export function cachedNotice({
90
114
  * trying again — `cachedNotice` reads an empty `update_latest` as a failure and uses the shorter
91
115
  * gap.
92
116
  */
93
- export async function refresh({ now = Date.now(), fetchImpl = fetch, save = writeConfig } = {}) {
117
+ export async function refresh({ now = Date.now(), fetchImpl = fetch, save = writeUpdate } = {}) {
94
118
  let latest = "";
95
119
  try {
96
120
  const res = await fetchImpl(REGISTRY, {