neoagent 3.0.1-beta.8 → 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 (100) 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.css +78 -0
  42. package/server/admin/admin.js +436 -10
  43. package/server/admin/billing.js +165 -42
  44. package/server/admin/index.html +80 -5
  45. package/server/admin/users.js +15 -15
  46. package/server/db/database.js +125 -20
  47. package/server/http/routes.js +1 -0
  48. package/server/http/static.js +4 -2
  49. package/server/public/.last_build_id +1 -1
  50. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  51. package/server/public/canvaskit/wimp.js.symbols +8475 -8467
  52. package/server/public/canvaskit/wimp.wasm +0 -0
  53. package/server/public/flutter_bootstrap.js +2 -2
  54. package/server/public/main.dart.js +91077 -87037
  55. package/server/routes/account.js +53 -0
  56. package/server/routes/admin.js +345 -64
  57. package/server/routes/agents.js +203 -21
  58. package/server/routes/billing.js +5 -0
  59. package/server/routes/browser.js +8 -1
  60. package/server/routes/recordings.js +96 -6
  61. package/server/routes/screenHistory.js +140 -2
  62. package/server/routes/timeline.js +43 -0
  63. package/server/services/account/erasure.js +263 -0
  64. package/server/services/ai/hooks.js +4 -1
  65. package/server/services/ai/loop/agent_engine_core.js +8 -1
  66. package/server/services/ai/loop/blank_recovery.js +36 -0
  67. package/server/services/ai/loop/conversation_loop.js +166 -33
  68. package/server/services/ai/messagingFallback.js +22 -0
  69. package/server/services/ai/rate_limits.js +28 -5
  70. package/server/services/ai/systemPrompt.js +6 -5
  71. package/server/services/ai/taskAnalysis.js +2 -0
  72. package/server/services/ai/toolEvidence.js +15 -0
  73. package/server/services/ai/toolResult.js +40 -0
  74. package/server/services/ai/tools.js +163 -4
  75. package/server/services/android/controller.js +6 -2
  76. package/server/services/billing/plans.js +2 -1
  77. package/server/services/browser/anti_detection.js +192 -0
  78. package/server/services/browser/controller.js +180 -54
  79. package/server/services/desktop/auth.js +3 -0
  80. package/server/services/desktop/registry.js +50 -2
  81. package/server/services/integrations/google/calendar.js +22 -14
  82. package/server/services/manager.js +12 -42
  83. package/server/services/memory/ingestion_chunking.js +268 -0
  84. package/server/services/messaging/telnyx.js +9 -8
  85. package/server/services/recordings/manager.js +60 -27
  86. package/server/services/runtime/backends/local-vm.js +40 -22
  87. package/server/services/runtime/docker-vm-manager.js +157 -266
  88. package/server/services/runtime/guest_bootstrap.js +17 -5
  89. package/server/services/runtime/guest_image.js +188 -0
  90. package/server/services/runtime/manager.js +0 -1
  91. package/server/services/runtime/validation.js +3 -8
  92. package/server/services/social_video/service.js +60 -10
  93. package/server/services/tasks/runtime.js +234 -9
  94. package/server/services/tasks/schedule_utils.js +5 -5
  95. package/server/services/tasks/task_repository.js +13 -0
  96. package/server/services/timeline/service.js +558 -0
  97. package/server/services/wearable/gateway.js +1 -1
  98. package/server/services/websocket.js +21 -3
  99. package/server/services/desktop/screenRecorder.js +0 -292
  100. package/server/services/desktop/screen_recorder_support.js +0 -46
@@ -0,0 +1,149 @@
1
+ import 'dart:async';
2
+ import 'dart:typed_data';
3
+
4
+ /// Metadata plus bytes for one recording chunk awaiting upload.
5
+ class PendingChunk {
6
+ const PendingChunk({
7
+ required this.sourceKey,
8
+ required this.sequence,
9
+ required this.startMs,
10
+ required this.endMs,
11
+ required this.mimeType,
12
+ required this.bytes,
13
+ });
14
+
15
+ final String sourceKey;
16
+ final int sequence;
17
+ final int startMs;
18
+ final int endMs;
19
+ final String mimeType;
20
+ final Uint8List bytes;
21
+
22
+ Map<String, Object?> toMeta() => <String, Object?>{
23
+ 'sourceKey': sourceKey,
24
+ 'sequence': sequence,
25
+ 'startMs': startMs,
26
+ 'endMs': endMs,
27
+ 'mimeType': mimeType,
28
+ };
29
+
30
+ static PendingChunk fromMeta(Map<String, Object?> meta, Uint8List bytes) {
31
+ return PendingChunk(
32
+ sourceKey: '${meta['sourceKey']}',
33
+ sequence: (meta['sequence'] as num?)?.toInt() ?? 0,
34
+ startMs: (meta['startMs'] as num?)?.toInt() ?? 0,
35
+ endMs: (meta['endMs'] as num?)?.toInt() ?? 0,
36
+ mimeType: meta['mimeType'] as String? ?? 'application/octet-stream',
37
+ bytes: bytes,
38
+ );
39
+ }
40
+ }
41
+
42
+ /// Uploads a single chunk. Must throw on any failure so the queue retries.
43
+ typedef ChunkUploader = Future<void> Function(PendingChunk chunk);
44
+
45
+ typedef QueueLogger =
46
+ void Function(String event, {Map<String, Object?> data, Object? error});
47
+
48
+ /// In-memory, ordered, retry-until-success upload queue.
49
+ ///
50
+ /// Chunks upload one at a time in enqueue order. A chunk that fails is kept and
51
+ /// retried (with backoff) until it succeeds or [dispose] is called, so a
52
+ /// transient network failure never silently drops audio while new chunks keep
53
+ /// buffering. Used on web, where no filesystem is available; the queue lives
54
+ /// only for the lifetime of the tab.
55
+ class MemoryChunkUploadQueue {
56
+ MemoryChunkUploadQueue({
57
+ required ChunkUploader uploader,
58
+ this.maxAttemptsPerPass = 3,
59
+ this.retryBackoff = const Duration(seconds: 2),
60
+ QueueLogger? logger,
61
+ }) : _uploader = uploader,
62
+ _logger = logger;
63
+
64
+ final ChunkUploader _uploader;
65
+ final int maxAttemptsPerPass;
66
+ final Duration retryBackoff;
67
+ final QueueLogger? _logger;
68
+
69
+ final List<PendingChunk> _pending = <PendingChunk>[];
70
+ bool _draining = false;
71
+ bool _disposed = false;
72
+ Timer? _retryTimer;
73
+
74
+ int get pendingCount => _pending.length;
75
+
76
+ void enqueue(PendingChunk chunk) {
77
+ if (_disposed) {
78
+ return;
79
+ }
80
+ _pending.add(chunk);
81
+ unawaited(drain());
82
+ }
83
+
84
+ Future<void> drain() async {
85
+ if (_draining || _disposed) {
86
+ return;
87
+ }
88
+ _draining = true;
89
+ try {
90
+ while (_pending.isNotEmpty && !_disposed) {
91
+ final chunk = _pending.first;
92
+ final uploaded = await _attemptUpload(chunk);
93
+ if (!uploaded) {
94
+ _scheduleRetry();
95
+ break;
96
+ }
97
+ if (_pending.isNotEmpty && identical(_pending.first, chunk)) {
98
+ _pending.removeAt(0);
99
+ }
100
+ }
101
+ } finally {
102
+ _draining = false;
103
+ }
104
+ }
105
+
106
+ Future<bool> _attemptUpload(PendingChunk chunk) async {
107
+ for (var attempt = 0; attempt < maxAttemptsPerPass && !_disposed; attempt += 1) {
108
+ try {
109
+ await _uploader(chunk);
110
+ return true;
111
+ } catch (error) {
112
+ _logger?.call(
113
+ 'chunk.upload.failed',
114
+ data: <String, Object?>{
115
+ 'sourceKey': chunk.sourceKey,
116
+ 'sequence': chunk.sequence,
117
+ 'attempt': attempt + 1,
118
+ },
119
+ error: error,
120
+ );
121
+ await Future<void>.delayed(retryBackoff * (attempt + 1));
122
+ }
123
+ }
124
+ return false;
125
+ }
126
+
127
+ void _scheduleRetry() {
128
+ _retryTimer?.cancel();
129
+ _retryTimer = Timer(retryBackoff, () => unawaited(drain()));
130
+ }
131
+
132
+ /// Wait until every queued chunk has uploaded, or [timeout] elapses.
133
+ Future<void> flush({Duration timeout = const Duration(seconds: 30)}) async {
134
+ final deadline = DateTime.now().add(timeout);
135
+ while (_pending.isNotEmpty && !_disposed && DateTime.now().isBefore(deadline)) {
136
+ await drain();
137
+ if (_pending.isNotEmpty) {
138
+ await Future<void>.delayed(const Duration(milliseconds: 200));
139
+ }
140
+ }
141
+ }
142
+
143
+ Future<void> dispose() async {
144
+ _disposed = true;
145
+ _retryTimer?.cancel();
146
+ _retryTimer = null;
147
+ _pending.clear();
148
+ }
149
+ }
@@ -0,0 +1,182 @@
1
+ import 'dart:async';
2
+ import 'dart:convert';
3
+ import 'dart:io';
4
+
5
+ import 'recording_chunk_queue.dart';
6
+
7
+ /// Disk-backed, ordered, retry-until-success upload queue for desktop.
8
+ ///
9
+ /// Each chunk is persisted atomically before upload, so a network outage or
10
+ /// backend hiccup mid-meeting never loses audio: the chunk stays on disk and is
11
+ /// re-sent on the next drain. Files are deleted only after a confirmed upload.
12
+ class DiskChunkUploadQueue {
13
+ DiskChunkUploadQueue({
14
+ required Directory directory,
15
+ required ChunkUploader uploader,
16
+ this.maxAttemptsPerPass = 3,
17
+ this.retryBackoff = const Duration(seconds: 2),
18
+ QueueLogger? logger,
19
+ }) : _dir = directory,
20
+ _uploader = uploader,
21
+ _logger = logger;
22
+
23
+ final Directory _dir;
24
+ final ChunkUploader _uploader;
25
+ final int maxAttemptsPerPass;
26
+ final Duration retryBackoff;
27
+ final QueueLogger? _logger;
28
+
29
+ bool _draining = false;
30
+ bool _disposed = false;
31
+ Timer? _retryTimer;
32
+ final Set<Future<void>> _pendingEnqueues = <Future<void>>{};
33
+
34
+ Future<void> enqueue(PendingChunk chunk) async {
35
+ if (_disposed) {
36
+ return;
37
+ }
38
+ final write = _persistChunk(chunk);
39
+ _pendingEnqueues.add(write);
40
+ try {
41
+ await write;
42
+ } finally {
43
+ _pendingEnqueues.remove(write);
44
+ }
45
+ unawaited(drain());
46
+ }
47
+
48
+ Future<void> drain() async {
49
+ if (_draining || _disposed) {
50
+ return;
51
+ }
52
+ _draining = true;
53
+ try {
54
+ while (!_disposed) {
55
+ final metaFiles = await _listMetaFiles();
56
+ if (metaFiles.isEmpty) {
57
+ break;
58
+ }
59
+ final metaFile = metaFiles.first;
60
+ final audioFile = File(
61
+ metaFile.path.replaceFirst(RegExp(r'\.json$'), '.bin'),
62
+ );
63
+ if (!await audioFile.exists()) {
64
+ await _deleteQuietly(metaFile);
65
+ continue;
66
+ }
67
+
68
+ final PendingChunk chunk;
69
+ try {
70
+ final meta =
71
+ jsonDecode(await metaFile.readAsString()) as Map<String, dynamic>;
72
+ chunk = PendingChunk.fromMeta(meta, await audioFile.readAsBytes());
73
+ } catch (error) {
74
+ // Corrupt metadata: drop the pair rather than blocking the queue.
75
+ _logger?.call('chunk.read.failed', error: error);
76
+ await _deleteQuietly(metaFile);
77
+ await _deleteQuietly(audioFile);
78
+ continue;
79
+ }
80
+
81
+ final uploaded = await _attemptUpload(chunk);
82
+ if (!uploaded) {
83
+ _scheduleRetry();
84
+ break;
85
+ }
86
+ await _deleteQuietly(audioFile);
87
+ await _deleteQuietly(metaFile);
88
+ }
89
+ } finally {
90
+ _draining = false;
91
+ }
92
+ }
93
+
94
+ Future<bool> _attemptUpload(PendingChunk chunk) async {
95
+ for (var attempt = 0; attempt < maxAttemptsPerPass && !_disposed; attempt += 1) {
96
+ try {
97
+ await _uploader(chunk);
98
+ return true;
99
+ } catch (error) {
100
+ _logger?.call(
101
+ 'chunk.upload.failed',
102
+ data: <String, Object?>{
103
+ 'sourceKey': chunk.sourceKey,
104
+ 'sequence': chunk.sequence,
105
+ 'attempt': attempt + 1,
106
+ },
107
+ error: error,
108
+ );
109
+ await Future<void>.delayed(retryBackoff * (attempt + 1));
110
+ }
111
+ }
112
+ return false;
113
+ }
114
+
115
+ Future<List<File>> _listMetaFiles() async {
116
+ if (!await _dir.exists()) {
117
+ return const <File>[];
118
+ }
119
+ final entries = await _dir.list().toList();
120
+ final metaFiles = entries
121
+ .whereType<File>()
122
+ .where((file) => file.path.endsWith('.json'))
123
+ .toList()
124
+ ..sort((a, b) => a.path.compareTo(b.path));
125
+ return metaFiles;
126
+ }
127
+
128
+ Future<int> pendingCount() async => (await _listMetaFiles()).length;
129
+
130
+ Future<void> _persistChunk(PendingChunk chunk) async {
131
+ await _dir.create(recursive: true);
132
+ final base = '${chunk.sourceKey}-${chunk.sequence.toString().padLeft(6, '0')}';
133
+ final audioFile = File('${_dir.path}/$base.bin');
134
+ final metaFile = File('${_dir.path}/$base.json');
135
+ final audioTmp = File('${audioFile.path}.tmp');
136
+ final metaTmp = File('${metaFile.path}.tmp');
137
+ await audioTmp.writeAsBytes(chunk.bytes, flush: true);
138
+ await metaTmp.writeAsString(jsonEncode(chunk.toMeta()), flush: true);
139
+ await audioTmp.rename(audioFile.path);
140
+ await metaTmp.rename(metaFile.path);
141
+ }
142
+
143
+ Future<void> _waitForPendingEnqueues() async {
144
+ while (_pendingEnqueues.isNotEmpty && !_disposed) {
145
+ await Future.wait<void>(List<Future<void>>.from(_pendingEnqueues));
146
+ }
147
+ }
148
+
149
+ void _scheduleRetry() {
150
+ _retryTimer?.cancel();
151
+ _retryTimer = Timer(retryBackoff, () => unawaited(drain()));
152
+ }
153
+
154
+ Future<void> _deleteQuietly(File file) async {
155
+ try {
156
+ if (await file.exists()) {
157
+ await file.delete();
158
+ }
159
+ } catch (error) {
160
+ _logger?.call('chunk.delete.failed', error: error);
161
+ }
162
+ }
163
+
164
+ /// Wait until every persisted chunk has uploaded, or [timeout] elapses.
165
+ Future<void> flush({Duration timeout = const Duration(seconds: 30)}) async {
166
+ final deadline = DateTime.now().add(timeout);
167
+ while (!_disposed && DateTime.now().isBefore(deadline)) {
168
+ await _waitForPendingEnqueues();
169
+ await drain();
170
+ if (_pendingEnqueues.isEmpty && await pendingCount() == 0) {
171
+ break;
172
+ }
173
+ await Future<void>.delayed(const Duration(milliseconds: 200));
174
+ }
175
+ }
176
+
177
+ Future<void> dispose() async {
178
+ _disposed = true;
179
+ _retryTimer?.cancel();
180
+ _retryTimer = null;
181
+ }
182
+ }
@@ -13,6 +13,15 @@ Map<String, dynamic> buildWebScreenAndMicRecordingPayload() {
13
13
  'transcribe': false,
14
14
  },
15
15
  },
16
+ <String, dynamic>{
17
+ 'sourceKey': 'system',
18
+ 'sourceKind': 'system-audio',
19
+ 'mediaKind': 'audio',
20
+ 'mimeType': 'audio/webm',
21
+ 'metadata': <String, dynamic>{
22
+ 'transcribe': true,
23
+ },
24
+ },
16
25
  <String, dynamic>{
17
26
  'sourceKey': 'microphone',
18
27
  'sourceKind': 'microphone',
@@ -16,6 +16,7 @@ class AppDelegate: FlutterAppDelegate {
16
16
 
17
17
  final class DesktopCompanionNativePlugin: NSObject {
18
18
  private static let channelName = "neoagent/desktop_companion_native"
19
+ private static let idleThresholdSeconds: Double = 300
19
20
  private static var channelAssociationKey: UInt8 = 0
20
21
  private static var instanceAssociationKey: UInt8 = 0
21
22
 
@@ -268,6 +269,7 @@ final class DesktopCompanionNativePlugin: NSObject {
268
269
 
269
270
  private func buildDesktopStatus() -> [String: Any] {
270
271
  let accessibilityTrusted = isAccessibilityTrusted()
272
+ let idleSeconds = userIdleSeconds()
271
273
  var status: [String: Any] = [
272
274
  "permissions": [
273
275
  "screenCapture": preflightScreenCapturePermission() ? "available" : "required",
@@ -276,6 +278,9 @@ final class DesktopCompanionNativePlugin: NSObject {
276
278
  ],
277
279
  "displays": desktopDisplays(),
278
280
  "activeDisplayId": defaultDisplayIdentifier(),
281
+ "sessionLocked": isSessionLocked(),
282
+ "idleSeconds": idleSeconds,
283
+ "userIdle": idleSeconds >= Self.idleThresholdSeconds,
279
284
  ]
280
285
 
281
286
  if let appName = NSWorkspace.shared.frontmostApplication?.localizedName {
@@ -424,6 +429,26 @@ final class DesktopCompanionNativePlugin: NSObject {
424
429
  return title
425
430
  }
426
431
 
432
+ private func isSessionLocked() -> Bool {
433
+ guard let session = CGSessionCopyCurrentDictionary() as? [String: Any] else {
434
+ return false
435
+ }
436
+ if let locked = session["CGSSessionScreenIsLocked"] as? Bool {
437
+ return locked
438
+ }
439
+ if let locked = session["CGSSessionScreenIsLocked"] as? NSNumber {
440
+ return locked.boolValue
441
+ }
442
+ return false
443
+ }
444
+
445
+ private func userIdleSeconds() -> Double {
446
+ CGEventSource.secondsSinceLastEventType(
447
+ .combinedSessionState,
448
+ eventType: .null
449
+ )
450
+ }
451
+
427
452
  private func performClick(x: Double, y: Double, button: String, displayId: String?) {
428
453
  let point = nativePointForCapturedPixel(x: x, y: y, displayId: displayId)
429
454
  let mouseButton = cgMouseButton(button)
@@ -241,6 +241,70 @@ std::string ForegroundWindowTitle() {
241
241
  return WideToUtf8(std::wstring(buffer, buffer + length));
242
242
  }
243
243
 
244
+ std::string BasenameFromPath(const std::wstring& path) {
245
+ if (path.empty()) {
246
+ return std::string();
247
+ }
248
+ const size_t slash = path.find_last_of(L"\\/");
249
+ const std::wstring name =
250
+ slash == std::wstring::npos ? path : path.substr(slash + 1);
251
+ return WideToUtf8(name);
252
+ }
253
+
254
+ std::string ForegroundWindowApp() {
255
+ HWND window = GetForegroundWindow();
256
+ if (window == nullptr) {
257
+ return std::string();
258
+ }
259
+ DWORD process_id = 0;
260
+ GetWindowThreadProcessId(window, &process_id);
261
+ if (process_id == 0) {
262
+ return std::string();
263
+ }
264
+ HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, process_id);
265
+ if (process == nullptr) {
266
+ return std::string();
267
+ }
268
+ wchar_t buffer[MAX_PATH];
269
+ DWORD size = MAX_PATH;
270
+ std::string app_name;
271
+ if (QueryFullProcessImageNameW(process, 0, buffer, &size)) {
272
+ app_name = BasenameFromPath(std::wstring(buffer, size));
273
+ }
274
+ CloseHandle(process);
275
+ return app_name;
276
+ }
277
+
278
+ double UserIdleSeconds() {
279
+ LASTINPUTINFO info{};
280
+ info.cbSize = sizeof(LASTINPUTINFO);
281
+ if (!GetLastInputInfo(&info)) {
282
+ return 0;
283
+ }
284
+ const ULONGLONG now = GetTickCount64();
285
+ const ULONGLONG then = static_cast<ULONGLONG>(info.dwTime);
286
+ if (now < then) {
287
+ return 0;
288
+ }
289
+ return static_cast<double>(now - then) / 1000.0;
290
+ }
291
+
292
+ bool IsWorkstationLocked() {
293
+ HDESK input_desktop = OpenInputDesktop(0, FALSE, GENERIC_READ);
294
+ if (input_desktop == nullptr) {
295
+ return false;
296
+ }
297
+ wchar_t name[256];
298
+ DWORD needed = 0;
299
+ const bool ok = GetUserObjectInformationW(
300
+ input_desktop, UOI_NAME, name, sizeof(name), &needed);
301
+ CloseDesktop(input_desktop);
302
+ if (!ok) {
303
+ return false;
304
+ }
305
+ return _wcsicmp(name, L"Default") != 0;
306
+ }
307
+
244
308
  void SendMouseButton(DWORD flag) {
245
309
  INPUT input{};
246
310
  input.type = INPUT_MOUSE;
@@ -329,6 +393,8 @@ bool FlutterWindow::OnCreate() {
329
393
  const auto* arguments = std::get_if<EncodableMap>(call.arguments());
330
394
 
331
395
  if (call.method_name() == "getStatus") {
396
+ const std::string frontmost_app = ForegroundWindowApp();
397
+ const double idle_seconds = UserIdleSeconds();
332
398
  EncodableMap permissions;
333
399
  permissions[EncodableValue("screenCapture")] = EncodableValue("available");
334
400
  permissions[EncodableValue("inputControl")] = EncodableValue("available");
@@ -339,6 +405,15 @@ bool FlutterWindow::OnCreate() {
339
405
  status[EncodableValue("displays")] = EncodableValue(DisplaysToEncodable());
340
406
  status[EncodableValue("activeDisplayId")] =
341
407
  EncodableValue(WideToUtf8(ResolveDisplay("").id));
408
+ status[EncodableValue("sessionLocked")] =
409
+ EncodableValue(IsWorkstationLocked());
410
+ status[EncodableValue("idleSeconds")] = EncodableValue(idle_seconds);
411
+ status[EncodableValue("userIdle")] =
412
+ EncodableValue(idle_seconds >= 300.0);
413
+ if (!frontmost_app.empty()) {
414
+ status[EncodableValue("frontmostApp")] =
415
+ EncodableValue(frontmost_app);
416
+ }
342
417
  const std::string window_title = ForegroundWindowTitle();
343
418
  if (!window_title.empty()) {
344
419
  status[EncodableValue("frontmostWindowTitle")] =
@@ -640,7 +640,7 @@ p { margin: 0; text-wrap: pretty; }
640
640
  .hero-note { margin-top: 16px; font-family: var(--mono); font-size: 12.5px; color: var(--ink-3); letter-spacing: 0.02em; }
641
641
 
642
642
  /* ---------- orbit system ---------- */
643
- .orbit-stage { position: relative; width: min(460px, 86vw); aspect-ratio: 1; margin-inline: auto; }
643
+ .orbit-stage { position: relative; width: min(460px, 86vw, 100%); aspect-ratio: 1; margin-inline: auto; }
644
644
  .orbit-glow { position: absolute; inset: 16%; border-radius: 50%; background: radial-gradient(circle, rgba(199,154,82,0.28), transparent 65%); filter: blur(22px); animation: corepulse 7s ease-in-out infinite; }
645
645
  @media (prefers-color-scheme: dark) { .orbit-glow { background: radial-gradient(circle, rgba(228,197,141,0.22), transparent 65%); } }
646
646
  @keyframes corepulse { 0%,100% { opacity: .7; transform: scale(1); } 50% { opacity: 1; transform: scale(1.07); } }
@@ -830,7 +830,7 @@ p { margin: 0; text-wrap: pretty; }
830
830
  .hero-actions { flex-direction: column; width: 100%; }
831
831
  .hero-actions .btn { width: 100%; justify-content: center; }
832
832
  .final .hero-actions .btn { width: auto; }
833
- .orbit-stage { width: min(320px, 84vw); }
833
+ .orbit-stage { width: min(320px, 78vw); }
834
834
  .frame-bar .url { max-width: 70%; }
835
835
  .footer-inner { flex-direction: column; gap: 22px; }
836
836
  .footer-bar { flex-direction: column; align-items: flex-start; gap: 8px; }
@@ -848,7 +848,7 @@ p { margin: 0; text-wrap: pretty; }
848
848
  .logo .ri { transform-box: fill-box; transform-origin: center; }
849
849
  .logo .ri-a { animation: spinA 18s linear infinite; }
850
850
  .logo .ri-b { animation: spinB 26s linear infinite; }
851
- .logo .nd { animation: nd 14s linear infinite; transform-box: fill-box; transform-origin: 50px 50px; }
851
+ .logo .nd { animation: nd 14s linear infinite; transform-box: view-box; transform-origin: 50px 50px; }
852
852
  @keyframes spinA { to { transform: rotate(360deg); } }
853
853
  @keyframes spinB { to { transform: rotate(-360deg); } }
854
854
  @keyframes nd { to { transform: rotate(360deg); } }