@dsh-fly/dsh-plugin-fly-engagement 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,13 +4,22 @@ DSH Fly 的 **engagement 记录面**(Security / CTF 作战室的"把发现沉
4
4
 
5
5
  - **agent 平面**(`tools/index.js`,由 Fly preset 按会话挂载):`eng_record`(goal/facts/findings/assets/edges,
6
6
  漏洞**必须带复现步骤**)、`eng_state`(投影 + `counts`/`dropped`/`refused`/`truncated`)、
7
- `eng_report`(全量 Markdown 报告)。
8
- - **宿主平面**(根 `index.js`,`inject: ['webServer','shell']`):`GET /fly/engagement/state|report`,
9
- 给抽屉面板取数;两半共用 `lib/fold.mjs` 的投影口径 ⇒ 面板读数与 `eng_state` 永远同口径。
7
+ `eng_report`(Markdown 报告)、`eng_adopt`(认领 v1 未归属记录)。
8
+ - **宿主平面**(根 `index.js`,`inject: ['webServer','shell']`):`GET /fly/engagement/state|report`、
9
+ `POST /fly/engagement/adopt`,给抽屉面板取数与认领;两半共用 `lib/fold.mjs` 的投影口径
10
+ ⇒ 面板读数与 `eng_state` 永远同口径。
10
11
 
11
12
  存储:`<workspace>/.fly-engagement/events.jsonl`(append-only,投影 = fold)。
12
13
  **不写任何自定义会话事件**(DSH 毒药条);文件读写全走 `ctx.shell` 接缝。
13
14
 
15
+ ## v2:会话维度
16
+
17
+ 一个工作区一条账本,**每行带 `session` 归属**——同一工作区开多个会话各自一条证据链
18
+ (同 workspace 分析不同程序、同 repo 分析不同模块都不会混)。默认只看本会话,
19
+ `scope=workspace` 看全部(`workspaceCounts` 始终给全量计数);goal 按会话独立。
20
+ 边(edges)**跟随端点**:两端在作用域内即可见。v1 遗留记录(无 session)计入 `legacy`,
21
+ 经 `eng_adopt` 认领进当前会话(已归属他人的一律跳过,`skipped` 计数)。
22
+
14
23
  借鉴 `@howmp/dsh-pentest`(MIT)的数据形状(六表 + 带词汇的边 + 必填步骤 + 双计数 + 确定性 id),
15
24
  承载面用 DSH Fly 自己的抽屉面板。设计与验收见 dsh-fly 仓 `docs/security-mode-review-and-plan.md` §11。
16
25
 
@@ -19,7 +28,7 @@ DSH Fly 的 **engagement 记录面**(Security / CTF 作战室的"把发现沉
19
28
  | 挂载点 | 文件 | 干什么 |
20
29
  | --- | --- | --- |
21
30
  | 宿主行(bundle patch,**裸包名**) | `index.js` | 端点 + 客户端半边(客户端半边要求行名是裸包名,带 `/` 不会被登记) |
22
- | agent 行(preset 指向子路径) | `tools/index.js` | 三个 `eng_*` 工具 |
31
+ | agent 行(preset 指向子路径) | `tools/index.js` | 四个 `eng_*` 工具 |
23
32
 
24
33
  ## 门禁
25
34
 
package/index.js CHANGED
@@ -1,15 +1,16 @@
1
- import { foldEvents, ledgerPath, renderReport, windowView } from './lib/fold.mjs'
1
+ import { foldEvents, ledgerPath, renderReport, scopeView } from './lib/fold.mjs'
2
2
 
3
3
  /**
4
4
  * fly-engagement —— **宿主平面的面板行**(根文件 = 裸包名,客户端半边才登记得上)。
5
5
  *
6
6
  * 形状照 `dsh-plugin-fly-smart`(一个包、两种挂载):
7
- * · 根 `index.js`(本文件)= 宿主行:`inject: ['webServer', 'shell']`,只做
8
- * `GET /fly/engagement/state|report`;**不注册工具**(bundle patch 里没有 `inject:['tools']` 的行);
9
- * · `tools/index.js` = agent 平面的三个 `eng_*`,由 preset 按会话挂;
7
+ * · 根 `index.js`(本文件)= 宿主行:`inject: ['webServer', 'shell']`,做
8
+ * `GET /fly/engagement/state|report` 与 `POST /fly/engagement/adopt`;
9
+ * · `tools/index.js` = agent 平面的 `eng_*`,由 preset 按会话挂;
10
10
  * · 两半共用 `lib/fold.mjs` 的投影口径 ⇒ 面板读数与 `eng_state` 永远同口径。
11
11
  *
12
- * 数据来源:`<workspace>/.fly-engagement/events.jsonl`(append-only,投影=fold),
12
+ * 数据来源:`<workspace>/.fly-engagement/events.jsonl`(append-only,投影=fold)。
13
+ * 一个工作区一条账本,**每行带 session 归属**;默认只看本会话,`scope=workspace` 看全部。
13
14
  * 读文件走沙箱化的 `ctx.shell` 接缝(本包不 import `node:fs`)。
14
15
  */
15
16
 
@@ -33,6 +34,18 @@ async function readText(shell, file) {
33
34
  return result.stdout.text
34
35
  }
35
36
 
37
+ async function appendText(shell, file, text) {
38
+ const encoded = Buffer.from(text, 'utf8').toString('base64')
39
+ const handle = await shell.execute(shell.resolve({ command: 'printf %s ' + shq(encoded) + ' | base64 -d >> ' + shq(file) }))
40
+ const result = await handle.result()
41
+ if (result.sandbox?.denied) {
42
+ const error = new Error(`[sandbox: file access denied under ${result.sandbox.mode} mode]`)
43
+ error.name = 'SandboxError'
44
+ throw error
45
+ }
46
+ return result
47
+ }
48
+
36
49
  function json(res, status, payload) {
37
50
  const body = JSON.stringify(payload, null, 2)
38
51
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(body) })
@@ -41,12 +54,19 @@ function json(res, status, payload) {
41
54
 
42
55
  /** 每条路由都自述(面板的"这个面板能问什么"表,与 netops 同一条纪律)。 */
43
56
  export const endpoints = [
44
- { path: `${BASE}/state`, summary: 'engagement 投影(与 eng_state 同口径):goal/节点/边/counts/dropped/refused/truncated' },
45
- { path: `${BASE}/report`, summary: '全量 Markdown 报告(与 eng_report 同一份渲染)' },
57
+ { path: `${BASE}/state`, summary: 'engagement 投影(与 eng_state 同口径):?workspace=&session=&scope=session|workspace&window=' },
58
+ { path: `${BASE}/report`, summary: 'Markdown 报告(与 eng_report 同一份渲染):?workspace=&session=&scope=' },
59
+ { path: `${BASE}/adopt`, method: 'POST', summary: '认领未归属(v1)记录到当前会话:{ workspace, session, all? , ids?, goal? }' },
46
60
  ]
47
61
 
62
+ function badWorkspace(workspace) {
63
+ if (typeof workspace !== 'string' || workspace.trim() === '') return 'missing ?workspace=<absolute path>'
64
+ if (!workspace.startsWith('/')) return 'workspace 必须是绝对路径(收到 ' + workspace + ')'
65
+ return null
66
+ }
67
+
48
68
  /**
49
- * 处理一条请求。`?workspace=<绝对路径>` 必填(取不到会话 cwd 时由面板填 + 记忆)。
69
+ * 处理一条 GET。`?workspace=<绝对路径>` 必填;`session` 缺省 = 未归属(v1 遗留)。
50
70
  * 返回 `{ status, payload }`,由 transport 层写回 —— 纯函数,好测。
51
71
  */
52
72
  export async function answer(shell, url) {
@@ -54,48 +74,109 @@ export async function answer(shell, url) {
54
74
  if (!path.startsWith(BASE)) return { status: 404, payload: { error: 'not found', endpoints } }
55
75
  const query = new URLSearchParams(path.slice(path.indexOf('?') + 1))
56
76
  const workspace = query.get('workspace')
57
- if (workspace === null || workspace.trim() === '') {
58
- return { status: 400, payload: { error: 'missing ?workspace=<absolute path>', endpoints } }
59
- }
60
- if (!workspace.startsWith('/')) {
61
- return { status: 400, payload: { error: 'workspace 必须是绝对路径(收到 ' + workspace + ')', endpoints } }
62
- }
77
+ const invalid = badWorkspace(workspace)
78
+ if (invalid !== null) return { status: 400, payload: { error: invalid, endpoints } }
79
+ const session = (query.get('session') ?? '').trim() === '' ? null : query.get('session')
80
+ const scope = query.get('scope') === 'workspace' ? 'workspace' : 'session'
63
81
  const windowSize = Math.min(2000, Math.max(1, Math.round(Number(query.get('window') ?? 200) || 200)))
64
82
  const file = ledgerPath(workspace)
65
83
  const state = foldEvents((await readText(shell, file)).split('\n'))
84
+ const view = scopeView(state, { session, scope }, windowSize)
66
85
  if (path.startsWith(BASE + '/report')) {
67
- return { status: 200, payload: { workspace, ledger: file, markdown: renderReport(workspace, state) } }
86
+ return { status: 200, payload: { workspace, ledger: file, session, scope, markdown: renderReport(workspace, view) } }
68
87
  }
69
88
  if (path.startsWith(BASE + '/state')) {
70
89
  return {
71
90
  status: 200,
72
- payload: {
73
- workspace,
74
- ledger: file,
75
- window: windowSize,
76
- projection: windowView(state, windowSize),
77
- endpoints,
78
- },
91
+ payload: { workspace, ledger: file, window: windowSize, session, scope, projection: view, endpoints },
79
92
  }
80
93
  }
81
94
  return { status: 404, payload: { error: 'not found', endpoints } }
82
95
  }
83
96
 
97
+ /**
98
+ * `POST /fly/engagement/adopt` —— 面板上的「认领未归属记录」按钮走这里。
99
+ * 只改未归属(session=null)的行;已归属其他会话的一律跳过。
100
+ */
101
+ export async function adopt(shell, body) {
102
+ const workspace = body?.workspace
103
+ const invalid = badWorkspace(workspace)
104
+ if (invalid !== null) return { status: 400, payload: { error: invalid, endpoints } }
105
+ const session = typeof body?.session === 'string' ? body.session.trim() : ''
106
+ if (session === '') return { status: 400, payload: { error: 'adopt 需要 session(当前会话身份)', endpoints } }
107
+ const file = ledgerPath(workspace)
108
+ const state = foldEvents((await readText(shell, file)).split('\n'))
109
+ const unowned = [...state.facts, ...state.findings, ...state.assets].filter(row => (row.session ?? null) === null)
110
+ const ids = body?.all === true ? unowned.map(row => row.id) : (Array.isArray(body?.ids) ? body.ids : [])
111
+ const goal = body?.goal === true || body?.all === true
112
+ if (ids.length === 0 && goal === false) {
113
+ return { status: 400, payload: { error: '没有可认领的记录(未归属为空,且未请求 goal)', endpoints } }
114
+ }
115
+ const event = { op: 'adopt', at: new Date().toISOString(), session, ids, goal }
116
+ await appendText(shell, file, JSON.stringify(event) + '\n')
117
+ const after = foldEvents((await readText(shell, file)).split('\n'))
118
+ const view = scopeView(after, { session, scope: 'session' }, 2000)
119
+ const count = rows => rows.filter(row => (row.session ?? null) === null).length
120
+ return {
121
+ status: 200,
122
+ payload: {
123
+ workspace,
124
+ session,
125
+ adopted: {
126
+ facts: count(state.facts) - count(after.facts),
127
+ findings: count(state.findings) - count(after.findings),
128
+ assets: count(state.assets) - count(after.assets),
129
+ edges: count(state.edges) - count(after.edges),
130
+ goal: state.goals.filter(row => row.session === null).length - after.goals.filter(row => row.session === null).length,
131
+ },
132
+ legacy: view.legacy,
133
+ skipped: after.skipped,
134
+ },
135
+ }
136
+ }
137
+
138
+ async function readBody(req) {
139
+ const chunks = []
140
+ for await (const chunk of req) chunks.push(chunk)
141
+ const text = Buffer.concat(chunks).toString('utf8')
142
+ return text === '' ? {} : JSON.parse(text)
143
+ }
144
+
84
145
  export function apply(ctx) {
85
146
  const shell = ctx.shell
86
147
  ctx.effect(
87
- () => ctx.webServer.register({
88
- kind: 'prefix',
89
- path: BASE,
90
- handler: async (req, res) => {
91
- try {
92
- const { status, payload } = await answer(shell, req.url ?? '/')
93
- json(res, status, payload)
94
- } catch (error) {
95
- json(res, 500, { error: String(error?.message ?? error), endpoints })
96
- }
97
- },
98
- }),
148
+ () => {
149
+ // `webServer.register` 对重复的 (kind, path) 是**抛错**语义(组合层契约)。
150
+ // profile 热重载时若上一代路由还没被 dispose,新实例会撞上它 —— 这里把失败
151
+ // 写进宿主日志再抛,否则症状只是"面板一直 404"而没有任何线索。
152
+ try {
153
+ return ctx.webServer.register({
154
+ kind: 'prefix',
155
+ path: BASE,
156
+ handler: async (req, res) => {
157
+ try {
158
+ const url = req.url ?? '/'
159
+ if (req.method === 'POST' && String(url).startsWith(BASE + '/adopt')) {
160
+ const { status, payload } = await adopt(shell, await readBody(req))
161
+ json(res, status, payload)
162
+ return
163
+ }
164
+ if (req.method !== 'GET') {
165
+ json(res, 405, { error: 'method not allowed', endpoints })
166
+ return
167
+ }
168
+ const { status, payload } = await answer(shell, url)
169
+ json(res, status, payload)
170
+ } catch (error) {
171
+ json(res, 500, { error: String(error?.message ?? error), endpoints })
172
+ }
173
+ },
174
+ })
175
+ } catch (error) {
176
+ ctx.logger?.error?.('fly-engagement: 注册 ' + BASE + '/* 失败(面板将 404)', error)
177
+ throw error
178
+ }
179
+ },
99
180
  'fly-engagement: ' + BASE + '/*',
100
181
  )
101
182
  }
package/lib/client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /* @dsh-fly/dsh-plugin-fly-engagement — browser half, built by scripts/build-plugin-client.mjs.
2
- * src-hash: 5e18fb2b6824fa812a98815cbc64cf270b64da44d6608ffd0d04db1eb8f26f15
2
+ * src-hash: 8df6cd805bb668cf7c43bc05afeae736d5f7f1e8e672ac1f4d084ca644afea08
3
3
  * Factory-form CJS: the loader calls the factory with its platform `require`
4
4
  * and memoizes the returned exports. Keep the banner so the loader id and the
5
5
  * package that produced the bundle stay greppable in a live page. */
@@ -36,9 +36,20 @@ const PANEL_CSS = `/*
36
36
  之间多出一条黑边、发光顶不到边"(用户判词)。宿主里每一条声明都自带 var() 兜底,
37
37
  不需要这里的 :root。留注释备查,mock 页仍按原样式表渲染。 */
38
38
  /* ── 面板框:照 .fly-panel 的结构(rail 8px / 3px 亮缘 / 底部关系型亮缘) ── */
39
+ /* 滚动可达性:宿主页签容器(P3OORG_tabBody)是 flex column + height:100% +
40
+ overflow:hidden,而本面板只声明了宽度与内边距 —— 高度由内容撑开、溢出被父层裁掉,
41
+ 整条祖先链上没有滚动容器,所以是"根本不能滚"。
42
+ 修法:让面板自己成为滚动容器(可收缩的 flex 子项 + min-height:0;不 grow,短内容不拉伸),
43
+ 与 fly-smart 的 .fly-panel 同一处方。max-width 兜住窄抽屉下的横向溢出。 */
39
44
  .fly-eg-panel {
40
45
  --fly-rail: 8px;
46
+ box-sizing: border-box;
47
+ flex: 0 1 auto;
48
+ min-height: 0;
41
49
  width: 760px;
50
+ max-width: 100%;
51
+ overflow-y: auto;
52
+ overscroll-behavior: contain;
42
53
  border-radius: 12px;
43
54
  border: 1px solid var(--dsw-alias-border-l1);
44
55
  background:
@@ -79,8 +90,12 @@ const PANEL_CSS = `/*
79
90
  /* ── 表 ── */
80
91
  .fly-eg-table { width: 100%; border-collapse: collapse; font-size: 12px; }
81
92
  .fly-eg-table th { text-align: left; font-weight: 500; color: var(--dsw-alias-fg-muted); font-size: 11px; padding: 4px 6px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
82
- .fly-eg-table td { padding: 5px 6px; border-bottom: 1px solid rgba(255, 255, 255, 0.05); vertical-align: top; }
83
- .fly-eg-id { font-family: ui-monospace, Menlo, monospace; font-size: 11px; color: var(--dsw-alias-fg-muted); }
93
+ /* overflow-wrap:anywhere 让单元格的**最小内容宽**变小 —— sha256、长路径这类
94
+ 不可断长串不再把表格撑出卡片(word-break 只在排版时折行,不影响最小宽)。 */
95
+ .fly-eg-table td { padding: 5px 6px; border-bottom: 1px solid rgba(255, 255, 255, 0.05); vertical-align: top; overflow-wrap: anywhere; word-break: break-word; }
96
+ /* id 列整行不折(fact-1 不该断成 fact-
97
+ 1),宽让给正文列。 */
98
+ .fly-eg-id { font-family: ui-monospace, Menlo, monospace; font-size: 11px; color: var(--dsw-alias-fg-muted); white-space: nowrap; }
84
99
  .fly-eg-src { font-size: 11px; color: var(--dsw-alias-fg-muted); }
85
100
  .fly-eg-src b { color: var(--dsw-alias-fg-secondary); font-weight: 500; }
86
101
  .fly-eg-sev { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 999px; display: inline-block; }
@@ -89,21 +104,54 @@ const PANEL_CSS = `/*
89
104
  .fly-eg-sev.medium { color: #1a1506; background: var(--fly-medium); }
90
105
  .fly-eg-sev.low { color: #061019; background: var(--fly-low); }
91
106
  .fly-eg-sev.info { color: #0b1017; background: var(--fly-info); }
92
- .fly-eg-steps { margin: 4px 0 0 0; padding-left: 18px; color: var(--dsw-alias-fg-secondary); font-size: 11.5px; }
93
- .fly-eg-steps code, .fly-eg-cmd { font-family: ui-monospace, Menlo, monospace; font-size: 11px; background: rgba(255, 255, 255, 0.06); padding: 0 4px; border-radius: 4px; }
107
+ /* 复现步骤 = 编号清单 + 行尾 ⧉ 单条复制(勾选功能已撤:li 保持 list-item,编号才有)。 */
108
+ .fly-eg-steps { margin: 4px 0 0 0; padding-left: 20px; color: var(--dsw-alias-fg-secondary); font-size: 11.5px; }
109
+ .fly-eg-step { position: relative; margin: 2px 0; padding-right: 20px; }
110
+ .fly-eg-step-text { font-family: ui-monospace, Menlo, monospace; font-size: 11px; background: rgba(255, 255, 255, 0.06); padding: 0 4px; border-radius: 4px; overflow-wrap: anywhere; }
111
+ .fly-eg-step-copy { position: absolute; right: 0; top: 0; border: none; background: none; padding: 0; cursor: pointer; color: var(--dsw-alias-fg-muted); font-size: 11.5px; line-height: 1.5; opacity: 0.55; }
112
+ .fly-eg-step-copy:hover { opacity: 1; color: var(--fly-accent); }
113
+ /* Report 标题行:标题左、动作右。 */
114
+ .fly-eg-section-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
115
+ .fly-eg-copy { font-size: 11px; padding: 2px 9px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-fg-secondary); cursor: default; }
116
+ .fly-eg-copy:hover { color: var(--dsw-alias-fg-primary); border-color: rgba(240, 165, 90, 0.45); }
117
+ .fly-eg-copy.is-done { color: var(--fly-accent); border-color: rgba(240, 165, 90, 0.55); }
118
+ .fly-eg-steps code, .fly-eg-cmd { font-family: ui-monospace, Menlo, monospace; font-size: 11px; background: rgba(255, 255, 255, 0.06); padding: 0 4px; border-radius: 4px; overflow-wrap: anywhere; white-space: pre-wrap; }
94
119
 
95
120
  /* ── 图区:静态分层 + 边胶囊 + 只读(借 xyflow 的形状,不引入拖拽) ── */
96
- .fly-eg-graph { position: relative; height: 300px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; background: radial-gradient(600px 240px at 50% 0%, rgba(255,255,255,0.035), transparent 70%), var(--dsw-alias-bg-layer-1); overflow: hidden; }
121
+ /* 图框:内层 .fly-eg-canvas 是按布局算出来的固定宽度,放不下时横向滚动(不裁剪)。 */
122
+ .fly-eg-graph { position: relative; height: 300px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; background: radial-gradient(600px 240px at 50% 0%, rgba(255,255,255,0.035), transparent 70%), var(--dsw-alias-bg-layer-1); overflow-x: auto; overflow-y: hidden; }
123
+ .fly-eg-canvas { position: relative; }
97
124
  .fly-eg-graph-hint { position: absolute; left: 8px; bottom: 6px; font-size: 10.5px; color: var(--dsw-alias-fg-muted); }
98
125
  .fly-eg-graph-badge { position: absolute; right: 8px; top: 8px; font-size: 10.5px; padding: 2px 7px; border-radius: 999px; border: 1px solid rgba(255, 95, 109, 0.45); background: rgba(255, 95, 109, 0.12); color: #ffb8be; }
99
- .fly-eg-node { position: absolute; transform: translate(-50%, -50%); border-radius: 8px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); padding: 5px 9px; font-size: 11.5px; white-space: nowrap; }
126
+ /* 节点宽 = 内容宽(max-content):短文本的框刚好包住文字;超过 240px 才省略号。
127
+ 真宽由 Graph 用 useLayoutEffect 量 offsetWidth 回填给布局(列宽、线头都用它)。 */
128
+ .fly-eg-node { position: absolute; transform: translate(-50%, -50%); box-sizing: border-box; width: max-content; max-width: 240px; border-radius: 8px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); padding: 5px 9px; font-size: 11.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
129
+ /* 色调规则在 hover 块**之前**:hover 靠同等特异性 + 源顺序压过它们,色调 hover 又以更高特异性收尾。 */
100
130
  .fly-eg-node.is-finding { border-color: rgba(255, 155, 82, 0.55); background: rgba(255, 155, 82, 0.14); color: #ffd7b0; }
101
131
  .fly-eg-node.is-asset { border-color: rgba(111, 194, 255, 0.45); background: rgba(111, 194, 255, 0.12); color: #cbe8ff; }
132
+ /* hover 展开全文:宽回到内容宽(封顶 420px 仍换行),抬高 z-index 压住邻居;
133
+ 被截断的内容从此 hover 就能读全 —— 不依赖原生 title(迟 1 秒、样式也不可控)。
134
+ 底色在 hover 时转**不透明**:半透明底一旦压到连线或邻近节点上,下层文字会透上来
135
+ 与展开的全文混在一起看不清(用户判词)。三档不透明底与各自的色调边框/字色配套。 */
136
+ .fly-eg-node:hover { width: max-content; max-width: 420px; white-space: normal; overflow-wrap: anywhere; overflow: visible; text-overflow: clip; z-index: 5; background: #20272f; border-color: rgba(240, 165, 90, 0.55); box-shadow: 0 10px 28px rgba(0, 0, 0, 0.5); cursor: default; }
137
+ .fly-eg-node.is-finding:hover { background: #3a2810; }
138
+ .fly-eg-node.is-asset:hover { background: #11293e; }
102
139
  .fly-eg-edge { position: absolute; height: 1.5px; background: rgba(240, 165, 90, 0.55); transform-origin: 0 50%; }
103
140
  .fly-eg-edge-cap { position: absolute; font-size: 10px; color: var(--fly-accent); background: rgba(7, 11, 18, 0.85); padding: 0 4px; border-radius: 4px; border: 1px solid rgba(240, 165, 90, 0.3); }
104
141
 
105
142
  /* ── 报告区 ── */
106
- .fly-eg-report { border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--dsw-alias-bg-layer-1); padding: 8px 10px; font-size: 11.5px; color: var(--dsw-alias-fg-secondary); }
143
+ .fly-eg-report { border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--dsw-alias-bg-layer-1); padding: 8px 10px; font-size: 11.5px; color: var(--dsw-alias-fg-secondary); overflow-wrap: anywhere; }
144
+ /* 报告 Markdown 的排版(受控子集):三级标题 + 段落 + 列表 + 行内 code/bold。 */
145
+ .fly-eg-md-h1 { margin: 2px 0 6px; font-size: 13.5px; font-weight: 650; color: var(--dsw-alias-fg-primary); }
146
+ .fly-eg-md-h2 { margin: 10px 0 4px; font-size: 12.5px; font-weight: 650; color: var(--dsw-alias-fg-primary); }
147
+ .fly-eg-md-h3 { margin: 8px 0 3px; font-size: 11.5px; font-weight: 650; color: var(--dsw-alias-fg-primary); }
148
+ .fly-eg-md-p { margin: 4px 0; line-height: 1.65; }
149
+ .fly-eg-md-list { margin: 4px 0; padding-left: 18px; line-height: 1.65; }
150
+ /* 报告里 "reproducible steps" 之后的有序列表带单条复制(renderMarkdown 认出该行后打标)。 */
151
+ .fly-eg-md-steps { padding-left: 20px; }
152
+ .fly-eg-md-list li { margin: 1px 0; }
153
+ .fly-eg-md-code { font-family: ui-monospace, Menlo, monospace; font-size: 10.5px; background: rgba(255, 255, 255, 0.06); padding: 0 4px; border-radius: 4px; overflow-wrap: anywhere; }
154
+ .fly-eg-report b { color: var(--dsw-alias-fg-primary); font-weight: 650; }
107
155
  .fly-eg-report-actions { display: flex; gap: 6px; margin-top: 8px; }
108
156
  .fly-eg-btn { font-size: 11.5px; padding: 3px 10px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-fg-primary); }
109
157
 
@@ -124,6 +172,16 @@ const PANEL_CSS = `/*
124
172
  .fly-eg-group > summary .fly-eg-count { margin-left: auto; font-size: 10.5px; color: var(--dsw-alias-fg-muted); }
125
173
  .fly-eg-group table { margin: 0 0 2px; }
126
174
 
175
+ /* ── 会话作用域条:本会话 / 工作区 + 未归属认领 ── */
176
+ .fly-eg-scope { display: flex; align-items: center; gap: 8px; margin: 8px 0 2px; font-size: 11.5px; }
177
+ .fly-eg-scope-tab { padding: 2px 9px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-l1); color: var(--dsw-alias-fg-secondary); cursor: default; }
178
+ .fly-eg-scope-tab.is-active { border-color: rgba(240, 165, 90, 0.55); background: rgba(240, 165, 90, 0.14); color: #ffd7b0; }
179
+ .fly-eg-scope-count { color: var(--dsw-alias-fg-muted); font-size: 10.5px; }
180
+ .fly-eg-adopt { margin-left: auto; border-color: rgba(240, 165, 90, 0.45); }
181
+ .fly-eg-scope-badge { font-size: 10.5px; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-l1); color: var(--dsw-alias-fg-muted); }
182
+ .fly-eg-hint { display: block; margin-top: 3px; font-size: 10.5px; color: var(--dsw-alias-fg-muted); }
183
+ .fly-eg-link { color: var(--fly-accent); cursor: default; }
184
+
127
185
  .fly-eg-input { width: 100%; font: inherit; font-size: 11.5px; padding: 3px 8px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-fg-primary); }
128
186
  .fly-eg-error { margin: 8px 0; padding: 6px 9px; border-radius: 8px; border: 1px solid rgba(255,95,109,0.45); background: rgba(255,95,109,0.10); color: #ffb8be; font-size: 11.5px; }
129
187
  .fly-eg-empty { margin: 14px 0; color: var(--dsw-alias-fg-muted); font-size: 11.5px; }
@@ -133,7 +191,145 @@ async function getJson(path) {
133
191
  if (!response.ok) throw new Error(path + " → HTTP " + response.status);
134
192
  return await response.json();
135
193
  }
194
+ const REPORT_PREVIEW_CAP = 2e4;
195
+ async function copyText(text) {
196
+ try {
197
+ await navigator.clipboard.writeText(text);
198
+ return true;
199
+ } catch {
200
+ const area = document.createElement("textarea");
201
+ area.value = text;
202
+ area.style.position = "fixed";
203
+ area.style.opacity = "0";
204
+ document.body.appendChild(area);
205
+ area.select();
206
+ let ok = true;
207
+ try {
208
+ document.execCommand("copy");
209
+ } catch {
210
+ ok = false;
211
+ }
212
+ area.remove();
213
+ return ok;
214
+ }
215
+ }
216
+ function StepItem({ step }) {
217
+ const [copied, setCopied] = react.useState(false);
218
+ return /* @__PURE__ */ jsxRuntime.jsxs("li", { className: "fly-eg-step", children: [
219
+ /* @__PURE__ */ jsxRuntime.jsx("code", { className: "fly-eg-step-text", children: step }),
220
+ /* @__PURE__ */ jsxRuntime.jsx("button", { className: "fly-eg-step-copy", title: "复制此步", onClick: () => void (async () => {
221
+ if (await copyText(step)) {
222
+ setCopied(true);
223
+ window.setTimeout(() => setCopied(false), 1200);
224
+ }
225
+ })(), children: copied ? "✓" : "⧉" })
226
+ ] });
227
+ }
228
+ function renderInline(text, keyPrefix) {
229
+ const out = [];
230
+ const pattern = /(`[^`]+`)|(\*\*[^*]+\*\*)|(?:(?<=^|[^\w])_([^_]+)_(?![\w]))/g;
231
+ let cursor = 0;
232
+ let index = 0;
233
+ for (const match of text.matchAll(pattern)) {
234
+ if (match.index > cursor) out.push(text.slice(cursor, match.index));
235
+ const token = match[0];
236
+ const key = keyPrefix + "-i" + index++;
237
+ if (token.startsWith("`")) out.push(/* @__PURE__ */ jsxRuntime.jsx("code", { className: "fly-eg-md-code", children: token.slice(1, -1) }, key));
238
+ else if (token.startsWith("**")) out.push(/* @__PURE__ */ jsxRuntime.jsx("b", { children: token.slice(2, -2) }, key));
239
+ else out.push(/* @__PURE__ */ jsxRuntime.jsx("i", { children: token.slice(1, -1) }, key));
240
+ cursor = match.index + token.length;
241
+ }
242
+ if (cursor < text.length) out.push(text.slice(cursor));
243
+ return out;
244
+ }
245
+ function renderMarkdown(text) {
246
+ const blocks = [];
247
+ const paragraph = [];
248
+ let list = null;
249
+ let stepsNext = false;
250
+ const flushParagraph = () => {
251
+ if (paragraph.length === 0) return;
252
+ const key = "p" + blocks.length;
253
+ const text2 = paragraph.join(" ");
254
+ if (/^reproducible steps/i.test(text2)) stepsNext = true;
255
+ blocks.push(/* @__PURE__ */ jsxRuntime.jsx("p", { className: "fly-eg-md-p", children: renderInline(text2, key) }, key));
256
+ paragraph.length = 0;
257
+ };
258
+ const flushList = () => {
259
+ if (list === null) return;
260
+ const key = "l" + blocks.length;
261
+ const asSteps = stepsNext && list.ordered;
262
+ const items = list.items.map((item, i) => asSteps ? /* @__PURE__ */ jsxRuntime.jsx(StepItem, { step: item.raw }, key + "-" + i) : /* @__PURE__ */ jsxRuntime.jsx("li", { children: item.content }, key + "-" + i));
263
+ const className = asSteps ? "fly-eg-md-list fly-eg-md-steps" : "fly-eg-md-list";
264
+ stepsNext = false;
265
+ blocks.push(list.ordered ? /* @__PURE__ */ jsxRuntime.jsx("ol", { className, children: items }, key) : /* @__PURE__ */ jsxRuntime.jsx("ul", { className, children: items }, key));
266
+ list = null;
267
+ };
268
+ for (const line of text.split("\n")) {
269
+ const heading = /^(#{1,3})\s+(.*)$/.exec(line);
270
+ const bullet = /^[-*]\s+(.*)$/.exec(line);
271
+ const ordered = /^\d+\.\s+(.*)$/.exec(line);
272
+ if (heading !== null) {
273
+ flushParagraph();
274
+ flushList();
275
+ stepsNext = false;
276
+ const key = "h" + blocks.length;
277
+ const content = renderInline(heading[2], key);
278
+ const level = heading[1].length;
279
+ blocks.push(level === 1 ? /* @__PURE__ */ jsxRuntime.jsx("h1", { className: "fly-eg-md-h1", children: content }, key) : level === 2 ? /* @__PURE__ */ jsxRuntime.jsx("h2", { className: "fly-eg-md-h2", children: content }, key) : /* @__PURE__ */ jsxRuntime.jsx("h3", { className: "fly-eg-md-h3", children: content }, key));
280
+ continue;
281
+ }
282
+ if (bullet !== null || ordered !== null) {
283
+ flushParagraph();
284
+ const isOrdered = ordered !== null;
285
+ const raw = (bullet ?? ordered)[1];
286
+ const content = renderInline(raw, "i" + blocks.length + "-" + (list?.items.length ?? 0));
287
+ if (list === null || list.ordered !== isOrdered) {
288
+ flushList();
289
+ list = { ordered: isOrdered, items: [] };
290
+ }
291
+ list.items.push({ raw, content });
292
+ continue;
293
+ }
294
+ if (line.trim() === "") {
295
+ flushParagraph();
296
+ flushList();
297
+ continue;
298
+ }
299
+ paragraph.push(line.trim());
300
+ }
301
+ flushParagraph();
302
+ flushList();
303
+ return blocks;
304
+ }
305
+ function assetDepth(assets, parentEdges, id) {
306
+ const parentOf = /* @__PURE__ */ new Map();
307
+ for (const edge of parentEdges) parentOf.set(edge.to, edge.from);
308
+ for (const asset of assets) if (asset.parentId !== void 0 && asset.parentId !== "") parentOf.set(asset.id, asset.parentId);
309
+ const seen = /* @__PURE__ */ new Set([id]);
310
+ let depth = 0;
311
+ let cursor = parentOf.get(id);
312
+ while (cursor !== void 0 && !seen.has(cursor)) {
313
+ seen.add(cursor);
314
+ depth += 1;
315
+ cursor = parentOf.get(cursor);
316
+ }
317
+ return depth;
318
+ }
136
319
  function Graph({ nodes, edges, kind }) {
320
+ const nodeRefs = react.useRef(/* @__PURE__ */ new Map());
321
+ const [widths, setWidths] = react.useState(/* @__PURE__ */ new Map());
322
+ react.useLayoutEffect(() => {
323
+ const next = /* @__PURE__ */ new Map();
324
+ let changed = widths.size !== nodes.length;
325
+ for (const node of nodes) {
326
+ const width = nodeRefs.current.get(node.id)?.offsetWidth ?? 0;
327
+ if (width <= 0) continue;
328
+ next.set(node.id, width);
329
+ if (widths.get(node.id) !== width) changed = true;
330
+ }
331
+ if (changed) setWidths(next);
332
+ });
137
333
  const byLayer = /* @__PURE__ */ new Map();
138
334
  for (const node of nodes) {
139
335
  const list = byLayer.get(node.layer) ?? [];
@@ -141,65 +337,152 @@ function Graph({ nodes, edges, kind }) {
141
337
  byLayer.set(node.layer, list);
142
338
  }
143
339
  const layers = [...byLayer.keys()].sort((a, b) => a - b);
144
- const widest = Math.max(120, ...nodes.map((node) => node.label.length * 6.6 + 20));
145
- const gap = 78;
340
+ const NODE_MAX = 240;
341
+ const NODE_MIN = 88;
342
+ const textW = (label) => {
343
+ let width = 22;
344
+ for (const ch of label) width += /[\u3000-\u9fff\uff01-\uff60]/.test(ch) ? 11.5 : 6.4;
345
+ return Math.min(NODE_MAX, Math.max(NODE_MIN, Math.round(width)));
346
+ };
347
+ const nodeW = /* @__PURE__ */ new Map();
348
+ for (const node of nodes) nodeW.set(node.id, widths.get(node.id) ?? textW(node.label));
349
+ const colW = /* @__PURE__ */ new Map();
350
+ for (const node of nodes) colW.set(node.layer, Math.max(colW.get(node.layer) ?? 0, nodeW.get(node.id) ?? 0));
351
+ const colGap = 56;
146
352
  const xOf = /* @__PURE__ */ new Map();
147
- let cursor = 60;
353
+ let cursor = 16;
148
354
  for (const layer of layers) {
149
- for (const node of byLayer.get(layer) ?? []) xOf.set(node.id, cursor + widest / 2);
150
- cursor += widest + gap;
355
+ const w = colW.get(layer) ?? NODE_MIN;
356
+ for (const node of byLayer.get(layer) ?? []) xOf.set(node.id, cursor + w / 2);
357
+ cursor += w + colGap;
151
358
  }
152
- const height = Math.max(180, ...layers.map((layer) => (byLayer.get(layer) ?? []).length * 54));
359
+ const totalWidth = Math.round(cursor - colGap + 16);
360
+ const rowPitch = 40;
361
+ const height = Math.max(96, ...layers.map((layer) => (byLayer.get(layer) ?? []).length * rowPitch));
153
362
  const yOf = /* @__PURE__ */ new Map();
154
363
  for (const layer of layers) {
155
364
  const list = byLayer.get(layer) ?? [];
156
365
  list.forEach((node, index) => yOf.set(node.id, height / (list.length + 1) * (index + 1)));
157
366
  }
158
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-graph", style: { height: height + 60 }, children: [
159
- /* @__PURE__ */ jsxRuntime.jsx("svg", { className: "fly-eg-wires", width: "100%", height: "100%", children: edges.filter((edge) => xOf.has(edge.from) && xOf.has(edge.to)).map((edge) => {
160
- const x1 = (xOf.get(edge.from) ?? 0) + widest / 2;
161
- const x2 = (xOf.get(edge.to) ?? 0) - widest / 2;
162
- const midX = (x1 + x2) / 2;
163
- return /* @__PURE__ */ jsxRuntime.jsx("path", { className: "fly-eg-wire", d: "M " + x1 + " " + yOf.get(edge.from) + " H " + midX + " V " + yOf.get(edge.to) + " H " + x2 }, kind + edge.from + edge.to);
164
- }) }),
165
- nodes.map((node) => /* @__PURE__ */ jsxRuntime.jsx(
166
- "div",
167
- {
168
- className: "fly-eg-node" + (node.tone === "finding" ? " is-finding" : node.tone === "asset" ? " is-asset" : ""),
169
- style: { left: xOf.get(node.id), top: yOf.get(node.id) },
170
- children: node.label
171
- },
172
- node.id
173
- )),
367
+ const markerId = "fly-eg-arrow-" + kind;
368
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-graph", style: { height: height + 34 }, children: [
369
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-canvas", style: { width: totalWidth, height: height + 34 }, children: [
370
+ /* @__PURE__ */ jsxRuntime.jsxs("svg", { className: "fly-eg-wires", width: totalWidth, height: height + 34, viewBox: `0 0 ${totalWidth} ${height + 34}`, children: [
371
+ /* @__PURE__ */ jsxRuntime.jsx("defs", { children: /* @__PURE__ */ jsxRuntime.jsx("marker", { id: markerId, markerWidth: "7", markerHeight: "7", refX: "6", refY: "3.5", orient: "auto", markerUnits: "userSpaceOnUse", children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M0,0.6 L6,3.5 L0,6.4 z", fill: "rgba(240, 165, 90, 0.85)" }) }) }),
372
+ edges.filter((edge) => xOf.has(edge.from) && xOf.has(edge.to)).map((edge) => {
373
+ const w1 = nodeW.get(edge.from) ?? NODE_MIN;
374
+ const w2 = nodeW.get(edge.to) ?? NODE_MIN;
375
+ const cx1 = xOf.get(edge.from) ?? 0;
376
+ const cx2 = xOf.get(edge.to) ?? 0;
377
+ const y1 = yOf.get(edge.from) ?? 0;
378
+ const y2 = yOf.get(edge.to) ?? 0;
379
+ let d;
380
+ if (cx2 > cx1) {
381
+ const x1 = cx1 + w1 / 2 + 3;
382
+ const x2 = cx2 - w2 / 2 - 3;
383
+ const midX = (x1 + x2) / 2;
384
+ d = "M " + x1 + " " + y1 + " H " + midX + " V " + y2 + " H " + x2;
385
+ } else {
386
+ const x1 = cx1 + w1 / 2 + 3;
387
+ const x2 = cx2 - w2 / 2 - 3;
388
+ const floor = height + 16;
389
+ d = "M " + x1 + " " + y1 + " H " + (x1 + 14) + " V " + floor + " H " + (x2 - 14) + " V " + y2 + " H " + x2;
390
+ }
391
+ return /* @__PURE__ */ jsxRuntime.jsx("path", { className: "fly-eg-wire", markerEnd: `url(#${markerId})`, d }, kind + edge.from + edge.to);
392
+ })
393
+ ] }),
394
+ nodes.map((node) => /* @__PURE__ */ jsxRuntime.jsx(
395
+ "div",
396
+ {
397
+ ref: (element) => {
398
+ if (element !== null) nodeRefs.current.set(node.id, element);
399
+ else nodeRefs.current.delete(node.id);
400
+ },
401
+ className: "fly-eg-node" + (node.tone === "finding" ? " is-finding" : node.tone === "asset" ? " is-asset" : ""),
402
+ style: { left: xOf.get(node.id), top: yOf.get(node.id) },
403
+ children: node.label
404
+ },
405
+ node.id
406
+ ))
407
+ ] }),
174
408
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "fly-eg-graph-hint", children: "折线走层间间隙 · 静态分层 · 只读" })
175
409
  ] });
176
410
  }
177
- function Panel() {
178
- const [workspace, setWorkspace] = react.useState(() => localStorage.getItem(STORE_KEY) ?? "");
411
+ function Panel({ sessionId, useSessions }) {
412
+ const [override, setOverride] = react.useState(() => localStorage.getItem(STORE_KEY) ?? "");
413
+ const liveCwd = useSessions === void 0 || sessionId === void 0 ? void 0 : useSessions((state2) => state2.byId[sessionId]?.cwd);
414
+ const workspace = override.trim() !== "" ? override : liveCwd ?? "";
415
+ const session = sessionId ?? "";
416
+ const [scope, setScope] = react.useState("session");
179
417
  const [state, setState] = react.useState(null);
180
418
  const [error, setError] = react.useState("");
181
419
  const [view, setView] = react.useState("table");
182
420
  const [report, setReport] = react.useState("");
183
- const load = react.useCallback(async (target) => {
421
+ const [busy, setBusy] = react.useState(false);
422
+ const [copied, setCopied] = react.useState(false);
423
+ const copyReport = async () => {
424
+ if (report === "") return;
425
+ if (await copyText(report)) {
426
+ setCopied(true);
427
+ window.setTimeout(() => setCopied(false), 1600);
428
+ } else {
429
+ setError("复制失败:浏览器拒绝了剪贴板写入");
430
+ }
431
+ };
432
+ const load = react.useCallback(async (target, mode) => {
184
433
  if (target.trim() === "") return;
185
434
  setError("");
435
+ const query = "workspace=" + encodeURIComponent(target) + "&session=" + encodeURIComponent(session) + "&scope=" + mode;
186
436
  try {
187
437
  const [next, rendered] = await Promise.all([
188
- getJson(BASE + "/state?workspace=" + encodeURIComponent(target)),
189
- getJson(BASE + "/report?workspace=" + encodeURIComponent(target)).catch(() => ({ markdown: "" }))
438
+ getJson(BASE + "/state?" + query),
439
+ getJson(BASE + "/report?" + query).catch(() => ({ markdown: "" }))
190
440
  ]);
191
441
  setState(next);
192
442
  setReport(rendered.markdown);
193
- localStorage.setItem(STORE_KEY, target);
194
443
  } catch (failure) {
195
- setError(String(failure.message ?? failure));
444
+ const message = String(failure.message ?? failure);
445
+ setError(message.includes("HTTP 404") ? "宿主没有注册 " + BASE + " 路由(面板行未激活)· 重启 DSH 或重载 profile 后再试 · " + message : message);
196
446
  setState(null);
197
447
  }
198
- }, []);
448
+ }, [session]);
199
449
  react.useEffect(() => {
200
- void load(workspace);
201
- }, [load, workspace]);
450
+ void load(workspace, scope);
451
+ }, [load, workspace, scope]);
452
+ react.useEffect(() => {
453
+ const refresh = () => {
454
+ if (document.visibilityState === "visible") void load(workspace, scope);
455
+ };
456
+ document.addEventListener("visibilitychange", refresh);
457
+ window.addEventListener("focus", refresh);
458
+ return () => {
459
+ document.removeEventListener("visibilitychange", refresh);
460
+ window.removeEventListener("focus", refresh);
461
+ };
462
+ }, [load, workspace, scope]);
463
+ const adopt = async () => {
464
+ if (busy || workspace.trim() === "" || session === "") return;
465
+ setBusy(true);
466
+ try {
467
+ const response = await fetch(BASE + "/adopt", {
468
+ method: "POST",
469
+ headers: { "content-type": "application/json" },
470
+ body: JSON.stringify({ workspace, session, all: true })
471
+ });
472
+ if (!response.ok) throw new Error("adopt → HTTP " + response.status);
473
+ await load(workspace, scope);
474
+ } catch (failure) {
475
+ setError(String(failure.message ?? failure));
476
+ } finally {
477
+ setBusy(false);
478
+ }
479
+ };
202
480
  const projection = state?.projection;
481
+ const legacy = projection?.legacy ?? { facts: 0, findings: 0, assets: 0, goal: 0 };
482
+ const totals = projection?.workspaceCounts ?? projection?.counts;
483
+ const legacyTotal = legacy.facts + legacy.findings + legacy.assets + legacy.goal;
484
+ const scopedTotal = projection === void 0 ? 0 : projection.counts.facts + projection.counts.findings + projection.counts.assets;
485
+ const workspaceTotal = totals === void 0 ? 0 : totals.facts + totals.findings + totals.assets;
203
486
  const groups = react.useMemo(() => {
204
487
  const map = /* @__PURE__ */ new Map();
205
488
  for (const fact of projection?.facts ?? []) {
@@ -212,20 +495,26 @@ function Panel() {
212
495
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-head", children: [
213
496
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-title", children: [
214
497
  /* @__PURE__ */ jsxRuntime.jsx("h1", { children: "Engagement" }),
498
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fly-eg-scope-badge", children: session === "" ? "未绑定会话" : "会话 " + session.slice(0, 8) }),
215
499
  projection?.goal === null || projection === void 0 ? null : /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fly-eg-badge", children: projection.goal.target })
216
500
  ] }),
217
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "fly-eg-meta", children: /* @__PURE__ */ jsxRuntime.jsx(
218
- "input",
219
- {
220
- className: "fly-eg-input",
221
- value: workspace,
222
- placeholder: "工作区绝对路径(记录在 <workspace>/.fly-engagement/events.jsonl)",
223
- onChange: (event) => setWorkspace(event.target.value),
224
- onKeyDown: (event) => {
225
- if (event.key === "Enter") void load(workspace);
501
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-meta", children: [
502
+ /* @__PURE__ */ jsxRuntime.jsx(
503
+ "input",
504
+ {
505
+ className: "fly-eg-input",
506
+ value: workspace,
507
+ placeholder: "工作区绝对路径(默认跟随当前会话 cwd)",
508
+ onChange: (event) => setOverride(event.target.value),
509
+ onKeyDown: (event) => {
510
+ if (event.key !== "Enter") return;
511
+ localStorage.setItem(STORE_KEY, override);
512
+ void load(workspace, scope);
513
+ }
226
514
  }
227
- }
228
- ) }),
515
+ ),
516
+ override.trim() === "" && liveCwd !== void 0 && liveCwd !== "" ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fly-eg-hint", children: "跟随会话 cwd · 输入可覆盖" }) : null
517
+ ] }),
229
518
  projection?.goal === null || projection === void 0 ? null : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
230
519
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "fly-eg-meta", children: /* @__PURE__ */ jsxRuntime.jsxs("span", { children: [
231
520
  "objective: ",
@@ -240,6 +529,20 @@ function Panel() {
240
529
  ] }),
241
530
  error !== "" ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "fly-eg-error", children: error }) : null,
242
531
  projection === void 0 ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "fly-eg-empty", children: "还没读取记录(填工作区后回车)" }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
532
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-scope", children: [
533
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fly-eg-scope-tab" + (scope === "session" ? " is-active" : ""), onClick: () => setScope("session"), children: "本会话" }),
534
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fly-eg-scope-tab" + (scope === "workspace" ? " is-active" : ""), onClick: () => setScope("workspace"), children: "工作区" }),
535
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "fly-eg-scope-count", children: [
536
+ "工作区合计 ",
537
+ workspaceTotal,
538
+ " 条"
539
+ ] }),
540
+ legacyTotal > 0 && session !== "" ? /* @__PURE__ */ jsxRuntime.jsx("button", { className: "fly-eg-btn fly-eg-adopt", disabled: busy, onClick: () => void adopt(), children: busy ? "认领中…" : "认领 " + legacyTotal + " 条未归属记录" }) : null
541
+ ] }),
542
+ scopedTotal === 0 ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-empty", children: [
543
+ scope === "session" ? workspaceTotal > 0 ? "本会话还没有记录;工作区共 " + workspaceTotal + " 条" : "本会话还没有记录" : "这个工作区还没有记录",
544
+ scope === "session" && workspaceTotal > 0 ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fly-eg-link", onClick: () => setScope("workspace"), children: " 看工作区 →" }) : null
545
+ ] }) : null,
243
546
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-counters", children: [
244
547
  /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "fly-eg-counter", children: [
245
548
  "facts",
@@ -286,7 +589,7 @@ function Panel() {
286
589
  /* @__PURE__ */ jsxRuntime.jsx("td", { className: "fly-eg-id", children: finding.id }),
287
590
  /* @__PURE__ */ jsxRuntime.jsx("td", { children: /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fly-eg-sev " + (finding.severity ?? "info"), children: finding.severity ?? "info" }) }),
288
591
  /* @__PURE__ */ jsxRuntime.jsx("td", { children: finding.title }),
289
- /* @__PURE__ */ jsxRuntime.jsx("td", { children: /* @__PURE__ */ jsxRuntime.jsx("ol", { className: "fly-eg-steps", children: finding.reproducibleSteps.map((step, index) => /* @__PURE__ */ jsxRuntime.jsx("li", { children: /* @__PURE__ */ jsxRuntime.jsx("code", { children: step }) }, index)) }) })
592
+ /* @__PURE__ */ jsxRuntime.jsx("td", { children: /* @__PURE__ */ jsxRuntime.jsx("ol", { className: "fly-eg-steps", children: finding.reproducibleSteps.map((step, index) => /* @__PURE__ */ jsxRuntime.jsx(StepItem, { step }, index)) }) })
290
593
  ] }, finding.id)) }) })
291
594
  ] }),
292
595
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-section", children: [
@@ -306,8 +609,11 @@ function Panel() {
306
609
  ] }, tool))
307
610
  ] }),
308
611
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-section", children: [
309
- /* @__PURE__ */ jsxRuntime.jsx("h2", { children: "Report" }),
310
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "fly-eg-report", children: report === "" ? "(还没有可渲染的报告)" : report.slice(0, 2e3) })
612
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-section-head", children: [
613
+ /* @__PURE__ */ jsxRuntime.jsx("h2", { children: "Report" }),
614
+ /* @__PURE__ */ jsxRuntime.jsx("button", { className: "fly-eg-copy" + (copied ? " is-done" : ""), disabled: report === "", onClick: () => void copyReport(), children: copied ? "已复制 ✓" : "复制 Markdown" })
615
+ ] }),
616
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "fly-eg-report", children: report === "" ? "(还没有可渲染的报告)" : renderMarkdown(report.length > REPORT_PREVIEW_CAP ? report.slice(0, REPORT_PREVIEW_CAP) + "\n\n**…(报告超过 " + REPORT_PREVIEW_CAP + " 字符,已截断预览)**" : report) })
311
617
  ] })
312
618
  ] }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
313
619
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-section", children: [
@@ -330,7 +636,7 @@ function Panel() {
330
636
  Graph,
331
637
  {
332
638
  kind: "parent",
333
- nodes: projection.assets.map((asset) => ({ id: asset.id, label: asset.value + " (" + asset.type + ")", layer: asset.parentId === void 0 ? 0 : 1, tone: "asset" })),
639
+ nodes: projection.assets.map((asset) => ({ id: asset.id, label: asset.value + " (" + asset.type + ")", layer: assetDepth(projection.assets, projection.edges.filter((edge) => edge.kind === "parent"), asset.id), tone: "asset" })),
334
640
  edges: projection.edges.filter((edge) => edge.kind === "parent")
335
641
  }
336
642
  )
package/lib/fold.mjs CHANGED
@@ -4,6 +4,9 @@
4
4
  * 为什么单独成文件:这两半是两个挂载点(`tools/index.js` 由 preset 按会话挂;根 `index.js`
5
5
  * 是宿主平面的面板行),**投影口径必须逐字一致**,否则面板上的计数和 `eng_state` 会讲两个故事。
6
6
  * 同包内 import ⇒ 不需要"两份逐字节相同"的钉子(那是跨包才付的代价)。
7
+ *
8
+ * v2 起事件带 `session`:一个工作区一条账本,但**每个会话各自一条证据链**。
9
+ * 没有 session 的旧事件归入「未归属」(v1 遗留),可以经 `eng_adopt` 认领。
7
10
  */
8
11
 
9
12
  export const FACT_KINDS = ['net', 'service', 'http', 'binary', 'code', 'vuln', 'info']
@@ -19,13 +22,18 @@ export function ledgerPath(workspace) {
19
22
  return String(workspace).replace(/\/+$/u, '') + '/' + LEDGER_DIR + '/' + LEDGER_FILE
20
23
  }
21
24
 
25
+ /** 事件信封上的会话身份:缺省 / 空串都折成 null(= v1 遗留的未归属)。 */
26
+ export function sessionOf(event) {
27
+ return typeof event?.session === 'string' && event.session !== '' ? event.session : null
28
+ }
29
+
22
30
  /**
23
31
  * 折叠事件流 ⇒ 完整状态(**确定性**:同一个文件永远折出同一张图)。
24
- * 坏引用计入 `refused`(不是静默丢弃)。
32
+ * 坏引用计入 `refused`(不是静默丢弃);认领别人的记录计入 `skipped`。
25
33
  */
26
34
  export function foldEvents(lines) {
27
- const state = { goal: null, facts: [], findings: [], assets: [], edges: [], refused: 0 }
28
- const known = () => new Set([...state.facts.map(row => row.id), ...state.findings.map(row => row.id), ...state.assets.map(row => row.id)])
35
+ const state = { goals: [], facts: [], findings: [], assets: [], edges: [], refused: 0, skipped: 0 }
36
+ const allRows = () => new Map([...state.facts, ...state.findings, ...state.assets].map(row => [row.id, row]))
29
37
  for (const line of lines) {
30
38
  if (line.trim() === '') continue
31
39
  let event
@@ -36,60 +44,131 @@ export function foldEvents(lines) {
36
44
  continue
37
45
  }
38
46
  if (event.op === 'goal') {
39
- if (state.goal !== null) { state.refused += 1; continue }
40
- state.goal = event.goal
47
+ const session = sessionOf(event)
48
+ if (state.goals.some(row => row.session === session)) { state.refused += 1; continue }
49
+ state.goals.push({ session, goal: event.goal, at: event.at ?? null })
50
+ continue
51
+ }
52
+ if (event.op === 'adopt') {
53
+ const session = sessionOf(event)
54
+ if (session === null) { state.refused += 1; continue }
55
+ const rows = allRows()
56
+ for (const id of event.ids ?? []) {
57
+ const row = rows.get(id)
58
+ if (row === undefined) { state.refused += 1; continue }
59
+ if ((row.session ?? null) !== null) { state.skipped += 1; continue }
60
+ row.session = session
61
+ }
62
+ if (event.goal === true) {
63
+ const legacy = state.goals.find(row => row.session === null)
64
+ if (legacy === undefined || state.goals.some(row => row.session === session)) state.skipped += 1
65
+ else legacy.session = session
66
+ }
67
+ // 边跟着端点走:两端都归本会话的未归属边一起认领(保持账本内部一致;渲染不依赖这一步)。
68
+ for (const edge of state.edges) {
69
+ if ((edge.session ?? null) !== null) continue
70
+ const from = rows.get(edge.from)
71
+ const to = rows.get(edge.to)
72
+ if (from !== undefined && to !== undefined && from.session === session && to.session === session) edge.session = session
73
+ }
41
74
  continue
42
75
  }
43
76
  if (event.op !== 'record') continue
44
- const ids = known()
77
+ const session = sessionOf(event)
78
+ const ids = new Set(allRows().keys())
45
79
  for (const asset of event.assets ?? []) {
46
80
  if (asset.parentId !== undefined && asset.parentId !== '' && !ids.has(asset.parentId)) { state.refused += 1; continue }
47
- state.assets.push(asset)
81
+ state.assets.push({ ...asset, session })
48
82
  ids.add(asset.id)
49
83
  }
50
84
  for (const fact of event.facts ?? []) {
51
- state.facts.push(fact)
85
+ state.facts.push({ ...fact, session })
52
86
  ids.add(fact.id)
53
87
  }
54
88
  for (const finding of event.findings ?? []) {
55
89
  if (finding.affectedAssetId !== undefined && finding.affectedAssetId !== '' && !ids.has(finding.affectedAssetId)) { state.refused += 1; continue }
56
- state.findings.push(finding)
90
+ state.findings.push({ ...finding, session })
57
91
  ids.add(finding.id)
58
92
  }
59
93
  for (const edge of event.edges ?? []) {
60
94
  if (!ids.has(edge.from) || !ids.has(edge.to)) { state.refused += 1; continue }
61
- state.edges.push(edge)
95
+ state.edges.push({ ...edge, session })
62
96
  }
63
97
  }
64
98
  return state
65
99
  }
66
100
 
101
+ /** 某个会话的 goal(`session` 为 null 即 v1 遗留)。 */
102
+ export function goalFor(state, session) {
103
+ return state.goals.find(row => row.session === (session ?? null))?.goal ?? null
104
+ }
105
+
106
+ const countsOf = rows => ({
107
+ facts: rows.facts.length,
108
+ findings: rows.findings.length,
109
+ assets: rows.assets.length,
110
+ edges: rows.edges.length,
111
+ })
112
+
67
113
  /**
68
- * 窗口视图:每张表留最新 `windowSize` 条;`dropped` 是被逐出的条数。
69
- * `counts` 报**全量**、窗口只影响 rows —— 两个读数都要在(面板与 `eng_state` 同口径)。
114
+ * 作用域视图:`scope='session'` 只留该会话的行,`scope='workspace'` 合并全部。
115
+ *
116
+ * 两个读数永远都给:`counts` 是**当前作用域**的计数,`workspaceCounts` 是全工作区的。
117
+ * `legacy` 单独报未归属条数(v1 遗留),面板据此决定要不要给「认领」按钮。
70
118
  */
71
- export function windowView(state, windowSize) {
119
+ export function scopeView(state, options = {}, windowSize = 200) {
120
+ const scope = options.scope === 'workspace' ? 'workspace' : 'session'
121
+ const session = scope === 'session' ? (options.session ?? null) : null
122
+ const inScope = row => scope === 'workspace' || (row.session ?? null) === session
72
123
  const cut = rows => rows.length <= windowSize
73
124
  ? { rows, dropped: 0 }
74
125
  : { rows: rows.slice(rows.length - windowSize), dropped: rows.length - windowSize }
75
- const facts = cut(state.facts)
76
- const findings = cut(state.findings)
77
- const assets = cut(state.assets)
126
+
127
+ const factsAll = state.facts.filter(inScope)
128
+ const findingsAll = state.findings.filter(inScope)
129
+ const assetsAll = state.assets.filter(inScope)
130
+ // 边**跟随端点**:两端都在作用域内才算这条作用域的边(边自己的 session 只是出处,
131
+ // 不参与过滤 —— 否则 adopt 只认领节点后,边会以未归属身份残留、图上看不到连线)。
132
+ const scopedIds = new Set([...factsAll, ...findingsAll, ...assetsAll].map(row => row.id))
133
+ const edgesAll = state.edges.filter(edge => scopedIds.has(edge.from) && scopedIds.has(edge.to))
134
+ const facts = cut(factsAll)
135
+ const findings = cut(findingsAll)
136
+ const assets = cut(assetsAll)
78
137
  const kept = new Set([...facts.rows, ...findings.rows, ...assets.rows].map(row => row.id))
79
138
  const dropped = facts.dropped + findings.dropped + assets.dropped
80
- return {
81
- goal: state.goal,
139
+ const scoped = {
82
140
  facts: facts.rows,
83
141
  findings: findings.rows,
84
142
  assets: assets.rows,
85
- edges: state.edges.filter(edge => kept.has(edge.from) && kept.has(edge.to)),
86
- counts: { facts: state.facts.length, findings: state.findings.length, assets: state.assets.length, edges: state.edges.length },
143
+ edges: edgesAll.filter(edge => kept.has(edge.from) && kept.has(edge.to)),
144
+ }
145
+ const goals = scope === 'workspace' ? state.goals : state.goals.filter(row => row.session === session)
146
+ return {
147
+ ...scoped,
148
+ scope,
149
+ session,
150
+ goal: goals.length === 1 ? goals[0].goal : null,
151
+ goals: goals.map(row => ({ session: row.session, goal: row.goal })),
152
+ counts: countsOf({ facts: factsAll, findings: findingsAll, assets: assetsAll, edges: edgesAll }),
153
+ workspaceCounts: countsOf(state),
154
+ legacy: {
155
+ facts: state.facts.filter(row => (row.session ?? null) === null).length,
156
+ findings: state.findings.filter(row => (row.session ?? null) === null).length,
157
+ assets: state.assets.filter(row => (row.session ?? null) === null).length,
158
+ goal: state.goals.filter(row => row.session === null).length,
159
+ },
87
160
  dropped,
88
161
  refused: state.refused,
162
+ skipped: state.skipped,
89
163
  truncated: dropped > 0,
90
164
  }
91
165
  }
92
166
 
167
+ /** 兼容旧调用:整份账本的窗口视图。 */
168
+ export function windowView(state, windowSize) {
169
+ return scopeView(state, { scope: 'workspace' }, windowSize)
170
+ }
171
+
93
172
  /** 下一个确定性 id `<kind>-<n>`。 */
94
173
  export function nextId(prefix, rows) {
95
174
  let max = 0
@@ -100,32 +179,35 @@ export function nextId(prefix, rows) {
100
179
  return prefix + '-' + (max + 1)
101
180
  }
102
181
 
103
- /** 从全量状态渲染 Markdown 报告(`eng_report` 与 `/fly/engagement/report` 同一份)。 */
104
- export function renderReport(workspace, state) {
182
+ /** 从视图渲染 Markdown 报告(`eng_report` 与 `/fly/engagement/report` 同一份)。 */
183
+ export function renderReport(workspace, view) {
105
184
  const lines = []
185
+ const scopeLabel = view.scope === 'workspace' ? '整个工作区' : '会话 ' + (view.session ?? '(未归属)')
106
186
  lines.push('# Engagement report')
107
187
  lines.push('')
108
188
  lines.push('- workspace: `' + workspace + '`')
109
189
  lines.push('- ledger: `' + ledgerPath(workspace) + '`')
110
- lines.push('- counts: facts=' + state.facts.length + ' findings=' + state.findings.length + ' assets=' + state.assets.length + ' edges=' + state.edges.length)
111
- if (state.refused > 0) lines.push('- refused writes: ' + state.refused + '(引用不存在的 id,已拒绝)')
190
+ lines.push('- scope: ' + scopeLabel)
191
+ lines.push('- counts: facts=' + view.counts.facts + ' findings=' + view.counts.findings + ' assets=' + view.counts.assets + ' edges=' + view.counts.edges)
192
+ if (view.scope === 'session') lines.push('- workspace totals: facts=' + view.workspaceCounts.facts + ' findings=' + view.workspaceCounts.findings + ' assets=' + view.workspaceCounts.assets + ' edges=' + view.workspaceCounts.edges)
193
+ if (view.refused > 0) lines.push('- refused writes: ' + view.refused + '(引用不存在的 id,已拒绝)')
112
194
  lines.push('', '## Goal', '')
113
- if (state.goal === null) {
114
- lines.push('_未设置(还没有 eng_record 带 goal)_')
195
+ if (view.goal === null) {
196
+ lines.push('**未设置(还没有 eng_record 带 goal)**')
115
197
  } else {
116
- lines.push('- target: ' + state.goal.target)
117
- lines.push('- objective: ' + state.goal.objective)
118
- lines.push('- authorization: ' + (state.goal.authorization === undefined || state.goal.authorization === '' ? '_未记录_(这是审计事实,不是门禁)' : state.goal.authorization))
198
+ lines.push('- target: ' + view.goal.target)
199
+ lines.push('- objective: ' + view.goal.objective)
200
+ lines.push('- authorization: ' + (view.goal.authorization === undefined || view.goal.authorization === '' ? '**未记录**(这是审计事实,不是门禁)' : view.goal.authorization))
119
201
  }
120
202
  lines.push('', '## Assets')
121
- if (state.assets.length === 0) lines.push('', '_none_')
122
- for (const asset of state.assets) {
203
+ if (view.assets.length === 0) lines.push('', 'none')
204
+ for (const asset of view.assets) {
123
205
  const parent = asset.parentId === undefined || asset.parentId === '' ? '' : ' ← parent `' + asset.parentId + '`'
124
206
  lines.push('- `' + asset.id + '` [' + asset.type + '] ' + asset.value + parent)
125
207
  }
126
208
  lines.push('', '## Findings')
127
- if (state.findings.length === 0) lines.push('', '_none_')
128
- for (const finding of state.findings) {
209
+ if (view.findings.length === 0) lines.push('', 'none')
210
+ for (const finding of view.findings) {
129
211
  lines.push('', '### `' + finding.id + '` ' + finding.title + ' — **' + (finding.severity ?? 'info') + '**')
130
212
  if (finding.description !== undefined && finding.description !== '') lines.push('', finding.description)
131
213
  if (finding.affectedAssetId !== undefined && finding.affectedAssetId !== '') lines.push('', 'affected asset: `' + finding.affectedAssetId + '`')
@@ -133,14 +215,14 @@ export function renderReport(workspace, state) {
133
215
  for (const [index, step] of finding.reproducibleSteps.entries()) lines.push((index + 1) + '. ' + step)
134
216
  }
135
217
  lines.push('', '## Facts')
136
- if (state.facts.length === 0) lines.push('', '_none_')
137
- for (const fact of state.facts) {
218
+ if (view.facts.length === 0) lines.push('', 'none')
219
+ for (const fact of view.facts) {
138
220
  const source = fact.source === undefined ? '' : ' _(source: ' + [fact.source.tool, fact.source.operation].filter(Boolean).join(' · ') + ')_'
139
221
  lines.push('- `' + fact.id + '` [' + (fact.kind ?? 'info') + ']' + (fact.target === undefined ? '' : ' ' + fact.target) + ' — ' + fact.detail + source)
140
222
  }
141
223
  lines.push('', '## Edges')
142
- if (state.edges.length === 0) lines.push('', '_none_')
143
- for (const edge of state.edges) lines.push('- `' + edge.from + '` -[' + edge.kind + ']→ `' + edge.to + '`')
224
+ if (view.edges.length === 0) lines.push('', 'none')
225
+ for (const edge of view.edges) lines.push('- `' + edge.from + '` -[' + edge.kind + ']→ `' + edge.to + '`')
144
226
  lines.push('')
145
227
  return lines.join('\n')
146
228
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-fly/dsh-plugin-fly-engagement",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "files": [
package/tools/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools'
2
2
  import {
3
3
  ASSET_TYPES, EDGE_KINDS, FACT_KINDS, SEVERITIES,
4
- foldEvents, ledgerPath, nextId, renderReport, windowView,
4
+ foldEvents, ledgerPath, nextId, renderReport, scopeView, sessionOf,
5
5
  } from '../lib/fold.mjs'
6
6
 
7
7
  /**
@@ -95,6 +95,16 @@ function normalizeRecord(args) {
95
95
  }
96
96
 
97
97
  const workspaceParam = { type: 'string', required: true, description: 'Absolute workspace path; the ledger lives in <workspace>/.fly-engagement/events.jsonl' }
98
+ const sessionParam = { type: 'string', description: 'Session identity; defaults to the calling agent (exec.agent.id). Explicit value wins — used for backfill/migration.' }
99
+ const scopeParam = { type: 'string', enum: ['session', 'workspace'], description: 'session (default): only this session\'s chain; workspace: every session in this workspace.' }
100
+
101
+ /** 记录归属:显式 `session` 优先,否则取调用它的 agent(`ToolExecutionInput.agent.id` 就是会话身份)。 */
102
+ function resolveSession(args, exec) {
103
+ const explicit = typeof args.session === 'string' ? args.session.trim() : ''
104
+ if (explicit !== '') return explicit
105
+ const agentId = exec?.agent?.id
106
+ return typeof agentId === 'string' && agentId !== '' ? agentId : null
107
+ }
98
108
 
99
109
  export function apply(ctx) {
100
110
  const shell = ctx.shell
@@ -104,7 +114,8 @@ export function apply(ctx) {
104
114
  description: 'Record engagement evidence into the workspace ledger: goal (once), facts, findings (reproducible steps required), assets and edges. Returns deterministic ids for later reference.',
105
115
  parameters: {
106
116
  workspace: workspaceParam,
107
- goal: { type: 'object', additionalProperties: true, description: 'Once per engagement: { target, objective, authorization? }' },
117
+ session: sessionParam,
118
+ goal: { type: 'object', additionalProperties: true, description: 'Once per session: { target, objective, authorization? }' },
108
119
  facts: { type: 'array', items: { type: 'object', additionalProperties: true }, description: 'Facts: { kind?, target?, detail (required), confidence?, source? }' },
109
120
  findings: { type: 'array', items: { type: 'object', additionalProperties: true }, description: 'Findings: { title, severity?, description?, reproducibleSteps (required), affectedAssetId? }' },
110
121
  assets: { type: 'array', items: { type: 'object', additionalProperties: true }, description: 'Assets: { type, value, parentId?, meta? }' },
@@ -117,12 +128,14 @@ export function apply(ctx) {
117
128
  if (normalized.goal === null && normalized.facts.length === 0 && normalized.findings.length === 0 && normalized.assets.length === 0 && normalized.edges.length === 0) {
118
129
  throw argumentError('eng_record 一次要写至少一样东西(goal / facts / findings / assets / edges 全空)')
119
130
  }
131
+ const session = resolveSession(args, exec)
120
132
  const file = ledgerPath(args.workspace)
121
133
  const dir = file.slice(0, file.lastIndexOf('/'))
122
134
  await runCmd(shell, ['sh', '-c', 'mkdir -p ' + shq(dir)], exec)
123
135
  const existing = foldEvents((await readText(shell, file, exec)).split('\n'))
124
- if (normalized.goal !== null && existing.goal !== null) {
125
- throw argumentError('这个 engagement 已经有 goal 了(target=' + existing.goal.target + ')—— 一个 engagement 只有一个 goal;要重新开始请换一个工作区')
136
+ const existingGoal = existing.goals.find(row => row.session === session)?.goal ?? null
137
+ if (normalized.goal !== null && existingGoal !== null) {
138
+ throw argumentError('这个会话已经有 goal 了(target=' + existingGoal.target + ')—— 一个会话只有一个 goal;要重新开始请换一个会话')
126
139
  }
127
140
  const ids = new Set([...existing.facts.map(row => row.id), ...existing.findings.map(row => row.id), ...existing.assets.map(row => row.id)])
128
141
  const created = { facts: [], findings: [], assets: [], edges: [] }
@@ -154,8 +167,8 @@ export function apply(ctx) {
154
167
  created.edges.push({ ...edge })
155
168
  }
156
169
  const events = []
157
- if (normalized.goal !== null) events.push({ op: 'goal', at: new Date().toISOString(), goal: normalized.goal })
158
- events.push({ op: 'record', at: new Date().toISOString(), facts: created.facts, findings: created.findings, assets: created.assets, edges: created.edges })
170
+ if (normalized.goal !== null) events.push({ op: 'goal', at: new Date().toISOString(), session, goal: normalized.goal })
171
+ events.push({ op: 'record', at: new Date().toISOString(), session, facts: created.facts, findings: created.findings, assets: created.assets, edges: created.edges })
159
172
  await appendText(shell, file, events.map(event => JSON.stringify(event)).join('\n') + '\n', exec)
160
173
  return JSON.stringify({
161
174
  tool: 'eng_record',
@@ -165,12 +178,13 @@ export function apply(ctx) {
165
178
  created: { facts: created.facts.length, findings: created.findings.length, assets: created.assets.length, edges: created.edges.length },
166
179
  ids: { facts: created.facts.map(row => row.id), findings: created.findings.map(row => row.id), assets: created.assets.map(row => row.id) },
167
180
  goal: normalized.goal === null ? null : normalized.goal.target,
181
+ session,
168
182
  },
169
183
  confidence: 'observed',
170
184
  limitations: [
171
- '记录是 append-only 的:写错要改请换工作区重来(v1 不做改写/删除)',
172
- 'id 是确定性 <kind>-<n>(按会话内的记录次序),跨调用可引用;同一次调用内不能互相引用(先父后子)',
173
- 'ledger 在 <workspace>/.fly-engagement/events.jsonl —— 工作区换一个就是另一个 engagement',
185
+ '记录是 append-only 的:写错要改请换会话/工作区重来(v1 不做改写/删除)',
186
+ 'id 是确定性 <kind>-<n>(按账本的记录次序),跨调用可引用;同一次调用内不能互相引用(先父后子)',
187
+ 'ledger 在 <workspace>/.fly-engagement/events.jsonl —— 一个工作区一条账本,每行带 session 归属',
174
188
  ],
175
189
  }, null, 2)
176
190
  },
@@ -181,6 +195,8 @@ export function apply(ctx) {
181
195
  description: 'Read the engagement projection: goal, nodes, edges, counts, and the window bookkeeping (dropped/refused/truncated).',
182
196
  parameters: {
183
197
  workspace: workspaceParam,
198
+ session: sessionParam,
199
+ scope: scopeParam,
184
200
  window: { type: 'number', description: 'Node window per table (default 200)' },
185
201
  },
186
202
  output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] },
@@ -188,16 +204,17 @@ export function apply(ctx) {
188
204
  if (typeof args.workspace !== 'string' || args.workspace.trim() === '') throw argumentError('eng_state 需要 workspace(绝对路径)')
189
205
  const windowSize = Math.min(2000, Math.max(1, Math.round(Number(args.window ?? 200) || 200)))
190
206
  const file = ledgerPath(args.workspace)
191
- const view = windowView(foldEvents((await readText(shell, file, exec)).split('\n')), windowSize)
207
+ const view = scopeView(foldEvents((await readText(shell, file, exec)).split('\n')), { session: resolveSession(args, exec), scope: args.scope }, windowSize)
192
208
  return JSON.stringify({
193
209
  tool: 'eng_state',
194
210
  target: file,
195
- operation: 'fold the workspace ledger into the engagement projection (window=' + windowSize + ')',
211
+ operation: 'fold the workspace ledger into the ' + view.scope + ' projection (window=' + windowSize + ')',
196
212
  observed: view,
197
213
  confidence: 'observed',
198
214
  limitations: [
199
215
  '投影是**窗口视图**:每张表只留最新 ' + windowSize + ' 条;dropped=' + view.dropped + '(被逐出)refused=' + view.refused + '(引用不存在的 id 被拒,累计)',
200
216
  view.truncated ? '⚠️ truncated=true:被逐出的记录不在这个视图里 —— 完整记录以 eng_report(读全量)为准' : '这次没有截断(每张表都在窗口内)',
217
+ view.scope === 'session' ? '当前是会话视图;scope=workspace 可看整个工作区(workspaceCounts 已给出全量计数)' : '当前是整个工作区视图:多会话的记录合并在这一份里',
201
218
  'ledger 不存在 ⇒ 空 projection(还没开始记录,不是错误)',
202
219
  ],
203
220
  }, null, 2)
@@ -207,24 +224,81 @@ export function apply(ctx) {
207
224
  ctx.tools.register(defineTool({
208
225
  name: 'eng_report',
209
226
  description: 'Render the engagement report as Markdown (goal + authorization, assets, facts with source tools, findings with reproducible steps, counts).',
210
- parameters: { workspace: workspaceParam },
227
+ parameters: { workspace: workspaceParam, session: sessionParam, scope: scopeParam },
211
228
  output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] },
212
229
  async execute(args, exec) {
213
230
  if (typeof args.workspace !== 'string' || args.workspace.trim() === '') throw argumentError('eng_report 需要 workspace(绝对路径)')
214
231
  const file = ledgerPath(args.workspace)
215
232
  const state = foldEvents((await readText(shell, file, exec)).split('\n'))
233
+ const view = scopeView(state, { session: resolveSession(args, exec), scope: args.scope }, 2000)
216
234
  return JSON.stringify({
217
235
  tool: 'eng_report',
218
236
  target: file,
219
- operation: 'render the full engagement ledger as Markdown (no window)',
220
- observed: { markdown: renderReport(args.workspace, state), counts: { facts: state.facts.length, findings: state.findings.length, assets: state.assets.length, edges: state.edges.length }, refused: state.refused },
237
+ operation: 'render the ' + view.scope + ' engagement report as Markdown (no window)',
238
+ observed: { markdown: renderReport(args.workspace, view), counts: view.counts, workspaceCounts: view.workspaceCounts, refused: view.refused, scope: view.scope, session: view.session },
221
239
  confidence: 'observed',
222
240
  limitations: [
223
- '这份报告读的是**全量** ledger(不经窗口)—— 面板上的图是窗口视图,两者条数可能不同',
241
+ '这份报告读的是**当前作用域**的全量 ledger(不经窗口)—— 面板上的图是窗口视图,两者条数可能不同',
224
242
  'findings 的复现步骤原样列出(不重新编号内容、不排序)',
225
243
  'ledger 写在 <workspace>/.fly-engagement/events.jsonl;报告可以复制走,记录仍在工作区里',
226
244
  ],
227
245
  }, null, 2)
228
246
  },
229
247
  }))
248
+
249
+ ctx.tools.register(defineTool({
250
+ name: 'eng_adopt',
251
+ description: 'Claim v1 records that have no session (legacy, session=null) into the calling session. Records already owned by another session are skipped, never stolen.',
252
+ parameters: {
253
+ workspace: workspaceParam,
254
+ session: sessionParam,
255
+ all: { type: 'boolean', description: 'Adopt every unowned node (and the unowned goal). Default false.' },
256
+ ids: { type: 'array', items: { type: 'string' }, description: 'Explicit node ids to adopt (facts/findings/assets). Ignored when all=true.' },
257
+ goal: { type: 'boolean', description: 'Also adopt the unowned goal when this session has none.' },
258
+ },
259
+ output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] },
260
+ async execute(args, exec) {
261
+ if (typeof args.workspace !== 'string' || args.workspace.trim() === '') throw argumentError('eng_adopt 需要 workspace(绝对路径)')
262
+ const session = resolveSession(args, exec)
263
+ if (session === null) throw argumentError('eng_adopt 需要会话身份:显式传 session,或由 agent 调用(exec.agent.id)')
264
+ const file = ledgerPath(args.workspace)
265
+ const state = foldEvents((await readText(shell, file, exec)).split('\n'))
266
+ const unowned = [...state.facts, ...state.findings, ...state.assets].filter(row => (row.session ?? null) === null)
267
+ const ids = args.all === true ? unowned.map(row => row.id) : (Array.isArray(args.ids) ? args.ids : [])
268
+ const wantGoal = args.goal === true || args.all === true
269
+ if (ids.length === 0 && !wantGoal) throw argumentError('eng_adopt 没有可认领的东西(ids 为空且 goal 未请求)')
270
+ if (args.all !== true && unowned.length > 0 && ids.every(id => !unowned.some(row => row.id === id)) && !wantGoal) {
271
+ throw argumentError('给出的 ids 里没有未归属记录(已归属其他会话的不能认领)')
272
+ }
273
+ const event = { op: 'adopt', at: new Date().toISOString(), session, ids, goal: wantGoal }
274
+ await appendText(shell, file, JSON.stringify(event) + '\n', exec)
275
+ const after = foldEvents((await readText(shell, file, exec)).split('\n'))
276
+ const view = scopeView(after, { session, scope: 'session' }, 2000)
277
+ const delta = (before, afterRows) => before - afterRows
278
+ return JSON.stringify({
279
+ tool: 'eng_adopt',
280
+ target: file,
281
+ operation: 'append an adopt event (reassign unowned v1 records to this session)',
282
+ observed: {
283
+ session,
284
+ adopted: {
285
+ facts: delta(state.facts.filter(row => (row.session ?? null) === null).length, after.facts.filter(row => (row.session ?? null) === null).length),
286
+ findings: delta(state.findings.filter(row => (row.session ?? null) === null).length, after.findings.filter(row => (row.session ?? null) === null).length),
287
+ assets: delta(state.assets.filter(row => (row.session ?? null) === null).length, after.assets.filter(row => (row.session ?? null) === null).length),
288
+ edges: delta(state.edges.filter(row => (row.session ?? null) === null).length, after.edges.filter(row => (row.session ?? null) === null).length),
289
+ goal: delta(state.goals.filter(row => row.session === null).length, after.goals.filter(row => row.session === null).length),
290
+ },
291
+ counts: view.counts,
292
+ legacy: view.legacy,
293
+ skipped: after.skipped,
294
+ },
295
+ confidence: 'observed',
296
+ limitations: [
297
+ '认领是 append-only 的:写一条 adopt 事件,不改历史行',
298
+ '已归属其他会话的记录一律跳过(计入 skipped),不会被抢',
299
+ '未归属 goal 只会在本会话还没有 goal 时被认领',
300
+ ],
301
+ }, null, 2)
302
+ },
303
+ }))
230
304
  }