@oh-my-pi/pi-coding-agent 14.9.8 → 15.0.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.
Files changed (138) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/package.json +7 -7
  3. package/scripts/build-binary.ts +11 -0
  4. package/scripts/format-prompts.ts +1 -1
  5. package/src/cli/args.ts +2 -2
  6. package/src/cli/stats-cli.ts +2 -0
  7. package/src/cli.ts +24 -1
  8. package/src/commands/acp.ts +24 -0
  9. package/src/commands/launch.ts +6 -4
  10. package/src/commit/agentic/prompts/system.md +1 -1
  11. package/src/config/model-resolver.ts +30 -0
  12. package/src/config/settings-schema.ts +61 -9
  13. package/src/config/settings.ts +18 -1
  14. package/src/edit/index.ts +22 -1
  15. package/src/edit/modes/patch.ts +10 -0
  16. package/src/edit/modes/replace.ts +3 -0
  17. package/src/edit/renderer.ts +10 -0
  18. package/src/edit/streaming.ts +1 -1
  19. package/src/eval/js/context-manager.ts +10 -9
  20. package/src/eval/js/shared/rewrite-imports.ts +120 -48
  21. package/src/eval/js/shared/runtime.ts +31 -4
  22. package/src/eval/js/tool-bridge.ts +43 -21
  23. package/src/extensibility/extensions/runner.ts +54 -1
  24. package/src/extensibility/extensions/types.ts +11 -0
  25. package/src/extensibility/skills.ts +33 -1
  26. package/src/hashline/grammar.lark +1 -1
  27. package/src/hashline/input.ts +11 -5
  28. package/src/internal-urls/docs-index.generated.ts +7 -7
  29. package/src/internal-urls/index.ts +1 -0
  30. package/src/internal-urls/issue-pr-protocol.ts +577 -0
  31. package/src/internal-urls/router.ts +6 -3
  32. package/src/internal-urls/types.ts +22 -1
  33. package/src/main.ts +13 -9
  34. package/src/modes/acp/acp-agent.ts +361 -54
  35. package/src/modes/acp/acp-client-bridge.ts +152 -0
  36. package/src/modes/acp/acp-event-mapper.ts +180 -15
  37. package/src/modes/acp/terminal-auth.ts +37 -0
  38. package/src/modes/components/read-tool-group.ts +29 -1
  39. package/src/modes/controllers/command-controller.ts +14 -6
  40. package/src/modes/controllers/event-controller.ts +24 -11
  41. package/src/modes/controllers/extension-ui-controller.ts +8 -2
  42. package/src/modes/controllers/input-controller.ts +72 -39
  43. package/src/modes/interactive-mode.ts +71 -7
  44. package/src/modes/rpc/rpc-mode.ts +17 -2
  45. package/src/modes/types.ts +6 -2
  46. package/src/modes/utils/ui-helpers.ts +15 -3
  47. package/src/prompts/agents/designer.md +5 -5
  48. package/src/prompts/agents/explore.md +7 -7
  49. package/src/prompts/agents/init.md +9 -9
  50. package/src/prompts/agents/librarian.md +14 -14
  51. package/src/prompts/agents/plan.md +4 -4
  52. package/src/prompts/agents/reviewer.md +5 -5
  53. package/src/prompts/agents/task.md +10 -10
  54. package/src/prompts/commands/orchestrate.md +2 -2
  55. package/src/prompts/compaction/branch-summary.md +3 -3
  56. package/src/prompts/compaction/compaction-short-summary.md +7 -7
  57. package/src/prompts/compaction/compaction-summary-context.md +1 -1
  58. package/src/prompts/compaction/compaction-summary.md +5 -5
  59. package/src/prompts/compaction/compaction-turn-prefix.md +3 -3
  60. package/src/prompts/compaction/compaction-update-summary.md +11 -11
  61. package/src/prompts/memories/consolidation.md +2 -2
  62. package/src/prompts/memories/read-path.md +1 -1
  63. package/src/prompts/memories/stage_one_input.md +1 -1
  64. package/src/prompts/memories/stage_one_system.md +5 -5
  65. package/src/prompts/review-request.md +4 -4
  66. package/src/prompts/system/agent-creation-architect.md +17 -17
  67. package/src/prompts/system/agent-creation-user.md +2 -2
  68. package/src/prompts/system/commit-message-system.md +2 -2
  69. package/src/prompts/system/custom-system-prompt.md +2 -2
  70. package/src/prompts/system/eager-todo.md +6 -6
  71. package/src/prompts/system/handoff-document.md +1 -1
  72. package/src/prompts/system/plan-mode-active.md +22 -21
  73. package/src/prompts/system/plan-mode-approved.md +4 -4
  74. package/src/prompts/system/plan-mode-compact-instructions.md +16 -0
  75. package/src/prompts/system/plan-mode-reference.md +2 -2
  76. package/src/prompts/system/plan-mode-subagent.md +8 -8
  77. package/src/prompts/system/plan-mode-tool-decision-reminder.md +2 -2
  78. package/src/prompts/system/project-prompt.md +4 -4
  79. package/src/prompts/system/subagent-system-prompt.md +7 -7
  80. package/src/prompts/system/subagent-yield-reminder.md +4 -4
  81. package/src/prompts/system/system-prompt.md +72 -71
  82. package/src/prompts/system/ttsr-interrupt.md +1 -1
  83. package/src/prompts/tools/apply-patch.md +1 -1
  84. package/src/prompts/tools/ast-edit.md +3 -3
  85. package/src/prompts/tools/ast-grep.md +3 -3
  86. package/src/prompts/tools/browser.md +3 -3
  87. package/src/prompts/tools/checkpoint.md +3 -3
  88. package/src/prompts/tools/exit-plan-mode.md +2 -2
  89. package/src/prompts/tools/find.md +3 -3
  90. package/src/prompts/tools/github.md +2 -5
  91. package/src/prompts/tools/hashline.md +20 -20
  92. package/src/prompts/tools/image-gen.md +3 -3
  93. package/src/prompts/tools/irc.md +1 -1
  94. package/src/prompts/tools/lsp.md +2 -2
  95. package/src/prompts/tools/patch.md +6 -6
  96. package/src/prompts/tools/read.md +7 -7
  97. package/src/prompts/tools/replace.md +5 -5
  98. package/src/prompts/tools/retain.md +1 -1
  99. package/src/prompts/tools/rewind.md +2 -2
  100. package/src/prompts/tools/search.md +2 -2
  101. package/src/prompts/tools/ssh.md +2 -2
  102. package/src/prompts/tools/task.md +12 -6
  103. package/src/prompts/tools/web-search.md +2 -2
  104. package/src/prompts/tools/write.md +3 -3
  105. package/src/sdk.ts +69 -12
  106. package/src/session/agent-session.ts +231 -22
  107. package/src/session/client-bridge.ts +81 -0
  108. package/src/session/compaction/errors.ts +31 -0
  109. package/src/session/compaction/index.ts +1 -0
  110. package/src/slash-commands/acp-builtins.ts +46 -0
  111. package/src/slash-commands/builtin-registry.ts +699 -116
  112. package/src/slash-commands/helpers/context-report.ts +39 -0
  113. package/src/slash-commands/helpers/format.ts +23 -0
  114. package/src/slash-commands/helpers/marketplace-manager.ts +25 -0
  115. package/src/slash-commands/helpers/mcp.ts +532 -0
  116. package/src/slash-commands/helpers/parse.ts +85 -0
  117. package/src/slash-commands/helpers/ssh.ts +193 -0
  118. package/src/slash-commands/helpers/todo.ts +279 -0
  119. package/src/slash-commands/helpers/usage-report.ts +91 -0
  120. package/src/slash-commands/types.ts +126 -0
  121. package/src/task/executor.ts +10 -3
  122. package/src/task/index.ts +29 -51
  123. package/src/task/render.ts +6 -3
  124. package/src/task/worktree.ts +170 -239
  125. package/src/tools/bash.ts +176 -2
  126. package/src/tools/browser/tab-supervisor.ts +13 -13
  127. package/src/tools/conflict-detect.ts +6 -6
  128. package/src/tools/fetch.ts +15 -4
  129. package/src/tools/find.ts +19 -1
  130. package/src/tools/gh-renderer.ts +0 -12
  131. package/src/tools/gh.ts +682 -176
  132. package/src/tools/github-cache.ts +548 -0
  133. package/src/tools/index.ts +3 -0
  134. package/src/tools/read.ts +110 -27
  135. package/src/tools/write.ts +23 -1
  136. package/src/tui/code-cell.ts +70 -2
  137. package/src/utils/git.ts +5 -0
  138. package/src/task/isolation-backend.ts +0 -94
@@ -0,0 +1,85 @@
1
+ import type { ParsedSlashCommand, SlashCommandResult, SlashCommandRuntime } from "../types";
2
+
3
+ export interface ParsedSubcommand {
4
+ verb: string;
5
+ rest: string;
6
+ }
7
+
8
+ export type ConfigScope = "user" | "project";
9
+
10
+ export interface NamedScopeArgs {
11
+ name?: string;
12
+ scope: ConfigScope;
13
+ error?: string;
14
+ }
15
+
16
+ /**
17
+ * Parse a slash-invocation string into `name`/`args`.
18
+ *
19
+ * The separator is the earliest whitespace or `:` character so that both
20
+ * `/foo bar` and `/foo:bar` map to `{ name: "foo", args: "bar" }`.
21
+ */
22
+ export function parseSlashCommand(text: string): ParsedSlashCommand | null {
23
+ if (!text.startsWith("/")) return null;
24
+ const body = text.slice(1);
25
+ if (!body) return null;
26
+ const firstWhitespace = body.search(/\s/);
27
+ const firstColon = body.indexOf(":");
28
+ const firstSeparator =
29
+ firstWhitespace === -1 ? firstColon : firstColon === -1 ? firstWhitespace : Math.min(firstWhitespace, firstColon);
30
+ if (firstSeparator === -1) return { name: body, args: "", text };
31
+ return {
32
+ name: body.slice(0, firstSeparator),
33
+ args: body.slice(firstSeparator + 1).trim(),
34
+ text,
35
+ };
36
+ }
37
+
38
+ /** Mark a command as fully consumed in the ACP shape. */
39
+ export function commandConsumed(): { consumed: true } {
40
+ return { consumed: true };
41
+ }
42
+
43
+ /** Emit a usage/error message and consume the command. */
44
+ export async function usage(text: string, runtime: SlashCommandRuntime): Promise<SlashCommandResult> {
45
+ await runtime.output(text);
46
+ return commandConsumed();
47
+ }
48
+
49
+ /** Split `<verb> <rest>` on the first whitespace; lowercases `verb`. */
50
+ export function parseSubcommand(input: string): ParsedSubcommand {
51
+ const trimmed = input.trim();
52
+ if (!trimmed) return { verb: "", rest: "" };
53
+ const spaceIdx = trimmed.search(/\s/);
54
+ if (spaceIdx === -1) return { verb: trimmed.toLowerCase(), rest: "" };
55
+ return { verb: trimmed.slice(0, spaceIdx).toLowerCase(), rest: trimmed.slice(spaceIdx + 1).trim() };
56
+ }
57
+
58
+ export function errorMessage(error: unknown): string {
59
+ return error instanceof Error ? error.message : String(error);
60
+ }
61
+
62
+ /**
63
+ * Parse `<name?> [--scope project|user]`-style argument strings used by
64
+ * remove/rm-style subcommands. `name` is optional so callers can surface
65
+ * "name required" diagnostics with their own messaging.
66
+ */
67
+ export function parseNamedScopeArgs(rest: string, invalidScopeMessage: string): NamedScopeArgs {
68
+ const tokens = rest.split(/\s+/).filter(Boolean);
69
+ let name: string | undefined;
70
+ let scope: ConfigScope = "project";
71
+ let i = 0;
72
+ if (tokens.length > 0 && !tokens[0]!.startsWith("-")) {
73
+ name = tokens[0];
74
+ i = 1;
75
+ }
76
+ while (i < tokens.length) {
77
+ const token = tokens[i]!;
78
+ if (token !== "--scope") return { scope, error: `Unknown option: ${token}` };
79
+ const value = tokens[i + 1];
80
+ if (!value || (value !== "project" && value !== "user")) return { scope, error: invalidScopeMessage };
81
+ scope = value;
82
+ i += 2;
83
+ }
84
+ return { name, scope };
85
+ }
@@ -0,0 +1,193 @@
1
+ import { getSSHConfigPath } from "@oh-my-pi/pi-utils";
2
+ import { addSSHHost, readSSHConfigFile, removeSSHHost, type SSHHostConfig } from "../../ssh/config-writer";
3
+ import { parseCommandArgs } from "../../utils/command-args";
4
+ import type { ParsedSlashCommand, SlashCommandResult, SlashCommandRuntime } from "../types";
5
+ import { commandConsumed, errorMessage, parseNamedScopeArgs, parseSubcommand, usage } from "./parse";
6
+
7
+ interface ParsedSshAddArgs {
8
+ name?: string;
9
+ scope: "user" | "project";
10
+ host?: string;
11
+ username?: string;
12
+ port?: number;
13
+ keyPath?: string;
14
+ error?: string;
15
+ }
16
+
17
+ type SshAddOptionParser = (parsed: ParsedSshAddArgs, value: string | undefined) => string | undefined;
18
+
19
+ const SSH_ADD_USAGE =
20
+ "Usage: /ssh add <name> --host <host> [--user <user>] [--port <port>] [--key <keyPath>] [--scope project|user]";
21
+
22
+ const SSH_ADD_OPTION_PARSERS = new Map<string, SshAddOptionParser>([
23
+ [
24
+ "--host",
25
+ (parsed, value) => {
26
+ if (!value) return "Missing value for --host.";
27
+ parsed.host = value;
28
+ return undefined;
29
+ },
30
+ ],
31
+ [
32
+ "--user",
33
+ (parsed, value) => {
34
+ if (!value) return "Missing value for --user.";
35
+ parsed.username = value;
36
+ return undefined;
37
+ },
38
+ ],
39
+ [
40
+ "--port",
41
+ (parsed, value) => {
42
+ if (!value) return "Missing value for --port.";
43
+ // Reject any non-integer token. `Number.parseInt` accepts trailing
44
+ // garbage (parseInt("22oops") === 22) which silently coerces typos
45
+ // to valid-looking ports.
46
+ if (!/^\d+$/.test(value)) {
47
+ return "Invalid --port value. Must be an integer between 1 and 65535.";
48
+ }
49
+ const port = Number.parseInt(value, 10);
50
+ if (port < 1 || port > 65535) {
51
+ return "Invalid --port value. Must be an integer between 1 and 65535.";
52
+ }
53
+ parsed.port = port;
54
+ return undefined;
55
+ },
56
+ ],
57
+ [
58
+ "--key",
59
+ (parsed, value) => {
60
+ if (!value) return "Missing value for --key.";
61
+ parsed.keyPath = value;
62
+ return undefined;
63
+ },
64
+ ],
65
+ [
66
+ "--scope",
67
+ (parsed, value) => {
68
+ if (!value || (value !== "project" && value !== "user")) return "Invalid --scope value. Use project or user.";
69
+ parsed.scope = value;
70
+ return undefined;
71
+ },
72
+ ],
73
+ ]);
74
+
75
+ function parseSshAddArgs(rest: string): ParsedSshAddArgs {
76
+ const tokens = parseCommandArgs(rest);
77
+ const parsed: ParsedSshAddArgs = { scope: "project" };
78
+ let index = 0;
79
+ if (tokens.length > 0 && !tokens[0]!.startsWith("-")) {
80
+ parsed.name = tokens[0];
81
+ index = 1;
82
+ }
83
+ while (index < tokens.length) {
84
+ const arg = tokens[index]!;
85
+ const parser = SSH_ADD_OPTION_PARSERS.get(arg);
86
+ if (!parser) return { ...parsed, error: `Unknown option: ${arg}` };
87
+ const error = parser(parsed, tokens[index + 1]);
88
+ if (error) return { ...parsed, error };
89
+ index += 2;
90
+ }
91
+ return parsed;
92
+ }
93
+
94
+ const SSH_HELP_TEXT = [
95
+ "SSH host management (ACP mode)",
96
+ " /ssh add <name> --host <host> [--user <user>] [--port <port>] [--key <keyPath>] [--scope project|user]",
97
+ " /ssh list List configured SSH hosts",
98
+ " /ssh remove <name> [--scope project|user] Remove an SSH host",
99
+ " /ssh help Show this help",
100
+ ].join("\n");
101
+
102
+ async function handleListCommand(runtime: SlashCommandRuntime): Promise<SlashCommandResult> {
103
+ try {
104
+ const userPath = getSSHConfigPath("user", runtime.cwd);
105
+ const projectPath = getSSHConfigPath("project", runtime.cwd);
106
+ const [userConfig, projectConfig] = await Promise.all([
107
+ readSSHConfigFile(userPath),
108
+ readSSHConfigFile(projectPath),
109
+ ]);
110
+ const entries: Array<{ name: string; host: string; user?: string; port?: number; scope: string }> = [];
111
+ // Capability loader resolves project before user, so list project hosts
112
+ // first and let the user-scope loop skip duplicates. Otherwise a host
113
+ // shared between scopes shows up under "user" when the project entry
114
+ // is the one actually in effect.
115
+ for (const [name, config] of Object.entries(projectConfig.hosts ?? {})) {
116
+ entries.push({ name, host: config.host, user: config.username, port: config.port, scope: "project" });
117
+ }
118
+ for (const [name, config] of Object.entries(userConfig.hosts ?? {})) {
119
+ if (!entries.some(entry => entry.name === name)) {
120
+ entries.push({ name, host: config.host, user: config.username, port: config.port, scope: "user" });
121
+ }
122
+ }
123
+ if (entries.length === 0) {
124
+ await runtime.output("No SSH hosts configured.");
125
+ return commandConsumed();
126
+ }
127
+ await runtime.output(
128
+ entries
129
+ .map(entry => `${entry.name} | ${entry.host} | ${entry.user ?? "-"} | ${entry.port ?? 22} [${entry.scope}]`)
130
+ .join("\n"),
131
+ );
132
+ return commandConsumed();
133
+ } catch (err) {
134
+ return usage(`Failed to list SSH hosts: ${errorMessage(err)}`, runtime);
135
+ }
136
+ }
137
+
138
+ async function handleRemoveCommand(rest: string, runtime: SlashCommandRuntime): Promise<SlashCommandResult> {
139
+ const parsed = parseNamedScopeArgs(rest, "Invalid --scope value. Use project or user.");
140
+ if (parsed.error) return usage(parsed.error, runtime);
141
+ if (!parsed.name) return usage("Usage: /ssh remove <name> [--scope project|user]", runtime);
142
+ try {
143
+ const filePath = getSSHConfigPath(parsed.scope, runtime.cwd);
144
+ await removeSSHHost(filePath, parsed.name);
145
+ await runtime.output(`Removed SSH host "${parsed.name}" from ${parsed.scope} config.`);
146
+ return commandConsumed();
147
+ } catch (err) {
148
+ return usage(`Failed to remove SSH host: ${errorMessage(err)}`, runtime);
149
+ }
150
+ }
151
+
152
+ async function handleAddCommand(rest: string, runtime: SlashCommandRuntime): Promise<SlashCommandResult> {
153
+ if (!rest) return usage(SSH_ADD_USAGE, runtime);
154
+ const parsed = parseSshAddArgs(rest);
155
+ if (parsed.error) return usage(parsed.error, runtime);
156
+ if (!parsed.name) return usage("Host name required. Usage: /ssh add <name> --host <host> ...", runtime);
157
+ if (!parsed.host) return usage("--host is required. Usage: /ssh add <name> --host <host> ...", runtime);
158
+ const hostConfig: SSHHostConfig = { host: parsed.host };
159
+ if (parsed.username) hostConfig.username = parsed.username;
160
+ if (parsed.port) hostConfig.port = parsed.port;
161
+ if (parsed.keyPath) hostConfig.keyPath = parsed.keyPath;
162
+ try {
163
+ const filePath = getSSHConfigPath(parsed.scope, runtime.cwd);
164
+ await addSSHHost(filePath, parsed.name, hostConfig);
165
+ await runtime.output(`Added SSH host "${parsed.name}" (${parsed.scope}).`);
166
+ return commandConsumed();
167
+ } catch (err) {
168
+ return usage(`Failed to add SSH host: ${errorMessage(err)}`, runtime);
169
+ }
170
+ }
171
+
172
+ /** ACP/text-mode `/ssh` handler. Shared by both dispatchers via the spec. */
173
+ export async function handleSshAcp(
174
+ command: ParsedSlashCommand,
175
+ runtime: SlashCommandRuntime,
176
+ ): Promise<SlashCommandResult> {
177
+ const { verb, rest } = parseSubcommand(command.args);
178
+ if (!verb || verb === "help") {
179
+ await runtime.output(SSH_HELP_TEXT);
180
+ return commandConsumed();
181
+ }
182
+ switch (verb) {
183
+ case "list":
184
+ return await handleListCommand(runtime);
185
+ case "remove":
186
+ case "rm":
187
+ return await handleRemoveCommand(rest, runtime);
188
+ case "add":
189
+ return await handleAddCommand(rest, runtime);
190
+ default:
191
+ return usage(`Unknown /ssh subcommand: ${verb}. Use /ssh help for available subcommands.`, runtime);
192
+ }
193
+ }
@@ -0,0 +1,279 @@
1
+ import * as path from "node:path";
2
+ import type { TodoPhase } from "../../tools/todo-write";
3
+ import {
4
+ applyOpsToPhases,
5
+ getLatestTodoPhasesFromEntries,
6
+ markdownToPhases,
7
+ phasesToMarkdown,
8
+ USER_TODO_EDIT_CUSTOM_TYPE,
9
+ } from "../../tools/todo-write";
10
+ import type { ParsedSlashCommand, SlashCommandResult, SlashCommandRuntime } from "../types";
11
+ import { commandConsumed, parseSubcommand, usage } from "./parse";
12
+
13
+ type TodoMutationVerb = "done" | "drop" | "rm";
14
+
15
+ interface TodoTaskMatch {
16
+ task: { content: string; status: string };
17
+ phase: TodoPhase;
18
+ }
19
+
20
+ function tokenize(input: string): string[] {
21
+ const tokens: string[] = [];
22
+ let current = "";
23
+ let inQuote = false;
24
+ for (let index = 0; index < input.length; index++) {
25
+ const ch = input[index];
26
+ if (ch === "\\" && index + 1 < input.length) {
27
+ current += input[++index];
28
+ continue;
29
+ }
30
+ if (ch === '"') {
31
+ inQuote = !inQuote;
32
+ continue;
33
+ }
34
+ if (!inQuote && /\s/.test(ch)) {
35
+ if (current) {
36
+ tokens.push(current);
37
+ current = "";
38
+ }
39
+ continue;
40
+ }
41
+ current += ch;
42
+ }
43
+ if (current) tokens.push(current);
44
+ return tokens;
45
+ }
46
+
47
+ function titleCaseWords(text: string): string {
48
+ return text
49
+ .split(/\s+/)
50
+ .filter(Boolean)
51
+ .map(word => word[0].toUpperCase() + word.slice(1))
52
+ .join(" ");
53
+ }
54
+
55
+ function titleCaseSentence(text: string): string {
56
+ const trimmed = text.trim();
57
+ if (!trimmed) return trimmed;
58
+ return trimmed[0].toUpperCase() + trimmed.slice(1);
59
+ }
60
+
61
+ function findPhaseFuzzy(phases: TodoPhase[], query: string): TodoPhase | undefined {
62
+ const normalizedQuery = query.trim().toLowerCase();
63
+ if (!normalizedQuery) return undefined;
64
+ const exact = phases.find(phase => phase.name.toLowerCase() === normalizedQuery);
65
+ if (exact) return exact;
66
+ const prefixMatches = phases.filter(phase => phase.name.toLowerCase().startsWith(normalizedQuery));
67
+ if (prefixMatches.length === 1) return prefixMatches[0];
68
+ const substringMatches = phases.filter(phase => phase.name.toLowerCase().includes(normalizedQuery));
69
+ if (substringMatches.length === 1) return substringMatches[0];
70
+ return undefined;
71
+ }
72
+
73
+ function findTaskFuzzy(phases: TodoPhase[], query: string): TodoTaskMatch | undefined {
74
+ const normalizedQuery = query.trim().toLowerCase();
75
+ if (!normalizedQuery) return undefined;
76
+ for (const phase of phases) {
77
+ for (const task of phase.tasks) {
78
+ if (task.content.toLowerCase() === normalizedQuery) return { task, phase };
79
+ }
80
+ }
81
+ const matches: TodoTaskMatch[] = [];
82
+ for (const phase of phases) {
83
+ for (const task of phase.tasks) {
84
+ if (task.content.toLowerCase().includes(normalizedQuery)) matches.push({ task, phase });
85
+ }
86
+ }
87
+ if (matches.length === 1) return matches[0];
88
+ const active = matches.filter(match => match.task.status === "in_progress" || match.task.status === "pending");
89
+ if (active.length === 1) return active[0];
90
+ return undefined;
91
+ }
92
+
93
+ function currentPhases(runtime: SlashCommandRuntime): TodoPhase[] {
94
+ const fromEntries = getLatestTodoPhasesFromEntries(runtime.sessionManager.getBranch());
95
+ return fromEntries.length > 0 ? fromEntries : runtime.session.getTodoPhases();
96
+ }
97
+
98
+ function commitTodos(runtime: SlashCommandRuntime, phases: TodoPhase[]): void {
99
+ runtime.session.setTodoPhases(phases);
100
+ runtime.sessionManager.appendCustomEntry(USER_TODO_EDIT_CUSTOM_TYPE, { phases });
101
+ }
102
+
103
+ const TODO_HELP_TEXT = [
104
+ "Usage: /todo <verb> [args]",
105
+ " /todo Show current todos",
106
+ " /todo edit (TUI only) open in $EDITOR",
107
+ " /todo copy Print todos as Markdown",
108
+ " /todo export [<path>] Write todos to file (default: TODO.md)",
109
+ " /todo import [<path>] Replace todos from file (default: TODO.md)",
110
+ " /todo append [<phase>] <task...> Append a task",
111
+ " /todo start <task> Mark task in_progress (fuzzy match)",
112
+ " /todo done [<task|phase>] Mark task/phase/all completed",
113
+ " /todo drop [<task|phase>] Mark task/phase/all abandoned",
114
+ " /todo rm [<task|phase>] Remove task/phase/all",
115
+ ].join("\n");
116
+
117
+ async function handleTodoCopyCommand(runtime: SlashCommandRuntime): Promise<SlashCommandResult> {
118
+ const phases = currentPhases(runtime);
119
+ const markdown = phases.length === 0 ? "" : phasesToMarkdown(phases).trimEnd();
120
+ await runtime.output(`Copy not available in ACP mode; printing instead:\n\n${markdown || "No todos."}`);
121
+ return commandConsumed();
122
+ }
123
+
124
+ async function handleTodoExportCommand(restArgs: string, runtime: SlashCommandRuntime): Promise<SlashCommandResult> {
125
+ const phases = currentPhases(runtime);
126
+ if (phases.length === 0) {
127
+ await runtime.output("No todos to export.");
128
+ return commandConsumed();
129
+ }
130
+ const target = restArgs ? path.resolve(runtime.cwd, restArgs) : path.resolve(runtime.cwd, "TODO.md");
131
+ await Bun.write(target, phasesToMarkdown(phases));
132
+ await runtime.output(`Wrote todos to ${target}`);
133
+ return commandConsumed();
134
+ }
135
+
136
+ async function handleTodoImportCommand(restArgs: string, runtime: SlashCommandRuntime): Promise<SlashCommandResult> {
137
+ const target = restArgs ? path.resolve(runtime.cwd, restArgs) : path.resolve(runtime.cwd, "TODO.md");
138
+ let content: string;
139
+ try {
140
+ content = await Bun.file(target).text();
141
+ } catch (err) {
142
+ return usage(`Failed to read ${target}: ${err instanceof Error ? err.message : String(err)}`, runtime);
143
+ }
144
+ const { phases, errors } = markdownToPhases(content);
145
+ if (errors.length > 0) return usage(`Could not parse ${target}:\n ${errors.join("\n ")}`, runtime);
146
+ commitTodos(runtime, phases);
147
+ const taskCount = phases.reduce((sum, phase) => sum + phase.tasks.length, 0);
148
+ await runtime.output(`Imported ${phases.length} phase(s), ${taskCount} task(s) from ${target}.`);
149
+ return commandConsumed();
150
+ }
151
+
152
+ async function handleTodoAppendCommand(restArgs: string, runtime: SlashCommandRuntime): Promise<SlashCommandResult> {
153
+ const tokens = tokenize(restArgs);
154
+ if (tokens.length === 0) return usage("Usage: /todo append [<phase>] <task...>", runtime);
155
+
156
+ const current = currentPhases(runtime);
157
+ const phaseName = tokens.length === 1 ? undefined : tokens[0];
158
+ const content = tokens.length === 1 ? tokens[0]! : tokens.slice(1).join(" ");
159
+ const next = current.map(phase => ({ ...phase, tasks: phase.tasks.slice() }));
160
+ let targetPhase: TodoPhase;
161
+
162
+ if (phaseName) {
163
+ const existing = findPhaseFuzzy(next, phaseName);
164
+ targetPhase = existing ?? { name: titleCaseWords(phaseName), tasks: [] };
165
+ if (!existing) next.push(targetPhase);
166
+ } else if (next.length > 0) {
167
+ targetPhase = next[next.length - 1]!;
168
+ } else {
169
+ targetPhase = { name: "Todos", tasks: [] };
170
+ next.push(targetPhase);
171
+ }
172
+
173
+ const finalContent = titleCaseSentence(content);
174
+ targetPhase.tasks.push({ content: finalContent, status: "pending" });
175
+ commitTodos(runtime, next);
176
+ await runtime.output(`Appended to ${targetPhase.name}: ${finalContent}`);
177
+ return commandConsumed();
178
+ }
179
+
180
+ async function handleTodoStartCommand(restArgs: string, runtime: SlashCommandRuntime): Promise<SlashCommandResult> {
181
+ if (!restArgs) return usage("Usage: /todo start <task>", runtime);
182
+ const current = currentPhases(runtime);
183
+ const query = tokenize(restArgs).join(" ") || restArgs;
184
+ const hit = findTaskFuzzy(current, query);
185
+ if (!hit) return usage(`No task matched "${restArgs}". Use /todo to list current tasks.`, runtime);
186
+ const { phases } = applyOpsToPhases(current, [{ op: "start", task: hit.task.content }]);
187
+ commitTodos(runtime, phases);
188
+ await runtime.output(`Started: ${hit.task.content}`);
189
+ return commandConsumed();
190
+ }
191
+
192
+ async function handleTodoMutationCommand(
193
+ verb: TodoMutationVerb,
194
+ restArgs: string,
195
+ runtime: SlashCommandRuntime,
196
+ ): Promise<SlashCommandResult> {
197
+ const current = currentPhases(runtime);
198
+ const trimmedArg = restArgs.trim();
199
+ if (!trimmedArg) {
200
+ if (verb === "rm") {
201
+ commitTodos(runtime, []);
202
+ await runtime.output("Cleared all todos.");
203
+ return commandConsumed();
204
+ }
205
+ const { phases } = applyOpsToPhases(current, [{ op: verb }]);
206
+ commitTodos(runtime, phases);
207
+ await runtime.output(verb === "done" ? "Marked all tasks completed." : "Marked all tasks abandoned.");
208
+ return commandConsumed();
209
+ }
210
+
211
+ const taskHit = findTaskFuzzy(current, trimmedArg);
212
+ if (taskHit) {
213
+ const { phases } = applyOpsToPhases(current, [{ op: verb, task: taskHit.task.content }]);
214
+ commitTodos(runtime, phases);
215
+ const label = verb === "done" ? "Marked completed" : verb === "drop" ? "Marked abandoned" : "Removed";
216
+ await runtime.output(`${label}: ${taskHit.task.content}`);
217
+ return commandConsumed();
218
+ }
219
+
220
+ const phaseHit = findPhaseFuzzy(current, trimmedArg);
221
+ if (phaseHit) {
222
+ const { phases } = applyOpsToPhases(current, [{ op: verb, phase: phaseHit.name }]);
223
+ commitTodos(runtime, phases);
224
+ const message =
225
+ verb === "done"
226
+ ? `Marked phase ${phaseHit.name} completed.`
227
+ : verb === "drop"
228
+ ? `Marked phase ${phaseHit.name} abandoned.`
229
+ : `Removed phase: ${phaseHit.name}`;
230
+ await runtime.output(message);
231
+ return commandConsumed();
232
+ }
233
+
234
+ return usage(`No task or phase matched "${trimmedArg}".`, runtime);
235
+ }
236
+
237
+ /** ACP/text-mode `/todo` handler. Shared by both dispatchers via the spec. */
238
+ export async function handleTodoAcp(
239
+ command: ParsedSlashCommand,
240
+ runtime: SlashCommandRuntime,
241
+ ): Promise<SlashCommandResult> {
242
+ const trimmed = command.args.trim();
243
+ if (!trimmed) {
244
+ const phases = currentPhases(runtime);
245
+ await runtime.output(
246
+ phases.length === 0 ? "No todos. Use /todo append <task> to start one." : phasesToMarkdown(phases).trimEnd(),
247
+ );
248
+ return commandConsumed();
249
+ }
250
+
251
+ const { verb, rest } = parseSubcommand(trimmed);
252
+ switch (verb) {
253
+ case "copy":
254
+ return await handleTodoCopyCommand(runtime);
255
+ case "export":
256
+ return await handleTodoExportCommand(rest, runtime);
257
+ case "import":
258
+ return await handleTodoImportCommand(rest, runtime);
259
+ case "append":
260
+ return await handleTodoAppendCommand(rest, runtime);
261
+ case "start":
262
+ return await handleTodoStartCommand(rest, runtime);
263
+ case "done":
264
+ case "drop":
265
+ case "rm":
266
+ return await handleTodoMutationCommand(verb, rest, runtime);
267
+ case "edit":
268
+ return usage(
269
+ "/todo edit requires the TUI editor; use /todo export then /todo import for non-interactive edits.",
270
+ runtime,
271
+ );
272
+ case "help":
273
+ case "?":
274
+ await runtime.output(TODO_HELP_TEXT);
275
+ return commandConsumed();
276
+ default:
277
+ return usage("Unknown /todo subcommand. Use append, start, done, drop, rm, copy, export, import.", runtime);
278
+ }
279
+ }
@@ -0,0 +1,91 @@
1
+ import type { UsageLimit, UsageReport } from "@oh-my-pi/pi-ai";
2
+ import type { SlashCommandRuntime } from "../types";
3
+ import { formatDuration, renderAsciiBar } from "./format";
4
+
5
+ function formatProviderName(provider: string): string {
6
+ return provider
7
+ .split(/[-_]/g)
8
+ .map(part => (part ? part[0].toUpperCase() + part.slice(1) : ""))
9
+ .join(" ");
10
+ }
11
+
12
+ function formatUsageAmount(limit: UsageLimit): string {
13
+ const amount = limit.amount;
14
+ const used = amount.used ?? (amount.usedFraction !== undefined ? amount.usedFraction * 100 : undefined);
15
+ const remainingFraction =
16
+ amount.remainingFraction ??
17
+ (amount.usedFraction !== undefined ? Math.max(0, 1 - amount.usedFraction) : undefined);
18
+ const unit = amount.unit === "percent" ? "%" : ` ${amount.unit}`;
19
+ const usedText = used === undefined ? "unknown used" : `${used.toFixed(2)}${unit} used`;
20
+ const remainingText = remainingFraction === undefined ? "" : ` (${(remainingFraction * 100).toFixed(1)}% left)`;
21
+ return `${usedText}${remainingText}`;
22
+ }
23
+
24
+ function formatUsageReportAccount(report: UsageReport, limit: UsageLimit, index: number): string {
25
+ const email = report.metadata?.email;
26
+ if (typeof email === "string" && email) return email;
27
+ const accountId = report.metadata?.accountId ?? limit.scope.accountId;
28
+ if (typeof accountId === "string" && accountId) return accountId;
29
+ return `account ${index + 1}`;
30
+ }
31
+
32
+ function renderUsageReports(reports: UsageReport[], nowMs: number): string {
33
+ const latestFetchedAt = Math.max(...reports.map(report => report.fetchedAt ?? 0));
34
+ const lines = [`Usage${latestFetchedAt ? ` (${formatDuration(nowMs - latestFetchedAt)} ago)` : ""}`];
35
+ const grouped = new Map<string, UsageReport[]>();
36
+ for (const report of reports) {
37
+ const providerReports = grouped.get(report.provider) ?? [];
38
+ providerReports.push(report);
39
+ grouped.set(report.provider, providerReports);
40
+ }
41
+
42
+ for (const [provider, providerReports] of [...grouped.entries()].sort(([left], [right]) =>
43
+ left.localeCompare(right),
44
+ )) {
45
+ lines.push("", formatProviderName(provider));
46
+ for (const report of providerReports) {
47
+ if (report.limits.length === 0) {
48
+ const email = typeof report.metadata?.email === "string" ? report.metadata.email : "account";
49
+ lines.push(`- ${email}: no limits reported`);
50
+ continue;
51
+ }
52
+ for (let index = 0; index < report.limits.length; index++) {
53
+ const limit = report.limits[index]!;
54
+ const window = limit.window?.label ?? limit.scope.windowId;
55
+ const tier = limit.scope.tier ? ` (${limit.scope.tier})` : "";
56
+ lines.push(`- ${limit.label}${tier}${window ? ` — ${window}` : ""}`);
57
+ lines.push(` ${formatUsageReportAccount(report, limit, index)}: ${formatUsageAmount(limit)}`);
58
+ lines.push(` ${renderAsciiBar(limit.amount.usedFraction)}`);
59
+ if (limit.window?.resetsAt) lines.push(` resets in ${formatDuration(limit.window.resetsAt - nowMs)}`);
60
+ if (limit.notes && limit.notes.length > 0) lines.push(` ${limit.notes.join(" • ")}`);
61
+ }
62
+ }
63
+ }
64
+ return ["```", ...lines, "```"].join("\n");
65
+ }
66
+
67
+ /**
68
+ * Build the `/usage` ACP-mode text. Prefers provider-reported limits when the
69
+ * session exposes `fetchUsageReports`; otherwise falls back to the local
70
+ * session-manager tallies.
71
+ */
72
+ export async function buildUsageReportText(runtime: SlashCommandRuntime): Promise<string> {
73
+ const provider = runtime.session as SlashCommandRuntime["session"] & {
74
+ fetchUsageReports?: () => Promise<UsageReport[] | null>;
75
+ };
76
+ if (provider.fetchUsageReports) {
77
+ const reports = await provider.fetchUsageReports();
78
+ if (reports && reports.length > 0) return renderUsageReports(reports, Date.now());
79
+ }
80
+
81
+ const stats = runtime.session.sessionManager.getUsageStatistics();
82
+ return [
83
+ "Usage",
84
+ `Input tokens: ${stats.input}`,
85
+ `Output tokens: ${stats.output}`,
86
+ `Cache read tokens: ${stats.cacheRead}`,
87
+ `Cache write tokens: ${stats.cacheWrite}`,
88
+ `Premium requests: ${stats.premiumRequests}`,
89
+ `Cost: $${stats.cost.toFixed(6)}`,
90
+ ].join("\n");
91
+ }