zillacore 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +12 -0
  4. data/Gemfile +3 -0
  5. data/Gemfile.lock +126 -0
  6. data/README.md +1166 -0
  7. data/Rakefile +12 -0
  8. data/bin/zillacore +1521 -0
  9. data/certs/stowzilla.pem +26 -0
  10. data/docs/waybar-config.md +96 -0
  11. data/lib/user_registry.rb +159 -0
  12. data/lib/zillacore/agents.rb +203 -0
  13. data/lib/zillacore/brain.rb +197 -0
  14. data/lib/zillacore/card_index.rb +389 -0
  15. data/lib/zillacore/config.rb +263 -0
  16. data/lib/zillacore/cron.rb +629 -0
  17. data/lib/zillacore/deployments.rb +258 -0
  18. data/lib/zillacore/handlers/discord.rb +1643 -0
  19. data/lib/zillacore/handlers/fizzy.rb +1249 -0
  20. data/lib/zillacore/handlers/github.rb +598 -0
  21. data/lib/zillacore/handlers/zoho.rb +487 -0
  22. data/lib/zillacore/helpers.rb +760 -0
  23. data/lib/zillacore/planning.rb +237 -0
  24. data/lib/zillacore/prompts.rb +620 -0
  25. data/lib/zillacore/sessions.rb +282 -0
  26. data/lib/zillacore/skills.rb +276 -0
  27. data/lib/zillacore/users.rb +76 -0
  28. data/lib/zillacore/version.rb +6 -0
  29. data/lib/zillacore/zoho_mail_api.rb +109 -0
  30. data/lib/zillacore.rb +10 -0
  31. data/monitor/daemon.rb +99 -0
  32. data/monitor/deploy-env-macos.rb +131 -0
  33. data/monitor/menubar.rb +295 -0
  34. data/monitor/open-action.sh +15 -0
  35. data/monitor/setup-menubar.rb +78 -0
  36. data/monitor/setup-waybar-deploy-envs.rb +121 -0
  37. data/monitor/setup-waybar-deployments.rb +96 -0
  38. data/monitor/setup-waybar-module.rb +113 -0
  39. data/monitor/setup-xbar-plugin.rb +35 -0
  40. data/monitor/view-logs-macos.rb +210 -0
  41. data/monitor/view-logs-rofi.rb +194 -0
  42. data/monitor/view-logs.rb +119 -0
  43. data/monitor/waybar-config-updater.rb +56 -0
  44. data/monitor/waybar-deploy-env.rb +206 -0
  45. data/monitor/waybar-deployments.rb +239 -0
  46. data/monitor/waybar.rb +146 -0
  47. data/monitor/xbar.3s.rb +179 -0
  48. data/receiver.rb +956 -0
  49. data/templates/agents.json.example +10 -0
  50. data/templates/discord.json.example +17 -0
  51. data/templates/fizzy.json.example +24 -0
  52. data/templates/github.json.example +4 -0
  53. data/templates/testflight.json.example +8 -0
  54. data/templates/users.json.example +121 -0
  55. data/templates/zoho.json.example +27 -0
  56. data/views/dashboard.erb +437 -0
  57. data/zillacore.gemspec +30 -0
  58. data.tar.gz.sig +2 -0
  59. metadata +235 -0
  60. metadata.gz.sig +0 -0
@@ -0,0 +1,10 @@
1
+ {
2
+ "galen": {
3
+ "fizzy_name": "Galen",
4
+ "local": true,
5
+ "env": {
6
+ "FIZZY_TOKEN": "your-fizzy-token",
7
+ "DISCORD_BOT_TOKEN": "your-discord-bot-token"
8
+ }
9
+ }
10
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "default_project": "marketplace",
3
+ "owner_discord_id": "DISCORD_USER_ID",
4
+ "dashboard_token": null,
5
+ "channel_mappings": {
6
+ "OPTIONAL_CHANNEL_ID": {
7
+ "project": "zillacore"
8
+ }
9
+ },
10
+ "user_mappings": {
11
+ "Andy": "DISCORD_USER_ID",
12
+ "Adam": "DISCORD_USER_ID",
13
+ "Kaylee": "DISCORD_BOT_USER_ID_FROM_OTHER_MACHINE"
14
+ },
15
+ "authorized_role_ids": [],
16
+ "authorized_user_ids": []
17
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "authorized_users": [
3
+ {"id": "397928984232591361", "name": "Andy", "human": true},
4
+ {"id": "832331260088287242", "name": "Adam", "human": true},
5
+ {"id": "1475925968584573181", "name": "Galen", "human": false},
6
+ {"id": "1475957005817610412", "name": "GLaDOS", "human": false},
7
+ {"id": "1476068144278929438", "name": "Threepio", "human": false},
8
+ {"id": "1475961955125825687", "name": "Kaylee", "human": false},
9
+ {"id": "1476572591220199514", "name": "Sleeper Service", "human": false},
10
+ {"id": "1476683857922228386", "name": "Avon", "human": false},
11
+ {"id": "1476689687966908601", "name": "Sheogorath", "human": false}
12
+ ],
13
+ "boards": {
14
+ "development": {
15
+ "board_id": "03f5ivksx9wnll33cu3ybs66r",
16
+ "webhook_secret": "your-dev-board-webhook-secret",
17
+ "columns": {
18
+ "right_now": "03f5xa5q9fog9592pa1279dts",
19
+ "needs_review": "03f5ykobhpsd78hbuvajtn8g8",
20
+ "uat": "03fsmglsr6az06ppyotawsti8"
21
+ }
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "webhook_secret": "your-github-webhook-secret",
3
+ "repos": {}
4
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "signing_secret": "YOUR_APPLE_WEBHOOK_SIGNING_SECRET",
3
+ "discord_channel_id": "CHANNEL_ID_FOR_TESTFLIGHT_NOTIFICATIONS",
4
+ "notify_as": "avon",
5
+ "api_key_path": "~/.appstoreconnect/AuthKey_6VBGCK56SQ.p8",
6
+ "api_key_id": "6VBGCK56SQ",
7
+ "issuer_id": "337a9282-6778-4fe4-bdbb-fd2845ef0c19"
8
+ }
@@ -0,0 +1,121 @@
1
+ {
2
+ "users": [
3
+ {
4
+ "canonical_name": "Adam Dalton",
5
+ "identities": {
6
+ "discord": {
7
+ "username": "fladamd",
8
+ "user_id": "832331260088287242",
9
+ "display_name": "fladamd"
10
+ },
11
+ "github": {
12
+ "username": "dalton"
13
+ },
14
+ "fizzy": {
15
+ "username": "adam-dalton"
16
+ }
17
+ },
18
+ "aliases": ["Andy"],
19
+ "relationships": {
20
+ "spouse": "Larissa"
21
+ },
22
+ "notes": "Primary user"
23
+ },
24
+ {
25
+ "canonical_name": "Andy Davis",
26
+ "identities": {
27
+ "discord": {
28
+ "username": "ardavis",
29
+ "user_id": "397928984232591361",
30
+ "display_name": "ardavis"
31
+ },
32
+ "github": {
33
+ "username": "ardavis"
34
+ }
35
+ },
36
+ "aliases": [],
37
+ "relationships": {},
38
+ "notes": "Works with Adam on zillacore development"
39
+ },
40
+ {
41
+ "canonical_name": "Kaylee",
42
+ "identities": {
43
+ "discord": {
44
+ "username": "kaylee",
45
+ "user_id": "1475961955125825687",
46
+ "display_name": "Kaylee"
47
+ }
48
+ },
49
+ "aliases": [],
50
+ "relationships": {},
51
+ "notes": "AI agent - cheerful, optimistic, loves fixing things"
52
+ },
53
+ {
54
+ "canonical_name": "Sleeper Service",
55
+ "identities": {
56
+ "discord": {
57
+ "username": "sleeper_service",
58
+ "user_id": "1476572591220199514",
59
+ "display_name": "Sleeper Service"
60
+ }
61
+ },
62
+ "aliases": [],
63
+ "relationships": {},
64
+ "notes": "AI agent - dry wit, cosmic perspective, understated"
65
+ },
66
+ {
67
+ "canonical_name": "Avon",
68
+ "identities": {
69
+ "discord": {
70
+ "username": "avon",
71
+ "user_id": "1476683857922228386",
72
+ "display_name": "Avon"
73
+ }
74
+ },
75
+ "aliases": [],
76
+ "relationships": {},
77
+ "notes": "AI agent - iOS specialist, pragmatic, competence-driven"
78
+ },
79
+ {
80
+ "canonical_name": "Galen",
81
+ "identities": {
82
+ "discord": {
83
+ "username": "galen",
84
+ "user_id": "1475925968584573181",
85
+ "display_name": "Galen"
86
+ }
87
+ },
88
+ "aliases": [],
89
+ "relationships": {},
90
+ "notes": "AI agent"
91
+ },
92
+ {
93
+ "canonical_name": "GLaDOS",
94
+ "identities": {
95
+ "discord": {
96
+ "username": "glados",
97
+ "user_id": "1475957005817610412",
98
+ "display_name": "GLaDOS"
99
+ }
100
+ },
101
+ "aliases": [],
102
+ "relationships": {},
103
+ "notes": "AI agent"
104
+ },
105
+ {
106
+ "canonical_name": "Threepio",
107
+ "identities": {
108
+ "discord": {
109
+ "username": "threepio",
110
+ "user_id": "1476068144278929438",
111
+ "display_name": "Threepio"
112
+ }
113
+ },
114
+ "aliases": [],
115
+ "relationships": {},
116
+ "notes": "AI agent"
117
+ }
118
+ ],
119
+ "schema_version": "1.0",
120
+ "last_updated": "2026-02-27T13:52:22-05:00"
121
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "hook_secret": null,
3
+ "default_discord_channel_id": "YOUR_DISCORD_CHANNEL_ID",
4
+ "notify_as": "threepio",
5
+ "rules": [
6
+ {
7
+ "label": "Item Sold",
8
+ "enabled": true,
9
+ "from_contains": "",
10
+ "to_contains": "",
11
+ "subject_contains": "sold",
12
+ "body_contains": "",
13
+ "exclude_words": [],
14
+ "emoji": "💰",
15
+ "discord_channel_id": null,
16
+ "notify_as": null
17
+ }
18
+ ],
19
+ "fallback": {
20
+ "enabled": true,
21
+ "label": "Unmatched Email",
22
+ "emoji": "📬",
23
+ "exclude_words": [],
24
+ "discord_channel_id": null,
25
+ "notify_as": null
26
+ }
27
+ }
@@ -0,0 +1,437 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>ZillaCore Dashboard</title>
7
+ <style>
8
+ :root { --bg: #0d1117; --card: #161b22; --border: #30363d; --text: #e6edf3; --dim: #8b949e; --green: #3fb950; --yellow: #d29922; --red: #f85149; --blue: #58a6ff; }
9
+ * { box-sizing: border-box; margin: 0; padding: 0; }
10
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
11
+ .header { padding: 16px 24px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
12
+ .header h1 { font-size: 18px; font-weight: 600; }
13
+ .header .meta { font-size: 13px; color: var(--dim); }
14
+ .status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
15
+ .status-dot.active { background: var(--green); box-shadow: 0 0 6px var(--green); }
16
+ .status-dot.idle { background: var(--dim); }
17
+ .container { max-width: 1200px; margin: 0 auto; padding: 24px; }
18
+ .section-title { font-size: 14px; font-weight: 600; color: var(--dim); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; }
19
+ .sessions { display: grid; gap: 12px; margin-bottom: 32px; }
20
+ .session-card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 16px; cursor: pointer; transition: border-color 0.15s; }
21
+ .session-card:hover { border-color: var(--blue); }
22
+ .session-card.selected { border-color: var(--blue); box-shadow: 0 0 0 1px var(--blue); }
23
+ .session-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
24
+ .agent-name { font-size: 16px; font-weight: 600; }
25
+ .agent-emoji { margin-right: 6px; }
26
+ .elapsed { font-size: 13px; color: var(--dim); font-variant-numeric: tabular-nums; }
27
+ .session-detail { font-size: 13px; color: var(--dim); }
28
+ .badge { display: inline-block; font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: 500; }
29
+ .badge.alive { background: rgba(63,185,80,0.15); color: var(--green); }
30
+ .badge.finished { background: rgba(139,148,158,0.15); color: var(--dim); }
31
+ .badge.enabled { background: rgba(63,185,80,0.15); color: var(--green); }
32
+ .badge.disabled { background: rgba(248,81,73,0.15); color: var(--red); }
33
+ .deploy-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; margin-bottom: 32px; }
34
+ .deploy-card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 16px; }
35
+ .deploy-card.occupied { border-left: 3px solid var(--red); }
36
+ .deploy-card.available { border-left: 3px solid var(--green); }
37
+ .deploy-card.deploying { border-left: 3px solid var(--yellow); }
38
+ .deploy-card.failed { border-left: 3px solid var(--red); background: rgba(248,81,73,0.05); }
39
+ .deploy-card .env-label { font-size: 14px; font-weight: 600; margin-bottom: 6px; display: flex; align-items: center; justify-content: space-between; }
40
+ .deploy-card .env-label .status-icon { font-size: 12px; }
41
+ .deploy-card .deploy-info { font-size: 13px; color: var(--dim); }
42
+ .deploy-card .deploy-info a { color: var(--blue); text-decoration: none; }
43
+ .deploy-card .deploy-info a:hover { text-decoration: underline; }
44
+ .deploy-card .deploy-meta { margin-top: 6px; font-size: 12px; color: var(--dim); }
45
+ .cron-table { width: 100%; border-collapse: collapse; font-size: 13px; margin-bottom: 32px; }
46
+ .cron-table th { text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--border); color: var(--dim); font-weight: 500; }
47
+ .cron-table td { padding: 8px 12px; border-bottom: 1px solid var(--border); }
48
+ .cron-table tr:last-child td { border-bottom: none; }
49
+ .cron-table .job-id { font-weight: 600; }
50
+ .cron-table .task-cell { min-width: 200px; }
51
+ .cron-table .prompt-text { display: inline-block; color: var(--dim); max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; vertical-align: middle; }
52
+ .cron-table .prompt-text:hover { color: var(--text); }
53
+ .cron-table .prompt-text::after { content: ' ▸'; font-size: 10px; color: var(--dim); }
54
+ .cron-table .prompt-full { color: var(--dim); white-space: pre-wrap; word-break: break-word; max-width: 400px; font-size: 12px; cursor: pointer; }
55
+ .cron-table .script-link { color: var(--blue); cursor: pointer; text-decoration: underline; text-decoration-style: dotted; }
56
+ .cron-table .script-link:hover { text-decoration-style: solid; }
57
+ .cron-toggle { background: none; border: 1px solid var(--border); color: var(--dim); padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 11px; }
58
+ .cron-toggle:hover { border-color: var(--blue); color: var(--text); }
59
+ .cron-runs { font-size: 12px; }
60
+ .cron-runs a { color: var(--blue); text-decoration: none; cursor: pointer; }
61
+ .cron-runs a:hover { text-decoration: underline; }
62
+ .cron-runs .run-time { color: var(--dim); margin-left: 4px; }
63
+ .script-viewer { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; margin-top: 8px; overflow: hidden; max-width: 600px; }
64
+ .script-viewer-header { display: flex; justify-content: space-between; align-items: center; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 11px; color: var(--dim); }
65
+ .script-viewer pre { margin: 0; padding: 12px; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; line-height: 1.5; overflow-x: auto; max-height: 400px; overflow-y: auto; }
66
+ .script-viewer .rb-kw { color: #ff7b72; }
67
+ .script-viewer .rb-str { color: #a5d6ff; }
68
+ .script-viewer .rb-cm { color: #8b949e; font-style: italic; }
69
+ .script-viewer .rb-num { color: #79c0ff; }
70
+ .script-viewer .rb-sym { color: #ffa657; }
71
+ .script-viewer .rb-const { color: #d2a8ff; }
72
+ .script-viewer .rb-ivar { color: #ffa657; }
73
+ .log-viewer { background: var(--card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
74
+ .log-header { padding: 12px 16px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
75
+ .log-header .title { font-size: 14px; font-weight: 600; }
76
+ .log-header .controls { display: flex; gap: 8px; align-items: center; }
77
+ .log-header button { background: var(--border); border: none; color: var(--text); padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 12px; }
78
+ .log-header button:hover { background: var(--dim); }
79
+ .log-header button.active { background: var(--blue); }
80
+ .log-body { padding: 16px; max-height: 70vh; overflow-y: auto; font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; color: var(--dim); }
81
+ .log-body.empty { text-align: center; padding: 48px; color: var(--dim); }
82
+ .recent-row { display: flex; align-items: center; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
83
+ .recent-row:last-child { border-bottom: none; }
84
+ .recent-row .agent { font-weight: 600; min-width: 120px; }
85
+ .recent-row .context { color: var(--dim); flex: 1; }
86
+ .recent-row .time { color: var(--dim); font-variant-numeric: tabular-nums; }
87
+ @media (max-width: 640px) { .container { padding: 12px; } .session-top { flex-wrap: wrap; } }
88
+ </style>
89
+ </head>
90
+ <body>
91
+ <div class="header">
92
+ <h1>⚡ ZillaCore</h1>
93
+ <div class="meta">
94
+ <span class="status-dot idle" id="conn-dot"></span>
95
+ <span id="version"></span> · updated <span id="last-update">—</span>
96
+ </div>
97
+ </div>
98
+ <div class="container">
99
+ <div id="active-section">
100
+ <div class="section-title">Active Sessions</div>
101
+ <div class="sessions" id="sessions"></div>
102
+ </div>
103
+ <div id="log-section" style="display:none; margin-bottom:32px;">
104
+ <div class="log-viewer">
105
+ <div class="log-header">
106
+ <div class="title" id="log-title">Log</div>
107
+ <div class="controls">
108
+ <button id="btn-follow" class="active" onclick="toggleFollow()">Auto-scroll</button>
109
+ <button onclick="closeLog()">✕ Close</button>
110
+ </div>
111
+ </div>
112
+ <div class="log-body" id="log-body">Select a session to view its log.</div>
113
+ </div>
114
+ </div>
115
+ <div id="deploy-section" style="margin-bottom:32px;">
116
+ <div class="section-title">Deployments</div>
117
+ <div class="deploy-grid" id="deployments"></div>
118
+ </div>
119
+ <div id="cron-section" style="margin-bottom:32px;">
120
+ <div class="section-title">Cron Jobs</div>
121
+ <div id="cron-jobs"></div>
122
+ </div>
123
+ <div>
124
+ <div class="section-title">Recent Sessions</div>
125
+ <div id="recent"></div>
126
+ </div>
127
+ </div>
128
+ <script>
129
+ const AGENTS = <%= load_dashboard_agents.to_json %>;
130
+ const DASH_TOKEN = new URLSearchParams(window.location.search).get('token');
131
+ function apiUrl(path) { return DASH_TOKEN ? path + (path.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(DASH_TOKEN) : path; }
132
+ let selectedFile = null;
133
+ let autoFollow = true;
134
+ let logPollTimer = null;
135
+
136
+ function agentMeta(name) {
137
+ return AGENTS[(name||'').toLowerCase()] || { emoji: '❓', color: 'gray' };
138
+ }
139
+
140
+ function elapsed(s) {
141
+ if (s < 60) return s + 's';
142
+ if (s < 3600) return Math.floor(s/60) + 'm';
143
+ return Math.floor(s/3600) + 'h ' + Math.floor((s%3600)/60) + 'm';
144
+ }
145
+
146
+ function contextLabel(key) {
147
+ if (key.startsWith('discord-')) return 'Discord';
148
+ if (key.startsWith('card-')) return '#' + key.split('-')[1];
149
+ return key;
150
+ }
151
+
152
+ function timeAgo(iso) {
153
+ if (!iso) return '—';
154
+ const d = (Date.now() - new Date(iso).getTime()) / 1000;
155
+ if (d < 60) return 'just now';
156
+ if (d < 3600) return Math.floor(d/60) + 'm ago';
157
+ if (d < 86400) return Math.floor(d/3600) + 'h ago';
158
+ return Math.floor(d/86400) + 'd ago';
159
+ }
160
+
161
+ function selectSession(logFile, agentName) {
162
+ selectedFile = logFile;
163
+ document.getElementById('log-section').style.display = 'block';
164
+ document.getElementById('log-title').textContent = agentMeta(agentName).emoji + ' ' + agentName + ' — Log';
165
+ document.getElementById('log-body').textContent = 'Loading…';
166
+ document.querySelectorAll('.session-card').forEach(c => c.classList.remove('selected'));
167
+ document.querySelectorAll('.session-card').forEach(c => { if (c.dataset.file === logFile) c.classList.add('selected'); });
168
+ fetchLog();
169
+ clearInterval(logPollTimer);
170
+ logPollTimer = setInterval(fetchLog, 3000);
171
+ }
172
+
173
+ function closeLog() {
174
+ selectedFile = null;
175
+ document.getElementById('log-section').style.display = 'none';
176
+ clearInterval(logPollTimer);
177
+ document.querySelectorAll('.session-card').forEach(c => c.classList.remove('selected'));
178
+ }
179
+
180
+ function toggleFollow() {
181
+ autoFollow = !autoFollow;
182
+ document.getElementById('btn-follow').classList.toggle('active', autoFollow);
183
+ }
184
+
185
+ async function fetchLog() {
186
+ if (!selectedFile) return;
187
+ try {
188
+ const r = await fetch(apiUrl('/api/logs?file=' + encodeURIComponent(selectedFile) + '&lines=300'));
189
+ if (!r.ok) { document.getElementById('log-body').textContent = 'Error: ' + r.status; return; }
190
+ const text = await r.text();
191
+ const el = document.getElementById('log-body');
192
+ el.textContent = text || '(empty)';
193
+ if (autoFollow) el.scrollTop = el.scrollHeight;
194
+ } catch(e) { document.getElementById('log-body').textContent = 'Fetch error: ' + e.message; }
195
+ }
196
+
197
+ async function poll() {
198
+ try {
199
+ const r = await fetch(apiUrl('/api/status'));
200
+ const data = await r.json();
201
+ document.getElementById('conn-dot').className = 'status-dot active';
202
+ document.getElementById('version').textContent = 'v' + data.version;
203
+ document.getElementById('last-update').textContent = new Date().toLocaleTimeString();
204
+
205
+ const el = document.getElementById('sessions');
206
+ if (data.sessions.length === 0) {
207
+ el.innerHTML = '<div style="color:var(--dim);font-size:14px;">💤 No active sessions</div>';
208
+ } else {
209
+ el.innerHTML = data.sessions.map(s => {
210
+ const m = agentMeta(s.agent);
211
+ const sel = s.log_file === selectedFile ? ' selected' : '';
212
+ return '<div class="session-card' + sel + '" data-file="' + (s.log_file||'') + '" onclick="selectSession(\'' + (s.log_file||'').replace(/'/g,"\\'") + "','" + (s.agent||'').replace(/'/g,"\\'") + '\')">' +
213
+ '<div class="session-top"><span><span class="agent-emoji">' + m.emoji + '</span><span class="agent-name">' + (s.agent||'Unknown') + '</span></span>' +
214
+ '<span class="elapsed">' + elapsed(s.elapsed_seconds) + '</span></div>' +
215
+ '<div class="session-detail">' + contextLabel(s.card_key) + ' · PID ' + s.pid + ' <span class="badge alive">running</span></div></div>';
216
+ }).join('');
217
+ }
218
+
219
+ const rec = document.getElementById('recent');
220
+ if ((data.recent||[]).length === 0) {
221
+ rec.innerHTML = '<div style="color:var(--dim);font-size:13px;">No recent sessions</div>';
222
+ } else {
223
+ rec.innerHTML = data.recent.map(s => {
224
+ const m = agentMeta(s.agent);
225
+ const clickable = s.log_file ? ' style="cursor:pointer" onclick="selectSession(\'' + s.log_file.replace(/'/g,"\\'") + "','" + (s.agent||'').replace(/'/g,"\\'") + '\')"' : '';
226
+ return '<div class="recent-row"' + clickable + '><span class="agent">' + m.emoji + ' ' + (s.agent||'Unknown') + '</span>' +
227
+ '<span class="context">' + contextLabel(s.card_key) + '</span>' +
228
+ '<span class="time">' + timeAgo(s.finished_at) + '</span></div>';
229
+ }).join('');
230
+ }
231
+ } catch(e) {
232
+ document.getElementById('conn-dot').className = 'status-dot idle';
233
+ }
234
+ }
235
+
236
+ async function toggleCron(id, enabled) {
237
+ try {
238
+ await fetch(apiUrl('/api/cron/toggle'), {
239
+ method: 'POST',
240
+ headers: { 'Content-Type': 'application/json' },
241
+ body: JSON.stringify({ id, enabled })
242
+ });
243
+ pollCron();
244
+ } catch(e) {}
245
+ }
246
+
247
+ let cronExpanded = {};
248
+ let cronScriptCache = {};
249
+
250
+ function togglePrompt(id) {
251
+ cronExpanded[id] = !cronExpanded[id];
252
+ pollCron();
253
+ }
254
+
255
+ async function viewScript(id, path) {
256
+ if (cronExpanded[id]) { cronExpanded[id] = false; pollCron(); return; }
257
+ cronExpanded[id] = true;
258
+ if (!cronScriptCache[path]) {
259
+ try {
260
+ const r = await fetch(apiUrl('/api/cron/script?path=' + encodeURIComponent(path)));
261
+ cronScriptCache[path] = r.ok ? await r.text() : 'Error: ' + r.status;
262
+ } catch(e) { cronScriptCache[path] = 'Fetch error: ' + e.message; }
263
+ }
264
+ pollCron();
265
+ }
266
+
267
+ function highlightRuby(code) {
268
+ // Tokenize first to avoid regexes matching inside HTML tags from earlier passes
269
+ const tokens = [];
270
+ const rules = [
271
+ [/^#[^\n]*/, 'rb-cm'],
272
+ [/^"(?:[^"\\]|\\.)*"/, 'rb-str'],
273
+ [/^'(?:[^'\\]|\\.)*'/, 'rb-str'],
274
+ [/^(?:def|end|class|module|do|if|elsif|else|unless|while|until|for|in|return|yield|begin|rescue|ensure|raise|require|require_relative|include|extend|attr_accessor|attr_reader|attr_writer|private|protected|public|self|true|false|nil|and|or|not|then|case|when|super|lambda|proc|block_given\?)(?!\w)/, 'rb-kw'],
275
+ [/^@\w+/, 'rb-ivar'],
276
+ [/^[A-Z][A-Za-z0-9_]*/, 'rb-const'],
277
+ [/^:[a-zA-Z_]\w*/, 'rb-sym'],
278
+ [/^\d+\.?\d*/, 'rb-num'],
279
+ ];
280
+ let rest = code;
281
+ while (rest.length > 0) {
282
+ let matched = false;
283
+ // Try word-boundary rules only at word-appropriate positions
284
+ for (const [re, cls] of rules) {
285
+ const m = rest.match(re);
286
+ if (m) {
287
+ // For keywords, ensure we're at a word boundary (not mid-identifier)
288
+ if (cls === 'rb-kw' && tokens.length > 0) {
289
+ const prev = tokens[tokens.length - 1];
290
+ if (!prev[1] && /\w$/.test(prev[0])) continue;
291
+ }
292
+ if (cls === 'rb-sym' && tokens.length > 0) {
293
+ const prev = tokens[tokens.length - 1];
294
+ if (!prev[1] && prev[0].endsWith(':')) continue;
295
+ }
296
+ tokens.push([m[0], cls]);
297
+ rest = rest.slice(m[0].length);
298
+ matched = true;
299
+ break;
300
+ }
301
+ }
302
+ if (!matched) {
303
+ // Accumulate plain text
304
+ if (tokens.length > 0 && !tokens[tokens.length - 1][1]) {
305
+ tokens[tokens.length - 1][0] += rest[0];
306
+ } else {
307
+ tokens.push([rest[0], null]);
308
+ }
309
+ rest = rest.slice(1);
310
+ }
311
+ }
312
+ return tokens.map(([text, cls]) => {
313
+ const esc = text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
314
+ return cls ? '<span class="' + cls + '">' + esc + '</span>' : esc;
315
+ }).join('');
316
+ }
317
+
318
+ async function fetchCronLogs(id) {
319
+ try {
320
+ const r = await fetch(apiUrl('/api/cron/logs?id=' + encodeURIComponent(id)));
321
+ return await r.json();
322
+ } catch(e) { return []; }
323
+ }
324
+
325
+ async function pollCron() {
326
+ try {
327
+ const r = await fetch(apiUrl('/api/cron'));
328
+ const data = await r.json();
329
+ const el = document.getElementById('cron-jobs');
330
+ const jobs = Object.entries(data.jobs || {});
331
+ if (jobs.length === 0) {
332
+ el.innerHTML = '<div style="color:var(--dim);font-size:13px;">No cron jobs configured</div>';
333
+ return;
334
+ }
335
+ let html = '<table class="cron-table"><tr><th>Job</th><th>Schedule</th><th>Agent</th><th>Project</th><th>Task</th><th>Runs</th><th></th></tr>';
336
+ const logPromises = jobs.map(([id]) => fetchCronLogs(id));
337
+ const allLogs = await Promise.all(logPromises);
338
+ jobs.forEach(([id, j], i) => {
339
+ const badge = j.enabled ? '<span class="badge enabled">enabled</span>' : '<span class="badge disabled">disabled</span>';
340
+ const agent = j.script ? 'script' : (j.agent || '—');
341
+ const toggleLabel = j.enabled ? 'disable' : 'enable';
342
+ const expanded = cronExpanded[id];
343
+ let task;
344
+ if (j.script) {
345
+ const scriptName = j.script.split('/').pop();
346
+ if (expanded && cronScriptCache[j.script]) {
347
+ task = '<span class="prompt-text" onclick="event.stopPropagation();viewScript(\'' + id + '\',\'' + j.script.replace(/'/g,"\\'") + '\')" title="Click to collapse"><code>' + scriptName + '</code> ▾</span>' +
348
+ '<div class="script-viewer"><div class="script-viewer-header"><span>' + j.script + '</span></div><pre>' + highlightRuby(cronScriptCache[j.script]) + '</pre></div>';
349
+ } else {
350
+ task = '<span class="prompt-text" onclick="event.stopPropagation();viewScript(\'' + id + '\',\'' + j.script.replace(/'/g,"\\'") + '\')" title="Click to view source"><code>' + scriptName + '</code></span>';
351
+ }
352
+ } else {
353
+ const prompt = j.prompt || '';
354
+ const rbMatch = prompt.match(/(\/\S+\.rb)\b/);
355
+ if (rbMatch) {
356
+ const scriptPath = rbMatch[1];
357
+ const scriptName = scriptPath.split('/').pop();
358
+ if (expanded && cronScriptCache[scriptPath]) {
359
+ task = '<span class="prompt-text" onclick="event.stopPropagation();togglePrompt(\'' + id + '\')" title="Click to collapse">' + prompt.slice(0,40).replace(/</g,'&lt;') + '…</span> ' +
360
+ '<span class="script-link" onclick="event.stopPropagation();viewScript(\'' + id + '\',\'' + scriptPath.replace(/'/g,"\\'") + '\')" title="View source">' + scriptName + ' ▾</span>' +
361
+ '<div class="script-viewer"><div class="script-viewer-header"><span>' + scriptPath + '</span></div><pre>' + highlightRuby(cronScriptCache[scriptPath]) + '</pre></div>';
362
+ } else if (expanded) {
363
+ task = '<span class="prompt-full" onclick="event.stopPropagation();togglePrompt(\'' + id + '\')" title="Click to collapse">' + prompt.replace(/</g,'&lt;') + '</span>';
364
+ } else {
365
+ task = '<span class="prompt-text" onclick="event.stopPropagation();togglePrompt(\'' + id + '\')" title="Click to expand">' + prompt.slice(0,40).replace(/</g,'&lt;') + '…</span> ' +
366
+ '<span class="script-link" onclick="event.stopPropagation();viewScript(\'' + id + '\',\'' + scriptPath.replace(/'/g,"\\'") + '\')" title="View source">' + scriptName + '</span>';
367
+ }
368
+ } else {
369
+ task = expanded
370
+ ? '<span class="prompt-full" onclick="event.stopPropagation();togglePrompt(\'' + id + '\')" title="Click to collapse">' + prompt.replace(/</g,'&lt;') + '</span>'
371
+ : '<span class="prompt-text" onclick="event.stopPropagation();togglePrompt(\'' + id + '\')" title="Click to expand">' + prompt.slice(0,60).replace(/</g,'&lt;') + (prompt.length > 60 ? '…' : '') + '</span>';
372
+ }
373
+ }
374
+ const logs = allLogs[i] || [];
375
+ let runsHtml = '<div class="cron-runs">';
376
+ if (logs.length === 0) {
377
+ runsHtml += '<span style="color:var(--dim)">none</span>';
378
+ } else {
379
+ logs.slice(0, 3).forEach(l => {
380
+ const fname = l.file.split('/').pop();
381
+ runsHtml += '<div><a onclick="selectSession(\'' + l.file.replace(/'/g,"\\'") + "','" + (j.agent||'cron').replace(/'/g,"\\'") + '\')">' + fname.replace(/^(agent-cron|cron-script)-/, '').replace('.log','') + '</a><span class="run-time">' + timeAgo(l.modified) + '</span></div>';
382
+ });
383
+ if (logs.length > 3) runsHtml += '<div style="color:var(--dim)">+' + (logs.length - 3) + ' more</div>';
384
+ }
385
+ runsHtml += '</div>';
386
+ html += '<tr><td class="job-id">' + id + '</td><td><code>' + j.schedule + '</code></td><td>' + agent + '</td><td>' + (j.project||'—') + '</td><td class="task-cell">' + task + '</td><td>' + runsHtml + '</td><td>' + badge + ' <button class="cron-toggle" onclick="toggleCron(\'' + id + '\',' + !j.enabled + ')">' + toggleLabel + '</button></td></tr>';
387
+ });
388
+ html += '</table>';
389
+ el.innerHTML = html;
390
+ } catch(e) {}
391
+ }
392
+
393
+ async function pollDeploys() {
394
+ try {
395
+ const r = await fetch(apiUrl('/api/deployments'));
396
+ const data = await r.json();
397
+ const el = document.getElementById('deployments');
398
+ const deps = data.deployments || [];
399
+ if (deps.length === 0) {
400
+ el.innerHTML = '<div style="color:var(--dim);font-size:13px;">No environments configured</div>';
401
+ return;
402
+ }
403
+ el.innerHTML = deps.map(d => {
404
+ const deploying = d.last_deploy_status === 'deploying';
405
+ const failed = d.last_deploy_status === 'failed';
406
+ const cls = failed ? 'failed' : deploying ? 'deploying' : d.status === 'occupied' ? 'occupied' : 'available';
407
+ const icon = failed ? '💥' : deploying ? '🚀' : d.status === 'occupied' ? '🔴' : '🟢';
408
+ let info = '';
409
+ if (d.status === 'occupied') {
410
+ const card = d.card_number ? '<a href="https://fizzy.do/cards/' + d.card_number + '" target="_blank">#' + d.card_number + '</a>' : 'unknown';
411
+ const branch = d.branch ? ' — <code>' + d.branch.replace(/</g,'&lt;') + '</code>' : '';
412
+ const pr = d.pr_url ? ' · <a href="' + d.pr_url + '" target="_blank">PR #' + d.pr_number + '</a>' : '';
413
+ info = card + branch + pr;
414
+ } else {
415
+ const last = d.last_card ? ' (was #' + d.last_card + ')' : '';
416
+ info = 'Available' + last;
417
+ }
418
+ const meta = d.deployed_at ? timeAgo(d.deployed_at) + (d.deployed_by ? ' by ' + d.deployed_by : '') : '';
419
+ const url = d.url ? '<a href="' + d.url + '" target="_blank" style="font-size:11px;color:var(--dim);">↗ open</a>' : '';
420
+ return '<div class="deploy-card ' + cls + '">' +
421
+ '<div class="env-label"><span>' + (d.label || d.env) + '</span><span class="status-icon">' + icon + ' ' + url + '</span></div>' +
422
+ '<div class="deploy-info">' + info + '</div>' +
423
+ (meta ? '<div class="deploy-meta">' + meta + '</div>' : '') +
424
+ '</div>';
425
+ }).join('');
426
+ } catch(e) {}
427
+ }
428
+
429
+ poll();
430
+ pollDeploys();
431
+ pollCron();
432
+ setInterval(poll, 5000);
433
+ setInterval(pollDeploys, 10000);
434
+ setInterval(pollCron, 30000);
435
+ </script>
436
+ </body>
437
+ </html>