pi-crew 0.7.2 → 0.7.4
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/CHANGELOG.md +45 -0
- package/package.json +1 -1
- package/src/extension/command-completions.ts +118 -0
- package/src/extension/crew-autocomplete.ts +139 -0
- package/src/extension/crew-input-router.ts +93 -0
- package/src/extension/crew-shortcuts.ts +56 -0
- package/src/extension/message-renderers.ts +109 -0
- package/src/extension/register.ts +30 -0
- package/src/extension/registration/commands.ts +88 -62
- package/src/runtime/background-runner.ts +25 -26
- package/src/state/atomic-write.ts +23 -18
- package/src/state/event-log-rotation.ts +29 -12
- package/src/state/mailbox.ts +32 -18
- package/src/state/observation-store.ts +7 -5
- package/src/state/state-store.ts +8 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.7.4] — Editor autocomplete + settings shortcut (2026-06-15)
|
|
4
|
+
|
|
5
|
+
Round 13 UX quick wins round-out: the remaining two Pi extension API integrations plus a hard-won CI reliability fix after the state-store test flake re-emerged on Windows and macOS.
|
|
6
|
+
|
|
7
|
+
### Features (UX)
|
|
8
|
+
|
|
9
|
+
- **Editor autocomplete provider** — registered via Pi's `addAutocompleteProvider`. As you type `crew <prefix>` or `team <prefix>` at the start of the input line, Pi's popup now suggests natural-language crew phrases and shows the slash command they map to (e.g. `crew status → /team-status`, `team dashboard → /team-dashboard`). `crew` and `team` are interchangeable keywords, driven by a single `CREW_PHRASES` source of truth shared with the input router.
|
|
10
|
+
- **Keyboard shortcut** — `alt+s` opens the pi-crew settings overlay (config + theme picker). `openTeamSettingsOverlay(ctx)` was extracted from the settings command handler so the shortcut reuses the exact same overlay (DRY). `alt+s` was chosen to avoid Pi's built-in keymap (Pi only binds `alt+v` and `alt+arrow`/`alt+enter` among alt+letter keys).
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
- **createRunManifest swallowed the real write error** — `saveManifestAndTasksAtomicSync` returns `error: String(err)`, but `createRunManifest` passed it to `errors.fileWrite` as a fake `ErrnoException`; `.code` was `undefined` → every write failure showed `": unknown"`, hiding the actual cause. Now surfaces the real error string in the thrown context, so CI logs and production callers see *why* the write failed.
|
|
15
|
+
- **`atomicWriteFile` Windows path-form correctness** — must NEVER rewrite the write target to a different realpath form. Callers build `filePath` via `canonicalizePath` (`realpathSync.native`) and later stat/read it at that exact path; rewriting it (even to a "canonical" form) made the file land on a divergent path that Windows treated as separate → `existsSync`/`readFileSync` failed after a "successful" write. `canonicalize()` is now used ONLY as an mkdir fallback on Windows `EPERM`, never to change the write target.
|
|
16
|
+
|
|
17
|
+
### Tests / CI
|
|
18
|
+
|
|
19
|
+
- **Cap `--test-concurrency` at 2 on all CI platforms.** After the Round 13/14 test additions pushed every GitHub Actions runner past its filesystem-contention threshold, `state-store.test.ts` write-then-stat tests flaked on Windows (Windows Defender locks fresh temp files → rename `EPERM` exhausts atomic-write retries) and macOS (`/var/folders` tmp contention under load). `scripts/test-runner.mjs` now clamps the CI-requested concurrency (`4 → 2`) so the FS has room to flush; local dev is unaffected. Green on all 3 platforms (run 27556451997). 8× concurrent local runs reproduced nothing — pure CI infra contention, not a deterministic bug.
|
|
20
|
+
- +20 tests for the new features (crew-autocomplete: 16, crew-shortcuts: 4).
|
|
21
|
+
|
|
22
|
+
## [0.7.3] — Reliability hardening + UX quick wins (2026-06-15)
|
|
23
|
+
|
|
24
|
+
This release fixes 4 critical data-loss bugs found by the Round 12 reliability audit and adds three UX quick wins from the Round 13 UX research (+125 tests from the Round 14 coverage sprint).
|
|
25
|
+
|
|
26
|
+
### Bug Fixes (Critical — data loss prevention)
|
|
27
|
+
|
|
28
|
+
- **`rotateEventLog` destroyed ALL events** — `atomicWriteFile("")` then `rename` replaced the file with empty content *before* the rename, so the archive received an empty file. Now copies content to archive first, then truncates in place. Also handles sub-millisecond timestamp collisions.
|
|
29
|
+
- **`compactEventLog` recovery loop replaced the file per-event** — each `atomicWriteFile` iteration overwrote the compacted log + previous recoveries, leaving only the last event. Now accumulates missing events into one `appendFileSync`.
|
|
30
|
+
- **Mailbox `delivery.json` lost-update race** — `appendMailboxMessage`, `acknowledgeMailboxMessage`, and `replayPendingMailboxMessages` all had unlocked read-modify-write cycles. Now wrapped in `withFileLockSync`.
|
|
31
|
+
- **`observation-store.save()` non-atomic write** — raw `writeFileSync` could leave a truncated file on crash. Now uses `atomicWriteJson`.
|
|
32
|
+
- **`background-runner` DEBUG log noise** — 10 trace-level `console.log` statements gated behind `PI_CREW_DEBUG` env var.
|
|
33
|
+
|
|
34
|
+
### Features (UX)
|
|
35
|
+
|
|
36
|
+
- **Command argument autocomplete** — 13 run-scoped and team-scoped commands now implement `getArgumentCompletions` so Pi's built-in Tab-completion surfaces run IDs (with status icon + goal preview), team names, workflow names, and task IDs. No more memorizing long generated run IDs.
|
|
37
|
+
- **Custom message renderers** — `crew:run-started`, `crew:run-completed`, and `crew:resume-directive` entries now render with a clean crew-branded look (🚀/✅/❌ status icons, theme colors) instead of raw JSON blobs.
|
|
38
|
+
- **Natural-language crew routing** — type `crew status`, `team dashboard`, `crew help`, `teams`, etc. and pi-crew rewrites it to the equivalent slash command. Only transforms interactive input; never shadows explicit slash commands.
|
|
39
|
+
|
|
40
|
+
### Tests
|
|
41
|
+
|
|
42
|
+
- +125 tests (4795 pass / 0 fail). New coverage: cascading replace engine (31), safe-paths traversal defense (21), atomic-write symlink prevention (15), command completions (20), message renderers (12), input router (18), event-log rotate regression (9).
|
|
43
|
+
|
|
44
|
+
### Research
|
|
45
|
+
|
|
46
|
+
This release was driven by 4 deep research rounds (11–14), documented in `research-findings/`.
|
|
47
|
+
|
|
3
48
|
## [0.7.2] — Fix: Knowledge Injection into Workers + HITL for All Workflows (2026-06-15)
|
|
4
49
|
|
|
5
50
|
### Bug Fixes
|
package/package.json
CHANGED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command argument autocomplete helpers (Round 13 UX quick-win).
|
|
3
|
+
*
|
|
4
|
+
* Pi's built-in slash-command autocomplete calls a command's
|
|
5
|
+
* `getArgumentCompletions(argumentPrefix)` when the user types
|
|
6
|
+
* `/command <prefix><TAB>`. Returning AutocompleteItem[] surfaces those
|
|
7
|
+
* suggestions; returning null falls back to file completion.
|
|
8
|
+
*
|
|
9
|
+
* These helpers provide run-id, team, and workflow completions without
|
|
10
|
+
* requiring the user to memorize long generated IDs.
|
|
11
|
+
*/
|
|
12
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
13
|
+
import { listRecentRuns } from "./run-index.ts";
|
|
14
|
+
import { discoverTeams, allTeams } from "../teams/discover-teams.ts";
|
|
15
|
+
import { discoverWorkflows, allWorkflows } from "../workflows/discover-workflows.ts";
|
|
16
|
+
import { discoverAgents, allAgents } from "../agents/discover-agents.ts";
|
|
17
|
+
import type { TeamRunManifest } from "../state/types.ts";
|
|
18
|
+
|
|
19
|
+
const MAX_RUN_SUGGESTIONS = 15;
|
|
20
|
+
|
|
21
|
+
function filterByPrefix(items: AutocompleteItem[], prefix: string): AutocompleteItem[] | null {
|
|
22
|
+
const trimmed = prefix.trim();
|
|
23
|
+
const filtered = trimmed === ""
|
|
24
|
+
? items
|
|
25
|
+
: items.filter((item) => item.value.startsWith(trimmed) || item.label.toLowerCase().includes(trimmed.toLowerCase()));
|
|
26
|
+
return filtered.length > 0 ? filtered.slice(0, MAX_RUN_SUGGESTIONS) : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function statusIcon(status: TeamRunManifest["status"]): string {
|
|
30
|
+
switch (status) {
|
|
31
|
+
case "running":
|
|
32
|
+
case "planning":
|
|
33
|
+
return "▶";
|
|
34
|
+
case "queued":
|
|
35
|
+
return "⏳";
|
|
36
|
+
case "completed":
|
|
37
|
+
return "✓";
|
|
38
|
+
case "failed":
|
|
39
|
+
case "blocked":
|
|
40
|
+
return "✗";
|
|
41
|
+
case "cancelled":
|
|
42
|
+
return "⊘";
|
|
43
|
+
default:
|
|
44
|
+
return "•";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Suggest recent run IDs for run-scoped commands (/team-status, /team-cancel, …).
|
|
50
|
+
* Falls back to `process.cwd()` because Pi does not pass cwd into
|
|
51
|
+
* `getArgumentCompletions` — this is correct in the interactive TUI where the
|
|
52
|
+
* process cwd matches the session cwd.
|
|
53
|
+
*/
|
|
54
|
+
export function suggestRunIds(_prefix: string, cwd?: string): AutocompleteItem[] | null {
|
|
55
|
+
const resolvedCwd = cwd ?? process.cwd();
|
|
56
|
+
const runs = listRecentRuns(resolvedCwd, MAX_RUN_SUGGESTIONS);
|
|
57
|
+
if (runs.length === 0) return null;
|
|
58
|
+
const items: AutocompleteItem[] = runs.map((run) => ({
|
|
59
|
+
value: run.runId,
|
|
60
|
+
label: run.runId,
|
|
61
|
+
description: `${statusIcon(run.status)} ${run.status} · ${run.team} · ${(run.goal ?? "").slice(0, 48)}`,
|
|
62
|
+
}));
|
|
63
|
+
return filterByPrefix(items, _prefix);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Suggest task IDs within a specific run (for /team-result <runId> <taskId>). */
|
|
67
|
+
export async function suggestTaskIds(runId: string, prefix: string, cwd?: string): Promise<AutocompleteItem[] | null> {
|
|
68
|
+
const resolvedCwd = cwd ?? process.cwd();
|
|
69
|
+
// Dynamic import to avoid pulling state-store into the hot command-registration path.
|
|
70
|
+
const { loadRunManifestById } = await import("../state/state-store.ts");
|
|
71
|
+
const loaded = loadRunManifestById(resolvedCwd, runId);
|
|
72
|
+
if (!loaded) return null;
|
|
73
|
+
const items: AutocompleteItem[] = loaded.tasks.map((task) => ({
|
|
74
|
+
value: task.id,
|
|
75
|
+
label: task.id,
|
|
76
|
+
description: `${task.status} · ${task.role} · ${task.title?.slice(0, 40) ?? ""}`,
|
|
77
|
+
}));
|
|
78
|
+
return filterByPrefix(items, prefix);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Suggest available teams for /team-run <team>. */
|
|
82
|
+
export function suggestTeams(prefix: string, cwd?: string): AutocompleteItem[] | null {
|
|
83
|
+
const resolvedCwd = cwd ?? process.cwd();
|
|
84
|
+
const teams = allTeams(discoverTeams(resolvedCwd));
|
|
85
|
+
if (teams.length === 0) return null;
|
|
86
|
+
const items: AutocompleteItem[] = teams.map((team) => ({
|
|
87
|
+
value: team.name,
|
|
88
|
+
label: team.name,
|
|
89
|
+
description: team.defaultWorkflow ? `workflow=${team.defaultWorkflow}` : undefined,
|
|
90
|
+
}));
|
|
91
|
+
return filterByPrefix(items, prefix);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Suggest available workflows. */
|
|
95
|
+
export function suggestWorkflows(prefix: string, cwd?: string): AutocompleteItem[] | null {
|
|
96
|
+
const resolvedCwd = cwd ?? process.cwd();
|
|
97
|
+
const workflows = allWorkflows(discoverWorkflows(resolvedCwd));
|
|
98
|
+
if (workflows.length === 0) return null;
|
|
99
|
+
const items: AutocompleteItem[] = workflows.map((wf) => ({
|
|
100
|
+
value: wf.name,
|
|
101
|
+
label: wf.name,
|
|
102
|
+
description: `${wf.steps?.length ?? 0} steps`,
|
|
103
|
+
}));
|
|
104
|
+
return filterByPrefix(items, prefix);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Suggest available agents. */
|
|
108
|
+
export function suggestAgents(prefix: string, cwd?: string): AutocompleteItem[] | null {
|
|
109
|
+
const resolvedCwd = cwd ?? process.cwd();
|
|
110
|
+
const agents = allAgents(discoverAgents(resolvedCwd));
|
|
111
|
+
if (agents.length === 0) return null;
|
|
112
|
+
const items: AutocompleteItem[] = agents.map((agent) => ({
|
|
113
|
+
value: agent.name,
|
|
114
|
+
label: agent.name,
|
|
115
|
+
description: agent.description?.slice(0, 60),
|
|
116
|
+
}));
|
|
117
|
+
return filterByPrefix(items, prefix);
|
|
118
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crew editor autocomplete provider (Round 13 UX).
|
|
3
|
+
*
|
|
4
|
+
* Wraps Pi's built-in autocomplete provider and adds natural-language crew
|
|
5
|
+
* phrase completion: when the user types `crew <prefix>` or `team <prefix>`
|
|
6
|
+
* at the start of the input line, we suggest the matching phrases (e.g.
|
|
7
|
+
* "crew status → /team-status"). This teaches users the natural-language
|
|
8
|
+
* phrases that the input router (crew-input-router.ts) will rewrite on
|
|
9
|
+
* submit — so they discover the feature without reading docs.
|
|
10
|
+
*
|
|
11
|
+
* For any non-crew input we delegate to the wrapped (`current`) provider, so
|
|
12
|
+
* slash-command, file (`@`), and command-argument completion all keep working
|
|
13
|
+
* unchanged.
|
|
14
|
+
*/
|
|
15
|
+
import type {
|
|
16
|
+
AutocompleteItem,
|
|
17
|
+
AutocompleteProvider,
|
|
18
|
+
AutocompleteSuggestions,
|
|
19
|
+
} from "@earendil-works/pi-tui";
|
|
20
|
+
import { CREW_PHRASES } from "./crew-input-router.ts";
|
|
21
|
+
|
|
22
|
+
/** Max phrases to suggest. */
|
|
23
|
+
const MAX_PHRASES = 12;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* If the text before the cursor is a crew-natural-language trigger, return the
|
|
27
|
+
* query word (the partial keyword after `crew `/`team `), or `undefined` when
|
|
28
|
+
* it is not a crew trigger.
|
|
29
|
+
*
|
|
30
|
+
* Triggers look like `crew ` or `team ` optionally followed by a partial word
|
|
31
|
+
* made of word characters, anchored at the start of the line.
|
|
32
|
+
*/
|
|
33
|
+
function extractCrewQuery(textBeforeCursor: string): string | undefined {
|
|
34
|
+
// Anchor at start of line; require the `crew|team` keyword + whitespace,
|
|
35
|
+
// then an optional partial word. We do NOT trigger mid-word on the keyword
|
|
36
|
+
// itself (e.g. "cre" alone is not a trigger) — the keyword must be complete.
|
|
37
|
+
const match = textBeforeCursor.match(/^(?:crew|team)\s+([\w-]*)$/i);
|
|
38
|
+
return match?.[1];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Filter the shared phrase list by a partial keyword prefix. */
|
|
42
|
+
export function suggestCrewPhrases(query: string): AutocompleteItem[] {
|
|
43
|
+
const q = query.toLowerCase();
|
|
44
|
+
// Phrases are keyed by their keyword after "crew "/"team " (or the bare
|
|
45
|
+
// word for "teams"). Build a lookup keyword per phrase.
|
|
46
|
+
const seen = new Set<string>();
|
|
47
|
+
const items: AutocompleteItem[] = [];
|
|
48
|
+
for (const entry of CREW_PHRASES) {
|
|
49
|
+
// Derive the autocomplete keyword: for "crew status" → "status";
|
|
50
|
+
// for "teams" → "teams".
|
|
51
|
+
const parts = entry.phrase.split(/\s+/);
|
|
52
|
+
const keyword = parts.length > 1 ? parts.slice(1).join(" ") : entry.phrase;
|
|
53
|
+
if (seen.has(entry.phrase)) continue;
|
|
54
|
+
if (q && !keyword.toLowerCase().startsWith(q)) continue;
|
|
55
|
+
seen.add(entry.phrase);
|
|
56
|
+
items.push({
|
|
57
|
+
value: entry.phrase,
|
|
58
|
+
label: entry.phrase,
|
|
59
|
+
description: `→ ${entry.command}`,
|
|
60
|
+
});
|
|
61
|
+
if (items.length >= MAX_PHRASES) break;
|
|
62
|
+
}
|
|
63
|
+
return items;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Create a crew autocomplete provider that wraps `current`. When the input is
|
|
68
|
+
* a crew natural-language trigger, returns phrase suggestions; otherwise
|
|
69
|
+
* delegates to `current`.
|
|
70
|
+
*/
|
|
71
|
+
export function createCrewAutocompleteProvider(
|
|
72
|
+
current: AutocompleteProvider,
|
|
73
|
+
): AutocompleteProvider {
|
|
74
|
+
return {
|
|
75
|
+
async getSuggestions(
|
|
76
|
+
lines: string[],
|
|
77
|
+
cursorLine: number,
|
|
78
|
+
cursorCol: number,
|
|
79
|
+
options: { signal: AbortSignal; force?: boolean },
|
|
80
|
+
): Promise<AutocompleteSuggestions | null> {
|
|
81
|
+
// Only trigger on the first line (Pi's main input is single-line;
|
|
82
|
+
// multiline editors are out of scope and would surprise the user).
|
|
83
|
+
if (cursorLine === 0) {
|
|
84
|
+
const currentLine = lines[cursorLine] ?? "";
|
|
85
|
+
const before = currentLine.slice(0, cursorCol);
|
|
86
|
+
const query = extractCrewQuery(before);
|
|
87
|
+
if (query !== undefined) {
|
|
88
|
+
const items = suggestCrewPhrases(query);
|
|
89
|
+
if (items.length > 0) {
|
|
90
|
+
// prefix = the full text to replace (e.g. "crew st").
|
|
91
|
+
// The default applyCompletion replaces the trailing
|
|
92
|
+
// `prefix`-length chars with item.value.
|
|
93
|
+
return { items, prefix: before };
|
|
94
|
+
}
|
|
95
|
+
// Triggered but no matches — return empty rather than
|
|
96
|
+
// falling through to file/command completion, so the user
|
|
97
|
+
// doesn't get a confusing file list while typing a phrase.
|
|
98
|
+
return { items: [], prefix: before };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
applyCompletion(
|
|
105
|
+
lines: string[],
|
|
106
|
+
cursorLine: number,
|
|
107
|
+
cursorCol: number,
|
|
108
|
+
item: AutocompleteItem,
|
|
109
|
+
prefix: string,
|
|
110
|
+
): { lines: string[]; cursorLine: number; cursorCol: number } {
|
|
111
|
+
// Delegate to the wrapped provider. For a non-slash, non-@ prefix
|
|
112
|
+
// the default applyCompletion replaces the trailing `prefix`-length
|
|
113
|
+
// chars with item.value — which is exactly the full phrase. This
|
|
114
|
+
// matches our prefix contract (prefix = full text to cursor).
|
|
115
|
+
return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
shouldTriggerFileCompletion(
|
|
119
|
+
lines: string[],
|
|
120
|
+
cursorLine: number,
|
|
121
|
+
cursorCol: number,
|
|
122
|
+
): boolean {
|
|
123
|
+
// Suppress file-completion trigger inside a crew phrase so the
|
|
124
|
+
// editor doesn't pop a file list over our phrase suggestions.
|
|
125
|
+
if (cursorLine === 0) {
|
|
126
|
+
const before = (lines[cursorLine] ?? "").slice(0, cursorCol);
|
|
127
|
+
if (extractCrewQuery(before) !== undefined) return false;
|
|
128
|
+
}
|
|
129
|
+
return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Register the crew autocomplete provider on a Pi UI context. Safe to call once. */
|
|
135
|
+
export function registerCrewAutocomplete(
|
|
136
|
+
ctx: { ui?: { addAutocompleteProvider?: (factory: (current: AutocompleteProvider) => AutocompleteProvider) => void } },
|
|
137
|
+
): void {
|
|
138
|
+
ctx.ui?.addAutocompleteProvider?.((current) => createCrewAutocompleteProvider(current));
|
|
139
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Natural-language crew input routing (Round 13 UX).
|
|
3
|
+
*
|
|
4
|
+
* Pi fires the `input` event before skill/template expansion and before
|
|
5
|
+
* before_agent_start. A handler can transform the text (e.g. rewrite
|
|
6
|
+
* "crew status" → "/team-status"), or fully handle it.
|
|
7
|
+
*
|
|
8
|
+
* This module matches a small set of natural-language crew phrases and
|
|
9
|
+
* rewrites them to the equivalent slash command, so users do not need to
|
|
10
|
+
* memorize command names. Slash-command input (text starting with "/") is
|
|
11
|
+
* always passed through unchanged — we never shadow explicit commands.
|
|
12
|
+
*/
|
|
13
|
+
import type { InputEvent, InputEventResult } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Natural-language crew phrases → slash-command mapping.
|
|
17
|
+
*
|
|
18
|
+
* Single source of truth shared by:
|
|
19
|
+
* - the `input`-event router (rewrites submitted text), and
|
|
20
|
+
* - the editor autocomplete provider (suggests phrases as you type).
|
|
21
|
+
*
|
|
22
|
+
* Each entry maps a phrase (what the user types) to a slash command.
|
|
23
|
+
* The router matches when submitted text STARTS WITH a phrase (word boundary);
|
|
24
|
+
* the autocomplete matches when the line starts with `crew `/`team ` and the
|
|
25
|
+
* partial word is a prefix of a phrase's keyword.
|
|
26
|
+
*/
|
|
27
|
+
export const CREW_PHRASES: ReadonlyArray<{ phrase: string; command: string }> = [
|
|
28
|
+
{ phrase: "crew status", command: "/team-status" },
|
|
29
|
+
{ phrase: "crew list", command: "/team-status" },
|
|
30
|
+
{ phrase: "crew dashboard", command: "/team-dashboard" },
|
|
31
|
+
{ phrase: "crew board", command: "/team-dashboard" },
|
|
32
|
+
{ phrase: "crew panel", command: "/team-dashboard" },
|
|
33
|
+
{ phrase: "crew help", command: "/team-help" },
|
|
34
|
+
{ phrase: "crew commands", command: "/team-help" },
|
|
35
|
+
{ phrase: "crew doctor", command: "/team-doctor" },
|
|
36
|
+
{ phrase: "crew diagnose", command: "/team-doctor" },
|
|
37
|
+
{ phrase: "teams", command: "/teams" },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build a case-insensitive anchored regex from a phrase. The leading `crew `
|
|
42
|
+
* keyword is treated as interchangeable with `team ` (so "crew status" matches
|
|
43
|
+
* both "crew status" and "team status"). Bare phrases like "teams" match
|
|
44
|
+
* verbatim.
|
|
45
|
+
*/
|
|
46
|
+
function phraseToRegex(phrase: string): RegExp {
|
|
47
|
+
const kw = phrase.match(/^(crew|team)\s+(.*)$/i);
|
|
48
|
+
if (kw) {
|
|
49
|
+
const rest = kw[2].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
50
|
+
return new RegExp(`^(?:crew|team)\\s+${rest}\\b`, "i");
|
|
51
|
+
}
|
|
52
|
+
const escaped = phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
53
|
+
return new RegExp(`^${escaped}\\b`, "i");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Try to rewrite a natural-language crew phrase into a slash command.
|
|
58
|
+
* Returns the rewritten command string, or `null` if no rule matches.
|
|
59
|
+
*
|
|
60
|
+
* Rules intentionally only match at the START of the input and require a
|
|
61
|
+
* word boundary, so ordinary sentences mentioning "crew" are untouched.
|
|
62
|
+
*/
|
|
63
|
+
export function rewriteCrewInput(text: string): string | null {
|
|
64
|
+
const trimmed = text.trim();
|
|
65
|
+
// Never transform explicit slash commands or inputs that don't start with
|
|
66
|
+
// a crew/team keyword phrase.
|
|
67
|
+
if (trimmed.startsWith("/")) return null;
|
|
68
|
+
for (const entry of CREW_PHRASES) {
|
|
69
|
+
const match = trimmed.match(phraseToRegex(entry.phrase));
|
|
70
|
+
if (!match) continue;
|
|
71
|
+
// Carry any remaining args after the matched phrase forward.
|
|
72
|
+
const rest = trimmed.slice(match[0].length).trim();
|
|
73
|
+
return rest ? `${entry.command} ${rest}` : entry.command;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Pi `input` event handler. Transforms matching crew phrases; passes
|
|
80
|
+
* everything else through unchanged.
|
|
81
|
+
*/
|
|
82
|
+
export function handleCrewInput(event: InputEvent): InputEventResult {
|
|
83
|
+
// Only transform interactive user input — never programmatic/scripted input.
|
|
84
|
+
if (event.source !== "interactive") return { action: "continue" };
|
|
85
|
+
const rewritten = rewriteCrewInput(event.text);
|
|
86
|
+
if (!rewritten) return { action: "continue" };
|
|
87
|
+
return { action: "transform", text: rewritten, images: event.images };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Register the crew input router on a Pi instance. Safe to call once. */
|
|
91
|
+
export function registerCrewInputRouter(pi: { on?: (event: "input", handler: (e: InputEvent) => InputEventResult) => void }): void {
|
|
92
|
+
pi.on?.("input", handleCrewInput);
|
|
93
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crew keyboard shortcuts (Round 13 UX).
|
|
3
|
+
*
|
|
4
|
+
* Registers a small set of keyboard shortcuts for fast access to the most
|
|
5
|
+
* useful pi-crew overlays. Keys are chosen to avoid collisions with Pi's
|
|
6
|
+
* built-in keymap (see analysis of pi-tui core/keybindings defaults):
|
|
7
|
+
*
|
|
8
|
+
* alt+s → open the pi-crew settings overlay (config + theme picker)
|
|
9
|
+
*
|
|
10
|
+
* `alt+<letter>` combos are safe: Pi only binds `alt+v`, `alt+enter`, and the
|
|
11
|
+
* alt+arrow navigation keys. `alt+s` is mnemonic (settings) and free.
|
|
12
|
+
*
|
|
13
|
+
* Shortcuts are guarded by `hasUI` so they never fire in print/RPC mode, and
|
|
14
|
+
* by the optional `registerShortcut` API so older Pi versions degrade
|
|
15
|
+
* gracefully (no-op).
|
|
16
|
+
*/
|
|
17
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import type { KeyId } from "@earendil-works/pi-tui";
|
|
19
|
+
|
|
20
|
+
type ShortcutHandler = (ctx: ExtensionContext) => Promise<void> | void;
|
|
21
|
+
|
|
22
|
+
interface ShortcutRegistration {
|
|
23
|
+
/** Pi KeyId, e.g. "alt+s". */
|
|
24
|
+
key: KeyId;
|
|
25
|
+
description: string;
|
|
26
|
+
handler: ShortcutHandler;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const CREW_SHORTCUTS: ReadonlyArray<ShortcutRegistration> = [
|
|
30
|
+
{
|
|
31
|
+
key: "alt+s",
|
|
32
|
+
description: "pi-crew: open settings (config + theme picker)",
|
|
33
|
+
// Lazy-import the overlay so this module stays lightweight at load time
|
|
34
|
+
// (avoids pulling the full commands.ts dependency tree into every
|
|
35
|
+
// process that imports this module, e.g. the unit test).
|
|
36
|
+
handler: async (ctx) => {
|
|
37
|
+
const { openTeamSettingsOverlay } = await import("./registration/commands.ts");
|
|
38
|
+
await openTeamSettingsOverlay(ctx);
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Register all crew keyboard shortcuts on a Pi instance. Safe to call once at
|
|
45
|
+
* extension load. No-ops when `registerShortcut` is unavailable (older Pi).
|
|
46
|
+
*/
|
|
47
|
+
export function registerCrewShortcuts(
|
|
48
|
+
pi: { registerShortcut?: (shortcut: KeyId, options: { description?: string; handler: ShortcutHandler }) => void },
|
|
49
|
+
): void {
|
|
50
|
+
for (const sc of CREW_SHORTCUTS) {
|
|
51
|
+
pi.registerShortcut?.(sc.key, { description: sc.description, handler: sc.handler });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Exported for tests / introspection. */
|
|
56
|
+
export const CREW_SHORTCUT_KEYS: readonly KeyId[] = CREW_SHORTCUTS.map((s) => s.key);
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom message renderers for pi-crew session entries (Round 13 UX).
|
|
3
|
+
*
|
|
4
|
+
* pi-crew emits CustomMessageEntry rows via `pi.appendEntry()` for run
|
|
5
|
+
* lifecycle events (crew:run-started, crew:run-completed,
|
|
6
|
+
* crew:resume-directive). Without a registered renderer these display as
|
|
7
|
+
* raw JSON in the conversation. These renderers give them a clean,
|
|
8
|
+
* crew-branded look using the active theme.
|
|
9
|
+
*/
|
|
10
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
11
|
+
import type { ExtensionAPI, MessageRenderOptions, Theme } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
|
|
13
|
+
interface CrewMessageDetails {
|
|
14
|
+
runId?: string;
|
|
15
|
+
team?: string;
|
|
16
|
+
workflow?: string;
|
|
17
|
+
agent?: string;
|
|
18
|
+
goal?: string;
|
|
19
|
+
status?: string;
|
|
20
|
+
taskCount?: number;
|
|
21
|
+
timestamp?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type MessageLike = {
|
|
25
|
+
content: string | Array<{ type: string; text?: string }>;
|
|
26
|
+
details?: CrewMessageDetails;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function extractText(message: MessageLike): string {
|
|
30
|
+
if (typeof message.content === "string") return message.content;
|
|
31
|
+
return (message.content ?? []).map((c) => c.text ?? "").join("");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function statusLevel(status: string | undefined): "success" | "error" | "warning" | "muted" {
|
|
35
|
+
switch (status) {
|
|
36
|
+
case "completed":
|
|
37
|
+
return "success";
|
|
38
|
+
case "failed":
|
|
39
|
+
case "blocked":
|
|
40
|
+
return "error";
|
|
41
|
+
case "cancelled":
|
|
42
|
+
return "warning";
|
|
43
|
+
default:
|
|
44
|
+
return "muted";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function statusIcon(status: string | undefined): string {
|
|
49
|
+
switch (status) {
|
|
50
|
+
case "completed":
|
|
51
|
+
return "✅";
|
|
52
|
+
case "failed":
|
|
53
|
+
case "blocked":
|
|
54
|
+
return "❌";
|
|
55
|
+
case "cancelled":
|
|
56
|
+
return "🚫";
|
|
57
|
+
case "running":
|
|
58
|
+
case "planning":
|
|
59
|
+
return "🚀";
|
|
60
|
+
default:
|
|
61
|
+
return "•";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Truncate a string to maxLen chars with an ellipsis. */
|
|
66
|
+
function truncate(text: string, maxLen: number): string {
|
|
67
|
+
return text.length > maxLen ? `${text.slice(0, maxLen - 1)}…` : text;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Render crew:run-started entries as a branded launch line. */
|
|
71
|
+
export function renderRunStarted(message: MessageLike, _options: MessageRenderOptions, theme: Theme): Text {
|
|
72
|
+
const details = message.details ?? {};
|
|
73
|
+
const goal = details.goal ? truncate(details.goal, 70) : "";
|
|
74
|
+
const team = details.team ?? details.agent ?? "direct";
|
|
75
|
+
const workflow = details.workflow ?? "default";
|
|
76
|
+
const text = `🚀 crew run ${details.runId ?? ""} started — ${team}/${workflow}${goal ? ` — ${goal}` : ""}`;
|
|
77
|
+
return new Text(theme.fg("accent", theme.bold("crew ")) + theme.fg("text", text), 0, 0);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Render crew:run-completed entries with a status-colored summary. */
|
|
81
|
+
export function renderRunCompleted(message: MessageLike, _options: MessageRenderOptions, theme: Theme): Text {
|
|
82
|
+
const details = message.details ?? {};
|
|
83
|
+
const status = details.status;
|
|
84
|
+
const level = statusLevel(status);
|
|
85
|
+
const icon = statusIcon(status);
|
|
86
|
+
const goal = details.goal ? truncate(details.goal, 60) : "";
|
|
87
|
+
const tasks = details.taskCount !== undefined ? ` · ${details.taskCount} tasks` : "";
|
|
88
|
+
const text = `${icon} crew run ${details.runId ?? ""} ${status ?? "finished"}${tasks}${goal ? ` — ${goal}` : ""}`;
|
|
89
|
+
return new Text(theme.fg(level, theme.bold("crew ")) + theme.fg(level, text), 0, 0);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Render crew:resume-directive entries as an informational system note. */
|
|
93
|
+
export function renderResumeDirective(message: MessageLike, _options: MessageRenderOptions, theme: Theme): Text {
|
|
94
|
+
const text = extractText(message) || "Context compacted — resuming in-flight crew work.";
|
|
95
|
+
return new Text(theme.fg("muted", theme.bold("crew ") + text), 0, 0);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Register all crew message renderers. Safe to call once at extension load. */
|
|
99
|
+
export function registerCrewMessageRenderers(
|
|
100
|
+
pi: { registerMessageRenderer?: ExtensionAPI["registerMessageRenderer"] },
|
|
101
|
+
): void {
|
|
102
|
+
// Optional chaining guards against older Pi versions (and test stubs)
|
|
103
|
+
// without registerMessageRenderer.
|
|
104
|
+
// The renderers return Text (a Component) — cast through never to match
|
|
105
|
+
// the MessageRenderer<T> signature which expects Component | undefined.
|
|
106
|
+
pi.registerMessageRenderer?.("crew:run-started", renderRunStarted as never);
|
|
107
|
+
pi.registerMessageRenderer?.("crew:run-completed", renderRunCompleted as never);
|
|
108
|
+
pi.registerMessageRenderer?.("crew:resume-directive", renderResumeDirective as never);
|
|
109
|
+
}
|
|
@@ -109,6 +109,10 @@ import {
|
|
|
109
109
|
sendFollowUp,
|
|
110
110
|
} from "./registration/subagent-helpers.ts";
|
|
111
111
|
import { registerSubagentTools } from "./registration/subagent-tools.ts";
|
|
112
|
+
import { registerCrewMessageRenderers } from "./message-renderers.ts";
|
|
113
|
+
import { registerCrewInputRouter } from "./crew-input-router.ts";
|
|
114
|
+
import { registerCrewAutocomplete } from "./crew-autocomplete.ts";
|
|
115
|
+
import { registerCrewShortcuts } from "./crew-shortcuts.ts";
|
|
112
116
|
import { registerTeamTool } from "./registration/team-tool.ts";
|
|
113
117
|
import { handleTeamTool } from "./team-tool.ts";
|
|
114
118
|
import { persistScheduledJobUpdate } from "./team-tool/handle-schedule.ts";
|
|
@@ -212,6 +216,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
212
216
|
let sessionGeneration = 0;
|
|
213
217
|
let rpcHandle: PiCrewRpcHandle | undefined;
|
|
214
218
|
let cleanedUp = false;
|
|
219
|
+
let crewAutocompleteRegistered = false;
|
|
215
220
|
let manifestCache = createManifestCache(process.cwd());
|
|
216
221
|
let runSnapshotCache = createRunSnapshotCache(process.cwd());
|
|
217
222
|
let cacheCwd = process.cwd();
|
|
@@ -1217,6 +1222,14 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
1217
1222
|
sessionGeneration++;
|
|
1218
1223
|
const ownerGeneration = sessionGeneration;
|
|
1219
1224
|
currentCtx = ctx;
|
|
1225
|
+
// Round 13 UX: register the crew natural-language autocomplete provider
|
|
1226
|
+
// once we have a UI context. Guarded so repeated session_start events
|
|
1227
|
+
// don't stack wrappers (each wrapper delegates, but stacking wastes
|
|
1228
|
+
// call depth).
|
|
1229
|
+
if (!crewAutocompleteRegistered) {
|
|
1230
|
+
crewAutocompleteRegistered = true;
|
|
1231
|
+
registerCrewAutocomplete(ctx);
|
|
1232
|
+
}
|
|
1220
1233
|
if (widgetState.interval) clearInterval(widgetState.interval);
|
|
1221
1234
|
widgetState.interval = undefined;
|
|
1222
1235
|
notifyActiveRuns(ctx);
|
|
@@ -2035,4 +2048,21 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
2035
2048
|
}
|
|
2036
2049
|
},
|
|
2037
2050
|
});
|
|
2051
|
+
|
|
2052
|
+
// Round 13 UX: render pi-crew lifecycle entries (crew:run-started,
|
|
2053
|
+
// crew:run-completed, crew:resume-directive) with a branded look instead
|
|
2054
|
+
// of raw JSON. No-op on Pi versions without registerMessageRenderer.
|
|
2055
|
+
registerCrewMessageRenderers(pi);
|
|
2056
|
+
|
|
2057
|
+
// Round 13 UX: natural-language crew input routing. Lets users type
|
|
2058
|
+
// "crew status" instead of remembering /team-status. Only transforms
|
|
2059
|
+
// interactive input that starts with a crew/team keyword phrase; never
|
|
2060
|
+
// shadows explicit slash commands.
|
|
2061
|
+
registerCrewInputRouter(pi);
|
|
2062
|
+
|
|
2063
|
+
// Round 13 UX: keyboard shortcuts. alt+s opens the settings overlay
|
|
2064
|
+
// (config + theme picker). Keys chosen to avoid Pi's built-in keymap.
|
|
2065
|
+
// (The crew autocomplete provider is registered from session_start once
|
|
2066
|
+
// a UI context is available — see the session_start handler below.)
|
|
2067
|
+
registerCrewShortcuts(pi);
|
|
2038
2068
|
}
|
|
@@ -23,6 +23,7 @@ import { handleTeamManagerCommand } from "../team-manager-command.ts";
|
|
|
23
23
|
import { loadRunManifestById } from "../../state/state-store.ts";
|
|
24
24
|
import type { TeamRunManifest } from "../../state/types.ts";
|
|
25
25
|
import { readCrewAgents } from "../../runtime/crew-agent-records.ts";
|
|
26
|
+
import { suggestRunIds, suggestTaskIds, suggestTeams } from "../command-completions.ts";
|
|
26
27
|
import * as path from "node:path";
|
|
27
28
|
// Heavy UI modules — lazy-loaded because they're only used in /crew commands.
|
|
28
29
|
// RunDashboard (288ms), DurableTextViewer (658ms), Overlays are unnecessary at Pi startup.
|
|
@@ -153,6 +154,72 @@ function teamCommandContext(ctx: ExtensionCommandContext): ExtensionCommandConte
|
|
|
153
154
|
return withSessionId(ctx);
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Open the pi-crew settings overlay (config editor + theme picker).
|
|
159
|
+
*
|
|
160
|
+
* Extracted from the `team-settings` command so it is reusable from a
|
|
161
|
+
* keyboard shortcut. Takes the base `ExtensionContext` (the shortcut
|
|
162
|
+
* handler's context) — uses only `hasUI`, `cwd`, and `ui` fields, so both
|
|
163
|
+
* `ExtensionContext` and `ExtensionCommandContext` satisfy it.
|
|
164
|
+
*/
|
|
165
|
+
export async function openTeamSettingsOverlay(ctx: ExtensionContext): Promise<void> {
|
|
166
|
+
if (!ctx.hasUI) return;
|
|
167
|
+
const [{ updateConfig, parseConfig }, { asCrewTheme }, { createSettingsOverlay }] = await Promise.all([
|
|
168
|
+
import("../../config/config.ts"),
|
|
169
|
+
import("../../ui/theme-adapter.ts"),
|
|
170
|
+
import("../../ui/settings-overlay.ts"),
|
|
171
|
+
]);
|
|
172
|
+
const loaded = loadConfig(ctx.cwd);
|
|
173
|
+
const config = loaded.config as Record<string, unknown>;
|
|
174
|
+
await ctx.ui.custom<undefined>((_tui, _theme, _keybindings, done) => {
|
|
175
|
+
const theme = asCrewTheme(_theme);
|
|
176
|
+
const { overlay } = createSettingsOverlay(config, theme, (id: string, value: unknown) => {
|
|
177
|
+
try {
|
|
178
|
+
const patch: Record<string, unknown> = {};
|
|
179
|
+
const keys = id.split(".");
|
|
180
|
+
let target: Record<string, unknown> = patch;
|
|
181
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
182
|
+
if (!target[keys[i]!] || typeof target[keys[i]!] !== "object") target[keys[i]!] = {};
|
|
183
|
+
target = target[keys[i]!] as Record<string, unknown>;
|
|
184
|
+
}
|
|
185
|
+
target[keys[keys.length - 1]!] = value;
|
|
186
|
+
if (value === undefined) { updateConfig({}, { unsetPaths: [id] }); }
|
|
187
|
+
else { updateConfig(parseConfig(patch)); }
|
|
188
|
+
} catch (error) {
|
|
189
|
+
ctx.ui.notify(`Failed to save: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
190
|
+
}
|
|
191
|
+
}, () => done(undefined), async (action: string, value: unknown) => {
|
|
192
|
+
// Action callbacks (Pi theme switch) write to a different store
|
|
193
|
+
// than pi-crew config (e.g. ~/.pi/agent/settings.json).
|
|
194
|
+
try {
|
|
195
|
+
if (action === "piTheme" && typeof value === "string") {
|
|
196
|
+
// Live theme switch: ctx.ui.setTheme() swaps the global theme,
|
|
197
|
+
// persists it to settings.json, and triggers a UI redraw — no
|
|
198
|
+
// restart needed. Falls back to file-write + restart hint if
|
|
199
|
+
// the live API is unavailable (e.g. non-TUI mode).
|
|
200
|
+
if (typeof ctx.ui.setTheme === "function") {
|
|
201
|
+
const res = ctx.ui.setTheme(value);
|
|
202
|
+
if (res.success) {
|
|
203
|
+
ctx.ui.notify(`Theme: ${value} (applied live)`, "info");
|
|
204
|
+
} else {
|
|
205
|
+
const { setPiTheme } = await import("../../ui/theme-discovery.ts");
|
|
206
|
+
setPiTheme(value);
|
|
207
|
+
ctx.ui.notify(`Theme saved as '${value}' but failed to apply: ${res.error ?? "unknown"}. Restart Pi.`, "warning");
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
const { setPiTheme } = await import("../../ui/theme-discovery.ts");
|
|
211
|
+
setPiTheme(value);
|
|
212
|
+
ctx.ui.notify(`Pi theme set to '${value}'. Restart Pi to apply.`, "info");
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
} catch (error) {
|
|
216
|
+
ctx.ui.notify(`Failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
return overlay;
|
|
220
|
+
}, { overlay: true, overlayOptions: { width: "90%", maxHeight: "85%", anchor: "center" } });
|
|
221
|
+
}
|
|
222
|
+
|
|
156
223
|
async function handleHealthDashboardAction(ctx: ExtensionCommandContext, selection: RunDashboardSelection): Promise<void> {
|
|
157
224
|
const loaded = loadRunManifestById(ctx.cwd, selection.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
158
225
|
if (!loaded) {
|
|
@@ -203,6 +270,8 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
|
|
|
203
270
|
|
|
204
271
|
pi.registerCommand("team-run", {
|
|
205
272
|
description: "Manually start a pi-crew run (agent may also use the team tool autonomously)",
|
|
273
|
+
// Round 13 UX: suggest team names for Tab-completion of the first positional arg.
|
|
274
|
+
getArgumentCompletions: (argumentPrefix: string) => suggestTeams(argumentPrefix),
|
|
206
275
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
207
276
|
const result = await handleTeamTool(parseRunArgs(args), { ...teamCommandContext(ctx), metricRegistry: deps.getMetricRegistry?.(), startForegroundRun: (runner, runId) => deps.startForegroundRun(ctx as ExtensionContext, runner, runId), abortForegroundRun: deps.abortForegroundRun, onRunStarted: undefined });
|
|
208
277
|
await notifyCommandResult(ctx, commandText(result));
|
|
@@ -219,15 +288,21 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
|
|
|
219
288
|
["team-export", "export", "Export a pi-crew run bundle to artifacts/export"],
|
|
220
289
|
["team-cancel", "cancel", "Cancel a pi-crew run"],
|
|
221
290
|
] as const) {
|
|
222
|
-
pi.registerCommand(name, {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
291
|
+
pi.registerCommand(name, {
|
|
292
|
+
description,
|
|
293
|
+
// Round 13 UX: suggest recent run IDs for Tab-completion.
|
|
294
|
+
getArgumentCompletions: (argumentPrefix: string) => suggestRunIds(argumentPrefix),
|
|
295
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
296
|
+
const runId = args.trim() || undefined;
|
|
297
|
+
const result = await handleTeamTool({ action, runId }, { ...teamCommandContext(ctx), getRunSnapshotCache: deps.getRunSnapshotCache });
|
|
298
|
+
await notifyCommandResult(ctx, commandText(result));
|
|
299
|
+
},
|
|
300
|
+
});
|
|
227
301
|
}
|
|
228
302
|
|
|
229
303
|
pi.registerCommand("team-invalidate", {
|
|
230
304
|
description: "Invalidate the snapshot cache for a run so the UI refreshes immediately: <runId>",
|
|
305
|
+
getArgumentCompletions: (argumentPrefix: string) => suggestRunIds(argumentPrefix),
|
|
231
306
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
232
307
|
const runId = args.trim() || undefined;
|
|
233
308
|
if (!runId) {
|
|
@@ -241,6 +316,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
|
|
|
241
316
|
|
|
242
317
|
pi.registerCommand("team-retry", {
|
|
243
318
|
description: "Retry failed/cancelled pi-crew tasks: <runId> [taskId]",
|
|
319
|
+
getArgumentCompletions: (argumentPrefix: string) => suggestRunIds(argumentPrefix),
|
|
244
320
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
245
321
|
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
246
322
|
const runId = tokens.shift();
|
|
@@ -256,6 +332,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
|
|
|
256
332
|
|
|
257
333
|
pi.registerCommand("team-respond", {
|
|
258
334
|
description: "Respond to a waiting pi-crew task: <runId> <taskId|--all> <message>",
|
|
335
|
+
getArgumentCompletions: (argumentPrefix: string) => suggestRunIds(argumentPrefix),
|
|
259
336
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
260
337
|
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
261
338
|
const runId = tokens.shift();
|
|
@@ -269,6 +346,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
|
|
|
269
346
|
|
|
270
347
|
pi.registerCommand("team-follow-up", {
|
|
271
348
|
description: "Send a follow-up prompt to a pi-crew task: <runId> <taskId> <prompt>",
|
|
349
|
+
getArgumentCompletions: (argumentPrefix: string) => suggestRunIds(argumentPrefix),
|
|
272
350
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
273
351
|
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
274
352
|
const runId = tokens.shift();
|
|
@@ -285,6 +363,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
|
|
|
285
363
|
|
|
286
364
|
pi.registerCommand("team-api", {
|
|
287
365
|
description: "Run safe pi-crew API interop operations: <runId> <operation> [key=value]",
|
|
366
|
+
getArgumentCompletions: (argumentPrefix: string) => suggestRunIds(argumentPrefix),
|
|
288
367
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
289
368
|
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
290
369
|
const positional = tokens.filter((token) => !token.includes("=") && !token.startsWith("--"));
|
|
@@ -329,7 +408,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
|
|
|
329
408
|
await notifyCommandResult(ctx, commandText(result));
|
|
330
409
|
} });
|
|
331
410
|
|
|
332
|
-
pi.registerCommand("team-forget", { description: "Forget a pi-crew run by deleting its state and artifacts", handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
411
|
+
pi.registerCommand("team-forget", { description: "Forget a pi-crew run by deleting its state and artifacts", getArgumentCompletions: (argumentPrefix: string) => suggestRunIds(argumentPrefix), handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
333
412
|
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
334
413
|
const runId = tokens.find((token) => !token.startsWith("--"));
|
|
335
414
|
const result = await handleTeamTool({ action: "forget", runId, force: tokens.includes("--force"), confirm: tokens.includes("--confirm") }, teamCommandContext(ctx));
|
|
@@ -340,60 +419,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
|
|
|
340
419
|
description: "View or update pi-crew settings: interactive UI or [list|get <key>|set <key> <value>|unset <key>|path|scope]",
|
|
341
420
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
342
421
|
if (ctx.hasUI && !args.trim()) {
|
|
343
|
-
|
|
344
|
-
import("../../config/config.ts"),
|
|
345
|
-
import("../../ui/theme-adapter.ts"),
|
|
346
|
-
import("../../ui/settings-overlay.ts"),
|
|
347
|
-
]);
|
|
348
|
-
const loaded = loadConfig(ctx.cwd);
|
|
349
|
-
const config = loaded.config as Record<string, unknown>;
|
|
350
|
-
await ctx.ui.custom<undefined>((_tui, _theme, _keybindings, done) => {
|
|
351
|
-
const theme = asCrewTheme(_theme);
|
|
352
|
-
const { overlay } = createSettingsOverlay(config, theme, (id: string, value: unknown) => {
|
|
353
|
-
try {
|
|
354
|
-
const patch: Record<string, unknown> = {};
|
|
355
|
-
const keys = id.split(".");
|
|
356
|
-
let target: Record<string, unknown> = patch;
|
|
357
|
-
for (let i = 0; i < keys.length - 1; i++) {
|
|
358
|
-
if (!target[keys[i]!] || typeof target[keys[i]!] !== "object") target[keys[i]!] = {};
|
|
359
|
-
target = target[keys[i]!] as Record<string, unknown>;
|
|
360
|
-
}
|
|
361
|
-
target[keys[keys.length - 1]!] = value;
|
|
362
|
-
if (value === undefined) { updateConfig({}, { unsetPaths: [id] }); }
|
|
363
|
-
else { updateConfig(parseConfig(patch)); }
|
|
364
|
-
} catch (error) {
|
|
365
|
-
ctx.ui.notify(`Failed to save: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
366
|
-
}
|
|
367
|
-
}, () => done(undefined), async (action: string, value: unknown) => {
|
|
368
|
-
// Action callbacks (Pi theme switch) write to a different store
|
|
369
|
-
// than pi-crew config (e.g. ~/.pi/agent/settings.json).
|
|
370
|
-
try {
|
|
371
|
-
if (action === "piTheme" && typeof value === "string") {
|
|
372
|
-
// Live theme switch: ctx.ui.setTheme() swaps the global theme,
|
|
373
|
-
// persists it to settings.json, and triggers a UI redraw — no
|
|
374
|
-
// restart needed. Falls back to file-write + restart hint if
|
|
375
|
-
// the live API is unavailable (e.g. non-TUI mode).
|
|
376
|
-
if (typeof ctx.ui.setTheme === "function") {
|
|
377
|
-
const res = ctx.ui.setTheme(value);
|
|
378
|
-
if (res.success) {
|
|
379
|
-
ctx.ui.notify(`Theme: ${value} (applied live)`, "info");
|
|
380
|
-
} else {
|
|
381
|
-
const { setPiTheme } = await import("../../ui/theme-discovery.ts");
|
|
382
|
-
setPiTheme(value);
|
|
383
|
-
ctx.ui.notify(`Theme saved as '${value}' but failed to apply: ${res.error ?? "unknown"}. Restart Pi.`, "warning");
|
|
384
|
-
}
|
|
385
|
-
} else {
|
|
386
|
-
const { setPiTheme } = await import("../../ui/theme-discovery.ts");
|
|
387
|
-
setPiTheme(value);
|
|
388
|
-
ctx.ui.notify(`Pi theme set to '${value}'. Restart Pi to apply.`, "info");
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
} catch (error) {
|
|
392
|
-
ctx.ui.notify(`Failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
393
|
-
}
|
|
394
|
-
});
|
|
395
|
-
return overlay;
|
|
396
|
-
}, { overlay: true, overlayOptions: { width: "90%", maxHeight: "85%", anchor: "center" } });
|
|
422
|
+
await openTeamSettingsOverlay(ctx);
|
|
397
423
|
return;
|
|
398
424
|
}
|
|
399
425
|
const result = await handleTeamTool({ action: "settings", config: { args: args.trim() } }, teamCommandContext(ctx));
|
|
@@ -415,7 +441,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
|
|
|
415
441
|
|
|
416
442
|
pi.registerCommand("team-cleanup", { description: "Open a simple pi-crew interactive manager", handler: handleTeamManagerCommand });
|
|
417
443
|
|
|
418
|
-
pi.registerCommand("team-result", { description: "Open a pi-crew agent result viewer: <runId> [taskId]", handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
444
|
+
pi.registerCommand("team-result", { description: "Open a pi-crew agent result viewer: <runId> [taskId]", getArgumentCompletions: async (argumentPrefix: string) => { const parts = argumentPrefix.trim().split(/\s+/); return parts.length <= 1 ? suggestRunIds(parts[0] ?? "") : suggestTaskIds(parts[0] ?? "", parts[1] ?? ""); }, handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
419
445
|
const [runId, rawTaskId] = args.trim().split(/\s+/).filter(Boolean);
|
|
420
446
|
const selected = await selectAgentTask(ctx, runId, rawTaskId);
|
|
421
447
|
const loaded = selected ? loadRunManifestById(ctx.cwd, selected.runId) : undefined; // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
@@ -430,7 +456,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
|
|
|
430
456
|
await notifyCommandResult(ctx, commandText(result));
|
|
431
457
|
} });
|
|
432
458
|
|
|
433
|
-
pi.registerCommand("team-transcript", { description: "Open a pi-crew transcript viewer: <runId> [taskId]", handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
459
|
+
pi.registerCommand("team-transcript", { description: "Open a pi-crew transcript viewer: <runId> [taskId]", getArgumentCompletions: async (argumentPrefix: string) => { const parts = argumentPrefix.trim().split(/\s+/); return parts.length <= 1 ? suggestRunIds(parts[0] ?? "") : suggestTaskIds(parts[0] ?? "", parts[1] ?? ""); }, handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
434
460
|
const [runId, taskId] = args.trim().split(/\s+/).filter(Boolean);
|
|
435
461
|
if (await openTranscriptViewer(ctx, runId, taskId)) return;
|
|
436
462
|
const result = await handleTeamTool({ action: "api", runId, config: { operation: "read-agent-transcript", agentId: taskId } }, teamCommandContext(ctx));
|
|
@@ -46,6 +46,15 @@ import {
|
|
|
46
46
|
runtimeResolutionState,
|
|
47
47
|
} from "./runtime-resolver.ts";
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Debug logger gated behind PI_CREW_DEBUG env var. Writes to background.log
|
|
51
|
+
* (console is redirected there). Eliminates log noise in normal operation
|
|
52
|
+
* while keeping diagnostics available when explicitly enabled.
|
|
53
|
+
*/
|
|
54
|
+
function debugLog(message: string): void {
|
|
55
|
+
if (process.env.PI_CREW_DEBUG) console.log(message);
|
|
56
|
+
}
|
|
57
|
+
|
|
49
58
|
/**
|
|
50
59
|
* Heartbeat mechanism: periodically write a heartbeat file so the stale reconciler
|
|
51
60
|
* can distinguish "process died" from "process still alive but quiet".
|
|
@@ -222,8 +231,7 @@ function runCleanup(
|
|
|
222
231
|
exitDueToRejection: boolean,
|
|
223
232
|
eventsPath?: string,
|
|
224
233
|
): void {
|
|
225
|
-
console.log(
|
|
226
|
-
`[background-runner] DEBUG: runCleanup, exitDueToRejection=${exitDueToRejection}`,
|
|
234
|
+
console.log(`[background-runner] runCleanup, exitDueToRejection=${exitDueToRejection}`,
|
|
227
235
|
);
|
|
228
236
|
stopInterruptGuard();
|
|
229
237
|
stopParentGuard();
|
|
@@ -492,8 +500,7 @@ async function main(): Promise<void> {
|
|
|
492
500
|
runId: manifest.runId,
|
|
493
501
|
data: { pid: process.pid },
|
|
494
502
|
});
|
|
495
|
-
|
|
496
|
-
`[background-runner] DEBUG: async.started written, pid=${process.pid}`,
|
|
503
|
+
debugLog(`[background-runner] async.started written, pid=${process.pid}`,
|
|
497
504
|
);
|
|
498
505
|
writeAsyncStartMarker(manifest, {
|
|
499
506
|
pid: process.pid,
|
|
@@ -505,7 +512,7 @@ async function main(): Promise<void> {
|
|
|
505
512
|
manifest.runId,
|
|
506
513
|
);
|
|
507
514
|
const stopInterruptGuard = startInterruptGuard(manifest, abortController, stopParentGuard);
|
|
508
|
-
|
|
515
|
+
debugLog(`[background-runner] heartbeat+interrupt guard started`);
|
|
509
516
|
// NOTE: Keep-alive interval is NOT unref'd (unlike heartbeat and interrupt
|
|
510
517
|
// guard intervals which ARE unref'd). This is intentional — during jiti
|
|
511
518
|
// compilation of team-runner.ts, the event loop must not drain prematurely.
|
|
@@ -514,25 +521,22 @@ async function main(): Promise<void> {
|
|
|
514
521
|
const keepAlive = setInterval(() => {}, 5000);
|
|
515
522
|
|
|
516
523
|
try {
|
|
517
|
-
|
|
524
|
+
debugLog(`[background-runner] about to call discoverAgents`);
|
|
518
525
|
const agents = allAgents(discoverAgents(cwd));
|
|
519
|
-
|
|
520
|
-
`[background-runner] DEBUG: discoverAgents done, ${agents.length} agents`,
|
|
526
|
+
debugLog(`[background-runner] discoverAgents done, ${agents.length} agents`,
|
|
521
527
|
);
|
|
522
528
|
try { fs.fsyncSync(fs.openSync(manifest.eventsPath, "a")); } catch { /* best-effort */ } // FORCE flush so we see this before death
|
|
523
|
-
|
|
524
|
-
`[background-runner] DEBUG: calling directTeamAndWorkflowFromRun`,
|
|
529
|
+
debugLog(`[background-runner] calling directTeamAndWorkflowFromRun`,
|
|
525
530
|
);
|
|
526
531
|
const direct = directTeamAndWorkflowFromRun(manifest, tasks, agents);
|
|
527
|
-
|
|
532
|
+
debugLog(`[background-runner] direct done, finding team`);
|
|
528
533
|
const team =
|
|
529
534
|
direct?.team ??
|
|
530
535
|
allTeams(discoverTeams(cwd)).find(
|
|
531
536
|
(candidate) => candidate.name === manifest.team,
|
|
532
537
|
);
|
|
533
538
|
if (!team) throw new Error(`Team '${manifest.team}' not found.`);
|
|
534
|
-
|
|
535
|
-
`[background-runner] DEBUG: team=${team.name}, finding workflow`,
|
|
539
|
+
debugLog(`[background-runner] team=${team.name}, finding workflow`,
|
|
536
540
|
);
|
|
537
541
|
const baseWorkflow =
|
|
538
542
|
direct?.workflow ??
|
|
@@ -541,9 +545,9 @@ async function main(): Promise<void> {
|
|
|
541
545
|
);
|
|
542
546
|
if (!baseWorkflow)
|
|
543
547
|
throw new Error(`Workflow '${manifest.workflow ?? ""}' not found.`);
|
|
544
|
-
|
|
548
|
+
debugLog(`[background-runner] workflow=${baseWorkflow.name}`);
|
|
545
549
|
const workflow = expandParallelResearchWorkflow(baseWorkflow, cwd);
|
|
546
|
-
|
|
550
|
+
debugLog(`[background-runner] loading config`);
|
|
547
551
|
const loadedConfig = loadConfig(cwd);
|
|
548
552
|
const runConfig =
|
|
549
553
|
manifest.runConfig &&
|
|
@@ -597,7 +601,7 @@ async function main(): Promise<void> {
|
|
|
597
601
|
// NOTE: abortController is already created above (before heartbeat/interrupt guard start)
|
|
598
602
|
// so it is available here and its signal is passed through to executeTeamRun → child-pi.
|
|
599
603
|
|
|
600
|
-
|
|
604
|
+
debugLog(`[background-runner] calling executeTeamRun`);
|
|
601
605
|
let result;
|
|
602
606
|
try {
|
|
603
607
|
result = await executeTeamRun({
|
|
@@ -615,15 +619,12 @@ async function main(): Promise<void> {
|
|
|
615
619
|
workspaceId: manifest.ownerSessionId ?? manifest.cwd,
|
|
616
620
|
signal: abortController.signal,
|
|
617
621
|
});
|
|
618
|
-
console.log(
|
|
619
|
-
`[background-runner] DEBUG: executeTeamRun returned, status=${result.manifest.status}`,
|
|
622
|
+
console.log(`[background-runner] executeTeamRun returned, status=${result.manifest.status}`,
|
|
620
623
|
);
|
|
621
624
|
} catch (execError) {
|
|
622
|
-
console.log(
|
|
623
|
-
`[background-runner] DEBUG: executeTeamRun THREW: ${execError instanceof Error ? execError.message : String(execError)}`,
|
|
625
|
+
console.log(`[background-runner] executeTeamRun THREW: ${execError instanceof Error ? execError.message : String(execError)}`,
|
|
624
626
|
);
|
|
625
|
-
console.log(
|
|
626
|
-
`[background-runner] DEBUG: stack: ${execError instanceof Error ? execError.stack : "N/A"}`,
|
|
627
|
+
console.log(`[background-runner] stack: ${execError instanceof Error ? execError.stack : "N/A"}`,
|
|
627
628
|
);
|
|
628
629
|
throw execError;
|
|
629
630
|
}
|
|
@@ -634,8 +635,7 @@ async function main(): Promise<void> {
|
|
|
634
635
|
runId: manifest.runId,
|
|
635
636
|
data: { status: manifest.status, tasks: tasks.length },
|
|
636
637
|
});
|
|
637
|
-
console.log(
|
|
638
|
-
`[background-runner] DEBUG: async.completed written, status=${manifest.status}`,
|
|
638
|
+
console.log(`[background-runner] async.completed written, status=${manifest.status}`,
|
|
639
639
|
);
|
|
640
640
|
if (
|
|
641
641
|
manifest.status === "failed" ||
|
|
@@ -682,8 +682,7 @@ async function main(): Promise<void> {
|
|
|
682
682
|
message,
|
|
683
683
|
});
|
|
684
684
|
process.exitCode = 1;
|
|
685
|
-
console.log(
|
|
686
|
-
`[background-runner] DEBUG: catch block, error=${error instanceof Error ? error.message : String(error)}`,
|
|
685
|
+
console.log(`[background-runner] catch block, error=${error instanceof Error ? error.message : String(error)}`,
|
|
687
686
|
);
|
|
688
687
|
} finally {
|
|
689
688
|
// FIX Issue #4: Use shared runCleanup() function for consistent cleanup
|
|
@@ -284,31 +284,36 @@ export const __test__renameWithRetryAsync = renameWithRetryAsync;
|
|
|
284
284
|
|
|
285
285
|
export function atomicWriteFile(filePath: string, content: string, expectedHash?: string): void {
|
|
286
286
|
if (!isSymlinkSafePath(filePath)) throw new Error(`Refusing to write: target is a symlink or inside untrusted directory: ${filePath}`);
|
|
287
|
-
// On Windows
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
|
|
292
|
-
|
|
287
|
+
// On Windows the parent directory may be referenced via a short-name alias
|
|
288
|
+
// (e.g. RUNNER~1 vs runneradmin). mkdirSync on one form can succeed while
|
|
289
|
+
// openSync on another form fails with ENOENT. We therefore ensure the dir
|
|
290
|
+
// exists, trying the canonical (realpathSync.native) form as a fallback on
|
|
291
|
+
// EPERM.
|
|
292
|
+
//
|
|
293
|
+
// CRITICAL: we NEVER rewrite `filePath`. The caller (e.g. createRunManifest)
|
|
294
|
+
// builds filePath via canonicalizePath() (realpathSync.native) and will later
|
|
295
|
+
// stat/read it back at that exact path. If we rewrote filePath to a different
|
|
296
|
+
// realpath form here, the written file would land on a path that diverges
|
|
297
|
+
// from the caller's path — making existsSync/readFileSync(callerPath) fail
|
|
298
|
+
// with ENOENT even though the write "succeeded". Writing to the original
|
|
299
|
+
// filePath guarantees the caller can always find the file it just wrote.
|
|
300
|
+
const canonicalize = (p: string): string => {
|
|
293
301
|
try {
|
|
294
|
-
const
|
|
295
|
-
|
|
302
|
+
const r = fs.realpathSync.native(p);
|
|
303
|
+
return r.startsWith("\\\\?\\") ? r.slice(4) : r;
|
|
296
304
|
} catch {
|
|
297
|
-
|
|
305
|
+
try { return fs.realpathSync(p); } catch { return p; }
|
|
298
306
|
}
|
|
299
|
-
|
|
300
|
-
|
|
307
|
+
};
|
|
308
|
+
const dirPath = path.dirname(filePath);
|
|
301
309
|
try {
|
|
302
310
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
303
311
|
} catch (error) {
|
|
304
312
|
if (process.platform === "win32" && (error as NodeJS.ErrnoException).code === "EPERM") {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
dirPath = realDir;
|
|
310
|
-
}
|
|
311
|
-
} catch { /* ignore – will fail at write time with better error */ }
|
|
313
|
+
// mkdir hit a short/long-name alias wall — retry with the canonical
|
|
314
|
+
// form. The write itself still targets the original filePath below.
|
|
315
|
+
const realDir = canonicalize(dirPath);
|
|
316
|
+
if (realDir !== dirPath) fs.mkdirSync(realDir, { recursive: true });
|
|
312
317
|
} else {
|
|
313
318
|
throw error;
|
|
314
319
|
}
|
|
@@ -113,15 +113,20 @@ export function compactEventLog(eventsPath: string, config?: Partial<RotationCon
|
|
|
113
113
|
const missingEvents = kept.filter((e) => e.metadata?.seq === undefined || !afterSeqs.has(e.metadata.seq));
|
|
114
114
|
let recoveredCount = 0;
|
|
115
115
|
let recoveryFailed = false;
|
|
116
|
-
|
|
116
|
+
if (missingEvents.length > 0) {
|
|
117
|
+
// BUGFIX (Round 12 C2): the previous loop called atomicWriteFile PER event,
|
|
118
|
+
// which REPLACES the entire file each iteration — destroying the
|
|
119
|
+
// compacted log and all previously-recovered events, leaving only the
|
|
120
|
+
// LAST missing event. FIX: accumulate all missing events into one
|
|
121
|
+
// string and append in a single write (appendFileSync appends without
|
|
122
|
+
// destroying existing content).
|
|
123
|
+
const recoveryLines = missingEvents.map((e) => JSON.stringify(e) + "\n").join("");
|
|
117
124
|
try {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
recoveredCount++;
|
|
125
|
+
fs.appendFileSync(eventsPath, recoveryLines);
|
|
126
|
+
recoveredCount = missingEvents.length;
|
|
121
127
|
} catch (err) {
|
|
122
128
|
recoveryFailed = true;
|
|
123
|
-
|
|
124
|
-
logInternalError("event-log-rotation.recovery", err, `eventsPath=${eventsPath} lostEvent=${JSON.stringify(event).slice(0, 100)}`);
|
|
129
|
+
logInternalError("event-log-rotation.recovery", err, `eventsPath=${eventsPath} lostEvents=${missingEvents.length}`);
|
|
125
130
|
}
|
|
126
131
|
}
|
|
127
132
|
return {
|
|
@@ -159,12 +164,24 @@ export function rotateEventLog(eventsPath: string): boolean {
|
|
|
159
164
|
return withEventLogLockSync(eventsPath, () => {
|
|
160
165
|
try {
|
|
161
166
|
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
162
|
-
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
fs.
|
|
167
|
+
let archivePath = `${eventsPath}.${ts}.archive.jsonl`;
|
|
168
|
+
// Round 12: avoid timestamp collisions when two rotations happen within
|
|
169
|
+
// the same millisecond (copyFileSync would silently overwrite the
|
|
170
|
+
// first archive). Append a counter until the path is free.
|
|
171
|
+
let collision = 1;
|
|
172
|
+
while (fs.existsSync(archivePath)) {
|
|
173
|
+
archivePath = `${eventsPath}.${ts}.${collision}.archive.jsonl`;
|
|
174
|
+
collision++;
|
|
175
|
+
}
|
|
176
|
+
// BUGFIX (Round 12 C1): the previous order (atomicWriteFile empty THEN
|
|
177
|
+
// rename) destroyed ALL events — atomicWriteFile replaces the file
|
|
178
|
+
// in place, so the rename then moved an EMPTY file to the archive.
|
|
179
|
+
// FIX: copy current content to the archive first (archive is populated,
|
|
180
|
+
// original still intact), then truncate the original to empty in place.
|
|
181
|
+
// copyFileSync + writeFileSync("") ensures eventsPath ALWAYS exists
|
|
182
|
+
// (no missing-file window for concurrent readers).
|
|
183
|
+
fs.copyFileSync(eventsPath, archivePath);
|
|
184
|
+
fs.writeFileSync(eventsPath, "", "utf-8");
|
|
168
185
|
return true;
|
|
169
186
|
} catch (error) {
|
|
170
187
|
logInternalError("event-log.rotate", error, `eventsPath=${eventsPath}`);
|
package/src/state/mailbox.ts
CHANGED
|
@@ -408,10 +408,16 @@ export function appendMailboxMessage(manifest: TeamRunManifest, message: Omit<Ma
|
|
|
408
408
|
// 3.3 — rotate mailbox file if it has grown past 10 MB. Cheap stat
|
|
409
409
|
// check; rotates at most once per append.
|
|
410
410
|
rotateMailboxFileIfNeeded(mailboxFile(manifest, complete.direction, complete.taskId));
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
delivery.
|
|
414
|
-
|
|
411
|
+
// BUGFIX (Round 12 C3): the delivery.json read-modify-write below was
|
|
412
|
+
// UNLOCKED, so concurrent appendMailboxMessage calls could interleave and
|
|
413
|
+
// clobber each other's delivery entries (lost-update race). FIX: wrap the
|
|
414
|
+
// entire read-modify-write in a file lock on the delivery file.
|
|
415
|
+
withFileLockSync(deliveryFile(manifest, true), () => {
|
|
416
|
+
const delivery = readDeliveryState(manifest);
|
|
417
|
+
delivery.messages[complete.id] = complete.status;
|
|
418
|
+
delivery.updatedAt = createdAt;
|
|
419
|
+
writeDeliveryState(manifest, delivery);
|
|
420
|
+
});
|
|
415
421
|
return complete;
|
|
416
422
|
}
|
|
417
423
|
|
|
@@ -437,12 +443,16 @@ export function readMailboxMessage(manifest: TeamRunManifest, messageId: string)
|
|
|
437
443
|
}
|
|
438
444
|
|
|
439
445
|
export function acknowledgeMailboxMessage(manifest: TeamRunManifest, messageId: string): MailboxDeliveryState {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
+
// BUGFIX (Round 12 I6): unlocked read-modify-write on delivery.json could
|
|
447
|
+
// clobber concurrent appends. FIX: wrap in a file lock.
|
|
448
|
+
return withFileLockSync(deliveryFile(manifest, true), () => {
|
|
449
|
+
const delivery = readDeliveryState(manifest);
|
|
450
|
+
if (!delivery.messages[messageId]) throw new Error(`Mailbox message '${messageId}' not found.`);
|
|
451
|
+
delivery.messages[messageId] = "acknowledged";
|
|
452
|
+
delivery.updatedAt = new Date().toISOString();
|
|
453
|
+
writeDeliveryState(manifest, delivery);
|
|
454
|
+
return delivery;
|
|
455
|
+
});
|
|
446
456
|
}
|
|
447
457
|
|
|
448
458
|
/**
|
|
@@ -503,14 +513,18 @@ export function updateMailboxMessageReply(manifest: TeamRunManifest, originalMes
|
|
|
503
513
|
}
|
|
504
514
|
|
|
505
515
|
export function replayPendingMailboxMessages(manifest: TeamRunManifest): MailboxReplayResult {
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
516
|
+
// BUGFIX (Round 12 I6): unlocked read-modify-write on delivery.json could
|
|
517
|
+
// clobber concurrent appends/acknowledgments. FIX: wrap in a file lock.
|
|
518
|
+
return withFileLockSync(deliveryFile(manifest, true), () => {
|
|
519
|
+
const delivery = readDeliveryState(manifest);
|
|
520
|
+
const pending = readAllInboxMessages(manifest).filter((message) => message.status !== "acknowledged" && delivery.messages[message.id] !== "acknowledged");
|
|
521
|
+
if (!pending.length) return { messages: [], updatedAt: delivery.updatedAt };
|
|
522
|
+
const updatedAt = new Date().toISOString();
|
|
523
|
+
for (const message of pending) delivery.messages[message.id] = "delivered";
|
|
524
|
+
delivery.updatedAt = updatedAt;
|
|
525
|
+
writeDeliveryState(manifest, delivery);
|
|
526
|
+
return { messages: pending, updatedAt };
|
|
527
|
+
});
|
|
514
528
|
}
|
|
515
529
|
|
|
516
530
|
export function validateMailbox(manifest: TeamRunManifest, options: { repair?: boolean; signal?: AbortSignal } = {}): MailboxValidationReport {
|
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
* Actual capture hooks into the lifecycle events (Pattern 12).
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { readFileSync, existsSync, appendFileSync } from "node:fs";
|
|
12
12
|
import * as fs from "node:fs";
|
|
13
13
|
import * as path from "node:path";
|
|
14
14
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
15
|
+
import { atomicWriteJson } from "./atomic-write.ts";
|
|
15
16
|
|
|
16
17
|
// ── Types ────────────────────────────────────────────────────────────────
|
|
17
18
|
|
|
@@ -153,12 +154,13 @@ export class ObservationStore {
|
|
|
153
154
|
*/
|
|
154
155
|
save(): void {
|
|
155
156
|
try {
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
157
|
+
// BUGFIX (Round 12 I4): use atomicWriteJson (temp-file + rename) instead
|
|
158
|
+
// of raw writeFileSync, so a crash mid-write cannot leave a truncated /
|
|
159
|
+
// empty file that breaks load() on restart.
|
|
160
|
+
atomicWriteJson(this.storePath, {
|
|
159
161
|
observations: this.observations,
|
|
160
162
|
compressed: this.compressed,
|
|
161
|
-
}
|
|
163
|
+
});
|
|
162
164
|
} catch (error) {
|
|
163
165
|
logInternalError("observation-store.save", error, `path=${this.storePath}`);
|
|
164
166
|
}
|
package/src/state/state-store.ts
CHANGED
|
@@ -213,8 +213,14 @@ export function createRunManifest(params: {
|
|
|
213
213
|
// throw to ensure manifest and tasks are always consistent.
|
|
214
214
|
const result = saveManifestAndTasksAtomicSync(manifest, tasks);
|
|
215
215
|
if (!result.manifestWritten || !result.tasksWritten) {
|
|
216
|
-
|
|
217
|
-
|
|
216
|
+
// Surface the underlying error message (result.error is String(err) from
|
|
217
|
+
// saveManifestAndTasksAtomicSync). Passing it through errors.fileWrite as a
|
|
218
|
+
// fake ErrnoException loses the message (reads .code → undefined →
|
|
219
|
+
// "unknown"). Include it explicitly in the thrown message so CI logs and
|
|
220
|
+
// production callers can see WHY the write failed instead of ": unknown".
|
|
221
|
+
const cause = result.error ? `: ${result.error}` : "";
|
|
222
|
+
throw errors.fileWrite(paths.stateRoot, { code: "EWRITEFAIL" } as NodeJS.ErrnoException)
|
|
223
|
+
.withContext(`saveManifestAndTasksAtomicSync: manifestWritten=${result.manifestWritten}, tasksWritten=${result.tasksWritten}${cause}`);
|
|
218
224
|
}
|
|
219
225
|
appendEvent(paths.eventsPath, {
|
|
220
226
|
type: "run.created",
|