@trim21/personal-pi-extensions 0.0.209 → 0.0.211

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
@@ -11,6 +11,7 @@
11
11
  | [opencode-edit](#opencode-edit) | 替换内置 edit 工具,使用 opencode 的 schema 和匹配引擎 |
12
12
  | [bash-default-timeout](#bash-default-timeout) | 为 bash 工具设置默认超时(180 秒) |
13
13
  | [vision-agent](#vision-agent) | 视觉代理:主模型不支持视觉时,spawn 子 agent 识别图片 |
14
+ | [session-name](#session-name) | 首个 user prompt 自动生成会话名,模型命名 + 启发式兜底 |
14
15
  | [todowrite](#todowrite) | opencode 风格的任务列表工具,完整列表替换语义 |
15
16
  | [question](#question) | opencode 风格的提问工具,阻塞式询问用户选择 |
16
17
  | [talk](#talk) | session 间消息传递,SQLite 邮箱 + 双向 ask 时间戳仲裁 |
@@ -172,6 +173,37 @@ pi -e ./src/vision-agent.ts
172
173
 
173
174
  ---
174
175
 
176
+ ## session-name
177
+
178
+ 根据会话的第一个 user prompt 自动生成显示名,在 `/resume` 和 `pi -r` 里更易区分会话。
179
+
180
+ - **双模式命名**:配置了 `sessionName.model` 时调用命名模型(OpenAI 兼容 API,复用 `~/.pi/agent/models.json` 的 provider 配置)把 prompt 概括成短名;未配置模型、provider 不可解析或模型调用失败时退化为启发式(取首行、去 markdown 装饰、截断到 `maxLength`)。
181
+ - **不覆盖已有名字**:`--name`、`/name` 设置过名字的会话不会被改;恢复的已命名会话同样跳过。
182
+ - **恢复无名会话**:resume/fork 恢复且无名字的会话,从历史第一条 user 消息生成名字。
183
+ - **非阻塞**:命名在后台进行,不拖慢首轮回复;中途切换会话也不会把名字写到错误的 session。
184
+ - **无需配置开箱即用**:缺省按启发式命名。
185
+
186
+ ### 配置
187
+
188
+ ```jsonc
189
+ // ~/.pi/agent/settings.json
190
+ {
191
+ "sessionName": {
192
+ "provider": "axonhub", // 可选,缺省回退 defaultProvider
193
+ "model": "deepseek-v4-flash", // 命名模型;不配置则用启发式
194
+ "maxLength": 30, // 可选,名字最大长度,默认 30
195
+ },
196
+ }
197
+ ```
198
+
199
+ ### 使用
200
+
201
+ ```bash
202
+ pi -e ./src/session-name.ts
203
+ ```
204
+
205
+ ---
206
+
175
207
  ## todowrite
176
208
 
177
209
  opencode 风格的任务列表工具,参数与语义和 opencode 的 [`todowrite`](https://github.com/anomalyco/opencode) 工具一致。取代原 `todo-pendant.ts` 的 widget 输出方式,改用 `details.pendant.markdown` 渲染(与 vision-agent 相同的 pendant 约定)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.209",
3
+ "version": "0.0.211",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -25,15 +25,15 @@
25
25
  "prepare": "husky"
26
26
  },
27
27
  "peerDependencies": {
28
- "@earendil-works/pi-agent-core": "*",
29
- "@earendil-works/pi-ai": "*",
30
- "@earendil-works/pi-coding-agent": "*",
31
- "@earendil-works/pi-tui": "*",
32
- "typebox": "*"
28
+ "@earendil-works/pi-agent-core": ">=0.84.1",
29
+ "@earendil-works/pi-ai": ">=0.84.1",
30
+ "@earendil-works/pi-coding-agent": ">=0.84.1",
31
+ "@earendil-works/pi-tui": ">=0.84.1",
32
+ "typebox": ">=1.3.1"
33
33
  },
34
34
  "devDependencies": {
35
- "@earendil-works/pi-ai": "^0.80.10",
36
- "@earendil-works/pi-coding-agent": "^0.80.10",
35
+ "@earendil-works/pi-ai": "^0.84.1",
36
+ "@earendil-works/pi-coding-agent": "^0.84.1",
37
37
  "@eslint/js": "10.0.1",
38
38
  "@types/node": "^24.13.3",
39
39
  "@typescript-eslint/utils": "8.67.0",
@@ -56,6 +56,7 @@
56
56
  "pi": {
57
57
  "extensions": [
58
58
  "src/vision-agent.ts",
59
+ "src/session-name.ts",
59
60
  "src/bwrap/index.ts",
60
61
  "src/workspace-guard.ts",
61
62
  "src/opencode-edit.ts",
@@ -62,11 +62,12 @@ import { access as fsAccess } from "node:fs/promises";
62
62
  import { delimiter, join } from "node:path";
63
63
  import { fileURLToPath } from "node:url";
64
64
 
65
- import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
65
+ import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
66
66
  import { type BashOperations, createBashTool, getAgentDir } from "@earendil-works/pi-coding-agent";
67
- import { Type } from "typebox";
67
+ import { type TObject, Type } from "typebox";
68
68
  import { Value } from "typebox/value";
69
69
 
70
+ import { type CommandSpec, parseCommand } from "../lib/cli.js";
70
71
  import { expandHome } from "../lib/path.js";
71
72
 
72
73
  const SANDBOX_PROMPT = `
@@ -711,25 +712,80 @@ export default function bwrapExtension(pi: ExtensionAPI) {
711
712
  };
712
713
  });
713
714
 
714
- pi.registerCommand("bwrap", {
715
+ const BWRAP_SPEC = {
716
+ name: "bwrap",
717
+ usage: "",
715
718
  description: "Show bwrap sandbox configuration",
716
- handler: (_args, ctx) => {
717
- const r = getResolved(ctx.hasUI);
718
- if (!r.bwrapEnabled) {
719
- ctx.ui.notify(`bwrap disabled (mode: ${r.mode})`, "info");
720
- return Promise.resolve();
721
- }
719
+ flags: Type.Object({}),
720
+ arity: { max: 0 },
721
+ };
722
+
723
+ const BWRAP_ALLOW_ALL_SPEC = {
724
+ name: "bwrap-allow-all",
725
+ usage: "",
726
+ description: "Disable bwrap sandbox, full access",
727
+ flags: Type.Object({}),
728
+ arity: { max: 0 },
729
+ };
722
730
 
723
- const net = r.network ? "net" : "no-net";
724
- const w = r.writablePaths.map((p) => resolvePath(p, localCwd));
725
- const t = r.tmpfsPaths.map((p) => resolvePath(p, localCwd));
731
+ const BWRAP_WORKSPACE_WRITE_SPEC = {
732
+ name: "bwrap-workspace-write",
733
+ usage: "",
734
+ description: "Sandbox on, network off, workspace writable",
735
+ flags: Type.Object({}),
736
+ arity: { max: 0 },
737
+ };
726
738
 
727
- ctx.ui.notify(
728
- `bwrap ${r.mode} ${net} write:[${w.join(", ")}] tmpfs:[${t.join(", ") || "-"}]`,
729
- "info",
730
- );
739
+ const BWRAP_ALLOW_NET_SPEC = {
740
+ name: "bwrap-allow-net",
741
+ usage: "",
742
+ description: "Sandbox on, network on, workspace writable",
743
+ flags: Type.Object({}),
744
+ arity: { max: 0 },
745
+ };
746
+
747
+ const BWRAP_READONLY_SPEC = {
748
+ name: "bwrap-readonly",
749
+ usage: "",
750
+ description: "Sandbox on, network off, no writes",
751
+ flags: Type.Object({}),
752
+ arity: { max: 0 },
753
+ };
754
+
755
+ /** Parse a /bwrap command; on help/error the text is sent to the session, otherwise run() executes. */
756
+ function runBwrapCommand(
757
+ spec: CommandSpec<TObject>,
758
+ args: string,
759
+ ctx: ExtensionCommandContext,
760
+ run: (ctx: ExtensionCommandContext) => void | Promise<void>,
761
+ ): Promise<void> {
762
+ const parsed = parseCommand(spec, args);
763
+ if (parsed.kind !== "ok") {
764
+ pi.sendMessage({ customType: "info", content: parsed.text, display: true });
731
765
  return Promise.resolve();
732
- },
766
+ }
767
+ return Promise.resolve(run(ctx));
768
+ }
769
+
770
+ pi.registerCommand("bwrap", {
771
+ description: BWRAP_SPEC.description,
772
+ handler: (args, ctx) =>
773
+ runBwrapCommand(BWRAP_SPEC, args, ctx, (c) => {
774
+ const r = getResolved(c.hasUI);
775
+ if (!r.bwrapEnabled) {
776
+ c.ui.notify(`bwrap disabled (mode: ${r.mode})`, "info");
777
+ return;
778
+ }
779
+
780
+ const net = r.network ? "net" : "no-net";
781
+ const w = r.writablePaths.map((p) => resolvePath(p, localCwd));
782
+ const t = r.tmpfsPaths.map((p) => resolvePath(p, localCwd));
783
+
784
+ c.ui.notify(
785
+ `bwrap ${r.mode} ${net} write:[${w.join(", ")}] tmpfs:[${t.join(", ") || "-"}]`,
786
+ "info",
787
+ );
788
+ }),
733
789
  });
734
790
 
735
791
  function switchMode(
@@ -760,22 +816,28 @@ export default function bwrapExtension(pi: ExtensionAPI) {
760
816
  }
761
817
 
762
818
  pi.registerCommand("bwrap-allow-all", {
763
- description: "Disable bwrap sandbox, full access",
764
- handler: (_args, ctx) => Promise.resolve(switchMode("allow-all", ctx)),
819
+ description: BWRAP_ALLOW_ALL_SPEC.description,
820
+ handler: (args, ctx) =>
821
+ runBwrapCommand(BWRAP_ALLOW_ALL_SPEC, args, ctx, (c) => switchMode("allow-all", c)),
765
822
  });
766
823
 
767
824
  pi.registerCommand("bwrap-workspace-write", {
768
- description: "Sandbox on, network off, workspace writable",
769
- handler: (_args, ctx) => Promise.resolve(switchMode("workspace-write", ctx)),
825
+ description: BWRAP_WORKSPACE_WRITE_SPEC.description,
826
+ handler: (args, ctx) =>
827
+ runBwrapCommand(BWRAP_WORKSPACE_WRITE_SPEC, args, ctx, (c) =>
828
+ switchMode("workspace-write", c),
829
+ ),
770
830
  });
771
831
 
772
832
  pi.registerCommand("bwrap-allow-net", {
773
- description: "Sandbox on, network on, workspace writable",
774
- handler: (_args, ctx) => Promise.resolve(switchMode("allow-net", ctx)),
833
+ description: BWRAP_ALLOW_NET_SPEC.description,
834
+ handler: (args, ctx) =>
835
+ runBwrapCommand(BWRAP_ALLOW_NET_SPEC, args, ctx, (c) => switchMode("allow-net", c)),
775
836
  });
776
837
 
777
838
  pi.registerCommand("bwrap-readonly", {
778
- description: "Sandbox on, network off, no writes",
779
- handler: (_args, ctx) => Promise.resolve(switchMode("readonly", ctx)),
839
+ description: BWRAP_READONLY_SPEC.description,
840
+ handler: (args, ctx) =>
841
+ runBwrapCommand(BWRAP_READONLY_SPEC, args, ctx, (c) => switchMode("readonly", c)),
780
842
  });
781
843
  }
package/src/lib/cli.ts ADDED
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Declarative command-line parsing for `/command` handlers.
3
+ *
4
+ * The shell-like tokenizer from `cli-args.ts` splits the raw argument string;
5
+ * this module parses the tokens into positional args plus typed flags. Flags
6
+ * are declared as a typebox object schema: each property is a flag and the
7
+ * property key is the long name (`--key`). The property schema decides the
8
+ * flag kind:
9
+ *
10
+ * Type.Boolean() boolean flag (`--flag`)
11
+ * Type.String() string value flag (`--flag value`)
12
+ * Type.Number() / Type.Integer() number value flag (value is coerced)
13
+ * Type.Union([Type.Literal(...)]) string enum value flag (validated)
14
+ *
15
+ * Supported syntax (common unix CLI conventions):
16
+ *
17
+ * -s short boolean flag
18
+ * -s value short flag with value (also `-s=value` and `-svalue`)
19
+ * -abc combined short boolean flags
20
+ * --long long boolean flag
21
+ * --long=value long flag with inline value
22
+ * --long value long flag consuming the next token
23
+ * -- everything after is a positional
24
+ *
25
+ * `Type.Optional(...)` marks a flag optional (no error when absent) and a
26
+ * `default` fills in a missing value; both flow through typebox's value
27
+ * pipeline (default → convert → check) so the `ok` result carries
28
+ * `Static<TFlags>` flags plus `string[]` args.
29
+ *
30
+ * `-h`/`--help` is registered automatically unless the schema declares a
31
+ * `help` property or a short `h` alias; it renders a usage/options text as
32
+ * the result so handlers can display it in chat instead of writing stdout.
33
+ */
34
+
35
+ import type { Static, TObject } from "typebox";
36
+ import { Value } from "typebox/value";
37
+
38
+ import { shlexSplit } from "./cli-args.js";
39
+
40
+ /** Per-flag CLI metadata on top of the typebox schema. */
41
+ export interface FlagMeta {
42
+ /** Optional short alias (single character), e.g. "n" for `-n`. */
43
+ short?: string;
44
+ /** Help line for this flag; defaults to the schema `description`. */
45
+ description?: string;
46
+ /** Placeholder shown for value flags in help, e.g. "<alias>"; defaults to `<key>`. */
47
+ valuePlaceholder?: string;
48
+ }
49
+
50
+ export interface CommandSpec<TFlags extends TObject> {
51
+ /** Command name used in usage/help, e.g. "talk-group-join". */
52
+ name: string;
53
+ /** Usage text after the command name, e.g. "[group name] [options]". */
54
+ usage: string;
55
+ /** One-line description shown under the usage line. */
56
+ description?: string;
57
+ /** Typebox object schema describing the flags (key = long flag name). */
58
+ flags: TFlags;
59
+ /** Per-flag CLI metadata (short alias, help text). */
60
+ flagMeta?: { [K in keyof Static<TFlags>]?: FlagMeta };
61
+ /** Positional count constraints. */
62
+ arity?: { min?: number; max?: number };
63
+ /** Example lines rendered under the help text. */
64
+ examples?: string[];
65
+ }
66
+
67
+ export type CommandResult<TFlags extends TObject> =
68
+ | { kind: "ok"; flags: Static<TFlags>; args: string[] }
69
+ | { kind: "help"; text: string }
70
+ | { kind: "error"; text: string };
71
+
72
+ /** Runtime view of a flag schema (typebox's `TSchema` is empty at the type level). */
73
+ interface FlagSchema {
74
+ "~kind"?: string;
75
+ "~optional"?: boolean;
76
+ type?: string;
77
+ default?: unknown;
78
+ description?: string;
79
+ anyOf?: { type?: string; const?: unknown }[];
80
+ }
81
+
82
+ type FlagKind = "boolean" | "string" | "number" | "enum";
83
+
84
+ interface FlagInfo {
85
+ key: string;
86
+ kind: FlagKind;
87
+ short?: string;
88
+ required: boolean;
89
+ placeholder: string;
90
+ description: string;
91
+ schema: FlagSchema;
92
+ }
93
+
94
+ function kindOf(key: string, schema: FlagSchema): FlagKind {
95
+ switch (schema["~kind"]) {
96
+ case "Boolean": {
97
+ return "boolean";
98
+ }
99
+ case "String": {
100
+ return "string";
101
+ }
102
+ case "Number":
103
+ case "Integer": {
104
+ return "number";
105
+ }
106
+ case "Union": {
107
+ return "enum";
108
+ }
109
+ default: {
110
+ throw new TypeError(
111
+ `Unsupported flag type for '${key}': ${schema["~kind"] ?? schema.type ?? "unknown"} ` +
112
+ "(use Type.Boolean/String/Number/Integer or a string literal union)",
113
+ );
114
+ }
115
+ }
116
+ }
117
+
118
+ /** Allowed values for a string-literal union flag, or undefined for mixed unions. */
119
+ function enumValues(schema: FlagSchema): string[] | undefined {
120
+ const anyOf = schema.anyOf;
121
+ if (!anyOf) return undefined;
122
+ const values = anyOf.map((s) => s.const).filter((c) => typeof c === "string");
123
+ return values.length === anyOf.length ? values : undefined;
124
+ }
125
+
126
+ function buildFlagInfos<TFlags extends TObject>(spec: CommandSpec<TFlags>): FlagInfo[] {
127
+ const meta = spec.flagMeta as Record<string, FlagMeta> | undefined;
128
+ const infos: FlagInfo[] = [];
129
+ for (const [key, rawSchema] of Object.entries(spec.flags.properties)) {
130
+ const schema = rawSchema as unknown as FlagSchema;
131
+ const m = meta?.[key];
132
+ infos.push({
133
+ key,
134
+ kind: kindOf(key, schema),
135
+ short: m?.short,
136
+ required: schema["~optional"] !== true && schema.default === undefined,
137
+ placeholder: m?.valuePlaceholder ?? `<${key}>`,
138
+ description: m?.description ?? schema.description ?? "",
139
+ schema,
140
+ });
141
+ }
142
+ return infos;
143
+ }
144
+
145
+ /** Is this token a flag-like argument (a negative number is a value)? */
146
+ function looksLikeFlag(token: string): boolean {
147
+ return token.startsWith("-") && !/^-\d/.test(token);
148
+ }
149
+
150
+ function errorResult<TFlags extends TObject>(
151
+ spec: CommandSpec<TFlags>,
152
+ message: string,
153
+ ): CommandResult<TFlags> {
154
+ return { kind: "error", text: `${message}\nTry '/${spec.name} --help' for usage.` };
155
+ }
156
+
157
+ function helpResult<TFlags extends TObject>(
158
+ spec: CommandSpec<TFlags>,
159
+ flags: FlagInfo[],
160
+ autoHelp: boolean,
161
+ ): CommandResult<TFlags> {
162
+ const lines = [`Usage: /${spec.name} ${spec.usage}`];
163
+ if (spec.description) lines.push("", spec.description);
164
+ const rows = flags.map((f) => ({
165
+ rawName:
166
+ (f.short ? `-${f.short}, ` : "") +
167
+ `--${f.key}` +
168
+ (f.kind === "boolean" ? "" : ` ${f.placeholder}`),
169
+ description: f.description,
170
+ }));
171
+ if (autoHelp) rows.push({ rawName: "-h, --help", description: "Display this message" });
172
+ if (rows.length > 0) {
173
+ lines.push("", "Options:");
174
+ const width = Math.max(...rows.map((r) => r.rawName.length));
175
+ for (const r of rows) lines.push(` ${r.rawName.padEnd(width)} ${r.description}`);
176
+ }
177
+ if (spec.examples?.length) {
178
+ lines.push("", "Examples:");
179
+ for (const e of spec.examples) lines.push(` ${e}`);
180
+ }
181
+ return { kind: "help", text: lines.join("\n") };
182
+ }
183
+
184
+ export function parseCommand<TFlags extends TObject>(
185
+ spec: CommandSpec<TFlags>,
186
+ raw: string,
187
+ ): CommandResult<TFlags> {
188
+ const flags = buildFlagInfos(spec);
189
+ const byLong = new Map(flags.map((f) => [f.key, f]));
190
+ const byShort = new Map<string, FlagInfo>();
191
+ for (const f of flags) {
192
+ if (!f.short) {
193
+ continue;
194
+ }
195
+
196
+ if (byShort.has(f.short)) {
197
+ throw new TypeError(`Duplicate short option '-${f.short}' in /${spec.name}`);
198
+ }
199
+ byShort.set(f.short, f);
200
+ }
201
+ const autoHelp = !byLong.has("help") && !byShort.has("h");
202
+
203
+ let tokens: string[];
204
+ try {
205
+ tokens = shlexSplit(raw);
206
+ } catch (error) {
207
+ return errorResult(spec, error instanceof Error ? error.message : String(error));
208
+ }
209
+
210
+ const rawFlags: Record<string, unknown> = {};
211
+ const args: string[] = [];
212
+ let positionalOnly = false;
213
+
214
+ for (let i = 0; i < tokens.length; i++) {
215
+ const token = tokens[i];
216
+ if (positionalOnly) {
217
+ args.push(token);
218
+ continue;
219
+ }
220
+ if (token === "--") {
221
+ positionalOnly = true;
222
+ continue;
223
+ }
224
+ if (token === "-") {
225
+ args.push(token);
226
+ continue;
227
+ }
228
+ if (token.startsWith("--")) {
229
+ const body = token.slice(2);
230
+ const eq = body.indexOf("=");
231
+ const name = eq === -1 ? body : body.slice(0, eq);
232
+ if (autoHelp && name === "help") return helpResult(spec, flags, autoHelp);
233
+ const info = byLong.get(name);
234
+ if (!info) return errorResult(spec, `Unknown option '--${name}'`);
235
+ const inline = eq === -1 ? undefined : body.slice(eq + 1);
236
+ if (inline !== undefined) {
237
+ rawFlags[info.key] = inline;
238
+ } else if (info.kind === "boolean") {
239
+ rawFlags[info.key] = true;
240
+ } else {
241
+ const next = tokens[i + 1];
242
+ if (next !== undefined && !looksLikeFlag(next)) {
243
+ rawFlags[info.key] = next;
244
+ i++;
245
+ } else if (autoHelp && next !== undefined && (next === "--help" || next === "-h")) {
246
+ return helpResult(spec, flags, autoHelp);
247
+ } else {
248
+ return errorResult(spec, `Option '--${name}' requires a value`);
249
+ }
250
+ }
251
+ continue;
252
+ }
253
+ if (token.startsWith("-")) {
254
+ const rest = token.slice(1);
255
+ for (let j = 0; j < rest.length; j++) {
256
+ const c = rest[j];
257
+ if (autoHelp && c === "h") return helpResult(spec, flags, autoHelp);
258
+ const info = byShort.get(c);
259
+ if (!info) return errorResult(spec, `Unknown option '-${c}'`);
260
+ if (info.kind === "boolean") {
261
+ rawFlags[info.key] = true;
262
+ continue;
263
+ }
264
+ if (rest[j + 1] === "=") {
265
+ rawFlags[info.key] = rest.slice(j + 2);
266
+ } else if (j + 1 < rest.length) {
267
+ rawFlags[info.key] = rest.slice(j + 1);
268
+ } else {
269
+ const next = tokens[i + 1];
270
+ if (next !== undefined && !looksLikeFlag(next)) {
271
+ rawFlags[info.key] = next;
272
+ i++;
273
+ } else if (autoHelp && next !== undefined && (next === "--help" || next === "-h")) {
274
+ return helpResult(spec, flags, autoHelp);
275
+ } else {
276
+ return errorResult(spec, `Option '-${c}' requires a value`);
277
+ }
278
+ }
279
+ break;
280
+ }
281
+ continue;
282
+ }
283
+ args.push(token);
284
+ }
285
+
286
+ for (const f of flags) {
287
+ if (f.required && !(f.key in rawFlags)) {
288
+ return errorResult(spec, `Missing required option '--${f.key}'`);
289
+ }
290
+ }
291
+
292
+ for (const f of flags) {
293
+ if (f.kind === "enum" && typeof rawFlags[f.key] === "string") {
294
+ const values = enumValues(f.schema);
295
+ if (values && !values.includes(rawFlags[f.key] as string)) {
296
+ return errorResult(
297
+ spec,
298
+ `Invalid value for '--${f.key}': '${String(rawFlags[f.key])}' (expected one of: ${values.join(", ")})`,
299
+ );
300
+ }
301
+ }
302
+ if (f.kind === "number" && typeof rawFlags[f.key] === "string") {
303
+ const n = Number(rawFlags[f.key]);
304
+ if (Number.isNaN(n)) {
305
+ return errorResult(spec, `Invalid value for '--${f.key}': '${String(rawFlags[f.key])}'`);
306
+ }
307
+ rawFlags[f.key] = n;
308
+ }
309
+ }
310
+
311
+ const { min, max } = spec.arity ?? {};
312
+ if (max !== undefined && args.length > max) {
313
+ const extra = args
314
+ .slice(max)
315
+ .map((a) => `'${a}'`)
316
+ .join(", ");
317
+ return errorResult(spec, `Too many arguments: ${extra} (expected at most ${max})`);
318
+ }
319
+ if (min !== undefined && args.length < min) {
320
+ return errorResult(spec, `Missing required argument: expected ${spec.usage}`);
321
+ }
322
+
323
+ // typebox value pipeline: defaults → coercion → check.
324
+ let parsed: unknown;
325
+ try {
326
+ parsed = Value.Default(spec.flags, rawFlags);
327
+ parsed = Value.Convert(spec.flags, Value.Clone(parsed));
328
+ if (!Value.Check(spec.flags, parsed)) {
329
+ const [first] = [...Value.Errors(spec.flags, parsed)];
330
+ return errorResult(
331
+ spec,
332
+ `Invalid arguments: ${first?.message ?? "value does not match flags"}`,
333
+ );
334
+ }
335
+ } catch {
336
+ return errorResult(spec, "Invalid arguments");
337
+ }
338
+
339
+ return { kind: "ok", flags: parsed, args };
340
+ }
@@ -23,7 +23,8 @@
23
23
  */
24
24
 
25
25
  import { StringEnum } from "@earendil-works/pi-ai";
26
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
26
+ import { type ExtensionAPI, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
27
+ import { Markdown } from "@earendil-works/pi-tui";
27
28
  import { Type } from "typebox";
28
29
 
29
30
  // ── constants ────────────────────────────────────────────────────────────────
@@ -120,7 +121,7 @@ export function serializeTodos(todos: readonly TodoInfo[]): string {
120
121
 
121
122
  const STATUS_MARK: Record<TodoStatus, string> = {
122
123
  pending: " ",
123
- in_progress: " ",
124
+ in_progress: ">",
124
125
  completed: "x",
125
126
  cancelled: "-",
126
127
  };
@@ -180,5 +181,14 @@ export default function todowrite(pi: ExtensionAPI) {
180
181
  },
181
182
  });
182
183
  },
184
+
185
+ renderResult(result) {
186
+ // 用 buildTodoMarkdown 生成任务列表 markdown,经 TUI Markdown 组件渲染。
187
+ // 折叠时只显示前几项,展开时显示完整列表(与 widget 的 pendant.expanded 语义一致)。
188
+ const todos = result.details.todos;
189
+ const display = todos.slice(0, 8);
190
+ const markdown = buildTodoMarkdown(display);
191
+ return new Markdown(markdown, 0, 0, getMarkdownTheme());
192
+ },
183
193
  });
184
194
  }
@@ -0,0 +1,333 @@
1
+ /**
2
+ * session-name —— 自动会话命名扩展
3
+ *
4
+ * 在会话收到第一个 user prompt 时自动生成显示名,方便在 /resume 和 pi -r
5
+ * 中区分会话:
6
+ * - 配置了 sessionName.model 时,通过 pi 的模型注册表(ctx.modelRegistry)
7
+ * 直接调用命名模型把 prompt 概括成短名 —— 复用 pi 的 provider 解析
8
+ * (~/.pi/agent/models.json 的 baseUrl/apiKey/env/OAuth)与 AI SDK,
9
+ * 不手写 HTTP 请求(模型来自 ~/.pi/agent/settings.json 的 sessionName 与
10
+ * defaultProvider,与 vision-agent 同一套配置体系);
11
+ * - 未配置模型、模型在注册表中找不到或模型调用失败时退化为启发式命名
12
+ * (取首行、去 markdown 装饰、截断到 maxLength)。
13
+ *
14
+ * 已命名的会话(--name / /name / 恢复的已命名 session)不会被覆盖;
15
+ * 恢复的无名会话从历史第一条 user 消息生成名字。命名在后台进行,不阻塞
16
+ * agent 启动;会话切换 / reload 后捕获的 pi 会抛 stale 错误,被 catch
17
+ * 忽略,名字绝不会写到错误的 session。
18
+ *
19
+ * 使用前提:无。未配置 sessionName 时开箱即用(启发式命名)。
20
+ */
21
+
22
+ import { readFileSync } from "node:fs";
23
+ import { homedir } from "node:os";
24
+ import { join } from "node:path";
25
+
26
+ import {
27
+ type Api,
28
+ type ApiStreamOptions,
29
+ type AssistantMessage,
30
+ contentText,
31
+ type Context,
32
+ type Model,
33
+ } from "@earendil-works/pi-ai";
34
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
35
+
36
+ import { jsoncToJson } from "./lib/jsonc.js";
37
+
38
+ // ── constants ────────────────────────────────────────────────────────────────
39
+
40
+ /** ~/.pi/agent/settings.json:sessionName 配置所在文件 */
41
+ export const SETTINGS_PATH = join(homedir(), ".pi", "agent", "settings.json");
42
+ /** 会话名最大长度(字符),模型命名与启发式共用 */
43
+ export const DEFAULT_MAX_LENGTH = 30;
44
+ /** 命名请求超时。ctx.signal 在 agent 空闲时为 undefined,不能只依赖它 */
45
+ export const REQUEST_TIMEOUT_MS = 30_000;
46
+ /**
47
+ * 命名模型输出上限。命名任务本身简单,但 reasoning 模型(如 deepseek-v4-flash)
48
+ * 会先输出推理过程再给最终名字:64 太小会在推理阶段被截断导致 content 为空,
49
+ * 调大到与 vision-agent 的 DEFAULT_MAX_TOKENS 一致,保证推理模型正常出结果。
50
+ */
51
+ export const NAMER_MAX_TOKENS = 4096;
52
+
53
+ // ── types ────────────────────────────────────────────────────────────────────
54
+
55
+ export interface SessionNameConfig {
56
+ provider?: string;
57
+ model?: string;
58
+ maxLength?: number;
59
+ }
60
+
61
+ /**
62
+ * 命名所需的模型注册表操作:扩展传 ctx.modelRegistry,测试传 mock。
63
+ * 结构化类型(duck typing),只声明用到的两个方法。
64
+ */
65
+ export interface ModelRegistryLike {
66
+ find(provider: string, modelId: string): Model<Api> | undefined;
67
+ complete(
68
+ model: Model<Api>,
69
+ context: Context,
70
+ options?: ApiStreamOptions<Api> & { signal?: AbortSignal },
71
+ ): Promise<AssistantMessage>;
72
+ }
73
+
74
+ /** 命名所需的 session 操作:扩展传 pi,测试传 mock */
75
+ export interface NamerAPI {
76
+ getSessionName(): string | undefined;
77
+ setSessionName(name: string): void;
78
+ }
79
+
80
+ /** 触发时的 UI / 模型上下文;print / json 模式(hasUI=false)下不通知 */
81
+ export interface SessionNamingContext {
82
+ hasUI?: boolean;
83
+ notify?: (message: string) => void;
84
+ /** 模型注册表(ctx.modelRegistry),用于按 provider/model 解析并调用命名模型 */
85
+ registry?: ModelRegistryLike;
86
+ /** 当前 abort signal;agent 空闲时为 undefined */
87
+ signal?: AbortSignal;
88
+ }
89
+
90
+ /** 只依赖 type/message.role/content 字段,不绑定 pi 内部类型 */
91
+ export interface UserMessageLike {
92
+ type: string;
93
+ message?: { role?: string; content?: unknown } | null;
94
+ }
95
+
96
+ // ── 配置解析(纯函数,可测试)───────────────────────────────────────────────
97
+
98
+ /**
99
+ * 读取 ~/.pi/agent/settings.json 的 sessionName 配置。
100
+ * provider 缺省时回退到 defaultProvider;文件缺失 / JSON 损坏 / 无 sessionName
101
+ * 时返回 undefined。支持 jsonc(注释/尾逗号),与 pi 文档的 settings.json
102
+ * 示例一致。
103
+ */
104
+ export function loadSessionNameConfig(settingsPath = SETTINGS_PATH): SessionNameConfig | undefined {
105
+ let raw: string;
106
+ try {
107
+ raw = readFileSync(settingsPath, "utf8");
108
+ } catch {
109
+ return undefined;
110
+ }
111
+ try {
112
+ const parsed: unknown = JSON.parse(jsoncToJson(raw));
113
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
114
+ const settings = parsed as Record<string, unknown>;
115
+ const sn = settings.sessionName;
116
+ if (!sn || typeof sn !== "object" || Array.isArray(sn)) return undefined;
117
+ const config = sn as Record<string, unknown>;
118
+ const provider =
119
+ typeof config.provider === "string" ? config.provider.trim() || undefined : undefined;
120
+ const defaultProvider =
121
+ typeof settings.defaultProvider === "string"
122
+ ? settings.defaultProvider.trim() || undefined
123
+ : undefined;
124
+ const maxLength =
125
+ typeof config.maxLength === "number" && config.maxLength > 0
126
+ ? Math.floor(config.maxLength)
127
+ : undefined;
128
+ return {
129
+ provider: provider ?? defaultProvider,
130
+ model: typeof config.model === "string" ? config.model.trim() || undefined : undefined,
131
+ maxLength,
132
+ };
133
+ } catch {
134
+ return undefined;
135
+ }
136
+ }
137
+
138
+ // ── 文本提取与命名生成(纯函数,可测试)────────────────────────────────────
139
+
140
+ /** 从消息 content(字符串或分片数组)提取文本 */
141
+ function messageText(content: unknown): string {
142
+ if (typeof content === "string") return content.trim();
143
+ if (!Array.isArray(content)) return "";
144
+ return content
145
+ .map((part: unknown) => {
146
+ if (typeof part === "string") return part;
147
+ if (
148
+ part &&
149
+ typeof part === "object" &&
150
+ typeof (part as Record<string, unknown>).text === "string"
151
+ ) {
152
+ return (part as Record<string, unknown>).text as string;
153
+ }
154
+ return "";
155
+ })
156
+ .join("")
157
+ .trim();
158
+ }
159
+
160
+ /**
161
+ * 取要命名的 prompt 文本:新会话(branch 为空)用当前 prompt;
162
+ * 恢复的会话从历史找第一条 user 消息。找不到可命名文本时返回 undefined。
163
+ */
164
+ export function extractFirstUserPrompt(
165
+ branch: readonly UserMessageLike[],
166
+ currentPrompt: string,
167
+ ): string | undefined {
168
+ const trimmed = currentPrompt.trim();
169
+ if (branch.length > 0) {
170
+ for (const entry of branch) {
171
+ if (entry.type !== "message") continue;
172
+ const text = messageText(entry.message?.content);
173
+ if (text) return text;
174
+ }
175
+ }
176
+ return trimmed || undefined;
177
+ }
178
+
179
+ /** 折叠空白、限制长度;空结果返回 undefined */
180
+ export function sanitizeName(raw: string, maxLength = DEFAULT_MAX_LENGTH): string | undefined {
181
+ const collapsed = raw.replaceAll(/\s+/g, " ").trim();
182
+ if (!collapsed) return undefined;
183
+ if (collapsed.length <= maxLength) return collapsed;
184
+ return `${collapsed.slice(0, maxLength - 1).trimEnd()}…`;
185
+ }
186
+
187
+ /**
188
+ * 启发式命名:取第一个非空行(首行为代码围栏时跳过),去掉常见 markdown
189
+ * 装饰,截断到 maxLength。适用于未配置命名模型、模型调用失败等场景。
190
+ */
191
+ export function heuristicName(text: string, maxLength = DEFAULT_MAX_LENGTH): string | undefined {
192
+ const lines = text
193
+ .split(/\r?\n/)
194
+ .map((s) => s.trim())
195
+ .filter((s) => s.length > 0);
196
+ const first = lines[0];
197
+ // "```" / "```ts" 之类的纯代码围栏行不是内容,取下一行
198
+ const line = first && /^`{1,3}\w*$/.test(first) ? (lines[1] ?? first) : first;
199
+ if (!line) return undefined;
200
+ const cleaned = line
201
+ .replace(/^#{1,6}\s+/, "") // 标题
202
+ .replace(/^[-*+]\s+/, "") // 无序列表
203
+ .replace(/^\d+[.)]\s+/, "") // 有序列表
204
+ .replace(/^>\s?/, "") // 引用
205
+ .replace(/^`{1,3}/, "") // 行首代码围栏 / 行内代码
206
+ .replace(/`{1,3}$/, "") // 行尾代码围栏
207
+ .trim();
208
+ return sanitizeName(cleaned || line, maxLength);
209
+ }
210
+
211
+ // ── 命名模型调用 ─────────────────────────────────────────────────────────────
212
+
213
+ /** 命名模型的 system prompt:只输出一个短名 */
214
+ export function buildNamerPrompt(maxLength: number): string {
215
+ return [
216
+ "你是一个会话命名助手。根据用户给出的第一条消息内容,生成一个简洁的会话显示名。",
217
+ `要求:不超过 ${maxLength} 个字符,概括消息主题;只输出名字本身,不要引号、标点、解释或多余说明。`,
218
+ "消息是中文时用中文命名,英文时用英文命名,保持原有语言。",
219
+ ].join("\n");
220
+ }
221
+
222
+ /** 合并调用方 signal 与本地超时;调用方未传时仍然有超时兜底 */
223
+ function withTimeout(signal: AbortSignal | undefined, ms: number): AbortSignal {
224
+ const timeout = AbortSignal.timeout(ms);
225
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
226
+ }
227
+
228
+ /**
229
+ * 通过模型注册表调用命名模型,让模型把 prompt 概括成短名。
230
+ * 走 pi 的 AI SDK(modelRegistry.complete),复用 provider 解析与
231
+ * thinking/重试/usage 等基础设施,不手写 HTTP 请求。
232
+ *
233
+ * 注意:contentText 只取 content 里的 text 块(自动排除 thinking),
234
+ * 推理模型的思考过程不会被当作会话名。
235
+ *
236
+ * @returns 模型返回的原始文本(未清洗,需再经 sanitizeName)
237
+ */
238
+ export async function callNamer(
239
+ registry: ModelRegistryLike,
240
+ model: Model<Api>,
241
+ text: string,
242
+ maxLength: number,
243
+ signal?: AbortSignal,
244
+ ): Promise<string> {
245
+ const result = await registry.complete(
246
+ model,
247
+ {
248
+ systemPrompt: buildNamerPrompt(maxLength),
249
+ messages: [{ role: "user", content: text, timestamp: Date.now() }],
250
+ },
251
+ { maxTokens: NAMER_MAX_TOKENS, signal: withTimeout(signal, REQUEST_TIMEOUT_MS) },
252
+ );
253
+ const output = contentText(result.content).trim();
254
+ if (!output) {
255
+ throw new Error("API 未返回内容");
256
+ }
257
+ return output;
258
+ }
259
+
260
+ // ── 命名编排 ─────────────────────────────────────────────────────────────────
261
+
262
+ /**
263
+ * 生成会话名:配置了命名模型且能在注册表中解析到模型时优先用模型生成
264
+ * (失败退化),否则用启发式。模型输出与启发式结果都经过 sanitizeName,
265
+ * 保证 ≤ maxLength。
266
+ */
267
+ export async function generateSessionName(
268
+ text: string,
269
+ config: SessionNameConfig | undefined,
270
+ options: {
271
+ registry?: ModelRegistryLike;
272
+ signal?: AbortSignal;
273
+ } = {},
274
+ ): Promise<string | undefined> {
275
+ const maxLength = config?.maxLength ?? DEFAULT_MAX_LENGTH;
276
+ const model = config?.model;
277
+ if (model) {
278
+ const registry = options.registry;
279
+ const resolved = registry?.find(config.provider ?? "default", model);
280
+ if (registry && resolved) {
281
+ const raw = await callNamer(registry, resolved, text, maxLength, options.signal).catch(
282
+ () => "",
283
+ );
284
+ if (raw) {
285
+ const name = sanitizeName(raw, maxLength);
286
+ if (name) return name;
287
+ }
288
+ }
289
+ }
290
+ return heuristicName(text, maxLength);
291
+ }
292
+
293
+ /**
294
+ * 完整命名流程:读配置 → 生成名字 → 设置会话名并通知。
295
+ * 失败(如会话已切换导致 pi stale)由调用方 catch 忽略。
296
+ */
297
+ export async function nameSession(
298
+ pi: NamerAPI,
299
+ text: string,
300
+ ctx: SessionNamingContext = {},
301
+ ): Promise<void> {
302
+ const config = loadSessionNameConfig();
303
+ const name = await generateSessionName(text, config, {
304
+ registry: ctx.registry,
305
+ signal: ctx.signal,
306
+ });
307
+ if (!name) return;
308
+ pi.setSessionName(name);
309
+ if (ctx.hasUI && ctx.notify) {
310
+ ctx.notify(`会话已命名为: ${name}`);
311
+ }
312
+ }
313
+
314
+ // ── extension ────────────────────────────────────────────────────────────────
315
+
316
+ export default function sessionNameExtension(pi: ExtensionAPI) {
317
+ // 首个 user prompt 到达时自动命名。命名是后台副作用,不阻塞 agent 启动;
318
+ // 会话切换 / reload 后捕获的 pi 会抛 stale 错误,被 catch 忽略,名字
319
+ // 绝不会写到错误的 session。
320
+ pi.on("before_agent_start", (event, ctx) => {
321
+ if (pi.getSessionName()) return;
322
+ const text = extractFirstUserPrompt(ctx.sessionManager.getBranch(), event.prompt);
323
+ if (!text) return;
324
+ void nameSession(pi, text, {
325
+ hasUI: ctx.hasUI,
326
+ notify: (message) => ctx.ui.notify(message, "info"),
327
+ registry: ctx.modelRegistry,
328
+ signal: ctx.signal,
329
+ }).catch(() => {
330
+ return; // 会话切换 / reload 后 pi 已 stale,名字不会写错 session,忽略即可
331
+ });
332
+ });
333
+ }
package/src/talk/index.ts CHANGED
@@ -16,9 +16,9 @@ import { fileURLToPath } from "node:url";
16
16
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
17
17
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
18
18
  import { Box, Text } from "@earendil-works/pi-tui";
19
- import { Type } from "typebox";
19
+ import { type TObject, Type } from "typebox";
20
20
 
21
- import { parseArgs } from "../lib/cli-args.js";
21
+ import { type CommandResult, type CommandSpec, parseCommand } from "../lib/cli.js";
22
22
  import { resolveHomePath } from "../lib/path.js";
23
23
  import { TalkCore } from "./core.js";
24
24
  import { formatDelivery } from "./format.js";
@@ -281,92 +281,155 @@ export default function talk(pi: ExtensionAPI) {
281
281
 
282
282
  // ── /talk commands ────────────────────────────────────────────────────
283
283
 
284
- pi.registerCommand("talk", {
285
- description: "List registered pi agents",
286
- async handler() {
287
- const text = requireInit() ?? (await core.list());
284
+ type OkResult<TFlags extends TObject> = Extract<CommandResult<TFlags>, { kind: "ok" }>;
285
+
286
+ /** Parse a /talk command; on help/error or init failure the text is sent, otherwise run() produces the listing text. */
287
+ function handleCommand<TFlags extends TObject>(
288
+ spec: CommandSpec<TFlags>,
289
+ args: string,
290
+ run: (parsed: OkResult<TFlags>) => Promise<string> | string,
291
+ ): Promise<void> {
292
+ const parsed = parseCommand(spec, args);
293
+ if (parsed.kind !== "ok") {
294
+ pi.sendMessage({ customType: LIST_TYPE, content: parsed.text, display: true });
295
+ return Promise.resolve();
296
+ }
297
+ return (async () => {
298
+ const initError = requireInit();
299
+ const text = initError ?? (await run(parsed));
288
300
  pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
289
- },
290
- });
301
+ })();
302
+ }
291
303
 
292
- pi.registerCommand("talk-dead", {
304
+ const TALK_SPEC = {
305
+ name: "talk",
306
+ usage: "",
307
+ description: "List registered pi agents",
308
+ flags: Type.Object({}),
309
+ arity: { max: 0 },
310
+ };
311
+
312
+ const TALK_DEAD_SPEC = {
313
+ name: "talk-dead",
314
+ usage: "[agentId] [options]",
293
315
  description:
294
316
  "Mark a talk agent as dead (shown offline, swept soon): no arg = this agent, <agentId> = that agent, --all = every other visible agent",
295
- async handler(args) {
296
- const initError = requireInit();
297
- const trimmed = args.trim();
298
- const text =
299
- initError ??
300
- (trimmed === "--all"
317
+ flags: Type.Object({
318
+ all: Type.Optional(Type.Boolean({ description: "Mark every other visible agent dead" })),
319
+ }),
320
+ flagMeta: { all: { short: "a" } },
321
+ arity: { max: 1 },
322
+ };
323
+
324
+ const TALK_GROUP_JOIN_SPEC = {
325
+ name: "talk-group-join",
326
+ usage: "[group name] [options]",
327
+ description:
328
+ "Join or create a private agent group (members see only each other; an agent in no group sees only itself). No arg = new group with a generated uuid; <name> = join that group, or create it when it does not exist; --name <alias> additionally sets this agent's display name",
329
+ flags: Type.Object({
330
+ name: Type.Optional(Type.String({ description: "Set this agent's display name" })),
331
+ }),
332
+ flagMeta: { name: { short: "n", valuePlaceholder: "<alias>" } },
333
+ arity: { max: 1 },
334
+ examples: ["/talk-group-join frontend", "/talk-group-join --name frontend"],
335
+ };
336
+
337
+ const TALK_GROUP_JOIN_LAST_SPEC = {
338
+ name: "talk-group-join-last",
339
+ usage: "",
340
+ description: "Join the most recently created agent group (no-op when already in it).",
341
+ flags: Type.Object({}),
342
+ arity: { max: 0 },
343
+ };
344
+
345
+ const TALK_GROUP_LEAVE_SPEC = {
346
+ name: "talk-group-leave",
347
+ usage: "",
348
+ description: "Leave the current agent group (an emptied group is deleted).",
349
+ flags: Type.Object({}),
350
+ arity: { max: 0 },
351
+ };
352
+
353
+ const TALK_GROUP_LIST_SPEC = {
354
+ name: "talk-group-list",
355
+ usage: "",
356
+ description: "List all agent groups and their members, newest first.",
357
+ flags: Type.Object({}),
358
+ arity: { max: 0 },
359
+ };
360
+
361
+ const TALK_GROUP_DEL_SPEC = {
362
+ name: "talk-group-del",
363
+ usage: "<group name>",
364
+ description:
365
+ "Delete an agent group by name; its members become ungrouped (see only themselves).",
366
+ flags: Type.Object({}),
367
+ arity: { min: 1, max: 1 },
368
+ };
369
+
370
+ const TALK_GROUP_CLEAR_SPEC = {
371
+ name: "talk-group-clear",
372
+ usage: "",
373
+ description: "Delete every agent group; all agents become ungrouped.",
374
+ flags: Type.Object({}),
375
+ arity: { max: 0 },
376
+ };
377
+
378
+ pi.registerCommand("talk", {
379
+ description: TALK_SPEC.description,
380
+ handler: (args) => handleCommand(TALK_SPEC, args, async () => core.list()),
381
+ });
382
+
383
+ pi.registerCommand("talk-dead", {
384
+ description: TALK_DEAD_SPEC.description,
385
+ handler: (args) =>
386
+ handleCommand(TALK_DEAD_SPEC, args, async (parsed) => {
387
+ if (parsed.flags.all && parsed.args.length > 0) {
388
+ return "--all cannot be combined with an agent id.\nTry '/talk-dead --help' for usage.";
389
+ }
390
+ return parsed.flags.all
301
391
  ? await core.markAllDead()
302
- : trimmed
303
- ? await core.markDead(trimmed)
304
- : await core.markDead());
305
- pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
306
- },
392
+ : parsed.args[0]
393
+ ? await core.markDead(parsed.args[0])
394
+ : await core.markDead();
395
+ }),
307
396
  });
308
397
 
309
398
  pi.registerCommand("talk-group-join", {
310
- description:
311
- "Join or create a private agent group (members see only each other; an agent in no group sees only itself). No arg = new group with a generated uuid; <name> = join that group, or create it when it does not exist; --name <alias> additionally sets this agent's display name (e.g. --name frontend)",
312
- async handler(args) {
313
- const initError = requireInit();
314
- const parsed = parseArgs(args);
315
- const groupName = parsed.positionals[0];
316
- const flag = parsed.flags.name;
317
- const agentName = typeof flag === "string" && flag.trim() !== "" ? flag.trim() : undefined;
318
- if (agentName !== undefined) explicitName = agentName;
319
- const text = initError ?? (await core.groupJoin(groupName || undefined, agentName));
320
- pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
321
- },
399
+ description: TALK_GROUP_JOIN_SPEC.description,
400
+ handler: (args) =>
401
+ handleCommand(TALK_GROUP_JOIN_SPEC, args, async (parsed) => {
402
+ const agentName = parsed.flags.name?.trim() || undefined;
403
+ if (agentName !== undefined) explicitName = agentName;
404
+ return core.groupJoin(parsed.args[0], agentName);
405
+ }),
322
406
  });
323
407
 
324
408
  pi.registerCommand("talk-group-join-last", {
325
- description: "Join the most recently created agent group (no-op when already in it).",
326
- async handler() {
327
- const initError = requireInit();
328
- const text = initError ?? (await core.groupJoinLast());
329
- pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
330
- },
409
+ description: TALK_GROUP_JOIN_LAST_SPEC.description,
410
+ handler: (args) =>
411
+ handleCommand(TALK_GROUP_JOIN_LAST_SPEC, args, async () => core.groupJoinLast()),
331
412
  });
332
413
 
333
414
  pi.registerCommand("talk-group-leave", {
334
- description: "Leave the current agent group (an emptied group is deleted).",
335
- async handler() {
336
- const initError = requireInit();
337
- const text = initError ?? (await core.groupLeave());
338
- pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
339
- },
415
+ description: TALK_GROUP_LEAVE_SPEC.description,
416
+ handler: (args) => handleCommand(TALK_GROUP_LEAVE_SPEC, args, async () => core.groupLeave()),
340
417
  });
341
418
 
342
419
  pi.registerCommand("talk-group-list", {
343
- description: "List all agent groups and their members, newest first.",
344
- async handler() {
345
- const initError = requireInit();
346
- const text = initError ?? (await core.groupList());
347
- pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
348
- },
420
+ description: TALK_GROUP_LIST_SPEC.description,
421
+ handler: (args) => handleCommand(TALK_GROUP_LIST_SPEC, args, async () => core.groupList()),
349
422
  });
350
423
 
351
424
  pi.registerCommand("talk-group-del", {
352
- description:
353
- "Delete an agent group by name; its members become ungrouped (see only themselves).",
354
- async handler(args) {
355
- const initError = requireInit();
356
- const name = args.trim();
357
- const text =
358
- initError ?? (name ? await core.groupDelete(name) : "Usage: /talk-group-del <group name>");
359
- pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
360
- },
425
+ description: TALK_GROUP_DEL_SPEC.description,
426
+ handler: (args) =>
427
+ handleCommand(TALK_GROUP_DEL_SPEC, args, async (parsed) => core.groupDelete(parsed.args[0])),
361
428
  });
362
429
 
363
430
  pi.registerCommand("talk-group-clear", {
364
- description: "Delete every agent group; all agents become ungrouped.",
365
- async handler() {
366
- const initError = requireInit();
367
- const text = initError ?? (await core.groupClear());
368
- pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
369
- },
431
+ description: TALK_GROUP_CLEAR_SPEC.description,
432
+ handler: (args) => handleCommand(TALK_GROUP_CLEAR_SPEC, args, async () => core.groupClear()),
370
433
  });
371
434
 
372
435
  // ── Delivery card ──────────────────────────────────────────────────────