@forwardimpact/libharness 1.2.1 → 1.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.
@@ -1,11 +1,23 @@
1
1
  import { Writable } from "node:stream";
2
2
  import { resolve } from "node:path";
3
3
  import { isoTimestamp } from "@forwardimpact/libutil";
4
+ import { createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
4
5
  import { createAgentRunner } from "../agent-runner.js";
5
- import { composeProfilePrompt } from "../profile-prompt.js";
6
+ import {
7
+ advisorGuidance,
8
+ createAdvisor,
9
+ createAdvisorBudget,
10
+ } from "../advisor.js";
11
+ import { advisorTool } from "../orchestration-toolkit.js";
12
+ import {
13
+ composeProfilePrompt,
14
+ composeSystemPrompt,
15
+ } from "../profile-prompt.js";
6
16
  import { createRedactor } from "../redaction.js";
7
17
  import { createTeeWriter } from "../tee-writer.js";
18
+ import { createTranscriptRecorder } from "../transcript-recorder.js";
8
19
  import { SequenceCounter } from "../sequence-counter.js";
20
+ import { parseAdvisorOptions } from "./advisor-flags.js";
9
21
  import { resolveWorkTracker } from "./work-tracker.js";
10
22
  import { resolveTaskContent } from "./task-input.js";
11
23
  import { createServiceConfig } from "@forwardimpact/libconfig";
@@ -15,7 +27,7 @@ import { AGENT_MODEL } from "@forwardimpact/libutil/models";
15
27
  * Parse and validate run command options from parsed values.
16
28
  * @param {object} values - Parsed option values from cli.parse()
17
29
  * @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
18
- * @returns {{ taskContent: string, cwd: string, model: string, maxTurns: number, outputPath: string|undefined, agentProfile: string|undefined, workTracker: string, allowedTools: string[] }}
30
+ * @returns {{ taskContent: string, taskAmend: string|undefined, cwd: string, agentModel: string, maxTurns: number, outputPath: string|undefined, agentProfile: string|undefined, workTracker: string, allowedTools: string[], mcpServer: string|undefined, advisorModel: string|undefined, advisorMaxUses: number }}
19
31
  */
20
32
  export function parseRunOptions(values, runtime) {
21
33
  const { task: taskContent, amend: taskAmend } = resolveTaskContent(
@@ -40,9 +52,148 @@ export function parseRunOptions(values, runtime) {
40
52
  "Bash,Read,Glob,Grep,Write,Edit,Agent,TodoWrite"
41
53
  ).split(","),
42
54
  mcpServer: values["mcp-server"] || undefined,
55
+ ...parseAdvisorOptions(values),
43
56
  };
44
57
  }
45
58
 
59
+ const devNull = new Writable({
60
+ write(_chunk, _enc, cb) {
61
+ cb();
62
+ },
63
+ });
64
+
65
+ /**
66
+ * Wire the run-mode agent session: external MCP entry, `LIBHARNESS_*` env
67
+ * writes, system-prompt composition, and — when an advisor model is set —
68
+ * the advisor wiring (budget, recorder, advisor session, dedicated MCP
69
+ * server holding only the `Advisor` tool). Extracted from `runRunCommand`
70
+ * so tests can inject a fake `query`.
71
+ *
72
+ * Run mode has no stop path (the command simply awaits the runner), so the
73
+ * consult timeout is deliberately the advisor's only guard.
74
+ *
75
+ * @param {object} deps
76
+ * @param {ReturnType<typeof parseRunOptions>} deps.opts
77
+ * @param {import("../redaction.js").Redactor} deps.redactor
78
+ * @param {import("stream").Writable} deps.output - Envelope NDJSON sink.
79
+ * @param {SequenceCounter} deps.counter
80
+ * @param {function} deps.query - SDK query function.
81
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} deps.runtime
82
+ * @returns {Promise<{runner: import("../agent-runner.js").AgentRunner, advisor: object|null}>}
83
+ */
84
+ export async function wireRunSession({
85
+ opts,
86
+ redactor,
87
+ output,
88
+ counter,
89
+ query,
90
+ runtime,
91
+ }) {
92
+ const emitEnvelope = (source, event) => {
93
+ output.write(
94
+ JSON.stringify(
95
+ redactor.redactValue({ source, seq: counter.next(), event }),
96
+ ) + "\n",
97
+ );
98
+ };
99
+ const onLine = (line) => emitEnvelope("agent", JSON.parse(line));
100
+
101
+ let mcpServers = null;
102
+ const allowedTools = opts.allowedTools;
103
+ if (opts.mcpServer) {
104
+ const mcpConfig = await createServiceConfig("mcp");
105
+ mcpServers = {
106
+ [opts.mcpServer]: {
107
+ type: "http",
108
+ url: mcpConfig.url,
109
+ headers: { Authorization: `Bearer ${mcpConfig.mcpToken()}` },
110
+ },
111
+ };
112
+ allowedTools.push(`mcp__${opts.mcpServer}__*`);
113
+ }
114
+
115
+ if (opts.agentProfile) {
116
+ runtime.proc.env.LIBHARNESS_AGENT_PROFILE = opts.agentProfile;
117
+ }
118
+ // Unconditional so the default "github" is observable to the agent's
119
+ // active-tracker resolution, mirroring --agent-profile's env write above.
120
+ runtime.proc.env.LIBHARNESS_WORK_TRACKER = opts.workTracker;
121
+
122
+ // With a profile, the consult guidance rides the profile composer's
123
+ // amendment parameter; with no profile, a preset-append prompt carries
124
+ // the guidance as its only session-protocol fragment. Advisor off and no
125
+ // profile means no system prompt — today's behavior, unchanged.
126
+ let systemPrompt;
127
+ if (opts.agentProfile) {
128
+ systemPrompt = composeProfilePrompt(opts.agentProfile, {
129
+ profilesDir: resolve(opts.cwd, ".claude/agents"),
130
+ runtime,
131
+ ...(opts.advisorModel && {
132
+ amend: advisorGuidance(opts.advisorMaxUses),
133
+ }),
134
+ });
135
+ } else if (opts.advisorModel) {
136
+ systemPrompt = composeSystemPrompt({
137
+ role: "agent",
138
+ trailer: advisorGuidance(opts.advisorMaxUses),
139
+ runtime,
140
+ });
141
+ }
142
+
143
+ let advisor = null;
144
+ let recorder = null;
145
+ if (opts.advisorModel) {
146
+ const budget = createAdvisorBudget(opts.advisorMaxUses);
147
+ recorder = createTranscriptRecorder({ systemPrompt, redactor });
148
+ advisor = createAdvisor({
149
+ model: opts.advisorModel,
150
+ cwd: opts.cwd,
151
+ query,
152
+ recorder,
153
+ redactor,
154
+ runtime,
155
+ onLine: (line) => emitEnvelope("advisor", JSON.parse(line)),
156
+ });
157
+ const advTool = advisorTool({
158
+ from: "agent",
159
+ consult: (q) => advisor.consult(q),
160
+ emit: (event) => emitEnvelope("orchestrator", event),
161
+ budget,
162
+ model: opts.advisorModel,
163
+ });
164
+ // No allowlist push: in-process SDK MCP servers work under
165
+ // bypassPermissions without allowlist entries (loop-mode precedent).
166
+ mcpServers = {
167
+ ...mcpServers,
168
+ advisor: createSdkMcpServer({ name: "advisor", tools: [advTool] }),
169
+ };
170
+ }
171
+
172
+ const runner = createAgentRunner({
173
+ cwd: opts.cwd,
174
+ query,
175
+ output: devNull,
176
+ model: opts.agentModel,
177
+ maxTurns: opts.maxTurns,
178
+ allowedTools,
179
+ onLine: recorder
180
+ ? (line) => {
181
+ onLine(line);
182
+ recorder.recordMessage(line);
183
+ }
184
+ : onLine,
185
+ ...(recorder && { onPrompt: (text) => recorder.recordPrompt(text) }),
186
+ settingSources: ["project"],
187
+ systemPrompt,
188
+ taskAmend: opts.taskAmend,
189
+ mcpServers,
190
+ redactor,
191
+ runtime,
192
+ });
193
+
194
+ return { runner, advisor };
195
+ }
196
+
46
197
  /**
47
198
  * Run command — execute a single agent via the Claude Agent SDK.
48
199
  *
@@ -53,18 +204,7 @@ export function parseRunOptions(values, runtime) {
53
204
  */
54
205
  export async function runRunCommand(ctx) {
55
206
  const runtime = ctx.deps.runtime;
56
- const {
57
- taskContent,
58
- taskAmend,
59
- cwd,
60
- agentModel,
61
- maxTurns,
62
- outputPath,
63
- agentProfile,
64
- workTracker,
65
- allowedTools,
66
- mcpServer,
67
- } = parseRunOptions(ctx.options, runtime);
207
+ const opts = parseRunOptions(ctx.options, runtime);
68
208
 
69
209
  // Build the redactor as the first observable side-effect after option
70
210
  // parsing — the env snapshot must freeze BEFORE any in-process
@@ -73,8 +213,8 @@ export async function runRunCommand(ctx) {
73
213
 
74
214
  // When --output is specified, stream text to stdout while writing NDJSON to file.
75
215
  // Otherwise, write NDJSON directly to stdout (backwards-compatible).
76
- const fileStream = outputPath
77
- ? runtime.fs.createWriteStream(outputPath)
216
+ const fileStream = opts.outputPath
217
+ ? runtime.fs.createWriteStream(opts.outputPath)
78
218
  : null;
79
219
  const output = fileStream
80
220
  ? createTeeWriter({
@@ -86,62 +226,17 @@ export async function runRunCommand(ctx) {
86
226
  : runtime.proc.stdout;
87
227
 
88
228
  const counter = new SequenceCounter();
89
- const devNull = new Writable({
90
- write(_chunk, _enc, cb) {
91
- cb();
92
- },
93
- });
94
- const onLine = (line) => {
95
- const event = JSON.parse(line);
96
- const tagged = { source: "agent", seq: counter.next(), event };
97
- output.write(JSON.stringify(redactor.redactValue(tagged)) + "\n");
98
- };
99
-
100
- let mcpServers = null;
101
- if (mcpServer) {
102
- const mcpConfig = await createServiceConfig("mcp");
103
- mcpServers = {
104
- [mcpServer]: {
105
- type: "http",
106
- url: mcpConfig.url,
107
- headers: { Authorization: `Bearer ${mcpConfig.mcpToken()}` },
108
- },
109
- };
110
- allowedTools.push(`mcp__${mcpServer}__*`);
111
- }
112
-
113
- if (agentProfile) {
114
- runtime.proc.env.LIBHARNESS_AGENT_PROFILE = agentProfile;
115
- }
116
- // Unconditional so the default "github" is observable to the agent's
117
- // active-tracker resolution, mirroring --agent-profile's env write above.
118
- runtime.proc.env.LIBHARNESS_WORK_TRACKER = workTracker;
119
-
120
- const systemPrompt = agentProfile
121
- ? composeProfilePrompt(agentProfile, {
122
- profilesDir: resolve(cwd, ".claude/agents"),
123
- runtime,
124
- })
125
- : undefined;
126
-
127
229
  const { query } = await import("@anthropic-ai/claude-agent-sdk");
128
- const runner = createAgentRunner({
129
- cwd,
130
- query,
131
- output: devNull,
132
- model: agentModel,
133
- maxTurns,
134
- allowedTools,
135
- onLine,
136
- settingSources: ["project"],
137
- systemPrompt,
138
- taskAmend,
139
- mcpServers,
230
+ const { runner } = await wireRunSession({
231
+ opts,
140
232
  redactor,
233
+ output,
234
+ counter,
235
+ query,
141
236
  runtime,
142
237
  });
143
238
 
144
- const result = await runner.run(taskContent);
239
+ const result = await runner.run(opts.taskContent);
145
240
 
146
241
  if (fileStream) {
147
242
  await new Promise((r) => output.end(r));
@@ -0,0 +1,155 @@
1
+ /**
2
+ * `fit-harness scan-logs` — scan a run's log archive for secret literals and
3
+ * fail closed.
4
+ *
5
+ * A run-lifecycle concern (not an NDJSON trace, so it lives here rather than
6
+ * in `fit-trace`): after a CI run that handled secrets, download or accept the
7
+ * run's own log archive and assert none of a supplied set of literals leaked
8
+ * into it. Any hit exits non-zero; any download/extract failure also exits
9
+ * non-zero — the gate must never silently disarm.
10
+ *
11
+ * Log resolution:
12
+ * - `--archive <zip>` — an already-resolved archive (extracted locally).
13
+ * - `--run-id <id> --repo <owner/repo>` — download this run's archive via
14
+ * `gh` first, then extract.
15
+ *
16
+ * Secrets are `--secret <label>=<literal>`, repeatable. The literal is
17
+ * everything after the FIRST `=` (JWTs and base64 keys contain `=`); the label
18
+ * is only cosmetic, named in the `FAIL:` line.
19
+ */
20
+
21
+ import { join } from "node:path";
22
+
23
+ /**
24
+ * Parse repeatable `--secret label=literal` flags. libcli's `multiple: true`
25
+ * yields an array from node's parseArgs in every case; tolerate a bare string
26
+ * or undefined defensively. Split on the FIRST `=` only.
27
+ *
28
+ * @param {string[]|string|undefined} secretOpt
29
+ * @returns {{label: string, literal: string}[]}
30
+ */
31
+ export function parseSecrets(secretOpt) {
32
+ const arr = Array.isArray(secretOpt)
33
+ ? secretOpt
34
+ : secretOpt
35
+ ? [secretOpt]
36
+ : [];
37
+ return arr.map((s) => {
38
+ const idx = s.indexOf("=");
39
+ if (idx === -1) return { label: s, literal: "" };
40
+ return { label: s.slice(0, idx), literal: s.slice(idx + 1) };
41
+ });
42
+ }
43
+
44
+ /**
45
+ * Walk a directory tree and return every file path. Uses per-level readdir so
46
+ * it works against both node:fs and the libmock fs (no `recursive` reliance).
47
+ */
48
+ async function collectFiles(dir, runtime) {
49
+ const out = [];
50
+ const entries = await runtime.fs.readdir(dir, { withFileTypes: true });
51
+ for (const ent of entries) {
52
+ const full = join(dir, ent.name);
53
+ if (ent.isDirectory()) {
54
+ out.push(...(await collectFiles(full, runtime)));
55
+ } else if (ent.isFile()) {
56
+ out.push(full);
57
+ }
58
+ }
59
+ return out;
60
+ }
61
+
62
+ /**
63
+ * Scan every file under `dir` for each secret literal. Returns the labels of
64
+ * secrets whose non-empty literal appears in any file (empty literals are
65
+ * skipped — a secret the run never set cannot leak).
66
+ *
67
+ * @param {object} params
68
+ * @param {string} params.dir
69
+ * @param {{label: string, literal: string}[]} params.secrets
70
+ * @param {import('@forwardimpact/libutil/runtime').Runtime} params.runtime
71
+ * @returns {Promise<string[]>} labels that hit
72
+ */
73
+ export async function scanDirectory({ dir, secrets, runtime }) {
74
+ const files = await collectFiles(dir, runtime);
75
+ const contents = await Promise.all(
76
+ files.map((f) => runtime.fs.readFile(f, "utf8").catch(() => "")),
77
+ );
78
+ const failures = [];
79
+ for (const { label, literal } of secrets) {
80
+ if (!literal) continue;
81
+ if (contents.some((c) => c.includes(literal))) failures.push(label);
82
+ }
83
+ return failures;
84
+ }
85
+
86
+ /**
87
+ * Resolve a directory of extracted log files, downloading the archive first
88
+ * when given a run id. Throws (→ fail closed) on any download or extract
89
+ * failure or on missing/invalid inputs.
90
+ */
91
+ async function resolveLogsDir({ options, runtime }) {
92
+ const tmpRoot = runtime.proc.env.RUNNER_TEMP || "/tmp";
93
+ const dir = await runtime.fs.mkdtemp(join(tmpRoot, "scan-logs-"));
94
+ let zip = options.archive;
95
+
96
+ if (!zip) {
97
+ const runId = options["run-id"];
98
+ const repo = options.repo;
99
+ if (!runId || !repo) {
100
+ throw new Error("requires --archive, or --run-id and --repo");
101
+ }
102
+ if (!/^\d+$/.test(String(runId))) {
103
+ throw new Error("--run-id must be numeric");
104
+ }
105
+ if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) {
106
+ throw new Error("--repo must be owner/name");
107
+ }
108
+ zip = join(dir, "run-logs.zip");
109
+ const dl = await runtime.subprocess.run("bash", [
110
+ "-c",
111
+ `gh api -H "Accept: application/vnd.github+json" ` +
112
+ `"/repos/${repo}/actions/runs/${runId}/logs" > "${zip}"`,
113
+ ]);
114
+ if (dl.exitCode !== 0) {
115
+ throw new Error(
116
+ `log archive download failed (gh exit ${dl.exitCode}): ${dl.stderr ?? ""}`,
117
+ );
118
+ }
119
+ }
120
+
121
+ const unz = await runtime.subprocess.run("unzip", ["-q", zip, "-d", dir]);
122
+ if (unz.exitCode !== 0) {
123
+ throw new Error(
124
+ `log archive empty/unreadable (unzip exit ${unz.exitCode})`,
125
+ );
126
+ }
127
+ return dir;
128
+ }
129
+
130
+ /**
131
+ * scan-logs command handler.
132
+ *
133
+ * @param {import("@forwardimpact/libcli").InvocationContext} ctx
134
+ * @returns {Promise<{ok: boolean, code: number, error?: string}>}
135
+ */
136
+ export async function runScanLogsCommand(ctx) {
137
+ const runtime = ctx.deps.runtime;
138
+ const options = ctx.options;
139
+ const secrets = parseSecrets(options.secret);
140
+
141
+ let dir;
142
+ try {
143
+ dir = await resolveLogsDir({ options, runtime });
144
+ } catch (err) {
145
+ // Fail closed: an unresolvable archive must not pass as "no leak". The
146
+ // dispatcher prints the returned `error`, so don't also write it here.
147
+ return { ok: false, code: 1, error: `scan-logs: ${err.message}` };
148
+ }
149
+
150
+ const failures = await scanDirectory({ dir, secrets, runtime });
151
+ for (const label of failures) {
152
+ runtime.proc.stderr.write(`FAIL: ${label} literal in run logs\n`);
153
+ }
154
+ return { ok: failures.length === 0, code: failures.length ? 1 : 0 };
155
+ }
@@ -3,6 +3,7 @@ import { isoTimestamp } from "@forwardimpact/libutil";
3
3
  import { createSupervisor } from "../supervisor.js";
4
4
  import { createRedactor } from "../redaction.js";
5
5
  import { createTeeWriter } from "../tee-writer.js";
6
+ import { parseAdvisorOptions } from "./advisor-flags.js";
6
7
  import { resolveTaskContent } from "./task-input.js";
7
8
  import { resolveWorkTracker } from "./work-tracker.js";
8
9
  import { createServiceConfig } from "@forwardimpact/libconfig";
@@ -52,6 +53,7 @@ export async function parseSuperviseOptions(values, runtime) {
52
53
  ? supervisorAllowedToolsRaw.split(",")
53
54
  : undefined,
54
55
  mcpServer: values["mcp-server"] || undefined,
56
+ ...parseAdvisorOptions(values),
55
57
  };
56
58
  }
57
59
 
@@ -125,6 +127,8 @@ export async function runSuperviseCommand(ctx) {
125
127
  agentMcpServers,
126
128
  redactor,
127
129
  runtime,
130
+ advisorModel: opts.advisorModel,
131
+ advisorMaxUses: opts.advisorMaxUses,
128
132
  });
129
133
 
130
134
  const result = await supervisor.run(opts.taskContent);
@@ -107,7 +107,7 @@ const ACKNOWLEDGE_DESC =
107
107
  "Acknowledge an Ask before starting work. Posts a visible comment on the thread. Does not discharge the Ask — you still owe an Answer.";
108
108
 
109
109
  /** Discuss-mode agent tool server. */
110
- export function createDiscussAgentToolServer(ctx, { from }) {
110
+ export function createDiscussAgentToolServer(ctx, { from, extraTools = [] }) {
111
111
  return orchestrationServer([
112
112
  ...baseTools(ctx, { from, defaultTo: "lead", broadcast: true }),
113
113
  requestForCommentTool(ctx),
@@ -133,6 +133,7 @@ export function createDiscussAgentToolServer(ctx, { from }) {
133
133
  return { content: [{ type: "text", text: "Acknowledged." }] };
134
134
  },
135
135
  ),
136
+ ...extraTools,
136
137
  ]);
137
138
  }
138
139
 
package/src/discusser.js CHANGED
@@ -22,7 +22,16 @@ import { ReplyEmitter } from "./reply-emitter.js";
22
22
  import { composeSystemPrompt } from "./profile-prompt.js";
23
23
  import { SequenceCounter } from "./sequence-counter.js";
24
24
  import { createMessageBus } from "./message-bus.js";
25
- import { createOrchestrationContext } from "./orchestration-toolkit.js";
25
+ import {
26
+ advisorTool,
27
+ createOrchestrationContext,
28
+ } from "./orchestration-toolkit.js";
29
+ import {
30
+ createAdvisor,
31
+ createAdvisorBudget,
32
+ withAdvisorGuidance,
33
+ } from "./advisor.js";
34
+ import { createTranscriptRecorder } from "./transcript-recorder.js";
26
35
  import {
27
36
  createDiscussLeadToolServer,
28
37
  createDiscussAgentToolServer,
@@ -206,6 +215,8 @@ export class Discusser {
206
215
  * @param {string|null} [deps.callbackUrl]
207
216
  * @param {string|null} [deps.inboxUrl]
208
217
  * @param {string|null} [deps.correlationId]
218
+ * @param {string} [deps.advisorModel] - Claude model for advisor consults; absent means no Advisor tool is offered.
219
+ * @param {number} [deps.advisorMaxUses] - Session-wide consult budget shared by all agent participants (default 3).
209
220
  * @returns {Discusser}
210
221
  */
211
222
  // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: factory wires N runners + resume hydration paths
@@ -228,6 +239,8 @@ export function createDiscusser({
228
239
  inboxUrl,
229
240
  correlationId,
230
241
  runtime,
242
+ advisorModel,
243
+ advisorMaxUses,
231
244
  }) {
232
245
  if (!redactor) throw new Error("redactor is required");
233
246
  if (!runtime) throw new Error("runtime is required");
@@ -306,11 +319,54 @@ export function createDiscusser({
306
319
  let discusser;
307
320
  const leadServer = createDiscussLeadToolServer(ctx);
308
321
 
322
+ // One budget per session, shared by every agent's Advisor handler.
323
+ const budget = advisorModel ? createAdvisorBudget(advisorMaxUses ?? 3) : null;
324
+
309
325
  const agents = resolvedConfigs.map((config) => {
326
+ // Everything advisor-shaped is gated on advisorModel; with it unset the
327
+ // composed prompt and tool surface are byte-identical to today's.
328
+ const systemPrompt = composeSystemPrompt({
329
+ role: "agent",
330
+ profile: config.agentProfile,
331
+ profilesDir: resolvedProfilesDir,
332
+ trailer: DISCUSS_AGENT_SYSTEM_PROMPT,
333
+ amend: withAdvisorGuidance(config.systemPromptAmend, budget),
334
+ runtime,
335
+ });
336
+
337
+ let recorder = null;
338
+ let extraTools;
339
+ if (advisorModel) {
340
+ recorder = createTranscriptRecorder({ systemPrompt, redactor });
341
+ // Late-bound through the `let discusser` closure — the instance does
342
+ // not exist yet when the advisor and tool are built.
343
+ const advisor = createAdvisor({
344
+ model: advisorModel,
345
+ cwd: config.cwd ?? resolvedLeadCwd,
346
+ query,
347
+ recorder,
348
+ redactor,
349
+ runtime,
350
+ onLine: (line) => discusser.loop.emitLine("advisor", line),
351
+ });
352
+ abortController.signal.addEventListener("abort", () => advisor.abort());
353
+ extraTools = [
354
+ advisorTool({
355
+ from: config.name,
356
+ consult: (q) => advisor.consult(q),
357
+ emit: (e) => discusser.loop.emitOrchestratorEvent(e),
358
+ budget,
359
+ model: advisorModel,
360
+ }),
361
+ ];
362
+ }
363
+
310
364
  const agentServer = createDiscussAgentToolServer(ctx, {
311
365
  from: config.name,
366
+ ...(extraTools && { extraTools }),
312
367
  });
313
368
 
369
+ const emitAgentLine = (line) => discusser.loop.emitLine(config.name, line);
314
370
  const runner = createAgentRunner({
315
371
  cwd: config.cwd ?? resolvedLeadCwd,
316
372
  query,
@@ -318,17 +374,16 @@ export function createDiscusser({
318
374
  model: agentModel ?? AGENT_MODEL,
319
375
  maxTurns: config.maxTurns ?? 50,
320
376
  allowedTools: config.allowedTools,
321
- onLine: (line) => discusser.loop.emitLine(config.name, line),
377
+ onLine: recorder
378
+ ? (line) => {
379
+ emitAgentLine(line);
380
+ recorder.recordMessage(line);
381
+ }
382
+ : emitAgentLine,
383
+ ...(recorder && { onPrompt: (text) => recorder.recordPrompt(text) }),
322
384
  mcpServers: { orchestration: agentServer },
323
385
  settingSources: ["project"],
324
- systemPrompt: composeSystemPrompt({
325
- role: "agent",
326
- profile: config.agentProfile,
327
- profilesDir: resolvedProfilesDir,
328
- trailer: DISCUSS_AGENT_SYSTEM_PROMPT,
329
- amend: config.systemPromptAmend,
330
- runtime,
331
- }),
386
+ systemPrompt,
332
387
  redactor,
333
388
  });
334
389