pi-herdr-subagents-lite 0.1.0 → 0.3.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 CHANGED
@@ -9,9 +9,9 @@ Experimental; compatibility across versions is not promised. Exact behavior live
9
9
  ## Features
10
10
 
11
11
  - **Separate process, human takeover**: each subagent is its own pi process in a herdr pane. Same cwd as the parent opens a tab in the same workspace, otherwise a new workspace. Switch into the pane and take over at any time.
12
- - **Profile layers**: Markdown + frontmatter under `<agent-dir>/pi-herdr-subagents-lite/profiles/`, plus a project layer under `.pi/pi-herdr-subagents-lite/profiles/` that wins on the same name.
12
+ - **Profile layers**: Markdown + frontmatter under `<agent-dir>/pi-herdr-subagents-lite/profiles/`, plus a project layer under `.pi/pi-herdr-subagents-lite/profiles/` that wins on the same `name`.
13
13
  - **Async, merged results**: `run` returns at once. Each finished child reports one `subagent_result` message; results inside `aggregateWindowMs` merge into one. Oversize results go to an artifact file.
14
- - **Questions stay with the human**: a child prompt produces `subagent_question` plus a `herdr:blocked` event. The human answers in the child pane; the parent model never relays the answer.
14
+ - **Questions stay with the human**: a child prompt marks its pane `herdr:blocked` and nothing goes to the parent. The human answers in the child pane; no model relays the answer.
15
15
  - **No nesting by default, no sandbox**: child tool sets drop this tool unless the profile names it. The profile picks tools; the extension does not restrict writes.
16
16
 
17
17
  ## Requirements
@@ -36,7 +36,7 @@ Upgrade with `pi update --extensions`, remove with `pi remove <source>`. After a
36
36
  ## Quick start
37
37
 
38
38
  1. Start `pi` in a herdr pane.
39
- 2. Write a profile (next section); the file name without `.md` is the name `run` takes.
39
+ 2. Write a profile (next section); its frontmatter `name` is the key `run` takes. The file name is not used for lookup.
40
40
  3. Ask the model to call the tool:
41
41
 
42
42
  ```json
@@ -51,12 +51,12 @@ It returns `{"state":"running",...}`, runs in its own tab, and a `[subagent expl
51
51
 
52
52
  ## Profile
53
53
 
54
- Global profiles live in `<agent-dir>/pi-herdr-subagents-lite/profiles/*.md`, project profiles in `.pi/pi-herdr-subagents-lite/profiles/*.md` (read once the project is trusted); the project wins on the same name.
54
+ Global profiles live in `<agent-dir>/pi-herdr-subagents-lite/profiles/*.md`, project profiles in `.pi/pi-herdr-subagents-lite/profiles/*.md` (read once the project is trusted); the project wins on the same `name`.
55
55
 
56
56
  ```markdown
57
57
  ---
58
- # Display name only; run takes the file name
59
- name: Explore
58
+ # Key run takes; keep it equal to the file name
59
+ name: explore
60
60
  description: "Read-only research: read code, answer questions, change nothing"
61
61
 
62
62
  # Omit to inherit from the parent
@@ -84,9 +84,9 @@ disable-model-invocation: false
84
84
  You are a read-only explorer. Answer with file paths and line numbers.
85
85
  ```
86
86
 
87
- Defaults and error codes: [contract section 2](docs/specs/0001-v1-contract.md#2-profile). A file that fails to parse stays out of the list; `run` returns `profile_invalid` with the file, field and reason.
87
+ Defaults and error codes: [contract section 2](docs/specs/0001-v1-contract.md#2-profile). A file without `name`, or with an invalid field, stays out of the list; duplicate names in one layer are all rejected. `run` returns `profile_invalid` with the file, field and reason.
88
88
 
89
- A bundled example ships with the extension. Loading reads `profiles/*.md` only and the example is written only when missing, so copy it one level up:
89
+ A bundled example ships with the extension; its file name and `name` are both `domain`. Loading reads `profiles/*.md` only and the example is written only when missing, so copy it one level up:
90
90
 
91
91
  ```bash
92
92
  cd ~/.pi/agent/pi-herdr-subagents-lite/profiles && cp examples/domain.md domain.md
@@ -176,6 +176,7 @@ All docs are in Chinese.
176
176
  - [ARCHITECTURE](docs/ARCHITECTURE.md): module boundaries, dependency direction, test seams
177
177
  - [GLOSSARY](docs/GLOSSARY.md): the only source for terms
178
178
  - [Modules](docs/modules/): usage and trade-offs per module
179
+ - [ADR](docs/decisions/): decisions and rejected proposals, with reasons
179
180
 
180
181
  ## Development
181
182
 
package/README.zh-CN.md CHANGED
@@ -9,9 +9,9 @@ pi 扩展:让子代理作为独立 pi 进程跑在 herdr pane 里。一个工
9
9
  ## 功能
10
10
 
11
11
  - **独立进程,可人工接管**:每个子代理是 herdr pane 里独立的 pi 进程。cwd 与父相同则同 workspace 新开 tab,否则新建 workspace。随时切进去接管。
12
- - **两层 profile**:Markdown + frontmatter,全局 `<agent-dir>/pi-herdr-subagents-lite/profiles/`,项目层 `.pi/pi-herdr-subagents-lite/profiles/`,同名时项目层覆盖。
12
+ - **两层 profile**:Markdown + frontmatter,全局 `<agent-dir>/pi-herdr-subagents-lite/profiles/`,项目层 `.pi/pi-herdr-subagents-lite/profiles/`,`name` 相同时项目层覆盖。
13
13
  - **异步、可合并的结果**:`run` 立即返回。每个子代理收工时投一条 `subagent_result`;落在 `aggregateWindowMs` 内的结果合并成一条。超长结果落 artifact 文件。
14
- - **反问留在人这边**:子代理弹出对话框时,父侧收到 `subagent_question` 并发出 `herdr:blocked` 事件。人在子 pane 里回答,父模型不转达。
14
+ - **反问留在人这边**:子代理弹出对话框时只发出 `herdr:blocked` 事件,父侧什么也收不到。人在子 pane 里回答,不经模型转达。
15
15
  - **默认不嵌套,也不是沙箱**:子代理的工具集默认去掉本工具,profile 里点名才授予。工具由 profile 决定,扩展不限制写操作。
16
16
 
17
17
  ## 要求
@@ -36,7 +36,7 @@ pi -e /path/to/pi-herdr-subagents-lite/src/index.ts # 只对当前进程
36
36
  ## 快速开始
37
37
 
38
38
  1. 在 herdr pane 里启动 `pi`。
39
- 2. 写一个 profile(下一节);文件名去掉 `.md` 就是 `run` 的寻址名。
39
+ 2. 写一个 profile(下一节);frontmatter `name` 就是 `run` 的寻址名,文件名不参与查找。
40
40
  3. 让模型调用工具:
41
41
 
42
42
  ```json
@@ -51,12 +51,12 @@ pi -e /path/to/pi-herdr-subagents-lite/src/index.ts # 只对当前进程
51
51
 
52
52
  ## Profile
53
53
 
54
- 全局 profile 在 `<agent-dir>/pi-herdr-subagents-lite/profiles/*.md`,项目 profile 在 `.pi/pi-herdr-subagents-lite/profiles/*.md`(项目受信任后才读);同名时项目覆盖全局。
54
+ 全局 profile 在 `<agent-dir>/pi-herdr-subagents-lite/profiles/*.md`,项目 profile 在 `.pi/pi-herdr-subagents-lite/profiles/*.md`(项目受信任后才读);`name` 相同时项目覆盖全局。
55
55
 
56
56
  ```markdown
57
57
  ---
58
- # 只作显示名;run 取文件名
59
- name: Explore
58
+ # run 的寻址名;建议与文件名保持一致
59
+ name: explore
60
60
  description: 只读调研:读代码、回答问题,不改动任何文件
61
61
 
62
62
  # 省略即继承父会话
@@ -84,9 +84,9 @@ disable-model-invocation: false
84
84
  你是一名只读探索者,回答时给出文件路径与行号。
85
85
  ```
86
86
 
87
- 字段默认值与错误码见 [契约 §2](docs/specs/0001-v1-contract.md#2-profile)。解析失败的文件不进列表;对它 `run` 返回 `profile_invalid`,带文件、字段与原因。
87
+ 字段默认值与错误码见 [契约 §2](docs/specs/0001-v1-contract.md#2-profile)。没写 `name`、字段非法或同层同名的文件都不进列表;对它 `run` 返回 `profile_invalid`,带文件、字段与原因。
88
88
 
89
- 扩展自带一个示例 profile。加载只读 `profiles/*.md`,示例也只在缺失时写入,所以复制一层上去即可:
89
+ 扩展自带一个示例 profile,文件名和 `name` 都是 `domain`。加载只读 `profiles/*.md`,示例也只在缺失时写入,所以复制一层上去即可:
90
90
 
91
91
  ```bash
92
92
  cd ~/.pi/agent/pi-herdr-subagents-lite/profiles && cp examples/domain.md domain.md
@@ -174,6 +174,7 @@ cd ~/.pi/agent/pi-herdr-subagents-lite/profiles && cp examples/domain.md domain.
174
174
  - [ARCHITECTURE](docs/ARCHITECTURE.md) — 模块边界与 seam,改结构前看
175
175
  - [GLOSSARY](docs/GLOSSARY.md) — 术语唯一来源,命名前看
176
176
  - [模块](docs/modules/) — 各模块的用法与取舍
177
+ - [ADR](docs/decisions/) — 已决选型与理由,改设计前扫一遍
177
178
 
178
179
  ## 开发
179
180
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-herdr-subagents-lite",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Run pi subagents in herdr panes.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -6,7 +6,7 @@ import { drain, hasInbox, pumpInbox } from "../inbox/index.ts";
6
6
  import type { ProfileCatalog, ProfileSources } from "../profile/index.ts";
7
7
  import { createRunService, type RunService } from "../run/index.ts";
8
8
  import type { ExitReceiptReader, ExitReceiptWriter, SessionReader } from "../session-file/index.ts";
9
- import { createTool, type ResultMessage, type SubagentTool, sendQuestion, sendResults } from "../tool/index.ts";
9
+ import { createTool, type ResultMessage, type SubagentTool, sendResults } from "../tool/index.ts";
10
10
  import { watchAutoExit } from "./auto-exit.ts";
11
11
  import { type ShutdownHandler, watchShutdown } from "./shutdown.ts";
12
12
 
@@ -120,7 +120,6 @@ export function createExtension(deps: ExtensionDeps): (pi: ExtensionRegistrar) =
120
120
  onDeliveryError: (error) => ctx.ui.notify(`delivery failed: ${errorText(error)}`, "error"),
121
121
  });
122
122
  service.onResult((results) => sendResults(pi, results));
123
- service.onQuestion((question) => sendQuestion(pi, question));
124
123
  };
125
124
 
126
125
  pi.on("session_start", (_event, ctx) => {
@@ -1,8 +1,8 @@
1
1
  export const EXAMPLE_PROFILE_FILE = "domain.md";
2
2
 
3
3
  export const EXAMPLE_PROFILE = `---
4
- # Copy this file to profiles/domain.md to use it. The file name is what \`run\` takes.
5
- name: Domain
4
+ # Copy this file to profiles/domain.md to use it. \`name\` is what \`run\` takes.
5
+ name: domain
6
6
  description: Take one domain of work into a fresh context. Invoke by name.
7
7
 
8
8
  # User-invoked: hidden from the tool guidelines, so only an explicit run uses it.
@@ -8,7 +8,6 @@ export type SystemPromptMode = (typeof SYSTEM_PROMPT_MODES)[number];
8
8
 
9
9
  export interface Profile {
10
10
  name: string;
11
- displayName: string;
12
11
  description: string;
13
12
  body: string;
14
13
  model: string | undefined;
@@ -48,6 +47,24 @@ function complain(fields: Fields, field: string, reason: string) {
48
47
  fields.problems.push({ name: fields.name, file: fields.file, field, reason });
49
48
  }
50
49
 
50
+ export function nameField(fields: Fields): string | undefined {
51
+ const value = fields.raw.name;
52
+ if (value === undefined) {
53
+ complain(fields, "name", "required");
54
+ return undefined;
55
+ }
56
+ if (typeof value !== "string") {
57
+ complain(fields, "name", `expected string, got ${typeName(value)}`);
58
+ return undefined;
59
+ }
60
+ const name = value.trim();
61
+ if (name === "") {
62
+ complain(fields, "name", "expected a non-empty name");
63
+ return undefined;
64
+ }
65
+ return name;
66
+ }
67
+
51
68
  export function stringField(fields: Fields, key: string): string | undefined {
52
69
  const value = fields.raw[key];
53
70
  if (value === undefined) {
@@ -7,6 +7,7 @@ import {
7
7
  booleanField,
8
8
  enumField,
9
9
  type Fields,
10
+ nameField,
10
11
  type Profile,
11
12
  type ProfileProblem,
12
13
  SYSTEM_PROMPT_MODES,
@@ -27,24 +28,27 @@ export interface ProfileSources {
27
28
  }
28
29
 
29
30
  function parseProfile(file: string, source: string): { profile?: Profile; problems: ProfileProblem[] } {
30
- const name = basename(file, ".md");
31
31
  let frontmatter: unknown;
32
32
  let body: string;
33
33
  try {
34
34
  ({ frontmatter, body } = parseFrontmatter(source));
35
35
  } catch (error) {
36
36
  const reason = error instanceof Error ? error.message : "parse failed";
37
- return { problems: [{ name, file, field: "frontmatter", reason }] };
37
+ return { problems: [{ name: basename(file, ".md"), file, field: "frontmatter", reason }] };
38
38
  }
39
39
  if (!isRaw(frontmatter)) {
40
- return { problems: [{ name, file, field: "frontmatter", reason: "expected a mapping" }] };
40
+ return { problems: [{ name: basename(file, ".md"), file, field: "frontmatter", reason: "expected a mapping" }] };
41
41
  }
42
42
 
43
43
  const problems: ProfileProblem[] = [];
44
- const fields: Fields = { raw: frontmatter, name, file, problems };
44
+ const fields: Fields = { raw: frontmatter, name: basename(file, ".md"), file, problems };
45
+ const name = nameField(fields);
46
+ if (name === undefined) {
47
+ return { problems };
48
+ }
49
+ fields.name = name;
45
50
  const profile: Profile = {
46
51
  name,
47
- displayName: stringField(fields, "name") ?? name,
48
52
  description: stringField(fields, "description") ?? "",
49
53
  body,
50
54
  model: stringField(fields, "model"),
@@ -69,7 +73,7 @@ function readProfilesDir(dir: string): ProfileCatalog {
69
73
  throw error;
70
74
  }
71
75
 
72
- const profiles: Profile[] = [];
76
+ const parsed: Array<{ profile: Profile; file: string }> = [];
73
77
  const problems: ProfileProblem[] = [];
74
78
  for (const entry of entries.sort()) {
75
79
  const file = join(dir, entry);
@@ -81,12 +85,41 @@ function readProfilesDir(dir: string): ProfileCatalog {
81
85
  problems.push({ name: basename(entry, ".md"), file, field: "file", reason });
82
86
  continue;
83
87
  }
84
- const parsed = parseProfile(file, source);
85
- if (parsed.profile) {
86
- profiles.push(parsed.profile);
88
+ const result = parseProfile(file, source);
89
+ if (result.profile) {
90
+ parsed.push({ profile: result.profile, file });
91
+ }
92
+ problems.push(...result.problems);
93
+ }
94
+
95
+ const byName = new Map<string, Array<{ profile: Profile; file: string }>>();
96
+ for (const entry of parsed) {
97
+ const group = byName.get(entry.profile.name);
98
+ if (group) {
99
+ group.push(entry);
100
+ } else {
101
+ byName.set(entry.profile.name, [entry]);
102
+ }
103
+ }
104
+
105
+ const profiles: Profile[] = [];
106
+ for (const [name, group] of byName) {
107
+ const [only] = group;
108
+ if (only && group.length === 1) {
109
+ profiles.push(only.profile);
110
+ continue;
111
+ }
112
+ for (const entry of group) {
113
+ const others = group.filter((other) => other.file !== entry.file).map((other) => other.file);
114
+ problems.push({
115
+ name,
116
+ file: entry.file,
117
+ field: "name",
118
+ reason: `duplicate profile name; also in ${others.join(", ")}`,
119
+ });
87
120
  }
88
- problems.push(...parsed.problems);
89
121
  }
122
+ profiles.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
90
123
  return { profiles, problems };
91
124
  }
92
125
 
package/src/run/index.ts CHANGED
@@ -5,7 +5,6 @@ import { createAggregator } from "./aggregate.ts";
5
5
  import { type CloseChildrenResult, closeRuns } from "./close.ts";
6
6
  import { createNamer } from "./naming.ts";
7
7
  import { stopPoll } from "./poll.ts";
8
- import type { QuestionHandler } from "./question.ts";
9
8
  import type { ResultHandler, RunResult } from "./result.ts";
10
9
  import { type SendResult, send } from "./send.ts";
11
10
  import { type RunStarted, start } from "./start.ts";
@@ -15,7 +14,6 @@ export interface RunService {
15
14
  run(profile: string, task: string, cwd?: string): Promise<RunStarted>;
16
15
  send(name: string, message: string): Promise<SendResult>;
17
16
  onResult(handler: ResultHandler): void;
18
- onQuestion(handler: QuestionHandler): void;
19
17
  shutdown(): void;
20
18
  closeChildren(): Promise<CloseChildrenResult>;
21
19
  active(): string[];
@@ -42,7 +40,6 @@ export function createRunService(deps: RunServiceDeps): RunService {
42
40
  runs: [],
43
41
  resultHandler: undefined,
44
42
  results: undefined,
45
- questionHandler: undefined,
46
43
  pollBusy: false,
47
44
  nextName: createNamer(),
48
45
  active() {
@@ -88,9 +85,6 @@ export function createRunService(deps: RunServiceDeps): RunService {
88
85
  onResult(handler) {
89
86
  state.resultHandler = handler;
90
87
  },
91
- onQuestion(handler) {
92
- state.questionHandler = handler;
93
- },
94
88
  shutdown() {
95
89
  stopService(state);
96
90
  takeRuns(state);
@@ -105,7 +99,6 @@ export function createRunService(deps: RunServiceDeps): RunService {
105
99
 
106
100
  export type { CloseChildrenResult } from "./close.ts";
107
101
  export { ToolError } from "./errors.ts";
108
- export type { QuestionHandler, RunQuestion } from "./question.ts";
109
102
  export { profileLabel } from "./resolve.ts";
110
103
  export type { ResultHandler, RunResult } from "./result.ts";
111
104
  export type { SendResult } from "./send.ts";
package/src/run/poll.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import type { AgentEntry } from "../herdr/index.ts";
2
2
  import { closeTab, shouldClose } from "./close.ts";
3
3
  import { findAgent } from "./find.ts";
4
- import { askBlocked } from "./question.ts";
5
4
  import type { RunResult } from "./result.ts";
6
5
  import { doneResult, unknownResult } from "./result.ts";
7
6
  import type { RunRecord, RunState } from "./state.ts";
@@ -66,11 +65,6 @@ async function pollRuns(state: RunState): Promise<void> {
66
65
  await exited(state, run);
67
66
  continue;
68
67
  }
69
- if (agent.status === "blocked") {
70
- await askBlocked(state, run);
71
- continue;
72
- }
73
- run.blocked = false;
74
68
  run.pollFailures = 0;
75
69
  if (agent.completionSeq > run.lastHandledSeq && (agent.status === "idle" || agent.status === "done")) {
76
70
  await readResult(state, run, agent.completionSeq, 1);
@@ -2,7 +2,7 @@ import type { Profile, ProfileCatalog } from "../profile/index.ts";
2
2
  import { ToolError } from "./errors.ts";
3
3
 
4
4
  export function profileLabel(profile: Profile): string {
5
- return profile.description === "" ? profile.displayName : `${profile.displayName} — ${profile.description}`;
5
+ return profile.description === "" ? profile.name : `${profile.name} — ${profile.description}`;
6
6
  }
7
7
 
8
8
  export function resolveProfile({ profiles, problems }: ProfileCatalog, name: string): Profile {
package/src/run/send.ts CHANGED
@@ -40,7 +40,6 @@ export async function send(state: RunState, name: string, message: string): Prom
40
40
  run.lastHandledSeq = agent.completionSeq;
41
41
  run.live = true;
42
42
  run.pollFailures = 0;
43
- run.blocked = false;
44
43
  ensurePoll(state);
45
44
  }
46
45
  await enqueue(run.sessionFile, inboxMessage(message));
@@ -94,7 +93,6 @@ export async function send(state: RunState, name: string, message: string): Prom
94
93
 
95
94
  run.live = true;
96
95
  run.pollFailures = 0;
97
- run.blocked = false;
98
96
  ensurePoll(state);
99
97
  await enqueue(run.sessionFile, inboxMessage(message));
100
98
  return { state: "restarted", name: run.name, paneId: run.paneId };
package/src/run/start.ts CHANGED
@@ -70,7 +70,6 @@ export async function start(state: RunState, profileName: string, task: string,
70
70
  lastDeliveredText: null,
71
71
  artifactSeq: 0,
72
72
  pollFailures: 0,
73
- blocked: false,
74
73
  live: true,
75
74
  };
76
75
  // 先占槽再 await:并发的 run() 必须立刻看到它。
package/src/run/state.ts CHANGED
@@ -2,7 +2,6 @@ import type { HerdrHost } from "../herdr/index.ts";
2
2
  import type { ProfileCatalog } from "../profile/index.ts";
3
3
  import type { ExitReceiptReader, SessionReader } from "../session-file/index.ts";
4
4
  import type { Aggregator } from "./aggregate.ts";
5
- import type { QuestionHandler } from "./question.ts";
6
5
  import type { ResultHandler, RunResult } from "./result.ts";
7
6
 
8
7
  export interface RunServiceDeps {
@@ -32,7 +31,6 @@ export interface RunRecord {
32
31
  artifactSeq: number;
33
32
  timer?: ReturnType<typeof setTimeout>;
34
33
  pollFailures: number;
35
- blocked: boolean;
36
34
  live: boolean;
37
35
  }
38
36
 
@@ -41,7 +39,6 @@ export interface RunState extends RunServiceDeps {
41
39
  runs: RunRecord[];
42
40
  resultHandler: ResultHandler | undefined;
43
41
  results: Aggregator<RunResult> | undefined;
44
- questionHandler: QuestionHandler | undefined;
45
42
  pollTimer?: ReturnType<typeof setTimeout>;
46
43
  pollBusy: boolean;
47
44
  nextName(profile: string): string;
@@ -1,5 +1,5 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { parseLastAssistant, type SessionSnapshot, type ToolCall } from "./jsonl.ts";
2
+ import { parseLastAssistant, type SessionSnapshot } from "./jsonl.ts";
3
3
 
4
4
  export {
5
5
  type ExitReceipt,
@@ -8,7 +8,6 @@ export {
8
8
  readExitReceipt,
9
9
  writeExitReceipt,
10
10
  } from "./receipt.ts";
11
- export type { ToolCall };
12
11
 
13
12
  export type SessionReader = (path: string) => SessionSnapshot;
14
13
 
@@ -1,12 +1,6 @@
1
- export interface ToolCall {
2
- name: string;
3
- arguments: unknown;
4
- }
5
-
6
1
  export interface SessionSnapshot {
7
2
  text: string | null;
8
3
  pendingToolCall: boolean;
9
- toolCall?: ToolCall;
10
4
  empty: boolean;
11
5
  }
12
6
 
@@ -26,21 +20,17 @@ function isTextBlock(block: unknown): block is { type: "text"; text: string } {
26
20
  return candidate.type === "text" && typeof candidate.text === "string";
27
21
  }
28
22
 
29
- function lastToolCall(content: unknown): ToolCall | undefined {
23
+ function hasToolCall(content: unknown): boolean {
30
24
  if (!Array.isArray(content)) {
31
- return undefined;
25
+ return false;
32
26
  }
33
- for (let index = content.length - 1; index >= 0; index--) {
34
- const block = content[index];
27
+ return content.some((block) => {
35
28
  if (typeof block !== "object" || block === null) {
36
- continue;
37
- }
38
- const candidate = block as { type?: unknown; name?: unknown; arguments?: unknown };
39
- if (candidate.type === "toolCall" && typeof candidate.name === "string") {
40
- return { name: candidate.name, arguments: candidate.arguments };
29
+ return false;
41
30
  }
42
- }
43
- return undefined;
31
+ const candidate = block as { type?: unknown; name?: unknown };
32
+ return candidate.type === "toolCall" && typeof candidate.name === "string";
33
+ });
44
34
  }
45
35
 
46
36
  function textOf(entry: SessionEntry): string | null {
@@ -77,10 +67,7 @@ export function parseLastAssistant(raw: string): SessionSnapshot {
77
67
  if (entry.type !== "message" || entry.message?.role !== "assistant") {
78
68
  continue;
79
69
  }
80
- const toolCall = lastToolCall(entry.message?.content);
81
- return toolCall === undefined
82
- ? { text: textOf(entry), pendingToolCall: false, empty: false }
83
- : { text: textOf(entry), pendingToolCall: true, toolCall, empty: false };
70
+ return { text: textOf(entry), pendingToolCall: hasToolCall(entry.message?.content), empty: false };
84
71
  }
85
72
  return { text: null, pendingToolCall: false, empty: true };
86
73
  }
package/src/tool/index.ts CHANGED
@@ -55,4 +55,4 @@ export function createTool({ config, profiles, service }: ToolDeps) {
55
55
  };
56
56
  }
57
57
 
58
- export { type ResultMessage, sendQuestion, sendResults } from "./message.ts";
58
+ export { type ResultMessage, sendResults } from "./message.ts";
@@ -1,4 +1,4 @@
1
- import type { RunQuestion, RunResult } from "../run/index.ts";
1
+ import type { RunResult } from "../run/index.ts";
2
2
 
3
3
  export interface ResultMessage {
4
4
  customType: string;
@@ -19,17 +19,6 @@ function resultText(result: RunResult): string {
19
19
  return `[subagent ${result.name}] done\n\n${result.text}${artifact}${warning}`;
20
20
  }
21
21
 
22
- const QUESTION_FOOTER = [
23
- "The human answers it in the child's pane. A follow-up sent with send is",
24
- "queued until the dialog closes.",
25
- ].join("\n");
26
-
27
- export function sendQuestion(sink: MessageSink, question: RunQuestion): void {
28
- const body = question.text.trim() === "" ? "" : `\n\n${question.text.trim()}`;
29
- const content = `[subagent ${question.name}] blocked (question)${body}\n\n${QUESTION_FOOTER}`;
30
- sink.sendMessage({ customType: "subagent_question", content, display: true }, { triggerTurn: true });
31
- }
32
-
33
22
  export function sendResults(sink: MessageSink, results: RunResult[]): void {
34
23
  const content = results.map(resultText).join("\n\n");
35
24
  sink.sendMessage({ customType: "subagent_result", content, display: true }, { triggerTurn: true });
@@ -1,88 +0,0 @@
1
- import type { ToolCall } from "../session-file/index.ts";
2
- import type { RunRecord, RunState } from "./state.ts";
3
-
4
- export interface RunQuestion {
5
- name: string;
6
- text: string;
7
- }
8
-
9
- export type QuestionHandler = (question: RunQuestion) => void | Promise<void>;
10
-
11
- function record(value: unknown): Record<string, unknown> {
12
- return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
13
- }
14
-
15
- function line(value: unknown): string | undefined {
16
- return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
17
- }
18
-
19
- function optionLines(options: unknown): string[] {
20
- if (!Array.isArray(options)) {
21
- return [];
22
- }
23
- const lines: string[] = [];
24
- for (const entry of options) {
25
- const label = line(record(entry).label);
26
- if (label === undefined) {
27
- continue;
28
- }
29
- const description = line(record(entry).description);
30
- lines.push(description === undefined ? `- ${label}` : `- ${label} — ${description}`);
31
- }
32
- return lines;
33
- }
34
-
35
- function groupLines(questions: unknown): string[] {
36
- if (!Array.isArray(questions)) {
37
- return [];
38
- }
39
- const lines: string[] = [];
40
- for (const entry of questions) {
41
- const group = record(entry);
42
- const question = line(group.question);
43
- if (question === undefined) {
44
- continue;
45
- }
46
- const header = line(group.header);
47
- lines.push(header === undefined ? question : `${header}: ${question}`, ...optionLines(group.options), "");
48
- }
49
- return lines;
50
- }
51
-
52
- function plainLine(args: unknown): string | undefined {
53
- const values = record(args);
54
- for (const key of ["question", "title", "message", "prompt", "text"]) {
55
- const found = line(values[key]);
56
- if (found !== undefined) {
57
- return found;
58
- }
59
- }
60
- return undefined;
61
- }
62
-
63
- export function questionText(call: ToolCall | undefined): string {
64
- if (call === undefined) {
65
- return "";
66
- }
67
- const lines = groupLines(record(call.arguments).questions);
68
- if (lines.length > 0) {
69
- return lines.join("\n").trim();
70
- }
71
- return plainLine(call.arguments) ?? "";
72
- }
73
-
74
- export async function askBlocked(state: RunState, run: RunRecord): Promise<void> {
75
- if (run.blocked) {
76
- return;
77
- }
78
- run.blocked = true;
79
- try {
80
- await state.questionHandler?.({
81
- name: run.name,
82
- text: questionText(state.readLastAssistant(run.sessionFile).toolCall),
83
- });
84
- run.pollFailures = 0;
85
- } catch (error) {
86
- state.onDeliveryError(error);
87
- }
88
- }