pi-pier 0.3.1 → 0.4.2
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 +7 -5
- package/package.json +3 -3
- package/schemas/efficiency-config.schema.json +6 -0
- package/src/ask-dialog.ts +9 -2
- package/src/ask-user.ts +142 -74
- package/src/bootstrap.ts +2 -1
- package/src/compact-coordinator.ts +207 -35
- package/src/compact-economics-core.ts +62 -29
- package/src/config-catalog-core.ts +1 -0
- package/src/cordis-services.ts +64 -0
- package/src/dashboard-command.ts +40 -15
- package/src/delegation-core.ts +115 -0
- package/src/efficiency-config-core.ts +17 -6
- package/src/efficiency-store.ts +43 -6
- package/src/guards.ts +6 -0
- package/src/herdr-client.ts +141 -36
- package/src/history-store.ts +2 -0
- package/src/index-gates.ts +6 -12
- package/src/index-master.ts +12 -16
- package/src/index-pipe.ts +6 -0
- package/src/index-runtime.ts +14 -0
- package/src/index-worker.ts +4 -10
- package/src/index.ts +310 -76
- package/src/jev-client.ts +32 -7
- package/src/jev-core.ts +71 -36
- package/src/observation-core.ts +13 -1
- package/src/panel-open.ts +47 -0
- package/src/pier-options.ts +1 -2
- package/src/pipe-channel.ts +9 -0
- package/src/plugins/heat-plan.ts +18 -3
- package/src/plugins/observation.ts +118 -46
- package/src/plugins/subagent.ts +119 -68
- package/src/plugins/terminal.ts +221 -57
- package/src/plugins/todo.ts +63 -63
- package/src/reducer-core.ts +3 -5
- package/src/reducer-invoker.ts +27 -13
- package/src/renderers.ts +25 -2
- package/src/role-manifest.ts +2 -3
- package/src/roles/worker-default.json +2 -2
- package/src/settle-wake-core.ts +17 -3
- package/src/subagent-core.ts +209 -3
- package/src/subagent-poller.ts +69 -19
- package/src/subagent-session.ts +67 -4
- package/src/subagent-spawn.ts +55 -7
- package/src/terminal-core.ts +178 -50
- package/src/todo-core.ts +47 -2
- package/src/todo-read-hook.ts +54 -17
- package/src/todo-reminder-core.ts +9 -5
- package/src/todo-window.ts +8 -4
- package/src/todos-service.ts +12 -0
- package/src/tree-view.ts +162 -0
- package/src/vocab.ts +7 -1
- package/src/worker-marker.ts +76 -0
- package/src/focus-poller.ts +0 -223
package/README.md
CHANGED
|
@@ -43,8 +43,7 @@ The installer also verifies node / pi / herdr versions.
|
|
|
43
43
|
| Tool | Purpose |
|
|
44
44
|
|---|---|
|
|
45
45
|
| `todo_write` | Full-replacement todo list. Session JSONL is the single source of truth; correct rollback on branch switches. Projected live onto the pane title (`▶i ○p ■b ✓c (N/M) · current task`). `/todos` command to view/edit/unblock |
|
|
46
|
-
| `subagent` | Delegate self-contained subtasks to an isolated pi session in its own herdr pane. Actions: `spawn` (default; foreground / parallel / background), `list`, `send`, `interrupt`, `resume`, `output` (incremental preview of a running subagent's pane output), `role` (switch that worker's role profile mid-session — pi
|
|
47
|
-
| `ask_user_question` | Human gate: 2-5 authored options plus a trailing free-text row (`allowOther: false` for pure choice); multiple related questions per call. TUI renders single- and multi-select through one picker dialog (multi adds checkboxes: space toggles, `a` all, live count; `recommended` seeds the cursor and pre-checks in multi); pane shows blocked in herdr while waiting |
|
|
46
|
+
| `subagent` | Delegate self-contained subtasks to an isolated pi session in its own herdr pane. Actions: `spawn` (default; foreground / parallel / background), `list`, `send`, `interrupt`, `resume`, `output` (incremental preview of a running subagent's pane output), `role` (switch that worker's role profile mid-session — pi records the toolset change as a transcript delta, so it applies on the worker's next request and survives resume), `help` (the full manual — placement, isolate, roles, ledger — kept out of the per-request description). Every spawned task gets a stable slug: outputs print `slug@paneId`, and `agentId` accepts the slug, the pane id, or the combined form |
|
|
48
47
|
| `terminal` | Persistent interactive shells in dedicated herdr panes. Actions: `open`, `send`, `read`, `signal`, `close`, `list` |
|
|
49
48
|
|
|
50
49
|
### Commands
|
|
@@ -52,13 +51,16 @@ The installer also verifies node / pi / herdr versions.
|
|
|
52
51
|
| Command | Purpose |
|
|
53
52
|
|---|---|
|
|
54
53
|
| `/todos` | Show the todo list, or edit it: `/todos done|drop|rm|unblock <fuzzy match>` |
|
|
54
|
+
| `/dashboard [overlay]` | Ops dashboard. Default opens the shared Pier **popup** on its `Dashboard` tab (90% × 80%, singleton); `overlay` opens the same view as an independent full-window pane. Non-Herdr falls back to an in-pane text view. |
|
|
55
|
+
| `/pier-tree [overlay]` | Master only: the full delegation tree — your complete todo list plus every running subagent's full list (live-pushed over the pipe) and settled nodes with ages. Default opens the same shared popup on its `Tree` tab (the snapshot refreshes on every todo/worker push; `1`/`2`/`Tab` switch views inside the popup); `overlay` opens an independent full-window pane; bare pi / a missing plugin falls back to the in-pane notify view. The resident widget stays one line per agent and hides entirely once the self list is fully completed (D113/D114) |
|
|
55
56
|
| `/locks` | Show write locks held by this pane and by live panes — the **human/operator view** of the write-lock beacons (herdr only). Agents see the holders inside their own write warnings; the raw tokens are readable via `herdr agent list` |
|
|
56
|
-
| `/pier-role` | Show the current role + available role names, or switch mid-session: `/pier-role <name>` (
|
|
57
|
+
| `/pier-role` | Show the current role + available role names, or switch mid-session: `/pier-role <name>` (widening beyond the current toolset asks for confirmation; the switch lands as a transcript tool delta) |
|
|
57
58
|
| `/pier-config` | Read-only configuration guide over four planes: bare call = index + hands a guided change to the agent; `show [plane\|all]` = effective value + source (`env > workspace > user > default`); `check` = validate all planes; `doc [path]` = write a report |
|
|
58
59
|
|
|
59
60
|
### Behaviors
|
|
60
61
|
|
|
61
62
|
- **Human-in-the-loop**: every subagent is a visible, interactive TUI pane — step into it anytime to talk directly (fix bugs, take over, answer its `ask_user_question`). Blocked gates raise sidebar markers + notifications
|
|
63
|
+
- **Delegation identity**: the worker knows who delegated it — spawn env (`PI_HERDR_DELEGATION`) replays through the worker marker and the session's own first-frame entry; its system prompt carries a `pier-delegation` section (identity + delivery contract), the transcript opens with an "⟵ delegated by master …" card, the pane title gets an `↳` prefix, and `pi-parent`/`pi-task` tokens feed the workbench dashboard tree and blocked-alert attribution
|
|
62
64
|
- **Blocking dialogs block the pane**: pi's `ui_prompt_start` / `ui_prompt_end` events cover the inherently blocking `ctx.ui` dialogs (`select` / `confirm` / `input` / `editor`), so a dialog from any extension (not only pier's own ask tool) reports the pane as blocked and emits the `herdr:blocked` edge; nested gates are coalesced. `custom` is excluded because pi also routes resident overlays through it (pier's slim-frame overlay never calls `done()`, and a resident overlay keeps pi's prompt span open anyway)
|
|
63
65
|
- **Role gate stops early**: a tool call denied by the role manifest returns `terminate`, so a batch whose results are all terminating ends without another model round trip
|
|
64
66
|
- **Readable transcript**: pier's own session entries (todo edits, subagent/terminal registries, role manifests, soft approvals) and its reminder messages render as compact cards instead of raw JSON
|
|
@@ -105,8 +107,8 @@ pi overwrites tools/commands by name; event listeners stack.
|
|
|
105
107
|
## Requirements
|
|
106
108
|
|
|
107
109
|
- Node ≥ 22
|
|
108
|
-
- pi ≥
|
|
109
|
-
- **herdr ≥ 0.9.
|
|
110
|
+
- pi ≥ 1.3.0 (`@earendil-works/pi-coding-agent`) — `before_agent_start.messageType` and resumed-session active tools
|
|
111
|
+
- **herdr ≥ 0.9.5** — required for subagents / pane integration / notifications (see the IMPORTANT note above); native `pane.focused`, self-reported resume, and shutdown-safe session saves restore subagent panes after a server restart
|
|
110
112
|
|
|
111
113
|
## Development
|
|
112
114
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-pier",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "pier: pi extension that fuses pi agent sessions with the herdr pane/tab orchestrator — todo_write loop closure + interactive subagents in isolated panes",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -49,11 +49,11 @@
|
|
|
49
49
|
"@deepseek-ai/cordis-plugin-loader": "^1.0.2"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
|
-
"@earendil-works/pi-coding-agent": "
|
|
52
|
+
"@earendil-works/pi-coding-agent": ">=1.3.0",
|
|
53
53
|
"typebox": "*"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
|
-
"@earendil-works/pi-coding-agent": "
|
|
56
|
+
"@earendil-works/pi-coding-agent": "1.3.0",
|
|
57
57
|
"typebox": "~1.3.7",
|
|
58
58
|
"typescript": "~5.9.3"
|
|
59
59
|
}
|
|
@@ -20,6 +20,12 @@
|
|
|
20
20
|
},
|
|
21
21
|
"firstCompactionRequestScale": { "type": "number", "default": 2.0, "minimum": 1.0 },
|
|
22
22
|
"subsequentCompactionMargin": { "type": "number", "default": 1.5, "minimum": 1.0 },
|
|
23
|
+
"summaryThinkingLevel": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"enum": ["inherit", "off", "minimal", "low", "medium", "high", "xhigh", "max"],
|
|
26
|
+
"default": "low",
|
|
27
|
+
"description": "压缩摘要请求的 thinking 等级上限(只降不升,结束后恢复会话等级)。inherit 表示沿用会话等级"
|
|
28
|
+
},
|
|
23
29
|
"keepRecentTokens": {
|
|
24
30
|
"type": "integer",
|
|
25
31
|
"default": 20000,
|
package/src/ask-dialog.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface DialogConfig {
|
|
|
25
25
|
recommended?: number;
|
|
26
26
|
/** true = toggle list (space toggles, enter confirms the set); false = pick-one (enter/space takes the cursor row). */
|
|
27
27
|
multi: boolean;
|
|
28
|
+
/** Multi mode only: toggles to restore when the dialog reopens (an empty Other answer does not lose them). */
|
|
29
|
+
initialSelected?: readonly boolean[];
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export interface DialogState {
|
|
@@ -38,7 +40,8 @@ type DialogKey = 'up' | 'down' | 'toggle' | 'confirm' | 'cancel' | 'all' | 'igno
|
|
|
38
40
|
|
|
39
41
|
export type DialogOutcome =
|
|
40
42
|
| { kind: 'selected'; labels: string[] }
|
|
41
|
-
|
|
43
|
+
/** `labels` are the toggled rows at the moment Other was taken, so a custom answer keeps them. */
|
|
44
|
+
| { kind: 'other'; labels: string[] }
|
|
42
45
|
| { kind: 'cancel' };
|
|
43
46
|
|
|
44
47
|
export interface DialogStep {
|
|
@@ -94,7 +97,7 @@ export function stepDialog(state: DialogState, key: DialogKey, config: DialogCon
|
|
|
94
97
|
}
|
|
95
98
|
case 'toggle':
|
|
96
99
|
case 'confirm': {
|
|
97
|
-
if (isOtherRow(state, config)) return { state, outcome: { kind: 'other' } };
|
|
100
|
+
if (isOtherRow(state, config)) return { state, outcome: { kind: 'other', labels: selectedLabels(state, config) } };
|
|
98
101
|
if (!config.multi) {
|
|
99
102
|
// Pick-one: enter and space both take the cursor row.
|
|
100
103
|
return { state, outcome: { kind: 'selected', labels: [config.options[state.cursor]!.label] } };
|
|
@@ -234,7 +237,11 @@ export function dialogFooter(
|
|
|
234
237
|
}
|
|
235
238
|
|
|
236
239
|
export function createDialogComponent(opts: DialogUiOptions): DialogComponent {
|
|
240
|
+
const initial = opts.config.initialSelected;
|
|
237
241
|
let state = createDialogState(opts.config);
|
|
242
|
+
if (initial && initial.length === opts.config.options.length) {
|
|
243
|
+
state = { ...state, selected: opts.config.options.map((_, i) => initial[i] === true) };
|
|
244
|
+
}
|
|
238
245
|
let finished = false;
|
|
239
246
|
const onAbort = (): void => finish({ kind: 'cancel' });
|
|
240
247
|
function finish(outcome: DialogOutcome): void {
|
package/src/ask-user.ts
CHANGED
|
@@ -37,49 +37,38 @@ const RESERVED_LOWER: Record<string, true> = {
|
|
|
37
37
|
};
|
|
38
38
|
|
|
39
39
|
const OptionSchema = Type.Object({
|
|
40
|
-
label: Type.String({ description: 'Short
|
|
41
|
-
description: Type.Optional(Type.String({ description: 'Tradeoff
|
|
40
|
+
label: Type.String({ description: 'Short label (1-5 words)' }),
|
|
41
|
+
description: Type.Optional(Type.String({ description: 'Tradeoff of this choice' })),
|
|
42
42
|
});
|
|
43
43
|
|
|
44
44
|
export const ASK_PARAMETERS = Type.Object({
|
|
45
|
-
question: Type.Optional(Type.String({ description: '
|
|
46
|
-
options: Type.Optional(Type.Array(OptionSchema, {
|
|
47
|
-
|
|
48
|
-
})),
|
|
49
|
-
|
|
50
|
-
allowOther: Type.Optional(Type.Boolean({ description: 'Set false to offer the authored choices only, with no free-text row (default true).' })),
|
|
51
|
-
recommended: Type.Optional(Type.Number({ description: '0-based index of the recommended option; UI adds (Recommended)' })),
|
|
45
|
+
question: Type.Optional(Type.String({ description: 'Flat form: the question. Required unless questions is set.' })),
|
|
46
|
+
options: Type.Optional(Type.Array(OptionSchema, { description: 'Flat form: 2-5 choices' })),
|
|
47
|
+
multi: Type.Optional(Type.Boolean({ description: 'Allow several selections (default false)' })),
|
|
48
|
+
allowOther: Type.Optional(Type.Boolean({ description: 'false = authored choices only, no free-text row (default true)' })),
|
|
49
|
+
recommended: Type.Optional(Type.Number({ description: '0-based default option; the UI adds "(Recommended)", never write it in the label' })),
|
|
52
50
|
questions: Type.Optional(Type.Array(
|
|
53
51
|
Type.Object({
|
|
54
52
|
id: Type.Optional(Type.String({ description: 'Stable id for mapping answers' })),
|
|
55
|
-
question: Type.String(
|
|
56
|
-
options: Type.Array(OptionSchema, {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
allowOther: Type.Optional(Type.Boolean({ description: 'Set false to offer the authored choices only, with no free-text row (default true).' })),
|
|
61
|
-
recommended: Type.Optional(Type.Number({ description: '0-based recommended option index' })),
|
|
53
|
+
question: Type.String(),
|
|
54
|
+
options: Type.Array(OptionSchema, { description: '2-5 choices' }),
|
|
55
|
+
multi: Type.Optional(Type.Boolean()),
|
|
56
|
+
allowOther: Type.Optional(Type.Boolean()),
|
|
57
|
+
recommended: Type.Optional(Type.Number()),
|
|
62
58
|
}),
|
|
63
|
-
{ description: '1-4
|
|
59
|
+
{ description: 'Batched form: 1-4 questions; wins over top-level question/options' },
|
|
64
60
|
)),
|
|
65
61
|
});
|
|
66
62
|
|
|
67
63
|
export const ASK_TOOL_DESCRIPTION = [
|
|
68
|
-
'Ask the human a
|
|
69
|
-
'
|
|
70
|
-
'While waiting, the pane shows as blocked in herdr (the human sees it and can step in).',
|
|
71
|
-
'Prefer 2-5 options with short labels; put tradeoffs in description.',
|
|
72
|
-
'Do NOT include an "Other" option — the UI appends "Other (type your own)" automatically.',
|
|
73
|
-
'Use recommended (0-based) to mark the default; "(Recommended)" is added automatically — do NOT write it in the label.',
|
|
74
|
-
'Use multi: true when several options can apply — the TUI opens an interactive toggle list (space toggles a row, a toggles all, enter confirms, esc declines) instead of making the human type numbers.',
|
|
75
|
-
'Set allowOther: false when the authored choices are exhaustive and a free-text answer would be misleading.',
|
|
64
|
+
'Ask the human and wait for the answer — only for a real decision (approval, direction, trade-off), not for information you can find yourself. While waiting, the pane shows as blocked in herdr.',
|
|
65
|
+
'Two shapes. Flat form (no questions key; do not close with ]): {"question":"Store?","options":[{"label":"Redis","description":"In-memory"},{"label":"Postgres"}]}. Batched form: {"questions":[{"question":"Store?","options":[{"label":"Redis"},{"label":"Postgres"}]},{"question":"Region?","options":[{"label":"EU"},{"label":"US"}],"multi":true}]}.',
|
|
76
66
|
].join(' ');
|
|
77
67
|
|
|
78
68
|
export const ASK_PROMPT_GUIDELINES = [
|
|
79
|
-
'Default to action. Ask only when options have materially different tradeoffs the user must decide.',
|
|
80
|
-
'Do NOT include "Other" in options
|
|
81
|
-
'
|
|
82
|
-
'Short option labels; explanatory tradeoffs in description.',
|
|
69
|
+
'Default to action. Ask only when options have materially different tradeoffs the user must decide; group related questions in one call (max 4).',
|
|
70
|
+
'Do NOT include "Other" in options: the UI appends it unless allowOther is false. Set allowOther: false when the choices are exhaustive.',
|
|
71
|
+
'Use multi: true when several options can apply; the human toggles rows and confirms — never ask them to type indices.',
|
|
83
72
|
];
|
|
84
73
|
|
|
85
74
|
export type AskOption = { label: string; description?: string };
|
|
@@ -114,6 +103,8 @@ export type AskDetails = {
|
|
|
114
103
|
export type AskToolResult = {
|
|
115
104
|
content: Array<{ type: 'text'; text: string }>;
|
|
116
105
|
details: AskDetails;
|
|
106
|
+
/** Set on failures so pi/UI do not count a rejected ask as a successful tool call. */
|
|
107
|
+
isError?: boolean;
|
|
117
108
|
};
|
|
118
109
|
|
|
119
110
|
export type AskUi = {
|
|
@@ -144,6 +135,7 @@ function errorResult(text: string, code: string): AskToolResult {
|
|
|
144
135
|
return {
|
|
145
136
|
content: [{ type: 'text', text }],
|
|
146
137
|
details: { answers: [], cancelled: true, error: code },
|
|
138
|
+
isError: true,
|
|
147
139
|
};
|
|
148
140
|
}
|
|
149
141
|
|
|
@@ -233,6 +225,42 @@ function parseQuestion(raw: unknown): { ok: true; question: AskQuestion } | { ok
|
|
|
233
225
|
};
|
|
234
226
|
}
|
|
235
227
|
|
|
228
|
+
type AskParams = Type.Static<typeof ASK_PARAMETERS>;
|
|
229
|
+
|
|
230
|
+
/** Repair the argument spellings models have sent historically, before pi validates them: `options` or
|
|
231
|
+
* `questions` as a JSON string instead of an array. Only strings that parse to arrays are replaced, so
|
|
232
|
+
* anything genuinely malformed still reaches prepareAsk and gets its normal error text. */
|
|
233
|
+
export function prepareAskArguments(args: unknown): AskParams {
|
|
234
|
+
const rec = asRecord(args);
|
|
235
|
+
if (!rec) return {};
|
|
236
|
+
const parseArray = (value: unknown): unknown[] | undefined => {
|
|
237
|
+
if (typeof value !== 'string') return undefined;
|
|
238
|
+
try {
|
|
239
|
+
const parsed: unknown = JSON.parse(value);
|
|
240
|
+
return Array.isArray(parsed) ? parsed : undefined;
|
|
241
|
+
} catch {
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
let changed = false;
|
|
246
|
+
const next: Record<string, unknown> = { ...rec };
|
|
247
|
+
const options = parseArray(rec.options);
|
|
248
|
+
if (options) {
|
|
249
|
+
next.options = options;
|
|
250
|
+
changed = true;
|
|
251
|
+
}
|
|
252
|
+
const questions = parseArray(rec.questions);
|
|
253
|
+
if (questions) {
|
|
254
|
+
next.questions = questions.map((item) => {
|
|
255
|
+
const question = asRecord(item);
|
|
256
|
+
const questionOptions = question ? parseArray(question.options) : undefined;
|
|
257
|
+
return question && questionOptions ? { ...question, options: questionOptions } : item;
|
|
258
|
+
});
|
|
259
|
+
changed = true;
|
|
260
|
+
}
|
|
261
|
+
return (changed ? next : rec) as AskParams;
|
|
262
|
+
}
|
|
263
|
+
|
|
236
264
|
export function prepareAsk(params: unknown): PrepareAskResult {
|
|
237
265
|
const rec = asRecord(params);
|
|
238
266
|
if (!rec) return fail('Error: `question` must be a non-empty string', 'empty_question');
|
|
@@ -312,15 +340,33 @@ function withId(question: AskQuestion, answer: AskAnswer): AskAnswer {
|
|
|
312
340
|
return question.id ? { ...answer, id: question.id } : answer;
|
|
313
341
|
}
|
|
314
342
|
|
|
315
|
-
/** Free-text answer behind the Other row; shared by the dialog and the typed-prompt fallback.
|
|
316
|
-
|
|
343
|
+
/** Free-text answer behind the Other row; shared by the dialog and the typed-prompt fallback. `labels`
|
|
344
|
+
* are the rows toggled when the dialog opened Other — a multi-select custom answer keeps them.
|
|
345
|
+
* Returns 'empty' for a blank answer so the caller can go back to the choices. */
|
|
346
|
+
async function askOtherText(
|
|
347
|
+
ui: AskUi,
|
|
348
|
+
question: AskQuestion,
|
|
349
|
+
labels: readonly string[],
|
|
350
|
+
signal?: AbortSignal,
|
|
351
|
+
): Promise<AskAnswer | 'empty' | undefined> {
|
|
317
352
|
const typed = await ui.input(`${question.question}\n\n${CUSTOM_ANSWER_TITLE}`, '', signal ? { signal } : undefined);
|
|
318
|
-
if (typed == null
|
|
353
|
+
if (typed == null) return undefined;
|
|
354
|
+
const customInput = typed.trim();
|
|
355
|
+
if (!customInput) return 'empty';
|
|
356
|
+
if (question.multi && labels.length > 0) {
|
|
357
|
+
return withId(question, {
|
|
358
|
+
question: question.question,
|
|
359
|
+
kind: 'multi',
|
|
360
|
+
answer: null,
|
|
361
|
+
selected: [...labels],
|
|
362
|
+
customInput,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
319
365
|
return withId(question, {
|
|
320
366
|
question: question.question,
|
|
321
367
|
kind: 'custom',
|
|
322
368
|
answer: OTHER_ANSWER,
|
|
323
|
-
customInput
|
|
369
|
+
customInput,
|
|
324
370
|
});
|
|
325
371
|
}
|
|
326
372
|
|
|
@@ -376,53 +422,71 @@ function parseTypedPicks(
|
|
|
376
422
|
}
|
|
377
423
|
|
|
378
424
|
/** Ask one question, both modes: the pier dialog, then the host's select dialog (single only), then
|
|
379
|
-
* the typed prompt. `ui.custom` is absent (or resolves undefined) in RPC mode, hence every rung.
|
|
425
|
+
* the typed prompt. `ui.custom` is absent (or resolves undefined) in RPC mode, hence every rung.
|
|
426
|
+
* A blank Other answer goes back to the choices instead of declining the question. */
|
|
380
427
|
async function askQuestion(ui: AskUi, question: AskQuestion, signal?: AbortSignal): Promise<AskAnswer | undefined> {
|
|
381
428
|
const { multi } = question;
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
question,
|
|
402
|
-
multi
|
|
403
|
-
? { question: question.question, kind: 'multi', answer: null, selected: outcome.labels }
|
|
404
|
-
: { question: question.question, kind: 'option', answer: outcome.labels[0]! },
|
|
429
|
+
const toggleToolsExpanded = ui.getToolsExpanded && ui.setToolsExpanded
|
|
430
|
+
? (): void => ui.setToolsExpanded!(!ui.getToolsExpanded!())
|
|
431
|
+
: undefined;
|
|
432
|
+
let custom = typeof ui.custom === 'function' ? ui.custom : undefined;
|
|
433
|
+
let toggled: string[] = [];
|
|
434
|
+
for (;;) {
|
|
435
|
+
const dialog = custom;
|
|
436
|
+
if (dialog) {
|
|
437
|
+
const outcome = await runSelectDialog(
|
|
438
|
+
dialog,
|
|
439
|
+
question.question,
|
|
440
|
+
{
|
|
441
|
+
options: question.options,
|
|
442
|
+
allowOther: question.allowOther,
|
|
443
|
+
multi,
|
|
444
|
+
...(question.recommended !== undefined ? { recommended: question.recommended } : {}),
|
|
445
|
+
...(toggled.length > 0 ? { initialSelected: question.options.map((option) => toggled.includes(option.label)) } : {}),
|
|
446
|
+
},
|
|
447
|
+
{ ...(signal ? { signal } : {}), ...(toggleToolsExpanded ? { toggleToolsExpanded } : {}) },
|
|
405
448
|
);
|
|
449
|
+
if (outcome?.kind === 'cancel') return undefined;
|
|
450
|
+
if (outcome?.kind === 'other') {
|
|
451
|
+
const answer = await askOtherText(ui, question, outcome.labels, signal);
|
|
452
|
+
if (answer !== 'empty') return answer;
|
|
453
|
+
toggled = outcome.labels;
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (outcome?.kind === 'selected') {
|
|
457
|
+
return withId(
|
|
458
|
+
question,
|
|
459
|
+
multi
|
|
460
|
+
? { question: question.question, kind: 'multi', answer: null, selected: outcome.labels }
|
|
461
|
+
: { question: question.question, kind: 'option', answer: outcome.labels[0]! },
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
// The host cannot render custom components (RPC mode): fall through to the typed prompt.
|
|
465
|
+
custom = undefined;
|
|
406
466
|
}
|
|
467
|
+
const lines = optionLines(question);
|
|
468
|
+
const opts = signal ? { signal } : undefined;
|
|
469
|
+
// A multi question has no single-pick host dialog; its typed prompt is always the fallback.
|
|
470
|
+
const select = multi ? undefined : ui.select;
|
|
471
|
+
const hint = multi
|
|
472
|
+
? (question.allowOther ? MULTI_INSTRUCTIONS : MULTI_NO_OTHER_INSTRUCTIONS)
|
|
473
|
+
: NUMBERED_HINT;
|
|
474
|
+
const raw = select
|
|
475
|
+
? await select(question.question, lines, opts)
|
|
476
|
+
: await ui.input(`${question.question}\n\n${lines.join('\n')}\n\n${hint}`, multi ? '1,3' : '1', opts);
|
|
477
|
+
if (raw == null) return undefined;
|
|
478
|
+
const picks = parseTypedPicks(question, lines, raw, select === undefined);
|
|
479
|
+
if (picks === 'other') {
|
|
480
|
+
const answer = await askOtherText(ui, question, [], signal);
|
|
481
|
+
if (answer !== 'empty') return answer;
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
return picks?.[0];
|
|
407
485
|
}
|
|
408
|
-
const lines = optionLines(question);
|
|
409
|
-
const opts = signal ? { signal } : undefined;
|
|
410
|
-
// A multi question has no single-pick host dialog; its typed prompt is always the fallback.
|
|
411
|
-
const select = multi ? undefined : ui.select;
|
|
412
|
-
const hint = multi
|
|
413
|
-
? (question.allowOther ? MULTI_INSTRUCTIONS : MULTI_NO_OTHER_INSTRUCTIONS)
|
|
414
|
-
: NUMBERED_HINT;
|
|
415
|
-
const raw = select
|
|
416
|
-
? await select(question.question, lines, opts)
|
|
417
|
-
: await ui.input(`${question.question}\n\n${lines.join('\n')}\n\n${hint}`, multi ? '1,3' : '1', opts);
|
|
418
|
-
if (raw == null) return undefined;
|
|
419
|
-
const picks = parseTypedPicks(question, lines, raw, select === undefined);
|
|
420
|
-
if (picks === 'other') return askOtherText(ui, question, signal);
|
|
421
|
-
return picks?.[0];
|
|
422
486
|
}
|
|
423
487
|
|
|
424
488
|
export function formatAskContent(details: AskDetails): string {
|
|
425
|
-
if (details.
|
|
489
|
+
if (details.answers.length === 0) return DECLINE_TEXT;
|
|
426
490
|
const entries = details.answers.map((answer) => {
|
|
427
491
|
if (answer.kind === 'custom' && answer.answer === OTHER_ANSWER) {
|
|
428
492
|
const notes = answer.customInput ? ` user notes: ${answer.customInput}` : '';
|
|
@@ -430,11 +494,15 @@ export function formatAskContent(details: AskDetails): string {
|
|
|
430
494
|
}
|
|
431
495
|
if (answer.kind === 'multi') {
|
|
432
496
|
const labels = answer.selected?.length ? answer.selected.join(', ') : '(none)';
|
|
433
|
-
|
|
497
|
+
const notes = answer.customInput ? ` user notes: ${answer.customInput}` : '';
|
|
498
|
+
return `"${answer.question}"="${labels}"${notes}`;
|
|
434
499
|
}
|
|
435
500
|
return `"${answer.question}"="${answer.answer ?? ''}"`;
|
|
436
501
|
});
|
|
437
|
-
|
|
502
|
+
const answered = `User has answered your questions: ${entries.join(', ')}.`;
|
|
503
|
+
return details.cancelled
|
|
504
|
+
? `${answered} The user declined the remaining questions — continue with these answers and your best judgment.`
|
|
505
|
+
: `${answered} You can now continue with the user's answers in mind.`;
|
|
438
506
|
}
|
|
439
507
|
|
|
440
508
|
export async function runAsk(spec: AskSpec, ui: AskUi, signal?: AbortSignal): Promise<AskToolResult> {
|
package/src/bootstrap.ts
CHANGED
|
@@ -12,6 +12,7 @@ import Group from '@deepseek-ai/cordis-plugin-group';
|
|
|
12
12
|
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
13
13
|
import * as path from 'node:path';
|
|
14
14
|
import { DisposeLedger } from './ledger.ts';
|
|
15
|
+
import { cordisProvide } from './cordis-services.ts';
|
|
15
16
|
import { pierOption } from './pier-options.ts';
|
|
16
17
|
|
|
17
18
|
export interface BootstrapHooks {
|
|
@@ -37,7 +38,7 @@ export async function createCordisApp(hooks: BootstrapHooks = {}): Promise<Cordi
|
|
|
37
38
|
const root = new Context();
|
|
38
39
|
const ledger = new DisposeLedger();
|
|
39
40
|
// Inject the ledger through Cordis DI because loader transforms entry config and cannot carry live instances.
|
|
40
|
-
root
|
|
41
|
+
cordisProvide(root, 'pi-herdr.ledger', ledger);
|
|
41
42
|
if (hooks.onDispose) {
|
|
42
43
|
root.effect(() => () => { hooks.onDispose?.(); }, 'session-root');
|
|
43
44
|
}
|