toolroll 0.9.95 → 0.9.96

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/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.96 — 2026-11-08
4
+
5
+ - **Tables in Telegram are readable on a phone.** Each row is a short list item with its name in full, and anything shared by every row is said once at the top, instead of a squeezed grid of cut-off words.
6
+ - **No more false git alerts while you use Claude Code beside Toolroll.** A Claude Code plugin briefly links a checkout's index while it looks at changes; Toolroll counted that as tampering in other checkouts of the same repository and stopped releases and builds. Real tampering is still caught.
7
+
3
8
  ## 0.9.95 — 2026-11-07
4
9
 
5
10
  - **Cards in chat, and approving from chat.** Slack, Discord and Telegram show tasks, plans and results as proper cards. Anything that needs approval can be approved there by a person with authority, the same as on the web; long terms link to the review page.
@@ -3,8 +3,11 @@
3
3
  * made inert for each app, so a value can't become a link, a mention or formatting. Whatever an app can't show comes out
4
4
  * as readable text, and every renderer has the same plain version to fall back on.
5
5
  */
6
- import type { ChatCard } from "./contracts/chat-card.js";
6
+ import type { ChatCard, ChatCardBlock } from "./contracts/chat-card.js";
7
7
  import { type Html } from "./html.js";
8
+ type Table = Extract<ChatCardBlock, {
9
+ kind: "table";
10
+ }>;
8
11
  /** A Text part is the lead's (a model's) words: always labelled as such, on every app, never as a record. */
9
12
  export declare const LEAD_NOTE_WORDS = "The lead's note, not Toolroll's records";
10
13
  /** The whole card as plain text: the version every app can show. */
@@ -24,3 +27,17 @@ export declare function telegramCardMessage(card: ChatCard): {
24
27
  };
25
28
  /** The card as the web chat's own markup; every value is escaped by `html`. */
26
29
  export declare function webCardHtml(card: ChatCard): Html;
30
+ /**
31
+ * A table's rows for a phone: each row's first value in full as its name, then its other values on one line ("queued ·
32
+ * standing-orders · 3h ago"; a number keeps its column's label: "Failed attempts 2"). A column with the same value in
33
+ * every shown row is said once above the rows instead ("All: queued · Failed attempts 0").
34
+ */
35
+ export declare function telegramTableRows(block: Table, keep: number): {
36
+ same: string;
37
+ rows: {
38
+ name: string;
39
+ rest: string;
40
+ }[];
41
+ more: number;
42
+ };
43
+ export {};
@@ -139,6 +139,16 @@ export function discordCardEmbed(card, plain) {
139
139
  // ---- Telegram ------------------------------------------------------------------------------------------------------
140
140
  /** The card as one Telegram message: plain words, tables and charts as `pre` entities, so nothing in a value is parsed. */
141
141
  export function telegramCardMessage(card) {
142
+ // A phone can't fit a table's columns side by side, so each row is a short list item. Past Telegram's limit, rows are
143
+ // cut from the end (counted in "and N more") before falling back to the plain version.
144
+ for (let keep = TELEGRAM_ROWS; keep >= 1; keep = Math.floor(keep / 2)) {
145
+ const message = telegramCardWith(card, block => Math.min(block.rows.length, keep));
146
+ if (message.text.length <= 4000)
147
+ return message;
148
+ }
149
+ return { text: cardPlainText(card).slice(0, 4000), entities: [] };
150
+ }
151
+ function telegramCardWith(card, rowsFor) {
142
152
  const entities = [];
143
153
  let text = "";
144
154
  const add = (words, type) => {
@@ -168,7 +178,16 @@ export function telegramCardMessage(card) {
168
178
  add(one, "bold");
169
179
  if (block.origin === "report")
170
180
  add(reportWords);
171
- add(tableLines(block, 40).join("\n"), "pre");
181
+ const listed = telegramTableRows(block, rowsFor(block));
182
+ if (listed.same !== "")
183
+ add(listed.same);
184
+ for (const row of listed.rows) {
185
+ add(row.name, "bold");
186
+ if (row.rest !== "")
187
+ text += `\n${row.rest}`;
188
+ }
189
+ if (listed.more > 0)
190
+ add(moreWords(listed.more));
172
191
  }
173
192
  else if (block.kind === "chart") {
174
193
  for (const one of headOf(card, block.heading))
@@ -180,8 +199,7 @@ export function telegramCardMessage(card) {
180
199
  add(one);
181
200
  if (card.actions.length > 0)
182
201
  add(`Confirm below: ${card.actions.map(one => one.label).join(", ")}`);
183
- // Telegram's own limit: past it, the whole plain version, cut, with no entities that could point past the end.
184
- return text.length <= 4000 ? { text, entities } : { text: cardPlainText(card).slice(0, 4000), entities: [] };
202
+ return { text, entities };
185
203
  }
186
204
  // ---- web chat ------------------------------------------------------------------------------------------------------
187
205
  const CHART_COLORS = 6;
@@ -221,3 +239,22 @@ export function webCardHtml(card) {
221
239
  });
222
240
  return html `<div class="tc-card">${joinHtml(blocks)}${joinHtml(footWords(card).map(one => html `<p class="tc-note">${one}</p>`))}</div>`;
223
241
  }
242
+ /** How many rows of a table a Telegram card shows before "and N more". */
243
+ const TELEGRAM_ROWS = 20;
244
+ /**
245
+ * A table's rows for a phone: each row's first value in full as its name, then its other values on one line ("queued ·
246
+ * standing-orders · 3h ago"; a number keeps its column's label: "Failed attempts 2"). A column with the same value in
247
+ * every shown row is said once above the rows instead ("All: queued · Failed attempts 0").
248
+ */
249
+ export function telegramTableRows(block, keep) {
250
+ const shown = block.rows.slice(0, Math.max(0, keep));
251
+ const more = block.more + block.rows.length - shown.length;
252
+ const cell = (at, value) => block.columns[at].numeric ? `${block.columns[at].label} ${value}` : value;
253
+ const constant = block.columns.map((_, at) => at > 0 && shown.length > 1 && shown.every(row => row[at] === shown[0][at]));
254
+ const same = constant.flatMap((yes, at) => yes && shown[0][at] !== "" ? [cell(at, shown[0][at])] : []);
255
+ const rows = shown.map(row => ({
256
+ name: row[0] === "" ? "(untitled)" : row[0],
257
+ rest: row.flatMap((value, at) => at === 0 || constant[at] || value === "" ? [] : [cell(at, value)]).join(" · "),
258
+ }));
259
+ return { same: same.length === 0 ? "" : `All: ${same.join(" · ")}`, rows, more };
260
+ }
@@ -737,8 +737,8 @@ const WORKTREE_STATE_DIRS = ["sequencer", "rebase-merge", "rebase-apply"];
737
737
  * state, a split index's shared files); and the object store (the common `objects`, and a submodule git directory's
738
738
  * own under modules/): its fan-out folders' entries for links, `info` and `pack` all the way down, but not each loose
739
739
  * object (a big repository has very many, and hard-linked objects are legitimate). Any link is refused, so is a folder
740
- * that can't be listed, and so is any file walked deep with a second hard link (git appends to a log in place). The
741
- * exemption is by place only: an `objects` anywhere else (under refs/, logs/, worktrees/) is walked like the rest. For each one found, where it leads (for an incident's record).
740
+ * that can't be listed, and so is any file walked deep with a second hard link (git appends to a log in place), but for
741
+ * another checkout's index (siblingIndexes: still never a link). The exemptions are by place only: an `objects` anywhere else (under refs/, logs/, worktrees/) is walked like the rest. For each one found, where it leads (for an incident's record).
742
742
  *
743
743
  * Checked when the view is made and again after git ran; between the two, a process that outlived the agent's turn
744
744
  * can still swap one of these for a link and have git write through it mid-command. That window is an accepted
@@ -749,6 +749,7 @@ function pinned(dirs) {
749
749
  const seen = new Set();
750
750
  let budget = 400_000;
751
751
  const stores = objectStores(dirs.common);
752
+ const siblings = siblingIndexes(dirs);
752
753
  /** `deep`: every entry all the way down, each file held to one hard link. `shallow`: the folder's own entries
753
754
  * checked for links only. `store`: an object store (objects/: its fan-out folders shallow, `info` and `pack` deep
754
755
  * without the hard-link test). A link, or a folder that can't be listed, is swapped. */
@@ -776,7 +777,10 @@ function pinned(dirs) {
776
777
  swapped.push({ path, leadsTo: real });
777
778
  return;
778
779
  }
779
- if (held.isFile() && mode === "deep" && held.nlink > 1) {
780
+ // Another checkout's own index, by exact place (siblingIndexes): held to being no link, but a second name is not
781
+ // counted. A tool in that checkout may hard-link it for a moment (Claude Code's security-guidance plugin does, to
782
+ // diff against a copy), and git never writes another checkout's index, nor any index in place.
783
+ if (held.isFile() && mode === "deep" && held.nlink > 1 && !siblings.has(path)) {
780
784
  swapped.push({ path, leadsTo: null });
781
785
  return;
782
786
  }
@@ -886,6 +890,32 @@ function objectStores(common) {
886
890
  visit(join(common, "modules"), 0);
887
891
  return stores;
888
892
  }
893
+ /**
894
+ * The index files of the repository's other checkouts, by exact place, enumerated before the walk: `index` directly in
895
+ * each worktree folder of the common `worktrees` that isn't this checkout's own git directory, and, for a linked
896
+ * checkout, the main checkout's `index` in the common folder. The walk still refuses any of them that is a link; it
897
+ * only doesn't count a second hard link to one (pinned). The view links only this checkout's own index, and git writes
898
+ * an index only by renaming a new file over it, so a hard link at another checkout's can't take a write of the plane's
899
+ * anywhere. Measured (Nov 2026): Claude Code's security-guidance plugin hard-links the index of the checkout a session
900
+ * runs in to a temporary copy for up to seconds at a time, and a release gate that ran meanwhile in another checkout of
901
+ * the repository was stopped as an incident naming that checkout's index.
902
+ */
903
+ function siblingIndexes(dirs) {
904
+ const indexes = new Set();
905
+ if (dirs.linked)
906
+ indexes.add(join(dirs.common, "index"));
907
+ let ids = [];
908
+ try {
909
+ ids = readdirSync(join(dirs.common, "worktrees"));
910
+ }
911
+ catch { /* none (an unlistable folder is the walk's to refuse) */ }
912
+ for (const id of ids) {
913
+ const folder = join(dirs.common, "worktrees", id);
914
+ if (folder !== dirs.gitdir)
915
+ indexes.add(join(folder, "index"));
916
+ }
917
+ return indexes;
918
+ }
889
919
  function anchorOf(path) {
890
920
  const anchor = [];
891
921
  for (let at = path;; at = dirname(at)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.95",
3
+ "version": "0.9.96",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"