@agentconnect.md/daemon 1.0.0-rc.157 → 1.0.0-rc.158

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 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.title` columns to a
24173
- * pre-existing DB. CREATE TABLE IF NOT EXISTS above adds them for fresh DBs but
24174
- * never alters an existing table, so upgrade in place. No-op once the columns
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", "cancel"]);
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.queued.delete(acpSessionId);
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.cfg.webAppUrl ? `${this.cfg.webAppUrl.replace(/\/$/, "")}/sessions/${sessionId}` : void 0;
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") this.store.setUsageSnapshot(p.sessionKey, {
85304
- contextUsed: update.used,
85305
- contextSize: update.size,
85306
- costAmount: update.cost?.amount ?? void 0,
85307
- costCurrency: update.cost?.currency ?? void 0
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