@addozhang/dsh-discord 0.5.0-alpha.1 → 0.5.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,7 +20,7 @@ No extra process: the adapter is a DSH plugin that mounts straight onto your `ds
20
20
 
21
21
  - **Mention-driven sessions** — an authorized `@bot <task>` in a bound channel anchors a thread (your message becomes the first post), creates the DSH session, and submits the prompt at most once. Follow-ups inside the thread queue without a mention. Attached images ride along: they are downloaded from the Discord CDN within strict size/host bounds and submitted as image parts for multimodal models.
22
22
  - **Stream rendering** — typing indicator, one head message edited in place, per-tool activity rows, fenced long-answer splitting, one-time finalize; the activity message is deleted when the turn ends.
23
- - **Approvals & questions** — DSH ask frames become buttons, select menus, and a free-text modal. Ownership is enforced (the asker — or the thread owner on later turns — clicks), expiry sweeps fail closed, and settled controls grey out in place.
23
+ - **Approvals & questions** — DSH asks become buttons, select menus, and a free-text modal. Ownership is enforced (the asker — or the thread owner on later turns — clicks), expiry sweeps fail closed, and settled controls grey out in place.
24
24
  - **Session control** — `/steer`, `/stop`, and `/queue list|remove` with turn-ownership checks; `/project bind|list|info` and `/session resume` for guild↔workspace binding and history; `/guild forget` for operator cleanup.
25
25
  - **Model selection** — `/model show` reads the session's live model directory; `/model select` walks an interactive provider → model → reasoning cascade, or applies a typed `provider/model` directly. Open to any authorized member by default; restrictable to Host operators.
26
26
  - **Settings card, bilingual out of the box** — token onboarding and connect/disconnect (stored in the Host credential service, never in settings or logs), guild allowlist, auto-archive, and language. Every Discord-visible string ships in Chinese and English; the bot follows the DSH language preference or a pinned choice.
@@ -28,7 +28,7 @@ No extra process: the adapter is a DSH plugin that mounts straight onto your `ds
28
28
 
29
29
  ## Requirements
30
30
 
31
- - The [dsh CLI](https://www.npmjs.com/package/@deepseek-ai/dsh) `0.1.1-rc.2` or newer, running a web profile
31
+ - The [dsh CLI](https://www.npmjs.com/package/@deepseek-ai/dsh) `0.1.6-alpha.1` or a compatible newer release, running a web profile. Adapter `0.5.x` uses the 0.1.6 controller services and is not compatible with the former `apiProxy` host surface.
32
32
  - Node.js `^22.19.0 || >=24`
33
33
  - A Discord application with a bot user and the **MESSAGE CONTENT** privileged intent enabled (Developer Portal → your application → Bot → Privileged Gateway Intents)
34
34
 
@@ -119,7 +119,7 @@ The settings card exposes the three high-frequency fields (guild allowlist, auto
119
119
  - The adapter start chain is generation-counted, so Connect/Disconnect races with the initial boot yield exactly one gateway.
120
120
  - A credential probe falls back to `resolve()` because the Host's `describe()` misses env-sourced values — a connected adapter never reads as unconfigured.
121
121
  - Adapter logging is default-quiet: flow records ride the Host's debug level and failure-shaped events escalate to warn — nothing prints into the DSH process at the default level.
122
- - Wire-level live-path tracing: set `DSH_DISCORD_TRACE=1` before booting to emit mux frames, drop points, and delivery outcomes to stderr (default silent). It exists because the rc.2 Host wires no plugin log exporter and exposes no log-level switch — `logger.debug` output is unobservable — and should be dropped once the Host grows one.
122
+ - Wire-level live-path tracing: set `DSH_DISCORD_TRACE=1` before booting to emit mux frames, drop points, and delivery outcomes to stderr (default silent). It exists because the Host wires no plugin log exporter and exposes no log-level switch at this surface — `logger.debug` output is unobservable — and should be dropped once the Host grows one.
123
123
 
124
124
  ## Known Limitations and Deferred Work
125
125
 
package/README.zh.md CHANGED
@@ -20,7 +20,7 @@
20
20
 
21
21
  - **@提及驱动会话** — 在已绑定的频道中,被授权的 `@机器人 <任务>` 会锚定一个线程(你的消息成为首帖)、创建 DSH 会话,并且至多提交一次。线程内的后续消息无需 @ 即可排队。消息附带的图片会一并送达:在严格的大小与主机白名单约束下从 Discord CDN 下载,并作为 image parts 提交给多模态模型。
22
22
  - **流式渲染** — typing 指示、单条头消息原地编辑、逐工具活动行、代码围栏感知的长文分段、一次性收尾;Turn 结束时活动消息会被删除。
23
- - **审批与提问** — DSH ask 帧渲染为按钮、下拉菜单与自由文本弹窗。所有权强制校验(提问者——或后续 Turn 的线程属主——才能点击),超时清扫 fail-closed,结算后的控件原地置灰。
23
+ - **审批与提问** — DSH ask 渲染为按钮、下拉菜单与自由文本弹窗。所有权强制校验(提问者——或后续 Turn 的线程属主——才能点击),超时清扫 fail-closed,结算后的控件原地置灰。
24
24
  - **会话控制** — `/steer`、`/stop`、`/queue list|remove` 带运行所有权校验;`/project bind|list|info` 与 `/session resume` 管理 Guild↔工作区绑定与历史会话;`/guild forget` 供操作员清理。
25
25
  - **模型切换** — `/model show` 读取会话的实时模型目录;`/model select` 走交互式 provider → 模型 → 推理强度级联,也可直接填写 `provider/model` 应用。默认对所有授权成员开放,可收紧为仅 Host 操作员。
26
26
  - **设置卡片,开箱双语** — Token 引导与连接/断开(存入 Host 凭据服务,绝不写入设置或日志)、服务器白名单、自动归档与语言。所有 Discord 可见文案提供中英双语;Bot 默认跟随 DSH 语言偏好,也可从卡片固定。
@@ -28,7 +28,7 @@
28
28
 
29
29
  ## 环境要求
30
30
 
31
- - [dsh CLI](https://www.npmjs.com/package/@deepseek-ai/dsh) `0.1.1-rc.2` 或更新(web profile
31
+ - [dsh CLI](https://www.npmjs.com/package/@deepseek-ai/dsh) `0.1.6-alpha.1` 或兼容的更新版本(web profile)。适配器 `0.5.x` 使用 0.1.6 的控制器服务,与旧版 `apiProxy` 宿主面不兼容。
32
32
  - Node.js `^22.19.0 || >=24`
33
33
  - 一个 Discord 应用(含 Bot 用户),并在开发者门户启用 **MESSAGE CONTENT** 特权 intent(Developer Portal → 你的应用 → Bot → Privileged Gateway Intents)
34
34
 
@@ -120,7 +120,7 @@ dsh-discord:
120
120
  - 适配器启动链带代际计数,Connect/Disconnect 与初始启动竞争时只会产生一个 Gateway。
121
121
  - 凭据探测会回退到 `resolve()`:Host 的 `describe()` 不识别环境变量来源的值——已连接的适配器不会被误报为未配置。
122
122
  - 适配器日志默认静默:流程记录走 Host 的 debug 级别,失败形态的事件升到 warn——默认级别下不会向 DSH 进程打印任何内容。
123
- - 链路级 trace:启动前设置 `DSH_DISCORD_TRACE=1` 可将 mux 帧、丢弃点与投递结果输出到 stderr(默认静默)。存在原因:rc.2 Host 未为插件日志接线任何 exporter,也没有日志级别开关——`logger.debug` 输出不可见;Host 提供等价机制后应移除。
123
+ - 链路级 trace:启动前设置 `DSH_DISCORD_TRACE=1` 可将 mux 帧、丢弃点与投递结果输出到 stderr(默认静默)。存在原因:Host 在该面上未为插件日志接线任何 exporter,也没有日志级别开关——`logger.debug` 输出不可见;Host 提供等价机制后应移除。
124
124
 
125
125
  ## 已知限制与推迟项
126
126
 
@@ -73,6 +73,12 @@ export interface HostAskDeps {
73
73
  questionTimeoutMs(): number;
74
74
  nowMs(): number;
75
75
  log(event: string, detail?: unknown): void;
76
+ /**
77
+ * Claim/settle steering for the live progress line (turn-progress spec):
78
+ * called with `true` when a thread-bound ask is claimed, `false` when it
79
+ * settles (answered, cancelled, or expired).
80
+ */
81
+ onWaitState?(threadId: string, waiting: boolean): void;
76
82
  }
77
83
  /**
78
84
  * Wrap both ask services. Thread-bound sessions get the Discord ask flow;
@@ -91,6 +91,7 @@ export function installAskServicePatches(approval, questions, deps) {
91
91
  }
92
92
  const approvalId = crypto.randomUUID();
93
93
  deps.log('discord_approval_claimed', { approvalId, sessionId, threadId, toolName: req.toolName });
94
+ deps.onWaitState?.(threadId, true);
94
95
  deps.askWiring.onApprovalRequested({
95
96
  sessionId,
96
97
  threadId,
@@ -106,7 +107,10 @@ export function installAskServicePatches(approval, questions, deps) {
106
107
  pendingApprovals.set(approvalId, pending);
107
108
  if (req.signal?.aborted === true)
108
109
  pending.dispose();
109
- void pending.promise.then(() => { void deps.askWiring.disableControl(approvalId); });
110
+ void pending.promise.then(() => {
111
+ deps.onWaitState?.(threadId, false);
112
+ void deps.askWiring.disableControl(approvalId);
113
+ });
110
114
  return pending.promise;
111
115
  };
112
116
  if (questions !== undefined && originalAsk !== undefined) {
@@ -123,6 +127,7 @@ export function installAskServicePatches(approval, questions, deps) {
123
127
  return originalAsk(req);
124
128
  const rpcId = crypto.randomUUID();
125
129
  deps.log('discord_question_claimed', { rpcId, sessionId, threadId, questions: rows.length });
130
+ deps.onWaitState?.(threadId, true);
126
131
  deps.askWiring.onQuestionRequested({
127
132
  sessionId,
128
133
  threadId,
@@ -135,7 +140,10 @@ export function installAskServicePatches(approval, questions, deps) {
135
140
  pendingQuestions.set(rpcId, pending);
136
141
  if (req.signal?.aborted === true)
137
142
  pending.dispose();
138
- void pending.promise.then(() => { void deps.askWiring.disableControl(rpcId); });
143
+ void pending.promise.then(() => {
144
+ deps.onWaitState?.(threadId, false);
145
+ void deps.askWiring.disableControl(rpcId);
146
+ });
139
147
  return pending.promise;
140
148
  };
141
149
  }
@@ -105,9 +105,19 @@ export function createHostEventRouter(services, options = {}) {
105
105
  const per = new AbortController();
106
106
  tracked.set(sessionId, per);
107
107
  void (async () => {
108
+ if (process.env['DSH_DISCORD_TRACE'] === '1')
109
+ console.error(`[dsh-discord:trace] follow-start session=${sessionId.slice(0, 8)} attempt=${String(attempt)}`);
108
110
  try {
109
111
  consumer?.push({ type: 'session/subscribed', sessionId });
110
- for await (const raw of services.follow({ address: { kind: 'session', sessionId } }, per.signal)) {
112
+ // The follow request MUST ask for assistant streaming: without the
113
+ // flag the host (alpha.2) delivers only the opening snapshot and
114
+ // never pushes later journal records — the live tail stays dead
115
+ // (diagnosis.md §D, run 2 vs run 3).
116
+ for await (const raw of services.follow({ address: { kind: 'session', sessionId }, assistantStream: true }, per.signal)) {
117
+ if (process.env['DSH_DISCORD_TRACE'] === '1') {
118
+ const dumped = JSON.stringify(raw);
119
+ console.error(`[dsh-discord:trace] raw-follow-frame type=${String(isRecord(raw) ? raw['type'] : typeof raw)} frame=${dumped.length > 300 ? `${dumped.slice(0, 300)}…` : dumped}`);
120
+ }
111
121
  if (!isRecord(raw))
112
122
  continue;
113
123
  const frame = raw;
@@ -115,12 +125,23 @@ export function createHostEventRouter(services, options = {}) {
115
125
  // same {type, data, seq} record envelope the rc.2 mux delivered;
116
126
  // the seq watermark keeps replayed windows idempotent.
117
127
  if (frame['type'] === 'event' || frame['type'] === 'snapshot') {
128
+ // Two carriers, both real-machine verified (alpha.2): snapshot
129
+ // windows batch their records under `records`, while live records
130
+ // arrive as single-record frames — the record rides the `event`
131
+ // key of the frame itself, no array. Snapshots without a records
132
+ // array stay drops (malformed), live frames become their own
133
+ // one-element batch.
118
134
  const records = raw.records;
119
- if (!Array.isArray(records))
135
+ const batch = Array.isArray(records)
136
+ ? records
137
+ : frame['type'] === 'event'
138
+ ? [raw]
139
+ : undefined;
140
+ if (batch === undefined)
120
141
  continue;
121
142
  const through = watermark.get(sessionId) ?? 0;
122
143
  let delivered = through;
123
- for (const record of records) {
144
+ for (const record of batch) {
124
145
  // Journal records arrive double-wrapped: {type:'event',
125
146
  // event:{type, seq, time, data}} — the wire event rides the
126
147
  // `event` key. Accept the flat shape defensively too.
@@ -138,6 +159,12 @@ export function createHostEventRouter(services, options = {}) {
138
159
  if (inner.seq > delivered)
139
160
  delivered = inner.seq;
140
161
  }
162
+ if (process.env['DSH_DISCORD_TRACE'] === '1') {
163
+ // Full data dump (truncated): diagnosis needs the field
164
+ // shapes, not just the key names — keys hide nesting.
165
+ const dumped = JSON.stringify(inner.data);
166
+ console.error(`[dsh-discord:trace] record type=${inner.type} seq=${String(inner.seq)} data=${dumped.length > 800 ? `${dumped.slice(0, 800)}…` : dumped}`);
167
+ }
141
168
  consumer?.push({
142
169
  type: 'session/event',
143
170
  sessionId,
@@ -150,16 +177,28 @@ export function createHostEventRouter(services, options = {}) {
150
177
  else if (frame['type'] === 'assistant-stream') {
151
178
  // Live assistant deltas ride the dedicated stream frames; the
152
179
  // renderer's durable events already carry the message texts.
180
+ if (process.env['DSH_DISCORD_TRACE'] === '1') {
181
+ const dumped = JSON.stringify(frame);
182
+ console.error(`[dsh-discord:trace] assistant-stream frame=${dumped.length > 300 ? `${dumped.slice(0, 300)}…` : dumped}`);
183
+ }
153
184
  continue;
154
185
  }
186
+ else if (process.env['DSH_DISCORD_TRACE'] === '1') {
187
+ const dumped = JSON.stringify(frame);
188
+ console.error(`[dsh-discord:trace] follow-frame UNHANDLED type=${String(frame['type'])} frame=${dumped.length > 400 ? `${dumped.slice(0, 400)}…` : dumped}`);
189
+ }
155
190
  }
156
191
  }
157
192
  catch (cause) {
158
193
  if (!per.signal.aborted) {
194
+ if (process.env['DSH_DISCORD_TRACE'] === '1')
195
+ console.error(`[dsh-discord:trace] follow-threw session=${sessionId.slice(0, 8)} cause=${String(cause).slice(0, 300)}`);
159
196
  log?.('discord_host_follow_threw', { sessionId, cause: String(cause) });
160
197
  }
161
198
  }
162
199
  finally {
200
+ if (process.env['DSH_DISCORD_TRACE'] === '1')
201
+ console.error(`[dsh-discord:trace] follow-end session=${sessionId.slice(0, 8)} aborted=${String(per.signal.aborted)} tracked=${String(tracked.get(sessionId) === per)}`);
163
202
  if (tracked.get(sessionId) === per)
164
203
  tracked.delete(sessionId);
165
204
  // A follow stream may END normally once its snapshot is delivered
@@ -185,6 +224,10 @@ export function createHostEventRouter(services, options = {}) {
185
224
  if (!isRecord(raw))
186
225
  continue;
187
226
  const frame = raw;
227
+ if (process.env['DSH_DISCORD_TRACE'] === '1') {
228
+ const dumped = JSON.stringify(frame);
229
+ console.error(`[dsh-discord:trace] control type=${String(frame['type'])} frame=${dumped.length > 400 ? `${dumped.slice(0, 400)}…` : dumped}`);
230
+ }
188
231
  if (frame['type'] === 'queue' && typeof frame['sessionId'] === 'string') {
189
232
  const rawItems = Array.isArray(frame['items']) ? frame['items'] : [];
190
233
  const items = rawItems
@@ -218,6 +261,8 @@ export function createHostEventRouter(services, options = {}) {
218
261
  startLoop(sessionId);
219
262
  },
220
263
  stream(signal) {
264
+ if (process.env['DSH_DISCORD_TRACE'] === '1')
265
+ console.error(`[dsh-discord:trace] router-stream-open tracked=${String(tracked.size)} rearm=${String(tracked.size)}`);
221
266
  rootSignal = signal;
222
267
  const queue = createFrameQueue();
223
268
  consumer = queue;
package/lib/i18n.d.ts CHANGED
@@ -85,6 +85,11 @@ declare const zh: {
85
85
  unboundNoticeAdministrator: string;
86
86
  unboundNoticeMember: string;
87
87
  interruptedMarker: string;
88
+ progressThinking: string;
89
+ progressThinkingStep: (step: number) => string;
90
+ progressWriting: string;
91
+ progressApprovalWait: string;
92
+ progressTurnSummary: (total: number, failed: number, breakdown: string) => string;
88
93
  modelNeedsThread: string;
89
94
  modelShowUnavailable: string;
90
95
  modelShowHeader: (sel: string, groups: number) => string;
package/lib/i18n.js CHANGED
@@ -93,6 +93,13 @@ const zh = {
93
93
  unboundNoticeMember: '💡 此频道未绑定工作区;请工作区管理员运行 `/project bind`。',
94
94
  // ── stream renderer ──────────────────────────────────────────────────
95
95
  interruptedMarker: '*(已被中断)*',
96
+ progressThinking: '⏳ 思考中…',
97
+ progressThinkingStep: (step) => `⏳ 思考中…(步骤 ${String(step)})`,
98
+ progressWriting: '✍️ 撰写回复…',
99
+ progressApprovalWait: '⏳ 等待审批…',
100
+ progressTurnSummary: (total, failed, breakdown) => failed > 0
101
+ ? `⚙️ 本轮 ${String(total)} 次工具调用 · ${String(failed)} 失败 ✗(${breakdown})`
102
+ : `⚙️ 本轮 ${String(total)} 次工具调用 ✓(${breakdown})`,
96
103
  // ── /model show / select ─────────────────────────────────────────────
97
104
  modelNeedsThread: '⚠️ /model 需要在已绑定 Session 的任务线程中使用(先在项目频道 @ 机器人)。',
98
105
  modelShowUnavailable: '⚠️ 模型目录暂时不可用,请稍后重试。',
@@ -215,6 +222,13 @@ const en = {
215
222
  unboundNoticeAdministrator: '💡 This channel is not bound to a workspace. A workspace administrator can run `/project bind` to create and bind the project channel.',
216
223
  unboundNoticeMember: '💡 This channel is not bound to a workspace; ask a workspace administrator to run `/project bind`.',
217
224
  interruptedMarker: '*(interrupted)*',
225
+ progressThinking: '⏳ Thinking…',
226
+ progressThinkingStep: (step) => `⏳ Thinking… (step ${String(step)})`,
227
+ progressWriting: '✍️ Writing the answer…',
228
+ progressApprovalWait: '⏳ Waiting for approval…',
229
+ progressTurnSummary: (total, failed, breakdown) => failed > 0
230
+ ? `⚙️ ${String(total)} tool calls this turn · ${String(failed)} failed ✗ (${breakdown})`
231
+ : `⚙️ ${String(total)} tool calls this turn ✓ (${breakdown})`,
218
232
  // ── /model show / select ─────────────────────────────────────────────
219
233
  modelNeedsThread: '⚠️ /model needs a thread bound to a Session (mention the bot in a project channel first).',
220
234
  modelShowUnavailable: '⚠️ The model catalog is temporarily unavailable; try again later.',
package/lib/index.js CHANGED
@@ -711,6 +711,10 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
711
711
  if (approvalService === undefined)
712
712
  throw new TypeError('dsh-discord cannot reach the approval service');
713
713
  const hostAsksRef = { current: undefined };
714
+ // Ask-claim steering for the turn progress line (turn-progress spec):
715
+ // declared before the patches so the closure is stable; the live
716
+ // renderer attaches later in startup (asks cannot fire before then).
717
+ const liveProgressRef = { current: undefined };
714
718
  hostAsksRef.current = installAskServicePatches(approvalService, questionsService, {
715
719
  threadForSession,
716
720
  askWiring,
@@ -718,6 +722,7 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
718
722
  questionTimeoutMs: () => current.questionTimeoutMs,
719
723
  nowMs: () => Date.now(),
720
724
  log: rpcLog,
725
+ onWaitState: (threadId, waiting) => { liveProgressRef.current?.setApprovalWait(threadId, waiting); },
721
726
  });
722
727
  ctx.effect(() => () => { hostAsksRef.current?.dispose(); }, 'dsh-discord host ask answerers');
723
728
  const approvalRespondPort = {
@@ -1081,6 +1086,13 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
1081
1086
  verbosity: current.defaultVerbosity,
1082
1087
  log: rpcLog,
1083
1088
  interruptedMarker: () => copy.interruptedMarker,
1089
+ progressCopy: () => ({
1090
+ thinking: copy.progressThinking,
1091
+ thinkingStep: copy.progressThinkingStep,
1092
+ writing: copy.progressWriting,
1093
+ approvalWait: copy.progressApprovalWait,
1094
+ turnSummary: copy.progressTurnSummary,
1095
+ }),
1084
1096
  onQueueSnapshot: (sessionId, items) => { queueSnapshots.set(sessionId, items); },
1085
1097
  onTurnEnded: (sessionId) => {
1086
1098
  const turn = turnTracker.active(sessionId);
@@ -1089,6 +1101,8 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
1089
1101
  rpcLog('discord_turn_ended', { sessionId, hadActiveTurn: turn !== undefined });
1090
1102
  },
1091
1103
  });
1104
+ // The ask patches steer the same renderer's progress line (3.2 wiring).
1105
+ liveProgressRef.current = liveRef.current;
1092
1106
  ctx.effect(() => () => { liveRef.current?.dispose(); }, 'dsh-discord live render');
1093
1107
  ctx.effect(() => () => { runtimeRef.current?.dispose(); }, 'dsh-discord composed runtime');
1094
1108
  })().catch((cause) => {
@@ -95,9 +95,23 @@ export interface LiveRenderDeps {
95
95
  }>) => void;
96
96
  /** Localized interruption suffix, resolved live (language can change). */
97
97
  interruptedMarker?: () => string;
98
+ /**
99
+ * Localized progress-phase copy, resolved live (language can change).
100
+ * Unset: the status line is omitted and only tool rows render (the
101
+ * pre-progress behavior).
102
+ */
103
+ progressCopy?: () => {
104
+ thinking: string;
105
+ thinkingStep: (step: number) => string;
106
+ writing: string;
107
+ approvalWait: string;
108
+ turnSummary: (total: number, failed: number, breakdown: string) => string;
109
+ };
98
110
  /** Turn ownership release on turn/end. */
99
111
  onTurnEnded?: (sessionId: string) => void;
100
112
  }
101
113
  export declare function startLiveRender(deps: LiveRenderDeps): {
102
114
  dispose(): void;
115
+ /** Approval-wait steering for the ask patches (turn-progress spec). */
116
+ setApprovalWait(threadId: string, waiting: boolean): void;
103
117
  };
@@ -61,6 +61,17 @@ function resultCallId(data) {
61
61
  const block = Array.isArray(message?.content) ? message.content[0] : undefined;
62
62
  return typeof block?.toolCallId === 'string' ? block.toolCallId : undefined;
63
63
  }
64
+ /**
65
+ * The failure flag of a tool/result event. The rc.2 wire carried a top-level
66
+ * `error`; the 0.1.6 journal marks failure on the result block (`isError`).
67
+ */
68
+ function resultFailed(data) {
69
+ if (data['error'] !== undefined)
70
+ return true;
71
+ const message = data['message'];
72
+ const block = Array.isArray(message?.content) ? message.content[0] : undefined;
73
+ return block?.isError === true;
74
+ }
64
75
  /**
65
76
  * The Host presentation view's title for one tool event: a terminal call's
66
77
  * title IS the command; generic/diff cards title the call. Host-curated
@@ -97,6 +108,9 @@ export function startLiveRender(deps) {
97
108
  finalizer: undefined,
98
109
  headMessageId: undefined,
99
110
  activityMessageId: undefined,
111
+ activityFlush: undefined,
112
+ progressPhase: undefined,
113
+ phaseBeforeApproval: undefined,
100
114
  headFlush: undefined,
101
115
  stepSeq: 0,
102
116
  headAttempted: false,
@@ -146,6 +160,7 @@ export function startLiveRender(deps) {
146
160
  runtime.turnId = turnId;
147
161
  runtime.headMessageId = undefined;
148
162
  runtime.activityMessageId = undefined;
163
+ runtime.activityFlush = undefined;
149
164
  runtime.headFlush = undefined;
150
165
  runtime.headAttempted = false;
151
166
  runtime.activityAttempted = false;
@@ -168,6 +183,10 @@ export function startLiveRender(deps) {
168
183
  onFlushError: (cause) => { deps.log?.('discord_live_activity_flush_failed', { threadId, cause: String(cause) }); },
169
184
  });
170
185
  runtime.finalizer = undefined;
186
+ // The status line opens the turn in the thinking phase (spec
187
+ // turn-progress: the message exists from turn/start, before any tool).
188
+ runtime.progressPhase = { kind: 'thinking', step: 1 };
189
+ runtime.phaseBeforeApproval = undefined;
171
190
  // A fresh lifecycle per turn: start() no-ops on a stopped one, so a
172
191
  // second turn in the same thread would otherwise never type again.
173
192
  runtime.typing.dispose();
@@ -177,40 +196,84 @@ export function startLiveRender(deps) {
177
196
  onFailure: (cause) => { deps.log?.('discord_live_typing_threw', { threadId, cause: String(cause) }); },
178
197
  });
179
198
  runtime.typing.start();
199
+ runtime.activityScheduler.schedule(renderActivityContent(runtime));
180
200
  }
181
- /** The activity message body: one icon + presentation title per call row. */
201
+ /** The status line's phase entry (undefined when copy is not provided). */
202
+ function progressPhaseLine(runtime) {
203
+ const copy = deps.progressCopy?.();
204
+ if (copy === undefined || runtime.progressPhase === undefined)
205
+ return undefined;
206
+ const phase = runtime.progressPhase;
207
+ switch (phase.kind) {
208
+ case 'thinking': return phase.step > 1 ? copy.thinkingStep(phase.step) : copy.thinking;
209
+ case 'tool': return `💻 ${truncateText(phase.title ?? phase.label, ACTIVITY_TITLE_MAX)}`;
210
+ case 'writing': return copy.writing;
211
+ case 'approval': return copy.approvalWait;
212
+ }
213
+ }
214
+ /**
215
+ * The turn's collapsed one-line summary (decision 5): the durable trace a
216
+ * finished turn leaves behind. Undefined when the turn ran no tools —
217
+ * those keep the pure Q&A thread and the message is deleted instead.
218
+ */
219
+ function renderTurnSummary(rows) {
220
+ const copy = deps.progressCopy?.();
221
+ if (copy === undefined || rows.length === 0)
222
+ return undefined;
223
+ const counts = new Map();
224
+ for (const row of rows)
225
+ counts.set(row.label, (counts.get(row.label) ?? 0) + 1);
226
+ const breakdown = truncateText([...counts.entries()].map(([label, n]) => `${label} ×${String(n)}`).join(' · '), 200);
227
+ const failed = rows.filter(row => row.state === 'failed').length;
228
+ return copy.turnSummary(rows.length, failed, breakdown);
229
+ }
230
+ /** The activity message body: phase line, then one icon + title per call row. */
182
231
  function renderActivityContent(runtime) {
183
232
  const rows = runtime.tools.render();
184
- return rows.map(row => {
233
+ const rowLines = rows.map(row => {
185
234
  const title = truncateText(row.title ?? row.label, ACTIVITY_TITLE_MAX);
186
- return `> ${toolCategoryIcon(row.label)} ${title}`;
187
- }).join('\n');
235
+ const mark = row.state === 'succeeded' ? '✓ ' : row.state === 'failed' ? '✗ ' : '';
236
+ return `> ${mark}${toolCategoryIcon(row.label)} ${title}`;
237
+ });
238
+ const phaseLine = progressPhaseLine(runtime);
239
+ return phaseLine === undefined ? rowLines.join('\n') : [phaseLine, ...rowLines].join('\n');
188
240
  }
189
241
  /** Render the tool rows into one bounded activity message (create once, edit after). */
190
242
  function renderActivity(threadId, runtime) {
191
243
  return async () => {
192
- if (runtime.tools.render().length === 0)
244
+ if (runtime.tools.render().length === 0 && progressPhaseLine(runtime) === undefined)
193
245
  return;
194
- // Tool titles are Host-presented free text (terminal commands): they
195
- // go through the same outbound builder as every other message path.
196
- const payload = buildOutboundMessage({ kind: 'tool', content: renderActivityContent(runtime) });
197
- if (runtime.activityMessageId === undefined) {
198
- if (runtime.activityAttempted)
199
- return;
200
- const sent = await deps.delivery.send({ channelId: threadId, content: payload.content });
201
- if (sent.outcome === 'completed')
202
- runtime.activityMessageId = sent.messageId;
203
- else if (sent.outcome === 'unknown') {
204
- runtime.activityAttempted = true;
205
- deps.log?.('discord_live_activity_send_unknown', { threadId });
246
+ // The flush promise records the message id it created so turn/end's
247
+ // cleanup can delete a send that is still in flight (orphan guard).
248
+ const flush = (async () => {
249
+ // Tool titles are Host-presented free text (terminal commands): they
250
+ // go through the same outbound builder as every other message path.
251
+ const payload = buildOutboundMessage({ kind: 'tool', content: renderActivityContent(runtime) });
252
+ if (runtime.activityMessageId === undefined) {
253
+ if (runtime.activityAttempted)
254
+ return undefined;
255
+ const sent = await deps.delivery.send({ channelId: threadId, content: payload.content });
256
+ if (sent.outcome === 'completed') {
257
+ runtime.activityMessageId = sent.messageId;
258
+ return sent.messageId;
259
+ }
260
+ if (sent.outcome === 'unknown') {
261
+ runtime.activityAttempted = true;
262
+ deps.log?.('discord_live_activity_send_unknown', { threadId });
263
+ }
264
+ return undefined;
206
265
  }
207
- return;
208
- }
209
- await deps.delivery.edit({ channelId: threadId, messageId: runtime.activityMessageId, content: payload.content });
266
+ await deps.delivery.edit({ channelId: threadId, messageId: runtime.activityMessageId, content: payload.content });
267
+ return undefined;
268
+ })();
269
+ runtime.activityFlush = flush;
270
+ await flush;
210
271
  };
211
272
  }
212
273
  function handleSessionEvent(sessionId, threadId, runtime, event, frameView) {
213
274
  const data = event.data;
275
+ if (TRACE)
276
+ trace('handleSessionEvent', event.type, 'keys:', Object.keys(data).join(','));
214
277
  const turnId = typeof data['turn'] === 'number' ? String(data['turn']) : undefined;
215
278
  const stepId = typeof data['step'] === 'number' ? String(data['step']) : undefined;
216
279
  switch (event.type) {
@@ -229,6 +292,11 @@ export function startLiveRender(deps) {
229
292
  runtime.headFlush = undefined;
230
293
  runtime.headAttempted = false;
231
294
  runtime.stepSeq += 1;
295
+ // A step boundary is a thinking boundary: the model is reasoning
296
+ // about the previous step's results before the next tool call.
297
+ // The journal's own step number is the display truth (stepSeq is a
298
+ // generation fence that also bumps at the turn boundary).
299
+ runtime.progressPhase = { kind: 'thinking', step: typeof data['step'] === 'number' ? data['step'] : runtime.stepSeq };
232
300
  // The previous step's finalize disposed the scheduler; a fresh one
233
301
  // carries the new step's chunk coalescing.
234
302
  runtime.scheduler?.dispose();
@@ -237,6 +305,7 @@ export function startLiveRender(deps) {
237
305
  onFlush: flushAnswer(threadId, runtime),
238
306
  onFlushError: (cause) => { deps.log?.('discord_live_flush_failed', { threadId, cause: String(cause) }); },
239
307
  });
308
+ runtime.activityScheduler?.schedule(renderActivityContent(runtime));
240
309
  return;
241
310
  }
242
311
  case 'assistant/chunk': {
@@ -265,6 +334,9 @@ export function startLiveRender(deps) {
265
334
  runtime.render.setAuthoritative({ turnId, stepId, text });
266
335
  if (interrupted)
267
336
  runtime.render.interrupt({ turnId, stepId });
337
+ // The step's answer is committed: the agent is writing its reply.
338
+ runtime.progressPhase = { kind: 'writing' };
339
+ runtime.activityScheduler?.schedule(renderActivityContent(runtime));
268
340
  runtime.scheduler?.dispose();
269
341
  runtime.scheduler = undefined;
270
342
  if (text === '')
@@ -319,6 +391,8 @@ export function startLiveRender(deps) {
319
391
  runtime.toolNames.set(data['callId'], data['name']);
320
392
  if (title !== undefined)
321
393
  runtime.toolTitles.set(data['callId'], title);
394
+ // The status line tracks the most recent call as the running phase.
395
+ runtime.progressPhase = { kind: 'tool', label: data['name'], title };
322
396
  runtime.tools.record({
323
397
  callId: data['callId'],
324
398
  toolName: data['name'],
@@ -333,7 +407,7 @@ export function startLiveRender(deps) {
333
407
  const callId = resultCallId(data);
334
408
  if (callId === undefined)
335
409
  return;
336
- const failed = data['error'] !== undefined;
410
+ const failed = resultFailed(data);
337
411
  runtime.tools.record({
338
412
  callId,
339
413
  toolName: runtime.toolNames.get(callId) ?? 'tool',
@@ -349,16 +423,36 @@ export function startLiveRender(deps) {
349
423
  runtime.scheduler = undefined;
350
424
  runtime.activityScheduler?.dispose();
351
425
  runtime.activityScheduler = undefined;
352
- // The activity message is the live "what's happening" surface only:
353
- // at turn end it is deleted — the durable record is the Session log
354
- // and the assistant's answer.
355
- const activityMessageId = runtime.activityMessageId;
426
+ runtime.progressPhase = undefined;
427
+ runtime.phaseBeforeApproval = undefined;
428
+ // The activity message collapses at turn end (decision 5): a turn
429
+ // that ran tools is EDITED into its one-line summary — the durable
430
+ // process trace — while a zero-tool turn keeps the pure Q&A thread
431
+ // and is deleted. A send still in flight when the cleanup ran would
432
+ // orphan the message, so the collapse waits for the flush to settle
433
+ // first (stepSeq fences a turn that began meanwhile: its message
434
+ // owns the slot and must survive).
435
+ const epochAtEnd = runtime.stepSeq;
436
+ const settledId = runtime.activityMessageId;
356
437
  runtime.activityMessageId = undefined;
357
- if (activityMessageId !== undefined) {
358
- void deps.delivery.delete({ channelId: threadId, messageId: activityMessageId }).catch((cause) => {
438
+ const summary = renderTurnSummary(runtime.tools.render());
439
+ const pendingActivityFlush = Promise.resolve(runtime.activityFlush).catch(() => undefined);
440
+ void pendingActivityFlush.then(flushedId => {
441
+ const id = runtime.activityMessageId ?? settledId ?? flushedId;
442
+ if (id === undefined || runtime.stepSeq !== epochAtEnd)
443
+ return;
444
+ runtime.activityMessageId = undefined;
445
+ if (summary !== undefined) {
446
+ const payload = buildOutboundMessage({ kind: 'tool', content: summary });
447
+ void deps.delivery.edit({ channelId: threadId, messageId: id, content: payload.content }).catch((cause) => {
448
+ deps.log?.('discord_live_activity_summary_edit_threw', { threadId, cause: String(cause) });
449
+ });
450
+ return;
451
+ }
452
+ void deps.delivery.delete({ channelId: threadId, messageId: id }).catch((cause) => {
359
453
  deps.log?.('discord_live_activity_delete_threw', { threadId, cause: String(cause) });
360
454
  });
361
- }
455
+ });
362
456
  deps.onTurnEnded?.(sessionId);
363
457
  return;
364
458
  }
@@ -503,5 +597,30 @@ export function startLiveRender(deps) {
503
597
  }
504
598
  runtimes.clear();
505
599
  },
600
+ /**
601
+ * Approval-wait steering for the ask patches (turn-progress spec): a
602
+ * claimed ask suspends the status line on the wait phase; settling
603
+ * restores the suspended phase so progress tracking resumes.
604
+ */
605
+ setApprovalWait(threadId, waiting) {
606
+ if (state.disposed)
607
+ return;
608
+ const runtime = runtimes.get(threadId);
609
+ if (runtime === undefined)
610
+ return;
611
+ if (waiting) {
612
+ if (runtime.progressPhase?.kind === 'approval')
613
+ return;
614
+ runtime.phaseBeforeApproval = runtime.progressPhase;
615
+ runtime.progressPhase = { kind: 'approval' };
616
+ }
617
+ else {
618
+ if (runtime.progressPhase?.kind !== 'approval')
619
+ return;
620
+ runtime.progressPhase = runtime.phaseBeforeApproval;
621
+ runtime.phaseBeforeApproval = undefined;
622
+ }
623
+ runtime.activityScheduler?.schedule(renderActivityContent(runtime));
624
+ },
506
625
  };
507
626
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@addozhang/dsh-discord",
3
- "version": "0.5.0-alpha.1",
3
+ "version": "0.5.0-alpha.2",
4
4
  "description": "Discord-first adapter for DeepSeek Harness",
5
5
  "license": "MIT",
6
6
  "type": "module",