space-architect 9.1.0 → 9.2.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.
@@ -0,0 +1,425 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require "find"
6
+
7
+ module Space
8
+ module Architect
9
+ # looplog — follow every active pi-subagents run in a space, live. A
10
+ # faithful port of the pi-port bin/looplog (the byte-compat oracle; same
11
+ # display bytes), module-ized under Space::Architect.
12
+ #
13
+ # architect looplog # follow active runs as they stream
14
+ # architect looplog --list # inventory only (active/age/size/agent per run)
15
+ # architect looplog --replay # print existing history on adoption, then follow
16
+ # architect looplog --all # adopt every transcript, however stale
17
+ # architect looplog my-space # default: nearest ancestor with space.yaml
18
+ #
19
+ # The source is pi-subagents' artifact transcripts —
20
+ # <session-dir>/**/subagent-artifacts/<runid>_<agent>_transcript.jsonl,
21
+ # nested session dirs included — from TWO roots: pi's default sessions dir
22
+ # for the space slug (agents spawned with cwd = the space) AND the space's
23
+ # build/ tree (the loop launches coordinators from headless
24
+ # `pi -p --session-dir build/loop/<launch>/` bootstraps, so the coordinator,
25
+ # its spec/builder/judge children, and deeper descendants all live inside
26
+ # the space — lane worktrees are pruned). Records are message-granularity:
27
+ # runlog's display discipline carries over — thinking dim, assistant text
28
+ # plain, tool calls one-lined with their args, results one-lined with a
29
+ # line count, ✓ done / ✗ failed with usage summed across the exchange, and
30
+ # truncation at RUNLOG_LIMIT (default 2000) characters. Followed lines carry
31
+ # the record's own timestamp (HH:MM:SS local) after the run tag; --list
32
+ # shows last-activity HH:MM beside the age. Thinking text is NOT truncated —
33
+ # set RUNLOG_THINK to clamp it if a run goes feral.
34
+ module Looplog
35
+ DEFAULT_LIMIT = 2000
36
+ WINDOW_DEFAULT = 900 # seconds: staleness threshold
37
+
38
+ class << self
39
+ # Inventory only: one line per transcript, mtime desc, no adoption.
40
+ def list(space:, io: $stdout, env: ENV)
41
+ Follower.new(space: space, io: io, env: env, replay: false, all: false, list_only: true).scan
42
+ end
43
+
44
+ # Follow active runs live; never returns on its own (Interrupt exits
45
+ # quietly inside Follower#run).
46
+ def follow(space:, io: $stdout, env: ENV, replay: false, all: false)
47
+ Follower.new(space: space, io: io, env: env, replay: replay, all: all).run
48
+ end
49
+ end
50
+
51
+ # Shared paint/oneline/discovery helpers, ported verbatim from the oracle
52
+ # script. Color and the truncation budgets arrive per call (env/io), not
53
+ # as load-time globals like the script's.
54
+ module Support
55
+ ROLE_ABBREV = {
56
+ "architect-coordinator" => "coord",
57
+ "architect-spec" => "spec",
58
+ "architect-judge" => "judge",
59
+ "architect-builder" => "bld"
60
+ }.freeze
61
+
62
+ TAG_COLORS = %w[36 35 33 32 34 95 96 91].freeze
63
+
64
+ private
65
+
66
+ def paint(text, code)
67
+ @color ? "\e[#{code}m#{text}\e[0m" : text
68
+ end
69
+
70
+ def dim(s) = paint(s, "2")
71
+ def bold(s) = paint(s, "1")
72
+ def cyan(s) = paint(s, "36")
73
+ def green(s) = paint(s, "32")
74
+ def red(s) = paint(s, "31")
75
+
76
+ # Collapses whitespace and clips, so a tool argument stays on one line.
77
+ def oneline(value, limit)
78
+ s = value.to_s.gsub(/\s+/, " ").strip
79
+ s.length > limit ? "#{s[0, limit]}…" : s
80
+ end
81
+
82
+ def clip(text, limit = nil)
83
+ return text if limit.nil? || text.length <= limit
84
+
85
+ "#{text[0, limit]}…"
86
+ end
87
+
88
+ def sessions_root(env)
89
+ env["PI_SESSIONS_DIR"] || File.join(Dir.home, ".pi", "agent", "sessions")
90
+ end
91
+
92
+ # pi names a session dir after the project root, dash-cased: the absolute
93
+ # path's leading slash contributes one leading dash, pi adds one more, and a
94
+ # trailing pair closes it (verified: …/20261009-pi-port → --Users-eric-
95
+ # architect-spaces-20261009-pi-port--; /private/tmp/tart-bang-smoke →
96
+ # --private-tmp-tart-bang-smoke--).
97
+ def session_slug(path)
98
+ "-#{path.tr('/', '-')}--"
99
+ end
100
+
101
+ # Two roots, deduped by path: pi's default sessions dir for the space slug
102
+ # (agents spawned with cwd = the space) and the space's build/ tree (the
103
+ # loop's headless --session-dir bootstraps root their whole transcript trees
104
+ # there). Lane worktrees are the only huge subtrees under build/ — pruned.
105
+ def discover(space, env)
106
+ roots = [File.join(sessions_root(env), session_slug(space.to_s)), File.join(space.to_s, "build")]
107
+ .select { |root| File.directory?(root) }
108
+ .flat_map { |root| scan_root(root) }
109
+ roots.uniq { |t| t.path }
110
+ end
111
+
112
+ def scan_root(root)
113
+ Find.find(root).filter_map do |p| # paths:exempt — walks the foreign pi sessions corpus and the space's build/ tree (nested session dirs included); any-depth discovery of *_transcript.jsonl IS this module's contract, mirroring the bin/looplog oracle
114
+ if File.directory?(p)
115
+ Find.prune if File.basename(p) == "wt"
116
+ next
117
+ end
118
+ next unless p.end_with?("_transcript.jsonl")
119
+
120
+ base = File.basename(p, ".jsonl").delete_suffix("_transcript")
121
+ run_id, agent = base.split("_", 2)
122
+ next unless run_id && agent
123
+
124
+ st = File.stat(p)
125
+ Transcript.new(p, run_id, agent, st.mtime.to_f, st.size, nil, nil)
126
+ end
127
+ end
128
+
129
+ # Bounded backscan for the newest "Task:" user record and the live cwd —
130
+ # every record carries cwd, so the file's tail suffices; the Task: record
131
+ # lives at the head, so a tail miss falls back to the first 64 KB.
132
+ def backscan(path)
133
+ tail = []
134
+ File.open(path, "rb") do |f|
135
+ f.seek(0, IO::SEEK_END)
136
+ window = [f.tell, 64 * 1024].min
137
+ f.seek(-window, IO::SEEK_END)
138
+ f.each_line { |l| tail << l }
139
+ end
140
+ head = []
141
+ File.open(path, "rb") do |f|
142
+ f.each_line { |l| head << l; break if head.sum(&:bytesize) > 64 * 1024 }
143
+ end
144
+ task = nil
145
+ cwd = nil
146
+ tail.reverse_each { |l| cwd, task = note_record(l, cwd, task) }
147
+ head.reverse_each { |l| cwd, task = note_record(l, cwd, task) }
148
+ [cwd, task]
149
+ end
150
+
151
+ def note_record(line, cwd, task)
152
+ return [cwd, task] unless line.start_with?("{") && (cwd.nil? || task.nil?)
153
+
154
+ rec = begin
155
+ JSON.parse(line)
156
+ rescue JSON::ParserError
157
+ nil
158
+ end
159
+ return [cwd, task] unless rec
160
+
161
+ cwd ||= rec["cwd"]
162
+ if task.nil? && rec["recordType"] == "message" && rec["role"] == "user" &&
163
+ rec["text"].to_s.start_with?("Task:")
164
+ task = rec["text"].lines.first.chomp[0, 110]
165
+ end
166
+ [cwd, task]
167
+ end
168
+
169
+ # [bld:I35-editor-multiline-core] when cwd is a lane worktree, else the role.
170
+ def tag_label(agent, cwd)
171
+ role = ROLE_ABBREV.fetch(agent, agent[0, 10])
172
+ return role unless cwd && File.basename(cwd) == "wt"
173
+
174
+ lane = File.basename(File.dirname(cwd)).sub(/\A[Ii]\d+-/, "")
175
+ "#{role}:#{lane[0, 26]}"
176
+ end
177
+
178
+ def paint_tag(label, run_id)
179
+ code = TAG_COLORS[run_id.sum % TAG_COLORS.size]
180
+ paint("[#{label}]", code)
181
+ end
182
+ end
183
+
184
+ Transcript = Data.define(:path, :run_id, :agent, :mtime, :size, :cwd, :task)
185
+
186
+ # Adapts runlog's Printer to whole-message transcript records; emits fully
187
+ # painted lines (the follower prefixes the per-run tag). Ported verbatim.
188
+ class Renderer
189
+ include Support
190
+
191
+ def initialize(limit:, think_limit:, color:)
192
+ @limit = limit
193
+ @think_limit = think_limit
194
+ @color = color
195
+ @exchange = nil # usage accumulated across the rounds of an exchange
196
+ end
197
+
198
+ def render(rec)
199
+ case rec["recordType"]
200
+ when "tool_start" then [tool_call(rec)]
201
+ when "tool_end" then [] # the result rides the toolResult message
202
+ when "message" then message(rec)
203
+ else []
204
+ end
205
+ end
206
+
207
+ private
208
+
209
+ def message(rec)
210
+ case rec["role"]
211
+ when "assistant" then assistant(rec)
212
+ when "toolResult" then [tool_result(rec)]
213
+ when "user"
214
+ # A user message opens an exchange; every assistant round that follows
215
+ # belongs to it, up through the final (non-toolUse) stop.
216
+ @exchange = { started_at: rec["ts"], output: 0, cache_read: 0, cost: 0.0 }
217
+ []
218
+ else # system/custom echoes: everything of interest shows elsewhere
219
+ []
220
+ end
221
+ end
222
+
223
+ def assistant(rec)
224
+ lines = []
225
+ (rec.dig("message", "content") || []).each do |block|
226
+ case block["type"]
227
+ when "thinking"
228
+ text = clip(block["thinking"].to_s, @think_limit)
229
+ next if text.empty?
230
+
231
+ lines << dim("· thinking")
232
+ text.lines.each { |l| lines << dim(l.chomp) }
233
+ when "text"
234
+ text = block["text"].to_s
235
+ next if text.empty?
236
+
237
+ lines << bold("● assistant")
238
+ text.lines.each { |l| lines << l.chomp }
239
+ end
240
+ # toolCall blocks: the harness re-emits canonical args on tool_start,
241
+ # which is what we display — one source, reflecting what actually ran.
242
+ end
243
+ lines << usage_line(rec) if final_stop?(rec)
244
+ lines
245
+ end
246
+
247
+ def final_stop?(rec)
248
+ reason = rec["stopReason"]
249
+ reason && reason != "toolUse"
250
+ end
251
+
252
+ def usage_line(rec)
253
+ u = rec["usage"] || {}
254
+ ex = @exchange
255
+ output = (ex ? ex[:output] : 0) + u["output"].to_i
256
+ cache = (ex ? ex[:cache_read] : 0) + u["cacheRead"].to_i
257
+ # cost is nested ({total: …}) in run.jsonl-shaped events but a plain
258
+ # Float in the artifact transcripts — accept both.
259
+ cost_value = u["cost"]
260
+ cost_value = cost_value["total"] if cost_value.is_a?(Hash)
261
+ cost = (ex ? ex[:cost] : 0.0) + cost_value.to_f
262
+ started = ex ? ex[:started_at] : nil
263
+ @exchange = nil
264
+ elapsed = started && rec["ts"] ? (rec["ts"] - started) / 1000 : nil
265
+ stats = [
266
+ "#{output} out",
267
+ "#{cache} cache read",
268
+ ("$#{format('%.2f', cost)}" if cost.positive?),
269
+ ("#{format('%.0f', elapsed)}s" if elapsed)
270
+ ].compact.join(" ")
271
+ failed = rec["stopReason"].to_s.casecmp("error").zero?
272
+ (failed ? red("✗ failed") : green("✓ done")) + dim(" #{stats}")
273
+ end
274
+
275
+ def tool_call(rec)
276
+ args = begin
277
+ JSON.parse(rec["argsPayload"] || "")
278
+ rescue JSON::ParserError, TypeError
279
+ nil
280
+ end
281
+ summary = if args.is_a?(Hash)
282
+ args.map { |k, v| "#{k}: #{oneline(v, @limit)}" }.join(" ")
283
+ else
284
+ oneline(rec["argsPreview"], @limit)
285
+ end
286
+ "#{cyan("⚒ #{rec['toolName']}")} #{dim(summary)}"
287
+ end
288
+
289
+ def tool_result(rec)
290
+ body = rec["text"].to_s
291
+ note = body.lines.size > 1 ? dim(" (#{body.lines.size} lines)") : ""
292
+ marker = rec["isError"] ? red("✗") : green("←")
293
+ "#{marker} #{oneline(body, @limit)}#{note}"
294
+ end
295
+ end
296
+
297
+ # Scans for transcripts, adopts the fresh ones, and drains their appended
298
+ # bytes — the follow loop's engine, ported verbatim.
299
+ class Follower
300
+ include Support
301
+
302
+ POLL = 0.5
303
+ RESCAN = 2.0
304
+
305
+ Run = Struct.new(:path, :run_id, :agent, :tag, :renderer, :offset, :partial, :stamp)
306
+
307
+ attr_reader :runs
308
+
309
+ def initialize(space:, io:, env:, replay:, all:, list_only: false)
310
+ @space = space
311
+ @io = io
312
+ @env = env
313
+ @limit = (env["RUNLOG_LIMIT"] || DEFAULT_LIMIT).to_i
314
+ @window = (env["LOOPLOG_WINDOW"] || WINDOW_DEFAULT).to_i
315
+ @replay = replay
316
+ @all = all
317
+ @list_only = list_only
318
+ @color = io.tty? && env["NO_COLOR"].nil?
319
+ @runs = {}
320
+ end
321
+
322
+ def run
323
+ scan
324
+ return if @list_only
325
+
326
+ last_scan = Process.clock_gettime(Process::CLOCK_MONOTONIC)
327
+ loop do
328
+ sleep POLL
329
+ @runs.each_value { |r| drain(r) }
330
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
331
+ if now - last_scan >= RESCAN
332
+ scan
333
+ last_scan = now
334
+ end
335
+ end
336
+ rescue Interrupt
337
+ # ctrl+c while following: exit quietly with the output already on screen.
338
+ end
339
+
340
+ # The incremental seams the tests exercise: scan adopts fresh transcripts
341
+ # (or prints the inventory when list-only), drain consumes newly appended
342
+ # bytes. Public on purpose — tests drive these directly instead of the
343
+ # never-ending follow loop.
344
+ def scan
345
+ now = Time.now.to_f
346
+ if @list_only
347
+ discover(@space, @env).sort_by { |t| -t.mtime }.each do |t|
348
+ age_min = (now - t.mtime) / 60
349
+ active = (now - t.mtime) < @window
350
+ cwd, task = backscan(t.path)
351
+ @io.puts format("%s %-9s %5s %5.0fm %7sK %s %s",
352
+ active ? green("●") : dim("·"),
353
+ tag_label(t.agent, cwd),
354
+ dim(Time.at(t.mtime).strftime("%H:%M")),
355
+ age_min, t.size / 1024,
356
+ dim(t.run_id[0, 8]),
357
+ dim(task.to_s[0, 70]))
358
+ end
359
+ return
360
+ end
361
+ discover(@space, @env).each do |t|
362
+ next if @runs.key?(t.path)
363
+ next unless @all || (now - t.mtime) < @window
364
+
365
+ adopt(t)
366
+ end
367
+ end
368
+
369
+ def drain(run)
370
+ return unless File.exist?(run.path) && File.size(run.path) > run.offset
371
+
372
+ File.open(run.path, "rb") do |f|
373
+ f.seek(run.offset)
374
+ run.partial << f.read
375
+ run.offset = f.tell
376
+ end
377
+ while (idx = run.partial.index("\n"))
378
+ line = run.partial.slice!(0..idx)
379
+ next unless line.start_with?("{")
380
+
381
+ rec = begin
382
+ JSON.parse(line)
383
+ rescue JSON::ParserError
384
+ next
385
+ end
386
+ run.stamp = stamp_for(rec)
387
+ emit(run, run.renderer.render(rec))
388
+ end
389
+ end
390
+
391
+ private
392
+
393
+ def adopt(t)
394
+ cwd, task = backscan(t.path)
395
+ tag = paint_tag(tag_label(t.agent, cwd), t.run_id)
396
+ run = Run.new(t.path, t.run_id, t.agent, tag,
397
+ Renderer.new(limit: @limit, think_limit: @env["RUNLOG_THINK"]&.to_i, color: @color),
398
+ @replay ? 0 : t.size, +"", stamp_now)
399
+ @runs[t.path] = run
400
+ emit(run, [bold("▶ #{t.agent} #{t.run_id[0, 8]}") +
401
+ (cwd ? dim(" @ #{cwd.sub(Dir.home, '~')}") : "") +
402
+ (task ? dim(" — #{task}") : "")])
403
+ end
404
+
405
+ def emit(run, lines)
406
+ stamp = run.stamp ? "#{dim(run.stamp)} " : ""
407
+ lines.each { |l| @io.puts "#{run.tag} #{stamp}#{l}" unless l.nil? || l.empty? }
408
+ @io.flush
409
+ end
410
+
411
+ def stamp_now = Time.now.strftime("%H:%M:%S")
412
+
413
+ # Records carry epoch-ms +"ts" and an ISO +"timestamp+" (UTC); display local.
414
+ def stamp_for(rec)
415
+ t = if rec["ts"].is_a?(Numeric)
416
+ Time.at(rec["ts"] / 1000.0)
417
+ elsif (iso = rec["timestamp"])
418
+ Time.parse(iso) rescue nil
419
+ end
420
+ t&.strftime("%H:%M:%S")
421
+ end
422
+ end
423
+ end
424
+ end
425
+ end
@@ -17,9 +17,9 @@ module Space::Architect
17
17
  # XDG_CONFIG_HOME); space-local is architect.yaml beside the loaded
18
18
  # space.yaml (the CLI already resolved the space — no walk-up).
19
19
  module Routing
20
- ROLES = %w[architect spec builder judge coordinator].freeze
21
- # The four agent roles that must be routed after the merge; `architect` is optional.
22
- REQUIRED_ROLES = %w[spec builder judge coordinator].freeze
20
+ ROLES = %w[architect spec builder judge coordinator narrator].freeze
21
+ # The five agent roles that must be routed after the merge; `architect` is optional.
22
+ REQUIRED_ROLES = %w[spec builder judge coordinator narrator].freeze
23
23
  ENTRY_KEYS = %w[model thinking].freeze
24
24
 
25
25
  def self.global_path
@@ -34,7 +34,7 @@ module Space::Architect
34
34
 
35
35
  # Parse one file's text into { role => {"model" => id, "thinking" => level|nil} }.
36
36
  # +path+ is used only in error messages. Frozen v1 rules: integer version 1,
37
- # the five known roles, entries ⊆ {model, thinking} with a non-empty model,
37
+ # the six known roles, entries ⊆ {model, thinking} with a non-empty model,
38
38
  # duplicate mapping keys rejected (psych's plain load silently keeps the
39
39
  # last — the npm yaml the extension parses with throws), aliases accepted,
40
40
  # a trailing :level suffix on model parsed via Harness.parse_model_suffix
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Space
6
+ module Architect
7
+ # Pretty-prints a pi agent run.jsonl as it streams — a faithful port of
8
+ # ~/bin/runlog (the byte-compat oracle; same display bytes).
9
+ #
10
+ # tail -f build/I08-.../run.jsonl | architect runlog
11
+ # architect runlog build/I08-.../run.jsonl
12
+ #
13
+ # Thinking is dim, assistant text is plain, tool calls and their results are
14
+ # one-liners. System/user/toolResult message echoes, and non-JSON noise on
15
+ # the stream, are dropped.
16
+ #
17
+ # Truncation of tool inputs/outputs defaults to 2000 characters; override
18
+ # with RUNLOG_LIMIT.
19
+ module Runlog
20
+ DEFAULT_LIMIT = 2000
21
+
22
+ class << self
23
+ # Iterates input's lines, dropping non-`{`-prefixed lines and JSON parse
24
+ # failures, feeding a Printer. Interrupt → quiet return with the output
25
+ # already on screen (ctrl+c while tail-following a live run).
26
+ def stream(input:, io: $stdout, env: ENV)
27
+ printer = Printer.new(
28
+ io: io,
29
+ limit: (env["RUNLOG_LIMIT"] || DEFAULT_LIMIT).to_i,
30
+ color: io.tty? && env["NO_COLOR"].nil?
31
+ )
32
+ input.each_line do |raw|
33
+ next unless raw.start_with?("{")
34
+
35
+ event = JSON.parse(raw) rescue next
36
+ printer.handle(event)
37
+ end
38
+ rescue Interrupt
39
+ nil
40
+ end
41
+ end
42
+
43
+ # Ported verbatim from the oracle script: streamed deltas write
44
+ # progressively (label held back until a block actually emits), whole
45
+ # tool events one-line, and usage accumulates across the tool rounds of
46
+ # an exchange so the done line reports the whole turn, not one round.
47
+ class Printer
48
+ def initialize(io:, limit:, color:)
49
+ @io = io
50
+ @limit = limit
51
+ @color = color
52
+ @column = 0 # tracks whether we're mid-line inside a streamed block
53
+ @mode = nil # :thinking or :text, so we only label a block once
54
+ @label = nil # header held back until the block actually emits something
55
+ @turn = nil # usage accumulated across the rounds of the exchange in flight
56
+ end
57
+
58
+ def handle(event)
59
+ case event["type"]
60
+ when "message_update" then delta(event["assistantMessageEvent"], event["usage"])
61
+ when "message_start" then message_start(event["message"])
62
+ when "message_end" then message_end(event["message"])
63
+ when "tool_execution_start" then tool_call(event)
64
+ when "tool_execution_end" then tool_result(event)
65
+ when "turn_end" then turn_end(event["message"])
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def message_start(message)
72
+ return unless message["role"] == "assistant"
73
+
74
+ @column = 0
75
+ @mode = nil
76
+ @label = nil
77
+ end
78
+
79
+ def delta(ev, usage)
80
+ case ev["type"]
81
+ when "thinking_start" then start_prose(:thinking, dim("· thinking"))
82
+ when "text_start" then start_prose(:text, bold("● assistant"))
83
+ when "thinking_delta" then write(dim(ev["delta"]))
84
+ when "text_delta" then write(ev["delta"])
85
+ when "thinking_end" then catch_up(:thinking, "· thinking", ev["content"], usage&.dig("reasoning").to_i)
86
+ when "text_end" then catch_up(:text, "● assistant", ev["content"], nil)
87
+ # toolcall_start/delta/end carry the model's tool arguments, but the
88
+ # harness re-emits the canonical args on tool_execution_start, which is
89
+ # what we display — one source, and it reflects what actually ran.
90
+ end
91
+ end
92
+
93
+ # Some models redact their thinking (or stream nothing at all) but still
94
+ # deliver the full block on the *_end event, or at least report a token
95
+ # count in the usage. The label is held back until a delta writes something;
96
+ # if the block ends with the label still pending, nothing was streamed, so
97
+ # show what we have.
98
+ def catch_up(mode, header, content, reasoning)
99
+ return unless @mode == mode && @label
100
+
101
+ if content.to_s.empty?
102
+ @label = nil
103
+ line dim("#{header} (#{reasoning} tokens, not streamed)") if reasoning.to_i.positive?
104
+ else
105
+ write(content)
106
+ end
107
+ end
108
+
109
+ # "system" / "user" / "toolResult" message echoes carry nothing the
110
+ # deltas and tool_execution_* events don't already show.
111
+ def message_end(message)
112
+ break_line if message["role"] == "assistant"
113
+ start_turn(message) if message["role"] == "user"
114
+ end
115
+
116
+ # A user message opens a conversation exchange: every round that follows
117
+ # belongs to it, up through the final assistant reply.
118
+ def start_turn(message)
119
+ @turn = { started_at: message["timestamp"], output: 0, cache_read: 0, cost: 0.0, duration_ms: 0 }
120
+ end
121
+
122
+ def tool_call(event)
123
+ break_line
124
+ args = event["args"] || {}
125
+ summary = args.map { |k, v| "#{k}: #{oneline(v)}" }.join(" ")
126
+ line "#{cyan("⚒ #{event["toolName"]}")} #{dim(summary)}"
127
+ end
128
+
129
+ def tool_result(event)
130
+ result = event["result"] || {}
131
+ body = flatten(result["content"])
132
+ body = result.dig("structuredContent", "output").to_s if body.empty?
133
+ lines = body.lines
134
+ marker = event["isError"] ? red("✗") : green("←")
135
+ note = lines.size > 1 ? dim(" (#{lines.size} lines)") : ""
136
+ line "#{marker} #{oneline(body)}#{note}"
137
+ end
138
+
139
+ def flatten(content)
140
+ case content
141
+ when String then content
142
+ when Array then content.map { |b| b.is_a?(Hash) ? b["text"].to_s : b.to_s }.join("\n")
143
+ else content.to_s
144
+ end
145
+ end
146
+
147
+ # Each API round in the stream is a "turn": turn_end fires whenever the
148
+ # model stops, with stopReason "toolUse" whenever it stopped to run tools.
149
+ # The done line therefore waits for a final stop reason and reports usage
150
+ # summed across the whole exchange — not once per tool round.
151
+ def turn_end(message)
152
+ usage = message["usage"] || {}
153
+ turn = (@turn ||= { started_at: nil, output: 0, cache_read: 0, cost: 0.0, duration_ms: 0 })
154
+ turn[:output] += usage["output"].to_i
155
+ turn[:cache_read] += usage["cacheRead"].to_i
156
+ turn[:cost] += usage.dig("cost", "total").to_f
157
+ turn[:duration_ms] += message["durationMs"].to_i
158
+ return if message["stopReason"] == "toolUse"
159
+
160
+ from, to = turn[:started_at], message["timestamp"]
161
+ elapsed = from && to ? to - from : turn[:duration_ms]
162
+ stats = [
163
+ "#{turn[:output]} out",
164
+ "#{turn[:cache_read]} cache read",
165
+ ("$#{format("%.2f", turn[:cost])}" if turn[:cost].positive?),
166
+ "#{elapsed / 1000}s"
167
+ ].compact.join(" ")
168
+ failed = message["stopReason"].to_s.casecmp("error").zero?
169
+ line((failed ? red("✗ failed") : green("✓ done")) + dim(" #{stats}"))
170
+ @turn = nil
171
+ end
172
+
173
+ # --- output plumbing ---------------------------------------------------
174
+
175
+ def start_prose(mode, label)
176
+ break_line
177
+ @label = label
178
+ @mode = mode
179
+ end
180
+
181
+ def write(text)
182
+ return if text.nil? || text.empty?
183
+
184
+ if @label
185
+ @io.puts @label
186
+ @label = nil
187
+ end
188
+ @io.write(text)
189
+ @io.flush
190
+ @column += text.length
191
+ end
192
+
193
+ def line(text)
194
+ @label = nil
195
+ break_line
196
+ @io.puts text
197
+ @io.flush
198
+ end
199
+
200
+ def break_line
201
+ return if @column.zero?
202
+
203
+ @io.puts
204
+ @column = 0
205
+ @mode = nil
206
+ end
207
+
208
+ # --- paint ---------------------------------------------------------------
209
+
210
+ def paint(text, code)
211
+ @color ? "\e[#{code}m#{text}\e[0m" : text
212
+ end
213
+
214
+ def dim(s) = paint(s, "2")
215
+ def bold(s) = paint(s, "1")
216
+ def cyan(s) = paint(s, "36")
217
+ def green(s) = paint(s, "32")
218
+ def red(s) = paint(s, "31")
219
+
220
+ # Collapses whitespace and clips, so a tool argument stays on one line.
221
+ def oneline(value, limit = @limit)
222
+ s = value.to_s.gsub(/\s+/, " ").strip
223
+ s.length > limit ? "#{s[0, limit]}…" : s
224
+ end
225
+ end
226
+ end
227
+ end
228
+ end