agent-golf 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Agent Golf contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,84 @@
1
- # Temporary Holding Version
1
+ # Agent Golf for Pi
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Pi commands for the Agent Golf coding-agent leaderboard. Login, browsing and submission never call a model or compute scores locally.
4
+
5
+ **0.1.1:** uses https://agent-golf.csbasics.in and includes read-only interactive problem views. Local integration with the real counter is verified and the production catalogue is reachable. A fresh solved production submission has not been verified; follow your organizer's readiness announcement before competition use.
6
+
7
+ ## Install and update
8
+
9
+ Requires Node.js 22.19+ and the Pi coding agent (tested with Pi 1.1.0).
10
+
11
+ ```bash
12
+ pi install npm:agent-golf
13
+ ```
14
+
15
+ Unversioned installations follow `latest`. The `preview` channel also receives this URL update; existing preview users can keep that installation source.
16
+
17
+ Start a new Pi process, or run `/reload` in an existing one. To update an unpinned installation:
18
+
19
+ ```bash
20
+ pi update --extensions
21
+ ```
22
+
23
+ Restart Pi or `/reload` afterward. An exact version such as `npm:agent-golf@0.1.0` is pinned; updating does not advance it to a different version. Remove the old pinned source (`pi remove npm:agent-golf@0.1.0`), then install `npm:agent-golf@0.1.1` explicitly to replace that pin. The `npm:agent-golf@preview` installation follows the preview channel; it does not automatically switch to `latest`.
24
+
25
+ **Updating from 0.1.0:** the default server origin changed. Unset any old `AGENT_GOLF_API_BASE_URL` override, update/restart Pi, and run `/golf-login` with your student key valid on the new website. Old-origin credentials are preserved but never migrated or sent to the new origin.
26
+
27
+ ## Commands
28
+
29
+ ### `/golf-problems`
30
+
31
+ Browse public contest rules, challenge URLs, instructions and each problem's model/thinking setup. Rules open first, followed by a problem selector. Close a detail view to return to the list; cancel the list to exit. An empty list still shows the rules.
32
+
33
+ Interactive Pi uses read-only, scrollable text views: Enter on rules opens the problem list, Esc closes browsing, and Enter/Esc on details returns to the list. Arrow keys, Page Up/Down and Home/End scroll; navigation hints use your configured confirm/cancel keys. RPC clients retain temporary text-editor dialogs with explicit navigation titles; all edits are discarded. Nothing is inserted into your prompt or session. `any` setup is displayed as "any model, one per session" or "any thinking level".
34
+
35
+ Browsing sends no key, cookie or session log, makes no model calls, and never starts/binds an attempt or changes your setup. The catalogue is advisory; server submission checks are authoritative. Replies are bounded to 64 KB and strings to 1,000 Unicode characters, with terminal-safety and exact Hackattic URL checks.
36
+
37
+ ### `/golf-login <student-key>`
38
+
39
+ Use the key provided by the Agent Golf website. Validation succeeds before anything is saved. Invalid keys and network/server failures preserve any existing saved credentials.
40
+
41
+ Credentials are stored separately per server origin in `~/.config/agent-golf/credentials.json`, with directory mode 0700 and file mode 0600. This file contains the original key so the client can authenticate: never share or commit it. Writes are atomic within one Pi process, but are not locked across separate Pi processes. Changing origins never reuses or migrates another server's key.
42
+
43
+ ### `/golf-submit <problem-slug>`
44
+
45
+ For example:
46
+
47
+ ```text
48
+ /golf-submit help_me_unpack
49
+ ```
50
+
51
+ Submit from the saved session where you solved the problem. Pi must be idle and all user Bash commands must have recorded their results. Confirm the problem, server, byte size and privacy warning before upload; cancelling sends nothing.
52
+
53
+ **Privacy:** submission uploads the complete unchanged raw JSONL, including abandoned branches. It may contain prompts, local paths, Hackattic access tokens and other sensitive material. Only submit to a server you trust. The website must keep raw logs private; public leaderboard rows should show only permitted identity, metrics and verdicts.
54
+
55
+ If the session or file changes during confirmation, submission is refused. Folder observations, when known, are sent as informational metadata, not proof that an attempt is eligible. Unknown/forked observations are omitted. User Bash starts are tracked by command hashes outside model context; missing recorded results block submission rather than guessing completion.
56
+
57
+ The server owns scores and status. A null total with `missing_usage` is displayed as `Tokens: unknown`, never zero. Warnings and model-switch reasons are displayed without client-side reranking.
58
+
59
+ ## Server configuration
60
+
61
+ Default API origin: **https://agent-golf.csbasics.in**.
62
+
63
+ For a trusted local server:
64
+
65
+ ```bash
66
+ AGENT_GOLF_API_BASE_URL=http://127.0.0.1:8000 pi
67
+ ```
68
+
69
+ Both authentication and browsing/submission use this origin. Only HTTPS and loopback HTTP are accepted; no credentials, API path, query or fragment belong in the origin. Empty/invalid overrides fail closed and never fall back silently to production. Redirects are refused. Loading the extension alone makes no HTTP requests.
70
+
71
+ ## Troubleshooting
72
+
73
+ - **No key saved:** run `/golf-login <student-key>` for the configured server.
74
+ - **Invalid key:** retry with your registered key; existing credentials remain unchanged.
75
+ - **Problem list unavailable:** check the configured origin and server availability; the endpoint may not yet be available.
76
+ - **Pi busy or pending Bash:** wait for the response/Bash result to finish recording.
77
+ - **No persisted session file:** submit from the saved attempt, not a fresh custom-only session that has not flushed to disk.
78
+ - **Upload/network error:** the upload may already have reached the server. Check the website before retrying. The plugin never retries automatically.
79
+
80
+ Removing the package with `pi remove npm:agent-golf` (use the source you installed, e.g. `npm:agent-golf@preview`) does not remove your credential file. Delete that file separately only if you intend to discard saved logins.
81
+
82
+ ## License
83
+
84
+ MIT for this plugin. This package contains no website/backend implementation or private repository material.
package/config.ts ADDED
@@ -0,0 +1,23 @@
1
+ export const DEFAULT_API_ORIGIN = "https://agent-golf.csbasics.in";
2
+
3
+ export function getApiOrigin(baseUrl: string | undefined): string {
4
+ if (!baseUrl?.trim()) throw new Error("missing-origin");
5
+ const url = new URL(baseUrl.trim());
6
+ const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
7
+ if (
8
+ url.username || url.password || url.search || url.hash || url.pathname !== "/" ||
9
+ (url.protocol !== "https:" && !(url.protocol === "http:" && loopback))
10
+ ) {
11
+ throw new Error("invalid-origin");
12
+ }
13
+ return url.origin;
14
+ }
15
+
16
+ export function resolveApiOrigin(
17
+ override?: string,
18
+ environment: Record<string, string | undefined> = process.env,
19
+ ): string {
20
+ // Only an absent override falls back. An empty/invalid value must not silently
21
+ // send credentials to production when the user intended another server.
22
+ return getApiOrigin(override ?? environment.AGENT_GOLF_API_BASE_URL ?? DEFAULT_API_ORIGIN);
23
+ }
package/index.ts ADDED
@@ -0,0 +1,160 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { loginStudent } from "./login.ts";
3
+ import { submitSession } from "./submit.ts";
4
+ import { browseProblems } from "./problems.ts";
5
+ import { USER_BASH_START, userBashStartMetadata } from "./user-bash.ts";
6
+ import type {
7
+ ExtensionAPI,
8
+ ExtensionContext,
9
+ CustomEntry,
10
+ SessionEntry,
11
+ SessionHeader,
12
+ } from "@earendil-works/pi-coding-agent";
13
+
14
+ export const FOLDER_CHECK_CUSTOM_TYPE = "agent-golf-folder-check";
15
+ export const FOLDER_CHECK_POLICY = "zero-directory-entries";
16
+
17
+ export type FolderCheckState = "empty" | "nonempty" | "unknown";
18
+
19
+ export interface FolderCheckMetadata {
20
+ schema: 1;
21
+ state: FolderCheckState;
22
+ checked_at: string;
23
+ policy: typeof FOLDER_CHECK_POLICY;
24
+ reason?: string;
25
+ }
26
+
27
+ interface SessionStartLike {
28
+ reason: "startup" | "reload" | "new" | "resume" | "fork";
29
+ }
30
+
31
+ function isFolderCheckMetadata(value: unknown): value is FolderCheckMetadata {
32
+ if (!value || typeof value !== "object") return false;
33
+ const data = value as Record<string, unknown>;
34
+ return (
35
+ data.schema === 1 &&
36
+ (data.state === "empty" || data.state === "nonempty" || data.state === "unknown") &&
37
+ typeof data.checked_at === "string" &&
38
+ data.policy === FOLDER_CHECK_POLICY &&
39
+ (data.reason === undefined || typeof data.reason === "string")
40
+ );
41
+ }
42
+
43
+ export function getFolderCheckEntries(entries: readonly SessionEntry[]): CustomEntry<FolderCheckMetadata>[] {
44
+ return entries.filter(
45
+ (entry): entry is CustomEntry<FolderCheckMetadata> =>
46
+ entry.type === "custom" &&
47
+ entry.customType === FOLDER_CHECK_CUSTOM_TYPE &&
48
+ isFolderCheckMetadata(entry.data),
49
+ );
50
+ }
51
+
52
+ export function getInitialFolderCheckMetadata(
53
+ entries: readonly SessionEntry[],
54
+ ): FolderCheckMetadata | undefined {
55
+ return getFolderCheckEntries(entries)[0]?.data;
56
+ }
57
+
58
+ export function getFolderWasEmptyUploadValue(
59
+ header: SessionHeader | null,
60
+ entries: readonly SessionEntry[],
61
+ ): "true" | "false" | undefined {
62
+ if (header?.parentSession) return undefined;
63
+
64
+ const metadata = getInitialFolderCheckMetadata(entries);
65
+ if (metadata?.state === "empty") return "true";
66
+ if (metadata?.state === "nonempty") return "false";
67
+ return undefined;
68
+ }
69
+
70
+ function observation(state: FolderCheckState, reason?: string): FolderCheckMetadata {
71
+ return {
72
+ schema: 1,
73
+ state,
74
+ checked_at: new Date().toISOString(),
75
+ policy: FOLDER_CHECK_POLICY,
76
+ ...(reason ? { reason } : {}),
77
+ };
78
+ }
79
+
80
+ async function observeFreshCwd(cwd: string): Promise<FolderCheckMetadata> {
81
+ try {
82
+ const entries = await readdir(cwd);
83
+ return observation(entries.length === 0 ? "empty" : "nonempty");
84
+ } catch (error) {
85
+ const code =
86
+ error && typeof error === "object" && "code" in error
87
+ ? String((error as { code?: unknown }).code)
88
+ : undefined;
89
+ return observation("unknown", code ? `read-failed:${code}` : "read-failed");
90
+ }
91
+ }
92
+
93
+ export async function recordFolderCheckForSession(
94
+ pi: Pick<ExtensionAPI, "appendEntry">,
95
+ event: SessionStartLike,
96
+ ctx: Pick<ExtensionContext, "cwd" | "sessionManager">,
97
+ ): Promise<void> {
98
+ const entries = ctx.sessionManager.getEntries();
99
+ if (getInitialFolderCheckMetadata(entries)) return;
100
+
101
+ const header = ctx.sessionManager.getHeader();
102
+ let metadata: FolderCheckMetadata;
103
+
104
+ if (event.reason === "fork" || header?.parentSession) {
105
+ metadata = observation("unknown", "inherited-session");
106
+ } else if (
107
+ (event.reason === "startup" || event.reason === "new") &&
108
+ // Startup also fires when the CLI opens an existing session. Only known
109
+ // initialization entries are safe; all messages (including bash/tool
110
+ // results), usage, summaries, and unrecognized metadata mean we're too late.
111
+ entries.every(
112
+ (entry) =>
113
+ entry.type === "model_change" ||
114
+ entry.type === "thinking_level_change" ||
115
+ entry.type === "session_info",
116
+ )
117
+ ) {
118
+ metadata = await observeFreshCwd(ctx.cwd);
119
+ } else {
120
+ metadata = observation("unknown", `not-fresh:${event.reason}`);
121
+ }
122
+
123
+ pi.appendEntry(FOLDER_CHECK_CUSTOM_TYPE, metadata);
124
+ }
125
+
126
+ export default function (pi: ExtensionAPI) {
127
+ pi.on("session_start", async (event, ctx) => {
128
+ await recordFolderCheckForSession(pi, event, ctx);
129
+ });
130
+
131
+ pi.on("user_bash", (event) => {
132
+ pi.appendEntry(USER_BASH_START, userBashStartMetadata(event.command));
133
+ });
134
+
135
+ pi.registerCommand("golf-login", {
136
+ description: "Validate and privately save your Agent Golf student key",
137
+ handler: async (args, ctx) => {
138
+ const result = await loginStudent(args);
139
+ ctx.ui.notify(result.message, result.type);
140
+ },
141
+ });
142
+
143
+ pi.registerCommand("golf-problems", {
144
+ description: "Browse public Agent Golf rules and problem setup without model calls",
145
+ handler: async (args, ctx) => {
146
+ const result = await browseProblems(args, ctx);
147
+ ctx.ui.notify(result.message, result.type);
148
+ },
149
+ });
150
+
151
+ pi.registerCommand("golf-submit", {
152
+ description: "Confirm and upload the current raw session to Agent Golf",
153
+ handler: async (args, ctx) => {
154
+ const result = await submitSession(args, ctx, {
155
+ folderWasEmpty: getFolderWasEmptyUploadValue(ctx.sessionManager.getHeader(), ctx.sessionManager.getEntries()),
156
+ });
157
+ ctx.ui.notify(result.message, result.type);
158
+ },
159
+ });
160
+ }
package/login.ts ADDED
@@ -0,0 +1,161 @@
1
+ import { constants } from "node:fs";
2
+ import { chmod, lstat, mkdir, open, rename, unlink } from "node:fs/promises";
3
+ import { randomUUID } from "node:crypto";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { getApiOrigin, resolveApiOrigin } from "./config.ts";
7
+ export { getApiOrigin } from "./config.ts";
8
+
9
+ export interface LoginOptions {
10
+ baseUrl?: string;
11
+ credentialDir?: string;
12
+ timeoutMs?: number;
13
+ }
14
+
15
+ export interface LoginResult {
16
+ message: string;
17
+ type: "info" | "error";
18
+ }
19
+
20
+ interface Credential {
21
+ key: string;
22
+ username: string;
23
+ }
24
+
25
+ interface CredentialFile {
26
+ schema: 1;
27
+ credentials: Record<string, Credential>;
28
+ }
29
+
30
+ const USERNAME = /^[a-z0-9_]{3,32}$/;
31
+ const SETUP_MESSAGE = "Set AGENT_GOLF_API_BASE_URL to the server origin (for local testing: http://localhost:8000).";
32
+
33
+ function isCredentialFile(value: unknown): value is CredentialFile {
34
+ if (!value || typeof value !== "object") return false;
35
+ const data = value as Partial<CredentialFile>;
36
+ if (data.schema !== 1 || !data.credentials || typeof data.credentials !== "object" || Array.isArray(data.credentials)) return false;
37
+ return Object.entries(data.credentials).every(([origin, credential]) => {
38
+ try {
39
+ return getApiOrigin(origin) === origin && !!credential &&
40
+ typeof credential.key === "string" && credential.key.length > 0 &&
41
+ typeof credential.username === "string" && USERNAME.test(credential.username);
42
+ } catch {
43
+ return false;
44
+ }
45
+ });
46
+ }
47
+
48
+ export async function readStoredCredential(
49
+ origin: string,
50
+ directory = join(homedir(), ".config", "agent-golf"),
51
+ ): Promise<Credential | undefined> {
52
+ let file;
53
+ try {
54
+ const folder = await lstat(directory);
55
+ if (!folder.isDirectory() || (folder.mode & 0o077) !== 0) throw new Error("insecure-credential-directory");
56
+ file = await open(join(directory, "credentials.json"), constants.O_RDONLY | constants.O_NOFOLLOW);
57
+ const info = await file.stat();
58
+ if (!info.isFile() || (info.mode & 0o077) !== 0) throw new Error("insecure-credential-file");
59
+ const parsed: unknown = JSON.parse(await file.readFile("utf8"));
60
+ if (!isCredentialFile(parsed)) throw new Error("invalid-credential-file");
61
+ return parsed.credentials[origin];
62
+ } catch (error) {
63
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return undefined;
64
+ throw error;
65
+ } finally {
66
+ await file?.close();
67
+ }
68
+ }
69
+
70
+ // Serialize read-modify-write operations within this Pi process so concurrent
71
+ // logins to different origins don't erase one another's saved credentials.
72
+ let pendingSave: Promise<void> = Promise.resolve();
73
+
74
+ async function writeCredential(origin: string, key: string, username: string, directory: string): Promise<void> {
75
+ await mkdir(directory, { recursive: true, mode: 0o700 });
76
+ if (!(await lstat(directory)).isDirectory()) throw new Error("invalid-credential-directory");
77
+ await chmod(directory, 0o700);
78
+ const destination = join(directory, "credentials.json");
79
+ let data: CredentialFile = { schema: 1, credentials: {} };
80
+ let existing;
81
+ try {
82
+ existing = await open(destination, constants.O_RDONLY | constants.O_NOFOLLOW);
83
+ const parsed: unknown = JSON.parse(await existing.readFile("utf8"));
84
+ if (!isCredentialFile(parsed)) throw new Error("invalid-credential-file");
85
+ data = parsed;
86
+ } catch (error) {
87
+ if (!(error && typeof error === "object" && "code" in error && error.code === "ENOENT")) throw error;
88
+ } finally {
89
+ await existing?.close();
90
+ }
91
+ data.credentials[origin] = { key, username };
92
+
93
+ const temporary = join(directory, `.credentials-${randomUUID()}.tmp`);
94
+ const file = await open(temporary, "wx", 0o600);
95
+ try {
96
+ try {
97
+ await file.chmod(0o600);
98
+ await file.writeFile(JSON.stringify(data) + "\n", "utf8");
99
+ await file.sync();
100
+ } finally {
101
+ await file.close();
102
+ }
103
+ await rename(temporary, destination);
104
+ } finally {
105
+ await unlink(temporary).catch(() => {});
106
+ }
107
+ }
108
+
109
+ export async function loginStudent(args: string, options: LoginOptions = {}): Promise<LoginResult> {
110
+ const key = args.trim();
111
+ if (!key || /\s|[\x00-\x1f\x7f]/.test(key)) {
112
+ return { message: "Usage: /golf-login <key>", type: "info" };
113
+ }
114
+
115
+ let origin: string;
116
+ try {
117
+ origin = resolveApiOrigin(options.baseUrl);
118
+ } catch {
119
+ return { message: `Invalid server origin override. ${SETUP_MESSAGE} Use HTTPS except for loopback development; do not include credentials, a path, query, or fragment.`, type: "error" };
120
+ }
121
+
122
+ let username: string;
123
+ try {
124
+ const response = await fetch(`${origin}/api/v1/me`, {
125
+ headers: { Authorization: `Bearer ${key}`, Accept: "application/json" },
126
+ redirect: "error",
127
+ signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
128
+ });
129
+ if (response.status === 401) {
130
+ await response.body?.cancel();
131
+ return { message: "Invalid student key. Run /golf-login <key> with your registered key. Existing credentials were not changed.", type: "error" };
132
+ }
133
+ if (response.status !== 200) {
134
+ await response.body?.cancel();
135
+ return { message: "The server could not validate the key. Existing credentials were not changed.", type: "error" };
136
+ }
137
+ if (!/^application\/json(?:\s*;|$)/i.test(response.headers.get("content-type") ?? "")) {
138
+ await response.body?.cancel();
139
+ return { message: "Invalid login response from the server. Existing credentials were not changed.", type: "error" };
140
+ }
141
+ const data: unknown = await response.json();
142
+ if (!data || typeof data !== "object" || !("username" in data) || typeof data.username !== "string" || !USERNAME.test(data.username)) {
143
+ return { message: "Invalid login response from the server. Existing credentials were not changed.", type: "error" };
144
+ }
145
+ username = data.username;
146
+ } catch {
147
+ // Never display server bodies or exception strings: either can contain keys.
148
+ return { message: "Login failed: server unreachable, request timed out, redirect refused, or invalid response. Check the configured server address and connection, then retry /golf-login. Existing credentials were not changed.", type: "error" };
149
+ }
150
+
151
+ try {
152
+ const save = pendingSave.then(() => writeCredential(
153
+ origin, key, username, options.credentialDir ?? join(homedir(), ".config", "agent-golf"),
154
+ ));
155
+ pendingSave = save.catch(() => {});
156
+ await save;
157
+ } catch {
158
+ return { message: "Key validated, but credentials could not be saved securely. Existing credentials were not replaced.", type: "error" };
159
+ }
160
+ return { message: `Logged in as ${username} on ${origin}.`, type: "info" };
161
+ }
package/package.json CHANGED
@@ -1,6 +1,21 @@
1
1
  {
2
2
  "name": "agent-golf",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Zero-model-call Pi commands for Agent Golf login, raw session submission and problem browsing",
5
+ "license": "MIT",
6
+ "homepage": "https://agent-golf.csbasics.in",
7
+ "keywords": ["pi-package", "agent-golf"],
8
+ "engines": { "node": ">=22.19.0" },
9
+ "files": ["index.ts", "config.ts", "login.ts", "submit.ts", "user-bash.ts", "problems.ts", "problem-view.ts", "README.md", "LICENSE"],
10
+ "pi": { "extensions": ["./index.ts"], "skills": [], "prompts": [], "themes": [] },
11
+ "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
12
+ "type": "module",
13
+ "scripts": {
14
+ "test": "node --test test/*.test.mjs"
15
+ },
16
+ "peerDependencies": {
17
+ "@earendil-works/pi-coding-agent": "*",
18
+ "@earendil-works/pi-tui": "*"
19
+ },
20
+ "devDependencies": {}
21
+ }
@@ -0,0 +1,74 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ type TuiModule = typeof import("@earendil-works/pi-tui");
4
+ type Factory = Parameters<ExtensionContext["ui"]["custom"]>[0];
5
+ type Tui = Parameters<Factory>[0];
6
+ type Theme = Parameters<Factory>[1];
7
+ type Bindings = Parameters<Factory>[2];
8
+
9
+ // Dependency injection keeps rendering tests independent of Pi's module loader.
10
+ export function createProblemView(
11
+ lib: TuiModule, tui: Tui, theme: Theme, bindings: Bindings,
12
+ done: (continued: boolean) => void, title: string, body: string, action: string, cancel: string,
13
+ ) {
14
+ const text = new lib.Text(body, 0, 0);
15
+ const scroll = new lib.ScrollView(text, { scrollbar: "hidden", overscroll: "contain" });
16
+ let height = 1;
17
+ let closed = false;
18
+ const finish = (value: boolean) => { if (!closed) { closed = true; done(value); } };
19
+ const keys = (name: "tui.select.confirm" | "tui.select.cancel") => bindings.getKeys(name).join("/");
20
+ return {
21
+ render(width: number) {
22
+ const w = Math.max(1, Math.floor(width));
23
+ const confirmKey = keys("tui.select.confirm"), cancelKey = keys("tui.select.cancel");
24
+ const navigation = action === cancel ? `${confirmKey}/${cancelKey}: ${action}` :
25
+ `${confirmKey}: ${action} · ${cancelKey}: ${cancel}`;
26
+ const navigationLines = new lib.Text(navigation, 0, 0).render(w);
27
+ const available = Math.max(1, tui.terminal.rows - 5);
28
+ // Physically tiny terminals cannot fit body plus controls; ask for resize
29
+ // rather than presenting a clipped action as if it were the full help.
30
+ if (w < 12 || navigationLines.length + 2 > available) {
31
+ return [lib.truncateToWidth("Resize terminal", w),
32
+ ...new lib.Text(`${confirmKey}/${cancelKey}`, 0, 0).render(w)].slice(0, available);
33
+ }
34
+ const showScrollHint = available >= navigationLines.length + 3;
35
+ height = Math.max(1, Math.min(16, available - 1 - navigationLines.length - Number(showScrollHint)));
36
+ const lines = text.render(w);
37
+ scroll.updateLayout(lines.length, height, () => tui.requestRender());
38
+ const visible = lines.slice(scroll.scrollTop, scroll.scrollTop + height);
39
+ const scrollHint = `${w < 45 ? "↑/↓ scroll" : "↑/↓ PgUp/PgDn Home/End: scroll"} · ${scroll.scrollTop + 1}-${Math.min(lines.length, scroll.scrollTop + height)}/${lines.length}`;
40
+ return [
41
+ lib.truncateToWidth(theme.fg("accent", title), w),
42
+ ...visible,
43
+ ...navigationLines.map((line) => theme.fg("muted", line)),
44
+ ...(showScrollHint ? [lib.truncateToWidth(theme.fg("muted", scrollHint), w)] : []),
45
+ ];
46
+ },
47
+ handleInput(data: string) {
48
+ if (closed) return;
49
+ if (bindings.matches(data, "tui.select.cancel") || lib.matchesKey(data, lib.Key.ctrl("c"))) { finish(false); return; }
50
+ if (bindings.matches(data, "tui.select.confirm")) { finish(true); return; }
51
+ if (lib.matchesKey(data, lib.Key.up)) scroll.scrollBy(-1);
52
+ else if (lib.matchesKey(data, lib.Key.down)) scroll.scrollBy(1);
53
+ else if (lib.matchesKey(data, lib.Key.pageUp)) scroll.scrollBy(-height);
54
+ else if (lib.matchesKey(data, lib.Key.pageDown)) scroll.scrollBy(height);
55
+ else if (lib.matchesKey(data, lib.Key.home)) scroll.scrollToStart();
56
+ else if (lib.matchesKey(data, lib.Key.end)) scroll.scrollToEnd();
57
+ // No input/editor exists: typing and paste cannot alter the displayed text.
58
+ },
59
+ invalidate() { text.invalidate(); scroll.invalidate(); },
60
+ };
61
+ }
62
+
63
+ export async function showProblemView(
64
+ ctx: Pick<ExtensionContext, "ui" | "mode">,
65
+ title: string, body: string, action: string, cancel: string,
66
+ ): Promise<boolean> {
67
+ if (ctx.mode !== "tui") {
68
+ return await ctx.ui.editor(`${title} — Enter: ${action}; Esc: ${cancel} (not an upload)`,
69
+ `${body}\n\nViewer only. Enter navigates; it does not upload. Edits are discarded.`) !== undefined;
70
+ }
71
+ const lib = await import("@earendil-works/pi-tui");
72
+ return ctx.ui.custom<boolean>((tui, theme, bindings, done) =>
73
+ createProblemView(lib, tui, theme, bindings, done, title, body, action, cancel));
74
+ }
package/problems.ts ADDED
@@ -0,0 +1,122 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { resolveApiOrigin } from "./config.ts";
3
+ import { showProblemView } from "./problem-view.ts";
4
+
5
+ export const MAX_PROBLEM_BODY_BYTES = 64 * 1024;
6
+ const MAX_STRING_CHARACTERS = 1000;
7
+ const UNAVAILABLE = "Problem list unavailable. Check the configured server and connection, then try /golf-problems again.";
8
+ const ADVISORY = "Informational only: browsing does not start or bind an attempt. Upload checks remain authoritative. Nothing is uploaded by browsing.";
9
+
10
+ interface Problem {
11
+ slug: string;
12
+ title: string;
13
+ challenge_url: string;
14
+ instructions: string[];
15
+ model: string;
16
+ thinking_level: string;
17
+ }
18
+ interface Catalogue { rules_version: string; rules: string[]; problems: Problem[] }
19
+ interface Result { message: string; type: "info" | "error" }
20
+ export interface ProblemOptions { baseUrl?: string; timeoutMs?: number }
21
+
22
+ function safeStrings(value: unknown): boolean {
23
+ if (typeof value === "string") {
24
+ // Newlines/tabs are ordinary text in the temporary editor, not escapes.
25
+ return Array.from(value).length <= MAX_STRING_CHARACTERS && value.isWellFormed() &&
26
+ !/[\x00-\x08\x0b-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/u.test(value);
27
+ }
28
+ if (Array.isArray(value)) return value.every(safeStrings);
29
+ if (value && typeof value === "object") return Object.entries(value).every(([key, item]) => safeStrings(key) && safeStrings(item));
30
+ return true;
31
+ }
32
+
33
+ function catalogue(value: unknown): Catalogue | undefined {
34
+ if (!value || typeof value !== "object" || !safeStrings(value)) return undefined;
35
+ const data = value as Record<string, unknown>;
36
+ const text = (item: unknown): item is string => typeof item === "string" && item.trim().length > 0;
37
+ const texts = (item: unknown): item is string[] => Array.isArray(item) && item.every((s) => typeof s === "string");
38
+ if (!text(data.rules_version) || !texts(data.rules) || !Array.isArray(data.problems)) return undefined;
39
+ const slugs = new Set<string>();
40
+ for (const item of data.problems) {
41
+ if (!item || typeof item !== "object") return undefined;
42
+ const problem = item as Record<string, unknown>;
43
+ if (!text(problem.slug) || !/^[a-z0-9]+(?:_[a-z0-9]+)*$/.test(problem.slug) || slugs.has(problem.slug) ||
44
+ !text(problem.title) || problem.challenge_url !== `https://hackattic.com/challenges/${problem.slug}` ||
45
+ !texts(problem.instructions) || !text(problem.model) || !text(problem.thinking_level)) return undefined;
46
+ if (problem.model !== "any" && !/^[^\s/]+\/\S+$/.test(problem.model)) return undefined;
47
+ if (!/^[a-z][a-z0-9_-]*$/.test(problem.thinking_level)) return undefined;
48
+ slugs.add(problem.slug);
49
+ }
50
+ return data as unknown as Catalogue;
51
+ }
52
+
53
+ async function readCatalogue(response: Response): Promise<Catalogue | undefined> {
54
+ if (!response.body) return undefined;
55
+ const reader = response.body.getReader();
56
+ let size = 0;
57
+ const chunks: Uint8Array[] = [];
58
+ try {
59
+ while (true) {
60
+ const { done, value } = await reader.read();
61
+ if (done) break;
62
+ size += value.byteLength;
63
+ if (size > MAX_PROBLEM_BODY_BYTES) return undefined;
64
+ chunks.push(value);
65
+ }
66
+ return catalogue(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks))));
67
+ } finally {
68
+ await reader.cancel();
69
+ reader.releaseLock();
70
+ }
71
+ }
72
+
73
+ let browsing = false;
74
+ export async function browseProblems(
75
+ args: string,
76
+ ctx: Pick<ExtensionContext, "ui" | "hasUI" | "mode">,
77
+ options: ProblemOptions = {},
78
+ ): Promise<Result> {
79
+ if (args.trim()) return { message: "Usage: /golf-problems", type: "info" };
80
+ if (!ctx.hasUI) return { message: "Problem browsing requires interactive Pi or an RPC client with UI support.", type: "error" };
81
+ if (browsing) return { message: "Another problem browser is open. Close it first.", type: "error" };
82
+ browsing = true;
83
+ try {
84
+ const origin = resolveApiOrigin(options.baseUrl);
85
+ const response = await fetch(`${origin}/api/v1/problems`, {
86
+ headers: { Accept: "application/json" }, credentials: "omit", redirect: "error",
87
+ signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
88
+ });
89
+ if (response.status !== 200 || !/^application\/json(?:\s*;|$)/i.test(response.headers.get("content-type") ?? "") ||
90
+ Number(response.headers.get("content-length")) > MAX_PROBLEM_BODY_BYTES) {
91
+ await response.body?.cancel();
92
+ return { message: UNAVAILABLE, type: "error" };
93
+ }
94
+ const data = await readCatalogue(response);
95
+ if (!data) return { message: UNAVAILABLE, type: "error" };
96
+ const rules = [`Rules version: ${data.rules_version}`, "", ...data.rules, "", ADVISORY];
97
+ if (!data.problems.length) rules.push("", "No problems are currently listed.");
98
+ const continued = await showProblemView(ctx, "Agent Golf rules", rules.join("\n"),
99
+ data.problems.length ? "view problems" : "close", "close");
100
+ if (continued && data.problems.length) {
101
+ const labels = data.problems.map((p) => `${p.title.replace(/[\n\t]/g, " ")} (${p.slug})`);
102
+ while (true) {
103
+ const selected = await ctx.ui.select("Agent Golf problems — choose to view details", labels);
104
+ const index = selected === undefined ? -1 : labels.indexOf(selected);
105
+ if (index < 0) break;
106
+ const problem = data.problems[index];
107
+ await showProblemView(ctx, "Problem details", [
108
+ problem.title, `Problem: ${problem.slug}`, problem.challenge_url,
109
+ `Model: ${problem.model === "any" ? "any model, one per session" : problem.model}`,
110
+ `Thinking level: ${problem.thinking_level === "any" ? "any thinking level" : problem.thinking_level}`,
111
+ "", ...problem.instructions, "", ADVISORY,
112
+ ].join("\n"), "back to problems", "back to problems");
113
+ }
114
+ }
115
+ return { message: "Problem browsing closed. No attempt or setup was changed.", type: "info" };
116
+ } catch {
117
+ // Never render arbitrary error bodies, URL overrides or exception strings.
118
+ return { message: UNAVAILABLE, type: "error" };
119
+ } finally {
120
+ browsing = false;
121
+ }
122
+ }
package/submit.ts ADDED
@@ -0,0 +1,147 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { readStoredCredential, type LoginResult } from "./login.ts";
4
+ import { getApiOrigin, resolveApiOrigin } from "./config.ts";
5
+ import { hasPendingUserBash } from "./user-bash.ts";
6
+
7
+ export interface SubmitOptions {
8
+ baseUrl?: string;
9
+ credentialDir?: string;
10
+ timeoutMs?: number;
11
+ folderWasEmpty?: "true" | "false";
12
+ }
13
+
14
+ type SubmitContext = Pick<ExtensionContext, "ui" | "hasUI" | "isIdle" | "sessionManager">;
15
+
16
+ const REASONS = new Set([
17
+ "not_solved", "model_mismatch", "thinking_level_mismatch", "model_switched", "folder_reused", "missing_usage",
18
+ "custom_setup", "inconsistent_log", "usage_not_recorded", "bad_key", "not_pi_log", "unknown_problem", "file_too_large",
19
+ ]);
20
+
21
+ function error(message: string): LoginResult {
22
+ return { message, type: "error" };
23
+ }
24
+
25
+ function formatReply(value: unknown, httpStatus: number, key: string): LoginResult | undefined {
26
+ if (!value || typeof value !== "object") return undefined;
27
+ const data = value as Record<string, unknown>;
28
+ if (!Array.isArray(data.reasons) || !data.reasons.every((reason) => typeof reason === "string" && REASONS.has(reason))) return undefined;
29
+ const reasons = data.reasons.length ? data.reasons.join(", ") : "none";
30
+ if (httpStatus !== 200) {
31
+ if (data.status !== "rejected" || data.metrics !== null || data.url !== null) return undefined;
32
+ const allowed = httpStatus === 401 ? ["bad_key"] : httpStatus === 413 ? ["file_too_large"] : ["not_pi_log", "unknown_problem"];
33
+ if (!data.reasons.length || !data.reasons.every((reason) => allowed.includes(reason))) return undefined;
34
+ return error(`Submission rejected: ${reasons}.${httpStatus === 401 ? " Run /golf-login with a valid key for this server." : ""}`);
35
+ }
36
+ if (data.status !== "ranked" && data.status !== "unranked") return undefined;
37
+ if (!data.metrics || typeof data.metrics !== "object" || typeof data.url !== "string") return undefined;
38
+ const metrics = data.metrics as Record<string, unknown>;
39
+ const unknownTokens = data.reasons.includes("missing_usage");
40
+ // Validate the contract's null/reason pair, not the score or ranking verdict.
41
+ if (unknownTokens !== (metrics.total_tokens === null)) return undefined;
42
+ for (const name of ["total_tokens", "prompts", "chars_typed", "tool_calls"]) {
43
+ if (name === "total_tokens" && unknownTokens) continue;
44
+ if (typeof metrics[name] !== "number" || !Number.isSafeInteger(metrics[name]) || metrics[name] < 0) return undefined;
45
+ }
46
+ for (const name of ["models", "thinking_levels"]) {
47
+ if (!Array.isArray(metrics[name]) || !metrics[name].every((item) => typeof item === "string" && !/[\x00-\x1f\x7f]/.test(item))) return undefined;
48
+ }
49
+ if (typeof metrics.solved !== "boolean") return undefined;
50
+ let url: URL;
51
+ try {
52
+ url = new URL(data.url);
53
+ getApiOrigin(url.origin);
54
+ if (url.username || url.password || /[\x00-\x1f\x7f]/.test(data.url)) return undefined;
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ const message = [
59
+ `Submission ${data.status}. Reasons: ${reasons}.`,
60
+ `Tokens: ${unknownTokens ? "unknown" : metrics.total_tokens}; prompts: ${metrics.prompts}; characters: ${metrics.chars_typed}; tool calls: ${metrics.tool_calls}; solved: ${metrics.solved}.`,
61
+ `Models: ${(metrics.models as string[]).join(", ")}; thinking levels: ${(metrics.thinking_levels as string[]).join(", ")}.`,
62
+ url.href,
63
+ ].join("\n");
64
+ if (message.includes(key)) return undefined;
65
+ return { message, type: "info" };
66
+ }
67
+
68
+ // One upload at a time within this extension runtime; no model interaction.
69
+ let submitting = false;
70
+
71
+ export async function submitSession(args: string, ctx: SubmitContext, options: SubmitOptions = {}): Promise<LoginResult> {
72
+ const problem = args.trim();
73
+ if (!/^[a-z0-9]+(?:_[a-z0-9]+)*$/.test(problem)) return { message: "Usage: /golf-submit <problem> (for example: help_me_unpack)", type: "info" };
74
+ if (submitting) return error("Another submission is already in progress. Wait for it to finish.");
75
+ submitting = true;
76
+ try {
77
+ if (!ctx.hasUI) return error("Submission requires a confirmation dialog. Use interactive Pi or an RPC client with UI support.");
78
+ if (!ctx.isIdle()) return error("Pi is busy. Finish the current response before submitting.");
79
+ if (hasPendingUserBash(ctx.sessionManager.getEntries())) return error("A user Bash command is still running or its result has not been recorded. Wait for it to finish before submitting.");
80
+ let origin: string;
81
+ try {
82
+ origin = resolveApiOrigin(options.baseUrl);
83
+ } catch {
84
+ return error("Invalid server origin override. Set AGENT_GOLF_API_BASE_URL to a valid HTTPS server origin (loopback HTTP is allowed for local testing).");
85
+ }
86
+ let credential;
87
+ try {
88
+ credential = await readStoredCredential(origin, options.credentialDir);
89
+ } catch {
90
+ return error("Stored credentials could not be read securely. Run /golf-login again; check the private credential file if the problem persists.");
91
+ }
92
+ if (!credential) return error("No key saved for this server. Run /golf-login <key> first.");
93
+
94
+ const sessionId = ctx.sessionManager.getSessionId();
95
+ const sessionFile = ctx.sessionManager.getSessionFile();
96
+ const leafId = ctx.sessionManager.getLeafId();
97
+ if (!sessionFile) return error("No persisted session file is available. Submit from the saved session where you solved the problem.");
98
+ let bytes: Buffer;
99
+ try {
100
+ bytes = await readFile(sessionFile);
101
+ } catch {
102
+ return error("The current session file is unavailable or unreadable. Nothing was uploaded.");
103
+ }
104
+ if (!bytes.length) return error("The current session file is empty. Nothing was uploaded.");
105
+ const stillCurrent = () => ctx.isIdle() && !hasPendingUserBash(ctx.sessionManager.getEntries()) && ctx.sessionManager.getSessionId() === sessionId &&
106
+ ctx.sessionManager.getSessionFile() === sessionFile && ctx.sessionManager.getLeafId() === leafId;
107
+ if (!stillCurrent()) return error("Pi or the session changed while preparing submission. Try again when idle.");
108
+ const confirmed = await ctx.ui.confirm("Submit to Agent Golf?", [
109
+ `Problem: ${problem}`,
110
+ `Server: ${origin}`,
111
+ `Session file size: ${bytes.length} bytes`,
112
+ "This uploads the complete raw session, including abandoned branches. It may contain Hackattic tokens, prompts, and local paths. Only proceed if you trust this server.",
113
+ ].join("\n"));
114
+ if (!confirmed) return { message: "Submission cancelled. Nothing was uploaded.", type: "info" };
115
+ if (!stillCurrent()) return error("Pi or the session changed during confirmation. Nothing was uploaded; try again when idle.");
116
+ const currentBytes = await readFile(sessionFile);
117
+ if (!bytes.equals(currentBytes) || !stillCurrent()) return error("The session changed during confirmation. Nothing was uploaded; try again.");
118
+
119
+ const form = new FormData();
120
+ form.append("problem", problem);
121
+ form.append("session", new Blob([new Uint8Array(bytes)], { type: "application/x-ndjson" }), "session.jsonl");
122
+ if (options.folderWasEmpty !== undefined) form.append("folder_was_empty", options.folderWasEmpty);
123
+ try {
124
+ const response = await fetch(`${origin}/api/v1/submissions`, {
125
+ method: "POST", headers: { Authorization: `Bearer ${credential.key}`, Accept: "application/json" },
126
+ body: form, redirect: "error", signal: AbortSignal.timeout(options.timeoutMs ?? 60_000),
127
+ });
128
+ if (![200, 400, 401, 413].includes(response.status)) {
129
+ await response.body?.cancel();
130
+ return error("Submission failed: server error. This does not mean your file or key is invalid.");
131
+ }
132
+ if (!/^application\/json(?:\s*;|$)/i.test(response.headers.get("content-type") ?? "")) {
133
+ await response.body?.cancel();
134
+ return error("The server returned an invalid submission response. Check the website before retrying.");
135
+ }
136
+ const result = formatReply(await response.json(), response.status, credential.key);
137
+ return result ?? error("The server returned an invalid submission response. Check the website before retrying.");
138
+ } catch {
139
+ // Never display exceptions or arbitrary response bodies: they may contain keys/logs.
140
+ return error("Submission failed: server unreachable, request timed out, redirect refused, or invalid response. The upload may have reached the server; check the website before retrying.");
141
+ }
142
+ } catch {
143
+ return error("Submission stopped because the session or local file became unavailable. No automatic retry was made.");
144
+ } finally {
145
+ submitting = false;
146
+ }
147
+ }
package/user-bash.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
3
+
4
+ export const USER_BASH_START = "agent-golf-user-bash-start";
5
+
6
+ export function userBashStartMetadata(command: string) {
7
+ return { schema: 1, command_sha256: createHash("sha256").update(command).digest("hex") };
8
+ }
9
+
10
+ // user_bash fires before execution; the Bash result is appended only after it
11
+ // finishes (or after deferred results are flushed). Scan the whole session,
12
+ // not just the active branch, to match starts with their recorded results.
13
+ // Custom markers persist across reloads and never enter model context.
14
+ export function hasPendingUserBash(entries: readonly SessionEntry[]): boolean {
15
+ const pending = new Map<string, number>();
16
+ for (const entry of entries) {
17
+ if (entry.type === "custom" && entry.customType === USER_BASH_START) {
18
+ const data = entry.data as { schema?: unknown; command_sha256?: unknown } | null;
19
+ if (!data || data.schema !== 1 || typeof data.command_sha256 !== "string" || !/^[a-f0-9]{64}$/.test(data.command_sha256)) return true;
20
+ pending.set(data.command_sha256, (pending.get(data.command_sha256) ?? 0) + 1);
21
+ } else if (entry.type === "message" && entry.message.role === "bashExecution") {
22
+ const digest = userBashStartMetadata(entry.message.command).command_sha256;
23
+ const count = pending.get(digest) ?? 0;
24
+ if (count > 0) pending.set(digest, count - 1);
25
+ }
26
+ }
27
+ return [...pending.values()].some((count) => count > 0);
28
+ }