@llm4ts/runner 3.2.6 → 3.4.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 (93) hide show
  1. package/README.md +1 -1
  2. package/dist/AgentTree.d.ts +2 -3
  3. package/dist/AgentTree.d.ts.map +1 -1
  4. package/dist/AgentTree.js +8 -20
  5. package/dist/AgentTree.js.map +1 -1
  6. package/dist/AgentTreeSurface.d.ts +1 -1
  7. package/dist/AgentTreeSurface.d.ts.map +1 -1
  8. package/dist/AgentTreeSurface.js +2 -5
  9. package/dist/AgentTreeSurface.js.map +1 -1
  10. package/dist/Cli.d.ts.map +1 -1
  11. package/dist/Cli.js +2 -1
  12. package/dist/Cli.js.map +1 -1
  13. package/dist/Connectors.d.ts +33 -2
  14. package/dist/Connectors.d.ts.map +1 -1
  15. package/dist/Connectors.js +85 -89
  16. package/dist/Connectors.js.map +1 -1
  17. package/dist/Doctor.d.ts.map +1 -1
  18. package/dist/Doctor.js +8 -11
  19. package/dist/Doctor.js.map +1 -1
  20. package/dist/EventPresentation.d.ts +35 -0
  21. package/dist/EventPresentation.d.ts.map +1 -0
  22. package/dist/EventPresentation.js +133 -0
  23. package/dist/EventPresentation.js.map +1 -0
  24. package/dist/ExecutorRoster.d.ts.map +1 -1
  25. package/dist/ExecutorRoster.js +4 -6
  26. package/dist/ExecutorRoster.js.map +1 -1
  27. package/dist/FlowArgs.d.ts +1 -1
  28. package/dist/FlowArgs.d.ts.map +1 -1
  29. package/dist/FlowArgs.js +4 -2
  30. package/dist/FlowArgs.js.map +1 -1
  31. package/dist/FlowRunner.d.ts +3 -1
  32. package/dist/FlowRunner.d.ts.map +1 -1
  33. package/dist/FlowRunner.js +31 -38
  34. package/dist/FlowRunner.js.map +1 -1
  35. package/dist/NodeGeminiCliExecutor.d.ts.map +1 -1
  36. package/dist/NodeGeminiCliExecutor.js +6 -4
  37. package/dist/NodeGeminiCliExecutor.js.map +1 -1
  38. package/dist/NodeGeminiModelBridge.d.ts.map +1 -1
  39. package/dist/NodeGeminiModelBridge.js +3 -1
  40. package/dist/NodeGeminiModelBridge.js.map +1 -1
  41. package/dist/Otel.d.ts.map +1 -1
  42. package/dist/Otel.js +4 -5
  43. package/dist/Otel.js.map +1 -1
  44. package/dist/Packs.d.ts.map +1 -1
  45. package/dist/Packs.js +2 -1
  46. package/dist/Packs.js.map +1 -1
  47. package/dist/PiModels.d.ts.map +1 -1
  48. package/dist/PiModels.js +6 -9
  49. package/dist/PiModels.js.map +1 -1
  50. package/dist/Profile.d.ts +4 -0
  51. package/dist/Profile.d.ts.map +1 -1
  52. package/dist/Profile.js +45 -75
  53. package/dist/Profile.js.map +1 -1
  54. package/dist/RunReaders.d.ts +1 -0
  55. package/dist/RunReaders.d.ts.map +1 -1
  56. package/dist/RunReaders.js +4 -51
  57. package/dist/RunReaders.js.map +1 -1
  58. package/dist/Terminal.d.ts +14 -11
  59. package/dist/Terminal.d.ts.map +1 -1
  60. package/dist/Terminal.js +25 -78
  61. package/dist/Terminal.js.map +1 -1
  62. package/dist/Tools.d.ts.map +1 -1
  63. package/dist/Tools.js +2 -1
  64. package/dist/Tools.js.map +1 -1
  65. package/dist/Transcripts.d.ts +1 -1
  66. package/dist/Transcripts.d.ts.map +1 -1
  67. package/dist/Transcripts.js +3 -2
  68. package/dist/Transcripts.js.map +1 -1
  69. package/dist/index.d.ts +1 -1
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.js +1 -1
  72. package/dist/index.js.map +1 -1
  73. package/package.json +4 -3
  74. package/src/AgentTree.ts +16 -25
  75. package/src/AgentTreeSurface.ts +6 -11
  76. package/src/Cli.ts +2 -1
  77. package/src/Connectors.ts +141 -107
  78. package/src/Doctor.ts +10 -12
  79. package/src/EventPresentation.ts +168 -0
  80. package/src/ExecutorRoster.ts +4 -6
  81. package/src/FlowArgs.ts +7 -2
  82. package/src/FlowRunner.ts +41 -55
  83. package/src/NodeGeminiCliExecutor.ts +7 -4
  84. package/src/NodeGeminiModelBridge.ts +4 -1
  85. package/src/Otel.ts +4 -6
  86. package/src/Packs.ts +2 -1
  87. package/src/PiModels.ts +6 -10
  88. package/src/Profile.ts +64 -81
  89. package/src/RunReaders.ts +4 -53
  90. package/src/Terminal.ts +105 -162
  91. package/src/Tools.ts +2 -1
  92. package/src/Transcripts.ts +3 -2
  93. package/src/index.ts +3 -0
@@ -0,0 +1,168 @@
1
+ import * as Schema from "effect/Schema"
2
+ import type { FlowEvent, FlowEventByTag, FlowEventTag } from "@llm4ts/flow/FlowEvents"
3
+
4
+ export const Verbosity = Schema.Literals(["Quiet", "Normal", "Verbose", "Debug"])
5
+ export type Verbosity = typeof Verbosity.Type
6
+
7
+ const rank: Readonly<Record<Verbosity, number>> = { Quiet: 0, Normal: 1, Verbose: 2, Debug: 3 }
8
+
9
+ /** Whether `verbosity` is `least` or louder. */
10
+ export const atLeast = (verbosity: Verbosity, least: Verbosity): boolean =>
11
+ rank[verbosity] >= rank[least]
12
+
13
+ /** A line of `llm4ts watch` and of the MCP run digests, and the lane it is filed under. */
14
+ export interface DigestLine {
15
+ readonly text: string
16
+ readonly lane?: string
17
+ }
18
+
19
+ /** How one event shows: on the terminal, in a profile's gaps, in a digest. */
20
+ export interface Presentation<E> {
21
+ /**
22
+ * Whether the terminal prints it as a line of its own: `never`, from a
23
+ * verbosity up (`Quiet`: always), or by a rule of its own.
24
+ */
25
+ readonly line: "never" | Verbosity | ((event: E, verbosity: Verbosity) => boolean)
26
+ /** The event by kind, never its content: how `llm4ts profile` names it around a gap. */
27
+ readonly kind: string | ((event: E) => string)
28
+ /** Its line in a digest. Absent: digests leave the event out. */
29
+ readonly digest?: (event: E) => DigestLine
30
+ }
31
+
32
+ const laned = (lane: string | undefined): { readonly lane?: string } =>
33
+ lane === undefined ? {} : { lane }
34
+
35
+ const capability = "a capability check"
36
+ const roster = "a roster change"
37
+ const other = "an event"
38
+
39
+ /**
40
+ * Screen and digest policy for every event. Exhaustive by type: adding an
41
+ * event class without an entry here fails `pnpm typecheck`.
42
+ */
43
+ export const eventPresentation: {
44
+ readonly [K in FlowEventTag]: Presentation<FlowEventByTag[K]>
45
+ } = {
46
+ // Advise mode publishes a separate Info event for the operator.
47
+ JudgmentObserved: { line: "never", kind: other },
48
+ // For the trace file.
49
+ BlackboardRun: { line: "never", kind: other },
50
+ StageStarted: { line: "Quiet", kind: "a stage start" },
51
+ StageCompleted: { line: "Quiet", kind: "a stage end" },
52
+ StageFailed: {
53
+ line: "Quiet",
54
+ kind: "a stage failure",
55
+ digest: (event) => ({ text: `${event.stage} failed: ${event.message}`, ...laned(event.lane) })
56
+ },
57
+ Aborted: {
58
+ line: "Quiet",
59
+ kind: other,
60
+ digest: (event) => ({ text: `aborted: ${event.message}` })
61
+ },
62
+ Info: { line: "Normal", kind: "a log note" },
63
+ ToolUse: { line: "Normal", kind: "a coder tool call" },
64
+ AssistantMessage: { line: "Normal", kind: "an assistant message" },
65
+ TokensUsed: { line: "Verbose", kind: "a token report" },
66
+ // Progress feeds the status rows.
67
+ UsageProgress: { line: "never", kind: other },
68
+ ReviewFindings: { line: "Normal", kind: "a review round" },
69
+ // Every git read and gate command is a capability event: useful in a
70
+ // trace, noise on screen once several stories run.
71
+ CapabilityUsed: { line: "Verbose", kind: capability },
72
+ CapabilityDenied: { line: "Quiet", kind: capability },
73
+ CapabilityUnenforceable: {
74
+ line: "Quiet",
75
+ kind: capability,
76
+ digest: (event) => ({ text: `capability unenforceable: ${event.detail}` })
77
+ },
78
+ EvidenceChecked: { line: "Quiet", kind: "an evidence check" },
79
+ ReviewFindingDemoted: { line: "Quiet", kind: "a review finding demoted" },
80
+ Declassified: { line: "Normal", kind: other },
81
+ ExecutorLeased: { line: "Normal", kind: roster },
82
+ // A lease ending is routine.
83
+ ExecutorReleased: { line: "never", kind: roster },
84
+ ExecutorExcluded: {
85
+ line: "Normal",
86
+ kind: roster,
87
+ digest: (event) => ({ text: `${event.executor} excluded: ${event.reason}` })
88
+ },
89
+ ExecutorResumed: { line: "Normal", kind: roster },
90
+ ExecutorHandedOver: {
91
+ line: "Normal",
92
+ kind: roster,
93
+ digest: (event) => ({
94
+ text: `${event.from} handed over ${event.role} (${event.scope}): ${event.reason}`,
95
+ ...laned(event.label)
96
+ })
97
+ },
98
+ // The story's outcome already says whether the judge cleared it.
99
+ StoryJudged: {
100
+ line: "never",
101
+ kind: "a judge verdict",
102
+ digest: (event) => ({
103
+ text: `${event.lane} judged round ${event.round}: ${event.cleared ? "cleared" : `${event.issues} issues`}`,
104
+ lane: event.lane
105
+ })
106
+ },
107
+ RoundtableTurn: { line: "never", kind: "a roundtable turn" },
108
+ BriefReported: { line: "never", kind: "a brief report" },
109
+ Timed: {
110
+ line: "never",
111
+ kind: (event) => {
112
+ switch (event.kind) {
113
+ case "model":
114
+ return `the end of a ${event.label} call`
115
+ case "tool":
116
+ return `the end of tool ${event.label}`
117
+ case "gate":
118
+ return `the end of gate ${event.label}`
119
+ case "wait":
120
+ return `the end of a wait for the ${event.label}`
121
+ default:
122
+ return `the end of ${event.label}`
123
+ }
124
+ }
125
+ },
126
+ Began: {
127
+ // A gate or a setup may run for many minutes: its start is a line, so a
128
+ // long one reads as running, not stuck. Other work starts too often.
129
+ line: (event, verbosity) => event.kind === "gate" && verbosity !== "Quiet",
130
+ kind: (event) =>
131
+ event.kind === "model"
132
+ ? `the start of a ${event.label} call`
133
+ : `the start of ${event.kind} ${event.label}`,
134
+ digest: (event) => ({ text: `began ${event.kind} ${event.label}`, ...laned(event.lane) })
135
+ },
136
+ TasksPlanned: { line: "never", kind: other },
137
+ TaskStarted: { line: "never", kind: other },
138
+ TaskCompleted: { line: "never", kind: other },
139
+ StoryStatusChanged: {
140
+ line: "never",
141
+ kind: other,
142
+ digest: (event) => ({ text: `story ${event.id} ${event.status}`, lane: event.id })
143
+ },
144
+ CriterionChecked: {
145
+ line: "Normal",
146
+ kind: other,
147
+ digest: (event) => ({ text: `criterion ${event.tag} ${event.status}`, lane: event.story })
148
+ },
149
+ Parked: {
150
+ line: "Normal",
151
+ kind: other,
152
+ digest: (event) => ({ text: `parked ${event.item}: ${event.reason}`, lane: event.story })
153
+ }
154
+ }
155
+
156
+ // A tag and its event are one value's, so the entry found by the tag takes
157
+ // the event. TypeScript does not correlate the two across the union; reading
158
+ // the entry through a generic key is the step that lets it.
159
+ const entryOf = <K extends FlowEventTag>(tag: K): Presentation<FlowEventByTag[K]> =>
160
+ eventPresentation[tag]
161
+
162
+ /** The entry of an event, to apply to that event. */
163
+ export const presentationOf = (event: FlowEvent): Presentation<FlowEvent> => entryOf(event._tag)
164
+
165
+ /** The tags a digest shows: the entries with a `digest`. */
166
+ export const digestedTags: ReadonlyArray<string> = Object.entries(eventPresentation)
167
+ .filter(([, entry]) => entry.digest !== undefined)
168
+ .map(([tag]) => tag)
@@ -33,6 +33,7 @@ import {
33
33
  effortOf,
34
34
  type Role
35
35
  } from "@llm4ts/flow/Roster"
36
+ import { settingValue } from "@llm4ts/flow/Settings"
36
37
  import { apiPresetFor, asReadOnly, coderFor, withEnvironment, withModel } from "./Connectors.ts"
37
38
 
38
39
  type Environment = Readonly<Record<string, string | undefined>>
@@ -188,11 +189,11 @@ export interface RosterSource {
188
189
  export const loadRosterDocument = Effect.fn("@llm4ts/runner/ExecutorRoster.load")(function* (
189
190
  source: RosterSource
190
191
  ): Effect.fn.Return<RosterDocument | undefined, FlowError> {
191
- const chosen = source.environment.LLM4TS_ROSTER?.trim()
192
+ const chosen = settingValue("LLM4TS_ROSTER", source.environment)
192
193
  if (chosen === "none") {
193
194
  return undefined
194
195
  }
195
- const loaded = yield* chosen !== undefined && chosen.length > 0
196
+ const loaded = yield* chosen !== undefined
196
197
  ? Effect.flatMap(readRoster(source.files, chosen), (document) =>
197
198
  document === undefined
198
199
  ? Effect.fail(RosterInvalid.make({ path: chosen, violations: ["no roster file there"] }))
@@ -218,10 +219,7 @@ export const loadRosterDocument = Effect.fn("@llm4ts/runner/ExecutorRoster.load"
218
219
  return undefined
219
220
  }
220
221
  const { document, where } = loaded
221
- const ids = (source.environment.LLM4TS_EXECUTORS ?? "")
222
- .split(",")
223
- .map((id) => id.trim())
224
- .filter((id) => id.length > 0)
222
+ const ids = settingValue("LLM4TS_EXECUTORS", source.environment) ?? []
225
223
  const unknown = ids.filter((id) => !document.executors.some((spec) => spec.id === id))
226
224
  const narrowed = narrowRoster(document, ids)
227
225
  const violations = [
package/src/FlowArgs.ts CHANGED
@@ -2,6 +2,7 @@ import { readFile, stat } from "node:fs/promises"
2
2
  import { resolve } from "node:path"
3
3
  import * as Effect from "effect/Effect"
4
4
  import * as Schema from "effect/Schema"
5
+ import { connectorTable, offeredCoderNames } from "@llm4ts/core/Models"
5
6
 
6
7
  export interface FlowArgs {
7
8
  readonly promptText?: string
@@ -11,6 +12,10 @@ export interface FlowArgs {
11
12
  readonly version?: boolean
12
13
  }
13
14
 
15
+ const firstCredentialVariables = Object.values(connectorTable).flatMap((row) =>
16
+ row.kind === "Api" ? row.credentialVariables.slice(0, 1) : []
17
+ )
18
+
14
19
  export const FlowUsage =
15
20
  'usage: llm4ts ["<prompt>" | --prompt-file <path> | @<path>] [--repo <path>] | llm4ts doctor'
16
21
 
@@ -30,13 +35,13 @@ commands:
30
35
  doctor check connector availability and credentials
31
36
 
32
37
  environment:
33
- LLM4TS_CODER coding agent: claude|codex|gemini|pi|agy|grok|cursor|opencode (default: claude)
38
+ LLM4TS_CODER coding agent: ${offeredCoderNames.join("|")} (default: claude)
34
39
  LLM4TS_VERBOSITY terminal verbosity: quiet|normal|verbose
35
40
  LLM4TS_UI tree: the full-screen agent tree (ADR 0022; q returns to the classic view)
36
41
  LLM4TS_TRANSCRIPT on: record each seat's input and output under .llm4ts/transcripts/
37
42
  LLM4TS_OTEL on: export spans over OTLP (a local Phoenix at localhost:6006 unless OTEL_* says otherwise)
38
43
  LLM4TS_OTEL_CONTENT on|full: prompts, replies and tool I/O travel too (off by default)
39
- OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY
44
+ ${firstCredentialVariables.join(", ")}
40
45
  credentials for API providers
41
46
 
42
47
  A trace of each run is written under .llm4ts/ in the target repository.
package/src/FlowRunner.ts CHANGED
@@ -27,6 +27,7 @@ import { createConnectorRegistry } from "@llm4ts/core/providers/ConnectorFactori
27
27
  import { ConnectorIds } from "@llm4ts/core/Models"
28
28
  import { ProviderError } from "@llm4ts/core/Errors"
29
29
  import { makeCostTracker, type CostTracker } from "@llm4ts/flow/CostTracker"
30
+ import { settingValue } from "@llm4ts/flow/Settings"
30
31
  import { runGeminiAcpBridge } from "./NodeGeminiAcpBridge.ts"
31
32
  import { runGeminiModelBridge } from "./NodeGeminiModelBridge.ts"
32
33
  import {
@@ -51,7 +52,7 @@ import {
51
52
  type FlowError
52
53
  } from "@llm4ts/flow/FlowError"
53
54
  import {
54
- defaultDrainTimeout,
55
+ type DrainReport,
55
56
  FlowEvents,
56
57
  FlowEventsValues,
57
58
  Info,
@@ -264,12 +265,6 @@ export interface FlowRunnerBundle {
264
265
  readonly announceStartupNotices: Effect.Effect<void>
265
266
  }
266
267
 
267
- const truthy = (value: string | undefined): boolean =>
268
- value !== undefined && ["1", "true", "on", "yes"].includes(value.trim().toLowerCase())
269
-
270
- const switchedOff = (value: string | undefined): boolean =>
271
- value !== undefined && ["0", "false", "no", "off"].includes(value.trim().toLowerCase())
272
-
273
268
  const isPiSeat = (config: ConnectorConfig | undefined): config is CliConnectorConfig =>
274
269
  config instanceof CliConnectorConfig && config.connectorId.value === ConnectorIds.Pi.value
275
270
 
@@ -289,7 +284,7 @@ const geminiBridgeSeats = Effect.fn("@llm4ts/runner/FlowRunner.geminiBridgeSeats
289
284
  environment: Readonly<Record<string, string | undefined>>
290
285
  ): Effect.fn.Return<(config: ConnectorConfig) => ConnectorConfig, FlowError, Scope.Scope> {
291
286
  const identity = (config: ConnectorConfig): ConnectorConfig => config
292
- if (!truthy(environment.LLM4TS_GEMINI_BRIDGE)) {
287
+ if (!settingValue("LLM4TS_GEMINI_BRIDGE", environment)) {
293
288
  return identity
294
289
  }
295
290
  // The reasoning seat defaults to the coder, so a pi coder makes this a pi
@@ -304,7 +299,7 @@ const geminiBridgeSeats = Effect.fn("@llm4ts/runner/FlowRunner.geminiBridgeSeats
304
299
  const resolution = resolveBridgeModel(
305
300
  readPiModelsJson(),
306
301
  port,
307
- environment.LLM4TS_GEMINI_BRIDGE_MODEL
302
+ settingValue("LLM4TS_GEMINI_BRIDGE_MODEL", environment)
308
303
  )
309
304
  const problem = bridgeModelProblem(resolution, port)
310
305
  if (problem !== undefined || resolution._tag !== "Resolved") {
@@ -322,8 +317,9 @@ const geminiBridgeSeats = Effect.fn("@llm4ts/runner/FlowRunner.geminiBridgeSeats
322
317
  })
323
318
  )
324
319
  }
325
- const geminiModel =
326
- environment.LLM4TS_GEMINI_MODEL === undefined ? {} : { model: environment.LLM4TS_GEMINI_MODEL }
320
+ const requestModel = settingValue("LLM4TS_GEMINI_MODEL", environment)
321
+ const geminiModel = requestModel === undefined ? {} : { model: requestModel }
322
+ const token = settingValue("LLM4TS_GEMINI_BRIDGE_TOKEN", environment)
327
323
  if (mode.mode === "model") {
328
324
  // ADR 0037: one locked-down `gemini` call per request, in an empty
329
325
  // directory so no project GEMINI.md reaches the model.
@@ -337,10 +333,7 @@ const geminiBridgeSeats = Effect.fn("@llm4ts/runner/FlowRunner.geminiBridgeSeats
337
333
  executor: dependencies.process,
338
334
  temporaryFiles: dependencies.temporaryFiles ?? nodeTemporaryFiles,
339
335
  ...geminiModel,
340
- ...(environment.LLM4TS_GEMINI_BRIDGE_TOKEN === undefined ||
341
- environment.LLM4TS_GEMINI_BRIDGE_TOKEN.length === 0
342
- ? {}
343
- : { token: environment.LLM4TS_GEMINI_BRIDGE_TOKEN })
336
+ ...(token === undefined ? {} : { token })
344
337
  }).pipe(Effect.mapError(FlowLlmError.from))
345
338
  } else {
346
339
  yield* runGeminiAcpBridge({
@@ -368,8 +361,8 @@ export const runIdentityFromEnvironment = (
368
361
  options: Pick<FlowRunnerOptions, "runId" | "tracePath">,
369
362
  environment: Readonly<Record<string, string | undefined>>
370
363
  ): { readonly runId?: string; readonly tracePath?: string } => {
371
- const runId = nonBlank(options.runId) ?? nonBlank(environment["LLM4TS_RUN_ID"])
372
- const tracePath = nonBlank(options.tracePath) ?? nonBlank(environment["LLM4TS_TRACE_PATH"])
364
+ const runId = nonBlank(options.runId) ?? settingValue("LLM4TS_RUN_ID", environment)
365
+ const tracePath = nonBlank(options.tracePath) ?? settingValue("LLM4TS_TRACE_PATH", environment)
373
366
  return {
374
367
  ...(runId === undefined ? {} : { runId }),
375
368
  ...(tracePath === undefined ? {} : { tracePath })
@@ -521,7 +514,7 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
521
514
  // notably the judgment seat and the worktree seats `contextFor` rebinds.
522
515
  const runEnvironment = options.environment ?? process.env
523
516
  const estimateOptions = estimatedUsageOptionsFromEnv(runEnvironment)
524
- const meterUsage = options.estimateUsage ?? !switchedOff(runEnvironment.LLM4TS_ESTIMATE_USAGE)
517
+ const meterUsage = options.estimateUsage ?? settingValue("LLM4TS_ESTIMATE_USAGE", runEnvironment)
525
518
  // Every seat's spans (ADR 0026): content only when asked, an estimate on a
526
519
  // call the backend reports no usage for, and who served it when known.
527
520
  const seatOptions = (executor?: Effect.Effect<string | undefined>): TimedSeatOptions => ({
@@ -578,11 +571,7 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
578
571
  seatConfig: ConnectorConfig,
579
572
  environment: Readonly<Record<string, string | undefined>>
580
573
  ): Effect.Effect<JudgmentShape, FlowError> => {
581
- const backend =
582
- options.judgmentBackend ??
583
- (environment.LLM4TS_JUDGMENT_BACKEND?.trim().toLowerCase() === "typesafe"
584
- ? "typesafe"
585
- : "llm")
574
+ const backend = options.judgmentBackend ?? settingValue("LLM4TS_JUDGMENT_BACKEND", environment)
586
575
  if (backend !== "typesafe") {
587
576
  return Effect.succeed(
588
577
  makeLlmJudgment(
@@ -592,7 +581,7 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
592
581
  ...(seatConfig.model === undefined ? {} : { model: seatConfig.model }),
593
582
  // LLM4TS_JUDGMENT_BATCHING=shared-prefix sends each request's state
594
583
  // once with every question; anything else keeps them independent.
595
- ...(environment.LLM4TS_JUDGMENT_BATCHING?.trim().toLowerCase() === "shared-prefix"
584
+ ...(settingValue("LLM4TS_JUDGMENT_BATCHING", environment) === "shared-prefix"
596
585
  ? { batching: "shared-prefix" as const }
597
586
  : {})
598
587
  }),
@@ -987,6 +976,21 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
987
976
  }
988
977
  })
989
978
 
979
+ /** What the run prints when `drained` reports a consumer short. */
980
+ export const drainShortfall = (report: DrainReport): string => {
981
+ const behind = report.behind.map(
982
+ (consumer) => `${consumer.name} was ${consumer.missing.toString()} event(s) behind`
983
+ )
984
+ const late = report.late.map(
985
+ (consumer) =>
986
+ `${consumer.name} started listening after ${consumer.missed.toString()} event(s) were published`
987
+ )
988
+ return (
989
+ `some run events were still unconsumed: ${[...late, ...behind].join("; ")}. ` +
990
+ "The totals below, the ledger, and the trace may be short"
991
+ )
992
+ }
993
+
990
994
  /** `claude (coder·reviewer·judge ×3)` — how the run header names an executor. */
991
995
  const describeExecutor = (spec: ExecutorSpec): string =>
992
996
  `${spec.id} (${rolesOf(spec).join("·")} ×${spec.slots ?? 1})`
@@ -1004,7 +1008,8 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
1004
1008
  const dependencies = withRunStateIgnore(given)
1005
1009
  const startedAt = yield* Clock.currentTimeMillis
1006
1010
  const environment = options.environment ?? process.env
1007
- const verbosity = options.verbosity ?? parseVerbosity(environment.LLM4TS_VERBOSITY)
1011
+ const verbosity =
1012
+ options.verbosity ?? parseVerbosity(settingValue("LLM4TS_VERBOSITY", environment))
1008
1013
  const persistRun = options.persistRun ?? true
1009
1014
  // OpenTelemetry export (ADR 0026): decided from the environment, built once
1010
1015
  // per run, flushed when the run's scope closes.
@@ -1043,7 +1048,7 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
1043
1048
  const surface = tree?.surface ?? classic
1044
1049
  const palette = surface.palette
1045
1050
  const terminal = yield* consumeTerminalEvents(bundle.events, surface, verbosity, {
1046
- timestamps: environment.LLM4TS_TIMESTAMPS === "1" || environment.LLM4TS_TIMESTAMPS === "true"
1051
+ timestamps: settingValue("LLM4TS_TIMESTAMPS", environment)
1047
1052
  })
1048
1053
  if (verbosity !== "Quiet") {
1049
1054
  const reviewers = options.reviewers ?? []
@@ -1130,10 +1135,11 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
1130
1135
  tracePath === undefined
1131
1136
  ? bundle.context
1132
1137
  : { ...bundle.context, trace: { runId: bundle.runId, path: tracePath } }
1138
+ const flow = settingValue("LLM4TS_FLOW", environment)
1133
1139
  const runAttributes: Record<string, unknown> = {
1134
1140
  [attr.run]: bundle.runId,
1135
1141
  [attr.session]: bundle.runId,
1136
- ...(environment.LLM4TS_FLOW === undefined ? {} : { [attr.flow]: environment.LLM4TS_FLOW })
1142
+ ...(flow === undefined ? {} : { [attr.flow]: flow })
1137
1143
  }
1138
1144
  // The run is a CHAIN span every other span descends from or links to; its
1139
1145
  // attributes ride on all of them. Effect's own function spans stay
@@ -1159,33 +1165,13 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
1159
1165
  Effect.provideService(FlowContext, context),
1160
1166
  Effect.andThen((value) => Effect.as(enforceBudget, value)),
1161
1167
  Effect.ensuring(
1162
- Effect.all(
1163
- [
1164
- terminal.awaitDrained(),
1165
- Effect.all(
1166
- [
1167
- tracker.awaitDrained(bundle.events),
1168
- recorder === undefined ? Effect.succeed(true) : recorder.awaitDrained(bundle.events),
1169
- bundle.judgmentLog === undefined
1170
- ? Effect.succeed(true)
1171
- : bundle.judgmentLog.awaitDrained(bundle.events)
1172
- ],
1173
- { concurrency: "unbounded" }
1174
- )
1175
- ],
1176
- { concurrency: "unbounded" }
1177
- ).pipe(
1178
- // A consumer that gave up before catching up leaves the totals below
1179
- // short. Say so rather than printing a quietly incomplete summary.
1180
- Effect.flatMap(([, accounted]) =>
1181
- accounted.every((drained) => drained)
1182
- ? Effect.void
1183
- : surface.log(
1184
- palette.fail(
1185
- `some run events were still unconsumed after ${defaultDrainTimeout} — ` +
1186
- "the totals below, the ledger, and the trace may be short"
1187
- )
1188
- )
1168
+ // One wait for every consumer of the run: the terminal, the totals,
1169
+ // the trace, the judgment log. A consumer that fell short leaves the
1170
+ // totals below short, so the run names it instead of printing a
1171
+ // quietly incomplete summary.
1172
+ bundle.events.drained().pipe(
1173
+ Effect.flatMap((report) =>
1174
+ report.complete ? Effect.void : surface.log(palette.fail(drainShortfall(report)))
1189
1175
  ),
1190
1176
  Effect.andThen(tree === undefined ? Effect.void : tree.close),
1191
1177
  Effect.andThen(appendLedger),
@@ -1254,7 +1240,7 @@ const wantsAgentTree = (
1254
1240
  output: { readonly isTTY?: boolean; readonly columns?: number },
1255
1241
  input: { readonly isTTY?: boolean }
1256
1242
  ): boolean =>
1257
- environment.LLM4TS_UI === "tree" &&
1243
+ settingValue("LLM4TS_UI", environment) === "tree" &&
1258
1244
  environment.NO_COLOR === undefined &&
1259
1245
  output.isTTY === true &&
1260
1246
  input.isTTY === true &&
@@ -19,6 +19,9 @@ import {
19
19
  } from "@llm4ts/core/providers/GeminiCliProvider"
20
20
  import { nodeProcessExecutor } from "./NodeProcessExecutor.ts"
21
21
  import { nodeTemporaryFiles } from "./NodeTemporaryFiles.ts"
22
+ import { cliExecutable } from "@llm4ts/core/Models"
23
+
24
+ const executable = cliExecutable("GeminiCli")
22
25
 
23
26
  const failure = (message: string): ProviderError => ProviderError.make({ message })
24
27
 
@@ -47,7 +50,7 @@ export const makeNodeGeminiCliExecutor = (
47
50
  const supportsAdminPolicy: Effect.Effect<boolean> = Effect.suspend(() =>
48
51
  adminPolicy !== undefined
49
52
  ? Effect.succeed(adminPolicy)
50
- : executor.run(["gemini", "--help"], process.cwd(), {}).pipe(
53
+ : executor.run([executable, "--help"], process.cwd(), {}).pipe(
51
54
  Effect.map((result) =>
52
55
  geminiSupportsAdminPolicy([...result.stdout, ...result.stderr].join("\n"))
53
56
  ),
@@ -75,7 +78,7 @@ export const makeNodeGeminiCliExecutor = (
75
78
  )
76
79
  return {
77
80
  checkGeminiInstalled: executor
78
- .run(["gemini", "--version"], process.cwd(), {})
81
+ .run([executable, "--version"], process.cwd(), {})
79
82
  .pipe(
80
83
  Effect.flatMap((result) =>
81
84
  result.exitCode === 0
@@ -89,7 +92,7 @@ export const makeNodeGeminiCliExecutor = (
89
92
  const environment = yield* environmentFor(context)
90
93
  const args = yield* argsFor(config, context, "json")
91
94
  const result = yield* executor.runWithStdin(
92
- ["gemini", ...args],
95
+ [executable, ...args],
93
96
  context.cwd ?? process.cwd(),
94
97
  environment,
95
98
  prompt
@@ -111,7 +114,7 @@ export const makeNodeGeminiCliExecutor = (
111
114
  ([environment, args]) =>
112
115
  executor
113
116
  .runStreamingWithStdin(
114
- ["gemini", ...args],
117
+ [executable, ...args],
115
118
  context.cwd ?? process.cwd(),
116
119
  environment,
117
120
  prompt
@@ -34,6 +34,9 @@ import {
34
34
  requestPath,
35
35
  writeJson
36
36
  } from "./NodeGeminiAcpBridge.ts"
37
+ import { cliExecutable } from "@llm4ts/core/Models"
38
+
39
+ const executable = cliExecutable("GeminiCli")
37
40
 
38
41
  /**
39
42
  * The bridge's model route (ADR 0037): `/v1/chat/completions` in OpenAI's
@@ -131,7 +134,7 @@ const geminiTurn = (
131
134
  config.executor
132
135
  .runStreamingWithStdin(
133
136
  [
134
- "gemini",
137
+ executable,
135
138
  // Bound to its flag, so it can never read as a flag of its own.
136
139
  `--model=${model}`,
137
140
  "--output-format",
package/src/Otel.ts CHANGED
@@ -12,6 +12,7 @@ import * as OtlpExporter from "effect/observability/OtlpExporter"
12
12
  import * as OtlpMetrics from "effect/observability/OtlpMetrics"
13
13
  import * as OtlpSerialization from "effect/observability/OtlpSerialization"
14
14
  import * as OtlpTracer from "effect/observability/OtlpTracer"
15
+ import { settingValue } from "@llm4ts/flow/Settings"
15
16
  import { packageVersion } from "./Package.ts"
16
17
 
17
18
  export type OtelConfig =
@@ -23,8 +24,6 @@ export const phoenixTracesUrl = "http://localhost:6006/v1/traces"
23
24
 
24
25
  const truthy = (value: string | undefined): boolean =>
25
26
  /^(on|1|true|yes)$/iu.test(value?.trim() ?? "")
26
- const falsy = (value: string | undefined): boolean =>
27
- /^(off|0|false|no)$/iu.test(value?.trim() ?? "")
28
27
 
29
28
  /**
30
29
  * Off unless asked. An OTLP endpoint in the environment wins and is used as
@@ -34,7 +33,8 @@ const falsy = (value: string | undefined): boolean =>
34
33
  export const otelConfig = (
35
34
  environment: Readonly<Record<string, string | undefined>>
36
35
  ): OtelConfig => {
37
- if (truthy(environment.OTEL_SDK_DISABLED) || falsy(environment.LLM4TS_OTEL)) {
36
+ const asked = settingValue("LLM4TS_OTEL", environment)
37
+ if (truthy(environment.OTEL_SDK_DISABLED) || asked === false) {
38
38
  return { mode: "off" }
39
39
  }
40
40
  // Under the test runner the exporter's sleeps run on a TestClock nobody
@@ -49,9 +49,7 @@ export const otelConfig = (
49
49
  if (endpoint !== undefined && endpoint.length > 0) {
50
50
  return { mode: "env", endpoint }
51
51
  }
52
- return truthy(environment.LLM4TS_OTEL)
53
- ? { mode: "phoenix", tracesUrl: phoenixTracesUrl }
54
- : { mode: "off" }
52
+ return asked === true ? { mode: "phoenix", tracesUrl: phoenixTracesUrl } : { mode: "off" }
55
53
  }
56
54
 
57
55
  export interface OtelResource {
package/src/Packs.ts CHANGED
@@ -7,6 +7,7 @@ import type { PlanParseError } from "@llm4ts/flow/FlowError"
7
7
  import { loadPack, type Pack } from "@llm4ts/flow/Pack"
8
8
  import { loadPatternCards, type PatternCard } from "@llm4ts/flow/Patterns"
9
9
  import type { WorkspaceError, WorkspaceShape } from "@llm4ts/flow/Workspace"
10
+ import { settingValue } from "@llm4ts/flow/Settings"
10
11
  import {
11
12
  builtinKitsDir,
12
13
  discoverKits,
@@ -108,7 +109,7 @@ export const openPack = Effect.fn("@llm4ts/runner/Packs.openPack")(function* (op
108
109
  /** Kit tiers to search; defaults to the standard tiers around `launchDir` and `flowDir`. */
109
110
  readonly kits?: KitTierPaths
110
111
  }): Effect.fn.Return<OpenedPack, PackNotFound | WorkspaceError | PlanParseError> {
111
- const reference = options.environment.LLM4TS_PACK?.trim() || defaultPackName
112
+ const reference = settingValue("LLM4TS_PACK", options.environment) ?? defaultPackName
112
113
  const builtinDir = builtinKitsDir(options.flowDir)
113
114
  const tiers =
114
115
  options.kits ??
package/src/PiModels.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from "node:fs"
2
2
  import { homedir } from "node:os"
3
3
  import { join } from "node:path"
4
+ import { settingGiven, settingText } from "@llm4ts/flow/Settings"
4
5
 
5
6
  /**
6
7
  * Reading `pi`'s own model config (ADR 0016). Separate from `Doctor.ts` so
@@ -29,10 +30,7 @@ export const defaultReadPiModelsJson = (): string | undefined => {
29
30
 
30
31
  export const geminiBridgePort = (
31
32
  environment: Readonly<Record<string, string | undefined>>
32
- ): string =>
33
- isSet(environment.LLM4TS_GEMINI_BRIDGE_PORT)
34
- ? environment.LLM4TS_GEMINI_BRIDGE_PORT.trim()
35
- : defaultGeminiBridgePort
33
+ ): string => settingText("LLM4TS_GEMINI_BRIDGE_PORT", environment) ?? defaultGeminiBridgePort
36
34
 
37
35
  /**
38
36
  * Which route the bridge serves (ADR 0037): `acp` (ADR 0016's Anthropic
@@ -46,12 +44,10 @@ export type GeminiBridgeMode =
46
44
  export const geminiBridgeMode = (
47
45
  environment: Readonly<Record<string, string | undefined>>
48
46
  ): GeminiBridgeMode => {
49
- const value = (environment.LLM4TS_GEMINI_BRIDGE_MODE ?? "").trim().toLowerCase()
50
- return value === "" || value === "acp"
51
- ? { _tag: "Mode", mode: "acp" }
52
- : value === "model"
53
- ? { _tag: "Mode", mode: "model" }
54
- : { _tag: "Unknown", value }
47
+ const given = settingGiven("LLM4TS_GEMINI_BRIDGE_MODE", environment)
48
+ return given._tag === "Unreadable"
49
+ ? { _tag: "Unknown", value: given.text.toLowerCase() }
50
+ : { _tag: "Mode", mode: given._tag === "Read" ? given.value : "acp" }
55
51
  }
56
52
 
57
53
  /**