@cmetech/otto 1.0.8 → 1.1.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/dist/cli-args.d.ts +2 -0
- package/dist/cli-args.js +6 -0
- package/dist/cli.js +27 -0
- package/dist/help-text.js +15 -0
- package/dist/onboarding.d.ts +19 -0
- package/dist/onboarding.js +133 -1
- package/dist/resources/.managed-resources-content-hash +1 -1
- package/dist/resources/extensions/otto/commands/release-notes/_data.js +171 -0
- package/dist/resources/extensions/otto/commands/release-notes/command.js +114 -0
- package/dist/resources/extensions/otto/commands/theme/command.js +75 -0
- package/dist/resources/extensions/otto/extension-manifest.json +1 -1
- package/dist/resources/extensions/otto/index.js +6 -0
- package/dist/resources/extensions/subagent/agents.js +160 -8
- package/dist/resources/extensions/subagent/index.js +45 -4
- package/dist/resources/extensions/subagent/skill-tool-stub.js +23 -0
- package/dist/seed-defaults.d.ts +66 -0
- package/dist/seed-defaults.js +294 -0
- package/package.json +8 -7
- package/packages/contracts/package.json +1 -1
- package/packages/daemon/package.json +3 -3
- package/packages/mcp-server/package.json +3 -3
- package/packages/native/package.json +1 -1
- package/packages/pi-agent-core/package.json +1 -1
- package/packages/pi-ai/package.json +1 -1
- package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/agent-session.js +3 -0
- package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts +17 -0
- package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.js +89 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/package-manager.d.ts +9 -0
- package/packages/pi-coding-agent/dist/core/package-manager.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/package-manager.js +105 -67
- package/packages/pi-coding-agent/dist/core/package-manager.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/resolve-config-value.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/resolve-config-value.js +20 -2
- package/packages/pi-coding-agent/dist/core/resolve-config-value.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/settings-manager.d.ts +28 -0
- package/packages/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/settings-manager.js +47 -0
- package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/skills.d.ts +11 -0
- package/packages/pi-coding-agent/dist/core/skills.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/skills.js +22 -0
- package/packages/pi-coding-agent/dist/core/skills.js.map +1 -1
- package/packages/pi-coding-agent/dist/index.d.ts +1 -1
- package/packages/pi-coding-agent/dist/index.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/index.js +1 -1
- package/packages/pi-coding-agent/dist/index.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +13 -2
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/theme/theme.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/theme/theme.js +4 -0
- package/packages/pi-coding-agent/dist/modes/interactive/theme/theme.js.map +1 -1
- package/packages/pi-coding-agent/package.json +2 -2
- package/packages/pi-coding-agent/src/core/agent-session.ts +3 -0
- package/packages/pi-coding-agent/src/core/extensions/runner.ts +90 -1
- package/packages/pi-coding-agent/src/core/package-manager.ts +131 -64
- package/packages/pi-coding-agent/src/core/resolve-config-value.ts +20 -2
- package/packages/pi-coding-agent/src/core/settings-manager.ts +65 -0
- package/packages/pi-coding-agent/src/core/skills.ts +23 -1
- package/packages/pi-coding-agent/src/index.ts +4 -0
- package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +13 -2
- package/packages/pi-coding-agent/src/modes/interactive/theme/theme.ts +4 -0
- package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
- package/packages/pi-tui/dist/autocomplete.d.ts +9 -0
- package/packages/pi-tui/dist/autocomplete.d.ts.map +1 -1
- package/packages/pi-tui/dist/autocomplete.js +2 -0
- package/packages/pi-tui/dist/autocomplete.js.map +1 -1
- package/packages/pi-tui/dist/components/select-list.d.ts +10 -0
- package/packages/pi-tui/dist/components/select-list.d.ts.map +1 -1
- package/packages/pi-tui/dist/components/select-list.js +30 -17
- package/packages/pi-tui/dist/components/select-list.js.map +1 -1
- package/packages/pi-tui/package.json +1 -1
- package/packages/pi-tui/src/autocomplete.ts +11 -0
- package/packages/pi-tui/src/components/select-list.ts +41 -17
- package/packages/pi-tui/tsconfig.tsbuildinfo +1 -1
- package/packages/rpc-client/package.json +2 -2
- package/pkg/dist/modes/interactive/theme/theme.d.ts.map +1 -1
- package/pkg/dist/modes/interactive/theme/theme.js +4 -0
- package/pkg/dist/modes/interactive/theme/theme.js.map +1 -1
- package/pkg/package.json +1 -1
- package/src/resources/extensions/otto/commands/release-notes/_data.ts +187 -0
- package/src/resources/extensions/otto/commands/release-notes/command.ts +141 -0
- package/src/resources/extensions/otto/commands/theme/command.ts +89 -0
- package/src/resources/extensions/otto/extension-manifest.json +1 -1
- package/src/resources/extensions/otto/index.ts +8 -0
- package/src/resources/extensions/subagent/agents.ts +166 -8
- package/src/resources/extensions/subagent/index.ts +46 -6
- package/src/resources/extensions/subagent/skill-tool-stub.ts +28 -0
- package/src/resources/extensions/subagent/tests/parse-agent-tools.test.ts +52 -0
- package/src/resources/extensions/subagent/tests/skill-tool-stub.test.ts +23 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /release-notes — browse OTTO's "what's new" history.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* /release-notes → interactive selector across all versions
|
|
6
|
+
* /release-notes <version> → show that version directly (e.g. 1.0.7 or v1.0.7)
|
|
7
|
+
* /release-notes latest → alias for the newest version
|
|
8
|
+
* /release-notes list → non-interactive index (one line per version)
|
|
9
|
+
*
|
|
10
|
+
* UX modelled after Claude Code's /release-notes: a top-level list with a
|
|
11
|
+
* count badge per entry, then full detail on selection. Output is written
|
|
12
|
+
* to stdout so it lands in the chat transcript.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@otto/pi-coding-agent";
|
|
16
|
+
import {
|
|
17
|
+
RELEASE_NOTES,
|
|
18
|
+
countItems,
|
|
19
|
+
findReleaseByVersion,
|
|
20
|
+
getLatestRelease,
|
|
21
|
+
type ReleaseNote,
|
|
22
|
+
} from "./_data.js";
|
|
23
|
+
|
|
24
|
+
const USAGE = `Usage:
|
|
25
|
+
/release-notes Browse all releases interactively
|
|
26
|
+
/release-notes <version> Show a specific release (e.g. 1.0.7)
|
|
27
|
+
/release-notes latest Show the newest release
|
|
28
|
+
/release-notes list Print a one-line index of every release`;
|
|
29
|
+
|
|
30
|
+
function formatSelectorLabel(release: ReleaseNote): string {
|
|
31
|
+
const total = countItems(release);
|
|
32
|
+
const headline = release.headline ? ` — ${release.headline}` : "";
|
|
33
|
+
const badge = total === 1 ? "1 item" : `${total} items`;
|
|
34
|
+
return `v${release.version} (${release.date}, ${badge})${headline}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function renderSection(title: string, items: string[] | undefined): string {
|
|
38
|
+
if (!items || items.length === 0) return "";
|
|
39
|
+
const bulleted = items.map((line) => `- ${line}`).join("\n");
|
|
40
|
+
return `\n### ${title}\n${bulleted}\n`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function renderRelease(release: ReleaseNote): string {
|
|
44
|
+
const header = `# OTTO v${release.version} — ${release.date}`;
|
|
45
|
+
const headline = release.headline ? `\n_${release.headline}_\n` : "";
|
|
46
|
+
const body = [
|
|
47
|
+
renderSection("Added", release.added),
|
|
48
|
+
renderSection("Fixed", release.fixed),
|
|
49
|
+
renderSection("Changed", release.changed),
|
|
50
|
+
renderSection("Notes", release.notes),
|
|
51
|
+
].join("");
|
|
52
|
+
const tail = `\n---\n${RELEASE_NOTES.length} releases tracked. Use \`/release-notes\` to browse, \`/release-notes <version>\` for any other release.`;
|
|
53
|
+
return `${header}${headline}${body}${tail}\n`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function renderIndex(): string {
|
|
57
|
+
const rows = RELEASE_NOTES.map((r) => {
|
|
58
|
+
const total = countItems(r);
|
|
59
|
+
const badge = total === 1 ? "1 item" : `${total} items`;
|
|
60
|
+
const headline = r.headline ? ` — ${r.headline}` : "";
|
|
61
|
+
return `- v${r.version} (${r.date}, ${badge})${headline}`;
|
|
62
|
+
}).join("\n");
|
|
63
|
+
return `# OTTO release index\n\n${rows}\n\nView any release with \`/release-notes <version>\`.\n`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function findByVersionToken(token: string): ReleaseNote | undefined {
|
|
67
|
+
if (token === "latest") return getLatestRelease();
|
|
68
|
+
return findReleaseByVersion(token);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const CUSTOM_TYPE = "otto-release-notes";
|
|
72
|
+
|
|
73
|
+
function postToChat(pi: ExtensionAPI, content: string): void {
|
|
74
|
+
// Routes through the session's custom-message stream so the content lands
|
|
75
|
+
// inside a chat response card instead of stdout (which the TUI redraws on
|
|
76
|
+
// top of, producing the interleaved-text bug from the first cut).
|
|
77
|
+
pi.sendMessage({ customType: CUSTOM_TYPE, content, display: true });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function registerReleaseNotesCommand(pi: ExtensionAPI): void {
|
|
81
|
+
pi.registerCommand("release-notes", {
|
|
82
|
+
description: "Browse OTTO release notes — what's new, fixed, and changed",
|
|
83
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
84
|
+
const trimmed = args.trim();
|
|
85
|
+
|
|
86
|
+
// ── Direct version / latest ──────────────────────────────────
|
|
87
|
+
if (trimmed && trimmed !== "list") {
|
|
88
|
+
const match = findByVersionToken(trimmed);
|
|
89
|
+
if (!match) {
|
|
90
|
+
postToChat(
|
|
91
|
+
pi,
|
|
92
|
+
`**No release found for \`${trimmed}\`**\n\nKnown versions: ${RELEASE_NOTES.map((r) => `v${r.version}`).join(", ")}`,
|
|
93
|
+
);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
postToChat(pi, renderRelease(match));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Index dump ───────────────────────────────────────────────
|
|
101
|
+
if (trimmed === "list") {
|
|
102
|
+
postToChat(pi, renderIndex());
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Interactive selector ─────────────────────────────────────
|
|
107
|
+
if (!ctx.hasUI || typeof ctx.ui?.select !== "function") {
|
|
108
|
+
// Headless / piped: print to stdout (no TUI to corrupt) so the
|
|
109
|
+
// content is still recoverable in scripted / mcp / rpc modes.
|
|
110
|
+
process.stdout.write(renderRelease(getLatestRelease()));
|
|
111
|
+
process.stdout.write("\n" + renderIndex());
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const options = RELEASE_NOTES.map(formatSelectorLabel);
|
|
116
|
+
let pick: string | string[] | undefined;
|
|
117
|
+
try {
|
|
118
|
+
pick = await ctx.ui.select(
|
|
119
|
+
`OTTO release notes — ${RELEASE_NOTES.length} versions available`,
|
|
120
|
+
options,
|
|
121
|
+
);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
postToChat(
|
|
124
|
+
pi,
|
|
125
|
+
`**release-notes error:** ${(err as Error).message}\n\n${USAGE}`,
|
|
126
|
+
);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!pick) return; // user cancelled — nothing to do
|
|
131
|
+
const picked = Array.isArray(pick) ? pick[0] : pick;
|
|
132
|
+
const index = options.indexOf(picked);
|
|
133
|
+
const release = index >= 0 ? RELEASE_NOTES[index] : undefined;
|
|
134
|
+
if (!release) {
|
|
135
|
+
postToChat(pi, `**No match for** \`${picked}\``);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
postToChat(pi, renderRelease(release));
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /theme — list and switch the active OTTO theme at runtime.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* /theme → interactive picker over built-ins + ~/.otto/agent/themes/*.json
|
|
6
|
+
* /theme <name> → switch directly (e.g. /theme cool-mint)
|
|
7
|
+
* /theme list → print a one-line list of available themes
|
|
8
|
+
*
|
|
9
|
+
* Switch is for the current session. To persist across launches, also set
|
|
10
|
+
* `"theme": "<name>"` in ~/.otto/agent/settings.json.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@otto/pi-coding-agent";
|
|
14
|
+
import { getAvailableThemesWithPaths, setTheme } from "@otto/pi-coding-agent";
|
|
15
|
+
|
|
16
|
+
const CUSTOM_TYPE = "otto-theme";
|
|
17
|
+
|
|
18
|
+
function postToChat(pi: ExtensionAPI, content: string): void {
|
|
19
|
+
pi.sendMessage({ customType: CUSTOM_TYPE, content, display: true });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function renderList(): string {
|
|
23
|
+
const themes = getAvailableThemesWithPaths();
|
|
24
|
+
const lines = themes.map((t) => {
|
|
25
|
+
const origin = t.path ? ` _(${t.path})_` : " _(built-in)_";
|
|
26
|
+
return `- \`${t.name}\`${origin}`;
|
|
27
|
+
});
|
|
28
|
+
return `**Available themes (${themes.length})**\n\n${lines.join("\n")}\n\nUse \`/theme <name>\` to switch. Persist with \`"theme": "<name>"\` in \`~/.otto/agent/settings.json\`.`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function applyTheme(pi: ExtensionAPI, name: string): void {
|
|
32
|
+
const result = setTheme(name);
|
|
33
|
+
if (result.success) {
|
|
34
|
+
postToChat(
|
|
35
|
+
pi,
|
|
36
|
+
`**Theme switched to \`${name}\`** for this session.\n\nTo persist, set \`"theme": "${name}"\` in \`~/.otto/agent/settings.json\`.`,
|
|
37
|
+
);
|
|
38
|
+
} else {
|
|
39
|
+
const available = getAvailableThemesWithPaths().map((t) => t.name).join(", ");
|
|
40
|
+
postToChat(
|
|
41
|
+
pi,
|
|
42
|
+
`**Theme switch failed:** ${result.error ?? "unknown error"}\n\nAvailable: ${available}`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function registerThemeCommand(pi: ExtensionAPI): void {
|
|
48
|
+
pi.registerCommand("theme", {
|
|
49
|
+
description: "List or switch the active OTTO theme",
|
|
50
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
51
|
+
const trimmed = args.trim();
|
|
52
|
+
|
|
53
|
+
if (trimmed === "list") {
|
|
54
|
+
postToChat(pi, renderList());
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (trimmed) {
|
|
59
|
+
applyTheme(pi, trimmed);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Interactive picker
|
|
64
|
+
if (!ctx.hasUI || typeof ctx.ui?.select !== "function") {
|
|
65
|
+
postToChat(pi, renderList());
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const themes = getAvailableThemesWithPaths();
|
|
70
|
+
const options = themes.map((t) => (t.path ? `${t.name} (custom)` : `${t.name} (built-in)`));
|
|
71
|
+
let pick: string | string[] | undefined;
|
|
72
|
+
try {
|
|
73
|
+
pick = await ctx.ui.select(`Choose theme — ${themes.length} available`, options);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
postToChat(pi, `**theme picker error:** ${(err as Error).message}`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (!pick) return;
|
|
79
|
+
const picked = Array.isArray(pick) ? pick[0] : pick;
|
|
80
|
+
const index = options.indexOf(picked);
|
|
81
|
+
const chosen = index >= 0 ? themes[index] : undefined;
|
|
82
|
+
if (!chosen) {
|
|
83
|
+
postToChat(pi, `**No match for** \`${picked}\``);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
applyTheme(pi, chosen.name);
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -22,6 +22,8 @@ import { registerOttoTools } from "./tools/_loader.js";
|
|
|
22
22
|
import { executeLangFlowTool } from "./tools/langflow.js";
|
|
23
23
|
import { registerBuildFlowCommand } from "./commands/build-flow/command.js";
|
|
24
24
|
import { registerPromptEngineerCommand } from "./commands/prompt-engineer/command.js";
|
|
25
|
+
import { registerReleaseNotesCommand } from "./commands/release-notes/command.js";
|
|
26
|
+
import { registerThemeCommand } from "./commands/theme/command.js";
|
|
25
27
|
import { parseLangFlowNaturalLanguage } from "./commands/langflow/natural-language.js";
|
|
26
28
|
|
|
27
29
|
const _here = dirname(fileURLToPath(import.meta.url));
|
|
@@ -186,6 +188,12 @@ export default function Otto(pi: ExtensionAPI): void {
|
|
|
186
188
|
// ── Register /otto prompt-engineer slash command (Phase 5) ──
|
|
187
189
|
registerPromptEngineerCommand(pi);
|
|
188
190
|
|
|
191
|
+
// ── Register /release-notes slash command ──
|
|
192
|
+
registerReleaseNotesCommand(pi);
|
|
193
|
+
|
|
194
|
+
// ── Register /theme slash command ──
|
|
195
|
+
registerThemeCommand(pi);
|
|
196
|
+
|
|
189
197
|
// ── Load and register flow-trigger slash commands ──
|
|
190
198
|
// Fire-and-forget. Pi's command registry is dynamic; late registrations work.
|
|
191
199
|
loadFlowTriggers(FLOW_TRIGGERS_DIR)
|
|
@@ -3,11 +3,53 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import * as fs from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
6
7
|
import * as path from "node:path";
|
|
7
8
|
import { getAgentDir, parseFrontmatter } from "@otto/pi-coding-agent";
|
|
8
9
|
|
|
9
10
|
const PROJECT_AGENT_DIR_CANDIDATES = [".otto/workflow", ".pi"] as const;
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Conventional agent folders used by other AI coding harnesses, mirroring the
|
|
14
|
+
* harness skill-paths support in pi-coding-agent's skills.ts. Each entry maps
|
|
15
|
+
* a harness id (used as a discriminator in logs and the agent's `source`
|
|
16
|
+
* label) to its user-scope and project-scope conventional paths.
|
|
17
|
+
*
|
|
18
|
+
* When a Claude-style skill delegates to a subagent (`subagent_type: foo` /
|
|
19
|
+
* `Task` tool call), OTTO's subagent tool resolves `foo` against everything
|
|
20
|
+
* `discoverAgents` returns. Including these harness paths is what lets a
|
|
21
|
+
* skill imported from `~/.claude/skills` find its companion agent in
|
|
22
|
+
* `~/.claude/agents` rather than failing with "unknown agent."
|
|
23
|
+
*
|
|
24
|
+
* Caveats — independent of discovery — that may affect runtime success:
|
|
25
|
+
* - Claude agents commonly declare `tools: [Bash, Read, ...]` (capitalized).
|
|
26
|
+
* OTTO's tool names tend to be lowercase. If an agent's allowlist is
|
|
27
|
+
* enforced strictly by the harness it was written for, capitalized
|
|
28
|
+
* entries won't match OTTO's tool registry. Agents without a `tools`
|
|
29
|
+
* field (no restriction) work without issue.
|
|
30
|
+
* - Agent body prompts may reference harness-specific features
|
|
31
|
+
* (Claude's `/compact`, MCP server names hardcoded for `claude_desktop`,
|
|
32
|
+
* `~/.claude/...` paths). The agent still runs; those references may
|
|
33
|
+
* just be ineffective.
|
|
34
|
+
*/
|
|
35
|
+
const HARNESS_AGENT_PATHS: Record<
|
|
36
|
+
string,
|
|
37
|
+
{ userDir: string; projectSubdir: string }
|
|
38
|
+
> = {
|
|
39
|
+
claude: {
|
|
40
|
+
userDir: path.join(homedir(), ".claude", "agents"),
|
|
41
|
+
projectSubdir: path.join(".claude", "agents"),
|
|
42
|
+
},
|
|
43
|
+
codex: {
|
|
44
|
+
userDir: path.join(homedir(), ".codex", "agents"),
|
|
45
|
+
projectSubdir: path.join(".codex", "agents"),
|
|
46
|
+
},
|
|
47
|
+
kiro: {
|
|
48
|
+
userDir: path.join(homedir(), ".kiro", "agents"),
|
|
49
|
+
projectSubdir: path.join(".kiro", "agents"),
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
11
53
|
export type AgentScope = "user" | "project" | "both";
|
|
12
54
|
|
|
13
55
|
export interface AgentConfig {
|
|
@@ -19,6 +61,9 @@ export interface AgentConfig {
|
|
|
19
61
|
systemPrompt: string;
|
|
20
62
|
source: "user" | "project";
|
|
21
63
|
filePath: string;
|
|
64
|
+
/** Harness id (e.g. "claude", "codex", "kiro") when discovered under a known
|
|
65
|
+
* harness agent folder. Surfaced as a glanceable chip in `/subagent`. */
|
|
66
|
+
harnessSource?: string;
|
|
22
67
|
}
|
|
23
68
|
|
|
24
69
|
export interface AgentDiscoveryResult {
|
|
@@ -40,27 +85,72 @@ export function parseConflictsWith(value: string | undefined): string[] | undefi
|
|
|
40
85
|
return conflicts.length > 0 ? conflicts : undefined;
|
|
41
86
|
}
|
|
42
87
|
|
|
43
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Maps capitalized tool names used by other AI coding harnesses (Claude, Codex,
|
|
90
|
+
* Kiro) to OTTO's lowercase tool-registry names. Applied at agent-load time so
|
|
91
|
+
* an agent imported from ~/.claude/agents/ with `tools: [Bash, Read]` ends up
|
|
92
|
+
* with `tools: ["bash", "read"]` and actually has access to those tools.
|
|
93
|
+
*
|
|
94
|
+
* Tools without an explicit mapping fall through to .toLowerCase() — that's
|
|
95
|
+
* enough for any name that's already aligned with OTTO's case-insensitive
|
|
96
|
+
* convention. Tool names with no OTTO equivalent (TodoWrite, SlashCommand,
|
|
97
|
+
* NotebookEdit) are kept (lowercased) and silently ignored by the runtime
|
|
98
|
+
* allowlist check — so they don't block the rest of the agent's toolset.
|
|
99
|
+
*
|
|
100
|
+
* MCP tool patterns (mcp__server__name, mcp__server__*) are preserved
|
|
101
|
+
* verbatim — they're already case-sensitive identifiers, and MCP servers
|
|
102
|
+
* register their own tools at runtime.
|
|
103
|
+
*/
|
|
104
|
+
const HARNESS_TOOL_NAME_MAP: Record<string, string> = {
|
|
105
|
+
Bash: "bash",
|
|
106
|
+
Read: "read",
|
|
107
|
+
Write: "write",
|
|
108
|
+
Edit: "edit",
|
|
109
|
+
Glob: "glob",
|
|
110
|
+
Grep: "grep",
|
|
111
|
+
AskUserQuestion: "ask_user_questions",
|
|
112
|
+
Agent: "subagent",
|
|
113
|
+
Task: "subagent",
|
|
114
|
+
WebSearch: "web_search",
|
|
115
|
+
WebFetch: "fetch_page",
|
|
116
|
+
Skill: "skill",
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
function normalizeHarnessToolName(raw: string): string | null {
|
|
120
|
+
const trimmed = raw.trim();
|
|
121
|
+
if (!trimmed) return null;
|
|
122
|
+
if (trimmed.startsWith("mcp__")) return trimmed;
|
|
123
|
+
if (HARNESS_TOOL_NAME_MAP[trimmed]) return HARNESS_TOOL_NAME_MAP[trimmed];
|
|
124
|
+
return trimmed.toLowerCase();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function parseAgentTools(value: string | string[] | undefined): string[] | undefined {
|
|
44
128
|
if (typeof value === "string") {
|
|
45
129
|
const tools = value
|
|
46
130
|
.split(",")
|
|
47
|
-
.map((tool) => tool
|
|
48
|
-
.filter(Boolean);
|
|
49
|
-
|
|
131
|
+
.map((tool) => normalizeHarnessToolName(tool))
|
|
132
|
+
.filter((tool): tool is string => Boolean(tool));
|
|
133
|
+
const deduped = Array.from(new Set(tools));
|
|
134
|
+
return deduped.length > 0 ? deduped : undefined;
|
|
50
135
|
}
|
|
51
136
|
|
|
52
137
|
if (Array.isArray(value)) {
|
|
53
138
|
const tools = value
|
|
54
139
|
.flatMap((tool) => typeof tool === "string" ? tool.split(",") : [])
|
|
55
|
-
.map((tool) => tool
|
|
56
|
-
.filter(Boolean);
|
|
57
|
-
|
|
140
|
+
.map((tool) => normalizeHarnessToolName(tool))
|
|
141
|
+
.filter((tool): tool is string => Boolean(tool));
|
|
142
|
+
const deduped = Array.from(new Set(tools));
|
|
143
|
+
return deduped.length > 0 ? deduped : undefined;
|
|
58
144
|
}
|
|
59
145
|
|
|
60
146
|
return undefined;
|
|
61
147
|
}
|
|
62
148
|
|
|
63
|
-
function loadAgentsFromDir(
|
|
149
|
+
function loadAgentsFromDir(
|
|
150
|
+
dir: string,
|
|
151
|
+
source: "user" | "project",
|
|
152
|
+
harnessSource?: string,
|
|
153
|
+
): AgentConfig[] {
|
|
64
154
|
const agents: AgentConfig[] = [];
|
|
65
155
|
|
|
66
156
|
if (!fs.existsSync(dir)) {
|
|
@@ -104,6 +194,7 @@ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig
|
|
|
104
194
|
systemPrompt: body,
|
|
105
195
|
source,
|
|
106
196
|
filePath,
|
|
197
|
+
harnessSource,
|
|
107
198
|
});
|
|
108
199
|
}
|
|
109
200
|
|
|
@@ -134,22 +225,89 @@ function findNearestProjectAgentsDir(cwd: string): string | null {
|
|
|
134
225
|
}
|
|
135
226
|
}
|
|
136
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Walk up from cwd looking for harness project agent dirs (e.g. `.claude/agents`).
|
|
230
|
+
* Returns ALL matching dirs found at the first ancestor that has at least one,
|
|
231
|
+
* so a project root with both `.claude/agents` and `.codex/agents` surfaces
|
|
232
|
+
* both. Stops at the first ancestor with any matches to avoid pulling in
|
|
233
|
+
* stale agents from outer directories.
|
|
234
|
+
*/
|
|
235
|
+
function findNearestHarnessProjectAgentsDirs(cwd: string): string[] {
|
|
236
|
+
let currentDir = cwd;
|
|
237
|
+
while (true) {
|
|
238
|
+
const matches: string[] = [];
|
|
239
|
+
for (const { projectSubdir } of Object.values(HARNESS_AGENT_PATHS)) {
|
|
240
|
+
const candidate = path.join(currentDir, projectSubdir);
|
|
241
|
+
if (isDirectory(candidate)) matches.push(candidate);
|
|
242
|
+
}
|
|
243
|
+
if (matches.length > 0) return matches;
|
|
244
|
+
|
|
245
|
+
const parentDir = path.dirname(currentDir);
|
|
246
|
+
if (parentDir === currentDir) return [];
|
|
247
|
+
currentDir = parentDir;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
137
251
|
export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
|
|
138
252
|
const userDir = path.join(getAgentDir(), "agents");
|
|
139
253
|
const projectAgentsDir = findNearestProjectAgentsDir(cwd);
|
|
254
|
+
const harnessProjectAgentsDirs = findNearestHarnessProjectAgentsDirs(cwd);
|
|
140
255
|
|
|
141
256
|
const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user");
|
|
142
257
|
const projectAgents = scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project");
|
|
143
258
|
|
|
259
|
+
// Harness agents — user scope: ~/.claude/agents, ~/.codex/agents, ~/.kiro/agents.
|
|
260
|
+
// Harness agents — project scope: nearest .claude/agents etc. up the cwd tree.
|
|
261
|
+
// Both go into the same scope buckets as OTTO's own agents; collisions are
|
|
262
|
+
// resolved by the existing Map-write-wins order below (project wins user).
|
|
263
|
+
const harnessUserAgents: AgentConfig[] = [];
|
|
264
|
+
if (scope !== "project") {
|
|
265
|
+
for (const [harnessId, { userDir: harnessUserDir }] of Object.entries(HARNESS_AGENT_PATHS)) {
|
|
266
|
+
harnessUserAgents.push(...loadAgentsFromDir(harnessUserDir, "user", harnessId));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const harnessProjectAgents: AgentConfig[] = [];
|
|
270
|
+
if (scope !== "user") {
|
|
271
|
+
// Map each discovered project dir back to its harness id by suffix match
|
|
272
|
+
// (e.g. ".../.claude/agents" → "claude"). The id surfaces in /subagent as
|
|
273
|
+
// a glanceable chip.
|
|
274
|
+
for (const dir of harnessProjectAgentsDirs) {
|
|
275
|
+
let harnessId: string | undefined;
|
|
276
|
+
for (const [id, { projectSubdir }] of Object.entries(HARNESS_AGENT_PATHS)) {
|
|
277
|
+
if (dir.endsWith(projectSubdir)) {
|
|
278
|
+
harnessId = id;
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
harnessProjectAgents.push(...loadAgentsFromDir(dir, "project", harnessId));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
144
286
|
const agentMap = new Map<string, AgentConfig>();
|
|
145
287
|
|
|
288
|
+
// Order: OTTO user → harness user → OTTO project → harness project.
|
|
289
|
+
// Later writes win on name collision, so OTTO's own agents take precedence
|
|
290
|
+
// at each scope, and project always overrides user. Same precedence as the
|
|
291
|
+
// existing logic, just with harness sources slotted in beneath.
|
|
146
292
|
if (scope === "both") {
|
|
147
293
|
for (const agent of userAgents) agentMap.set(agent.name, agent);
|
|
294
|
+
for (const agent of harnessUserAgents) {
|
|
295
|
+
if (!agentMap.has(agent.name)) agentMap.set(agent.name, agent);
|
|
296
|
+
}
|
|
148
297
|
for (const agent of projectAgents) agentMap.set(agent.name, agent);
|
|
298
|
+
for (const agent of harnessProjectAgents) {
|
|
299
|
+
if (!agentMap.has(agent.name)) agentMap.set(agent.name, agent);
|
|
300
|
+
}
|
|
149
301
|
} else if (scope === "user") {
|
|
150
302
|
for (const agent of userAgents) agentMap.set(agent.name, agent);
|
|
303
|
+
for (const agent of harnessUserAgents) {
|
|
304
|
+
if (!agentMap.has(agent.name)) agentMap.set(agent.name, agent);
|
|
305
|
+
}
|
|
151
306
|
} else {
|
|
152
307
|
for (const agent of projectAgents) agentMap.set(agent.name, agent);
|
|
308
|
+
for (const agent of harnessProjectAgents) {
|
|
309
|
+
if (!agentMap.has(agent.name)) agentMap.set(agent.name, agent);
|
|
310
|
+
}
|
|
153
311
|
}
|
|
154
312
|
|
|
155
313
|
return { agents: Array.from(agentMap.values()), projectAgentsDir };
|
|
@@ -20,12 +20,13 @@ import * as path from "node:path";
|
|
|
20
20
|
import type { AgentToolResult } from "@otto/pi-agent-core";
|
|
21
21
|
import type { Message } from "@otto/pi-ai";
|
|
22
22
|
import { StringEnum } from "@otto/pi-ai";
|
|
23
|
-
import { type ExtensionAPI, getMarkdownTheme } from "@otto/pi-coding-agent";
|
|
23
|
+
import { type ExtensionAPI, getMarkdownTheme, getSelectListTheme } from "@otto/pi-coding-agent";
|
|
24
24
|
import { Container, Markdown, Spacer, Text } from "@otto/pi-tui";
|
|
25
25
|
import { Type } from "@sinclair/typebox";
|
|
26
26
|
import { formatTokenCount } from "../shared/mod.js";
|
|
27
27
|
import { getCurrentPhase } from "../shared/phase-state.js";
|
|
28
28
|
import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js";
|
|
29
|
+
import { buildSkillToolStubResponse } from "./skill-tool-stub.js";
|
|
29
30
|
import {
|
|
30
31
|
type IsolationEnvironment,
|
|
31
32
|
type IsolationMode,
|
|
@@ -798,7 +799,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
798
799
|
await stopLiveSubagents();
|
|
799
800
|
});
|
|
800
801
|
|
|
801
|
-
// /subagent command - list available agents
|
|
802
|
+
// /subagent command - list available agents.
|
|
803
|
+
// Renders each row with embedded ANSI styling so the message bypasses the
|
|
804
|
+
// notify renderer's "dim everything" fallback (see hasAnsiStyling check in
|
|
805
|
+
// pi-coding-agent's renderExtensionNotifyInChat). Agent name renders in the
|
|
806
|
+
// terminal's default foreground (white-ish), source/model in the muted
|
|
807
|
+
// description tint, and the harness origin — when detected — as an accent
|
|
808
|
+
// chip matching the /skills autocomplete style.
|
|
802
809
|
pi.registerCommand("subagent", {
|
|
803
810
|
description: "List available subagents",
|
|
804
811
|
handler: async (_args, ctx) => {
|
|
@@ -807,10 +814,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
807
814
|
ctx.ui.notify("No agents found. Add .md files to ~/.otto/agent/agents/ or .otto/workflow/agents/", "warning");
|
|
808
815
|
return;
|
|
809
816
|
}
|
|
810
|
-
const
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
817
|
+
const slt = getSelectListTheme();
|
|
818
|
+
const tag = slt.tag ?? slt.selectedText;
|
|
819
|
+
const dim = slt.description;
|
|
820
|
+
const lines = discovery.agents.map((a) => {
|
|
821
|
+
const chip = a.harnessSource ? `${tag(`[${a.harnessSource}]`)} ` : "";
|
|
822
|
+
const meta = dim(`[${a.source}]${a.model ? ` (${a.model})` : ""}`);
|
|
823
|
+
const desc = dim(`: ${a.description}`);
|
|
824
|
+
return ` ${chip}${a.name} ${meta}${desc}`;
|
|
825
|
+
});
|
|
826
|
+
const header = dim(`Available agents (${discovery.agents.length}):`);
|
|
827
|
+
ctx.ui.notify(`${header}\n${lines.join("\n")}`, "info");
|
|
814
828
|
},
|
|
815
829
|
});
|
|
816
830
|
|
|
@@ -1935,4 +1949,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
1935
1949
|
return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
|
|
1936
1950
|
},
|
|
1937
1951
|
});
|
|
1952
|
+
|
|
1953
|
+
pi.registerTool({
|
|
1954
|
+
name: "skill",
|
|
1955
|
+
label: "Skill (stub)",
|
|
1956
|
+
description: [
|
|
1957
|
+
"Stub for the Claude-style `Skill` tool used by some imported skills.",
|
|
1958
|
+
"OTTO does not support invoking skills as a tool — users invoke skills via /skill:<name> from the chat input.",
|
|
1959
|
+
"Returns a clear message redirecting the model to use the skill content inline.",
|
|
1960
|
+
].join(" "),
|
|
1961
|
+
parameters: Type.Object({
|
|
1962
|
+
name: Type.Optional(Type.String({ description: "Name of the skill the model wanted to invoke." })),
|
|
1963
|
+
}),
|
|
1964
|
+
async execute(_toolCallId, params) {
|
|
1965
|
+
return {
|
|
1966
|
+
content: [
|
|
1967
|
+
{
|
|
1968
|
+
type: "text" as const,
|
|
1969
|
+
text: buildSkillToolStubResponse({
|
|
1970
|
+
name: typeof params.name === "string" ? params.name : undefined,
|
|
1971
|
+
}),
|
|
1972
|
+
},
|
|
1973
|
+
],
|
|
1974
|
+
details: {},
|
|
1975
|
+
};
|
|
1976
|
+
},
|
|
1977
|
+
});
|
|
1938
1978
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stub for the `skill` tool that Claude-style skills sometimes invoke
|
|
3
|
+
* (`Skill(name="foo")` in the skill body) to chain into another skill.
|
|
4
|
+
*
|
|
5
|
+
* OTTO does not support model-invoked skill execution — skills are user-
|
|
6
|
+
* initiated via `/skill:<name>` from the chat input. Rather than have those
|
|
7
|
+
* tool calls fail with "no such tool," we register a stub that returns a
|
|
8
|
+
* clear, actionable message. The model sees the response and can either
|
|
9
|
+
* fall back to acting on the skill content itself or surface the message
|
|
10
|
+
* to the user.
|
|
11
|
+
*
|
|
12
|
+
* Registered in src/resources/extensions/subagent/index.ts.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface SkillToolStubInput {
|
|
16
|
+
name?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function buildSkillToolStubResponse(input: SkillToolStubInput): string {
|
|
20
|
+
const skillRef = typeof input.name === "string" && input.name.trim().length > 0
|
|
21
|
+
? `/skill:${input.name.trim()}`
|
|
22
|
+
: "the desired /skill:<name>";
|
|
23
|
+
return [
|
|
24
|
+
"OTTO does not support invoking skills as a tool from inside an agent turn.",
|
|
25
|
+
`To use this skill, ask the user to run \`${skillRef}\` from the chat input — that's how OTTO surfaces skills.`,
|
|
26
|
+
"If you have access to the skill's body (it was loaded as a system prompt), follow its instructions inline instead.",
|
|
27
|
+
].join("\n\n");
|
|
28
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { parseAgentTools } from "../agents.js";
|
|
4
|
+
|
|
5
|
+
describe("parseAgentTools", () => {
|
|
6
|
+
it("lowercases standard Claude tool names", () => {
|
|
7
|
+
const result = parseAgentTools("Bash, Read, Write, Edit, Glob, Grep");
|
|
8
|
+
assert.deepEqual(result, ["bash", "read", "write", "edit", "glob", "grep"]);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("maps Claude-specific tool names to OTTO equivalents (with dedup)", () => {
|
|
12
|
+
const result = parseAgentTools("AskUserQuestion, Agent, Task, WebSearch, WebFetch, Skill");
|
|
13
|
+
assert.deepEqual(result, [
|
|
14
|
+
"ask_user_questions",
|
|
15
|
+
"subagent",
|
|
16
|
+
"web_search",
|
|
17
|
+
"fetch_page",
|
|
18
|
+
"skill",
|
|
19
|
+
]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("keeps unknown tools as lowercase (runtime silently drops them)", () => {
|
|
23
|
+
const result = parseAgentTools("Bash, TodoWrite, SlashCommand, NotebookEdit");
|
|
24
|
+
assert.deepEqual(result, ["bash", "todowrite", "slashcommand", "notebookedit"]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("preserves MCP tool names verbatim (already lowercase, server-scoped)", () => {
|
|
28
|
+
const result = parseAgentTools("Bash, mcp__context7__*, mcp__exa__*");
|
|
29
|
+
assert.deepEqual(result, ["bash", "mcp__context7__*", "mcp__exa__*"]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("accepts an array as well as a comma-string", () => {
|
|
33
|
+
const result = parseAgentTools(["Bash", "Read", "Glob"]);
|
|
34
|
+
assert.deepEqual(result, ["bash", "read", "glob"]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("returns undefined for empty input", () => {
|
|
38
|
+
assert.equal(parseAgentTools(undefined), undefined);
|
|
39
|
+
assert.equal(parseAgentTools(""), undefined);
|
|
40
|
+
assert.equal(parseAgentTools([]), undefined);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("trims whitespace and filters blank entries", () => {
|
|
44
|
+
const result = parseAgentTools(" Bash , , Read , ");
|
|
45
|
+
assert.deepEqual(result, ["bash", "read"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("dedupes after normalization (Task and Agent both map to subagent)", () => {
|
|
49
|
+
const result = parseAgentTools("Task, Agent, subagent");
|
|
50
|
+
assert.deepEqual(result, ["subagent"]);
|
|
51
|
+
});
|
|
52
|
+
});
|