@oisin-ee/oisinpi 0.3.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 +22 -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/README.md +30 -0
- package/node_modules/@oisin-ee/pi-todo/package.json +1 -1
- package/node_modules/@oisin-ee/pi-todo/src/index.ts +29 -27
- package/node_modules/@oisin-ee/pi-todo/src/plannotator.ts +107 -0
- package/node_modules/@oisin-ee/pi-todo/src/presentation.ts +23 -1
- package/node_modules/@oisin-ee/pi-todo/src/work.ts +230 -0
- 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,27 @@
|
|
|
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
|
+
|
|
16
|
+
## 0.4.0
|
|
17
|
+
|
|
18
|
+
### Patch Changes
|
|
19
|
+
|
|
20
|
+
- Updated dependencies [[`0a1434e`](https://github.com/oisin-ee/oisinpi/commit/0a1434eedb156c21abd36ae611a124b06f0ad1d8), [`74a563f`](https://github.com/oisin-ee/oisinpi/commit/74a563f9fa806902d9c0cf7d1d2e01edea255dec)]:
|
|
21
|
+
- @oisin-ee/pi-todo@0.4.0
|
|
22
|
+
- @oisin-ee/pi-guardrails@0.4.0
|
|
23
|
+
- @oisin-ee/pi-prewalk@0.4.0
|
|
24
|
+
|
|
3
25
|
## 0.3.0
|
|
4
26
|
|
|
5
27
|
### 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.";
|
|
@@ -42,6 +42,36 @@ Expansion stays open through task updates. Session changes and `/tree` navigatio
|
|
|
42
42
|
When no task is active, a short status and the shortcut hint remain visible.
|
|
43
43
|
Outside the terminal UI, `/todos` prints the list instead.
|
|
44
44
|
|
|
45
|
+
## Shared work display
|
|
46
|
+
|
|
47
|
+
When a compatible pi-wrangle extension is active, its `WORK` strip replaces the standalone todo widget.
|
|
48
|
+
Tasks remain on the current Pi session branch. Wrangle receives read-only snapshots through `pi.events`.
|
|
49
|
+
`/todos` and Alt+T request the shared Todos view. `/todos list` still prints the full list.
|
|
50
|
+
When shared presentation ends, the standalone widget returns without changing task state.
|
|
51
|
+
|
|
52
|
+
The [version 1 display contract](https://github.com/oisin-ee/wrangle/blob/main/docs/pi-work.md)
|
|
53
|
+
defines the snapshot request, acknowledgement, release, and read-only view events.
|
|
54
|
+
|
|
55
|
+
## Plannotator plans
|
|
56
|
+
|
|
57
|
+
With Plannotator external execution, pi-todo continues approved plans in the same session.
|
|
58
|
+
Add this setting to `~/.pi/agent/plannotator.json`:
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"executionMode": "external"
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
After approval, the agent reads the current todo list and adds the remaining approved work before implementation.
|
|
67
|
+
It preserves unrelated tasks and completed progress. Free-form plans do not need a special checklist format.
|
|
68
|
+
The todo tool owns progress, persistence, reminders, and the shared work display.
|
|
69
|
+
Plannotator returns to idle and does not maintain a second checklist or require `[DONE:n]` markers.
|
|
70
|
+
|
|
71
|
+
This integration uses the supported `plannotator:plan-approved` event, verified with Plannotator `0.28.19`.
|
|
72
|
+
It does not import Plannotator internals or change its code.
|
|
73
|
+
Malformed events, a different working directory, or another owner of the `todo` tool stop the handoff with an error.
|
|
74
|
+
|
|
45
75
|
## Reminder
|
|
46
76
|
|
|
47
77
|
When the agent stops with open tasks, pi-todo adds a hidden reminder that lists them and continues the turn.
|
|
@@ -1,17 +1,26 @@
|
|
|
1
|
-
import { ownsTodo, settle
|
|
1
|
+
import { ownsTodo, settle } from "./lifecycle.ts";
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { PlannotatorHandoff } from "./plannotator.ts";
|
|
3
4
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
4
|
-
import { TODO_SHORTCUT } from "./widget.ts";
|
|
5
5
|
import { TodoPresentation } from "./presentation.ts";
|
|
6
|
+
import { TodoWork } from "./work.ts";
|
|
6
7
|
import { Type } from "typebox";
|
|
8
|
+
import type { WorkHost } from "./work.ts";
|
|
7
9
|
import { runTodo } from "./tool.ts";
|
|
8
10
|
|
|
9
|
-
type TodoAPI =
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
type TodoAPI = WorkHost &
|
|
12
|
+
Readonly<
|
|
13
|
+
Pick<
|
|
14
|
+
ExtensionAPI,
|
|
15
|
+
| "appendEntry"
|
|
16
|
+
| "getAllTools"
|
|
17
|
+
| "on"
|
|
18
|
+
| "registerCommand"
|
|
19
|
+
| "registerShortcut"
|
|
20
|
+
| "registerTool"
|
|
21
|
+
| "sendMessage"
|
|
22
|
+
>
|
|
23
|
+
>;
|
|
15
24
|
|
|
16
25
|
const items = Type.Immutable(
|
|
17
26
|
Type.Array(Type.String(), { description: "For init or append: tasks, in order." }),
|
|
@@ -32,13 +41,10 @@ const items = Type.Immutable(
|
|
|
32
41
|
* @param {TodoAPI} pi The extension API methods used here.
|
|
33
42
|
*/
|
|
34
43
|
export default function piTodo(pi: TodoAPI): void {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
presentation.toggle(pi, ctx);
|
|
40
|
-
},
|
|
41
|
-
});
|
|
44
|
+
const handoff = new PlannotatorHandoff(pi),
|
|
45
|
+
presentation = new TodoPresentation(),
|
|
46
|
+
work = new TodoWork(pi, presentation);
|
|
47
|
+
work.register(pi);
|
|
42
48
|
pi.registerTool({
|
|
43
49
|
description:
|
|
44
50
|
"Keep the session todo list. Tasks are addressed by their exact content. One task is active; the next open task starts when it closes.",
|
|
@@ -49,6 +55,7 @@ export default function piTodo(pi: TodoAPI): void {
|
|
|
49
55
|
},
|
|
50
56
|
branch: () => ctx.sessionManager.getBranch(),
|
|
51
57
|
});
|
|
58
|
+
work.publish(ctx);
|
|
52
59
|
presentation.refresh(ctx);
|
|
53
60
|
return result;
|
|
54
61
|
},
|
|
@@ -59,23 +66,18 @@ export default function piTodo(pi: TodoAPI): void {
|
|
|
59
66
|
});
|
|
60
67
|
pi.on("session_start", (_event, ctx) => {
|
|
61
68
|
presentation.start(pi, ctx);
|
|
69
|
+
work.start(ctx);
|
|
70
|
+
handoff.start(ctx);
|
|
71
|
+
});
|
|
72
|
+
pi.on("session_shutdown", () => {
|
|
73
|
+
handoff.stop();
|
|
74
|
+
work.stop();
|
|
62
75
|
});
|
|
63
76
|
pi.on("session_tree", (_event, ctx) => {
|
|
64
77
|
if (ownsTodo(pi)) {
|
|
78
|
+
work.publish(ctx);
|
|
65
79
|
presentation.reset(ctx);
|
|
66
80
|
}
|
|
67
81
|
});
|
|
68
82
|
pi.on("agent_before_settle", (event, ctx) => settle(pi, event, ctx));
|
|
69
|
-
pi.registerCommand("todos", {
|
|
70
|
-
description: "Toggle todo details; /todos list prints the full branch list.",
|
|
71
|
-
async handler(args, ctx) {
|
|
72
|
-
if (ownsTodo(pi)) {
|
|
73
|
-
if (args.trim() === "list" || ctx.mode !== "tui") {
|
|
74
|
-
show(ctx);
|
|
75
|
-
} else {
|
|
76
|
-
presentation.toggle(pi, ctx);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
},
|
|
80
|
-
});
|
|
81
83
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { TodoContext } from "./lifecycle.ts";
|
|
3
|
+
import type { WorkHost } from "./work.ts";
|
|
4
|
+
import { ownsTodo } from "./lifecycle.ts";
|
|
5
|
+
|
|
6
|
+
/** The supported Plannotator external-execution event. */
|
|
7
|
+
interface ApprovedPlan {
|
|
8
|
+
readonly cwd: string;
|
|
9
|
+
readonly planFilePath: string;
|
|
10
|
+
readonly planContent: string;
|
|
11
|
+
readonly feedback?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type PlanHost = WorkHost & Readonly<Pick<ExtensionAPI, "sendMessage">>;
|
|
15
|
+
type PlanContext = TodoContext & { readonly cwd: string };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Validates the event before it enters the model context.
|
|
19
|
+
* @param {unknown} value The shared event payload.
|
|
20
|
+
* @returns {boolean} Whether the required handoff fields are present.
|
|
21
|
+
*/
|
|
22
|
+
const isApprovedPlan = (value: unknown): value is ApprovedPlan =>
|
|
23
|
+
typeof value === "object" &&
|
|
24
|
+
value !== null &&
|
|
25
|
+
"cwd" in value &&
|
|
26
|
+
typeof value.cwd === "string" &&
|
|
27
|
+
"planFilePath" in value &&
|
|
28
|
+
typeof value.planFilePath === "string" &&
|
|
29
|
+
value.planFilePath.trim() !== "" &&
|
|
30
|
+
"planContent" in value &&
|
|
31
|
+
typeof value.planContent === "string" &&
|
|
32
|
+
value.planContent.trim() !== "" &&
|
|
33
|
+
(!("feedback" in value) || value.feedback === undefined || typeof value.feedback === "string"),
|
|
34
|
+
/**
|
|
35
|
+
* Continues the approved plan through the existing todo tool, not a second parser or store.
|
|
36
|
+
* @param {ApprovedPlan} plan The approved document and reviewer feedback.
|
|
37
|
+
* @returns {string} Instructions followed by the exact handoff data.
|
|
38
|
+
*/
|
|
39
|
+
continuation = (plan: ApprovedPlan): string =>
|
|
40
|
+
[
|
|
41
|
+
"The user approved this plan in Plannotator. Continue execution in this session.",
|
|
42
|
+
"Use the todo tool as the only task tracker. Do not maintain Plannotator [DONE:n] markers.",
|
|
43
|
+
"First call todo with op=view to inspect the current branch list.",
|
|
44
|
+
"Before implementation, represent the approved plan's remaining work in that list.",
|
|
45
|
+
"If the list is empty, call todo with op=init and the remaining approved tasks in order.",
|
|
46
|
+
"Otherwise, reuse matching tasks and append missing tasks. Preserve unrelated work and completed progress.",
|
|
47
|
+
"If approved work remains, select its next open task with op=start. Never restart a completed task just because the plan was approved again.",
|
|
48
|
+
"If no approved work remains, report completion without changing tasks. Otherwise, update tasks with op=done or op=drop as work proceeds.",
|
|
49
|
+
"Resolve a relative planFilePath against cwd. The approved document and reviewer feedback follow as JSON:",
|
|
50
|
+
JSON.stringify({
|
|
51
|
+
cwd: plan.cwd,
|
|
52
|
+
feedback: plan.feedback,
|
|
53
|
+
planContent: plan.planContent,
|
|
54
|
+
planFilePath: plan.planFilePath,
|
|
55
|
+
}),
|
|
56
|
+
].join("\n");
|
|
57
|
+
|
|
58
|
+
/** Owns the approval subscription, never the todo list or execution progress. */
|
|
59
|
+
class PlannotatorHandoff {
|
|
60
|
+
readonly #host: PlanHost;
|
|
61
|
+
#unsubscribe: (() => void) | undefined;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Keeps the supported event bus and conversation API.
|
|
65
|
+
* @param {PlanHost} host The Pi methods used by the handoff.
|
|
66
|
+
*/
|
|
67
|
+
public constructor(host: PlanHost) {
|
|
68
|
+
this.#host = host;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Subscribes with the current session context after startup or replacement.
|
|
73
|
+
* @param {PlanContext} ctx The current branch and working directory.
|
|
74
|
+
*/
|
|
75
|
+
public start(ctx: PlanContext): void {
|
|
76
|
+
this.stop();
|
|
77
|
+
this.#unsubscribe = this.#host.events.on(
|
|
78
|
+
"plannotator:plan-approved",
|
|
79
|
+
(value: unknown): void => {
|
|
80
|
+
if (!isApprovedPlan(value)) {
|
|
81
|
+
ctx.ui.notify("pi-todo: Invalid Plannotator approval. No execution started.", "error");
|
|
82
|
+
} else if (value.cwd !== ctx.cwd) {
|
|
83
|
+
ctx.ui.notify(
|
|
84
|
+
"pi-todo: The approved plan belongs to another working directory. No execution started.",
|
|
85
|
+
"error",
|
|
86
|
+
);
|
|
87
|
+
} else if (ownsTodo(this.#host)) {
|
|
88
|
+
this.#host.sendMessage(
|
|
89
|
+
{ content: continuation(value), customType: "todo-plan-approved", display: true },
|
|
90
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
91
|
+
);
|
|
92
|
+
} else {
|
|
93
|
+
ctx.ui.notify("pi-todo: Another extension owns todo. No execution started.", "error");
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Removes the previous session's listener before replacement or shutdown. */
|
|
100
|
+
public stop(): void {
|
|
101
|
+
this.#unsubscribe?.();
|
|
102
|
+
this.#unsubscribe = undefined;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export { PlannotatorHandoff };
|
|
107
|
+
export type { PlanContext, PlanHost };
|
|
@@ -4,13 +4,16 @@ import { ownsTodo, replay, start } from "./lifecycle.ts";
|
|
|
4
4
|
/** Owns transient expansion for one extension instance, never task state. */
|
|
5
5
|
class TodoPresentation {
|
|
6
6
|
#expanded = false;
|
|
7
|
+
#external = false;
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Replays current branch tasks without changing expansion.
|
|
10
11
|
* @param {TodoContext} ctx Current branch and UI.
|
|
11
12
|
*/
|
|
12
13
|
public refresh(ctx: TodoContext): void {
|
|
13
|
-
|
|
14
|
+
if (!this.#external) {
|
|
15
|
+
replay(ctx, this.#expanded);
|
|
16
|
+
}
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
/**
|
|
@@ -29,9 +32,28 @@ class TodoPresentation {
|
|
|
29
32
|
*/
|
|
30
33
|
public start(pi: TodoHost, ctx: TodoContext): void {
|
|
31
34
|
this.#expanded = false;
|
|
35
|
+
this.#external = false;
|
|
32
36
|
start(pi, ctx);
|
|
33
37
|
}
|
|
34
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Yields only this extension's widget to an acknowledged work presenter.
|
|
41
|
+
* @param {TodoContext} ctx Current terminal UI.
|
|
42
|
+
*/
|
|
43
|
+
public claim(ctx: TodoContext): void {
|
|
44
|
+
this.#external = true;
|
|
45
|
+
ctx.ui.setWidget("pi-todo", undefined);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Restores the standalone view without changing task state.
|
|
50
|
+
* @param {TodoContext} ctx Current terminal UI.
|
|
51
|
+
*/
|
|
52
|
+
public release(ctx: TodoContext): void {
|
|
53
|
+
this.#external = false;
|
|
54
|
+
this.refresh(ctx);
|
|
55
|
+
}
|
|
56
|
+
|
|
35
57
|
/**
|
|
36
58
|
* Reveals or hides secondary groups without writing session state.
|
|
37
59
|
* @param {TodoHost} pi Registered tool metadata.
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import type { TodoContext, TodoHost } from "./lifecycle.ts";
|
|
2
|
+
import { ownsTodo, show } from "./lifecycle.ts";
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { TODO_SHORTCUT } from "./widget.ts";
|
|
5
|
+
import type { TodoPresentation } from "./presentation.ts";
|
|
6
|
+
import { latestSnapshot } from "./state.ts";
|
|
7
|
+
import { parse } from "./wasm.ts";
|
|
8
|
+
|
|
9
|
+
interface WorkHost extends TodoHost {
|
|
10
|
+
readonly events: Readonly<ExtensionAPI["events"]>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type WorkCommands = Readonly<Pick<ExtensionAPI, "registerCommand" | "registerShortcut">>;
|
|
14
|
+
const protocolVersion = 1,
|
|
15
|
+
initialRevision = 0;
|
|
16
|
+
|
|
17
|
+
interface WorkContext extends TodoContext {
|
|
18
|
+
readonly sessionManager: TodoContext["sessionManager"] & {
|
|
19
|
+
readonly getSessionId: () => string;
|
|
20
|
+
readonly getLeafId: () => string | null;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The identity of one work presenter requesting this provider's snapshot. */
|
|
25
|
+
interface Presenter {
|
|
26
|
+
readonly presenter: string;
|
|
27
|
+
readonly request: string;
|
|
28
|
+
readonly version: typeof protocolVersion;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Checks a presentation request without trusting an extension's event payload.
|
|
33
|
+
* @param {unknown} value Event payload.
|
|
34
|
+
* @returns {boolean} Whether the envelope uses the supported protocol.
|
|
35
|
+
*/
|
|
36
|
+
const isPresenter = (value: unknown): value is Presenter =>
|
|
37
|
+
typeof value === "object" &&
|
|
38
|
+
value !== null &&
|
|
39
|
+
"version" in value &&
|
|
40
|
+
value.version === protocolVersion &&
|
|
41
|
+
"presenter" in value &&
|
|
42
|
+
typeof value.presenter === "string" &&
|
|
43
|
+
"request" in value &&
|
|
44
|
+
typeof value.request === "string";
|
|
45
|
+
|
|
46
|
+
/** Publishes validated branch data; presentation claims never write task state. */
|
|
47
|
+
class TodoWork {
|
|
48
|
+
readonly #host: WorkHost;
|
|
49
|
+
readonly #presentation: Readonly<TodoPresentation>;
|
|
50
|
+
#context: WorkContext | undefined;
|
|
51
|
+
#publisher = "";
|
|
52
|
+
#revision = initialRevision;
|
|
53
|
+
#presenter: Presenter | undefined;
|
|
54
|
+
#claimed = false;
|
|
55
|
+
#subscriptions: (() => void)[] = [];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Keeps the event bus and the existing standalone presentation owner.
|
|
59
|
+
* @param {WorkHost} host Pi event bus and tool ownership.
|
|
60
|
+
* @param {Readonly<TodoPresentation>} presentation Standalone display owner.
|
|
61
|
+
*/
|
|
62
|
+
public constructor(host: WorkHost, presentation: Readonly<TodoPresentation>) {
|
|
63
|
+
this.#host = host;
|
|
64
|
+
this.#presentation = presentation;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Registers view entrypoints with a shared or standalone presentation target.
|
|
69
|
+
* @param {WorkCommands} commands Pi command registration.
|
|
70
|
+
*/
|
|
71
|
+
public register(commands: WorkCommands): void {
|
|
72
|
+
commands.registerShortcut(TODO_SHORTCUT, {
|
|
73
|
+
description: "Show todo details.",
|
|
74
|
+
handler: (ctx): void => {
|
|
75
|
+
this.#toggle(ctx);
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
commands.registerCommand("todos", {
|
|
79
|
+
description: "Show todo details; /todos list prints the full branch list.",
|
|
80
|
+
handler: async (args, ctx): Promise<void> => {
|
|
81
|
+
if (ownsTodo(this.#host)) {
|
|
82
|
+
if (args.trim() === "list" || ctx.mode !== "tui") {
|
|
83
|
+
show(ctx);
|
|
84
|
+
} else {
|
|
85
|
+
this.#toggle(ctx);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Opens the shared view or toggles the existing standalone view.
|
|
94
|
+
* @param {TodoContext} ctx Current UI and branch.
|
|
95
|
+
*/
|
|
96
|
+
#toggle(ctx: TodoContext): void {
|
|
97
|
+
if (!this.open()) {
|
|
98
|
+
this.#presentation.toggle(this.#host, ctx);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Subscribes only when this extension owns the terminal todo tool.
|
|
104
|
+
* @param {WorkContext} ctx Current Pi session.
|
|
105
|
+
*/
|
|
106
|
+
public start(ctx: WorkContext): void {
|
|
107
|
+
this.stop();
|
|
108
|
+
if (ctx.mode !== "tui" || !ownsTodo(this.#host)) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
this.#context = ctx;
|
|
112
|
+
this.#publisher = crypto.randomUUID();
|
|
113
|
+
this.#revision = initialRevision;
|
|
114
|
+
this.#subscriptions = [
|
|
115
|
+
this.#host.events.on("oisin.work.request", (value: unknown): void => {
|
|
116
|
+
if (isPresenter(value)) {
|
|
117
|
+
this.#presenter = value;
|
|
118
|
+
if (this.#context !== undefined) {
|
|
119
|
+
this.publish(this.#context);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}),
|
|
123
|
+
this.#host.events.on("oisin.work.claim", (value: unknown): void => {
|
|
124
|
+
this.#claim(value);
|
|
125
|
+
}),
|
|
126
|
+
this.#host.events.on("oisin.work.release", (value: unknown): void => {
|
|
127
|
+
if (this.#matches(value)) {
|
|
128
|
+
this.#release();
|
|
129
|
+
}
|
|
130
|
+
}),
|
|
131
|
+
];
|
|
132
|
+
this.#host.events.emit("oisin.todo.ready", { publisher: this.#publisher, version: 1 });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Publishes the actual branch snapshot after tool or branch changes.
|
|
137
|
+
* @param {WorkContext} ctx Current Pi session, never a stored file's last entry.
|
|
138
|
+
*/
|
|
139
|
+
public publish(ctx: WorkContext): void {
|
|
140
|
+
if (this.#context === undefined || this.#presenter === undefined) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
this.#context = ctx;
|
|
144
|
+
this.#revision += 1;
|
|
145
|
+
const list = parse(latestSnapshot(ctx.sessionManager.getBranch()));
|
|
146
|
+
this.#host.events.emit("oisin.todo.snapshot", {
|
|
147
|
+
branch: ctx.sessionManager.getLeafId(),
|
|
148
|
+
presenter: this.#presenter.presenter,
|
|
149
|
+
publisher: this.#publisher,
|
|
150
|
+
request: this.#presenter.request,
|
|
151
|
+
revision: this.#revision,
|
|
152
|
+
session: ctx.sessionManager.getSessionId(),
|
|
153
|
+
tasks: list.tasks,
|
|
154
|
+
version: 1,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Requests the read-only Todos view without changing expansion or task state.
|
|
160
|
+
* @returns {boolean} Whether a shared presenter owns the view.
|
|
161
|
+
*/
|
|
162
|
+
public open(): boolean {
|
|
163
|
+
if (!this.#claimed || this.#presenter === undefined) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
this.#host.events.emit("oisin.work.open", {
|
|
167
|
+
presenter: this.#presenter.presenter,
|
|
168
|
+
request: this.#presenter.request,
|
|
169
|
+
version: 1,
|
|
170
|
+
view: "todos",
|
|
171
|
+
});
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Releases subscriptions and tells the presenter to discard this provider. */
|
|
176
|
+
public stop(): void {
|
|
177
|
+
for (const unsubscribe of this.#subscriptions) {
|
|
178
|
+
unsubscribe();
|
|
179
|
+
}
|
|
180
|
+
this.#subscriptions = [];
|
|
181
|
+
if (this.#context !== undefined) {
|
|
182
|
+
this.#host.events.emit("oisin.todo.stop", { publisher: this.#publisher, version: 1 });
|
|
183
|
+
}
|
|
184
|
+
this.#release();
|
|
185
|
+
this.#context = undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Accepts only an acknowledgement of the latest published snapshot.
|
|
190
|
+
* @param {unknown} value Claim envelope.
|
|
191
|
+
*/
|
|
192
|
+
#claim(value: unknown): void {
|
|
193
|
+
if (
|
|
194
|
+
this.#matches(value) &&
|
|
195
|
+
"publisher" in value &&
|
|
196
|
+
value.publisher === this.#publisher &&
|
|
197
|
+
"revision" in value &&
|
|
198
|
+
value.revision === this.#revision &&
|
|
199
|
+
this.#context !== undefined
|
|
200
|
+
) {
|
|
201
|
+
this.#claimed = true;
|
|
202
|
+
this.#presentation.claim(this.#context);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Matches a live presenter, excluding delayed messages from replaced sessions.
|
|
208
|
+
* @param {unknown} value Event envelope.
|
|
209
|
+
* @returns {boolean} Whether the envelope belongs to the current presenter.
|
|
210
|
+
*/
|
|
211
|
+
#matches(value: unknown): value is Presenter {
|
|
212
|
+
return (
|
|
213
|
+
isPresenter(value) &&
|
|
214
|
+
value.presenter === this.#presenter?.presenter &&
|
|
215
|
+
value.request === this.#presenter.request
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Restores the standalone display when shared presentation ends. */
|
|
220
|
+
#release(): void {
|
|
221
|
+
this.#claimed = false;
|
|
222
|
+
this.#presenter = undefined;
|
|
223
|
+
if (this.#context !== undefined) {
|
|
224
|
+
this.#presentation.release(this.#context);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export { TodoWork };
|
|
230
|
+
export type { WorkContext, WorkHost };
|
|
@@ -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",
|