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

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/ask.d.ts CHANGED
@@ -46,6 +46,8 @@ export declare const DONE_OPTION = "Done \u2014 that's all of them";
46
46
  export declare const TICKED = "\u2611";
47
47
  export declare const UNTICKED = "\u2610";
48
48
  export declare const DISMISSED = "The user dismissed the question without answering.";
49
+ /** ctrl+c at a question: the person would not answer it - or stopped the turn that asked it. */
50
+ export declare const DECLINED = "The user declined to answer the question.";
49
51
  /** How an answer that is no answer starts: the person chose to talk about the question first. */
50
52
  export declare const CHAT_PREFIX = "The user has not answered this question yet: they chose to talk about it first.";
51
53
  /** What comes back when the person chose "Chat about this": what they said, and what they had picked so far. */
package/dist/ask.js CHANGED
@@ -13,6 +13,8 @@ export const DONE_OPTION = "Done — that's all of them";
13
13
  export const TICKED = "☑";
14
14
  export const UNTICKED = "☐";
15
15
  export const DISMISSED = "The user dismissed the question without answering.";
16
+ /** ctrl+c at a question: the person would not answer it - or stopped the turn that asked it. */
17
+ export const DECLINED = "The user declined to answer the question.";
16
18
  /** How an answer that is no answer starts: the person chose to talk about the question first. */
17
19
  export const CHAT_PREFIX = "The user has not answered this question yet: they chose to talk about it first.";
18
20
  /** What comes back when the person chose "Chat about this": what they said, and what they had picked so far. */
@@ -1,5 +1,23 @@
1
1
  import type { RunRecord } from "./chat-records.ts";
2
- import { type TurriCore } from "./chats.ts";
2
+ import { type Stoppable, type TurriCore } from "./chats.ts";
3
+ /** What ctrl+c reaches of pi's screen: the chat shown, its editor, and leaving. */
4
+ export interface CtrlCScreen {
5
+ session: Stoppable;
6
+ editor: {
7
+ getText(): string;
8
+ };
9
+ clearEditor(): void;
10
+ /** Its queued messages back in the editor - and, with `abort`, the turn stopped: pi's own esc. */
11
+ restoreQueuedMessagesToEditor(options?: {
12
+ abort?: boolean;
13
+ }): number;
14
+ shutdown(): Promise<void>;
15
+ }
16
+ /**
17
+ * ctrl+c, in the chat shown: what it is doing stopped, the other chats left as they are (`interruptChat`). In a chat
18
+ * doing nothing it clears a draft in the editor, and with none leaves turri, every chat with it.
19
+ */
20
+ export declare function pressCtrlC(screen: CtrlCScreen, lastStop: number, now?: number): "stopped" | "ignored" | "cleared" | "left";
3
21
  export declare function driveTerminal(core: TurriCore, options?: {
4
22
  restore?: RunRecord;
5
23
  }): void;
@@ -1,5 +1,24 @@
1
1
  import { AgentSessionRuntime, InteractiveMode, SessionManager } from "@earendil-works/pi-coding-agent";
2
- import { terminalUi } from "./chats.js";
2
+ import { interruptChat, terminalUi } from "./chats.js";
3
+ /**
4
+ * ctrl+c, in the chat shown: what it is doing stopped, the other chats left as they are (`interruptChat`). In a chat
5
+ * doing nothing it clears a draft in the editor, and with none leaves turri, every chat with it.
6
+ */
7
+ export function pressCtrlC(screen, lastStop, now = Date.now()) {
8
+ return interruptChat(screen.session, {
9
+ lastStop,
10
+ now,
11
+ stopTurn: () => void screen.restoreQueuedMessagesToEditor({ abort: true }),
12
+ idle: () => {
13
+ if (screen.editor.getText().trim() !== "") {
14
+ screen.clearEditor();
15
+ return "cleared";
16
+ }
17
+ void screen.shutdown();
18
+ return "left";
19
+ },
20
+ });
21
+ }
3
22
  export function driveTerminal(core, options = {}) {
4
23
  let screen;
5
24
  let runtime;
@@ -54,6 +73,14 @@ export function driveTerminal(core, options = {}) {
54
73
  return { cancelled: true };
55
74
  }
56
75
  };
76
+ // ctrl+c: pi's clears the editor and leaves on a second press; here it stops what the chat shown is doing, and leaves
77
+ // when it is doing nothing.
78
+ let lastStop = 0;
79
+ prototype.handleCtrlC = function () {
80
+ const did = pressCtrlC(this, lastStop);
81
+ if (did === "stopped")
82
+ lastStop = Date.now();
83
+ };
57
84
  // /new: a fresh chat beside the others.
58
85
  const newSession = runtimes.newSession;
59
86
  runtimes.newSession = function (asked) {
package/dist/chats.d.ts CHANGED
@@ -166,6 +166,22 @@ export interface CoreOptions {
166
166
  /** The host's dialogs, a chat's own. */
167
167
  dialogs?: (chat: string) => AskUserIO | undefined;
168
168
  }
169
+ /** A chat's session, as stopping what it does reaches it. */
170
+ export type Stoppable = Pick<AgentSession, "isStreaming" | "isCompacting" | "isRetrying" | "isBashRunning" | "abort" | "abortBash">;
171
+ /** A second interrupt this soon after one that stopped something stops nothing more and ends nothing: one pressed twice to be sure. */
172
+ export declare const INTERRUPT_SETTLE_MS = 500;
173
+ /**
174
+ * ctrl+c in a chat, the terminal's or a host's: what the chat is doing stopped - its turn, a skill or a tool in it, a
175
+ * `!` command, a compaction, a retry - and nothing in the other chats. `stopTurn` ends its turn: pi's own esc in the
176
+ * terminal. A chat doing nothing gets `idle`'s answer - the session ended - unless the last interrupt, at `lastStop`,
177
+ * stopped something a moment ago: then it is `ignored`.
178
+ */
179
+ export declare function interruptChat<R>(session: Stoppable, asked: {
180
+ lastStop: number;
181
+ now?: number;
182
+ stopTurn?: () => void;
183
+ idle: () => R;
184
+ }): "stopped" | "ignored" | R;
169
185
  export declare function createChats(options: CoreOptions): TurriCore;
170
186
  /**
171
187
  * pi's extension API for a chat: every tool, command, shortcut and handler the extension registers runs inside the
package/dist/chats.js CHANGED
@@ -24,6 +24,28 @@ import { record } from "./regenerate/ledger.js";
24
24
  export const MAX_CHATS = 9;
25
25
  /** What a chat is called until something is said in it. */
26
26
  export const NEW_CHAT_TITLE = "new chat";
27
+ /** A second interrupt this soon after one that stopped something stops nothing more and ends nothing: one pressed twice to be sure. */
28
+ export const INTERRUPT_SETTLE_MS = 500;
29
+ /**
30
+ * ctrl+c in a chat, the terminal's or a host's: what the chat is doing stopped - its turn, a skill or a tool in it, a
31
+ * `!` command, a compaction, a retry - and nothing in the other chats. `stopTurn` ends its turn: pi's own esc in the
32
+ * terminal. A chat doing nothing gets `idle`'s answer - the session ended - unless the last interrupt, at `lastStop`,
33
+ * stopped something a moment ago: then it is `ignored`.
34
+ */
35
+ export function interruptChat(session, asked) {
36
+ if (session.isBashRunning || session.isStreaming || session.isCompacting || session.isRetrying) {
37
+ if (session.isBashRunning)
38
+ session.abortBash();
39
+ if (session.isStreaming)
40
+ (asked.stopTurn ?? (() => void session.abort()))();
41
+ else if (session.isCompacting || session.isRetrying)
42
+ void session.abort();
43
+ return "stopped";
44
+ }
45
+ if ((asked.now ?? Date.now()) - asked.lastStop < INTERRUPT_SETTLE_MS)
46
+ return "ignored";
47
+ return asked.idle();
48
+ }
27
49
  /** A chat's title from what it says: its name, else its first message, short. */
28
50
  function titleOf(session) {
29
51
  if (session === undefined)
package/dist/extension.js CHANGED
@@ -44,7 +44,7 @@ import { createJobsTreeState, selectJob } from "./regenerate/jobs-tree.js";
44
44
  import { describeDiagnosis, diagnoseFailure } from "./regenerate/diagnose.js";
45
45
  import { HISTORY_DIR, historyLog, historyShow } from "./regenerate/lineage.js";
46
46
  import { defaultBuildDir } from "./regenerate/paths.js";
47
- import { describeTurnIncident, describeUsage, onModelEvent } from "./regenerate/activity.js";
47
+ import { describeTurnIncident, describeUsage, JobStoppedError, onModelEvent } from "./regenerate/activity.js";
48
48
  import { ledgerWanted, startLedger } from "./regenerate/ledger.js";
49
49
  import { describePastPulse, describeRuns, earlierPulses, ledgerProjects } from "./regenerate/ledger-view.js";
50
50
  import { doctorTool } from "./doctor-tool.js";
@@ -55,7 +55,7 @@ import { beginCapture, captureConsole, describeCaptured, dropNoise } from "./qui
55
55
  import { loadRules, renderRulesBlock } from "./rules.js";
56
56
  import { registerTurriConfig } from "./config-command.js";
57
57
  import { describeStdlib } from "./stdlib.js";
58
- import { peekKeyListener, runAsk } from "./ask.js";
58
+ import { DECLINED, peekKeyListener, runAsk } from "./ask.js";
59
59
  import { QuestionDialog } from "./question-dialog.js";
60
60
  import { registerStacks } from "./stacks.js";
61
61
  import { catalogueLine, chosenCommand, createCommandGate, startPicked, discoverSkills, leftOutNote, startedByCommandOnly } from "./skills.js";
@@ -92,57 +92,81 @@ aside,
92
92
  /** A host's own dialogs (TurriHost.dialogs), the question put in them instead of the terminal's. */
93
93
  dialogs) {
94
94
  return async (request) => {
95
- if (dialogs !== undefined) {
96
- const asking = (asked) => runAsk(asked, dialogs);
97
- const bare = { ...request, said: undefined };
98
- const answer = aside !== undefined ? await askWithAsides(bare, asking, aside) : await asking(bare);
99
- onExchange(request.question, answer, [request.context, request.said].map((part) => part?.trim() ?? "").filter((part) => part !== "").join("\n\n") || undefined);
100
- return answer;
101
- }
102
- if (!ctx.hasUI) {
95
+ if (dialogs === undefined && !ctx.hasUI) {
103
96
  return "No user is available to answer. Decide yourself, and say in your report what you assumed.";
104
97
  }
105
- ctx.ui.setWorkingMessage("waiting for your answer");
98
+ // Declined - ctrl+c at it, or the turn that asked it stopped: the dialog open is closed, no other is opened, and it
99
+ // comes back as DECLINED.
100
+ const turn = ctx.signal;
101
+ const declining = { declined: turn?.aborted === true, close: undefined };
102
+ const decline = () => {
103
+ declining.declined = true;
104
+ declining.close?.();
105
+ };
106
+ turn?.addEventListener("abort", decline, { once: true });
106
107
  // The question has the keys: ctrl+h is caught here, or it would never reach the shortcut, which only the chat's editor sees.
107
- const unhook = hookPeekKey(ctx);
108
+ const unhook = dialogs === undefined ? hookPeekKey(ctx) : () => { };
109
+ if (dialogs === undefined)
110
+ ctx.ui.setWorkingMessage("waiting for your answer");
108
111
  try {
109
- // The question, with what the asker gives to read for it - a brief to confirm - above it; what it said of its work
110
- // before asking is kept in its record, never put in the dialog.
111
- // A dialog of turri's own: a long brief scrolls above the question, and the options always show (question-dialog.ts).
112
- const dialog = (question, context, options, selected) => ctx.ui.custom((tui, theme, _keybindings, done) => {
113
- const shown = new QuestionDialog({
114
- question,
115
- ...(context !== undefined ? { context } : {}),
116
- ...(options !== undefined ? { options } : {}),
117
- ...(selected !== undefined ? { selected } : {}),
118
- rows: () => paddedRows(tui.terminal.rows),
119
- style: { accent: (text) => theme.fg("accent", text), muted: (text) => theme.fg("muted", text), bold: (text) => theme.bold(text) },
120
- done: (answer) => {
121
- if (questionShown === shown)
122
- questionShown = undefined;
123
- done(answer);
124
- },
125
- requestRender: () => tui.requestRender(),
126
- });
127
- questionShown = shown;
128
- return shown;
129
- });
130
- const asking = (asked) => runAsk(asked, {
131
- select: (title, options, context, at) => dialog(title, context, options, at),
132
- input: (title, context) => dialog(title, context),
133
- });
112
+ const io = dialogs !== undefined ? hostDialogs(dialogs, declining) : terminalDialogs(ctx, declining, decline);
113
+ const asking = (asked) => runAsk(asked, io);
134
114
  const bare = { ...request, said: undefined };
135
- const answer = aside !== undefined ? await askWithAsides(bare, asking, aside) : await asking(bare);
115
+ const given = declining.declined ? DECLINED : aside !== undefined ? await askWithAsides(bare, asking, aside) : await asking(bare);
116
+ const answer = declining.declined ? DECLINED : given;
136
117
  // Kept: what was shown with it, and what it said before it.
137
118
  onExchange(request.question, answer, [request.context, request.said].map((part) => part?.trim() ?? "").filter((part) => part !== "").join("\n\n") || undefined);
138
119
  return answer;
139
120
  }
140
121
  finally {
122
+ turn?.removeEventListener("abort", decline);
141
123
  unhook();
142
- ctx.ui.setWorkingMessage();
124
+ if (dialogs === undefined)
125
+ ctx.ui.setWorkingMessage();
143
126
  }
144
127
  };
145
128
  }
129
+ /** A host's dialogs, for a question that can be declined: its dialog stays the host's to close, the question over all the same. */
130
+ function hostDialogs(dialogs, declining) {
131
+ const until = (asked) => declining.declined ? Promise.resolve(undefined) : Promise.race([asked(), new Promise((resolve) => (declining.close = () => resolve(undefined)))]);
132
+ return {
133
+ select: (...args) => until(() => dialogs.select(...args)),
134
+ input: (...args) => until(() => dialogs.input(...args)),
135
+ };
136
+ }
137
+ /**
138
+ * The terminal's dialogs: turri's own, a long brief scrolling above the question and the options always showing
139
+ * (question-dialog.ts). ctrl+c declines the question - each one it is pressed at, however many in a row; what asked
140
+ * goes on. The turn, and a skill asking in it, is stopped by ctrl+c with no question open.
141
+ */
142
+ function terminalDialogs(ctx, declining, decline) {
143
+ const dialog = (question, context, options, selected) => declining.declined
144
+ ? Promise.resolve(undefined)
145
+ : ctx.ui.custom((tui, theme, _keybindings, done) => {
146
+ const shown = new QuestionDialog({
147
+ question,
148
+ ...(context !== undefined ? { context } : {}),
149
+ ...(options !== undefined ? { options } : {}),
150
+ ...(selected !== undefined ? { selected } : {}),
151
+ rows: () => paddedRows(tui.terminal.rows),
152
+ style: { accent: (text) => theme.fg("accent", text), muted: (text) => theme.fg("muted", text), bold: (text) => theme.bold(text) },
153
+ done: (answer) => {
154
+ if (questionShown === shown)
155
+ questionShown = undefined;
156
+ done(answer);
157
+ },
158
+ requestRender: () => tui.requestRender(),
159
+ interrupt: decline,
160
+ });
161
+ declining.close = () => shown.dismiss();
162
+ questionShown = shown;
163
+ return shown;
164
+ });
165
+ return {
166
+ select: (title, options, context, at) => dialog(title, context, options, at),
167
+ input: (title, context) => dialog(title, context),
168
+ };
169
+ }
146
170
  /** The side panel, as a question open in the chat reaches it: set once the extension has its panel. */
147
171
  let questionPeek;
148
172
  /**
@@ -574,7 +598,7 @@ function turriExtension(api, session, link) {
574
598
  description: "Start a new run even though an interrupted one exists here, discarding it.",
575
599
  })),
576
600
  }),
577
- async execute(_id, params, _signal, onUpdate, ctx) {
601
+ async execute(_id, params, signal, onUpdate, ctx) {
578
602
  const skill = skills.find((s) => s.name === params.skill);
579
603
  if (!skill) {
580
604
  return {
@@ -690,8 +714,10 @@ function turriExtension(api, session, link) {
690
714
  const ended = skillRunStarted({ skill: skill.name, dir: skill.dir, step: () => live.step, trace: () => [...trace], runDir });
691
715
  // From here the time to first regeneration is counted, for a skill that writes spec diffs: its first pulse started.
692
716
  const began = Date.now();
693
- const { result, output: captured } = await captureConsole(() => runSkill({
717
+ const ran = await captureConsole(() => runSkill({
694
718
  skillDir: skill.dir,
719
+ // The chat's turn stopped - ctrl+c, esc - stops the run with it.
720
+ signal,
695
721
  cwd,
696
722
  runDir,
697
723
  resume: params.resume === true && interrupted !== undefined,
@@ -736,12 +762,23 @@ function turriExtension(api, session, link) {
736
762
  }
737
763
  return ctx.ui.confirm(`Review gate: ${skill.name}`, `${critique}\n\n${question}`);
738
764
  },
739
- })).finally(() => {
765
+ }))
766
+ .catch((error) => {
767
+ if (!(error instanceof JobStoppedError))
768
+ throw error;
769
+ return undefined;
770
+ })
771
+ .finally(() => {
740
772
  ended();
741
773
  clearInterval(ticker);
742
774
  if (ctx.hasUI)
743
775
  ctx.ui.setWidget("turri-skill", undefined);
744
776
  });
777
+ if (ran === undefined) {
778
+ const text = [`${skill.name}: stopped by the person, in its ${live.step || "first"} step. It can be continued with resume: true.`, "", "<trace>", ...trace, "</trace>"].join("\n");
779
+ return { content: [{ type: "text", text }], details: { stopped: true } };
780
+ }
781
+ const { result, output: captured } = ran;
745
782
  const noise = describeCaptured(captured);
746
783
  if (noise !== null)
747
784
  push(noise);
package/dist/genome.d.ts CHANGED
@@ -17,12 +17,7 @@ export interface RunSkillResult {
17
17
  /** The last step's artifact - what the caller usually wants. */
18
18
  output: string;
19
19
  }
20
- /**
21
- * Run one skill to completion. The only entry point: the CLI and the Pi extension
22
- * both go through here, so a skill behaves identically whether a human launched it
23
- * or a conversational agent decided to.
24
- */
25
- export declare function runSkill(opts: {
20
+ export interface RunSkillOptions {
26
21
  skillDir: string;
27
22
  cwd: string;
28
23
  runDir: string;
@@ -51,7 +46,18 @@ export declare function runSkill(opts: {
51
46
  loopTools?: ToolDefinition[];
52
47
  /** The jobs the render queue runs, for a skill that waits for its builds and reads what they recorded. */
53
48
  jobs?: JobRegistry;
54
- }): Promise<RunSkillResult>;
49
+ /**
50
+ * Stops the run: its agents end their turns and it throws JobStoppedError, its manifest left as it was - an
51
+ * interrupted run, for `resume` to pick up. What it started on the work loop goes on.
52
+ */
53
+ signal?: AbortSignal;
54
+ }
55
+ /**
56
+ * Run one skill to completion. The only entry point: the CLI and the Pi extension
57
+ * both go through here, so a skill behaves identically whether a human launched it
58
+ * or a conversational agent decided to.
59
+ */
60
+ export declare function runSkill(opts: RunSkillOptions): Promise<RunSkillResult>;
55
61
  /** Whether there is a run of this skill here that was interrupted, and could be resumed. */
56
62
  export declare function interruptedRun(runDir: string, skillName: string): {
57
63
  step?: string;
package/dist/genome.js CHANGED
@@ -4,11 +4,11 @@ import { Command, MemorySaver } from "@langchain/langgraph";
4
4
  import { compileSkill } from "./graph.js";
5
5
  import { createExecutor } from "./pi.js";
6
6
  import { toolchainsBlock } from "./stack.js";
7
- import { describeToolCall, describeToolResult } from "./regenerate/activity.js";
7
+ import { clearOfStop, describeToolCall, describeToolResult, withStopSignal } from "./regenerate/activity.js";
8
8
  import { createLiveReviewer } from "./review/live.js";
9
9
  import { loadSkill, readRubric } from "./skill.js";
10
10
  import { createAskUserTool, createEndQuestionsTool, createReviewTools, createWriteArtifactTool, withReviewNote } from "./tools.js";
11
- import { DISMISSED } from "./ask.js";
11
+ import { DECLINED, DISMISSED } from "./ask.js";
12
12
  import { createQuestioning, levelOf, questioningQuestion } from "./questioning.js";
13
13
  import { createSpecDiffTools } from "./spec-tools.js";
14
14
  import { createMatureTools, MATURE_TOOLS } from "./mature-tools.js";
@@ -23,6 +23,16 @@ const SAID_MAX = 6000;
23
23
  * or a conversational agent decided to.
24
24
  */
25
25
  export async function runSkill(opts) {
26
+ if (opts.signal === undefined)
27
+ return runStoppable(opts);
28
+ return withStopSignal(opts.signal, () => runStoppable({
29
+ ...opts,
30
+ ...(opts.renderQueue !== undefined ? { renderQueue: clearOfStop(opts.renderQueue) } : {}),
31
+ ...(opts.jobs !== undefined ? { jobs: clearOfStop(opts.jobs) } : {}),
32
+ ...(opts.loopTools !== undefined ? { loopTools: opts.loopTools.map(clearOfStop) } : {}),
33
+ }));
34
+ }
35
+ async function runStoppable(opts) {
26
36
  const skill = loadSkill(opts.skillDir);
27
37
  const runDir = resolve(opts.runDir);
28
38
  // A resumed run keeps its folder - the artifacts are in it - and starts where it stopped.
@@ -102,7 +112,7 @@ export async function runSkill(opts) {
102
112
  customTools.push(createAskUserTool(opts.askUser, (answer, confirmsPhase) => {
103
113
  said = "";
104
114
  // A phase's confirmation, or a question dismissed, owes the spec nothing.
105
- diffs?.pace.answered(!confirmsPhase && answer !== DISMISSED);
115
+ diffs?.pace.answered(!confirmsPhase && answer !== DISMISSED && answer !== DECLINED);
106
116
  }, () => said, holdsQuestions ? questions : undefined));
107
117
  if (capsQuestions)
108
118
  customTools.push(createEndQuestionsTool(questions));
@@ -1,6 +1,6 @@
1
1
  import { type AgentSession, type ExtensionFactory, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { AskUserIO } from "./ask.ts";
3
- import type { ChatInfo, TurriHost } from "./chats.ts";
3
+ import { type ChatInfo, type TurriHost } from "./chats.ts";
4
4
  import type { JobRegistry } from "./regenerate/jobs.ts";
5
5
  import type { WorkLoop } from "./regenerate/loop.ts";
6
6
  /**
@@ -42,6 +42,13 @@ export interface Turri {
42
42
  closeChat(id: string): Promise<void>;
43
43
  /** The chat the person looks at now. */
44
44
  focus(id: string): void;
45
+ /**
46
+ * ctrl+c in a chat, as the terminal has it: what the chat is doing stopped - its turn, a skill or a tool in it, a `!`
47
+ * command, a compaction - and nothing in the others (`stopped`). In a chat doing nothing the session ends - every
48
+ * chat let go and what activate started stopped, as `dispose` does - for the host to close what showed it (`ended`).
49
+ * A second interrupt straight after one that stopped something does nothing (`ignored`); `missing` for a chat not open.
50
+ */
51
+ interrupt(id: string): Promise<"stopped" | "ignored" | "ended" | "missing">;
45
52
  /** Told when a chat is opened, closed, renamed, or starts or stops working. */
46
53
  onChats(listener: (chats: ChatInfo[]) => void): () => void;
47
54
  /** Every chat let go, and what activate started stopped. */
@@ -1,5 +1,6 @@
1
1
  import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager } from "@earendil-works/pi-coding-agent";
2
2
  import { lastRun } from "./chat-records.js";
3
+ import { interruptChat } from "./chats.js";
3
4
  import { createTurriCore, sessionLoop } from "./extension.js";
4
5
  import { stopDeployed } from "./regenerate/release.js";
5
6
  /** turri's session in a host, in the process's working folder - the project's. */
@@ -36,6 +37,11 @@ export async function createTurri(options = {}) {
36
37
  if (first !== undefined)
37
38
  await core.show(first.id);
38
39
  }
40
+ let lastStop = 0;
41
+ const dispose = async () => {
42
+ await core.dispose();
43
+ await stopDeployed();
44
+ };
39
45
  return {
40
46
  jobs: core.jobs,
41
47
  get loop() {
@@ -49,10 +55,18 @@ export async function createTurri(options = {}) {
49
55
  },
50
56
  closeChat: (id) => core.close(id),
51
57
  focus: (id) => void core.show(id),
52
- onChats: (listener) => core.onChats(() => listener(core.chats())),
53
- async dispose() {
54
- await core.dispose();
55
- await stopDeployed();
58
+ async interrupt(id) {
59
+ const session = core.session(id);
60
+ if (session === undefined)
61
+ return "missing";
62
+ const did = interruptChat(session, { lastStop, idle: () => "ended" });
63
+ if (did === "stopped")
64
+ lastStop = Date.now();
65
+ if (did === "ended")
66
+ await dispose();
67
+ return did;
56
68
  },
69
+ onChats: (listener) => core.onChats(() => listener(core.chats())),
70
+ dispose,
57
71
  };
58
72
  }
@@ -25,6 +25,8 @@ export interface QuestionDialogOptions {
25
25
  style: DialogStyle;
26
26
  /** The answer - an option, or what was typed - or undefined, dismissed. */
27
27
  done: (answer: string | undefined) => void;
28
+ /** ctrl+c: the question declined, the asker deciding what that stops (askUserVia). Without it, dismissed as esc is. */
29
+ interrupt?: () => void;
28
30
  /** Draw again: a key changed what shows. */
29
31
  requestRender: () => void;
30
32
  }
@@ -42,6 +44,8 @@ export declare class QuestionDialog implements Component, Focusable {
42
44
  constructor(options: QuestionDialogOptions);
43
45
  get focused(): boolean;
44
46
  set focused(value: boolean);
47
+ /** Closed, no answer given: the question declined from outside - its turn stopped. */
48
+ dismiss(): void;
45
49
  private finish;
46
50
  private scrollBy;
47
51
  render(width: number): string[];
@@ -36,6 +36,10 @@ export class QuestionDialog {
36
36
  if (this.input !== undefined)
37
37
  this.input.focused = value;
38
38
  }
39
+ /** Closed, no answer given: the question declined from outside - its turn stopped. */
40
+ dismiss() {
41
+ this.finish(undefined);
42
+ }
39
43
  finish(answer) {
40
44
  if (this.answered)
41
45
  return;
@@ -65,8 +69,8 @@ export class QuestionDialog {
65
69
  return index === this.selected ? style.accent(shown) : shown;
66
70
  }));
67
71
  const hint = this.input !== undefined
68
- ? "enter answers · esc dismisses"
69
- : "↑↓ choose · enter picks · esc dismisses";
72
+ ? "enter answers · esc dismisses · ctrl+c declines"
73
+ : "↑↓ choose · enter picks · esc dismisses · ctrl+c declines";
70
74
  // The brief gets what the question, the options and the hints leave of the screen.
71
75
  const fixed = question.length + body.length + 5;
72
76
  const room = Math.max(3, this.options.rows() - KEPT_ABOVE - fixed);
@@ -89,6 +93,13 @@ export class QuestionDialog {
89
93
  return lines;
90
94
  }
91
95
  handleInput(data) {
96
+ if (matchesKey(data, "ctrl+c")) {
97
+ if (this.options.interrupt !== undefined)
98
+ this.options.interrupt();
99
+ else
100
+ this.finish(undefined);
101
+ return;
102
+ }
92
103
  // The brief scrolls whatever has the keys.
93
104
  if (matchesKey(data, "pageDown"))
94
105
  return this.scrollBy(Math.max(1, this.window - 1));
@@ -84,8 +84,15 @@ export declare function reportSpecConflict(conflict: SpecConflict): void;
84
84
  export declare class JobStoppedError extends Error {
85
85
  constructor();
86
86
  }
87
- /** The stop signal of the job this code is running for, if it is running for one. */
87
+ /** The stop signal of the job this code is running for - else of the work outside a job it runs for - if there is one. */
88
88
  export declare function currentStopSignal(): AbortSignal | undefined;
89
+ /** Run work for no job that `signal` stops: every agent under it ends its turn, and the work unwinds with JobStoppedError. */
90
+ export declare function withStopSignal<T>(signal: AbortSignal | undefined, run: () => Promise<T>): Promise<T>;
91
+ /**
92
+ * What work under withStopSignal hands on to the session - the work loop, the job registry - with each of its methods
93
+ * run clear of that signal: a pulse or a plan started from a skill outlives the skill being stopped.
94
+ */
95
+ export declare function clearOfStop<T extends object>(given: T): T;
89
96
  /** The job this code is running for, if it is running for one. */
90
97
  export declare function currentJob(): {
91
98
  id: string;
@@ -14,9 +14,30 @@ export class JobStoppedError extends Error {
14
14
  this.name = "JobStoppedError";
15
15
  }
16
16
  }
17
- /** The stop signal of the job this code is running for, if it is running for one. */
17
+ /** What stops work that runs for no job - a skill run, stopped with the chat's turn that runs it (withStopSignal). */
18
+ const stops = new AsyncLocalStorage();
19
+ /** The stop signal of the job this code is running for - else of the work outside a job it runs for - if there is one. */
18
20
  export function currentStopSignal() {
19
- return store.getStore()?.signal;
21
+ const sink = store.getStore();
22
+ return sink !== undefined ? sink.signal : stops.getStore();
23
+ }
24
+ /** Run work for no job that `signal` stops: every agent under it ends its turn, and the work unwinds with JobStoppedError. */
25
+ export function withStopSignal(signal, run) {
26
+ return stops.run(signal, run);
27
+ }
28
+ /**
29
+ * What work under withStopSignal hands on to the session - the work loop, the job registry - with each of its methods
30
+ * run clear of that signal: a pulse or a plan started from a skill outlives the skill being stopped.
31
+ */
32
+ export function clearOfStop(given) {
33
+ return new Proxy(given, {
34
+ get(target, key, receiver) {
35
+ const value = Reflect.get(target, key, receiver);
36
+ if (typeof value !== "function")
37
+ return value;
38
+ return (...args) => stops.run(undefined, () => value.apply(target, args));
39
+ },
40
+ });
20
41
  }
21
42
  /** The job this code is running for, if it is running for one. */
22
43
  export function currentJob() {
package/docs/chats.md CHANGED
@@ -33,6 +33,7 @@ A pulse's end is told to the chat that asked for it, shown or not - its agent re
33
33
  | `/chat` | a list of the chats, to pick one - or a new one |
34
34
  | `/new` | the same as `/chat new` |
35
35
  | `/resume` | a saved chat, opened as a chat of its own beside the others - or shown, when it is open |
36
+ | `ctrl+c` | what the chat shown is doing, stopped - its turn, a skill or a tool in it, a `!` command, a compaction; in a chat doing nothing, a draft in the editor cleared, else turri left, every chat with it |
36
37
 
37
38
  A line above the editor names each chat once there are two, a `│` between them: its number, its title - "new chat" until something is said in it, then its first message, until `/name` gives it one - and, after it, what it is doing: a spinner while it works, `?` while a question waits in it, `•` when it said something since you last looked. The chat shown is in the accent colour.
38
39
 
@@ -41,6 +42,7 @@ On macOS, `alt` is Option (⌥), and reaches turri only when the terminal sends
41
42
  - **A hidden chat's questions** wait until you show it, then come up one after another. What it says in passing is said as you show it; what it keeps on the screen - a skill's progress, the job panel - is put back each time; what you had typed in its editor and not sent is still there.
42
43
  - **The side panel** (`ctrl+h`) is the session's, not a chat's: docked beside the chat or over it, it stays as another chat is shown, and opening it from any chat finds it as it was left.
43
44
  - **A long command** - `/regenerate`, planning a module from scratch - does its work in the background once it is under way: the chat, and switching to another, stay free while the planner plans.
45
+ - **`ctrl+c`** reaches the chat shown and nothing else: another chat's turn and skill go on, and so do the pulses - `/halt` stops one. A skill stopped keeps its run, for the model to continue with `resume`. At a question - the chat's `ask_user`, a skill's interview - `ctrl+c` declines it, and the asker is told so and goes on - each question it is pressed at, however many in a row. A skill is stopped by `ctrl+c` with no question open. A second press straight after one that stopped something does nothing, so pressing twice to be sure does not leave turri; once the chat is doing nothing, the next one does - with what every other chat is doing. A host has the same as `interrupt(id)` ([hosting](hosting.md)).
44
46
  - **The last chat** is not closed: `/quit` leaves turri.
45
47
  - **`/fork`** and **`/reload`** work on the chat shown, as [pi](https://pi.dev)'s do: the chat's history forked, or turri's extension rebuilt in it.
46
48
  - **A chat shown mid-turn** draws its reply as far as it is written, and follows it from there.
package/docs/hosting.md CHANGED
@@ -89,6 +89,7 @@ const chat = await turri.openChat(); // { id, title, status, shown, session }:
89
89
  chat.session.subscribe((event) => {}); // pi's session: its events, prompt(), abort()
90
90
  await chat.session.prompt("add a cart to the shop");
91
91
  turri.focus(other.id); // the chat the person looks at now
92
+ await turri.interrupt(chat.id); // ctrl+c: "stopped" what it was doing, else "ended" the session - "ignored", "missing"
92
93
  turri.chats(); // [{ id, title, status: "idle" | "working" | "question" | "unread", shown }]
93
94
  turri.onChats((chats) => {}); // a chat opened, closed, renamed, working or done
94
95
  await turri.closeChat(chat.id); // its turn stopped, its conversation deleted - its pulses run on
@@ -98,6 +99,7 @@ await turri.dispose(); // every chat let go, what activate start
98
99
  ```
99
100
 
100
101
  - **The chats.** Up to nine, each its own history, model, thinking level and stack, all on the session's one job registry and work loop - see [chats](chats.md). `openChat({ sessionFile })` reopens a saved one. A pulse's end is told to the chat that asked for it, its agent reacting there; to the chat `focus` last named when that one is closed. A chat is never hidden from turri's side: what the host does not show, it still runs, and its questions go to the host's dialogs at once - the host decides where they wait. `status` says a turn is running (`working`) or that it said something since it was last focused (`unread`).
102
+ - **Interrupting.** `interrupt(id)` is the terminal's ctrl+c for a host's key or button: it stops what that chat is doing - its turn, a skill or a tool in it, a `!` command, a compaction - and nothing in the other chats (`stopped`). A skill stopped keeps its run, to be continued. A question of the chat's open in the host's dialogs when its turn is stopped is over - declined - though its dialog is the host's to close. A chat doing nothing ends the session, every chat with it, as `dispose` does (`ended`): turri never exits the host's process, so the host closes what showed it. An interrupt straight after one that stopped something is `ignored`, so pressing twice to be sure ends nothing. `session.abort()` is the chat's turn alone, and never ends the session.
101
103
  - **The record.** The chats are recorded in `.turri/chats.json` as the terminal records them, and `restore: true` reopens the last ended session's - without it, a host starts with none and deletes nothing. A chat closed is deleted; a session's chats are kept until five newer sessions have recorded theirs.
102
104
  - **The jobs.** The host is told of each job as it changes, and has the registry and the loop themselves, so it shows the very jobs the chats run. Its buttons are best a chat's commands - `session.prompt("/regenerate app.plain")` - so the plan is the chats'.
103
105
  - **The questions.** `dialogs(chat)` gives a chat's two dialogs: `select(title, options, context, at)` and `input(title, context)`, each resolving with the answer, or undefined when the person dismissed it. The chat's questions, a skill's interview and a review gate's escalation are put in them; without them, in the `uiContext`'s own dialogs.
@@ -43,7 +43,7 @@ A skill declares its steps with a `## Steps` section whose `###` headings are th
43
43
 
44
44
  `questioning:` under `metadata:` names the steps the questioning level caps (`src/questioning.ts`) - `brood`'s `[stack, product]`, `bud`'s `[product]`. When there are any and someone to ask, the harness itself asks the first question of the first step: how many questions those steps may ask - light (3), medium (5), thorough (10) or best judgement - and tells the agent the answer beside each step's instructions, with how broad each question is to be: broad at light, a whole area at once, narrow at thorough, an edge case at a time. Every level covers the same ground, in less or more depth. In a capped step `ask_user` refuses a question past the cap; the step's closing confirmation, asked with `confirms_phase: true`, is never counted. `end_questions` ends the step's questions sooner - best judgement's only end. What the questions leave open, the agent decides itself and says what it chose. The level, and how many questions each step has asked, are kept in the run's `run.json`, so a resumed run keeps them. A step it does not name, or a run with nobody to ask, is not capped.
45
45
 
46
- `change-plain-per-answer:` under `metadata:` names the steps where every answer is a \*\*\*plain change - `brood`'s and `bud`'s `[product]`, at every questioning level. There `ask_user` refuses the next question until the last answer is written with `change_plain` (a confirmation, or a question dismissed, owes nothing), and `change_plain` no longer waits for an answer before a change: the agent writes as many as the step needs, those it decides itself included. The level limits the questions, never the \*\*\*plain changes.
46
+ `change-plain-per-answer:` under `metadata:` names the steps where every answer is a \*\*\*plain change - `brood`'s and `bud`'s `[product]`, at every questioning level. There `ask_user` refuses the next question until the last answer is written with `change_plain` (a confirmation, or a question dismissed or declined, owes nothing), and `change_plain` no longer waits for an answer before a change: the agent writes as many as the step needs, those it decides itself included. The level limits the questions, never the \*\*\*plain changes.
47
47
 
48
48
  A step ends when its agent's turn does. A turn that ends on a question in the reply would end the step there, half done, on a question no one reads - the person sees only what `ask_user` asks. So such a step is sent back, twice at most: to ask with `ask_user` if the question is truly the person's, or, in an unpaced skill, to decide it itself - then to carry on until the step is done.
49
49
 
@@ -81,7 +81,7 @@ A gate can also have a `continuous` reviewer: a read-only agent that re-reads th
81
81
  - **Regenerate, and conform.** A skill that names them in `allowed-tools` gets the conversation's own `regenerate`, `drift`, `halt` and `conform`, on its loop: `conform` schedules a conformance pulse for functional specs not built yet, as it does from the chat. The skill never waits for them.
82
82
  - **Choose the model that regenerates**, when the user says so - `engine`, the same tool the conversation has.
83
83
 
84
- The harness still owns the sequence. A step chooses what to ask; it does not choose whether the gate afterwards runs. A run is saved as it goes, so an interrupted one can be resumed where it stopped.
84
+ The harness still owns the sequence. A step chooses what to ask; it does not choose whether the gate afterwards runs. A run is saved as it goes, so an interrupted one can be resumed where it stopped - a run stopped with the chat's turn (`ctrl+c`, `esc`) too: its agents end their turns there, and what it started on the work loop goes on.
85
85
 
86
86
  While a skill runs in the chat, `cultivate`'s output leads with what its agent is doing now: the skill, its step and how long each has taken, its last few tool calls, and the end of what it is saying, refreshed every few seconds. [pi](https://pi.dev) shows only a running tool's first lines, and a step can take many minutes without a new line in the trace. The result the conversation gets back keeps the trace alone.
87
87
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turri-dev/cli",
3
- "version": "0.1.17-next.1ecfd4f",
3
+ "version": "0.1.17",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {