@oisin-ee/oisinpi 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +5 -1
- package/node_modules/@oisin-ee/pi-guardrails/dist/policies/shell.js +3 -1
- package/node_modules/@oisin-ee/pi-guardrails/package.json +1 -1
- package/node_modules/@oisin-ee/pi-guardrails/src/pi/tool-timeouts.ts +4 -2
- package/node_modules/@oisin-ee/pi-guardrails/src/policies/shell.ts +3 -1
- package/node_modules/@oisin-ee/pi-prewalk/package.json +1 -1
- package/node_modules/@oisin-ee/pi-todo/package.json +1 -1
- package/node_modules/@oisin-ee/pi-wrangle/README.md +7 -2
- package/node_modules/@oisin-ee/pi-wrangle/package.json +1 -1
- package/node_modules/@oisin-ee/pi-wrangle/pi/index.ts +92 -6
- package/node_modules/@oisin-ee/pi-wrangle/pi/work-history.ts +33 -0
- package/node_modules/@oisin-ee/pi-wrangle/pi/work-panel.ts +147 -0
- package/node_modules/@oisin-ee/pi-wrangle/pi/work-todo.ts +114 -0
- package/node_modules/@oisin-ee/pi-wrangle/pi/work.ts +45 -0
- package/node_modules/@oisin-ee/pi-wrangle/plugins/wrangle/skills/wrangle/SKILL.md +7 -5
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @oisin-ee/oisinpi
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Updated dependencies [`1f4a622`]:
|
|
12
|
+
- @oisin-ee/pi-guardrails@0.5.0
|
|
13
|
+
- @oisin-ee/pi-prewalk@0.5.0
|
|
14
|
+
- @oisin-ee/pi-todo@0.5.0
|
|
15
|
+
|
|
3
16
|
## 0.4.0
|
|
4
17
|
|
|
5
18
|
### 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
|
|
9
|
+
| [`@oisin-ee/pi-wrangle`](https://www.npmjs.com/package/@oisin-ee/pi-wrangle) | The shared `WORK` strip, `/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,7 @@ 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
|
+
`/work` shows read-only todos for the active Pi branch and agents/jobs for the Herdr owner pane.
|
|
21
|
+
Use Wrangle `run`, `read`, and `stop` for background work. The collection does not require `pi-background-tasks`.
|
|
22
|
+
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
|
-
"
|
|
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.";
|
|
@@ -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
|
-
"
|
|
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
|
|
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
|
-
"
|
|
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.";
|
|
@@ -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
|
|
469
|
+
- **The `WORK` strip** above the editor: one row for todo progress, agents, jobs, and attention. Agent/job data refreshes every ten seconds. Compatible pi-todo snapshots update immediately. Empty providers hide the strip; unavailable providers show an error instead of false zero counts.
|
|
470
|
+
- **`/work`**, a bounded overlay with All, Todos, Agents, and Jobs views. Tab changes views; Enter opens details. Todos are read-only. Explicit actions stop jobs, cancel queued tickets, or message agents through the inbox. Stop and cancel require confirmation. Escape returns to the unchanged editor draft. Completed job history stays collapsed until selected.
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.12.1",
|
|
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
3
|
import { Box, Text } from "@earendil-works/pi-tui";
|
|
4
|
-
import { type Board, clean, parseBoard
|
|
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, runWorkAction, showWorkDetail, type WorkAction, type WorkCursor, type WorkData, type WorkTab } from "./work-panel.ts";
|
|
7
11
|
|
|
8
12
|
const REFRESH_MS = 10_000;
|
|
9
13
|
|
|
@@ -57,11 +61,69 @@ 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 closeWork: (() => void) | undefined;
|
|
68
|
+
let workAbort: AbortController | undefined;
|
|
69
|
+
let context: ExtensionContext | undefined;
|
|
70
|
+
const todos = new WorkTodo(pi.events, () => { if (context) draw(context); }, () => {
|
|
71
|
+
if (context) void openWork(context, "todos").catch(error => context?.ui.notify(String(error), "error"));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
function state(): WorkState {
|
|
75
|
+
return { board, todo: todos.current, error: failure ?? todos.error };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function workData(): WorkData { return { ...state(), history }; }
|
|
79
|
+
|
|
80
|
+
async function openWork(ctx: ExtensionContext, tab: WorkTab): Promise<void> {
|
|
81
|
+
if (ctx.mode !== "tui") { ctx.ui.notify(workText(state()), "info"); return; }
|
|
82
|
+
if (workOpen || panelOpen) return;
|
|
83
|
+
workOpen = true;
|
|
84
|
+
const interaction = new AbortController();
|
|
85
|
+
workAbort = interaction;
|
|
86
|
+
const current = generation;
|
|
87
|
+
let cursor: WorkCursor = { tab, selected: undefined, history: false };
|
|
88
|
+
try {
|
|
89
|
+
// An older refresh may predate history demand; finish it before the panel refresh.
|
|
90
|
+
if (refresh) await refresh;
|
|
91
|
+
if (interaction.signal.aborted || current !== generation) return;
|
|
92
|
+
await update(ctx);
|
|
93
|
+
while (current === generation && workOpen) {
|
|
94
|
+
const action = await ctx.ui.custom<WorkAction | undefined>((tui, theme, keys, done) => {
|
|
95
|
+
closeWork = () => done(undefined);
|
|
96
|
+
workPanel = new WorkPanel(workData(), cursor, theme, keys, done, () => tui.requestRender(), () => tui.terminal.rows);
|
|
97
|
+
return workPanel;
|
|
98
|
+
}, { overlay: true, overlayOptions: { width: "90%", maxHeight: "85%", anchor: "center" } });
|
|
99
|
+
workPanel = undefined;
|
|
100
|
+
closeWork = undefined;
|
|
101
|
+
if (!action || current !== generation) break;
|
|
102
|
+
cursor = action.cursor;
|
|
103
|
+
try {
|
|
104
|
+
const detail = await runWorkAction(action, ctx, exec, interaction.signal);
|
|
105
|
+
if (detail !== undefined && current === generation) await showWorkDetail(ctx, action.entry.label, detail, interaction.signal);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (current === generation) await showWorkDetail(ctx, "Work action unavailable", String(error), interaction.signal);
|
|
108
|
+
}
|
|
109
|
+
if (current === generation) await update(ctx);
|
|
110
|
+
}
|
|
111
|
+
} finally {
|
|
112
|
+
interaction.abort();
|
|
113
|
+
if (workAbort === interaction) workAbort = undefined;
|
|
114
|
+
workPanel = undefined;
|
|
115
|
+
closeWork = undefined;
|
|
116
|
+
workOpen = false;
|
|
117
|
+
history = { jobs: [] };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
60
120
|
|
|
61
121
|
function draw(ctx: ExtensionContext) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
122
|
+
if (ctx.mode !== "tui") return;
|
|
123
|
+
workPanel?.update(workData());
|
|
124
|
+
const data = state();
|
|
125
|
+
ctx.ui.setWidget("wrangle", panelOpen || (!data.board.rows.length && !data.todo?.tasks.length && !data.error) ? undefined : (_tui, theme) => ({
|
|
126
|
+
render: (width: number) => workStrip(data, width, theme),
|
|
65
127
|
invalidate: () => {},
|
|
66
128
|
}), { placement: "aboveEditor" });
|
|
67
129
|
}
|
|
@@ -75,15 +137,22 @@ export default function wrangle(pi: ExtensionAPI) {
|
|
|
75
137
|
if (ctx.mode !== "tui") return;
|
|
76
138
|
try {
|
|
77
139
|
const next = parseBoard(JSON.parse(await exec(["board", "--json"])));
|
|
78
|
-
failure = undefined;
|
|
79
140
|
if (current !== generation) return;
|
|
141
|
+
failure = undefined;
|
|
80
142
|
board = next;
|
|
143
|
+
if (workOpen) {
|
|
144
|
+
const loaded = await loadJobHistory(exec, owner);
|
|
145
|
+
if (current !== generation) return;
|
|
146
|
+
history = loaded;
|
|
147
|
+
}
|
|
81
148
|
draw(ctx);
|
|
82
149
|
} catch (error) {
|
|
83
150
|
// Say so once, not on every refresh; the last board stays on screen.
|
|
84
151
|
const text = String(error);
|
|
152
|
+
if (current !== generation) return;
|
|
85
153
|
if (text !== failure) ctx.ui.notify(text, "error");
|
|
86
154
|
failure = text;
|
|
155
|
+
draw(ctx);
|
|
87
156
|
}
|
|
88
157
|
})().finally(() => { refresh = undefined; });
|
|
89
158
|
return refresh;
|
|
@@ -92,7 +161,13 @@ export default function wrangle(pi: ExtensionAPI) {
|
|
|
92
161
|
pi.on("session_start", async (_event, ctx) => {
|
|
93
162
|
if (timer) clearInterval(timer);
|
|
94
163
|
generation++;
|
|
95
|
-
|
|
164
|
+
workAbort?.abort();
|
|
165
|
+
closeWork?.();
|
|
166
|
+
context = ctx;
|
|
167
|
+
board = { hosts: [], rows: [] };
|
|
168
|
+
failure = owner ? undefined : "Wrangle needs a Herdr owner pane";
|
|
169
|
+
todos.start(ctx);
|
|
170
|
+
if (!owner) { draw(ctx); return; }
|
|
96
171
|
timer = setInterval(() => { void update(ctx); }, REFRESH_MS);
|
|
97
172
|
timer.unref();
|
|
98
173
|
await inbox.start(ctx);
|
|
@@ -106,9 +181,13 @@ export default function wrangle(pi: ExtensionAPI) {
|
|
|
106
181
|
});
|
|
107
182
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
108
183
|
generation++;
|
|
184
|
+
workAbort?.abort();
|
|
185
|
+
closeWork?.();
|
|
109
186
|
if (timer) clearInterval(timer);
|
|
110
187
|
timer = undefined;
|
|
111
188
|
inbox.stop();
|
|
189
|
+
todos.stop();
|
|
190
|
+
context = undefined;
|
|
112
191
|
if (ctx.mode === "tui") ctx.ui.setWidget("wrangle", undefined);
|
|
113
192
|
});
|
|
114
193
|
// A delivered message is in the transcript once Pi ends it: only then is it acknowledged.
|
|
@@ -124,6 +203,12 @@ export default function wrangle(pi: ExtensionAPI) {
|
|
|
124
203
|
return;
|
|
125
204
|
}
|
|
126
205
|
});
|
|
206
|
+
pi.registerCommand("work", {
|
|
207
|
+
description: "Read todos on this branch and this owner's agents and jobs",
|
|
208
|
+
handler: async (_args, ctx) => {
|
|
209
|
+
await openWork(ctx, "all");
|
|
210
|
+
},
|
|
211
|
+
});
|
|
127
212
|
pi.registerCommand("wrangle", {
|
|
128
213
|
description: "Hosts, your children and jobs, profiles, and messages",
|
|
129
214
|
handler: async (_args, ctx) => {
|
|
@@ -131,6 +216,7 @@ export default function wrangle(pi: ExtensionAPI) {
|
|
|
131
216
|
ctx.ui.notify(await exec(["board"]), "info");
|
|
132
217
|
return;
|
|
133
218
|
}
|
|
219
|
+
if (workOpen || panelOpen) return;
|
|
134
220
|
panelOpen = true;
|
|
135
221
|
draw(ctx);
|
|
136
222
|
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,147 @@
|
|
|
1
|
+
import type { ExtensionContext, KeybindingsManager, 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)}`, description: `${clean(row.state)} · ${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 keys: KeybindingsManager;
|
|
43
|
+
private readonly done: (action: WorkAction | undefined) => void;
|
|
44
|
+
private readonly renderRequest: () => void;
|
|
45
|
+
private readonly rows: () => number;
|
|
46
|
+
private height = 0;
|
|
47
|
+
|
|
48
|
+
constructor(data: WorkData, cursor: WorkCursor, theme: Theme, keys: KeybindingsManager, done: (action: WorkAction | undefined) => void, request: () => void, rows: () => number) {
|
|
49
|
+
this.data = data; this.cursor = cursor; this.theme = theme; this.keys = keys; this.done = done; this.renderRequest = request; this.rows = rows;
|
|
50
|
+
this.rebuild();
|
|
51
|
+
}
|
|
52
|
+
update(data: WorkData): void { this.data = data; this.rebuild(); this.renderRequest(); }
|
|
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 = Math.max(1, Math.min(14, Math.floor(this.rows() * 0.8) - 5));
|
|
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
|
+
}
|
|
86
|
+
invalidate(): void { this.list.invalidate(); }
|
|
87
|
+
render(width: number): string[] {
|
|
88
|
+
if (this.height !== Math.max(1, Math.min(14, Math.floor(this.rows() * 0.8) - 5))) this.rebuild();
|
|
89
|
+
const tabbar = tabs.map(tab => tab === this.cursor.tab ? `[${tab.toUpperCase()}]` : tab).join(" · ");
|
|
90
|
+
return [this.theme.fg("accent", `WORK ${tabbar}`), this.theme.fg("dim", "Todos: Pi branch · Agents/jobs: Herdr owner pane"), ...this.list.render(width), this.theme.fg("dim", "Tab view · Enter inspect · s stop · c cancel · m message"), this.theme.fg("dim", `r refresh · ${this.keys.getKeys("tui.select.cancel").join("/")} close`)].map(line => truncateToWidth(line, width));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Strip terminal control sequences from output while retaining line breaks. */
|
|
95
|
+
function safeOutput(value: string): string {
|
|
96
|
+
return value.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "").replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").split("\n").map(clean).join("\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function inspectEntry(entry: WorkEntry, exec: WorkExec): Promise<string> {
|
|
100
|
+
if (entry.kind === "todo") return entry.detail ?? "";
|
|
101
|
+
if (entry.kind === "queued") return `${entry.label}\n${entry.description ?? ""}\nWaiting for placement. Cancel explicitly to remove this ticket.`;
|
|
102
|
+
if (!entry.id) return "This record is unavailable.";
|
|
103
|
+
const output = await exec(["read", entry.kind === "job" ? entry.id : entry.pane ?? entry.id, "--host", entry.host ?? "local"]);
|
|
104
|
+
const hint = entry.kind === "job" ? `Recent job output (Wrangle's bounded tail).\nFull log: wrangle logs ${clean(entry.id)}\n\n` : "";
|
|
105
|
+
return hint + safeOutput(output);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Actions run after closing the list, so confirmation/input dialogs own focus. */
|
|
109
|
+
export async function runWorkAction(action: WorkAction, ctx: ExtensionContext, exec: WorkExec, signal: AbortSignal): Promise<string | undefined> {
|
|
110
|
+
if (signal.aborted) return undefined;
|
|
111
|
+
const entry = action.entry;
|
|
112
|
+
if (action.action === "inspect") return inspectEntry(entry, exec);
|
|
113
|
+
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]);
|
|
114
|
+
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]);
|
|
115
|
+
if (action.action === "send" && entry.kind === "agent") {
|
|
116
|
+
const message = await ctx.ui.input(`Message ${clean(entry.label)}`, undefined, { signal });
|
|
117
|
+
if (!signal.aborted && message?.trim() && entry.id) await exec(["send", entry.pane ?? entry.id, "--host", entry.host ?? "local", "--message", message]);
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function showWorkDetail(ctx: ExtensionContext, title: string, body: string, signal: AbortSignal): Promise<void> {
|
|
123
|
+
if (signal.aborted) return;
|
|
124
|
+
await ctx.ui.custom<void>((tui, theme, keys, done) => {
|
|
125
|
+
const abort = () => done();
|
|
126
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
127
|
+
const text = new Text(safeOutput(body) || "No output yet.", 0, 0);
|
|
128
|
+
const scroll = new ScrollView(text, { overscroll: "contain", scrollbar: "hidden" });
|
|
129
|
+
return {
|
|
130
|
+
dispose() { signal.removeEventListener("abort", abort); },
|
|
131
|
+
render(width: number) {
|
|
132
|
+
const height = Math.max(1, Math.min(24, Math.floor(tui.terminal.rows * 0.8) - 3));
|
|
133
|
+
scroll.updateLayout(text.render(width).length, height, () => tui.requestRender());
|
|
134
|
+
return [theme.fg("accent", truncateToWidth(title, width)), ...scroll.render(width).slice(scroll.scrollTop, scroll.scrollTop + height), theme.fg("dim", truncateToWidth("↑↓ / PageUp / PageDown scroll · Esc back", width))];
|
|
135
|
+
},
|
|
136
|
+
invalidate() { text.invalidate(); scroll.invalidate(); },
|
|
137
|
+
handleInput(input: string) {
|
|
138
|
+
if (keys.matches(input, "tui.select.cancel")) { done(); return; }
|
|
139
|
+
if (matchesKey(input, Key.up)) scroll.scrollBy(-1);
|
|
140
|
+
if (matchesKey(input, Key.down)) scroll.scrollBy(1);
|
|
141
|
+
if (matchesKey(input, Key.pageUp)) scroll.scrollBy(-scroll.viewportHeight);
|
|
142
|
+
if (matchesKey(input, Key.pageDown)) scroll.scrollBy(scroll.viewportHeight);
|
|
143
|
+
tui.requestRender();
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}, { overlay: true, overlayOptions: { width: "90%", maxHeight: "85%", anchor: "center" } });
|
|
147
|
+
}
|
|
@@ -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,45 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { clean, type Board, type Paint } 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
|
+
/** One bounded row. Attention survives before progress and descriptive labels. */
|
|
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 attention = board.rows.filter(row => row.group === "attention" || row.group === "to_land");
|
|
16
|
+
const urgent = attention.length ? `!${attention.length}` : error ? "!" : "";
|
|
17
|
+
const prefix = `WORK${urgent ? ` ${urgent}` : ""}`;
|
|
18
|
+
const counts: string[] = [];
|
|
19
|
+
if (todo?.tasks.length) counts.push(`${todo.tasks.filter(task => task.status === "completed").length}/${todo.tasks.length} todos`);
|
|
20
|
+
const jobs = board.rows.filter(row => row.kind === "job").length;
|
|
21
|
+
const agents = board.rows.filter(row => row.kind !== "job").length;
|
|
22
|
+
if (agents) counts.push(`${agents} agent${agents === 1 ? "" : "s"}`);
|
|
23
|
+
if (jobs) counts.push(`${jobs} job${jobs === 1 ? "" : "s"}`);
|
|
24
|
+
const item = attention[0];
|
|
25
|
+
const detail = error ? `unavailable · ${clean(error)}` : item ? `${clean(item.name)} · ${clean(item.state)}`
|
|
26
|
+
: todo?.tasks.find(task => task.status === "in_progress")?.content;
|
|
27
|
+
let line = prefix;
|
|
28
|
+
for (const count of counts) {
|
|
29
|
+
const next = `${line} · ${count}`;
|
|
30
|
+
if (visibleWidth(next) + 8 <= width) line = next;
|
|
31
|
+
}
|
|
32
|
+
if (detail && width - visibleWidth(line) > 12) line += ` · ${truncateToWidth(clean(detail), width - visibleWidth(line) - 11)}`;
|
|
33
|
+
if (visibleWidth(line) + 8 <= width) line += " /work";
|
|
34
|
+
return [truncateToWidth(paint.fg(urgent ? "warning" : "accent", line), width)];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A plain readout also works in modes where custom terminal UI is unavailable. */
|
|
38
|
+
export function workText(state: WorkState): string {
|
|
39
|
+
const lines = ["WORK · todos: Pi branch · agents/jobs: Herdr owner pane"];
|
|
40
|
+
if (state.error) lines.push(state.error);
|
|
41
|
+
for (const task of state.todo?.tasks ?? []) lines.push(`${task.status}: ${clean(task.content)}`);
|
|
42
|
+
for (const row of state.board.rows) lines.push(`${clean(row.name)} · ${clean(row.state)} · ${clean(row.host)}`);
|
|
43
|
+
if (lines.length === 1) lines.push("No work.");
|
|
44
|
+
return lines.join("\n");
|
|
45
|
+
}
|
|
@@ -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
|
|
21
|
+
Your prompt context carries host status and a `To land:` line. In Pi, the one-row `WORK` strip sits above the editor. `/work` opens read-only todos and agent/job inspection. 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 --
|
|
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
|
|
72
|
-
wrangle wait JOB --max 30m
|
|
72
|
+
wrangle logs JOB
|
|
73
73
|
wrangle stop JOB
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
-
|
|
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.
|
|
3
|
+
"version": "0.5.0",
|
|
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.
|
|
22
|
-
"@oisin-ee/pi-prewalk": "0.
|
|
23
|
-
"@oisin-ee/pi-todo": "0.
|
|
24
|
-
"@oisin-ee/pi-wrangle": "0.
|
|
21
|
+
"@oisin-ee/pi-guardrails": "0.5.0",
|
|
22
|
+
"@oisin-ee/pi-prewalk": "0.5.0",
|
|
23
|
+
"@oisin-ee/pi-todo": "0.5.0",
|
|
24
|
+
"@oisin-ee/pi-wrangle": "0.12.1"
|
|
25
25
|
},
|
|
26
26
|
"bundleDependencies": [
|
|
27
27
|
"@oisin-ee/pi-guardrails",
|