@plurnk/plurnk 0.74.0 → 0.76.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 (96) hide show
  1. package/.env.defaults +3 -4
  2. package/README.md +60 -23
  3. package/completions/_plurnk +13 -0
  4. package/completions/plurnk.bash +14 -0
  5. package/completions/plurnk.fish +37 -0
  6. package/conformance/agui-client.json +863 -0
  7. package/dist/agents.d.ts +11 -0
  8. package/dist/agents.d.ts.map +1 -0
  9. package/dist/agents.js +170 -0
  10. package/dist/agents.js.map +1 -0
  11. package/dist/agui.d.ts +1 -0
  12. package/dist/agui.d.ts.map +1 -1
  13. package/dist/agui.js +45 -5
  14. package/dist/agui.js.map +1 -1
  15. package/dist/agui_cli.d.ts +1 -4
  16. package/dist/agui_cli.d.ts.map +1 -1
  17. package/dist/agui_cli.js +29 -9
  18. package/dist/agui_cli.js.map +1 -1
  19. package/dist/build-info.json +1 -1
  20. package/dist/cli.d.ts.map +1 -1
  21. package/dist/cli.js +12 -7
  22. package/dist/cli.js.map +1 -1
  23. package/dist/color.d.ts +2 -0
  24. package/dist/color.d.ts.map +1 -0
  25. package/dist/color.js +5 -0
  26. package/dist/color.js.map +1 -0
  27. package/dist/completion.js +4 -4
  28. package/dist/completion.js.map +1 -1
  29. package/dist/diagnostics.d.ts +1 -1
  30. package/dist/diagnostics.d.ts.map +1 -1
  31. package/dist/diagnostics.js +4 -2
  32. package/dist/diagnostics.js.map +1 -1
  33. package/dist/dispatcher.d.ts +8 -8
  34. package/dist/dispatcher.d.ts.map +1 -1
  35. package/dist/dispatcher.js +196 -104
  36. package/dist/dispatcher.js.map +1 -1
  37. package/dist/envdefaults.d.ts.map +1 -1
  38. package/dist/envdefaults.js +4 -6
  39. package/dist/envdefaults.js.map +1 -1
  40. package/dist/markdown.d.ts +17 -0
  41. package/dist/markdown.d.ts.map +1 -0
  42. package/dist/markdown.js +234 -0
  43. package/dist/markdown.js.map +1 -0
  44. package/dist/mcp.d.ts +11 -1
  45. package/dist/mcp.d.ts.map +1 -1
  46. package/dist/mcp.js +213 -69
  47. package/dist/mcp.js.map +1 -1
  48. package/dist/paste.d.ts +1 -0
  49. package/dist/paste.d.ts.map +1 -1
  50. package/dist/paste.js +5 -0
  51. package/dist/paste.js.map +1 -1
  52. package/dist/paths.d.ts +2 -0
  53. package/dist/paths.d.ts.map +1 -0
  54. package/dist/paths.js +12 -0
  55. package/dist/paths.js.map +1 -0
  56. package/dist/plan.d.ts +12 -0
  57. package/dist/plan.d.ts.map +1 -0
  58. package/dist/plan.js +30 -0
  59. package/dist/plan.js.map +1 -0
  60. package/dist/proposal.d.ts +3 -5
  61. package/dist/proposal.d.ts.map +1 -1
  62. package/dist/proposal.js +54 -34
  63. package/dist/proposal.js.map +1 -1
  64. package/dist/reasoning-events.d.ts +23 -0
  65. package/dist/reasoning-events.d.ts.map +1 -0
  66. package/dist/reasoning-events.js +40 -0
  67. package/dist/reasoning-events.js.map +1 -0
  68. package/dist/reasoning.d.ts +11 -0
  69. package/dist/reasoning.d.ts.map +1 -0
  70. package/dist/reasoning.js +10 -0
  71. package/dist/reasoning.js.map +1 -0
  72. package/dist/render.d.ts +4 -0
  73. package/dist/render.d.ts.map +1 -1
  74. package/dist/render.js +93 -50
  75. package/dist/render.js.map +1 -1
  76. package/dist/skills.d.ts +7 -0
  77. package/dist/skills.d.ts.map +1 -0
  78. package/dist/skills.js +152 -0
  79. package/dist/skills.js.map +1 -0
  80. package/dist/stream.d.ts.map +1 -1
  81. package/dist/stream.js +11 -11
  82. package/dist/stream.js.map +1 -1
  83. package/dist/subcommands.d.ts +3 -0
  84. package/dist/subcommands.d.ts.map +1 -1
  85. package/dist/subcommands.js +32 -11
  86. package/dist/subcommands.js.map +1 -1
  87. package/dist/transport.d.ts +12 -4
  88. package/dist/transport.d.ts.map +1 -1
  89. package/dist/transport.js +77 -21
  90. package/dist/transport.js.map +1 -1
  91. package/dist/tui.d.ts +23 -13
  92. package/dist/tui.d.ts.map +1 -1
  93. package/dist/tui.js +335 -92
  94. package/dist/tui.js.map +1 -1
  95. package/man/plurnk.1 +153 -0
  96. package/package.json +16 -9
@@ -3,19 +3,20 @@
3
3
  // §2 (CLI mode) and §3 (TUI mode).
4
4
  import { parseArgs } from "node:util";
5
5
  import { readFile } from "node:fs/promises";
6
- import { isAbsolute, join, resolve } from "node:path";
7
- import { homedir } from "node:os";
6
+ import { isAbsolute, resolve } from "node:path";
8
7
  import { createRequire } from "node:module";
9
- import { parseAliasesFromEnv } from "@plurnk/plurnk-aliases";
10
8
  import { buildJsonError } from "./cli.js";
11
9
  import { loadFloor } from "./envdefaults.js";
12
10
  import { runCliViaBridge, runScriptViaBridge } from "./agui_cli.js";
13
11
  import { BridgeTransport } from "./transport.js";
14
12
  import { actionViaBridge, resolveWorld } from "./agui.js";
15
13
  import { runTui } from "./tui.js";
14
+ import { handleMcp } from "./mcp.js";
15
+ import { formatWorkerReasoning, readWorkerReasoning, setWorkerReasoning, } from "./reasoning.js";
16
16
  import { runModels, runWorkspaceList, runWorkspaceWorkers, runWorkspaceRename, runLogRead, runRead } from "./subcommands.js";
17
17
  import { ProblemError, report, clientConnectionRefused, isUnreachable, clientProblem, clientFlagInvalid, clientFlagMissingDependency, clientRuntimeError, clientSubcommandMissingArgument, clientSubcommandUnknownVerb, clientWorkerNotFound, } from "./diagnostics.js";
18
18
  import { formatBuildInfo, getBuildInfo } from "./build-info.js";
19
+ import { userConfigFile } from "./paths.js";
19
20
  // Read all of stdin to EOF. Called when stdin is piped (not a TTY) — never
20
21
  // blocks an interactive workspace because we gate on isTTY upstream.
21
22
  const readStdin = async () => {
@@ -43,19 +44,6 @@ export const resolveLoopFlags = (rawJson, auto = false) => {
43
44
  }
44
45
  return { ...parsed, ...(auto ? { auto: true } : {}) };
45
46
  };
46
- // #90 — resolve a model alias to a concrete "<provider>/<model>" from the CLIENT's
47
- // (always-fresh) env, so a long-lived daemon launched before the user set
48
- // PLURNK_MODEL_<alias> doesn't reject loop.run with "unknown alias" (the daemon's
49
- // launch env is frozen; ours isn't). First-slash split is lossless — provider is
50
- // before the first "/", the model id is the rest (may itself contain "/"). baseUrl
51
- // stays daemon-side. null → send bare {alias} and let the daemon resolve or fail.
52
- // parseAliasesFromEnv is fail-hard on a duplicate/dangling env config — let it throw.
53
- export const resolveModelSpec = (alias, env = process.env) => {
54
- if (alias === undefined)
55
- return undefined;
56
- const match = parseAliasesFromEnv(env).find((a) => a.alias === alias.toLowerCase());
57
- return match !== undefined ? `${match.provider}/${match.model}` : undefined;
58
- };
59
47
  // #132 — the client's per-workspace exec-policy layer: forward the closed
60
48
  // enable/disable grammar (PLURNK_EXECS_ONLY, PLURNK_EXECS_<TAG>=0|false) so the
61
49
  // daemon intersects it with its own ceiling (service ∧ client — subtractive,
@@ -77,6 +65,23 @@ export const collectExecsPolicy = (env = process.env) => {
77
65
  }
78
66
  return out;
79
67
  };
68
+ const MCP_CONFIGURATION_PREFIX = "PLURNK_MCP_";
69
+ const MCP_SERVICE_CONTROLS = new Set([
70
+ "PLURNK_MCP_CONNECT_TIMEOUT",
71
+ "PLURNK_MCP_REQUEST_TIMEOUT",
72
+ "PLURNK_MCP_ENABLED",
73
+ ]);
74
+ export const collectMcpConfiguration = (env = process.env) => {
75
+ const configuration = {};
76
+ for (const [key, value] of Object.entries(env)) {
77
+ if (value === undefined || !key.startsWith(MCP_CONFIGURATION_PREFIX))
78
+ continue;
79
+ if (MCP_SERVICE_CONTROLS.has(key.toUpperCase()))
80
+ continue;
81
+ configuration[key] = value;
82
+ }
83
+ return configuration;
84
+ };
80
85
  // projectRoot resolution: empty string = explicit headless (null on wire);
81
86
  // otherwise must be an absolute path. Caller passes cwd as default.
82
87
  export const resolveProjectRoot = (raw) => {
@@ -88,15 +93,18 @@ export const resolveProjectRoot = (raw) => {
88
93
  throw new ProblemError(clientFlagInvalid("--project-root", raw, "must be an absolute path"));
89
94
  return raw;
90
95
  };
91
- const USAGE = `usage: plurnk [--json] [--workspace <name>] [--worker <name>] [--model <alias>] [prompt...]
96
+ export const USAGE = `usage: plurnk [--json] [--workspace <name>] [--worker <name>] [--model <selector>] [--reasoning <policy>] [prompt...]
92
97
  <piped stdin> | plurnk [options] [prompt...]
93
- plurnk models [--json]
98
+ plurnk models [search...] [--provider <name>] [--all] [--offset <n>] [--limit <n>] [--json]
94
99
  plurnk workspace list [--json]
95
100
  plurnk workspace workers <name> [--json]
96
101
  plurnk workspace rename <name> <newname> [--json]
97
102
  plurnk log read --workspace <name> [--worker <name>]
98
103
  [--loop <id>] [--turn <id>] [--since <id>] [--limit <n>] [--json]
99
104
  plurnk read <loop>/<turn>/<seq> --workspace <name> [--worker <name>] [--json]
105
+ plurnk reasoning [policy] --workspace <name> [--worker <name>] [--json]
106
+ plurnk mcp [add <alias> <target> [options.json] | enable <alias> [options.json]
107
+ | disable|remove <alias> | oauth <alias> <callback-url>]
100
108
 
101
109
  Connects to the plurnk-service daemon. Run a single prompt one-shot
102
110
  (positional args, piped stdin, or both — positionals come first, stdin
@@ -104,17 +112,12 @@ is appended after a blank line). With no positionals and a TTY stdin,
104
112
  enters the interactive REPL. Read-only subcommands (models / workspace list /
105
113
  log read / read <coord>) inspect daemon state without running a loop.
106
114
 
107
- env (cascade, highest first: shell < --env-file < ./.env < ~/.plurnk/.env
108
- < ~/.plurnk/.env.defaults < the client's packaged .env.defaults floor):
115
+ env (cascade, low → high: packaged .env.defaults < $XDG_CONFIG_HOME/plurnk/.env
116
+ < ./.env < repeated --env-file flags (last wins) < shell):
109
117
  Works with no config at all.
110
118
  PLURNK_CLIENT_WORKSPACE resume/create a workspace by name. UNSET = the daemon
111
119
  mints a fresh, uniquely-named workspace per invocation.
112
120
  PLURNK_CLIENT_WORKER resume (or create) a named worker within that workspace
113
- PLURNK_MODEL model alias to use for every loop.run on this invocation.
114
- Shared with the daemon (user-level preference). --model
115
- overrides for this invocation only.
116
- PLURNK_MODEL_CHILD optional WORK/FORK/BARE provider alias. Unset = inherit the
117
- spawning loop's provider; interactive /child overrides.
118
121
  PLURNK_CLIENT_PROJECT_ROOT absolute path passed to workspace.create as the workspace's
119
122
  project_root (workspace for file ops). Default: cwd.
120
123
  Empty string = headless (no project_root, file ops 400).
@@ -122,9 +125,9 @@ env (cascade, highest first: shell < --env-file < ./.env < ~/.plurnk/.env
122
125
  PLURNK_AUTO when truthy, keep proposal authority inside the loop.
123
126
  Client-side only — proposals still go through the wire.
124
127
  PLURNK_CLIENT_JSON when truthy, same as --json for one-shot runs.
125
- PLURNK_QUESTIONS when truthy, let the model ask you via a SEND carrying
126
- signal 300 (shared intent the daemon reads it too).
127
- --questions overrides.
128
+ PLURNK_REQUEST_USER_INPUT when truthy, the conversation worker may ask you
129
+ through the question tool. TUI/nvim default on; the
130
+ one-shot CLI defaults off. --request_user_input overrides.
128
131
  PLURNK_AGUI_URL plurnk-agui bridge URL (e.g. http://127.0.0.1:8787). When
129
132
  set, a one-shot (text AND --json) runs THROUGH the bridge
130
133
  instead of raw daemon WS (the exclusive-portal path).
@@ -136,7 +139,10 @@ env (cascade, highest first: shell < --env-file < ./.env < ~/.plurnk/.env
136
139
  never forwarded. Subtractive only — the
137
140
  daemon intersects with its ceiling; the client can narrow,
138
141
  never re-enable. Shares the daemon's grammar; a workspace's
139
- .env carries its own. (MCP server configs are NOT sent.)
142
+ .env carries its own.
143
+ PLURNK_MCP_* raw server declarations accompany MCP list and enable.
144
+ Service controls do not. The daemon owns parsing,
145
+ activation, persistence, and credential expansion.
140
146
 
141
147
  options:
142
148
  -h, --help print this message and exit
@@ -151,10 +157,14 @@ options:
151
157
  auto-named workspace is created. Overrides PLURNK_CLIENT_WORKSPACE.
152
158
  --worker <name> resume (or create) the named worker within the workspace.
153
159
  Requires --workspace. Overrides PLURNK_CLIENT_WORKER.
154
- --model <alias> model alias to pass on every loop.run. Resolved
155
- server-side against PLURNK_MODEL_<alias>. Without
156
- this (and PLURNK_MODEL unset), the daemon uses its
157
- own boot-time PLURNK_MODEL.
160
+ --model <selector> persistently select the conversation worker's model
161
+ before the first loop (worker.model.set). A selector is
162
+ a declared alias or exact provider/model route. Without
163
+ this, the worker's durable model or the daemon's
164
+ boot-time default runs.
165
+ --reasoning <policy> persistently select the conversation worker's reasoning
166
+ policy before the first loop. The daemon validates the
167
+ policy against the selected parent and child models.
158
168
  --project-root <p> absolute path. Sent on workspace.create only; ignored
159
169
  on --workspace attach (daemon preserves stored value).
160
170
  Default: cwd. Empty string = headless. Overrides
@@ -166,10 +176,11 @@ options:
166
176
  --flags <json> raw LoopFlags JSON passthrough on every loop.run
167
177
  (e.g. '{"auto":true}' for unattended
168
178
  benchmark/automation runs).
169
- --questions let the model ask you with SEND signal 300 when it needs a
170
- decision multiple choice with a free-response escape,
171
- or an open question. Off by default. Overrides
172
- PLURNK_QUESTIONS. (Requires a daemon that emits that signal.)
179
+ --request_user_input let the model ask you through the question tool when it
180
+ needs a decision (multiple choice with a free-response
181
+ escape, or an open question). Off by default for the
182
+ one-shot CLI; on by default for the TUI. Overrides
183
+ PLURNK_REQUEST_USER_INPUT.
173
184
  --env-file <p> load env from <p> (errors if missing). Repeatable.
174
185
  --env-file-if-exists <p> same, but silently skip a missing file. Repeatable.
175
186
  --max-turns <n> per-loop turn cap (daemon default PLURNK_MAX_TURNS).
@@ -192,14 +203,19 @@ options:
192
203
  --turn <id> (log read) filter to a single turn id
193
204
  --since <id> (log read) return entries with id > <id>
194
205
  --limit <n> (log read) max entries to return (default 100)
206
+ --provider <name> (models) restrict the catalog to one provider
207
+ --all (models) include unconfigured models with readiness causes
208
+ --offset <n> (models) catalog page offset (default 0)
195
209
 
196
210
  subcommands:
197
- models list configured model aliases (providers.list)
211
+ models [search...] list the bounded daemon model catalog (models.list)
198
212
  workspace list list workspaces on the daemon (workspace.list)
199
213
  workspace workers <name> list workers in the named workspace (workspace.workers)
200
214
  workspace rename <a> <b> rename workspace <a> to <b> (workspace.rename — a workspace's
201
215
  name is a mutable handle; workers are immutable)
202
216
  log read --workspace ... read log entries from the named workspace's worker
217
+ reasoning [policy] inspect or set a worker's durable reasoning policy
218
+ mcp ... list and manage MCP servers for --workspace
203
219
  script <file.plk> run a .plk file: feed its DSL to op.parse, render the
204
220
  trace, exit by worst op status. Honors --workspace/--yolo
205
221
  /--project-root + membership flags. The daemon owns the
@@ -215,32 +231,45 @@ const dieJson = (code, problem) => {
215
231
  process.stdout.write(`${JSON.stringify(buildJsonError(problem))}\n`);
216
232
  process.exit(code);
217
233
  };
218
- // Env cascade, aligned with plurnk-service's ~/.plurnk layering so the two share
219
- // one config home. process.loadEnvFile only fills UNSET vars, so loading
220
- // highest-precedence-first yields:
221
- // shell > --env-file > --env-file-if-exists > ./.env > ~/.plurnk/.env
222
- // > ~/.plurnk/.env.defaults (the daemon family's rendered catalog)
223
- // > the client's OWN packaged .env.defaults (#141 — the self-serve floor:
224
- // the client is the one member the daemon cannot assemble).
225
- const loadEnvCascade = (envFiles, envFilesIfExists) => {
234
+ export const orderedEnvFiles = (args) => {
235
+ const files = [];
236
+ for (let index = 0; index < args.length; index += 1) {
237
+ const arg = args[index];
238
+ if (arg === "--")
239
+ break;
240
+ for (const [flag, required] of [["--env-file", true], ["--env-file-if-exists", false]]) {
241
+ if (arg === flag) {
242
+ const path = args[index + 1];
243
+ if (path !== undefined)
244
+ files.push({ path, required });
245
+ index += 1;
246
+ break;
247
+ }
248
+ if (arg.startsWith(`${flag}=`))
249
+ files.push({ path: arg.slice(flag.length + 1), required });
250
+ }
251
+ }
252
+ return files;
253
+ };
254
+ export const loadEnvCascade = (explicitFiles, userConfig = userConfigFile()) => {
226
255
  const ifExists = (p) => { try {
227
256
  process.loadEnvFile(p);
228
257
  }
229
258
  catch { /* optional layer */ } };
230
- for (const f of envFiles) {
259
+ for (const { path, required } of explicitFiles.toReversed()) {
260
+ if (!required) {
261
+ ifExists(path);
262
+ continue;
263
+ }
231
264
  try {
232
- process.loadEnvFile(f);
265
+ process.loadEnvFile(path);
233
266
  }
234
267
  catch {
235
- dieWith(64, clientFlagInvalid("--env-file", f, "file not found"));
268
+ dieWith(64, clientFlagInvalid("--env-file", path, "file not found"));
236
269
  }
237
270
  }
238
- for (const f of envFilesIfExists)
239
- ifExists(f);
240
271
  ifExists(".env");
241
- const home = join(homedir(), ".plurnk");
242
- ifExists(join(home, ".env"));
243
- ifExists(join(home, ".env.defaults"));
272
+ ifExists(userConfig);
244
273
  loadFloor();
245
274
  };
246
275
  // Map repeatable membership flags to workspace constraints.
@@ -254,11 +283,6 @@ export const buildSettings = async (values, cwd, env = process.env) => {
254
283
  const execs = collectExecsPolicy(env);
255
284
  if (Object.keys(execs).length > 0)
256
285
  settings.execs = execs;
257
- // svc#346 — enable model→user SEND signal 300 questions (per-workspace; the daemon
258
- // injects questions.md teaching + intersects its PLURNK_QUESTIONS ceiling).
259
- // Flag or bare env (shared user intent). The daemon owns refusal when off.
260
- if (values.questions === true || ["1", "true", "yes", "on"].includes((env.PLURNK_QUESTIONS ?? "").toLowerCase()))
261
- settings.questions = true;
262
286
  const mc = values["max-commands"];
263
287
  if (mc !== undefined) {
264
288
  const n = Number(mc);
@@ -277,25 +301,6 @@ export const buildSettings = async (values, cwd, env = process.env) => {
277
301
  }
278
302
  settings.filesItems = n;
279
303
  }
280
- const mdSpecs = values.md ?? [];
281
- if (mdSpecs.length > 0) {
282
- const mdDocs = [];
283
- for (const spec of mdSpecs) {
284
- const eq = spec.indexOf("=");
285
- if (eq <= 0)
286
- throw new ProblemError(clientFlagInvalid("--md", spec, "must be NAME=path"));
287
- const alias = spec.slice(0, eq);
288
- const raw = spec.slice(eq + 1);
289
- const abs = isAbsolute(raw) ? raw : resolve(cwd, raw);
290
- try {
291
- mdDocs.push({ alias, content: await readFile(abs, "utf8") });
292
- }
293
- catch (cause) {
294
- throw new ProblemError(clientFlagInvalid("--md", spec, `file not readable: ${cause instanceof Error ? cause.message : String(cause)}`));
295
- }
296
- }
297
- settings.mdDocs = mdDocs;
298
- }
299
304
  return settings;
300
305
  };
301
306
  // svc#235: discover.versions { service:{installed, latest?}, client:{latest?} }.
@@ -359,10 +364,22 @@ const runSubcommand = async (rpc, positionals, opts) => {
359
364
  const verb = positionals[0];
360
365
  const sub = positionals[1];
361
366
  if (verb === "models") {
362
- if (positionals.length > 1) {
363
- throw new ProblemError(clientSubcommandUnknownVerb(`models ${positionals.slice(1).join(" ")}`));
367
+ const offset = parseIntFlag(opts.values.offset, "--offset");
368
+ const limit = parseIntFlag(opts.values.limit, "--limit");
369
+ if (limit !== undefined && (limit < 1 || limit > 100)) {
370
+ throw new ProblemError(clientFlagInvalid("--limit", String(limit), "must be between 1 and 100 for models"));
364
371
  }
365
- return await runModels(rpc, { json: opts.json });
372
+ const search = positionals.slice(1).join(" ").trim();
373
+ return await runModels(rpc, {
374
+ json: opts.json,
375
+ query: {
376
+ ...(typeof opts.values.provider === "string" ? { provider: opts.values.provider } : {}),
377
+ ...(search.length > 0 ? { search } : {}),
378
+ ...(opts.values.all === true ? { availability: "all" } : {}),
379
+ ...(offset !== undefined ? { offset } : {}),
380
+ ...(limit !== undefined ? { limit } : {}),
381
+ },
382
+ });
366
383
  }
367
384
  if (verb === "workspace") {
368
385
  if (sub === "list") {
@@ -394,6 +411,32 @@ const runSubcommand = async (rpc, positionals, opts) => {
394
411
  }
395
412
  throw new ProblemError(clientSubcommandUnknownVerb(`workspace ${sub ?? "(missing)"}`, ["list", "workers", "rename"]));
396
413
  }
414
+ if (verb === "mcp") {
415
+ if (opts.workspaceName === undefined) {
416
+ throw new ProblemError(clientFlagMissingDependency("plurnk mcp", "--workspace (or PLURNK_CLIENT_WORKSPACE)"));
417
+ }
418
+ const result = await handleMcp(positionals.slice(1), rpc, opts.json ? () => undefined : (text) => process.stdout.write(text), { overlay: opts.mcpConfiguration });
419
+ if (result === null)
420
+ return 64;
421
+ if (opts.json)
422
+ process.stdout.write(`${JSON.stringify(result)}\n`);
423
+ return 0;
424
+ }
425
+ if (verb === "reasoning") {
426
+ if (opts.workspaceName === undefined) {
427
+ throw new ProblemError(clientFlagMissingDependency("plurnk reasoning", "--workspace (or PLURNK_CLIENT_WORKSPACE)"));
428
+ }
429
+ if (positionals.length > 2) {
430
+ throw new ProblemError(clientSubcommandUnknownVerb(`reasoning ${positionals.slice(1).join(" ")}`));
431
+ }
432
+ const reasoning = sub === undefined
433
+ ? await readWorkerReasoning(rpc)
434
+ : await setWorkerReasoning(rpc, sub);
435
+ process.stdout.write(opts.json
436
+ ? `${JSON.stringify(reasoning)}\n`
437
+ : formatWorkerReasoning(reasoning));
438
+ return 0;
439
+ }
397
440
  if (verb === "log") {
398
441
  if (sub !== "read") {
399
442
  throw new ProblemError(clientSubcommandUnknownVerb(`log ${sub ?? "(missing)"}`, ["read"]));
@@ -403,7 +446,8 @@ const runSubcommand = async (rpc, positionals, opts) => {
403
446
  }
404
447
  // The caller's threadId (--workspace) scopes the action to that workspace; the
405
448
  // module defaults reads to the conversation (model worker); --worker pins by name.
406
- const filters = { ...(await resolveWorkerId(rpc, opts.workerName) !== undefined ? { workerId: await resolveWorkerId(rpc, opts.workerName) } : {}) };
449
+ const workerId = await resolveWorkerId(rpc, opts.workerName);
450
+ const filters = { ...(workerId === undefined ? {} : { workerId }) };
407
451
  const loopId = parseIntFlag(opts.values.loop, "--loop");
408
452
  const turnId = parseIntFlag(opts.values.turn, "--turn");
409
453
  const sinceId = parseIntFlag(opts.values.since, "--since");
@@ -450,11 +494,12 @@ export const main = async (argv) => {
450
494
  workspace: { type: "string" },
451
495
  worker: { type: "string" },
452
496
  model: { type: "string" },
497
+ reasoning: { type: "string" },
453
498
  "project-root": { type: "string" },
454
499
  yolo: { type: "boolean" },
455
500
  auto: { type: "boolean" },
456
501
  flags: { type: "string" },
457
- questions: { type: "boolean" }, // --questions: allow the model to ask via SEND signal 300
502
+ "request-user-input": { type: "boolean" }, // --request_user_input: the worker may ask through the question tool
458
503
  "max-turns": { type: "string" },
459
504
  timeout: { type: "string" },
460
505
  // membership overlay — repeatable globs; service vocabulary
@@ -463,7 +508,6 @@ export const main = async (argv) => {
463
508
  view: { type: "string", multiple: true },
464
509
  // workspace-open settings (svc#231) + tighten-only ceilings (svc#232)
465
510
  "files-items": { type: "string" },
466
- md: { type: "string", multiple: true },
467
511
  "max-commands": { type: "string" },
468
512
  "no-git": { type: "boolean" },
469
513
  // log read filters
@@ -471,6 +515,9 @@ export const main = async (argv) => {
471
515
  turn: { type: "string" },
472
516
  since: { type: "string" },
473
517
  limit: { type: "string" },
518
+ provider: { type: "string" },
519
+ all: { type: "boolean" },
520
+ offset: { type: "string" },
474
521
  },
475
522
  });
476
523
  if (values.help) {
@@ -482,17 +529,17 @@ export const main = async (argv) => {
482
529
  process.stdout.write(`${formatBuildInfo(buildInfo)}\n`);
483
530
  process.exit(0);
484
531
  }
485
- // Shared ~/.plurnk env cascade (after parse so --env-file flags participate).
486
- loadEnvCascade(values["env-file"] ?? [], values["env-file-if-exists"] ?? []);
532
+ // Shared XDG user env cascade (after parse so --env-file flags participate).
533
+ loadEnvCascade(orderedEnvFiles(argv.slice(2)));
534
+ const mcpConfiguration = collectMcpConfiguration(process.env);
487
535
  // json OUTPUT MODE — flag or env (user-level, same name client+daemon would
488
536
  // read). One complete document on stdout, stderr silent, structured errors.
489
537
  const json = values.json === true || ["1", "true", "yes", "on"].includes((process.env.PLURNK_CLIENT_JSON ?? "").toLowerCase());
490
538
  if (!json)
491
539
  process.stderr.write(`plurnk: ${formatBuildInfo(buildInfo)}\n`);
492
- // Subcommand routing happens BEFORE prompt assembly: if positionals[0] is
493
- // a known read-only subcommand (models / workspace / log), we skip stdin
494
- // reading and prompt construction entirely.
495
- const SUBCOMMANDS = ["models", "workspace", "log", "read", "script"];
540
+ // State-command routing happens BEFORE prompt assembly, so inspection and
541
+ // deliberate configuration never consume stdin or become model prompts.
542
+ const SUBCOMMANDS = ["models", "workspace", "log", "read", "script", "mcp", "reasoning"];
496
543
  const subcommand = positionals[0];
497
544
  const isSubcommand = subcommand !== undefined && SUBCOMMANDS.includes(subcommand);
498
545
  // Assemble the prompt only if we're NOT running a subcommand.
@@ -510,11 +557,16 @@ export const main = async (argv) => {
510
557
  // PLURNK_CLIENT_JSON with no prompt is the interactive TUI — env shouldn't force CLI mode.
511
558
  }
512
559
  }
513
- // CLI flag overrides env; env overrides nothing.
560
+ // Client flags select client behavior. Provider defaults remain daemon-owned.
514
561
  const workspaceName = values.workspace ?? process.env.PLURNK_CLIENT_WORKSPACE;
515
562
  const workerName = values.worker ?? process.env.PLURNK_CLIENT_WORKER;
516
- const modelAlias = values.model ?? process.env.PLURNK_MODEL;
517
- const childAlias = process.env.PLURNK_MODEL_CHILD;
563
+ const modelSelector = values.model;
564
+ const reasoningPolicy = values.reasoning;
565
+ // {§worker-settings} — the worker's request-user-input rule: TUI/nvim default on,
566
+ // the one-shot CLI defaults off; the explicit flag always wins, then the env.
567
+ const requestUserInputEnv = ["1", "true", "yes", "on"].includes((process.env.PLURNK_REQUEST_USER_INPUT ?? "").toLowerCase());
568
+ const requestUserInputCli = values["request-user-input"] === true || (values["request-user-input"] === undefined && requestUserInputEnv);
569
+ const requestUserInputTui = values["request-user-input"] ?? (requestUserInputEnv || true);
518
570
  const yolo = values.yolo === true || ["1", "true", "yes", "on"].includes((process.env.PLURNK_CLIENT_YOLO ?? "").toLowerCase());
519
571
  if (workerName !== undefined && workspaceName === undefined) {
520
572
  dieWith(64, clientFlagMissingDependency("--worker (or PLURNK_CLIENT_WORKER)", "--workspace (or PLURNK_CLIENT_WORKSPACE)"));
@@ -535,9 +587,10 @@ export const main = async (argv) => {
535
587
  dieWith(cause.exitCode, cause.problem);
536
588
  dieWith(64, clientRuntimeError(cause));
537
589
  }
538
- // --questions / PLURNK_QUESTIONS is a SESSION setting (settings.questions in
539
- // buildSettings), NOT a loop flag svc#346 ruled it workspace-scoped (it also
540
- // gates the questions.md teaching, so capability + teaching arrive together).
590
+ // --request_user_input / PLURNK_REQUEST_USER_INPUT is the worker's own
591
+ // behavioral rule ({§worker-settings}): it rides the run's forwardedProps, the
592
+ // AG-UI thread binding persists it on the conversation worker, and it stays
593
+ // flippable between loops via worker.settings.set.
541
594
  const projectRootRaw = values["project-root"] ?? process.env.PLURNK_CLIENT_PROJECT_ROOT;
542
595
  const projectRoot = (() => {
543
596
  try {
@@ -592,7 +645,16 @@ export const main = async (argv) => {
592
645
  // thread == world (the model worker).
593
646
  const w = await world();
594
647
  const { constraints, settings } = await workspaceOptions();
595
- const code = await runCliViaBridge({ bridgeUrl, token: process.env.PLURNK_AGUI_TOKEN }, prompt, { threadId: workerName ?? w, workspace: w, ...(modelAlias !== undefined ? { alias: modelAlias } : {}), ...(resolveModelSpec(modelAlias) !== undefined ? { model: resolveModelSpec(modelAlias) } : {}), ...(childAlias !== undefined ? { childAlias } : {}), ...(resolveModelSpec(childAlias) !== undefined ? { childModel: resolveModelSpec(childAlias) } : {}), ...(loopFlags !== undefined ? { flags: loopFlags } : {}), ...(maxTurns !== undefined ? { maxTurns } : {}), ...(timeoutSec !== undefined ? { timeoutSec } : {}), yolo, json, projectRoot, constraints, settings });
648
+ // {§worker-model-selection} an explicit --model is a durable selection:
649
+ // persist it onto the conversation worker before the run, then run WITHOUT
650
+ // a per-loop model selector (the worker owns the model).
651
+ if (values.model !== undefined && modelSelector !== undefined) {
652
+ await actionViaBridge({ bridgeUrl, token: process.env.PLURNK_AGUI_TOKEN }, { threadId: workerName ?? w, workspace: w, kind: "worker.model.set", params: { selector: modelSelector } });
653
+ }
654
+ if (reasoningPolicy !== undefined) {
655
+ await actionViaBridge({ bridgeUrl, token: process.env.PLURNK_AGUI_TOKEN }, { threadId: workerName ?? w, workspace: w, kind: "worker.reasoning.set", params: { policy: reasoningPolicy } });
656
+ }
657
+ const code = await runCliViaBridge({ bridgeUrl, token: process.env.PLURNK_AGUI_TOKEN }, prompt, { threadId: workerName ?? w, workspace: w, requestUserInput: requestUserInputCli, ...(loopFlags !== undefined ? { flags: loopFlags } : {}), ...(maxTurns !== undefined ? { maxTurns } : {}), ...(timeoutSec !== undefined ? { timeoutSec } : {}), yolo, json, projectRoot, constraints, settings });
596
658
  // Let Node drain stdout before termination. A forced exit truncated large
597
659
  // --json records mid-string when notices made the pipe exceed its buffer.
598
660
  process.exitCode = code;
@@ -624,7 +686,7 @@ export const main = async (argv) => {
624
686
  // TUI through the bridge (no prompt): skip the WS connect + workspace.create — a
625
687
  // pure-bridge client has no direct daemon WS. The bridge owns the workspace; we
626
688
  // pass a threadId-named stub (the daemon workspace id is bridge-created). projectRoot
627
- // rides forwardedProps; PLURNK_AGUI_QUESTIONS gates questions bridge-side.
689
+ // rides forwardedProps.
628
690
  // (Per-workspace constraints/settings over the bridge are a follow-up.)
629
691
  if (bridgeUrl !== undefined && bridgeUrl.length > 0 && !isSubcommand && subcommand !== "script" && prompt.length === 0) {
630
692
  const w = await world();
@@ -641,15 +703,45 @@ export const main = async (argv) => {
641
703
  constraints,
642
704
  settings,
643
705
  });
644
- await runTui(transport, { id: 0, name: w }, { modelAlias, model: resolveModelSpec(modelAlias), childAlias, childModel: resolveModelSpec(childAlias), resolveModel: (a) => resolveModelSpec(a), yolo, loopFlags, maxTurns, projectRoot, workerName, client: CLIENT_ID_TUI });
645
- process.exitCode = 0;
646
- return;
706
+ try {
707
+ await runTui(transport, { id: 0, name: w }, {
708
+ modelSelector,
709
+ modelExplicit: values.model !== undefined,
710
+ reasoningPolicy,
711
+ reasoningExplicit: reasoningPolicy !== undefined,
712
+ requestUserInput: requestUserInputTui,
713
+ yolo,
714
+ loopFlags,
715
+ maxTurns,
716
+ projectRoot,
717
+ workerName,
718
+ client: CLIENT_ID_TUI,
719
+ mcpConfiguration,
720
+ });
721
+ process.exitCode = 0;
722
+ return;
723
+ }
724
+ catch (cause) {
725
+ transport.shutdown();
726
+ if (cause instanceof ProblemError)
727
+ dieWith(cause.exitCode, cause.problem);
728
+ if (isUnreachable(cause))
729
+ dieWith(1, clientConnectionRefused(bridgeUrl, cause));
730
+ dieWith(1, clientRuntimeError(cause));
731
+ }
647
732
  }
648
733
  // AG-UI+ is the ONLY wire (the WS transport is deleted). Subcommands + script
649
734
  // speak the action surface through a structural Caller.
650
735
  const target = { bridgeUrl, token: process.env.PLURNK_AGUI_TOKEN };
651
- const callerThread = workspaceName ?? "cli";
652
- const caller = { call: (method, params) => actionViaBridge(target, { threadId: callerThread, kind: method, params }) };
736
+ const callerThread = workerName ?? workspaceName ?? "cli";
737
+ const caller = {
738
+ call: (method, params) => actionViaBridge(target, {
739
+ threadId: callerThread,
740
+ ...(workspaceName !== undefined ? { workspace: workspaceName } : {}),
741
+ kind: method,
742
+ params,
743
+ }),
744
+ };
653
745
  try {
654
746
  // `plurnk script foo.plk` — feed a .plk file to op.parse over the action
655
747
  // surface. The client never parses the file; the module owns the grammar.
@@ -668,7 +760,7 @@ export const main = async (argv) => {
668
760
  }
669
761
  if (isSubcommand) {
670
762
  const exitCode = await runSubcommand(caller, positionals, {
671
- json, workspaceName, workerName, projectRoot, values,
763
+ json, workspaceName, workerName, projectRoot, values, mcpConfiguration,
672
764
  });
673
765
  process.exitCode = exitCode;
674
766
  return;