@turri-dev/cli 0.1.19 → 0.1.20-next.3ae4a0b

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 (49) hide show
  1. package/dist/chat-commands.js +2 -1
  2. package/dist/chat-records.d.ts +2 -0
  3. package/dist/chat-terminal.d.ts +1 -0
  4. package/dist/chat-terminal.js +1 -1
  5. package/dist/chats.d.ts +60 -3
  6. package/dist/chats.js +201 -14
  7. package/dist/config-edit.js +2 -0
  8. package/dist/config.d.ts +8 -1
  9. package/dist/config.js +5 -2
  10. package/dist/extension.js +57 -1
  11. package/dist/host-session.d.ts +11 -0
  12. package/dist/host-session.js +2 -0
  13. package/dist/host.d.ts +1 -0
  14. package/dist/host.js +2 -0
  15. package/dist/identity.js +2 -2
  16. package/dist/init-config.d.ts +1 -0
  17. package/dist/init-config.js +6 -0
  18. package/dist/notice.d.ts +59 -0
  19. package/dist/notice.js +38 -0
  20. package/dist/pi-launch.js +9 -2
  21. package/dist/regenerate/agent-shell.d.ts +5 -0
  22. package/dist/regenerate/agent-shell.js +2 -2
  23. package/dist/regenerate/audit.js +1 -1
  24. package/dist/regenerate/codebase.js +1 -1
  25. package/dist/regenerate/command-check.js +1 -1
  26. package/dist/regenerate/confine.d.ts +2 -0
  27. package/dist/regenerate/confine.js +2 -2
  28. package/dist/regenerate/ledger.d.ts +14 -0
  29. package/dist/regenerate/paths.d.ts +2 -0
  30. package/dist/regenerate/paths.js +2 -0
  31. package/dist/regenerate/records.d.ts +1 -1
  32. package/dist/regenerate/records.js +3 -1
  33. package/dist/regenerate/release.js +1 -1
  34. package/dist/regenerate/testing.js +1 -1
  35. package/dist/regenerate/workspace.js +1 -1
  36. package/dist/sentinel-chat.d.ts +14 -0
  37. package/dist/sentinel-chat.js +178 -0
  38. package/dist/sentinel.d.ts +55 -0
  39. package/dist/sentinel.js +279 -0
  40. package/docs/README.md +2 -0
  41. package/docs/chats.md +4 -3
  42. package/docs/config.md +3 -1
  43. package/docs/deploy.md +2 -2
  44. package/docs/development.md +1 -0
  45. package/docs/hosting.md +18 -3
  46. package/docs/sentinel.md +54 -0
  47. package/docs/using.md +1 -0
  48. package/genome/rules/turri-regeneration.md +1 -0
  49. package/package.json +1 -1
@@ -88,7 +88,8 @@ function closeChat(ctx, session, which) {
88
88
  const chat = chatNamed(session, which);
89
89
  if (typeof chat === "string")
90
90
  return ctx.ui.notify(chat, "warning");
91
- if (session.chats().length === 1)
91
+ // The sentinel is not the person's: the last chat of theirs is not closed.
92
+ if (chat.role === undefined && session.chats().filter((other) => other.role === undefined).length === 1)
92
93
  return ctx.ui.notify("This is the only chat - /quit leaves turri.", "info");
93
94
  later(ctx, session, () => session.close(chat.id));
94
95
  }
@@ -11,6 +11,8 @@ export interface ChatRecord {
11
11
  title: string;
12
12
  /** pi's session file: the chat's conversation. */
13
13
  sessionFile: string;
14
+ /** `sentinel` for the session's sentinel. */
15
+ role?: "sentinel";
14
16
  }
15
17
  export interface RunRecord {
16
18
  run: string;
@@ -20,4 +20,5 @@ export interface CtrlCScreen {
20
20
  export declare function pressCtrlC(screen: CtrlCScreen, lastStop: number, now?: number): "stopped" | "ignored" | "cleared" | "left";
21
21
  export declare function driveTerminal(core: TurriCore, options?: {
22
22
  restore?: RunRecord;
23
+ skipFirst?: boolean;
23
24
  }): void;
@@ -42,7 +42,7 @@ export function driveTerminal(core, options = {}) {
42
42
  if (restore !== undefined) {
43
43
  const saved = restore;
44
44
  restore = undefined;
45
- void core.restore(saved, { skipFirst: true });
45
+ void core.restore(saved, { skipFirst: options.skipFirst !== false });
46
46
  }
47
47
  };
48
48
  /**
package/dist/chats.d.ts CHANGED
@@ -4,6 +4,8 @@ import { type RunRecord } from "./chat-records.ts";
4
4
  import { type ChatUi } from "./chat-ui.ts";
5
5
  import type { AskUserIO } from "./ask.ts";
6
6
  import type { Job, JobRegistry } from "./regenerate/jobs.ts";
7
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
8
+ import { type ChatRole, type Conclusion, type Notice, type NoticeAnswer, type NoticeInput } from "./notice.ts";
7
9
  /**
8
10
  * The chats of a turri session.
9
11
  *
@@ -18,6 +20,10 @@ import type { Job, JobRegistry } from "./regenerate/jobs.ts";
18
20
  *
19
21
  * Where the chats live is a driver's: the terminal's (chat-terminal.ts) swaps which one pi's screen shows; a host's
20
22
  * (`createTurri`) makes a session for each and shows them in its own windows.
23
+ *
24
+ * With the project's sentinel on (sentinel.ts), every finished pulse goes to the sentinel - a chat of the session's own,
25
+ * opened in the background with the first, and again after it is closed - and the chat the person looks at shows only
26
+ * that it landed. What the sentinel finds wrong it puts to the person as a notice, in the chat they look at.
21
27
  */
22
28
  /** The most chats a session has open at once. */
23
29
  export declare const MAX_CHATS = 9;
@@ -31,6 +37,8 @@ export interface ChatInfo {
31
37
  status: ChatStatus;
32
38
  /** On the screen now - the terminal's, or the window the host said it shows (`focus`). */
33
39
  shown: boolean;
40
+ /** `sentinel` for the session's sentinel; none for a chat of the person's. */
41
+ role?: ChatRole;
34
42
  }
35
43
  /** What turri tells and asks a host that runs its conversation itself. */
36
44
  export interface TurriHost {
@@ -51,11 +59,44 @@ export interface TurriHost {
51
59
  * work: what it returns is what is sent. For a host that needs to change anything else.
52
60
  */
53
61
  systemPrompt?(prompt: string): string;
62
+ /** The sentinel is done with a pulse - told to the chat it names, whose agent says so. */
63
+ onConcluded?(conclusion: Conclusion & {
64
+ chat: string;
65
+ }): void;
66
+ /** What the sentinel puts to the person: the host draws it in the chat it names, and answers it with `answer`. */
67
+ onNotice?(notice: Notice): void;
68
+ /** A notice is over - answered, dismissed, or superseded by a newer one about its module: the host takes its card down. */
69
+ onNoticeOver?(notice: Notice): void;
70
+ /** The sentinel's own tools beside turri's: a host's browser, say. */
71
+ sentinel?: {
72
+ tools?: ToolDefinition[];
73
+ };
54
74
  }
55
75
  /** What a chat's extension does for its chat when the session asks. */
56
76
  export interface ChatInstance {
57
- /** A pulse it asked for ended: said in the chat, and its agent told. */
77
+ /** A pulse it asked for ended: said in the chat, and its agent told, to react to it. */
58
78
  announce(job: Job): void;
79
+ /**
80
+ * A pulse ended, with the sentinel on: said in the chat - and, with `tell`, its agent told it landed and what the
81
+ * sentinel does with it, to tell the person and do nothing more.
82
+ */
83
+ landed(job: Job, options?: {
84
+ tell?: boolean;
85
+ }): void;
86
+ /** The sentinel's: a pulse ended - its agent told, at once, or after what it does now; `replace`, what it does now stopped. */
87
+ brief(job: Job, context: {
88
+ asked?: string;
89
+ focused?: string;
90
+ replace: boolean;
91
+ }): Promise<void>;
92
+ /** A notice put in the chat: drawn, and - in the terminal - put to the person once the chat is idle. */
93
+ notice(notice: Notice): void;
94
+ /** A notice is over: its dialog closed, if it is open. */
95
+ noticeOver(notice: Notice): void;
96
+ /** The sentinel is done with a pulse: said in the chat, and its agent told, to tell the person and do nothing more. */
97
+ concluded(conclusion: Conclusion): void;
98
+ /** The person answered a notice with something to do: the chat's agent started on it. */
99
+ act(task: string): void;
59
100
  /** The jobs changed: what it draws of them is drawn again. */
60
101
  jobsChanged(): void;
61
102
  /** How hard the chat's model thinks now: sub-agents started from it think as hard. */
@@ -69,6 +110,8 @@ export interface ChatLink {
69
110
  readonly scope: ChatScope;
70
111
  /** The host's dialogs, for this chat's questions - undefined in the terminal. */
71
112
  readonly dialogs: AskUserIO | undefined;
113
+ /** `sentinel` for the session's sentinel. */
114
+ readonly role: ChatRole | undefined;
72
115
  /** Its pi session, once it has one. */
73
116
  session(): AgentSession | undefined;
74
117
  /** The extension built for the chat - again, when pi rebuilds it - and what it does for it. */
@@ -89,6 +132,7 @@ export interface ChatDriver {
89
132
  export interface ChatState {
90
133
  id: string;
91
134
  scope: ChatScope;
135
+ role?: ChatRole;
92
136
  /** Its title as recorded, until it says one of its own. */
93
137
  recordedTitle?: string;
94
138
  session?: AgentSession;
@@ -119,11 +163,12 @@ export interface TurriCore {
119
163
  decline(id: string): "declined" | "none" | "missing";
120
164
  /** The chat shown now. */
121
165
  shown(): string | undefined;
122
- /** Open a chat - fresh, or the saved one `sessionFile` is - shown at once unless `hidden`. */
166
+ /** Open a chat - fresh, or the saved one `sessionFile` is - shown at once unless `hidden`; the sentinel with `role`. */
123
167
  open(options?: {
124
168
  sessionFile?: string;
125
169
  hidden?: boolean;
126
170
  title?: string;
171
+ role?: ChatRole;
127
172
  }): Promise<ChatInfo>;
128
173
  close(id: string): Promise<void>;
129
174
  /** Show the chat - in the terminal, put it on the screen; for a host, the one its person looks at now. */
@@ -147,8 +192,18 @@ export interface TurriCore {
147
192
  adopt(session: AgentSession, services: unknown): ChatState;
148
193
  /** For a driver: `chat` is on the screen now. */
149
194
  showing(chat: ChatState): void;
150
- /** For the extension: a pulse ended - told to the chat that asked for it. */
195
+ /** For the extension: a pulse ended - told to the chat that asked for it, or, with the sentinel on, to the sentinel. */
151
196
  jobFinished(job: Job): void;
197
+ /** Whether finished pulses go to the sentinel now: it is on, and there is somewhere to open it. */
198
+ sentinelOn(): boolean;
199
+ /** A notice put to the person, in the chat they look at - the sentinel's, or turri's own. Undefined: no chat to put it in. */
200
+ notice(input: NoticeInput): Notice | undefined;
201
+ /** The sentinel done with a pulse: told to the chat the person looks at. Undefined: no chat to tell. */
202
+ concluded(conclusion: Conclusion): string | undefined;
203
+ /** The notices put to the person and not yet answered. */
204
+ notices(): Notice[];
205
+ /** A notice answered: what it asked done in the chat it is in, or dismissed. */
206
+ answer(id: string, answer: NoticeAnswer): "done" | "dismissed" | "missing" | "over";
152
207
  /** For the extension: the jobs changed - every chat draws them again. */
153
208
  jobsChanged(): void;
154
209
  /** The chat whose work this is, else the one shown: its instance. */
@@ -169,6 +224,8 @@ export interface CoreOptions {
169
224
  persist?: boolean;
170
225
  /** The host's dialogs, a chat's own. */
171
226
  dialogs?: (chat: string) => AskUserIO | undefined;
227
+ /** Whether the project wants a sentinel - read as each pulse finishes. Unset: none. */
228
+ sentinel?: () => boolean;
172
229
  }
173
230
  /** A chat's session, as stopping what it does reaches it. */
174
231
  export type Stoppable = Pick<AgentSession, "isStreaming" | "isCompacting" | "isRetrying" | "isBashRunning" | "abort" | "abortBash">;
package/dist/chats.js CHANGED
@@ -5,6 +5,7 @@ import { adoptRun, forgetChat, recordRun } from "./chat-records.js";
5
5
  import { createChatUi } from "./chat-ui.js";
6
6
  import { debugMode } from "./config.js";
7
7
  import { record } from "./regenerate/ledger.js";
8
+ import { newNotice, noticeTask, SENTINEL, wentWrong } from "./notice.js";
8
9
  /**
9
10
  * The chats of a turri session.
10
11
  *
@@ -19,6 +20,10 @@ import { record } from "./regenerate/ledger.js";
19
20
  *
20
21
  * Where the chats live is a driver's: the terminal's (chat-terminal.ts) swaps which one pi's screen shows; a host's
21
22
  * (`createTurri`) makes a session for each and shows them in its own windows.
23
+ *
24
+ * With the project's sentinel on (sentinel.ts), every finished pulse goes to the sentinel - a chat of the session's own,
25
+ * opened in the background with the first, and again after it is closed - and the chat the person looks at shows only
26
+ * that it landed. What the sentinel finds wrong it puts to the person as a notice, in the chat they look at.
22
27
  */
23
28
  /** The most chats a session has open at once. */
24
29
  export const MAX_CHATS = 9;
@@ -81,6 +86,10 @@ export function createChats(options) {
81
86
  /** Chats are opened one at a time: each one's extension is built while it is the one attaching. */
82
87
  let opening = Promise.resolve();
83
88
  let notices = [];
89
+ /** What the sentinel put to the person, and turri itself, this session: open until answered or superseded. */
90
+ const open = [];
91
+ /** The pulse the sentinel is on now, while its agent works: a newer one of the same module replaces a check of one that went well. */
92
+ let sentinelOnJob;
84
93
  const run = { run: randomUUID().slice(0, 8), pid: process.pid, started: new Date().toISOString(), chats: [] };
85
94
  const changed = () => {
86
95
  for (const listener of listeners) {
@@ -93,24 +102,44 @@ export function createChats(options) {
93
102
  }
94
103
  };
95
104
  /** A chat nothing was said in yet is a new chat: its number is beside it already, in the strip and the picker. */
96
- const titleFor = (chat) => titleOf(chat.session) ?? chat.recordedTitle ?? NEW_CHAT_TITLE;
105
+ const titleFor = (chat) => (chat.role === SENTINEL ? SENTINEL : (titleOf(chat.session) ?? chat.recordedTitle ?? NEW_CHAT_TITLE));
106
+ const sentinelChat = () => chats.find((chat) => chat.role === SENTINEL);
107
+ /** The person's chats: all but the sentinel. */
108
+ const people = () => chats.filter((chat) => chat.role !== SENTINEL);
109
+ const wantsSentinel = () => {
110
+ try {
111
+ return options.sentinel?.() === true;
112
+ }
113
+ catch {
114
+ return false;
115
+ }
116
+ };
117
+ /** The chat the person looks at, for what is put to them - never the sentinel: when it is shown, `fallback`, else their first. */
118
+ const personFacing = (fallback) => {
119
+ const shown = chats.find((chat) => chat.id === shownId);
120
+ if (shown !== undefined && shown.role !== SENTINEL && shown.instance !== undefined)
121
+ return shown;
122
+ return fallback?.instance !== undefined && fallback.role !== SENTINEL ? fallback : people().find((chat) => chat.instance !== undefined);
123
+ };
97
124
  const statusOf = (chat) => (chat.ui?.waiting() ?? 0) > 0 ? "question" : chat.working ? "working" : chat.unread ? "unread" : "idle";
98
125
  /** What the session keeps of its chats on disk: those with something said in them, in order. */
99
126
  const persist = () => {
100
127
  if (options.persist !== true)
101
128
  return;
102
- run.chats = chats.flatMap((chat) => {
129
+ // The sentinel last: `--continue` opens the first as pi's own chat, and that is the person's.
130
+ run.chats = [...people(), ...chats.filter((chat) => chat.role === SENTINEL)].flatMap((chat) => {
103
131
  const file = chat.session?.sessionFile;
104
- return file !== undefined && existsSync(file) ? [{ id: chat.id, title: titleFor(chat), sessionFile: file }] : [];
132
+ return file !== undefined && existsSync(file) ? [{ id: chat.id, title: titleFor(chat), sessionFile: file, ...(chat.role !== undefined ? { role: chat.role } : {}) }] : [];
105
133
  });
106
134
  recordRun(cwd, run, { keep: debugMode(cwd) });
107
135
  };
108
- const newChat = (title) => {
136
+ const newChat = (title, role) => {
109
137
  const id = randomUUID().slice(0, 8);
110
138
  const chat = {
111
139
  id,
112
140
  scope: { id, stack: process.env.TURRI_STACK?.trim() || undefined, asking: new Set() },
113
141
  ...(title !== undefined ? { recordedTitle: title } : {}),
142
+ ...(role !== undefined ? { role } : {}),
114
143
  working: false,
115
144
  unread: false,
116
145
  };
@@ -131,6 +160,8 @@ export function createChats(options) {
131
160
  }
132
161
  else if (event.type === "agent_end") {
133
162
  chat.working = false;
163
+ if (chat.role === SENTINEL)
164
+ sentinelOnJob = undefined;
134
165
  if (shownId !== chat.id)
135
166
  chat.unread = true;
136
167
  persist();
@@ -153,7 +184,7 @@ export function createChats(options) {
153
184
  get terminal() {
154
185
  return driver?.terminal === true;
155
186
  },
156
- chats: () => chats.map((chat) => ({ id: chat.id, title: titleFor(chat), status: statusOf(chat), shown: chat.id === shownId })),
187
+ chats: () => chats.map((chat) => ({ id: chat.id, title: titleFor(chat), status: statusOf(chat), shown: chat.id === shownId, ...(chat.role !== undefined ? { role: chat.role } : {}) })),
157
188
  session: (id) => chats.find((chat) => chat.id === id)?.session,
158
189
  decline(id) {
159
190
  const chat = chats.find((candidate) => candidate.id === id);
@@ -171,12 +202,16 @@ export function createChats(options) {
171
202
  if (driver === undefined)
172
203
  throw new Error("This turri has one chat: there is nowhere to open another.");
173
204
  const done = opening.then(async () => {
174
- const already = asked.sessionFile !== undefined ? chats.find((chat) => chat.session?.sessionFile === asked.sessionFile) : undefined;
205
+ const already = asked.sessionFile !== undefined ? chats.find((chat) => chat.session?.sessionFile === asked.sessionFile) : asked.role === SENTINEL ? sentinelChat() : undefined;
175
206
  if (already !== undefined)
176
207
  return already;
177
- if (chats.length >= MAX_CHATS)
178
- throw new Error(`${MAX_CHATS} chats are open, the most there can be: close one first.`);
179
- const chat = newChat(asked.title);
208
+ // The sentinel has a slot of its own: with it on, the person's chats are one fewer.
209
+ const room = asked.role === SENTINEL ? MAX_CHATS - people().length : (wantsSentinel() ? MAX_CHATS - 1 : MAX_CHATS) - people().length;
210
+ if (room <= 0) {
211
+ const most = wantsSentinel() ? MAX_CHATS - 1 : MAX_CHATS;
212
+ throw new Error(`${most} chats are open, the most there can be${wantsSentinel() ? " beside the sentinel" : ""}: close one first.`);
213
+ }
214
+ const chat = newChat(asked.title, asked.role);
180
215
  attaching = chat;
181
216
  try {
182
217
  await driver.create(chat, asked.sessionFile);
@@ -189,7 +224,7 @@ export function createChats(options) {
189
224
  attaching = undefined;
190
225
  }
191
226
  follow(chat);
192
- inChat(chat.scope, () => record({ kind: "chat.opened", reopened: asked.sessionFile !== undefined }, { project: cwd }));
227
+ inChat(chat.scope, () => record({ kind: "chat.opened", reopened: asked.sessionFile !== undefined, ...(chat.role !== undefined ? { role: chat.role } : {}) }, { project: cwd }));
193
228
  persist();
194
229
  changed();
195
230
  return chat;
@@ -206,7 +241,8 @@ export function createChats(options) {
206
241
  return;
207
242
  const chat = chats[index];
208
243
  if (shownId === id) {
209
- const next = chats[index + 1] ?? chats[index - 1];
244
+ const others = chats.filter((other) => other !== chat);
245
+ const next = others.find((other, at) => other.role !== SENTINEL && at >= index) ?? [...others].reverse().find((other) => other.role !== SENTINEL) ?? others[0];
210
246
  if (next !== undefined)
211
247
  await core.show(next.id);
212
248
  else
@@ -287,6 +323,7 @@ export function createChats(options) {
287
323
  id: chat.id,
288
324
  scope: chat.scope,
289
325
  dialogs: chat.dialogs,
326
+ role: chat.role,
290
327
  session: () => chat.session,
291
328
  live: (instance) => {
292
329
  chat.instance = instance;
@@ -304,7 +341,12 @@ export function createChats(options) {
304
341
  }
305
342
  return chat;
306
343
  },
344
+ sentinelOn: () => driver !== undefined && wantsSentinel(),
307
345
  jobFinished(job) {
346
+ if (core.sentinelOn()) {
347
+ sentinelFinished(job);
348
+ return;
349
+ }
308
350
  const asked = chats.find((chat) => chat.id === job.chat);
309
351
  const chat = asked?.instance !== undefined ? asked : (core.shownChat() ?? chats[0]);
310
352
  if (chat === undefined)
@@ -332,6 +374,77 @@ export function createChats(options) {
332
374
  }
333
375
  }
334
376
  },
377
+ notice(input) {
378
+ const asked = chats.find((chat) => chat.id === (input.job !== undefined ? jobs.get(input.job)?.chat : undefined));
379
+ const target = personFacing(asked);
380
+ if (target === undefined)
381
+ return undefined;
382
+ // One notice open a module: a newer one makes the older one's call to action stale.
383
+ if (input.module !== undefined) {
384
+ for (const older of open.filter((notice) => notice.state === "open" && notice.module === input.module))
385
+ over(older, "superseded");
386
+ }
387
+ const notice = newNotice(input, target.id);
388
+ open.push(notice);
389
+ if (target.id !== shownId)
390
+ target.unread = true;
391
+ inChat(target.scope, () => record({ kind: "notice.put", notice: notice.id, severity: notice.severity, ...(notice.module !== undefined ? { module: notice.module } : {}) }, { project: cwd }));
392
+ try {
393
+ target.instance?.notice(notice);
394
+ }
395
+ catch {
396
+ /* a chat let go as it was put: the host still hears it */
397
+ }
398
+ try {
399
+ host.onNotice?.(notice);
400
+ }
401
+ catch {
402
+ /* a host that throws must not lose the notice */
403
+ }
404
+ changed();
405
+ return notice;
406
+ },
407
+ notices: () => open.filter((notice) => notice.state === "open"),
408
+ concluded(conclusion) {
409
+ const job = jobs.get(conclusion.job);
410
+ const target = personFacing(chats.find((chat) => chat.id === job?.chat));
411
+ if (target === undefined)
412
+ return undefined;
413
+ const module = conclusion.module ?? job?.module;
414
+ const pulse = conclusion.pulse ?? job?.pulse;
415
+ const told = { ...conclusion, ...(module !== undefined ? { module } : {}), ...(pulse !== undefined ? { pulse } : {}) };
416
+ if (target.id !== shownId)
417
+ target.unread = true;
418
+ inChat(target.scope, () => record({ kind: "sentinel.concluded", verdict: told.verdict, ...(module !== undefined ? { module } : {}) }, { project: cwd, job: told.job }));
419
+ try {
420
+ target.instance?.concluded(told);
421
+ }
422
+ catch {
423
+ /* a chat let go as it was told: the host still hears it */
424
+ }
425
+ try {
426
+ host.onConcluded?.({ ...told, chat: target.id });
427
+ }
428
+ catch {
429
+ /* a host that throws must not lose it */
430
+ }
431
+ changed();
432
+ return target.id;
433
+ },
434
+ answer(id, answer) {
435
+ const notice = open.find((candidate) => candidate.id === id);
436
+ if (notice === undefined)
437
+ return "missing";
438
+ if (notice.state !== "open")
439
+ return "over";
440
+ const task = noticeTask(notice, answer);
441
+ over(notice, "dismiss" in answer ? "dismissed" : "steer" in answer ? "steered" : "accepted");
442
+ if (task === undefined)
443
+ return "dismissed";
444
+ const chat = chats.find((candidate) => candidate.id === notice.chat) ?? personFacing();
445
+ chat?.instance?.act(task);
446
+ return "done";
447
+ },
335
448
  instanceInScope() {
336
449
  const scoped = currentChat();
337
450
  return (chats.find((chat) => chat.id === scoped?.id) ?? core.shownChat() ?? chats[0])?.instance;
@@ -341,15 +454,16 @@ export function createChats(options) {
341
454
  },
342
455
  async restore(saved, asked = {}) {
343
456
  adoptRun(cwd, saved.run);
344
- const [first, ...rest] = saved.chats;
457
+ // The first of the person's chats is the one pi reopened itself (pi-launch.ts): the sentinel is never it.
458
+ const first = saved.chats.find((kept) => kept.role === undefined);
345
459
  if (first !== undefined && asked.skipFirst === true) {
346
460
  const chat = chats[0];
347
461
  if (chat !== undefined)
348
462
  chat.recordedTitle ??= first.title;
349
463
  }
350
- for (const kept of asked.skipFirst === true ? rest : saved.chats) {
464
+ for (const kept of asked.skipFirst === true ? saved.chats.filter((other) => other !== first) : saved.chats) {
351
465
  try {
352
- await core.open({ sessionFile: kept.sessionFile, hidden: true, title: kept.title });
466
+ await core.open({ sessionFile: kept.sessionFile, hidden: true, title: kept.title, ...(kept.role !== undefined ? { role: kept.role } : {}) });
353
467
  }
354
468
  catch (error) {
355
469
  core.notify(`The chat "${kept.title}" could not be reopened: ${error instanceof Error ? error.message : String(error)}`, "warning");
@@ -358,6 +472,79 @@ export function createChats(options) {
358
472
  persist();
359
473
  },
360
474
  };
475
+ /** A notice ended - answered, dismissed, superseded: its dialog closed, and the host told. */
476
+ function over(notice, state) {
477
+ notice.state = state;
478
+ const chat = chats.find((candidate) => candidate.id === notice.chat);
479
+ try {
480
+ chat?.instance?.noticeOver(notice);
481
+ }
482
+ catch {
483
+ /* a chat let go has no dialog to close */
484
+ }
485
+ try {
486
+ host.onNoticeOver?.(notice);
487
+ }
488
+ catch {
489
+ /* a host that throws must not keep the notice open */
490
+ }
491
+ if (chat !== undefined)
492
+ inChat(chat.scope, () => record({ kind: "notice.over", notice: notice.id, state }, { project: cwd }));
493
+ }
494
+ /**
495
+ * A pulse ended, with the sentinel on: the chat the person looks at says so - its agent told of one that landed, to
496
+ * tell the person it landed and what the sentinel does with it, and nothing more; of one that went wrong, not, the
497
+ * sentinel's notice being what it hears of it - and the sentinel is told, opened in the background when it is not open. A newer pulse of a module replaces a check of the
498
+ * module's last pulse that went well; one that went wrong is looked into to its end.
499
+ */
500
+ function sentinelFinished(job) {
501
+ const asked = chats.find((chat) => chat.id === job.chat && chat.role !== SENTINEL);
502
+ const where = personFacing(asked);
503
+ if (where !== undefined) {
504
+ if (where.id !== shownId)
505
+ where.unread = true;
506
+ try {
507
+ where.instance?.landed(job, { tell: !wentWrong(job) });
508
+ }
509
+ catch {
510
+ /* a chat let go as the pulse ended: the sentinel still hears of it */
511
+ }
512
+ }
513
+ changed();
514
+ void (async () => {
515
+ let sentinel = sentinelChat();
516
+ if (sentinel === undefined) {
517
+ try {
518
+ await core.open({ hidden: true, role: SENTINEL });
519
+ sentinel = sentinelChat();
520
+ }
521
+ catch (error) {
522
+ // No room for it, or nowhere to open it: the pulse is told as it is without one.
523
+ core.notify(`The sentinel could not be opened: ${error instanceof Error ? error.message : String(error)}`, "warning");
524
+ (asked ?? where)?.instance?.announce(job);
525
+ return;
526
+ }
527
+ }
528
+ if (sentinel?.instance === undefined)
529
+ return;
530
+ const busy = sentinel.session?.isStreaming === true;
531
+ const replace = busy && sentinelOnJob !== undefined && !sentinelOnJob.wrong && sentinelOnJob.module !== undefined && sentinelOnJob.module === job.module;
532
+ sentinelOnJob = { module: job.module, wrong: wentWrong(job) };
533
+ if (sentinel.id !== shownId)
534
+ sentinel.unread = true;
535
+ try {
536
+ await sentinel.instance.brief(job, {
537
+ ...(asked !== undefined ? { asked: titleFor(asked) } : {}),
538
+ ...(where !== undefined ? { focused: titleFor(where) } : {}),
539
+ replace,
540
+ });
541
+ }
542
+ catch (error) {
543
+ core.notify(`The sentinel could not be told of ${job.id}: ${error instanceof Error ? error.message : String(error)}`, "warning");
544
+ }
545
+ changed();
546
+ })();
547
+ }
361
548
  return core;
362
549
  }
363
550
  /**
@@ -20,6 +20,7 @@ const SECONDS = ["0", "60", "120", "180", "300"];
20
20
  const ROLE_ABOUT = {
21
21
  default: "every role not named",
22
22
  chat: "the conversation - as harness-model, which it wins over",
23
+ sentinel: "the sentinel: checks every finished pulse and tests the release",
23
24
  chooser: "chooses what gets conformance tests under turri regenerate",
24
25
  planner: "cuts the work into pulses",
25
26
  diagnosis: "looks into a pulse that failed for good",
@@ -65,6 +66,7 @@ export function settings(rules = []) {
65
66
  { key: "mutation-testing", about: "whether the coding agent and the reviewer can put a bug in the code for one test run (can_it_fail)", kind: "boolean", unset: "true", effect: "next-pulse" },
66
67
  { key: "allow-to-stop-jobs", about: "whether turri stops a running pulse the ***plain has moved on from", kind: "boolean", unset: "true", effect: "now" },
67
68
  { key: "release-after-pulse", about: "whether every pulse that lands code releases it", kind: "boolean", unset: "false", effect: "next-landing" },
69
+ { key: "sentinel", about: "whether a chat of its own checks every finished pulse, tests the release, and tells you what is wrong", kind: "boolean", unset: "true", effect: "next-landing" },
68
70
  { key: "unittests-script", about: "the unit testing script, by path from the project root", kind: "path", unset: "the one run_unittests*.sh found", effect: "next-test-run" },
69
71
  { key: "conformance-tests-script", about: "the conformance testing script, by path from the project root", kind: "path", unset: "the one run_conformance_tests*.sh found", effect: "next-test-run" },
70
72
  { key: "prepare-environment-script", about: "run on the build before every conformance testing script run, by path from the project root - optional", kind: "path", unset: "the one prepare_environment*.sh found, else none", effect: "next-test-run" },
package/dist/config.d.ts CHANGED
@@ -19,6 +19,7 @@
19
19
  * mutation-testing: false # give the coding agent and the reviewer can_it_fail (default: true)
20
20
  * allow-to-stop-jobs: false # stop a running job the spec moved on from (default: true)
21
21
  * release-after-pulse: true # release what each pulse lands, as its last stage, before the next pulse (default: false)
22
+ * sentinel: false # a chat of its own every finished pulse goes to, which tests the release and tells you what is wrong (default: true)
22
23
  * rules-off: [integrations] # ***plain rules to leave out of the prompts, by name (default: none)
23
24
  * unittests-script: test_scripts/run_unittests.sh # the testing scripts - see test-scripts.ts
24
25
  * conformance-tests-script: test_scripts/run_conformance_tests.sh
@@ -127,7 +128,7 @@ export interface ModelChoice {
127
128
  * The code fixer of a conformance run and the repair agent are coding agents; the unit-test and conformance arbiters
128
129
  * are both `arbiter`.
129
130
  */
130
- export declare const AGENT_ROLES: readonly ["chat", "chooser", "planner", "diagnosis", "release", "coding", "conformance", "explorer", "reviewer", "test-reviewer", "judge", "arbiter", "debugger"];
131
+ export declare const AGENT_ROLES: readonly ["chat", "sentinel", "chooser", "planner", "diagnosis", "release", "coding", "conformance", "explorer", "reviewer", "test-reviewer", "judge", "arbiter", "debugger"];
131
132
  export type AgentRole = (typeof AGENT_ROLES)[number];
132
133
  /**
133
134
  * How hard each role thinks when nothing names a level - not the config's `models:`, nor a `:level` on the model it is
@@ -225,6 +226,11 @@ export interface ProjectConfig {
225
226
  * looking for the project's release guidance as the chat does, and doing what it says. Unset: false.
226
227
  */
227
228
  releaseAfterPulse?: boolean;
229
+ /**
230
+ * Whether the session has a sentinel: a chat of its own that every finished pulse goes to - checked, a release tested,
231
+ * and what is wrong put to the person - instead of the chat that asked for it (docs/sentinel.md). Unset: true.
232
+ */
233
+ sentinel?: boolean;
228
234
  /** Path from the root to the unit testing script. */
229
235
  unittestsScript?: string;
230
236
  /** Path from the root to the conformance testing script. */
@@ -300,6 +306,7 @@ export declare const CONFIG_KEYS: {
300
306
  readonly mutationTesting: "mutation-testing";
301
307
  readonly allowToStopJobs: "allow-to-stop-jobs";
302
308
  readonly releaseAfterPulse: "release-after-pulse";
309
+ readonly sentinel: "sentinel";
303
310
  readonly unittestsScript: "unittests-script";
304
311
  readonly conformanceTestsScript: "conformance-tests-script";
305
312
  readonly prepareEnvironmentScript: "prepare-environment-script";
package/dist/config.js CHANGED
@@ -25,6 +25,7 @@ import { projectDirOf } from "./regenerate/paths.js";
25
25
  * mutation-testing: false # give the coding agent and the reviewer can_it_fail (default: true)
26
26
  * allow-to-stop-jobs: false # stop a running job the spec moved on from (default: true)
27
27
  * release-after-pulse: true # release what each pulse lands, as its last stage, before the next pulse (default: false)
28
+ * sentinel: false # a chat of its own every finished pulse goes to, which tests the release and tells you what is wrong (default: true)
28
29
  * rules-off: [integrations] # ***plain rules to leave out of the prompts, by name (default: none)
29
30
  * unittests-script: test_scripts/run_unittests.sh # the testing scripts - see test-scripts.ts
30
31
  * conformance-tests-script: test_scripts/run_conformance_tests.sh
@@ -215,7 +216,7 @@ export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhig
215
216
  * The code fixer of a conformance run and the repair agent are coding agents; the unit-test and conformance arbiters
216
217
  * are both `arbiter`.
217
218
  */
218
- export const AGENT_ROLES = ["chat", "chooser", "planner", "diagnosis", "release", "coding", "conformance", "explorer", "reviewer", "test-reviewer", "judge", "arbiter", "debugger"];
219
+ export const AGENT_ROLES = ["chat", "sentinel", "chooser", "planner", "diagnosis", "release", "coding", "conformance", "explorer", "reviewer", "test-reviewer", "judge", "arbiter", "debugger"];
219
220
  /**
220
221
  * How hard each role thinks when nothing names a level - not the config's `models:`, nor a `:level` on the model it is
221
222
  * given. The agents that do the work - write the code and the tests, explore, release - think little: they work from a
@@ -239,7 +240,7 @@ export const ROLE_THINKING = {
239
240
  /** The roles on the conversation's side: without their own entry they take the chat's model, not `default`. */
240
241
  // The release is not: it follows steps - a script, a start command - and runs on the coding model, thinking low. Nor
241
242
  // is the diagnosis: a post-mortem of a job that failed, read by the person - it runs on the coding model, thinking medium.
242
- const CHAT_SIDE = ["chat", "chooser", "planner"];
243
+ const CHAT_SIDE = ["chat", "sentinel", "chooser", "planner"];
243
244
  function isThinkingLevel(value) {
244
245
  return typeof value === "string" && THINKING_LEVELS.includes(value);
245
246
  }
@@ -382,6 +383,7 @@ export const CONFIG_KEYS = {
382
383
  mutationTesting: "mutation-testing",
383
384
  allowToStopJobs: "allow-to-stop-jobs",
384
385
  releaseAfterPulse: "release-after-pulse",
386
+ sentinel: "sentinel",
385
387
  unittestsScript: "unittests-script",
386
388
  conformanceTestsScript: "conformance-tests-script",
387
389
  prepareEnvironmentScript: "prepare-environment-script",
@@ -559,6 +561,7 @@ function readConfigFile(path) {
559
561
  mutationTesting: typeof raw[CONFIG_KEYS.mutationTesting] === "boolean" ? raw[CONFIG_KEYS.mutationTesting] : undefined,
560
562
  allowToStopJobs: typeof raw[CONFIG_KEYS.allowToStopJobs] === "boolean" ? raw[CONFIG_KEYS.allowToStopJobs] : undefined,
561
563
  releaseAfterPulse: typeof raw[CONFIG_KEYS.releaseAfterPulse] === "boolean" ? raw[CONFIG_KEYS.releaseAfterPulse] : undefined,
564
+ sentinel: typeof raw[CONFIG_KEYS.sentinel] === "boolean" ? raw[CONFIG_KEYS.sentinel] : undefined,
562
565
  unittestsScript: text(raw[CONFIG_KEYS.unittestsScript]),
563
566
  conformanceTestsScript: text(raw[CONFIG_KEYS.conformanceTestsScript]),
564
567
  prepareEnvironmentScript: text(raw[CONFIG_KEYS.prepareEnvironmentScript]),