neoagent 3.0.1-beta.9 → 3.1.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 (97) hide show
  1. package/.env.example +2 -10
  2. package/README.md +12 -3
  3. package/docs/automation.md +37 -0
  4. package/docs/benchmarking.md +102 -0
  5. package/docs/billing.md +34 -8
  6. package/docs/configuration.md +11 -2
  7. package/docs/getting-started.md +10 -8
  8. package/docs/operations.md +1 -1
  9. package/flutter_app/lib/main.dart +3 -0
  10. package/flutter_app/lib/main_account_settings.dart +138 -0
  11. package/flutter_app/lib/main_app_shell.dart +38 -13
  12. package/flutter_app/lib/main_billing.dart +1465 -0
  13. package/flutter_app/lib/main_chat.dart +1612 -214
  14. package/flutter_app/lib/main_controller.dart +398 -26
  15. package/flutter_app/lib/main_devices.dart +293 -207
  16. package/flutter_app/lib/main_models.dart +142 -0
  17. package/flutter_app/lib/main_navigation.dart +19 -1
  18. package/flutter_app/lib/main_operations.dart +288 -9
  19. package/flutter_app/lib/main_settings.dart +510 -300
  20. package/flutter_app/lib/main_shared.dart +2 -0
  21. package/flutter_app/lib/main_timeline.dart +1378 -0
  22. package/flutter_app/lib/src/backend_client.dart +149 -19
  23. package/flutter_app/lib/src/desktop_companion_actions.dart +132 -21
  24. package/flutter_app/lib/src/desktop_companion_io.dart +65 -1
  25. package/flutter_app/lib/src/desktop_companion_stub.dart +12 -0
  26. package/flutter_app/lib/src/desktop_ocr_bridge.dart +2 -0
  27. package/flutter_app/lib/src/desktop_ocr_bridge_io.dart +125 -0
  28. package/flutter_app/lib/src/desktop_ocr_bridge_stub.dart +30 -0
  29. package/flutter_app/lib/src/desktop_passive_history.dart +332 -0
  30. package/flutter_app/lib/src/recording_bridge_io.dart +80 -72
  31. package/flutter_app/lib/src/recording_bridge_web.dart +127 -114
  32. package/flutter_app/lib/src/recording_chunk_queue.dart +149 -0
  33. package/flutter_app/lib/src/recording_chunk_queue_io.dart +182 -0
  34. package/flutter_app/lib/src/recording_payloads.dart +9 -0
  35. package/flutter_app/macos/Runner/AppDelegate.swift +25 -0
  36. package/flutter_app/windows/runner/flutter_window.cpp +75 -0
  37. package/landing/index.html +3 -3
  38. package/lib/manager.js +184 -66
  39. package/package.json +4 -1
  40. package/server/admin/access.js +12 -7
  41. package/server/admin/admin.js +436 -10
  42. package/server/admin/billing.js +158 -35
  43. package/server/admin/index.html +72 -2
  44. package/server/admin/users.js +15 -15
  45. package/server/db/database.js +125 -20
  46. package/server/http/routes.js +1 -0
  47. package/server/http/static.js +4 -2
  48. package/server/public/.last_build_id +1 -1
  49. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  50. package/server/public/canvaskit/wimp.js.symbols +8475 -8467
  51. package/server/public/canvaskit/wimp.wasm +0 -0
  52. package/server/public/flutter_bootstrap.js +2 -2
  53. package/server/public/main.dart.js +91077 -87037
  54. package/server/routes/account.js +53 -0
  55. package/server/routes/admin.js +345 -64
  56. package/server/routes/agents.js +203 -21
  57. package/server/routes/billing.js +5 -0
  58. package/server/routes/browser.js +8 -1
  59. package/server/routes/recordings.js +96 -6
  60. package/server/routes/screenHistory.js +140 -2
  61. package/server/routes/timeline.js +43 -0
  62. package/server/services/account/erasure.js +263 -0
  63. package/server/services/ai/hooks.js +4 -1
  64. package/server/services/ai/loop/agent_engine_core.js +8 -1
  65. package/server/services/ai/loop/blank_recovery.js +36 -0
  66. package/server/services/ai/loop/conversation_loop.js +166 -33
  67. package/server/services/ai/messagingFallback.js +22 -0
  68. package/server/services/ai/rate_limits.js +28 -5
  69. package/server/services/ai/systemPrompt.js +6 -5
  70. package/server/services/ai/taskAnalysis.js +2 -0
  71. package/server/services/ai/toolEvidence.js +15 -0
  72. package/server/services/ai/toolResult.js +40 -0
  73. package/server/services/ai/tools.js +163 -4
  74. package/server/services/android/controller.js +6 -2
  75. package/server/services/billing/plans.js +2 -1
  76. package/server/services/browser/anti_detection.js +192 -0
  77. package/server/services/browser/controller.js +180 -54
  78. package/server/services/desktop/auth.js +3 -0
  79. package/server/services/desktop/registry.js +50 -2
  80. package/server/services/integrations/google/calendar.js +22 -14
  81. package/server/services/manager.js +12 -42
  82. package/server/services/memory/ingestion_chunking.js +268 -0
  83. package/server/services/messaging/telnyx.js +9 -8
  84. package/server/services/recordings/manager.js +60 -27
  85. package/server/services/runtime/docker-vm-manager.js +157 -266
  86. package/server/services/runtime/guest_bootstrap.js +17 -5
  87. package/server/services/runtime/guest_image.js +188 -0
  88. package/server/services/runtime/manager.js +0 -1
  89. package/server/services/runtime/validation.js +3 -8
  90. package/server/services/social_video/service.js +60 -10
  91. package/server/services/tasks/runtime.js +234 -9
  92. package/server/services/tasks/task_repository.js +13 -0
  93. package/server/services/timeline/service.js +558 -0
  94. package/server/services/wearable/gateway.js +1 -1
  95. package/server/services/websocket.js +21 -3
  96. package/server/services/desktop/screenRecorder.js +0 -292
  97. package/server/services/desktop/screen_recorder_support.js +0 -46
@@ -2302,6 +2302,96 @@ class RunSummary {
2302
2302
  }
2303
2303
  }
2304
2304
 
2305
+ class TimelineEventItem {
2306
+ const TimelineEventItem({
2307
+ required this.id,
2308
+ required this.sourceKind,
2309
+ required this.eventKind,
2310
+ required this.occurredAt,
2311
+ required this.title,
2312
+ required this.summary,
2313
+ required this.metadata,
2314
+ this.agentId,
2315
+ this.sourceId,
2316
+ });
2317
+
2318
+ factory TimelineEventItem.fromJson(Map<dynamic, dynamic> json) {
2319
+ return TimelineEventItem(
2320
+ id: _asInt(json['id']),
2321
+ sourceKind: json['sourceKind']?.toString() ?? '',
2322
+ eventKind: json['eventKind']?.toString() ?? '',
2323
+ occurredAt: _parseTimestamp(json['occurredAt']?.toString()),
2324
+ title: json['title']?.toString() ?? '',
2325
+ summary: json['summary']?.toString() ?? '',
2326
+ agentId: json['agentId']?.toString(),
2327
+ sourceId: json['sourceId']?.toString(),
2328
+ metadata: json['metadata'] is Map
2329
+ ? Map<String, dynamic>.from(json['metadata'] as Map)
2330
+ : const <String, dynamic>{},
2331
+ );
2332
+ }
2333
+
2334
+ final int id;
2335
+ final String sourceKind;
2336
+ final String eventKind;
2337
+ final DateTime occurredAt;
2338
+ final String title;
2339
+ final String summary;
2340
+ final String? agentId;
2341
+ final String? sourceId;
2342
+ final Map<String, dynamic> metadata;
2343
+
2344
+ String get occurredAtLabel => _formatTimestamp(occurredAt);
2345
+
2346
+ String get sourceLabel => switch (sourceKind) {
2347
+ 'screen' => 'Screen',
2348
+ 'tasks' => 'Task',
2349
+ 'runs' => 'Run',
2350
+ _ => _titleCase(sourceKind.replaceAll('_', ' ')),
2351
+ };
2352
+
2353
+ Color get sourceColor => switch (sourceKind) {
2354
+ 'screen' => _accent,
2355
+ 'tasks' => _warning,
2356
+ 'runs' => _success,
2357
+ _ => _textSecondary,
2358
+ };
2359
+
2360
+ String get appName => metadata['appName']?.toString() ?? '';
2361
+ String get windowTitle => metadata['windowTitle']?.toString() ?? '';
2362
+ String get deviceLabel =>
2363
+ metadata['deviceLabel']?.toString() ??
2364
+ metadata['deviceId']?.toString() ??
2365
+ '';
2366
+ String get previewText => metadata['previewText']?.toString() ?? summary;
2367
+ String get runId => metadata['runId']?.toString() ?? sourceId ?? '';
2368
+ String get taskName =>
2369
+ metadata['taskName']?.toString().trim().isNotEmpty == true
2370
+ ? metadata['taskName'].toString()
2371
+ : title;
2372
+ DateTime? get startedAt =>
2373
+ _parseOptionalTimestamp(metadata['startedAt']?.toString());
2374
+ DateTime? get endedAt =>
2375
+ _parseOptionalTimestamp(metadata['endedAt']?.toString());
2376
+
2377
+ String get screenSpanLabel {
2378
+ final start = startedAt;
2379
+ final end = endedAt;
2380
+ if (start == null) {
2381
+ return occurredAtLabel;
2382
+ }
2383
+ final localStart = start.toLocal();
2384
+ final startMinute = localStart.minute.toString().padLeft(2, '0');
2385
+ if (end == null) {
2386
+ return '${localStart.hour.toString().padLeft(2, '0')}:$startMinute';
2387
+ }
2388
+ final localEnd = end.toLocal();
2389
+ final endMinute = localEnd.minute.toString().padLeft(2, '0');
2390
+ return '${localStart.hour.toString().padLeft(2, '0')}:$startMinute'
2391
+ ' - ${localEnd.hour.toString().padLeft(2, '0')}:$endMinute';
2392
+ }
2393
+ }
2394
+
2305
2395
  class TokenUsageSnapshot {
2306
2396
  const TokenUsageSnapshot({
2307
2397
  required this.totalTokens,
@@ -3147,6 +3237,8 @@ class TaskItem {
3147
3237
  required this.triggerType,
3148
3238
  required this.triggerSummary,
3149
3239
  required this.triggerConfig,
3240
+ required this.taskConfig,
3241
+ required this.loopBudget,
3150
3242
  required this.nextRun,
3151
3243
  required this.prompt,
3152
3244
  required this.model,
@@ -3178,6 +3270,11 @@ class TaskItem {
3178
3270
  : const <String, dynamic>{}),
3179
3271
  };
3180
3272
  final triggerSummary = json['triggerSummary']?.toString() ?? '';
3273
+ final loopBudgetJson = json['loopBudget'] is Map
3274
+ ? Map<String, dynamic>.from(json['loopBudget'] as Map)
3275
+ : (taskConfig['loopBudget'] is Map
3276
+ ? Map<String, dynamic>.from(taskConfig['loopBudget'] as Map)
3277
+ : const <String, dynamic>{});
3181
3278
  return TaskItem(
3182
3279
  id: _asInt(json['id']),
3183
3280
  agentId: json['agentId']?.toString() ?? json['agent_id']?.toString(),
@@ -3187,6 +3284,8 @@ class TaskItem {
3187
3284
  ? 'Task trigger'
3188
3285
  : triggerSummary,
3189
3286
  triggerConfig: triggerConfig,
3287
+ taskConfig: taskConfig,
3288
+ loopBudget: TaskLoopBudget.fromJson(loopBudgetJson),
3190
3289
  nextRun: _parseOptionalTimestamp(json['nextRun']?.toString()),
3191
3290
  prompt:
3192
3291
  json['prompt']?.toString().ifEmpty(
@@ -3222,6 +3321,8 @@ class TaskItem {
3222
3321
  final String triggerType;
3223
3322
  final String triggerSummary;
3224
3323
  final Map<String, dynamic> triggerConfig;
3324
+ final Map<String, dynamic> taskConfig;
3325
+ final TaskLoopBudget loopBudget;
3225
3326
  final DateTime? nextRun;
3226
3327
  final String prompt;
3227
3328
  final String model;
@@ -3245,6 +3346,47 @@ class TaskItem {
3245
3346
  bool get isWidgetRefresh => taskType == 'widget_refresh';
3246
3347
  }
3247
3348
 
3349
+ class TaskLoopBudget {
3350
+ const TaskLoopBudget({
3351
+ required this.enabled,
3352
+ required this.paused,
3353
+ required this.maxRunsPerDay,
3354
+ required this.maxTokensPerDay,
3355
+ });
3356
+
3357
+ factory TaskLoopBudget.fromJson(Map<String, dynamic> json) {
3358
+ final maxRuns = _asInt(json['maxRunsPerDay'] ?? json['max_runs_per_day']);
3359
+ final maxTokens = _asInt(
3360
+ json['maxTokensPerDay'] ?? json['max_tokens_per_day'],
3361
+ );
3362
+ final enabled = json['enabled'] == false || json['enabled'] == 'false'
3363
+ ? false
3364
+ : true;
3365
+ return TaskLoopBudget(
3366
+ enabled: enabled,
3367
+ paused:
3368
+ json['paused'] == true ||
3369
+ json['pause'] == true ||
3370
+ json['paused'] == 'true' ||
3371
+ json['pause'] == 'true',
3372
+ maxRunsPerDay: maxRuns > 0 ? maxRuns : 24,
3373
+ maxTokensPerDay: maxTokens > 0 ? maxTokens : 250000,
3374
+ );
3375
+ }
3376
+
3377
+ final bool enabled;
3378
+ final bool paused;
3379
+ final int maxRunsPerDay;
3380
+ final int maxTokensPerDay;
3381
+
3382
+ Map<String, dynamic> toJson() => <String, dynamic>{
3383
+ 'enabled': enabled,
3384
+ 'paused': paused,
3385
+ 'maxRunsPerDay': maxRunsPerDay,
3386
+ 'maxTokensPerDay': maxTokensPerDay,
3387
+ };
3388
+ }
3389
+
3248
3390
  class WidgetSnapshotItem {
3249
3391
  const WidgetSnapshotItem({
3250
3392
  required this.id,
@@ -14,6 +14,7 @@ NeoAgentAppMode _appModeFromEnvironment() {
14
14
 
15
15
  enum AppSection {
16
16
  chat,
17
+ timeline,
17
18
  voiceAssistant,
18
19
  devices,
19
20
  recordings,
@@ -30,15 +31,18 @@ enum AppSection {
30
31
  mcp,
31
32
  health,
32
33
  server,
34
+ billing,
33
35
  }
34
36
 
35
- enum SidebarGroup { chat, recordings, automation, settings }
37
+ enum SidebarGroup { chat, timeline, recordings, automation, settings }
36
38
 
37
39
  extension SidebarGroupX on SidebarGroup {
38
40
  String get label {
39
41
  switch (this) {
40
42
  case SidebarGroup.chat:
41
43
  return 'Chat';
44
+ case SidebarGroup.timeline:
45
+ return 'Timeline';
42
46
  case SidebarGroup.recordings:
43
47
  return 'Recordings';
44
48
  case SidebarGroup.automation:
@@ -52,6 +56,8 @@ extension SidebarGroupX on SidebarGroup {
52
56
  switch (this) {
53
57
  case SidebarGroup.chat:
54
58
  return Icons.chat_bubble_outline;
59
+ case SidebarGroup.timeline:
60
+ return Icons.timeline_rounded;
55
61
  case SidebarGroup.recordings:
56
62
  return Icons.fiber_smart_record_outlined;
57
63
  case SidebarGroup.automation:
@@ -67,6 +73,8 @@ extension AppSectionX on AppSection {
67
73
  switch (this) {
68
74
  case AppSection.chat:
69
75
  return 'Chat';
76
+ case AppSection.timeline:
77
+ return 'Timeline';
70
78
  case AppSection.voiceAssistant:
71
79
  return 'Voice assistant';
72
80
  case AppSection.devices:
@@ -99,6 +107,8 @@ extension AppSectionX on AppSection {
99
107
  return 'Health';
100
108
  case AppSection.server:
101
109
  return 'Server';
110
+ case AppSection.billing:
111
+ return 'Billing';
102
112
  }
103
113
  }
104
114
 
@@ -106,6 +116,8 @@ extension AppSectionX on AppSection {
106
116
  switch (this) {
107
117
  case AppSection.chat:
108
118
  return Icons.chat_bubble_outline;
119
+ case AppSection.timeline:
120
+ return Icons.timeline_rounded;
109
121
  case AppSection.voiceAssistant:
110
122
  return Icons.keyboard_voice_outlined;
111
123
  case AppSection.devices:
@@ -138,6 +150,8 @@ extension AppSectionX on AppSection {
138
150
  return Icons.favorite_border;
139
151
  case AppSection.server:
140
152
  return Icons.dns_outlined;
153
+ case AppSection.billing:
154
+ return Icons.credit_card;
141
155
  }
142
156
  }
143
157
 
@@ -146,6 +160,8 @@ extension AppSectionX on AppSection {
146
160
  case AppSection.chat:
147
161
  case AppSection.voiceAssistant:
148
162
  return SidebarGroup.chat;
163
+ case AppSection.timeline:
164
+ return SidebarGroup.timeline;
149
165
  case AppSection.recordings:
150
166
  return SidebarGroup.recordings;
151
167
  case AppSection.devices:
@@ -163,6 +179,7 @@ extension AppSectionX on AppSection {
163
179
  case AppSection.messaging:
164
180
  case AppSection.agents:
165
181
  case AppSection.server:
182
+ case AppSection.billing:
166
183
  return SidebarGroup.settings;
167
184
  }
168
185
  }
@@ -193,6 +210,7 @@ extension AppSectionX on AppSection {
193
210
  return effectiveSection.label;
194
211
  }
195
212
  if (effectiveSection.group == SidebarGroup.chat ||
213
+ effectiveSection.group == SidebarGroup.timeline ||
196
214
  effectiveSection.group == SidebarGroup.recordings) {
197
215
  return groupLabel;
198
216
  }
@@ -503,17 +503,30 @@ class _SkillsPanelState extends State<SkillsPanel>
503
503
  late final TabController _tabController;
504
504
  String _selectedCategory = 'all';
505
505
 
506
+ // Installed tab search & filter state
507
+ String _installedQuery = '';
508
+ String _installedStatusFilter = 'all'; // 'all' | 'active' | 'draft' | 'disabled'
509
+ String _installedSourceFilter = 'all'; // 'all' | 'built-in' | 'learned' | 'user' | 'store'
510
+ late final TextEditingController _installedSearchController;
511
+
506
512
  @override
507
513
  void initState() {
508
514
  super.initState();
509
515
  _searchController = TextEditingController();
510
516
  _tabController = TabController(length: 2, vsync: this);
517
+ _installedSearchController = TextEditingController();
518
+ _installedSearchController.addListener(() {
519
+ setState(() {
520
+ _installedQuery = _installedSearchController.text.trim().toLowerCase();
521
+ });
522
+ });
511
523
  }
512
524
 
513
525
  @override
514
526
  void dispose() {
515
527
  _tabController.dispose();
516
528
  _searchController.dispose();
529
+ _installedSearchController.dispose();
517
530
  super.dispose();
518
531
  }
519
532
 
@@ -628,13 +641,122 @@ class _SkillsPanelState extends State<SkillsPanel>
628
641
  );
629
642
  }
630
643
 
644
+ final filteredSkills = controller.skills.where((skill) {
645
+ final q = _installedQuery;
646
+ if (q.isNotEmpty &&
647
+ !skill.name.toLowerCase().contains(q) &&
648
+ !skill.description.toLowerCase().contains(q)) return false;
649
+ if (_installedStatusFilter != 'all') {
650
+ if (_installedStatusFilter == 'active' && (!skill.enabled || skill.draft)) return false;
651
+ if (_installedStatusFilter == 'draft' && !skill.draft) return false;
652
+ if (_installedStatusFilter == 'disabled' && skill.enabled) return false;
653
+ }
654
+ if (_installedSourceFilter != 'all' && skill.source != _installedSourceFilter) return false;
655
+ return true;
656
+ }).toList();
657
+
658
+ final statusFilters = <String>['all', 'active', 'draft', 'disabled'];
659
+ final sourceFilters = <String>['all', 'built-in', 'learned', 'user', 'store'];
660
+
631
661
  return Card(
632
- child: ListView.separated(
633
- padding: const EdgeInsets.all(14),
634
- itemCount: controller.skills.length,
635
- separatorBuilder: (_, __) => const SizedBox(height: 10),
636
- itemBuilder: (context, index) {
637
- final skill = controller.skills[index];
662
+ child: Column(
663
+ children: <Widget>[
664
+ Padding(
665
+ padding: const EdgeInsets.fromLTRB(14, 14, 14, 0),
666
+ child: Column(
667
+ crossAxisAlignment: CrossAxisAlignment.start,
668
+ children: <Widget>[
669
+ TextField(
670
+ controller: _installedSearchController,
671
+ decoration: InputDecoration(
672
+ labelText: 'Search by name or description',
673
+ prefixIcon: Icon(Icons.search),
674
+ suffixIcon: _installedSearchController.text.isEmpty
675
+ ? null
676
+ : IconButton(
677
+ onPressed: () {
678
+ _installedSearchController.clear();
679
+ },
680
+ icon: Icon(Icons.close),
681
+ ),
682
+ ),
683
+ ),
684
+ const SizedBox(height: 10),
685
+ SizedBox(
686
+ height: 38,
687
+ child: ListView.separated(
688
+ scrollDirection: Axis.horizontal,
689
+ itemCount: statusFilters.length,
690
+ separatorBuilder: (_, __) => const SizedBox(width: 8),
691
+ itemBuilder: (context, index) {
692
+ final filter = statusFilters[index];
693
+ final selected = filter == _installedStatusFilter;
694
+ return FilterChip(
695
+ selected: selected,
696
+ label: Text(
697
+ filter == 'all'
698
+ ? 'All'
699
+ : filter[0].toUpperCase() + filter.substring(1),
700
+ ),
701
+ selectedColor: _accentMuted,
702
+ checkmarkColor: _accent,
703
+ backgroundColor: _bgSecondary,
704
+ side: BorderSide(color: _border),
705
+ onSelected: (_) =>
706
+ setState(() => _installedStatusFilter = filter),
707
+ );
708
+ },
709
+ ),
710
+ ),
711
+ const SizedBox(height: 8),
712
+ SizedBox(
713
+ height: 38,
714
+ child: ListView.separated(
715
+ scrollDirection: Axis.horizontal,
716
+ itemCount: sourceFilters.length,
717
+ separatorBuilder: (_, __) => const SizedBox(width: 8),
718
+ itemBuilder: (context, index) {
719
+ final filter = sourceFilters[index];
720
+ final selected = filter == _installedSourceFilter;
721
+ return FilterChip(
722
+ selected: selected,
723
+ label: Text(filter == 'all' ? 'All' : filter),
724
+ selectedColor: _accentMuted,
725
+ checkmarkColor: _accent,
726
+ backgroundColor: _bgSecondary,
727
+ side: BorderSide(color: _border),
728
+ onSelected: (_) =>
729
+ setState(() => _installedSourceFilter = filter),
730
+ );
731
+ },
732
+ ),
733
+ ),
734
+ const SizedBox(height: 8),
735
+ Text(
736
+ '${filteredSkills.length} skill${filteredSkills.length == 1 ? '' : 's'}',
737
+ style: TextStyle(color: _textSecondary),
738
+ ),
739
+ const SizedBox(height: 8),
740
+ ],
741
+ ),
742
+ ),
743
+ if (filteredSkills.isEmpty)
744
+ Expanded(
745
+ child: Center(
746
+ child: Text(
747
+ 'No skills match your filters',
748
+ style: TextStyle(color: _textSecondary),
749
+ ),
750
+ ),
751
+ )
752
+ else
753
+ Expanded(
754
+ child: ListView.separated(
755
+ padding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
756
+ itemCount: filteredSkills.length,
757
+ separatorBuilder: (_, __) => const SizedBox(height: 10),
758
+ itemBuilder: (context, index) {
759
+ final skill = filteredSkills[index];
638
760
  return LayoutBuilder(
639
761
  builder: (context, constraints) {
640
762
  final compact = constraints.maxWidth < 760;
@@ -780,9 +902,12 @@ class _SkillsPanelState extends State<SkillsPanel>
780
902
  );
781
903
  },
782
904
  );
783
- },
784
- ),
785
- );
905
+ },
906
+ ),
907
+ ),
908
+ ],
909
+ ),
910
+ );
786
911
  }
787
912
 
788
913
  Widget _buildStoreTab(
@@ -5171,6 +5296,24 @@ class _TasksPanelState extends State<TasksPanel> {
5171
5296
  }
5172
5297
  }
5173
5298
 
5299
+ String _compactBudgetNumber(int value) {
5300
+ if (value >= 1000000) {
5301
+ final millions = value / 1000000;
5302
+ return '${millions.toStringAsFixed(millions >= 10 ? 0 : 1)}M';
5303
+ }
5304
+ if (value >= 1000) {
5305
+ final thousands = value / 1000;
5306
+ return '${thousands.toStringAsFixed(thousands >= 10 ? 0 : 1)}k';
5307
+ }
5308
+ return '$value';
5309
+ }
5310
+
5311
+ String _taskBudgetLabel(TaskItem task) {
5312
+ final budget = task.loopBudget;
5313
+ return 'Budget: ${budget.maxRunsPerDay} runs/day · '
5314
+ '${_compactBudgetNumber(budget.maxTokensPerDay)} tokens/day';
5315
+ }
5316
+
5174
5317
  Future<void> _showLastRun(TaskItem task) async {
5175
5318
  final runId = task.lastRunId.trim();
5176
5319
  if (runId.isEmpty) return;
@@ -5387,6 +5530,10 @@ class _TasksPanelState extends State<TasksPanel> {
5387
5530
  label: task.enabled ? 'Active' : 'Paused',
5388
5531
  color: task.enabled ? _success : _textSecondary,
5389
5532
  ),
5533
+ if (task.loopBudget.paused) ...<Widget>[
5534
+ const SizedBox(width: 8),
5535
+ _StatusPill(label: 'Loop paused', color: _warning),
5536
+ ],
5390
5537
  if (task.hasLastRunStatus) ...<Widget>[
5391
5538
  const SizedBox(width: 8),
5392
5539
  _StatusPill(
@@ -5417,6 +5564,11 @@ class _TasksPanelState extends State<TasksPanel> {
5417
5564
  style: TextStyle(color: _textSecondary),
5418
5565
  ),
5419
5566
  const SizedBox(height: 8),
5567
+ Text(
5568
+ _taskBudgetLabel(task),
5569
+ style: TextStyle(color: _textSecondary),
5570
+ ),
5571
+ const SizedBox(height: 8),
5420
5572
  Text(task.prompt, style: TextStyle(color: _textPrimary)),
5421
5573
  if (task.lastRunLabel.isNotEmpty) ...<Widget>[
5422
5574
  const SizedBox(height: 8),
@@ -5506,6 +5658,10 @@ class _TasksPanelState extends State<TasksPanel> {
5506
5658
  label: task.enabled ? 'Active' : 'Paused',
5507
5659
  color: task.enabled ? _success : _textSecondary,
5508
5660
  ),
5661
+ if (task.loopBudget.paused) ...<Widget>[
5662
+ const SizedBox(width: 8),
5663
+ _StatusPill(label: 'Loop paused', color: _warning),
5664
+ ],
5509
5665
  if (task.hasLastRunStatus) ...<Widget>[
5510
5666
  const SizedBox(width: 8),
5511
5667
  _StatusPill(
@@ -5535,6 +5691,11 @@ class _TasksPanelState extends State<TasksPanel> {
5535
5691
  style: TextStyle(color: _textSecondary),
5536
5692
  ),
5537
5693
  const SizedBox(height: 8),
5694
+ Text(
5695
+ _taskBudgetLabel(task),
5696
+ style: TextStyle(color: _textSecondary),
5697
+ ),
5698
+ const SizedBox(height: 8),
5538
5699
  Text(
5539
5700
  linkedWidget.prompt,
5540
5701
  style: TextStyle(color: _textPrimary),
@@ -5716,7 +5877,15 @@ class _TasksPanelState extends State<TasksPanel> {
5716
5877
  text: task?.triggerConfig['sender']?.toString() ?? '',
5717
5878
  );
5718
5879
  final promptController = TextEditingController(text: task?.prompt ?? '');
5880
+ final loopBudget = task?.loopBudget ?? TaskLoopBudget.fromJson(const {});
5881
+ final maxRunsController = TextEditingController(
5882
+ text: loopBudget.maxRunsPerDay.toString(),
5883
+ );
5884
+ final maxTokensController = TextEditingController(
5885
+ text: loopBudget.maxTokensPerDay.toString(),
5886
+ );
5719
5887
  var enabled = task?.enabled ?? true;
5888
+ var loopPaused = loopBudget.paused;
5720
5889
  var unreadOnly = task?.triggerConfig['unreadOnly'] == true;
5721
5890
  var ignoreGroups = task?.triggerConfig['ignoreGroups'] == true;
5722
5891
  var selectedModel = _ensureModelValue(
@@ -6019,6 +6188,88 @@ class _TasksPanelState extends State<TasksPanel> {
6019
6188
  () => selectedModel = value ?? 'auto',
6020
6189
  ),
6021
6190
  ),
6191
+ const SizedBox(height: 12),
6192
+ Container(
6193
+ width: double.infinity,
6194
+ padding: const EdgeInsets.all(14),
6195
+ decoration: BoxDecoration(
6196
+ border: Border.all(
6197
+ color: _border.withValues(alpha: 0.8),
6198
+ ),
6199
+ borderRadius: BorderRadius.circular(12),
6200
+ ),
6201
+ child: Column(
6202
+ crossAxisAlignment: CrossAxisAlignment.start,
6203
+ children: <Widget>[
6204
+ Row(
6205
+ children: <Widget>[
6206
+ Icon(
6207
+ Icons.speed_outlined,
6208
+ size: 18,
6209
+ color: _textSecondary,
6210
+ ),
6211
+ const SizedBox(width: 8),
6212
+ Text(
6213
+ 'Loop budget',
6214
+ style: TextStyle(
6215
+ fontWeight: FontWeight.w700,
6216
+ color: _textPrimary,
6217
+ ),
6218
+ ),
6219
+ ],
6220
+ ),
6221
+ const SizedBox(height: 12),
6222
+ LayoutBuilder(
6223
+ builder: (context, constraints) {
6224
+ final stacked = constraints.maxWidth < 520;
6225
+ final fields = <Widget>[
6226
+ TextField(
6227
+ controller: maxRunsController,
6228
+ keyboardType: TextInputType.number,
6229
+ decoration: const InputDecoration(
6230
+ labelText: 'Max runs per day',
6231
+ ),
6232
+ ),
6233
+ TextField(
6234
+ controller: maxTokensController,
6235
+ keyboardType: TextInputType.number,
6236
+ decoration: const InputDecoration(
6237
+ labelText: 'Max tokens per day',
6238
+ ),
6239
+ ),
6240
+ ];
6241
+ if (stacked) {
6242
+ return Column(
6243
+ children: <Widget>[
6244
+ fields[0],
6245
+ const SizedBox(height: 12),
6246
+ fields[1],
6247
+ ],
6248
+ );
6249
+ }
6250
+ return Row(
6251
+ children: <Widget>[
6252
+ Expanded(child: fields[0]),
6253
+ const SizedBox(width: 12),
6254
+ Expanded(child: fields[1]),
6255
+ ],
6256
+ );
6257
+ },
6258
+ ),
6259
+ const SizedBox(height: 8),
6260
+ SwitchListTile(
6261
+ value: loopPaused,
6262
+ contentPadding: EdgeInsets.zero,
6263
+ title: const Text('Pause loop budget'),
6264
+ subtitle: const Text(
6265
+ 'Skip this task before it calls the model.',
6266
+ ),
6267
+ onChanged: (value) =>
6268
+ setLocalState(() => loopPaused = value),
6269
+ ),
6270
+ ],
6271
+ ),
6272
+ ),
6022
6273
  if (controller.agentProfiles.isNotEmpty) ...<Widget>[
6023
6274
  const SizedBox(height: 12),
6024
6275
  DropdownButtonFormField<String>(
@@ -6139,12 +6390,40 @@ class _TasksPanelState extends State<TasksPanel> {
6139
6390
  triggerConfig['ignoreGroups'] = ignoreGroups;
6140
6391
  }
6141
6392
  }
6393
+ final maxRuns = int.tryParse(maxRunsController.text.trim());
6394
+ final maxTokens = int.tryParse(
6395
+ maxTokensController.text.trim(),
6396
+ );
6397
+ if (maxRuns == null ||
6398
+ maxRuns <= 0 ||
6399
+ maxTokens == null ||
6400
+ maxTokens <= 0) {
6401
+ ScaffoldMessenger.of(context).showSnackBar(
6402
+ const SnackBar(
6403
+ content: Text(
6404
+ 'Loop budget values must be positive numbers.',
6405
+ ),
6406
+ backgroundColor: Colors.red,
6407
+ ),
6408
+ );
6409
+ return;
6410
+ }
6411
+ final taskConfig = <String, dynamic>{
6412
+ ...?task?.taskConfig,
6413
+ 'loopBudget': <String, dynamic>{
6414
+ 'enabled': true,
6415
+ 'paused': loopPaused,
6416
+ 'maxRunsPerDay': maxRuns,
6417
+ 'maxTokensPerDay': maxTokens,
6418
+ },
6419
+ };
6142
6420
  await controller.saveTask(
6143
6421
  id: task?.id,
6144
6422
  name: nameController.text.trim(),
6145
6423
  triggerType: selectedTriggerType,
6146
6424
  triggerConfig: triggerConfig,
6147
6425
  prompt: promptController.text.trim(),
6426
+ taskConfig: taskConfig,
6148
6427
  model: selectedModel == 'auto' ? null : selectedModel,
6149
6428
  enabled: enabled,
6150
6429
  agentId: selectedAgentId,