lemming-cli 0.1.0__py3-none-any.whl

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 (94) hide show
  1. lemming/__init__.py +1 -0
  2. lemming/api/__init__.py +5 -0
  3. lemming/api/auth.py +34 -0
  4. lemming/api/auth_test.py +40 -0
  5. lemming/api/config.py +85 -0
  6. lemming/api/config_test.py +84 -0
  7. lemming/api/conftest.py +204 -0
  8. lemming/api/context.py +39 -0
  9. lemming/api/context_test.py +62 -0
  10. lemming/api/directories.py +57 -0
  11. lemming/api/directories_test.py +94 -0
  12. lemming/api/files.py +116 -0
  13. lemming/api/files_test.py +163 -0
  14. lemming/api/hooks.py +15 -0
  15. lemming/api/hooks_test.py +33 -0
  16. lemming/api/logging.py +16 -0
  17. lemming/api/logging_test.py +59 -0
  18. lemming/api/loop.py +45 -0
  19. lemming/api/loop_test.py +58 -0
  20. lemming/api/main.py +53 -0
  21. lemming/api/main_test.py +30 -0
  22. lemming/api/tasks.py +170 -0
  23. lemming/api/tasks_test.py +426 -0
  24. lemming/api.py +5 -0
  25. lemming/api_test.py +7 -0
  26. lemming/cli/__init__.py +12 -0
  27. lemming/cli/config.py +67 -0
  28. lemming/cli/config_test.py +50 -0
  29. lemming/cli/context.py +40 -0
  30. lemming/cli/context_test.py +49 -0
  31. lemming/cli/hooks.py +147 -0
  32. lemming/cli/hooks_test.py +50 -0
  33. lemming/cli/main.py +42 -0
  34. lemming/cli/main_test.py +21 -0
  35. lemming/cli/operations.py +226 -0
  36. lemming/cli/operations_test.py +44 -0
  37. lemming/cli/progress.py +54 -0
  38. lemming/cli/progress_test.py +40 -0
  39. lemming/cli/readability_cli.py +28 -0
  40. lemming/cli/readability_cli_test.py +57 -0
  41. lemming/cli/tasks.py +529 -0
  42. lemming/cli/tasks_test.py +168 -0
  43. lemming/cli.py +5 -0
  44. lemming/cli_test.py +22 -0
  45. lemming/conftest.py +13 -0
  46. lemming/hooks.py +145 -0
  47. lemming/hooks_test.py +180 -0
  48. lemming/integration_test.py +299 -0
  49. lemming/main.py +6 -0
  50. lemming/main_test.py +44 -0
  51. lemming/models.py +88 -0
  52. lemming/models_test.py +91 -0
  53. lemming/orchestrator.py +407 -0
  54. lemming/orchestrator_test.py +468 -0
  55. lemming/paths.py +245 -0
  56. lemming/paths_test.py +186 -0
  57. lemming/persistence.py +179 -0
  58. lemming/persistence_test.py +150 -0
  59. lemming/prompts/hooks/readability.md +42 -0
  60. lemming/prompts/hooks/roadmap.md +61 -0
  61. lemming/prompts/hooks/testing.md +44 -0
  62. lemming/prompts/taskrunner.md +69 -0
  63. lemming/prompts.py +354 -0
  64. lemming/prompts_test.py +421 -0
  65. lemming/providers.py +190 -0
  66. lemming/providers_test.py +73 -0
  67. lemming/runner.py +327 -0
  68. lemming/runner_test.py +378 -0
  69. lemming/tasks/__init__.py +75 -0
  70. lemming/tasks/lifecycle.py +331 -0
  71. lemming/tasks/lifecycle_test.py +312 -0
  72. lemming/tasks/operations.py +258 -0
  73. lemming/tasks/operations_test.py +172 -0
  74. lemming/tasks/progress.py +29 -0
  75. lemming/tasks/progress_test.py +22 -0
  76. lemming/tasks/queries.py +128 -0
  77. lemming/tasks/queries_test.py +233 -0
  78. lemming/web/dashboard.spec.js +350 -0
  79. lemming/web/dashboard.test.js +998 -0
  80. lemming/web/favicon.js +40 -0
  81. lemming/web/favicon.spec.js +242 -0
  82. lemming/web/files.html +375 -0
  83. lemming/web/files.spec.js +97 -0
  84. lemming/web/index.html +983 -0
  85. lemming/web/index.js +753 -0
  86. lemming/web/logs.html +358 -0
  87. lemming/web/logs.test.js +195 -0
  88. lemming/web/mancha.js +58 -0
  89. lemming/web/screenshots.spec.js +328 -0
  90. lemming_cli-0.1.0.dist-info/METADATA +314 -0
  91. lemming_cli-0.1.0.dist-info/RECORD +94 -0
  92. lemming_cli-0.1.0.dist-info/WHEEL +4 -0
  93. lemming_cli-0.1.0.dist-info/entry_points.txt +2 -0
  94. lemming_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
lemming/web/index.js ADDED
@@ -0,0 +1,753 @@
1
+ (async () => {
2
+ // Mancha.initMancha is the robust way to initialize.
3
+ window.ManchaApp = Mancha.initMancha({
4
+ cloak: true,
5
+ callback: async (renderer) => {
6
+ const { $ } = renderer;
7
+
8
+ // --- Project Scoping ---
9
+ // $$project is auto-synced with the ?project= URL query param by mancha.
10
+ $.$$project = $.$$project ?? '';
11
+
12
+ // Helper: build a URL with the project query param.
13
+ function apiUrl(path, extraParams = {}) {
14
+ const params = new URLSearchParams(extraParams);
15
+ if ($.$$project) params.set('project', $.$$project);
16
+ const qs = params.toString();
17
+ return qs ? `${path}?${qs}` : path;
18
+ }
19
+
20
+ // --- Persistence Management (scoped by project) ---
21
+ const Storage = {
22
+ getPrefix() {
23
+ return `lemming[${$.$$project}]_`;
24
+ },
25
+ get(key, fallback) {
26
+ try {
27
+ const val = localStorage.getItem(this.getPrefix() + key);
28
+ return val !== null ? JSON.parse(val) : fallback;
29
+ } catch {
30
+ return fallback;
31
+ }
32
+ },
33
+ set(key, val) {
34
+ localStorage.setItem(this.getPrefix() + key, JSON.stringify(val));
35
+ },
36
+ };
37
+
38
+ // --- Initial State ---
39
+ $.tasks = [];
40
+ $.context = '';
41
+ $.config = {
42
+ retries: 3,
43
+ runner: 'agy',
44
+ hooks: ['roadmap'],
45
+ };
46
+ $.cwd = '';
47
+ $.newTask = '';
48
+ $.loading = true;
49
+ $.runners = [];
50
+ $.availableHooks = [];
51
+ $.selectedRunner = 'agy';
52
+ $.retries = 3;
53
+ $.timeLimit = '60';
54
+ $.envOverrides = []; // Will hydrate below
55
+ $.hideCompleted = false; // Hydrate after mount
56
+ $.toasts = [];
57
+ $.expanded = {};
58
+ $.loopRunning = false;
59
+ $.editingTask = null;
60
+ $.editFormData = { description: '', parent: '' };
61
+
62
+ // --- Favicon Status ---
63
+ $.faviconState = 'idle';
64
+ $.lastSeenState = null; // Hydrate after mount
65
+ // --- Folder Picker State ---
66
+ $.folderPickerPath = '';
67
+ $.folderPickerDirs = [];
68
+ $.folderPickerLoading = false;
69
+ $.showNewFolderInput = false;
70
+ $.newFolderName = '';
71
+
72
+ // --- Computed Properties ---
73
+ $.runningCount = $.$computed(
74
+ ($) => $.tasks.filter((t) => t.status === 'in_progress').length,
75
+ );
76
+ $.pendingCount = $.$computed(
77
+ ($) => $.tasks.filter((t) => t.status === 'pending').length,
78
+ );
79
+ $.completedCount = $.$computed(
80
+ ($) => $.tasks.filter((t) => t.status === 'completed').length,
81
+ );
82
+ $.failedCount = $.$computed(
83
+ ($) =>
84
+ $.tasks.filter(
85
+ (t) => t.status === 'failed' || t.status === 'cancelled',
86
+ ).length,
87
+ );
88
+
89
+ $.filteredTasks = $.$computed(($) => {
90
+ const ts = [...$.tasks];
91
+ // Sort in frontend to ensure consistent order:
92
+ // 1. Uncompleted tasks (pending, in_progress) first.
93
+ // 2. Completed tasks (completed, failed) at the bottom.
94
+ ts.sort((a, b) => {
95
+ const aDone =
96
+ a.status === 'completed' ||
97
+ a.status === 'failed' ||
98
+ a.status === 'cancelled'
99
+ ? 1
100
+ : 0;
101
+ const bDone =
102
+ b.status === 'completed' ||
103
+ b.status === 'failed' ||
104
+ b.status === 'cancelled'
105
+ ? 1
106
+ : 0;
107
+ if (aDone !== bDone) return aDone - bDone;
108
+
109
+ if (!aDone) {
110
+ // Uncompleted tasks: prioritize in_progress first, then FIFO (index)
111
+ const aInProgress = a.status === 'in_progress' ? 0 : 1;
112
+ const bInProgress = b.status === 'in_progress' ? 0 : 1;
113
+ if (aInProgress !== bInProgress) return aInProgress - bInProgress;
114
+
115
+ if (a.index !== b.index) return (a.index || 0) - (b.index || 0);
116
+ return (a.created_at || 0) - (b.created_at || 0);
117
+ }
118
+
119
+ // Completed tasks: newest first (reverse chronological by completion/creation time).
120
+ const aTime = a.completed_at || a.created_at || 0;
121
+ const bTime = b.completed_at || b.created_at || 0;
122
+ if (aTime !== bTime) return bTime - aTime;
123
+
124
+ return (b.index || 0) - (a.index || 0);
125
+ });
126
+ return ts.filter(
127
+ (t) =>
128
+ (t.status !== 'completed' &&
129
+ t.status !== 'failed' &&
130
+ t.status !== 'cancelled') ||
131
+ !$.hideCompleted,
132
+ );
133
+ });
134
+
135
+ // --- Utilities ---
136
+ $.trim = (s, l = 60) =>
137
+ s && s.length > l ? `${s.substring(0, l - 3)}...` : s;
138
+ $.formatDate = (ts) => (ts ? new Date(ts * 1000).toLocaleString() : '');
139
+ $.formatDuration = (seconds) => {
140
+ if (!seconds) return '0s';
141
+ if (seconds < 60) return `${Math.floor(seconds)}s`;
142
+ const minutes = Math.floor(seconds / 60);
143
+ const remainingSeconds = Math.floor(seconds % 60);
144
+ return `${minutes}m ${remainingSeconds}s`;
145
+ };
146
+ $.formatTaskRunTime = (task) => {
147
+ let total = task.run_time || 0;
148
+ if (task.status === 'in_progress' && task.last_started_at) {
149
+ total += Date.now() / 1000 - task.last_started_at;
150
+ }
151
+ return $.formatDuration(total);
152
+ };
153
+
154
+ $.getParent = (parentId) => {
155
+ return $.tasks.find((t) => t.id === parentId);
156
+ };
157
+
158
+ $.copyToClipboard = function (text) {
159
+ if (!navigator.clipboard) {
160
+ const el = document.createElement('textarea');
161
+ el.value = text;
162
+ document.body.appendChild(el);
163
+ el.select();
164
+ document.execCommand('copy');
165
+ document.body.removeChild(el);
166
+ } else {
167
+ navigator.clipboard.writeText(text);
168
+ }
169
+ this.addToast('Copied to clipboard', 'info');
170
+ };
171
+
172
+ // --- UI Feedback ---
173
+ $.addToast = function (message, type = 'info') {
174
+ const id = Date.now() + Math.random();
175
+ this.toasts.push({ id, message: this.trim(message, 120), type });
176
+ setTimeout(() => {
177
+ this.toasts = this.toasts.filter((t) => t.id !== id);
178
+ }, 5000);
179
+ };
180
+
181
+ // --- Data Actions ---
182
+ $.notifyChanges = (oldTasks, newTasks) => {
183
+ if ($.loading || !oldTasks.length) return;
184
+ const oldTaskMap = new Map(oldTasks.map((t) => [t.id, t]));
185
+
186
+ for (const newTask of newTasks) {
187
+ const oldTask = oldTaskMap.get(newTask.id);
188
+ if (!oldTask) continue;
189
+
190
+ if (
191
+ oldTask.status !== 'completed' &&
192
+ newTask.status === 'completed'
193
+ ) {
194
+ $.addToast(
195
+ `Task completed: ${$.trim(newTask.description, 60)}`,
196
+ 'success',
197
+ );
198
+ } else if (
199
+ oldTask.status !== 'failed' &&
200
+ newTask.status === 'failed'
201
+ ) {
202
+ $.addToast(
203
+ `Terminal failure: ${$.trim(newTask.description, 60)}`,
204
+ 'error',
205
+ );
206
+ } else if (
207
+ oldTask.status !== 'cancelled' &&
208
+ newTask.status === 'cancelled'
209
+ ) {
210
+ $.addToast(
211
+ `Task cancelled: ${$.trim(newTask.description, 60)}`,
212
+ 'info',
213
+ );
214
+ } else if (
215
+ oldTask.status === 'in_progress' &&
216
+ newTask.status === 'pending'
217
+ ) {
218
+ $.addToast(
219
+ `Attempt failed (retry pending): ${$.trim(newTask.description, 60)}`,
220
+ 'error',
221
+ );
222
+ } else if (
223
+ (newTask.progress?.length || 0) > (oldTask.progress?.length || 0)
224
+ ) {
225
+ $.addToast(
226
+ `Progress recorded: ${$.trim(newTask.progress[newTask.progress.length - 1], 60)}`,
227
+ 'info',
228
+ );
229
+ } else if (newTask.attempts > oldTask.attempts) {
230
+ $.addToast(
231
+ `Task attempt ${newTask.attempts}: ${$.trim(newTask.description, 60)}`,
232
+ 'info',
233
+ );
234
+ }
235
+ }
236
+ };
237
+
238
+ $.updateTitle = () => {
239
+ const project = $.$$project;
240
+ let folderName = '';
241
+ if (project) {
242
+ folderName = project.split('/').filter(Boolean)[0];
243
+ } else if ($.cwd) {
244
+ folderName = $.cwd.split('/').filter(Boolean).pop();
245
+ }
246
+ document.title = folderName ? `Lemming · ${folderName}` : 'Lemming';
247
+ };
248
+
249
+ $.updateFaviconStatus = () => {
250
+ if (!window.updateFavicon) return;
251
+ const hasError = $.tasks.some(
252
+ (t) =>
253
+ (t.status === 'pending' && t.attempts > 0) ||
254
+ t.status === 'cancelled',
255
+ );
256
+ const allCompleted =
257
+ $.tasks.length > 0 && $.tasks.every((t) => t.status === 'completed');
258
+ const state = $.loopRunning
259
+ ? 'running'
260
+ : hasError
261
+ ? 'error'
262
+ : allCompleted
263
+ ? 'success'
264
+ : 'idle';
265
+
266
+ $.faviconState = state;
267
+ if (state === 'running') {
268
+ $.lastSeenState = null;
269
+ Storage.set('last_seen_state', null);
270
+ }
271
+
272
+ const effectiveState =
273
+ (state === 'success' || state === 'error') &&
274
+ state === $.lastSeenState
275
+ ? 'idle'
276
+ : state;
277
+ window.updateFavicon(effectiveState);
278
+ };
279
+
280
+ $.fetchData = async () => {
281
+ const response = await fetch(apiUrl('/api/data'));
282
+ if (!response.ok) return;
283
+ const data = await response.json();
284
+ const newTasks = data.tasks || [];
285
+
286
+ // Guard against transient empty responses from the server
287
+ // (e.g. reading the YAML file mid-write). Skip the update entirely
288
+ // so the UI doesn't flash empty and fire rogue notifications.
289
+ if (!newTasks.length && $.tasks.length) return;
290
+
291
+ $.notifyChanges($.tasks, newTasks);
292
+
293
+ // Update core state
294
+ $.cwd = data.cwd || '';
295
+ $.loopRunning = data.loop_running || false;
296
+ $.tasks = newTasks;
297
+
298
+ // Sync config from server
299
+ if (data.config) {
300
+ $.config = data.config;
301
+
302
+ const runnerElem = document.querySelector(
303
+ 'input[aria-label="Runner command"]',
304
+ );
305
+ if (
306
+ $.loading ||
307
+ !runnerElem ||
308
+ document.activeElement !== runnerElem
309
+ ) {
310
+ $.selectedRunner = data.config.runner;
311
+ }
312
+
313
+ const retriesElem = document.querySelector(
314
+ 'input[aria-label="Retries"]',
315
+ );
316
+ if (
317
+ $.loading ||
318
+ !retriesElem ||
319
+ document.activeElement !== retriesElem
320
+ ) {
321
+ $.retries = data.config.retries;
322
+ }
323
+
324
+ const timeLimitElem = document.querySelector(
325
+ 'select[aria-label="Time limit"]',
326
+ );
327
+ if (
328
+ $.loading ||
329
+ !timeLimitElem ||
330
+ document.activeElement !== timeLimitElem
331
+ ) {
332
+ $.timeLimit = String(data.config.time_limit || 0);
333
+ }
334
+ }
335
+
336
+ $.updateTitle();
337
+ $.updateFaviconStatus();
338
+
339
+ const contextElem = document.querySelector('textarea');
340
+ if (
341
+ $.loading ||
342
+ (contextElem && document.activeElement !== contextElem)
343
+ ) {
344
+ $.context = data.context || '';
345
+ }
346
+ $.loading = false;
347
+ };
348
+
349
+ $.fetchRunners = async () => {
350
+ const response = await fetch(apiUrl('/api/runners'));
351
+ if (response.ok) {
352
+ $.runners = await response.json();
353
+ }
354
+ };
355
+
356
+ $.fetchHooks = async () => {
357
+ const response = await fetch(apiUrl('/api/hooks'));
358
+ if (response.ok) {
359
+ $.availableHooks = await response.json();
360
+ }
361
+ };
362
+
363
+ $.saveConfigToServer = async () => {
364
+ const config = {
365
+ retries: Number.parseInt($.retries, 10) || 3,
366
+ runner: $.selectedRunner,
367
+ hooks: $.config.hooks,
368
+ time_limit: Number.parseInt($.timeLimit, 10) || 0,
369
+ };
370
+ await fetch(apiUrl('/api/config'), {
371
+ method: 'POST',
372
+ headers: { 'Content-Type': 'application/json' },
373
+ body: JSON.stringify(config),
374
+ });
375
+ };
376
+
377
+ $.saveRunnerPreference = () => {
378
+ $.saveConfigToServer();
379
+ };
380
+ $.saveRetriesPreference = () => {
381
+ $.saveConfigToServer();
382
+ };
383
+ $.saveTimeLimitPreference = () => {
384
+ $.saveConfigToServer();
385
+ };
386
+ $.saveHideCompletedPreference = () => {
387
+ Storage.set('hide_completed', $.hideCompleted);
388
+ };
389
+ $.toggleHook = (name) => {
390
+ let hooks = $.config.hooks;
391
+ if (hooks === null || hooks === undefined) {
392
+ hooks = [...$.availableHooks];
393
+ }
394
+ if (hooks.includes(name)) {
395
+ $.config.hooks = hooks.filter((h) => h !== name);
396
+ } else {
397
+ $.config.hooks = [...hooks, name];
398
+ }
399
+ $.saveConfigToServer();
400
+ };
401
+ $.resetHooks = () => {
402
+ $.config.hooks = null;
403
+ $.saveConfigToServer();
404
+ };
405
+
406
+ let envSaveTimeout;
407
+ $.saveEnvOverrides = () => {
408
+ clearTimeout(envSaveTimeout);
409
+ envSaveTimeout = setTimeout(() => {
410
+ const toSave = $.envOverrides.map(({ key, value }) => ({
411
+ key,
412
+ value,
413
+ }));
414
+ Storage.set('env_overrides', toSave);
415
+ }, 300);
416
+ };
417
+
418
+ // --- Operations ---
419
+ $.addEnvOverride = () => {
420
+ const id =
421
+ typeof crypto !== 'undefined' && crypto.randomUUID
422
+ ? crypto.randomUUID()
423
+ : `env-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
424
+ $.envOverrides.push({ id, key: '', value: '' });
425
+ $.saveEnvOverrides();
426
+ };
427
+
428
+ $.removeEnvOverride = (index) => {
429
+ $.envOverrides.splice(index, 1);
430
+ $.saveEnvOverrides();
431
+ };
432
+
433
+ $.addTask = async () => {
434
+ if (!$.newTask.trim()) return;
435
+ const res = await fetch(apiUrl('/api/tasks'), {
436
+ method: 'POST',
437
+ headers: { 'Content-Type': 'application/json' },
438
+ body: JSON.stringify({ description: $.newTask }),
439
+ });
440
+ if (res.ok) {
441
+ $.newTask = '';
442
+ await $.fetchData();
443
+ }
444
+ };
445
+
446
+ $.taskActionTarget = null;
447
+ $.taskActionTargetStatus = null;
448
+ $.taskActionTargetHasAttempts = false;
449
+
450
+ $.openTaskActionModal = (id) => {
451
+ $.taskActionTarget = id;
452
+ const task = $.tasks.find((t) => t.id === id);
453
+ $.taskActionTargetStatus = task ? task.status : null;
454
+ $.taskActionTargetHasAttempts = task ? task.attempts > 0 : false;
455
+ const modal = document.getElementById('task-action-modal');
456
+ if (modal) modal.showModal();
457
+ };
458
+
459
+ $.closeTaskActionModal = () => {
460
+ const modal = document.getElementById('task-action-modal');
461
+ if (modal) modal.close();
462
+ $.taskActionTarget = null;
463
+ $.taskActionTargetStatus = null;
464
+ $.taskActionTargetHasAttempts = false;
465
+ };
466
+
467
+ $.editTaskFromModal = () => {
468
+ const task = $.tasks.find((t) => t.id === $.taskActionTarget);
469
+ $.closeTaskActionModal();
470
+ if (task) $.editTask(task);
471
+ };
472
+
473
+ $.clearTaskFromModal = () => {
474
+ const id = $.taskActionTarget;
475
+ $.closeTaskActionModal();
476
+ if (id) $.clearTask(id);
477
+ };
478
+
479
+ $.deleteTask = async (id) => {
480
+ const res = await fetch(apiUrl(`/api/tasks/${id}/delete`), {
481
+ method: 'POST',
482
+ });
483
+ if (res.ok) await $.fetchData();
484
+ };
485
+
486
+ $.confirmDeleteTask = async () => {
487
+ if (!$.taskActionTarget) return;
488
+ await $.deleteTask($.taskActionTarget);
489
+ $.closeTaskActionModal();
490
+ };
491
+
492
+ $.confirmCancelTask = async () => {
493
+ if (!$.taskActionTarget) return;
494
+ const id = $.taskActionTarget;
495
+ const res = await fetch(apiUrl(`/api/tasks/${id}/cancel`), {
496
+ method: 'POST',
497
+ });
498
+ if (res.ok) {
499
+ $.addToast('Execution cancelled', 'info');
500
+ await $.fetchData();
501
+ }
502
+ $.closeTaskActionModal();
503
+ };
504
+
505
+ $.confirmReopenTask = async () => {
506
+ if (!$.taskActionTarget) return;
507
+ const id = $.taskActionTarget;
508
+ const res = await fetch(apiUrl(`/api/tasks/${id}/update`), {
509
+ method: 'POST',
510
+ headers: { 'Content-Type': 'application/json' },
511
+ body: JSON.stringify({ status: 'pending' }),
512
+ });
513
+ if (res.ok) {
514
+ $.addToast('Task re-opened', 'info');
515
+ await $.fetchData();
516
+ }
517
+ $.closeTaskActionModal();
518
+ };
519
+
520
+ $.deleteCompletedTasks = async () => {
521
+ if (confirm('Delete ALL completed tasks?')) {
522
+ const res = await fetch(apiUrl('/api/tasks/delete-completed'), {
523
+ method: 'POST',
524
+ });
525
+ if (res.ok) {
526
+ $.addToast('Completed tasks deleted', 'success');
527
+ await $.fetchData();
528
+ }
529
+ }
530
+ };
531
+
532
+ $.cancelTask = async (id) => {
533
+ $.openTaskActionModal(id);
534
+ };
535
+
536
+ $.editTask = (task) => {
537
+ $.editingTask = task;
538
+ $.editFormData = {
539
+ description: task.description || '',
540
+ parent: task.parent || '',
541
+ };
542
+ const modal = document.getElementById('edit-modal');
543
+ if (modal) modal.showModal();
544
+ };
545
+
546
+ $.closeEditModal = () => {
547
+ const modal = document.getElementById('edit-modal');
548
+ if (modal) modal.close();
549
+ $.editingTask = null;
550
+ };
551
+
552
+ $.submitEditTask = async () => {
553
+ if (!$.editingTask) return;
554
+
555
+ const task = $.editingTask;
556
+ const update = {
557
+ description: $.editFormData.description.trim() || task.description,
558
+ parent: $.editFormData.parent.trim() || null,
559
+ };
560
+
561
+ const res = await fetch(apiUrl(`/api/tasks/${task.id}/update`), {
562
+ method: 'POST',
563
+ headers: { 'Content-Type': 'application/json' },
564
+ body: JSON.stringify(update),
565
+ });
566
+ if (res.ok) {
567
+ $.addToast('Task updated', 'success');
568
+ await $.fetchData();
569
+ }
570
+
571
+ $.closeEditModal();
572
+ };
573
+
574
+ $.uncompleteTask = async (id) => {
575
+ const res = await fetch(apiUrl(`/api/tasks/${id}/update`), {
576
+ method: 'POST',
577
+ headers: { 'Content-Type': 'application/json' },
578
+ body: JSON.stringify({ status: 'pending' }),
579
+ });
580
+ if (res.ok) {
581
+ $.addToast('Task reset to pending', 'info');
582
+ await $.fetchData();
583
+ }
584
+ };
585
+
586
+ $.clearTask = async (id) => {
587
+ if (confirm('Clear task attempts and progress?')) {
588
+ const res = await fetch(apiUrl(`/api/tasks/${id}/clear`), {
589
+ method: 'POST',
590
+ });
591
+ if (res.ok) {
592
+ $.addToast('Task cleared', 'success');
593
+ await $.fetchData();
594
+ }
595
+ }
596
+ };
597
+
598
+ $.ctxSaveTimeout = null;
599
+ $.updateContext = () => {
600
+ clearTimeout($.ctxSaveTimeout);
601
+ $.ctxSaveTimeout = setTimeout(async () => {
602
+ const res = await fetch(apiUrl('/api/context'), {
603
+ method: 'POST',
604
+ headers: { 'Content-Type': 'application/json' },
605
+ body: JSON.stringify({ context: $.context }),
606
+ });
607
+ if (res.ok) $.addToast('Context saved', 'info');
608
+ }, 1000);
609
+ };
610
+
611
+ $.runLemming = async () => {
612
+ const env = {};
613
+ for (const o of $.envOverrides) {
614
+ if (o.key?.trim()) env[o.key.trim()] = o.value || '';
615
+ }
616
+
617
+ const payload = {
618
+ env: Object.keys(env).length > 0 ? env : undefined,
619
+ };
620
+
621
+ const res = await fetch(apiUrl('/api/run'), {
622
+ method: 'POST',
623
+ headers: { 'Content-Type': 'application/json' },
624
+ body: JSON.stringify(payload),
625
+ });
626
+ if (res.ok) {
627
+ $.addToast('Run started!', 'success');
628
+ await $.fetchData();
629
+ }
630
+ };
631
+
632
+ // --- Folder Picker ---
633
+ $.openFolderPicker = async () => {
634
+ $.folderPickerPath = '';
635
+ $.showNewFolderInput = false;
636
+ $.newFolderName = '';
637
+ await $.fetchFolderPickerDirs('');
638
+ const modal = document.getElementById('folder-picker-modal');
639
+ if (modal) modal.showModal();
640
+ };
641
+
642
+ $.closeFolderPicker = () => {
643
+ const modal = document.getElementById('folder-picker-modal');
644
+ if (modal) modal.close();
645
+ };
646
+
647
+ $.startNewFolder = () => {
648
+ $.showNewFolderInput = true;
649
+ $.newFolderName = '';
650
+ };
651
+
652
+ $.fetchFolderPickerDirs = async (path) => {
653
+ $.folderPickerLoading = true;
654
+ const params = new URLSearchParams(path ? { path } : {});
655
+ const res = await fetch(`/api/directories?${params.toString()}`);
656
+ if (res.ok) {
657
+ const data = await res.json();
658
+ $.folderPickerPath = data.path;
659
+ $.folderPickerDirs = data.directories;
660
+ }
661
+ $.folderPickerLoading = false;
662
+ };
663
+
664
+ $.createFolder = async () => {
665
+ if (!$.newFolderName) return;
666
+ const res = await fetch('/api/directories', {
667
+ method: 'POST',
668
+ headers: { 'Content-Type': 'application/json' },
669
+ body: JSON.stringify({
670
+ path: $.folderPickerPath,
671
+ name: $.newFolderName,
672
+ }),
673
+ });
674
+ if (res.ok) {
675
+ $.addToast('Folder created!', 'success');
676
+ $.showNewFolderInput = false;
677
+ $.newFolderName = '';
678
+ await $.fetchFolderPickerDirs($.folderPickerPath);
679
+ } else {
680
+ const err = await res.json();
681
+ $.addToast(err.detail || 'Failed to create folder', 'error');
682
+ }
683
+ };
684
+ $.folderPickerNavigate = async (path) => {
685
+ await $.fetchFolderPickerDirs(path);
686
+ };
687
+
688
+ $.folderPickerUp = async () => {
689
+ const parts = $.folderPickerPath.split('/').filter(Boolean);
690
+ parts.pop();
691
+ await $.fetchFolderPickerDirs(parts.join('/'));
692
+ };
693
+
694
+ $.folderPickerSelect = (path) => {
695
+ // Navigate to the same page with the new project param.
696
+ const url = new URL(window.location.href);
697
+ if (path) {
698
+ url.searchParams.set('project', path);
699
+ } else {
700
+ url.searchParams.delete('project');
701
+ }
702
+ window.open(url.toString(), '_blank');
703
+ $.closeFolderPicker();
704
+ };
705
+
706
+ $.folderPickerBreadcrumbs = $.$computed(($) => {
707
+ const parts = $.folderPickerPath.split('/').filter(Boolean);
708
+ const crumbs = [{ name: 'root', path: '' }];
709
+ for (let i = 0; i < parts.length; i++) {
710
+ crumbs.push({
711
+ name: parts[i],
712
+ path: parts.slice(0, i + 1).join('/'),
713
+ });
714
+ }
715
+ return crumbs;
716
+ });
717
+
718
+ // --- Mount to DOM (syncs $$project from URL) ---
719
+ await renderer.mount(document.body);
720
+
721
+ // --- Final Hydration from Storage (Post-Mount) ---
722
+ $.hideCompleted = Storage.get('hide_completed', false);
723
+ $.lastSeenState = Storage.get('last_seen_state', null);
724
+ const loadedOverrides = Storage.get('env_overrides', []);
725
+ if (loadedOverrides.length > 0) {
726
+ $.envOverrides = loadedOverrides.map((o, i) => ({
727
+ ...o,
728
+ id: o.id || `env-${i}`,
729
+ }));
730
+ }
731
+
732
+ // --- Initial Data Fetch (after mount so $$project is available) ---
733
+ await Promise.all([$.fetchData(), $.fetchRunners(), $.fetchHooks()]);
734
+
735
+ // --- Auto-refresh via polling ---
736
+ document.addEventListener('visibilitychange', () => {
737
+ if (document.visibilityState === 'visible') {
738
+ // Force an immediate fetch when returning to the tab.
739
+ $.fetchData();
740
+
741
+ const state = $.faviconState;
742
+ if (state === 'success' || state === 'error') {
743
+ $.lastSeenState = state;
744
+ Storage.set('last_seen_state', state);
745
+ if (window.updateFavicon) window.updateFavicon('idle');
746
+ }
747
+ }
748
+ });
749
+
750
+ setInterval(() => $.fetchData(), 1000);
751
+ },
752
+ });
753
+ })();