@llm4ts/runner 2.21.0 → 2.22.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.
@@ -38,18 +38,69 @@ export interface AgentTreeHost {
38
38
  readonly close: Effect.Effect<void>
39
39
  }
40
40
 
41
- const enterScreen = "\u001b[?1049h\u001b[?25l"
42
- const leaveScreen = "\u001b[?25h\u001b[?1049l"
43
- const home = "\u001b[H\u001b[2J"
41
+ const beginUpdate = "\u001b[?2026h"
42
+ const endUpdate = "\u001b[?2026l"
43
+ const clearScreen = "\u001b[H\u001b[2J"
44
+ const at = (row: number): string => `\u001b[${row};1H`
45
+
46
+ /**
47
+ * What to send to turn the screen showing `previous` into `next`: the whole
48
+ * frame after a clear when there is no previous one (first paint, resize, a
49
+ * prompt in between), otherwise only the rows that changed — never a clear,
50
+ * which is what flickers under tmux and Windows terminals — and nothing at
51
+ * all when the frame is the same. Each paint is one synchronized update, which
52
+ * terminals that know it show at once and the rest ignore. Rows are drawn
53
+ * at full width, so an overwritten row leaves nothing behind.
54
+ */
55
+ export const paintFrame = (
56
+ previous: ReadonlyArray<string> | undefined,
57
+ next: ReadonlyArray<string>
58
+ ): string => {
59
+ if (previous === undefined) {
60
+ return `${beginUpdate}${clearScreen}${next.map((line, row) => `${at(row + 1)}${line}`).join("")}${endUpdate}`
61
+ }
62
+ const changed = next.flatMap((line, row) =>
63
+ line === previous[row] ? [] : [`${at(row + 1)}${line}`]
64
+ )
65
+ const shorter = next.length < previous.length ? `${at(next.length + 1)}\u001b[J` : ""
66
+ return changed.length === 0 && shorter === ""
67
+ ? ""
68
+ : `${beginUpdate}${changed.join("")}${shorter}${endUpdate}`
69
+ }
70
+
71
+ /** A full-screen view's painter: remembers what is on screen, and at which size. */
72
+ export interface ScreenPainter {
73
+ /** What to write to show `lines` at terminal size `size`; empty when nothing changed. */
74
+ readonly paint: (lines: ReadonlyArray<string>, size: string) => Effect.Effect<string>
75
+ /** The screen was cleared or covered: the next paint is a whole one. */
76
+ readonly reset: Effect.Effect<void>
77
+ }
78
+
79
+ export const makeScreenPainter: Effect.Effect<ScreenPainter> = Effect.map(
80
+ Ref.make<{ readonly lines: ReadonlyArray<string>; readonly size: string } | undefined>(undefined),
81
+ (shown) => ({
82
+ paint: (lines, size) =>
83
+ Ref.modify(shown, (previous) => [
84
+ paintFrame(previous?.size === size ? previous.lines : undefined, lines),
85
+ { lines, size }
86
+ ]),
87
+ reset: Ref.set(shown, undefined)
88
+ })
89
+ )
90
+
91
+ /** The alternate screen, cursor hidden, no auto-wrap: a full-width last row never scrolls. */
92
+ export const enterScreen = "\u001b[?1049h\u001b[?25l\u001b[?7l"
93
+ export const leaveScreen = "\u001b[?7h\u001b[?25h\u001b[?1049l"
44
94
  const redrawEvery = "100 millis"
45
95
 
46
96
  export const makeAgentTreeHost = Effect.fn("@llm4ts/runner/AgentTreeSurface.make")(function* (
47
97
  hub: FlowEventHub,
48
98
  classic: TerminalSurface,
49
99
  output: AgentTreeOutput,
50
- keys: Stream.Stream<string>
100
+ keys: Stream.Stream<string>,
101
+ idleAfterMs?: number
51
102
  ): Effect.fn.Return<AgentTreeHost, never, Scope.Scope> {
52
- const state = yield* Ref.make(emptyTree())
103
+ const state = yield* Ref.make(emptyTree(idleAfterMs === undefined ? {} : { idleAfterMs }))
53
104
  const view = yield* Ref.make<TreeView>(initialView)
54
105
  const active = yield* Ref.make(true)
55
106
  const consumed = yield* Ref.make(0)
@@ -65,19 +116,28 @@ export const makeAgentTreeHost = Effect.fn("@llm4ts/runner/AgentTreeSurface.make
65
116
  Effect.forkScoped
66
117
  )
67
118
 
68
- const render = (rows: number | undefined): Effect.Effect<string> =>
119
+ const render = (rows: number | undefined): Effect.Effect<ReadonlyArray<string>> =>
69
120
  Effect.gen(function* () {
70
- const lines = renderTree(yield* Ref.get(state), {
121
+ return renderTree(yield* Ref.get(state), {
71
122
  width: Math.max(90, output.columns() ?? 90),
72
123
  colour: output.colour,
73
124
  view: yield* Ref.get(view),
74
125
  ...(rows === undefined ? {} : { height: rows })
75
126
  })
76
- return lines.join("\n")
77
127
  })
78
128
  /** The full screen fits the terminal; the last frame left behind is whole. */
79
- const frame = render(undefined)
80
- const screen = Effect.suspend(() => render(output.rows()))
129
+ const frame = Effect.map(render(undefined), (lines) => lines.join("\n"))
130
+ const painter = yield* makeScreenPainter
131
+ const repaint = Effect.gen(function* () {
132
+ // The clock moves between events: lane timers and the idle marker run.
133
+ const now = yield* Clock.currentTimeMillis
134
+ yield* Ref.update(state, (current) => reduceTree(current, { _tag: "Tick", at: now }))
135
+ const rows = output.rows()
136
+ const text = yield* painter.paint(yield* render(rows), `${output.columns()}x${rows}`)
137
+ if (text.length > 0) {
138
+ yield* output.write(text)
139
+ }
140
+ })
81
141
 
82
142
  /** Runs `effect` only while the tree has the screen, under the drawing lock. */
83
143
  const whileShown = (effect: Effect.Effect<void>): Effect.Effect<void> =>
@@ -93,12 +153,9 @@ export const makeAgentTreeHost = Effect.fn("@llm4ts/runner/AgentTreeSurface.make
93
153
 
94
154
  yield* output.write(enterScreen)
95
155
  yield* Effect.addFinalizer(() => leave(false))
96
- yield* Effect.forever(
97
- Effect.andThen(
98
- Effect.sleep(redrawEvery),
99
- whileShown(Effect.flatMap(screen, (text) => output.write(`${home}${text}`)))
100
- )
101
- ).pipe(Effect.forkScoped)
156
+ yield* Effect.forever(Effect.andThen(Effect.sleep(redrawEvery), whileShown(repaint))).pipe(
157
+ Effect.forkScoped
158
+ )
102
159
  yield* Stream.runForEach(keys, (key) =>
103
160
  Effect.gen(function* () {
104
161
  const next = onTreeKey(yield* Ref.get(view), key, yield* Ref.get(state))
@@ -123,7 +180,9 @@ export const makeAgentTreeHost = Effect.fn("@llm4ts/runner/AgentTreeSurface.make
123
180
  lock.withPermit(Effect.andThen(Ref.set(active, false), output.write(leaveScreen))),
124
181
  () => classic.suspend(effect),
125
182
  () =>
126
- lock.withPermit(Effect.andThen(Ref.set(active, true), output.write(enterScreen)))
183
+ lock.withPermit(
184
+ Effect.all([Ref.set(active, true), painter.reset, output.write(enterScreen)])
185
+ )
127
186
  )
128
187
  : classic.suspend(effect)
129
188
  )
package/src/FlowRunner.ts CHANGED
@@ -58,6 +58,8 @@ import {
58
58
  } from "@llm4ts/flow/FlowEvents"
59
59
  import { FlowContext, type ContextOptions, type FlowContextShape } from "@llm4ts/flow/FlowContext"
60
60
  import { makeFlowRecorder, type RunOutcome } from "@llm4ts/flow/FlowRecorder"
61
+ import { timedSeat } from "@llm4ts/flow/Timing"
62
+ import { idleAfterFrom } from "./AgentTree.ts"
61
63
  import { makeAgentTreeHost, nodeTreeKeys } from "./AgentTreeSurface.ts"
62
64
  import { makeJudgmentLog, type JudgmentLogShape } from "@llm4ts/flow/JudgmentLog"
63
65
  import { makeGitHubTool } from "@llm4ts/flow/GitHubTool"
@@ -454,13 +456,14 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
454
456
  laneEvents: FlowEventsShape = events
455
457
  ): FlowContextShape =>
456
458
  FlowContext.of({
457
- reasoning: seats.reasoning,
458
- coder: seats.coder,
459
+ // Every call is timed on the lane's events, under its role.
460
+ reasoning: timedSeat(seats.reasoning, laneEvents, "reasoning"),
461
+ coder: { ...seats.coder, ...timedSeat(seats.coder, laneEvents, "coder") },
459
462
  judgment: seats.judgment,
460
463
  git: makeGitTool(dependencies.process, workDir, laneEvents),
461
464
  hosting: makeGitHubTool(dependencies.process, workDir, laneEvents),
462
465
  events: laneEvents,
463
- reviewers: seats.reviewers,
466
+ reviewers: seats.reviewers.map((reviewer) => timedSeat(reviewer, laneEvents, "reviewer")),
464
467
  coderCapabilities: seats.coder.capabilities,
465
468
  userPrompt: options.userPrompt,
466
469
  workDir,
@@ -618,12 +621,16 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
618
621
  laneEvents: FlowEventsShape
619
622
  ): RosterView => ({
620
623
  forRole: (role) =>
621
- rosterSeat(roster, source, role, workDir, {
622
- events: laneEvents,
623
- avoid: Effect.map(held.executor, (id) => (id === undefined ? [] : [id])),
624
- borrow: held.lease,
625
- label
626
- }),
624
+ timedSeat(
625
+ rosterSeat(roster, source, role, workDir, {
626
+ events: laneEvents,
627
+ avoid: Effect.map(held.executor, (id) => (id === undefined ? [] : [id])),
628
+ borrow: held.lease,
629
+ label
630
+ }),
631
+ laneEvents,
632
+ role
633
+ ),
627
634
  available: roster.available,
628
635
  slots: roster.slots,
629
636
  executor: held.executor,
@@ -647,7 +654,7 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
647
654
  const judgment = yield* judgmentFor(judgmentSeat, judgmentConfig, runEnvironment)
648
655
  return FlowContext.of({
649
656
  reasoning: view.forRole(rebind ? "planner" : "reviewer"),
650
- coder: held.service,
657
+ coder: timedSeat(held.service, laneEvents, "coder"),
651
658
  judgment,
652
659
  git: makeGitTool(dependencies.process, workDir, laneEvents),
653
660
  hosting: makeGitHubTool(dependencies.process, workDir, laneEvents),
@@ -765,7 +772,8 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
765
772
  rows: () => process.stdout.rows,
766
773
  colour: true
767
774
  },
768
- nodeTreeKeys("interrupt")
775
+ nodeTreeKeys("interrupt"),
776
+ idleAfterFrom(environment)
769
777
  )
770
778
  : undefined
771
779
  const surface = tree?.surface ?? classic