dsh-monkeyai-sessions 0.1.2 → 0.1.4

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
@@ -39,7 +39,7 @@ All three request `device:connect`, `session:read`, and `session:control` throug
39
39
 
40
40
  ## Sessions
41
41
 
42
- Full conversation history is loaded over the runtime's HTTP read channel (`POST {http_base}/read/session` with `_monkeyai/session/messages`, paginated by cursor) instead of the bounded ACP `session/load` replay, so long sessions show every turn including the latest replies. While a turn is running the composer shows a stop button that sends `session/cancel`; the model switcher uses `_monkeyai/session/model/set` on the live session connection. Assistant messages render lightweight markdown (code blocks, inline code, bold, headings, lists).
42
+ Full conversation history is loaded over the runtime's HTTP read channel (`POST {http_base}/read/session` with `_monkeyai/session/messages`, paginated by cursor) instead of the bounded ACP `session/load` replay, so long sessions show every turn including the latest replies. After mounting the session with the same `historyHTTP` capabilities as the MonkeyAI client, the panel subscribes to ACP live updates: messages and assistant chunks started in the MonkeyAI desktop client appear as they stream, without refreshing. For large replies persisted by the runtime, `history_changed` triggers a cursor-based HTTP delta refresh and replaces any partial snapshot. A status badge next to the session title shows the turn state (running / idle / error / interrupted), driven by the runtime's `_monkeyai/session/state` notifications, so turns started from the MonkeyAI desktop client also show as running; when the panel opens while a turn is already in flight, the host fetches the current state to seed the badge. While a turn is running (from either side) the composer shows a stop button that sends `session/cancel`; the model switcher uses `_monkeyai/session/model/set` on the live session connection. Assistant messages render lightweight markdown (code blocks, inline code, bold, headings, lists).
43
43
 
44
44
  ## Installation
45
45
 
@@ -61,7 +61,7 @@ From a checkout:
61
61
  npm install
62
62
  npm run build
63
63
  npm pack
64
- dsh plugin --profile web add ./dsh-monkeyai-sessions-0.1.2.tgz
64
+ dsh plugin --profile web add ./dsh-monkeyai-sessions-0.1.4.tgz
65
65
  ```
66
66
 
67
67
  Verified against a real DSH `0.2.2-rc.1` web profile and a live MonkeyAI runtime: authorization, full history loading, prompt delivery, model switching, and turn cancellation all work end to end.
package/README.zh.md CHANGED
@@ -28,7 +28,7 @@ https://artenx.github.io/dsh-monkeyai-sessions/index.json
28
28
  npm install
29
29
  npm run build
30
30
  npm pack
31
- dsh plugin --profile web add ./dsh-monkeyai-sessions-0.1.2.tgz
31
+ dsh plugin --profile web add ./dsh-monkeyai-sessions-0.1.4.tgz
32
32
  ```
33
33
 
34
34
  已在真实的 DSH `0.2.2-rc.1` web profile 与在线 MonkeyAI runtime 上端到端验证:授权、完整历史加载、指令下发、模型切换、回合取消均正常工作。
@@ -45,9 +45,9 @@ dsh plugin --profile web add ./dsh-monkeyai-sessions-0.1.2.tgz
45
45
 
46
46
  ## 会话能力
47
47
 
48
- 完整对话历史通过 runtime 的 HTTP 读取通道加载(`POST {http_base}/read/session`,方法 `_monkeyai/session/messages`,按游标翻页),而不是 ACP `session/load` 的有界回放,因此长会话能看到每一轮内容,包括最新回复。
48
+ 完整对话历史通过 runtime 的 HTTP 读取通道加载(`POST {http_base}/read/session`,方法 `_monkeyai/session/messages`,按游标翻页),而不是 ACP `session/load` 的有界回放,因此长会话能看到每一轮内容,包括最新回复。会话使用与 MonkeyAI 客户端相同的 `historyHTTP` 能力挂载后,面板会订阅 ACP 实时更新:在 MonkeyAI 桌面客户端发起的用户消息和助手回复都会边生成边显示,无需刷新。对于 runtime 先持久化再通知的超大回复,收到 `history_changed` 后会按游标增量读取,并用完整快照补齐部分内容。
49
49
 
50
- 回合执行期间输入框旁会显示停止按钮,点击发送 `session/cancel`;模型切换在会话自己的连接上调用 `_monkeyai/session/model/set`。助手消息支持轻量 Markdown 渲染(代码块、行内代码、粗体、标题、列表)。
50
+ 会话标题旁会显示会话状态徽章(进行中 / 空闲 / 出错 / 已中断),数据来自 runtime 广播的 `_monkeyai/session/state` 通知,因此由 MonkeyAI 桌面客户端发起的回合同样会显示为「进行中」——打开面板时如果回合已经在跑,Host 会主动读取一次当前状态补齐。回合执行期间(无论哪一端发起)输入框旁会显示停止按钮,点击发送 `session/cancel`;模型切换在会话自己的连接上调用 `_monkeyai/session/model/set`。助手消息支持轻量 Markdown 渲染(代码块、行内代码、粗体、标题、列表)。
51
51
 
52
52
  ## 界面说明
53
53
 
package/client.js CHANGED
@@ -331,6 +331,12 @@ window.__ModuleLoader__.load({
331
331
  failed: "\u5931\u8D25",
332
332
  cancelled: "\u5DF2\u53D6\u6D88"
333
333
  });
334
+ var SESSION_STATUS_LABEL = Object.freeze({
335
+ running: "\u8FDB\u884C\u4E2D",
336
+ idle: "\u7A7A\u95F2",
337
+ error: "\u51FA\u9519",
338
+ interrupted: "\u5DF2\u4E2D\u65AD"
339
+ });
334
340
  function contentText(content) {
335
341
  const blocks = Array.isArray(content) ? content : content === void 0 || content === null ? [] : [content];
336
342
  const parts = [];
@@ -495,6 +501,12 @@ window.__ModuleLoader__.load({
495
501
  continue;
496
502
  }
497
503
  if (frame.type === "notification" && typeof frame.method === "string" && frame.method.startsWith("_monkeyai/")) {
504
+ if (frame.method === "_monkeyai/session/state") {
505
+ const status = frame.data?.state?.status;
506
+ if (typeof status === "string" && SESSION_STATUS_LABEL[status]) {
507
+ entries.push({ kind: "system", label: `\u4F1A\u8BDD\u72B6\u6001\uFF1A${SESSION_STATUS_LABEL[status]}`, key: `sys-state:${entries.length}` });
508
+ }
509
+ }
498
510
  continue;
499
511
  }
500
512
  if (frame.type === "error") {
@@ -808,6 +820,7 @@ window.__ModuleLoader__.load({
808
820
  const [visibleCount, setVisibleCount] = useState(RECENT_LIMIT);
809
821
  const [turnActive, setTurnActive] = useState(false);
810
822
  const [stopping, setStopping] = useState(false);
823
+ const [sessionStatus, setSessionStatus] = useState("");
811
824
  const [signInEmail, setSignInEmail] = useState("");
812
825
  const [signInPassword, setSignInPassword] = useState("");
813
826
  const streamRef = useRef(null);
@@ -1048,6 +1061,7 @@ window.__ModuleLoader__.load({
1048
1061
  setLastDelivery(void 0);
1049
1062
  setTurnActive(false);
1050
1063
  setStopping(false);
1064
+ setSessionStatus("");
1051
1065
  setError("");
1052
1066
  setAnswerText("");
1053
1067
  setCurrentModel("");
@@ -1090,6 +1104,14 @@ window.__ModuleLoader__.load({
1090
1104
  if (frame.type === "request") {
1091
1105
  setPendingRequests((current) => [...current.filter((item) => item.requestId !== frame.requestId), frame]);
1092
1106
  }
1107
+ if (frame.type === "notification" && frame.method === "_monkeyai/session/state") {
1108
+ const status2 = frame.data?.state?.status;
1109
+ if (typeof status2 === "string" && SESSION_STATUS_LABEL[status2]) {
1110
+ setSessionStatus(status2);
1111
+ setTurnActive(status2 === "running");
1112
+ if (status2 !== "running") setStopping(false);
1113
+ }
1114
+ }
1093
1115
  if (frame.type === "delivery") {
1094
1116
  const accepted = Boolean(frame.data?.accepted);
1095
1117
  setLastDelivery({ accepted, code: frame.data?.code, frame: frame.type });
@@ -1120,12 +1142,14 @@ window.__ModuleLoader__.load({
1120
1142
  setHistoryLoading(false);
1121
1143
  setTurnActive(false);
1122
1144
  setStopping(false);
1145
+ setSessionStatus("");
1123
1146
  } catch (reason) {
1124
1147
  setStatus("\u8FDC\u7A0B\u4F1A\u8BDD\u4E0D\u53EF\u7528");
1125
1148
  setError(errorText(reason));
1126
1149
  setHistoryLoading(false);
1127
1150
  setTurnActive(false);
1128
1151
  setStopping(false);
1152
+ setSessionStatus("");
1129
1153
  }
1130
1154
  }
1131
1155
  function sendPrompt() {
@@ -1636,10 +1660,26 @@ window.__ModuleLoader__.load({
1636
1660
  setSessionId("");
1637
1661
  setTurnActive(false);
1638
1662
  setStopping(false);
1663
+ setSessionStatus("");
1639
1664
  setStatus("\u8FDC\u7A0B\u4F1A\u8BDD\u5DF2\u5173\u95ED");
1640
1665
  }
1641
1666
  }, "\u2190"),
1642
1667
  h("strong", { style: { flex: 1, fontSize: 13.5, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, title }, title),
1668
+ sessionStatus ? h(
1669
+ "span",
1670
+ {
1671
+ style: {
1672
+ ...style.statusBadge,
1673
+ background: sessionStatus === "running" ? t.accentSoft : sessionStatus === "error" ? t.dangerBg : t.surfaceAlt,
1674
+ borderColor: sessionStatus === "running" ? t.accent : sessionStatus === "error" ? t.dangerBorder : t.border,
1675
+ color: sessionStatus === "running" ? t.accent : sessionStatus === "error" ? t.danger : t.text2
1676
+ },
1677
+ title: "\u4F1A\u8BDD\u72B6\u6001\uFF08\u542B\u8FDC\u7AEF\u5BA2\u6237\u7AEF\u53D1\u8D77\u7684\u56DE\u5408\uFF09",
1678
+ "aria-label": `\u4F1A\u8BDD\u72B6\u6001\uFF1A${SESSION_STATUS_LABEL[sessionStatus]}`
1679
+ },
1680
+ sessionStatus === "running" ? h("span", { className: "mai-pulse", style: { ...style.dot, background: t.accent } }) : null,
1681
+ SESSION_STATUS_LABEL[sessionStatus]
1682
+ ) : null,
1643
1683
  h(
1644
1684
  "select",
1645
1685
  {
@@ -1823,7 +1863,7 @@ window.__ModuleLoader__.load({
1823
1863
  "div",
1824
1864
  { style: { fontSize: 11.5, color: turnActive ? t.accent : lastDelivery?.accepted ? t.success : lastDelivery?.frame === "pending" ? t.text3 : t.danger, textAlign: "right", display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 6 } },
1825
1865
  turnActive ? h("span", { className: "mai-pulse", style: { ...style.dot, background: t.accent } }) : null,
1826
- turnActive ? stopping ? "\u6B63\u5728\u505C\u6B62\u5F53\u524D\u56DE\u5408\u2026" : "MonkeyAI \u6B63\u5728\u5904\u7406\u2026" : lastDelivery?.accepted ? "\u56DE\u5408\u5DF2\u7ED3\u675F" : lastDelivery?.frame === "pending" ? "\u7B49\u5F85\u6295\u9012" : "\u6295\u9012\u88AB\u62D2\u7EDD"
1866
+ turnActive ? stopping ? "\u6B63\u5728\u505C\u6B62\u5F53\u524D\u56DE\u5408\u2026" : sessionStatus === "running" ? "MonkeyAI \u6B63\u5728\u5904\u7406\uFF08\u8FDC\u7AEF\u5BA2\u6237\u7AEF\u53D1\u8D77\uFF09\u2026" : "MonkeyAI \u6B63\u5728\u5904\u7406\u2026" : lastDelivery?.accepted ? "\u56DE\u5408\u5DF2\u7ED3\u675F" : lastDelivery?.frame === "pending" ? "\u7B49\u5F85\u6295\u9012" : "\u6295\u9012\u88AB\u62D2\u7EDD"
1827
1867
  ) : null
1828
1868
  ) : null
1829
1869
  );
@@ -326,6 +326,12 @@ var TOOL_STATUS_LABEL = Object.freeze({
326
326
  failed: "\u5931\u8D25",
327
327
  cancelled: "\u5DF2\u53D6\u6D88"
328
328
  });
329
+ var SESSION_STATUS_LABEL = Object.freeze({
330
+ running: "\u8FDB\u884C\u4E2D",
331
+ idle: "\u7A7A\u95F2",
332
+ error: "\u51FA\u9519",
333
+ interrupted: "\u5DF2\u4E2D\u65AD"
334
+ });
329
335
  function contentText(content) {
330
336
  const blocks = Array.isArray(content) ? content : content === void 0 || content === null ? [] : [content];
331
337
  const parts = [];
@@ -490,6 +496,12 @@ function projectFrames(frames) {
490
496
  continue;
491
497
  }
492
498
  if (frame.type === "notification" && typeof frame.method === "string" && frame.method.startsWith("_monkeyai/")) {
499
+ if (frame.method === "_monkeyai/session/state") {
500
+ const status = frame.data?.state?.status;
501
+ if (typeof status === "string" && SESSION_STATUS_LABEL[status]) {
502
+ entries.push({ kind: "system", label: `\u4F1A\u8BDD\u72B6\u6001\uFF1A${SESSION_STATUS_LABEL[status]}`, key: `sys-state:${entries.length}` });
503
+ }
504
+ }
493
505
  continue;
494
506
  }
495
507
  if (frame.type === "error") {
@@ -803,6 +815,7 @@ function RemoteSessionPage({ api }) {
803
815
  const [visibleCount, setVisibleCount] = useState(RECENT_LIMIT);
804
816
  const [turnActive, setTurnActive] = useState(false);
805
817
  const [stopping, setStopping] = useState(false);
818
+ const [sessionStatus, setSessionStatus] = useState("");
806
819
  const [signInEmail, setSignInEmail] = useState("");
807
820
  const [signInPassword, setSignInPassword] = useState("");
808
821
  const streamRef = useRef(null);
@@ -1043,6 +1056,7 @@ function RemoteSessionPage({ api }) {
1043
1056
  setLastDelivery(void 0);
1044
1057
  setTurnActive(false);
1045
1058
  setStopping(false);
1059
+ setSessionStatus("");
1046
1060
  setError("");
1047
1061
  setAnswerText("");
1048
1062
  setCurrentModel("");
@@ -1085,6 +1099,14 @@ function RemoteSessionPage({ api }) {
1085
1099
  if (frame.type === "request") {
1086
1100
  setPendingRequests((current) => [...current.filter((item) => item.requestId !== frame.requestId), frame]);
1087
1101
  }
1102
+ if (frame.type === "notification" && frame.method === "_monkeyai/session/state") {
1103
+ const status2 = frame.data?.state?.status;
1104
+ if (typeof status2 === "string" && SESSION_STATUS_LABEL[status2]) {
1105
+ setSessionStatus(status2);
1106
+ setTurnActive(status2 === "running");
1107
+ if (status2 !== "running") setStopping(false);
1108
+ }
1109
+ }
1088
1110
  if (frame.type === "delivery") {
1089
1111
  const accepted = Boolean(frame.data?.accepted);
1090
1112
  setLastDelivery({ accepted, code: frame.data?.code, frame: frame.type });
@@ -1115,12 +1137,14 @@ function RemoteSessionPage({ api }) {
1115
1137
  setHistoryLoading(false);
1116
1138
  setTurnActive(false);
1117
1139
  setStopping(false);
1140
+ setSessionStatus("");
1118
1141
  } catch (reason) {
1119
1142
  setStatus("\u8FDC\u7A0B\u4F1A\u8BDD\u4E0D\u53EF\u7528");
1120
1143
  setError(errorText(reason));
1121
1144
  setHistoryLoading(false);
1122
1145
  setTurnActive(false);
1123
1146
  setStopping(false);
1147
+ setSessionStatus("");
1124
1148
  }
1125
1149
  }
1126
1150
  function sendPrompt() {
@@ -1631,10 +1655,26 @@ function RemoteSessionPage({ api }) {
1631
1655
  setSessionId("");
1632
1656
  setTurnActive(false);
1633
1657
  setStopping(false);
1658
+ setSessionStatus("");
1634
1659
  setStatus("\u8FDC\u7A0B\u4F1A\u8BDD\u5DF2\u5173\u95ED");
1635
1660
  }
1636
1661
  }, "\u2190"),
1637
1662
  h("strong", { style: { flex: 1, fontSize: 13.5, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, title }, title),
1663
+ sessionStatus ? h(
1664
+ "span",
1665
+ {
1666
+ style: {
1667
+ ...style.statusBadge,
1668
+ background: sessionStatus === "running" ? t.accentSoft : sessionStatus === "error" ? t.dangerBg : t.surfaceAlt,
1669
+ borderColor: sessionStatus === "running" ? t.accent : sessionStatus === "error" ? t.dangerBorder : t.border,
1670
+ color: sessionStatus === "running" ? t.accent : sessionStatus === "error" ? t.danger : t.text2
1671
+ },
1672
+ title: "\u4F1A\u8BDD\u72B6\u6001\uFF08\u542B\u8FDC\u7AEF\u5BA2\u6237\u7AEF\u53D1\u8D77\u7684\u56DE\u5408\uFF09",
1673
+ "aria-label": `\u4F1A\u8BDD\u72B6\u6001\uFF1A${SESSION_STATUS_LABEL[sessionStatus]}`
1674
+ },
1675
+ sessionStatus === "running" ? h("span", { className: "mai-pulse", style: { ...style.dot, background: t.accent } }) : null,
1676
+ SESSION_STATUS_LABEL[sessionStatus]
1677
+ ) : null,
1638
1678
  h(
1639
1679
  "select",
1640
1680
  {
@@ -1818,7 +1858,7 @@ function RemoteSessionPage({ api }) {
1818
1858
  "div",
1819
1859
  { style: { fontSize: 11.5, color: turnActive ? t.accent : lastDelivery?.accepted ? t.success : lastDelivery?.frame === "pending" ? t.text3 : t.danger, textAlign: "right", display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 6 } },
1820
1860
  turnActive ? h("span", { className: "mai-pulse", style: { ...style.dot, background: t.accent } }) : null,
1821
- turnActive ? stopping ? "\u6B63\u5728\u505C\u6B62\u5F53\u524D\u56DE\u5408\u2026" : "MonkeyAI \u6B63\u5728\u5904\u7406\u2026" : lastDelivery?.accepted ? "\u56DE\u5408\u5DF2\u7ED3\u675F" : lastDelivery?.frame === "pending" ? "\u7B49\u5F85\u6295\u9012" : "\u6295\u9012\u88AB\u62D2\u7EDD"
1861
+ turnActive ? stopping ? "\u6B63\u5728\u505C\u6B62\u5F53\u524D\u56DE\u5408\u2026" : sessionStatus === "running" ? "MonkeyAI \u6B63\u5728\u5904\u7406\uFF08\u8FDC\u7AEF\u5BA2\u6237\u7AEF\u53D1\u8D77\uFF09\u2026" : "MonkeyAI \u6B63\u5728\u5904\u7406\u2026" : lastDelivery?.accepted ? "\u56DE\u5408\u5DF2\u7ED3\u675F" : lastDelivery?.frame === "pending" ? "\u7B49\u5F85\u6295\u9012" : "\u6295\u9012\u88AB\u62D2\u7EDD"
1822
1862
  ) : null
1823
1863
  ) : null
1824
1864
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-monkeyai-sessions",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "在 DeepSeek Harness 中读取和继续 MonkeyAI 会话:查看完整对话历史、下发新指令,无缝接续之前在 MonkeyAI 客户端上的工作",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  import React from 'react'
2
2
  import { TYPERT_REMOTE } from '../remote.js'
3
- import { projectFrames, TOOL_STATUS_LABEL } from '../shared/projection.mjs'
3
+ import { projectFrames, TOOL_STATUS_LABEL, SESSION_STATUS_LABEL } from '../shared/projection.mjs'
4
4
 
5
5
  const PANEL_ID = 'monkeyai-sessions'
6
6
  const MANUAL_DEVICES_KEY = 'monkeyai-sessions/manual-devices'
@@ -290,6 +290,7 @@ function RemoteSessionPage({ api }) {
290
290
  const [visibleCount, setVisibleCount] = useState(RECENT_LIMIT)
291
291
  const [turnActive, setTurnActive] = useState(false)
292
292
  const [stopping, setStopping] = useState(false)
293
+ const [sessionStatus, setSessionStatus] = useState('')
293
294
  const [signInEmail, setSignInEmail] = useState('')
294
295
  const [signInPassword, setSignInPassword] = useState('')
295
296
  const streamRef = useRef(null)
@@ -540,6 +541,7 @@ function RemoteSessionPage({ api }) {
540
541
  setLastDelivery(undefined)
541
542
  setTurnActive(false)
542
543
  setStopping(false)
544
+ setSessionStatus('')
543
545
  setError('')
544
546
  setAnswerText('')
545
547
  setCurrentModel('')
@@ -582,6 +584,14 @@ function RemoteSessionPage({ api }) {
582
584
  if (frame.type === 'request') {
583
585
  setPendingRequests(current => [...current.filter(item => item.requestId !== frame.requestId), frame])
584
586
  }
587
+ if (frame.type === 'notification' && frame.method === '_monkeyai/session/state') {
588
+ const status = frame.data?.state?.status
589
+ if (typeof status === 'string' && SESSION_STATUS_LABEL[status]) {
590
+ setSessionStatus(status)
591
+ setTurnActive(status === 'running')
592
+ if (status !== 'running') setStopping(false)
593
+ }
594
+ }
585
595
  if (frame.type === 'delivery') {
586
596
  const accepted = Boolean(frame.data?.accepted)
587
597
  setLastDelivery({ accepted, code: frame.data?.code, frame: frame.type })
@@ -612,12 +622,14 @@ function RemoteSessionPage({ api }) {
612
622
  setHistoryLoading(false)
613
623
  setTurnActive(false)
614
624
  setStopping(false)
625
+ setSessionStatus('')
615
626
  } catch (reason) {
616
627
  setStatus('远程会话不可用')
617
628
  setError(errorText(reason))
618
629
  setHistoryLoading(false)
619
630
  setTurnActive(false)
620
631
  setStopping(false)
632
+ setSessionStatus('')
621
633
  }
622
634
  }
623
635
 
@@ -1014,9 +1026,21 @@ function RemoteSessionPage({ api }) {
1014
1026
  h('div', { style: style.row },
1015
1027
  h('button', {
1016
1028
  type: 'button', style: { ...style.button, flexShrink: 0 }, title: '关闭会话并返回列表',
1017
- onClick: () => { streamRef.current?.dispose(); streamRef.current = null; setSessionId(''); setTurnActive(false); setStopping(false); setStatus('远程会话已关闭') },
1029
+ onClick: () => { streamRef.current?.dispose(); streamRef.current = null; setSessionId(''); setTurnActive(false); setStopping(false); setSessionStatus(''); setStatus('远程会话已关闭') },
1018
1030
  }, '←'),
1019
1031
  h('strong', { style: { flex: 1, fontSize: 13.5, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title }, title),
1032
+ sessionStatus ? h('span', {
1033
+ style: {
1034
+ ...style.statusBadge,
1035
+ background: sessionStatus === 'running' ? t.accentSoft : sessionStatus === 'error' ? t.dangerBg : t.surfaceAlt,
1036
+ borderColor: sessionStatus === 'running' ? t.accent : sessionStatus === 'error' ? t.dangerBorder : t.border,
1037
+ color: sessionStatus === 'running' ? t.accent : sessionStatus === 'error' ? t.danger : t.text2,
1038
+ },
1039
+ title: '会话状态(含远端客户端发起的回合)',
1040
+ 'aria-label': `会话状态:${SESSION_STATUS_LABEL[sessionStatus]}`,
1041
+ },
1042
+ sessionStatus === 'running' ? h('span', { className: 'mai-pulse', style: { ...style.dot, background: t.accent } }) : null,
1043
+ SESSION_STATUS_LABEL[sessionStatus]) : null,
1020
1044
  h('select', {
1021
1045
  style: { ...style.input, flex: '0 1 190px', minWidth: 130 },
1022
1046
  value: currentModel,
@@ -1134,7 +1158,7 @@ function RemoteSessionPage({ api }) {
1134
1158
  h('path', { d: 'M8 12.5V3.5M8 3.5L4 7.5M8 3.5L12 7.5', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round' })))),
1135
1159
  lastDelivery || turnActive ? h('div', { style: { fontSize: 11.5, color: turnActive ? t.accent : lastDelivery?.accepted ? t.success : lastDelivery?.frame === 'pending' ? t.text3 : t.danger, textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 6 } },
1136
1160
  turnActive ? h('span', { className: 'mai-pulse', style: { ...style.dot, background: t.accent } }) : null,
1137
- turnActive ? (stopping ? '正在停止当前回合…' : 'MonkeyAI 正在处理…') : lastDelivery?.accepted ? '回合已结束' : lastDelivery?.frame === 'pending' ? '等待投递' : '投递被拒绝') : null)
1161
+ turnActive ? (stopping ? '正在停止当前回合…' : sessionStatus === 'running' ? 'MonkeyAI 正在处理(远端客户端发起)…' : 'MonkeyAI 正在处理…') : lastDelivery?.accepted ? '回合已结束' : lastDelivery?.frame === 'pending' ? '等待投递' : '投递被拒绝') : null)
1138
1162
  : null)
1139
1163
  }
1140
1164
 
@@ -689,6 +689,7 @@ export class MonkeyAIRemoteService extends TypertRemoteService {
689
689
  let reconnectsUsed = 0
690
690
  const MAX_REPLAY_RECONNECTS = 32
691
691
  let ticketValue
692
+ let ticketHttpBase
692
693
  let loadCompleted = false
693
694
  let currentLoadedModel = undefined
694
695
  const aborted = new Promise(resolve => {
@@ -726,10 +727,12 @@ export class MonkeyAIRemoteService extends TypertRemoteService {
726
727
  // for close detection but suppressed from the event stream to avoid
727
728
  // duplicating text the projection already folded in.
728
729
  const httpHistoryMessageIds = new Set()
729
- // When the HTTP channel delivered the full history, the ACP replay adds
730
- // nothing: skip session/load entirely and mount the session live.
730
+ // The full history was loaded over HTTP. Keep the message ids so ACP
731
+ // replay duplicates can be suppressed and history_changed notifications
732
+ // can fetch only newly persisted snapshots.
731
733
  let httpHistoryComplete = false
732
734
  let httpConfigurationRevision
735
+ let historyRefreshPromise = Promise.resolve()
733
736
  // history_loaded can be triggered by both the HTTP history channel and
734
737
  // the ACP load path; the stream must carry it exactly once.
735
738
  let historyLoadedEmitted = false
@@ -738,6 +741,26 @@ export class MonkeyAIRemoteService extends TypertRemoteService {
738
741
  historyLoadedEmitted = true
739
742
  emit({ type: 'history_loaded', sessionId: selectedSession, ...(model === undefined ? {} : { model }) })
740
743
  }
744
+ const refreshPersistedHistory = async changedMessageId => {
745
+ if (!ticketHttpBase || !ticketValue) return
746
+ const delta = await fetchSessionHistoryDelta({
747
+ httpBase: ticketHttpBase,
748
+ ticket: ticketValue,
749
+ sessionId: selectedSession,
750
+ signal,
751
+ knownMessageIds: httpHistoryMessageIds,
752
+ changedMessageId,
753
+ })
754
+ if (Number.isSafeInteger(delta.configurationRevision)) {
755
+ httpConfigurationRevision = delta.configurationRevision
756
+ }
757
+ for (const message of delta.messages) {
758
+ httpHistoryMessageIds.add(message.id)
759
+ for (const frame of historyMessageToFrames(selectedSession, message, delta.toolCalls, { snapshot: true })) {
760
+ await emit(frame)
761
+ }
762
+ }
763
+ }
741
764
 
742
765
  function replayKey(params) {
743
766
  const update = params?.update
@@ -784,6 +807,22 @@ export class MonkeyAIRemoteService extends TypertRemoteService {
784
807
  data: event.params,
785
808
  })
786
809
  } else {
810
+ if (event.method === '_monkeyai/session/history_changed' && httpHistoryComplete && ticketHttpBase) {
811
+ // Large live message bodies are persisted instead of streamed as
812
+ // chunks. Match the desktop client: refresh the newest HTTP pages
813
+ // and merge the resulting message snapshots into the projection.
814
+ historyRefreshPromise = historyRefreshPromise.then(() => refreshPersistedHistory(
815
+ typeof event.params?.messageId === 'string' ? event.params.messageId : undefined,
816
+ )).catch(error => {
817
+ if (!signal.aborted) emit({
818
+ type: 'error',
819
+ sessionId: selectedSession,
820
+ code: 'history_refresh_failed',
821
+ message: error instanceof Error ? error.message : '刷新实时会话内容失败',
822
+ })
823
+ })
824
+ return
825
+ }
787
826
  if (withLoad && !loadCompleted) {
788
827
  const key = replayKey(event.params)
789
828
  if (key !== null) {
@@ -795,7 +834,7 @@ export class MonkeyAIRemoteService extends TypertRemoteService {
795
834
  }
796
835
  // Suppress replayed messages already delivered over HTTP, on every
797
836
  // connection generation (re-mounts replay the same window again).
798
- if (withLoad) {
837
+ if (withLoad && event.params?._meta?.replay === true) {
799
838
  const replayMessageId = (typeof event.params?._meta?.messageId === 'string' && event.params._meta.messageId !== '')
800
839
  ? event.params._meta.messageId
801
840
  : (typeof event.params?.update?.messageId === 'string' ? event.params.update.messageId : '')
@@ -887,7 +926,22 @@ export class MonkeyAIRemoteService extends TypertRemoteService {
887
926
  const initialized = await acp.request('initialize', {
888
927
  protocolVersion: 1,
889
928
  clientInfo: ACP_CLIENT_INFO,
890
- clientCapabilities: {},
929
+ // Match the MonkeyAI desktop client. historyHTTP opts this connection
930
+ // into HTTP-backed snapshots and history_changed notifications;
931
+ // session/load then mounts the live subscription without replaying the
932
+ // oversized history over ACP.
933
+ clientCapabilities: {
934
+ _meta: {
935
+ messageSnapshots: true,
936
+ interactionReplies: true,
937
+ historyHTTP: true,
938
+ queueHTTP: true,
939
+ historyRefresh: true,
940
+ configHTTP: true,
941
+ stateHTTP: true,
942
+ interactionHTTP: true,
943
+ },
944
+ },
891
945
  }, { signal })
892
946
  if (initialized?.agentCapabilities?.loadSession !== true) {
893
947
  throw new Error('MonkeyAI runtime does not advertise ACP session history loading')
@@ -898,14 +952,11 @@ export class MonkeyAIRemoteService extends TypertRemoteService {
898
952
  return
899
953
  }
900
954
  emit({ type: 'ready', sessionId: selectedSession })
901
- if (httpHistoryComplete) {
902
- // History came over HTTP; prompts and model switches work on a plain
903
- // initialized connection. Skip session/load: its bounded replay
904
- // always dies mid-stream and would churn reconnects for nothing.
905
- loadCompleted = true
906
- return
907
- }
908
955
  try {
956
+ // With historyHTTP advertised this mounts the session for live
957
+ // notifications while leaving persisted history reads to the HTTP
958
+ // cursor API above. Older runtimes may still replay a bounded window;
959
+ // the message-id filter suppresses those duplicate snapshots.
909
960
  const loadResult = await acp.request('session/load', {
910
961
  sessionId: selectedSession,
911
962
  cwd,
@@ -914,6 +965,31 @@ export class MonkeyAIRemoteService extends TypertRemoteService {
914
965
  loadCompleted = true
915
966
  currentLoadedModel = typeof loadResult?._meta?.model === 'string' ? loadResult._meta.model : currentLoadedModel
916
967
  emitHistoryLoaded(currentLoadedModel)
968
+ // Close the gap between the initial full-history read and mounting the
969
+ // live subscription. A turn can start during that interval; snapshots
970
+ // replace any partial chunks that arrived before session/load resolved.
971
+ if (httpHistoryComplete) {
972
+ historyRefreshPromise = historyRefreshPromise.then(() => refreshPersistedHistory())
973
+ await historyRefreshPromise
974
+ // The desktop client tracks turn state via _monkeyai/session/state
975
+ // notifications. Seed the panel with the current status so a turn
976
+ // already running when the panel opens is shown as 进行中.
977
+ try {
978
+ const state = await readSessionState({ httpBase: ticketHttpBase, ticket: ticketValue, sessionId: selectedSession, signal })
979
+ const status = state?.state?.status
980
+ if (typeof status === 'string' && ['running', 'idle', 'error', 'interrupted'].includes(status)) {
981
+ emit({
982
+ type: 'notification',
983
+ sessionId: selectedSession,
984
+ method: '_monkeyai/session/state',
985
+ data: { sessionId: selectedSession, state: { status }, eventSeq: state.eventSeq },
986
+ })
987
+ }
988
+ } catch {
989
+ // Older runtimes may not expose the state read; live notifications
990
+ // still drive the status once the next turn starts.
991
+ }
992
+ }
917
993
  } catch (error) {
918
994
  if (signal?.aborted) throw error
919
995
  const message = error instanceof Error ? error.message : String(error)
@@ -958,6 +1034,7 @@ export class MonkeyAIRemoteService extends TypertRemoteService {
958
1034
  const token = await this.refreshAccessToken(accountId, signal)
959
1035
  const ticket = await this.requestConnectionTicket(accountId, grantRef, token, target, { signal })
960
1036
  ticketValue = ticket.ticket
1037
+ ticketHttpBase = ticket.httpBase
961
1038
  if (signal.aborted) throw signal.reason ?? new Error('远程会话已取消')
962
1039
 
963
1040
  // Load the full history over the runtime's HTTP read channel before the
@@ -1302,6 +1379,34 @@ async function readSessionHistoryPage({ httpBase, ticket, sessionId, cursor, dir
1302
1379
  return result
1303
1380
  }
1304
1381
 
1382
+ async function readSessionState({ httpBase, ticket, sessionId, signal }) {
1383
+ const timeout = AbortSignal.any([AbortSignal.timeout(15_000), ...(signal ? [signal] : [])])
1384
+ const response = await fetch(`${httpBase.endsWith('/') ? httpBase : `${httpBase}/`}read/session`, {
1385
+ method: 'POST',
1386
+ headers: { authorization: `Bearer ${ticket}`, 'content-type': 'application/json' },
1387
+ body: JSON.stringify({ method: '_monkeyai/session/state/get', params: { sessionId } }),
1388
+ signal: timeout,
1389
+ })
1390
+ const text = await readHistoryBodyText(response).catch(error => {
1391
+ if (response.ok) throw error
1392
+ return ''
1393
+ })
1394
+ if (!response.ok) {
1395
+ throw new Error(`MonkeyAI state read failed (${response.status}): ${text.slice(0, 200)}`)
1396
+ }
1397
+ let data
1398
+ try {
1399
+ data = JSON.parse(text)
1400
+ } catch {
1401
+ throw new Error('MonkeyAI state read returned invalid JSON')
1402
+ }
1403
+ const result = data?.result
1404
+ if (!result || typeof result !== 'object' || !result.state || typeof result.state !== 'object') {
1405
+ throw new Error('MonkeyAI state read returned an invalid response')
1406
+ }
1407
+ return result
1408
+ }
1409
+
1305
1410
  function historyMessageText(parts) {
1306
1411
  return (Array.isArray(parts) ? parts : [])
1307
1412
  .filter(part => part?.type === 'text' && typeof part.text === 'string')
@@ -1312,7 +1417,7 @@ function historyMessageText(parts) {
1312
1417
  // Converts one history message snapshot into the same notification frame
1313
1418
  // shape the ACP replay produces, so the shared projection layer renders both
1314
1419
  // identically. Message ids match ACP replay messageIds (u-turn-N / a-turn-N).
1315
- function historyMessageToFrames(sessionId, message, toolCallsById) {
1420
+ function historyMessageToFrames(sessionId, message, toolCallsById, { snapshot = false } = {}) {
1316
1421
  const frames = []
1317
1422
  const meta = { messageId: message.id, replay: true }
1318
1423
  const emitUpdate = update => {
@@ -1326,16 +1431,47 @@ function historyMessageToFrames(sessionId, message, toolCallsById) {
1326
1431
  const content = fileLinks.length > 0
1327
1432
  ? [...(text === '' ? [] : [{ type: 'text', text }]), ...fileLinks]
1328
1433
  : (text === '' ? undefined : { type: 'text', text })
1329
- if (content !== undefined) emitUpdate({ sessionUpdate: 'user_message_chunk', content })
1434
+ if (content !== undefined) emitUpdate({ sessionUpdate: snapshot ? 'user_message' : 'user_message_chunk', content })
1330
1435
  return frames
1331
1436
  }
1332
- for (const part of Array.isArray(message.parts) ? message.parts : []) {
1437
+ const parts = Array.isArray(message.parts) ? message.parts : []
1438
+ const snapshotThought = snapshot
1439
+ ? parts.filter(part => part?.type === 'thought' && typeof part.text === 'string').map(part => part.text).join('')
1440
+ : ''
1441
+ const snapshotText = snapshot
1442
+ ? parts.filter(part => part?.type === 'text' && typeof part.text === 'string').map(part => part.text)
1443
+ .concat(parts.filter(part => part?.type === 'error' && typeof part.reason === 'string').map(part => `[error] ${part.reason}`)).join('')
1444
+ : ''
1445
+ let snapshotThoughtEmitted = false
1446
+ let snapshotTextEmitted = false
1447
+ for (const part of parts) {
1333
1448
  if (part?.type === 'thought' && typeof part.text === 'string' && part.text !== '') {
1334
- emitUpdate({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: part.text } })
1449
+ if (snapshot) {
1450
+ if (!snapshotThoughtEmitted && snapshotThought !== '') {
1451
+ emitUpdate({ sessionUpdate: 'agent_thought', content: { type: 'text', text: snapshotThought } })
1452
+ snapshotThoughtEmitted = true
1453
+ }
1454
+ } else {
1455
+ emitUpdate({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: part.text } })
1456
+ }
1335
1457
  } else if (part?.type === 'text' && typeof part.text === 'string' && part.text !== '') {
1336
- emitUpdate({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: part.text } })
1458
+ if (snapshot) {
1459
+ if (!snapshotTextEmitted && snapshotText !== '') {
1460
+ emitUpdate({ sessionUpdate: 'agent_message', content: { type: 'text', text: snapshotText } })
1461
+ snapshotTextEmitted = true
1462
+ }
1463
+ } else {
1464
+ emitUpdate({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: part.text } })
1465
+ }
1337
1466
  } else if (part?.type === 'error' && typeof part.reason === 'string' && part.reason !== '') {
1338
- emitUpdate({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: `[error] ${part.reason}` } })
1467
+ if (snapshot) {
1468
+ if (!snapshotTextEmitted && snapshotText !== '') {
1469
+ emitUpdate({ sessionUpdate: 'agent_message', content: { type: 'text', text: snapshotText } })
1470
+ snapshotTextEmitted = true
1471
+ }
1472
+ } else {
1473
+ emitUpdate({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: `[error] ${part.reason}` } })
1474
+ }
1339
1475
  } else if (part?.type === 'file' && typeof part.uri === 'string') {
1340
1476
  emitUpdate({ sessionUpdate: 'agent_message_chunk', content: { type: 'resource_link', name: part.name, uri: part.uri } })
1341
1477
  } else if (part?.type === 'tool_call' && typeof part.toolCallId === 'string') {
@@ -1389,6 +1525,47 @@ async function fetchFullSessionHistory({ httpBase, ticket, sessionId, signal, on
1389
1525
  return { messageIds: [...seenIds], count: seenIds.size, configurationRevision }
1390
1526
  }
1391
1527
 
1528
+ // Refresh only the newest pages after a _monkeyai/session/history_changed
1529
+ // notification. Pages are oldest-to-newest internally and arrive newest-first;
1530
+ // stop once a known boundary is reached, retaining a changed message id even
1531
+ // when it was already present (the runtime may have replaced a partial body).
1532
+ async function fetchSessionHistoryDelta({ httpBase, ticket, sessionId, signal, knownMessageIds, changedMessageId, maxPages = 200 }) {
1533
+ const pages = []
1534
+ const seenIds = new Set()
1535
+ const toolCalls = {}
1536
+ let cursor
1537
+ let configurationRevision
1538
+ let foundBoundary = false
1539
+ let foundChangedMessage = !changedMessageId
1540
+ for (let page = 0; page < maxPages; page += 1) {
1541
+ const timeout = AbortSignal.any([AbortSignal.timeout(30_000), ...(signal ? [signal] : [])])
1542
+ const result = await readSessionHistoryPage({ httpBase, ticket, sessionId, cursor, direction: 'older', signal: timeout })
1543
+ if (Number.isSafeInteger(result.session?.configurationRevision)) configurationRevision = result.session.configurationRevision
1544
+ const pageMessages = []
1545
+ for (const message of result.messages) {
1546
+ if (typeof message?.id !== 'string' || message.id === '') continue
1547
+ const known = knownMessageIds.has(message.id)
1548
+ const changed = message.id === changedMessageId
1549
+ if (changed) foundChangedMessage = true
1550
+ if ((!known || changed) && !seenIds.has(message.id)) {
1551
+ seenIds.add(message.id)
1552
+ pageMessages.push(message)
1553
+ }
1554
+ if (known && !changed) foundBoundary = true
1555
+ }
1556
+ pages.push(pageMessages)
1557
+ Object.assign(toolCalls, result.toolCalls ?? {})
1558
+ if (foundBoundary && foundChangedMessage) break
1559
+ cursor = typeof result.olderCursor === 'string' && result.olderCursor !== '' ? result.olderCursor : undefined
1560
+ if (!cursor || result.messages.length === 0) break
1561
+ }
1562
+ return {
1563
+ messages: pages.reverse().flat(),
1564
+ toolCalls,
1565
+ configurationRevision,
1566
+ }
1567
+ }
1568
+
1392
1569
  function validateResponse(method, params, result) {
1393
1570
  if (!isObject(result)) throw new TypeError('ACP response must be a JSON object')
1394
1571
  if (method === 'session/request_permission') {
@@ -14,6 +14,10 @@ export const TOOL_STATUS_LABEL = Object.freeze({
14
14
  pending: '排队中', in_progress: '执行中', completed: '完成', failed: '失败', cancelled: '已取消',
15
15
  })
16
16
 
17
+ export const SESSION_STATUS_LABEL = Object.freeze({
18
+ running: '进行中', idle: '空闲', error: '出错', interrupted: '已中断',
19
+ })
20
+
17
21
  function contentText(content) {
18
22
  const blocks = Array.isArray(content) ? content : content === undefined || content === null ? [] : [content]
19
23
  const parts = []
@@ -139,7 +143,15 @@ function projectFrames(frames) {
139
143
  if (frame.type === 'request') { continue } // rendered as an approval card from pendingRequests
140
144
  if (frame.type === 'model_switched') { entries.push({ kind: 'system', label: `已切换模型:${frame.data?.model ?? '所选模型'}`, key: `sys-model:${entries.length}` }); continue }
141
145
  if (frame.type === 'model_switch_failed') { continue } // surfaced via the error banner
142
- if (frame.type === 'notification' && typeof frame.method === 'string' && frame.method.startsWith('_monkeyai/')) { continue } // internal runtime state notifications
146
+ if (frame.type === 'notification' && typeof frame.method === 'string' && frame.method.startsWith('_monkeyai/')) {
147
+ if (frame.method === '_monkeyai/session/state') {
148
+ const status = frame.data?.state?.status
149
+ if (typeof status === 'string' && SESSION_STATUS_LABEL[status]) {
150
+ entries.push({ kind: 'system', label: `会话状态:${SESSION_STATUS_LABEL[status]}`, key: `sys-state:${entries.length}` })
151
+ }
152
+ }
153
+ continue // internal runtime state notifications
154
+ }
143
155
  if (frame.type === 'error') { entries.push({ kind: 'raw', label: frame.code || 'error', data: { message: frame.message || frame.code }, key: `sys-err:${entries.length}` }); continue }
144
156
  entries.push({ kind: 'raw', label: frame.method || frame.type, data: frame.data, key: `raw:${frame.type}:${entries.length}` })
145
157
  }