@oisin-ee/oisinpi 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # @oisin-ee/oisinpi
2
2
 
3
+ ## 0.5.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [`0170caa`](https://github.com/oisin-ee/oisinpi/commit/0170caa9c59c4e3d71409627b14925cefcc68679) Thanks [@momo-momokaya[bot]](https://github.com/momo-momokaya%5Bbot%5D)! - Bundle pi-wrangle 0.12.2. Show the current task and named agents and jobs above the editor. Alt+W and `/work` expand the same widget in place, without a popup or editor replacement.
8
+ - Updated dependencies []:
9
+ - @oisin-ee/pi-guardrails@0.5.1
10
+ - @oisin-ee/pi-prewalk@0.5.1
11
+ - @oisin-ee/pi-todo@0.5.1
12
+
13
+ ## 0.5.0
14
+
15
+ ### Minor Changes
16
+
17
+ - Bundle the released shared work strip and panel. Use Wrangle for background jobs and keep todo state on its Pi branch. Include updated guardrails that direct long work to Wrangle without relaxing timeout or wait checks.
18
+
19
+ ### Patch Changes
20
+
21
+ - Updated dependencies [`1f4a622`]:
22
+ - @oisin-ee/pi-guardrails@0.5.0
23
+ - @oisin-ee/pi-prewalk@0.5.0
24
+ - @oisin-ee/pi-todo@0.5.0
25
+
3
26
  ## 0.4.0
4
27
 
5
28
  ### Patch Changes
package/README.md CHANGED
@@ -6,7 +6,7 @@ Oisin's [pi](https://pi.dev) packages in one install, at versions tested togethe
6
6
  | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
7
7
  | [`@oisin-ee/pi-prewalk`](https://www.npmjs.com/package/@oisin-ee/pi-prewalk) | A frontier model plans and makes the first edit; a cheaper model finishes. |
8
8
  | [`@oisin-ee/pi-todo`](https://www.npmjs.com/package/@oisin-ee/pi-todo) | The `todo` tool, which also opens the prewalk gate. |
9
- | [`@oisin-ee/pi-wrangle`](https://www.npmjs.com/package/@oisin-ee/pi-wrangle) | The board, `/wrangle`, and the inbox for children and jobs across hosts. |
9
+ | [`@oisin-ee/pi-wrangle`](https://www.npmjs.com/package/@oisin-ee/pi-wrangle) | The inline `WORK` widget, `/work`, `/wrangle`, and the agent/job inbox. |
10
10
  | [`@oisin-ee/pi-guardrails`](https://www.npmjs.com/package/@oisin-ee/pi-guardrails) | Git safety, the wait gate, and the agent-typing gate on every tool call. |
11
11
 
12
12
  ```bash
@@ -16,3 +16,9 @@ pi install npm:@oisin-ee/oisinpi
16
16
  The collection bundles the four packages in its tarball and adds no code. No member imports another, so each one also
17
17
  installs by itself. pi-wrangle needs the `wrangle` binary at the same version
18
18
  ([oisin-ee/wrangle](https://github.com/oisin-ee/wrangle)).
19
+
20
+ The compact widget shows the current task and named agents and jobs above the editor.
21
+ Alt+W or `/work` expands that widget in place, without a popup. Typing stays in the editor.
22
+ Todos follow the active Pi branch. Agents and jobs follow the Herdr owner pane.
23
+ Use Wrangle `run`, `read`, and `stop` for background work. The collection does not require `pi-background-tasks`.
24
+ Standalone pi-todo keeps its own widget when Wrangle does not claim the shared display.
@@ -177,7 +177,9 @@ export const IMPERATIVE_MSG = {
177
177
  };
178
178
  export const WAIT_MSG = "wait-gate: blocked a command that waits instead of working (sleep, watch, a follow flag, a --watch or --wait " +
179
179
  "flag, or a wait subcommand). Never hold a tool call to wait, and never re-read a log to check progress. " +
180
- "Start long work so the host reports its exit: in pi, the `bg_task` tool; in Claude Code, `run_in_background` " +
180
+ "For long work in pi, use Wrangle `run` (mcp__wrangle__run) and continue other work. " +
181
+ "Use `read` (mcp__wrangle__read) for output and `stop` (mcp__wrangle__stop) to cancel the job. " +
182
+ "Start long work so the host reports its exit: in Claude Code, `run_in_background` " +
181
183
  "or Monitor (the exit arrives as a message); in Codex, exec_command, then empty write_stdin calls, which " +
182
184
  "return when the process exits. " +
183
185
  "For CI, use ci_find and ci_watch.";
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oisin-ee/pi-guardrails",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Agent guardrails for pi, Claude Code, and Codex: Git safety, no waiting, no typing into other agents, GitOps-only clusters.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -41,7 +41,8 @@ export function codeWaitViolation(
41
41
  }
42
42
  return CODE_WAITS.some((pattern) => pattern.test(code))
43
43
  ? "wait-gate: blocked a sleep or timer wait in sandbox code. Never hold a tool call to wait. Start long work with " +
44
- "the `bg_task` tool and continue other work; its exit arrives as a message."
44
+ "Wrangle `run` (mcp__wrangle__run) and continue other work. Use `read` (mcp__wrangle__read) for output " +
45
+ "and `stop` (mcp__wrangle__stop) to cancel the job."
45
46
  : null;
46
47
  }
47
48
 
@@ -61,7 +62,8 @@ function limitMessage(
61
62
  return (
62
63
  `timeout-gate: blocked ${toolName} ${field}=${value}. The ${field} unit is ${unit === "s" ? "seconds" : "milliseconds"} ` +
63
64
  `and the limit is ${max} (${MAX_TIMEOUT_SECONDS / 60} minutes).${unitHint} ` +
64
- "For a longer job, start it with the `bg_task` tool and continue other work; its exit arrives as a message. " +
65
+ "For a longer job, start it with Wrangle `run` (mcp__wrangle__run) and continue other work. " +
66
+ "Use `read` (mcp__wrangle__read) for output and `stop` (mcp__wrangle__stop) to cancel the job. " +
65
67
  "For CI, use the ci_find and ci_watch tools."
66
68
  );
67
69
  }
@@ -229,7 +229,9 @@ export const IMPERATIVE_MSG = {
229
229
  export const WAIT_MSG =
230
230
  "wait-gate: blocked a command that waits instead of working (sleep, watch, a follow flag, a --watch or --wait " +
231
231
  "flag, or a wait subcommand). Never hold a tool call to wait, and never re-read a log to check progress. " +
232
- "Start long work so the host reports its exit: in pi, the `bg_task` tool; in Claude Code, `run_in_background` " +
232
+ "For long work in pi, use Wrangle `run` (mcp__wrangle__run) and continue other work. " +
233
+ "Use `read` (mcp__wrangle__read) for output and `stop` (mcp__wrangle__stop) to cancel the job. " +
234
+ "Start long work so the host reports its exit: in Claude Code, `run_in_background` " +
233
235
  "or Monitor (the exit arrives as a message); in Codex, exec_command, then empty write_stdin calls, which " +
234
236
  "return when the process exits. " +
235
237
  "For CI, use ci_find and ci_watch.";
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oisin-ee/pi-prewalk",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "A one-shot planner-to-executor handoff for pi, with branch-backed Rust rules.",
5
5
  "keywords": [
6
6
  "pi-extension",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oisin-ee/pi-todo",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "A todo tool for pi with a Rust core: one active task, state on the session branch.",
5
5
  "keywords": [
6
6
  "pi-extension",
@@ -466,11 +466,16 @@ The caller is the harness's `HERDR_PANE_ID`. Pi registers the server from its ex
466
466
 
467
467
  The extension adds host status before each prompt, registers the tools, and draws:
468
468
 
469
- - **The board widget** above the editor: `wrangle board --json` for this pane, every ten seconds. The header counts each group and shows a meter per host. On a narrow terminal the activity column goes first, then the host. An empty board removes the widget.
469
+ - **The `WORK` widget** above the editor shows the current task and named agents and jobs. Agent/job data refreshes every ten seconds. Compatible pi-todo snapshots update immediately. Empty providers hide the widget. Unavailable providers show an error.
470
+ - **Alt+W or `/work`** expands or collapses that widget in place, without a popup or editor replacement. Alt+J/K moves selection, Alt+] changes views, and Alt+I shows inline details. Typing stays in the editor. Alt+S stops jobs, Alt+C cancels queued work, and Alt+M sends agent messages. Stop and cancel require confirmation. Completed job history starts collapsed.
470
471
  - **`/wrangle`**, the control panel, with four tabs: Status (hosts with their retry state, then your rows; Enter on a host reads it again now), Hosts (reconnect, rename, enable, disable, add), Profiles (Enter edits `prompt.md`), and Inbox. The widget steps aside while the panel is open.
471
472
  - **The inbox reader** and a renderer that shows each record's kind, sender, and text, folded past twelve lines.
472
473
 
473
- Without `HERDR_PANE_ID` the extension starts nothing. `$WRANGLE_BIN` selects the binary.
474
+ Without `HERDR_PANE_ID`, the extension starts no Wrangle polling or jobs. The shared display can still show todos and reports Wrangle unavailable. `$WRANGLE_BIN` selects the binary.
475
+
476
+ Todos follow the active Pi session branch. Agents and jobs follow the calling Herdr owner pane, including after a Pi session change. `/todos` and Alt+T open the Todos view when compatible pi-todo is active. `/todos list` remains a plain readout. The [display contract](docs/pi-work.md) keeps task state and reminders in pi-todo. Wrangle retains execution, logs, cancellation, and completion delivery.
477
+
478
+ The Jobs view joins retained records to the current owner's ledger by host and job ID. It does not show unrelated fleet jobs. Job inspection uses `wrangle read` for a bounded log tail and shows the `wrangle logs` command for full output. Expired records and unavailable hosts produce explicit errors. The panel never starts a new process manager, edits todos, or writes into an agent terminal.
474
479
 
475
480
  The Herdr plugin keeps only the status-board popup:
476
481
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oisin-ee/pi-wrangle",
3
- "version": "0.11.0",
3
+ "version": "0.12.2",
4
4
  "description": "Pi extension for wrangle: the board, the /wrangle panel, the inbox reader, and the wrangle tools. Needs the wrangle binary of the same version.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,9 +1,13 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { Box, Text } from "@earendil-works/pi-tui";
4
- import { type Board, clean, parseBoard, renderBoard } from "./board.ts";
3
+ import { Box, Text, Key } from "@earendil-works/pi-tui";
4
+ import { type Board, clean, parseBoard } from "./board.ts";
5
+ import { WorkTodo } from "./work-todo.ts";
6
+ import { workStrip, workText, type WorkState } from "./work.ts";
5
7
  import { type Details, InboxReader, MESSAGE_TYPE } from "./inbox.ts";
6
8
  import { openPanel } from "./panel.ts";
9
+ import { loadJobHistory, type JobHistory } from "./work-history.ts";
10
+ import { WorkPanel, WorkDetail, runWorkAction, type WorkAction, type WorkCursor, type WorkData, type WorkTab } from "./work-panel.ts";
7
11
 
8
12
  const REFRESH_MS = 10_000;
9
13
 
@@ -57,13 +61,90 @@ export default function wrangle(pi: ExtensionAPI) {
57
61
  });
58
62
 
59
63
  let panelOpen = false;
64
+ let workOpen = false;
65
+ let history: JobHistory = { jobs: [] };
66
+ let workPanel: WorkPanel | undefined;
67
+ let workDetail: WorkDetail | undefined;
68
+ let detail: { title: string; body: string } | undefined;
69
+ let cursor: WorkCursor = { tab: "all", selected: undefined, history: false };
70
+ let workAbort: AbortController | undefined;
71
+ let context: ExtensionContext | undefined;
72
+ const todos = new WorkTodo(pi.events, () => { if (context) draw(context); }, () => {
73
+ if (context) void openWork(context, "todos").catch(error => context?.ui.notify(String(error), "error"));
74
+ });
75
+
76
+ function state(): WorkState {
77
+ return { board, todo: todos.current, error: failure ?? todos.error };
78
+ }
79
+
80
+ function workData(): WorkData { return { ...state(), history }; }
81
+
82
+ function collapseWork(ctx: ExtensionContext): void {
83
+ workAbort?.abort();
84
+ workAbort = undefined;
85
+ workOpen = false;
86
+ workPanel = undefined;
87
+ workDetail = undefined;
88
+ detail = undefined;
89
+ history = { jobs: [] };
90
+ draw(ctx);
91
+ }
92
+
93
+ async function openWork(ctx: ExtensionContext, tab: WorkTab): Promise<void> {
94
+ if (ctx.mode !== "tui") { ctx.ui.notify(workText(state()), "info"); return; }
95
+ if (panelOpen) return;
96
+ workOpen = true;
97
+ detail = undefined;
98
+ workDetail = undefined;
99
+ cursor = { tab, selected: undefined, history: false };
100
+ workPanel = undefined;
101
+ const current = generation;
102
+ draw(ctx);
103
+ // Draw immediately; remote history must not delay expansion or typing.
104
+ if (refresh) await refresh;
105
+ if (current === generation && workOpen) await update(ctx);
106
+ }
107
+
108
+ async function act(ctx: ExtensionContext, action: WorkAction | undefined): Promise<void> {
109
+ if (!action) { collapseWork(ctx); return; }
110
+ if (workAbort) return;
111
+ const interaction = new AbortController();
112
+ workAbort = interaction;
113
+ const current = generation;
114
+ try {
115
+ const body = await runWorkAction(action, ctx, exec, interaction.signal);
116
+ if (current !== generation || interaction.signal.aborted) return;
117
+ if (body !== undefined) { detail = { title: action.entry.label, body }; workDetail = undefined; }
118
+ draw(ctx);
119
+ await update(ctx);
120
+ } catch (error) {
121
+ if (current === generation && !interaction.signal.aborted) {
122
+ detail = { title: "Work action unavailable", body: String(error) };
123
+ workDetail = undefined;
124
+ draw(ctx);
125
+ }
126
+ } finally {
127
+ if (workAbort === interaction) workAbort = undefined;
128
+ }
129
+ }
60
130
 
61
131
  function draw(ctx: ExtensionContext) {
62
- // The panel lists the same rows; while it is open the widget steps aside.
63
- ctx.ui.setWidget("wrangle", panelOpen || board.rows.length === 0 ? undefined : (_tui, theme) => ({
64
- render: (width: number) => renderBoard(board, width, theme),
65
- invalidate: () => {},
66
- }), { placement: "aboveEditor" });
132
+ if (ctx.mode !== "tui") return;
133
+ workPanel?.update(workData());
134
+ const data = state();
135
+ ctx.ui.setWidget("wrangle", panelOpen || (!workOpen && !data.board.rows.length && !data.todo?.tasks.length && !data.error) ? undefined : (tui, theme) => {
136
+ if (workOpen) {
137
+ if (detail) {
138
+ workDetail ??= new WorkDetail(detail.title, detail.body, theme, () => tui.terminal.rows, () => tui.requestRender());
139
+ workDetail.setTheme(theme);
140
+ return workDetail;
141
+ }
142
+ workDetail = undefined;
143
+ workPanel = new WorkPanel(workData(), cursor, theme, action => { void act(ctx, action); }, () => tui.requestRender(), () => tui.terminal.rows);
144
+ return workPanel;
145
+ }
146
+ return { render: (width: number) => workStrip(data, width, theme), invalidate() {} };
147
+ }, { placement: "aboveEditor" });
67
148
  }
68
149
 
69
150
  function update(ctx: ExtensionContext): Promise<void> {
@@ -75,15 +156,22 @@ export default function wrangle(pi: ExtensionAPI) {
75
156
  if (ctx.mode !== "tui") return;
76
157
  try {
77
158
  const next = parseBoard(JSON.parse(await exec(["board", "--json"])));
78
- failure = undefined;
79
159
  if (current !== generation) return;
160
+ failure = undefined;
80
161
  board = next;
162
+ if (workOpen) {
163
+ const loaded = await loadJobHistory(exec, owner);
164
+ if (current !== generation) return;
165
+ if (workOpen) history = loaded;
166
+ }
81
167
  draw(ctx);
82
168
  } catch (error) {
83
169
  // Say so once, not on every refresh; the last board stays on screen.
84
170
  const text = String(error);
171
+ if (current !== generation) return;
85
172
  if (text !== failure) ctx.ui.notify(text, "error");
86
173
  failure = text;
174
+ draw(ctx);
87
175
  }
88
176
  })().finally(() => { refresh = undefined; });
89
177
  return refresh;
@@ -92,7 +180,18 @@ export default function wrangle(pi: ExtensionAPI) {
92
180
  pi.on("session_start", async (_event, ctx) => {
93
181
  if (timer) clearInterval(timer);
94
182
  generation++;
95
- if (!owner) return;
183
+ workAbort?.abort();
184
+ workAbort = undefined;
185
+ workOpen = false;
186
+ workPanel = undefined;
187
+ workDetail = undefined;
188
+ detail = undefined;
189
+ history = { jobs: [] };
190
+ context = ctx;
191
+ board = { hosts: [], rows: [] };
192
+ failure = owner ? undefined : "Wrangle needs a Herdr owner pane";
193
+ todos.start(ctx);
194
+ if (!owner) { draw(ctx); return; }
96
195
  timer = setInterval(() => { void update(ctx); }, REFRESH_MS);
97
196
  timer.unref();
98
197
  await inbox.start(ctx);
@@ -106,9 +205,17 @@ export default function wrangle(pi: ExtensionAPI) {
106
205
  });
107
206
  pi.on("session_shutdown", (_event, ctx) => {
108
207
  generation++;
208
+ workAbort?.abort();
209
+ workAbort = undefined;
210
+ workOpen = false;
211
+ workPanel = undefined;
212
+ workDetail = undefined;
213
+ detail = undefined;
109
214
  if (timer) clearInterval(timer);
110
215
  timer = undefined;
111
216
  inbox.stop();
217
+ todos.stop();
218
+ context = undefined;
112
219
  if (ctx.mode === "tui") ctx.ui.setWidget("wrangle", undefined);
113
220
  });
114
221
  // A delivered message is in the transcript once Pi ends it: only then is it acknowledged.
@@ -124,6 +231,29 @@ export default function wrangle(pi: ExtensionAPI) {
124
231
  return;
125
232
  }
126
233
  });
234
+ async function toggleWork(ctx: ExtensionContext): Promise<void> {
235
+ if (workOpen) collapseWork(ctx);
236
+ else await openWork(ctx, "all");
237
+ }
238
+ pi.registerCommand("work", {
239
+ description: "Expand or collapse work above the editor (Alt+W)",
240
+ handler: async (_args, ctx) => { await toggleWork(ctx); },
241
+ });
242
+ pi.registerShortcut(Key.alt("w"), { description: "Expand or collapse work above the editor", handler: toggleWork });
243
+ for (const [key, input, description] of [
244
+ ["j", "\u001b[B", "Next work item / scroll down"], ["k", "\u001b[A", "Previous work item / scroll up"],
245
+ ["]", "\t", "Next work view"], ["i", "\r", "Inspect work / return to list"],
246
+ ["s", "s", "Stop selected job"], ["c", "c", "Cancel selected queued work"],
247
+ ["m", "m", "Message selected agent"], ["r", "r", "Refresh work"],
248
+ ] as const) {
249
+ pi.registerShortcut(Key.alt(key), { description, handler: async (_ctx) => {
250
+ if (!workOpen || panelOpen || workAbort || !context) return;
251
+ if (detail) {
252
+ if (key === "i") { detail = undefined; draw(context); }
253
+ else if (key === "j" || key === "k") workDetail?.handleInput(input);
254
+ } else workPanel?.handleInput(input);
255
+ } });
256
+ }
127
257
  pi.registerCommand("wrangle", {
128
258
  description: "Hosts, your children and jobs, profiles, and messages",
129
259
  handler: async (_args, ctx) => {
@@ -131,6 +261,7 @@ export default function wrangle(pi: ExtensionAPI) {
131
261
  ctx.ui.notify(await exec(["board"]), "info");
132
262
  return;
133
263
  }
264
+ if (panelOpen || workAbort) return;
134
265
  panelOpen = true;
135
266
  draw(ctx);
136
267
  try {
@@ -0,0 +1,33 @@
1
+ import { clean } from "./board.ts";
2
+
3
+ export interface WorkJob {
4
+ id: string; host: string; owner: string; cmd: string[]; state: string;
5
+ ended_ms: number | null; exit: number | null;
6
+ }
7
+ export interface JobHistory { jobs: WorkJob[]; hosts?: { id: string; label: string }[]; error?: string }
8
+ export type WorkExec = (args: string[]) => Promise<string>;
9
+ function record(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === "object"; }
10
+ function job(value: unknown): value is WorkJob {
11
+ return record(value) && ["id", "host", "owner", "state"].every(key => typeof value[key] === "string")
12
+ && Array.isArray(value["cmd"]) && value["cmd"].every(part => typeof part === "string")
13
+ && (value["ended_ms"] === null || typeof value["ended_ms"] === "number")
14
+ && (value["exit"] === null || typeof value["exit"] === "number");
15
+ }
16
+
17
+ /** History comes from the owner's ledger, not a coincidentally equal pane on another host. */
18
+ export async function loadJobHistory(exec: WorkExec, owner: string): Promise<JobHistory> {
19
+ try {
20
+ const [itemText, jobText, hostText] = await Promise.all([exec(["items", "--owner", owner, "--json"]), exec(["ps", "--all", "--json"]), exec(["machine", "list", "--json"])]);
21
+ const items: unknown = JSON.parse(itemText), jobs: unknown = JSON.parse(jobText), hosts: unknown = JSON.parse(hostText);
22
+ if (!Array.isArray(hosts) || !hosts.every((host): host is { id: string; label: string } => record(host) && typeof host["id"] === "string" && typeof host["label"] === "string")) throw new Error("Invalid Wrangle host identities");
23
+ if (!record(items) || !Array.isArray(items["items"]) || !Array.isArray(jobs) || !jobs.every(job)) throw new Error("Invalid Wrangle job history");
24
+ const allowed = new Set<string>();
25
+ for (const item of items["items"]) {
26
+ if (!record(item) || typeof item["id"] !== "string" || typeof item["host"] !== "string" || typeof item["owner_pane"] !== "string") throw new Error("Invalid Wrangle owner item");
27
+ if (item["owner_pane"] === owner && item["kind"] === "job") allowed.add(JSON.stringify([item["host"], item["id"]]));
28
+ }
29
+ return { jobs: jobs.filter(item => allowed.has(JSON.stringify([item.host, item.id]))), hosts };
30
+ } catch (error) {
31
+ return { jobs: [], error: `History unavailable: ${clean(String(error))}` };
32
+ }
33
+ }
@@ -0,0 +1,148 @@
1
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Key, matchesKey, SelectList, Text, ScrollView, truncateToWidth } from "@earendil-works/pi-tui";
3
+ import { clean } from "./board.ts";
4
+ import type { WorkState } from "./work.ts";
5
+ import type { JobHistory, WorkExec } from "./work-history.ts";
6
+
7
+ export type WorkTab = "all" | "todos" | "agents" | "jobs";
8
+ const tabs: WorkTab[] = ["all", "todos", "agents", "jobs"];
9
+ export interface WorkEntry {
10
+ value: string; label: string; description?: string;
11
+ kind: "todo" | "agent" | "job" | "queued" | "history" | "empty";
12
+ id?: string; host?: string; pane?: string; detail?: string; running?: boolean;
13
+ }
14
+ export interface WorkData extends WorkState { history: JobHistory }
15
+ export interface WorkCursor { tab: WorkTab; selected: string | undefined; history: boolean }
16
+ export interface WorkAction { action: "inspect" | "stop" | "cancel" | "send" | "refresh"; entry: WorkEntry; cursor: WorkCursor }
17
+
18
+ /** The same content in every tab, without task mutation or duplicate job rows. */
19
+ export function workEntries(data: WorkData, tab: WorkTab, history: boolean): WorkEntry[] {
20
+ const todos: WorkEntry[] = (data.todo?.tasks ?? []).map(task => ({ value: `todo:${task.content}`, label: `${task.status === "in_progress" ? "→" : task.status === "completed" ? "✓" : task.status === "abandoned" ? "−" : "·"} ${clean(task.content)}`, description: task.status, kind: "todo", detail: task.content }));
21
+ const canonicalHost = (label: string) => data.history.hosts?.find(host => host.label === label)?.id ?? label;
22
+ const rows: WorkEntry[] = data.board.rows.map(row => ({ value: `${row.kind}:${canonicalHost(row.host)}:${row.id}`, id: row.id, host: canonicalHost(row.host), ...(row.pane ? { pane: row.pane } : {}), kind: row.group === "waiting" ? "queued" : row.kind === "job" ? "job" : "agent", label: `${row.group === "attention" || row.group === "to_land" ? "!" : "●"} ${clean(row.name)} · ${clean(row.state)}`, description: clean(row.host), running: row.group === "running" }));
23
+ const visible = tab === "todos" ? todos : tab === "agents" ? rows.filter(row => row.value.startsWith("agent:")) : tab === "jobs" ? rows.filter(row => row.value.startsWith("job:")) : [...rows.filter(row => row.label.startsWith("!")), ...todos.filter(row => row.description === "in_progress"), ...rows.filter(row => !row.label.startsWith("!")), ...todos.filter(row => row.description === "pending")];
24
+ if (tab === "all" || tab === "jobs") {
25
+ const known = new Set(rows.map(row => row.value));
26
+ const done = data.history.jobs.filter(job => job.ended_ms !== null && !known.has(`job:${job.host}:${job.id}`));
27
+ if (done.length) visible.push({ value: "history", kind: "history", label: `${history ? "▾" : "▸"} Completed jobs (${done.length})` });
28
+ if (history) visible.push(...done.map(job => ({ value: `job:${job.host}:${job.id}`, id: job.id, host: job.host, kind: "job" as const, label: `${job.exit === 0 ? "✓" : "!"} ${clean(job.cmd.join(" "))}`, description: `${clean(job.state)} · exit ${job.exit ?? "unknown"} · ${clean(job.host)}`, running: false })));
29
+ if (data.history.error) visible.push({ value: "history-error", kind: "empty", label: data.history.error });
30
+ }
31
+ if (data.error) visible.unshift({ value: "error", kind: "empty", label: clean(data.error) });
32
+ return visible.length ? visible : [{ value: "empty", kind: "empty", label: "No work in this view." }];
33
+ }
34
+
35
+ /** One interaction. The existing refresh coordinator supplies changes without another timer. */
36
+ export class WorkPanel {
37
+ private list!: SelectList;
38
+ private entries: WorkEntry[] = [];
39
+ private data: WorkData;
40
+ readonly cursor: WorkCursor;
41
+ private readonly theme: Theme;
42
+ private readonly done: (action: WorkAction | undefined) => void;
43
+ private readonly renderRequest: () => void;
44
+ private readonly rows: () => number;
45
+ private height = 0;
46
+
47
+ constructor(data: WorkData, cursor: WorkCursor, theme: Theme, done: (action: WorkAction | undefined) => void, request: () => void, rows: () => number) {
48
+ this.data = data; this.cursor = cursor; this.theme = theme; this.done = done; this.renderRequest = request; this.rows = rows;
49
+ this.rebuild();
50
+ }
51
+ update(data: WorkData): void { this.data = data; this.rebuild(); this.renderRequest(); }
52
+ private listHeight(): number { return Math.max(1, Math.min(12, Math.floor(this.rows() / 2) - 4)); }
53
+ private rebuild(preserveSelection = true): void {
54
+ if (preserveSelection) this.cursor.selected = this.list?.getSelectedItem()?.value ?? this.cursor.selected;
55
+ this.entries = workEntries(this.data, this.cursor.tab, this.cursor.history);
56
+ this.height = this.listHeight();
57
+ this.list = new SelectList(this.entries, this.height, {
58
+ selectedPrefix: text => this.theme.fg("accent", text), selectedText: text => this.theme.fg("accent", text),
59
+ description: text => this.theme.fg("muted", text), scrollInfo: text => this.theme.fg("dim", text), noMatch: text => this.theme.fg("muted", text),
60
+ }, { minPrimaryColumnWidth: 8, maxPrimaryColumnWidth: 60 });
61
+ this.list.setSelectedIndex(Math.max(0, this.entries.findIndex(entry => entry.value === this.cursor.selected)));
62
+ this.list.onCancel = () => this.done(undefined);
63
+ this.list.onSelect = () => this.act("inspect");
64
+ }
65
+ private act(action: WorkAction["action"]): void {
66
+ const selected = this.list.getSelectedItem();
67
+ const entry = this.entries.find(item => item.value === selected?.value);
68
+ if (!entry) return;
69
+ if (entry.kind === "history" && action === "inspect") { this.cursor.history = !this.cursor.history; this.rebuild(); this.renderRequest(); return; }
70
+ if (entry.kind === "empty" && action !== "refresh") return;
71
+ if (action === "stop" && (entry.kind !== "job" || !entry.running)) return;
72
+ if (action === "cancel" && entry.kind !== "queued") return;
73
+ if (action === "send" && entry.kind !== "agent") return;
74
+ this.cursor.selected = entry.value;
75
+ this.done({ action, entry, cursor: this.cursor });
76
+ }
77
+ handleInput(input: string): void {
78
+ const step = matchesKey(input, Key.tab) ? 1 : matchesKey(input, Key.shift("tab")) ? -1 : 0;
79
+ if (step) { this.cursor.tab = tabs[(tabs.indexOf(this.cursor.tab) + step + tabs.length) % tabs.length]!; this.cursor.selected = undefined; this.rebuild(false); this.renderRequest(); return; }
80
+ if (input === "r") this.act("refresh");
81
+ else if (input === "s") this.act("stop");
82
+ else if (input === "c") this.act("cancel");
83
+ else if (input === "m") this.act("send");
84
+ else this.list.handleInput(input);
85
+ this.renderRequest();
86
+ }
87
+ invalidate(): void { this.list.invalidate(); }
88
+ render(width: number): string[] {
89
+ if (this.height !== this.listHeight()) this.rebuild();
90
+ const tabbar = tabs.map(tab => tab === this.cursor.tab ? `[${tab.toUpperCase()}]` : tab).join(" · ");
91
+ return [this.theme.fg("accent", `WORK ${tabbar}`), ...this.list.render(width), this.theme.fg("dim", "Alt+W collapse · Alt+J/K select · Alt+] view · Alt+I inspect"), this.theme.fg("dim", "Alt+S stop · Alt+C cancel · Alt+M message · Alt+R refresh")].map(line => truncateToWidth(line, width));
92
+ }
93
+ }
94
+
95
+ /** Strip terminal control sequences from output while retaining line breaks. */
96
+ function safeOutput(value: string): string {
97
+ return value.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "").replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").split("\n").map(clean).join("\n");
98
+ }
99
+
100
+ export async function inspectEntry(entry: WorkEntry, exec: WorkExec): Promise<string> {
101
+ if (entry.kind === "todo") return entry.detail ?? "";
102
+ if (entry.kind === "queued") return `${entry.label}\n${entry.description ?? ""}\nWaiting for placement. Cancel explicitly to remove this ticket.`;
103
+ if (!entry.id) return "This record is unavailable.";
104
+ const output = await exec(["read", entry.kind === "job" ? entry.id : entry.pane ?? entry.id, "--host", entry.host ?? "local"]);
105
+ const hint = entry.kind === "job" ? `Recent job output (Wrangle's bounded tail).\nFull log: wrangle logs ${clean(entry.id)}\n\n` : "";
106
+ return hint + safeOutput(output);
107
+ }
108
+
109
+ /** Explicit actions may open confirmation/input dialogs; the work widget stays inline. */
110
+ export async function runWorkAction(action: WorkAction, ctx: ExtensionContext, exec: WorkExec, signal: AbortSignal): Promise<string | undefined> {
111
+ if (signal.aborted) return undefined;
112
+ const entry = action.entry;
113
+ if (action.action === "inspect") return inspectEntry(entry, exec);
114
+ if (action.action === "stop" && entry.kind === "job" && entry.running && entry.id && await ctx.ui.confirm("Stop job?", clean(entry.label), { signal }) && !signal.aborted) await exec(["stop", entry.id]);
115
+ if (action.action === "cancel" && entry.kind === "queued" && entry.id && await ctx.ui.confirm("Cancel queued work?", clean(entry.label), { signal }) && !signal.aborted) await exec(["cancel", entry.id]);
116
+ if (action.action === "send" && entry.kind === "agent") {
117
+ const message = await ctx.ui.input(`Message ${clean(entry.label)}`, undefined, { signal });
118
+ if (!signal.aborted && message?.trim() && entry.id) await exec(["send", entry.pane ?? entry.id, "--host", entry.host ?? "local", "--message", message]);
119
+ }
120
+ return undefined;
121
+ }
122
+
123
+ /** Details occupy the same above-editor widget, never an overlay. */
124
+ export class WorkDetail {
125
+ private readonly text: Text;
126
+ private readonly scroll: ScrollView;
127
+ private readonly title: string;
128
+ private theme: Theme;
129
+ private readonly rows: () => number;
130
+ private readonly request: () => void;
131
+ constructor(title: string, body: string, theme: Theme, rows: () => number, request: () => void) {
132
+ this.title = title; this.theme = theme; this.rows = rows; this.request = request;
133
+ this.text = new Text(safeOutput(body) || "No output yet.", 0, 0);
134
+ this.scroll = new ScrollView(this.text, { overscroll: "contain", scrollbar: "hidden" });
135
+ }
136
+ render(width: number): string[] {
137
+ const height = Math.max(1, Math.min(12, Math.floor(this.rows() / 2) - 2));
138
+ this.scroll.updateLayout(this.text.render(width).length, height, this.request);
139
+ return [this.theme.fg("accent", truncateToWidth(this.title, width)), ...this.scroll.render(width).slice(this.scroll.scrollTop, this.scroll.scrollTop + height), this.theme.fg("dim", truncateToWidth("Alt+J/K scroll · Alt+I back · Alt+W collapse", width))];
140
+ }
141
+ setTheme(theme: Theme): void { this.theme = theme; this.invalidate(); }
142
+ invalidate(): void { this.text.invalidate(); this.scroll.invalidate(); }
143
+ handleInput(input: string): void {
144
+ if (matchesKey(input, Key.up)) this.scroll.scrollBy(-1);
145
+ if (matchesKey(input, Key.down)) this.scroll.scrollBy(1);
146
+ this.request();
147
+ }
148
+ }
@@ -0,0 +1,114 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { randomUUID } from "node:crypto";
3
+
4
+ export interface WorkTask { content: string; status: "pending" | "in_progress" | "completed" | "abandoned" }
5
+ export interface TodoSnapshot {
6
+ version: 1;
7
+ presenter: string;
8
+ request: string;
9
+ publisher: string;
10
+ revision: number;
11
+ session: string;
12
+ branch: string | null;
13
+ tasks: WorkTask[];
14
+ }
15
+
16
+ type Bus = ExtensionAPI["events"];
17
+ function record(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null; }
18
+
19
+ /** Validate the independently installed provider's wire envelope, not rendered widget text. */
20
+ export function snapshot(value: unknown): TodoSnapshot | undefined {
21
+ if (!record(value) || value["version"] !== 1 || typeof value["presenter"] !== "string" || typeof value["request"] !== "string"
22
+ || typeof value["publisher"] !== "string" || typeof value["session"] !== "string"
23
+ || (value["branch"] !== null && typeof value["branch"] !== "string")
24
+ || !Number.isSafeInteger(value["revision"]) || Number(value["revision"]) < 1 || !Array.isArray(value["tasks"])) return undefined;
25
+ if (!value["tasks"].every((task: unknown) => record(task) && typeof task["content"] === "string"
26
+ && ["pending", "in_progress", "completed", "abandoned"].includes(String(task["status"])))) return undefined;
27
+ return value as unknown as TodoSnapshot;
28
+ }
29
+
30
+ /** A session-scoped display lease. The provider remains the only task-state writer. */
31
+ export class WorkTodo {
32
+ private readonly bus: Bus;
33
+ private readonly changed: () => void;
34
+ private readonly open: () => void;
35
+ private subscriptions: (() => void)[] = [];
36
+ private ctx: ExtensionContext | undefined;
37
+ private presenter = "";
38
+ private request = "";
39
+ private retired = new Set<string>();
40
+ current: TodoSnapshot | undefined;
41
+ error: string | undefined;
42
+
43
+ constructor(bus: Bus, changed: () => void, open: () => void) { this.bus = bus; this.changed = changed; this.open = open; }
44
+
45
+ start(ctx: ExtensionContext): void {
46
+ this.stop();
47
+ if (ctx.mode !== "tui") return;
48
+ this.ctx = ctx;
49
+ this.presenter = randomUUID();
50
+ this.retired.clear();
51
+ this.subscriptions = [
52
+ this.bus.on("oisin.todo.ready", value => {
53
+ if (!record(value) || value["version"] !== 1 || typeof value["publisher"] !== "string") { this.reject(); return; }
54
+ if (this.retired.has(value["publisher"])) return;
55
+ this.ask();
56
+ }),
57
+ this.bus.on("oisin.todo.snapshot", value => this.receive(value)),
58
+ this.bus.on("oisin.todo.stop", value => {
59
+ if (!record(value) || !this.current || value["publisher"] !== this.current.publisher) return;
60
+ this.retired.add(this.current.publisher);
61
+ this.current = undefined;
62
+ this.changed();
63
+ }),
64
+ this.bus.on("oisin.work.open", value => {
65
+ if (record(value) && value["version"] === 1 && value["presenter"] === this.presenter && value["request"] === this.request) this.open();
66
+ }),
67
+ ];
68
+ this.ask();
69
+ }
70
+
71
+ private ask(): void {
72
+ this.request = randomUUID();
73
+ this.bus.emit("oisin.work.request", { version: 1, presenter: this.presenter, request: this.request });
74
+ }
75
+
76
+ private reject(): void {
77
+ this.error = "Todo integration unavailable";
78
+ this.release();
79
+ this.changed();
80
+ }
81
+
82
+ private receive(value: unknown): void {
83
+ if (!record(value) || value["presenter"] !== this.presenter || value["request"] !== this.request) return;
84
+ const next = snapshot(value);
85
+ if (!next) { this.reject(); return; }
86
+ if (!this.ctx || next.session !== this.ctx.sessionManager.getSessionId() || next.branch !== this.ctx.sessionManager.getLeafId() || this.retired.has(next.publisher)) return;
87
+ if (this.current?.publisher === next.publisher && next.revision <= this.current.revision) return;
88
+ if (this.current && this.current.publisher !== next.publisher) this.retired.add(this.current.publisher);
89
+ this.current = next;
90
+ this.error = undefined;
91
+ try {
92
+ // The strip must render successfully before its provider relinquishes the widget.
93
+ this.changed();
94
+ this.bus.emit("oisin.work.claim", { version: 1, presenter: this.presenter, request: this.request, publisher: next.publisher, revision: next.revision });
95
+ } catch (error) {
96
+ this.error = "Todo integration unavailable";
97
+ this.release();
98
+ this.ctx.ui.notify(`Work display unavailable: ${String(error)}`, "error");
99
+ }
100
+ }
101
+
102
+ private release(): void {
103
+ if (this.presenter) this.bus.emit("oisin.work.release", { version: 1, presenter: this.presenter, request: this.request });
104
+ this.current = undefined;
105
+ }
106
+
107
+ stop(): void {
108
+ for (const off of this.subscriptions) off();
109
+ this.subscriptions = [];
110
+ this.release();
111
+ this.ctx = undefined;
112
+ this.error = undefined;
113
+ }
114
+ }
@@ -0,0 +1,57 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { age, clean, type Board, type Paint, type Row } from "./board.ts";
3
+ import type { TodoSnapshot } from "./work-todo.ts";
4
+
5
+ export interface WorkState {
6
+ board: Board;
7
+ todo: TodoSnapshot | undefined;
8
+ error: string | undefined;
9
+ }
10
+
11
+ /** A small, persistent readout: names and current work, not just counts. */
12
+ export function workStrip(state: WorkState, width: number, paint: Paint): string[] {
13
+ const { board, todo, error } = state;
14
+ if (!board.rows.length && !todo?.tasks.length && !error) return [];
15
+ const lines = [paint.fg("dim", "WORK · Alt+W expand")];
16
+ if (error) lines.push(paint.fg("warning", `! unavailable · ${clean(error)}`));
17
+ const tasks = todo?.tasks ?? [];
18
+ const active = tasks.find(task => task.status === "in_progress");
19
+ const next = tasks.find(task => task.status === "pending");
20
+ const current = active ?? next;
21
+ if (current) lines.push(`${paint.fg("accent", active ? "→ Task" : "· Next")} ${clean(current.content)}`);
22
+ else if (tasks.length) lines.push(paint.fg("muted", `Tasks · ${tasks.filter(task => task.status === "completed").length}/${tasks.length} completed`));
23
+ const priority = (row: Row) => row.group === "attention" || row.group === "to_land" ? 0 : row.group === "running" ? 1 : 2;
24
+ for (const [label, members] of [
25
+ ["Agents", board.rows.filter(row => row.kind !== "job")],
26
+ ["Jobs", board.rows.filter(row => row.kind === "job")],
27
+ ] as const) {
28
+ const rows = [...members].sort((a, b) => priority(a) - priority(b));
29
+ if (!rows.length) continue;
30
+ let line = `${label} `;
31
+ let shown = 0;
32
+ for (const row of rows) {
33
+ const warning = priority(row) === 0;
34
+ const entry = `${warning ? "! " : ""}${clean(row.name)} · ${clean(row.state)} · ${age(row.age_ms)}`;
35
+ const remaining = rows.length - shown - 1;
36
+ const suffix = remaining ? ` +${remaining} more` : "";
37
+ const join = shown ? " | " : "";
38
+ if (shown && visibleWidth(line + join + entry + suffix) > width) break;
39
+ const room = Math.max(0, width - visibleWidth(line + join + suffix));
40
+ line += join + paint.fg(warning ? "warning" : "muted", truncateToWidth(entry, room));
41
+ shown++;
42
+ }
43
+ if (shown < rows.length) line += paint.fg("dim", ` +${rows.length - shown} more`);
44
+ lines.push(line);
45
+ }
46
+ return lines.map(line => truncateToWidth(line, width));
47
+ }
48
+
49
+ /** A plain readout also works in modes where terminal UI is unavailable. */
50
+ export function workText(state: WorkState): string {
51
+ const lines = ["WORK · todos: Pi branch · agents/jobs: Herdr owner pane"];
52
+ if (state.error) lines.push(state.error);
53
+ for (const task of state.todo?.tasks ?? []) lines.push(`${task.status}: ${clean(task.content)}`);
54
+ for (const row of state.board.rows) lines.push(`${clean(row.name)} · ${clean(row.state)} · ${clean(row.host)}`);
55
+ if (lines.length === 1) lines.push("No work.");
56
+ return lines.join("\n");
57
+ }
@@ -18,7 +18,7 @@ wrangle read NAME_OR_PANE [--host H]
18
18
  ```
19
19
 
20
20
  The board groups what you started by what you must do: TO LAND (a finished child with commits), ATTENTION (blocked, stalled, failed), RUNNING, and WAITING (queued for a host). Each agent row shows its harness and model; each job row its command.
21
- Your prompt context carries host status and a `To land:` line. In Pi, the board widget sits above the editor and `/wrangle` opens the control panel.
21
+ Your prompt context carries host status and a `To land:` line. In Pi, the compact `WORK` widget shows the current task and named agents and jobs above the editor. Alt+W or `/work` expands it in place. Work inspection stays inline, not in a popup. Todos follow the Pi branch; agents and jobs follow the owner pane. `/wrangle` retains host, profile, and inbox administration.
22
22
 
23
23
  ## Delegate
24
24
 
@@ -66,14 +66,16 @@ It refuses, before it changes anything, a busy agent, a dirty or detached worktr
66
66
  ## Run a job
67
67
 
68
68
  ```sh
69
- wrangle run --wait --max 30m --timeout 1h --idle-timeout 10m -- cargo test
69
+ wrangle run --host local --timeout 1h --idle-timeout 10m -- cargo test
70
+ wrangle read JOB
70
71
  wrangle ps --all
71
- wrangle logs JOB --follow
72
- wrangle wait JOB --max 30m
72
+ wrangle logs JOB
73
73
  wrangle stop JOB
74
74
  ```
75
75
 
76
- Run long waits through the harness's background-task tool. A completed wait returns the job's exit code. At the wait deadline, wrangle prints `still running` and exits 124; the job continues.
76
+ Use `run` without `--wait` so the conversation stays usable. Continue other work; Wrangle delivers owner check-ins for job completion. Use `read` for a bounded output tail. Use `logs` when you need the full retained log, not as a polling loop. Do not run `wait` or `logs --follow` in a foreground agent tool call.
77
+
78
+ Pin `--host local` when a job needs your working tree, local environment, or ports. Choose a remote host explicitly when required. Without a host, Wrangle selects one with room. Stop only jobs you own or have permission to stop.
77
79
  Job timeouts kill the process group. Both timeout defaults are unlimited. Durations accept `ms`, `s`, `m`, and `h`.
78
80
  When the job ends, `exec` saves the final record, then closes its own tab. `logs` and `wait` still work after that.
79
81
  Jobs use a tab in your workspace. Remote work uses a workspace with the same name.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oisin-ee/oisinpi",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Oisin's pi packages in one install: prewalk, todo, wrangle, and guardrails.",
5
5
  "keywords": [
6
6
  "guardrails",
@@ -18,10 +18,10 @@
18
18
  },
19
19
  "type": "module",
20
20
  "dependencies": {
21
- "@oisin-ee/pi-guardrails": "0.4.0",
22
- "@oisin-ee/pi-prewalk": "0.4.0",
23
- "@oisin-ee/pi-todo": "0.4.0",
24
- "@oisin-ee/pi-wrangle": "0.11.0"
21
+ "@oisin-ee/pi-guardrails": "0.5.1",
22
+ "@oisin-ee/pi-prewalk": "0.5.1",
23
+ "@oisin-ee/pi-todo": "0.5.1",
24
+ "@oisin-ee/pi-wrangle": "0.12.2"
25
25
  },
26
26
  "bundleDependencies": [
27
27
  "@oisin-ee/pi-guardrails",