diff --git a/.gitignore b/.gitignore
index 39499a6d2d..5a26d77395 100644
--- a/.gitignore
+++ b/.gitignore
@@ -84,3 +84,9 @@ external/
/.bitfun/search/flashgrep-index/
.agents/
/.flashgrep-index-engine/
+
+# LoopX / agent control-plane state (project-local; never commit)
+.loopx/
+.codex/
+.opencode/
+.local/
diff --git a/src/crates/contracts/product-domains/src/miniapp/bridge_builder.rs b/src/crates/contracts/product-domains/src/miniapp/bridge_builder.rs
index 457f2fe594..b1472a147b 100644
--- a/src/crates/contracts/product-domains/src/miniapp/bridge_builder.rs
+++ b/src/crates/contracts/product-domains/src/miniapp/bridge_builder.rs
@@ -5,7 +5,7 @@ use serde_json;
/// Build the Runtime Adapter script (JS) to inject into the iframe.
/// Exposes window.app with call(), fs.*, shell.*, net.*, os.*, storage.*, dialog.*,
-/// ai.*, agent.*, deck.*, chat.*, clipboard.*, lifecycle, events.
+/// ai.*, agent.*, cron.*, deck.*, chat.*, workspace.*, clipboard.*, lifecycle, events.
pub fn build_bridge_script(
app_id: &str,
app_data_dir: &str,
@@ -74,6 +74,7 @@ pub fn build_bridge_script(
shell: {{ exec: (cmd, opts) => _call('shell.exec', Array.isArray(cmd) ? {{ args: cmd, ...(opts||{{}}) }} : {{ command: cmd, ...(opts||{{}}) }}) }},
net: {{ fetch: (url, opts) => _call('net.fetch', {{ url: typeof url === 'string' ? url : (url && url.url), ...(opts||{{}}) }}) }},
os: {{ info: () => _call('os.info', {{}}) }},
+ workspace: {{ info: () => _rpc('workspace.info', {{}}) }},
system: {{
openExternal: (url) => _rpc('system.openExternal', {{ url }}),
revealInFolder: (path) => _rpc('system.revealInFolder', {{ path }}),
@@ -131,6 +132,17 @@ pub fn build_bridge_script(
offEvent: (fn) => app.off('agent:event', fn),
}},
+ // Cron namespace — create/list/delete host scheduled jobs so an agentic
+ // MiniApp can wire its own recurring heartbeat (e.g. a LoopX goal driven
+ // by the host CronService) without leaving the iframe. Requires manifest
+ // permissions.cron.enabled = true; enforced host-side. The host reuses the
+ // existing cron Tauri commands; this bridge only routes the call.
+ cron: {{
+ createJob: (request) => _rpc('cron.createJob', request || {{}}),
+ listJobs: (opts) => _rpc('cron.listJobs', opts || {{}}),
+ deleteJob: (jobId) => _rpc('cron.deleteJob', {{ jobId }}),
+ }},
+
// Deck namespace — renders one slide HTML page in a hidden host WebView
// and returns base64 PNG/PDF. Used by presentation MiniApps for
// page-by-page export rasterization.
diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin.rs b/src/crates/contracts/product-domains/src/miniapp/builtin.rs
index cc7d09437a..e0d369850a 100644
--- a/src/crates/contracts/product-domains/src/miniapp/builtin.rs
+++ b/src/crates/contracts/product-domains/src/miniapp/builtin.rs
@@ -149,6 +149,16 @@ pub const BUILTIN_APPS: &[BuiltinMiniAppBundle] = &[
worker_js: include_str!("builtin/assets/coding-selfie/worker.js"),
esm_dependencies_json: "[]",
},
+ BuiltinMiniAppBundle {
+ id: "builtin-loopx-console",
+ version: 6,
+ meta_json: include_str!("builtin/assets/loopx-console/meta.json"),
+ html: include_str!("builtin/assets/loopx-console/index.html"),
+ css: include_str!("builtin/assets/loopx-console/style.css"),
+ ui_js: include_str!("builtin/assets/loopx-console/ui.js"),
+ worker_js: include_str!("builtin/assets/loopx-console/worker.js"),
+ esm_dependencies_json: "[]",
+ },
BuiltinMiniAppBundle {
id: "builtin-ppt-live",
version: 258,
@@ -367,6 +377,7 @@ mod tests {
"builtin-daily-divination",
"builtin-regex-playground",
"builtin-coding-selfie",
+ "builtin-loopx-console",
"builtin-ppt-live",
]
);
diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/esm_dependencies.json b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/esm_dependencies.json
new file mode 100644
index 0000000000..fe51488c70
--- /dev/null
+++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/esm_dependencies.json
@@ -0,0 +1 @@
+[]
diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/index.html b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/index.html
new file mode 100644
index 0000000000..2edfd22b8a
--- /dev/null
+++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/index.html
@@ -0,0 +1,270 @@
+
+
+
+
+
+ Issue Autofix
+
+
+
+
+
+
+
+
Issue 自动修复
+
+ GitHub Issues
+
+ LoopX
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 执行工作区
+ --
+ --
+
+
+
+
+
+
+
+ --
+ 自动推进
+
+
+ --
+ Issue 队列
+
+
+ --
+ 最近交付
+
+
+ --
+ 需要确认
+
+
+
+
+
+
Issue 交付流水线
+ --
+
+
+
1监控
+
2筛选
+
3修复
+
4验证
+
5PR
+
+
+
+
+
+
+
+
+
+
导入当前 Open Issues
+
--
+
+
+
+
+
+
+
+
模式--
+
状态--
+
等待对象--
+
下次推进--
+
+
+
+
+
+
+
+
+ 执行详情
+
+
+ --
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/meta.json b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/meta.json
new file mode 100644
index 0000000000..89ce4fbaae
--- /dev/null
+++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/meta.json
@@ -0,0 +1,57 @@
+{
+ "id": "builtin-loopx-console",
+ "name": "Issue 自动修复",
+ "description": "首次导入当前仓库已有的 Open Issues,并通过 LoopX 原生流程完成筛选、修复、验证和 PR 交付。",
+ "icon": "Route",
+ "category": "developer",
+ "tags": [
+ "LoopX",
+ "GitHub Issues",
+ "自动修复",
+ "Agent",
+ "内置"
+ ],
+ "version": 6,
+ "created_at": 0,
+ "updated_at": 0,
+ "permissions": {
+ "shell": {
+ "allow": [
+ "loopx"
+ ]
+ },
+ "net": {
+ "allow": []
+ },
+ "node": {
+ "enabled": false
+ },
+ "agent": {
+ "enabled": true,
+ "rate_limit_per_minute": 6
+ },
+ "cron": {
+ "enabled": true
+ }
+ },
+ "ai_context": null,
+ "i18n": {
+ "locales": {
+ "zh-CN": {
+ "name": "Issue 自动修复",
+ "description": "首次导入当前仓库已有的 Open Issues,并通过 LoopX 原生流程完成筛选、修复、验证和 PR 交付。",
+ "tags": ["LoopX", "GitHub Issues", "自动修复", "Agent", "内置"]
+ },
+ "en-US": {
+ "name": "Issue Autofix",
+ "description": "Import the repository's currently open GitHub Issues once and use LoopX's native flow to triage, fix, validate, and deliver pull requests.",
+ "tags": ["LoopX", "GitHub Issues", "autofix", "Agent", "built-in"]
+ },
+ "zh-TW": {
+ "name": "Issue 自動修復",
+ "description": "首次匯入目前倉庫已有的 Open Issues,並透過 LoopX 原生流程完成篩選、修復、驗證和 PR 交付。",
+ "tags": ["LoopX", "GitHub Issues", "自動修復", "Agent", "內置"]
+ }
+ }
+ }
+}
diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/style.css b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/style.css
new file mode 100644
index 0000000000..d1a1ad9c28
--- /dev/null
+++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/style.css
@@ -0,0 +1,1436 @@
+:root {
+ color-scheme: light dark;
+ font-family: var(--bitfun-font-sans, Inter, "Segoe UI", system-ui, sans-serif);
+ font-synthesis: none;
+ --lx-bg: var(--bitfun-bg, #f5f7f8);
+ --lx-surface: var(--bitfun-bg-secondary, #ffffff);
+ --lx-surface-subtle: var(--bitfun-bg-tertiary, #eef1f3);
+ --lx-border: var(--bitfun-border, #d8dee3);
+ --lx-text: var(--bitfun-text, #1c252b);
+ --lx-muted: var(--bitfun-text-muted, #64727c);
+ --lx-accent: var(--bitfun-accent, #087f71);
+ --lx-accent-contrast: var(--bitfun-accent-text, var(--bitfun-bg, #ffffff));
+ --lx-success: var(--bitfun-success, #16805c);
+ --lx-warning: var(--bitfun-warning, #a56300);
+ --lx-danger: var(--bitfun-error, #bd3a45);
+ --lx-info: var(--bitfun-info, #236f9f);
+ background: var(--lx-bg);
+ color: var(--lx-text);
+}
+
+html[data-theme-type="dark"] {
+ color-scheme: dark;
+}
+
+html[data-theme-type="light"] {
+ color-scheme: light;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+[hidden] {
+ display: none !important;
+}
+
+html,
+body {
+ width: 100%;
+ min-width: 320px;
+ height: 100%;
+ margin: 0;
+ overflow: hidden;
+ background: var(--lx-bg);
+}
+
+button,
+input,
+select,
+textarea {
+ color: inherit;
+ font: inherit;
+}
+
+button {
+ border: 0;
+}
+
+button:focus-visible,
+input:focus-visible,
+select:focus-visible,
+textarea:focus-visible,
+summary:focus-visible {
+ outline: 2px solid var(--lx-accent);
+ outline-offset: 2px;
+}
+
+.app-shell {
+ display: flex;
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+ flex-direction: column;
+ background: var(--lx-bg);
+}
+
+.topbar,
+.brand,
+.toolbar,
+.toolbar-status,
+.brand-meta,
+.workspace-band,
+.workspace-main,
+.workspace-action,
+.section-heading,
+.detail-title-row,
+.detail-action,
+.detail-meta,
+.modal-actions,
+.check-control,
+.target-workspace {
+ display: flex;
+ align-items: center;
+}
+
+.topbar {
+ position: relative;
+ z-index: 20;
+ justify-content: space-between;
+ min-height: 58px;
+ padding: 9px 16px;
+ border-bottom: 1px solid var(--lx-border);
+ background: var(--lx-surface);
+}
+
+.brand {
+ min-width: 0;
+ gap: 10px;
+}
+
+.brand-mark {
+ display: grid;
+ flex: 0 0 34px;
+ width: 34px;
+ height: 34px;
+ place-items: center;
+ border-radius: 7px;
+ background: var(--lx-accent);
+ color: var(--lx-accent-contrast);
+}
+
+.brand-mark svg {
+ width: 20px;
+ height: 20px;
+}
+
+.brand-copy {
+ min-width: 0;
+}
+
+.brand-copy h1 {
+ margin: 0;
+ overflow: hidden;
+ font-size: 15px;
+ font-weight: 680;
+ letter-spacing: 0;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.brand-copy p {
+ margin: 2px 0 0;
+ color: var(--lx-muted);
+ font-size: 11px;
+}
+
+.brand-meta {
+ min-width: 0;
+ gap: 6px;
+}
+
+.brand-meta span:not(.dot) {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.brand-meta .dot {
+ flex: 0 0 auto;
+ width: 3px;
+ height: 3px;
+ border-radius: 50%;
+ background: var(--lx-muted);
+}
+
+.toolbar {
+ flex: 0 0 auto;
+ gap: 8px;
+}
+
+.toolbar-status {
+ gap: 6px;
+ color: var(--lx-muted);
+ font-size: 12px;
+ white-space: nowrap;
+}
+
+.status-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--lx-muted);
+}
+
+.status-dot.online {
+ background: var(--lx-success);
+}
+
+.status-dot.error {
+ background: var(--lx-danger);
+}
+
+.goal-switcher {
+ position: relative;
+}
+
+.goal-switcher summary {
+ display: inline-flex;
+ min-height: 32px;
+ align-items: center;
+ gap: 6px;
+ padding: 5px 9px;
+ border: 1px solid var(--lx-border);
+ border-radius: 5px;
+ background: var(--lx-surface);
+ color: var(--lx-muted);
+ cursor: pointer;
+ font-size: 12px;
+ list-style: none;
+}
+
+.goal-switcher summary::-webkit-details-marker {
+ display: none;
+}
+
+.goal-switcher summary:hover {
+ background: var(--lx-surface-subtle);
+ color: var(--lx-text);
+}
+
+.goal-switcher summary svg {
+ width: 15px;
+ height: 15px;
+}
+
+.goal-menu {
+ position: absolute;
+ z-index: 30;
+ top: calc(100% + 7px);
+ right: 0;
+ width: min(380px, calc(100vw - 28px));
+ max-height: min(540px, calc(100vh - 92px));
+ overflow: hidden;
+ border: 1px solid var(--lx-border);
+ border-radius: 7px;
+ background: var(--lx-surface);
+ box-shadow: 0 14px 34px color-mix(in srgb, #000000 24%, transparent);
+}
+
+.goal-menu-header {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 10px 12px;
+ border-bottom: 1px solid var(--lx-border);
+}
+
+.goal-menu-header strong {
+ font-size: 12px;
+}
+
+.goal-menu-header span {
+ overflow: hidden;
+ color: var(--lx-muted);
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.goal-list {
+ max-height: min(470px, calc(100vh - 142px));
+ overflow: auto;
+ padding: 5px;
+}
+
+.goal-item {
+ position: relative;
+ display: grid;
+ width: 100%;
+ min-height: 60px;
+ gap: 5px;
+ padding: 8px 9px;
+ border: 1px solid transparent;
+ border-radius: 5px;
+ background: transparent;
+ text-align: left;
+ cursor: pointer;
+}
+
+.goal-item:hover {
+ background: var(--lx-surface-subtle);
+}
+
+.goal-item.selected {
+ border-color: color-mix(in srgb, var(--lx-accent) 44%, var(--lx-border));
+ background: color-mix(in srgb, var(--lx-accent) 7%, var(--lx-surface));
+}
+
+.goal-item.current::before {
+ position: absolute;
+ top: 8px;
+ bottom: 8px;
+ left: 2px;
+ width: 3px;
+ border-radius: 2px;
+ background: var(--lx-accent);
+ content: "";
+}
+
+.goal-primary {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.goal-title {
+ min-width: 0;
+ overflow: hidden;
+ font-size: 12px;
+ font-weight: 620;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.goal-path,
+.goal-meta {
+ overflow: hidden;
+ color: var(--lx-muted);
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.goal-path {
+ font-family: Consolas, "SFMono-Regular", monospace;
+}
+
+.goal-meta {
+ display: flex;
+ gap: 9px;
+}
+
+.goal-meta span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.goal-status,
+.detail-status {
+ flex: 0 0 auto;
+ padding: 2px 6px;
+ border-radius: 999px;
+ background: var(--lx-surface-subtle);
+ color: var(--lx-muted);
+ font-size: 10px;
+ font-weight: 650;
+}
+
+.goal-status.action,
+.detail-status.action {
+ background: color-mix(in srgb, var(--lx-success) 14%, var(--lx-surface));
+ color: var(--lx-success);
+}
+
+.goal-status.waiting,
+.detail-status.waiting {
+ background: color-mix(in srgb, var(--lx-warning) 14%, var(--lx-surface));
+ color: var(--lx-warning);
+}
+
+.icon-button,
+.primary-button,
+.secondary-button,
+.danger-action-button {
+ display: inline-flex;
+ min-height: 32px;
+ align-items: center;
+ justify-content: center;
+ border-radius: 5px;
+ cursor: pointer;
+}
+
+.icon-button {
+ width: 32px;
+ padding: 0;
+ border: 1px solid var(--lx-border);
+ background: var(--lx-surface);
+}
+
+.icon-button svg,
+.primary-button svg,
+.secondary-button svg {
+ width: 15px;
+ height: 15px;
+}
+
+.icon-button:hover:not(:disabled),
+.secondary-button:hover:not(:disabled) {
+ background: var(--lx-surface-subtle);
+}
+
+.icon-button.loading svg {
+ animation: spin 800ms linear infinite;
+}
+
+button:disabled {
+ cursor: not-allowed;
+ opacity: 0.48;
+}
+
+.primary-button,
+.secondary-button,
+.danger-action-button {
+ gap: 6px;
+ min-width: 92px;
+ padding: 6px 11px;
+ font-size: 12px;
+ font-weight: 620;
+}
+
+.primary-button {
+ background: var(--lx-accent);
+ color: var(--lx-accent-contrast);
+}
+
+.primary-button:hover:not(:disabled),
+.danger-action-button:hover:not(:disabled) {
+ filter: brightness(0.94);
+}
+
+.secondary-button {
+ border: 1px solid var(--lx-border);
+ background: var(--lx-surface);
+}
+
+.compact-button {
+ min-width: 0;
+ min-height: 28px;
+ padding: 4px 9px;
+}
+
+.danger-button {
+ color: var(--lx-danger);
+}
+
+.danger-button:hover:not(:disabled) {
+ border-color: color-mix(in srgb, var(--lx-danger) 45%, var(--lx-border));
+ background: color-mix(in srgb, var(--lx-danger) 8%, var(--lx-surface));
+}
+
+.danger-action-button {
+ border: 1px solid var(--lx-danger);
+ background: var(--lx-danger);
+ color: var(--lx-accent-contrast);
+}
+
+.text-button {
+ flex: 0 0 auto;
+ padding: 2px 4px;
+ background: transparent;
+ color: inherit;
+ cursor: pointer;
+ font-size: 11px;
+ font-weight: 620;
+}
+
+.notice {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-height: 34px;
+ padding: 6px 16px;
+ border-bottom: 1px solid color-mix(in srgb, var(--lx-warning) 35%, var(--lx-border));
+ background: color-mix(in srgb, var(--lx-warning) 10%, var(--lx-surface));
+ color: var(--lx-warning);
+ font-size: 12px;
+}
+
+.notice[data-kind="error"] {
+ border-bottom-color: color-mix(in srgb, var(--lx-danger) 35%, var(--lx-border));
+ background: color-mix(in srgb, var(--lx-danger) 8%, var(--lx-surface));
+ color: var(--lx-danger);
+}
+
+.notice[data-kind="success"] {
+ border-bottom-color: color-mix(in srgb, var(--lx-success) 35%, var(--lx-border));
+ background: color-mix(in srgb, var(--lx-success) 8%, var(--lx-surface));
+ color: var(--lx-success);
+}
+
+.notice-message {
+ min-width: 0;
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.workspace-band {
+ justify-content: space-between;
+ gap: 16px;
+ min-height: 52px;
+ padding: 7px 16px;
+ border-bottom: 1px solid var(--lx-border);
+ background: var(--lx-bg);
+}
+
+.workspace-main {
+ min-width: 0;
+ gap: 9px;
+}
+
+.workspace-main > svg {
+ flex: 0 0 auto;
+ width: 17px;
+ height: 17px;
+ color: var(--lx-muted);
+}
+
+.workspace-copy {
+ min-width: 0;
+}
+
+.workspace-kicker,
+.workspace-name,
+.workspace-path {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.workspace-kicker {
+ margin-bottom: 1px;
+ color: var(--lx-muted);
+ font-size: 9px;
+ font-weight: 650;
+ text-transform: uppercase;
+}
+
+.workspace-name {
+ font-size: 12px;
+ font-weight: 620;
+}
+
+.workspace-path {
+ margin-top: 1px;
+ color: var(--lx-muted);
+ font-family: Consolas, "SFMono-Regular", monospace;
+ font-size: 10px;
+}
+
+.workspace-action {
+ min-width: 0;
+ flex: 0 1 auto;
+ justify-content: flex-end;
+ gap: 10px;
+}
+
+.workspace-state {
+ max-width: 420px;
+ overflow: hidden;
+ color: var(--lx-muted);
+ font-size: 11px;
+ text-align: right;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.workspace-state.warning {
+ color: var(--lx-warning);
+}
+
+.workspace-state.ready {
+ color: var(--lx-success);
+}
+
+.summary-strip {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(110px, 1fr));
+ border-bottom: 1px solid var(--lx-border);
+ background: var(--lx-surface);
+}
+
+.metric {
+ min-width: 0;
+ padding: 9px 16px;
+ border-right: 1px solid var(--lx-border);
+}
+
+.metric:last-child {
+ border-right: 0;
+}
+
+.metric-value {
+ display: block;
+ overflow: hidden;
+ font-size: 18px;
+ font-weight: 680;
+ line-height: 1.15;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.metric-value--text {
+ font-size: 13px;
+ line-height: 21px;
+}
+
+.metric-value.ready {
+ color: var(--lx-success);
+}
+
+.metric-value.warning {
+ color: var(--lx-warning);
+}
+
+.metric-value.error {
+ color: var(--lx-danger);
+}
+
+.metric-label {
+ display: block;
+ margin-top: 2px;
+ overflow: hidden;
+ color: var(--lx-muted);
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.pipeline {
+ padding: 10px 16px 11px;
+ border-bottom: 1px solid var(--lx-border);
+ background: var(--lx-surface);
+}
+
+.pipeline-heading {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 8px;
+}
+
+.pipeline-heading h2 {
+ margin: 0;
+ font-size: 11px;
+ font-weight: 680;
+}
+
+.pipeline-heading span {
+ min-width: 0;
+ overflow: hidden;
+ color: var(--lx-muted);
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.pipeline-stages {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(84px, 1fr));
+ gap: 0;
+}
+
+.pipeline-stage {
+ position: relative;
+ display: flex;
+ min-width: 0;
+ min-height: 30px;
+ align-items: center;
+ gap: 7px;
+ padding: 4px 12px 4px 8px;
+ border-top: 1px solid var(--lx-border);
+ border-bottom: 1px solid var(--lx-border);
+ background: var(--lx-bg);
+ color: var(--lx-muted);
+ font-size: 11px;
+}
+
+.pipeline-stage:first-child {
+ border-left: 1px solid var(--lx-border);
+ border-radius: 5px 0 0 5px;
+}
+
+.pipeline-stage:last-child {
+ border-right: 1px solid var(--lx-border);
+ border-radius: 0 5px 5px 0;
+}
+
+.pipeline-stage:not(:last-child)::after {
+ position: absolute;
+ z-index: 1;
+ top: 9px;
+ right: -5px;
+ width: 9px;
+ height: 9px;
+ border-top: 1px solid var(--lx-border);
+ border-right: 1px solid var(--lx-border);
+ background: inherit;
+ content: "";
+ transform: rotate(45deg);
+}
+
+.stage-index {
+ display: grid;
+ flex: 0 0 18px;
+ width: 18px;
+ height: 18px;
+ place-items: center;
+ border: 1px solid var(--lx-border);
+ border-radius: 50%;
+ background: var(--lx-surface);
+ font-size: 9px;
+ font-weight: 700;
+}
+
+.pipeline-stage.complete {
+ background: color-mix(in srgb, var(--lx-success) 9%, var(--lx-surface));
+ color: var(--lx-success);
+}
+
+.pipeline-stage.active {
+ background: color-mix(in srgb, var(--lx-info) 10%, var(--lx-surface));
+ color: var(--lx-info);
+ font-weight: 650;
+}
+
+.pipeline-stage.blocked {
+ background: color-mix(in srgb, var(--lx-warning) 10%, var(--lx-surface));
+ color: var(--lx-warning);
+}
+
+.pipeline-stage.complete .stage-index,
+.pipeline-stage.active .stage-index,
+.pipeline-stage.blocked .stage-index {
+ border-color: currentColor;
+}
+
+.console-grid {
+ display: grid;
+ flex: 1 1 auto;
+ grid-template-columns: minmax(320px, 31%) minmax(0, 1fr);
+ min-height: 0;
+ background: var(--lx-surface);
+}
+
+.queue-pane,
+.detail-pane {
+ min-width: 0;
+ min-height: 0;
+ overflow: auto;
+}
+
+.queue-pane {
+ border-right: 1px solid var(--lx-border);
+ background: var(--lx-bg);
+}
+
+.pane-header {
+ position: sticky;
+ z-index: 5;
+ top: 0;
+ padding: 12px 13px 10px;
+ border-bottom: 1px solid var(--lx-border);
+ background: var(--lx-surface);
+}
+
+.section-heading {
+ min-width: 0;
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.section-heading > div {
+ min-width: 0;
+}
+
+.section-heading h2,
+.section-heading h3 {
+ margin: 0;
+ letter-spacing: 0;
+}
+
+.section-heading h2 {
+ font-size: 13px;
+ font-weight: 680;
+}
+
+.section-heading h3 {
+ color: var(--lx-muted);
+ font-size: 10px;
+ font-weight: 680;
+ text-transform: uppercase;
+}
+
+.section-subtitle {
+ margin: 3px 0 0;
+ overflow: hidden;
+ color: var(--lx-muted);
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.section-count,
+.count-badge {
+ color: var(--lx-muted);
+ font-size: 10px;
+}
+
+.count-badge {
+ display: inline-grid;
+ min-width: 22px;
+ height: 20px;
+ padding: 0 5px;
+ place-items: center;
+ border: 1px solid var(--lx-border);
+ border-radius: 999px;
+ background: var(--lx-surface-subtle);
+ font-weight: 650;
+}
+
+.queue-section {
+ min-height: 120px;
+ padding: 6px;
+}
+
+.issue-list {
+ display: grid;
+ gap: 4px;
+}
+
+.issue-item {
+ position: relative;
+ display: grid;
+ min-height: 76px;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 4px 9px;
+ padding: 10px 10px 9px;
+ border: 1px solid transparent;
+ border-radius: 6px;
+ background: var(--lx-surface);
+}
+
+.issue-item:first-child {
+ border-color: color-mix(in srgb, var(--lx-info) 40%, var(--lx-border));
+ background: color-mix(in srgb, var(--lx-info) 5%, var(--lx-surface));
+}
+
+.issue-priority {
+ grid-row: 1 / span 2;
+ align-self: start;
+ min-width: 28px;
+ padding: 2px 5px;
+ border-radius: 4px;
+ background: var(--lx-surface-subtle);
+ color: var(--lx-muted);
+ font-family: Consolas, "SFMono-Regular", monospace;
+ font-size: 9px;
+ font-weight: 700;
+ text-align: center;
+}
+
+.issue-priority.p0 {
+ background: color-mix(in srgb, var(--lx-danger) 12%, var(--lx-surface));
+ color: var(--lx-danger);
+}
+
+.issue-priority.p1 {
+ background: color-mix(in srgb, var(--lx-info) 12%, var(--lx-surface));
+ color: var(--lx-info);
+}
+
+.issue-title {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ font-size: 11px;
+ font-weight: 620;
+ line-height: 1.45;
+}
+
+.issue-meta {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ gap: 7px;
+ color: var(--lx-muted);
+ font-size: 9px;
+}
+
+.issue-meta span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.queue-empty {
+ display: grid;
+ min-height: 160px;
+ padding: 22px;
+ place-items: center;
+ color: var(--lx-muted);
+ font-size: 11px;
+ text-align: center;
+}
+
+.gate-section {
+ margin: 0 12px;
+ padding: 12px 0 16px;
+ border-top: 1px solid var(--lx-border);
+}
+
+.detail-pane {
+ padding: 0 20px 20px;
+ background: var(--lx-surface);
+}
+
+#detail-content {
+ display: flex;
+ min-height: 100%;
+ flex-direction: column;
+}
+
+.detail-header {
+ position: sticky;
+ z-index: 4;
+ top: 0;
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 14px;
+ padding: 14px 0 12px;
+ border-bottom: 1px solid var(--lx-border);
+ background: var(--lx-surface);
+}
+
+.detail-heading {
+ min-width: 0;
+}
+
+.detail-title-row {
+ min-width: 0;
+ gap: 8px;
+}
+
+.detail-heading h2 {
+ margin: 0;
+ overflow-wrap: anywhere;
+ font-size: 16px;
+ font-weight: 680;
+ letter-spacing: 0;
+}
+
+.detail-meta {
+ min-width: 0;
+ margin-top: 5px;
+ color: var(--lx-muted);
+ font-family: Consolas, "SFMono-Regular", monospace;
+ font-size: 10px;
+}
+
+.detail-meta span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.detail-action {
+ flex: 0 0 auto;
+ gap: 7px;
+}
+
+.detail-facts {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ border-bottom: 1px solid var(--lx-border);
+}
+
+.detail-facts > div {
+ min-width: 0;
+ padding: 10px 10px 10px 0;
+}
+
+.detail-facts span,
+.detail-facts strong {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.detail-facts span {
+ color: var(--lx-muted);
+ font-size: 9px;
+}
+
+.detail-facts strong {
+ margin-top: 3px;
+ font-size: 11px;
+ font-weight: 630;
+}
+
+.detail-section {
+ padding: 14px 0;
+ border-bottom: 1px solid var(--lx-border);
+}
+
+.next-action-section {
+ background: color-mix(in srgb, var(--lx-info) 4%, var(--lx-surface));
+}
+
+.detail-section p {
+ margin: 0;
+ overflow-wrap: anywhere;
+ font-size: 12px;
+ line-height: 1.6;
+ white-space: pre-wrap;
+}
+
+.adapter-label {
+ overflow: hidden;
+ color: var(--lx-muted);
+ font-family: Consolas, "SFMono-Regular", monospace;
+ font-size: 9px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.progress-section {
+ flex: 0 0 auto;
+ min-height: 0;
+}
+
+.detail-list,
+.timeline {
+ display: grid;
+ gap: 7px;
+}
+
+.detail-list-item,
+.timeline-item {
+ padding-left: 10px;
+ border-left: 2px solid var(--lx-border);
+ overflow-wrap: anywhere;
+ font-size: 11px;
+ line-height: 1.5;
+}
+
+.detail-list-item.gate {
+ border-left-color: var(--lx-warning);
+}
+
+.timeline-item {
+ display: grid;
+ grid-template-columns: 92px minmax(0, 1fr);
+ gap: 10px;
+ border-left-color: var(--lx-info);
+}
+
+.timeline-item time {
+ color: var(--lx-muted);
+ font-family: Consolas, "SFMono-Regular", monospace;
+ font-size: 9px;
+}
+
+.diagnostics {
+ margin-top: 0;
+ border-bottom: 1px solid var(--lx-border);
+}
+
+.diagnostics > summary {
+ display: flex;
+ min-height: 38px;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ color: var(--lx-muted);
+ cursor: pointer;
+ font-size: 10px;
+ font-weight: 680;
+ list-style-position: inside;
+ text-transform: uppercase;
+}
+
+.diagnostics > summary .text-button {
+ margin-left: auto;
+ text-transform: none;
+}
+
+.handoff-content {
+ min-height: min(240px, 28vh);
+ max-height: 300px;
+ overflow: auto;
+ padding: 10px 11px;
+ border: 1px solid var(--lx-border);
+ border-bottom: 0;
+ border-radius: 5px 5px 0 0;
+ background: var(--lx-bg);
+ color: var(--lx-muted);
+ font-family: Consolas, "SFMono-Regular", monospace;
+ font-size: 10px;
+ line-height: 1.55;
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+}
+
+.empty-state {
+ display: grid;
+ min-height: 220px;
+ padding: 30px;
+ place-content: center;
+ justify-items: center;
+ gap: 8px;
+ color: var(--lx-muted);
+ font-size: 12px;
+ text-align: center;
+}
+
+.empty-state svg {
+ width: 24px;
+ height: 24px;
+}
+
+.empty-state .primary-button {
+ margin-top: 6px;
+}
+
+.empty-state .primary-button svg {
+ width: 14px;
+ height: 14px;
+}
+
+.modal {
+ width: min(560px, calc(100% - 28px));
+ padding: 0;
+ border: 1px solid var(--lx-border);
+ border-radius: 7px;
+ background: var(--lx-surface);
+ color: var(--lx-text);
+ box-shadow: 0 18px 45px color-mix(in srgb, #000000 28%, transparent);
+}
+
+.modal::backdrop {
+ background: color-mix(in srgb, #000000 48%, transparent);
+}
+
+.modal form {
+ margin: 0;
+}
+
+.modal-header,
+.modal-body,
+.modal-actions {
+ padding: 14px 16px;
+}
+
+.modal-header {
+ border-bottom: 1px solid var(--lx-border);
+}
+
+.modal-header h2 {
+ margin: 0;
+ font-size: 14px;
+ letter-spacing: 0;
+}
+
+.setup-form > label:not(.check-control),
+.form-row label {
+ display: block;
+ margin-bottom: 6px;
+ color: var(--lx-muted);
+ font-size: 10px;
+ font-weight: 650;
+}
+
+.setup-form input[type="url"],
+.setup-form select,
+.setup-form textarea {
+ width: 100%;
+ padding: 8px 9px;
+ border: 1px solid var(--lx-border);
+ border-radius: 5px;
+ background: var(--lx-bg);
+ font-size: 12px;
+}
+
+.setup-form input[type="url"] {
+ margin-bottom: 13px;
+}
+
+.setup-form textarea {
+ min-height: 88px;
+ resize: vertical;
+ line-height: 1.5;
+}
+
+.form-row {
+ margin-bottom: 13px;
+}
+
+.check-control {
+ gap: 8px;
+ min-height: 34px;
+ margin-bottom: 13px;
+ font-size: 11px;
+ cursor: pointer;
+}
+
+.check-control input {
+ width: 15px;
+ height: 15px;
+ accent-color: var(--lx-accent);
+}
+
+.target-workspace {
+ min-width: 0;
+ justify-content: space-between;
+ gap: 12px;
+ margin-top: 12px;
+ padding-top: 10px;
+ border-top: 1px solid var(--lx-border);
+ color: var(--lx-muted);
+ font-size: 10px;
+}
+
+.target-workspace strong {
+ min-width: 0;
+ overflow: hidden;
+ color: var(--lx-text);
+ font-family: Consolas, "SFMono-Regular", monospace;
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.compact-modal {
+ width: min(440px, calc(100% - 28px));
+}
+
+.delete-dialog-body p {
+ margin: 0;
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.delete-dialog-body code {
+ display: block;
+ margin: 12px 0 0;
+ padding: 9px 10px;
+ border: 1px solid var(--lx-border);
+ border-radius: 5px;
+ background: var(--lx-bg);
+ overflow-wrap: anywhere;
+ font-family: Consolas, "SFMono-Regular", monospace;
+ font-size: 11px;
+}
+
+.delete-dialog-body .workspace-path {
+ margin: 6px 0 12px;
+}
+
+.delete-dialog-body .modal-note {
+ color: var(--lx-muted);
+ font-size: 11px;
+}
+
+.modal-actions {
+ justify-content: flex-end;
+ gap: 8px;
+ border-top: 1px solid var(--lx-border);
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (max-width: 900px) {
+ .console-grid {
+ grid-template-columns: minmax(280px, 38%) minmax(0, 1fr);
+ }
+
+ .detail-facts {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .detail-facts > div:nth-child(-n + 2) {
+ border-bottom: 1px solid var(--lx-border);
+ }
+}
+
+@media (max-width: 720px) {
+ body {
+ overflow: auto;
+ }
+
+ .app-shell {
+ min-height: 100%;
+ height: auto;
+ }
+
+ .toolbar-status {
+ display: none;
+ }
+
+ .workspace-band,
+ .detail-header {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .workspace-action {
+ justify-content: space-between;
+ }
+
+ .workspace-state {
+ max-width: none;
+ text-align: left;
+ }
+
+ .summary-strip {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .metric:nth-child(2) {
+ border-right: 0;
+ }
+
+ .metric:nth-child(n + 3) {
+ border-top: 1px solid var(--lx-border);
+ }
+
+ .pipeline-stages {
+ overflow-x: auto;
+ grid-template-columns: repeat(5, minmax(104px, 1fr));
+ }
+
+ .console-grid {
+ display: block;
+ }
+
+ .queue-pane,
+ .detail-pane {
+ overflow: visible;
+ }
+
+ .queue-pane {
+ border-right: 0;
+ border-bottom: 1px solid var(--lx-border);
+ }
+
+ .pane-header,
+ .detail-header {
+ position: static;
+ }
+
+ .queue-section {
+ max-height: 330px;
+ overflow: auto;
+ }
+
+ .detail-action {
+ width: 100%;
+ }
+
+ .detail-action .primary-button,
+ .detail-action .secondary-button {
+ flex: 1 1 auto;
+ }
+}
+
+@media (max-width: 480px) {
+ .topbar {
+ min-height: 54px;
+ padding-right: 12px;
+ padding-left: 12px;
+ }
+
+ .goal-switcher summary span:not(.section-count) {
+ display: none;
+ }
+
+ .workspace-band,
+ .pipeline,
+ .detail-pane {
+ padding-right: 12px;
+ padding-left: 12px;
+ }
+
+ .workspace-action {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .workspace-action .secondary-button {
+ width: 100%;
+ }
+
+ .pipeline-heading {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 3px;
+ }
+
+ .detail-action {
+ display: grid;
+ grid-template-columns: 32px minmax(0, 1fr);
+ }
+
+ .detail-action #continue-button {
+ grid-column: 1 / -1;
+ }
+
+ .detail-facts {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .detail-facts > div {
+ border-bottom: 1px solid var(--lx-border);
+ }
+
+ .timeline-item {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 2px;
+ }
+
+ .modal-header,
+ .modal-body,
+ .modal-actions {
+ padding-right: 13px;
+ padding-left: 13px;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .icon-button.loading svg {
+ animation: none;
+ }
+}
diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/ui.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/ui.js
new file mode 100644
index 0000000000..72c1dbce23
--- /dev/null
+++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/ui.js
@@ -0,0 +1,1056 @@
+const CACHE_KEY = 'loopx-console-cache-v2';
+const CACHE_VERSION = 2;
+const COMMAND_TIMEOUT_MS = 60_000;
+const DEFAULT_CADENCE_MS = 60 * 60 * 1000;
+const LOG_KEY = 'loopx-console-error-log';
+
+const copy = {
+ 'zh-CN': {
+ title: 'Issue 自动修复',
+ otherGoals: '其他目标',
+ goals: 'LoopX 目标',
+ updated: '更新于 {time}',
+ neverUpdated: '尚未更新',
+ ready: '可用',
+ refreshing: '正在刷新',
+ cached: '正在显示缓存',
+ unavailable: '不可用',
+ retry: '重试',
+ refresh: '刷新',
+ executionWorkspace: '执行工作区',
+ noWorkspace: '未打开工作区',
+ localWorkspace: '本地工作区',
+ remoteReadOnly: '远程工作区暂不支持自动修复',
+ globalReadOnly: '打开本地工作区后可配置自动修复',
+ matchedGoal: '已绑定当前工作区',
+ noMatchedGoal: '尚未配置 GitHub Issue 自动修复',
+ workspaceMismatch: '监控源与当前工作区不一致,已暂停自动推进',
+ configureAutofix: '导入当前 Issues',
+ startTracking: '导入当前 Issues',
+ selectWorkspaceFirst: '请先在左侧选择一个本地工作区,再开始跟踪 Issue。',
+ monitor: '自动推进',
+ queue: '初始 Issue 队列',
+ recentDelivery: '最近交付',
+ confirmations: '需要确认',
+ pipeline: 'Issue 交付流水线',
+ pipelineIdle: '等待导入当前 Issues',
+ pipelineReady: '按初始队列逐个修复并交付 PR',
+ pipelineBlocked: '自动推进已暂停,请先处理异常',
+ pipelineMonitor: '监控',
+ pipelineTriage: '筛选',
+ pipelineFix: '修复',
+ pipelineValidate: '验证',
+ pipelinePr: 'PR',
+ noGoals: '没有其他 LoopX 目标',
+ current: '当前',
+ continue: '立即推进',
+ running: 'Agent 执行中',
+ mode: '模式',
+ modeAutofix: '自动修复 + PR',
+ modeTracking: '仅跟踪',
+ modeUnconfigured: '未配置',
+ status: '状态',
+ waitingOn: '等待对象',
+ heartbeatStatus: '下次推进',
+ monitorReady: '运行中',
+ monitorMissing: '未配置',
+ monitorBroken: '需修复',
+ monitorUnavailable: '不可读取',
+ monitorRepair: '修复调度',
+ monitorRepairing: '正在修复',
+ monitorRepairSuccess: '自动推进已绑定到正确的 Goal 和工作区。',
+ monitorRepairFailed: '自动推进修复失败,请展开执行详情检查 LoopX。',
+ currentExecution: '当前执行',
+ noNextAction: 'LoopX 暂未提供下一步动作。',
+ queueEmpty: '初始导入范围内没有可执行 Issue。',
+ userGate: '需要确认',
+ executionDetails: '执行详情',
+ reload: '重新加载',
+ detailLoading: '正在读取 Goal 状态和交接包...',
+ detailUnavailable: '执行详情暂不可用,可重试加载。',
+ selectGoal: '导入当前 Open Issues',
+ selectGoalCopy: '指定 GitHub Issues 地址和推进频率。LoopX 只导入配置时已有的 Open Issues。',
+ setupTitle: '导入当前 Issues',
+ issueSource: 'GitHub Issues(首次导入)',
+ issueScopeNote: '之后新增的 Issue 不会加入;修复和临时文件都不得改动当前目录,代码只在独立 worktree 中执行。',
+ issueSourceRequired: '请输入有效的 GitHub Issues 地址。',
+ cadence: 'Agent 自动推进间隔',
+ cadence30m: '每 30 分钟(高频)',
+ cadence1h: '每小时(推荐)',
+ cadence6h: '每 6 小时(低频)',
+ cadence1d: '每天',
+ autoPr: '修复后推送 fork 并创建 PR',
+ constraints: '处理规则(可选)',
+ constraintsPlaceholder: '例如:只处理 bug 标签;每轮最多修复一个 Issue',
+ targetWorkspace: '执行工作区',
+ cancel: '取消',
+ startInAgent: '交给 Agent 导入',
+ close: '关闭',
+ loopxMissing: '未找到 LoopX。请先安装或修复 PATH,然后重试。',
+ incompatible: 'LoopX JSON 契约版本不兼容。已保留上一次成功数据。',
+ invalidData: 'LoopX 返回了无效数据。已保留上一次成功数据。',
+ refreshFailed: '刷新失败。已保留上一次成功数据。',
+ noCacheSuffix: '当前没有可用缓存。',
+ agentStarted: 'Agent 已启动,正在导入初始 Issue;完成后会创建自动推进。',
+ agentFailed: 'Agent 启动失败,请重试。',
+ currentOnly: '只能在该 Goal 对应的本地工作区推进。',
+ sourceMismatch: 'GitHub 仓库与当前目录不匹配,请检查地址。',
+ cacheRestored: '已恢复上次成功数据,正在后台刷新。',
+ composerPlaceholder: '让 Agent 按 LoopX 推进当前 Issue',
+ heartbeatAutoCreated: '自动推进已创建,BitFun 将按设定频率处理初始 Issue 队列。',
+ deleteGoal: '删除目标',
+ deleteGoalTitle: '删除 LoopX 目标',
+ deleteGoalCopy: '这会断开该目标与对应项目的连接。',
+ deleteGoalArchive: 'LoopX 会先备份注册表,并归档该项目中的目标状态。',
+ deleteGoalConfirm: '确认删除',
+ deleteGoalDeleting: '正在删除',
+ deleteGoalSuccess: '目标已删除,原状态已归档。',
+ deleteGoalFailed: '目标删除失败,请检查 LoopX 状态后重试。',
+ deleteUnavailable: '该目标缺少可用的本地项目路径,暂时无法删除。',
+ },
+ 'en-US': {
+ title: 'Issue Autofix',
+ otherGoals: 'Other goals',
+ goals: 'LoopX goals',
+ updated: 'Updated {time}',
+ neverUpdated: 'Not updated yet',
+ ready: 'Ready',
+ refreshing: 'Refreshing',
+ cached: 'Showing cache',
+ unavailable: 'Unavailable',
+ retry: 'Retry',
+ refresh: 'Refresh',
+ executionWorkspace: 'Execution workspace',
+ noWorkspace: 'No workspace open',
+ localWorkspace: 'Local workspace',
+ remoteReadOnly: 'Remote workspaces do not support autofix yet',
+ globalReadOnly: 'Open a local workspace to configure autofix',
+ matchedGoal: 'Bound to the current workspace',
+ noMatchedGoal: 'GitHub Issue autofix is not configured',
+ workspaceMismatch: 'The issue source does not match this workspace; automation is paused',
+ configureAutofix: 'Import current issues',
+ startTracking: 'Import current issues',
+ selectWorkspaceFirst: 'Select a local workspace in the sidebar before tracking issues.',
+ monitor: 'Auto advance',
+ queue: 'Initial issue queue',
+ recentDelivery: 'Recent delivery',
+ confirmations: 'Confirmations',
+ pipeline: 'Issue delivery pipeline',
+ pipelineIdle: 'Waiting to import current issues',
+ pipelineReady: 'Fix the initial queue and deliver pull requests',
+ pipelineBlocked: 'Automation is paused until the issue is fixed',
+ pipelineMonitor: 'Monitor',
+ pipelineTriage: 'Triage',
+ pipelineFix: 'Fix',
+ pipelineValidate: 'Validate',
+ pipelinePr: 'PR',
+ noGoals: 'No other LoopX goals',
+ current: 'Current',
+ continue: 'Advance now',
+ running: 'Agent running',
+ mode: 'Mode',
+ modeAutofix: 'Autofix + PR',
+ modeTracking: 'Tracking only',
+ modeUnconfigured: 'Not configured',
+ status: 'Status',
+ waitingOn: 'Waiting on',
+ heartbeatStatus: 'Next run',
+ monitorReady: 'Running',
+ monitorMissing: 'Not configured',
+ monitorBroken: 'Needs repair',
+ monitorUnavailable: 'Unavailable',
+ monitorRepair: 'Repair monitor',
+ monitorRepairing: 'Repairing',
+ monitorRepairSuccess: 'The monitor is now bound to the correct goal and workspace.',
+ monitorRepairFailed: 'Could not repair the monitor. Inspect execution details for LoopX state.',
+ currentExecution: 'Current execution',
+ noNextAction: 'LoopX has not provided a next action.',
+ queueEmpty: 'No actionable issues remain in the initial import.',
+ userGate: 'Confirmations',
+ executionDetails: 'Execution details',
+ reload: 'Reload',
+ detailLoading: 'Loading goal status and handoff...',
+ detailUnavailable: 'Execution details are unavailable. Retry when ready.',
+ selectGoal: 'Import current open issues',
+ selectGoalCopy: 'Choose a GitHub Issues URL and cadence. LoopX imports only the issues that are open during setup.',
+ setupTitle: 'Import current issues',
+ issueSource: 'GitHub Issues (initial import)',
+ issueScopeNote: 'Issues opened later are ignored. Fixes and temporary files must not change this checkout; code work runs in isolated worktrees.',
+ issueSourceRequired: 'Enter a valid GitHub Issues URL.',
+ cadence: 'Agent advance interval',
+ cadence30m: 'Every 30 minutes (high frequency)',
+ cadence1h: 'Hourly (recommended)',
+ cadence6h: 'Every 6 hours (low frequency)',
+ cadence1d: 'Daily',
+ autoPr: 'Push to a fork and open a PR after each fix',
+ constraints: 'Processing rules (optional)',
+ constraintsPlaceholder: 'For example: bug label only; at most one issue per run',
+ targetWorkspace: 'Execution workspace',
+ cancel: 'Cancel',
+ startInAgent: 'Import with Agent',
+ close: 'Close',
+ loopxMissing: 'LoopX was not found. Install it or repair PATH, then retry.',
+ incompatible: 'The LoopX JSON contract is incompatible. Cached data is preserved.',
+ invalidData: 'LoopX returned invalid data. Cached data is preserved.',
+ refreshFailed: 'Refresh failed. Cached data is preserved.',
+ noCacheSuffix: 'No cached data is available.',
+ agentStarted: 'The Agent started and is importing the initial issues; automatic advancement will be scheduled after it completes.',
+ agentFailed: 'Could not start the Agent. Retry when ready.',
+ currentOnly: 'This goal can only advance in its local workspace.',
+ sourceMismatch: 'The GitHub repository does not match the current folder.',
+ cacheRestored: 'Restored cached data and refreshing in the background.',
+ composerPlaceholder: 'Ask the Agent to advance the current issue through LoopX',
+ heartbeatAutoCreated: 'Automatic advancement is scheduled for the initial issue queue.',
+ deleteGoal: 'Delete goal',
+ deleteGoalTitle: 'Delete LoopX goal',
+ deleteGoalCopy: 'This disconnects the goal from its project.',
+ deleteGoalArchive: 'LoopX backs up the registry and archives the project state first.',
+ deleteGoalConfirm: 'Delete goal',
+ deleteGoalDeleting: 'Deleting',
+ deleteGoalSuccess: 'The goal was deleted and its previous state was archived.',
+ deleteGoalFailed: 'Could not delete the goal. Check LoopX state and retry.',
+ deleteUnavailable: 'This goal does not have an available local project path.',
+ },
+};
+
+const state = {
+ locale: 'zh-CN',
+ version: '',
+ registry: null,
+ summary: null,
+ fetchedAt: '',
+ workspace: { available: false, name: '', path: '', kind: null, isRemote: false },
+ selectedGoalId: null,
+ packets: new Map(),
+ cronJobs: [],
+ cronAvailable: false,
+ refreshing: false,
+ repairingHeartbeat: false,
+ running: null,
+ notice: null,
+ pendingNewGoal: null,
+ deletingGoalId: null,
+};
+
+const dom = {};
+
+const errorLog = [];
+const MAX_LOG_ENTRIES = 50;
+
+function logError(context, error) {
+ const entry = {
+ time: new Date().toISOString(),
+ context: String(context),
+ message: error?.message || String(error),
+ stack: error?.stack || '',
+ state: {
+ running: state.running ? { sessionId: state.running.sessionId, turnId: state.running.turnId, goalId: state.running.goalId } : null,
+ pendingNewGoal: state.pendingNewGoal ? { workspacePath: state.pendingNewGoal.workspacePath, issueUrl: state.pendingNewGoal.issueUrl } : null,
+ selectedGoalId: state.selectedGoalId,
+ workspaceAvailable: state.workspace.available,
+ workspacePath: state.workspace.path,
+ workspaceIsRemote: state.workspace.isRemote,
+ refreshing: state.refreshing,
+ repairingHeartbeat: state.repairingHeartbeat,
+ deletingGoalId: state.deletingGoalId,
+ registryGoals: asArray(state.registry?.goals).map((g) => g.id),
+ },
+ };
+ errorLog.push(entry);
+ if (errorLog.length > MAX_LOG_ENTRIES) errorLog.shift();
+ console.error('[loopx-console]', context, error);
+ void flushErrorLog().catch(() => {});
+}
+
+async function flushErrorLog() {
+ try { await window.app.storage.set(LOG_KEY, errorLog); } catch { /* storage may be unavailable during init */ }
+}
+
+async function readErrorLog() {
+ try {
+ let cached = await window.app.storage.get(LOG_KEY);
+ if (typeof cached === 'string') cached = JSON.parse(cached);
+ return Array.isArray(cached) ? cached : [];
+ } catch { return []; }
+}
+
+function $(id) { return document.getElementById(id); }
+
+function t(key, values = {}) {
+ const dictionary = copy[state.locale] || copy['en-US'];
+ let value = dictionary[key] || copy['en-US'][key] || key;
+ for (const [name, replacement] of Object.entries(values)) {
+ value = value.replace(`{${name}}`, String(replacement));
+ }
+ return value;
+}
+
+function resolveLocale(locale) {
+ return String(locale || '').toLowerCase().startsWith('zh') ? 'zh-CN' : 'en-US';
+}
+
+function createElement(tagName, className, text) {
+ const element = document.createElement(tagName);
+ if (className) element.className = className;
+ if (text !== undefined && text !== null) element.textContent = String(text);
+ return element;
+}
+
+function asArray(value) { return Array.isArray(value) ? value : []; }
+function numberOrZero(value) { const number = Number(value); return Number.isFinite(number) ? number : 0; }
+
+function formatTimestamp(value) {
+ if (!value) return '--';
+ return String(value).replace('T', ' ').replace(/:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/, '');
+}
+
+function formatEpoch(value) {
+ const milliseconds = Number(value);
+ if (!Number.isFinite(milliseconds) || milliseconds <= 0) return '--';
+ return new Intl.DateTimeFormat(state.locale, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
+ .format(new Date(milliseconds));
+}
+
+function normalizePath(value) {
+ let path = String(value || '').trim().replace(/\//g, '\\');
+ while (path.length > 3 && path.endsWith('\\')) path = path.slice(0, -1);
+ return /^[A-Za-z]:\\/.test(path) ? path.toLowerCase() : path;
+}
+
+function selectedGoal() {
+ return asArray(state.registry?.goals).find((goal) => goal.id === state.selectedGoalId) || null;
+}
+
+function currentGoal() {
+ if (!state.workspace.available || state.workspace.isRemote) return null;
+ const workspacePath = normalizePath(state.workspace.path);
+ return asArray(state.registry?.goals).find((goal) => normalizePath(goal.repo) === workspacePath) || null;
+}
+
+function canDeleteGoal(goal) {
+ return Boolean(goal && state.workspace.available && !state.workspace.isRemote && String(goal.repo || '').trim());
+}
+
+function packetState(goalId) { return state.packets.get(goalId) || null; }
+
+function statusAttentionItem(goalId) {
+ return asArray(packetState(goalId)?.statusData?.attention_queue?.items)
+ .find((item) => item.goal_id === goalId) || null;
+}
+
+function goalFacts(goalId) {
+ const groups = state.summary?.groups || {};
+ const lanes = asArray(state.summary?.lanes);
+ const attention = statusAttentionItem(goalId);
+ const asset = attention?.project_asset || {};
+ const detailedTodos = asArray(asset.agent_todos?.items || attention?.agent_todos?.first_open_items);
+ const statusProgress = asArray(packetState(goalId)?.statusData?.run_history?.recent_runs);
+ return {
+ lane: attention || lanes.find((item) => item.goal_id === goalId) || null,
+ gates: asArray(state.summary?.gates || groups.user_gates).filter((item) => item.goal_id === goalId),
+ todos: detailedTodos.length
+ ? detailedTodos.filter((item) => item.done !== true && item.status !== 'done')
+ : asArray(state.summary?.todos || groups.runnable_agent_work).filter((item) => item.goal_id === goalId),
+ progress: statusProgress.length
+ ? statusProgress
+ : asArray(state.summary?.recent_progress || groups.recent_progress).filter((item) => item.goal_id === goalId),
+ asset,
+ };
+}
+
+function searchableGoalText(goalId) {
+ const goal = asArray(state.registry?.goals).find((item) => item.id === goalId);
+ const details = packetState(goalId);
+ return JSON.stringify({ goal, summary: goalFacts(goalId), status: details?.statusData, packet: details?.packet });
+}
+
+function detectIssueSource(goalId) {
+ const match = searchableGoalText(goalId).match(/https:\/\/github\.com\/([^/\s\"'<>]+)\/([^/\s\"'<>]+?)(?:\.git)?(?:\/issues(?:[/?#][^\s\"'<>]*)?)?(?=[\s\"'<>]|$)/i);
+ if (!match) return '';
+ return `https://github.com/${match[1]}/${match[2]}/issues`;
+}
+
+function issueRepository(source) {
+ const match = String(source || '').match(/^https:\/\/github\.com\/([^/]+)\/([^/?#]+)\/issues(?:[/?#].*)?$/i);
+ return match ? { owner: match[1], repo: match[2].replace(/\.git$/i, '') } : null;
+}
+
+function repositoryMismatch(goal) {
+ const repository = issueRepository(detectIssueSource(goal?.id));
+ if (!repository || !goal?.repo) return false;
+ const folder = normalizePath(goal.repo).split('\\').filter(Boolean).at(-1) || '';
+ return folder.toLowerCase() !== repository.repo.toLowerCase();
+}
+
+function isIssueGoal(goal) {
+ if (!goal) return false;
+ const text = searchableGoalText(goal.id).toLowerCase();
+ return Boolean(detectIssueSource(goal.id)) || text.includes('github_issue') || text.includes('issue monitor');
+}
+
+function isAutofixGoal(goalId) {
+ const text = searchableGoalText(goalId).toLowerCase();
+ const fixes = /auto.?fix|修复|implement|pull request|\bpr\b/.test(text);
+ return isIssueGoal(asArray(state.registry?.goals).find((goal) => goal.id === goalId)) && fixes;
+}
+
+function ownedHeartbeatJobs(goalId) {
+ return state.cronJobs.filter((job) => {
+ const name = String(job?.name || '');
+ const text = String(job?.payload?.text || '');
+ return name.endsWith(`: ${goalId}`) || text.includes(`goal_id=${goalId}`) || text.includes(`\`${goalId}\``);
+ });
+}
+
+function heartbeatFacts(goal) {
+ if (!state.cronAvailable) return { state: 'unavailable', job: null, jobs: [] };
+ const jobs = ownedHeartbeatJobs(goal?.id);
+ const job = jobs.find((item) => normalizePath(item?.target?.workspace?.workspacePath) === normalizePath(goal?.repo)) || jobs[0] || null;
+ if (!job) return { state: 'missing', job: null, jobs };
+ const healthy = job.enabled !== false &&
+ normalizePath(job?.target?.workspace?.workspacePath) === normalizePath(goal?.repo) &&
+ numberOrZero(job?.state?.consecutiveFailures) === 0;
+ return { state: healthy ? 'ready' : 'broken', job, jobs };
+}
+
+function goalCategory(goal) {
+ const facts = goalFacts(goal.id);
+ const status = String(facts.lane?.status || goal.status || '').toLowerCase();
+ if (facts.gates.length || status.includes('gate')) return 'waiting';
+ if (facts.todos.length || ['eligible', 'active'].includes(status)) return 'action';
+ return 'neutral';
+}
+
+function isLoopxMissing(error) {
+ const message = String(error?.message || error || '').toLowerCase();
+ return ['not found', 'not recognized', 'cannot find', 'enoent'].some((part) => message.includes(part));
+}
+
+function schemaError() { const error = new Error('LOOPX_SCHEMA_INCOMPATIBLE'); error.code = 'schema'; return error; }
+function invalidDataError() { const error = new Error('LOOPX_INVALID_DATA'); error.code = 'invalid'; return error; }
+
+async function runLoopx(args, timeout = COMMAND_TIMEOUT_MS, cwd = window.app.appDataDir) {
+ const result = await window.app.shell.exec(['loopx', ...args], { cwd, timeout });
+ return String(result?.stdout || '').trim();
+}
+
+async function runLoopxJson(args, cwd) {
+ const stdout = await runLoopx(args, COMMAND_TIMEOUT_MS, cwd);
+ try { return JSON.parse(stdout.replace(/^\uFEFF/, '')); } catch { throw invalidDataError(); }
+}
+
+function validateRegistry(value) {
+ if (value?.schema_version !== '0.1') throw schemaError();
+ if (value?.ok !== true || !Array.isArray(value.goals)) throw invalidDataError();
+ return value;
+}
+
+function validateSummary(value) {
+ if (value?.schema_version !== 'global_manager_command_response_v0') throw schemaError();
+ if (value?.ok !== true || !value.summary || !Array.isArray(value.lanes)) throw invalidDataError();
+ return value;
+}
+
+function validatePacket(value, goalId) {
+ if (value?.ok !== true || value?.goal_id !== goalId || value?.handoff_only !== true ||
+ (typeof value.handoff_text !== 'string' && typeof value.project_agent_handoff !== 'string')) throw schemaError();
+ return value;
+}
+
+function validateGoalStatus(value, goalId) {
+ if (value?.ok !== true || value?.goal_filter !== goalId || numberOrZero(value?.status_contract?.schema_version) < 2) throw schemaError();
+ return value;
+}
+
+function validateGoalDeletion(value, goalId) {
+ if (value?.ok !== true || value?.schema_version !== 'loopx_project_uninstall_v0' || value?.execute !== true ||
+ !asArray(value.goal_ids).includes(goalId)) throw schemaError();
+ return value;
+}
+
+function validateHeartbeatPrompt(value, goalId) {
+ if (value?.ok !== true || value?.goal_id !== goalId || typeof value?.task_body !== 'string' || !value.task_body.trim()) throw schemaError();
+ return value;
+}
+
+async function readCache() {
+ try {
+ let cached = await window.app.storage.get(CACHE_KEY);
+ if (typeof cached === 'string') cached = JSON.parse(cached);
+ if (cached?.cacheVersion === CACHE_VERSION && cached.registry?.schema_version === '0.1' &&
+ cached.summary?.schema_version === 'global_manager_command_response_v0') {
+ state.version = String(cached.version || '');
+ state.registry = cached.registry;
+ state.summary = cached.summary;
+ state.fetchedAt = String(cached.fetchedAt || '');
+ state.selectedGoalId = cached.selectedGoalId || null;
+ state.notice = { kind: 'cache', message: t('cacheRestored') };
+ return true;
+ }
+ } catch { /* Live data remains authoritative. */ }
+ return false;
+}
+
+async function writeCache() {
+ await window.app.storage.set(CACHE_KEY, {
+ cacheVersion: CACHE_VERSION, version: state.version, registry: state.registry,
+ summary: state.summary, fetchedAt: state.fetchedAt, selectedGoalId: state.selectedGoalId,
+ });
+}
+
+function setNotice(kind, message) { state.notice = message ? { kind, message } : null; renderNotice(); }
+
+function renderNotice() {
+ if (!state.notice) { dom.notice.hidden = true; return; }
+ dom.notice.hidden = false;
+ dom.notice.dataset.kind = state.notice.kind;
+ dom.noticeText.textContent = state.notice.message;
+ dom.noticeRetry.textContent = t('retry');
+ dom.noticeRetry.hidden = !['error'].includes(state.notice.kind);
+}
+
+function renderRuntime() {
+ dom.runtimeVersion.textContent = state.version || 'LoopX';
+ dom.updatedAt.textContent = state.fetchedAt ? t('updated', { time: formatTimestamp(state.fetchedAt) }) : t('neverUpdated');
+ dom.refreshButton.disabled = state.refreshing;
+ dom.refreshButton.classList.toggle('loading', state.refreshing);
+ dom.refreshButton.title = t('refresh');
+ dom.refreshButton.setAttribute('aria-label', t('refresh'));
+ dom.runtimeDot.className = 'status-dot';
+ if (state.refreshing) dom.runtimeStatus.textContent = t('refreshing');
+ else if (state.registry && state.summary) {
+ dom.runtimeStatus.textContent = state.notice?.kind === 'error' ? t('cached') : t('ready');
+ dom.runtimeDot.classList.add('online');
+ } else {
+ dom.runtimeStatus.textContent = t('unavailable');
+ dom.runtimeDot.classList.add('error');
+ }
+}
+
+function renderWorkspace() {
+ const goal = selectedGoal();
+ const workspace = state.workspace;
+ const mismatch = repositoryMismatch(goal) || (goal && normalizePath(goal.repo) !== normalizePath(workspace.path));
+ dom.workspaceKicker.textContent = t('executionWorkspace');
+ dom.workspaceName.textContent = workspace.available ? workspace.name || t('localWorkspace') : t('noWorkspace');
+ dom.workspacePath.textContent = workspace.available ? workspace.path || '--' : '--';
+ dom.newGoalButton.hidden = false;
+ dom.newGoalButton.disabled = !workspace.available || workspace.isRemote || Boolean(state.running) || Boolean(state.deletingGoalId);
+ dom.newGoalButton.title = !workspace.available ? t('selectWorkspaceFirst') : '';
+ dom.workspaceState.className = 'workspace-state';
+ if (!workspace.available) dom.workspaceState.textContent = t('globalReadOnly');
+ else if (workspace.isRemote) dom.workspaceState.textContent = t('remoteReadOnly');
+ else if (mismatch) { dom.workspaceState.textContent = t('workspaceMismatch'); dom.workspaceState.classList.add('warning'); }
+ else if (goal && isIssueGoal(goal)) { dom.workspaceState.textContent = t('matchedGoal'); dom.workspaceState.classList.add('ready'); }
+ else dom.workspaceState.textContent = t('noMatchedGoal');
+}
+
+function renderGoals() {
+ const goals = asArray(state.registry?.goals);
+ const matched = currentGoal();
+ dom.goalCount.textContent = String(goals.length);
+ dom.goalsList.replaceChildren();
+ if (!goals.length) { dom.goalsList.append(createElement('div', 'empty-state', t('noGoals'))); return; }
+ for (const goal of goals) {
+ const facts = goalFacts(goal.id);
+ const category = goalCategory(goal);
+ const item = createElement('button', 'goal-item');
+ item.type = 'button';
+ item.dataset.goalId = goal.id;
+ item.classList.toggle('selected', goal.id === state.selectedGoalId);
+ item.classList.toggle('current', goal.id === matched?.id);
+ const primary = createElement('span', 'goal-primary');
+ primary.append(createElement('span', 'goal-title', goal.id));
+ primary.append(createElement('span', `goal-status ${category}`, facts.lane?.status || goal.status || '--'));
+ item.append(primary, createElement('span', 'goal-path', goal.repo || '--'));
+ const meta = createElement('span', 'goal-meta');
+ if (isIssueGoal(goal)) meta.append(createElement('span', '', isAutofixGoal(goal.id) ? t('modeAutofix') : t('modeTracking')));
+ if (facts.todos.length) meta.append(createElement('span', '', `${facts.todos.length} todo`));
+ if (facts.gates.length) meta.append(createElement('span', '', `${facts.gates.length} gate`));
+ item.append(meta);
+ dom.goalsList.append(item);
+ }
+}
+
+function renderMetrics() {
+ const goal = selectedGoal();
+ if (!goal) {
+ dom.metricMonitor.textContent = '--'; dom.metricQueue.textContent = '--';
+ dom.metricProgress.textContent = '--'; dom.metricGates.textContent = '--'; return;
+ }
+ const facts = goalFacts(goal.id);
+ const heartbeat = heartbeatFacts(goal);
+ const monitorKey = heartbeat.state === 'ready' ? 'monitorReady' : heartbeat.state === 'missing' ? 'monitorMissing' :
+ heartbeat.state === 'unavailable' ? 'monitorUnavailable' : 'monitorBroken';
+ dom.metricMonitor.textContent = t(monitorKey);
+ dom.metricMonitor.className = `metric-value metric-value--text ${heartbeat.state === 'ready' ? 'ready' : heartbeat.state === 'missing' ? 'warning' : 'error'}`;
+ dom.metricQueue.textContent = String(facts.todos.length);
+ dom.metricProgress.textContent = String(facts.progress.length);
+ dom.metricGates.textContent = String(facts.gates.length);
+}
+
+function pipelineState(goal, facts, heartbeat) {
+ const text = searchableGoalText(goal.id).toLowerCase();
+ const progress = JSON.stringify(facts.progress).toLowerCase();
+ const mismatch = repositoryMismatch(goal) || normalizePath(goal.repo) !== normalizePath(state.workspace.path);
+ const monitor = mismatch || heartbeat.state === 'broken' ? 'blocked' : heartbeat.state === 'ready' ? 'complete' : detectIssueSource(goal.id) ? 'active' : '';
+ const triage = /triage|classif|筛选|分类/.test(progress) ? 'complete' : facts.todos.length ? 'active' : '';
+ const fix = /fix|implement|修复/.test(progress) ? 'complete' : /fix|implement|修复/.test(text) && facts.todos.length ? 'active' : '';
+ const validate = /validat|\btest|验证|测试/.test(progress) ? 'complete' : /validat|\btest|验证|测试/.test(text) ? 'active' : '';
+ const pr = /github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+|pull request.*(?:created|opened)|pr.*(?:创建|提交)/.test(progress) ? 'complete' : /pull request|\bpr\b/.test(text) ? 'active' : '';
+ return { monitor, triage, fix, validate, pr, blocked: mismatch || heartbeat.state === 'broken' };
+}
+
+function renderPipeline() {
+ const goal = selectedGoal();
+ const stages = ['monitor', 'triage', 'fix', 'validate', 'pr'];
+ if (!goal) {
+ dom.pipelineSummary.textContent = t('pipelineIdle');
+ stages.forEach((name) => { dom[`pipeline${name[0].toUpperCase()}${name.slice(1)}`].className = 'pipeline-stage'; });
+ return;
+ }
+ const status = pipelineState(goal, goalFacts(goal.id), heartbeatFacts(goal));
+ dom.pipelineSummary.textContent = status.blocked ? t('pipelineBlocked') : t('pipelineReady');
+ for (const name of stages) {
+ const element = dom[`pipeline${name[0].toUpperCase()}${name.slice(1)}`];
+ element.className = `pipeline-stage${status[name] ? ` ${status[name]}` : ''}`;
+ }
+}
+
+function renderIssueQueue(facts) {
+ dom.todoList.replaceChildren();
+ dom.todoCount.textContent = String(facts.todos.length);
+ if (!facts.todos.length) {
+ dom.todoList.append(createElement('div', 'queue-empty', t('queueEmpty')));
+ } else {
+ for (const todo of facts.todos) {
+ const item = createElement('article', 'issue-item');
+ const priority = String(todo.priority || 'P2').toUpperCase();
+ item.append(createElement('span', `issue-priority ${priority.toLowerCase()}`, priority));
+ const body = createElement('div', 'issue-body');
+ body.append(createElement('div', 'issue-title', todo.title || todo.text || todo.next_safe_action || todo.todo_id || '--'));
+ const meta = createElement('div', 'issue-meta');
+ const issueNumber = String(todo.title || todo.text || '').match(/#\d+/)?.[0];
+ [issueNumber, todo.action_kind, todo.task_class].filter(Boolean).forEach((value) => meta.append(createElement('span', '', value)));
+ body.append(meta);
+ item.append(body);
+ dom.todoList.append(item);
+ }
+ }
+ dom.gateSection.hidden = !facts.gates.length;
+ dom.gateCount.textContent = String(facts.gates.length);
+ dom.gateList.replaceChildren();
+ for (const gate of facts.gates) {
+ dom.gateList.append(createElement('div', 'detail-list-item gate', gate.question || gate.next_safe_action || gate.gate_id || '--'));
+ }
+}
+
+function renderDetail() {
+ const goal = selectedGoal();
+ dom.detailEmpty.hidden = Boolean(goal);
+ dom.detailContent.hidden = !goal;
+ if (!goal) {
+ dom.emptyConfigureButton.disabled = !state.workspace.available || state.workspace.isRemote || Boolean(state.running);
+ dom.emptyConfigureButton.title = !state.workspace.available ? t('selectWorkspaceFirst') : '';
+ dom.emptyCopy.textContent = state.workspace.available ? t('selectGoalCopy') : t('selectWorkspaceFirst');
+ dom.queueSource.textContent = 'GitHub Issues';
+ renderIssueQueue({ todos: [], gates: [] });
+ return;
+ }
+
+ const facts = goalFacts(goal.id);
+ const details = packetState(goal.id);
+ const heartbeat = heartbeatFacts(goal);
+ const source = detectIssueSource(goal.id);
+ const isCurrent = normalizePath(goal.repo) === normalizePath(state.workspace.path);
+ const mismatch = !isCurrent || repositoryMismatch(goal);
+ dom.trackedSource.textContent = source ? source.replace('https://github.com/', '') : 'GitHub Issues';
+ dom.queueSource.textContent = source || goal.repo || '--';
+ dom.detailHeading.textContent = goal.id;
+ dom.detailRepo.textContent = goal.repo || '--';
+ dom.detailCurrent.hidden = !isCurrent;
+ dom.detailCurrent.textContent = t('current');
+ dom.detailCurrent.className = `detail-status ${goalCategory(goal)}`;
+ dom.detailMode.textContent = isIssueGoal(goal) ? (isAutofixGoal(goal.id) ? t('modeAutofix') : t('modeTracking')) : t('modeUnconfigured');
+ dom.detailStatus.textContent = facts.lane?.status || goal.status || '--';
+ dom.detailWaiting.textContent = facts.lane?.waiting_on || goal.waiting_on || '--';
+ dom.detailHeartbeat.textContent = heartbeat.state === 'ready'
+ ? formatEpoch(heartbeat.job?.state?.nextRunAtMs)
+ : t(heartbeat.state === 'missing' ? 'monitorMissing' : heartbeat.state === 'unavailable' ? 'monitorUnavailable' : 'monitorBroken');
+ dom.adapterLabel.textContent = facts.asset?.support_mode || goal.adapter_kind || '--';
+ dom.nextActionText.textContent = facts.lane?.recommended_action || facts.lane?.next_safe_action ||
+ facts.asset?.next_action || goal.recommended_action || goal.next_probe || t('noNextAction');
+ renderIssueQueue(facts);
+
+ if (details?.status === 'ready') dom.handoffContent.textContent = details.packet.handoff_text || details.packet.project_agent_handoff || '--';
+ else if (details?.status === 'error') dom.handoffContent.textContent = t('detailUnavailable');
+ else dom.handoffContent.textContent = t('detailLoading');
+
+ dom.progressSection.hidden = !facts.progress.length;
+ dom.progressList.replaceChildren();
+ for (const progress of facts.progress) {
+ const item = createElement('div', 'timeline-item');
+ item.append(createElement('time', '', formatTimestamp(progress.generated_at)));
+ item.append(createElement('div', '', progress.recommended_action || progress.classification || '--'));
+ dom.progressList.append(item);
+ }
+
+ dom.continueButton.hidden = false;
+ dom.continueButton.disabled = mismatch || state.workspace.isRemote || Boolean(state.running);
+ dom.continueButton.title = mismatch ? t(repositoryMismatch(goal) ? 'sourceMismatch' : 'currentOnly') : '';
+ dom.continueLabel.textContent = state.running?.goalId === goal.id ? t('running') : t('continue');
+ dom.heartbeatButton.hidden = heartbeat.state === 'ready' || heartbeat.state === 'unavailable';
+ dom.heartbeatButton.disabled = mismatch || state.repairingHeartbeat || Boolean(state.running);
+ dom.heartbeatLabel.textContent = state.repairingHeartbeat ? t('monitorRepairing') : t('monitorRepair');
+ dom.deleteGoalButton.hidden = false;
+ dom.deleteGoalButton.disabled = !canDeleteGoal(goal) || Boolean(state.running) || Boolean(state.deletingGoalId);
+ dom.deleteGoalButton.title = canDeleteGoal(goal) ? t('deleteGoal') : t('deleteUnavailable');
+ dom.deleteGoalButton.setAttribute('aria-label', dom.deleteGoalButton.title);
+}
+
+function applyCopy() {
+ document.documentElement.lang = state.locale;
+ dom.appTitle.textContent = t('title');
+ dom.otherGoalsLabel.textContent = t('otherGoals'); dom.goalsHeading.textContent = t('goals');
+ dom.metricMonitorLabel.textContent = t('monitor'); dom.metricQueueLabel.textContent = t('queue');
+ dom.metricProgressLabel.textContent = t('recentDelivery'); dom.metricGatesLabel.textContent = t('confirmations');
+ dom.newGoalLabel.textContent = t('configureAutofix'); dom.pipelineHeading.textContent = t('pipeline');
+ dom.emptyConfigureLabel.textContent = t('startTracking');
+ dom.pipelineMonitorLabel.textContent = t('pipelineMonitor'); dom.pipelineTriageLabel.textContent = t('pipelineTriage');
+ dom.pipelineFixLabel.textContent = t('pipelineFix'); dom.pipelineValidateLabel.textContent = t('pipelineValidate');
+ dom.pipelinePrLabel.textContent = t('pipelinePr'); dom.todoHeading.textContent = t('queue');
+ dom.gateHeading.textContent = t('userGate'); dom.emptyTitle.textContent = t('selectGoal'); dom.emptyCopy.textContent = t('selectGoalCopy');
+ dom.modeLabel.textContent = t('mode'); dom.statusLabel.textContent = t('status'); dom.waitingLabel.textContent = t('waitingOn');
+ dom.heartbeatStatusLabel.textContent = t('heartbeatStatus'); dom.nextActionHeading.textContent = t('currentExecution');
+ dom.progressHeading.textContent = t('recentDelivery'); dom.handoffHeading.textContent = t('executionDetails');
+ dom.reloadDetailButton.textContent = t('reload'); dom.dialogTitle.textContent = t('setupTitle');
+ dom.issueSourceLabel.textContent = t('issueSource'); dom.issueScopeNote.textContent = t('issueScopeNote');
+ dom.issueCadenceLabel.textContent = t('cadence');
+ dom.cadence30m.textContent = t('cadence30m'); dom.cadence1h.textContent = t('cadence1h');
+ dom.cadence6h.textContent = t('cadence6h'); dom.cadence1d.textContent = t('cadence1d');
+ dom.issuePrDeliveryLabel.textContent = t('autoPr'); dom.goalTextLabel.textContent = t('constraints');
+ dom.goalText.placeholder = t('constraintsPlaceholder'); dom.dialogWorkspaceLabel.textContent = t('targetWorkspace');
+ dom.dialogCancel.textContent = t('cancel'); dom.dialogSubmitLabel.textContent = t('startInAgent');
+ dom.dialogClose.title = t('close'); dom.dialogClose.setAttribute('aria-label', t('close'));
+ dom.deleteDialogTitle.textContent = t('deleteGoalTitle'); dom.deleteDialogCopy.textContent = t('deleteGoalCopy');
+ dom.deleteDialogArchive.textContent = t('deleteGoalArchive'); dom.deleteDialogCancel.textContent = t('cancel');
+ dom.deleteDialogSubmitLabel.textContent = state.deletingGoalId ? t('deleteGoalDeleting') : t('deleteGoalConfirm');
+ dom.deleteDialogClose.title = t('close'); dom.deleteDialogClose.setAttribute('aria-label', t('close'));
+}
+
+function render() {
+ try {
+ applyCopy(); renderNotice(); renderRuntime(); renderWorkspace(); renderGoals();
+ renderMetrics(); renderPipeline(); renderDetail();
+ } catch (error) { logError('render()', error); }
+}
+
+async function loadPacket(goalId, force = false) {
+ if (!goalId || (!force && state.packets.has(goalId))) return;
+ const goal = asArray(state.registry?.goals).find((item) => item.id === goalId);
+ if (!goal?.repo) return;
+ state.packets.set(goalId, { status: 'loading' }); render();
+ try {
+ const [packetValue, statusValue] = await Promise.all([
+ runLoopxJson(['--format', 'json', 'review-packet', '--goal-id', goalId, '--handoff-only', '--limit', '5'], goal.repo),
+ runLoopxJson(['--format', 'json', 'status', '--goal-id', goalId, '--limit', '8'], goal.repo),
+ ]);
+ state.packets.set(goalId, { status: 'ready', packet: validatePacket(packetValue, goalId), statusData: validateGoalStatus(statusValue, goalId) });
+ } catch (error) { state.packets.set(goalId, { status: 'error', error }); }
+ render();
+}
+
+async function loadCronJobs() {
+ try { state.cronJobs = asArray(await window.app.cron.listJobs({})); state.cronAvailable = true; }
+ catch { state.cronJobs = []; state.cronAvailable = false; }
+}
+
+function refreshFailureMessage(error) {
+ const suffix = state.registry && state.summary ? '' : ` ${t('noCacheSuffix')}`;
+ if (error?.code === 'schema') return `${t('incompatible')}${suffix}`;
+ if (error?.code === 'invalid') return `${t('invalidData')}${suffix}`;
+ if (isLoopxMissing(error)) return `${t('loopxMissing')}${suffix}`;
+ return `${t('refreshFailed')}${suffix}`;
+}
+
+function chooseSelectedGoal(goals) {
+ if (goals.some((goal) => goal.id === state.selectedGoalId)) return;
+ const workspacePath = normalizePath(state.workspace.path);
+ const localIssueGoal = goals.find((goal) => normalizePath(goal.repo) === workspacePath && isIssueGoal(goal));
+ const localGoal = goals.find((goal) => normalizePath(goal.repo) === workspacePath);
+ state.selectedGoalId = localIssueGoal?.id || localGoal?.id || goals.find(isIssueGoal)?.id || goals[0]?.id || null;
+}
+
+async function refresh() {
+ if (state.refreshing) return;
+ state.refreshing = true; renderRuntime();
+ try {
+ const [version, registryValue, workspace] = await Promise.all([
+ runLoopx(['--version'], 20_000), runLoopxJson(['--format', 'json', 'registry']),
+ window.app.workspace.info().catch(() => state.workspace),
+ ]);
+ state.workspace = workspace || state.workspace;
+ const registry = validateRegistry(registryValue);
+ const [summaryValue] = await Promise.all([
+ runLoopxJson(['--format', 'json', 'global-summary', '--time-range', '24h', '--limit', '8']),
+ loadCronJobs(),
+ ]);
+ state.version = version; state.registry = registry; state.summary = validateSummary(summaryValue);
+ state.fetchedAt = state.summary.generated_at || new Date().toISOString(); state.notice = null;
+ chooseSelectedGoal(asArray(registry.goals));
+ await writeCache().catch(() => {});
+ if (state.selectedGoalId) void loadPacket(state.selectedGoalId, true);
+ } catch (error) { state.notice = { kind: 'error', message: refreshFailureMessage(error) }; }
+ finally { state.refreshing = false; render(); }
+}
+
+async function startAgent(prompt, displayText, sessionName, goalId) {
+ const workspacePath = state.workspace.available && !state.workspace.isRemote ? state.workspace.path : '';
+ if (!workspacePath) { logError('startAgent: no workspace', new Error('WORKSPACE_UNAVAILABLE')); throw new Error('WORKSPACE_UNAVAILABLE'); }
+ logError('startAgent: calling agent.run', new Error('STEP'));
+ let result;
+ try {
+ result = await window.app.agent.run(prompt, { sessionName, displayText, enableTools: true, workspacePath });
+ } catch (error) { logError('startAgent: agent.run rejected', error); throw error; }
+ if (!result?.sessionId || !result?.turnId) { logError('startAgent: missing sessionId/turnId', new Error('AGENT_SESSION_UNAVAILABLE: ' + JSON.stringify(result))); throw new Error('AGENT_SESSION_UNAVAILABLE'); }
+ state.running = { sessionId: result.sessionId, turnId: result.turnId, goalId };
+ try {
+ setNotice('success', t('agentStarted')); render();
+ } catch (error) { logError('startAgent: render after success', error); }
+ void window.app.chat.focusSession(result.sessionId).catch(() => {});
+}
+
+function buildIssueGoalText(issueUrl, allowPr, constraints) {
+ return `Process the complete set of open GitHub Issues present at ${issueUrl} when this goal is initialized. Freeze that exact set as the goal's immutable intake scope and ignore issues opened later. Use LoopX's native Issue-Fix domain state for feasibility, selected successors, delivery evidence, outcome projection, and grouped PR lifecycle monitoring; do not maintain a parallel queue. Advance at most one bounded executable issue per run. Leave the registered checkout's tracked and untracked contents unchanged, and make code changes only in a clean isolated git worktree. ${allowPr ? 'After validation, push to the authenticated fork and open a pull request against the upstream repository only when LoopX authority permits it.' : 'Do not push or open a pull request; stop after a validated local fix and request approval.'} Never merge, release, close issues, or post GitHub comments without explicit user approval. Heartbeats may advance or monitor only the recorded initial scope. ${constraints ? `Additional constraints: ${constraints}` : ''}`;
+}
+
+function newGoalPrompt(goalText, workspacePath, issueUrl) {
+ return `You are operating inside the exact local workspace: ${workspacePath}
+The host surface is BitFun's interactive chat-box, not Codex App.
+LoopX is the control-plane source of truth. Do not edit .loopx files, goal state files, todo files, or the global registry directly.
+
+Start the durable goal below with LoopX guided flow in this exact project:
+loopx start-goal --guided --project . --goal-text --host-surface chat-box
+
+
+${goalText}
+
+
+Pass the goal text exactly and execute the transaction returned by the guided flow.
+
+Perform one-time intake before any code change:
+1. Fetch the complete paginated set of issue URLs that are OPEN at ${issueUrl} now, using public metadata only.
+2. Record the snapshot time and materialize every concrete URL as LoopX-native intake work in this goal. This exact set is immutable: never refresh the repository issue list and never add issues opened later.
+3. Create or update the LoopX-native successor todo/recommended action that tells future heartbeat turns to select at most one recorded URL per run, use "loopx issue-fix workflow-plan --url --fetch-metadata --repo-path . --format json", then use "loopx issue-fix feasibility ... --goal-id --project ." with real reproduction and scope observations. Follow the returned route, todo, gate, and writeback contracts. Do not pass the repository Issues page to workflow-plan and do not create an app-local or parallel workflow ledger.
+
+Do not run workflow-plan or feasibility for every URL in this setup turn. Persist the entire initial URL set in LoopX, record the bounded successor todo, then stop so BitFun can create the heartbeat. Keep triage, the selected successor, validation, delivery evidence, and grouped PR lifecycle monitoring in LoopX. Preserve every approval and write gate; never approve on the user's behalf. Do not write snapshots, command packs, status output, or other temporary files into the registered checkout; use stdout or an OS temporary directory and remove temporary artifacts when finished. Before modifying code, create or reuse a clean issue-specific git worktree and leave both tracked and untracked contents of the registered checkout unchanged. Do not report that a heartbeat exists because BitFun will create it only after this guided turn succeeds.`;
+}
+
+function continueGoalPrompt(goalId) {
+ return `You are operating inside the local repository registered for LoopX goal_id=${goalId}.
+LoopX is the control-plane source of truth. Do not edit its state files directly.
+
+Read the current packet with:
+loopx --format json review-packet --goal-id ${goalId} --handoff-only --limit 5
+
+Confirm the packet matches goal_id=${goalId}. Follow its gate, owner, stop condition, quota, and next action. Select at most one executable GitHub Issue already recorded in this goal's immutable initial scope. Do not query the repository issue list to discover or add issues opened after the initial snapshot. Use LoopX's native Issue-Fix workflow and feasibility state for that concrete issue, and keep PR monitoring grouped by LoopX's repository lifecycle bucket instead of creating one monitor per PR. Do not write snapshots, command output, or other temporary files into the registered checkout. Before modifying code, create or reuse a clean issue-specific git worktree and leave both tracked and untracked contents of the registered checkout unchanged. Implement a coherent fix, run targeted validation, and write evidence/state back through LoopX. Push a fork branch and create a PR only when current authority permits it. Never infer a user gate or merge/release/close/comment without explicit authority.`;
+}
+
+async function submitNewGoal() {
+ const issueUrl = dom.issueSource.value.trim().replace(/\/$/, '');
+ if (!issueRepository(issueUrl)) {
+ dom.issueSource.setCustomValidity(t('issueSourceRequired')); dom.issueSource.reportValidity(); return;
+ }
+ dom.issueSource.setCustomValidity('');
+ if (!state.workspace.available || state.workspace.isRemote || state.running) return;
+ const workspaceRepo = normalizePath(state.workspace.path).split('\\').filter(Boolean).at(-1) || '';
+ if (workspaceRepo.toLowerCase() !== issueRepository(issueUrl).repo.toLowerCase()) {
+ dom.issueSource.setCustomValidity(t('sourceMismatch')); dom.issueSource.reportValidity(); return;
+ }
+ const everyMs = numberOrZero(dom.issueCadence.value) || DEFAULT_CADENCE_MS;
+ const goalText = buildIssueGoalText(issueUrl, dom.issuePrDelivery.checked, dom.goalText.value.trim());
+ const beforeGoalIds = asArray(state.registry?.goals).map((goal) => goal.id);
+ dom.dialogSubmit.disabled = true;
+ state.pendingNewGoal = { workspacePath: normalizePath(state.workspace.path), everyMs, issueUrl, beforeGoalIds };
+ try {
+ await startAgent(newGoalPrompt(goalText, state.workspace.path, issueUrl), goalText, `${t('title')}: ${state.workspace.name}`, null);
+ dom.newGoalDialog.close(); dom.goalText.value = '';
+ } catch (error) {
+ state.pendingNewGoal = null;
+ logError('submitNewGoal: startAgent failed', error);
+ setNotice('error', `${t('agentFailed')} ${error?.message ? '(' + error.message + ')' : ''}`);
+ }
+ finally { dom.dialogSubmit.disabled = false; }
+}
+
+async function continueGoal() {
+ const goal = selectedGoal();
+ if (!goal || normalizePath(goal.repo) !== normalizePath(state.workspace.path) || repositoryMismatch(goal) || state.workspace.isRemote || state.running) return;
+ try {
+ await loadPacket(goal.id, true);
+ if (packetState(goal.id)?.status !== 'ready') throw new Error('HANDOFF_UNAVAILABLE');
+ await startAgent(continueGoalPrompt(goal.id), goalFacts(goal.id).lane?.recommended_action || `${t('continue')}: ${goal.id}`, `${t('title')}: ${goal.id}`, goal.id);
+ } catch (error) {
+ logError('continueGoal: failed', error);
+ setNotice('error', `${t('agentFailed')} ${error?.message ? '(' + error.message + ')' : ''}`);
+ }
+}
+
+function openDeleteGoalDialog() {
+ const goal = selectedGoal();
+ if (!canDeleteGoal(goal) || state.running || state.deletingGoalId) return;
+ dom.deleteDialogGoal.textContent = goal.id; dom.deleteDialogRepo.textContent = goal.repo;
+ dom.deleteGoalDialog.showModal(); dom.deleteDialogSubmit.focus();
+}
+
+async function deleteGoal() {
+ const goal = selectedGoal();
+ if (!goal || !canDeleteGoal(goal) || state.running || state.deletingGoalId) return;
+ state.deletingGoalId = goal.id; dom.deleteDialogSubmit.disabled = true; render();
+ try {
+ validateGoalDeletion(await runLoopxJson(['--format', 'json', 'uninstall-project', '--goal-id', goal.id, '--archive-state', '--remove-empty-registry', '--execute'], goal.repo), goal.id);
+ state.packets.delete(goal.id); state.selectedGoalId = null; dom.deleteGoalDialog.close();
+ await refresh(); setNotice('success', t('deleteGoalSuccess'));
+ } catch { setNotice('error', t('deleteGoalFailed')); }
+ finally { state.deletingGoalId = null; dom.deleteDialogSubmit.disabled = false; render(); }
+}
+
+async function registeredAgentId(goal) {
+ try {
+ const history = await runLoopxJson(['--format', 'json', 'history', '--goal-id', goal.id, '--limit', '1'], goal.repo);
+ const agents = asArray(history?.goals?.[0]?.coordination?.registered_agents);
+ return agents[0]?.id || agents[0]?.agent_id || null;
+ } catch { return null; }
+}
+
+async function heartbeatPrompt(goal) {
+ const agentId = await registeredAgentId(goal);
+ const args = ['--format', 'json', 'heartbeat-prompt', '--goal-id', goal.id];
+ if (agentId) args.push('--agent-id', agentId);
+ args.push('-H', 'local_scheduler', '-O', 'host_automation', '-M', 'hosted_automation', '--thin');
+ return validateHeartbeatPrompt(await runLoopxJson(args, goal.repo), goal.id).task_body;
+}
+
+async function ensureHeartbeat(goal, everyMs = DEFAULT_CADENCE_MS, replaceOwned = false) {
+ if (!goal || normalizePath(goal.repo) !== normalizePath(state.workspace.path) || repositoryMismatch(goal)) throw new Error('GOAL_WORKSPACE_MISMATCH');
+ const taskBody = await heartbeatPrompt(goal);
+ if (replaceOwned) {
+ for (const job of ownedHeartbeatJobs(goal.id)) await window.app.cron.deleteJob(job.id);
+ }
+ await window.app.cron.createJob({
+ name: `${t('title')}: ${goal.id}`,
+ schedule: { kind: 'every', everyMs }, payload: { text: taskBody }, enabled: true,
+ target: { kind: 'workspace', workspace: { workspacePath: goal.repo }, launch: { agentType: 'agentic' } },
+ });
+ await loadCronJobs();
+}
+
+async function repairHeartbeat() {
+ const goal = selectedGoal();
+ if (!goal || state.repairingHeartbeat) return;
+ state.repairingHeartbeat = true; render();
+ try {
+ const everyMs = numberOrZero(heartbeatFacts(goal).job?.schedule?.everyMs) || DEFAULT_CADENCE_MS;
+ await ensureHeartbeat(goal, everyMs, true); setNotice('success', t('monitorRepairSuccess'));
+ } catch { setNotice('error', t('monitorRepairFailed')); }
+ finally { state.repairingHeartbeat = false; render(); }
+}
+
+function resolveNewGoal(pending) {
+ const goals = asArray(state.registry?.goals).filter((goal) => normalizePath(goal.repo) === pending.workspacePath);
+ return goals.find((goal) => !pending.beforeGoalIds.includes(goal.id)) ||
+ goals.find((goal) => isIssueGoal(goal)) ||
+ (goals.length === 1 ? goals[0] : null);
+}
+
+function onAgentEvent(event) {
+ try {
+ if (!state.running || !event || typeof event !== 'object') return;
+ const sourceEvent = String(event.sourceEvent || event.source_event || event.type || '');
+ const sessionId = event.sessionId || event.session_id; const turnId = event.turnId || event.turn_id;
+ if (sessionId && sessionId !== state.running.sessionId) return;
+ if (turnId && turnId !== state.running.turnId) return;
+ if (sourceEvent.endsWith('dialog-turn-completed')) {
+ logError('onAgentEvent: dialog-turn-completed', new Error('STEP'));
+ const pending = state.pendingNewGoal; state.pendingNewGoal = null; state.running = null; setNotice(null, null); render();
+ void refresh().then(async () => {
+ try {
+ if (!pending || state.workspace.isRemote || normalizePath(state.workspace.path) !== pending.workspacePath) return;
+ const goal = resolveNewGoal(pending);
+ if (!goal) { logError('onAgentEvent: resolveNewGoal returned null', new Error('NO_NEW_GOAL')); setNotice('error', t('monitorRepairFailed')); return; }
+ state.selectedGoalId = goal.id;
+ try { await ensureHeartbeat(goal, pending.everyMs, false); setNotice('success', t('heartbeatAutoCreated')); }
+ catch (error) { logError('onAgentEvent: ensureHeartbeat failed', error); setNotice('error', t('monitorRepairFailed')); }
+ render(); void loadPacket(goal.id, true);
+ } catch (error) { logError('onAgentEvent: post-refresh callback', error); }
+ }).catch((error) => logError('onAgentEvent: refresh() rejected', error));
+ } else if (sourceEvent.endsWith('dialog-turn-failed') || sourceEvent.endsWith('dialog-turn-cancelled')) {
+ logError('onAgentEvent: ' + sourceEvent, new Error('TURN_FAILED_OR_CANCELLED'));
+ state.pendingNewGoal = null; state.running = null; setNotice('error', t('agentFailed')); render();
+ }
+ } catch (error) { logError('onAgentEvent: top-level catch', error); }
+}
+
+function bindEvents() {
+ dom.refreshButton.addEventListener('click', () => void refresh()); dom.noticeRetry.addEventListener('click', () => void refresh());
+ dom.goalsList.addEventListener('click', (event) => {
+ const button = event.target.closest('[data-goal-id]'); if (!button) return;
+ state.selectedGoalId = button.dataset.goalId; dom.goalSwitcher.open = false; render(); void loadPacket(state.selectedGoalId);
+ });
+ dom.reloadDetailButton.addEventListener('click', (event) => { event.preventDefault(); void loadPacket(state.selectedGoalId, true); });
+ const openSetupDialog = () => {
+ if (!state.workspace.available || state.workspace.isRemote || state.running) {
+ setNotice('error', t('selectWorkspaceFirst'));
+ return;
+ }
+ dom.dialogWorkspace.textContent = state.workspace.path; dom.issueSource.value = '';
+ dom.newGoalDialog.showModal(); dom.issueSource.focus();
+ };
+ dom.newGoalButton.addEventListener('click', openSetupDialog);
+ dom.emptyConfigureButton.addEventListener('click', openSetupDialog);
+ dom.dialogClose.addEventListener('click', () => dom.newGoalDialog.close()); dom.dialogCancel.addEventListener('click', () => dom.newGoalDialog.close());
+ dom.newGoalForm.addEventListener('submit', (event) => { event.preventDefault(); void submitNewGoal(); });
+ dom.issueSource.addEventListener('input', () => dom.issueSource.setCustomValidity(''));
+ dom.continueButton.addEventListener('click', () => void continueGoal()); dom.heartbeatButton.addEventListener('click', () => void repairHeartbeat());
+ dom.deleteGoalButton.addEventListener('click', openDeleteGoalDialog);
+ dom.deleteDialogClose.addEventListener('click', () => dom.deleteGoalDialog.close()); dom.deleteDialogCancel.addEventListener('click', () => dom.deleteGoalDialog.close());
+ dom.deleteGoalForm.addEventListener('submit', (event) => { event.preventDefault(); void deleteGoal(); });
+ window.app.agent.onEvent(onAgentEvent);
+ window.app.onLocaleChange((locale) => { state.locale = resolveLocale(locale); render(); });
+}
+
+function collectDom() {
+ const ids = [
+ 'app-title', 'tracked-source', 'runtime-version', 'goal-switcher', 'other-goals-label', 'goal-count', 'goals-heading',
+ 'updated-at', 'goals-list', 'runtime-dot', 'runtime-status', 'refresh-button', 'notice', 'notice-text', 'notice-retry',
+ 'workspace-kicker', 'workspace-name', 'workspace-path', 'workspace-state', 'new-goal-button', 'new-goal-label',
+ 'metric-monitor', 'metric-monitor-label', 'metric-queue', 'metric-queue-label', 'metric-progress', 'metric-progress-label',
+ 'metric-gates', 'metric-gates-label', 'pipeline-heading', 'pipeline-summary', 'pipeline-monitor', 'pipeline-monitor-label',
+ 'pipeline-triage', 'pipeline-triage-label', 'pipeline-fix', 'pipeline-fix-label', 'pipeline-validate', 'pipeline-validate-label',
+ 'pipeline-pr', 'pipeline-pr-label', 'todo-heading', 'queue-source', 'todo-count', 'todo-list', 'gate-section', 'gate-heading',
+ 'gate-count', 'gate-list', 'detail-empty', 'empty-title', 'empty-copy', 'empty-configure-button', 'empty-configure-label',
+ 'detail-content', 'detail-heading', 'detail-current',
+ 'detail-repo', 'delete-goal-button', 'heartbeat-button', 'heartbeat-label', 'continue-button', 'continue-label', 'mode-label',
+ 'detail-mode', 'status-label', 'detail-status', 'waiting-label', 'detail-waiting', 'heartbeat-status-label', 'detail-heartbeat',
+ 'next-action-heading', 'adapter-label', 'next-action-text', 'progress-section', 'progress-heading', 'progress-list',
+ 'handoff-heading', 'reload-detail-button', 'handoff-content', 'new-goal-dialog', 'new-goal-form', 'dialog-title', 'dialog-close',
+ 'issue-source-label', 'issue-source', 'issue-scope-note', 'issue-cadence-label', 'issue-cadence', 'cadence-30m', 'cadence-1h', 'cadence-6h',
+ 'cadence-1d', 'issue-pr-delivery', 'issue-pr-delivery-label',
+ 'goal-text-label', 'goal-text', 'dialog-workspace-label', 'dialog-workspace', 'dialog-cancel', 'dialog-submit', 'dialog-submit-label',
+ 'delete-goal-dialog', 'delete-goal-form', 'delete-dialog-title', 'delete-dialog-close', 'delete-dialog-copy', 'delete-dialog-goal',
+ 'delete-dialog-repo', 'delete-dialog-archive', 'delete-dialog-cancel', 'delete-dialog-submit', 'delete-dialog-submit-label',
+ ];
+ for (const id of ids) dom[id.replace(/-([a-z])/g, (_, char) => char.toUpperCase())] = $(id);
+}
+
+async function initialize() {
+ collectDom(); state.locale = resolveLocale(window.app.locale); bindEvents();
+ window.addEventListener('error', (event) => { logError('global error handler', event.error || event.message); });
+ window.addEventListener('unhandledrejection', (event) => { logError('unhandledrejection', event.reason); });
+ const [hadCache, workspace] = await Promise.all([readCache(), window.app.workspace.info().catch((error) => { logError('initialize: workspace.info', error); return state.workspace; })]);
+ state.workspace = workspace || state.workspace; chooseSelectedGoal(asArray(state.registry?.goals)); render();
+ if (!hadCache) setNotice(null, null); void refresh();
+ if (state.selectedGoalId) void loadPacket(state.selectedGoalId);
+}
+
+void initialize();
diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/worker.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/worker.js
new file mode 100644
index 0000000000..e209ac8053
--- /dev/null
+++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/worker.js
@@ -0,0 +1,2 @@
+// LoopX Console uses host primitives and the Agent bridge; no Node worker is needed.
+module.exports = {};
diff --git a/src/crates/contracts/product-domains/src/miniapp/host_routing.rs b/src/crates/contracts/product-domains/src/miniapp/host_routing.rs
index 4019367cca..04cff176ae 100644
--- a/src/crates/contracts/product-domains/src/miniapp/host_routing.rs
+++ b/src/crates/contracts/product-domains/src/miniapp/host_routing.rs
@@ -4,7 +4,12 @@ use std::path::{Path, PathBuf};
const HOST_NAMESPACES: &[&str] = &["fs", "shell", "os", "net"];
const DEFAULT_SHELL_EXEC_TIMEOUT_MS: u64 = 30_000;
-const SHELL_EXEC_DEFAULT_ENV: [(&str, &str); 2] = [("GIT_TERMINAL_PROMPT", "0"), ("LC_ALL", "C")];
+const SHELL_EXEC_DEFAULT_ENV: [(&str, &str); 4] = [
+ ("GIT_TERMINAL_PROMPT", "0"),
+ ("LC_ALL", "C"),
+ ("PYTHONUTF8", "1"),
+ ("PYTHONIOENCODING", "utf-8"),
+];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FsAccessMode {
@@ -432,7 +437,7 @@ pub fn shell_exec_timeout_ms(explicit_timeout_ms: Option) -> u64 {
explicit_timeout_ms.unwrap_or(DEFAULT_SHELL_EXEC_TIMEOUT_MS)
}
-pub fn shell_exec_default_env() -> [(&'static str, &'static str); 2] {
+pub fn shell_exec_default_env() -> [(&'static str, &'static str); 4] {
SHELL_EXEC_DEFAULT_ENV
}
@@ -524,7 +529,12 @@ mod tests {
assert_eq!(shell_exec_timeout_ms(Some(8_000)), 8_000);
assert_eq!(
shell_exec_default_env(),
- [("GIT_TERMINAL_PROMPT", "0"), ("LC_ALL", "C")]
+ [
+ ("GIT_TERMINAL_PROMPT", "0"),
+ ("LC_ALL", "C"),
+ ("PYTHONUTF8", "1"),
+ ("PYTHONIOENCODING", "utf-8")
+ ]
);
}
diff --git a/src/crates/contracts/product-domains/src/miniapp/types.rs b/src/crates/contracts/product-domains/src/miniapp/types.rs
index f00eb1af46..5412b22004 100644
--- a/src/crates/contracts/product-domains/src/miniapp/types.rs
+++ b/src/crates/contracts/product-domains/src/miniapp/types.rs
@@ -53,6 +53,8 @@ pub struct MiniAppPermissions {
#[serde(skip_serializing_if = "Option::is_none")]
pub agent: Option,
#[serde(skip_serializing_if = "Option::is_none")]
+ pub cron: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
pub notifications: Option,
}
@@ -127,6 +129,20 @@ pub struct AgentPermissions {
pub rate_limit_per_minute: Option,
}
+/// Scheduled-job (cron) permissions for MiniApps.
+///
+/// Grants the MiniApp the ability to create, list, and delete host scheduled
+/// jobs through the host cron bridge, so an agentic MiniApp can wire its own
+/// recurring heartbeat (e.g. a LoopX goal driven by the host CronService)
+/// without leaving the iframe. Gated by manifest `permissions.cron.enabled`;
+/// enforced host-side by reusing the existing cron Tauri commands.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct CronPermissions {
+ /// Whether scheduled-job access is enabled for this MiniApp.
+ #[serde(default)]
+ pub enabled: bool,
+}
+
/// Host notification permissions for MiniApps.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct NotificationPermissions {
diff --git a/src/crates/contracts/product-domains/tests/miniapp_contracts.rs b/src/crates/contracts/product-domains/tests/miniapp_contracts.rs
index 8f3b10f8fc..82ba65729b 100644
--- a/src/crates/contracts/product-domains/tests/miniapp_contracts.rs
+++ b/src/crates/contracts/product-domains/tests/miniapp_contracts.rs
@@ -6,7 +6,7 @@ use bitfun_product_domains::miniapp::builtin::{
builtin_content_hash, builtin_source_files, legacy_builtin_version_marker_content,
parse_builtin_install_marker, preserved_builtin_created_at, resolve_builtin_seed_action,
resolve_builtin_seed_check, serialize_builtin_install_marker, should_seed_builtin_app,
- BuiltinInstallMarker, BuiltinMiniAppBundle, BuiltinSeedAction, BuiltinSeedCheck,
+ BuiltinInstallMarker, BuiltinMiniAppBundle, BuiltinSeedAction, BuiltinSeedCheck, BUILTIN_APPS,
BUILTIN_INSTALL_MARKER, BUILTIN_PLACEHOLDER_COMPILED_HTML, LEGACY_BUILTIN_VERSION_MARKER,
};
use bitfun_product_domains::miniapp::compiler::compile;
@@ -557,6 +557,104 @@ fn miniapp_bridge_exposes_topic_session_lifecycle() {
assert!(bridge.contains("chat.clearSession"));
}
+#[test]
+fn miniapp_bridge_exposes_read_only_workspace_info() {
+ let bridge = build_bridge_script("app-1", "/tmp/app", "/tmp/workspace", "dark", "win32");
+
+ assert!(bridge.contains("workspace:"));
+ assert!(bridge.contains("workspace.info"));
+}
+
+#[test]
+fn builtin_loopx_console_has_complete_resources_and_minimal_permissions() {
+ let app = BUILTIN_APPS
+ .iter()
+ .find(|app| app.id == "builtin-loopx-console")
+ .expect("LoopX console should be registered as a built-in MiniApp");
+ let meta: serde_json::Value =
+ serde_json::from_str(app.meta_json).expect("LoopX console metadata should be valid JSON");
+
+ assert!(!app.html.trim().is_empty());
+ assert!(!app.css.trim().is_empty());
+ assert!(!app.ui_js.trim().is_empty());
+ assert!(!app.worker_js.trim().is_empty());
+ assert_eq!(app.esm_dependencies_json, "[]");
+ assert_eq!(meta["id"], "builtin-loopx-console");
+ assert_eq!(
+ meta["permissions"]["shell"]["allow"],
+ serde_json::json!(["loopx"])
+ );
+ assert_eq!(meta["permissions"]["net"]["allow"], serde_json::json!([]));
+ assert_eq!(meta["permissions"]["node"]["enabled"], false);
+ assert_eq!(meta["permissions"]["agent"]["enabled"], true);
+ assert_eq!(meta["permissions"]["cron"]["enabled"], true);
+ assert!(meta["permissions"].get("fs").is_none());
+}
+
+#[test]
+fn builtin_loopx_console_keeps_loopx_on_read_only_control_plane_paths() {
+ let app = BUILTIN_APPS
+ .iter()
+ .find(|app| app.id == "builtin-loopx-console")
+ .expect("LoopX console should be registered as a built-in MiniApp");
+ let source = format!(
+ "{}\n{}\n{}\n{}",
+ app.html, app.css, app.ui_js, app.worker_js
+ );
+ let source_lower = source.to_lowercase();
+
+ assert!(source.contains("['--version']"));
+ assert!(source.contains("['--format', 'json', 'registry']"));
+ assert!(source.contains("'global-summary', '--time-range', '24h', '--limit', '8'"));
+ assert!(
+ source.contains("'review-packet', '--goal-id', goalId, '--handoff-only', '--limit', '5'")
+ );
+ assert!(source.contains("schema_version !== '0.1'"));
+ assert!(source.contains("schema_version !== 'global_manager_command_response_v0'"));
+ assert!(source.contains("window.app.appDataDir"));
+ assert!(source.contains("window.app.agent.run"));
+ assert!(source.contains("window.app.chat.focusSession"));
+ assert!(source.contains("void window.app.chat.focusSession(result.sessionId).catch(() => {});"));
+ assert!(!source.contains("await window.app.chat.focusSession(result.sessionId);"));
+ assert!(!source.contains("window.app.chat.claimComposer"));
+ assert!(source.contains("window.app.cron.listJobs"));
+ assert!(source.contains("window.app.cron.createJob"));
+ assert!(source.contains("window.app.cron.deleteJob"));
+ assert!(source.contains("'heartbeat-prompt', '--goal-id', goal.id"));
+ assert!(source.contains("typeof value?.task_body !== 'string'"));
+ assert!(source.contains("payload: { text: taskBody }"));
+ assert!(source.contains("const DEFAULT_CADENCE_MS = 60 * 60 * 1000;"));
+ assert!(source.contains("value=\"3600000\" selected"));
+ assert!(source.contains("id=\"empty-configure-button\""));
+ assert!(source.contains("id=\"issue-scope-note\""));
+ assert!(source.contains("window.app.workspace.info().catch(() => state.workspace)"));
+ assert!(source.contains("normalizePath(goal.repo) !== normalizePath(state.workspace.path)"));
+ assert!(source.contains("Freeze that exact set as the goal's immutable intake scope"));
+ assert!(source.contains("LoopX-native successor todo/recommended action"));
+ assert!(source.contains("loopx issue-fix workflow-plan --url "));
+ assert!(source.contains("loopx issue-fix feasibility ... --goal-id "));
+ assert!(source.contains("Do not run workflow-plan or feasibility for every URL in this setup turn"));
+ assert!(source.contains("already recorded in this goal's immutable initial scope"));
+ assert!(source.contains(
+ "leave both tracked and untracked contents of the registered checkout unchanged"
+ ));
+ assert!(source.contains("Do not write snapshots, command packs, status output"));
+ assert!(!source.contains("For each recorded URL"));
+ assert!(!source.contains("materialize every concrete URL as LoopX-native triage/feasibility work"));
+ assert!(
+ !source.contains("Maintain a durable queue for newly opened, closed, and updated issues")
+ );
+ assert!(!source.contains("Continuously monitor ${issueUrl}"));
+ assert!(!source_lower.contains("acp"));
+ assert!(!source_lower.contains("serve-status"));
+ // The only direct LoopX write is user-confirmed goal deletion. Goal work
+ // runs through Agent and recurring execution through the host Cron bridge.
+ assert_eq!(source_lower.matches("--execute").count(), 1);
+ assert!(source.contains("'uninstall-project', '--goal-id', goal.id,"));
+ assert!(source.contains("'--archive-state', '--remove-empty-registry', '--execute']"));
+ assert!(source.contains("schema_version !== 'loopx_project_uninstall_v0'"));
+}
+
#[test]
fn miniapp_permission_policy_preserves_scope_resolution() {
let permissions = MiniAppPermissions {
@@ -841,7 +939,12 @@ fn miniapp_host_routing_preserves_existing_primitive_and_allowlist_contract() {
assert_eq!(shell_exec_timeout_ms(Some(8_000)), 8_000);
assert_eq!(
shell_exec_default_env(),
- [("GIT_TERMINAL_PROMPT", "0"), ("LC_ALL", "C")]
+ [
+ ("GIT_TERMINAL_PROMPT", "0"),
+ ("LC_ALL", "C"),
+ ("PYTHONUTF8", "1"),
+ ("PYTHONIOENCODING", "utf-8")
+ ]
);
assert!(command_basename_allowed(&[], "git"));
diff --git a/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts b/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts
index cfb647d33c..ffc1d243c1 100644
--- a/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts
+++ b/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts
@@ -4,6 +4,7 @@
* ai.* → Host AI client, agent.* → Host agent bridge (hidden subagent runs),
* deck.renderPage → hidden host WebView slide rasterization (export),
* chat.* → floating session bubble composer claims and session focus,
+ * workspace.info → the host's existing read-only workspace context,
* clipboard.* → Host navigator.clipboard.
* Also handles bitfun/request-theme and pushes theme changes to the iframe.
*/
@@ -14,11 +15,17 @@ import type { MiniApp } from '@/infrastructure/api/service-api/MiniAppAPI';
import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext';
import { useTheme } from '@/infrastructure/theme/hooks/useTheme';
import { buildMiniAppThemeVars } from '../utils/buildMiniAppThemeVars';
+import { buildMiniAppWorkspaceInfo } from '../utils/buildMiniAppWorkspaceInfo';
import { api } from '@/infrastructure/api/service-api/ApiClient';
import { useI18n } from '@/infrastructure/i18n';
import type { MiniAppRunScope } from '../customization/miniAppCustomizationTypes';
import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI';
import { workspaceAPI } from '@/infrastructure/api';
+import {
+ cronAPI,
+ type CreateCronJobRequest,
+ type ListCronJobsRequest,
+} from '@/infrastructure/api';
import {
useMiniAppStore,
MINIAPP_COMPOSER_MESSAGE_EVENT,
@@ -27,6 +34,7 @@ import {
type MiniAppComposerMessageDetail,
} from '../miniAppStore';
import { flowChatStore } from '@/flow_chat/store/FlowChatStore';
+import { useSceneStore } from '@/app/stores/sceneStore';
import { createLogger } from '@/shared/utils/logger';
interface JSONRPC {
@@ -52,13 +60,17 @@ export function useMiniAppBridge(
app: MiniApp,
runScope: MiniAppRunScope,
) {
- const { workspacePath } = useCurrentWorkspace();
+ const { workspace, workspaceName, workspacePath } = useCurrentWorkspace();
const { theme: currentTheme } = useTheme();
const { currentLanguage } = useI18n('scenes/miniapp');
const themeRef = useRef(currentTheme);
themeRef.current = currentTheme;
const workspacePathRef = useRef(workspacePath);
workspacePathRef.current = workspacePath;
+ const workspaceInfoRef = useRef(
+ buildMiniAppWorkspaceInfo(workspace, workspaceName, workspacePath),
+ );
+ workspaceInfoRef.current = buildMiniAppWorkspaceInfo(workspace, workspaceName, workspacePath);
const localeRef = useRef(currentLanguage);
localeRef.current = currentLanguage;
@@ -73,11 +85,13 @@ export function useMiniAppBridge(
const nodeDisabledRef = useRef(app.permissions?.node?.enabled === false);
const systemNotificationsAllowedRef = useRef(app.permissions?.notifications?.system === true);
const agentEnabledRef = useRef(app.permissions?.agent?.enabled === true);
+ const cronEnabledRef = useRef(app.permissions?.cron?.enabled === true);
useLayoutEffect(() => {
nodeDisabledRef.current = app.permissions?.node?.enabled === false;
systemNotificationsAllowedRef.current = app.permissions?.notifications?.system === true;
agentEnabledRef.current = app.permissions?.agent?.enabled === true;
- }, [app.id, app.permissions?.node?.enabled, app.permissions?.notifications?.system, app.permissions?.agent?.enabled]);
+ cronEnabledRef.current = app.permissions?.cron?.enabled === true;
+ }, [app.id, app.permissions?.node?.enabled, app.permissions?.notifications?.system, app.permissions?.agent?.enabled, app.permissions?.cron?.enabled]);
// Hidden agent sessions started by this iframe; used to filter the global
// agentic:// event stream before forwarding events into the iframe.
@@ -131,6 +145,10 @@ export function useMiniAppBridge(
}
try {
+ if (method === 'workspace.info') {
+ reply(workspaceInfoRef.current);
+ return;
+ }
if (method === 'worker.call') {
const innerMethod = (params.method as string) ?? '';
const innerParams = (params.params as Record) ?? {};
@@ -367,6 +385,36 @@ export function useMiniAppBridge(
return;
}
+ // ── Scheduled-jobs (cron) commands ─────────────────────────────────
+ // Gated on cron permission: an agentic MiniApp may wire its own
+ // recurring heartbeat (e.g. a LoopX goal driven by the host CronService)
+ // by reusing the existing cron Tauri commands. This bridge only routes
+ // the call; the host owns scheduling, state, and authority.
+ if (method.startsWith('cron.')) {
+ if (!cronEnabledRef.current) {
+ replyError(`MiniApp '${appId}' does not have cron permission (permissions.cron.enabled).`);
+ return;
+ }
+ if (method === 'cron.createJob') {
+ const request = params as unknown as CreateCronJobRequest;
+ const result = await cronAPI.createJob(request);
+ reply(result);
+ return;
+ }
+ if (method === 'cron.listJobs') {
+ const result = await cronAPI.listJobs((params as ListCronJobsRequest) ?? {});
+ reply(result);
+ return;
+ }
+ if (method === 'cron.deleteJob') {
+ const result = await cronAPI.deleteJob(String(params.jobId ?? ''));
+ reply(result);
+ return;
+ }
+ replyError(`Unknown cron method: ${method}`);
+ return;
+ }
+
// ── Floating bubble chat commands ────────────────────────────────────
// Gated on agent permission: the composer claim exists so agentic
// MiniApps can reuse the bubble as their input surface, and
@@ -423,18 +471,23 @@ export function useMiniAppBridge(
return;
}
const claim = useMiniAppStore.getState().composerClaims[appId];
- if (claim?.token !== composerTokenRef.current) {
- replyError('chat.focusSession: this MiniApp does not hold the bubble composer.');
- return;
+ if (claim?.token === composerTokenRef.current) {
+ // Has a valid composer claim — bind the validated session to this
+ // runner's bubble. FloatingMiniChat owns the temporary global-store
+ // switch while its panel is open, then restores the user's normal
+ // session on close.
+ useMiniAppStore.getState().setComposerSession(
+ appId,
+ composerTokenRef.current,
+ sessionId,
+ );
+ } else {
+ // No composer claim — route the agent session to the main session
+ // scene instead of the floating bubble. This covers MiniApps that
+ // drive agent.run from their own UI without claiming the composer.
+ flowChatStore.switchSession(sessionId);
+ useSceneStore.getState().openScene('session');
}
- // Bind the validated session to this runner's composer claim.
- // FloatingMiniChat owns the temporary global-store switch while its
- // panel is open, then restores the user's normal session on close.
- useMiniAppStore.getState().setComposerSession(
- appId,
- composerTokenRef.current,
- sessionId,
- );
reply(null);
return;
}
diff --git a/src/web-ui/src/app/scenes/miniapps/utils/buildMiniAppWorkspaceInfo.test.ts b/src/web-ui/src/app/scenes/miniapps/utils/buildMiniAppWorkspaceInfo.test.ts
new file mode 100644
index 0000000000..df56077ebc
--- /dev/null
+++ b/src/web-ui/src/app/scenes/miniapps/utils/buildMiniAppWorkspaceInfo.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from 'vitest';
+
+import { WorkspaceKind, WorkspaceType, type WorkspaceInfo } from '@/shared/types';
+import { buildMiniAppWorkspaceInfo } from './buildMiniAppWorkspaceInfo';
+
+function workspace(kind: WorkspaceKind): WorkspaceInfo {
+ return {
+ id: `workspace-${kind}`,
+ name: `${kind} workspace`,
+ rootPath: `C:\\projects\\${kind}`,
+ workspaceType: WorkspaceType.SingleProject,
+ workspaceKind: kind,
+ languages: [],
+ openedAt: '2026-07-27T00:00:00Z',
+ lastAccessed: '2026-07-27T00:00:00Z',
+ tags: [],
+ };
+}
+
+describe('buildMiniAppWorkspaceInfo', () => {
+ it('returns an unavailable contract when no workspace is open', () => {
+ expect(buildMiniAppWorkspaceInfo(null, 'stale name', 'C:\\stale')).toEqual({
+ available: false,
+ name: '',
+ path: '',
+ kind: null,
+ isRemote: false,
+ });
+ });
+
+ it('returns the current local workspace context', () => {
+ expect(buildMiniAppWorkspaceInfo(workspace(WorkspaceKind.Normal), 'BitFun', 'C:\\codeagent\\BitFun'))
+ .toEqual({
+ available: true,
+ name: 'BitFun',
+ path: 'C:\\codeagent\\BitFun',
+ kind: WorkspaceKind.Normal,
+ isRemote: false,
+ });
+ });
+
+ it('marks a remote workspace without changing its displayed path', () => {
+ expect(buildMiniAppWorkspaceInfo(workspace(WorkspaceKind.Remote), 'Remote app', '/srv/app'))
+ .toEqual({
+ available: true,
+ name: 'Remote app',
+ path: '/srv/app',
+ kind: WorkspaceKind.Remote,
+ isRemote: true,
+ });
+ });
+});
diff --git a/src/web-ui/src/app/scenes/miniapps/utils/buildMiniAppWorkspaceInfo.ts b/src/web-ui/src/app/scenes/miniapps/utils/buildMiniAppWorkspaceInfo.ts
new file mode 100644
index 0000000000..6780fd34e1
--- /dev/null
+++ b/src/web-ui/src/app/scenes/miniapps/utils/buildMiniAppWorkspaceInfo.ts
@@ -0,0 +1,23 @@
+import { isRemoteWorkspace, type WorkspaceInfo } from '@/shared/types';
+
+export interface MiniAppWorkspaceInfo {
+ available: boolean;
+ name: string;
+ path: string;
+ kind: string | null;
+ isRemote: boolean;
+}
+
+export function buildMiniAppWorkspaceInfo(
+ workspace: WorkspaceInfo | null | undefined,
+ workspaceName: string,
+ workspacePath: string,
+): MiniAppWorkspaceInfo {
+ return {
+ available: Boolean(workspace),
+ name: workspace ? workspaceName : '',
+ path: workspace ? workspacePath : '',
+ kind: workspace?.workspaceKind ?? null,
+ isRemote: isRemoteWorkspace(workspace),
+ };
+}
diff --git a/src/web-ui/src/infrastructure/api/service-api/MiniAppAPI.ts b/src/web-ui/src/infrastructure/api/service-api/MiniAppAPI.ts
index dd0f562aa2..51bab72900 100644
--- a/src/web-ui/src/infrastructure/api/service-api/MiniAppAPI.ts
+++ b/src/web-ui/src/infrastructure/api/service-api/MiniAppAPI.ts
@@ -38,6 +38,7 @@ export interface MiniAppPermissions {
enabled?: boolean;
rate_limit_per_minute?: number;
};
+ cron?: { enabled?: boolean };
notifications?: { system?: boolean };
}