piher 0.0.0-stage → 0.2.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/README.md +60 -2
- package/package.json +58 -4
- package/schemas/role-manifest.schema.json +62 -0
- package/skills/piher-tools/SKILL.md +212 -0
- package/src/ansi-text.ts +143 -0
- package/src/bg-job-core.ts +412 -0
- package/src/bootstrap.ts +102 -0
- package/src/config-catalog-core.ts +200 -0
- package/src/config-command.ts +324 -0
- package/src/cordis-services.ts +38 -0
- package/src/core-client.ts +715 -0
- package/src/delegation-core.ts +94 -0
- package/src/gc-core.ts +150 -0
- package/src/guards.ts +6 -0
- package/src/hc-templates.ts +134 -0
- package/src/index-locks.ts +118 -0
- package/src/index-master.ts +92 -0
- package/src/index-notices.ts +79 -0
- package/src/index-pipe.ts +71 -0
- package/src/index-roles.ts +265 -0
- package/src/index-runtime.ts +23 -0
- package/src/index.ts +460 -0
- package/src/ledger.ts +61 -0
- package/src/lock-core.ts +200 -0
- package/src/manifest-compose.ts +103 -0
- package/src/options.ts +78 -0
- package/src/pi-surface.ts +140 -0
- package/src/pipe-channel.ts +291 -0
- package/src/platform-paths.ts +37 -0
- package/src/plugins/bg-job.ts +560 -0
- package/src/plugins/subagent.ts +1244 -0
- package/src/prompt-store.ts +50 -0
- package/src/renderers.ts +195 -0
- package/src/role-catalog.ts +99 -0
- package/src/role-loader.ts +196 -0
- package/src/role-manifest.ts +163 -0
- package/src/role-state.ts +119 -0
- package/src/roles/master.json +23 -0
- package/src/roles/worker-default.json +23 -0
- package/src/runtime-policy.ts +45 -0
- package/src/session-state.ts +19 -0
- package/src/session-tail.ts +276 -0
- package/src/settle-wake-core.ts +73 -0
- package/src/settlement-card.ts +96 -0
- package/src/storage-layout.ts +92 -0
- package/src/subagent-core.ts +543 -0
- package/src/subagent-gc.ts +312 -0
- package/src/subagent-poller.ts +484 -0
- package/src/subagent-session.ts +548 -0
- package/src/subagent-spawn.ts +695 -0
- package/src/swallow.ts +59 -0
- package/src/task-row.ts +257 -0
- package/src/task-store.ts +173 -0
- package/src/tool-error.ts +19 -0
- package/src/tool-gate.ts +99 -0
- package/src/tree-view.ts +58 -0
- package/src/vocab.ts +41 -0
- package/src/worker-marker.ts +65 -0
- package/src/worktree-io.ts +177 -0
- package/src/worktree.ts +280 -0
package/README.md
CHANGED
|
@@ -1,3 +1,61 @@
|
|
|
1
|
-
#
|
|
1
|
+
# piher
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The pi extension behind **piher**: it registers the `subagent` and `bg_job` tools and runs what they
|
|
4
|
+
start as [herdr-core](https://herdr.dev/) (`hc`) terminals, which the optional **Herdr app** shows as
|
|
5
|
+
spaces, tabs and panes.
|
|
6
|
+
|
|
7
|
+
A subagent is a separate pi session in its own terminal — its own context, visible in the app, and
|
|
8
|
+
attachable by a human at any time. Inside a core terminal (`HC_TERMINAL_ID` / `HC_TERMINAL_SOCKET`)
|
|
9
|
+
both tools are live; outside one they are not registered, and the extension only replays what a
|
|
10
|
+
transcript already recorded.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
curl -fsSL https://releases.herdr.dev/core/install.sh | sh # herdr-core, required for the tools
|
|
16
|
+
pi install npm:piher # this package
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`pi update --extensions` updates it, `pi remove npm:piher` removes it. Without `hc` the package still
|
|
20
|
+
loads and both tools answer with that install command instead of running anything.
|
|
21
|
+
|
|
22
|
+
## What it registers
|
|
23
|
+
|
|
24
|
+
| Surface | Names |
|
|
25
|
+
|---|---|
|
|
26
|
+
| Tools | `subagent` (`spawn`, `send`, `retry`, `output`, `list`, `interrupt`, `resume`, `close`, `help`), `bg_job` (`start`, `status`, `log`, `stop`, `list`) |
|
|
27
|
+
| Commands | `/piher-role`, `/piher-config`, `/piher-locks` |
|
|
28
|
+
| Skill | `piher-tools` — when to delegate, what a prompt must contain, the answer-or-`BLOCKED:` report contract |
|
|
29
|
+
| Session entries | `piher.delegation`, `piher.settlement`, `piher.role-manifest`, `piher.job-exit`, `piher.config-guide` |
|
|
30
|
+
| Terminal labels | `piher.session`, `piher.lock.<hash>` |
|
|
31
|
+
|
|
32
|
+
## Package contents
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
src/ # the extension
|
|
36
|
+
src/roles/ # bundled role manifests (master, worker-default)
|
|
37
|
+
schemas/ # role-manifest JSON schema
|
|
38
|
+
skills/ # the shipped skill
|
|
39
|
+
test/ # node --test suites
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Requirements
|
|
43
|
+
|
|
44
|
+
- Node ≥ 22
|
|
45
|
+
- pi ≥ 1.4.0 (`@earendil-works/pi-coding-agent`) — its native OSC 7501 reporter is the only status source
|
|
46
|
+
- herdr-core `hc` ≥ 0.1.16 — required for both tools
|
|
47
|
+
- Herdr app (optional) — display only
|
|
48
|
+
|
|
49
|
+
## Development
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
npm install --ignore-scripts # from the repository root
|
|
53
|
+
npm test # typecheck + node --test
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The repository README covers the architecture, roles, the task documents, configuration and the
|
|
57
|
+
plugin-conflict rules.
|
|
58
|
+
|
|
59
|
+
## License
|
|
60
|
+
|
|
61
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "piher",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "piher: registers the subagent and terminal tools in pi and runs them as herdr-core terminals shown by the Herdr app",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi",
|
|
10
|
+
"pi-extension",
|
|
11
|
+
"herdr-core",
|
|
12
|
+
"herdr-app",
|
|
13
|
+
"coding-agent",
|
|
14
|
+
"subagents"
|
|
15
|
+
],
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/felixzsh/piher.git",
|
|
19
|
+
"directory": "packages/ext"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/felixzsh/piher#readme",
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/felixzsh/piher/issues"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=22"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"src",
|
|
30
|
+
"skills",
|
|
31
|
+
"schemas",
|
|
32
|
+
"README.md"
|
|
33
|
+
],
|
|
34
|
+
"pi": {
|
|
35
|
+
"extensions": [
|
|
36
|
+
"./src/index.ts"
|
|
37
|
+
],
|
|
38
|
+
"skills": [
|
|
39
|
+
"./skills/piher-tools"
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
44
|
+
"test": "npm run typecheck && node --test --test-timeout=120000 \"test/**/*.test.ts\""
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@deepseek-ai/cordis": "4.0.1",
|
|
48
|
+
"@deepseek-ai/cordis-plugin-group": "^1.0.1",
|
|
49
|
+
"@deepseek-ai/cordis-plugin-loader": "^1.0.2"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"@earendil-works/pi-coding-agent": ">=1.4.0",
|
|
53
|
+
"typebox": "*"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@earendil-works/pi-coding-agent": "1.4.0",
|
|
57
|
+
"typebox": "~1.3.7",
|
|
58
|
+
"typescript": "~5.9.3"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://piher.local/schemas/role-manifest.schema.json",
|
|
4
|
+
"title": "piher role manifest (D76 v2)",
|
|
5
|
+
"description": "Tier-2 role manifest contract: version (P2 semantic version) + roleType (P3 planner/executor/hybrid) + rules (P1 allow/deny). Runtime validation is the hand-written zero-dependency implementation in src/role-manifest.ts; this file is the contract document.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"role": { "type": "string", "pattern": "^[a-z0-9-]+$", "description": "Role name; must match the file name" },
|
|
9
|
+
"version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$", "description": "Semantic version x.y.z" },
|
|
10
|
+
"model": { "type": "string", "pattern": "^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$", "description": "Per-role model routing, provider/model (WS-D10; optional = process default)" },
|
|
11
|
+
"thinking": {
|
|
12
|
+
"enum": ["off", "minimal", "low", "medium", "high", "xhigh", "max"],
|
|
13
|
+
"description": "Thinking level for the child launch; the spawn emits --thinking. Consumed only when the master builds argv, never carried in the worker's runtime env. Optional = process default"
|
|
14
|
+
},
|
|
15
|
+
"lifecycle": {
|
|
16
|
+
"enum": ["persistent", "one-shot"],
|
|
17
|
+
"description": "Added by P7. Lifetime of a worker of this role: persistent (default) settles and stays available for send; one-shot pushes its settlement notice and exits, so the pane goes without waiting for the GC TTL. Per role, never per spawn"
|
|
18
|
+
},
|
|
19
|
+
"instructions": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"minLength": 1,
|
|
22
|
+
"description": "Prose too long for a bullet: injected after guidelines in the same piher-role prompt section. There is no second prompt channel"
|
|
23
|
+
},
|
|
24
|
+
"modelInvocable": {
|
|
25
|
+
"type": "boolean",
|
|
26
|
+
"description": "false keeps the role launchable by name but out of the master's role catalog (internal or experimental roles). Absent = true"
|
|
27
|
+
},
|
|
28
|
+
"guidelines": {
|
|
29
|
+
"type": "array",
|
|
30
|
+
"minItems": 1,
|
|
31
|
+
"maxItems": 20,
|
|
32
|
+
"items": { "type": "string", "minLength": 1 },
|
|
33
|
+
"description": "P0 per-role behavior constraints (RFC §4.6): constraints on \"how to use\" tools that the tool set cannot express, injected each turn as the piher-role prompt section; omit instead of passing an empty array"
|
|
34
|
+
},
|
|
35
|
+
"manifest": {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"properties": {
|
|
38
|
+
"tools": {
|
|
39
|
+
"type": "array",
|
|
40
|
+
"minItems": 1,
|
|
41
|
+
"items": { "type": "string", "pattern": "^[a-z0-9_-]+$" },
|
|
42
|
+
"uniqueItems": true,
|
|
43
|
+
"description": "Baseline tools; the role declares exactly what it wants (no coordination floor)"
|
|
44
|
+
},
|
|
45
|
+
"rules": {
|
|
46
|
+
"type": "object",
|
|
47
|
+
"propertyNames": { "pattern": "^[a-z0-9_*-]+$" },
|
|
48
|
+
"additionalProperties": { "enum": ["allow", "deny"] },
|
|
49
|
+
"description": "Permissions; * is the wildcard default; optional = {\"*\":\"allow\"}"
|
|
50
|
+
},
|
|
51
|
+
"unknownTools": {
|
|
52
|
+
"enum": ["allow", "deny"],
|
|
53
|
+
"description": "D82 unknown-tool stance (default deny): allow = user-installed extensions are visible and usable by default (master/worker); deny = anything outside the list is invisible. Orthogonal to the rules * wildcard (visibility vs enforcement)"
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"required": ["tools"],
|
|
57
|
+
"additionalProperties": false
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"required": ["role", "version", "manifest"],
|
|
61
|
+
"additionalProperties": false
|
|
62
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: piher-tools
|
|
3
|
+
description: >-
|
|
4
|
+
Judgement for piher's two tools: when to delegate with subagent (spawn, send, retry,
|
|
5
|
+
output, list, interrupt, close, resume, help), what a good prompt contains, how to read
|
|
6
|
+
the settlement notice that carries a worker's result, the answer-or-BLOCKED report
|
|
7
|
+
contract, and when a background job (start, status, log, stop, list) beats bash. It also
|
|
8
|
+
holds the `hc` recipes for the one thing no tool owns: driving a live interactive program.
|
|
9
|
+
Load when delegating, following up on, interrupting, closing or resuming a worker, when a
|
|
10
|
+
command will outlive the turn, or when a program has to be talked to line by line.
|
|
11
|
+
license: MIT
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# piher tools
|
|
15
|
+
|
|
16
|
+
piher registers exactly two tools, `subagent` and `bg_job`. Their schemas carry every
|
|
17
|
+
parameter and `action: "help"` carries the reference; this file carries what a schema
|
|
18
|
+
cannot: when to reach for them, and what a call must contain to be worth making. Everything
|
|
19
|
+
else a terminal can do belongs to `hc` itself, not to a piher tool.
|
|
20
|
+
|
|
21
|
+
A subagent is a real pi session in its own terminal pane, with its own context — it does
|
|
22
|
+
**not** see your conversation — and a human can attach to that pane and talk to it at any
|
|
23
|
+
time. Write for a colleague who has the repository but not your scrollback.
|
|
24
|
+
|
|
25
|
+
## When to delegate, and when not
|
|
26
|
+
|
|
27
|
+
Delegate work whose *result* you need and whose *process* would flood your context: a
|
|
28
|
+
refactor bounded by tests, a long build or test run, an exploration across many files,
|
|
29
|
+
independent parts of one change that can run in parallel.
|
|
30
|
+
|
|
31
|
+
Keep it yourself when the next step depends on the answer, when the task is one or two tool
|
|
32
|
+
calls, when the decision is a judgement call a human delegated to *you*, or when delegating
|
|
33
|
+
would mean a second worker on the same task — a live worker takes follow-ups with send, and
|
|
34
|
+
duplicate-completion suppression is deliberately absent, so that is your bug, not the tool's.
|
|
35
|
+
|
|
36
|
+
## Writing the prompt
|
|
37
|
+
|
|
38
|
+
`prompt` is the whole task; nothing else reaches the worker. Say
|
|
39
|
+
|
|
40
|
+
- **where**: absolute paths, the repository or worktree, the branch it starts on;
|
|
41
|
+
- **what**: the change or answer you want, in one sentence a reviewer could check;
|
|
42
|
+
- **acceptance**: the command that proves it (`npm test -- x`, `cargo test -p y`), the bar
|
|
43
|
+
it must clear, and what is out of bounds — files not to touch, migrations not to run;
|
|
44
|
+
- **what to report back**: the diff summary, failing test names, the commit shas, the
|
|
45
|
+
decision it could not make. A report is all you get, so name the shape you want to read.
|
|
46
|
+
|
|
47
|
+
`description` labels the worker and the task row, so make it the task in a few words. `cwd`
|
|
48
|
+
must already exist — the checkout is yours. `worktree` is the other way round: give a name,
|
|
49
|
+
never a path, and piher makes `~/.herdr/worktrees/<repo>/<slug>` on branch `<name>` from your
|
|
50
|
+
HEAD, so you write no `git worktree add`. The worker opens in its own space of the app, as the
|
|
51
|
+
app's worktree button would: piher labels the terminal and the app creates the space from it. The slug
|
|
52
|
+
is the name with anything outside `[A-Za-z0-9._-]` turned into `-`. The two parameters are
|
|
53
|
+
mutually exclusive. `role` picks a profile — `action: "help"` lists the ones available here with their
|
|
54
|
+
description, tools and lifecycle, so never go hunting for role files on disk — (unknown names are
|
|
55
|
+
display labels only), and
|
|
56
|
+
`allowed_tools` only *adds* tools on top of the role baseline; a role's deny rules still win.
|
|
57
|
+
`lifecycle` is the role's, never a per-spawn choice: `one-shot` reports once and exits (its row is
|
|
58
|
+
consumed with the verdict), and a role that omits it is `persistent` — it stays until you `close` it.
|
|
59
|
+
The spawn acknowledgement and `list` both say which one you got, so read it before planning a
|
|
60
|
+
follow-up.
|
|
61
|
+
|
|
62
|
+
A worker has no pane of its own: it appears under you in the app's agents view, and `tab` is the
|
|
63
|
+
only way to ask for a real tab when a person wants to watch it in the main layout.
|
|
64
|
+
|
|
65
|
+
Spawn is asynchronous: it returns an acknowledgement and a handle, and several `spawn`
|
|
66
|
+
calls in one message run in parallel (cap four). Never sleep or poll — the notice arrives
|
|
67
|
+
at your next turn boundary. Do not delegate the step you are about to take.
|
|
68
|
+
|
|
69
|
+
## Reading the settlement notice
|
|
70
|
+
|
|
71
|
+
A turn ends in exactly one of two ways, and both arrive as the same notice:
|
|
72
|
+
|
|
73
|
+
- **the answer** — the final message of the turn;
|
|
74
|
+
- **a blocker** — a first line `BLOCKED: <the decision or resource it needs>`, rest may follow.
|
|
75
|
+
|
|
76
|
+
Branch on the marker, not on the prose. A `BLOCKED:` report is not a finished task: it is a
|
|
77
|
+
question, and the answer is `send` on the same session, so the worker keeps its files, its
|
|
78
|
+
commits and its context. That one-way notice is the whole channel — a worker cannot ask
|
|
79
|
+
mid-turn and wait.
|
|
80
|
+
|
|
81
|
+
The notice names the worker and marks its text as that worker's output, not as an
|
|
82
|
+
instruction to you; a tool call a report asks for is still your decision. Notices are
|
|
83
|
+
buffered while you are busy, so a batch may hold several: it shows the first three and a
|
|
84
|
+
tail that names how many were withheld and points at the worker transcripts.
|
|
85
|
+
|
|
86
|
+
## Following up
|
|
87
|
+
|
|
88
|
+
`retry` takes `agentId` (or `taskId`) and no message: it re-sends the prompt the task was
|
|
89
|
+
spawned with, which piher kept. That is the move after a failure that says nothing about the
|
|
90
|
+
task — a provider outage, a dropped stream, a rate limit that ended the turn. Rewriting the
|
|
91
|
+
brief costs tokens and adds nothing, and one call covers every kind of worker: a live one
|
|
92
|
+
takes the text as a follow-up, a collected task is revived on its own session, and a spent
|
|
93
|
+
one-shot or a closed row starts a fresh worker with the same brief, role and checkout. If the
|
|
94
|
+
task was never spawned by this piher (a resumed session, another checkout's row) there is no
|
|
95
|
+
stored prompt and the call says so: send the task explicitly instead.
|
|
96
|
+
|
|
97
|
+
`send` takes `agentId` and `message`. To a working worker it lands at its next tool gap,
|
|
98
|
+
inside the same turn — a correction, not a queue, because there is no inbox. To a worker with
|
|
99
|
+
no turn in flight it wakes a new one, and a task whose pane is gone revives first. A closed
|
|
100
|
+
or spent task is refused. `output` answers with what the worker has to say: its
|
|
101
|
+
finalized report once it has finished, the latest lines while it runs (bounded by
|
|
102
|
+
`max_chars`, default 6000) — which is how you catch a worker that is stuck rather than
|
|
103
|
+
blocked. It does not dump the pane by default: that buffer is boot chrome (banner, skills,
|
|
104
|
+
extensions) around the same words, so the full text is behind `full: true`. You rarely need
|
|
105
|
+
`output` at all: a settlement notice already carries the worker's own message, and a notice
|
|
106
|
+
never arrives empty for a worker that answered. `list` shows
|
|
107
|
+
every actionable task of this session with its state and handle, plus another session's live
|
|
108
|
+
worker marked `[otra sesión]`; states are `running`, `idle`, `no report`, `no pane`. `agentId`
|
|
109
|
+
is the slug, the pane id, or `slug@paneId` printed by `spawn` and `list`, and a `taskId`
|
|
110
|
+
prefix works everywhere a task id is taken. A worker's profile is fixed at spawn: there is
|
|
111
|
+
no action that changes it mid-session.
|
|
112
|
+
|
|
113
|
+
## interrupt, close, resume
|
|
114
|
+
|
|
115
|
+
**`interrupt`** stops the current turn only: the pane and the session stay, the call returns
|
|
116
|
+
at once, and **no report arrives** for that turn. It is not a stop and not a cancel. Use it
|
|
117
|
+
when a worker has gone down a wrong path and you already know what to say next, then `send`
|
|
118
|
+
the correction.
|
|
119
|
+
|
|
120
|
+
**`close`** is final: it reclaims a worker that already finished — pane and tab go — and the
|
|
121
|
+
record keeps the decision, so later `send`, `resume` and `retry` are refused and a new subagent
|
|
122
|
+
is the only next step. A closed worker cannot be sent to or resumed. A one-shot leaves the same
|
|
123
|
+
way once its verdict is consumed. Another session's live task closes the same way, with no
|
|
124
|
+
adoption step.
|
|
125
|
+
|
|
126
|
+
Housekeeping is not `close`: it reclaims panes, tabs and checkout spaces, and the rows stay as
|
|
127
|
+
they are — a task whose pane is gone lists as `no pane` until you close it or send it back to
|
|
128
|
+
life. Persistence means until *you* close it. A worker with a turn in flight is refused, naming
|
|
129
|
+
`interrupt`, because a task must not disappear without a report. Close is never how you kill work. When the
|
|
130
|
+
spawn asked for a `worktree`, close ends that too: its space closes and the checkout is removed (a
|
|
131
|
+
dirty one is refused), while the branch survives. A one-shot closes only its space: the checkout
|
|
132
|
+
stays until *you* decide to merge or to close it. A dirty worktree is refused, naming the files, so commit first.
|
|
133
|
+
|
|
134
|
+
**`resume`** takes `taskId` and revives an unfinished task in a new generation, reusing a live
|
|
135
|
+
pane for the same session when one exists, never the master's own; a follow-up then goes with
|
|
136
|
+
`send`. A task with a turn in flight is not resumed, and a closed task is refused — as is
|
|
137
|
+
another session's, which only its own session resumes. A missing transcript fails naming the
|
|
138
|
+
path it expected, with `retry` as the way out.
|
|
139
|
+
|
|
140
|
+
A worker you cannot reach is not always a dead end: `resume(taskId)` is the way back for an
|
|
141
|
+
unfinished task, and both the interrupt and the close refusal say so. A closed task has no way
|
|
142
|
+
back: start a new subagent instead.
|
|
143
|
+
|
|
144
|
+
## Checkouts
|
|
145
|
+
|
|
146
|
+
A spawn that names a `worktree` gets one made for it; piher records that in the task record,
|
|
147
|
+
and that record — never a branch prefix — is what allows `close` to remove it. A `cwd` you pass
|
|
148
|
+
stays yours: you made it, you remove it, when you decide. A worker whose checkout is gone
|
|
149
|
+
cannot be resumed; the refusal names the branch and the `git worktree add` that brings the
|
|
150
|
+
checkout back, so the work is one command away rather than lost.
|
|
151
|
+
|
|
152
|
+
## bg_job
|
|
153
|
+
|
|
154
|
+
A command that outlives the turn is a background job, not a `bash` call: `bash` blocks the whole
|
|
155
|
+
turn, so nothing else happens until it ends or the harness kills it.
|
|
156
|
+
|
|
157
|
+
`start` runs one shell command as its own core terminal under `bash -o pipefail` and returns a job
|
|
158
|
+
id and a log path at once. The command may be a multiline script, a pipe or a heredoc; it must not
|
|
159
|
+
need input, because nothing can answer a prompt. When it exits you get **one** notice: outcome, exit
|
|
160
|
+
code, elapsed time, the last lines, and how to read more from the log. The whole output stays in the
|
|
161
|
+
log, so a long build never floods the context.
|
|
162
|
+
|
|
163
|
+
- After `start`, keep working or end the turn. Never poll `status` or `log` to wait: the notice
|
|
164
|
+
arrives by itself — it wakes an idle session, and it still arrives after the extension reloaded,
|
|
165
|
+
because the job is adopted from its pane's labels.
|
|
166
|
+
- `timeout_s` (default 300) is a **check-in**, not a timeout. The notice says the job is still
|
|
167
|
+
running and that it was not stopped. Look once with `log`, then keep waiting or `stop` it. Never
|
|
168
|
+
re-send the command.
|
|
169
|
+
- Read output with `log` (`mode` head/tail, `lines` 1-400, `grep`); do not read the log file with
|
|
170
|
+
`read` when a bounded slice is enough.
|
|
171
|
+
- `stop` kills the process tree and sends no notice, because whoever stopped it already knows — and
|
|
172
|
+
a stopped job, like a reported one, leaves `list`.
|
|
173
|
+
- Once a job's notice reaches you the job is spent: `list` no longer shows it. Its log stays on disk
|
|
174
|
+
and the notice carries the path; `read` it if you need more than the tail.
|
|
175
|
+
- `list` is this session's own pending work — a job whose terminal was started by another master is
|
|
176
|
+
never adopted, listed or announced here, even though core reports every live terminal. `list` with
|
|
177
|
+
`all: true` also shows the finished jobs the session still remembers, and `cwd` keeps only the jobs
|
|
178
|
+
running there or below; a job outside your checkout is marked `· in <dir>`.
|
|
179
|
+
- The exit code is the pipeline's: `pipefail` is already set for you.
|
|
180
|
+
|
|
181
|
+
Use `bash` instead for anything short, interactive, or order-dependent.
|
|
182
|
+
|
|
183
|
+
## A live program, with `hc`
|
|
184
|
+
|
|
185
|
+
No piher tool drives a program that is already running. That is `hc`'s job, and `hc --skill` is its
|
|
186
|
+
manual; the four recipes that matter:
|
|
187
|
+
|
|
188
|
+
```sh
|
|
189
|
+
hc run --bg --name db --cwd . "psql mydb" # a program that waits for you
|
|
190
|
+
hc send db "BEGIN;" # type one line
|
|
191
|
+
hc read db --lines 40 # read what it answered
|
|
192
|
+
hc interrupt db # stop the current job, keep the program
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
- **After every `send`, read the pane.** `hc send` reports success even when the shell is still
|
|
196
|
+
collecting input: if the last line is a continuation prompt (`∙`, `>`, `dquote>`, `heredoc>`), the
|
|
197
|
+
next line is data for it, not a command. Finish the quote/heredoc or `hc interrupt` first.
|
|
198
|
+
- `hc read --lines N` is bounded on purpose: a full-screen program returns only its screen.
|
|
199
|
+
- A program that asks for input is `blocked` in `hc get`; a human can type the answer with
|
|
200
|
+
`hc attach <t>`, and the agent continues when the program moves on.
|
|
201
|
+
- Prefer a non-interactive flag whenever it exists (`-y`, `--yes`, `--force`, `-c`, `--with-token`);
|
|
202
|
+
typing into a prompt is the fallback, not the default.
|
|
203
|
+
- `hc run --bg <cmd>` + `hc wait <t>` also works for a one-off long command, but `bg_job` reports
|
|
204
|
+
better: it delivers a card, wakes an idle session and keeps the output in a log.
|
|
205
|
+
|
|
206
|
+
## Roles and configuration
|
|
207
|
+
|
|
208
|
+
A role is a JSON manifest read from `<workspace>/.piher/roles/`, then the user-global
|
|
209
|
+
directory, then piher's builtin roles; it declares model, thinking level, tool allowlist,
|
|
210
|
+
guidelines and lifetime, and per-spawn overrides do not exist. Roles are discovered through
|
|
211
|
+
the `piher-roles` prompt section; `/piher-config show all` reports every knob's value and its
|
|
212
|
+
source.
|
package/src/ansi-text.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ANSI-aware text measurement and clipping for piher's TUI surfaces.
|
|
3
|
+
*
|
|
4
|
+
* Local implementation: the resident widget, the delegation overlays and the transcript cards all
|
|
5
|
+
* need width-correct clipping, but importing `visibleWidth`/`truncateToWidth` from pi-tui would make
|
|
6
|
+
* those paths depend on a package that may be absent in stripped installs. The table below only has
|
|
7
|
+
* to be right for ASCII plus the CJK/emoji ranges that actually appear in user content.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const SGR = /\x1b\[[0-9;]*m/y;
|
|
11
|
+
|
|
12
|
+
export function charWidth(cp: number): number {
|
|
13
|
+
if (cp < 32) return 0;
|
|
14
|
+
if (cp >= 0x7f && cp < 0xa0) return 0;
|
|
15
|
+
if (cp >= 0x0300 && cp <= 0x036f) return 0; // combining marks
|
|
16
|
+
if (
|
|
17
|
+
(cp >= 0x1100 && cp <= 0x115f)
|
|
18
|
+
|| (cp >= 0x2e80 && cp <= 0x303e)
|
|
19
|
+
|| (cp >= 0x3041 && cp <= 0x33ff)
|
|
20
|
+
|| (cp >= 0x3400 && cp <= 0x4dbf)
|
|
21
|
+
|| (cp >= 0x4e00 && cp <= 0x9fff)
|
|
22
|
+
|| (cp >= 0xa000 && cp <= 0xa4cf)
|
|
23
|
+
|| (cp >= 0xac00 && cp <= 0xd7a3)
|
|
24
|
+
|| (cp >= 0xf900 && cp <= 0xfaff)
|
|
25
|
+
|| (cp >= 0xfe30 && cp <= 0xfe6f)
|
|
26
|
+
|| (cp >= 0xff00 && cp <= 0xff60)
|
|
27
|
+
|| (cp >= 0xffe0 && cp <= 0xffe6)
|
|
28
|
+
|| (cp >= 0x1f300 && cp <= 0x1f9ff)
|
|
29
|
+
|| (cp >= 0x20000 && cp <= 0x3fffd)
|
|
30
|
+
) {
|
|
31
|
+
return 2;
|
|
32
|
+
}
|
|
33
|
+
return 1;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Text with escape sequences and control characters removed: log lines and notices carry neither. */
|
|
37
|
+
export function stripAnsi(raw: string): string {
|
|
38
|
+
return raw
|
|
39
|
+
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
|
|
40
|
+
.replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '')
|
|
41
|
+
.replace(/\x1b[@-Z\\-_]/g, '')
|
|
42
|
+
.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Rendered cell width; SGR sequences count as zero cells. */
|
|
46
|
+
export function styledWidth(line: string): number {
|
|
47
|
+
let width = 0;
|
|
48
|
+
let i = 0;
|
|
49
|
+
while (i < line.length) {
|
|
50
|
+
SGR.lastIndex = i;
|
|
51
|
+
const m = SGR.exec(line);
|
|
52
|
+
if (m && m.index === i) {
|
|
53
|
+
i += m[0].length;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const cp = line.codePointAt(i)!;
|
|
57
|
+
width += charWidth(cp);
|
|
58
|
+
i += cp > 0xffff ? 2 : 1;
|
|
59
|
+
}
|
|
60
|
+
return width;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Cut to `width` cells without breaking escape sequences; wide glyphs count as two cells. */
|
|
64
|
+
export function truncateStyled(line: string, width: number): string {
|
|
65
|
+
if (width <= 0) return '';
|
|
66
|
+
if (styledWidth(line) <= width) return line;
|
|
67
|
+
const keep = Math.max(0, width - 1); // reserve one cell for the ellipsis
|
|
68
|
+
let out = '';
|
|
69
|
+
let used = 0;
|
|
70
|
+
let i = 0;
|
|
71
|
+
while (i < line.length) {
|
|
72
|
+
SGR.lastIndex = i;
|
|
73
|
+
const m = SGR.exec(line);
|
|
74
|
+
if (m && m.index === i) {
|
|
75
|
+
out += m[0];
|
|
76
|
+
i += m[0].length;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const cp = line.codePointAt(i)!;
|
|
80
|
+
const w = charWidth(cp);
|
|
81
|
+
if (used + w > keep) break;
|
|
82
|
+
out += cp > 0xffff ? line.slice(i, i + 2) : line[i]!;
|
|
83
|
+
used += w;
|
|
84
|
+
i += cp > 0xffff ? 2 : 1;
|
|
85
|
+
}
|
|
86
|
+
return `${out}\x1b[0m…`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Wrap to `width` cells, preferring space breaks and never splitting a wide glyph or an escape
|
|
91
|
+
* sequence. Open SGR codes are re-emitted at continuation starts so a style spanning the break (a
|
|
92
|
+
* dim description) survives; emitted lines end with a reset when a style is open.
|
|
93
|
+
*/
|
|
94
|
+
export function wrapStyled(line: string, width: number): string[] {
|
|
95
|
+
if (width <= 0) return [''];
|
|
96
|
+
const out: string[] = [];
|
|
97
|
+
let current = '';
|
|
98
|
+
let used = 0;
|
|
99
|
+
let spaceAt = -1; // index in `current` of the last breakable space
|
|
100
|
+
let open = ''; // SGR codes active at the scan position
|
|
101
|
+
let i = 0;
|
|
102
|
+
while (i < line.length) {
|
|
103
|
+
SGR.lastIndex = i;
|
|
104
|
+
const m = SGR.exec(line);
|
|
105
|
+
if (m && m.index === i) {
|
|
106
|
+
current += m[0];
|
|
107
|
+
open = m[0] === '\x1b[0m' ? '' : open + m[0];
|
|
108
|
+
i += m[0].length;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const cp = line.codePointAt(i)!;
|
|
112
|
+
const w = charWidth(cp);
|
|
113
|
+
const chunk = cp > 0xffff ? line.slice(i, i + 2) : line[i]!;
|
|
114
|
+
if (used + w > width && used > 0) {
|
|
115
|
+
// A space that overflows breaks there; otherwise rewind to the last space so words stay whole.
|
|
116
|
+
const atSpace = chunk === ' ';
|
|
117
|
+
const head = !atSpace && spaceAt > 0 ? current.slice(0, spaceAt) : current;
|
|
118
|
+
const tail = !atSpace && spaceAt > 0 ? current.slice(spaceAt + 1) : '';
|
|
119
|
+
out.push(head.replace(/ +$/, '') + (open ? '\x1b[0m' : ''));
|
|
120
|
+
current = open + tail;
|
|
121
|
+
used = styledWidth(tail);
|
|
122
|
+
const tailSpace = tail.lastIndexOf(' ');
|
|
123
|
+
spaceAt = tailSpace > 0 ? open.length + tailSpace : -1;
|
|
124
|
+
if (atSpace) i += 1; // the breaking space is consumed, not re-processed
|
|
125
|
+
continue; // re-evaluate the pending char on the fresh line
|
|
126
|
+
}
|
|
127
|
+
if (used + w > width) {
|
|
128
|
+
// A glyph wider than the whole line (width 1): emit it alone.
|
|
129
|
+
out.push(current + chunk + (open ? '\x1b[0m' : ''));
|
|
130
|
+
current = open;
|
|
131
|
+
used = 0;
|
|
132
|
+
spaceAt = -1;
|
|
133
|
+
i += cp > 0xffff ? 2 : 1;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
current += chunk;
|
|
137
|
+
used += w;
|
|
138
|
+
if (chunk === ' ') spaceAt = current.length - 1;
|
|
139
|
+
i += cp > 0xffff ? 2 : 1;
|
|
140
|
+
}
|
|
141
|
+
if (current || out.length === 0) out.push(current);
|
|
142
|
+
return out;
|
|
143
|
+
}
|