iterate-plugin 2.12.0 → 2.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools';
2
2
  import { loadEffectiveConfig, resolveProjectRootForExec } from "../config-loader.js";
3
+ import { runWithJob } from "../jobs.js";
3
4
  import { buildReviewPlan, buildReviewReport, sanitizeRounds, validateRoundsSchema, } from "../review.js";
4
5
  import { buildFinalReviewReport, metaReviewReport } from "../meta-review.js";
5
6
  import { evidenceToPlain, verifyFindings } from "../evidence.js";
@@ -65,6 +66,15 @@ export function registerReviewTool(ctx) {
65
66
  description: 'For `meta-review`: the ReviewReport JSON (as returned by `aggregate`) to audit for ' +
66
67
  'internal consistency and produce the final review report.',
67
68
  },
69
+ attachments: {
70
+ type: 'json',
71
+ description: 'Optional (plan only): image/visual attachments to thread into the review, e.g. ' +
72
+ '[{"path":"screens/hits.png","caption":"reproduced layout bug"}]. Each entry: ' +
73
+ '{path?, data?, media_type?, caption?} — path resolves relative to the project root, ' +
74
+ 'data is a base64 payload (media_type e.g. image/png), caption gives human context. ' +
75
+ 'Injected as a mandatory clause into every dimension reviewer prompt so screenshots/' +
76
+ 'mockups/failure repros are weighed alongside the code.',
77
+ },
68
78
  fixedCount: {
69
79
  type: 'integer',
70
80
  description: 'For `aggregate` (normal mode only): number of atomic fixes applied so far. ' +
@@ -107,134 +117,145 @@ export function registerReviewTool(ctx) {
107
117
  ],
108
118
  },
109
119
  async execute(args, exec) {
110
- const resolved = resolveProjectRootForExec(exec, args.path);
111
- if (!resolved.ok) {
112
- return { operation: args.operation, error: resolved.reason };
113
- }
114
- const projectRoot = resolved.root;
115
- // Effective config = defaults merged with project overrides. Never
116
- // null, so `plan`/`aggregate` work even without a config file.
117
- const { config } = loadEffectiveConfig(projectRoot);
118
- const mode = args.mode ?? 'dry-run';
119
- if (args.operation === 'plan') {
120
- const maxReviewRounds = args.maxReviewRounds ?? config.max_rounds ?? DEFAULT_MAX_REVIEW_ROUNDS;
121
- const knownIntentional = config.personalization
122
- ?.known_intentional;
123
- // changed-only scope: resolve the changed-file set against
124
- // git.target_branch before building the plan so reviewers get the
125
- // concrete file list (and the plan auto-falls back to full when there
126
- // are no changes). git failures degrade to a full-scope plan.
127
- let changedFiles;
128
- if (config.review?.scope === 'changed-only') {
129
- const gitScope = await resolveChangedFiles(projectRoot, config.git?.target_branch ?? 'main');
130
- changedFiles = gitScope.changedFiles;
120
+ const { result } = await runWithJob(ctx, 'iterate-review', `iterate_review ${String(args.operation ?? '')} (${String(args.mode ?? 'dry-run')})`, async () => {
121
+ const resolved = resolveProjectRootForExec(exec, args.path);
122
+ if (!resolved.ok) {
123
+ return { operation: args.operation, error: resolved.reason };
131
124
  }
132
- // Full-codebase review: pre-collect the source inventory so
133
- // buildReviewPlan can batch it into per-chunk reviewer tasks
134
- // (coverage enforcement).
135
- let scopeFiles;
136
- if (config.review?.scope === 'full') {
137
- scopeFiles = collectScopeFiles(projectRoot, { scope: 'full' });
138
- }
139
- const plan = buildReviewPlan({ config, mode, maxReviewRounds, knownIntentional, changedFiles, scopeFiles });
140
- return { operation: 'plan', mode, found: true, plan: plan };
141
- }
142
- if (args.operation === 'aggregate') {
143
- const rawRounds = Array.isArray(args.rounds) ? args.rounds : [];
144
- const rounds = rawRounds
145
- .map((r) => {
146
- const rr = r;
147
- const findings = Array.isArray(rr?.findings) ? rr.findings : [];
148
- const readFiles = Array.isArray(rr?.readFiles)
149
- ? rr.readFiles.filter((f) => typeof f === 'string')
125
+ const projectRoot = resolved.root;
126
+ // Effective config = defaults merged with project overrides. Never
127
+ // null, so `plan`/`aggregate` work even without a config file.
128
+ const { config } = loadEffectiveConfig(projectRoot);
129
+ const mode = args.mode ?? 'dry-run';
130
+ if (args.operation === 'plan') {
131
+ const maxReviewRounds = args.maxReviewRounds ?? config.max_rounds ?? DEFAULT_MAX_REVIEW_ROUNDS;
132
+ const knownIntentional = config.personalization
133
+ ?.known_intentional;
134
+ // changed-only scope: resolve the changed-file set against
135
+ // git.target_branch before building the plan so reviewers get the
136
+ // concrete file list (and the plan auto-falls back to full when there
137
+ // are no changes). git failures degrade to a full-scope plan.
138
+ let changedFiles;
139
+ if (config.review?.scope === 'changed-only') {
140
+ const gitScope = await resolveChangedFiles(projectRoot, config.git?.target_branch ?? 'main');
141
+ changedFiles = gitScope.changedFiles;
142
+ }
143
+ // Full-codebase review: pre-collect the source inventory so
144
+ // buildReviewPlan can batch it into per-chunk reviewer tasks
145
+ // (coverage enforcement).
146
+ let scopeFiles;
147
+ if (config.review?.scope === 'full') {
148
+ scopeFiles = collectScopeFiles(projectRoot, { scope: 'full' });
149
+ }
150
+ // Thread image/visual attachments (screenshots/mockups/failure repros)
151
+ // into the plan so every reviewer prompt weighs them alongside code.
152
+ const attachments = Array.isArray(args.attachments)
153
+ ? args.attachments.filter((a) => Boolean(a) &&
154
+ typeof a === 'object' &&
155
+ ((typeof a.path === 'string' && a.path.length > 0) ||
156
+ (typeof a.data === 'string' && a.data.length > 0)))
150
157
  : [];
151
- return { round: typeof rr?.round === 'number' ? rr.round : 0, findings, readFiles };
152
- })
153
- .filter((r) => r.round > 0);
154
- if (rounds.length === 0) {
158
+ const plan = buildReviewPlan({ config, mode, maxReviewRounds, knownIntentional, changedFiles, scopeFiles, attachments });
159
+ return { operation: 'plan', mode, found: true, plan: plan };
160
+ }
161
+ if (args.operation === 'aggregate') {
162
+ const rawRounds = Array.isArray(args.rounds) ? args.rounds : [];
163
+ const rounds = rawRounds
164
+ .map((r) => {
165
+ const rr = r;
166
+ const findings = Array.isArray(rr?.findings) ? rr.findings : [];
167
+ const readFiles = Array.isArray(rr?.readFiles)
168
+ ? rr.readFiles.filter((f) => typeof f === 'string')
169
+ : [];
170
+ return { round: typeof rr?.round === 'number' ? rr.round : 0, findings, readFiles };
171
+ })
172
+ .filter((r) => r.round > 0);
173
+ if (rounds.length === 0) {
174
+ return {
175
+ operation: 'aggregate',
176
+ mode,
177
+ error: 'rounds must be a non-empty array of {round, findings}.',
178
+ };
179
+ }
180
+ const maxReviewRounds = args.maxReviewRounds ?? config.max_rounds ?? DEFAULT_MAX_REVIEW_ROUNDS;
181
+ const goal = args.goal ?? config.goal ?? '';
182
+ const dimensions = config.dimensions ?? [];
183
+ // Output schema validation gate (reviewer.output_schema_validation,
184
+ // default true): validate every round's findings against the findings
185
+ // schema, then drop schema-invalid entries before the deterministic
186
+ // core so malformed reviewer output can never crash dedupe/sort or
187
+ // leak into fixes. The `schemaValidation` array is surfaced so the
188
+ // workflow can retry failing rounds (≤2 times) with a strict-JSON
189
+ // nudge. When disabled, non-object entries are still dropped for
190
+ // crash-safety.
191
+ const schemaEnabled = config.reviewer?.output_schema_validation !== false;
192
+ const schemaValidation = schemaEnabled ? validateRoundsSchema(rounds) : null;
193
+ const cleanRounds = sanitizeRounds(rounds, schemaValidation);
194
+ const report = buildReviewReport({
195
+ mode,
196
+ goal,
197
+ dimensions,
198
+ maxReviewRounds,
199
+ rounds: cleanRounds,
200
+ knownIntentional: args.knownIntentional,
201
+ fixedCount: typeof args.fixedCount === 'number' ? args.fixedCount : undefined,
202
+ });
155
203
  return {
156
204
  operation: 'aggregate',
157
205
  mode,
158
- error: 'rounds must be a non-empty array of {round, findings}.',
206
+ report: report,
207
+ schemaValidation: (schemaValidation ?? null),
159
208
  };
160
209
  }
161
- const maxReviewRounds = args.maxReviewRounds ?? config.max_rounds ?? DEFAULT_MAX_REVIEW_ROUNDS;
162
- const goal = args.goal ?? config.goal ?? '';
163
- const dimensions = config.dimensions ?? [];
164
- // Output schema validation gate (reviewer.output_schema_validation,
165
- // default true): validate every round's findings against the findings
166
- // schema, then drop schema-invalid entries before the deterministic
167
- // core so malformed reviewer output can never crash dedupe/sort or
168
- // leak into fixes. The `schemaValidation` array is surfaced so the
169
- // workflow can retry failing rounds (≤2 times) with a strict-JSON
170
- // nudge. When disabled, non-object entries are still dropped for
171
- // crash-safety.
172
- const schemaEnabled = config.reviewer?.output_schema_validation !== false;
173
- const schemaValidation = schemaEnabled ? validateRoundsSchema(rounds) : null;
174
- const cleanRounds = sanitizeRounds(rounds, schemaValidation);
175
- const report = buildReviewReport({
176
- mode,
177
- goal,
178
- dimensions,
179
- maxReviewRounds,
180
- rounds: cleanRounds,
181
- knownIntentional: args.knownIntentional,
182
- fixedCount: typeof args.fixedCount === 'number' ? args.fixedCount : undefined,
183
- });
184
- return {
185
- operation: 'aggregate',
186
- mode,
187
- report: report,
188
- schemaValidation: (schemaValidation ?? null),
189
- };
190
- }
191
- if (args.operation === 'meta-review') {
192
- const source = args.report;
193
- if (!source || typeof source !== 'object') {
210
+ if (args.operation === 'meta-review') {
211
+ const source = args.report;
212
+ if (!source || typeof source !== 'object') {
213
+ return {
214
+ operation: 'meta-review',
215
+ mode,
216
+ error: 'report must be a ReviewReport JSON object (as returned by `aggregate`).',
217
+ };
218
+ }
219
+ const audit = metaReviewReport(source);
220
+ // Hard code-evidence gate (default on): every finding's file/line is
221
+ // validated against real files on disk before folding into the final
222
+ // verdict. Disable via config `reviewer.evidence_validation: false`.
223
+ const evidenceEnabled = config.reviewer?.evidence_validation !== false;
224
+ const findings = Array.isArray(source.findings) ? source.findings : [];
225
+ const evidence = evidenceEnabled ? verifyFindings(projectRoot, findings) : null;
226
+ // Prompt-informative coverage: compare the reviewer's self-reported
227
+ // reads against the assigned scope inventory (never flips the
228
+ // verdict). Disable via config `reviewer.coverage_validation: false`.
229
+ const coverageEnabled = config.reviewer?.coverage_validation !== false;
230
+ let coverage = null;
231
+ if (coverageEnabled) {
232
+ const assigned = collectScopeFiles(projectRoot, {
233
+ scope: config.review?.scope === 'changed-only' ? 'changed-only' : 'full',
234
+ });
235
+ const readFiles = Array.isArray(source.readFiles)
236
+ ? source.readFiles
237
+ : null;
238
+ if (readFiles && readFiles.length > 0) {
239
+ coverage = computeCoverage(assigned, readFiles);
240
+ }
241
+ }
242
+ const finalReport = buildFinalReviewReport(source, { evidence, coverage });
194
243
  return {
195
244
  operation: 'meta-review',
196
245
  mode,
197
- error: 'report must be a ReviewReport JSON object (as returned by `aggregate`).',
246
+ found: true,
247
+ report: audit,
248
+ evidence: evidence ? evidenceToPlain(evidence) : null,
249
+ coverage: coverage ? coverageToDict(coverage) : null,
250
+ finalReport: finalReport,
198
251
  };
199
252
  }
200
- const audit = metaReviewReport(source);
201
- // Hard code-evidence gate (default on): every finding's file/line is
202
- // validated against real files on disk before folding into the final
203
- // verdict. Disable via config `reviewer.evidence_validation: false`.
204
- const evidenceEnabled = config.reviewer?.evidence_validation !== false;
205
- const findings = Array.isArray(source.findings) ? source.findings : [];
206
- const evidence = evidenceEnabled ? verifyFindings(projectRoot, findings) : null;
207
- // Prompt-informative coverage: compare the reviewer's self-reported
208
- // reads against the assigned scope inventory (never flips the
209
- // verdict). Disable via config `reviewer.coverage_validation: false`.
210
- const coverageEnabled = config.reviewer?.coverage_validation !== false;
211
- let coverage = null;
212
- if (coverageEnabled) {
213
- const assigned = collectScopeFiles(projectRoot, {
214
- scope: config.review?.scope === 'changed-only' ? 'changed-only' : 'full',
215
- });
216
- const readFiles = Array.isArray(source.readFiles)
217
- ? source.readFiles
218
- : null;
219
- if (readFiles && readFiles.length > 0) {
220
- coverage = computeCoverage(assigned, readFiles);
221
- }
222
- }
223
- const finalReport = buildFinalReviewReport(source, { evidence, coverage });
224
253
  return {
225
- operation: 'meta-review',
226
- mode,
227
- found: true,
228
- report: audit,
229
- evidence: evidence ? evidenceToPlain(evidence) : null,
230
- coverage: coverage ? coverageToDict(coverage) : null,
231
- finalReport: finalReport,
254
+ operation: args.operation,
255
+ error: `Unknown operation "${args.operation}". Use "plan", "aggregate", or "meta-review".`,
232
256
  };
233
- }
234
- return {
235
- operation: args.operation,
236
- error: `Unknown operation "${args.operation}". Use "plan", "aggregate", or "meta-review".`,
237
- };
257
+ });
258
+ return result;
238
259
  },
239
260
  }));
240
261
  }
package/lib/client.js CHANGED
@@ -1629,6 +1629,11 @@ function TriagePanel(props) {
1629
1629
  const t = ev.target;
1630
1630
  if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT")) return;
1631
1631
  if (t && typeof t.isContentEditable === "boolean" && t.isContentEditable) return;
1632
+ const rootEl = doc.querySelector('[data-iterate="triage"]');
1633
+ if (!rootEl) return;
1634
+ const activeEl = doc.activeElement;
1635
+ if (!activeEl) return;
1636
+ if (activeEl !== rootEl && !(typeof rootEl.contains === "function" && rootEl.contains(activeEl))) return;
1632
1637
  const verdict = keyToVerdict(ev.key);
1633
1638
  if (verdict && selected !== null && indices.includes(selected)) {
1634
1639
  ev.preventDefault();
@@ -2146,6 +2151,7 @@ function ObservatoryPanel(props) {
2146
2151
  const [tab, setTab] = React.useState("live");
2147
2152
  const [expandedThreads, setExpandedThreads] = React.useState(/* @__PURE__ */ new Set());
2148
2153
  const [copiedKey, setCopiedKey] = React.useState(null);
2154
+ const [copyFailText, setCopyFailText] = React.useState(null);
2149
2155
  const [nudgeText, setNudgeText] = React.useState("");
2150
2156
  const [timelineType, setTimelineType] = React.useState("");
2151
2157
  const [timelineSearch, setTimelineSearch] = React.useState("");
@@ -2156,7 +2162,10 @@ function ObservatoryPanel(props) {
2156
2162
  const copyInstruction = (key, text) => {
2157
2163
  if (!text) return;
2158
2164
  copyText(text).then((ok) => {
2159
- if (!ok) return;
2165
+ if (!ok) {
2166
+ setCopyFailText(text);
2167
+ return;
2168
+ }
2160
2169
  setCopiedKey(key);
2161
2170
  if (copyTimer.current) clearTimeout(copyTimer.current);
2162
2171
  copyTimer.current = setTimeout(() => setCopiedKey((cur) => cur === key ? null : cur), 1600);
@@ -2446,6 +2455,11 @@ ${JSON.stringify({
2446
2455
 
2447
2456
  \`\`\`json
2448
2457
  ${JSON.stringify({ operation: "nudge", text: nudgeText }, null, 2)}
2458
+ \`\`\``;
2459
+ const activeClearInstruction = `\u8BF7\u8C03\u7528 \`iterate_transcript\` \u6E05\u9664\u5F53\u524D nudge\uFF1A
2460
+
2461
+ \`\`\`json
2462
+ ${JSON.stringify({ operation: "nudge", text: null }, null, 2)}
2449
2463
  \`\`\``;
2450
2464
  return React.createElement(
2451
2465
  "div",
@@ -2465,7 +2479,12 @@ ${JSON.stringify({ operation: "nudge", text: nudgeText }, null, 2)}
2465
2479
  { className: "iterate-obs-bar", style: { marginBottom: 6 } },
2466
2480
  React.createElement("b", {}, "\u5F53\u524D nudge"),
2467
2481
  React.createElement("span", { className: "iterate-obs-msg" }, activeNudgeText),
2468
- React.createElement("button", { className: "iterate-btn", onClick: () => setNudgeText("") }, "\u6E05\u9664")
2482
+ React.createElement("button", {
2483
+ className: "iterate-btn",
2484
+ "data-copied": copiedKey === "nudge-clear" ? "" : void 0,
2485
+ onClick: () => copyInstruction("nudge-clear", activeClearInstruction),
2486
+ title: "\u590D\u5236\u6E05\u9664 nudge \u6307\u4EE4\uFF08\u5199 text:null\uFF09\uFF0C\u5728\u8FD0\u884C\u63A7\u5236\u53F0\u63D0\u793A\u6E05\u9664\u5DF2\u6301\u4E45\u5316\u7684 nudge"
2487
+ }, copiedKey === "nudge-clear" ? "\u5DF2\u590D\u5236\u6E05\u9664\u6307\u4EE4" : "\u590D\u5236\u6E05\u9664\u6307\u4EE4")
2469
2488
  ) : null,
2470
2489
  React.createElement("textarea", {
2471
2490
  className: "iterate-obs-input iterate-obs-mono",
@@ -2559,6 +2578,21 @@ ${JSON.stringify({ operation: "nudge", text: nudgeText }, null, 2)}
2559
2578
  const renderLive = () => {
2560
2579
  const liveEntries = manifest.live || [];
2561
2580
  if (liveEntries.length === 0) {
2581
+ const hasThreads = (manifest.rounds || []).length > 0;
2582
+ const hasFindings = (manifest.findings || []).length > 0;
2583
+ if (hasThreads || hasFindings) {
2584
+ return React.createElement(
2585
+ "div",
2586
+ { className: "iterate-obs-empty" },
2587
+ React.createElement("div", {}, "\u5B9E\u65F6\u6D3B\u52A8\u6D41\u4EC5\u5728\u8FD0\u884C\u671F\u95F4\u8BB0\u5F55\uFF0C\u5F53\u524D\u5DF2\u5B8C\u6210\u3002"),
2588
+ React.createElement(
2589
+ "div",
2590
+ { className: "iterate-obs-bar", style: { marginTop: 8 } },
2591
+ React.createElement("button", { className: "iterate-btn", onClick: () => setTab("f1") }, "\u67E5\u770B\u5BA1\u67E5\u7EBF\u7A0B"),
2592
+ React.createElement("button", { className: "iterate-btn", onClick: () => setTab("f3") }, "\u67E5\u770B\u53D1\u73B0")
2593
+ )
2594
+ );
2595
+ }
2562
2596
  return React.createElement("div", { className: "iterate-obs-empty" }, "\u6682\u65E0\u5B9E\u65F6\u6D3B\u52A8");
2563
2597
  }
2564
2598
  const rows = liveEntries.map((e, i) => {
@@ -2631,6 +2665,23 @@ ${JSON.stringify({ operation: "nudge", text: nudgeText }, null, 2)}
2631
2665
  open ? React.createElement(
2632
2666
  "div",
2633
2667
  {},
2668
+ copyFailText ? React.createElement(
2669
+ "div",
2670
+ { className: "iterate-obs-block" },
2671
+ React.createElement(
2672
+ "div",
2673
+ { className: "iterate-obs-block-head" },
2674
+ React.createElement("b", {}, "\u590D\u5236\u672A\u6210\u529F"),
2675
+ React.createElement("span", { className: "iterate-obs-head-meta" }, "\u526A\u8D34\u677F\u5199\u5165\u88AB\u62D2\u7EDD\uFF0C\u8BF7\u624B\u52A8\u9009\u4E2D\u4E0B\u65B9\u6307\u4EE4\u590D\u5236"),
2676
+ React.createElement("button", {
2677
+ className: "iterate-btn",
2678
+ "data-ghost": "",
2679
+ "aria-label": "\u5173\u95ED\u590D\u5236\u5931\u8D25\u63D0\u793A",
2680
+ onClick: () => setCopyFailText(null)
2681
+ }, "\u5173\u95ED")
2682
+ ),
2683
+ React.createElement("div", { className: "iterate-obs-code" }, copyFailText)
2684
+ ) : null,
2634
2685
  React.createElement(
2635
2686
  "div",
2636
2687
  { className: "iterate-obs-tabs" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-plugin",
3
- "version": "2.12.0",
3
+ "version": "2.12.2",
4
4
  "description": "dsh plugin that turns the iterate skill into an autonomous closed-loop harness: plan -> parallel review xN -> atomic fixes -> validate -> loop -> auto-stop, plus a dry-run pure-review mode with multi-round convergence and a meta-review that audits the report and emits a final review report.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -61,11 +61,12 @@
61
61
  },
62
62
  "dependencies": {
63
63
  "@deepseek-ai/cordis": "4.0.1",
64
- "@deepseek-ai/dsh-tools": "0.1.1-rc.1",
64
+ "@deepseek-ai/dsh-tools": "0.1.1-rc.2",
65
65
  "js-yaml": "4.3.1"
66
66
  },
67
67
  "devDependencies": {
68
- "@deepseek-ai/dsh-session": "0.1.1-rc.1",
68
+ "@deepseek-ai/dsh-jobs": "^0.1.1-rc.2",
69
+ "@deepseek-ai/dsh-session": "0.1.1-rc.2",
69
70
  "@types/js-yaml": "4.0.9",
70
71
  "@types/node": "22.15.0",
71
72
  "@types/react": "19.2.2",
@@ -967,6 +967,14 @@ function TriagePanel(props: SlotProps) {
967
967
  const t = ev.target as HTMLElement | null
968
968
  if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return
969
969
  if (t && typeof t.isContentEditable === 'boolean' && t.isContentEditable) return
970
+ // Only act when focus is INSIDE the triage panel. Everything else keeps
971
+ // standard behavior (↑/↓ page scroll, typing in the composer), so we do
972
+ // not hijack document-level keys just because a report is mounted.
973
+ const rootEl = doc.querySelector('[data-iterate="triage"]')
974
+ if (!rootEl) return
975
+ const activeEl = doc.activeElement as HTMLElement | null
976
+ if (!activeEl) return
977
+ if (activeEl !== rootEl && !(typeof rootEl.contains === 'function' && rootEl.contains(activeEl))) return
970
978
  const verdict = keyToVerdict(ev.key)
971
979
  if (verdict && selected !== null && indices.includes(selected)) {
972
980
  ev.preventDefault()
@@ -1466,6 +1474,10 @@ function ObservatoryPanel(props: SlotProps) {
1466
1474
  const [tab, setTab] = React.useState('live')
1467
1475
  const [expandedThreads, setExpandedThreads] = React.useState<Set<string>>(new Set())
1468
1476
  const [copiedKey, setCopiedKey] = React.useState<string | null>(null)
1477
+ // When a clipboard write is blocked (permissions/unsupported), reveal the
1478
+ // raw instruction text so the user can copy it manually instead of silently
1479
+ // dropping the action (mirrors the triage/settings fallback).
1480
+ const [copyFailText, setCopyFailText] = React.useState<string | null>(null)
1469
1481
  const [nudgeText, setNudgeText] = React.useState('')
1470
1482
  const [timelineType, setTimelineType] = React.useState('')
1471
1483
  const [timelineSearch, setTimelineSearch] = React.useState('')
@@ -1478,7 +1490,7 @@ function ObservatoryPanel(props: SlotProps) {
1478
1490
  const copyInstruction = (key: string, text: string) => {
1479
1491
  if (!text) return
1480
1492
  copyText(text).then((ok) => {
1481
- if (!ok) return
1493
+ if (!ok) { setCopyFailText(text); return }
1482
1494
  setCopiedKey(key)
1483
1495
  if (copyTimer.current) clearTimeout(copyTimer.current)
1484
1496
  copyTimer.current = setTimeout(() => setCopiedKey((cur) => (cur === key ? null : cur)), 1600)
@@ -1732,6 +1744,10 @@ function ObservatoryPanel(props: SlotProps) {
1732
1744
  const present = Boolean(nudge && nudge.text)
1733
1745
  const activeNudgeText = nudge && nudge.text ? nudge.text : ''
1734
1746
  const nudgeInstruction = `请调用 \`iterate_transcript\` 写入 nudge 指令:\n\n\`\`\`json\n${JSON.stringify({ operation: 'nudge', text: nudgeText }, null, 2)}\n\`\`\``
1747
+ // Clearing the PERSISTED nudge (as opposed to the draft) requires
1748
+ // text:null. Copied as a paste-able instruction, mirroring the panel's
1749
+ // copy-to-command pattern — it cannot be cleared from the client directly.
1750
+ const activeClearInstruction = `请调用 \`iterate_transcript\` 清除当前 nudge:\n\n\`\`\`json\n${JSON.stringify({ operation: 'nudge', text: null }, null, 2)}\n\`\`\``
1735
1751
  return React.createElement('div', { className: 'iterate-obs-block' },
1736
1752
  React.createElement('div', { className: 'iterate-obs-block-head' },
1737
1753
  React.createElement('span', {}, '运行控制台'),
@@ -1743,7 +1759,11 @@ function ObservatoryPanel(props: SlotProps) {
1743
1759
  ? React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 6 } },
1744
1760
  React.createElement('b', {}, '当前 nudge'),
1745
1761
  React.createElement('span', { className: 'iterate-obs-msg' }, activeNudgeText),
1746
- React.createElement('button', { className: 'iterate-btn', onClick: () => setNudgeText('') }, '清除'),
1762
+ React.createElement('button', {
1763
+ className: 'iterate-btn', 'data-copied': copiedKey === 'nudge-clear' ? '' : undefined,
1764
+ onClick: () => copyInstruction('nudge-clear', activeClearInstruction),
1765
+ title: '复制清除 nudge 指令(写 text:null),在运行控制台提示清除已持久化的 nudge',
1766
+ }, copiedKey === 'nudge-clear' ? '已复制清除指令' : '复制清除指令'),
1747
1767
  )
1748
1768
  : null,
1749
1769
  React.createElement('textarea', {
@@ -1828,6 +1848,20 @@ function ObservatoryPanel(props: SlotProps) {
1828
1848
  const renderLive = () => {
1829
1849
  const liveEntries = manifest.live || []
1830
1850
  if (liveEntries.length === 0) {
1851
+ // Live entries only accumulate during an ACTIVE run. For a completed run
1852
+ // (or before any capture) this tab is empty — guide the user to the
1853
+ // populated summary tabs instead of leaving a dead-end "暂无实时活动" state.
1854
+ const hasThreads = (manifest.rounds || []).length > 0
1855
+ const hasFindings = (manifest.findings || []).length > 0
1856
+ if (hasThreads || hasFindings) {
1857
+ return React.createElement('div', { className: 'iterate-obs-empty' },
1858
+ React.createElement('div', {}, '实时活动流仅在运行期间记录,当前已完成。'),
1859
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginTop: 8 } },
1860
+ React.createElement('button', { className: 'iterate-btn', onClick: () => setTab('f1') }, '查看审查线程'),
1861
+ React.createElement('button', { className: 'iterate-btn', onClick: () => setTab('f3') }, '查看发现'),
1862
+ ),
1863
+ )
1864
+ }
1831
1865
  return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无实时活动')
1832
1866
  }
1833
1867
  const rows = liveEntries.map((e, i) => {
@@ -1880,6 +1914,19 @@ function ObservatoryPanel(props: SlotProps) {
1880
1914
  ),
1881
1915
  open
1882
1916
  ? React.createElement('div', {},
1917
+ copyFailText
1918
+ ? React.createElement('div', { className: 'iterate-obs-block' },
1919
+ React.createElement('div', { className: 'iterate-obs-block-head' },
1920
+ React.createElement('b', {}, '复制未成功'),
1921
+ React.createElement('span', { className: 'iterate-obs-head-meta' }, '剪贴板写入被拒绝,请手动选中下方指令复制'),
1922
+ React.createElement('button', {
1923
+ className: 'iterate-btn', 'data-ghost': '', 'aria-label': '关闭复制失败提示',
1924
+ onClick: () => setCopyFailText(null),
1925
+ }, '关闭'),
1926
+ ),
1927
+ React.createElement('div', { className: 'iterate-obs-code' }, copyFailText),
1928
+ )
1929
+ : null,
1883
1930
  React.createElement('div', { className: 'iterate-obs-tabs' },
1884
1931
  ...OBS_TABS.map((t) =>
1885
1932
  React.createElement('button', {
package/src/jobs.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * src/jobs.ts — dsh Job Panel integration for iterate tool executions.
3
+ *
4
+ * dsh's background-job registry (`ctx.jobs`, @deepseek-ai/dsh-jobs) lets
5
+ * plugins surface long-running work in the client's Job Panel
6
+ * (`conversation.session.header.actions` list). We register custom kinds via
7
+ * declaration merging and wrap tool executions so each `iterate_review` /
8
+ * `iterate_fix` call shows up as a tracked job (running -> completed/failed).
9
+ *
10
+ * Defensive by design (matches the plugin's overall philosophy):
11
+ * - `ctx.jobs` only exists when the dsh host loaded a job registry + a
12
+ * controller serves the calling owner (`@deepseek-ai/dsh-tool-jobs` or an
13
+ * equivalent). When it is missing, `start()` throws or is absent — we
14
+ * detect both and fall through to plain execution, so the Job Panel is a
15
+ * pure enhancement and never breaks a tool call.
16
+ * - The registry is memory-only and panel rows are read-only (no progress
17
+ * updates), so these jobs are completion records, not control channels.
18
+ */
19
+
20
+ import type { JobOutcome, JobRegistry } from '@deepseek-ai/dsh-jobs'
21
+
22
+ /** Extend dsh's producer-kind registry with iterate's custom kinds. */
23
+ declare module '@deepseek-ai/dsh-jobs' {
24
+ interface JobKindMap {
25
+ 'iterate-review': 'iterate-review'
26
+ 'iterate-fix': 'iterate-fix'
27
+ }
28
+ }
29
+
30
+ /** Custom job kinds this plugin registers. */
31
+ export type IterateJobKind = 'iterate-review' | 'iterate-fix'
32
+
33
+ /** Shape of the `ctx.jobs` surface we rely on (duck-typed for safety). */
34
+ interface JobsLike {
35
+ start(spec: {
36
+ kind: IterateJobKind
37
+ label: string
38
+ run(): { done: Promise<JobOutcome>; cancel?: () => void }
39
+ }): string
40
+ }
41
+
42
+ /**
43
+ * Run `fn` wrapped in a dsh background job, settling it completed/failed
44
+ * with the execution's outcome. When the host exposes no job registry (or
45
+ * refuses the start), `fn` runs untouched and `null` is returned — the Job
46
+ * Panel is an enhancement, never a dependency.
47
+ *
48
+ * @param ctx the dsh plugin context (may or may not expose `jobs`).
49
+ * @param kind iterate job kind registered via {@link IterateJobKind}.
50
+ * @param label one-line job label shown in the panel.
51
+ * @param fn the tool execution to track.
52
+ * @returns the registry-issued job id, or `null` when unavailable.
53
+ */
54
+ export async function runWithJob<T>(
55
+ ctx: unknown,
56
+ kind: IterateJobKind,
57
+ label: string,
58
+ fn: () => Promise<T> | T,
59
+ ): Promise<{ result: T; jobId: string | null }> {
60
+ const jobs = (ctx as { jobs?: JobsLike } | undefined)?.jobs
61
+ if (!jobs || typeof jobs.start !== 'function') {
62
+ return { result: await fn(), jobId: null }
63
+ }
64
+
65
+ let settle!: (outcome: JobOutcome) => void
66
+ const done = new Promise<JobOutcome>((resolve) => {
67
+ settle = resolve
68
+ })
69
+
70
+ let jobId: string | null = null
71
+ try {
72
+ jobId = jobs.start({
73
+ kind,
74
+ label,
75
+ run: () => ({
76
+ done,
77
+ cancel: () => settle({ status: 'killed', detail: 'cancelled' }),
78
+ }),
79
+ })
80
+ } catch {
81
+ // Registry present but refuses work (e.g. no controller serves this
82
+ // owner) — run without panel tracking.
83
+ return { result: await fn(), jobId: null }
84
+ }
85
+
86
+ try {
87
+ const result = await fn()
88
+ settle({ status: 'completed', detail: 'done' })
89
+ return { result, jobId }
90
+ } catch (error) {
91
+ settle({
92
+ status: 'failed',
93
+ detail: error instanceof Error ? error.message : 'execution failed',
94
+ })
95
+ throw error
96
+ }
97
+ }