@ckanner/dsh-plugin-flywheel 0.28.0 → 0.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -1,205 +1,732 @@
1
1
  window.__ModuleLoader__.load({id:"@ckanner/dsh-plugin-flywheel",factory:(require)=>{
2
2
  const module={exports:{}};const exports=module.exports;
3
- // The build wraps this file in rc.5's browser ModuleLoader factory.
3
+ // The build wraps this file in DSH's lazy browser ModuleLoader factory.
4
+ // React is the only runtime module dependency. External plain-JS plugins must
5
+ // not import Harness Client packages (DSH practices, b830ec950faba0a46dda564be3e2d854afed94f1).
4
6
  const React = require('react');
5
7
  const h = React.createElement;
8
+ const NS = 'flywheel-ui';
9
+ // build.mjs supplies scoped CSS; React owns its style element inside this view.
10
+ const css = {"actions":"flywheel_actions_K2PPkW","advanced":"flywheel_advanced_K2PPkW","advancedBody":"flywheel_advancedBody_K2PPkW","badge":"flywheel_badge_K2PPkW","binding":"flywheel_binding_K2PPkW","bindingHeader":"flywheel_bindingHeader_K2PPkW","bindingTitle":"flywheel_bindingTitle_K2PPkW","bindings":"flywheel_bindings_K2PPkW","budget":"flywheel_budget_K2PPkW","button":"flywheel_button_K2PPkW","buttonOutline":"flywheel_buttonOutline_K2PPkW","buttonPrimary":"flywheel_buttonPrimary_K2PPkW","buttonSmall":"flywheel_buttonSmall_K2PPkW","check":"flywheel_check_K2PPkW","checkBody":"flywheel_checkBody_K2PPkW","checkState":"flywheel_checkState_K2PPkW","checks":"flywheel_checks_K2PPkW","details":"flywheel_details_K2PPkW","empty":"flywheel_empty_K2PPkW","error":"flywheel_error_K2PPkW","errorText":"flywheel_errorText_K2PPkW","feedback":"flywheel_feedback_K2PPkW","field":"flywheel_field_K2PPkW","fieldLabel":"flywheel_fieldLabel_K2PPkW","fields":"flywheel_fields_K2PPkW","flywheel-dash":"flywheel_flywheel-dash_K2PPkW","flywheel-spin":"flywheel_flywheel-spin_K2PPkW","header":"flywheel_header_K2PPkW","heading":"flywheel_heading_K2PPkW","idleFeedback":"flywheel_idleFeedback_K2PPkW","input":"flywheel_input_K2PPkW","inputShell":"flywheel_inputShell_K2PPkW","loading":"flywheel_loading_K2PPkW","loadingCompact":"flywheel_loadingCompact_K2PPkW","meta":"flywheel_meta_K2PPkW","notice":"flywheel_notice_K2PPkW","packets":"flywheel_packets_K2PPkW","page":"flywheel_page_K2PPkW","path":"flywheel_path_K2PPkW","properties":"flywheel_properties_K2PPkW","raw":"flywheel_raw_K2PPkW","section":"flywheel_section_K2PPkW","sectionHeader":"flywheel_sectionHeader_K2PPkW","selection":"flywheel_selection_K2PPkW","spinner":"flywheel_spinner_K2PPkW","spinnerArc":"flywheel_spinnerArc_K2PPkW","spinnerMotion":"flywheel_spinnerMotion_K2PPkW","spinnerTrack":"flywheel_spinnerTrack_K2PPkW","textInput":"flywheel_textInput_K2PPkW","title":"flywheel_title_K2PPkW","warningText":"flywheel_warningText_K2PPkW"};
11
+ const stylesheet = ".flywheel_page_K2PPkW {\n box-sizing: border-box;\n width: 100%;\n min-width: 0;\n max-width: 1120px;\n color: var(--dsw-alias-label-primary);\n overflow-wrap: anywhere;\n margin: 0 auto;\n padding: 24px clamp(16px, 3vw, 32px) 32px;\n font-family: inherit;\n font-size: 13px;\n line-height: 20px;\n}\n\n.flywheel_page_K2PPkW p, .flywheel_page_K2PPkW h2, .flywheel_page_K2PPkW h3, .flywheel_page_K2PPkW h4 {\n margin: 0;\n}\n\n.flywheel_page_K2PPkW code, .flywheel_raw_K2PPkW {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: inherit;\n}\n\n.flywheel_header_K2PPkW {\n gap: 4px;\n margin-bottom: 24px;\n display: grid;\n}\n\n.flywheel_title_K2PPkW {\n font-size: 18px;\n font-weight: 500;\n line-height: 28px;\n}\n\n.flywheel_heading_K2PPkW {\n font-size: 14px;\n font-weight: 500;\n line-height: 22px;\n}\n\n.flywheel_meta_K2PPkW {\n color: var(--dsw-alias-label-secondary);\n}\n\n.flywheel_selection_K2PPkW {\n gap: 10px;\n display: grid;\n}\n\n.flywheel_fields_K2PPkW {\n grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));\n gap: 16px;\n margin-bottom: 2px;\n display: grid;\n}\n\n.flywheel_field_K2PPkW {\n align-content: start;\n gap: 6px;\n min-width: 0;\n display: grid;\n}\n\n.flywheel_fieldLabel_K2PPkW {\n font-weight: 500;\n}\n\n.flywheel_button_K2PPkW {\n box-sizing: border-box;\n min-height: 36px;\n color: var(--dsw-alias-label-primary);\n cursor: pointer;\n background: none;\n border: none;\n border-radius: 12px;\n justify-content: center;\n align-items: center;\n gap: 4px;\n padding: 0 14px;\n font-family: inherit;\n font-size: 14px;\n font-weight: 400;\n line-height: 22px;\n display: inline-flex;\n}\n\n.flywheel_button_K2PPkW:disabled {\n opacity: .4;\n cursor: not-allowed;\n}\n\n.flywheel_button_K2PPkW:focus-visible {\n outline: 2px solid var(--dsw-alias-state-business-primary);\n outline-offset: 2px;\n}\n\n.flywheel_buttonSmall_K2PPkW {\n border-radius: 8px;\n min-height: 28px;\n padding: 0 10px;\n font-size: 12px;\n line-height: 18px;\n}\n\n.flywheel_buttonPrimary_K2PPkW {\n background: var(--dsw-alias-button-primary-fill);\n color: var(--dsw-alias-label-primary-foreground);\n}\n\n.flywheel_buttonOutline_K2PPkW {\n border: .5px solid var(--dsw-alias-border-l3);\n}\n\n@media (hover: hover) {\n .flywheel_buttonPrimary_K2PPkW:hover:not(:disabled) {\n background: var(--dsw-alias-button-primary-hover);\n }\n\n .flywheel_buttonOutline_K2PPkW:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n }\n}\n\n.flywheel_inputShell_K2PPkW {\n border: .5px solid var(--dsw-alias-border-l4);\n background: var(--dsw-alias-bg-layer-1);\n border-radius: 12px;\n align-items: center;\n gap: 6px;\n height: 32px;\n padding: 0 8px;\n display: inline-flex;\n}\n\n.flywheel_inputShell_K2PPkW:focus-within {\n border-color: var(--dsw-alias-state-business-primary);\n}\n\n.flywheel_inputShell_K2PPkW[data-disabled=\"true\"] {\n opacity: .4;\n cursor: not-allowed;\n}\n\n.flywheel_textInput_K2PPkW {\n min-width: 0;\n color: var(--dsw-alias-label-primary);\n background: none;\n border: none;\n outline: none;\n flex: 1;\n font-family: inherit;\n font-size: 14px;\n line-height: 22px;\n}\n\n.flywheel_textInput_K2PPkW::placeholder {\n color: var(--dsw-alias-label-secondary);\n}\n\n.flywheel_textInput_K2PPkW:disabled {\n cursor: not-allowed;\n}\n\n.flywheel_input_K2PPkW {\n box-sizing: border-box;\n width: 100%;\n min-width: 0;\n}\n\n.flywheel_input_K2PPkW input {\n font-size: 13px;\n line-height: 20px;\n}\n\n@media (pointer: coarse) {\n .flywheel_input_K2PPkW input {\n font-size: 16px;\n }\n}\n\n.flywheel_actions_K2PPkW {\n flex-wrap: wrap;\n align-items: center;\n gap: 8px;\n display: flex;\n}\n\n.flywheel_actions_K2PPkW > button {\n overflow-wrap: anywhere;\n max-width: 100%;\n height: auto;\n min-height: 28px;\n padding-top: 4px;\n padding-bottom: 4px;\n}\n\n.flywheel_feedback_K2PPkW {\n box-sizing: border-box;\n border: 1px solid #0000;\n border-radius: 8px;\n min-width: 0;\n height: 176px;\n margin-top: 12px;\n padding: 12px;\n scroll-padding: 12px;\n overflow: auto;\n}\n\n@media (width <= 600px) {\n .flywheel_feedback_K2PPkW {\n height: 224px;\n }\n}\n\n.flywheel_feedback_K2PPkW[data-state=\"uncertain\"] {\n border-color: var(--dsw-alias-state-warn-primary);\n background: var(--dsw-alias-state-warn-tertiary);\n}\n\n.flywheel_feedback_K2PPkW[data-state=\"error\"] {\n border-color: var(--dsw-alias-state-error-primary);\n background: var(--dsw-alias-bg-layer-1);\n}\n\n.flywheel_feedback_K2PPkW:focus-visible {\n outline: 2px solid var(--dsw-alias-state-business-primary);\n outline-offset: 2px;\n}\n\n.flywheel_idleFeedback_K2PPkW {\n height: 100%;\n color: var(--dsw-alias-label-secondary);\n align-content: center;\n gap: 8px;\n display: grid;\n}\n\n.flywheel_loading_K2PPkW {\n text-align: center;\n flex-direction: column;\n justify-content: center;\n align-items: center;\n gap: 8px;\n min-height: 100%;\n display: flex;\n}\n\n.flywheel_loadingCompact_K2PPkW {\n justify-content: center;\n align-items: center;\n padding-bottom: 8px;\n display: flex;\n}\n\n.flywheel_spinner_K2PPkW {\n color: var(--dsw-alias-label-tertiary);\n flex: none;\n}\n\n.flywheel_spinnerMotion_K2PPkW {\n transform-origin: center;\n animation: 1.5s linear infinite flywheel_flywheel-spin_K2PPkW;\n}\n\n.flywheel_spinnerTrack_K2PPkW, .flywheel_spinnerArc_K2PPkW {\n fill: none;\n stroke: currentColor;\n stroke-width: 2px;\n stroke-linecap: round;\n}\n\n.flywheel_spinnerTrack_K2PPkW {\n opacity: .25;\n}\n\n.flywheel_spinnerArc_K2PPkW {\n stroke-dasharray: 12 150;\n animation: 1.5s ease-in-out infinite flywheel_flywheel-dash_K2PPkW;\n}\n\n@keyframes flywheel_flywheel-spin_K2PPkW {\n to {\n transform: rotate(360deg);\n }\n}\n\n@keyframes flywheel_flywheel-dash_K2PPkW {\n 0%, 100% {\n stroke-dasharray: 12 150;\n stroke-dashoffset: 0;\n }\n\n 50% {\n stroke-dasharray: 24 150;\n stroke-dashoffset: -6px;\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .flywheel_spinnerMotion_K2PPkW, .flywheel_spinnerArc_K2PPkW {\n animation: none;\n }\n\n .flywheel_spinnerArc_K2PPkW {\n stroke-dasharray: 18 150;\n stroke-dashoffset: -3px;\n }\n}\n\n.flywheel_notice_K2PPkW, .flywheel_error_K2PPkW {\n justify-items: start;\n gap: 8px;\n min-width: 0;\n display: grid;\n}\n\n.flywheel_notice_K2PPkW + .flywheel_error_K2PPkW {\n margin-top: 16px;\n}\n\n.flywheel_error_K2PPkW > h3, .flywheel_errorText_K2PPkW {\n color: var(--dsw-alias-state-error-primary);\n}\n\n.flywheel_warningText_K2PPkW {\n border-left: 1px solid var(--dsw-alias-state-warn-primary);\n color: var(--dsw-alias-label-secondary);\n padding-left: 8px;\n}\n\n.flywheel_path_K2PPkW {\n white-space: pre-wrap;\n min-width: 0;\n max-width: 100%;\n}\n\n.flywheel_section_K2PPkW, .flywheel_empty_K2PPkW {\n gap: 8px;\n min-width: 0;\n margin-top: 24px;\n display: grid;\n}\n\n.flywheel_empty_K2PPkW {\n padding: 20px 0;\n}\n\n.flywheel_sectionHeader_K2PPkW, .flywheel_bindingHeader_K2PPkW {\n flex-wrap: wrap;\n justify-content: space-between;\n align-items: baseline;\n gap: 6px 16px;\n min-width: 0;\n display: flex;\n}\n\n.flywheel_sectionHeader_K2PPkW > *, .flywheel_bindingHeader_K2PPkW > * {\n min-width: 0;\n}\n\n.flywheel_badge_K2PPkW {\n background: var(--dsw-alias-bg-layer-2);\n color: var(--dsw-alias-label-secondary);\n border-radius: 8px;\n padding: 2px 8px;\n}\n\n.flywheel_packets_K2PPkW {\n table-layout: fixed;\n border-collapse: collapse;\n text-align: left;\n width: 100%;\n margin-top: 8px;\n}\n\n.flywheel_packets_K2PPkW caption {\n text-align: left;\n color: var(--dsw-alias-label-secondary);\n padding-bottom: 8px;\n}\n\n.flywheel_packets_K2PPkW th {\n color: var(--dsw-alias-label-secondary);\n font-weight: 500;\n}\n\n.flywheel_packets_K2PPkW th, .flywheel_packets_K2PPkW td {\n border-bottom: .5px solid var(--dsw-alias-border-l4);\n vertical-align: top;\n padding: 10px 12px 10px 0;\n}\n\n.flywheel_packets_K2PPkW th:first-child {\n width: 42%;\n}\n\n.flywheel_packets_K2PPkW th:last-child, .flywheel_packets_K2PPkW td:last-child {\n padding-right: 0;\n}\n\n.flywheel_budget_K2PPkW {\n gap: 4px;\n margin-top: 8px;\n display: grid;\n}\n\n.flywheel_advanced_K2PPkW {\n border-top: .5px solid var(--dsw-alias-border-l4);\n margin: 28px 0 20px;\n padding-top: 20px;\n}\n\n.flywheel_advanced_K2PPkW > summary, .flywheel_details_K2PPkW > summary {\n cursor: pointer;\n width: fit-content;\n max-width: 100%;\n}\n\n.flywheel_advanced_K2PPkW > summary:focus-visible, .flywheel_details_K2PPkW > summary:focus-visible {\n outline: 2px solid var(--dsw-alias-state-business-primary);\n outline-offset: 3px;\n border-radius: 8px;\n}\n\n.flywheel_advancedBody_K2PPkW {\n gap: 12px;\n min-width: 0;\n padding-top: 12px;\n display: grid;\n}\n\n.flywheel_bindings_K2PPkW {\n grid-template-columns: repeat(auto-fit, minmax(min(100%, 290px), 1fr));\n gap: 12px;\n margin-top: 8px;\n display: grid;\n}\n\n.flywheel_binding_K2PPkW {\n border: .5px solid var(--dsw-alias-settings-card-stroke);\n background: var(--dsw-alias-settings-card-fill);\n border-radius: 12px;\n min-width: 0;\n padding: 14px;\n}\n\n.flywheel_bindingTitle_K2PPkW {\n font-size: 13px;\n font-weight: 500;\n line-height: 20px;\n}\n\n.flywheel_properties_K2PPkW {\n grid-template-columns: minmax(76px, .7fr) minmax(0, 1.3fr);\n gap: 6px 12px;\n margin: 12px 0 0;\n display: grid;\n}\n\n.flywheel_properties_K2PPkW dt {\n color: var(--dsw-alias-label-secondary);\n}\n\n.flywheel_properties_K2PPkW dd {\n min-width: 0;\n margin: 0;\n}\n\n.flywheel_checks_K2PPkW {\n gap: 12px;\n margin: 8px 0 0;\n padding: 0;\n list-style: none;\n display: grid;\n}\n\n.flywheel_check_K2PPkW {\n grid-template-columns: 4em minmax(0, 1fr);\n align-items: start;\n gap: 12px;\n display: grid;\n}\n\n.flywheel_checkBody_K2PPkW {\n gap: 3px;\n min-width: 0;\n display: grid;\n}\n\n.flywheel_checkState_K2PPkW {\n border-left: .5px solid var(--dsw-alias-border-l3);\n color: var(--dsw-alias-label-secondary);\n padding-left: 6px;\n}\n\n.flywheel_checkState_K2PPkW[data-state=\"pass\"] {\n border-left-width: 1px;\n border-color: var(--dsw-alias-state-success-primary);\n}\n\n.flywheel_checkState_K2PPkW[data-state=\"fail\"] {\n border-left-width: 1px;\n border-color: var(--dsw-alias-state-error-primary);\n}\n\n.flywheel_checkState_K2PPkW[data-state=\"warn\"] {\n border-left-width: 1px;\n border-color: var(--dsw-alias-state-warn-primary);\n}\n\n.flywheel_details_K2PPkW {\n width: 100%;\n min-width: 0;\n}\n\n.flywheel_details_K2PPkW > summary {\n color: var(--dsw-alias-label-secondary);\n}\n\n.flywheel_raw_K2PPkW {\n box-sizing: border-box;\n background: var(--dsw-alias-bg-layer-2);\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n border-radius: 8px;\n max-width: 100%;\n max-height: 320px;\n margin: 8px 0 0;\n padding: 12px;\n overflow: auto;\n}\n";
12
+ // Host controlTimeoutMs is bounded at 120 s, with up to 20 s diagnostic cleanup.
13
+ // A browser timeout reports uncertainty; it cannot cancel a detached Go task.
14
+ const CONTROL_WAIT_MS = 150000;
15
+
16
+ // Own the two controls we use. Markup and native-prop/ref forwarding follow
17
+ // DSH b830ec950faba0a46dda564be3e2d854afed94f1:
18
+ // packages/client/ui-primitives/src/{Button,Input}.tsx. Scoped styles below copy
19
+ // their compact geometry; only public alias colors are shared with the host.
20
+ // The copied portions retain their MIT notice in THIRD_PARTY_NOTICES.md.
21
+ const Button = React.forwardRef(function FlywheelButton({
22
+ variant = 'outline', size = 'sm', className, children, ...rest
23
+ }, ref) {
24
+ return h('button', { type: 'button', ...rest, ref,
25
+ className: [css.button, variant === 'primary' ? css.buttonPrimary : css.buttonOutline,
26
+ size === 'sm' ? css.buttonSmall : '', className].filter(Boolean).join(' '),
27
+ }, children);
28
+ });
29
+ const Input = React.forwardRef(function FlywheelInput({ className, ...rest }, ref) {
30
+ return h('span', { className: [css.inputShell, className].filter(Boolean).join(' '),
31
+ 'data-disabled': rest.disabled ? 'true' : undefined,
32
+ }, h('input', { ...rest, ref, className: css.textInput }));
33
+ });
34
+
35
+ // The ongoing glyph and reduced-motion behavior are copied from the same
36
+ // pinned host's ui-primitives StateDot; this plugin owns the SVG and CSS.
37
+ function Spinner() {
38
+ const synchronize = element => {
39
+ if (!element) return;
40
+ for (const animation of element.getAnimations?.({ subtree: true }) ?? []) animation.startTime = 0;
41
+ };
42
+ return h('svg', { className: css.spinner, width: 20, height: 20,
43
+ viewBox: '0 0 24 24', 'aria-hidden': true, ref: synchronize,
44
+ onAnimationStart: event => synchronize(event.currentTarget) },
45
+ h('g', { className: css.spinnerMotion },
46
+ h('circle', { className: css.spinnerTrack, cx: 12, cy: 12, r: 9.5 }),
47
+ h('circle', { className: css.spinnerArc, cx: 12, cy: 12, r: 9.5 })));
48
+ }
49
+
50
+ // A small immutable snapshot source for this plugin's flat write sets. This is
51
+ // not a generic deep-draft engine: all updates replace root fields, and nested
52
+ // engine reports are retained as read-only values. Only the host subscribes.
53
+ function createSnapshot(initial) {
54
+ let snapshot = Object.freeze({ ...initial });
55
+ const listeners = new Set();
56
+ return {
57
+ getSnapshot: () => snapshot,
58
+ subscribe: listener => {
59
+ listeners.add(listener);
60
+ return () => { listeners.delete(listener); };
61
+ },
62
+ update: mutate => {
63
+ const next = { ...snapshot };
64
+ mutate(next);
65
+ if (Object.keys(next).every(key => Object.is(next[key], snapshot[key]))) return;
66
+ snapshot = Object.freeze(next);
67
+ for (const listener of [...listeners]) listener();
68
+ },
69
+ };
70
+ }
71
+
72
+ // Public structural StoreHandle / StoreInstance contract, verified at pinned
73
+ // packages/client/store/src/contract.ts. The renderer creates and caches each
74
+ // instance by session binding, then provides useStore/actions to the component.
75
+ // We do not import its engine, subscribe from React, or persist live state.
76
+ function createViewStore() {
77
+ const spec = {
78
+ init: () => ({ taskDir: '', cwd: '', advancedOpen: false }),
79
+ actions: {
80
+ setSelection: (draft, next) => { draft.taskDir = next.taskDir; draft.cwd = next.cwd; },
81
+ setAdvancedOpen: (draft, open) => { draft.advancedOpen = open; },
82
+ },
83
+ };
84
+ return {
85
+ spec,
86
+ create: () => {
87
+ const source = createSnapshot(spec.init());
88
+ const actions = Object.fromEntries(Object.entries(spec.actions).map(([name, action]) =>
89
+ [name, (...args) => source.update(draft => action(draft, ...args))]));
90
+ return { actions, getSnapshot: source.getSnapshot, subscribe: source.subscribe,
91
+ clearPersisted: () => {},
92
+ };
93
+ },
94
+ };
95
+ }
96
+
97
+ const zh = {
98
+ view: 'Flywheel',
99
+ title: '任务控制',
100
+ description: '查看 Flywheel 任务的进度与验收结果,在需要时启动、恢复或停止任务',
101
+ lifecycle: '任务在后台独立运行。切换视图或会话不会停止任务;需要停止时请使用这里的“停止”',
102
+ selection: '选择任务',
103
+ taskDir: '任务目录',
104
+ taskHint: '填写已准备好契约的绝对路径;只查看配置或检查环境时可留空',
105
+ cwd: '仓库目录',
106
+ cwdHint: '留空使用当前 DSH 会话目录,也可指定任务 worktree 的绝对路径',
107
+ pathInvalid: '请输入以 / 开头的绝对路径',
108
+ pathControl: '路径不能包含换行或空字符',
109
+ taskRequired: '填写任务目录后可刷新、启动或停止任务',
110
+ status: '刷新状态',
111
+ start: '启动',
112
+ stop: '停止',
113
+ config: '查看模型配置',
114
+ doctor: '检查环境',
115
+ probe: 'SDK 无推理检查',
116
+ busy: '正在等待“{action}”确认…',
117
+ busyOther: '正在等待上一个目录的“{action}”结束;其结果不会覆盖当前选择',
118
+ mutationLock: '启动或停止确认前,暂时不能切换任务目录',
119
+ unknownOutcome: '“{action}”结果未确认',
120
+ unknownHelp: '请求可能已经送达引擎。先刷新这个任务的状态,再决定下一步操作',
121
+ reconcile: '刷新待确认任务',
122
+ errorTitle: '“{action}”未完成',
123
+ errorHelp: '检查连接和目录后,重新执行这项只读检查',
124
+ notDispatchedTitle: '“{action}”未发送到引擎',
125
+ notDispatchedHelp: '检查错误详情,修正目录或引擎配置后重试',
126
+ errorDetails: '错误详情',
127
+ feedback: '操作反馈',
128
+ statusHeading: '任务状态',
129
+ emptyHeading: '还没有任务状态',
130
+ emptyBody: '在“对话”中准备并审阅任务契约,然后填写任务目录并刷新状态。已有的后台任务也可在这里继续查看',
131
+ emptySelected: '点击“刷新状态”查看这个任务;打开本页不会自动启动任务',
132
+ lastChecked: '上次确认:{time}',
133
+ stale: '本次状态未确认,下方保留上次结果',
134
+ stateUnconfirmed: '状态待确认',
135
+ previousState: '上次确认状态:{status}',
136
+ task: '任务',
137
+ running: '运行中',
138
+ uninitialized: '尚未初始化',
139
+ engineMissing: '引擎未运行,状态待核对',
140
+ unknownState: '未知状态({status})',
141
+ unknown: '未知',
142
+ unreported: '未报告',
143
+ pending: '待开始',
144
+ paused: '已暂停',
145
+ stopped: '已停止',
146
+ completed: '已完成',
147
+ escalated: '需要人工处理',
148
+ budgetStopped: '预算已停止',
149
+ milestone: '当前里程碑:{name}',
150
+ packets: '工作单元',
151
+ packetsCount: '工作单元(共 {count} 个)',
152
+ packet: '工作单元',
153
+ state: '状态',
154
+ route: '路由',
155
+ packetPending: '待执行',
156
+ awaitingCredentials: '等待凭证',
157
+ executing: '执行中',
158
+ executed: '待验收',
159
+ l1Passed: '验收已通过',
160
+ l1Failed: '验收未通过',
161
+ reviewFailed: '评审退回',
162
+ interrupted: '已中断',
163
+ inbox: '等待处理',
164
+ inboxCount: '待人工处理记录:{count} 条',
165
+ budget: '用量与成本',
166
+ cost: '已报告成本合计:$' + '{amount}',
167
+ costUnknown: '成本:未知',
168
+ costPartial: '已知成本合计:$' + '{amount};另有 {count} 次调用成本未知,合计不完整',
169
+ costMissing: '成本:未知({count} 次调用未报告结算成本)',
170
+ tokens: '已报告 Token:{tokens}',
171
+ tokensMissing: ';{count} 次调用用量未知,合计不完整',
172
+ tokenLimit: '每日 Token 限额:{limit} · 预算策略:{policy}',
173
+ budgetWarning: '预算提示:{message}',
174
+ advanced: '模型配置与环境检查',
175
+ advancedHelp: '查看当前任务实际使用的绑定,或在启动前检查本地环境',
176
+ configuration: '实际模型配置',
177
+ source: '配置来源:{source}',
178
+ embedded: '内嵌模板',
179
+ scope: '检查范围:{scope}',
180
+ outOfScope: '未用于本次检查',
181
+ model: '模型',
182
+ profile: 'Profile',
183
+ harness: 'Harness',
184
+ provider: 'Provider',
185
+ channel: '渠道',
186
+ thinking: '推理',
187
+ permission: '阶段权限要求',
188
+ session: '会话恢复',
189
+ enabled: '启用',
190
+ disabled: '关闭',
191
+ notConfigured: '未配置',
192
+ nativeProvider: '由原生运行时管理',
193
+ default: '默认',
194
+ undeclared: '未声明',
195
+ planRole: '规划',
196
+ executeRole: '执行',
197
+ reviewRole: '评审',
198
+ judgeRole: '终验',
199
+ intakeRole: '需求收集',
200
+ gardenRole: '知识维护',
201
+ panelRole: '第二意见',
202
+ diagnostic: '环境检查结果',
203
+ doctorPassed: '检查结束:未发现阻断项',
204
+ doctorFailed: '检查结束:存在阻断项',
205
+ localOnly: '本次只检查本地环境,未启动 SDK 探测',
206
+ probeRequested: '已请求 SDK 无推理检查',
207
+ inferenceBoundary: '未发送推理请求;账号授权、额度和真实模型能力未通过本次检查验证',
208
+ pass: '通过',
209
+ fail: '失败',
210
+ warn: '注意',
211
+ unverified: '未验证',
212
+ skipped: '跳过',
213
+ errorCode: '错误码:{code}',
214
+ remedy: '处理建议:{remedy}',
215
+ raw: '引擎原始结果(JSON)',
216
+ };
217
+ const en = {
218
+ view: 'Flywheel',
219
+ title: 'Task controls',
220
+ description: 'Inspect Flywheel progress and verification results, then start, resume or stop a task when needed',
221
+ lifecycle: 'Tasks run independently in the background. Switching views or sessions does not stop a task; use Stop here when needed.',
222
+ selection: 'Select a task',
223
+ taskDir: 'Task directory',
224
+ taskHint: 'Absolute path to a prepared task contract; optional for configuration and environment checks',
225
+ cwd: 'Repository directory',
226
+ cwdHint: 'Leave blank to use this DSH session directory, or enter the absolute task worktree path',
227
+ pathInvalid: 'Enter an absolute path beginning with /',
228
+ pathControl: 'Paths cannot contain line breaks or null characters',
229
+ taskRequired: 'Enter a task directory to refresh, start or stop a task',
230
+ status: 'Refresh status',
231
+ start: 'Start',
232
+ stop: 'Stop',
233
+ config: 'Inspect model configuration',
234
+ doctor: 'Check environment',
235
+ probe: 'SDK check without inference',
236
+ busy: 'Waiting for “{action}” confirmation…',
237
+ busyOther: 'Waiting for “{action}” for the previous directory; its result will not replace the current selection',
238
+ mutationLock: 'Task directories are locked until start or stop is confirmed',
239
+ unknownOutcome: '“{action}” outcome is unconfirmed',
240
+ unknownHelp: 'The request may have reached the engine. Refresh this task before choosing the next action.',
241
+ reconcile: 'Refresh unconfirmed task',
242
+ errorTitle: '“{action}” did not complete',
243
+ errorHelp: 'Check the connection and directories, then retry this read-only check.',
244
+ notDispatchedTitle: '“{action}” was not dispatched to the engine',
245
+ notDispatchedHelp: 'Check the error details, correct the directories or engine configuration, then retry.',
246
+ errorDetails: 'Error details',
247
+ feedback: 'Operation feedback',
248
+ statusHeading: 'Task status',
249
+ emptyHeading: 'No task status yet',
250
+ emptyBody: 'Prepare and review a task contract in Chat, then enter its directory and refresh. Existing background tasks can also be inspected here.',
251
+ emptySelected: 'Refresh status to inspect this task. Opening this view does not start it.',
252
+ lastChecked: 'Last confirmed: {time}',
253
+ stale: 'Current status is unconfirmed; the previous result is retained below',
254
+ stateUnconfirmed: 'Status unconfirmed',
255
+ previousState: 'Previously confirmed state: {status}',
256
+ task: 'Task',
257
+ running: 'Running',
258
+ uninitialized: 'Not initialized',
259
+ engineMissing: 'Engine is not running; state needs verification',
260
+ unknownState: 'Unknown state ({status})',
261
+ unknown: 'Unknown',
262
+ unreported: 'Not reported',
263
+ pending: 'Pending',
264
+ paused: 'Paused',
265
+ stopped: 'Stopped',
266
+ completed: 'Completed',
267
+ escalated: 'Needs human attention',
268
+ budgetStopped: 'Stopped by budget',
269
+ milestone: 'Current milestone: {name}',
270
+ packets: 'Work packets',
271
+ packetsCount: 'Work packets ({count})',
272
+ packet: 'Work packet',
273
+ state: 'State',
274
+ route: 'Route',
275
+ packetPending: 'Pending',
276
+ awaitingCredentials: 'Waiting for credentials',
277
+ executing: 'Executing',
278
+ executed: 'Awaiting verification',
279
+ l1Passed: 'Verification passed',
280
+ l1Failed: 'Verification failed',
281
+ reviewFailed: 'Changes requested',
282
+ interrupted: 'Interrupted',
283
+ inbox: 'Waiting for attention',
284
+ inboxCount: 'Human attention records: {count}',
285
+ budget: 'Usage and cost',
286
+ cost: 'Reported cost total: $' + '{amount}',
287
+ costUnknown: 'Cost: unknown',
288
+ costPartial: 'Known cost: $' + '{amount}; {count} other calls have unknown cost, so this total is incomplete',
289
+ costMissing: 'Cost: unknown ({count} calls did not report billed cost)',
290
+ tokens: 'Reported tokens: {tokens}',
291
+ tokensMissing: '; usage is unknown for {count} calls, so this total is incomplete',
292
+ tokenLimit: 'Daily token limit: {limit} · Budget policy: {policy}',
293
+ budgetWarning: 'Budget notice: {message}',
294
+ advanced: 'Model configuration and environment',
295
+ advancedHelp: 'Inspect the bindings used by this task or check the local environment before starting',
296
+ configuration: 'Effective model configuration',
297
+ source: 'Configuration source: {source}',
298
+ embedded: 'Embedded template',
299
+ scope: 'Check scope: {scope}',
300
+ outOfScope: 'Not used for this check',
301
+ model: 'Model',
302
+ profile: 'Profile',
303
+ harness: 'Harness',
304
+ provider: 'Provider',
305
+ channel: 'Channel',
306
+ thinking: 'Reasoning',
307
+ permission: 'Required permission',
308
+ session: 'Session resume',
309
+ enabled: 'Enabled',
310
+ disabled: 'Disabled',
311
+ notConfigured: 'Not configured',
312
+ nativeProvider: 'Managed by the native runtime',
313
+ default: 'Default',
314
+ undeclared: 'Not declared',
315
+ planRole: 'Plan',
316
+ executeRole: 'Execute',
317
+ reviewRole: 'Review',
318
+ judgeRole: 'Judge',
319
+ intakeRole: 'Intake',
320
+ gardenRole: 'Knowledge maintenance',
321
+ panelRole: 'Second opinion',
322
+ diagnostic: 'Environment check results',
323
+ doctorPassed: 'Check complete: no blockers found',
324
+ doctorFailed: 'Check complete: blockers found',
325
+ localOnly: 'Only the local environment was checked; no SDK probe ran',
326
+ probeRequested: 'An SDK check without inference was requested',
327
+ inferenceBoundary: 'No inference request was sent. Account authorization, quota and actual model capability remain unverified.',
328
+ pass: 'Passed',
329
+ fail: 'Failed',
330
+ warn: 'Notice',
331
+ unverified: 'Unverified',
332
+ skipped: 'Skipped',
333
+ errorCode: 'Error code: {code}',
334
+ remedy: 'Next step: {remedy}',
335
+ raw: 'Raw engine response (JSON)',
336
+ };
6
337
 
7
338
  const taskLabels = {
8
- pending: '待开始', paused: '已暂停', stopped: '已停止', completed: '已完成',
9
- escalated: '需要人工处理', 'budget-stopped': '预算已停止',
339
+ pending: 'pending', paused: 'paused', stopped: 'stopped', completed: 'completed',
340
+ escalated: 'escalated', 'budget-stopped': 'budgetStopped',
10
341
  };
11
342
  const packetLabels = {
12
- pending: '待执行', 'awaiting-credentials': '等待凭证', executing: '执行中', executed: '待验收',
13
- 'l1-passed': '验收已通过', 'l1-failed': '验收未通过', 'review-failed': '评审退回',
14
- completed: '已完成', interrupted: '已中断', escalated: '需要人工处理', inbox: '等待处理',
343
+ pending: 'packetPending', 'awaiting-credentials': 'awaitingCredentials',
344
+ executing: 'executing', executed: 'executed', 'l1-passed': 'l1Passed',
345
+ 'l1-failed': 'l1Failed', 'review-failed': 'reviewFailed', completed: 'completed',
346
+ interrupted: 'interrupted', escalated: 'escalated', inbox: 'inbox',
15
347
  };
16
- const checkLabels = { pass: '通过', fail: '失败', warn: '注意', unknown: '未验证', skipped: '跳过' };
17
- const roleLabels = { plan: '规划', execute: '执行', review: '评审', judge: '终验',
18
- intake: '需求收集', garden: '知识维护', 'judge-panel': '第二意见' };
19
- const buttonStyle = { border: '1px solid #8888', padding: '4px 10px', borderRadius: 5 };
20
- const tableStyle = { width: '100%', textAlign: 'left', fontSize: 11, borderCollapse: 'collapse' };
21
- const cellStyle = { padding: '5px 8px 5px 0', verticalAlign: 'top', overflowWrap: 'anywhere' };
22
- const rawStyle = { fontSize: 11, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', maxHeight: 240, overflow: 'auto' };
23
-
24
- function taskLabel(value) {
25
- if (value.running) return '运行中';
26
- if (!value.state) return '尚未初始化';
27
- if (value.state.status === 'running') return '引擎未运行,状态待核对';
28
- return taskLabels[value.state.status] ?? `未知状态(${value.state.status ?? '未报告'})`;
348
+ const roleLabels = { plan: 'planRole', execute: 'executeRole', review: 'reviewRole', judge: 'judgeRole',
349
+ intake: 'intakeRole', garden: 'gardenRole', 'judge-panel': 'panelRole' };
350
+ const checkLabels = { pass: 'pass', fail: 'fail', warn: 'warn', unknown: 'unverified', skipped: 'skipped' };
351
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
352
+ const textValue = (value, fallback = '—') => typeof value === 'string' || typeof value === 'number' ? String(value) : fallback;
353
+ const selectionOf = value => ({ taskDir: value.taskDir.trim(), cwd: value.cwd.trim() });
354
+ const selectionKey = value => JSON.stringify([value.taskDir, value.cwd]);
355
+ const mutates = action => action === 'start' || action === 'stop';
356
+ const actionKey = value => value.probe ? 'probe' : value.action;
357
+ class NotDispatchedControlReply extends Error {}
358
+
359
+ function pathProblem(value) {
360
+ if (/[\0\r\n]/.test(value)) return 'pathControl';
361
+ if (value.trim() && !value.trim().startsWith('/')) return 'pathInvalid';
362
+ return null;
363
+ }
364
+
365
+ function taskLabel(value, t) {
366
+ if (value.running) return t('running');
367
+ if (!value.state) return t('uninitialized');
368
+ if (value.state.status === 'running') return t('engineMissing');
369
+ const key = taskLabels[value.state.status];
370
+ return key ? t(key) : t('unknownState', { status: textValue(value.state.status, t('unreported')) });
371
+ }
372
+
373
+ function readReply(reply, request) {
374
+ if (!reply?.ok) throw new Error(reply?.error?.message ?? 'DSH command transport failed');
375
+ const result = reply.value?.result;
376
+ if (result?.kind !== 'success') {
377
+ let failure;
378
+ // This is a versioned command envelope, not a heuristic over error prose.
379
+ // Older hosts and malformed/ambiguous replies keep the conservative outcome.
380
+ if (result?.kind === 'error' && typeof result.text === 'string') {
381
+ try { failure = JSON.parse(result.text); } catch { /* legacy unstructured error */ }
382
+ }
383
+ const typed = record(failure) && failure.protocol === 1 &&
384
+ failure.type === 'flywheel-control-error' && failure.action === request.action &&
385
+ ['not-dispatched', 'unknown'].includes(failure.dispatch) && typeof failure.message === 'string';
386
+ const message = typed ? failure.message : result?.text ?? 'Flywheel command unavailable';
387
+ if (typed && failure.dispatch === 'not-dispatched') throw new NotDispatchedControlReply(message);
388
+ throw new Error(message);
389
+ }
390
+ const value = JSON.parse(result.text);
391
+ // runControl has already matched the engine's task identity against the
392
+ // host's realpath-normalized request. A browser cannot compare that identity
393
+ // lexically with a user path (/var, symlinks, trailing slash or "..").
394
+ if (!record(value) || value.protocol !== 1 || typeof value.ok !== 'boolean' ||
395
+ typeof value.taskDir !== 'string' ||
396
+ (request.taskDir ? !value.taskDir.startsWith('/') : value.taskDir !== '')) {
397
+ throw new Error('Flywheel control protocol or task identity mismatch');
398
+ }
399
+ if (!value.ok && !(request.action === 'doctor' && value.diagnostic?.ok === false)) {
400
+ throw new Error(typeof value.error === 'string' ? value.error : 'Flywheel control failed');
401
+ }
402
+ const state = value.state;
403
+ const configurationValid = report => record(report) && Array.isArray(report.bindings) &&
404
+ report.bindings.every(record);
405
+ if (['status', 'start', 'stop'].includes(request.action) &&
406
+ (typeof value.running !== 'boolean' || (state !== null && !record(state)) ||
407
+ (state?.packets != null && !record(state.packets)) ||
408
+ (state?.inbox != null && !Array.isArray(state.inbox)) ||
409
+ (state?.budget != null && (!record(state.budget) ||
410
+ (state.budget.warnings != null && !Array.isArray(state.budget.warnings)))))) {
411
+ throw new Error('Flywheel control returned malformed task status');
412
+ }
413
+ if (request.action === 'config' && !configurationValid(value.configuration)) {
414
+ throw new Error('Flywheel control returned malformed model configuration');
415
+ }
416
+ if (request.action === 'doctor' && (!record(value.diagnostic) ||
417
+ value.diagnostic.ok !== value.ok || !Array.isArray(value.diagnostic.checks) ||
418
+ !value.diagnostic.checks.every(record) ||
419
+ (value.diagnostic.configuration !== undefined && !configurationValid(value.diagnostic.configuration)))) {
420
+ throw new Error('Flywheel control returned malformed environment report');
421
+ }
422
+ return value;
423
+ }
424
+
425
+ function initialControl() {
426
+ return { pending: null, status: null, checkedAt: null, stale: false, configuration: null,
427
+ diagnostic: null, raw: null, error: null, uncertainty: null };
428
+ }
429
+
430
+ // Transport state belongs to this apply-owned controller, not a component or
431
+ // the draft store. Its observable reaches React only through slot injection.
432
+ function createControl(execute, pendingOperations, isDisposed) {
433
+ const source = createSnapshot(initialControl());
434
+ let selected = null;
435
+ let epoch = 0;
436
+ let active = null;
437
+ const select = selection => {
438
+ const normalized = selectionOf(selection);
439
+ const key = selectionKey(normalized);
440
+ if (selected === key) return;
441
+ selected = key;
442
+ epoch += 1;
443
+ source.update(draft => {
444
+ Object.assign(draft, initialControl(), { pending: draft.pending, uncertainty: draft.uncertainty });
445
+ });
446
+ };
447
+ const run = async (action, selection, probe = false) => {
448
+ if (isDisposed() || active || (mutates(action) && source.getSnapshot().uncertainty)) return;
449
+ if (!['status', 'start', 'stop', 'config', 'doctor'].includes(action)) return;
450
+ if (pathProblem(selection.taskDir) || pathProblem(selection.cwd)) return;
451
+ const target = selectionOf(selection);
452
+ if (['status', 'start', 'stop'].includes(action) && !target.taskDir) return;
453
+ select(selection);
454
+ const requestEpoch = epoch;
455
+ const requestKey = selected;
456
+ const request = { action, ...(target.taskDir ? { taskDir: target.taskDir } : {}),
457
+ ...(target.cwd ? { cwd: target.cwd } : {}), ...(probe ? { probe: true } : {}) };
458
+ const operation = { action, probe, ...target, key: requestKey };
459
+ active = operation;
460
+ source.update(draft => { draft.pending = operation; draft.error = null; });
461
+ let timer;
462
+ let disposeWait;
463
+ try {
464
+ const deadline = new Promise((_, reject) => {
465
+ timer = setTimeout(() => reject(new Error('Flywheel control confirmation timed out')), CONTROL_WAIT_MS);
466
+ disposeWait = () => reject(new Error('Flywheel UI was unloaded before confirmation'));
467
+ pendingOperations.add(disposeWait);
468
+ });
469
+ const value = await Promise.race([execute(request), deadline]);
470
+ if (isDisposed() || requestEpoch !== epoch) return;
471
+ source.update(draft => {
472
+ if (['status', 'start', 'stop'].includes(action)) {
473
+ draft.status = value;
474
+ draft.checkedAt = Date.now();
475
+ draft.stale = false;
476
+ if (draft.uncertainty?.key === requestKey) draft.uncertainty = null;
477
+ }
478
+ if (['config', 'doctor'].includes(action)) {
479
+ draft.configuration = value.configuration ?? value.diagnostic?.configuration ?? null;
480
+ draft.diagnostic = value.diagnostic ?? null;
481
+ }
482
+ draft.raw = value;
483
+ });
484
+ } catch (error) {
485
+ if (isDisposed() || requestEpoch !== epoch) return;
486
+ source.update(draft => {
487
+ const detail = error instanceof Error ? error.message : String(error);
488
+ const dispatch = error instanceof NotDispatchedControlReply ? 'not-dispatched' : 'unknown';
489
+ draft.error = { action, probe, detail, dispatch };
490
+ if (action === 'status' || (mutates(action) && dispatch === 'unknown')) draft.stale = true;
491
+ if (mutates(action) && dispatch === 'unknown') draft.uncertainty = { ...operation, detail };
492
+ });
493
+ } finally {
494
+ clearTimeout(timer);
495
+ pendingOperations.delete(disposeWait);
496
+ active = null;
497
+ if (!isDisposed()) source.update(draft => { draft.pending = null; });
498
+ }
499
+ };
500
+ return { source, select, run, isMutating: () => active !== null && mutates(active.action) };
29
501
  }
30
502
 
31
- function Configuration({ value }) {
503
+ function Configuration({ value, t }) {
32
504
  if (!value) return null;
33
- return h('section', { 'aria-label': '实际模型配置' },
34
- h('h4', { style: { marginBottom: 6 } }, '实际模型配置'),
35
- h('p', { style: { overflowWrap: 'anywhere' } }, `来源:${value.source || '内嵌模板'} · 范围:${value.scope}`),
36
- h('div', { style: { overflowX: 'auto' } }, h('table', { style: tableStyle },
37
- h('thead', null, h('tr', null, ...['阶段 / 路由', '模型 / Profile', 'Harness / Provider / 渠道', '推理 / 阶段权限要求 / 会话']
38
- .map(title => h('th', { key: title, scope: 'col', style: cellStyle }, title)))),
39
- h('tbody', null, ...value.bindings.map(binding => h('tr', { key: binding.id },
40
- h('td', { style: cellStyle }, h('strong', null, roleLabels[binding.role] ?? binding.role),
41
- h('div', null, binding.route || binding.id),
42
- binding.inScope === false && h('div', null, '未用于本次检查')),
43
- h('td', { style: cellStyle }, h('code', null, binding.model || '未配置'),
44
- binding.profile && h('div', null, `Profile:${binding.profile}`)),
45
- h('td', { style: cellStyle }, h('div', null, binding.harness),
46
- h('div', null, `Provider:${binding.provider || (binding.harness === 'dsh' ? '未配置' : '由原生 CLI 管理')}`),
47
- h('div', null, `渠道:${binding.channel || '未配置'}`)),
48
- h('td', { style: cellStyle }, h('div', null, `推理:${binding.thinking || '默认'}`),
49
- h('div', null, `阶段权限要求:${binding.permissionMode || '未声明'}`),
50
- h('div', null, binding.residentSession ? '会话恢复:启用' : '会话恢复:关闭'))))))));
505
+ const field = (name, value, code = false) => h(React.Fragment, { key: name },
506
+ h('dt', null, t(name)), h('dd', null, code ? h('code', null, value) : value));
507
+ return h('section', { className: css.section, 'aria-label': t('configuration') },
508
+ h('h3', { className: css.heading }, t('configuration')),
509
+ h('p', { className: css.meta }, t('source', { source: textValue(value.source, '') || t('embedded') })),
510
+ h('p', { className: css.meta }, t('scope', { scope: textValue(value.scope) })),
511
+ h('div', { className: css.bindings }, ...(value.bindings ?? []).map((binding, index) =>
512
+ h('article', { className: css.binding, key: textValue(binding?.id, String(index)) },
513
+ h('div', { className: css.bindingHeader },
514
+ h('h4', { className: css.bindingTitle }, roleLabels[binding?.role] ? t(roleLabels[binding.role]) : textValue(binding?.role)),
515
+ h('code', { className: css.meta }, textValue(binding?.route || binding?.id))),
516
+ binding?.inScope === false && h('p', { className: css.meta }, t('outOfScope')),
517
+ h('dl', { className: css.properties },
518
+ field('model', textValue(binding?.model, '') || t('notConfigured'), true),
519
+ binding?.profile && field('profile', textValue(binding.profile)),
520
+ field('harness', textValue(binding?.harness), true),
521
+ field('provider', textValue(binding?.provider, '') || (binding?.harness === 'dsh' ? t('notConfigured') : t('nativeProvider'))),
522
+ field('channel', textValue(binding?.channel, '') || t('notConfigured')),
523
+ field('thinking', textValue(binding?.thinking, '') || t('default')),
524
+ field('permission', textValue(binding?.permissionMode, '') || t('undeclared')),
525
+ field('session', t(binding?.residentSession ? 'enabled' : 'disabled')))))));
51
526
  }
52
527
 
53
- function Diagnostic({ value }) {
528
+ function Diagnostic({ value, t }) {
54
529
  if (!value) return null;
55
- return h('section', { 'aria-label': '环境检查结果' },
56
- h('h4', { style: { marginBottom: 6 } }, value.ok ? '检查结束:未发现阻断项' : '检查结束:存在阻断项'),
57
- h('p', null, value.probeRequested ? '已请求 SDK 无推理检查。' : '本次只检查本地环境,未启动 SDK 探测。'),
58
- h('p', null, '未发送推理请求;账号授权、额度和真实模型能力未通过本次检查验证。'),
59
- h('ul', { style: { paddingLeft: 20 } }, ...value.checks.map((check, index) =>
60
- h('li', { key: `${check.id}:${index}`, style: { marginBottom: 9, overflowWrap: 'anywhere' } },
61
- h('strong', null, `${checkLabels[check.status]} · ${check.id}`),
62
- h('div', null, check.summary),
63
- check.code && h('div', null, `错误码:${check.code}`),
64
- check.remedy && h('div', null, `处理建议:${check.remedy}`)))));
530
+ return h('section', { className: css.section, 'aria-label': t('diagnostic') },
531
+ h('h3', { className: css.heading }, t(value.ok ? 'doctorPassed' : 'doctorFailed')),
532
+ h('p', { className: css.meta }, t(value.probeRequested ? 'probeRequested' : 'localOnly')),
533
+ h('p', { className: css.meta }, t('inferenceBoundary')),
534
+ h('ul', { className: css.checks }, ...(value.checks ?? []).map((check, index) =>
535
+ h('li', { className: css.check, key: textValue(check?.id, '') + ':' + index },
536
+ h('span', { className: css.checkState, 'data-state': textValue(check?.status, 'unknown') },
537
+ t(checkLabels[check?.status] ?? 'unverified')),
538
+ h('div', { className: css.checkBody },
539
+ h('h4', { className: css.bindingTitle }, textValue(check?.id)),
540
+ h('p', null, textValue(check?.summary)),
541
+ check?.code && h('p', { className: css.meta }, t('errorCode', { code: textValue(check.code) })),
542
+ check?.remedy && h('p', null, t('remedy', { remedy: textValue(check.remedy) })))))));
65
543
  }
66
544
 
67
- function Budget({ value }) {
545
+ function Budget({ value, t }) {
68
546
  if (!value) return null;
69
547
  const cost = value.totals?.['cost-usd'];
70
548
  const unknownCost = value['unknown-cost-calls'] ?? 0;
71
549
  const unknownUsage = value['unknown-usage-calls'] ?? 0;
72
- let costText = Number.isFinite(cost) ? `已报告成本合计:$${cost.toFixed(4)}` : '成本:未知';
550
+ let costText = Number.isFinite(cost) ? t('cost', { amount: cost.toFixed(4) }) : t('costUnknown');
73
551
  if (unknownCost > 0) {
74
552
  costText = Number.isFinite(cost) && cost > 0 ?
75
- `已知成本合计:$${cost.toFixed(4)};另有 ${unknownCost} 次调用成本未知,合计不完整` :
76
- `成本:未知(${unknownCost} 次调用未报告结算成本)`;
553
+ t('costPartial', { amount: cost.toFixed(4), count: unknownCost }) : t('costMissing', { count: unknownCost });
77
554
  }
78
- return h('section', { 'aria-label': '用量与成本' },
555
+ return h('section', { className: css.budget, 'aria-label': t('budget') },
79
556
  h('p', null, costText),
80
- h('p', null, `已报告 Token:${value.totals?.tokens ?? '未知'}` +
81
- (unknownUsage > 0 ? `;${unknownUsage} 次调用用量未知,合计不完整` : '')),
82
- value['daily-token-limit'] != null && h('p', null,
83
- `每日 Token 限额:${value['daily-token-limit']} · 预算策略:${value.enforce}`),
84
- ...(value.warnings ?? []).map((warning, index) => h('p', { key: index }, `预算提示:${warning}`)));
557
+ h('p', null, t('tokens', { tokens: value.totals?.tokens ?? t('unknown') }) +
558
+ (unknownUsage > 0 ? t('tokensMissing', { count: unknownUsage }) : '')),
559
+ value['daily-token-limit'] != null && h('p', { className: css.meta },
560
+ t('tokenLimit', { limit: value['daily-token-limit'], policy: textValue(value.enforce) })),
561
+ ...(value.warnings ?? []).map((warning, index) => h('p', { key: index },
562
+ t('budgetWarning', { message: textValue(warning) }))));
85
563
  }
86
564
 
87
- function Panel({ execute }) {
88
- const [open, setOpen] = React.useState(false);
89
- const [taskDir, setTaskDir] = React.useState('');
90
- const [cwd, setCwd] = React.useState('');
91
- const [busy, setBusy] = React.useState(false);
92
- const [status, setStatus] = React.useState(null);
93
- const [configuration, setConfiguration] = React.useState(null);
94
- const [diagnostic, setDiagnostic] = React.useState(null);
95
- const [raw, setRaw] = React.useState(null);
96
- const [error, setError] = React.useState('');
97
- const requestVersion = React.useRef(0);
98
- const panelId = React.useId();
99
- React.useEffect(() => () => { requestVersion.current += 1; }, []);
100
- const changePath = (setPath, value) => {
101
- // Editing a path changes the view's identity, even while a request is in
102
- // flight. It never implies stopping an independently running Go task.
103
- requestVersion.current += 1;
104
- setPath(value);
105
- setBusy(false);
106
- setStatus(null);
107
- setConfiguration(null);
108
- setDiagnostic(null);
109
- setRaw(null);
110
- setError('');
111
- };
112
- const packets = Object.entries(status?.state?.packets ?? {});
113
- const run = async (action, probe = false) => {
114
- const version = ++requestVersion.current;
115
- setBusy(true);
116
- setError('');
117
- try {
118
- const request = { action, ...(taskDir.trim() ? { taskDir: taskDir.trim() } : {}),
119
- ...(cwd.trim() ? { cwd: cwd.trim() } : {}), ...(probe ? { probe: true } : {}) };
120
- const value = await execute(request);
121
- if (version !== requestVersion.current) return;
122
- if (['start', 'status', 'stop'].includes(action)) setStatus(value);
123
- if (['config', 'doctor'].includes(action)) {
124
- setConfiguration(value.configuration ?? value.diagnostic?.configuration ?? null);
125
- setDiagnostic(value.diagnostic ?? null);
126
- }
127
- setRaw(value);
128
- } catch (error) {
129
- if (version === requestVersion.current) setError(error instanceof Error ? error.message : String(error));
130
- } finally {
131
- if (version === requestVersion.current) setBusy(false);
132
- }
133
- };
134
- const button = (action, label, needsTask, probe = false) => h('button', {
135
- key: label, type: 'button', disabled: busy || (needsTask && !taskDir.trim()),
136
- onClick: () => run(action, probe), style: buttonStyle,
137
- }, label);
138
- return h('div', { style: { position: 'relative', fontSize: 12 } },
139
- h('button', { type: 'button', onClick: () => setOpen(!open), 'aria-expanded': open, 'aria-controls': panelId,
140
- style: { border: '1px solid currentColor', borderRadius: 6, padding: '2px 8px' } },
141
- `Flywheel${status ? ` · ${taskLabel(status)}` : ''}`),
142
- open && h('section', { id: panelId, 'aria-label': 'Flywheel 任务控制', style: {
143
- position: 'absolute', bottom: 'calc(100% + 8px)', left: 0, width: 'min(720px, 88vw)',
144
- maxHeight: '70vh', overflow: 'auto', padding: 14, border: '1px solid #8888', borderRadius: 10,
145
- background: 'var(--color-background, Canvas)', color: 'var(--color-foreground, CanvasText)',
146
- boxShadow: '0 8px 32px #0003', zIndex: 40,
147
- } },
148
- h('strong', null, 'Flywheel · Go 引擎'),
149
- h('p', { style: { margin: '8px 0' } }, '任务独立于桌面会话运行。切换下方路径只切换查看对象;停止任务请使用“停止”。'),
150
- h('label', { style: { display: 'block' } }, '任务目录(绝对路径,配置与环境检查可留空)',
151
- h('input', { value: taskDir, onChange: e => changePath(setTaskDir, e.target.value),
152
- placeholder: '/path/to/repo/docs/task',
153
- style: { width: '100%', boxSizing: 'border-box', padding: 6, border: '1px solid #8888', borderRadius: 4, margin: '4px 0 8px' } })),
154
- h('label', { style: { display: 'block' } }, '仓库目录(留空使用当前 DSH 会话目录)',
155
- h('input', { value: cwd, onChange: e => changePath(setCwd, e.target.value), placeholder: '/path/to/repo',
156
- style: { width: '100%', boxSizing: 'border-box', padding: 6, border: '1px solid #8888', borderRadius: 4, margin: '4px 0 8px' } })),
157
- h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 } },
158
- button('status', '刷新状态', true), button('start', '启动', true), button('stop', '停止', true)),
159
- h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 } },
160
- button('config', '查看模型配置', false), button('doctor', '检查环境', false),
161
- button('doctor', 'SDK 无推理检查', false, true)),
162
- busy && h('p', { role: 'status' }, '正在等待引擎确认…'),
163
- error && h('p', { role: 'alert', style: { color: '#d34b4b', whiteSpace: 'pre-wrap' } }, error),
164
- status && h('section', { 'aria-label': '任务状态' },
165
- h('h4', { style: { marginBottom: 6 } }, '任务状态(上次刷新)'),
166
- h('p', null, `${status.state?.slug ?? '任务'} · ${taskLabel(status)}` + (status.running && status.pid > 0 ? ` · PID ${status.pid}` : '')),
167
- status.state?.['current-milestone'] && h('p', null, `当前里程碑:${status.state['current-milestone']}`),
168
- packets.length > 0 && h('div', { style: { maxHeight: 260, overflow: 'auto' } },
169
- h('table', { 'aria-label': '工作单元', style: tableStyle },
170
- h('caption', { style: { textAlign: 'left' } }, `工作单元(共 ${packets.length} 个)`),
171
- h('thead', null, h('tr', null, ...['工作单元', '状态', '路由'].map(title => h('th', { key: title, scope: 'col', style: cellStyle }, title)))),
172
- h('tbody', null, ...packets.map(([id, packet]) => h('tr', { key: id },
173
- h('td', { style: cellStyle }, id),
174
- h('td', { style: cellStyle }, packetLabels[packet?.status] ?? packet?.status ?? '未知'),
175
- h('td', { style: cellStyle }, packet?.route ?? '—')))))),
176
- h(Budget, { value: status.state?.budget }),
177
- status.state?.inbox?.length > 0 && h('details', null,
178
- h('summary', null, `待人工处理记录:${status.state.inbox.length} 条`),
179
- h('pre', { style: rawStyle }, JSON.stringify(status.state.inbox, null, 2)))),
180
- h(Diagnostic, { value: diagnostic }),
181
- h(Configuration, { value: configuration }),
182
- raw && h('details', null, h('summary', null, '引擎原始结果(JSON)'),
183
- h('pre', { style: rawStyle }, JSON.stringify(raw, null, 2))),
184
- ));
565
+ function TaskStatus({ control, selection, t }) {
566
+ const { status, checkedAt, stale } = control;
567
+ if (!status) return h('section', { className: css.empty, 'aria-label': t('statusHeading') },
568
+ h('h3', { className: css.heading }, t('emptyHeading')),
569
+ h('p', { className: css.meta }, t(selection.taskDir.trim() ? 'emptySelected' : 'emptyBody')));
570
+ const packets = Object.entries(status.state?.packets ?? {});
571
+ return h('section', { className: css.section, 'aria-label': t('statusHeading') },
572
+ h('div', { className: css.sectionHeader },
573
+ h('h3', { className: css.heading }, textValue(status.state?.slug, t('task'))),
574
+ h('span', { className: css.badge }, stale ? t('stateUnconfirmed') : taskLabel(status, t))),
575
+ h('p', { className: css.meta },
576
+ t('lastChecked', { time: new Date(checkedAt).toLocaleString() }) +
577
+ (status.running && status.pid > 0 ? ' · PID ' + status.pid : '')),
578
+ stale && h('p', { className: css.warningText },
579
+ t('stale') + ' · ' + t('previousState', { status: taskLabel(status, t) })),
580
+ status.state?.['current-milestone'] && h('p', null,
581
+ t('milestone', { name: textValue(status.state['current-milestone']) })),
582
+ packets.length > 0 && h('table', { className: css.packets, 'aria-label': t('packets') },
583
+ h('caption', null, t('packetsCount', { count: packets.length })),
584
+ h('thead', null, h('tr', null, ...['packet', 'state', 'route'].map(key => h('th', { key, scope: 'col' }, t(key))))),
585
+ h('tbody', null, ...packets.map(([id, packet]) => h('tr', { key: id },
586
+ h('td', null, id),
587
+ h('td', null, packetLabels[packet?.status] ? t(packetLabels[packet.status]) : textValue(packet?.status, t('unknown'))),
588
+ h('td', null, textValue(packet?.route)))))),
589
+ h(Budget, { value: status.state?.budget, t }),
590
+ status.state?.inbox?.length > 0 && h('details', { className: css.details },
591
+ h('summary', null, t('inboxCount', { count: status.state.inbox.length })),
592
+ h('pre', { className: css.raw }, JSON.stringify(status.state.inbox, null, 2))));
593
+ }
594
+
595
+ function Feedback({ control, selection, reconcile, t }) {
596
+ const busy = control.pending !== null;
597
+ const error = control.error &&
598
+ (!mutates(control.error.action) || control.error.dispatch === 'not-dispatched') ? control.error : null;
599
+ const mode = control.uncertainty ? 'uncertain' : error ? 'error' : busy ? 'loading' : 'idle';
600
+ const otherSelection = busy && control.pending.key !== selectionKey(selectionOf(selection));
601
+ const locked = busy && mutates(control.pending.action);
602
+ const pendingLabel = busy ? t(otherSelection ? 'busyOther' : 'busy', {
603
+ action: t(actionKey(control.pending)),
604
+ }) : '';
605
+ return h('div', { className: css.feedback, 'data-state': mode,
606
+ role: 'region', 'aria-label': t('feedback'), tabIndex: mode === 'uncertain' || mode === 'error' ? 0 : undefined },
607
+ busy && h('div', { className: control.uncertainty ? css.loadingCompact : css.loading,
608
+ role: 'status', 'aria-label': pendingLabel },
609
+ h(Spinner),
610
+ !control.uncertainty && (locked || otherSelection) && h('p', null, pendingLabel),
611
+ locked && h('p', { className: css.meta }, t('mutationLock'))),
612
+ control.uncertainty && h('section', { className: css.notice, role: 'alert' },
613
+ h('h3', { className: css.heading }, t('unknownOutcome', { action: t(control.uncertainty.action) })),
614
+ h('p', null, t('unknownHelp')),
615
+ h('code', { className: css.path }, control.uncertainty.taskDir),
616
+ h(Button, { variant: 'outline', size: 'sm', disabled: busy, onClick: reconcile }, t('reconcile')),
617
+ h('details', { className: css.details },
618
+ h('summary', null, t('errorDetails')),
619
+ h('pre', { className: css.raw }, control.uncertainty.detail))),
620
+ error && h('section', { className: css.error, role: 'alert' },
621
+ h('h3', { className: css.heading }, t(error.dispatch === 'not-dispatched' ? 'notDispatchedTitle' : 'errorTitle',
622
+ { action: t(actionKey(error)) })),
623
+ h('p', null, t(error.dispatch === 'not-dispatched' ? 'notDispatchedHelp' : 'errorHelp')),
624
+ h('details', { className: css.details },
625
+ h('summary', null, t('errorDetails')),
626
+ h('pre', { className: css.raw }, error.detail))),
627
+ mode === 'idle' && h('div', { className: css.idleFeedback },
628
+ h('p', null, t('lifecycle')),
629
+ !selection.taskDir.trim() && h('p', null, t('taskRequired'))));
185
630
  }
186
631
 
187
- exports.inject = ['slots', 'remote', 'remote.commands'];
632
+ function TaskView({ useStore, actions, useControl, changeSelection, run, reconcile, t }) {
633
+ const selection = useStore(value => value);
634
+ const control = useControl(value => value);
635
+ const formId = React.useId();
636
+ const busy = control.pending !== null;
637
+ const locked = busy && mutates(control.pending.action);
638
+ const invalidTask = pathProblem(selection.taskDir);
639
+ const invalidCwd = pathProblem(selection.cwd);
640
+ const button = (action, labelKey, needsTask, probe = false, variant = 'outline') => h(Button, {
641
+ key: labelKey, variant, size: 'sm',
642
+ disabled: busy || !!invalidTask || !!invalidCwd || (needsTask && !selection.taskDir.trim()) ||
643
+ (mutates(action) && control.uncertainty !== null),
644
+ onClick: () => run(action, selection, probe),
645
+ }, t(labelKey));
646
+ const field = (name, hint, placeholder, problem) => h('div', { className: css.field },
647
+ h('label', { htmlFor: formId + '-' + name, className: css.fieldLabel }, t(name)),
648
+ h(Input, { id: formId + '-' + name, className: css.input,
649
+ value: selection[name], disabled: locked, placeholder, spellCheck: false,
650
+ 'aria-invalid': !!problem, 'aria-describedby': formId + '-' + name + '-help',
651
+ onChange: event => changeSelection({ taskDir: selection.taskDir, cwd: selection.cwd, [name]: event.target.value }) }),
652
+ h('p', { id: formId + '-' + name + '-help', className: problem ? css.errorText : css.meta }, t(problem ?? hint)));
653
+ return h('section', { className: css.page, 'aria-label': 'Flywheel ' + t('title') },
654
+ h('style', { 'data-plugin': '@ckanner/dsh-plugin-flywheel',
655
+ 'data-plugin-css': '@ckanner/dsh-plugin-flywheel/client.module.css',
656
+ }, stylesheet),
657
+ h('header', { className: css.header },
658
+ h('h2', { className: css.title }, t('title')),
659
+ h('p', { className: css.meta }, t('description'))),
660
+ h('section', { className: css.selection, 'aria-label': t('selection') },
661
+ h('div', { className: css.fields },
662
+ field('taskDir', 'taskHint', '/path/to/repo/docs/task', invalidTask),
663
+ field('cwd', 'cwdHint', '/path/to/repo', invalidCwd)),
664
+ h('div', { className: css.actions },
665
+ button('status', 'status', true, false, 'primary'),
666
+ button('start', 'start', true), button('stop', 'stop', true))),
667
+ h(Feedback, { control, selection, reconcile, t }),
668
+ h(TaskStatus, { control, selection, t }),
669
+ h('details', { className: css.advanced, open: selection.advancedOpen,
670
+ onToggle: event => actions.setAdvancedOpen(event.currentTarget.open) },
671
+ h('summary', { className: css.heading }, t('advanced')),
672
+ h('div', { className: css.advancedBody },
673
+ h('p', { className: css.meta }, t('advancedHelp')),
674
+ h('div', { className: css.actions },
675
+ button('config', 'config', false), button('doctor', 'doctor', false),
676
+ button('doctor', 'probe', false, true)),
677
+ h(Diagnostic, { value: control.diagnostic, t }),
678
+ h(Configuration, { value: control.configuration, t }))),
679
+ control.raw && h('details', { className: css.details },
680
+ h('summary', null, t('raw')),
681
+ h('pre', { className: css.raw }, JSON.stringify(control.raw, null, 2))));
682
+ }
683
+
684
+ exports.inject = ['slots', 'remote', 'remote.commands', 'locale'];
188
685
  exports.apply = function apply(ctx) {
189
- ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
190
- name: 'conversation.composer.dock', id: 'flywheel', order: 50,
191
- inject: sessionId => ({
192
- execute: async request => {
193
- const reply = await ctx.remote.commands.execute(sessionId, `/flywheel ${JSON.stringify(request)}`, []);
194
- if (!reply?.ok) throw new Error(reply?.error?.message ?? 'DSH command transport failed');
195
- const result = reply.value?.result;
196
- if (result?.kind !== 'success') throw new Error(result?.text ?? 'Flywheel command unavailable');
197
- const value = JSON.parse(result.text);
198
- if (value?.protocol !== 1) throw new Error('Flywheel control protocol mismatch');
199
- return value;
200
- },
201
- }),
202
- }, Panel));
686
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'flywheel: locales');
687
+ const t = ctx.locale.bind(NS);
688
+ // Drafts are slot-owned view state. No busy/status value is persisted or
689
+ // restored as a live fact after a new Session binding or a plugin reload.
690
+ const viewStore = createViewStore();
691
+ const controls = new WeakMap();
692
+ const pendingOperations = new Set();
693
+ let disposed = false;
694
+ ctx.on('dispose', () => {
695
+ disposed = true;
696
+ for (const release of pendingOperations) release();
697
+ pendingOperations.clear();
698
+ });
699
+ ctx.slots.inject('conversation.view', () => ctx.slots.register({
700
+ name: 'conversation.view', id: 'flywheel', order: 20, label: () => t('view'),
701
+ locale: NS, store: viewStore,
702
+ inject: (sessionId, actions) => {
703
+ // Bound actions identify one retained Session binding. A recycled
704
+ // session id with a fresh binding must not adopt an old async result.
705
+ let control = controls.get(actions);
706
+ if (!control) {
707
+ control = createControl(async request => readReply(
708
+ await ctx.remote.commands.execute(sessionId, '/flywheel ' + JSON.stringify(request), []), request),
709
+ pendingOperations, () => disposed);
710
+ controls.set(actions, control);
711
+ }
712
+ return {
713
+ hooks: { control: control.source },
714
+ changeSelection: next => {
715
+ if (disposed || control.isMutating()) return;
716
+ actions.setSelection(next);
717
+ control.select(next);
718
+ },
719
+ run: control.run,
720
+ reconcile: () => {
721
+ const outcome = control.source.getSnapshot().uncertainty;
722
+ if (!outcome || control.source.getSnapshot().pending) return;
723
+ const next = { taskDir: outcome.taskDir, cwd: outcome.cwd };
724
+ actions.setSelection(next);
725
+ return control.run('status', next);
726
+ },
727
+ };
728
+ },
729
+ }, TaskView));
203
730
  };
204
731
 
205
732
  return module.exports;