@pi-unipi/unipi 3.0.0-alpha.19 → 3.0.0-alpha.20

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/CHANGELOG.md +19 -0
  2. package/package.json +21 -21
  3. package/packages/ask-user/package.json +2 -2
  4. package/packages/autocomplete/package.json +1 -1
  5. package/packages/background-tasks/package.json +2 -2
  6. package/packages/btw/package.json +2 -2
  7. package/packages/compactor/package.json +3 -3
  8. package/packages/compactor/src/commands/index.ts +7 -2
  9. package/packages/core/harness-messages.ts +392 -0
  10. package/packages/core/index.ts +1 -0
  11. package/packages/core/package.json +1 -1
  12. package/packages/footer/package.json +3 -3
  13. package/packages/fusion/package.json +4 -4
  14. package/packages/fusion/src/index.ts +22 -4
  15. package/packages/info-screen/package.json +2 -2
  16. package/packages/input-shortcuts/package.json +2 -2
  17. package/packages/kanboard/package.json +7 -7
  18. package/packages/kanboard/skills/kanboard/SKILL.md +1 -1
  19. package/packages/kanboard/src/commands.ts +21 -6
  20. package/packages/kanboard/src/monitor.ts +3 -1
  21. package/packages/kanboard/src/reminders.ts +11 -2
  22. package/packages/long-horizon/index.ts +27 -8
  23. package/packages/long-horizon/package.json +2 -2
  24. package/packages/long-horizon/src/commands.ts +6 -2
  25. package/packages/long-horizon/src/engine/nudge-stash.ts +19 -0
  26. package/packages/long-horizon/src/runtime.ts +7 -2
  27. package/packages/mcp/package.json +2 -2
  28. package/packages/memory/commands.ts +7 -2
  29. package/packages/memory/index.ts +15 -6
  30. package/packages/memory/package.json +3 -3
  31. package/packages/memory/tools.ts +13 -0
  32. package/packages/notify/package.json +2 -2
  33. package/packages/skill-registry/index.ts +10 -3
  34. package/packages/skill-registry/package.json +2 -2
  35. package/packages/subagents/package.json +3 -3
  36. package/packages/unipi/bundled.js +1256 -396
  37. package/packages/unipi/index.ts +13 -3
  38. package/packages/updater/package.json +2 -2
  39. package/packages/utility/package.json +2 -2
  40. package/packages/utility/src/commands.ts +7 -2
  41. package/packages/utility/src/index.ts +12 -0
  42. package/packages/utility/src/render/harness.ts +683 -0
  43. package/packages/utility/src/render/simple.ts +12 -3
  44. package/packages/utility/src/render/tools.ts +20 -3
  45. package/packages/watchdog/index.ts +14 -2
  46. package/packages/watchdog/package.json +3 -3
  47. package/packages/web-api/package.json +2 -2
  48. package/packages/workflow/package.json +2 -2
  49. package/packages/workflow/src/plan/index.ts +12 -3
package/CHANGELOG.md CHANGED
@@ -6,6 +6,25 @@ 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.20] — 2026-10-02
10
+
11
+ ### Added
12
+ - `kanboard` (board UI): **Dashboard** command center — greeting header with waiting/agents/ready/done-7d stats and a 14-day throughput spark; a per-project "Needs you" inbox (Approve / Rework / Answer inline, j/k/Enter, 3 rows per project + 10 overall, `+N more` / `Show fewer`, Expand all / Collapse all, oldest-wait on collapsed groups); project cards; live agents; up next; and an activity timeline that folds a task's quick steps into one trail. Expand/collapse animate. Backed by `GET /api/dashboard`. (UNI-67)
13
+ - `kanboard`: archive projects from the sidebar's right-click menu into an **Archived** section. (UNI-51)
14
+ - `kanboard`: labels — create or pick labels in the task form; each label gets a stable hash colour. (UNI-60)
15
+ - `kanboard`: task owner — tasks record "Created by agent / user" automatically; read-only. (UNI-59)
16
+ - `kanboard`: undo toast for moves that are hard to reverse (e.g. dragging to Cancelled, or In Review → Done); cancelled tasks stay final and offer **Recreate task** (right-click), which opens Add Task prefilled with the add-task fields only. (UNI-57)
17
+
18
+ ### Changed
19
+ - `kanboard` CLI: bare `list` shows only the active lanes (todo, in progress, blocked, in review, done); `--all` adds backlog, cancelled and archived. (UNI-62)
20
+ - `kanboard`: a user can move a Blocked task straight to Done. (UNI-63)
21
+ - `kanboard` (board UI): clicking outside the create/edit task form no longer closes it and discards input. (UNI-61)
22
+ - render: harness-origin messages (reminders, nudges, kickoffs) are styled as source-labelled violet/slate panels in every render style, while the model-facing text is unchanged. (UNI-53)
23
+ - `memory`: the save-pass card shows humanized token counts with explicit cache read/write labels. (UNI-56)
24
+
25
+ ### Fixed
26
+ - `kanboard`: titles containing `"` no longer gain an extra backslash on every save. (UNI-58)
27
+
9
28
  ## [3.0.0-alpha.19] — 2026-10-01
10
29
 
11
30
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/unipi",
3
- "version": "3.0.0-alpha.19",
3
+ "version": "3.0.0-alpha.20",
4
4
  "description": "All-in-one extension suite for Pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -82,26 +82,26 @@
82
82
  "typebox": "^1.1.38"
83
83
  },
84
84
  "dependencies": {
85
- "@pi-unipi/ask-user": "3.0.0-alpha.19",
86
- "@pi-unipi/background-tasks": "3.0.0-alpha.19",
87
- "@pi-unipi/btw": "3.0.0-alpha.19",
88
- "@pi-unipi/command-enchantment": "3.0.0-alpha.19",
89
- "@pi-unipi/compactor": "3.0.0-alpha.19",
90
- "@pi-unipi/core": "3.0.0-alpha.19",
91
- "@pi-unipi/footer": "3.0.0-alpha.19",
92
- "@pi-unipi/fusion": "3.0.0-alpha.19",
93
- "@pi-unipi/info-screen": "3.0.0-alpha.19",
94
- "@pi-unipi/input-shortcuts": "3.0.0-alpha.19",
95
- "@pi-unipi/kanboard": "3.0.0-alpha.19",
96
- "@pi-unipi/mcp": "3.0.0-alpha.19",
97
- "@pi-unipi/memory": "3.0.0-alpha.19",
98
- "@pi-unipi/notify": "3.0.0-alpha.19",
99
- "@pi-unipi/skill-registry": "3.0.0-alpha.19",
100
- "@pi-unipi/subagents": "3.0.0-alpha.19",
101
- "@pi-unipi/updater": "3.0.0-alpha.19",
102
- "@pi-unipi/utility": "3.0.0-alpha.19",
103
- "@pi-unipi/web-api": "3.0.0-alpha.19",
104
- "@pi-unipi/workflow": "3.0.0-alpha.19"
85
+ "@pi-unipi/ask-user": "3.0.0-alpha.20",
86
+ "@pi-unipi/background-tasks": "3.0.0-alpha.20",
87
+ "@pi-unipi/btw": "3.0.0-alpha.20",
88
+ "@pi-unipi/command-enchantment": "3.0.0-alpha.20",
89
+ "@pi-unipi/compactor": "3.0.0-alpha.20",
90
+ "@pi-unipi/core": "3.0.0-alpha.20",
91
+ "@pi-unipi/footer": "3.0.0-alpha.20",
92
+ "@pi-unipi/fusion": "3.0.0-alpha.20",
93
+ "@pi-unipi/info-screen": "3.0.0-alpha.20",
94
+ "@pi-unipi/input-shortcuts": "3.0.0-alpha.20",
95
+ "@pi-unipi/kanboard": "3.0.0-alpha.20",
96
+ "@pi-unipi/mcp": "3.0.0-alpha.20",
97
+ "@pi-unipi/memory": "3.0.0-alpha.20",
98
+ "@pi-unipi/notify": "3.0.0-alpha.20",
99
+ "@pi-unipi/skill-registry": "3.0.0-alpha.20",
100
+ "@pi-unipi/subagents": "3.0.0-alpha.20",
101
+ "@pi-unipi/updater": "3.0.0-alpha.20",
102
+ "@pi-unipi/utility": "3.0.0-alpha.20",
103
+ "@pi-unipi/web-api": "3.0.0-alpha.20",
104
+ "@pi-unipi/workflow": "3.0.0-alpha.20"
105
105
  },
106
106
  "devDependencies": {
107
107
  "@earendil-works/pi-agent-core": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/ask-user",
3
- "version": "3.0.0-alpha.19",
3
+ "version": "3.0.0-alpha.20",
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.19"
43
+ "@pi-unipi/core": "3.0.0-alpha.20"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/command-enchantment",
3
- "version": "3.0.0-alpha.19",
3
+ "version": "3.0.0-alpha.20",
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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/background-tasks",
3
- "version": "3.0.0-alpha.19",
3
+ "version": "3.0.0-alpha.20",
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.19",
13
+ "@pi-unipi/core": "3.0.0-alpha.20",
14
14
  "turndown": "^7.2.4"
15
15
  },
16
16
  "devDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/btw",
3
- "version": "3.0.0-alpha.19",
3
+ "version": "3.0.0-alpha.20",
4
4
  "description": "A pi extension for parallel side conversations with /unipi:btw — part of the Unipi suite",
5
5
  "type": "module",
6
6
  "main": "extensions/btw.ts",
@@ -37,7 +37,7 @@
37
37
  "@earendil-works/pi-tui": "^0.87.1"
38
38
  },
39
39
  "dependencies": {
40
- "@pi-unipi/core": "3.0.0-alpha.19"
40
+ "@pi-unipi/core": "3.0.0-alpha.20"
41
41
  },
42
42
  "pi": {
43
43
  "extensions": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/compactor",
3
- "version": "3.0.0-alpha.19",
3
+ "version": "3.0.0-alpha.20",
4
4
  "description": "Compaction for Pi — lossless zero-LLM or model summaries, rebuilt from full history, with session recall",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -33,8 +33,8 @@
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "@pi-unipi/core": "3.0.0-alpha.19",
37
- "@pi-unipi/info-screen": "3.0.0-alpha.19",
36
+ "@pi-unipi/core": "3.0.0-alpha.20",
37
+ "@pi-unipi/info-screen": "3.0.0-alpha.20",
38
38
  "@earendil-works/pi-agent-core": "^0.87.1"
39
39
  },
40
40
  "peerDependencies": {
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
11
- import { COMPACTOR_INSTRUCTION, formatTokens } from "@pi-unipi/core";
11
+ import { COMPACTOR_INSTRUCTION, formatTokens, harnessMetadata } from "@pi-unipi/core";
12
12
  import { loadConfig } from "../config/manager.js";
13
13
  import { setPendingCompaction } from "../compaction/hooks.js";
14
14
  import { vccRecall } from "../tools/vcc-recall.js";
@@ -67,7 +67,12 @@ export function registerCommands(pi: ExtensionAPI): void {
67
67
  return;
68
68
  }
69
69
  const result = vccRecall(blocks, { query, scope: parsed.scope, page });
70
- pi.sendMessage({ customType: "compactor-recall", content: result.text, display: true }, { triggerTurn: true });
70
+ pi.sendMessage({
71
+ customType: "compactor-recall",
72
+ content: result.text,
73
+ display: true,
74
+ details: { unipiHarness: harnessMetadata({ source: "Compactor", title: "Session recall", synopsis: "Results from /unipi:session-recall" }, "direct") },
75
+ }, { triggerTurn: true });
71
76
  };
72
77
  pi.registerCommand("unipi:session-recall", {
73
78
  description: "Recall earlier parts of this session. Plain keywords work best; add scope:all to reach edited or retried turns.",
@@ -0,0 +1,392 @@
1
+ /**
2
+ * @unipi/core — harness message provenance.
3
+ *
4
+ * Harness-generated text that reaches the model as role USER (direct
5
+ * sendUserMessage) is annotated with a namespaced `unipiHarness` metadata field
6
+ * so the UI can style it — model roles, text, delivery and admission behavior
7
+ * stay untouched.
8
+ *
9
+ * Lifecycle (single-call synchronous arm — the SDK runs `input` handlers
10
+ * synchronously inside sendUserMessage):
11
+ * 1. `sendHarnessUserMessage` ensures the observers are installed, pushes a
12
+ * single-call ARM, calls the unchanged `pi.sendUserMessage`, and pops the
13
+ * arm in `finally` — nothing lingers across calls.
14
+ * 2. The input handler run DURING that call confirms the arm (source
15
+ * `extension`, exact text). Human/rpc inputs and extension inputs without
16
+ * an active arm become human/unattributed admission records.
17
+ * 3. Idle (non-queued) harness records are dispatch-eligible only after
18
+ * `before_agent_start` carries the exact prompt (auth/model passed).
19
+ * Queued records are eligible from their confirmed delivery while active.
20
+ * 4. `message_start` selects among ELIGIBLE harness records; human/
21
+ * unattributed records participate in conflict detection but never label.
22
+ * Identical texts with conflicting origins — across deliveries — fail
23
+ * CLOSED and clear the whole matching group. The selected message is
24
+ * marked processed; `message_end` never re-selects.
25
+ * 5. `message_end` attaches `unipiHarness` only when the finalized text still
26
+ * equals the selected text (later transforms fail closed). SessionManager
27
+ * persists the whole object, so provenance survives resume/branch.
28
+ *
29
+ * Irreducible SDK gap: input events carry no per-send ID. The next input
30
+ * purges ALL unmatched direct records (harness + human/rpc) and terminal
31
+ * boundaries purge everything outstanding — the failure mode is always native
32
+ * fallback, never a human message mislabelled.
33
+ */
34
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
35
+
36
+ export type HarnessDelivery =
37
+ | "direct"
38
+ | "steer"
39
+ | "followUp"
40
+ | "nextTurn"
41
+ | "boundary"
42
+ | "before_agent_start";
43
+
44
+ export interface HarnessMessageMeta {
45
+ version: 1;
46
+ id: string;
47
+ source: string;
48
+ title: string;
49
+ synopsis?: string;
50
+ delivery: HarnessDelivery;
51
+ severity?: "warning";
52
+ }
53
+
54
+ export type HarnessMessageMetaInput = Omit<HarnessMessageMeta, "version" | "id" | "delivery">;
55
+
56
+ /** Canonical per-API state, carried on the API ROOT via a Symbol.for property.
57
+ * Proxies (withCommandEcho, the umbrella registerTool proxy) forward unknown
58
+ * property gets/sets to their target, so every wrapper observes one state. */
59
+ const STATE_SYM = Symbol.for("unipi.harnessMessages.state");
60
+
61
+ interface HarnessToken {
62
+ kind: "harness";
63
+ origin: string;
64
+ title: string;
65
+ synopsis?: string;
66
+ severity?: "warning";
67
+ text: string;
68
+ delivery: "direct" | "steer" | "followUp";
69
+ /** Direct records become eligible only at before_agent_start. */
70
+ eligible: boolean;
71
+ state: "admitted" | "consumed";
72
+ seq: number;
73
+ }
74
+
75
+ interface HarnessHumanRecord {
76
+ kind: "human" | "unattributed";
77
+ origin: string;
78
+ text: string;
79
+ delivery: "direct" | "steer" | "followUp";
80
+ state: "admitted" | "consumed";
81
+ seq: number;
82
+ }
83
+
84
+ type AdmissionRecord = HarnessToken | HarnessHumanRecord;
85
+
86
+ interface Arm {
87
+ origin: string;
88
+ title: string;
89
+ synopsis?: string;
90
+ severity?: "warning";
91
+ text: string;
92
+ delivery: "direct" | "steer" | "followUp";
93
+ seq: number;
94
+ }
95
+
96
+ export interface HarnessRegistryState {
97
+ records: AdmissionRecord[];
98
+ seq: number;
99
+ arms: Arm[];
100
+ /** message object → { meta, text } (set at message_start). */
101
+ pendingMeta: WeakMap<object, { meta: HarnessMessageMeta; text: string }>;
102
+ /** messages whose selection already happened — message_end never re-selects. */
103
+ processed: WeakSet<object>;
104
+ installed: boolean;
105
+ }
106
+
107
+ function stateFor(pi: object): HarnessRegistryState {
108
+ const existing = (pi as { [STATE_SYM]?: HarnessRegistryState })[STATE_SYM];
109
+ if (existing) return existing;
110
+ const state: HarnessRegistryState = {
111
+ records: [],
112
+ seq: 0,
113
+ arms: [],
114
+ pendingMeta: new WeakMap(),
115
+ processed: new WeakSet(),
116
+ installed: false,
117
+ };
118
+ try {
119
+ Object.defineProperty(pi, STATE_SYM, { value: state, enumerable: false, configurable: false, writable: false });
120
+ } catch {
121
+ // Frozen hosts: fall back to an instance-local property (single-proxy use).
122
+ try {
123
+ (pi as { [STATE_SYM]?: HarnessRegistryState })[STATE_SYM] = state;
124
+ } catch {
125
+ // Absolutely frozen: operate armless — sends stay UNTRACKED (native).
126
+ }
127
+ }
128
+ return (pi as { [STATE_SYM]?: HarnessRegistryState })[STATE_SYM] ?? state;
129
+ }
130
+
131
+ let idCounter = 0;
132
+ function nextId(): string {
133
+ idCounter += 1;
134
+ return `hm-${Date.now().toString(36)}-${idCounter.toString(36)}`;
135
+ }
136
+
137
+ /** Build a stable namespaced meta (no date header — ids are monotonic). */
138
+ export function harnessMetadata(meta: HarnessMessageMetaInput, delivery: HarnessDelivery): HarnessMessageMeta {
139
+ return { version: 1, id: nextId(), delivery, ...meta };
140
+ }
141
+
142
+ /** Details payload for tool results: provenance header data for annotations
143
+ * appended to the result content (fusion nudges, kanboard R1, watchdog kill). */
144
+ export interface HarnessToolAnnotation {
145
+ meta: HarnessMessageMeta;
146
+ text: string;
147
+ }
148
+
149
+ export function harnessToolResultDetails(
150
+ eventDetails: unknown,
151
+ meta: HarnessMessageMetaInput,
152
+ delivery: HarnessDelivery,
153
+ text: string,
154
+ ): Record<string, unknown> {
155
+ const existing = (eventDetails as { unipiHarnessAnnotations?: unknown } | null | undefined)?.unipiHarnessAnnotations;
156
+ const list = Array.isArray(existing) ? existing : [];
157
+ return {
158
+ ...((eventDetails as Record<string, unknown> | null | undefined) ?? {}),
159
+ unipiHarnessAnnotations: [...list, { meta: harnessMetadata(meta, delivery), text }],
160
+ };
161
+ }
162
+
163
+ /** Namespaced read of harness metadata from a message or details object. */
164
+ export function readHarnessMeta(source: unknown): HarnessMessageMeta | undefined {
165
+ const meta = (source as { unipiHarness?: unknown } | null | undefined)?.unipiHarness ?? source;
166
+ if (!meta || typeof meta !== "object") return undefined;
167
+ const m = meta as Partial<HarnessMessageMeta>;
168
+ const ALLOWED_DELIVERY: readonly string[] = ["direct", "steer", "followUp", "nextTurn", "boundary", "before_agent_start"];
169
+ if (
170
+ m.version !== 1 || typeof m.id !== "string" || typeof m.source !== "string" ||
171
+ typeof m.title !== "string" || typeof m.delivery !== "string" || !ALLOWED_DELIVERY.includes(m.delivery)
172
+ ) {
173
+ return undefined;
174
+ }
175
+ return m as HarnessMessageMeta;
176
+ }
177
+
178
+ /** Extract the exact text the SDK renders/queues for a user message. */
179
+ export function userMessageText(content: unknown): string {
180
+ if (typeof content === "string") return content;
181
+ if (!Array.isArray(content)) return "";
182
+ return content
183
+ .filter((c): c is { type: "text"; text: string } => (c as { type?: string }).type === "text")
184
+ .map((c) => (c as { text: string }).text)
185
+ .join("");
186
+ }
187
+
188
+ function userTextFromMessage(message: unknown): string {
189
+ if (!message || typeof message !== "object") return "";
190
+ const m = message as { role?: unknown; content?: unknown };
191
+ if (m.role !== "user") return "";
192
+ return userMessageText(m.content);
193
+ }
194
+
195
+ /**
196
+ * Send a harness-generated user message through the UNCHANGED
197
+ * `pi.sendUserMessage` with a synchronous single-call provenance arm. Returns
198
+ * the raw sender's value (callers may await; failures propagate unchanged).
199
+ * Provenance tracking is best-effort: if observers cannot install on this API,
200
+ * the raw send proceeds untracked (native fallback).
201
+ */
202
+ export function sendHarnessUserMessage(
203
+ pi: ExtensionAPI,
204
+ content: string,
205
+ meta: HarnessMessageMetaInput,
206
+ options?: { deliverAs?: "steer" | "followUp" },
207
+ ): ReturnType<ExtensionAPI["sendUserMessage"]> {
208
+ let arm: Arm | undefined;
209
+ try {
210
+ try {
211
+ installHarnessProvenance(pi);
212
+ const state = stateFor(pi);
213
+ state.seq += 1;
214
+ arm = {
215
+ origin: meta.source,
216
+ title: meta.title,
217
+ synopsis: meta.synopsis,
218
+ severity: meta.severity,
219
+ text: content,
220
+ delivery: options?.deliverAs ?? "direct",
221
+ seq: state.seq,
222
+ };
223
+ state.arms.push(arm);
224
+ } catch {
225
+ // Observers/state unavailable (frozen host): send untracked.
226
+ arm = undefined;
227
+ }
228
+ return pi.sendUserMessage(content, options);
229
+ } finally {
230
+ // The input handler ran synchronously inside the call (or never ran —
231
+ // compaction/auth failure). Either way the arm dies with the call: an
232
+ // unconfirmed arm leaves NO token, so nothing can mismatch later sends.
233
+ if (arm) {
234
+ const idx = stateFor(pi).arms.indexOf(arm);
235
+ if (idx !== -1) stateFor(pi).arms.splice(idx, 1);
236
+ }
237
+ }
238
+ }
239
+
240
+ const DELIVERY_ORDER: Record<string, number> = { direct: 0, steer: 1, followUp: 2 };
241
+
242
+ /** Prune consumed records immediately (bounded arrays over long runs). */
243
+ function prune(state: HarnessRegistryState): void {
244
+ if (state.records.some((r) => r.state === "consumed")) {
245
+ state.records = state.records.filter((r) => r.state !== "consumed");
246
+ }
247
+ }
248
+
249
+ /** Select the outstanding admission record for an arriving user message.
250
+ * Only ELIGIBLE harness records can label; human/unattributed records
251
+ * participate in conflict detection but never label. Identical texts with
252
+ * conflicting origins — across deliveries — fail closed and clear the whole
253
+ * matching group. */
254
+ function selectRecord(state: HarnessRegistryState, text: string): HarnessMessageMeta | undefined {
255
+ const candidates = state.records.filter(
256
+ (r) => r.state === "admitted" && r.text === text && (r.kind !== "harness" || r.eligible),
257
+ );
258
+ if (candidates.length === 0) return undefined;
259
+ const origins = new Set(candidates.map((r) => r.origin));
260
+ if (origins.size > 1) {
261
+ for (const r of candidates) r.state = "consumed";
262
+ prune(state);
263
+ return undefined;
264
+ }
265
+ candidates.sort((a, b) => (DELIVERY_ORDER[a.delivery] ?? 3) - (DELIVERY_ORDER[b.delivery] ?? 3) || a.seq - b.seq);
266
+ const chosen = candidates[0]!;
267
+ for (const r of candidates) r.state = "consumed";
268
+ prune(state); // consumed records never linger, even on normal success
269
+ if (chosen.kind !== "harness") return undefined;
270
+ return harnessMetadata(
271
+ { source: chosen.origin, title: chosen.title, synopsis: chosen.synopsis, severity: chosen.severity },
272
+ chosen.delivery,
273
+ );
274
+ }
275
+
276
+ /**
277
+ * Install the provenance observers. Idempotent per API ROOT (state travels with
278
+ * the root through proxies). Model roles/text/delivery/admission are untouched;
279
+ * the only behavior change is the namespaced `unipiHarness` field on harness
280
+ * user messages.
281
+ */
282
+ export function installHarnessProvenance(pi: ExtensionAPI): void {
283
+ const state = stateFor(pi);
284
+ if (state.installed) return;
285
+ state.installed = true;
286
+ const onAny = pi.on as unknown as (event: string, handler: (event?: unknown, ctx?: unknown) => unknown) => void;
287
+
288
+ onAny("input", (event) => {
289
+ const ev = (event ?? {}) as { text?: unknown; source?: unknown; streamingBehavior?: unknown };
290
+ const text = typeof ev.text === "string" ? ev.text : "";
291
+ const source = typeof ev.source === "string" ? ev.source : "interactive";
292
+ const streaming = ev.streamingBehavior === "steer" || ev.streamingBehavior === "followUp";
293
+ // Effective delivery: the queued value ONLY while streaming (the real SDK
294
+ // omits streamingBehavior on idle events even when the caller passed one —
295
+ // an idle followUp option is ignored and dispatches direct).
296
+ const effective = streaming ? (ev.streamingBehavior as "steer" | "followUp") : "direct";
297
+ // The next input invalidates ALL unmatched direct records (harness and
298
+ // human/rpc alike) — bounds failed/aborted sends. Queued stays.
299
+ state.records = state.records.filter(
300
+ (r) => !(r.delivery === "direct" && r.state === "admitted"),
301
+ );
302
+ state.seq += 1;
303
+ const arm = state.arms[state.arms.length - 1];
304
+ if (arm && source === "extension" && text === arm.text) {
305
+ state.records.push({
306
+ kind: "harness",
307
+ origin: arm.origin,
308
+ title: arm.title,
309
+ synopsis: arm.synopsis,
310
+ severity: arm.severity,
311
+ text: arm.text,
312
+ delivery: effective,
313
+ // Queued is eligible immediately; idle direct only at before_agent_start.
314
+ eligible: streaming,
315
+ state: "admitted",
316
+ seq: arm.seq,
317
+ });
318
+ prune(state);
319
+ return;
320
+ }
321
+ state.records.push({
322
+ kind: source === "extension" ? "unattributed" : "human",
323
+ origin: source,
324
+ text,
325
+ delivery: effective,
326
+ state: "admitted",
327
+ seq: state.seq,
328
+ });
329
+ prune(state);
330
+ });
331
+
332
+ onAny("before_agent_start", (event) => {
333
+ const prompt = (event as { prompt?: unknown } | undefined)?.prompt;
334
+ if (typeof prompt !== "string") return;
335
+ // Idle harness records matching THIS prompt become eligible; other
336
+ // unconfirmed direct records are stale and dropped. Queued untouched.
337
+ for (const r of state.records) {
338
+ if (r.kind === "harness" && r.delivery === "direct" && r.state === "admitted") {
339
+ if (r.text === prompt) r.eligible = true;
340
+ }
341
+ }
342
+ state.records = state.records.filter(
343
+ (r) => !(r.kind === "harness" && r.delivery === "direct" && r.state === "admitted" && !r.eligible),
344
+ );
345
+ prune(state);
346
+ });
347
+
348
+ onAny("message_start", (event) => {
349
+ const message = (event as { message?: unknown } | undefined)?.message;
350
+ if (!message || typeof message !== "object") return;
351
+ const text = userTextFromMessage(message);
352
+ state.processed.add(message as object);
353
+ if (!text) return;
354
+ const meta = selectRecord(state, text);
355
+ if (meta) state.pendingMeta.set(message as object, { meta, text });
356
+ });
357
+
358
+ onAny("message_end", (event) => {
359
+ const message = (event as { message?: unknown } | undefined)?.message;
360
+ if (!message || typeof message !== "object") return undefined;
361
+ if (!state.processed.has(message as object)) return undefined; // never re-select
362
+ const pending = state.pendingMeta.get(message as object);
363
+ if (!pending) return undefined;
364
+ state.pendingMeta.delete(message as object);
365
+ // Later transforms: attach only if the finalized text is still the
366
+ // selected text (original content identity preserved either way).
367
+ if (userTextFromMessage(message) !== pending.text) return undefined;
368
+ return { message: { ...(message as Record<string, unknown>), unipiHarness: pending.meta } };
369
+ });
370
+
371
+ // Terminal boundary: purge everything outstanding (queued records were
372
+ // consumed by their message_start before agent_end).
373
+ onAny("agent_settled", () => {
374
+ state.records = state.records.filter((r) => r.state === "consumed");
375
+ prune(state);
376
+ state.arms.length = 0;
377
+ });
378
+
379
+ // Fresh session context: pending state from the old tree is meaningless.
380
+ for (const evt of ["session_before_switch", "session_before_fork", "session_before_tree", "session_start", "session_shutdown"]) {
381
+ onAny(evt, () => {
382
+ state.records = [];
383
+ state.pendingMeta = new WeakMap();
384
+ state.processed = new WeakSet();
385
+ });
386
+ }
387
+ }
388
+
389
+ /** Test hook: inspect the per-API state. */
390
+ export function harnessStateForTests(pi: object): HarnessRegistryState {
391
+ return stateFor(pi);
392
+ }
@@ -45,3 +45,4 @@ export * from "./compaction-context.js";
45
45
  export * from "./src/settings/migrations.js";
46
46
  export * from "./src/tips/index.js";
47
47
  export * from "./src/tips/store.js";
48
+ export * from "./harness-messages.js";
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/core",
3
- "version": "3.0.0-alpha.19",
3
+ "version": "3.0.0-alpha.20",
4
4
  "description": "Shared utilities, event types, and constants for Unipi extension suite",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/footer",
3
- "version": "3.0.0-alpha.19",
3
+ "version": "3.0.0-alpha.20",
4
4
  "description": "Persistent status bar for Unipi — subscribes to UNIPI_EVENTS and renders key stats from all unipi packages",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -32,8 +32,8 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@pi-unipi/core": "3.0.0-alpha.19",
36
- "@pi-unipi/background-tasks": "3.0.0-alpha.19"
35
+ "@pi-unipi/core": "3.0.0-alpha.20",
36
+ "@pi-unipi/background-tasks": "3.0.0-alpha.20"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/fusion",
3
- "version": "3.0.0-alpha.19",
3
+ "version": "3.0.0-alpha.20",
4
4
  "description": "Devin-style model picker, fusion presets (lead + sidekick), and Local Fusion runtime for UniPi",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -32,9 +32,9 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@pi-unipi/core": "3.0.0-alpha.19",
36
- "@pi-unipi/subagents": "3.0.0-alpha.19",
37
- "@pi-unipi/utility": "3.0.0-alpha.19"
35
+ "@pi-unipi/core": "3.0.0-alpha.20",
36
+ "@pi-unipi/subagents": "3.0.0-alpha.20",
37
+ "@pi-unipi/utility": "3.0.0-alpha.20"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@earendil-works/pi-ai": "^0.87.1",