@gr8ful/spf 0.19.0 → 0.19.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.
Files changed (48) hide show
  1. package/README.md +8 -0
  2. package/assets/skill/references/config.md +1 -0
  3. package/dist/cli/commands/doctor.js +25 -3
  4. package/dist/cli/commands/estimate.d.ts +22 -6
  5. package/dist/cli/commands/estimate.js +32 -10
  6. package/dist/cli/commands/loop.d.ts +20 -0
  7. package/dist/cli/commands/loop.js +20 -1
  8. package/dist/cli/commands/ui.js +2 -1
  9. package/dist/cli/commands/watch.js +1 -1
  10. package/dist/cli/index.js +2 -2
  11. package/dist/cli/interview.js +13 -0
  12. package/dist/cli/ui/run_dashboard.js +13 -7
  13. package/dist/core/agent_cc.d.ts +19 -3
  14. package/dist/core/agent_cc.js +38 -18
  15. package/dist/core/agent_flue.js +51 -14
  16. package/dist/core/agent_opencode.d.ts +62 -25
  17. package/dist/core/agent_opencode.js +71 -30
  18. package/dist/core/agents.d.ts +51 -4
  19. package/dist/core/agents.js +79 -4
  20. package/dist/core/console.d.ts +24 -4
  21. package/dist/core/console.js +20 -7
  22. package/dist/core/data_types.d.ts +300 -19
  23. package/dist/core/data_types.js +134 -5
  24. package/dist/core/issues/jira_provider.d.ts +51 -1
  25. package/dist/core/issues/jira_provider.js +69 -1
  26. package/dist/core/issues/provider.d.ts +23 -0
  27. package/dist/core/loop.d.ts +39 -1
  28. package/dist/core/loop.js +33 -2
  29. package/dist/core/ollama_provider.d.ts +96 -13
  30. package/dist/core/ollama_provider.js +172 -26
  31. package/dist/core/otel.js +10 -1
  32. package/dist/core/otel_propagation.d.ts +168 -24
  33. package/dist/core/otel_propagation.js +219 -43
  34. package/dist/core/permissions.d.ts +16 -1
  35. package/dist/core/permissions.js +91 -3
  36. package/dist/core/providers.js +8 -3
  37. package/dist/core/refine.js +13 -1
  38. package/dist/core/runner.d.ts +33 -2
  39. package/dist/core/runner.js +40 -5
  40. package/dist/core/tiering.js +7 -3
  41. package/dist/core/tracer.d.ts +7 -1
  42. package/dist/core/tracer.js +15 -3
  43. package/dist/ui/server/db.d.ts +8 -1
  44. package/dist/ui/server/db.js +21 -4
  45. package/dist/ui/server/serve.d.ts +7 -0
  46. package/dist/ui/server/serve.js +10 -7
  47. package/dist/ui/shared/types.d.ts +16 -0
  48. package/package.json +1 -1
@@ -66,6 +66,30 @@ export class Run {
66
66
  phases = [];
67
67
  tokens = 0;
68
68
  cost = 0;
69
+ /** The BILLABLE half of `tokens` — see `UsageBreakdown.billable_tokens`'s doc comment. What `assertRunBudget` actually checks `defaults.max_run_tokens` against; `tokens` stays the display total. */
70
+ billable_tokens = 0;
71
+ /**
72
+ * True once this run has DISPATCHED at least one `claude_code` agent
73
+ * while `ANTHROPIC_BASE_URL` pointed somewhere other than Anthropic's own
74
+ * API — i.e. a gateway/proxy stood in for Anthropic on a call that
75
+ * actually happened. Read by `Console.sessionFinished` to label the run's
76
+ * printed cost as an estimate rather than a fact: `claude`'s own
77
+ * `total_cost_usd` is ANTHROPIC's price table applied to whatever the CLI
78
+ * thinks it called, which is honest only when Anthropic itself served the
79
+ * request.
80
+ *
81
+ * Starts `false` and is flipped by `recordDispatch()` below, called once
82
+ * per agent dispatch (`agents.ts`'s `execute()`, right before the real
83
+ * coding-agent call) — computed from what this run actually DISPATCHED,
84
+ * never from the roster's static shape (see `agents.ts`'s
85
+ * `isGatewayEstimatedDispatch` vs. `isGatewayEstimatedCost` doc comments
86
+ * for why that distinction matters: a chain can configure a `claude_code`
87
+ * agent it never actually calls this run, and labeling a real cost as
88
+ * "estimated" because the roster merely CONTAINS such an agent would be
89
+ * its own kind of dishonesty). Sticky: once a qualifying dispatch has
90
+ * happened, a later non-qualifying one must never flip it back to false.
91
+ */
92
+ cost_is_estimate = false;
69
93
  repo_root; // where every agent is spawned to work — always absolute
70
94
  /** Every git operation for this run, bound to repo_root. Never call git_helper directly. */
71
95
  git;
@@ -107,12 +131,23 @@ export class Run {
107
131
  this.agent_map[agent] = entry;
108
132
  writeFileSync(this.agentMapPath, JSON.stringify(this.agent_map, null, 2));
109
133
  }
134
+ /**
135
+ * Called once per agent dispatch, right before the real coding-agent call
136
+ * (`agents.ts`'s `execute()`) — see `cost_is_estimate`'s own doc comment
137
+ * for why this, not the roster, is what decides the label. Sticky: only
138
+ * ever flips `cost_is_estimate` from false to true, never back.
139
+ */
140
+ recordDispatch(agent) {
141
+ if (agents.isGatewayEstimatedDispatch(agent))
142
+ this.cost_is_estimate = true;
143
+ }
110
144
  // ── usage (run totals mirror what the tracer accumulates in the trace db) ─
111
- async addUsage(tokens, cost) {
145
+ async addUsage(tokens, cost, billableTokens) {
112
146
  this.tokens += tokens;
113
147
  this.cost += cost;
114
- await this.tracer.sessionAddUsage(this.adw_id, tokens, cost);
115
- await this.console.notifyUsage(this.tokens, this.cost);
148
+ this.billable_tokens += billableTokens;
149
+ await this.tracer.sessionAddUsage(this.adw_id, tokens, cost, billableTokens);
150
+ await this.console.notifyUsage(this.tokens, this.cost, this.billable_tokens);
116
151
  }
117
152
  // ── the phase primitive ─────────────────────────────────────────────────
118
153
  async phase(params, fn) {
@@ -157,7 +192,7 @@ export class Run {
157
192
  await this.tracer.phaseUpsert(phase);
158
193
  await this.tracer.sessionFinish(this.adw_id, false);
159
194
  await this.console.phaseEnded(phase, (performance.now() - clock) / 1000);
160
- await this.console.sessionFinished(false, this.tokens, this.cost, describeObservabilityDb(this.cfg.observability.db));
195
+ await this.console.sessionFinished(false, this.tokens, this.cost, describeObservabilityDb(this.cfg.observability.db), this.cost_is_estimate);
161
196
  throw error;
162
197
  }
163
198
  }
@@ -185,7 +220,7 @@ export class Run {
185
220
  await this.console.note(`not accepted: ${note}`);
186
221
  }
187
222
  await this.tracer.sessionFinish(this.adw_id, ok);
188
- await this.console.sessionFinished(ok, this.tokens, this.cost, describeObservabilityDb(this.cfg.observability.db));
223
+ await this.console.sessionFinished(ok, this.tokens, this.cost, describeObservabilityDb(this.cfg.observability.db), this.cost_is_estimate);
189
224
  return ok ? 0 : 1;
190
225
  }
191
226
  }
@@ -25,7 +25,7 @@
25
25
  * stands. `effectiveAgent` is the one place that turns a resolution into an
26
26
  * actual `AgentConfig` — overriding `model` and NOTHING else.
27
27
  */
28
- import { ollamaBaseUrl } from "./ollama_provider.js";
28
+ import { ollamaApiKey, ollamaBaseUrl } from "./ollama_provider.js";
29
29
  // ── the classifier (design doc §2) ──────────────────────────────────────────
30
30
  /**
31
31
  * One explicit `name -> weight` table entry per BUILT-IN chain
@@ -184,9 +184,13 @@ async function fetchServedOllamaTags() {
184
184
  try {
185
185
  // ollamaBaseUrl() is the SAME default-substitution a real dispatch
186
186
  // uses, including treating a set-but-EMPTY OLLAMA_BASE_URL as unset —
187
- // never re-derive that default here.
187
+ // never re-derive that default here. Same for ollamaApiKey(): against a
188
+ // gateway (not bare Ollama) this endpoint 401s without a bearer — send
189
+ // the SAME dummy-or-real bearer a real dispatch sends (byte-identical
190
+ // to doctor.ts's own OLLAMA_BASE_URL reachability probe), or this probe
191
+ // fails closed (401 -> null) even when a real dispatch would succeed.
188
192
  const url = ollamaBaseUrl().replace(/\/+$/, "") + "/models";
189
- const res = await fetch(url, { signal: controller.signal });
193
+ const res = await fetch(url, { signal: controller.signal, headers: { authorization: `Bearer ${ollamaApiKey()}` } });
190
194
  if (res.status !== 200)
191
195
  return null;
192
196
  const body = (await res.json());
@@ -87,7 +87,13 @@ export declare class Tracer {
87
87
  sessionStart(adwId: string, engineer: string, adwName?: string | null): Promise<void>;
88
88
  sessionRequest(adwId: string, request: string): Promise<void>;
89
89
  sessionFinish(adwId: string, ok: boolean): Promise<void>;
90
- sessionAddUsage(adwId: string, tokens: number, cost: number): Promise<void>;
90
+ /**
91
+ * `tokens`/`cost` are the DISPLAY totals (`total_tokens`, unchanged from
92
+ * before `billable_tokens` existed); `billableTokens` is the new column —
93
+ * see `UsageBreakdown.billable_tokens`'s doc comment for what it excludes
94
+ * and why. Both accumulate in the same row, same as before.
95
+ */
96
+ sessionAddUsage(adwId: string, tokens: number, cost: number, billableTokens: number): Promise<void>;
91
97
  /**
92
98
  * Record a live process for this run.
93
99
  *
@@ -123,6 +123,12 @@ const MIGRATIONS = [
123
123
  ["agent_sessions", "context_tokens", "INTEGER"],
124
124
  ["agent_sessions", "context_window", "INTEGER"],
125
125
  ["sessions", "archived", "INTEGER DEFAULT 0"],
126
+ // The BILLABLE half of `total_tokens` — see `UsageBreakdown.billable_tokens`'s
127
+ // own doc comment (`data_types.ts`) for why the two diverge. `total_tokens`
128
+ // is untouched (still every re-sent token, cache reads included); this is
129
+ // the new column `sessionAddUsage` also increments, so a trace db from
130
+ // before this existed just gets a column full of 0 until its next run.
131
+ ["sessions", "billable_tokens", "INTEGER DEFAULT 0"],
126
132
  ];
127
133
  export class Tracer {
128
134
  db;
@@ -285,10 +291,16 @@ export class Tracer {
285
291
  if (processesError)
286
292
  throw processesError;
287
293
  }
288
- async sessionAddUsage(adwId, tokens, cost) {
294
+ /**
295
+ * `tokens`/`cost` are the DISPLAY totals (`total_tokens`, unchanged from
296
+ * before `billable_tokens` existed); `billableTokens` is the new column —
297
+ * see `UsageBreakdown.billable_tokens`'s doc comment for what it excludes
298
+ * and why. Both accumulate in the same row, same as before.
299
+ */
300
+ async sessionAddUsage(adwId, tokens, cost, billableTokens) {
289
301
  await this.db
290
- .query("UPDATE sessions SET total_tokens=total_tokens+?, total_cost=total_cost+? WHERE adw_id=?")
291
- .run(tokens, cost, adwId);
302
+ .query("UPDATE sessions SET total_tokens=total_tokens+?, total_cost=total_cost+?, billable_tokens=billable_tokens+? WHERE adw_id=?")
303
+ .run(tokens, cost, billableTokens, adwId);
292
304
  }
293
305
  // ── processes (adw_id → pid, so a hung run can be found and killed) ─────
294
306
  /**
@@ -180,10 +180,17 @@ export interface ChainHistorySession {
180
180
  started_at: string | null;
181
181
  total_tokens: number;
182
182
  total_cost: number;
183
- /** In `phases.seq` order; one entry per phase iteration (e.g. `fix_1`, `fix_2`), not yet loop-normalized. */
183
+ /**
184
+ * In `phases.seq` order; one entry per phase iteration (e.g. `fix_1`,
185
+ * `fix_2`), not yet loop-normalized. `tokens` is the display total;
186
+ * `billable_tokens` is what `spf estimate`'s ceiling projection
187
+ * (`findCutoffPhase`) actually walks against `defaults.max_run_tokens` —
188
+ * see that field's own doc comment above for the old-row fallback.
189
+ */
184
190
  phases: {
185
191
  name: string;
186
192
  seq: number;
187
193
  tokens: number;
194
+ billable_tokens: number;
188
195
  }[];
189
196
  }
@@ -237,7 +237,7 @@ export class SfDb {
237
237
  const rows = await this.db
238
238
  .query(`SELECT adw_id, ${await this.optionalColumn("sessions", "adw_name")}, request,
239
239
  status, engineer, started_at, ended_at,
240
- total_tokens, total_cost,
240
+ total_tokens, total_cost, ${await this.optionalColumn("sessions", "billable_tokens")},
241
241
  ${await this.optionalColumn("sessions", "archived")}
242
242
  FROM sessions
243
243
  WHERE COALESCE(${(await this.hasColumn("sessions", "archived")) ? "archived" : "0"}, 0) = 0
@@ -282,7 +282,7 @@ export class SfDb {
282
282
  return ((await this.db
283
283
  .query(`SELECT adw_id, ${await this.optionalColumn("sessions", "adw_name")}, request,
284
284
  status, engineer, started_at, ended_at,
285
- total_tokens, total_cost
285
+ total_tokens, total_cost, ${await this.optionalColumn("sessions", "billable_tokens")}
286
286
  FROM sessions WHERE adw_id = ?`)
287
287
  .get(adwId)) ?? null);
288
288
  }
@@ -497,7 +497,7 @@ export class SfDb {
497
497
  const phaseRows = await this.chunked(ids, (chunk) => {
498
498
  const placeholders = chunk.map(() => "?").join(", ");
499
499
  return this.db
500
- .query(`SELECT p.adw_id, p.name, p.seq, e.tokens
500
+ .query(`SELECT p.adw_id, p.name, p.seq, e.tokens, e.payload_json
501
501
  FROM phases p LEFT JOIN events e ON e.phase_id = p.phase_id AND e.type = 'agent_end'
502
502
  WHERE p.adw_id IN (${placeholders})
503
503
  ORDER BY p.seq ASC`)
@@ -506,7 +506,24 @@ export class SfDb {
506
506
  const phasesByAdw = new Map();
507
507
  for (const row of phaseRows) {
508
508
  const list = phasesByAdw.get(row.adw_id);
509
- const entry = { name: row.name, seq: row.seq, tokens: row.tokens ?? 0 };
509
+ const totalTokens = row.tokens ?? 0;
510
+ // Derived from the payload, same "parse it in JS" approach `usage()`
511
+ // above already uses (never a stored column) — so a phase from before
512
+ // `billable_tokens` existed just falls back to the total, exactly
513
+ // like `Session.billable_tokens`'s own doc comment (`ui/shared/types.ts`)
514
+ // describes for the session-level number.
515
+ let billableTokens = totalTokens;
516
+ if (row.payload_json) {
517
+ try {
518
+ const usage = JSON.parse(row.payload_json).usage;
519
+ if (usage && typeof usage.billable_tokens === "number")
520
+ billableTokens = usage.billable_tokens;
521
+ }
522
+ catch {
523
+ /* a payload written by an older tracer simply falls back to the total */
524
+ }
525
+ }
526
+ const entry = { name: row.name, seq: row.seq, tokens: totalTokens, billable_tokens: billableTokens };
510
527
  if (list)
511
528
  list.push(entry);
512
529
  else
@@ -7,6 +7,13 @@ export interface UiOptions {
7
7
  webDir: string;
8
8
  /** Explicit port. Omit to try the default and probe upward on collision. */
9
9
  port?: number;
10
+ /**
11
+ * Bind address. Omit for the safe default (`127.0.0.1`, loopback-only).
12
+ * Set explicitly (e.g. `"0.0.0.0"`, or a specific interface IP) to make
13
+ * this reachable off-box — a deliberate widening, not something a caller
14
+ * should ever get by accident. See the module doc comment above.
15
+ */
16
+ host?: string;
10
17
  open?: boolean;
11
18
  }
12
19
  export interface UiHandle {
@@ -2,9 +2,11 @@
2
2
  * `spf ui` bootstrap: bind the app to a real port, print the URL, optionally
3
3
  * open a browser, and shut down cleanly on SIGINT/SIGTERM.
4
4
  *
5
- * Binds loopback-only (127.0.0.1) deliberately — this server can flip a
6
- * database column and reveal agent prompts, and a companion CLI has no
7
- * reason to listen on every interface.
5
+ * Binds loopback-only (127.0.0.1) by DEFAULT, deliberately — this server can
6
+ * flip a database column and reveal agent prompts, and a companion CLI has
7
+ * no reason to listen on every interface unless someone explicitly asks for
8
+ * it via `options.host` (`--host` on the CLI). Widening the bind is an
9
+ * opt-in the caller states on purpose, not a default — see issue #90.
8
10
  */
9
11
  import { spawn } from "node:child_process";
10
12
  import { serve } from "@hono/node-server";
@@ -15,9 +17,9 @@ const PORT_PROBE_ATTEMPTS = 10;
15
17
  function isAddrInUse(error) {
16
18
  return typeof error === "object" && error !== null && error.code === "EADDRINUSE";
17
19
  }
18
- function listen(app, port) {
20
+ function listen(app, port, hostname) {
19
21
  return new Promise((resolvePromise, reject) => {
20
- const server = serve({ fetch: app.fetch, port, hostname: "127.0.0.1" }, () => resolvePromise(server));
22
+ const server = serve({ fetch: app.fetch, port, hostname }, () => resolvePromise(server));
21
23
  server.on("error", reject);
22
24
  });
23
25
  }
@@ -37,6 +39,7 @@ function openUrl(url) {
37
39
  export async function runUi(options) {
38
40
  const db = await SfDb.open(options.db, options.sessionsDir);
39
41
  const app = createApp(db, options.webDir);
42
+ const host = options.host ?? "127.0.0.1";
40
43
  const explicit = options.port !== undefined;
41
44
  const startPort = options.port ?? DEFAULT_PORT;
42
45
  let server;
@@ -45,7 +48,7 @@ export async function runUi(options) {
45
48
  for (let i = 0; i < attempts; i++) {
46
49
  port = startPort + i;
47
50
  try {
48
- server = await listen(app, port);
51
+ server = await listen(app, port, host);
49
52
  break;
50
53
  }
51
54
  catch (error) {
@@ -61,7 +64,7 @@ export async function runUi(options) {
61
64
  }
62
65
  const info = server.address();
63
66
  const boundPort = info?.port ?? port;
64
- const url = `http://127.0.0.1:${boundPort}`;
67
+ const url = `http://${host}:${boundPort}`;
65
68
  const shouldOpen = options.open !== false && process.stdout.isTTY && !process.env.CI && !process.env.SSH_CONNECTION;
66
69
  if (shouldOpen)
67
70
  openUrl(url);
@@ -24,6 +24,22 @@ export interface Session {
24
24
  ended_at: string | null;
25
25
  total_tokens: number | null;
26
26
  total_cost: number | null;
27
+ /**
28
+ * The BILLABLE half of `total_tokens` — see `UsageBreakdown.billable_tokens`'s
29
+ * doc comment (`core/data_types.ts`) for why the two diverge.
30
+ *
31
+ * Two ways a row can predate real tracking, both meaning "fall back to
32
+ * `total_tokens`" (see `cli/commands/loop.ts`'s readback):
33
+ * - `null`: this db has never run the migration that adds the column at
34
+ * all (`optionalColumn` in `db.ts` substitutes `NULL AS billable_tokens`
35
+ * against it) — only possible for a db a writer Tracer has never opened
36
+ * since this column shipped.
37
+ * - `0` while `total_tokens > 0`: the column exists (a Tracer's
38
+ * `ALTER TABLE ... DEFAULT 0` ran) but this SPECIFIC row was written
39
+ * before that, so it was backfilled to 0 rather than to the true
40
+ * (unknowable, after the fact) billable figure.
41
+ */
42
+ billable_tokens: number | null;
27
43
  /** 1 once archived out of the review list. Review state, not run state. */
28
44
  archived: number | null;
29
45
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gr8ful/spf",
3
- "version": "0.19.0",
3
+ "version": "0.19.1",
4
4
  "description": "Super Portable Factory — a global CLI for repeatable agents-plus-code workflows (ADWs)",
5
5
  "type": "module",
6
6
  "license": "MIT",