tau-all-agent 0.1.6 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +17 -14
  2. package/extensions/answer.ts +223 -146
  3. package/extensions/branch-term/README.md +7 -0
  4. package/extensions/{branch-term.ts → branch-term/index.ts} +121 -108
  5. package/extensions/btw.ts +79 -33
  6. package/extensions/caffeinate/README.md +5 -0
  7. package/extensions/caffeinate/index.ts +144 -0
  8. package/extensions/fast.ts +292 -0
  9. package/extensions/ghostty.ts +214 -211
  10. package/extensions/git-diff-stats.ts +124 -84
  11. package/extensions/git-pr-status.ts +274 -208
  12. package/extensions/insights.ts +166 -237
  13. package/extensions/loop.ts +126 -160
  14. package/extensions/memory.ts +202 -118
  15. package/extensions/notify.ts +17 -27
  16. package/extensions/openai-verbosity.ts +108 -42
  17. package/extensions/review/fix.ts +25 -22
  18. package/extensions/review/git.ts +99 -103
  19. package/extensions/review/index.ts +62 -64
  20. package/extensions/review/interrupt.ts +117 -0
  21. package/extensions/review/message-queue.ts +45 -12
  22. package/extensions/review/models.ts +90 -4
  23. package/extensions/review/prompts.ts +41 -38
  24. package/extensions/review/review.ts +164 -171
  25. package/extensions/review/runner.ts +139 -147
  26. package/extensions/review/runtime.ts +182 -128
  27. package/extensions/review/triage.ts +43 -48
  28. package/extensions/sandbox/README.md +5 -0
  29. package/extensions/sandbox/bash.ts +773 -0
  30. package/extensions/sandbox/command.ts +605 -0
  31. package/extensions/sandbox/config.ts +764 -0
  32. package/extensions/sandbox/index.ts +145 -3341
  33. package/extensions/sandbox/permissions/dialog.ts +118 -0
  34. package/extensions/sandbox/permissions/filesystem.ts +559 -0
  35. package/extensions/sandbox/permissions/mach-lookup.ts +186 -0
  36. package/extensions/sandbox/permissions/network.ts +169 -0
  37. package/extensions/sandbox/permissions/unsandboxed.ts +31 -0
  38. package/extensions/sandbox/runtime.ts +650 -0
  39. package/extensions/stash.ts +28 -15
  40. package/extensions/subagent/README.md +130 -0
  41. package/extensions/subagent/config.ts +118 -0
  42. package/extensions/subagent/index.ts +854 -0
  43. package/extensions/subagent/interrupt.ts +117 -0
  44. package/extensions/subagent/permissions.ts +101 -0
  45. package/extensions/subagent/rpc.ts +177 -0
  46. package/extensions/telegram/README.md +13 -1
  47. package/extensions/telegram/assistant-result.d.mts +4 -1
  48. package/extensions/telegram/assistant-result.mjs +31 -16
  49. package/extensions/telegram/daemon.mjs +351 -255
  50. package/extensions/telegram/index.ts +47 -55
  51. package/extensions/tool-display-mode.ts +288 -106
  52. package/extensions/usage/README.md +5 -0
  53. package/extensions/usage/index.ts +384 -432
  54. package/extensions/usage/openrouter.ts +10 -2
  55. package/extensions/websearch/README.md +13 -41
  56. package/extensions/websearch/config.ts +6 -2
  57. package/extensions/websearch/index.ts +111 -117
  58. package/extensions/websearch/output.ts +51 -0
  59. package/extensions/websearch/providers/anthropic.pi.ts +27 -44
  60. package/extensions/websearch/providers/gemini.browser.ts +68 -77
  61. package/extensions/websearch/providers/gemini.pi.ts +17 -17
  62. package/extensions/websearch/providers/openai.pi.ts +222 -0
  63. package/extensions/websearch/providers/pi-model.shared.ts +33 -39
  64. package/extensions/websearch/providers/shared.ts +94 -2
  65. package/extensions/websearch/types.ts +2 -4
  66. package/extensions/worktree.ts +122 -218
  67. package/package.json +10 -5
  68. package/skills/browser-tools/SKILL.md +29 -234
  69. package/skills/browser-tools/references/cookies.md +36 -0
  70. package/skills/browser-tools/references/interaction.md +90 -0
  71. package/skills/browser-tools/references/logging.md +34 -0
  72. package/skills/git-clean-history/SKILL.md +6 -6
  73. package/skills/git-commit/SKILL.md +5 -3
  74. package/skills/github-pull-request/SKILL.md +60 -0
  75. package/skills/github-pull-request/references/create.md +40 -0
  76. package/skills/github-pull-request/references/stewardship.md +60 -0
  77. package/skills/homeassistant-ops/SKILL.md +30 -161
  78. package/skills/homeassistant-ops/references/api.md +4 -3
  79. package/skills/homeassistant-ops/references/commands.md +137 -0
  80. package/skills/homeassistant-ops/references/id_conventions.md +10 -10
  81. package/skills/homeassistant-ops/references/playbook.md +22 -15
  82. package/skills/openscad/SKILL.md +25 -133
  83. package/skills/openscad/references/authoring.md +38 -0
  84. package/skills/openscad/references/parameters.md +36 -0
  85. package/skills/openscad/references/publishing.md +12 -0
  86. package/skills/openscad/references/rendering.md +40 -0
  87. package/skills/oracle/SKILL.md +15 -9
  88. package/skills/oracle/scripts/oracle +51 -18
  89. package/skills/sentry/SKILL.md +15 -185
  90. package/skills/sentry/references/events.md +79 -0
  91. package/skills/sentry/references/issues.md +62 -0
  92. package/skills/sentry/references/logs.md +46 -0
  93. package/skills/update-changelog/SKILL.md +30 -120
  94. package/skills/web-design/SKILL.md +16 -105
  95. package/themes/tau-dark.json +4 -0
  96. package/extensions/openai-fast.ts +0 -229
  97. package/extensions/websearch/providers/openai-codex.browser.ts +0 -77
  98. package/extensions/websearch/providers/openai-codex.pi.ts +0 -49
  99. package/extensions/websearch/providers/openai-codex.shared.ts +0 -123
@@ -1,14 +1,7 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
- import { randomUUID } from "node:crypto";
3
2
  import * as fs from "node:fs";
4
- import * as path from "node:path";
5
3
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
6
- import {
7
- CURRENT_SESSION_VERSION,
8
- SessionManager,
9
- highlightCode,
10
- type SessionHeader,
11
- } from "@earendil-works/pi-coding-agent";
4
+ import { SessionManager, highlightCode, type SessionHeader } from "@earendil-works/pi-coding-agent";
12
5
  import { Box, Text } from "@earendil-works/pi-tui";
13
6
 
14
7
  const TERMINAL_FLAG = "branch-term";
@@ -46,7 +39,7 @@ const TMUX_LAYOUT_CONFIG: Record<
46
39
  };
47
40
 
48
41
  function getStringFlag(pi: ExtensionAPI, flagName: string): string | undefined {
49
- const value = pi.getFlag(`--${flagName}`);
42
+ const value = pi.getFlag(flagName);
50
43
  if (typeof value !== "string") return undefined;
51
44
  const trimmed = value.trim();
52
45
  return trimmed.length > 0 ? trimmed : undefined;
@@ -55,7 +48,7 @@ function getStringFlag(pi: ExtensionAPI, flagName: string): string | undefined {
55
48
  function parseTmuxLayout(value: string | undefined): TmuxLayout | undefined {
56
49
  if (!value) return undefined;
57
50
  const normalized = value.trim().toLowerCase();
58
- if (normalized in TMUX_LAYOUT_CONFIG) {
51
+ if (Object.hasOwn(TMUX_LAYOUT_CONFIG, normalized)) {
59
52
  return normalized as TmuxLayout;
60
53
  }
61
54
  return undefined;
@@ -103,73 +96,23 @@ function getBranchArgumentCompletions(
103
96
  return matches;
104
97
  }
105
98
 
106
- function renderTerminalCommand(template: string, cwd: string, sessionFile: string): string {
107
- let command = template;
108
- command = command.split("{cwd}").join(cwd);
109
-
110
- if (command.includes("{command}")) {
111
- const piCommand = `pi --session ${shellQuote(sessionFile)}`;
112
- command = command.split("{command}").join(piCommand);
113
- }
114
-
115
- if (command.includes("{session}")) {
116
- command = command.split("{session}").join(sessionFile);
117
- }
118
-
119
- if (template.includes("{command}") || template.includes("{session}")) {
120
- return command;
121
- }
122
-
123
- return `${command} ${sessionFile}`;
99
+ function renderTerminalCommand(template: string): string {
100
+ // Paths stay data even when they contain shell syntax or placeholder-like text.
101
+ const command = template.replace(
102
+ /\{(cwd|session|command)\}/g,
103
+ (_match, name: string) => `"$TAU_BRANCH_${name.toUpperCase()}"`,
104
+ );
105
+ return /\{(command|session)\}/.test(template) ? command : `${command} "$TAU_BRANCH_SESSION"`;
124
106
  }
125
107
 
126
- function spawnDetached(command: string, args: string[], onError?: (error: Error) => void): void {
127
- const child = spawn(command, args, { detached: true, stdio: "ignore" });
128
- child.unref();
129
- if (onError) child.on("error", onError);
130
- }
108
+ // Shared outside the factory so detached children cannot retain a session through this listener.
109
+ function ignoreDetachedLauncherError(): void {}
131
110
 
132
111
  function shellQuote(value: string): string {
133
112
  if (value.length === 0) return "''";
134
113
  return `'${value.replace(/'/g, "'\\''")}'`;
135
114
  }
136
115
 
137
- function escapeForDoubleQuotes(value: string): string {
138
- return value.replace(/(["\\$`!])/g, "\\$1");
139
- }
140
-
141
- function shellQuoteCompact(value: string): string {
142
- const home = process.env.HOME;
143
- if (!home) return shellQuote(value);
144
-
145
- if (value === home) return "$HOME";
146
-
147
- if (value.startsWith(`${home}/`)) {
148
- const suffix = value.slice(home.length + 1);
149
- return `"$HOME/${escapeForDoubleQuotes(suffix)}"`;
150
- }
151
-
152
- return shellQuote(value);
153
- }
154
-
155
- function parseSessionIdFromFile(sessionFile: string): string | undefined {
156
- const fileName = path.basename(sessionFile, ".jsonl");
157
- const separatorIndex = fileName.lastIndexOf("_");
158
- if (separatorIndex <= 0) return undefined;
159
-
160
- const candidate = fileName.slice(separatorIndex + 1);
161
- return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(candidate)
162
- ? candidate
163
- : undefined;
164
- }
165
-
166
- function formatResumeSessionArgument(sessionFile: string): string {
167
- const sessionId = parseSessionIdFromFile(sessionFile);
168
- if (sessionId) return sessionId;
169
-
170
- return shellQuoteCompact(sessionFile);
171
- }
172
-
173
116
  function runClipboardCommand(command: string, args: string[], text: string): boolean {
174
117
  try {
175
118
  const result = spawnSync(command, args, {
@@ -214,7 +157,7 @@ function showCommandMessage(
214
157
  command: string,
215
158
  ): void {
216
159
  const copiedToClipboard = copyToClipboard(command);
217
- if (!ctx.hasUI) {
160
+ if (ctx.mode === "print") {
218
161
  console.log(formatCommandMessageIntro(intro, copiedToClipboard));
219
162
  console.log(command);
220
163
  return;
@@ -241,26 +184,32 @@ function hasValidSessionFile(sessionFile: string): boolean {
241
184
  }
242
185
  }
243
186
 
244
- function createFreshSessionFile(cwd: string, sessionDir: string): string {
245
- fs.mkdirSync(sessionDir, { recursive: true });
246
-
247
- const sessionId = randomUUID();
248
- const timestamp = new Date().toISOString();
249
- const fileTimestamp = timestamp.replace(/[:.]/g, "-");
250
- const sessionFile = path.join(sessionDir, `${fileTimestamp}_${sessionId}.jsonl`);
251
- const header: SessionHeader = {
252
- type: "session",
253
- version: CURRENT_SESSION_VERSION,
254
- id: sessionId,
255
- timestamp,
256
- cwd,
257
- };
258
-
259
- fs.writeFileSync(sessionFile, `${JSON.stringify(header)}\n`);
187
+ /** Write a standalone handoff snapshot when Pi defers persistence; discard the manager afterward. */
188
+ function persistFork(manager: SessionManager): string {
189
+ const sessionFile = manager.getSessionFile();
190
+ const header = manager.getHeader();
191
+ if (!sessionFile || !header) throw new Error("Failed to create branched session");
192
+ if (!fs.existsSync(sessionFile)) {
193
+ const contents =
194
+ [header, ...manager.getEntries()].map((entry) => JSON.stringify(entry)).join("\n") + "\n";
195
+ const fd = fs.openSync(sessionFile, "wx");
196
+ try {
197
+ fs.writeFileSync(fd, contents);
198
+ } catch (error) {
199
+ fs.rmSync(sessionFile, { force: true });
200
+ throw error;
201
+ } finally {
202
+ fs.closeSync(fd);
203
+ }
204
+ }
260
205
  return sessionFile;
261
206
  }
262
207
 
263
208
  export default function (pi: ExtensionAPI) {
209
+ const launchObservers = new Set<() => void>();
210
+ pi.on("session_start", releaseLaunchObservers);
211
+ pi.on("session_shutdown", releaseLaunchObservers);
212
+
264
213
  pi.registerEntryRenderer<CommandMessageDetails>(BRANCH_ENTRY_TYPE, (entry, _options, theme) => {
265
214
  const details = entry.data;
266
215
  if (!details || typeof details.intro !== "string" || typeof details.command !== "string") {
@@ -287,7 +236,7 @@ export default function (pi: ExtensionAPI) {
287
236
 
288
237
  pi.registerFlag(TERMINAL_FLAG, {
289
238
  description:
290
- "Command to open a new terminal. Use {cwd} for working directory and optional {command} for the pi command.",
239
+ "Bash launcher template. Unquoted {cwd}, {session}, and {command} become single arguments; {command} is a command string for e.g. bash -c.",
291
240
  type: "string",
292
241
  });
293
242
 
@@ -320,44 +269,48 @@ export default function (pi: ExtensionAPI) {
320
269
  }
321
270
 
322
271
  const leafId = ctx.sessionManager.getLeafId();
323
- const hasAssistantReply = ctx.sessionManager
272
+ const hasPersistedMessage = ctx.sessionManager
324
273
  .getEntries()
325
- .some((entry) => entry.type === "message" && entry.message.role === "assistant");
274
+ .some(
275
+ (entry) =>
276
+ entry.type === "message" &&
277
+ (entry.message.role === "user" || entry.message.role === "assistant"),
278
+ );
326
279
 
327
280
  let forkFile: string;
328
281
  if (leafId && hasValidSessionFile(sessionFile)) {
329
- const forkManager = SessionManager.open(sessionFile);
330
- const branchedSessionFile = forkManager.createBranchedSession(leafId);
331
- if (!branchedSessionFile) {
282
+ const forkManager = SessionManager.open(
283
+ sessionFile,
284
+ ctx.sessionManager.getSessionDir(),
285
+ ctx.cwd,
286
+ );
287
+ if (!forkManager.createBranchedSession(leafId)) {
332
288
  throw new Error("Failed to create branched session");
333
289
  }
334
- forkFile = branchedSessionFile;
290
+ forkFile = persistFork(forkManager);
335
291
  } else {
336
- if (hasAssistantReply) {
292
+ if (leafId && hasPersistedMessage) {
337
293
  throw new Error(`Current session file is missing or invalid: ${sessionFile}`);
338
294
  }
339
295
 
340
- const message = "Current session has no persisted history yet. Opening a fresh session.";
296
+ const message = leafId
297
+ ? "Current session has no persisted history yet. Opening a fresh session."
298
+ : "Selected conversation is empty. Opening a fresh session.";
341
299
  if (ctx.hasUI) ctx.ui.notify(message, "warning");
342
- else console.log(message);
300
+ else console.error(message);
343
301
 
344
- forkFile = createFreshSessionFile(ctx.cwd, ctx.sessionManager.getSessionDir());
302
+ forkFile = persistFork(
303
+ SessionManager.create(ctx.cwd, ctx.sessionManager.getSessionDir(), {
304
+ parentSession: sessionFile,
305
+ }),
306
+ );
345
307
  }
346
308
 
347
- const resumeCommand = `cd ${shellQuoteCompact(ctx.cwd)} && pi --session ${formatResumeSessionArgument(forkFile)}`;
309
+ const resumeCommand = `cd ${shellQuote(ctx.cwd)} && pi --session ${shellQuote(forkFile)}`;
348
310
 
349
311
  const terminalFlag = getStringFlag(pi, TERMINAL_FLAG);
350
312
  if (terminalFlag) {
351
- const command = renderTerminalCommand(terminalFlag, ctx.cwd, forkFile);
352
- spawnDetached("bash", ["-lc", command], (error) => {
353
- if (ctx.hasUI) {
354
- ctx.ui.notify(`Terminal command failed: ${error.message}`, "error");
355
- } else {
356
- console.error(`Terminal command failed: ${error.message}`);
357
- }
358
- showCommandMessage(pi, ctx, MANUAL_RESUME_INTRO, resumeCommand);
359
- });
360
- if (ctx.hasUI) ctx.ui.notify("Opened fork in new terminal", "info");
313
+ launchTerminal(terminalFlag, forkFile, resumeCommand, ctx);
361
314
  return;
362
315
  }
363
316
 
@@ -397,4 +350,64 @@ export default function (pi: ExtensionAPI) {
397
350
  showCommandMessage(pi, ctx, MANUAL_RESUME_INTRO, resumeCommand);
398
351
  },
399
352
  });
353
+
354
+ function launchTerminal(
355
+ template: string,
356
+ forkFile: string,
357
+ resumeCommand: string,
358
+ ctx: ExtensionCommandContext,
359
+ ) {
360
+ const reportFailure = (message: string) => {
361
+ try {
362
+ if (ctx.hasUI) ctx.ui.notify(`Terminal command failed: ${message}`, "error");
363
+ else console.error(`Terminal command failed: ${message}`);
364
+ showCommandMessage(pi, ctx, MANUAL_RESUME_INTRO, resumeCommand);
365
+ } catch (error) {
366
+ console.error(
367
+ `Could not show terminal recovery instructions: ${error instanceof Error ? error.message : String(error)}`,
368
+ );
369
+ console.error(`${MANUAL_RESUME_INTRO}:\n${resumeCommand}`);
370
+ }
371
+ };
372
+ try {
373
+ const child = spawn("bash", ["-c", renderTerminalCommand(template)], {
374
+ cwd: ctx.cwd,
375
+ env: {
376
+ ...process.env,
377
+ TAU_BRANCH_CWD: ctx.cwd,
378
+ TAU_BRANCH_SESSION: forkFile,
379
+ TAU_BRANCH_COMMAND: `pi --session ${shellQuote(forkFile)}`,
380
+ },
381
+ detached: true,
382
+ stdio: "ignore",
383
+ });
384
+ const stopObserving = () => {
385
+ child.removeListener("error", onError);
386
+ child.removeListener("close", onClose);
387
+ launchObservers.delete(stopObserving);
388
+ };
389
+ const onError = (error: Error) => {
390
+ stopObserving();
391
+ reportFailure(error.message);
392
+ };
393
+ const onClose = (code: number | null, signal: NodeJS.Signals | null) => {
394
+ stopObserving();
395
+ if (code !== 0) reportFailure(signal ? `terminated by ${signal}` : `exit code ${code}`);
396
+ };
397
+ // A detached, user-owned launcher may still emit an error after Pi stops observing it.
398
+ child.on("error", ignoreDetachedLauncherError);
399
+ child.once("error", onError);
400
+ child.once("close", onClose);
401
+ launchObservers.add(stopObserving);
402
+ child.unref();
403
+ } catch (error) {
404
+ reportFailure(error instanceof Error ? error.message : String(error));
405
+ return;
406
+ }
407
+ if (ctx.hasUI) ctx.ui.notify("Terminal launch requested", "info");
408
+ }
409
+
410
+ function releaseLaunchObservers() {
411
+ for (const stopObserving of launchObservers) stopObserving();
412
+ }
400
413
  }
package/extensions/btw.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  matchesKey,
25
25
  truncateToWidth,
26
26
  type Component,
27
+ type KeybindingsManager,
27
28
  type TUI,
28
29
  visibleWidth,
29
30
  wrapTextWithAnsi,
@@ -31,6 +32,9 @@ import {
31
32
 
32
33
  const STATUS_KEY = "0-btw";
33
34
  const RESULT_MARKDOWN_THEME = getMarkdownTheme();
35
+ const MIN_RESULT_WIDTH = 50;
36
+ const RESULT_CHROME_ROWS = 6;
37
+ const MIN_RESULT_ROWS = RESULT_CHROME_ROWS + 3; // Two overlay margins and one reading row.
34
38
  const STATUS_SPINNER_INTERVAL_MS = 80;
35
39
  const STATUS_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
36
40
 
@@ -349,9 +353,11 @@ function seedSessionManager(sessionManager: SessionManager, messages: AgentMessa
349
353
  }
350
354
 
351
355
  async function showResultDialog(ctx: ExtensionContext, result: BtwResult): Promise<void> {
352
- await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
353
- return new BtwResultComponent(result, tui, theme, done);
354
- });
356
+ await ctx.ui.custom<void>(
357
+ (tui, theme, keybindings, done) =>
358
+ new BtwResultComponent(result, tui, theme, keybindings, done),
359
+ { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", margin: 1 } },
360
+ );
355
361
  }
356
362
 
357
363
  function getLastAssistantMessage(messages: AgentMessage[]): AssistantMessage | null {
@@ -380,6 +386,7 @@ function extractText(content: AssistantMessage["content"]): string {
380
386
  class BtwResultComponent implements Component {
381
387
  private readonly markdown: Markdown;
382
388
  private scrollOffset = 0;
389
+ private renderWidth?: number;
383
390
  private cachedBodyWidth?: number;
384
391
  private cachedBodyLines?: string[];
385
392
 
@@ -387,6 +394,7 @@ class BtwResultComponent implements Component {
387
394
  private readonly result: BtwResult,
388
395
  private readonly tui: TUI,
389
396
  private readonly theme: Theme,
397
+ private readonly keybindings: KeybindingsManager,
390
398
  private readonly onDone: () => void,
391
399
  ) {
392
400
  this.markdown = new Markdown(result.answer, 0, 0, RESULT_MARKDOWN_THEME);
@@ -409,37 +417,62 @@ class BtwResultComponent implements Component {
409
417
  return;
410
418
  }
411
419
 
420
+ const width = this.renderWidth ?? this.tui.terminal.columns;
421
+ if (width < MIN_RESULT_WIDTH || this.tui.terminal.rows < MIN_RESULT_ROWS) return;
422
+
412
423
  const bodyHeight = this.getBodyHeight();
413
- const boxWidth = this.getBoxWidth(this.tui.terminal.columns);
424
+ const boxWidth = width;
414
425
  const bodyLines = this.getBodyLines(this.getContentWidth(boxWidth));
415
426
  const maxScroll = Math.max(0, bodyLines.length - bodyHeight);
416
427
 
417
- if (matchesKey(data, Key.up) || data.toLowerCase() === "k") {
418
- this.scrollOffset = Math.max(0, this.scrollOffset - 1);
419
- } else if (matchesKey(data, Key.down) || data.toLowerCase() === "j") {
420
- this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
421
- } else if (matchesKey(data, Key.pageUp)) {
422
- this.scrollOffset = Math.max(0, this.scrollOffset - Math.max(4, bodyHeight - 2));
423
- } else if (matchesKey(data, Key.pageDown)) {
424
- this.scrollOffset = Math.min(maxScroll, this.scrollOffset + Math.max(4, bodyHeight - 2));
425
- } else if (matchesKey(data, Key.home)) {
428
+ this.scrollOffset = clamp(this.scrollOffset, 0, maxScroll);
429
+ const kb = this.keybindings;
430
+ if (kb.matches(data, "tui.altScreen.pageUp")) {
431
+ this.scrollOffset -= bodyHeight;
432
+ } else if (kb.matches(data, "tui.altScreen.pageDown")) {
433
+ this.scrollOffset += bodyHeight;
434
+ } else if (kb.matches(data, "tui.altScreen.halfPageUp")) {
435
+ this.scrollOffset -= Math.max(1, Math.floor(bodyHeight / 2));
436
+ } else if (kb.matches(data, "tui.altScreen.halfPageDown")) {
437
+ this.scrollOffset += Math.max(1, Math.floor(bodyHeight / 2));
438
+ } else if (kb.matches(data, "tui.altScreen.top")) {
426
439
  this.scrollOffset = 0;
427
- } else if (matchesKey(data, Key.end)) {
440
+ } else if (kb.matches(data, "tui.altScreen.bottom")) {
428
441
  this.scrollOffset = maxScroll;
442
+ } else if (kb.matches(data, "tui.altScreen.lineUp")) {
443
+ this.scrollOffset -= 1;
444
+ } else if (kb.matches(data, "tui.altScreen.lineDown")) {
445
+ this.scrollOffset += 1;
446
+ } else if (matchesKey(data, Key.up) || data.toLowerCase() === "k") {
447
+ this.scrollOffset -= 1;
448
+ } else if (matchesKey(data, Key.down) || data.toLowerCase() === "j") {
449
+ this.scrollOffset += 1;
429
450
  } else {
430
451
  return;
431
452
  }
432
453
 
454
+ this.scrollOffset = clamp(this.scrollOffset, 0, maxScroll);
433
455
  this.tui.requestRender();
434
456
  }
435
457
 
436
458
  render(width: number): string[] {
437
- const boxWidth = this.getBoxWidth(width);
459
+ this.renderWidth = width;
460
+ if (width < 1 || this.tui.terminal.rows <= 2) return [];
461
+ if (width < MIN_RESULT_WIDTH || this.tui.terminal.rows < MIN_RESULT_ROWS) {
462
+ return [
463
+ truncateToWidth(
464
+ this.theme.fg(
465
+ "muted",
466
+ `Esc close · Resize to ${MIN_RESULT_WIDTH + 2}+ columns, ${MIN_RESULT_ROWS}+ rows.`,
467
+ ),
468
+ width,
469
+ "",
470
+ ),
471
+ ];
472
+ }
473
+
474
+ const boxWidth = width;
438
475
  const contentWidth = this.getContentWidth(boxWidth);
439
- const questionLines = wrapTextWithAnsi(
440
- this.theme.fg("muted", this.result.question),
441
- contentWidth,
442
- );
443
476
  const bodyLines = this.getBodyLines(contentWidth);
444
477
  const bodyHeight = this.getBodyHeight();
445
478
  const maxScroll = Math.max(0, bodyLines.length - bodyHeight);
@@ -447,11 +480,7 @@ class BtwResultComponent implements Component {
447
480
 
448
481
  const lines: string[] = [];
449
482
  lines.push(this.borderLine("╭", "╮", boxWidth));
450
- lines.push(this.boxLine(this.theme.bold("Request"), boxWidth));
451
- for (const line of questionLines) {
452
- lines.push(this.boxLine(line, boxWidth));
453
- }
454
- lines.push(this.separatorLine(boxWidth));
483
+ lines.push(this.boxLine(this.theme.bold("BTW"), boxWidth));
455
484
 
456
485
  const visibleBody = bodyLines.slice(this.scrollOffset, this.scrollOffset + bodyHeight);
457
486
  for (const line of visibleBody) {
@@ -463,15 +492,30 @@ class BtwResultComponent implements Component {
463
492
 
464
493
  lines.push(this.separatorLine(boxWidth));
465
494
  const scrollText = `${Math.min(bodyLines.length, this.scrollOffset + 1)}-${Math.min(bodyLines.length, this.scrollOffset + visibleBody.length)}/${bodyLines.length}`;
466
- const controls = `${this.theme.fg("dim", "↑↓ scroll · PgUp/PgDn jump · Home/End · Enter/Esc close")} ${this.theme.fg("muted", scrollText)}`;
467
- lines.push(this.boxLine(truncateToWidth(controls, contentWidth), boxWidth));
495
+ const navigation = [
496
+ ["pageUp", "pageDown", "page"],
497
+ ["top", "bottom", "ends"],
498
+ ] as const;
499
+ const hints = [
500
+ "↑↓/j/k scroll",
501
+ ...navigation.flatMap(([up, down, label]) => {
502
+ const keys = [up, down].map((action) =>
503
+ this.keybindings
504
+ .getKeys(`tui.altScreen.${action}`)
505
+ .find((key) => !["enter", "escape", "ctrl+c", "q"].includes(key)),
506
+ );
507
+ return keys.some(Boolean) ? [`${keys.map((key) => key ?? "—").join("/")} ${label}`] : [];
508
+ }),
509
+ ];
510
+ lines.push(this.boxLine(this.theme.fg("dim", hints.join(" · ")), boxWidth));
511
+ lines.push(this.boxLine(this.theme.fg("dim", `Enter/Esc close · ${scrollText}`), boxWidth));
468
512
  lines.push(this.borderLine("╰", "╯", boxWidth));
469
513
 
470
514
  return lines;
471
515
  }
472
516
 
473
517
  private getBodyHeight(): number {
474
- return Math.max(8, this.tui.terminal.rows - 14);
518
+ return this.tui.terminal.rows - 2 - RESULT_CHROME_ROWS;
475
519
  }
476
520
 
477
521
  private getBodyLines(contentWidth: number): string[] {
@@ -479,18 +523,20 @@ class BtwResultComponent implements Component {
479
523
  return this.cachedBodyLines;
480
524
  }
481
525
 
482
- const lines = this.markdown.render(contentWidth);
526
+ const lines = [
527
+ this.theme.bold("Request"),
528
+ ...wrapTextWithAnsi(this.theme.fg("muted", this.result.question), contentWidth),
529
+ "",
530
+ this.theme.bold("Answer"),
531
+ ...this.markdown.render(contentWidth),
532
+ ];
483
533
  this.cachedBodyWidth = contentWidth;
484
534
  this.cachedBodyLines = lines;
485
535
  return lines;
486
536
  }
487
537
 
488
- private getBoxWidth(width: number): number {
489
- return Math.max(50, Math.min(width - 2, 140));
490
- }
491
-
492
538
  private getContentWidth(boxWidth: number): number {
493
- return Math.max(10, boxWidth - 4);
539
+ return boxWidth - 4;
494
540
  }
495
541
 
496
542
  private borderLine(left: string, right: string, width: number): string {
@@ -0,0 +1,5 @@
1
+ # Caffeinate
2
+
3
+ `caffeinate` prevents system sleep during agent runs, including retries, automatic compaction, and queued follow-ups. It releases the inhibitor when the agent settles or the session closes or reloads. Display sleep settings remain unchanged.
4
+
5
+ macOS uses `caffeinate -i`; Linux uses `systemd-inhibit --what=sleep` and requires an accessible, authorized logind service. Linux's sleep lock can also block manual suspend; lid-close and forced-sleep behavior remain OS-policy decisions. If inhibition is unavailable, Pi warns once per load, on the first failed attempt, and continues normally. There are no commands or settings; disable the extension through Pi's package configuration if unwanted.
@@ -0,0 +1,144 @@
1
+ /** Keep the system awake during agent runs without inhibiting display sleep. */
2
+
3
+ import { spawn } from "node:child_process";
4
+ import { platform } from "node:os";
5
+ import { stripVTControlCharacters } from "node:util";
6
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
7
+
8
+ interface InhibitCommand {
9
+ command: string;
10
+ args: string[];
11
+ waitForStdin: boolean;
12
+ }
13
+
14
+ export default function (pi: ExtensionAPI) {
15
+ let inhibitor: ReturnType<typeof startInhibitor> | undefined;
16
+ let releasing = Promise.resolve();
17
+ let shutdown = false;
18
+ let warned = false;
19
+
20
+ pi.on("agent_start", async (_event, ctx) => {
21
+ await releasing;
22
+ if (shutdown || inhibitor) return;
23
+ const command = inhibitCommand();
24
+ if (!command) return;
25
+ try {
26
+ inhibitor = startInhibitor(command, ctx.cwd, (reason) => warn(ctx, reason));
27
+ } catch (error) {
28
+ warn(ctx, error instanceof Error ? error.message : String(error));
29
+ }
30
+ });
31
+
32
+ pi.on("agent_settled", async (_event, ctx) => {
33
+ if (!shutdown && ctx.isIdle()) await release();
34
+ });
35
+
36
+ pi.on("session_shutdown", async () => {
37
+ shutdown = true;
38
+ await release();
39
+ });
40
+
41
+ async function release() {
42
+ if (inhibitor) {
43
+ releasing = inhibitor.stop();
44
+ inhibitor = undefined;
45
+ }
46
+ await releasing;
47
+ }
48
+
49
+ function warn(ctx: ExtensionContext, reason: string) {
50
+ if (shutdown || warned) return;
51
+ warned = true;
52
+ const message = `Caffeinate: sleep prevention unavailable. ${stripVTControlCharacters(reason)}`;
53
+ try {
54
+ if (ctx.hasUI) {
55
+ ctx.ui.notify(message, "warning");
56
+ return;
57
+ }
58
+ } catch {
59
+ // A missing UI must not turn an optional inhibitor failure into an uncaught error.
60
+ }
61
+ console.warn(message);
62
+ }
63
+ }
64
+
65
+ function inhibitCommand(): InhibitCommand | undefined {
66
+ switch (platform()) {
67
+ case "darwin":
68
+ return {
69
+ command: "/usr/bin/caffeinate",
70
+ args: ["-i", "-w", String(process.pid)],
71
+ waitForStdin: false,
72
+ };
73
+ case "linux":
74
+ return {
75
+ command: "systemd-inhibit",
76
+ args: [
77
+ "--what=sleep",
78
+ "--mode=block",
79
+ "--who=Pi",
80
+ "--why=Pi agent is running",
81
+ "--",
82
+ "cat",
83
+ ],
84
+ waitForStdin: true,
85
+ };
86
+ }
87
+ }
88
+
89
+ /** Own the inhibitor through close; a parent PID watch or stdin EOF also releases it if Pi exits. */
90
+ function startInhibitor(command: InhibitCommand, cwd: string, onFailure: (reason: string) => void) {
91
+ const child = spawn(command.command, command.args, {
92
+ cwd,
93
+ detached: true,
94
+ stdio: ["pipe", "ignore", "pipe"],
95
+ });
96
+ let stderr = "";
97
+ let error: string | undefined;
98
+ let stopped = false;
99
+ let closed = false;
100
+ let escalation: NodeJS.Timeout | undefined;
101
+ child.stderr.setEncoding("utf8");
102
+ child.stderr.on("data", (chunk: string) => {
103
+ stderr = (stderr + chunk).slice(0, 2048);
104
+ });
105
+ child.on("error", (cause: Error) => {
106
+ error = cause.message;
107
+ });
108
+ child.stdin.on("error", (cause: Error) => {
109
+ error = cause.message;
110
+ });
111
+ const completion = new Promise<void>((resolve) => {
112
+ child.once("close", (code, signal) => {
113
+ closed = true;
114
+ clearTimeout(escalation);
115
+ if (!stopped) {
116
+ onFailure(
117
+ error || stderr.trim() || `${command.command} exited unexpectedly (${signal ?? code}).`,
118
+ );
119
+ }
120
+ resolve();
121
+ });
122
+ });
123
+
124
+ return {
125
+ stop() {
126
+ if (!stopped && !closed) {
127
+ stopped = true;
128
+ child.stdin.end();
129
+ if (!command.waitForStdin) sendSignal("SIGTERM");
130
+ escalation = setTimeout(() => sendSignal("SIGKILL"), 500);
131
+ }
132
+ return completion;
133
+ },
134
+ };
135
+
136
+ function sendSignal(signal: "SIGTERM" | "SIGKILL") {
137
+ if (closed || child.pid === undefined) return;
138
+ try {
139
+ process.kill(-child.pid, signal);
140
+ } catch (cause) {
141
+ if ((cause as NodeJS.ErrnoException).code !== "ESRCH") child.kill(signal);
142
+ }
143
+ }
144
+ }