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 +5 -0
- package/dist/card-render.d.ts +18 -1
- package/dist/card-render.js +40 -3
- package/dist/teardown-git.js +33 -3
- package/package.json +1 -1
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.
|
package/dist/card-render.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/card-render.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/teardown-git.js
CHANGED
|
@@ -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)
|
|
741
|
-
*
|
|
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
|
-
|
|
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.
|
|
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"
|