@teammates/cli 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -29
- package/dist/adapter.d.ts +7 -1
- package/dist/adapter.js +28 -3
- package/dist/adapter.test.js +10 -13
- package/dist/adapters/cli-proxy.d.ts +3 -0
- package/dist/adapters/cli-proxy.js +133 -108
- package/dist/cli-utils.d.ts +29 -0
- package/dist/cli-utils.js +74 -0
- package/dist/cli-utils.test.js +185 -1
- package/dist/cli.js +401 -47
- package/dist/compact.d.ts +29 -0
- package/dist/compact.js +125 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/log-parser.d.ts +53 -0
- package/dist/log-parser.js +228 -0
- package/dist/log-parser.test.d.ts +1 -0
- package/dist/log-parser.test.js +113 -0
- package/dist/orchestrator.d.ts +2 -0
- package/dist/orchestrator.js +4 -0
- package/dist/types.d.ts +18 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -7,18 +7,17 @@ Agent-agnostic CLI orchestrator for teammates. Routes tasks to teammates, manage
|
|
|
7
7
|
## Quick Start
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
cd cli
|
|
10
|
+
cd packages/cli
|
|
11
11
|
npm install
|
|
12
12
|
npm run build
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
Then launch a session
|
|
15
|
+
Then launch a session:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
teammates
|
|
19
|
-
teammates
|
|
20
|
-
teammates
|
|
21
|
-
teammates echo # Test adapter (no external agent)
|
|
18
|
+
teammates # Uses default adapter (claude)
|
|
19
|
+
teammates --model sonnet # Override the agent model
|
|
20
|
+
teammates echo # Use the test adapter (no external agent)
|
|
22
21
|
```
|
|
23
22
|
|
|
24
23
|
The CLI auto-discovers your `.teammates/` directory by walking up from the current working directory.
|
|
@@ -26,7 +25,7 @@ The CLI auto-discovers your `.teammates/` directory by walking up from the curre
|
|
|
26
25
|
## Usage
|
|
27
26
|
|
|
28
27
|
```
|
|
29
|
-
teammates
|
|
28
|
+
teammates [options] [-- agent-flags...]
|
|
30
29
|
```
|
|
31
30
|
|
|
32
31
|
### Options
|
|
@@ -37,7 +36,7 @@ teammates <agent> [options] [-- agent-flags...]
|
|
|
37
36
|
| `--dir <path>` | Override `.teammates/` directory location |
|
|
38
37
|
| `--help` | Show usage information |
|
|
39
38
|
|
|
40
|
-
Any arguments after
|
|
39
|
+
Any arguments after `--` are passed through to the underlying agent CLI.
|
|
41
40
|
|
|
42
41
|
## In-Session Commands
|
|
43
42
|
|
|
@@ -55,16 +54,18 @@ Once inside the REPL, you can interact with teammates using `@mentions`, `/comma
|
|
|
55
54
|
|
|
56
55
|
| Command | Aliases | Description |
|
|
57
56
|
|---|---|---|
|
|
58
|
-
| `/status` | `/s`, `/queue
|
|
59
|
-
| `/
|
|
60
|
-
| `/
|
|
61
|
-
| `/
|
|
62
|
-
| `/init` |
|
|
63
|
-
| `/install <service>` | | Install a teammates service (e.g. `recall`) |
|
|
57
|
+
| `/status` | `/s`, `/queue` | Show teammates, active tasks, and queue |
|
|
58
|
+
| `/debug [teammate] [focus]` | | Analyze the last agent task with optional focus text |
|
|
59
|
+
| `/cancel [n]` | | Cancel a queued task by number |
|
|
60
|
+
| `/init` | `/onboard`, `/setup` | Run onboarding to set up teammates |
|
|
61
|
+
| `/init pick` | | Pick teammates from persona templates (in-TUI) |
|
|
64
62
|
| `/compact [teammate]` | | Compact daily logs into weekly/monthly summaries |
|
|
65
63
|
| `/retro [teammate]` | | Run a structured self-retrospective for a teammate |
|
|
64
|
+
| `/user [change]` | | View or update USER.md |
|
|
65
|
+
| `/btw [question]` | | Ask a quick side question without interrupting |
|
|
66
66
|
| `/copy` | `/cp` | Copy the last response to clipboard |
|
|
67
67
|
| `/theme` | | Show current theme colors |
|
|
68
|
+
| `/configure [service]` | `/config` | Configure external services (e.g. GitHub) |
|
|
68
69
|
| `/clear` | `/cls`, `/reset` | Clear history and reset the session |
|
|
69
70
|
| `/help` | `/h`, `/?` | Show available commands |
|
|
70
71
|
| `/exit` | `/q`, `/quit` | Exit the session |
|
|
@@ -121,21 +122,24 @@ The CLI uses a generic adapter interface to support any coding agent. Each adapt
|
|
|
121
122
|
|
|
122
123
|
| Preset | Command | Notes |
|
|
123
124
|
|---|---|---|
|
|
124
|
-
| `claude` | `claude -p --verbose` | Requires `claude` on PATH |
|
|
125
|
+
| `claude` | `claude -p --verbose` | Default adapter. Requires `claude` on PATH |
|
|
125
126
|
| `codex` | `codex exec` | Requires `codex` on PATH |
|
|
126
127
|
| `aider` | `aider --message-file` | Requires `aider` on PATH |
|
|
128
|
+
| `copilot` | GitHub Copilot SDK | Requires `@anthropic-ai/copilot-sdk` |
|
|
127
129
|
| `echo` | (in-process) | Test adapter — echoes prompts, no external agent |
|
|
128
130
|
|
|
129
131
|
### How Adapters Work
|
|
130
132
|
|
|
131
|
-
1.
|
|
132
|
-
2.
|
|
133
|
-
3. The prompt
|
|
134
|
-
4. The
|
|
135
|
-
5.
|
|
136
|
-
6.
|
|
137
|
-
7.
|
|
138
|
-
8.
|
|
133
|
+
1. **Auto-compaction** — If daily logs exceed the 24k token budget, oldest weeks are compacted into weekly summaries
|
|
134
|
+
2. **Two-pass recall** — Pass 1: keyword extraction → query variation generation → frontmatter catalog matching → multi-query fusion with dedup. Pass 2: agents can search mid-task via `teammates-recall search`
|
|
135
|
+
3. The orchestrator builds a full prompt with token-budgeted sections (identity → wisdom → recall → daily logs → roster → services → date/time/environment → user profile → task → output protocol → session/memory instructions)
|
|
136
|
+
4. The prompt is written to a temp file
|
|
137
|
+
5. The agent CLI is spawned with the prompt
|
|
138
|
+
6. stdout/stderr are captured for result parsing
|
|
139
|
+
7. **Empty response defense** — If the agent returns no text: retry with raw mode (no prompt wrapping), then minimal prompt, then synthetic fallback from metadata
|
|
140
|
+
8. The output is parsed for embedded handoff blocks (with natural-language fallback)
|
|
141
|
+
9. The recall index is synced to pick up any files the agent created/modified
|
|
142
|
+
10. Temp files are cleaned up
|
|
139
143
|
|
|
140
144
|
### Writing a Custom Adapter
|
|
141
145
|
|
|
@@ -169,13 +173,15 @@ Or add a preset to `cli-proxy.ts` for any CLI agent that accepts a prompt and ru
|
|
|
169
173
|
The CLI startup runs in two phases:
|
|
170
174
|
|
|
171
175
|
**Phase 1 — Pre-TUI (console I/O)**
|
|
172
|
-
1. **User profile setup** —
|
|
173
|
-
2. **Team onboarding** (if
|
|
176
|
+
1. **User profile setup** — If `USER.md` is missing, offers three paths: **GitHub** (imports name/alias via `gh api user`), **Manual** (prompts for alias, name, role, experience, preferences), or **Skip**. Creates `USER.md` and a user avatar folder at `.teammates/<alias>/` with `SOUL.md` (`**Type:** human`). Auto-detects the user's timezone.
|
|
177
|
+
2. **Team onboarding** (if `.teammates/` was just created) — Offers **Pick teammates** (persona templates), **Auto-generate** (agent-driven), **Import** (from another project), **Solo mode**, or **Exit**.
|
|
174
178
|
3. **Orchestrator init** — Loads existing teammates from `.teammates/`, registers user avatar with `type: "human"` and `presence: "online"`.
|
|
179
|
+
4. **Startup maintenance** — Runs auto-compaction and recall sync for all teammates (silent — progress bar only, no feed output unless actual work was done).
|
|
175
180
|
|
|
176
181
|
**Phase 2 — TUI (Consolonia)**
|
|
177
|
-
|
|
178
|
-
|
|
182
|
+
5. Animated startup banner with presence-colored roster
|
|
183
|
+
6. REPL starts — routing, slash commands, handoff approval
|
|
184
|
+
7. System tasks (compaction, summarization, wisdom distillation) run in the background without blocking user tasks
|
|
179
185
|
|
|
180
186
|
All user interaction during Phase 1 uses plain console I/O (readline + ora spinners), avoiding mouse tracking issues that would occur inside the TUI.
|
|
181
187
|
|
|
@@ -191,7 +197,7 @@ The CLI ships with 15 built-in persona templates that serve as starting points w
|
|
|
191
197
|
| **2 — Specialist** | Security (`shield`), Designer (`canvas`), Tech Writer (`quill`), Data Engineer (`forge`), SRE (`watchtower`), Architect (`blueprint`) |
|
|
192
198
|
| **3 — Niche** | Frontend (`pixel`), Backend (`engine`), Mobile (`orbit`), ML/AI (`neuron`), Performance (`tempo`) |
|
|
193
199
|
|
|
194
|
-
During onboarding, the CLI uses these personas to scaffold teammates. The user picks roles, optionally renames them, and the persona's SOUL.md body becomes the starting template — project-specific sections (commands, file patterns, technologies) are filled in by the onboarding agent.
|
|
200
|
+
During onboarding, the CLI uses these personas to scaffold teammates. Use **Pick teammates** during initial onboarding or `/init pick` in-session to choose from the list. The user picks roles, optionally renames them, and the persona's SOUL.md body becomes the starting template — project-specific sections (commands, file patterns, technologies) are filled in by the onboarding agent.
|
|
195
201
|
|
|
196
202
|
## Architecture
|
|
197
203
|
|
|
@@ -201,12 +207,18 @@ cli/src/
|
|
|
201
207
|
orchestrator.ts # Task routing, session management, presence tracking
|
|
202
208
|
adapter.ts # AgentAdapter interface, prompt builder, handoff formatting
|
|
203
209
|
registry.ts # Discovers teammates from .teammates/, loads SOUL.md + memory, type detection
|
|
210
|
+
compact.ts # Episodic memory compaction (daily→weekly→monthly) + auto-compaction
|
|
211
|
+
banner.ts # Animated startup banner with presence roster and segmented footer
|
|
204
212
|
personas.ts # Persona loader — reads and parses bundled persona templates
|
|
213
|
+
theme.ts # Theme configuration, color palette, styled text shortcuts
|
|
214
|
+
cli-args.ts # CLI argument parsing, .teammates/ directory discovery
|
|
215
|
+
cli-utils.ts # Pure utility functions (relativeTime, wrapLine, findAtMention, etc.)
|
|
205
216
|
types.ts # Core types (TeammateConfig, TaskResult, HandoffEnvelope, TeammateType, PresenceState)
|
|
206
217
|
onboard.ts # Template copying, team import, onboarding/adaptation prompts
|
|
207
218
|
dropdown.ts # Terminal dropdown/wordwheel widget
|
|
208
219
|
adapters/
|
|
209
|
-
cli-proxy.ts # Generic subprocess adapter with agent presets
|
|
220
|
+
cli-proxy.ts # Generic subprocess adapter with agent presets (claude, codex, aider)
|
|
221
|
+
copilot.ts # GitHub Copilot adapter
|
|
210
222
|
echo.ts # Test adapter (no-op)
|
|
211
223
|
cli/personas/ # 15 persona template files (pm.md, swe.md, devops.md, etc.)
|
|
212
224
|
```
|
|
@@ -246,6 +258,11 @@ Tests use [Vitest](https://vitest.dev/) and cover the core modules:
|
|
|
246
258
|
| `src/adapter.test.ts` | `buildTeammatePrompt`, `formatHandoffContext` |
|
|
247
259
|
| `src/orchestrator.test.ts` | Task routing, assignment, reset |
|
|
248
260
|
| `src/registry.test.ts` | Teammate discovery, SOUL.md parsing (role, ownership), daily logs |
|
|
261
|
+
| `src/compact.test.ts` | Daily→weekly compaction, auto-compaction, partial merge |
|
|
262
|
+
| `src/personas.test.ts` | Persona loading and scaffolding |
|
|
263
|
+
| `src/theme.test.ts` | Theme configuration |
|
|
264
|
+
| `src/cli-args.test.ts` | Argument parsing, directory discovery |
|
|
265
|
+
| `src/cli-utils.test.ts` | Utility functions |
|
|
249
266
|
| `src/adapters/echo.test.ts` | Echo adapter session and task execution |
|
|
250
267
|
|
|
251
268
|
## Dependencies
|
package/dist/adapter.d.ts
CHANGED
|
@@ -29,6 +29,12 @@ export interface AgentAdapter {
|
|
|
29
29
|
resumeSession?(teammate: TeammateConfig, sessionId: string): Promise<string>;
|
|
30
30
|
/** Get the session file path for a teammate (if session is active). */
|
|
31
31
|
getSessionFile?(teammateName: string): string | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Kill a running agent and return its partial output.
|
|
34
|
+
* Used by the interrupt-and-resume system to capture in-progress work.
|
|
35
|
+
* Returns null if no agent is running for this teammate.
|
|
36
|
+
*/
|
|
37
|
+
killAgent?(teammate: string): Promise<import("./adapters/cli-proxy.js").SpawnResult | null>;
|
|
32
38
|
/** Clean up a session. */
|
|
33
39
|
destroySession?(sessionId: string): Promise<void>;
|
|
34
40
|
/**
|
|
@@ -82,7 +88,7 @@ export declare function syncRecallIndex(teammatesDir: string, teammate?: string)
|
|
|
82
88
|
* - Last recall entry can push total up to budget + RECALL_OVERFLOW (4k grace)
|
|
83
89
|
* - Weekly summaries are excluded (already indexed by recall)
|
|
84
90
|
*/
|
|
85
|
-
export declare const DAILY_LOG_BUDGET_TOKENS =
|
|
91
|
+
export declare const DAILY_LOG_BUDGET_TOKENS = 12000;
|
|
86
92
|
/**
|
|
87
93
|
* Build the full prompt for a teammate session.
|
|
88
94
|
* Includes identity, memory, roster, output protocol, and the task.
|
package/dist/adapter.js
CHANGED
|
@@ -67,7 +67,7 @@ const CHARS_PER_TOKEN = 4;
|
|
|
67
67
|
* - Last recall entry can push total up to budget + RECALL_OVERFLOW (4k grace)
|
|
68
68
|
* - Weekly summaries are excluded (already indexed by recall)
|
|
69
69
|
*/
|
|
70
|
-
export const DAILY_LOG_BUDGET_TOKENS =
|
|
70
|
+
export const DAILY_LOG_BUDGET_TOKENS = 12_000;
|
|
71
71
|
const RECALL_MIN_BUDGET_TOKENS = 8_000;
|
|
72
72
|
const RECALL_OVERFLOW_TOKENS = 4_000;
|
|
73
73
|
/** Estimate tokens from character count. */
|
|
@@ -171,7 +171,14 @@ export function buildTeammatePrompt(teammate, taskPrompt, options) {
|
|
|
171
171
|
// ── Task-adjacent context (close to task for maximum relevance) ───
|
|
172
172
|
// <RECALL_RESULTS> — budget-allocated, adjacent to task
|
|
173
173
|
const recallBudget = Math.max(RECALL_MIN_BUDGET_TOKENS, RECALL_MIN_BUDGET_TOKENS + dailyBudget);
|
|
174
|
-
|
|
174
|
+
// Filter recall results that duplicate daily log content already in the prompt
|
|
175
|
+
const dailyLogDates = new Set(teammate.dailyLogs.slice(0, 7).map((log) => log.date));
|
|
176
|
+
const recallResults = (options?.recallResults ?? []).filter((r) => {
|
|
177
|
+
const dailyMatch = r.uri.match(/memory\/(\d{4}-\d{2}-\d{2})\.md/);
|
|
178
|
+
if (dailyMatch && dailyLogDates.has(dailyMatch[1]))
|
|
179
|
+
return false;
|
|
180
|
+
return true;
|
|
181
|
+
});
|
|
175
182
|
if (recallResults.length > 0) {
|
|
176
183
|
const lines = [
|
|
177
184
|
"<RECALL_RESULTS>",
|
|
@@ -207,6 +214,8 @@ export function buildTeammatePrompt(teammate, taskPrompt, options) {
|
|
|
207
214
|
const instrLines = [
|
|
208
215
|
"<INSTRUCTIONS>",
|
|
209
216
|
"",
|
|
217
|
+
"**Your FIRST priority is answering the user's request in `<TASK>`. Session updates, memory writes, and continuity housekeeping are SECONDARY — do them AFTER producing your text response, not before.**",
|
|
218
|
+
"",
|
|
210
219
|
"### Output Protocol (CRITICAL)",
|
|
211
220
|
"",
|
|
212
221
|
"**Your #1 job is to produce a visible text response.** Session updates and memory writes are secondary — they support continuity but are not the deliverable. The user sees ONLY your text output. If you update files but return no text, the user sees an empty message and your work is invisible.",
|
|
@@ -246,6 +255,10 @@ export function buildTeammatePrompt(teammate, taskPrompt, options) {
|
|
|
246
255
|
if (options?.sessionFile) {
|
|
247
256
|
instrLines.push("", "### Session State", "", `Your session file is at: \`${options.sessionFile}\``, "", "**After completing the task**, append a brief entry to this file with:", "- What you did", "- Key decisions made", "- Files changed", "- Anything the next task should know", "", "This is how you maintain continuity across tasks. Always read it, always update it.");
|
|
248
257
|
}
|
|
258
|
+
// Cross-folder write boundary (AI teammates only)
|
|
259
|
+
if (teammate.type === "ai") {
|
|
260
|
+
instrLines.push("", "### Folder Boundaries (ENFORCED)", "", `**You MUST NOT create, edit, or delete files inside another teammate's folder (\`.teammates/<other>/\`).** Your folder is \`.teammates/${teammate.name}/\` — you may only write inside it. Shared folders (\`.teammates/_*/\`) and ephemeral folders (\`.teammates/.*/\`) are also writable.`, "", "If your task requires changes to another teammate's files, you MUST hand off that work using the handoff block format above. Violation of this rule will cause your changes to be flagged and potentially reverted.");
|
|
261
|
+
}
|
|
249
262
|
// Memory updates
|
|
250
263
|
instrLines.push("", "### Memory Updates", "", "**After completing the task**, update your memory files:", "", `1. **Daily log** — Read \`.teammates/${teammate.name}/memory/${today}.md\` first (it may have entries from earlier tasks today), then write it back with your entry added. Create the file if it doesn't exist.`, " - What you did", " - Key decisions made", " - Files changed", " - Anything the next task should know", "", `2. **Typed memories** — If you learned something durable (a decision, pattern, feedback, or reference), create a typed memory file at \`.teammates/${teammate.name}/memory/<type>_<topic>.md\` with frontmatter (\`name\`, \`description\`, \`type\`). Update existing memory files if the topic already has one.`, "", "3. **WISDOM.md** — Do not edit directly. Wisdom entries are distilled from typed memories during compaction.", "", "These files are your persistent memory. Without them, your next session starts from scratch.");
|
|
251
264
|
// Section Reinforcement — back-references from high-attention bottom edge to each section tag
|
|
@@ -273,7 +286,19 @@ export function buildTeammatePrompt(teammate, taskPrompt, options) {
|
|
|
273
286
|
if (options?.handoffContext) {
|
|
274
287
|
instrLines.push("- When `<HANDOFF_CONTEXT>` is present, address its requirements and open questions directly.");
|
|
275
288
|
}
|
|
276
|
-
instrLines.push("- Your response must answer `<TASK>` — everything else is supporting context."
|
|
289
|
+
instrLines.push("- Your response must answer `<TASK>` — everything else is supporting context.");
|
|
290
|
+
// Echo the user's actual request at the bottom edge for maximum attention.
|
|
291
|
+
// The orchestrator prepends conversation history before a "---" separator,
|
|
292
|
+
// so the user's raw message is the last segment after splitting on "---".
|
|
293
|
+
const segments = taskPrompt.split(/\n\n---\n\n/);
|
|
294
|
+
const userRequest = segments[segments.length - 1].trim();
|
|
295
|
+
if (userRequest.length > 0 && userRequest.length < 500) {
|
|
296
|
+
instrLines.push("", `**THE USER'S REQUEST:** ${userRequest}`);
|
|
297
|
+
}
|
|
298
|
+
else if (userRequest.length >= 500) {
|
|
299
|
+
instrLines.push("", "**IMPORTANT: The user's actual request is at the end of `<TASK>`. Read and address it before doing anything else.**");
|
|
300
|
+
}
|
|
301
|
+
instrLines.push("", "**REMINDER: You MUST end your turn with visible text output. A turn with only file edits and no text is a failed turn.**");
|
|
277
302
|
parts.push(instrLines.join("\n"));
|
|
278
303
|
return parts.join("\n");
|
|
279
304
|
}
|
package/dist/adapter.test.js
CHANGED
|
@@ -101,27 +101,24 @@ describe("buildTeammatePrompt", () => {
|
|
|
101
101
|
expect(prompt).toContain("### Session State");
|
|
102
102
|
expect(prompt).toContain("/tmp/beacon-session.md");
|
|
103
103
|
});
|
|
104
|
-
it("drops daily logs that exceed the
|
|
105
|
-
// Each log is ~50k chars = ~12.5k tokens.
|
|
104
|
+
it("drops daily logs that exceed the 12k daily budget", () => {
|
|
105
|
+
// Each log is ~50k chars = ~12.5k tokens. First one exceeds 12k budget, dropped.
|
|
106
106
|
const bigContent = "D".repeat(50_000);
|
|
107
107
|
const config = makeConfig({
|
|
108
108
|
dailyLogs: [
|
|
109
109
|
{ date: "2026-03-18", content: "Today's log — never trimmed" },
|
|
110
|
-
{ date: "2026-03-17", content: bigContent }, // day 2 —
|
|
111
|
-
{ date: "2026-03-16", content: bigContent }, // day 3 — exceeds 24k, dropped
|
|
110
|
+
{ date: "2026-03-17", content: bigContent }, // day 2 — exceeds 12k, dropped
|
|
112
111
|
],
|
|
113
112
|
});
|
|
114
113
|
const prompt = buildTeammatePrompt(config, "task");
|
|
115
114
|
// Today's log is always fully present (never trimmed)
|
|
116
115
|
expect(prompt).toContain("Today's log — never trimmed");
|
|
117
|
-
// Day 2
|
|
118
|
-
expect(prompt).toContain("2026-03-17");
|
|
119
|
-
// Day 3 doesn't fit (12.5k + 12.5k > 24k)
|
|
120
|
-
expect(prompt).not.toContain("2026-03-16");
|
|
116
|
+
// Day 2 doesn't fit (12.5k > 12k)
|
|
117
|
+
expect(prompt).not.toContain("2026-03-17");
|
|
121
118
|
});
|
|
122
|
-
it("recall gets at least 8k tokens even when daily logs use full
|
|
123
|
-
// Daily logs fill their
|
|
124
|
-
const dailyContent = "D".repeat(
|
|
119
|
+
it("recall gets at least 8k tokens even when daily logs use full 12k", () => {
|
|
120
|
+
// Daily logs fill their 12k budget. Recall still gets its guaranteed 8k minimum.
|
|
121
|
+
const dailyContent = "D".repeat(40_000); // ~10k tokens — fits in 12k
|
|
125
122
|
const config = makeConfig({
|
|
126
123
|
dailyLogs: [
|
|
127
124
|
{ date: "2026-03-18", content: "today" },
|
|
@@ -144,7 +141,7 @@ describe("buildTeammatePrompt", () => {
|
|
|
144
141
|
expect(prompt).toContain("<RECALL_RESULTS>");
|
|
145
142
|
});
|
|
146
143
|
it("recall gets unused daily log budget", () => {
|
|
147
|
-
// Small daily logs leave most of
|
|
144
|
+
// Small daily logs leave most of 12k unused — recall gets the surplus.
|
|
148
145
|
const config = makeConfig({
|
|
149
146
|
dailyLogs: [
|
|
150
147
|
{ date: "2026-03-18", content: "today" },
|
|
@@ -152,7 +149,7 @@ describe("buildTeammatePrompt", () => {
|
|
|
152
149
|
],
|
|
153
150
|
});
|
|
154
151
|
// Large recall result — should fit because daily logs barely used any budget
|
|
155
|
-
const recallText = "R".repeat(80_000); // ~20k tokens — fits in (8k + ~
|
|
152
|
+
const recallText = "R".repeat(80_000); // ~20k tokens — fits in (8k + ~12k unused)
|
|
156
153
|
const prompt = buildTeammatePrompt(config, "task", {
|
|
157
154
|
recallResults: [
|
|
158
155
|
{
|
|
@@ -86,6 +86,8 @@ export declare class CliProxyAdapter implements AgentAdapter {
|
|
|
86
86
|
private sessionsDir;
|
|
87
87
|
/** Temp prompt files that need cleanup — guards against crashes before finally. */
|
|
88
88
|
private pendingTempFiles;
|
|
89
|
+
/** Active child processes per teammate — used by killAgent() for interruption. */
|
|
90
|
+
private activeProcesses;
|
|
89
91
|
constructor(options: CliProxyOptions);
|
|
90
92
|
startSession(teammate: TeammateConfig): Promise<string>;
|
|
91
93
|
executeTask(_sessionId: string, teammate: TeammateConfig, prompt: string, options?: {
|
|
@@ -93,6 +95,7 @@ export declare class CliProxyAdapter implements AgentAdapter {
|
|
|
93
95
|
}): Promise<TaskResult>;
|
|
94
96
|
routeTask(task: string, roster: RosterEntry[]): Promise<string | null>;
|
|
95
97
|
getSessionFile(teammateName: string): string | undefined;
|
|
98
|
+
killAgent(teammate: string): Promise<SpawnResult | null>;
|
|
96
99
|
destroySession(_sessionId: string): Promise<void>;
|
|
97
100
|
/**
|
|
98
101
|
* Spawn the agent, stream its output live, and capture it.
|
|
@@ -20,7 +20,7 @@ import { mkdirSync } from "node:fs";
|
|
|
20
20
|
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
21
21
|
import { tmpdir } from "node:os";
|
|
22
22
|
import { join } from "node:path";
|
|
23
|
-
import {
|
|
23
|
+
import { buildTeammatePrompt, DAILY_LOG_BUDGET_TOKENS, queryRecallContext, } from "../adapter.js";
|
|
24
24
|
import { autoCompactForBudget } from "../compact.js";
|
|
25
25
|
export const PRESETS = {
|
|
26
26
|
claude: {
|
|
@@ -103,6 +103,8 @@ export class CliProxyAdapter {
|
|
|
103
103
|
sessionsDir = "";
|
|
104
104
|
/** Temp prompt files that need cleanup — guards against crashes before finally. */
|
|
105
105
|
pendingTempFiles = new Set();
|
|
106
|
+
/** Active child processes per teammate — used by killAgent() for interruption. */
|
|
107
|
+
activeProcesses = new Map();
|
|
106
108
|
constructor(options) {
|
|
107
109
|
this.options = options;
|
|
108
110
|
this.preset =
|
|
@@ -321,6 +323,20 @@ export class CliProxyAdapter {
|
|
|
321
323
|
getSessionFile(teammateName) {
|
|
322
324
|
return this.sessionFiles.get(teammateName);
|
|
323
325
|
}
|
|
326
|
+
async killAgent(teammate) {
|
|
327
|
+
const entry = this.activeProcesses.get(teammate);
|
|
328
|
+
if (!entry || entry.child.killed)
|
|
329
|
+
return null;
|
|
330
|
+
// Kill with SIGTERM → 5s → SIGKILL
|
|
331
|
+
entry.child.kill("SIGTERM");
|
|
332
|
+
setTimeout(() => {
|
|
333
|
+
if (!entry.child.killed) {
|
|
334
|
+
entry.child.kill("SIGKILL");
|
|
335
|
+
}
|
|
336
|
+
}, 5_000);
|
|
337
|
+
// Wait for the process to exit — the spawnAndProxy close handler resolves this
|
|
338
|
+
return entry.done;
|
|
339
|
+
}
|
|
324
340
|
async destroySession(_sessionId) {
|
|
325
341
|
// Clean up any leaked temp prompt files
|
|
326
342
|
for (const file of this.pendingTempFiles) {
|
|
@@ -337,119 +353,128 @@ export class CliProxyAdapter {
|
|
|
337
353
|
* Spawn the agent, stream its output live, and capture it.
|
|
338
354
|
*/
|
|
339
355
|
spawnAndProxy(teammate, promptFile, fullPrompt) {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const args = [
|
|
355
|
-
...this.preset.buildArgs({ promptFile, prompt: fullPrompt, debugFile }, teammate, this.options),
|
|
356
|
-
...(this.options.extraFlags ?? []),
|
|
357
|
-
];
|
|
358
|
-
const command = this.options.commandPath ?? this.preset.command;
|
|
359
|
-
const env = { ...process.env, ...this.preset.env };
|
|
360
|
-
const timeout = this.options.timeout ?? 600_000;
|
|
361
|
-
const interactive = this.preset.interactive ?? false;
|
|
362
|
-
const useStdin = this.preset.stdinPrompt ?? false;
|
|
363
|
-
// Suppress Node.js ExperimentalWarning (e.g. SQLite) in agent
|
|
364
|
-
// subprocesses so it doesn't leak into the terminal UI.
|
|
365
|
-
const existingNodeOpts = env.NODE_OPTIONS ?? "";
|
|
366
|
-
if (!existingNodeOpts.includes("--disable-warning=ExperimentalWarning")) {
|
|
367
|
-
env.NODE_OPTIONS = existingNodeOpts
|
|
368
|
-
? `${existingNodeOpts} --disable-warning=ExperimentalWarning`
|
|
369
|
-
: "--disable-warning=ExperimentalWarning";
|
|
356
|
+
// Create a deferred promise so killAgent() can await the same result
|
|
357
|
+
let resolveOuter;
|
|
358
|
+
let rejectOuter;
|
|
359
|
+
const done = new Promise((res, rej) => {
|
|
360
|
+
resolveOuter = res;
|
|
361
|
+
rejectOuter = rej;
|
|
362
|
+
});
|
|
363
|
+
// Always generate a debug log file for presets that support it (e.g. Claude's --debug-file).
|
|
364
|
+
// Written to .teammates/.tmp/debug/ so startup maintenance can clean old logs.
|
|
365
|
+
let debugFile;
|
|
366
|
+
if (this.preset.supportsDebugFile) {
|
|
367
|
+
const debugDir = join(teammate.cwd ?? process.cwd(), ".teammates", ".tmp", "debug");
|
|
368
|
+
try {
|
|
369
|
+
mkdirSync(debugDir, { recursive: true });
|
|
370
370
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
// Node DEP0190 deprecation warning about unescaped args with shell: true.
|
|
374
|
-
const needsShell = this.preset.shell ?? process.platform === "win32";
|
|
375
|
-
const spawnCmd = needsShell ? [command, ...args].join(" ") : command;
|
|
376
|
-
const spawnArgs = needsShell ? [] : args;
|
|
377
|
-
const child = spawn(spawnCmd, spawnArgs, {
|
|
378
|
-
cwd: teammate.cwd ?? process.cwd(),
|
|
379
|
-
env,
|
|
380
|
-
stdio: [interactive || useStdin ? "pipe" : "ignore", "pipe", "pipe"],
|
|
381
|
-
shell: needsShell,
|
|
382
|
-
});
|
|
383
|
-
// Pipe prompt via stdin if the preset requires it
|
|
384
|
-
if (useStdin && child.stdin) {
|
|
385
|
-
child.stdin.write(fullPrompt);
|
|
386
|
-
child.stdin.end();
|
|
371
|
+
catch {
|
|
372
|
+
/* best effort */
|
|
387
373
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
374
|
+
debugFile = join(debugDir, `agent-${teammate.name}-${Date.now()}.log`);
|
|
375
|
+
}
|
|
376
|
+
const args = [
|
|
377
|
+
...this.preset.buildArgs({ promptFile, prompt: fullPrompt, debugFile }, teammate, this.options),
|
|
378
|
+
...(this.options.extraFlags ?? []),
|
|
379
|
+
];
|
|
380
|
+
const command = this.options.commandPath ?? this.preset.command;
|
|
381
|
+
const env = { ...process.env, ...this.preset.env };
|
|
382
|
+
const timeout = this.options.timeout ?? 600_000;
|
|
383
|
+
const interactive = this.preset.interactive ?? false;
|
|
384
|
+
const useStdin = this.preset.stdinPrompt ?? false;
|
|
385
|
+
// Suppress Node.js ExperimentalWarning (e.g. SQLite) in agent
|
|
386
|
+
// subprocesses so it doesn't leak into the terminal UI.
|
|
387
|
+
const existingNodeOpts = env.NODE_OPTIONS ?? "";
|
|
388
|
+
if (!existingNodeOpts.includes("--disable-warning=ExperimentalWarning")) {
|
|
389
|
+
env.NODE_OPTIONS = existingNodeOpts
|
|
390
|
+
? `${existingNodeOpts} --disable-warning=ExperimentalWarning`
|
|
391
|
+
: "--disable-warning=ExperimentalWarning";
|
|
392
|
+
}
|
|
393
|
+
// On Windows, npm-installed CLIs are .cmd wrappers that require shell.
|
|
394
|
+
// When using shell mode, pass command+args as a single string to avoid
|
|
395
|
+
// Node DEP0190 deprecation warning about unescaped args with shell: true.
|
|
396
|
+
const needsShell = this.preset.shell ?? process.platform === "win32";
|
|
397
|
+
const spawnCmd = needsShell ? [command, ...args].join(" ") : command;
|
|
398
|
+
const spawnArgs = needsShell ? [] : args;
|
|
399
|
+
const child = spawn(spawnCmd, spawnArgs, {
|
|
400
|
+
cwd: teammate.cwd ?? process.cwd(),
|
|
401
|
+
env,
|
|
402
|
+
stdio: [interactive || useStdin ? "pipe" : "ignore", "pipe", "pipe"],
|
|
403
|
+
shell: needsShell,
|
|
404
|
+
});
|
|
405
|
+
// Register the active process for killAgent() access
|
|
406
|
+
this.activeProcesses.set(teammate.name, { child, done, debugFile });
|
|
407
|
+
// Pipe prompt via stdin if the preset requires it
|
|
408
|
+
if (useStdin && child.stdin) {
|
|
409
|
+
child.stdin.write(fullPrompt);
|
|
410
|
+
child.stdin.end();
|
|
411
|
+
}
|
|
412
|
+
// ── Timeout with SIGTERM → SIGKILL escalation ──────────────
|
|
413
|
+
let killed = false;
|
|
414
|
+
let killTimer = null;
|
|
415
|
+
const timeoutTimer = setTimeout(() => {
|
|
416
|
+
if (!child.killed) {
|
|
417
|
+
killed = true;
|
|
418
|
+
child.kill("SIGTERM");
|
|
419
|
+
// If SIGTERM doesn't work after 5s, force-kill
|
|
420
|
+
killTimer = setTimeout(() => {
|
|
421
|
+
if (!child.killed) {
|
|
422
|
+
child.kill("SIGKILL");
|
|
423
|
+
}
|
|
424
|
+
}, 5_000);
|
|
414
425
|
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
stderrBufs.push(chunk);
|
|
422
|
-
});
|
|
423
|
-
const cleanup = () => {
|
|
424
|
-
clearTimeout(timeoutTimer);
|
|
425
|
-
if (killTimer)
|
|
426
|
-
clearTimeout(killTimer);
|
|
427
|
-
if (onUserInput) {
|
|
428
|
-
process.stdin.removeListener("data", onUserInput);
|
|
429
|
-
}
|
|
426
|
+
}, timeout);
|
|
427
|
+
// Connect user's stdin → child only if agent may ask questions
|
|
428
|
+
let onUserInput = null;
|
|
429
|
+
if (interactive && !useStdin && child.stdin) {
|
|
430
|
+
onUserInput = (chunk) => {
|
|
431
|
+
child.stdin?.write(chunk);
|
|
430
432
|
};
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
433
|
+
if (process.stdin.isTTY) {
|
|
434
|
+
process.stdin.setRawMode(false);
|
|
435
|
+
}
|
|
436
|
+
process.stdin.resume();
|
|
437
|
+
process.stdin.on("data", onUserInput);
|
|
438
|
+
}
|
|
439
|
+
const stdoutBufs = [];
|
|
440
|
+
const stderrBufs = [];
|
|
441
|
+
child.stdout?.on("data", (chunk) => {
|
|
442
|
+
stdoutBufs.push(chunk);
|
|
443
|
+
});
|
|
444
|
+
child.stderr?.on("data", (chunk) => {
|
|
445
|
+
stderrBufs.push(chunk);
|
|
446
|
+
});
|
|
447
|
+
const cleanup = () => {
|
|
448
|
+
clearTimeout(timeoutTimer);
|
|
449
|
+
if (killTimer)
|
|
450
|
+
clearTimeout(killTimer);
|
|
451
|
+
if (onUserInput) {
|
|
452
|
+
process.stdin.removeListener("data", onUserInput);
|
|
453
|
+
}
|
|
454
|
+
this.activeProcesses.delete(teammate.name);
|
|
455
|
+
};
|
|
456
|
+
child.on("close", (code, signal) => {
|
|
457
|
+
cleanup();
|
|
458
|
+
const stdout = Buffer.concat(stdoutBufs).toString("utf-8");
|
|
459
|
+
const stderr = Buffer.concat(stderrBufs).toString("utf-8");
|
|
460
|
+
const output = stdout + (stderr ? `\n${stderr}` : "");
|
|
461
|
+
resolveOuter({
|
|
462
|
+
output: killed
|
|
463
|
+
? `${output}\n\n[TIMEOUT] Agent process killed after ${timeout}ms`
|
|
464
|
+
: output,
|
|
465
|
+
stdout,
|
|
466
|
+
stderr,
|
|
467
|
+
exitCode: code,
|
|
468
|
+
signal: signal ?? null,
|
|
469
|
+
timedOut: killed,
|
|
470
|
+
debugFile,
|
|
451
471
|
});
|
|
452
472
|
});
|
|
473
|
+
child.on("error", (err) => {
|
|
474
|
+
cleanup();
|
|
475
|
+
rejectOuter(new Error(`Failed to spawn ${command}: ${err.message}`));
|
|
476
|
+
});
|
|
477
|
+
return done;
|
|
453
478
|
}
|
|
454
479
|
}
|
|
455
480
|
// ─── Output parsing (shared across all agents) ─────────────────────
|
package/dist/cli-utils.d.ts
CHANGED
|
@@ -17,5 +17,34 @@ export declare function findAtMention(line: string, cursor: number): {
|
|
|
17
17
|
} | null;
|
|
18
18
|
/** Set of recognized image file extensions. */
|
|
19
19
|
export declare const IMAGE_EXTS: Set<string>;
|
|
20
|
+
/** A single entry in the conversation history. */
|
|
21
|
+
export interface ConversationEntry {
|
|
22
|
+
role: string;
|
|
23
|
+
text: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Strip protocol artifacts (TO: header, handoff blocks, trailing JSON) from
|
|
27
|
+
* an agent's raw output, returning just the message body.
|
|
28
|
+
*/
|
|
29
|
+
export declare function cleanResponseBody(rawOutput: string): string;
|
|
30
|
+
/**
|
|
31
|
+
* Format a conversation entry for inclusion in a prompt.
|
|
32
|
+
* Single-line text stays inline; multi-line text gets the body on the next line.
|
|
33
|
+
*/
|
|
34
|
+
export declare function formatConversationEntry(role: string, text: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* Build the conversation context section for a teammate prompt.
|
|
37
|
+
* Works backwards from newest entries, including whole entries up to the budget.
|
|
38
|
+
*/
|
|
39
|
+
export declare function buildConversationContext(history: ConversationEntry[], summary: string, budget: number): string;
|
|
40
|
+
/**
|
|
41
|
+
* Find the split index where older conversation entries should be summarized.
|
|
42
|
+
* Returns 0 if everything fits within the budget (nothing to summarize).
|
|
43
|
+
*/
|
|
44
|
+
export declare function findSummarizationSplit(history: ConversationEntry[], budget: number): number;
|
|
45
|
+
/**
|
|
46
|
+
* Build the summarization prompt text from entries being pushed out of the budget.
|
|
47
|
+
*/
|
|
48
|
+
export declare function buildSummarizationPrompt(entries: ConversationEntry[], existingSummary: string): string;
|
|
20
49
|
/** Check if a string looks like an image file path. */
|
|
21
50
|
export declare function isImagePath(text: string): boolean;
|