notilens 0.3.0 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4f4a0ebf4c82fabed926e4c1f823449339cb5e9318a43b571ed9e0d7e5d959e4
4
- data.tar.gz: 31adaa715cc3936496b80561cde5f7f1f08a551007325802b9bdcd8d9fa25c66
3
+ metadata.gz: fd7625736ecfbf29e8edd2db4793939a9b934b0889decbb9289d57c7d9a5f8f4
4
+ data.tar.gz: ec9b1425f1876f3c995fba3d06706e29ab0971b6770edff89843abdce3a860b3
5
5
  SHA512:
6
- metadata.gz: 24439ee3122debf3bbd68b3d4b6af2aa4d6970ae8addfbbabcef7345692e98d8b693872bdbf5d9ab6466977e2432db0f09d9caab9e589c3aa91a0e13347c7f8a
7
- data.tar.gz: 165602c07940bcae4cf2843d5089b23316b31be68735a4a092fdd2d927470b8c4136d85010f72985495a2b4201546253dc34fc0805e1abf77138c3a86324e538
6
+ metadata.gz: 6015855ee231777d06b19c229edeb9d04d95b5a8d6916ac2deea918f20438119bce87113a6d2649531c904aea5f2f51003f70078de082e6f137c42f72d95a986
7
+ data.tar.gz: 03c43d1eee264f2759fd765d772683125bd6c30ed7704339f1bee04bd91bcf593ca174f777a5f980103a1da235efae9b6eda26c2b222e1a4b79247a7e8ca18e4
data/README.md CHANGED
@@ -19,11 +19,11 @@ gem "notilens"
19
19
  ```ruby
20
20
  require "notilens"
21
21
 
22
- nl = NotiLens.init("my-agent")
23
-
24
- task_id = nl.task_start
25
- nl.task_progress("Processing...", task_id)
26
- nl.task_complete("Done!", task_id)
22
+ nl = NotiLens.init("my-agent")
23
+ run = nl.task("report")
24
+ run.start
25
+ run.progress("Processing...")
26
+ run.complete("Done!")
27
27
  ```
28
28
 
29
29
  ## Credentials
@@ -34,38 +34,50 @@ Resolved in order:
34
34
  3. Saved CLI config (`notilens init --agent ...`)
35
35
 
36
36
  ```ruby
37
- nl = NotiLens.init("my-agent", token: "your-token", secret: "your-secret")
37
+ nl = NotiLens.init("my-agent",
38
+ token: "your-token",
39
+ secret: "your-secret",
40
+ state_ttl: 86400 # optional — orphaned state TTL in seconds (default: 86400)
41
+ )
38
42
  ```
39
43
 
40
44
  ## SDK Reference
41
45
 
42
46
  ### Task Lifecycle
43
47
 
48
+ `nl.task(label)` creates a `Run` — an isolated execution context. Multiple concurrent runs of the same label never conflict.
49
+
44
50
  ```ruby
45
- task_id = nl.task_start # auto-generated ID
46
- task_id = nl.task_start("my-task-123") # custom ID
47
-
48
- nl.task_progress("Fetching data...", task_id)
49
- nl.task_loop("Processing item 42", task_id)
50
- nl.task_retry(task_id)
51
- nl.task_stop(task_id)
52
- nl.task_error("Quota exceeded", task_id) # non-fatal
53
- nl.task_complete("All done!", task_id) # terminal
54
- nl.task_fail("Unrecoverable error", task_id) # terminal
55
- nl.task_timeout("Timed out after 5m", task_id) # terminal
56
- nl.task_cancel("Cancelled by user", task_id) # terminal
57
- nl.task_terminate("Force-killed", task_id) # terminal
51
+ run = nl.task("email") # create a run for the "email" task
52
+ run.queue # optional — pre-start signal
53
+ run.start # begin the run
54
+
55
+ run.progress("Fetching data...")
56
+ run.loop("Processing item 42")
57
+ run.retry
58
+ run.pause("Waiting for rate limit")
59
+ run.resume("Resuming work")
60
+ run.wait("Waiting for tool response")
61
+ run.stop
62
+ run.error("Quota exceeded") # non-fatal, run continues
63
+
64
+ # Terminal — pick one
65
+ run.complete("All done!")
66
+ run.fail("Unrecoverable error")
67
+ run.timeout("Timed out after 5m")
68
+ run.cancel("Cancelled by user")
69
+ run.terminate("Force-killed")
58
70
  ```
59
71
 
60
72
  ### Output & Input Events
61
73
 
62
74
  ```ruby
63
- nl.output_generated("Report ready", task_id)
64
- nl.output_failed("Rendering failed", task_id)
75
+ run.output_generated("Report ready")
76
+ run.output_failed("Rendering failed")
65
77
 
66
- nl.input_required("Approve deployment?", task_id)
67
- nl.input_approved("Approved", task_id)
68
- nl.input_rejected("Rejected", task_id)
78
+ run.input_required("Approve deployment?")
79
+ run.input_approved("Approved")
80
+ run.input_rejected("Rejected")
69
81
  ```
70
82
 
71
83
  ### Metrics
@@ -73,18 +85,53 @@ nl.input_rejected("Rejected", task_id)
73
85
  Numeric values accumulate; strings are replaced.
74
86
 
75
87
  ```ruby
76
- nl.metric("tokens", 512)
77
- nl.metric("tokens", 128) # now 640
88
+ run.metric("tokens", 512)
89
+ run.metric("tokens", 128) # now 640
90
+ run.metric("cost", 0.003)
78
91
 
79
- nl.reset_metrics("tokens") # reset one key
80
- nl.reset_metrics # reset all
92
+ run.reset_metrics("tokens") # reset one key
93
+ run.reset_metrics # reset all
81
94
  ```
82
95
 
96
+ ### Automatic Timing
97
+
98
+ NotiLens automatically tracks task timing. These fields are included in every notification's `meta` payload when non-zero:
99
+
100
+ | Field | Description |
101
+ |-------|-------------|
102
+ | `total_duration_ms` | Wall-clock time since `start` |
103
+ | `queue_ms` | Time between `queue` and `start` |
104
+ | `pause_ms` | Cumulative time spent paused |
105
+ | `wait_ms` | Cumulative time spent waiting |
106
+ | `active_ms` | Active time (`total − pause − wait`) |
107
+
83
108
  ### Generic Events
84
109
 
85
110
  ```ruby
86
- nl.emit("custom.event", "Something happened")
87
- nl.emit("custom.event", "With meta", meta: { "key" => "value" })
111
+ nl.track("custom.event", "Something happened")
112
+ nl.track("custom.event", "With meta", meta: { "key" => "value" })
113
+
114
+ run.track("custom.event", "Run-level event")
115
+ run.track("custom.event", "With meta", meta: { "key" => "value" })
116
+ ```
117
+
118
+ ### Full Example
119
+
120
+ ```ruby
121
+ require "notilens"
122
+
123
+ nl = NotiLens.init("summarizer", token: "TOKEN", secret: "SECRET")
124
+ run = nl.task("report")
125
+ run.start
126
+
127
+ begin
128
+ result = llm.complete(prompt)
129
+ run.metric("tokens", result.usage.total_tokens)
130
+ run.output_generated("Summary ready")
131
+ run.complete("All done!")
132
+ rescue => e
133
+ run.fail(e.message)
134
+ end
88
135
  ```
89
136
 
90
137
  ## CLI
@@ -105,24 +152,41 @@ notilens remove-agent my-agent
105
152
 
106
153
  ### Commands
107
154
 
108
- ```bash
109
- notilens task.start --agent my-agent --task job-123
110
- notilens task.progress "Fetching" --agent my-agent --task job-123
111
- notilens task.loop "Item 5" --agent my-agent --task job-123
112
- notilens task.retry --agent my-agent --task job-123
113
- notilens task.complete "Done!" --agent my-agent --task job-123
114
- notilens task.fail "Error" --agent my-agent --task job-123
115
-
116
- notilens output.generate "Report ready" --agent my-agent --task job-123
117
- notilens input.required "Approve?" --agent my-agent --task job-123
118
-
119
- notilens metric tokens=512 cost=0.003 --agent my-agent --task job-123
120
- notilens metric.reset --agent my-agent --task job-123
155
+ `--task` is a semantic label (e.g. `email`, `report`). Each `task.start` creates an isolated run internally — concurrent executions of the same label never conflict.
121
156
 
122
- notilens emit my.event "msg" --agent my-agent
157
+ ```bash
158
+ notilens queue --agent my-agent --task email
159
+ notilens start --agent my-agent --task email
160
+ notilens progress "Fetching data" --agent my-agent --task email
161
+ notilens loop "Item 5/100" --agent my-agent --task email
162
+ notilens retry --agent my-agent --task email
163
+ notilens pause "Rate limited" --agent my-agent --task email
164
+ notilens resume "Resuming" --agent my-agent --task email
165
+ notilens wait "Awaiting tool" --agent my-agent --task email
166
+ notilens stop --agent my-agent --task email
167
+ notilens error "Quota hit" --agent my-agent --task email
168
+ notilens fail "Fatal error" --agent my-agent --task email
169
+ notilens timeout "Timed out" --agent my-agent --task email
170
+ notilens cancel "Cancelled" --agent my-agent --task email
171
+ notilens terminate "Force stop" --agent my-agent --task email
172
+ notilens complete "Done!" --agent my-agent --task email
173
+
174
+ notilens output.generate "Report ready" --agent my-agent --task email
175
+ notilens output.fail "Render failed" --agent my-agent --task email
176
+ notilens input.required "Approve?" --agent my-agent --task email
177
+ notilens input.approve "Approved" --agent my-agent --task email
178
+ notilens input.reject "Rejected" --agent my-agent --task email
179
+
180
+ notilens metric tokens=512 cost=0.003 --agent my-agent --task email
181
+ notilens metric.reset tokens --agent my-agent --task email
182
+ notilens metric.reset --agent my-agent --task email
183
+
184
+ notilens track my.event "Something happened" --agent my-agent
123
185
  notilens version
124
186
  ```
125
187
 
188
+ `task.start` prints the internal `run_id` to stdout.
189
+
126
190
  ## Requirements
127
191
 
128
192
  - Ruby 2.7+
data/bin/notilens CHANGED
@@ -8,6 +8,7 @@ require "notilens/config"
8
8
  require "notilens/state"
9
9
  require "notilens/notify"
10
10
  require "notilens/version"
11
+ require "securerandom"
11
12
 
12
13
  # ── Helpers ───────────────────────────────────────────────────────────────────
13
14
 
@@ -22,21 +23,21 @@ end
22
23
 
23
24
  def parse_flags(args)
24
25
  f = {
25
- agent: "", task_id: "", type: "", meta: {},
26
+ agent: "", task_label: "", type: "", meta: {},
26
27
  image_url: "", open_url: "", download_url: "",
27
28
  tags: "", is_actionable: ""
28
29
  }
29
30
  i = 0
30
31
  while i < args.length
31
32
  case args[i]
32
- when "--agent" then f[:agent] = args[i += 1].to_s
33
- when "--task" then f[:task_id] = args[i += 1].to_s
34
- when "--type" then f[:type] = args[i += 1].to_s
35
- when "--image_url" then f[:image_url] = args[i += 1].to_s
36
- when "--open_url" then f[:open_url] = args[i += 1].to_s
37
- when "--download_url" then f[:download_url] = args[i += 1].to_s
38
- when "--tags" then f[:tags] = args[i += 1].to_s
39
- when "--is_actionable"then f[:is_actionable] = args[i += 1].to_s
33
+ when "--agent" then f[:agent] = args[i += 1].to_s
34
+ when "--task" then f[:task_label] = args[i += 1].to_s
35
+ when "--type" then f[:type] = args[i += 1].to_s
36
+ when "--image_url" then f[:image_url] = args[i += 1].to_s
37
+ when "--open_url" then f[:open_url] = args[i += 1].to_s
38
+ when "--download_url" then f[:download_url] = args[i += 1].to_s
39
+ when "--tags" then f[:tags] = args[i += 1].to_s
40
+ when "--is_actionable" then f[:is_actionable] = args[i += 1].to_s
40
41
  when "--meta"
41
42
  kv = args[i += 1].to_s
42
43
  eq = kv.index("=")
@@ -48,35 +49,70 @@ def parse_flags(args)
48
49
  $stderr.puts "❌ --agent is required"
49
50
  exit 1
50
51
  end
51
- f[:task_id] = "task_#{NotiLens::State.now_ms}" if f[:task_id].empty?
52
52
  f
53
53
  end
54
54
 
55
- def calc_duration(sf)
56
- NotiLens::State.calc_duration(sf)
55
+ def resolve_run_id(f)
56
+ if f[:task_label].empty?
57
+ $stderr.puts "❌ --task is required"
58
+ exit 1
59
+ end
60
+ run_id = NotiLens::State.read_pointer(f[:agent], f[:task_label])
61
+ if run_id.empty?
62
+ $stderr.puts "❌ No active run for task '#{f[:task_label]}' on agent '#{f[:agent]}'. Run start first."
63
+ exit 1
64
+ end
65
+ run_id
57
66
  end
58
67
 
59
- def send_notify(event, message, f)
68
+ def gen_run_id
69
+ "run_#{NotiLens::State.now_ms}_#{SecureRandom.hex(4)}"
70
+ end
71
+
72
+ def send_notify(event, message, f, run_id)
60
73
  conf = NotiLens::Config.get_agent(f[:agent])
61
74
  if conf.nil? || conf["token"].to_s.empty? || conf["secret"].to_s.empty?
62
75
  $stderr.puts "❌ Agent '#{f[:agent]}' not configured. Run: notilens init --agent #{f[:agent]} --token TOKEN --secret SECRET"
63
76
  exit 1
64
77
  end
65
78
 
66
- sf = NotiLens::State.file(f[:agent], f[:task_id])
79
+ sf = NotiLens::State.file(f[:agent], run_id)
67
80
  state = NotiLens::State.read(sf)
68
81
 
69
82
  meta = { "agent" => f[:agent] }
70
- dur = state["duration_ms"].to_i
71
- rc = state["retry_count"].to_i
72
- lc = state["loop_count"].to_i
73
- meta["duration_ms"] = dur if dur > 0
74
- meta["retry_count"] = rc if rc > 0
75
- meta["loop_count"] = lc if lc > 0
83
+ meta["run_id"] = run_id unless run_id.to_s.empty?
84
+
85
+ unless f[:task_label].empty?
86
+ meta["task"] = f[:task_label]
87
+ now = NotiLens::State.now_ms
88
+ start_time = state["start_time"].to_i
89
+ queued_at = state["queued_at"].to_i
90
+ pause_total = state["pause_total_ms"].to_i
91
+ wait_total = state["wait_total_ms"].to_i
92
+ pause_total += now - state["paused_at"].to_i if state["paused_at"].to_i > 0
93
+ wait_total += now - state["wait_at"].to_i if state["wait_at"].to_i > 0
94
+ total_ms = start_time > 0 ? now - start_time : 0
95
+ queue_ms = (start_time > 0 && queued_at > 0) ? start_time - queued_at : 0
96
+ active_ms = [0, total_ms - pause_total - wait_total].max
97
+
98
+ meta["total_duration_ms"] = total_ms if total_ms > 0
99
+ meta["queue_ms"] = queue_ms if queue_ms > 0
100
+ meta["pause_ms"] = pause_total if pause_total > 0
101
+ meta["wait_ms"] = wait_total if wait_total > 0
102
+ meta["active_ms"] = active_ms if active_ms > 0
103
+ rc = state["retry_count"].to_i; meta["retry_count"] = rc if rc > 0
104
+ lc = state["loop_count"].to_i; meta["loop_count"] = lc if lc > 0
105
+ ec = state["error_count"].to_i; meta["error_count"] = ec if ec > 0
106
+ pc = state["pause_count"].to_i; meta["pause_count"] = pc if pc > 0
107
+ wc = state["wait_count"].to_i; meta["wait_count"] = wc if wc > 0
108
+ end
109
+
76
110
  (state["metrics"] || {}).each { |k, v| meta[k] = v }
77
111
  f[:meta].each { |k, v| meta[k] = v }
78
112
 
79
- title = "#{f[:agent]} | #{f[:task_id]} | #{event}"
113
+ title = f[:task_label].empty? \
114
+ ? "#{f[:agent]} | #{event}" \
115
+ : "#{f[:agent]} | #{f[:task_label]} | #{event}"
80
116
 
81
117
  ev_type = %w[info success warning urgent].include?(f[:type]) \
82
118
  ? f[:type] : NotiLens::Notify.event_type(event)
@@ -93,7 +129,7 @@ def send_notify(event, message, f)
93
129
  "message" => message,
94
130
  "type" => ev_type,
95
131
  "agent" => f[:agent],
96
- "task_id" => f[:task_id],
132
+ "task_id" => f[:task_label],
97
133
  "is_actionable" => is_actionable,
98
134
  "image_url" => f[:image_url],
99
135
  "open_url" => f[:open_url],
@@ -145,109 +181,181 @@ when "remove-agent"
145
181
  ? puts("✔ Agent '#{rest[0]}' removed") \
146
182
  : $stderr.puts("Agent '#{rest[0]}' not found")
147
183
 
148
- when "task.start"
149
- f = parse_flags(rest)
150
- sf = NotiLens::State.file(f[:agent], f[:task_id])
184
+ when "queue"
185
+ f = parse_flags(rest)
186
+ run_id = gen_run_id
187
+ sf = NotiLens::State.file(f[:agent], run_id)
151
188
  NotiLens::State.write(sf, {
152
- "agent" => f[:agent],
153
- "task" => f[:task_id],
154
- "start_time" => NotiLens::State.now_ms,
155
- "retry_count" => 0,
156
- "loop_count" => 0
189
+ "agent" => f[:agent],
190
+ "task" => f[:task_label],
191
+ "run_id" => run_id,
192
+ "queued_at" => NotiLens::State.now_ms,
193
+ "retry_count" => 0,
194
+ "loop_count" => 0,
195
+ "error_count" => 0,
196
+ "pause_count" => 0,
197
+ "wait_count" => 0,
198
+ "pause_total_ms" => 0,
199
+ "wait_total_ms" => 0
200
+ })
201
+ NotiLens::State.write_pointer(f[:agent], f[:task_label], run_id)
202
+ send_notify("task.queued", "Task queued", f, run_id)
203
+ puts run_id
204
+
205
+ when "start"
206
+ f = parse_flags(rest)
207
+ # Reuse run_id from a prior task.queue if available
208
+ run_id = NotiLens::State.read_pointer(f[:agent], f[:task_label])
209
+ run_id = gen_run_id if run_id.empty?
210
+ sf = NotiLens::State.file(f[:agent], run_id)
211
+ existing = NotiLens::State.read(sf)
212
+ if existing.key?("queued_at")
213
+ NotiLens::State.update(sf, { "start_time" => NotiLens::State.now_ms })
214
+ else
215
+ NotiLens::State.write(sf, {
216
+ "agent" => f[:agent],
217
+ "task" => f[:task_label],
218
+ "run_id" => run_id,
219
+ "start_time" => NotiLens::State.now_ms,
220
+ "retry_count" => 0,
221
+ "loop_count" => 0,
222
+ "error_count" => 0,
223
+ "pause_count" => 0,
224
+ "wait_count" => 0,
225
+ "pause_total_ms" => 0,
226
+ "wait_total_ms" => 0
227
+ })
228
+ end
229
+ NotiLens::State.write_pointer(f[:agent], f[:task_label], run_id)
230
+ send_notify("task.started", "Task started", f, run_id)
231
+ puts run_id
232
+
233
+ when "progress"
234
+ pos, rest2 = positional_args(rest)
235
+ f = parse_flags(rest2)
236
+ run_id = resolve_run_id(f)
237
+ send_notify("task.progress", pos[0].to_s, f, run_id)
238
+
239
+ when "loop"
240
+ pos, rest2 = positional_args(rest)
241
+ f = parse_flags(rest2)
242
+ run_id = resolve_run_id(f)
243
+ sf = NotiLens::State.file(f[:agent], run_id)
244
+ count = NotiLens::State.read(sf)["loop_count"].to_i + 1
245
+ NotiLens::State.update(sf, { "loop_count" => count })
246
+ send_notify("task.loop", pos[0].to_s, f, run_id)
247
+
248
+ when "retry"
249
+ f = parse_flags(rest)
250
+ run_id = resolve_run_id(f)
251
+ sf = NotiLens::State.file(f[:agent], run_id)
252
+ count = NotiLens::State.read(sf)["retry_count"].to_i + 1
253
+ NotiLens::State.update(sf, { "retry_count" => count })
254
+ send_notify("task.retry", "Retrying task", f, run_id)
255
+
256
+ when "stop"
257
+ f = parse_flags(rest)
258
+ run_id = resolve_run_id(f)
259
+ send_notify("task.stopped", "Task stopped", f, run_id)
260
+
261
+ when "pause"
262
+ pos, rest2 = positional_args(rest)
263
+ f = parse_flags(rest2)
264
+ run_id = resolve_run_id(f)
265
+ sf = NotiLens::State.file(f[:agent], run_id)
266
+ state = NotiLens::State.read(sf)
267
+ NotiLens::State.update(sf, {
268
+ "paused_at" => NotiLens::State.now_ms,
269
+ "pause_count" => state["pause_count"].to_i + 1
157
270
  })
158
- send_notify("task.started", "Task started", f)
159
- puts "▶ Started: #{f[:agent]} | #{f[:task_id]}"
271
+ send_notify("task.paused", pos[0].to_s, f, run_id)
160
272
 
161
- when "task.progress"
273
+ when "resume"
162
274
  pos, rest2 = positional_args(rest)
163
- f = parse_flags(rest2)
164
- sf = NotiLens::State.file(f[:agent], f[:task_id])
165
- NotiLens::State.update(sf, { "duration_ms" => calc_duration(sf) })
166
- send_notify("task.progress", pos[0].to_s, f)
167
- puts "⏳ Progress: #{f[:agent]} | #{f[:task_id]}"
275
+ f = parse_flags(rest2)
276
+ run_id = resolve_run_id(f)
277
+ sf = NotiLens::State.file(f[:agent], run_id)
278
+ state = NotiLens::State.read(sf)
279
+ now = NotiLens::State.now_ms
280
+ updates = {}
281
+ if state["paused_at"].to_i > 0
282
+ updates["pause_total_ms"] = state["pause_total_ms"].to_i + (now - state["paused_at"].to_i)
283
+ updates["paused_at"] = nil
284
+ end
285
+ if state["wait_at"].to_i > 0
286
+ updates["wait_total_ms"] = state["wait_total_ms"].to_i + (now - state["wait_at"].to_i)
287
+ updates["wait_at"] = nil
288
+ end
289
+ NotiLens::State.update(sf, updates) unless updates.empty?
290
+ send_notify("task.resumed", pos[0].to_s, f, run_id)
168
291
 
169
- when "task.loop"
292
+ when "wait"
170
293
  pos, rest2 = positional_args(rest)
171
- f = parse_flags(rest2)
172
- sf = NotiLens::State.file(f[:agent], f[:task_id])
173
- count = NotiLens::State.read(sf)["loop_count"].to_i + 1
174
- NotiLens::State.update(sf, { "duration_ms" => calc_duration(sf), "loop_count" => count })
175
- send_notify("task.loop", pos[0].to_s, f)
176
- puts "🔄 Loop (#{count}): #{f[:agent]} | #{f[:task_id]}"
177
-
178
- when "task.retry"
179
- f = parse_flags(rest)
180
- sf = NotiLens::State.file(f[:agent], f[:task_id])
181
- count = NotiLens::State.read(sf)["retry_count"].to_i + 1
182
- NotiLens::State.update(sf, { "duration_ms" => calc_duration(sf), "retry_count" => count })
183
- send_notify("task.retry", "Retrying task", f)
184
- puts "🔁 Retry: #{f[:agent]} | #{f[:task_id]}"
185
-
186
- when "task.stop"
187
- f = parse_flags(rest)
188
- sf = NotiLens::State.file(f[:agent], f[:task_id])
189
- dur = calc_duration(sf)
190
- NotiLens::State.update(sf, { "duration_ms" => dur })
191
- send_notify("task.stopped", "Task stopped", f)
192
- puts "⏹ Stopped: #{f[:agent]} | #{f[:task_id]} (#{dur} ms)"
193
-
194
- when "task.error"
294
+ f = parse_flags(rest2)
295
+ run_id = resolve_run_id(f)
296
+ sf = NotiLens::State.file(f[:agent], run_id)
297
+ state = NotiLens::State.read(sf)
298
+ NotiLens::State.update(sf, {
299
+ "wait_at" => NotiLens::State.now_ms,
300
+ "wait_count" => state["wait_count"].to_i + 1
301
+ })
302
+ send_notify("task.waiting", pos[0].to_s, f, run_id)
303
+
304
+ when "error"
195
305
  pos, rest2 = positional_args(rest)
196
- f = parse_flags(rest2)
197
- sf = NotiLens::State.file(f[:agent], f[:task_id])
198
- NotiLens::State.update(sf, { "duration_ms" => calc_duration(sf), "last_error" => pos[0].to_s })
199
- send_notify("task.error", pos[0].to_s, f)
306
+ f = parse_flags(rest2)
307
+ run_id = resolve_run_id(f)
308
+ sf = NotiLens::State.file(f[:agent], run_id)
309
+ state = NotiLens::State.read(sf)
310
+ NotiLens::State.update(sf, { "last_error" => pos[0].to_s, "error_count" => state["error_count"].to_i + 1 })
311
+ send_notify("task.error", pos[0].to_s, f, run_id)
200
312
  $stderr.puts "❌ Error: #{pos[0]}"
201
313
 
202
- when "task.fail"
314
+ when "fail"
203
315
  pos, rest2 = positional_args(rest)
204
- f = parse_flags(rest2)
205
- sf = NotiLens::State.file(f[:agent], f[:task_id])
206
- NotiLens::State.update(sf, { "duration_ms" => calc_duration(sf) })
207
- send_notify("task.failed", pos[0].to_s, f)
208
- NotiLens::State.delete(sf)
209
- puts "💥 Failed: #{f[:agent]} | #{f[:task_id]}"
210
-
211
- when "task.timeout"
316
+ f = parse_flags(rest2)
317
+ run_id = resolve_run_id(f)
318
+ send_notify("task.failed", pos[0].to_s, f, run_id)
319
+ NotiLens::State.delete(NotiLens::State.file(f[:agent], run_id))
320
+ NotiLens::State.delete_pointer(f[:agent], f[:task_label])
321
+
322
+ when "timeout"
212
323
  pos, rest2 = positional_args(rest)
213
- f = parse_flags(rest2)
214
- sf = NotiLens::State.file(f[:agent], f[:task_id])
215
- NotiLens::State.update(sf, { "duration_ms" => calc_duration(sf) })
216
- send_notify("task.timeout", pos[0].to_s, f)
217
- NotiLens::State.delete(sf)
218
- puts "⏰ Timeout: #{f[:agent]} | #{f[:task_id]}"
219
-
220
- when "task.cancel"
324
+ f = parse_flags(rest2)
325
+ run_id = resolve_run_id(f)
326
+ send_notify("task.timeout", pos[0].to_s, f, run_id)
327
+ NotiLens::State.delete(NotiLens::State.file(f[:agent], run_id))
328
+ NotiLens::State.delete_pointer(f[:agent], f[:task_label])
329
+
330
+ when "cancel"
221
331
  pos, rest2 = positional_args(rest)
222
- f = parse_flags(rest2)
223
- sf = NotiLens::State.file(f[:agent], f[:task_id])
224
- NotiLens::State.update(sf, { "duration_ms" => calc_duration(sf) })
225
- send_notify("task.cancelled", pos[0].to_s, f)
226
- NotiLens::State.delete(sf)
227
- puts "🚫 Cancelled: #{f[:agent]} | #{f[:task_id]}"
228
-
229
- when "task.terminate"
332
+ f = parse_flags(rest2)
333
+ run_id = resolve_run_id(f)
334
+ send_notify("task.cancelled", pos[0].to_s, f, run_id)
335
+ NotiLens::State.delete(NotiLens::State.file(f[:agent], run_id))
336
+ NotiLens::State.delete_pointer(f[:agent], f[:task_label])
337
+
338
+ when "terminate"
230
339
  pos, rest2 = positional_args(rest)
231
- f = parse_flags(rest2)
232
- sf = NotiLens::State.file(f[:agent], f[:task_id])
233
- NotiLens::State.update(sf, { "duration_ms" => calc_duration(sf) })
234
- send_notify("task.terminated", pos[0].to_s, f)
235
- NotiLens::State.delete(sf)
236
- puts "⚠ Terminated: #{f[:agent]} | #{f[:task_id]}"
237
-
238
- when "task.complete"
340
+ f = parse_flags(rest2)
341
+ run_id = resolve_run_id(f)
342
+ send_notify("task.terminated", pos[0].to_s, f, run_id)
343
+ NotiLens::State.delete(NotiLens::State.file(f[:agent], run_id))
344
+ NotiLens::State.delete_pointer(f[:agent], f[:task_label])
345
+
346
+ when "complete"
239
347
  pos, rest2 = positional_args(rest)
240
- f = parse_flags(rest2)
241
- sf = NotiLens::State.file(f[:agent], f[:task_id])
242
- NotiLens::State.update(sf, { "duration_ms" => calc_duration(sf) })
243
- send_notify("task.completed", pos[0].to_s, f)
244
- NotiLens::State.delete(sf)
245
- puts "✅ Completed: #{f[:agent]} | #{f[:task_id]}"
348
+ f = parse_flags(rest2)
349
+ run_id = resolve_run_id(f)
350
+ send_notify("task.completed", pos[0].to_s, f, run_id)
351
+ NotiLens::State.delete(NotiLens::State.file(f[:agent], run_id))
352
+ NotiLens::State.delete_pointer(f[:agent], f[:task_label])
246
353
 
247
354
  when "metric"
248
355
  pos, rest2 = positional_args(rest)
249
356
  f = parse_flags(rest2)
250
- sf = NotiLens::State.file(f[:agent], f[:task_id])
357
+ run_id = resolve_run_id(f)
358
+ sf = NotiLens::State.file(f[:agent], run_id)
251
359
  state = NotiLens::State.read(sf)
252
360
  metrics = state["metrics"] || {}
253
361
  pos.each do |kv|
@@ -267,8 +375,9 @@ when "metric"
267
375
 
268
376
  when "metric.reset"
269
377
  pos, rest2 = positional_args(rest)
270
- f = parse_flags(rest2)
271
- sf = NotiLens::State.file(f[:agent], f[:task_id])
378
+ f = parse_flags(rest2)
379
+ run_id = resolve_run_id(f)
380
+ sf = NotiLens::State.file(f[:agent], run_id)
272
381
  if pos[0]
273
382
  state = NotiLens::State.read(sf)
274
383
  metrics = state["metrics"] || {}
@@ -282,36 +391,46 @@ when "metric.reset"
282
391
 
283
392
  when "output.generate"
284
393
  pos, rest2 = positional_args(rest)
285
- send_notify("output.generated", pos[0].to_s, parse_flags(rest2))
394
+ f = parse_flags(rest2)
395
+ run_id = resolve_run_id(f)
396
+ send_notify("output.generated", pos[0].to_s, f, run_id)
286
397
 
287
398
  when "output.fail"
288
399
  pos, rest2 = positional_args(rest)
289
- send_notify("output.failed", pos[0].to_s, parse_flags(rest2))
400
+ f = parse_flags(rest2)
401
+ run_id = resolve_run_id(f)
402
+ send_notify("output.failed", pos[0].to_s, f, run_id)
290
403
 
291
404
  when "input.required"
292
405
  pos, rest2 = positional_args(rest)
293
- send_notify("input.required", pos[0].to_s, parse_flags(rest2))
406
+ f = parse_flags(rest2)
407
+ run_id = resolve_run_id(f)
408
+ send_notify("input.required", pos[0].to_s, f, run_id)
294
409
 
295
410
  when "input.approve"
296
411
  pos, rest2 = positional_args(rest)
297
- send_notify("input.approved", pos[0].to_s, parse_flags(rest2))
412
+ f = parse_flags(rest2)
413
+ run_id = resolve_run_id(f)
414
+ send_notify("input.approved", pos[0].to_s, f, run_id)
298
415
 
299
416
  when "input.reject"
300
417
  pos, rest2 = positional_args(rest)
301
- send_notify("input.rejected", pos[0].to_s, parse_flags(rest2))
418
+ f = parse_flags(rest2)
419
+ run_id = resolve_run_id(f)
420
+ send_notify("input.rejected", pos[0].to_s, f, run_id)
302
421
 
303
- when "emit"
422
+ when "track"
304
423
  if rest.length < 2
305
- $stderr.puts "Usage: notilens emit <event> <message> --agent <agent>"
424
+ $stderr.puts "Usage: notilens track <event> <message> --agent <agent>"
306
425
  exit 1
307
426
  end
308
- event = rest[0]
309
- msg = rest[1]
310
- f = parse_flags(rest[2..])
311
- sf = NotiLens::State.file(f[:agent], f[:task_id])
312
- NotiLens::State.update(sf, { "duration_ms" => calc_duration(sf) })
313
- send_notify(event, msg, f)
314
- puts "📡 Event emitted: #{event}"
427
+ event = rest[0]
428
+ msg = rest[1]
429
+ f = parse_flags(rest[2..])
430
+ # track is agent-level; use pointer if available but don't error if absent
431
+ run_id = NotiLens::State.read_pointer(f[:agent], f[:task_label])
432
+ send_notify(event, msg, f, run_id)
433
+ puts "📡 Tracked: #{event}"
315
434
 
316
435
  when "version"
317
436
  puts "NotiLens v#{NotiLens::VERSION}"
@@ -324,36 +443,40 @@ else
324
443
  notilens remove-agent <agent>
325
444
 
326
445
  Task Lifecycle:
327
- notilens task.start --agent <agent> [--task <id>]
328
- notilens task.progress "msg" --agent <agent> [--task <id>]
329
- notilens task.loop "msg" --agent <agent> [--task <id>]
330
- notilens task.retry --agent <agent> [--task <id>]
331
- notilens task.stop --agent <agent> [--task <id>]
332
- notilens task.error "msg" --agent <agent> [--task <id>]
333
- notilens task.fail "msg" --agent <agent> [--task <id>]
334
- notilens task.timeout "msg" --agent <agent> [--task <id>]
335
- notilens task.cancel "msg" --agent <agent> [--task <id>]
336
- notilens task.terminate "msg" --agent <agent> [--task <id>]
337
- notilens task.complete "msg" --agent <agent> [--task <id>]
446
+ notilens queue --agent <agent> --task <label>
447
+ notilens start --agent <agent> --task <label>
448
+ notilens progress "msg" --agent <agent> --task <label>
449
+ notilens loop "msg" --agent <agent> --task <label>
450
+ notilens retry --agent <agent> --task <label>
451
+ notilens stop --agent <agent> --task <label>
452
+ notilens pause "msg" --agent <agent> --task <label>
453
+ notilens resume "msg" --agent <agent> --task <label>
454
+ notilens wait "msg" --agent <agent> --task <label>
455
+ notilens error "msg" --agent <agent> --task <label>
456
+ notilens fail "msg" --agent <agent> --task <label>
457
+ notilens timeout "msg" --agent <agent> --task <label>
458
+ notilens cancel "msg" --agent <agent> --task <label>
459
+ notilens terminate "msg" --agent <agent> --task <label>
460
+ notilens complete "msg" --agent <agent> --task <label>
338
461
 
339
462
  Output / Input:
340
- notilens output.generate "msg" --agent <agent> [--task <id>]
341
- notilens output.fail "msg" --agent <agent> [--task <id>]
342
- notilens input.required "msg" --agent <agent> [--task <id>]
343
- notilens input.approve "msg" --agent <agent> [--task <id>]
344
- notilens input.reject "msg" --agent <agent> [--task <id>]
463
+ notilens output.generate "msg" --agent <agent> --task <label>
464
+ notilens output.fail "msg" --agent <agent> --task <label>
465
+ notilens input.required "msg" --agent <agent> --task <label>
466
+ notilens input.approve "msg" --agent <agent> --task <label>
467
+ notilens input.reject "msg" --agent <agent> --task <label>
345
468
 
346
469
  Metrics:
347
- notilens metric tokens=512 cost=0.003 --agent <agent> --task <id>
348
- notilens metric.reset tokens --agent <agent> --task <id>
349
- notilens metric.reset --agent <agent> --task <id>
470
+ notilens metric tokens=512 cost=0.003 --agent <agent> --task <label>
471
+ notilens metric.reset tokens --agent <agent> --task <label>
472
+ notilens metric.reset --agent <agent> --task <label>
350
473
 
351
474
  Generic:
352
- notilens emit <event> "msg" --agent <agent>
475
+ notilens track <event> "msg" --agent <agent>
353
476
 
354
477
  Options:
355
478
  --agent <name>
356
- --task <id>
479
+ --task <label>
357
480
  --type success|warning|urgent|info
358
481
  --meta key=value (repeatable)
359
482
  --image_url <url>
@@ -9,7 +9,7 @@ module NotiLens
9
9
 
10
10
  SUCCESS_EVENTS = %w[task.completed output.generated input.approved].freeze
11
11
  URGENT_EVENTS = %w[task.failed task.timeout task.error task.terminated output.failed].freeze
12
- WARNING_EVENTS = %w[task.retry task.cancelled input.required input.rejected].freeze
12
+ WARNING_EVENTS = %w[task.retry task.cancelled task.paused task.waiting input.required input.rejected].freeze
13
13
  ACTIONABLE_EVENTS = %w[
14
14
  task.error task.failed task.timeout task.retry task.loop
15
15
  output.failed input.required input.rejected
@@ -37,11 +37,12 @@ module NotiLens
37
37
  req.body = body
38
38
 
39
39
  http = Net::HTTP.new(uri.host, uri.port)
40
- http.use_ssl = true
40
+ http.use_ssl = true
41
41
  http.read_timeout = 10
42
42
  http.open_timeout = 10
43
+
43
44
  http.request(req)
44
- rescue
45
+ rescue StandardError
45
46
  nil
46
47
  end
47
48
  end
@@ -3,11 +3,21 @@ require "tmpdir"
3
3
 
4
4
  module NotiLens
5
5
  module State
6
- def self.file(agent, task_id)
7
- user = ENV["USER"] || ENV["USERNAME"] || ""
6
+ def self.os_user
7
+ ENV["USER"] || ENV["USERNAME"] || ""
8
+ end
9
+
10
+ def self.file(agent, run_id)
11
+ user = os_user
8
12
  agent = agent.gsub(File::SEPARATOR, "_")
9
- task_id = task_id.gsub(File::SEPARATOR, "_")
10
- File.join(Dir.tmpdir, "notilens_#{user}_#{agent}_#{task_id}.json")
13
+ run_id = run_id.gsub(File::SEPARATOR, "_")
14
+ File.join(Dir.tmpdir, "notilens_#{user}_#{agent}_#{run_id}.json")
15
+ end
16
+
17
+ def self.pointer_file(agent, label)
18
+ user = os_user
19
+ safe_label = label.gsub("/", "_").gsub("\\", "_")
20
+ File.join(Dir.tmpdir, "notilens_#{user}_#{agent}_#{safe_label}.ptr")
11
21
  end
12
22
 
13
23
  def self.read(path)
@@ -18,8 +28,10 @@ module NotiLens
18
28
  end
19
29
 
20
30
  def self.write(path, data)
21
- File.write(path, JSON.pretty_generate(data))
22
- File.chmod(0600, path)
31
+ tmp = path + ".tmp"
32
+ File.write(tmp, JSON.pretty_generate(data))
33
+ File.chmod(0600, tmp)
34
+ File.rename(tmp, path)
23
35
  rescue
24
36
  nil
25
37
  end
@@ -36,11 +48,38 @@ module NotiLens
36
48
  nil
37
49
  end
38
50
 
39
- def self.calc_duration(path)
40
- s = read(path)
41
- start = s["start_time"].to_i
42
- return 0 if start == 0
43
- (Time.now.to_f * 1000).to_i - start
51
+ def self.read_pointer(agent, label)
52
+ path = pointer_file(agent, label)
53
+ return "" unless File.exist?(path)
54
+ File.read(path).strip
55
+ rescue
56
+ ""
57
+ end
58
+
59
+ def self.write_pointer(agent, label, run_id)
60
+ File.write(pointer_file(agent, label), run_id)
61
+ rescue
62
+ nil
63
+ end
64
+
65
+ def self.delete_pointer(agent, label)
66
+ path = pointer_file(agent, label)
67
+ File.delete(path) if File.exist?(path)
68
+ rescue
69
+ nil
70
+ end
71
+
72
+ def self.cleanup_stale(agent, state_ttl_seconds)
73
+ user = os_user
74
+ tmp = Dir.tmpdir
75
+ cutoff = Time.now.to_f - state_ttl_seconds
76
+ prefix = "notilens_#{user}_#{agent}_"
77
+ Dir.entries(tmp).each do |name|
78
+ next unless name.start_with?(prefix)
79
+ next unless name.end_with?(".json") || name.end_with?(".ptr")
80
+ path = File.join(tmp, name)
81
+ File.delete(path) if File.mtime(path).to_f < cutoff rescue nil
82
+ end
44
83
  end
45
84
 
46
85
  def self.now_ms
@@ -1,3 +1,3 @@
1
1
  module NotiLens
2
- VERSION = "0.3.0"
2
+ VERSION = "0.4.1"
3
3
  end
data/lib/notilens.rb CHANGED
@@ -4,24 +4,15 @@ require_relative "notilens/state"
4
4
  require_relative "notilens/notify"
5
5
 
6
6
  module NotiLens
7
- class Client
8
- def initialize(agent, token: nil, secret: nil)
9
- @agent = agent
10
- @token = token || ENV["NOTILENS_TOKEN"] || ""
11
- @secret = secret || ENV["NOTILENS_SECRET"] || ""
7
+ class Run
8
+ attr_reader :run_id, :label
9
+
10
+ def initialize(agent_obj, label, run_id)
11
+ @agent = agent_obj
12
+ @label = label
13
+ @run_id = run_id
14
+ @sf = State.file(agent_obj.agent_name, run_id)
12
15
  @metrics = {}
13
-
14
- if @token.empty? || @secret.empty?
15
- conf = Config.get_agent(agent) || {}
16
- @token = conf["token"] if @token.empty?
17
- @secret = conf["secret"] if @secret.empty?
18
- end
19
-
20
- if @token.to_s.empty? || @secret.to_s.empty?
21
- raise "NotiLens: token and secret are required. Pass them directly, " \
22
- "set NOTILENS_TOKEN/NOTILENS_SECRET env vars, or run: " \
23
- "notilens init --agent #{agent} --token TOKEN --secret SECRET"
24
- end
25
16
  end
26
17
 
27
18
  # ── Metrics ────────────────────────────────────────────────────────────────
@@ -40,142 +31,259 @@ module NotiLens
40
31
  self
41
32
  end
42
33
 
43
- # ── Task lifecycle ─────────────────────────────────────────────────────────
44
-
45
- def task_start(task_id = nil)
46
- id = task_id.to_s.empty? ? "task_#{State.now_ms}" : task_id
47
- sf = State.file(@agent, id)
48
- State.write(sf, {
49
- "agent" => @agent,
50
- "task" => id,
51
- "start_time" => State.now_ms,
52
- "retry_count" => 0,
53
- "loop_count" => 0
34
+ # ── Lifecycle ──────────────────────────────────────────────────────────────
35
+
36
+ def queue
37
+ State.write(@sf, {
38
+ "agent" => @agent.agent_name,
39
+ "task" => @label,
40
+ "run_id" => @run_id,
41
+ "queued_at" => State.now_ms,
42
+ "retry_count" => 0,
43
+ "loop_count" => 0,
44
+ "error_count" => 0,
45
+ "pause_count" => 0,
46
+ "wait_count" => 0,
47
+ "pause_total_ms" => 0,
48
+ "wait_total_ms" => 0
54
49
  })
55
- send_event("task.started", "Task started", id)
56
- id
50
+ send_event("task.queued", "Task queued")
51
+ self
52
+ end
53
+
54
+ def start
55
+ now = State.now_ms
56
+ existing = State.read(@sf)
57
+ if existing.key?("queued_at")
58
+ State.update(@sf, { "start_time" => now })
59
+ else
60
+ State.write(@sf, {
61
+ "agent" => @agent.agent_name,
62
+ "task" => @label,
63
+ "run_id" => @run_id,
64
+ "start_time" => now,
65
+ "retry_count" => 0,
66
+ "loop_count" => 0,
67
+ "error_count" => 0,
68
+ "pause_count" => 0,
69
+ "wait_count" => 0,
70
+ "pause_total_ms" => 0,
71
+ "wait_total_ms" => 0
72
+ })
73
+ end
74
+ send_event("task.started", "Task started")
75
+ self
57
76
  end
58
77
 
59
- def task_progress(message, task_id)
60
- sf = State.file(@agent, task_id)
61
- State.update(sf, { "duration_ms" => State.calc_duration(sf) })
62
- send_event("task.progress", message, task_id)
78
+ def progress(message) = send_event("task.progress", message)
79
+
80
+ def loop(message)
81
+ s = State.read(@sf)
82
+ State.update(@sf, { "loop_count" => s["loop_count"].to_i + 1 })
83
+ send_event("task.loop", message)
63
84
  end
64
85
 
65
- def task_loop(message, task_id)
66
- sf = State.file(@agent, task_id)
67
- s = State.read(sf)
68
- count = s["loop_count"].to_i + 1
69
- State.update(sf, { "duration_ms" => State.calc_duration(sf), "loop_count" => count })
70
- send_event("task.loop", message, task_id)
86
+ def retry
87
+ s = State.read(@sf)
88
+ State.update(@sf, { "retry_count" => s["retry_count"].to_i + 1 })
89
+ send_event("task.retry", "Retrying task")
71
90
  end
72
91
 
73
- def task_retry(task_id)
74
- sf = State.file(@agent, task_id)
75
- s = State.read(sf)
76
- count = s["retry_count"].to_i + 1
77
- State.update(sf, { "duration_ms" => State.calc_duration(sf), "retry_count" => count })
78
- send_event("task.retry", "Retrying task", task_id)
92
+ def pause(message)
93
+ s = State.read(@sf)
94
+ State.update(@sf, {
95
+ "paused_at" => State.now_ms,
96
+ "pause_count" => s["pause_count"].to_i + 1
97
+ })
98
+ send_event("task.paused", message)
79
99
  end
80
100
 
81
- def task_error(message, task_id)
82
- sf = State.file(@agent, task_id)
83
- State.update(sf, { "duration_ms" => State.calc_duration(sf), "last_error" => message })
84
- send_event("task.error", message, task_id)
101
+ def resume(message)
102
+ s = State.read(@sf)
103
+ now = State.now_ms
104
+ updates = {}
105
+ if s["paused_at"].to_i > 0
106
+ updates["pause_total_ms"] = s["pause_total_ms"].to_i + (now - s["paused_at"].to_i)
107
+ updates["paused_at"] = nil
108
+ end
109
+ if s["wait_at"].to_i > 0
110
+ updates["wait_total_ms"] = s["wait_total_ms"].to_i + (now - s["wait_at"].to_i)
111
+ updates["wait_at"] = nil
112
+ end
113
+ State.update(@sf, updates) unless updates.empty?
114
+ send_event("task.resumed", message)
85
115
  end
86
116
 
87
- def task_complete(message, task_id)
88
- sf = State.file(@agent, task_id)
89
- State.update(sf, { "duration_ms" => State.calc_duration(sf) })
90
- send_event("task.completed", message, task_id)
91
- State.delete(sf)
117
+ def wait(message)
118
+ s = State.read(@sf)
119
+ State.update(@sf, {
120
+ "wait_at" => State.now_ms,
121
+ "wait_count" => s["wait_count"].to_i + 1
122
+ })
123
+ send_event("task.waiting", message)
92
124
  end
93
125
 
94
- def task_fail(message, task_id)
95
- sf = State.file(@agent, task_id)
96
- State.update(sf, { "duration_ms" => State.calc_duration(sf) })
97
- send_event("task.failed", message, task_id)
98
- State.delete(sf)
126
+ def stop = send_event("task.stopped", "Task stopped")
127
+
128
+ def error(message)
129
+ s = State.read(@sf)
130
+ State.update(@sf, { "last_error" => message, "error_count" => s["error_count"].to_i + 1 })
131
+ send_event("task.error", message)
99
132
  end
100
133
 
101
- def task_timeout(message, task_id)
102
- sf = State.file(@agent, task_id)
103
- State.update(sf, { "duration_ms" => State.calc_duration(sf) })
104
- send_event("task.timeout", message, task_id)
105
- State.delete(sf)
134
+ def complete(message)
135
+ send_event("task.completed", message)
136
+ terminal
106
137
  end
107
138
 
108
- def task_cancel(message, task_id)
109
- sf = State.file(@agent, task_id)
110
- State.update(sf, { "duration_ms" => State.calc_duration(sf) })
111
- send_event("task.cancelled", message, task_id)
112
- State.delete(sf)
139
+ def fail(message)
140
+ send_event("task.failed", message)
141
+ terminal
113
142
  end
114
143
 
115
- def task_stop(task_id)
116
- sf = State.file(@agent, task_id)
117
- State.update(sf, { "duration_ms" => State.calc_duration(sf) })
118
- send_event("task.stopped", "Task stopped", task_id)
144
+ def timeout(message)
145
+ send_event("task.timeout", message)
146
+ terminal
119
147
  end
120
148
 
121
- def task_terminate(message, task_id)
122
- sf = State.file(@agent, task_id)
123
- State.update(sf, { "duration_ms" => State.calc_duration(sf) })
124
- send_event("task.terminated", message, task_id)
125
- State.delete(sf)
149
+ def cancel(message)
150
+ send_event("task.cancelled", message)
151
+ terminal
126
152
  end
127
153
 
128
- # ── Input events ───────────────────────────────────────────────────────────
154
+ def terminate(message)
155
+ send_event("task.terminated", message)
156
+ terminal
157
+ end
129
158
 
130
- def input_required(message, task_id)
131
- send_event("input.required", message, task_id)
159
+ # ── Input / Output ─────────────────────────────────────────────────────────
160
+
161
+ def input_required(message) = send_event("input.required", message)
162
+ def input_approved(message) = send_event("input.approved", message)
163
+ def input_rejected(message) = send_event("input.rejected", message)
164
+ def output_generated(message) = send_event("output.generated", message)
165
+ def output_failed(message) = send_event("output.failed", message)
166
+
167
+ def track(event, message, meta: {})
168
+ send_event(event, message, extra_meta: meta)
132
169
  end
133
170
 
134
- def input_approved(message, task_id)
135
- send_event("input.approved", message, task_id)
171
+ private
172
+
173
+ def send_event(event, message, extra_meta: {})
174
+ @agent.send_payload(event, message, @run_id, @label, @sf, @metrics, extra_meta)
136
175
  end
137
176
 
138
- def input_rejected(message, task_id)
139
- send_event("input.rejected", message, task_id)
177
+ def terminal
178
+ State.delete(@sf)
179
+ State.delete_pointer(@agent.agent_name, @label)
140
180
  end
181
+ end
182
+
183
+ class Client
184
+ attr_reader :agent_name
141
185
 
142
- # ── Output events ──────────────────────────────────────────────────────────
186
+ def initialize(agent, token: nil, secret: nil, state_ttl: 86400)
187
+ @agent_name = agent
188
+ @token = token || ENV["NOTILENS_TOKEN"] || ""
189
+ @secret = secret || ENV["NOTILENS_SECRET"] || ""
190
+ @state_ttl = state_ttl
191
+ @metrics = {}
143
192
 
144
- def output_generated(message, task_id)
145
- send_event("output.generated", message, task_id)
193
+ if @token.empty? || @secret.empty?
194
+ conf = Config.get_agent(agent) || {}
195
+ @token = conf["token"] if @token.empty?
196
+ @secret = conf["secret"] if @secret.empty?
197
+ end
198
+
199
+ if @token.to_s.empty? || @secret.to_s.empty?
200
+ raise "NotiLens: token and secret are required. Pass them directly, " \
201
+ "set NOTILENS_TOKEN/NOTILENS_SECRET env vars, or run: " \
202
+ "notilens init --agent #{agent} --token TOKEN --secret SECRET"
203
+ end
204
+
205
+ State.cleanup_stale(agent, state_ttl)
206
+
207
+ # Single background worker — one thread shared across all sends
208
+ @_queue = SizedQueue.new(256)
209
+ token_w = @token
210
+ secret_w = @secret
211
+ @_worker = Thread.new do
212
+ while (job = @_queue.pop)
213
+ Notify.send(token_w, secret_w, job) rescue nil
214
+ end
215
+ end
146
216
  end
147
217
 
148
- def output_failed(message, task_id)
149
- send_event("output.failed", message, task_id)
218
+ # ── Task factory ───────────────────────────────────────────────────────────
219
+
220
+ def task(label)
221
+ State.cleanup_stale(@agent_name, @state_ttl)
222
+ Run.new(self, label, gen_run_id)
150
223
  end
151
224
 
152
- # ── Generic emit ───────────────────────────────────────────────────────────
225
+ # ── Agent-level metrics ────────────────────────────────────────────────────
153
226
 
154
- def emit(event, message, meta: {})
155
- send_event(event, message, "", extra_meta: meta)
227
+ def metric(key, value)
228
+ if value.is_a?(Numeric) && @metrics[key].is_a?(Numeric)
229
+ @metrics[key] += value
230
+ else
231
+ @metrics[key] = value
232
+ end
233
+ self
156
234
  end
157
235
 
158
- private
236
+ def reset_metrics(key = nil)
237
+ key ? @metrics.delete(key) : @metrics.clear
238
+ self
239
+ end
159
240
 
160
- def send_event(event, message, task_id, extra_meta: {})
161
- title = task_id.to_s.empty? \
162
- ? "#{@agent} | #{event}" \
163
- : "#{@agent} | #{task_id} | #{event}"
164
-
165
- duration = retry_count = loop_count = 0
166
- unless task_id.to_s.empty?
167
- sf = State.file(@agent, task_id)
168
- s = State.read(sf)
169
- duration = s["duration_ms"].to_i
170
- retry_count = s["retry_count"].to_i
171
- loop_count = s["loop_count"].to_i
241
+ # ── Generic track ──────────────────────────────────────────────────────────
242
+
243
+ def track(event, message, meta: {})
244
+ send_payload(event, message, "", "", "", @metrics, meta)
245
+ end
246
+
247
+ # ── Internal (called by Run) ───────────────────────────────────────────────
248
+
249
+ def send_payload(event, message, run_id, label, state_file, run_metrics, extra_meta = {})
250
+ title = label.to_s.empty? \
251
+ ? "#{@agent_name} | #{event}" \
252
+ : "#{@agent_name} | #{label} | #{event}"
253
+
254
+ meta = { "agent" => @agent_name }
255
+ meta["run_id"] = run_id unless run_id.to_s.empty?
256
+ meta["task"] = label unless label.to_s.empty?
257
+
258
+ unless state_file.to_s.empty?
259
+ state = State.read(state_file)
260
+ now = State.now_ms
261
+ start_time = state["start_time"].to_i
262
+ queued_at = state["queued_at"].to_i
263
+ pause_total = state["pause_total_ms"].to_i
264
+ wait_total = state["wait_total_ms"].to_i
265
+ pause_total += now - state["paused_at"].to_i if state["paused_at"].to_i > 0
266
+ wait_total += now - state["wait_at"].to_i if state["wait_at"].to_i > 0
267
+
268
+ total_ms = start_time > 0 ? now - start_time : 0
269
+ queue_ms = (start_time > 0 && queued_at > 0) ? start_time - queued_at : 0
270
+ active_ms = [0, total_ms - pause_total - wait_total].max
271
+
272
+ meta["total_duration_ms"] = total_ms if total_ms > 0
273
+ meta["queue_ms"] = queue_ms if queue_ms > 0
274
+ meta["pause_ms"] = pause_total if pause_total > 0
275
+ meta["wait_ms"] = wait_total if wait_total > 0
276
+ meta["active_ms"] = active_ms if active_ms > 0
277
+
278
+ rc = state["retry_count"].to_i; meta["retry_count"] = rc if rc > 0
279
+ lc = state["loop_count"].to_i; meta["loop_count"] = lc if lc > 0
280
+ ec = state["error_count"].to_i; meta["error_count"] = ec if ec > 0
281
+ pc = state["pause_count"].to_i; meta["pause_count"] = pc if pc > 0
282
+ wc = state["wait_count"].to_i; meta["wait_count"] = wc if wc > 0
283
+ (state["metrics"] || {}).each { |k, v| meta[k] = v }
172
284
  end
173
285
 
174
- meta = { "agent" => @agent }
175
- meta["duration_ms"] = duration if duration > 0
176
- meta["retry_count"] = retry_count if retry_count > 0
177
- meta["loop_count"] = loop_count if loop_count > 0
178
- @metrics.each { |k, v| meta[k] = v }
286
+ run_metrics.each { |k, v| meta[k] = v }
179
287
  meta.merge!(extra_meta)
180
288
 
181
289
  image_url = meta.delete("image_url").to_s
@@ -191,8 +299,8 @@ module NotiLens
191
299
  "title" => title,
192
300
  "message" => message,
193
301
  "type" => Notify.event_type(event),
194
- "agent" => @agent,
195
- "task_id" => task_id,
302
+ "agent" => @agent_name,
303
+ "task_id" => label,
196
304
  "is_actionable" => is_actionable,
197
305
  "image_url" => image_url,
198
306
  "open_url" => open_url,
@@ -202,12 +310,21 @@ module NotiLens
202
310
  "meta" => meta
203
311
  }
204
312
 
205
- Notify.send(@token, @secret, payload)
313
+ # Fire-and-forget — push to background worker queue, never blocks the caller
314
+ @_queue.push(payload)
315
+ end
316
+
317
+ private
318
+
319
+ def gen_run_id
320
+ "run_#{State.now_ms}_#{SecureRandom.hex(4)}"
206
321
  end
207
322
  end
208
323
 
209
- # Convenience factory: NotiLens.init("agent")
210
- def self.init(agent, token: nil, secret: nil)
211
- Client.new(agent, token: token, secret: secret)
324
+ # Convenience factory
325
+ def self.init(agent, token: nil, secret: nil, state_ttl: 86400)
326
+ Client.new(agent, token: token, secret: secret, state_ttl: state_ttl)
212
327
  end
213
328
  end
329
+
330
+ require "securerandom"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: notilens
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - NotiLens
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-26 00:00:00.000000000 Z
11
+ date: 2026-03-27 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Send task started/progress/complete/failed notifications from AI agents,
14
14
  Sidekiq workers, and any Ruby application.