@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
package/dist/tui.js CHANGED
@@ -19,12 +19,16 @@ import { isAbsolute, resolve } from "node:path";
19
19
  import PasteFilter from "./paste.js";
20
20
  import { extractOpenPaths } from "./openpaths.js";
21
21
  import { pathPartial, completePath, dslOpPartial, completeOps, dslStatement } from "./completion.js";
22
- import { renderLogEntry, renderSummary, isPromptEntry, coordLabel, progressLabel, entryTarget, isEntryMaterialization } from "./render.js";
23
- import { renderProposalMenu, keyToResolution, isServerResolved, questionFromProposal, renderQuestionMenu, answerForLine } from "./proposal.js";
22
+ import { renderLogEntry, renderReasoning, renderReasoningPreview, renderSummary, isPromptEntry, progressLabel, entryTarget, isEntryMaterialization } from "./render.js";
23
+ import { renderProposalMenu, keyToResolution, isServerResolved, renderQuestionMenu, questionChoices, answerForQuestion, editInEditor } from "./proposal.js";
24
24
  import { ProblemError, renderDiagnostic, report, clientSubcommandUnknownVerb, NO_MODEL_HINT } from "./diagnostics.js";
25
25
  import StreamTrace, { inlineable, renderInline } from "./stream.js";
26
26
  import { runModels, runWorkspaceList, runWorkspaceWorkers, runLogRead } from "./subcommands.js";
27
+ import { Validator, } from "@plurnk/plurnk-contracts";
27
28
  import { handleMcp } from "./mcp.js";
29
+ import { handleSkills } from "./skills.js";
30
+ import { handleAgents } from "./agents.js";
31
+ import { formatWorkerReasoning, readWorkerReasoning, setWorkerReasoning, } from "./reasoning.js";
28
32
  export const renderTuiFailure = (cause) => {
29
33
  if (cause instanceof ProblemError) {
30
34
  return renderDiagnostic(cause.problem)
@@ -39,14 +43,15 @@ export const renderTuiFailure = (cause) => {
39
43
  // ambiguous (workspace or worker?) and is gone. /rename retargets the current
40
44
  // workspace's mutable handle (a worker's name is immutable — no /rename for workers).
41
45
  export const VERBS = [
42
- "help", "models", "workspaces", "workers", "log", "model", "child",
46
+ "help", "models", "workspaces", "workers", "log", "model", "child", "reasoning",
43
47
  "yolo", "workspace", "rename", "worker", "stop", "quit",
44
- "pick", "hide", "view", "drop", "members", "import", "script", "mcp",
48
+ "pick", "hide", "view", "drop", "members", "import", "script", "mcp", "editor",
45
49
  "accept", "reject", "cancel", "edit",
46
50
  ];
47
51
  export const TUI_HELP = [
48
- " /models /workspaces /workers /log [n] inspect",
49
- " /model <alias> · /child <alias|inherit> parent and child models",
52
+ " /models [search] /workspaces /workers /log [n] inspect",
53
+ " /model <selector> · /child <selector|inherit> parent and child models",
54
+ " /reasoning [off|adaptive|effort] inspect or set reasoning",
50
55
  " /yolo toggle local auto-accept",
51
56
  " /workspace [name] new workspace",
52
57
  " /rename <name> rename this workspace (a mutable handle)",
@@ -58,8 +63,13 @@ export const TUI_HELP = [
58
63
  " /members the model's resolved file universe (+ rules)",
59
64
  " /import <path> dump a local file's content into the prompt",
60
65
  " /script <path> run a .plk file (its DSL → op.parse)",
61
- " /mcp [definition.json] list or attach workspace MCP servers",
62
- " replace <file> · detach/reconnect <name> · oauth <name> <callback-url>",
66
+ " /mcp list available workspace MCP servers",
67
+ " add <alias> <target> [options.json] · enable/disable/remove <alias>",
68
+ " oauth <alias> <callback-url>",
69
+ " /agents list this Worker's outbound A2A agents",
70
+ " discover <url> · add <alias> <url> [options.json] · enable/disable/remove <alias>",
71
+ " /skills list this Worker's Agent Skills",
72
+ " discover <query|source> · add <name> <source> [--global] · enable/disable/remove <name>",
63
73
  " /accept /reject /cancel /edit resolve a pending proposal (or keys a/e/r/c)",
64
74
  " /stop cancel the running loop",
65
75
  " /quit exit",
@@ -67,6 +77,8 @@ export const TUI_HELP = [
67
77
  " ## LOOK0 (uri) off-run READ — inspect a uri for you, not the model",
68
78
  " Alt-p / Alt-n cycle ## LOOK0 through prior operations' targets",
69
79
  " Ctrl-J / Alt-Enter insert a ↵ newline (editable); Enter submits",
80
+ " Esc cancel the running loop; idle: clear the line",
81
+ " /editor · Alt-e compose the prompt line in $EDITOR",
70
82
  " Alt-m/s · Alt-R/L/Y/N/M · Alt-x quick verbs (nvim case): models workspaces runs",
71
83
  " log yolo workspace members stop · Alt-h help",
72
84
  ].join("\n") + "\n";
@@ -93,7 +105,7 @@ export const expandNewlines = (line) => line.replaceAll(NL_MARK, "\n");
93
105
  // owns a/e/n/p/u/w/k/l. Alt-b/f/d are skipped (readline word-ops).
94
106
  export const ALT_SHORTCUTS = Object.freeze({
95
107
  m: "/models", s: "/workspaces", R: "/workers", L: "/log",
96
- Y: "/yolo", N: "/workspace", M: "/members", x: "/stop", h: "/help",
108
+ Y: "/yolo", N: "/workspace", M: "/members", x: "/stop", h: "/help", e: "/editor",
97
109
  });
98
110
  // An Alt-<letter> keypress (ESC then a single letter, no `[`/`O` → not an arrow
99
111
  // or function key) mapped to its verb, or null. Case-sensitive (mirrors nvim).
@@ -140,8 +152,11 @@ export const parseSlash = (line) => {
140
152
  // Plain readline machinery only; no screen takeover, no terminal hell.
141
153
  // readline's async completer form (arity 2 → async). Verb/alias completion
142
154
  // answers synchronously; path positions read the local fs (co-location law)
143
- // and answer once readdir resolves.
144
- export const makeCompleter = (getAliases, cwd) => (line, callback) => {
155
+ // and answer once readdir resolves. A fragment holding a provider prefix
156
+ // (`/model openai/…`) completes lazily from one bounded, provider-scoped
157
+ // daemon catalog page ([§cli-plurnk-models]); the client never preloads or
158
+ // owns the Models.dev snapshot.
159
+ export const makeCompleter = (getAliases, cwd, getReasoningPolicies = () => [], getProviderModels = async () => []) => (line, callback) => {
145
160
  const verbFrag = line.match(/^\/(\w*)$/);
146
161
  if (verbFrag) {
147
162
  callback(null, [VERBS.map((v) => `/${v}`).filter((v) => v.startsWith(line)), line]);
@@ -149,18 +164,48 @@ export const makeCompleter = (getAliases, cwd) => (line, callback) => {
149
164
  }
150
165
  const aliasFrag = line.match(/^\/(model|child)\s+(\S*)$/);
151
166
  if (aliasFrag) {
167
+ const fragment = aliasFrag[2];
168
+ const provider = /^([A-Za-z0-9_-]+)\//.exec(fragment)?.[1];
169
+ if (provider !== undefined) {
170
+ getProviderModels(provider).then((selectors) => callback(null, [selectors.filter((selector) => selector.startsWith(fragment)), fragment]), () => callback(null, [[], fragment]));
171
+ return;
172
+ }
152
173
  const candidates = aliasFrag[1] === "child"
153
174
  ? ["inherit", ...getAliases().filter((alias) => alias !== "inherit")]
154
175
  : getAliases();
155
- callback(null, [candidates.filter((a) => a.startsWith(aliasFrag[2])), aliasFrag[2]]);
176
+ callback(null, [candidates.filter((a) => a.startsWith(fragment)), fragment]);
177
+ return;
178
+ }
179
+ const reasoningFrag = line.match(/^\/reasoning\s+(\S*)$/);
180
+ if (reasoningFrag) {
181
+ callback(null, [
182
+ getReasoningPolicies().filter((policy) => policy.startsWith(reasoningFrag[1])),
183
+ reasoningFrag[1],
184
+ ]);
156
185
  return;
157
186
  }
158
187
  const mcpFrag = line.match(/^\/mcp\s+(\S*)$/);
159
188
  if (mcpFrag) {
160
189
  const fragment = mcpFrag[1];
161
- const commands = ["replace", "detach", "reconnect", "oauth"]
190
+ const commands = ["add", "enable", "disable", "remove", "oauth"]
162
191
  .filter((command) => command.startsWith(fragment));
163
- void completePath(fragment, cwd).then(([paths]) => callback(null, [[...commands, ...paths], fragment]));
192
+ callback(null, [commands, fragment]);
193
+ return;
194
+ }
195
+ const agentsFrag = line.match(/^\/agents\s+(\S*)$/);
196
+ if (agentsFrag) {
197
+ const fragment = agentsFrag[1];
198
+ const commands = ["add", "discover", "enable", "disable", "remove"]
199
+ .filter((command) => command.startsWith(fragment));
200
+ callback(null, [commands, fragment]);
201
+ return;
202
+ }
203
+ const skillsFrag = line.match(/^\/skills\s+(\S*)$/);
204
+ if (skillsFrag) {
205
+ const fragment = skillsFrag[1];
206
+ const commands = ["add", "discover", "enable", "disable", "remove"]
207
+ .filter((command) => command.startsWith(fragment));
208
+ callback(null, [commands, fragment]);
164
209
  return;
165
210
  }
166
211
  const op = dslOpPartial(line);
@@ -190,26 +235,50 @@ export const seedPromptHistory = async (rpc, workspaceId, rl) => {
190
235
  catch { /* history is a convenience; never block the REPL */ }
191
236
  };
192
237
  // The one-line startup banner: version · workspace [· worker] · model · help. Pure so the
193
- // model-label resolution is unit-testable. modelLabel = the client's
194
- // --model/PLURNK_MODEL when set, else the daemon's active default
238
+ // model-label resolution is unit-testable. modelLabel = the client's explicit
239
+ // --model selector when set, else the daemon's active default
195
240
  // (providers.list `active`), else an honest fallback.
196
241
  export const buildHeader = (opts) => {
197
242
  const head = opts.versionNotice ?? "plurnk";
198
243
  const worker = opts.workerName !== undefined ? ` · worker: ${opts.workerName}` : "";
199
- const modelLabel = opts.modelAlias ?? opts.activeAlias ?? "(daemon default)";
244
+ const modelLabel = opts.modelSelector ?? opts.activeAlias ?? "(daemon default)";
245
+ const reasoning = opts.reasoningPolicy === undefined || opts.reasoningPolicy === null
246
+ ? ""
247
+ : ` · reasoning: ${opts.reasoningPolicy}`;
200
248
  const yolo = opts.yolo ? " · yolo: on" : "";
201
- return `${head} · workspace: ${opts.workspaceName}${worker} · model: ${modelLabel}${yolo} · /help`;
249
+ return `${head} · workspace: ${opts.workspaceName}${worker} · model: ${modelLabel}${reasoning}${yolo} · /help`;
202
250
  };
251
+ const modelRouteOrNull = (value) => value === null ? null : Validator.assertModelRoute(value);
252
+ const workerModelProjection = (value) => {
253
+ if (value === null || typeof value !== "object") {
254
+ throw new TypeError("worker.model.get returned no model projection");
255
+ }
256
+ const projection = value;
257
+ return {
258
+ model: modelRouteOrNull(projection.model),
259
+ spawnModel: modelRouteOrNull(projection.spawnModel),
260
+ };
261
+ };
262
+ export const resolvedModelLabel = (spec) => spec.alias ?? `${spec.provider}/${spec.model}`;
203
263
  export const handleVerb = async (line, ctx) => {
204
264
  const { verb, rest } = parseSlash(line);
205
265
  const { rpc, opts, write } = ctx;
266
+ const refreshWorkerPolicy = async () => {
267
+ const model = workerModelProjection(await rpc.call("worker.model.get"));
268
+ ctx.setModel(model.model);
269
+ ctx.setSpawnModel(model.spawnModel);
270
+ ctx.setReasoning(await readWorkerReasoning(rpc));
271
+ };
206
272
  switch (verb) {
207
273
  case "":
208
274
  case "help":
209
275
  write(TUI_HELP);
210
276
  return;
211
277
  case "models":
212
- await runModels(rpc, { json: false });
278
+ await runModels(rpc, {
279
+ json: false,
280
+ query: rest.length === 0 ? {} : { search: rest },
281
+ });
213
282
  return;
214
283
  case "workspaces":
215
284
  await runWorkspaceList(rpc, { json: false });
@@ -225,30 +294,53 @@ export const handleVerb = async (line, ctx) => {
225
294
  }
226
295
  case "model":
227
296
  if (rest.length === 0) {
228
- write(` model: ${opts.modelAlias ?? "(daemon default)"}\n`);
297
+ write(` model: ${ctx.model === null ? "(daemon default)" : resolvedModelLabel(ctx.model)}\n`);
298
+ return;
299
+ }
300
+ // {§worker-model-selection} — /model is a server-backed durable selection:
301
+ // the daemon resolves and persists onto the conversation worker; nothing
302
+ // client-local rides the next loop.
303
+ try {
304
+ ctx.setModel(Validator.assertModelRoute(await rpc.call("worker.model.set", { selector: rest })));
305
+ write(` model: ${rest}\n`);
306
+ }
307
+ catch (cause) {
308
+ write(` model set failed: ${cause instanceof Error ? cause.message : String(cause)}\n`);
229
309
  return;
230
310
  }
231
- opts.modelAlias = rest;
232
- // Retarget ROUTING too, not just the label: without this the next loop
233
- // keeps sending the BOOT model's resolved spec (which the daemon prefers
234
- // over the alias, #90) — the switch would be cosmetic.
235
- opts.model = ctx.resolveModel?.(rest);
236
- write(` model: ${rest}\n`);
311
+ try {
312
+ ctx.setReasoning(await readWorkerReasoning(rpc));
313
+ }
314
+ catch (cause) {
315
+ write(` reasoning refresh failed: ${cause instanceof Error ? cause.message : String(cause)}\n`);
316
+ }
317
+ return;
318
+ case "reasoning":
319
+ try {
320
+ const reasoning = rest.length === 0
321
+ ? await readWorkerReasoning(rpc)
322
+ : await setWorkerReasoning(rpc, rest);
323
+ ctx.setReasoning(reasoning);
324
+ write(`${formatWorkerReasoning(reasoning).trimEnd().replace(/^/gm, " ")}\n`);
325
+ }
326
+ catch (cause) {
327
+ write(`${renderTuiFailure(cause)}\n`);
328
+ }
237
329
  return;
238
330
  case "child":
239
331
  if (rest.length === 0) {
240
- write(` child: ${opts.childAlias ?? "inherit"}\n`);
332
+ write(` child: ${ctx.spawnModel === null ? "inherit" : resolvedModelLabel(ctx.spawnModel)}\n`);
241
333
  return;
242
334
  }
243
- if (rest === "inherit") {
244
- opts.childAlias = null;
245
- opts.childModel = undefined;
335
+ try {
336
+ // {§worker-model-selection} — inherit IS the server action (selector null
337
+ // clears the override); the daemon returns null for it.
338
+ ctx.setSpawnModel(modelRouteOrNull(await rpc.call("worker.child.set", { selector: rest === "inherit" ? null : rest })));
339
+ write(` child: ${rest}\n`);
246
340
  }
247
- else {
248
- opts.childAlias = rest;
249
- opts.childModel = ctx.resolveModel?.(rest);
341
+ catch (cause) {
342
+ write(` child set failed: ${cause instanceof Error ? cause.message : String(cause)}\n`);
250
343
  }
251
- write(` child: ${rest}\n`);
252
344
  return;
253
345
  case "yolo":
254
346
  opts.yolo = !opts.yolo;
@@ -261,6 +353,7 @@ export const handleVerb = async (line, ctx) => {
261
353
  // handle (/rename retargets it). client id (#249) + AGENTS override
262
354
  // (#268) ride the switch.
263
355
  ctx.setWorkspace(await ctx.switchWorkspace(rest.length > 0 ? rest : undefined));
356
+ await refreshWorkerPolicy();
264
357
  write(` workspace: ${ctx.getWorkspace().name} (new)\n`);
265
358
  return;
266
359
  }
@@ -277,11 +370,12 @@ export const handleVerb = async (line, ctx) => {
277
370
  return;
278
371
  }
279
372
  case "worker": {
280
- // New worker — worker.fork (svc#248) branches this conversation, optionally
373
+ // New worker — run.fork branches this conversation, optionally
281
374
  // named at instantiation (immutable after). Bind to the fork so the
282
375
  // next prompt speaks there. The workspace (the world) is unchanged.
283
- const forked = await rpc.call("worker.fork", rest.length > 0 ? { name: rest } : {});
376
+ const forked = await rpc.call("run.fork", rest.length > 0 ? { name: rest } : {});
284
377
  await rpc.call("workspace.attach", { id: ctx.getWorkspace().id, workerId: forked.workerId });
378
+ await refreshWorkerPolicy();
285
379
  write(` worker: ${forked.workerName} (new)\n`);
286
380
  return;
287
381
  }
@@ -309,8 +403,8 @@ export const handleVerb = async (line, ctx) => {
309
403
  write(` no constraint matching ${JSON.stringify(rest)}\n`);
310
404
  return;
311
405
  }
312
- for (const c of matches)
313
- await rpc.call("workspace.unconstrain", c);
406
+ for (const { effect, glob } of matches)
407
+ await rpc.call("workspace.unconstrain", { effect, glob });
314
408
  write(` dropped ${matches.length} constraint${matches.length === 1 ? "" : "s"} (${rest})\n`);
315
409
  return;
316
410
  }
@@ -371,7 +465,15 @@ export const handleVerb = async (line, ctx) => {
371
465
  return;
372
466
  }
373
467
  case "mcp": {
374
- await handleMcp(rest, rpc, write);
468
+ await handleMcp(rest, rpc, write, { overlay: opts.mcpConfiguration });
469
+ return;
470
+ }
471
+ case "skills": {
472
+ await handleSkills(rest, rpc, write);
473
+ return;
474
+ }
475
+ case "agents": {
476
+ await handleAgents(rest, rpc, write);
375
477
  return;
376
478
  }
377
479
  case "accept":
@@ -384,6 +486,9 @@ export const handleVerb = async (line, ctx) => {
384
486
  case "stop":
385
487
  await rpc.call("loop.cancel", { reason: "user_stop" });
386
488
  return;
489
+ case "editor":
490
+ await ctx.composeInEditor();
491
+ return;
387
492
  case "quit":
388
493
  return "quit";
389
494
  default:
@@ -393,12 +498,23 @@ export const handleVerb = async (line, ctx) => {
393
498
  };
394
499
  export const runTui = async (transport, workspace, opts) => {
395
500
  let current = workspace;
396
- // Highest loop_seq the waterfall has shown — the next prompt is one beyond.
397
- let lastLoopSeq = 0;
398
501
  // Loop state, hoisted so buildPrompt can show a steer prompt while a loop
399
502
  // runs and the line handler / SIGINT can share it.
400
503
  let inFlight = false;
401
504
  let cancelRequested = false;
505
+ // One cancel path for every interrupt gesture (Ctrl-C, Esc, /stop): the
506
+ // run's active drain cancels via loop.cancel; the pending loop resolves
507
+ // 499 and the REPL continues. A failed cancel SURFACES — a stop button
508
+ // that silently does nothing is the worst kind of broken.
509
+ const requestCancel = (reason) => {
510
+ if (cancelRequested)
511
+ return;
512
+ cancelRequested = true;
513
+ process.stdout.write("\r\x1b[2K \x1b[2mcancelling… (ctrl-c again to quit)\x1b[0m\n");
514
+ void transport.rpc("loop.cancel", { reason }).catch((err) => {
515
+ process.stdout.write(`\r\x1b[2K \x1b[31mcancel failed: ${err instanceof Error ? err.message : String(err)}\x1b[0m\n`);
516
+ });
517
+ };
402
518
  // Prompt status gutter (#114, refined): two reserved slots — 🧮 while
403
519
  // embeddings warm, and a busy glyph (⏳ working / 💤 hibernating) whenever a
404
520
  // loop OR embedding is active. Glyphs when active, blanks when not, so the
@@ -414,6 +530,7 @@ export const runTui = async (transport, workspace, opts) => {
414
530
  // Alt-n cycler — not synthesized log-entry coordinates. lookCursor walks them.
415
531
  const priorTargets = [];
416
532
  let lookCursor = null;
533
+ let liveReasoning = null;
417
534
  // Print a line ABOVE the live readline prompt without eating the user's
418
535
  // in-progress input. The canonical "log while prompting" idiom: wipe the
419
536
  // prompt row, write the line, then re-render the prompt + current buffer on
@@ -433,9 +550,10 @@ export const runTui = async (transport, workspace, opts) => {
433
550
  // Alias cache for /model completion + the active alias for the header —
434
551
  // one cheap RPC, refreshed never (aliases are daemon-boot-time config).
435
552
  // Awaited before the banner so the header can name the model the daemon
436
- // will actually use when --model/PLURNK_MODEL is unset (providers.list
553
+ // will actually use when --model is unset (providers.list
437
554
  // marks the boot-time default `active`).
438
555
  let aliasCache = [];
556
+ const providerModelCache = new Map();
439
557
  let activeAlias;
440
558
  // Alias list for completion + the header's active default. Both terminal gauges
441
559
  // ride the loop usage envelope; this client never reconstructs curation policy or
@@ -448,12 +566,50 @@ export const runTui = async (transport, workspace, opts) => {
448
566
  }
449
567
  }
450
568
  catch { /* completion stays empty; header falls back to (daemon default) */ }
569
+ // {§worker-model-selection} — the worker owns the model. Read the server truth
570
+ // for the header and the /model /child display; an EXPLICIT --model persists
571
+ // onto the worker at startup (a one-time durable selection, not a per-loop
572
+ // reassertion).
573
+ let workerModel = null;
574
+ let workerSpawnModel = null;
575
+ let workerReasoning = { policy: null, supportedPolicies: [] };
576
+ let reasoningFailure;
577
+ // Model identity is control-plane truth, not decorative header data. A
578
+ // transport failure or malformed projection leaves this client unable to
579
+ // know which durable worker policy it is presenting, so admission fails
580
+ // instead of silently relabeling the worker as the daemon default.
581
+ const initialModel = workerModelProjection(await transport.rpc("worker.model.get"));
582
+ workerModel = initialModel.model;
583
+ workerSpawnModel = initialModel.spawnModel;
584
+ if (opts.modelExplicit === true && opts.modelSelector !== undefined) {
585
+ // A deliberate selection is part of invocation admission, not display
586
+ // hydration. Refuse the TUI before it accepts input if the daemon cannot
587
+ // persist it; continuing would silently run the worker's previous model.
588
+ workerModel = Validator.assertModelRoute(await transport.rpc("worker.model.set", { selector: opts.modelSelector }));
589
+ }
590
+ if (opts.reasoningExplicit === true && opts.reasoningPolicy !== undefined) {
591
+ // Same admission rule as --model: an explicit policy must take effect or
592
+ // the invocation fails before any model work can run under stale policy.
593
+ workerReasoning = await setWorkerReasoning({ call: (method, params) => transport.rpc(method, params) }, opts.reasoningPolicy);
594
+ }
595
+ else {
596
+ try {
597
+ workerReasoning = await readWorkerReasoning({ call: (method, params) => transport.rpc(method, params) });
598
+ }
599
+ catch (cause) {
600
+ reasoningFailure = cause;
601
+ }
602
+ }
451
603
  // One header line: version · workspace [· worker] · model · help (see buildHeader).
452
604
  const header = buildHeader({
453
605
  versionNotice: opts.versionNotice, workspaceName: current.name, workerName: opts.workerName,
454
- modelAlias: opts.modelAlias, activeAlias, yolo: opts.yolo,
606
+ modelSelector: workerModel === null ? opts.modelSelector : resolvedModelLabel(workerModel), activeAlias,
607
+ reasoningPolicy: workerReasoning.policy,
608
+ yolo: opts.yolo,
455
609
  });
456
610
  process.stdout.write(`\x1b[2m${header}\x1b[0m\n\n`);
611
+ if (reasoningFailure !== undefined)
612
+ process.stdout.write(`${renderTuiFailure(reasoningFailure)}\n`);
457
613
  // The prompt is the user's row, restricted to WIDTH-STABLE glyphs —
458
614
  // settled empirically in two rounds. Round 1 (` 👤 ✉️ ✅ 201 : `)
459
615
  // drifted by exactly one column: ✉️ is U+2709+VS16, the variation-
@@ -468,10 +624,6 @@ export const runTui = async (transport, workspace, opts) => {
468
624
  // 💬 (U+1F4AC, stable-wide) fills the op slot the toxic ✉️ vacated.
469
625
  // Policy: VS16/ambiguous glyphs are banned from the ENTIRE palette
470
626
  // (render.ts) — stable widths are also what make columns align.
471
- // The prompt is the user's row, coordinate-prefixed like every other
472
- // waterfall line (§5.1). The coordinate is the next actionless prompt row.
473
- // lastLoopSeq tracks the highest loop the waterfall has shown; the next
474
- // prompt is one beyond it. Plain-ASCII coordinate — width-safe.
475
627
  // The prompt is the SAME whether or not a loop is running — kept alive above
476
628
  // the streaming traces by printAbove. The user just types; a line submitted
477
629
  // while a loop is in flight is folded into it (loop.inject), else it starts
@@ -506,10 +658,10 @@ export const runTui = async (transport, workspace, opts) => {
506
658
  const activePercent = embedding ? embeddingPercent
507
659
  : searchFetching ? searchPercent
508
660
  : branchPercent;
509
- const position = activePercent !== null
510
- ? progressLabel(activePercent)
511
- : coordLabel(lastLoopSeq + 1, 1, 1);
512
- return `${yolo}${position}${origin} ${status} \x1b[32m201\x1b[0m \x1b[1m: \x1b[0m`;
661
+ // No coordinate, no decorative code (plurnk#21): the prompt is
662
+ // `[gauge] 🐹 <status> : ` — the busy gauge appears only while active.
663
+ const position = activePercent !== null ? progressLabel(activePercent) : "";
664
+ return `${yolo}${position}${origin} ${status} \x1b[1m: \x1b[0m`;
513
665
  };
514
666
  // Bracketed-paste buffering (paste.ts): a multi-line paste must become ONE
515
667
  // prompt, not one loop.run per line. readline reads a PassThrough we feed
@@ -525,7 +677,19 @@ export const runTui = async (transport, workspace, opts) => {
525
677
  output: process.stdout,
526
678
  terminal: true,
527
679
  prompt: buildPrompt(),
528
- completer: makeCompleter(() => aliasCache, process.cwd()),
680
+ completer: makeCompleter(() => aliasCache, process.cwd(), () => workerReasoning.supportedPolicies, async (provider) => {
681
+ // One bounded page per provider, cached for the session: lazy,
682
+ // provider-scoped, never the whole catalog.
683
+ const cached = providerModelCache.get(provider);
684
+ if (cached !== undefined)
685
+ return cached;
686
+ const page = await transport.rpc("models.list", { provider, limit: 100 });
687
+ const selectors = (page.items ?? [])
688
+ .map(({ selector }) => selector)
689
+ .filter((selector) => typeof selector === "string");
690
+ providerModelCache.set(provider, selectors);
691
+ return selectors;
692
+ }),
529
693
  });
530
694
  const reprompt = () => {
531
695
  readline.cursorTo(process.stdout, 0);
@@ -542,11 +706,50 @@ export const runTui = async (transport, workspace, opts) => {
542
706
  rl.setPrompt(buildPrompt());
543
707
  rl.prompt(true);
544
708
  };
545
- // Now that rl exists, printAbove can re-render the prompt after each line.
546
- printAbove = (text) => {
709
+ const clearPromptAndLiveReasoning = () => {
547
710
  readline.cursorTo(process.stdout, 0);
548
711
  readline.clearLine(process.stdout, 0);
712
+ if (liveReasoning?.shown === true) {
713
+ readline.moveCursor(process.stdout, 0, -1);
714
+ readline.cursorTo(process.stdout, 0);
715
+ readline.clearLine(process.stdout, 0);
716
+ }
717
+ };
718
+ const redrawLiveReasoning = () => {
719
+ if (liveReasoning?.shown === true) {
720
+ process.stdout.write(`${renderReasoningPreview(liveReasoning.content, process.stdout.columns ?? 80)}\n`);
721
+ }
722
+ rl.prompt(true);
723
+ };
724
+ // Now that rl exists, printAbove can re-render the prompt after each line.
725
+ // A live reasoning preview remains the final row above it, so concurrent
726
+ // stream or worker activity cannot corrupt the preview's cursor ownership.
727
+ printAbove = (text) => {
728
+ clearPromptAndLiveReasoning();
549
729
  process.stdout.write(`${text}\n`);
730
+ redrawLiveReasoning();
731
+ };
732
+ const presentReasoning = (update) => {
733
+ if (update.phase === "start") {
734
+ if (liveReasoning !== null)
735
+ throw new TypeError("A second reasoning message started before the first ended.");
736
+ liveReasoning = { messageId: update.messageId, content: "", shown: false };
737
+ return;
738
+ }
739
+ if (liveReasoning === null || liveReasoning.messageId !== update.messageId) {
740
+ throw new TypeError(`Reasoning ${update.phase} did not match the active message.`);
741
+ }
742
+ clearPromptAndLiveReasoning();
743
+ if (update.phase === "content") {
744
+ liveReasoning.content = update.content;
745
+ liveReasoning.shown = update.content.length > 0;
746
+ redrawLiveReasoning();
747
+ return;
748
+ }
749
+ const content = update.content;
750
+ liveReasoning = null;
751
+ if (content.length > 0)
752
+ process.stdout.write(`${renderReasoning(content)}\n`);
550
753
  rl.prompt(true);
551
754
  };
552
755
  // Replace the whole readline buffer with `text`, public-API only: Ctrl-U kills
@@ -590,13 +793,32 @@ export const runTui = async (transport, workspace, opts) => {
590
793
  // handlers are forward-declared (assigned once verbCtx exists).
591
794
  let pendingProposal = null;
592
795
  let onProposalKey = () => { };
593
- // A pending SEND signal 300 question (#346): answered by TYPING (a number picks a
594
- // choice; anything else is free response), NOT the a/e/r/c keypress path —
595
- // free response needs text. The line handler intercepts while this is set.
796
+ // A pending request-user-input question ({§question-tool}): answered by TYPING
797
+ // (a number picks an enum choice; anything else is free response), NOT the
798
+ // a/e/r/c keypress path — free response needs text. The line handler
799
+ // intercepts while this is set.
596
800
  let pendingQuestion = null;
597
801
  let dispatchShortcut = () => { };
598
802
  const onStdin = (chunk) => {
599
- const forward = paste.feed(chunk.toString("utf8"));
803
+ const text = chunk.toString("utf8");
804
+ // Esc interrupts (plurnk#25) — the agent-CLI convention: bare Esc with
805
+ // a loop in flight cancels it (same path as the first Ctrl-C); idle it
806
+ // clears the composed line. Read BEFORE the paste filter, which would
807
+ // otherwise hold a lone ESC as a possible split \x1b[200~ prefix. An
808
+ // exact lone-ESC chunk while no paste is open is the KEY — the same
809
+ // whole-chunk rule the Alt-shortcuts rely on.
810
+ if (text === "\x1b" && paste.idle()) {
811
+ if (inFlight) {
812
+ requestCancel("user_escape");
813
+ return;
814
+ }
815
+ if (rl.line.length > 0) {
816
+ rl.write(null, { ctrl: true, name: "e" });
817
+ rl.write(null, { ctrl: true, name: "u" });
818
+ }
819
+ return;
820
+ }
821
+ const forward = paste.feed(text);
600
822
  if (forward.length === 0)
601
823
  return;
602
824
  // A pending proposal + an EMPTY prompt line: a single review key
@@ -745,9 +967,8 @@ export const runTui = async (transport, workspace, opts) => {
745
967
  repromptPreserving();
746
968
  };
747
969
  transport.subscribe({
970
+ onReasoning: presentReasoning,
748
971
  onEntry: (entry) => {
749
- if (entry.loop_seq > lastLoopSeq)
750
- lastLoopSeq = entry.loop_seq;
751
972
  // The typed line at the prompt is the user's record — rendering the
752
973
  // prompt broadcast too would duplicate it (see isPromptEntry).
753
974
  if (isPromptEntry(entry))
@@ -787,15 +1008,6 @@ export const runTui = async (transport, workspace, opts) => {
787
1008
  }
788
1009
  },
789
1010
  onProposal: (p) => {
790
- // SEND signal 300 question FIRST — even a yolo loop stops the world for a human;
791
- // the line handler resolves it from the next typed line.
792
- const q = questionFromProposal(p);
793
- if (q !== null) {
794
- pendingQuestion = { logEntryId: p.logEntryId, choices: q.choices };
795
- printAbove(renderQuestionMenu(q.question, q.choices));
796
- reprompt();
797
- return;
798
- }
799
1011
  if (isServerResolved(p))
800
1012
  return;
801
1013
  if (opts.yolo) {
@@ -806,6 +1018,14 @@ export const runTui = async (transport, workspace, opts) => {
806
1018
  proposalQueue.push(p);
807
1019
  showNextProposal();
808
1020
  },
1021
+ onInteraction: (i) => {
1022
+ // The question tool paused its loop: render the standard message + the
1023
+ // schema's single-property enum choices; the line handler resolves the
1024
+ // typed answer back into the paused run.
1025
+ pendingQuestion = { interactionId: i.interactionId, schema: i.responseSchema };
1026
+ printAbove(renderQuestionMenu(i.message, questionChoices(i.responseSchema)));
1027
+ reprompt();
1028
+ },
809
1029
  // A loop terminating just clears the parked glyph; the summary is rendered
810
1030
  // from the run's own done (below).
811
1031
  onTerminated: () => { hibernating = false; },
@@ -849,7 +1069,13 @@ export const runTui = async (transport, workspace, opts) => {
849
1069
  // Verb dispatch runs through the testable module-level handleVerb; this
850
1070
  // context injects the live workspace / opts / stdout / import glue.
851
1071
  const verbCtx = {
852
- rpc: verbRpc, opts, resolveModel: opts.resolveModel,
1072
+ rpc: verbRpc, opts,
1073
+ get model() { return workerModel; },
1074
+ get spawnModel() { return workerSpawnModel; },
1075
+ get reasoning() { return workerReasoning; },
1076
+ setModel: (spec) => { workerModel = spec; },
1077
+ setSpawnModel: (spec) => { workerSpawnModel = spec; },
1078
+ setReasoning: (reasoning) => { workerReasoning = reasoning; },
853
1079
  getWorkspace: () => current,
854
1080
  setWorkspace: (s) => { current = s; },
855
1081
  switchWorkspace: (name) => transport.useSession(name, { projectRoot: opts.projectRoot, client: opts.client }),
@@ -877,6 +1103,32 @@ export const runTui = async (transport, workspace, opts) => {
877
1103
  }
878
1104
  await resolvePending({ decision: action });
879
1105
  },
1106
+ // /editor · Alt-e (plurnk#26): seed $EDITOR with the composed line
1107
+ // (markers expanded), place the result BACK on the line — never
1108
+ // auto-submit (bash Ctrl-X Ctrl-E executes on exit; a prompt line
1109
+ // spends model tokens, so Enter stays the only submit). Same bounded
1110
+ // terminal handoff as proposal edit. Empty buffer ⇒ line unchanged.
1111
+ composeInEditor: async () => {
1112
+ process.stdin.off("data", onStdin);
1113
+ process.stdin.setRawMode?.(false);
1114
+ let edited = null;
1115
+ try {
1116
+ edited = await editInEditor(paste.expand(expandNewlines(rl.line)), ".md");
1117
+ }
1118
+ catch (cause) {
1119
+ process.stdout.write(`${renderTuiFailure(cause)}\n`);
1120
+ }
1121
+ finally {
1122
+ process.stdin.setRawMode?.(true);
1123
+ process.stdin.resume();
1124
+ process.stdin.on("data", onStdin);
1125
+ }
1126
+ if (edited === null)
1127
+ return;
1128
+ rl.write(null, { ctrl: true, name: "e" });
1129
+ rl.write(null, { ctrl: true, name: "u" });
1130
+ rl.write(paste.stash(edited.replace(/\n$/, "")));
1131
+ },
880
1132
  };
881
1133
  // Alt-<letter> shortcut → run the verb. Wipe the prompt line, dispatch
882
1134
  // through the same handleVerb the typed `/verb` uses (the partial prompt in
@@ -899,19 +1151,20 @@ export const runTui = async (transport, workspace, opts) => {
899
1151
  reprompt();
900
1152
  return new Promise((resolve) => {
901
1153
  rl.on("line", async (line) => {
902
- // A pending SEND signal 300 question consumes the typed line as its answer,
903
- // BEFORE any verb/prompt/inject handling (#346): a number picks a
904
- // choice, anything else is free response. Empty re-ask (don't
905
- // resolve with nothing). Resolves the world-stopped proposal via body.
1154
+ // A pending request-user-input question consumes the typed line as its
1155
+ // answer, BEFORE any verb/prompt/inject handling: a number picks an
1156
+ // enum choice, anything else is free response (or raw JSON for a
1157
+ // multi-property schema). Empty → re-ask. Resolves the paused run with
1158
+ // the standard ElicitResult payload.
906
1159
  if (pendingQuestion !== null) {
907
- const body = answerForLine(line, pendingQuestion.choices);
908
- if (body === null) {
1160
+ const content = answerForQuestion(line, pendingQuestion.schema);
1161
+ if (content === null) {
909
1162
  reprompt();
910
1163
  return;
911
1164
  }
912
- const { logEntryId } = pendingQuestion;
1165
+ const { interactionId } = pendingQuestion;
913
1166
  pendingQuestion = null;
914
- await transport.resolve({ logEntryId, decision: "accept", body })
1167
+ await transport.resolveInteraction(interactionId, { action: "accept", content })
915
1168
  .catch((e) => printAbove(` \x1b[31manswer failed: ${e instanceof Error ? e.message : String(e)}\x1b[0m`));
916
1169
  reprompt();
917
1170
  return;
@@ -998,15 +1251,9 @@ export const runTui = async (transport, workspace, opts) => {
998
1251
  // Prompt. `? ` = ask (read-only loop, flags.mode="ask");
999
1252
  // `: ` = act. Per-line prefix overrides --flags mode.
1000
1253
  const { flags: lineFlags, prompt: promptText } = lineMode(trimmed, opts.loopFlags);
1001
- const loopParams = { prompt: promptText };
1002
- if (opts.modelAlias !== undefined)
1003
- loopParams.alias = opts.modelAlias; // display label
1004
- if (opts.model !== undefined)
1005
- loopParams.model = opts.model; // #90 client-resolved routing (precedence)
1006
- if (opts.childAlias !== undefined)
1007
- loopParams.childAlias = opts.childAlias;
1008
- if (opts.childModel !== undefined)
1009
- loopParams.childModel = opts.childModel;
1254
+ // {§worker-model-selection} no model selector rides the loop: the
1255
+ // worker owns the model; /model and /child persisted it server-side.
1256
+ const loopParams = { prompt: promptText, requestUserInput: opts.requestUserInput !== false };
1010
1257
  if (lineFlags !== undefined && Object.keys(lineFlags).length > 0)
1011
1258
  loopParams.flags = lineFlags;
1012
1259
  if (opts.maxTurns !== undefined)
@@ -1057,11 +1304,7 @@ export const runTui = async (transport, workspace, opts) => {
1057
1304
  // unblock (op.parse). A failed cancel SURFACES — a stop button that
1058
1305
  // silently does nothing is the worst kind of broken.
1059
1306
  if (inFlight && !cancelRequested) {
1060
- cancelRequested = true;
1061
- process.stdout.write("\r\x1b[2K \x1b[2mcancelling… (ctrl-c again to quit)\x1b[0m\n");
1062
- void transport.rpc("loop.cancel", { reason: "user_sigint" }).catch((err) => {
1063
- process.stdout.write(`\r\x1b[2K \x1b[31mcancel failed: ${err instanceof Error ? err.message : String(err)}\x1b[0m\n`);
1064
- });
1307
+ requestCancel("user_sigint");
1065
1308
  return;
1066
1309
  }
1067
1310
  rl.close();