@agentconnect.md/daemon 1.0.0-rc.157 → 1.0.0-rc.159
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/dist/index.js +781 -297
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -12852,6 +12852,7 @@ const AuthOk = object({
|
|
|
12852
12852
|
sessionEpoch: number().int(),
|
|
12853
12853
|
heartbeatSec: number().int(),
|
|
12854
12854
|
serverTime: string().datetime(),
|
|
12855
|
+
webAppUrl: string().optional(),
|
|
12855
12856
|
resume: object({ accepted: boolean() }).optional()
|
|
12856
12857
|
});
|
|
12857
12858
|
//#endregion
|
|
@@ -13618,6 +13619,18 @@ const WebchatEvent = discriminatedUnion("kind", [
|
|
|
13618
13619
|
title: string()
|
|
13619
13620
|
})
|
|
13620
13621
|
]);
|
|
13622
|
+
const WebchatStatus = object({
|
|
13623
|
+
model: string().optional(),
|
|
13624
|
+
effort: string().optional(),
|
|
13625
|
+
fastMode: boolean().optional(),
|
|
13626
|
+
contextUsed: number().int().optional(),
|
|
13627
|
+
contextSize: number().int().optional(),
|
|
13628
|
+
totalTokens: number().int().optional(),
|
|
13629
|
+
costAmount: number().optional(),
|
|
13630
|
+
costCurrency: string().optional(),
|
|
13631
|
+
models: array(string()).optional(),
|
|
13632
|
+
sessionId: string().optional()
|
|
13633
|
+
});
|
|
13621
13634
|
//#endregion
|
|
13622
13635
|
//#region ../protocol/dist/frame.js
|
|
13623
13636
|
/**
|
|
@@ -13650,8 +13663,9 @@ const FRAME_SCHEMAS = {
|
|
|
13650
13663
|
conversationId: string().uuid(),
|
|
13651
13664
|
turnId: string().uuid(),
|
|
13652
13665
|
index: number().int(),
|
|
13653
|
-
event: WebchatEvent
|
|
13654
|
-
|
|
13666
|
+
event: WebchatEvent.optional(),
|
|
13667
|
+
status: WebchatStatus.optional()
|
|
13668
|
+
}).refine((o) => o.event !== void 0 || o.status !== void 0, { message: "WebchatOutput must carry event and/or status" }),
|
|
13655
13669
|
"webchat/done": object({
|
|
13656
13670
|
conversationId: string().uuid(),
|
|
13657
13671
|
turnId: string().uuid(),
|
|
@@ -13663,6 +13677,12 @@ const FRAME_SCHEMAS = {
|
|
|
13663
13677
|
error: string().optional()
|
|
13664
13678
|
}),
|
|
13665
13679
|
"webchat/close": object({ conversationId: string().uuid() }),
|
|
13680
|
+
"webchat/set-model": object({
|
|
13681
|
+
conversationId: string().uuid(),
|
|
13682
|
+
agentId: string().uuid(),
|
|
13683
|
+
model: string()
|
|
13684
|
+
}),
|
|
13685
|
+
"webchat/cancel": object({ conversationId: string().uuid() }),
|
|
13666
13686
|
"route/assign": RouteAssign,
|
|
13667
13687
|
"route/assign/ack": RouteAssignAck,
|
|
13668
13688
|
"route/update": RouteUpdate,
|
|
@@ -13739,6 +13759,8 @@ discriminatedUnion("type", [
|
|
|
13739
13759
|
frame("webchat/output", FRAME_SCHEMAS["webchat/output"]),
|
|
13740
13760
|
frame("webchat/done", FRAME_SCHEMAS["webchat/done"]),
|
|
13741
13761
|
frame("webchat/close", FRAME_SCHEMAS["webchat/close"]),
|
|
13762
|
+
frame("webchat/set-model", FRAME_SCHEMAS["webchat/set-model"]),
|
|
13763
|
+
frame("webchat/cancel", FRAME_SCHEMAS["webchat/cancel"]),
|
|
13742
13764
|
frame("route/assign", FRAME_SCHEMAS["route/assign"]),
|
|
13743
13765
|
frame("route/assign/ack", FRAME_SCHEMAS["route/assign/ack"]),
|
|
13744
13766
|
frame("route/update", FRAME_SCHEMAS["route/update"]),
|
|
@@ -18213,6 +18235,7 @@ var AcpHost = class {
|
|
|
18213
18235
|
canLoad = false;
|
|
18214
18236
|
promptCaps = {};
|
|
18215
18237
|
lastModelOptions = null;
|
|
18238
|
+
sessionConfigs = /* @__PURE__ */ new Map();
|
|
18216
18239
|
negotiatedProtocolVersion;
|
|
18217
18240
|
constructor(runtime, opts) {
|
|
18218
18241
|
this.runtime = runtime;
|
|
@@ -18286,6 +18309,7 @@ var AcpHost = class {
|
|
|
18286
18309
|
this.live.add(res.sessionId);
|
|
18287
18310
|
const configOptions = await this.applySessionConfig(res.sessionId, res.configOptions);
|
|
18288
18311
|
this.lastModelOptions = modelOptionsFrom(configOptions);
|
|
18312
|
+
this.sessionConfigs.set(res.sessionId, configOptions);
|
|
18289
18313
|
return res.sessionId;
|
|
18290
18314
|
}
|
|
18291
18315
|
/**
|
|
@@ -18331,6 +18355,30 @@ var AcpHost = class {
|
|
|
18331
18355
|
modelOptions() {
|
|
18332
18356
|
return this.lastModelOptions;
|
|
18333
18357
|
}
|
|
18358
|
+
/**
|
|
18359
|
+
* Switch the model of an ALREADY-RUNNING session via ACP `session/set_config_option`
|
|
18360
|
+
* (category `model`) — the mid-session counterpart of the model applied at
|
|
18361
|
+
* new/load. Returns true iff the change was applied: false when the session isn't
|
|
18362
|
+
* live here, the runtime advertises no model selector, the value isn't offered, or
|
|
18363
|
+
* it's already selected. Refreshes the cached options + `lastModelOptions` on success.
|
|
18364
|
+
*/
|
|
18365
|
+
async setSessionModel(sessionId, model) {
|
|
18366
|
+
if (!this.live.has(sessionId)) return false;
|
|
18367
|
+
const plan = planConfigSelection(this.sessionConfigs.get(sessionId), "model", model);
|
|
18368
|
+
if ("skip" in plan) {
|
|
18369
|
+
this.opts.log?.debug(`acp: setSessionModel "${model}" on ${sessionId} not applied — ${plan.skip}`);
|
|
18370
|
+
return false;
|
|
18371
|
+
}
|
|
18372
|
+
const res = await this.conn.agent.request(methods.agent.session.setConfigOption, {
|
|
18373
|
+
sessionId,
|
|
18374
|
+
configId: plan.configId,
|
|
18375
|
+
value: plan.value
|
|
18376
|
+
});
|
|
18377
|
+
this.sessionConfigs.set(sessionId, res.configOptions);
|
|
18378
|
+
this.lastModelOptions = modelOptionsFrom(res.configOptions);
|
|
18379
|
+
this.opts.log?.info(`acp: session ${sessionId} model set to "${model}"`);
|
|
18380
|
+
return true;
|
|
18381
|
+
}
|
|
18334
18382
|
/** The ACP protocol version negotiated at initialize, or undefined before the
|
|
18335
18383
|
* handshake completed. Used by the runtime probe to report ACP coverage. */
|
|
18336
18384
|
acpProtocolVersion() {
|
|
@@ -18359,7 +18407,9 @@ var AcpHost = class {
|
|
|
18359
18407
|
..._meta ? { _meta } : {}
|
|
18360
18408
|
});
|
|
18361
18409
|
this.live.add(sessionId);
|
|
18362
|
-
await this.applySessionConfig(sessionId, res.configOptions);
|
|
18410
|
+
const configOptions = await this.applySessionConfig(sessionId, res.configOptions);
|
|
18411
|
+
this.lastModelOptions = modelOptionsFrom(configOptions);
|
|
18412
|
+
this.sessionConfigs.set(sessionId, configOptions);
|
|
18363
18413
|
this.opts.log?.info(`acp: resumed session ${sessionId} via session/load`);
|
|
18364
18414
|
} catch (err) {
|
|
18365
18415
|
this.opts.log?.debug(`acp: session/load failed for ${sessionId} (${err.message}) — will recreate`);
|
|
@@ -24123,7 +24173,7 @@ var LocalStore = class {
|
|
|
24123
24173
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
24124
24174
|
key TEXT PRIMARY KEY, agentId TEXT, platform TEXT, channel TEXT, thread TEXT,
|
|
24125
24175
|
acpSessionId TEXT, state TEXT, lastDeliveredTs TEXT, updatedAt INTEGER,
|
|
24126
|
-
usage TEXT, muted INTEGER, triggeredBy TEXT, title TEXT
|
|
24176
|
+
usage TEXT, muted INTEGER, triggeredBy TEXT, title TEXT, modelOverride TEXT
|
|
24127
24177
|
);
|
|
24128
24178
|
-- Platform id → human display name (Slack channel/user names, daemon-resolved
|
|
24129
24179
|
-- and cached here so session read-back can label ids without a Slack call).
|
|
@@ -24169,16 +24219,17 @@ var LocalStore = class {
|
|
|
24169
24219
|
ON transcript (channel, thread, tool_call_id) WHERE tool_call_id IS NOT NULL`);
|
|
24170
24220
|
}
|
|
24171
24221
|
/** Add the `sessions.usage` (JSON token accounting), `sessions.muted` (`!stop`
|
|
24172
|
-
* thread mute), `sessions.triggeredBy` and `sessions.
|
|
24173
|
-
* pre-existing DB. CREATE TABLE IF
|
|
24174
|
-
* never alters an existing table, so
|
|
24175
|
-
* are present. */
|
|
24222
|
+
* thread mute), `sessions.triggeredBy`, `sessions.title` and `sessions.modelOverride`
|
|
24223
|
+
* (the sticky per-session model choice) columns to a pre-existing DB. CREATE TABLE IF
|
|
24224
|
+
* NOT EXISTS above adds them for fresh DBs but never alters an existing table, so
|
|
24225
|
+
* upgrade in place. No-op once the columns are present. */
|
|
24176
24226
|
migrateSessionUsage() {
|
|
24177
24227
|
const cols = this.db.prepare("PRAGMA table_info(sessions)").all();
|
|
24178
24228
|
if (!cols.some((c) => c.name === "usage")) this.db.exec("ALTER TABLE sessions ADD COLUMN usage TEXT");
|
|
24179
24229
|
if (!cols.some((c) => c.name === "muted")) this.db.exec("ALTER TABLE sessions ADD COLUMN muted INTEGER");
|
|
24180
24230
|
if (!cols.some((c) => c.name === "triggeredBy")) this.db.exec("ALTER TABLE sessions ADD COLUMN triggeredBy TEXT");
|
|
24181
24231
|
if (!cols.some((c) => c.name === "title")) this.db.exec("ALTER TABLE sessions ADD COLUMN title TEXT");
|
|
24232
|
+
if (!cols.some((c) => c.name === "modelOverride")) this.db.exec("ALTER TABLE sessions ADD COLUMN modelOverride TEXT");
|
|
24182
24233
|
}
|
|
24183
24234
|
/**
|
|
24184
24235
|
* Pre-`kind` transcript tables keyed on (channel, thread, ts) hold only conversational
|
|
@@ -24266,6 +24317,16 @@ var LocalStore = class {
|
|
|
24266
24317
|
isSessionMuted(key) {
|
|
24267
24318
|
return this.db.prepare("SELECT muted FROM sessions WHERE key = ?").get(key)?.muted === 1;
|
|
24268
24319
|
}
|
|
24320
|
+
/** The session-scoped model override (set via the console's in-session model switch),
|
|
24321
|
+
* or undefined if the session runs on the agent's default. Sticky across turns and
|
|
24322
|
+
* restarts, re-applied to the ACP session on each dispatch. */
|
|
24323
|
+
getModelOverride(key) {
|
|
24324
|
+
return this.db.prepare("SELECT modelOverride FROM sessions WHERE key = ?").get(key)?.modelOverride ?? void 0;
|
|
24325
|
+
}
|
|
24326
|
+
/** Persist the session-scoped model override. No-op on an unknown key. */
|
|
24327
|
+
setModelOverride(key, model) {
|
|
24328
|
+
this.db.prepare("UPDATE sessions SET modelOverride = ? WHERE key = ?").run(model, key);
|
|
24329
|
+
}
|
|
24269
24330
|
/** Current token accounting for a session (parsed from the `usage` JSON column),
|
|
24270
24331
|
* or `{}` if none has been recorded / the JSON is unreadable. */
|
|
24271
24332
|
getUsage(key) {
|
|
@@ -25157,7 +25218,8 @@ function routeRules(msg, rules, threadOwner, explicitAgentId) {
|
|
|
25157
25218
|
//#region src/commands/commands.ts
|
|
25158
25219
|
/** Accepted command prefixes (Slack uses `!`; `/` is for future platforms). */
|
|
25159
25220
|
const COMMAND_PREFIXES = ["!", "/"];
|
|
25160
|
-
const STOP_WORDS = /* @__PURE__ */ new Set(["stop"
|
|
25221
|
+
const STOP_WORDS = /* @__PURE__ */ new Set(["stop"]);
|
|
25222
|
+
const CANCEL_WORDS = /* @__PURE__ */ new Set(["cancel"]);
|
|
25161
25223
|
const QUEUE_WORDS = /* @__PURE__ */ new Set(["queue"]);
|
|
25162
25224
|
/**
|
|
25163
25225
|
* Parse a leading control command from a message's text. Returns `null` when the
|
|
@@ -25175,6 +25237,7 @@ function parseCommand(raw) {
|
|
|
25175
25237
|
const word = m[1].toLowerCase();
|
|
25176
25238
|
const arg = (m[2] ?? "").trim();
|
|
25177
25239
|
if (STOP_WORDS.has(word)) return { kind: "stop" };
|
|
25240
|
+
if (CANCEL_WORDS.has(word)) return { kind: "cancel" };
|
|
25178
25241
|
if (QUEUE_WORDS.has(word)) return {
|
|
25179
25242
|
kind: "queue",
|
|
25180
25243
|
text: arg
|
|
@@ -81220,6 +81283,450 @@ var SlackSendQueue = class {
|
|
|
81220
81283
|
}
|
|
81221
81284
|
};
|
|
81222
81285
|
//#endregion
|
|
81286
|
+
//#region src/slack/formatter.ts
|
|
81287
|
+
/**
|
|
81288
|
+
* Last-mile Slack formatting (§9.1 / §9.3). One pure helper:
|
|
81289
|
+
* - splitIntoSections: break a message into <= maxLen sections.
|
|
81290
|
+
*
|
|
81291
|
+
* The daemon posts the agent's reply as a Block Kit `markdown` block (which renders
|
|
81292
|
+
* standard CommonMark natively), so NO markdown→mrkdwn conversion happens here — the
|
|
81293
|
+
* agent's text is sent verbatim. We only chunk it: a single `markdown` block caps at
|
|
81294
|
+
* 12000 chars, so a long body is split across blocks/messages on line boundaries.
|
|
81295
|
+
*/
|
|
81296
|
+
/** Slack `markdown` block hard cap (12000 chars per block). */
|
|
81297
|
+
const SLACK_MARKDOWN_BLOCK_LIMIT = 12e3;
|
|
81298
|
+
/**
|
|
81299
|
+
* Split text into sections no longer than maxLen, preferring line boundaries;
|
|
81300
|
+
* a single line longer than maxLen is hard-cut. Whitespace-only input yields no
|
|
81301
|
+
* sections. Content is never lost across sections.
|
|
81302
|
+
*/
|
|
81303
|
+
function splitIntoSections(text, maxLen = SLACK_MARKDOWN_BLOCK_LIMIT) {
|
|
81304
|
+
if (!text.trim()) return [];
|
|
81305
|
+
if (text.length <= maxLen) return [text];
|
|
81306
|
+
const sections = [];
|
|
81307
|
+
let cur = "";
|
|
81308
|
+
const flush = () => {
|
|
81309
|
+
if (cur.length) sections.push(cur);
|
|
81310
|
+
cur = "";
|
|
81311
|
+
};
|
|
81312
|
+
for (const line of text.split("\n")) {
|
|
81313
|
+
const piece = cur.length ? `\n${line}` : line;
|
|
81314
|
+
if (cur.length + piece.length <= maxLen) {
|
|
81315
|
+
cur += piece;
|
|
81316
|
+
continue;
|
|
81317
|
+
}
|
|
81318
|
+
flush();
|
|
81319
|
+
if (line.length <= maxLen) cur = line;
|
|
81320
|
+
else for (let i = 0; i < line.length; i += maxLen) sections.push(line.slice(i, i + maxLen));
|
|
81321
|
+
}
|
|
81322
|
+
flush();
|
|
81323
|
+
return sections;
|
|
81324
|
+
}
|
|
81325
|
+
//#endregion
|
|
81326
|
+
//#region src/slack/render.ts
|
|
81327
|
+
const THINKING = "is thinking…";
|
|
81328
|
+
const MAX_ACTIVITY = 10;
|
|
81329
|
+
const MAX_LABEL = 100;
|
|
81330
|
+
const MAX_STATUS = 50;
|
|
81331
|
+
const MAX_REASONING = 2800;
|
|
81332
|
+
function clampTo(s, max) {
|
|
81333
|
+
const t = s.trim();
|
|
81334
|
+
return t.length > max ? `${t.slice(0, max - 1)}…` : t;
|
|
81335
|
+
}
|
|
81336
|
+
function clampLabel(s) {
|
|
81337
|
+
return clampTo(s, MAX_LABEL);
|
|
81338
|
+
}
|
|
81339
|
+
/** Wrap tool activity (a command line, tool title, or tool output) in a CommonMark code
|
|
81340
|
+
* span so it renders monospace and verbatim — Slack posts these as `markdown` blocks, so
|
|
81341
|
+
* unwrapped text with `*` / `_` / `#` would otherwise be interpreted as emphasis. A
|
|
81342
|
+
* single-line value uses an inline span, multi-line (or `block`-forced) a fenced block;
|
|
81343
|
+
* the delimiter is grown past the longest internal backtick run so values containing
|
|
81344
|
+
* backticks stay intact (CommonMark's variable-length fence rule). */
|
|
81345
|
+
function codeSpan(s, block = false) {
|
|
81346
|
+
const text = s.trim();
|
|
81347
|
+
if (!text) return "";
|
|
81348
|
+
const longestRun = Math.max(0, ...[...text.matchAll(/`+/g)].map((m) => m[0].length));
|
|
81349
|
+
if (block || text.includes("\n")) {
|
|
81350
|
+
const fence = "`".repeat(Math.max(3, longestRun + 1));
|
|
81351
|
+
return `${fence}\n${text}\n${fence}`;
|
|
81352
|
+
}
|
|
81353
|
+
const ticks = "`".repeat(longestRun + 1);
|
|
81354
|
+
const pad = text.startsWith("`") || text.endsWith("`") ? " " : "";
|
|
81355
|
+
return `${ticks}${pad}${text}${pad}${ticks}`;
|
|
81356
|
+
}
|
|
81357
|
+
const MAX_TOOL_OUTPUT = 2800;
|
|
81358
|
+
function capOutput(s) {
|
|
81359
|
+
const t = s.trim();
|
|
81360
|
+
return t.length > MAX_TOOL_OUTPUT ? `${t.slice(0, MAX_TOOL_OUTPUT - 1)}…` : t;
|
|
81361
|
+
}
|
|
81362
|
+
/** Pull human-readable output text out of an ACP tool_call/_update. Prefers the `content[]`
|
|
81363
|
+
* text blocks (the tool's reported output); falls back to a string `rawOutput`. diff /
|
|
81364
|
+
* terminal blocks and non-string rawOutput are skipped — they have no compact inline text. */
|
|
81365
|
+
function extractToolOutput(update) {
|
|
81366
|
+
const content = update.content;
|
|
81367
|
+
if (Array.isArray(content)) {
|
|
81368
|
+
const parts = [];
|
|
81369
|
+
for (const item of content) {
|
|
81370
|
+
if (!item || typeof item !== "object") continue;
|
|
81371
|
+
if (item.type !== "content") continue;
|
|
81372
|
+
const block = item.content;
|
|
81373
|
+
if (block?.type === "text" && block.text) parts.push(block.text);
|
|
81374
|
+
}
|
|
81375
|
+
if (parts.length) return parts.join("\n").trim();
|
|
81376
|
+
}
|
|
81377
|
+
return typeof update.rawOutput === "string" ? update.rawOutput.trim() : "";
|
|
81378
|
+
}
|
|
81379
|
+
function planIcon(status) {
|
|
81380
|
+
if (status === "completed") return ":white_check_mark:";
|
|
81381
|
+
if (status === "in_progress") return ":hourglass_flowing_sand:";
|
|
81382
|
+
return ":white_large_square:";
|
|
81383
|
+
}
|
|
81384
|
+
/** Render an ACP plan (full entry list, resent on every update) as a compact summary.
|
|
81385
|
+
* Posted as a `markdown` block, so emphasis is CommonMark (`**bold**`), not mrkdwn. */
|
|
81386
|
+
function renderPlan(entries) {
|
|
81387
|
+
return [":clipboard: **Plan**", ...entries.map((e) => `${planIcon(e.status)} ${clampLabel(e.content ?? "")}`)].join("\n");
|
|
81388
|
+
}
|
|
81389
|
+
/** Render the accumulated reasoning trace (high mode) as one in-place message. Posted as
|
|
81390
|
+
* a `markdown` block (verbatim CommonMark, so `**bold**`), tail-clamped to a compact cap
|
|
81391
|
+
* — the newest thinking is the most relevant, so keep the end and mark truncation with a
|
|
81392
|
+
* leading ellipsis. */
|
|
81393
|
+
function renderReasoning(buf) {
|
|
81394
|
+
const trimmed = buf.trim();
|
|
81395
|
+
return `:thought_balloon: **Thinking**\n${trimmed.length > MAX_REASONING ? `…${trimmed.slice(-2800)}` : trimmed}`;
|
|
81396
|
+
}
|
|
81397
|
+
/** Compact a token count as `1.2k` / `3.4M` (whole numbers under 1000 stay verbatim). */
|
|
81398
|
+
function compactCount(n) {
|
|
81399
|
+
if (n < 1e3) return String(n);
|
|
81400
|
+
if (n < 1e6) return `${(n / 1e3).toFixed(n < 1e4 ? 1 : 0)}k`;
|
|
81401
|
+
return `${(n / 1e6).toFixed(1)}M`;
|
|
81402
|
+
}
|
|
81403
|
+
/** Render the session status line as one compact `markdown` block — e.g.
|
|
81404
|
+
* `:bar_chart: *opus-4.8* · xhigh · fast · ctx 120k/200k (60%) · 45.2k tok · $0.18`.
|
|
81405
|
+
* Pure: unknown fields are dropped so a partial snapshot still yields a clean line, and
|
|
81406
|
+
* an empty snapshot degrades to a bare `:bar_chart: —` placeholder. */
|
|
81407
|
+
function renderStatusBar(info) {
|
|
81408
|
+
const parts = [];
|
|
81409
|
+
if (info.model) parts.push(`*${info.model}*`);
|
|
81410
|
+
if (info.effort) parts.push(info.effort);
|
|
81411
|
+
if (info.fastMode) parts.push("fast");
|
|
81412
|
+
if (info.contextUsed !== void 0 && info.contextSize !== void 0 && info.contextSize > 0) {
|
|
81413
|
+
const pct = Math.round(info.contextUsed / info.contextSize * 100);
|
|
81414
|
+
parts.push(`ctx ${compactCount(info.contextUsed)}/${compactCount(info.contextSize)} (${pct}%)`);
|
|
81415
|
+
} else if (info.contextUsed !== void 0) parts.push(`ctx ${compactCount(info.contextUsed)}`);
|
|
81416
|
+
if (info.totalTokens !== void 0) parts.push(`${compactCount(info.totalTokens)} tok`);
|
|
81417
|
+
if (info.costAmount !== void 0) {
|
|
81418
|
+
const cur = info.costCurrency && info.costCurrency !== "USD" ? ` ${info.costCurrency}` : "";
|
|
81419
|
+
const sym = !info.costCurrency || info.costCurrency === "USD" ? "$" : "";
|
|
81420
|
+
parts.push(`${sym}${info.costAmount.toFixed(info.costAmount < 1 ? 3 : 2)}${cur}`);
|
|
81421
|
+
}
|
|
81422
|
+
return `:bar_chart: ${parts.length ? parts.join(" · ") : "—"}`;
|
|
81423
|
+
}
|
|
81424
|
+
/** Slack action_ids for the interactive status bar + modal (matched by the connection's
|
|
81425
|
+
* handlers). Kept here so the builders and the handlers can't drift apart. */
|
|
81426
|
+
const STATUS_ACTION = {
|
|
81427
|
+
manage: "ac_manage",
|
|
81428
|
+
setModel: "ac_set_model",
|
|
81429
|
+
cancel: "ac_cancel",
|
|
81430
|
+
view: "ac_view"
|
|
81431
|
+
};
|
|
81432
|
+
/** The modal view's callback_id — used if we ever handle a submit (controls apply on
|
|
81433
|
+
* interaction today, so there's no submit). */
|
|
81434
|
+
const STATUS_MODAL_CALLBACK = "ac_status_modal";
|
|
81435
|
+
/**
|
|
81436
|
+
* Build the COMPACT in-thread status message as ONE row: a `section` whose mrkdwn text is
|
|
81437
|
+
* the live status line (model · ctx · tokens · cost, updated via chat.update) with a
|
|
81438
|
+
* `View session` link appended when a `link` is known, and whose `accessory` is the ⚙
|
|
81439
|
+
* Manage button (opens the controls modal). Section text + one accessory share a row, so
|
|
81440
|
+
* status + View link + Manage all sit on one line. The session key rides the button
|
|
81441
|
+
* `value`. Pure — safe to unit-test.
|
|
81442
|
+
*/
|
|
81443
|
+
function buildStatusBlocks(info, sessionKey, link) {
|
|
81444
|
+
return [{
|
|
81445
|
+
type: "section",
|
|
81446
|
+
text: {
|
|
81447
|
+
type: "mrkdwn",
|
|
81448
|
+
text: link ? `${renderStatusBar(info)} · <${link}|View session>` : renderStatusBar(info)
|
|
81449
|
+
},
|
|
81450
|
+
accessory: {
|
|
81451
|
+
type: "button",
|
|
81452
|
+
action_id: STATUS_ACTION.manage,
|
|
81453
|
+
text: {
|
|
81454
|
+
type: "plain_text",
|
|
81455
|
+
text: ":gear: Manage",
|
|
81456
|
+
emoji: true
|
|
81457
|
+
},
|
|
81458
|
+
value: sessionKey
|
|
81459
|
+
}
|
|
81460
|
+
}];
|
|
81461
|
+
}
|
|
81462
|
+
/** One `Label: value` field line for the modal's usage detail (omitted when absent). */
|
|
81463
|
+
function usageField(label, n) {
|
|
81464
|
+
return n === void 0 ? void 0 : `*${label}:* ${n.toLocaleString("en-US")}`;
|
|
81465
|
+
}
|
|
81466
|
+
/**
|
|
81467
|
+
* Build the controls modal opened from the ⚙ button (a snapshot — Slack modals don't
|
|
81468
|
+
* stream). `private_metadata` carries the session key so the modal's `block_actions`
|
|
81469
|
+
* (model select / Cancel) resolve the session. Blocks: the model `static_select` (omitted
|
|
81470
|
+
* when no models are advertised), a usage-detail section (input/output/cache/thought
|
|
81471
|
+
* tokens · context · cost), and Cancel + View-session (URL, when `link`). Pure.
|
|
81472
|
+
*/
|
|
81473
|
+
function buildStatusModal(info, sessionKey, link) {
|
|
81474
|
+
const blocks = [];
|
|
81475
|
+
const models = info.models ?? [];
|
|
81476
|
+
if (models.length > 0) {
|
|
81477
|
+
const opts = info.model && !models.includes(info.model) ? [info.model, ...models] : models;
|
|
81478
|
+
const option = (m) => ({
|
|
81479
|
+
text: {
|
|
81480
|
+
type: "plain_text",
|
|
81481
|
+
text: clampTo(m, 75)
|
|
81482
|
+
},
|
|
81483
|
+
value: m
|
|
81484
|
+
});
|
|
81485
|
+
blocks.push({
|
|
81486
|
+
type: "section",
|
|
81487
|
+
text: {
|
|
81488
|
+
type: "mrkdwn",
|
|
81489
|
+
text: "*Model*"
|
|
81490
|
+
},
|
|
81491
|
+
accessory: {
|
|
81492
|
+
type: "static_select",
|
|
81493
|
+
action_id: STATUS_ACTION.setModel,
|
|
81494
|
+
placeholder: {
|
|
81495
|
+
type: "plain_text",
|
|
81496
|
+
text: "Model"
|
|
81497
|
+
},
|
|
81498
|
+
...info.model ? { initial_option: option(info.model) } : {},
|
|
81499
|
+
options: opts.map(option)
|
|
81500
|
+
}
|
|
81501
|
+
});
|
|
81502
|
+
} else if (info.model) blocks.push({
|
|
81503
|
+
type: "section",
|
|
81504
|
+
text: {
|
|
81505
|
+
type: "mrkdwn",
|
|
81506
|
+
text: `*Model:* ${info.model}`
|
|
81507
|
+
}
|
|
81508
|
+
});
|
|
81509
|
+
const detail = [
|
|
81510
|
+
renderStatusBar({
|
|
81511
|
+
...info,
|
|
81512
|
+
model: void 0
|
|
81513
|
+
}),
|
|
81514
|
+
usageField("Input", info.inputTokens),
|
|
81515
|
+
usageField("Output", info.outputTokens),
|
|
81516
|
+
usageField("Thought", info.thoughtTokens),
|
|
81517
|
+
usageField("Cache read", info.cachedReadTokens),
|
|
81518
|
+
usageField("Cache write", info.cachedWriteTokens)
|
|
81519
|
+
].filter(Boolean);
|
|
81520
|
+
blocks.push({
|
|
81521
|
+
type: "context",
|
|
81522
|
+
elements: [{
|
|
81523
|
+
type: "mrkdwn",
|
|
81524
|
+
text: detail.join(" · ")
|
|
81525
|
+
}]
|
|
81526
|
+
});
|
|
81527
|
+
const actions = [{
|
|
81528
|
+
type: "button",
|
|
81529
|
+
action_id: STATUS_ACTION.cancel,
|
|
81530
|
+
text: {
|
|
81531
|
+
type: "plain_text",
|
|
81532
|
+
text: "Cancel turn"
|
|
81533
|
+
},
|
|
81534
|
+
style: "danger",
|
|
81535
|
+
value: sessionKey
|
|
81536
|
+
}];
|
|
81537
|
+
if (link) actions.push({
|
|
81538
|
+
type: "button",
|
|
81539
|
+
action_id: STATUS_ACTION.view,
|
|
81540
|
+
text: {
|
|
81541
|
+
type: "plain_text",
|
|
81542
|
+
text: "View session"
|
|
81543
|
+
},
|
|
81544
|
+
url: link
|
|
81545
|
+
});
|
|
81546
|
+
blocks.push({
|
|
81547
|
+
type: "actions",
|
|
81548
|
+
elements: actions
|
|
81549
|
+
});
|
|
81550
|
+
return {
|
|
81551
|
+
type: "modal",
|
|
81552
|
+
callback_id: STATUS_MODAL_CALLBACK,
|
|
81553
|
+
private_metadata: sessionKey,
|
|
81554
|
+
title: {
|
|
81555
|
+
type: "plain_text",
|
|
81556
|
+
text: "Session"
|
|
81557
|
+
},
|
|
81558
|
+
close: {
|
|
81559
|
+
type: "plain_text",
|
|
81560
|
+
text: "Close"
|
|
81561
|
+
},
|
|
81562
|
+
blocks
|
|
81563
|
+
};
|
|
81564
|
+
}
|
|
81565
|
+
var OutputConverger = class {
|
|
81566
|
+
mode;
|
|
81567
|
+
buf = "";
|
|
81568
|
+
reasoningBuf = "";
|
|
81569
|
+
reasoningDirty = false;
|
|
81570
|
+
activity = [];
|
|
81571
|
+
toolTitles = /* @__PURE__ */ new Map();
|
|
81572
|
+
toolOutputs = /* @__PURE__ */ new Map();
|
|
81573
|
+
emittedOutput = /* @__PURE__ */ new Set();
|
|
81574
|
+
constructor(mode) {
|
|
81575
|
+
this.mode = mode;
|
|
81576
|
+
}
|
|
81577
|
+
/** True while body text OR reasoning is pending — the daemon uses this to (re)arm
|
|
81578
|
+
* the ~2s idle-flush timer (§9.1 text-buffer) so a long pure-text stream posts in
|
|
81579
|
+
* steps and streamed thinking updates its in-place block at most once per window. */
|
|
81580
|
+
hasBuffered() {
|
|
81581
|
+
return this.buf.trim().length > 0 || this.reasoningDirty;
|
|
81582
|
+
}
|
|
81583
|
+
/** Flush pending output for the idle timer: in high mode one in-place `reasoning` update
|
|
81584
|
+
* carrying the reasoning accumulated since the last flush, THEN the buffered body
|
|
81585
|
+
* (verbatim markdown split into ≤block-limit `post` sections — each a Block Kit
|
|
81586
|
+
* `markdown` block, no mrkdwn conversion). Reasoning is emitted first so its in-place
|
|
81587
|
+
* block is first-posted ABOVE the reply: thinking precedes the answer (§9.1), so the
|
|
81588
|
+
* Thinking block must sit above it, not below. */
|
|
81589
|
+
flushBuffered() {
|
|
81590
|
+
return [...this.drainReasoning(), ...this.flush()];
|
|
81591
|
+
}
|
|
81592
|
+
/** Drain reasoning buffered since the last flush into a 0-or-1-length action list (only
|
|
81593
|
+
* high mode ever sets `reasoningDirty`). Callers place it before the body flush so the
|
|
81594
|
+
* Thinking block posts above the reply. */
|
|
81595
|
+
drainReasoning() {
|
|
81596
|
+
if (!this.reasoningDirty) return [];
|
|
81597
|
+
this.reasoningDirty = false;
|
|
81598
|
+
return [{
|
|
81599
|
+
kind: "reasoning",
|
|
81600
|
+
text: renderReasoning(this.reasoningBuf)
|
|
81601
|
+
}];
|
|
81602
|
+
}
|
|
81603
|
+
flush() {
|
|
81604
|
+
const text = this.buf;
|
|
81605
|
+
this.buf = "";
|
|
81606
|
+
if (!text.trim()) return [];
|
|
81607
|
+
return splitIntoSections(text).map((t) => ({
|
|
81608
|
+
kind: "post",
|
|
81609
|
+
text: t
|
|
81610
|
+
}));
|
|
81611
|
+
}
|
|
81612
|
+
/**
|
|
81613
|
+
* Record an activity label and build the loading-status action carrying the rolling
|
|
81614
|
+
* window as `loading_messages`. Consecutive repeats collapse to nothing (returns []),
|
|
81615
|
+
* which throttles streamed thought chunks down to one status update per thinking run.
|
|
81616
|
+
*/
|
|
81617
|
+
pushActivity(raw) {
|
|
81618
|
+
const label = clampTo(raw, MAX_STATUS);
|
|
81619
|
+
if (this.activity[this.activity.length - 1] === label) return [];
|
|
81620
|
+
this.activity.push(label);
|
|
81621
|
+
if (this.activity.length > MAX_ACTIVITY) this.activity.shift();
|
|
81622
|
+
return [{
|
|
81623
|
+
kind: "set-status",
|
|
81624
|
+
text: label,
|
|
81625
|
+
loadingMessages: [...this.activity]
|
|
81626
|
+
}];
|
|
81627
|
+
}
|
|
81628
|
+
/** Resolve a tool call's display label, reusing a known title when an update omits it. */
|
|
81629
|
+
toolLabel(update) {
|
|
81630
|
+
const id = update.toolCallId;
|
|
81631
|
+
if (update.title) {
|
|
81632
|
+
if (id) this.toolTitles.set(id, update.title);
|
|
81633
|
+
return update.title;
|
|
81634
|
+
}
|
|
81635
|
+
return (id && this.toolTitles.get(id)) ?? id ?? "tool";
|
|
81636
|
+
}
|
|
81637
|
+
/** high mode: surface a finished tool's output as a code-block `tool-output` action.
|
|
81638
|
+
* Tracks the latest output across streamed updates and emits it exactly once, when the
|
|
81639
|
+
* call reaches a terminal status (completed/failed) — so partial output isn't posted
|
|
81640
|
+
* early and the final result isn't re-posted. Returns [] when there's nothing to post. */
|
|
81641
|
+
drainToolOutput(update) {
|
|
81642
|
+
const id = update.toolCallId;
|
|
81643
|
+
if (!id) return [];
|
|
81644
|
+
if (update.content !== void 0 || update.rawOutput !== void 0) {
|
|
81645
|
+
const out = extractToolOutput(update);
|
|
81646
|
+
if (out) this.toolOutputs.set(id, out);
|
|
81647
|
+
else this.toolOutputs.delete(id);
|
|
81648
|
+
}
|
|
81649
|
+
if (!(update.status === "completed" || update.status === "failed") || this.emittedOutput.has(id)) return [];
|
|
81650
|
+
const text = this.toolOutputs.get(id);
|
|
81651
|
+
if (!text) return [];
|
|
81652
|
+
this.emittedOutput.add(id);
|
|
81653
|
+
this.toolOutputs.delete(id);
|
|
81654
|
+
return [{
|
|
81655
|
+
kind: "tool-output",
|
|
81656
|
+
text: `${update.status === "failed" ? ":x:" : ":page_facing_up:"}\n${codeSpan(capOutput(text), true)}`
|
|
81657
|
+
}];
|
|
81658
|
+
}
|
|
81659
|
+
onUpdate(update) {
|
|
81660
|
+
switch (update.sessionUpdate) {
|
|
81661
|
+
case "agent_message_chunk": {
|
|
81662
|
+
const content = update.content;
|
|
81663
|
+
if (content?.type === "text") this.buf += content.text ?? "";
|
|
81664
|
+
return [];
|
|
81665
|
+
}
|
|
81666
|
+
case "agent_thought_chunk":
|
|
81667
|
+
if (this.mode === "high") {
|
|
81668
|
+
const text = update.content?.text ?? "";
|
|
81669
|
+
if (text) {
|
|
81670
|
+
this.reasoningBuf += text;
|
|
81671
|
+
if (this.reasoningBuf.length > MAX_REASONING * 2) this.reasoningBuf = this.reasoningBuf.slice(-2800 * 2);
|
|
81672
|
+
this.reasoningDirty = true;
|
|
81673
|
+
}
|
|
81674
|
+
}
|
|
81675
|
+
return [...this.flush(), ...this.pushActivity(THINKING)];
|
|
81676
|
+
case "tool_call":
|
|
81677
|
+
case "tool_call_update": {
|
|
81678
|
+
const u = update;
|
|
81679
|
+
const label = this.toolLabel(u);
|
|
81680
|
+
const status = this.pushActivity(label);
|
|
81681
|
+
if (this.mode === "low") return [...this.flush(), ...status];
|
|
81682
|
+
const actions = [
|
|
81683
|
+
...this.flush(),
|
|
81684
|
+
...status,
|
|
81685
|
+
{
|
|
81686
|
+
kind: "progress",
|
|
81687
|
+
text: `:hammer_and_wrench: ${codeSpan(label)}`
|
|
81688
|
+
}
|
|
81689
|
+
];
|
|
81690
|
+
if (this.mode === "high") actions.push(...this.drainToolOutput(u));
|
|
81691
|
+
return actions;
|
|
81692
|
+
}
|
|
81693
|
+
case "plan": {
|
|
81694
|
+
const entries = update.entries ?? [];
|
|
81695
|
+
if (this.mode === "low") return [...this.flush(), ...this.pushActivity("planning…")];
|
|
81696
|
+
return [...this.flush(), {
|
|
81697
|
+
kind: "plan",
|
|
81698
|
+
text: renderPlan(entries)
|
|
81699
|
+
}];
|
|
81700
|
+
}
|
|
81701
|
+
case "usage_update": return [];
|
|
81702
|
+
default: return [];
|
|
81703
|
+
}
|
|
81704
|
+
}
|
|
81705
|
+
/**
|
|
81706
|
+
* Turn end (§9.1 stopReason): flush remaining body, clear the loading status, and
|
|
81707
|
+
* — in medium/high — append a footer linking to the Web App session detail. The
|
|
81708
|
+
* link is omitted entirely when none is configured (no `local://` placeholder).
|
|
81709
|
+
*/
|
|
81710
|
+
onFinal(link) {
|
|
81711
|
+
const clear = {
|
|
81712
|
+
kind: "set-status",
|
|
81713
|
+
text: ""
|
|
81714
|
+
};
|
|
81715
|
+
if (this.mode === "low") return [...this.flush(), clear];
|
|
81716
|
+
const reasoning = this.drainReasoning();
|
|
81717
|
+
const footer = link ? [{
|
|
81718
|
+
kind: "notice",
|
|
81719
|
+
text: `:white_check_mark: done — [details](${link})`
|
|
81720
|
+
}] : [];
|
|
81721
|
+
return [
|
|
81722
|
+
...reasoning,
|
|
81723
|
+
...this.flush(),
|
|
81724
|
+
clear,
|
|
81725
|
+
...footer
|
|
81726
|
+
];
|
|
81727
|
+
}
|
|
81728
|
+
};
|
|
81729
|
+
//#endregion
|
|
81223
81730
|
//#region src/slack/connection.ts
|
|
81224
81731
|
const { App, LogLevel } = import_dist.default;
|
|
81225
81732
|
/** §6.1: one Slack Socket Mode connection per unique appToken. */
|
|
@@ -81324,6 +81831,43 @@ var SlackConnection = class {
|
|
|
81324
81831
|
log?.debug(`slack: bot left channel ${event.channel ?? "?"} (${type})`);
|
|
81325
81832
|
this.deps.onChannelsChanged?.();
|
|
81326
81833
|
});
|
|
81834
|
+
this.app.action(STATUS_ACTION.manage, async ({ ack, action, body }) => {
|
|
81835
|
+
await ack();
|
|
81836
|
+
const sessionKey = action.value;
|
|
81837
|
+
const triggerId = body?.trigger_id;
|
|
81838
|
+
if (!sessionKey || !triggerId) return;
|
|
81839
|
+
const data = this.deps.onStatusInfo?.(sessionKey);
|
|
81840
|
+
if (!data) return;
|
|
81841
|
+
try {
|
|
81842
|
+
await this.app.client.views.open({
|
|
81843
|
+
trigger_id: triggerId,
|
|
81844
|
+
view: buildStatusModal(data.info, sessionKey, data.link)
|
|
81845
|
+
});
|
|
81846
|
+
} catch (err) {
|
|
81847
|
+
this.deps.log?.debug(`slack: views.open failed: ${err.message}`);
|
|
81848
|
+
}
|
|
81849
|
+
});
|
|
81850
|
+
this.app.action(STATUS_ACTION.setModel, async ({ ack, action, body }) => {
|
|
81851
|
+
await ack();
|
|
81852
|
+
const sessionKey = body?.view?.private_metadata ?? action.block_id;
|
|
81853
|
+
const model = action.selected_option?.value;
|
|
81854
|
+
if (sessionKey && model) this.deps.onStatusAction?.({
|
|
81855
|
+
kind: "set-model",
|
|
81856
|
+
sessionKey,
|
|
81857
|
+
model
|
|
81858
|
+
});
|
|
81859
|
+
});
|
|
81860
|
+
this.app.action(STATUS_ACTION.cancel, async ({ ack, action, body }) => {
|
|
81861
|
+
await ack();
|
|
81862
|
+
const sessionKey = body?.view?.private_metadata ?? action.value;
|
|
81863
|
+
if (sessionKey) this.deps.onStatusAction?.({
|
|
81864
|
+
kind: "cancel",
|
|
81865
|
+
sessionKey
|
|
81866
|
+
});
|
|
81867
|
+
});
|
|
81868
|
+
this.app.action(STATUS_ACTION.view, async ({ ack }) => {
|
|
81869
|
+
await ack();
|
|
81870
|
+
});
|
|
81327
81871
|
log?.debug("slack: app.start → opening Socket Mode WebSocket (wss://…slack.com)…");
|
|
81328
81872
|
await this.app.start();
|
|
81329
81873
|
log?.debug("slack: app.start resolved → socket established");
|
|
@@ -81352,6 +81896,40 @@ var SlackConnection = class {
|
|
|
81352
81896
|
}
|
|
81353
81897
|
});
|
|
81354
81898
|
}
|
|
81899
|
+
/** Post an explicit Block Kit message (the interactive status bar) — `text` is the
|
|
81900
|
+
* notification/accessibility fallback. `unfurl_links`/`unfurl_media` are off so the
|
|
81901
|
+
* status bar's "View session" URL doesn't sprout a link-preview card. Returns the ts
|
|
81902
|
+
* for later in-place edits. */
|
|
81903
|
+
async postBlocks(channel, blocks, text, threadTs) {
|
|
81904
|
+
return this.queue.enqueue(async () => {
|
|
81905
|
+
return (await this.app.client.chat.postMessage({
|
|
81906
|
+
channel,
|
|
81907
|
+
thread_ts: threadTs,
|
|
81908
|
+
text,
|
|
81909
|
+
blocks,
|
|
81910
|
+
unfurl_links: false,
|
|
81911
|
+
unfurl_media: false
|
|
81912
|
+
}))?.ts;
|
|
81913
|
+
});
|
|
81914
|
+
}
|
|
81915
|
+
/** Edit a previously-posted Block Kit message in place (chat.update). Best-effort.
|
|
81916
|
+
* `unfurl_links: false` keeps an edit from unfurling the "View session" URL. */
|
|
81917
|
+
async updateBlocks(channel, ts, blocks, text) {
|
|
81918
|
+
await this.queue.enqueue(async () => {
|
|
81919
|
+
try {
|
|
81920
|
+
await this.app.client.chat.update({
|
|
81921
|
+
channel,
|
|
81922
|
+
ts,
|
|
81923
|
+
text,
|
|
81924
|
+
blocks,
|
|
81925
|
+
unfurl_links: false,
|
|
81926
|
+
unfurl_media: false
|
|
81927
|
+
});
|
|
81928
|
+
} catch (err) {
|
|
81929
|
+
this.deps.log?.debug(`slack: chat.update (blocks) failed (ch=${channel} ts=${ts}): ${err.message}`);
|
|
81930
|
+
}
|
|
81931
|
+
});
|
|
81932
|
+
}
|
|
81355
81933
|
/**
|
|
81356
81934
|
* Pull a Slack thread's full history (conversations.replies, cursor-paginated)
|
|
81357
81935
|
* for §8.4/§9.2 mid-thread context. Returns root + replies in Slack ts order;
|
|
@@ -81621,282 +82199,6 @@ var SlackNameResolver = class {
|
|
|
81621
82199
|
}
|
|
81622
82200
|
};
|
|
81623
82201
|
//#endregion
|
|
81624
|
-
//#region src/slack/formatter.ts
|
|
81625
|
-
/**
|
|
81626
|
-
* Last-mile Slack formatting (§9.1 / §9.3). One pure helper:
|
|
81627
|
-
* - splitIntoSections: break a message into <= maxLen sections.
|
|
81628
|
-
*
|
|
81629
|
-
* The daemon posts the agent's reply as a Block Kit `markdown` block (which renders
|
|
81630
|
-
* standard CommonMark natively), so NO markdown→mrkdwn conversion happens here — the
|
|
81631
|
-
* agent's text is sent verbatim. We only chunk it: a single `markdown` block caps at
|
|
81632
|
-
* 12000 chars, so a long body is split across blocks/messages on line boundaries.
|
|
81633
|
-
*/
|
|
81634
|
-
/** Slack `markdown` block hard cap (12000 chars per block). */
|
|
81635
|
-
const SLACK_MARKDOWN_BLOCK_LIMIT = 12e3;
|
|
81636
|
-
/**
|
|
81637
|
-
* Split text into sections no longer than maxLen, preferring line boundaries;
|
|
81638
|
-
* a single line longer than maxLen is hard-cut. Whitespace-only input yields no
|
|
81639
|
-
* sections. Content is never lost across sections.
|
|
81640
|
-
*/
|
|
81641
|
-
function splitIntoSections(text, maxLen = SLACK_MARKDOWN_BLOCK_LIMIT) {
|
|
81642
|
-
if (!text.trim()) return [];
|
|
81643
|
-
if (text.length <= maxLen) return [text];
|
|
81644
|
-
const sections = [];
|
|
81645
|
-
let cur = "";
|
|
81646
|
-
const flush = () => {
|
|
81647
|
-
if (cur.length) sections.push(cur);
|
|
81648
|
-
cur = "";
|
|
81649
|
-
};
|
|
81650
|
-
for (const line of text.split("\n")) {
|
|
81651
|
-
const piece = cur.length ? `\n${line}` : line;
|
|
81652
|
-
if (cur.length + piece.length <= maxLen) {
|
|
81653
|
-
cur += piece;
|
|
81654
|
-
continue;
|
|
81655
|
-
}
|
|
81656
|
-
flush();
|
|
81657
|
-
if (line.length <= maxLen) cur = line;
|
|
81658
|
-
else for (let i = 0; i < line.length; i += maxLen) sections.push(line.slice(i, i + maxLen));
|
|
81659
|
-
}
|
|
81660
|
-
flush();
|
|
81661
|
-
return sections;
|
|
81662
|
-
}
|
|
81663
|
-
//#endregion
|
|
81664
|
-
//#region src/slack/render.ts
|
|
81665
|
-
const THINKING = "is thinking…";
|
|
81666
|
-
const MAX_ACTIVITY = 10;
|
|
81667
|
-
const MAX_LABEL = 100;
|
|
81668
|
-
const MAX_STATUS = 50;
|
|
81669
|
-
const MAX_REASONING = 2800;
|
|
81670
|
-
function clampTo(s, max) {
|
|
81671
|
-
const t = s.trim();
|
|
81672
|
-
return t.length > max ? `${t.slice(0, max - 1)}…` : t;
|
|
81673
|
-
}
|
|
81674
|
-
function clampLabel(s) {
|
|
81675
|
-
return clampTo(s, MAX_LABEL);
|
|
81676
|
-
}
|
|
81677
|
-
/** Wrap tool activity (a command line, tool title, or tool output) in a CommonMark code
|
|
81678
|
-
* span so it renders monospace and verbatim — Slack posts these as `markdown` blocks, so
|
|
81679
|
-
* unwrapped text with `*` / `_` / `#` would otherwise be interpreted as emphasis. A
|
|
81680
|
-
* single-line value uses an inline span, multi-line (or `block`-forced) a fenced block;
|
|
81681
|
-
* the delimiter is grown past the longest internal backtick run so values containing
|
|
81682
|
-
* backticks stay intact (CommonMark's variable-length fence rule). */
|
|
81683
|
-
function codeSpan(s, block = false) {
|
|
81684
|
-
const text = s.trim();
|
|
81685
|
-
if (!text) return "";
|
|
81686
|
-
const longestRun = Math.max(0, ...[...text.matchAll(/`+/g)].map((m) => m[0].length));
|
|
81687
|
-
if (block || text.includes("\n")) {
|
|
81688
|
-
const fence = "`".repeat(Math.max(3, longestRun + 1));
|
|
81689
|
-
return `${fence}\n${text}\n${fence}`;
|
|
81690
|
-
}
|
|
81691
|
-
const ticks = "`".repeat(longestRun + 1);
|
|
81692
|
-
const pad = text.startsWith("`") || text.endsWith("`") ? " " : "";
|
|
81693
|
-
return `${ticks}${pad}${text}${pad}${ticks}`;
|
|
81694
|
-
}
|
|
81695
|
-
const MAX_TOOL_OUTPUT = 2800;
|
|
81696
|
-
function capOutput(s) {
|
|
81697
|
-
const t = s.trim();
|
|
81698
|
-
return t.length > MAX_TOOL_OUTPUT ? `${t.slice(0, MAX_TOOL_OUTPUT - 1)}…` : t;
|
|
81699
|
-
}
|
|
81700
|
-
/** Pull human-readable output text out of an ACP tool_call/_update. Prefers the `content[]`
|
|
81701
|
-
* text blocks (the tool's reported output); falls back to a string `rawOutput`. diff /
|
|
81702
|
-
* terminal blocks and non-string rawOutput are skipped — they have no compact inline text. */
|
|
81703
|
-
function extractToolOutput(update) {
|
|
81704
|
-
const content = update.content;
|
|
81705
|
-
if (Array.isArray(content)) {
|
|
81706
|
-
const parts = [];
|
|
81707
|
-
for (const item of content) {
|
|
81708
|
-
if (!item || typeof item !== "object") continue;
|
|
81709
|
-
if (item.type !== "content") continue;
|
|
81710
|
-
const block = item.content;
|
|
81711
|
-
if (block?.type === "text" && block.text) parts.push(block.text);
|
|
81712
|
-
}
|
|
81713
|
-
if (parts.length) return parts.join("\n").trim();
|
|
81714
|
-
}
|
|
81715
|
-
return typeof update.rawOutput === "string" ? update.rawOutput.trim() : "";
|
|
81716
|
-
}
|
|
81717
|
-
function planIcon(status) {
|
|
81718
|
-
if (status === "completed") return ":white_check_mark:";
|
|
81719
|
-
if (status === "in_progress") return ":hourglass_flowing_sand:";
|
|
81720
|
-
return ":white_large_square:";
|
|
81721
|
-
}
|
|
81722
|
-
/** Render an ACP plan (full entry list, resent on every update) as a compact summary.
|
|
81723
|
-
* Posted as a `markdown` block, so emphasis is CommonMark (`**bold**`), not mrkdwn. */
|
|
81724
|
-
function renderPlan(entries) {
|
|
81725
|
-
return [":clipboard: **Plan**", ...entries.map((e) => `${planIcon(e.status)} ${clampLabel(e.content ?? "")}`)].join("\n");
|
|
81726
|
-
}
|
|
81727
|
-
/** Render the accumulated reasoning trace (high mode) as one in-place message. Posted as
|
|
81728
|
-
* a `markdown` block (verbatim CommonMark, so `**bold**`), tail-clamped to a compact cap
|
|
81729
|
-
* — the newest thinking is the most relevant, so keep the end and mark truncation with a
|
|
81730
|
-
* leading ellipsis. */
|
|
81731
|
-
function renderReasoning(buf) {
|
|
81732
|
-
const trimmed = buf.trim();
|
|
81733
|
-
return `:thought_balloon: **Thinking**\n${trimmed.length > MAX_REASONING ? `…${trimmed.slice(-2800)}` : trimmed}`;
|
|
81734
|
-
}
|
|
81735
|
-
var OutputConverger = class {
|
|
81736
|
-
mode;
|
|
81737
|
-
buf = "";
|
|
81738
|
-
reasoningBuf = "";
|
|
81739
|
-
reasoningDirty = false;
|
|
81740
|
-
activity = [];
|
|
81741
|
-
toolTitles = /* @__PURE__ */ new Map();
|
|
81742
|
-
toolOutputs = /* @__PURE__ */ new Map();
|
|
81743
|
-
emittedOutput = /* @__PURE__ */ new Set();
|
|
81744
|
-
constructor(mode) {
|
|
81745
|
-
this.mode = mode;
|
|
81746
|
-
}
|
|
81747
|
-
/** True while body text OR reasoning is pending — the daemon uses this to (re)arm
|
|
81748
|
-
* the ~2s idle-flush timer (§9.1 text-buffer) so a long pure-text stream posts in
|
|
81749
|
-
* steps and streamed thinking updates its in-place block at most once per window. */
|
|
81750
|
-
hasBuffered() {
|
|
81751
|
-
return this.buf.trim().length > 0 || this.reasoningDirty;
|
|
81752
|
-
}
|
|
81753
|
-
/** Flush pending output for the idle timer: in high mode one in-place `reasoning` update
|
|
81754
|
-
* carrying the reasoning accumulated since the last flush, THEN the buffered body
|
|
81755
|
-
* (verbatim markdown split into ≤block-limit `post` sections — each a Block Kit
|
|
81756
|
-
* `markdown` block, no mrkdwn conversion). Reasoning is emitted first so its in-place
|
|
81757
|
-
* block is first-posted ABOVE the reply: thinking precedes the answer (§9.1), so the
|
|
81758
|
-
* Thinking block must sit above it, not below. */
|
|
81759
|
-
flushBuffered() {
|
|
81760
|
-
return [...this.drainReasoning(), ...this.flush()];
|
|
81761
|
-
}
|
|
81762
|
-
/** Drain reasoning buffered since the last flush into a 0-or-1-length action list (only
|
|
81763
|
-
* high mode ever sets `reasoningDirty`). Callers place it before the body flush so the
|
|
81764
|
-
* Thinking block posts above the reply. */
|
|
81765
|
-
drainReasoning() {
|
|
81766
|
-
if (!this.reasoningDirty) return [];
|
|
81767
|
-
this.reasoningDirty = false;
|
|
81768
|
-
return [{
|
|
81769
|
-
kind: "reasoning",
|
|
81770
|
-
text: renderReasoning(this.reasoningBuf)
|
|
81771
|
-
}];
|
|
81772
|
-
}
|
|
81773
|
-
flush() {
|
|
81774
|
-
const text = this.buf;
|
|
81775
|
-
this.buf = "";
|
|
81776
|
-
if (!text.trim()) return [];
|
|
81777
|
-
return splitIntoSections(text).map((t) => ({
|
|
81778
|
-
kind: "post",
|
|
81779
|
-
text: t
|
|
81780
|
-
}));
|
|
81781
|
-
}
|
|
81782
|
-
/**
|
|
81783
|
-
* Record an activity label and build the loading-status action carrying the rolling
|
|
81784
|
-
* window as `loading_messages`. Consecutive repeats collapse to nothing (returns []),
|
|
81785
|
-
* which throttles streamed thought chunks down to one status update per thinking run.
|
|
81786
|
-
*/
|
|
81787
|
-
pushActivity(raw) {
|
|
81788
|
-
const label = clampTo(raw, MAX_STATUS);
|
|
81789
|
-
if (this.activity[this.activity.length - 1] === label) return [];
|
|
81790
|
-
this.activity.push(label);
|
|
81791
|
-
if (this.activity.length > MAX_ACTIVITY) this.activity.shift();
|
|
81792
|
-
return [{
|
|
81793
|
-
kind: "set-status",
|
|
81794
|
-
text: label,
|
|
81795
|
-
loadingMessages: [...this.activity]
|
|
81796
|
-
}];
|
|
81797
|
-
}
|
|
81798
|
-
/** Resolve a tool call's display label, reusing a known title when an update omits it. */
|
|
81799
|
-
toolLabel(update) {
|
|
81800
|
-
const id = update.toolCallId;
|
|
81801
|
-
if (update.title) {
|
|
81802
|
-
if (id) this.toolTitles.set(id, update.title);
|
|
81803
|
-
return update.title;
|
|
81804
|
-
}
|
|
81805
|
-
return (id && this.toolTitles.get(id)) ?? id ?? "tool";
|
|
81806
|
-
}
|
|
81807
|
-
/** high mode: surface a finished tool's output as a code-block `tool-output` action.
|
|
81808
|
-
* Tracks the latest output across streamed updates and emits it exactly once, when the
|
|
81809
|
-
* call reaches a terminal status (completed/failed) — so partial output isn't posted
|
|
81810
|
-
* early and the final result isn't re-posted. Returns [] when there's nothing to post. */
|
|
81811
|
-
drainToolOutput(update) {
|
|
81812
|
-
const id = update.toolCallId;
|
|
81813
|
-
if (!id) return [];
|
|
81814
|
-
if (update.content !== void 0 || update.rawOutput !== void 0) {
|
|
81815
|
-
const out = extractToolOutput(update);
|
|
81816
|
-
if (out) this.toolOutputs.set(id, out);
|
|
81817
|
-
else this.toolOutputs.delete(id);
|
|
81818
|
-
}
|
|
81819
|
-
if (!(update.status === "completed" || update.status === "failed") || this.emittedOutput.has(id)) return [];
|
|
81820
|
-
const text = this.toolOutputs.get(id);
|
|
81821
|
-
if (!text) return [];
|
|
81822
|
-
this.emittedOutput.add(id);
|
|
81823
|
-
this.toolOutputs.delete(id);
|
|
81824
|
-
return [{
|
|
81825
|
-
kind: "tool-output",
|
|
81826
|
-
text: `${update.status === "failed" ? ":x:" : ":page_facing_up:"}\n${codeSpan(capOutput(text), true)}`
|
|
81827
|
-
}];
|
|
81828
|
-
}
|
|
81829
|
-
onUpdate(update) {
|
|
81830
|
-
switch (update.sessionUpdate) {
|
|
81831
|
-
case "agent_message_chunk": {
|
|
81832
|
-
const content = update.content;
|
|
81833
|
-
if (content?.type === "text") this.buf += content.text ?? "";
|
|
81834
|
-
return [];
|
|
81835
|
-
}
|
|
81836
|
-
case "agent_thought_chunk":
|
|
81837
|
-
if (this.mode === "high") {
|
|
81838
|
-
const text = update.content?.text ?? "";
|
|
81839
|
-
if (text) {
|
|
81840
|
-
this.reasoningBuf += text;
|
|
81841
|
-
if (this.reasoningBuf.length > MAX_REASONING * 2) this.reasoningBuf = this.reasoningBuf.slice(-2800 * 2);
|
|
81842
|
-
this.reasoningDirty = true;
|
|
81843
|
-
}
|
|
81844
|
-
}
|
|
81845
|
-
return [...this.flush(), ...this.pushActivity(THINKING)];
|
|
81846
|
-
case "tool_call":
|
|
81847
|
-
case "tool_call_update": {
|
|
81848
|
-
const u = update;
|
|
81849
|
-
const label = this.toolLabel(u);
|
|
81850
|
-
const status = this.pushActivity(label);
|
|
81851
|
-
if (this.mode === "low") return [...this.flush(), ...status];
|
|
81852
|
-
const actions = [
|
|
81853
|
-
...this.flush(),
|
|
81854
|
-
...status,
|
|
81855
|
-
{
|
|
81856
|
-
kind: "progress",
|
|
81857
|
-
text: `:hammer_and_wrench: ${codeSpan(label)}`
|
|
81858
|
-
}
|
|
81859
|
-
];
|
|
81860
|
-
if (this.mode === "high") actions.push(...this.drainToolOutput(u));
|
|
81861
|
-
return actions;
|
|
81862
|
-
}
|
|
81863
|
-
case "plan": {
|
|
81864
|
-
const entries = update.entries ?? [];
|
|
81865
|
-
if (this.mode === "low") return [...this.flush(), ...this.pushActivity("planning…")];
|
|
81866
|
-
return [...this.flush(), {
|
|
81867
|
-
kind: "plan",
|
|
81868
|
-
text: renderPlan(entries)
|
|
81869
|
-
}];
|
|
81870
|
-
}
|
|
81871
|
-
case "usage_update": return [];
|
|
81872
|
-
default: return [];
|
|
81873
|
-
}
|
|
81874
|
-
}
|
|
81875
|
-
/**
|
|
81876
|
-
* Turn end (§9.1 stopReason): flush remaining body, clear the loading status, and
|
|
81877
|
-
* — in medium/high — append a footer linking to the Web App session detail. The
|
|
81878
|
-
* link is omitted entirely when none is configured (no `local://` placeholder).
|
|
81879
|
-
*/
|
|
81880
|
-
onFinal(link) {
|
|
81881
|
-
const clear = {
|
|
81882
|
-
kind: "set-status",
|
|
81883
|
-
text: ""
|
|
81884
|
-
};
|
|
81885
|
-
if (this.mode === "low") return [...this.flush(), clear];
|
|
81886
|
-
const reasoning = this.drainReasoning();
|
|
81887
|
-
const footer = link ? [{
|
|
81888
|
-
kind: "notice",
|
|
81889
|
-
text: `:white_check_mark: done — [details](${link})`
|
|
81890
|
-
}] : [];
|
|
81891
|
-
return [
|
|
81892
|
-
...reasoning,
|
|
81893
|
-
...this.flush(),
|
|
81894
|
-
clear,
|
|
81895
|
-
...footer
|
|
81896
|
-
];
|
|
81897
|
-
}
|
|
81898
|
-
};
|
|
81899
|
-
//#endregion
|
|
81900
82202
|
//#region ../../node_modules/.pnpm/croner@10.0.1/node_modules/croner/dist/croner.js
|
|
81901
82203
|
function T(s) {
|
|
81902
82204
|
return Date.UTC(s.y, s.m - 1, s.d, s.h, s.i, s.s);
|
|
@@ -83200,6 +83502,7 @@ var CpClient = class {
|
|
|
83200
83502
|
this.deps.daemonId = ok.daemonId;
|
|
83201
83503
|
this.deps.onDaemonId?.(ok.daemonId);
|
|
83202
83504
|
}
|
|
83505
|
+
this.deps.onWebAppUrl?.(ok.webAppUrl);
|
|
83203
83506
|
this.state = "REGISTERING";
|
|
83204
83507
|
const snap = (await this.request("register", {
|
|
83205
83508
|
host: this.deps.host,
|
|
@@ -83465,6 +83768,12 @@ var CpClient = class {
|
|
|
83465
83768
|
case "webchat/close":
|
|
83466
83769
|
this.deps.onWebchatClose?.(frame.payload);
|
|
83467
83770
|
return;
|
|
83771
|
+
case "webchat/set-model":
|
|
83772
|
+
this.deps.onWebchatSetModel?.(frame.payload);
|
|
83773
|
+
return;
|
|
83774
|
+
case "webchat/cancel":
|
|
83775
|
+
this.deps.onWebchatCancel?.(frame.payload);
|
|
83776
|
+
return;
|
|
83468
83777
|
default:
|
|
83469
83778
|
this.deps.log.debug(`cp: ignoring ${frame.type}`);
|
|
83470
83779
|
return;
|
|
@@ -84386,6 +84695,9 @@ function chunkText(text) {
|
|
|
84386
84695
|
return out;
|
|
84387
84696
|
}
|
|
84388
84697
|
const IDLE_FLUSH_MS = 2e3;
|
|
84698
|
+
/** Public Web App console origin used for session deep links when neither a local
|
|
84699
|
+
* `webAppUrl` config nor a CP-provided one is set — so "View session" always resolves. */
|
|
84700
|
+
const DEFAULT_WEB_APP_URL = "https://app.agentconnect.md";
|
|
84389
84701
|
/** Build the wire SessionKey (protocol §5) for a pending turn — what `drain/done`
|
|
84390
84702
|
* reports as released so the CP may reassign it. Uses the real `thread` (absent for
|
|
84391
84703
|
* a channel-root message), NOT `statusThread` (which falls back to msgId): the CP
|
|
@@ -84578,6 +84890,8 @@ var Daemon = class Daemon {
|
|
|
84578
84890
|
this.onInbound(msg);
|
|
84579
84891
|
},
|
|
84580
84892
|
onChannelsChanged: () => void this.refreshChannels(conn),
|
|
84893
|
+
onStatusAction: (a) => this.handleSlackStatusAction(a),
|
|
84894
|
+
onStatusInfo: (key) => this.statusInfoForKey(key),
|
|
84581
84895
|
log: this.log,
|
|
84582
84896
|
boltDebug: cfg.logging.level === "debug" || cfg.logging.level === "trace"
|
|
84583
84897
|
});
|
|
@@ -84755,6 +85069,8 @@ var Daemon = class Daemon {
|
|
|
84755
85069
|
this.onInbound(msg);
|
|
84756
85070
|
},
|
|
84757
85071
|
onChannelsChanged: () => void this.refreshChannels(conn),
|
|
85072
|
+
onStatusAction: (a) => this.handleSlackStatusAction(a),
|
|
85073
|
+
onStatusInfo: (key) => this.statusInfoForKey(key),
|
|
84758
85074
|
log: this.log,
|
|
84759
85075
|
boltDebug: this.cfg.logging.level === "debug" || this.cfg.logging.level === "trace"
|
|
84760
85076
|
});
|
|
@@ -84919,6 +85235,55 @@ var Daemon = class Daemon {
|
|
|
84919
85235
|
handleWebchatClose(m) {
|
|
84920
85236
|
this.log.debug(`webchat: conversation ${m.conversationId} closed by client`);
|
|
84921
85237
|
}
|
|
85238
|
+
/** The local session key a webchat conversation maps to — mirrors `handleWebchatMessage`
|
|
85239
|
+
* (channel = conversationId, statusThread = the stable `webchat:<id>` msgId, no thread). */
|
|
85240
|
+
webchatSessionKey(conversationId, agentId) {
|
|
85241
|
+
return sessionKey("webchat", conversationId, `webchat:${conversationId}`, agentId);
|
|
85242
|
+
}
|
|
85243
|
+
/** Switch a session's model by its local key — the core shared by the webchat
|
|
85244
|
+
* `set-model` frame and the Slack status-bar select. Records the sticky per-session
|
|
85245
|
+
* override (re-applied on every turn by dispatch) and, if the ACP session is warm,
|
|
85246
|
+
* applies it live now and re-pushes the status bar. Never changes the agent default. */
|
|
85247
|
+
setModelByKey(key, model) {
|
|
85248
|
+
const rec = this.store.getSession(key);
|
|
85249
|
+
if (!rec) return;
|
|
85250
|
+
this.store.setModelOverride(key, model);
|
|
85251
|
+
this.log.info(`session ${key} model override → "${model}"`);
|
|
85252
|
+
const acpSessionId = rec.acpSessionId;
|
|
85253
|
+
const host = this.hosts.get(rec.agentId);
|
|
85254
|
+
if (!acpSessionId || !host?.hasSession(acpSessionId)) return;
|
|
85255
|
+
host.setSessionModel(acpSessionId, model).then((applied) => {
|
|
85256
|
+
const p = this.pending.get(acpSessionId);
|
|
85257
|
+
if (applied && p) this.emitStatusBar(p);
|
|
85258
|
+
}).catch((err) => this.log.warn(`set-model failed: ${err.message}`));
|
|
85259
|
+
}
|
|
85260
|
+
/** Cancel the in-flight turn for a local session key — the `!cancel` core (interrupt,
|
|
85261
|
+
* NO mute) shared by the Slack status-bar Cancel button. No-op if nothing is running. */
|
|
85262
|
+
cancelSessionByKey(key) {
|
|
85263
|
+
const rec = this.store.getSession(key);
|
|
85264
|
+
if (!rec?.acpSessionId || !this.pending.has(rec.acpSessionId)) return;
|
|
85265
|
+
this.interruptTurn(rec.agentId, rec.acpSessionId, key, "cancel");
|
|
85266
|
+
}
|
|
85267
|
+
/** Handle a webchat in-session model switch (C→D `webchat/set-model`). */
|
|
85268
|
+
handleWebchatSetModel(m) {
|
|
85269
|
+
this.setModelByKey(this.webchatSessionKey(m.conversationId, m.agentId), m.model);
|
|
85270
|
+
}
|
|
85271
|
+
/** Handle a webchat cancel (C→D `webchat/cancel`) — the status-bar "Cancel". Interrupts
|
|
85272
|
+
* the conversation's in-flight turn like `!cancel` (no mute; follow-ups still dispatch).
|
|
85273
|
+
* Resolved by conversationId (the frame carries no agentId). No-op when idle. */
|
|
85274
|
+
handleWebchatCancel(m) {
|
|
85275
|
+
for (const [acpSessionId, p] of this.pending) if (p.webchat?.conversationId === m.conversationId) {
|
|
85276
|
+
this.interruptTurn(p.agentId, acpSessionId, p.sessionKey, "cancel");
|
|
85277
|
+
return;
|
|
85278
|
+
}
|
|
85279
|
+
this.log.debug(`webchat cancel: no in-flight turn for conversation ${m.conversationId}`);
|
|
85280
|
+
}
|
|
85281
|
+
/** Route a Slack status-bar Block Kit interaction (model select / Cancel button) to the
|
|
85282
|
+
* shared key-based cores. `sessionKey` rides the block; no-op on an unknown key. */
|
|
85283
|
+
handleSlackStatusAction(a) {
|
|
85284
|
+
if (a.kind === "cancel") this.cancelSessionByKey(a.sessionKey);
|
|
85285
|
+
else if (a.model) this.setModelByKey(a.sessionKey, a.model);
|
|
85286
|
+
}
|
|
84922
85287
|
/** All of our own connections' bot identities (both `U…` user ids and `B…`
|
|
84923
85288
|
* bot_ids), so we recognize self-echoes regardless of which field Slack sends. */
|
|
84924
85289
|
ownBotIdentities() {
|
|
@@ -84996,14 +85361,19 @@ var Daemon = class Daemon {
|
|
|
84996
85361
|
conn?.postMessage(msg.channel, rec ? `🔇 Nothing is running. ${muteNote}` : "Nothing is running to stop.", thread);
|
|
84997
85362
|
return;
|
|
84998
85363
|
}
|
|
84999
|
-
this.
|
|
85000
|
-
this.log.info(`command: stop → agent "${target.agentId}" session ${acpSessionId}`);
|
|
85001
|
-
this.store.setSessionState(key, "cancelling", this.clock.now());
|
|
85002
|
-
this.hosts.get(target.agentId)?.cancel(acpSessionId).catch((err) => this.log.error(`command stop: cancel failed: ${err.message}`));
|
|
85003
|
-
this.armCancelBackstop(target.agentId, acpSessionId, key);
|
|
85364
|
+
this.interruptTurn(target.agentId, acpSessionId, key, "stop");
|
|
85004
85365
|
conn?.postMessage(msg.channel, `🛑 Stopped. ${muteNote}`, thread);
|
|
85005
85366
|
return;
|
|
85006
85367
|
}
|
|
85368
|
+
if (command.kind === "cancel") {
|
|
85369
|
+
if (!inflight) {
|
|
85370
|
+
conn?.postMessage(msg.channel, "Nothing is running to cancel.", thread);
|
|
85371
|
+
return;
|
|
85372
|
+
}
|
|
85373
|
+
this.interruptTurn(target.agentId, acpSessionId, key, "cancel");
|
|
85374
|
+
conn?.postMessage(msg.channel, "🛑 Cancelled.", thread);
|
|
85375
|
+
return;
|
|
85376
|
+
}
|
|
85007
85377
|
if (!command.text) {
|
|
85008
85378
|
conn?.postMessage(msg.channel, "Usage: `!queue <message>` — runs when the current turn finishes.", thread);
|
|
85009
85379
|
return;
|
|
@@ -85117,6 +85487,7 @@ var Daemon = class Daemon {
|
|
|
85117
85487
|
agentId,
|
|
85118
85488
|
platform: msg.platform,
|
|
85119
85489
|
sessionKey: key,
|
|
85490
|
+
acpSessionId: sessionId,
|
|
85120
85491
|
channel: msg.channel,
|
|
85121
85492
|
thread: msg.thread,
|
|
85122
85493
|
statusThread,
|
|
@@ -85133,7 +85504,10 @@ var Daemon = class Daemon {
|
|
|
85133
85504
|
this.pending.set(sessionId, p);
|
|
85134
85505
|
try {
|
|
85135
85506
|
const host = await this.ensureHostAsync(agentId);
|
|
85507
|
+
const override = this.store.getModelOverride(key);
|
|
85508
|
+
if (override) await host.setSessionModel(sessionId, override).catch((err) => this.log.debug(`model override "${override}" not applied: ${err.message}`));
|
|
85136
85509
|
if (!wasRunning) replyConn?.setStatus(msg.channel, statusThread, "is thinking…");
|
|
85510
|
+
this.emitStatusBar(p);
|
|
85137
85511
|
const { stopReason, usage } = await host.prompt(sessionId, blocks);
|
|
85138
85512
|
if (usage) this.store.setTokenUsage(key, {
|
|
85139
85513
|
totalTokens: usage.totalTokens,
|
|
@@ -85143,6 +85517,7 @@ var Daemon = class Daemon {
|
|
|
85143
85517
|
cachedReadTokens: usage.cachedReadTokens ?? void 0,
|
|
85144
85518
|
cachedWriteTokens: usage.cachedWriteTokens ?? void 0
|
|
85145
85519
|
});
|
|
85520
|
+
this.emitStatusBar(p);
|
|
85146
85521
|
const mergedUsage = this.store.getUsage(key);
|
|
85147
85522
|
if (Object.keys(mergedUsage).length > 0) try {
|
|
85148
85523
|
this.cpClient?.emitUsageReport({
|
|
@@ -85173,7 +85548,7 @@ var Daemon = class Daemon {
|
|
|
85173
85548
|
...usage?.totalTokens !== void 0 ? { usage: { used: usage.totalTokens } } : {}
|
|
85174
85549
|
});
|
|
85175
85550
|
} else {
|
|
85176
|
-
const link = this.
|
|
85551
|
+
const link = this.sessionLink(sessionId);
|
|
85177
85552
|
for (const action of conv.onFinal(link)) this.enqueueApply(p, action);
|
|
85178
85553
|
}
|
|
85179
85554
|
for (const ev of rec.onFinal()) this.recordEvent(agentId, msg.channel, statusThread, ev);
|
|
@@ -85198,6 +85573,17 @@ var Daemon = class Daemon {
|
|
|
85198
85573
|
this.flushQueued(agentId, sessionId, integrationId);
|
|
85199
85574
|
return sessionId;
|
|
85200
85575
|
}
|
|
85576
|
+
/** Interrupt an in-flight turn (shared by `!stop`, `!cancel`, and webchat cancel):
|
|
85577
|
+
* drop anything queued behind it, mark the session `cancelling`, send ACP
|
|
85578
|
+
* `session/cancel`, and arm the force backstop. Muting is the CALLER's concern —
|
|
85579
|
+
* `!stop` mutes, `!cancel`/webchat-cancel do not. `reason` is just for the log line. */
|
|
85580
|
+
interruptTurn(agentId, acpSessionId, key, reason) {
|
|
85581
|
+
this.queued.delete(acpSessionId);
|
|
85582
|
+
this.log.info(`command: ${reason} → agent "${agentId}" session ${acpSessionId}`);
|
|
85583
|
+
this.store.setSessionState(key, "cancelling", this.clock.now());
|
|
85584
|
+
this.hosts.get(agentId)?.cancel(acpSessionId).catch((err) => this.log.error(`command ${reason}: cancel failed: ${err.message}`));
|
|
85585
|
+
this.armCancelBackstop(agentId, acpSessionId, key);
|
|
85586
|
+
}
|
|
85201
85587
|
/** After `!stop` sends session/cancel, give the agent `cancelBackstopMs` to yield.
|
|
85202
85588
|
* If the turn is still in flight when the timer fires, the agent ignored the
|
|
85203
85589
|
* cancel — force-stop its host (the only hard kill available) so the session
|
|
@@ -85275,6 +85661,95 @@ var Daemon = class Daemon {
|
|
|
85275
85661
|
p.reasoningTs = await conn.postMessage(p.channel, action.text, p.thread);
|
|
85276
85662
|
}
|
|
85277
85663
|
return;
|
|
85664
|
+
case "status-bar":
|
|
85665
|
+
if (p.statusBarTs) await conn.updateBlocks(p.channel, p.statusBarTs, action.blocks, action.text);
|
|
85666
|
+
else if (!p.statusBarAttempted) {
|
|
85667
|
+
p.statusBarAttempted = true;
|
|
85668
|
+
p.statusBarTs = await conn.postBlocks(p.channel, action.blocks, action.text, p.thread);
|
|
85669
|
+
}
|
|
85670
|
+
return;
|
|
85671
|
+
}
|
|
85672
|
+
}
|
|
85673
|
+
/** Web App console base URL the CP sent on `auth/ok` (its own console origin). A local
|
|
85674
|
+
* `webAppUrl` config overrides it; undefined until auth completes / if neither is set. */
|
|
85675
|
+
cpWebAppUrl;
|
|
85676
|
+
/** The Web App console base for session deep links: explicit local `webAppUrl`, else the
|
|
85677
|
+
* CP-provided one, else the public default (`DEFAULT_WEB_APP_URL`). `<base>/sessions/<id>`
|
|
85678
|
+
* — always resolves, so the View-session link is always available. */
|
|
85679
|
+
sessionLink(acpSessionId) {
|
|
85680
|
+
return `${(this.cfg.webAppUrl ?? this.cpWebAppUrl ?? DEFAULT_WEB_APP_URL).replace(/\/$/, "")}/sessions/${acpSessionId}`;
|
|
85681
|
+
}
|
|
85682
|
+
/** Assemble a status snapshot from the parts (agent config + host model selector +
|
|
85683
|
+
* folded ACP usage). Shared by `buildStatusInfo` (per-turn Pending) and
|
|
85684
|
+
* `statusInfoForKey` (the ⚙-modal, keyed by session). */
|
|
85685
|
+
statusInfoFrom(agentId, sessionKey, acpSessionId, opts = {}) {
|
|
85686
|
+
const agent = this.agents.get(agentId);
|
|
85687
|
+
const usage = this.store.getUsage(sessionKey);
|
|
85688
|
+
const model = this.hosts.get(agentId)?.modelOptions?.();
|
|
85689
|
+
return {
|
|
85690
|
+
model: model?.current ?? agent?.runtimeOverrides?.model,
|
|
85691
|
+
effort: agent?.reasoningEffort,
|
|
85692
|
+
fastMode: agent?.fastMode,
|
|
85693
|
+
contextUsed: usage.contextUsed,
|
|
85694
|
+
contextSize: usage.contextSize,
|
|
85695
|
+
totalTokens: usage.totalTokens,
|
|
85696
|
+
costAmount: usage.costAmount,
|
|
85697
|
+
costCurrency: usage.costCurrency,
|
|
85698
|
+
...opts.breakdown ? {
|
|
85699
|
+
inputTokens: usage.inputTokens,
|
|
85700
|
+
outputTokens: usage.outputTokens,
|
|
85701
|
+
thoughtTokens: usage.thoughtTokens,
|
|
85702
|
+
cachedReadTokens: usage.cachedReadTokens,
|
|
85703
|
+
cachedWriteTokens: usage.cachedWriteTokens
|
|
85704
|
+
} : {},
|
|
85705
|
+
...model?.models?.length ? { models: model.models } : {},
|
|
85706
|
+
...acpSessionId ? { sessionId: acpSessionId } : {}
|
|
85707
|
+
};
|
|
85708
|
+
}
|
|
85709
|
+
/** Per-turn status snapshot for the live in-thread line + webchat frame (no breakdown). */
|
|
85710
|
+
buildStatusInfo(p) {
|
|
85711
|
+
return this.statusInfoFrom(p.agentId, p.sessionKey, p.acpSessionId);
|
|
85712
|
+
}
|
|
85713
|
+
/** Status snapshot (WITH the full token breakdown) + deep link for a session by KEY — the
|
|
85714
|
+
* source of truth the Slack connection queries to build the ⚙ controls modal on click.
|
|
85715
|
+
* Undefined on unknown key. */
|
|
85716
|
+
statusInfoForKey(sessionKey) {
|
|
85717
|
+
const rec = this.store.getSession(sessionKey);
|
|
85718
|
+
if (!rec) return void 0;
|
|
85719
|
+
const info = this.statusInfoFrom(rec.agentId, sessionKey, rec.acpSessionId ?? void 0, { breakdown: true });
|
|
85720
|
+
const link = rec.acpSessionId ? this.sessionLink(rec.acpSessionId) : void 0;
|
|
85721
|
+
return {
|
|
85722
|
+
info,
|
|
85723
|
+
...link ? { link } : {}
|
|
85724
|
+
};
|
|
85725
|
+
}
|
|
85726
|
+
/** Emit/refresh the session's status bar (model / context / tokens / cost). Called at
|
|
85727
|
+
* turn start (model + carried-over usage), on each `usage_update` (live context/cost),
|
|
85728
|
+
* and at turn end (token totals). Deduped against the last snapshot so an unchanged
|
|
85729
|
+
* update is a no-op. Webchat rides a status-only `webchat/output` frame; every other
|
|
85730
|
+
* (Slack / headless) target gets the in-place `status-bar` action — headless no-ops in
|
|
85731
|
+
* applyAction (no connection), which is fine. */
|
|
85732
|
+
emitStatusBar(p) {
|
|
85733
|
+
const info = this.buildStatusInfo(p);
|
|
85734
|
+
const key = JSON.stringify(info);
|
|
85735
|
+
if (key === p.lastStatusBar) return;
|
|
85736
|
+
if (p.webchat) {
|
|
85737
|
+
if (!Object.entries(info).some(([k, v]) => k !== "sessionId" && v !== void 0)) return;
|
|
85738
|
+
p.lastStatusBar = key;
|
|
85739
|
+
const wc = p.webchat;
|
|
85740
|
+
this.cpClient?.emitWebchatOutput({
|
|
85741
|
+
conversationId: wc.conversationId,
|
|
85742
|
+
turnId: wc.turnId,
|
|
85743
|
+
index: wc.index++,
|
|
85744
|
+
status: info
|
|
85745
|
+
});
|
|
85746
|
+
} else {
|
|
85747
|
+
p.lastStatusBar = key;
|
|
85748
|
+
this.enqueueApply(p, {
|
|
85749
|
+
kind: "status-bar",
|
|
85750
|
+
text: renderStatusBar(info),
|
|
85751
|
+
blocks: buildStatusBlocks(info, p.sessionKey, this.sessionLink(p.acpSessionId))
|
|
85752
|
+
});
|
|
85278
85753
|
}
|
|
85279
85754
|
}
|
|
85280
85755
|
/** Serialize action application per session so in-place edits never race on the
|
|
@@ -85300,12 +85775,15 @@ var Daemon = class Daemon {
|
|
|
85300
85775
|
onAcpUpdate(_agentId, sessionId, update) {
|
|
85301
85776
|
const p = this.pending.get(sessionId);
|
|
85302
85777
|
if (!p) return;
|
|
85303
|
-
if (update?.sessionUpdate === "usage_update")
|
|
85304
|
-
|
|
85305
|
-
|
|
85306
|
-
|
|
85307
|
-
|
|
85308
|
-
|
|
85778
|
+
if (update?.sessionUpdate === "usage_update") {
|
|
85779
|
+
this.store.setUsageSnapshot(p.sessionKey, {
|
|
85780
|
+
contextUsed: update.used,
|
|
85781
|
+
contextSize: update.size,
|
|
85782
|
+
costAmount: update.cost?.amount ?? void 0,
|
|
85783
|
+
costCurrency: update.cost?.currency ?? void 0
|
|
85784
|
+
});
|
|
85785
|
+
this.emitStatusBar(p);
|
|
85786
|
+
}
|
|
85309
85787
|
if (update?.sessionUpdate === "session_info_update" && update.title !== void 0) this.store.setSessionTitle(p.sessionKey, update.title);
|
|
85310
85788
|
if (p.webchat) this.emitWebchatUpdate(p, update);
|
|
85311
85789
|
else {
|
|
@@ -85776,6 +86254,10 @@ var Daemon = class Daemon {
|
|
|
85776
86254
|
persistDaemonId(root, id);
|
|
85777
86255
|
this.log.info(`cp: adopted daemonId ${id} from auth/ok`);
|
|
85778
86256
|
},
|
|
86257
|
+
onWebAppUrl: (url) => {
|
|
86258
|
+
this.cpWebAppUrl = url;
|
|
86259
|
+
if (url) this.log.debug(`cp: web app url ${url} (session deep links)`);
|
|
86260
|
+
},
|
|
85779
86261
|
agentVersion: DAEMON_VERSION,
|
|
85780
86262
|
host: hostname(),
|
|
85781
86263
|
heartbeatDefaultMs: cp.heartbeatMs,
|
|
@@ -85821,6 +86303,8 @@ var Daemon = class Daemon {
|
|
|
85821
86303
|
workspaceGit: createWorkspaceGit((id) => this.agents.get(id)?.workspace.path),
|
|
85822
86304
|
onWebchatMessage: (m) => this.handleWebchatMessage(m),
|
|
85823
86305
|
onWebchatClose: (m) => this.handleWebchatClose(m),
|
|
86306
|
+
onWebchatSetModel: (m) => this.handleWebchatSetModel(m),
|
|
86307
|
+
onWebchatCancel: (m) => this.handleWebchatCancel(m),
|
|
85824
86308
|
clock: systemClock,
|
|
85825
86309
|
connect: () => ClientTransport.dial(url),
|
|
85826
86310
|
log: this.log
|