agent-control-plane 0.3.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +141 -28
  2. package/assets/workflow-catalog.json +1 -1
  3. package/bin/pr-risk.sh +22 -7
  4. package/bin/sync-pr-labels.sh +1 -1
  5. package/hooks/heartbeat-hooks.sh +125 -12
  6. package/hooks/issue-reconcile-hooks.sh +1 -1
  7. package/hooks/pr-reconcile-hooks.sh +1 -1
  8. package/npm/bin/agent-control-plane.js +257 -59
  9. package/package.json +39 -32
  10. package/tools/bin/debug-session.sh +106 -0
  11. package/tools/bin/flow-config-lib.sh +1203 -60
  12. package/tools/bin/flow-runtime-doctor-linux.sh +136 -0
  13. package/tools/bin/flow-runtime-doctor.sh +5 -1
  14. package/tools/bin/flow-shell-lib.sh +32 -0
  15. package/tools/bin/github-core-rate-limit-state.sh +77 -0
  16. package/tools/bin/github-write-outbox.sh +470 -0
  17. package/tools/bin/heartbeat-loop-scheduling-lib.sh +7 -7
  18. package/tools/bin/heartbeat-safe-auto.sh +42 -0
  19. package/tools/bin/install-project-launchd.sh +17 -2
  20. package/tools/bin/install-project-systemd.sh +255 -0
  21. package/tools/bin/project-init.sh +21 -1
  22. package/tools/bin/project-launchd-bootstrap.sh +5 -1
  23. package/tools/bin/project-runtimectl.sh +91 -2
  24. package/tools/bin/project-systemd-bootstrap.sh +74 -0
  25. package/tools/bin/scaffold-profile.sh +61 -3
  26. package/tools/bin/uninstall-project-systemd.sh +87 -0
  27. package/tools/dashboard/app.js +228 -6
  28. package/tools/dashboard/dashboard_snapshot.py +55 -0
  29. package/tools/dashboard/issue_queue_state.py +101 -0
  30. package/tools/dashboard/server.py +123 -1
  31. package/tools/dashboard/styles.css +526 -455
  32. package/tools/templates/pr-fix-template.md +3 -1
  33. package/tools/templates/pr-merge-repair-template.md +2 -1
  34. package/references/architecture.md +0 -217
  35. package/references/commands.md +0 -128
  36. package/references/control-plane-map.md +0 -124
  37. package/references/docs-map.md +0 -73
  38. package/references/release-checklist.md +0 -65
  39. package/references/repo-map.md +0 -36
  40. package/tools/bin/agent-cleanup-worktree +0 -247
  41. package/tools/bin/agent-github-update-labels +0 -71
  42. package/tools/bin/agent-init-worktree +0 -216
  43. package/tools/bin/agent-project-archive-run +0 -52
  44. package/tools/bin/agent-project-capture-worker +0 -46
  45. package/tools/bin/agent-project-catch-up-issue-pr-links +0 -118
  46. package/tools/bin/agent-project-catch-up-merged-prs +0 -194
  47. package/tools/bin/agent-project-catch-up-scheduled-issue-retries +0 -123
  48. package/tools/bin/agent-project-cleanup-session +0 -513
  49. package/tools/bin/agent-project-detached-launch +0 -127
  50. package/tools/bin/agent-project-heartbeat-loop +0 -1029
  51. package/tools/bin/agent-project-open-issue-worktree +0 -89
  52. package/tools/bin/agent-project-open-pr-worktree +0 -80
  53. package/tools/bin/agent-project-publish-issue-pr +0 -465
  54. package/tools/bin/agent-project-reconcile-issue-session +0 -1398
  55. package/tools/bin/agent-project-reconcile-pr-session +0 -1230
  56. package/tools/bin/agent-project-retry-state +0 -147
  57. package/tools/bin/agent-project-run-claude-session +0 -805
  58. package/tools/bin/agent-project-run-codex-resilient +0 -955
  59. package/tools/bin/agent-project-run-codex-session +0 -435
  60. package/tools/bin/agent-project-run-kilo-session +0 -369
  61. package/tools/bin/agent-project-run-ollama-session +0 -658
  62. package/tools/bin/agent-project-run-openclaw-session +0 -1309
  63. package/tools/bin/agent-project-run-opencode-session +0 -377
  64. package/tools/bin/agent-project-run-pi-session +0 -479
  65. package/tools/bin/agent-project-sync-anchor-repo +0 -139
  66. package/tools/bin/agent-project-worker-status +0 -188
  67. package/tools/bin/branch-verification-guard.sh +0 -364
  68. package/tools/bin/capture-worker.sh +0 -18
  69. package/tools/bin/cleanup-worktree.sh +0 -52
  70. package/tools/bin/codex-quota +0 -31
  71. package/tools/bin/create-follow-up-issue.sh +0 -114
  72. package/tools/bin/dashboard-launchd-bootstrap.sh +0 -50
  73. package/tools/bin/issue-publish-localization-guard.sh +0 -142
  74. package/tools/bin/issue-publish-scope-guard.sh +0 -242
  75. package/tools/bin/issue-requires-local-workspace-install.sh +0 -31
  76. package/tools/bin/issue-resource-class.sh +0 -12
  77. package/tools/bin/kick-scheduler.sh +0 -75
  78. package/tools/bin/label-follow-up-issues.sh +0 -14
  79. package/tools/bin/new-pr-worktree.sh +0 -50
  80. package/tools/bin/new-worktree.sh +0 -49
  81. package/tools/bin/pr-risk.sh +0 -12
  82. package/tools/bin/prepare-worktree.sh +0 -142
  83. package/tools/bin/provider-cooldown-state.sh +0 -204
  84. package/tools/bin/publish-issue-worker.sh +0 -31
  85. package/tools/bin/reconcile-bootstrap-lib.sh +0 -113
  86. package/tools/bin/reconcile-issue-worker.sh +0 -34
  87. package/tools/bin/reconcile-pr-worker.sh +0 -34
  88. package/tools/bin/record-verification.sh +0 -71
  89. package/tools/bin/render-flow-config.sh +0 -98
  90. package/tools/bin/resident-issue-controller-lib.sh +0 -448
  91. package/tools/bin/resident-issue-queue-status.py +0 -35
  92. package/tools/bin/retry-state.sh +0 -31
  93. package/tools/bin/reuse-issue-worktree.sh +0 -121
  94. package/tools/bin/run-codex-bypass.sh +0 -3
  95. package/tools/bin/run-codex-safe.sh +0 -3
  96. package/tools/bin/run-codex-task.sh +0 -280
  97. package/tools/bin/serve-dashboard.sh +0 -5
  98. package/tools/bin/split-retained-slice.sh +0 -124
  99. package/tools/bin/start-issue-worker.sh +0 -943
  100. package/tools/bin/start-pr-fix-worker.sh +0 -491
  101. package/tools/bin/start-pr-merge-repair-worker.sh +0 -8
  102. package/tools/bin/start-pr-review-worker.sh +0 -261
  103. package/tools/bin/start-resident-issue-loop.sh +0 -499
  104. package/tools/bin/update-github-labels.sh +0 -14
  105. package/tools/bin/worker-status.sh +0 -19
  106. package/tools/bin/workflow-catalog.sh +0 -77
@@ -14,7 +14,12 @@ Create a new installed project profile, profile templates, and profile notes.
14
14
 
15
15
  Options:
16
16
  --profile-id <id> Profile id, e.g. billing-api
17
- --repo-slug <owner/repo> GitHub repo slug
17
+ --repo-slug <owner/repo> Forge repo slug
18
+ --forge-provider <github|gitea> Forge provider (default: github)
19
+ --gitea-base-url <url> Base URL for a local/self-hosted Gitea instance
20
+ --gitea-token <token> Gitea API token written to profile runtime.env
21
+ --gitea-username <user> Gitea username written to profile runtime.env
22
+ --gitea-password <pass> Gitea password written to profile runtime.env
18
23
  --profile-home <path> Profile registry root (default: ~/.agent-runtime/control-plane/profiles)
19
24
  --repo-root <path> Canonical repo root
20
25
  --agent-repo-root <path> Agent-owned anchor repo root (defaults to repo root)
@@ -40,6 +45,11 @@ EOF
40
45
 
41
46
  profile_id=""
42
47
  repo_slug=""
48
+ forge_provider="github"
49
+ gitea_base_url=""
50
+ gitea_token=""
51
+ gitea_username=""
52
+ gitea_password=""
43
53
  profile_home=""
44
54
  repo_root=""
45
55
  agent_repo_root=""
@@ -63,6 +73,11 @@ while [[ $# -gt 0 ]]; do
63
73
  case "$1" in
64
74
  --profile-id) profile_id="${2:-}"; shift 2 ;;
65
75
  --repo-slug) repo_slug="${2:-}"; shift 2 ;;
76
+ --forge-provider) forge_provider="${2:-}"; shift 2 ;;
77
+ --gitea-base-url) gitea_base_url="${2:-}"; shift 2 ;;
78
+ --gitea-token) gitea_token="${2:-}"; shift 2 ;;
79
+ --gitea-username) gitea_username="${2:-}"; shift 2 ;;
80
+ --gitea-password) gitea_password="${2:-}"; shift 2 ;;
66
81
  --profile-home) profile_home="${2:-}"; shift 2 ;;
67
82
  --repo-root) repo_root="${2:-}"; shift 2 ;;
68
83
  --agent-repo-root) agent_repo_root="${2:-}"; shift 2 ;;
@@ -104,6 +119,14 @@ case "$coding_worker" in
104
119
  ;;
105
120
  esac
106
121
 
122
+ case "$forge_provider" in
123
+ github|gitea) ;;
124
+ *)
125
+ echo "--forge-provider must be github or gitea" >&2
126
+ exit 1
127
+ ;;
128
+ esac
129
+
107
130
  case "$claude_effort" in
108
131
  low|medium|high|max) ;;
109
132
  *)
@@ -133,6 +156,7 @@ profile_home="${profile_home:-$(resolve_flow_profile_registry_root)}"
133
156
  profiles_dir="${profile_home}"
134
157
  profile_dir="${profiles_dir}/${profile_id}"
135
158
  profile_yaml="${profile_dir}/control-plane.yaml"
159
+ profile_runtime_env="${profile_dir}/runtime.env"
136
160
  profile_templates_dir="${profile_dir}/templates"
137
161
  profile_readme="${profile_dir}/README.md"
138
162
 
@@ -228,9 +252,9 @@ session_naming:
228
252
  pr_worktree_branch_prefix: "${pr_worktree_branch_prefix}"
229
253
  managed_pr_branch_globs: "${managed_pr_branch_globs}"
230
254
  queue:
231
- source: "github"
255
+ source: "${forge_provider}"
232
256
  issue_labels:
233
- ready: "agent-ready"
257
+ ready: ""
234
258
  running: "agent-running"
235
259
  blocked: "agent-blocked"
236
260
  heavy: "agent-e2e-heavy"
@@ -366,7 +390,38 @@ policies:
366
390
  EOF
367
391
  }
368
392
 
393
+ write_profile_runtime_env() {
394
+ local target_file="${1:?target file required}"
395
+
396
+ : >"$target_file"
397
+ {
398
+ printf 'ACP_FORGE_PROVIDER=%s\n' "${forge_provider}"
399
+ printf 'F_LOSNING_FORGE_PROVIDER=%s\n' "${forge_provider}"
400
+ if [[ "${forge_provider}" == "gitea" ]]; then
401
+ if [[ -n "${gitea_base_url}" ]]; then
402
+ printf 'ACP_GITEA_BASE_URL=%s\n' "${gitea_base_url}"
403
+ printf 'GITEA_BASE_URL=%s\n' "${gitea_base_url}"
404
+ fi
405
+ if [[ -n "${gitea_token}" ]]; then
406
+ printf 'ACP_GITEA_TOKEN=%s\n' "${gitea_token}"
407
+ printf 'GITEA_TOKEN=%s\n' "${gitea_token}"
408
+ fi
409
+ if [[ -n "${gitea_username}" ]]; then
410
+ printf 'ACP_GITEA_USERNAME=%s\n' "${gitea_username}"
411
+ printf 'GITEA_USERNAME=%s\n' "${gitea_username}"
412
+ fi
413
+ if [[ -n "${gitea_password}" ]]; then
414
+ printf 'ACP_GITEA_PASSWORD=%s\n' "${gitea_password}"
415
+ printf 'GITEA_PASSWORD=%s\n' "${gitea_password}"
416
+ fi
417
+ printf 'ACP_SOURCE_SYNC_REMOTE=gitea\n'
418
+ printf 'F_LOSNING_SOURCE_SYNC_REMOTE=gitea\n'
419
+ fi
420
+ } >"$target_file"
421
+ }
422
+
369
423
  write_profile_yaml "$profile_yaml"
424
+ write_profile_runtime_env "$profile_runtime_env"
370
425
  write_profile_readme "$profile_readme"
371
426
 
372
427
  if compgen -G "${flow_skill_dir}/tools/templates/*.md" >/dev/null; then
@@ -375,15 +430,18 @@ fi
375
430
 
376
431
  profile_home_real="$(mkdir -p "$profile_home" && cd "$profile_home" && pwd -P)"
377
432
  profile_yaml_real="$(cd "$(dirname "$profile_yaml")" && pwd -P)/$(basename "$profile_yaml")"
433
+ profile_runtime_env_real="$(cd "$(dirname "$profile_runtime_env")" && pwd -P)/$(basename "$profile_runtime_env")"
378
434
  profile_templates_dir_real="$(cd "$profile_templates_dir" && pwd -P)"
379
435
  profile_readme_real="$(cd "$(dirname "$profile_readme")" && pwd -P)/$(basename "$profile_readme")"
380
436
 
381
437
  printf 'PROFILE_ID=%s\n' "$profile_id"
382
438
  printf 'PROFILE_HOME=%s\n' "$profile_home_real"
383
439
  printf 'PROFILE_YAML=%s\n' "$profile_yaml_real"
440
+ printf 'PROFILE_RUNTIME_ENV=%s\n' "$profile_runtime_env_real"
384
441
  printf 'PROFILE_TEMPLATE_DIR=%s\n' "$profile_templates_dir_real"
385
442
  printf 'PROFILE_README=%s\n' "$profile_readme_real"
386
443
  printf 'REPO_SLUG=%s\n' "$repo_slug"
444
+ printf 'FORGE_PROVIDER=%s\n' "$forge_provider"
387
445
  printf 'CODING_WORKER=%s\n' "$coding_worker"
388
446
  printf 'NEXT_STEP=ACP_PROJECT_ID=%s bash %s/tools/bin/render-flow-config.sh\n' "$profile_id" "$flow_skill_dir"
389
447
  printf 'NEXT_STEP=bash %s/tools/bin/sync-shared-agent-home.sh\n' "$flow_skill_dir"
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ usage() {
5
+ cat <<'EOF'
6
+ Usage:
7
+ uninstall-project-systemd.sh --profile-id <id> [options]
8
+
9
+ Remove a previously installed systemd user service for an ACP project.
10
+
11
+ Options:
12
+ --profile-id <id> Installed profile id to manage
13
+ --unit-name <name> Override systemd unit name (default: agent-project-<id>.service)
14
+ --help Show this help
15
+ EOF
16
+ }
17
+
18
+ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
19
+ # shellcheck source=/dev/null
20
+ source "${script_dir}/flow-config-lib.sh"
21
+
22
+ profile_id_override=""
23
+ unit_name_override=""
24
+ profile_registry_root_override="${ACP_PROJECT_RUNTIME_PROFILE_REGISTRY_ROOT:-${ACP_PROFILE_REGISTRY_ROOT:-}}"
25
+
26
+ while [[ $# -gt 0 ]]; do
27
+ case "$1" in
28
+ --profile-id) profile_id_override="${2:-}"; shift 2 ;;
29
+ --unit-name) unit_name_override="${2:-}"; shift 2 ;;
30
+ --help|-h) usage; exit 0 ;;
31
+ *) echo "Unknown argument: $1" >&2; usage >&2; exit 64 ;;
32
+ esac
33
+ done
34
+
35
+ if [[ -z "${profile_id_override}" ]]; then
36
+ usage >&2
37
+ exit 64
38
+ fi
39
+
40
+ export ACP_PROJECT_ID="${profile_id_override}"
41
+ export AGENT_PROJECT_ID="${profile_id_override}"
42
+
43
+ if [[ -n "${profile_registry_root_override}" ]]; then
44
+ export ACP_PROFILE_REGISTRY_ROOT="${profile_registry_root_override}"
45
+ fi
46
+
47
+ flow_skill_dir="$(resolve_flow_skill_dir "${BASH_SOURCE[0]}")"
48
+ if ! flow_require_explicit_profile_selection "${flow_skill_dir}" "uninstall-project-systemd.sh"; then
49
+ exit 64
50
+ fi
51
+
52
+ config_yaml="$(resolve_flow_config_yaml "${BASH_SOURCE[0]}")"
53
+ if [[ ! -f "${config_yaml}" ]]; then
54
+ printf 'profile not installed: %s\n' "${profile_id_override}" >&2
55
+ exit 66
56
+ fi
57
+
58
+ profile_id="$(flow_resolve_adapter_id "${config_yaml}")"
59
+ profile_slug="$(printf '%s' "${profile_id}" | tr -c 'A-Za-z0-9._-' '-')"
60
+ home_dir="${ACP_PROJECT_RUNTIME_HOME_DIR:-${HOME:-}}"
61
+ systemd_dir="${ACP_PROJECT_RUNTIME_SYSTEMD_DIR:-${home_dir}/.config/systemd/user}"
62
+ unit_name="${unit_name_override:-${ACP_PROJECT_RUNTIME_SYSTEMD_UNIT:-agent-project-${profile_slug}.service}}"
63
+ unit_file="${systemd_dir}/${unit_name}"
64
+ workspace_dir="${ACP_PROJECT_RUNTIME_WORKSPACE_DIR:-${home_dir}/.agent-runtime/control-plane/workspace}"
65
+ wrapper_path="${workspace_dir}/bin/agent-project-${profile_slug}-systemd.sh"
66
+
67
+ # Check if systemd is available
68
+ if ! command -v systemctl &>/dev/null; then
69
+ echo "systemctl not found. Is systemd installed?" >&2
70
+ exit 1
71
+ fi
72
+
73
+ # Stop and disable the service
74
+ "${SYSTEMCTL_BIN}" --user stop "${unit_name}" 2>&1 || true
75
+ "${SYSTEMCTL_BIN}" --user disable "${unit_name}" 2>&1 || true
76
+
77
+ # Remove unit file
78
+ rm -f "${unit_file}"
79
+
80
+ # Remove wrapper script
81
+ rm -f "${wrapper_path}"
82
+
83
+ printf 'SYSTEMD_UNINSTALL_STATUS=ok\n'
84
+ printf 'PROFILE_ID=%s\n' "${profile_id}"
85
+ printf 'UNIT_NAME=%s\n' "${unit_name}"
86
+ printf 'UNIT_FILE=%s\n' "${unit_file}"
87
+ printf 'WRAPPER=%s\n' "${wrapper_path}"
@@ -72,6 +72,35 @@ function relativeTime(input) {
72
72
  return seconds >= 0 ? `${absolute}s ago` : `in ${absolute}s`;
73
73
  }
74
74
 
75
+ function formatDuration(seconds) {
76
+ if (!seconds && seconds !== 0) return "n/a";
77
+ const absSeconds = Math.abs(seconds);
78
+ const parts = [];
79
+ const units = [
80
+ [86400, "d"],
81
+ [3600, "h"],
82
+ [60, "m"],
83
+ [1, "s"],
84
+ ];
85
+ for (const [unitSeconds, label] of units) {
86
+ if (absSeconds >= unitSeconds) {
87
+ const amount = Math.floor(absSeconds / unitSeconds);
88
+ parts.push(`${amount}${label}`);
89
+ seconds -= amount * unitSeconds;
90
+ }
91
+ }
92
+ return parts.slice(0, 2).join(" ") || "0s";
93
+ }
94
+
95
+ function timeRemaining(isoString) {
96
+ if (!isoString) return "n/a";
97
+ const next = new Date(isoString);
98
+ if (Number.isNaN(next.getTime())) return isoString;
99
+ const diffSeconds = Math.round((next.getTime() - Date.now()) / 1000);
100
+ if (diffSeconds <= 0) return "ready now";
101
+ return formatDuration(diffSeconds);
102
+ }
103
+
75
104
  function statusClass(status) {
76
105
  if (!status) return "";
77
106
  return status.replace(/[^a-zA-Z0-9_-]/g, "-");
@@ -111,9 +140,10 @@ function renderOverview(snapshot) {
111
140
  acc.cooldowns += profile.counts.provider_cooldowns;
112
141
  acc.queue += profile.counts.queued_issues;
113
142
  acc.alerts += profile.counts.alerts || 0;
143
+ acc.pendingGithubWrites += profile.counts.pending_github_writes || 0;
114
144
  return acc;
115
145
  },
116
- { activeRuns: 0, runningRuns: 0, implementedRuns: 0, reportedRuns: 0, blockedRuns: 0, controllers: 0, cooldowns: 0, queue: 0, alerts: 0 },
146
+ { activeRuns: 0, runningRuns: 0, implementedRuns: 0, reportedRuns: 0, blockedRuns: 0, controllers: 0, cooldowns: 0, queue: 0, alerts: 0, pendingGithubWrites: 0 },
117
147
  );
118
148
 
119
149
  overviewNode.innerHTML = [
@@ -125,8 +155,11 @@ function renderOverview(snapshot) {
125
155
  ["Blocked", totals.blockedRuns],
126
156
  ["Live Controllers", totals.controllers],
127
157
  ["Provider Cooldowns", totals.cooldowns],
158
+ ["Pending GitHub Writes", totals.pendingGithubWrites],
128
159
  ["Alerts", totals.alerts],
129
160
  ["Queued Issues", totals.queue],
161
+ ["Retries", totals.retries || 0],
162
+ ["Blockers", totals.blockers || 0],
130
163
  ]
131
164
  .map(
132
165
  ([label, value]) => `
@@ -237,6 +270,8 @@ function renderProfile(profile) {
237
270
  ["Live controllers", profile.counts.live_resident_controllers],
238
271
  ["Stale controllers", profile.counts.stale_resident_controllers],
239
272
  ["Provider cooldowns", profile.counts.provider_cooldowns],
273
+ ["Pending GitHub writes", profile.counts.pending_github_writes || 0],
274
+ ["Failed GitHub writes", profile.counts.failed_github_writes || 0],
240
275
  ["Alerts", profile.counts.alerts || 0],
241
276
  ["Issue retries", profile.counts.active_retries],
242
277
  ["Queued issues", profile.counts.queued_issues],
@@ -252,6 +287,23 @@ function renderProfile(profile) {
252
287
  )
253
288
  .join("");
254
289
 
290
+ const runsFilterState = window._acpRunsFilter || { search: "", status: "all" };
291
+ window._acpRunsFilter = runsFilterState;
292
+
293
+ const filteredRuns = profile.runs.filter((row) => {
294
+ if (runsFilterState.status !== "all" && row.status !== runsFilterState.status) return false;
295
+ if (runsFilterState.search) {
296
+ const q = runsFilterState.search.toLowerCase();
297
+ return (
298
+ (row.session || "").toLowerCase().includes(q) ||
299
+ (row.coding_worker || "").toLowerCase().includes(q) ||
300
+ (row.task_kind || "").toLowerCase().includes(q) ||
301
+ (row.task_id || "").toLowerCase().includes(q)
302
+ );
303
+ }
304
+ return true;
305
+ });
306
+
255
307
  const runsTable = renderTable(
256
308
  [
257
309
  { label: "Session", render: (row) => `<div class="mono">${row.session}</div>` },
@@ -262,10 +314,26 @@ function renderProfile(profile) {
262
314
  { label: "Result", render: renderResult },
263
315
  { label: "Updated", render: (row) => row.updated_at ? `${relativeTime(row.updated_at)}<div class="muted">${row.updated_at}</div>` : "n/a" },
264
316
  ],
265
- profile.runs,
317
+ filteredRuns,
266
318
  "No active run directories for this profile.",
267
319
  );
268
320
 
321
+ const historyFilterState = window._acpHistoryFilter || { search: "", result: "all" };
322
+ window._acpHistoryFilter = historyFilterState;
323
+
324
+ const filteredHistory = (profile.recent_history || []).filter((row) => {
325
+ if (historyFilterState.result !== "all" && row.result_kind !== historyFilterState.result) return false;
326
+ if (historyFilterState.search) {
327
+ const q = historyFilterState.search.toLowerCase();
328
+ return (
329
+ (row.session || "").toLowerCase().includes(q) ||
330
+ (row.coding_worker || "").toLowerCase().includes(q) ||
331
+ (row.task_kind || "").toLowerCase().includes(q)
332
+ );
333
+ }
334
+ return true;
335
+ });
336
+
269
337
  const recentHistoryTable = renderTable(
270
338
  [
271
339
  { label: "Session", render: (row) => `<div class="mono">${row.session}</div>` },
@@ -275,7 +343,7 @@ function renderProfile(profile) {
275
343
  { label: "Result", render: renderResult },
276
344
  { label: "Updated", render: (row) => row.updated_at ? `${relativeTime(row.updated_at)}<div class="muted">${row.updated_at}</div>` : "n/a" },
277
345
  ],
278
- profile.recent_history || [],
346
+ filteredHistory,
279
347
  "No recently archived runs.",
280
348
  );
281
349
 
@@ -301,6 +369,7 @@ function renderProfile(profile) {
301
369
  { label: "Reason", render: (row) => row.last_reason || "n/a" },
302
370
  { label: "Attempts", key: "attempts" },
303
371
  { label: "Next attempt", render: (row) => row.next_attempt_at ? `${relativeTime(row.next_attempt_at)}<div class="muted">${row.next_attempt_at}</div>` : "n/a" },
372
+ { label: "Time Remaining", render: (row) => row.next_attempt_at ? timeRemaining(row.next_attempt_at) : "n/a" },
304
373
  ],
305
374
  profile.issue_retries || [],
306
375
  "No issue retries recorded.",
@@ -313,6 +382,7 @@ function renderProfile(profile) {
313
382
  { label: "Reason", render: (row) => row.last_reason || "n/a" },
314
383
  { label: "Attempts", key: "attempts" },
315
384
  { label: "Next attempt", render: (row) => row.next_attempt_at ? `${relativeTime(row.next_attempt_at)}<div class="muted">${row.next_attempt_at}</div>` : "n/a" },
385
+ { label: "Time Remaining", render: (row) => row.next_attempt_at ? timeRemaining(row.next_attempt_at) : "n/a" },
316
386
  ],
317
387
  profile.pr_retries || [],
318
388
  "No PR retries recorded.",
@@ -340,6 +410,7 @@ function renderProfile(profile) {
340
410
  { label: "Reason", render: (row) => row.last_reason || "n/a" },
341
411
  { label: "Attempts", key: "attempts" },
342
412
  { label: "Next attempt", render: (row) => row.next_attempt_at ? `${relativeTime(row.next_attempt_at)}<div class="muted">${row.next_attempt_at}</div>` : "n/a" },
413
+ { label: "Time Remaining", render: (row) => row.next_attempt_at ? timeRemaining(row.next_attempt_at) : "n/a" },
343
414
  ],
344
415
  profile.provider_cooldowns,
345
416
  "No provider cooldowns recorded.",
@@ -350,6 +421,7 @@ function renderProfile(profile) {
350
421
  { label: "Issue", key: "issue_id" },
351
422
  { label: "Interval", render: (row) => `${row.interval_seconds}s` },
352
423
  { label: "Next due", render: (row) => row.next_due_at ? `${relativeTime(row.next_due_at)}<div class="muted">${row.next_due_at}</div>` : "n/a" },
424
+ { label: "Time Remaining", render: (row) => row.next_due_at ? timeRemaining(row.next_due_at) : "n/a" },
353
425
  { label: "Last started", render: (row) => row.last_started_at ? `${relativeTime(row.last_started_at)}<div class="muted">${row.last_started_at}</div>` : "n/a" },
354
426
  ],
355
427
  profile.scheduled_issues,
@@ -378,6 +450,26 @@ function renderProfile(profile) {
378
450
  "No claimed issues.",
379
451
  );
380
452
 
453
+ const githubOutbox = profile.github_outbox || { counts: {}, pending: [] };
454
+ const githubOutboxTable = renderTable(
455
+ [
456
+ { label: "Type", render: (row) => row.type || "n/a" },
457
+ { label: "Target", render: (row) => `${row.kind || row.type || "write"} #${row.number || "?"}` },
458
+ {
459
+ label: "Payload",
460
+ render: (row) => {
461
+ if (row.type === "labels") {
462
+ return `+${row.add_count || 0} / -${row.remove_count || 0}`;
463
+ }
464
+ return row.body_preview || "n/a";
465
+ },
466
+ },
467
+ { label: "Created", render: (row) => row.created_at ? `${relativeTime(row.created_at)}<div class="muted">${row.created_at}</div>` : "n/a" },
468
+ ],
469
+ githubOutbox.pending || [],
470
+ "No pending GitHub write intents.",
471
+ );
472
+
381
473
  const codexRotationPanel =
382
474
  profile.coding_worker === "codex"
383
475
  ? `
@@ -389,6 +481,28 @@ function renderProfile(profile) {
389
481
  `
390
482
  : "";
391
483
 
484
+ const runsFilterBar = `
485
+ <div class="filter-bar">
486
+ <input type="text" class="filter-search" placeholder="Search runs..." value="${runsFilterState.search}"
487
+ oninput="window._acpRunsFilter.search=this.value; rerenderAll();" />
488
+ <button class="filter-btn ${runsFilterState.status === 'all' ? 'active' : ''}" onclick="window._acpRunsFilter.status='all'; rerenderAll();">All</button>
489
+ <button class="filter-btn ${runsFilterState.status === 'RUNNING' ? 'active' : ''}" onclick="window._acpRunsFilter.status='RUNNING'; rerenderAll();">Running</button>
490
+ <button class="filter-btn ${runsFilterState.status === 'SUCCEEDED' ? 'active' : ''}" onclick="window._acpRunsFilter.status='SUCCEEDED'; rerenderAll();">Completed</button>
491
+ <button class="filter-btn ${runsFilterState.status === 'FAILED' ? 'active' : ''}" onclick="window._acpRunsFilter.status='FAILED'; rerenderAll();">Failed</button>
492
+ </div>
493
+ `;
494
+
495
+ const historyFilterBar = `
496
+ <div class="filter-bar">
497
+ <input type="text" class="filter-search" placeholder="Search history..." value="${historyFilterState.search}"
498
+ oninput="window._acpHistoryFilter.search=this.value; rerenderAll();" />
499
+ <button class="filter-btn ${historyFilterState.result === 'all' ? 'active' : ''}" onclick="window._acpHistoryFilter.result='all'; rerenderAll();">All</button>
500
+ <button class="filter-btn ${historyFilterState.result === 'implemented' ? 'active' : ''}" onclick="window._acpHistoryFilter.result='implemented'; rerenderAll();">Implemented</button>
501
+ <button class="filter-btn ${historyFilterState.result === 'reported' ? 'active' : ''}" onclick="window._acpHistoryFilter.result='reported'; rerenderAll();">Reported</button>
502
+ <button class="filter-btn ${historyFilterState.result === 'blocked' ? 'active' : ''}" onclick="window._acpHistoryFilter.result='blocked'; rerenderAll();">Blocked</button>
503
+ </div>
504
+ `;
505
+
392
506
  return `
393
507
  <article class="profile">
394
508
  <header class="profile-header">
@@ -411,11 +525,13 @@ function renderProfile(profile) {
411
525
  <section class="panel">
412
526
  <h3>Active Runs</h3>
413
527
  <p class="panel-subtitle">Lifecycle shows technical session completion. Result shows what the run achieved: implemented, reported, or blocked.</p>
528
+ ${runsFilterBar}
414
529
  ${runsTable}
415
530
  </section>
416
531
  <section class="panel">
417
532
  <h3>Recent Completed Runs</h3>
418
533
  <p class="panel-subtitle">Recently archived runs so they do not disappear from the dashboard immediately after completion.</p>
534
+ ${historyFilterBar}
419
535
  ${recentHistoryTable}
420
536
  </section>
421
537
  <section class="panel">
@@ -436,6 +552,18 @@ function renderProfile(profile) {
436
552
  <h3>Resident Worker Metadata</h3>
437
553
  ${workerTable}
438
554
  </section>
555
+ <section class="panel">
556
+ <h3>Troubleshooting</h3>
557
+ <p class="panel-subtitle">Run diagnostics or debugging tools against this live profile.</p>
558
+ <div class="action-bar">
559
+ <button class="action-btn" onclick="runDoctor('${profile.id}')">Run Doctor</button>
560
+ <button class="action-btn" onclick="exportProfile('${profile.id}')">Export Profile</button>
561
+ <button class="action-btn" onclick="document.getElementById('import-file-${profile.id}').click()">Import Profile</button>
562
+ <input type="file" id="import-file-${profile.id}" style="display:none" accept=".json" onchange="importProfile('${profile.id}', this)">
563
+ <span id="doctor-status-${profile.id}"></span>
564
+ </div>
565
+ <pre id="doctor-output-${profile.id}" class="doctor-output" style="display:none;"></pre>
566
+ </section>
439
567
  <section class="panel half">
440
568
  <h3>Provider Cooldowns</h3>
441
569
  ${cooldownTable}
@@ -452,6 +580,11 @@ function renderProfile(profile) {
452
580
  <h3>Claimed Issues</h3>
453
581
  ${claimsTable}
454
582
  </section>
583
+ <section class="panel">
584
+ <h3>GitHub Outbox</h3>
585
+ <p class="panel-subtitle">Local write intents queued while ACP defers or retries GitHub sync. Pending ${githubOutbox.counts?.pending || 0}, sent ${githubOutbox.counts?.sent || 0}, failed ${githubOutbox.counts?.failed || 0}.</p>
586
+ ${githubOutboxTable}
587
+ </section>
455
588
  </section>
456
589
  </article>
457
590
  `;
@@ -494,9 +627,8 @@ async function loadSnapshot() {
494
627
  throw new Error(`Snapshot request failed with ${response.status}`);
495
628
  }
496
629
  const snapshot = await response.json();
497
- generatedAtNode.textContent = `Snapshot: ${snapshot.generated_at}`;
498
- renderOverview(snapshot);
499
- profilesNode.innerHTML = snapshot.profiles.map(renderProfile).join("");
630
+ window._acpSnapshot = snapshot;
631
+ renderFromSnapshot(snapshot);
500
632
  await maybeNotifyAlerts(snapshot);
501
633
  } catch (error) {
502
634
  generatedAtNode.textContent = `Snapshot load failed: ${error.message}`;
@@ -506,6 +638,96 @@ async function loadSnapshot() {
506
638
  }
507
639
  }
508
640
 
641
+ function renderFromSnapshot(snapshot) {
642
+ generatedAtNode.textContent = `Snapshot: ${snapshot.generated_at}`;
643
+ renderOverview(snapshot);
644
+ profilesNode.innerHTML = snapshot.profiles.map(renderProfile).join("");
645
+ }
646
+
647
+ function rerenderAll() {
648
+ const snapshot = window._acpSnapshot;
649
+ if (!snapshot) return;
650
+ renderFromSnapshot(snapshot);
651
+ }
652
+
653
+ async function runDoctor(profileId) {
654
+ const statusEl = document.getElementById(`doctor-status-${profileId}`);
655
+ const outputEl = document.getElementById(`doctor-output-${profileId}`);
656
+ if (statusEl) statusEl.textContent = "Running...";
657
+ if (outputEl) {
658
+ outputEl.style.display = "none";
659
+ outputEl.textContent = "";
660
+ }
661
+ try {
662
+ const response = await fetch(`/api/doctor?profile_id=${encodeURIComponent(profileId)}`, { cache: "no-store" });
663
+ const data = await response.json();
664
+ if (statusEl) statusEl.textContent = response.ok ? "Done" : `Error: ${data.error || response.status}`;
665
+ if (outputEl) {
666
+ outputEl.style.display = "block";
667
+ outputEl.textContent = data.output || data.error || "No output";
668
+ }
669
+ } catch (error) {
670
+ if (statusEl) statusEl.textContent = `Error: ${error.message}`;
671
+ }
672
+ }
673
+
674
+ async function exportProfile(profileId) {
675
+ try {
676
+ const response = await fetch(`/api/profile/export?profile_id=${encodeURIComponent(profileId)}`, { cache: "no-store" });
677
+ if (!response.ok) {
678
+ const data = await response.json();
679
+ alert(`Export failed: ${data.error || response.status}`);
680
+ return;
681
+ }
682
+ const data = await response.json();
683
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
684
+ const url = URL.createObjectURL(blob);
685
+ const a = document.createElement("a");
686
+ a.href = url;
687
+ a.download = `acp-profile-${profileId}.json`;
688
+ document.body.appendChild(a);
689
+ a.click();
690
+ document.body.removeChild(a);
691
+ URL.revokeObjectURL(url);
692
+ } catch (error) {
693
+ alert(`Export failed: ${error.message}`);
694
+ }
695
+ }
696
+
697
+ async function importProfile(profileId, inputEl) {
698
+ const file = inputEl.files[0];
699
+ if (!file) return;
700
+
701
+ try {
702
+ const text = await file.text();
703
+ const data = JSON.parse(text);
704
+
705
+ if (!data.profile_id || !data.config) {
706
+ alert("Invalid profile file: missing profile_id or config");
707
+ return;
708
+ }
709
+
710
+ const response = await fetch("/api/profile/import", {
711
+ method: "POST",
712
+ headers: { "Content-Type": "application/json" },
713
+ body: JSON.stringify(data),
714
+ });
715
+
716
+ const result = await response.json();
717
+ if (response.ok) {
718
+ alert(`Profile ${profileId} imported successfully!`);
719
+ // Refresh the page to show imported profile
720
+ setTimeout(() => window.location.reload(), 1000);
721
+ } else {
722
+ alert(`Import failed: ${result.error || response.status}`);
723
+ }
724
+ } catch (error) {
725
+ alert(`Import failed: ${error.message}`);
726
+ } finally {
727
+ inputEl.value = "";
728
+ }
729
+ }
730
+
509
731
  refreshButton.addEventListener("click", () => {
510
732
  void loadSnapshot();
511
733
  });
@@ -697,6 +697,57 @@ def collect_pr_retries(state_root: Path) -> list[dict[str, Any]]:
697
697
  return items
698
698
 
699
699
 
700
+ def collect_github_outbox(state_root: Path) -> dict[str, Any]:
701
+ outbox_root = state_root / "github-outbox"
702
+ pending_root = outbox_root / "pending"
703
+ sent_root = outbox_root / "sent"
704
+ failed_root = outbox_root / "failed"
705
+
706
+ def list_items(root: Path, limit: int | None = None) -> list[dict[str, Any]]:
707
+ if not root.is_dir():
708
+ return []
709
+
710
+ items: list[dict[str, Any]] = []
711
+ for path in sorted(root.glob("*.json"), key=lambda item: item.stat().st_mtime, reverse=True):
712
+ payload = read_json_file(path)
713
+ items.append(
714
+ {
715
+ "type": str(payload.get("type", "")),
716
+ "repo_slug": str(payload.get("repo_slug", "")),
717
+ "number": str(payload.get("number", "")),
718
+ "kind": str(payload.get("kind", "")),
719
+ "created_at": str(payload.get("created_at", "")),
720
+ "updated_at": file_mtime_iso(path),
721
+ "file": str(path),
722
+ "add_count": len(payload.get("add", []) or []),
723
+ "remove_count": len(payload.get("remove", []) or []),
724
+ "body_preview": summarize_whitespace(str(payload.get("body", "")))[:120],
725
+ }
726
+ )
727
+ if limit is not None and len(items) >= limit:
728
+ break
729
+ return items
730
+
731
+ all_pending_items = list_items(pending_root)
732
+ pending_items = all_pending_items[:20]
733
+ sent_items = list_items(sent_root, limit=5)
734
+ failed_items = list_items(failed_root, limit=5)
735
+
736
+ return {
737
+ "pending": pending_items,
738
+ "sent_recent": sent_items,
739
+ "failed_recent": failed_items,
740
+ "counts": {
741
+ "pending": len(all_pending_items),
742
+ "sent": len(list(sent_root.glob("*.json"))) if sent_root.is_dir() else 0,
743
+ "failed": len(list(failed_root.glob("*.json"))) if failed_root.is_dir() else 0,
744
+ "pending_comments": sum(1 for item in all_pending_items if item["type"] == "comment"),
745
+ "pending_approvals": sum(1 for item in all_pending_items if item["type"] == "approval"),
746
+ "pending_label_updates": sum(1 for item in all_pending_items if item["type"] == "labels"),
747
+ },
748
+ }
749
+
750
+
700
751
  def resolve_history_root(render_env: dict[str, str], yaml_env: dict[str, str], runs_root: Path) -> Path:
701
752
  configured = (
702
753
  render_env.get("EFFECTIVE_HISTORY_ROOT", "").strip()
@@ -726,6 +777,7 @@ def build_profile_snapshot(profile_id: str, registry_root: Path) -> dict[str, An
726
777
  retries = collect_issue_retries(state_root)
727
778
  pr_retries = collect_pr_retries(state_root)
728
779
  queue = collect_issue_queue(state_root)
780
+ github_outbox = collect_github_outbox(state_root)
729
781
  alerts = [alert for run in (runs + recent_history) for alert in run.get("alerts", [])]
730
782
  codex_rotation = collect_codex_rotation(render_env)
731
783
 
@@ -773,6 +825,8 @@ def build_profile_snapshot(profile_id: str, registry_root: Path) -> dict[str, An
773
825
  "active_retries": sum(1 for item in retries if not item.get("ready", True)),
774
826
  "scheduled_issues": len(scheduled),
775
827
  "alerts": len(alerts),
828
+ "pending_github_writes": github_outbox["counts"]["pending"],
829
+ "failed_github_writes": github_outbox["counts"]["failed"],
776
830
  },
777
831
  "runs": runs,
778
832
  "recent_history": recent_history,
@@ -784,6 +838,7 @@ def build_profile_snapshot(profile_id: str, registry_root: Path) -> dict[str, An
784
838
  "issue_retries": retries,
785
839
  "pr_retries": pr_retries,
786
840
  "issue_queue": queue,
841
+ "github_outbox": github_outbox,
787
842
  }
788
843
 
789
844