allaboard 0.4.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/README.md +9 -7
- package/dist/agent/claude/backend.js +73 -2
- package/dist/agent/claude/capabilities.js +4 -0
- package/dist/agent/claude/module.js +55 -0
- package/dist/agent/claude/translate.js +6 -2
- package/dist/agent/claude/trust.js +22 -0
- package/dist/chat/seam.js +3 -0
- package/dist/chat/slack/app/app.js +52 -4
- package/dist/chat/slack/app/delete-thread.js +90 -0
- package/dist/chat/slack/app/guards.js +44 -0
- package/dist/chat/slack/app/home-controls.js +8 -1
- package/dist/chat/slack/channels.js +23 -5
- package/dist/chat/slack/delete-thread.js +37 -0
- package/dist/chat/slack/delete.js +13 -7
- package/dist/chat/slack/home.js +194 -35
- package/dist/chat/slack/module.js +64 -14
- package/dist/chat/slack/onboarding.js +159 -0
- package/dist/chat/slack/reply/blocks.js +27 -11
- package/dist/chat/slack/reply/sinks.js +3 -1
- package/dist/chat/slack/tag.js +317 -0
- package/dist/chat/slack/texts.js +8 -0
- package/dist/chat/slack/thread.js +62 -2
- package/dist/chat/slack/tokens.js +15 -0
- package/dist/cli.js +43 -4
- package/dist/core/cleanup.js +66 -3
- package/dist/core/commands.js +40 -0
- package/dist/core/config.js +19 -11
- package/dist/core/files/listing.js +19 -0
- package/dist/core/flows/bind.js +29 -6
- package/dist/core/flows/flows.js +2 -0
- package/dist/core/flows/gone.js +39 -0
- package/dist/core/flows/inbound.js +3 -0
- package/dist/core/flows/messages.js +4 -2
- package/dist/core/flows/status.js +98 -48
- package/dist/core/flows/words.js +80 -1
- package/dist/core/guide.js +2 -0
- package/dist/core/port/cleanup.js +1 -0
- package/dist/core/port.js +1 -0
- package/dist/core/sessions/directory.js +47 -8
- package/dist/core/sessions/manager.js +32 -17
- package/dist/core/sessions/quote.js +57 -0
- package/dist/core/sessions/session.js +324 -35
- package/dist/core/sessions/title.js +98 -0
- package/dist/core/state.js +25 -0
- package/dist/core/texts.js +58 -3
- package/dist/core/wizard.js +306 -0
- package/dist/daemon.js +23 -11
- package/dist/host/program.js +22 -7
- package/dist/host/prompter.js +61 -0
- package/dist/module-setup.js +6 -0
- package/dist/version.js +4 -0
- package/package.json +5 -2
- package/slack-app-manifest.json +2 -1
|
@@ -6,16 +6,23 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
8
|
import { ConfigError, } from "../../core/port.js";
|
|
9
|
+
import { getLogger } from "../../log.js";
|
|
10
|
+
import { setupGuide } from "../../version.js";
|
|
9
11
|
import { ChatError } from "../seam.js";
|
|
10
12
|
import { socketReceiver } from "./app/app.js";
|
|
11
13
|
import { ChannelGuard } from "./app/guards.js";
|
|
12
14
|
import { prepareUploads } from "./attachments.js";
|
|
13
15
|
import { ThreadDeleter } from "./delete.js";
|
|
14
16
|
import { Home } from "./home.js";
|
|
17
|
+
import { slackSetup } from "./onboarding.js";
|
|
15
18
|
import { repliesClient, sharedClient } from "./reply/clients.js";
|
|
16
19
|
import { describe } from "./reply/errors.js";
|
|
17
20
|
import { UpdateLimiter } from "./reply/limiter.js";
|
|
21
|
+
import { RootEditor } from "./tag.js";
|
|
22
|
+
import { SETUP_SLACK_APP } from "./texts.js";
|
|
18
23
|
import { SlackChat } from "./thread.js";
|
|
24
|
+
import { wrongKind } from "./tokens.js";
|
|
25
|
+
export const logger = getLogger("allaboard.chat.slack.module");
|
|
19
26
|
/**
|
|
20
27
|
* The daemon's two Slack clients on one token (the retry policy is in `reply/clients.ts`): the
|
|
21
28
|
* shared one for everything but a reply, and the one replies are written with.
|
|
@@ -35,10 +42,16 @@ export function authField(answer, key) {
|
|
|
35
42
|
return value;
|
|
36
43
|
}
|
|
37
44
|
/**
|
|
38
|
-
*
|
|
39
|
-
* must be the owner's own, in the bot's workspace:
|
|
45
|
+
* The client of the owner's user token with the token's scopes, or null when no token is
|
|
46
|
+
* configured. The token must be the owner's own, in the bot's workspace: what it writes is
|
|
47
|
+
* written as whoever it belongs to, so it is checked once here for everything that uses it (the
|
|
48
|
+
* thread deleter, the root editor). The scopes are read from the same `auth.test`: the Web API
|
|
49
|
+
* reference for `auth.test` lists none (read 2026-10-19), but `@slack/web-api` 8.2.0 types
|
|
50
|
+
* `WebAPICallResult.response_metadata.scopes` and fills it from the `X-OAuth-Scopes` header of
|
|
51
|
+
* every response (`WebClient.js`), and a user token's `auth.test` answered
|
|
52
|
+
* `identify,chat:write,pins:write` on a dev app on 2026-10-19.
|
|
40
53
|
*/
|
|
41
|
-
export async function
|
|
54
|
+
export async function ownerClient(userToken, identity, clients = CLIENTS) {
|
|
42
55
|
if (userToken === null)
|
|
43
56
|
return null;
|
|
44
57
|
const owner = clients.owner(userToken);
|
|
@@ -47,6 +60,36 @@ export async function deleter(userToken, slack, identity, state, sessions, names
|
|
|
47
60
|
authField(who, "team_id") !== identity.teamId) {
|
|
48
61
|
throw new ConfigError("SLACK_USER_TOKEN must be the owner's own token, in the bot's workspace");
|
|
49
62
|
}
|
|
63
|
+
return { client: owner, scopes: who.response_metadata?.scopes ?? null };
|
|
64
|
+
}
|
|
65
|
+
/** The scope `!tag` and `!untag` need beyond what the Home tab's Delete does (`chat:write`). */
|
|
66
|
+
export const PINS_SCOPE = "pins:write";
|
|
67
|
+
/**
|
|
68
|
+
* Whether the owner's token can pin, which `!tag` needs and a title does not. A token made before
|
|
69
|
+
* the manifest asked for `pins:write` has the Delete's scope alone: tags are then not offered,
|
|
70
|
+
* with one line saying why, and Delete and titles keep working. A token whose scopes Slack did
|
|
71
|
+
* not report is not trusted with tags either.
|
|
72
|
+
*/
|
|
73
|
+
export function canTag(owner) {
|
|
74
|
+
if (owner === null)
|
|
75
|
+
return false;
|
|
76
|
+
if (owner.scopes === null) {
|
|
77
|
+
logger.warning(`Slack lists no scopes for SLACK_USER_TOKEN, so !tag and !untag are not offered: reinstall the app with the user scope ${PINS_SCOPE} (see ${setupGuide(SETUP_SLACK_APP)})`);
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
if (!owner.scopes.includes(PINS_SCOPE)) {
|
|
81
|
+
logger.warning(`SLACK_USER_TOKEN lacks the scope ${PINS_SCOPE}, so !tag is not offered until the app is reinstalled with that scope (see ${setupGuide(SETUP_SLACK_APP)})`);
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* What deletes a thread for the Home tab and for `!delete-thread`, when the owner has a client of
|
|
88
|
+
* their own.
|
|
89
|
+
*/
|
|
90
|
+
export function deleter(owner, slack, identity, state, sessions, names) {
|
|
91
|
+
if (owner === null)
|
|
92
|
+
return null;
|
|
50
93
|
return new ThreadDeleter(slack, owner, {
|
|
51
94
|
botUserId: identity.botUserId,
|
|
52
95
|
ownerUserId: identity.ownerUserId,
|
|
@@ -73,22 +116,21 @@ function variable(context, name) {
|
|
|
73
116
|
}
|
|
74
117
|
/** The shape of each token, checked before Slack is called: a swapped pair is told at once. */
|
|
75
118
|
function checkTokens(context) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
throw new ConfigError("SLACK_APP_TOKEN must be the app-level token (xapp-...)");
|
|
81
|
-
}
|
|
119
|
+
const wrong = wrongKind("SLACK_BOT_TOKEN", variable(context, "SLACK_BOT_TOKEN")) ??
|
|
120
|
+
wrongKind("SLACK_APP_TOKEN", variable(context, "SLACK_APP_TOKEN"));
|
|
121
|
+
if (wrong !== null)
|
|
122
|
+
throw new ConfigError(wrong);
|
|
82
123
|
const userToken = context.variables.SLACK_USER_TOKEN;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
124
|
+
const wrongUser = userToken === undefined ? null : wrongKind("SLACK_USER_TOKEN", userToken);
|
|
125
|
+
if (wrongUser !== null)
|
|
126
|
+
throw new ConfigError(wrongUser);
|
|
86
127
|
}
|
|
87
128
|
/** The registry's entry for Slack. */
|
|
88
129
|
export function slackChat(options = {}) {
|
|
89
130
|
return {
|
|
90
131
|
required: ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_OWNER_USER_ID"],
|
|
91
132
|
optional: ["SLACK_USER_TOKEN"],
|
|
133
|
+
setup: (instance) => slackSetup(instance, options.probe),
|
|
92
134
|
async build(context) {
|
|
93
135
|
checkTokens(context);
|
|
94
136
|
try {
|
|
@@ -129,10 +171,13 @@ async function assemble(options, context) {
|
|
|
129
171
|
// The provider is made last, from these parts; they ask it for the names when they show a
|
|
130
172
|
// line, which is after the core exists.
|
|
131
173
|
const names = () => chat.names();
|
|
132
|
-
const
|
|
174
|
+
const owner = await ownerClient(context.variables.SLACK_USER_TOKEN ?? null, identity, clients);
|
|
175
|
+
// One editor serves tags and titles, so their edits of a root queue behind one another.
|
|
176
|
+
const roots = owner === null ? null : new RootEditor(slack, owner.client);
|
|
177
|
+
const threadDeleter = deleter(owner?.client ?? null, slack, identity, state, {
|
|
133
178
|
release: (channelId, threadId) => context.core().sessions.release(channelId, threadId),
|
|
134
179
|
free: (channelId, threadId) => context.core().sessions.free(channelId, threadId),
|
|
135
|
-
}, names
|
|
180
|
+
}, names);
|
|
136
181
|
const home = new Home(slack, {
|
|
137
182
|
ownerUserId: identity.ownerUserId,
|
|
138
183
|
teamId: identity.teamId,
|
|
@@ -144,6 +189,7 @@ async function assemble(options, context) {
|
|
|
144
189
|
clean: (channelId) => threadDeleter.clean(channelId),
|
|
145
190
|
}),
|
|
146
191
|
update: () => context.core().updates?.line() ?? null,
|
|
192
|
+
cleanup: () => context.core().cleanup ?? null,
|
|
147
193
|
clock: context.clock,
|
|
148
194
|
});
|
|
149
195
|
const uploads = options.uploads ?? context.scratchDir;
|
|
@@ -156,11 +202,15 @@ async function assemble(options, context) {
|
|
|
156
202
|
clock: context.clock,
|
|
157
203
|
steady: context.steady,
|
|
158
204
|
agent: () => context.core().agent,
|
|
205
|
+
...(roots !== null && { roots, canTag: canTag(owner) }),
|
|
159
206
|
app: {
|
|
160
207
|
core: () => context.core(),
|
|
161
208
|
config: { botToken, allowedRoot: context.allowedRoot },
|
|
162
209
|
guard: new ChannelGuard(slack, identity),
|
|
163
210
|
home,
|
|
211
|
+
...(threadDeleter !== null && {
|
|
212
|
+
deleteThread: (channelId, threadId) => threadDeleter.delete(channelId, threadId),
|
|
213
|
+
}),
|
|
164
214
|
uploads,
|
|
165
215
|
receiver: (options.receiver ?? socketReceiver)(appToken),
|
|
166
216
|
appKey: appTokenKey(appToken),
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `allaboard setup` asks for Slack: the link that creates the app from the package's manifest,
|
|
3
|
+
* the three tokens, each checked with Slack as it is pasted, and the owner's member ID. The core
|
|
4
|
+
* runs the flow (`core/wizard.ts`); this file holds the questions and what a Slack answer means.
|
|
5
|
+
*
|
|
6
|
+
* A token is checked with the one call that needs no scope (Slack's method reference, read
|
|
7
|
+
* 2026-10-18): `auth.test` for the bot and user tokens, `apps.connections.open` for the app-level
|
|
8
|
+
* token, the only method documented for it. The latter only asks for a WebSocket URL; nothing
|
|
9
|
+
* here connects to it.
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { WebAPIPlatformError, WebClient } from "@slack/web-api";
|
|
13
|
+
import { silentLogger } from "./quiet-logger.js";
|
|
14
|
+
import { REQUEST_TIMEOUT_MS, SENT_ONCE } from "./reply/clients.js";
|
|
15
|
+
import { describe } from "./reply/errors.js";
|
|
16
|
+
import { wrongKind } from "./tokens.js";
|
|
17
|
+
/** The longest name Slack takes for an app (manifest reference, read 2026-10-17). */
|
|
18
|
+
export const APP_NAME_LIMIT = 35;
|
|
19
|
+
/** A client that sends each call once and logs nothing: the answer or the error is all there is. */
|
|
20
|
+
function probeClient(token) {
|
|
21
|
+
return new WebClient(token, {
|
|
22
|
+
...SENT_ONCE,
|
|
23
|
+
timeout: REQUEST_TIMEOUT_MS,
|
|
24
|
+
logger: silentLogger(),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
const MANIFEST = new URL("../../../slack-app-manifest.json", import.meta.url);
|
|
28
|
+
/**
|
|
29
|
+
* The package's manifest as the JSON that `api.slack.com/apps?new_app=1` takes, named for
|
|
30
|
+
* `instance`: a named instance's app and bot are called `allaboard-<name>`, the app's name cut to
|
|
31
|
+
* `APP_NAME_LIMIT`. `cut` says whether it was.
|
|
32
|
+
*/
|
|
33
|
+
export function manifestOf(instance) {
|
|
34
|
+
const manifest = JSON.parse(readFileSync(MANIFEST, "utf8"));
|
|
35
|
+
let cut = false;
|
|
36
|
+
if (instance.name !== null) {
|
|
37
|
+
const name = `${manifest.display_information.name}-${instance.name}`;
|
|
38
|
+
cut = name.length > APP_NAME_LIMIT;
|
|
39
|
+
manifest.display_information.name = name.slice(0, APP_NAME_LIMIT);
|
|
40
|
+
manifest.features.bot_user.display_name = name;
|
|
41
|
+
}
|
|
42
|
+
return { json: JSON.stringify(manifest), cut };
|
|
43
|
+
}
|
|
44
|
+
/** The link that opens Slack's "create an app" page with `manifest` filled in. */
|
|
45
|
+
export function creationLink(manifestJson) {
|
|
46
|
+
return `https://api.slack.com/apps?new_app=1&manifest_json=${encodeURIComponent(manifestJson)}`;
|
|
47
|
+
}
|
|
48
|
+
const MEMBER_ID = /^[UW][A-Z0-9]{7,}$/;
|
|
49
|
+
/** What Slack's refusal code means for the owner pasting a token. */
|
|
50
|
+
function refusalOf(variable, code) {
|
|
51
|
+
switch (code) {
|
|
52
|
+
case "invalid_auth":
|
|
53
|
+
case "not_authed":
|
|
54
|
+
return `Slack does not accept this ${variable}: check you copied all of it.`;
|
|
55
|
+
case "token_revoked":
|
|
56
|
+
case "token_expired":
|
|
57
|
+
case "account_inactive":
|
|
58
|
+
return `Slack says this token is no longer valid (${code}): create a new one.`;
|
|
59
|
+
case "not_allowed_token_type":
|
|
60
|
+
return `Slack says this is not the kind of token ${variable} takes (${code}).`;
|
|
61
|
+
default:
|
|
62
|
+
return `Slack refused this token (${code}).`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Runs `call`; a refusal sentence when Slack refused the token or could not be reached. */
|
|
66
|
+
async function answered(variable, call) {
|
|
67
|
+
try {
|
|
68
|
+
return { answer: await call() };
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error instanceof WebAPIPlatformError) {
|
|
72
|
+
return { refusal: refusalOf(variable, String(error.data.error)) };
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
refusal: `Could not reach Slack (${describe(error)}): check the connection and try again.`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** The questions of the Slack setup for `instance`; `probe` makes the client for a token. */
|
|
80
|
+
export function slackSetup(instance, probe = probeClient) {
|
|
81
|
+
const { json, cut } = manifestOf(instance);
|
|
82
|
+
// What the bot token turned out to be, which the user token is held against.
|
|
83
|
+
let teamId = null;
|
|
84
|
+
const bot = {
|
|
85
|
+
variable: "SLACK_BOT_TOKEN",
|
|
86
|
+
message: "Bot token (xoxb-…, OAuth & Permissions):",
|
|
87
|
+
secret: true,
|
|
88
|
+
optional: false,
|
|
89
|
+
async check(token) {
|
|
90
|
+
const wrong = wrongKind("SLACK_BOT_TOKEN", token);
|
|
91
|
+
if (wrong !== null)
|
|
92
|
+
return { refusal: wrong };
|
|
93
|
+
const result = await answered("SLACK_BOT_TOKEN", () => probe(token).auth.test());
|
|
94
|
+
if ("refusal" in result)
|
|
95
|
+
return result;
|
|
96
|
+
teamId = result.answer.team_id ?? null;
|
|
97
|
+
return { note: `workspace "${result.answer.team}", bot "${result.answer.user}"` };
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
const app = {
|
|
101
|
+
variable: "SLACK_APP_TOKEN",
|
|
102
|
+
message: "App-level token (xapp-…, Basic Information > App-Level Tokens):",
|
|
103
|
+
secret: true,
|
|
104
|
+
optional: false,
|
|
105
|
+
async check(token) {
|
|
106
|
+
const wrong = wrongKind("SLACK_APP_TOKEN", token);
|
|
107
|
+
if (wrong !== null)
|
|
108
|
+
return { refusal: wrong };
|
|
109
|
+
const result = await answered("SLACK_APP_TOKEN", () => probe(token).apps.connections.open());
|
|
110
|
+
return "refusal" in result ? result : {};
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
const user = {
|
|
114
|
+
variable: "SLACK_USER_TOKEN",
|
|
115
|
+
message: "User token (xoxp-…, optional, Enter to skip):",
|
|
116
|
+
secret: true,
|
|
117
|
+
optional: true,
|
|
118
|
+
sets: ["SLACK_OWNER_USER_ID"],
|
|
119
|
+
async check(token) {
|
|
120
|
+
const wrong = wrongKind("SLACK_USER_TOKEN", token);
|
|
121
|
+
if (wrong !== null)
|
|
122
|
+
return { refusal: wrong };
|
|
123
|
+
const result = await answered("SLACK_USER_TOKEN", () => probe(token).auth.test());
|
|
124
|
+
if ("refusal" in result)
|
|
125
|
+
return result;
|
|
126
|
+
const { user_id: userId, team_id: owners } = result.answer;
|
|
127
|
+
if (userId === undefined || owners === undefined) {
|
|
128
|
+
return { refusal: "Slack did not say whose token this is." };
|
|
129
|
+
}
|
|
130
|
+
// The Home tab deletes under this token's name: it must be the owner's, in the bot's workspace.
|
|
131
|
+
if (owners !== teamId) {
|
|
132
|
+
return { refusal: "This token belongs to another workspace than the bot token." };
|
|
133
|
+
}
|
|
134
|
+
return { note: `you are ${userId}`, set: { SLACK_OWNER_USER_ID: userId } };
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
const owner = {
|
|
138
|
+
variable: "SLACK_OWNER_USER_ID",
|
|
139
|
+
message: "Your Slack member ID (Profile > ⋮ > Copy member ID):",
|
|
140
|
+
secret: false,
|
|
141
|
+
optional: false,
|
|
142
|
+
check: async (id) => MEMBER_ID.test(id)
|
|
143
|
+
? {}
|
|
144
|
+
: {
|
|
145
|
+
refusal: "A member ID is a U or W followed by capital letters and digits, such as U012AB3CD.",
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
return {
|
|
149
|
+
intro: [
|
|
150
|
+
"1. Create the Slack app. Open this link, pick your workspace and confirm:",
|
|
151
|
+
` ${creationLink(json)}`,
|
|
152
|
+
...(cut
|
|
153
|
+
? [` (The app's name is cut to Slack's limit of ${APP_NAME_LIMIT} characters.)`]
|
|
154
|
+
: []),
|
|
155
|
+
" Then install the app to the workspace (Install App in the sidebar).",
|
|
156
|
+
],
|
|
157
|
+
questions: [bot, app, user, owner],
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* `@slack/types` 3.2.0 declares them as `MarkdownBlock`, `ContextBlock`, `DividerBlock`,
|
|
6
6
|
* `TaskCardBlock`, `ContainerBlock`, `RichTextBlock` and the chunks of `AnyChunk`.
|
|
7
7
|
*/
|
|
8
|
-
import { NESTED, STOPPED, shownPreview } from "../../../core/port.js";
|
|
8
|
+
import { formatDuration, NESTED, STOPPED, shownPreview } from "../../../core/port.js";
|
|
9
9
|
import { RECORD_MARK } from "../texts.js";
|
|
10
10
|
import { at, lastNewline, len, take } from "./chars.js";
|
|
11
11
|
import { shownAsWritten } from "./escape.js";
|
|
@@ -27,6 +27,9 @@ export const BANNER_LIMIT = 300;
|
|
|
27
27
|
// A task card's details and output are text of a rich text element; a context block's text
|
|
28
28
|
// holds at most 3,000 characters.
|
|
29
29
|
export const CARD_TEXT_LIMIT = 2_900;
|
|
30
|
+
// A line of the list of what still runs, or its step: seven tasks of two such lines and an
|
|
31
|
+
// agent's duration and count of calls stay under CARD_TEXT_LIMIT.
|
|
32
|
+
const CARD_LINE_LIMIT = 200;
|
|
30
33
|
export const CARD_TITLE_LIMIT = 150;
|
|
31
34
|
// The list of what still runs: its card's id (a tool call's id never has this form), and how many
|
|
32
35
|
// lines it draws before `+N more`. With the footer it stays inside the margins MESSAGE_LIMIT
|
|
@@ -339,18 +342,31 @@ export function cardBlock(update) {
|
|
|
339
342
|
}
|
|
340
343
|
return block;
|
|
341
344
|
}
|
|
345
|
+
/**
|
|
346
|
+
* A task's line, and under it the step of an agent that has made a call. How long the agent has
|
|
347
|
+
* run as `now` (on the steady clock) reads it, worded as a task's end line words its duration, and
|
|
348
|
+
* its count of calls end the agent's line, not the step's: they are the agent's.
|
|
349
|
+
*/
|
|
350
|
+
function runningRows(line, now) {
|
|
351
|
+
if (line.agent === null)
|
|
352
|
+
return take(line.text, CARD_LINE_LIMIT);
|
|
353
|
+
const { since, calls, step } = line.agent;
|
|
354
|
+
const count = `${calls} ${calls === 1 ? "call" : "calls"}`;
|
|
355
|
+
const text = take(line.text, CARD_LINE_LIMIT);
|
|
356
|
+
return `${text} · ${formatDuration(now - since)} · ${count}\n ⎿ ${take(step, CARD_LINE_LIMIT)}`;
|
|
357
|
+
}
|
|
342
358
|
/**
|
|
343
359
|
* The card of the list of what still runs: one `task_card` that keeps its `block_id` for its whole
|
|
344
360
|
* life, since Slack leaves a card with a fixed id open across an edit (measured 2026-10-12,
|
|
345
|
-
* desktop). Past `RUNNING_LIST_LINES`
|
|
361
|
+
* desktop). Past `RUNNING_LIST_LINES` tasks the rest is told as `+N more`; an agent's step is part
|
|
362
|
+
* of its task's entry. `now` is the steady clock's reading, against which an agent's start is
|
|
363
|
+
* measured.
|
|
346
364
|
*/
|
|
347
|
-
export function runningListBlock(running) {
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
]
|
|
353
|
-
: running.lines;
|
|
365
|
+
export function runningListBlock(running, now) {
|
|
366
|
+
const rows = running.lines.slice(0, RUNNING_LIST_LINES).map((line) => runningRows(line, now));
|
|
367
|
+
if (running.lines.length > RUNNING_LIST_LINES) {
|
|
368
|
+
rows.push(`+${running.lines.length - RUNNING_LIST_LINES} more`);
|
|
369
|
+
}
|
|
354
370
|
const block = {
|
|
355
371
|
type: "task_card",
|
|
356
372
|
block_id: RUNNING_LIST_ID,
|
|
@@ -358,8 +374,8 @@ export function runningListBlock(running) {
|
|
|
358
374
|
title: take(running.title, CARD_TITLE_LIMIT),
|
|
359
375
|
status: "in_progress",
|
|
360
376
|
};
|
|
361
|
-
if (
|
|
362
|
-
block.details = richText(take(
|
|
377
|
+
if (rows.length > 0)
|
|
378
|
+
block.details = richText(take(rows.join("\n"), CARD_TEXT_LIMIT));
|
|
363
379
|
return block;
|
|
364
380
|
}
|
|
365
381
|
/** Whether the block is the list of what still runs. */
|
|
@@ -975,7 +975,9 @@ export class ReplySink {
|
|
|
975
975
|
// The list only from what the places handed this reply once it rests: a reply whose end is on
|
|
976
976
|
// its way would draw a second card beside the one its holder still shows.
|
|
977
977
|
const list = this.resting ? this.assigned : null;
|
|
978
|
-
return list === null
|
|
978
|
+
return list === null
|
|
979
|
+
? this.footerBlocks()
|
|
980
|
+
: [runningListBlock(list, this.steady.time()), ...this.footerBlocks()];
|
|
979
981
|
}
|
|
980
982
|
/**
|
|
981
983
|
* What a message shows under its body: only the reply's last, when the footer rode on its
|