@pi-unipi/unipi 3.0.0-alpha.29 → 3.0.0-alpha.30

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 (150) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/LICENSE +0 -0
  3. package/package.json +21 -21
  4. package/packages/ask-user/config.ts +0 -0
  5. package/packages/ask-user/handoff.ts +0 -0
  6. package/packages/ask-user/package.json +2 -2
  7. package/packages/ask-user/skills/ask-user/SKILL.md +0 -0
  8. package/packages/ask-user/types.ts +0 -0
  9. package/packages/autocomplete/package.json +1 -1
  10. package/packages/autocomplete/src/constants.ts +2 -0
  11. package/packages/autocomplete/src/index.ts +0 -0
  12. package/packages/autocomplete/src/provider.ts +0 -0
  13. package/packages/autocomplete/src/settings.ts +0 -0
  14. package/packages/autocomplete/src/sorting.ts +0 -0
  15. package/packages/background-tasks/package.json +2 -2
  16. package/packages/background-tasks/src/cards.ts +77 -50
  17. package/packages/background-tasks/src/child-process.ts +1 -1
  18. package/packages/background-tasks/src/index.ts +40 -1
  19. package/packages/background-tasks/src/registry.ts +158 -50
  20. package/packages/background-tasks/src/tools.ts +4 -4
  21. package/packages/background-tasks/src/types.ts +28 -0
  22. package/packages/btw/package.json +2 -2
  23. package/packages/btw/skills/btw/SKILL.md +0 -0
  24. package/packages/compactor/README.md +7 -0
  25. package/packages/compactor/index.ts +0 -0
  26. package/packages/compactor/package.json +3 -3
  27. package/packages/compactor/skills/compactor/SKILL.md +0 -0
  28. package/packages/compactor/src/compaction/brief.ts +0 -0
  29. package/packages/compactor/src/compaction/content.ts +0 -0
  30. package/packages/compactor/src/compaction/extract/commits.ts +0 -0
  31. package/packages/compactor/src/compaction/extract/files.ts +0 -0
  32. package/packages/compactor/src/compaction/filter-noise.ts +0 -0
  33. package/packages/compactor/src/compaction/hooks.ts +205 -53
  34. package/packages/compactor/src/compaction/normalize.ts +0 -0
  35. package/packages/compactor/src/compaction/sanitize.ts +0 -0
  36. package/packages/compactor/src/compaction/search-entries.ts +0 -0
  37. package/packages/compactor/src/display/diff-width-safety.ts +0 -0
  38. package/packages/compactor/src/index.ts +41 -0
  39. package/packages/compactor/src/session/recall-blocks.ts +0 -0
  40. package/packages/compactor/src/tools/context-budget.ts +0 -0
  41. package/packages/compactor/src/tools/register.ts +0 -0
  42. package/packages/compactor/src/tools/vcc-recall.ts +0 -0
  43. package/packages/core/constants.ts +1 -0
  44. package/packages/core/detachable-bash.ts +139 -0
  45. package/packages/core/events.ts +0 -0
  46. package/packages/core/global-types.ts +0 -0
  47. package/packages/core/index.ts +2 -0
  48. package/packages/core/model-cache.ts +0 -0
  49. package/packages/core/move.ts +39 -0
  50. package/packages/core/package.json +1 -1
  51. package/packages/core/utils.ts +0 -0
  52. package/packages/footer/index.ts +0 -0
  53. package/packages/footer/package.json +3 -3
  54. package/packages/footer/src/events.ts +0 -0
  55. package/packages/footer/types.ts +0 -0
  56. package/packages/fusion/package.json +4 -4
  57. package/packages/fusion/src/index.ts +12 -10
  58. package/packages/info-screen/package.json +2 -2
  59. package/packages/input-shortcuts/package.json +2 -2
  60. package/packages/kanboard/index.ts +3 -0
  61. package/packages/kanboard/package.json +7 -7
  62. package/packages/kanboard/skills/kanboard/SKILL.md +13 -5
  63. package/packages/kanboard/src/commands.ts +27 -15
  64. package/packages/kanboard/src/move.ts +63 -0
  65. package/packages/long-horizon/package.json +2 -2
  66. package/packages/mcp/package.json +2 -2
  67. package/packages/mcp/skills/mcp/SKILL.md +0 -0
  68. package/packages/mcp/src/bridge/registry.ts +0 -0
  69. package/packages/mcp/src/bridge/translator.ts +0 -0
  70. package/packages/mcp/src/tui/add-overlay.ts +0 -0
  71. package/packages/mcp/src/tui/settings-overlay.ts +0 -0
  72. package/packages/memory/index.ts +3 -0
  73. package/packages/memory/move.ts +194 -0
  74. package/packages/memory/package.json +4 -3
  75. package/packages/memory/skills/memory/SKILL.md +0 -0
  76. package/packages/notify/ask-user-prompt-message.ts +0 -0
  77. package/packages/notify/commands.ts +0 -0
  78. package/packages/notify/ntfy-config.ts +0 -0
  79. package/packages/notify/package.json +2 -2
  80. package/packages/notify/platforms/focus-win.ts +0 -0
  81. package/packages/notify/platforms/focus.ts +0 -0
  82. package/packages/notify/platforms/gotify.ts +0 -0
  83. package/packages/notify/platforms/native.ts +0 -0
  84. package/packages/notify/platforms/node-notifier.d.ts +0 -0
  85. package/packages/notify/platforms/ntfy.ts +0 -0
  86. package/packages/notify/platforms/telegram.ts +0 -0
  87. package/packages/notify/skills/notify/SKILL.md +0 -0
  88. package/packages/notify/summarize.ts +0 -0
  89. package/packages/notify/tools.ts +0 -0
  90. package/packages/skill-registry/package.json +2 -2
  91. package/packages/subagents/package.json +3 -3
  92. package/packages/subagents/src/model-resolver.ts +0 -0
  93. package/packages/unipi/bundled.js +3457 -2041
  94. package/packages/updater/index.ts +0 -0
  95. package/packages/updater/package.json +2 -2
  96. package/packages/updater/skills/configure-updater/SKILL.md +0 -0
  97. package/packages/updater/src/cache.ts +0 -0
  98. package/packages/updater/src/changelog.ts +0 -0
  99. package/packages/updater/src/checker.ts +0 -0
  100. package/packages/updater/src/installer.ts +0 -0
  101. package/packages/updater/src/markdown.ts +0 -0
  102. package/packages/updater/src/settings.ts +0 -0
  103. package/packages/updater/src/tui/changelog-overlay.ts +0 -0
  104. package/packages/updater/src/tui/readme-overlay.ts +0 -0
  105. package/packages/updater/src/tui/update-overlay.ts +0 -0
  106. package/packages/updater/src/version.ts +0 -0
  107. package/packages/updater/types.ts +0 -0
  108. package/packages/utility/README.md +1 -0
  109. package/packages/utility/package.json +2 -2
  110. package/packages/utility/src/diagnostics/engine.ts +0 -0
  111. package/packages/utility/src/index.ts +3 -0
  112. package/packages/utility/src/lifecycle/cleanup.ts +0 -0
  113. package/packages/utility/src/move/git.ts +37 -0
  114. package/packages/utility/src/move/index.ts +144 -0
  115. package/packages/utility/src/move/pi-state.ts +158 -0
  116. package/packages/utility/src/render/harness.ts +60 -6
  117. package/packages/utility/src/render/simple.ts +2 -0
  118. package/packages/utility/src/render/tools.ts +32 -7
  119. package/packages/utility/src/types.ts +0 -0
  120. package/packages/watchdog/README.md +30 -16
  121. package/packages/watchdog/index.ts +122 -32
  122. package/packages/watchdog/package.json +3 -3
  123. package/packages/watchdog/src/bash-prompt.ts +65 -0
  124. package/packages/watchdog/src/config.ts +25 -20
  125. package/packages/watchdog/src/decide.ts +36 -0
  126. package/packages/watchdog/src/proc-sample.ts +62 -0
  127. package/packages/web-api/package.json +2 -2
  128. package/packages/web-api/skills/web/SKILL.md +0 -0
  129. package/packages/web-api/src/cache.ts +0 -0
  130. package/packages/web-api/src/commands.ts +0 -0
  131. package/packages/web-api/src/engine/constants.ts +0 -0
  132. package/packages/web-api/src/engine/dependencies.ts +0 -0
  133. package/packages/web-api/src/engine/dom.ts +0 -0
  134. package/packages/web-api/src/engine/extract.ts +0 -0
  135. package/packages/web-api/src/engine/format.ts +0 -0
  136. package/packages/web-api/src/engine/profiles.ts +0 -0
  137. package/packages/web-api/src/engine/types.ts +0 -0
  138. package/packages/web-api/src/providers/base.ts +0 -0
  139. package/packages/web-api/src/providers/duckduckgo.ts +0 -0
  140. package/packages/web-api/src/providers/firecrawl.ts +0 -0
  141. package/packages/web-api/src/providers/jina-reader.ts +0 -0
  142. package/packages/web-api/src/providers/jina-search.ts +0 -0
  143. package/packages/web-api/src/providers/perplexity.ts +0 -0
  144. package/packages/web-api/src/providers/registry.ts +0 -0
  145. package/packages/web-api/src/providers/serpapi.ts +0 -0
  146. package/packages/web-api/src/providers/tavily.ts +0 -0
  147. package/packages/web-api/src/settings.ts +0 -0
  148. package/packages/web-api/src/tools.ts +0 -0
  149. package/packages/web-api/src/tui/provider-selector.ts +0 -0
  150. package/packages/workflow/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -6,6 +6,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [3.0.0-alpha.30] — 2026-10-05
10
+
11
+ ### Changed
12
+ - `watchdog`: **on by default, for bash calls only.** A bash call that still looks stuck after the first check (2 min) is handed to a background task, never killed by default; background tasks and other tools stay unwatched unless you turn them on. (UNI-107)
13
+ - `watchdog`: bash calls are judged by process activity (no CPU, disk or output on two checks), a jev "will it finish on its own?" score on two checks in a row, or jev expecting the command to take seconds; a time limit written in the command (`timeout N`, `sleep N &&`, a bounded `seq … sleep` loop) holds every trigger off until it passes. Checks every 3 min after the first at 2 min. On a 20-case holdout: 9/10 stuck caught, 0/10 false (majority of 3 jev runs). Detached bash cards show `→ background task <id>` instead of an exit footer. (UNI-107)
14
+ - `watchdog`: a bash call that looks stuck is no longer killed by default: the call returns to the agent at once with the output so far, and the command keeps running as a background task the agent can check (`bg_logs`) or stop (`bg_kill`); its completion wakes the agent as usual. New watchdog action `background` (the default; `kill` and `warn` stay available) and `/unipi:bg-detach [reason]` to send the running bash call to the background by hand. (UNI-107)
15
+ - `kanboard`: agents can unblock tasks to Todo or resume a blocked task through `start`, with the usual claim and session limits. (UNI-105)
16
+ - `kanboard`: unblocking and review rework no longer require a description; empty notes produce clean activity text. (UNI-106)
17
+ - `kanboard`: agent-created labels must reuse existing project labels unless `--new-label` explicitly allows a new one; matching preserves stored spelling. (UNI-100)
18
+ - `kanboard`: removed the board's Task defaults tab and legacy Blocking setting, preserving the other board settings. (UNI-101)
19
+ - `kanboard`: `/unipi:kanboard show <lane>` filters the chat board, with lane and `--all` argument autocomplete. (UNI-102)
20
+ - `background-tasks`: launch and completion notices use width-fitted one-line chips in every render style; expanded views retain command, output and task details. (UNI-103)
21
+ - `compactor`: `/unipi:compact-then <prompt>` compacts before continuing, preserving next-turn versus after-run queue behavior while streaming. (UNI-104)
22
+ - `utility`: **warnings share one collapsed shape.** Harness warning panels (Progress guard, watchdog, Kanboard unfinished-task, …) render label + synopsis + `Ctrl+O: full message (N rendered rows)` in regular/advanced; simple mode compresses them to the single label line (`▏ ⚠ UniPi · Source · Title`). Expanded views add the body; the advanced origin footer is dropped for warnings. Tool-result annotations with `severity: "warning"` (Fusion edit/shell nudges, Kanboard R1 reminder) trade the old plain two-line header for the same rail+fill panel — in simple mode just one rail line under the tool row.
23
+
24
+ ### Fixed
25
+ - `fusion`: the shell-delegation nudge no longer re-arms on every new prompt (the reason it kept reappearing after each prompt). It now fires at most once per handoff gap: after a nudge, only a completed `sidekick`/`read_subagent` handoff re-arms it. The per-prompt streak reset stays, so a carried-over count still cannot fire early. Synopsis reads "Non-trivial shell work since last handoff" again — which is now literally true. (follow-up to UNI-87)
26
+
9
27
  ## [3.0.0-alpha.29] — 2026-10-04
10
28
 
11
29
  ### Fixed
package/LICENSE CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/unipi",
3
- "version": "3.0.0-alpha.29",
3
+ "version": "3.0.0-alpha.30",
4
4
  "description": "All-in-one extension suite for Pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -83,26 +83,26 @@
83
83
  "typebox": "^1.1.38"
84
84
  },
85
85
  "dependencies": {
86
- "@pi-unipi/ask-user": "3.0.0-alpha.29",
87
- "@pi-unipi/background-tasks": "3.0.0-alpha.29",
88
- "@pi-unipi/btw": "3.0.0-alpha.29",
89
- "@pi-unipi/command-enchantment": "3.0.0-alpha.29",
90
- "@pi-unipi/compactor": "3.0.0-alpha.29",
91
- "@pi-unipi/core": "3.0.0-alpha.29",
92
- "@pi-unipi/footer": "3.0.0-alpha.29",
93
- "@pi-unipi/fusion": "3.0.0-alpha.29",
94
- "@pi-unipi/info-screen": "3.0.0-alpha.29",
95
- "@pi-unipi/input-shortcuts": "3.0.0-alpha.29",
96
- "@pi-unipi/kanboard": "3.0.0-alpha.29",
97
- "@pi-unipi/mcp": "3.0.0-alpha.29",
98
- "@pi-unipi/memory": "3.0.0-alpha.29",
99
- "@pi-unipi/notify": "3.0.0-alpha.29",
100
- "@pi-unipi/skill-registry": "3.0.0-alpha.29",
101
- "@pi-unipi/subagents": "3.0.0-alpha.29",
102
- "@pi-unipi/updater": "3.0.0-alpha.29",
103
- "@pi-unipi/utility": "3.0.0-alpha.29",
104
- "@pi-unipi/web-api": "3.0.0-alpha.29",
105
- "@pi-unipi/workflow": "3.0.0-alpha.29"
86
+ "@pi-unipi/ask-user": "3.0.0-alpha.30",
87
+ "@pi-unipi/background-tasks": "3.0.0-alpha.30",
88
+ "@pi-unipi/btw": "3.0.0-alpha.30",
89
+ "@pi-unipi/command-enchantment": "3.0.0-alpha.30",
90
+ "@pi-unipi/compactor": "3.0.0-alpha.30",
91
+ "@pi-unipi/core": "3.0.0-alpha.30",
92
+ "@pi-unipi/footer": "3.0.0-alpha.30",
93
+ "@pi-unipi/fusion": "3.0.0-alpha.30",
94
+ "@pi-unipi/info-screen": "3.0.0-alpha.30",
95
+ "@pi-unipi/input-shortcuts": "3.0.0-alpha.30",
96
+ "@pi-unipi/kanboard": "3.0.0-alpha.30",
97
+ "@pi-unipi/mcp": "3.0.0-alpha.30",
98
+ "@pi-unipi/memory": "3.0.0-alpha.30",
99
+ "@pi-unipi/notify": "3.0.0-alpha.30",
100
+ "@pi-unipi/skill-registry": "3.0.0-alpha.30",
101
+ "@pi-unipi/subagents": "3.0.0-alpha.30",
102
+ "@pi-unipi/updater": "3.0.0-alpha.30",
103
+ "@pi-unipi/utility": "3.0.0-alpha.30",
104
+ "@pi-unipi/web-api": "3.0.0-alpha.30",
105
+ "@pi-unipi/workflow": "3.0.0-alpha.30"
106
106
  },
107
107
  "devDependencies": {
108
108
  "@earendil-works/pi-agent-core": "^0.87.1",
File without changes
File without changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/ask-user",
3
- "version": "3.0.0-alpha.29",
3
+ "version": "3.0.0-alpha.30",
4
4
  "description": "Structured user input tool for Pi coding agent — single-select, multi-select, freeform",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -40,7 +40,7 @@
40
40
  "access": "public"
41
41
  },
42
42
  "dependencies": {
43
- "@pi-unipi/core": "3.0.0-alpha.29"
43
+ "@pi-unipi/core": "3.0.0-alpha.30"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "@earendil-works/pi-coding-agent": "^0.87.1",
File without changes
File without changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/command-enchantment",
3
- "version": "3.0.0-alpha.29",
3
+ "version": "3.0.0-alpha.30",
4
4
  "description": "Enhanced TUI autocomplete for /unipi:* commands — colored, sorted, and grouped by package",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -75,6 +75,7 @@ export const COMMAND_REGISTRY: Record<string, string> = {
75
75
  "unipi:doctor": "utility",
76
76
  "unipi:answer": "utility",
77
77
  "unipi:summarize": "utility",
78
+ "unipi:move": "utility",
78
79
 
79
80
  // skill-registry
80
81
  "unipi:skills": "skill-registry",
@@ -162,6 +163,7 @@ export const COMMAND_DESCRIPTIONS: Record<string, string> = {
162
163
  "unipi:answer": "Answer the last reply — fixed input under the scrollable reply, per question, or web form",
163
164
  "unipi:summarize": "Summarize your last reply again (answer first, then findings, open items, questions). Add text to ask about it",
164
165
  "unipi:skills": "Manage skills — on/off per project, listed or not, the skill vault",
166
+ "unipi:move": "Re-link sessions, trust, worktrees, boards and memories after moving this project",
165
167
 
166
168
  "unipi:kanboard": "Kanboard — capture tasks, run them, open the board",
167
169
  "unipi:kanboard-add": "Kanboard — capture a task into Backlog",
File without changes
File without changes
File without changes
File without changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/background-tasks",
3
- "version": "3.0.0-alpha.29",
3
+ "version": "3.0.0-alpha.30",
4
4
  "description": "Background tasks for UniPi — durable shell jobs and delegated background agents with a footer dock",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@earendil-works/pi-ai": "^0.87.1",
13
- "@pi-unipi/core": "3.0.0-alpha.29",
13
+ "@pi-unipi/core": "3.0.0-alpha.30",
14
14
  "turndown": "^7.2.4"
15
15
  },
16
16
  "devDependencies": {
@@ -1,12 +1,22 @@
1
1
  /**
2
- * @pi-unipi/background-tasks — tinted chat cards
2
+ * @pi-unipi/background-tasks — chat cards (badge + leader, no tinted box)
3
3
  *
4
- * Background-task launch/completion messages were plain single lines that got
5
- * lost in the transcript. These helpers render them as padded, background-
6
- * tinted boxes (same visual language as pi's tool cards) so a user scanning
7
- * the chat can spot "a bg task started here" / "it finished here" at a glance.
4
+ * Background-task launch/completion messages render as one dotted-leader line
5
+ * (same visual language as @pi-unipi/core's badge/leader kit used elsewhere —
6
+ * subagents, etc.): a padded four-letter accent chip, the bold task name, a
7
+ * dotted leader, and a dim right side.
8
+ *
9
+ * BG Run full test suite ························· started · wakes agent
10
+ * DONE Run full test suite ························· exit 0 · 25s · agent woken
11
+ * FAIL Run full test suite ························· exit 101 · 12s · agent woken
12
+ * STOP Run full test suite ························· killed · 4s
13
+ *
14
+ * Collapsed is the chip line only. Expanded adds: the dim `$ command` line, an
15
+ * error line in error color, output tail lines, and the dim output path + id.
8
16
  */
9
- import { Box, Text } from "@earendil-works/pi-tui";
17
+ import type { Component } from "@earendil-works/pi-tui";
18
+ import { truncateToWidth } from "@earendil-works/pi-tui";
19
+ import { badge, leader, STATE_COLOR, type RunState } from "@pi-unipi/core";
10
20
  import { formatDuration, taskDisplayName, type BgTaskSnapshot } from "./types.js";
11
21
 
12
22
  type ThemeLike = {
@@ -15,62 +25,79 @@ type ThemeLike = {
15
25
  bold: (text: string) => string;
16
26
  };
17
27
 
18
- type CardTone = "pending" | "success" | "error";
28
+ /** Width-aware component — same shape as subagents' `lines()` helper. */
29
+ const lines = (render: (w: number) => string[]): Component => ({ invalidate() {}, render });
19
30
 
20
- const BG_FOR_TONE: Record<CardTone, string> = {
21
- pending: "toolPendingBg",
22
- success: "toolSuccessBg",
23
- error: "toolErrorBg",
31
+ const CHIP: Record<"running" | "completed" | "failed" | "killed", string> = {
32
+ running: "BG ",
33
+ completed: "DONE",
34
+ failed: "FAIL",
35
+ killed: "STOP",
24
36
  };
25
37
 
26
- function toneFor(status: BgTaskSnapshot["status"]): CardTone {
27
- if (status === "running") return "pending";
28
- if (status === "completed") return "success";
29
- return "error";
38
+ function runStateFor(status: BgTaskSnapshot["status"]): RunState {
39
+ if (status === "running") return "running";
40
+ if (status === "completed") return "completed";
41
+ if (status === "killed") return "cancelled";
42
+ return "failed";
30
43
  }
31
44
 
32
- function statusGlyph(theme: ThemeLike, status: BgTaskSnapshot["status"]): string {
33
- if (status === "running") return theme.fg("accent", "●");
34
- if (status === "completed") return theme.fg("success", "✓");
35
- if (status === "killed") return theme.fg("warning", "■");
36
- return theme.fg("error", "✗");
45
+ function chip(theme: ThemeLike, status: BgTaskSnapshot["status"]): string {
46
+ return badge(theme, STATE_COLOR[runStateFor(status)], CHIP[status]);
37
47
  }
38
48
 
39
- function card(theme: ThemeLike, tone: CardTone, lines: string[]): Box {
40
- const box = new Box(1, 0, (text) => theme.bg(BG_FOR_TONE[tone], text));
41
- box.addChild(new Text(lines.join("\n"), 0, 0));
42
- return box;
49
+ function head(theme: ThemeLike, task: BgTaskSnapshot, right: string, width: number): string {
50
+ return leader(theme, `${chip(theme, task.status)} ${theme.bold(taskDisplayName(task))}`, theme.fg("dim", right), width);
43
51
  }
44
52
 
45
- /** `bg_run` result card: "● bg started <name> (id) · will wake agent" */
46
- export function renderLaunchCard(theme: ThemeLike, task: BgTaskSnapshot): Box {
47
- const wake = task.triggerOnCompletion
48
- ? theme.fg("dim", " · wakes agent on completion")
49
- : task.notifyOnCompletion
50
- ? theme.fg("dim", " · notifies on completion")
51
- : theme.fg("dim", " · silent");
52
- const head = `${statusGlyph(theme, "running")} ${theme.fg("accent", theme.bold("bg started"))} ${theme.fg("accent", taskDisplayName(task))} ${theme.fg("dim", `(${task.id})`)}${wake}`;
53
- const cmd = theme.fg("dim", `$ ${task.command.length > 120 ? `${task.command.slice(0, 120)}…` : task.command}`);
54
- return card(theme, "pending", [head, cmd]);
53
+ function commandLine(theme: ThemeLike, task: BgTaskSnapshot, width: number): string {
54
+ const text = ` $ ${task.command.length > 120 ? `${task.command.slice(0, 120)}…` : task.command}`;
55
+ return truncateToWidth(theme.fg("dim", text), width, "…");
55
56
  }
56
57
 
57
- /** Completion notification card: "✓ bg done <name> · exit 0 · 25s" + last output lines */
58
- export function renderCompletionCard(theme: ThemeLike, task: BgTaskSnapshot | undefined): Box {
58
+ /** `bg_run` result card: `BG <name> ···· started · wakes agent`. */
59
+ export function renderLaunchCard(theme: ThemeLike, task: BgTaskSnapshot, expanded = false): Component {
60
+ const wake = task.triggerOnCompletion ? "wakes agent" : task.notifyOnCompletion ? "notifies" : "silent";
61
+ return lines((w) => {
62
+ const out = [head(theme, task, `started · ${wake}`, w)];
63
+ if (!expanded) return out;
64
+ out.push(commandLine(theme, task, w));
65
+ if (task.error) out.push(truncateToWidth(theme.fg("error", ` ${task.error}`), w, "…"));
66
+ for (const line of task.outputTail ?? []) out.push(truncateToWidth(theme.fg("toolOutput", ` ${line}`), w, "…"));
67
+ out.push(truncateToWidth(theme.fg("dim", ` ${task.outputPath}`), w, "…"));
68
+ out.push(truncateToWidth(theme.fg("dim", ` (${task.id})`), w, "…"));
69
+ return out;
70
+ });
71
+ }
72
+
73
+ /** Completion notification card: `DONE <name> ···· exit 0 · 25s · agent woken` + tail. */
74
+ export function renderCompletionCard(
75
+ theme: ThemeLike,
76
+ task: BgTaskSnapshot | undefined,
77
+ expanded = false,
78
+ ): Component {
59
79
  if (task === undefined) {
60
- return card(theme, "success", [`${theme.fg("success", "✓")} ${theme.fg("accent", theme.bold("bg done"))}`]);
80
+ return lines((w) => [leader(theme, `${badge(theme, STATE_COLOR.completed, "DONE")} ${theme.bold("bg task")}`, "", w)]);
61
81
  }
62
- const tone = toneFor(task.status);
63
- const label =
64
- task.status === "completed" ? "bg done" : task.status === "killed" ? "bg stopped" : "bg failed";
65
82
  const meta: string[] = [];
66
- if (task.exitCode !== undefined && task.exitCode !== null) meta.push(`exit ${String(task.exitCode)}`);
67
- if (task.endTime !== undefined) meta.push(formatDuration(task.endTime - task.startTime));
68
- if (task.triggerOnCompletion) meta.push("agent woken");
69
- const head = `${statusGlyph(theme, task.status)} ${theme.fg(tone === "error" ? "error" : "success", theme.bold(label))} ${theme.fg("accent", taskDisplayName(task))} ${theme.fg("dim", `(${task.id})`)}${meta.length > 0 ? theme.fg("dim", ` · ${meta.join(" · ")}`) : ""}`;
70
- const lines = [head];
71
- if (task.error) lines.push(theme.fg("error", task.error));
72
- const tail = task.outputTail ?? [];
73
- for (const line of tail) lines.push(theme.fg("toolOutput", ` ${line}`));
74
- lines.push(theme.fg("dim", ` ${task.outputPath}`));
75
- return card(theme, tone, lines);
83
+ if (task.status === "killed") {
84
+ if (task.endTime !== undefined) meta.push(formatDuration(task.endTime - task.startTime));
85
+ meta.unshift("killed");
86
+ } else {
87
+ if (task.exitCode !== undefined && task.exitCode !== null) meta.push(`exit ${String(task.exitCode)}`);
88
+ else if (task.status === "failed") meta.push(task.error ? "error" : "failed");
89
+ if (task.endTime !== undefined) meta.push(formatDuration(task.endTime - task.startTime));
90
+ if (task.triggerOnCompletion) meta.push("agent woken");
91
+ }
92
+ return lines((w) => {
93
+ const out = [head(theme, task, meta.join(" · "), w)];
94
+ if (!expanded) return out;
95
+ out.push(commandLine(theme, task, w));
96
+ if (task.error) out.push(truncateToWidth(theme.fg("error", ` ${task.error}`), w, "…"));
97
+ const tail = task.outputTail ?? [];
98
+ for (const line of tail) out.push(truncateToWidth(theme.fg("toolOutput", ` ${line}`), w, "…"));
99
+ out.push(truncateToWidth(theme.fg("dim", ` ${task.outputPath}`), w, "…"));
100
+ out.push(truncateToWidth(theme.fg("dim", ` (${task.id})`), w, "…"));
101
+ return out;
102
+ });
76
103
  }
@@ -16,7 +16,7 @@ export type ChildStdin = Writable;
16
16
  export interface OutputEventSource {
17
17
  on(event: "data", listener: (data: Buffer | string) => void): unknown;
18
18
  on(event: "end", listener: () => void): unknown;
19
- [Symbol.asyncIterator](): AsyncIterableIterator<Buffer>;
19
+ [Symbol.asyncIterator]?(): AsyncIterableIterator<Buffer>;
20
20
  }
21
21
 
22
22
  /** Minimal child-process surface the registry drives. */
@@ -10,7 +10,15 @@
10
10
  */
11
11
 
12
12
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
13
- import { createSpinnerLine, isChildProcess, registerWaitSource, setHerdrWorking } from "@pi-unipi/core";
13
+ import {
14
+ createSpinnerLine,
15
+ isChildProcess,
16
+ registerWaitSource,
17
+ setHerdrWorking,
18
+ setBashBackgroundAdopter,
19
+ type BashBackgroundRequest,
20
+ type BashDetachResult,
21
+ } from "@pi-unipi/core";
14
22
  import { loadBackgroundTasksConfig } from "./config.js";
15
23
  import { BackgroundTaskRegistry } from "./registry.js";
16
24
  import {
@@ -274,12 +282,42 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
274
282
  clearFinishedNotices,
275
283
  });
276
284
 
285
+ // Core handoff adapter: when pi's bash tool decides a running command
286
+ // should move to the background (watchdog-driven or otherwise), core calls
287
+ // this callback with the already-running child. We adopt it as a standard
288
+ // background task via `adoptRunningProcess` and mark it watchdogAdopted so
289
+ // the watchdog itself skips automatic kills on the now-registry-owned task.
290
+ function bashBackgroundAdopter(request: BashBackgroundRequest): Promise<BashDetachResult | null> {
291
+ const ctx = currentCtx;
292
+ if (!ctx) return Promise.resolve(null);
293
+ return registry
294
+ .adoptRunningProcess(ctx, request.child, {
295
+ command: request.command,
296
+ stop: request.stop,
297
+ startTime: request.startTime,
298
+ initialOutput: request.initialOutput,
299
+ notifyOnCompletion: true,
300
+ triggerOnCompletion: true,
301
+ })
302
+ .then((task) => {
303
+ updateUi(ctx);
304
+ return { taskId: task.id, outputPath: task.outputPath };
305
+ })
306
+ .catch((error: unknown) => {
307
+ console.error(
308
+ `[background-tasks] bash background adoption failed: ${error instanceof Error ? error.message : String(error)}`,
309
+ );
310
+ return null;
311
+ });
312
+ }
313
+
277
314
  pi.on("session_start", async (_event, ctx) => {
278
315
  registry.setShuttingDown(false);
279
316
  wakeLineInstalled = false; // pi clears extension widgets on reload/new session
280
317
  setSharedTaskRegistry(registry);
281
318
  currentCtx = ctx;
282
319
  await registry.ensureRuntimeDir(ctx);
320
+ setBashBackgroundAdopter(bashBackgroundAdopter);
283
321
  updateUi(ctx);
284
322
  if (statusInterval) clearInterval(statusInterval);
285
323
  statusInterval = setInterval(() => {
@@ -291,6 +329,7 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
291
329
  registry.setShuttingDown(true);
292
330
  wakeLineInstalled = false;
293
331
  clearSharedTaskRegistry();
332
+ setBashBackgroundAdopter(null);
294
333
  currentCtx = undefined;
295
334
  if (statusInterval) {
296
335
  clearInterval(statusInterval);
@@ -22,6 +22,7 @@ import {
22
22
  shellQuote,
23
23
  snapshot,
24
24
  taskDisplayName,
25
+ type AdoptRunningProcessOptions,
25
26
  type BgLogsDetails,
26
27
  type BgTask,
27
28
  type BgTaskSnapshot,
@@ -830,68 +831,170 @@ export class BackgroundTaskRegistry {
830
831
 
831
832
  task.child = child;
832
833
  task.pid = child.pid;
834
+ this.wireChildProcess(task, child, timeoutSeconds);
833
835
 
834
- child.stdout?.on('data', (data) => {
835
- this.appendChildOutput(task, data, 'stdout');
836
- });
837
- child.stderr?.on('data', (data) => {
838
- this.appendChildOutput(task, data, 'stderr');
839
- });
836
+ await this.writeMetadata(task);
837
+ this.onChange();
838
+ return task;
839
+ } catch (error) {
840
+ const message = error instanceof Error ? error.message : String(error);
841
+ this.writeNotice(task, `\n[background task spawn exception: ${message}]\n`);
842
+ await this.finalizeTask(task, 'failed', null, undefined, message);
843
+ throw new Error(`Failed to start background task: ${message}`);
844
+ }
845
+ }
840
846
 
841
- child.on('error', (error) => {
842
- this.writeNotice(task, `\n[background task spawn error: ${error.message}]\n`);
843
- void this.finalizeTask(task, 'failed', null, undefined, error.message);
844
- });
847
+ /**
848
+ * Adopt an already-running child process (e.g. a bash call handed off by
849
+ * core's `setBashBackgroundAdopter`) as a standard background task, without
850
+ * spawning anything ourselves. Shares all post-spawn wiring (output
851
+ * capture, close/error handling, timeout, metadata, notification) with
852
+ * `startTask` via `wireChildProcess`; the only difference is that the
853
+ * caller already owns a live `child` and may have buffered output that
854
+ * preceded adoption.
855
+ */
856
+ async adoptRunningProcess(
857
+ ctx: BackgroundTaskContext,
858
+ child: BackgroundTaskChildProcess,
859
+ options: AdoptRunningProcessOptions,
860
+ ): Promise<BgTask> {
861
+ const normalizedCommand = options.command.trim();
862
+ if (!normalizedCommand) throw new Error('Background command is empty');
863
+ if (this.shuttingDown)
864
+ throw new Error('Cannot adopt a background task while Pi is shutting down');
845
865
 
846
- child.on('close', (code, signalName) => {
847
- let status: TaskStatus;
848
- let error: string | undefined;
849
- if (task.killKind === 'user' || task.killKind === 'shutdown') {
850
- status = 'killed';
851
- } else if (task.killKind === 'timeout') {
852
- status = 'failed';
853
- error = task.error ?? `Timed out after ${String(timeoutSeconds)}s`;
854
- } else if (task.killKind === 'output_cap') {
855
- status = 'failed';
856
- error = task.error ?? `Output exceeded cap of ${formatSize(this.maxOutputBytes)}`;
857
- } else if ((code ?? 0) === 0) {
858
- status = 'completed';
859
- } else {
860
- status = 'failed';
861
- const exitCode = code === null ? 'null' : String(code);
862
- error = `Exited with code ${exitCode}${signalName ? ` (${signalName})` : ''}`;
863
- }
864
- void this.finalizeTask(task, status, code, signalName, error);
865
- });
866
+ const dir = await this.ensureRuntimeDir(ctx);
867
+ const id = this.makeTaskIdFn();
868
+ const outputAbsPath = join(dir.abs, `${id}.output`);
869
+ const metadataAbsPath = join(dir.abs, `${id}.json`);
870
+ const outputPath = join(dir.display, `${id}.output`);
871
+ const taskName =
872
+ normalizeTaskName(options.name) ?? deriveTaskNameFromCommand(normalizedCommand);
866
873
 
867
- if (timeoutSeconds !== undefined) {
868
- task.timeoutHandle = setTimeout(() => {
869
- if (task.status !== 'running') return;
870
- task.killKind = 'timeout';
871
- task.error = `Timed out after ${String(timeoutSeconds)}s`;
872
- this.writeNotice(task, `\n[background task timeout: ${task.error}]\n`);
873
- try {
874
- this.requestKill(task, 'SIGTERM');
875
- } catch (error) {
876
- void this.finalizeTask(
877
- task,
878
- 'failed',
879
- null,
880
- undefined,
881
- `${task.error}; kill failed: ${error instanceof Error ? error.message : String(error)}`,
882
- );
883
- }
884
- }, timeoutSeconds * 1000);
874
+ const task: BgTask = {
875
+ id,
876
+ name: taskName,
877
+ command: normalizedCommand,
878
+ description: undefined,
879
+ status: 'running',
880
+ outputPath,
881
+ outputAbsPath,
882
+ metadataAbsPath,
883
+ cwd: ctx.cwd,
884
+ startTime: options.startTime,
885
+ exitCode: undefined,
886
+ pid: child.pid,
887
+ bytesWritten: 0,
888
+ isAgent: false,
889
+ notified: false,
890
+ notifyOnCompletion: options.notifyOnCompletion ?? true,
891
+ triggerOnCompletion: options.triggerOnCompletion ?? false,
892
+ timeoutSeconds: undefined,
893
+ terminalPublicationGate: undefined,
894
+ watchdogAdopted: true,
895
+ waiters: [],
896
+ };
897
+ this.tasks.set(id, task);
898
+
899
+ const stream = createWriteStream(outputAbsPath, { flags: 'a', encoding: 'utf8' });
900
+ task.stream = stream;
901
+ stream.on('error', (error) => {
902
+ task.error = `Output file write failed: ${error.message}`;
903
+ if (task.status === 'running') {
904
+ task.killKind = 'output_cap';
905
+ try {
906
+ this.requestKill(task, 'SIGTERM');
907
+ } catch (killError) {
908
+ void this.finalizeTask(
909
+ task,
910
+ 'failed',
911
+ null,
912
+ undefined,
913
+ `${task.error}; kill failed: ${killError instanceof Error ? killError.message : String(killError)}`,
914
+ );
915
+ }
885
916
  }
917
+ });
886
918
 
919
+ try {
920
+ task.child = child;
921
+ task.externalStop = options.stop;
922
+ task.pid = undefined;
923
+ if (options.initialOutput) this.writeNotice(task, options.initialOutput);
924
+ this.wireChildProcess(task, child, undefined);
887
925
  await this.writeMetadata(task);
888
926
  this.onChange();
889
927
  return task;
890
928
  } catch (error) {
891
929
  const message = error instanceof Error ? error.message : String(error);
892
- this.writeNotice(task, `\n[background task spawn exception: ${message}]\n`);
930
+ this.writeNotice(task, `\n[background task adoption exception: ${message}]\n`);
893
931
  await this.finalizeTask(task, 'failed', null, undefined, message);
894
- throw new Error(`Failed to start background task: ${message}`);
932
+ throw new Error(`Failed to adopt background task: ${message}`);
933
+ }
934
+ }
935
+
936
+ /**
937
+ * Shared post-spawn wiring: output capture, error/close handling, and the
938
+ * optional timeout escalation. Used by both `startTask` (fresh spawn) and
939
+ * `adoptRunningProcess` (already-running child) so the finalize/close
940
+ * semantics never drift between the two paths.
941
+ */
942
+ private wireChildProcess(
943
+ task: BgTask,
944
+ child: BackgroundTaskChildProcess,
945
+ timeoutSeconds: number | undefined,
946
+ ): void {
947
+ child.stdout?.on('data', (data) => {
948
+ this.appendChildOutput(task, data, 'stdout');
949
+ });
950
+ child.stderr?.on('data', (data) => {
951
+ this.appendChildOutput(task, data, 'stderr');
952
+ });
953
+
954
+ child.on('error', (error) => {
955
+ this.writeNotice(task, `\n[background task spawn error: ${error.message}]\n`);
956
+ void this.finalizeTask(task, 'failed', null, undefined, error.message);
957
+ });
958
+
959
+ child.on('close', (code, signalName) => {
960
+ let status: TaskStatus;
961
+ let error: string | undefined;
962
+ if (task.killKind === 'user' || task.killKind === 'shutdown') {
963
+ status = 'killed';
964
+ } else if (task.killKind === 'timeout') {
965
+ status = 'failed';
966
+ error = task.error ?? `Timed out after ${String(timeoutSeconds)}s`;
967
+ } else if (task.killKind === 'output_cap') {
968
+ status = 'failed';
969
+ error = task.error ?? `Output exceeded cap of ${formatSize(this.maxOutputBytes)}`;
970
+ } else if (code === 0 && !signalName) {
971
+ status = 'completed';
972
+ } else {
973
+ status = 'failed';
974
+ const exitCode = code === null ? 'null' : String(code);
975
+ error = `Exited with code ${exitCode}${signalName ? ` (${signalName})` : ''}`;
976
+ }
977
+ void this.finalizeTask(task, status, code, signalName, error);
978
+ });
979
+
980
+ if (timeoutSeconds !== undefined) {
981
+ task.timeoutHandle = setTimeout(() => {
982
+ if (task.status !== 'running') return;
983
+ task.killKind = 'timeout';
984
+ task.error = `Timed out after ${String(timeoutSeconds)}s`;
985
+ this.writeNotice(task, `\n[background task timeout: ${task.error}]\n`);
986
+ try {
987
+ this.requestKill(task, 'SIGTERM');
988
+ } catch (error) {
989
+ void this.finalizeTask(
990
+ task,
991
+ 'failed',
992
+ null,
993
+ undefined,
994
+ `${task.error}; kill failed: ${error instanceof Error ? error.message : String(error)}`,
995
+ );
996
+ }
997
+ }, timeoutSeconds * 1000);
895
998
  }
896
999
  }
897
1000
 
@@ -1471,6 +1574,11 @@ export class BackgroundTaskRegistry {
1471
1574
  if (task.status !== 'running') {
1472
1575
  throw new Error(`Task ${task.id} is ${task.status}, not running`);
1473
1576
  }
1577
+ if (task.externalStop) {
1578
+ task.externalStop();
1579
+ task.killSignalSent = true;
1580
+ return;
1581
+ }
1474
1582
  if (!task.child) {
1475
1583
  throw new Error(`Task ${task.id} has no child process handle`);
1476
1584
  }