@turri-dev/cli 0.1.16 → 0.1.17-next.1ecfd4f

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.
package/dist/extension.js CHANGED
@@ -62,6 +62,8 @@ import { catalogueLine, chosenCommand, createCommandGate, startPicked, discoverS
62
62
  import { supersededByTurri } from "./chat-skills.js";
63
63
  import { conversationIdentity } from "./identity.js";
64
64
  import { setSurface, surfaceOf } from "./surface.js";
65
+ import { createChats, scopedToChat } from "./chats.js";
66
+ import { registerChats } from "./chat-commands.js";
65
67
  import { turriIdentity } from "./environment.js";
66
68
  import { conformanceChooser } from "./regenerate/choose-conformance.js";
67
69
  import { answerSideQuestion, askWithAsides, BTW_HOW, runningSkill, skillRunStarted, skillSessionText, transcriptOf } from "./btw.js";
@@ -201,33 +203,25 @@ function conversationSystemPrompt(base, host, blocks) {
201
203
  const prompt = [base, conversationIdentity(host.surface), ...blocks].filter((part) => part !== "").join("\n\n");
202
204
  return host.systemPrompt !== undefined ? host.systemPrompt(prompt) : prompt;
203
205
  }
204
- /** The host each conversation's jobs report to, for the work loop made for them later. */
206
+ /** Whether the person has been told which of pi's skills turri left out: once a session, not once a chat. */
207
+ let leftOutSaid = false;
208
+ /** The host each session's jobs report to. */
205
209
  const hosts = new WeakMap();
206
- /** turri's conversation as an extension of pi's, for a host that runs it itself: see TurriHost. */
207
- export function createTurriExtension(host = {}) {
208
- return (pi) => turriExtension(pi, host);
209
- }
210
- export default createTurriExtension();
211
- function turriExtension(pi, host) {
210
+ /**
211
+ * A turri session: its job registry and work loop, the ledger, and its chats, each with turri's extension in it
212
+ * (chats.ts). The terminal makes one as it starts (pi-launch.ts), a host with `createTurri` (host.ts); `persist`
213
+ * records the open chats for `turri --continue`.
214
+ */
215
+ export function createTurriCore(host = {}, options = {}) {
212
216
  dropNoise();
213
217
  const cwd = process.cwd();
214
- // turri's own skills, and the project's - .turri/skills, .agents/skills (skills.ts).
215
- const discoveredSkills = discoverSkills(PACKAGE_ROOT, cwd);
216
- // What the background work is doing right now, drawn above the editor while it runs.
217
- const panel = createJobPanel(() => shownJobs(jobs));
218
- // Finished work is announced twice, to two audiences, because pi treats them differently.
219
- // An entry is drawn the moment it is appended: the person sees the job end, now. The agent
220
- // gets the full report as a hidden message that starts a turn of its own - at once when it is
221
- // idle, else as soon as the turn it is in ends (a follow-up, never cutting into a reply or an
222
- // interview) - so it reacts to every job that ends without waiting for the person to speak:
223
- // does what the project's guidance says to do then (release what was built, say), or says
224
- // what finished.
225
- // What release has shipped, module by module: release does nothing for a module whose last commit it already shipped.
226
- const deployState = createDeployState();
218
+ let core;
219
+ // Finished work is announced twice, to two audiences, because pi treats them differently - see ChatInstance.announce:
220
+ // in the chat that asked for it, or the one shown when that is closed.
227
221
  const jobs = createJobRegistry({
228
222
  // The loop's plan changed - a module being planned, or planned: the live panel shows it at once.
229
223
  onNotify: () => {
230
- panel.refresh();
224
+ core?.jobsChanged();
231
225
  host.onPlanChange?.();
232
226
  },
233
227
  // A job that fails is run once more; failing again, it and every job behind it fail, and the
@@ -249,41 +243,145 @@ function turriExtension(pi, host) {
249
243
  return diagnosis !== undefined ? describeDiagnosis(diagnosis) : undefined;
250
244
  },
251
245
  onChange: (job) => {
252
- panel.refresh();
246
+ core?.jobsChanged();
253
247
  host.onJobChange?.(job);
254
248
  },
255
249
  onFinish: (job) => {
256
- panel.refresh();
250
+ core?.jobsChanged();
257
251
  host.onJobChange?.(job);
258
252
  // Queued again as another job: that one says how it went.
259
253
  if (job.replacedBy !== undefined)
260
254
  return;
261
- announceJob(pi, job);
262
255
  // Gone wrong: the next ctrl+h opens on it - the live panel that showed it is gone with the last pulse.
263
256
  if (job.status === "failed" || job.status === "stopped" || didNotPass(job))
264
257
  needsLooking.set(jobs, job.id);
265
- // A spec that contradicts itself is the person's to settle, now: the agent is started on it.
266
- if (job.specConflict !== undefined) {
267
- void pi.sendMessage({ customType: "turri_spec_conflict", content: specConflictTask(job.specConflict), display: false, details: { jobId: job.id } }, { triggerTurn: true });
268
- return;
269
- }
270
- void pi.sendMessage({
271
- customType: "turri_job",
272
- content: (job.status === "succeeded"
273
- ? `Pulse ${job.id} (${job.kind}) finished after ${formatDuration(job)}.\n\n${job.report ?? ""}`
274
- : job.status === "stopped"
275
- ? `Pulse ${job.id} (${job.kind}) was stopped after ${formatDuration(job)}. ${job.error ?? STOPPED_ON_REQUEST}${job.error === undefined || job.error === STOPPED_ON_REQUEST ? " The person stopped it; what it was building counts as not built." : ""}`
276
- : `Pulse ${job.id} (${job.kind}) failed after ${formatDuration(job)}.\n\n${job.error ?? ""}`) +
277
- `\n\n${JOB_FINISHED_REACTION}`,
278
- display: false,
279
- details: { jobId: job.id, status: job.status },
280
- }, { triggerTurn: true, deliverAs: "followUp" });
258
+ core?.jobFinished(job);
281
259
  },
282
260
  });
261
+ core = createChats({
262
+ cwd,
263
+ host,
264
+ jobs,
265
+ extension: (pi, link) => turriExtension(pi, core, link),
266
+ ...(options.persist !== undefined ? { persist: options.persist } : {}),
267
+ ...(options.dialogs !== undefined ? { dialogs: options.dialogs } : {}),
268
+ });
269
+ const session = core;
283
270
  hosts.set(jobs, host);
284
271
  // What the model is told of where the person watches the work, deep in the tools too: the host's surface, or the terminal.
285
272
  setSurface(jobs, host.surface);
286
- host.onJobs?.(jobs);
273
+ // What release has shipped, module by module: release does nothing for a module whose last commit it already shipped.
274
+ deployStates.set(jobs, createDeployState());
275
+ // What this session does, as facts, in the project's .turri/ledger - a pulse's own in its project. Said once, in the
276
+ // chat shown, when it cannot be written.
277
+ if (ledgerWanted()) {
278
+ startLedger({
279
+ mode: Object.keys(host).length > 0 ? "host" : "chat",
280
+ project: cwd,
281
+ warn: (message) => session.notify(message, "warning"),
282
+ });
283
+ }
284
+ // What a turri that was killed left running - its commands' process groups - stopped as this one starts.
285
+ const orphans = sweepOrphanedGroups();
286
+ if (orphans > 0)
287
+ session.notify(`Stopped ${orphans} process group${orphans === 1 ? "" : "s"} a turri that was killed left running.`, "info");
288
+ // A pulse's provider failing and waited out, or a turn turri ended itself: said in the chat shown as it happens - the
289
+ // job panel's activity holds it too, but scrolls by.
290
+ onModelEvent((event) => {
291
+ const line = describeTurnIncident(event);
292
+ if (line !== undefined)
293
+ session.notify(event.job !== undefined ? `${event.job}: ${line}` : line, "warning");
294
+ });
295
+ // Sub-agents think as hard as the chat that started them, not pi's default - and reach the providers its extensions
296
+ // registered - a company's own gateway - which they load none of.
297
+ inheritThinkingLevel(() => session.instanceInScope()?.thinkingLevel());
298
+ inheritProviders(() => session.instanceInScope()?.context()?.modelRegistry);
299
+ return core;
300
+ }
301
+ /** The session pi builds turri's extension into when it is given turri's file rather than a session's factory. */
302
+ const SESSION = Symbol.for("turri.session");
303
+ /** The session of this process: pi-launch.ts's, or one made as pi first builds the extension (`pi -e src/extension.ts`). */
304
+ export function processSession(make = () => createTurriCore()) {
305
+ const global = globalThis;
306
+ return (global[SESSION] ??= make());
307
+ }
308
+ const extension = (pi) => processSession().extension(pi);
309
+ export default extension;
310
+ /**
311
+ * The session's work loop, for a host to read: made with the first chat's context - the models it plans with - when no
312
+ * chat has asked for work yet.
313
+ */
314
+ export function sessionLoop(session) {
315
+ const known = workLoops.get(session.jobs)?.loop;
316
+ if (known !== undefined)
317
+ return known;
318
+ const live = session.shownChat()?.session ?? session.chats().map((info) => session.session(info.id)).find((found) => found !== undefined);
319
+ if (live === undefined)
320
+ throw new Error("No chat is open yet: the work loop plans with a chat's models.");
321
+ return workLoopFor(session.jobs, session.cwd, live.extensionRunner.createContext());
322
+ }
323
+ /**
324
+ * turri's extension, built into one chat of the session: its tools, commands and handlers run in the chat's scope -
325
+ * its stack, its id on the pulses it asks for - on the session's job registry and work loop.
326
+ */
327
+ function turriExtension(api, session, link) {
328
+ const pi = scopedToChat(api, link.scope);
329
+ const { cwd, host, jobs } = session;
330
+ const dialogs = link.dialogs ?? host.dialogs;
331
+ // turri's own skills, and the project's - .turri/skills, .agents/skills (skills.ts).
332
+ const discoveredSkills = discoverSkills(PACKAGE_ROOT, cwd);
333
+ // What the background work is doing right now, drawn above the editor while it runs.
334
+ const panel = createJobPanel(() => shownJobs(jobs));
335
+ // The chat's context, as its tools and handlers were last given it: for its model and its providers.
336
+ let lastContext;
337
+ const contextNow = () => {
338
+ try {
339
+ void lastContext?.model;
340
+ return lastContext;
341
+ }
342
+ catch {
343
+ // Rebuilt by pi since - a /reload, a /fork: its new extension has the context now.
344
+ return undefined;
345
+ }
346
+ };
347
+ // Finished work is announced twice, to two audiences, because pi treats them differently.
348
+ // An entry is drawn the moment it is appended: the person sees the job end, now. The agent
349
+ // gets the full report as a hidden message that starts a turn of its own - at once when it is
350
+ // idle, else as soon as the turn it is in ends (a follow-up, never cutting into a reply or an
351
+ // interview) - so it reacts to every job that ends without waiting for the person to speak:
352
+ // does what the project's guidance says to do then (release what was built, say), or says
353
+ // what finished. In the chat that asked for the pulse, shown or not.
354
+ const announce = (job) => {
355
+ announceJob(pi, job);
356
+ // A spec that contradicts itself is the person's to settle, now: the agent is started on it.
357
+ if (job.specConflict !== undefined) {
358
+ void pi.sendMessage({ customType: "turri_spec_conflict", content: specConflictTask(job.specConflict), display: false, details: { jobId: job.id } }, { triggerTurn: true });
359
+ return;
360
+ }
361
+ void pi.sendMessage({
362
+ customType: "turri_job",
363
+ content: (job.status === "succeeded"
364
+ ? `Pulse ${job.id} (${job.kind}) finished after ${formatDuration(job)}.\n\n${job.report ?? ""}`
365
+ : job.status === "stopped"
366
+ ? `Pulse ${job.id} (${job.kind}) was stopped after ${formatDuration(job)}. ${job.error ?? STOPPED_ON_REQUEST}${job.error === undefined || job.error === STOPPED_ON_REQUEST ? " The person stopped it; what it was building counts as not built." : ""}`
367
+ : `Pulse ${job.id} (${job.kind}) failed after ${formatDuration(job)}.\n\n${job.error ?? ""}`) +
368
+ `\n\n${JOB_FINISHED_REACTION}`,
369
+ display: false,
370
+ details: { jobId: job.id, status: job.status },
371
+ }, { triggerTurn: true, deliverAs: "followUp" });
372
+ };
373
+ link.live({
374
+ announce,
375
+ jobsChanged: () => panel.refresh(),
376
+ thinkingLevel: () => pi.getThinkingLevel(),
377
+ context: contextNow,
378
+ });
379
+ pi.on("session_start", (_event, ctx) => {
380
+ lastContext = ctx;
381
+ });
382
+ pi.on("before_agent_start", (_event, ctx) => {
383
+ lastContext = ctx;
384
+ });
287
385
  // The ***plain rules go into the system prompt in full, every turn. The block is
288
386
  // identical each time, so it sits inside the cached prefix rather than invalidating it.
289
387
  // With them, the instruction to do turri's work with its tools rather than its command
@@ -307,7 +405,6 @@ function turriExtension(pi, host) {
307
405
  const machine = machineNeeds(cwd);
308
406
  pi.on("session_start", () => machine.refreshSoon());
309
407
  pi.on("agent_settled", () => machine.refreshSoon());
310
- let leftOutSaid = false;
311
408
  pi.on("before_agent_start", (event, ctx) => {
312
409
  const offered = event.systemPromptOptions.skills;
313
410
  const leftOut = offered.filter((skill) => supersededByTurri(skill, turriRuns));
@@ -378,66 +475,28 @@ function turriExtension(pi, host) {
378
475
  };
379
476
  pi.on("agent_end", endTurnCapture);
380
477
  pi.on("agent_settled", endTurnCapture);
381
- // A pulse's provider failing and waited out, or a turn turri ended itself: said in the chat as it happens - the job
382
- // panel's activity holds it too, but scrolls by.
383
- let stopTurnIncidents;
384
- pi.on("session_start", (_event, ctx) => {
385
- stopTurnIncidents?.();
386
- if (!ctx.hasUI)
387
- return;
388
- stopTurnIncidents = onModelEvent((event) => {
389
- const line = describeTurnIncident(event);
390
- if (line !== undefined)
391
- ctx.ui.notify(event.job !== undefined ? `${event.job}: ${line}` : line, "warning");
392
- });
393
- });
394
- // What this session does, as facts, in the project's .turri/ledger - a pulse's own in its project. Said once, in the
395
- // chat, when it cannot be written.
396
- const ledgerTrouble = [];
397
- let tellLedgerTrouble;
398
- if (ledgerWanted()) {
399
- startLedger({
400
- mode: Object.keys(host).length > 0 ? "host" : "chat",
401
- project: cwd,
402
- warn: (message) => (tellLedgerTrouble !== undefined ? tellLedgerTrouble(message) : ledgerTrouble.push(message)),
403
- });
404
- }
405
478
  pi.on("session_start", (_event, ctx) => {
406
479
  if (!ctx.hasUI)
407
480
  return;
408
- tellLedgerTrouble = (message) => ctx.ui.notify(message, "warning");
409
- for (const message of ledgerTrouble.splice(0))
410
- tellLedgerTrouble(message);
411
- });
412
- // What a turri that was killed left running - its commands' process groups - stopped as this one starts.
413
- const orphans = sweepOrphanedGroups();
414
- let orphansSaid = orphans === 0;
415
- pi.on("session_start", (_event, ctx) => {
416
- if (orphansSaid || !ctx.hasUI)
417
- return;
418
- orphansSaid = true;
419
- ctx.ui.notify(`Stopped ${orphans} process group${orphans === 1 ? "" : "s"} a turri that was killed left running.`, "info");
420
- });
421
- pi.on("session_start", (_event, ctx) => {
422
- if (!ctx.hasUI)
423
- return;
424
- const ui = ctx.ui;
425
481
  panel.attach({
426
- setWidget: (key, content, options) => ui.setWidget(key, content, options),
427
- getToolsExpanded: () => ui.getToolsExpanded(),
482
+ setWidget: (key, content, options) => {
483
+ try {
484
+ ctx.ui.setWidget(key, content, options);
485
+ }
486
+ catch {
487
+ /* rebuilt by pi since: its new extension draws the panel */
488
+ }
489
+ },
490
+ getToolsExpanded: () => {
491
+ try {
492
+ return ctx.ui.getToolsExpanded();
493
+ }
494
+ catch {
495
+ return false;
496
+ }
497
+ },
428
498
  });
429
499
  });
430
- // Sub-agents think as hard as the conversation that started them, not pi's default.
431
- inheritThinkingLevel(() => pi.getThinkingLevel());
432
- // And reach the providers its extensions registered - a company's own gateway - which they load none of.
433
- let conversationModels;
434
- pi.on("session_start", (_event, ctx) => {
435
- conversationModels = ctx.modelRegistry;
436
- });
437
- pi.on("before_agent_start", (_event, ctx) => {
438
- conversationModels = ctx.modelRegistry;
439
- });
440
- inheritProviders(() => conversationModels);
441
500
  registerBranding(pi, discoveredSkills);
442
501
  registerInterviewLog(pi);
443
502
  registerJobTools(pi, jobs, cwd);
@@ -445,7 +504,7 @@ function turriExtension(pi, host) {
445
504
  // once its command is typed, or picked among the options the conversation offers with ask_user.
446
505
  const gate = createCommandGate();
447
506
  // A skill picked is started at once, with what the person asked for: they never type its command after picking it.
448
- registerCheckPlain(pi, cwd, host.dialogs, (answer) => {
507
+ registerCheckPlain(pi, cwd, dialogs, (answer) => {
449
508
  const chosen = chosenCommand(answer, discoveredSkills.skills);
450
509
  if (chosen === undefined)
451
510
  return undefined;
@@ -457,11 +516,13 @@ function turriExtension(pi, host) {
457
516
  registerGlimpse(pi, cwd, jobs);
458
517
  registerBind(pi, cwd, jobs);
459
518
  registerRenderModels(pi);
460
- deployStates.set(jobs, deployState);
461
- registerDeploy(pi, cwd, jobs, deployState);
519
+ registerDeploy(pi, cwd, jobs, deployStates.get(jobs) ?? createDeployState());
462
520
  registerSteering(pi, cwd);
463
521
  registerSideQuestions(pi, cwd, jobs);
464
522
  registerStacks(pi, cwd);
523
+ // More chats beside this one, in the terminal: /chat, alt+n, alt+1…9 (chat-commands.ts).
524
+ if (session.terminal)
525
+ registerChats(pi, session);
465
526
  const { skills } = discoveredSkills;
466
527
  if (skills.length === 0)
467
528
  return;
@@ -643,7 +704,7 @@ function turriExtension(pi, host) {
643
704
  push(` Q: ${question}`);
644
705
  push(` A: ${answer}`);
645
706
  recordExchange(pi, { skill: skill.name, question, answer, ...(said !== undefined && said.trim() !== "" ? { said: said.trim() } : {}) });
646
- }, (question) => askOnTheSide(pi, ctx, cwd, jobs, question), host.dialogs),
707
+ }, (question) => askOnTheSide(pi, ctx, cwd, jobs, question), dialogs),
647
708
  model: conversationModel(ctx),
648
709
  // Renders queued by a step land in the same registry as everything else, so
649
710
  // pulses sees them without knowing a skill started them.
@@ -661,9 +722,14 @@ function turriExtension(pi, host) {
661
722
  haltTool({ jobs, workLoop: () => workLoops.get(jobs)?.loop }),
662
723
  conformTool({ cwd, loop: () => workLoopFor(jobs, cwd, ctx), model: (_skill, plainFile, named) => jobModel(ctx, "spec_changes", named, plainFile), wait: () => false }),
663
724
  ],
664
- // The escalation becomes a real dialog in the TUI. Without dialog-capable
665
- // UI there is nobody to ask, so the gate stands.
725
+ // The escalation becomes a real dialog - the host's own, when it has them. Without one there is nobody to ask,
726
+ // so the gate stands.
666
727
  escalate: async ({ question, critique }) => {
728
+ if (dialogs !== undefined) {
729
+ const through = "Let it through";
730
+ const answer = await dialogs.select(`Review gate: ${skill.name} - ${question}`, [through, "Keep the gate"], critique);
731
+ return answer === through;
732
+ }
667
733
  if (!ctx.hasUI) {
668
734
  push(" no UI available for human review - gate stands");
669
735
  return false;
@@ -793,22 +859,29 @@ function registerRegenerate(pi, cwd, jobs) {
793
859
  ? `Planning ${module}'s functionalit${again.length === 1 ? "y" : "ies"} ${again.join(", ")} again…`
794
860
  : `Planning ${module}'s pulses…`, "info");
795
861
  await nextFrame();
796
- try {
797
- // Built with the stack chosen now - pinned to its pulses - or refused, a project of stacks built with none.
798
- const statuses = await withCallStack(plainFile, undefined, () => scratch
799
- ? loop.fromScratch(plainFile, { ...request, chain })
800
- : again.length > 0
801
- ? loop.regenerateAgain(plainFile, again, request)
802
- : loop.bringUp(plainFile, request));
803
- // Nothing to do: say how to build it again anyway.
804
- const hint = statuses.length > 0 && statuses.every(isUpToDate)
805
- ? `\nTo regenerate parts of it anyway, name their functionalities - /regenerate ${basename(plainFile)} 2.1 - or start over with /regenerate ${basename(plainFile)} from scratch.`
806
- : "";
807
- say(describeModules(statuses) + hint, "info");
808
- }
809
- catch (error) {
810
- say(error instanceof Error ? error.message : String(error), "error");
811
- }
862
+ // Planned in the background: pi reads what is typed next only once a command returns, and planning takes the
863
+ // planner's time - the chat, and switching to another, stay free meanwhile. The plan is said once it is made.
864
+ const target = plainFile;
865
+ void (async () => {
866
+ try {
867
+ // Built with the stack chosen now - pinned to its pulses - or refused, a project of stacks built with none.
868
+ const statuses = await withCallStack(target, undefined, () => scratch
869
+ ? loop.fromScratch(target, { ...request, chain })
870
+ : again.length > 0
871
+ ? loop.regenerateAgain(target, again, request)
872
+ : loop.bringUp(target, request));
873
+ // Nothing to do: say how to build it again anyway.
874
+ const hint = statuses.length > 0 && statuses.every(isUpToDate)
875
+ ? `\nTo regenerate parts of it anyway, name their functionalities - /regenerate ${basename(target)} 2.1 - or start over with /regenerate ${basename(target)} from scratch.`
876
+ : "";
877
+ say(describeModules(statuses) + hint, "info");
878
+ }
879
+ catch (error) {
880
+ say(error instanceof Error ? error.message : String(error), "error");
881
+ }
882
+ })().catch(() => {
883
+ // The chat let go while it planned - closed, or rebuilt: there is nobody to say it to.
884
+ });
812
885
  },
813
886
  });
814
887
  }
@@ -1141,7 +1214,7 @@ export function projectConfigBlock(root) {
1141
1214
  return [
1142
1215
  ...settings(readProjectConfig(dir), withStack(undefined, () => findTestScripts(dir)), dir === root ? `${projectConfigFile(dir)}, for the specs here:` : `${relative(root, dir)}/${projectConfigFile(dir)}, for the specs in ${relative(root, dir)}/ - built in ${relative(root, dir)}/plain_modules:`, where, stacks.length > 0 ? "project of stacks" : "project"),
1143
1216
  ...(stacks.length > 0
1144
- ? ["", `Stacks: ${stacks.join(", ")} - chosen now: ${chosen ?? "none, so the project's file alone"}. Each module is built with the stack of its part, pinned to its pulses whatever is chosen after: a stack named after the module is its own - backend.plain is built with config.turri.backend.yaml - with nothing to choose. For a module whose stack has another name, pass it as stack to regenerate, conform, glimpse and change_plain. Choose one for the whole session with the stack tool only when the user asks.`]
1217
+ ? ["", `Stacks: ${stacks.join(", ")} - chosen now: ${chosen ?? "none, so the project's file alone"}. Each module is built with the stack of its part, pinned to its pulses whatever is chosen after: a stack named after the module is its own - backend.plain is built with config.turri.backend.yaml - with nothing to choose. For a module whose stack has another name, pass it as stack to regenerate, conform, glimpse and change_plain. Choose one for this chat with the stack tool only when the user asks.`]
1145
1218
  : []),
1146
1219
  // A stack: its file over the project's, for everything built while it is chosen.
1147
1220
  ...stacks.flatMap((stack) => [
@@ -1203,7 +1276,7 @@ export function projectConfigBlock(root) {
1203
1276
  `<project config>`,
1204
1277
  `A project's ${CONFIG_FILE} sets how its specs are built - and a stack's config.turri.<stack>.yaml, over it, how everything is built`,
1205
1278
  "while that stack is chosen: its testing scripts, toolchains, models and conformance - a backend and a frontend each with their own scripts.",
1206
- "One stack is chosen at a time, for the session: the render tools follow the project's file with the chosen stack's over it;",
1279
+ "One stack is chosen at a time, in each chat for that chat: the render tools follow the project's file with the chosen stack's over it;",
1207
1280
  `a project with no ${CONFIG_FILE} yet is read from its ${LEGACY_CONFIG_FILE} (and config.<stack>.yaml): to change a setting there, copy`,
1208
1281
  `${LEGACY_CONFIG_FILE} to ${CONFIG_FILE} - and each config.<stack>.yaml to config.turri.<stack>.yaml - first, then edit the copy; never edit ${LEGACY_CONFIG_FILE}.`,
1209
1282
  "answer questions about the project's settings from it, and say when a render will differ from it because the user",
@@ -1239,8 +1312,10 @@ function chatModelOf(ctx) {
1239
1312
  }
1240
1313
  }
1241
1314
  let lastChatModel;
1242
- /** The models chosen in this session, by kind of work. Saved in the session, so a resume keeps them. */
1315
+ /** The models chosen in this session, by kind of work - for every chat. Saved in the chat they were chosen in, so reopening it keeps them. */
1243
1316
  const chosenModels = {};
1317
+ /** When they were last chosen: a chat reopened with older choices of its own leaves them be. */
1318
+ let chosenModelsAt = 0;
1244
1319
  const MODEL_CHOICE_ENTRY = "turri_render_models";
1245
1320
  /**
1246
1321
  * The model a job's agents run on: the one named on the call, checked now so a wrong name fails
@@ -1355,6 +1430,7 @@ function setRenderModelTool(pi, conversation) {
1355
1430
  }
1356
1431
  chosenModels[params.work] = resolved.model;
1357
1432
  }
1433
+ chosenModelsAt = Date.now();
1358
1434
  pi.appendEntry(MODEL_CHOICE_ENTRY, { ...chosenModels });
1359
1435
  const text = `The engine now:\n${describeChoices(chosenModels, chatModelOf(ctx), projectModelLabel(ctx))}`;
1360
1436
  return { content: [{ type: "text", text }], details: { ...chosenModels } };
@@ -1363,14 +1439,24 @@ function setRenderModelTool(pi, conversation) {
1363
1439
  }
1364
1440
  /** Choosing which model renders what, for the rest of the session. */
1365
1441
  function registerRenderModels(pi) {
1442
+ // The session's choices, every chat's: a chat reopened brings back what it chose, when that is newer than what the
1443
+ // session has - and a fresh chat starts with the session's.
1444
+ let started = false;
1366
1445
  pi.on("session_start", (_event, ctx) => {
1367
- for (const key of Object.keys(chosenModels))
1368
- delete chosenModels[key];
1446
+ if (started)
1447
+ return;
1448
+ started = true;
1369
1449
  const saved = ctx.sessionManager
1370
1450
  .getBranch()
1371
1451
  .filter((entry) => entry.type === "custom" && entry.customType === MODEL_CHOICE_ENTRY)
1372
1452
  .at(-1);
1373
- Object.assign(chosenModels, saved?.data ?? {});
1453
+ const at = Date.parse(saved?.timestamp ?? "") || 1;
1454
+ if (saved === undefined || at <= chosenModelsAt)
1455
+ return;
1456
+ chosenModelsAt = at;
1457
+ for (const key of Object.keys(chosenModels))
1458
+ delete chosenModels[key];
1459
+ Object.assign(chosenModels, saved.data ?? {});
1374
1460
  });
1375
1461
  pi.registerTool(setRenderModelTool(pi));
1376
1462
  pi.registerCommand("engine", {
@@ -1426,7 +1512,6 @@ function workLoopFor(jobs, cwd, ctx) {
1426
1512
  });
1427
1513
  shared = { loop, from };
1428
1514
  workLoops.set(jobs, shared);
1429
- hosts.get(jobs)?.onWorkLoop?.(loop);
1430
1515
  }
1431
1516
  shared.from.ctx = ctx;
1432
1517
  return shared.loop;
@@ -1646,6 +1731,133 @@ function registerJobTools(pi, jobs, cwd) {
1646
1731
  // screen - beside the chat, the key switches sides, giving the keys to the chat or the panel, as esc gives them to the
1647
1732
  // chat, the panel staying; ctrl+c closes it.
1648
1733
  // While the panel has the keys, every key is its own.
1734
+ // The side panel is the session's, one for every chat: it stays on the screen - beside the chat or over it - as
1735
+ // another chat is shown, and what it says to a chat it says to the one shown.
1736
+ const sidePanel = sidePanelOf(jobs, cwd);
1737
+ const openPanel = sidePanel.open;
1738
+ pi.on("session_start", () => {
1739
+ sidePanel.say = (text) => pi.sendUserMessage(text, { deliverAs: "followUp" });
1740
+ });
1741
+ // /pulses opens the side panel on every pulse, regenerating, planned and finished, and what each regenerates; regen -
1742
+ // the spec, with how far the code has been regenerated into it - is PANEL_KEY's, no command of its own beside
1743
+ // /regenerate. What the agents did is in each pulse's log.
1744
+ pi.registerCommand("pulses", {
1745
+ description: `The pulses, in the side panel: every pulse, regenerating, planned and finished, and what each regenerates. ${PANEL_KEY} opens the panel too.`,
1746
+ handler: async (_args, ctx) => {
1747
+ if (!ctx.hasUI)
1748
+ return;
1749
+ openPanel(ctx, "pulses");
1750
+ },
1751
+ });
1752
+ panelOpeners.set(jobs, (ctx) => openPanel(ctx));
1753
+ // A question open in the chat on the screen peeks at this chat's panel.
1754
+ const peek = {
1755
+ open: (ctx) => openPanel(ctx),
1756
+ shown: () => sidePanel.shown(),
1757
+ };
1758
+ questionPeek = peek;
1759
+ pi.on("session_start", () => {
1760
+ questionPeek = peek;
1761
+ });
1762
+ pi.registerShortcut(PANEL_KEY, {
1763
+ description: "Peek at regen and the pulses - a question waiting in the chat waits for you - or go back to it",
1764
+ handler: async (ctx) => {
1765
+ if (!ctx.hasUI)
1766
+ return;
1767
+ openPanel(ctx);
1768
+ },
1769
+ });
1770
+ // /turri-config: every setting of config.turri.yaml from the chat, each taking effect as it says - what turri fixed at its
1771
+ // start changed here, as it runs.
1772
+ registerTurriConfig(pi, {
1773
+ cwd,
1774
+ rules: () => loadRules(join(PACKAGE_ROOT, "genome", "rules")).map((rule) => rule.name),
1775
+ chatSpec: () => modelSpec(projectModels(readProjectConfig(cwd)).harness),
1776
+ live: {
1777
+ chatModel: async (spec, ctx) => {
1778
+ if (spec === undefined)
1779
+ return "The conversation keeps the model it runs on now; turri starts on pi's own choice next time.";
1780
+ const { model, thinking } = splitModelSpec(spec);
1781
+ if (model !== undefined) {
1782
+ const resolved = resolveModel(model, ctx.modelRegistry);
1783
+ if (!resolved.ok)
1784
+ return `The conversation keeps its model: ${model} cannot be used here.\n${describeResolution(resolved)}`;
1785
+ const slash = resolved.model.indexOf("/");
1786
+ const found = ctx.modelRegistry.find(resolved.model.slice(0, slash), resolved.model.slice(slash + 1));
1787
+ if (found === undefined || !(await pi.setModel(found)))
1788
+ return `The conversation keeps its model: ${resolved.model} could not be switched to.`;
1789
+ }
1790
+ if (thinking !== undefined)
1791
+ pi.setThinkingLevel(thinking);
1792
+ return `The conversation runs on ${spec} now.`;
1793
+ },
1794
+ codingModel: () => {
1795
+ const dropped = [];
1796
+ // What the session chose wins over the file; changed in the file, the file's holds from now on.
1797
+ if (process.env[CODING_MODEL_ENV] !== undefined) {
1798
+ dropped.push(`--coding-model ${process.env[CODING_MODEL_ENV]}`);
1799
+ delete process.env[CODING_MODEL_ENV];
1800
+ }
1801
+ chosenModelsAt = Date.now();
1802
+ for (const work of Object.keys(chosenModels)) {
1803
+ dropped.push(`the engine's ${work === "all" ? "" : `${work.replace(/_/g, " ")} `}model ${chosenModels[work]}`);
1804
+ delete chosenModels[work];
1805
+ }
1806
+ return dropped.length === 0 ? undefined : `Chosen this session, and given way to the config: ${dropped.join("; ")}.`;
1807
+ },
1808
+ parallelJobs: (count) => {
1809
+ const limit = count ?? parallelJobs(cwd);
1810
+ jobs.setMaxRunning(limit);
1811
+ return `Up to ${limit} pulse${limit === 1 ? "" : "s"} run at once now.`;
1812
+ },
1813
+ },
1814
+ });
1815
+ }
1816
+ export { fromWorkingDir };
1817
+ const WATCH_REDRAW_MS = 500;
1818
+ /**
1819
+ * What the agent is to do with a job that just ended, unasked: what the project's guidance says to do then, or else say
1820
+ * in a line or two what ended - the person saw it end already, in the transcript.
1821
+ */
1822
+ export const JOB_FINISHED_REACTION = [
1823
+ "React to this now - the person has not asked, and need not. If guidance for this project - its AGENTS.md, a skill,",
1824
+ "what the person said earlier - says what to do when a pulse finishes (release what was built, run something, tell",
1825
+ "someone), do that, as it says. Otherwise say in a line or two what finished and what it means for the person - the",
1826
+ "report is theirs to read in full; do not repeat it - and, when it failed, what they can do. Start no other work.",
1827
+ "A report that says \"Released after the pulse\" was released by turri itself, as the pulse's last stage: do not call",
1828
+ "release for it. Say in a line what was released, or why nothing was - then, when it was released, do what you were",
1829
+ "told to do once something is released (opening it where the person can watch it, saying what to test), if anything.",
1830
+ "A pulse that stopped on its environment - \"stopped: turri's sandbox kept its agents from…\" or \"stopped: the",
1831
+ "environment lacks…\" - stops the same way again until that is fixed: never regenerate it before. Heal it, then regenerate the",
1832
+ "module to resume:",
1833
+ "- turri's sandbox kept the agents from reading a folder on this machine: nothing is missing. Add the folder its report",
1834
+ " names to `toolchains:` in config.turri.yaml - a list, keeping what is there - say so in a line, and resume.",
1835
+ "- turri's sandbox kept the agents from writing a file - a lock a build tool takes, say: no setting lets them, and",
1836
+ " `toolchains:` does not - never add it there, and never resume before the fix. Do what the report says if it is the",
1837
+ " project's to change - a tool told to use `$HOME` in the build's own files - say so, and resume; otherwise tell the",
1838
+ " person, in a line or two, what the report says and that \"resume\" picks it up once it is done.",
1839
+ "- the machine lacks a toolchain or a tool - a JDK, Maven, Gradle, Go, uv, Node, a system library: first find out, in your",
1840
+ " shell, whether it is installed somewhere off the PATH (`command -v`, Homebrew's opt folders, /Library/Java/",
1841
+ " JavaVirtualMachines, a version manager the person uses - mise, asdf, sdkman, nvm). Then ask the person with ask_user",
1842
+ " to let you put it in place the way this machine installs things - the exact command, `brew install openjdk@21`,",
1843
+ " `mise use -g java@21`, `sdk install maven` - and on yes run it in your shell, check that the command is found now as",
1844
+ " the testing script would find it (`command -v`, then the doctor tool, which looks on turri's own PATH), and resume. A tool installed where the PATH turri",
1845
+ " was started with does not reach needs turri restarted: say so, with the line for the shell profile. On no, or when it",
1846
+ " needs what you cannot do - sudo with a password, a licence, a download behind a login - tell the person exactly what",
1847
+ " to do, and that \"resume\" picks it up once it is in.",
1848
+ "- a credential `agent-env:` names that is not there: never make one up - tell the person where it is read from.",
1849
+ ].join("\n");
1850
+ const sidePanels = new WeakMap();
1851
+ /** The side panel of the session these jobs are, made as a chat first asks for it. */
1852
+ function sidePanelOf(jobs, cwd) {
1853
+ const known = sidePanels.get(jobs);
1854
+ if (known !== undefined)
1855
+ return known;
1856
+ const self = {
1857
+ open: (ctx, view) => openPanel(ctx, view),
1858
+ shown: () => panel !== undefined && panel.keys,
1859
+ };
1860
+ sidePanels.set(jobs, self);
1649
1861
  let panel;
1650
1862
  // Beside the chat or over it: as the person last left it, for the conversation.
1651
1863
  let panelDocked = false;
@@ -1797,7 +2009,7 @@ function registerJobTools(pi, jobs, cwd) {
1797
2009
  discuss: (page) => {
1798
2010
  back();
1799
2011
  const why = page.codebase?.stuck?.why;
1800
- pi.sendUserMessage(`regen says ${page.module} needs me: ${page.status}. What went wrong, and what do we do about it?${why !== undefined ? `\n\n(turri's reason: ${why})` : ""}`, { deliverAs: "followUp" });
2012
+ self.say?.(`regen says ${page.module} needs me: ${page.status}. What went wrong, and what do we do about it?${why !== undefined ? `\n\n(turri's reason: ${why})` : ""}`);
1801
2013
  },
1802
2014
  // Something took the keys' focus while the panel showed - a question in the chat - and waits there.
1803
2015
  waiting: () => {
@@ -1914,109 +2126,8 @@ function registerJobTools(pi, jobs, cwd) {
1914
2126
  },
1915
2127
  });
1916
2128
  };
1917
- // /pulses opens the side panel on every pulse, regenerating, planned and finished, and what each regenerates; regen -
1918
- // the spec, with how far the code has been regenerated into it - is PANEL_KEY's, no command of its own beside
1919
- // /regenerate. What the agents did is in each pulse's log.
1920
- pi.registerCommand("pulses", {
1921
- description: `The pulses, in the side panel: every pulse, regenerating, planned and finished, and what each regenerates. ${PANEL_KEY} opens the panel too.`,
1922
- handler: async (_args, ctx) => {
1923
- if (!ctx.hasUI)
1924
- return;
1925
- openPanel(ctx, "pulses");
1926
- },
1927
- });
1928
- panelOpeners.set(jobs, (ctx) => openPanel(ctx));
1929
- questionPeek = {
1930
- open: (ctx) => openPanel(ctx),
1931
- shown: () => panel !== undefined && panel.keys,
1932
- };
1933
- pi.registerShortcut(PANEL_KEY, {
1934
- description: "Peek at regen and the pulses - a question waiting in the chat waits for you - or go back to it",
1935
- handler: async (ctx) => {
1936
- if (!ctx.hasUI)
1937
- return;
1938
- openPanel(ctx);
1939
- },
1940
- });
1941
- // /turri-config: every setting of config.turri.yaml from the chat, each taking effect as it says - what turri fixed at its
1942
- // start changed here, as it runs.
1943
- registerTurriConfig(pi, {
1944
- cwd,
1945
- rules: () => loadRules(join(PACKAGE_ROOT, "genome", "rules")).map((rule) => rule.name),
1946
- chatSpec: () => modelSpec(projectModels(readProjectConfig(cwd)).harness),
1947
- live: {
1948
- chatModel: async (spec, ctx) => {
1949
- if (spec === undefined)
1950
- return "The conversation keeps the model it runs on now; turri starts on pi's own choice next time.";
1951
- const { model, thinking } = splitModelSpec(spec);
1952
- if (model !== undefined) {
1953
- const resolved = resolveModel(model, ctx.modelRegistry);
1954
- if (!resolved.ok)
1955
- return `The conversation keeps its model: ${model} cannot be used here.\n${describeResolution(resolved)}`;
1956
- const slash = resolved.model.indexOf("/");
1957
- const found = ctx.modelRegistry.find(resolved.model.slice(0, slash), resolved.model.slice(slash + 1));
1958
- if (found === undefined || !(await pi.setModel(found)))
1959
- return `The conversation keeps its model: ${resolved.model} could not be switched to.`;
1960
- }
1961
- if (thinking !== undefined)
1962
- pi.setThinkingLevel(thinking);
1963
- return `The conversation runs on ${spec} now.`;
1964
- },
1965
- codingModel: () => {
1966
- const dropped = [];
1967
- // What the session chose wins over the file; changed in the file, the file's holds from now on.
1968
- if (process.env[CODING_MODEL_ENV] !== undefined) {
1969
- dropped.push(`--coding-model ${process.env[CODING_MODEL_ENV]}`);
1970
- delete process.env[CODING_MODEL_ENV];
1971
- }
1972
- for (const work of Object.keys(chosenModels)) {
1973
- dropped.push(`the engine's ${work === "all" ? "" : `${work.replace(/_/g, " ")} `}model ${chosenModels[work]}`);
1974
- delete chosenModels[work];
1975
- }
1976
- return dropped.length === 0 ? undefined : `Chosen this session, and given way to the config: ${dropped.join("; ")}.`;
1977
- },
1978
- parallelJobs: (count) => {
1979
- const limit = count ?? parallelJobs(cwd);
1980
- jobs.setMaxRunning(limit);
1981
- return `Up to ${limit} pulse${limit === 1 ? "" : "s"} run at once now.`;
1982
- },
1983
- },
1984
- });
2129
+ return self;
1985
2130
  }
1986
- export { fromWorkingDir };
1987
- const WATCH_REDRAW_MS = 500;
1988
- /**
1989
- * What the agent is to do with a job that just ended, unasked: what the project's guidance says to do then, or else say
1990
- * in a line or two what ended - the person saw it end already, in the transcript.
1991
- */
1992
- export const JOB_FINISHED_REACTION = [
1993
- "React to this now - the person has not asked, and need not. If guidance for this project - its AGENTS.md, a skill,",
1994
- "what the person said earlier - says what to do when a pulse finishes (release what was built, run something, tell",
1995
- "someone), do that, as it says. Otherwise say in a line or two what finished and what it means for the person - the",
1996
- "report is theirs to read in full; do not repeat it - and, when it failed, what they can do. Start no other work.",
1997
- "A report that says \"Released after the pulse\" was released by turri itself, as the pulse's last stage: do not call",
1998
- "release for it. Say in a line what was released, or why nothing was - then, when it was released, do what you were",
1999
- "told to do once something is released (opening it where the person can watch it, saying what to test), if anything.",
2000
- "A pulse that stopped on its environment - \"stopped: turri's sandbox kept its agents from…\" or \"stopped: the",
2001
- "environment lacks…\" - stops the same way again until that is fixed: never regenerate it before. Heal it, then regenerate the",
2002
- "module to resume:",
2003
- "- turri's sandbox kept the agents from reading a folder on this machine: nothing is missing. Add the folder its report",
2004
- " names to `toolchains:` in config.turri.yaml - a list, keeping what is there - say so in a line, and resume.",
2005
- "- turri's sandbox kept the agents from writing a file - a lock a build tool takes, say: no setting lets them, and",
2006
- " `toolchains:` does not - never add it there, and never resume before the fix. Do what the report says if it is the",
2007
- " project's to change - a tool told to use `$HOME` in the build's own files - say so, and resume; otherwise tell the",
2008
- " person, in a line or two, what the report says and that \"resume\" picks it up once it is done.",
2009
- "- the machine lacks a toolchain or a tool - a JDK, Maven, Gradle, Go, uv, Node, a system library: first find out, in your",
2010
- " shell, whether it is installed somewhere off the PATH (`command -v`, Homebrew's opt folders, /Library/Java/",
2011
- " JavaVirtualMachines, a version manager the person uses - mise, asdf, sdkman, nvm). Then ask the person with ask_user",
2012
- " to let you put it in place the way this machine installs things - the exact command, `brew install openjdk@21`,",
2013
- " `mise use -g java@21`, `sdk install maven` - and on yes run it in your shell, check that the command is found now as",
2014
- " the testing script would find it (`command -v`, then the doctor tool, which looks on turri's own PATH), and resume. A tool installed where the PATH turri",
2015
- " was started with does not reach needs turri restarted: say so, with the line for the shell profile. On no, or when it",
2016
- " needs what you cannot do - sudo with a password, a licence, a download behind a login - tell the person exactly what",
2017
- " to do, and that \"resume\" picks it up once it is in.",
2018
- "- a credential `agent-env:` names that is not there: never make one up - tell the person where it is read from.",
2019
- ].join("\n");
2020
2131
  /** Opens the side panel, and goes back to it from the conversation. */
2021
2132
  /** The modules in this project that have a history, by name. */
2022
2133
  function modulesWithHistory(cwd) {
@@ -2225,8 +2336,10 @@ function registerDeploy(pi, cwd, jobs, state) {
2225
2336
  });
2226
2337
  for (const tool of tools)
2227
2338
  pi.registerTool(tool);
2228
- pi.on("session_shutdown", async () => {
2229
- await stopDeployed();
2339
+ // What activate started runs until turri ends - not when a chat is opened, closed, forked or reloaded.
2340
+ pi.on("session_shutdown", async (event) => {
2341
+ if (event.reason === "quit")
2342
+ await stopDeployed();
2230
2343
  });
2231
2344
  }
2232
2345
  /** validate, for the conversation and for the skills that write specs alike. */
@@ -2480,11 +2593,16 @@ function registerBranding(pi, skills) {
2480
2593
  // where the terminal shows images, drawn in text where it does not.
2481
2594
  return new Banner(lines, (rule) => theme.fg("borderMuted", rule), jellyfishArt(theme.appearance, (theme.getColorMode?.() ?? "truecolor") === "truecolor"));
2482
2595
  });
2596
+ let bannered = false;
2483
2597
  pi.on("session_start", (_event, ctx) => {
2484
2598
  if (ctx.hasUI) {
2485
2599
  padTheScreen(ctx);
2486
2600
  animateJellyfish(ctx);
2487
2601
  }
2602
+ // Once a chat: it starts again each time it is shown.
2603
+ if (bannered)
2604
+ return;
2605
+ bannered = true;
2488
2606
  try {
2489
2607
  const identity = turriIdentity();
2490
2608
  pi.appendEntry(BANNER_ENTRY, {