pi-atuin 0.1.2 → 0.1.6
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 +33 -7
- package/atuin-cli.ts +106 -0
- package/bash-exit-code.ts +20 -0
- package/bash-tracker.ts +87 -0
- package/commands.ts +48 -0
- package/config.ts +55 -0
- package/history-store.ts +27 -23
- package/index.ts +17 -1
- package/package.json +1 -1
- package/test/bash-tracker.test.ts +60 -0
- package/test/commands.test.ts +18 -0
- package/test/config.test.ts +9 -0
package/README.md
CHANGED
|
@@ -35,30 +35,56 @@ curl --proto '=https' --tlsv1.2 -LsSf https://setup.atuin.sh | sh
|
|
|
35
35
|
|
|
36
36
|
# Register / login
|
|
37
37
|
atuin register -u <username> -e <email>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
pi-atuin records history into atuin automatically — no `atuin hook install pi` needed. That hook registers a conflicting `bash` tool and breaks extensions like [pi-tool-display](https://github.com/MasuRii/pi-tool-display).
|
|
41
|
+
|
|
42
|
+
If you previously ran `atuin hook install pi`, remove the hook extension:
|
|
38
43
|
|
|
39
|
-
|
|
40
|
-
|
|
44
|
+
```bash
|
|
45
|
+
rm ~/.pi/agent/extensions/atuin.ts
|
|
46
|
+
pi /reload
|
|
41
47
|
```
|
|
42
48
|
|
|
43
|
-
With
|
|
49
|
+
With atuin installed, history syncs bidirectionally. Search in either pi (↑) or your shell (`atuin search`).
|
|
44
50
|
|
|
45
51
|
### JSONL-only (no atuin)
|
|
46
52
|
|
|
47
|
-
No setup needed.
|
|
53
|
+
No setup needed. Pi prompts are stored in `~/.pi/agent/pi-history.jsonl`.
|
|
54
|
+
|
|
55
|
+
## What gets recorded
|
|
56
|
+
|
|
57
|
+
| Source | JSONL (↑ search) | Atuin DB |
|
|
58
|
+
|--------|------------------|----------|
|
|
59
|
+
| Your pi prompts | Yes | Yes |
|
|
60
|
+
| Agent `bash` tool calls | No | Yes (default on) |
|
|
61
|
+
| Your `!` shell commands | No | Yes |
|
|
62
|
+
|
|
63
|
+
- **Prompts** always go to JSONL and atuin (when installed).
|
|
64
|
+
- **Agent bash** is recorded to atuin only (not mixed into ↑ prompt search). On by default; `/atuin record-agent-history` toggles it.
|
|
65
|
+
- **`!` commands** are your direct shell input in pi — tracked in atuin, not JSONL.
|
|
48
66
|
|
|
49
67
|
## Usage
|
|
50
68
|
|
|
69
|
+
### History search
|
|
70
|
+
|
|
51
71
|
1. Press **↑** when your input is empty (or cursor is on the first line)
|
|
52
72
|
2. Type to fuzzy-filter
|
|
53
73
|
3. **↑↓** to navigate, **Enter** to select, **Esc** to cancel
|
|
54
74
|
|
|
55
|
-
|
|
75
|
+
### Commands
|
|
76
|
+
|
|
77
|
+
```text
|
|
78
|
+
/atuin record-agent-history # toggle agent bash → atuin, shows on/off
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Settings persist in `~/.pi/agent/extensions/pi-atuin/config.json`.
|
|
56
82
|
|
|
57
83
|
## Privacy
|
|
58
84
|
|
|
59
85
|
- History file permissions: `600` (owner-only)
|
|
60
|
-
- Max 1,000 entries, auto-trimmed
|
|
61
|
-
- Atuin calls use `execFile` (no shell injection)
|
|
86
|
+
- Max 1,000 JSONL entries, auto-trimmed
|
|
87
|
+
- Atuin calls use `execFile` / `pi.exec` (no shell injection)
|
|
62
88
|
- No hardcoded credentials or keys
|
|
63
89
|
|
|
64
90
|
## License
|
package/atuin-cli.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Atuin CLI helpers for pi-atuin.
|
|
3
|
+
*
|
|
4
|
+
* Uses execFile (history-store) or pi.exec (bash-tracker) via a thin adapter.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
|
|
13
|
+
export const ATUIN_AUTHOR = "pi";
|
|
14
|
+
export const ATUIN_TIMEOUT_MS = 10_000;
|
|
15
|
+
|
|
16
|
+
export type AtuinExecResult = {
|
|
17
|
+
code: number;
|
|
18
|
+
stdout: string;
|
|
19
|
+
stderr: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type AtuinExec = (
|
|
23
|
+
command: string,
|
|
24
|
+
args: string[],
|
|
25
|
+
options?: { cwd?: string; timeout?: number },
|
|
26
|
+
) => Promise<AtuinExecResult>;
|
|
27
|
+
|
|
28
|
+
export function createExecFileAtuinExec(): AtuinExec {
|
|
29
|
+
return async (command, args, options) => {
|
|
30
|
+
try {
|
|
31
|
+
const { stdout, stderr } = await execFileAsync(command, args, {
|
|
32
|
+
cwd: options?.cwd,
|
|
33
|
+
timeout: options?.timeout ?? ATUIN_TIMEOUT_MS,
|
|
34
|
+
});
|
|
35
|
+
return { code: 0, stdout: String(stdout), stderr: String(stderr) };
|
|
36
|
+
} catch (err: unknown) {
|
|
37
|
+
const e = err as { code?: number; stdout?: string; stderr?: string };
|
|
38
|
+
return {
|
|
39
|
+
code: typeof e.code === "number" ? e.code : 1,
|
|
40
|
+
stdout: String(e.stdout ?? ""),
|
|
41
|
+
stderr: String(e.stderr ?? ""),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createPiAtuinExec(pi: ExtensionAPI): AtuinExec {
|
|
48
|
+
return async (command, args, options) => {
|
|
49
|
+
const result = await pi.exec(command, args, {
|
|
50
|
+
cwd: options?.cwd,
|
|
51
|
+
timeout: options?.timeout ?? ATUIN_TIMEOUT_MS,
|
|
52
|
+
});
|
|
53
|
+
return {
|
|
54
|
+
code: result.code,
|
|
55
|
+
stdout: result.stdout,
|
|
56
|
+
stderr: result.stderr,
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function startAtuinHistory(
|
|
62
|
+
exec: AtuinExec,
|
|
63
|
+
cwd: string,
|
|
64
|
+
command: string,
|
|
65
|
+
): Promise<string | undefined> {
|
|
66
|
+
try {
|
|
67
|
+
const result = await exec(
|
|
68
|
+
"atuin",
|
|
69
|
+
["history", "start", "--author", ATUIN_AUTHOR, "--", command],
|
|
70
|
+
{ cwd, timeout: ATUIN_TIMEOUT_MS },
|
|
71
|
+
);
|
|
72
|
+
if (result.code !== 0) return undefined;
|
|
73
|
+
const id = result.stdout.trim();
|
|
74
|
+
return id.length > 0 ? id : undefined;
|
|
75
|
+
} catch {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function endAtuinHistory(
|
|
81
|
+
exec: AtuinExec,
|
|
82
|
+
cwd: string,
|
|
83
|
+
historyId: string,
|
|
84
|
+
exitCode: number,
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
try {
|
|
87
|
+
await exec(
|
|
88
|
+
"atuin",
|
|
89
|
+
["history", "end", historyId, "--exit", String(exitCode)],
|
|
90
|
+
{ cwd, timeout: ATUIN_TIMEOUT_MS },
|
|
91
|
+
);
|
|
92
|
+
} catch {
|
|
93
|
+
// Never block pi on atuin failures.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function recordAtuinCommand(
|
|
98
|
+
exec: AtuinExec,
|
|
99
|
+
cwd: string,
|
|
100
|
+
command: string,
|
|
101
|
+
exitCode = 0,
|
|
102
|
+
): Promise<void> {
|
|
103
|
+
const historyId = await startAtuinHistory(exec, cwd, command);
|
|
104
|
+
if (!historyId) return;
|
|
105
|
+
await endAtuinHistory(exec, cwd, historyId, exitCode);
|
|
106
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Derive exit code from a bash tool_result event. */
|
|
2
|
+
export function parseBashExitCode(event: {
|
|
3
|
+
isError: boolean;
|
|
4
|
+
content: Array<{ type: string; text?: string }>;
|
|
5
|
+
}): number {
|
|
6
|
+
if (!event.isError) return 0;
|
|
7
|
+
|
|
8
|
+
const text = event.content
|
|
9
|
+
.filter((part) => part.type === "text")
|
|
10
|
+
.map((part) => part.text ?? "")
|
|
11
|
+
.join("\n");
|
|
12
|
+
|
|
13
|
+
const exitMatch = text.match(/Command exited with code (\d+)/);
|
|
14
|
+
if (exitMatch) return Number.parseInt(exitMatch[1]!, 10);
|
|
15
|
+
|
|
16
|
+
if (text.includes("Command aborted")) return 130;
|
|
17
|
+
if (text.includes("Command timed out")) return 124;
|
|
18
|
+
|
|
19
|
+
return 1;
|
|
20
|
+
}
|
package/bash-tracker.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event-based bash → atuin tracking for pi-atuin.
|
|
3
|
+
*
|
|
4
|
+
* Replaces `atuin hook install pi` without registering a conflicting `bash` tool.
|
|
5
|
+
* Compatible with pi-tool-display and other extensions that own bash rendering.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { createLocalBashOperations, isToolCallEventType } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import {
|
|
11
|
+
createPiAtuinExec,
|
|
12
|
+
endAtuinHistory,
|
|
13
|
+
startAtuinHistory,
|
|
14
|
+
} from "./atuin-cli.js";
|
|
15
|
+
import { parseBashExitCode } from "./bash-exit-code.js";
|
|
16
|
+
import type { ConfigGetter } from "./commands.js";
|
|
17
|
+
|
|
18
|
+
interface PendingBashHistory {
|
|
19
|
+
historyId: string;
|
|
20
|
+
cwd: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function registerBashTracker(pi: ExtensionAPI, getConfig: ConfigGetter): void {
|
|
24
|
+
const exec = createPiAtuinExec(pi);
|
|
25
|
+
const pending = new Map<string, PendingBashHistory>();
|
|
26
|
+
|
|
27
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
28
|
+
if (!getConfig().recordAgentHistory) return;
|
|
29
|
+
if (!isToolCallEventType("bash", event)) return;
|
|
30
|
+
|
|
31
|
+
const command = event.input.command?.trim();
|
|
32
|
+
if (!command) return;
|
|
33
|
+
|
|
34
|
+
const historyId = await startAtuinHistory(exec, ctx.cwd, command);
|
|
35
|
+
if (historyId) {
|
|
36
|
+
pending.set(event.toolCallId, { historyId, cwd: ctx.cwd });
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
pi.on("tool_result", async (event) => {
|
|
41
|
+
if (!getConfig().recordAgentHistory) return;
|
|
42
|
+
if (event.toolName !== "bash") return;
|
|
43
|
+
|
|
44
|
+
const track = pending.get(event.toolCallId);
|
|
45
|
+
if (!track) return;
|
|
46
|
+
|
|
47
|
+
pending.delete(event.toolCallId);
|
|
48
|
+
await endAtuinHistory(
|
|
49
|
+
exec,
|
|
50
|
+
track.cwd,
|
|
51
|
+
track.historyId,
|
|
52
|
+
parseBashExitCode(event),
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const local = createLocalBashOperations();
|
|
57
|
+
|
|
58
|
+
pi.on("user_bash", async (event) => {
|
|
59
|
+
const historyId = await startAtuinHistory(exec, event.cwd, event.command);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
operations: {
|
|
63
|
+
async exec(command, cwd, options) {
|
|
64
|
+
let exitCode: number | null = null;
|
|
65
|
+
try {
|
|
66
|
+
const result = await local.exec(command, cwd, options);
|
|
67
|
+
exitCode = result.exitCode;
|
|
68
|
+
return result;
|
|
69
|
+
} finally {
|
|
70
|
+
if (historyId) {
|
|
71
|
+
await endAtuinHistory(
|
|
72
|
+
exec,
|
|
73
|
+
cwd,
|
|
74
|
+
historyId,
|
|
75
|
+
exitCode ?? (options.signal?.aborted ? 130 : 1),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
pi.on("session_shutdown", () => {
|
|
85
|
+
pending.clear();
|
|
86
|
+
});
|
|
87
|
+
}
|
package/commands.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /atuin slash commands for pi-atuin.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { PiAtuinConfig } from "./config.js";
|
|
7
|
+
|
|
8
|
+
export type ConfigGetter = () => PiAtuinConfig;
|
|
9
|
+
export type ConfigSetter = (next: PiAtuinConfig) => void;
|
|
10
|
+
|
|
11
|
+
export function toggleRecordAgentHistory(current: boolean): boolean {
|
|
12
|
+
return !current;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function formatRecordAgentHistoryStatus(enabled: boolean): string {
|
|
16
|
+
return `Agent bash history recording: ${enabled ? "on" : "off"}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function registerAtuinCommands(
|
|
20
|
+
pi: ExtensionAPI,
|
|
21
|
+
getConfig: ConfigGetter,
|
|
22
|
+
setConfig: ConfigSetter,
|
|
23
|
+
): void {
|
|
24
|
+
pi.registerCommand("atuin", {
|
|
25
|
+
description: "Toggle agent bash history recording",
|
|
26
|
+
handler: async (args, ctx) => {
|
|
27
|
+
const subcommand = args.trim().split(/\s+/)[0]?.toLowerCase();
|
|
28
|
+
|
|
29
|
+
if (subcommand !== "record-agent-history") {
|
|
30
|
+
ctx.ui.notify("/atuin record-agent-history", "info");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
toggleRecordAgentHistoryCommand(ctx, getConfig, setConfig);
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function toggleRecordAgentHistoryCommand(
|
|
40
|
+
ctx: ExtensionCommandContext,
|
|
41
|
+
getConfig: ConfigGetter,
|
|
42
|
+
setConfig: ConfigSetter,
|
|
43
|
+
): void {
|
|
44
|
+
const config = getConfig();
|
|
45
|
+
const next = toggleRecordAgentHistory(config.recordAgentHistory);
|
|
46
|
+
setConfig({ ...config, recordAgentHistory: next });
|
|
47
|
+
ctx.ui.notify(formatRecordAgentHistoryStatus(next), "info");
|
|
48
|
+
}
|
package/config.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-atuin settings persisted under ~/.pi/agent/extensions/pi-atuin/config.json
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
const CONFIG_DIR = join(
|
|
10
|
+
process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"),
|
|
11
|
+
"extensions",
|
|
12
|
+
"pi-atuin",
|
|
13
|
+
);
|
|
14
|
+
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
15
|
+
|
|
16
|
+
export interface PiAtuinConfig {
|
|
17
|
+
/** Record agent `bash` tool calls into atuin (default: true). */
|
|
18
|
+
recordAgentHistory: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_CONFIG: PiAtuinConfig = {
|
|
22
|
+
recordAgentHistory: true,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function normalizeConfig(source: unknown): PiAtuinConfig {
|
|
26
|
+
const raw = source && typeof source === "object" ? (source as Partial<PiAtuinConfig>) : {};
|
|
27
|
+
return {
|
|
28
|
+
recordAgentHistory:
|
|
29
|
+
typeof raw.recordAgentHistory === "boolean"
|
|
30
|
+
? raw.recordAgentHistory
|
|
31
|
+
: DEFAULT_CONFIG.recordAgentHistory,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function loadConfig(): PiAtuinConfig {
|
|
36
|
+
try {
|
|
37
|
+
if (!existsSync(CONFIG_FILE)) return { ...DEFAULT_CONFIG };
|
|
38
|
+
const raw = JSON.parse(readFileSync(CONFIG_FILE, "utf8")) as unknown;
|
|
39
|
+
return normalizeConfig(raw);
|
|
40
|
+
} catch {
|
|
41
|
+
return { ...DEFAULT_CONFIG };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function saveConfig(config: PiAtuinConfig): void {
|
|
46
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
47
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", {
|
|
48
|
+
encoding: "utf8",
|
|
49
|
+
mode: 0o600,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getConfigPath(): string {
|
|
54
|
+
return CONFIG_FILE;
|
|
55
|
+
}
|
package/history-store.ts
CHANGED
|
@@ -5,14 +5,16 @@
|
|
|
5
5
|
import { readFile, writeFile, appendFile, mkdir } from "node:fs/promises";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
+
import { fuzzySearch } from "./fuzzy-match.js";
|
|
9
|
+
import { createExecFileAtuinExec, recordAtuinCommand } from "./atuin-cli.js";
|
|
8
10
|
import { execFile } from "node:child_process";
|
|
9
11
|
import { promisify } from "node:util";
|
|
10
|
-
import { fuzzySearch } from "./fuzzy-match.js";
|
|
11
12
|
|
|
12
13
|
const execFileAsync = promisify(execFile);
|
|
14
|
+
const atuinExec = createExecFileAtuinExec();
|
|
13
15
|
|
|
14
16
|
const JSONL_PATH = join(homedir(), ".pi", "agent", "pi-history.jsonl");
|
|
15
|
-
const
|
|
17
|
+
const ATUIN_READ_TIMEOUT_MS = 5_000;
|
|
16
18
|
const MAX_ENTRIES = 1000;
|
|
17
19
|
|
|
18
20
|
export interface HistoryEntry {
|
|
@@ -25,6 +27,26 @@ export interface HistoryEntry {
|
|
|
25
27
|
|
|
26
28
|
let writeQueue: Promise<void> = Promise.resolve();
|
|
27
29
|
|
|
30
|
+
// ponytail: in-memory dedup guard — input event may fire twice per entry.
|
|
31
|
+
// prevents duplicate writes to JSONL within 5s window.
|
|
32
|
+
const recentWrites = new Map<string, number>();
|
|
33
|
+
const WRITE_DEDUP_MS = 5_000;
|
|
34
|
+
|
|
35
|
+
export function isRecentWrite(text: string): boolean {
|
|
36
|
+
const last = recentWrites.get(text);
|
|
37
|
+
if (last && Date.now() - last < WRITE_DEDUP_MS) return true;
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function markRecentWrite(text: string): void {
|
|
42
|
+
recentWrites.set(text, Date.now());
|
|
43
|
+
// Cleanup entries older than WRITE_DEDUP_MS
|
|
44
|
+
const cutoff = Date.now() - WRITE_DEDUP_MS;
|
|
45
|
+
for (const [k, ts] of recentWrites) {
|
|
46
|
+
if (ts < cutoff) recentWrites.delete(k);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
28
50
|
// ---------------------------------------------------------------------------
|
|
29
51
|
// JSONL persistence
|
|
30
52
|
// ---------------------------------------------------------------------------
|
|
@@ -91,7 +113,7 @@ async function readAtuinHistory(): Promise<HistoryEntry[]> {
|
|
|
91
113
|
const { stdout } = await execFileAsync(
|
|
92
114
|
"atuin",
|
|
93
115
|
["search", "--format", "{time}\t{command}", "--limit", "200"],
|
|
94
|
-
{ timeout:
|
|
116
|
+
{ timeout: ATUIN_READ_TIMEOUT_MS },
|
|
95
117
|
);
|
|
96
118
|
|
|
97
119
|
const lines = stdout.split("\n").filter((l) => l.trim());
|
|
@@ -143,7 +165,7 @@ async function loadAllEntries(): Promise<HistoryEntry[]> {
|
|
|
143
165
|
const key = entry.text.trim();
|
|
144
166
|
const existing = seen.get(key);
|
|
145
167
|
if (!existing || Math.abs(entry.timestamp - existing.timestamp) > 5000) {
|
|
146
|
-
seen.set(
|
|
168
|
+
seen.set(key, entry);
|
|
147
169
|
}
|
|
148
170
|
}
|
|
149
171
|
|
|
@@ -192,24 +214,6 @@ export function invalidateCache(): void {
|
|
|
192
214
|
// Write back to atuin (so shell atuin search also sees pi prompts)
|
|
193
215
|
// ---------------------------------------------------------------------------
|
|
194
216
|
|
|
195
|
-
const ATUIN_AUTHOR = "pi";
|
|
196
|
-
|
|
197
217
|
export async function writeToAtuin(text: string, cwd: string): Promise<void> {
|
|
198
|
-
|
|
199
|
-
const { stdout } = await execFileAsync(
|
|
200
|
-
"atuin",
|
|
201
|
-
["history", "start", "--author", ATUIN_AUTHOR, "--", text],
|
|
202
|
-
{ cwd, timeout: ATUIN_TIMEOUT_MS },
|
|
203
|
-
);
|
|
204
|
-
const id = stdout.trim();
|
|
205
|
-
if (id) {
|
|
206
|
-
await execFileAsync(
|
|
207
|
-
"atuin",
|
|
208
|
-
["history", "end", id, "--exit", "0"],
|
|
209
|
-
{ cwd, timeout: ATUIN_TIMEOUT_MS },
|
|
210
|
-
);
|
|
211
|
-
}
|
|
212
|
-
} catch {
|
|
213
|
-
// atuin not installed or failed — silently ignore
|
|
214
|
-
}
|
|
218
|
+
await recordAtuinCommand(atuinExec, cwd, text, 0);
|
|
215
219
|
}
|
package/index.ts
CHANGED
|
@@ -9,14 +9,30 @@
|
|
|
9
9
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import { matchesKey, Key } from "@earendil-works/pi-tui";
|
|
12
|
-
import { addEntry, invalidateCache, listRecent, writeToAtuin } from "./history-store.js";
|
|
12
|
+
import { addEntry, invalidateCache, listRecent, writeToAtuin, isRecentWrite, markRecentWrite } from "./history-store.js";
|
|
13
13
|
import { HistorySearchComponent } from "./search-ui.js";
|
|
14
|
+
import { registerBashTracker } from "./bash-tracker.js";
|
|
15
|
+
import { loadConfig, saveConfig } from "./config.js";
|
|
16
|
+
import { registerAtuinCommands } from "./commands.js";
|
|
14
17
|
|
|
15
18
|
export default function piAtuin(pi: ExtensionAPI) {
|
|
19
|
+
let config = loadConfig();
|
|
20
|
+
|
|
21
|
+
const getConfig = () => config;
|
|
22
|
+
const setConfig = (next: typeof config) => {
|
|
23
|
+
config = next;
|
|
24
|
+
saveConfig(config);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
registerBashTracker(pi, getConfig);
|
|
28
|
+
registerAtuinCommands(pi, getConfig, setConfig);
|
|
16
29
|
// ---- 1. Record user inputs to history ----
|
|
17
30
|
pi.on("input", async (event, ctx) => {
|
|
18
31
|
if (event.source === "interactive" && event.text.trim()) {
|
|
19
32
|
const text = event.text.trim();
|
|
33
|
+
// Guard against duplicate input events (pi may fire input twice)
|
|
34
|
+
if (isRecentWrite(text)) return { action: "continue" as const };
|
|
35
|
+
markRecentWrite(text);
|
|
20
36
|
await addEntry(text, ctx.cwd);
|
|
21
37
|
// Also write to atuin DB so shell atuin can search pi prompts
|
|
22
38
|
writeToAtuin(text, ctx.cwd);
|
package/package.json
CHANGED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { parseBashExitCode } from "../bash-exit-code.ts";
|
|
4
|
+
|
|
5
|
+
describe("parseBashExitCode", () => {
|
|
6
|
+
it("returns 0 for successful commands", () => {
|
|
7
|
+
assert.equal(
|
|
8
|
+
parseBashExitCode({
|
|
9
|
+
isError: false,
|
|
10
|
+
content: [{ type: "text", text: "hello\n" }],
|
|
11
|
+
}),
|
|
12
|
+
0,
|
|
13
|
+
);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("parses explicit exit codes from bash output", () => {
|
|
17
|
+
assert.equal(
|
|
18
|
+
parseBashExitCode({
|
|
19
|
+
isError: true,
|
|
20
|
+
content: [
|
|
21
|
+
{
|
|
22
|
+
type: "text",
|
|
23
|
+
text: "something failed\n\nCommand exited with code 42",
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
}),
|
|
27
|
+
42,
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns 130 for aborted commands", () => {
|
|
32
|
+
assert.equal(
|
|
33
|
+
parseBashExitCode({
|
|
34
|
+
isError: true,
|
|
35
|
+
content: [{ type: "text", text: "Command aborted" }],
|
|
36
|
+
}),
|
|
37
|
+
130,
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns 124 for timed out commands", () => {
|
|
42
|
+
assert.equal(
|
|
43
|
+
parseBashExitCode({
|
|
44
|
+
isError: true,
|
|
45
|
+
content: [{ type: "text", text: "Command timed out after 30 seconds" }],
|
|
46
|
+
}),
|
|
47
|
+
124,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("defaults to 1 for generic errors", () => {
|
|
52
|
+
assert.equal(
|
|
53
|
+
parseBashExitCode({
|
|
54
|
+
isError: true,
|
|
55
|
+
content: [{ type: "text", text: "unknown failure" }],
|
|
56
|
+
}),
|
|
57
|
+
1,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
formatRecordAgentHistoryStatus,
|
|
5
|
+
toggleRecordAgentHistory,
|
|
6
|
+
} from "../commands.ts";
|
|
7
|
+
|
|
8
|
+
describe("record-agent-history toggle", () => {
|
|
9
|
+
it("flips the current value", () => {
|
|
10
|
+
assert.equal(toggleRecordAgentHistory(true), false);
|
|
11
|
+
assert.equal(toggleRecordAgentHistory(false), true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("formats status for notify", () => {
|
|
15
|
+
assert.equal(formatRecordAgentHistoryStatus(true), "Agent bash history recording: on");
|
|
16
|
+
assert.equal(formatRecordAgentHistoryStatus(false), "Agent bash history recording: off");
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { DEFAULT_CONFIG } from "../config.ts";
|
|
4
|
+
|
|
5
|
+
describe("DEFAULT_CONFIG", () => {
|
|
6
|
+
it("records agent bash history by default", () => {
|
|
7
|
+
assert.equal(DEFAULT_CONFIG.recordAgentHistory, true);
|
|
8
|
+
});
|
|
9
|
+
});
|